diff --git a/angular.json b/angular.json index d736425e5b0..ac823072f0d 100644 --- a/angular.json +++ b/angular.json @@ -307,8 +307,8 @@ { "type": "bundle", "name": "styles", - "maximumWarning": "500kb", - "maximumError": "550kb" + "maximumWarning": "600kb", + "maximumError": "600kb" }, { "type": "anyComponentStyle", diff --git a/package-lock.json b/package-lock.json index 5849e3ed253..d2a82f586bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,9 +23,9 @@ "@igniteui/material-icons-extended": "^3.1.0", "@lit-labs/ssr-dom-shim": "^1.3.0", "@types/source-map": "0.5.2", - "express": "^5.1.0", + "express": "^5.2.1", "fflate": "^0.8.1", - "igniteui-theming": "^21.0.2", + "igniteui-theming": "^23.2.0", "igniteui-trial-watermark": "^3.1.0", "lodash-es": "^4.17.21", "rxjs": "^7.8.2", @@ -9240,9 +9240,9 @@ } }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -10687,18 +10687,19 @@ "license": "Apache-2.0" }, "node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", "dependencies": { "accepts": "^2.0.0", - "body-parser": "^2.2.0", + "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", + "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", @@ -10758,35 +10759,63 @@ } }, "node_modules/express/node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.0", + "debug": "^4.4.3", "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/express/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/express/node_modules/media-typer": { @@ -10844,16 +10873,25 @@ } }, "node_modules/express/node_modules/raw-body": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.6.3", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/express/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -13403,9 +13441,9 @@ } }, "node_modules/igniteui-theming": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/igniteui-theming/-/igniteui-theming-21.0.2.tgz", - "integrity": "sha512-RXs8b3PThVlS1FhLeUT9TlLMcPoNAiwJm/L+jHU7jrwsgZU7gGjipjEbQQRe97AURyTxgXKiC4M8CAuUilWQ2A==", + "version": "23.2.0", + "resolved": "https://registry.npmjs.org/igniteui-theming/-/igniteui-theming-23.2.0.tgz", + "integrity": "sha512-JV3fclrxG72x0ilzqscv1YbhleA3dCzAIZPPPGSaSrnMfnexgursXivl5aJxXpn9683wuzvIVKWiW6oPMq1HHw==", "license": "MIT" }, "node_modules/igniteui-trial-watermark": { diff --git a/package.json b/package.json index 37449a322a4..0538a54c15a 100644 --- a/package.json +++ b/package.json @@ -73,9 +73,9 @@ "@igniteui/material-icons-extended": "^3.1.0", "@lit-labs/ssr-dom-shim": "^1.3.0", "@types/source-map": "0.5.2", - "express": "^5.1.0", + "express": "^5.2.1", "fflate": "^0.8.1", - "igniteui-theming": "^21.0.2", + "igniteui-theming": "^23.2.0", "igniteui-trial-watermark": "^3.1.0", "lodash-es": "^4.17.21", "rxjs": "^7.8.2", diff --git a/projects/bundle-test/src/app/h-grid/h-grid.component.html b/projects/bundle-test/src/app/h-grid/h-grid.component.html new file mode 100644 index 00000000000..ab93d5c978c --- /dev/null +++ b/projects/bundle-test/src/app/h-grid/h-grid.component.html @@ -0,0 +1,28 @@ +
+

Hierarchical Grid Example

+ + + + + + + + + + + + + + + + + + + + +
diff --git a/projects/bundle-test/src/app/h-grid/h-grid.component.scss b/projects/bundle-test/src/app/h-grid/h-grid.component.scss new file mode 100644 index 00000000000..bc539d70250 --- /dev/null +++ b/projects/bundle-test/src/app/h-grid/h-grid.component.scss @@ -0,0 +1,8 @@ +.h-grid-container { + padding: 20px; + + h2 { + margin-bottom: 20px; + color: #333; + } +} diff --git a/projects/bundle-test/src/app/h-grid/h-grid.component.ts b/projects/bundle-test/src/app/h-grid/h-grid.component.ts new file mode 100644 index 00000000000..ef3bfb90058 --- /dev/null +++ b/projects/bundle-test/src/app/h-grid/h-grid.component.ts @@ -0,0 +1,96 @@ +import { Component } from '@angular/core'; +import { IGX_HIERARCHICAL_GRID_DIRECTIVES } from 'igniteui-angular/grids/hierarchical-grid'; + +@Component({ + selector: 'app-h-grid', + templateUrl: './h-grid.component.html', + styleUrls: ['./h-grid.component.scss'], + imports: [IGX_HIERARCHICAL_GRID_DIRECTIVES] +}) +export class HGridComponent { + public data = [ + { + ID: 1, + CompanyName: 'Company A', + ContactName: 'John Doe', + ContactTitle: 'Sales Manager', + Address: '123 Main St', + City: 'New York', + Region: 'NY', + PostalCode: '10001', + Country: 'USA', + Phone: '555-1234', + Fax: '555-1235', + ChildCompanies: [ + { + ID: 11, + CompanyName: 'Subsidiary A1', + ContactName: 'Jane Smith', + ContactTitle: 'Manager', + Address: '456 Park Ave', + City: 'New York', + Region: 'NY', + PostalCode: '10002', + Country: 'USA', + Phone: '555-2345', + Fax: '555-2346' + }, + { + ID: 12, + CompanyName: 'Subsidiary A2', + ContactName: 'Bob Johnson', + ContactTitle: 'Director', + Address: '789 Broadway', + City: 'New York', + Region: 'NY', + PostalCode: '10003', + Country: 'USA', + Phone: '555-3456', + Fax: '555-3457' + } + ] + }, + { + ID: 2, + CompanyName: 'Company B', + ContactName: 'Alice Williams', + ContactTitle: 'CEO', + Address: '321 Oak St', + City: 'Los Angeles', + Region: 'CA', + PostalCode: '90001', + Country: 'USA', + Phone: '555-4567', + Fax: '555-4568', + ChildCompanies: [ + { + ID: 21, + CompanyName: 'Subsidiary B1', + ContactName: 'Charlie Brown', + ContactTitle: 'VP', + Address: '654 Sunset Blvd', + City: 'Los Angeles', + Region: 'CA', + PostalCode: '90002', + Country: 'USA', + Phone: '555-5678', + Fax: '555-5679' + } + ] + }, + { + ID: 3, + CompanyName: 'Company C', + ContactName: 'David Miller', + ContactTitle: 'President', + Address: '987 Elm St', + City: 'Chicago', + Region: 'IL', + PostalCode: '60601', + Country: 'USA', + Phone: '555-6789', + Fax: '555-6790', + ChildCompanies: [] + } + ]; +} diff --git a/projects/bundle-test/src/app/pivot-grid/pivot-grid.component.html b/projects/bundle-test/src/app/pivot-grid/pivot-grid.component.html new file mode 100644 index 00000000000..8f6590a78ab --- /dev/null +++ b/projects/bundle-test/src/app/pivot-grid/pivot-grid.component.html @@ -0,0 +1,11 @@ +
+

Pivot Grid Example

+ + + +
diff --git a/projects/bundle-test/src/app/pivot-grid/pivot-grid.component.scss b/projects/bundle-test/src/app/pivot-grid/pivot-grid.component.scss new file mode 100644 index 00000000000..d21b87988a5 --- /dev/null +++ b/projects/bundle-test/src/app/pivot-grid/pivot-grid.component.scss @@ -0,0 +1,8 @@ +.pivot-grid-container { + padding: 20px; + + h2 { + margin-bottom: 20px; + color: #333; + } +} diff --git a/projects/bundle-test/src/app/pivot-grid/pivot-grid.component.ts b/projects/bundle-test/src/app/pivot-grid/pivot-grid.component.ts new file mode 100644 index 00000000000..cf0624d6786 --- /dev/null +++ b/projects/bundle-test/src/app/pivot-grid/pivot-grid.component.ts @@ -0,0 +1,148 @@ +import { Component } from '@angular/core'; +import { IGX_PIVOT_GRID_DIRECTIVES } from 'igniteui-angular/grids/pivot-grid'; +import { IgxPivotDateDimension, IPivotConfiguration } from 'igniteui-angular/grids/core'; + +@Component({ + selector: 'app-pivot-grid', + templateUrl: './pivot-grid.component.html', + styleUrls: ['./pivot-grid.component.scss'], + imports: [IGX_PIVOT_GRID_DIRECTIVES] +}) +export class PivotGridComponent { + public data = [ + { + ProductCategory: 'Clothing', + ProductName: 'Shirt', + SellerName: 'John Doe', + SellerCity: 'New York', + Date: new Date('2023-01-15'), + UnitsSold: 20, + UnitPrice: 25.99, + Revenue: 519.8 + }, + { + ProductCategory: 'Clothing', + ProductName: 'Shirt', + SellerName: 'Jane Smith', + SellerCity: 'Los Angeles', + Date: new Date('2023-01-20'), + UnitsSold: 15, + UnitPrice: 25.99, + Revenue: 389.85 + }, + { + ProductCategory: 'Clothing', + ProductName: 'Pants', + SellerName: 'John Doe', + SellerCity: 'New York', + Date: new Date('2023-02-10'), + UnitsSold: 10, + UnitPrice: 45.50, + Revenue: 455 + }, + { + ProductCategory: 'Electronics', + ProductName: 'Phone', + SellerName: 'Bob Johnson', + SellerCity: 'Chicago', + Date: new Date('2023-02-15'), + UnitsSold: 8, + UnitPrice: 699.99, + Revenue: 5599.92 + }, + { + ProductCategory: 'Electronics', + ProductName: 'Laptop', + SellerName: 'Alice Williams', + SellerCity: 'New York', + Date: new Date('2023-03-01'), + UnitsSold: 5, + UnitPrice: 1299.99, + Revenue: 6499.95 + }, + { + ProductCategory: 'Electronics', + ProductName: 'Phone', + SellerName: 'Jane Smith', + SellerCity: 'Los Angeles', + Date: new Date('2023-03-10'), + UnitsSold: 12, + UnitPrice: 699.99, + Revenue: 8399.88 + }, + { + ProductCategory: 'Clothing', + ProductName: 'Jacket', + SellerName: 'Bob Johnson', + SellerCity: 'Chicago', + Date: new Date('2023-03-20'), + UnitsSold: 7, + UnitPrice: 89.99, + Revenue: 629.93 + }, + { + ProductCategory: 'Electronics', + ProductName: 'Tablet', + SellerName: 'John Doe', + SellerCity: 'New York', + Date: new Date('2023-04-05'), + UnitsSold: 6, + UnitPrice: 499.99, + Revenue: 2999.94 + } + ]; + + public pivotConfigHierarchy: IPivotConfiguration = { + rows: [ + { + memberName: 'ProductCategory', + enabled: true + }, + { + memberName: 'ProductName', + enabled: true + } + ], + columns: [ + new IgxPivotDateDimension( + { + memberName: 'Date', + enabled: true + }, + { + months: false, + quarters: true, + years: true + } + ), + { + memberName: 'SellerCity', + enabled: true + } + ], + values: [ + { + member: 'UnitsSold', + displayName: 'Units Sold', + aggregate: { + aggregator: (members: any[], data?: any[]) => members.reduce((acc, val) => acc + val, 0), + key: 'SUM', + label: 'Sum' + }, + enabled: true + }, + { + member: 'Revenue', + displayName: 'Revenue', + aggregate: { + aggregator: (members: any[], data?: any[]) => members.reduce((acc, val) => acc + val, 0), + key: 'SUM', + label: 'Sum' + }, + enabled: true, + dataType: 'currency' + } + ], + filters: null + }; +} diff --git a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts index 2e2f1a45c4c..0a07f136279 100644 --- a/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts +++ b/projects/igniteui-angular-elements/src/app/custom-strategy.spec.ts @@ -12,6 +12,8 @@ import { IgcPaginatorComponent, IgcGridStateComponent, IgcColumnLayoutComponent, + IgcActionStripComponent, + IgcGridEditingActionsComponent, } from './components'; import { defineComponents } from '../utils/register'; @@ -27,6 +29,8 @@ describe('Elements: ', () => { IgcColumnLayoutComponent, IgcPaginatorComponent, IgcGridStateComponent, + IgcActionStripComponent, + IgcGridEditingActionsComponent ); }); @@ -230,5 +234,66 @@ describe('Elements: ', () => { expect(grid.columns.length).toEqual(6); expect(grid.getColumnByVisibleIndex(1).field).toEqual('ProductName'); }); + + it('should not destroy action strip when row it is shown in is destroyed or cached.', async() => { + const innerHtml = ` + + + + + `; + testContainer.innerHTML = innerHtml; + + // TODO: Better way to wait - potentially expose the queue or observable for update on the strategy + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 3)); + + const grid = document.querySelector>('#testGrid'); + const actionStrip = document.querySelector>('#testStrip'); + grid.data = SampleTestData.foodProductData(); + + // TODO: Better way to wait - potentially expose the queue or observable for update on the strategy + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 3)); + + let row = grid.dataRowList.toArray()[0]; + actionStrip.show(row); + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 3)); + + expect(actionStrip.hidden).toBeFalse(); + + grid.data = []; + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 3)); + + // row destroyed + expect((row.cdr as any).destroyed).toBeTrue(); + // action strip still in DOM, only hidden. + expect(actionStrip.hidden).toBeTrue(); + expect(actionStrip.isConnected).toBeTrue(); + + grid.data = SampleTestData.foodProductData(); + grid.groupBy({ fieldName: 'InStock', dir: 1, ignoreCase: false }); + + // TODO: Better way to wait - potentially expose the queue or observable for update on the strategy + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 3)); + + row = grid.dataRowList.toArray()[0]; + actionStrip.show(row); + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 3)); + + expect(actionStrip.hidden).toBeFalse(); + + // collapse all data rows, leave only groups + grid.toggleAllGroupRows(); + + // TODO: Better way to wait - potentially expose the queue or observable for update on the strategy + await firstValueFrom(timer(10 /* SCHEDULE_DELAY */ * 3)); + + // row not destroyed, but also not in dom anymore + expect((row.cdr as any).destroyed).toBeFalse(); + expect(row.element.nativeElement.isConnected).toBe(false); + + // action strip still in DOM, only hidden. + expect(actionStrip.hidden).toBeTrue(); + expect(actionStrip.isConnected).toBeTrue(); + }); }); }); diff --git a/projects/igniteui-angular-elements/src/public_api.ts b/projects/igniteui-angular-elements/src/public_api.ts index c026ae7d4fc..6b32e413bab 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, DefaultTreeGridMergeStrategy } from 'projects/igniteui-angular/src/lib/data-operations/merge-strategy'; +import { ByLevelTreeGridMergeStrategy, DefaultMergeStrategy, DefaultTreeGridMergeStrategy } 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, + DefaultMergeStrategy as IgcDefaultMergeStrategy, DefaultTreeGridMergeStrategy as IgcDefaultTreeGridMergeStrategy, ByLevelTreeGridMergeStrategy as IgcByLevelTreeGridMergeStrategy, diff --git a/projects/igniteui-angular-i18n/src/i18n/CS/calendar-resources.ts b/projects/igniteui-angular-i18n/src/i18n/CS/calendar-resources.ts index 086c692c84b..ddca15c36f2 100644 --- a/projects/igniteui-angular-i18n/src/i18n/CS/calendar-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/CS/calendar-resources.ts @@ -7,18 +7,18 @@ import { ICalendarResourceStrings } from 'igniteui-angular'; export const CalendarResourceStringsCS = { igx_calendar_previous_month: 'Předchozí měsíc', igx_calendar_next_month: 'Příští měsíc', - igx_calendar_previous_year: 'Previous Year', - igx_calendar_next_year: 'Next Year', - igx_calendar_previous_years: 'Previous {0} Years', - igx_calendar_next_years: 'Next {0} Years', - igx_calendar_select_date: 'Select Date', + igx_calendar_previous_year: 'Předchozí rok', + igx_calendar_next_year: 'Příští rok', + igx_calendar_previous_years: 'Předchozích {0} let', + igx_calendar_next_years: 'Následujících {0} let', + igx_calendar_select_date: 'Vyberte datum', igx_calendar_select_month: 'Vyberte měsíc', igx_calendar_select_year: 'Vyberte rok', igx_calendar_range_start: 'Začátek dosahu', igx_calendar_range_end: 'Konec rozsahu', - igx_calendar_range_label_start: 'Start', - igx_calendar_range_label_end: 'End', - igx_calendar_range_placeholder: 'Select Range', + igx_calendar_range_label_start: 'Začátek', + igx_calendar_range_label_end: 'Konec', + igx_calendar_range_placeholder: 'Vyberte rozsah', igx_calendar_selected_month_is: 'Vybraný měsíc je ', igx_calendar_first_picker_of: 'První výběr z {0} začíná od', igx_calendar_multi_selection: 'Kalendář s více výběry s {0} nástroji pro výběr data', diff --git a/projects/igniteui-angular-i18n/src/i18n/DA/calendar-resources.ts b/projects/igniteui-angular-i18n/src/i18n/DA/calendar-resources.ts index 01eae9a8f7c..7a59f7a063d 100644 --- a/projects/igniteui-angular-i18n/src/i18n/DA/calendar-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/DA/calendar-resources.ts @@ -7,18 +7,18 @@ import { ICalendarResourceStrings } from 'igniteui-angular'; export const CalendarResourceStringsDA = { igx_calendar_previous_month: 'Forrige måned', igx_calendar_next_month: 'Næste måned', - igx_calendar_previous_year: 'Previous Year', - igx_calendar_next_year: 'Next Year', - igx_calendar_previous_years: 'Previous {0} Years', - igx_calendar_next_years: 'Next {0} Years', - igx_calendar_select_date: 'Select Date', + igx_calendar_previous_year: 'Forrige år', + igx_calendar_next_year: 'Næste år', + igx_calendar_previous_years: 'Forrige {0} år', + igx_calendar_next_years: 'Næste {0} år', + igx_calendar_select_date: 'Vælg dato', igx_calendar_select_month: 'Vælg måned', igx_calendar_select_year: 'Vælg år', igx_calendar_range_start: 'Interval start', igx_calendar_range_end: 'Interval slut', igx_calendar_range_label_start: 'Start', - igx_calendar_range_label_end: 'End', - igx_calendar_range_placeholder: 'Select Range', + igx_calendar_range_label_end: 'Slut', + igx_calendar_range_placeholder: 'Vælg interval', igx_calendar_selected_month_is: 'Den valgte måned er ', igx_calendar_first_picker_of: 'Første vælger af {0} starter fra', igx_calendar_multi_selection: 'Kalender med flere markeringer med {0} datovælgere', diff --git a/projects/igniteui-angular-i18n/src/i18n/DE/calendar-resources.ts b/projects/igniteui-angular-i18n/src/i18n/DE/calendar-resources.ts index 8fbbeee0d96..3a6e0131bc1 100644 --- a/projects/igniteui-angular-i18n/src/i18n/DE/calendar-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/DE/calendar-resources.ts @@ -7,18 +7,18 @@ import { ICalendarResourceStrings } from 'igniteui-angular'; export const CalendarResourceStringsDE = { igx_calendar_previous_month: 'Vorheriger Monat', igx_calendar_next_month: 'Nächster Monat', - igx_calendar_previous_year: 'Previous Year', - igx_calendar_next_year: 'Next Year', - igx_calendar_previous_years: 'Previous {0} Years', - igx_calendar_next_years: 'Next {0} Years', - igx_calendar_select_date: 'Select Date', + igx_calendar_previous_year: 'Vorheriges Jahr', + igx_calendar_next_year: 'Nächstes Jahr', + igx_calendar_previous_years: 'Vorige {0} Jahre', + igx_calendar_next_years: 'Nächste {0} Jahre', + igx_calendar_select_date: 'Datum auswählen', igx_calendar_select_month: 'Wähle Monat', igx_calendar_select_year: 'Wähle Jahr', igx_calendar_range_start: 'Datumsperiode Anfang', igx_calendar_range_end: 'Datumsperiode Ende', - igx_calendar_range_label_start: 'Start', - igx_calendar_range_label_end: 'End', - igx_calendar_range_placeholder: 'Select Range', + igx_calendar_range_label_start: 'Anfang', + igx_calendar_range_label_end: 'Ende', + igx_calendar_range_placeholder: 'Bereich auswählen', igx_calendar_selected_month_is: 'Der ausgewählter Monat ist ', igx_calendar_first_picker_of: 'Die erste Auswahl von {0} beginnt am', igx_calendar_multi_selection: 'Mehrfachauswahl-Kalender mit {0} Datumswählern', diff --git a/projects/igniteui-angular-i18n/src/i18n/ES/calendar-resources.ts b/projects/igniteui-angular-i18n/src/i18n/ES/calendar-resources.ts index 996cadfeaca..35f5b8d31e7 100644 --- a/projects/igniteui-angular-i18n/src/i18n/ES/calendar-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/ES/calendar-resources.ts @@ -7,18 +7,18 @@ import { ICalendarResourceStrings } from 'igniteui-angular'; export const CalendarResourceStringsES = { igx_calendar_previous_month: 'Mes anterior', igx_calendar_next_month: 'Mes siguiente', - igx_calendar_previous_year: 'Previous Year', - igx_calendar_next_year: 'Next Year', - igx_calendar_previous_years: 'Previous {0} Years', - igx_calendar_next_years: 'Next {0} Years', - igx_calendar_select_date: 'Select Date', + igx_calendar_previous_year: 'Año anterior', + igx_calendar_next_year: 'Año siguiente', + igx_calendar_previous_years: '{0} años anteriores', + igx_calendar_next_years: 'Próximos {0} años', + igx_calendar_select_date: 'Seleccionar fecha', igx_calendar_select_month: 'Seleccionar mes', igx_calendar_select_year: 'Seleccionar año', igx_calendar_range_start: 'Inicio de rango', igx_calendar_range_end: 'Fin de rango', - igx_calendar_range_label_start: 'Start', - igx_calendar_range_label_end: 'End', - igx_calendar_range_placeholder: 'Select Range', + igx_calendar_range_label_start: 'Inicio', + igx_calendar_range_label_end: 'Fin', + igx_calendar_range_placeholder: 'Seleccionar rango', igx_calendar_selected_month_is: 'El mes seleccionado es ', igx_calendar_first_picker_of: 'El primer selector de {0} comienza en', igx_calendar_multi_selection: 'Calendario de selección múltiple con {0} selectores de fechas', diff --git a/projects/igniteui-angular-i18n/src/i18n/FR/calendar-resources.ts b/projects/igniteui-angular-i18n/src/i18n/FR/calendar-resources.ts index 4ec12e7fada..131d320d947 100644 --- a/projects/igniteui-angular-i18n/src/i18n/FR/calendar-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/FR/calendar-resources.ts @@ -5,20 +5,20 @@ import { ICalendarResourceStrings } from 'igniteui-angular'; * French resource strings for IgxCalendar */ export const CalendarResourceStringsFR = { - igx_calendar_previous_month: 'Le mois dernier', - igx_calendar_next_month: 'Le mois prochain', - igx_calendar_previous_year: 'Previous Year', - igx_calendar_next_year: 'Next Year', - igx_calendar_previous_years: 'Previous {0} Years', - igx_calendar_next_years: 'Next {0} Years', - igx_calendar_select_date: 'Select Date', + igx_calendar_previous_month: 'Mois précédent', + igx_calendar_next_month: 'Mois suivant', + igx_calendar_previous_year: 'Année précédente', + igx_calendar_next_year: 'Mois suivant', + igx_calendar_previous_years: '{0} années précédentes', + igx_calendar_next_years: '{0} années suivantes', + igx_calendar_select_date: 'Sélectionner une date', igx_calendar_select_month: 'Sélectionner un mois', igx_calendar_select_year: 'Sélectionner une année', igx_calendar_range_start: 'Début de l\'intervalle', igx_calendar_range_end: 'Fin de l\'intervalle', - igx_calendar_range_label_start: 'Start', - igx_calendar_range_label_end: 'End', - igx_calendar_range_placeholder: 'Select Range', + igx_calendar_range_label_start: 'Début', + igx_calendar_range_label_end: 'Fin', + igx_calendar_range_placeholder: 'Sélectionner une intervalle', igx_calendar_selected_month_is: 'Le mois sélectionné est ', igx_calendar_first_picker_of: 'Le premier sélecteur de {0} commence à partir de', igx_calendar_multi_selection: 'Calendrier à sélection multiple avec {0} sélecteurs de dates', diff --git a/projects/igniteui-angular-i18n/src/i18n/HU/calendar-resources.ts b/projects/igniteui-angular-i18n/src/i18n/HU/calendar-resources.ts index f24567552ac..954199cff39 100644 --- a/projects/igniteui-angular-i18n/src/i18n/HU/calendar-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/HU/calendar-resources.ts @@ -7,18 +7,18 @@ import { ICalendarResourceStrings } from 'igniteui-angular'; export const CalendarResourceStringsHU = { igx_calendar_previous_month: 'Előző hónap', igx_calendar_next_month: 'Következő hónap', - igx_calendar_previous_year: 'Previous Year', - igx_calendar_next_year: 'Next Year', - igx_calendar_previous_years: 'Previous {0} Years', - igx_calendar_next_years: 'Next {0} Years', - igx_calendar_select_date: 'Select Date', + igx_calendar_previous_year: 'Előző év', + igx_calendar_next_year: 'Következő év', + igx_calendar_previous_years: 'Előző {0} év', + igx_calendar_next_years: 'Következő {0} év', + igx_calendar_select_date: 'Dátum kiválasztása', igx_calendar_select_month: 'Hónap kiválasztása', igx_calendar_select_year: 'Év kiválasztása', igx_calendar_range_start: 'Tartomány kezdete', igx_calendar_range_end: 'Tartomány vége', - igx_calendar_range_label_start: 'Start', - igx_calendar_range_label_end: 'End', - igx_calendar_range_placeholder: 'Select Range', + igx_calendar_range_label_start: 'Kezdés', + igx_calendar_range_label_end: 'Vége', + igx_calendar_range_placeholder: 'Tartomány kiválasztása', igx_calendar_selected_month_is: 'A kiválasztott hónap ', igx_calendar_first_picker_of: 'A(z) {0} első választója innen indul:', igx_calendar_multi_selection: 'Többszörös időpontválasztó naptár {0} dátumválasztóval', diff --git a/projects/igniteui-angular-i18n/src/i18n/IT/calendar-resources.ts b/projects/igniteui-angular-i18n/src/i18n/IT/calendar-resources.ts index 0a58c109b0d..84c1a842b6c 100644 --- a/projects/igniteui-angular-i18n/src/i18n/IT/calendar-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/IT/calendar-resources.ts @@ -9,16 +9,16 @@ export const CalendarResourceStringsIT = { igx_calendar_next_month: 'Mese prossimo', igx_calendar_previous_year: 'L\'anno precedente', igx_calendar_next_year: 'L\'anno prossimo', - igx_calendar_previous_years: '{0} anni precedenti', - igx_calendar_next_years: 'Prossimi {0} anni', - igx_calendar_select_date: 'Select Date', + igx_calendar_previous_years: 'Precedenti {0} anni', + igx_calendar_next_years: 'Precedenti {0} anni', + igx_calendar_select_date: 'Selezionare la data', igx_calendar_select_month: 'Selezionare il mese', igx_calendar_select_year: 'Selezionare l\'anno', igx_calendar_range_start: 'Inizio intervallo', igx_calendar_range_end: 'Fine intervallo', - igx_calendar_range_label_start: 'Start', - igx_calendar_range_label_end: 'End', - igx_calendar_range_placeholder: 'Select Range', + igx_calendar_range_label_start: 'Inizio', + igx_calendar_range_label_end: 'Fine', + igx_calendar_range_placeholder: 'Selezionare l\'intervallo', igx_calendar_selected_month_is: 'Mese selezionato: ', igx_calendar_first_picker_of: 'Il primo selettore di {0} inizia da', igx_calendar_multi_selection: 'Calendario a selezione multipla con {0} selettori di data', diff --git a/projects/igniteui-angular-i18n/src/i18n/JA/calendar-resources.ts b/projects/igniteui-angular-i18n/src/i18n/JA/calendar-resources.ts index fa19b12d63b..f898d5ff3a4 100644 --- a/projects/igniteui-angular-i18n/src/i18n/JA/calendar-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/JA/calendar-resources.ts @@ -7,18 +7,18 @@ import { ICalendarResourceStrings } from 'igniteui-angular'; export const CalendarResourceStringsJA = { igx_calendar_previous_month: '前月', igx_calendar_next_month: '翌月', - igx_calendar_previous_year: 'Previous Year', - igx_calendar_next_year: 'Next Year', - igx_calendar_previous_years: 'Previous {0} Years', - igx_calendar_next_years: 'Next {0} Years', - igx_calendar_select_date: 'Select Date', + igx_calendar_previous_year: '前年', + igx_calendar_next_year: '翌年', + igx_calendar_previous_years: '過去 {0} 年', + igx_calendar_next_years: '今後 {0} 年', + igx_calendar_select_date: '日付の選択', igx_calendar_select_month: '月の選択', igx_calendar_select_year: '年の選択', igx_calendar_range_start: '範囲開始', igx_calendar_range_end: '範囲終了', - igx_calendar_range_label_start: 'Start', - igx_calendar_range_label_end: 'End', - igx_calendar_range_placeholder: 'Select Range', + igx_calendar_range_label_start: '開始', + igx_calendar_range_label_end: '終了', + igx_calendar_range_placeholder: '範囲の選択', igx_calendar_selected_month_is: '選択した月: ', igx_calendar_first_picker_of: '{0} の最初のピッカーの開始: ', igx_calendar_multi_selection: '{0} 日付ピッカーの複数選択カレンダー', diff --git a/projects/igniteui-angular-i18n/src/i18n/KO/calendar-resources.ts b/projects/igniteui-angular-i18n/src/i18n/KO/calendar-resources.ts index 9da5aaea61c..62318c0fe26 100644 --- a/projects/igniteui-angular-i18n/src/i18n/KO/calendar-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/KO/calendar-resources.ts @@ -7,18 +7,18 @@ import { ICalendarResourceStrings } from 'igniteui-angular'; export const CalendarResourceStringsKO = { igx_calendar_previous_month: '이전 달', igx_calendar_next_month: '다음 달', - igx_calendar_previous_year: 'Previous Year', - igx_calendar_next_year: 'Next Year', - igx_calendar_previous_years: 'Previous {0} Years', - igx_calendar_next_years: 'Next {0} Years', - igx_calendar_select_date: 'Select Date', + igx_calendar_previous_year: '이전 해', + igx_calendar_next_year: '다음 해', + igx_calendar_previous_years: '이전 {0} 년', + igx_calendar_next_years: '다음 {0} 년', + igx_calendar_select_date: '날짜 선택', igx_calendar_select_month: '월 선택', igx_calendar_select_year: '연도 선택', igx_calendar_range_start: '범위 시작', igx_calendar_range_end: '범위 끝', - igx_calendar_range_label_start: 'Start', - igx_calendar_range_label_end: 'End', - igx_calendar_range_placeholder: 'Select Range', + igx_calendar_range_label_start: '시작', + igx_calendar_range_label_end: '끝', + igx_calendar_range_placeholder: '범위 선택', igx_calendar_selected_month_is: '선택한 달은 ', igx_calendar_first_picker_of: '{0} 의 첫 번째 선택기는 다음에서 시작', igx_calendar_multi_selection: '{0} 날짜 선택기가있는 다중 선택 달력', diff --git a/projects/igniteui-angular-i18n/src/i18n/NB/calendar-resources.ts b/projects/igniteui-angular-i18n/src/i18n/NB/calendar-resources.ts index 0c0e21ed23d..fbd1cba9226 100644 --- a/projects/igniteui-angular-i18n/src/i18n/NB/calendar-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/NB/calendar-resources.ts @@ -7,18 +7,18 @@ import { ICalendarResourceStrings } from 'igniteui-angular'; export const CalendarResourceStringsNB = { igx_calendar_previous_month: 'Forrige måned', igx_calendar_next_month: 'Neste måned', - igx_calendar_previous_year: 'Previous Year', - igx_calendar_next_year: 'Next Year', - igx_calendar_previous_years: 'Previous {0} Years', - igx_calendar_next_years: 'Next {0} Years', - igx_calendar_select_date: 'Select Date', + igx_calendar_previous_year: 'Forrige år', + igx_calendar_next_year: 'Neste år', + igx_calendar_previous_years: 'Forrige {0} år', + igx_calendar_next_years: 'Neste {0} år', + igx_calendar_select_date: 'Velg dato', igx_calendar_select_month: 'Velg måned', igx_calendar_select_year: 'Velg år', igx_calendar_range_start: 'Rekkevidde start', igx_calendar_range_end: 'Rekkevidde slutt', igx_calendar_range_label_start: 'Start', - igx_calendar_range_label_end: 'End', - igx_calendar_range_placeholder: 'Select Range', + igx_calendar_range_label_end: 'Slutt', + igx_calendar_range_placeholder: 'Velg rekkevidde', igx_calendar_selected_month_is: 'Valgt måned er ', igx_calendar_first_picker_of: 'Den første plukkeren på {0} starter fra', igx_calendar_multi_selection: 'Flervalgskalender med {0} datovelgere', diff --git a/projects/igniteui-angular-i18n/src/i18n/NL/calendar-resources.ts b/projects/igniteui-angular-i18n/src/i18n/NL/calendar-resources.ts index b934e7a47c5..ed7b7a002ca 100644 --- a/projects/igniteui-angular-i18n/src/i18n/NL/calendar-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/NL/calendar-resources.ts @@ -7,18 +7,18 @@ import { ICalendarResourceStrings } from 'igniteui-angular'; export const CalendarResourceStringsNL = { igx_calendar_previous_month: 'Vorige maand', igx_calendar_next_month: 'Volgende maand', - igx_calendar_previous_year: 'Previous Year', - igx_calendar_next_year: 'Next Year', - igx_calendar_previous_years: 'Previous {0} Years', - igx_calendar_next_years: 'Next {0} Years', - igx_calendar_select_date: 'Select Date', + igx_calendar_previous_year: 'Vorig jaar', + igx_calendar_next_year: 'Volgend jaar', + igx_calendar_previous_years: 'Vorige {0} jaar', + igx_calendar_next_years: 'Volgende {0} jaar', + igx_calendar_select_date: 'Selecteer datum', igx_calendar_select_month: 'Selecteer maand', igx_calendar_select_year: 'Selecteer jaar', igx_calendar_range_start: 'Begin van bereik', igx_calendar_range_end: 'Einde van bereik', - igx_calendar_range_label_start: 'Start', - igx_calendar_range_label_end: 'End', - igx_calendar_range_placeholder: 'Select Range', + igx_calendar_range_label_start: 'Begin', + igx_calendar_range_label_end: 'Einde', + igx_calendar_range_placeholder: 'Selecteer bereik', igx_calendar_selected_month_is: 'Geselecteerde maand is ', igx_calendar_first_picker_of: 'De eerste kiezer van {0} begint vanaf', igx_calendar_multi_selection: 'Multi-selectiekalender met {0} datumkiezers', diff --git a/projects/igniteui-angular-i18n/src/i18n/PL/calendar-resources.ts b/projects/igniteui-angular-i18n/src/i18n/PL/calendar-resources.ts index f0eff5d2c42..16ff3339dd9 100644 --- a/projects/igniteui-angular-i18n/src/i18n/PL/calendar-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/PL/calendar-resources.ts @@ -7,18 +7,18 @@ import { ICalendarResourceStrings } from 'igniteui-angular'; export const CalendarResourceStringsPL = { igx_calendar_previous_month: 'Poprzedni miesiąc', igx_calendar_next_month: 'W przyszłym miesiącu', - igx_calendar_previous_year: 'Previous Year', - igx_calendar_next_year: 'Next Year', - igx_calendar_previous_years: 'Previous {0} Years', - igx_calendar_next_years: 'Next {0} Years', + igx_calendar_previous_year: 'Poprzedni rok', + igx_calendar_next_year: 'Następny rok', + igx_calendar_previous_years: 'Poprzednie {0} lata', + igx_calendar_next_years: 'Następne {0} lata', igx_calendar_select_month: 'Wybierz miesiąc', - igx_calendar_select_date: 'Select Date', + igx_calendar_select_date: 'Wybierz datę', igx_calendar_select_year: 'Wybierz rok', igx_calendar_range_start: 'Początek zakresu', igx_calendar_range_end: 'Koniec zakresu', - igx_calendar_range_label_start: 'Start', - igx_calendar_range_label_end: 'End', - igx_calendar_range_placeholder: 'Select Range', + igx_calendar_range_label_start: 'Początek', + igx_calendar_range_label_end: 'Koniec', + igx_calendar_range_placeholder: 'Wybierz zakres', igx_calendar_selected_month_is: 'Wybrany miesiąc to ', igx_calendar_first_picker_of: 'Pierwszy wybór {0} zaczyna się od', igx_calendar_multi_selection: 'Kalendarz wielokrotnego wyboru z {0} selektorami dat', diff --git a/projects/igniteui-angular-i18n/src/i18n/PT/calendar-resources.ts b/projects/igniteui-angular-i18n/src/i18n/PT/calendar-resources.ts index 9b761cf0113..12a00721a3c 100644 --- a/projects/igniteui-angular-i18n/src/i18n/PT/calendar-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/PT/calendar-resources.ts @@ -7,18 +7,18 @@ import { ICalendarResourceStrings } from 'igniteui-angular'; export const CalendarResourceStringsPT = { igx_calendar_previous_month: 'Mês anterior', igx_calendar_next_month: 'Mês seguinte', - igx_calendar_previous_year: 'Previous Year', - igx_calendar_next_year: 'Next Year', - igx_calendar_previous_years: 'Previous {0} Years', - igx_calendar_next_years: 'Next {0} Years', + igx_calendar_previous_year: 'Ano anterior', + igx_calendar_next_year: 'Ano seguinte', + igx_calendar_previous_years: '{0} anos anteriores', + igx_calendar_next_years: 'Próximos {0} anos', igx_calendar_select_month: 'Selecionar mês', - igx_calendar_select_date: 'Select Date', + igx_calendar_select_date: 'Selecionar data', igx_calendar_select_year: 'Selecionar ano', igx_calendar_range_start: 'Início do intervalo', igx_calendar_range_end: 'Fim do intervalo', - igx_calendar_range_label_start: 'Start', - igx_calendar_range_label_end: 'End', - igx_calendar_range_placeholder: 'Select Range', + igx_calendar_range_label_start: 'Início', + igx_calendar_range_label_end: 'Fim', + igx_calendar_range_placeholder: 'Selecionar intervalo', igx_calendar_selected_month_is: 'O mês selecionado é ', igx_calendar_first_picker_of: 'O primeiro selecionador de {0} começa em', igx_calendar_multi_selection: 'Calendário de seleção múltipla com {0} selecionadores de data', diff --git a/projects/igniteui-angular-i18n/src/i18n/RO/calendar-resources.ts b/projects/igniteui-angular-i18n/src/i18n/RO/calendar-resources.ts index 95f598a18ba..fff0a419c71 100644 --- a/projects/igniteui-angular-i18n/src/i18n/RO/calendar-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/RO/calendar-resources.ts @@ -7,18 +7,18 @@ import { ICalendarResourceStrings } from 'igniteui-angular'; export const CalendarResourceStringsRO = { igx_calendar_previous_month: 'Luna trecută', igx_calendar_next_month: 'Luna viitoare', - igx_calendar_previous_year: 'Previous Year', - igx_calendar_next_year: 'Next Year', - igx_calendar_previous_years: 'Previous {0} Years', - igx_calendar_next_years: 'Next {0} Years', - igx_calendar_select_date: 'Select Date', + igx_calendar_previous_year: 'Anul precedent', + igx_calendar_next_year: 'Anul următor', + igx_calendar_previous_years: '{0} ani precedenți', + igx_calendar_next_years: 'Următorii {0} ani', + igx_calendar_select_date: 'Selectați data', igx_calendar_select_month: 'Alege luna', igx_calendar_select_year: 'Selectați Anul', igx_calendar_range_start: 'Începutul intervalului', igx_calendar_range_end: 'Sfârșitul intervalului', - igx_calendar_range_label_start: 'Start', - igx_calendar_range_label_end: 'End', - igx_calendar_range_placeholder: 'Select Range', + igx_calendar_range_label_start: 'Sfârșit', + igx_calendar_range_label_end: 'Sfârșit', + igx_calendar_range_placeholder: 'Selectați intervalul', igx_calendar_selected_month_is: 'Luna selectată este ', igx_calendar_first_picker_of: 'Primul selector din {0} începe de la', igx_calendar_multi_selection: 'Calendar cu selecție multiplă cu {0} selectoare de date', diff --git a/projects/igniteui-angular-i18n/src/i18n/SV/calendar-resources.ts b/projects/igniteui-angular-i18n/src/i18n/SV/calendar-resources.ts index d64c3d82fc0..7a465986558 100644 --- a/projects/igniteui-angular-i18n/src/i18n/SV/calendar-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/SV/calendar-resources.ts @@ -7,18 +7,18 @@ import { ICalendarResourceStrings } from 'igniteui-angular'; export const CalendarResourceStringsSV = { igx_calendar_previous_month: 'Förra månaden', igx_calendar_next_month: 'Nästa månad', - igx_calendar_previous_year: 'Previous Year', - igx_calendar_next_year: 'Next Year', - igx_calendar_previous_years: 'Previous {0} Years', - igx_calendar_next_years: 'Next {0} Years', - igx_calendar_select_date: 'Select Date', + igx_calendar_previous_year: 'Föregående år', + igx_calendar_next_year: 'Nästa år', + igx_calendar_previous_years: 'Föregående {0} år', + igx_calendar_next_years: 'Nästa {0} år', + igx_calendar_select_date: 'Välj datum', igx_calendar_select_month: 'Välj månad', igx_calendar_select_year: 'Välj år', igx_calendar_range_start: 'Områdesstart', igx_calendar_range_end: 'Områdesslut', igx_calendar_range_label_start: 'Start', - igx_calendar_range_label_end: 'End', - igx_calendar_range_placeholder: 'Select Range', + igx_calendar_range_label_end: 'Slut', + igx_calendar_range_placeholder: 'Välj intervall', igx_calendar_selected_month_is: 'Vald månad är ', igx_calendar_first_picker_of: 'Första väljaren av {0} börjar från', igx_calendar_multi_selection: 'Flervalskalender med {0} datumväljare', diff --git a/projects/igniteui-angular-i18n/src/i18n/TR/calendar-resources.ts b/projects/igniteui-angular-i18n/src/i18n/TR/calendar-resources.ts index 9176642954b..98a293d77f9 100644 --- a/projects/igniteui-angular-i18n/src/i18n/TR/calendar-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/TR/calendar-resources.ts @@ -7,18 +7,18 @@ import { ICalendarResourceStrings } from 'igniteui-angular'; export const CalendarResourceStringsTR = { igx_calendar_previous_month: 'Geçtiğimiz ay', igx_calendar_next_month: 'Gelecek ay', - igx_calendar_previous_year: 'Previous Year', - igx_calendar_next_year: 'Next Year', - igx_calendar_previous_years: 'Previous {0} Years', - igx_calendar_next_years: 'Next {0} Years', - igx_calendar_select_date: 'Select Date', + igx_calendar_previous_year: 'Önceki yıl', + igx_calendar_next_year: 'Gelecek yıl', + igx_calendar_previous_years: 'Önceki {0} yıl', + igx_calendar_next_years: 'Gelecek {0} yıl', + igx_calendar_select_date: 'Tarih seç', igx_calendar_select_month: 'Ay seç', igx_calendar_select_year: 'Yıl Seç', igx_calendar_range_start: 'Aralık başlangıcı', igx_calendar_range_end: 'Aralık bitişi', - igx_calendar_range_label_start: 'Start', - igx_calendar_range_label_end: 'End', - igx_calendar_range_placeholder: 'Select Range', + igx_calendar_range_label_start: 'Başlangıç', + igx_calendar_range_label_end: 'Bitiş', + igx_calendar_range_placeholder: 'Aralık seç', igx_calendar_selected_month_is: 'Seçilen ay ', igx_calendar_first_picker_of: '{0} için ilk seçici başlangıcı', igx_calendar_multi_selection: '{0} tarih seçicili çoklu seçim takvimi', diff --git a/projects/igniteui-angular-i18n/src/i18n/ZH-HANS/calendar-resources.ts b/projects/igniteui-angular-i18n/src/i18n/ZH-HANS/calendar-resources.ts index 9453317a2a9..22a49cff943 100644 --- a/projects/igniteui-angular-i18n/src/i18n/ZH-HANS/calendar-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/ZH-HANS/calendar-resources.ts @@ -7,18 +7,18 @@ import { ICalendarResourceStrings } from 'igniteui-angular'; export const CalendarResourceStringsZHHANS = { igx_calendar_previous_month: '上个月', igx_calendar_next_month: '下个月', - igx_calendar_previous_year: 'Previous Year', - igx_calendar_next_year: 'Next Year', - igx_calendar_previous_years: 'Previous {0} Years', - igx_calendar_next_years: 'Next {0} Years', - igx_calendar_select_date: 'Select Date', + igx_calendar_previous_year: '去年', + igx_calendar_next_year: '明年', + igx_calendar_previous_years: '前 {0} 年', + igx_calendar_next_years: '后 {0} 年', + igx_calendar_select_date: '选择日期', igx_calendar_select_month: '选择月', igx_calendar_select_year: '选择年', igx_calendar_range_start: '范围开始', igx_calendar_range_end: '范围结束', - igx_calendar_range_label_start: 'Start', - igx_calendar_range_label_end: 'End', - igx_calendar_range_placeholder: 'Select Range', + igx_calendar_range_label_start: '开始', + igx_calendar_range_label_end: '结束', + igx_calendar_range_placeholder: '选择范围', igx_calendar_selected_month_is: '所选月份: ', igx_calendar_first_picker_of: '{0} 的第一个选择器从开始', igx_calendar_multi_selection: '带有 {0} 日期选择器的多选日历', diff --git a/projects/igniteui-angular-i18n/src/i18n/ZH-HANT/calendar-resources.ts b/projects/igniteui-angular-i18n/src/i18n/ZH-HANT/calendar-resources.ts index 61d23d6f71c..aa3c1d0f844 100644 --- a/projects/igniteui-angular-i18n/src/i18n/ZH-HANT/calendar-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/ZH-HANT/calendar-resources.ts @@ -7,19 +7,19 @@ import { ICalendarResourceStrings } from 'igniteui-angular'; export const CalendarResourceStringsZHHANT = { igx_calendar_previous_month: '上個月', igx_calendar_next_month: '下個月', - igx_calendar_previous_year: 'Previous Year', - igx_calendar_next_year: 'Next Year', - igx_calendar_previous_years: 'Previous {0} Years', - igx_calendar_next_years: 'Next {0} Years', - igx_calendar_select_date: 'Select Date', - igx_calendar_select_month: '選取月', - igx_calendar_select_year: '選取年', + igx_calendar_previous_year: '去年', + igx_calendar_next_year: '明年', + igx_calendar_previous_years: '前 {0} 年', + igx_calendar_next_years: '後 {0} 年', + igx_calendar_select_date: '選擇日期', + igx_calendar_select_month: '選擇月', + igx_calendar_select_year: '選擇年', igx_calendar_range_start: '範圍開始', igx_calendar_range_end: '範圍結束', - igx_calendar_range_label_start: 'Start', - igx_calendar_range_label_end: 'End', - igx_calendar_range_placeholder: 'Select Range', - igx_calendar_selected_month_is: '選取的月份: ', + igx_calendar_range_label_start: '開始', + igx_calendar_range_label_end: '結束', + igx_calendar_range_placeholder: '選擇範圍', + igx_calendar_selected_month_is: '選擇的月份: ', igx_calendar_first_picker_of: '{0} 的第一個選擇器從開始', igx_calendar_multi_selection: '帶有 {0} 日期選擇器的多重選擇日曆', igx_calendar_range_selection: '帶有 {0} 日期選擇器的範圍選擇日曆', diff --git a/projects/igniteui-angular-performance/README.md b/projects/igniteui-angular-performance/README.md new file mode 100644 index 00000000000..75a2fa6d745 --- /dev/null +++ b/projects/igniteui-angular-performance/README.md @@ -0,0 +1,111 @@ +# Ignite UI performance project + +This project contains sample applications for the grid, tree grid, and pivot grid, with predefined data source sizes of 1,000, 100,000, and 1,000,000 records. These samples are used to measure the performance of different grid features using an internal `PerformanceService`, which is a wrapper around the browser's Performance API. + +To run the application: + +```sh +npm run start:performance +``` + +## Using the performance service + +The performance service is intended to be used as an injectable service inside a component that would be measured. In order to inject it add a private property in the component: + +```ts +private performanceService = inject(PerformanceService); +``` + +This will initialize the service and also create a global `$$igcPerformance` variable that can be used for convenience. + +### API + +```ts +performanceService.setLogEnabled(true); +``` + +- Set whether the service should use `console.debug` to print the performance entries. Defaults to `false`. + +```ts +const end = performanceService.start(name); +data.sort(); +end(); +``` + +- Starts a performance measuring. The method returns a function that should be invoked after the code you want to measure has finished executing. This creates a `PerformanceMeasure` entry in the browser's performance timeline. + +```ts +const performanceMeasures: PerformanceEntryList = performanceService.getMeasures(name?); +``` + +- Gets list of `PerformanceMeasure` entries. If a name is provided, it returns only the measures with that name. + +```ts +performanceService.clearMeasures(name?); +``` + +- Clears all performed measures. If a `name` is provided, it clears only the measures with that name. + +> **Note:** The `$$igcPerformance` global object could be used as an alternative to the injected `performanceService` instance. + +### Reading results + +Let's say that we want to measure the time that the grid's sorting pipeline takes to sort the data. We first go to the `IgxGridSortingPipe` and modify the code to look like this: + +```ts +@Pipe({ + name: 'gridSort', + standalone: true, +}) +export class IgxGridSortingPipe implements PipeTransform { + private performance = inject(PerformanceService); + + constructor(@Inject(IGX_GRID_BASE) private grid: GridType) {} + + public transform( + collection: any[], + sortExpressions: ISortingExpression[], + groupExpressions: IGroupingExpression[], + sorting: IGridSortingStrategy, + id: string, + pipeTrigger: number, + pinned? + ): any[] { + // This is important! + this.performance.setLogEnabled(true); + const endSorting = this.performance.start('Sorting pipe'); + + let result: any[]; + const expressions = groupExpressions.concat(sortExpressions); + if (!expressions.length) { + result = collection; + } else { + result = DataUtil.sort( + cloneArray(collection), + expressions, + sorting, + this.grid + ); + } + this.grid.setFilteredSortedData(result, pinned); + + // This is important! + endSorting(); + + return result; + } +} +``` + +If you run a sample and sort a column, the performance service will measure the execution time of the sorting pipe. There are two ways to see the results: + +1. If `setLogEnabled` is called with `true` then the service will print the measurement result in the console using `console.debug`. +performance-console + +2. The browser's DevTools Performance tab can be used for a more detailed analysis. Before triggering the action (e.g., sorting), start a new performance recording in DevTools. + + +https://github.com/user-attachments/assets/8aa80ead-82d0-48a4-a6d2-1c17b3d099b1 + + +You should look at the timings tab in the performance window in dev-tools. diff --git a/projects/igniteui-angular/accordion/README.md b/projects/igniteui-angular/accordion/README.md new file mode 100644 index 00000000000..dd43aa5294d --- /dev/null +++ b/projects/igniteui-angular/accordion/README.md @@ -0,0 +1,68 @@ + +# IgxAccordion + + +**IgxAccordion** is a container-based component that contains a collection of collapsible **IgxExpansionPanels**. + +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/accordion) + +# Usage + +```html + + + ... + + +``` + +# API Summary +The following tables summarize the **igx-accordion** accessors, inputs, outputs and methods. + +### Accessors +The following accessors are available in the **igx-accordion** component: +| Name | Type | Description | +| :--- | :--- | :--- | +| `panels` | `IgxExpansionPanelComponent[]` | All IgxExpansionPanel children of the accordion | + +### Inputs +The following inputs are available in the **igx-accordion** component: + +| Name | Type | Description | +| :--- | :--- | :--- | +| `id` | `string` | The id of the accordion. | +| `animationSettings` | `AnimationSettings` | Animation settings that override all single animations passed to underlying panels | +| `singleBranchExpand` | `boolean` | How the accordion handles panel expansion. | + +### Outputs +The following outputs are available in the **igx-accordion** component: + +| Name | Cancelable | Description | Parameters +| :--- | :--- | :--- | :--- | +| `panelExpanded` | `false` | Emitted when the panel is collapsed | `IAccordionEventArgs` | +| `panelCollapsing` | `true` | Emitted when the panel begins collapsing | `IAccordionCancelableEventArgs` | +| `panelCollapsed` | `false` | Emitted when the panel is expanded | `IAccordionEventArgs` | +| `panelExpanding` | `true` | Emitted when the panel begins expanding | `IAccordionCancelableEventArgs` | + + +### Methods +The following methods are available in the **igx-accordion** component: + +| Name | Signature | Description | +| :--- | :--- | :--- | +| `collapseAll` | `(event?: Event ): void` | Collapse all expanded expansion panels | +| `expandAll` | `(event?: Event ): void` | Expands all collapsed expansion panels when singleBranchExpand === false | + +## Keyboard Navigation +|Keys |Description| +|---------------|-----------| +| Tab | Moves the focus to the first(if the focus is before accordeon)/next panel. | +| Shift + Tab | Moves the focus to the last(if the focus is after accordeon)/previous panel. | +| Arrow Down | Move the focus to the panel below. | +| Arrow Up | Move the focus to the panel above. | +| Alt + Arrow Down | Expand the focused panel in the accordion. | +| Alt + Arrow Up | Collapse the focused panel in the accordion. | +| Shift + Alt + Arrow Down | Expand all panels when this is enabled. | +| Shift + Alt + Arrow Up | Collapse all panels whichever panel is focused. | +| Home | Navigates to the first panel in the accordion. | +| End | Navigates to the last panel in the accordion. | diff --git a/projects/igniteui-angular/accordion/index.ts b/projects/igniteui-angular/accordion/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/accordion/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/accordion/ng-package.json b/projects/igniteui-angular/accordion/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/accordion/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/accordion/src/accordion/accordion.component.html b/projects/igniteui-angular/accordion/src/accordion/accordion.component.html new file mode 100644 index 00000000000..49b00a207c1 --- /dev/null +++ b/projects/igniteui-angular/accordion/src/accordion/accordion.component.html @@ -0,0 +1 @@ + diff --git a/projects/igniteui-angular/accordion/src/accordion/accordion.component.spec.ts b/projects/igniteui-angular/accordion/src/accordion/accordion.component.spec.ts new file mode 100644 index 00000000000..f75116923a9 --- /dev/null +++ b/projects/igniteui-angular/accordion/src/accordion/accordion.component.spec.ts @@ -0,0 +1,411 @@ +import { useAnimation } from '@angular/animations'; +import { Component, ViewChild } from '@angular/core'; +import { waitForAsync, TestBed, fakeAsync, ComponentFixture, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxExpansionPanelBodyComponent, IgxExpansionPanelComponent, IgxExpansionPanelHeaderComponent, IgxExpansionPanelTitleDirective } from '../../../expansion-panel/src/public_api'; +import { IAccordionCancelableEventArgs, IAccordionEventArgs, IgxAccordionComponent } from './accordion.component'; +import { slideInLeft, slideOutRight } from 'igniteui-angular/animations'; +import { UIInteractions } from 'igniteui-angular/test-utils/ui-interactions.spec'; + +const ACCORDION_CLASS = 'igx-accordion'; +const PANEL_TAG = 'IGX-EXPANSION-PANEL'; +const ACCORDION_TAG = 'IGX-ACCORDION'; + +describe('Rendering Tests', () => { + let fix: ComponentFixture; + let accordion: IgxAccordionComponent; + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxAccordionSampleTestComponent + ] + }).compileComponents(); + }) + ); + beforeEach(() => { + fix = TestBed.createComponent(IgxAccordionSampleTestComponent); + fix.detectChanges(); + accordion = fix.componentInstance.accordion; + }); + + describe('General', () => { + it('Should render accordion with expansion panels', () => { + const accordionElement: HTMLElement = fix.debugElement.queryAll(By.css(`.${ACCORDION_CLASS}`))[0].nativeElement; + const childPanels = accordionElement.children; + expect(childPanels.length).toBe(4); + expect(accordion.panels.length).toEqual(4); + for (let i = 0; i < childPanels.length; i++) { + expect(childPanels.item(i).tagName === PANEL_TAG).toBeTruthy(); + } + }); + + it('Should allow overriding animationSettings that are used for expansion panels toggle', () => { + const animationSettingsCustom = { + closeAnimation: useAnimation(slideOutRight, { params: { duration: '100ms', toPosition: 'translateX(25px)' } }), + openAnimation: useAnimation(slideInLeft, { params: { duration: '500ms', fromPosition: 'translateX(-15px)' } }) + }; + + const animationSettingsCustomPanel = { + closeAnimation: useAnimation(slideOutRight, { params: { duration: '200ms', toPosition: 'translateX(25px)' } }), + openAnimation: useAnimation(slideInLeft, { params: { duration: '500ms', fromPosition: 'translateX(-15px)' } }) + }; + + accordion.panels[0].animationSettings = animationSettingsCustomPanel; + + accordion.animationSettings = animationSettingsCustom; + + for (let i = 0; i < 3; i++) { + expect(accordion.panels[i].animationSettings.closeAnimation.options.params.duration).toEqual('100ms'); + } + }); + + it('Should be able to render nested accordions', () => { + const panelBody = accordion.panels[0].body?.element.nativeElement; + expect(panelBody.children[0].tagName === ACCORDION_TAG).toBeTruthy(); + }); + + it(`Should be able to expand only one panel when singleBranchExpanded is set to true + and expandAll/collapseAll should not update the current expansion state `, fakeAsync(() => { + spyOn(accordion.panelExpanded, 'emit').and.callThrough(); + spyOn(accordion.panelCollapsed, 'emit').and.callThrough(); + accordion.singleBranchExpand = true; + fix.detectChanges(); + + accordion.expandAll(); + tick(); + fix.detectChanges(); + + expect(accordion.panels.filter(panel => !panel.collapsed).length).toEqual(1); + expect(accordion.panels[3].collapsed).toBeFalse(); + expect(accordion.panelExpanded.emit).toHaveBeenCalledTimes(0); + + accordion.panels[0].expand(); + tick(); + fix.detectChanges(); + + expect(accordion.panels.filter(panel => !panel.collapsed).length).toEqual(2); + expect(accordion.panels[0].collapsed).toBeFalse(); + expect(accordion.panels[1].collapsed).toBeTrue(); + expect(accordion.panels[2].collapsed).toBeTrue(); + expect(accordion.panels[3].collapsed).toBeFalse(); + + accordion.collapseAll(); + tick(); + fix.detectChanges(); + + expect(accordion.panelCollapsed.emit).toHaveBeenCalledTimes(3); + + accordion.panels[0].expand(); + accordion.panels[1].expand(); + tick(); + fix.detectChanges(); + + expect(accordion.panels.filter(panel => !panel.collapsed).length).toEqual(1); + expect(accordion.panels[0].collapsed).toBeTrue(); + expect(accordion.panels[1].collapsed).toBeFalse(); + expect(accordion.panels[2].collapsed).toBeTrue(); + expect(accordion.panels[3].collapsed).toBeTrue(); + + })); + + it('Should be able to expand multiple panels when singleBranchExpanded is set to false', fakeAsync(() => { + accordion.singleBranchExpand = false; + fix.detectChanges(); + + accordion.panels[0].expand(); + tick(); + fix.detectChanges(); + + expect(accordion.panels.filter(panel => !panel.collapsed).length).toEqual(3); + expect(accordion.panels[0].collapsed).toBeFalse(); + expect(accordion.panels[1].collapsed).toBeTrue(); + expect(accordion.panels[2].collapsed).toBeFalse(); + expect(accordion.panels[3].collapsed).toBeFalse(); + + accordion.panels[1].expand(); + tick(); + fix.detectChanges(); + + expect(accordion.panels.filter(panel => !panel.collapsed).length).toEqual(4); + expect(accordion.panels[0].collapsed).toBeFalse(); + expect(accordion.panels[1].collapsed).toBeFalse(); + expect(accordion.panels[2].collapsed).toBeFalse(); + expect(accordion.panels[3].collapsed).toBeFalse(); + })); + + it(`Should update the current expansion state when expandAll/collapseAll is invoked and + singleBranchExpaned is set to false`, fakeAsync(() => { + spyOn(accordion.panelExpanded, 'emit').and.callThrough(); + spyOn(accordion.panelCollapsed, 'emit').and.callThrough(); + accordion.singleBranchExpand = false; + accordion.panels[3].collapse(); + tick(); + fix.detectChanges(); + + accordion.expandAll(); + tick(); + fix.detectChanges(); + + expect(accordion.panels.filter(panel => panel.collapsed).length).toEqual(0); + expect(accordion.panelExpanded.emit).toHaveBeenCalledTimes(3); + + accordion.collapseAll(); + tick(); + fix.detectChanges(); + + expect(accordion.panels.filter(panel => panel.collapsed).length).toEqual(4); + expect(accordion.panelCollapsed.emit).toHaveBeenCalledTimes(5); + })); + + it(`Should collapse all expanded and not disabled panels except for the last one when setting singleBranchExpand to true`, () => { + expect(accordion.panels[0].collapsed).toBeTrue(); + expect(accordion.panels[1].collapsed).toBeTrue(); + expect(accordion.panels[2].collapsed).toBeFalse(); + expect(accordion.panels[3].collapsed).toBeFalse(); + + accordion.panels[1].collapsed = false; + fix.detectChanges(); + + expect(accordion.panels[0].collapsed).toBeTrue(); + expect(accordion.panels[1].collapsed).toBeFalse(); + expect(accordion.panels[2].collapsed).toBeFalse(); + expect(accordion.panels[3].collapsed).toBeFalse(); + + accordion.singleBranchExpand = true; + fix.detectChanges(); + + expect(accordion.panels[0].collapsed).toBeTrue(); + expect(accordion.panels[1].collapsed).toBeTrue(); + expect(accordion.panels[2].collapsed).toBeFalse(); + expect(accordion.panels[3].collapsed).toBeFalse(); + }); + + it('Should emit ing and ed events when expand panel state is toggled', fakeAsync(() => { + spyOn(accordion.panelExpanded, 'emit').and.callThrough(); + spyOn(accordion.panelExpanding, 'emit').and.callThrough(); + spyOn(accordion.panelCollapsed, 'emit').and.callThrough(); + spyOn(accordion.panelCollapsing, 'emit').and.callThrough(); + + spyOn(accordion.panels[0].contentCollapsing, 'emit').and.callThrough(); + spyOn(accordion.panels[0].contentCollapsed, 'emit').and.callThrough(); + spyOn(accordion.panels[0].contentExpanding, 'emit').and.callThrough(); + spyOn(accordion.panels[0].contentExpanded, 'emit').and.callThrough(); + + accordion.singleBranchExpand = false; + fix.detectChanges(); + + let argsEd: IAccordionEventArgs; + let argsIng: IAccordionCancelableEventArgs; + const subsExpanded = accordion.panels[0].contentExpanded.subscribe(expArgs => { + argsEd = { event: expArgs.event, owner: accordion, panel: expArgs.owner }; + }); + + const subsExpanding = accordion.panels[0].contentExpanding.subscribe(expArgs => { + argsIng = { event: expArgs.event, cancel: expArgs.cancel, owner: accordion, panel: expArgs.owner }; + }); + accordion.panels[0].expand(); + tick(); + fix.detectChanges(); + + expect(accordion.panelExpanding.emit).toHaveBeenCalledTimes(1); + expect(accordion.panelExpanding.emit).toHaveBeenCalledWith(argsIng); + expect(accordion.panelExpanded.emit).toHaveBeenCalledTimes(1); + expect(accordion.panelExpanded.emit).toHaveBeenCalledWith(argsEd); + + subsExpanded.unsubscribe(); + subsExpanding.unsubscribe(); + + const subsCollapsed = accordion.panels[0].contentCollapsed.subscribe(expArgs => { + argsEd = { event: expArgs.event, owner: accordion, panel: expArgs.owner }; + }); + + const subsCollapsing = accordion.panels[0].contentCollapsing.subscribe(expArgs => { + argsIng = { event: expArgs.event, cancel: expArgs.cancel, owner: accordion, panel: expArgs.owner }; + }); + accordion.panels[0].collapse(); + tick(); + fix.detectChanges(); + + expect(accordion.panelCollapsing.emit).toHaveBeenCalledTimes(1); + expect(accordion.panelCollapsing.emit).toHaveBeenCalledWith(argsIng); + expect(accordion.panelCollapsed.emit).toHaveBeenCalledTimes(1); + expect(accordion.panelCollapsed.emit).toHaveBeenCalledWith(argsEd); + + subsCollapsed.unsubscribe(); + subsCollapsing.unsubscribe(); + })); + + + it('Should focus the first/last panel on Home/End key press', () => { + accordion.panels[2].header.disabled = true; + fix.detectChanges(); + accordion.panels[1].header.elementRef.nativeElement.dispatchEvent(new Event('pointerdown')); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('home', accordion.panels[1].header.innerElement); + fix.detectChanges(); + + expect(accordion.panels[0].header.innerElement).toBe(document.activeElement); + + UIInteractions.triggerKeyDownEvtUponElem('end', accordion.panels[0].header.innerElement); + fix.detectChanges(); + + expect(accordion.panels[1].header.innerElement).toBe(document.activeElement); + }); + + it('Should focus the correct panel on ArrowDown/ArrowUp key pressed', () => { + accordion.panels[1].header.disabled = true; + fix.detectChanges(); + accordion.panels[0].header.elementRef.nativeElement.children[0].dispatchEvent(new Event('pointerdown')); + fix.detectChanges(); + + // ArrowDown + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', accordion.panels[0].header.innerElement); + fix.detectChanges(); + + expect(accordion.panels[2].header.innerElement).toBe(document.activeElement); + + // ArrowUp + UIInteractions.triggerKeyDownEvtUponElem('arrowup', accordion.panels[2].header.innerElement); + fix.detectChanges(); + + expect(accordion.panels[0].header.innerElement).toBe(document.activeElement); + }); + + it(`Should expand/collapse all panels on SHIFT + ALT + ArrowDown/ArrowUp key pressed + when singleBranchExpanded is false`, fakeAsync(() => { + accordion.singleBranchExpand = false; + fix.detectChanges(); + accordion.panels[1].header.disabled = true; + fix.detectChanges(); + + accordion.panels[0].header.elementRef.nativeElement.dispatchEvent(new Event('pointerdown')); + fix.detectChanges(); + + // SHIFT + ALT + ArrowDown + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', + accordion.panels[0].header.innerElement, true, true, true, false); + tick(); + fix.detectChanges(); + + expect(accordion.panels.filter(p => !p.collapsed && !p.header.disabled).length).toEqual(2); + expect(accordion.panels.filter(p => !p.collapsed).length).toEqual(3); + + // SHIFT + ALT + ArrowUp + UIInteractions.triggerKeyDownEvtUponElem('arrowup', + accordion.panels[0].header.innerElement, true, true, true, false); + tick(); + fix.detectChanges(); + + expect(accordion.panels.filter(p => p.collapsed && !p.header.disabled).length).toEqual(2); + expect(accordion.panels.filter(p => p.collapsed).length).toEqual(3); + })); + + it(`Should do nothing/collapse the only panel on SHIFT + ALT + ArrowDown/ArrowUp key pressed + when singleBranchExpanded is true`, fakeAsync(() => { + accordion.singleBranchExpand = true; + fix.detectChanges(); + + accordion.panels[0].header.elementRef.nativeElement.dispatchEvent(new Event('pointerdown')); + fix.detectChanges(); + + // SHIFT + ALT + ArrowDown + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', + accordion.panels[0].header.innerElement, true, true, true, false); + tick(); + fix.detectChanges(); + + expect(accordion.panels.filter(p => !p.collapsed).length).toEqual(2); + + // SHIFT + ALT + ArrowUp + UIInteractions.triggerKeyDownEvtUponElem('arrowup', + accordion.panels[0].header.innerElement, true, true, true, false); + tick(); + fix.detectChanges(); + + expect(accordion.panels.filter(p => p.collapsed).length).toEqual(3); + })); + + }); +}); + +@Component({ + template: ` + + + + HTML5 + + + + + + First + + +
+ Content1 +
+
+
+ + + Second + + +
+ Content2 +
+
+
+
+
+
+ + + CSS3 + + +
+ Cascading Style Sheets (CSS) is a style sheet language used for + describing the presentation of a document written in a markup language + like HTML +
+
+
+ + + SASS/SCSS + + +
+ Sass is a preprocessor scripting language that is interpreted or + compiled into Cascading Style Sheets (CSS). +
+
+
+ + + Javascript + + +
+ JavaScript is the world's most popular programming language. + JavaScript is the programming language of the Web. +
+
+
+ @if (divChild) { +
+ } +
+ `, + imports: [IgxAccordionComponent, IgxExpansionPanelComponent, IgxExpansionPanelHeaderComponent, IgxExpansionPanelBodyComponent, IgxExpansionPanelTitleDirective] +}) +export class IgxAccordionSampleTestComponent { + @ViewChild(IgxAccordionComponent) public accordion: IgxAccordionComponent; + public divChild = true; +} diff --git a/projects/igniteui-angular/accordion/src/accordion/accordion.component.ts b/projects/igniteui-angular/accordion/src/accordion/accordion.component.ts new file mode 100644 index 00000000000..a7362c8a755 --- /dev/null +++ b/projects/igniteui-angular/accordion/src/accordion/accordion.component.ts @@ -0,0 +1,412 @@ +import { AfterContentInit, AfterViewInit, ChangeDetectorRef, Component, ContentChildren, EventEmitter, HostBinding, Input, OnDestroy, Output, QueryList, booleanAttribute, inject } from '@angular/core'; +import { fromEvent, Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { ACCORDION_NAVIGATION_KEYS } from 'igniteui-angular/core'; +import { + IExpansionPanelCancelableEventArgs, + IExpansionPanelEventArgs, IgxExpansionPanelBase +} from 'igniteui-angular/expansion-panel'; +import { IgxExpansionPanelComponent } from 'igniteui-angular/expansion-panel'; +import { ToggleAnimationSettings } from 'igniteui-angular/expansion-panel'; + +export interface IAccordionEventArgs extends IExpansionPanelEventArgs { + owner: IgxAccordionComponent; + /** Provides a reference to the `IgxExpansionPanelComponent` which was expanded/collapsed. */ + panel: IgxExpansionPanelBase; +} + +export interface IAccordionCancelableEventArgs extends IExpansionPanelCancelableEventArgs { + owner: IgxAccordionComponent; + /** Provides a reference to the `IgxExpansionPanelComponent` which is currently expanding/collapsing. */ + panel: IgxExpansionPanelBase; + /** Enables canceling the expansion/collapse operation. */ + cancel: boolean; +} + +let NEXT_ID = 0; + +/** + * IgxAccordion is a container-based component that contains that can house multiple expansion panels. + * + * @igxModule IgxAccordionModule + * + * @igxKeywords accordion + * + * @igxGroup Layouts + * + * @remarks + * The Ignite UI for Angular Accordion component enables the user to navigate among multiple collapsing panels + * displayed in a single container. + * The accordion offers keyboard navigation and API to control the underlying panels' expansion state. + * + * @example + * ```html + * + * + * ... + * + * + * ``` + */ +@Component({ + selector: 'igx-accordion', + templateUrl: 'accordion.component.html', + standalone: true +}) +export class IgxAccordionComponent implements AfterContentInit, AfterViewInit, OnDestroy { + private cdr = inject(ChangeDetectorRef); + + /** + * Get/Set the `id` of the accordion component. + * Default value is `"igx-accordion-0"`; + * ```html + * + * ``` + * ```typescript + * const accordionId = this.accordion.id; + * ``` + */ + @HostBinding('attr.id') + @Input() + public id = `igx-accordion-${NEXT_ID++}`; + + /** @hidden @internal **/ + @HostBinding('class.igx-accordion') + public cssClass = 'igx-accordion'; + + /** @hidden @internal **/ + @HostBinding('style.display') + public displayStyle = 'block'; + + /** + * Get/Set the animation settings that panels should use when expanding/collpasing. + * + * ```html + * + * ``` + * + * ```typescript + * const customAnimationSettings: ToggleAnimationSettings = { + * openAnimation: growVerIn, + * closeAnimation: growVerOut + * }; + * + * this.accordion.animationSettings = customAnimationSettings; + * ``` + */ + @Input() + public get animationSettings(): ToggleAnimationSettings { + return this._animationSettings; + } + + public set animationSettings(value: ToggleAnimationSettings) { + this._animationSettings = value; + this.updatePanelsAnimation(); + } + + /** + * Get/Set how the accordion handles the expansion of the projected expansion panels. + * If set to `true`, only a single panel can be expanded at a time, collapsing all others + * + * ```html + * + * ... + * + * ``` + * + * ```typescript + * this.accordion.singleBranchExpand = false; + * ``` + */ + @Input({ transform: booleanAttribute }) + public get singleBranchExpand(): boolean { + return this._singleBranchExpand; + } + + public set singleBranchExpand(val: boolean) { + this._singleBranchExpand = val; + if (val) { + this.collapseAllExceptLast(); + } + } + + /** + * Emitted before a panel is expanded. + * + * @remarks + * This event is cancelable. + * + * ```html + * + * + * ``` + * + *```typescript + * public handlePanelExpanding(event: IExpansionPanelCancelableEventArgs){ + * const expandedPanel: IgxExpansionPanelComponent = event.panel; + * if (expandedPanel.disabled) { + * event.cancel = true; + * } + * } + *``` + */ + @Output() + public panelExpanding = new EventEmitter(); + + /** + * Emitted after a panel has been expanded. + * + * ```html + * + * + * ``` + * + *```typescript + * public handlePanelExpanded(event: IExpansionPanelCancelableEventArgs) { + * const expandedPanel: IgxExpansionPanelComponent = event.panel; + * console.log("Panel is expanded: ", expandedPanel.id); + * } + *``` + */ + @Output() + public panelExpanded = new EventEmitter(); + + /** + * Emitted before a panel is collapsed. + * + * @remarks + * This event is cancelable. + * + * ```html + * + * + * ``` + */ + @Output() + public panelCollapsing = new EventEmitter(); + + /** + * Emitted after a panel has been collapsed. + * + * ```html + * + * + * ``` + */ + @Output() + public panelCollapsed = new EventEmitter(); + + /** + * Get all panels. + * + * ```typescript + * const panels: IgxExpansionPanelComponent[] = this.accordion.panels; + * ``` + */ + public get panels(): IgxExpansionPanelComponent[] { + return this._panels?.toArray(); + } + + @ContentChildren(IgxExpansionPanelComponent) + private _panels!: QueryList; + private _animationSettings!: ToggleAnimationSettings; + private _expandedPanels!: Set; + private _expandingPanels!: Set; + private _destroy$ = new Subject(); + private _unsubChildren$ = new Subject(); + private _enabledPanels!: IgxExpansionPanelComponent[]; + private _singleBranchExpand = false; + + /** @hidden @internal **/ + public ngAfterContentInit(): void { + this.updatePanelsAnimation(); + if (this.singleBranchExpand) { + this.collapseAllExceptLast(); + } + } + + /** @hidden @internal **/ + public ngAfterViewInit(): void { + this._expandedPanels = new Set(this._panels.filter(panel => !panel.collapsed)); + this._expandingPanels = new Set(); + this._panels.changes.pipe(takeUntil(this._destroy$)).subscribe(() => { + this.subToChanges(); + }); + this.subToChanges(); + } + + /** @hidden @internal */ + public ngOnDestroy(): void { + this._unsubChildren$.next(); + this._unsubChildren$.complete(); + this._destroy$.next(); + this._destroy$.complete(); + } + + /** + * Expands all collapsed expansion panels. + * + * ```typescript + * accordion.expandAll(); + * ``` + */ + public expandAll(): void { + if (this.singleBranchExpand) { + for (let i = 0; i < this.panels.length - 1; i++) { + this.panels[i].collapse(); + } + this._panels.last.expand(); + return; + } + + this.panels.forEach(panel => panel.expand()); + } + + /** + * Collapses all expanded expansion panels. + * + * ```typescript + * accordion.collapseAll(); + * ``` + */ + public collapseAll(): void { + this.panels.forEach(panel => panel.collapse()); + } + + private collapseAllExceptLast(): void { + const lastExpanded = this.panels?.filter(p => !p.collapsed && !p.header.disabled).pop(); + this.panels?.forEach((p: IgxExpansionPanelComponent) => { + if (p !== lastExpanded && !p.header.disabled) { + p.collapsed = true; + } + }); + this.cdr.markForCheck(); + } + + private handleKeydown(event: KeyboardEvent, panel: IgxExpansionPanelComponent): void { + const key = event.key.toLowerCase(); + if (!(ACCORDION_NAVIGATION_KEYS.has(key))) { + return; + } + // TO DO: if we ever want to improve the performance of the accordion, + // enabledPanels could be cached (by making a disabledChange emitter on the panel header) + this._enabledPanels = this._panels.filter(p => !p.header.disabled); + event.preventDefault(); + this.handleNavigation(event, panel); + } + + private handleNavigation(event: KeyboardEvent, panel: IgxExpansionPanelComponent): void { + switch (event.key.toLowerCase()) { + case 'home': + this._enabledPanels[0].header.innerElement.focus(); + break; + case 'end': + this._enabledPanels[this._enabledPanels.length - 1].header.innerElement.focus(); + break; + case 'arrowup': + case 'up': + this.handleUpDownArrow(true, event, panel); + break; + case 'arrowdown': + case 'down': + this.handleUpDownArrow(false, event, panel); + break; + } + } + + private handleUpDownArrow(isUp: boolean, event: KeyboardEvent, panel: IgxExpansionPanelComponent): void { + if (!event.altKey) { + const focusedPanel = panel; + const next = this.getNextPanel(focusedPanel, isUp ? -1 : 1); + if (next === focusedPanel) { + return; + } + next.header.innerElement.focus(); + } + if (event.altKey && event.shiftKey) { + if (isUp) { + this._enabledPanels.forEach(p => p.collapse()); + } else { + if (this.singleBranchExpand) { + for (let i = 0; i < this._enabledPanels.length - 1; i++) { + this._enabledPanels[i].collapse(); + } + this._enabledPanels[this._enabledPanels.length - 1].expand(); + return; + } + this._enabledPanels.forEach(p => p.expand()); + } + } + } + + private getNextPanel(panel: IgxExpansionPanelComponent, dir: 1 | -1 = 1): IgxExpansionPanelComponent { + const panelIndex = this._enabledPanels.indexOf(panel); + return this._enabledPanels[panelIndex + dir] || panel; + } + + private subToChanges(): void { + this._unsubChildren$.next(); + this._panels.forEach(panel => { + panel.contentExpanded.pipe(takeUntil(this._unsubChildren$)).subscribe((args: IExpansionPanelEventArgs) => { + this._expandedPanels.add(args.owner); + this._expandingPanels.delete(args.owner); + const evArgs: IAccordionEventArgs = { ...args, owner: this, panel: args.owner }; + this.panelExpanded.emit(evArgs); + }); + panel.contentExpanding.pipe(takeUntil(this._unsubChildren$)).subscribe((args: IExpansionPanelCancelableEventArgs) => { + if (args.cancel) { + return; + } + const evArgs: IAccordionCancelableEventArgs = { ...args, owner: this, panel: args.owner }; + this.panelExpanding.emit(evArgs); + if (evArgs.cancel) { + args.cancel = true; + return; + } + if (this.singleBranchExpand) { + this._expandedPanels.forEach(p => { + if (!p.header.disabled) { + p.collapse(); + } + }); + this._expandingPanels.forEach(p => { + if (!p.header.disabled) { + if (!p.animationSettings.closeAnimation) { + p.openAnimationPlayer?.reset(); + } + if (!p.animationSettings.openAnimation) { + p.closeAnimationPlayer?.reset(); + } + p.collapse(); + } + }); + this._expandingPanels.add(args.owner); + } + }); + panel.contentCollapsed.pipe(takeUntil(this._unsubChildren$)).subscribe((args: IExpansionPanelEventArgs) => { + this._expandedPanels.delete(args.owner); + this._expandingPanels.delete(args.owner); + const evArgs: IAccordionEventArgs = { ...args, owner: this, panel: args.owner }; + this.panelCollapsed.emit(evArgs); + }); + panel.contentCollapsing.pipe(takeUntil(this._unsubChildren$)).subscribe((args: IExpansionPanelCancelableEventArgs) => { + const evArgs: IAccordionCancelableEventArgs = { ...args, owner: this, panel: args.owner }; + this.panelCollapsing.emit(evArgs); + if (evArgs.cancel) { + args.cancel = true; + } + }); + fromEvent(panel.header.innerElement, 'keydown') + .pipe(takeUntil(this._unsubChildren$)) + .subscribe((e: KeyboardEvent) => { + this.handleKeydown(e, panel); + }); + }); + } + + private updatePanelsAnimation(): void { + if (this.animationSettings !== undefined) { + this.panels?.forEach(panel => panel.animationSettings = this.animationSettings); + } + } +} diff --git a/projects/igniteui-angular/accordion/src/accordion/accordion.module.ts b/projects/igniteui-angular/accordion/src/accordion/accordion.module.ts new file mode 100644 index 00000000000..75031c4c5d3 --- /dev/null +++ b/projects/igniteui-angular/accordion/src/accordion/accordion.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { IGX_ACCORDION_DIRECTIVES } from './public_api'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + ...IGX_ACCORDION_DIRECTIVES + ], + exports: [ + ...IGX_ACCORDION_DIRECTIVES + ] +}) +export class IgxAccordionModule { +} diff --git a/projects/igniteui-angular/accordion/src/accordion/public_api.ts b/projects/igniteui-angular/accordion/src/accordion/public_api.ts new file mode 100644 index 00000000000..4f380b900ab --- /dev/null +++ b/projects/igniteui-angular/accordion/src/accordion/public_api.ts @@ -0,0 +1,26 @@ +import { IgxAccordionComponent } from './accordion.component'; + +export * from './accordion.component'; + +/* Imports that cannot be resolved from IGX_EXPANSION_PANEL_DIRECTIVES spread + NOTE: Do not remove! Issue: https://github.com/IgniteUI/igniteui-angular/issues/13310 +*/ +import { + IgxExpansionPanelComponent, + IgxExpansionPanelHeaderComponent, + IgxExpansionPanelBodyComponent, + IgxExpansionPanelDescriptionDirective, + IgxExpansionPanelTitleDirective, + IgxExpansionPanelIconDirective +} from 'igniteui-angular/expansion-panel'; + +/* Accordion directives collection for ease-of-use import in standalone components scenario */ +export const IGX_ACCORDION_DIRECTIVES = [ + IgxAccordionComponent, + IgxExpansionPanelComponent, + IgxExpansionPanelHeaderComponent, + IgxExpansionPanelBodyComponent, + IgxExpansionPanelDescriptionDirective, + IgxExpansionPanelTitleDirective, + IgxExpansionPanelIconDirective +] as const; diff --git a/projects/igniteui-angular/accordion/src/public_api.ts b/projects/igniteui-angular/accordion/src/public_api.ts new file mode 100644 index 00000000000..82c507769a4 --- /dev/null +++ b/projects/igniteui-angular/accordion/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './accordion/public_api'; +export * from './accordion/accordion.module'; diff --git a/projects/igniteui-angular/action-strip/README.md b/projects/igniteui-angular/action-strip/README.md new file mode 100644 index 00000000000..68fbe1aa475 --- /dev/null +++ b/projects/igniteui-angular/action-strip/README.md @@ -0,0 +1,78 @@ +# igx-action-strip + +The **igx-action-strip** provides a template area for one or more actions. +In its simplest form the Action Strip is an overlay of any container and shows additional content over that container. +A walk-through of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/action_strip.html) + +# Usage +The Action Strip can be initialized in any HTML element that can contain elements. This parent element should be with a relative position as the action strip is trying to overlay it. Interactions with the parent and its content are available while the action strip is shown. +```html + + + +``` + +# Grid Action Components +Action strip provides functionality and UI for IgxGrid. All that can be utilized with grid action components. These components inherit `IgxGridActionsBaseDirective` and when creating a custom grid action component, this component should also inherit `IgxGridActionsBaseDirective`. + +```html + + + + +``` + +# IgxActionStripMenuItem + +The Action Strip can show items as menu. This is achieved with `igxActionStripMenuItem` directive applied to its content. Action strip will render three-dot button that toggles a drop down. And the content will be those items that are marked with `igxActionStripMenuItem` directive. + +```html + + Copy + Paste + Edit + +``` +# API Summary + +## Inputs +`IgxActionStripComponent` + + | Name | Description | Type | Default value | + |-----------------|---------------------------------------------------|-----------------------------|---------------| + | hidden | An @Input property that sets the visibility of the Action Strip. | boolean | `false` | + | context | Sets the context of an action strip. The context should be an instance of a @Component, that has element property. This element will be the placeholder of the action strip. | any | | + +`IgxGridActionsBaseDirective` ( `IgxGridPinningActionsComponent`, `IgxGridEditingActionsComponent`) + + | Name | Description | Type | Default value | + |-----------------|---------------------------------------------------|-----------------------------|---------------| + | grid | Set an instance of the grid for which to display the actions. | any | | + | context | Sets the context of an action strip. The context is expected to be grid cell or grid row | any | | + +## Outputs +|Name|Description|Cancelable|Parameters| +|--|--|--|--| +| onMenuOpening | Emitted before the menu is opened | true | | +| onMenuOpened | Emitted after the menu is opened | false | | + +## Methods + +`IgxActionStripComponent` + + | Name | Description | Return type | Parameters | + |----------|----------------------------|---------------------------------------------------|----------------------| + | show | Showing the Action Strip and appending it the specified context element. | void | context | + | hide | Hiding the Action Strip and removing it from its current context element. | void | | + +`IgxGridPinningActionsComponent` + | Name | Description | Return type | Parameters | + |----------|----------------------------|---------------------------------------------------|----------------------| + | pin | Pin the row according to the context. | void | | + | unpin | Unpin the row according to the context. | void | | + +`IgxGridPinningActionsComponent` + | Name | Description | Return type | Parameters | + |----------|----------------------------|---------------------------------------------------|----------------------| + | startEdit | Enter row or cell edit mode depending the grid `rowEdibable` option | void | | + | deleteRow | Delete a row according to the context | void | | \ No newline at end of file diff --git a/projects/igniteui-angular/action-strip/index.ts b/projects/igniteui-angular/action-strip/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/action-strip/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/action-strip/ng-package.json b/projects/igniteui-angular/action-strip/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/action-strip/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/action-strip/src/action-strip/action-strip.component.html b/projects/igniteui-angular/action-strip/src/action-strip/action-strip.component.html new file mode 100644 index 00000000000..65566b3f7aa --- /dev/null +++ b/projects/igniteui-angular/action-strip/src/action-strip/action-strip.component.html @@ -0,0 +1,33 @@ +
+ + @if (menuItems.length > 0) { + + } + + @for (item of menuItems; track trackMenuItem(item)) { + +
+ +
+
+ } +
+
diff --git a/projects/igniteui-angular/action-strip/src/action-strip/action-strip.component.spec.ts b/projects/igniteui-angular/action-strip/src/action-strip/action-strip.component.spec.ts new file mode 100644 index 00000000000..5f17dcfc137 --- /dev/null +++ b/projects/igniteui-angular/action-strip/src/action-strip/action-strip.component.spec.ts @@ -0,0 +1,235 @@ +import { IgxActionStripComponent, IgxActionStripMenuItemDirective } from './action-strip.component'; +import { Component, ViewChild, ElementRef, ViewContainerRef } from '@angular/core'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { wait } from '../../../test-utils/ui-interactions.spec'; + +const ACTION_STRIP_CONTAINER_CSS = 'igx-action-strip__actions'; +const DROP_DOWN_LIST = 'igx-drop-down__list'; + +describe('igxActionStrip', () => { + let fixture; + let actionStrip: IgxActionStripComponent; + let actionStripElement: ElementRef; + let parentContainer: ElementRef; + let innerContainer: ViewContainerRef; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxActionStripComponent, + IgxActionStripTestingComponent, + IgxActionStripMenuTestingComponent, + IgxActionStripCombinedMenuTestingComponent + ] + }).compileComponents(); + })); + + describe('Unit tests: ', () => { + + it('should properly show and hide using API', () => { + fixture = TestBed.createComponent(IgxActionStripComponent); + actionStrip = fixture.componentInstance as IgxActionStripComponent; + fixture.detectChanges(); + expect(actionStrip.hidden).toBeTruthy(); + + const el = document.createElement('div'); + fixture.debugElement.nativeElement.appendChild(el); + actionStrip.show(el); + expect(actionStrip.hidden).toBeFalsy(); + expect(actionStrip.context).toBe(el); + actionStrip.hide(); + expect(actionStrip.hidden).toBeTruthy(); + fixture.debugElement.nativeElement.removeChild(el); + }); + + }); + + describe('Initialization and rendering tests: ', () => { + + beforeEach(() => { + fixture = TestBed.createComponent(IgxActionStripTestingComponent); + fixture.detectChanges(); + actionStrip = fixture.componentInstance.actionStrip; + actionStripElement = fixture.componentInstance.actionStripElement; + parentContainer = fixture.componentInstance.parentContainer; + innerContainer = fixture.componentInstance.innerContainer; + }); + + it('should be overlapping its parent container when no context is applied', () => { + const parentBoundingRect = parentContainer.nativeElement.getBoundingClientRect(); + const actionStripBoundingRect = actionStripElement.nativeElement.getBoundingClientRect(); + expect(parentBoundingRect.top).toBe(actionStripBoundingRect.top); + expect(parentBoundingRect.bottom).toBe(actionStripBoundingRect.bottom); + expect(parentBoundingRect.left).toBe(actionStripBoundingRect.left); + expect(parentBoundingRect.right).toBe(actionStripBoundingRect.right); + }); + + it('should be overlapping context.element when context is applied', () => { + actionStrip.show(innerContainer); + fixture.detectChanges(); + const innerBoundingRect = innerContainer.element.nativeElement.getBoundingClientRect(); + const actionStripBoundingRect = actionStripElement.nativeElement.getBoundingClientRect(); + expect(innerBoundingRect.top).toBe(actionStripBoundingRect.top); + expect(innerBoundingRect.bottom).toBe(actionStripBoundingRect.bottom); + expect(innerBoundingRect.left).toBe(actionStripBoundingRect.left); + expect(innerBoundingRect.right).toBe(actionStripBoundingRect.right); + }); + + it('should allow interacting with the content elements', () => { + const asIcon = fixture.debugElement.query(By.css('.asIcon')); + asIcon.triggerEventHandler('click', new Event('click')); + fixture.detectChanges(); + expect(fixture.componentInstance.flag).toBeTruthy(); + }); + + it('should not display the action strip when setting it hidden', () => { + actionStrip.hidden = true; + fixture.detectChanges(); + const asQuery = fixture.debugElement.query(By.css('igx-action-strip')); + expect(asQuery.nativeElement.style.display).toBe('none'); + }); + }); + + describe('render content as menu', () => { + + it('should render tree-dot button which toggles the content as menu', () => { + fixture = TestBed.createComponent(IgxActionStripMenuTestingComponent); + fixture.detectChanges(); + actionStrip = fixture.componentInstance.actionStrip; + const actionStripContainer = fixture.debugElement.query(By.css(`.${ACTION_STRIP_CONTAINER_CSS}`)); + // there should be one rendered child and one hidden dropdown + expect(actionStripContainer.nativeElement.children.length).toBe(2); + let dropDownList = fixture.debugElement.query(By.css(`.${DROP_DOWN_LIST}`)); + expect(dropDownList.nativeElement.getAttribute('aria-hidden')).toBe('true'); + const icon = fixture.debugElement.query(By.css(`igx-icon`)); + icon.parent.triggerEventHandler('click', new Event('click')); + fixture.detectChanges(); + dropDownList = fixture.debugElement.query(By.css(`.${DROP_DOWN_LIST}`)); + expect(dropDownList.nativeElement.getAttribute('aria-hidden')).toBe('false'); + const dropDownItems = dropDownList.queryAll(By.css('igx-drop-down-item')); + expect(dropDownItems.length).toBe(3); + }); + + it('should emit onMenuOpen/onMenuOpening when toggling the menu', () => { + pending('implementation'); + }); + + it('should allow combining content outside and inside the menu', () => { + fixture = TestBed.createComponent(IgxActionStripCombinedMenuTestingComponent); + fixture.detectChanges(); + actionStrip = fixture.componentInstance.actionStrip; + const actionStripContainer = fixture.debugElement.query(By.css(`.${ACTION_STRIP_CONTAINER_CSS}`)); + // there should be one rendered child and one hidden dropdown and one additional icon + expect(actionStripContainer.nativeElement.children.length).toBe(3); + let dropDownList = fixture.debugElement.query(By.css(`.${DROP_DOWN_LIST}`)); + expect(dropDownList.nativeElement.getAttribute('aria-hidden')).toBe('true'); + const icon = fixture.debugElement.query(By.css(`igx-icon`)); + icon.parent.triggerEventHandler('click', new Event('click')); + fixture.detectChanges(); + dropDownList = fixture.debugElement.query(By.css(`.${DROP_DOWN_LIST}`)); + expect(dropDownList.nativeElement.getAttribute('aria-hidden')).toBe('false'); + const dropDownItems = dropDownList.queryAll(By.css('igx-drop-down-item')); + expect(dropDownItems.length).toBe(2); + }); + + it('should close the menu when hiding action strip', async () => { + fixture = TestBed.createComponent(IgxActionStripCombinedMenuTestingComponent); + fixture.detectChanges(); + actionStrip = fixture.componentInstance.actionStrip; + // there should be one rendered child and one hidden dropdown and one additional icon + const icon = fixture.debugElement.query(By.css(`igx-icon`)); + icon.parent.triggerEventHandler('click', new Event('click')); + fixture.detectChanges(); + let dropDownList = fixture.debugElement.query(By.css(`.${DROP_DOWN_LIST}`)); + expect(dropDownList.nativeElement.getAttribute('aria-hidden')).toBe('false'); + actionStrip.hide(); + await wait(); + fixture.detectChanges(); + dropDownList = fixture.debugElement.query(By.css(`.${DROP_DOWN_LIST}`)); + expect(dropDownList.nativeElement.getAttribute('aria-hidden')).toBe('true'); + }); + }); +}); + +@Component({ + template: ` +
+
+

+ Lorem ipsum dolor sit +

+
+ + alarm + +
+ `, + imports: [IgxActionStripComponent, IgxIconComponent] +}) +class IgxActionStripTestingComponent { + @ViewChild('actionStrip', { read: IgxActionStripComponent, static: true }) + public actionStrip: IgxActionStripComponent; + + @ViewChild('actionStrip', { read: ElementRef, static: true }) + public actionStripElement: ElementRef; + + @ViewChild('parent', { static: true }) + public parentContainer: ElementRef; + + @ViewChild('inner', { read: ViewContainerRef, static: true }) + public innerContainer: ViewContainerRef; + + public flag = false; + + public onIconClick() { + this.flag = true; + } +} + +@Component({ + template: ` +
+
+

+ Lorem ipsum dolor sit +

+
+ + Mark + Favorite + Download + +
+ `, + imports: [IgxActionStripComponent, IgxActionStripMenuItemDirective] +}) +class IgxActionStripMenuTestingComponent { + @ViewChild('actionStrip', { read: IgxActionStripComponent, static: true }) + public actionStrip: IgxActionStripComponent; +} + +@Component({ + template: ` +
+
+

+ Lorem ipsum dolor sit +

+
+ + Mark + Favorite + Download + +
+ `, + imports: [IgxActionStripComponent, IgxActionStripMenuItemDirective] +}) +class IgxActionStripCombinedMenuTestingComponent { + @ViewChild('actionStrip', { read: IgxActionStripComponent, static: true }) + public actionStrip: IgxActionStripComponent; +} diff --git a/projects/igniteui-angular/action-strip/src/action-strip/action-strip.component.ts b/projects/igniteui-angular/action-strip/src/action-strip/action-strip.component.ts new file mode 100644 index 00000000000..09ff6f865d5 --- /dev/null +++ b/projects/igniteui-angular/action-strip/src/action-strip/action-strip.component.ts @@ -0,0 +1,323 @@ +import { + Component, + Directive, + HostBinding, + Input, + Renderer2, + ViewContainerRef, + ContentChildren, + QueryList, + ViewChild, + TemplateRef, + ChangeDetectorRef, + AfterViewInit, + ElementRef, + booleanAttribute, + AfterContentInit, + inject +} from '@angular/core'; + + +import { ActionStripResourceStringsEN, CloseScrollStrategy, getCurrentResourceStrings, IActionStripResourceStrings, IgxActionStripActionsToken, IgxActionStripToken, OverlaySettings } from 'igniteui-angular/core'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxToggleActionDirective } from 'igniteui-angular/directives'; +import { IgxRippleDirective } from 'igniteui-angular/directives'; +import { NgTemplateOutlet } from '@angular/common'; +import { IgxIconButtonDirective } from 'igniteui-angular/directives'; +import { trackByIdentity } from 'igniteui-angular/core'; +import { IgxDropDownComponent, IgxDropDownItemComponent, IgxDropDownItemNavigationDirective } from 'igniteui-angular/drop-down'; + +@Directive({ + selector: '[igxActionStripMenuItem]', + standalone: true +}) +export class IgxActionStripMenuItemDirective { + public templateRef = inject>(TemplateRef); +} + +/* blazorElement */ +/* jsonAPIManageItemInMarkup */ +/* jsonAPIManageCollectionInMarkup */ +/* wcElementTag: igc-action-strip */ +/* blazorIndirectRender */ +/* singleInstanceIdentifier */ +/* contentParent: GridBaseDirective */ +/* contentParent: RowIsland */ +/* contentParent: HierarchicalGrid */ +/** + * Action Strip provides templatable area for one or more actions. + * + * @igxModule IgxActionStripModule + * + * @igxTheme igx-action-strip-theme + * + * @igxKeywords action, strip, actionStrip, pinning, editing + * + * @igxGroup Data Entry & Display + * + * @igxParent IgxGridComponent, IgxTreeGridComponent, IgxHierarchicalGridComponent, IgxRowIslandComponent, * + * + * @remarks + * The Ignite UI Action Strip is a container, overlaying its parent container, + * and displaying action buttons with action applicable to the parent component the strip is instantiated or shown for. + * + * @example + * ```html + * + * + * + */ +@Component({ + selector: 'igx-action-strip', + templateUrl: 'action-strip.component.html', + imports: [ + NgTemplateOutlet, + IgxIconButtonDirective, + IgxRippleDirective, + IgxToggleActionDirective, + IgxDropDownItemNavigationDirective, + IgxIconComponent, + IgxDropDownComponent, + IgxDropDownItemComponent + ], + providers: [{ provide: IgxActionStripToken, useExisting: IgxActionStripComponent }] +}) +export class IgxActionStripComponent implements IgxActionStripToken, AfterViewInit, AfterContentInit { + private _viewContainer = inject(ViewContainerRef); + private renderer = inject(Renderer2); + protected el = inject(ElementRef); + public cdr = inject(ChangeDetectorRef); + + + /* blazorSuppress */ + /** + * Sets the context of an action strip. + * The context should be an instance of a @Component, that has element property. + * This element will be the placeholder of the action strip. + * + * @example + * ```html + * + * ``` + */ + @Input() + public context: any; + + /** + * Menu Items ContentChildren inside the Action Strip + * + * @hidden + * @internal + */ + @ContentChildren(IgxActionStripMenuItemDirective) + public _menuItems: QueryList; + + + /* blazorInclude */ + /* contentChildren */ + /* blazorTreatAsCollection */ + /* blazorCollectionName: GridActionsBaseDirectiveCollection */ + /** + * ActionButton as ContentChildren inside the Action Strip + * + * @hidden + * @internal + */ + @ContentChildren(IgxActionStripActionsToken as any) + public actionButtons: QueryList; + + /** + * Gets/Sets the visibility of the Action Strip. + * Could be used to set if the Action Strip will be initially hidden. + * + * @example + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public hidden = true; + + + /** + * Gets/Sets the resource strings. + * + * @remarks + * By default it uses EN resources. + */ + @Input() + public set resourceStrings(value: IActionStripResourceStrings) { + this._resourceStrings = Object.assign({}, this._resourceStrings, value); + } + + public get resourceStrings(): IActionStripResourceStrings { + return this._resourceStrings; + } + + /** + * Hide or not the Action Strip based on if it is a menu. + * + * @hidden + * @internal + */ + public get hideOnRowLeave(): boolean { + if (this.menu.items.length === 0) { + return true; + } else if (this.menu.items.length > 0) { + if (this.menu.collapsed) { + return true; + } else { + return false; + } + } + } + + /** + * Reference to the menu + * + * @hidden + * @internal + */ + @ViewChild('dropdown') + public menu: IgxDropDownComponent; + + /** + * Getter for menu overlay settings + * + * @hidden + * @internal + */ + public menuOverlaySettings: OverlaySettings = { scrollStrategy: new CloseScrollStrategy() }; + + private _resourceStrings = getCurrentResourceStrings(ActionStripResourceStringsEN); + private _originalParent!: HTMLElement; + + /** + * Menu Items list. + * + * @hidden + * @internal + */ + public get menuItems() { + const actions = []; + this.actionButtons.forEach(button => { + if (button.asMenuItems) { + const children = button.buttons; + if (children) { + children.toArray().forEach(x => actions.push(x)); + } + } + }); + return [... this._menuItems.toArray(), ...actions]; + } + + + /** + * Getter for the 'display' property of the current `IgxActionStrip` + */ + @HostBinding('style.display') + private get display(): string { + return this.hidden ? 'none' : 'flex'; + } + + /** + * Host `attr.class` binding. + */ + @HostBinding('class.igx-action-strip') + protected hostClass = 'igx-action-strip'; + + /** + * @hidden + * @internal + */ + public ngAfterContentInit() { + this.actionButtons.forEach(button => { + button.strip = this; + }); + this.actionButtons.changes.subscribe(() => { + this.actionButtons.forEach(button => { + button.strip = this; + }); + }); + } + + /** + * @hidden + * @internal + */ + public ngAfterViewInit() { + this.menu.selectionChanging.subscribe(($event) => { + const newSelection = ($event.newSelection as any).elementRef.nativeElement; + let allButtons = []; + this.actionButtons.forEach(actionButtons => { + if (actionButtons.asMenuItems) { + allButtons = [...allButtons, ...actionButtons.buttons.toArray()]; + } + }); + const button = allButtons.find(x => newSelection.contains(x.container.nativeElement)); + if (button) { + button.actionClick.emit(); + } + }); + this._originalParent = this._viewContainer.element.nativeElement?.parentElement; + } + + /** + * Showing the Action Strip and appending it the specified context element. + * + * @param context + * @example + * ```typescript + * this.actionStrip.show(row); + * ``` + */ + public show(context?: any): void { + this.hidden = false; + if (!context) { + return; + } + // when shown for different context make sure the menu won't stay opened + if (this.context !== context) { + this.closeMenu(); + } + this.context = context; + if (this.context && this.context.element) { + this.renderer.appendChild(context.element.nativeElement, this._viewContainer.element.nativeElement); + } + this.cdr.detectChanges(); + } + + /** + * Hiding the Action Strip and removing it from its current context element. + * + * @example + * ```typescript + * this.actionStrip.hide(); + * ``` + */ + public hide(): void { + this.hidden = true; + this.closeMenu(); + if (this._originalParent) { + // D.P. fix(elements) don't detach native DOM, instead move back. Might not matter for Angular, but Elements will destroy + this.renderer.appendChild(this._originalParent, this._viewContainer.element.nativeElement); + } else if (this.context && this.context.element) { + this.renderer.removeChild(this.context.element.nativeElement, this._viewContainer.element.nativeElement); + } + } + + /** pin swapping w/ unpin resets the menuItems collection */ + protected trackMenuItem = trackByIdentity; + + /** + * Close the menu if opened + * + * @hidden + * @internal + */ + private closeMenu(): void { + if (this.menu && !this.menu.collapsed) { + this.menu.close(); + } + } +} diff --git a/projects/igniteui-angular/action-strip/src/action-strip/action-strip.module.ts b/projects/igniteui-angular/action-strip/src/action-strip/action-strip.module.ts new file mode 100644 index 00000000000..9b70f111a1b --- /dev/null +++ b/projects/igniteui-angular/action-strip/src/action-strip/action-strip.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { IGX_ACTION_STRIP_DIRECTIVES } from './public_api'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + ...IGX_ACTION_STRIP_DIRECTIVES + ], + exports: [ + ...IGX_ACTION_STRIP_DIRECTIVES + ], +}) +export class IgxActionStripModule { } diff --git a/projects/igniteui-angular/action-strip/src/action-strip/public_api.ts b/projects/igniteui-angular/action-strip/src/action-strip/public_api.ts new file mode 100644 index 00000000000..a7a9f20fe60 --- /dev/null +++ b/projects/igniteui-angular/action-strip/src/action-strip/public_api.ts @@ -0,0 +1,9 @@ +import { IgxActionStripComponent, IgxActionStripMenuItemDirective } from './action-strip.component'; + +export { IgxActionStripComponent, IgxActionStripMenuItemDirective } from './action-strip.component'; + +/* Action-strip outside of grid directives collection for ease-of-use import in standalone components scenario */ +export const IGX_ACTION_STRIP_DIRECTIVES = [ + IgxActionStripComponent, + IgxActionStripMenuItemDirective +] as const; diff --git a/projects/igniteui-angular/action-strip/src/public_api.ts b/projects/igniteui-angular/action-strip/src/public_api.ts new file mode 100644 index 00000000000..f032eb134ce --- /dev/null +++ b/projects/igniteui-angular/action-strip/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './action-strip/public_api'; +export * from './action-strip/action-strip.module'; diff --git a/projects/igniteui-angular/avatar/README.md b/projects/igniteui-angular/avatar/README.md new file mode 100644 index 00000000000..c87bf88630d --- /dev/null +++ b/projects/igniteui-angular/avatar/README.md @@ -0,0 +1,38 @@ +# igx-avatar + +The **igx-avatar** component allows you to add images or initials as avatars in your application. +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/avatar.html) + +# Usage +```html + + +``` + +# API Summary +| Name | Type | Description | +|:----------|:-------------:|:------| +| `id` | string | Unique identifier of the component. If not provided it will be automatically generated.| +| `src` | string | Set the image source of the avatar. | +| `initials` | string | Set the initials of the avatar. | +| `icon` | string | Set the icon of the avatar. Currently all icons from the material icon set are supported. Not applicable for initials and image avatars. | +| `bgColor` | string | Set the background color of initials or icon avatars. | +| `color` | string | Set the color of initials or icon avatars. (optional) | +| `shape` | boolean | Set the shape of the avatar to rounded. The default shape is square. | +| `size` | string | Set the size of the avatar to either small, medium, or large. | + +*You can also set all igx-avatar properties programmatically. + +# Examples + +Using `igx-avatar` tag to include it into your app. +```html + + +``` + +Using `TypeScript` to modify and existing igx-avatar instance. +```typescript +avatarInstance.srcImage('https://unsplash.it/60/60?image=55'); +avatarInstance.size('small'); +``` diff --git a/projects/igniteui-angular/avatar/index.ts b/projects/igniteui-angular/avatar/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/avatar/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/avatar/ng-package.json b/projects/igniteui-angular/avatar/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/avatar/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/avatar/src/avatar/avatar.component.html b/projects/igniteui-angular/avatar/src/avatar/avatar.component.html new file mode 100644 index 00000000000..55f188f6fc7 --- /dev/null +++ b/projects/igniteui-angular/avatar/src/avatar/avatar.component.html @@ -0,0 +1,17 @@ + + + + + +
+
+ + + {{initials.substring(0, 2)}} + + + + {{icon}} + + + diff --git a/projects/igniteui-angular/avatar/src/avatar/avatar.component.spec.ts b/projects/igniteui-angular/avatar/src/avatar/avatar.component.spec.ts new file mode 100644 index 00000000000..5f13ec83de7 --- /dev/null +++ b/projects/igniteui-angular/avatar/src/avatar/avatar.component.spec.ts @@ -0,0 +1,229 @@ +import { Component, ViewChild } from '@angular/core'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { IgxAvatarComponent, IgxAvatarType, IgxAvatarSize } from './avatar.component'; + +describe('Avatar', () => { + const baseClass = 'igx-avatar'; + + const classes = { + round: `${baseClass}--rounded`, + circle: `${baseClass}--circle`, + small: `${baseClass}--small`, + medium: `${baseClass}--medium`, + large: `${baseClass}--large`, + image: `${baseClass}--image`, + initials: `${baseClass}--initials`, + icon: `${baseClass}--icon` + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + InitAvatarComponent, + AvatarWithAttribsComponent, + IgxAvatarComponent, + InitIconAvatarComponent, + InitImageAvatarComponent + ] + }).compileComponents(); + })); + + it('Initializes avatar with auto-incremented id', () => { + const fixture = TestBed.createComponent(InitAvatarComponent); + fixture.detectChanges(); + const instance = fixture.componentInstance.avatar; + const hostEl = fixture.debugElement.query(By.css(baseClass)).nativeElement; + + expect(instance.id).toContain('igx-avatar-'); + expect(hostEl.id).toContain('igx-avatar-'); + + instance.id = 'customAvatar'; + fixture.detectChanges(); + + expect(instance.id).toBe('customAvatar'); + expect(hostEl.id).toBe('customAvatar'); + }); + + it('Initializes square and round avatar', () => { + const fixture = TestBed.createComponent(AvatarWithAttribsComponent); + fixture.detectChanges(); + const instance = fixture.componentInstance.avatar; + const hostEl = fixture.debugElement.query(By.css(baseClass)).nativeElement; + + expect(instance.shape).toEqual('square'); + expect(hostEl.classList).not.toContain(classes.circle); + expect(hostEl.classList).not.toContain(classes.round); + + instance.shape = "circle"; + + fixture.detectChanges(); + expect(instance.shape).toEqual('circle'); + expect(hostEl.classList).toContain(classes.circle); + expect(hostEl.classList).not.toContain(classes.round); + + instance.shape = "rounded"; + + fixture.detectChanges(); + expect(instance.shape).toEqual('rounded'); + expect(hostEl.classList).toContain(classes.round); + expect(hostEl.classList).not.toContain(classes.circle); + }); + + it('Can change its size', () => { + const fixture = TestBed.createComponent(AvatarWithAttribsComponent); + fixture.detectChanges(); + const instance = fixture.componentInstance.avatar; + + expect(instance.size).toEqual(IgxAvatarSize.SMALL); + + instance.size = IgxAvatarSize.MEDIUM; + fixture.detectChanges(); + expect(instance.size).toEqual(IgxAvatarSize.MEDIUM); + + instance.size = IgxAvatarSize.LARGE; + fixture.detectChanges(); + expect(instance.size).toEqual(IgxAvatarSize.LARGE); + + instance.size = 'nonsense' as any; + fixture.detectChanges(); + expect(instance.size).toEqual(IgxAvatarSize.SMALL); + }); + + it('Initializes default avatar', () => { + const fixture = TestBed.createComponent(InitAvatarComponent); + fixture.detectChanges(); + + const instance = fixture.componentInstance.avatar; + const hostEl = fixture.debugElement.query(By.css(baseClass)).nativeElement; + + expect(instance.initials).toBeUndefined(); + expect(instance.src).toBeUndefined(); + expect(instance.icon).toBeUndefined(); + expect(instance.shape).toEqual('square'); + + expect(hostEl.textContent).toEqual('TEST'); + }); + + + it('Initializes initials avatar', () => { + const fixture = TestBed.createComponent(AvatarWithAttribsComponent); + fixture.detectChanges(); + + const instance = fixture.componentInstance.avatar; + const hostEl = fixture.debugElement.query(By.css(baseClass)).nativeElement; + + expect(instance.type).toEqual(IgxAvatarType.INITIALS); + expect(instance.initials).toEqual('ZK'); + expect(hostEl.querySelector('span').textContent).toEqual('ZK'); + expect(hostEl.classList).toContain(classes.initials); + }); + + it('Initializes icon avatar', () => { + const fixture = TestBed.createComponent(InitIconAvatarComponent); + fixture.detectChanges(); + const instance = fixture.componentInstance.avatar; + const hostEl = fixture.debugElement.query(By.css(baseClass)).nativeElement; + + expect(instance.type).toEqual(IgxAvatarType.ICON); + expect(instance.icon).toBeTruthy(); + expect(hostEl.classList).toContain(classes.icon); + }); + + it('Initializes image avatar', () => { + const fixture = TestBed.createComponent(InitImageAvatarComponent); + fixture.detectChanges(); + const instance = fixture.componentInstance.avatar; + const hostEl = fixture.debugElement.query(By.css(baseClass)).nativeElement; + + expect(instance.type).toEqual(IgxAvatarType.IMAGE); + const image = instance.elementRef.nativeElement.children[0]; + expect(image).toBeTruthy(); + expect(image.style.backgroundImage).toBeDefined(); + + expect(image.classList).toContain(`${baseClass}__image`); + expect(hostEl.classList).toContain(classes.image); + }); + + it('Sets ARIA attributes', () => { + const fixture = TestBed.createComponent(InitImageAvatarComponent); + fixture.detectChanges(); + const instance = fixture.componentInstance.avatar; + const hostEl = fixture.debugElement.query(By.css(baseClass)).nativeElement; + + expect(instance.roleDescription).toEqual('image avatar'); + expect(hostEl.getAttribute('role')).toEqual('img'); + expect(hostEl.getAttribute('aria-roledescription')).toEqual('image avatar'); + expect(hostEl.getAttribute('aria-label')).toEqual('avatar'); + }); + + it('Normalizes the value of the `src` input', () => { + const fixture = TestBed.createComponent(InitImageAvatarComponent); + fixture.detectChanges(); + + const instance = fixture.componentInstance.avatar; + instance.src = "/assets/Test - 17.jpg"; + fixture.detectChanges(); + + expect(instance.src).toEqual("/assets/Test%20-%2017.jpg"); + }); + + it('should not throw error if src is null', () => { + const fixture = TestBed.createComponent(InitImageAvatarComponent); + fixture.detectChanges(); + expect(() => { + const instance = fixture.componentInstance.avatar; + instance.src = null; + fixture.detectChanges(); + }).not.toThrow(); + }); + + it('avatar with [src] and fallback [initials] should not throw error if src is null', () => { + const fixture = TestBed.createComponent(AvatarWithAttribsComponent); + fixture.detectChanges(); + const instance = fixture.componentInstance.avatar; + expect(instance.type).toEqual(IgxAvatarType.INITIALS); + expect(instance.initials).toEqual('ZK'); + expect(() => { + instance.src = null; + fixture.detectChanges(); + }).not.toThrow(); + }); +}); + +@Component({ + template: `TEST`, + imports: [IgxAvatarComponent] +}) +class InitAvatarComponent { + @ViewChild(IgxAvatarComponent, { static: true }) public avatar: IgxAvatarComponent; +} + +@Component({ + template: ` + `, + imports: [IgxAvatarComponent] +}) +class AvatarWithAttribsComponent { + @ViewChild(IgxAvatarComponent, { static: true }) public avatar: IgxAvatarComponent; + + public initials = 'ZK'; +} + +@Component({ + template: ``, + imports: [IgxAvatarComponent] +}) +class InitIconAvatarComponent { + @ViewChild(IgxAvatarComponent, { static: true }) public avatar: IgxAvatarComponent; +} + +@Component({ + template: ``, + imports: [IgxAvatarComponent] +}) +class InitImageAvatarComponent { + @ViewChild(IgxAvatarComponent, { static: true }) public avatar: IgxAvatarComponent; + + public source = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAkGBxQTEhUUExQWFRUXFRcaFRgXFxcXFBcYFxQXFxcUFxcYHCggGBolHBQUITEhJSkrLi4uFx8zODMsNygtLiwBCgoKDg0OGhAQGi4kHyQsLCwsLCwsLSwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLP/AABEIAK4BIgMBIgACEQEDEQH/xAAbAAACAwEBAQAAAAAAAAAAAAACAwABBAcFBv/EADUQAAICAgADBQYFBAIDAAAAAAABAgMRIQQxQQYSUaPSE1NhcYHwM5GhseEWI8HxQ9EUInP/xAAaAQEBAQEBAQEAAAAAAAAAAAAAAQIDBAYF/8QAMBEBAAECAggEBgIDAAAAAAAAAAECEQNRBRITFCGhotEEFTFTFkGBsdLwQ2EGM1L/2gAMAwEAAhEDEQA/APp+Gj0Hxis8s66iKORppjyz9/UDNxkc70vvkLpo1k9HiK8pff2wa6l+XL4AL9k8LBdcceJqppx/I9UR/jIGateP/X+xtdOzXTw+dDLqsaQGZ0JFxoxyHxTfQdXHYGNQfUKNeDcoC5V8wAUC5LoFXF5I1sBeBiQfsw3EDJKP7GZ0tt6PSugDGIHnrhsfUaquhsjDJbrQGeFAFlKWcnoRgZ3Xlv5gZK68/IXZwia2up6NcNgSgB5tlWvzM74bK3k9WVWmLjD4AeNbw+G8pvwJGjevljw0ev7DYp1Yf3sDPVRjf7cgb6875ffM2NaWRUoged/42zNbQstPoz1FB5F8XVzesa+/2A8mzh8LWV/r9Aqac7+WH9P2NX34gVWY5Y+/tADPhVjo+XiJv4XC8Ovhs9GMtchPEy1j9gPn/Zvx/REPSwvH9ywHV5NtEdY3n6GfhYZfP/s9Hha88/ECpvGt/Uup/uPvgnjAuqADMJL7/wAmmpJ46/EXClfM1RSxhfwA+K8AnV4k4eIedgV7PHIBxNKexGMv6gCkSMeZojAjrASoBKGB0UCwEyQXdG91Bez0AnuC5o0SQkCokkhkYF93IEgtCc4GpYX0Ed3IF8yOAyDAcwF2CMDLoPmLekAFmurE2rwHTeQMcgFxgw3VlBx/wGloBDpaXiIugnp9eaPRxyEOHj/AHlXR1jHUVVwuNvS/we1GCzyFzq2B53VJL/AriasPL59Pv6np2VpI8/iIN/f6AeY/mQNw+C/NlgauHqWXs9GpIzx4br9/IfDQGxQLpq8eQNd6aNUGArGGMiNSRTq8AG1svu7LpRJgV7RoYq1zM0ZZl+xsrXQCgostoGpAW9gsPuk7rApLREF3cAyYFTkhGdhyr6klXyAvJbQUYlyjoBeNMGIcUVKIFd7oLUQ3F9CopgLsQiS6Gh/EUgFKoCcMI1uIiSyAiKZoS1skaw4VgBGJLIrpz6ju7sGaAVJCt9OnIbbB4yVBa8AF2VcjDxED1E29GTio4eFzYHkSofh+hRqnxiTenzIA1yyHE43X2243a9t193V6Bj7a8bj8br7ur0HLbQ4bxTlLsaNdS0cWh2y43X9/X/zq9Bu4bthxvvvLq9BNvSxPi6I+UuwRQ2uXQ5BZ2v433/l1egOntjxvvvLq9A29LO+4eU/v1dgSx9ALVrJymXbXjMNe28ur9P8A0F3dseNwsXeXV6Cbek37Dynl3db4avqaIy2cjr7Z8b3fxvLq9Av+s+Nx+N5dXoG3pTfsPKeXd2VMiici4ftlxuPxvLq9Af8AWHG++8ur0jeKU8ww8p5d3W69gTk84OVw7Y8Yv+by6vSBHtnxjk17b5f26vSN4pTzDDynl3dWjBsYoJHKF2x4z33l1+kj7Ycb77y6/SN4pPMcLKeXd1axCJo5c+2PG++8ur0iru2PG++6+7q9A3ilY0hh5Ty7utxKkjktfbHjffeXV6Rj7YcZ77y6vSN4pPMMPKeXd1PugSZy2XbDjcfjdPd1egzrtjxvvvLq9A3ikjx+HPynl3derRbjo5NDthxuPxvLq9AT7Y8b77y6vSN4pPMMPKeXd1GxAKODlj7Y8b77y6vSIn2z43P43l1egbeld+w8p5d3W0hLicsj2x43f97y6vQA+2HG5/G8ur0Db0rv2HlP79XW4QHYOP19suN995dXoHf1lxmPxvLq9I3ilN+w8p5d3UrGLjE5RZ2y43P43l1egz/1nxy53eXV6C7elqPGUZT+/V1+4VCRyeXbfjPfeXV6DMu2vGvld5dXoG3pWPF0ZT+/V2C2zWvv72Y7Jdfmcon2245f83l1egzS7Z8c+d3l1egbalqPE0ZS6o6WQ5M+2vG+/wDLq9BZdtC7xTlLwYpbHRWRTX7DaY7PM8MnezNVK/JY/MFfm+nzNVVWDLhVVwLnIingK5Y/wSineWRnhY+mDby/oNui9fMKnxYeMvZHKZ4resCHEcl0IodchImy6Fp/UuUvAXF5Wiu/sJYUZ7BfNBKG8jMIF12MOEzPKYyuQSY4DmhMx9ktGfG8ApMhDAdkNA1v7+Ac2En1JkxUYhTkApZDcQYglyA2iNMCTaBnDIMmUregWwormBNY2MhLmBYsBYKqY2MzOuY5V8gspL/Bhtls9P2ejLdDYhaZY2go1t8vqM9n8xsI65FbmpinWYz0LeqPPskjUOtDO5linL4ohp2s114+uTTFGRS2a6UZlyqaOFqfPqbq44BqgMZl5qpvJd3ND0taESSbQzK5ESWiqr4hN+ArhW09jJy8A5z6pnwFTmPr2JvWwR6i4Nc/vmBdF5G0RwiWAvxHQs6L4iKArnhh8U9Bn5s8UUrCq2NSDciTAfiPwLa2GYkuL+I+vlvmKhHfzH8kCpnsiJjHH5j7DOg3BtUs5CxkVBjYMJJEwJL8h9iwIk/9BqB1vmgbZaFwlhjpRC+kslcmmPVouUNjfZcslamw3Y8ASfUOcFgyS546ESIHFvI1S1kTkkp6wVbM/GWb0YO6bO7kX3OeSu1M2hk9n8CDnJeBDTpeWap4xo21Jv4GaETXRokpXL0qlpDqrDFGQ6u3xwZeWaU4iW8kjMXbLQFb+IatwbYPkPseGZqp5HLeEiOUxxF3y5S0XKvYqTwEgMbHyHN7E98tTTCzB8UFY8lVx0SL3gME2R+/v6F1sbZHIuCwGr8DW9ASlsFvoDNhIg+sOYuOQZ29AluIbAYxz9STJVPIa+SlH+A60DWv4GwjpgmSL5dBVstYKtt2JTyw6RCI0SsEOPMuMtYYWTKZbHuejFVIZHl9/mVJhdthl9rsOd2/oZpL9w3TSc55+hS3zLjHKJLGApdj54ESbwNkt/BguOStwyYZZGmWadB11/H9B1cPiJbHQkeOcSvN9vGh/BT60c6u7RFl6YuIRz2tebfkfgPb6qu60yRr8Acj0Jxq4+bUaC8BP8fVV3SttGim1pCE9h97BicfEzXyDR8+uH1Vd2n2vUCc8gRkU2Tb4ma/D2jvb6qvySEw4y+AokJDbYmafD+jvb6qvyaoXEV2HyEqReSbfEzX4e0b7XVV+TQ789BU5lJi5sRj4mf2X4d0bH8XVV+Se02X3xUBiLOPXmkf4/o72+qru0q3C5Coz5skmKkyRj4mf2Wf8e0bH8XVV+QpWgqxpARCuRrb15s/D+j7f6+qrudGeg5W/AVHkWuRnb4mf2a+HdHe11VfkR4/MpayFPxKfI1t6808g0f7fVV3DnwKUvELAt6Ltq80nQPgPb6qu461hhWPohff0DKehtcTNJ0Fo/2+qruTNbKrTI+ZcUdNrXmx5J4H2+dXcyM2DLZUi1Im1rzXyPwHt86u5Tj8QIa6jLEB3DUYtWbM6F8F7fOruTKD8f0KG5RDW1qZ8n8H/wAc6u5Q6Bm74yqZmqH6FNUXa0y3ICMi4o5Wei62aIMzjq3ozU1T6jbCi8gBIw6Qtg94JoXJiCR94iyVFh4CoEgMssimxYFjy9cyrJYRdKwsvmyf2t78FxgW2ScxcpdPEE8DO9kt8i+7oFkUEdMpvLI46KizTI3IYuQlstSJZbrbFOWAmxNkjUQxVJzYi2QXe8BbNRDNUrKSKGRax8SyzHFXdBehjA5iFkMpEyXMCxFhmVTYEpAymAzpEOVVQXMgpkOlnDWkOBsGJTGxLLNJ7ngOqWTOpDVM5zDvTVxOYUJaFNhpmJh1iTnIODM7mHCRiYbiri0d4TORIMqSJENTN4FCwZCwzA94urdnXs1xmW7DNB5LfgTVa1+Bzl3sDnYZ4vCBdhLXWKrGzmVB7FyKSLbgl+LXCZYiuQffMTDpEiyJTDUhedliGZkeSgO8WmWyXRgSDmxUmahmoUWFjItbCcxMJEhRGyrJld41Zm562gO8VGzWALrDMRxamqLXXkCbKjYD7Y3aXOaosXIXNjZS6iLpnWlwrmxbkQU7PiQ66rza6h9b0Z0x0BUUepr5FxnoGbEqzZmIu6TVaW+DLczJG3P5jFPRzml1jEua7Ao2GRyYUJbLNJGJxehGRUmKU+RcrGctV31uA++C5GedocJGtWzGvfgdCQcZ7yZe+/1GqZJpaprPlPIrvYAUy5sllmq5qsyFCZkUmWrOY1EjEbLJC3YIVhVmxFKziZNVdyF22b+ZmjJh9/KLqWlNpeLHxmTviURMmqa0nuzJWRLkU5l1TXaFIpyExb6lSmNU1zLXnDAQtTySTNavyY1r8TZT2KmLcyKeixTZia7md4WwJyKNxDE1JOYu2RU5AW9DcQ41VAILafiQ62efW/p//9k='; +} diff --git a/projects/igniteui-angular/avatar/src/avatar/avatar.component.ts b/projects/igniteui-angular/avatar/src/avatar/avatar.component.ts new file mode 100644 index 00000000000..91ead023fea --- /dev/null +++ b/projects/igniteui-angular/avatar/src/avatar/avatar.component.ts @@ -0,0 +1,358 @@ +import { NgTemplateOutlet } from '@angular/common'; +import { Component, ElementRef, HostBinding, Input, OnInit, TemplateRef, ViewChild, inject } from '@angular/core'; + +import { normalizeURI } from 'igniteui-angular/core'; +import { IgxIconComponent } from 'igniteui-angular/icon'; + +let NEXT_ID = 0; +export const IgxAvatarSize = { + SMALL: 'small', + MEDIUM: 'medium', + LARGE: 'large' +} as const; +export type IgxAvatarSize = (typeof IgxAvatarSize)[keyof typeof IgxAvatarSize]; + +export const IgxAvatarType = { + INITIALS: 'initials', + IMAGE: 'image', + ICON: 'icon', + CUSTOM: 'custom' +} as const; +export type IgxAvatarType = (typeof IgxAvatarType)[keyof typeof IgxAvatarType]; + +/** + * Avatar provides a way to display an image, icon or initials to the user. + * + * @igxModule IgxAvatarModule + * + * @igxTheme igx-avatar-theme, igx-icon-theme + * + * @igxKeywords avatar, profile, picture, initials + * + * @igxGroup Layouts + * + * @remarks + * + * The Ignite UI Avatar provides an easy way to add an avatar icon to your application. This icon can be an + * image, someone's initials or a material icon from the Google Material icon set. + * + * @example + * ```html + * + * + * ``` + */ +@Component({ + selector: 'igx-avatar', + templateUrl: 'avatar.component.html', + imports: [IgxIconComponent, NgTemplateOutlet] +}) +export class IgxAvatarComponent implements OnInit { + public elementRef = inject(ElementRef); + + /** + * Returns the `aria-label` attribute of the avatar. + * + * @example + * ```typescript + * let ariaLabel = this.avatar.ariaLabel; + * ``` + * + */ + @HostBinding('attr.aria-label') + public ariaLabel = 'avatar'; + + /** + * Returns the `role` attribute of the avatar. + * + * @example + * ```typescript + * let avatarRole = this.avatar.role; + * ``` + */ + @HostBinding('attr.role') + public role = 'img'; + + /** + * Host `class.igx-avatar` binding. + * + * @hidden + * @internal + */ + @HostBinding('class.igx-avatar') + public cssClass = 'igx-avatar'; + + /** + * Returns the type of the avatar. + * The avatar can be: + * - `"initials type avatar"` + * - `"icon type avatar"` + * - `"image type avatar"`. + * - `"custom type avatar"`. + * + * @example + * ```typescript + * let avatarDescription = this.avatar.roleDescription; + * ``` + */ + @HostBinding('attr.aria-roledescription') + public roleDescription: string; + + /** + * Sets the `id` of the avatar. If not set, the first avatar component will have `id` = `"igx-avatar-0"`. + * + * @example + * ```html + * + * ``` + */ + @HostBinding('attr.id') + @Input() + public id = `igx-avatar-${NEXT_ID++}`; + + /** + * Sets square, rounded or circular shape to the avatar. + * By default the shape of the avatar is square. + * + * @example + * ```html + * + * ``` + */ + @Input() + public shape: 'square' | 'rounded' | 'circle' = 'square'; + + /** @hidden @internal */ + @HostBinding('class.igx-avatar--rounded') + public get isRounded(): boolean { + return this.shape === 'rounded'; + } + + /** @hidden @internal */ + @HostBinding('class.igx-avatar--circle') + public get isCircle(): boolean { + return this.shape === 'circle'; + } + + /** + * Sets the color of the avatar's initials or icon. + * + * @example + * ```html + * + * ``` + * @deprecated in version 17.2.0. + */ + + @HostBinding('style.color') + @Input() + public color: string; + + /** + * Sets the background color of the avatar. + * + * @example + * ```html + * + * ``` + * @igxFriendlyName Background color + * @deprecated in version 17.2.0. + */ + + @HostBinding('style.background') + @Input() + public bgColor: string; + + /** + * Sets initials to the avatar. + * + * @example + * ```html + * + * ``` + */ + @Input() + public initials: string; + + /** + * Sets an icon to the avatar. All icons from the material icon set are supported. + * + * @example + * ```html + * + * ``` + */ + @Input() + public icon: string; + + /** + * Sets the image source of the avatar. + * + * @example + * ```html + * + * ``` + * @igxFriendlyName Image URL + */ + @Input() + public set src(value: string) { + this._src = normalizeURI(value); + } + + public get src() { + return this._src; + } + + /** @hidden @internal */ + @ViewChild('defaultTemplate', { read: TemplateRef, static: true }) + protected defaultTemplate: TemplateRef; + + /** @hidden @internal */ + @ViewChild('imageTemplate', { read: TemplateRef, static: true }) + protected imageTemplate: TemplateRef; + + /** @hidden @internal */ + @ViewChild('initialsTemplate', { read: TemplateRef, static: true }) + protected initialsTemplate: TemplateRef; + + /** @hidden @internal */ + @ViewChild('iconTemplate', { read: TemplateRef, static: true }) + protected iconTemplate: TemplateRef; + + /** + * @hidden + * @internal + */ + private _size: string | IgxAvatarSize; + private _src: string; + + /** + * Returns the size of the avatar. + * + * @example + * ```typescript + * let avatarSize = this.avatar.size; + * ``` + */ + @Input() + public get size(): string | IgxAvatarSize { + return this._size || IgxAvatarSize.SMALL; + } + + /** + * Sets the size of the avatar. + * By default, the size is `"small"`. It can be set to `"medium"` or `"large"`. + * + * @example + * ```html + * + * ``` + */ + public set size(value: string | IgxAvatarSize) { + switch (value) { + case 'small': + case 'medium': + case 'large': + this._size = value; + break; + default: + this._size = 'small'; + } + } + + /** + * Returns the type of the avatar. + * + * @example + * ```typescript + * let avatarType = this.avatar.type; + * ``` + */ + public get type(): IgxAvatarType { + if (this.src) { + return IgxAvatarType.IMAGE; + } + + if (this.icon) { + return IgxAvatarType.ICON; + } + + if (this.initials) { + return IgxAvatarType.INITIALS; + } + + return IgxAvatarType.CUSTOM; + } + + /** @hidden @internal */ + @HostBinding('class.igx-avatar--image') + public get _isImageType(): boolean { + return this.type === IgxAvatarType.IMAGE; + } + /** @hidden @internal */ + @HostBinding('class.igx-avatar--icon') + public get _isIconType(): boolean { + return this.type === IgxAvatarType.ICON; + } + /** @hidden @internal */ + @HostBinding('class.igx-avatar--initials') + public get _isInitialsType(): boolean { + return this.type === IgxAvatarType.INITIALS; + } + + @HostBinding('style.--component-size') + protected get componentSize() { + if (this._size) { + return `var(--ig-size-${this._size})`; + } + } + + /** + * Returns the template of the avatar. + * + * @hidden + * @internal + */ + public get template(): TemplateRef { + switch (this.type) { + case IgxAvatarType.IMAGE: + return this.imageTemplate; + case IgxAvatarType.INITIALS: + return this.initialsTemplate; + case IgxAvatarType.ICON: + return this.iconTemplate; + default: + return this.defaultTemplate; + } + } + + /** + * Returns the css url of the image. + * + * @hidden + * @internal + */ + public getSrcUrl() { + return `url("${this.src}")`; + } + + /** @hidden @internal */ + public ngOnInit() { + this.roleDescription = this.getRole(); + } + + /** @hidden @internal */ + private getRole(): string { + switch (this.type) { + case IgxAvatarType.IMAGE: + return 'image avatar'; + case IgxAvatarType.ICON: + return 'icon avatar'; + case IgxAvatarType.INITIALS: + return 'initials avatar'; + default: + return 'custom avatar'; + } + } +} + diff --git a/projects/igniteui-angular/avatar/src/avatar/avatar.module.ts b/projects/igniteui-angular/avatar/src/avatar/avatar.module.ts new file mode 100644 index 00000000000..d2742d06293 --- /dev/null +++ b/projects/igniteui-angular/avatar/src/avatar/avatar.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { IgxAvatarComponent } from './avatar.component'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [IgxAvatarComponent], + exports: [IgxAvatarComponent] +}) +export class IgxAvatarModule { } diff --git a/projects/igniteui-angular/avatar/src/avatar/public_api.ts b/projects/igniteui-angular/avatar/src/avatar/public_api.ts new file mode 100644 index 00000000000..a1ddb6ffbc4 --- /dev/null +++ b/projects/igniteui-angular/avatar/src/avatar/public_api.ts @@ -0,0 +1 @@ +export * from './avatar.component'; diff --git a/projects/igniteui-angular/avatar/src/public_api.ts b/projects/igniteui-angular/avatar/src/public_api.ts new file mode 100644 index 00000000000..982097cc64b --- /dev/null +++ b/projects/igniteui-angular/avatar/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './avatar/public_api'; +export * from './avatar/avatar.module' diff --git a/projects/igniteui-angular/badge/README.md b/projects/igniteui-angular/badge/README.md new file mode 100644 index 00000000000..a342c2e09c6 --- /dev/null +++ b/projects/igniteui-angular/badge/README.md @@ -0,0 +1,46 @@ +# igx-badge + +The **igx-badge** component is an absolutely positioned element that can be used in tandem with other components such as avatars, navigation menus, or anywhere else in an app where some active indication is required. +With the igx-badge you can display active count or an icon in several different predefined styles and sizes. +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/badge.html) + +# Usage +```html + +``` + +# API Summary +| Name | Type | Description | +|:----------|:-------------:|:------| +| `id` | string | Unique identifier of the component. If not provided it will be automatically generated.| +| `type` | string | Set the type of the badge to either `primary`, `info`, `success`, `warning`, or `error`. This will change the background color of the badge according to the values set in the default theme. | +| `dot` | boolean | Set whether the badge is displayed as a minimal dot indicator without any content. Default is `false`. | +| `position` | string | Set the position of the badge relative to its parent container to either `top-right`, `top-left`, `bottom-right`, or `bottom-left`. | +| `value` | string | Set the value to be displayed inside the badge. | +| `icon` | string | Set an icon for the badge from the material icons set. Will not be displayed if `value` for the badge is already set. | +| `outlined` | boolean | Set whether the badge should have an outline. Default is `false`. | +| `shape` | string | Set the shape of the badge to either `rounded` or `square`. Default is `rounded`. | + +# Examples + +Using `igx-badge` with the `igx-avatar` component to show active status. +```html + + + +``` + +Using `igx-badge` as a dot indicator for notifications. +```html + + +``` + +Using different badge types. +```html + + + + + +``` diff --git a/projects/igniteui-angular/badge/index.ts b/projects/igniteui-angular/badge/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/badge/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/badge/ng-package.json b/projects/igniteui-angular/badge/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/badge/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/badge/src/badge/badge.component.html b/projects/igniteui-angular/badge/src/badge/badge.component.html new file mode 100644 index 00000000000..14451e6100d --- /dev/null +++ b/projects/igniteui-angular/badge/src/badge/badge.component.html @@ -0,0 +1,10 @@ +@if (value || value === 0 && !icon) { + {{value}} +} +@if (icon && !iconSet) { + {{icon}} +} +@if (icon && iconSet) { + {{icon}} +} + diff --git a/projects/igniteui-angular/badge/src/badge/badge.component.spec.ts b/projects/igniteui-angular/badge/src/badge/badge.component.spec.ts new file mode 100644 index 00000000000..6d398d5e871 --- /dev/null +++ b/projects/igniteui-angular/badge/src/badge/badge.component.spec.ts @@ -0,0 +1,151 @@ +import { Component, ViewChild } from '@angular/core'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { IgxBadgeComponent, IgxBadgeType } from './badge.component'; + +describe('Badge', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + InitBadgeComponent, + InitBadgeWithDefaultsComponent, + InitBadgeWithIconComponent, + IgxBadgeComponent, + InitBadgeWithIconARIAComponent, + InitBadgeWithDotComponent + ] + }).compileComponents(); + })); + + it('Initializes outlined badge of type error', () => { + const fixture = TestBed.createComponent(InitBadgeComponent); + fixture.detectChanges(); + const badge = fixture.componentInstance.badge; + + expect(badge.value).toBeTruthy(); + expect(badge.type).toBeTruthy(); + expect(badge.outlined).toBeTruthy(); + + expect(fixture.debugElement.query(By.css('.igx-badge'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('.igx-badge--error'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('.igx-badge--outlined'))).toBeTruthy(); + + expect(badge.value).toMatch('22'); + expect(badge.type).toMatch('error'); + }); + + it('Initializes badge with id', () => { + const fixture = TestBed.createComponent(InitBadgeComponent); + fixture.detectChanges(); + const badge = fixture.componentInstance.badge; + const domBadge = fixture.debugElement.query(By.css('igx-badge')).nativeElement; + + expect(badge.id).toContain('igx-badge-'); + expect(domBadge.id).toContain('igx-badge-'); + + badge.id = 'customBadge'; + fixture.detectChanges(); + + expect(badge.id).toBe('customBadge'); + expect(domBadge.id).toBe('customBadge'); + }); + + it('Initializes badge defaults', () => { + const fixture = TestBed.createComponent(InitBadgeWithDefaultsComponent); + fixture.detectChanges(); + const badge = fixture.componentInstance.badge; + + expect(badge.value).toMatch(''); + expect(badge.icon).toBeFalsy(); + expect(badge.outlined).toBeFalsy(); + + expect(fixture.debugElement.query(By.css('.igx-badge'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('.igx-badge--icon'))).toBeFalsy(); + expect(fixture.debugElement.query(By.css('.igx-badge--outlined'))).toBeFalsy(); + }); + + it('Initializes badge with icon', () => { + const fixture = TestBed.createComponent(InitBadgeWithIconComponent); + fixture.detectChanges(); + const badge = fixture.componentInstance.badge; + + expect(badge.icon === 'person').toBeTruthy(); + expect(badge.type === IgxBadgeType.INFO).toBeTruthy(); + expect(badge.value === '').toBeTruthy(); + + expect(fixture.debugElement.query(By.css('.igx-badge'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('.igx-badge--info'))).toBeTruthy(); + }); + + it('Initializes badge with icon ARIA', () => { + const fixture = TestBed.createComponent(InitBadgeWithIconARIAComponent); + fixture.detectChanges(); + const badge = fixture.componentInstance.badge; + + const expectedDescription = `${badge.type} type badge with icon type ${badge.icon}`; + expect(badge.roleDescription).toMatch(expectedDescription); + + const container = fixture.nativeElement.querySelectorAll('.igx-badge')[0]; + expect(container.getAttribute('aria-roledescription')).toMatch(expectedDescription); + }); + + it('Initializes badge with dot property', () => { + const fixture = TestBed.createComponent(InitBadgeWithDotComponent); + fixture.detectChanges(); + const badge = fixture.componentInstance.badge; + + expect(badge.dot).toBeTruthy(); + expect(fixture.debugElement.query(By.css('.igx-badge--dot'))).toBeTruthy(); + }); + + it('Initializes success badge as dot', () => { + const fixture = TestBed.createComponent(InitBadgeWithDotComponent); + fixture.detectChanges(); + const badge = fixture.componentInstance.badge; + + expect(badge.type).toBe(IgxBadgeType.SUCCESS); + expect(badge.dot).toBeTruthy(); + expect(fixture.debugElement.query(By.css('.igx-badge--dot'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('.igx-badge--success'))).toBeTruthy(); + }); +}); + +@Component({ + template: ``, + imports: [IgxBadgeComponent] +}) +class InitBadgeComponent { + @ViewChild(IgxBadgeComponent, { static: true }) public badge: IgxBadgeComponent; +} + +@Component({ + template: ``, + imports: [IgxBadgeComponent] +}) +class InitBadgeWithDefaultsComponent { + @ViewChild(IgxBadgeComponent, { static: true }) public badge: IgxBadgeComponent; +} + +@Component({ + template: ``, + imports: [IgxBadgeComponent] +}) +class InitBadgeWithIconComponent { + @ViewChild(IgxBadgeComponent, { static: true }) public badge: IgxBadgeComponent; +} + +@Component({ + template: ``, + imports: [IgxBadgeComponent] +}) +class InitBadgeWithIconARIAComponent { + @ViewChild(IgxBadgeComponent, { static: true }) public badge: IgxBadgeComponent; +} + +@Component({ + template: ``, + imports: [IgxBadgeComponent] +}) +class InitBadgeWithDotComponent { + @ViewChild(IgxBadgeComponent, { static: true }) public badge: IgxBadgeComponent; +} diff --git a/projects/igniteui-angular/badge/src/badge/badge.component.ts b/projects/igniteui-angular/badge/src/badge/badge.component.ts new file mode 100644 index 00000000000..99baa5ae77e --- /dev/null +++ b/projects/igniteui-angular/badge/src/badge/badge.component.ts @@ -0,0 +1,238 @@ +import { booleanAttribute, Component, HostBinding, Input } from '@angular/core'; +import { IgxIconComponent } from 'igniteui-angular/icon'; + +let NEXT_ID = 0; + +/** + * Determines the igxBadge type + */ +export const IgxBadgeType = { + PRIMARY: 'primary', + INFO: 'info', + SUCCESS: 'success', + WARNING: 'warning', + ERROR: 'error' +} as const; +export type IgxBadgeType = (typeof IgxBadgeType)[keyof typeof IgxBadgeType]; +/** + * Badge provides visual notifications used to decorate avatars, menus, etc. + * + * @igxModule IgxBadgeModule + * + * @igxTheme igx-badge-theme + * + * @igxKeywords badge, icon, notification + * + * @igxGroup Data Entry & Display + * + * @remarks + * The Ignite UI Badge is used to decorate avatars, navigation menus, or other components in the + * application when visual notification is needed. They are usually designed as icons with a predefined + * style to communicate information, success, warnings, or errors. + * + * @example + * ```html + * + * + * + */ +@Component({ + selector: 'igx-badge', + templateUrl: 'badge.component.html', + imports: [IgxIconComponent] +}) +export class IgxBadgeComponent { + + /** + * Sets/gets the `id` of the badge. + * + * @remarks + * If not set, the `id` will have value `"igx-badge-0"`. + * + * @example + * ```html + * + * ``` + */ + @HostBinding('attr.id') + @Input() + public id = `igx-badge-${NEXT_ID++}`; + + /** + * Sets/gets the type of the badge. + * + * @remarks + * Allowed values are `primary`, `info`, `success`, `warning`, `error`. + * Providing an invalid value won't display a badge. + * + * @example + * ```html + * + * ``` + */ + @Input() + public type: string | IgxBadgeType = IgxBadgeType.PRIMARY; + + /** + * Sets/gets the value to be displayed inside the badge. + * + * @remarks + * If an `icon` property is already set the `icon` will be displayed. + * If neither a `value` nor an `icon` is set the content of the badge will be empty. + * + * @example + * ```html + * + * ``` + */ + @Input() + public value: string | number = ''; + + /** + * Sets/gets an icon for the badge from the material icons set. + * + * @remarks + * Has priority over the `value` property. + * If neither a `value` nor an `icon` is set the content of the badge will be empty. + * Providing an invalid value won't display anything. + * + * @example + * ```html + * + * ``` + */ + @Input() + public icon: string; + + /** + * The name of the icon set. Used in case the icon is from a different icon set. + */ + @Input() + public iconSet: string; + + /** + * Sets/gets the role attribute value. + * + * @example + * ```typescript + * @ViewChild("MyBadge", { read: IgxBadgeComponent }) + * public badge: IgxBadgeComponent; + * + * badge.role = 'status'; + * ``` + */ + @HostBinding('attr.role') + public role = 'status'; + + /** + * Sets/gets the css class to use on the badge. + * + * @example + * ```typescript + * @ViewChild("MyBadge", { read: IgxBadgeComponent }) + * public badge: IgxBadgeComponent; + * + * badge.cssClass = 'my-badge-class'; + * ``` + */ + @HostBinding('class.igx-badge') + public cssClass = 'igx-badge'; + + /** + * Sets a square shape to the badge, if `shape` is set to `square`. + * By default the shape of the badge is rounded. + * + * @example + * ```html + * + * ``` + */ + @Input() + public shape: 'rounded' | 'square' = 'rounded'; + + /** @hidden @internal */ + @HostBinding('class.igx-badge--square') + public get _squareShape(): boolean { + if (!this.dot) { + return this.shape === 'square'; + } + } + + /** + * Sets/gets the aria-label attribute value. + * + * @example + * ```typescript + * @ViewChild("MyBadge", { read: IgxBadgeComponent }) + * public badge: IgxBadgeComponent; + * + * badge.label = 'badge'; + * ``` + */ + @HostBinding('attr.aria-label') + public label = 'badge'; + + /** + * Sets/gets whether the badge is outlined. + * Default value is `false`. + * + * @example + * ```html + * + * ``` + */ + @Input({transform: booleanAttribute}) + @HostBinding('class.igx-badge--outlined') + public outlined = false; + + /** + * Sets/gets whether the badge is displayed as a dot. + * When true, the badge will be rendered as a minimal 8px indicator without any content. + * Default value is `false`. + * + * @example + * ```html + * + * ``` + */ + @Input({transform: booleanAttribute}) + @HostBinding('class.igx-badge--dot') + public dot = false; + + /** + * Defines a human-readable, accessor, author-localized description for + * the `type` and the `icon` or `value` of the element. + * + * @hidden + * @internal + */ + @HostBinding('attr.aria-roledescription') + public get roleDescription() { + if (this.icon) { + return this.type + ' type badge with icon type ' + this.icon; + } else if (this.value || this.value === 0) { + return this.type + ' badge type with value ' + this.value; + } + return this.type + ' badge type without value'; + } + + @HostBinding('class.igx-badge--info') + public get infoClass() { + return this.type === IgxBadgeType.INFO; + } + + @HostBinding('class.igx-badge--success') + public get successClass() { + return this.type === IgxBadgeType.SUCCESS; + } + + @HostBinding('class.igx-badge--warning') + public get warningClass() { + return this.type === IgxBadgeType.WARNING; + } + + @HostBinding('class.igx-badge--error') + public get errorClass() { + return this.type === IgxBadgeType.ERROR; + } +} diff --git a/projects/igniteui-angular/badge/src/badge/badge.module.ts b/projects/igniteui-angular/badge/src/badge/badge.module.ts new file mode 100644 index 00000000000..c114ff7d4a3 --- /dev/null +++ b/projects/igniteui-angular/badge/src/badge/badge.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { IgxBadgeComponent } from './badge.component'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + exports: [IgxBadgeComponent], + imports: [IgxBadgeComponent] +}) +export class IgxBadgeModule { } diff --git a/projects/igniteui-angular/badge/src/badge/public_api.ts b/projects/igniteui-angular/badge/src/badge/public_api.ts new file mode 100644 index 00000000000..613ec25be2e --- /dev/null +++ b/projects/igniteui-angular/badge/src/badge/public_api.ts @@ -0,0 +1 @@ +export * from './badge.component'; diff --git a/projects/igniteui-angular/badge/src/public_api.ts b/projects/igniteui-angular/badge/src/public_api.ts new file mode 100644 index 00000000000..5a19e39988b --- /dev/null +++ b/projects/igniteui-angular/badge/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './badge/public_api'; +export * from './badge/badge.module' diff --git a/projects/igniteui-angular/banner/README.md b/projects/igniteui-angular/banner/README.md new file mode 100644 index 00000000000..0d4b32ba8bd --- /dev/null +++ b/projects/igniteui-angular/banner/README.md @@ -0,0 +1,50 @@ +# igx-banner + +**igx-banner** supports banner component that is shown at the full width of the screen above the app content but below a Navigation Bar if available. A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/banner.html) + +# Usage +```html + + This is default template's message! + +``` + +# API Summary + +### Inputs + +Inputs available on the **IgxBanner**: + +| Name | Type | Description | +|---------------------|:-------------:|----------------------------------------------------------| +| `animationSettings` | `{ openAnimation: AnimationRefMetadata, closeAnimation: AnimationRefMetadata }` | Sets the open / close animations for the banner. | + + +### Outputs + +A list of the events emitted by the **IgxBanner**: + +| Name | Description | Cancelable | +|---------------------|--------------------------------------------------------------------------|------------| +| `opening` | Fires before the banner is opened | `true` | +| `opened` | Fires after the banner is opened | `false` | +| `closing` | Fire before the banner is closed | `true` | +| `closed` | Fires after the banner is closed | `false`| + +### Getters + +Getters available on the **IgxBanner**: + +| Name | Type | Getter | Setter | Description | +|---------------------|:-------------:|:------:|:------:|----------------------------------------| +| `collapsed` | boolean | Yes | No |Gets whether `igx-banner` is collapsed. | + +### Methods + +Here is a list of all public methods exposed by **IgxBanner**: + +| Signature | Description | +|---------------------|--------------------------------------------------------------------------| +| `open()` | Opens the banner | +| `close()` | Closes the banner | +| `toggle()` | Toggles the banner | diff --git a/projects/igniteui-angular/banner/index.ts b/projects/igniteui-angular/banner/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/banner/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/banner/ng-package.json b/projects/igniteui-angular/banner/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/banner/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/banner/src/banner/banner.component.html b/projects/igniteui-angular/banner/src/banner/banner.component.html new file mode 100644 index 00000000000..d8cf0bb0adf --- /dev/null +++ b/projects/igniteui-angular/banner/src/banner/banner.component.html @@ -0,0 +1,26 @@ + + +
+
+ @if (bannerIcon) { +
+ +
+ } + + + +
+
+ @if (useDefaultTemplate) { + + } @else { + + } +
+
+
+
diff --git a/projects/igniteui-angular/banner/src/banner/banner.component.spec.ts b/projects/igniteui-angular/banner/src/banner/banner.component.spec.ts new file mode 100644 index 00000000000..0dad3dffeab --- /dev/null +++ b/projects/igniteui-angular/banner/src/banner/banner.component.spec.ts @@ -0,0 +1,657 @@ +import { Component, ViewChild, DebugElement } from '@angular/core'; +import { TestBed, ComponentFixture, tick, fakeAsync, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { IgxBannerComponent } from './banner.component'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxBannerActionsDirective } from './banner.directives'; +import { IgxCardComponent, IgxCardContentDirective, IgxCardHeaderComponent } from 'igniteui-angular/card'; +import { IgxAvatarComponent } from 'igniteui-angular/avatar'; + +const CSS_CLASS_EXPANSION_PANEL = 'igx-expansion-panel'; +const CSS_CLASS_EXPANSION_PANEL_BODY = 'igx-expansion-panel__body'; +const CSS_CLASS_BANNER = 'igx-banner'; +const CSS_CLASS_BANNER_MESSAGE = 'igx-banner__message'; +const CSS_CLASS_BANNER_ILLUSTRATION = 'igx-banner__illustration'; +const CSS_CLASS_BANNER_TEXT = 'igx-banner__text'; +const CSS_CLASS_BANNER_ACTIONS = 'igx-banner__actions'; + +describe('igxBanner', () => { + let bannerElement: DebugElement = null; + let bannerMessageElement: DebugElement = null; + let bannerIllustrationElement: DebugElement = null; + let bannerTextElement: DebugElement = null; + let bannerActionsElement: DebugElement = null; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxBannerEmptyComponent, + IgxBannerOneButtonComponent, + IgxBannerSampleComponent, + IgxBannerCustomTemplateComponent, + SimpleBannerEventsComponent, + IgxBannerInitializedOpenComponent + ] + }).compileComponents(); + })); + + describe('General tests: ', () => { + it('Should initialize properly banner component with empty template', () => { + const fixture = TestBed.createComponent(IgxBannerEmptyComponent); + fixture.detectChanges(); + const banner = fixture.componentInstance.banner; + expect(fixture.componentInstance).toBeDefined(); + expect(banner).toBeDefined(); + expect(banner.collapsed).toBeTruthy(); + expect(banner.useDefaultTemplate).toBeTruthy(); + }); + + it(`Should properly initialize banner component with message`, () => { + const fixture = TestBed.createComponent(SimpleBannerEventsComponent); + fixture.detectChanges(); + const banner = fixture.componentInstance.banner; + expect(fixture.componentInstance).toBeDefined(); + expect(banner).toBeDefined(); + expect(banner.collapsed).toBeTruthy(); + expect(banner.useDefaultTemplate).toBeTruthy(); + banner.toggle(); + const bannerMessage = banner.element.querySelector('.' + CSS_CLASS_BANNER_TEXT); + expect(bannerMessage.innerHTML.trim()).toEqual('Simple message'); + }); + + it('Should initialize properly banner component with message and a button', () => { + const fixture = TestBed.createComponent(IgxBannerOneButtonComponent); + fixture.detectChanges(); + const banner = fixture.componentInstance.banner; + expect(fixture.componentInstance).toBeDefined(); + expect(banner).toBeDefined(); + expect(banner.collapsed).toBeTruthy(); + expect(banner.useDefaultTemplate).toBeFalsy(); + banner.toggle(); + const bannerMessage = banner.element.querySelector('.' + CSS_CLASS_BANNER_TEXT); + expect(bannerMessage.innerHTML.trim()).toEqual('You have lost connection to the internet.'); + const button = banner.element.querySelector('button'); + expect(button.innerHTML).toEqual('TURN ON WIFI'); + }); + + it('Should initialize properly banner component with message and buttons', () => { + const fixture: ComponentFixture = TestBed.createComponent(IgxBannerSampleComponent); + fixture.detectChanges(); + const banner = fixture.componentInstance.banner; + expect(fixture.componentInstance).toBeDefined(); + expect(banner).toBeDefined(); + expect(banner.collapsed).toBeTruthy(); + expect(banner.useDefaultTemplate).toBeFalsy(); + banner.toggle(); + const bannerMessage = banner.element.querySelector('.' + CSS_CLASS_BANNER_TEXT); + expect(bannerMessage.innerHTML.trim()).toEqual('Unfortunately, the credit card did not go through, please try again.'); + const buttons = banner.element.querySelectorAll('button'); + expect(buttons[0].innerHTML).toEqual('UPDATE'); + expect(buttons[1].innerHTML).toEqual('DISMISS'); + }); + + it('Should properly set base classes', fakeAsync(() => { + const fixture: ComponentFixture = TestBed.createComponent(IgxBannerSampleComponent); + fixture.detectChanges(); + + getBaseClassElements(fixture); + + expect(bannerElement).toBeNull(); + expect(bannerMessageElement).toBeNull(); + expect(bannerIllustrationElement).toBeNull(); + expect(bannerTextElement).toBeNull(); + expect(bannerActionsElement).toBeNull(); + + const banner = fixture.componentInstance.banner; + banner.open(); + tick(); + fixture.detectChanges(); + + getBaseClassElements(fixture); + + expect(bannerElement).toBeDefined(); + expect(bannerMessageElement).toBeDefined(); + expect(bannerIllustrationElement).toBeDefined(); + expect(bannerTextElement).toBeDefined(); + expect(bannerActionsElement).toBeDefined(); + })); + + it('Should initialize banner with at least one and up to two buttons', fakeAsync(() => { + const fixture: ComponentFixture = TestBed.createComponent(IgxBannerSampleComponent); + fixture.detectChanges(); + + getBaseClassElements(fixture); + + expect(bannerElement).toBeNull(); + expect(bannerMessageElement).toBeNull(); + expect(bannerIllustrationElement).toBeNull(); + expect(bannerTextElement).toBeNull(); + expect(bannerActionsElement).toBeNull(); + + const banner = fixture.componentInstance.banner; + banner.open(); + tick(); + fixture.detectChanges(); + + getBaseClassElements(fixture); + + expect(bannerElement).not.toBeNull(); + expect(bannerMessageElement).not.toBeNull(); + expect(bannerIllustrationElement).not.toBeNull(); + expect(bannerTextElement).not.toBeNull(); + expect(bannerActionsElement).not.toBeNull(); + + banner.close(); + tick(); + fixture.detectChanges(); + + getBaseClassElements(fixture); + + expect(bannerElement).toBeNull(); + expect(bannerMessageElement).toBeNull(); + expect(bannerIllustrationElement).toBeNull(); + expect(bannerTextElement).toBeNull(); + expect(bannerActionsElement).toBeNull(); + })); + + it('Should position buttons next to the banner content', fakeAsync(() => { + const fixture: ComponentFixture = TestBed.createComponent(IgxBannerSampleComponent); + fixture.detectChanges(); + + const banner: IgxBannerComponent = fixture.componentInstance.banner; + banner.open(); + tick(); + fixture.detectChanges(); + + getBaseClassElements(fixture); + + const bannerMessageElementTop = bannerMessageElement.nativeElement.getClientRects().y; + const bannerActionsElementTop = bannerActionsElement.nativeElement.getClientRects().y; + + expect(bannerMessageElementTop).toBe(bannerActionsElementTop); + })); + + it('Should span the entire width of the parent element', fakeAsync(() => { + const fixture: ComponentFixture = TestBed.createComponent(IgxBannerOneButtonComponent); + fixture.detectChanges(); + + const banner: IgxBannerComponent = fixture.componentInstance.banner; + banner.open(); + tick(); + fixture.detectChanges(); + + getBaseClassElements(fixture); + + const parentElement = fixture.debugElement.query(By.css('#wrapper')); + const parentElementRect = parentElement.nativeElement.getBoundingClientRect(); + + const bannerElementRect = banner.elementRef.nativeElement.getBoundingClientRect(); + + expect(parentElementRect.left).toBe(bannerElementRect.left); + expect(parentElementRect.top).toBe(bannerElementRect.top); + expect(parentElementRect.right).toBe(bannerElementRect.right); + expect(parentElementRect.bottom).toBe(bannerElementRect.bottom); + })); + + it('Should push parent element content downwards on loading', fakeAsync(() => { + const fixture: ComponentFixture = TestBed.createComponent(IgxBannerSampleComponent); + fixture.detectChanges(); + + let pageContentElement = fixture.debugElement.query(By.css('#content')); + let pageContentElementTop = pageContentElement.nativeElement.getBoundingClientRect().top; + + const banner: IgxBannerComponent = fixture.componentInstance.banner; + banner.open(); + tick(); + fixture.detectChanges(); + + const bannerElementRect = banner.elementRef.nativeElement.getBoundingClientRect(); + expect(pageContentElementTop).toBe(bannerElementRect.top); + + pageContentElement = fixture.debugElement.query(By.css('#content')); + pageContentElementTop = pageContentElement.nativeElement.getBoundingClientRect().top; + expect(pageContentElementTop).toBe(bannerElementRect.bottom); + + banner.close(); + tick(); + fixture.detectChanges(); + + pageContentElement = fixture.debugElement.query(By.css('#content')); + pageContentElementTop = pageContentElement.nativeElement.getBoundingClientRect().top; + expect(pageContentElementTop).toBe(bannerElementRect.top); + })); + }); + + describe('Action tests: ', () => { + it('Should dismiss/confirm banner on button clicking', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxBannerSampleComponent); + fixture.detectChanges(); + const banner = fixture.componentInstance.banner; + expect(banner.collapsed).toBeTruthy(); + + spyOn(banner.opened, 'emit'); + spyOn(banner.closed, 'emit'); + spyOn(banner, 'onExpansionPanelClose').and.callThrough(); + spyOn(banner, 'onExpansionPanelOpen').and.callThrough(); + spyOn(banner, 'open').and.callThrough(); + spyOn(banner, 'close').and.callThrough(); + + banner.open(); + tick(); + fixture.detectChanges(); + + expect(banner.open).toHaveBeenCalledTimes(1); + expect(banner.opened.emit).toHaveBeenCalledTimes(1); + expect(banner.onExpansionPanelOpen).toHaveBeenCalledTimes(1); + expect(banner.collapsed).toBeFalsy(); + + getBaseClassElements(fixture); + + expect(bannerMessageElement).not.toBeNull(); + expect(bannerIllustrationElement).not.toBeNull(); + expect(bannerTextElement).not.toBeNull(); + expect(bannerTextElement.nativeElement.innerHTML.trim()). + toEqual('Unfortunately, the credit card did not go through, please try again.'); + expect(bannerActionsElement).not.toBeNull(); + + const buttons = bannerActionsElement.nativeElement.querySelectorAll('button'); + expect(buttons.length).toEqual(2); + buttons[0].click(); + tick(); + fixture.detectChanges(); + + getBaseClassElements(fixture); + + expect(banner.close).toHaveBeenCalledTimes(1); + expect(banner.closed.emit).toHaveBeenCalledTimes(1); + expect(banner.onExpansionPanelClose).toHaveBeenCalledTimes(1); + expect(banner.collapsed).toBeTruthy(); + expect(bannerMessageElement).toBeNull(); + expect(bannerActionsElement).toBeNull(); + + banner.open(); + tick(); + fixture.detectChanges(); + + getBaseClassElements(fixture); + + expect(banner.open).toHaveBeenCalledTimes(2); + expect(banner.opened.emit).toHaveBeenCalledTimes(2); + expect(banner.onExpansionPanelOpen).toHaveBeenCalledTimes(2); + expect(banner.collapsed).toBeFalsy(); + expect(bannerMessageElement).not.toBeNull(); + expect(bannerIllustrationElement).not.toBeNull(); + expect(bannerTextElement).not.toBeNull(); + expect(bannerTextElement.nativeElement.innerHTML.trim()). + toEqual('Unfortunately, the credit card did not go through, please try again.'); + expect(bannerActionsElement).not.toBeNull(); + + buttons[1].click(); + tick(); + fixture.detectChanges(); + + getBaseClassElements(fixture); + expect(banner.close).toHaveBeenCalledTimes(2); + expect(banner.closed.emit).toHaveBeenCalledTimes(2); + expect(banner.onExpansionPanelClose).toHaveBeenCalledTimes(2); + expect(banner.collapsed).toBeTruthy(); + expect(bannerMessageElement).toBeNull(); + expect(bannerActionsElement).toBeNull(); + })); + + it('Should not be dismissed on user actions outside the component', () => { + const fixture = TestBed.createComponent(IgxBannerSampleComponent); + fixture.detectChanges(); + const banner = fixture.componentInstance.banner; + const targetDiv = document.createElement('DIV'); + const bannerNode: HTMLElement = banner.elementRef.nativeElement; + targetDiv.style.height = '3000px'; + targetDiv.style.width = '1000px'; + targetDiv.style.backgroundColor = '#aa44bb'; + targetDiv.tabIndex = 1; + bannerNode.parentNode.appendChild(targetDiv); + expect(banner.collapsed).toBeTruthy(); + banner.open(); + fixture.detectChanges(); + expect(banner.collapsed).toBeFalsy(); + targetDiv.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + fixture.detectChanges(); + expect(banner.collapsed).toBeFalsy(); + targetDiv.click(); + fixture.detectChanges(); + expect(banner.collapsed).toBeFalsy(); + targetDiv.focus(); + fixture.detectChanges(); + expect(banner.collapsed).toBeFalsy(); + targetDiv.style.height = '3000px'; + fixture.detectChanges(); + targetDiv.dispatchEvent(new Event('scroll')); + fixture.detectChanges(); + expect(banner.collapsed).toBeFalsy(); + targetDiv.parentNode.removeChild(targetDiv); + }); + + it('Should properly emit events', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxBannerSampleComponent); + fixture.detectChanges(); + const banner = fixture.componentInstance.banner; + spyOn(banner.closed, 'emit'); + spyOn(banner.closing, 'emit'); + spyOn(banner.opened, 'emit'); + spyOn(banner.opening, 'emit'); + expect(banner.collapsed).toEqual(true); + expect(banner.opening.emit).toHaveBeenCalledTimes(0); + expect(banner.opened.emit).toHaveBeenCalledTimes(0); + expect(banner.closing.emit).toHaveBeenCalledTimes(0); + expect(banner.closed.emit).toHaveBeenCalledTimes(0); + banner.toggle(); + tick(); + expect(banner.opening.emit).toHaveBeenCalledTimes(1); + expect(banner.opened.emit).toHaveBeenCalledTimes(1); + expect(banner.closing.emit).toHaveBeenCalledTimes(0); + expect(banner.closed.emit).toHaveBeenCalledTimes(0); + banner.toggle(); + tick(); + expect(banner.opening.emit).toHaveBeenCalledTimes(1); + expect(banner.opened.emit).toHaveBeenCalledTimes(1); + expect(banner.closing.emit).toHaveBeenCalledTimes(1); + expect(banner.closed.emit).toHaveBeenCalledTimes(1); + })); + + it('Should properly cancel opening and closing', fakeAsync(() => { + const fixture = TestBed.createComponent(SimpleBannerEventsComponent); + fixture.detectChanges(); + const banner = fixture.componentInstance.banner; + spyOn(banner.closing, 'emit').and.callThrough(); + spyOn(banner.opening, 'emit').and.callThrough(); + spyOn(banner.closed, 'emit').and.callThrough(); + spyOn(banner.opened, 'emit').and.callThrough(); + expect(banner.collapsed).toEqual(true); + fixture.componentInstance.cancelFlag = true; + banner.toggle(); + tick(); + expect(banner.collapsed).toEqual(true); + expect(banner.opening.emit).toHaveBeenCalledTimes(1); + expect(banner.opened.emit).toHaveBeenCalledTimes(0); + fixture.componentInstance.cancelFlag = false; + banner.toggle(); + tick(); + expect(banner.collapsed).toEqual(false); + expect(banner.opening.emit).toHaveBeenCalledTimes(2); + expect(banner.opened.emit).toHaveBeenCalledTimes(1); + fixture.componentInstance.cancelFlag = true; + banner.toggle(); + tick(); + expect(banner.collapsed).toEqual(false); + expect(banner.closing.emit).toHaveBeenCalledTimes(1); + expect(banner.closed.emit).toHaveBeenCalledTimes(0); + fixture.componentInstance.cancelFlag = false; + banner.toggle(); + tick(); + expect(banner.collapsed).toEqual(true); + expect(banner.closing.emit).toHaveBeenCalledTimes(2); + expect(banner.closed.emit).toHaveBeenCalledTimes(1); + })); + + it('Should toggle banner state when expanded property changes', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxBannerInitializedOpenComponent); + fixture.detectChanges(); + const banner = fixture.componentInstance.banner; + + banner.expanded = false; + tick(); + fixture.detectChanges(); + + expect(banner.expanded).toBeFalse(); + + banner.expanded = true; + tick(); + fixture.detectChanges(); + expect(banner.expanded).toBeTrue(); + expect(banner.elementRef.nativeElement.style.display).toEqual('block'); + + banner.expanded = false; + tick(); + fixture.detectChanges(); + expect(banner.expanded).toBeFalse(); + expect(banner.elementRef.nativeElement.style.display).toEqual(''); + + banner.expanded = true; + tick(); + fixture.detectChanges(); + expect(banner.expanded).toBeTrue(); + expect(banner.elementRef.nativeElement.style.display).toEqual('block'); + })); + }); + + describe('Rendering tests: ', () => { + it('Should apply all appropriate classes on initialization_default template', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxBannerSampleComponent); + fixture.detectChanges(); + const banner = fixture.componentInstance.banner; + const bannerNode: HTMLElement = banner.elementRef.nativeElement; + expect(banner.collapsed).toBeTruthy(); + expect(bannerNode.childElementCount).toEqual(1); // collapsed expansion panel + expect(bannerNode.firstElementChild.childElementCount).toEqual(0); // no content + getBaseClassElements(fixture); + expect(bannerElement).toBeNull(); + expect(bannerMessageElement).toBeNull(); + expect(bannerIllustrationElement).toBeNull(); + expect(bannerTextElement).toBeNull(); + expect(bannerActionsElement).toBeNull(); + banner.toggle(); + tick(); + fixture.detectChanges(); + getBaseClassElements(fixture); + expect(bannerElement).not.toBeNull(); + expect(bannerMessageElement).not.toBeNull(); + expect(bannerIllustrationElement).not.toBeNull(); + expect(bannerTextElement).not.toBeNull(); + expect(bannerActionsElement).not.toBeNull(); + banner.toggle(); + tick(); + fixture.detectChanges(); + getBaseClassElements(fixture); + expect(bannerElement).toBeNull(); + expect(bannerMessageElement).toBeNull(); + expect(bannerIllustrationElement).toBeNull(); + expect(bannerTextElement).toBeNull(); + expect(bannerActionsElement).toBeNull(); + })); + + it('Should apply all appropriate classes on initialization_custom template', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxBannerCustomTemplateComponent); + fixture.detectChanges(); + const banner = fixture.componentInstance.banner; + const panel = fixture.nativeElement.querySelector('.' + CSS_CLASS_EXPANSION_PANEL); + expect(panel).not.toBeNull(); + expect(panel.attributes.getNamedItem('aria-expanded').nodeValue).toEqual('false'); + expect(panel.childElementCount).toEqual(0); + + banner.open(); + tick(); + fixture.detectChanges(); + expect(panel.childElementCount).toEqual(1); + + const panelBody = panel.children[0]; + expect(panelBody.attributes.getNamedItem('class').nodeValue).toContain(CSS_CLASS_EXPANSION_PANEL_BODY); + expect(panelBody.attributes.getNamedItem('role').nodeValue).toEqual('region'); + expect(panelBody.childElementCount).toEqual(1); + })); + + it('Should apply the appropriate display style to the banner host', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxBannerOneButtonComponent); + fixture.detectChanges(); + const banner = fixture.componentInstance.banner; + // Banner is collapsed, display is ''; + expect(banner.elementRef.nativeElement.style.display).toEqual(''); + banner.toggle(); + tick(); + // Banner is expanded, display is 'block'; + fixture.detectChanges(); + expect(banner.elementRef.nativeElement.style.display).toEqual('block'); + expect(banner.collapsed).toBeFalsy(); + banner.toggle(); + tick(); + // Banner is collapsING, display is 'block'; + expect(banner.elementRef.nativeElement.style.display).toEqual('block'); + tick(); + fixture.detectChanges(); + // Banner is collapsed, display is ''; + expect(banner.elementRef.nativeElement.style.display).toEqual(''); + expect(banner.collapsed).toBeTruthy(); + })); + + it('Should apply the appropriate attributes on initialization', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxBannerOneButtonComponent); + fixture.detectChanges(); + + const panel = fixture.nativeElement.querySelector('.' + CSS_CLASS_EXPANSION_PANEL); + expect(panel).not.toBeNull(); + expect(panel.attributes.getNamedItem('role').nodeValue).toEqual('status'); + expect(panel.attributes.getNamedItem('aria-live').nodeValue).toEqual('polite'); + })); + + it('Should initialize banner as open when expanded is set to true', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxBannerInitializedOpenComponent); + fixture.detectChanges(); + const banner = fixture.componentInstance.banner; + + expect(banner.expanded).toBeTrue(); + expect(banner.elementRef.nativeElement.style.display).toEqual('block'); + expect(banner.elementRef.nativeElement.querySelector('.' + CSS_CLASS_BANNER)).not.toBeNull(); + })); + }); + + const getBaseClassElements = (fixture: ComponentFixture) => { + bannerElement = fixture.debugElement.query(By.css('.' + CSS_CLASS_BANNER)); + bannerMessageElement = fixture.debugElement.query(By.css('.' + CSS_CLASS_BANNER_MESSAGE)); + bannerIllustrationElement = fixture.debugElement.query(By.css('.' + CSS_CLASS_BANNER_ILLUSTRATION)); + bannerTextElement = fixture.debugElement.query(By.css('.' + CSS_CLASS_BANNER_TEXT)); + bannerActionsElement = fixture.debugElement.query(By.css('.' + CSS_CLASS_BANNER_ACTIONS)); + }; +}); + +@Component({ + template: ` +
+ +
+
SOME PAGE CONTENT
`, + imports: [IgxBannerComponent] +}) +export class IgxBannerEmptyComponent { + @ViewChild(IgxBannerComponent, { read: IgxBannerComponent, static: true }) + public banner: IgxBannerComponent; +} + +@Component({ + template: ` +
+ + You have lost connection to the internet. + + + + +
+
SOME PAGE CONTENT
+ `, + imports: [IgxBannerComponent, IgxBannerActionsDirective] +}) +export class IgxBannerOneButtonComponent { + @ViewChild(IgxBannerComponent, { read: IgxBannerComponent, static: true }) + public banner: IgxBannerComponent; +} + +@Component({ + template: ` +
+ + error + Unfortunately, the credit card did not go through, please try again. + + + + + +
+
SOME PAGE CONTENT
+ `, + imports: [IgxBannerComponent, IgxBannerActionsDirective, IgxIconComponent] +}) +export class IgxBannerSampleComponent { + @ViewChild(IgxBannerComponent, { read: IgxBannerComponent, static: true }) + public banner: IgxBannerComponent; +} + +@Component({ + template: ` +
+ + + + + +

Brad Stanley

+
Audi AG
+
+ +

Brad Stanley has requested to follow you.

+
+
+ + + + +
+
+
SOME PAGE CONTENT
`, + imports: [IgxBannerComponent, IgxCardComponent, IgxCardHeaderComponent, IgxCardContentDirective, IgxBannerActionsDirective, IgxAvatarComponent] +}) +export class IgxBannerCustomTemplateComponent { + @ViewChild(IgxBannerComponent, { read: IgxBannerComponent, static: true }) + public banner: IgxBannerComponent; +} + +@Component({ + template: ` +
+ Simple message +
+
SOME PAGE CONTENT
`, + imports: [IgxBannerComponent] +}) +export class SimpleBannerEventsComponent { + @ViewChild(IgxBannerComponent, { read: IgxBannerComponent, static: true }) + public banner: IgxBannerComponent; + + public cancelFlag = false; + + public handleOpening(event: any) { + event.cancel = this.cancelFlag; + } + + public handleClosing(event: any) { + event.cancel = this.cancelFlag; + } +} + +@Component({ + template: ` +
+ + Banner initialized as open. + +
+ `, + standalone: true, + imports: [IgxBannerComponent] +}) +export class IgxBannerInitializedOpenComponent { + @ViewChild(IgxBannerComponent, { static: true }) + public banner: IgxBannerComponent; +} diff --git a/projects/igniteui-angular/banner/src/banner/banner.component.ts b/projects/igniteui-angular/banner/src/banner/banner.component.ts new file mode 100644 index 00000000000..14ade6c7f7f --- /dev/null +++ b/projects/igniteui-angular/banner/src/banner/banner.component.ts @@ -0,0 +1,331 @@ +import { Component, ContentChild, ElementRef, EventEmitter, HostBinding, Input, Output, ViewChild, inject } from '@angular/core'; + +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxButtonDirective, IgxRippleDirective } from 'igniteui-angular/directives'; +import { IgxBannerActionsDirective } from './banner.directives'; +import { + CancelableEventArgs, + IBaseEventArgs, + BannerResourceStringsEN, + IBannerResourceStrings, + getCurrentResourceStrings, + IToggleView +} from 'igniteui-angular/core'; +import { IgxExpansionPanelBodyComponent, IgxExpansionPanelComponent, ToggleAnimationSettings } from 'igniteui-angular/expansion-panel'; + +export interface BannerEventArgs extends IBaseEventArgs { + event?: Event; +} + +export interface BannerCancelEventArgs extends BannerEventArgs, CancelableEventArgs { +} +/** + * **Ignite UI for Angular Banner** - + * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/banner.html) + * + * The Ignite UI Banner provides a highly template-able and easy to use banner that can be shown in your application. + * + * Usage: + * + * ```html + * + * Our privacy settings have changed. + * + * + * + * + * + * ``` + */ +@Component({ + selector: 'igx-banner', + templateUrl: 'banner.component.html', + imports: [IgxExpansionPanelComponent, IgxExpansionPanelBodyComponent, IgxButtonDirective, IgxRippleDirective] +}) +export class IgxBannerComponent implements IToggleView { + public elementRef = inject>(ElementRef); + + /** + * @hidden + */ + @ContentChild(IgxIconComponent) + public bannerIcon: IgxIconComponent; + + /** + * Fires after the banner shows up + * ```typescript + * public handleOpened(event) { + * ... + * } + * ``` + * ```html + * + * ``` + */ + @Output() + public opened = new EventEmitter(); + + /** + * Fires before the banner shows up + * ```typescript + * public handleOpening(event) { + * ... + * } + * ``` + * ```html + * + * ``` + */ + @Output() + public opening = new EventEmitter(); + + /** + * Fires after the banner hides + * ```typescript + * public handleClosed(event) { + * ... + * } + * ``` + * ```html + * + * ``` + */ + @Output() + public closed = new EventEmitter(); + + /** + * Fires before the banner hides + * ```typescript + * public handleClosing(event) { + * ... + * } + * ``` + * ```html + * + * ``` + */ + @Output() + public closing = new EventEmitter(); + + /** @hidden */ + public get useDefaultTemplate(): boolean { + return !this._bannerActionTemplate; + } + + /** + * Set the animation settings used by the banner open/close methods + * ```typescript + * import { slideInLeft, slideOutRight } from 'igniteui-angular'; + * ... + * banner.animationSettings: ToggleAnimationSettings = { openAnimation: slideInLeft, closeAnimation: slideOutRight }; + * ``` + */ + public set animationSettings(settings: ToggleAnimationSettings) { + this._animationSettings = settings; + } + + /** + * Get the animation settings used by the banner open/close methods + * ```typescript + * let currentAnimations: ToggleAnimationSettings = banner.animationSettings + * ``` + */ + @Input() + public get animationSettings(): ToggleAnimationSettings { + return this._animationSettings ? this._animationSettings : this._expansionPanel.animationSettings; + } + + /** + * Gets/Sets the resource strings. + * + * @remarks + * By default it uses EN resources. + */ + @Input() + public set resourceStrings(value: IBannerResourceStrings) { + this._resourceStrings = Object.assign({}, this._resourceStrings, value); + } + + public get resourceStrings(): IBannerResourceStrings { + return this._resourceStrings; + } + + /** + * Gets/Sets whether the banner is expanded (visible) or collapsed (hidden). + * Defaults to `false`. + * Setting to `true` opens the banner, while `false` closes it. + * + * @example + * // Expand the banner + * banner.expanded = true; + * + * @example + * // Collapse the banner + * banner.expanded = false; + * + * @example + * // Check if the banner is expanded + * const isExpanded = banner.expanded; + */ + @Input() + public get expanded(): boolean { + return this._expanded; + } + + public set expanded(value: boolean) { + if (value === this._expanded) { + return; + } + + this._expanded = value; + this._shouldFireEvent = true; + + if (value) { + this._expansionPanel.open(); + } else { + this._expansionPanel.close(); + } + } + + /** + * Gets whether the banner is collapsed. + * + * ```typescript + * const isCollapsed: boolean = banner.collapsed; + * ``` + */ + public get collapsed(): boolean { + return this._expansionPanel.collapsed; + } + + /** + * Returns the native element of the banner component + * ```typescript + * const myBannerElement: HTMLElement = banner.element; + * ``` + */ + public get element() { + return this.elementRef.nativeElement; + } + + @HostBinding('class') + public cssClass = 'igx-banner-host'; + + /** + * @hidden + */ + @HostBinding('style.display') + public get displayStyle(): string { + return this.collapsed ? '' : 'block'; + } + + @ViewChild('expansionPanel', { static: true }) + private _expansionPanel: IgxExpansionPanelComponent; + + @ContentChild(IgxBannerActionsDirective) + private _bannerActionTemplate: IgxBannerActionsDirective; + + private _expanded: boolean = false; + private _shouldFireEvent: boolean = false; + private _bannerEvent: BannerEventArgs; + private _animationSettings: ToggleAnimationSettings; + private _resourceStrings = getCurrentResourceStrings(BannerResourceStringsEN); + + /** + * Opens the banner + * + * ```typescript + * myBanner.open(); + * ``` + * + * ```html + * + * ... + * + * + * ``` + */ + public open(event?: Event) { + this._bannerEvent = { owner: this, event }; + const openingArgs: BannerCancelEventArgs = { + owner: this, + event, + cancel: false + }; + this.opening.emit(openingArgs); + if (openingArgs.cancel) { + return; + } + this._expansionPanel.open(event); + this._expanded = true; + this._shouldFireEvent = false; + } + + /** + * Closes the banner + * + * ```typescript + * myBanner.close(); + * ``` + * + * ```html + * + * ... + * + * + * ``` + */ + public close(event?: Event) { + this._bannerEvent = { owner: this, event}; + const closingArgs: BannerCancelEventArgs = { + owner: this, + event, + cancel: false + }; + this.closing.emit(closingArgs); + if (closingArgs.cancel) { + return; + } + this._expansionPanel.close(event); + this._expanded = false; + this._shouldFireEvent = false; + } + + /** + * Toggles the banner + * + * ```typescript + * myBanner.toggle(); + * ``` + * + * ```html + * + * ... + * + * + * ``` + */ + public toggle(event?: Event) { + if (this.collapsed) { + this.open(event); + } else { + this.close(event); + } + } + + /** @hidden */ + public onExpansionPanelOpen() { + if (this._shouldFireEvent) { + return; + } + this.opened.emit(this._bannerEvent); + } + + /** @hidden */ + public onExpansionPanelClose() { + if (this._shouldFireEvent) { + return; + } + this.closed.emit(this._bannerEvent); + } +} diff --git a/projects/igniteui-angular/banner/src/banner/banner.directives.ts b/projects/igniteui-angular/banner/src/banner/banner.directives.ts new file mode 100644 index 00000000000..84e437b2970 --- /dev/null +++ b/projects/igniteui-angular/banner/src/banner/banner.directives.ts @@ -0,0 +1,7 @@ +import { Directive } from '@angular/core'; + +@Directive({ + selector: 'igx-banner-actions', + standalone: true +}) +export class IgxBannerActionsDirective { } diff --git a/projects/igniteui-angular/banner/src/banner/banner.module.ts b/projects/igniteui-angular/banner/src/banner/banner.module.ts new file mode 100644 index 00000000000..6473a7f1814 --- /dev/null +++ b/projects/igniteui-angular/banner/src/banner/banner.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { IGX_BANNER_DIRECTIVES } from './public_api'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [...IGX_BANNER_DIRECTIVES], + exports: [...IGX_BANNER_DIRECTIVES] +}) +export class IgxBannerModule { } diff --git a/projects/igniteui-angular/banner/src/banner/public_api.ts b/projects/igniteui-angular/banner/src/banner/public_api.ts new file mode 100644 index 00000000000..0433362fde6 --- /dev/null +++ b/projects/igniteui-angular/banner/src/banner/public_api.ts @@ -0,0 +1,11 @@ +import { IgxBannerComponent } from './banner.component'; +import { IgxBannerActionsDirective } from './banner.directives'; + +export * from './banner.component'; +export * from './banner.directives'; + +/* Banner directives collection for ease-of-use import in standalone components scenario */ +export const IGX_BANNER_DIRECTIVES = [ + IgxBannerComponent, + IgxBannerActionsDirective +] as const; diff --git a/projects/igniteui-angular/banner/src/public_api.ts b/projects/igniteui-angular/banner/src/public_api.ts new file mode 100644 index 00000000000..61c484caf1b --- /dev/null +++ b/projects/igniteui-angular/banner/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './banner/public_api'; +export * from './banner/banner.module'; diff --git a/projects/igniteui-angular/bottom-nav/README.md b/projects/igniteui-angular/bottom-nav/README.md new file mode 100644 index 00000000000..3c2907f2783 --- /dev/null +++ b/projects/igniteui-angular/bottom-nav/README.md @@ -0,0 +1,81 @@ +# igx-bottom-nav + +## Description +_igx-bottom-nav represents a single content area with multiple nav items. The bottom navigation in Ignite UI for Angular can be composed with the following components and directives:_ + +- *igx-bottom-nav-item* - single content area that holds header and content components +- *igx-bottom-nav-header* - holds the title and/or icon of the item and you can add them with `igxBottomNavHeaderIcon` and `igxBottomNavHeaderLabel` +- *igx-bottom-nav-content* - represents the wrapper of the content that needs to be displayed + +Each item (`igx-bottom-nav-item`) contains header (`igx-bottom-nav-header`) and content (`igx-bottom-nav-content`). Header is related to particular content. +When a tab is clicked, the associated content is selected and visualized into a single container. There should always be a selected tab. Only one tab can be selected at a time. +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/tabbar). + +---------- +## Usage + + + + + folder + Tab 1 + + + Content 1 + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius sapien ligula. + + + + + + folder + Tab 2 + + + Content 2 + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius sapien ligula. + + + + + + Tab 3 + + + Content 3 + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius sapien ligula. + + + + + +# API Summary + +## igx-bottom-nav + +### Properties + +| Name | Type | Description | +|:----------|:-------------:|:------| +| `items` | QueryList | Observable collection of `IgxTabItemDirective` content children. | +| `selectedIndex` | number | Gets the index of selected tab/item in the respective collection. Default value is 0 if content is defined otherwise defaults to -1. | +| `disableAnimation` | boolean | Enables/disables the transition animation of the content. | +| `selectedItem` | IgxTabItemDirective | Gets the selected `IgxTabItemDirective` in the bottom-nav based on selectedIndex. | + + +### Events + +| Name | Description | +|:---------- |:-----------------------------------------| +| `selectedIndexChange` | Emitted when the new tab item is selected. | +| `selectedIndexChanging` | Emitted before new tab item is selected. This event is cancelable| +| `selectedItemChange` | Emitted when the new tab item is selected. | + +## igx-bottom-nav-item + +### Properties + +| Name | Type | Description | +|:----------|:-------------:|:------| +| `selected` | boolean | Determines whether the item is selected. | +| `disabled` | boolean | Determines whether the item is disabled. | diff --git a/projects/igniteui-angular/bottom-nav/index.ts b/projects/igniteui-angular/bottom-nav/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/bottom-nav/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/bottom-nav/ng-package.json b/projects/igniteui-angular/bottom-nav/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/bottom-nav/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav-content.component.html b/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav-content.component.html new file mode 100644 index 00000000000..8500c10e34a --- /dev/null +++ b/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav-content.component.html @@ -0,0 +1,3 @@ +@if (tab.selected || tab.previous) { + +} diff --git a/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav-content.component.ts b/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav-content.component.ts new file mode 100644 index 00000000000..f9d9a3241cd --- /dev/null +++ b/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav-content.component.ts @@ -0,0 +1,14 @@ +import { Component, HostBinding } from '@angular/core'; +import { IgxTabContentBase, IgxTabContentDirective } from 'igniteui-angular/tabs'; + +@Component({ + selector: 'igx-bottom-nav-content', + templateUrl: 'bottom-nav-content.component.html', + providers: [{ provide: IgxTabContentBase, useExisting: IgxBottomNavContentComponent }], + imports: [] +}) +export class IgxBottomNavContentComponent extends IgxTabContentDirective { + /** @hidden */ + @HostBinding('class.igx-bottom-nav__panel') + public defaultClass = true; +} diff --git a/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav-header.component.html b/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav-header.component.html new file mode 100644 index 00000000000..6dbc7430638 --- /dev/null +++ b/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav-header.component.html @@ -0,0 +1 @@ + diff --git a/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav-header.component.ts b/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav-header.component.ts new file mode 100644 index 00000000000..6a043964b57 --- /dev/null +++ b/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav-header.component.ts @@ -0,0 +1,29 @@ +import { Component, HostBinding } from '@angular/core'; +import { IgxTabHeaderBase, IgxTabHeaderDirective } from 'igniteui-angular/tabs'; + +@Component({ + selector: 'igx-bottom-nav-header', + templateUrl: 'bottom-nav-header.component.html', + providers: [{ provide: IgxTabHeaderBase, useExisting: IgxBottomNavHeaderComponent }], + standalone: true +}) +export class IgxBottomNavHeaderComponent extends IgxTabHeaderDirective { + + /** @hidden */ + @HostBinding('class.igx-bottom-nav__menu-item--selected') + public get cssClassSelected(): boolean { + return this.tab.selected; + } + + /** @hidden */ + @HostBinding('class.igx-bottom-nav__menu-item--disabled') + public get cssClassDisabled(): boolean { + return this.tab.disabled; + } + + /** @hidden */ + @HostBinding('class.igx-bottom-nav__menu-item') + public get cssClass(): boolean { + return (!this.tab.disabled && !this.tab.selected); + } +} diff --git a/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav-item.component.html b/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav-item.component.html new file mode 100644 index 00000000000..24c573d1d00 --- /dev/null +++ b/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav-item.component.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav-item.component.ts b/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav-item.component.ts new file mode 100644 index 00000000000..51c62ddc373 --- /dev/null +++ b/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav-item.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; +import { IgxTabItemDirective } from 'igniteui-angular/tabs'; + +@Component({ + selector: 'igx-bottom-nav-item', + templateUrl: 'bottom-nav-item.component.html', + providers: [{ provide: IgxTabItemDirective, useExisting: IgxBottomNavItemComponent }], + standalone: true +}) +export class IgxBottomNavItemComponent extends IgxTabItemDirective { +} diff --git a/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav.component.html b/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav.component.html new file mode 100644 index 00000000000..bfd9cc459ac --- /dev/null +++ b/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav.component.html @@ -0,0 +1,14 @@ +@for (tab of items; track tab; let i = $index) { + +} + +
+ @for (tab of items; track tab; let i = $index) { + + } +
diff --git a/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav.component.spec.ts b/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav.component.spec.ts new file mode 100644 index 00000000000..d60446d833b --- /dev/null +++ b/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav.component.spec.ts @@ -0,0 +1,427 @@ +import { QueryList } from '@angular/core'; +import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Router } from '@angular/router'; +import { Location } from '@angular/common'; +import { BottomTabBarTestComponent, + TabBarRoutingTestComponent, + TabBarTabsOnlyModeTestComponent, + TabBarTestComponent, + BottomNavRoutingGuardTestComponent, + BottomNavTestHtmlAttributesComponent } from '../../../test-utils/bottom-nav-components.spec'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxBottomNavContentComponent } from './bottom-nav-content.component'; +import { UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { RoutingTestGuard } from '../../../test-utils/routing-test-guard.spec'; +import { RoutingView1Component, RoutingView2Component, RoutingView3Component, RoutingView4Component, RoutingView5Component } from '../../../test-utils/routing-view-components.spec'; +import { IgxBottomNavItemComponent } from './bottom-nav-item.component'; +import { IgxBottomNavComponent } from './bottom-nav.component'; + +describe('IgxBottomNav', () => { + + const tabItemNormalCssClass = 'igx-bottom-nav__menu-item'; + const tabItemSelectedCssClass = 'igx-bottom-nav__menu-item--selected'; + const testRoutes = [ + { path: 'view1', component: RoutingView1Component, canActivate: [RoutingTestGuard] }, + { path: 'view2', component: RoutingView2Component, canActivate: [RoutingTestGuard] }, + { path: 'view3', component: RoutingView3Component, canActivate: [RoutingTestGuard] }, + { path: 'view4', component: RoutingView4Component, canActivate: [RoutingTestGuard] }, + { path: 'view5', component: RoutingView5Component, canActivate: [RoutingTestGuard] }, + ]; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + RouterTestingModule.withRoutes(testRoutes), + TabBarTestComponent, + BottomTabBarTestComponent, + TabBarRoutingTestComponent, + TabBarTabsOnlyModeTestComponent, + BottomNavRoutingGuardTestComponent, + BottomNavTestHtmlAttributesComponent, + RoutingView1Component, + RoutingView2Component, + RoutingView3Component, + RoutingView4Component, + RoutingView5Component + ], + providers: [RoutingTestGuard] + }).compileComponents(); + })); + + describe('Html Attributes', () => { + let fixture; + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(BottomNavTestHtmlAttributesComponent); + fixture.detectChanges(); + })); + + it('should set the correct attributes on the html elements', () => { + const igxBottomNavs = document.querySelectorAll('igx-bottom-nav'); + expect(igxBottomNavs.length).toBe(2); + + igxBottomNavs.forEach((bottomNav, i) => { + const tabHeaders = bottomNav.querySelectorAll('igx-bottom-nav-header'); + const tabPanels = bottomNav.querySelectorAll('igx-bottom-nav-content'); + expect(tabHeaders.length).toBe(3); + expect(tabPanels.length).toBe(3); + + for (let itemIndex = 0; itemIndex < 3; itemIndex++) { + const headerId = `igx-bottom-nav-header-${itemIndex + 3 * i}`; + const panelId = `igx-bottom-nav-content-${itemIndex + 3 * i}`; + + expect(tabHeaders[itemIndex].id).toEqual(headerId); + expect(tabPanels[itemIndex].id).toEqual(panelId); + + expect(tabHeaders[itemIndex].getAttribute('aria-controls')).toEqual(panelId); + expect(tabPanels[itemIndex].getAttribute('aria-labelledby')).toEqual(headerId); + } + }); + }); + }); + + describe('Component with Panels Definitions', () => { + let fixture; + let bottomNav; + let tabItems: IgxBottomNavItemComponent[]; + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(TabBarTestComponent); + fixture.detectChanges(); + + bottomNav = fixture.componentInstance.bottomNav; + tabItems = bottomNav.items.toArray(); + })); + + it('should initialize igx-bottom-nav, igx-bottom-nav-content and igx-bottom-nav-item', () => { + const panels: IgxBottomNavContentComponent[] = bottomNav.panels.toArray(); + + expect(bottomNav).toBeDefined(); + expect(bottomNav instanceof IgxBottomNavComponent).toBeTruthy(); + expect(bottomNav.panels instanceof QueryList).toBeTruthy(); + expect(bottomNav.panels.length).toBe(3); + + for (let i = 0; i < bottomNav.panels.length; i++) { + expect(panels[i] instanceof IgxBottomNavContentComponent).toBeTruthy(); + expect(panels[i].tab).toBe(tabItems[i]); + } + + expect(bottomNav.items instanceof QueryList).toBeTruthy(); + expect(bottomNav.items.length).toBe(3); + + for (let i = 0; i < bottomNav.items.length; i++) { + expect(tabItems[i] instanceof IgxBottomNavItemComponent).toBeTruthy(); + expect(tabItems[i].panelComponent).toBe(panels[i]); + } + }); + + it('should initialize default values of properties', () => { + expect(bottomNav.selectedIndex).toBe(0); + expect(bottomNav.selectedItem).toBe(tabItems[0]); + + expect(tabItems[0].disabled).toBeFalsy(); + expect(tabItems[1].disabled).toBeFalsy(); + }); + + it('should initialize set/get properties', () => { + const icons = ['library_music', 'video_library', 'library_books']; + + const tabHeaderElements = tabItems.map(item => item.headerComponent.nativeElement); + + for (let i = 0; i < tabHeaderElements.length; i++) { + expect(tabHeaderElements[i].firstElementChild.localName).toBe('igx-icon'); + expect(tabHeaderElements[i].firstElementChild.textContent).toBe(icons[i]); + expect(tabHeaderElements[i].lastElementChild.localName).toBe('span'); + expect(tabHeaderElements[i].lastElementChild.textContent).toBe('Tab ' + (i + 1)); + } + }); + + it('should select/deselect tabs', fakeAsync(() => { + expect(bottomNav.selectedIndex).toBe(0); + const tab1: IgxBottomNavItemComponent = tabItems[0]; + const tab2: IgxBottomNavItemComponent = tabItems[1]; + + tab2.selected = true; + tick(100); + fixture.detectChanges(); + + expect(bottomNav.selectedIndex).toBe(1); + expect(bottomNav.selectedItem).toBe(tab2); + expect(tab2.selected).toBeTruthy(); + expect(tab1.selected).toBeFalsy(); + + tab1.selected = true; + tick(100); + fixture.detectChanges(); + + expect(bottomNav.selectedIndex).toBe(0); + expect(bottomNav.selectedItem).toBe(tab1); + expect(tab1.selected).toBeTruthy(); + expect(tab2.selected).toBeFalsy(); + + // select disabled tab + tab2.disabled = true; + tab2.selected = true; + tick(100); + fixture.detectChanges(); + + expect(bottomNav.selectedIndex).toBe(1); + expect(bottomNav.selectedItem).toBe(tab2); + expect(tab2.selected).toBeTruthy(); + expect(tab1.selected).toBeFalsy(); + })); + + }); + + describe('Routing Navigation Tests', () => { + let router; + let location; + let fixture; + let bottomNav; + let tabItems; + let headers; + + beforeEach(waitForAsync(() => { + router = TestBed.inject(Router); + location = TestBed.inject(Location); + })); + + describe('', () => { + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(TabBarRoutingTestComponent); + fixture.detectChanges(); + bottomNav = fixture.componentInstance.bottomNav; + tabItems = bottomNav.items.toArray(); + headers = tabItems.map(item => item.headerComponent.nativeElement); + })); + + it('should navigate to the correct URL when clicking on tab buttons', fakeAsync(() => { + fixture.ngZone.run(() => router.initialNavigation()); + tick(); + expect(location.path()).toBe('/'); + + fixture.ngZone.run(() => UIInteractions.simulateClickAndSelectEvent(headers[2])); + tick(); + expect(location.path()).toBe('/view3'); + + fixture.ngZone.run(() => UIInteractions.simulateClickAndSelectEvent(headers[1])); + tick(); + expect(location.path()).toBe('/view2'); + + fixture.ngZone.run(() => UIInteractions.simulateClickAndSelectEvent(headers[0])); + tick(); + expect(location.path()).toBe('/view1'); + })); + + it('should select the correct tab button/panel when navigating an URL', fakeAsync(() => { + fixture.ngZone.run(() => router.initialNavigation()); + tick(); + expect(location.path()).toBe('/'); + + fixture.ngZone.run(() => router.navigate(['/view3'])); + tick(); + expect(location.path()).toBe('/view3'); + fixture.detectChanges(); + expect(bottomNav.selectedIndex).toBe(2); + expect(tabItems[2].selected).toBe(true); + expect(tabItems[0].selected).toBe(false); + expect(tabItems[1].selected).toBe(false); + + fixture.ngZone.run(() => router.navigate(['/view2'])); + tick(); + expect(location.path()).toBe('/view2'); + fixture.detectChanges(); + expect(bottomNav.selectedIndex).toBe(1); + expect(tabItems[1].selected).toBe(true); + expect(tabItems[0].selected).toBe(false); + expect(tabItems[2].selected).toBe(false); + + fixture.ngZone.run(() => router.navigate(['/view1'])); + tick(); + expect(location.path()).toBe('/view1'); + fixture.detectChanges(); + expect(bottomNav.selectedIndex).toBe(0); + expect(tabItems[0].selected).toBe(true); + expect(tabItems[1].selected).toBe(false); + expect(tabItems[2].selected).toBe(false); + })); + }); + + describe('', () => { + it('should not navigate to an URL blocked by activate guard', fakeAsync(() => { + fixture = TestBed.createComponent(BottomNavRoutingGuardTestComponent); + fixture.detectChanges(); + + bottomNav = fixture.componentInstance.bottomNav; + tabItems = bottomNav.items.toArray(); + headers = tabItems.map(item => item.headerComponent.nativeElement); + + fixture.ngZone.run(() => router.initialNavigation()); + tick(); + expect(location.path()).toBe('/'); + + fixture.ngZone.run(() => UIInteractions.simulateClickAndSelectEvent(headers[0])); + tick(); + expect(location.path()).toBe('/view1'); + fixture.detectChanges(); + expect(bottomNav.selectedIndex).toBe(0); + expect(tabItems[0].selected).toBe(true); + expect(tabItems[1].selected).toBe(false); + + fixture.ngZone.run(() => { + UIInteractions.simulateClickAndSelectEvent(headers[1]); + }); + tick(); + expect(location.path()).toBe('/view1'); + fixture.detectChanges(); + expect(bottomNav.selectedIndex).toBe(0); + expect(tabItems[0].selected).toBe(true); + expect(tabItems[1].selected).toBe(false); + })); + }); + }); + + describe('Tabs-only Mode Tests', () => { + let fixture; + let bottomNav; + let tabItems; + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(TabBarTabsOnlyModeTestComponent); + bottomNav = fixture.componentInstance.bottomNav; + fixture.detectChanges(); + tabItems = bottomNav.items.toArray(); + })); + + it('should retain the correct initial selection status', () => { + const headers = tabItems.map(item => item.headerComponent.nativeElement); + expect(tabItems[0].selected).toBe(false); + expect(headers[0].classList.contains(tabItemNormalCssClass)).toBe(true); + + expect(tabItems[1].selected).toBe(true); + expect(headers[1].classList.contains(tabItemSelectedCssClass)).toBe(true); + + expect(tabItems[2].selected).toBe(false); + expect(headers[2].classList.contains(tabItemNormalCssClass)).toBe(true); + }); + }); + + describe('Events', () => { + let fixture; + let bottomNav; + let tabItems; + let headers; + let itemChangeSpy; + let indexChangeSpy; + let indexChangingSpy; + + describe('', () => { + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(TabBarTestComponent); + fixture.detectChanges(); + bottomNav = fixture.componentInstance.bottomNav; + tabItems = bottomNav.items.toArray(); + headers = tabItems.map(item => item.headerComponent.nativeElement); + itemChangeSpy = spyOn(bottomNav.selectedItemChange, 'emit').and.callThrough(); + indexChangeSpy = spyOn(bottomNav.selectedIndexChange, 'emit').and.callThrough(); + indexChangingSpy = spyOn(bottomNav.selectedIndexChanging, 'emit').and.callThrough(); + })); + + it('Validate the fired events on clicking tab headers.', fakeAsync(() => { + tick(100); + + headers[1].dispatchEvent(new Event('click', { bubbles: true })); + tick(200); + fixture.detectChanges(); + expect(bottomNav.selectedIndex).toBe(1); + + expect(indexChangingSpy).toHaveBeenCalledWith({ + owner: bottomNav, + cancel: false, + oldIndex: 0, + newIndex: 1 + }); + expect(indexChangeSpy).toHaveBeenCalledWith(1); + expect(itemChangeSpy).toHaveBeenCalledWith({ + owner: bottomNav, + oldItem: tabItems[0], + newItem: tabItems[1] + }); + })); + + it('Cancel selectedIndexChanging event.', fakeAsync(() => { + tick(100); + bottomNav.selectedIndexChanging.pipe().subscribe((e) => e.cancel = true); + fixture.detectChanges(); + + headers[1].dispatchEvent(new Event('click', { bubbles: true })); + tick(200); + fixture.detectChanges(); + expect(bottomNav.selectedIndex).toBe(0); + + expect(indexChangingSpy).toHaveBeenCalledWith({ + owner: bottomNav, + cancel: true, + oldIndex: 0, + newIndex: 1 + }); + expect(itemChangeSpy).not.toHaveBeenCalled(); + expect(indexChangeSpy).not.toHaveBeenCalled(); + })); + }); + + describe('& Routing', () => { + let router; + let location; + const KEY_ENTER_EVENT = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }); + beforeEach(waitForAsync(() => { + router = TestBed.inject(Router); + location = TestBed.inject(Location); + fixture = TestBed.createComponent(TabBarRoutingTestComponent); + fixture.detectChanges(); + bottomNav = fixture.componentInstance.bottomNav; + tabItems = bottomNav.items.toArray(); + headers = tabItems.map(item => item.headerComponent.nativeElement); + itemChangeSpy = spyOn(bottomNav.selectedItemChange, 'emit'); + indexChangeSpy = spyOn(bottomNav.selectedIndexChange, 'emit'); + indexChangingSpy = spyOn(bottomNav.selectedIndexChanging, 'emit'); + })); + + it('Validate the events are not fired on clicking tab headers before pressing enter/space key.', fakeAsync(() => { + fixture.ngZone.run(() => router.initialNavigation()); + tick(); + expect(location.path()).toBe('/'); + + fixture.ngZone.run(() => { + UIInteractions.simulateClickAndSelectEvent(headers[1]); + }); + tick(); + expect(location.path()).toBe('/view2'); + expect(bottomNav.selectedIndex).toBe(-1); + + expect(indexChangingSpy).not.toHaveBeenCalled(); + expect(indexChangeSpy).not.toHaveBeenCalled(); + expect(itemChangeSpy).not.toHaveBeenCalled(); + + headers[1].dispatchEvent(KEY_ENTER_EVENT); + tick(200); + fixture.detectChanges(); + + expect(itemChangeSpy).toHaveBeenCalledWith({ + owner: bottomNav, + oldItem: undefined, + newItem: tabItems[1] + }); + expect(indexChangingSpy).toHaveBeenCalledWith({ + owner: bottomNav, + cancel: false, + oldIndex: -1, + newIndex: 1 + }); + expect(indexChangeSpy).toHaveBeenCalledWith(1); + })); + }); + }); +}); diff --git a/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav.component.ts b/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav.component.ts new file mode 100644 index 00000000000..90de22cd5a7 --- /dev/null +++ b/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav.component.ts @@ -0,0 +1,56 @@ +import { Component } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import { IgxTabsBase, IgxTabsDirective } from 'igniteui-angular/tabs'; + + +/** @hidden */ +let NEXT_BOTTOM_NAV_ITEM_ID = 0; + +/** + * Bottom Navigation component enables the user to navigate among a number of contents displayed in a single view. + * + * @igxModule IgxBottomNavModule + * + * @igxTheme igx-bottom-nav-theme + * + * @igxKeywords bottom navigation + * + * @igxGroup Layouts + * + * @remarks + * The Ignite UI for Angular Bottom Navigation component enables the user to navigate among a number of contents + * displayed in a single view. The navigation through the contents is accomplished with the tab buttons located at bottom. + * + * @example + * ```html + * + * + * + * folder + * Tab 1 + * + * + * Content 1 + * + * + * ... + * + * ``` + */ +@Component({ + selector: 'igx-bottom-nav', + templateUrl: 'bottom-nav.component.html', + providers: [{ provide: IgxTabsBase, useExisting: IgxBottomNavComponent }], + imports: [NgTemplateOutlet] +}) +export class IgxBottomNavComponent extends IgxTabsDirective { + /** @hidden */ + public override disableAnimation = true; + /** @hidden */ + protected override componentName = 'igx-bottom-nav'; + + /** @hidden */ + protected getNextTabId() { + return NEXT_BOTTOM_NAV_ITEM_ID++; + } +} diff --git a/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav.directives.ts b/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav.directives.ts new file mode 100644 index 00000000000..3348cf062be --- /dev/null +++ b/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav.directives.ts @@ -0,0 +1,13 @@ +import { Directive } from '@angular/core'; + +@Directive({ + selector: 'igx-bottom-nav-header-label,[igxBottomNavHeaderLabel]', + standalone: true +}) +export class IgxBottomNavHeaderLabelDirective { } + +@Directive({ + selector: 'igx-bottom-nav-header-icon,[igxBottomNavHeaderIcon]', + standalone: true +}) +export class IgxBottomNavHeaderIconDirective { } diff --git a/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav.module.ts b/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav.module.ts new file mode 100644 index 00000000000..c76878569b6 --- /dev/null +++ b/projects/igniteui-angular/bottom-nav/src/bottom-nav/bottom-nav.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { IGX_BOTTOM_NAV_DIRECTIVES } from './public_api'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + ...IGX_BOTTOM_NAV_DIRECTIVES + ], + exports: [ + ...IGX_BOTTOM_NAV_DIRECTIVES + ] +}) +export class IgxBottomNavModule { } diff --git a/projects/igniteui-angular/bottom-nav/src/bottom-nav/public_api.ts b/projects/igniteui-angular/bottom-nav/src/bottom-nav/public_api.ts new file mode 100644 index 00000000000..49252d5f3ce --- /dev/null +++ b/projects/igniteui-angular/bottom-nav/src/bottom-nav/public_api.ts @@ -0,0 +1,21 @@ +import { IgxBottomNavContentComponent } from './bottom-nav-content.component'; +import { IgxBottomNavHeaderComponent } from './bottom-nav-header.component'; +import { IgxBottomNavItemComponent } from './bottom-nav-item.component'; +import { IgxBottomNavComponent } from './bottom-nav.component'; +import { IgxBottomNavHeaderIconDirective, IgxBottomNavHeaderLabelDirective } from './bottom-nav.directives'; + +export * from './bottom-nav.component'; +export * from './bottom-nav-item.component'; +export * from './bottom-nav-header.component'; +export * from './bottom-nav.directives'; +export * from './bottom-nav-content.component'; + +/* NOTE: Bottom navigation directives collection for ease-of-use import in standalone components scenario */ +export const IGX_BOTTOM_NAV_DIRECTIVES = [ + IgxBottomNavComponent, + IgxBottomNavItemComponent, + IgxBottomNavHeaderComponent, + IgxBottomNavContentComponent, + IgxBottomNavHeaderLabelDirective, + IgxBottomNavHeaderIconDirective +] as const; diff --git a/projects/igniteui-angular/bottom-nav/src/public_api.ts b/projects/igniteui-angular/bottom-nav/src/public_api.ts new file mode 100644 index 00000000000..c31fa99f15c --- /dev/null +++ b/projects/igniteui-angular/bottom-nav/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './bottom-nav/public_api'; +export * from './bottom-nav/bottom-nav.module'; diff --git a/projects/igniteui-angular/button-group/README.md b/projects/igniteui-angular/button-group/README.md new file mode 100644 index 00000000000..9e69f2232a1 --- /dev/null +++ b/projects/igniteui-angular/button-group/README.md @@ -0,0 +1,40 @@ +# igx-ButtonGroup + +The **igx-ButtonGroup** component aims at providing a button group functionality to developers that also allow horizontal/vertical alignment, single/multiple selection with toggling. The igx-ButtounGroup component makes use of the igxButton directive. +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/buttongroup.html) + +# Usage +```html + + +``` + +# API Summary +| Name | Type | Description | +|:----------|:-------------:|:------| +| `id` | string | Unique identifier of the component. If not provided it will be automatically generated.| +| `multiSelection` | boolean | Enables selecting multiple buttons. Value by default is false. | +| `alignment` | enum | Set the button group alignment. Available enum members are ButtonGroupAlignment.horizontal (default) or ButtonGroupAlignment.vertical. | +| `disabled` | boolean | Disables the igxButtounGroup component. False by default. | + +# API Methods +| Name | Description | +|:----------|:------| +| `selectButton(index: number)` | Selects a button by its index. | +| `deselectButton(index: number)` | Deselects a button by its index. | +| `selectedButtons()` | Gets the selected button/buttons. | + +# Events +| Name | Description | +|:----------|:-------------:| +| `onSelect` | Fired when a button is selected. | +| `onUnselect` | Fired when a button is unselected. | +| `onClick` | Fired when a button is clicked. | + +# Examples + +Using `igx-ButtonGroup` to organize buttons into an Angular styled button group. +```html + + +``` diff --git a/projects/igniteui-angular/button-group/index.ts b/projects/igniteui-angular/button-group/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/button-group/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/button-group/ng-package.json b/projects/igniteui-angular/button-group/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/button-group/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/button-group/src/button-group/button-group-content.component.html b/projects/igniteui-angular/button-group/src/button-group/button-group-content.component.html new file mode 100644 index 00000000000..58f674c856a --- /dev/null +++ b/projects/igniteui-angular/button-group/src/button-group/button-group-content.component.html @@ -0,0 +1,23 @@ +
+ @for (button of values; track button) { + + } + +
diff --git a/projects/igniteui-angular/button-group/src/button-group/button-group.component.spec.ts b/projects/igniteui-angular/button-group/src/button-group/button-group.component.spec.ts new file mode 100644 index 00000000000..dd51eec749e --- /dev/null +++ b/projects/igniteui-angular/button-group/src/button-group/button-group.component.spec.ts @@ -0,0 +1,642 @@ +import { Component, OnInit, ViewChild } from '@angular/core'; +import { TestBed, fakeAsync, flushMicrotasks, waitForAsync } from '@angular/core/testing'; +import { ButtonGroupAlignment, IgxButtonGroupComponent } from './button-group.component'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxButtonDirective } from '../../../directives/src/directives/button/button.directive'; +import { IgxRadioComponent } from '../../../radio/src/radio/radio.component'; +import { UIInteractions, wait } from 'igniteui-angular/test-utils/ui-interactions.spec'; +import { IgxRadioGroupDirective } from 'igniteui-angular/radio'; + +interface IButton { + type?: string; + ripple?: string; + label?: string; + disabled?: boolean; + togglable?: boolean; + selected?: boolean; + color?: string; + bgcolor?: string; + icon?: string; +} + +class Button { + private type: string; + private ripple: string; + private label: string; + private disabled: boolean; + private togglable: boolean; + private selected: boolean; + private color: string; + private bgcolor: string; + private icon: string; + + constructor(obj?: IButton) { + this.type = obj.type || 'contained'; + this.ripple = obj.ripple || 'orange'; + this.label = obj.label || 'Button label'; + this.selected = obj.selected || false; + this.togglable = obj.togglable && true; + this.disabled = obj.disabled || false; + this.color = obj.color || '#484848'; + this.bgcolor = obj.bgcolor || 'white'; + this.icon = obj.icon || ''; + } +} + + +describe('IgxButtonGroup', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + InitButtonGroupComponent, + InitButtonGroupWithValuesComponent, + TemplatedButtonGroupComponent, + TemplatedButtonGroupDesplayDensityComponent, + ButtonGroupWithSelectedButtonComponent, + ButtonGroupButtonWithBoundSelectedOutputComponent, + ] + }).compileComponents(); + })); + + it('should initialize buttonGroup with default values', () => { + const fixture = TestBed.createComponent(InitButtonGroupComponent); + fixture.detectChanges(); + + const instance = fixture.componentInstance; + const buttongroup = fixture.componentInstance.buttonGroup; + + expect(instance.buttonGroup).toBeDefined(); + expect(buttongroup instanceof IgxButtonGroupComponent).toBe(true); + expect(instance.buttonGroup.id).toContain('igx-buttongroup-'); + expect(buttongroup.disabled).toBeFalsy(); + expect(buttongroup.alignment).toBe(ButtonGroupAlignment.horizontal); + expect(buttongroup.selectionMode).toBe('single'); + expect(buttongroup.itemContentCssClass).toBeUndefined(); + expect(buttongroup.selectedIndexes.length).toEqual(1); + expect(buttongroup.selectedButtons.length).toEqual(1); + }); + + it('should initialize buttonGroup with passed values', () => { + const fixture = TestBed.createComponent(InitButtonGroupWithValuesComponent); + fixture.detectChanges(); + + const instance = fixture.componentInstance; + const buttongroup = fixture.componentInstance.buttonGroup; + + expect(instance.buttonGroup).toBeDefined(); + expect(buttongroup instanceof IgxButtonGroupComponent).toBe(true); + expect(buttongroup.disabled).toBeFalsy(); + expect(buttongroup.alignment).toBe(ButtonGroupAlignment.vertical); + expect(buttongroup.selectionMode).toBe('multi'); + expect(buttongroup.itemContentCssClass).toEqual('customContentStyle'); + expect(buttongroup.selectedIndexes.length).toEqual(0); + expect(buttongroup.selectedButtons.length).toEqual(0); + }); + + it('should fire the selected event when a button is selected by user interaction, not on initial or programmatic selection', () => { + const fixture = TestBed.createComponent(ButtonGroupWithSelectedButtonComponent); + fixture.detectChanges(); + + const btnGroupInstance = fixture.componentInstance.buttonGroup; + spyOn(btnGroupInstance.selected, 'emit'); + + btnGroupInstance.ngAfterViewInit(); + fixture.detectChanges(); + + expect(btnGroupInstance.selected.emit).not.toHaveBeenCalled(); + + btnGroupInstance.buttons[1].select(); + fixture.detectChanges(); + + expect(btnGroupInstance.selected.emit).not.toHaveBeenCalled(); + + const button = fixture.debugElement.nativeElement.querySelector('button'); + button.click(); + // The first button is already selected, so it should not fire the selected event, but the deselected one. + expect(btnGroupInstance.selected.emit).not.toHaveBeenCalled(); + + const unselectedButton = fixture.debugElement.nativeElement.querySelector('#unselected'); + unselectedButton.click(); + expect(btnGroupInstance.selected.emit).toHaveBeenCalled(); + }); + + it('should fire the deselected event when a button is deselected by user interaction, not on programmatic deselection', () => { + const fixture = TestBed.createComponent(ButtonGroupWithSelectedButtonComponent); + fixture.detectChanges(); + + const btnGroupInstance = fixture.componentInstance.buttonGroup; + btnGroupInstance.buttons[0].select(); + btnGroupInstance.buttons[1].select(); + spyOn(btnGroupInstance.deselected, 'emit'); + + btnGroupInstance.ngAfterViewInit(); + fixture.detectChanges(); + + expect(btnGroupInstance.deselected.emit).not.toHaveBeenCalled(); + + btnGroupInstance.buttons[1].deselect(); + fixture.detectChanges(); + + expect(btnGroupInstance.deselected.emit).not.toHaveBeenCalled(); + + const button = fixture.debugElement.nativeElement.querySelector('button'); + button.click(); + + expect(btnGroupInstance.deselected.emit).toHaveBeenCalled(); + }); + + it('should should reset its current selection state on selectionMode runtime change', async () => { + const fixture = TestBed.createComponent(ButtonGroupWithSelectedButtonComponent); + + await wait(); + fixture.detectChanges(); + + const buttonGroup = fixture.componentInstance.buttonGroup; + + buttonGroup.selectionMode = 'multi'; + + await wait(); + fixture.detectChanges(); + + buttonGroup.selectButton(0); + buttonGroup.selectButton(1); + buttonGroup.selectButton(2); + + await wait(); + fixture.detectChanges(); + + expect(buttonGroup.selectedButtons.length).toBe(3); + + + buttonGroup.selectionMode = 'single'; + + await wait(); + fixture.detectChanges(); + + expect(buttonGroup.selectedButtons.length).toBe(0); + }); + + it('Button Group single selection', async () => { + const fixture = TestBed.createComponent(InitButtonGroupComponent); + + await wait(); + fixture.detectChanges(); + + const buttongroup = fixture.componentInstance.buttonGroup; + + buttongroup.selectButton(0); + await wait(); + expect(buttongroup.selectedButtons.length).toBe(1); + expect(buttongroup.buttons.indexOf(buttongroup.selectedButtons[0])).toBe(0); + + buttongroup.selectButton(2); + await wait(); + expect(buttongroup.selectedButtons.length).toBe(1); + expect(buttongroup.buttons.indexOf(buttongroup.selectedButtons[0])).toBe(2); + }); + + it('Button Group single required selection', async () => { + const fixture = TestBed.createComponent(InitButtonGroupComponent); + await wait(); + fixture.detectChanges(); + + const buttongroup = fixture.componentInstance.buttonGroup; + buttongroup.selectionMode = 'singleRequired'; + await wait(); + spyOn(buttongroup.deselected, 'emit'); + + buttongroup.selectButton(0); + await wait(); + expect(buttongroup.selectedButtons.length).toBe(1); + expect(buttongroup.buttons.indexOf(buttongroup.selectedButtons[0])).toBe(0); + + const button = fixture.debugElement.nativeElement.querySelector('button'); + button.click(); + await wait(); + + expect(buttongroup.selectedButtons.length).toBe(1); + expect(buttongroup.buttons.indexOf(buttongroup.selectedButtons[0])).toBe(0); + expect(buttongroup.deselected.emit).not.toHaveBeenCalled(); + }); + + it('Button Group multiple selection', async () => { + const fixture = TestBed.createComponent(InitButtonGroupWithValuesComponent); + await wait(); + fixture.detectChanges(); + + const buttongroup = fixture.componentInstance.buttonGroup; + expect(buttongroup.selectionMode).toBe('multi'); + + buttongroup.selectButton(1); + await wait(); + expect(buttongroup.selectedButtons.length).toBe(1); + + buttongroup.selectButton(2); + await wait(); + expect(buttongroup.selectedButtons.length).toBe(2); + + buttongroup.deselectButton(2); + buttongroup.deselectButton(1); + await wait(); + expect(buttongroup.selectedButtons.length).toBe(0); + + buttongroup.selectButton(0); + buttongroup.selectButton(3); + await wait(); + // Button 3 is disabled, but it can be selected + expect(buttongroup.selectedButtons.length).toBe(2); + }); + + it('Button Group multiple selection with mouse click', async () => { + const fixture = TestBed.createComponent(InitButtonGroupWithValuesComponent); + await wait(); + fixture.detectChanges(); + + const buttongroup = fixture.componentInstance.buttonGroup; + expect(buttongroup.selectionMode).toBe('multi'); + + UIInteractions.simulateClickEvent(buttongroup.buttons[0].nativeElement); + await wait(); + expect(buttongroup.selectedButtons.length).toBe(1); + + UIInteractions.simulateClickEvent(buttongroup.buttons[1].nativeElement); + await wait(); + expect(buttongroup.selectedButtons.length).toBe(2); + + UIInteractions.simulateClickEvent(buttongroup.buttons[0].nativeElement); + UIInteractions.simulateClickEvent(buttongroup.buttons[1].nativeElement); + await wait(); + expect(buttongroup.selectedButtons.length).toBe(0); + + buttongroup.buttons[0].nativeElement.click(); + buttongroup.buttons[3].nativeElement.click(); + await wait(); + // Button 3 is disabled, and it should not be selected with mouse click + expect(buttongroup.selectedButtons.length).toBe(1); + }); + + it('Button Group - templated buttons with multiple selection', async () => { + const fixture = TestBed.createComponent(TemplatedButtonGroupComponent); + await wait(); + fixture.detectChanges(); + + const buttongroup = fixture.componentInstance.buttonGroup; + expect(buttongroup.buttons.length).toBe(4); + expect(buttongroup.selectionMode).toBe('multi'); + + buttongroup.selectButton(1); + await wait(); + expect(buttongroup.selectedButtons.length).toBe(1); + + buttongroup.selectButton(2); + await wait(); + expect(buttongroup.selectedButtons.length).toBe(2); + + buttongroup.deselectButton(1); + buttongroup.deselectButton(2); + await wait(); + expect(buttongroup.selectedButtons.length).toBe(0); + + buttongroup.selectButton(0); + buttongroup.selectButton(3); + await wait(); + // It should be possible to select disabled buttons + expect(buttongroup.selectedButtons.length).toBe(2); + + buttongroup.deselectButton(3); + await wait(); + expect(buttongroup.selectedButtons.length).toBe(1); + }); + + it('Button Group - templated buttons with single selection', async () => { + const fixture = TestBed.createComponent(TemplatedButtonGroupComponent); + await wait(); + fixture.detectChanges(); + + const buttongroup = fixture.componentInstance.buttonGroup; + buttongroup.selectionMode = 'single'; + await wait(); + expect(buttongroup.buttons.length).toBe(4); + expect(buttongroup.selectionMode).toBe('single'); + + buttongroup.selectButton(1); + await wait(); + expect(buttongroup.selectedButtons.length).toBe(1); + expect(buttongroup.buttons.indexOf(buttongroup.selectedButtons[0])).toBe(1); + + buttongroup.selectButton(2); + await wait(); + expect(buttongroup.selectedButtons.length).toBe(1); + expect(buttongroup.buttons.indexOf(buttongroup.selectedButtons[0])).toBe(2); + + buttongroup.deselectButton(2); + await wait(); + expect(buttongroup.selectedButtons.length).toBe(0); + + buttongroup.selectButton(0); + buttongroup.selectButton(2); + buttongroup.selectButton(3); + await wait(); + expect(buttongroup.selectedButtons.length).toBe(1); + // Button 3 is disabled, but it can be selected + expect(buttongroup.buttons.indexOf(buttongroup.selectedButtons[0])).toBe(3); + }); + + it('Button Group - selection handles wrong indexes gracefully', () => { + const fixture = TestBed.createComponent(TemplatedButtonGroupComponent); + fixture.detectChanges(); + + const buttongroup = fixture.componentInstance.buttonGroup; + let error = ''; + + try { + buttongroup.selectButton(-1); + buttongroup.selectButton(3000); + + buttongroup.deselectButton(-1); + buttongroup.deselectButton(3000); + } catch (ex) { + error = ex.message; + } + + expect(error).toBe(''); + }); + + it('Button Group - should support tab navigation', () => { + const fixture = TestBed.createComponent(InitButtonGroupWithValuesComponent); + fixture.detectChanges(); + + const buttongroup = fixture.componentInstance.buttonGroup; + const groupChildren = buttongroup.buttons; + + for (let i = 0; i < groupChildren.length; i++) { + const button = groupChildren[i]; + expect(button.nativeElement.tagName).toBe('BUTTON'); + + if (i < groupChildren.length - 1) { + expect(button.nativeElement.disabled).toBe(false); + } else { + expect(button.nativeElement.disabled).toBe(true); + } + } + }); + + it('should style the corresponding button as deselected when the value bound to the selected input changes', fakeAsync(() => { + const fixture = TestBed.createComponent(ButtonGroupButtonWithBoundSelectedOutputComponent); + fixture.detectChanges(); + + const btnGroupInstance = fixture.componentInstance.buttonGroup; + + expect(btnGroupInstance.selectedButtons.length).toBe(1); + expect(btnGroupInstance.buttons[1].selected).toBe(true); + + fixture.componentInstance.selectedValue = 100; + flushMicrotasks(); + fixture.detectChanges(); + + btnGroupInstance.buttons.forEach((button) => { + expect(button.selected).toBe(false); + }); + })); + + it('should correctly change the selection state of a button group and styling of its buttons when bound to another component\'s selection', async () => { + const fixture = TestBed.createComponent(ButtonGroupSelectionBoundToAnotherComponent); + fixture.detectChanges(); + + const radioGroup = fixture.componentInstance.radioGroup; + const buttonGroup = fixture.componentInstance.buttonGroup; + expect(radioGroup.radioButtons.last.checked).toBe(true); + expect(buttonGroup.buttons[1].selected).toBe(true); + expect(buttonGroup.buttons[1].nativeElement.classList.contains('igx-button-group__item--selected')).toBe(true); + + radioGroup.radioButtons.first.select(); + fixture.detectChanges(); + await wait(); + + expect(radioGroup.radioButtons.first.checked).toBe(true); + expect(buttonGroup.buttons[0].selected).toBe(true); + expect(buttonGroup.buttons[0].nativeElement.classList.contains('igx-button-group__item--selected')).toBe(true); + expect(buttonGroup.buttons[1].selected).toBe(false); + expect(buttonGroup.buttons[1].nativeElement.classList.contains('igx-button-group__item--selected')).toBe(false); + + radioGroup.radioButtons.last.select(); + fixture.detectChanges(); + await wait(); + + expect(radioGroup.radioButtons.last.checked).toBe(true); + expect(buttonGroup.buttons[1].selected).toBe(true); + expect(buttonGroup.buttons[1].nativeElement.classList.contains('igx-button-group__item--selected')).toBe(true); + expect(buttonGroup.buttons[0].selected).toBe(false); + expect(buttonGroup.buttons[0].nativeElement.classList.contains('igx-button-group__item--selected')).toBe(false); + }); + + it('should emit selected event only once per selection', async() => { + const fixture = TestBed.createComponent(InitButtonGroupComponent); + fixture.detectChanges(); + await wait(); + + const buttonGroup = fixture.componentInstance.buttonGroup; + + spyOn(buttonGroup.selected, 'emit').and.callThrough(); + + buttonGroup.selectButton(0); + await wait(); + fixture.detectChanges(); + + const buttons = fixture.nativeElement.querySelectorAll('button'); + buttons[1].click(); + await wait(); + fixture.detectChanges(); + + expect(buttonGroup.selected.emit).toHaveBeenCalledTimes(1); + + buttons[0].click(); + await wait(); + fixture.detectChanges(); + + expect(buttonGroup.selected.emit).toHaveBeenCalledTimes(2); + }); +}); + +@Component({ + template: ``, + imports: [IgxButtonGroupComponent] +}) +class InitButtonGroupComponent implements OnInit { + @ViewChild(IgxButtonGroupComponent, { static: true }) public buttonGroup: IgxButtonGroupComponent; + + public buttons: Button[]; + + constructor() {} + + public ngOnInit(): void { + this.buttons = [ + new Button({ + disabled: false, + label: 'Euro', + selected: false + }), + new Button({ + label: 'British Pound', + selected: true + }), + new Button({ + label: 'US Dollar', + selected: false + }) + ]; + } +} + +@Component({ + template: ` + + + `, + imports: [IgxButtonGroupComponent] +}) +class InitButtonGroupWithValuesComponent implements OnInit { + @ViewChild(IgxButtonGroupComponent, { static: true }) public buttonGroup: IgxButtonGroupComponent; + + public cities: Button[]; + + public alignment = ButtonGroupAlignment.vertical; + + constructor() {} + + public ngOnInit(): void { + + this.cities = [ + new Button({ + disabled: false, + label: 'Sofia', + selected: false, + togglable: false + }), + new Button({ + disabled: false, + label: 'London', + selected: false + }), + new Button({ + disabled: false, + label: 'New York', + selected: false + }), + new Button({ + disabled: true, + label: 'Tokyo', + selected: false + }) + ]; + } +} + + +@Component({ + template: ` + + + + + + + `, + imports: [IgxButtonGroupComponent, IgxButtonDirective] +}) +class TemplatedButtonGroupComponent { + @ViewChild(IgxButtonGroupComponent, { static: true }) public buttonGroup: IgxButtonGroupComponent; + + public alignment = ButtonGroupAlignment.vertical; +} + +@Component({ + template: ` + + + + + `, + imports: [IgxButtonGroupComponent, IgxButtonDirective] +}) +class TemplatedButtonGroupDesplayDensityComponent { + @ViewChild(IgxButtonGroupComponent, { static: true }) public buttonGroup: IgxButtonGroupComponent; +} + +@Component({ + template: ` + + + + + + `, + imports: [IgxButtonGroupComponent, IgxButtonDirective] +}) +class ButtonGroupWithSelectedButtonComponent { + @ViewChild(IgxButtonGroupComponent, { static: true }) public buttonGroup: IgxButtonGroupComponent; +} + +@Component({ + template: ` + + @for (item of items; track item.key) { + + } + + `, + imports: [IgxButtonGroupComponent, IgxButtonDirective] +}) +class ButtonGroupButtonWithBoundSelectedOutputComponent { + @ViewChild(IgxButtonGroupComponent, { static: true }) public buttonGroup: IgxButtonGroupComponent; + + public items = [ + { key: 0, value: 'Button 1' }, + { key: 1, value: 'Button 2' }, + { key: 2, value: 'Button 3' }, + ]; + + public selectedValue = 1; +} + +@Component({ + template: ` + + @for (item of ['Foo', 'Bar']; track item) { + + {{ item }} + + } + + + + + + + `, + imports: [IgxButtonGroupComponent, IgxButtonDirective, IgxRadioGroupDirective, IgxRadioComponent] +}) +class ButtonGroupSelectionBoundToAnotherComponent { + @ViewChild('radioGroup', { read: IgxRadioGroupDirective, static: true }) public radioGroup: IgxRadioGroupDirective; + @ViewChild('buttonGroup', { static: true }) public buttonGroup: IgxButtonGroupComponent; + + public selectedValue = 'Bar'; + + public onRadioChange(event: { value: string; }) { + this.selectedValue = event.value; + } + + public get isFirstRadioButtonSelected() { + return this.selectedValue === 'Foo'; + } +} diff --git a/projects/igniteui-angular/button-group/src/button-group/button-group.component.ts b/projects/igniteui-angular/button-group/src/button-group/button-group.component.ts new file mode 100644 index 00000000000..f11590b12c7 --- /dev/null +++ b/projects/igniteui-angular/button-group/src/button-group/button-group.component.ts @@ -0,0 +1,539 @@ +import { AfterViewInit, Component, ContentChildren, ChangeDetectorRef, EventEmitter, HostBinding, Input, Output, QueryList, Renderer2, ViewChildren, OnDestroy, ElementRef, booleanAttribute, inject } from '@angular/core'; +import { Subject } from 'rxjs'; +import { IgxButtonDirective } from 'igniteui-angular/directives'; +import { IgxRippleDirective } from 'igniteui-angular/directives'; + +import { takeUntil } from 'rxjs/operators'; +import { IBaseEventArgs } from 'igniteui-angular/core'; +import { IgxIconComponent } from 'igniteui-angular/icon'; + +/** + * Determines the Button Group alignment + */ +export const ButtonGroupAlignment = { + horizontal: 'horizontal', + vertical: 'vertical' +} as const; +export type ButtonGroupAlignment = typeof ButtonGroupAlignment[keyof typeof ButtonGroupAlignment]; + +let NEXT_ID = 0; + +/** + * **Ignite UI for Angular Button Group** - + * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/buttongroup.html) + * + * The Ignite UI Button Group displays a group of buttons either vertically or horizontally. The group supports + * single, multi and singleRequired selection. + * + * Example: + * ```html + * + * + * ``` + * The `fontOptions` value shown above is defined as: + * ```typescript + * this.fontOptions = [ + * { icon: 'format_bold', selected: false }, + * { icon: 'format_italic', selected: false }, + * { icon: 'format_underlined', selected: false }]; + * ``` + */ +@Component({ + selector: 'igx-buttongroup', + templateUrl: 'button-group-content.component.html', + imports: [IgxButtonDirective, IgxRippleDirective, IgxIconComponent] +}) +export class IgxButtonGroupComponent implements AfterViewInit, OnDestroy { + private _cdr = inject(ChangeDetectorRef); + private _renderer = inject(Renderer2); + private _el = inject(ElementRef); + + /** + * A collection containing all buttons inside the button group. + */ + public get buttons(): IgxButtonDirective[] { + return [...this.viewButtons.toArray(), ...this.templateButtons.toArray()]; + } + + /** + * Gets/Sets the value of the `id` attribute. If not set it will be automatically generated. + * ```html + * + * ``` + */ + @HostBinding('attr.id') + @Input() + public id = `igx-buttongroup-${NEXT_ID++}`; + + /** + * @hidden + */ + @HostBinding('style.zIndex') + public zIndex = 0; + + /** + * Allows you to set a style using the `itemContentCssClass` input. + * The value should be the CSS class name that will be applied to the button group. + * ```typescript + * public style1 = "styleClass"; + * //.. + * ``` + * ```html + * + * ``` + */ + @Input() + public set itemContentCssClass(value: string) { + this._itemContentCssClass = value || this._itemContentCssClass; + } + + /** + * Returns the CSS class of the item content of the `IgxButtonGroup`. + * ```typescript + * @ViewChild("MyChild") + * public buttonG: IgxButtonGroupComponent; + * ngAfterViewInit(){ + * let buttonSelect = this.buttonG.itemContentCssClass; + * } + * ``` + */ + public get itemContentCssClass(): string { + return this._itemContentCssClass; + } + + /** + * Enables selecting multiple buttons. By default, multi-selection is false. + * + * @deprecated in version 16.1.0. Use the `selectionMode` property instead. + */ + @Input() + public get multiSelection() { + if (this.selectionMode === 'multi') { + return true; + } else { + return false; + } + } + public set multiSelection(selectionMode: boolean) { + if (selectionMode) { + this.selectionMode = 'multi'; + } else { + this.selectionMode = 'single'; + } + } + + /** + * Gets/Sets the selection mode to 'single', 'singleRequired' or 'multi' of the buttons. By default, the selection mode is 'single'. + * ```html + * + * ``` + */ + @Input() + public get selectionMode() { + return this._selectionMode; + } + public set selectionMode(selectionMode: 'single' | 'singleRequired' | 'multi') { + if (this.viewButtons && selectionMode !== this._selectionMode) { + this.buttons.forEach((b,i) => { + this.deselectButton(i); + }); + this._selectionMode = selectionMode; + } else { + this._selectionMode = selectionMode; + } + } + + /** + * Property that configures the buttons in the button group using a collection of `Button` objects. + * ```typescript + * public ngOnInit() { + * this.cities = [ + * new Button({ + * label: "Sofia" + * }), + * new Button({ + * label: "London" + * }), + * new Button({ + * label: "New York", + * selected: true + * }), + * new Button({ + * label: "Tokyo" + * }) + * ]; + * } + * //.. + * ``` + * ```html + * + * ``` + */ + @Input() public values: any; + + /** + * Disables the `igx-buttongroup` component. By default it's false. + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public get disabled(): boolean { + return this._disabled; + } + public set disabled(value: boolean) { + if (this._disabled !== value) { + this._disabled = value; + + if (this.viewButtons && this.templateButtons) { + this.buttons.forEach((b) => (b.disabled = this._disabled)); + } + } + } + + /** + * Allows you to set the button group alignment. + * Available options are `ButtonGroupAlignment.horizontal` (default) and `ButtonGroupAlignment.vertical`. + * ```typescript + * public alignment = ButtonGroupAlignment.vertical; + * //.. + * ``` + * ```html + * + * ``` + */ + @Input() + public set alignment(value: ButtonGroupAlignment) { + this._isVertical = value === ButtonGroupAlignment.vertical; + } + /** + * Returns the alignment of the `igx-buttongroup`. + * ```typescript + * @ViewChild("MyChild") + * public buttonG: IgxButtonGroupComponent; + * ngAfterViewInit(){ + * let buttonAlignment = this.buttonG.alignment; + * } + * ``` + */ + public get alignment(): ButtonGroupAlignment { + return this._isVertical ? ButtonGroupAlignment.vertical : ButtonGroupAlignment.horizontal; + } + + /** + * An @Ouput property that emits an event when a button is selected. + * ```typescript + * @ViewChild("toast") + * private toast: IgxToastComponent; + * public selectedHandler(buttongroup) { + * this.toast.open() + * } + * //... + * ``` + * ```html + * + * You have made a selection! + * ``` + */ + @Output() + public selected = new EventEmitter(); + + /** + * An @Ouput property that emits an event when a button is deselected. + * ```typescript + * @ViewChild("toast") + * private toast: IgxToastComponent; + * public deselectedHandler(buttongroup){ + * this.toast.open() + * } + * //... + * ``` + * ```html + * #MyChild [selectionMode]="'multi'" (deselected)="deselectedHandler($event)"> + * You have deselected a button! + * ``` + */ + @Output() + public deselected = new EventEmitter(); + + @ViewChildren(IgxButtonDirective) private viewButtons: QueryList; + @ContentChildren(IgxButtonDirective) private templateButtons: QueryList; + + /** + * Returns true if the `igx-buttongroup` alignment is vertical. + * Note that in order for the accessor to work correctly the property should be set explicitly. + * ```html + * + * ``` + * ```typescript + * //... + * @ViewChild("MyChild") + * private buttonG: IgxButtonGroupComponent; + * ngAfterViewInit(){ + * let orientation = this.buttonG.isVertical; + * } + * ``` + */ + public get isVertical(): boolean { + return this._isVertical; + } + + /** + * @hidden + */ + public selectedIndexes: number[] = []; + + protected buttonClickNotifier$ = new Subject(); + protected queryListNotifier$ = new Subject(); + + private _isVertical: boolean; + private _itemContentCssClass: string; + private _disabled = false; + private _selectionMode: 'single' | 'singleRequired' | 'multi' = 'single'; + + private mutationObserver: MutationObserver; + private observerConfig: MutationObserverInit = { + attributeFilter: ["data-selected"], + childList: true, + subtree: true, + }; + + /** + * Gets the selected button/buttons. + * ```typescript + * @ViewChild("MyChild") + * private buttonG: IgxButtonGroupComponent; + * ngAfterViewInit(){ + * let selectedButton = this.buttonG.selectedButtons; + * } + * ``` + */ + public get selectedButtons(): IgxButtonDirective[] { + return this.buttons.filter((_, i) => this.selectedIndexes.indexOf(i) !== -1); + } + + /** + * Selects a button by its index. + * ```typescript + * @ViewChild("MyChild") + * private buttonG: IgxButtonGroupComponent; + * ngAfterViewInit(){ + * this.buttonG.selectButton(2); + * this.cdr.detectChanges(); + * } + * ``` + * + * @memberOf {@link IgxButtonGroupComponent} + */ + public selectButton(index: number) { + if (index >= this.buttons.length || index < 0) { + return; + } + + const button = this.buttons[index]; + button.select(); + } + + /** + * @hidden + * @internal + */ + public updateSelected(index: number) { + const button = this.buttons[index]; + + if (this.selectedIndexes.indexOf(index) === -1) { + this.selectedIndexes.push(index); + } + + this._renderer.setAttribute(button.nativeElement, 'aria-pressed', 'true'); + this._renderer.addClass(button.nativeElement, 'igx-button-group__item--selected'); + + const indexInViewButtons = this.viewButtons.toArray().indexOf(button); + if (indexInViewButtons !== -1) { + this.values[indexInViewButtons].selected = true; + } + + // deselect other buttons if selectionMode is not multi + if (this.selectionMode !== 'multi' && this.selectedIndexes.length > 1) { + this.buttons.forEach((_, i) => { + if (i !== index && this.selectedIndexes.indexOf(i) !== -1) { + this.deselectButton(i); + this.updateDeselected(i); + } + }); + } + + } + + public updateDeselected(index: number) { + const button = this.buttons[index]; + if (this.selectedIndexes.indexOf(index) !== -1) { + this.selectedIndexes.splice(this.selectedIndexes.indexOf(index), 1); + } + + this._renderer.setAttribute(button.nativeElement, 'aria-pressed', 'false'); + this._renderer.removeClass(button.nativeElement, 'igx-button-group__item--selected'); + + const indexInViewButtons = this.viewButtons.toArray().indexOf(button); + if (indexInViewButtons !== -1) { + this.values[indexInViewButtons].selected = false; + } + } + + /** + * Deselects a button by its index. + * ```typescript + * @ViewChild("MyChild") + * private buttonG: IgxButtonGroupComponent; + * ngAfterViewInit(){ + * this.buttonG.deselectButton(2); + * this.cdr.detectChanges(); + * } + * ``` + * + * @memberOf {@link IgxButtonGroupComponent} + */ + public deselectButton(index: number) { + if (index >= this.buttons.length || index < 0) { + return; + } + + const button = this.buttons[index]; + button.deselect(); + } + + /** + * @hidden + */ + public ngAfterViewInit() { + const initButtons = () => { + // Cancel any existing buttonClick subscriptions + this.buttonClickNotifier$.next(); + + this.selectedIndexes.splice(0, this.selectedIndexes.length); + + // initial configuration + this.buttons.forEach((button, index) => { + const buttonElement = button.nativeElement; + this._renderer.addClass(buttonElement, 'igx-button-group__item'); + + if (this.disabled) { + button.disabled = true; + } + + if (button.selected) { + this.updateSelected(index); + } + + button.buttonClick.pipe(takeUntil(this.buttonClickNotifier$)).subscribe((_) => this._clickHandler(index)); + }); + }; + + this.mutationObserver = this.setMutationsObserver(); + + this.viewButtons.changes.pipe(takeUntil(this.queryListNotifier$)).subscribe(() => { + this.mutationObserver.disconnect(); + initButtons(); + this.mutationObserver?.observe(this._el.nativeElement, this.observerConfig); + }); + this.templateButtons.changes.pipe(takeUntil(this.queryListNotifier$)).subscribe(() => { + this.mutationObserver.disconnect(); + initButtons(); + this.mutationObserver?.observe(this._el.nativeElement, this.observerConfig); + }); + + initButtons(); + this._cdr.detectChanges(); + this.mutationObserver?.observe(this._el.nativeElement, this.observerConfig); + } + + /** + * @hidden + */ + public ngOnDestroy() { + this.buttonClickNotifier$.next(); + this.buttonClickNotifier$.complete(); + + this.queryListNotifier$.next(); + this.queryListNotifier$.complete(); + + this.mutationObserver?.disconnect(); + } + + /** + * @hidden + */ + public _clickHandler(index: number) { + const button = this.buttons[index]; + const args: IButtonGroupEventArgs = { owner: this, button, index }; + + if (this.selectionMode !== 'multi') { + this.buttons.forEach((b, i) => { + if (i !== index && this.selectedIndexes.indexOf(i) !== -1) { + this.deselected.emit({ owner: this, button: b, index: i }); + } + }); + } + + if (this.selectedIndexes.indexOf(index) === -1) { + this.selectButton(index); + this.selected.emit(args); + } else { + if (this.selectionMode !== 'singleRequired') { + this.deselectButton(index); + this.deselected.emit(args); + } + } + } + + private setMutationsObserver() { + if (typeof MutationObserver !== 'undefined') { + return new MutationObserver((records, observer) => { + // Stop observing while handling changes + observer.disconnect(); + + const updatedButtons = this.getUpdatedButtons(records); + + if (updatedButtons.length > 0) { + updatedButtons.forEach((button) => { + const index = this.buttons.map((b) => b.nativeElement).indexOf(button); + + this.updateButtonSelectionState(index); + }); + } + + // Watch for changes again + observer.observe(this._el.nativeElement, this.observerConfig); + }); + } + } + + private getUpdatedButtons(records: MutationRecord[]) { + const updated: HTMLButtonElement[] = []; + + records + .filter((x) => x.type === 'attributes') + .reduce((prev, curr) => { + prev.push( + curr.target as HTMLButtonElement + ); + return prev; + }, updated); + + return updated; + } + + private updateButtonSelectionState(index: number) { + if (this.buttons[index].selected) { + this.updateSelected(index); + } else { + this.updateDeselected(index); + } + } +} + +export interface IButtonGroupEventArgs extends IBaseEventArgs { + owner: IgxButtonGroupComponent; + button: IgxButtonDirective; + index: number; +} diff --git a/projects/igniteui-angular/button-group/src/button-group/button-group.module.ts b/projects/igniteui-angular/button-group/src/button-group/button-group.module.ts new file mode 100644 index 00000000000..215ade92931 --- /dev/null +++ b/projects/igniteui-angular/button-group/src/button-group/button-group.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from '@angular/core'; +import { IGX_BUTTON_GROUP_DIRECTIVES } from './public_api'; + +/** + * @hidden + * @deprecated + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [...IGX_BUTTON_GROUP_DIRECTIVES], + exports: [...IGX_BUTTON_GROUP_DIRECTIVES] +}) +export class IgxButtonGroupModule {} diff --git a/projects/igniteui-angular/button-group/src/button-group/public_api.ts b/projects/igniteui-angular/button-group/src/button-group/public_api.ts new file mode 100644 index 00000000000..5df07c7dfda --- /dev/null +++ b/projects/igniteui-angular/button-group/src/button-group/public_api.ts @@ -0,0 +1,10 @@ +import { IgxButtonDirective } from 'igniteui-angular/directives'; +import { IgxButtonGroupComponent } from './button-group.component'; + +export * from './button-group.component'; + +/* Button group directives collection for ease-of-use import in standalone components scenario */ +export const IGX_BUTTON_GROUP_DIRECTIVES = [ + IgxButtonGroupComponent, + IgxButtonDirective +] as const; diff --git a/projects/igniteui-angular/button-group/src/public_api.ts b/projects/igniteui-angular/button-group/src/public_api.ts new file mode 100644 index 00000000000..402befdd294 --- /dev/null +++ b/projects/igniteui-angular/button-group/src/public_api.ts @@ -0,0 +1,4 @@ +export * from './button-group/public_api'; + +// exporting for backward compatibility +export * from './button-group/button-group.module'; diff --git a/projects/igniteui-angular/calendar/README.md b/projects/igniteui-angular/calendar/README.md new file mode 100644 index 00000000000..4393e48ba12 --- /dev/null +++ b/projects/igniteui-angular/calendar/README.md @@ -0,0 +1,251 @@ +# igxCalendar Component + +The **igxCalendar** provides a way for the user to select date(s). +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/calendar.html) + +## Dependencies +In order to be able to use **igxCalendar** you should keep in mind that it is dependent on **BrowserAnimationsModule**, +which must be imported **only once** in your application's AppModule, for example: +```typescript +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +@NgModule({ + imports: [ + BrowserAnimationsModule, + ... + ] +}) +export class AppModule { +} +``` +Also the **igxCalendar** uses the [Intl](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat) WebAPI for localization and formatting of dates. Consider using the [appropriate polyfills](https://github.com/andyearnshaw/Intl.js/) if your target platform does not support them. + + +## Usage +Be sure to consult the API below for additional information. + +### Importing the calendar in your application + +```typescript +import { IgxCalendarComponent } from "igniteui-angular/main"; +``` +or +```typescript +import { IgxCalendarComponent } from "igniteui-angular/calendar"; +``` + +Instantiate a calendar component in single selection mode displaying the current month. +```html + +``` + + +A range selection calendar with first day of week set to Monday and an event +handler when selection is done. +```html + +``` + +A multiple selection calendar with different locale and templating for the subheader. +```html + + + {{ format.year.combined }} + {{ format.month.combined | titlecase }} + + +``` + +A calendar displaying more than one month in the view and hiding the days that are outside of the current month +```html + + +``` + +The **igxCalendar** implements the `ControlValueAccessor` interface, providing two-way data-binding +and the expected behavior when used both in Template-driven or Reactive Forms. + + +### Keyboard navigation +When the **igxCalendar** component is focused: +- `PageUp` will move to the previous month. +- `PageDown` will move to the next month. +- `Shift + PageUp` will move to the previous year. +- `Shift + PageDown` will move to the next year. +- `Home` will focus the first day of the current month (or first month if more months are displayed) hat is into view. +- `End` will focus the last day of the current month ((or last month if more months are displayed)) that is into view. +- `Tab` will navigate through the subheader buttons; + +When `prev` or `next` month buttons (in the subheader) are focused: +- `Space` or `Enter` will scroll into view the next or previous month. + +When `months` button (in the subheader) is focused: +- `Space` or `Enter` will open the months view. + +When `year` button (in the subheader) is focused: +- `Space` or `Enter` will open the decade view. + +When a day inside the current month is focused: +- Arrow keys will navigate through the days. +- Arrow keys will allow navigation to previous/next month as well. +- `Enter` will select the currently focused day. +- When more than one month view is displayed, navigating with the arrow keys should move to next/previous month after navigating from first/last day in current month. + +When a month inside the months view is focused: +- Arrow keys will navigate through the months. +- `Home` will focus the first month inside the months view. +- `End` will focus the last month inside the months view. +- `Enter` will select the currently focused month and close the view. + +When an year inside the decade view is focused: +- Arrow keys will navigate through the years. +- `Enter` will select the currently focused year and close the view. + +## API Summary + +### Inputs + +- `id: string` + +Unique identifier of the component. If not provided it will be automatically generated. + +- `vertical: boolean` + +Controls the layout of the calendar component. When vertical is set to `true` +the calendar header will be rendered to the side of the calendar body. +Defaults to `false`. + +- `weekStart: WEEKDAYS | number` + +Controls the starting day of the weeek for the calendar. +Defaults to Sunday. + +- `locale: string` + +Controls the locale used for formatting and displaying the dates in the calendar. +The expected string should be a [BCP 47 language tag](http://tools.ietf.org/html/rfc5646). +The default value is `en`. + +- `selection: CalendarSelection | string` + +Controls the type of selection in the calendar. Defaults to `CalendarSelection.SINGLE` which is equivalent to the string `single`. +Changing the selection type during 'runtime' will clear the previously selected values in the calendar. +The calendar header will not be rendered when the selection is either `multi` or `range`. + +- `viewDate: Date` + +Controls the year/month that will be presented in the default view when the calendar renders. By default it is the first day of the current year/month. + +- `value: Date | Date[]` + +Gets and sets the selected date(s) in the calendar component. +Both `multi` and `range` selection accepts single date values but they always return an array of date objects. + +- `formatOptions: Object` + +Controls the date-time components to use in formatted output, and their desired representations. +Consult [this](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat) +for additional information on the available options. + +The defaul values are listed below. +```typescript +{ day: 'numeric', month: 'short', weekday: 'short', year: 'numeric' } +``` + +- `formatViews: Object` + +Controls whether the date parts in the different calendar views should be formatted according to the provided +`locale` and `formatOptions`. + +The default values are listed below. +```typescript +{ day: false, month: true, year: false } +``` + +- `monthViewsNumber: number` +Controls the number of month views displayed. Default is 1. + +- `hideOusideDays: boolean` +Controls the visibility of the dates that do not belong to the current month. + + +### Outputs + +- `selected(): Date | Date[]` + +Event fired when a value is selected through UI interaction. +Emits the selected value (depending on the type of selection). + +- `viewDateChanged(): IViewDateChangeEventArgs` + +Event fired after the month/year presented in the view is changed. +Emits an object containing the previous and current value of the `viewDate` property. + +- `activeViewChanged(): CalendarView` + +Event fired after the active view is changed. +Emits an CalendarView enum, indicating the `activeView` property value. + + +### Methods + +- `selectedDate(value: Date | Date[]): void` + +Sets a new value for the calendar component. **Does not** trigger `selected` event. + +### Templating + +The **igxCalendar** supports templating of its header and subheader parts. +Just decorate a ng-template inside the calendar with `igxCalendarHeader` or `igxCalendarSubheader` directive +and use the context returned to customize the way the date is displayed. + +The template decorated with the `igxCalendarHeader` directive is rendered only when the calendar selection is set to `single`. +The `igxCalendarSubheader` is available in all selection modes. + +Example: + +```html + + + ... + + + + + {{ parts.month.combined }} + + + {{ parts.year.combined }} + + + +``` +#### Template context + +| Name | Type | Description | +| :-------- | :------: | :--------------------------------------------------------------------------- | +| date | Date | The date object in the context of the template. See * below for details. | +| full | string | The full date representation returned after applying the `formatOptions`. | +| monthView | Function | A function which when called puts the calendar in month view. | +| yearView | Function | A function which when called puts the calendar in year view. | +| era | Object | The era date component (if applicable) formatted to the supplied locale. | +| year | Object | The year date component (if applicable) formatted to the supplied locale. | +| month | Object | The month date component (if applicable) formatted to the supplied locale. | +| day | Object | The day date component (if applicable) formatted to the supplied locale. | +| weekday | Object | The weekday date component (if applicable) formatted to the supplied locale. | + +\* In the `igxCalendarHeader` context this is either the current date or the current selection of the calendar. +In the `igxCalendarSubheaderContext` this is the same as the `viewDate` + +**NOTE:** All of the date components (year, month, etc.) are objects with the structure +```typescript +{ + value: string; + literal: string; + combined: string; +} +``` +where `value` is the locale string representation of the date component, `literal` is the locale string separator (if any), +and `combined` is as the name suggests the combined output of the two. + +**NOTE 2:** Mind that both in Internet Explorer and Edge all of the date parts will be empty strings as both browsers don't +implement the Intl API providing this functionality. diff --git a/projects/igniteui-angular/calendar/index.ts b/projects/igniteui-angular/calendar/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/calendar/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/calendar/ng-package.json b/projects/igniteui-angular/calendar/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/calendar/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/calendar/src/calendar/calendar-base.ts b/projects/igniteui-angular/calendar/src/calendar/calendar-base.ts new file mode 100644 index 00000000000..55c938a24ba --- /dev/null +++ b/projects/igniteui-angular/calendar/src/calendar/calendar-base.ts @@ -0,0 +1,1030 @@ +import { Input, Output, EventEmitter, Directive, LOCALE_ID, HostListener, booleanAttribute, ViewChildren, QueryList, ElementRef, ChangeDetectorRef, inject } from '@angular/core'; +import { IFormattingOptions, IFormattingViews, IViewDateChangeEventArgs, ScrollDirection, IgxCalendarView, CalendarSelection } from './calendar'; +import { ControlValueAccessor } from '@angular/forms'; +import { noop, Subject } from 'rxjs'; +import { + isDate, + isEqual, + PlatformUtil, + DateRangeDescriptor, + DateTimeUtil, + CalendarResourceStringsEN, + ICalendarResourceStrings, + getCurrentResourceStrings, + CalendarDay, + getYearRange, + isDateInRanges, + WEEKDAYS +} from 'igniteui-angular/core'; +import { getLocaleFirstDayOfWeek } from "@angular/common"; +import { KeyboardNavigationService } from './calendar.services'; + +/** @hidden @internal */ +@Directive({ + selector: '[igxCalendarBase]', + standalone: true, + providers: [KeyboardNavigationService] +}) +export class IgxCalendarBaseDirective implements ControlValueAccessor { + protected platform = inject(PlatformUtil); + protected _localeId = inject(LOCALE_ID); + protected keyboardNavigation? = inject(KeyboardNavigationService); + protected cdr? = inject(ChangeDetectorRef); + + /** + * Holds month view index we are operating on. + */ + protected activeViewIdx = 0; + + /** + * @hidden + */ + private _activeView: IgxCalendarView = IgxCalendarView.Month; + + /** + * @hidden + */ + private activeViewSubject = new Subject(); + + /** + * @hidden + */ + protected activeView$ = this.activeViewSubject.asObservable(); + + /** + * Sets/gets whether the outside dates (dates that are out of the current month) will be hidden. + * Default value is `false`. + * ```html + * + * ``` + * ```typescript + * let hideOutsideDays = this.calendar.hideOutsideDays; + * ``` + */ + + @Input({ transform: booleanAttribute }) + public hideOutsideDays = false; + + /** + * Emits an event when a date is selected. + * Provides reference the `selectedDates` property. + */ + @Output() + public selected = new EventEmitter(); + + /** + * Emits an event when the month in view is changed. + * ```html + * + * ``` + * ```typescript + * public viewDateChanged(event: IViewDateChangeEventArgs) { + * let viewDate = event.currentValue; + * } + * ``` + */ + @Output() + public viewDateChanged = new EventEmitter(); + + /** + * Emits an event when the active view is changed. + * ```html + * + * ``` + * ```typescript + * public activeViewChanged(event: CalendarView) { + * let activeView = event; + * } + * ``` + */ + @Output() + public activeViewChanged = new EventEmitter(); + + /** + * @hidden + */ + public rangeStarted = false; + + /** + * @hidden + */ + public pageScrollDirection = ScrollDirection.NONE; + + /** + * @hidden + */ + public scrollPage$ = new Subject(); + + /** + * @hidden + */ + public stopPageScroll$ = new Subject(); + + /** + * @hidden + */ + public startPageScroll$ = new Subject(); + + /** + * @hidden + */ + public selectedDates: Date[]; + + /** + * @hidden + */ + public shiftKey = false; + + /** + * @hidden + */ + public lastSelectedDate: Date; + + /** + * @hidden + */ + protected formatterWeekday: Intl.DateTimeFormat; + + /** + * @hidden + */ + protected formatterDay: Intl.DateTimeFormat; + + /** + * @hidden + */ + protected formatterMonth: Intl.DateTimeFormat; + + /** + * @hidden + */ + protected formatterYear: Intl.DateTimeFormat; + + /** + * @hidden + */ + protected formatterMonthday: Intl.DateTimeFormat; + + /** + * @hidden + */ + protected formatterRangeday: Intl.DateTimeFormat; + + /** + * @hidden + */ + protected _onTouchedCallback: () => void = noop; + /** + * @hidden + */ + protected _onChangeCallback: (_: Date | Date[]) => void = noop; + + /** + * @hidden + */ + protected _deselectDate: boolean; + + /** + * @hidden + */ + private initialSelection: Date | Date[]; + + /** + * @hidden + */ + private _locale: string; + + /** + * @hidden + */ + private _weekStart: WEEKDAYS | number; + + /** + * @hidden + */ + private _viewDate: Date; + + /** + * @hidden + */ + private _startDate: Date; + + /** + * @hidden + */ + private _endDate: Date; + + /** + * @hidden + */ + private _disabledDates: DateRangeDescriptor[] = []; + + /** + * @hidden + */ + private _specialDates: DateRangeDescriptor[] = []; + + /** + * @hidden + */ + private _selection: CalendarSelection | string = CalendarSelection.SINGLE; + + /** @hidden @internal */ + private _resourceStrings = getCurrentResourceStrings(CalendarResourceStringsEN); + + /** + * @hidden + */ + private _formatOptions: IFormattingOptions = { + day: 'numeric', + month: 'long', + weekday: 'narrow', + year: 'numeric' + }; + + /** + * @hidden + */ + private _formatViews: IFormattingViews = { + day: false, + month: true, + year: false + }; + + /** + * An accessor that sets the resource strings. + * By default it uses EN resources. + */ + @Input() + public set resourceStrings(value: ICalendarResourceStrings) { + this._resourceStrings = Object.assign({}, this._resourceStrings, value); + } + + /** + * An accessor that returns the resource strings. + */ + public get resourceStrings(): ICalendarResourceStrings { + return this._resourceStrings; + } + + /** + * Gets the start day of the week. + * Can return a numeric or an enum representation of the week day. + * If not set, defaults to the first day of the week for the application locale. + */ + @Input() + public get weekStart(): WEEKDAYS | number { + return this._weekStart; + } + + /** + * Sets the start day of the week. + * Can be assigned to a numeric value or to `WEEKDAYS` enum value. + */ + public set weekStart(value: WEEKDAYS | number) { + this._weekStart = value; + } + + /** + * Gets the `locale` of the calendar. + * If not set, defaults to application's locale. + */ + @Input() + public get locale(): string { + return this._locale; + } + + /** + * Sets the `locale` of the calendar. + * Expects a valid BCP 47 language tag. + */ + public set locale(value: string) { + this._locale = value; + + // if value is not a valid BCP 47 tag, set it back to _localeId + try { + getLocaleFirstDayOfWeek(this._locale); + } catch (e) { + this._locale = this._localeId; + } + + // changing locale runtime needs to update the `weekStart` too, if `weekStart` is not explicitly set + if (!this.weekStart) { + this.weekStart = getLocaleFirstDayOfWeek(this._locale); + } + + this.initFormatters(); + } + + /** + * Gets the date format options of the views. + */ + @Input() + public get formatOptions(): IFormattingOptions { + return this._formatOptions; + } + + /** + * Sets the date format options of the views. + * Default is { day: 'numeric', month: 'short', weekday: 'short', year: 'numeric' } + */ + public set formatOptions(formatOptions: IFormattingOptions) { + this._formatOptions = {...this._formatOptions, ...formatOptions}; + this.initFormatters(); + } + + /** + * Gets whether the `day`, `month` and `year` should be rendered + * according to the locale and formatOptions, if any. + */ + @Input() + public get formatViews(): IFormattingViews { + return this._formatViews; + } + + /** + * Sets whether the `day`, `month` and `year` should be rendered + * according to the locale and formatOptions, if any. + */ + public set formatViews(formatViews: IFormattingViews) { + this._formatViews = Object.assign(this._formatViews, formatViews); + } + + /** + * Gets the current active view. + * ```typescript + * this.activeView = calendar.activeView; + * ``` + */ + @Input() + public get activeView(): IgxCalendarView { + return this._activeView; + } + + /** + * Sets the current active view. + * ```html + * + * ``` + * ```typescript + * calendar.activeView = IgxCalendarView.YEAR; + * ``` + */ + public set activeView(val: IgxCalendarView) { + this._activeView = val; + this.activeViewSubject.next(val); + } + + /** + * @hidden + */ + public get isDefaultView(): boolean { + return this._activeView === IgxCalendarView.Month; + } + + /** + * @hidden + */ + public get isDecadeView(): boolean { + return this._activeView === IgxCalendarView.Decade; + } + + /** + * @hidden + */ + public activeViewDecade(activeViewIdx = 0): void { + this.activeView = IgxCalendarView.Decade; + this.activeViewIdx = activeViewIdx; + } + + /** + * @hidden + */ + public activeViewDecadeKB(event: KeyboardEvent, activeViewIdx = 0) { + event.stopPropagation(); + + if (this.platform.isActivationKey(event)) { + event.preventDefault(); + this.activeViewDecade(activeViewIdx); + } + } + + /** + * @hidden + */ + @ViewChildren('yearsBtn') + public yearsBtns: QueryList; + + /** + * @hidden @internal + */ + public previousViewDate: Date; + + /** + * @hidden + */ + public changeYear(date: Date) { + this.previousViewDate = this.viewDate; + this.viewDate = CalendarDay.from(date).add('month', -this.activeViewIdx).native; + this.activeView = IgxCalendarView.Month; + } + + /** + * Returns the locale representation of the year in the year view if enabled, + * otherwise returns the default `Date.getFullYear()` value. + * + * @hidden + */ + public formattedYear(value: Date | Date[]): string { + if (Array.isArray(value)) { + return; + } + + if (this.formatViews.year) { + return this.formatterYear.format(value); + } + + return `${value.getFullYear()}`; + } + + public formattedYears(value: Date) { + const dates = value as unknown as Date[]; + return dates.map(date => this.formattedYear(date)).join(' - '); + } + + protected prevNavLabel(detail?: string): string { + switch (this.activeView) { + case 'month': + return `${this.resourceStrings.igx_calendar_previous_month}, ${detail}` + case 'year': + return this.resourceStrings.igx_calendar_previous_year; + case 'decade': + return this.resourceStrings.igx_calendar_previous_years.replace('{0}', '15'); + } + } + + protected nextNavLabel(detail?: string): string { + switch (this.activeView) { + case 'month': + return `${this.resourceStrings.igx_calendar_next_month}, ${detail}` + case 'year': + return this.resourceStrings.igx_calendar_next_year; + case 'decade': + return this.resourceStrings.igx_calendar_next_years.replace('{0}', '15'); + } + } + + protected getDecadeRange(): { start: string; end: string } { + const range = getYearRange(this.viewDate, 15); + const start = CalendarDay.from(this.viewDate).set({ date: 1, year: range.start }); + const end = CalendarDay.from(this.viewDate).set({ date: 1, year: range.end }); + + return { + start: this.formatterYear.format(start.native), + end: this.formatterYear.format(end.native) + } + } + /** + * + * Gets the selection type. + * Default value is `"single"`. + * Changing the type of selection resets the currently + * selected values if any. + */ + @Input() + public get selection(): string { + return this._selection; + } + + /** + * Sets the selection. + */ + public set selection(value: string) { + switch (value) { + case CalendarSelection.SINGLE: + this.selectedDates = null; + break; + case CalendarSelection.MULTI: + case CalendarSelection.RANGE: + this.selectedDates = []; + break; + default: + throw new Error('Invalid selection value'); + } + this._onChangeCallback(this.selectedDates); + this.rangeStarted = false; + this._selection = value; + } + + /** + * Gets the date that is presented. By default it is the current date. + */ + @Input() + public get viewDate(): Date { + return this._viewDate; + } + + /** + * Sets the date that will be presented in the default view when the component renders. + */ + public set viewDate(value: Date | string) { + if (Array.isArray(value)) { + return; + } + + if (typeof value === 'string') { + value = DateTimeUtil.parseIsoDate(value); + } + + const validDate = this.validateDate(value); + + if (this._viewDate) { + this.initialSelection = validDate; + } + + const date = this.getDateOnly(validDate).setDate(1); + this._viewDate = new Date(date); + } + + /** + * Gets the disabled dates descriptors. + */ + @Input() + public get disabledDates(): DateRangeDescriptor[] { + return this._disabledDates; + } + + /** + * Sets the disabled dates' descriptors. + * ```typescript + * @ViewChild("MyCalendar") + * public calendar: IgxCalendarComponent; + * ngOnInit(){ + * this.calendar.disabledDates = [ + * {type: DateRangeType.Between, dateRange: [new Date("2020-1-1"), new Date("2020-1-15")]}, + * {type: DateRangeType.Weekends}]; + * } + * ``` + */ + public set disabledDates(value: DateRangeDescriptor[]) { + this._disabledDates = value; + } + + /** + * Checks whether a date is disabled. + * + * @hidden + */ + public isDateDisabled(date: Date | string) { + if (!this.disabledDates) { + return false; + } + + if (typeof date === 'string') { + date = DateTimeUtil.parseIsoDate(date); + } + + return isDateInRanges(date, this.disabledDates); + } + + /** + * Gets the special dates descriptors. + */ + @Input() + public get specialDates(): DateRangeDescriptor[] { + return this._specialDates; + } + + /** + * Sets the special dates' descriptors. + * ```typescript + * @ViewChild("MyCalendar") + * public calendar: IgxCalendarComponent; + * ngOnInit(){ + * this.calendar.specialDates = [ + * {type: DateRangeType.Between, dateRange: [new Date("2020-1-1"), new Date("2020-1-15")]}, + * {type: DateRangeType.Weekends}]; + * } + * ``` + */ + public set specialDates(value: DateRangeDescriptor[]) { + this._specialDates = value; + } + + /** + * Gets the selected date(s). + * + * When selection is set to `single`, it returns + * a single `Date` object. + * Otherwise it is an array of `Date` objects. + */ + @Input() + public get value(): Date | Date[] { + if (this.selection === CalendarSelection.SINGLE) { + return this.selectedDates?.at(0); + } + + return this.selectedDates; + } + + /** + * Sets the selected date(s). + * + * When selection is set to `single`, it accepts + * a single `Date` object. + * Otherwise it is an array of `Date` objects. + */ + public set value(value: Date | Date[] | string) { + // Validate the date if it is of type string and it is IsoDate + if (typeof value === 'string') { + value = DateTimeUtil.parseIsoDate(value); + } + + // Check if value is set initially by the user, + // if it's not set the initial selection to the current date + if (!value || (Array.isArray(value) && value.length === 0)) { + this.initialSelection = new Date(); + return; + } + + // Value is provided, but there's no initial selection, set the initial selection to the passed value + if (!this.initialSelection) { + this.viewDate = Array.isArray(value) ? new Date(Math.min(...value as unknown as number[])) : value; + } + + // we then call selectDate with either a single date or an array of dates + // we also set the initial selection to the provided value + this.selectDate(value); + this.initialSelection = value; + } + + /** + * @hidden + */ + constructor() { + const _localeId = this._localeId; + + this.locale = _localeId; + this.viewDate = this.viewDate ? this.viewDate : new Date(); + this.initFormatters(); + } + + /** + * Multi/Range selection with shift key + * + * @hidden + * @internal + */ + @HostListener('pointerdown', ['$event']) + public onPointerdown(event: MouseEvent) { + this.shiftKey = event.button === 0 && event.shiftKey; + } + + /** + * @hidden + */ + public registerOnChange(fn: (v: Date | Date[]) => void) { + this._onChangeCallback = fn; + } + + /** + * @hidden + */ + public registerOnTouched(fn: () => void) { + this._onTouchedCallback = fn; + } + + /** + * @hidden + */ + public writeValue(value: Date | Date[]) { + this.value = value; + } + + /** + * Selects date(s) (based on the selection type). + */ + public selectDate(value: Date | Date[] | string) { + if (typeof value === 'string') { + value = DateTimeUtil.parseIsoDate(value); + } + + if (value === null || value === undefined || (Array.isArray(value) && value.length === 0)) { + return; + } + + switch (this.selection) { + case CalendarSelection.SINGLE: + if (isDate(value)) { + this.selectSingle(value as Date); + } + break; + case CalendarSelection.MULTI: + this.selectMultiple(value); + break; + case CalendarSelection.RANGE: + this.selectRange(value); + break; + } + } + + /** + * Deselects date(s) (based on the selection type). + */ + public deselectDate(value?: Date | Date[] | string) { + if (!this.selectedDates || this.selectedDates.length === 0) { + return; + } + + if (typeof value === 'string') { + value = DateTimeUtil.parseIsoDate(value); + } + + if (value === null || value === undefined) { + this.selectedDates = this.selection === CalendarSelection.SINGLE ? null : []; + this.rangeStarted = false; + this._onChangeCallback(this.selectedDates); + return; + } + + switch (this.selection) { + case CalendarSelection.SINGLE: + this.deselectSingle(value as Date); + break; + case CalendarSelection.MULTI: + this.deselectMultiple(value as Date[]); + break; + case CalendarSelection.RANGE: + this.deselectRange(value as Date[]); + break; + } + } + + /** + * Performs a single selection. + * + * @hidden + */ + private selectSingle(value: Date) { + if (!isEqual(this.selectedDates?.at(0), value)) { + this.selectedDates = [this.getDateOnly(value)]; + this._onChangeCallback(this.selectedDates.at(0)); + } + } + + /** + * Performs a single deselection. + * + * @hidden + */ + private deselectSingle(value: Date) { + if (this.selectedDates !== null && + this.getDateOnlyInMs(value as Date) === this.getDateOnlyInMs(this.selectedDates.at(0))) { + this.selectedDates = null; + this._onChangeCallback(this.selectedDates); + } + } + + /** + * Performs a multiple selection + * + * @hidden + */ + private selectMultiple(value: Date | Date[]) { + if (Array.isArray(value)) { + const newDates = value.map(v => this.getDateOnly(v).getTime()); + const selDates = this.selectedDates.map(v => this.getDateOnly(v).getTime()); + + if (JSON.stringify(newDates) === JSON.stringify(selDates)) { + return; + } + + if (selDates.length === 0 || selDates.length > newDates.length) { + // deselect the dates that are part of currently selectedDates and not part of updated new values + this.selectedDates = newDates.map(v => new Date(v)); + } else { + this.selectedDates = Array.from(new Set([...newDates, ...selDates])).map(v => new Date(v)); + } + } else { + let newSelection = []; + + if (this.shiftKey && this.lastSelectedDate) { + + [this._startDate, this._endDate] = this.lastSelectedDate.getTime() < value.getTime() + ? [this.lastSelectedDate, value] + : [value, this.lastSelectedDate]; + + const unselectedDates = [this._startDate, ...this.generateDateRange(this._startDate, this._endDate)] + .filter(date => this.selectedDates.every((d: Date) => d.getTime() !== date.getTime())); + + // select all dates from last selected to shift clicked date + if (this.selectedDates.some((date: Date) => date.getTime() === this.lastSelectedDate.getTime()) + && unselectedDates.length) { + + newSelection = unselectedDates; + } else { + // delesect all dates from last clicked to shift clicked date (excluding) + this.selectedDates = this.selectedDates.filter((date: Date) => + date.getTime() < this._startDate.getTime() || date.getTime() > this._endDate.getTime() + ); + + this.selectedDates.push(value); + this._deselectDate = true; + } + + this._startDate = this._endDate = undefined; + + } else if (this.selectedDates.every((date: Date) => date.getTime() !== value.getTime())) { + newSelection.push(value); + + } else { + this.selectedDates = this.selectedDates.filter( + (date: Date) => date.getTime() !== value.getTime() + ); + + this._deselectDate = true; + } + + if (newSelection.length > 0) { + this.selectedDates = this.selectedDates.concat(newSelection); + this._deselectDate = false; + } + + this.lastSelectedDate = value; + } + + this.selectedDates = this.selectedDates.filter(d => !this.isDateDisabled(d)); + this.selectedDates.sort((a: Date, b: Date) => a.valueOf() - b.valueOf()); + this._onChangeCallback(this.selectedDates); + } + + /** + * Performs a multiple deselection. + * + * @hidden + */ + private deselectMultiple(value: Date[]) { + value = value.filter(v => v !== null); + const selectedDatesCount = this.selectedDates.length; + const datesInMsToDeselect: Set = new Set( + value.map(v => this.getDateOnlyInMs(v))); + + for (let i = this.selectedDates.length - 1; i >= 0; i--) { + if (datesInMsToDeselect.has(this.getDateOnlyInMs(this.selectedDates[i]))) { + this.selectedDates.splice(i, 1); + } + } + + if (this.selectedDates.length !== selectedDatesCount) { + this._onChangeCallback(this.selectedDates); + } + } + + /** + * @hidden + */ + private selectRange(value: Date | Date[], excludeDisabledDates = false) { + if (Array.isArray(value)) { + value.sort((a: Date, b: Date) => a.valueOf() - b.valueOf()); + this._startDate = this.getDateOnly(value[0]); + this._endDate = this.getDateOnly(value[value.length - 1]); + } else { + + if (this.shiftKey && this.lastSelectedDate) { + + if (this.lastSelectedDate.getTime() === value.getTime()) { + this.selectedDates = this.selectedDates.length === 1 ? [] : [value]; + this.rangeStarted = !!this.selectedDates.length; + this._onChangeCallback(this.selectedDates); + return; + } + + // shortens the range when selecting a date inside of it + if (this.selectedDates.some((date: Date) => date.getTime() === value.getTime())) { + + this.lastSelectedDate.getTime() < value.getTime() + ? this._startDate = value + : this._endDate = value; + + } else { + // extends the range when selecting a date outside of it + // allows selection from last deselected to current selected date + if (this.lastSelectedDate.getTime() < value.getTime()) { + this._startDate = this._startDate ?? this.lastSelectedDate; + this._endDate = value; + } else { + this._startDate = value; + this._endDate = this._endDate ?? this.lastSelectedDate; + } + } + + this.rangeStarted = false; + + } else if (!this.rangeStarted) { + this.rangeStarted = true; + this.selectedDates = [value]; + this._startDate = this._endDate = undefined; + } else { + this.rangeStarted = false; + + if (this.selectedDates?.at(0)?.getTime() === value.getTime()) { + this.selectedDates = []; + this._onChangeCallback(this.selectedDates); + return; + } + + [this._startDate, this._endDate] = this.lastSelectedDate.getTime() < value.getTime() + ? [this.lastSelectedDate, value] + : [value, this.lastSelectedDate]; + } + + this.lastSelectedDate = value; + } + + if (this._startDate && this._endDate) { + this.selectedDates = [this._startDate, ...this.generateDateRange(this._startDate, this._endDate)]; + } + + if (excludeDisabledDates) { + this.selectedDates = this.selectedDates.filter(d => !this.isDateDisabled(d)); + } + + this._onChangeCallback(this.selectedDates); + } + + /** + * Performs a range deselection. + * + * @hidden + */ + private deselectRange(value: Date[]) { + value = value.filter(v => v !== null); + + if (value.length < 1) { + return; + } + + value.sort((a: Date, b: Date) => a.valueOf() - b.valueOf()); + + const valueStart = this.getDateOnlyInMs(value[0]); + const valueEnd = this.getDateOnlyInMs(value[value.length - 1]); + + this.selectedDates.sort((a: Date, b: Date) => a.valueOf() - b.valueOf()); + + const selectedDatesStart = this.getDateOnlyInMs(this.selectedDates[0]); + const selectedDatesEnd = this.getDateOnlyInMs(this.selectedDates[this.selectedDates.length - 1]); + + if (!(valueEnd < selectedDatesStart) && !(valueStart > selectedDatesEnd)) { + this.selectedDates = []; + this.rangeStarted = false; + this._onChangeCallback(this.selectedDates); + } + } + + /** + * @hidden + */ + protected initFormatters() { + this.formatterDay = new Intl.DateTimeFormat(this._locale, { day: this._formatOptions.day }); + this.formatterWeekday = new Intl.DateTimeFormat(this._locale, { weekday: this._formatOptions.weekday }); + this.formatterMonth = new Intl.DateTimeFormat(this._locale, { month: this._formatOptions.month }); + this.formatterYear = new Intl.DateTimeFormat(this._locale, { year: this._formatOptions.year }); + this.formatterMonthday = new Intl.DateTimeFormat(this._locale, { month: this._formatOptions.month, day: this._formatOptions.day }); + this.formatterRangeday = new Intl.DateTimeFormat(this._locale, { day: this._formatOptions.day, month: 'short' }); + } + + /** + * @hidden + */ + protected getDateOnly(date: Date) { + const validDate = this.validateDate(date); + return new Date(validDate.getFullYear(), validDate.getMonth(), validDate.getDate()); + } + + /** + * @hidden + */ + private getDateOnlyInMs(date: Date) { + return this.getDateOnly(date).getTime(); + } + + /** + * @hidden + */ + private generateDateRange(start: Date, end: Date): Date[] { + const result = []; + start = this.getDateOnly(start); + end = this.getDateOnly(end); + + while (start.getTime() < end.getTime()) { + start = CalendarDay.from(start).add('day', 1).native; + result.push(start); + } + + return result; + } + + private validateDate(value: Date) { + return DateTimeUtil.isValidDate(value) ? value : new Date(); + } +} diff --git a/projects/igniteui-angular/calendar/src/calendar/calendar-multi-view.component.spec.ts b/projects/igniteui-angular/calendar/src/calendar/calendar-multi-view.component.spec.ts new file mode 100644 index 00000000000..c62c2dd72a3 --- /dev/null +++ b/projects/igniteui-angular/calendar/src/calendar/calendar-multi-view.component.spec.ts @@ -0,0 +1,1145 @@ +import { Component, ViewChild } from '@angular/core'; +import { ComponentFixture, TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { DateRangeType } from 'igniteui-angular/core'; +import { HelperTestFunctions } from '../../../test-utils/calendar-helper-utils'; +import { ymd } from '../../../test-utils/helper-utils.spec'; +import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; +import { IgxCalendarComponent } from './calendar.component'; +import { IgxDatePickerComponent } from 'igniteui-angular/date-picker'; + +describe('Multi-View Calendar - ', () => { + let fixture: ComponentFixture + let calendar: any; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + MultiViewCalendarSampleComponent, + MultiViewDatePickerSampleComponent, + MultiViewNgModelSampleComponent + ] + }).compileComponents(); + })); + + describe('Base Tests - ', () => { + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(MultiViewCalendarSampleComponent); + fixture.detectChanges(); + calendar = fixture.componentInstance.calendar; + })); + + it('should render properly when monthsViewNumber is initially set or changed runtime', () => { + const today = new Date(Date.now()); + + expect(calendar.monthsViewNumber).toBe(3); + HelperTestFunctions.verifyMonthsViewNumber(fixture, 3, true); + HelperTestFunctions.verifyCalendarHeader(fixture, today); + + calendar.monthsViewNumber = 4; + fixture.detectChanges(); + + expect(calendar.monthsViewNumber).toBe(4); + HelperTestFunctions.verifyMonthsViewNumber(fixture, 4, true); + HelperTestFunctions.verifyCalendarHeader(fixture, today); + + calendar.monthsViewNumber = 2; + fixture.detectChanges(); + + expect(calendar.monthsViewNumber).toBe(2); + HelperTestFunctions.verifyMonthsViewNumber(fixture, 2, true); + HelperTestFunctions.verifyCalendarHeader(fixture, today); + }); + + it('should render properly if set monthsViewNumber to a value < 1', () => { + expect(calendar.monthsViewNumber).toBe(3); + HelperTestFunctions.verifyMonthsViewNumber(fixture, 3, true); + + calendar.monthsViewNumber = 0; + fixture.detectChanges(); + + expect(calendar.monthsViewNumber).toBe(3); + HelperTestFunctions.verifyMonthsViewNumber(fixture, 3, true); + + calendar.monthsViewNumber = -3; + fixture.detectChanges(); + + expect(calendar.monthsViewNumber).toBe(3); + HelperTestFunctions.verifyMonthsViewNumber(fixture, 3, true); + }); + + it('should change months views when viewDate is changed', () => { + const dates = [ymd('2019-06-19'), ymd('2019-07-19'), ymd('2019-08-19')]; + const today = new Date(Date.now()); + expect(calendar.monthsViewNumber).toBe(3); + HelperTestFunctions.verifyMonthsViewNumber(fixture, 3, true); + + calendar.viewDate = dates[0]; + fixture.detectChanges(); + + expect(calendar.monthsViewNumber).toBe(3); + HelperTestFunctions.verifyMonthsViewNumber(fixture, 3); + HelperTestFunctions.verifyCalendarHeader(fixture, today); + HelperTestFunctions.verifyCalendarSubHeaders(fixture, dates); + }); + + it('should be able to change hideOutsideDays property runtime', () => { + calendar.viewDate = ymd('2019-07-19'); + fixture.detectChanges(); + + expect(calendar.hideOutsideDays).toBe(false); + calendar.monthsViewNumber = 2; + fixture.detectChanges(); + + expect(calendar.monthsViewNumber).toBe(2); + expect(HelperTestFunctions.getInactiveDays(fixture, 0).length).toBeGreaterThan(1); + expect(HelperTestFunctions.getHiddenDays(fixture, 0).length).toBe(10); + expect(HelperTestFunctions.getInactiveDays(fixture, 1).length).toBeGreaterThan(1); + expect(HelperTestFunctions.getHiddenDays(fixture, 1).length).toBe(4); + + calendar.hideOutsideDays = true; + fixture.detectChanges(); + + expect(HelperTestFunctions.getHiddenDays(fixture, 0).length).toBe(HelperTestFunctions.getInactiveDays(fixture, 0).length); + expect(HelperTestFunctions.getHiddenDays(fixture, 1).length).toBe(HelperTestFunctions.getInactiveDays(fixture, 1).length); + }); + + it('weekStart should be properly set to all month views', () => { + expect(calendar.weekStart).toBe(0); + const firstMonth = HelperTestFunctions.getMonthView(fixture, 0); + let startDay = firstMonth.querySelector(HelperTestFunctions.WEEKSTART_LABEL_CSSCLASS); + expect(startDay.innerText.trim()).toEqual('S'); + + calendar.weekStart = 1; + fixture.detectChanges(); + + expect(calendar.weekStart).toBe(1); + startDay = firstMonth.querySelector(HelperTestFunctions.WEEKSTART_LABEL_CSSCLASS); + expect(startDay.innerText.trim()).toEqual('M'); + + const secondMonth = HelperTestFunctions.getMonthView(fixture, 1); + startDay = secondMonth.querySelector(HelperTestFunctions.WEEKSTART_LABEL_CSSCLASS); + expect(startDay.innerText.trim()).toEqual('M'); + }); + + it('calendar can be vertical when monthsViewNumber is set', () => { + calendar.orientation = 'vertical'; + fixture.detectChanges(); + + const verticalCalendar = fixture.nativeElement.querySelector(HelperTestFunctions.VERTICAL_CALENDAR_CSSCLASS); + expect(verticalCalendar).not.toBeNull(); + const today = new Date(Date.now()); + + expect(calendar.monthsViewNumber).toBe(3); + HelperTestFunctions.verifyMonthsViewNumber(fixture, 3, true); + HelperTestFunctions.verifyCalendarHeader(fixture, today); + + calendar.monthsViewNumber = 2; + fixture.detectChanges(); + + expect(calendar.monthsViewNumber).toBe(2); + HelperTestFunctions.verifyMonthsViewNumber(fixture, 2, true); + HelperTestFunctions.verifyCalendarHeader(fixture, today); + }); + + it('selected event should be fired when selecting a date', () => { + spyOn(calendar.selected, 'emit'); + const viewDate = ymd('2019-09-06'); + calendar.viewDate = viewDate; + fixture.detectChanges(); + + let dateEls = HelperTestFunctions.getMonthViewDates(fixture, 0); + UIInteractions.simulateClickAndSelectEvent(dateEls[15].firstChild); + fixture.detectChanges(); + + expect(calendar.selected.emit).toHaveBeenCalledTimes(1); + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 0).length).toBe(1); + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 1).length).toBe(0); + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 2).length).toBe(0); + + dateEls = HelperTestFunctions.getMonthViewDates(fixture, 1); + UIInteractions.simulateClickAndSelectEvent(dateEls[21].firstChild); + fixture.detectChanges(); + + expect(calendar.selected.emit).toHaveBeenCalledTimes(2); + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 0).length).toBe(0); + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 1).length).toBe(1); + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 2).length).toBe(0); + + dateEls = HelperTestFunctions.getMonthViewDates(fixture, 2); + UIInteractions.simulateClickAndSelectEvent(dateEls[19].firstChild); + fixture.detectChanges(); + + expect(calendar.selected.emit).toHaveBeenCalledTimes(3); + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 0).length).toBe(0); + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 1).length).toBe(0); + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 2).length).toBe(1); + }); + }); + + describe('KB Navigation test - ', () => { + const aug2019 = ymd('2019-08-19'); + const sept2019 = ymd('2019-09-19'); + const oct2019 = ymd('2019-10-19'); + const nov2019 = ymd('2019-11-19'); + const dec2019 = ymd('2019-12-19'); + const jan2020 = ymd('2020-01-19'); + const feb2020 = ymd('2020-02-19'); + const march2020 = ymd('2020-03-19'); + const oct2021 = ymd('2021-10-19'); + const nov2021 = ymd('2021-11-19'); + const dec2021 = ymd('2021-12-19'); + + const dateRangeDescriptors = [ + { type: DateRangeType.Between, dateRange: [new Date(2019, 10, 15), new Date(2019, 11, 8)] }, + { type: DateRangeType.Between, dateRange: [new Date(2019, 11, 15), new Date(2020, 0, 11)] }, + { type: DateRangeType.Between, dateRange: [new Date(2020, 0, 19), new Date(2020, 0, 25)] }, + { type: DateRangeType.Between, dateRange: [new Date(2020, 1, 1), new Date(2020, 1, 15)] }, + { type: DateRangeType.Between, dateRange: [new Date(2020, 1, 25), new Date(2020, 2, 11)] }]; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(MultiViewCalendarSampleComponent); + fixture.detectChanges(); + calendar = fixture.componentInstance.calendar; + const viewDate = new Date(2019, 9, 25); + calendar.viewDate = viewDate; + tick(); + fixture.detectChanges(); + })); + + it('Verify navigation with arrow up', () => { + const secondMonthDates = HelperTestFunctions.getMonthViewDates(fixture, 1); + UIInteractions.simulateMouseDownEvent(secondMonthDates[10].firstChild); // TODO: Use pointerdown for focus & remove + UIInteractions.simulateClickAndSelectEvent(secondMonthDates[10].firstChild); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(4); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(28); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(21); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(14); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(7); + + // Verify months are not changed + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [oct2019, nov2019, dec2019]); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(30); + + // Verify months are changed + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [sept2019, oct2019, nov2019]); + }); + + it('Verify navigation with arrow down', () => { + const monthDates = HelperTestFunctions.getMonthViewDates(fixture, 1); + UIInteractions.simulateMouseDownEvent(monthDates[22].firstChild); // TODO: Use pointerdown for focus & remove + UIInteractions.simulateClickAndSelectEvent(monthDates[22].firstChild); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(30); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(7); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(14); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(21); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(28); + + // Verify months are not changed + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [oct2019, nov2019, dec2019]); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(4); + + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [nov2019, dec2019, jan2020]); + }); + + it('Verify navigation with arrow left', () => { + const secondMonthDates = HelperTestFunctions.getMonthViewDates(fixture, 1); + UIInteractions.simulateMouseDownEvent(secondMonthDates[1].firstChild); // TODO: Use pointerdown for focus & remove + UIInteractions.simulateClickAndSelectEvent(secondMonthDates[1].firstChild); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(1); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(31); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(30); + + // Go to the first day of the month + UIInteractions.triggerKeyDownEvtUponElem('Home', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(1); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(30); + + // Verify months are changed + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [sept2019, oct2019, nov2019]); + }); + + it('Verify navigation with arrow right', () => { + const monthDates = HelperTestFunctions.getMonthViewDates(fixture, 1); + UIInteractions.simulateMouseDownEvent(monthDates[20].firstChild); // TODO: Use pointerdown for focus & remove + UIInteractions.simulateClickAndSelectEvent(monthDates[20].firstChild); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(22); + + UIInteractions.triggerKeyDownEvtUponElem('End', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(31); + + // Verify months aren't changed + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [oct2019, nov2019, dec2019]); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(1); + + // Verify months are changed + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [nov2019, dec2019, jan2020]); + }); + + it('Verify navigation with arrow up when there are disabled dates', () => { + calendar.viewDate = new Date(2019, 11, 25); + calendar.disabledDates = dateRangeDescriptors; + fixture.detectChanges(); + + const secondMonthDates = HelperTestFunctions.getMonthViewDates(fixture, 1); + UIInteractions.simulateMouseDownEvent(secondMonthDates[27].firstChild); // TODO: Use pointerdown for focus & remove + UIInteractions.simulateClickAndSelectEvent(secondMonthDates[27].firstChild); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(14); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(10); + + // Verify months are not changed + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [dec2019, jan2020, feb2020]); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(12); + + // Verify months are changed + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [nov2019, dec2019, jan2020]); + }); + + it('Verify navigation with arrow down when there are disabled dates', () => { + calendar.viewDate = new Date(2019, 11, 25); + calendar.disabledDates = dateRangeDescriptors; + fixture.detectChanges(); + + const secondMonthDates = HelperTestFunctions.getMonthViewDates(fixture, 1); + UIInteractions.simulateMouseDownEvent(secondMonthDates[16].firstChild); // TODO: Use pointerdown for focus & remove + UIInteractions.simulateClickAndSelectEvent(secondMonthDates[16].firstChild); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(31); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(21); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(13); + + // Verify months are changed + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [jan2020, feb2020, march2020]); + }); + + it('Verify navigation with arrow left when there are disabled dates', () => { + calendar.viewDate = new Date(2019, 11, 25); + calendar.disabledDates = dateRangeDescriptors; + fixture.detectChanges(); + + const secondMonthDates = HelperTestFunctions.getMonthViewDates(fixture, 1); + UIInteractions.simulateMouseDownEvent(secondMonthDates[25].firstChild); // TODO: Use pointerdown for focus & remove + UIInteractions.simulateClickAndSelectEvent(secondMonthDates[25].firstChild); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(18); + + UIInteractions.triggerKeyDownEvtUponElem('Home', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(9); + + // Verify months are not changed + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [dec2019, jan2020, feb2020]); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(14); + + // Verify months are changed + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [nov2019, dec2019, jan2020]); + }); + + it('Verify navigation with arrow right when there are disabled dates', () => { + calendar.viewDate = new Date(2019, 11, 25); + calendar.disabledDates = dateRangeDescriptors; + fixture.detectChanges(); + + const monthDates = HelperTestFunctions.getMonthViewDates(fixture, 1); + UIInteractions.simulateMouseDownEvent(monthDates[17].firstChild); // TODO: Use pointerdown for focus & remove + UIInteractions.simulateClickAndSelectEvent(monthDates[17].firstChild); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(26); + + UIInteractions.triggerKeyDownEvtUponElem('End', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(24); + + // Verify months are not changed + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [dec2019, jan2020, feb2020]); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(12); + + // Verify months are changed + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [jan2020, feb2020, march2020]); + }); + + it('Verify navigation with pageUp', () => { + const monthDates = HelperTestFunctions.getMonthViewDates(fixture, 1); + UIInteractions.simulateMouseDownEvent(monthDates[16].firstChild); // TODO: Use pointerdown for focus & remove + UIInteractions.simulateClickAndSelectEvent(monthDates[16].firstChild); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('PageUp', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(17); + + // Verify months are changed + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [sept2019, oct2019, nov2019]); + + UIInteractions.triggerKeyDownEvtUponElem('PageUp', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(17); + + // Verify months are changed + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [aug2019, sept2019, oct2019]); + }); + + it('Verify navigation with pageDown', () => { + const monthDates = HelperTestFunctions.getMonthViewDates(fixture, 1); + UIInteractions.simulateMouseDownEvent(monthDates[17].firstChild); // TODO: Use pointerdown for focus & remove + UIInteractions.simulateClickAndSelectEvent(monthDates[17].firstChild); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('PageDown', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(18); + + // Verify months are changed + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [nov2019, dec2019, jan2020]); + + UIInteractions.triggerKeyDownEvtUponElem('PageDown', document.activeElement); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(18); + + // Verify months are changed + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [dec2019, jan2020, feb2020]); + }); + + it('Verify navigation with Shift plus pageUp', () => { + const monthDates = HelperTestFunctions.getMonthViewDates(fixture, 1); + UIInteractions.simulateMouseDownEvent(monthDates[16].firstChild); // TODO: Use pointerdown for focus & remove + UIInteractions.simulateClickAndSelectEvent(monthDates[16].firstChild); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('PageUp', document.activeElement, true, false, true); + fixture.detectChanges(); + + expect(calendar.activeDate.getDate()).toEqual(17); + expect(calendar.activeDate.getFullYear()).toEqual(2018); + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [ymd('2018-10-19'), ymd('2018-11-19'), ymd('2018-12-19')]); + + UIInteractions.triggerKeyDownEvtUponElem('PageUp', document.activeElement, true, false, true); + fixture.detectChanges(); + + expect(calendar.activeDate.getDate()).toEqual(17); + expect(calendar.activeDate.getFullYear()).toEqual(2017); + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [ymd('2017-10-19'), ymd('2017-11-19'), ymd('2017-12-19')]); + }); + + it('Verify navigation with Shift plus pageDown', fakeAsync(() => { + const monthDates = HelperTestFunctions.getMonthViewDates(fixture, 1); + UIInteractions.simulateMouseDownEvent(monthDates[16].firstChild); // TODO: Use pointerdown for focus & remove + UIInteractions.simulateClickAndSelectEvent(monthDates[16].firstChild); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('PageDown', document.activeElement, true, false, true); + fixture.detectChanges(); + + expect(calendar.activeDate.getDate()).toEqual(17); + expect(calendar.activeDate.getFullYear()).toEqual(2020); + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [ymd('2020-10-19'), ymd('2020-11-19'), ymd('2020-12-19')]); + + UIInteractions.triggerKeyDownEvtUponElem('PageDown', document.activeElement, true, false, true); + fixture.detectChanges(); + + expect(calendar.activeDate.getDate()).toEqual(17); + expect(calendar.activeDate.getFullYear()).toEqual(2021); + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [oct2021, nov2021, dec2021]); + })); + + it('Verify navigation with Home and End keys', () => { + const monthDates = HelperTestFunctions.getMonthViewDates(fixture, 1); + UIInteractions.simulateMouseDownEvent(monthDates[16].firstChild); // TODO: Use pointerdown for focus & remove + UIInteractions.simulateClickAndSelectEvent(monthDates[16].firstChild); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('Home', document.activeElement, true); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(1); + + UIInteractions.triggerKeyDownEvtUponElem('End', document.activeElement, true); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(31); + }); + + it('Verify navigation with Home and End keys when there are disabled dates', () => { + calendar.disabledDates = [ + { type: DateRangeType.Between, dateRange: [new Date(2019, 9, 1), new Date(2019, 9, 14)] }, + { type: DateRangeType.Between, dateRange: [new Date(2019, 10, 12), new Date(2020, 0, 14)] } + ]; + fixture.detectChanges(); + + const monthDates = HelperTestFunctions.getMonthViewDates(fixture, 1); + UIInteractions.simulateMouseDownEvent(monthDates[3].firstChild); // TODO: Use pointerdown for focus & remove + UIInteractions.simulateClickAndSelectEvent(monthDates[3].firstChild); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('Home', document.activeElement, true); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(15); + + UIInteractions.triggerKeyDownEvtUponElem('End', document.activeElement, true); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(11); + }); + + it('Verify that months increment/decrement continuously on enter keydown', async () => { + calendar.monthsViewNumber = 2; + fixture.detectChanges(); + + const dates = [ + new Date("2019-10-15"), + new Date("2019-11-15"), + new Date("2019-12-15"), + new Date("2020-1-15"), + new Date("2020-2-15"), + new Date("2020-3-15"), + new Date("2020-4-15"), + ]; + + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [dates[0], dates[1]]); + + for (let i = 1; i < dates.length - 1; i++) { + const arrowRight = HelperTestFunctions.getNexArrowElement(fixture); + UIInteractions.triggerKeyDownEvtUponElem('Enter', arrowRight); + fixture.detectChanges(); + await wait(100); + fixture.detectChanges(); + + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [dates[i], dates[i + 1]]); + } + + for (let index = dates.length - 2; index > 0; index--) { + const arrowLeft = HelperTestFunctions.getPreviousArrowElement(fixture); + UIInteractions.triggerKeyDownEvtUponElem('Enter', arrowLeft); + fixture.detectChanges(); + await wait(200); + fixture.detectChanges(); + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [dates[index - 1], dates[index]]); + } + }); + + it('Verify that months increment/decrement continuously on mouse down', async () => { + calendar.monthsViewNumber = 2; + fixture.detectChanges(); + + const dates = [ + new Date("2019-10-15"), + new Date("2019-11-15"), + new Date("2019-12-15"), + new Date("2020-1-15"), + new Date("2020-2-15"), + new Date("2020-3-15"), + new Date("2020-4-15"), + ]; + + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [dates[0], dates[1]]); + + for (let i = 1; i < dates.length - 1; i++) { + const arrowRight = HelperTestFunctions.getNexArrowElement(fixture); + UIInteractions.simulateMouseEvent('mousedown', arrowRight, 0, 0); + await wait(100); + fixture.detectChanges(); + + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [dates[i], dates[i + 1]]); + } + for (let index = dates.length - 2; index > 0; index--) { + const arrowLeft = HelperTestFunctions.getPreviousArrowElement(fixture); + UIInteractions.simulateMouseEvent('mousedown', arrowLeft, 0, 0); + await wait(100); + fixture.detectChanges(); + + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [dates[index - 1], dates[index]]); + } + }); + + it('should be able to select a month after switching to months view', () => { + const secondMonthPicker = HelperTestFunctions.getCalendarSubHeader(fixture) + .querySelectorAll(HelperTestFunctions.CALENDAR_DATE_CSSCLASS)[2]; + + UIInteractions.simulateMouseDownEvent(secondMonthPicker as HTMLElement); + fixture.detectChanges(); + + const months = HelperTestFunctions.getMonthsFromMonthView(fixture); + expect(months.length).toBe(12); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', document.activeElement); + fixture.detectChanges(); + expect(document.activeElement.getAttribute('aria-activeDescendant')).toEqual(months[7].id); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', document.activeElement); + fixture.detectChanges(); + expect(document.activeElement.getAttribute('aria-activeDescendant')).toEqual(months[6].id); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', document.activeElement); + fixture.detectChanges(); + expect(document.activeElement.getAttribute('aria-activeDescendant')).toEqual(months[9].id); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', document.activeElement); + fixture.detectChanges(); + expect(document.activeElement.getAttribute('aria-activeDescendant')).toEqual(months[10].id); + + UIInteractions.triggerKeyDownEvtUponElem('Enter', document.activeElement); + fixture.detectChanges(); + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [oct2019, nov2019, dec2019]); + }); + + it('should be able to select a year after switching to years view', () => { + const secondYearPicker = HelperTestFunctions.getCalendarSubHeader(fixture) + .querySelectorAll(HelperTestFunctions.CALENDAR_DATE_CSSCLASS)[3]; + + UIInteractions.simulateMouseDownEvent(secondYearPicker as HTMLElement); + fixture.detectChanges(); + + const years = HelperTestFunctions.getYearsFromYearView(fixture); + expect(years.length).toBe(15); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', document.activeElement); + fixture.detectChanges(); + expect(document.activeElement.getAttribute('aria-activeDescendant')).toEqual(years[6].id); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', document.activeElement); + fixture.detectChanges(); + expect(document.activeElement.getAttribute('aria-activeDescendant')).toEqual(years[5].id); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', document.activeElement); + fixture.detectChanges(); + expect(document.activeElement.getAttribute('aria-activeDescendant')).toEqual(years[8].id); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', document.activeElement); + fixture.detectChanges(); + expect(document.activeElement.getAttribute('aria-activeDescendant')).toEqual(years[9].id); + + UIInteractions.triggerKeyDownEvtUponElem('Enter', document.activeElement); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('Enter', document.activeElement); + fixture.detectChanges(); + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [sept2019, oct2019, nov2019]); + }); + + }); + + describe('Selection tests - ', () => { + const septemberDate = ymd('2019-09-16'); + const octoberDate = ymd('2019-10-16'); + const novemberDate = ymd('2019-11-16'); + const decemberDate = ymd('2019-12-16'); + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(MultiViewCalendarSampleComponent); + fixture.detectChanges(); + calendar = fixture.componentInstance.calendar; + calendar.viewDate = new Date(2019, 8, 1); // 1st September 2019 + tick(); + fixture.detectChanges(); + })); + + + it('should select the days in only in of the months in single/multi selection mode', () => { + spyOn(calendar.selected, 'emit'); + + const fistMonthDates = HelperTestFunctions.getMonthViewDates(fixture, 0); + const secondMonthDates = HelperTestFunctions.getMonthViewDates(fixture, 1); + + UIInteractions.simulateClickAndSelectEvent(fistMonthDates[29].firstChild); + fixture.detectChanges(); + + expect(calendar.selected.emit).toHaveBeenCalledTimes(1); + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 0).length).toBe(1); + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 1).length).toBe(0); + + calendar.selection = 'multi'; + fixture.detectChanges(); + + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 0).length).toBe(0); + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 1).length).toBe(0); + + UIInteractions.simulateClickAndSelectEvent(secondMonthDates[2].firstChild); + fixture.detectChanges(); + UIInteractions.simulateClickAndSelectEvent(secondMonthDates[3].firstChild); + fixture.detectChanges(); + UIInteractions.simulateClickAndSelectEvent(secondMonthDates[28].firstChild); + fixture.detectChanges(); + UIInteractions.simulateClickAndSelectEvent(secondMonthDates[29].firstChild); + fixture.detectChanges(); + + expect(calendar.selected.emit).toHaveBeenCalledTimes(5); + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 0).length).toBe(0); + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 1).length).toBe(4); + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 2).length).toBe(0); + }); + + it('Multi Selection - select/deselect date in the view', () => { + spyOn(calendar.selected, 'emit'); + calendar.selection = 'multi'; + fixture.detectChanges(); + + const octoberFourth = ymd('2019-10-04'); + const octoberThird = ymd('2019-10-03'); + const secondMonthDates = HelperTestFunctions.getMonthViewDates(fixture, 1); + UIInteractions.simulateClickAndSelectEvent(secondMonthDates[2].firstChild); + fixture.detectChanges(); + + calendar.selectDate(octoberFourth); + fixture.detectChanges(); + + expect(calendar.selected.emit).toHaveBeenCalledTimes(1); + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 0).length).toBe(0); + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 1).length).toBe(2); + + UIInteractions.simulateClickAndSelectEvent(secondMonthDates[3].firstChild); + fixture.detectChanges(); + + expect(calendar.selected.emit).toHaveBeenCalledTimes(2); + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 0).length).toBe(0); + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 1).length).toBe(1); + + calendar.deselectDate([octoberThird]); + fixture.detectChanges(); + + expect(calendar.selected.emit).toHaveBeenCalledTimes(2); + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 0).length).toBe(0); + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 1).length).toBe(0); + }); + + it('should select/deselect dates in multiple day views with Shift key pressed', () => { + calendar.selection = 'multi'; + fixture.detectChanges(); + + const octoberDates = HelperTestFunctions.getMonthViewDates(fixture, 1); + const novemberDates = HelperTestFunctions.getMonthViewDates(fixture, 2); + const october27th = octoberDates[26]; + const november3rd = novemberDates[2]; + + UIInteractions.simulateClickAndSelectEvent(october27th.firstChild); + UIInteractions.simulateClickAndSelectEvent(november3rd.firstChild, true); + fixture.detectChanges(); + + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 1).length).toBe(5); + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 2).length).toBe(3); + + + UIInteractions.simulateClickAndSelectEvent(october27th.firstChild, true); + fixture.detectChanges(); + + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 1).length).toBe(1); + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 2).length).toBe(0); + }); + + it('should select multiple dates should while not creating a range', () => { + calendar.selection = 'multi'; + fixture.detectChanges(); + + calendar.selectDate([ + ymd('2019-10-29'), + ymd('2019-11-02'), + ymd('2019-10-31'), + ymd('2019-11-01'), + ymd('2019-10-30'), + ]); + fixture.detectChanges(); + + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 1).length).toBe(3); + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 2).length).toBe(2); + + HelperTestFunctions.verifyNoRangeSelectionCreated(fixture, 1); + HelperTestFunctions.verifyNoRangeSelectionCreated(fixture, 2); + + calendar.selection = 'single'; + fixture.detectChanges(); + + calendar.selectDate(ymd('2019-10-29')); + fixture.detectChanges(); + + calendar.selectDate(ymd('2019-10-30')); + fixture.detectChanges(); + + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 1).length).toBe(1); + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 2).length).toBe(0); + HelperTestFunctions.verifyNoRangeSelectionCreated(fixture, 1); + HelperTestFunctions.verifyNoRangeSelectionCreated(fixture, 2); + }); + + it('outside month days should be hidden when hideOutsideDays is true', () => { + calendar.monthsViewNumber = 2; + fixture.detectChanges(); + + expect(HelperTestFunctions.getHiddenDays(fixture, 0).length).toBe(12); + expect(HelperTestFunctions.getHiddenDays(fixture, 1).length).toBe(2); + + calendar.hideOutsideDays = true; + fixture.detectChanges(); + + const firstMonthInactiveDays = HelperTestFunctions.getInactiveDays(fixture, 0).length; + const secondMonthInactiveDays = HelperTestFunctions.getInactiveDays(fixture, 1).length; + + expect(HelperTestFunctions.getHiddenDays(fixture, 0).length).toBe(firstMonthInactiveDays); + expect(HelperTestFunctions.getHiddenDays(fixture, 1).length).toBe(secondMonthInactiveDays); + + calendar.selection = 'multi'; + fixture.detectChanges(); + + expect(HelperTestFunctions.getHiddenDays(fixture, 0).length).toBe(firstMonthInactiveDays); + expect(HelperTestFunctions.getHiddenDays(fixture, 1).length).toBe(secondMonthInactiveDays); + + calendar.selection = 'range'; + fixture.detectChanges(); + expect(HelperTestFunctions.getHiddenDays(fixture, 0).length).toBe(firstMonthInactiveDays); + expect(HelperTestFunctions.getHiddenDays(fixture, 1).length).toBe(secondMonthInactiveDays); + }); + + it('should change days view when selecting an outside day', () => { + calendar.monthsViewNumber = 2; + fixture.detectChanges(); + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [septemberDate, octoberDate]); + + const inactiveDaysOctober = HelperTestFunctions.getInactiveDays(fixture, 1); + UIInteractions.simulateClickAndSelectEvent(inactiveDaysOctober[8].firstChild); + fixture.detectChanges(); + + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [novemberDate, decemberDate]); + + const inactiveDaysNovember = HelperTestFunctions.getInactiveDays(fixture, 0); + UIInteractions.simulateClickAndSelectEvent(inactiveDaysNovember[0].firstChild); + fixture.detectChanges(); + + HelperTestFunctions.verifyCalendarSubHeaders(fixture, [octoberDate, novemberDate]); + }); + + it('Single Selection - Verify API methods selectDate and deselectDate', () => { + expect(calendar.selection).toEqual('single'); + + calendar.selectDate(septemberDate); + fixture.detectChanges(); + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 0).length).toBe(1); + + calendar.deselectDate(septemberDate); + fixture.detectChanges(); + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 0).length).toBe(0); + + calendar.selectDate(octoberDate); + fixture.detectChanges(); + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 1).length).toBe(1); + + calendar.deselectDate(); + fixture.detectChanges(); + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 1).length).toBe(0); + }); + + it('Multi Selection - Verify API methods selectDate and deselectDate', () => { + calendar.selection = 'multi'; + fixture.detectChanges(); + expect(calendar.selection).toEqual('multi'); + + calendar.selectDate([septemberDate]); + fixture.detectChanges(); + calendar.selectDate([ymd('2019-09-21')]); + fixture.detectChanges(); + + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 0).length).toBe(2); + + + calendar.deselectDate([septemberDate, ymd('2019-09-21')]); + fixture.detectChanges(); + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 0).length).toBe(0); + + calendar.selectDate([septemberDate, ymd('2019-10-24'), octoberDate, novemberDate]); + fixture.detectChanges(); + + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 1).length).toBe(2); // october + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 0).length).toBe(1); // september + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 2).length).toBe(1); // november + + calendar.deselectDate(); + fixture.detectChanges(); + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 1).length).toBe(0); // october + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 0).length).toBe(0); // september + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 2).length).toBe(0); // november + }); + + it('Range Selection - Verify API methods selectDate and deselectDate', () => { + calendar.selection = 'range'; + calendar.hideOutsideDays = true; + fixture.detectChanges(); + expect(calendar.selection).toEqual('range'); + + calendar.selectDate([octoberDate, septemberDate, novemberDate]); + fixture.detectChanges(); + + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 1).length).toBe(31); // october + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 0).length).toBe(15); // september + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 2).length).toBe(16); // november + + calendar.deselectDate([octoberDate, septemberDate, novemberDate]); + fixture.detectChanges(); + + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 1).length).toBe(0); // october + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 0).length).toBe(0); // september + expect(HelperTestFunctions.getMonthViewSelectedDates(fixture, 2).length).toBe(0); // november + }); + + it('outside days should NOT be selected in all month views, when hideOutsideDays is false and selection is range', () => { + spyOn(calendar.selected, 'emit'); + calendar.selection = 'range'; + fixture.detectChanges(); + + const secondMonthDates = HelperTestFunctions.getMonthViewDates(fixture, 1); + UIInteractions.simulateClickAndSelectEvent(secondMonthDates[0].firstChild); + fixture.detectChanges(); + + expect(calendar.selected.emit).toHaveBeenCalledTimes(1); + + UIInteractions.simulateClickAndSelectEvent(secondMonthDates[30].firstChild); + fixture.detectChanges(); + expect(calendar.selected.emit).toHaveBeenCalledTimes(2); + + HelperTestFunctions.getMonthViewSelectedDates(fixture, 0).forEach((el: HTMLElement) => { + expect(el.classList.contains(HelperTestFunctions.RANGE_CSSCLASS)).toBeTruthy(); + }); + + HelperTestFunctions.getMonthViewSelectedDates(fixture, 1).forEach((el: HTMLElement) => { + expect(el.classList.contains(HelperTestFunctions.RANGE_CSSCLASS)).toBeTruthy(); + }); + + HelperTestFunctions.getMonthViewSelectedDates(fixture, 2).forEach((el: HTMLElement) => { + expect(el.classList.contains(HelperTestFunctions.RANGE_CSSCLASS)).toBeTruthy(); + }); + }); + }); + + describe('Selection tests with ngModel - ', () => { + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(MultiViewNgModelSampleComponent); + fixture.detectChanges(); + calendar = fixture.componentInstance.calendar; + calendar.viewDate = new Date(2019, 8, 1); // 1st September 2019 + tick(); + fixture.detectChanges(); + })); + + it('Should be able to select/deselect dates in multi mode', () => { + const secondMonthDates = HelperTestFunctions.getMonthViewDates(fixture, 1); + UIInteractions.simulateClickAndSelectEvent(secondMonthDates[16].firstChild); + + fixture.detectChanges(); + expect(calendar.value[0].getTime()).toEqual(new Date(2019, 9, 10).getTime()); + expect(calendar.daysView.value[0].getTime()).toEqual(new Date(2019, 9, 10).getTime()); + + UIInteractions.simulateClickAndSelectEvent(secondMonthDates[17].firstChild); + + fixture.detectChanges(); + expect(calendar.value[0].getTime()).toEqual(new Date(2019, 9, 10).getTime()); + expect(calendar.value[1].getTime()).toEqual(new Date(2019, 9, 17).getTime()); + expect(calendar.value[2].getTime()).toEqual(new Date(2019, 9, 18).getTime()); + + expect(calendar.daysView.value[0].getTime()).toEqual(new Date(2019, 9, 10).getTime()); + expect(calendar.daysView.value[1].getTime()).toEqual(new Date(2019, 9, 17).getTime()); + expect(calendar.daysView.value[2].getTime()).toEqual(new Date(2019, 9, 18).getTime()); + }); + }); + + describe('DatePicker/Calendar Integration Tests - ', () => { + let datePicker; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(MultiViewDatePickerSampleComponent); + fixture.detectChanges(); + datePicker = fixture.componentInstance.datePicker; + })); + afterEach(() => { + UIInteractions.clearOverlay(); + }); + + it('Verify opening Multi View Calendar from datepicker', fakeAsync(() => { + let target = fixture.nativeElement.querySelector(HelperTestFunctions.ICON_CSSCLASS); + UIInteractions.simulateClickAndSelectEvent(target); + tick(400); + fixture.detectChanges(); + + let overlay = document.querySelector(HelperTestFunctions.OVERLAY_CSSCLASS); + HelperTestFunctions.verifyMonthsViewNumber(overlay, 3); + HelperTestFunctions.verifyCalendarSubHeaders(overlay, [ymd('2019-09-16'), ymd('2019-10-16'), ymd('2019-11-16')]); + + // close the datePicker + datePicker.close(); + tick(400); + fixture.detectChanges(); + + datePicker.mode = 'dropdown'; + datePicker.displayMonthsCount = 2; + tick(); + fixture.detectChanges(); + + target = fixture.nativeElement.querySelector(HelperTestFunctions.ICON_CSSCLASS); + UIInteractions.simulateClickAndSelectEvent(target); + tick(400); + fixture.detectChanges(); + + overlay = document.querySelector(HelperTestFunctions.OVERLAY_CSSCLASS); + HelperTestFunctions.verifyMonthsViewNumber(overlay, 2); + HelperTestFunctions.verifyCalendarSubHeaders(overlay, [ymd('2019-09-16'), ymd('2019-10-16')]); + + // clean up test + tick(350); + })); + + it('Verify setting hideOutsideDays and monthsViewNumber from datepicker', fakeAsync(() => { + const target = fixture.nativeElement.querySelector(HelperTestFunctions.ICON_CSSCLASS); + UIInteractions.simulateClickAndSelectEvent(target); + tick(400); + fixture.detectChanges(); + + expect(datePicker.hideOutsideDays).toBe(true); + let overlay = document.querySelector(HelperTestFunctions.OVERLAY_CSSCLASS); + expect(HelperTestFunctions.getHiddenDays(overlay, 0).length).toBe(HelperTestFunctions.getInactiveDays(overlay, 0).length); + expect(HelperTestFunctions.getHiddenDays(overlay, 1).length).toBe(HelperTestFunctions.getInactiveDays(overlay, 1).length); + expect(HelperTestFunctions.getHiddenDays(overlay, 2).length).toBe(HelperTestFunctions.getInactiveDays(overlay, 2).length); + + // close the datePicker + datePicker.close(); + tick(400); + fixture.detectChanges(); + + datePicker.hideOutsideDays = false; + tick(); + fixture.detectChanges(); + + UIInteractions.simulateClickAndSelectEvent(target); + tick(400); + fixture.detectChanges(); + + expect(datePicker.hideOutsideDays).toBe(false); + overlay = document.querySelector(HelperTestFunctions.OVERLAY_CSSCLASS); + expect(HelperTestFunctions.getHiddenDays(overlay, 0).length).toBe(12); + expect(HelperTestFunctions.getHiddenDays(overlay, 1).length).toBe(11); + expect(HelperTestFunctions.getHiddenDays(overlay, 2).length).toBe(5); + + // clean up test + tick(350); + })); + }); +}); + +@Component({ + template: ` + + `, + imports: [IgxCalendarComponent] +}) +export class MultiViewCalendarSampleComponent { + @ViewChild(IgxCalendarComponent, { static: true }) public calendar: IgxCalendarComponent; + public monthViews = 3; +} + +@Component({ + template: ` + + `, + imports: [IgxDatePickerComponent] +}) +export class MultiViewDatePickerSampleComponent { + @ViewChild(IgxDatePickerComponent, { static: true }) public datePicker: IgxDatePickerComponent; + public date = ymd('2019-09-15'); + public monthViews = 3; +} + +@Component({ + template: ` + + `, + imports: [IgxCalendarComponent, FormsModule] +}) +export class MultiViewNgModelSampleComponent { + @ViewChild(IgxCalendarComponent, { static: true }) public calendar: IgxCalendarComponent; + public monthViews = 3; + public model = new Date(2019, 9, 10); +} diff --git a/projects/igniteui-angular/calendar/src/calendar/calendar.component.html b/projects/igniteui-angular/calendar/src/calendar/calendar.component.html new file mode 100644 index 00000000000..bcca8927465 --- /dev/null +++ b/projects/igniteui-angular/calendar/src/calendar/calendar.component.html @@ -0,0 +1,306 @@ + + @if (selection === 'single') { + {{ resourceStrings.igx_calendar_select_date }} + } + @if (selection === 'range') { + {{ resourceStrings.igx_calendar_range_placeholder }} + } + + + + @if (selection === 'single') { + {{ getFormattedDate().weekday }},  + {{ getFormattedDate().monthday }} + } + @if (selection === 'range') { + {{ selectedDates.length === 0 ? 'Start' : getFormattedRange().start}} +  -  + {{ selectedDates.length <= 1 ? 'End' : getFormattedRange().end}} + } + + + + + + {{ formattedMonth(obj.date) }} + + + + + + @if (activeView === 'year') { + {{ formattedYear(obj.date) }} + } + + {{ formattedYear(obj.date) }} + + + + + + @if (monthsViewNumber < 2 || obj.index < 1) { + + {{ monthsViewNumber > 1 ? + (resourceStrings.igx_calendar_first_picker_of.replace('{0}', monthsViewNumber.toString()) + ' ' + + (obj.date | date: 'LLLL yyyy')) : + resourceStrings.igx_calendar_selected_month_is + (obj.date | date: 'LLLL yyyy')}} + + } + + + + + + + {{ getDecadeRange().start }} - {{ getDecadeRange().end }} + + + + + + + + + + + + + + +
+ +
+
+ + + +
+ +
+
+ + + +
+
+ + +
+ @if (this.orientation === 'horizontal' ? i === monthsViewNumber - 1 : i === 0) { +
+ + +
+ } +
+
+ + + +
+
+ + +
+
+ + +
+
+
+ + + +
+
+ + +
+
+ + +
+
+
+ + +@if (selection === 'single' && hasHeader || selection === 'range' && hasHeader) { +
+
+ +
+

+ + +

+
+} + + +
+
+ @switch (selection) { + @case ('multi') { + {{ monthsViewNumber && monthsViewNumber > 1 ? + resourceStrings.igx_calendar_multi_selection.replace('{0}', monthsViewNumber.toString()) : + resourceStrings.igx_calendar_singular_multi_selection}} + } + @case ('range') { + {{ monthsViewNumber && monthsViewNumber > 1 ? + resourceStrings.igx_calendar_range_selection.replace('{0}', monthsViewNumber.toString()) : + resourceStrings.igx_calendar_singular_range_selection}} + } + @default { + {{ monthsViewNumber && monthsViewNumber > 1 ? + resourceStrings.igx_calendar_single_selection.replace('{0}', monthsViewNumber.toString()) : + resourceStrings.igx_calendar_singular_single_selection}} + } + } +
+
+ @if (isDefaultView) { + @for (view of monthsViewNumber | IgxMonthViewSlots; track $index; let i = $index) { + + } + } + + @if (isYearView) { + + } + + @if (isDecadeView) { + + } +
+ + +
diff --git a/projects/igniteui-angular/calendar/src/calendar/calendar.component.spec.ts b/projects/igniteui-angular/calendar/src/calendar/calendar.component.spec.ts new file mode 100644 index 00000000000..0ba26e598b4 --- /dev/null +++ b/projects/igniteui-angular/calendar/src/calendar/calendar.component.spec.ts @@ -0,0 +1,3511 @@ +import { Component, DebugElement, LOCALE_ID, ViewChild } from "@angular/core"; +import { + TestBed, + tick, + fakeAsync, + flush, + waitForAsync, + ComponentFixture, +} from "@angular/core/testing"; +import { FormsModule } from "@angular/forms"; +import { By } from "@angular/platform-browser"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; + +import { registerLocaleData } from "@angular/common"; +import localeFr from "@angular/common/locales/fr"; + +import { + Calendar, + IgxCalendarComponent, + IgxCalendarView, + isLeap, + IViewDateChangeEventArgs, + monthRange, + weekDay, +} from "./public_api"; +import { UIInteractions } from "../../../test-utils/ui-interactions.spec"; +import { + DateRangeDescriptor, + DateRangeType, +} from "../../../core/src/core/dates/dateRange"; + +import { IgxDayItemComponent } from "./days-view/day-item.component"; +import { HelperTestFunctions } from "../../../test-utils/calendar-helper-utils"; +import { WEEKDAYS } from "../../../core/src/core/enums"; + +describe("IgxCalendar - ", () => { + registerLocaleData(localeFr); + + it("Should receive correct values from utility functions", () => { + const calendar = new Calendar(); + + // Leap year + expect(isLeap(2017)).toBe(false); + expect(isLeap(2016)).toBe(true); + + // monthRange + expect(() => monthRange(2017, -1)).toThrow(); + expect(() => monthRange(2017, 12)).toThrow(); + expect(monthRange(2017, 5)).toEqual([weekDay(2017, 5, 1), 30]); + expect(monthRange(2016, 1)).toEqual([weekDay(2016, 1, 1), 29]); // Leap year + + // Calendar timedelta + const startDate = new Date(2017, 0, 1, 0, 0, 0); + + // Year timedelta + let newDate = calendar.timedelta(startDate, "year", 1); + expect(newDate.getFullYear()).toEqual(2018); + newDate = calendar.timedelta(startDate, "year", -1); + expect(newDate.getFullYear()).toEqual(2016); + + // Quarter timedelta + newDate = calendar.timedelta(startDate, "quarter", 1); + expect(newDate.getMonth()).toEqual(3); + newDate = calendar.timedelta(startDate, "quarter", -1); + expect(newDate.getFullYear()).toEqual(2016); + expect(newDate.getMonth()).toEqual(9); + + // Month timedelta + newDate = calendar.timedelta(startDate, "month", 1); + expect(newDate.getMonth()).toEqual(1); + newDate = calendar.timedelta(startDate, "month", -1); + expect(newDate.getFullYear()).toEqual(2016); + expect(newDate.getMonth()).toEqual(11); + + // Week timedelta + newDate = calendar.timedelta(startDate, "week", 1); + expect(newDate.getDate()).toEqual(8); + newDate = calendar.timedelta(startDate, "week", -1); + expect(newDate.getFullYear()).toEqual(2016); + expect(newDate.getDate()).toEqual(25); + + // Day timedelta + newDate = calendar.timedelta(startDate, "day", 3); + expect(newDate.getDate()).toEqual(4); + expect(calendar.timedelta(startDate, "day", 7).toDateString()).toEqual( + calendar.timedelta(startDate, "week", 1).toDateString(), + ); + newDate = calendar.timedelta(startDate, "day", -3); + expect(newDate.getFullYear()).toEqual(2016); + expect(newDate.getDate()).toEqual(29); + + // Hour timedelta + newDate = calendar.timedelta(startDate, "hour", 1); + expect(newDate.getHours()).toEqual(1); + newDate = calendar.timedelta(startDate, "hour", 24); + expect(newDate.getDate()).toEqual(2); + expect(newDate.getHours()).toEqual(0); + newDate = calendar.timedelta(startDate, "hour", -1); + expect(newDate.getHours()).toEqual(23); + expect(newDate.getDate()).toEqual(31); + expect(newDate.getFullYear()).toEqual(2016); + + // Minute timedelta + newDate = calendar.timedelta(startDate, "minute", 60); + expect(newDate.getHours()).toEqual(1); + newDate = calendar.timedelta(startDate, "minute", 24 * 60); + expect(newDate.getDate()).toEqual(2); + expect(newDate.getHours()).toEqual(0); + newDate = calendar.timedelta(startDate, "minute", -60); + expect(newDate.getHours()).toEqual(23); + expect(newDate.getDate()).toEqual(31); + expect(newDate.getFullYear()).toEqual(2016); + + // Seconds timedelta + newDate = calendar.timedelta(startDate, "second", 3600); + expect(newDate.getHours()).toEqual(1); + newDate = calendar.timedelta(startDate, "second", 24 * 3600); + expect(newDate.getDate()).toEqual(2); + expect(newDate.getHours()).toEqual(0); + newDate = calendar.timedelta(startDate, "second", -3600); + expect(newDate.getHours()).toEqual(23); + expect(newDate.getDate()).toEqual(31); + expect(newDate.getFullYear()).toEqual(2016); + + // Throws on invalid interval + expect(() => calendar.timedelta(startDate, "nope", 1)).toThrow(); + }); + + describe("Basic -", () => { + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxCalendarSampleComponent, + IgxCalendarRangeComponent, + IgxCalendarDisabledSpecialDatesComponent, + IgxCalendarValueComponent, + ], + }).compileComponents(); + })); + + describe("Calendar - ", () => { + let fixture: ComponentFixture; + let calendar: IgxCalendarComponent; + let dom: DebugElement; + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(IgxCalendarSampleComponent); + + fixture.detectChanges(); + calendar = fixture.componentInstance.calendar; + dom = fixture.debugElement; + })); + afterEach(() => { + fixture = undefined; + calendar = undefined; + dom = undefined; + }); + + it("Should initialize a calendar component", () => { + expect(fixture.componentInstance).toBeDefined(); + }); + + it("Should initialize a calendar component with `id` property", () => { + const domCalendar = dom.query( + By.css(HelperTestFunctions.CALENDAR), + ).nativeElement; + + expect(calendar.id).toContain("igx-calendar-"); + expect(domCalendar.id).toContain("igx-calendar-"); + + calendar.id = "customCalendar"; + fixture.detectChanges(); + + expect(calendar.id).toBe("customCalendar"); + expect(domCalendar.id).toBe("customCalendar"); + }); + + it("Should properly set @Input properties and setters", () => { + const today = new Date(Date.now()); + + expect(calendar.weekStart).toEqual(WEEKDAYS.SUNDAY); + expect(calendar.selection).toEqual("single"); + + calendar.viewDate = today; + fixture.detectChanges(); + + calendar.weekStart = WEEKDAYS.MONDAY; + expect(calendar.weekStart).toEqual(1); + + calendar.value = new Date(today); + fixture.detectChanges(); + + expect( + (fixture.componentInstance.model as Date).toDateString(), + ).toMatch(today.toDateString()); + expect((calendar.value as Date).toDateString()).toMatch( + today.toDateString(), + ); + + expect(() => (calendar.selection = "non-existant")).toThrow(); + + const todayIsoDate = new Date(Date.now()).toISOString(); + + calendar.viewDate = todayIsoDate; + fixture.detectChanges(); + + calendar.value = new Date(todayIsoDate); + fixture.detectChanges(); + + expect( + (fixture.componentInstance.model as Date).toDateString(), + ).toMatch(new Date(todayIsoDate).toDateString()); + expect((calendar.value as Date).toDateString()).toMatch( + new Date(todayIsoDate).toDateString(), + ); + }); + + describe("Rendered Component - ", () => { + it("Should properly set formatOptions and formatViews", () => { + fixture.componentInstance.viewDate = new Date(2018, 8, 17); + fixture.componentInstance.model = new Date(); + fixture.detectChanges(); + + const defaultOptions = { + day: "numeric", + month: "long", + weekday: "narrow", + year: "numeric", + }; + const defaultViews = { + day: false, + month: true, + year: false, + }; + const bodyMonth = dom.query( + By.css(HelperTestFunctions.CALENDAR_DATE_CSSCLASS), + ); + const headerTitle = dom.query( + By.css( + HelperTestFunctions.CALENDAR_HEADER_YEAR_CSSCLASS, + ), + ); + const bodyYear = dom.queryAll( + By.css(HelperTestFunctions.CALENDAR_DATE_CSSCLASS), + )[1]; + const headerWeekday = dom.queryAll( + By.css( + `${HelperTestFunctions.CALENDAR_HEADER_DATE_CSSCLASS} span`, + ), + )[0]; + const headerDate = dom.queryAll( + By.css( + `${HelperTestFunctions.CALENDAR_HEADER_DATE_CSSCLASS} span`, + ), + )[1]; + + calendar.selectDate(calendar.viewDate); + fixture.detectChanges(); + + expect(calendar.formatOptions).toEqual( + jasmine.objectContaining(defaultOptions), + ); + expect(calendar.formatViews).toEqual( + jasmine.objectContaining(defaultViews), + ); + expect( + headerTitle.nativeElement.textContent.trim(), + ).toMatch("Select Date"); + expect( + headerWeekday.nativeElement.textContent.trim(), + ).toMatch("S"); + expect(headerDate.nativeElement.textContent.trim()).toMatch( + "Sep 1", + ); + expect(bodyYear.nativeElement.textContent.trim()).toMatch( + "2018", + ); + expect(bodyMonth.nativeElement.textContent.trim()).toMatch( + "Sep", + ); + + // change formatOptions and formatViews + const formatOptions: any = { + month: "long", + year: "2-digit", + }; + const formatViews: any = { month: true, year: true }; + calendar.formatOptions = formatOptions; + calendar.formatViews = formatViews; + fixture.detectChanges(); + + expect(calendar.formatOptions).toEqual( + jasmine.objectContaining( + Object.assign(defaultOptions, formatOptions), + ), + ); + expect(calendar.formatViews).toEqual( + jasmine.objectContaining( + Object.assign(defaultViews, formatViews), + ), + ); + expect( + headerTitle.nativeElement.textContent.trim(), + ).toMatch("Select Date"); + expect( + headerWeekday.nativeElement.textContent.trim(), + ).toMatch("S"); + expect(headerDate.nativeElement.textContent.trim()).toMatch( + "Sep 1", + ); + expect(bodyYear.nativeElement.textContent.trim()).toMatch( + "18", + ); + expect(bodyMonth.nativeElement.textContent.trim()).toMatch( + "September", + ); + + // change formatOptions and formatViews + formatOptions.year = "numeric"; + formatViews.day = true; + formatViews.month = false; + calendar.formatOptions = formatOptions; + calendar.formatViews = formatViews; + fixture.detectChanges(); + + expect(calendar.formatOptions).toEqual( + jasmine.objectContaining( + Object.assign(defaultOptions, formatOptions), + ), + ); + expect(calendar.formatViews).toEqual( + jasmine.objectContaining( + Object.assign(defaultViews, formatViews), + ), + ); + expect( + headerTitle.nativeElement.textContent.trim(), + ).toMatch("Select Date"); + expect( + headerWeekday.nativeElement.textContent.trim(), + ).toMatch("S"); + expect(headerDate.nativeElement.textContent.trim()).toMatch( + "Sep 1", + ); + expect(bodyYear.nativeElement.textContent.trim()).toMatch( + "2018", + ); + expect(bodyMonth.nativeElement.textContent.trim()).toMatch( + "8", + ); + }); + + it("Should show right month when value is set", () => { + fixture = TestBed.createComponent( + IgxCalendarValueComponent, + ); + fixture.detectChanges(); + calendar = fixture.componentInstance.calendar; + + expect(calendar.weekStart).toEqual(WEEKDAYS.SUNDAY); + expect(calendar.selection).toEqual("single"); + expect(calendar.viewDate.getMonth()).toEqual( + (calendar.value as Date).getMonth(), + ); + + const date = new Date(2020, 8, 28); + calendar.viewDate = date; + fixture.detectChanges(); + + expect(calendar.viewDate.getMonth()).toEqual( + date.getMonth(), + ); + + calendar.value = new Date(2020, 9, 15); + fixture.detectChanges(); + + expect(calendar.viewDate.getMonth()).toEqual( + date.getMonth(), + ); + + const isoStringDate = new Date(2020, 10, 10).toISOString(); + calendar.viewDate = isoStringDate; + fixture.detectChanges(); + expect(calendar.viewDate.getMonth()).toEqual( + new Date(isoStringDate).getMonth(), + ); + + calendar.value = new Date(2020, 11, 15).toISOString(); + fixture.detectChanges(); + + expect(calendar.viewDate.getMonth()).toEqual( + new Date(isoStringDate).getMonth(), + ); + }); + + it("Should properly set locale", () => { + fixture.componentInstance.viewDate = new Date(2018, 8, 17); + fixture.componentInstance.model = new Date(); + fixture.detectChanges(); + + const bodyMonth = dom.query( + By.css(HelperTestFunctions.CALENDAR_DATE_CSSCLASS), + ); + const headerTitle = dom.query( + By.css( + HelperTestFunctions.CALENDAR_HEADER_YEAR_CSSCLASS, + ), + ); + const bodyYear = dom.queryAll( + By.css(HelperTestFunctions.CALENDAR_DATE_CSSCLASS), + )[1]; + const headerWeekday = dom.queryAll( + By.css( + `${HelperTestFunctions.CALENDAR_HEADER_DATE_CSSCLASS} span`, + ), + )[0]; + const headerDate = dom.queryAll( + By.css( + `${HelperTestFunctions.CALENDAR_HEADER_DATE_CSSCLASS} span`, + ), + )[1]; + let bodyWeekday = dom.query( + By.css(HelperTestFunctions.WEEKSTART_LABEL_CSSCLASS), + ); + + calendar.selectDate(calendar.viewDate); + fixture.detectChanges(); + + expect( + headerTitle.nativeElement.textContent.trim(), + ).toMatch("Select Date"); + expect( + headerWeekday.nativeElement.textContent.trim(), + ).toMatch("S"); + expect(headerDate.nativeElement.textContent.trim()).toMatch( + "Sep 1", + ); + expect(bodyYear.nativeElement.textContent.trim()).toMatch( + "2018", + ); + expect(bodyMonth.nativeElement.textContent.trim()).toMatch( + "September", + ); + expect( + bodyWeekday.nativeElement.textContent.trim(), + ).toMatch("S"); + + // change formatOptions and formatViews + const locale = "fr"; + calendar.locale = locale; + fixture.detectChanges(); + + bodyWeekday = dom.query( + By.css(HelperTestFunctions.WEEKSTART_LABEL_CSSCLASS), + ); + expect(calendar.locale).toEqual(locale); + expect( + headerTitle.nativeElement.textContent.trim(), + ).toMatch("Select Date"); + expect( + headerWeekday.nativeElement.textContent.trim(), + ).toMatch("sam.,"); + expect(headerDate.nativeElement.textContent.trim()).toMatch( + "1 sept.", + ); + expect(bodyYear.nativeElement.textContent.trim()).toMatch( + "18", + ); + expect(bodyMonth.nativeElement.textContent.trim()).toMatch( + "sept.", + ); + expect( + bodyWeekday.nativeElement.textContent.trim(), + ).toMatch("L"); + }); + + it("Should default to today date when invalid date is passed", () => { + fixture = TestBed.createComponent( + IgxCalendarValueComponent, + ); + fixture.detectChanges(); + calendar = fixture.componentInstance.calendar; + + const today = new Date().setHours(0, 0, 0, 0); + calendar.value = new Date(NaN); + fixture.detectChanges(); + + expect(calendar.value.getTime()).toEqual(today); + + calendar.value = undefined; + fixture.detectChanges(); + + expect((calendar.value as Date).getTime()).toEqual(today); + + calendar.value = new Date("1989-5s-dd"); + fixture.detectChanges(); + + expect(calendar.value.getTime()).toEqual(today); + }); + + it("Should properly render calendar DOM structure", () => { + const today = new Date(Date.now()); + calendar.viewDate = today; + fixture.detectChanges(); + const calendarRows = dom.queryAll( + By.css(HelperTestFunctions.CALENDAR_ROW_CSSCLASS), + ); + + // 6 weeks + week header + expect(calendarRows.length).toEqual(7); + + // 6 calendar rows * 7 elements in each + expect( + dom.queryAll( + By.css( + `${HelperTestFunctions.CALENDAR_ROW_CSSCLASS} > igx-day-item`, + ), + ).length, + ).toEqual(42); + expect( + dom.queryAll( + By.css( + `${HelperTestFunctions.CALENDAR_ROW_CSSCLASS} > span`, + ), + ).length, + ).toEqual(7); + + // Today class applied + expect( + dom + .query( + By.css( + HelperTestFunctions.CURRENT_DATE_CSSCLASS, + ), + ) + .nativeElement.textContent.trim(), + ).toMatch(today.getDate().toString()); + + // Hide calendar header when not single selection + calendar.selection = "multi"; + fixture.detectChanges(); + + const calendarHeader = dom.query( + By.css(HelperTestFunctions.CALENDAR_HEADER_CSSCLASS), + ); + expect(calendarHeader).toBeFalsy(); + }); + + it("Should properly render calendar DOM with week numbers enabled", () => { + const today = new Date(Date.now()); + calendar.viewDate = today; + calendar.showWeekNumbers = true; + fixture.detectChanges(); + + const calendarRows = dom.queryAll( + By.css(HelperTestFunctions.CALENDAR_ROW_CSSCLASS), + ); + expect(calendarRows.length).toEqual(7); + + // 6 calendar rows * 8 elements in each + expect( + dom.queryAll( + By.css( + `${HelperTestFunctions.CALENDAR_ROW_CSSCLASS} > igx-day-item`, + ), + ).length + + dom.queryAll( + By.css(`${HelperTestFunctions.CALENDAR_ROW_CSSCLASS} > + ${HelperTestFunctions.CALENDAR_WEEK_NUMBER_CLASS}`), + ).length, + ).toEqual(48); + + expect( + dom.queryAll( + By.css( + `${HelperTestFunctions.CALENDAR_ROW_CSSCLASS} > span`, + ), + ).length + + dom.queryAll( + By.css( + `${HelperTestFunctions.CALENDAR_ROW_CSSCLASS} > ${HelperTestFunctions.CALENDAR_WEEK_NUMBER_LABEL_CLASS}`, + ), + ).length, + ).toEqual(8); + }); + + it("Week numbers should appear as first column", () => { + const firstWeekOfTheYear = new Date(2017, 0, 5); + calendar.viewDate = firstWeekOfTheYear; + calendar.showWeekNumbers = true; + fixture.detectChanges(); + + const calendarRows = dom.queryAll( + By.css(`${HelperTestFunctions.CALENDAR_ROW_CSSCLASS}`), + ); + + const expectedWeeks = ["W", "1", "2", "3", "4", "5", "6"]; + + calendarRows.forEach((row, idx) => { + const firstRowItem = row.nativeElement.children[0]; + expect(firstRowItem.firstChild.innerText).toEqual( + expectedWeeks[idx], + ); + }); + }); + + it("should display the correct week numbers in the first column", () => { + const firstDayOfMar = new Date(2023, 2, 1); + calendar.viewDate = firstDayOfMar; + calendar.weekStart = 0; + calendar.showWeekNumbers = true; + fixture.detectChanges(); + + const calendarRowsMar = dom.queryAll( + By.css(`${HelperTestFunctions.CALENDAR_ROW_CSSCLASS}`), + ); + + calendarRowsMar.forEach((row, idx) => { + const firstRowItem = row.nativeElement.children[0]; + if (idx === 5) { + expect(firstRowItem.firstChild.innerText).toEqual( + "12", + ); + } + }); + + const firstDayOfOct = new Date(2023, 9, 1); + calendar.viewDate = firstDayOfOct; + fixture.detectChanges(); + + const calendarRowsOct = dom.queryAll( + By.css(`${HelperTestFunctions.CALENDAR_ROW_CSSCLASS}`), + ); + + calendarRowsOct.forEach((row, idx) => { + const firstRowItem = row.nativeElement.children[0]; + if (idx === 5) { + expect(firstRowItem.firstChild.innerText).toEqual( + "43", + ); + } + }); + + const firstDayOfDec = new Date(2023, 11, 1); + calendar.viewDate = firstDayOfDec; + fixture.detectChanges(); + + const calendarRowsDec = dom.queryAll( + By.css(`${HelperTestFunctions.CALENDAR_ROW_CSSCLASS}`), + ); + + calendarRowsDec.forEach((row, idx) => { + const firstRowItem = row.nativeElement.children[0]; + if (idx === 6) { + // With simple counting for Sunday start, expect 53 + expect(firstRowItem.firstChild.innerText).toEqual( + "53", + ); + } + }); + }); + + it("Calendar DOM structure - year view | month view", () => { + calendar.activeView = "year"; + fixture.detectChanges(); + + expect( + dom.query( + By.css( + HelperTestFunctions.CALENDAR_ROW_WRAP_CSSCLASS, + ), + ), + ).toBeDefined(); + const months = dom.queryAll( + By.css(HelperTestFunctions.MONTH_CSSCLASS), + ); + const currentMonth = dom.query( + By.css(HelperTestFunctions.CURRENT_MONTH_CSSCLASS), + ); + + expect(months.length).toEqual(12); + expect( + currentMonth.nativeElement.textContent.trim(), + ).toMatch("June"); + + months[0].nativeElement.dispatchEvent( + new Event("mousedown"), + ); + + fixture.detectChanges(); + expect(calendar.viewDate.getMonth()).toEqual(0); + + calendar.activeView = "decade"; + fixture.detectChanges(); + + const years = dom.queryAll( + By.css(HelperTestFunctions.YEAR_CSSCLASS), + ); + const currentYear = dom.query( + By.css(HelperTestFunctions.CURRENT_YEAR_CSSCLASS), + ); + + expect(years.length).toEqual(15); + expect( + currentYear.nativeElement.textContent.trim(), + ).toMatch("2017"); + + years[0].nativeElement.dispatchEvent( + new Event("mousedown"), + ); + fixture.detectChanges(); + + expect(calendar.viewDate.getFullYear()).toEqual(2010); + }); + + it("Calendar selection - single with event", () => { + fixture.detectChanges(); + + const target = dom.query( + By.css(HelperTestFunctions.SELECTED_DATE_CSSCLASS), + ); + const weekDiv = target.parent; + const weekDays = weekDiv.queryAll( + By.css(HelperTestFunctions.DAY_CSSCLASS), + ); + const nextDay = new Date(2017, 5, 14); + + expect((calendar.value as Date).toDateString()).toMatch( + new Date(2017, 5, 13).toDateString(), + ); + + spyOn(calendar.selected, "emit"); + + // Select 14th + const dateElement = weekDays[3].nativeElement.firstChild; + + dateElement.click(); + fixture.detectChanges(); + + expect(calendar.selected.emit).toHaveBeenCalled(); + expect((calendar.value as Date).toDateString()).toMatch( + nextDay.toDateString(), + ); + + HelperTestFunctions.verifyDateSelected(weekDays[3]); + expect( + ( + fixture.componentInstance.model as Date + ).toDateString(), + ).toMatch(nextDay.toDateString()); + HelperTestFunctions.verifyDateNotSelected(target); + }); + + it("Calendar selection - outside of current month - next month", () => { + const parent = dom.query( + By.css( + `${HelperTestFunctions.CALENDAR_ROW_CSSCLASS}:last-child`, + ), + ); + const parentDates = parent.queryAll( + By.css(HelperTestFunctions.INACTIVE_DAYS_CSSCLASS), + ); + const target = parentDates[parentDates.length - 1]; + + target.nativeElement.firstChild.click(); + fixture.detectChanges(); + + expect( + ( + fixture.componentInstance.model as Date + ).toDateString(), + ).toMatch(new Date(2017, 6, 8).toDateString()); + + expect( + dom + .query( + By.css( + HelperTestFunctions.CALENDAR_HEADER_DATE_CSSCLASS, + ), + ) + .nativeElement.textContent.includes("Jul"), + ).toBe(true); + }); + + it("Calendar selection - outside of current month - previous month", () => { + const parent = dom.queryAll( + By.css(HelperTestFunctions.CALENDAR_ROW_CSSCLASS), + )[1]; + const target = parent.queryAll( + By.css(HelperTestFunctions.INACTIVE_DAYS_CSSCLASS), + )[0]; + + target.nativeElement.firstChild.click(); + fixture.detectChanges(); + + expect( + ( + fixture.componentInstance.model as Date + ).toDateString(), + ).toMatch(new Date(2017, 4, 28).toDateString()); + expect( + dom + .query( + By.css( + HelperTestFunctions.CALENDAR_HEADER_DATE_CSSCLASS, + ), + ) + .nativeElement.textContent.includes("May"), + ).toBe(true); + }); + + it("Calendar selection - single through API", () => { + fixture.detectChanges(); + + const target = dom.query( + By.css(HelperTestFunctions.SELECTED_DATE_CSSCLASS), + ); + const weekDiv = target.parent; + const weekDays = weekDiv.queryAll( + By.css(HelperTestFunctions.DAY_CSSCLASS), + ); + let nextDay = new Date(2017, 5, 14); + + expect((calendar.value as Date).toDateString()).toMatch( + new Date(2017, 5, 13).toDateString(), + ); + + calendar.selectDate(new Date(2017, 5, 14)); + fixture.detectChanges(); + + expect((calendar.value as Date).toDateString()).toMatch( + nextDay.toDateString(), + ); + HelperTestFunctions.verifyDateSelected(weekDays[3]); + expect( + (fixture.componentInstance.model as Date).toDateString(), + ).toMatch(nextDay.toDateString()); + HelperTestFunctions.verifyDateNotSelected(target); + + nextDay = new Date(2017, 6, 15); + calendar.selectDate(new Date(2017, 6, 15).toISOString()); + fixture.detectChanges(); + + expect((calendar.value as Date).toDateString()).toMatch( + nextDay.toDateString(), + ); + }); + + it("Calendar selection - multiple with event", () => { + fixture.detectChanges(); + + const target = dom.query( + By.css(HelperTestFunctions.SELECTED_DATE_CSSCLASS), + ); + const weekDiv = target.parent; + const weekDays = weekDiv.queryAll( + By.css(HelperTestFunctions.DAY_CSSCLASS), + ); + + calendar.selection = "multi"; + fixture.detectChanges(); + + expect(calendar.value instanceof Array).toBeTruthy(); + expect( + fixture.componentInstance.model instanceof Array, + ).toBeTruthy(); + expect((calendar.value as Date[]).length).toEqual(0); + expect( + (fixture.componentInstance.model as Date[]).length, + ).toEqual(0); + + for (const day of weekDays) { + day.nativeElement.firstChild.click(); + fixture.detectChanges(); + } + + expect((calendar.value as Date[]).length).toEqual(7); + expect( + (fixture.componentInstance.model as Date[]).length, + ).toEqual(7); + weekDays.forEach((el) => { + HelperTestFunctions.verifyDateSelected(el); + }); + + // Deselect last day + weekDays.at(-1).nativeElement.firstChild.click(); + fixture.detectChanges(); + + expect((calendar.value as Date[]).length).toEqual(6); + + expect( + (fixture.componentInstance.model as Date[]).length, + ).toEqual(6); + HelperTestFunctions.verifyDateNotSelected( + weekDays.at(-1) + ); + }); + + it("Calendar selection - multiple through API", () => { + fixture.detectChanges(); + + const target = dom.query( + By.css(HelperTestFunctions.SELECTED_DATE_CSSCLASS), + ); + const weekDiv = target.parent; + const weekDays = weekDiv.queryAll( + By.css(HelperTestFunctions.DAY_CSSCLASS), + ); + + calendar.selection = "multi"; + fixture.detectChanges(); + + const lastDay = new Date(2017, 5, 17); + + // Single date + calendar.selectDate(lastDay); + fixture.detectChanges(); + + expect( + ( + fixture.componentInstance.model as Date[] + )[0].toDateString(), + ).toMatch(lastDay.toDateString()); + expect(calendar.value[0].toDateString()).toMatch( + lastDay.toDateString(), + ); + HelperTestFunctions.verifyDateSelected( + weekDays[weekDays.length - 1], + ); + + // Multiple dates + calendar.selectDate([ + new Date(2017, 5, 11), + new Date(2017, 5, 12), + ]); + fixture.detectChanges(); + + expect( + (fixture.componentInstance.model as Date[]).length, + ).toEqual(3); + expect((calendar.value as Date[]).length).toEqual(3); + + // 11th June + HelperTestFunctions.verifyDateSelected(weekDays[0]); + // 12th June + HelperTestFunctions.verifyDateSelected(weekDays[1]); + }); + + it("Calendar selection - range with event", () => { + fixture.detectChanges(); + + const target = dom.query( + By.css(HelperTestFunctions.SELECTED_DATE_CSSCLASS), + ); + const weekDiv = target.parent; + const weekDays = weekDiv.queryAll( + By.css(HelperTestFunctions.DAY_CSSCLASS), + ); + + calendar.selection = "range"; + fixture.detectChanges(); + + const lastDay = new Date(2017, 5, 17); + const firstDay = new Date(2017, 5, 11); + + // Start range selection... + weekDays[0].nativeElement.firstChild.click(); + fixture.detectChanges(); + + expect( + (fixture.componentInstance.model as Date[]).length, + ).toEqual(1); + expect((calendar.value as Date[]).length).toEqual(1); + expect( + ( + fixture.componentInstance.model as Date[] + )[0].toDateString(), + ).toMatch(firstDay.toDateString()); + HelperTestFunctions.verifyDateSelected(weekDays[0]); + + // ...and cancel it + weekDays[0].nativeElement.firstChild.click(); + fixture.detectChanges(); + + expect( + (fixture.componentInstance.model as Date[]).length, + ).toEqual(0); + expect((calendar.value as Date[]).length).toEqual(0); + HelperTestFunctions.verifyDateNotSelected(weekDays[0]); + + // Start range selection... + weekDays.at(0).nativeElement.firstChild.click(); + fixture.detectChanges(); + + // ...and complete it + weekDays.at(-1).nativeElement.firstChild.click(); + fixture.detectChanges(); + + + expect( + (fixture.componentInstance.model as Date[]).length, + ).toEqual(7); + expect((calendar.value as Date[]).length).toEqual(7); + expect(calendar.value[0].toDateString()).toMatch( + firstDay.toDateString(), + ); + expect( + calendar.value[ + (calendar.value as Date[]).length - 1 + ].toDateString(), + ).toMatch(lastDay.toDateString()); + weekDays.forEach((el) => { + HelperTestFunctions.verifyDateSelected(el); + }); + }); + + it("Calendar selection - range through API", () => { + fixture.detectChanges(); + + const target = dom.query( + By.css(HelperTestFunctions.SELECTED_DATE_CSSCLASS), + ); + const weekDiv = target.parent; + const weekDays = weekDiv.queryAll( + By.css(HelperTestFunctions.DAY_CSSCLASS), + ); + + calendar.selection = "range"; + fixture.detectChanges(); + + const lastDay = new Date(2017, 5, 17); + const midDay = new Date(2017, 5, 14); + const firstDay = new Date(2017, 5, 11); + + calendar.selectDate([firstDay, lastDay]); + fixture.detectChanges(); + + expect( + (fixture.componentInstance.model as Date[]).length, + ).toEqual(7); + expect((calendar.value as Date[]).length).toEqual(7); + expect(calendar.value[0].toDateString()).toMatch( + firstDay.toDateString(), + ); + expect( + calendar.value[ + (calendar.value as Date[]).length - 1 + ].toDateString(), + ).toMatch(lastDay.toDateString()); + weekDays.forEach((el) => { + HelperTestFunctions.verifyDateSelected(el); + }); + + calendar.selectDate([firstDay, midDay]); + fixture.detectChanges(); + + expect( + (fixture.componentInstance.model as Date[]).length, + ).toEqual(4); + expect((calendar.value as Date[]).length).toEqual(4); + expect(calendar.value[0].toDateString()).toMatch( + firstDay.toDateString(), + ); + expect( + calendar.value[ + (calendar.value as Date[]).length - 1 + ].toDateString(), + ).toMatch(midDay.toDateString()); + for (const i of [0, 1, 2, 3]) { + HelperTestFunctions.verifyDateSelected(weekDays[i]); + } + + // Select with only one day + calendar.selectDate([lastDay]); + fixture.detectChanges(); + + expect((calendar.value as Date[]).length).toEqual(1); + expect(calendar.value[0].toDateString()).toMatch( + lastDay.toDateString(), + ); + HelperTestFunctions.verifyDateSelected(weekDays[6]); + + // Select with array of 3 days + calendar.selectDate([midDay, lastDay, firstDay]); + fixture.detectChanges(); + + expect( + (fixture.componentInstance.model as Date[]).length, + ).toEqual(7); + expect((calendar.value as Date[]).length).toEqual(7); + expect(calendar.value[0].toDateString()).toMatch( + firstDay.toDateString(), + ); + expect( + calendar.value[ + (calendar.value as Date[]).length - 1 + ].toDateString(), + ).toMatch(lastDay.toDateString()); + weekDays.forEach((el) => { + HelperTestFunctions.verifyDateSelected(el); + }); + }); + }); + + describe("Keyboard Navigation - ", () => { + let component: DebugElement; + + beforeEach(waitForAsync(() => { + component = dom.query( + By.css(HelperTestFunctions.CALENDAR_WRAPPER_CLASS), + ); + component.nativeElement.focus(); + })); + + it("Calendar keyboard navigation - PageUp/PageDown", () => { + UIInteractions.triggerKeyDownEvtUponElem( + "PageUp", + component.nativeElement, + ); + + fixture.detectChanges(); + expect(calendar.viewDate.getMonth()).toEqual(4); + + calendar.viewDate = new Date(2017, 5, 13); + fixture.detectChanges(); + UIInteractions.triggerKeyDownEvtUponElem( + "PageDown", + component.nativeElement, + ); + fixture.detectChanges(); + + expect(calendar.viewDate.getMonth()).toEqual(6); + + UIInteractions.triggerKeyDownEvtUponElem( + "PageUp", + component.nativeElement, + true, + false, + true, + ); + fixture.detectChanges(); + expect(calendar.viewDate.getFullYear()).toEqual(2016); + + calendar.viewDate = new Date(2017, 5, 13); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem( + "PageDown", + component.nativeElement, + true, + false, + true, + ); + fixture.detectChanges(); + expect(calendar.viewDate.getFullYear()).toEqual(2018); + }); + + it("Calendar keyboard navigation - Home/End/Enter", () => { + const days = calendar.daysView.dates.filter( + (day) => day.isCurrentMonth, + ); + const firstDay = days.at(0); + const lastDay = days.at(-1); + + UIInteractions.triggerKeyDownEvtUponElem( + "Home", + component.nativeElement, + ); + fixture.detectChanges(); + expect(calendar.activeDate.getDate().toString()).toEqual(firstDay.nativeElement.textContent.trim()); + + UIInteractions.triggerKeyDownEvtUponElem( + "End", + component.nativeElement, + ); + fixture.detectChanges(); + expect(calendar.activeDate.getDate().toString()).toEqual(lastDay.nativeElement.textContent.trim()); + + UIInteractions.triggerKeyDownEvtUponElem( + "Enter", + component.nativeElement, + ); + fixture.detectChanges(); + + expect(calendar.value).toEqual(lastDay.date.native); + }); + + it("Calendar keyboard navigation - Arrow keys", () => { + // Initial active date must be the first if no date is selected + // and no prior user interaction has been made + calendar.activeDate = new Date(2017, 1, 1); + expect(calendar.activeDate.getDate()).toEqual(1); + + // Go to the next row + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowDown", + component.nativeElement + ); + + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(8); + + // Go to the left + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowLeft", + component.nativeElement + ); + + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(7); + + // Go to the right + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowRight", + document.activeElement, + ); + + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(8); + + // Go up a row + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowUp", + document.activeElement, + ); + + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(1); + }); + + it("Calendar should persist focus when navigating between prev/next month.", () => { + UIInteractions.triggerKeyDownEvtUponElem( + "Home", + component.nativeElement, + ); + fixture.detectChanges(); + + // Ensure first of month day is active + expect(calendar.activeDate.getDate()).toEqual(1); + + // Navigate to the previous month by pressing Arrow Left + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowLeft", + component.nativeElement, + ); + fixture.detectChanges(); + expect(calendar.viewDate.getMonth()).toEqual(calendar.activeDate.getMonth()); + expect(calendar.activeDate.getDate()).toEqual(31); + expect(document.activeElement).toBe(component.nativeElement); + + // Select the active date by pressing Enter + UIInteractions.triggerKeyDownEvtUponElem("Enter", component.nativeElement); + + fixture.detectChanges(); + expect(calendar.value).toEqual(calendar.activeDate); + + // Go to the next month + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowRight", + document.activeElement, + ); + fixture.detectChanges(); + + expect(calendar.value).not.toEqual(calendar.activeDate); + expect(calendar.viewDate.getMonth()).toEqual(calendar.activeDate.getMonth()); + expect(calendar.activeDate.getDate()).toEqual(1); + expect(document.activeElement).toBe(component.nativeElement); + + UIInteractions.triggerKeyDownEvtUponElem('Enter', component.nativeElement); + fixture.detectChanges(); + expect(calendar.value).toEqual(calendar.activeDate); + }); + + it('Should navigate to first enabled date when using "home" key.', () => { + const dateRangeDescriptors: DateRangeDescriptor[] = []; + const specificDates = [ + new Date(2017, 5, 1), + new Date(2017, 5, 2), + ]; + dateRangeDescriptors.push( + { type: DateRangeType.Specific, dateRange: specificDates }, + { type: DateRangeType.Weekends }, + ); + calendar.disabledDates = dateRangeDescriptors; + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem( + "Home", + component.nativeElement, + ); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(5); + }); + + it('Should navigate to last enabled date when using "end" key.', () => { + const dateRangeDescriptors: DateRangeDescriptor[] = []; + const rangeDates = [ + new Date(2017, 5, 28), + new Date(2017, 5, 30), + ]; + dateRangeDescriptors.push( + { type: DateRangeType.Between, dateRange: rangeDates }, + { + type: DateRangeType.Specific, + dateRange: [new Date(2017, 5, 27)], + }, + ); + calendar.disabledDates = dateRangeDescriptors; + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem( + "End", + component.nativeElement, + ); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(26); + }); + + it('Should navigate to first enabled date when using "arrow up" key.', () => { + const dateRangeDescriptors: DateRangeDescriptor[] = []; + const specificDates = [ + new Date(2017, 5, 23), + new Date(2017, 5, 16), + ]; + dateRangeDescriptors.push( + { type: DateRangeType.Specific, dateRange: specificDates }, + { type: DateRangeType.Weekends }, + ); + calendar.disabledDates = dateRangeDescriptors; + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem( + "End", + component.nativeElement, + ); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowUp", + document.activeElement, + ); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(9); + }); + + it('Should navigate to first enabled date when using "arrow down" key.', () => { + const dateRangeDescriptors: DateRangeDescriptor[] = []; + const specificDates = [ + new Date(2017, 5, 8), + new Date(2017, 5, 15), + ]; + dateRangeDescriptors.push( + { type: DateRangeType.Specific, dateRange: specificDates }, + { type: DateRangeType.Weekends }, + ); + calendar.disabledDates = dateRangeDescriptors; + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem( + "Home", + component.nativeElement, + ); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowDown", + document.activeElement, + ); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(22); + }); + + it('Should navigate to first enabled date when using "arrow left" key.', () => { + const dateRangeDescriptors: DateRangeDescriptor[] = []; + const rangeDates = [ + new Date(2017, 5, 2), + new Date(2017, 5, 29), + ]; + dateRangeDescriptors.push({ + type: DateRangeType.Between, + dateRange: rangeDates, + }); + calendar.disabledDates = dateRangeDescriptors; + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem( + "End", + component.nativeElement, + ); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowLeft", + component.nativeElement, + ); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(1); + }); + + it('Should navigate to first enabled date when using "arrow right" key.', () => { + const dateRangeDescriptors: DateRangeDescriptor[] = []; + const rangeDates = [ + new Date(2017, 5, 2), + new Date(2017, 5, 29), + ]; + dateRangeDescriptors.push({ + type: DateRangeType.Between, + dateRange: rangeDates, + }); + calendar.disabledDates = dateRangeDescriptors; + fixture.detectChanges(); + UIInteractions.triggerKeyDownEvtUponElem( + "Home", + component.nativeElement, + ); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowRight", + document.activeElement, + ); + fixture.detectChanges(); + expect(calendar.activeDate.getDate()).toEqual(30); + }); + + it('Should not select disabled dates when having "range" selection', () => { + const dateRangeDescriptors: DateRangeDescriptor[] = []; + const rangeDates = [ + new Date(2017, 5, 10), + new Date(2017, 5, 15), + ]; + dateRangeDescriptors.push({ + type: DateRangeType.Between, + dateRange: rangeDates, + }); + calendar.disabledDates = dateRangeDescriptors; + calendar.selection = "range"; + fixture.detectChanges(); + + // Select range using keyboard events + const fromDate = calendar.daysView.dates.filter( + (d) => + getDate(d).getTime() === new Date(2017, 5, 5).getTime(), + )[0]; + UIInteractions.simulateClickAndSelectEvent(fromDate.nativeElement.firstChild); + fixture.detectChanges(); + + const toDate = calendar.daysView.dates.filter( + (d) => + getDate(d).getTime() === + new Date(2017, 5, 20).getTime(), + )[0]; + UIInteractions.simulateClickAndSelectEvent(toDate.nativeElement.firstChild); + fixture.detectChanges(); + + // Check selection + const selectedDates = calendar.daysView.dates + .toArray() + .filter((d) => { + const dateTime = getDate(d).getTime(); + return ( + (dateTime >= new Date(2017, 5, 5).getTime() && + dateTime <= new Date(2017, 5, 9).getTime()) || + (dateTime >= new Date(2017, 5, 16).getTime() && + dateTime <= new Date(2017, 5, 20).getTime()) + ); + }); + + selectedDates.forEach((d) => { + expect(d.selected).toBe(true); + }); + + const notSelectedDates = calendar.daysView.dates + .toArray() + .filter((d) => { + const dateTime = getDate(d).getTime(); + return ( + dateTime >= new Date(2017, 5, 10).getTime() && + dateTime <= new Date(2017, 5, 15).getTime() + ); + }); + + notSelectedDates.forEach((d) => { + expect(d.selected).toBe(false); + }); + }); + }); + + describe("Disabled dates - ", () => { + it('Should disable date when using "After" date descriptor.', () => { + DateRangesPropertiesTester.testAfter( + DateRangesPropertiesTester.assignDisableDatesDescriptors, + DateRangesPropertiesTester.testDisabledDates, + ); + }); + + it('Should disable date when using "Before" date descriptor.', () => { + DateRangesPropertiesTester.testBefore( + DateRangesPropertiesTester.assignDisableDatesDescriptors, + DateRangesPropertiesTester.testDisabledDates, + ); + }); + + it('Should disable date when using "Between" date descriptor with min date declared first.', () => { + DateRangesPropertiesTester.testBetweenWithMinDateFirst( + DateRangesPropertiesTester.assignDisableDatesDescriptors, + DateRangesPropertiesTester.testDisabledDates, + ); + }); + + it('Should disable date when using "Between" date descriptor with max date declared first.', () => { + DateRangesPropertiesTester.testBetweenWithMaxDateFirst( + DateRangesPropertiesTester.assignDisableDatesDescriptors, + DateRangesPropertiesTester.testDisabledDates, + ); + }); + + it('Should disable date when using "Between" date descriptor with min and max the same.', () => { + DateRangesPropertiesTester.testBetweenWithMinMaxTheSame( + DateRangesPropertiesTester.assignDisableDatesDescriptors, + DateRangesPropertiesTester.testDisabledDates, + ); + }); + + it('Should disable date when using overlapping "Between" ranges.', () => { + DateRangesPropertiesTester.testOverlappingBetweens( + DateRangesPropertiesTester.assignDisableDatesDescriptors, + DateRangesPropertiesTester.testDisabledDates, + ); + }); + + it('Should disable date when using "Specific" date descriptor.', () => { + DateRangesPropertiesTester.testSpecific( + DateRangesPropertiesTester.assignDisableDatesDescriptors, + DateRangesPropertiesTester.testDisabledDates, + ); + }); + + it('Should disable date when using "Weekdays" date descriptor.', () => { + DateRangesPropertiesTester.testWeekdays( + DateRangesPropertiesTester.assignDisableDatesDescriptors, + DateRangesPropertiesTester.testDisabledDates, + ); + }); + + it('Should disable date when using "Weekends" date descriptor.', () => { + DateRangesPropertiesTester.testWeekends( + DateRangesPropertiesTester.assignDisableDatesDescriptors, + DateRangesPropertiesTester.testDisabledDates, + ); + }); + + it("Should disable dates when using multiple ranges.", () => { + DateRangesPropertiesTester.testMultipleRanges( + DateRangesPropertiesTester.assignDisableDatesDescriptors, + DateRangesPropertiesTester.testDisabledDates, + ); + }); + + it("Should be able to change disable dates runtime.", () => { + DateRangesPropertiesTester.testRangeUpdateRuntime( + DateRangesPropertiesTester.assignDisableDatesDescriptors, + DateRangesPropertiesTester.testDisabledDates, + ); + }); + + it('Should disable previous month with "before" date descriptor', () => { + DateRangesPropertiesTester.testPreviousMonthRange( + DateRangesPropertiesTester.assignDisableDatesDescriptors, + DateRangesPropertiesTester.testDisabledDates, + ); + }); + }); + + describe("Special dates - ", () => { + it('Should mark date as special when using "After" date descriptor.', () => { + DateRangesPropertiesTester.testAfter( + DateRangesPropertiesTester.assignSpecialDatesDescriptors, + DateRangesPropertiesTester.testSpecialDates, + ); + }); + + it('Should mark date as special when using "Before" date descriptor.', () => { + DateRangesPropertiesTester.testBefore( + DateRangesPropertiesTester.assignSpecialDatesDescriptors, + DateRangesPropertiesTester.testSpecialDates, + ); + }); + + it('Should mark date as special when using "Between" date descriptor with min date declared first.', () => { + DateRangesPropertiesTester.testBetweenWithMinDateFirst( + DateRangesPropertiesTester.assignSpecialDatesDescriptors, + DateRangesPropertiesTester.testSpecialDates, + ); + }); + + it('Should mark date as special when using "Between" date descriptor with max date declared first.', () => { + DateRangesPropertiesTester.testBetweenWithMaxDateFirst( + DateRangesPropertiesTester.assignSpecialDatesDescriptors, + DateRangesPropertiesTester.testSpecialDates, + ); + }); + + it('Should mark date as special when using "Between" date descriptor with min and max the same.', () => { + DateRangesPropertiesTester.testBetweenWithMinMaxTheSame( + DateRangesPropertiesTester.assignSpecialDatesDescriptors, + DateRangesPropertiesTester.testSpecialDates, + ); + }); + + it('Should mark date as special when using overlapping "Between" ranges.', () => { + DateRangesPropertiesTester.testOverlappingBetweens( + DateRangesPropertiesTester.assignSpecialDatesDescriptors, + DateRangesPropertiesTester.testSpecialDates, + ); + }); + + it('Should mark date as special when using "Specific" date descriptor.', () => { + DateRangesPropertiesTester.testSpecific( + DateRangesPropertiesTester.assignSpecialDatesDescriptors, + DateRangesPropertiesTester.testSpecialDates, + ); + }); + + it('Should mark date as special when using "Weekdays" date descriptor.', () => { + DateRangesPropertiesTester.testWeekdays( + DateRangesPropertiesTester.assignSpecialDatesDescriptors, + DateRangesPropertiesTester.testSpecialDates, + ); + }); + + it('Should mark date as special when using "Weekends" date descriptor.', () => { + DateRangesPropertiesTester.testWeekends( + DateRangesPropertiesTester.assignSpecialDatesDescriptors, + DateRangesPropertiesTester.testSpecialDates, + ); + }); + + it("Should mark dates as special when using multiple ranges.", () => { + DateRangesPropertiesTester.testMultipleRanges( + DateRangesPropertiesTester.assignSpecialDatesDescriptors, + DateRangesPropertiesTester.testSpecialDates, + ); + }); + + it('Should mark as special previous month with "before" date descriptor', () => { + DateRangesPropertiesTester.testPreviousMonthRange( + DateRangesPropertiesTester.assignSpecialDatesDescriptors, + DateRangesPropertiesTester.testSpecialDates, + ); + }); + + it("Should be able to change special dates runtime.", () => { + DateRangesPropertiesTester.testRangeUpdateRuntime( + DateRangesPropertiesTester.assignSpecialDatesDescriptors, + DateRangesPropertiesTester.testSpecialDates, + ); + }); + }); + + describe("Disabled special dates - ", () => { + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent( + IgxCalendarDisabledSpecialDatesComponent, + ); + fixture.detectChanges(); + calendar = fixture.componentInstance.calendar; + })); + + it("Should be able to set disabled and active dates as @Input", () => { + expect(calendar.specialDates).toEqual([ + { + type: DateRangeType.Between, + dateRange: [new Date(2017, 5, 1), new Date(2017, 5, 6)], + }, + ]); + expect(calendar.disabledDates).toEqual([ + { + type: DateRangeType.Between, + dateRange: [ + new Date(2017, 5, 23), + new Date(2017, 5, 29), + ], + }, + ]); + let specialDates = calendar.daysView.dates + .toArray() + .filter((d) => { + const dateTime = getDate(d).getTime(); + return ( + dateTime >= new Date(2017, 5, 1).getTime() && + dateTime <= new Date(2017, 5, 6).getTime() + ); + }); + + specialDates.forEach((d) => { + expect(d.isSpecial).toBe(true); + }); + + let disabledDates = calendar.daysView.dates + .toArray() + .filter((d) => { + const dateTime = getDate(d).getTime(); + return ( + dateTime >= new Date(2017, 5, 23).getTime() && + dateTime <= new Date(2017, 5, 29).getTime() + ); + }); + + disabledDates.forEach((d) => { + expect(d.isDisabled).toBe(true); + expect(d.isDisabledCSS).toBe(true); + }); + + // change Inputs + fixture.componentInstance.disabledDates = [ + { + type: DateRangeType.Before, + dateRange: [new Date(2017, 5, 10)], + }, + ]; + fixture.componentInstance.specialDates = [ + { + type: DateRangeType.After, + dateRange: [new Date(2017, 5, 19)], + }, + ]; + fixture.detectChanges(); + + expect(calendar.disabledDates).toEqual([ + { + type: DateRangeType.Before, + dateRange: [new Date(2017, 5, 10)], + }, + ]); + expect(calendar.specialDates).toEqual([ + { + type: DateRangeType.After, + dateRange: [new Date(2017, 5, 19)], + }, + ]); + + specialDates = calendar.daysView.dates.toArray().filter((d) => { + const dateTime = getDate(d).getTime(); + return dateTime >= new Date(2017, 5, 20).getTime(); + }); + + specialDates.forEach((d) => { + if (!d.isInactive) { + expect(d.isSpecial).toBe(true); + } + }); + + disabledDates = calendar.daysView.dates + .toArray() + .filter((d) => { + const dateTime = getDate(d).getTime(); + return dateTime <= new Date(2017, 5, 9).getTime(); + }); + + disabledDates.forEach((d) => { + expect(d.isDisabled).toBe(true); + expect(d.isDisabledCSS).toBe(true); + }); + }); + + it("Should not select date from model, if it is part of disabled dates", () => { + // Changed per WC alignment task #16131 - calendar should not block selection of dates through API/model + expect(calendar.value).toBeTruthy(); + }); + + it("Should not select date from model in range selection, if model passes null", () => { + calendar.selection = "range"; + fixture.componentInstance.model = null; + fixture.detectChanges(); + + expect((calendar.value as Date[]).length).toEqual(0); + }); + }); + + describe("Select and deselect dates - ", () => { + let ci: any; + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(IgxCalendarSampleComponent); + fixture.detectChanges(); + ci = fixture.componentInstance; + calendar = ci.calendar; + })); + + it('Deselect using API. Should deselect in "single" selection mode.', () => { + const date = calendar.viewDate; + calendar.selectDate(date); + fixture.detectChanges(); + expect(calendar.value).toEqual(date); + + calendar.deselectDate(date); + fixture.detectChanges(); + expect(calendar.value).toBeUndefined(); + + // Deselect with date different than selected date + calendar.selectDate(date); + fixture.detectChanges(); + expect(calendar.value).toEqual(date); + + + const dateToDeselect = new Date(date); + dateToDeselect.setDate(dateToDeselect.getDate() + 5); + + calendar.deselectDate(dateToDeselect); + fixture.detectChanges(); + expect(calendar.value).toEqual(date); + + // Select date with ISOString as value + const isoDate = new Date(2024, 10, 10).toISOString(); + calendar.selectDate(isoDate); + fixture.detectChanges(); + expect(calendar.value).toEqual(new Date(isoDate)); + + calendar.deselectDate(isoDate); + fixture.detectChanges(); + expect(calendar.value).toBeUndefined(); + + // Deselect with date different than selected date + calendar.selectDate(isoDate); + fixture.detectChanges(); + expect(calendar.value).toEqual(new Date(isoDate)); + + calendar.deselectDate(new Date(2024, 10, 11).toISOString()); + fixture.detectChanges(); + expect(calendar.value).toEqual(new Date(isoDate)); + }); + + it('Deselect using API. Should deselect in "multi" selection mode.', () => { + calendar.selection = "multi"; + fixture.detectChanges(); + + const year = calendar.viewDate.getFullYear(); + const month = calendar.viewDate.getMonth(); + const dates = []; + const datesCount = 10; + for (let i = 0; i < datesCount; i++) { + dates.push(new Date(year, month, i + 1)); + } + + fixture.detectChanges(); + calendar.selectDate(dates); + + fixture.detectChanges(); + const evenDates = dates.filter((d) => d.getDate() % 2 === 0); + calendar.deselectDate(evenDates); + + fixture.detectChanges(); + const oddDates = dates.filter((d) => d.getDate() % 2 !== 0); + let selectedDates: Date[] = calendar.value as Date[]; + + expect(selectedDates.length).toBe(5); + for (const selectedDate of selectedDates) { + const fdate = oddDates.some( + (date: Date) => + date.getTime() === selectedDate.getTime(), + ); + expect(fdate).toBeTruthy(); + } + + // Deselect with array not included in the selected dates + calendar.deselectDate(evenDates); + fixture.detectChanges(); + selectedDates = calendar.value as Date[]; + expect(selectedDates.length).toBe(5); + for (const selectedDate of selectedDates) { + const fdate = oddDates.some( + (date: Date) => + date.getTime() === selectedDate.getTime(), + ); + expect(fdate).toBeTruthy(); + } + + // Deselect one date included in the selected dates + calendar.deselectDate([oddDates[0]]); + fixture.detectChanges(); + selectedDates = calendar.value as Date[]; + expect(selectedDates.length).toBe(4); + for (const selectedDate of selectedDates) { + const fdate = oddDates.some( + (date: Date) => + date.getTime() === selectedDate.getTime(), + ); + expect(fdate).toBeTruthy(); + } + + // Deselect with array with all dates included in the selected dates + calendar.deselectDate(oddDates); + fixture.detectChanges(); + selectedDates = calendar.value as Date[]; + expect(selectedDates.length).toBe(0); + }); + + it('Deselect using API. Should deselect in "range" selection mode when period is not included in the selected dates', () => { + ci.model = []; + calendar.selection = "range"; + fixture.detectChanges(); + + const startDate = calendar.viewDate; + const endDate = new Date(calendar.viewDate); + endDate.setDate(endDate.getDate() + 5); + const startDateDeselect = new Date(startDate); + startDateDeselect.setDate(startDate.getDate() - 7); + const endDateDeselect = new Date(endDate); + endDateDeselect.setDate(startDate.getDate() - 3); + + calendar.selectDate([startDate, endDate]); + fixture.detectChanges(); + + let selectedDates: Date[] = calendar.value as Date[]; + expect(selectedDates.length).toBe(6); + expect(selectedDates[0]).toEqual(startDate); + expect(selectedDates[5]).toEqual(endDate); + + // Deselect with range which is not included in the selected dates + calendar.deselectDate([startDateDeselect, endDateDeselect]); + fixture.detectChanges(); + + selectedDates = calendar.value as Date[]; + expect(selectedDates.length).toBe(6); + expect(selectedDates[0]).toEqual(startDate); + expect(selectedDates[5]).toEqual(endDate); + }); + + it('Deselect using API. Should deselect in "range" selection mode when period is not included.', () => { + ci.model = []; + calendar.selection = "range"; + fixture.detectChanges(); + + const startDate = calendar.viewDate; + const endDate = new Date(calendar.viewDate); + endDate.setDate(endDate.getDate() + 5); + + calendar.selectDate([startDate, endDate]); + fixture.detectChanges(); + + let selectedDates: Date[] = calendar.value as Date[]; + expect(selectedDates.length).toBe(6); + expect(selectedDates[0]).toEqual(startDate); + expect(selectedDates[5]).toEqual(endDate); + + // Deselect with range is includes the selection + let startDateDeselect = new Date(startDate); + startDateDeselect.setDate(startDate.getDate() - 7); + let endDateDeselect = new Date(endDate); + endDateDeselect.setDate(endDate.getDate() + 5); + + calendar.deselectDate([startDateDeselect, endDateDeselect]); + fixture.detectChanges(); + + selectedDates = calendar.value as Date[]; + expect(selectedDates.length).toBe(0); + + // Deselect with range which includes the beginning of the selection + calendar.selectDate([startDate, endDate]); + fixture.detectChanges(); + + selectedDates = calendar.value as Date[]; + expect(selectedDates.length).toBe(6); + + startDateDeselect = new Date(startDate); + startDateDeselect.setDate(startDate.getDate() - 7); + endDateDeselect = new Date(endDate); + endDateDeselect.setDate(endDate.getDate() - 2); + calendar.deselectDate([startDateDeselect, endDateDeselect]); + fixture.detectChanges(); + + selectedDates = calendar.value as Date[]; + expect(selectedDates.length).toBe(0); + + // Deselect with range which includes the end of the selection + calendar.selectDate([startDate, endDate]); + fixture.detectChanges(); + + selectedDates = calendar.value as Date[]; + expect(selectedDates.length).toBe(6); + + startDateDeselect = new Date(startDate); + startDateDeselect.setDate(startDate.getDate() + 2); + endDateDeselect = new Date(endDate); + endDateDeselect.setDate(endDate.getDate() + 5); + calendar.deselectDate([startDateDeselect, endDateDeselect]); + fixture.detectChanges(); + + selectedDates = calendar.value as Date[]; + expect(selectedDates.length).toBe(0); + + // Deselect with range which is inside the selection + calendar.selectDate([startDate, endDate]); + fixture.detectChanges(); + + selectedDates = calendar.value as Date[]; + expect(selectedDates.length).toBe(6); + + startDateDeselect = new Date(startDate); + startDateDeselect.setDate(startDate.getDate() + 1); + endDateDeselect = new Date(endDate); + endDateDeselect.setDate(endDate.getDate() - 1); + calendar.deselectDate([startDateDeselect, endDateDeselect]); + fixture.detectChanges(); + + selectedDates = calendar.value as Date[]; + expect(selectedDates.length).toBe(0); + }); + + it('Deselect using API. Should deselect in "range" with array of dates.', () => { + ci.model = []; + calendar.selection = "range"; + fixture.detectChanges(); + + const startDate = calendar.viewDate; + const endDate = new Date(calendar.viewDate); + endDate.setDate(endDate.getDate() + 5); + const endDateDeselect = new Date(endDate); + endDateDeselect.setDate(endDate.getDate() - 1); + const midDateDeselect = new Date(endDate); + + // Deselect with range with only one date + calendar.selectDate([startDate, endDate]); + fixture.detectChanges(); + + let selectedDates = calendar.value as Date[]; + expect(selectedDates.length).toBe(6); + + let startDateDeselect = new Date(startDate); + startDateDeselect.setDate(startDate.getDate() - 5); + calendar.deselectDate([startDateDeselect]); + fixture.detectChanges(); + + selectedDates = calendar.value as Date[]; + expect(selectedDates.length).toBe(6); + + startDateDeselect = new Date(startDate); + startDateDeselect.setDate(startDate.getDate() + 2); + calendar.deselectDate([startDateDeselect]); + fixture.detectChanges(); + + selectedDates = calendar.value as Date[]; + expect(selectedDates.length).toBe(0); + + // Deselect with array of dates + calendar.selectDate([startDate, endDate]); + fixture.detectChanges(); + + selectedDates = calendar.value as Date[]; + expect(selectedDates.length).toBe(6); + + startDateDeselect = new Date(startDate); + startDateDeselect.setDate(startDate.getDate() - 10); + + midDateDeselect.setDate(endDate.getDate() + 3); + calendar.deselectDate([ + midDateDeselect, + endDateDeselect, + startDateDeselect, + ]); + fixture.detectChanges(); + + selectedDates = calendar.value as Date[]; + expect(selectedDates.length).toBe(0); + }); + + it('Deselect using API. Should deselect all in "single" mode.', () => { + const date = calendar.viewDate; + calendar.selectDate(date); + fixture.detectChanges(); + + let selectedDate = calendar.value; + expect(selectedDate).toEqual(date); + + calendar.deselectDate(); + fixture.detectChanges(); + + selectedDate = calendar.value; + expect(selectedDate).toBeUndefined(); + }); + + it('Deselect using API. Should deselect all in "multi" mode.', () => { + calendar.selection = "multi"; + fixture.detectChanges(); + + const year = calendar.viewDate.getFullYear(); + const month = calendar.viewDate.getMonth(); + const dates = []; + const datesCount = 10; + for (let i = 0; i < datesCount; i++) { + dates.push(new Date(year, month, i + 1)); + } + + calendar.selectDate(dates); + fixture.detectChanges(); + + calendar.deselectDate(); + fixture.detectChanges(); + + expect(calendar.value).toEqual([]); + }); + + it('Deselect using API. Should deselect all in "range" mode.', () => { + calendar.selection = "range"; + fixture.detectChanges(); + + const startDate = calendar.viewDate; + const endDate = new Date(calendar.viewDate); + endDate.setDate(endDate.getDate() + 7); + + calendar.selectDate(startDate); + fixture.detectChanges(); + + calendar.selectDate(endDate); + fixture.detectChanges(); + + calendar.deselectDate(); + fixture.detectChanges(); + + expect(calendar.value).toEqual([]); + }); + + it("Should extend the range when selecting a date outside of it with shift click.", () => { + calendar.selection = "range"; + fixture.detectChanges(); + + const days = calendar.daysView.dates.filter( + (day) => day.isCurrentMonth, + ); + const june11th = days[10]; + const june13th = days[12]; + const june15th = days[14]; + const june17th = days[16]; + + let calendarValue: Date[]; + + // range selection from June 13th to June 15th + UIInteractions.simulateClickAndSelectEvent(june13th.nativeElement.firstChild); + UIInteractions.simulateClickAndSelectEvent(june15th.nativeElement.firstChild); + fixture.detectChanges(); + + calendarValue = calendar.value as Date[]; + expect(calendarValue.length).toEqual(3); + expect(calendarValue[0].toDateString()).toMatch( + new Date(2017, 5, 13).toDateString(), + ); + expect( + calendarValue[calendarValue.length - 1].toDateString(), + ).toMatch(new Date(2017, 5, 15).toDateString()); + + // extend the range to June 17th (June 13th - June 17th) + UIInteractions.simulateClickAndSelectEvent(june17th.nativeElement.firstChild, true); + fixture.detectChanges(); + + calendarValue = calendar.value as Date[]; + expect(calendarValue.length).toEqual(5); + expect( + calendarValue[calendarValue.length - 1].toDateString(), + ).toMatch(new Date(2017, 5, 17).toDateString()); + + // extend the range to June 11th (June 11th - June 17th) + UIInteractions.simulateClickAndSelectEvent(june11th.nativeElement.firstChild, true); + fixture.detectChanges(); + + calendarValue = calendar.value as Date[]; + expect(calendarValue.length).toEqual(7); + expect(calendarValue[0].toDateString()).toMatch( + new Date(2017, 5, 11).toDateString(), + ); + }); + + it("Should shorten the range when selecting a date inside of it with shift click.", () => { + calendar.selection = "range"; + fixture.detectChanges(); + + const days = calendar.daysView.dates.filter( + (day) => day.isCurrentMonth, + ); + const june11th = days[10]; + const june13th = days[12]; + const june15th = days[14]; + const june17th = days[16]; + + let calendarValue: Date[]; + + // range selection from June 13th to June 17th + UIInteractions.simulateClickAndSelectEvent(june13th.nativeElement.firstChild); + UIInteractions.simulateClickAndSelectEvent(june17th.nativeElement.firstChild); + fixture.detectChanges(); + + calendarValue = calendar.value as Date[]; + expect(calendarValue.length).toEqual(5); + expect(calendarValue[0].toDateString()).toMatch( + new Date(2017, 5, 13).toDateString(), + ); + expect( + calendarValue[calendarValue.length - 1].toDateString(), + ).toMatch(new Date(2017, 5, 17).toDateString()); + + // shorten the range to June 15th (June 13th - June 15th) + UIInteractions.simulateClickAndSelectEvent(june15th.nativeElement.firstChild, true); + fixture.detectChanges(); + + calendarValue = calendar.value as Date[]; + expect(calendarValue.length).toEqual(3); + expect( + calendarValue[calendarValue.length - 1].toDateString(), + ).toMatch(new Date(2017, 5, 15).toDateString()); + + // extend the range to June 11th (June 11th - June 15th) + UIInteractions.simulateClickAndSelectEvent(june11th.nativeElement.firstChild, true); + fixture.detectChanges(); + + calendarValue = calendar.value as Date[]; + expect(calendarValue.length).toEqual(5); + expect(calendarValue[0].toDateString()).toMatch( + new Date(2017, 5, 11).toDateString(), + ); + expect( + calendarValue[calendarValue.length - 1].toDateString(), + ).toMatch(new Date(2017, 5, 15).toDateString()); + + // shorten the range to June 13th (June 13th - June 15th) + UIInteractions.simulateClickAndSelectEvent(june13th.nativeElement.firstChild, true); + fixture.detectChanges(); + + calendarValue = calendar.value as Date[]; + expect(calendarValue.length).toEqual(3); + expect(calendarValue[0].toDateString()).toMatch( + new Date(2017, 5, 13).toDateString(), + ); + }); + + it('Should select all dates from last selected to shift clicked date in "multi" mode.', () => { + calendar.selection = "multi"; + fixture.detectChanges(); + + const days = calendar.daysView.dates.filter( + (day) => day.isCurrentMonth, + ); + const june11th = days[10]; + const june13th = days[12]; + const june15th = days[14]; + const june17th = days[16]; + + // select June 13th and June 15th + UIInteractions.simulateClickAndSelectEvent(june13th.nativeElement.firstChild); + UIInteractions.simulateClickAndSelectEvent(june15th.nativeElement.firstChild); + fixture.detectChanges(); + expect((calendar.value as Date[]).length).toEqual(2); + + // select all dates from June 15th to June 17th + UIInteractions.simulateClickAndSelectEvent(june17th.nativeElement.firstChild, true); + fixture.detectChanges(); + expect((calendar.value as Date[]).length).toEqual(4); + + let expected = [ + new Date(2017, 5, 13), + new Date(2017, 5, 15), + new Date(2017, 5, 16), + new Date(2017, 5, 17), + ]; + + expect(JSON.stringify(calendar.value as Date[])).toEqual( + JSON.stringify(expected), + ); + + // select all dates from June 17th (last selected) to June 11th + UIInteractions.simulateClickAndSelectEvent(june11th.nativeElement.firstChild, true); + fixture.detectChanges(); + expect((calendar.value as Date[]).length).toEqual(7); + + const year = calendar.viewDate.getFullYear(); + const month = calendar.viewDate.getMonth(); + expected = []; + + for (let i = 11; i <= 17; i++) { + expected.push(new Date(year, month, i)); + } + + expect(JSON.stringify(calendar.value as Date[])).toEqual( + JSON.stringify(expected), + ); + }); + + it('Should deselect all dates from last clicked to shift clicked date in "multi" mode.', () => { + calendar.selection = "multi"; + fixture.detectChanges(); + + const days = calendar.daysView.dates.filter( + (day) => day.isCurrentMonth, + ); + const june11th = days[10]; + const june13th = days[12]; + const june15th = days[14]; + const june17th = days[16]; + + const year = calendar.viewDate.getFullYear(); + const month = calendar.viewDate.getMonth(); + const dates = []; + + for (let i = 11; i <= 17; i++) { + dates.push(new Date(year, month, i)); + } + + calendar.selectDate(dates); + fixture.detectChanges(); + expect((calendar.value as Date[]).length).toEqual(7); + + // deselect all dates from June 11th (last clicked) to June 13th + UIInteractions.simulateClickAndSelectEvent(june11th.nativeElement.firstChild); + UIInteractions.simulateClickAndSelectEvent(june13th.nativeElement.firstChild, true); + fixture.detectChanges(); + expect((calendar.value as Date[]).length).toEqual(5); + expect(JSON.stringify(calendar.value as Date[])).toEqual( + JSON.stringify(dates.slice(2)), + ); + + // deselect all dates from June 17th (last clicked) to June 15th + UIInteractions.simulateClickAndSelectEvent(june17th.nativeElement.firstChild); + UIInteractions.simulateClickAndSelectEvent(june15th.nativeElement.firstChild, true); + fixture.detectChanges(); + expect((calendar.value as Date[]).length).toEqual(3); + expect(JSON.stringify(calendar.value as Date[])).toEqual( + JSON.stringify(dates.slice(2, 5)), + ); + }); + }); + + describe("Advanced KB Navigation - ", () => { + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(IgxCalendarSampleComponent); + fixture.detectChanges(); + calendar = fixture.componentInstance.calendar; + dom = fixture.debugElement; + })); + + it("Should navigate to the previous/next month via KB.", fakeAsync(() => { + const prev = dom.queryAll( + By.css(HelperTestFunctions.CALENDAR_PREV_BUTTON_CSSCLASS), + )[0]; + prev.nativeElement.focus(); + + expect(prev.nativeElement).toBe(document.activeElement); + UIInteractions.triggerKeyDownEvtUponElem( + "Enter", + prev.nativeElement, + ); + fixture.detectChanges(); + tick(100); + + expect(calendar.viewDate.getMonth()).toEqual(4); + const next = dom.queryAll( + By.css(HelperTestFunctions.CALENDAR_NEXT_BUTTON_CSSCLASS), + )[0]; + next.nativeElement.focus(); + expect(next.nativeElement).toBe(document.activeElement); + + UIInteractions.triggerKeyDownEvtUponElem( + "Enter", + next.nativeElement, + ); + + fixture.detectChanges(); + tick(100); + UIInteractions.triggerKeyDownEvtUponElem( + "Enter", + next.nativeElement, + ); + tick(100); + fixture.detectChanges(); + + expect(calendar.viewDate.getMonth()).toEqual(6); + })); + + it("Should open years view, navigate through and select an year via KB.", fakeAsync(() => { + const year = dom.queryAll( + By.css(HelperTestFunctions.CALENDAR_DATE_CSSCLASS), + )[1]; + year.nativeElement.focus(); + + expect(year.nativeElement).toBe(document.activeElement); + + spyOn(calendar.activeViewChanged, "emit").and.callThrough(); + + UIInteractions.triggerKeyDownEvtUponElem( + "Enter", + document.activeElement, + ); + fixture.detectChanges(); + tick(); + + expect(calendar.activeViewChanged.emit).toHaveBeenCalledTimes( + 1, + ); + expect(calendar.activeViewChanged.emit).toHaveBeenCalledWith( + IgxCalendarView.Decade, + ); + + const years = dom.queryAll( + By.css(HelperTestFunctions.YEAR_CSSCLASS), + ); + let currentYear = dom.query( + By.css(HelperTestFunctions.CURRENT_YEAR_CSSCLASS), + ); + + expect(years.length).toEqual(15); + expect(currentYear.nativeElement.textContent.trim()).toMatch( + "2017", + ); + + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowRight", + document.activeElement, + ); + fixture.detectChanges(); + + currentYear = dom.query( + By.css(HelperTestFunctions.CURRENT_YEAR_CSSCLASS), + ); + expect(currentYear.nativeElement.textContent.trim()).toMatch( + "2018", + ); + + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowLeft", + document.activeElement, + ); + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowLeft", + document.activeElement, + ); + fixture.detectChanges(); + + currentYear = dom.query( + By.css(HelperTestFunctions.CURRENT_YEAR_CSSCLASS), + ); + expect(currentYear.nativeElement.textContent.trim()).toMatch( + "2016", + ); + + const previousValue = + fixture.componentInstance.calendar.viewDate; + spyOn(calendar.viewDateChanged, "emit").and.callThrough(); + + // Should open the year view + UIInteractions.triggerKeyDownEvtUponElem( + "Enter", + document.activeElement, + ); + + fixture.detectChanges(); + tick(); + + const eventArgs: IViewDateChangeEventArgs = { + previousValue, + currentValue: fixture.componentInstance.calendar.viewDate, + }; + expect(calendar.viewDateChanged.emit).toHaveBeenCalledTimes(1); + expect(calendar.viewDateChanged.emit).toHaveBeenCalledWith( + eventArgs, + ); + expect(calendar.viewDate.getFullYear()).toEqual(2016); + })); + + it("Should open months view, navigate through and select a month via KB.", fakeAsync(() => { + const month = dom.queryAll( + By.css(HelperTestFunctions.CALENDAR_DATE_CSSCLASS), + )[0]; + month.nativeElement.focus(); + spyOn(calendar.activeViewChanged, "emit").and.callThrough(); + + expect(month.nativeElement).toBe(document.activeElement); + + UIInteractions.triggerKeyDownEvtUponElem( + "Enter", + document.activeElement, + ); + fixture.detectChanges(); + tick(); + + expect(calendar.activeViewChanged.emit).toHaveBeenCalledTimes( + 1, + ); + expect(calendar.activeViewChanged.emit).toHaveBeenCalledWith( + IgxCalendarView.Year, + ); + + const months = dom.queryAll( + By.css(HelperTestFunctions.MONTH_CSSCLASS), + ); + const currentMonth = dom.query( + By.css(HelperTestFunctions.CURRENT_MONTH_CSSCLASS), + ); + + expect(months.length).toEqual(12); + expect(currentMonth.nativeElement.textContent.trim()).toMatch( + "June", + ); + + UIInteractions.triggerKeyDownEvtUponElem( + "Home", + document.activeElement, + ); + fixture.detectChanges(); + + expect(document.activeElement.textContent.trim()).toMatch( + "January", + ); + + UIInteractions.triggerKeyDownEvtUponElem( + "End", + document.activeElement, + ); + fixture.detectChanges(); + + expect(document.activeElement.textContent.trim()).toMatch( + "December", + ); + + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowLeft", + document.activeElement, + ); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowUp", + document.activeElement, + ); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowRight", + document.activeElement, + ); + fixture.detectChanges(); + + expect(document.activeElement.textContent.trim()).toMatch( + "September", + ); + + const previousValue = + fixture.componentInstance.calendar.viewDate; + spyOn(calendar.viewDateChanged, "emit").and.callThrough(); + + UIInteractions.triggerKeyDownEvtUponElem( + "Enter", + document.activeElement, + ); + fixture.detectChanges(); + + tick(); + + const eventArgs: IViewDateChangeEventArgs = { + previousValue, + currentValue: fixture.componentInstance.calendar.viewDate, + }; + expect(calendar.viewDateChanged.emit).toHaveBeenCalledTimes(1); + expect(calendar.viewDateChanged.emit).toHaveBeenCalledWith( + eventArgs, + ); + expect(calendar.viewDate.getMonth()).toEqual(8); + })); + + it('Should navigate to the first enabled date from the previous month when using "arrow up" key.', () => { + const dateRangeDescriptors: DateRangeDescriptor[] = []; + const specificDates = [ + new Date(2017, 4, 25), + new Date(2017, 4, 11), + ]; + + dateRangeDescriptors.push({ + type: DateRangeType.Specific, + dateRange: specificDates, + }); + + calendar.disabledDates = dateRangeDescriptors; + fixture.detectChanges(); + + const calendarNativeElement = dom.query( + By.css(HelperTestFunctions.CALENDAR_WRAPPER_CLASS), + ).nativeElement; + calendarNativeElement.focus(); + + UIInteractions.triggerKeyDownEvtUponElem( + "Home", + calendarNativeElement, + ); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowUp", + document.activeElement, + ); + fixture.detectChanges(); + + let date = new Date(2017, 4, 18); + expect(calendar.activeDate).toEqual(date); + + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowUp", + document.activeElement, + ); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowUp", + document.activeElement, + ); + fixture.detectChanges(); + + date = new Date(2017, 3, 27); + expect(calendar.activeDate).toEqual(date); + }); + + it('Should navigate to the first enabled date from the previous month when using "arrow left" key.', () => { + const dateRangeDescriptors: DateRangeDescriptor[] = []; + const specificDates = [ + new Date(2017, 4, 27), + new Date(2017, 4, 25), + ]; + + dateRangeDescriptors.push({ + type: DateRangeType.Specific, + dateRange: specificDates, + }); + + calendar.disabledDates = dateRangeDescriptors; + fixture.detectChanges(); + + const calendarNativeElement = dom.query( + By.css(HelperTestFunctions.CALENDAR_WRAPPER_CLASS), + ).nativeElement; + calendarNativeElement.focus(); + + UIInteractions.triggerKeyDownEvtUponElem( + "Home", + calendarNativeElement, + ); + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowLeft", + document.activeElement, + ); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowLeft", + document.activeElement, + ); + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowLeft", + document.activeElement, + ); + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowLeft", + document.activeElement, + ); + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowLeft", + document.activeElement, + ); + fixture.detectChanges(); + + let date = new Date(2017, 4, 26); + expect(calendar.activeDate).toEqual(date); + + UIInteractions.triggerKeyDownEvtUponElem( + "Home", + calendarNativeElement, + ); + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowLeft", + document.activeElement, + ); + fixture.detectChanges(); + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowLeft", + document.activeElement, + ); + fixture.detectChanges(); + + date = new Date(2017, 3, 29); + expect(date).toEqual(date); + }); + + it('Should navigate to the first enabled date from the next month when using "arrow down" key.', () => { + const dateRangeDescriptors: DateRangeDescriptor[] = []; + const specificDates = [ + new Date(2017, 6, 14), + new Date(2017, 6, 28), + ]; + + dateRangeDescriptors.push({ + type: DateRangeType.Specific, + dateRange: specificDates, + }); + + calendar.disabledDates = dateRangeDescriptors; + fixture.detectChanges(); + + const calendarNativeElement = dom.query( + By.css(HelperTestFunctions.CALENDAR_WRAPPER_CLASS), + ).nativeElement; + calendarNativeElement.focus(); + + UIInteractions.triggerKeyDownEvtUponElem( + "End", + calendarNativeElement, + ); + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowDown", + document.activeElement, + ); + fixture.detectChanges(); + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowDown", + document.activeElement, + ); + fixture.detectChanges(); + + let date = new Date(2017, 6, 21); + expect(calendar.activeDate).toEqual(date); + + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowDown", + document.activeElement, + ); + fixture.detectChanges(); + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowDown", + document.activeElement, + ); + fixture.detectChanges(); + + date = new Date(2017, 7, 11); + expect(calendar.activeDate).toEqual(date); + }); + + it('Should navigate to the first enabled date from the next month when using "arrow right" key.', () => { + const dateRangeDescriptors: DateRangeDescriptor[] = []; + const specificDates = [ + new Date(2017, 6, 9), + new Date(2017, 6, 10), + ]; + + dateRangeDescriptors.push({ + type: DateRangeType.Specific, + dateRange: specificDates, + }); + + calendar.disabledDates = dateRangeDescriptors; + + const calendarNativeElement = dom.query( + By.css(HelperTestFunctions.CALENDAR_WRAPPER_CLASS), + ).nativeElement; + calendarNativeElement.focus(); + + UIInteractions.triggerKeyDownEvtUponElem( + "End", + calendarNativeElement, + ); + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowDown", + document.activeElement, + ); + fixture.detectChanges(); + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowRight", + document.activeElement, + ); + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowRight", + document.activeElement, + ); + fixture.detectChanges(); + + let date = new Date(2017, 6, 11); + expect(calendar.activeDate).toEqual(date); + + calendar.activeDate = new Date(2017, 7, 5); + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowRight", + document.activeElement, + ); + fixture.detectChanges(); + + date = new Date(2017, 7, 6); + expect(calendar.activeDate).toEqual(date); + }); + + it("Should preserve the active date on (shift) pageup and pagedown.", () => { + const calendarNativeElement = dom.query( + By.css(HelperTestFunctions.CALENDAR_WRAPPER_CLASS), + ).nativeElement; + calendarNativeElement.focus(); + + UIInteractions.triggerKeyDownEvtUponElem( + "Home", + calendarNativeElement, + ); + + let date = new Date(2017, 5, 1); + expect(calendar.activeDate).toEqual(date); + + UIInteractions.triggerKeyDownEvtUponElem( + "PageUp", + document.activeElement, + ); + fixture.detectChanges(); + + date = new Date(2017, 4, 1); + expect(calendar.activeDate).toEqual(date); + + UIInteractions.triggerKeyDownEvtUponElem( + "PageDown", + document.activeElement, + ); + fixture.detectChanges(); + + date = new Date(2017, 5, 1); + expect(calendar.activeDate).toEqual(date); + + UIInteractions.triggerKeyDownEvtUponElem( + "PageUp", + document.activeElement, + true, + false, + true, + ); + fixture.detectChanges(); + + date = new Date(2016, 5, 1); + expect(calendar.activeDate).toEqual(date); + + UIInteractions.triggerKeyDownEvtUponElem( + "PageDown", + document.activeElement, + true, + false, + true, + ); + fixture.detectChanges(); + + date = new Date(2017, 5, 1); + expect(calendar.activeDate).toEqual(date); + }); + }); + + }); + + describe("Continuous month increment/decrement - ", () => { + let fixture: ComponentFixture; + let dom: DebugElement; + let calendar: IgxCalendarComponent; + let prevMonthBtn: HTMLElement; + let nextMonthBtn: HTMLElement; + + beforeEach(waitForAsync(() => { + TestBed.overrideProvider(LOCALE_ID, { useValue: "fr" }); + fixture = TestBed.createComponent(IgxCalendarSampleComponent); + fixture.detectChanges(); + dom = fixture.debugElement; + calendar = fixture.componentInstance.calendar; + + prevMonthBtn = dom.queryAll( + By.css(HelperTestFunctions.CALENDAR_PREV_BUTTON_CSSCLASS), + )[0].nativeElement; + nextMonthBtn = dom.queryAll( + By.css(HelperTestFunctions.CALENDAR_NEXT_BUTTON_CSSCLASS), + )[0].nativeElement; + })); + + it("Should increment/decrement months continuously on mousedown.", fakeAsync(() => { + expect(calendar.viewDate.getMonth()).toEqual(5); + // Have no idea how this test worked before, + // changing expectation based on my udnerstanding of that the test does + UIInteractions.simulateMouseEvent( + "mousedown", + prevMonthBtn, + 0, + 0, + ); + tick(); + UIInteractions.simulateMouseEvent( + "mouseup", + prevMonthBtn, + 0, + 0, + ); + fixture.detectChanges(); + expect(calendar.viewDate.getMonth()).toEqual(4); + + UIInteractions.simulateMouseEvent( + "mousedown", + nextMonthBtn, + 0, + 0, + ); + tick(); + UIInteractions.simulateMouseEvent( + "mouseup", + nextMonthBtn, + 0, + 0, + ); + fixture.detectChanges(); + flush(); + expect(calendar.viewDate.getMonth()).toEqual(5); + })); + + it("Should increment/decrement months continuously on enter keydown.", fakeAsync(() => { + expect(calendar.viewDate.getMonth()).toEqual(5); + + prevMonthBtn.focus(); + UIInteractions.triggerKeyDownEvtUponElem("Enter", prevMonthBtn); + tick(100); + fixture.detectChanges(); + expect(calendar.viewDate.getMonth()).toEqual(4); + + nextMonthBtn.focus(); + UIInteractions.triggerKeyDownEvtUponElem("Enter", nextMonthBtn); + tick(100); + fixture.detectChanges(); + expect(calendar.viewDate.getMonth()).toEqual(5); + })); + + it("Should prioritize weekStart property over locale.", fakeAsync(() => { + calendar.locale = "en"; + fixture.detectChanges(); + expect(calendar.weekStart).toEqual(1); + + calendar.weekStart = WEEKDAYS.FRIDAY; + expect(calendar.weekStart).toEqual(5); + + calendar.locale = "fr"; + fixture.detectChanges(); + + expect(calendar.weekStart).toEqual(5); + flush(); + })); + + it("Should respect passing invalid value for locale, then setting weekStart.", fakeAsync(() => { + calendar.locale = "frrr"; + calendar.weekStart = WEEKDAYS.FRIDAY; + fixture.detectChanges(); + + expect(calendar.locale).toEqual("fr"); + expect(calendar.weekStart).toEqual(WEEKDAYS.FRIDAY); + + flush(); + })); + + it("Should setting the global LOCALE_ID, Calendar must be displayed per current locale.", fakeAsync(() => { + // Verify locale is set respecting the globally LOCALE_ID provider + expect(calendar.locale).toEqual("fr"); + + // Verify Calendar is displayed per FR locale + fixture.componentInstance.viewDate = new Date(2022, 5, 23); + fixture.componentInstance.model = new Date(); + fixture.detectChanges(); + + const defaultOptions = { + day: "numeric", + month: "long", + weekday: "narrow", + year: "numeric", + }; + const defaultViews = { day: false, month: true, year: false }; + const bodyMonth = dom.query( + By.css(HelperTestFunctions.CALENDAR_DATE_CSSCLASS), + ); + const headerYear = dom.query( + By.css(HelperTestFunctions.CALENDAR_HEADER_YEAR_CSSCLASS), + ); + const bodyYear = dom.queryAll( + By.css(HelperTestFunctions.CALENDAR_DATE_CSSCLASS), + )[1]; + const headerWeekday = dom.queryAll( + By.css( + `${HelperTestFunctions.CALENDAR_HEADER_DATE_CSSCLASS} span`, + ), + )[0]; + const headerDate = dom.queryAll( + By.css( + `${HelperTestFunctions.CALENDAR_HEADER_DATE_CSSCLASS} span`, + ), + )[1]; + + calendar.selectDate(calendar.viewDate); + fixture.detectChanges(); + + expect(calendar.formatOptions).toEqual( + jasmine.objectContaining(defaultOptions), + ); + expect(calendar.formatViews).toEqual( + jasmine.objectContaining(defaultViews), + ); + expect(headerYear.nativeElement.textContent.trim()).toMatch( + "Select Date", + ); + expect(headerWeekday.nativeElement.textContent.trim()).toMatch( + "mer", + ); + expect(headerDate.nativeElement.textContent.trim()).toMatch( + "1 juin", + ); + expect(bodyYear.nativeElement.textContent.trim()).toMatch( + "2022", + ); + expect(bodyMonth.nativeElement.textContent.trim()).toMatch( + "juin", + ); + + flush(); + })); + }); + }); +}); + +@Component({ + template: ` + + `, + imports: [IgxCalendarComponent, FormsModule] +}) +export class IgxCalendarSampleComponent { + @ViewChild(IgxCalendarComponent, { static: true }) + public calendar: IgxCalendarComponent; + public model: Date | Date[] = new Date(2017, 5, 13); + public viewDate = new Date(2017, 5, 13); +} + +@Component({ + template: ` + + `, + imports: [IgxCalendarComponent] +}) +export class IgxCalendarRangeComponent { + @ViewChild(IgxCalendarComponent, { static: true }) + public calendar: IgxCalendarComponent; + public viewDate = new Date(2017, 5, 13); +} + +@Component({ + template: ` + + + `, + imports: [IgxCalendarComponent, FormsModule] +}) +export class IgxCalendarDisabledSpecialDatesComponent { + @ViewChild(IgxCalendarComponent, { static: true }) + public calendar: IgxCalendarComponent; + public model: Date | Date[] = new Date(2017, 5, 23); + public viewDate = new Date(2017, 5, 13); + public specialDates = [ + { + type: DateRangeType.Between, + dateRange: [new Date(2017, 5, 1), new Date(2017, 5, 6)], + }, + ]; + public disabledDates = [ + { + type: DateRangeType.Between, + dateRange: [new Date(2017, 5, 23), new Date(2017, 5, 29)], + }, + ]; +} + +@Component({ + template: ` `, + imports: [IgxCalendarComponent] +}) +export class IgxCalendarValueComponent { + @ViewChild(IgxCalendarComponent, { static: true }) + public calendar: IgxCalendarComponent; + public value = new Date(2020, 7, 13); +} + +class DateTester { + // tests whether a date is disabled or not + public static testDatesAvailability( + dates: IgxDayItemComponent[], + disabled: boolean, + ) { + for (const day of dates) { + expect(day.isDisabled).toBe( + disabled, + day.date.native.toLocaleDateString() + " is not disabled", + ); + expect(day.isDisabledCSS).toBe( + disabled, + day.date.native.toLocaleDateString() + + " is not with disabled style", + ); + } + } + + // tests whether a dates is special or not + public static testDatesSpeciality( + dates: IgxDayItemComponent[], + special: boolean, + ): void { + for (const date of dates) { + if (!date.isInactive) { + expect(date.isSpecial).toBe(special); + } + } + } +} + +type assignDateRangeDescriptors = ( + component: IgxCalendarComponent, + dateRangeDescriptors: DateRangeDescriptor[], +) => void; +type testDatesRange = ( + inRange: IgxDayItemComponent[], + outOfRange: IgxDayItemComponent[], +) => void; + +class DateRangesPropertiesTester { + public static testAfter( + assignFunc: assignDateRangeDescriptors, + testRangesFunc: testDatesRange, + ) { + const fixture = TestBed.createComponent(IgxCalendarSampleComponent); + const calendar = fixture.componentInstance.calendar; + const dateRangeDescriptors: DateRangeDescriptor[] = []; + const afterDate = new Date(2017, 5, 13); + const afterDateRangeDescriptor: DateRangeDescriptor = { + type: DateRangeType.After, + dateRange: [afterDate], + }; + dateRangeDescriptors.push(afterDateRangeDescriptor); + assignFunc(calendar, dateRangeDescriptors); + fixture.detectChanges(); + + const dates = calendar.daysView.dates.toArray(); + const inRangeDates = dates.filter( + (d) => getDate(d).getTime() > afterDate.getTime(), + ); + const outOfRangeDates = dates.filter( + (d) => getDate(d).getTime() <= afterDate.getTime(), + ); + testRangesFunc(inRangeDates, outOfRangeDates); + } + + public static testBefore( + assignFunc: assignDateRangeDescriptors, + testRangesFunc: testDatesRange, + ) { + const fixture = TestBed.createComponent(IgxCalendarSampleComponent); + const calendar = fixture.componentInstance.calendar; + const dateRangeDescriptors: DateRangeDescriptor[] = []; + const beforeDate = new Date(2017, 5, 13); + const beforeDateRangeDescriptor: DateRangeDescriptor = { + type: DateRangeType.Before, + dateRange: [beforeDate], + }; + dateRangeDescriptors.push(beforeDateRangeDescriptor); + assignFunc(calendar, dateRangeDescriptors); + fixture.detectChanges(); + + const dates = calendar.daysView.dates.toArray(); + const inRangeDates = dates.filter( + (d) => getDate(d).getTime() < beforeDate.getTime(), + ); + const outOfRangeDates = dates.filter( + (d) => getDate(d).getTime() >= beforeDate.getTime(), + ); + testRangesFunc(inRangeDates, outOfRangeDates); + } + + public static testBetweenWithMinDateFirst( + assignFunc: assignDateRangeDescriptors, + testRangesFunc: testDatesRange, + ) { + this.testBetween( + assignFunc, + testRangesFunc, + new Date(2017, 5, 13), + new Date(2017, 5, 20), + ); + } + + public static testBetweenWithMaxDateFirst( + assignFunc: assignDateRangeDescriptors, + testRangesFunc: testDatesRange, + ) { + this.testBetween( + assignFunc, + testRangesFunc, + new Date(2017, 5, 20), + new Date(2017, 5, 13), + ); + } + + public static testBetweenWithMinMaxTheSame( + assignFunc: assignDateRangeDescriptors, + testRangesFunc: testDatesRange, + ) { + this.testBetween( + assignFunc, + testRangesFunc, + new Date(2017, 5, 20), + new Date(2017, 5, 20), + ); + } + + public static testSpecific( + assignFunc: assignDateRangeDescriptors, + testRangesFunc: testDatesRange, + ) { + const fixture = TestBed.createComponent(IgxCalendarSampleComponent); + const calendar = fixture.componentInstance.calendar; + const dateRangeDescriptors: DateRangeDescriptor[] = []; + const specificDates = [ + new Date(2017, 5, 1), + new Date(2017, 5, 10), + new Date(2017, 5, 20), + new Date(2017, 5, 21), + new Date(2017, 5, 22), + ]; + dateRangeDescriptors.push({ + type: DateRangeType.Specific, + dateRange: specificDates, + }); + assignFunc(calendar, dateRangeDescriptors); + fixture.detectChanges(); + + const specificDatesSet = new Set(); + specificDates.map((d) => specificDatesSet.add(d.getTime())); + const dates = calendar.daysView.dates.toArray(); + const inRangeDates = dates.filter((d) => + specificDatesSet.has(getDate(d).getTime()), + ); + const outOfRangeDates = dates.filter( + (d) => !specificDatesSet.has(getDate(d).getTime()), + ); + testRangesFunc(inRangeDates, outOfRangeDates); + } + + public static testWeekdays( + assignFunc: assignDateRangeDescriptors, + testRangesFunc: testDatesRange, + ) { + const fixture = TestBed.createComponent(IgxCalendarSampleComponent); + const calendar = fixture.componentInstance.calendar; + const dateRangeDescriptors: DateRangeDescriptor[] = [ + { type: DateRangeType.Weekdays }, + ]; + assignFunc(calendar, dateRangeDescriptors); + fixture.detectChanges(); + + const dates = calendar.daysView.dates.toArray(); + const inRangeDates = dates.filter( + (d) => d.date.day !== 0 && d.date.day !== 6, + ); + const outOfRangeDates = dates.filter( + (d) => d.date.day === 0 || d.date.day === 6, + ); + testRangesFunc(inRangeDates, outOfRangeDates); + } + + public static testWeekends( + assignFunc: assignDateRangeDescriptors, + testRangesFunc: testDatesRange, + ) { + const fixture = TestBed.createComponent(IgxCalendarSampleComponent); + const calendar = fixture.componentInstance.calendar; + const dateRangeDescriptors: DateRangeDescriptor[] = [ + { type: DateRangeType.Weekends }, + ]; + assignFunc(calendar, dateRangeDescriptors); + fixture.detectChanges(); + + const dates = calendar.daysView.dates.toArray(); + const inRangeDates = dates.filter( + (d) => d.date.day === 0 || d.date.day === 6, + ); + const outOfRangeDates = dates.filter( + (d) => d.date.day !== 0 && d.date.day !== 6, + ); + testRangesFunc(inRangeDates, outOfRangeDates); + } + + public static testOverlappingBetweens( + assignFunc: assignDateRangeDescriptors, + testRangesFunc: testDatesRange, + ) { + const fixture = TestBed.createComponent(IgxCalendarSampleComponent); + const calendar = fixture.componentInstance.calendar; + const dateRangeDescriptors: DateRangeDescriptor[] = []; + const firstBetweenMin = new Date(2017, 5, 5); + const firstBetweenMax = new Date(2017, 5, 10); + const secondBetweenMin = new Date(2017, 5, 7); + const secondBetweenMax = new Date(2017, 5, 15); + dateRangeDescriptors.push({ + type: DateRangeType.Between, + dateRange: [firstBetweenMin, firstBetweenMax], + }); + dateRangeDescriptors.push({ + type: DateRangeType.Between, + dateRange: [secondBetweenMin, secondBetweenMax], + }); + assignFunc(calendar, dateRangeDescriptors); + fixture.detectChanges(); + + const dates = calendar.daysView.dates.toArray(); + const inRangeDates = dates.filter( + (d) => + getDate(d).getTime() >= firstBetweenMin.getTime() && + getDate(d).getTime() <= secondBetweenMax.getTime(), + ); + const outOfRangeDates = dates.filter( + (d) => + getDate(d).getTime() < firstBetweenMin.getTime() && + getDate(d).getTime() > secondBetweenMax.getTime(), + ); + testRangesFunc(inRangeDates, outOfRangeDates); + } + + public static testMultipleRanges( + assignFunc: assignDateRangeDescriptors, + testRangesFunc: testDatesRange, + ) { + const fixture = TestBed.createComponent(IgxCalendarSampleComponent); + const calendar = fixture.componentInstance.calendar; + const dateRangeDescriptors: DateRangeDescriptor[] = []; + dateRangeDescriptors.push({ + type: DateRangeType.Before, + dateRange: [new Date(2017, 5, 1)], + }); + dateRangeDescriptors.push({ + type: DateRangeType.After, + dateRange: [new Date(2017, 5, 29)], + }); + dateRangeDescriptors.push({ type: DateRangeType.Weekends }); + dateRangeDescriptors.push({ + type: DateRangeType.Between, + dateRange: [new Date(2017, 5, 1), new Date(2017, 5, 16)], + }); + dateRangeDescriptors.push({ + type: DateRangeType.Between, + dateRange: [new Date(2017, 5, 5), new Date(2017, 5, 28)], + }); + assignFunc(calendar, dateRangeDescriptors); + fixture.detectChanges(); + + const dates = calendar.daysView.dates.toArray(); + const enabledDateTime = new Date(2017, 5, 29).getTime(); + const inRangesDates = dates.filter( + (d) => getDate(d).getTime() !== enabledDateTime, + ); + const outOfRangeDates = dates.filter( + (d) => getDate(d).getTime() === enabledDateTime, + ); + testRangesFunc(inRangesDates, outOfRangeDates); + } + + public static testRangeUpdateRuntime( + assignFunc: assignDateRangeDescriptors, + testRangesFunc: testDatesRange, + ) { + const fixture = TestBed.createComponent(IgxCalendarSampleComponent); + const calendar = fixture.componentInstance.calendar; + const dateRangeDescriptors: DateRangeDescriptor[] = []; + const specificDate = new Date(2017, 5, 15); + dateRangeDescriptors.push({ + type: DateRangeType.Specific, + dateRange: [specificDate], + }); + assignFunc(calendar, dateRangeDescriptors); + fixture.detectChanges(); + + const dates = calendar.daysView.dates.toArray(); + let inRangesDates = dates.filter( + (d) => getDate(d).getTime() === specificDate.getTime(), + ); + let outOfRangesDates = dates.filter( + (d) => getDate(d).getTime() !== specificDate.getTime(), + ); + testRangesFunc(inRangesDates, outOfRangesDates); + + const newSpecificDate = new Date(2017, 5, 16); + const newDateRangeDescriptors: DateRangeDescriptor[] = []; + newDateRangeDescriptors.push({ + type: DateRangeType.Specific, + dateRange: [newSpecificDate], + }); + assignFunc(calendar, newDateRangeDescriptors); + fixture.detectChanges(); + + inRangesDates = dates.filter( + (d) => getDate(d).getTime() === newSpecificDate.getTime(), + ); + outOfRangesDates = dates.filter( + (d) => getDate(d).getTime() !== newSpecificDate.getTime(), + ); + testRangesFunc(inRangesDates, outOfRangesDates); + } + + public static testPreviousMonthRange( + assignFunc: assignDateRangeDescriptors, + testRangesFunc: testDatesRange, + ) { + const fixture = TestBed.createComponent(IgxCalendarSampleComponent); + const calendar = fixture.componentInstance.calendar; + const dateRangeDescriptors: DateRangeDescriptor[] = []; + const beforeDate = new Date(2017, 5, 13); + + dateRangeDescriptors.push({ + type: DateRangeType.Before, + dateRange: [beforeDate], + }); + assignFunc(calendar, dateRangeDescriptors); + fixture.detectChanges(); + + const debugEl = fixture.debugElement; + + const component = debugEl.query( + By.css(HelperTestFunctions.CALENDAR_WRAPPER_CLASS), + ); + + UIInteractions.triggerKeyDownEvtUponElem( + "PageUp", + component.nativeElement, + ); + + fixture.detectChanges(); + testRangesFunc(calendar.daysView.dates.toArray(), []); + } + + public static assignDisableDatesDescriptors( + component: IgxCalendarComponent, + dateRangeDescriptors: DateRangeDescriptor[], + ) { + component.disabledDates = dateRangeDescriptors; + } + + public static testDisabledDates( + inRange: IgxDayItemComponent[], + outOfRange: IgxDayItemComponent[], + ) { + DateTester.testDatesAvailability(inRange, true); + DateTester.testDatesAvailability(outOfRange, false); + } + + public static assignSpecialDatesDescriptors( + component: IgxCalendarComponent, + dateRangeDescriptors: DateRangeDescriptor[], + ) { + component.specialDates = dateRangeDescriptors; + } + + public static testSpecialDates( + inRange: IgxDayItemComponent[], + outOfRange: IgxDayItemComponent[], + ) { + DateTester.testDatesSpeciality(inRange, true); + DateTester.testDatesSpeciality(outOfRange, false); + } + + private static testBetween( + assignFunc: assignDateRangeDescriptors, + testRangesFunc: testDatesRange, + firstDate: Date, + secondDate: Date, + ) { + const fixture = TestBed.createComponent(IgxCalendarSampleComponent); + const calendar = fixture.componentInstance.calendar; + const dateRangeDescriptors: DateRangeDescriptor[] = []; + const betweenMin = + firstDate.getTime() > secondDate.getTime() ? secondDate : firstDate; + const betweenMax = + firstDate.getTime() > secondDate.getTime() ? firstDate : secondDate; + dateRangeDescriptors.push({ + type: DateRangeType.Between, + dateRange: [betweenMax, betweenMin], + }); + assignFunc(calendar, dateRangeDescriptors); + fixture.detectChanges(); + + const dates = calendar.daysView.dates.toArray(); + const inRangeDates = dates.filter( + (d) => + getDate(d).getTime() >= betweenMin.getTime() && + getDate(d).getTime() <= betweenMax.getTime(), + ); + const outOfRangeDates = dates.filter( + (d) => + getDate(d).getTime() < betweenMin.getTime() && + getDate(d).getTime() > betweenMax.getTime(), + ); + testRangesFunc(inRangeDates, outOfRangeDates); + } +} + +const getDate = ({ date: day }: IgxDayItemComponent) => { + return new Date(day.year, day.month, day.date); +}; diff --git a/projects/igniteui-angular/calendar/src/calendar/calendar.component.ts b/projects/igniteui-angular/calendar/src/calendar/calendar.component.ts new file mode 100644 index 00000000000..5e3afd219ab --- /dev/null +++ b/projects/igniteui-angular/calendar/src/calendar/calendar.component.ts @@ -0,0 +1,1111 @@ +import { + Component, + ContentChild, + forwardRef, + HostBinding, + Input, + ViewChild, + ElementRef, + AfterViewInit, + ViewChildren, + QueryList, + OnDestroy, + booleanAttribute, + HostListener, +} from '@angular/core'; +import { NgTemplateOutlet, DatePipe } from '@angular/common'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; + +import { + IgxCalendarHeaderTemplateDirective, + IgxCalendarHeaderTitleTemplateDirective, + IgxCalendarSubheaderTemplateDirective, + IgxCalendarScrollPageDirective, +} from './calendar.directives'; +import { IgxCalendarView, ScrollDirection } from './calendar'; +import { IgxMonthsViewComponent } from './months-view/months-view.component'; +import { IgxYearsViewComponent } from './years-view/years-view.component'; +import { IgxDaysViewComponent } from './days-view/days-view.component'; +import { interval } from 'rxjs'; +import { takeUntil, debounce, skipLast, switchMap } from 'rxjs/operators'; +import { IgxMonthViewSlotsCalendar, IgxGetViewDateCalendar } from './months-view.pipe'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxCalendarBaseDirective } from './calendar-base'; +import { KeyboardNavigationService } from './calendar.services'; +import { areSameMonth, CalendarDay, formatToParts, getClosestActiveDate, isDateInRanges } from 'igniteui-angular/core'; + +let NEXT_ID = 0; + +/** + * Calendar provides a way to display date information. + * + * @igxModule IgxCalendarModule + * + * @igxTheme igx-calendar-theme, igx-icon-theme + * + * @igxKeywords calendar, datepicker, schedule, date + * + * @igxGroup Scheduling + * + * @remarks + * The Ignite UI Calendar provides an easy way to display a calendar and allow users to select dates using single, multiple + * or range selection. + * + * @example: + * ```html + * + * ``` + */ +@Component({ + providers: [ + { + multi: true, + provide: NG_VALUE_ACCESSOR, + useExisting: IgxCalendarComponent, + }, + { + multi: false, + provide: KeyboardNavigationService, + }, + ], + selector: 'igx-calendar', + templateUrl: 'calendar.component.html', + imports: [NgTemplateOutlet, IgxCalendarScrollPageDirective, IgxIconComponent, IgxDaysViewComponent, IgxMonthsViewComponent, IgxYearsViewComponent, DatePipe, IgxMonthViewSlotsCalendar, IgxGetViewDateCalendar] +}) +export class IgxCalendarComponent extends IgxCalendarBaseDirective implements AfterViewInit, OnDestroy { + /** + * @hidden + * @internal + */ + private _activeDescendant: number; + + /** + * @hidden + * @internal + */ + @ViewChild("wrapper") + public wrapper: ElementRef; + + /** + * Sets/gets the `id` of the calendar. + * + * @remarks + * If not set, the `id` will have value `"igx-calendar-0"`. + * + * @example + * ```html + * + * ``` + * @memberof IgxCalendarComponent + */ + @HostBinding('attr.id') + @Input() + public id = `igx-calendar-${ NEXT_ID++ }`; + + /** + * Sets/gets whether the calendar has header. + * Default value is `true`. + * + * @example + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public hasHeader = true; + + /** + * Sets/gets whether the calendar header will be in vertical position. + * Default value is `false`. + * + * @example + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public vertical = false; + + @Input() + public orientation: 'horizontal' | 'vertical' = 'horizontal'; + + @Input() + public headerOrientation: 'horizontal' | 'vertical' = 'horizontal'; + + /** + * Sets/gets the number of month views displayed. + * Default value is `1`. + * + * @example + * ```html + * + * ``` + */ + @Input() + public get monthsViewNumber() { + return this._monthsViewNumber; + } + + public set monthsViewNumber(val: number) { + if (val < 1) { + return; + } + + this._monthsViewNumber = val; + } + + /** + * Show/hide week numbers + * + * @example + * ```html + * + * `` + */ + @Input({ transform: booleanAttribute }) + public showWeekNumbers = false; + + /** + * The default css class applied to the component. + * + * @hidden + * @internal + */ + @HostBinding('class.igx-calendar--vertical') + public get styleVerticalClass(): boolean { + return this.headerOrientation === 'vertical'; + } + + /** + * The default css class applied to the component. + * + * @hidden + * @internal + */ + @HostBinding('class.igx-calendar') + public styleClass = true; + + /** + * Month button, that displays the months view. + * + * @hidden + * @internal + */ + @ViewChildren('monthsBtn') + public monthsBtns: QueryList; + + /** + * ViewChild that represents the decade view. + * + * @hidden + * @internal + */ + @ViewChild('decade', { read: IgxYearsViewComponent }) + public dacadeView: IgxYearsViewComponent; + + /** + * ViewChild that represents the months view. + * + * @hidden + * @internal + */ + @ViewChild('months', { read: IgxMonthsViewComponent }) + public monthsView: IgxMonthsViewComponent; + + /** + * ViewChild that represents the days view. + * + * @hidden + * @internal + */ + @ViewChild('days', { read: IgxDaysViewComponent }) + public daysView: IgxDaysViewComponent; + + /** + * ViewChildrenden representing all of the rendered days views. + * + * @hidden + * @internal + */ + @ViewChildren('days', { read: IgxDaysViewComponent }) + public monthViews: QueryList; + + /** + * Button for previous month. + * + * @hidden + * @internal + */ + @ViewChild('prevPageBtn') + public prevPageBtn: ElementRef; + + /** + * Button for next month. + * + * @hidden + * @internal + */ + @ViewChild('nextPageBtn') + public nextPageBtn: ElementRef; + + /** + * Denote if the year view is active. + * + * @hidden + * @internal + */ + public get isYearView(): boolean { + return this.activeView === IgxCalendarView.Year; + } + + /** + * Gets the header template. + * + * @example + * ```typescript + * let headerTitleTemplate = this.calendar.headerTitleTeamplate; + * ``` + * @memberof IgxCalendarComponent + */ + public get headerTitleTemplate(): any { + if (this.headerTitleTemplateDirective) { + return this.headerTitleTemplateDirective.template; + } + return null; + } + + /** + * Sets the header template. + * + * @example + * ```html + * + * ``` + * @memberof IgxCalendarComponent + */ + public set headerTitleTemplate(directive: any) { + this.headerTitleTemplateDirective = directive; + } + + /** + * Gets the header template. + * + * @example + * ```typescript + * let headerTemplate = this.calendar.headerTeamplate; + * ``` + * @memberof IgxCalendarComponent + */ + public get headerTemplate(): any { + if (this.headerTemplateDirective) { + return this.headerTemplateDirective.template; + } + return null; + } + + /** + * Sets the header template. + * + * @example + * ```html + * + * ``` + * @memberof IgxCalendarComponent + */ + public set headerTemplate(directive: any) { + this.headerTemplateDirective = directive; + } + + /** + * Gets the subheader template. + * + * @example + * ```typescript + * let subheaderTemplate = this.calendar.subheaderTemplate; + * ``` + */ + public get subheaderTemplate(): any { + if (this.subheaderTemplateDirective) { + return this.subheaderTemplateDirective.template; + } + return null; + } + + /** + * Sets the subheader template. + * + * @example + * ```html + * + * ``` + * @memberof IgxCalendarComponent + */ + public set subheaderTemplate(directive: any) { + this.subheaderTemplateDirective = directive; + } + + /** + * Gets the context for the template marked with the `igxCalendarHeader` directive. + * + * @example + * ```typescript + * let headerContext = this.calendar.headerContext; + * ``` + */ + public get headerContext() { + return this.generateContext(this.headerDate); + } + + /** + * Gets the context for the template marked with either `igxCalendarSubHeaderMonth` + * or `igxCalendarSubHeaderYear` directive. + * + * @example + * ```typescript + * let context = this.calendar.context; + * ``` + */ + public get context() { + const date: Date = this.viewDate; + return this.generateContext(date); + } + + /** + * Date displayed in header + * + * @hidden + * @internal + */ + public get headerDate(): Date { + return this.selectedDates?.at(0) ?? new Date(); + } + + /** + * @hidden + * @internal + */ + @ContentChild(forwardRef(() => IgxCalendarHeaderTemplateDirective), { read: IgxCalendarHeaderTemplateDirective, static: true }) + private headerTemplateDirective: IgxCalendarHeaderTemplateDirective; + + /** + * @hidden + * @internal + */ + @ContentChild(forwardRef(() => IgxCalendarHeaderTitleTemplateDirective), { read: IgxCalendarHeaderTitleTemplateDirective, static: true }) + private headerTitleTemplateDirective: IgxCalendarHeaderTitleTemplateDirective; + + /** + * @hidden + * @internal + */ + @ContentChild(forwardRef(() => IgxCalendarSubheaderTemplateDirective), { read: IgxCalendarSubheaderTemplateDirective, static: true }) + private subheaderTemplateDirective: IgxCalendarSubheaderTemplateDirective; + + /** + * @hidden + * @internal + */ + public activeDate = CalendarDay.today.native; + + /** + * @hidden + * @internal + */ + protected previewRangeDate: Date; + + /** + * Used to apply the active date when the calendar view is changed + * + * @hidden + * @internal + */ + public nextDate: Date; + + /** + * Denote if the calendar view was changed with the keyboard + * + * @hidden + * @internal + */ + public isKeydownTrigger = false; + + /** + * @hidden + * @internal + */ + private _monthsViewNumber = 1; + + @HostListener('mousedown', ['$event']) + protected onMouseDown(event: MouseEvent) { + event.stopPropagation(); + if (this.platform.isBrowser && this.wrapper?.nativeElement) { + this.wrapper.nativeElement.focus(); + } + } + + private _showActiveDay: boolean; + + /** + * @hidden + * @internal + */ + protected set showActiveDay(value: boolean) { + this._showActiveDay = value; + this.cdr.detectChanges(); + } + + protected get showActiveDay() { + return this._showActiveDay; + } + + protected get activeDescendant(): number { + if (this.activeView === 'month') { + return this.activeDate.getTime(); + } + + return this._activeDescendant ?? this.viewDate.getTime(); + } + + protected set activeDescendant(date: Date) { + this._activeDescendant = date.getTime(); + } + + public ngAfterViewInit() { + this.keyboardNavigation + .attachKeyboardHandlers(this.wrapper, this) + .set("ArrowUp", this.onArrowUp) + .set("ArrowDown", this.onArrowDown) + .set("ArrowLeft", this.onArrowLeft) + .set("ArrowRight", this.onArrowRight) + .set("Enter", this.onEnter) + .set(" ", this.onEnter) + .set("Home", this.onHome) + .set("End", this.onEnd) + .set("PageUp", this.handlePageUp) + .set("PageDown", this.handlePageDown); + + this.startPageScroll$.pipe( + takeUntil(this.stopPageScroll$), + switchMap(() => this.scrollPage$.pipe( + skipLast(1), + debounce(() => interval(300)), + takeUntil(this.stopPageScroll$) + ))).subscribe(() => { + switch (this.pageScrollDirection) { + case ScrollDirection.PREV: + this.previousPage(); + break; + case ScrollDirection.NEXT: + this.nextPage(); + break; + case ScrollDirection.NONE: + default: + break; + } + }); + + this.activeView$.subscribe((view) => { + this.activeViewChanged.emit(view); + + this.viewDateChanged.emit({ + previousValue: this.previousViewDate, + currentValue: this.viewDate + }); + }); + } + + protected onWrapperFocus(_event: FocusEvent) { + this.showActiveDay = true; + this.monthViews.forEach(view => view.changePreviewRange(this.activeDate)); + } + + protected onWrapperBlur(_event: FocusEvent) { + this.showActiveDay = false; + this.monthViews.forEach(view => view.clearPreviewRange()); + this._onTouchedCallback(); + } + + private handleArrowKeydown(event: KeyboardEvent, delta: number) { + event.preventDefault(); + + const date = getClosestActiveDate( + CalendarDay.from(this.activeDate), + delta, + this.disabledDates, + ); + + this.activeDate = date.native; + + const dates = this.viewDates; + const isDateInView = dates.some(d => d.date.equalTo(this.activeDate)); + this.monthViews.forEach(view => view.clearPreviewRange()); + + if (!isDateInView) { + delta > 0 ? this.nextPage(true) : this.previousPage(true); + } + } + + private handlePageUpDown(event: KeyboardEvent, delta: number) { + event.preventDefault(); + + const dir = delta > 0 ? ScrollDirection.NEXT : ScrollDirection.PREV; + + if (this.activeView === IgxCalendarView.Month && event.shiftKey) { + this.viewDate = CalendarDay.from(this.viewDate).add('year', delta).native; + this.resetActiveDate(this.viewDate); + this.cdr.detectChanges(); + } else { + this.changePage(false, dir); + } + } + + private handlePageUp(event: KeyboardEvent) { + this.handlePageUpDown(event, -1); + } + + private handlePageDown(event: KeyboardEvent) { + this.handlePageUpDown(event, 1); + } + + private onArrowUp(event: KeyboardEvent) { + if (this.activeView === IgxCalendarView.Month) { + this.handleArrowKeydown(event, -7); + this.cdr.detectChanges(); + } + + if (this.activeView === IgxCalendarView.Year) { + this.monthsView.onKeydownArrowUp(event); + } + + if (this.activeView === IgxCalendarView.Decade) { + this.dacadeView.onKeydownArrowUp(event); + } + } + + private onArrowDown(event: KeyboardEvent) { + if (this.activeView === IgxCalendarView.Month) { + this.handleArrowKeydown(event, 7); + this.cdr.detectChanges(); + } + + if (this.activeView === IgxCalendarView.Year) { + this.monthsView.onKeydownArrowDown(event); + } + + if (this.activeView === IgxCalendarView.Decade) { + this.dacadeView.onKeydownArrowDown(event); + } + } + + private onArrowLeft(event: KeyboardEvent) { + if (this.activeView === IgxCalendarView.Month) { + this.handleArrowKeydown(event, -1); + this.cdr.detectChanges(); + } + + if (this.activeView === IgxCalendarView.Year) { + this.monthsView.onKeydownArrowLeft(event); + } + + if (this.activeView === IgxCalendarView.Decade) { + this.dacadeView.onKeydownArrowLeft(event); + } + } + + private onArrowRight(event: KeyboardEvent) { + if (this.activeView === IgxCalendarView.Month) { + this.handleArrowKeydown(event, 1); + this.cdr.detectChanges(); + } + + if (this.activeView === IgxCalendarView.Year) { + this.monthsView.onKeydownArrowRight(event); + } + + if (this.activeView === IgxCalendarView.Decade) { + this.dacadeView.onKeydownArrowRight(event); + } + } + + private onEnter(event: KeyboardEvent) { + if (this.activeView === IgxCalendarView.Month) { + this.handleDateSelection(this.activeDate); + this.cdr.detectChanges(); + } + + if (this.activeView === IgxCalendarView.Year) { + this.monthsView.onKeydownEnter(event); + } + + if (this.activeView === IgxCalendarView.Decade) { + this.dacadeView.onKeydownEnter(event); + } + + this.monthViews.forEach(view => view.clearPreviewRange()); + } + + private onHome(event: KeyboardEvent) { + if (this.activeView === IgxCalendarView.Month) { + const dates = this.monthViews.toArray() + .flatMap((view) => view.dates.toArray()) + .filter((d) => d.isCurrentMonth && d.isFocusable); + + this.activeDate = dates.at(0).date.native; + this.cdr.detectChanges(); + } + + if (this.activeView === IgxCalendarView.Year) { + this.monthsView.onKeydownHome(event); + } + + if (this.activeView === IgxCalendarView.Decade) { + this.dacadeView.onKeydownHome(event); + } + } + + private onEnd(event: KeyboardEvent) { + if (this.activeView === IgxCalendarView.Month) { + const dates = this.monthViews.toArray() + .flatMap((view) => view.dates.toArray()) + .filter((d) => d.isCurrentMonth && d.isFocusable); + + this.activeDate = dates.at(-1).date.native; + this.cdr.detectChanges(); + } + + if (this.activeView === IgxCalendarView.Year) { + this.monthsView.onKeydownEnd(event); + } + + if (this.activeView === IgxCalendarView.Decade) { + this.dacadeView.onKeydownEnd(event); + } + } + + /** + * Returns the locale representation of the month in the month view if enabled, + * otherwise returns the default `Date.getMonth()` value. + * + * @hidden + * @internal + */ + public formattedMonth(value: Date): string { + if (this.formatViews.month) { + return this.formatterMonth.format(value); + } + + return `${ value.getMonth() }`; + } + + /** + * Change to previous page + * + * @hidden + * @internal + */ + public previousPage(isKeydownTrigger = false) { + if (isKeydownTrigger && this.pageScrollDirection === ScrollDirection.NEXT) { + return; + } + + this.changePage(isKeydownTrigger, ScrollDirection.PREV); + } + + /** + * Change to next page + * + * @hidden + * @internal + */ + public nextPage(isKeydownTrigger = false) { + if (isKeydownTrigger && this.pageScrollDirection === ScrollDirection.PREV) { + return; + } + + this.changePage(isKeydownTrigger, ScrollDirection.NEXT); + } + + /** + * Changes the current page + * + * @hidden + * @internal + */ + protected changePage(isKeydownTrigger = false, direction: ScrollDirection) { + this.previousViewDate = this.viewDate; + this.isKeydownTrigger = isKeydownTrigger; + + switch (this.activeView) { + case "month": + if (direction === ScrollDirection.PREV) { + this.viewDate = CalendarDay.from(this.viewDate).add('month', -1).native; + } + + if (direction === ScrollDirection.NEXT) { + this.viewDate = CalendarDay.from(this.viewDate).add('month', 1).native; + } + + this.viewDateChanged.emit({ + previousValue: this.previousViewDate, + currentValue: this.viewDate + }); + + break; + + case "year": + if (direction === ScrollDirection.PREV) { + this.viewDate = CalendarDay.from(this.viewDate).add('year', -1).native; + } + + if (direction === ScrollDirection.NEXT) { + this.viewDate = CalendarDay.from(this.viewDate).add('year', 1).native; + } + + break; + + case "decade": + if (direction === ScrollDirection.PREV) { + this.viewDate = CalendarDay.from(this.viewDate).add('year', -15).native; + } + + if (direction === ScrollDirection.NEXT) { + this.viewDate = CalendarDay.from(this.viewDate).add('year', 15).native; + } + + break; + } + + // XXX: Why only when it's not triggered by keyboard? + if (!this.isKeydownTrigger) this.resetActiveDate(this.viewDate); + } + + /** + * Continious navigation through the previous pages + * + * @hidden + * @internal + */ + public startPrevPageScroll = (isKeydownTrigger = false) => { + this.startPageScroll$.next(); + this.pageScrollDirection = ScrollDirection.PREV; + this.previousPage(isKeydownTrigger); + } + + /** + * Continious navigation through the next pages + * + * @hidden + * @internal + */ + public startNextPageScroll = (isKeydownTrigger = false) => { + this.startPageScroll$.next(); + this.pageScrollDirection = ScrollDirection.NEXT; + this.nextPage(isKeydownTrigger); + } + + /** + * Stop continuous navigation + * + * @hidden + * @internal + */ + public stopPageScroll = (event: KeyboardEvent) => { + event.stopPropagation(); + + this.stopPageScroll$.next(true); + this.stopPageScroll$.complete(); + + if (this.platform.isActivationKey(event)) { + this.resetActiveDate(this.viewDate); + } + + this.pageScrollDirection = ScrollDirection.NONE; + } + + /** + * @hidden + * @internal + */ + public onActiveViewDecade(event: MouseEvent, date: Date, activeViewIdx: number): void { + event.preventDefault(); + + super.activeViewDecade(activeViewIdx); + this.viewDate = date; + } + + /** + * @hidden + * @internal + */ + public onActiveViewDecadeKB(date: Date, event: KeyboardEvent, activeViewIdx: number) { + super.activeViewDecadeKB(event, activeViewIdx); + + if (this.platform.isActivationKey(event)) { + this.viewDate = date; + if (this.platform.isBrowser && this.wrapper?.nativeElement) { + this.wrapper.nativeElement.focus(); + } + } + } + + /** + * @hidden + * @internal + */ + public onYearsViewClick(event: MouseEvent) { + if (!this.platform.isBrowser) { + return; + } + + const path = event.composed ? event.composedPath() : [event.target]; + const years = this.dacadeView.viewItems.toArray(); + const validTarget = years.some(year => path.includes(year.nativeElement)); + + if (validTarget) { + this.activeView = IgxCalendarView.Year; + } + } + + /** + * @hidden + * @internal + */ + public onYearsViewKeydown(event: KeyboardEvent) { + if (this.platform.isActivationKey(event)) { + this.activeView = IgxCalendarView.Year; + } + } + + /** + * @hidden + * @internal + */ + protected getFormattedDate(): { weekday: string; monthday: string } { + const date = this.headerDate; + const monthFormatter = new Intl.DateTimeFormat(this.locale, { month: 'short', day: 'numeric' }) + const dayFormatter = new Intl.DateTimeFormat(this.locale, { weekday: 'short' }) + + return { + monthday: monthFormatter.format(date), + weekday: dayFormatter.format(date), + }; + } + + /** + * @hidden + * @internal + */ + protected getFormattedRange(): { start: string; end: string } { + const dates = this.selectedDates as Date[]; + + return { + start: this.formatterRangeday.format(dates.at(0)), + end: this.formatterRangeday.format(dates.at(-1)) + }; + } + + /** + * @hidden + * @internal + */ + protected get viewDates() { + return this.monthViews.toArray() + .flatMap(view => view.dates.toArray()) + .filter(d => d.isCurrentMonth); + } + + /** + * Handles invoked on date selection + * + * @hidden + * @internal + */ + protected handleDateSelection(date: Date) { + const outOfRange = !this.viewDates.some(d => { + return d.date.equalTo(date) + }); + + if (outOfRange) { + this.viewDate = date; + } + + this.selectDate(date); + + // keep views in sync + this.monthViews.forEach((m) => { + m.shiftKey = this.shiftKey; + m.selectedDates = this.selectedDates; + m.cdr.markForCheck(); + }); + + if (this.selection !== 'single') { + this.selected.emit(this.selectedDates); + } else { + this.selected.emit(this.selectedDates.at(0)); + } + } + + /** + * @hidden + * @intenal + */ + public changeMonth(date: Date) { + this.previousViewDate = this.viewDate; + this.viewDate = CalendarDay.from(date).add('month', -this.activeViewIdx).native; + this.activeView = IgxCalendarView.Month; + this.resetActiveDate(date); + } + + /** + * @hidden + * @intenal + */ + public override changeYear(date: Date) { + this.previousViewDate = this.viewDate; + this.viewDate = CalendarDay.from(date).add('month', -this.activeViewIdx).native; + this.activeView = IgxCalendarView.Year; + } + + /** + * @hidden + * @intenal + */ + public updateYear(date: Date) { + this.previousViewDate = this.viewDate; + this.viewDate = CalendarDay.from(date).add('year', -this.activeViewIdx).native; + } + + public updateActiveDescendant(date: Date) { + this.activeDescendant = date; + } + + /** + * @hidden + * @internal + */ + public onActiveViewYear(event: MouseEvent, date: Date, activeViewIdx: number): void { + event.preventDefault(); + + this.activeView = IgxCalendarView.Year; + this.activeViewIdx = activeViewIdx; + this.viewDate = date; + } + + /** + * @hidden + * @internal + */ + public onActiveViewYearKB(date: Date, event: KeyboardEvent, activeViewIdx: number): void { + event.stopPropagation(); + + if (this.platform.isActivationKey(event)) { + event.preventDefault(); + this.activeView = IgxCalendarView.Year; + this.activeViewIdx = activeViewIdx; + this.viewDate = date; + + if (this.platform.isBrowser && this.wrapper?.nativeElement) { + this.wrapper.nativeElement.focus(); + } + } + } + + /** + * Deselects date(s) (based on the selection type). + * + * @example + * ```typescript + * this.calendar.deselectDate(new Date(`2018-06-12`)); + * ```` + */ + public override deselectDate(value?: Date | Date[] | string) { + super.deselectDate(value); + + this.monthViews.forEach((m) => { + m.selectedDates = this.selectedDates; + m.rangeStarted = false; + m.cdr.markForCheck(); + }); + + this._onChangeCallback(this.selectedDates); + } + + + /** + * Getter for the context object inside the calendar templates. + * + * @hidden + * @internal + */ + public getContext(i: number) { + const date = CalendarDay.from(this.viewDate).add('month', i).native; + return this.generateContext(date, i); + } + + /** + * @hidden + * @internal + */ + // TODO: See if this can be incorporated in the DaysView directly + public resetActiveDate(date: Date) { + const target = CalendarDay.from(this.activeDate).set({ + month: date.getMonth(), + year: date.getFullYear(), + }); + const outOfRange = + !areSameMonth(date, target) || + isDateInRanges(target, this.disabledDates); + + this.activeDate = outOfRange ? date : target.native; + } + + /** + * @hidden + * @internal + */ + public ngOnDestroy(): void { + this.keyboardNavigation.detachKeyboardHandlers(); + } + + /** + * @hidden + * @internal + */ + public getPrevMonth(date: Date): Date { + return CalendarDay.from(date).add('month', -1).native; + } + + /** + * @hidden + * @internal + */ + public getNextMonth(date: Date, viewIndex: number): Date { + return CalendarDay.from(date).add('month', viewIndex).native; + } + + /** + * Helper method building and returning the context object inside the calendar templates. + * + * @hidden + * @internal + */ + private generateContext(value: Date | Date[], i?: number) { + const construct = (date: Date, index: number) => ({ + index: index, + date, + ...formatToParts(date, this.locale, this.formatOptions, [ + "era", + "year", + "month", + "day", + "weekday", + ]), + }); + + const formatObject = Array.isArray(value) + ? value.map((date, index) => construct(date, index)) + : construct(value, i); + + return { $implicit: formatObject }; + } +} diff --git a/projects/igniteui-angular/calendar/src/calendar/calendar.directives.ts b/projects/igniteui-angular/calendar/src/calendar/calendar.directives.ts new file mode 100644 index 00000000000..93688d4dc74 --- /dev/null +++ b/projects/igniteui-angular/calendar/src/calendar/calendar.directives.ts @@ -0,0 +1,223 @@ +/** + * This file contains all the directives used by the @link IgxCalendarComponent. + * Except for the directives which are used for templating the calendar itself + * you should generally not use them directly. + * + * @preferred + */ +import { Directive, EventEmitter, HostBinding, HostListener, Input, InjectionToken, Output, TemplateRef, ElementRef, AfterViewInit, OnDestroy, NgZone, inject } from '@angular/core'; +import { fromEvent, Subject, interval } from 'rxjs'; +import { takeUntil, debounce, tap } from 'rxjs/operators'; +import { CalendarDay, PlatformUtil } from 'igniteui-angular/core'; + +export const IGX_CALENDAR_VIEW_ITEM = + new InjectionToken('IgxCalendarViewItem'); + +@Directive() +export abstract class IgxCalendarViewBaseDirective { + public elementRef = inject(ElementRef); + + @Input() + public value: Date; + + @Input() + public date: Date; + + @Input() + public showActive = false; + + @Output() + public itemSelection = new EventEmitter(); + + public get nativeElement() { + return this.elementRef.nativeElement; + } + + @HostListener('mousedown', ['$event']) + public onMouseDown(event: MouseEvent) { + event.preventDefault(); + this.itemSelection.emit(this.value); + } + + public abstract get isCurrent(): boolean; + public abstract get isSelected(): boolean; + public abstract get isActive(): boolean; +} + +/** + * @hidden + */ +@Directive({ + selector: '[igxCalendarYear]', + providers: [ + { provide: IGX_CALENDAR_VIEW_ITEM, useExisting: IgxCalendarYearDirective } + ], + exportAs: 'igxCalendarYear', + standalone: true +}) +export class IgxCalendarYearDirective extends IgxCalendarViewBaseDirective { + @HostBinding('class.igx-calendar-view__item--current') + public get isCurrent(): boolean { + return CalendarDay.today.year === this.value.getFullYear(); + } + + @HostBinding('class.igx-calendar-view__item--selected') + public get isSelected(): boolean { + return this.value.getFullYear() === this.date.getFullYear(); + } + + @HostBinding('class.igx-calendar-view__item--active') + public get isActive(): boolean { + return this.isSelected && this.showActive; + } +} + +@Directive({ + selector: '[igxCalendarMonth]', + providers: [ + { provide: IGX_CALENDAR_VIEW_ITEM, useExisting: IgxCalendarMonthDirective } + ], + exportAs: 'igxCalendarMonth', + standalone: true +}) +export class IgxCalendarMonthDirective extends IgxCalendarViewBaseDirective { + @HostBinding('class.igx-calendar-view__item--current') + public get isCurrent(): boolean { + const today = CalendarDay.today; + const date = CalendarDay.from(this.value); + return date.year === today.year && date.month === today.month; + } + + @HostBinding('class.igx-calendar-view__item--selected') + public get isSelected(): boolean { + return (this.value.getFullYear() === this.date.getFullYear() && + this.value.getMonth() === this.date.getMonth() + ); + } + + @HostBinding('class.igx-calendar-view__item--active') + public get isActive(): boolean { + return this.isSelected && this.showActive; + } +} + +/** + * @hidden + */ +@Directive({ + selector: '[igxCalendarHeaderTitle]', + standalone: true +}) +export class IgxCalendarHeaderTitleTemplateDirective { + public template = inject>(TemplateRef); +} + +/** + * @hidden + */ +@Directive({ + selector: '[igxCalendarHeader]', + standalone: true +}) +export class IgxCalendarHeaderTemplateDirective { + public template = inject>(TemplateRef); +} + +/** + * @hidden + */ +@Directive({ + selector: '[igxCalendarSubheader]', + standalone: true +}) +export class IgxCalendarSubheaderTemplateDirective { + public template = inject>(TemplateRef); +} + +/** + * @hidden + */ +@Directive({ + selector: '[igxCalendarScrollPage]', + standalone: true +}) +export class IgxCalendarScrollPageDirective implements AfterViewInit, OnDestroy { + private element = inject(ElementRef); + private zone = inject(NgZone); + protected platform = inject(PlatformUtil); + + /** + * A callback function to be invoked when increment/decrement page is triggered. + * + * @hidden + */ + @Input() + public startScroll: (keydown?: boolean) => void; + + /** + * A callback function to be invoked when increment/decrement page stops. + * + * @hidden + */ + @Input() + public stopScroll: (event: any) => void; + + /** + * @hidden + */ + private destroy$ = new Subject(); + + /** + * @hidden + */ + @HostListener('mousedown', ['$event']) + public onMouseDown(event: MouseEvent) { + event.preventDefault(); + this.startScroll(); + } + + /** + * @hidden + */ + @HostListener('mouseup', ['$event']) + public onMouseUp(event: MouseEvent) { + this.stopScroll(event); + } + + /** + * @hidden + */ + public ngAfterViewInit() { + fromEvent(this.element.nativeElement, 'keyup').pipe( + debounce(() => interval(100)), + takeUntil(this.destroy$) + ).subscribe((event: KeyboardEvent) => { + this.stopScroll(event); + }); + + this.zone.runOutsideAngular(() => { + fromEvent(this.element.nativeElement, 'keydown').pipe( + tap((event: KeyboardEvent) => { + if (this.platform.isActivationKey(event)) { + event.preventDefault(); + event.stopPropagation(); + } + }), + debounce(() => interval(100)), + takeUntil(this.destroy$) + ).subscribe((event: KeyboardEvent) => { + if (this.platform.isActivationKey(event)) { + this.zone.run(() => this.startScroll(true)); + } + }); + }); + } + + /** + * @hidden + */ + public ngOnDestroy() { + this.destroy$.next(true); + this.destroy$.complete(); + } +} diff --git a/projects/igniteui-angular/calendar/src/calendar/calendar.module.ts b/projects/igniteui-angular/calendar/src/calendar/calendar.module.ts new file mode 100644 index 00000000000..88df25cfaf9 --- /dev/null +++ b/projects/igniteui-angular/calendar/src/calendar/calendar.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { IGX_CALENDAR_DIRECTIVES } from './public_api'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + ...IGX_CALENDAR_DIRECTIVES + ], + exports: [ + ...IGX_CALENDAR_DIRECTIVES + ] +}) +export class IgxCalendarModule { } diff --git a/projects/igniteui-angular/calendar/src/calendar/calendar.services.ts b/projects/igniteui-angular/calendar/src/calendar/calendar.services.ts new file mode 100644 index 00000000000..1873e35cc63 --- /dev/null +++ b/projects/igniteui-angular/calendar/src/calendar/calendar.services.ts @@ -0,0 +1,56 @@ +import { Injectable, ElementRef, NgZone, inject } from "@angular/core"; +import { EventManager } from "@angular/platform-browser"; +import { PlatformUtil } from 'igniteui-angular/core'; + +@Injectable() +export class KeyboardNavigationService { + private eventManager = inject(EventManager); + private ngZone = inject(NgZone); + + private keyHandlers = new Map void>(); + private eventUnsubscribeFn: Function | null = null; + private platform = inject(PlatformUtil); + + public attachKeyboardHandlers(elementRef: ElementRef, context: any) { + if (!this.platform.isBrowser) { + return this; + } + + this.detachKeyboardHandlers(); // Clean up any existing listeners + + this.ngZone.runOutsideAngular(() => { + this.eventUnsubscribeFn = this.eventManager.addEventListener( + elementRef.nativeElement, + 'keydown', + (event: KeyboardEvent) => { + const handler = this.keyHandlers.get(event.key); + + if (handler) { + this.ngZone.run(handler.bind(context, event)); + } + } + ); + }); + + return this; + } + + public detachKeyboardHandlers() { + if (this.eventUnsubscribeFn) { + this.eventUnsubscribeFn(); + this.eventUnsubscribeFn = null; + } + + this.keyHandlers.clear(); + } + + public set(key : string, handler: (event: KeyboardEvent) => void) { + this.keyHandlers.set(key, handler); + return this; + } + + public unset(key: string) { + this.keyHandlers.delete(key); + return this; + } +} diff --git a/projects/igniteui-angular/calendar/src/calendar/calendar.ts b/projects/igniteui-angular/calendar/src/calendar/calendar.ts new file mode 100644 index 00000000000..68b7a5e5e44 --- /dev/null +++ b/projects/igniteui-angular/calendar/src/calendar/calendar.ts @@ -0,0 +1,141 @@ + +/** + * Sets the selection type - single, multi or range. + */ +export const CalendarSelection = { + SINGLE: 'single', + MULTI: 'multi', + RANGE: 'range' +} as const; +export type CalendarSelection = (typeof CalendarSelection)[keyof typeof CalendarSelection]; + +export const enum ScrollDirection { + PREV = 'prev', + NEXT = 'next', + NONE = 'none' +} + +export interface IViewDateChangeEventArgs { + previousValue: Date; + currentValue: Date; +} + +export const IgxCalendarView = { + Month: 'month', + Year: 'year', + Decade: 'decade' +} as const; + +/** + * Determines the Calendar active view - days, months or years. + */ +export type IgxCalendarView = (typeof IgxCalendarView)[keyof typeof IgxCalendarView]; + +const MDAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; +const FEBRUARY = 1; + +export const range = (start = 0, stop: number, step = 1) => { + const res = []; + const cur = (stop === undefined) ? 0 : start; + const max = (stop === undefined) ? start : stop; + for (let i = cur; step < 0 ? i > max : i < max; i += step) { + res.push(i); + } + return res; +}; + +/** + * Returns true for leap years, false for non-leap years. + * + * @export + * @param year + * @returns + */ +export const isLeap = (year: number): boolean => (year % 4 === 0) && ((year % 100 !== 0) || (year % 400 === 0)); + +export const weekDay = (year: number, month: number, day: number): number => new Date(year, month, day).getDay(); + +/** + * Return weekday and number of days for year, month. + * + * @export + * @param year + * @param month + * @returns + */ +export const monthRange = (year: number, month: number): number[] => { + if ((month < 0) || (month > 11)) { + throw new Error('Invalid month specified'); + } + const day = weekDay(year, month, 1); + let nDays = MDAYS[month]; + if ((month === FEBRUARY) && (isLeap(year))) { + nDays++; + } + return [day, nDays]; +}; + +export interface IFormattedParts { + value: string; + literal?: string; + combined: string; +} + +export interface IFormattingOptions { + day?: 'numeric' | '2-digit'; + month?: 'numeric' | '2-digit' | 'long' | 'short' | 'narrow'; + weekday?: 'long' | 'short' | 'narrow'; + year?: 'numeric' | '2-digit'; +} + +export interface IFormattingViews { + day?: boolean; + month?: boolean; + year?: boolean; +} + +export class Calendar { + public timedelta(date: Date, interval: string, units: number): Date { + const ret = new Date(date); + + const checkRollover = () => { + if (ret.getDate() !== date.getDate()) { + ret.setDate(0); + } + }; + + switch (interval.toLowerCase()) { + case 'year': + ret.setFullYear(ret.getFullYear() + units); + checkRollover(); + break; + case 'quarter': + ret.setMonth(ret.getMonth() + 3 * units); + checkRollover(); + break; + case 'month': + ret.setMonth(ret.getMonth() + units); + checkRollover(); + break; + case 'week': + ret.setDate(ret.getDate() + 7 * units); + break; + case 'day': + ret.setDate(ret.getDate() + units); + break; + case 'hour': + ret.setTime(ret.getTime() + units * 3600000); + break; + case 'minute': + ret.setTime(ret.getTime() + units * 60000); + break; + case 'second': + ret.setTime(ret.getTime() + units * 1000); + break; + default: + throw new Error('Invalid interval specifier'); + } + + return ret; + } +} diff --git a/projects/igniteui-angular/calendar/src/calendar/common/calendar-view.directive.ts b/projects/igniteui-angular/calendar/src/calendar/common/calendar-view.directive.ts new file mode 100644 index 00000000000..b86d2f8613b --- /dev/null +++ b/projects/igniteui-angular/calendar/src/calendar/common/calendar-view.directive.ts @@ -0,0 +1,340 @@ +import { + Output, + EventEmitter, + Input, + HostListener, + ViewChildren, + QueryList, + booleanAttribute, + Directive, + HostBinding, + InjectionToken, + inject, +} from "@angular/core"; +import { noop } from "rxjs"; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; +import { + IGX_CALENDAR_VIEW_ITEM, + IgxCalendarMonthDirective, + IgxCalendarYearDirective, +} from "../calendar.directives"; +import { CalendarDay, DateRangeType, DayInterval, getNextActiveDate, isDate, isDateInRanges } from 'igniteui-angular/core'; + + +export enum IgxCalendarNavDirection { + NEXT = 1, + PREV = -1, +} + +export const DAY_INTERVAL_TOKEN = new InjectionToken( + "DAY_INTERVAL", +); + +@Directive({ + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: IgxCalendarViewDirective, + multi: true, + }, + ], + standalone: true, +}) +export abstract class IgxCalendarViewDirective implements ControlValueAccessor { + protected dayInterval = inject(DAY_INTERVAL_TOKEN); + + @HostBinding("attr.role") + @Input() + public role = 'grid'; + + @HostBinding("attr.tabIndex") + @Input() + public tabIndex = 0; + + @HostBinding('attr.aria-activeDescendant') + protected get activeDescendant() { + if (this.tabIndex === -1) return; + + return this.date.getTime(); + } + + /** + * Gets/sets whether the view should be rendered + * according to the locale and format, if any. + */ + @Input({ transform: booleanAttribute }) + public formatView: boolean; + + /** + * Applies styles to the active item on view focus. + */ + @Input({ transform: booleanAttribute }) + public showActive = false; + + /** + * Emits an event when a selection is made in the view. + * Provides reference the `date` property in the component. + * @memberof IgxCalendarViewDirective + */ + @Output() + public selected = new EventEmitter(); + + /** + * Emits an event when a page changes in the view. + * Provides reference the `date` property in the component. + * @memberof IgxCalendarViewDirective + * @hidden @internal + */ + @Output() + public pageChanged = new EventEmitter(); + + /** + * Emits an event when the active date has changed. + * @memberof IgxCalendarViewDirective + * @hidden @internal + */ + @Output() + public activeDateChanged = new EventEmitter(); + + /** + * @hidden + * @internal + */ + @ViewChildren(IGX_CALENDAR_VIEW_ITEM, { read: IGX_CALENDAR_VIEW_ITEM }) + public viewItems: QueryList< + IgxCalendarMonthDirective | IgxCalendarYearDirective + >; + + /** + * @hidden + */ + protected _formatter: Intl.DateTimeFormat; + + /** + * @hidden + */ + protected _locale = "en"; + + /** + * @hidden + * @internal + */ + private _date = new Date(); + + /** + * @hidden + */ + protected _onTouchedCallback: () => void = noop; + + /** + * @hidden + */ + protected _onChangeCallback: (_: Date) => void = noop; + + /** + * Gets/sets the selected date of the view. + * By default it's the current date. + * ```typescript + * let date = this.view.date; + * ``` + * + * @memberof IgxYearsViewComponent + */ + @Input() + public set date(value: Date) { + if (!isDate(value)) return; + + this._date = value; + } + + public get date() { + return this._date; + } + + /** + * Gets the `locale` of the view. + * Default value is `"en"`. + * ```typescript + * let locale = this.view.locale; + * ``` + * + * @memberof IgxCalendarViewDirective + */ + @Input() + public get locale(): string { + return this._locale; + } + + /** + * Sets the `locale` of the view. + * Expects a valid BCP 47 language tag. + * Default value is `"en"`. + * + * @memberof IgxCalendarViewDirective + */ + public set locale(value: string) { + this._locale = value; + this.initFormatter(); + } + + constructor() { + this.initFormatter(); + } + + /** + * @hidden + */ + @HostListener("keydown.arrowdown", ["$event"]) + public onKeydownArrowDown(event: KeyboardEvent) { + this.navigateTo(event, IgxCalendarNavDirection.NEXT, 3); + } + + /** + * @hidden + */ + @HostListener("keydown.arrowup", ["$event"]) + public onKeydownArrowUp(event: KeyboardEvent) { + this.navigateTo(event, IgxCalendarNavDirection.PREV, 3); + } + + /** + * @hidden + */ + @HostListener("keydown.arrowright", ["$event"]) + public onKeydownArrowRight(event: KeyboardEvent) { + this.navigateTo(event, IgxCalendarNavDirection.NEXT, 1); + } + + /** + * @hidden + */ + @HostListener("keydown.arrowleft", ["$event"]) + public onKeydownArrowLeft(event: KeyboardEvent) { + this.navigateTo(event, IgxCalendarNavDirection.PREV, 1); + } + + /** + * @hidden + */ + @HostListener("keydown.home", ["$event"]) + public onKeydownHome(event: KeyboardEvent) { + event.preventDefault(); + event.stopPropagation(); + + this.date = this.range.at(0); + this.activeDateChanged.emit(this.date); + } + + /** + * @hidden + */ + @HostListener("keydown.end", ["$event"]) + public onKeydownEnd(event: KeyboardEvent) { + event.preventDefault(); + event.stopPropagation(); + + this.date = this.range.at(-1); + this.activeDateChanged.emit(this.date); + } + + /** + * @hidden + */ + @HostListener("keydown.enter", ["$event"]) + public onKeydownEnter(event: KeyboardEvent) { + event.stopPropagation(); + + this.selected.emit(this.date); + this._onChangeCallback(this.date); + } + + /** + * @hidden + */ + @HostListener("focus") + protected handleFocus() { + this.showActive = true; + } + + /** + * @hidden + */ + @HostListener("blur") + protected handleBlur() { + this.showActive = false; + } + + /** + * @hidden + */ + public selectDate(value: Date) { + this.date = value; + + this.selected.emit(this.date); + this._onChangeCallback(this.date); + } + + /** + * @hidden + */ + public registerOnChange(fn: (v: Date) => void) { + this._onChangeCallback = fn; + } + + /** + * @hidden + */ + public registerOnTouched(fn: () => void) { + this._onTouchedCallback = fn; + } + + /** + * @hidden + */ + public writeValue(value: Date) { + if (value) { + this.date = value; + } + } + + /** + * @hidden + */ + protected navigateTo( + event: KeyboardEvent, + direction: IgxCalendarNavDirection, + delta: number, + ) { + event.preventDefault(); + event.stopPropagation(); + + const date = getNextActiveDate( + CalendarDay.from(this.date).add(this.dayInterval, direction * delta), + [], + ); + + const outOfRange = !isDateInRanges(date, [ + { + type: DateRangeType.Between, + dateRange: [this.range.at(0), this.range.at(-1)], + }, + ]); + + if (outOfRange) { + this.pageChanged.emit(date.native); + } + + this.date = date.native; + this.activeDateChanged.emit(this.date); + } + + /** + * @hidden + */ + protected abstract initFormatter(): void; + + /** + * @hidden + */ + protected abstract get range(): Date[]; +} diff --git a/projects/igniteui-angular/calendar/src/calendar/days-view/day-item.component.html b/projects/igniteui-angular/calendar/src/calendar/days-view/day-item.component.html new file mode 100644 index 00000000000..ea05eb65eb2 --- /dev/null +++ b/projects/igniteui-angular/calendar/src/calendar/days-view/day-item.component.html @@ -0,0 +1,9 @@ + diff --git a/projects/igniteui-angular/calendar/src/calendar/days-view/day-item.component.ts b/projects/igniteui-angular/calendar/src/calendar/days-view/day-item.component.ts new file mode 100644 index 00000000000..69c708164a9 --- /dev/null +++ b/projects/igniteui-angular/calendar/src/calendar/days-view/day-item.component.ts @@ -0,0 +1,194 @@ +import { Component, Input, Output, EventEmitter, HostBinding, ElementRef, booleanAttribute, ChangeDetectionStrategy, inject } from '@angular/core'; +import { CalendarSelection } from '../calendar'; +import { areSameMonth, CalendarDay, DateRangeDescriptor, isDateInRanges, isNextMonth, isPreviousMonth } from 'igniteui-angular/core'; + +/** + * @hidden + */ +@Component({ + selector: 'igx-day-item', + templateUrl: 'day-item.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true +}) +export class IgxDayItemComponent { + private elementRef = inject(ElementRef); + + @Input() + public date: CalendarDay; + + @Input() + public viewDate: Date; + + @Input() + public selection: string; + + /** + * Returns boolean indicating if the day is selected + * + */ + @Input() + public get selected(): any { + return this._selected; + } + + /** + * Selects the day + */ + public set selected(value: any) { + this._selected = value; + } + + @Input() + public disabledDates: DateRangeDescriptor[]; + + @Input() + public specialDates: DateRangeDescriptor[]; + + @Input({ transform: booleanAttribute }) + public hideOutsideDays = false; + + @Input({ transform: booleanAttribute }) + @HostBinding('class.igx-days-view__date--last') + public isLastInRange = false; + + @Input({ transform: booleanAttribute }) + @HostBinding('class.igx-days-view__date--first') + public isFirstInRange = false; + + @Input({ transform: booleanAttribute }) + public isWithinRange = false; + + @Input({ transform: booleanAttribute }) + public isWithinPreviewRange = false; + + @Input({ transform: booleanAttribute }) + public hideLeadingDays = false; + + @Input({ transform: booleanAttribute }) + public hideTrailingDays = false; + + private get hideLeading() { + return this.hideLeadingDays && this.isPreviousMonth; + } + + private get hideTrailing() { + return this.hideTrailingDays && this.isNextMonth; + } + + @Output() + public dateSelection = new EventEmitter(); + + @Output() + public mouseEnter = new EventEmitter(); + + @Output() + public mouseLeave = new EventEmitter(); + + @Output() + public mouseDown = new EventEmitter(); + + public get isCurrentMonth(): boolean { + return areSameMonth(this.date, this.viewDate); + } + + public get isPreviousMonth(): boolean { + return isPreviousMonth(this.date, this.viewDate); + } + + public get isNextMonth(): boolean { + return isNextMonth(this.date, this.viewDate); + } + + public get nativeElement() { + return this.elementRef.nativeElement; + } + + @Input({ transform: booleanAttribute }) + @HostBinding('class.igx-days-view__date--active') + public isActive = false; + + @HostBinding('class.igx-days-view__date--selected') + public get isSelectedCSS(): boolean { + const selectable = + !this.isInactive || this.isWithinPreviewRange || + (this.isWithinRange && this.selection === "range"); + return !this.isDisabled && selectable && this.selected; + } + + @HostBinding('class.igx-days-view__date--inactive') + public get isInactive(): boolean { + return !this.isCurrentMonth; + } + + @HostBinding('class.igx-days-view__date--hidden') + public get isHidden(): boolean { + return (this.hideLeading || this.hideTrailing) && this.isInactive; + } + + @HostBinding('class.igx-days-view__date--current') + public get isToday(): boolean { + return !this.isInactive && this.date.equalTo(CalendarDay.today); + } + + @HostBinding('class.igx-days-view__date--weekend') + public get isWeekend(): boolean { + return this.date.weekend; + } + + public get isDisabled(): boolean { + if (!this.disabledDates) { + return false; + } + + return isDateInRanges(this.date, this.disabledDates); + } + + public get isFocusable(): boolean { + return this.isCurrentMonth && !this.isHidden && !this.isDisabled; + } + + protected onMouseEnter() { + this.mouseEnter.emit(); + } + + protected onMouseLeave() { + this.mouseLeave.emit(); + } + + protected onMouseDown(event: MouseEvent) { + event.preventDefault(); + this.mouseDown.emit(); + } + + @HostBinding('class.igx-days-view__date--range') + public get isWithinRangeCSS(): boolean { + return !this.isSingleSelection && this.isWithinRange; + } + + @HostBinding('class.igx-days-view__date--range-preview') + public get isWithinPreviewRangeCSS(): boolean { + return !this.isSingleSelection && this.isWithinPreviewRange; + } + + @HostBinding('class.igx-days-view__date--special') + public get isSpecial(): boolean { + if (!this.specialDates) { + return false; + } + + return !this.isInactive && isDateInRanges(this.date, this.specialDates); + } + + @HostBinding('class.igx-days-view__date--disabled') + public get isDisabledCSS(): boolean { + return this.isHidden || this.isDisabled; + } + + @HostBinding('class.igx-days-view__date--single') + public get isSingleSelection(): boolean { + return this.selection !== CalendarSelection.RANGE; + } + + private _selected = false; +} diff --git a/projects/igniteui-angular/calendar/src/calendar/days-view/days-view.component.html b/projects/igniteui-angular/calendar/src/calendar/days-view/days-view.component.html new file mode 100644 index 00000000000..96a403d93b3 --- /dev/null +++ b/projects/igniteui-angular/calendar/src/calendar/days-view/days-view.component.html @@ -0,0 +1,88 @@ +
+ @if (showWeekNumbers) { +
+ + {{ weekNumberHeader.short | titlecase }} + +
+ } + @for (dayName of weekHeaderLabels; track dayName.long) { + + + {{ dayName.formatted | titlecase }} + + + } +
+ +@for ( + week of monthWeeks; track rowTracker(i, week); + let isLast = $last; let i = $index +) { +
+ @if (showWeekNumbers) { +
+ + {{ getWeekNumber(week[0]) }} + +
+ } + @for (day of week; track dateTracker($index, day)) { + + {{ formattedDate(day.native) }} + + } +
+} diff --git a/projects/igniteui-angular/calendar/src/calendar/days-view/days-view.component.spec.ts b/projects/igniteui-angular/calendar/src/calendar/days-view/days-view.component.spec.ts new file mode 100644 index 00000000000..cf2fac8ad27 --- /dev/null +++ b/projects/igniteui-angular/calendar/src/calendar/days-view/days-view.component.spec.ts @@ -0,0 +1,411 @@ +import { Component, DebugElement, ViewChild } from "@angular/core"; +import { IgxDaysViewComponent } from "./days-view.component"; +import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { DateRangeDescriptor, DateRangeType } from 'igniteui-webcomponents'; +import { ScrollDirection } from "../calendar"; +import { KeyboardNavigationService } from '../calendar.services'; +import { CalendarDay } from 'igniteui-angular/core'; +import { UIInteractions } from '../../../../test-utils/ui-interactions.spec'; + +const TODAY = new Date(2024, 6, 12); + +describe("Days View Component", () => { + const baseClass = "igx-days-view"; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [InitDaysViewComponent], + providers: [ + KeyboardNavigationService + ] + }).compileComponents(); + })); + + it("initializes a days-view component with auto-incremented id", () => { + const fixture = TestBed.createComponent(InitDaysViewComponent); + fixture.detectChanges(); + const { instance } = fixture.componentInstance; + const { nativeElement: hostEl } = fixture.debugElement.query( + By.css(baseClass), + ); + + expect(instance.id).toContain(`${baseClass}-`); + expect(hostEl.id).toContain(`${baseClass}-`); + + instance.id = "customDaysView"; + fixture.detectChanges(); + + expect(instance.id).toBe("customDaysView"); + expect(hostEl.id).toBe("customDaysView"); + }); + + it("should set the showActiveDay property", () => { + const fixture = TestBed.createComponent(InitDaysViewComponent); + const { instance } = fixture.componentInstance; + instance.showActiveDay = true; + fixture.detectChanges(); + }); + + it("should automatically set the showActiveDay property to `true` on focus", () => { + const fixture = TestBed.createComponent(InitDaysViewComponent); + const el = fixture.debugElement.query(By.css("igx-days-view")); + const { instance } = fixture.componentInstance; + fixture.detectChanges(); + + el.nativeElement.focus(); + fixture.detectChanges(); + + expect(instance.showActiveDay).toBe(true); + }); + + it("should automatically set the showActiveDay property to `false` on blur", () => { + const fixture = TestBed.createComponent(InitDaysViewComponent); + const el = fixture.debugElement.query(By.css("igx-days-view")); + const { instance } = fixture.componentInstance; + fixture.detectChanges(); + + el.nativeElement.focus(); + fixture.detectChanges(); + + el.nativeElement.blur(); + fixture.detectChanges(); + + expect(instance.showActiveDay).toBe(false); + }); + + it("should set activeDate to the first day of the current month when no value is provided", () => { + const firstMonthDay = new Date( + TODAY.getFullYear(), + TODAY.getMonth(), + 1, + ); + const fixture = TestBed.createComponent(InitDaysViewComponent); + const { instance } = fixture.componentInstance; + fixture.detectChanges(); + + expect(instance.activeDate).toEqual(firstMonthDay); + }); + + it("should hide leading/trailing inactive days when hideLeadingDays/hideTrailingDays are set", () => { + const fixture = TestBed.createComponent(InitDaysViewComponent); + const { instance } = fixture.componentInstance; + fixture.detectChanges(); + + const { leading: initialLeading, trailing: initialTrailing } = + getInactiveDays(fixture); + + instance.hideLeadingDays = true; + fixture.detectChanges(); + + const { leading } = getInactiveDays(fixture); + + if (initialLeading.length > 0) { + expect(leading.length).toEqual(0); + } + + instance.hideTrailingDays = true; + fixture.detectChanges(); + + const { trailing } = getInactiveDays(fixture); + + if (initialTrailing.length > 0) { + expect(trailing.length).toEqual(0); + } + }); + + describe("Keyboard navigation", () => { + let fixture: ComponentFixture; + let el: HTMLElement; + let instance: IgxDaysViewComponent; + const firstDay = CalendarDay.from( + new Date(TODAY.getFullYear(), TODAY.getMonth(), 1), + ); + const lastDay = CalendarDay.from( + new Date(TODAY.getFullYear(), TODAY.getMonth() + 1, 0), + ); + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(InitDaysViewComponent); + el = fixture.debugElement.query( + By.css("igx-days-view"), + ).nativeElement; + instance = fixture.componentInstance.instance; + fixture.detectChanges(); + + el.focus(); + fixture.detectChanges(); + })); + + it("should navigate to the next day when pressing the right arrow key", () => { + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowRight", + document.activeElement, + ); + + fixture.detectChanges(); + expect(instance.activeDate).toEqual(firstDay.add("day", 1).native); + }); + + it("should navigate to the previous day when pressing the left arrow key", () => { + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowLeft", + document.activeElement, + ); + + fixture.detectChanges(); + expect(instance.activeDate).toEqual(firstDay.add("day", -1).native); + }); + + it("should navigate to same day next week when pressing the down arrow key", () => { + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowDown", + document.activeElement, + ); + + fixture.detectChanges(); + expect(instance.activeDate).toEqual(firstDay.add("day", 7).native); + }); + + it("should navigate to same day prev week when pressing the up arrow key", () => { + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowUp", + document.activeElement, + ); + + fixture.detectChanges(); + expect(instance.activeDate).toEqual(firstDay.add("day", -7).native); + }); + + it("should navigate to the first active date in the month when pressing the Home key", () => { + instance.activeDate = firstDay.add("day", 10).native; + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem( + "Home", + document.activeElement, + ); + + fixture.detectChanges(); + expect(instance.activeDate).toEqual(firstDay.native); + }); + + it("should navigate to the last active date in the month when pressing the End key", () => { + UIInteractions.triggerKeyDownEvtUponElem( + "End", + document.activeElement, + ); + + fixture.detectChanges(); + expect(instance.activeDate).toEqual(lastDay.native); + }); + + it("should select the activeDate when pressing the enter key", () => { + spyOn(instance.dateSelected, "emit"); + spyOn(instance.selected, "emit"); + + instance.activeDate = firstDay.add("day", 4).native; + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem( + "Enter", + document.activeElement, + ); + fixture.detectChanges(); + + expect(instance.dateSelected.emit).toHaveBeenCalledWith( + instance.activeDate, + ); + expect(instance.selected.emit).toHaveBeenCalledWith([ + instance.activeDate, + ]); + expect(instance.selectedDates).toEqual([instance.activeDate]); + }); + + it("should skip disabled dates when navigating with arrow keys", () => { + instance.activeDate = firstDay.add("day", 10).native; + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowRight", + document.activeElement, + ); + + fixture.detectChanges(); + expect(instance.activeDate).toEqual(firstDay.add("day", 12).native); + + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowLeft", + document.activeElement, + ); + expect(instance.activeDate).toEqual(firstDay.add("day", 10).native); + + instance.activeDate = firstDay.add("day", 4).native; + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowDown", + document.activeElement, + ); + expect(instance.activeDate).toEqual(firstDay.add("day", 18).native); + + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowUp", + document.activeElement, + ); + expect(instance.activeDate).toEqual(firstDay.add("day", 4).native); + }); + + it("should emit pageChaged event when the active date is in the previous/next months", () => { + spyOn(instance.pageChanged, "emit"); + instance.activeDate = firstDay.native; + fixture.detectChanges(); + + // Movo to previous month + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowLeft", + document.activeElement, + ); + + expect(instance.pageChanged.emit).toHaveBeenCalledWith({ + monthAction: ScrollDirection.PREV, + key: "ArrowLeft", + nextDate: instance.activeDate, + }); + + // Movo to next month + UIInteractions.triggerKeyDownEvtUponElem( + "ArrowRight", + document.activeElement, + ); + + expect(instance.pageChanged.emit).toHaveBeenCalledWith({ + monthAction: ScrollDirection.NEXT, + key: "ArrowRight", + nextDate: instance.activeDate, + }); + }); + }); + + describe("Mouse interactions", () => { + let fixture: ComponentFixture; + let el: HTMLElement; + let instance: IgxDaysViewComponent; + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(InitDaysViewComponent); + el = fixture.debugElement.query( + By.css("igx-days-view"), + ).nativeElement; + instance = fixture.componentInstance.instance; + fixture.detectChanges(); + + el.focus(); + fixture.detectChanges(); + })); + + it("should select the clicked date", () => { + spyOn(instance.dateSelected, "emit"); + spyOn(instance.selected, "emit"); + + const day = fixture.debugElement.query( + By.css( + ".igx-days-view__date:not(.igx-days-view__date--inactive)", + ), + ); + + UIInteractions.simulateClickAndSelectEvent(day.nativeElement.firstChild); + fixture.detectChanges(); + + expect(instance.dateSelected.emit).toHaveBeenCalledWith( + instance.activeDate, + ); + + expect(instance.selected.emit).toHaveBeenCalledWith([ + instance.activeDate, + ]); + + expect(instance.selectedDates).toEqual([instance.activeDate]); + }); + + it("should emit pageChanged when clicking on a date outside the previous/next months", () => { + spyOn(instance.pageChanged, "emit"); + + let days = fixture.debugElement.queryAll( + By.css(".igx-days-view__date--inactive"), + ); + + UIInteractions.simulateClickAndSelectEvent( + days.at(0).nativeElement.firstChild, + ); + fixture.detectChanges(); + + expect(instance.pageChanged.emit).toHaveBeenCalledWith({ + monthAction: ScrollDirection.PREV, + key: "", + nextDate: instance.activeDate, + }); + + days = fixture.debugElement.queryAll( + By.css(".igx-days-view__date--inactive"), + ); + + UIInteractions.simulateClickAndSelectEvent( + days.at(-1).nativeElement.firstChild, + ); + fixture.detectChanges(); + + expect(instance.pageChanged.emit).toHaveBeenCalledWith({ + monthAction: ScrollDirection.NEXT, + key: "", + nextDate: instance.activeDate, + }); + }); + }); +}); + +function getInactiveDays(fixture: ComponentFixture) { + const days = fixture.debugElement.queryAll(By.css(".igx-days-view__date")); + const inactiveDays = fixture.debugElement.queryAll( + By.css( + ".igx-days-view__date--inactive:not(igx-dasy-view__date--hidden)", + ), + ); + + const firstActiveIndex = days.findIndex( + (d: DebugElement) => + !d.nativeElement.classList.contains( + "igx-days-view__date--inactive", + ), + ); + + const notHidden = (d: DebugElement) => + !d.nativeElement.classList.contains("igx-days-view__date--hidden"); + + const leading = inactiveDays.slice(0, firstActiveIndex).filter(notHidden); + const trailing = inactiveDays + .slice(firstActiveIndex, inactiveDays.length) + .filter(notHidden); + + return { leading, trailing }; +} + +@Component({ + template: ``, + imports: [IgxDaysViewComponent] +}) +class InitDaysViewComponent { + @ViewChild(IgxDaysViewComponent, { static: true }) + public instance: IgxDaysViewComponent; + public date = TODAY; + protected disabledDates: DateRangeDescriptor[] = [ + { + type: DateRangeType.Specific, + dateRange: [ + new Date(TODAY.getFullYear(), TODAY.getMonth(), 12), + new Date(TODAY.getFullYear(), TODAY.getMonth(), 24), + ], + }, + ]; +} diff --git a/projects/igniteui-angular/calendar/src/calendar/days-view/days-view.component.ts b/projects/igniteui-angular/calendar/src/calendar/days-view/days-view.component.ts new file mode 100644 index 00000000000..d704c218e5f --- /dev/null +++ b/projects/igniteui-angular/calendar/src/calendar/days-view/days-view.component.ts @@ -0,0 +1,659 @@ +import { + Component, + Output, + EventEmitter, + Input, + HostListener, + ViewChildren, + QueryList, + HostBinding, + booleanAttribute, + ElementRef, + ChangeDetectorRef, + ChangeDetectionStrategy, + inject, + DestroyRef, + AfterContentChecked +} from '@angular/core'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; +import { TitleCasePipe } from '@angular/common'; +import { CalendarSelection, ScrollDirection } from '../../calendar/calendar'; +import { IgxDayItemComponent } from './day-item.component'; +import { + CalendarDay, + DateRangeType, + areSameMonth, + generateMonth, + getClosestActiveDate, + getNextActiveDate, + getPreviousActiveDate, + intoChunks, + isDateInRanges, + getComponentTheme, + IgxTheme, + THEME_TOKEN, + ThemeToken +} from 'igniteui-angular/core'; +import { IgxCalendarBaseDirective } from '../calendar-base'; +import { IViewChangingEventArgs } from './days-view.interface'; +import { KeyboardNavigationService } from '../calendar.services'; + +let NEXT_ID = 0; + +@Component({ + providers: [ + { + multi: true, + provide: NG_VALUE_ACCESSOR, + useExisting: IgxDaysViewComponent + }, + KeyboardNavigationService + ], + selector: 'igx-days-view', + templateUrl: 'days-view.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [IgxDayItemComponent, TitleCasePipe] +}) +export class IgxDaysViewComponent extends IgxCalendarBaseDirective implements AfterContentChecked { + protected el = inject(ElementRef); + public override cdr = inject(ChangeDetectorRef); + private themeToken: ThemeToken = inject(THEME_TOKEN); + #standalone = true; + + /** + * Sets/gets the `id` of the days view. + * If not set, the `id` will have value `"igx-days-view-0"`. + * ```html + * + * ``` + * ```typescript + * let daysViewId = this.daysView.id; + * ``` + */ + @HostBinding('attr.id') + @Input() + public id = `igx-days-view-${NEXT_ID++}`; + + @HostBinding('attr.tabIndex') + @Input() + public tabIndex = 0; + + @HostBinding('attr.role') + @Input() + public role = 'grid'; + + @HostBinding('class.igx-days-view') + public readonly viewClass = true; + + @Input() + @HostBinding('class.igx-days-view--standalone') + public get standalone() { + return this.#standalone; + } + + public set standalone(value: boolean) { + this.#standalone = value; + } + + @HostBinding('attr.aria-activeDescendant') + protected get activeDescendant() { + if (this.tabIndex === -1) return; + + return this.activeDate.getTime(); + } + + /** + * Show/hide week numbers + * + * @example + * ```html + * + * `` + */ + @Input({ transform: booleanAttribute }) + public showWeekNumbers: boolean; + + /** + * @hidden + * @internal + */ + @Input() + public set activeDate(value: Date) { + this._activeDate = value; + this.changePreviewRange(value); + this.activeDateChange.emit(this._activeDate); + } + + public get activeDate(): Date { + return this._activeDate ?? this.viewDate; + } + + /** + * @hidden + * @internal + */ + @Input() + public set previewRangeDate(value: Date) { + this._previewRangeDate = value; + this.previewRangeDateChange.emit(value); + } + + public get previewRangeDate() { + return this._previewRangeDate; + } + + @Input({ transform: booleanAttribute }) + public set hideLeadingDays(value: boolean) { + this._hideLeadingDays = value; + this.cdr.detectChanges(); + } + + public get hideLeadingDays() { + return this._hideLeadingDays ?? this.hideOutsideDays; + } + + @Input({ transform: booleanAttribute }) + public set hideTrailingDays(value: boolean) { + this._hideTrailingDays = value; + this.cdr.detectChanges(); + } + + public get hideTrailingDays() { + return this._hideTrailingDays ?? this.hideOutsideDays; + } + + @Input({ transform: booleanAttribute }) + public set showActiveDay(value: boolean) { + this._showActiveDay = value; + } + + public get showActiveDay() { + return this._showActiveDay; + } + + /** + * @hidden + */ + @Output() + public dateSelected = new EventEmitter(); + + /** + * @hidden + */ + @Output() + public pageChanged = new EventEmitter(); + + /** + * @hidden + */ + @Output() + public activeDateChange = new EventEmitter(); + + /** + * @hidden + */ + @Output() + public previewRangeDateChange = new EventEmitter(); + + /** + * @hidden + */ + @ViewChildren(IgxDayItemComponent, { read: IgxDayItemComponent }) + public dates: QueryList; + + private _activeDate: Date; + private _previewRangeDate: Date; + private _hideLeadingDays: boolean; + private _hideTrailingDays: boolean; + private _showActiveDay: boolean; + + private _destroyRef = inject(DestroyRef); + private _theme: IgxTheme; + + @HostBinding('class.igx-days-view') + public defaultClass = true; + + // Theme-specific classes + @HostBinding('class.igx-days-view--material') + protected get isMaterial(): boolean { + return this._theme === 'material'; + } + + @HostBinding('class.igx-days-view--fluent') + protected get isFluent(): boolean { + return this._theme === 'fluent'; + } + + @HostBinding('class.igx-days-view--bootstrap') + protected get isBootstrap(): boolean { + return this._theme === 'bootstrap'; + } + + @HostBinding('class.igx-days-view--indigo') + protected get isIndigo(): boolean { + return this._theme === 'indigo'; + } + + /** + * @hidden + */ + constructor() { + super(); + this._theme = this.themeToken.theme; + + const themeChange = this.themeToken.onChange((theme) => { + if (this._theme !== theme) { + this._theme = theme; + this.cdr.detectChanges(); + } + }); + + this._destroyRef.onDestroy(() => themeChange.unsubscribe()); + } + + private setComponentTheme() { + // allow DOM theme override (same pattern as input-group) + if (!this.themeToken.preferToken) { + const theme = getComponentTheme(this.el.nativeElement); + + if (theme && theme !== this._theme) { + this._theme = theme; + this.cdr.markForCheck(); + } + } + } + + public ngAfterContentChecked() { + this.setComponentTheme(); + } + + /** + * @hidden + */ + private handleArrowKeydown(event: KeyboardEvent, delta: number) { + event.preventDefault(); + event.stopPropagation(); + + const date = getClosestActiveDate( + CalendarDay.from(this.activeDate), + delta, + this.disabledDates, + ); + + if (!areSameMonth(this.activeDate, date.native)) { + this.pageChanged.emit({ + monthAction: delta > 0 ? ScrollDirection.NEXT : ScrollDirection.PREV, + key: event.key, + nextDate: date.native + }); + } + + this.activeDate = date.native; + this.viewDate = date.native; + this.clearPreviewRange(); + this.changePreviewRange(date.native); + this.cdr.detectChanges(); + } + + /** + * @hidden + */ + @HostListener('keydown.arrowright', ['$event']) + protected onArrowRight(event: KeyboardEvent) { + this.handleArrowKeydown(event, 1); + } + + /** + * @hidden + */ + @HostListener('keydown.arrowleft', ['$event']) + protected onArrowLeft(event: KeyboardEvent) { + this.handleArrowKeydown(event, -1); + } + + /** + * @hidden + */ + @HostListener('keydown.arrowup', ['$event']) + protected onArrowUp(event: KeyboardEvent) { + this.handleArrowKeydown(event, -7); + } + + /** + * @hidden + */ + @HostListener('keydown.arrowdown', ['$event']) + protected onArrowDown(event: KeyboardEvent) { + this.handleArrowKeydown(event, 7); + } + + /** + * @hidden + */ + @HostListener('keydown.space', ['$event']) + @HostListener('keydown.enter', ['$event']) + protected onKeydownEnter(event: KeyboardEvent) { + event.stopPropagation(); + this.selectActiveDate(); + } + + /** + * @hidden + */ + @HostListener('keydown.home', ['$event']) + protected onKeydownHome(event: KeyboardEvent) { + event.preventDefault(); + event.stopPropagation(); + + const first = CalendarDay.from(this.activeDate); + this.activeDate = getNextActiveDate( + first.set({ date: 1 }), + this.disabledDates, + ).native; + } + + /** + * @hidden + */ + @HostListener('keydown.end', ['$event']) + protected onKeydownEnd(event: KeyboardEvent) { + event.preventDefault(); + event.stopPropagation(); + + const last = CalendarDay.from(this.activeDate); + this.activeDate = getPreviousActiveDate( + last.set({ month: last.month + 1, date: 0 }), + this.disabledDates, + ).native; + } + + /** + * @hidden + */ + @HostListener('focus') + protected handleFocus() { + this._showActiveDay = true; + this.changePreviewRange(this.activeDate); + } + + /** + * @hidden + */ + @HostListener('blur') + protected handleBlur() { + this._showActiveDay = false; + this.clearPreviewRange(); + } + + /** + * @hidden + */ + protected handleDateClick(item: IgxDayItemComponent) { + const date = item.date.native; + + if (item.isPreviousMonth) { + this.pageChanged.emit({ + monthAction: ScrollDirection.PREV, + key: '', + nextDate: date + }); + } + + if (item.isNextMonth) { + this.pageChanged.emit({ + monthAction: ScrollDirection.NEXT, + key: '', + nextDate: date + }); + } + + if (this.tabIndex !== -1 && this.platform.isBrowser && this.el?.nativeElement) { + this.el.nativeElement.focus(); + } + + this.activeDate = item.date.native; + this.selectActiveDate(); + } + + private selectActiveDate() { + this.selectDate(this.activeDate); + this.dateSelected.emit(this.activeDate); + this.selected.emit(this.selectedDates); + this.clearPreviewRange(); + } + + protected get calendarMonth(): CalendarDay[] { + return Array.from(generateMonth(this.viewDate, this.weekStart)); + } + + protected get monthWeeks(): CalendarDay[][] { + return Array.from(intoChunks(this.calendarMonth, 7)); + } + + /** + * Returns the week number by date + * + * @hidden + */ + public getWeekNumber(date: CalendarDay): number { + return date.getWeekNumber(this.weekStart); + } + + /** + * Returns the locale representation of the date in the days view. + * + * @hidden + */ + public formattedDate(value: Date): string { + if (this.formatViews.day) { + return this.formatterDay.format(value); + } + + return `${value.getDate()}`; + } + + /** + * @hidden + */ + public get weekHeaderLabels(): {long: string, formatted: string}[] { + const weekdays = []; + const rawFormatter = new Intl.DateTimeFormat(this.locale, { weekday: 'long' }); + + for (const day of this.monthWeeks.at(0)) { + weekdays.push({ + long: rawFormatter.format(day.native), + formatted: this.formatterWeekday.format(day.native) + }); + } + + return weekdays; + } + + protected get weekNumberHeader(): { short: string, long: string } { + const weekOfYear = (style: 'narrow' | 'long') => { + const dn = new Intl.DisplayNames(this.locale, { + type: 'dateTimeField', + style, + }); + + return dn.of('weekOfYear'); + } + + return { + short: weekOfYear('narrow').substring(0, 1), + long: weekOfYear('long'), + } + } + + /** + * @hidden + */ + public rowTracker(_: number, item: CalendarDay[]): string { + return `${item[0].month}${item[0].date}`; + } + + /** + * @hidden + */ + public dateTracker(_: number, item: CalendarDay): string { + return `${item.month}--${item.date}`; + } + + /** + * @hidden + */ + public isSelected(date: CalendarDay): boolean { + const dates = this.value as Date[]; + const hasValue = this.value || (Array.isArray(this.value) && this.value.length === 1); + + if (isDateInRanges(date, this.disabledDates)) { + return false; + } + + if (this.selection === CalendarSelection.SINGLE) { + return !!this.value && date.equalTo(this.value as Date); + } + + if (!hasValue) { + return false; + } + + if (this.selection === CalendarSelection.MULTI && dates.length > 0) { + return isDateInRanges(date, [ + { + type: DateRangeType.Specific, + dateRange: dates, + }, + ]); + } + + if (this.selection === CalendarSelection.RANGE && dates.length > 0) { + return isDateInRanges(date, [ + { + type: DateRangeType.Between, + dateRange: [dates.at(0), dates.at(-1)], + }, + ]); + } + } + + /** + * @hidden + */ + protected isFirstInRange(date: CalendarDay): boolean { + const dates = this.selectedDates; + + if (this.isSingleSelection || dates.length === 0) { + return false; + } + + let target = dates.at(0); + + if (this.previewRangeDate && this.previewRangeDate < target) { + target = this.previewRangeDate; + } + + return date.equalTo(target); + } + + /** + * @hidden + */ + protected isLastInRange(date: CalendarDay): boolean { + const dates = this.selectedDates; + + if (this.isSingleSelection || dates.length === 0) { + return false; + } + + let target = dates.at(-1); + + if (this.previewRangeDate && this.previewRangeDate > target) { + target = this.previewRangeDate; + } + + return date.equalTo(target); + } + + /** + * @hidden + */ + protected isActiveDate(day: CalendarDay): boolean { + return this._showActiveDay && day.equalTo(this.activeDate); + } + + /** + * @hidden + */ + protected isWithinRange(date: Date, checkForRange: boolean, min?: Date, max?: Date): boolean { + const dates = this.selectedDates; + + if (checkForRange && !(Array.isArray(dates) && dates.length > 1)) { + return false; + } + + min = min ? min : dates.at(0); + max = max ? max : dates.at(-1); + + return isDateInRanges(date, + [ + { + type: DateRangeType.Between, + dateRange: [min, max] + } + ] + ); + } + + protected isWithinPreviewRange(date: Date): boolean { + if (this.selection !== 'range') return false; + + const dates = this.selectedDates; + + if (!(dates.length > 0 && this.previewRangeDate)) { + return false; + } + + return isDateInRanges(date, [ + { + type: DateRangeType.Between, + dateRange: [dates.at(0), this.previewRangeDate], + }, + ]); + } + + /** + * @hidden + */ + private get isSingleSelection(): boolean { + return this.selection !== CalendarSelection.RANGE; + } + + /** + * @hidden @internal + */ + public changePreviewRange(date: Date) { + const dates = this.value as Date[]; + + if (this.selection === 'range' && dates.length === 1) { + const first = CalendarDay.from(dates.at(0)); + + if (!first.equalTo(date)) { + this.setPreviewRangeDate(date); + } + } + } + + /** + * @hidden @internal + */ + public clearPreviewRange() { + if (this.previewRangeDate) { + this.setPreviewRangeDate(undefined); + } + } + + private setPreviewRangeDate(value?: Date) { + this.previewRangeDate = value; + } +} diff --git a/projects/igniteui-angular/calendar/src/calendar/days-view/days-view.interface.ts b/projects/igniteui-angular/calendar/src/calendar/days-view/days-view.interface.ts new file mode 100644 index 00000000000..43aea81f89b --- /dev/null +++ b/projects/igniteui-angular/calendar/src/calendar/days-view/days-view.interface.ts @@ -0,0 +1,5 @@ +export interface IViewChangingEventArgs { + monthAction: string; + key: string; + nextDate: Date; +} diff --git a/projects/igniteui-angular/calendar/src/calendar/month-picker/README.md b/projects/igniteui-angular/calendar/src/calendar/month-picker/README.md new file mode 100644 index 00000000000..277084ec8af --- /dev/null +++ b/projects/igniteui-angular/calendar/src/calendar/month-picker/README.md @@ -0,0 +1,133 @@ +# igxMonthPicker Component + +The **igxMonthPicker** provides a way for the user to select a month. + + +## Dependencies +In order to be able to use **igxMonthPicker** you should keep in mind that it is dependent on **BrowserAnimationsModule**, +which must be imported **only once** in your application's AppModule, for example: +```typescript +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +@NgModule({ + imports: [ + BrowserAnimationsModule, + ... + ] +}) +export class AppModule { +} +``` +Also the **igxMonthPicker** uses the [Intl](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat) WebAPI for localization and formatting of dates. Consider using the [appropriate polyfills](https://github.com/andyearnshaw/Intl.js/) if your target platform does not support them. + + +## Usage + +Importing the month picker in your application +```typescript +import { IgxMonthPickerComponent } from "igniteui-angular"; +``` + +Instantiate a month picker component and pass a date object. +```html + +``` + +The **igxMonthPicker** implements the `ControlValueAccessor` interface, providing two-way data-binding +and the expected behavior when used both in Template-driven or Reactive Forms. +```html + +``` + +Customize the format, set the views to be formatted and the locale +```typescript + public formatViews = { month: true, year: true }; + public formatOptions = { month: 'long', year: 'numeric' }; + + public localeDe = 'de'; +``` + +```html + +``` + +### Keyboard navigation +When the **igxMonthPicker** component is focused: +- `PageUp` will move to the previous year. +- `PageDown` will move to the next year. +- `Home` will focus the first month of the current year. +- `End` will focus the last month of the current year. +- `Tab` will navigate through the subheader buttons; + +When `prev` or `next` year buttons (in the subheader) are focused: +- `Space` or `Enter` will scroll into view the next or previous year. + +When `years` button (in the subheader) is focused: +- `Space` or `Enter` will open the years view. + +When a month inside the months view is focused: +- Arrow keys will navigate through the months. +- `Home` will focus the first month inside the months view. +- `End` will focus the last month inside the months view. +- `Enter` will select the currently focused month and close the view. +- `Tab` will navigate through the months; + + +## API Summary + +### Inputs + +- `id: string` + +Unique identifier of the component. If not provided it will be automatically generated. + +- `locale: string` + +Controls the locale used for formatting and displaying the dates in the month picker. +The expected string should be a [BCP 47 language tag](http://tools.ietf.org/html/rfc5646). +The default value is `en`. + +- `viewDate: Date` + +Controls the year/month that will be presented in the default view when the month picker renders. By default it is the first day of the current year/month. + +- `value: Date` + +Gets and sets the selected date in the month picker component. + +- `formatOptions: Object` + +Controls the date-time components to use in formatted output, and their desired representations. +Consult [this](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat) +for additional information on the available options. + +The defaul values are listed below. +```typescript +{ month: 'short', year: 'numeric' } +``` + +- `formatViews: Object` + +Controls whether the date parts in the different month picker views should be formatted according to the provided +`locale` and `formatOptions`. + +The default values are listed below. +```typescript +{ month: true, year: false } +``` + +### Outputs + +- `onSelection(): Date | Date[]` + +Event fired when a value is selected through UI interaction. +Returns the selected value (depending on the type of selection). + +- `viewDateChanged(): IViewDateChangeEventArgs` + +Event fired after the month/year presented in the view is changed. +Emits an object containing the previous and current value of the `viewDate` property. + +- `activeViewChanged(): CalendarView` + +Event fired after the active view is changed. +Emits an CalendarView enum, indicating the `activeView` property value. diff --git a/projects/igniteui-angular/calendar/src/calendar/month-picker/month-picker.component.html b/projects/igniteui-angular/calendar/src/calendar/month-picker/month-picker.component.html new file mode 100644 index 00000000000..38d59f48875 --- /dev/null +++ b/projects/igniteui-angular/calendar/src/calendar/month-picker/month-picker.component.html @@ -0,0 +1,163 @@ + + + + + + + + + + + + +
+ +
+
+ + + +
+ +
+
+ + + + @if (activeView === 'year') { + {{ formattedYear(obj.date) }} + } + + {{ formattedYear(obj.date) }} + + + + + + {{ getDecadeRange().start }} - {{ getDecadeRange().end }} + + + + +
+
+ + +
+
+ + +
+
+
+ + + +
+
+ + +
+
+ + +
+
+
+ +
+ + {{ resourceStrings.igx_calendar_singular_single_selection}} + + +
+ @if (isDefaultView) { + + } + + @if (isDecadeView) { + + } +
+ +
+ @if (isDefaultView) { + + > + + } + + @if (isDecadeView) { + + + } +
+
diff --git a/projects/igniteui-angular/calendar/src/calendar/month-picker/month-picker.component.spec.ts b/projects/igniteui-angular/calendar/src/calendar/month-picker/month-picker.component.spec.ts new file mode 100644 index 00000000000..0fdd6733c63 --- /dev/null +++ b/projects/igniteui-angular/calendar/src/calendar/month-picker/month-picker.component.spec.ts @@ -0,0 +1,578 @@ +import { Component, ViewChild } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { UIInteractions } from '../../../../test-utils/ui-interactions.spec'; +import { IgxMonthPickerComponent } from './month-picker.component'; +import { IFormattingOptions, IgxCalendarView } from '../calendar'; + +describe('IgxMonthPicker', () => { + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, IgxMonthPickerSampleComponent] + }).compileComponents(); + }); + + it('should initialize a month picker component', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + fixture.detectChanges(); + + expect(fixture.componentInstance).toBeDefined(); + }); + + it('should initialize a month picker component with `id` property', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + fixture.detectChanges(); + + const monthPicker = fixture.componentInstance.monthPicker; + + expect(monthPicker.id).toBe('igx-month-picker-1'); + + monthPicker.id = 'custom'; + fixture.detectChanges(); + + expect(monthPicker.id).toBe('custom'); + }); + + it('should properly render month picker DOM structure', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + fixture.detectChanges(); + + const dom = fixture.debugElement; + + const months = dom.queryAll(By.css('.igx-calendar-view__item')); + const current = dom.query(By.css('.igx-calendar-view__item--selected')); + + expect(months.length).toEqual(12); + expect(current.nativeElement.textContent.trim()).toMatch('Feb'); + + const yearBtn = dom.query(By.css('.igx-calendar-picker__date')); + UIInteractions.simulateMouseDownEvent(yearBtn.nativeElement); + fixture.detectChanges(); + + const years = dom.queryAll(By.css('.igx-calendar-view__item')); + const currentYear = dom.query(By.css('.igx-calendar-view__item--selected')); + + expect(years.length).toEqual(15); + expect(currentYear.nativeElement.textContent.trim()).toMatch('2019'); + }); + + it('should properly render month picker rowheader elements', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + fixture.detectChanges(); + + const dom = fixture.debugElement; + const prev = dom.query(By.css('.igx-calendar-picker__prev')); + const next = dom.query(By.css('.igx-calendar-picker__next')); + + expect(prev.nativeElement.getAttribute('aria-label')).toEqual('Previous Year'); + expect(prev.nativeElement.getAttribute('role')).toEqual('button'); + expect(prev.nativeElement.getAttribute('data-action')).toEqual('prev'); + + expect(next.nativeElement.getAttribute('aria-label')).toEqual('Next Year'); + expect(next.nativeElement.getAttribute('role')).toEqual('button'); + expect(next.nativeElement.getAttribute('data-action')).toEqual('next'); + }); + + it('should properly set @Input properties and setters', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + fixture.detectChanges(); + + const instance = fixture.componentInstance; + const monthPicker = fixture.componentInstance.monthPicker; + + const format: IFormattingOptions = { + day: '2-digit', + month: 'long', + weekday: 'long', + year: '2-digit' + }; + + expect(monthPicker.value).toBeUndefined(); + expect(monthPicker.viewDate.getDate()).toEqual(1); + expect(monthPicker.locale).toEqual('en'); + + const today = new Date(Date.now()); + monthPicker.viewDate = today; + monthPicker.value = today; + instance.locale = 'fr'; + instance.formatOptions = format; + fixture.detectChanges(); + + expect(monthPicker.locale).toEqual('fr'); + expect(monthPicker.formatOptions.year).toEqual('2-digit'); + expect(monthPicker.value.getDate()).toEqual(today.getDate()); + expect(monthPicker.viewDate.getDate()).toEqual(1); + }); + + it('should properly set formatOptions and formatViews', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + fixture.detectChanges(); + + const dom = fixture.debugElement; + const monthPicker = fixture.componentInstance.monthPicker; + + const defaultOptions = { + day: 'numeric', + month: 'short', + weekday: 'short', + year: 'numeric' + }; + const defaultViews = { day: false, month: true, year: false }; + + const yearBtn = dom.query(By.css('.igx-calendar-picker__date')); + const month = dom.queryAll(By.css('.igx-calendar-view__item'))[0]; + + expect(monthPicker.formatOptions).toEqual(jasmine.objectContaining(defaultOptions)); + expect(monthPicker.formatViews).toEqual(jasmine.objectContaining(defaultViews)); + expect(yearBtn.nativeElement.textContent.trim()).toMatch('2019'); + expect(month.nativeElement.textContent.trim()).toMatch('Jan'); + + const formatOptions: any = { month: 'long', year: '2-digit' }; + const formatViews: any = { month: true, year: true }; + + monthPicker.formatViews = formatViews; + monthPicker.formatOptions = formatOptions; + fixture.detectChanges(); + + const march = dom.queryAll(By.css('.igx-calendar-view__item'))[2]; + + expect(monthPicker.formatOptions).toEqual(jasmine.objectContaining(Object.assign(defaultOptions, formatOptions))); + expect(monthPicker.formatViews).toEqual(jasmine.objectContaining(Object.assign(defaultViews, formatViews))); + expect(yearBtn.nativeElement.textContent.trim()).toMatch('19'); + expect(march.nativeElement.textContent.trim()).toMatch('March'); + + UIInteractions.simulateMouseDownEvent(yearBtn.nativeElement); + fixture.detectChanges(); + const year = dom.queryAll(By.css('.igx-calendar-view__item'))[0]; + + expect(year.nativeElement.textContent.trim()).toMatch('10'); + }); + + it('should properly set locale', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + fixture.detectChanges(); + + const dom = fixture.debugElement; + const monthPicker = fixture.componentInstance.monthPicker; + + const locale = 'de'; + monthPicker.locale = locale; + fixture.detectChanges(); + + const yearBtn = dom.query(By.css('.igx-calendar-picker__date')); + const month = dom.queryAll(By.css('.igx-calendar-view__item'))[2]; + + expect(yearBtn.nativeElement.textContent.trim()).toMatch('2019'); + expect(month.nativeElement.textContent.trim()).toMatch('Mär'); + }); + + it('should select a month on mousedown', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + fixture.detectChanges(); + + const dom = fixture.debugElement; + const monthPicker = fixture.componentInstance.monthPicker; + + const months = dom.queryAll(By.css('.igx-calendar-view__item')); + + spyOn(monthPicker.selected, 'emit'); + + UIInteractions.simulateMouseDownEvent(months[2].nativeElement.firstChild); + fixture.detectChanges(); + + const currentMonth = dom.query(By.css('.igx-calendar-view__item--selected')); + + expect(monthPicker.selected.emit).toHaveBeenCalled(); + expect(currentMonth.nativeElement.textContent.trim()).toEqual('Mar'); + + const nextDay = new Date(2019, 2, 1); + expect(fixture.componentInstance.model.getDate()).toEqual(nextDay.getDate()); + }); + + it('should select a month through API', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + fixture.detectChanges(); + + const dom = fixture.debugElement; + const monthPicker = fixture.componentInstance.monthPicker; + + const nextDay = new Date(2022, 3, 14); + + monthPicker.selectDate(nextDay); + fixture.detectChanges(); + + const currentMonth = dom.query(By.css('.igx-calendar-view__item--selected')); + const yearBtn = dom.query(By.css('.igx-calendar-picker__date')); + + expect(currentMonth.nativeElement.textContent.trim()).toEqual('Apr'); + expect(yearBtn.nativeElement.textContent.trim()).toMatch('2022'); + }); + + it('should navigate to the previous/next year.', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + fixture.detectChanges(); + + const dom = fixture.debugElement; + const monthPicker = fixture.componentInstance.monthPicker; + + + const prev = dom.query(By.css('.igx-calendar-picker__prev')); + const next = dom.query(By.css('.igx-calendar-picker__next')); + const yearBtn = dom.query(By.css('.igx-calendar-picker__date')); + + expect(yearBtn.nativeElement.textContent.trim()).toMatch('2019'); + + UIInteractions.simulateMouseDownEvent(prev.nativeElement); + fixture.detectChanges(); + + expect(monthPicker.viewDate.getFullYear()).toEqual(2018); + expect(yearBtn.nativeElement.textContent.trim()).toMatch('2018'); + + for (let i = 0; i < 3; i++) { + UIInteractions.simulateMouseDownEvent(next.nativeElement); + } + + fixture.detectChanges(); + + expect(monthPicker.viewDate.getFullYear()).toEqual(2021); + expect(yearBtn.nativeElement.textContent.trim()).toMatch('2021'); + }); + + it('should navigate to the previous/next years.', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + fixture.detectChanges(); + + const dom = fixture.debugElement; + const monthPicker = fixture.componentInstance.monthPicker; + const yearBtn = dom.query(By.css('.igx-calendar-picker__date')); + UIInteractions.simulateMouseDownEvent(yearBtn.nativeElement); + fixture.detectChanges(); + + const prev = dom.query(By.css('.igx-calendar-picker__prev')); + const next = dom.query(By.css('.igx-calendar-picker__next')); + UIInteractions.simulateMouseDownEvent(next.nativeElement); + fixture.detectChanges(); + + expect(monthPicker.viewDate.getFullYear()).toEqual(2034); + + UIInteractions.simulateMouseDownEvent(prev.nativeElement); + fixture.detectChanges(); + expect(monthPicker.viewDate.getFullYear()).toEqual(2019); + }); + + it('should navigate to the previous/next year via KB.', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + fixture.detectChanges(); + + const dom = fixture.debugElement; + const monthPicker = fixture.componentInstance.monthPicker; + + const prev = dom.query(By.css('.igx-calendar-picker__prev')); + const next = dom.query(By.css('.igx-calendar-picker__next')); + const yearBtn = dom.query(By.css('.igx-calendar-picker__date')); + + expect(yearBtn.nativeElement.textContent.trim()).toMatch('2019'); + + prev.nativeElement.focus(); + + expect(prev.nativeElement).toBe(document.activeElement); + + UIInteractions.triggerKeyDownEvtUponElem('Enter', prev.nativeElement); + fixture.detectChanges(); + + expect(monthPicker.viewDate.getFullYear()).toEqual(2018); + expect(yearBtn.nativeElement.textContent.trim()).toMatch('2018'); + + next.nativeElement.focus(); + + expect(next.nativeElement).toBe(document.activeElement); + + UIInteractions.triggerKeyDownEvtUponElem('Enter', next.nativeElement); + UIInteractions.triggerKeyDownEvtUponElem('Enter', next.nativeElement); + UIInteractions.triggerKeyDownEvtUponElem('Enter', next.nativeElement); + fixture.detectChanges(); + + expect(monthPicker.viewDate.getFullYear()).toEqual(2021); + expect(yearBtn.nativeElement.textContent.trim()).toMatch('2021'); + }); + + it('should navigate to the previous/next year via arrowLeft and arrowRight', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + fixture.detectChanges(); + + const monthPicker = fixture.componentInstance.monthPicker; + monthPicker.activeView = IgxCalendarView.Decade; + fixture.detectChanges(); + + const wrapper = fixture.debugElement.query(By.css('.igx-calendar__wrapper')); + wrapper.nativeElement.focus(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', wrapper.nativeElement); + UIInteractions.triggerKeyDownEvtUponElem('Enter', wrapper.nativeElement); + fixture.detectChanges(); + + expect(monthPicker.viewDate.getFullYear()).toEqual(2018); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', wrapper.nativeElement); + UIInteractions.triggerKeyDownEvtUponElem('Enter', wrapper.nativeElement); + fixture.detectChanges(); + + expect(monthPicker.viewDate.getFullYear()).toEqual(2018); + }); + + it('should not emit selected when navigating to the next year', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + fixture.detectChanges(); + + const dom = fixture.debugElement; + const monthPicker = fixture.componentInstance.monthPicker; + spyOn(monthPicker.selected, 'emit').and.callThrough(); + + const next = dom.query(By.css('.igx-calendar-picker__next')); + let yearBtn = dom.query(By.css('.igx-calendar-picker__date')); + expect(yearBtn.nativeElement.textContent.trim()).toMatch('2019'); + + UIInteractions.simulateMouseDownEvent(next.nativeElement); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('Enter', next.nativeElement); + fixture.detectChanges(); + + expect(monthPicker.selected.emit).toHaveBeenCalledTimes(0); + yearBtn = dom.query(By.css('.igx-calendar-picker__date')); + expect(yearBtn.nativeElement.textContent.trim()).toMatch('2021'); + }); + + it('should not emit selected when navigating to the previous year', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + fixture.detectChanges(); + + const dom = fixture.debugElement; + const monthPicker = fixture.componentInstance.monthPicker; + spyOn(monthPicker.selected, 'emit').and.callThrough(); + + const prev = dom.query(By.css('.igx-calendar-picker__prev')); + let yearBtn = dom.query(By.css('.igx-calendar-picker__date')); + expect(yearBtn.nativeElement.textContent.trim()).toMatch('2019'); + + UIInteractions.triggerKeyDownEvtUponElem('Enter', prev.nativeElement); + fixture.detectChanges(); + + UIInteractions.simulateMouseDownEvent(prev.nativeElement); + fixture.detectChanges(); + + expect(monthPicker.selected.emit).toHaveBeenCalledTimes(0); + yearBtn = dom.query(By.css('.igx-calendar-picker__date')); + expect(yearBtn.nativeElement.textContent.trim()).toMatch('2017'); + }); + + it('should not emit selected when changing the year', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + fixture.detectChanges(); + + const dom = fixture.debugElement; + const monthPicker = fixture.componentInstance.monthPicker; + spyOn(monthPicker.selected, 'emit').and.callThrough(); + + let yearBtn = dom.query(By.css('.igx-calendar-picker__date')); + expect(yearBtn.nativeElement.textContent.trim()).toMatch('2019'); + + UIInteractions.simulateMouseDownEvent(yearBtn.nativeElement); + fixture.detectChanges(); + + const year = dom.query(By.css('.igx-calendar-view__item')); + + UIInteractions.simulateMouseDownEvent(year.nativeElement); + fixture.detectChanges(); + + expect(monthPicker.selected.emit).toHaveBeenCalledTimes(0); + yearBtn = dom.query(By.css('.igx-calendar-picker__date')); + expect(yearBtn.nativeElement.textContent.trim()).toMatch('2010'); + }); + + it('should open years view, navigate through and select an year via KB.', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + fixture.detectChanges(); + + const dom = fixture.debugElement; + const monthPicker = fixture.componentInstance.monthPicker; + monthPicker.activeView = IgxCalendarView.Decade; + const wrapper = dom.query(By.css('.igx-calendar__wrapper')); + wrapper.nativeElement.focus(); + fixture.detectChanges(); + + let selectedYear = dom.query(By.css('.igx-calendar-view__item--selected')); + expect(selectedYear.nativeElement.textContent.trim()).toMatch('2019'); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown' , document.activeElement); + fixture.detectChanges(); + + selectedYear = dom.query(By.css('.igx-calendar-view__item--selected')); + expect(selectedYear.nativeElement.textContent.trim()).toMatch('2022'); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp' , document.activeElement); + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp' , document.activeElement); + fixture.detectChanges(); + + selectedYear = dom.query(By.css('.igx-calendar-view__item--selected')); + expect(selectedYear.nativeElement.textContent.trim()).toMatch('2016'); + + UIInteractions.triggerKeyDownEvtUponElem('Enter' , document.activeElement); + fixture.detectChanges(); + + expect(monthPicker.viewDate.getFullYear()).toEqual(2016); + }); + + it('should navigate through and select a month via KB.', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + fixture.detectChanges(); + + const dom = fixture.debugElement; + const monthPicker = fixture.componentInstance.monthPicker; + + const months = dom.queryAll(By.css('.igx-calendar-view__item')); + let currentMonth = dom.query(By.css('.igx-calendar-view__item--selected')); + + expect(months.length).toEqual(12); + expect(currentMonth.nativeElement.textContent.trim()).toMatch('Feb'); + + const monthsView = dom.query(By.css('igx-months-view')); + monthsView.nativeElement.focus(); + expect(monthsView.nativeElement).toBe(document.activeElement); + + UIInteractions.triggerKeyDownEvtUponElem('Home' , document.activeElement); + fixture.detectChanges(); + currentMonth = dom.query(By.css('.igx-calendar-view__item--selected')); + + expect(months.at(0).nativeElement.classList).toContain('igx-calendar-view__item--selected'); + expect(currentMonth.nativeElement.textContent.trim()).toMatch('Jan'); + + UIInteractions.triggerKeyDownEvtUponElem('End' , currentMonth.nativeElement ); + fixture.detectChanges(); + currentMonth = dom.query(By.css('.igx-calendar-view__item--selected')); + + expect(months.at(-1).nativeElement.classList).toContain('igx-calendar-view__item--selected'); + expect(currentMonth.nativeElement.textContent.trim()).toMatch('Dec'); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft' , document.activeElement ); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp' , document.activeElement ); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight' , document.activeElement ); + fixture.detectChanges(); + currentMonth = dom.query(By.css('.igx-calendar-view__item--selected')); + + expect(currentMonth.nativeElement.textContent.trim()).toMatch('Sep'); + UIInteractions.triggerKeyDownEvtUponElem('Enter' , document.activeElement ); + fixture.detectChanges(); + + expect(monthPicker.viewDate.getMonth()).toEqual(8); + }); + + it('should update the view date and throw viewDateChanged event on page changes', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + const monthPicker = fixture.componentInstance.monthPicker; + spyOn(monthPicker.viewDateChanged, 'emit'); + fixture.detectChanges(); + + const dom = fixture.debugElement; + const view = dom.query(By.css('igx-months-view')); + view.nativeElement.focus(); + fixture.detectChanges(); + + // Change the current page to the previous year using keyboard navigation + UIInteractions.triggerEventHandlerKeyDown('Home', view); + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', view); + fixture.detectChanges(); + + expect(monthPicker.viewDateChanged.emit).toHaveBeenCalled(); + expect(monthPicker.viewDate.getFullYear()).toEqual(2018); + + // Change the current page to the next year using keyboard + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', view); + fixture.detectChanges(); + + expect(monthPicker.viewDateChanged.emit).toHaveBeenCalled(); + expect(monthPicker.viewDate.getFullYear()).toEqual(2019); + }); + + it('should emit an activeViewChanged event whenever the view changes', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + const monthPicker = fixture.componentInstance.monthPicker; + spyOn(monthPicker.activeViewChanged, 'emit'); + fixture.detectChanges(); + + const dom = fixture.debugElement; + const yearBtn = dom.query(By.css('.igx-calendar-picker__date')); + + UIInteractions.simulateMouseDownEvent(yearBtn.nativeElement); + fixture.detectChanges(); + + expect(monthPicker.activeViewChanged.emit).toHaveBeenCalled(); + expect(monthPicker.activeView).toEqual('decade'); + + const selectedYear = dom.query(By.css('.igx-calendar-view__item--selected')); + UIInteractions.simulateMouseDownEvent(selectedYear.nativeElement.firstChild); + fixture.detectChanges(); + + expect(monthPicker.activeViewChanged.emit).toHaveBeenCalled(); + expect(monthPicker.activeView).toEqual('year'); + }); + + it('should emit viewDateChanged event when changing year with arrow buttons', () => { + const fixture = TestBed.createComponent(IgxMonthPickerSampleComponent); + const monthPicker = fixture.componentInstance.monthPicker; + spyOn(monthPicker.viewDateChanged, 'emit'); + + fixture.detectChanges(); + + const dom = fixture.debugElement; + const prev = dom.query(By.css('.igx-calendar-picker__prev')); + const next = dom.query(By.css('.igx-calendar-picker__next')); + + UIInteractions.simulateMouseDownEvent(prev.nativeElement); + fixture.detectChanges(); + + expect(monthPicker.viewDateChanged.emit).toHaveBeenCalledWith({ + previousValue: new Date(2019, 1, 1), + currentValue: new Date(2018, 1, 1) + }); + + UIInteractions.simulateMouseDownEvent(next.nativeElement); + UIInteractions.simulateMouseDownEvent(next.nativeElement); + fixture.detectChanges(); + + expect(monthPicker.viewDateChanged.emit).toHaveBeenCalledWith({ + previousValue: new Date(2018, 1, 1), + currentValue: new Date(2019, 1, 1) + }); + }); +}); + +@Component({ + template: ` + `, + imports: [FormsModule, IgxMonthPickerComponent] +}) +export class IgxMonthPickerSampleComponent { + @ViewChild(IgxMonthPickerComponent, { static: true }) public monthPicker: IgxMonthPickerComponent; + + public model: Date = new Date(2019, 1, 7); + public viewDate = new Date(2019, 1, 7); + public locale = 'en'; + + public formatOptions: IFormattingOptions = { + day: 'numeric', + month: 'short', + weekday: 'short', + year: 'numeric' + }; +} diff --git a/projects/igniteui-angular/calendar/src/calendar/month-picker/month-picker.component.ts b/projects/igniteui-angular/calendar/src/calendar/month-picker/month-picker.component.ts new file mode 100644 index 00000000000..31df4440d5f --- /dev/null +++ b/projects/igniteui-angular/calendar/src/calendar/month-picker/month-picker.component.ts @@ -0,0 +1,519 @@ +import { + Component, + HostListener, + ViewChild, + HostBinding, + Input, + ElementRef, + AfterViewInit, + OnDestroy, + OnInit, +} from "@angular/core"; +import { NgTemplateOutlet, DatePipe } from "@angular/common"; +import { NG_VALUE_ACCESSOR } from "@angular/forms"; + +import { IgxMonthsViewComponent } from "../months-view/months-view.component"; +import { IgxYearsViewComponent } from "../years-view/years-view.component"; +import { IgxDaysViewComponent } from "../days-view/days-view.component"; +import { IgxCalendarView } from "../calendar"; +import { IgxCalendarBaseDirective } from "../calendar-base"; +import { KeyboardNavigationService } from "../calendar.services"; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { CalendarDay, formatToParts } from 'igniteui-angular/core'; + +let NEXT_ID = 0; +@Component({ + providers: [ + { + multi: true, + provide: NG_VALUE_ACCESSOR, + useExisting: IgxMonthPickerComponent, + }, + { + multi: false, + provide: KeyboardNavigationService + }, + ], + selector: "igx-month-picker", + templateUrl: "month-picker.component.html", + imports: [ + NgTemplateOutlet, + DatePipe, + IgxIconComponent, + IgxMonthsViewComponent, + IgxYearsViewComponent, + ] +}) +export class IgxMonthPickerComponent extends IgxCalendarBaseDirective implements OnInit, AfterViewInit, OnDestroy { + /** + * Sets/gets the `id` of the month picker. + * If not set, the `id` will have value `"igx-month-picker-0"`. + */ + @HostBinding("attr.id") + @Input() + public id = `igx-month-picker-${NEXT_ID++}`; + + /** + * @hidden + * @internal + */ + private _activeDescendant: number; + + /** + * @hidden + * @internal + */ + @ViewChild("wrapper") + public wrapper: ElementRef; + + /** + * The default css class applied to the component. + * + * @hidden + */ + @HostBinding("class.igx-month-picker") + public styleClass = true; + + /** + * @hidden + */ + @ViewChild("months", { read: IgxMonthsViewComponent }) + public monthsView: IgxMonthsViewComponent; + + /** + * @hidden + */ + @ViewChild("decade", { read: IgxYearsViewComponent }) + public dacadeView: IgxYearsViewComponent; + + /** + * @hidden + */ + @ViewChild("days", { read: IgxDaysViewComponent }) + public daysView: IgxDaysViewComponent; + + /** + * @hidden + */ + @ViewChild("yearsBtn") + public yearsBtn: ElementRef; + + /** + * @hidden + */ + @HostListener("keydown.pageup", ["$event"]) + public previousPage(event?: KeyboardEvent) { + event?.preventDefault(); + this.previousViewDate = this.viewDate; + + if (this.isDefaultView) { + this.viewDate = CalendarDay.from(this.viewDate).add('year', -1).native; + } + + if (this.isDecadeView) { + this.viewDate = CalendarDay.from(this.viewDate).add('year', -15).native; + } + + this.viewDateChanged.emit({ + previousValue: this.previousViewDate, + currentValue: this.viewDate, + }); + } + + /** + * @hidden + */ + @HostListener("keydown.pagedown", ["$event"]) + public nextPage(event?: KeyboardEvent) { + event?.preventDefault(); + this.previousViewDate = this.viewDate; + + if (this.isDefaultView) { + this.viewDate = CalendarDay.from(this.viewDate).add('year', 1).native; + } + + if (this.isDecadeView) { + this.viewDate = CalendarDay.from(this.viewDate).add('year', 15).native; + } + + this.viewDateChanged.emit({ + previousValue: this.previousViewDate, + currentValue: this.viewDate, + }); + } + + /** + * @hidden + * @internal + */ + public onActiveViewDecadeKB(date: Date, event: KeyboardEvent, activeViewIdx: number) { + super.activeViewDecadeKB(event, activeViewIdx); + + if (this.platform.isActivationKey(event)) { + this.viewDate = date; + if (this.platform.isBrowser && this.wrapper?.nativeElement) { + this.wrapper.nativeElement.focus(); + } + } + } + + /** + * @hidden + * @internal + */ + public onActiveViewDecade(event: MouseEvent, date: Date, activeViewIdx: number): void { + event.preventDefault(); + + super.activeViewDecade(activeViewIdx); + this.viewDate = date; + } + + /** + * @hidden + */ + public override activeViewDecadeKB(event: KeyboardEvent) { + super.activeViewDecadeKB(event); + + if (event.key === this.platform.KEYMAP.ARROW_RIGHT) { + this.nextPage(event); + } + + if (event.key === this.platform.KEYMAP.ARROW_LEFT) { + this.previousPage(event); + } + } + + /** + * @hidden + */ + public override activeViewDecade() { + super.activeViewDecade(); + + if (this.platform.isBrowser) { + requestAnimationFrame(() => { + if (this.dacadeView?.el?.nativeElement) { + this.dacadeView.el.nativeElement.focus(); + } + }); + } + } + + /** + * @hidden + */ + public changePageKB(event: KeyboardEvent, next = true) { + if (this.platform.isActivationKey(event)) { + event.stopPropagation(); + + if (next) { + this.nextPage(); + } else { + this.previousPage(); + } + } + } + + /** + * @hidden + */ + public selectYear(event: Date) { + this.previousViewDate = this.viewDate; + + this.viewDate = new Date( + event.getFullYear(), + event.getMonth(), + event.getDate(), + ); + + this.activeView = IgxCalendarView.Year; + if (this.platform.isBrowser && this.wrapper?.nativeElement) { + this.wrapper.nativeElement.focus(); + } + } + + /** + * @hidden + */ + public selectMonth(event: Date) { + this.selectDate(event); + this.selected.emit(this.selectedDates); + } + + /** + * Selects a date. + * ```typescript + * this.monthPicker.selectDate(new Date(`2018-06-12`)); + * ``` + */ + public override selectDate(value: Date) { + if (!value) { + return new Date(); + } + + super.selectDate(value); + this.viewDate = value; + } + + /** + * @hidden + */ + public getNextYear() { + return CalendarDay.from(this.viewDate).add('year', 1).year; + } + + /** + * @hidden + */ + public getPreviousYear() { + return CalendarDay.from(this.viewDate).add('year', -1).year; + } + + /** + * @hidden + */ + public updateDate(date: Date) { + this.previousViewDate = this.viewDate; + this.viewDate = CalendarDay.from(date).add('year', -this.activeViewIdx).native; + + if (this.isDefaultView) { + this.viewDateChanged.emit({ + previousValue: this.previousViewDate, + currentValue: this.viewDate, + }); + } + } + + @HostListener('mousedown', ['$event']) + protected onMouseDown(event: MouseEvent) { + event.stopPropagation(); + if (this.platform.isBrowser && this.wrapper?.nativeElement) { + this.wrapper.nativeElement.focus(); + } + } + + private _showActiveDay: boolean; + + /** + * @hidden + * @internal + */ + protected set showActiveDay(value: boolean) { + this._showActiveDay = value; + this.cdr.detectChanges(); + } + + protected get showActiveDay() { + return this._showActiveDay; + } + + protected get activeDescendant(): number { + if (this.activeView === 'month') { + return (this.value as Date)?.getTime(); + } + + return this._activeDescendant ?? this.viewDate.getTime(); + } + + protected set activeDescendant(date: Date) { + this._activeDescendant = date.getTime(); + } + + public override get isDefaultView(): boolean { + return this.activeView === IgxCalendarView.Year; + } + + public ngOnInit() { + this.activeView = IgxCalendarView.Year; + } + + public ngAfterViewInit() { + this.keyboardNavigation + .attachKeyboardHandlers(this.wrapper, this) + .set("ArrowUp", this.onArrowUp) + .set("ArrowDown", this.onArrowDown) + .set("ArrowLeft", this.onArrowLeft) + .set("ArrowRight", this.onArrowRight) + .set("Enter", this.onEnter) + .set(" ", this.onEnter) + .set("Home", this.onHome) + .set("End", this.onEnd) + .set("PageUp", this.handlePageUp) + .set("PageDown", this.handlePageDown); + + this.activeView$.subscribe((view) => { + this.activeViewChanged.emit(view); + + this.viewDateChanged.emit({ + previousValue: this.previousViewDate, + currentValue: this.viewDate + }); + }); + } + + protected onWrapperFocus(event: FocusEvent) { + event.stopPropagation(); + this.showActiveDay = true; + } + + protected onWrapperBlur(event: FocusEvent) { + event.stopPropagation(); + + this.showActiveDay = false; + this._onTouchedCallback(); + } + + private handlePageUpDown(event: KeyboardEvent, delta: number) { + event.preventDefault(); + event.stopPropagation(); + + if (this.isDefaultView && event.shiftKey) { + this.viewDate = CalendarDay.from(this.viewDate).add('year', delta).native; + this.cdr.detectChanges(); + } else { + delta > 0 ? this.nextPage() : this.previousPage(); + } + } + + private handlePageUp(event: KeyboardEvent) { + this.handlePageUpDown(event, -1); + } + + private handlePageDown(event: KeyboardEvent) { + this.handlePageUpDown(event, 1); + } + + private onArrowUp(event: KeyboardEvent) { + if (this.isDefaultView) { + this.monthsView.onKeydownArrowUp(event); + } + + if (this.isDecadeView) { + this.dacadeView.onKeydownArrowUp(event); + } + } + + private onArrowDown(event: KeyboardEvent) { + if (this.isDefaultView) { + this.monthsView.onKeydownArrowDown(event); + } + + if (this.isDecadeView) { + this.dacadeView.onKeydownArrowDown(event); + } + } + + private onArrowLeft(event: KeyboardEvent) { + if (this.isDefaultView) { + this.monthsView.onKeydownArrowLeft(event); + } + + if (this.isDecadeView) { + this.dacadeView.onKeydownArrowLeft(event); + } + } + + private onArrowRight(event: KeyboardEvent) { + if (this.isDefaultView) { + this.monthsView.onKeydownArrowRight(event); + } + + if (this.isDecadeView) { + this.dacadeView.onKeydownArrowRight(event); + } + } + + private onEnter(event: KeyboardEvent) { + event.stopPropagation(); + + if (this.isDefaultView) { + this.monthsView.onKeydownEnter(event); + } + + if (this.isDecadeView) { + this.dacadeView.onKeydownEnter(event); + } + } + + private onHome(event: KeyboardEvent) { + event.stopPropagation(); + if (this.isDefaultView) { + this.monthsView.onKeydownHome(event); + } + + if (this.isDecadeView) { + this.dacadeView.onKeydownHome(event); + } + } + + private onEnd(event: KeyboardEvent) { + event.stopPropagation(); + if (this.isDefaultView) { + this.monthsView.onKeydownEnd(event); + } + + if (this.isDecadeView) { + this.dacadeView.onKeydownEnd(event); + } + } + + /** + * @hidden + * @internal + */ + public ngOnDestroy(): void { + this.keyboardNavigation.detachKeyboardHandlers(); + } + + /** + * @hidden + * @internal + */ + public getPrevYearDate(date: Date): Date { + return CalendarDay.from(date).add('year', -1).native; + } + + /** + * @hidden + * @internal + */ + public getNextYearDate(date: Date): Date { + return CalendarDay.from(date).add('year', 1).native; + } + + /** + * Getter for the context object inside the calendar templates. + * + * @hidden + * @internal + */ + public getContext(i: number) { + const date = CalendarDay.from(this.viewDate).add('month', i).native; + return this.generateContext(date, i); + } + + /** + * Helper method building and returning the context object inside the calendar templates. + * + * @hidden + * @internal + */ + private generateContext(value: Date | Date[], i?: number) { + const construct = (date: Date, index: number) => ({ + index: index, + date, + ...formatToParts(date, this.locale, this.formatOptions, [ + "era", + "year", + "month", + "day", + "weekday", + ]), + }); + + const formatObject = Array.isArray(value) + ? value.map((date, index) => construct(date, index)) + : construct(value, i); + + return { $implicit: formatObject }; + } +} diff --git a/projects/igniteui-angular/calendar/src/calendar/months-view.pipe.ts b/projects/igniteui-angular/calendar/src/calendar/months-view.pipe.ts new file mode 100644 index 00000000000..447edf0d7d3 --- /dev/null +++ b/projects/igniteui-angular/calendar/src/calendar/months-view.pipe.ts @@ -0,0 +1,36 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { Calendar } from './calendar'; + +/** + * @hidden + */ +@Pipe({ + name: 'IgxMonthViewSlots', + standalone: true +}) +export class IgxMonthViewSlotsCalendar implements PipeTransform { + public transform(monthViews: number) { + return new Array(monthViews); + } +} + +/** + * @hidden + */ +@Pipe({ + name: 'IgxGetViewDate', + standalone: true +}) +export class IgxGetViewDateCalendar implements PipeTransform { + private calendar: Calendar; + constructor() { + this.calendar = new Calendar(); + } + + public transform(index: number, viewDate: Date): Date; + public transform(index: number, viewDate: Date, wholeDate: false): number; + public transform(index: number, viewDate: Date, wholeDate = true) { + const date = this.calendar.timedelta(viewDate, 'month', index); + return wholeDate ? date : date.getMonth(); + } +} diff --git a/projects/igniteui-angular/calendar/src/calendar/months-view/months-view.component.html b/projects/igniteui-angular/calendar/src/calendar/months-view/months-view.component.html new file mode 100644 index 00000000000..8fcda69dff5 --- /dev/null +++ b/projects/igniteui-angular/calendar/src/calendar/months-view/months-view.component.html @@ -0,0 +1,22 @@ +
+ @for (month of range; track monthTracker($index, month)) { + + + + } +
diff --git a/projects/igniteui-angular/calendar/src/calendar/months-view/months-view.component.ts b/projects/igniteui-angular/calendar/src/calendar/months-view/months-view.component.ts new file mode 100644 index 00000000000..e5439bdb1bb --- /dev/null +++ b/projects/igniteui-angular/calendar/src/calendar/months-view/months-view.component.ts @@ -0,0 +1,180 @@ +import { + Component, + Input, + HostBinding, + ElementRef, + booleanAttribute, + inject, +} from "@angular/core"; +import { IgxCalendarMonthDirective } from "../calendar.directives"; +import { TitleCasePipe } from "@angular/common"; +import { + IgxCalendarViewDirective, + DAY_INTERVAL_TOKEN, +} from "../common/calendar-view.directive"; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; +import { CalendarDay, calendarRange, PlatformUtil } from 'igniteui-angular/core'; + +let NEXT_ID = 0; + +@Component({ + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: IgxMonthsViewComponent, + multi: true, + }, + { + provide: DAY_INTERVAL_TOKEN, + useValue: "month", + }, + ], + selector: "igx-months-view", + templateUrl: "months-view.component.html", + imports: [IgxCalendarMonthDirective, TitleCasePipe] +}) +export class IgxMonthsViewComponent extends IgxCalendarViewDirective implements ControlValueAccessor { + public el = inject(ElementRef); + + #standalone = true; + private platform = inject(PlatformUtil); + + /** + * Sets/gets the `id` of the months view. + * If not set, the `id` will have value `"igx-months-view-0"`. + * ```html + * + * ``` + * ```typescript + * let monthsViewId = this.monthsView.id; + * ``` + * + * @memberof IgxMonthsViewComponent + */ + @HostBinding("attr.id") + @Input() + public id = `igx-months-view-${NEXT_ID++}`; + + /** + * The default css class applied to the component. + * + * @hidden + */ + @HostBinding("class.igx-calendar-view") + public readonly viewClass = true; + + /** + * @hidden @internal + */ + @Input() + @HostBinding("class.igx-calendar-view--standalone") + public get standalone() { + return this.#standalone; + } + + public set standalone(value: boolean) { + this.#standalone = value; + } + + /** + * Gets the month format option of the months view. + * ```typescript + * let monthFormat = this.monthsView.monthFormat. + * ``` + */ + @Input() + public get monthFormat(): any { + return this._monthFormat; + } + + /** + * Sets the month format option of the months view. + * ```html + * [monthFormat]="short'" + * ``` + * + * @memberof IgxMonthsViewComponent + */ + public set monthFormat(value: any) { + this._monthFormat = value; + this.initFormatter(); + } + + /** + * Gets/sets whether the view should be rendered + * according to the locale and format, if any. + */ + @Input({ transform: booleanAttribute }) + public override formatView = true; + + /** + * Returns an array of date objects which are then used to + * properly render the month names. + * + * Used in the template of the component + * + * @hidden @internal + */ + public get range(): Date[] { + const start = CalendarDay.from(this.date).set({ date: 1, month: 0 }); + const end = start.add(this.dayInterval, 12); + + return Array.from( + calendarRange({ start, end, unit: this.dayInterval }), + ).map((m) => m.native); + } + + /** + * @hidden + */ + private _monthFormat = "short"; + + /** + * @hidden + */ + protected onMouseDown() { + if (this.tabIndex !== -1 && this.platform.isBrowser && this.el?.nativeElement) { + this.el.nativeElement.focus(); + } + } + + /** + * Returns the locale representation of the month in the months view. + * + * @hidden + */ + public formattedMonth(value: Date): { long: string; formatted: string } { + const rawFormatter = new Intl.DateTimeFormat(this.locale, { + month: "long", + year: "numeric", + }); + + if (this.formatView) { + return { + long: rawFormatter.format(value), + formatted: this._formatter.format(value), + }; + } + + return { + long: rawFormatter.format(value), + formatted: `${value.getMonth()}`, + }; + } + + /** + * @hidden + */ + public monthTracker(_: number, item: Date): string { + return `${item.getMonth()}}`; + } + + /** + * @hidden + */ + protected initFormatter() { + this._formatter = new Intl.DateTimeFormat(this._locale, { + month: this.monthFormat, + }); + } +} diff --git a/projects/igniteui-angular/calendar/src/calendar/public_api.ts b/projects/igniteui-angular/calendar/src/calendar/public_api.ts new file mode 100644 index 00000000000..1de02a55b40 --- /dev/null +++ b/projects/igniteui-angular/calendar/src/calendar/public_api.ts @@ -0,0 +1,29 @@ +import { IgxCalendarComponent } from './calendar.component'; +import { IgxCalendarHeaderTemplateDirective, IgxCalendarHeaderTitleTemplateDirective, IgxCalendarMonthDirective, IgxCalendarSubheaderTemplateDirective, IgxCalendarYearDirective } from './calendar.directives'; +import { IgxDaysViewComponent } from './days-view/days-view.component'; +import { IgxMonthPickerComponent } from './month-picker/month-picker.component'; +import { IgxMonthsViewComponent } from './months-view/months-view.component'; +import { IgxYearsViewComponent } from './years-view/years-view.component'; + +export * from './calendar'; +export * from './calendar.component'; +export * from './calendar.directives'; +export * from './days-view/days-view.component'; +export * from './months-view/months-view.component'; +export * from './years-view/years-view.component'; +export * from './month-picker/month-picker.component'; + +/* NOTE: Calendar directives collection for ease-of-use import in standalone components scenario */ +export const IGX_CALENDAR_DIRECTIVES = [ + IgxCalendarComponent, + IgxDaysViewComponent, + IgxMonthsViewComponent, + IgxYearsViewComponent, + IgxMonthPickerComponent, + IgxCalendarHeaderTemplateDirective, + IgxCalendarHeaderTitleTemplateDirective, + IgxCalendarMonthDirective, + IgxCalendarYearDirective, + IgxCalendarSubheaderTemplateDirective +] as const; + diff --git a/projects/igniteui-angular/calendar/src/calendar/years-view/years-view.component.html b/projects/igniteui-angular/calendar/src/calendar/years-view/years-view.component.html new file mode 100644 index 00000000000..2f83bea48d9 --- /dev/null +++ b/projects/igniteui-angular/calendar/src/calendar/years-view/years-view.component.html @@ -0,0 +1,23 @@ +
+ @for (year of range; track yearTracker($index, year)) { + + + + } +
diff --git a/projects/igniteui-angular/calendar/src/calendar/years-view/years-view.component.ts b/projects/igniteui-angular/calendar/src/calendar/years-view/years-view.component.ts new file mode 100644 index 00000000000..4a25f361dc0 --- /dev/null +++ b/projects/igniteui-angular/calendar/src/calendar/years-view/years-view.component.ts @@ -0,0 +1,159 @@ +import { + Component, + Input, + HostBinding, + ElementRef, + inject, +} from "@angular/core"; +import { IgxCalendarYearDirective } from "../calendar.directives"; +import { + IgxCalendarViewDirective, + DAY_INTERVAL_TOKEN, +} from "../common/calendar-view.directive"; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; +import { CalendarDay, calendarRange, PlatformUtil } from 'igniteui-angular/core'; + +@Component({ + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: IgxYearsViewComponent, + multi: true, + }, + { + provide: DAY_INTERVAL_TOKEN, + useValue: "year", + }, + ], + selector: "igx-years-view", + templateUrl: "years-view.component.html", + imports: [IgxCalendarYearDirective] +}) +export class IgxYearsViewComponent extends IgxCalendarViewDirective implements ControlValueAccessor { + public el = inject(ElementRef); + + #standalone = true; + private platform = inject(PlatformUtil); + + /** + * The default css class applied to the component. + * + * @hidden + */ + @HostBinding("class.igx-calendar-view") + public readonly viewClass = true; + + /** + * @hidden @internal + */ + @Input() + @HostBinding('class.igx-calendar-view--standalone') + public get standalone() { + return this.#standalone; + } + + public set standalone(value: boolean) { + this.#standalone = value; + } + + /** + * @hidden + */ + private _yearFormat = "numeric"; + + /** + * @hidden + */ + private _yearsPerPage = 15; + + /** + * Gets the year format option of the years view. + * ```typescript + * let yearFormat = this.yearsView.yearFormat. + * ``` + */ + @Input() + public get yearFormat(): any { + return this._yearFormat; + } + + /** + * Sets the year format option of the years view. + * ```html + * + * ``` + * + * @memberof IgxYearsViewComponent + */ + public set yearFormat(value: any) { + this._yearFormat = value; + this.initFormatter(); + } + + /** + * Returns an array of date objects which are then used to properly + * render the years. + * + * Used in the template of the component. + * + * @hidden @internal + */ + public get range(): Date[] { + const year = this.date.getFullYear(); + const start = new CalendarDay({ + year: Math.floor(year / this._yearsPerPage) * this._yearsPerPage, + month: this.date.getMonth(), + }); + const end = start.add(this.dayInterval, this._yearsPerPage); + + return Array.from(calendarRange({ start, end, unit: this.dayInterval })).map( + (m) => m.native, + ); + } + + /** + * Returns the locale representation of the year in the years view. + * + * @hidden + */ + public formattedYear(value: Date): {long: string, formatted: string} { + const rawFormatter = new Intl.DateTimeFormat(this.locale, { year: 'numeric' }); + + if (this.formatView) { + return { + long: rawFormatter.format(value), + formatted: this._formatter.format(value) + } + } + + return { + long: rawFormatter.format(value), + formatted: `${value.getFullYear()}` + } + } + + /** + * @hidden + */ + public yearTracker(_: number, item: Date): string { + return `${item.getFullYear()}}`; + } + + /** + * @hidden + */ + protected initFormatter() { + this._formatter = new Intl.DateTimeFormat(this._locale, { + year: this.yearFormat, + }); + } + + /** + * @hidden + */ + protected onMouseDown() { + if (this.tabIndex !== -1 && this.platform.isBrowser && this.el?.nativeElement) { + this.el.nativeElement.focus(); + } + } +} diff --git a/projects/igniteui-angular/calendar/src/public_api.ts b/projects/igniteui-angular/calendar/src/public_api.ts new file mode 100644 index 00000000000..4f4ad762805 --- /dev/null +++ b/projects/igniteui-angular/calendar/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './calendar/public_api'; +export * from './calendar/calendar.module'; diff --git a/projects/igniteui-angular/card/README.md b/projects/igniteui-angular/card/README.md new file mode 100644 index 00000000000..8fc63a240d5 --- /dev/null +++ b/projects/igniteui-angular/card/README.md @@ -0,0 +1,70 @@ +# igx-card + +A walk-through of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/card.html) + +**igx-card** is a sheet of material that serves as an entry point to more detailed information. The cards in Ignite UI for Angular can be composed using the `igx-card-media`, `igx-card-header`, `igx-card-content`, and `igx-card-actions` components and directives. Those card elements ensure aesthetically pleasing design that conforms to the Material Design guidelines. + +Supporting directives and components: +**igx-card-media** is a container for images, videos, or any other type of media; It ensures the content placed inside is sized correctly. +**igx-card-header** is the place to put your `igxCardHeaderTitle`, `igxCardHeaderSubtitle`, and `igxCardHeaderThumbnail`; It will also detect `igx-avatar` components and place them in the thumbnail area for you. +**igx-card-content** is used to wrap any layout you want to appear in the content area of the `igx-card`; +**igx-card-actions** will organize all `igxButton` tagged elements placed in it automatically. + +# Usage +```html + + + +

Elon Musk

+
Entrepreneur
+
+ + + + + + +

South African entrepreneur Elon Musk is known for founding Tesla Motors and SpaceX, which launched a landmark commercial spacecraft in 2012.

+
+ + + + + +
+``` +# API Summary + +## igx-card +| Name | Type | Description | +|:----------|:-------------:|:------| +| `id` | string | Unique identifier of the component. If not provided it will be automatically generated.| +| `type` | IgxCardType | The type of the card component. It can be either `elevated` or `outlined`. | +| `role` | string | The role attribute of the card. By default it's set to `group`. | +| `isCardOutlined` | boolean | Returns `true` if the card is outlined. | +| `horizontal`* | boolean | Sets the card layout direction. When set to `true` the card content is horizontally layed out. | + +## igx-card-header +| Name | Type | Description | +|:----------|:-------------:|:------| +| `vertical` | boolean | Sets the header layout direction. When set to `true` the card content is vertically layed out. | + +## igx-card-media +| Name | Type | Description | +|:----------|:-------------:|:------| +| `role` | string | The role attribute. By default it's set to `img`. | +| `width` | string | Sets the width property. | +| `height` | string | Sets the height property. | + +## igx-card-actions +| Name | Type | Description | +|:----------|:-------------:|:------| +| `layout` | IgxCardActionsLayout | Sets the layout type of the area. Can be either `start` or `justify`. | +| `vertical` | boolean | Sets the layout direction. When set to `true` all buttons in the area will be aligned vertically. | +| `reverse` | boolean | Reverses the layout of the area. When set to `true` all `igx-icons` and/or `igx-button='icon` will appear before all regular(text) buttons. | +| `isJustifyLayout` | boolean | Returns true when the layout type is set to `justify`. | + + +`*` When the `horizontal` property of the card is set to `true`, any `igx-card-actions` between the opening and closing brackets of the `igx-card` component will automatically have their `vertical` property set to true. + +`**` The `igx-card-content` is just a container for the content placed in it. It is used as a layout hook so that whe can arrange it correctly with respect to all other card elements. diff --git a/projects/igniteui-angular/card/index.ts b/projects/igniteui-angular/card/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/card/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/card/ng-package.json b/projects/igniteui-angular/card/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/card/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/card/src/card/card-actions.component.html b/projects/igniteui-angular/card/src/card/card-actions.component.html new file mode 100644 index 00000000000..2802a6664df --- /dev/null +++ b/projects/igniteui-angular/card/src/card/card-actions.component.html @@ -0,0 +1,9 @@ +
+ +
+ + + +
+ +
diff --git a/projects/igniteui-angular/card/src/card/card-header.component.html b/projects/igniteui-angular/card/src/card/card-header.component.html new file mode 100644 index 00000000000..d8f745670a2 --- /dev/null +++ b/projects/igniteui-angular/card/src/card/card-header.component.html @@ -0,0 +1,15 @@ +
+ +
+ +
+ + +
+ + diff --git a/projects/igniteui-angular/card/src/card/card.component.html b/projects/igniteui-angular/card/src/card/card.component.html new file mode 100644 index 00000000000..6dbc7430638 --- /dev/null +++ b/projects/igniteui-angular/card/src/card/card.component.html @@ -0,0 +1 @@ + diff --git a/projects/igniteui-angular/card/src/card/card.component.ts b/projects/igniteui-angular/card/src/card/card.component.ts new file mode 100644 index 00000000000..eeb346f783b --- /dev/null +++ b/projects/igniteui-angular/card/src/card/card.component.ts @@ -0,0 +1,332 @@ +import { Component, Directive, HostBinding, Input, OnInit, OnChanges, SimpleChanges, booleanAttribute, inject } from '@angular/core'; + +let NEXT_ID = 0; + +/** + * IgxCardMedia is container for the card media section. + * Use it to wrap images and videos. + */ +@Directive({ + selector: 'igx-card-media', + standalone: true +}) +export class IgxCardMediaDirective { + /** @hidden @internal */ + @HostBinding('class.igx-card__media') + public cssClass = 'igx-card__media'; + + /** + * Sets the `width` and `min-width` style property + * of the media container. If not provided it will be set to `auto`. + * + * @example + * ```html + * + * ``` + */ + @HostBinding('style.width') + @HostBinding('style.min-width') + @Input() + public width = 'auto'; + + /** + * Sets the `height` style property of the media container. + * If not provided it will be set to `auto`. + * + * @example + * ```html + * + * ``` + */ + @HostBinding('style.height') + @Input() + public height = 'auto'; + + /** + * Sets the `role` attribute of the media container. + */ + @HostBinding('attr.role') + @Input() + public role = 'img'; +} + +/** + * IgxCardHeader is container for the card header + */ +@Component({ + selector: 'igx-card-header', + templateUrl: 'card-header.component.html', + standalone: true +}) +export class IgxCardHeaderComponent { + /** @hidden @internal */ + @HostBinding('class.igx-card-header') + public cssClass = 'igx-card-header'; + + /** + * Sets the layout style of the header. + * By default the header elements(thumbnail and title/subtitle) are aligned horizontally. + * + * @example + * ```html + * + * ``` + */ + @HostBinding('class.igx-card-header--vertical') + @Input({ transform: booleanAttribute }) + public vertical = false; +} + +/** + * IgxCardThumbnail is container for the card thumbnail section. + * Use it to wrap anything you want to be used as a thumbnail. + */ +@Directive({ + selector: '[igxCardThumbnail]', + standalone: true +}) +export class IgxCardThumbnailDirective { } + +/** + * igxCardHeaderTitle is used to denote the header title in a card. + * Use it to tag text nodes. + */ +@Directive({ + selector: '[igxCardHeaderTitle]', + standalone: true +}) +export class IgxCardHeaderTitleDirective { + /** @hidden @internal */ + @HostBinding('class.igx-card-header__title') + public cssClass = 'igx-card__header__title'; +} + +/** + * igxCardHeaderSubtitle is used to denote the header subtitle in a card. + * Use it to tag text nodes. + */ +@Directive({ + selector: '[igxCardHeaderSubtitle]', + standalone: true +}) +export class IgxCardHeaderSubtitleDirective { + /** @hidden @internal */ + @HostBinding('class.igx-card-header__subtitle') + public cssClass = 'igx-card-header__subtitle'; +} +/** + * IgxCardContent is container for the card content. + */ +@Directive({ + + selector: 'igx-card-content', + standalone: true +}) +export class IgxCardContentDirective { + /** @hidden @internal */ + @HostBinding('class.igx-card-content') + public cssClass = 'igx-card-content'; +} + +/** + * IgxCardFooter is container for the card footer + */ +@Directive({ + + selector: 'igx-card-footer', + standalone: true +}) +export class IgxCardFooterDirective { + /** + * Sets the value of the `role` attribute of the card footer. + * By default the value is set to `footer`. + * + * @example + * ```html + * + * ``` + */ + @HostBinding('attr.role') + @Input() + public role = 'footer'; +} + +/** + * Card provides a way to display organized content in appealing way. + * + * @igxModule IgxCardModule + * + * @igxTheme igx-card-theme, igx-icon-theme, igx-button-theme + * + * @igxKeywords card, button, avatar, icon + * + * @igxGroup Layouts + * + * @remarks + * The Ignite UI Card serves as a container that allows custom content to be organized in an appealing way. There are + * five sections in a card that you can use to organize your content. These are header, media, content, actions, and footer. + * + * @example + * ```html + * + * + *

{{title}}

+ *
{{subtitle}}
+ *
+ * + * + * + * + *
+ * ``` + */ + +@Component({ + selector: 'igx-card', + templateUrl: 'card.component.html', + standalone: true +}) +export class IgxCardComponent { + /** + * Sets/gets the `id` of the card. + * If not set, `id` will have value `"igx-card-0"`; + * + * @example + * ```html + * + * ``` + * ```typescript + * let cardId = this.card.id; + * ``` + */ + @HostBinding('attr.id') + @Input() + public id = `igx-card-${NEXT_ID++}`; + + /** + * Sets the `igx-card` css class to the card component. + * + * @hidden + * @internal + */ + @HostBinding('class.igx-card') + public cssClass = 'igx-card'; + + /** + * Sets the value of the `role` attribute of the card. + * By default the value is set to `group`. + * + * @example + * ```html + * + * ``` + */ + @HostBinding('attr.role') + @Input() + public role = 'group'; + + /** + * Sets/gets whether the card is elevated. + * Default value is `false`. + * + * @example + * ```html + * + * ``` + * ```typescript + * let cardElevation = this.card.elevated; + * ``` + */ + @Input({transform: booleanAttribute}) + @HostBinding('class.igx-card--elevated') + public elevated = false; + + /** + * Sets the value of the `horizontal` attribute of the card. + * Setting this to `true` will make the different card sections align horizontally, + * essentially flipping the card to the side. + * + * @example + * ```html + * + * ``` + */ + @HostBinding('class.igx-card--horizontal') + @Input({ transform: booleanAttribute }) + public horizontal = false; +} + +export const IgxCardActionsLayout = { + START: 'start', + JUSTIFY: 'justify' +} as const; +export type IgxCardActionsLayout = (typeof IgxCardActionsLayout)[keyof typeof IgxCardActionsLayout]; + +/** + * IgxCardActions is container for the card actions. + */ +@Component({ + + selector: 'igx-card-actions', + templateUrl: 'card-actions.component.html', + standalone: true +}) +export class IgxCardActionsComponent implements OnInit, OnChanges { + public card = inject(IgxCardComponent, { optional: true }); + + /** + * Sets the layout style of the actions. + * You can justify the elements slotted in the igx-card-action container + * so that they are positioned equally from one another taking up all the + * space available along the card actions axis. + * + * @example + * ```html + * + * ``` + */ + @HostBinding('class.igx-card-actions') + @Input() + public layout: IgxCardActionsLayout | string = IgxCardActionsLayout.START; + + /** + * Sets the vertical attribute of the actions. + * When set to `true` the actions will be layed out vertically. + */ + @HostBinding('class.igx-card-actions--vertical') + @Input({ transform: booleanAttribute }) + public vertical = false; + + /** + * A getter that returns `true` when the layout has been + * set to `justify`. + */ + @HostBinding('class.igx-card-actions--justify') + public get isJustifyLayout() { + return this.layout === IgxCardActionsLayout.JUSTIFY; + } + + private isVerticalSet = false; + + /** + * @hidden + * @internal + */ + public ngOnChanges(changes: SimpleChanges) { + for (const prop in changes) { + if (prop === 'vertical') { + this.isVerticalSet = true; + } + } + } + + /** + * @hidden + * @internal + */ + public ngOnInit() { + if (!this.isVerticalSet && this.card.horizontal) { + this.vertical = true; + } + } +} diff --git a/projects/igniteui-angular/card/src/card/card.module.ts b/projects/igniteui-angular/card/src/card/card.module.ts new file mode 100644 index 00000000000..19d252da76f --- /dev/null +++ b/projects/igniteui-angular/card/src/card/card.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { IGX_CARD_DIRECTIVES } from './public_api'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + ...IGX_CARD_DIRECTIVES + ], + exports: [ + ...IGX_CARD_DIRECTIVES + ] +}) +export class IgxCardModule { } diff --git a/projects/igniteui-angular/card/src/card/card.spec.ts b/projects/igniteui-angular/card/src/card/card.spec.ts new file mode 100644 index 00000000000..3c148b30d43 --- /dev/null +++ b/projects/igniteui-angular/card/src/card/card.spec.ts @@ -0,0 +1,322 @@ +import { Component, ViewChild } from '@angular/core'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { + IgxCardComponent, + IgxCardThumbnailDirective, + IgxCardHeaderTitleDirective, + IgxCardHeaderSubtitleDirective, + IgxCardActionsComponent, + IgxCardMediaDirective, + IgxCardHeaderComponent, + IgxCardContentDirective, +} from './card.component'; + +import { IgxButtonDirective } from '../../../directives/src/directives/button/button.directive'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxIconButtonDirective } from '../../../directives/src/directives/button/icon-button.directive'; + +describe('Card', () => { + // TODO: Refactor card tests to reuse components + const baseClass = 'igx-card'; + + const classes = { + outlined: `${baseClass}--outlined`, + elevated: `${baseClass}--elevated`, + horizontal: `${baseClass}--horizontal`, + header: { + base: `${baseClass}-header`, + get vertical() { + return `${this.base}--vertical`; + }, + get thumb() { + return `${this.base}__thumbnail`; + }, + get title() { + return `${this.base}__title`; + }, + get subtitle() { + return `${this.base}__subtitle`; + }, + get titles() { + return `${this.base}__titles`; + } + }, + actions: { + base: `${baseClass}-actions`, + get vertical() { + return `${this.base}--vertical`; + }, + get justify() { + return `${this.base}--justify`; + }, + get end() { + return `${this.base}__end`; + }, + get start() { + return `${this.base}__start`; + } + }, + media: `${baseClass}__media` + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + InitCardComponent, + InitOutlinedCardComponent, + CardWithHeaderComponent, + CardContentIconComponent, + VerticalCardComponent, + HorizontalCardComponent + ] + }).compileComponents(); + })); + + it('Initializes default card', () => { + const fixture = TestBed.createComponent(InitCardComponent); + fixture.detectChanges(); + + const card = fixture.debugElement.query(By.css('igx-card')).nativeElement; + + expect(card).toBeDefined(); + expect(card.getAttribute('role')).toEqual('group'); + + expect(card).toHaveClass(`${baseClass}`); + expect(card).not.toHaveClass(classes.elevated); + expect(card).not.toHaveClass(classes.horizontal); + expect(card.id).toContain(`${baseClass}-`); + }); + + it('Initializes horizontal card', () => { + const fixture = TestBed.createComponent(HorizontalCardComponent); + fixture.detectChanges(); + + const instance = fixture.componentInstance.card; + const card = fixture.debugElement.query(By.css(baseClass)).nativeElement; + + expect(instance.horizontal).toEqual(true); + expect(card).toHaveClass(classes.horizontal); + }); + + it('Initializes card header', () => { + const fixture = TestBed.createComponent(CardWithHeaderComponent); + fixture.detectChanges(); + + const header = fixture.debugElement.query(By.css('igx-card-header')).nativeElement; + + expect(header).toBeDefined(); + // K.D. March 20th, 2023 #12792 Card header should have no role + // expect(header.getAttribute('role')).toEqual('header'); + expect(header.getAttribute('role')).toBeNull(); + + expect(header).toHaveClass(classes.header.base); + expect(header).not.toHaveClass(classes.header.vertical); + }); + + it('Initializes vertical card header', () => { + const fixture = TestBed.createComponent(VerticalCardComponent); + fixture.detectChanges(); + + const header = fixture.debugElement.query(By.css('igx-card-header')).nativeElement; + expect(header).toHaveClass(classes.header.vertical); + }); + + it('Initializes title, subtitle, and thumb in header', () => { + const fixture = TestBed.createComponent(VerticalCardComponent); + fixture.detectChanges(); + + const thumb = fixture.debugElement.query(By.directive(IgxCardThumbnailDirective)); + const title = fixture.debugElement.query(By.directive(IgxCardHeaderTitleDirective)); + const subtitle = fixture.debugElement.query(By.directive(IgxCardHeaderSubtitleDirective)); + + // Check to see if thumbnail has been inserted in the thumbnail section; + expect(thumb.parent.nativeElement).toHaveClass(classes.header.thumb); + + // Check to see if the title and subtitle have been + // inserted in the titles section; + expect(title.parent.nativeElement).toHaveClass(classes.header.titles); + expect(subtitle.parent.nativeElement).toHaveClass(classes.header.titles); + + expect(title.nativeElement).toHaveClass(classes.header.title); + expect(subtitle.nativeElement).toHaveClass(classes.header.subtitle); + + // Validate Content + expect(thumb.nativeElement.textContent).toEqual('Thumb'); + expect(title.nativeElement.textContent).toEqual('Title'); + expect(subtitle.nativeElement.textContent).toEqual('Subtitle'); + }); + + it('Initializes content', () => { + const fixture = TestBed.createComponent(VerticalCardComponent); + fixture.detectChanges(); + + const content = fixture.debugElement.query(By.css('igx-card-content')).nativeElement; + + expect(content).toBeDefined(); + expect(content.textContent).toEqual('Test Content'); + }); + + it('Initializes card media', () => { + const fixture = TestBed.createComponent(VerticalCardComponent); + fixture.detectChanges(); + + const media = fixture.debugElement.query(By.css('igx-card-media')); + const mediaContent = media.query(By.css('div')).nativeElement; + + expect(media).toBeDefined(); + expect(mediaContent.textContent).toEqual('media'); + expect(media.nativeElement).toHaveClass(classes.media); + }); + + it('Initializes actions with buttons', () => { + const fixture = TestBed.createComponent(VerticalCardComponent); + fixture.detectChanges(); + + const actions = fixture.debugElement.query(By.css('igx-card-actions')).nativeElement; + + expect(actions).toBeDefined(); + expect(actions).toHaveClass(classes.actions.base); + expect(actions).not.toHaveClass(classes.actions.justify); + expect(actions).not.toHaveClass(classes.actions.vertical); + }); + + it('Should use Material Icons font-family for igx-icon in card content', () => { + const fixture = TestBed.createComponent(CardContentIconComponent); + fixture.detectChanges(); + + const iconElement = fixture.debugElement.query(By.css('igx-icon')).nativeElement; + const computedStyle = window.getComputedStyle(iconElement); + + expect(computedStyle.fontFamily).toBe('"Material Icons"'); + }); + + it('Should automatically align actions vertically when in horizontal layout', () => { + const fixture = TestBed.createComponent(HorizontalCardComponent); + fixture.detectChanges(); + + const actionsInstance = fixture.componentInstance.actions; + const actionsElement = fixture.debugElement.query(By.css('igx-card-actions')).nativeElement; + + expect(actionsInstance.vertical).toEqual(true); + expect(actionsElement).toHaveClass(classes.actions.vertical); + }); + + it('Should align actions horizontally and vertically when explicitly set', () => { + const fixture = TestBed.createComponent(HorizontalCardComponent); + fixture.detectChanges(); + + const actionsInstance = fixture.componentInstance.actions; + const actionsElement = fixture.debugElement.query(By.css('igx-card-actions')).nativeElement; + + actionsInstance.vertical = false; + fixture.detectChanges(); + + expect(actionsInstance.vertical).toEqual(false); + expect(actionsElement).not.toHaveClass(classes.actions.vertical); + }); + + it('Should display icon buttons after regular buttons by default', () => { + const fixture = TestBed.createComponent(HorizontalCardComponent); + fixture.detectChanges(); + + const actionsElement = fixture.debugElement.query(By.css('igx-card-actions')); + + const buttons = actionsElement.query(By.css(`.${classes.actions.start}`)).nativeElement; + const icons = actionsElement.query(By.css(`.${classes.actions.end}`)).nativeElement; + + const buttonsOrder = window.getComputedStyle(buttons).getPropertyValue('order'); + const iconsOrder = window.getComputedStyle(icons).getPropertyValue('order'); + + expect(parseInt(buttonsOrder, 10)).toBeLessThan(parseInt(iconsOrder, 10)); + }); +}); + +@Component({ + template: ``, + imports: [IgxCardComponent] +}) +class InitCardComponent { } + +@Component({ + template: ``, + imports: [IgxCardComponent] +}) +class InitOutlinedCardComponent { + @ViewChild(IgxCardComponent, { static: true }) + public card: IgxCardComponent; +} + +@Component({ + template: ` + + `, + imports: [IgxCardComponent, IgxCardHeaderComponent] +}) +class CardWithHeaderComponent { } + +@Component({ + template: ` + + face + + `, + imports: [IgxCardComponent, IgxCardContentDirective, IgxIconComponent] +}) +class CardContentIconComponent { } + +@Component({ + template: ` + +
media
+ + + +
Thumb
+

Title

+
Subtitle
+
+ + Test Content + + + + + + `, + imports: [ + IgxCardComponent, + IgxCardMediaDirective, + IgxCardHeaderComponent, + IgxCardThumbnailDirective, + IgxCardHeaderTitleDirective, + IgxCardHeaderSubtitleDirective, + IgxCardContentDirective, + IgxCardActionsComponent, + IgxButtonDirective, + IgxIconComponent, + IgxIconButtonDirective + ] +}) +class VerticalCardComponent { + @ViewChild(IgxCardMediaDirective, { static: true }) public media: IgxCardMediaDirective; +} + +@Component({ + template: ` + + + + + + `, + imports: [IgxCardComponent, IgxCardActionsComponent, IgxButtonDirective, IgxIconComponent, IgxIconButtonDirective] +}) +class HorizontalCardComponent { + @ViewChild(IgxCardComponent, { static: true }) public card: IgxCardComponent; + @ViewChild(IgxCardActionsComponent, { static: true }) public actions: IgxCardActionsComponent; +} diff --git a/projects/igniteui-angular/card/src/card/public_api.ts b/projects/igniteui-angular/card/src/card/public_api.ts new file mode 100644 index 00000000000..c243c9588c2 --- /dev/null +++ b/projects/igniteui-angular/card/src/card/public_api.ts @@ -0,0 +1,26 @@ +import { + IgxCardActionsComponent, + IgxCardComponent, + IgxCardContentDirective, + IgxCardFooterDirective, + IgxCardHeaderComponent, + IgxCardHeaderSubtitleDirective, + IgxCardHeaderTitleDirective, + IgxCardMediaDirective, + IgxCardThumbnailDirective +} from './card.component'; + +export * from './card.component'; + +/* NOTE: Card directives collection for ease-of-use import in standalone components scenario */ +export const IGX_CARD_DIRECTIVES = [ + IgxCardComponent, + IgxCardHeaderComponent, + IgxCardMediaDirective, + IgxCardContentDirective, + IgxCardActionsComponent, + IgxCardFooterDirective, + IgxCardHeaderTitleDirective, + IgxCardHeaderSubtitleDirective, + IgxCardThumbnailDirective +] as const; diff --git a/projects/igniteui-angular/card/src/public_api.ts b/projects/igniteui-angular/card/src/public_api.ts new file mode 100644 index 00000000000..b3b75219dfb --- /dev/null +++ b/projects/igniteui-angular/card/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './card/public_api'; +export * from './card/card.module'; diff --git a/projects/igniteui-angular/carousel/README.md b/projects/igniteui-angular/carousel/README.md new file mode 100644 index 00000000000..9f912772346 --- /dev/null +++ b/projects/igniteui-angular/carousel/README.md @@ -0,0 +1,103 @@ +# igx-carousel + +A carousel component is used to browse or navigate through a collection of slides - galleries of images, +cards, on-boarding tutorials or page-based interfaces. It can be used as a separate full screen element +or inside another component. +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/carousel.html) + +# API Summary `igx-carousel` +| Name | Type | Description | +|:----------|:-------------:|:------| +| `id` | string | Unique identifier of the component. If not provided it will be automatically generated.| +| `loop` | boolean | Should the carousel wrap back to the first slide after it reaches the last. Defaults to `true`. | +| `pause` | boolean | Should the carousel stop playing on user interaction. Defaults to `true`. | +| `interval` | number | The amount of time in milliseconds between slides transition. | +| `navigation` | boolean | Controls should the carousel render the left/right navigation buttons. Defaults to `true`. | +| `indicators` | boolean | Controls should the carousel render the indicators. Defaults to `true`. | +| `vertical` | boolean | Controls should the carousel be rendered in vertical alignment. Defaults to `false`. | +| `gesturesSupport` | boolean | Controls should the gestures should be supported. Defaults to `true`. | +| `maximumIndicatorsCount` | number | The number of visible indicators. Defaults to `10`. | +| `indicatorsOrientation` | CarouselIndicatorsOrientation | Controls the orientation of the indicators. Defaults to `end`. | +| `animationType` | CarouselAnimationType | Controls what animation should be played when slides are changing. Defaults to `slide`. | +| `total` | number | The number of slides the carousel currently has. | +| `current` | number | The index of the slide currently showing. | +| `isPlaying` | boolean | Returns whether the carousel is paused/playing. | +| `isDestroyed` | boolean | If the carousel is destroyed (`ngOnDestroy` was called) | +| `slideChanged` | event | Emitted on slide change | +| `slideAdded` | event | Emitted when a slide is being added to the carousel | +| `slideRemoved`| event | Emitted whe a slide is being removed from the carousel | +| `carouselPaused` | event | Emitted when the carousel is pausing. | +| `carouselPlaying`| event | Emitted when the carousel starts/resumes playing. | +| `play()` | void | Emits `carouselPlaying` event and starts the transition between slides. | +| `stop()` | void | Emits `carouselPaused` event and stops the transition between slides. | +| `prev()` | void | Switches to the previous slide. Emits `slideChanged` event. | +| `next()` | void | Switches to the next slide. Emits `slideChanged` event. | +| `add(slide: IgxSlide)` | void | Adds a slide to the carousel. Emits `slideAdded` event. | +| `remove(slide: IgxSlide)` | void | Removes an existing slide from the carousel. Emits `slideRemoved` event. | +| `get(index: number)` | IgxSlide or void | Returns the slide with the given index or null. | +| `select(slide: IgxSlide, direction: Direction)`| void | Switches to the passed-in slide with a given direction. Emits `slideChanged` event. | +| `select(index: number, direction: Direction)`| void | Switches to slide by index with a given direction. Emits `slideChanged` event. | + +### Keyboard navigation + +- Navigation buttons + - `Space`/`Enter` key - navigates to the next/previous slide. +- Indicators + - `ArrowLeft` key - navigates to the previous (next in Right-to-Left mode) slide. + - `ArrowRight` key - navigates to the next (previous in Right-to-Left mode) slide. + - `Home` key - navigates to the first (last in Right-to-Left mode) slide. + - `End` key - navigates to the last (first in Right-to-Left mode) slide. + +### Templates +The **IgxCarousel** supports templating indicators and navigation buttons + +#### Defining item template: +```html + + ... + + brightness_7 + brightness_5 + + +``` + +#### Defining next button template: +```html + + ... + + + + +``` + +#### Defining previous button template: +```html + + ... + + + + +``` + +# API Summary `igx-slide` +| Name | Type | Description | +|:----------|:-------------:|:------| +| `index` | number | The index of the slide inside the carousel. | +| `direction` | Direction | The direction in which the slide should transition. Possibly values are `NONE`, `NEXT`, `PREV` | +| `active`| boolean | Whether the current slide is active, i.e. the one being currently displayed by the carousel. | + +# Usage +```html + + + + + +``` diff --git a/projects/igniteui-angular/carousel/index.ts b/projects/igniteui-angular/carousel/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/carousel/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/carousel/ng-package.json b/projects/igniteui-angular/carousel/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/carousel/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/carousel/src/carousel/carousel-base.ts b/projects/igniteui-angular/carousel/src/carousel/carousel-base.ts new file mode 100644 index 00000000000..e2b7eb87bf1 --- /dev/null +++ b/projects/igniteui-angular/carousel/src/carousel/carousel-base.ts @@ -0,0 +1,195 @@ +import { AnimationReferenceMetadata, useAnimation } from '@angular/animations'; +import { ChangeDetectorRef, Directive, EventEmitter, inject, OnDestroy } from '@angular/core'; +import { IgxAngularAnimationService } from 'igniteui-angular/core'; +import { AnimationPlayer, AnimationService } from 'igniteui-angular/core'; +import { fadeIn, slideInLeft } from 'igniteui-angular/animations'; +import { CarouselAnimationType } from './enums'; + +export enum CarouselAnimationDirection { NONE, NEXT, PREV } + +export interface CarouselAnimationSettings { + enterAnimation: AnimationReferenceMetadata; + leaveAnimation: AnimationReferenceMetadata; +} + +/** @hidden */ +export interface IgxSlideComponentBase { + direction: CarouselAnimationDirection; + previous: boolean; +} + +/** @hidden */ +@Directive() +export abstract class IgxCarouselComponentBase implements OnDestroy { + private animationService = inject(IgxAngularAnimationService); + protected cdr = inject(ChangeDetectorRef); + + /** @hidden */ + public animationType: CarouselAnimationType = CarouselAnimationType.slide; + + /** @hidden @internal */ + public enterAnimationDone = new EventEmitter(); + /** @hidden @internal */ + public leaveAnimationDone = new EventEmitter(); + + /** @hidden */ + protected currentItem: IgxSlideComponentBase; + /** @hidden */ + protected previousItem: IgxSlideComponentBase; + /** @hidden */ + protected enterAnimationPlayer?: AnimationPlayer; + /** @hidden */ + protected leaveAnimationPlayer?: AnimationPlayer; + /** @hidden */ + protected defaultAnimationDuration = 320; + /** @hidden */ + protected animationPosition = 0; + /** @hidden */ + protected newDuration = 0; + /** @hidden */ + protected vertical = false; + + public ngOnDestroy(): void { + if (this.enterAnimationPlayer) { + this.enterAnimationPlayer.destroy(); + this.enterAnimationPlayer = null; + } + if (this.leaveAnimationPlayer) { + this.leaveAnimationPlayer.destroy(); + this.leaveAnimationPlayer = null; + } + } + + /** @hidden */ + protected triggerAnimations() { + if (this.animationType !== CarouselAnimationType.none) { + if (this.animationStarted(this.leaveAnimationPlayer) || this.animationStarted(this.enterAnimationPlayer)) { + requestAnimationFrame(() => { + this.resetAnimations(); + this.playAnimations(); + }); + } else { + this.playAnimations(); + } + } + } + + /** @hidden */ + protected animationStarted(animation: AnimationPlayer): boolean { + return animation && animation.hasStarted(); + } + + /** @hidden */ + protected playAnimations() { + this.playLeaveAnimation(); + this.playEnterAnimation(); + } + + private resetAnimations() { + if (this.animationStarted(this.leaveAnimationPlayer)) { + this.leaveAnimationPlayer.reset(); + this.leaveAnimationDone.emit(); + } + + if (this.animationStarted(this.enterAnimationPlayer)) { + this.enterAnimationPlayer.reset(); + this.enterAnimationDone.emit(); + this.cdr.markForCheck(); + } + } + + private getAnimation(): CarouselAnimationSettings { + let duration; + if (this.newDuration) { + duration = this.animationPosition ? this.animationPosition * this.newDuration : this.newDuration; + } else { + duration = this.animationPosition ? this.animationPosition * this.defaultAnimationDuration : this.defaultAnimationDuration; + } + + const trans = this.animationPosition ? this.animationPosition * 100 : 100; + switch (this.animationType) { + case CarouselAnimationType.slide: + return { + enterAnimation: useAnimation(slideInLeft, + { + params: { + delay: '0s', + duration: `${duration}ms`, + endOpacity: 1, + startOpacity: 1, + fromPosition: `${this.vertical ? 'translateY' : 'translateX'}(${this.currentItem.direction === 1 ? trans : -trans}%)`, + toPosition: `${this.vertical ? 'translateY(0%)' : 'translateX(0%)'}` + } + }), + leaveAnimation: useAnimation(slideInLeft, + { + params: { + delay: '0s', + duration: `${duration}ms`, + endOpacity: 1, + startOpacity: 1, + fromPosition: `${this.vertical ? 'translateY(0%)' : 'translateX(0%)'}`, + toPosition: `${this.vertical ? 'translateY' : 'translateX'}(${this.currentItem.direction === 1 ? -trans : trans}%)`, + } + }) + }; + case CarouselAnimationType.fade: + return { + enterAnimation: useAnimation(fadeIn, + { params: { duration: `${duration}ms`, startOpacity: `${this.animationPosition}` } }), + leaveAnimation: null + }; + } + return { + enterAnimation: null, + leaveAnimation: null + }; + } + + private playEnterAnimation() { + const animation = this.getAnimation().enterAnimation; + if (!animation) { + return; + } + + this.enterAnimationPlayer = this.animationService.buildAnimation(animation, this.getCurrentElement()); + this.enterAnimationPlayer.animationEnd.subscribe(() => { + // TODO: animation may never end. Find better way to clean up the player + if (this.enterAnimationPlayer) { + this.enterAnimationPlayer.destroy(); + this.enterAnimationPlayer = null; + } + this.animationPosition = 0; + this.newDuration = 0; + this.previousItem.previous = false; + this.enterAnimationDone.emit(); + this.cdr.markForCheck(); + }); + this.previousItem.previous = true; + this.enterAnimationPlayer.play(); + } + + private playLeaveAnimation() { + const animation = this.getAnimation().leaveAnimation; + if (!animation) { + return; + } + + this.leaveAnimationPlayer = this.animationService.buildAnimation(animation, this.getPreviousElement()); + this.leaveAnimationPlayer.animationEnd.subscribe(() => { + // TODO: animation may never end. Find better way to clean up the player + if (this.leaveAnimationPlayer) { + this.leaveAnimationPlayer.destroy(); + this.leaveAnimationPlayer = null; + } + this.animationPosition = 0; + this.newDuration = 0; + this.leaveAnimationDone.emit(); + }); + this.leaveAnimationPlayer.play(); + } + + protected abstract getPreviousElement(): HTMLElement; + + protected abstract getCurrentElement(): HTMLElement; +} diff --git a/projects/igniteui-angular/carousel/src/carousel/carousel.component.html b/projects/igniteui-angular/carousel/src/carousel/carousel.component.html new file mode 100644 index 00000000000..d79dc110577 --- /dev/null +++ b/projects/igniteui-angular/carousel/src/carousel/carousel.component.html @@ -0,0 +1,69 @@ + +
+
+
+ + + + + + + + + +@if (navigation && slides.length) { + +} + +@if (navigation && slides.length) { + +} + +@if (showIndicators) { +
+ @for (slide of slides; track slide) { + + } +
+} + +@if (showIndicatorsLabel) { + +} + + diff --git a/projects/igniteui-angular/carousel/src/carousel/carousel.component.spec.ts b/projects/igniteui-angular/carousel/src/carousel/carousel.component.spec.ts new file mode 100644 index 00000000000..74ddfc755c9 --- /dev/null +++ b/projects/igniteui-angular/carousel/src/carousel/carousel.component.spec.ts @@ -0,0 +1,1291 @@ +import { Component, ViewChild, TemplateRef, ChangeDetectionStrategy, ElementRef } from '@angular/core'; +import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { + IgxCarouselComponent, + ISlideEventArgs +} from './carousel.component'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxSlideComponent } from './slide.component'; +import { IgxCarouselIndicatorDirective, IgxCarouselNextButtonDirective, IgxCarouselPrevButtonDirective } from './carousel.directives'; +import { CarouselIndicatorsOrientation, CarouselAnimationType } from './enums'; +import { UIInteractions, wait } from 'igniteui-angular/test-utils/ui-interactions.spec'; + +describe('Carousel', () => { + let fixture; + let carousel: IgxCarouselComponent; + let mockElement: any; + let mockElementRef: ElementRef; + + beforeEach(waitForAsync(() => { + mockElement = document.createElement("div"); + mockElementRef = new ElementRef(mockElement); + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + CarouselTestComponent, + CarouselTemplateSetInMarkupTestComponent, + CarouselTemplateSetInTypescriptTestComponent, + CarouselAnimationsComponent, + CarouselDynamicSlidesComponent + ], + providers: [ + { provide: ElementRef, useValue: mockElementRef }, + IgxSlideComponent + ] + }).compileComponents(); + })); + + describe('Base Tests: ', () => { + beforeEach(() => { + fixture = TestBed.createComponent(CarouselTestComponent); + carousel = fixture.componentInstance.carousel; + fixture.detectChanges(); + }); + + it('should initialize a carousel with id property', () => { + const domCarousel = fixture.debugElement.query(By.css('igx-carousel')).nativeElement; + + expect(carousel.id).toContain('igx-carousel-'); + expect(domCarousel.id).toContain('igx-carousel-'); + + carousel.id = 'cusrtomCarousel'; + fixture.detectChanges(); + + expect(carousel.id).toBe('cusrtomCarousel'); + expect(domCarousel.id).toBe('cusrtomCarousel'); + }); + + it('should initialize a carousel with four slides and then destroy it', () => { + const domCarousel = fixture.debugElement.query(By.css('igx-carousel')).nativeElement; + expect(carousel).toBeDefined(); + expect(carousel.id).toContain('igx-carousel-'); + expect(domCarousel.id).toContain('igx-carousel-'); + expect(carousel instanceof IgxCarouselComponent).toBe(true); + expect(carousel.slides.first instanceof IgxSlideComponent).toBe(true); + + expect(carousel.loop).toBe(true); + expect(carousel.pause).toBe(true); + expect(carousel.slides.length).toEqual(4); + expect(carousel.interval).toEqual(2500); + + carousel.ngOnDestroy(); + fixture.detectChanges(); + expect(carousel.isDestroyed).toBe(true); + }); + + it('disabled looping', () => { + carousel.loop = false; + fixture.detectChanges(); + carousel.next(); + carousel.next(); + carousel.next(); + fixture.detectChanges(); + HelperTestFunctions.verifyActiveSlide(carousel, 3); + + carousel.next(); + fixture.detectChanges(); + HelperTestFunctions.verifyActiveSlide(carousel, 3); + + carousel.prev(); + carousel.prev(); + carousel.prev(); + fixture.detectChanges(); + HelperTestFunctions.verifyActiveSlide(carousel, 0); + + carousel.prev(); + fixture.detectChanges(); + HelperTestFunctions.verifyActiveSlide(carousel, 0); + }); + + it('getter/setter tests', () => { + carousel.loop = false; + carousel.pause = false; + carousel.interval = 500; + carousel.navigation = false; + + fixture.detectChanges(); + + expect(carousel.loop).toBe(false); + expect(carousel.pause).toBe(false); + expect(carousel.interval).toEqual(500); + expect(carousel.navigation).toBe(false); + }); + + it('add/remove slides tests', () => { + const slide1 = carousel.get(carousel.current); + carousel.remove(slide1); + + fixture.detectChanges(); + expect(carousel.slides.length).toEqual(3); + expect(carousel.total).toEqual(3); + + const slide2 = carousel.get(carousel.current); + carousel.remove(slide2); + + fixture.detectChanges(); + expect(carousel.slides.length).toEqual(2); + expect(carousel.total).toEqual(2); + + carousel.add(slide1); + carousel.add(slide2); + + fixture.detectChanges(); + expect(carousel.slides.length).toEqual(4); + expect(carousel.total).toEqual(4); + }); + + it('checking if a slide is not active when it gets removed', () => { + const currentSlide = carousel.get(carousel.current); + carousel.remove(currentSlide); + + fixture.detectChanges(); + expect(currentSlide.active).toBe(false); + }); + + it('public methods', () => { + carousel.stop(); + + fixture.detectChanges(); + expect(carousel.isPlaying).toBe(false); + + carousel.next(); + let currentSlide = carousel.get(carousel.current); + fixture.detectChanges(); + expect(carousel.get(1)).toBe(currentSlide); + + currentSlide = carousel.get(0); + carousel.prev(); + fixture.detectChanges(); + expect(carousel.get(0)).toBe(currentSlide); + + carousel.select(1); + fixture.detectChanges(); + expect(carousel.get(1)).toBe(carousel.get(carousel.current)); + + // select a negative index -> active slide remains the same + carousel.select(-1); + fixture.detectChanges(); + expect(carousel.get(1)).toBe(carousel.get(carousel.current)); + + // select a non-existent index -> active slide remains the same + carousel.select(carousel.slides.length); + fixture.detectChanges(); + expect(carousel.get(1)).toBe(carousel.get(carousel.current)); + }); + + it('emit events', () => { + spyOn(carousel.slideChanged, 'emit'); + carousel.next(); + fixture.detectChanges(); + let args: ISlideEventArgs = { + carousel, + slide: carousel.get(carousel.current) + }; + expect(carousel.slideChanged.emit).toHaveBeenCalledWith(args); + expect(carousel.slideChanged.emit).toHaveBeenCalledTimes(1); + + carousel.prev(); + args = { + carousel, + slide: carousel.get(carousel.current) + }; + fixture.detectChanges(); + expect(carousel.slideChanged.emit).toHaveBeenCalledWith(args); + expect(carousel.slideChanged.emit).toHaveBeenCalledTimes(2); + + carousel.select(carousel.get(2)); + args = { + carousel, + slide: carousel.get(2) + }; + fixture.detectChanges(); + expect(carousel.slideChanged.emit).toHaveBeenCalledWith(args); + expect(carousel.slideChanged.emit).toHaveBeenCalledTimes(3); + + spyOn(carousel.slideAdded, 'emit'); + const newSlide = TestBed.inject(IgxSlideComponent); + carousel.add(newSlide); + fixture.detectChanges(); + args = { + carousel, + slide: newSlide + }; + expect(carousel.slideAdded.emit).toHaveBeenCalledWith(args); + + spyOn(carousel.slideRemoved, 'emit'); + args = { + carousel, + slide: carousel.get(carousel.current) + }; + carousel.remove(carousel.get(carousel.current)); + fixture.detectChanges(); + expect(carousel.slideRemoved.emit).toHaveBeenCalledWith(args); + + spyOn(carousel.carouselPaused, 'emit'); + carousel.stop(); + fixture.detectChanges(); + expect(carousel.carouselPaused.emit).toHaveBeenCalledWith(carousel); + + spyOn(carousel.carouselPlaying, 'emit'); + carousel.play(); + fixture.detectChanges(); + expect(carousel.carouselPlaying.emit).toHaveBeenCalledWith(carousel); + }); + + it('click handlers', () => { + const nextNav = HelperTestFunctions.getNextButton(fixture); + const prevNav = HelperTestFunctions.getPreviousButton(fixture); + + spyOn(carousel, 'prev'); + prevNav.dispatchEvent(new Event('click')); + fixture.detectChanges(); + expect(carousel.prev).toHaveBeenCalled(); + + spyOn(carousel, 'next'); + nextNav.dispatchEvent(new Event('click')); + fixture.detectChanges(); + expect(carousel.next).toHaveBeenCalled(); + }); + + it('keyboard navigation test', () => { + spyOn(carousel.slideChanged, 'emit'); + carousel.pause = true; + const indicators = HelperTestFunctions.getIndicatorsContainer(fixture); + + indicators.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab' })); + fixture.detectChanges(); + expect(indicators.classList).toContain('igx-carousel-indicators--focused'); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', indicators, true); + fixture.detectChanges(); + HelperTestFunctions.verifyActiveSlide(carousel, 1); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', indicators, true); + fixture.detectChanges(); + HelperTestFunctions.verifyActiveSlide(carousel, 2); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', indicators, true); + fixture.detectChanges(); + HelperTestFunctions.verifyActiveSlide(carousel, 3); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', indicators, true); + fixture.detectChanges(); + HelperTestFunctions.verifyActiveSlide(carousel, 0); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', indicators, true); + fixture.detectChanges(); + HelperTestFunctions.verifyActiveSlide(carousel, 3); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', indicators, true); + fixture.detectChanges(); + HelperTestFunctions.verifyActiveSlide(carousel, 2); + + UIInteractions.triggerKeyDownEvtUponElem('Home', indicators, true); + fixture.detectChanges(); + HelperTestFunctions.verifyActiveSlide(carousel, 0); + + UIInteractions.triggerKeyDownEvtUponElem('End', indicators, true); + fixture.detectChanges(); + HelperTestFunctions.verifyActiveSlide(carousel, 3); + + expect(carousel.slideChanged.emit).toHaveBeenCalledTimes(8); + }); + + it('changing slides with navigation buttons', () => { + spyOn(carousel.slideChanged, 'emit'); + carousel.pause = true; + + const prevNav = HelperTestFunctions.getPreviousButton(fixture); + const nextNav = HelperTestFunctions.getNextButton(fixture); + + nextNav.dispatchEvent(new Event('click')); + fixture.detectChanges(); + + HelperTestFunctions.verifyActiveSlide(carousel, 1); + + nextNav.dispatchEvent(new Event('click')); + fixture.detectChanges(); + + HelperTestFunctions.verifyActiveSlide(carousel, 2); + + nextNav.dispatchEvent(new Event('click')); + fixture.detectChanges(); + + HelperTestFunctions.verifyActiveSlide(carousel, 3); + + nextNav.dispatchEvent(new Event('click')); + fixture.detectChanges(); + + HelperTestFunctions.verifyActiveSlide(carousel, 0); + + prevNav.dispatchEvent(new Event('click')); + fixture.detectChanges(); + + HelperTestFunctions.verifyActiveSlide(carousel, 3); + + prevNav.dispatchEvent(new Event('click')); + fixture.detectChanges(); + + HelperTestFunctions.verifyActiveSlide(carousel, 2); + + expect(carousel.slideChanged.emit).toHaveBeenCalledTimes(6); + }); + + it('changing slides with indicators buttons', () => { + spyOn(carousel.slideChanged, 'emit'); + carousel.pause = true; + + const indicators = HelperTestFunctions.getIndicators(fixture); + expect(indicators.length).toBe(4); + + indicators[3].dispatchEvent(new Event('click')); + fixture.detectChanges(); + + HelperTestFunctions.verifyActiveSlide(carousel, 3); + + indicators[1].dispatchEvent(new Event('click')); + fixture.detectChanges(); + + HelperTestFunctions.verifyActiveSlide(carousel, 1); + + expect(carousel.slideChanged.emit).toHaveBeenCalledTimes(2); + }); + + it('navigation changes visibility of arrows', () => { + expect(HelperTestFunctions.getNextButton(fixture) === null).toBe(false); + expect(HelperTestFunctions.getPreviousButton(fixture) === null).toBe(false); + + carousel.navigation = false; + fixture.detectChanges(); + expect(HelperTestFunctions.getNextButton(fixture) === null).toBe(true); + expect(HelperTestFunctions.getPreviousButton(fixture) === null).toBe(true); + + carousel.navigation = true; + fixture.detectChanges(); + expect(HelperTestFunctions.getNextButton(fixture) === null).toBe(false); + expect(HelperTestFunctions.getPreviousButton(fixture) === null).toBe(false); + }); + + it('maximumIndicatorsCount changes visibility of indicators', () => { + expect(HelperTestFunctions.getIndicators(fixture).length).toBe(4); + expect(HelperTestFunctions.getIndicatorsDots(fixture).length).toBe(4); + expect(HelperTestFunctions.getIndicatorsLabel(fixture)).toBeNull(); + + carousel.maximumIndicatorsCount = 3; + fixture.detectChanges(); + expect(carousel.maximumIndicatorsCount).toBe(3); + expect(HelperTestFunctions.getIndicators(fixture).length).toBe(0); + expect(HelperTestFunctions.getIndicatorsDots(fixture).length).toBe(0); + const label = HelperTestFunctions.getIndicatorsLabel(fixture); + expect(label).toBeDefined(); + expect(label.innerHTML).toBe('1 of 4'); + + carousel.maximumIndicatorsCount = 6; + fixture.detectChanges(); + expect(carousel.maximumIndicatorsCount).toBe(6); + expect(HelperTestFunctions.getIndicators(fixture).length).toBe(4); + expect(HelperTestFunctions.getIndicatorsDots(fixture).length).toBe(4); + expect(HelperTestFunctions.getIndicatorsLabel(fixture)).toBeNull(); + }); + + it('`indicators` changes visibility of indicators', () => { + expect(HelperTestFunctions.getIndicatorsContainer(fixture)).toBeDefined(); + + carousel.indicators = false; + fixture.detectChanges(); + expect(carousel.indicators).toBe(false); + expect(HelperTestFunctions.getIndicatorsContainer(fixture)).toBeNull(); + + carousel.indicators = true; + fixture.detectChanges(); + expect(carousel.indicators).toBe(true); + expect(HelperTestFunctions.getIndicatorsContainer(fixture)).toBeDefined(); + }); + + it('indicatorsOrientation changes the position of indicators', () => { + let indicatorsContainer = HelperTestFunctions.getIndicatorsContainer(fixture); + expect(indicatorsContainer).toBeDefined(); + + carousel.indicatorsOrientation = CarouselIndicatorsOrientation.start; + fixture.detectChanges(); + + indicatorsContainer = HelperTestFunctions.getIndicatorsContainer(fixture); + expect(indicatorsContainer).toBeNull(); + indicatorsContainer = HelperTestFunctions.getIndicatorsContainer(fixture, CarouselIndicatorsOrientation.start); + expect(indicatorsContainer).toBeDefined(); + + carousel.indicatorsOrientation = CarouselIndicatorsOrientation.end; + fixture.detectChanges(); + + indicatorsContainer = HelperTestFunctions.getIndicatorsContainer(fixture, CarouselIndicatorsOrientation.start); + expect(indicatorsContainer).toBeNull(); + indicatorsContainer = HelperTestFunctions.getIndicatorsContainer(fixture, CarouselIndicatorsOrientation.end); + expect(indicatorsContainer).toBeDefined(); + }); + + it('should stop/play on mouse enter/leave ', () => { + carousel.interval = 1000; + carousel.play(); + fixture.detectChanges(); + + spyOn(carousel.carouselPaused, 'emit'); + spyOn(carousel.carouselPlaying, 'emit'); + + expect(carousel.isPlaying).toBeTruthy(); + + UIInteractions.hoverElement(carousel.nativeElement, true); + fixture.detectChanges(); + + expect(carousel.isPlaying).toBeFalsy(); + expect(carousel.carouselPaused.emit).toHaveBeenCalledTimes(1); + + UIInteractions.unhoverElement(carousel.nativeElement, true); + fixture.detectChanges(); + + expect(carousel.isPlaying).toBeTruthy(); + expect(carousel.carouselPlaying.emit).toHaveBeenCalledTimes(1); + expect(carousel.carouselPaused.emit).toHaveBeenCalledTimes(1); + + // When the carousel is stopped mouseleave does not start playing + carousel.stop(); + fixture.detectChanges(); + + expect(carousel.isPlaying).toBeFalsy(); + expect(carousel.carouselPlaying.emit).toHaveBeenCalledTimes(1); + expect(carousel.carouselPaused.emit).toHaveBeenCalledTimes(2); + + UIInteractions.hoverElement(carousel.nativeElement, true); + fixture.detectChanges(); + + expect(carousel.isPlaying).toBeFalsy(); + + UIInteractions.unhoverElement(carousel.nativeElement, true); + fixture.detectChanges(); + expect(carousel.isPlaying).toBeFalsy(); + expect(carousel.carouselPlaying.emit).toHaveBeenCalledTimes(1); + }); + + + it('should apply correctly aria attributes to carousel component', () => { + const expectedRole = 'region'; + const expectedRoleDescription = 'carousel'; + const tabIndex = carousel.nativeElement.getAttribute('tabindex'); + + expect(tabIndex).toBeNull(); + expect(carousel.nativeElement.getAttribute('role')).toEqual(expectedRole); + expect(carousel.nativeElement.getAttribute('aria-roledescription')).toEqual(expectedRoleDescription); + + const indicators = carousel.nativeElement.querySelector(HelperTestFunctions.INDICATORS_END_CLASS); + + expect(indicators).toBeDefined(); + expect(indicators.getAttribute('role')).toEqual('tablist'); + + const tabs = carousel.nativeElement.querySelectorAll('[role="tab"]'); + expect(tabs.length).toEqual(4); + }); + + it('should apply correctly aria attributes to slide components', () => { + carousel.loop = false; + carousel.select(carousel.get(1)); + fixture.detectChanges(); + + const expectedRole = 'tabpanel'; + const slide = carousel.slides.find(s => s.active); + const tabIndex = slide.nativeElement.getAttribute('tabindex'); + + expect(+tabIndex).toBe(0); + expect(slide.nativeElement.getAttribute('role')).toEqual(expectedRole); + + const tabs = carousel.nativeElement.querySelectorAll('[role="tab"]'); + const slides = carousel.nativeElement.querySelectorAll('[role="tabpanel"]'); + + expect(slides.length).toEqual(tabs.length); + }); + + it('should change slide on Enter/Space keys', () => { + const nextNav = HelperTestFunctions.getNextButton(fixture); + const prevNav = HelperTestFunctions.getPreviousButton(fixture); + + spyOn(carousel, 'next'); + spyOn(carousel, 'prev'); + + UIInteractions.triggerKeyDownEvtUponElem('Enter', nextNav, true); + UIInteractions.triggerKeyDownEvtUponElem(' ', nextNav, true); + fixture.detectChanges(); + expect(carousel.next).toHaveBeenCalledTimes(2); + + UIInteractions.triggerKeyDownEvtUponElem('Enter', prevNav, true); + UIInteractions.triggerKeyDownEvtUponElem(' ', prevNav, true); + fixture.detectChanges(); + expect(carousel.prev).toHaveBeenCalledTimes(2); + }); + + it('should set focused class on indicators container on keyboard tab focus', () => { + const indicators = HelperTestFunctions.getIndicatorsContainer(fixture); + + expect(indicators.classList).not.toContain('igx-carousel-indicators--focused'); + + indicators.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab' })); + fixture.detectChanges(); + + expect(indicators.classList).toContain('igx-carousel-indicators--focused'); + }); + + it('should remove focused class from indicators container on focusout', () => { + const indicators = HelperTestFunctions.getIndicatorsContainer(fixture); + const indicator = HelperTestFunctions.getIndicators(fixture)[1] as HTMLElement; + + indicators.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab' })); + fixture.detectChanges(); + expect(indicators.classList).toContain('igx-carousel-indicators--focused'); + + // if element gaining focus is an indicator the focused class remains + indicators.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: indicator })); + fixture.detectChanges(); + expect(indicators.classList).toContain('igx-carousel-indicators--focused'); + + // if element gaining focus is not an indicator the focused class is removed + indicators.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + fixture.detectChanges(); + expect(indicators.classList).not.toContain('igx-carousel-indicators--focused'); + }); + + it('should remove focused class from indicators container on click', () => { + const indicators = HelperTestFunctions.getIndicatorsContainer(fixture); + + indicators.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab' })); + fixture.detectChanges(); + expect(indicators.classList).toContain('igx-carousel-indicators--focused'); + + indicators.dispatchEvent(new MouseEvent('click', { bubbles: true })); + fixture.detectChanges(); + expect(indicators.classList).not.toContain('igx-carousel-indicators--focused'); + }); + + it('should keep focused class on indicators container on keyboard nav with supported keys', () => { + const indicators = HelperTestFunctions.getIndicatorsContainer(fixture); + + indicators.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab' })); + fixture.detectChanges(); + expect(indicators.classList).toContain('igx-carousel-indicators--focused'); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', indicators, true); + fixture.detectChanges(); + expect(carousel.current).toEqual(1); + expect(indicators.classList).toContain('igx-carousel-indicators--focused'); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', indicators, true); + fixture.detectChanges(); + expect(carousel.current).toEqual(0); + expect(indicators.classList).toContain('igx-carousel-indicators--focused'); + + UIInteractions.triggerKeyDownEvtUponElem('End', indicators, true); + fixture.detectChanges(); + expect(carousel.current).toEqual(3); + expect(indicators.classList).toContain('igx-carousel-indicators--focused'); + + UIInteractions.triggerKeyDownEvtUponElem('Home', indicators, true); + fixture.detectChanges(); + expect(carousel.current).toEqual(0); + expect(indicators.classList).toContain('igx-carousel-indicators--focused'); + }); + }); + + describe('RTL Tests: ', () => { + beforeEach(() => { + document.body.dir = 'rtl'; + fixture = TestBed.createComponent(CarouselTestComponent); + carousel = fixture.componentInstance.carousel; + fixture.detectChanges(); + }); + afterEach(() => { + document.body.dir = 'ltr'; + }); + + it('should support keyboard navigation when the indicators container is focused', () => { + const indicators = HelperTestFunctions.getIndicatorsContainer(fixture); + + indicators.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab' })); + fixture.detectChanges(); + expect(indicators.classList).toContain('igx-carousel-indicators--focused'); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', indicators, true); + fixture.detectChanges(); + expect(carousel.current).toEqual(3); + expect(indicators.classList).toContain('igx-carousel-indicators--focused'); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', indicators, true); + fixture.detectChanges(); + expect(carousel.current).toEqual(0); + expect(indicators.classList).toContain('igx-carousel-indicators--focused'); + + UIInteractions.triggerKeyDownEvtUponElem('End', indicators, true); + fixture.detectChanges(); + expect(carousel.current).toEqual(0); + expect(indicators.classList).toContain('igx-carousel-indicators--focused'); + + UIInteractions.triggerKeyDownEvtUponElem('Home', indicators, true); + fixture.detectChanges(); + expect(carousel.current).toEqual(3); + expect(indicators.classList).toContain('igx-carousel-indicators--focused'); + }); + }); + + describe('Templates Tests: ', () => { + it('verify that templates can be defined in the markup', () => { + fixture = TestBed.createComponent(CarouselTemplateSetInMarkupTestComponent); + carousel = fixture.componentInstance.carousel; + fixture.detectChanges(); + + expect(HelperTestFunctions.getIndicators(fixture).length).toBe(4); + expect(HelperTestFunctions.getIndicatorsDots(fixture).length).toBe(0); + for (let index = 0; index < 4; index++) { + const indicator = HelperTestFunctions.getIndicators(fixture)[index] as HTMLElement; + expect(indicator.innerText).toEqual(index.toString()); + } + + expect(HelperTestFunctions.getNextButtonArrow(fixture)).toBeNull(); + expect(HelperTestFunctions.getPreviousButtonArrow(fixture)).toBeNull(); + + expect(HelperTestFunctions.getNextButton(fixture).innerText).toEqual('next'); + expect(HelperTestFunctions.getPreviousButton(fixture).innerText).toEqual('prev'); + }); + + it('verify that templates can be changed', () => { + fixture = TestBed.createComponent(CarouselTemplateSetInTypescriptTestComponent); + carousel = fixture.componentInstance.carousel; + fixture.detectChanges(); + + carousel.select(carousel.get(1)); + fixture.detectChanges(); + + expect(HelperTestFunctions.getIndicators(fixture).length).toBe(4); + expect(HelperTestFunctions.getIndicatorsDots(fixture).length).toBe(4); + expect(HelperTestFunctions.getNextButtonArrow(fixture)).toBeDefined(); + expect(HelperTestFunctions.getPreviousButtonArrow(fixture)).toBeDefined(); + + carousel.indicatorTemplate = fixture.componentInstance.customIndicatorTemplate1; + carousel.nextButtonTemplate = fixture.componentInstance.customNextTemplate; + carousel.prevButtonTemplate = fixture.componentInstance.customPrevTemplate; + fixture.detectChanges(); + + expect(HelperTestFunctions.getIndicators(fixture).length).toBe(4); + expect(HelperTestFunctions.getIndicatorsDots(fixture).length).toBe(0); + for (let index = 0; index < 4; index++) { + const indicator = HelperTestFunctions.getIndicators(fixture)[index] as HTMLElement; + expect(indicator.innerText).toEqual(index.toString()); + } + expect(HelperTestFunctions.getNextButtonArrow(fixture)).toBeNull(); + expect(HelperTestFunctions.getPreviousButtonArrow(fixture)).toBeNull(); + + expect(HelperTestFunctions.getNextButton(fixture).innerText).toEqual('next'); + expect(HelperTestFunctions.getPreviousButton(fixture).innerText).toEqual('prev'); + + carousel.indicatorTemplate = fixture.componentInstance.customIndicatorTemplate2; + fixture.detectChanges(); + + expect(HelperTestFunctions.getIndicators(fixture).length).toBe(4); + expect(HelperTestFunctions.getIndicatorsDots(fixture).length).toBe(0); + + for (let index = 0; index < 4; index++) { + const indicator = HelperTestFunctions.getIndicators(fixture)[index] as HTMLElement; + if (index === 1) { + expect(indicator.innerText).toEqual('1: Active'); + } else { + expect(indicator.innerText).toEqual(index.toString()); + } + } + + carousel.indicatorTemplate = null; + carousel.nextButtonTemplate = null; + carousel.prevButtonTemplate = null; + fixture.detectChanges(); + + expect(HelperTestFunctions.getIndicators(fixture).length).toBe(4); + expect(HelperTestFunctions.getIndicatorsDots(fixture).length).toBe(4); + expect(HelperTestFunctions.getNextButtonArrow(fixture)).toBeDefined(); + expect(HelperTestFunctions.getPreviousButtonArrow(fixture)).toBeDefined(); + }); + }); + + describe('Animations Tests: ', () => { + beforeEach(() => { + fixture = TestBed.createComponent(CarouselAnimationsComponent); + fixture.detectChanges(); + carousel = fixture.componentInstance.carousel; + }); + + it('Test slide animation', async () => { + await wait(); + expect(carousel.get(0).active).toBeTruthy(); + expect(carousel.get(0).nativeElement.classList.contains(HelperTestFunctions.ACTIVE_SLIDE_CLASS)).toBeTruthy(); + expect(carousel.animationType).toBe(CarouselAnimationType.slide); + carousel.next(); + fixture.detectChanges(); + await wait(200); + + expect(carousel.get(1).nativeElement.classList.contains(HelperTestFunctions.ACTIVE_SLIDE_CLASS)).toBeTruthy(); + expect(carousel.get(0).nativeElement.classList.contains(HelperTestFunctions.PREVIOUS_SLIDE_CLASS)).toBeTruthy(); + await wait(200); + fixture.detectChanges(); + + expect(carousel.get(1).nativeElement.classList.contains(HelperTestFunctions.ACTIVE_SLIDE_CLASS)).toBeTruthy(); + expect(carousel.get(0).nativeElement.classList.contains(HelperTestFunctions.PREVIOUS_SLIDE_CLASS)).toBeFalsy(); + carousel.prev(); + fixture.detectChanges(); + await wait(230); + + expect(carousel.get(0).nativeElement.classList.contains(HelperTestFunctions.ACTIVE_SLIDE_CLASS)).toBeTruthy(); + expect(carousel.get(1).nativeElement.classList.contains(HelperTestFunctions.PREVIOUS_SLIDE_CLASS)).toBeTruthy(); + await wait(200); + fixture.detectChanges(); + + expect(carousel.get(0).nativeElement.classList.contains(HelperTestFunctions.ACTIVE_SLIDE_CLASS)).toBeTruthy(); + expect(carousel.get(1).nativeElement.classList.contains(HelperTestFunctions.PREVIOUS_SLIDE_CLASS)).toBeFalsy(); + }); + + it('Test fade animation', async () => { + await wait(); + carousel.animationType = CarouselAnimationType.fade; + fixture.detectChanges(); + + expect(carousel.get(0).active).toBeTruthy(); + expect(carousel.get(0).nativeElement.classList.contains(HelperTestFunctions.ACTIVE_SLIDE_CLASS)).toBeTruthy(); + expect(carousel.animationType).toBe(CarouselAnimationType.fade); + carousel.next(); + fixture.detectChanges(); + await wait(200); + + expect(carousel.get(1).nativeElement.classList.contains(HelperTestFunctions.ACTIVE_SLIDE_CLASS)).toBeTruthy(); + expect(carousel.get(0).nativeElement.classList.contains(HelperTestFunctions.PREVIOUS_SLIDE_CLASS)).toBeTruthy(); + await wait(200); + fixture.detectChanges(); + + expect(carousel.get(1).nativeElement.classList.contains(HelperTestFunctions.ACTIVE_SLIDE_CLASS)).toBeTruthy(); + expect(carousel.get(0).nativeElement.classList.contains(HelperTestFunctions.PREVIOUS_SLIDE_CLASS)).toBeFalsy(); + }); + + it('should not throw an error when playing an animation and destroying the component - #15976', () => { + expect(() => { + carousel.next(); + carousel.ngOnDestroy(); + fixture.detectChanges(); + }).not.toThrow(); + + expect(carousel['enterAnimationPlayer']).toBe(null); + expect(carousel['leaveAnimationPlayer']).toBe(null); + }); + }); + + describe('Dynamic Slides: ', () => { + let slides; + beforeEach(() => { + fixture = TestBed.createComponent(CarouselDynamicSlidesComponent); + fixture.detectChanges(); + carousel = fixture.componentInstance.carousel; + slides = fixture.componentInstance.slides; + }); + + it('should activate slide when change its property active', fakeAsync(() => { + tick(); + // Verify 3th slide is active + HelperTestFunctions.verifyActiveSlide(carousel, 2); + + // Change active slide + slides[0].active = true; + fixture.detectChanges(); + + HelperTestFunctions.verifyActiveSlide(carousel, 0); + })); + + it('should add slides to the carousel when collection is changed', fakeAsync(() => { + tick(); + spyOn(carousel.slideAdded, 'emit'); + + // add a slide + slides.push({ text: 'Slide 5' }); + fixture.detectChanges(); + + HelperTestFunctions.verifyActiveSlide(carousel, 2); + expect(carousel.total).toEqual(5); + + // add an active slide + slides.push({ text: 'Slide 6', active: true }); + fixture.detectChanges(); + tick(100); + + HelperTestFunctions.verifyActiveSlide(carousel, 5); + expect(carousel.total).toEqual(6); + + expect(carousel.slideAdded.emit).toHaveBeenCalledTimes(2); + })); + + it('should remove slides in the carousel', fakeAsync(() => { + tick(); + spyOn(carousel.slideRemoved, 'emit'); + + // remove a slide + slides.pop(); + fixture.detectChanges(); + + HelperTestFunctions.verifyActiveSlide(carousel, 2); + expect(carousel.total).toEqual(3); + + // remove active slide + slides.pop(); + fixture.detectChanges(); + tick(200); + fixture.detectChanges(); + + expect(carousel.total).toEqual(2); + HelperTestFunctions.verifyActiveSlide(carousel, 1); + + expect(carousel.slideRemoved.emit).toHaveBeenCalledTimes(2); + })); + + it('should not render navigation buttons and indicators when carousel does not have slides', fakeAsync(() => { + fixture.componentInstance.removeAllSlides(); + fixture.detectChanges(); + tick(200); + + expect(carousel.total).toEqual(0); + expect(HelperTestFunctions.getIndicatorsContainer(fixture)).toBeNull(); + expect(HelperTestFunctions.getIndicatorsContainer(fixture, CarouselIndicatorsOrientation.start)).toBeNull(); + expect(HelperTestFunctions.getNextButton(fixture)).toBeNull(); + expect(HelperTestFunctions.getPreviousButton(fixture)).toBeNull(); + + // add a slide + fixture.componentInstance.addSlides(); + fixture.detectChanges(); + tick(200); + + expect(carousel.total).toEqual(2); + expect(HelperTestFunctions.getIndicatorsContainer(fixture)).toBeDefined(); + expect(HelperTestFunctions.getIndicatorsContainer(fixture, CarouselIndicatorsOrientation.start)).toBeDefined(); + expect(HelperTestFunctions.getNextButton(fixture).hidden).toBeFalsy(); + expect(HelperTestFunctions.getPreviousButton(fixture).hidden).toBeFalsy(); + })); + }); + + describe('Gestures Tests: ', () => { + beforeEach(() => { + fixture = TestBed.createComponent(CarouselDynamicSlidesComponent); + fixture.detectChanges(); + carousel = fixture.componentInstance.carousel; + }); + + it('should stop/play on tap ', () => { + carousel.interval = 1000; + carousel.play(); + fixture.detectChanges(); + + spyOn(carousel.carouselPaused, 'emit'); + spyOn(carousel.carouselPlaying, 'emit'); + + expect(carousel.isPlaying).toBeTruthy(); + + HelperTestFunctions.simulateTap(fixture, carousel); + fixture.detectChanges(); + + expect(carousel.isPlaying).toBeFalsy(); + + HelperTestFunctions.simulateTap(fixture, carousel); + fixture.detectChanges(); + + expect(carousel.isPlaying).toBeTruthy(); + + // When the carousel is stopped tap does not start playing + carousel.stop(); + fixture.detectChanges(); + + expect(carousel.isPlaying).toBeFalsy(); + + HelperTestFunctions.simulateTap(fixture, carousel); + fixture.detectChanges(); + + expect(carousel.isPlaying).toBeFalsy(); + + HelperTestFunctions.simulateTap(fixture, carousel); + fixture.detectChanges(); + + expect(carousel.isPlaying).toBeFalsy(); + }); + + it('verify changing slides with pan left ', () => { + expect(carousel.current).toEqual(2); + + HelperTestFunctions.simulatePan(fixture, carousel, -0.05, 0.1, 'horizontal'); + expect(carousel.current).toEqual(2); + + HelperTestFunctions.simulatePan(fixture, carousel, -0.7, 0.1, 'horizontal'); + expect(carousel.current).toEqual(3); + + HelperTestFunctions.simulatePan(fixture, carousel, -0.2, 2, 'horizontal'); + expect(carousel.current).toEqual(0); + }); + + it('verify changing slides with pan right ', () => { + expect(carousel.current).toEqual(2); + + HelperTestFunctions.simulatePan(fixture, carousel, 0.1, 0.1, 'horizontal'); + expect(carousel.current).toEqual(2); + + HelperTestFunctions.simulatePan(fixture, carousel, 0.6, 0.1, 'horizontal'); + expect(carousel.current).toEqual(1); + + HelperTestFunctions.simulatePan(fixture, carousel, 0.05, 2, 'horizontal'); + expect(carousel.current).toEqual(0); + }); + + it('verify changing slides with pan up', () => { + carousel.vertical = true; + fixture.detectChanges(); + expect(carousel.current).toEqual(2); + + HelperTestFunctions.simulatePan(fixture, carousel, -0.05, 0.1, 'vertical'); + expect(carousel.current).toEqual(2); + + HelperTestFunctions.simulatePan(fixture, carousel, -0.7, 0.1, 'vertical'); + expect(carousel.current).toEqual(3); + + HelperTestFunctions.simulatePan(fixture, carousel, -0.2, 2, 'vertical'); + expect(carousel.current).toEqual(0); + }); + + it('verify changing slides with pan down', () => { + carousel.vertical = true; + fixture.detectChanges(); + expect(carousel.current).toEqual(2); + + HelperTestFunctions.simulatePan(fixture, carousel, 0.1, 0.1, 'vertical'); + expect(carousel.current).toEqual(2); + + HelperTestFunctions.simulatePan(fixture, carousel, 0.6, 0.1, 'vertical'); + expect(carousel.current).toEqual(1); + + HelperTestFunctions.simulatePan(fixture, carousel, 0.05, 2, 'vertical'); + expect(carousel.current).toEqual(0); + }); + + it('verify pan when loop is false', () => { + carousel.loop = false; + fixture.detectChanges(); + + carousel.select(carousel.get(0)); + fixture.detectChanges(); + + expect(carousel.current).toEqual(0); + + HelperTestFunctions.simulatePan(fixture, carousel, 0.9, 2, 'horizontal'); + + expect(carousel.current).toEqual(0); + + carousel.select(carousel.get(3)); + fixture.detectChanges(); + + expect(carousel.current).toEqual(3); + + HelperTestFunctions.simulatePan(fixture, carousel, -0.9, 2, 'horizontal'); + + expect(carousel.current).toEqual(3); + }); + + it('verify pan when gesturesSupport is false', () => { + carousel.gesturesSupport = false; + fixture.detectChanges(); + + expect(carousel.current).toEqual(2); + + HelperTestFunctions.simulatePan(fixture, carousel, 0.9, 2, 'horizontal'); + + expect(carousel.current).toEqual(2); + + HelperTestFunctions.simulatePan(fixture, carousel, -0.6, 2, 'horizontal'); + + expect(carousel.current).toEqual(2); + }); + + it('verify pan left/right when `vertical` is true', () => { + carousel.vertical = true; + fixture.detectChanges(); + expect(carousel.vertical).toBe(true); + expect(carousel.current).toEqual(2); + + // pan left + HelperTestFunctions.simulatePan(fixture, carousel, -0.7, 0.1, 'horizontal'); + expect(carousel.current).toEqual(2); + + HelperTestFunctions.simulatePan(fixture, carousel, -0.2, 2, 'horizontal'); + expect(carousel.current).toEqual(2); + + // pan right + HelperTestFunctions.simulatePan(fixture, carousel, 0.6, 0.1, 'horizontal'); + expect(carousel.current).toEqual(2); + + HelperTestFunctions.simulatePan(fixture, carousel, 0.05, 2, 'horizontal'); + expect(carousel.current).toEqual(2); + }); + + it('verify pan up/down when `vertical` is false', () => { + expect(carousel.vertical).toBe(false); + expect(carousel.current).toEqual(2); + + // pan up + HelperTestFunctions.simulatePan(fixture, carousel, -0.7, 0.1, 'vertical'); + expect(carousel.current).toEqual(2); + + HelperTestFunctions.simulatePan(fixture, carousel, -0.2, 2, 'vertical'); + expect(carousel.current).toEqual(2); + + // pan down + HelperTestFunctions.simulatePan(fixture, carousel, 0.6, 0.1, 'vertical'); + expect(carousel.current).toEqual(2); + + HelperTestFunctions.simulatePan(fixture, carousel, 0.05, 2, 'vertical'); + expect(carousel.current).toEqual(2); + }); + }); +}); + +class HelperTestFunctions { + public static NEXT_BUTTON_CLASS = '.igx-carousel__arrow--next'; + public static PRIV_BUTTON_CLASS = '.igx-carousel__arrow--prev'; + public static BUTTON_ARROW_CLASS = '.igx-nav-arrow'; + public static ACTIVE_SLIDE_CLASS = 'igx-slide--current'; + public static PREVIOUS_SLIDE_CLASS = 'igx-slide--previous'; + public static INDICATORS_START_CLASS = '.igx-carousel-indicators--start'; + public static INDICATORS_END_CLASS = '.igx-carousel-indicators--end'; + public static INDICATORS_LABEL_CLASS = '.igx-carousel__label'; + public static INDICATOR_CLASS = '.igx-carousel-indicators__indicator'; + public static INDICATOR_DOT_CLASS = '.igx-nav-dot'; + public static INDICATOR_ACTIVE_DOT_CLASS = '.igx-nav-dot--active'; + + public static getNextButton(fixture): HTMLElement { + return fixture.nativeElement.querySelector(HelperTestFunctions.NEXT_BUTTON_CLASS); + } + + public static getPreviousButton(fixture): HTMLElement { + return fixture.nativeElement.querySelector(HelperTestFunctions.PRIV_BUTTON_CLASS); + } + + public static getNextButtonArrow(fixture): HTMLElement { + const next = HelperTestFunctions.getNextButton(fixture); + return next.querySelector(HelperTestFunctions.BUTTON_ARROW_CLASS); + } + + public static getPreviousButtonArrow(fixture): HTMLElement { + const prev = HelperTestFunctions.getPreviousButton(fixture); + return prev.querySelector(HelperTestFunctions.BUTTON_ARROW_CLASS); + } + + public static getIndicatorsContainer(fixture, position: CarouselIndicatorsOrientation = CarouselIndicatorsOrientation.end): HTMLElement { + const carouselNative = fixture.nativeElement; + if (position === CarouselIndicatorsOrientation.end) { + return carouselNative.querySelector(HelperTestFunctions.INDICATORS_END_CLASS); + } else { + return carouselNative.querySelector(HelperTestFunctions.INDICATORS_START_CLASS); + } + } + + public static getIndicatorsLabel(fixture, position: CarouselIndicatorsOrientation = CarouselIndicatorsOrientation.end) { + const indContainer = HelperTestFunctions.getIndicatorsContainer(fixture, position); + return indContainer.querySelector(HelperTestFunctions.INDICATORS_LABEL_CLASS); + } + + public static getIndicators(fixture, position: CarouselIndicatorsOrientation = CarouselIndicatorsOrientation.end) { + const indContainer = HelperTestFunctions.getIndicatorsContainer(fixture, position); + return indContainer.querySelectorAll(HelperTestFunctions.INDICATOR_CLASS); + } + + public static getIndicatorsDots(fixture, position: CarouselIndicatorsOrientation = CarouselIndicatorsOrientation.end) { + const indContainer = HelperTestFunctions.getIndicatorsContainer(fixture, position); + return indContainer.querySelectorAll(HelperTestFunctions.INDICATOR_DOT_CLASS); + } + + public static verifyActiveSlide(carousel, index: number) { + const activeSlide = carousel.get(index); + expect(carousel.current).toEqual(index); + expect(activeSlide.active).toBeTruthy(); + expect(activeSlide.nativeElement.classList.contains(HelperTestFunctions.ACTIVE_SLIDE_CLASS)).toBeTruthy(); + expect(carousel.slides.find((slide) => slide.active && slide.index !== index)).toBeUndefined(); + } + + public static simulateTap(fixture, carousel) { + const activeSlide = carousel.get(carousel.current).nativeElement; + const carouselElement = fixture.debugElement.query(By.css('igx-carousel')); + carouselElement.triggerEventHandler('tap', {target: activeSlide}); + // Simulator.gestures.press(activeSlide, { duration: 180 }); + } + + public static simulatePan(fixture, carousel, deltaOffset, velocity, dir: 'horizontal' | 'vertical') { + const activeSlide = carousel.get(carousel.current).nativeElement; + const carouselElement = fixture.debugElement.query(By.css('igx-carousel')); + const deltaX = dir === 'horizontal' ? activeSlide.offsetWidth * deltaOffset : 0; + const deltaY = dir === 'horizontal' ? 0 : activeSlide.offsetHeight * deltaOffset; + + let event; + if (dir === 'horizontal') { + event = deltaOffset < 0 ? 'panleft' : 'panright'; + } else { + event = deltaOffset < 0 ? 'panup' : 'pandown'; + } + const panOptions = { + deltaX, + deltaY, + duration: 100, + velocity, + preventDefault: ( () => { }) + }; + + carouselElement.triggerEventHandler(event, panOptions); + fixture.detectChanges(); + carouselElement.triggerEventHandler('panend', panOptions); + fixture.detectChanges(); + } +} +@Component({ + template: ` + +

Slide1

+

Slide2

+

Slide3

+

Slide4

+
+ `, + imports: [IgxCarouselComponent, IgxSlideComponent] +}) +class CarouselTestComponent { + @ViewChild('carousel', { static: true }) public carousel: IgxCarouselComponent; + + public loop = true; + public pause = true; + public interval = 2500; +} + +@Component({ + template: ` + +

Slide1

+

Slide2

+

Slide3

+

Slide4

+
+ `, + imports: [IgxCarouselComponent, IgxSlideComponent], + changeDetection: ChangeDetectionStrategy.OnPush +}) +class CarouselAnimationsComponent { + @ViewChild('carousel', { static: true }) public carousel: IgxCarouselComponent; +} + + +@Component({ + template: ` + +

Slide1

+

Slide2

+

Slide3

+

Slide4

+ + + {{slide.index}} + + + + next + + + + prev + +
+ `, + imports: [IgxCarouselComponent, IgxSlideComponent, IgxCarouselIndicatorDirective, IgxCarouselNextButtonDirective, IgxCarouselPrevButtonDirective] +}) +class CarouselTemplateSetInMarkupTestComponent { + @ViewChild('carousel', { static: true }) public carousel: IgxCarouselComponent; +} + +@Component({ + template: ` + + {{slide.index}} + + + + @if (!slide.active) { + {{slide.index}} + } + @if (slide.active) { + {{slide.index}}: Active + } + + + + next + + + + prev + + + +

Slide1

+

Slide2

+

Slide3

+

Slide4

+
+ `, + imports: [IgxCarouselComponent, IgxSlideComponent] +}) +class CarouselTemplateSetInTypescriptTestComponent { + @ViewChild('carousel', { static: true }) public carousel: IgxCarouselComponent; + @ViewChild('customIndicatorTemplate1', { read: TemplateRef, static: false }) + public customIndicatorTemplate1; + @ViewChild('customIndicatorTemplate2', { read: TemplateRef, static: false }) + public customIndicatorTemplate2; + @ViewChild('customNextTemplate', { read: TemplateRef, static: false }) + public customNextTemplate; + @ViewChild('customPrevTemplate', { read: TemplateRef, static: false }) + public customPrevTemplate; +} + +@Component({ + template: ` + + @for (slide of slides; track slide.text) { + +

{{slide.text}}

+
+ } +
+ `, + imports: [IgxCarouselComponent, IgxSlideComponent] +}) +class CarouselDynamicSlidesComponent { + @ViewChild('carousel', { static: true }) public carousel: IgxCarouselComponent; + + public loop = true; + public slides = []; + + constructor() { + this.slides.push( + { text: 'Slide 1', active: false }, + { text: 'Slide 2', active: false }, + { text: 'Slide 3', active: true }, + { text: 'Slide 4', active: false } + ); + } + + public removeAllSlides() { + this.slides = []; + } + + public addSlides() { + this.slides.push( + { text: 'Slide 1' }, + { text: 'Slide 2' } + ); + } +} diff --git a/projects/igniteui-angular/carousel/src/carousel/carousel.component.ts b/projects/igniteui-angular/carousel/src/carousel/carousel.component.ts new file mode 100644 index 00000000000..068dc72d817 --- /dev/null +++ b/projects/igniteui-angular/carousel/src/carousel/carousel.component.ts @@ -0,0 +1,1099 @@ +import { NgClass, NgTemplateOutlet } from '@angular/common'; +import { AfterContentInit, Component, ContentChild, ContentChildren, ElementRef, EventEmitter, HostBinding, HostListener, Injectable, Input, IterableChangeRecord, IterableDiffer, IterableDiffers, OnDestroy, Output, QueryList, TemplateRef, ViewChild, ViewChildren, booleanAttribute, DOCUMENT, inject } from '@angular/core'; +import { HammerGestureConfig, HAMMER_GESTURE_CONFIG } from '@angular/platform-browser'; +import { merge, Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { CarouselResourceStringsEN, ICarouselResourceStrings, ɵIgxDirectionality } from 'igniteui-angular/core'; +import { first, IBaseEventArgs, last, PlatformUtil } from 'igniteui-angular/core'; +import { CarouselAnimationDirection, IgxCarouselComponentBase } from './carousel-base'; +import { IgxCarouselIndicatorDirective, IgxCarouselNextButtonDirective, IgxCarouselPrevButtonDirective } from './carousel.directives'; +import { IgxSlideComponent } from './slide.component'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxButtonDirective } from 'igniteui-angular/directives'; +import { getCurrentResourceStrings } from 'igniteui-angular/core'; +import { HammerGesturesManager } from 'igniteui-angular/core'; +import { CarouselAnimationType, CarouselIndicatorsOrientation } from './enums'; + +let NEXT_ID = 0; + + +@Injectable() +export class CarouselHammerConfig extends HammerGestureConfig { + public override overrides = { + pan: { direction: HammerGesturesManager.Hammer?.DIRECTION_HORIZONTAL } + }; +} +/** + * **Ignite UI for Angular Carousel** - + * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/carousel.html) + * + * The Ignite UI Carousel is used to browse or navigate through a collection of slides. Slides can contain custom + * content such as images or cards and be used for things such as on-boarding tutorials or page-based interfaces. + * It can be used as a separate fullscreen element or inside another component. + * + * Example: + * ```html + * + * + *

First Slide Header

+ *

First slide Content

+ * + * + *

Second Slide Header

+ *

Second Slide Content

+ *
+ * ``` + */ +@Component({ + providers: [ + { + provide: HAMMER_GESTURE_CONFIG, + useClass: CarouselHammerConfig + } + ], + selector: 'igx-carousel', + templateUrl: 'carousel.component.html', + styles: [` + :host { + display: block; + outline-style: none; + }`], + imports: [IgxButtonDirective, IgxIconComponent, NgClass, NgTemplateOutlet] +}) +export class IgxCarouselComponent extends IgxCarouselComponentBase implements OnDestroy, AfterContentInit { + private element = inject(ElementRef); + private iterableDiffers = inject(IterableDiffers); + private platformUtil = inject(PlatformUtil); + private dir = inject(ɵIgxDirectionality); + private document = inject(DOCUMENT); + + + /** + * Sets the `id` of the carousel. + * If not set, the `id` of the first carousel component will be `"igx-carousel-0"`. + * ```html + * + * ``` + * + * @memberof IgxCarouselComponent + */ + @HostBinding('attr.id') + @Input() + public id = `igx-carousel-${NEXT_ID++}`; + /** + * Returns the `role` attribute of the carousel. + * ```typescript + * let carouselRole = this.carousel.role; + * ``` + * + * @memberof IgxCarouselComponent + */ + @HostBinding('attr.role') public role = 'region'; + + /** @hidden */ + @HostBinding('attr.aria-roledescription') + public roleDescription = 'carousel'; + + /** @hidden */ + @HostBinding('attr.aria-labelledby') + public get labelId() { + return this.showIndicatorsLabel ? `${this.id}-label` : null; + } + + /** @hidden */ + @HostBinding('class.igx-carousel--vertical') + public get isVertical(): boolean { + return this.vertical; + } + + /** + * Returns the class of the carousel component. + * ```typescript + * let class = this.carousel.cssClass; + * ``` + * + * @memberof IgxCarouselComponent + */ + @HostBinding('class.igx-carousel') + public cssClass = 'igx-carousel'; + + /** + * Gets the `touch-action` style of the `list item`. + * ```typescript + * let touchAction = this.listItem.touchAction; + * ``` + */ + @HostBinding('style.touch-action') + public get touchAction() { + return this.gesturesSupport ? 'pan-y' : 'auto'; + } + + /** + * Sets whether the carousel should `loop` back to the first slide after reaching the last slide. + * Default value is `true`. + * ```html + * + * ``` + * + * @memberOf IgxCarouselComponent + */ + @Input({ transform: booleanAttribute }) public loop = true; + + /** + * Sets whether the carousel will `pause` the slide transitions on user interactions. + * Default value is `true`. + * ```html + * + * ``` + * + * @memberOf IgxCarouselComponent + */ + @Input({ transform: booleanAttribute }) public pause = true; + + /** + * Controls whether the carousel should render the left/right `navigation` buttons. + * Default value is `true`. + * ```html + * + * ``` + * + * @memberOf IgxCarouselComponent + */ + @Input({ transform: booleanAttribute }) public navigation = true; + + /** + * Controls whether the carousel should render the indicators. + * Default value is `true`. + * ```html + * + * ``` + * + * @memberOf IgxCarouselComponent + */ + @Input({ transform: booleanAttribute }) public indicators = true; + + + /** + * Controls whether the carousel has vertical alignment. + * Default value is `false`. + * ```html + * + * ``` + * + * @memberOf IgxCarouselComponent + */ + @Input({ transform: booleanAttribute }) public override vertical = false; + + /** + * Controls whether the carousel should support gestures. + * Default value is `true`. + * ```html + * + * ``` + * + * @memberOf IgxCarouselComponent + */ + @Input({ transform: booleanAttribute }) public gesturesSupport = true; + + /** + * Controls the maximum indexes that can be shown. + * Default value is `10`. + * ```html + * + * ``` + * + * @memberOf IgxCarouselComponent + */ + @Input() public maximumIndicatorsCount = 10; + + /** + * Gets/sets the display mode of carousel indicators. It can be `start` or `end`. + * Default value is `end`. + * ```html + * + * + * ``` + * + * @memberOf IgxCarouselComponent + */ + @Input() public indicatorsOrientation: CarouselIndicatorsOrientation = CarouselIndicatorsOrientation.end; + + /** + * Gets/sets the animation type of carousel. + * Default value is `slide`. + * ```html + * + * + * ``` + * + * @memberOf IgxCarouselComponent + */ + @Input() public override animationType: CarouselAnimationType = CarouselAnimationType.slide; + + /** + * The custom template, if any, that should be used when rendering carousel indicators + * + * ```typescript + * // Set in typescript + * const myCustomTemplate: TemplateRef = myComponent.customTemplate; + * myComponent.carousel.indicatorTemplate = myCustomTemplate; + * ``` + * ```html + * + * + * ... + * + * brightness_7 + * brightness_5 + * + * + * ``` + */ + @ContentChild(IgxCarouselIndicatorDirective, { read: TemplateRef, static: false }) + public indicatorTemplate: TemplateRef = null; + + /** + * The custom template, if any, that should be used when rendering carousel next button + * + * ```typescript + * // Set in typescript + * const myCustomTemplate: TemplateRef = myComponent.customTemplate; + * myComponent.carousel.nextButtonTemplate = myCustomTemplate; + * ``` + * ```html + * + * + * ... + * + * + * + * + * ``` + */ + @ContentChild(IgxCarouselNextButtonDirective, { read: TemplateRef, static: false }) + public nextButtonTemplate: TemplateRef = null; + + /** + * The custom template, if any, that should be used when rendering carousel previous button + * + * ```typescript + * // Set in typescript + * const myCustomTemplate: TemplateRef = myComponent.customTemplate; + * myComponent.carousel.prevButtonTemplate = myCustomTemplate; + * ``` + * ```html + * + * + * ... + * + * + * + * + * ``` + */ + @ContentChild(IgxCarouselPrevButtonDirective, { read: TemplateRef, static: false }) + public prevButtonTemplate: TemplateRef = null; + + /** + * The collection of `slides` currently in the carousel. + * ```typescript + * let slides: QueryList = this.carousel.slides; + * ``` + * + * @memberOf IgxCarouselComponent + */ + @ContentChildren(IgxSlideComponent) + public slides: QueryList; + + /** + * An event that is emitted after a slide transition has happened. + * Provides references to the `IgxCarouselComponent` and `IgxSlideComponent` as event arguments. + * ```html + * + * ``` + * + * @memberOf IgxCarouselComponent + */ + @Output() public slideChanged = new EventEmitter(); + + /** + * An event that is emitted after a slide has been added to the carousel. + * Provides references to the `IgxCarouselComponent` and `IgxSlideComponent` as event arguments. + * ```html + * + * ``` + * + * @memberOf IgxCarouselComponent + */ + @Output() public slideAdded = new EventEmitter(); + + /** + * An event that is emitted after a slide has been removed from the carousel. + * Provides references to the `IgxCarouselComponent` and `IgxSlideComponent` as event arguments. + * ```html + * + * ``` + * + * @memberOf IgxCarouselComponent + */ + @Output() public slideRemoved = new EventEmitter(); + + /** + * An event that is emitted after the carousel has been paused. + * Provides a reference to the `IgxCarouselComponent` as an event argument. + * ```html + * + * ``` + * + * @memberOf IgxCarouselComponent + */ + @Output() public carouselPaused = new EventEmitter(); + + /** + * An event that is emitted after the carousel has resumed transitioning between `slides`. + * Provides a reference to the `IgxCarouselComponent` as an event argument. + * ```html + * + * ``` + * + * @memberOf IgxCarouselComponent + */ + @Output() public carouselPlaying = new EventEmitter(); + + @ViewChild('defaultIndicator', { read: TemplateRef, static: true }) + private defaultIndicator: TemplateRef; + + @ViewChild('defaultNextButton', { read: TemplateRef, static: true }) + private defaultNextButton: TemplateRef; + + @ViewChild('defaultPrevButton', { read: TemplateRef, static: true }) + private defaultPrevButton: TemplateRef; + + @ViewChildren('indicators', { read: ElementRef }) + private _indicators: QueryList>; + + /** + * @hidden + * @internal + */ + public stoppedByInteraction: boolean; + protected override currentItem: IgxSlideComponent; + protected override previousItem: IgxSlideComponent; + private _interval: number; + private _resourceStrings = getCurrentResourceStrings(CarouselResourceStringsEN); + private lastInterval: any; + private playing: boolean; + private destroyed: boolean; + private destroy$ = new Subject(); + private differ: IterableDiffer | null = null; + private incomingSlide: IgxSlideComponent; + private _hasKeyboardFocusOnIndicators = false; + + /** + * An accessor that sets the resource strings. + * By default it uses EN resources. + */ + @Input() + public set resourceStrings(value: ICarouselResourceStrings) { + this._resourceStrings = Object.assign({}, this._resourceStrings, value); + } + + /** + * An accessor that returns the resource strings. + */ + public get resourceStrings(): ICarouselResourceStrings { + return this._resourceStrings; + } + + /** @hidden */ + public get getIndicatorTemplate(): TemplateRef { + if (this.indicatorTemplate) { + return this.indicatorTemplate; + } + return this.defaultIndicator; + } + + /** @hidden */ + public get getNextButtonTemplate(): TemplateRef { + if (this.nextButtonTemplate) { + return this.nextButtonTemplate; + } + + return this.defaultNextButton + } + + /** @hidden */ + public get getPrevButtonTemplate(): TemplateRef { + if (this.prevButtonTemplate) { + return this.prevButtonTemplate; + } + + return this.defaultPrevButton + } + + /** @hidden */ + public get indicatorsClass() { + return { + ['igx-carousel-indicators--focused']: this._hasKeyboardFocusOnIndicators, + [`igx-carousel-indicators--${this.getIndicatorsClass()}`]: true + }; + } + + /** @hidden */ + public get showIndicators(): boolean { + return this.indicators && this.total <= this.maximumIndicatorsCount && this.total > 0; + } + + /** @hidden */ + public get showIndicatorsLabel(): boolean { + return this.indicators && this.total > this.maximumIndicatorsCount; + } + + /** @hidden */ + public get getCarouselLabel() { + return `${this.current + 1} ${this.resourceStrings.igx_carousel_of} ${this.total}`; + } + + /** + * Returns the total number of `slides` in the carousel. + * ```typescript + * let slideCount = this.carousel.total; + * ``` + * + * @memberOf IgxCarouselComponent + */ + public get total(): number { + return this.slides?.length; + } + + /** + * The index of the slide being currently shown. + * ```typescript + * let currentSlideNumber = this.carousel.current; + * ``` + * + * @memberOf IgxCarouselComponent + */ + public get current(): number { + return !this.currentItem ? 0 : this.currentItem.index; + } + + /** + * Returns a boolean indicating if the carousel is playing. + * ```typescript + * let isPlaying = this.carousel.isPlaying; + * ``` + * + * @memberOf IgxCarouselComponent + */ + public get isPlaying(): boolean { + return this.playing; + } + + /** + * Returns а boolean indicating if the carousel is destroyed. + * ```typescript + * let isDestroyed = this.carousel.isDestroyed; + * ``` + * + * @memberOf IgxCarouselComponent + */ + public get isDestroyed(): boolean { + return this.destroyed; + } + /** + * Returns a reference to the carousel element in the DOM. + * ```typescript + * let nativeElement = this.carousel.nativeElement; + * ``` + * + * @memberof IgxCarouselComponent + */ + public get nativeElement(): any { + return this.element.nativeElement; + } + + /** + * Returns the time `interval` in milliseconds before the slide changes. + * ```typescript + * let timeInterval = this.carousel.interval; + * ``` + * + * @memberof IgxCarouselComponent + */ + @Input() + public get interval(): number { + return this._interval; + } + + /** + * Sets the time `interval` in milliseconds before the slide changes. + * If not set, the carousel will not change `slides` automatically. + * ```html + * + * ``` + * + * @memberof IgxCarouselComponent + */ + public set interval(value: number) { + this._interval = +value; + this.restartInterval(); + } + + constructor() { + super(); + this.differ = this.iterableDiffers.find([]).create(null); + } + + /** @hidden */ + @HostListener('tap', ['$event']) + public onTap(event) { + // play pause only when tap on slide + if (event.target && event.target.classList.contains('igx-slide')) { + if (this.isPlaying) { + if (this.pause) { + this.stoppedByInteraction = true; + } + this.stop(); + } else if (this.stoppedByInteraction) { + this.play(); + } + } + } + + /** @hidden */ + @HostListener('mouseenter') + public onMouseEnter() { + if (this.pause && this.isPlaying) { + this.stoppedByInteraction = true; + } + this.stop(); + } + + /** @hidden */ + @HostListener('mouseleave') + public onMouseLeave() { + if (this.stoppedByInteraction) { + this.play(); + } + } + + /** @hidden */ + @HostListener('panleft', ['$event']) + public onPanLeft(event) { + if (!this.vertical) { + this.pan(event); + } + } + + /** @hidden */ + @HostListener('panright', ['$event']) + public onPanRight(event) { + if (!this.vertical) { + this.pan(event); + } + } + + /** @hidden */ + @HostListener('panup', ['$event']) + public onPanUp(event) { + if (this.vertical) { + this.pan(event); + } + } + + /** @hidden */ + @HostListener('pandown', ['$event']) + public onPanDown(event) { + if (this.vertical) { + this.pan(event); + } + } + + /** + * @hidden + */ + @HostListener('panend', ['$event']) + public onPanEnd(event) { + if (!this.gesturesSupport) { + return; + } + event.preventDefault(); + + const slideSize = this.vertical + ? this.currentItem.nativeElement.offsetHeight + : this.currentItem.nativeElement.offsetWidth; + const panOffset = (slideSize / 1000); + const eventDelta = this.vertical ? event.deltaY : event.deltaX; + const delta = Math.abs(eventDelta) + panOffset < slideSize ? Math.abs(eventDelta) : slideSize - panOffset; + const velocity = Math.abs(event.velocity); + this.resetSlideStyles(this.currentItem); + if (this.incomingSlide) { + this.resetSlideStyles(this.incomingSlide); + if (slideSize / 2 < delta || velocity > 1) { + this.incomingSlide.direction = eventDelta < 0 ? CarouselAnimationDirection.NEXT : CarouselAnimationDirection.PREV; + this.incomingSlide.previous = false; + + this.animationPosition = this.animationType === CarouselAnimationType.fade ? + delta / slideSize : (slideSize - delta) / slideSize; + + if (velocity > 1) { + this.newDuration = this.defaultAnimationDuration / velocity; + } + this.incomingSlide.active = true; + } else { + this.currentItem.direction = eventDelta > 0 ? CarouselAnimationDirection.NEXT : CarouselAnimationDirection.PREV; + this.previousItem = this.incomingSlide; + this.previousItem.previous = true; + this.animationPosition = this.animationType === CarouselAnimationType.fade ? + Math.abs((slideSize - delta) / slideSize) : delta / slideSize; + this.playAnimations(); + } + } + + if (this.stoppedByInteraction) { + this.play(); + } + } + + /** @hidden */ + public ngAfterContentInit() { + this.slides.changes + .pipe(takeUntil(this.destroy$)) + .subscribe((change: QueryList) => this.initSlides(change)); + + this.initSlides(this.slides); + } + + /** @hidden */ + public override ngOnDestroy() { + super.ngOnDestroy(); + this.destroy$.next(true); + this.destroy$.complete(); + this.destroyed = true; + if (this.lastInterval) { + clearInterval(this.lastInterval); + } + } + + /** @hidden */ + public handleKeydownPrev(event: KeyboardEvent): void { + if (this.platformUtil.isActivationKey(event)) { + event.preventDefault(); + this.prev(); + } + } + + /** @hidden */ + public handleKeydownNext(event: KeyboardEvent): void { + if (this.platformUtil.isActivationKey(event)) { + event.preventDefault(); + this.next(); + } + } + + /** @hidden */ + public handleKeyUp(event: KeyboardEvent): void { + if (event.key === this.platformUtil.KEYMAP.TAB) { + this._hasKeyboardFocusOnIndicators = true; + } + } + + /** @hidden */ + public handleFocusOut(event: FocusEvent): void { + const target = event.relatedTarget as HTMLElement; + + if (!target || !target.classList.contains('igx-carousel-indicators__indicator')) { + this._hasKeyboardFocusOnIndicators = false; + } + } + + /** @hidden */ + public handleClick(): void { + this._hasKeyboardFocusOnIndicators = false; + } + + /** @hidden */ + public handleKeydown(event: KeyboardEvent): void { + const { key } = event; + const slides = this.slides.toArray(); + + switch (key) { + case this.platformUtil.KEYMAP.ARROW_LEFT: + this.dir.rtl ? this.next() : this.prev(); + break; + case this.platformUtil.KEYMAP.ARROW_RIGHT: + this.dir.rtl ? this.prev() : this.next(); + break; + case this.platformUtil.KEYMAP.HOME: + event.preventDefault(); + this.select(this.dir.rtl ? last(slides) : first(slides)); + break; + case this.platformUtil.KEYMAP.END: + event.preventDefault(); + this.select(this.dir.rtl ? first(slides) : last(slides)); + break; + } + + this.indicatorsElements[this.current].nativeElement.focus(); + } + + /** + * Returns the slide corresponding to the provided `index` or null. + * ```typescript + * let slide1 = this.carousel.get(1); + * ``` + * + * @memberOf IgxCarouselComponent + */ + public get(index: number): IgxSlideComponent { + return this.slides.find((slide) => slide.index === index); + } + + /** + * Adds a new slide to the carousel. + * ```typescript + * this.carousel.add(newSlide); + * ``` + * + * @memberOf IgxCarouselComponent + */ + public add(slide: IgxSlideComponent) { + const newSlides = this.slides.toArray(); + newSlides.push(slide); + this.slides.reset(newSlides); + this.slides.notifyOnChanges(); + } + + /** + * Removes a slide from the carousel. + * ```typescript + * this.carousel.remove(slide); + * ``` + * + * @memberOf IgxCarouselComponent + */ + public remove(slide: IgxSlideComponent) { + if (slide && slide === this.get(slide.index)) { // check if the requested slide for delete is present in the carousel + const newSlides = this.slides.toArray(); + newSlides.splice(slide.index, 1); + this.slides.reset(newSlides); + this.slides.notifyOnChanges(); + } + } + + /** + * Switches to the passed-in slide with a given `direction`. + * ```typescript + * const slide = this.carousel.get(2); + * this.carousel.select(slide, CarouselAnimationDirection.NEXT); + * ``` + * + * @memberOf IgxCarouselComponent + */ + public select(slide: IgxSlideComponent, direction?: CarouselAnimationDirection): void; + /** + * Switches to slide by index with a given `direction`. + * ```typescript + * this.carousel.select(2, CarouselAnimationDirection.NEXT); + * ``` + * + * @memberOf IgxCarouselComponent + */ + public select(index: number, direction?: CarouselAnimationDirection): void; + public select(slideOrIndex: IgxSlideComponent | number, direction: CarouselAnimationDirection = CarouselAnimationDirection.NONE): void { + const slide = typeof slideOrIndex === 'number' + ? this.get(slideOrIndex) + : slideOrIndex; + + if (slide && slide !== this.currentItem) { + slide.direction = direction; + slide.active = true; + } + } + + /** + * Transitions to the next slide in the carousel. + * ```typescript + * this.carousel.next(); + * ``` + * + * @memberOf IgxCarouselComponent + */ + public next() { + const index = this.getNextIndex(); + + if (index === 0 && !this.loop) { + this.stop(); + return; + } + return this.select(this.get(index), CarouselAnimationDirection.NEXT); + } + + /** + * Transitions to the previous slide in the carousel. + * ```typescript + * this.carousel.prev(); + * ``` + * + * @memberOf IgxCarouselComponent + */ + public prev() { + const index = this.getPrevIndex(); + + if (!this.loop && index === this.total - 1) { + this.stop(); + return; + } + return this.select(this.get(index), CarouselAnimationDirection.PREV); + } + + /** + * Resumes playing of the carousel if in paused state. + * No operation otherwise. + * ```typescript + * this.carousel.play(); + * } + * ``` + * + * @memberOf IgxCarouselComponent + */ + public play() { + if (!this.playing) { + this.playing = true; + this.carouselPlaying.emit(this); + this.restartInterval(); + this.stoppedByInteraction = false; + } + } + + /** + * Stops slide transitions if the `pause` option is set to `true`. + * No operation otherwise. + * ```typescript + * this.carousel.stop(); + * } + * ``` + * + * @memberOf IgxCarouselComponent + */ + public stop() { + if (this.pause) { + this.playing = false; + this.carouselPaused.emit(this); + this.resetInterval(); + } + } + + protected getPreviousElement(): HTMLElement { + return this.previousItem.nativeElement; + } + + protected getCurrentElement(): HTMLElement { + return this.currentItem.nativeElement; + } + + private resetInterval() { + if (this.lastInterval) { + clearInterval(this.lastInterval); + this.lastInterval = null; + } + } + + private restartInterval() { + this.resetInterval(); + + if (!isNaN(this.interval) && this.interval > 0 && this.platformUtil.isBrowser) { + this.lastInterval = setInterval(() => { + const tick = +this.interval; + if (this.playing && this.total && !isNaN(tick) && tick > 0) { + this.next(); + } else { + this.stop(); + } + }, this.interval); + } + } + + /** @hidden */ + public get nextButtonDisabled() { + return !this.loop && this.current === (this.total - 1); + } + + /** @hidden */ + public get prevButtonDisabled() { + return !this.loop && this.current === 0; + } + + private get indicatorsElements() { + return this._indicators.toArray(); + } + + private getIndicatorsClass(): string { + switch (this.indicatorsOrientation) { + case CarouselIndicatorsOrientation.top: + return CarouselIndicatorsOrientation.start; + case CarouselIndicatorsOrientation.bottom: + return CarouselIndicatorsOrientation.end; + default: + return this.indicatorsOrientation; + } + } + + private getNextIndex(): number { + return (this.current + 1) % this.total; + } + + private getPrevIndex(): number { + return this.current - 1 < 0 ? this.total - 1 : this.current - 1; + } + + private resetSlideStyles(slide: IgxSlideComponent) { + slide.nativeElement.style.transform = ''; + slide.nativeElement.style.opacity = ''; + } + + private pan(event) { + const slideSize = this.vertical + ? this.currentItem.nativeElement.offsetHeight + : this.currentItem.nativeElement.offsetWidth; + const panOffset = (slideSize / 1000); + const delta = this.vertical ? event.deltaY : event.deltaX; + const index = delta < 0 ? this.getNextIndex() : this.getPrevIndex(); + const offset = delta < 0 ? slideSize + delta : -slideSize + delta; + + if (!this.gesturesSupport || event.isFinal || Math.abs(delta) + panOffset >= slideSize) { + return; + } + + if (!this.loop && ((this.current === 0 && delta > 0) || (this.current === this.total - 1 && delta < 0))) { + this.incomingSlide = null; + return; + } + + event.preventDefault(); + if (this.isPlaying) { + this.stoppedByInteraction = true; + this.stop(); + } + + if (this.previousItem && this.previousItem.previous) { + this.previousItem.previous = false; + } + this.finishAnimations(); + + if (this.incomingSlide) { + if (index !== this.incomingSlide.index) { + this.resetSlideStyles(this.incomingSlide); + this.incomingSlide.previous = false; + this.incomingSlide = this.get(index); + } + } else { + this.incomingSlide = this.get(index); + } + this.incomingSlide.previous = true; + + if (this.animationType === CarouselAnimationType.fade) { + this.currentItem.nativeElement.style.opacity = `${Math.abs(offset) / slideSize}`; + } else { + this.currentItem.nativeElement.style.transform = this.vertical + ? `translateY(${delta}px)` + : `translateX(${delta}px)`; + this.incomingSlide.nativeElement.style.transform = this.vertical + ? `translateY(${offset}px)` + : `translateX(${offset}px)`; + } + } + + private unsubscriber(slide: IgxSlideComponent) { + return merge(this.destroy$, slide.isDestroyed); + } + + private onSlideActivated(slide: IgxSlideComponent) { + if (slide.active && slide !== this.currentItem) { + if (slide.direction === CarouselAnimationDirection.NONE) { + const newIndex = slide.index; + slide.direction = newIndex > this.current ? CarouselAnimationDirection.NEXT : CarouselAnimationDirection.PREV; + } + + if (this.currentItem) { + if (this.previousItem && this.previousItem.previous) { + this.previousItem.previous = false; + } + this.currentItem.direction = slide.direction; + this.currentItem.active = false; + + this.previousItem = this.currentItem; + this.currentItem = slide; + this.triggerAnimations(); + } else { + this.currentItem = slide; + } + this.slideChanged.emit({ carousel: this, slide }); + this.restartInterval(); + this.cdr.markForCheck(); + } + } + + + private finishAnimations() { + if (this.animationStarted(this.leaveAnimationPlayer)) { + this.leaveAnimationPlayer.finish(); + } + + if (this.animationStarted(this.enterAnimationPlayer)) { + this.enterAnimationPlayer.finish(); + } + } + + private initSlides(change: QueryList) { + const diff = this.differ.diff(change.toArray()); + if (diff) { + this.slides.reduce((any, c, ind) => c.index = ind, 0); // reset slides indexes + diff.forEachAddedItem((record: IterableChangeRecord) => { + const slide = record.item; + slide.total = this.total; + this.slideAdded.emit({ carousel: this, slide }); + if (slide.active) { + this.currentItem = slide; + } + slide.activeChange.pipe(takeUntil(this.unsubscriber(slide))).subscribe(() => this.onSlideActivated(slide)); + }); + + diff.forEachRemovedItem((record: IterableChangeRecord) => { + const slide = record.item; + this.slideRemoved.emit({ carousel: this, slide }); + if (slide.active) { + slide.active = false; + this.currentItem = this.get(slide.index < this.total ? slide.index : this.total - 1); + } + }); + + this.updateSlidesSelection(); + } + } + + private updateSlidesSelection() { + if (this.platformUtil.isBrowser) { + requestAnimationFrame(() => { + if (this.currentItem) { + this.currentItem.active = true; + const activeSlides = this.slides.filter(slide => slide.active && slide.index !== this.currentItem.index); + activeSlides.forEach(slide => slide.active = false); + } else if (this.total) { + this.slides.first.active = true; + } + this.play(); + }); + } + } +} + +export interface ISlideEventArgs extends IBaseEventArgs { + carousel: IgxCarouselComponent; + slide: IgxSlideComponent; +} diff --git a/projects/igniteui-angular/carousel/src/carousel/carousel.directives.ts b/projects/igniteui-angular/carousel/src/carousel/carousel.directives.ts new file mode 100644 index 00000000000..3803cd6cc75 --- /dev/null +++ b/projects/igniteui-angular/carousel/src/carousel/carousel.directives.ts @@ -0,0 +1,22 @@ +import { Directive } from '@angular/core'; + +@Directive({ + selector: '[igxCarouselIndicator]', + standalone: true +}) +export class IgxCarouselIndicatorDirective { +} + +@Directive({ + selector: '[igxCarouselNextButton]', + standalone: true +}) +export class IgxCarouselNextButtonDirective { +} + +@Directive({ + selector: '[igxCarouselPrevButton]', + standalone: true +}) +export class IgxCarouselPrevButtonDirective { +} diff --git a/projects/igniteui-angular/carousel/src/carousel/carousel.module.ts b/projects/igniteui-angular/carousel/src/carousel/carousel.module.ts new file mode 100644 index 00000000000..a12ed7eb531 --- /dev/null +++ b/projects/igniteui-angular/carousel/src/carousel/carousel.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { IGX_CAROUSEL_DIRECTIVES } from './public_api'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + ...IGX_CAROUSEL_DIRECTIVES + ], + exports: [ + ...IGX_CAROUSEL_DIRECTIVES + ] +}) +export class IgxCarouselModule { +} diff --git a/projects/igniteui-angular/carousel/src/carousel/enums.ts b/projects/igniteui-angular/carousel/src/carousel/enums.ts new file mode 100644 index 00000000000..06a41379133 --- /dev/null +++ b/projects/igniteui-angular/carousel/src/carousel/enums.ts @@ -0,0 +1,21 @@ + +export const CarouselAnimationType = { + none: 'none', + slide: 'slide', + fade: 'fade' +} as const; +export type CarouselAnimationType = (typeof CarouselAnimationType)[keyof typeof CarouselAnimationType]; + +export const CarouselIndicatorsOrientation = { + /** + * @deprecated in version 19.1.0. Use `end` instead. + */ + bottom: 'bottom', + /** + * @deprecated in version 19.1.0. Use `start` instead. + */ + top: 'top', + start: 'start', + end: 'end' +} as const; +export type CarouselIndicatorsOrientation = (typeof CarouselIndicatorsOrientation)[keyof typeof CarouselIndicatorsOrientation]; diff --git a/projects/igniteui-angular/carousel/src/carousel/public_api.ts b/projects/igniteui-angular/carousel/src/carousel/public_api.ts new file mode 100644 index 00000000000..350e9641a2c --- /dev/null +++ b/projects/igniteui-angular/carousel/src/carousel/public_api.ts @@ -0,0 +1,18 @@ +import { IgxCarouselComponent } from './carousel.component'; +import { IgxCarouselIndicatorDirective, IgxCarouselNextButtonDirective, IgxCarouselPrevButtonDirective } from './carousel.directives'; +import { IgxSlideComponent } from './slide.component'; + +export { CarouselAnimationDirection, IgxCarouselComponentBase, IgxSlideComponentBase, CarouselAnimationSettings } from './carousel-base'; +export * from './carousel.component'; +export * from './slide.component'; +export * from './carousel.directives'; +export { CarouselAnimationType, CarouselIndicatorsOrientation } from './enums'; + +/* NOTE: Carousel directives collection for ease-of-use import in standalone components scenario */ +export const IGX_CAROUSEL_DIRECTIVES = [ + IgxCarouselComponent, + IgxSlideComponent, + IgxCarouselIndicatorDirective, + IgxCarouselNextButtonDirective, + IgxCarouselPrevButtonDirective +] as const; diff --git a/projects/igniteui-angular/carousel/src/carousel/slide.component.html b/projects/igniteui-angular/carousel/src/carousel/slide.component.html new file mode 100644 index 00000000000..6dbc7430638 --- /dev/null +++ b/projects/igniteui-angular/carousel/src/carousel/slide.component.html @@ -0,0 +1 @@ + diff --git a/projects/igniteui-angular/carousel/src/carousel/slide.component.ts b/projects/igniteui-angular/carousel/src/carousel/slide.component.ts new file mode 100644 index 00000000000..308f5d52d16 --- /dev/null +++ b/projects/igniteui-angular/carousel/src/carousel/slide.component.ts @@ -0,0 +1,166 @@ +import { Component, OnDestroy, Input, HostBinding, Output, EventEmitter, ElementRef, AfterContentChecked, booleanAttribute, inject } from '@angular/core'; +import { Subject } from 'rxjs'; +import { CarouselAnimationDirection, IgxSlideComponentBase } from './carousel-base'; + +/** + * A slide component that usually holds an image and/or a caption text. + * IgxSlideComponent is usually a child component of an IgxCarouselComponent. + * + * ``` + * + * + * + * ``` + * + * @export + */ +@Component({ + selector: 'igx-slide', + templateUrl: 'slide.component.html', + standalone: true +}) +export class IgxSlideComponent implements AfterContentChecked, OnDestroy, IgxSlideComponentBase { + private elementRef = inject(ElementRef); + + /** + * Gets/sets the `index` of the slide inside the carousel. + * ```html + * + * + * + * ``` + * + * @memberOf IgxSlideComponent + */ + @Input() public index: number; + + /** + * Gets/sets the target `direction` for the slide. + * ```html + * + * + * + * ``` + * + * @memberOf IgxSlideComponent + */ + @Input() public direction: CarouselAnimationDirection; + + @Input() + public total: number; + + /** + * Returns the `tabIndex` of the slide component. + * ```typescript + * let tabIndex = this.carousel.tabIndex; + * ``` + * + * @memberof IgxSlideComponent + * @deprecated in version 19.2.0. + */ + @HostBinding('attr.tabindex') + public get tabIndex() { + return this.active ? 0 : null; + } + + /** + * @hidden + */ + @HostBinding('attr.id') + public id: string; + + /** + * Returns the `role` of the slide component. + * By default is set to `tabpanel` + * + * @memberof IgxSlideComponent + */ + @HostBinding('attr.role') + public tab = 'tabpanel'; + + /** @hidden */ + @HostBinding('attr.aria-labelledby') + public ariaLabelledBy; + + /** + * Returns the class of the slide component. + * ```typescript + * let class = this.slide.cssClass; + * ``` + * + * @memberof IgxSlideComponent + */ + @HostBinding('class.igx-slide') + public cssClass = 'igx-slide'; + + /** + * Gets/sets the `active` state of the slide. + * ```html + * + * + * + * ``` + * + * Two-way data binding. + * ```html + * + * + * + * ``` + * + * @memberof IgxSlideComponent + */ + @HostBinding('class.igx-slide--current') + @Input({ transform: booleanAttribute }) + public get active(): boolean { + return this._active; + } + + public set active(value) { + this._active = value; + this.activeChange.emit(this._active); + } + + @HostBinding('class.igx-slide--previous') + @Input({ transform: booleanAttribute }) public previous = false; + + /** + * @hidden + */ + @Output() public activeChange = new EventEmitter(); + + private _active = false; + private _destroy$ = new Subject(); + + /** + * Returns a reference to the carousel element in the DOM. + * ```typescript + * let nativeElement = this.slide.nativeElement; + * ``` + * + * @memberof IgxSlideComponent + */ + public get nativeElement() { + return this.elementRef.nativeElement; + } + + /** + * @hidden + */ + public get isDestroyed(): Subject { + return this._destroy$; + } + + public ngAfterContentChecked() { + this.id = `panel-${this.index}`; + this.ariaLabelledBy = `tab-${this.index}-${this.total}`; + } + + /** + * @hidden + */ + public ngOnDestroy() { + this._destroy$.next(true); + this._destroy$.complete(); + } +} diff --git a/projects/igniteui-angular/carousel/src/public_api.ts b/projects/igniteui-angular/carousel/src/public_api.ts new file mode 100644 index 00000000000..1a8f5a6a10d --- /dev/null +++ b/projects/igniteui-angular/carousel/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './carousel/public_api'; +export * from './carousel/carousel.module'; diff --git a/projects/igniteui-angular/chat-extras/index.ts b/projects/igniteui-angular/chat-extras/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/chat-extras/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/chat-extras/ng-package.json b/projects/igniteui-angular/chat-extras/ng-package.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/projects/igniteui-angular/chat-extras/ng-package.json @@ -0,0 +1 @@ +{} diff --git a/projects/igniteui-angular/chat-extras/src/markdown-pipe.spec.ts b/projects/igniteui-angular/chat-extras/src/markdown-pipe.spec.ts new file mode 100644 index 00000000000..cffbb6d5c2b --- /dev/null +++ b/projects/igniteui-angular/chat-extras/src/markdown-pipe.spec.ts @@ -0,0 +1,57 @@ +import { DomSanitizer } from '@angular/platform-browser'; +import { TestBed } from '@angular/core/testing'; +import { IgxChatMarkdownService } from './markdown-service'; +import { MarkdownPipe } from './markdown-pipe'; +import Spy = jasmine.Spy; + +// Mock the Service: We only care that the pipe calls the service and gets an HTML string. +// We provide a *known* unsafe HTML string to ensure sanitization is working. +const mockUnsafeHtml = ` +
unsafe
+ +`; + +class MockChatMarkdownService { + public async parse(_: string): Promise { + return mockUnsafeHtml; + } +} + +describe('MarkdownPipe', () => { + let pipe: MarkdownPipe; + let sanitizer: DomSanitizer; + let bypassSpy: Spy; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + MarkdownPipe, + { provide: IgxChatMarkdownService, useClass: MockChatMarkdownService }, + ], + }); + + pipe = TestBed.inject(MarkdownPipe); + sanitizer = TestBed.inject(DomSanitizer); + bypassSpy = spyOn(sanitizer, 'bypassSecurityTrustHtml').and.callThrough(); + }); + + it('should be created', () => { + expect(pipe).toBeTruthy(); + }); + + it('should call the service, sanitize content, and return SafeHtml', async () => { + await pipe.transform('some markdown'); + + expect(bypassSpy).toHaveBeenCalledTimes(1); + + const sanitizedString = bypassSpy.calls.mostRecent().args[0]; + + expect(sanitizedString).not.toContain('onerror'); + expect(sanitizedString).toContain('style="color: var(--shiki-fg);"'); + }); + + it('should handle undefined input text', async () => { + await pipe.transform(undefined); + expect(sanitizer.bypassSecurityTrustHtml).toHaveBeenCalled(); + }); +}); diff --git a/projects/igniteui-angular/chat-extras/src/markdown-pipe.ts b/projects/igniteui-angular/chat-extras/src/markdown-pipe.ts new file mode 100644 index 00000000000..9b7bb9bcb96 --- /dev/null +++ b/projects/igniteui-angular/chat-extras/src/markdown-pipe.ts @@ -0,0 +1,18 @@ +import DOMPurify from 'dompurify'; +import { inject, Pipe, type PipeTransform } from '@angular/core'; +import { IgxChatMarkdownService } from './markdown-service'; +import { DomSanitizer, type SafeHtml } from '@angular/platform-browser'; + + +@Pipe({ name: 'fromMarkdown' }) +export class MarkdownPipe implements PipeTransform { + private _service = inject(IgxChatMarkdownService); + private _sanitizer = inject(DomSanitizer); + + + public async transform(text?: string): Promise { + return this._sanitizer.bypassSecurityTrustHtml(DOMPurify.sanitize( + await this._service.parse(text ?? '') + )); + } +} diff --git a/projects/igniteui-angular/chat-extras/src/markdown-service.spec.ts b/projects/igniteui-angular/chat-extras/src/markdown-service.spec.ts new file mode 100644 index 00000000000..4d25633f394 --- /dev/null +++ b/projects/igniteui-angular/chat-extras/src/markdown-service.spec.ts @@ -0,0 +1,41 @@ +import { TestBed } from '@angular/core/testing'; +import { IgxChatMarkdownService } from './markdown-service'; + +describe('IgxChatMarkdownService', () => { + let service: IgxChatMarkdownService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(IgxChatMarkdownService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should parse basic markdown to HTML', async () => { + const markdown = '**Hello** *World*'; + const expectedHtml = '

Hello World

\n'; + + const result = await service.parse(markdown); + expect(result).toBe(expectedHtml); + }); + + it('should parse a code block with shiki highlighting', async () => { + const markdown = '```typescript\nconst x = 5;\n```'; + const result = await service.parse(markdown); + + expect(result).toContain('
 {
+        const markdown = '[Infragistics](https://www.infragistics.com)';
+        const expectedLink = '

Infragistics

'; + + const result = await service.parse(markdown); + expect(result).toContain(expectedLink); + }); +}); diff --git a/projects/igniteui-angular/chat-extras/src/markdown-service.ts b/projects/igniteui-angular/chat-extras/src/markdown-service.ts new file mode 100644 index 00000000000..4f2edf2a508 --- /dev/null +++ b/projects/igniteui-angular/chat-extras/src/markdown-service.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@angular/core'; +import { Marked } from 'marked'; +import markedShiki from 'marked-shiki'; +import { bundledThemes, createHighlighter } from 'shiki/bundle/web'; + + +const DEFAULT_LANGUAGES = ['javascript', 'typescript', 'html', 'css']; +const DEFAULT_THEMES = { + light: 'github-light', + dark: 'github-dark' +}; + +@Injectable({ providedIn: 'root' }) +export class IgxChatMarkdownService { + + private _instance: Marked; + private _isInitialized: Promise; + + private _initializeMarked(): void { + this._instance = new Marked({ + breaks: true, + gfm: true, + extensions: [ + { + name: 'link', + renderer({ href, title, text }) { + return `${text}`; + } + } + ] + }); + } + + private async _initializeShiki(): Promise { + const highlighter = await createHighlighter({ + langs: DEFAULT_LANGUAGES, + themes: Object.keys(bundledThemes) + }); + + this._instance.use( + markedShiki({ + highlight(code, lang, _) { + try { + return highlighter.codeToHtml(code, { + lang, + themes: DEFAULT_THEMES, + }); + + } catch { + return `
${code}
`; + } + } + }) + ); + } + + + constructor() { + this._initializeMarked(); + this._isInitialized = this._initializeShiki(); + } + + public async parse(text: string): Promise { + await this._isInitialized; + return await this._instance.parse(text); + } +} diff --git a/projects/igniteui-angular/chat-extras/src/public_api.ts b/projects/igniteui-angular/chat-extras/src/public_api.ts new file mode 100644 index 00000000000..de599f08302 --- /dev/null +++ b/projects/igniteui-angular/chat-extras/src/public_api.ts @@ -0,0 +1 @@ +export { MarkdownPipe } from './markdown-pipe'; diff --git a/projects/igniteui-angular/chat/index.ts b/projects/igniteui-angular/chat/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/chat/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/chat/ng-package.json b/projects/igniteui-angular/chat/ng-package.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/projects/igniteui-angular/chat/ng-package.json @@ -0,0 +1 @@ +{} diff --git a/projects/igniteui-angular/chat/src/chat.component.html b/projects/igniteui-angular/chat/src/chat.component.html new file mode 100644 index 00000000000..896e36340b4 --- /dev/null +++ b/projects/igniteui-angular/chat/src/chat.component.html @@ -0,0 +1,16 @@ + + + diff --git a/projects/igniteui-angular/chat/src/chat.component.ts b/projects/igniteui-angular/chat/src/chat.component.ts new file mode 100644 index 00000000000..97aa851aeff --- /dev/null +++ b/projects/igniteui-angular/chat/src/chat.component.ts @@ -0,0 +1,329 @@ +import { + ChangeDetectionStrategy, + Component, + CUSTOM_ELEMENTS_SCHEMA, + Directive, + effect, + inject, + input, + OnInit, + output, + signal, + TemplateRef, + ViewContainerRef, + OnDestroy, + ViewRef, + computed, +} from '@angular/core'; +import { + IgcChatComponent, + type IgcChatMessageAttachment, + type IgcChatMessage, + type IgcChatOptions, + type ChatRenderContext, + type ChatRenderers, + type ChatAttachmentRenderContext, + type ChatInputRenderContext, + type ChatMessageRenderContext, + type IgcChatMessageReaction, +} from 'igniteui-webcomponents'; + +type ChatContextUnion = + | ChatAttachmentRenderContext + | ChatMessageRenderContext + | ChatInputRenderContext + | ChatRenderContext; + +type ChatContextType = + T extends ChatAttachmentRenderContext + ? IgcChatMessageAttachment + : T extends ChatMessageRenderContext + ? IgcChatMessage + : T extends ChatInputRenderContext + ? string + : T extends ChatRenderContext + ? { instance: IgcChatComponent } + : never; + +type ExtractChatContext = T extends (ctx: infer R) => any ? R : never; + +type ChatTemplatesContextMap = { + [K in keyof ChatRenderers]: { + $implicit: ChatContextType< + ExtractChatContext> & ChatContextUnion + >; + }; +}; + +/** + * Template references for customizing chat component rendering. + * Each property corresponds to a specific part of the chat UI that can be customized. + * + * @example + * ```typescript + * templates = { + * messageContent: this.customMessageTemplate, + * attachment: this.customAttachmentTemplate + * } + * ``` + */ +export type IgxChatTemplates = { + [K in keyof Omit]?: TemplateRef; +}; + +/** + * Configuration options for the chat component. + */ +export type IgxChatOptions = Omit; + + +/** + * Angular wrapper component for the Ignite UI Web Components Chat component. + * + * This component provides an Angular-friendly interface to the igc-chat web component, + * including support for Angular templates, signals, and change detection. + * + * Uses OnPush change detection strategy for optimal performance. All inputs are signals, + * so changes are automatically tracked and propagated to the underlying web component. + * + * @example + * ```typescript + * + * ``` + */ +@Component({ + selector: 'igx-chat', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + templateUrl: './chat.component.html' +}) +export class IgxChatComponent implements OnInit, OnDestroy { + //#region Internal state + + private readonly _view = inject(ViewContainerRef); + private readonly _templateViewRefs = new Map, Set>(); + private _oldTemplates: IgxChatTemplates = {}; + + protected readonly _transformedTemplates = signal({}); + + protected readonly _mergedOptions = computed(() => { + const options = this.options(); + const transformedTemplates = this._transformedTemplates(); + return { + ...options, + renderers: transformedTemplates + }; + }); + + //#endregion + + //#region Inputs + + /** Array of chat messages to display */ + public readonly messages = input([]); + + /** Draft message with text and optional attachments */ + public readonly draftMessage = input< + { text: string; attachments?: IgcChatMessageAttachment[] } | undefined + >({ text: '' }); + + /** Configuration options for the chat component */ + public readonly options = input({}); + + /** Custom templates for rendering chat elements */ + public readonly templates = input({}); + + //#endregion + + //#region Outputs + + /** Emitted when a new message is created */ + public readonly messageCreated = output(); + + /** Emitted when a user reacts to a message */ + public readonly messageReact = output(); + + /** Emitted when an attachment is clicked */ + public readonly attachmentClick = output(); + + /** Emitted when attachment drag starts */ + public readonly attachmentDrag = output(); + + /** Emitted when attachment is dropped */ + public readonly attachmentDrop = output(); + + /** Emitted when typing indicator state changes */ + public readonly typingChange = output(); + + /** Emitted when the input receives focus */ + public readonly inputFocus = output(); + + /** Emitted when the input loses focus */ + public readonly inputBlur = output(); + + /** Emitted when the input value changes */ + public readonly inputChange = output(); + + //#endregion + + /** @internal */ + public ngOnInit(): void { + IgcChatComponent.register(); + } + + /** @internal */ + public ngOnDestroy(): void { + for (const viewSet of this._templateViewRefs.values()) { + viewSet.forEach(viewRef => viewRef.destroy()); + } + this._templateViewRefs.clear(); + } + + constructor() { + // Templates changed - update transformed templates and viewRefs + effect(() => { + const templates = this.templates(); + this._setTemplates(templates ?? {}); + }); + } + + private _setTemplates(newTemplates: IgxChatTemplates): void { + const templateCopies: ChatRenderers = {}; + const newTemplateKeys = Object.keys(newTemplates) as Array; + + const oldTemplates = this._oldTemplates; + const oldTemplateKeys = Object.keys(oldTemplates) as Array; + + for (const key of oldTemplateKeys) { + const oldRef = oldTemplates[key]; + const newRef = newTemplates[key]; + + if (oldRef && oldRef !== newRef) { + const obsolete = this._templateViewRefs.get(oldRef); + if (obsolete) { + obsolete.forEach(viewRef => viewRef.destroy()); + this._templateViewRefs.delete(oldRef); + } + } + } + + this._oldTemplates = {}; + + for (const key of newTemplateKeys) { + const ref = newTemplates[key]; + if (ref) { + (this._oldTemplates as Record>)[key] = ref; + templateCopies[key] = this._createTemplateRenderer(ref); + } + } + + this._transformedTemplates.set(templateCopies); + } + + private _createTemplateRenderer(ref: NonNullable) { + type ChatContext = ExtractChatContext>; + + if (!this._templateViewRefs.has(ref)) { + this._templateViewRefs.set(ref, new Set()); + } + + const viewSet = this._templateViewRefs.get(ref)!; + + return (ctx: ChatContext) => { + const context = ctx as ChatContextUnion; + let angularContext: any; + + if ('message' in context && 'attachment' in context) { + angularContext = { $implicit: context.attachment }; + } else if ('message' in context) { + angularContext = { $implicit: context.message }; + } else if ('value' in context) { + angularContext = { + $implicit: context.value, + attachments: context.attachments + }; + } else { + angularContext = { $implicit: { instance: context.instance } }; + } + + const viewRef = this._view.createEmbeddedView(ref, angularContext); + viewSet.add(viewRef); + + return viewRef.rootNodes; + } + } +} + +/** + * Context provided to the chat input template. + */ +export interface ChatInputContext { + /** The current input value */ + $implicit: string; + /** Array of attachments associated with the input */ + attachments: IgcChatMessageAttachment[]; +} + +/** + * Directive providing type information for chat message template contexts. + * Use this directive on ng-template elements that render chat messages. + * + * @example + * ```html + * + *
{{ message.text }}
+ *
+ * ``` + */ +@Directive({ selector: '[igxChatMessageContext]', standalone: true }) +export class IgxChatMessageContextDirective { + + public static ngTemplateContextGuard(_: IgxChatMessageContextDirective, ctx: unknown): ctx is { $implicit: IgcChatMessage } { + return true; + } +} + +/** + * Directive providing type information for chat attachment template contexts. + * Use this directive on ng-template elements that render message attachments. + * + * @example + * ```html + * + * + * + * ``` + */ +@Directive({ selector: '[igxChatAttachmentContext]', standalone: true }) +export class IgxChatAttachmentContextDirective { + + public static ngTemplateContextGuard(_: IgxChatAttachmentContextDirective, ctx: unknown): ctx is { $implicit: IgcChatMessageAttachment } { + return true; + } +} + +/** + * Directive providing type information for chat input template contexts. + * Use this directive on ng-template elements that render the chat input. + * + * @example + * ```html + * + * + * + * ``` + */ +@Directive({ selector: '[igxChatInputContext]', standalone: true }) +export class IgxChatInputContextDirective { + + public static ngTemplateContextGuard(_: IgxChatInputContextDirective, ctx: unknown): ctx is ChatInputContext { + return true; + } +} diff --git a/projects/igniteui-angular/chat/src/chat.spec.ts b/projects/igniteui-angular/chat/src/chat.spec.ts new file mode 100644 index 00000000000..2977d06f194 --- /dev/null +++ b/projects/igniteui-angular/chat/src/chat.spec.ts @@ -0,0 +1,177 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing' +import { IgxChatComponent, IgxChatMessageContextDirective, type IgxChatTemplates } from './chat.component' +import { Component, signal, TemplateRef, viewChild } from '@angular/core'; +import type { IgcChatComponent, IgcChatMessage, IgcTextareaComponent } from 'igniteui-webcomponents'; + +describe('Chat wrapper', () => { + + let chatComponent: IgxChatComponent; + let chatElement: IgcChatComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [IgxChatComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(IgxChatComponent); + chatComponent = fixture.componentInstance; + chatElement = getChatElement(fixture); + fixture.detectChanges(); + }) + + it('is created', () => { + expect(chatComponent).toBeDefined(); + }); + + it('has correct initial empty state', () => { + const draft = chatComponent.draftMessage(); + + expect(chatComponent.messages().length).toEqual(0); + expect(draft.text).toEqual(''); + expect(draft.attachments).toBeUndefined(); + }); + + it('correct bindings for messages', async () => { + fixture.componentRef.setInput('messages', [{ id: '1', sender: 'user', text: 'Hello' }]); + + fixture.detectChanges(); + await fixture.whenStable(); + + + const messageElement = getChatMessages(chatElement)[0]; + expect(messageElement).toBeDefined(); + expect(getChatMessageDOM(messageElement).textContent.trim()).toEqual(chatComponent.messages()[0].text); + }); + + it('correct bindings for draft message', async () => { + fixture.componentRef.setInput('draftMessage', { text: 'Hello world' }); + + fixture.detectChanges(); + await fixture.whenStable(); + + const textarea = getChatInput(chatElement); + expect(textarea.value).toEqual(chatComponent.draftMessage().text); + }); +}); + +describe('Chat templates', () => { + let fixture: ComponentFixture; + let chatElement: IgcChatComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [IgxChatComponent, IgxChatMessageContextDirective, ChatTemplatesBed] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ChatTemplatesBed); + fixture.detectChanges(); + chatElement = getChatElement(fixture); + }); + + it('has correct initially bound template', async () => { + await fixture.whenStable(); + + // NOTE: This is invoked since in the test bed there is no app ref so fresh embedded view + // has no change detection ran on it. In an application scenario this is not the case. + // This is so we don't explicitly invoke `viewRef.detectChanges()` inside the returned closure + // from the wrapper's `_createTemplateRenderer` call. + fixture.detectChanges(); + expect(getChatMessageDOM(getChatMessages(chatElement)[0]).textContent.trim()) + .toEqual(`Your message: ${fixture.componentInstance.messages()[0].text}`); + }); +}); + +describe('Chat dynamic templates binding', () => { + let fixture: ComponentFixture; + let chatElement: IgcChatComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [IgxChatComponent, IgxChatMessageContextDirective, ChatDynamicTemplatesBed] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ChatDynamicTemplatesBed); + fixture.detectChanges(); + chatElement = getChatElement(fixture); + }); + + it('supports late binding', async () => { + fixture.componentInstance.bindTemplates(); + fixture.detectChanges(); + + await fixture.whenStable(); + fixture.detectChanges(); + + expect(getChatMessageDOM(getChatMessages(chatElement)[0]).textContent.trim()) + .toEqual(`Your message: ${fixture.componentInstance.messages()[0].text}`); + }); + +}); + + +@Component({ + template: ` + + +

Your message: {{ message.text }}

+
+ `, + imports: [IgxChatComponent, IgxChatMessageContextDirective] +}) +class ChatTemplatesBed { + public messages = signal([{ + id: '1', + sender: 'user', + text: 'Hello world' + }]); + public messageTemplate = viewChild.required>('message'); +} + +@Component({ + template: ` + + +

Your message: {{ message.text }}

+
+ `, + imports: [IgxChatComponent, IgxChatMessageContextDirective] +}) +class ChatDynamicTemplatesBed { + public templates = signal(null); + public messages = signal([{ + id: '1', + sender: 'user', + text: 'Hello world' + }]); + public messageTemplate = viewChild.required>('message'); + + public bindTemplates(): void { + this.templates.set({ + messageContent: this.messageTemplate() + }); + } +} + +function getChatElement(fixture: ComponentFixture): IgcChatComponent { + const nativeElement = fixture.nativeElement as HTMLElement; + return nativeElement.querySelector('igc-chat'); +} + +function getChatInput(chat: IgcChatComponent): IgcTextareaComponent { + return chat.renderRoot.querySelector('igc-chat-input').shadowRoot.querySelector('igc-textarea'); +} + +function getChatMessages(chat: IgcChatComponent): HTMLElement[] { + return Array.from(chat.renderRoot.querySelectorAll('igc-chat-message')); +} + +function getChatMessageDOM(message: HTMLElement) { + return message.shadowRoot; +} diff --git a/projects/igniteui-angular/chat/src/public_api.ts b/projects/igniteui-angular/chat/src/public_api.ts new file mode 100644 index 00000000000..eca793fd7b9 --- /dev/null +++ b/projects/igniteui-angular/chat/src/public_api.ts @@ -0,0 +1 @@ +export * from './chat.component'; diff --git a/projects/igniteui-angular/checkbox/README.md b/projects/igniteui-angular/checkbox/README.md new file mode 100644 index 00000000000..de933d26899 --- /dev/null +++ b/projects/igniteui-angular/checkbox/README.md @@ -0,0 +1,89 @@ +# igx-checkbox + +`igx-checkbox` is a selection component that allows users to make a binary choice for a certain condition. It behaves similar to the native browser checkbox. +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/checkbox.html) + +# Usage + +Basic usage of `igx-checkbox` + +```html +
    +
  • + + {{ task.description }} + +
  • +
+``` + +You can easily use it within forms with `[(ngModel)]` + +```html +
+
+
+ + {{ item.description } + +
+
+
+``` + +### Checkbox Label + +The checkbox label is set to anything passed between the opening and closing tags of the `` component. + +The position of the label can be set to either `before` or `after`(default) the actual checkbox using the `labelPosition` input property. For instance, to set the label position ___before___ the checkbox: + +```html +Label +``` + +### Indeterminate State + +The checkbox component supports an indeterminate state, which behaves the same as the native [indeterminate state](https://developer.mozilla.org/en-US/docs/Web/CSS/:indeterminate) of an input of type checkbox. +To set the indeterminate state for an `igx-checkbox`, do: + +```html +Label +``` + +### Ripple Touch Feedback + +The `igx-checkbox` is styled according to the Google's Material spec, and provides a ripple effect around the checkbox when the checkbox is clicked/tapped. +To disable the ripple effect, do: + +```html + +``` + +## API + +# API Summary +| Name | Type | Description | +|:----------|:-------------:|:------| +| `@Input()` id | string | The unique `id` attribute to be used for the checkbox. If you do not provide a value, it will be auto-generated. | +| `@Input()` labelId | string | The unique `id` attribute to be used for the checkbox label. If you do not provide a value, it will be auto-generated. | +| `@Input()` name | string | The `name` attribute to be used for the checkbox. | +| `@Input()` value | any | The value to be set for the checkbox. | +| `@Input()` tabindex | number | Specifies the tabbing order of the checkbox. | +| `@Input()` checked | boolean | Specifies the checked state of the checkbox. | +| `@Input()` indeterminate | boolean | Specifies the indeterminate state of the checkbox. | +| `@Input()` required | boolean | Specifies the required state of the checkbox. | +| `@Input()` disabled | boolean | Specifies the disabled state of the checkbox. | +| `@Input()` readonly | boolean | Specifies the readonly state of the checkbox. | +| `@Input()` disableRipple | boolean | Specifies whether the ripple effect should be disabled for the checkbox. | +| `@Input()` disableTransitions | boolean | Specifies whether CSS transitions should be disabled for the checkbox. | +| `@Input()` labelPosition | string `|` enum LabelPosition | Specifies the position of the text label relative to the checkbox element. | +| `@Input("aria-labelledby")` ariaLabelledBy | string | Specify an external element by id to be used as label for the checkbox. | +| `@Output()` change | EventEmitter | Emitted when the checkbox checked value changes. | + +### Methods + +| toggle | +|:----------| +| Toggles the checked state of the checkbox. | diff --git a/projects/igniteui-angular/checkbox/index.ts b/projects/igniteui-angular/checkbox/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/checkbox/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/checkbox/ng-package.json b/projects/igniteui-angular/checkbox/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/checkbox/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.html b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.html new file mode 100644 index 00000000000..9bab0879680 --- /dev/null +++ b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.html @@ -0,0 +1,47 @@ + + +
+ + @if (theme === 'indigo') { + + + + + } @else { + + + + } + + +
+
+ + + + diff --git a/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.spec.ts b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.spec.ts new file mode 100644 index 00000000000..1c0cd7e15f7 --- /dev/null +++ b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.spec.ts @@ -0,0 +1,532 @@ +import { Component, ViewChild, inject } from '@angular/core'; +import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { UntypedFormBuilder, FormsModule, ReactiveFormsModule, Validators, NgForm } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { IgxCheckboxComponent } from './checkbox.component'; + +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +describe('IgxCheckbox', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + InitCheckboxComponent, + CheckboxSimpleComponent, + CheckboxReadonlyComponent, + CheckboxIndeterminateComponent, + CheckboxRequiredComponent, + CheckboxExternalLabelComponent, + CheckboxInvisibleLabelComponent, + CheckboxDisabledTransitionsComponent, + CheckboxFormComponent, + CheckboxFormGroupComponent, + IgxCheckboxComponent + ] + }).compileComponents(); + })); + + it('Initializes a checkbox', () => { + const fixture = TestBed.createComponent(InitCheckboxComponent); + fixture.detectChanges(); + + const checkbox = fixture.componentInstance.cb; + const nativeCheckbox = checkbox.nativeInput.nativeElement; + const nativeLabel = checkbox.nativeLabel.nativeElement; + const placeholderLabel = fixture.debugElement.query(By.css('.igx-checkbox__label')).nativeElement; + + expect(nativeCheckbox).toBeTruthy(); + expect(nativeCheckbox.id).toContain('igx-checkbox-'); + expect(nativeCheckbox.getAttribute('aria-label')).toEqual(null); + expect(nativeCheckbox.getAttribute('aria-labelledby')).toContain('igx-checkbox-'); + + expect(nativeLabel).toBeTruthy(); + // No longer have a for attribute to not propagate clicks to the native checkbox + // expect(nativeLabel.getAttribute('for')).toEqual('igx-checkbox-0-input'); + + expect(placeholderLabel.textContent.trim()).toEqual('Init'); + expect(placeholderLabel.classList).toContain('igx-checkbox__label'); + expect(placeholderLabel.getAttribute('id')).toContain('igx-checkbox-'); + + // When aria-label is present, aria-labeledby shouldn't be + checkbox.ariaLabel = 'New Label'; + fixture.detectChanges(); + expect(nativeCheckbox.getAttribute('aria-labelledby')).toEqual(null); + expect(nativeCheckbox.getAttribute('aria-label')).toMatch('New Label'); + }); + + it('Initializes with ngModel', fakeAsync(() => { + const fixture = TestBed.createComponent(CheckboxSimpleComponent); + fixture.detectChanges(); + + const testInstance = fixture.componentInstance; + const checkboxInstance = testInstance.cb; + const nativeCheckbox = checkboxInstance.nativeInput.nativeElement; + + fixture.detectChanges(); + + expect(nativeCheckbox.checked).toBe(false); + expect(checkboxInstance.checked).toBe(null); + + testInstance.subscribed = true; + checkboxInstance.name = 'my-checkbox'; + // One change detection cycle for updating our checkbox + fixture.detectChanges(); + tick(); + expect(checkboxInstance.checked).toBe(true); + + // Now one more change detection cycle to update the native checkbox + fixture.detectChanges(); + tick(); + expect(nativeCheckbox.checked).toBe(true); + expect(checkboxInstance.name).toEqual('my-checkbox'); + })); + + it('Initializes with form group', () => { + const fixture = TestBed.createComponent(CheckboxFormGroupComponent); + fixture.detectChanges(); + + const testInstance = fixture.componentInstance; + const checkboxInstance = testInstance.cb; + const form = testInstance.myForm; + + form.setValue({ checkbox: true }); + expect(checkboxInstance.checked).toBe(true); + + form.reset(); + + expect(checkboxInstance.checked).toBe(null); + }); + + it('Initializes with external label', () => { + const fixture = TestBed.createComponent(CheckboxExternalLabelComponent); + const checkboxInstance = fixture.componentInstance.cb; + const nativeCheckbox = checkboxInstance.nativeInput.nativeElement; + const externalLabel = fixture.debugElement.query(By.css('#my-label')).nativeElement; + fixture.detectChanges(); + + expect(nativeCheckbox.getAttribute('aria-labelledby')).toMatch(externalLabel.getAttribute('id')); + expect(externalLabel.textContent).toMatch(fixture.componentInstance.label); + }); + + it('Initializes with invisible label', () => { + const fixture = TestBed.createComponent(CheckboxInvisibleLabelComponent); + const checkboxInstance = fixture.componentInstance.cb; + const nativeCheckbox = checkboxInstance.nativeInput.nativeElement; + fixture.detectChanges(); + + expect(nativeCheckbox.getAttribute('aria-label')).toMatch(fixture.componentInstance.label); + }); + + it('Positions label before and after checkbox', () => { + const fixture = TestBed.createComponent(CheckboxSimpleComponent); + const checkboxInstance = fixture.componentInstance.cb; + const placeholderLabel = checkboxInstance.placeholderLabel.nativeElement; + const labelStyles = window.getComputedStyle(placeholderLabel); + fixture.detectChanges(); + + expect(labelStyles.order).toEqual('0'); + + checkboxInstance.labelPosition = 'before'; + fixture.detectChanges(); + + expect(labelStyles.order).toEqual('-1'); + }); + + it('Indeterminate state', fakeAsync(() => { + const fixture = TestBed.createComponent(CheckboxIndeterminateComponent); + const testInstance = fixture.componentInstance; + const checkboxInstance = testInstance.cb; + const nativeCheckbox = checkboxInstance.nativeInput.nativeElement; + const nativeLabel = checkboxInstance.nativeLabel.nativeElement; + + // Before any changes indeterminate should be true + fixture.detectChanges(); + expect(checkboxInstance.indeterminate).toBe(true); + expect(nativeCheckbox.indeterminate).toBe(true); + + testInstance.subscribed = true; + + fixture.detectChanges(); + tick(); + // First change detection should update our checkbox state and API call should not change indeterminate + expect(checkboxInstance.checked).toBe(true); + expect(checkboxInstance.indeterminate).toBe(true); + + // Second change detection should update native checkbox state but indeterminate should not change + fixture.detectChanges(); + tick(); + expect(nativeCheckbox.indeterminate).toBe(true); + expect(nativeCheckbox.checked).toBe(true); + + // Should not change the state + nativeCheckbox.dispatchEvent(new Event('change')); + fixture.detectChanges(); + + expect(nativeCheckbox.indeterminate).toBe(true); + expect(checkboxInstance.checked).toBe(true); + expect(nativeCheckbox.checked).toBe(true); + + // Should update the state on click + nativeLabel.click(); + fixture.detectChanges(); + + expect(nativeCheckbox.indeterminate).toBe(false); + expect(checkboxInstance.checked).toBe(false); + expect(nativeCheckbox.checked).toBe(false); + + // Should update the state again on click + nativeLabel.click(); + fixture.detectChanges(); + + expect(nativeCheckbox.indeterminate).toBe(false); + expect(checkboxInstance.checked).toBe(true); + expect(nativeCheckbox.checked).toBe(true); + + // Should be able to set indeterminate again + checkboxInstance.indeterminate = true; + fixture.detectChanges(); + + expect(nativeCheckbox.indeterminate).toBe(true); + expect(checkboxInstance.checked).toBe(true); + expect(nativeCheckbox.checked).toBe(true); + })); + + it('Disabled state', () => { + const fixture = TestBed.createComponent(IgxCheckboxComponent); + + const checkboxInstance = fixture.componentInstance; + // For test fixture destroy + checkboxInstance.id = "root1"; + checkboxInstance.disabled = true; + const nativeCheckbox = checkboxInstance.nativeInput.nativeElement as HTMLInputElement; + const nativeLabel = checkboxInstance.nativeLabel.nativeElement as HTMLLabelElement; + const placeholderLabel = checkboxInstance.placeholderLabel.nativeElement; + fixture.detectChanges(); + + expect(checkboxInstance.disabled).toBe(true); + expect(nativeCheckbox.disabled).toBe(true); + + nativeCheckbox.click(); + nativeLabel.click(); + placeholderLabel.click(); + fixture.detectChanges(); + + // Should not update + expect(checkboxInstance.checked).toBe(false); + }); + + it('Readonly state', () => { + const fixture = TestBed.createComponent(CheckboxReadonlyComponent); + const testInstance = fixture.componentInstance; + const checkboxInstance = testInstance.cb; + const nativeCheckbox = checkboxInstance.nativeInput.nativeElement; + const nativeLabel = checkboxInstance.nativeLabel.nativeElement; + const placeholderLabel = checkboxInstance.placeholderLabel.nativeElement; + fixture.detectChanges(); + expect(checkboxInstance.readonly).toBe(true); + expect(testInstance.subscribed).toBe(false); + + nativeCheckbox.dispatchEvent(new Event('change')); + fixture.detectChanges(); + // Should not update + expect(testInstance.subscribed).toBe(false); + + nativeLabel.click(); + fixture.detectChanges(); + // Should not update + expect(testInstance.subscribed).toBe(false); + + placeholderLabel.click(); + fixture.detectChanges(); + // Should not update + expect(testInstance.subscribed).toBe(false); + + nativeCheckbox.click(); + fixture.detectChanges(); + // Should not update + expect(testInstance.subscribed).toBe(false); + expect(checkboxInstance.indeterminate).toBe(true); + }); + + it('Should be able to enable/disable CSS transitions', () => { + const fixture = TestBed.createComponent(CheckboxDisabledTransitionsComponent); + const testInstance = fixture.componentInstance; + const checkboxInstance = testInstance.cb; + const checkboxHost = fixture.debugElement.query(By.css('igx-checkbox')).nativeElement; + fixture.detectChanges(); + + expect(checkboxInstance.disableTransitions).toBe(true); + expect(checkboxHost.classList).toContain('igx-checkbox--plain'); + + testInstance.cb.disableTransitions = false; + fixture.detectChanges(); + expect(checkboxHost.classList).not.toContain('igx-checkbox--plain'); + }); + + it('Required state', () => { + const fixture = TestBed.createComponent(CheckboxRequiredComponent); + const testInstance = fixture.componentInstance; + const checkboxInstance = testInstance.cb; + const nativeCheckbox = checkboxInstance.nativeInput.nativeElement; + fixture.detectChanges(); + + expect(checkboxInstance.required).toBe(true); + expect(nativeCheckbox.required).toBeTruthy(); + + checkboxInstance.required = false; + nativeCheckbox.required = false; + fixture.detectChanges(); + + expect(checkboxInstance.required).toBe(false); + expect(nativeCheckbox.required).toBe(false); + }); + + it('Event handling', () => { + const fixture = TestBed.createComponent(CheckboxSimpleComponent); + const testInstance = fixture.componentInstance; + const checkboxInstance = testInstance.cb; + const cbxEl = fixture.debugElement.query(By.directive(IgxCheckboxComponent)).nativeElement; + const nativeCheckbox = checkboxInstance.nativeInput.nativeElement; + const nativeLabel = checkboxInstance.nativeLabel.nativeElement; + const placeholderLabel = checkboxInstance.placeholderLabel.nativeElement; + + fixture.detectChanges(); + expect(checkboxInstance.focused).toBe(false); + + cbxEl.dispatchEvent(new KeyboardEvent('keyup')); + fixture.detectChanges(); + expect(checkboxInstance.focused).toBe(true); + + nativeCheckbox.dispatchEvent(new Event('blur')); + fixture.detectChanges(); + expect(checkboxInstance.focused).toBe(false); + + nativeLabel.click(); + fixture.detectChanges(); + + expect(testInstance.changeEventCalled).toBe(true); + expect(testInstance.subscribed).toBe(true); + expect(testInstance.clickCounter).toEqual(1); + + placeholderLabel.click(); + fixture.detectChanges(); + + expect(testInstance.changeEventCalled).toBe(true); + expect(testInstance.subscribed).toBe(false); + expect(testInstance.clickCounter).toEqual(2); + }); + + it('Should update style when required checkbox\'s value is set.', () => { + const fixture = TestBed.createComponent(CheckboxRequiredComponent); + fixture.detectChanges(); + + const checkboxInstance = fixture.componentInstance.cb; + const domCheckbox = fixture.debugElement.query(By.css('igx-checkbox')).nativeElement; + + expect(domCheckbox.classList.contains('igx-checkbox--invalid')).toBe(false); + expect(checkboxInstance.invalid).toBe(false); + expect(checkboxInstance.checked).toBe(false); + expect(checkboxInstance.required).toBe(true); + + dispatchCbEvent('keyup', domCheckbox, fixture); + expect(domCheckbox.classList.contains('igx-checkbox--focused')).toBe(true); + dispatchCbEvent('blur', domCheckbox, fixture); + + expect(checkboxInstance.invalid).toBe(true); + expect(domCheckbox.classList.contains('igx-checkbox--invalid')).toBe(true); + + dispatchCbEvent('keyup', domCheckbox, fixture); + expect(domCheckbox.classList.contains('igx-checkbox--focused')).toBe(true); + dispatchCbEvent('click', domCheckbox, fixture); + + expect(domCheckbox.classList.contains('igx-checkbox--checked')).toBe(true); + expect(checkboxInstance.checked).toBe(true); + expect(checkboxInstance.invalid).toBe(false); + expect(domCheckbox.classList.contains('igx-checkbox--invalid')).toBe(false); + + dispatchCbEvent('click', domCheckbox, fixture); + dispatchCbEvent('keyup', domCheckbox, fixture); + expect(domCheckbox.classList.contains('igx-checkbox--focused')).toBe(true); + dispatchCbEvent('blur', domCheckbox, fixture); + + expect(checkboxInstance.checked).toBe(false); + expect(checkboxInstance.invalid).toBe(true); + expect(domCheckbox.classList.contains('igx-checkbox--invalid')).toBe(true); + }); + + it('Should work properly with ngModel', fakeAsync(() => { + const fixture = TestBed.createComponent(CheckboxFormComponent); + fixture.detectChanges(); + tick(); + + const checkbox = fixture.componentInstance.checkbox; + expect(checkbox.invalid).toEqual(false); + + checkbox.onBlur(); + expect(checkbox.invalid).toEqual(true); + + fixture.componentInstance.ngForm.resetForm(); + tick(); + expect(checkbox.invalid).toEqual(false); + })); + + it('Should work properly with reactive forms validation.', () => { + const fixture = TestBed.createComponent(CheckboxFormGroupComponent); + fixture.detectChanges(); + + const checkbox = fixture.componentInstance.cb; + const cbxEl = fixture.debugElement.query(By.directive(IgxCheckboxComponent)).nativeElement; + expect(checkbox.required).toBe(true); + expect(checkbox.invalid).toBe(false); + expect(cbxEl.classList.contains('igx-checkbox--invalid')).toBe(false); + expect(checkbox.nativeElement.getAttribute('aria-required')).toEqual('true'); + expect(checkbox.nativeElement.getAttribute('aria-invalid')).toEqual('false'); + + dispatchCbEvent('keyup', cbxEl, fixture); + expect(checkbox.focused).toBe(true); + dispatchCbEvent('blur', cbxEl, fixture); + + expect(cbxEl.classList.contains('igx-checkbox--invalid')).toBe(true); + expect(checkbox.invalid).toBe(true); + expect(checkbox.nativeElement.getAttribute('aria-invalid')).toEqual('true'); + + checkbox.checked = true; + fixture.detectChanges(); + + expect(cbxEl.classList.contains('igx-checkbox--invalid')).toBe(false); + expect(checkbox.invalid).toBe(false); + expect(checkbox.nativeElement.getAttribute('aria-invalid')).toEqual('false'); + }); + + describe('EditorProvider', () => { + it('Should return correct edit element', () => { + const fixture = TestBed.createComponent(CheckboxSimpleComponent); + fixture.detectChanges(); + + const instance = fixture.componentInstance.cb; + const editElement = fixture.debugElement.query(By.css('.igx-checkbox__input')).nativeElement; + + expect(instance.getEditElement()).toBe(editElement); + }); + }); +}); + +@Component({ + template: `Init`, + imports: [IgxCheckboxComponent] +}) +class InitCheckboxComponent { + @ViewChild('cb', { static: true }) public cb: IgxCheckboxComponent; +} + +@Component({ + template: `Simple`, + imports: [IgxCheckboxComponent, FormsModule] +}) +class CheckboxSimpleComponent { + @ViewChild('cb', { static: true }) public cb: IgxCheckboxComponent; + public changeEventCalled = false; + public subscribed = false; + public clickCounter = 0; + public onChange() { + this.changeEventCalled = true; + } + public onClick() { + this.clickCounter++; + } +} +@Component({ + template: `Indeterminate`, + imports: [IgxCheckboxComponent, FormsModule] +}) +class CheckboxIndeterminateComponent { + @ViewChild('cb', { static: true }) public cb: IgxCheckboxComponent; + + public subscribed = false; +} + +@Component({ + template: `Required`, + imports: [IgxCheckboxComponent] +}) +class CheckboxRequiredComponent { + @ViewChild('cb', { static: true }) public cb: IgxCheckboxComponent; +} + +@Component({ + template: `Readonly`, + imports: [IgxCheckboxComponent, FormsModule] +}) +class CheckboxReadonlyComponent { + @ViewChild('cb', { static: true }) public cb: IgxCheckboxComponent; + + public subscribed = false; +} + +@Component({ + template: `

{{label}}

+ `, + imports: [IgxCheckboxComponent] +}) +class CheckboxExternalLabelComponent { + @ViewChild('cb', { static: true }) public cb: IgxCheckboxComponent; + public label = 'My Label'; +} + +@Component({ + template: ``, + imports: [IgxCheckboxComponent] +}) +class CheckboxInvisibleLabelComponent { + @ViewChild('cb', { static: true }) public cb: IgxCheckboxComponent; + public label = 'Invisible Label'; +} + +@Component({ + template: ``, + imports: [IgxCheckboxComponent] +}) +class CheckboxDisabledTransitionsComponent { + @ViewChild('cb', { static: true }) public cb: IgxCheckboxComponent; +} + +@Component({ + template: `
Form Group
`, + imports: [IgxCheckboxComponent, ReactiveFormsModule] +}) +class CheckboxFormGroupComponent { + private fb = inject(UntypedFormBuilder); + + @ViewChild('cb', { static: true }) public cb: IgxCheckboxComponent; + + public myForm = this.fb.group({ checkbox: ['', Validators.required] }); +} +@Component({ + template: ` +
+ Checkbox +
+ `, + imports: [IgxCheckboxComponent, FormsModule] +}) +class CheckboxFormComponent { + @ViewChild('checkbox', { read: IgxCheckboxComponent, static: true }) + public checkbox: IgxCheckboxComponent; + @ViewChild(NgForm, { static: true }) + public ngForm: NgForm; + public subscribed: string; +} + +const dispatchCbEvent = (eventName, cbNativeElement, fixture) => { + cbNativeElement.dispatchEvent(new Event(eventName)); + fixture.detectChanges(); +}; diff --git a/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.ts b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.ts new file mode 100644 index 00000000000..f79b9362451 --- /dev/null +++ b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.ts @@ -0,0 +1,227 @@ +import { + Component, + HostBinding, + Input, + AfterViewInit, + booleanAttribute, +} from '@angular/core'; +import { CheckboxBaseDirective, IgxRippleDirective } from 'igniteui-angular/directives'; +import { ControlValueAccessor } from '@angular/forms'; +import { EditorProvider, EDITOR_PROVIDER } from 'igniteui-angular/core'; + +/** + * Allows users to make a binary choice for a certain condition. + * + * @igxModule IgxCheckboxModule + * + * @igxTheme igx-checkbox-theme + * + * @igxKeywords checkbox, label + * + * @igxGroup Data entry and display + * + * @remarks + * The Ignite UI Checkbox is a selection control that allows users to make a binary choice for a certain condition.It behaves similarly + * to the native browser checkbox. + * + * @example + * ```html + * + * simple checkbox + * + * ``` + */ +@Component({ + selector: 'igx-checkbox', + providers: [ + { + provide: EDITOR_PROVIDER, + useExisting: IgxCheckboxComponent, + multi: true, + }, + ], + preserveWhitespaces: false, + templateUrl: 'checkbox.component.html', + imports: [IgxRippleDirective], +}) +export class IgxCheckboxComponent + extends CheckboxBaseDirective + implements AfterViewInit, ControlValueAccessor, EditorProvider { + /** + * Returns the class of the checkbox component. + * + * @example + * ```typescript + * let class = this.checkbox.cssClass; + * ``` + */ + @HostBinding('class.igx-checkbox') + public override cssClass = 'igx-checkbox'; + + /** + * Returns if the component is of type `material`. + * + * @example + * ```typescript + * let checkbox = this.checkbox.material; + * ``` + */ + @HostBinding('class.igx-checkbox--material') + protected get material() { + return this.theme === 'material'; + } + + /** + * Returns if the component is of type `indigo`. + * + * @example + * ```typescript + * let checkbox = this.checkbox.indigo; + * ``` + */ + @HostBinding('class.igx-checkbox--indigo') + protected get indigo() { + return this.theme === 'indigo'; + } + + /** + * Returns if the component is of type `bootstrap`. + * + * @example + * ```typescript + * let checkbox = this.checkbox.bootstrap; + * ``` + */ + @HostBinding('class.igx-checkbox--bootstrap') + protected get bootstrap() { + return this.theme === 'bootstrap'; + } + + /** + * Returns if the component is of type `fluent`. + * + * @example + * ```typescript + * let checkbox = this.checkbox.fluent; + * ``` + */ + @HostBinding('class.igx-checkbox--fluent') + protected get fluent() { + return this.theme === 'fluent'; + } + + /** + * Sets/gets whether the checkbox component is on focus. + * Default value is `false`. + * + * @example + * ```typescript + * this.checkbox.focused = true; + * ``` + * ```typescript + * let isFocused = this.checkbox.focused; + * ``` + */ + @HostBinding('class.igx-checkbox--focused') + public override focused = false; + + /** + * Sets/gets the checkbox indeterminate visual state. + * Default value is `false`; + * + * @example + * ```html + * + * ``` + * ```typescript + * let isIndeterminate = this.checkbox.indeterminate; + * ``` + */ + @HostBinding('class.igx-checkbox--indeterminate') + @Input({ transform: booleanAttribute }) + public override indeterminate = false; + + /** + * Sets/gets whether the checkbox is checked. + * Default value is `false`. + * + * @example + * ```html + * + * ``` + * ```typescript + * let isChecked = this.checkbox.checked; + * ``` + */ + @HostBinding('class.igx-checkbox--checked') + @Input({ transform: booleanAttribute }) + public override set checked(value: boolean) { + super.checked = value; + } + public override get checked() { + return super.checked; + } + + /** + * Sets/gets whether the checkbox is disabled. + * Default value is `false`. + * + * @example + * ```html + * + * ``` + * ```typescript + * let isDisabled = this.checkbox.disabled; + * ``` + */ + @HostBinding('class.igx-checkbox--disabled') + @Input({ transform: booleanAttribute }) + public override disabled = false; + + /** + * Sets/gets whether the checkbox is invalid. + * Default value is `false`. + * + * @example + * ```html + * + * ``` + * ```typescript + * let isInvalid = this.checkbox.invalid; + * ``` + */ + @HostBinding('class.igx-checkbox--invalid') + @Input({ transform: booleanAttribute }) + public override invalid = false; + + /** + * Sets/gets whether the checkbox is readonly. + * Default value is `false`. + * + * @example + * ```html + * + * ``` + * ```typescript + * let readonly = this.checkbox.readonly; + * ``` + */ + @Input({ transform: booleanAttribute }) + public override readonly = false; + + /** + * Sets/gets whether the checkbox should disable all css transitions. + * Default value is `false`. + * + * @example + * ```html + * + * ``` + * ```typescript + * let disableTransitions = this.checkbox.disableTransitions; + * ``` + */ + @HostBinding('class.igx-checkbox--plain') + @Input({ transform: booleanAttribute }) + public disableTransitions = false; +} diff --git a/projects/igniteui-angular/checkbox/src/checkbox/checkbox.module.ts b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.module.ts new file mode 100644 index 00000000000..5ad2cd5dbe9 --- /dev/null +++ b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { IgxCheckboxComponent } from './checkbox.component'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [IgxCheckboxComponent], + exports: [IgxCheckboxComponent] +}) +export class IgxCheckboxModule {} diff --git a/projects/igniteui-angular/checkbox/src/checkbox/public_api.ts b/projects/igniteui-angular/checkbox/src/checkbox/public_api.ts new file mode 100644 index 00000000000..ea6b061501f --- /dev/null +++ b/projects/igniteui-angular/checkbox/src/checkbox/public_api.ts @@ -0,0 +1,2 @@ +export { LabelPosition, type IChangeCheckboxEventArgs } from "igniteui-angular/directives"; +export * from "./checkbox.component"; diff --git a/projects/igniteui-angular/checkbox/src/public_api.ts b/projects/igniteui-angular/checkbox/src/public_api.ts new file mode 100644 index 00000000000..c646179661f --- /dev/null +++ b/projects/igniteui-angular/checkbox/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './checkbox/public_api'; +export * from './checkbox/checkbox.module'; diff --git a/projects/igniteui-angular/chips/README.md b/projects/igniteui-angular/chips/README.md new file mode 100644 index 00000000000..f1f1c7fb767 --- /dev/null +++ b/projects/igniteui-angular/chips/README.md @@ -0,0 +1,192 @@ +# igxChip Component + +The **igxChip** is a compact visual component that displays information in an obround. A chip can be templated, deleted and selected. Multiple chips can be reordered and visually connected to each other. Chips reside in a container called chips area which is responsible for managing the interactions between the chips. + +#### Initializing Chips + +The `IgxChipComponent` is the main class for a chip element and the `IgxChipsAreaComponent` is the main class for the chip area. The chip area is used for handling more complex scenarios that require interaction between chips (dragging, selection, navigation, etc.). The `IgxChipComponent` has an `id` input so that the different chips can be easily distinguished. If `id` is not provided it will be automatically generated. + +Example of using `igxChip` with `igxChipArea`: + +```html + + + {{chip.text}} + + +``` + +### Features + +#### Selection + +Selection can be enabled by setting an input called `selectable`. The selecting is done either by clicking on the chip itself or by using the `Tab` key to focus the chip and then pressing the `Space` key. If a chip is already selected it can be deselected by pressing the `Space` key again while the chip is focused or by clicking on it. + +An event `onSelection` is fired when the selection state of the `igxChip` changes. It provides the new `selected` value so you can get the new state and the original event in `originalEvent` that triggered this selection change. If this is not done through user interaction but instead is done by setting the `selected` property programmatically the `originalEvent` argument has value `null`. + +Also by default an icon is shown indicating that the chip is being selected. It is fully customizable and can be done through the `selectIcon` input. It accepts values of type `TemplateRef` and overrides the default icon while retaining the same functionality. + +Example of customizing the select icon: + +```html + + + {{chip.text}} + + + + done_outline + +``` + +#### Removing + +Removing can be enabled by setting the `removable` input to `true`. When enabled a remove button is rendered at the end of the chip. When the end-users performs any interaction like clicking on the remove button or pressing the `Delete` key while the chip is focused the `remove` event is emitted. + +By default the chip does not remove itself from the template when the user wants to delete a chip. This needs to be handled manually using the `remove` event. + +If you need to customize the remove icon use the `removeIcon` input. It takes a value of type `TemplateRef` and renders it instead of the default remove icon. This means that you can customize the remove button in any way while all the handling of it is still handled by the chip itself. + +Example of handling chip removing and custom remove icon: +```html + + + {{chip.text}} + + + + delete + +``` + +```ts +public chipRemoved(event) { + this.chipList = this.chipList.filter((item) => { + return item.id !== event.owner.id; + }); + this.cdr.detectChanges(); +} +``` + +#### Moving/Dragging + +The chip can be dragged by the end-user in order to change its position. The moving/dragging is disabled by default, but can be enabled by setting an input `draggable`. The actual moving of the chip in the template has to be handled manually by the developer. + +```html + + + {{chip.text}} + + +``` + +```ts +public ngOnInit() { + chipArea.forEach((chip) => { + chip.draggable = true; + }); +} + +public chipsOrderChanged(event) { + const newChipList = []; + for (const chip of event.chipsArray) { + const chipItem = this.chipList.filter((item) => { + return item.id === chip.id; + })[0]; + newChipList.push(chipItem); + } + this.chipList = newChipList; +} + +``` + +#### Chip Templates + +The `IgxChipComponent`'s main structure consists of chip content, `select icon`, `remove button`, `prefix` and `suffix`. All of those elements are templatable. + +The content of the chip is taken by the content defined inside the chip template except elements that define the `prefix`or `suffix` of the chip. You can define any type of content you need. + +The `prefix` and `suffix` are also elements inside the actual chip area where they can be templated by your preference. The way they can be specified is by using the `IgxPrefix` and `IxgSuffix` directives respectively. + +Example of using an icon for a `prefix`, text for content and a custom icon again for a `suffix`: + +```html + + drag_indicator + {{chip.text}} + close + +``` + +#### Keyboard Navigation + +The chips can be focused using the `Tab` key or by clicking on them. Chips can be reordered using the keyboard navigation: + +- Keyboard controls when the chip is focused: + + - LEFT - Moves the focus to the chip on the left. + - RIGHT - Focuses the chip on the right. + - SPACE - Toggles chip selection if it is selectable. + - DELETE - Triggers the `remove` event for the `igxChip` so the chip deletion can be handled manually + - SHIFT + LEFT - Triggers `onReorder` event for the `igxChipArea` when the currently focused chip should move position to the left. + - SHIFT + RIGHT - Triggers `onReorder` event for the `igxChipArea` when the currently focused chip should move one position to the right + +- Keyboard controls when the remove button is focused: + + - SPACE or ENTER Triggers the `remove` event so the chip deletion can be handled manually + +# API + +## IgxChipComponent + +### Inputs +| Name | Type | Description | +|:----------|:-------------:|:------| +| `id` | `string` | Unique identifier of the component. | +| `data` | `any` | Stores data related to the chip. | +| `draggable ` | `boolean` | Defines if the chip can be dragged in order to change its position. | +| `removable ` | `boolean` | Defines if the chip should render remove button and throw remove events. | +| `removeIcon ` | `TemplateRef` | Overrides the default remove icon when `removable` is set to `true`. | +| `selectable ` | `boolen` | Defines if the chip can be selected on click or through navigation. | +| `selectIcon ` | `TemplateRef` | Overrides the default select icon when `selectable` is set to `true`. | +| `selected` | `boolen` | Sets if the chip is selected. | +| `disabled` | `boolean` | Sets if the chip is disabled. | +| `color` | `string` | Sets the chip background color. | +| `hideBaseOnDrag` | `boolean` | Sets if the chip base should be hidden when the chip is dragged. | + +### Outputs +| Name | Argument Type | Description | +|:--:|:---|:---| +| `moveStart` | `IBaseChipEventArgs` | Fired when the chip moving(dragging) starts. | +| `moveEnd` | `IBaseChipEventArgs` | Fired when the chip moving(dragging) ends. | +| `remove ` | `IBaseChipEventArgs` | Fired when the chip remove button is clicked. | +| `chipClick ` | `IChipClickEventArgs` | Fired when the chip is clicked instead of dragged. | +| `selectedChanging` | `IChipSelectEventArgs` | Fired when the chip is being selected/deselected. Cancellable | +| `selectedChange` | | +| `selectedChanging` | `IChipSelectEventArgs` | Fired when the chip is being selected/deselected. Cancellable | +| `keyDown ` | `IChipKeyDownEventArgs` | Fired when the chip keyboard navigation is being used. | +| `dragEnter ` | `IChipEnterDragAreaEventArgs` | Fired when another chip has entered the current chip area. | +| `dragLeave ` | `IChipEnterDragAreaEventArgs` | Fired when another chip has left the current chip area. | +| `dragDrop ` | `IChipEnterDragAreaEventArgs` | Fired when another chip has been dropped in the current chip area. | +| `dragOver ` | `IChipEnterDragAreaEventArgs` | Fired when another chip has moved over the current chip area. | + +## IgxChipsAreaComponent + +### Inputs +| Name | Type | Description | +|:----------|:-------------:|:------| +| `width` | `number` | Sets the width of the chips area. | +| `height ` | `number` | Sets the height of the chips area. | + +### Outputs +| Name | Argument Type | Description | +|:--:|:---|:---| +| `reorder ` | `IChipsAreaReorderEventArgs` | Fired when the chips order should be changed(from dragging). Requires custom logic for actual reorder. | +| `selectionChange ` | `IChipsAreaSelectEventArgs` | Fired for all initially selected chips and when chip is being selected/deselected. | +| `moveStart ` | `IBaseChipsAreaEventArgs` | Fired when any chip moving(dragging) starts. | +| `moveEnd ` | `IBaseChipsAreaEventArgs` | Fired when any chip moving(dragging) ends. | + +### Properties +| Name | Return Type | Description | +|:----------:|:------|:------| +| `chipsList` | `QueryList` | Returns the list of chips inside the chip area. | diff --git a/projects/igniteui-angular/chips/index.ts b/projects/igniteui-angular/chips/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/chips/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/chips/ng-package.json b/projects/igniteui-angular/chips/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/chips/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/chips/src/chips/chip.component.html b/projects/igniteui-angular/chips/src/chips/chip.component.html new file mode 100644 index 00000000000..27958ceca06 --- /dev/null +++ b/projects/igniteui-angular/chips/src/chips/chip.component.html @@ -0,0 +1,64 @@ +
+ +
+ @if (selected) { +
+ +
+ } + + +
+ +
+ +
+ +
+ + + @if (removable) { +
+ +
+ } +
+
+ + + + + + + + diff --git a/projects/igniteui-angular/chips/src/chips/chip.component.ts b/projects/igniteui-angular/chips/src/chips/chip.component.ts new file mode 100644 index 00000000000..b25a5e646a0 --- /dev/null +++ b/projects/igniteui-angular/chips/src/chips/chip.component.ts @@ -0,0 +1,919 @@ +import { + Component, + ChangeDetectorRef, + EventEmitter, + ElementRef, + HostBinding, + HostListener, + Input, + Output, + ViewChild, + Renderer2, + TemplateRef, + OnDestroy, + booleanAttribute, + OnInit, + inject, + DOCUMENT +} from '@angular/core'; +import { IgxDragDirective, IDragBaseEventArgs, IDragStartEventArgs, IDropBaseEventArgs, IDropDroppedEventArgs, IgxDropDirective } from 'igniteui-angular/directives'; +import { IBaseEventArgs, ɵSize } from 'igniteui-angular/core'; +import { ChipResourceStringsEN, IChipResourceStrings } from 'igniteui-angular/core'; +import { Subject } from 'rxjs'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { NgClass, NgTemplateOutlet } from '@angular/common'; +import { getCurrentResourceStrings } from 'igniteui-angular/core'; + +export const IgxChipTypeVariant = { + PRIMARY: 'primary', + INFO: 'info', + SUCCESS: 'success', + WARNING: 'warning', + DANGER: 'danger' +} as const; +export type IgxChipTypeVariant = (typeof IgxChipTypeVariant)[keyof typeof IgxChipTypeVariant]; + +export interface IBaseChipEventArgs extends IBaseEventArgs { + originalEvent: IDragBaseEventArgs | IDropBaseEventArgs | KeyboardEvent | MouseEvent | TouchEvent; + owner: IgxChipComponent; +} + +export interface IChipClickEventArgs extends IBaseChipEventArgs { + cancel: boolean; +} + +export interface IChipKeyDownEventArgs extends IBaseChipEventArgs { + originalEvent: KeyboardEvent; + cancel: boolean; +} + +export interface IChipEnterDragAreaEventArgs extends IBaseChipEventArgs { + dragChip: IgxChipComponent; +} + +export interface IChipSelectEventArgs extends IBaseChipEventArgs { + cancel: boolean; + selected: boolean; +} + +let CHIP_ID = 0; + +/** + * Chip is compact visual component that displays information in an obround. + * + * @igxModule IgxChipsModule + * + * @igxTheme igx-chip-theme + * + * @igxKeywords chip + * + * @igxGroup display + * + * @remarks + * The Ignite UI Chip can be templated, deleted, and selected. + * Multiple chips can be reordered and visually connected to each other. + * Chips reside in a container called chips area which is responsible for managing the interactions between the chips. + * + * @example + * ```html + * + * + * + * ``` + */ +@Component({ + selector: 'igx-chip', + templateUrl: 'chip.component.html', + imports: [IgxDropDirective, IgxDragDirective, NgClass, NgTemplateOutlet, IgxIconComponent] +}) +export class IgxChipComponent implements OnInit, OnDestroy { + public cdr = inject(ChangeDetectorRef); + private ref = inject>(ElementRef); + private renderer = inject(Renderer2); + public document = inject(DOCUMENT); + + + /** + * Sets/gets the variant of the chip. + * + * @remarks + * Allowed values are `primary`, `info`, `success`, `warning`, `danger`. + * Providing no/nullish value leaves the chip in its default state. + * + * @example + * ```html + * + * ``` + */ + @Input() + public variant?: IgxChipTypeVariant | null; + /** + * Sets the value of `id` attribute. If not provided it will be automatically generated. + * + * @example + * ```html + * + * ``` + */ + @HostBinding('attr.id') + @Input() + public id = `igx-chip-${CHIP_ID++}`; + + /** + * Returns the `role` attribute of the chip. + * + * @example + * ```typescript + * let chipRole = this.chip.role; + * ``` + */ + @HostBinding('attr.role') + public role = 'option'; + + /** + * Sets the value of `tabindex` attribute. If not provided it will use the element's tabindex if set. + * + * @example + * ```html + * + * ``` + */ + @HostBinding('attr.tabIndex') + @Input() + public set tabIndex(value: number) { + this._tabIndex = value; + } + + public get tabIndex() { + if (this._tabIndex !== null) { + return this._tabIndex; + } + return !this.disabled ? 0 : null; + } + + /** + * Stores data related to the chip. + * + * @example + * ```html + * + * ``` + */ + @Input() + public data: any; + + /** + * Defines if the `IgxChipComponent` can be dragged in order to change it's position. + * By default it is set to false. + * + * @example + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public draggable = false; + + /** + * Enables/disables the draggable element animation when the element is released. + * By default it's set to true. + * + * @example + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public animateOnRelease = true; + + /** + * Enables/disables the hiding of the base element that has been dragged. + * By default it's set to true. + * + * @example + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public hideBaseOnDrag = true; + + /** + * Defines if the `IgxChipComponent` should render remove button and throw remove events. + * By default it is set to false. + * + * @example + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public removable = false; + + /** + * Overrides the default icon that the chip applies to the remove button. + * + * @example + * ```html + * + * delete + * ``` + */ + @Input() + public removeIcon: TemplateRef; + + /** + * Defines if the `IgxChipComponent` can be selected on click or through navigation, + * By default it is set to false. + * + * @example + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public selectable = false; + + /** + * Overrides the default icon that the chip applies when it is selected. + * + * @example + * ```html + * + * done_outline + * ``` + */ + @Input() + public selectIcon: TemplateRef; + + /** + * @hidden + * @internal + */ + @Input() + public class = ''; + + /** + * Disables the `IgxChipComponent`. When disabled it restricts user interactions + * like focusing on click or tab, selection on click or Space, dragging. + * By default it is set to false. + * + * @example + * ```html + * + * ``` + */ + @HostBinding('class.igx-chip--disabled') + @Input({ transform: booleanAttribute }) + public disabled = false; + + /** + * Sets the `IgxChipComponent` selected state. + * + * @example + * ```html + * + * ``` + * + * Two-way data binding: + * ```html + * + * ``` + */ + @HostBinding('attr.aria-selected') + @Input({ transform: booleanAttribute }) + public set selected(newValue: boolean) { + this.changeSelection(newValue); + } + + /** + * Returns if the `IgxChipComponent` is selected. + * + * @example + * ```typescript + * @ViewChild('myChip') + * public chip: IgxChipComponent; + * selectedChip(){ + * let selectedChip = this.chip.selected; + * } + * ``` + */ + public get selected() { + return this._selected; + } + + /** + * @hidden + * @internal + */ + @Output() + public selectedChange = new EventEmitter(); + + /** + * Sets the `IgxChipComponent` background color. + * The `color` property supports string, rgb, hex. + * + * @example + * ```html + * + * ``` + */ + @Input() + public set color(newColor) { + this.chipArea.nativeElement.style.backgroundColor = newColor; + } + + /** + * Returns the background color of the `IgxChipComponent`. + * + * @example + * ```typescript + * @ViewChild('myChip') + * public chip: IgxChipComponent; + * ngAfterViewInit(){ + * let chipColor = this.chip.color; + * } + * ``` + */ + public get color() { + return this.chipArea.nativeElement.style.backgroundColor; + } + + /** + * An accessor that sets the resource strings. + * By default it uses EN resources. + */ + @Input() + public set resourceStrings(value: IChipResourceStrings) { + this._resourceStrings = Object.assign({}, this._resourceStrings, value); + } + + /** + * An accessor that returns the resource strings. + */ + public get resourceStrings(): IChipResourceStrings { + return this._resourceStrings; + } + + /** + * Emits an event when the `IgxChipComponent` moving starts. + * Returns the moving `IgxChipComponent`. + * + * @example + * ```html + * + * ``` + */ + @Output() + public moveStart = new EventEmitter(); + + /** + * Emits an event when the `IgxChipComponent` moving ends. + * Returns the moved `IgxChipComponent`. + * + * @example + * ```html + * + * ``` + */ + @Output() + public moveEnd = new EventEmitter(); + + /** + * Emits an event when the `IgxChipComponent` is removed. + * Returns the removed `IgxChipComponent`. + * + * @example + * ```html + * + * ``` + */ + @Output() + public remove = new EventEmitter(); + + /** + * Emits an event when the `IgxChipComponent` is clicked. + * Returns the clicked `IgxChipComponent`, whether the event should be canceled. + * + * @example + * ```html + * + * ``` + */ + @Output() + public chipClick = new EventEmitter(); + + /** + * Emits event when the `IgxChipComponent` is selected/deselected. + * Returns the selected chip reference, whether the event should be canceled, what is the next selection state and + * when the event is triggered by interaction `originalEvent` is provided, otherwise `originalEvent` is `null`. + * + * @example + * ```html + * + * ``` + */ + @Output() + public selectedChanging = new EventEmitter(); + + /** + * Emits event when the `IgxChipComponent` is selected/deselected and any related animations and transitions also end. + * + * @example + * ```html + * + * ``` + */ + @Output() + public selectedChanged = new EventEmitter(); + + /** + * Emits an event when the `IgxChipComponent` keyboard navigation is being used. + * Returns the focused/selected `IgxChipComponent`, whether the event should be canceled, + * if the `alt`, `shift` or `control` key is pressed and the pressed key name. + * + * @example + * ```html + * + * ``` + */ + @Output() + public keyDown = new EventEmitter(); + + /** + * Emits an event when the `IgxChipComponent` has entered the `IgxChipsAreaComponent`. + * Returns the target `IgxChipComponent`, the drag `IgxChipComponent`, as well as + * the original drop event arguments. + * + * @example + * ```html + * + * ``` + */ + @Output() + public dragEnter = new EventEmitter(); + + /** + * Emits an event when the `IgxChipComponent` has left the `IgxChipsAreaComponent`. + * Returns the target `IgxChipComponent`, the drag `IgxChipComponent`, as well as + * the original drop event arguments. + * + * @example + * ```html + * + * ``` + */ + @Output() + public dragLeave = new EventEmitter(); + + /** + * Emits an event when the `IgxChipComponent` is over the `IgxChipsAreaComponent`. + * Returns the target `IgxChipComponent`, the drag `IgxChipComponent`, as well as + * the original drop event arguments. + * + * @example + * ```html + * + * ``` + */ + @Output() + public dragOver = new EventEmitter(); + + /** + * Emits an event when the `IgxChipComponent` has been dropped in the `IgxChipsAreaComponent`. + * Returns the target `IgxChipComponent`, the drag `IgxChipComponent`, as well as + * the original drop event arguments. + * + * @example + * ```html + * + * ``` + */ + @Output() + public dragDrop = new EventEmitter(); + + @HostBinding('class.igx-chip') + protected defaultClass = 'igx-chip'; + + @HostBinding('class.igx-chip--primary') + protected get isPrimary() { + return this.variant === IgxChipTypeVariant.PRIMARY; + } + + @HostBinding('class.igx-chip--info') + protected get isInfo() { + return this.variant === IgxChipTypeVariant.INFO; + } + + @HostBinding('class.igx-chip--success') + protected get isSuccess() { + return this.variant === IgxChipTypeVariant.SUCCESS; + } + + @HostBinding('class.igx-chip--warning') + protected get isWarning() { + return this.variant === IgxChipTypeVariant.WARNING; + } + + @HostBinding('class.igx-chip--danger') + protected get isDanger() { + return this.variant === IgxChipTypeVariant.DANGER; + } + + /** + * Property that contains a reference to the `IgxDragDirective` the `IgxChipComponent` uses for dragging behavior. + * + * @example + * ```html + * + * ``` + * ```typescript + * onMoveStart(event: IBaseChipEventArgs){ + * let dragDirective = event.owner.dragDirective; + * } + * ``` + */ + @ViewChild('chipArea', { read: IgxDragDirective, static: true }) + public dragDirective: IgxDragDirective; + + /** + * @hidden + * @internal + */ + @ViewChild('chipArea', { read: ElementRef, static: true }) + public chipArea: ElementRef; + + /** + * @hidden + * @internal + */ + @ViewChild('defaultRemoveIcon', { read: TemplateRef, static: true }) + public defaultRemoveIcon: TemplateRef; + + /** + * @hidden + * @internal + */ + @ViewChild('defaultSelectIcon', { read: TemplateRef, static: true }) + public defaultSelectIcon: TemplateRef; + + /** + * @hidden + * @internal + */ + public get removeButtonTemplate() { + if (!this.disabled) { + return this.removeIcon || this.defaultRemoveIcon; + } + } + + /** + * @hidden + * @internal + */ + public get selectIconTemplate() { + return this.selectIcon || this.defaultSelectIcon; + } + + /** + * @hidden + * @internal + */ + public get ghostStyles() { + return { '--ig-size': `${this.chipSize}` }; + } + + /** @hidden @internal */ + public get nativeElement() { + return this.ref.nativeElement; + } + + /** + * @hidden + * @internal + */ + public hideBaseElement = false; + + /** + * @hidden + * @internal + */ + public destroy$ = new Subject(); + + protected get chipSize(): ɵSize { + return this.computedStyles?.getPropertyValue('--ig-size') || ɵSize.Medium; + } + protected _tabIndex = null; + protected _selected = false; + protected _selectedItemClass = 'igx-chip__item--selected'; + protected _movedWhileRemoving = false; + protected computedStyles; + private _resourceStrings = getCurrentResourceStrings(ChipResourceStringsEN); + + /** + * @hidden + * @internal + */ + @HostListener('keydown', ['$event']) + public keyEvent(event: KeyboardEvent) { + this.onChipKeyDown(event); + } + + /** + * @hidden + * @internal + */ + public selectClass(condition: boolean): any { + const SELECT_CLASS = 'igx-chip__select'; + + return { + [SELECT_CLASS]: condition, + [`${SELECT_CLASS}--hidden`]: !condition + }; + } + + public onSelectTransitionDone(event) { + if (event.target.tagName) { + // Trigger onSelectionDone on when `width` property is changed and the target is valid element(not comment). + this.selectedChanged.emit({ + owner: this, + originalEvent: event + }); + } + } + + /** + * @hidden + * @internal + */ + public onChipKeyDown(event: KeyboardEvent) { + const keyDownArgs: IChipKeyDownEventArgs = { + originalEvent: event, + owner: this, + cancel: false + }; + + this.keyDown.emit(keyDownArgs); + if (keyDownArgs.cancel) { + return; + } + + if ((event.key === 'Delete' || event.key === 'Del') && this.removable) { + this.remove.emit({ + originalEvent: event, + owner: this + }); + } + + if ((event.key === ' ' || event.key === 'Spacebar') && this.selectable && !this.disabled) { + this.changeSelection(!this.selected, event); + } + + if (event.key !== 'Tab') { + event.preventDefault(); + } + } + + /** + * @hidden + * @internal + */ + public onRemoveBtnKeyDown(event: KeyboardEvent) { + if (event.key === ' ' || event.key === 'Spacebar' || event.key === 'Enter') { + this.remove.emit({ + originalEvent: event, + owner: this + }); + + event.preventDefault(); + event.stopPropagation(); + } + } + + public onRemoveMouseDown(event: PointerEvent | MouseEvent) { + event.stopPropagation(); + } + + /** + * @hidden + * @internal + */ + public onRemoveClick(event: MouseEvent | TouchEvent) { + this.remove.emit({ + originalEvent: event, + owner: this + }); + } + + /** + * @hidden + * @internal + */ + public onRemoveTouchMove() { + // We don't remove chip if user starting touch interacting on the remove button moves the chip + this._movedWhileRemoving = true; + } + + /** + * @hidden + * @internal + */ + public onRemoveTouchEnd(event: TouchEvent) { + if (!this._movedWhileRemoving) { + this.onRemoveClick(event); + } + this._movedWhileRemoving = false; + } + + /** + * @hidden + * @internal + */ + // ----------------------------- + // Start chip igxDrag behavior + public onChipDragStart(event: IDragStartEventArgs) { + this.moveStart.emit({ + originalEvent: event, + owner: this + }); + event.cancel = !this.draggable || this.disabled; + } + + /** + * @hidden + * @internal + */ + public onChipDragEnd() { + if (this.animateOnRelease) { + this.dragDirective.transitionToOrigin(); + } + } + + /** + * @hidden + * @internal + */ + public onChipMoveEnd(event: IDragBaseEventArgs) { + // moveEnd is triggered after return animation has finished. This happen when we drag and release the chip. + this.moveEnd.emit({ + originalEvent: event, + owner: this + }); + + if (this.selected) { + this.chipArea.nativeElement.focus(); + } + } + + /** + * @hidden + * @internal + */ + public onChipGhostCreate() { + this.hideBaseElement = this.hideBaseOnDrag; + } + + /** + * @hidden + * @internal + */ + public onChipGhostDestroy() { + this.hideBaseElement = false; + } + + /** + * @hidden + * @internal + */ + public onChipDragClicked(event: IDragBaseEventArgs) { + const clickEventArgs: IChipClickEventArgs = { + originalEvent: event, + owner: this, + cancel: false + }; + this.chipClick.emit(clickEventArgs); + + if (!clickEventArgs.cancel && this.selectable && !this.disabled) { + this.changeSelection(!this.selected, event); + } + } + // End chip igxDrag behavior + + /** + * @hidden + * @internal + */ + // ----------------------------- + // Start chip igxDrop behavior + public onChipDragEnterHandler(event: IDropBaseEventArgs) { + if (this.dragDirective === event.drag) { + return; + } + + const eventArgs: IChipEnterDragAreaEventArgs = { + owner: this, + dragChip: event.drag.data?.chip, + originalEvent: event + }; + this.dragEnter.emit(eventArgs); + } + + /** + * @hidden + * @internal + */ + public onChipDragLeaveHandler(event: IDropBaseEventArgs) { + if (this.dragDirective === event.drag) { + return; + } + + const eventArgs: IChipEnterDragAreaEventArgs = { + owner: this, + dragChip: event.drag.data?.chip, + originalEvent: event + }; + this.dragLeave.emit(eventArgs); + } + + /** + * @hidden + * @internal + */ + public onChipDrop(event: IDropDroppedEventArgs) { + // Cancel the default drop logic + event.cancel = true; + if (this.dragDirective === event.drag) { + return; + } + + const eventArgs: IChipEnterDragAreaEventArgs = { + owner: this, + dragChip: event.drag.data?.chip, + originalEvent: event + }; + this.dragDrop.emit(eventArgs); + } + + /** + * @hidden + * @internal + */ + public onChipOverHandler(event: IDropBaseEventArgs) { + if (this.dragDirective === event.drag) { + return; + } + + const eventArgs: IChipEnterDragAreaEventArgs = { + owner: this, + dragChip: event.drag.data?.chip, + originalEvent: event + }; + this.dragOver.emit(eventArgs); + } + // End chip igxDrop behavior + + protected changeSelection(newValue: boolean, srcEvent = null) { + const onSelectArgs: IChipSelectEventArgs = { + originalEvent: srcEvent, + owner: this, + selected: false, + cancel: false + }; + + if (newValue && !this._selected) { + onSelectArgs.selected = true; + this.selectedChanging.emit(onSelectArgs); + + if (!onSelectArgs.cancel) { + this.renderer.addClass(this.chipArea.nativeElement, this._selectedItemClass); + this._selected = newValue; + this.selectedChange.emit(this._selected); + this.selectedChanged.emit({ + owner: this, + originalEvent: srcEvent + }); + } + } else if (!newValue && this._selected) { + this.selectedChanging.emit(onSelectArgs); + + if (!onSelectArgs.cancel) { + this.renderer.removeClass(this.chipArea.nativeElement, this._selectedItemClass); + this._selected = newValue; + this.selectedChange.emit(this._selected); + this.selectedChanged.emit({ + owner: this, + originalEvent: srcEvent + }); + } + } + } + + public ngOnInit(): void { + this.computedStyles = this.document.defaultView.getComputedStyle(this.nativeElement); + } + + public ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/projects/igniteui-angular/chips/src/chips/chip.spec.ts b/projects/igniteui-angular/chips/src/chips/chip.spec.ts new file mode 100644 index 00000000000..b7c54bf7951 --- /dev/null +++ b/projects/igniteui-angular/chips/src/chips/chip.spec.ts @@ -0,0 +1,407 @@ +import { Component, ViewChild, ViewChildren, QueryList, ChangeDetectorRef, inject } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { IgxChipComponent } from './chip.component'; +import { IgxChipsAreaComponent } from './chips-area.component'; +import { IgxPrefixDirective } from '../../../input-group/src/public_api'; +import { IgxLabelDirective } from '../../../input-group/src/public_api'; +import { IgxSuffixDirective } from '../../../input-group/src/public_api'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { getComponentSize } from 'igniteui-angular/core'; +import { ControlsFunction } from 'igniteui-angular/test-utils/controls-functions.spec'; +import { UIInteractions, wait } from 'igniteui-angular/test-utils/ui-interactions.spec'; + +@Component({ + template: ` + + @for (chip of chipList; track chip.id) { + + {{chip.text}} + drag_indicator + + } + + Tab Chip + + + Tab Chip + + + Tab Chip + + + Tab Chip + + + Tab Chip + + + `, + imports: [IgxChipComponent, IgxChipsAreaComponent, IgxIconComponent, IgxPrefixDirective] +}) +class TestChipComponent { + public cdr = inject(ChangeDetectorRef); + + + @ViewChild('chipsArea', { read: IgxChipsAreaComponent, static: true }) + public chipsArea: IgxChipsAreaComponent; + + @ViewChildren('chipElem', { read: IgxChipComponent }) + public chips: QueryList; + + public chipList = [ + { id: 'Country', text: 'Country', removable: false, selectable: false, draggable: true }, + { id: 'City', text: 'City', removable: true, selectable: true, draggable: true, chipSize: '--ig-size-large' }, + { id: 'Town', text: 'Town', removable: true, selectable: true, draggable: true, chipSize: '--ig-size-small' }, + { id: 'FirstName', text: 'First Name', removable: true, selectable: true, draggable: true, chipSize: '--ig-size-medium' } + ]; + + public chipRemoved(event) { + this.chipList = this.chipList.filter((item) => item.id !== event.owner.id); + this.cdr.detectChanges(); + } +} + +@Component({ + template: ` + + @for (chip of chipList; track chip.id) { + + label + suf + + } + + `, + imports: [IgxChipsAreaComponent, IgxChipComponent, IgxLabelDirective, IgxSuffixDirective] +}) +class TestChipsLabelAndSuffixComponent { + + @ViewChild('chipsArea', { read: IgxChipsAreaComponent, static: true }) + public chipsArea: IgxChipsAreaComponent; + + @ViewChildren('chipElem', { read: IgxChipComponent }) + public chips: QueryList; + + public chipList = [ + { id: 'Country', text: 'Country', removable: false, selectable: false, draggable: true }, + { id: 'City', text: 'City', removable: true, selectable: true, draggable: true }, + { id: 'Town', text: 'Town', removable: true, selectable: true, draggable: true }, + { id: 'FirstName', text: 'First Name', removable: true, selectable: true, draggable: true }, + ]; +} + + +describe('IgxChip', () => { + const CHIP_TEXT_CLASS = 'igx-chip__text'; + const CHIP_ITEM_CLASS = 'igx-chip__item'; + + let fix: ComponentFixture; + let chipArea; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TestChipComponent, TestChipsLabelAndSuffixComponent] + }).compileComponents(); + })); + + describe('Rendering Tests: ', () => { + beforeEach(() => { + fix = TestBed.createComponent(TestChipComponent); + fix.detectChanges(); + chipArea = fix.debugElement.queryAll(By.directive(IgxChipsAreaComponent)); + }); + + it('should render chip area and chips inside it', () => { + expect(chipArea.length).toEqual(1); + expect(chipArea[0].nativeElement.children.length).toEqual(9); + expect(chipArea[0].nativeElement.children[0].tagName).toEqual('IGX-CHIP'); + }); + + it('should render prefix element inside the chip before the content', () => { + const igxChip = fix.debugElement.queryAll(By.directive(IgxChipComponent)); + const igxChipItem = igxChip[1].nativeElement; + + expect(igxChipItem.children[0].children[0].children[0].hasAttribute('igxprefix')).toEqual(true); + }); + + it('should render remove button when enabled after the content inside the chip', () => { + const igxChip = fix.debugElement.queryAll(By.directive(IgxChipComponent)); + const igxChipItem = igxChip[1].nativeElement; + const chipRemoveButton = ControlsFunction.getChipRemoveButton(igxChipItem); + + expect(igxChipItem.children[0].children[2].children[0]).toHaveClass('igx-chip__remove'); + expect(chipRemoveButton).toBeTruthy(); + }); + + it('should change chip variant', () => { + const fixture = TestBed.createComponent(IgxChipComponent); + const igxChip = fixture.componentInstance; + // For test fixture destroy + igxChip.id = "root1"; + + igxChip.variant = 'danger'; + + fixture.detectChanges(); + + expect(igxChip.variant).toMatch('danger'); + expect(igxChip.nativeElement).toHaveClass('igx-chip--danger'); + }); + + it('should set text in chips correctly', () => { + const chipElements = chipArea[0].queryAll(By.directive(IgxChipComponent)); + const firstChipTextElement = chipElements[0].queryAllNodes(By.css(`.${CHIP_TEXT_CLASS}`)); + const firstChipText = firstChipTextElement[0].nativeNode.innerHTML; + + expect(firstChipText).toContain('Country'); + + const secondChipTextElement = chipElements[1].queryAllNodes(By.css(`.${CHIP_TEXT_CLASS}`)); + const secondChipText = secondChipTextElement[0].nativeNode.innerHTML; + + expect(secondChipText).toContain('City'); + }); + + it('should set chips prefix correctly', () => { + const chipElements = chipArea[0].queryAll(By.directive(IgxChipComponent)); + const firstChipPrefix = chipElements[0].queryAll(By.directive(IgxPrefixDirective)); + const firstChipIconName = firstChipPrefix[0].nativeElement.textContent; + + expect(firstChipIconName).toContain('drag_indicator'); + }); + + it('should set correctly color of chip when color is set through code', () => { + const chipColor = 'rgb(255, 0, 0)'; + + const components = fix.debugElement.queryAll(By.directive(IgxChipComponent)); + const firstComponent = components[0]; + const chipAreaElem = firstComponent.queryAll(By.css(`.${CHIP_ITEM_CLASS}`))[0]; + + firstComponent.componentInstance.color = chipColor; + + expect(chipAreaElem.nativeElement.style.backgroundColor).toEqual(chipColor); + expect(firstComponent.componentInstance.color).toEqual(chipColor); + }); + + it('should apply correct tabIndex to the chip area only when tabIndex is set as property of the chip and chip is disabled', () => { + const firstTabChip = fix.debugElement.queryAll(By.directive(IgxChipComponent))[4]; + expect(firstTabChip.nativeElement.getAttribute('tabindex')).toEqual('1'); + + // Chip is disabled, but attribute tabindex has bigger priority. + const secondTabChip = fix.debugElement.queryAll(By.directive(IgxChipComponent))[5]; + expect(secondTabChip.nativeElement.getAttribute('tabindex')).toEqual('2'); + }); + + it('should apply correct tab indexes when tabIndex and removeTabIndex are set as inputs', () => { + const thirdTabChip = fix.debugElement.queryAll(By.directive(IgxChipComponent))[6]; + const deleteBtn = ControlsFunction.getChipRemoveButton(thirdTabChip.componentInstance.chipArea.nativeElement); + expect(thirdTabChip.nativeElement.getAttribute('tabindex')).toEqual('3'); + expect(deleteBtn.getAttribute('tabindex')).toEqual('3'); + + // tabIndex attribute has higher priority than tabIndex. + const fourthTabChip = fix.debugElement.queryAll(By.directive(IgxChipComponent))[7]; + expect(fourthTabChip.nativeElement.getAttribute('tabindex')).toEqual('1'); + + // tabIndex attribute has higher priority than tabIndex input and chip being disabled. + const fifthTabChip = fix.debugElement.queryAll(By.directive(IgxChipComponent))[8]; + expect(fifthTabChip.nativeElement.getAttribute('tabindex')).toEqual('1'); + }); + }); + + describe('Interactions Tests: ', () => { + beforeEach(() => { + fix = TestBed.createComponent(TestChipComponent); + fix.detectChanges(); + }); + + it('should not trigger remove event when delete button is pressed when not removable', () => { + const firstChipComp = fix.componentInstance.chips.toArray()[0]; + + spyOn(firstChipComp.remove, 'emit'); + UIInteractions.triggerKeyDownEvtUponElem('Delete', firstChipComp.chipArea.nativeElement, true); + fix.detectChanges(); + + expect(firstChipComp.remove.emit).not.toHaveBeenCalled(); + }); + + it('should trigger remove event when delete button is pressed when removable', () => { + const secondChipComp = fix.componentInstance.chips.toArray()[1]; + + spyOn(secondChipComp.remove, 'emit'); + UIInteractions.triggerKeyDownEvtUponElem('Delete', secondChipComp.chipArea.nativeElement, true); + fix.detectChanges(); + + expect(secondChipComp.remove.emit).toHaveBeenCalled(); + }); + + it('should delete chip when space button is pressed on delete button', () => { + HelperTestFunctions.verifyChipsCount(fix, 9); + const chipElems = fix.debugElement.queryAll(By.directive(IgxChipComponent)); + const deleteButtonElement = ControlsFunction.getChipRemoveButton(chipElems[1].nativeElement); + // Removes chip with id City, because country chip is unremovable + UIInteractions.triggerKeyDownEvtUponElem(' ', deleteButtonElement, true); + fix.detectChanges(); + + HelperTestFunctions.verifyChipsCount(fix, 8); + + const chipComponentsIds = fix.componentInstance.chipList.map(c => c.id); + expect(chipComponentsIds.length).toEqual(3); + expect(chipComponentsIds).not.toContain('City'); + }); + + it('should delete chip when enter button is pressed on delete button', () => { + HelperTestFunctions.verifyChipsCount(fix, 9); + + const chipElems = fix.debugElement.queryAll(By.directive(IgxChipComponent)); + const deleteButtonElement = ControlsFunction.getChipRemoveButton(chipElems[1].nativeElement); + // Removes chip with id City, because country chip is unremovable + UIInteractions.triggerKeyDownEvtUponElem('Enter', deleteButtonElement, true); + fix.detectChanges(); + + HelperTestFunctions.verifyChipsCount(fix, 8); + + const chipComponentsIds = fix.componentInstance.chipList.map(c => c.id); + expect(chipComponentsIds.length).toEqual(3); + expect(chipComponentsIds).not.toContain('City'); + }); + + it('should affect the ghostElement size when chip has it set to compact', () => { + const thirdChip = fix.componentInstance.chips.toArray()[2]; + const thirdChipElem = thirdChip.chipArea.nativeElement; + + const startingTop = thirdChipElem.getBoundingClientRect().top; + const startingLeft = thirdChipElem.getBoundingClientRect().left; + const startingBottom = thirdChipElem.getBoundingClientRect().bottom; + const startingRight = thirdChipElem.getBoundingClientRect().right; + + const startingX = (startingLeft + startingRight) / 2; + const startingY = (startingTop + startingBottom) / 2; + + UIInteractions.simulatePointerEvent('pointerdown', thirdChipElem, startingX, startingY); + fix.detectChanges(); + + UIInteractions.simulatePointerEvent('pointermove', thirdChipElem, startingX + 10, startingY + 10); + fix.detectChanges(); + + expect(getComponentSize(thirdChip.dragDirective.ghostElement)).toEqual('1'); + }); + + it('should fire selectedChanging event when selectable is true', () => { + const secondChipComp = fix.componentInstance.chips.toArray()[1]; + spyOn(secondChipComp.selectedChanging, 'emit'); + spyOn(secondChipComp.selectedChanged, 'emit'); + + UIInteractions.triggerKeyDownEvtUponElem(' ', secondChipComp.chipArea.nativeElement, true); + fix.detectChanges(); + expect(secondChipComp.selectedChanging.emit).toHaveBeenCalled(); + expect(secondChipComp.selectedChanged.emit).toHaveBeenCalled(); + expect(secondChipComp.selectedChanging.emit).not.toHaveBeenCalledWith({ + originalEvent: null, + owner: secondChipComp, + cancel: false, + selected: true + }); + + expect(secondChipComp.selectedChanging.emit).toHaveBeenCalledWith({ + originalEvent: jasmine.anything(), + owner: secondChipComp, + cancel: false, + selected: true + }); + }); + + it('should fire selectedChanged event when selectable is true', (async () => { + pending('This should be tested in the e2e test'); + const secondChipComp: IgxChipComponent = fix.componentInstance.chips.toArray()[1]; + + spyOn(secondChipComp.selectedChanging, 'emit'); + spyOn(secondChipComp.selectedChanged, 'emit'); + secondChipComp.chipArea.nativeElement.focus(); + + UIInteractions.triggerKeyDownEvtUponElem(' ', secondChipComp.chipArea.nativeElement, true); + fix.detectChanges(); + expect(secondChipComp.selectedChanging.emit).toHaveBeenCalled(); + expect(secondChipComp.selectedChanged.emit).not.toHaveBeenCalled(); + expect(secondChipComp.selectedChanging.emit).not.toHaveBeenCalledWith({ + originalEvent: null, + owner: secondChipComp, + cancel: false, + selected: true + }); + + await wait(400); + expect(secondChipComp.selectedChanged.emit).toHaveBeenCalledTimes(1); + expect(secondChipComp.selectedChanged.emit).not.toHaveBeenCalledWith({ + originalEvent: null, + owner: secondChipComp + }); + })); + + it('should not fire selectedChanging event when selectable is false', () => { + const firstChipComp: IgxChipComponent = fix.componentInstance.chips.toArray()[0]; + + spyOn(firstChipComp.selectedChanging, 'emit'); + spyOn(firstChipComp.selectedChanged, 'emit'); + firstChipComp.nativeElement.focus(); + + UIInteractions.triggerKeyDownEvtUponElem(' ', firstChipComp.chipArea.nativeElement, true); + fix.detectChanges(); + expect(firstChipComp.selectedChanging.emit).toHaveBeenCalledTimes(0); + expect(firstChipComp.selectedChanged.emit).toHaveBeenCalledTimes(0); + }); + + it('should not fire selectedChanging event when the remove button is clicked', () => { + const secondChipComp: IgxChipComponent = fix.componentInstance.chips.toArray()[1]; + + spyOn(secondChipComp.selectedChanging, 'emit'); + spyOn(secondChipComp.selectedChanged, 'emit'); + + const chipRemoveButton = ControlsFunction.getChipRemoveButton(secondChipComp.chipArea.nativeElement); + + const removeBtnTop = chipRemoveButton.getBoundingClientRect().top; + const removeBtnLeft = chipRemoveButton.getBoundingClientRect().left; + + UIInteractions.simulatePointerEvent('pointerdown', chipRemoveButton, removeBtnLeft, removeBtnTop); + fix.detectChanges(); + UIInteractions.simulatePointerEvent('pointerup', chipRemoveButton, removeBtnLeft, removeBtnTop); + fix.detectChanges(); + + expect(secondChipComp.selectedChanging.emit).not.toHaveBeenCalled(); + expect(secondChipComp.selectedChanged.emit).not.toHaveBeenCalled(); + // console.log('id', secondChipComp.id); + }); + }); + + describe('Chips Label Tests: ', () => { + beforeEach(() => { + fix = TestBed.createComponent(TestChipsLabelAndSuffixComponent); + fix.detectChanges(); + chipArea = fix.debugElement.queryAll(By.directive(IgxChipsAreaComponent)); + }); + + it('should set chips label correctly', () => { + const chipElements = chipArea[0].queryAll(By.directive(IgxChipComponent)); + const firstChipLabel = chipElements[0].queryAll(By.directive(IgxLabelDirective)); + const firstChipLabelText = firstChipLabel[0].nativeElement.innerHTML; + + expect(firstChipLabelText).toEqual('label'); + }); + + it('should set chips suffix correctly', () => { + const chipElements = chipArea[0].queryAll(By.directive(IgxChipComponent)); + const firstChipSuffix = chipElements[0].queryAll(By.directive(IgxSuffixDirective)); + const firstChipSuffixText = firstChipSuffix[0].nativeElement.innerHTML; + + expect(firstChipSuffixText).toEqual('suf'); + }); + }); +}); + +class HelperTestFunctions { + public static verifyChipsCount(fix, count) { + const chipComponents = fix.debugElement.queryAll(By.directive(IgxChipComponent)); + expect(chipComponents.length).toEqual(count); + } +} diff --git a/projects/igniteui-angular/chips/src/chips/chips-area.component.html b/projects/igniteui-angular/chips/src/chips/chips-area.component.html new file mode 100644 index 00000000000..6dbc7430638 --- /dev/null +++ b/projects/igniteui-angular/chips/src/chips/chips-area.component.html @@ -0,0 +1 @@ + diff --git a/projects/igniteui-angular/chips/src/chips/chips-area.component.ts b/projects/igniteui-angular/chips/src/chips/chips-area.component.ts new file mode 100644 index 00000000000..1146c073d6c --- /dev/null +++ b/projects/igniteui-angular/chips/src/chips/chips-area.component.ts @@ -0,0 +1,373 @@ +import { Component, ContentChildren, ChangeDetectorRef, EventEmitter, HostBinding, Input, IterableDiffer, IterableDiffers, Output, QueryList, DoCheck, AfterViewInit, OnDestroy, ElementRef, inject } from '@angular/core'; +import { + IgxChipComponent, + IChipSelectEventArgs, + IChipKeyDownEventArgs, + IChipEnterDragAreaEventArgs, + IBaseChipEventArgs +} from './chip.component'; +import { IDropBaseEventArgs, IDragBaseEventArgs } from 'igniteui-angular/directives'; +import { takeUntil } from 'rxjs/operators'; +import { Subject } from 'rxjs'; +import { rem } from 'igniteui-angular/core'; + +export interface IBaseChipsAreaEventArgs { + originalEvent: IDragBaseEventArgs | IDropBaseEventArgs | KeyboardEvent | MouseEvent | TouchEvent; + owner: IgxChipsAreaComponent; +} + +export interface IChipsAreaReorderEventArgs extends IBaseChipsAreaEventArgs { + chipsArray: IgxChipComponent[]; +} + +export interface IChipsAreaSelectEventArgs extends IBaseChipsAreaEventArgs { + newSelection: IgxChipComponent[]; +} + +/** + * The chip area allows you to perform more complex scenarios with chips that require interaction, + * like dragging, selection, navigation, etc. + * + * @igxModule IgxChipsModule + * + * @igxTheme igx-chip-theme + * + * @igxKeywords chip area, chip + * + * @igxGroup display + * + * @example + * ```html + * + * + * {{chip.text}} + * + * + * ``` + */ +@Component({ + selector: 'igx-chips-area', + templateUrl: 'chips-area.component.html', + standalone: true +}) +export class IgxChipsAreaComponent implements DoCheck, AfterViewInit, OnDestroy { + public cdr = inject(ChangeDetectorRef); + public element = inject(ElementRef); + private _iterableDiffers = inject(IterableDiffers); + + + /** + * Returns the `role` attribute of the chips area. + * + * @example + * ```typescript + * let chipsAreaRole = this.chipsArea.role; + * ``` + */ + @HostBinding('attr.role') + public role = 'listbox'; + + /** + * Returns the `aria-label` attribute of the chips area. + * + * @example + * ```typescript + * let ariaLabel = this.chipsArea.ariaLabel; + * ``` + * + */ + @HostBinding('attr.aria-label') + public ariaLabel = 'chip area'; + + /** + * Sets the width of the `IgxChipsAreaComponent`. + * + * @example + * ```html + * + * ``` + */ + @Input() + public width: number; + + /** @hidden @internal */ + @HostBinding('style.width.rem') + public get _widthToRem() { + return rem(this.width); + } + + /** + * Sets the height of the `IgxChipsAreaComponent`. + * + * @example + * ```html + * + * ``` + */ + @Input() + public height: number; + + /** @hidden @internal */ + @HostBinding('style.height.rem') + public get _heightToRem() { + return rem(this.height); + } + + /** + * Emits an event when `IgxChipComponent`s in the `IgxChipsAreaComponent` should be reordered. + * Returns an array of `IgxChipComponent`s. + * + * @example + * ```html + * + * ``` + */ + @Output() + public reorder = new EventEmitter(); + + /** + * Emits an event when an `IgxChipComponent` in the `IgxChipsAreaComponent` is selected/deselected. + * Fired after the chips area is initialized if there are initially selected chips as well. + * Returns an array of selected `IgxChipComponent`s and the `IgxChipAreaComponent`. + * + * @example + * ```html + * + * ``` + */ + @Output() + public selectionChange = new EventEmitter(); + + /** + * Emits an event when an `IgxChipComponent` in the `IgxChipsAreaComponent` is moved. + * + * @example + * ```html + * + * ``` + */ + @Output() + public moveStart = new EventEmitter(); + + /** + * Emits an event after an `IgxChipComponent` in the `IgxChipsAreaComponent` is moved. + * + * @example + * ```html + * + * ``` + */ + @Output() + public moveEnd = new EventEmitter(); + + /** + * Holds the `IgxChipComponent` in the `IgxChipsAreaComponent`. + * + * @example + * ```typescript + * ngAfterViewInit(){ + * let chips = this.chipsArea.chipsList; + * } + * ``` + */ + @ContentChildren(IgxChipComponent, { descendants: true }) + public chipsList: QueryList; + + protected destroy$ = new Subject(); + + @HostBinding('class') + protected hostClass = 'igx-chip-area'; + + private modifiedChipsArray: IgxChipComponent[]; + private _differ: IterableDiffer | null = null; + + constructor() { + this._differ = this._iterableDiffers.find([]).create(null); + } + + /** + * @hidden + * @internal + */ + public ngAfterViewInit() { + // If we have initially selected chips through their inputs, we need to get them, because we cannot listen to their events yet. + if (this.chipsList.length) { + const selectedChips = this.chipsList.filter((item: IgxChipComponent) => item.selected); + if (selectedChips.length) { + this.selectionChange.emit({ + originalEvent: null, + newSelection: selectedChips, + owner: this + }); + } + } + } + + /** + * @hidden + * @internal + */ + public ngDoCheck(): void { + if (this.chipsList) { + const changes = this._differ.diff(this.chipsList.toArray()); + if (changes) { + changes.forEachAddedItem((addedChip) => { + addedChip.item.moveStart.pipe(takeUntil(addedChip.item.destroy$)).subscribe((args) => { + this.onChipMoveStart(args); + }); + addedChip.item.moveEnd.pipe(takeUntil(addedChip.item.destroy$)).subscribe((args) => { + this.onChipMoveEnd(args); + }); + addedChip.item.dragEnter.pipe(takeUntil(addedChip.item.destroy$)).subscribe((args) => { + this.onChipDragEnter(args); + }); + addedChip.item.keyDown.pipe(takeUntil(addedChip.item.destroy$)).subscribe((args) => { + this.onChipKeyDown(args); + }); + if (addedChip.item.selectable) { + addedChip.item.selectedChanging.pipe(takeUntil(addedChip.item.destroy$)).subscribe((args) => { + this.onChipSelectionChange(args); + }); + } + }); + this.modifiedChipsArray = this.chipsList.toArray(); + } + } + } + + /** + * @hidden + * @internal + */ + public ngOnDestroy(): void { + this.destroy$.next(true); + this.destroy$.complete(); + } + + /** + * @hidden + * @internal + */ + protected onChipKeyDown(event: IChipKeyDownEventArgs) { + let orderChanged = false; + const chipsArray = this.chipsList.toArray(); + const dragChipIndex = chipsArray.findIndex((el) => el === event.owner); + if (event.originalEvent.shiftKey === true) { + if (event.originalEvent.key === 'ArrowLeft' || event.originalEvent.key === 'Left') { + orderChanged = this.positionChipAtIndex(dragChipIndex, dragChipIndex - 1, false, event.originalEvent); + if (orderChanged) { + setTimeout(() => { + this.chipsList.get(dragChipIndex - 1).nativeElement.focus(); + }); + } + } else if (event.originalEvent.key === 'ArrowRight' || event.originalEvent.key === 'Right') { + orderChanged = this.positionChipAtIndex(dragChipIndex, dragChipIndex + 1, true, event.originalEvent); + } + } else { + if ((event.originalEvent.key === 'ArrowLeft' || event.originalEvent.key === 'Left') && dragChipIndex > 0) { + chipsArray[dragChipIndex - 1].nativeElement.focus(); + } else if ((event.originalEvent.key === 'ArrowRight' || event.originalEvent.key === 'Right') && + dragChipIndex < chipsArray.length - 1) { + chipsArray[dragChipIndex + 1].nativeElement.focus(); + } + } + } + + /** + * @hidden + * @internal + */ + protected onChipMoveStart(event: IBaseChipEventArgs) { + this.moveStart.emit({ + originalEvent: event.originalEvent, + owner: this + }); + } + + /** + * @hidden + * @internal + */ + protected onChipMoveEnd(event: IBaseChipEventArgs) { + this.moveEnd.emit({ + originalEvent: event.originalEvent, + owner: this + }); + } + + /** + * @hidden + * @internal + */ + protected onChipDragEnter(event: IChipEnterDragAreaEventArgs) { + const dropChipIndex = this.chipsList.toArray().findIndex((el) => el === event.owner); + const dragChipIndex = this.chipsList.toArray().findIndex((el) => el === event.dragChip); + if (dragChipIndex < dropChipIndex) { + // from the left to right + this.positionChipAtIndex(dragChipIndex, dropChipIndex, true, event.originalEvent); + } else { + // from the right to left + this.positionChipAtIndex(dragChipIndex, dropChipIndex, false, event.originalEvent); + } + } + + /** + * @hidden + * @internal + */ + protected positionChipAtIndex(chipIndex, targetIndex, shiftRestLeft, originalEvent) { + if (chipIndex < 0 || this.chipsList.length <= chipIndex || + targetIndex < 0 || this.chipsList.length <= targetIndex) { + return false; + } + + const chipsArray = this.chipsList.toArray(); + const result: IgxChipComponent[] = []; + for (let i = 0; i < chipsArray.length; i++) { + if (shiftRestLeft) { + if (chipIndex <= i && i < targetIndex) { + result.push(chipsArray[i + 1]); + } else if (i === targetIndex) { + result.push(chipsArray[chipIndex]); + } else { + result.push(chipsArray[i]); + } + } else { + if (targetIndex < i && i <= chipIndex) { + result.push(chipsArray[i - 1]); + } else if (i === targetIndex) { + result.push(chipsArray[chipIndex]); + } else { + result.push(chipsArray[i]); + } + } + } + this.modifiedChipsArray = result; + + const eventData: IChipsAreaReorderEventArgs = { + chipsArray: this.modifiedChipsArray, + originalEvent, + owner: this + }; + this.reorder.emit(eventData); + return true; + } + + /** + * @hidden + * @internal + */ + protected onChipSelectionChange(event: IChipSelectEventArgs) { + let selectedChips = this.chipsList.filter((chip) => chip.selected); + if (event.selected && !selectedChips.includes(event.owner)) { + selectedChips.push(event.owner); + } else if (!event.selected && selectedChips.includes(event.owner)) { + selectedChips = selectedChips.filter((chip) => chip.id !== event.owner.id); + } + this.selectionChange.emit({ + originalEvent: event.originalEvent, + newSelection: selectedChips, + owner: this + }); + } +} diff --git a/projects/igniteui-angular/chips/src/chips/chips-area.spec.ts b/projects/igniteui-angular/chips/src/chips/chips-area.spec.ts new file mode 100644 index 00000000000..75cd1090f56 --- /dev/null +++ b/projects/igniteui-angular/chips/src/chips/chips-area.spec.ts @@ -0,0 +1,650 @@ +import { Component, ViewChild, ViewChildren, QueryList, ChangeDetectorRef, inject } from '@angular/core'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { IgxChipComponent } from './chip.component'; +import { IgxChipsAreaComponent } from './chips-area.component'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxPrefixDirective } from 'igniteui-angular/input-group'; +import { UIInteractions, wait } from 'igniteui-angular/test-utils/ui-interactions.spec'; + + +@Component({ + template: ` + + @for (chip of chipList; track chip.id) { + + drag_indicator + {{chip.text}} + + } + + `, + imports: [IgxChipsAreaComponent, IgxChipComponent, IgxIconComponent, IgxPrefixDirective] +}) +class TestChipComponent { + public cdr = inject(ChangeDetectorRef); + + @ViewChild('chipsArea', { read: IgxChipsAreaComponent, static: true }) + public chipsArea: IgxChipsAreaComponent; + + @ViewChildren('chipElem', { read: IgxChipComponent }) + public chips: QueryList; + + public chipList = [ + { id: 'Country', text: 'Country', removable: false, selectable: false, draggable: false }, + { id: 'City', text: 'City', removable: true, selectable: true, draggable: true } + ]; +} + +@Component({ + template: ` + + + first chip + + + second chip + + + third chip + + + `, + imports: [IgxChipsAreaComponent, IgxChipComponent] +}) +class TestChipSelectComponent extends TestChipComponent { +} + +@Component({ + template: ` + + @for (chip of chipList; track chip.id) { + + drag_indicator + {{chip.text}} + + } + + `, + imports: [IgxChipsAreaComponent, IgxChipComponent, IgxIconComponent, IgxPrefixDirective] +}) +class TestChipReorderComponent { + public cdr = inject(ChangeDetectorRef); + + @ViewChild('chipsArea', { read: IgxChipsAreaComponent, static: true }) + public chipsArea: IgxChipsAreaComponent; + + @ViewChildren('chipElem', { read: IgxChipComponent }) + public chips: QueryList; + + public chipList = [ + { id: 'Country', text: 'Country' }, + { id: 'City', text: 'City' }, + { id: 'Town', text: 'Town' }, + { id: 'FirstName', text: 'First Name' }, + ]; + + public chipsOrderChanged(event) { + const newChipList = []; + for (const chip of event.chipsArray) { + newChipList.push(this.chipList.find((item) => item.id === chip.id)); + } + this.chipList = newChipList; + } + + public chipRemoved(event) { + this.chipList = this.chipList.filter((item) => item.id !== event.owner.id); + this.chipsArea.cdr.detectChanges(); + } +} + + +describe('IgxChipsArea ', () => { + const CHIP_REMOVE_BUTTON = 'igx-chip__remove'; + const CHIP_AREA_CLASS = 'igx-chip-area'; + + let fix; + let chipArea: IgxChipsAreaComponent; + let chipAreaElement; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + TestChipComponent, + TestChipReorderComponent, + TestChipSelectComponent + ] + }).compileComponents(); + })); + + describe('Basic', () => { + beforeEach(() => { + fix = TestBed.createComponent(TestChipComponent); + fix.detectChanges(); + chipAreaElement = fix.debugElement.query(By.directive(IgxChipsAreaComponent)); + }); + + it('should add chips when adding data items ', () => { + expect(chipAreaElement.nativeElement.classList).toEqual(jasmine.arrayWithExactContents(['customClass', CHIP_AREA_CLASS])); + expect(chipAreaElement.nativeElement.children.length).toEqual(2); + + fix.componentInstance.chipList.push({ id: 'Town', text: 'Town', removable: true, selectable: true, draggable: true }); + + fix.detectChanges(); + + expect(chipAreaElement.nativeElement.children.length).toEqual(3); + }); + + it('should remove chips when removing data items ', () => { + expect(chipAreaElement.nativeElement.children.length).toEqual(2); + + fix.componentInstance.chipList.pop(); + fix.detectChanges(); + + expect(chipAreaElement.nativeElement.children.length).toEqual(1); + }); + + it('should change data in chips when data item is changed', () => { + expect(chipAreaElement.nativeElement.children[0].innerHTML).toContain('Country'); + + fix.componentInstance.chipList[0].text = 'New text'; + fix.detectChanges(); + + expect(chipAreaElement.nativeElement.children[0].innerHTML).toContain('New text'); + }); + }); + + + describe('Selection', () => { + const spaceKeyEvent = new KeyboardEvent('keydown', { key: ' ' }); + + it('should be able to select chip using input property', () => { + fix = TestBed.createComponent(TestChipSelectComponent); + fix.detectChanges(); + + const firstChipComp = fix.componentInstance.chips.toArray()[0]; + const secondChipComp = fix.componentInstance.chips.toArray()[1]; + const thirdChipComp = fix.componentInstance.chips.toArray()[2]; + + expect(firstChipComp.selected).toBe(true); + expect(secondChipComp.selected).toBe(true); + expect(thirdChipComp.selected).toBe(false); + }); + + it('should emit selectionChange for the chipArea event when there are initially selected chips through their inputs', () => { + fix = TestBed.createComponent(TestChipSelectComponent); + chipArea = fix.componentInstance.chipsArea; + + spyOn(chipArea.selectionChange, 'emit'); + + fix.detectChanges(); + + const chipComponents = fix.componentInstance.chips.toArray(); + expect(chipArea.selectionChange.emit).toHaveBeenCalledWith({ + originalEvent: null, + owner: chipArea, + newSelection: [chipComponents[0], chipComponents[1]] + }); + }); + + it('should focus on chip correctly', () => { + fix = TestBed.createComponent(TestChipSelectComponent); + fix.detectChanges(); + + const firstChipComp = fix.componentInstance.chips.toArray()[0]; + const secondChipComp = fix.componentInstance.chips.toArray()[1]; + + firstChipComp.nativeElement.focus(); + expect(document.activeElement).toBe(firstChipComp.nativeElement); + + secondChipComp.nativeElement.focus(); + expect(document.activeElement).toBe(secondChipComp.nativeElement); + }); + + it('should focus on previous and next chips after arrows are pressed', () => { + fix = TestBed.createComponent(TestChipSelectComponent); + fix.detectChanges(); + + const firstChipComp = fix.componentInstance.chips.toArray()[0]; + const secondChipComp = fix.componentInstance.chips.toArray()[1]; + + firstChipComp.nativeElement.focus(); + fix.detectChanges(); + + expect(document.activeElement).toBe(firstChipComp.nativeElement); + + const rightKey = new KeyboardEvent('keydown', { key: 'ArrowRight' }); + firstChipComp.onChipKeyDown(rightKey); + fix.detectChanges(); + + expect(document.activeElement).toBe(secondChipComp.nativeElement); + + const leftKey = new KeyboardEvent('keydown', { key: 'ArrowLeft' }); + secondChipComp.onChipKeyDown(leftKey); + fix.detectChanges(); + + expect(document.activeElement).toBe(firstChipComp.nativeElement); + }); + + it('should fire selectionChange event', () => { + fix = TestBed.createComponent(TestChipComponent); + fix.detectChanges(); + fix.componentInstance.cdr.detectChanges(); + + const secondChipComp: IgxChipComponent = fix.componentInstance.chips.toArray()[1]; + const chipAreaComp: IgxChipsAreaComponent = fix.debugElement.query(By.directive(IgxChipsAreaComponent)).componentInstance; + spyOn(chipAreaComp.selectionChange, 'emit'); + + secondChipComp.onChipKeyDown(spaceKeyEvent); + fix.detectChanges(); + + expect(chipAreaComp.selectionChange.emit).toHaveBeenCalledWith({ + originalEvent: spaceKeyEvent, + owner: chipAreaComp, + newSelection: [secondChipComp] + }); + + let chipsSelectionStates = fix.componentInstance.chips.toArray().filter(c => c.selected); + expect(chipsSelectionStates.length).toEqual(1); + expect(secondChipComp.selected).toBeTruthy(); + + secondChipComp.onChipKeyDown(spaceKeyEvent); + fix.detectChanges(); + + expect(chipAreaComp.selectionChange.emit).toHaveBeenCalledWith({ + originalEvent: spaceKeyEvent, + owner: chipAreaComp, + newSelection: [] + }); + + chipsSelectionStates = fix.componentInstance.chips.toArray().filter(c => c.selected); + expect(chipsSelectionStates.length).toEqual(0); + expect(secondChipComp.selected).not.toBeTruthy(); + }); + + it('should be able to have multiple chips selected', () => { + fix = TestBed.createComponent(TestChipComponent); + fix.detectChanges(); + + const chipAreaComponent = fix.componentInstance; + + chipAreaComponent.chipList.push({ id: 'Town', text: 'Town', removable: true, selectable: true, draggable: true }); + fix.detectChanges(); + + spyOn(chipAreaComponent.chipsArea.selectionChange, `emit`); + chipAreaComponent.chipsArea.chipsList.toArray()[1].selected = true; + fix.detectChanges(); + chipAreaComponent.chipsArea.chipsList.toArray()[2].selected = true; + fix.detectChanges(); + + const secondChipComp = fix.componentInstance.chips.toArray()[1]; + const thirdChipComp = fix.componentInstance.chips.toArray()[2]; + expect(chipAreaComponent.chipsArea.selectionChange.emit).toHaveBeenCalledTimes(2); + expect(chipAreaComponent.chipsArea.selectionChange.emit).toHaveBeenCalledWith({ + originalEvent: null, + owner: chipAreaComponent.chipsArea, + newSelection: [secondChipComp, thirdChipComp] + }); + }); + + it('should be able to select chip using input property', () => { + fix = TestBed.createComponent(TestChipSelectComponent); + fix.detectChanges(); + + const firstChipComp = fix.componentInstance.chips.toArray()[0]; + const secondChipComp = fix.componentInstance.chips.toArray()[1]; + const thirdChipComp = fix.componentInstance.chips.toArray()[2]; + + expect(firstChipComp.selected).toBe(true); + expect(secondChipComp.selected).toBe(true); + expect(thirdChipComp.selected).toBe(false); + }); + + it('should emit onSelection for the chipArea event when there are initially selected chips through their inputs', () => { + fix = TestBed.createComponent(TestChipSelectComponent); + chipArea = fix.componentInstance.chipsArea; + + spyOn(chipArea.selectionChange, 'emit'); + + fix.detectChanges(); + + const chipComponents = fix.componentInstance.chips.toArray(); + expect(chipArea.selectionChange.emit).toHaveBeenCalledWith({ + originalEvent: null, + owner: chipArea, + newSelection: [chipComponents[0], chipComponents[1]] + }); + }); + + it('should be able to select chip using api when selectable is set to false', () => { + fix = TestBed.createComponent(TestChipComponent); + fix.detectChanges(); + + const igxChip = fix.componentInstance.chipsArea.chipsList.toArray()[0]; + const igxChipItem = igxChip.nativeElement.children[0]; // Return igx-chip__item + + igxChip.selected = true; + fix.detectChanges(); + + expect(igxChip.selected).toBe(true); + expect(igxChipItem).toHaveClass(`igx-chip__item--selected`); + }); + + it('should fire only onSelection event for chip area when selecting a chip using spacebar', () => { + fix = TestBed.createComponent(TestChipComponent); + fix.detectChanges(); + fix.componentInstance.cdr.detectChanges(); + + chipArea = fix.componentInstance.chipsArea; + const secondChip = fix.componentInstance.chips.toArray()[1]; + + spyOn(chipArea.reorder, 'emit'); + spyOn(chipArea.selectionChange, 'emit'); + spyOn(chipArea.moveStart, 'emit'); + spyOn(chipArea.moveEnd, 'emit'); + + + secondChip.onChipKeyDown(spaceKeyEvent); + fix.detectChanges(); + + expect(chipArea.selectionChange.emit).toHaveBeenCalled(); + expect(chipArea.reorder.emit).not.toHaveBeenCalled(); + expect(chipArea.moveStart.emit).not.toHaveBeenCalled(); + expect(chipArea.moveEnd.emit).not.toHaveBeenCalled(); + }); + + it('should select a chip by clicking on it and emit onSelection event', () => { + fix = TestBed.createComponent(TestChipComponent); + fix.detectChanges(); + + chipArea = fix.componentInstance.chipsArea; + const secondChip = fix.componentInstance.chips.toArray()[1]; + const pointerUpEvt = new PointerEvent('pointerup'); + + spyOn(chipArea.selectionChange, 'emit'); + fix.detectChanges(); + + secondChip.onChipDragClicked({ + originalEvent: pointerUpEvt, + owner: secondChip.dragDirective, + pageX: 0, pageY: 0, startX: 0, startY: 0 + }); + fix.detectChanges(); + + expect(chipArea.selectionChange.emit).toHaveBeenCalled(); + expect(chipArea.selectionChange.emit).not.toHaveBeenCalledWith({ + originalEvent: pointerUpEvt, + owner: chipArea, + newSelection: [secondChip] + }); + }); + + it('should persist selected state when it is dragged and dropped', () => { + fix = TestBed.createComponent(TestChipComponent); + fix.detectChanges(); + + chipAreaElement = fix.debugElement.query(By.directive(IgxChipsAreaComponent)); + const chipComponents = chipAreaElement.queryAll(By.directive(IgxChipComponent)); + const secondChip = chipComponents[1].componentInstance; + + secondChip.animateOnRelease = false; + secondChip.onChipKeyDown(spaceKeyEvent); + + expect(secondChip.selected).toBeTruthy(); + UIInteractions.moveDragDirective(fix, secondChip.dragDirective, 200, 100); + + const firstChip = chipComponents[0].componentInstance; + expect(firstChip.selected).not.toBeTruthy(); + expect(secondChip.selected).toBeTruthy(); + }); + }); + + describe('Reorder', () => { + const leftKeyEvent = new KeyboardEvent('keydown', { key: 'ArrowLeft', shiftKey: true }); + const rightKeyEvent = new KeyboardEvent('keydown', { key: 'ArrowRight', shiftKey: true }); + const deleteKeyEvent = new KeyboardEvent('keydown', { key: 'Delete' }); + + beforeEach(() => { + fix = TestBed.createComponent(TestChipReorderComponent); + fix.detectChanges(); + fix.componentInstance.cdr.detectChanges(); + }); + + it('should reorder chips when shift + leftarrow and shift + rightarrow is pressed', () => { + const chipComponents = fix.debugElement.queryAll(By.directive(IgxChipComponent)); + const firstChipAreaElem = chipComponents[0].componentInstance.nativeElement; + const secondChipAreaElem = chipComponents[1].componentInstance.nativeElement; + const firstChipLeft = firstChipAreaElem.getBoundingClientRect().left; + const secondChipLeft = secondChipAreaElem.getBoundingClientRect().left; + + firstChipAreaElem.dispatchEvent(rightKeyEvent); + fix.detectChanges(); + + let newFirstChipLeft = firstChipAreaElem.getBoundingClientRect().left; + let newSecondChipLeft = secondChipAreaElem.getBoundingClientRect().left; + expect(firstChipLeft).toBeLessThan(newFirstChipLeft); + expect(newSecondChipLeft).toBeLessThan(secondChipLeft); + + firstChipAreaElem.dispatchEvent(leftKeyEvent); + fix.detectChanges(); + + newFirstChipLeft = firstChipAreaElem.getBoundingClientRect().left; + newSecondChipLeft = secondChipAreaElem.getBoundingClientRect().left; + + expect(firstChipLeft).toEqual(newFirstChipLeft); + expect(newSecondChipLeft).toEqual(secondChipLeft); + }); + + it('should reorder chips and keeps focus when Shift + Left Arrow is pressed and Shift + Right Arrow is pressed twice', + (async () => { + chipArea = fix.componentInstance.chipsArea; + const chipComponents = fix.debugElement.queryAll(By.directive(IgxChipComponent)); + const targetChip = chipComponents[2].componentInstance; + const targetChipElem = targetChip.nativeElement; + + targetChipElem.focus(); + fix.detectChanges(); + + expect(document.activeElement).toBe(targetChipElem); + expect(chipArea.chipsList.toArray()[2].id).toEqual('Town'); + expect(chipArea.chipsList.toArray()[3].id).toEqual('FirstName'); + + targetChip.onChipKeyDown(rightKeyEvent); + fix.detectChanges(); + + expect(document.activeElement).toBe(targetChipElem); + expect(chipArea.chipsList.toArray()[2].id).toEqual('FirstName'); + expect(chipArea.chipsList.toArray()[3].id).toEqual('Town'); + + targetChip.onChipKeyDown(leftKeyEvent); + fix.detectChanges(); + await wait(); + + expect(document.activeElement).toBe(targetChipElem); + expect(chipArea.chipsList.toArray()[2].id).toEqual('Town'); + expect(chipArea.chipsList.toArray()[3].id).toEqual('FirstName'); + + targetChip.onChipKeyDown(leftKeyEvent); + fix.detectChanges(); + await wait(); + + expect(document.activeElement).toBe(targetChipElem); + expect(chipArea.chipsList.toArray()[2].id).toEqual('City'); + expect(chipArea.chipsList.toArray()[3].id).toEqual('FirstName'); + }) + ); + + it('should not reorder chips for shift + leftarrow when the chip is going out of bounds', () => { + const chipComponents = fix.debugElement.queryAll(By.directive(IgxChipComponent)); + + const firstChipAreaElem = chipComponents[0].componentInstance.chipArea.nativeElement; + const firstChipLeft = firstChipAreaElem.getBoundingClientRect().left; + firstChipAreaElem.dispatchEvent(leftKeyEvent); + fix.detectChanges(); + + const newFirstChipLeft = firstChipAreaElem.getBoundingClientRect().left; + expect(firstChipLeft).toEqual(newFirstChipLeft); + }); + + it('should not reorder chips for shift + rightarrow when the chip is going out of bounds', () => { + const chipComponents = fix.debugElement.queryAll(By.directive(IgxChipComponent)); + + const lastChipAreaElem = chipComponents[chipComponents.length - 1].componentInstance.chipArea.nativeElement; + const lastChipLeft = lastChipAreaElem.getBoundingClientRect().left; + lastChipAreaElem.dispatchEvent(rightKeyEvent); + fix.detectChanges(); + + const newLastChipLeft = lastChipAreaElem.getBoundingClientRect().left; + expect(newLastChipLeft).toEqual(lastChipLeft); + }); + + it('should delete chip when delete key is pressed and chip is removable', () => { + let chipComponents = fix.debugElement.queryAll(By.directive(IgxChipComponent)); + + expect(chipComponents.length).toEqual(4); + + const firstChipComp = chipComponents[0].componentInstance; + firstChipComp.onChipKeyDown(deleteKeyEvent); + fix.detectChanges(); + + chipComponents = fix.debugElement.queryAll(By.directive(IgxChipComponent)); + expect(chipComponents.length).toEqual(3); + }); + + it('should delete chip when delete button is clicked', () => { + let chipComponents = fix.debugElement.queryAll(By.directive(IgxChipComponent)); + expect(chipComponents.length).toEqual(4); + + const deleteButtonElement = fix.debugElement.queryAll(By.css('.' + CHIP_REMOVE_BUTTON))[0]; + deleteButtonElement.nativeElement.click(); + fix.detectChanges(); + + chipComponents = fix.debugElement.queryAll(By.directive(IgxChipComponent)); + expect(chipComponents.length).toEqual(3); + }); + + it('should not fire any event of the chip area when attempting deleting of a chip', () => { + chipArea = fix.componentInstance.chipsArea; + const secondChip = fix.componentInstance.chips.toArray()[1]; + + spyOn(chipArea.reorder, 'emit'); + spyOn(chipArea.selectionChange, 'emit'); + spyOn(chipArea.moveStart, 'emit'); + spyOn(chipArea.moveEnd, 'emit'); + spyOn(secondChip.remove, 'emit'); + + secondChip.onChipKeyDown(deleteKeyEvent); + fix.detectChanges(); + + expect(secondChip.remove.emit).toHaveBeenCalled(); + expect(chipArea.reorder.emit).not.toHaveBeenCalled(); + expect(chipArea.selectionChange.emit).not.toHaveBeenCalled(); + expect(chipArea.moveStart.emit).not.toHaveBeenCalled(); + expect(chipArea.moveEnd.emit).not.toHaveBeenCalled(); + }); + }); + + describe('Interaction', () => { + it('should not be able to drag and drop when chip is not draggable', () => { + fix = TestBed.createComponent(TestChipComponent); + fix.detectChanges(); + + chipAreaElement = fix.debugElement.query(By.directive(IgxChipsAreaComponent)); + const chipComponents = chipAreaElement.queryAll(By.directive(IgxChipComponent)); + const firstChip = chipComponents[0].componentInstance; + + UIInteractions.moveDragDirective(fix, firstChip.dragDirective, 50, 50, false); + + expect(firstChip.dragDirective.ghostElement).toBeUndefined(); + }); + + it('should be able to drag when chip is draggable', () => { + fix = TestBed.createComponent(TestChipComponent); + fix.detectChanges(); + + chipAreaElement = fix.debugElement.query(By.directive(IgxChipsAreaComponent)); + const chipComponents = chipAreaElement.queryAll(By.directive(IgxChipComponent)); + const secondChip = chipComponents[1].componentInstance; + const secondChipElem = secondChip.chipArea.nativeElement; + + const startingTop = secondChipElem.getBoundingClientRect().top; + const startingLeft = secondChipElem.getBoundingClientRect().left; + + const xDragDifference = 200; + const yDragDifference = 100; + UIInteractions.moveDragDirective(fix, secondChip.dragDirective, xDragDifference, yDragDifference, false); + + expect(secondChip.dragDirective.ghostElement).toBeTruthy(); + + const afterDragTop = secondChip.dragDirective.ghostElement.getBoundingClientRect().top; + const afterDragLeft = secondChip.dragDirective.ghostElement.getBoundingClientRect().left; + expect(afterDragTop - startingTop).toEqual(yDragDifference); + expect(afterDragLeft - startingLeft).toEqual(xDragDifference); + }); + + it('should fire correctly reorder event when element is dragged and dropped to the right', () => { + fix = TestBed.createComponent(TestChipReorderComponent); + fix.detectChanges(); + + chipAreaElement = fix.debugElement.query(By.directive(IgxChipsAreaComponent)); + + const chipComponents = chipAreaElement.queryAll(By.directive(IgxChipComponent)); + const firstChip = chipComponents[0].componentInstance; + const secondChip = chipComponents[1].componentInstance; + + firstChip.animateOnRelease = false; + secondChip.animateOnRelease = false; + + const firstChipElem = firstChip.chipArea.nativeElement; + const secondChipElem = secondChip.chipArea.nativeElement; + + const firstChipLeft = firstChipElem.getBoundingClientRect().left; + UIInteractions.moveDragDirective(fix, firstChip.dragDirective, 100, 0); + + const afterDropSecondChipLeft = secondChipElem.getBoundingClientRect().left; + expect(afterDropSecondChipLeft).toEqual(firstChipLeft); + + const afterDropFirstChipLeft = firstChipElem.getBoundingClientRect().left; + expect(afterDropFirstChipLeft).not.toEqual(firstChipLeft); + }); + + it('should fire correctly reorder event when element is dragged and dropped to the left', () => { + fix = TestBed.createComponent(TestChipReorderComponent); + fix.detectChanges(); + + chipAreaElement = fix.debugElement.query(By.directive(IgxChipsAreaComponent)); + const chipComponents = chipAreaElement.queryAll(By.directive(IgxChipComponent)); + const firstChip = chipComponents[0].componentInstance; + const secondChip = chipComponents[1].componentInstance; + + firstChip.animateOnRelease = false; + secondChip.animateOnRelease = false; + + const firstChipElem = firstChip.chipArea.nativeElement; + const secondChipElem = secondChip.chipArea.nativeElement; + + const firstChipLeft = firstChipElem.getBoundingClientRect().left; + UIInteractions.moveDragDirective(fix, secondChip.dragDirective, -100, 0); + + const afterDropSecondChipLeft = secondChipElem.getBoundingClientRect().left; + expect(afterDropSecondChipLeft).toEqual(firstChipLeft); + + const afterDropFirstChipLeft = firstChipElem.getBoundingClientRect().left; + expect(afterDropFirstChipLeft).not.toEqual(firstChipLeft); + }); + + it('should fire chipClick event', () => { + fix = TestBed.createComponent(TestChipComponent); + fix.detectChanges(); + + const firstChipComp: IgxChipComponent = fix.componentInstance.chips.toArray()[1]; + spyOn(firstChipComp.chipClick, 'emit'); + + UIInteractions.clickDragDirective(fix, firstChipComp.dragDirective); + + expect(firstChipComp.chipClick.emit).toHaveBeenCalled(); + }); + }); +}); diff --git a/projects/igniteui-angular/chips/src/chips/chips.module.ts b/projects/igniteui-angular/chips/src/chips/chips.module.ts new file mode 100644 index 00000000000..88e0c339e9a --- /dev/null +++ b/projects/igniteui-angular/chips/src/chips/chips.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { IGX_CHIPS_DIRECTIVES } from './public_api'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + exports: [ + ...IGX_CHIPS_DIRECTIVES + ], + imports: [ + ...IGX_CHIPS_DIRECTIVES + ] +}) +export class IgxChipsModule { } diff --git a/projects/igniteui-angular/chips/src/chips/public_api.ts b/projects/igniteui-angular/chips/src/chips/public_api.ts new file mode 100644 index 00000000000..c09118da6ae --- /dev/null +++ b/projects/igniteui-angular/chips/src/chips/public_api.ts @@ -0,0 +1,14 @@ +import { IgxPrefixDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; +import { IgxChipComponent } from './chip.component'; +import { IgxChipsAreaComponent } from './chips-area.component'; + +export * from './chip.component'; +export * from './chips-area.component'; + +/* NOTE: Chips directives collection for ease-of-use import in standalone components scenario */ +export const IGX_CHIPS_DIRECTIVES = [ + IgxChipsAreaComponent, + IgxChipComponent, + IgxPrefixDirective, + IgxSuffixDirective +] as const; diff --git a/projects/igniteui-angular/chips/src/public_api.ts b/projects/igniteui-angular/chips/src/public_api.ts new file mode 100644 index 00000000000..5e4fa766e79 --- /dev/null +++ b/projects/igniteui-angular/chips/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './chips/public_api'; +export * from './chips/chips.module'; diff --git a/projects/igniteui-angular/combo/README.md b/projects/igniteui-angular/combo/README.md new file mode 100644 index 00000000000..d98765fcc4c --- /dev/null +++ b/projects/igniteui-angular/combo/README.md @@ -0,0 +1,354 @@ +# igx-combo +The igx-combo component provides a powerful input, combining the features of the basic HTML input, select and the IgniteUI for Angular igx-drop-down components. +The combo component provides easy filtering and selection of multiple items, grouping and adding custom values to the dropdown list. +Custom templates could be provided in order to customize different areas of the components, such as items, header, footer, etc. +The combo component is integrated with the Template Driven and Reactive Forms. +The igx-combo exposes intuitive keyboard navigation and it is accessibility compliant. +Drop Down items are virtualized, which guarantees smooth work, even if the igx-combo is bound to data source with a lot of items. + + +`igx-combo` is a component. +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/combo.html) + +# Usage +Basic usage of `igx-combo` bound to a local data source, defining `valueKey` and `displayKey`: + +```html + +``` + +Remote binding, defining `valueKey` and `displayKey`, and exposing `dataPreLoad` that allows to load new chunk of remote data to the combo (see the sample above as a reference): + +```html + +``` + +```typescript +public ngOnInit(): void { + this.remoteData = this.remoteService.remoteData; +} + +public ngAfterViewInit(): void { + this.remoteService.getData(this.combo.virtualizationState, (data) => { + this.combo.totalItemCount = data.length; + }); +} + +public dataLoading(evt): void { + if (this.prevRequest) { + this.prevRequest.unsubscribe(); + } + + this.prevRequest = this.remoteService.getData(this.combo.virtualizationState, () => { + this.cdr.detectChanges(); + this.combo.triggerCheck(); + }); + } +``` + +> Note: In order to have combo with remote data, what you need is to have a service that retrieves data chunks from a server. +What the combo exposes is a `virtualizationState` property that gives state of the combo - first index and the number of items that needs to be loaded. +The service, should inform the combo for the total items that are on the server - using the `totalItemCount` property. + + +## Features + +### Selection + +Combo selection depends on the `[valueKey]` input property: + +- If a `[valueKey]` is specified, **all** methods and events tied to the selection operate w/ the value key property of the combo's `[data]` items: +```html + +``` +```typescript +export class MyCombo { + ... + public combo: IgxComboComponent; + public myCustomData: { id: number, text: string } = [{ id: 0, name: "One" }, ...]; + ... + public ngOnInit(): void { + // Selection is done only by valueKey property value + this.combo.select([0, 1]); + } +} +``` + +- When **no** `valueKey` is specified, selection is handled by **equality (===)**. To select items by object reference, the `valueKey` property should be removed: +```html + +``` +```typescript +export class MyCombo { + public ngOnInit(): void { + this.combo.select([this.data[0], this.data[1]]); + } +} +``` + +### Value Binding + +If we want to use a two-way data-binding, we could just use `ngModel` like this: + +```html + +``` +```typescript +export class MyExampleComponent { + ... + public data: {text: string, id: number, ... }[] = ...; + ... + public values: number[] = ...; +} +``` + +When the `data` input is made up of complex types (i.e. objects), it is advised to bind the selected data via `valueKey` (as in the above code snippet). Specify a property that is unique for each data entry and pass an array with values for those properties, corresponding to the items you want selected. + +If you want to bind the selected data by reference, **do not** specify a `valueKey`: + +```html + +``` +```typescript +export class MyExampleComponent { + ... + public data: {text: string, id: number, ... }[] = ...; + ... + public values: {text: string, id: number, ...} [] = [this.items[0], this.items[5]]; +} +``` + +
+ +### Filtering +By default filtering in the combo is enabled. However you can disable it using the following code: + +```html + +``` + +You can enable search case sensitivity by setting the `showSearchCaseIcon` property to true + +```html + +``` + +
+ +
+ +### Custom Values +Enabling the custom values will add missing from the list, using the combo's interface. + +```html + +``` + +
+ +### Disabled +You can disable combo using the following code: + +```html + +``` + +
+ +### Grouping +Defining a combo's groupKey option will group the items, according to that key. + +```html + +``` + +
+ +### Templates +Templates for different parts of the control can be defined, including items, header and footer, etc. +When defining one of them, you need to reference list of predefined names, as follows: + +#### Defining item template: +```html + + +
+ State: {{ display[key] }} + Region: {{ display.region }} +
+
+
+``` + +#### Defining group headers template: + +```html + + +
+ Header for {{ headerItem[key] }} +
+
+
+``` + +#### Defining header template: + +```html + + +
Custom header
+ +
+
+``` + +#### Defining footer template: + +```html + + + + + + +``` + +#### Defining empty template: + +```html + + + List is empty + + +``` + +#### Defining add template: + +```html + + + Add town + + +``` + +#### Defining toggle icon template: + +```html + + + {{ collapsed ? 'remove_circle' : 'remove_circle_outline'}} + + +``` + +#### Defining toggle icon template: + +```html + + + clear + + +``` + +
+ +## Keyboard Navigation + +When igxCombo is closed and focused: +- `ArrowDown` or `Alt` + `ArrowDown` will open the combo drop down and will move focus to the search input. + +When igxCombo is opened and search input is focused: +- `ArrowUp` or `Alt` + `ArrowUp` will close the combo drop down and will move focus to the closed combo. +- `ArrowDown` will move focus from the search input to the first list item.If list is empty and custom values are enabled will move it to the Add new item button. + > Note: Any other key stroke will be handled by the input. + +When igxCombo is opened and list item is focused: +- `ArrowDown` will move to next list item. If the active item is the last one in the list and custom values are enabled then focus will be moved to the Add item button. + +- `ArrowUp` will move to previous list item. If the active item is the first one in the list then focus will be moved back to the search input. + +- `End` will move to last list item. + +- `Home` will move to first list item. + +- `Space` will select/deselect active list item. + +- `Enter` will confirm the already selected items and will close the list. + +- `Esc` will close the list. + +When igxCombo is opened, allow custom values are enabled and add item button is focused: + +- `Enter` will add new item with valueKey and displayKey equal to the text in the search input and will select the new item. + +- `ArrowUp` focus will be moved back to the last list item or if list is empty will be moved to the search input. + +## API + +### Inputs + +| Name | Description | Type | +|-----------------------|---------------------------------------------------|-----------------------------| +| `id` | combo id | string | +| `data` | combo data source | any[] | +| `allowCustomValue` | enables/disables combo custom value | boolean | +| `filterable` | enables/disables combo drop down filtering - enabled by default | boolean | +| `showSearchCaseIcon` | defines whether the search case-sensitive icon should be displayed - disabled by default | boolean | +| `valueKey` | combo value data source property | string | +| `displayKey` | combo display data source property | string | +| `groupKey` | combo item group | string | +| `virtualizationState` | defines the current state of the virtualized data. It contains `startIndex` and `chunkSize` | `IForOfState` | +| `totalItemCount` | total count of the virtual data items, when using remote service | number | +| `width ` | defines combo width | string | +| `itemsMaxHeight ` | defines drop down maximum height | number | +| `itemsWidth ` | defines drop down width | string | +| `itemHeight ` | defines drop down item height | number | +| `placeholder ` | defines the "empty value" text | string | +| `searchPlaceholder ` | defines the placeholder text for search input | string | +| `collapsed` | gets drop down state | boolean | +| `disabled` | defines whether the control is active or not | boolean | +| `ariaLabelledBy` | defines label ID related to combo | boolean | +| `type` | Combo style. - "line", "box", "border", "search" | string | +| `valid` | gets if control is valid, when used in a form | boolean | +| `overlaySettings` | gets/sets the custom overlay settings that control how the drop-down list displays | OverlaySettings | +| `autoFocusSearch` | controls whether the search input should be focused when the combo is opened | boolean | +| `filteringOptions` | Configures the way combo items will be filtered | IComboFilteringOptions | +| `filterFunction` | Gets/Sets the custom filtering function of the combo | `(collection: any[], searchValue: any, caseSensitive: boolean) => any[]` | + +### Getters +| Name | Description | Type | +|--------------------------|---------------------------------------------------|-----------------------------| +| `displayValue` | the value of the combo text field | string | +| `value` | the value of the combo | any[] | +| `selection` | the selected items of the combo | any[] | + +### Outputs + +| Name | Description | Cancelable | Emitted with | +|---------------------|-------------------------------------------------------------------------|--------------|-----------------------------------| +| `selectionChanging` | Emitted when item selection is changing, before the selection completes | true | `IComboSelectionChangingEventArgs` | +| `searchInputUpdate` | Emitted when an the search input's input event is triggered | true | `IComboSearchInputEventArgs` | +| `addition` | Emitted when an item is being added to the data collection | true | `IComboItemAdditionEvent` | +| `dataPreLoad` | Emitted when new chunk of data is loaded from the virtualization | false | `IForOfState` | +| `opening` | Emitted before the dropdown is opened | false | `IBaseCancelableBrowserEventArgs` | +| `opened` | Emitted after the dropdown is opened | false | `IBaseEventArgs` | +| `closing` | Emitted before the dropdown is closed | false | `IBaseCancelableBrowserEventArgs` | +| `closed` | Emitted after the dropdown is closed | false | `IBaseEventArgs` | + +### Methods + +| Name | Description | Return type | Parameters | +|--------------------|------------------------------------------|-------------|---------------------------------------------------------------| +| `open` | Opens drop down | `void` | `None` | +| `close` | Closes drop down | `void` | `None` | +| `toggle` | Toggles drop down | `void` | `None` | +| `selectedItems` | Get current selection state | `any[]` | `None` | +| `select` | Select defined items | `void` | items: `any[]`, clearCurrentSelection: `boolean` | +| `deselect` | Deselect defined items | `void` | items: `any[]` | +| `selectAllItems` | Select all (filtered) items | `void` | ignoreFilter?: `boolean` - if `true` selects **all** values | +| `deselectAllItems` | Deselect (filtered) all items | `void` | ignoreFilter?: `boolean` - if `true` deselects **all** values | +| `selected` | Toggles (select/deselect) an item by key | `void` | itemID: any, select = true, event?: Event | diff --git a/projects/igniteui-angular/combo/index.ts b/projects/igniteui-angular/combo/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/combo/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/combo/ng-package.json b/projects/igniteui-angular/combo/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/combo/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/combo/src/combo/combo-add-item.component.ts b/projects/igniteui-angular/combo/src/combo/combo-add-item.component.ts new file mode 100644 index 00000000000..cad1b5cf927 --- /dev/null +++ b/projects/igniteui-angular/combo/src/combo/combo-add-item.component.ts @@ -0,0 +1,28 @@ +import { IgxComboItemComponent } from './combo-item.component'; +import { Component, HostBinding } from '@angular/core'; + +/** + * @hidden + */ +@Component({ + selector: 'igx-combo-add-item', + template: '', + providers: [{ provide: IgxComboItemComponent, useExisting: IgxComboAddItemComponent }], +}) +export class IgxComboAddItemComponent extends IgxComboItemComponent { + @HostBinding('class.igx-drop-down__item') + public get isDropDownItem(): boolean { + return false; + } + + public override get selected(): boolean { + return false; + } + public override set selected(value: boolean) { + } + + public override clicked(event?) {// eslint-disable-line + this.comboAPI.disableTransitions = false; + this.comboAPI.add_custom_item(); + } +} diff --git a/projects/igniteui-angular/combo/src/combo/combo-dropdown.component.ts b/projects/igniteui-angular/combo/src/combo/combo-dropdown.component.ts new file mode 100644 index 00000000000..8cdbaf022ae --- /dev/null +++ b/projects/igniteui-angular/combo/src/combo/combo-dropdown.component.ts @@ -0,0 +1,219 @@ +import { Component, QueryList, OnDestroy, AfterViewInit, ContentChildren, Input, booleanAttribute, inject } from '@angular/core'; +import { IgxComboBase, IGX_COMBO_COMPONENT } from './combo.common'; +import { IgxComboAddItemComponent } from './combo-add-item.component'; +import { IgxComboAPIService } from './combo.api'; +import { IgxComboItemComponent } from './combo-item.component'; +import { IgxToggleDirective } from 'igniteui-angular/directives'; +import { DropDownActionKey, IDropDownBase, IGX_DROPDOWN_BASE, IgxDropDownComponent, IgxDropDownItemBaseDirective } from 'igniteui-angular/drop-down'; + +/** @hidden */ +@Component({ + selector: 'igx-combo-drop-down', + templateUrl: '../../../drop-down/src/drop-down/drop-down.component.html', + providers: [{ provide: IGX_DROPDOWN_BASE, useExisting: IgxComboDropDownComponent }], + imports: [IgxToggleDirective] +}) +export class IgxComboDropDownComponent extends IgxDropDownComponent implements IDropDownBase, OnDestroy, AfterViewInit { + public combo = inject(IGX_COMBO_COMPONENT); + protected comboAPI = inject(IgxComboAPIService); + + /** @hidden @internal */ + @Input({ transform: booleanAttribute }) + public singleMode = false; + + /** + * @hidden + * @internal + */ + @ContentChildren(IgxComboItemComponent, { descendants: true }) + public override children: QueryList = null; + + /** @hidden @internal */ + public override get scrollContainer(): HTMLElement { + // TODO: Update, use public API if possible: + return this.virtDir.dc.location.nativeElement; + } + + protected get isScrolledToLast(): boolean { + const scrollTop = this.virtDir.scrollPosition; + const scrollHeight = this.virtDir.getScroll().scrollHeight; + return Math.floor(scrollTop + this.virtDir.igxForContainerSize) === scrollHeight; + } + + protected get lastVisibleIndex(): number { + return this.combo.totalItemCount ? + Math.floor(this.combo.itemsMaxHeight / this.combo.itemHeight) : + this.items.length - 1; + } + + protected get sortedChildren(): IgxDropDownItemBaseDirective[] { + if (this.children !== undefined) { + return this.children.toArray() + .sort((a: IgxDropDownItemBaseDirective, b: IgxDropDownItemBaseDirective) => a.index - b.index); + } + return null; + } + + /** + * Get all non-header items + * + * ```typescript + * let myDropDownItems = this.dropdown.items; + * ``` + */ + public override get items(): IgxComboItemComponent[] { + const items: IgxComboItemComponent[] = []; + if (this.children !== undefined) { + const sortedChildren = this.sortedChildren as IgxComboItemComponent[]; + for (const child of sortedChildren) { + if (!child.isHeader) { + items.push(child); + } + } + } + + return items; + } + + /** + * @hidden @internal + */ + public onFocus() { + this.focusedItem = this._focusedItem || this.items[0]; + this.combo.setActiveDescendant(); + } + + /** + * @hidden @internal + */ + public onBlur(_evt?) { + this.focusedItem = null; + this.combo.setActiveDescendant(); + } + + /** + * @hidden @internal + */ + public override onToggleOpened() { + this.opened.emit(); + } + + /** + * @hidden + */ + public override navigateFirst() { + this.navigateItem(this.virtDir.igxForOf.findIndex(e => !e?.isHeader)); + this.combo.setActiveDescendant(); + } + + /** + * @hidden + */ + public override navigatePrev() { + if (this._focusedItem && this._focusedItem.index === 0 && this.virtDir.state.startIndex === 0) { + this.combo.focusSearchInput(false); + this.focusedItem = null; + } else { + super.navigatePrev(); + } + this.combo.setActiveDescendant(); + } + + + /** + * @hidden + */ + public override navigateNext() { + const lastIndex = this.combo.totalItemCount ? this.combo.totalItemCount - 1 : this.virtDir.igxForOf.length - 1; + if (this._focusedItem && this._focusedItem.index === lastIndex) { + this.focusAddItemButton(); + } else { + super.navigateNext(); + } + this.combo.setActiveDescendant(); + } + + /** + * @hidden @internal + */ + public override selectItem(item: IgxDropDownItemBaseDirective) { + if (item === null || item === undefined) { + return; + } + this.comboAPI.set_selected_item(item.itemID); + this._focusedItem = item; + this.combo.setActiveDescendant(); + } + + /** + * @hidden @internal + */ + public override updateScrollPosition() { + this.virtDir.getScroll().scrollTop = this._scrollPosition; + } + + /** + * @hidden @internal + */ + public override onItemActionKey(key: DropDownActionKey) { + switch (key) { + case DropDownActionKey.ENTER: + this.handleEnter(); + break; + case DropDownActionKey.SPACE: + this.handleSpace(); + break; + case DropDownActionKey.ESCAPE: + case DropDownActionKey.TAB: + this.close(); + } + } + + public override ngAfterViewInit() { + this.virtDir.getScroll().addEventListener('scroll', this.scrollHandler); + } + + /** + * @hidden @internal + */ + public override ngOnDestroy(): void { + this.virtDir.getScroll().removeEventListener('scroll', this.scrollHandler); + super.ngOnDestroy(); + } + + protected override scrollToHiddenItem(_newItem: any): void { } + + protected scrollHandler = () => { + this.comboAPI.disableTransitions = true; + }; + + private handleEnter() { + if (this.isAddItemFocused()) { + this.combo.addItemToCollection(); + return; + } + if (this.singleMode && this.focusedItem) { + this.combo.select(this.focusedItem.itemID); + } + + this.close(); + } + + private handleSpace() { + if (this.isAddItemFocused()) { + return; + } else { + this.selectItem(this.focusedItem); + } + } + + private isAddItemFocused(): boolean { + return this.focusedItem instanceof IgxComboAddItemComponent; + } + + private focusAddItemButton() { + if (this.combo.isAddButtonVisible()) { + this.focusedItem = this.items[this.items.length - 1]; + } + } +} diff --git a/projects/igniteui-angular/combo/src/combo/combo-item.component.html b/projects/igniteui-angular/combo/src/combo/combo-item.component.html new file mode 100644 index 00000000000..b467360ae0d --- /dev/null +++ b/projects/igniteui-angular/combo/src/combo/combo-item.component.html @@ -0,0 +1,5 @@ +@if (!isHeader && !singleMode) { + + +} + diff --git a/projects/igniteui-angular/combo/src/combo/combo-item.component.ts b/projects/igniteui-angular/combo/src/combo/combo-item.component.ts new file mode 100644 index 00000000000..bd6add67e1d --- /dev/null +++ b/projects/igniteui-angular/combo/src/combo/combo-item.component.ts @@ -0,0 +1,120 @@ +import { + Component, + HostBinding, + Input, + booleanAttribute, + inject +} from '@angular/core'; +import { IgxComboAPIService } from './combo.api'; +import { rem } from 'igniteui-angular/core'; +import { IgxCheckboxComponent } from 'igniteui-angular/checkbox'; +import { IgxDropDownItemComponent, Navigate } from 'igniteui-angular/drop-down'; + +/** @hidden */ +@Component({ + selector: 'igx-combo-item', + templateUrl: 'combo-item.component.html', + imports: [IgxCheckboxComponent] +}) +export class IgxComboItemComponent extends IgxDropDownItemComponent { + protected comboAPI = inject(IgxComboAPIService); + + + /** + * Gets the height of a list item + * + * @hidden + */ + @Input() + public itemHeight: string | number = ''; + + /** @hidden @internal */ + @HostBinding('style.height.rem') + public get _itemHeightToRem() { + if (this.itemHeight) { + return rem(this.itemHeight); + } + } + + @HostBinding('attr.aria-label') + @Input() + public override get ariaLabel(): string { + const valueKey = this.comboAPI.valueKey; + return (valueKey !== null && this.value != null) ? this.value[valueKey] : this.value; + } + + /** @hidden @internal */ + @Input({ transform: booleanAttribute }) + public singleMode: boolean; + + /** + * @hidden + */ + public override get itemID() { + const valueKey = this.comboAPI.valueKey; + return valueKey !== null ? this.value[valueKey] : this.value; + } + + /** + * @hidden + */ + public get comboID() { + return this.comboAPI.comboID; + } + + /** + * @hidden + * @internal + */ + public get disableTransitions() { + return this.comboAPI.disableTransitions; + } + + /** + * @hidden + */ + public override get selected(): boolean { + return this.comboAPI.is_item_selected(this.itemID); + } + + public override set selected(value: boolean) { + if (this.isHeader) { + return; + } + this._selected = value; + } + + /** + * @hidden + */ + public isVisible(direction: Navigate): boolean { + const rect = this.element.nativeElement.getBoundingClientRect(); + const parentDiv = this.element.nativeElement.parentElement.parentElement.getBoundingClientRect(); + if (direction === Navigate.Down) { + return rect.y + rect.height <= parentDiv.y + parentDiv.height; + } + return rect.y >= parentDiv.y; + } + + public override clicked(event): void { + this.comboAPI.disableTransitions = false; + if (!this.isSelectable) { + return; + } + this.dropDown.navigateItem(this.index); + this.comboAPI.set_selected_item(this.itemID, event); + } + + /** + * @hidden + * @internal + * The event that is prevented is the click on the checkbox label element. + * That is the only visible element that a user can interact with. + * The click propagates to the host and the preventDefault is to stop it from + * switching focus to the input it's base on. + * The toggle happens in an internal handler in the drop-down on the next task queue cycle. + */ + public disableCheck(event: MouseEvent) { + event.preventDefault(); + } +} diff --git a/projects/igniteui-angular/combo/src/combo/combo.api.ts b/projects/igniteui-angular/combo/src/combo/combo.api.ts new file mode 100644 index 00000000000..bf48875c880 --- /dev/null +++ b/projects/igniteui-angular/combo/src/combo/combo.api.ts @@ -0,0 +1,57 @@ +import { IgxComboBase } from './combo.common'; +import { Injectable } from '@angular/core'; + +/** + * @hidden + */ +@Injectable() +export class IgxComboAPIService { + public disableTransitions = false; + protected combo: IgxComboBase; + + public get valueKey() { + return this.combo.valueKey !== null && this.combo.valueKey !== undefined ? this.combo.valueKey : null; + } + + public get item_focusable(): boolean { + return false; + } + public get isRemote(): boolean { + return this.combo.isRemote; + } + + public get comboID(): string { + return this.combo.id; + } + + public register(combo: IgxComboBase) { + this.combo = combo; + } + + public clear(): void { + this.combo = null; + } + + public add_custom_item(): void { + if (!this.combo) { + return; + } + this.combo.addItemToCollection(); + } + + public set_selected_item(itemID: any, event?: Event): void { + const selected = this.combo.isItemSelected(itemID); + if (itemID === undefined) { + return; + } + if (!selected) { + this.combo.select([itemID], false, event); + } else { + this.combo.deselect([itemID], event); + } + } + + public is_item_selected(itemID: any): boolean { + return this.combo.isItemSelected(itemID); + } +} diff --git a/projects/igniteui-angular/combo/src/combo/combo.common.ts b/projects/igniteui-angular/combo/src/combo/combo.common.ts new file mode 100644 index 00000000000..31b8db9df3e --- /dev/null +++ b/projects/igniteui-angular/combo/src/combo/combo.common.ts @@ -0,0 +1,1417 @@ +import { + AfterContentChecked, + AfterViewChecked, + AfterViewInit, + booleanAttribute, + ChangeDetectorRef, + ContentChild, + ContentChildren, + Directive, + ElementRef, + EventEmitter, + forwardRef, + HostBinding, + InjectionToken, + Injector, + Input, + OnDestroy, + OnInit, + Output, + QueryList, + TemplateRef, + ViewChild, + DOCUMENT, + ViewChildren, + inject +} from '@angular/core'; +import { AbstractControl, ControlValueAccessor, NgControl } from '@angular/forms'; +import { caseSensitive } from '@igniteui/material-icons-extended'; +import { noop, Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { + IgxSelectionAPIService, + SortingDirection, + CancelableBrowserEventArgs, + cloneArray, + IBaseCancelableBrowserEventArgs, + IBaseEventArgs, + rem, + AbsoluteScrollStrategy, + AutoPositionStrategy, + OverlaySettings, + ComboResourceStringsEN, + IComboResourceStrings, + getCurrentResourceStrings +} from 'igniteui-angular/core'; +import { IForOfState, IgxForOfDirective } from 'igniteui-angular/directives'; +import { IgxIconService } from 'igniteui-angular/icon'; +import { IGX_INPUT_GROUP_TYPE, IgxInputDirective, IgxInputGroupComponent, IgxInputGroupType, IgxInputState, IgxLabelDirective, IgxPrefixDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; +import { IgxComboDropDownComponent } from './combo-dropdown.component'; +import { IgxComboAPIService } from './combo.api'; +import { + IgxComboAddItemDirective, IgxComboClearIconDirective, IgxComboEmptyDirective, + IgxComboFooterDirective, IgxComboHeaderDirective, IgxComboHeaderItemDirective, IgxComboItemDirective, IgxComboToggleIconDirective +} from './combo.directives'; +import { isEqual } from 'lodash-es'; +import { IComboItemAdditionEvent, IComboSearchInputEventArgs } from './combo.component'; + +export const IGX_COMBO_COMPONENT = /*@__PURE__*/new InjectionToken('IgxComboComponentToken'); + +/** @hidden @internal TODO: Evaluate */ +export interface IgxComboBase { + id: string; + data: any[] | null; + valueKey: string; + groupKey: string; + isRemote: boolean; + filteredData: any[] | null; + totalItemCount: number; + itemsMaxHeight: number; + itemHeight: number; + searchValue: string; + searchInput: ElementRef; + comboInput: ElementRef; + opened: EventEmitter; + opening: EventEmitter; + closing: EventEmitter; + closed: EventEmitter; + focusSearchInput(opening?: boolean): void; + triggerCheck(): void; + addItemToCollection(): void; + isAddButtonVisible(): boolean; + handleInputChange(event?: string): void; + isItemSelected(itemID: any): boolean; + select(item: any): void; + select(itemIDs: any[], clearSelection?: boolean, event?: Event): void; + deselect(...args: [] | [itemIDs: any[], event?: Event]): void; + setActiveDescendant(): void; +} + +let NEXT_ID = 0; + + +/** @hidden @internal */ +export const enum DataTypes { + EMPTY = 'empty', + PRIMITIVE = 'primitive', + COMPLEX = 'complex', + PRIMARYKEY = 'valueKey' +} + +/** The filtering criteria to be applied on data search */ +export interface IComboFilteringOptions { + /** Defines filtering case-sensitivity */ + caseSensitive?: boolean; + /** Defines optional key to filter against complex list items. Default to displayKey if provided.*/ + filteringKey?: string; +} + +@Directive() +export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewChecked, OnInit, + AfterViewInit, AfterContentChecked, OnDestroy, ControlValueAccessor { + protected elementRef = inject(ElementRef); + protected cdr = inject(ChangeDetectorRef); + protected selectionService = inject(IgxSelectionAPIService); + protected comboAPI = inject(IgxComboAPIService); + public document = inject(DOCUMENT); + protected _inputGroupType = inject(IGX_INPUT_GROUP_TYPE, { optional: true }); + protected _injector = inject(Injector, { optional: true }); + protected _iconService = inject(IgxIconService, { optional: true }); + + /** + * Defines whether the caseSensitive icon should be shown in the search input + * + * ```typescript + * // get + * let myComboShowSearchCaseIcon = this.combo.showSearchCaseIcon; + * ``` + * + * ```html + * + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public showSearchCaseIcon = false; + + /** + * Enables/disables filtering in the list. The default is `false`. + */ + @Input({ transform: booleanAttribute }) + public get disableFiltering(): boolean { + return this._disableFiltering; + } + public set disableFiltering(value: boolean) { + this._disableFiltering = value; + } + + /** + * Set custom overlay settings that control how the combo's list of items is displayed. + * Set: + * ```html + * + * ``` + * + * ```typescript + * const customSettings = { positionStrategy: { settings: { target: myTarget } } }; + * combo.overlaySettings = customSettings; + * ``` + * Get any custom overlay settings used by the combo: + * ```typescript + * const comboOverlaySettings: OverlaySettings = myCombo.overlaySettings; + * ``` + */ + @Input() + public overlaySettings: OverlaySettings = null; + + /** + * Gets/gets combo id. + * + * ```typescript + * // get + * let id = this.combo.id; + * ``` + * + * ```html + * + * + * ``` + */ + @HostBinding('attr.id') + @Input() + public get id(): string { + return this._id; + } + + public set id(value: string) { + if (!value) { + return; + } + const selection = this.selectionService.get(this._id); + this.selectionService.clear(this._id); + this._id = value; + if (selection) { + this.selectionService.set(this._id, selection); + } + } + + /** + * Sets the style width of the element + * + * ```typescript + * // get + * let myComboWidth = this.combo.width; + * ``` + * + * ```html + * + * + * ``` + */ + @HostBinding('style.width') + @Input() + public width: string; + + /** + * Controls whether custom values can be added to the collection + * + * ```typescript + * // get + * let comboAllowsCustomValues = this.combo.allowCustomValues; + * ``` + * + * ```html + * + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public allowCustomValues = false; + + /** + * Configures the drop down list height + * + * ```typescript + * // get + * let myComboItemsMaxHeight = this.combo.itemsMaxHeight; + * ``` + * + * ```html + * + * + * ``` + */ + @Input() + public get itemsMaxHeight(): number { + if (this.itemHeight && !this._itemsMaxHeight) { + return this.itemHeight * this.itemsInContainer; + } + return this._itemsMaxHeight; + } + + public set itemsMaxHeight(val: number) { + this._itemsMaxHeight = val; + } + + /** @hidden */ + public get itemsMaxHeightInRem() { + if (this.itemsMaxHeight) { + return rem(this.itemsMaxHeight); + } + } + + /** + * Configures the drop down list item height + * + * ```typescript + * // get + * let myComboItemHeight = this.combo.itemHeight; + * ``` + * + * ```html + * + * + * ``` + */ + @Input() + public get itemHeight(): number { + return this._itemHeight; + } + + public set itemHeight(val: number) { + this._itemHeight = val; + } + + /** + * Configures the drop down list width + * + * ```typescript + * // get + * let myComboItemsWidth = this.combo.itemsWidth; + * ``` + * + * ```html + * + * + * ``` + */ + @Input() + public itemsWidth: string; + + /** + * Defines the placeholder value for the combo value field + * + * ```typescript + * // get + * let myComboPlaceholder = this.combo.placeholder; + * ``` + * + * ```html + * + * + * ``` + */ + @Input() + public placeholder: string; + + /** + * Combo data source. + * + * ```html + * + * + * ``` + */ + @Input() + public get data(): any[] | null { + return this._data; + } + public set data(val: any[] | null) { + // igxFor directive ignores undefined values + // if the combo uses simple data and filtering is applied + // an error will occur due to the mismatch of the length of the data + // this can occur during filtering for the igx-combo and + // during filtering & selection for the igx-simple-combo + // since the simple combo's input is both a container for the selection and a filter + this._data = (val) ? val.filter(x => x !== undefined) : []; + } + + /** + * Determines which column in the data source is used to determine the value. + * + * ```typescript + * // get + * let myComboValueKey = this.combo.valueKey; + * ``` + * + * ```html + * + * + * ``` + */ + @Input() + public valueKey: string = null; + + @Input() + public set displayKey(val: string) { + this._displayKey = val; + } + + /** + * Determines which column in the data source is used to determine the display value. + * + * ```typescript + * // get + * let myComboDisplayKey = this.combo.displayKey; + * + * // set + * this.combo.displayKey = 'val'; + * + * ``` + * + * ```html + * + * + * ``` + */ + public get displayKey() { + return this._displayKey ? this._displayKey : this.valueKey; + } + + /** + * The item property by which items should be grouped inside the items list. Not usable if data is not of type Object[]. + * + * ```html + * + * + * ``` + */ + @Input() + public set groupKey(val: string) { + this._groupKey = val; + } + + /** + * The item property by which items should be grouped inside the items list. Not usable if data is not of type Object[]. + * + * ```typescript + * // get + * let currentGroupKey = this.combo.groupKey; + * ``` + */ + public get groupKey(): string { + return this._groupKey; + } + + /** + * Sets groups sorting order. + * + * @example + * ```html + * + * ``` + * ```typescript + * public groupSortingDirection = SortingDirection.Asc; + * ``` + */ + @Input() + public get groupSortingDirection(): SortingDirection { + return this._groupSortingDirection; + } + public set groupSortingDirection(val: SortingDirection) { + this._groupSortingDirection = val; + } + + /** + * Gets/Sets the custom filtering function of the combo. + * + * @example + * ```html + * + * ``` + */ + @Input() + public filterFunction: (collection: any[], searchValue: any, filteringOptions: IComboFilteringOptions) => any[]; + + /** + * Sets aria-labelledby attribute value. + * ```html + * + * ``` + */ + @Input() + public ariaLabelledBy: string; + + /** @hidden @internal */ + @HostBinding('class.igx-combo') + public cssClass = 'igx-combo'; // Independent of display density for the time being + + /** + * Disables the combo. The default is `false`. + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public disabled = false; + + /** + * Sets the visual combo type. + * The allowed values are `line`, `box`, `border` and `search`. The default is `box`. + * ```html + * + * ``` + */ + @Input() + public get type(): IgxInputGroupType { + return this._type || this._inputGroupType || 'box'; + } + + public set type(val: IgxInputGroupType) { + this._type = val; + } + + /** + * Gets/Sets the resource strings. + * + * @remarks + * By default it uses EN resources. + */ + @Input() + public get resourceStrings(): IComboResourceStrings { + return this._resourceStrings; + } + public set resourceStrings(value: IComboResourceStrings) { + this._resourceStrings = Object.assign({}, this._resourceStrings, value); + } + + /** + * Emitted before the dropdown is opened + * + * ```html + * + * ``` + */ + @Output() + public opening = new EventEmitter(); + + /** + * Emitted after the dropdown is opened + * + * ```html + * + * ``` + */ + @Output() + public opened = new EventEmitter(); + + /** + * Emitted before the dropdown is closed + * + * ```html + * + * ``` + */ + @Output() + public closing = new EventEmitter(); + + /** + * Emitted after the dropdown is closed + * + * ```html + * + * ``` + */ + @Output() + public closed = new EventEmitter(); + + /** + * Emitted when an item is being added to the data collection + * + * ```html + * + * ``` + */ + @Output() + public addition = new EventEmitter(); + + /** + * Emitted when the value of the search input changes (e.g. typing, pasting, clear, etc.) + * + * ```html + * + * ``` + */ + @Output() + public searchInputUpdate = new EventEmitter(); + + /** + * Emitted when new chunk of data is loaded from the virtualization + * + * ```html + * + * ``` + */ + @Output() + public dataPreLoad = new EventEmitter(); + + /** + * The custom template, if any, that should be used when rendering ITEMS in the combo list + * + * ```typescript + * // Set in typescript + * const myCustomTemplate: TemplateRef = myComponent.customTemplate; + * myComponent.combo.itemTemplate = myCustomTemplate; + * ``` + * ```html + * + * + * ... + * + *
+ *
{{ item[key] }}
+ *
{{ item.cost }}
+ *
+ *
+ *
+ * ``` + */ + @ContentChild(IgxComboItemDirective, { read: TemplateRef }) + public itemTemplate: TemplateRef = null; + + /** + * The custom template, if any, that should be used when rendering the HEADER for the combo items list + * + * ```typescript + * // Set in typescript + * const myCustomTemplate: TemplateRef = myComponent.customTemplate; + * myComponent.combo.headerTemplate = myCustomTemplate; + * ``` + * ```html + * + * + * ... + * + *
+ * This is a custom header + *
+ *
+ *
+ * ``` + */ + @ContentChild(IgxComboHeaderDirective, { read: TemplateRef }) + public headerTemplate: TemplateRef = null; + + /** + * The custom template, if any, that should be used when rendering the FOOTER for the combo items list + * + * ```typescript + * // Set in typescript + * const myCustomTemplate: TemplateRef = myComponent.customTemplate; + * myComponent.combo.footerTemplate = myCustomTemplate; + * ``` + * ```html + * + * + * ... + * + * + * + * + * ``` + */ + @ContentChild(IgxComboFooterDirective, { read: TemplateRef }) + public footerTemplate: TemplateRef = null; + + /** + * The custom template, if any, that should be used when rendering HEADER ITEMS for groups in the combo list + * + * ```typescript + * // Set in typescript + * const myCustomTemplate: TemplateRef = myComponent.customTemplate; + * myComponent.combo.headerItemTemplate = myCustomTemplate; + * ``` + * ```html + * + * + * ... + * + *
Group header for {{ item[key] }}
+ *
+ *
+ * ``` + */ + @ContentChild(IgxComboHeaderItemDirective, { read: TemplateRef }) + public headerItemTemplate: TemplateRef = null; + + /** + * The custom template, if any, that should be used when rendering the ADD BUTTON in the combo drop down + * + * ```typescript + * // Set in typescript + * const myCustomTemplate: TemplateRef = myComponent.customTemplate; + * myComponent.combo.addItemTemplate = myCustomTemplate; + * ``` + * ```html + * + * + * ... + * + * + * + * + * ``` + */ + @ContentChild(IgxComboAddItemDirective, { read: TemplateRef }) + public addItemTemplate: TemplateRef = null; + + /** + * The custom template, if any, that should be used when rendering the ADD BUTTON in the combo drop down + * + * ```typescript + * // Set in typescript + * const myCustomTemplate: TemplateRef = myComponent.customTemplate; + * myComponent.combo.emptyTemplate = myCustomTemplate; + * ``` + * ```html + * + * + * ... + * + *
+ * There are no items to display + *
+ *
+ *
+ * ``` + */ + @ContentChild(IgxComboEmptyDirective, { read: TemplateRef }) + public emptyTemplate: TemplateRef = null; + + /** + * The custom template, if any, that should be used when rendering the combo TOGGLE(open/close) button + * + * ```typescript + * // Set in typescript + * const myCustomTemplate: TemplateRef = myComponent.customTemplate; + * myComponent.combo.toggleIconTemplate = myCustomTemplate; + * ``` + * ```html + * + * + * ... + * + * {{ collapsed ? 'remove_circle' : 'remove_circle_outline'}} + * + * + * ``` + */ + @ContentChild(IgxComboToggleIconDirective, { read: TemplateRef }) + public toggleIconTemplate: TemplateRef = null; + + /** + * The custom template, if any, that should be used when rendering the combo CLEAR button + * + * ```typescript + * // Set in typescript + * const myCustomTemplate: TemplateRef = myComponent.customTemplate; + * myComponent.combo.clearIconTemplate = myCustomTemplate; + * ``` + * ```html + * + * + * ... + * + * clear + * + * + * ``` + */ + @ContentChild(IgxComboClearIconDirective, { read: TemplateRef }) + public clearIconTemplate: TemplateRef = null; + + /** @hidden @internal */ + @ContentChild(forwardRef(() => IgxLabelDirective), { static: true }) public label: IgxLabelDirective; + + /** @hidden @internal */ + @ViewChild('inputGroup', { read: IgxInputGroupComponent, static: true }) + public inputGroup: IgxInputGroupComponent; + + /** @hidden @internal */ + @ViewChild('comboInput', { read: IgxInputDirective, static: true }) + public comboInput: IgxInputDirective; + + /** @hidden @internal */ + @ViewChild('searchInput') + public searchInput: ElementRef = null; + + /** @hidden @internal */ + @ViewChild(IgxForOfDirective, { static: true }) + public virtualScrollContainer: IgxForOfDirective; + + @ViewChild(IgxForOfDirective, { read: IgxForOfDirective, static: true }) + protected virtDir: IgxForOfDirective; + + @ViewChild('dropdownItemContainer', { static: true }) + protected dropdownContainer: ElementRef = null; + + @ViewChild('primitive', { read: TemplateRef, static: true }) + protected primitiveTemplate: TemplateRef; + + @ViewChild('complex', { read: TemplateRef, static: true }) + protected complexTemplate: TemplateRef; + + @ContentChildren(IgxPrefixDirective, { descendants: true }) + protected prefixes: QueryList; + + @ContentChildren(IgxSuffixDirective, { descendants: true }) + protected suffixes: QueryList; + + @ViewChildren(IgxSuffixDirective) + protected internalSuffixes: QueryList; + + /** @hidden @internal */ + public get searchValue(): string { + return this._searchValue; + } + public set searchValue(val: string) { + this.filterValue = val; + this._searchValue = val; + } + + /** @hidden @internal */ + public get isRemote() { + return this.totalItemCount > 0 && + this.valueKey && + this.dataType === DataTypes.COMPLEX; + } + + /** @hidden @internal */ + public get dataType(): string { + if (this.displayKey) { + return DataTypes.COMPLEX; + } + return DataTypes.PRIMITIVE; + } + + /** + * Gets if control is valid, when used in a form + * + * ```typescript + * // get + * let valid = this.combo.valid; + * ``` + */ + public get valid(): IgxInputState { + return this._valid; + } + + /** + * Sets if control is valid, when used in a form + * + * ```typescript + * // set + * this.combo.valid = IgxInputState.INVALID; + * ``` + */ + public set valid(valid: IgxInputState) { + this._valid = valid; + this.comboInput.valid = valid; + } + + /** + * The value of the combo + * + * ```typescript + * // get + * let comboValue = this.combo.value; + * ``` + */ + public get value(): any[] { + return this._value; + } + + /** + * The text displayed in the combo input + * + * ```typescript + * // get + * let comboDisplayValue = this.combo.displayValue; + * ``` + */ + public get displayValue(): string { + return this._displayValue; + } + + /** + * Defines the current state of the virtualized data. It contains `startIndex` and `chunkSize` + * + * ```typescript + * // get + * let state = this.combo.virtualizationState; + * ``` + */ + public get virtualizationState(): IForOfState { + return this.virtDir.state; + } + /** + * Sets the current state of the virtualized data. + * + * ```typescript + * // set + * this.combo.virtualizationState(state); + * ``` + */ + public set virtualizationState(state: IForOfState) { + this.virtDir.state = state; + } + + /** + * Gets drop down state. + * + * ```typescript + * let state = this.combo.collapsed; + * ``` + */ + public get collapsed(): boolean { + return this.dropdown.collapsed; + } + + /** + * Gets total count of the virtual data items, when using remote service. + * + * ```typescript + * // get + * let count = this.combo.totalItemCount; + * ``` + */ + public get totalItemCount(): number { + return this.virtDir.totalItemCount; + } + /** + * Sets total count of the virtual data items, when using remote service. + * + * ```typescript + * // set + * this.combo.totalItemCount(remoteService.count); + * ``` + */ + public set totalItemCount(count: number) { + this.virtDir.totalItemCount = count; + } + + /** @hidden @internal */ + public get template(): TemplateRef { + this._dataType = this.dataType; + if (this.itemTemplate) { + return this.itemTemplate; + } + if (this._dataType === DataTypes.COMPLEX) { + return this.complexTemplate; + } + return this.primitiveTemplate; + } + + /** @hidden @internal */ + public customValueFlag = true; + /** @hidden @internal */ + public filterValue = ''; + /** @hidden @internal */ + public defaultFallbackGroup = 'Other'; + /** @hidden @internal */ + public activeDescendant = ''; + + /** + * Configures the way combo items will be filtered. + * + * ```typescript + * // get + * let myFilteringOptions = this.combo.filteringOptions; + * ``` + * + * ```html + * + * + * ``` + */ + + @Input() + public get filteringOptions(): IComboFilteringOptions { + return this._filteringOptions || this._defaultFilteringOptions; + } + public set filteringOptions(value: IComboFilteringOptions) { + this._filteringOptions = value; + } + + protected containerSize = undefined; + protected itemSize = undefined; + protected _data = []; + protected _value = []; + protected _displayValue = ''; + protected _groupKey = ''; + protected _searchValue = ''; + protected _filteredData = []; + protected _displayKey: string; + protected _remoteSelection = {}; + protected _resourceStrings = getCurrentResourceStrings(ComboResourceStringsEN); + protected _valid = IgxInputState.INITIAL; + protected ngControl: NgControl = null; + protected destroy$ = new Subject(); + protected _onTouchedCallback: () => void = noop; + protected _onChangeCallback: (_: any) => void = noop; + protected compareCollator = new Intl.Collator(); + protected computedStyles; + + private _id: string = `igx-combo-${NEXT_ID++}`; + private _disableFiltering = false; + private _type = null; + private _dataType = ''; + private _itemHeight = undefined; + private _itemsMaxHeight = null; + private _overlaySettings: OverlaySettings; + private _groupSortingDirection: SortingDirection = SortingDirection.Asc; + private _filteringOptions: IComboFilteringOptions; + private _defaultFilteringOptions: IComboFilteringOptions = { caseSensitive: false }; + private itemsInContainer = 10; + + public abstract dropdown: IgxComboDropDownComponent; + public abstract selectionChanging: EventEmitter; + + public ngAfterViewChecked() { + const targetElement = this.inputGroup.element.nativeElement.querySelector('.igx-input-group__bundle') as HTMLElement; + + this._overlaySettings = { + target: targetElement, + scrollStrategy: new AbsoluteScrollStrategy(), + positionStrategy: new AutoPositionStrategy(), + modal: false, + closeOnOutsideClick: true, + excludeFromOutsideClick: [targetElement] + }; + } + + /** @hidden @internal */ + public ngAfterContentChecked(): void { + if (this.inputGroup && this.prefixes?.length > 0) { + this.inputGroup.prefixes = this.prefixes; + } + + if (this.inputGroup) { + const suffixesArray = this.suffixes?.toArray() ?? []; + const internalSuffixesArray = this.internalSuffixes?.toArray() ?? []; + const mergedSuffixes = new QueryList(); + mergedSuffixes.reset([ + ...suffixesArray, + ...internalSuffixesArray + ]); + this.inputGroup.suffixes = mergedSuffixes; + } + } + + /** @hidden @internal */ + public ngOnInit() { + this.ngControl = this._injector.get(NgControl, null); + this.selectionService.set(this.id, new Set()); + this._iconService?.addSvgIconFromText(caseSensitive.name, caseSensitive.value, 'imx-icons'); + this.computedStyles = this.document.defaultView.getComputedStyle(this.elementRef.nativeElement); + } + + /** @hidden @internal */ + public ngAfterViewInit(): void { + this.filteredData = [...this.data]; + if (this.ngControl) { + this.ngControl.statusChanges.pipe(takeUntil(this.destroy$)).subscribe(this.onStatusChanged); + this.manageRequiredAsterisk(); + this.cdr.detectChanges(); + } + this.virtDir.chunkPreload.pipe(takeUntil(this.destroy$)).subscribe((e: IForOfState) => { + const eventArgs: IForOfState = Object.assign({}, e, { owner: this }); + this.dataPreLoad.emit(eventArgs); + }); + this.dropdown?.opening.subscribe((_args: IBaseCancelableBrowserEventArgs) => { + // calculate the container size and item size based on the sizes from the DOM + const dropdownContainerHeight = this.dropdownContainer.nativeElement.getBoundingClientRect().height; + if (dropdownContainerHeight) { + this.containerSize = parseFloat(dropdownContainerHeight); + } + if (this.dropdown.children?.first) { + this.itemSize = this.dropdown.children.first.element.nativeElement.getBoundingClientRect().height; + } + }); + } + + /** @hidden @internal */ + public ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + this.comboAPI.clear(); + this.selectionService.delete(this.id); + } + + /** + * A method that opens/closes the combo. + * + * ```html + * + * + * ``` + */ + public toggle(): void { + if (this.collapsed && this._displayValue.length !== 0) { + this.filterValue = ''; + this.cdr.detectChanges(); + } + const overlaySettings = Object.assign({}, this._overlaySettings, this.overlaySettings); + this.dropdown.toggle(overlaySettings); + if (!this.collapsed) { + this.setActiveDescendant(); + } + } + + /** + * A method that opens the combo. + * + * ```html + * + * + * ``` + */ + public open(): void { + if (this.collapsed && this._displayValue.length !== 0) { + this.filterValue = ''; + this.cdr.detectChanges(); + } + const overlaySettings = Object.assign({}, this._overlaySettings, this.overlaySettings); + this.dropdown.open(overlaySettings); + this.setActiveDescendant(); + } + + /** + * A method that closes the combo. + * + * ```html + * + * + * ``` + */ + public close(): void { + this.dropdown.close(); + } + + /** + * Triggers change detection on the combo view + */ + public triggerCheck() { + this.cdr.detectChanges(); + } + + /** + * Get current selection state + * + * @returns Array of selected items + * ```typescript + * let mySelection = this.combo.selection; + * ``` + */ + public get selection(): any[] { + const serviceRef = this.selectionService.get(this.id); + return serviceRef ? this.convertKeysToItems(Array.from(serviceRef)) : []; + } + + /** + * Returns if the specified itemID is selected + * + * @hidden + * @internal + */ + public isItemSelected(item: any): boolean { + return this.selectionService.is_item_selected(this.id, item); + } + + /** @hidden @internal */ + public get toggleIcon(): string { + return this.dropdown.collapsed ? 'input_expand' : 'input_collapse'; + } + + /** @hidden @internal */ + public addItemToCollection() { + if (!this.searchValue) { + return; + } + const addedItem = this.displayKey ? { + [this.valueKey]: this.searchValue, + [this.displayKey]: this.searchValue + } : this.searchValue; + if (this.groupKey) { + Object.assign(addedItem, { [this.groupKey]: this.defaultFallbackGroup }); + } + // expose shallow copy instead of this.data in event args so this.data can't be mutated + const oldCollection = [...this.data]; + const newCollection = [...this.data, addedItem]; + const args: IComboItemAdditionEvent = { + oldCollection, addedItem, newCollection, owner: this, cancel: false + }; + this.addition.emit(args); + if (args.cancel) { + return; + } + this.data.push(args.addedItem); + // trigger re-render + this.data = cloneArray(this.data); + this.select(this.valueKey !== null && this.valueKey !== undefined ? + [args.addedItem[this.valueKey]] : [args.addedItem], false); + this.customValueFlag = false; + this.searchInput?.nativeElement.focus(); + this.dropdown.focusedItem = null; + this.virtDir.scrollTo(0); + } + + /** @hidden @internal */ + public isAddButtonVisible(): boolean { + // This should always return a boolean value. If this.searchValue was '', it returns '' instead of false; + return this.searchValue !== '' && this.customValueFlag; + } + + /** @hidden @internal */ + public handleInputChange(event?: any) { + if (event !== undefined) { + const args: IComboSearchInputEventArgs = { + searchText: typeof event === 'string' ? event : event.target.value, + owner: this, + cancel: false + }; + this.searchInputUpdate.emit(args); + if (args.cancel) { + this.filterValue = null; + } + } + this.checkMatch(); + } + + /** + * Event handlers + * + * @hidden + * @internal + */ + public handleOpening(e: IBaseCancelableBrowserEventArgs) { + const args: IBaseCancelableBrowserEventArgs = { owner: this, event: e.event, cancel: e.cancel }; + this.opening.emit(args); + e.cancel = args.cancel; + } + + /** @hidden @internal */ + public handleClosing(e: IBaseCancelableBrowserEventArgs) { + const args: IBaseCancelableBrowserEventArgs = { owner: this, event: e.event, cancel: e.cancel }; + this.closing.emit(args); + e.cancel = args.cancel; + if (e.cancel) { + return; + } + this.searchValue = ''; + if (!e.event) { + this.comboInput?.nativeElement.focus(); + } else { + this._onTouchedCallback(); + this.updateValidity(); + } + } + + /** @hidden @internal */ + public handleClosed() { + this.closed.emit({ owner: this }); + } + + /** @hidden @internal */ + public handleKeyDown(event: KeyboardEvent) { + if (event.key === 'ArrowUp' || event.key === 'Up') { + event.preventDefault(); + event.stopPropagation(); + this.close(); + } + } + + /** @hidden @internal */ + public handleToggleKeyDown(eventArgs: KeyboardEvent) { + if (eventArgs.key === 'Enter' || eventArgs.key === ' ') { + eventArgs.preventDefault(); + this.toggle(); + } + } + + /** @hidden @internal */ + public getAriaLabel(): string { + return this.displayValue ? this.resourceStrings.igx_combo_aria_label_options : this.resourceStrings.igx_combo_aria_label_no_options; + } + + + /** @hidden @internal */ + public registerOnChange(fn: any): void { + this._onChangeCallback = fn; + } + + /** @hidden @internal */ + public registerOnTouched(fn: any): void { + this._onTouchedCallback = fn; + } + + /** @hidden @internal */ + public setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + /** @hidden @internal */ + public onClick(event: Event) { + event.stopPropagation(); + event.preventDefault(); + if (!this.disabled) { + this.toggle(); + } + } + + /** @hidden @internal */ + public onBlur() { + if (this.collapsed) { + this._onTouchedCallback(); + this.updateValidity(); + } + } + + /** @hidden @internal */ + public setActiveDescendant(): void { + this.activeDescendant = this.dropdown.focusedItem?.id || ''; + } + + /** @hidden @internal */ + public toggleCaseSensitive() { + this.filteringOptions = Object.assign({}, this.filteringOptions, { caseSensitive: !this.filteringOptions.caseSensitive }); + } + + protected onStatusChanged = () => { + if (this.ngControl && this.isTouchedOrDirty && !this.disabled) { + if (this.hasValidators && (!this.collapsed || this.inputGroup.isFocused)) { + this.valid = this.ngControl.valid ? IgxInputState.VALID : IgxInputState.INVALID; + } else { + this.valid = this.ngControl.valid ? IgxInputState.INITIAL : IgxInputState.INVALID; + } + } else { + // B.P. 18 May 2021: IgxDatePicker does not reset its state upon resetForm #9526 + this.valid = IgxInputState.INITIAL; + } + this.manageRequiredAsterisk(); + }; + + private updateValidity() { + if (this.ngControl && this.ngControl.invalid) { + this.valid = IgxInputState.INVALID; + } else { + this.valid = IgxInputState.INITIAL; + } + } + + private get isTouchedOrDirty(): boolean { + return (this.ngControl.control.touched || this.ngControl.control.dirty); + } + + private get hasValidators(): boolean { + return (!!this.ngControl.control.validator || !!this.ngControl.control.asyncValidator); + } + + /** if there is a valueKey - map the keys to data items, else - just return the keys */ + protected convertKeysToItems(keys: any[]) { + if (this.valueKey === null || this.valueKey === undefined) { + return keys; + } + + return keys.map(key => { + const item = this.data.find(entry => isEqual(entry[this.valueKey], key)); + + return item !== undefined ? item : { [this.valueKey]: key }; + }); + } + + protected checkMatch(): void { + const itemMatch = this.filteredData.some(this.findMatch); + this.customValueFlag = this.allowCustomValues && !itemMatch; + } + + protected findMatch = (element: any): boolean => { + const value = this.displayKey ? element[this.displayKey] : element; + const searchValue = this.searchValue || this.comboInput?.value; + return value?.toString().trim().toLowerCase() === searchValue.trim().toLowerCase(); + }; + + protected manageRequiredAsterisk(): void { + if (this.ngControl) { + this.inputGroup.isRequired = this.required; + } + } + + /** Contains key-value pairs of the selected valueKeys and their resp. displayKeys */ + protected registerRemoteEntries(ids: any[], add = true) { + if (add) { + const selection = this.getValueDisplayPairs(ids); + for (const entry of selection) { + this._remoteSelection[entry[this.valueKey]] = entry[this.displayKey]; + } + } else { + for (const entry of ids) { + delete this._remoteSelection[entry]; + } + } + } + + /** + * For `id: any[]` returns a mapped `{ [combo.valueKey]: any, [combo.displayKey]: any }[]` + */ + protected getValueDisplayPairs(ids: any[]) { + return this.data.filter(entry => ids.indexOf(entry[this.valueKey]) > -1).map(e => ({ + [this.valueKey]: e[this.valueKey], + [this.displayKey]: e[this.displayKey] + })); + } + + protected getRemoteSelection(newSelection: any[], oldSelection: any[]): string { + if (!newSelection.length) { + // If new selection is empty, clear all items + this.registerRemoteEntries(oldSelection, false); + return ''; + } + const removedItems = oldSelection.filter(e => newSelection.indexOf(e) < 0); + const addedItems = newSelection.filter(e => oldSelection.indexOf(e) < 0); + this.registerRemoteEntries(addedItems); + this.registerRemoteEntries(removedItems, false); + return Object.keys(this._remoteSelection).map(e => this._remoteSelection[e]).join(', '); + } + + protected get required(): boolean { + if (this.ngControl && this.ngControl.control && this.ngControl.control.validator) { + // Run the validation with empty object to check if required is enabled. + const error = this.ngControl.control.validator({} as AbstractControl); + return error && error.required; + } + + return false; + } + + public abstract get filteredData(): any[] | null; + public abstract set filteredData(val: any[] | null); + + public abstract handleOpened(); + public abstract onArrowDown(event: Event); + public abstract focusSearchInput(opening?: boolean); + + public abstract select(newItem: any): void; + public abstract select(newItems: Array | any, clearCurrentSelection?: boolean, event?: Event): void; + + public abstract deselect(...args: [] | [items: Array, event?: Event]): void; + + public abstract writeValue(value: any): void; + + protected abstract setSelection(newSelection: Set, event?: Event): void; + protected abstract createDisplayText(newSelection: any[], oldSelection: any[]); +} diff --git a/projects/igniteui-angular/combo/src/combo/combo.component.html b/projects/igniteui-angular/combo/src/combo/combo.component.html new file mode 100644 index 00000000000..9fa321d2514 --- /dev/null +++ b/projects/igniteui-angular/combo/src/combo/combo.component.html @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + @if (displayValue) { + + @if (clearIconTemplate) { + + } + @if (!clearIconTemplate) { + + } + + } + + @if (toggleIconTemplate) { + + } + @if (!toggleIconTemplate) { + + } + + + + @if (displaySearchInput) { + + } + + +
+ + @if (item?.isHeader) { + + + } + + @if (!item?.isHeader) { + + + } + +
+ @if (filteredData.length === 0 || isAddButtonVisible()) { +
+ @if (filteredData.length === 0) { +
+ + +
+ } + @if (isAddButtonVisible()) { + + + + + } +
+ } + + +
+ + {{display[key]}} + + + {{display}} + + + {{resourceStrings.igx_combo_empty_message}} + + + + + + {{ item[key] }} + diff --git a/projects/igniteui-angular/combo/src/combo/combo.component.spec.ts b/projects/igniteui-angular/combo/src/combo/combo.component.spec.ts new file mode 100644 index 00000000000..3f3be0f1da7 --- /dev/null +++ b/projects/igniteui-angular/combo/src/combo/combo.component.spec.ts @@ -0,0 +1,3935 @@ +import { AsyncPipe } from '@angular/common'; +import { AfterViewInit, ChangeDetectorRef, Component, DebugElement, ElementRef, Injectable, Injector, OnDestroy, OnInit, ViewChild, inject } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { + FormsModule, NgForm, NgModel, ReactiveFormsModule, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, Validators +} from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { BehaviorSubject, Observable, firstValueFrom } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { IgxSelectionAPIService } from 'igniteui-angular/core'; +import { IBaseCancelableBrowserEventArgs } from 'igniteui-angular/core'; +import { SortingDirection } from '../../../core/src/data-operations/sorting-strategy'; +import { IForOfState } from '../../../directives/src/directives/for-of/for_of.directive'; +import { IgxInputState } from '../../../input-group/src/public_api'; +import { IGX_INPUT_GROUP_TYPE, IgxLabelDirective } from '../../../input-group/src/public_api'; +import { AbsoluteScrollStrategy, ConnectedPositioningStrategy } from 'igniteui-angular/core'; +import { IgxComboAddItemComponent } from './combo-add-item.component'; +import { IgxComboDropDownComponent } from './combo-dropdown.component'; +import { IgxComboItemComponent } from './combo-item.component'; +import { IComboFilteringOptions, IGX_COMBO_COMPONENT } from './combo.common'; +import { + IComboItemAdditionEvent, IComboSearchInputEventArgs, IComboSelectionChangingEventArgs, IgxComboComponent +} from './combo.component'; +import { IgxComboFooterDirective, IgxComboHeaderDirective, IgxComboItemDirective } from './combo.directives'; +import { IgxComboFilteringPipe, comboIgnoreDiacriticsFilter } from './combo.pipes'; +import { IgxDropDownItemBaseDirective } from '../../../drop-down/src/drop-down/drop-down-item.base'; +import { UIInteractions, wait } from 'igniteui-angular/test-utils/ui-interactions.spec'; +import { IgxComboAPIService } from './combo.api'; + +const CSS_CLASS_COMBO = 'igx-combo'; +const CSS_CLASS_COMBO_DROPDOWN = 'igx-combo__drop-down'; +const CSS_CLASS_DROPDOWN = 'igx-drop-down'; +const CSS_CLASS_DROPDOWNLIST = 'igx-drop-down__list'; +const CSS_CLASS_DROPDOWNLIST_SCROLL = 'igx-drop-down__list-scroll'; +const CSS_CLASS_CONTENT = 'igx-combo__content'; +const CSS_CLASS_CONTAINER = 'igx-display-container'; +const CSS_CLASS_DROPDOWNLISTITEM = 'igx-drop-down__item'; +const CSS_CLASS_TOGGLEBUTTON = 'igx-combo__toggle-button'; +const CSS_CLASS_CLEARBUTTON = 'igx-combo__clear-button'; +const CSS_CLASS_ADDBUTTON = 'igx-combo__add-item'; +const CSS_CLASS_SELECTED = 'igx-drop-down__item--selected'; +const CSS_CLASS_FOCUSED = 'igx-drop-down__item--focused'; +const CSS_CLASS_HEADERITEM = 'igx-drop-down__header'; +const CSS_CLASS_SCROLLBAR_VERTICAL = 'igx-vhelper--vertical'; +const CSS_CLASS_INPUTGROUP = 'igx-input-group'; +const CSS_CLASS_COMBO_INPUTGROUP = 'igx-input-group__input'; +const CSS_CLASS_INPUTGROUP_WRAPPER = 'igx-input-group__wrapper'; +const CSS_CLASS_INPUTGROUP_REQUIRED = 'igx-input-group--required'; +const CSS_CLASS_INPUTGROUP_LABEL = 'igx-input-group__label'; +const CSS_CLASS_SEARCHINPUT = 'input[name=\'searchInput\']'; +const CSS_CLASS_HEADER = 'header-class'; +const CSS_CLASS_FOOTER = 'footer-class'; +const CSS_CLASS_INPUT_GROUP_REQUIRED = 'igx-input-group--required'; +const CSS_CLASS_INPUT_GROUP_INVALID = 'igx-input-group--invalid'; +const CSS_CLASS_EMPTY = 'igx-combo__empty'; +const CSS_CLASS_ITEM_CHECKBOX = 'igx-combo__checkbox'; +const CSS_CLASS_ITME_CHECKBOX_CHECKED = 'igx-checkbox--checked'; +const defaultDropdownItemHeight = 40; +const defaultDropdownItemMaxHeight = 240; + +describe('igxCombo', () => { + let fixture: ComponentFixture; + let combo: IgxComboComponent; + let input: DebugElement; + + describe('Unit tests: ', () => { + const data = ['Item1', 'Item2', 'Item3', 'Item4', 'Item5', 'Item6', 'Item7']; + const complexData = [ + { country: 'UK', city: 'London' }, + { country: 'France', city: 'Paris' }, + { country: 'Germany', city: 'Berlin' }, + { country: 'Bulgaria', city: 'Sofia' }, + { country: 'Austria', city: 'Vienna' }, + { country: 'Spain', city: 'Madrid' }, + { country: 'Italy', city: 'Rome' } + ]; + const elementRef = { nativeElement: null }; + const mockSelection: { + [key: string]: jasmine.Spy; + } = jasmine.createSpyObj('IgxSelectionAPIService', ['get', 'set', 'add_items', 'select_items', 'delete']); + const mockCdr = jasmine.createSpyObj('ChangeDetectorRef', ['markForCheck', 'detectChanges']); + const mockComboService = jasmine.createSpyObj('IgxComboAPIService', ['register', 'clear']); + const mockNgControl = jasmine.createSpyObj('NgControl', ['registerOnChangeCb', 'registerOnTouchedCb']); + const mockInjector = jasmine.createSpyObj('Injector', ['get']); + mockInjector.get.and.returnValue(mockNgControl); + mockSelection.get.and.returnValue(new Set([])); + jasmine.getEnv().allowRespy(true); + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [IgxComboComponent, NoopAnimationsModule], + providers: [ + { provide: ElementRef, useValue: elementRef }, + { provide: ChangeDetectorRef, useValue: mockCdr }, + { provide: IgxSelectionAPIService, useValue: mockSelection }, + { provide: IgxComboAPIService, useValue: mockComboService }, + { provide: IGX_INPUT_GROUP_TYPE, useValue: null }, + { provide: Injector, useValue: mockInjector } + ] + }) + .overrideComponent(IgxComboComponent, { + set: { + providers: [ + { provide: IgxComboAPIService, useValue: mockComboService }, + { provide: IGX_COMBO_COMPONENT, useExisting: IgxComboComponent } + ] + } + }) + .compileComponents(); + + fixture = TestBed.createComponent(IgxComboComponent); + combo = fixture.componentInstance; + + const dropdown = (combo as any).dropdown; + if (dropdown) { + (dropdown as any).virtDir = { + getScroll: () => ({ + removeEventListener: () => { } + }) + }; + } + }); + + afterAll(() => { + jasmine.getEnv().allowRespy(false); + }); + + it('should correctly implement interface methods - ControlValueAccessor ', () => { + combo.ngOnInit(); + expect(combo['ngControl']).toBeDefined(); + combo.registerOnChange(mockNgControl.registerOnChangeCb); + combo.registerOnTouched(mockNgControl.registerOnTouchedCb); + + // writeValue + expect(combo.displayValue).toEqual(''); + mockSelection.get.and.returnValue(new Set(['test'])); + spyOnProperty(combo, 'isRemote').and.returnValue(false); + combo.writeValue(['test']); + expect(mockNgControl.registerOnChangeCb).not.toHaveBeenCalled(); + expect(mockSelection.select_items).toHaveBeenCalledWith(combo.id, ['test'], true); + expect(combo.displayValue).toEqual('test'); + expect(combo.value).toEqual(['test']); + + // setDisabledState + combo.setDisabledState(true); + expect(combo.disabled).toBe(true); + combo.setDisabledState(false); + expect(combo.disabled).toBe(false); + + // OnChange callback + mockSelection.add_items.and.returnValue(new Set(['simpleValue'])); + combo.select(['simpleValue']); + expect(mockSelection.add_items).toHaveBeenCalledWith(combo.id, ['simpleValue'], undefined); + expect(mockSelection.select_items).toHaveBeenCalledWith(combo.id, ['simpleValue'], true); + expect(mockNgControl.registerOnChangeCb).toHaveBeenCalledWith(['simpleValue']); + + // OnTouched callback + spyOnProperty(combo, 'collapsed').and.returnValue(true); + spyOnProperty(combo, 'valid', 'set'); + + combo.onBlur(); + expect(mockNgControl.registerOnTouchedCb).toHaveBeenCalledTimes(1); + }); + it('should properly call dropdown methods on toggle', () => { + const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['open', 'close', 'toggle']); + combo.ngOnInit(); + combo.dropdown = dropdown; + dropdown.collapsed = true; + + combo.open(); + dropdown.collapsed = false; + expect(combo.dropdown.open).toHaveBeenCalledTimes(1); + expect(combo.collapsed).toBe(false); + + combo.close(); + dropdown.collapsed = true; + expect(combo.dropdown.close).toHaveBeenCalledTimes(1); + expect(combo.collapsed).toBe(true); + + combo.toggle(); + dropdown.collapsed = false; + expect(combo.dropdown.toggle).toHaveBeenCalledTimes(1); + expect(combo.collapsed).toBe(false); + }); + it(`should not focus search input when property autoFocusSearch=false`, () => { + + const dropdownContainer = { nativeElement: { focus: () => { } } }; + combo['dropdownContainer'] = dropdownContainer; + spyOn(combo, 'focusSearchInput'); + + combo.autoFocusSearch = false; + combo.handleOpened(); + expect(combo.focusSearchInput).toHaveBeenCalledTimes(0); + + combo.autoFocusSearch = true; + combo.handleOpened(); + expect(combo.focusSearchInput).toHaveBeenCalledTimes(1); + + combo.autoFocusSearch = false; + combo.handleOpened(); + expect(combo.focusSearchInput).toHaveBeenCalledTimes(1); + }); + it('should call dropdown toggle with correct overlaySettings', () => { + + const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['toggle']); + combo.ngOnInit(); + combo.dropdown = dropdown; + const defaultSettings = (combo as any)._overlaySettings; + combo.toggle(); + expect(combo.dropdown.toggle).toHaveBeenCalledWith(defaultSettings || {}); + const newSettings = { + positionStrategy: new ConnectedPositioningStrategy(), + scrollStrategy: new AbsoluteScrollStrategy() + }; + combo.overlaySettings = newSettings; + const expectedSettings = Object.assign({}, defaultSettings, newSettings); + combo.toggle(); + expect(combo.dropdown.toggle).toHaveBeenCalledWith(expectedSettings); + }); + it('should properly get/set displayKey', () => { + + combo.ngOnInit(); + combo.valueKey = 'field'; + expect(combo.displayKey).toEqual(combo.valueKey); + combo.displayKey = 'region'; + expect(combo.displayKey).toEqual('region'); + expect(combo.displayKey === combo.valueKey).toBeFalsy(); + }); + it('should properly call "writeValue" method', () => { + + combo.ngOnInit(); + combo.data = data; + mockSelection.select_items.calls.reset(); + spyOnProperty(combo, 'isRemote').and.returnValue(false); + combo.writeValue(['EXAMPLE']); + expect(mockSelection.select_items).toHaveBeenCalledTimes(1); + + // Calling "select_items" through the writeValue accessor should clear the previous values; + // Select items is called with the invalid value and it is written in selection, though no item is selected + // Controlling the selection is up to the user + expect(mockSelection.select_items).toHaveBeenCalledWith(combo.id, ['EXAMPLE'], true); + combo.writeValue(combo.data[0]); + // When value key is specified, the item's value key is stored in the selection + expect(mockSelection.select_items).toHaveBeenCalledWith(combo.id, [], true); + }); + it('should emit owner on `opening` and `closing`', () => { + + combo.ngOnInit(); + spyOn(combo.opening, 'emit').and.callThrough(); + spyOn(combo.closing, 'emit').and.callThrough(); + const mockObj = {}; + const mockEvent = new Event('mock'); + const inputEvent: IBaseCancelableBrowserEventArgs = { + cancel: false, + owner: mockObj, + event: mockEvent + }; + combo.comboInput = { + nativeElement: { + focus: () => { } + } + } as any; + combo.handleOpening(inputEvent); + const expectedCall: IBaseCancelableBrowserEventArgs = { owner: combo, event: inputEvent.event, cancel: inputEvent.cancel }; + expect(combo.opening.emit).toHaveBeenCalledWith(expectedCall); + combo.handleClosing(inputEvent); + expect(combo.closing.emit).toHaveBeenCalledWith(expectedCall); + let sub = combo.opening.subscribe((e: IBaseCancelableBrowserEventArgs) => { + e.cancel = true; + }); + combo.handleOpening(inputEvent); + expect(inputEvent.cancel).toEqual(true); + sub.unsubscribe(); + inputEvent.cancel = false; + + sub = combo.closing.subscribe((e: IBaseCancelableBrowserEventArgs) => { + e.cancel = true; + }); + combo.handleClosing(inputEvent); + expect(inputEvent.cancel).toEqual(true); + sub.unsubscribe(); + }); + it('should not throw error when setting data to null', () => { + + combo.ngOnInit(); + let errorMessage = ''; + try { + combo.data = null; + } catch (ex) { + errorMessage = ex.message; + } + expect(errorMessage).toBe(''); + expect(combo.data).not.toBeUndefined(); + expect(combo.data).not.toBeNull(); + expect(combo.data.length).toBe(0); + }); + it('should not throw error when setting data to undefined', () => { + + combo.ngOnInit(); + let errorMessage = ''; + try { + combo.data = undefined; + } catch (ex) { + errorMessage = ex.message; + } + expect(errorMessage).toBe(''); + expect(combo.data).not.toBeUndefined(); + expect(combo.data).not.toBeNull(); + expect(combo.data.length).toBe(0); + }); + it('should properly handleInputChange', () => { + + const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); + combo.ngOnInit(); + combo.data = data; + combo.dropdown = dropdown; + combo.comboInput = { + value: '', + } as any; + combo.disableFiltering = false; + const matchSpy = spyOn(combo, 'checkMatch').and.callThrough(); + spyOn(combo.searchInputUpdate, 'emit'); + + combo.handleInputChange(); + expect(matchSpy).toHaveBeenCalledTimes(1); + expect(combo.searchInputUpdate.emit).toHaveBeenCalledTimes(0); + + const args = { + searchText: 'Fake', + owner: combo, + cancel: false + }; + combo.handleInputChange('Fake'); + expect(matchSpy).toHaveBeenCalledTimes(2); + expect(combo.searchInputUpdate.emit).toHaveBeenCalledTimes(1); + expect(combo.searchInputUpdate.emit).toHaveBeenCalledWith(args); + + args.searchText = ''; + combo.handleInputChange(''); + expect(matchSpy).toHaveBeenCalledTimes(3); + expect(combo.searchInputUpdate.emit).toHaveBeenCalledTimes(2); + expect(combo.searchInputUpdate.emit).toHaveBeenCalledWith(args); + + combo.disableFiltering = true; + combo.handleInputChange(); + expect(matchSpy).toHaveBeenCalledTimes(4); + expect(combo.searchInputUpdate.emit).toHaveBeenCalledTimes(2); + }); + it('should be able to cancel searchInputUpdate', () => { + + combo.ngOnInit(); + combo.data = data; + combo.disableFiltering = false; + combo.searchInputUpdate.subscribe((e) => { + e.cancel = true; + }); + const matchSpy = spyOn(combo, 'checkMatch').and.callThrough(); + spyOn(combo.searchInputUpdate, 'emit').and.callThrough(); + + combo.handleInputChange('Item1'); + expect(combo.searchInputUpdate.emit).toHaveBeenCalledTimes(1); + expect(matchSpy).toHaveBeenCalledTimes(1); + }); + it('should not open on click if combo is disabled', () => { + + const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['open', 'close', 'toggle']); + const spyObj = jasmine.createSpyObj('event', ['stopPropagation', 'preventDefault']); + combo.ngOnInit(); + combo.dropdown = dropdown; + dropdown.collapsed = true; + + combo.disabled = true; + combo.onClick(spyObj); + expect(combo.dropdown.collapsed).toBeTruthy(); + }); + it('should delete the selection on destroy', () => { + combo.ngOnDestroy(); + expect(mockComboService.clear).toHaveBeenCalled(); + expect(mockSelection.delete).toHaveBeenCalled(); + }); + + describe('Combo selection API unit tests: ', () => { + let selectionService: IgxSelectionAPIService; + beforeEach(() => { + selectionService = new IgxSelectionAPIService(); + TestBed.resetTestingModule(); + + TestBed.configureTestingModule({ + imports: [IgxComboComponent, NoopAnimationsModule], + providers: [ + { provide: ElementRef, useValue: elementRef }, + { provide: ChangeDetectorRef, useValue: mockCdr }, + { provide: IgxSelectionAPIService, useValue: selectionService }, + { provide: IgxComboAPIService, useValue: mockComboService }, + { provide: IGX_INPUT_GROUP_TYPE, useValue: null }, + { provide: Injector, useValue: mockInjector } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(IgxComboComponent); + combo = fixture.componentInstance; + + const dropdown = (combo as any).dropdown; + if (dropdown) { + (dropdown as any).virtDir = { + getScroll: () => ({ removeEventListener: () => { } }) + }; + } + }); + + it('should select items through setSelctedItem method', () => { + const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); + combo.ngOnInit(); + combo.data = complexData; + combo.valueKey = 'country'; + combo.dropdown = dropdown; + spyOnProperty(combo, 'totalItemCount').and.returnValue(combo.data.length); + + const selectedItems = [combo.data[0]]; + const selectedValues = [combo.data[0].country]; + combo.setSelectedItem('UK', true); + expect(combo.selection).toEqual(selectedItems); + expect(combo.value).toEqual(selectedValues); + combo.setSelectedItem('Germany', true); + selectedItems.push(combo.data[2]); + selectedValues.push(combo.data[2].country); + expect(combo.selection).toEqual(selectedItems); + expect(combo.value).toEqual(selectedValues); + selectedItems.pop(); + selectedValues.pop(); + combo.setSelectedItem('Germany', false); + expect(combo.selection).toEqual(selectedItems); + expect(combo.value).toEqual(selectedValues); + selectedItems.pop(); + selectedValues.pop(); + combo.setSelectedItem('UK', false); + expect(combo.selection).toEqual(selectedItems); + expect(combo.value).toEqual(selectedValues); + + combo.valueKey = null; + selectedItems.push(combo.data[5]); + combo.setSelectedItem(combo.data[5], true); + expect(combo.selection).toEqual(selectedItems); + expect(combo.value).toEqual(selectedItems); + selectedItems.push(combo.data[1]); + combo.setSelectedItem(combo.data[1], true); + expect(combo.selection).toEqual(selectedItems); + expect(combo.value).toEqual(selectedItems); + selectedItems.pop(); + combo.setSelectedItem(combo.data[1], false); + expect(combo.selection).toEqual(selectedItems); + expect(combo.value).toEqual(selectedItems); + }); + it('should set selectedItems correctly on selectItems method call', () => { + const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); + combo.ngOnInit(); + combo.data = data; + combo.dropdown = dropdown; + spyOnProperty(combo, 'totalItemCount').and.returnValue(combo.data.length); + + combo.select([], false); + expect(combo.selection).toEqual([]); + expect(combo.value).toEqual([]); + combo.select([], true); + expect(combo.selection).toEqual([]); + expect(combo.value).toEqual([]); + const selectedItems = combo.data.slice(0, 3); + combo.select(combo.data.slice(0, 3), true); + expect(combo.selection).toEqual(selectedItems); + expect(combo.value).toEqual(selectedItems); + combo.select([], false); + expect(combo.selection).toEqual(selectedItems); + expect(combo.value).toEqual(selectedItems); + selectedItems.push(combo.data[3]); + combo.select([combo.data[3]], false); + expect(combo.selection).toEqual(combo.data.slice(0, 4)); + expect(combo.value).toEqual(combo.data.slice(0, 4)); + combo.select([], true); + expect(combo.selection).toEqual([]); + expect(combo.value).toEqual([]); + }); + it('should fire selectionChanging event on item selection', () => { + const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); + combo.ngOnInit(); + combo.data = data; + combo.dropdown = dropdown; + spyOnProperty(combo, 'totalItemCount').and.returnValue(combo.data.length); + spyOn(combo.selectionChanging, 'emit'); + + let oldValue = []; + let newValue = [combo.data[1], combo.data[5], combo.data[6]]; + + let oldSelection = []; + let newSelection = [combo.data[1], combo.data[5], combo.data[6]]; + + combo.select(newSelection); + expect(combo.selectionChanging.emit).toHaveBeenCalledTimes(1); + expect(combo.selectionChanging.emit).toHaveBeenCalledWith({ + oldValue, + newValue, + oldSelection, + newSelection, + added: newSelection, + removed: [], + event: undefined, + owner: combo, + displayText: `${newSelection.join(', ')}`, + cancel: false + }); + + let newItem = combo.data[3]; + combo.select([newItem]); + oldValue = [...newValue]; + newValue.push(newItem); + oldSelection = [...newSelection]; + newSelection.push(newItem); + expect(combo.selectionChanging.emit).toHaveBeenCalledTimes(2); + expect(combo.selectionChanging.emit).toHaveBeenCalledWith({ + oldValue, + newValue, + oldSelection, + newSelection, + removed: [], + added: [combo.data[3]], + event: undefined, + owner: combo, + displayText: `${newSelection.join(', ')}`, + cancel: false + }); + + oldValue = [...newValue]; + newValue = [combo.data[0]]; + oldSelection = [...newSelection]; + newSelection = [combo.data[0]]; + combo.select(newSelection, true); + expect(combo.selectionChanging.emit).toHaveBeenCalledTimes(3); + expect(combo.selectionChanging.emit).toHaveBeenCalledWith({ + oldValue, + newValue, + oldSelection, + newSelection, + removed: oldSelection, + added: newSelection, + event: undefined, + owner: combo, + displayText: `${newSelection.join(', ')}`, + cancel: false + }); + + oldValue = [...newValue]; + newValue = []; + oldSelection = [...newSelection]; + newSelection = []; + newItem = combo.data[0]; + combo.deselect([newItem]); + expect(combo.selection.length).toEqual(0); + expect(combo.selectionChanging.emit).toHaveBeenCalledTimes(4); + expect(combo.selectionChanging.emit).toHaveBeenCalledWith({ + oldValue, + newValue, + oldSelection, + newSelection, + removed: [combo.data[0]], + added: [], + event: undefined, + owner: combo, + displayText: `${newSelection.join(', ')}`, + cancel: false + }); + }); + it('should properly emit added and removed values in change event on single value selection', () => { + const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); + combo.ngOnInit(); + combo.data = complexData; + combo.valueKey = 'country'; + combo.dropdown = dropdown; + spyOnProperty(combo, 'totalItemCount').and.returnValue(combo.data.length); + const selectionSpy = spyOn(combo.selectionChanging, 'emit'); + const expectedResults: IComboSelectionChangingEventArgs = { + newValue: [combo.data[0][combo.valueKey]], + oldValue: [], + newSelection: [combo.data[0]], + oldSelection: [], + added: [combo.data[0]], + removed: [], + event: undefined, + owner: combo, + displayText: `${combo.data[0][combo.displayKey]}`, + cancel: false + }; + combo.select([combo.data[0][combo.valueKey]]); + expect(selectionSpy).toHaveBeenCalledWith(expectedResults); + Object.assign(expectedResults, { + newValue: [], + oldValue: [combo.data[0][combo.valueKey]], + newSelection: [], + oldSelection: [combo.data[0]], + added: [], + displayText: '', + removed: [combo.data[0]] + }); + combo.deselect([combo.data[0][combo.valueKey]]); + expect(selectionSpy).toHaveBeenCalledWith(expectedResults); + }); + it('should properly emit added and removed values in change event on multiple values selection', () => { + const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); + combo.ngOnInit(); + combo.data = complexData; + combo.valueKey = 'country'; + combo.displayKey = 'city'; + combo.dropdown = dropdown; + spyOnProperty(combo, 'totalItemCount').and.returnValue(combo.data.length); + const selectionSpy = spyOn(combo.selectionChanging, 'emit'); + + let oldSelection = []; + let newSelection = [combo.data[0], combo.data[1], combo.data[2]]; + combo.select(newSelection.map(e => e[combo.valueKey])); + const expectedResults: IComboSelectionChangingEventArgs = { + newValue: newSelection.map(e => e[combo.valueKey]), + oldValue: [], + newSelection: newSelection, + oldSelection, + added: newSelection, + removed: [], + event: undefined, + owner: combo, + displayText: `${newSelection.map(entry => entry[combo.displayKey]).join(', ')}`, + cancel: false + }; + expect(selectionSpy).toHaveBeenCalledWith(expectedResults); + + oldSelection = [...newSelection]; + newSelection = [combo.data[1], combo.data[2]]; + combo.deselect([combo.data[0][combo.valueKey]]); + Object.assign(expectedResults, { + newValue: newSelection.map(e => e[combo.valueKey]), + oldValue: oldSelection.map(e => e[combo.valueKey]), + newSelection, + oldSelection, + added: [], + displayText: newSelection.map(e => e[combo.displayKey]).join(', '), + removed: [combo.data[0]] + }); + expect(selectionSpy).toHaveBeenCalledWith(expectedResults); + + oldSelection = [...newSelection]; + newSelection = [combo.data[4], combo.data[5], combo.data[6]]; + combo.select(newSelection.map(e => e[combo.valueKey]), true); + Object.assign(expectedResults, { + newValue: newSelection.map(e => e[combo.valueKey]), + oldValue: oldSelection.map(e => e[combo.valueKey]), + newSelection, + oldSelection, + added: newSelection, + displayText: newSelection.map(e => e[combo.displayKey]).join(', '), + removed: oldSelection + }); + expect(selectionSpy).toHaveBeenCalledWith(expectedResults); + }); + it('should handle select/deselect ALL items', () => { + const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); + combo.ngOnInit(); + combo.data = data; + combo.dropdown = dropdown; + spyOnProperty(combo, 'totalItemCount').and.returnValue(combo.data.length); + spyOn(combo, 'selectAllItems'); + spyOn(combo, 'deselectAllItems'); + + combo.handleSelectAll({ checked: true }); + expect(combo.selectAllItems).toHaveBeenCalledTimes(1); + expect(combo.deselectAllItems).toHaveBeenCalledTimes(0); + + combo.handleSelectAll({ checked: false }); + expect(combo.selectAllItems).toHaveBeenCalledTimes(1); + expect(combo.deselectAllItems).toHaveBeenCalledTimes(1); + }); + it('should emit onSelectonChange event on select/deselect ALL items method call', () => { + const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); + combo.ngOnInit(); + combo.data = data; + combo.dropdown = dropdown; + spyOnProperty(combo, 'totalItemCount').and.returnValue(combo.data.length); + spyOn(combo.selectionChanging, 'emit'); + + combo.selectAllItems(true); + expect(combo.selection).toEqual(data); + expect(combo.value).toEqual(data); + expect(combo.selectionChanging.emit).toHaveBeenCalledTimes(1); + expect(combo.selectionChanging.emit).toHaveBeenCalledWith({ + oldValue: [], + newValue: data, + oldSelection: [], + newSelection: data, + added: data, + removed: [], + owner: combo, + event: undefined, + displayText: `${combo.data.join(', ')}`, + cancel: false + }); + + combo.deselectAllItems(true); + expect(combo.selection).toEqual([]); + expect(combo.value).toEqual([]); + expect(combo.selectionChanging.emit).toHaveBeenCalledTimes(2); + expect(combo.selectionChanging.emit).toHaveBeenCalledWith({ + oldValue: data, + newValue: [], + oldSelection: data, + newSelection: [], + added: [], + removed: data, + owner: combo, + event: undefined, + displayText: '', + cancel: false + }); + }); + it('should properly handle selection manipulation through selectionChanging emit', () => { + const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); + combo.ngOnInit(); + combo.data = data; + combo.dropdown = dropdown; + spyOnProperty(combo, 'totalItemCount').and.returnValue(combo.data.length); + spyOn(combo.selectionChanging, 'emit').and.callFake((event: IComboSelectionChangingEventArgs) => event.newValue = []); + // No items are initially selected + expect(combo.selection).toEqual([]); + // Select the first 5 items + combo.select(combo.data.splice(0, 5)); + // selectionChanging fires and overrides the selection to be []; + expect(combo.selection).toEqual([]); + }); + it('should not clear value when combo is disabled', () => { + const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); + const spyObj = jasmine.createSpyObj('event', ['stopPropagation']); + combo.ngOnInit(); + combo.data = data; + combo.dropdown = dropdown; + combo.disabled = true; + spyOnProperty(combo, 'totalItemCount').and.returnValue(combo.data.length); + + const item = combo.data.slice(0, 1); + combo.select(item, true); + combo.handleClearItems(spyObj); + expect(combo.displayValue).toEqual(item[0]); + }); + it('should allow canceling and overwriting of item addition', fakeAsync(() => { + const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); + const mockVirtDir = jasmine.createSpyObj('virtDir', ['scrollTo']); + const mockInput = jasmine.createSpyObj('mockInput', [], { + nativeElement: jasmine.createSpyObj('mockElement', ['focus']) + }); + spyOn(combo.addition, 'emit').and.callThrough(); + const subParams: { cancel: boolean; newValue: string; modify: boolean } = { + cancel: false, + modify: false, + newValue: 'mockValue' + }; + const sub = combo.addition.subscribe((e) => { + if (subParams.cancel) { + e.cancel = true; + } + if (subParams.modify) { + e.addedItem = subParams.newValue; + } + }); + + combo.ngOnInit(); + combo.data = ['Item 1', 'Item 2', 'Item 3']; + combo.dropdown = dropdown; + combo.searchInput = mockInput; + (combo as any).virtDir = mockVirtDir; + let mockAddParams: IComboItemAdditionEvent = { + cancel: false, + owner: combo, + addedItem: 'Item 99', + newCollection: ['Item 1', 'Item 2', 'Item 3', 'Item 99'], + oldCollection: ['Item 1', 'Item 2', 'Item 3'] + }; + + + // handle addition + + combo.searchValue = 'Item 99'; + combo.addItemToCollection(); + tick(); + expect(combo.data.length).toEqual(4); + expect(combo.addition.emit).toHaveBeenCalledWith(mockAddParams); + expect(combo.addition.emit).toHaveBeenCalledTimes(1); + expect(mockVirtDir.scrollTo).toHaveBeenCalledTimes(1); + expect(combo.searchInput.nativeElement.focus).toHaveBeenCalledTimes(1); + expect(combo.data[combo.data.length - 1]).toBe('Item 99'); + expect(selectionService.get(combo.id).size).toBe(1); + expect([...selectionService.get(combo.id)][0]).toBe('Item 99'); + + // cancel + subParams.cancel = true; + mockAddParams = { + cancel: true, + owner: combo, + addedItem: 'Item 99', + newCollection: ['Item 1', 'Item 2', 'Item 3', 'Item 99', 'Item 99'], + oldCollection: ['Item 1', 'Item 2', 'Item 3', 'Item 99'] + }; + + combo.searchValue = 'Item 99'; + combo.addItemToCollection(); + tick(); + expect(combo.addition.emit).toHaveBeenCalledWith(mockAddParams); + expect(combo.addition.emit).toHaveBeenCalledTimes(2); + expect(mockVirtDir.scrollTo).toHaveBeenCalledTimes(1); + expect(combo.searchInput.nativeElement.focus).toHaveBeenCalledTimes(1); + expect(combo.data.length).toEqual(4); + expect(combo.data[combo.data.length - 1]).toBe('Item 99'); + expect(selectionService.get(combo.id).size).toBe(1); + expect([...selectionService.get(combo.id)][0]).toBe('Item 99'); + + // overwrite + subParams.modify = true; + subParams.cancel = false; + mockAddParams = { + cancel: false, + owner: combo, + addedItem: 'mockValue', + newCollection: ['Item 1', 'Item 2', 'Item 3', 'Item 99', 'Item 99'], + oldCollection: ['Item 1', 'Item 2', 'Item 3', 'Item 99'] + }; + + combo.searchValue = 'Item 99'; + combo.addItemToCollection(); + tick(); + expect(combo.addition.emit).toHaveBeenCalledWith(mockAddParams); + expect(combo.addition.emit).toHaveBeenCalledTimes(3); + expect(mockVirtDir.scrollTo).toHaveBeenCalledTimes(2); + expect(combo.searchInput.nativeElement.focus).toHaveBeenCalledTimes(2); + expect(combo.data.length).toEqual(5); + expect(combo.data[combo.data.length - 1]).toBe(subParams.newValue); + expect(selectionService.get(combo.id).size).toBe(2); + expect([...selectionService.get(combo.id)][1]).toBe(subParams.newValue); + sub.unsubscribe(); + })); + }); + }); + + describe('Combo feature tests: ', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxComboSampleComponent, + IgxComboInContainerTestComponent, + IgxComboRemoteDataComponent, + ComboModelBindingComponent, + IgxComboBindingDataAfterInitComponent, + IgxComboFormComponent, + IgxComboInTemplatedFormComponent + ] + }).compileComponents(); + })); + + describe('Initialization and rendering tests: ', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxComboSampleComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.combo; + input = fixture.debugElement.query(By.css(`.${CSS_CLASS_COMBO_INPUTGROUP}`)); + }); + it('should initialize the combo component properly', () => { + const toggleButton = fixture.debugElement.query(By.css('.' + CSS_CLASS_TOGGLEBUTTON)); + expect(fixture.componentInstance).toBeDefined(); + expect(combo).toBeDefined(); + expect(combo.collapsed).toBeDefined(); + expect(combo.collapsed).toBeTruthy(); + expect(input).toBeDefined(); + expect(toggleButton).toBeDefined(); + expect(combo.searchInput).toBeDefined(); + expect(combo.placeholder).toBeDefined(); + }); + it('should initialize input properties properly', () => { + expect(combo.data).toBeDefined(); + expect(combo.valueKey).toEqual('field'); + expect(combo.displayKey).toEqual('field'); + expect(combo.groupKey).toEqual('region'); + expect(combo.width).toEqual('400px'); + expect(combo.placeholder).toEqual('Location'); + expect(combo.disableFiltering).toEqual(false); + expect(combo.allowCustomValues).toEqual(false); + expect(combo.cssClass).toEqual(CSS_CLASS_COMBO); + expect(combo.type).toEqual('box'); + }); + it('should apply all appropriate classes on combo initialization', () => { + const comboWrapper = fixture.nativeElement.querySelector(CSS_CLASS_COMBO); + expect(comboWrapper).not.toBeNull(); + expect(comboWrapper.classList.contains(CSS_CLASS_COMBO)).toBeTruthy(); + expect(comboWrapper.childElementCount).toEqual(2); + + const dropDownElement = comboWrapper.children[1]; + expect(dropDownElement.classList.contains(CSS_CLASS_COMBO_DROPDOWN)).toBeTruthy(); + expect(dropDownElement.classList.contains(CSS_CLASS_DROPDOWN)).toBeTruthy(); + expect(dropDownElement.childElementCount).toEqual(1); + + const dropDownList = dropDownElement.children[0]; + const dropDownScrollList = dropDownElement.children[0].children[0]; + expect(dropDownList.classList.contains(CSS_CLASS_DROPDOWNLIST)).toBeTruthy(); + expect(dropDownList.classList.contains('igx-toggle--hidden')).toBeTruthy(); + expect(dropDownScrollList.childElementCount).toEqual(0); + }); + it('should render aria attributes properly', fakeAsync(() => { + expect(input.nativeElement.getAttribute('role')).toEqual('combobox'); + expect(input.nativeElement.getAttribute('aria-haspopup')).toEqual('listbox'); + expect(input.nativeElement.getAttribute('aria-expanded')).toMatch('false'); + expect(input.nativeElement.getAttribute('aria-controls')).toEqual(combo.dropdown.listId); + expect(input.nativeElement.getAttribute('aria-labelledby')).toEqual(combo.placeholder); + expect(input.nativeElement.getAttribute('aria-label')).toEqual('No options selected'); + + const dropdown = fixture.debugElement.query(By.css(`div[role="listbox"]`)); + expect(dropdown.nativeElement.getAttribute('aria-labelledby')).toEqual(combo.placeholder); + + combo.open(); + tick(); + fixture.detectChanges(); + + const searchInput = fixture.debugElement.query(By.css(CSS_CLASS_SEARCHINPUT)); + expect(searchInput.nativeElement.getAttribute('role')).toEqual('searchbox'); + expect(searchInput.nativeElement.getAttribute('aria-label')).toEqual('search'); + expect(searchInput.nativeElement.getAttribute('aria-autocomplete')).toEqual('list'); + + const list = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTENT}`)); + expect(list.nativeElement.getAttribute('aria-multiselectable')).toEqual('true'); + expect(list.nativeElement.getAttribute('aria-activedescendant')).toEqual(null); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', list); + tick(); + fixture.detectChanges(); + expect(list.nativeElement.getAttribute('aria-activedescendant')).toEqual(combo.dropdown.focusedItem.id); + + combo.select(['Illinois', 'Mississippi', 'Ohio']); + fixture.detectChanges(); + expect(input.nativeElement.getAttribute('aria-label')).toEqual('Selected options'); + })); + it('should render aria-expanded attribute properly', fakeAsync(() => { + expect(input.nativeElement.getAttribute('aria-expanded')).toMatch('false'); + combo.open(); + tick(); + fixture.detectChanges(); + expect(input.nativeElement.getAttribute('aria-expanded')).toMatch('true'); + combo.close(); + tick(); + fixture.detectChanges(); + expect(input.nativeElement.getAttribute('aria-expanded')).toMatch('false'); + })); + it('should render placeholder values for inputs properly', () => { + combo.toggle(); + fixture.detectChanges(); + expect(combo.collapsed).toBeFalsy(); + expect(combo.placeholder).toEqual('Location'); + expect(combo.comboInput.nativeElement.placeholder).toEqual('Location'); + + expect(combo.searchInput.nativeElement.placeholder).toEqual('Enter a Search Term'); + + combo.searchPlaceholder = 'Filter'; + fixture.detectChanges(); + expect(combo.searchPlaceholder).toEqual('Filter'); + expect(combo.searchInput.nativeElement.placeholder).toEqual('Filter'); + + combo.disableFiltering = true; + fixture.detectChanges(); + expect(combo.searchPlaceholder).toEqual('Filter'); + + combo.placeholder = 'States'; + fixture.detectChanges(); + expect(combo.placeholder).toEqual('States'); + expect(combo.comboInput.nativeElement.placeholder).toEqual('States'); + }); + it('should render dropdown list and item height properly', fakeAsync(() => { + // NOTE: Minimum itemHeight is 2 rem, per Material Design Guidelines (for mobile only) + let itemHeight = defaultDropdownItemHeight; + let itemMaxHeight = defaultDropdownItemMaxHeight; + fixture.componentInstance.size = "large"; + fixture.detectChanges(); + combo.toggle(); + tick(); + fixture.detectChanges(); + const dropdownItems = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`)); + const dropdownList = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTENT}`)); + + const verifyDropdownItemHeight = () => { + expect(dropdownItems[0].nativeElement.clientHeight).toEqual(itemHeight); + expect(dropdownList.nativeElement.clientHeight).toEqual(itemMaxHeight); + }; + verifyDropdownItemHeight(); + + itemHeight = 48; + itemMaxHeight = 480; + combo.itemHeight = itemHeight; + tick(); + fixture.detectChanges(); + verifyDropdownItemHeight(); + + itemMaxHeight = 438; + combo.itemsMaxHeight = 438; + tick(); + fixture.detectChanges(); + verifyDropdownItemHeight(); + + itemMaxHeight = 1171; + combo.itemsMaxHeight = 1171; + tick(); + fixture.detectChanges(); + verifyDropdownItemHeight(); + + itemHeight = 83; + combo.itemHeight = 83; + tick(); + fixture.detectChanges(); + verifyDropdownItemHeight(); + })); + it('should render grouped items properly', (done) => { + let dropdownContainer; + let dropdownItems; + let scrollIndex = 0; + const headers: Array = Array.from(new Set(combo.data.map(item => item.region))); + combo.toggle(); + fixture.detectChanges(); + const checkGroupedItemsClass = () => { + fixture.detectChanges(); + dropdownContainer = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTAINER}`)).nativeElement; + dropdownItems = dropdownContainer.children; + Array.from(dropdownItems).forEach((item) => { + const itemElement = item as HTMLElement; + const itemText = itemElement.innerText.toString(); + const expectedClass: string = headers.includes(itemText) ? CSS_CLASS_HEADERITEM : CSS_CLASS_DROPDOWNLISTITEM; + expect(itemElement.classList.contains(expectedClass)).toBeTruthy(); + }); + scrollIndex += 10; + if (scrollIndex < combo.data.length) { + combo.virtualScrollContainer.scrollTo(scrollIndex); + combo.virtualScrollContainer.chunkLoad.pipe(take(1)).subscribe(async () => { + await wait(30); + checkGroupedItemsClass(); + }); + } else { + done(); + } + }; + checkGroupedItemsClass(); + }); + it('should render selected items properly', () => { + combo.toggle(); + fixture.detectChanges(); + + const dropdownList = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROPDOWNLIST_SCROLL}`)).nativeElement; + const dropdownItems = dropdownList.querySelectorAll(`.${CSS_CLASS_DROPDOWNLISTITEM}`); + + + expect(dropdownItems[1].classList.contains(CSS_CLASS_SELECTED)).toBeFalsy(); + expect(dropdownItems[3].classList.contains(CSS_CLASS_SELECTED)).toBeFalsy(); + + combo.select(['Illinois', 'Ohio']); + fixture.detectChanges(); + expect(dropdownItems[1].classList.contains(CSS_CLASS_SELECTED)).toBeTruthy(); + expect(dropdownItems[3].classList.contains(CSS_CLASS_SELECTED)).toBeTruthy(); + + combo.deselect(['Ohio']); + fixture.detectChanges(); + expect(dropdownItems[1].classList.contains(CSS_CLASS_SELECTED)).toBeFalsy(); + }); + it('should render focused items properly', () => { + const dropdown = combo.dropdown; + combo.toggle(); + fixture.detectChanges(); + + dropdown.navigateItem(2); // Componenent is virtualized, so this will focus the ACTUAL 3rd item + fixture.detectChanges(); + + const dropdownList = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROPDOWNLIST_SCROLL}`)).nativeElement; + const dropdownItems = dropdownList.querySelectorAll(`.${CSS_CLASS_DROPDOWNLISTITEM}`); + const focusedItem_1 = dropdownItems[1]; + expect(focusedItem_1.classList.contains(CSS_CLASS_FOCUSED)).toBeTruthy(); + + // Change focus + dropdown.navigateItem(4); + fixture.detectChanges(); + const focusedItem_2 = dropdownItems[3]; + expect(focusedItem_2.classList.contains(CSS_CLASS_FOCUSED)).toBeTruthy(); + expect(focusedItem_1.classList.contains(CSS_CLASS_FOCUSED)).toBeFalsy(); + }); + it(`should not render search input if 'allowCustomValues' is false and 'disableFiltering' is true`, () => { + combo.allowCustomValues = false; + combo.disableFiltering = true; + expect(combo.displaySearchInput).toBeFalsy(); + combo.toggle(); + fixture.detectChanges(); + expect(combo.searchInput).toBeFalsy(); + }); + it('should focus search input', fakeAsync(() => { + combo.toggle(); + tick(); + fixture.detectChanges(); + expect(document.activeElement).toEqual(combo.searchInput.nativeElement); + })); + it('should not focus search input, when autoFocusSearch=false', fakeAsync(() => { + combo.autoFocusSearch = false; + combo.toggle(); + tick(); + fixture.detectChanges(); + expect(document.activeElement).not.toEqual(combo.searchInput.nativeElement); + })); + it('should properly initialize templates', () => { + expect(combo).toBeDefined(); + expect(combo.footerTemplate).toBeDefined(); + expect(combo.headerTemplate).toBeDefined(); + expect(combo.itemTemplate).toBeDefined(); + expect(combo.addItemTemplate).toBeUndefined(); + expect(combo.headerItemTemplate).toBeUndefined(); + }); + it('should properly render header and footer templates', () => { + let headerElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_HEADER}`)); + let footerElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_FOOTER}`)); + expect(headerElement).toBeNull(); + expect(footerElement).toBeNull(); + combo.toggle(); + fixture.detectChanges(); + expect(combo.headerTemplate).toBeDefined(); + expect(combo.footerTemplate).toBeDefined(); + const dropdownList: HTMLElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROPDOWNLIST_SCROLL}`)).nativeElement; + headerElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_HEADER}`)); + footerElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_FOOTER}`)); + expect(headerElement).not.toBeNull(); + const headerHTMLElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_HEADER}`)).nativeElement; + expect(headerHTMLElement.parentNode).toEqual(dropdownList); + expect(headerHTMLElement.textContent).toEqual('This is a header'); + expect(footerElement).not.toBeNull(); + const footerHTMLElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_FOOTER}`)).nativeElement; + expect(footerHTMLElement.parentNode).toEqual(dropdownList); + expect(footerHTMLElement.textContent).toEqual('This is a footer'); + }); + it('should render case-sensitive icon properly', () => { + combo.showSearchCaseIcon = true; + fixture.detectChanges(); + combo.toggle(); + fixture.detectChanges(); + + let caseSensitiveIcon = fixture.debugElement.query(By.css('igx-icon[name=\'case-sensitive\']')); + expect(caseSensitiveIcon).toBeDefined(); + + combo.toggle(); + fixture.detectChanges(); + combo.showSearchCaseIcon = false; + fixture.detectChanges(); + combo.toggle(); + fixture.detectChanges(); + + caseSensitiveIcon = fixture.debugElement.query(By.css('igx-icon[name=\'case-sensitive\']')); + expect(caseSensitiveIcon).toBeNull(); + }); + it('should render the combo component with the id set and not throw an error', () => { + fixture = TestBed.createComponent(ComboWithIdComponent); + fixture.detectChanges(); + + combo = fixture.componentInstance.combo; + fixture.detectChanges(); + + expect(combo).toBeTruthy(); + expect(combo.id).toEqual("id1"); + fixture.detectChanges(); + + const errorSpy = spyOn(console, 'error'); + fixture.detectChanges(); + + expect(errorSpy).not.toHaveBeenCalled(); + }); + it('should properly assign the resource string to the aria-label of the clear button', () => { + combo.toggle(); + fixture.detectChanges(); + + combo.select(['Illinois', 'Mississippi', 'Ohio']); + fixture.detectChanges(); + + const clearBtn = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + expect(clearBtn.nativeElement.ariaLabel).toEqual('Clear Selection'); + }); + }); + describe('Positioning tests: ', () => { + let containerElement: any; + beforeEach(() => { + fixture = TestBed.createComponent(IgxComboInContainerTestComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.combo; + containerElement = fixture.debugElement.query(By.css('.comboContainer')).nativeElement; + }); + it('should adjust combo width to the container element width when set to 100%', fakeAsync(() => { + const containerWidth = 500; + const comboWrapper = fixture.debugElement.query(By.css(CSS_CLASS_COMBO)).nativeElement; + let containerElementWidth = containerElement.getBoundingClientRect().width; + let wrapperWidth = comboWrapper.getBoundingClientRect().width; + expect(containerElementWidth).toEqual(containerWidth); + expect(containerElementWidth).toEqual(wrapperWidth); + + combo.toggle(); + tick(); + fixture.detectChanges(); + const inputElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_INPUTGROUP_WRAPPER}`)).nativeElement; + const dropDownElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROPDOWNLIST_SCROLL}`)).nativeElement; + containerElementWidth = containerElement.getBoundingClientRect().width; + wrapperWidth = comboWrapper.getBoundingClientRect().width; + const inputWidth = inputElement.getBoundingClientRect().width; + const dropDownWidth = dropDownElement.getBoundingClientRect().width; + expect(containerElementWidth).toEqual(wrapperWidth); + expect(dropDownWidth).toEqual(containerElementWidth); + expect(inputWidth).toEqual(containerElementWidth); + })); + it('should render combo width properly when placed in container', fakeAsync(() => { + let comboWidth = '300px'; + const containerWidth = '500px'; + combo.width = comboWidth; + fixture.detectChanges(); + + let comboWrapper = fixture.debugElement.query(By.css(CSS_CLASS_COMBO)).nativeElement; + let containerElementWidth = containerElement.style.width; + let wrapperWidth = comboWrapper.style.width; + expect(containerElementWidth).toEqual(containerWidth); + expect(wrapperWidth).toEqual(comboWidth); + + combo.toggle(); + tick(); + fixture.detectChanges(); + + let inputElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_INPUTGROUP_WRAPPER}`)).nativeElement; + let dropDownElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROPDOWNLIST_SCROLL}`)).nativeElement; + containerElementWidth = containerElement.style.width; + wrapperWidth = comboWrapper.style.width; + let inputWidth = inputElement.getBoundingClientRect().width + 'px'; + let dropDownWidth = dropDownElement.getBoundingClientRect().width + 'px'; + expect(containerElementWidth).toEqual(containerWidth); + expect(wrapperWidth).toEqual(comboWidth); + expect(dropDownWidth).toEqual(comboWidth); + expect(inputWidth).toEqual(comboWidth); + + combo.toggle(); + tick(); + fixture.detectChanges(); + + comboWidth = '700px'; + combo.width = comboWidth; + fixture.detectChanges(); + + combo.toggle(); + tick(); + fixture.detectChanges(); + + comboWrapper = fixture.debugElement.query(By.css(CSS_CLASS_COMBO)).nativeElement; + inputElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_INPUTGROUP_WRAPPER}`)).nativeElement; + dropDownElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROPDOWNLIST_SCROLL}`)).nativeElement; + containerElementWidth = containerElement.style.width; + wrapperWidth = comboWrapper.style.width; + inputWidth = inputElement.getBoundingClientRect().width + 'px'; + dropDownWidth = dropDownElement.getBoundingClientRect().width + 'px'; + expect(containerElementWidth).toEqual(containerWidth); + expect(wrapperWidth).toEqual(comboWidth); + expect(dropDownWidth).toEqual(comboWidth); + expect(inputWidth).toEqual(comboWidth); + })); + }); + describe('Binding to primitive array tests: ', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxComboInContainerTestComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.combo; + }); + + it('should bind combo data to array of primitive data', () => { + const data = [...fixture.componentInstance.citiesData]; + const comboData = combo.data; + expect(comboData).toEqual(data); + }); + it('should remove undefined from array of primitive data', () => { + combo.data = ['New York', 'Sofia', undefined, 'Istanbul', 'Paris']; + + expect(combo.data).toEqual(['New York', 'Sofia', 'Istanbul', 'Paris']); + }); + it('should render empty template when combo data source is not set', () => { + combo.data = []; + fixture.detectChanges(); + combo.toggle(); + fixture.detectChanges(); + const dropdownList = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROPDOWNLIST_SCROLL}`)).nativeElement; + const dropdownItemsContainer = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTENT}`)).nativeElement; + const dropDownContainer = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTAINER}`)).nativeElement; + const listItems = dropDownContainer.querySelectorAll(`.${CSS_CLASS_DROPDOWNLISTITEM}`); + expect(listItems.length).toEqual(0); + expect(dropdownList.childElementCount).toEqual(3); + // Expect no items to be rendered in the virtual container + expect(dropdownItemsContainer.children[0].childElementCount).toEqual(0); + // Expect the list child (NOT COMBO ITEM) to be a container with "The list is empty"; + const dropdownItem = dropdownList.lastElementChild as HTMLElement; + expect(dropdownItem.firstElementChild.textContent).toEqual('The list is empty'); + }); + it('should bind combo data properly when changing data source runtime', () => { + const newData = ['Item 1', 'Item 2']; + const data = [...fixture.componentInstance.citiesData]; + expect(combo.data).toEqual(data); + combo.data = newData; + fixture.detectChanges(); + expect(combo.data).toEqual(newData); + }); + }); + describe('Binding to object array tests: ', () => { + it('should bind combo data to array of objects', () => { + fixture = TestBed.createComponent(IgxComboSampleComponent); + fixture.detectChanges(); + const data = [...fixture.componentInstance.items]; + combo = fixture.componentInstance.combo; + const comboData = combo.data; + expect(comboData).toEqual(data); + }); + }); + describe('Binding to remote data tests: ', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxComboRemoteDataComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.instance; + }); + it('should bind combo data to remote service data', async () => { + let productIndex = 0; + + const verifyComboData = () => { + fixture.detectChanges(); + let ind = combo.virtualScrollContainer.state.startIndex; + for (let itemIndex = 0; itemIndex < 10; itemIndex++) { + expect(combo.data[itemIndex].id).toEqual(ind); + expect(combo.data[itemIndex].product).toEqual('Product ' + ind); + const dropdownList = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROPDOWNLIST_SCROLL}`)).nativeElement; + const dropdownItems = dropdownList.querySelectorAll(`.${CSS_CLASS_DROPDOWNLISTITEM}`); + expect(dropdownItems[itemIndex].innerText.trim()).toEqual('Product ' + ind); + ind++; + } + }; + + combo.toggle(); + fixture.detectChanges(); + verifyComboData(); + expect(combo.virtualizationState.startIndex).toEqual(productIndex); + + productIndex = 42; + combo.virtualScrollContainer.scrollTo(productIndex); + await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + fixture.detectChanges(); + verifyComboData(); + // index is at bottom + expect(combo.virtualizationState.startIndex + combo.virtualizationState.chunkSize - 1) + .toEqual(productIndex); + + productIndex = 485; + combo.virtualScrollContainer.scrollTo(productIndex); + await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + fixture.detectChanges(); + verifyComboData(); + expect(combo.virtualizationState.startIndex + combo.virtualizationState.chunkSize - 1) + .toEqual(productIndex); + + productIndex = 873; + combo.virtualScrollContainer.scrollTo(productIndex); + await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + fixture.detectChanges(); + verifyComboData(); + + productIndex = 649; + combo.virtualScrollContainer.scrollTo(productIndex); + await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + fixture.detectChanges(); + verifyComboData(); + }); + it('should bind combo data to remote data and clear selection properly', async () => { + let selectedItems = [combo.data[0], combo.data[1]]; + const spyObj = jasmine.createSpyObj('event', ['stopPropagation']); + combo.toggle(); + combo.select([selectedItems[0][combo.valueKey], selectedItems[1][combo.valueKey]]); + expect(combo.displayValue).toEqual(`${selectedItems[0][combo.displayKey]}, ${selectedItems[1][combo.displayKey]}`); + expect(combo.selection).toEqual([selectedItems[0], selectedItems[1]]); + expect(combo.value).toEqual([selectedItems[0][combo.valueKey], selectedItems[1][combo.valueKey]]); + // Clear items while they are in view + combo.handleClearItems(spyObj); + expect(combo.selection).toEqual([]); + expect(combo.displayValue).toEqual(''); + expect(combo.value).toEqual([]); + selectedItems = [combo.data[2], combo.data[3]]; + combo.select([selectedItems[0][combo.valueKey], selectedItems[1][combo.valueKey]]); + expect(combo.displayValue).toEqual(`${selectedItems[0][combo.displayKey]}, ${selectedItems[1][combo.displayKey]}`); + + // Scroll selected items out of view + combo.virtualScrollContainer.scrollTo(40); + await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + fixture.detectChanges(); + combo.handleClearItems(spyObj); + expect(combo.selection).toEqual([]); + expect(combo.value).toEqual([]); + expect(combo.displayValue).toEqual(''); + combo.select([combo.data[7][combo.valueKey]]); + expect(combo.displayValue).toEqual(combo.data[7][combo.displayKey]); + }); + it('should add selected items to the input when data is loaded', async () => { + expect(combo.selection.length).toEqual(0); + expect(combo.value).toEqual([]); + + // current combo data - id: 0 - 9 + // select item that is not present in the data source yet should be added as partial item + combo.select([9, 19]); + expect(combo.selection.length).toEqual(2); + expect(combo.value.length).toEqual(2); + + const firstItem = combo.data[combo.data.length - 1]; + expect(combo.displayValue).toEqual(firstItem[combo.displayKey]); + + combo.toggle(); + + // scroll to second selected item + combo.virtualScrollContainer.scrollTo(19); + await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + fixture.detectChanges(); + + const secondItem = combo.data[combo.data.length - 1]; + expect(combo.displayValue).toEqual(`${firstItem[combo.displayKey]}, ${secondItem[combo.displayKey]}`); + }); + it('should fire selectionChanging event with partial data for items out of view', async () => { + const selectionSpy = spyOn(combo.selectionChanging, 'emit').and.callThrough(); + const valueKey = combo.valueKey; + + combo.toggle(); + combo.select([combo.data[0][valueKey], combo.data[1][valueKey]]); + + const expectedResults: IComboSelectionChangingEventArgs = { + newValue: [combo.data[0][valueKey], combo.data[1][valueKey]], + oldValue: [], + newSelection: [combo.data[0], combo.data[1]], + oldSelection: [], + added: [combo.data[0], combo.data[1]], + removed: [], + event: undefined, + owner: combo, + displayText: `${combo.data[0][combo.displayKey]}, ${combo.data[1][combo.displayKey]}`, + cancel: false + }; + expect(selectionSpy).toHaveBeenCalledWith(expectedResults); + + // Scroll selected items out of view + combo.virtualScrollContainer.scrollTo(40); + await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + fixture.detectChanges(); + combo.select([combo.data[0][valueKey], combo.data[1][valueKey]]); + Object.assign(expectedResults, { + newValue: [0, 1, 31, 32], + oldValue: [0, 1], + newSelection: [{ [valueKey]: 0 }, { [valueKey]: 1 }, combo.data[0], combo.data[1]], + oldSelection: [{ [valueKey]: 0 }, { [valueKey]: 1 }], + added: [combo.data[0], combo.data[1]], + removed: [], + event: undefined, + owner: combo, + displayText: `Product 0, Product 1, Product 31, Product 32`, + cancel: false + }); + + expect(selectionSpy).toHaveBeenCalledWith(expectedResults); + }); + }); + describe('Binding to ngModel tests: ', () => { + let component: ComboModelBindingComponent; + beforeEach(() => { + fixture = TestBed.createComponent(ComboModelBindingComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.combo; + component = fixture.componentInstance; + }); + it('should properly bind to object value w/ valueKey', fakeAsync(() => { + combo.valueKey = 'id'; + component.selectedItems = [0, 2]; + fixture.detectChanges(); + tick(); + expect(combo.selection).toEqual([combo.data[0], combo.data[2]]); + expect(combo.value).toEqual([combo.data[0][combo.valueKey], combo.data[2][combo.valueKey]]); + combo.select([combo.data[4][combo.valueKey]]); + fixture.detectChanges(); + expect(component.selectedItems).toEqual([0, 2, 4]); + })); + it('should properly bind to object value w/o valueKey', fakeAsync(() => { + component.selectedItems = [component.items[0], component.items[2]]; + fixture.detectChanges(); + tick(); + expect(combo.selection).toEqual([combo.data[0], combo.data[2]]); + expect(combo.value).toEqual([combo.data[0], combo.data[2]]); + combo.select([combo.data[4]]); + fixture.detectChanges(); + expect(component.selectedItems).toEqual([combo.data[0], combo.data[2], combo.data[4]]); + })); + it('should properly bind to values w/o valueKey', fakeAsync(() => { + component.items = ['One', 'Two', 'Three', 'Four', 'Five']; + component.selectedItems = ['One', 'Two']; + fixture.detectChanges(); + tick(); + const data = fixture.componentInstance.items; + expect(combo.selection).toEqual(component.selectedItems); + expect(combo.value).toEqual(component.selectedItems); + combo.select([...data].splice(1, 3), true); + fixture.detectChanges(); + expect(fixture.componentInstance.selectedItems).toEqual([...data].splice(1, 3)); + })); + }); + describe('Dropdown tests: ', () => { + describe('complex data dropdown: ', () => { + let dropdown: IgxComboDropDownComponent; + beforeEach(() => { + fixture = TestBed.createComponent(IgxComboSampleComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.combo; + dropdown = combo.dropdown; + input = fixture.debugElement.query(By.css(`.${CSS_CLASS_INPUTGROUP}`)); + }); + it('should properly call dropdown navigatePrev method', fakeAsync(() => { + expect(dropdown.focusedItem).toBeFalsy(); + expect(dropdown.focusedItem).toEqual(null); + expect(combo.collapsed).toBeTruthy(); + combo.toggle(); + tick(); + fixture.detectChanges(); + expect(document.activeElement).toEqual(combo.searchInput.nativeElement); + expect(combo.collapsed).toBeFalsy(); + combo.handleKeyUp(UIInteractions.getKeyboardEvent('keyup', 'ArrowDown')); + fixture.detectChanges(); + expect(dropdown.focusedItem).toBeTruthy(); + expect(dropdown.focusedItem.itemIndex).toEqual(0); + expect(combo.virtualizationState.startIndex).toEqual(0); + dropdown.navigatePrev(); + tick(); + fixture.detectChanges(); + expect(document.activeElement).toEqual(combo.searchInput.nativeElement); + combo.handleKeyUp(UIInteractions.getKeyboardEvent('keyup', 'ArrowDown')); + fixture.detectChanges(); + expect(dropdown.focusedItem).toBeTruthy(); + expect(dropdown.focusedItem.itemIndex).toEqual(0); + dropdown.navigateNext(); + tick(); + fixture.detectChanges(); + expect(dropdown.focusedItem).toBeTruthy(); + expect(dropdown.focusedItem.itemIndex).toEqual(1); + expect(combo.virtualizationState.startIndex).toEqual(0); + spyOn(dropdown, 'navigatePrev').and.callThrough(); + dropdown.navigatePrev(); + tick(); + expect(dropdown.focusedItem).toBeTruthy(); + expect(dropdown.focusedItem.itemIndex).toEqual(0); + expect(combo.virtualizationState.startIndex).toEqual(0); + expect(dropdown.navigatePrev).toHaveBeenCalledTimes(1); + })); + it('should properly call dropdown navigateNext with virtual items', (async () => { + expect(combo).toBeDefined(); + expect(dropdown).toBeDefined(); + expect(dropdown.focusedItem).toBeFalsy(); + expect(combo.virtualScrollContainer).toBeDefined(); + combo.allowCustomValues = true; + const mockClick = jasmine.createSpyObj('event', ['preventDefault', 'stopPropagation']); + const virtualMockUP = spyOn(dropdown, 'navigatePrev').and.callThrough(); + const virtualMockDOWN = spyOn(dropdown, 'navigateNext').and.callThrough(); + expect(dropdown.focusedItem).toEqual(null); + expect(combo.collapsed).toBeTruthy(); + combo.toggle(); + await wait(); + fixture.detectChanges(); + expect(combo.collapsed).toBeFalsy(); + combo.virtualScrollContainer.scrollTo(51); + await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + fixture.detectChanges(); + const items = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`)); + const lastItem = items[items.length - 1].componentInstance; + expect(lastItem).toBeDefined(); + lastItem.clicked(mockClick); + fixture.detectChanges(); + expect(dropdown.focusedItem).toEqual(lastItem); + dropdown.navigateItem(-1); + fixture.detectChanges(); + expect(virtualMockDOWN).toHaveBeenCalledTimes(0); + lastItem.clicked(mockClick); + fixture.detectChanges(); + expect(dropdown.focusedItem).toEqual(lastItem); + dropdown.navigateNext(); + fixture.detectChanges(); + expect(virtualMockDOWN).toHaveBeenCalledTimes(1); + combo.searchValue = 'New'; + combo.handleInputChange(); + fixture.detectChanges(); + await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + const addItemButton = fixture.debugElement.query(By.directive(IgxComboAddItemComponent)); + addItemButton.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + // After `Add Item` is clicked, the input is focused and the item is added to the list + expect(dropdown.focusedItem).toEqual(null); + expect(document.activeElement).toEqual(combo.searchInput.nativeElement); + expect(combo.customValueFlag).toBeFalsy(); + expect(combo.searchInput.nativeElement.value).toBeTruthy(); + + // TEST move from first item + const firstItem = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`))[0].componentInstance; + firstItem.clicked(mockClick); + fixture.detectChanges(); + expect(dropdown.focusedItem).toEqual(firstItem); + expect(dropdown.focusedItem.itemIndex).toEqual(0); + dropdown.navigateFirst(); + fixture.detectChanges(); + dropdown.navigatePrev(); + fixture.detectChanges(); + // Called once manually and called once more, because item @ index 0 is a header + expect(virtualMockUP).toHaveBeenCalledTimes(2); + expect(dropdown.focusedItem).toBeNull(); + })); + it('should properly get the first focusable item when focusing the component list', () => { + const expectedItemText = 'State: MichiganRegion: East North Central'; + combo.toggle(); + fixture.detectChanges(); + combo.dropdown.onFocus(); + fixture.detectChanges(); + (document.getElementsByClassName(CSS_CLASS_CONTENT)[0] as HTMLElement).focus(); + expect(combo.dropdown.focusedItem.element.nativeElement.textContent.trim()).toEqual(expectedItemText); + }); + it('should focus item when onFocus and onBlur are called', () => { + expect(dropdown.focusedItem).toEqual(null); + dropdown.toggle(); + fixture.detectChanges(); + expect(dropdown.items).toBeDefined(); + expect(dropdown.items.length).toEqual(5); + dropdown.onFocus(); + expect(dropdown.focusedItem).toEqual(dropdown.items[0]); + expect(dropdown.focusedItem.focused).toEqual(true); + dropdown.onFocus(); + dropdown.onBlur(); + expect(dropdown.focusedItem).toEqual(null); + dropdown.onBlur(); + }); + it('should properly handle dropdown.focusItem', fakeAsync(() => { + combo.toggle(); + tick(); + fixture.detectChanges(); + const virtualSpyUP = spyOn(dropdown, 'navigatePrev'); + const virtualSpyDOWN = spyOn(dropdown, 'navigateNext'); + spyOn(IgxComboDropDownComponent.prototype, 'navigateItem').and.callThrough(); + dropdown.navigateItem(0); + fixture.detectChanges(); + expect(IgxComboDropDownComponent.prototype.navigateItem).toHaveBeenCalledTimes(1); + dropdown.navigatePrev(); + expect(IgxComboDropDownComponent.prototype.navigateItem).toHaveBeenCalledTimes(1); + dropdown.navigateItem(dropdown.items.length - 1); + dropdown.navigateNext(); + expect(IgxComboDropDownComponent.prototype.navigateItem).toHaveBeenCalledTimes(2); + expect(virtualSpyDOWN).toHaveBeenCalled(); + expect(virtualSpyUP).toHaveBeenCalled(); + })); + it('should handle keyboard events', fakeAsync(() => { + combo.toggle(); + tick(); + fixture.detectChanges(); + spyOn(combo, 'selectAllItems'); + spyOn(combo, 'toggle'); + spyOn(combo.dropdown, 'onFocus').and.callThrough(); + combo.handleKeyUp(UIInteractions.getKeyboardEvent('keyup', 'A')); + combo.handleKeyUp(UIInteractions.getKeyboardEvent('keyup', null)); + expect(combo.selectAllItems).toHaveBeenCalledTimes(0); + expect(combo.dropdown.onFocus).toHaveBeenCalledTimes(0); + combo.handleKeyUp(UIInteractions.getKeyboardEvent('keyup', 'Enter')); + expect(combo.selectAllItems).toHaveBeenCalledTimes(0); + spyOnProperty(combo, 'filteredData', 'get').and.returnValue([1]); + combo.handleKeyUp(UIInteractions.getKeyboardEvent('keyup', 'Enter')); + expect(combo.selectAllItems).toHaveBeenCalledTimes(0); + combo.handleKeyUp(UIInteractions.getKeyboardEvent('keyup', 'ArrowDown')); + expect(combo.selectAllItems).toHaveBeenCalledTimes(0); + // expect(combo.dropdown.onFocus).toHaveBeenCalledTimes(1); + combo.handleKeyUp(UIInteractions.getKeyboardEvent('keyup', 'Escape')); + expect(combo.toggle).toHaveBeenCalledTimes(1); + })); + it('should toggle combo dropdown on toggle button click', fakeAsync(() => { + spyOn(combo, 'toggle').and.callThrough(); + input.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + tick(); + fixture.detectChanges(); + expect(combo.collapsed).toEqual(false); + expect(combo.toggle).toHaveBeenCalledTimes(1); + expect(document.activeElement).toEqual(combo.searchInput.nativeElement); + + input.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + tick(); + fixture.detectChanges(); + expect(combo.collapsed).toEqual(true); + expect(combo.toggle).toHaveBeenCalledTimes(2); + })); + it('should toggle dropdown list with arrow down/up keys', fakeAsync(() => { + spyOn(combo, 'open').and.callThrough(); + spyOn(combo, 'close').and.callThrough(); + + combo.onArrowDown(UIInteractions.getKeyboardEvent('keydown', 'ArrowDown')); + tick(); + fixture.detectChanges(); + expect(combo.open).toHaveBeenCalledTimes(1); + + combo.onArrowDown(UIInteractions.getKeyboardEvent('keydown', 'ArrowDown', true)); + tick(); + fixture.detectChanges(); + expect(combo.collapsed).toEqual(false); + expect(combo.open).toHaveBeenCalledTimes(2); + + combo.handleKeyDown(UIInteractions.getKeyboardEvent('keydown', 'ArrowUp')); + tick(); + fixture.detectChanges(); + expect(combo.close).toHaveBeenCalledTimes(1); + + combo.handleKeyDown(UIInteractions.getKeyboardEvent('keydown', 'ArrowUp', true)); + fixture.detectChanges(); + tick(); + expect(combo.close).toHaveBeenCalledTimes(2); + })); + it('should select/focus dropdown list items with space/up and down arrow keys', () => { + let selectedItemsCount = 0; + combo.toggle(); + fixture.detectChanges(); + + const dropdownList = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROPDOWNLIST_SCROLL}`)).nativeElement; + const dropdownItems = dropdownList.querySelectorAll(`.${CSS_CLASS_DROPDOWNLISTITEM}`); + const dropdownContent = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTENT}`)); + let focusedItems = dropdownList.querySelectorAll(`.${CSS_CLASS_FOCUSED}`); + let selectedItems = dropdownList.querySelectorAll(`.${CSS_CLASS_SELECTED}`); + expect(focusedItems.length).toEqual(0); + expect(selectedItems.length).toEqual(0); + + const focusAndVerifyItem = (itemIndex: number, key: string) => { + UIInteractions.triggerEventHandlerKeyDown(key, dropdownContent); + fixture.detectChanges(); + focusedItems = dropdownList.querySelectorAll(`.${CSS_CLASS_FOCUSED}`); + expect(focusedItems.length).toEqual(1); + expect(focusedItems[0]).toEqual(dropdownItems[itemIndex]); + }; + + const selectAndVerifyItem = (itemIndex: number) => { + UIInteractions.triggerEventHandlerKeyDown('Space', dropdownContent); + fixture.detectChanges(); + selectedItems = dropdownList.querySelectorAll(`.${CSS_CLASS_SELECTED}`); + expect(selectedItems.length).toEqual(selectedItemsCount); + expect(selectedItems).toContain(dropdownItems[itemIndex]); + }; + + focusAndVerifyItem(0, 'ArrowDown'); + selectedItemsCount++; + selectAndVerifyItem(0); + + for (let index = 1; index < 5; index++) { + focusAndVerifyItem(index, 'ArrowDown'); + } + selectedItemsCount++; + selectAndVerifyItem(4); + + for (let index = 3; index >= 2; index--) { + focusAndVerifyItem(index, 'ArrowUp'); + } + selectedItemsCount++; + selectAndVerifyItem(2); + }); + it('should properly navigate using HOME/END key', (async () => { + let firstVisibleItem: Element; + combo.toggle(); + fixture.detectChanges(); + const dropdownContent = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTENT}`)); + const scrollbar = fixture.debugElement.query(By.css(`.${CSS_CLASS_SCROLLBAR_VERTICAL}`)).nativeElement as HTMLElement; + expect(scrollbar.scrollTop).toEqual(0); + // Scroll to bottom; + UIInteractions.triggerEventHandlerKeyDown('End', dropdownContent); + await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + fixture.detectChanges(); + // Content was scrolled to bottom + expect(scrollbar.scrollHeight - scrollbar.scrollTop).toEqual(scrollbar.clientHeight); + + // Scroll to top + UIInteractions.triggerEventHandlerKeyDown('Home', dropdownContent); + await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + fixture.detectChanges(); + const dropdownContainer: HTMLElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTAINER}`)).nativeElement; + firstVisibleItem = dropdownContainer.querySelector(`.${CSS_CLASS_DROPDOWNLISTITEM}` + ':first-child'); + // Container is scrolled to top + expect(scrollbar.scrollTop).toEqual(32); + + // First item is focused + expect(firstVisibleItem.classList.contains(CSS_CLASS_FOCUSED)).toBeTruthy(); + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', dropdownContent); + fixture.detectChanges(); + firstVisibleItem = dropdownContainer.querySelector(`.${CSS_CLASS_DROPDOWNLISTITEM}` + ':first-child'); + + // Scroll has not change + expect(scrollbar.scrollTop).toEqual(32); + // First item is no longer focused + expect(firstVisibleItem.classList.contains(CSS_CLASS_FOCUSED)).toBeFalsy(); + UIInteractions.triggerEventHandlerKeyDown('Home', dropdownContent); + fixture.detectChanges(); + expect(firstVisibleItem.classList.contains(CSS_CLASS_FOCUSED)).toBeTruthy(); + })); + it('should close the dropdown list on pressing Tab key', fakeAsync(() => { + combo.toggle(); + fixture.detectChanges(); + + const dropdownContent = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTENT}`)); + UIInteractions.triggerEventHandlerKeyDown('Tab', dropdownContent); + tick(); + fixture.detectChanges(); + expect(combo.collapsed).toBeTruthy(); + })); + }); + describe('primitive data dropdown: ', () => { + it('should properly navigate with HOME/END keys when no virtScroll is necessary', async () => { + fixture = TestBed.createComponent(IgxComboInContainerTestComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.combo; + input = fixture.debugElement.query(By.css(`.${CSS_CLASS_INPUTGROUP}`)); + let firstVisibleItem: Element; + combo.toggle(); + fixture.detectChanges(); + const dropdownContent = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTENT}`)); + const scrollbar = fixture.debugElement.query(By.css(`.${CSS_CLASS_SCROLLBAR_VERTICAL}`)) + .nativeElement as HTMLElement; + expect(scrollbar.scrollTop).toEqual(0); + // Scroll to bottom; + UIInteractions.triggerEventHandlerKeyDown('End', dropdownContent); + await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + fixture.detectChanges(); + // Content was scrolled to bottom + expect(scrollbar.scrollHeight - scrollbar.scrollTop).toEqual(scrollbar.clientHeight); + + // Scroll to top + UIInteractions.triggerEventHandlerKeyDown('Home', dropdownContent); + await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + fixture.detectChanges(); + const dropdownContainer: HTMLElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTAINER}`)).nativeElement; + firstVisibleItem = dropdownContainer.querySelector(`.${CSS_CLASS_DROPDOWNLISTITEM}` + ':first-child'); + // Container is scrolled to top + expect(scrollbar.scrollTop).toEqual(0); + + // First item is focused + expect(firstVisibleItem.classList.contains(CSS_CLASS_FOCUSED)).toBeTruthy(); + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', dropdownContent); + fixture.detectChanges(); + firstVisibleItem = dropdownContainer.querySelector(`.${CSS_CLASS_DROPDOWNLISTITEM}` + ':first-child'); + + // Scroll has not change + expect(scrollbar.scrollTop).toEqual(0); + // First item is no longer focused + expect(firstVisibleItem.classList.contains(CSS_CLASS_FOCUSED)).toBeFalsy(); + UIInteractions.triggerEventHandlerKeyDown('Home', dropdownContent); + fixture.detectChanges(); + expect(firstVisibleItem.classList.contains(CSS_CLASS_FOCUSED)).toBeTruthy(); + }); + }); + }); + describe('Virtualization tests: ', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxComboSampleComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.combo; + }); + it('should properly return a reference to the VirtScrollContainer', () => { + expect(combo.dropdown.element).toBeDefined(); + const mockScroll = spyOnProperty(combo.dropdown, 'scrollContainer', 'get').and.callThrough(); + const mockFunc = () => mockScroll(); + expect(mockFunc).toThrow(); + combo.toggle(); + fixture.detectChanges(); + expect(combo.dropdown.element).toBeDefined(); + expect(mockFunc).toBeDefined(); + }); + it('should restore position of dropdown scroll after opening', async () => { + const virtDir = combo.virtualScrollContainer; + spyOn(combo.dropdown, 'onToggleOpening').and.callThrough(); + spyOn(combo.dropdown, 'onToggleOpened').and.callThrough(); + spyOn(combo.dropdown, 'onToggleClosing').and.callThrough(); + spyOn(combo.dropdown, 'onToggleClosed').and.callThrough(); + combo.toggle(); + await wait(); + fixture.detectChanges(); + expect(combo.collapsed).toEqual(false); + expect(combo.dropdown.onToggleOpening).toHaveBeenCalledTimes(1); + expect(combo.dropdown.onToggleOpened).toHaveBeenCalledTimes(1); + let vContainerScrollHeight = virtDir.getScroll().scrollHeight; + expect(virtDir.getScroll().scrollTop).toEqual(0); + const itemHeight = parseFloat(combo.dropdown.children.first.element.nativeElement.getBoundingClientRect().height); + expect(vContainerScrollHeight).toBeGreaterThan(itemHeight); + virtDir.getScroll().scrollTop = Math.floor(vContainerScrollHeight / 2); + await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + fixture.detectChanges(); + expect(virtDir.getScroll().scrollTop).toBeGreaterThan(0); + UIInteractions.simulateClickEvent(document.documentElement); + await wait(); + fixture.detectChanges(); + expect(combo.collapsed).toEqual(true); + expect(combo.dropdown.onToggleClosing).toHaveBeenCalledTimes(1); + expect(combo.dropdown.onToggleClosed).toHaveBeenCalledTimes(1); + combo.toggle(); + await wait(); + fixture.detectChanges(); + expect(combo.collapsed).toEqual(false); + expect(combo.dropdown.onToggleOpening).toHaveBeenCalledTimes(2); + expect(combo.dropdown.onToggleOpened).toHaveBeenCalledTimes(2); + vContainerScrollHeight = virtDir.getScroll().scrollHeight; + expect(virtDir.getScroll().scrollTop).toEqual(vContainerScrollHeight / 2); + }); + it('should display vertical scrollbar properly', () => { + combo.toggle(); + fixture.detectChanges(); + const scrollbarContainer = fixture.debugElement + .query(By.css(`.${CSS_CLASS_SCROLLBAR_VERTICAL}`)) + .nativeElement as HTMLElement; + let hasScrollbar = scrollbarContainer.scrollHeight > scrollbarContainer.clientHeight; + expect(hasScrollbar).toBeTruthy(); + + combo.data = [{ field: 'Mid-Atlantic', region: 'New Jersey' }, { field: 'Mid-Atlantic', region: 'New York' }]; + fixture.detectChanges(); + combo.toggle(); + fixture.detectChanges(); + hasScrollbar = scrollbarContainer.scrollHeight > scrollbarContainer.clientHeight; + expect(hasScrollbar).toBeFalsy(); + }); + it('should preserve selection on scrolling', async () => { + combo.toggle(); + fixture.detectChanges(); + const scrollbar = fixture.debugElement.query(By.css(`.${CSS_CLASS_SCROLLBAR_VERTICAL}`)).nativeElement as HTMLElement; + expect(scrollbar.scrollTop).toEqual(0); + + combo.virtualScrollContainer.scrollTo(12); + await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + fixture.detectChanges(); + let selectedItem = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`))[1]; + selectedItem.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + expect(selectedItem.classes[CSS_CLASS_SELECTED]).toEqual(true); + const selectedItemText = selectedItem.nativeElement.textContent; + + const dropdownContent = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTENT}`)); + UIInteractions.triggerEventHandlerKeyDown('End', dropdownContent); + await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + fixture.detectChanges(); + // Content was scrolled to bottom + expect(scrollbar.scrollHeight - scrollbar.scrollTop).toEqual(scrollbar.clientHeight); + + combo.virtualScrollContainer.scrollTo(4); + await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + fixture.detectChanges(); + selectedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_SELECTED}`)); + expect(selectedItem.nativeElement.textContent).toEqual(selectedItemText); + + combo.toggle(); + await wait(10); + fixture.detectChanges(); + expect(combo.collapsed).toBeTruthy(); + combo.toggle(); + await wait(10); + fixture.detectChanges(); + expect(combo.collapsed).toBeFalsy(); + selectedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_SELECTED}`)); + expect(selectedItem.nativeElement.textContent).toEqual(selectedItemText); + }); + }); + describe('Selection tests: ', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxComboSampleComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.combo; + input = fixture.debugElement.query(By.css(`.${CSS_CLASS_COMBO_INPUTGROUP}`)); + }); + const simulateComboItemClick = (itemIndex: number, isHeader = false) => { + const itemClass = isHeader ? CSS_CLASS_HEADERITEM : CSS_CLASS_DROPDOWNLISTITEM; + const dropdownItem = fixture.debugElement.queryAll(By.css('.' + itemClass))[itemIndex]; + dropdownItem.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + }; + const simulateComboItemCheckboxClick = (itemIndex: number, isHeader = false) => { + const itemClass = isHeader ? CSS_CLASS_HEADERITEM : CSS_CLASS_DROPDOWNLISTITEM; + const dropdownItem = fixture.debugElement.queryAll(By.css('.' + itemClass))[itemIndex]; + const itemCheckbox = dropdownItem.query(By.css('.' + CSS_CLASS_ITEM_CHECKBOX)); + itemCheckbox.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + }; + it('should append/remove selected items to the input in their selection order', () => { + let expectedOutput = 'Illinois'; + combo.select(['Illinois']); + fixture.detectChanges(); + expect(input.nativeElement.value).toEqual(expectedOutput); + + expectedOutput += ', Mississippi'; + combo.select(['Mississippi']); + fixture.detectChanges(); + expect(input.nativeElement.value).toEqual(expectedOutput); + + expectedOutput += ', Ohio'; + combo.select(['Ohio']); + fixture.detectChanges(); + expect(input.nativeElement.value).toEqual(expectedOutput); + + expectedOutput += ', Arkansas'; + combo.select(['Arkansas']); + fixture.detectChanges(); + expect(input.nativeElement.value).toEqual(expectedOutput); + + expectedOutput = 'Illinois, Mississippi, Arkansas'; + combo.deselect(['Ohio']); + fixture.detectChanges(); + expect(input.nativeElement.value).toEqual(expectedOutput); + + expectedOutput += ', Florida'; + combo.select(['Florida'], false); + fixture.detectChanges(); + expect(input.nativeElement.value).toEqual(expectedOutput); + + expectedOutput = 'Mississippi, Arkansas, Florida'; + combo.deselect(['Illinois']); + fixture.detectChanges(); + expect(input.nativeElement.value).toEqual(expectedOutput); + }); + it('should dismiss all selected items by pressing clear button', () => { + const expectedOutput = 'Ohio, Indiana'; + combo.select(['Ohio', 'Indiana']); + fixture.detectChanges(); + expect(input.nativeElement.value).toEqual(expectedOutput); + combo.toggle(); + fixture.detectChanges(); + expect(combo.dropdown.items[1].selected).toBeTruthy(); + expect(combo.dropdown.items[4].selected).toBeTruthy(); + + const clearBtn = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + clearBtn.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + + expect(input.nativeElement.value).toEqual(''); + expect(combo.selection.length).toEqual(0); + expect(combo.value.length).toEqual(0); + combo.toggle(); + fixture.detectChanges(); + expect(combo.dropdown.items[1].selected).toBeFalsy(); + expect(combo.dropdown.items[4].selected).toBeFalsy(); + }); + it('should show/hide clear button after selecting/deselecting items', () => { + // This is a workaround for issue github.com/angular/angular/issues/14235 + // Expecting existing DebugElement toBeFalsy creates circular reference in Jasmine + expect(fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_CLEARBUTTON}`)).length).toBeFalsy(); + + // Open dropdown and select an item + combo.select(['Maryland']); + fixture.detectChanges(); + expect(fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_CLEARBUTTON}`)).length).toEqual(1); + + combo.deselect(['Maryland']); + fixture.detectChanges(); + expect(fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_CLEARBUTTON}`)).length).toEqual(0); + + combo.select(['Oklahome']); + fixture.detectChanges(); + expect(fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_CLEARBUTTON}`)).length).toEqual(1); + + combo.select(['Wisconsin']); + fixture.detectChanges(); + expect(fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_CLEARBUTTON}`)).length).toEqual(1); + + // Clear selected items + const clearButton = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + clearButton.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + fixture.detectChanges(); + expect(fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_CLEARBUTTON}`)).length).toBeFalsy(); + }); + it('should select/deselect item on check/uncheck', () => { + const dropdown = combo.dropdown; + spyOn(combo.selectionChanging, 'emit').and.callThrough(); + combo.toggle(); + fixture.detectChanges(); + + const selectedItem_1 = dropdown.items[1]; + simulateComboItemClick(1); + expect(combo.selection[0]).toEqual(selectedItem_1.value); + expect(combo.value[0]).toEqual(selectedItem_1.value[combo.valueKey]); + expect(selectedItem_1.selected).toBeTruthy(); + expect(selectedItem_1.element.nativeElement.classList.contains(CSS_CLASS_SELECTED)).toBeTruthy(); + expect(combo.selectionChanging.emit).toHaveBeenCalledTimes(1); + expect(combo.selectionChanging.emit).toHaveBeenCalledWith( + { + newValue: [selectedItem_1.value[combo.valueKey]], + oldValue: [], + newSelection: [selectedItem_1.value], + oldSelection: [], + added: [selectedItem_1.value], + removed: [], + event: UIInteractions.getMouseEvent('click'), + owner: combo, + displayText: selectedItem_1.value[combo.valueKey], + cancel: false + }); + + const selectedItem_2 = dropdown.items[4]; + simulateComboItemClick(4); + expect(combo.selection[1]).toEqual(selectedItem_2.value); + expect(combo.value[1]).toEqual(selectedItem_2.value[combo.valueKey]); + expect(selectedItem_2.selected).toBeTruthy(); + expect(selectedItem_2.element.nativeElement.classList.contains(CSS_CLASS_SELECTED)).toBeTruthy(); + expect(combo.selectionChanging.emit).toHaveBeenCalledTimes(2); + expect(combo.selectionChanging.emit).toHaveBeenCalledWith( + { + newValue: [selectedItem_1.value[combo.valueKey], selectedItem_2.value[combo.valueKey]], + oldValue: [selectedItem_1.value[combo.valueKey]], + newSelection: [selectedItem_1.value, selectedItem_2.value], + oldSelection: [selectedItem_1.value], + added: [selectedItem_2.value], + removed: [], + event: UIInteractions.getMouseEvent('click'), + owner: combo, + displayText: selectedItem_1.value[combo.valueKey] + ', ' + selectedItem_2.value[combo.valueKey], + cancel: false + }); + + // Unselecting an item + const unselectedItem = dropdown.items[1]; + simulateComboItemClick(1); + expect(combo.selection.length).toEqual(1); + expect(unselectedItem.selected).toBeFalsy(); + expect(unselectedItem.element.nativeElement.classList.contains(CSS_CLASS_SELECTED)).toBeFalsy(); + expect(combo.selectionChanging.emit).toHaveBeenCalledTimes(3); + expect(combo.selectionChanging.emit).toHaveBeenCalledWith( + { + newValue: [selectedItem_2.value[combo.valueKey]], + oldValue: [selectedItem_1.value[combo.valueKey], selectedItem_2.value[combo.valueKey]], + newSelection: [selectedItem_2.value], + oldSelection: [selectedItem_1.value, selectedItem_2.value], + added: [], + removed: [unselectedItem.value], + event: UIInteractions.getMouseEvent('click'), + owner: combo, + displayText: selectedItem_2.value[combo.valueKey], + cancel: false + }); + }); + it('should toggle combo dropdown on Enter of the focused toggle icon', fakeAsync(() => { + spyOn(combo, 'toggle').and.callThrough(); + const toggleBtn = fixture.debugElement.query(By.css(`.${CSS_CLASS_TOGGLEBUTTON}`)); + + UIInteractions.triggerEventHandlerKeyDown('Enter', toggleBtn); + tick(); + fixture.detectChanges(); + expect(combo.toggle).toHaveBeenCalledTimes(1); + expect(combo.collapsed).toEqual(false); + + UIInteractions.triggerEventHandlerKeyDown('Enter', toggleBtn); + tick(); + fixture.detectChanges(); + expect(combo.toggle).toHaveBeenCalledTimes(2); + expect(combo.collapsed).toEqual(true); + })); + it('should clear the selection on Enter of the focused clear icon', () => { + const selectedItem_1 = combo.dropdown.items[1]; + combo.toggle(); + fixture.detectChanges(); + simulateComboItemClick(1); + expect(combo.selection[0]).toEqual(selectedItem_1.value); + expect(combo.value[0]).toEqual(selectedItem_1.value[combo.valueKey]); + + const clearBtn = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + UIInteractions.triggerEventHandlerKeyDown('Enter', clearBtn); + fixture.detectChanges(); + expect(input.nativeElement.value).toEqual(''); + expect(combo.selection.length).toEqual(0); + expect(combo.value.length).toEqual(0); + }); + it('should not be able to select group header', () => { + spyOn(combo.selectionChanging, 'emit').and.callThrough(); + combo.toggle(); + fixture.detectChanges(); + + simulateComboItemClick(0, true); + expect(combo.selection.length).toEqual(0); + expect(combo.selectionChanging.emit).toHaveBeenCalledTimes(0); + }); + it('should select falsy values except "undefined"', () => { + combo.valueKey = 'value'; + combo.displayKey = 'field'; + combo.data = [ + { field: '0', value: 0 }, + { field: 'false', value: false }, + { field: '', value: '' }, + { field: 'null', value: null }, + { field: 'NaN', value: NaN }, + { field: 'undefined', value: undefined }, + ]; + + combo.open(); + fixture.detectChanges(); + const item1 = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`)); + expect(item1).toBeDefined(); + + item1.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + expect(combo.displayValue).toEqual('0'); + expect(combo.value).toEqual([0]); + expect(combo.selection).toEqual([{ field: '0', value: 0 }]); + + combo.open(); + fixture.detectChanges(); + const item2 = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`))[1]; + expect(item2).toBeDefined(); + + item2.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + expect(combo.displayValue).toEqual('0, false'); + expect(combo.value).toEqual([0, false]); + expect(combo.selection).toEqual([{ field: '0', value: 0 }, { field: 'false', value: false }]); + + combo.open(); + fixture.detectChanges(); + const item3 = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`))[2]; + expect(item3).toBeDefined(); + + item3.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + expect(combo.displayValue).toEqual('0, false, '); + expect(combo.value).toEqual([0, false, '']); + expect(combo.selection).toEqual([{ field: '0', value: 0 }, { field: 'false', value: false }, { field: '', value: '' }]); + + combo.open(); + fixture.detectChanges(); + const item4 = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`))[3]; + expect(item4).toBeDefined(); + + item4.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + expect(combo.displayValue).toEqual('0, false, , null'); + expect(combo.value).toEqual([0, false, '', null]); + expect(combo.selection).toEqual([{ field: '0', value: 0 }, { field: 'false', value: false }, { field: '', value: '' }, { field: 'null', value: null }]); + + combo.open(); + fixture.detectChanges(); + const item5 = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`))[4]; + expect(item5).toBeDefined(); + + item5.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + expect(combo.displayValue).toEqual('0, false, , null, NaN'); + expect(combo.value).toEqual([0, false, '', null, NaN]); + expect(combo.selection).toEqual([{ field: '0', value: 0 }, { field: 'false', value: false }, + { field: '', value: '' }, { field: 'null', value: null }, { field: 'NaN', value: NaN }]); + + combo.open(); + fixture.detectChanges(); + const item6 = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`))[5]; + expect(item6).toBeDefined(); + + item6.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + expect(combo.displayValue).toEqual('0, false, , null, NaN'); + expect(combo.value).toEqual([0, false, '', null, NaN]); + expect(combo.selection).toEqual([{ field: '0', value: 0 }, { field: 'false', value: false }, + { field: '', value: '' }, { field: 'null', value: null }, { field: 'NaN', value: NaN }]); + }); + it('should select falsy values except "undefined" with "writeValue" method', () => { + combo.valueKey = 'value'; + combo.displayKey = 'field'; + combo.data = [ + { field: '0', value: 0 }, + { field: 'false', value: false }, + { field: 'empty', value: '' }, + { field: 'null', value: null }, + { field: 'NaN', value: NaN }, + { field: 'undefined', value: undefined }, + ]; + + combo.writeValue([0]); + expect(combo.selection).toEqual([{ field: '0', value: 0 }]); + expect(combo.value).toEqual([0]); + expect(combo.displayValue).toEqual('0'); + + combo.writeValue([false]); + expect(combo.selection).toEqual([{ field: 'false', value: false }]); + expect(combo.value).toEqual([false]); + expect(combo.displayValue).toEqual('false'); + + combo.writeValue(['']); + expect(combo.selection).toEqual([{ field: 'empty', value: '' }]); + expect(combo.value).toEqual(['']); + expect(combo.displayValue).toEqual('empty'); + + combo.writeValue([null]); + expect(combo.selection).toEqual([{ field: 'null', value: null }]); + expect(combo.value).toEqual([null]); + expect(combo.displayValue).toEqual('null'); + + combo.writeValue([NaN]); + expect(combo.selection).toEqual([{ field: 'NaN', value: NaN }]); + expect(combo.value).toEqual([NaN]); + expect(combo.displayValue).toEqual('NaN'); + + // should not select undefined + combo.writeValue([undefined]); + expect(combo.selection).toEqual([]); + expect(combo.value).toEqual([]); + expect(combo.displayValue).toEqual(''); + }); + it('should select values that have spaces as prefixes/suffixes', fakeAsync(() => { + combo.displayKey = combo.valueKey = 'value'; + combo.data = [ + { value: "Mississippi " } + ]; + const dropdown = combo.dropdown; + + dropdown.toggle(); + tick(); + fixture.detectChanges(); + const dropdownContent = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTENT}`)); + + UIInteractions.simulateTyping('Mississippi ', input); + // combo.searchValue = 'My New Custom Item'; + // combo.handleInputChange(); + fixture.detectChanges(); + + combo.handleKeyUp(UIInteractions.getKeyboardEvent('keyup', 'ArrowDown')); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + UIInteractions.triggerEventHandlerKeyDown('Space', dropdownContent); + tick(); + fixture.detectChanges(); + combo.toggle(); + tick(); + fixture.detectChanges(); + combo.onBlur(); + tick(); + fixture.detectChanges(); + expect(combo.displayValue).toEqual('Mississippi '); + })); + it('should prevent selection when selectionChanging is cancelled', () => { + spyOn(combo.selectionChanging, 'emit').and.callFake((event: IComboSelectionChangingEventArgs) => event.cancel = true); + combo.toggle(); + fixture.detectChanges(); + + const dropdownFirstItem = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`))[0].nativeElement; + const itemCheckbox = dropdownFirstItem.querySelectorAll(`.${CSS_CLASS_ITEM_CHECKBOX}`); + + simulateComboItemCheckboxClick(0); + expect(combo.selection.length).toEqual(0); + expect(itemCheckbox[0].classList.contains(CSS_CLASS_ITME_CHECKBOX_CHECKED)).toBeFalsy(); + + simulateComboItemClick(0); + expect(combo.selection.length).toEqual(0); + expect(itemCheckbox[0].classList.contains(CSS_CLASS_ITME_CHECKBOX_CHECKED)).toBeFalsy(); + }); + it('should prevent registration of remote entries when selectionChanging is cancelled', () => { + fixture = TestBed.createComponent(IgxComboRemoteDataComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.instance; + + spyOn(combo.selectionChanging, 'emit').and.callFake((event: IComboSelectionChangingEventArgs) => event.cancel = true); + combo.toggle(); + fixture.detectChanges(); + + simulateComboItemClick(0); + expect(combo.selection.length).toEqual(0); + expect((combo as any)._remoteSelection[0]).toBeUndefined(); + }); + it('should add predefined selection to the input when data is bound after initialization', fakeAsync(() => { + fixture = TestBed.createComponent(IgxComboBindingDataAfterInitComponent); + fixture.detectChanges(); + input = fixture.debugElement.query(By.css(`.${CSS_CLASS_COMBO_INPUTGROUP}`)); + let expectedOutput = ''; + expect(input.nativeElement.value).toEqual(expectedOutput); + tick(1000); + fixture.detectChanges(); + + expectedOutput = 'One'; + expect(input.nativeElement.value).toEqual(expectedOutput); + })); + it('should display custom displayText on selection/deselection', () => { + combo.valueKey = 'key'; + combo.displayKey = 'value'; + combo.data = [ + { key: 1, value: 'One' }, + { key: 2, value: 'Two' }, + { key: 3, value: 'Three' }, + ]; + + spyOn(combo.selectionChanging, 'emit').and.callFake( + (event: IComboSelectionChangingEventArgs) => event.displayText = `Selected Count: ${event.newSelection.length}`); + + combo.select([1]); + fixture.detectChanges(); + + expect(combo.selection).toEqual([{ key: 1, value: 'One' }]); + expect(combo.value).toEqual([1]); + expect(combo.displayValue).toEqual('Selected Count: 1'); + + combo.deselect([1]); + + expect(combo.selection).toEqual([]); + expect(combo.value).toEqual([]); + expect(combo.displayValue).toEqual('Selected Count: 0'); + }); + it('should handle selection for combo with array type value key correctly - issue #14103', () => { + fixture = TestBed.createComponent(ComboArrayTypeValueKeyComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.combo; + input = fixture.debugElement.query(By.css(`.${CSS_CLASS_COMBO_INPUTGROUP}`)); + const items = fixture.componentInstance.items; + expect(combo).toBeDefined(); + + const selectionSpy = spyOn(combo.selectionChanging, 'emit'); + let expectedResults: IComboSelectionChangingEventArgs = { + newValue: [combo.data[1][combo.valueKey]], + oldValue: [], + newSelection: [combo.data[1]], + oldSelection: [], + added: [combo.data[1]], + removed: [], + event: undefined, + owner: combo, + displayText: `${combo.data[1][combo.displayKey]}`, + cancel: false + }; + + let expectedDisplayText = items[1][combo.displayKey]; + combo.select([fixture.componentInstance.items[1].value]); + fixture.detectChanges(); + + expect(selectionSpy).toHaveBeenCalledWith(expectedResults); + expect(input.nativeElement.value).toEqual(expectedDisplayText); + + expectedDisplayText = `${items[1][combo.displayKey]}, ${items[2][combo.displayKey]}`; + expectedResults = { + newValue: [combo.data[1][combo.valueKey], combo.data[2][combo.valueKey]], + oldValue: [combo.data[1][combo.valueKey]], + newSelection: [combo.data[1], combo.data[2]], + oldSelection: [combo.data[1]], + added: [combo.data[2]], + removed: [], + event: undefined, + owner: combo, + displayText: expectedDisplayText, + cancel: false + }; + + combo.select([items[2].value]); + fixture.detectChanges(); + + expect(selectionSpy).toHaveBeenCalledWith(expectedResults); + expect(input.nativeElement.value).toEqual(expectedDisplayText); + }); + }); + describe('Grouping tests: ', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxComboSampleComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.combo; + input = fixture.debugElement.query(By.css(`.${CSS_CLASS_COMBO_INPUTGROUP}`)); + }); + it('should group items correctly', fakeAsync(() => { + combo.toggle(); + tick(); + fixture.detectChanges(); + expect(combo.groupKey).toEqual('region'); + expect(combo.dropdown.items[0].value.field === combo.data[0].field).toBeFalsy(); + const listItems = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`)); + const listHeaders = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_HEADERITEM}`)); + expect(listItems.length).toBeGreaterThan(0); + expect(listHeaders.length).toBeGreaterThan(0); + expect(listHeaders[0].nativeElement.innerHTML).toContain('East North Central'); + + combo.groupKey = ''; + fixture.detectChanges(); + // First item is regular item + expect(combo.dropdown.items[0].value).toEqual(combo.data[0]); + })); + it('should properly handle click events on disabled/header items', fakeAsync(() => { + spyOn(combo.dropdown, 'selectItem').and.callThrough(); + combo.toggle(); + tick(); + fixture.detectChanges(); + expect(combo.collapsed).toBeFalsy(); + expect(combo.dropdown.headers).toBeDefined(); + expect(combo.dropdown.headers.length).toEqual(2); + (combo.dropdown.headers[0] as IgxComboItemComponent).clicked(null); + fixture.detectChanges(); + + const mockObj = jasmine.createSpyObj('nativeElement', ['focus']); + spyOnProperty(combo.dropdown, 'focusedItem', 'get').and.returnValue({ element: { nativeElement: mockObj } } as IgxDropDownItemBaseDirective); + (combo.dropdown.headers[0] as IgxComboItemComponent).clicked(null); + fixture.detectChanges(); + expect(mockObj.focus).not.toHaveBeenCalled(); // Focus only if `allowItemFocus === true` + + combo.dropdown.items[0].clicked(null); + fixture.detectChanges(); + expect(document.activeElement).toEqual(combo.searchInput.nativeElement); + })); + it('should properly add items to the defaultFallbackGroup', () => { + combo.allowCustomValues = true; + combo.toggle(); + fixture.detectChanges(); + const fallBackGroup = combo.defaultFallbackGroup; + const initialDataLength = combo.data.length + 0; + expect(combo.filteredData.filter((e) => e[combo.groupKey] === undefined)).toEqual([]); + combo.searchValue = 'My Custom Item 1'; + combo.addItemToCollection(); + combo.searchValue = 'My Custom Item 2'; + combo.addItemToCollection(); + combo.searchValue = 'My Custom Item 3'; + combo.addItemToCollection(); + const searchInput = fixture.debugElement.query(By.css(CSS_CLASS_SEARCHINPUT)); + UIInteractions.triggerInputEvent(searchInput, 'My Custom Item'); + fixture.detectChanges(); + expect(combo.data.length).toEqual(initialDataLength + 3); + expect(combo.dropdown.items.length).toEqual(4); // Add Item button is included + expect(combo.dropdown.headers.length).toEqual(1); + expect(combo.dropdown.headers[0].element.nativeElement.innerText).toEqual(fallBackGroup); + }); + it('should sort groups correctly', () => { + combo.groupSortingDirection = SortingDirection.Asc; + combo.toggle(); + fixture.detectChanges(); + expect(combo.dropdown.headers[0].element.nativeElement.innerText).toEqual('East North Central'); + + combo.groupSortingDirection = SortingDirection.Desc; + combo.toggle(); + fixture.detectChanges(); + expect(combo.dropdown.headers[0].element.nativeElement.innerText).toEqual('West South Cent'); + + combo.groupSortingDirection = SortingDirection.None; + combo.toggle(); + fixture.detectChanges(); + expect(combo.dropdown.headers[0].element.nativeElement.innerText).toEqual('New England') + }); + it('should sort groups with diacritics correctly', () => { + combo.data = [ + { field: "Alaska", region: "Méxícó" }, + { field: "California", region: "Méxícó" }, + { field: "Michigan", region: "Ángel" }, + { field: "Ohio", region: "Ángel" }, + { field: "Iowa", region: "México" }, + { field: "Kansas", region: "México" }, + { field: "Wisconsin", region: "Boris" }, + ]; + combo.groupSortingDirection = SortingDirection.Asc; + combo.toggle(); + fixture.detectChanges(); + let headers = combo.dropdown.headers.map(header => header.element.nativeElement.innerText); + expect(headers).toEqual(['Ángel', 'Boris', 'México']); + + combo.groupSortingDirection = SortingDirection.Desc; + combo.toggle(); + fixture.detectChanges(); + headers = combo.dropdown.headers.map(header => header.element.nativeElement.innerText); + expect(headers).toEqual(['Méxícó', 'México', 'Boris']); + + combo.groupSortingDirection = SortingDirection.None; + combo.toggle(); + fixture.detectChanges(); + headers = combo.dropdown.headers.map(header => header.element.nativeElement.innerText); + expect(headers).toEqual(['Méxícó', 'Ángel', 'México']); + }); + }); + describe('Filtering tests: ', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxComboSampleComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.combo; + input = fixture.debugElement.query(By.css(`.${CSS_CLASS_COMBO_INPUTGROUP}`)); + }); + it('should properly get/set filteredData', () => { + combo.toggle(); + fixture.detectChanges(); + const initialData = [...combo.filteredData]; + expect(combo.searchValue).toEqual(''); + + const filterSpy = spyOn(IgxComboFilteringPipe.prototype, 'transform').and.callThrough(); + combo.searchValue = 'New '; + combo.handleInputChange(); + fixture.detectChanges(); + expect(filterSpy).toHaveBeenCalledTimes(1); + expect(combo.filteredData.length).toBeLessThan(initialData.length); + + const firstFilter = [...combo.filteredData]; + combo.searchValue += ' '; + combo.handleInputChange(); + fixture.detectChanges(); + expect(combo.filteredData.length).toBeLessThan(initialData.length); + expect(filterSpy).toHaveBeenCalledTimes(2); + + combo.searchValue = ''; + combo.handleInputChange(); + fixture.detectChanges(); + expect(combo.filteredData.length).toEqual(initialData.length); + expect(combo.filteredData.length).toBeGreaterThan(firstFilter.length); + expect(filterSpy).toHaveBeenCalledTimes(3); + expect(combo.filteredData.length).toEqual(initialData.length); + }); + it('should properly select/deselect filteredData', () => { + combo.toggle(); + fixture.detectChanges(); + const initialData = [...combo.filteredData]; + expect(combo.searchValue).toEqual(''); + + const filterSpy = spyOn(IgxComboFilteringPipe.prototype, 'transform').and.callThrough(); + combo.searchValue = 'New '; + combo.handleInputChange(); + fixture.detectChanges(); + expect(filterSpy).toHaveBeenCalledTimes(1); + expect(combo.filteredData.length).toBeLessThan(initialData.length); + expect(combo.filteredData.length).toEqual(4); + + combo.selectAllItems(); + fixture.detectChanges(); + expect(combo.selection.length).toEqual(4); + + combo.selectAllItems(true); + fixture.detectChanges(); + expect(combo.selection.length).toEqual(51); + + combo.deselectAllItems(); + fixture.detectChanges(); + expect(combo.selection.length).toEqual(47); + + combo.deselectAllItems(true); + fixture.detectChanges(); + expect(combo.selection.length).toEqual(0); + }); + it('should properly handle addItemToCollection calls (Complex data)', () => { + const initialData = [...combo.data]; + expect(combo.searchValue).toEqual(''); + combo.addItemToCollection(); + fixture.detectChanges(); + expect(initialData).toEqual(combo.data); + expect(combo.data.length).toEqual(initialData.length); + combo.searchValue = 'myItem'; + fixture.detectChanges(); + spyOn(combo.addition, 'emit').and.callThrough(); + combo.addItemToCollection(); + fixture.detectChanges(); + expect(initialData.length).toBeLessThan(combo.data.length); + expect(combo.data.length).toEqual(initialData.length + 1); + expect(combo.addition.emit).toHaveBeenCalledTimes(1); + expect(combo.data[combo.data.length - 1]).toEqual({ + field: 'myItem', + region: 'Other' + }); + combo.addition.subscribe((e) => { + e.addedItem.region = 'exampleRegion'; + }); + combo.searchValue = 'myItem2'; + fixture.detectChanges(); + combo.addItemToCollection(); + fixture.detectChanges(); + expect(initialData.length).toBeLessThan(combo.data.length); + expect(combo.data.length).toEqual(initialData.length + 2); + expect(combo.addition.emit).toHaveBeenCalledTimes(2); + expect(combo.data[combo.data.length - 1]).toEqual({ + field: 'myItem2', + region: 'exampleRegion' + }); + combo.toggle(); + fixture.detectChanges(); + expect(combo.collapsed).toEqual(false); + expect(combo.searchInput).toBeDefined(); + combo.searchValue = 'myItem3'; + combo.addItemToCollection(); + fixture.detectChanges(); + expect(initialData.length).toBeLessThan(combo.data.length); + expect(combo.data.length).toEqual(initialData.length + 3); + expect(combo.addition.emit).toHaveBeenCalledTimes(3); + expect(combo.data[combo.data.length - 1]).toEqual({ + field: 'myItem3', + region: 'exampleRegion' + }); + }); + it('should properly handle addItemToCollection calls (Primitive data)', () => { + combo.data = ['Item1', 'Item2', 'Item3']; + combo.groupKey = null; + combo.valueKey = null; + fixture.detectChanges(); + const initialData = [...combo.data]; + expect(combo.searchValue).toEqual(''); + combo.addItemToCollection(); + fixture.detectChanges(); + expect(initialData).toEqual(combo.data); + expect(combo.data.length).toEqual(initialData.length); + combo.searchValue = 'myItem'; + fixture.detectChanges(); + spyOn(combo.addition, 'emit').and.callThrough(); + combo.addItemToCollection(); + fixture.detectChanges(); + expect(initialData.length).toBeLessThan(combo.data.length); + expect(combo.data.length).toEqual(initialData.length + 1); + expect(combo.addition.emit).toHaveBeenCalledTimes(1); + expect(combo.data[combo.data.length - 1]).toEqual('myItem'); + }); + + it('should support filtering strings containing diacritic characters', fakeAsync(() => { + combo.filterFunction = comboIgnoreDiacriticsFilter; + combo.displayKey = null; + combo.valueKey = null; + combo.filteringOptions = { caseSensitive: false, filteringKey: undefined }; + combo.data = ['José', 'Óscar', 'Ángel', 'Germán', 'Niño', 'México', 'Méxícó', 'Mexico', 'Köln', 'München']; + combo.toggle(); + fixture.detectChanges(); + + const searchInput = fixture.debugElement.query(By.css(`input[name="searchInput"]`)); + + const verifyFilteredItems = (term: string, expected: number) => { + UIInteractions.triggerInputEvent(searchInput, term); + fixture.detectChanges(); + const list = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTAINER}`)).nativeElement; + const items = list.querySelectorAll(`.${CSS_CLASS_DROPDOWNLISTITEM}`); + expect(items.length).toEqual(expected); + }; + + verifyFilteredItems('jose', 1); + verifyFilteredItems('mexico', 3); + verifyFilteredItems('o', 6); + verifyFilteredItems('é', 6); + })); + + it('should filter the dropdown items when typing in the search input', fakeAsync(() => { + let dropdownList; + let dropdownItems; + let expectedValues = combo.data.filter(data => data.field.toLowerCase().includes('m')); + + const checkFilteredItems = (listItems: HTMLElement[]) => { + listItems.forEach((el) => { + const itemText: string = el.textContent.trim(); + expect(expectedValues.find(item => 'State: ' + item.field + 'Region: ' + item.region === itemText)).toBeDefined(); + }); + }; + + combo.toggle(); + fixture.detectChanges(); + const searchInput = fixture.debugElement.query(By.css('input[name=\'searchInput\']')); + const verifyFilteredItems = (inputValue: string, expectedItemsNumber) => { + UIInteractions.triggerInputEvent(searchInput, inputValue); + fixture.detectChanges(); + dropdownList = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTAINER}`)).nativeElement; + dropdownItems = dropdownList.querySelectorAll(`.${CSS_CLASS_DROPDOWNLISTITEM}`); + expect(dropdownItems.length).toEqual(expectedItemsNumber); + }; + verifyFilteredItems('M', 4); + + verifyFilteredItems('Mi', 3); + expectedValues = expectedValues.filter(data => data.field.toLowerCase().includes('mi')); + checkFilteredItems(dropdownItems); + + verifyFilteredItems('Mis', 2); + expectedValues = expectedValues.filter(data => data.field.toLowerCase().includes('mis')); + checkFilteredItems(dropdownItems); + + verifyFilteredItems('Mist', 0); + })); + it('should display empty list when the search query does not match any item', () => { + let dropDownContainer: HTMLElement; + let listItems; + combo.toggle(); + fixture.detectChanges(); + + const searchInput = fixture.debugElement.query(By.css(CSS_CLASS_SEARCHINPUT)); + UIInteractions.triggerInputEvent(searchInput, 'P'); + fixture.detectChanges(); + dropDownContainer = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTAINER}`)).nativeElement; + listItems = dropDownContainer.querySelectorAll(`.${CSS_CLASS_DROPDOWNLISTITEM}`); + expect(listItems.length).toEqual(3); + let emptyTemplate = fixture.debugElement.query(By.css('.' + CSS_CLASS_EMPTY)); + expect(emptyTemplate).toBeNull(); + + UIInteractions.triggerInputEvent(searchInput, 'Pat'); + fixture.detectChanges(); + dropDownContainer = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTAINER}`)).nativeElement; + listItems = dropDownContainer.querySelectorAll(`.${CSS_CLASS_DROPDOWNLISTITEM}`); + expect(listItems.length).toEqual(0); + emptyTemplate = fixture.debugElement.query(By.css('.' + CSS_CLASS_EMPTY)); + expect(emptyTemplate).not.toBeNull(); + }); + it('should fire searchInputUpdate event when typing in the search box ', () => { + let timesFired = 0; + spyOn(combo.searchInputUpdate, 'emit').and.callThrough(); + combo.toggle(); + fixture.detectChanges(); + const searchInput = fixture.debugElement.query(By.css(CSS_CLASS_SEARCHINPUT)); + + const verifyOnSearchInputEventIsFired = (inputValue: string) => { + UIInteractions.triggerInputEvent(searchInput, inputValue); + fixture.detectChanges(); + timesFired++; + expect(combo.searchInputUpdate.emit).toHaveBeenCalledTimes(timesFired); + }; + + verifyOnSearchInputEventIsFired('M'); + verifyOnSearchInputEventIsFired('Mi'); + verifyOnSearchInputEventIsFired('Miss'); + verifyOnSearchInputEventIsFired('Misso'); + }); + it('should restore the initial combo dropdown list after clearing the search input', () => { + let dropdownList; + let dropdownItems; + combo.toggle(); + fixture.detectChanges(); + const searchInput = fixture.debugElement.query(By.css(CSS_CLASS_SEARCHINPUT)); + + const verifyFilteredItems = (inputValue: string, + expectedDropdownItemsNumber: number, + expectedFilteredItemsNumber: number) => { + UIInteractions.triggerInputEvent(searchInput, inputValue); + fixture.detectChanges(); + dropdownList = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTAINER}`)).nativeElement; + dropdownItems = dropdownList.querySelectorAll(`.${CSS_CLASS_DROPDOWNLISTITEM}`); + expect(dropdownItems.length).toEqual(expectedDropdownItemsNumber); + expect(combo.filteredData.length).toEqual(expectedFilteredItemsNumber); + }; + + verifyFilteredItems('M', 4, 15); + verifyFilteredItems('Mi', 3, 5); + verifyFilteredItems('M', 4, 15); + combo.filteredData.forEach((item) => expect(combo.data).toContain(item)); + }); + it('should clear the search input and close the dropdown list on pressing ESC key', fakeAsync(() => { + combo.toggle(); + fixture.detectChanges(); + + const searchInput = fixture.debugElement.query(By.css(CSS_CLASS_SEARCHINPUT)); + UIInteractions.triggerInputEvent(searchInput, 'P'); + fixture.detectChanges(); + const dropdownList = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTAINER}`)).nativeElement; + const dropdownItems = dropdownList.querySelectorAll(`.${CSS_CLASS_DROPDOWNLISTITEM}`); + expect(dropdownItems.length).toEqual(3); + + UIInteractions.triggerEventHandlerKeyUp('Escape', searchInput); + tick(); + fixture.detectChanges(); + expect(combo.collapsed).toBeTruthy(); + expect(searchInput.nativeElement.textContent).toEqual(''); + })); + it('should not display group headers when no results are filtered for a group', () => { + const filteredItems: { [index: string]: any } = combo.data.reduce((filteredArray, item) => { + if (item.field.toLowerCase().trim().includes('mi')) { + (filteredArray[item['region']] = filteredArray[item['region']] || []).push(item); + } + return filteredArray; + }, {}); + combo.toggle(); + fixture.detectChanges(); + const searchInput = fixture.debugElement.query(By.css(CSS_CLASS_SEARCHINPUT)); + UIInteractions.triggerInputEvent(searchInput, 'Mi'); + fixture.detectChanges(); + const dropdownList = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTAINER}`)).nativeElement; + const listHeaders: NodeListOf = dropdownList.querySelectorAll(`.${CSS_CLASS_HEADERITEM}`); + expect(listHeaders.length).toEqual(Object.keys(filteredItems).length); + const headers = Array.prototype.map.call(listHeaders, (item) => item.textContent.trim()); + Object.keys(filteredItems).forEach(key => expect(headers).toContain(key)); + }); + it('should dismiss the input text when clear button is being pressed and custom values are enabled', () => { + combo.allowCustomValues = true; + fixture.detectChanges(); + combo.toggle(); + fixture.detectChanges(); + expect(combo.selection).toEqual([]); + expect(combo.displayValue).toEqual(''); + expect(combo.value).toEqual([]); + expect(combo.comboInput.nativeElement.value).toEqual(''); + + combo.searchValue = 'New '; + fixture.detectChanges(); + expect(combo.isAddButtonVisible()).toEqual(true); + const addItemButton = fixture.debugElement.query(By.css(`.${CSS_CLASS_ADDBUTTON}`)); + expect(addItemButton.nativeElement).toBeDefined(); + + addItemButton.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + expect(combo.selection).toEqual([{ field: 'New ', region: 'Other' }]); + expect(combo.value).toEqual(['New ']); + expect(combo.comboInput.nativeElement.value).toEqual('New '); + + const clearButton = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + clearButton.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + expect(combo.selection).toEqual([]); + expect(combo.value).toEqual([]); + expect(combo.comboInput.nativeElement.value).toEqual(''); + }); + it('should remove ADD button when search value matches an already selected item and custom values are enabled ', () => { + combo.allowCustomValues = true; + fixture.detectChanges(); + combo.toggle(); + fixture.detectChanges(); + + let addItemButton = fixture.debugElement.query(By.css(`.${CSS_CLASS_ADDBUTTON}`)); + expect(addItemButton).toEqual(null); + const searchInput = fixture.debugElement.query(By.css(CSS_CLASS_SEARCHINPUT)); + UIInteractions.triggerInputEvent(searchInput, 'New'); + fixture.detectChanges(); + expect(combo.searchValue).toEqual('New'); + addItemButton = fixture.debugElement.query(By.css(`.${CSS_CLASS_ADDBUTTON}`)); + expect(addItemButton === null).toBeFalsy(); + + UIInteractions.triggerInputEvent(searchInput, 'New York'); + fixture.detectChanges(); + expect(combo.searchValue).toEqual('New York'); + addItemButton = fixture.debugElement.query(By.css(`.${CSS_CLASS_ADDBUTTON}`)); + expect(addItemButton).toEqual(null); + }); + it(`should handle enter keydown on "Add Item" properly`, () => { + combo.allowCustomValues = true; + fixture.detectChanges(); + combo.toggle(); + fixture.detectChanges(); + + combo.searchValue = 'My New Custom Item'; + combo.handleInputChange(); + fixture.detectChanges(); + expect(combo.collapsed).toBeFalsy(); + expect(combo.displayValue).toEqual(''); + expect(combo.isAddButtonVisible()).toBeTruthy(); + + combo.handleKeyUp(UIInteractions.getKeyboardEvent('keyup', 'ArrowDown')); + fixture.detectChanges(); + const dropdownContent = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTENT}`)); + UIInteractions.triggerEventHandlerKeyDown('Space', dropdownContent); + fixture.detectChanges(); + expect(combo.collapsed).toBeFalsy(); + expect(combo.displayValue).toEqual(''); + expect(combo.isAddButtonVisible()).toBeTruthy(); + + UIInteractions.triggerEventHandlerKeyDown('Enter', dropdownContent); + fixture.detectChanges(); + expect(combo.collapsed).toBeFalsy(); + expect(combo.displayValue).toEqual('My New Custom Item'); + }); + it(`should handle click on "Add Item" properly`, () => { + combo.allowCustomValues = true; + fixture.detectChanges(); + combo.toggle(); + fixture.detectChanges(); + combo.searchValue = 'My New Custom Item'; + combo.handleInputChange(); + fixture.detectChanges(); + expect(combo.collapsed).toBeFalsy(); + expect(combo.displayValue).toEqual(''); + expect(combo.isAddButtonVisible()).toBeTruthy(); + + combo.handleKeyUp(UIInteractions.getKeyboardEvent('keyup', 'ArrowDown')); + fixture.detectChanges(); + const dropdownContent = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTENT}`)); + UIInteractions.triggerEventHandlerKeyDown('Space', dropdownContent); + fixture.detectChanges(); + // SPACE does not add item to collection + expect(combo.collapsed).toBeFalsy(); + expect(combo.displayValue).toEqual(''); + + const focusedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_FOCUSED}`)); + focusedItem.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + expect(combo.collapsed).toBeFalsy(); + expect(combo.displayValue).toEqual('My New Custom Item'); + }); + it('should enable/disable filtering at runtime', fakeAsync(() => { + combo.open(); // Open combo - all data items are in filteredData + tick(); + fixture.detectChanges(); + expect(combo.dropdown.items.length).toBeGreaterThan(0); + + const searchInput = fixture.debugElement.query(By.css(CSS_CLASS_SEARCHINPUT)); + searchInput.nativeElement.value = 'Not-available item'; + searchInput.triggerEventHandler('input', { target: searchInput.nativeElement }); + tick(); + fixture.detectChanges(); + expect(combo.dropdown.items.length).toEqual(0); // No items are available because of filtering + + combo.close(); // Filter is cleared on close + tick(); + fixture.detectChanges(); + combo.disableFiltering = true; // Filtering is disabled + fixture.detectChanges(); + combo.open(); // All items are visible since filtering is disabled + tick(); + fixture.detectChanges(); + expect(combo.dropdown.items.length).toBeGreaterThan(0); // All items are visible since filtering is disabled + + combo.searchValue = 'Not-available item'; + combo.handleInputChange(); + fixture.detectChanges(); + expect(combo.dropdown.items.length).toBeGreaterThan(0); // All items are visible since filtering is disabled + + combo.close(); // Filter is cleared on close + tick(); + fixture.detectChanges(); + combo.disableFiltering = false; // Filtering is re-enabled + fixture.detectChanges(); + combo.open(); // Filter is cleared on open + tick(); + fixture.detectChanges(); + expect(combo.dropdown.items.length).toBeGreaterThan(0); + })); + it(`should properly display "Add Item" button when filtering is off`, () => { + combo.allowCustomValues = true; + combo.disableFiltering = true; + fixture.detectChanges(); + expect(combo.isAddButtonVisible()).toEqual(false); + + combo.toggle(); + fixture.detectChanges(); + expect(combo.collapsed).toEqual(false); + expect(combo.searchInput.nativeElement.placeholder).toEqual('Add Item'); + const searchInput = fixture.debugElement.query(By.css(CSS_CLASS_SEARCHINPUT)); + UIInteractions.triggerInputEvent(searchInput, combo.data[2].field); + fixture.detectChanges(); + expect(combo.isAddButtonVisible()).toEqual(false); + + UIInteractions.triggerInputEvent(searchInput, combo.searchValue.substring(0, 2)); + fixture.detectChanges(); + expect(combo.isAddButtonVisible()).toEqual(true); + }); + it('should be able to toggle search case sensitivity', () => { + combo.showSearchCaseIcon = true; + fixture.detectChanges(); + combo.toggle(); + fixture.detectChanges(); + + const searchInput = fixture.debugElement.query(By.css('input[name=\'searchInput\']')); + UIInteractions.triggerInputEvent(searchInput, 'M'); + fixture.detectChanges(); + expect([...combo.filteredData]).toEqual(combo.data.filter(e => e['field'].toLowerCase().includes('m'))); + + combo.toggleCaseSensitive(); + fixture.detectChanges(); + expect([...combo.filteredData]).toEqual(combo.data.filter(e => e['field'].includes('M'))); + }); + it('Should NOT filter the data when searchInputUpdate is canceled', () => { + const cancelSub = combo.searchInputUpdate.subscribe((event: IComboSearchInputEventArgs) => event.cancel = true); + combo.toggle(); + fixture.detectChanges(); + const searchInput = fixture.debugElement.query(By.css('input[name=\'searchInput\']')); + UIInteractions.triggerInputEvent(searchInput, 'Test'); + fixture.detectChanges(); + expect(combo.filteredData.length).toEqual(combo.data.length); + expect(combo.searchValue).toEqual('Test'); + cancelSub.unsubscribe(); + }); + it('Should filter the data when custom filterFunction is provided', fakeAsync(() => { + combo.open(); + tick(); + fixture.detectChanges(); + expect(combo.dropdown.items.length).toBeGreaterThan(0); + + combo.searchValue = 'new england'; + combo.handleInputChange(); + fixture.detectChanges(); + expect(combo.dropdown.items.length).toEqual(0); + + combo.close(); + tick(); + fixture.detectChanges(); + combo.filteringOptions = { caseSensitive: false, filteringKey: combo.groupKey }; + combo.filterFunction = (collection: any[], searchValue: any, filteringOptions: IComboFilteringOptions): any[] => { + if (!collection) return []; + if (!searchValue) return collection; + const searchTerm = filteringOptions.caseSensitive ? searchValue.trim() : searchValue.toLowerCase().trim(); + return collection.filter(i => filteringOptions.caseSensitive ? + i[filteringOptions.filteringKey]?.includes(searchTerm) : + i[filteringOptions.filteringKey]?.toString().toLowerCase().includes(searchTerm)) + } + combo.open(); + tick(); + fixture.detectChanges(); + expect(combo.dropdown.items.length).toBeGreaterThan(0); + + combo.searchValue = 'new england'; + combo.handleInputChange(); + fixture.detectChanges(); + expect(combo.dropdown.items.length).toBeGreaterThan(0); + + combo.filterFunction = undefined; + combo.filteringOptions = undefined; + fixture.detectChanges(); + expect(combo.dropdown.items.length).toEqual(0); + })); + it('Should update filtering when custom filterFunction is provided and filteringOptions.caseSensitive is changed', fakeAsync(() => { + combo.open(); + tick(); + fixture.detectChanges(); + expect(combo.dropdown.items.length).toBeGreaterThan(0); + + combo.searchValue = 'new england'; + combo.handleInputChange(); + fixture.detectChanges(); + expect(combo.dropdown.items.length).toEqual(0); + + combo.close(); + tick(); + fixture.detectChanges(); + combo.filteringOptions = { caseSensitive: false, filteringKey: combo.groupKey }; + combo.filterFunction = (collection: any[], searchValue: any, filteringOptions: IComboFilteringOptions): any[] => { + if (!collection) return []; + if (!searchValue) return collection; + const searchTerm = filteringOptions.caseSensitive ? searchValue.trim() : searchValue.toLowerCase().trim(); + return collection.filter(i => filteringOptions.caseSensitive ? + i[filteringOptions.filteringKey]?.includes(searchTerm) : + i[filteringOptions.filteringKey]?.toString().toLowerCase().includes(searchTerm)) + } + combo.open(); + tick(); + fixture.detectChanges(); + expect(combo.dropdown.items.length).toBeGreaterThan(0); + + combo.searchValue = 'new england'; + combo.handleInputChange(); + fixture.detectChanges(); + expect(combo.dropdown.items.length).toBeGreaterThan(0); + + combo.filteringOptions = Object.assign({}, combo.filteringOptions, { caseSensitive: true }); + fixture.detectChanges(); + expect(combo.dropdown.items.length).toEqual(0); + })); + it('Should update filtering when custom filteringOptions are provided', fakeAsync(() => { + combo.open(); + tick(); + fixture.detectChanges(); + expect(combo.dropdown.items.length).toBeGreaterThan(0); + + combo.searchValue = 'new england'; + combo.handleInputChange(); + fixture.detectChanges(); + expect(combo.dropdown.items.length).toEqual(0); + + combo.close(); + tick(); + fixture.detectChanges(); + combo.filteringOptions = { caseSensitive: false, filteringKey: combo.groupKey }; + combo.open(); + tick(); + fixture.detectChanges(); + expect(combo.dropdown.items.length).toBeGreaterThan(0); + + combo.searchValue = 'new england'; + combo.handleInputChange(); + fixture.detectChanges(); + expect(combo.dropdown.items.length).toBeGreaterThan(0); + + combo.searchValue = 'value not in the list'; + combo.handleInputChange(); + fixture.detectChanges(); + expect(combo.dropdown.items.length).toEqual(0); + + combo.disableFiltering = true; + fixture.detectChanges(); + expect(combo.dropdown.items.length).toBeGreaterThan(0); + })); + }); + describe('Form control tests: ', () => { + describe('Reactive form tests: ', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxComboFormComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.combo; + input = fixture.debugElement.query(By.css(`.${CSS_CLASS_INPUTGROUP}`)); + }); + it('should properly initialize when used as a form control', () => { + expect(combo).toBeDefined(); + const comboFormReference = fixture.componentInstance.reactiveForm.controls.townCombo; + expect(comboFormReference).toBeDefined(); + expect(combo.selection).toEqual(comboFormReference.value); + expect(combo.value).toEqual(comboFormReference.value); + expect(combo.selection.length).toEqual(1); + expect(combo.selection[0].field).toEqual('Connecticut'); + expect(combo.valid).toEqual(IgxInputState.INITIAL); + expect(combo.comboInput.valid).toEqual(IgxInputState.INITIAL); + const clearButton = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + clearButton.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + + combo.onBlur(); + fixture.detectChanges(); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + + combo.select([combo.dropdown.items[0], combo.dropdown.items[1]]); + expect(combo.valid).toEqual(IgxInputState.VALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.VALID); + + combo.onBlur(); + fixture.detectChanges(); + expect(combo.valid).toEqual(IgxInputState.INITIAL); + expect(combo.comboInput.valid).toEqual(IgxInputState.INITIAL); + }); + it('should properly initialize when used as a form control - without validators', () => { + const form: UntypedFormGroup = fixture.componentInstance.reactiveForm; + form.controls.townCombo.validator = null; + expect(combo).toBeDefined(); + const comboFormReference = fixture.componentInstance.reactiveForm.controls.townCombo; + expect(comboFormReference).toBeDefined(); + expect(combo.selection).toEqual(comboFormReference.value); + expect(combo.value).toEqual(comboFormReference.value); + expect(combo.selection.length).toEqual(1); + expect(combo.selection[0].field).toEqual('Connecticut'); + expect(combo.valid).toEqual(IgxInputState.INITIAL); + expect(combo.comboInput.valid).toEqual(IgxInputState.INITIAL); + const clearButton = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + clearButton.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + expect(combo.valid).toEqual(IgxInputState.INITIAL); + expect(combo.comboInput.valid).toEqual(IgxInputState.INITIAL); + + combo.onBlur(); + fixture.detectChanges(); + expect(combo.valid).toEqual(IgxInputState.INITIAL); + expect(combo.comboInput.valid).toEqual(IgxInputState.INITIAL); + + combo.select([combo.dropdown.items[0], combo.dropdown.items[1]]); + expect(combo.valid).toEqual(IgxInputState.INITIAL); + expect(combo.comboInput.valid).toEqual(IgxInputState.INITIAL); + + combo.onBlur(); + fixture.detectChanges(); + expect(combo.valid).toEqual(IgxInputState.INITIAL); + expect(combo.comboInput.valid).toEqual(IgxInputState.INITIAL); + }); + it('should be possible to be enabled/disabled when used as a form control', () => { + const form = fixture.componentInstance.reactiveForm; + const comboFormReference = form.controls.townCombo; + expect(comboFormReference).toBeDefined(); + expect(combo.disabled).toBeFalsy(); + expect(comboFormReference.disabled).toBeFalsy(); + spyOn(combo, 'onClick'); + spyOn(combo, 'setDisabledState').and.callThrough(); + combo.comboInput.nativeElement.click(); + fixture.detectChanges(); + expect(combo.onClick).toHaveBeenCalledTimes(1); + combo.comboInput.nativeElement.blur(); + + // Disabling the form disables all of the controls in it + form.disable(); + fixture.detectChanges(); + expect(comboFormReference.disabled).toBeTruthy(); + expect(combo.disabled).toBeTruthy(); + expect(combo.setDisabledState).toHaveBeenCalledTimes(1); + + // Disabled form controls don't handle click events + combo.comboInput.nativeElement.click(); + fixture.detectChanges(); + expect(combo.onClick).toHaveBeenCalledTimes(1); + combo.comboInput.nativeElement.blur(); + + // Can enabling the form re-enables all of the controls in it + form.enable(); + fixture.detectChanges(); + expect(comboFormReference.disabled).toBeFalsy(); + expect(combo.disabled).toBeFalsy(); + }); + it('should change value when addressed as a form control', () => { + expect(combo).toBeDefined(); + const form = fixture.componentInstance.reactiveForm; + const comboFormReference = form.controls.townCombo; + expect(comboFormReference).toBeDefined(); + expect(combo.selection).toEqual(comboFormReference.value); + expect(combo.value).toEqual(comboFormReference.value); + + // Form -> Combo + comboFormReference.setValue([{ field: 'Missouri', region: 'West North Central' }]); + fixture.detectChanges(); + expect(combo.selection).toEqual([{ field: 'Missouri', region: 'West North Central' }]); + expect(combo.value).toEqual([{ field: 'Missouri', region: 'West North Central' }]); + + // Combo -> Form + combo.select([{ field: 'South Carolina', region: 'South Atlantic' }], true); + fixture.detectChanges(); + expect(comboFormReference.value).toEqual([{ field: 'South Carolina', region: 'South Atlantic' }]); + }); + it('should properly submit values when used as a form control', () => { + expect(combo).toBeDefined(); + const form = fixture.componentInstance.reactiveForm; + const comboFormReference = form.controls.townCombo; + expect(comboFormReference).toBeDefined(); + expect(combo.selection).toEqual(comboFormReference.value); + expect(form.status).toEqual('INVALID'); + form.controls.password.setValue('TEST'); + form.controls.firstName.setValue('TEST'); + + spyOn(console, 'log'); + fixture.detectChanges(); + expect(form.status).toEqual('VALID'); + fixture.debugElement.query(By.css('button')).triggerEventHandler('click', UIInteractions.simulateClickAndSelectEvent); + }); + it('should add/remove asterisk when setting validators dynamically', () => { + let inputGroupIsRequiredClass = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUTGROUP_REQUIRED)); + let asterisk = window.getComputedStyle(fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUTGROUP_LABEL)).nativeElement, ':after').content; + expect(asterisk).toBe('"*"'); + expect(inputGroupIsRequiredClass).toBeDefined(); + + fixture.componentInstance.reactiveForm.controls.townCombo.clearValidators(); + fixture.componentInstance.reactiveForm.controls.townCombo.updateValueAndValidity(); + fixture.detectChanges(); + inputGroupIsRequiredClass = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUTGROUP_REQUIRED)); + asterisk = window.getComputedStyle(fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUTGROUP_LABEL)).nativeElement, ':after').content; + expect(asterisk).toBe('none'); + expect(inputGroupIsRequiredClass).toBeNull(); + + fixture.componentInstance.reactiveForm.controls.townCombo.setValidators(Validators.required); + fixture.componentInstance.reactiveForm.controls.townCombo.updateValueAndValidity(); + fixture.detectChanges(); + inputGroupIsRequiredClass = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUTGROUP_REQUIRED)); + asterisk = window.getComputedStyle(fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUTGROUP_LABEL)).nativeElement, ':after').content; + expect(asterisk).toBe('"*"'); + expect(inputGroupIsRequiredClass).toBeDefined(); + }); + + it('Should update validity state when programmatically setting errors on reactive form controls', fakeAsync(() => { + const form = fixture.componentInstance.reactiveForm; + + form.markAllAsTouched(); + form.get('townCombo').setErrors({ error: true }); + fixture.detectChanges(); + + expect((combo as any).comboInput.valid).toBe(IgxInputState.INVALID); + expect((combo as any).inputGroup.element.nativeElement.classList.contains(CSS_CLASS_INPUT_GROUP_INVALID)).toBe(true); + expect((combo as any).inputGroup.element.nativeElement.classList.contains(CSS_CLASS_INPUT_GROUP_REQUIRED)).toBe(true); + + // remove the validators and set errors + form.get('townCombo').clearValidators(); + form.markAsUntouched(); + fixture.detectChanges(); + + form.markAllAsTouched(); + form.get('townCombo').setErrors({ error: true }); + fixture.detectChanges(); + + // no validator, but there is a set error + expect((combo as any).comboInput.valid).toBe(IgxInputState.INVALID); + expect((combo as any).inputGroup.element.nativeElement.classList.contains(CSS_CLASS_INPUT_GROUP_INVALID)).toBe(true); + expect((combo as any).inputGroup.element.nativeElement.classList.contains(CSS_CLASS_INPUT_GROUP_REQUIRED)).toBe(false); + })); + }); + describe('Template form tests: ', () => { + let inputGroupRequired: DebugElement; + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(IgxComboInTemplatedFormComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.testCombo; + input = fixture.debugElement.query(By.css(`${CSS_CLASS_INPUTGROUP} input`)); + inputGroupRequired = fixture.debugElement.query(By.css(`.${CSS_CLASS_INPUTGROUP_REQUIRED}`)); + })); + it('should properly initialize when used in a template form control', () => { + expect(combo.valid).toEqual(IgxInputState.INITIAL); + expect(combo.comboInput.valid).toEqual(IgxInputState.INITIAL); + expect(inputGroupRequired).toBeDefined(); + combo.onBlur(); + fixture.detectChanges(); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + + input.triggerEventHandler('focus', {}); + combo.selectAllItems(); + fixture.detectChanges(); + expect(combo.valid).toEqual(IgxInputState.VALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.VALID); + + const clearButton = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + clearButton.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + }); + it('should properly init with empty array and handle consecutive model changes', fakeAsync(() => { + const model = fixture.debugElement.query(By.directive(NgModel)).injector.get(NgModel); + fixture.componentInstance.values = []; + fixture.detectChanges(); + tick(); + expect(combo.valid).toEqual(IgxInputState.INITIAL); + expect(combo.comboInput.valid).toEqual(IgxInputState.INITIAL); + expect(model.valid).toBeFalse(); + expect(model.dirty).toBeFalse(); + expect(model.touched).toBeFalse(); + + fixture.componentInstance.values = ['Missouri']; + fixture.detectChanges(); + tick(); + expect(combo.valid).toEqual(IgxInputState.INITIAL); + expect(combo.comboInput.valid).toEqual(IgxInputState.INITIAL); + expect(combo.selection).toEqual([{ field: 'Missouri', region: 'West North Central' }]); + expect(combo.value).toEqual(['Missouri']); + expect(combo.displayValue).toEqual('Missouri'); + expect(model.valid).toBeTrue(); + expect(model.touched).toBeFalse(); + + fixture.componentInstance.values = ['Missouri', 'Missouri']; + fixture.detectChanges(); + expect(combo.valid).toEqual(IgxInputState.INITIAL); + expect(combo.comboInput.valid).toEqual(IgxInputState.INITIAL); + expect(combo.selection).toEqual([{ field: 'Missouri', region: 'West North Central' }]); + expect(combo.value).toEqual(['Missouri']); + expect(combo.displayValue).toEqual('Missouri'); + expect(model.valid).toBeTrue(); + expect(model.touched).toBeFalse(); + + fixture.componentInstance.values = null; + fixture.detectChanges(); + tick(); + expect(combo.valid).toEqual(IgxInputState.INITIAL); + expect(combo.comboInput.valid).toEqual(IgxInputState.INITIAL); + expect(combo.selection).toEqual([]); + expect(combo.value).toEqual([]); + expect(combo.displayValue).toEqual(''); + expect(model.valid).toBeFalse(); + expect(model.touched).toBeFalse(); + expect(model.dirty).toBeFalse(); + + combo.onBlur(); + fixture.detectChanges(); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + expect(model.valid).toBeFalse(); + expect(model.touched).toBeTrue(); + expect(model.dirty).toBeFalse(); + + fixture.componentInstance.values = ['New Jersey']; + fixture.detectChanges(); + tick(); + expect(combo.valid).toEqual(IgxInputState.INITIAL); + expect(combo.comboInput.valid).toEqual(IgxInputState.INITIAL); + expect(combo.selection).toEqual([{ field: 'New Jersey', region: 'Mid-Atlan' }]); + expect(combo.value).toEqual(['New Jersey']); + expect(combo.displayValue).toEqual('New Jersey'); + expect(model.valid).toBeTrue(); + expect(model.touched).toBeTrue(); + expect(model.dirty).toBeFalse(); + })); + it('should have correctly bound blur handler', () => { + spyOn(combo, 'onBlur'); + + input.triggerEventHandler('blur', {}); + expect(combo.onBlur).toHaveBeenCalled(); + expect(combo.onBlur).toHaveBeenCalledWith(); + }); + it('should set validity to initial when the form is reset', fakeAsync(() => { + combo.onBlur(); + fixture.detectChanges(); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + + fixture.componentInstance.form.resetForm(); + tick(); + expect(combo.valid).toEqual(IgxInputState.INITIAL); + expect(combo.comboInput.valid).toEqual(IgxInputState.INITIAL); + })); + it('should mark as touched and invalid when combo is focused, dropdown appears, and user clicks away without selection', fakeAsync(() => { + const ngModel = fixture.debugElement.query(By.directive(NgModel)).injector.get(NgModel); + expect(combo.valid).toEqual(IgxInputState.INITIAL); + expect(combo.comboInput.valid).toEqual(IgxInputState.INITIAL); + expect(ngModel.touched).toBeFalse(); + + combo.open(); + input.triggerEventHandler('focus', {}); + fixture.detectChanges(); + expect(ngModel.touched).toBeFalse(); + combo.searchInput.nativeElement.focus(); + fixture.detectChanges(); + const documentClickEvent = new MouseEvent('click', { bubbles: true }); + document.body.dispatchEvent(documentClickEvent); + fixture.detectChanges(); + tick(); + document.body.focus(); + fixture.detectChanges(); + tick(); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + expect(ngModel.touched).toBeTrue(); + })); + }); + }); + }); +}); + +@Component({ + template: ` + + +
+ +
State: {{display[key]}}
+
Region: {{display.region}}
+
+
+ +
This is a header
+
+ + + +
`, + imports: [IgxComboComponent, IgxComboItemDirective, IgxComboHeaderDirective, IgxComboFooterDirective] +}) +class IgxComboSampleComponent { + public elementRef = inject(ElementRef); + + /** + * TODO + * Test that use this component should properly call `selectItems` method + * IF a `valueKey` is defined, calls should be w/ the items' valueKey property value + * IF no `valueKey` is defined, calls should be w/ object references to the items + */ + @ViewChild('combo', { read: IgxComboComponent, static: true }) + public combo: IgxComboComponent; + + public items = []; + public initData = []; + public size = 'medium'; + + constructor() { + + const division = { + 'New England 01': ['Connecticut', 'Maine', 'Massachusetts'], + 'New England 02': ['New Hampshire', 'Rhode Island', 'Vermont'], + 'Mid-Atlantic': ['New Jersey', 'New York', 'Pennsylvania'], + 'East North Central 02': ['Michigan', 'Ohio', 'Wisconsin'], + 'East North Central 01': ['Illinois', 'Indiana'], + 'West North Central 01': ['Missouri', 'Nebraska', 'North Dakota', 'South Dakota'], + 'West North Central 02': ['Iowa', 'Kansas', 'Minnesota'], + 'South Atlantic 01': ['Delaware', 'Florida', 'Georgia', 'Maryland'], + 'South Atlantic 02': ['North Carolina', 'South Carolina', 'Virginia'], + 'South Atlantic 03': ['District of Columbia', 'West Virginia'], + 'East South Central 01': ['Alabama', 'Kentucky'], + 'East South Central 02': ['Mississippi', 'Tennessee'], + 'West South Central': ['Arkansas', 'Louisiana', 'Oklahome', 'Texas'], + Mountain: ['Arizona', 'Colorado', 'Idaho', 'Montana', 'Nevada', 'New Mexico', 'Utah', 'Wyoming'], + 'Pacific 01': ['Alaska', 'California'], + 'Pacific 02': ['Hawaii', 'Oregon', 'Washington'] + }; + const keys = Object.keys(division); + for (const key of keys) { + division[key].map((e) => { + this.items.push({ + field: e, + region: key.substring(0, key.length - 3) + }); + }); + } + + this.initData = this.items; + } + + public selectionChanging() { + } +} + +@Component({ + template: ` +
+

+ + +

+

+ + +

+

+ + + +

+

+ +

+
+ `, + imports: [IgxComboComponent, IgxLabelDirective, ReactiveFormsModule] +}) +class IgxComboFormComponent { + @ViewChild('comboReactive', { read: IgxComboComponent, static: true }) + public combo: IgxComboComponent; + public items = []; + + public get valuesTemplate() { + return this.combo.selection; + } + public set valuesTemplate(values: any[]) { + this.combo.select(values); + } + + public reactiveForm: UntypedFormGroup; + + constructor() { + const fb = inject(UntypedFormBuilder); + + + const division = { + 'New England 01': ['Connecticut', 'Maine', 'Massachusetts'], + 'New England 02': ['New Hampshire', 'Rhode Island', 'Vermont'], + 'Mid-Atlantic': ['New Jersey', 'New York', 'Pennsylvania'], + 'East North Central 02': ['Michigan', 'Ohio', 'Wisconsin'], + 'East North Central 01': ['Illinois', 'Indiana'], + 'West North Central 01': ['Missouri', 'Nebraska', 'North Dakota', 'South Dakota'], + 'West North Central 02': ['Iowa', 'Kansas', 'Minnesota'], + 'South Atlantic 01': ['Delaware', 'Florida', 'Georgia', 'Maryland'], + 'South Atlantic 02': ['North Carolina', 'South Carolina', 'Virginia', 'District of Columbia', 'West Virginia'], + 'South Atlantic 03': ['District of Columbia', 'West Virginia'], + 'East South Central 01': ['Alabama', 'Kentucky'], + 'East South Central 02': ['Mississippi', 'Tennessee'], + 'West South Central': ['Arkansas', 'Louisiana', 'Oklahome', 'Texas'], + Mountain: ['Arizona', 'Colorado', 'Idaho', 'Montana', 'Nevada', 'New Mexico', 'Utah', 'Wyoming'], + 'Pacific 01': ['Alaska', 'California'], + 'Pacific 02': ['Hawaii', 'Oregon', 'Washington'] + }; + const keys = Object.keys(division); + for (const key of keys) { + division[key].map((e) => { + this.items.push({ + field: e, + region: key.substring(0, key.length - 3) + }); + }); + } + + this.reactiveForm = fb.group({ + firstName: new UntypedFormControl('', Validators.required), + password: ['', Validators.required], + townCombo: [[this.items[0]], Validators.required] + }); + } + public onSubmitReactive() { } + + public onSubmitTemplateBased() { } +} + +@Component({ + template: ` +
+ + + +
+ `, + imports: [IgxComboComponent, IgxLabelDirective, FormsModule] +}) +class IgxComboInTemplatedFormComponent { + @ViewChild('testCombo', { read: IgxComboComponent, static: true }) + public testCombo: IgxComboComponent; + @ViewChild('form') + public form: NgForm; + public items: any[] = []; + public values: Array; + + constructor() { + const division = { + 'New England 01': ['Connecticut', 'Maine', 'Massachusetts'], + 'New England 02': ['New Hampshire', 'Rhode Island', 'Vermont'], + 'Mid-Atlantic': ['New Jersey', 'New York', 'Pennsylvania'], + 'East North Central 02': ['Michigan', 'Ohio', 'Wisconsin'], + 'East North Central 01': ['Illinois', 'Indiana'], + 'West North Central 01': ['Missouri', 'Nebraska', 'North Dakota', 'South Dakota'], + 'West North Central 02': ['Iowa', 'Kansas', 'Minnesota'], + 'South Atlantic 01': ['Delaware', 'Florida', 'Georgia', 'Maryland'], + 'South Atlantic 02': ['North Carolina', 'South Carolina', 'Virginia'], + 'South Atlantic 03': ['District of Columbia', 'West Virginia'], + 'East South Central 01': ['Alabama', 'Kentucky'], + 'East South Central 02': ['Mississippi', 'Tennessee'], + 'West South Central': ['Arkansas', 'Louisiana', 'Oklahome', 'Texas'], + Mountain: ['Arizona', 'Colorado', 'Idaho', 'Montana', 'Nevada', 'New Mexico', 'Utah', 'Wyoming'], + 'Pacific 01': ['Alaska', 'California'], + 'Pacific 02': ['Hawaii', 'Oregon', 'Washington'] + }; + const keys = Object.keys(division); + for (const key of keys) { + division[key].map((e) => { + this.items.push({ + field: e, + region: key.substring(0, key.length - 3) + }); + }); + } + } +} +@Injectable() +export class LocalService { + public getData() { + const fakeData = new Observable(obs => { + setTimeout(() => { + obs.next(this.generateData()); + obs.complete(); + }, 3000); + }); + return fakeData; + } + + private generateData() { + const dummyData = []; + for (let i = 1; i <= 20; i++) { + dummyData.push({ id: i, product: 'Product ' + i }); + } + return dummyData; + } +} + +@Component({ + template: ` + + + + `, + providers: [LocalService], + imports: [IgxComboComponent] +}) +export class IgxComboBindingTestComponent { + private localService = inject(LocalService); + + + @ViewChild('combo', { read: IgxComboComponent, static: true }) + public combo: IgxComboComponent; + + public items = []; + constructor() { + this.localService.getData().subscribe( + (data: any[]) => { + this.items = data; + } + ); + } +} +@Component({ + template: ` +
+ + +
+ `, + imports: [IgxComboComponent] +}) +class IgxComboInContainerTestComponent { + @ViewChild('combo', { read: IgxComboComponent, static: true }) + public combo: IgxComboComponent; + + public citiesData: string[] = [ + 'New York', + 'Sofia', + 'Istanbul', + 'Paris', + 'Hamburg', + 'Berlin', + 'London', + 'Oslo', + 'Los Angeles', + 'Rome', + 'Madrid', + 'Ottawa', + 'Prague', + 'Padua', + 'Palermo', + 'Palma de Mallorca']; +} + +@Injectable() +export class RemoteDataService { + public records: Observable; + private _records: BehaviorSubject; + private dataStore: any[]; + private initialData: any[]; + + constructor() { + this.dataStore = []; + this._records = new BehaviorSubject([]); + this.records = this._records.asObservable(); + this.initialData = this.generateInitialData(0, 1000); + } + + public getData(data?: IForOfState, cb?: (any) => void): any { + const size = data.chunkSize === 0 ? 10 : data.chunkSize; + this.dataStore = this.generateData(data.startIndex, data.startIndex + size); + this._records.next(this.dataStore); + const count = 1000; + if (cb) { + cb(count); + } + } + + public generateData(start, end) { + return this.initialData.slice(start, end); + } + + public generateInitialData(start, end) { + const data = []; + for (let i = start; i < end; i++) { + data.push({ id: i, product: 'Product ' + i }); + } + return data; + } +} +@Component({ + template: ` + + + + `, + providers: [RemoteDataService], + imports: [IgxComboComponent, AsyncPipe] +}) +export class IgxComboRemoteDataComponent implements OnInit, AfterViewInit, OnDestroy { + private remoteDataService = inject(RemoteDataService); + public cdr = inject(ChangeDetectorRef); + + @ViewChild('combo', { read: IgxComboComponent, static: true }) + public instance: IgxComboComponent; + public data; + public ngOnInit(): void { + this.data = this.remoteDataService.records; + } + + public ngAfterViewInit() { + this.remoteDataService.getData(this.instance.virtualizationState, (count) => { + this.instance.totalItemCount = count; + this.cdr.detectChanges(); + }); + } + + public dataLoading(evt) { + this.remoteDataService.getData(evt, () => { + this.cdr.detectChanges(); + }); + } + + public ngOnDestroy() { + this.cdr.detach(); + } +} + +@Component({ + template: ``, + imports: [IgxComboComponent, FormsModule] +}) +export class ComboModelBindingComponent implements OnInit { + @ViewChild(IgxComboComponent, { read: IgxComboComponent, static: true }) + public combo: IgxComboComponent; + public items: any[]; + public selectedItems: any[]; + + public ngOnInit() { + this.items = [{ text: 'One', id: 0 }, { text: 'Two', id: 1 }, { text: 'Three', id: 2 }, + { text: 'Four', id: 3 }, { text: 'Five', id: 4 }]; + } +} + +@Component({ + template: ` + `, + imports: [IgxComboComponent, FormsModule] +}) +export class IgxComboBindingDataAfterInitComponent implements AfterViewInit { + private cdr = inject(ChangeDetectorRef); + + public items: any[] = []; + public selectedItems: any[] = [0]; + + public ngAfterViewInit() { + setTimeout(() => { + this.items = [{ text: 'One', id: 0 }, { text: 'Two', id: 1 }, { text: 'Three', id: 2 }, + { text: 'Four', id: 3 }, { text: 'Five', id: 4 }]; + this.cdr.detectChanges(); + }, 1000); + } +} + +@Component({ + template: ` + `, + imports: [IgxComboComponent] +}) +export class ComboArrayTypeValueKeyComponent { + @ViewChild(IgxComboComponent, { read: IgxComboComponent, static: true }) + public combo: IgxComboComponent; + public items: any[] = []; + + constructor() { + this.items = [ + { + item: "Item1", + value: [1, 2, 3] + }, + { + item: "Item2", + value: [4, 5, 6] + }, + { + item: "Item3", + value: [7, 8, 9] + } + ]; + } +} + +@Component({ + template: ` + `, + imports: [IgxComboComponent] +}) +export class ComboWithIdComponent { + @ViewChild(IgxComboComponent, { read: IgxComboComponent, static: true }) + public combo: IgxComboComponent; + public items: any[] = []; + + constructor() { + this.items = [ + { + item: "Item1", + value: "Option1" + }, + { + item: "Item2", + value: "Option2" + }, + { + item: "Item3", + value: "Option3", + } + ]; + } +} diff --git a/projects/igniteui-angular/combo/src/combo/combo.component.ts b/projects/igniteui-angular/combo/src/combo/combo.component.ts new file mode 100644 index 00000000000..5a54c9c7f0f --- /dev/null +++ b/projects/igniteui-angular/combo/src/combo/combo.component.ts @@ -0,0 +1,461 @@ +import { NgClass, NgTemplateOutlet } from '@angular/common'; +import { AfterViewInit, Component, OnInit, OnDestroy, ViewChild, Input, Output, EventEmitter, HostListener, DoCheck, booleanAttribute } from '@angular/core'; + +import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'; + +import { + IBaseEventArgs, + IBaseCancelableEventArgs, + CancelableEventArgs, + EditorProvider +} from 'igniteui-angular/core'; +import { IgxForOfDirective } from 'igniteui-angular/directives'; +import { IgxRippleDirective } from 'igniteui-angular/directives'; +import { IgxButtonDirective } from 'igniteui-angular/directives'; +import { IgxComboItemComponent } from './combo-item.component'; +import { IgxComboDropDownComponent } from './combo-dropdown.component'; +import { IgxComboFilteringPipe, IgxComboGroupingPipe } from './combo.pipes'; +import { IGX_COMBO_COMPONENT, IgxComboBaseDirective } from './combo.common'; +import { IgxComboAddItemComponent } from './combo-add-item.component'; +import { IgxComboAPIService } from './combo.api'; +import { IgxInputGroupComponent, IgxInputDirective, IgxReadOnlyInputDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxDropDownItemNavigationDirective } from 'igniteui-angular/drop-down'; + +/** Event emitted when an igx-combo's selection is changing */ +export interface IComboSelectionChangingEventArgs extends IBaseCancelableEventArgs { + /** An array containing the values that are currently selected */ + oldValue: any[]; + /** An array containing the values that will be selected after this event */ + newValue: any[]; + /** An array containing the items that are currently selected */ + oldSelection: any[]; + /** An array containing the items that will be selected after this event */ + newSelection: any[]; + /** An array containing the items that will be added to the selection (if any) */ + added: any[]; + /** An array containing the items that will be removed from the selection (if any) */ + removed: any[]; + /** The text that will be displayed in the combo text box */ + displayText: string; + /** The user interaction that triggered the selection change */ + event?: Event; +} + +/** Event emitted when the igx-combo's search input changes */ +export interface IComboSearchInputEventArgs extends IBaseCancelableEventArgs { + /** The text that has been typed into the search input */ + searchText: string; +} + +export interface IComboItemAdditionEvent extends IBaseEventArgs, CancelableEventArgs { + oldCollection: any[]; + addedItem: any; + newCollection: any[]; +} + +/** + * When called with sets A & B, returns A - B (as array); + * + * @hidden + */ +const diffInSets = (set1: Set, set2: Set): any[] => { + const results = []; + set1.forEach(entry => { + if (!set2.has(entry)) { + results.push(entry); + } + }); + return results; +}; + +/** + * Represents a drop-down list that provides editable functionalities, allowing users to choose an option from a predefined list. + * + * @igxModule IgxComboModule + * @igxTheme igx-combo-theme + * @igxKeywords combobox, combo selection + * @igxGroup Grids & Lists + * + * @remarks + * It provides the ability to filter items as well as perform selection with the provided data. + * Additionally, it exposes keyboard navigation and custom styling capabilities. + * @example + * ```html + * + * + * ``` + */ +@Component({ + selector: 'igx-combo', + templateUrl: 'combo.component.html', + providers: [ + IgxComboAPIService, + { provide: IGX_COMBO_COMPONENT, useExisting: IgxComboComponent }, + { provide: NG_VALUE_ACCESSOR, useExisting: IgxComboComponent, multi: true } + ], + imports: [ + NgTemplateOutlet, + NgClass, + FormsModule, + IgxInputGroupComponent, + IgxInputDirective, + IgxSuffixDirective, + IgxIconComponent, + IgxComboDropDownComponent, + IgxDropDownItemNavigationDirective, + IgxForOfDirective, + IgxComboItemComponent, + IgxComboAddItemComponent, + IgxButtonDirective, + IgxRippleDirective, + IgxReadOnlyInputDirective, + IgxComboFilteringPipe, + IgxComboGroupingPipe + ] +}) +export class IgxComboComponent extends IgxComboBaseDirective implements AfterViewInit, ControlValueAccessor, OnInit, + OnDestroy, DoCheck, EditorProvider { + /** + * Whether the combo's search box should be focused after the dropdown is opened. + * When `false`, the combo's list item container will be focused instead + */ + @Input({ transform: booleanAttribute }) + public autoFocusSearch = true; + + + /** + * Defines the placeholder value for the combo dropdown search field + * + * @deprecated in version 18.2.0. Replaced with values in the localization resource strings. + * + * ```typescript + * // get + * let myComboSearchPlaceholder = this.combo.searchPlaceholder; + * ``` + * + * ```html + * + * + * ``` + */ + @Input() + public searchPlaceholder: string; + + /** + * Emitted when item selection is changing, before the selection completes + * + * ```html + * + * ``` + */ + @Output() + public selectionChanging = new EventEmitter(); + + /** @hidden @internal */ + @ViewChild(IgxComboDropDownComponent, { static: true }) + public dropdown: IgxComboDropDownComponent; + + /** @hidden @internal */ + public get filteredData(): any[] | null { + return this.disableFiltering ? this.data : this._filteredData; + } + /** @hidden @internal */ + public set filteredData(val: any[] | null) { + this._filteredData = this.groupKey ? (val || []).filter((e) => e.isHeader !== true) : val; + this.checkMatch(); + } + + protected _prevInputValue = ''; + + private _displayText: string; + + constructor() { + super(); + this.comboAPI.register(this); + } + + @HostListener('keydown.ArrowDown', ['$event']) + @HostListener('keydown.Alt.ArrowDown', ['$event']) + public onArrowDown(event: Event) { + event.preventDefault(); + event.stopPropagation(); + this.open(); + } + + /** @hidden @internal */ + public get displaySearchInput(): boolean { + return !this.disableFiltering || this.allowCustomValues; + } + + /** + * @hidden @internal + */ + public handleKeyUp(event: KeyboardEvent): void { + // TODO: use PlatformUtil for keyboard navigation + if (event.key === 'ArrowDown' || event.key === 'Down') { + this.dropdown.focusedItem = this.dropdown.items[0]; + this.dropdownContainer.nativeElement.focus(); + } else if (event.key === 'Escape' || event.key === 'Esc') { + this.toggle(); + } + } + + /** + * @hidden @internal + */ + public handleSelectAll(evt) { + if (evt.checked) { + this.selectAllItems(); + } else { + this.deselectAllItems(); + } + } + + /** + * @hidden @internal + */ + public writeValue(value: any[]): void { + const selection = Array.isArray(value) ? value.filter(x => x !== undefined) : []; + const oldSelection = this.selection; + this.selectionService.select_items(this.id, selection, true); + this.cdr.markForCheck(); + this._displayValue = this.createDisplayText(this.selection, oldSelection); + this._value = this.valueKey ? this.selection.map(item => item[this.valueKey]) : this.selection; + } + + /** @hidden @internal */ + public ngDoCheck(): void { + if (this.data?.length && this.selection.length) { + this._displayValue = this._displayText || this.createDisplayText(this.selection, []); + this._value = this.valueKey ? this.selection.map(item => item[this.valueKey]) : this.selection; + } + } + + /** + * @hidden + */ + public getEditElement(): HTMLElement { + return this.comboInput.nativeElement; + } + + /** + * @hidden @internal + */ + public get context(): any { + return { + $implicit: this + }; + } + + /** + * @hidden @internal + */ + public clearInput(event: Event): void { + this.deselectAllItems(true, event); + if (this.collapsed) { + this.getEditElement().focus(); + } else { + this.focusSearchInput(true); + } + event.stopPropagation(); + } + + /** + * @hidden @internal + */ + public handleClearItems(event: Event): void { + if (this.disabled) { + return; + } + this.clearInput(event); + } + + /** + * @hidden @internal + */ + public handleClearKeyDown(eventArgs: KeyboardEvent) { + if (eventArgs.key === 'Enter' || eventArgs.key === ' ') { + eventArgs.preventDefault(); + this.clearInput(eventArgs); + } + } + + /** + * Select defined items + * + * @param newItems new items to be selected + * @param clearCurrentSelection if true clear previous selected items + * ```typescript + * this.combo.select(["New York", "New Jersey"]); + * ``` + */ + public select(newItems: Array, clearCurrentSelection?: boolean, event?: Event) { + if (newItems) { + const newSelection = this.selectionService.add_items(this.id, newItems, clearCurrentSelection); + this.setSelection(newSelection, event); + } + } + + /** + * Deselect defined items + * + * @param items items to deselected + * ```typescript + * this.combo.deselect(["New York", "New Jersey"]); + * ``` + */ + public deselect(items: Array, event?: Event) { + if (items) { + const newSelection = this.selectionService.delete_items(this.id, items); + this.setSelection(newSelection, event); + } + } + + /** + * Select all (filtered) items + * + * @param ignoreFilter if set to true, selects all items, otherwise selects only the filtered ones. + * ```typescript + * this.combo.selectAllItems(); + * ``` + */ + public selectAllItems(ignoreFilter?: boolean, event?: Event) { + const allVisible = this.selectionService.get_all_ids(ignoreFilter ? this.data : this.filteredData, this.valueKey); + const newSelection = this.selectionService.add_items(this.id, allVisible); + this.setSelection(newSelection, event); + } + + /** + * Deselect all (filtered) items + * + * @param ignoreFilter if set to true, deselects all items, otherwise deselects only the filtered ones. + * ```typescript + * this.combo.deselectAllItems(); + * ``` + */ + public deselectAllItems(ignoreFilter?: boolean, event?: Event): void { + let newSelection = this.selectionService.get_empty(); + if (this.filteredData.length !== this.data.length && !ignoreFilter) { + newSelection = this.selectionService.delete_items(this.id, this.selectionService.get_all_ids(this.filteredData, this.valueKey)); + } + this.setSelection(newSelection, event); + } + + /** + * Selects/Deselects a single item + * + * @param itemID the itemID of the specific item + * @param select If the item should be selected (true) or deselected (false) + * + * Without specified valueKey; + * ```typescript + * this.combo.valueKey = null; + * const items: { field: string, region: string}[] = data; + * this.combo.setSelectedItem(items[0], true); + * ``` + * With specified valueKey; + * ```typescript + * this.combo.valueKey = 'field'; + * const items: { field: string, region: string}[] = data; + * this.combo.setSelectedItem('Connecticut', true); + * ``` + */ + public setSelectedItem(itemID: any, select = true, event?: Event): void { + if (itemID === undefined) { + return; + } + if (select) { + this.select([itemID], false, event); + } else { + this.deselect([itemID], event); + } + } + + /** @hidden @internal */ + public handleOpened() { + this.triggerCheck(); + + // Disabling focus of the search input should happen only when drop down opens. + // During keyboard navigation input should receive focus, even the autoFocusSearch is disabled. + // That is why in such cases focusing of the dropdownContainer happens outside focusSearchInput method. + if (this.autoFocusSearch) { + this.focusSearchInput(true); + } else { + this.dropdownContainer.nativeElement.focus(); + } + this.opened.emit({ owner: this }); + } + + /** @hidden @internal */ + public focusSearchInput(opening?: boolean): void { + if (this.displaySearchInput && this.searchInput) { + this.searchInput.nativeElement.focus(); + } else { + if (opening) { + this.dropdownContainer.nativeElement.focus(); + } else { + this.comboInput.nativeElement.focus(); + this.toggle(); + } + } + } + + protected setSelection(selection: Set, event?: Event): void { + const currentSelection = this.selectionService.get(this.id); + const removed = this.convertKeysToItems(diffInSets(currentSelection, selection)); + const added = this.convertKeysToItems(diffInSets(selection, currentSelection)); + const newValue = Array.from(selection); + const oldValue = Array.from(currentSelection || []); + const newSelection = this.convertKeysToItems(newValue); + const oldSelection = this.convertKeysToItems(oldValue); + const displayText = this.createDisplayText(this.convertKeysToItems(newValue), oldValue); + const args: IComboSelectionChangingEventArgs = { + newValue, + oldValue, + newSelection, + oldSelection, + added, + removed, + event, + owner: this, + displayText, + cancel: false + }; + this.selectionChanging.emit(args); + if (!args.cancel) { + this.selectionService.select_items(this.id, args.newValue, true); + this._value = args.newValue; + if (displayText !== args.displayText) { + this._displayValue = this._displayText = args.displayText; + } else { + this._displayValue = this.createDisplayText(this.selection, args.oldSelection); + } + this._onChangeCallback(args.newValue); + } else if (this.isRemote) { + this.registerRemoteEntries(diffInSets(selection, currentSelection), false); + } + } + + protected createDisplayText(newSelection: any[], oldSelection: any[]) { + const selection = this.valueKey ? newSelection.map(item => item[this.valueKey]) : newSelection; + return this.isRemote + ? this.getRemoteSelection(selection, oldSelection) + : this.concatDisplayText(newSelection); + } + + protected getSearchPlaceholderText(): string { + return this.searchPlaceholder || + (this.disableFiltering ? this.resourceStrings.igx_combo_addCustomValues_placeholder : this.resourceStrings.igx_combo_filter_search_placeholder); + } + + /** Returns a string that should be populated in the combo's text box */ + private concatDisplayText(selection: any[]): string { + const value = this.displayKey !== null && this.displayKey !== undefined ? + selection.map(entry => entry[this.displayKey]).join(', ') : + selection.join(', '); + return value; + } +} diff --git a/projects/igniteui-angular/combo/src/combo/combo.directives.ts b/projects/igniteui-angular/combo/src/combo/combo.directives.ts new file mode 100644 index 00000000000..c3b1f674e88 --- /dev/null +++ b/projects/igniteui-angular/combo/src/combo/combo.directives.ts @@ -0,0 +1,180 @@ +import { Directive } from '@angular/core'; + +/** + * Allows a custom element to be added at the beginning of the combo list. + * + * @igxModule IgxComboModule + * @igxTheme igx-combo-theme + * @igxKeywords combobox, combo selection + * @igxGroup Grids & Lists + * + * @example + * + * + *
Custom header
+ * + *
+ *
+ */ +@Directive({ + selector: '[igxComboHeader]', + standalone: true +}) +export class IgxComboHeaderDirective { } + +/** + * Allows a custom element to be added at the end of the combo list. + * + * @igxModule IgxComboModule + * @igxTheme igx-combo-theme + * @igxKeywords combobox, combo selection + * @igxGroup Grids & Lists + * + * @example + * + * + * + * + * + * + */ +@Directive({ + selector: '[igxComboFooter]', + standalone: true +}) +export class IgxComboFooterDirective { } + +/** + * Allows the combo's items to be modified with a custom template + * + * @igxModule IgxComboModule + * @igxTheme igx-combo-theme + * @igxKeywords combobox, combo selection + * @igxGroup Grids & Lists + * + * @example + * + * + *
+ * State: {{ display[key] }} + * Region: {{ display.region }} + *
+ *
+ *
+ */ +@Directive({ + selector: '[igxComboItem]', + standalone: true +}) +export class IgxComboItemDirective { } + +/** + * Defines the custom template that will be displayed when the combo's list is empty + * + * @igxModule IgxComboModule + * @igxTheme igx-combo-theme + * @igxKeywords combobox, combo selection + * @igxGroup Grids & Lists + * + * @example + * + * + *
+ * There are no items to display + *
+ *
+ *
+ */ +@Directive({ + selector: '[igxComboEmpty]', + standalone: true +}) +export class IgxComboEmptyDirective { } + +/** + * Defines the custom template that will be used when rendering header items for groups in the combo's list + * + * @igxModule IgxComboModule + * @igxTheme igx-combo-theme + * @igxKeywords combobox, combo selection + * @igxGroup Grids & Lists + * + * @example + * + * + *
Group header for {{ item[key] }}
+ *
+ *
+ */ +@Directive({ + selector: '[igxComboHeaderItem]', + standalone: true +}) +export class IgxComboHeaderItemDirective { } + +/** + * Defines the custom template that will be used to display the `ADD` button + * + * @remarks To show the `ADD` button, the `allowCustomValues` option must be enabled + * + * @igxModule IgxComboModule + * @igxTheme igx-combo-theme + * @igxKeywords combobox, combo selection + * @igxGroup Grids & Lists + * + * @example + * + * + * + * + * + */ +@Directive({ + selector: '[igxComboAddItem]', + standalone: true +}) +export class IgxComboAddItemDirective { } + +/** + * The custom template that will be used when rendering the combo's toggle button + * + * @igxModule IgxComboModule + * @igxTheme igx-combo-theme + * @igxKeywords combobox, combo selection + * @igxGroup Grids & Lists + * + * @example + * + * + * {{ collapsed ? 'remove_circle' : 'remove_circle_outline'}} + * + * + */ +@Directive({ + selector: '[igxComboToggleIcon]', + standalone: true +}) +export class IgxComboToggleIconDirective { } + +/** + * Defines the custom template that will be used when rendering the combo's clear icon + * + * @igxModule IgxComboModule + * @igxTheme igx-combo-theme + * @igxKeywords combobox, combo selection + * @igxGroup Grids & Lists + * + * @example + * + * + * clear + * + * + */ +@Directive({ + selector: '[igxComboClearIcon]', + standalone: true +}) +export class IgxComboClearIconDirective { } diff --git a/projects/igniteui-angular/combo/src/combo/combo.module.ts b/projects/igniteui-angular/combo/src/combo/combo.module.ts new file mode 100644 index 00000000000..16c3036eac5 --- /dev/null +++ b/projects/igniteui-angular/combo/src/combo/combo.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { IGX_COMBO_DIRECTIVES } from './public_api'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + ...IGX_COMBO_DIRECTIVES + ], + exports: [ + ...IGX_COMBO_DIRECTIVES + ] +}) +export class IgxComboModule { } diff --git a/projects/igniteui-angular/combo/src/combo/combo.pipes.ts b/projects/igniteui-angular/combo/src/combo/combo.pipes.ts new file mode 100644 index 00000000000..8550301598e --- /dev/null +++ b/projects/igniteui-angular/combo/src/combo/combo.pipes.ts @@ -0,0 +1,118 @@ +import { Pipe, PipeTransform, inject } from '@angular/core'; +import { IComboFilteringOptions, IgxComboBase, IGX_COMBO_COMPONENT } from './combo.common'; +import { SortingDirection } from 'igniteui-angular/core'; + +/** @hidden */ +@Pipe({ + name: 'comboFiltering', + standalone: true +}) +export class IgxComboFilteringPipe implements PipeTransform { + public transform( + collection: any[], + searchValue: any, + displayKey: any, + filteringOptions: IComboFilteringOptions, + filterFunction: (collection: any[], searchValue: any, filteringOptions: IComboFilteringOptions) => any[] = defaultFilterFunction, + disableFiltering: boolean = false) { + if (!collection) { + return []; + } + if (disableFiltering) { + return collection; + } + filteringOptions.filteringKey = filteringOptions.filteringKey ?? displayKey; + return filterFunction(collection, searchValue, filteringOptions); + } +} + +/** @hidden */ +@Pipe({ + name: 'comboGrouping', + standalone: true +}) +export class IgxComboGroupingPipe implements PipeTransform { + public combo = inject(IGX_COMBO_COMPONENT); + + + public transform(collection: any[], groupKey: any, valueKey: any, sortingDirection: SortingDirection, compareCollator: Intl.Collator) { + // TODO: should filteredData be changed here? + this.combo.filteredData = collection; + if ((!groupKey && groupKey !== 0) || !collection.length) { + return collection; + } + const groups = Object.entries(groupBy(collection, (item) => item[groupKey] ?? 'Other')); + if (sortingDirection !== SortingDirection.None) { + const reverse = sortingDirection === SortingDirection.Desc ? -1 : 1; + groups.sort((a,b) => { + return compareCollator.compare(a[0], b[0]) * reverse; + }); + } + const result = groups.flatMap(([_, items]) => { + items.unshift({ + isHeader: true, + [valueKey]: items[0][groupKey], + [groupKey]: items[0][groupKey] + }) + return items; + }); + return result; + } +} + +function defaultFilterFunction(collection: T[], searchValue: string, filteringOptions: IComboFilteringOptions): T[] { + if (!searchValue) { + return collection; + } + + const { caseSensitive, filteringKey } = filteringOptions; + const term = caseSensitive ? searchValue : searchValue.toLowerCase(); + + return collection.filter(item => { + const str = filteringKey ? `${item[filteringKey]}` : `${item}`; + return (caseSensitive ? str : str.toLowerCase()).includes(term); + }); +} + +function normalizeString(str: string, caseSensitive = false): string { + return (caseSensitive ? str : str.toLocaleLowerCase()) + .normalize('NFKD') + .replace(/\p{M}/gu, ''); +} + +function groupBy(data: T[], key: keyof T | ((item: T) => any)) { + const result: Record = {}; + const _get = typeof key === 'function' ? key : (item: T) => item[key]; + + for (const item of data) { + const category = _get(item); + const group = result[category]; + + Array.isArray(group) ? group.push(item) : (result[category] = [item]); + } + + return result; +} + +/** + * Combo filter function which does not distinguish between accented letters and their base letters. + * For example, when filtering for "resume", this function will match both "resume" and "résumé". + * + * @example + * ```html + * + * ``` + */ +export function comboIgnoreDiacriticsFilter(collection: T[], searchValue: string, filteringOptions: IComboFilteringOptions): T[] { + if (!searchValue) { + return collection; + } + + const { caseSensitive, filteringKey } = filteringOptions; + const term = normalizeString(searchValue, caseSensitive); + + return collection.filter(item => { + const str = filteringKey ? `${item[filteringKey]}` : `${item}`; + return normalizeString(str, caseSensitive).includes(term); + }); +} diff --git a/projects/igniteui-angular/combo/src/combo/public_api.ts b/projects/igniteui-angular/combo/src/combo/public_api.ts new file mode 100644 index 00000000000..2b764ffb017 --- /dev/null +++ b/projects/igniteui-angular/combo/src/combo/public_api.ts @@ -0,0 +1,39 @@ +import { IgxHintDirective, IgxLabelDirective, IgxPrefixDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; +import { IgxComboComponent } from './combo.component'; +import { + IgxComboAddItemDirective, + IgxComboClearIconDirective, + IgxComboEmptyDirective, + IgxComboFooterDirective, + IgxComboHeaderDirective, + IgxComboHeaderItemDirective, + IgxComboItemDirective, + IgxComboToggleIconDirective +} from './combo.directives'; + +export * from './combo.api'; +export * from './combo.common'; +export * from './combo.component'; +export * from './combo.directives'; +export * from './combo.pipes'; +export * from './combo-add-item.component'; +export * from './combo-dropdown.component' +export * from './combo-item.component'; +export { comboIgnoreDiacriticsFilter } from './combo.pipes'; + +/* NOTE: Combo directives collection for ease-of-use import in standalone components scenario */ +export const IGX_COMBO_DIRECTIVES = [ + IgxComboComponent, + IgxComboAddItemDirective, + IgxComboClearIconDirective, + IgxComboEmptyDirective, + IgxComboFooterDirective, + IgxComboHeaderDirective, + IgxComboHeaderItemDirective, + IgxComboItemDirective, + IgxComboToggleIconDirective, + IgxLabelDirective, + IgxPrefixDirective, + IgxSuffixDirective, + IgxHintDirective +] as const; diff --git a/projects/igniteui-angular/combo/src/public_api.ts b/projects/igniteui-angular/combo/src/public_api.ts new file mode 100644 index 00000000000..cd5fd468c9a --- /dev/null +++ b/projects/igniteui-angular/combo/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './combo/public_api'; +export * from './combo/combo.module'; diff --git a/projects/igniteui-angular/core/README.md b/projects/igniteui-angular/core/README.md new file mode 100644 index 00000000000..0428c60f4ab --- /dev/null +++ b/projects/igniteui-angular/core/README.md @@ -0,0 +1,25 @@ +# Core + +Core utilities, services, data operations, and common types for Ignite UI for Angular. + +This entry point provides: +- Core utilities and helper functions +- Data operations (filtering, sorting, grouping) +- Overlay and interaction services +- Common types and interfaces + +## Additional Documentation + +### Core Utilities +- [Styles](src/core/styles/README.md) + - [Typography](src/core/styles/typography/README.md) + +### Data Operations +- [Data Utilities](src/data-operations/README-DATAUTIL.md) +- [Data Container](src/data-operations/README-DATACONTAINER.md) + +### Services +- [Overlay](src/services/overlay/README.md) + - [Position Strategies](src/services/overlay/position/README.md) + - [Scroll Strategies](src/services/overlay/scroll/README.md) +- [Transaction](src/services/transaction/README.md) diff --git a/projects/igniteui-angular/core/index.ts b/projects/igniteui-angular/core/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/core/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/core/ng-package.json b/projects/igniteui-angular/core/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/core/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/core/src/core/dates.ts b/projects/igniteui-angular/core/src/core/dates.ts new file mode 100644 index 00000000000..fe73de03bcd --- /dev/null +++ b/projects/igniteui-angular/core/src/core/dates.ts @@ -0,0 +1 @@ +export * from './dates/dateRange'; diff --git a/projects/igniteui-angular/core/src/core/dates/dateRange.ts b/projects/igniteui-angular/core/src/core/dates/dateRange.ts new file mode 100644 index 00000000000..b93b7307fe1 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/dates/dateRange.ts @@ -0,0 +1,13 @@ +export interface DateRangeDescriptor { + type: DateRangeType; + dateRange?: Date[]; +} + +export enum DateRangeType { + After, + Before, + Between, + Specific, + Weekdays, + Weekends +} diff --git a/projects/igniteui-angular/core/src/core/edit-provider.ts b/projects/igniteui-angular/core/src/core/edit-provider.ts new file mode 100644 index 00000000000..18341c6f3e0 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/edit-provider.ts @@ -0,0 +1,18 @@ +import { InjectionToken } from "@angular/core"; + +/** + * Used for editor control components + * + * @hidden + */ +export interface EditorProvider { + /** Return the focusable native element */ + getEditElement(): HTMLElement; +} + +/** + * Injection token is used to inject the EditorProvider token into components + * + * @hidden @internal + */ +export const EDITOR_PROVIDER = new InjectionToken('EditorProvider'); diff --git a/projects/igniteui-angular/core/src/core/enums.ts b/projects/igniteui-angular/core/src/core/enums.ts new file mode 100644 index 00000000000..c12a73cae4e --- /dev/null +++ b/projects/igniteui-angular/core/src/core/enums.ts @@ -0,0 +1,28 @@ +/** + * @hidden @internal + * + * Enumeration representing the possible predefined size options of the grid. + * - Small: This is the smallest size with 32px row height. Left and Right paddings are 12px. Minimal column width is 56px. + * - Medium: This is the middle size with 40px row height. Left and Right paddings are 16px. Minimal column width is 64px. + * - Large: this is the default Grid size with the lowest intense and row height equal to 50px. Left and Right paddings are 24px. Minimal column width is 80px. + */ +export const Size = { + Small: '1', + Medium: '2', + Large: '3' +} as const; +export type Size = (typeof Size)[keyof typeof Size]; + + +/** + * Enumeration representing the days of the week. + */ +export enum WEEKDAYS { + SUNDAY, + MONDAY, + TUESDAY, + WEDNESDAY, + THURSDAY, + FRIDAY, + SATURDAY +} diff --git a/projects/igniteui-angular/core/src/core/global-types.ts b/projects/igniteui-angular/core/src/core/global-types.ts new file mode 100644 index 00000000000..b0e71885868 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/global-types.ts @@ -0,0 +1,27 @@ +/** + * Global type augmentations for IgniteUI Angular + */ + +declare global { + interface GlobalEventHandlersEventMap { + 'keydown.enter': KeyboardEvent; + 'keydown.escape': KeyboardEvent; + 'keydown.tab': KeyboardEvent; + 'keydown.arrowup': KeyboardEvent; + 'keydown.arrowdown': KeyboardEvent; + 'keydown.arrowleft': KeyboardEvent; + 'keydown.arrowright': KeyboardEvent; + 'keydown.shift.tab': KeyboardEvent; + 'keydown.alt.arrowup': KeyboardEvent; + 'keydown.alt.arrowdown': KeyboardEvent; + 'keydown.pageup': KeyboardEvent; + 'keydown.pagedown': KeyboardEvent; + 'keydown.home': KeyboardEvent; + 'keydown.end': KeyboardEvent; + 'keydown.space': KeyboardEvent; + 'igcChange': CustomEvent; + } +} + +// This export is needed to make this file a module +export {}; \ No newline at end of file diff --git a/projects/igniteui-angular/core/src/core/i18n/action-strip-resources.ts b/projects/igniteui-angular/core/src/core/i18n/action-strip-resources.ts new file mode 100644 index 00000000000..5f49236c43e --- /dev/null +++ b/projects/igniteui-angular/core/src/core/i18n/action-strip-resources.ts @@ -0,0 +1,7 @@ +export interface IActionStripResourceStrings { + igx_action_strip_button_more_title?: string; +} + +export const ActionStripResourceStringsEN: IActionStripResourceStrings = { + igx_action_strip_button_more_title: 'More' +}; diff --git a/projects/igniteui-angular/core/src/core/i18n/banner-resources.ts b/projects/igniteui-angular/core/src/core/i18n/banner-resources.ts new file mode 100644 index 00000000000..f9cb275eb72 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/i18n/banner-resources.ts @@ -0,0 +1,7 @@ +export interface IBannerResourceStrings { + igx_banner_button_dismiss?: string; +} + +export const BannerResourceStringsEN: IBannerResourceStrings = { + igx_banner_button_dismiss: 'Dismiss' +}; diff --git a/projects/igniteui-angular/core/src/core/i18n/calendar-resources.ts b/projects/igniteui-angular/core/src/core/i18n/calendar-resources.ts new file mode 100644 index 00000000000..6f8c77ceaf6 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/i18n/calendar-resources.ts @@ -0,0 +1,49 @@ +export interface ICalendarResourceStrings { + igx_calendar_previous_month?: string; + igx_calendar_next_month?: string; + igx_calendar_previous_year?: string; + igx_calendar_next_year?: string; + igx_calendar_previous_years?: string; + igx_calendar_next_years?: string; + igx_calendar_select_date?: string; + igx_calendar_select_month?: string; + igx_calendar_select_year?: string; + igx_calendar_range_start?: string; + igx_calendar_range_end?: string; + igx_calendar_range_label_start?: string; + igx_calendar_range_label_end?: string; + igx_calendar_range_placeholder?: string; + igx_calendar_selected_month_is?: string; + igx_calendar_first_picker_of?: string; + igx_calendar_multi_selection?: string; + igx_calendar_range_selection?: string; + igx_calendar_single_selection?: string; + igx_calendar_singular_multi_selection?: string; + igx_calendar_singular_range_selection?: string; + igx_calendar_singular_single_selection?: string; +} + +export const CalendarResourceStringsEN: ICalendarResourceStrings = { + igx_calendar_previous_month: 'Previous Month', + igx_calendar_next_month: 'Next Month', + igx_calendar_previous_year: 'Previous Year', + igx_calendar_next_year: 'Next Year', + igx_calendar_previous_years: 'Previous {0} Years', + igx_calendar_next_years: 'Next {0} Years', + igx_calendar_select_date: 'Select Date', + igx_calendar_select_month: 'Select Month', + igx_calendar_select_year: 'Select Year', + igx_calendar_range_start: 'Range start', + igx_calendar_range_end: 'Range end', + igx_calendar_range_label_start: 'Start', + igx_calendar_range_label_end: 'End', + igx_calendar_range_placeholder: 'Select Range', + igx_calendar_selected_month_is: 'Selected month is ', + igx_calendar_first_picker_of: 'First picker of {0} starts from', + igx_calendar_multi_selection: 'Multi selection calendar with {0} date pickers', + igx_calendar_range_selection: 'Range selection calendar with {0} date pickers', + igx_calendar_single_selection: 'Calendar with {0} date pickers', + igx_calendar_singular_multi_selection: 'Multi selection calendar', + igx_calendar_singular_range_selection: 'Range selection calendar', + igx_calendar_singular_single_selection: 'Calendar', +}; diff --git a/projects/igniteui-angular/core/src/core/i18n/carousel-resources.ts b/projects/igniteui-angular/core/src/core/i18n/carousel-resources.ts new file mode 100644 index 00000000000..4745b4ecc88 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/i18n/carousel-resources.ts @@ -0,0 +1,13 @@ +export interface ICarouselResourceStrings { + igx_carousel_of?: string; + igx_carousel_slide?: string; + igx_carousel_previous_slide?: string; + igx_carousel_next_slide?: string; +} + +export const CarouselResourceStringsEN: ICarouselResourceStrings = { + igx_carousel_of: 'of', + igx_carousel_slide: 'slide', + igx_carousel_previous_slide: 'previous slide', + igx_carousel_next_slide: 'next slide' +}; diff --git a/projects/igniteui-angular/core/src/core/i18n/chip-resources.ts b/projects/igniteui-angular/core/src/core/i18n/chip-resources.ts new file mode 100644 index 00000000000..276c3615112 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/i18n/chip-resources.ts @@ -0,0 +1,9 @@ +export interface IChipResourceStrings { + igx_chip_remove?: string; + igx_chip_select?: string; +} + +export const ChipResourceStringsEN: IChipResourceStrings = { + igx_chip_remove: 'remove chip', + igx_chip_select: 'select chip' +}; diff --git a/projects/igniteui-angular/core/src/core/i18n/combo-resources.ts b/projects/igniteui-angular/core/src/core/i18n/combo-resources.ts new file mode 100644 index 00000000000..1df1321d629 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/i18n/combo-resources.ts @@ -0,0 +1,17 @@ +export interface IComboResourceStrings { + igx_combo_empty_message?: string; + igx_combo_filter_search_placeholder?: string; + igx_combo_addCustomValues_placeholder?: string; + igx_combo_clearItems_placeholder?: string; + igx_combo_aria_label_options?: string; + igx_combo_aria_label_no_options?: string; +} + +export const ComboResourceStringsEN: IComboResourceStrings = { + igx_combo_empty_message: 'The list is empty', + igx_combo_filter_search_placeholder: 'Enter a Search Term', + igx_combo_addCustomValues_placeholder: 'Add Item', + igx_combo_clearItems_placeholder: 'Clear Selection', + igx_combo_aria_label_options: 'Selected options', + igx_combo_aria_label_no_options: 'No options selected' +}; diff --git a/projects/igniteui-angular/core/src/core/i18n/date-picker-resources.ts b/projects/igniteui-angular/core/src/core/i18n/date-picker-resources.ts new file mode 100644 index 00000000000..e3a2f736374 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/i18n/date-picker-resources.ts @@ -0,0 +1,9 @@ +export interface IDatePickerResourceStrings { + igx_date_picker_change_date?: string; + igx_date_picker_choose_date?: string; +} + +export const DatePickerResourceStringsEN: IDatePickerResourceStrings = { + igx_date_picker_change_date: 'Change Date', + igx_date_picker_choose_date: 'Choose Date' +}; diff --git a/projects/igniteui-angular/core/src/core/i18n/date-range-picker-resources.ts b/projects/igniteui-angular/core/src/core/i18n/date-range-picker-resources.ts new file mode 100644 index 00000000000..2229be14bfe --- /dev/null +++ b/projects/igniteui-angular/core/src/core/i18n/date-range-picker-resources.ts @@ -0,0 +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_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/core/src/core/i18n/grid-resources.ts b/projects/igniteui-angular/core/src/core/i18n/grid-resources.ts new file mode 100644 index 00000000000..1731bf127d2 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/i18n/grid-resources.ts @@ -0,0 +1,365 @@ +export interface IGridResourceStrings { + igx_grid_groupByArea_message?: string; + igx_grid_groupByArea_select_message?: string; + igx_grid_groupByArea_deselect_message?: string; + igx_grid_emptyFilteredGrid_message?: string; + igx_grid_emptyGrid_message?: string; + igx_grid_filter?: string; + igx_grid_filter_row_close?: string; + igx_grid_filter_row_reset?: string; + igx_grid_filter_row_placeholder?: string; + igx_grid_filter_row_boolean_placeholder?: string; + igx_grid_filter_row_date_placeholder?: string; + igx_grid_filter_row_time_placeholder?: string; + igx_grid_filter_operator_and?: string; + igx_grid_complex_filter?: string; + igx_grid_filter_operator_or?: string; + igx_grid_filter_contains?: string; + igx_grid_filter_doesNotContain?: string; + igx_grid_filter_startsWith?: string; + igx_grid_filter_endsWith?: string; + igx_grid_filter_equals?: string; + igx_grid_filter_doesNotEqual?: string; + igx_grid_filter_empty?: string; + igx_grid_filter_notEmpty?: string; + igx_grid_filter_null?: string; + igx_grid_filter_notNull?: string; + igx_grid_filter_before?: string; + igx_grid_filter_after?: string; + igx_grid_filter_at?: string; + igx_grid_filter_not_at?: string; + igx_grid_filter_at_before?: string; + igx_grid_filter_at_after?: string; + igx_grid_filter_today?: string; + igx_grid_filter_yesterday?: string; + igx_grid_filter_thisMonth?: string; + igx_grid_filter_lastMonth?: string; + igx_grid_filter_nextMonth?: string; + igx_grid_filter_thisYear?: string; + igx_grid_filter_lastYear?: string; + igx_grid_filter_nextYear?: string; + igx_grid_filter_greaterThan?: string; + igx_grid_filter_lessThan?: string; + igx_grid_filter_greaterThanOrEqualTo?: string; + igx_grid_filter_lessThanOrEqualTo?: string; + igx_grid_filter_true?: string; + igx_grid_filter_false?: string; + igx_grid_filter_all?: string; + igx_grid_filter_in?: string; + igx_grid_filter_notIn?: string; + igx_grid_filter_condition_placeholder?: string; + igx_grid_summary_count?: string; + igx_grid_summary_min?: string; + igx_grid_summary_max?: string; + igx_grid_summary_sum?: string; + igx_grid_summary_average?: string; + igx_grid_summary_earliest?: string; + igx_grid_summary_latest?: string; + igx_grid_excel_filter_moving_left?: string; + igx_grid_excel_filter_moving_left_short?: string; + igx_grid_excel_filter_moving_right?: string; + igx_grid_excel_filter_moving_right_short?: string; + igx_grid_excel_filter_moving_header?: string; + igx_grid_excel_filter_sorting_asc?: string; + igx_grid_excel_filter_sorting_asc_short?: string; + igx_grid_excel_filter_sorting_desc?: string; + igx_grid_excel_filter_sorting_desc_short?: string; + igx_grid_excel_filter_sorting_header?: string; + igx_grid_excel_filter_clear?: string; + igx_grid_excel_custom_dialog_add?: string; + igx_grid_excel_custom_dialog_clear?: string; + igx_grid_excel_custom_dialog_header?: string; + igx_grid_excel_cancel?: string; + igx_grid_excel_apply?: string; + igx_grid_excel_search_placeholder?: string; + igx_grid_excel_select_all?: string; + igx_grid_excel_select_all_search_results?: string; + igx_grid_excel_add_to_filter?: string; + igx_grid_excel_blanks?: string; + igx_grid_excel_hide?: string; + igx_grid_excel_show?: string; + igx_grid_excel_pin?: string; + igx_grid_excel_unpin?: string; + igx_grid_excel_select?: string; + igx_grid_excel_deselect?: string; + igx_grid_excel_text_filter?: string; + igx_grid_excel_number_filter?: string; + igx_grid_excel_date_filter?: string; + igx_grid_excel_boolean_filter?: string; + igx_grid_excel_currency_filter?: string; + igx_grid_excel_custom_filter?: string; + igx_grid_excel_no_matches?: string; + igx_grid_excel_matches_count?: string; + igx_grid_advanced_filter_title?: string; + igx_grid_advanced_filter_from_label?: string; + igx_grid_advanced_filter_and_group?: string; + igx_grid_advanced_filter_or_group?: string; + igx_grid_advanced_filter_end_group?: string; + igx_grid_advanced_filter_create_and_group?: string; + igx_grid_advanced_filter_create_or_group?: string; + igx_grid_advanced_filter_and_label?: string; + igx_grid_advanced_filter_or_label?: string; + igx_grid_advanced_filter_add_condition?: string; + igx_grid_advanced_filter_add_condition_root?: string; + igx_grid_advanced_filter_add_group?: string; + igx_grid_advanced_filter_add_group_root?: string; + igx_grid_advanced_filter_ungroup?: string; + igx_grid_advanced_filter_delete?: string; + igx_grid_advanced_filter_delete_filters?: string; + igx_grid_advanced_filter_initial_text?: string; + igx_grid_advanced_filter_column_placeholder?: string; + igx_grid_advanced_filter_value_placeholder?: string; + igx_grid_advanced_filter_query_value_placeholder?: string; + igx_grid_advanced_filter_switch_group?: string; + igx_grid_advanced_filter_dialog_title? : string; + igx_grid_advanced_filter_dialog_message? : string; + igx_grid_advanced_filter_dialog_checkbox_text? : string; + igx_grid_advanced_filter_drop_ghost_text?: string; + igx_grid_advanced_filter_select_entity?: string; + igx_grid_advanced_filter_select_return_field_single?: string; + igx_grid_pinned_row_indicator?: string; + igx_grid_hiding_check_all_label?: string; + igx_grid_hiding_uncheck_all_label?: string; + igx_grid_pinning_check_all_label?: string; + igx_grid_pinning_uncheck_all_label?: string; + igx_grid_row_edit_btn_done?: string; + igx_grid_row_edit_btn_cancel?: string; + igx_grid_row_edit_text?: string; + igx_grid_toolbar_actions_filter_prompt?: string; + igx_grid_toolbar_pinning_button_tooltip?: string; + igx_grid_toolbar_hiding_button_tooltip?: string; + igx_grid_toolbar_pinning_title?: string; + igx_grid_toolbar_hiding_title?: string; + igx_grid_toolbar_advanced_filtering_button_tooltip?: string; + igx_grid_toolbar_advanced_filtering_button_label?: string; + igx_grid_toolbar_exporter_button_tooltip?: string; + igx_grid_toolbar_exporter_button_label?: string; + igx_grid_toolbar_exporter_excel_entry_text?: string; + igx_grid_toolbar_exporter_csv_entry_text?: string; + igx_grid_toolbar_exporter_pdf_entry_text?: string; + igx_grid_snackbar_addrow_label?: string; + igx_grid_snackbar_addrow_actiontext?: string; + igx_grid_actions_edit_label?: string; + igx_grid_actions_add_label?: string; + igx_grid_add_row_label?: string; + igx_grid_actions_add_child_label?: string; + igx_grid_actions_delete_label?: string; + igx_grid_actions_pin_label?: string; + igx_grid_actions_unpin_label?: string; + igx_grid_actions_jumpUp_label?: string; + igx_grid_actions_jumpDown_label?: string; + igx_grid_pivot_date_dimension_total?: string; + igx_grid_pivot_aggregate_count?: string; + igx_grid_pivot_aggregate_min?: string; + igx_grid_pivot_aggregate_max?: string; + igx_grid_pivot_aggregate_sum?: string; + igx_grid_pivot_aggregate_avg?: string; + igx_grid_pivot_aggregate_date_latest?: string; + igx_grid_pivot_aggregate_date_earliest?: string; + igx_grid_pivot_aggregate_time_latest?: string; + igx_grid_pivot_aggregate_time_earliest?: string; + igx_grid_pivot_empty_row_drop_area?: string; + igx_grid_pivot_empty_column_drop_area?: string; + igx_grid_pivot_empty_filter_drop_area?: string; + igx_grid_pivot_empty_value_drop_area?: string; + igx_grid_pivot_row_drop_chip?: string; + igx_grid_pivot_column_drop_chip?: string; + igx_grid_pivot_filter_drop_chip?: string; + igx_grid_pivot_value_drop_chip?: string; + igx_grid_pivot_empty_message?: string; + igx_grid_pivot_selector_filters?: string; + igx_grid_pivot_selector_rows?: string; + igx_grid_pivot_selector_columns?: string; + igx_grid_pivot_selector_values?: string; + igx_grid_pivot_selector_panel_empty?: string; + igx_grid_required_validation_error?: string; + igx_grid_min_validation_error?: string; + igx_grid_max_validation_error?: string; + igx_grid_min_length_validation_error?: string; + igx_grid_max_length_validation_error?: string; + igx_grid_email_validation_error?: string; + igx_grid_pattern_validation_error?: string; +} + +export const GridResourceStringsEN: IGridResourceStrings = { + igx_grid_groupByArea_message: 'Drag a column header and drop it here to group by that column.', + igx_grid_groupByArea_select_message: 'Select all rows in the group with field name {0} and value {1}.', + igx_grid_groupByArea_deselect_message: 'Deselect all rows in the group with field name {0} and value {1}.', + igx_grid_emptyFilteredGrid_message: 'No records found.', + igx_grid_emptyGrid_message: 'Grid has no data.', + igx_grid_filter: 'Filter', + igx_grid_filter_row_close: 'Close', + igx_grid_filter_row_reset: 'Reset', + igx_grid_filter_row_placeholder: 'Add filter value', + igx_grid_filter_row_boolean_placeholder: 'All', + igx_grid_filter_row_date_placeholder: 'Pick up date', + igx_grid_filter_row_time_placeholder: 'Pick up time', + igx_grid_filter_operator_and: 'And', + igx_grid_filter_operator_or: 'Or', + igx_grid_complex_filter: 'Complex Filter', + igx_grid_filter_contains: 'Contains', + igx_grid_filter_doesNotContain: 'Does Not Contain', + igx_grid_filter_startsWith: 'Starts With', + igx_grid_filter_endsWith: 'Ends With', + igx_grid_filter_equals: 'Equals', + igx_grid_filter_doesNotEqual: 'Does Not Equal', + igx_grid_filter_empty: 'Empty', + igx_grid_filter_notEmpty: 'Not Empty', + igx_grid_filter_null: 'Null', + igx_grid_filter_notNull: 'Not Null', + igx_grid_filter_before: 'Before', + igx_grid_filter_after: 'After', + igx_grid_filter_at: 'At', + igx_grid_filter_not_at: 'Not At', + igx_grid_filter_at_before: 'At or Before', + igx_grid_filter_at_after: 'At or After', + igx_grid_filter_today: 'Today', + igx_grid_filter_yesterday: 'Yesterday', + igx_grid_filter_thisMonth: 'This Month', + igx_grid_filter_lastMonth: 'Last Month', + igx_grid_filter_nextMonth: 'Next Month', + igx_grid_filter_thisYear: 'This Year', + igx_grid_filter_lastYear: 'Last Year', + igx_grid_filter_nextYear: 'Next Year', + igx_grid_filter_greaterThan: 'Greater Than', + igx_grid_filter_lessThan: 'Less Than', + igx_grid_filter_greaterThanOrEqualTo: 'Greater Than Or Equal To', + igx_grid_filter_lessThanOrEqualTo: 'Less Than Or Equal To', + igx_grid_filter_true: 'True', + igx_grid_filter_false: 'False', + igx_grid_filter_all: 'All', + igx_grid_filter_condition_placeholder: 'Select filter', + igx_grid_filter_in: 'In', + igx_grid_filter_notIn: 'Not In', + igx_grid_summary_count: 'Count', + igx_grid_summary_min: 'Min', + igx_grid_summary_max: 'Max', + igx_grid_summary_sum: 'Sum', + igx_grid_summary_average: 'Avg', + igx_grid_summary_earliest: 'Earliest', + igx_grid_summary_latest: 'Latest', + igx_grid_excel_filter_moving_left: 'move left', + igx_grid_excel_filter_moving_left_short: 'left', + igx_grid_excel_filter_moving_right: 'move right', + igx_grid_excel_filter_moving_right_short: 'right', + igx_grid_excel_filter_moving_header: 'move', + igx_grid_excel_filter_sorting_asc: 'ascending', + igx_grid_excel_filter_sorting_asc_short: 'asc', + igx_grid_excel_filter_sorting_desc: 'descending', + igx_grid_excel_filter_sorting_desc_short: 'desc', + igx_grid_excel_filter_sorting_header: 'sort', + igx_grid_excel_filter_clear: 'Clear column filters', + igx_grid_excel_custom_dialog_add: 'add filter', + igx_grid_excel_custom_dialog_clear: 'Clear filter', + igx_grid_excel_custom_dialog_header: 'Custom auto-filter on column: ', + igx_grid_excel_cancel: 'Cancel', + igx_grid_excel_apply: 'Apply', + igx_grid_excel_search_placeholder: 'Search', + igx_grid_excel_select_all: 'Select All', + igx_grid_excel_select_all_search_results: 'Select all search results', + igx_grid_excel_add_to_filter: 'Add current selection to filter', + igx_grid_excel_blanks: '(Blanks)', + igx_grid_excel_hide: 'Hide column', + igx_grid_excel_show: 'Show column', + igx_grid_excel_pin: 'Pin column', + igx_grid_excel_unpin: 'Unpin column', + igx_grid_excel_select: 'Select column', + igx_grid_excel_deselect: 'Deselect column', + igx_grid_excel_text_filter: 'Text filter', + igx_grid_excel_number_filter: 'Number filter', + igx_grid_excel_date_filter: 'Date filter', + igx_grid_excel_boolean_filter: 'Boolean filter', + igx_grid_excel_currency_filter: 'Currency filter', + igx_grid_excel_custom_filter: 'Custom filter...', + igx_grid_excel_no_matches: 'No matches', + igx_grid_excel_matches_count: '{0} matches.', + igx_grid_advanced_filter_title: 'Advanced Filtering', + igx_grid_advanced_filter_from_label: 'From', + igx_grid_advanced_filter_and_group: '"And" Group', + igx_grid_advanced_filter_or_group: '"Or" Group', + igx_grid_advanced_filter_end_group: 'End Group', + igx_grid_advanced_filter_create_and_group: 'Create "And" Group', + igx_grid_advanced_filter_create_or_group: 'Create "Or" Group', + igx_grid_advanced_filter_and_label: 'and', + igx_grid_advanced_filter_or_label: 'or', + igx_grid_advanced_filter_switch_group: 'Switch to {0}', + igx_grid_advanced_filter_add_condition: 'Add condition', + igx_grid_advanced_filter_add_group: 'Add group', + igx_grid_advanced_filter_add_condition_root: 'Condition', + igx_grid_advanced_filter_add_group_root: 'Group', + igx_grid_advanced_filter_ungroup: 'Ungroup', + igx_grid_advanced_filter_delete: 'Delete', + igx_grid_advanced_filter_delete_filters: 'Delete filters', + igx_grid_advanced_filter_initial_text: 'Start with creating a group of conditions linked with "And" or "Or"', + igx_grid_advanced_filter_column_placeholder: 'Select column', + igx_grid_advanced_filter_value_placeholder: 'Value', + igx_grid_advanced_filter_query_value_placeholder: 'Sub-query results', + igx_grid_advanced_filter_select_entity: 'Select entity', + igx_grid_advanced_filter_select_return_field_single: 'Select return field', + igx_grid_advanced_filter_dialog_title: 'Confirmation', + igx_grid_advanced_filter_dialog_message: 'By changing the entity, you will lose your current settings. Are you sure you want to do that?', + igx_grid_advanced_filter_dialog_checkbox_text: 'Do not show this dialog again', + igx_grid_advanced_filter_drop_ghost_text: 'Drop here to insert', + igx_grid_pinned_row_indicator: 'Pinned', + igx_grid_hiding_check_all_label: 'Show All', + igx_grid_hiding_uncheck_all_label: 'Hide All', + igx_grid_pinning_check_all_label: 'Pin All', + igx_grid_pinning_uncheck_all_label: 'Unpin All', + igx_grid_row_edit_btn_done: 'Done', + igx_grid_row_edit_btn_cancel: 'Cancel', + igx_grid_row_edit_text: 'You have {0} changes in this row and {1} hidden columns', + igx_grid_toolbar_actions_filter_prompt: 'Filter columns list ...', + igx_grid_toolbar_pinning_button_tooltip: 'Open column pinning dropdown', + igx_grid_toolbar_hiding_button_tooltip: 'Open column hiding dropdown', + igx_grid_toolbar_pinning_title: 'Pinned columns', + igx_grid_toolbar_hiding_title: 'Visible columns', + igx_grid_toolbar_advanced_filtering_button_tooltip: 'Open advanced filtering dialog', + igx_grid_toolbar_advanced_filtering_button_label: 'Advanced filtering', + igx_grid_toolbar_exporter_button_tooltip: 'Open exporter dropdown', + igx_grid_toolbar_exporter_button_label: 'Export', + igx_grid_toolbar_exporter_excel_entry_text: 'Export to Excel', + igx_grid_toolbar_exporter_csv_entry_text: 'Export to CSV', + igx_grid_toolbar_exporter_pdf_entry_text: 'Export to PDF', + igx_grid_snackbar_addrow_label: 'Row added', + igx_grid_snackbar_addrow_actiontext: 'SHOW', + igx_grid_actions_edit_label: 'Edit', + igx_grid_actions_add_label: 'Add', + igx_grid_add_row_label: 'ADD ROW', + igx_grid_actions_add_child_label: 'Add Child', + igx_grid_actions_delete_label: 'Delete', + igx_grid_actions_pin_label: 'Pin', + igx_grid_actions_unpin_label: 'Unpin', + igx_grid_actions_jumpUp_label: 'Jump up', + igx_grid_actions_jumpDown_label: 'Jump down', + igx_grid_pivot_date_dimension_total: 'All Periods', + igx_grid_pivot_aggregate_count: 'Count', + igx_grid_pivot_aggregate_min: 'Minimum', + igx_grid_pivot_aggregate_max: 'Maximum', + igx_grid_pivot_aggregate_sum: 'Sum', + igx_grid_pivot_aggregate_avg: 'Average', + igx_grid_pivot_aggregate_date_latest: 'Latest Date', + igx_grid_pivot_aggregate_date_earliest: 'Earliest Date', + igx_grid_pivot_aggregate_time_latest: 'Latest Time', + igx_grid_pivot_aggregate_time_earliest: 'Earliest Time', + igx_grid_pivot_empty_row_drop_area: 'Drop Row Fields here.', + igx_grid_pivot_empty_column_drop_area: 'Drop Column Fields here.', + igx_grid_pivot_empty_filter_drop_area: 'Drop Filter Fields here.', + igx_grid_pivot_empty_value_drop_area: 'Drop Value Fields here.', + igx_grid_pivot_row_drop_chip: 'Drop here to use as row', + igx_grid_pivot_column_drop_chip: 'Drop here to use as column', + igx_grid_pivot_filter_drop_chip: 'Drop here to use as filter', + igx_grid_pivot_value_drop_chip: 'Drop here to use as value', + igx_grid_pivot_empty_message: 'Pivot grid has no dimensions and values.', + igx_grid_pivot_selector_filters: 'Filters', + igx_grid_pivot_selector_rows: 'Rows', + igx_grid_pivot_selector_columns: 'Columns', + igx_grid_pivot_selector_values: 'Values', + igx_grid_pivot_selector_panel_empty: 'Drop Items Here', + igx_grid_required_validation_error: 'This field is required', + igx_grid_min_validation_error: 'A value of at least {0} should be entered', + igx_grid_max_validation_error: 'A value no more than {0} should be entered', + igx_grid_min_length_validation_error: 'Entry should be at least {0} character(s) long', + igx_grid_max_length_validation_error: 'Entry should be no more than {0} character(s) long', + igx_grid_email_validation_error: 'A valid email address should be entered', + igx_grid_pattern_validation_error: 'Entry does not match the required pattern' +}; diff --git a/projects/igniteui-angular/core/src/core/i18n/input-resources.ts b/projects/igniteui-angular/core/src/core/i18n/input-resources.ts new file mode 100644 index 00000000000..5b8c244506e --- /dev/null +++ b/projects/igniteui-angular/core/src/core/i18n/input-resources.ts @@ -0,0 +1,9 @@ +export interface IInputResourceStrings { + igx_input_upload_button?: string; + igx_input_file_placeholder?: string; +} + +export const InputResourceStringsEN: IInputResourceStrings = { + igx_input_upload_button: 'Browse', + igx_input_file_placeholder: 'No file chosen', +}; diff --git a/projects/igniteui-angular/core/src/core/i18n/list-resources.ts b/projects/igniteui-angular/core/src/core/i18n/list-resources.ts new file mode 100644 index 00000000000..06a4b2d1e9d --- /dev/null +++ b/projects/igniteui-angular/core/src/core/i18n/list-resources.ts @@ -0,0 +1,9 @@ +export interface IListResourceStrings { + igx_list_no_items?: string; + igx_list_loading?: string; +} + +export const ListResourceStringsEN: IListResourceStrings = { + igx_list_no_items: 'There are no items in the list.', + igx_list_loading: 'Loading data from the server...' +}; diff --git a/projects/igniteui-angular/core/src/core/i18n/paginator-resources.ts b/projects/igniteui-angular/core/src/core/i18n/paginator-resources.ts new file mode 100644 index 00000000000..a43a89bbde8 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/i18n/paginator-resources.ts @@ -0,0 +1,17 @@ +export interface IPaginatorResourceStrings { + igx_paginator_label?: string; + igx_paginator_pager_text?: string; + igx_paginator_first_page_button_text?: string; + igx_paginator_previous_page_button_text?: string; + igx_paginator_last_page_button_text?: string; + igx_paginator_next_page_button_text?: string; +} + +export const PaginatorResourceStringsEN: IPaginatorResourceStrings = { + igx_paginator_label: 'Items per page', + igx_paginator_pager_text: 'of', + igx_paginator_first_page_button_text: 'Go to first page', + igx_paginator_previous_page_button_text: 'Previous page', + igx_paginator_last_page_button_text: 'Go to last page', + igx_paginator_next_page_button_text: 'Next page', +}; diff --git a/projects/igniteui-angular/core/src/core/i18n/query-builder-resources.ts b/projects/igniteui-angular/core/src/core/i18n/query-builder-resources.ts new file mode 100644 index 00000000000..e486e6ba242 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/i18n/query-builder-resources.ts @@ -0,0 +1,149 @@ +export interface IQueryBuilderResourceStrings { + igx_query_builder_date_placeholder?: string; + igx_query_builder_time_placeholder?: string; + igx_query_builder_datetime_placeholder?: string; + igx_query_builder_filter_operator_and?: string; + igx_query_builder_filter_operator_or?: string; + igx_query_builder_filter_contains?: string; + igx_query_builder_filter_doesNotContain?: string; + igx_query_builder_filter_startsWith?: string; + igx_query_builder_filter_endsWith?: string; + igx_query_builder_filter_equals?: string; + igx_query_builder_filter_doesNotEqual?: string; + igx_query_builder_filter_empty?: string; + igx_query_builder_filter_notEmpty?: string; + igx_query_builder_filter_null?: string; + igx_query_builder_filter_notNull?: string; + igx_query_builder_filter_in?: string; + igx_query_builder_filter_notIn?: string; + igx_query_builder_filter_before?: string; + igx_query_builder_filter_after?: string; + igx_query_builder_filter_at?: string; + igx_query_builder_filter_not_at?: string; + igx_query_builder_filter_at_before?: string; + igx_query_builder_filter_at_after?: string; + igx_query_builder_filter_today?: string; + igx_query_builder_filter_yesterday?: string; + igx_query_builder_filter_thisMonth?: string; + igx_query_builder_filter_lastMonth?: string; + igx_query_builder_filter_nextMonth?: string; + igx_query_builder_filter_thisYear?: string; + igx_query_builder_filter_lastYear?: string; + igx_query_builder_filter_nextYear?: string; + igx_query_builder_filter_greaterThan?: string; + igx_query_builder_filter_lessThan?: string; + igx_query_builder_filter_greaterThanOrEqualTo?: string; + igx_query_builder_filter_lessThanOrEqualTo?: string; + igx_query_builder_filter_true?: string; + igx_query_builder_filter_false?: string; + igx_query_builder_filter_all?: string; + igx_query_builder_from_label?: string; + igx_query_builder_select_label?: string; + igx_query_builder_where_label?: string; + igx_query_builder_and_group?: string; + igx_query_builder_or_group?: string; + igx_query_builder_end_group?: string; + igx_query_builder_and_label?: string; + igx_query_builder_or_label?: string; + igx_query_builder_switch_group?: string; + igx_query_builder_add_condition?: string; + igx_query_builder_add_group?: string; + igx_query_builder_add_condition_root?: string; + igx_query_builder_add_group_root?: string; + igx_query_builder_ungroup?: string; + igx_query_builder_delete?: string; + igx_query_builder_delete_filters?: string; + igx_query_builder_initial_text?: string; + igx_query_builder_column_placeholder?: string; + igx_query_builder_condition_placeholder?: string; + igx_query_builder_value_placeholder?: string; + igx_query_builder_query_value_placeholder?: string; + igx_query_builder_all_fields?: string; + igx_query_builder_details?: string; + igx_query_builder_search?: string; + igx_query_builder_select_all?: string; + igx_query_builder_select_entity?: string; + igx_query_builder_select_return_field_single?: string; + igx_query_builder_select_return_fields?: string; + igx_query_builder_dialog_title?: string; + igx_query_builder_dialog_message?: string; + igx_query_builder_dialog_checkbox_text?: string; + igx_query_builder_dialog_cancel?: string; + igx_query_builder_dialog_confirm?: string; + igx_query_builder_drop_ghost_text?: string; +} + +export const QueryBuilderResourceStringsEN: IQueryBuilderResourceStrings = { + igx_query_builder_date_placeholder: 'Select date', + igx_query_builder_time_placeholder: 'Select time', + igx_query_builder_datetime_placeholder: 'Select date & time', + igx_query_builder_filter_operator_and: 'And', + igx_query_builder_filter_operator_or: 'Or', + igx_query_builder_filter_contains: 'Contains', + igx_query_builder_filter_doesNotContain: 'Does Not Contain', + igx_query_builder_filter_startsWith: 'Starts With', + igx_query_builder_filter_endsWith: 'Ends With', + igx_query_builder_filter_equals: 'Equals', + igx_query_builder_filter_doesNotEqual: 'Does Not Equal', + igx_query_builder_filter_empty: 'Empty', + igx_query_builder_filter_notEmpty: 'Not Empty', + igx_query_builder_filter_null: 'Null', + igx_query_builder_filter_notNull: 'Not Null', + igx_query_builder_filter_in: 'In', + igx_query_builder_filter_notIn: 'Not In', + igx_query_builder_filter_before: 'Before', + igx_query_builder_filter_after: 'After', + igx_query_builder_filter_at: 'At', + igx_query_builder_filter_not_at: 'Not At', + igx_query_builder_filter_at_before: 'At or Before', + igx_query_builder_filter_at_after: 'At or After', + igx_query_builder_filter_today: 'Today', + igx_query_builder_filter_yesterday: 'Yesterday', + igx_query_builder_filter_thisMonth: 'This Month', + igx_query_builder_filter_lastMonth: 'Last Month', + igx_query_builder_filter_nextMonth: 'Next Month', + igx_query_builder_filter_thisYear: 'This Year', + igx_query_builder_filter_lastYear: 'Last Year', + igx_query_builder_filter_nextYear: 'Next Year', + igx_query_builder_filter_greaterThan: 'Greater Than', + igx_query_builder_filter_lessThan: 'Less Than', + igx_query_builder_filter_greaterThanOrEqualTo: 'Greater Than Or Equal To', + igx_query_builder_filter_lessThanOrEqualTo: 'Less Than Or Equal To', + igx_query_builder_filter_true: 'True', + igx_query_builder_filter_false: 'False', + igx_query_builder_filter_all: 'All', + igx_query_builder_from_label: 'From', + igx_query_builder_select_label: 'Select', + igx_query_builder_where_label: 'Where', + igx_query_builder_and_group: '"And" Group', + igx_query_builder_or_group: '"Or" Group', + igx_query_builder_end_group: 'End Group', + igx_query_builder_and_label: 'and', + igx_query_builder_or_label: 'or', + igx_query_builder_switch_group: 'Switch to {0}', + igx_query_builder_add_condition: 'Add condition', + igx_query_builder_add_group: 'Add group', + igx_query_builder_add_condition_root: 'Condition', + igx_query_builder_add_group_root: 'Group', + igx_query_builder_ungroup: 'Ungroup', + igx_query_builder_delete: 'Delete', + igx_query_builder_delete_filters: 'Delete filters', + igx_query_builder_initial_text: 'Start with creating a group of conditions linked with "And" or "Or"', + igx_query_builder_column_placeholder: 'Select column', + igx_query_builder_condition_placeholder: 'Select filter', + igx_query_builder_value_placeholder: 'Value', + igx_query_builder_query_value_placeholder: 'Sub-query results', + igx_query_builder_all_fields: 'All fields', + igx_query_builder_details: 'Details', + igx_query_builder_search: 'Search', + igx_query_builder_select_all: 'Select All', + igx_query_builder_select_entity: 'Select entity', + igx_query_builder_select_return_field_single: 'Select return field', + igx_query_builder_select_return_fields: 'Select return fields', + igx_query_builder_dialog_title: 'Confirmation', + igx_query_builder_dialog_message: 'By changing the entity, you will lose your current settings. Are you sure you want to do that?', + igx_query_builder_dialog_checkbox_text: 'Do not show this dialog again', + igx_query_builder_dialog_cancel: 'Cancel', + igx_query_builder_dialog_confirm: 'Confirm', + igx_query_builder_drop_ghost_text: 'Drop here to insert' +}; \ No newline at end of file diff --git a/projects/igniteui-angular/core/src/core/i18n/resources.ts b/projects/igniteui-angular/core/src/core/i18n/resources.ts new file mode 100644 index 00000000000..e8eed61033f --- /dev/null +++ b/projects/igniteui-angular/core/src/core/i18n/resources.ts @@ -0,0 +1,60 @@ +import { IDatePickerResourceStrings } from './date-picker-resources'; +import { IDateRangePickerResourceStrings } from './date-range-picker-resources'; +import { IGridResourceStrings } from './grid-resources'; +import { ITimePickerResourceStrings } from './time-picker-resources'; +import { IPaginatorResourceStrings } from './paginator-resources'; +import { ICarouselResourceStrings } from './carousel-resources'; +import { IChipResourceStrings } from './chip-resources'; +import { IListResourceStrings } from './list-resources'; +import { ICalendarResourceStrings } from './calendar-resources'; +import { IInputResourceStrings } from './input-resources'; +import { ITreeResourceStrings } from './tree-resources'; +import { IActionStripResourceStrings } from './action-strip-resources'; +import { IQueryBuilderResourceStrings } from './query-builder-resources'; +import { IComboResourceStrings } from './combo-resources'; +import { IBannerResourceStrings } from './banner-resources'; + +export interface IResourceStrings extends IGridResourceStrings, ITimePickerResourceStrings, ICalendarResourceStrings, + ICarouselResourceStrings, IChipResourceStrings, IComboResourceStrings, IInputResourceStrings, IDatePickerResourceStrings, + IDateRangePickerResourceStrings, IListResourceStrings, IPaginatorResourceStrings, ITreeResourceStrings, + IActionStripResourceStrings, IQueryBuilderResourceStrings, IBannerResourceStrings { } + +export class igxI18N { + private static _instance: igxI18N; + + private _currentResourceStrings: IResourceStrings = { }; + + private constructor() { } + + public static instance() { + return this._instance || (this._instance = new this()); + } + + /** + * Changes the resource strings for all components in the application + * ``` + * @param resourceStrings to be applied + */ + public changei18n(resourceStrings: IResourceStrings) { + for (const key of Object.keys(resourceStrings)) { + this._currentResourceStrings[key] = resourceStrings[key]; + } + } + + public getCurrentResourceStrings(en: IResourceStrings): IResourceStrings { + for (const key of Object.keys(en)) { + if (!this._currentResourceStrings[key]) { + this._currentResourceStrings[key] = en[key]; + } + } + return this._currentResourceStrings; + } +} + +export function getCurrentResourceStrings(en: IResourceStrings) { + return igxI18N.instance().getCurrentResourceStrings(en); +} + +export function changei18n(resourceStrings: IResourceStrings) { + igxI18N.instance().changei18n(resourceStrings); +} diff --git a/projects/igniteui-angular/core/src/core/i18n/tests/tests.mjs b/projects/igniteui-angular/core/src/core/i18n/tests/tests.mjs new file mode 100644 index 00000000000..912a85e9958 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/i18n/tests/tests.mjs @@ -0,0 +1,34 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const i18nProductPath = path.join(__dirname, '../'); +const i18nLanguagesPath = path.join(__dirname, '../../../../../../igniteui-angular-i18n/src/i18n'); +const errors = []; + +class i18nTests { + runTests() { + this.i18nFilesMatchForAllLanguages(); + } + + getDirectories = (srcPath) => fs.readdirSync(srcPath).filter(file => fs.statSync(path.join(srcPath, file)).isDirectory()); + getFiles = (srcPath) => fs.readdirSync(srcPath).filter(file => fs.statSync(path.join(srcPath, file)).isFile()); + + i18nFilesMatchForAllLanguages() { + this.getDirectories(i18nLanguagesPath).forEach(dir => { + const curDirPath = path.join(i18nLanguagesPath, dir); + if (this.getFiles(curDirPath).length !== this.getFiles(i18nProductPath).length) { + errors.push(`Not all i18n component files that are available for localization have matching files for ${dir} language. + Check and add the appropriate resource strings with EN translation and mark the PR as 'pending localization'` + ); + } + }); + + if (errors.length > 0) { + throw errors; + } + } +} + +new i18nTests().runTests(); diff --git a/projects/igniteui-angular/core/src/core/i18n/time-picker-resources.ts b/projects/igniteui-angular/core/src/core/i18n/time-picker-resources.ts new file mode 100644 index 00000000000..d6571a266fa --- /dev/null +++ b/projects/igniteui-angular/core/src/core/i18n/time-picker-resources.ts @@ -0,0 +1,13 @@ +export interface ITimePickerResourceStrings { + igx_time_picker_ok?: string; + igx_time_picker_cancel?: string; + igx_time_picker_change_time?: string; + igx_time_picker_choose_time?: string; +} + +export const TimePickerResourceStringsEN: ITimePickerResourceStrings = { + igx_time_picker_ok: 'OK', + igx_time_picker_cancel: 'Cancel', + igx_time_picker_change_time: 'Change Time', + igx_time_picker_choose_time: 'Choose Time' +}; diff --git a/projects/igniteui-angular/core/src/core/i18n/tree-resources.ts b/projects/igniteui-angular/core/src/core/i18n/tree-resources.ts new file mode 100644 index 00000000000..2ac191e8f3b --- /dev/null +++ b/projects/igniteui-angular/core/src/core/i18n/tree-resources.ts @@ -0,0 +1,9 @@ +export interface ITreeResourceStrings { + igx_expand?: string; + igx_collapse?: string; +} + +export const TreeResourceStringsEN: ITreeResourceStrings = { + igx_expand: 'Expand', + igx_collapse: 'Collapse', +}; diff --git a/projects/igniteui-angular/core/src/core/navigation.ts b/projects/igniteui-angular/core/src/core/navigation.ts new file mode 100644 index 00000000000..b943deba6fd --- /dev/null +++ b/projects/igniteui-angular/core/src/core/navigation.ts @@ -0,0 +1,3 @@ +export * from './navigation/IToggleView'; +export * from './navigation/nav.service'; +export * from './navigation/directives'; diff --git a/projects/igniteui-angular/core/src/core/navigation/IToggleView.ts b/projects/igniteui-angular/core/src/core/navigation/IToggleView.ts new file mode 100644 index 00000000000..3ff8a7915a5 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/navigation/IToggleView.ts @@ -0,0 +1,10 @@ +/** + * Common interface for Components with show and collapse functionality + */ +export interface IToggleView { + element; + + open(...args); + close(...args); + toggle(...args); +} diff --git a/projects/igniteui-angular/core/src/core/navigation/directives.ts b/projects/igniteui-angular/core/src/core/navigation/directives.ts new file mode 100644 index 00000000000..c900ad410f6 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/navigation/directives.ts @@ -0,0 +1,62 @@ +import { Directive, HostListener, Input, inject } from '@angular/core'; +import { IgxNavigationService } from './nav.service'; + +/** + * Directive that can toggle targets through provided NavigationService. + * + * Usage: + * ``` + * + * ``` + * Where the `ID` matches the ID of compatible `IToggleView` component. + */ +@Directive({ + selector: '[igxNavToggle]', + standalone: true +}) +export class IgxNavigationToggleDirective { + @Input('igxNavToggle') private target; + + public state: IgxNavigationService; + + constructor() { + const nav = inject(IgxNavigationService); + + this.state = nav; + } + + @HostListener('click') + public toggleNavigationDrawer() { + this.state.toggle(this.target, true); + } +} + +/** + * Directive that can close targets through provided NavigationService. + * + * Usage: + * ``` + * + * ``` + * Where the `ID` matches the ID of compatible `IToggleView` component. + */ +@Directive({ + selector: '[igxNavClose]', + standalone: true +}) +export class IgxNavigationCloseDirective { + @Input('igxNavClose') private target; + + public state: IgxNavigationService; + + constructor() { + const nav = inject(IgxNavigationService); + + this.state = nav; + } + + @HostListener('click') + public closeNavigationDrawer() { + this.state.close(this.target, true); + } +} diff --git a/projects/igniteui-angular/core/src/core/navigation/nav.service.ts b/projects/igniteui-angular/core/src/core/navigation/nav.service.ts new file mode 100644 index 00000000000..d8b5e6f667a --- /dev/null +++ b/projects/igniteui-angular/core/src/core/navigation/nav.service.ts @@ -0,0 +1,46 @@ +import { IToggleView } from './IToggleView'; +import { Injectable } from '@angular/core'; + +/** + * Common service to be injected between components where those implementing common + * ToggleView interface can register and toggle directives can call their methods. + * TODO: Track currently active? Events? + */ +@Injectable({ providedIn: 'root' }) +export class IgxNavigationService { + private navs: { [id: string]: IToggleView }; + + constructor() { + this.navs = {}; + } + + public add(id: string, navItem: IToggleView) { + this.navs[id] = navItem; + } + + public remove(id: string) { + delete this.navs[id]; + } + + public get(id: string): IToggleView { + if (id) { + return this.navs[id]; + } + } + + public toggle(id: string, ...args) { + if (this.navs[id]) { + return this.navs[id].toggle(...args); + } + } + public open(id: string, ...args) { + if (this.navs[id]) { + return this.navs[id].open(...args); + } + } + public close(id: string, ...args) { + if (this.navs[id]) { + return this.navs[id].close(...args); + } + } +} diff --git a/projects/igniteui-angular/core/src/core/selection.spec.ts b/projects/igniteui-angular/core/src/core/selection.spec.ts new file mode 100644 index 00000000000..3adf6fe4d95 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/selection.spec.ts @@ -0,0 +1,32 @@ +import {IgxSelectionAPIService} from './selection'; + +describe('IgxSelectionAPIService', () => { + let service; + beforeEach(() => { + service = new IgxSelectionAPIService(); + }); + + it('call set method with undefined componentID', () => { + expect(() => service.set(undefined, new Set())).toThrowError('Invalid value for component id!'); + }); + + it('call add_item method with falsy itemID', () => { + const componentId = 'id1'; + service.set(componentId, new Set()); + + const selection1 = service.add_item(componentId, 0); + expect(selection1.has(0)).toBe(true); + + const selection2 = service.add_item(componentId, false); + expect(selection2.has(false)).toBe(true); + + const selection3 = service.add_item(componentId, null); + expect(selection3.has(null)).toBe(true); + + const selection4 = service.add_item(componentId, ''); + expect(selection4.has('')).toBe(true); + + const selection5 = service.add_item(componentId, NaN); + expect(selection5.has(NaN)).toBe(true); + }); +}); diff --git a/projects/igniteui-angular/core/src/core/selection.ts b/projects/igniteui-angular/core/src/core/selection.ts new file mode 100644 index 00000000000..f8d94ae7604 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/selection.ts @@ -0,0 +1,269 @@ +import { Injectable } from '@angular/core'; + +/** @hidden */ +@Injectable({ + providedIn: 'root', +}) +export class IgxSelectionAPIService { + /** + * If primaryKey is defined, then multiple selection is based on the primaryKey, and it is array of numbers, strings, etc. + * If the primaryKey is omitted, then selection is based on the item data + */ + protected selection: Map> = new Map>(); + + /** + * Get current component selection. + * + * @param componentID ID of the component. + */ + public get(componentID: string): Set { + return this.selection.get(componentID); + } + + /** + * Set new component selection. + * + * @param componentID ID of the component. + * @param newSelection The new component selection to be set. + */ + public set(componentID: string, newSelection: Set) { + if (!componentID) { + throw Error('Invalid value for component id!'); + } + this.selection.set(componentID, newSelection); + } + + /** + * Clears selection for component. + * + * @param componentID ID of the component. + */ + public clear(componentID: string) { + this.selection.set(componentID, this.get_empty()); + } + + /** + * Removes selection for a component. + * @param componentID + */ + public delete(componentID: string) { + this.selection.delete(componentID); + } + + /** + * Get current component selection length. + * + * @param componentID ID of the component. + */ + public size(componentID: string): number { + const sel = this.get(componentID); + return sel ? sel.size : 0; + } + + /** + * Creates new selection that consist of the new item added to the current component selection. + * The returned collection is new Set, + * therefore if you want to update component selection you need to call in addition the set_selection() method + * or instead use the select_item() one. + * + * @param componentID ID of the component, which we add new item to. + * @param itemID ID of the item to add to component selection. + * @param sel Used internally only by the selection (add_items method) to accumulate selection for multiple items. + * + * @returns Selection after the new item is added. + */ + public add_item(componentID: string, itemID, sel?: Set): Set { + if (!sel) { + sel = new Set(this.get(componentID)); + } + if (sel === undefined) { + sel = this.get_empty(); + } + sel.add(itemID); + return sel; + } + + /** + * Creates new selection that consist of the new items added to the current component selection. + * The returned collection is new Set, + * therefore if you want to update component selection you need to call in addition the set_selection() method + * or instead use the select_items() one. + * + * @param componentID ID of the component, which we add new items to. + * @param itemIDs Array of IDs of the items to add to component selection. + * @param clearSelection If true it will clear previous selection. + * + * @returns Selection after the new items are added. + */ + public add_items(componentID: string, itemIDs: any[], clearSelection?: boolean): Set { + let selection: Set; + if (clearSelection) { + selection = this.get_empty(); + } else if (itemIDs && itemIDs.length === 0) { + selection = new Set(this.get(componentID)); + } + itemIDs.forEach((item) => selection = this.add_item(componentID, item, selection)); + return selection; + } + + /** + * Add item to the current component selection. + * + * @param componentID ID of the component, which we add new item to. + * @param itemID ID of the item to add to component selection. + * @param sel Used internally only by the selection (select_items method) to accumulate selection for multiple items. + */ + public select_item(componentID: string, itemID, sel?: Set) { + this.set(componentID, this.add_item(componentID, itemID, sel)); + } + + /** + * Add items to the current component selection. + * + * @param componentID ID of the component, which we add new items to. + * @param itemIDs Array of IDs of the items to add to component selection. + * @param clearSelection If true it will clear previous selection. + */ + public select_items(componentID: string, itemID: any[], clearSelection?: boolean) { + this.set(componentID, this.add_items(componentID, itemID, clearSelection)); + } + + /** + * Creates new selection that consist of the new items excluded from the current component selection. + * The returned collection is new Set, + * therefore if you want to update component selection you need to call in addition the set_selection() method + * or instead use the deselect_item() one. + * + * @param componentID ID of the component, which we remove items from. + * @param itemID ID of the item to remove from component selection. + * @param sel Used internally only by the selection (delete_items method) to accumulate deselected items. + * + * @returns Selection after the item is removed. + */ + public delete_item(componentID: string, itemID, sel?: Set) { + if (!sel) { + sel = new Set(this.get(componentID)); + } + if (sel === undefined) { + return; + } + sel.delete(itemID); + return sel; + } + + /** + * Creates new selection that consist of the new items removed to the current component selection. + * The returned collection is new Set, + * therefore if you want to update component selection you need to call in addition the set_selection() method + * or instead use the deselect_items() one. + * + * @param componentID ID of the component, which we remove items from. + * @param itemID ID of the items to remove from component selection. + * + * @returns Selection after the items are removed. + */ + public delete_items(componentID: string, itemIDs: any[]): Set { + let selection: Set; + itemIDs.forEach((deselectedItem) => selection = this.delete_item(componentID, deselectedItem, selection)); + return selection; + } + + /** + * Remove item from the current component selection. + * + * @param componentID ID of the component, which we remove item from. + * @param itemID ID of the item to remove from component selection. + * @param sel Used internally only by the selection (deselect_items method) to accumulate selection for multiple items. + */ + public deselect_item(componentID: string, itemID, sel?: Set) { + this.set(componentID, this.delete_item(componentID, itemID, sel)); + } + + /** + * Remove items to the current component selection. + * + * @param componentID ID of the component, which we add new items to. + * @param itemIDs Array of IDs of the items to add to component selection. + */ + public deselect_items(componentID: string, itemID: any[], _clearSelection?: boolean) { + this.set(componentID, this.delete_items(componentID, itemID)); + } + + /** + * Check if the item is selected in the component selection. + * + * @param componentID ID of the component. + * @param itemID ID of the item to search. + * + * @returns If item is selected. + */ + public is_item_selected(componentID: string, itemID): boolean { + const sel = this.get(componentID); + if (!sel) { + return false; + } + return sel.has(itemID); + } + + /** + * Get first element in the selection. + * This is correct when we have only one item in the collection (for single selection purposes) + * and the method returns that item. + * + * @param componentID ID of the component. + * + * @returns First element in the set. + */ + public first_item(componentID: string) { + const sel = this.get(componentID); + if (sel && sel.size > 0) { + return sel.values().next().value; + } + } + + /** + * Returns whether all items are selected. + * + * @param componentID ID of the component. + * @param dataCount: number Number of items in the data. + * + * @returns If all items are selected. + */ + public are_all_selected(componentID: string, dataCount: number): boolean { + return dataCount > 0 && dataCount === this.size(componentID); + } + + /** + * Returns whether any of the items is selected. + * + * @param componentID ID of the component. + * @param data Entire data array. + * + * @returns If there is any item selected. + */ + public are_none_selected(componentID: string): boolean { + return this.size(componentID) === 0; + } + + /** + * Get all primary key values from a data array. If there isn't a primary key defined that the entire data is returned instead. + * + * @param data Entire data array. + * @param primaryKey Data primary key. + * + * @returns Array of identifiers, either primary key values or the entire data array. + */ + public get_all_ids(data, primaryKey?) { + // If primaryKey is 0, this should still map to the property + return primaryKey !== undefined && primaryKey !== null ? data.map((x) => x[primaryKey]) : data; + } + + /** + * Returns empty selection collection. + * + * @returns empty set. + */ + public get_empty() { + return new Set(); + } +} diff --git a/projects/igniteui-angular/core/src/core/setImmediate.ts b/projects/igniteui-angular/core/src/core/setImmediate.ts new file mode 100644 index 00000000000..ca3edd360ca --- /dev/null +++ b/projects/igniteui-angular/core/src/core/setImmediate.ts @@ -0,0 +1,72 @@ +/* Copyright (c) 2014-2020 Denis Pushkarev + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE + */ + +// Note: Originally copied from core-js-pure package and modified. (https://github.com/zloirock/core-js) + +const queue = {}; +let counter = 0; +let eventListenerAdded = false; + +declare global { + interface Window { + setImmediate: any; + clearImmediate: any; + } +} + +const run = (id) => { + if (queue.hasOwnProperty(id)) { + const fn = queue[id]; + delete queue[id]; + fn(); + } +}; + +const listener = (event) => run(event.data); + +// Use function instead of arrow function to workaround an issue in codesandbox +export function setImmediate(cb: () => void, ...args) { + if (window.setImmediate) { + return window.setImmediate(cb); + } + + if (!eventListenerAdded) { + eventListenerAdded = true; + window.addEventListener('message', listener, false); + } + + queue[++counter] = () => { + cb.apply(undefined, args); + }; + + const windowLocation = window.location; + window.postMessage(counter + '', windowLocation.protocol + '//' + windowLocation.host); + + return counter; +} + +export function clearImmediate(id: any) { + if (window.clearImmediate) { + return window.clearImmediate(id); + } + + delete queue[id]; +} diff --git a/projects/igniteui-angular/core/src/core/styles/README.md b/projects/igniteui-angular/core/src/core/styles/README.md new file mode 100644 index 00000000000..d0ae6325d46 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/README.md @@ -0,0 +1,44 @@ +## Ignite UI for Angular Themes + +### Overview +Since **IgniteUI for Angular** bases its component designs on the **Material Design Principles**, we try to get as close as possible to colors, sizes, typography, and overall look and feel of our components to those created by Google. + +Our approach to theming is based around several simple concepts. + +The Ignite UI for Angular theming library is based on [**Sass**](https://sass-lang.com). If you used the **Ignite UI CLI** to bootstrap your app, you can specify the style in the **angular.json** config to _scss_, the CLI will take care of compiling the Sass styles for you. If you haven't used Ignite UI CLI then you have to configure your builder to compile Sass styles for you. + +### Palettes + +The first concept is the one of palettes of colors. As in any visual tool, colors are the main differentiating factor between one application and another. The Material Design Guidelines prescribe predefined palettes of colors that range in hue and lightness for a base color. There's a good reason for that. They really want to ensure good color matching and contrast between the background colors and the foreground text colors. This is great, but limiting at the same time. If you wanted to have your own custom palette that matches your branding, you would be out of luck. We recognize this is a problem, so we invented an algorithm that would generate Material-like palettes from base colors you provide. Even more, we also generate contrast text colors for each hue in the palette. + + +### Schemas + +The second important concept revolves around theme schemas. Theme schemas are like recipes for component themes. They give individual component themes information about colors, margins, paddings, etc. For instance, a component scheme tells a component theme that the background color for an element should be the `500` variant from the `primary` palette, without caring what palette the user passes to the component theme. + + +### Themes + +Finally, we have component themes. Palettes and Schemas wouldn't do much good on their own if they weren't used by a theme. We have themes for individual components, and a global one, that styles the entire application and every component in it. You simply pass a palette and a schema to the global theme, we take care of the rest. You can, of course, style each component individually to your liking. + +### Typography + +Typography is a separate module in our Sass theming framework, which is decoupled from the component themes. Although we have a default typeface of choice, we really want to give you the power to style your application in every single way. Typography is such an important part of that. We provide a method for changing the font family, the sizes and weights for headings, subheadings, buttons, body text, etc. in your app. + +### Additional Resources + +Learn how to create themes: + +* [Global Themes](https://www.infragistics.com/products/ignite-ui-angular/angular/components/themes/global-theme.html) +* [Component Themes](https://www.infragistics.com/products/ignite-ui-angular/angular/components/themes/component-themes.html) + +Learn how to create a component schema: +* [Schemas](https://www.infragistics.com/products/ignite-ui-angular/angular/components/themes/sass/schemas) + +Learn how to build color palettes: +* [Palettes](https://www.infragistics.com/products/ignite-ui-angular/angular/components/themes/palette.html) + +Our community is active and always welcoming to new ideas. + +* [Ignite UI for Angular **Forums**](https://www.infragistics.com/community/forums/f/ignite-ui-for-angular) +* [Ignite UI for Angular **GitHub**](https://github.com/IgniteUI/igniteui-angular) diff --git a/projects/igniteui-angular/core/src/core/styles/base/_functions.scss b/projects/igniteui-angular/core/src/core/styles/base/_functions.scss new file mode 100644 index 00000000000..e15608cb283 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/base/_functions.scss @@ -0,0 +1,65 @@ +@use 'variables' as *; +@use 'sass:map'; +@use 'sass:list'; +@use 'sass:string'; + +/// Get the difference between two lists. +/// @access private +/// @param {List} $list1 - The source list. +/// @param {List} $list2 - The list to check against the source list. +/// @return {List} - A list containing the diff items. +@function list-diff($list1, $list2) { + $result: (); + + @each $item in $list1 { + @if not list.index($list2, $item) { + $result: list.append($result, $item, comma); + } + } + + @return $result; +} + +/// @group Utilities +/// @author Simeon Simeonoff +/// @access private +@function is-used($component, $checklist) { + $used: true; + + @if list.index($checklist, $component) { + $deps: map.get($components, $component, 'usedBy'); + $excluded: (); + + @each $item in $checklist { + @if list.index($deps, $item) { + $excluded: list.append($excluded, $item); + } + } + + $used: list.length($deps) != list.length($excluded); + + @if not($used) { + $dropped-themes: list.append($dropped-themes, $component) !global; + } @else { + $remaining: list-diff($deps, $excluded); + + @warn string.unquote('You\'ve opted to exclude the "#{$component}" theme but it was held back as the following components depend on it: "#{$remaining}".'); + } + } + + @return $used; +} + +/// Test if a component, or list of components is in the list of known components. +/// @access private +/// @param {String|List} $items - The components list to check in. +/// @return {List} - The list of passed items. +@function is-component($items) { + $register: map.keys($components); + @each $item in $items { + @if not(list.index($register, $item)) { + @warn string.unquote('Can\'t exclude "#{$item}" because it is not in the list of known components.'); + } + } + @return $items; +} diff --git a/projects/igniteui-angular/core/src/core/styles/base/_index.scss b/projects/igniteui-angular/core/src/core/styles/base/_index.scss new file mode 100644 index 00000000000..58ff1cc14cf --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/base/_index.scss @@ -0,0 +1,10 @@ +@forward 'functions'; +@forward 'mixins'; +@forward 'variables'; +@forward 'igniteui-theming/sass/bem'; +@forward 'igniteui-theming/sass/color'; +@forward 'igniteui-theming/sass/elevations'; +@forward 'igniteui-theming/sass/themes'; +@forward 'igniteui-theming/sass/themes/components'; +@forward 'igniteui-theming/sass/typography' hide typography; +@forward 'igniteui-theming/sass/utils'; diff --git a/projects/igniteui-angular/core/src/core/styles/base/_mixins.scss b/projects/igniteui-angular/core/src/core/styles/base/_mixins.scss new file mode 100644 index 00000000000..0084a10ee22 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/base/_mixins.scss @@ -0,0 +1,146 @@ +@use 'igniteui-theming/sass/color/functions' as *; +@use 'igniteui-theming/sass/color/presets/light/material' as *; +@use 'variables' as *; +@use 'functions' as *; +@use 'sass:color'; +@use 'sass:list'; +@use 'sass:map'; +@use 'sass:math'; +@use 'sass:meta'; +@use 'sass:string'; + +//// +/// @group Utilities +/// @author Simeon Simeonoff +//// + +/// Registers a component to the list of known components. +/// @access private +/// @param {String} $name - The component name. +/// @param {List} $deps - A list of all components the component depends on. +@mixin register-component($name, $deps: ()) { + $component: map.get($components, $name); + $deps: if($component, map.get($component, 'deps'), $deps); + $usedBy: if($component, map.get($component, 'usedBy'), ()); + + $components: map.merge($components, ( + #{$name}: ( + deps: $deps, + usedBy: $usedBy + ) + )) !global; +} + +/// Adds dependecies to a component's internal usedBy list. +/// @access private +/// @param {String} $component - The component name. +/// @param {List} $deps - The dependecies to be added to the component's usedBy list. +@mixin add-deps($component, $deps) { + @each $dep in $deps { + $c: map.get($components, $dep); + $d: (); + $u: (); + + @if $c { + $cu: map.get($c, 'usedBy'); + + $d: map.get($c, 'deps'); + + @if not list.index($cu, $component) { + $u: list.append($cu, $component, comma); + } @else { + $u: $cu; + } + } @else { + $u: list.append($u, $component, comma); + } + + $components: map.merge($components, ( + #{$dep}: ( + deps: $d, + usedBy: $u + ) + )) !global; + + @include add-deps($component, $d); + } +} + +/// Modifies the global components register by updating the usedBy list for each component. +/// @access private +/// @param {Map} $components - A map of all registered components. +@mixin dependecy-tree($components) { + @each $c in $components { + $component: list.nth($c, 1); + $deps: map.get($components, $component, 'deps'); + + @include add-deps($component, $deps); + } +} + +/// Generates a CSS class name for a color from a +/// given name, variant, prefix and suffix +/// @access private +/// @param {string} $name - The main class name. +/// @param {string} $variant - An additional string to be attached to the main class name. +/// @param {string} $prefix - A prefix to be attached to the name and variant string. +/// @param {string} $prefix - A suffix to be attached to the name and variant string. +@mixin gen-color-class($name, $variant, $prefix, $suffix) { + $prefix: if($prefix, '#{$prefix}-', ''); + $suffix: if($suffix, '-#{$suffix}', ''); + $_name: '' + $name; + + .#{$prefix}#{$_name}-#{$variant}#{$suffix} { + @content; + } +} + +// stylelint-disable max-nesting-depth +/// Generates CSS class names for all colors from +/// for a given property and color palette, with +/// optional prefix and suffix attached to the class name. +/// @access private +/// @param {string} $prop - The CSS property to assign the palette color to. +/// @param {string} $prefix - A prefix to be attached to the class name. +/// @param {string} $suffix - A suffix to be attached to the class name. +/// @param {Map} $palette [$default-palette] - The palette to use to generate css class names for. +/// @example scss Generate background classes with colors from the palette. +/// // Will generate class names like +/// // .my-primary-500-bg { ... }; +/// @include gen-color-classes( +/// $prop: 'background-color', +/// $prefix: 'my', +/// $suffix: 'bg' +/// ); +/// @requires {mixin} gen-color-class +@mixin gen-color-classes($prop, $prefix, $suffix) { + @each $name, $color in $palette { + @each $variant, $value in $color { + @if meta.type-of($value) != 'map' { + @include gen-color-class($name, $variant, $prefix, $suffix) { + #{$prop}: color($color: $name, $variant: $variant); + } + } + } + } +} + +/// Generates CSS class names for all colors from +/// for a given property and color palette, with +/// optional prefix and suffix attached to the class name. +/// @access public +/// @param {string} $prop - The CSS property to assign the palette color to. +/// @param {string} $prefix [igx] - A prefix to be attached to the class name. +/// @param {string} $suffix [null] - A suffix to be attached to the class name. +/// @param {Map} $palette [$default-palette] - The igx palette to use to generate css class names for. +/// @example scss Generate background classes with colors from the palette. +/// // Will generate class names like +/// // .igx-primary-500-bg { ... }; +/// @include color-classes( +/// $prop: 'background-color', +/// $suffix: 'bg' +/// ); +/// @requires {mixin} gen-color-classes +@mixin color-classes($prop, $suffix: null, $prefix: 'igx') { + @include gen-color-classes($prop, $prefix, $suffix); +} diff --git a/projects/igniteui-angular/core/src/core/styles/base/_variables.scss b/projects/igniteui-angular/core/src/core/styles/base/_variables.scss new file mode 100644 index 00000000000..75cb715ec87 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/base/_variables.scss @@ -0,0 +1,12 @@ +//// +/// @group base +/// @access private +/// @author Simeon Simeonoff +//// + +/// The global component registry map. +/// @type List +$components: () !default; + +/// Stores a list of dropped component themes. +$dropped-themes: () !default; diff --git a/projects/igniteui-angular/core/src/core/styles/components/_common/_igx-control.scss b/projects/igniteui-angular/core/src/core/styles/components/_common/_igx-control.scss new file mode 100644 index 00000000000..ae74aa3107d --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/_common/_igx-control.scss @@ -0,0 +1,6 @@ +%igx-control-display { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 0; +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/_common/_igx-display-container.scss b/projects/igniteui-angular/core/src/core/styles/components/_common/_igx-display-container.scss new file mode 100644 index 00000000000..fa2b98be6ec --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/_common/_igx-display-container.scss @@ -0,0 +1,24 @@ +@use 'igniteui-theming/sass/bem' as *; + +%display-container { + display: inherit; + flex-flow: inherit; + position: relative; + width: 100%; + overflow: hidden; + flex-shrink: 0; +} + +%display-container--inactive { + width: 100%; +} + +@mixin component { + @include b(igx-display-container) { + @extend %display-container !optional; + + @include m(inactive) { + @extend %display-container--inactive !optional; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/_common/_igx-drag.scss b/projects/igniteui-angular/core/src/core/styles/components/_common/_igx-drag.scss new file mode 100644 index 00000000000..3003d80542b --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/_common/_igx-drag.scss @@ -0,0 +1,27 @@ +@use 'igniteui-theming/sass/bem' as *; + +%drag { + touch-action: none; +} + +%drag--select-disabled { + user-select: none; +} + +%drag-handle { + user-select: none; +} + +@mixin component { + @include b(igx-drag) { + @extend %drag !optional; + + @include e(handle) { + @extend %drag-handle !optional; + } + + @include m(select-disabled) { + @extend %drag--select-disabled !optional; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/_common/_igx-vhelper.scss b/projects/igniteui-angular/core/src/core/styles/components/_common/_igx-vhelper.scss new file mode 100644 index 00000000000..5c623bf1e34 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/_common/_igx-vhelper.scss @@ -0,0 +1,50 @@ +@use 'igniteui-theming/sass/bem' as *; + +/// @group themes +/// @access private +%vhelper-display { + display: block; + overflow: auto; + z-index: 10001; +} + +%vhelper--vertical { + position: absolute; + top: 0; + inset-inline-end: 0; + width: var(--vhelper-scrollbar-size); +} + +%vhelper--horizontal { + width: 100%; +} + +%vhelper-content--vertical { + width: 1px; +} + +%vhelper-content--horizontal { + height: 1px; +} + +@mixin component { + @include b(igx-vhelper) { + @include m(vertical) { + @extend %vhelper-display !optional; + @extend %vhelper--vertical !optional; + + @include e(placeholder-content) { + @extend %vhelper-content--vertical !optional; + } + } + + @include m(horizontal) { + @extend %vhelper-display !optional; + @extend %vhelper--horizontal !optional; + + @include e(placeholder-content) { + @extend %vhelper-content--horizontal !optional; + } + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/_index.scss b/projects/igniteui-angular/core/src/core/styles/components/_index.scss new file mode 100644 index 00000000000..5f0d6a09fc6 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/_index.scss @@ -0,0 +1,55 @@ +@forward 'action-strip/action-strip-theme'; +@forward 'avatar/avatar-theme'; +@forward 'badge/badge-theme'; +@forward 'banner/banner-theme'; +@forward 'bottom-nav/bottom-nav-theme'; +@forward 'button/button-theme'; +@forward 'button-group/button-group-theme'; +@forward 'card/card-theme'; +@forward 'calendar/calendar-theme'; +@forward 'carousel/carousel-theme'; +@forward 'checkbox/checkbox-theme'; +@forward 'chip/chip-theme'; +@forward 'column-actions/column-actions-theme'; +@forward 'combo/combo-theme'; +@forward 'select/select-theme'; +@forward 'date-picker/date-picker-theme'; +@forward 'date-range-picker/date-range-picker-theme'; +@forward 'dialog/dialog-theme'; +@forward 'divider/divider-theme'; +@forward 'dock-manager/dock-manager-theme'; +@forward 'rating/rating-theme'; +@forward 'drop-down/drop-down-theme'; +@forward 'expansion-panel/expansion-panel-theme'; +@forward 'grid/grid-theme'; +@forward 'grid/pivot-data-selector-theme'; +@forward 'grid-summary/grid-summary-theme'; +@forward 'grid-toolbar/grid-toolbar-theme'; +@forward 'highlight/highlight-theme'; +@forward 'icon/icon-theme'; +@forward 'icon-button/icon-button-theme'; +@forward 'input/input-group-theme'; +@forward 'label/label-theme'; +@forward 'list/list-theme'; +@forward 'navbar/navbar-theme'; +@forward 'navdrawer/navdrawer-theme'; +@forward 'overlay/overlay-theme'; +@forward 'paginator/paginator-theme'; +@forward 'progress/linear/linear-theme'; +@forward 'progress/circular/circular-theme'; +@forward 'radio/radio-theme'; +@forward 'ripple/ripple-theme'; +@forward 'query-builder/query-builder-theme'; +@forward 'scrollbar/scrollbar-theme'; +@forward 'slider/slider-theme'; +@forward 'snackbar/snackbar-theme'; +@forward 'splitter/splitter-theme'; +@forward 'switch/switch-theme'; +@forward 'stepper/stepper-theme'; +@forward 'tabs/tabs-theme'; +@forward 'time-picker/time-picker-theme'; +@forward 'toast/toast-theme'; +@forward 'tooltip/tooltip-theme'; +@forward 'tree/tree-theme'; +@forward 'watermark/watermark-theme'; +@forward 'input/file-input-theme'; diff --git a/projects/igniteui-angular/core/src/core/styles/components/action-strip/_action-strip-component.scss b/projects/igniteui-angular/core/src/core/styles/components/action-strip/_action-strip-component.scss new file mode 100644 index 00000000000..637ad64a458 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/action-strip/_action-strip-component.scss @@ -0,0 +1,50 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin component { + @include b(igx-action-strip) { + // Register the component in the component registry + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: ( + igx-icon-button, + igx-drop-down, + igx-icon, + ) + ); + + @extend %igx-action-strip !optional; + + @include e(actions) { + @extend %igx-action-strip__actions !optional; + } + + @include e(delete) { + @extend %igx-action-strip__delete !optional; + } + + @include e(editing-actions) { + @extend %igx-action-strip__editing-actions !optional; + } + + @include e(pinning-actions) { + @extend %igx-action-strip__pinning-actions !optional; + } + + @include e(menu-item) { + @extend %igx-action-strip__menu-item !optional; + } + + @include e(menu-item, $m: 'danger') { + @extend %igx-action-strip__menu-item !optional; + @extend %igx-action-strip__menu-item--danger !optional; + } + + @include e(menu-button) { + @extend %igx-action-strip__menu-button !optional; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/action-strip/_action-strip-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/action-strip/_action-strip-theme.scss new file mode 100644 index 00000000000..26c2c7dd010 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/action-strip/_action-strip-theme.scss @@ -0,0 +1,147 @@ +@use 'sass:map'; +@use '../../base' as *; +@use '../../themes/schemas' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin action-strip($theme) { + @include css-vars($theme); + + $variant: map.get($theme, '_meta', 'theme'); + $icon-button-size: map.get(( + 'material': rem(36px), + 'fluent': rem(32px), + 'bootstrap': rem(36px), + 'indigo': rem(36px), + ), $variant); + $button-size: sizable(rem(28px), rem(28px), $icon-button-size); + $icon-size: sizable(rem(14px), rem(14px), rem(18px)); + + %igx-action-strip { + @include sizable(); + --action-strip-size: var(--component-size); + + display: flex; + align-items: center; + justify-content: flex-end; + position: absolute; + width: 100%; + height: 100%; + pointer-events: none; + top: 0; + inset-inline-start: 0; + background: var-get($theme, 'background'); + color: inherit; + padding-inline: pad-inline(rem(12px), rem(16px), rem(24px)); + padding-block: 0; + z-index: 9999; + + [igxIconButton] { + --component-size: var(--action-strip-size) !important; + + width: $button-size; + height: $button-size; + + igx-icon { + --component-size: var(--action-strip-size); + + width: var(--ig-icon-size, $icon-size); + height: var(--ig-icon-size, $icon-size); + font-size: var(--ig-icon-size, $icon-size); + } + } + } + + %igx-action-strip__editing-actions, + %igx-action-strip__pinning-actions { + display: flex; + align-items: center; + justify-content: center; + } + + %igx-action-strip__menu-item { + [igxLabel] { + cursor: pointer; + } + + igx-icon { + --component-size: var(--action-strip-size); + + width: var(--igx-icon-size, rem(18px)); + height: var(--igx-icon-size, rem(18px)); + font-size: var(--igx-icon-size, rem(18px)); + } + + &%igx-drop-down__item { + igx-icon + [igxLabel] { + margin-inline-start: pad-inline(rem(8px), rem(10px), rem(12px)); + } + } + } + + %igx-action-strip__menu-item--danger { + color: color($color: 'error'); + + &:hover { + color: color($color: 'error'); + } + } + + %igx-action-strip__menu-button { + display: flex; + align-items: center; + } + + %igx-action-strip__actions { + display: inline-flex; + align-items: center; + justify-content: center; + pointer-events: all; + position: relative; + color: var-get($theme, 'icon-color'); + border-radius: var-get($theme, 'actions-border-radius'); + background: var-get($theme, 'actions-background'); + max-height: rem(36px); + + &:last-child { + margin-inline-end: 0; + } + + igx-icon { + color: var-get($theme, 'icon-color'); + } + + [igxIconButton] { + igx-icon { + color: var-get($theme, 'icon-color'); + } + + @if $variant == 'indigo' or $variant == 'fluent' { + border-radius: var-get($theme, 'actions-border-radius'); + } + } + } + + %igx-action-strip__editing-actions { + > [igxButton] { + margin-inline-start: rem(4px); + + &:first-of-type { + margin-inline-start: 0; + } + } + } + + %igx-action-strip__delete { + igx-icon { + color: var-get($theme, 'delete-action'); + } + + [igxIconButton] { + igx-icon { + color: var-get($theme, 'delete-action'); + } + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/avatar/_avatar-component.scss b/projects/igniteui-angular/core/src/core/styles/components/avatar/_avatar-component.scss new file mode 100644 index 00000000000..69f51e2e839 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/avatar/_avatar-component.scss @@ -0,0 +1,36 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin component { + @include b(igx-avatar) { + // Register the component in the component registry + $this: bem--selector-to-string(&); + + @include register-component( + $name: string.slice($this, 2, -1), + $deps: ( + igx-icon, + ) + ); + + @extend %igx-avatar-display !optional; + + @include e(image) { + @extend %igx-avatar-image !optional; + } + + @include m(circle) { + @extend %igx-avatar--circle !optional; + } + + @include m(rounded) { + @extend %igx-avatar--rounded !optional; + } + + @include m(initials) { + @extend %igx-avatar--initials !optional; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/avatar/_avatar-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/avatar/_avatar-theme.scss new file mode 100644 index 00000000000..c01b4128d44 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/avatar/_avatar-theme.scss @@ -0,0 +1,66 @@ +@use 'sass:map'; +@use '../../base' as *; +@use 'igniteui-theming/sass/animations/easings' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin avatar($theme) { + @include css-vars($theme); + + $variant: map.get($theme, '_meta', 'theme'); + + %igx-avatar-display { + @include sizable(); + + --component-size: var(--ig-size, #{var-get($theme, 'default-size')}); + + position: relative; + display: inline-flex; + justify-content: center; + align-items: center; + user-select: none; + color: var-get($theme, 'color'); + background: var-get($theme, 'background'); + vertical-align: middle; + outline-style: none; + flex-shrink: 0; + width: var-get($theme, 'size'); + height: var-get($theme, 'size'); + + igx-icon { + --component-size: 3; + + color: var-get($theme, 'icon-color'); + } + + @if $variant == 'indigo' { + igx-icon { + --component-size: 1; + } + } + } + + %igx-avatar-image { + width: 100%; + height: 100%; + border-radius: inherit; + background-size: cover; + background-repeat: no-repeat; + background-position: center; + } + + %igx-avatar--circle { + border-radius: calc(#{var-get($theme, 'size')} / 2); + } + + %igx-avatar--rounded { + border-radius: var-get($theme, 'border-radius'); + } + + %igx-avatar--initials { + text-transform: uppercase; + font-size: calc(#{var-get($theme, 'size')} / 2); + line-height: calc(#{var-get($theme, 'size')} / 2); + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/badge/_badge-component.scss b/projects/igniteui-angular/core/src/core/styles/components/badge/_badge-component.scss new file mode 100644 index 00000000000..a630908f0f7 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/badge/_badge-component.scss @@ -0,0 +1,54 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin component { + @include b(igx-badge) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: ( + igx-icon, + ) + ); + + @extend %igx-badge-display !optional; + + @include e(value) { + @extend %igx-badge-value !optional; + } + + @include m(info) { + @extend %igx-badge--info !optional; + } + + @include m(success) { + @extend %igx-badge--success !optional; + } + + @include m(warning) { + @extend %igx-badge--warn !optional; + } + + @include m(error) { + @extend %igx-badge--error !optional; + } + + @include m(dot) { + @extend %igx-badge--dot !optional; + } + + @include m(outlined) { + @extend %igx-badge--outlined !optional; + } + + @include m(square) { + @extend %igx-badge--square !optional; + } + + @include m(hidden) { + @extend %igx-badge--hidden !optional; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/badge/_badge-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/badge/_badge-theme.scss new file mode 100644 index 00000000000..47555fe0140 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/badge/_badge-theme.scss @@ -0,0 +1,143 @@ +@use '../../base' as *; +@use 'sass:map'; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin badge($theme) { + @include css-vars($theme); + $theme-variant: map.get($theme, '_meta', 'variant'); + $variant: map.get($theme, '_meta', 'theme'); + + %igx-badge-display { + @include sizable(); + + --component-size: var(--ig-size, #{var-get($theme, 'default-size')}); + --badge-size: var(--component-size); + --_badge-size: #{var-get($theme, 'size')}; + + display: inline-flex; + justify-content: center; + align-items: center; + min-width: var(--_badge-size); + min-height: var(--_badge-size); + color: var-get($theme, 'text-color'); + background: var-get($theme, 'background-color'); + border-radius: calc(var(--size) / 2); + box-shadow: var-get($theme, 'elevation'); + overflow: hidden; + + igx-icon { + --size: var(--igx-icon-size, #{sizable(rem(12px), rem(14px), rem(16px))}); + --component-size: var(--badge-size); + + display: inline-flex; + justify-content: center; + align-items: center; + color: var-get($theme, 'icon-color'); + } + + @if $variant == 'indigo' { + igx-icon { + $icon-size: sizable(rem(8px), rem(10px), rem(12px)); + + --ig-icon-size: #{$icon-size}; + --igx-icon-size: #{$icon-size}; + } + } + } + + %igx-badge--outlined { + box-shadow: 0 0 0 rem(2px) var-get($theme, 'border-color'); + } + + %igx-badge--square { + border-radius: var-get($theme, 'border-radius'); + } + + %igx-badge-value { + white-space: nowrap; + padding-inline: pad-inline(rem(4px), rem(6px), if($variant == 'indigo', rem(6px), rem(8px))); + } + + %igx-badge--success { + background: color($color: 'success', $variant: if($variant != 'material', if($variant == 'indigo', 700, 500), 900)); + } + + %igx-badge--info { + background: color($color: 'info', $variant: if($variant != 'material', if($variant == 'fluent', 700, 500), 800)); + } + + %igx-badge--warn { + background: color($color: 'warn'); + + @if $variant == 'indigo' and $theme-variant == 'light' { + color: color($color: 'gray', $variant: 900); + + igx-icon { + color: color($color: 'gray', $variant: 900); + } + } @else if $variant == 'indigo' and $theme-variant == 'dark' { + color: color($color: 'gray', $variant: 50); + + igx-icon { + color: color($color: 'gray', $variant: 50); + } + } @else { + color: contrast-color($color: 'warn', $variant: 500); + + igx-icon { + color: contrast-color($color: 'warn', $variant: 500); + } + } + } + + %igx-badge--error { + background: color($color: 'error', $variant: if($variant == 'material', 700, 500)); + color: contrast-color($color: 'error', $variant: if($variant == 'bootstrap', 100, 900)); + } + + %igx-badge--dot { + --_dot-size: #{var-get($theme, 'dot-size')}; + + min-width: var(--_dot-size); + min-height: var(--_dot-size); + padding: 0; + + igx-icon, + > * { + display: none; + } + } + + %igx-badge--hidden { + visibility: hidden; + } +} + +/// Adds typography styles for the igx-badge component. +/// Uses 'caption' and 'body-2' categories from the typographic scale. +/// @group typography +/// @param {Map} $categories [(text: 'caption')] - The categories from the typographic scale used for type styles. +@mixin badge-typography($categories: (text: null), $theme: null) { + $text: map.get($categories, 'text'); + + %igx-badge-display { + @if $text { + @include type-style($text); + } @else { + @if $theme == 'indigo' { + @include type-style('button', false) { + font-size: sizable(rem(9px), rem(10px), var(--ig-button-font-size)); + line-height: sizable(rem(12px), rem(14px), var(--ig-button-line-height)); + } + } @else { + font-size: sizable(var(--ig-caption-font-size), var(--ig-body-2-font-size), var(--ig-body-2-font-size)); + font-weight: sizable(var(--ig-caption-font-weight), var(--ig-body-2-font-weight), var(--ig-body-2-font-weight)); + line-height: sizable(var(--ig-caption-line-height), var(--ig-body-2-line-height), var(--ig-body-2-line-height)); + letter-spacing: sizable(var(--ig-caption-letter-spacing), var(--ig-body-2-letter-spacing), var(--ig-body-2-letter-spacing)); + text-transform: sizable(var(--ig-caption-text-transform), var(--ig-body-2-text-transform), var(--ig-body-2-text-transform)); + } + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/banner/_banner-component.scss b/projects/igniteui-angular/core/src/core/styles/components/banner/_banner-component.scss new file mode 100644 index 00000000000..645ad2c6767 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/banner/_banner-component.scss @@ -0,0 +1,41 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Marin Popov +@mixin component { + @include b(igx-banner-host) { + @extend %igx-banner-host !optional; + } + + @include b(igx-banner) { + // Register the component in the component registry + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: ( + igx-button, + igx-expansion-panel, + igx-icon, + ) + ); + + @extend %igx-banner !optional; + + @include e(message) { + @extend %igx-banner__message !optional; + } + + @include e(illustration) { + @extend %igx-banner__illustration !optional; + } + + @include e(text) { + @extend %igx-banner__text !optional; + } + + @include e(actions) { + @extend %igx-banner__actions !optional; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/banner/_banner-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/banner/_banner-theme.scss new file mode 100644 index 00000000000..dfb88eff7af --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/banner/_banner-theme.scss @@ -0,0 +1,114 @@ +@use 'sass:map'; +@use '../../base' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin banner($theme) { + @include css-vars($theme); + $variant: map.get($theme, '_meta', 'theme'); + + %igx-banner-host { + igx-expansion-panel-body { + padding: 0; + } + } + + %igx-banner__actions, + %igx-banner__actions > igx-banner-actions, + %igx-banner__illustration, + %igx-banner__message { + display: flex; + } + + %igx-banner__illustration, + %igx-banner__message { + align-items: center; + } + + %igx-banner { + @include sizable(); + --component-size: var(--ig-size, var(--ig-size-large)); + + display: flex; + justify-content: flex-end; + flex-wrap: wrap; + gap: rem(8px); + padding: pad-block(rem(16px)) pad-inline(rem(8px)); + min-width: rem(320px); + background: var-get($theme, 'banner-background'); + box-shadow: inset 0 rem(-1px) 0 0 var-get($theme, 'banner-border-color'); + border-radius: var-get($theme, 'border-radius'); + + @if $variant == 'indigo' { + box-shadow: inset 0 0 0 rem(1px) var-get($theme, 'banner-border-color'); + padding: pad(rem(16px)); + } + + igc-icon, + igx-icon, + igc-button, + [igxButton] { + --component-size: var(--ig-size, var(--ig-size-large)); + + @if $variant == 'indigo' { + --component-size: var(--ig-size, var(--ig-size-medium)); + } + } + } + + %igx-banner__illustration { + justify-content: center; + color: var-get($theme, 'banner-illustration-color'); + } + + %igx-banner__text { + color: var-get($theme, 'banner-message-color'); + flex: 1 0 0%; + + > * { + margin-block-start: 0 !important; + } + } + + %igx-banner__message { + min-width: rem(150px); + flex: 1 0 0%; + gap: rem(16px); + + @if $variant == 'indigo' { + gap: rem(8px); + } @else { + padding: 0 pad-inline(rem(8px)); + } + } + + %igx-banner__actions, + %igx-banner__actions > igx-banner-actions { + flex-wrap: wrap; + align-self: flex-end; + gap: rem(8px); + + > a { + display: inline-flex; + align-items: center; + } + } +} + +/// Adds typography styles for the igx-banner component. +/// Uses the 'body-2' category from the typographic scale. +/// @group typography +/// @param {Map} $categories [(message: 'body-2')] - The categories from the typographic scale used for type styles. +@mixin banner-typography($categories: ( + message: 'body-2') +) { + $message: map.get($categories, 'message'); + + %igx-banner__text { + @include type-style($message) { + margin-block-start: 0; + margin-block-end: 0; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/bottom-nav/_bottom-nav-component.scss b/projects/igniteui-angular/core/src/core/styles/components/bottom-nav/_bottom-nav-component.scss new file mode 100644 index 00000000000..1e81b00d0a4 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/bottom-nav/_bottom-nav-component.scss @@ -0,0 +1,74 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin component { + @include b(igx-bottom-nav) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: ( + igx-icon, + ) + ); + + @include e(panel) { + @extend %igx-bottom-nav-panel !optional; + } + + @include e(menu) { + @extend %igx-bottom-nav-menu !optional; + } + + @include e(menu, $m: top) { + @extend %igx-bottom-nav-menu--top !optional; + } + + @include e(menu, $m: bottom) { + @extend %igx-bottom-nav-menu--bottom !optional; + } + + @include e(menu-item) { + @extend %igx-bottom-nav-menu-item !optional; + + [igxBottomNavHeaderIcon] { + @extend %igx-tab-icon !optional; + } + + [igxBottomNavHeaderLabel] { + @extend %igx-tab-label !optional; + } + } + + @include e(menu-item, $m: selected) { + @extend %igx-bottom-nav-menu-item !optional; + @extend %igx-bottom-nav-menu-item--selected !optional; + + [igxBottomNavHeaderIcon] { + @extend %igx-tab-icon !optional; + @extend %igx-tab-icon--selected !optional; + } + + [igxBottomNavHeaderLabel] { + @extend %igx-tab-label !optional; + @extend %igx-tab-label--selected !optional; + } + } + + @include e(menu-item, $m: disabled) { + @extend %igx-bottom-nav-menu-item !optional; + @extend %igx-bottom-nav-menu-item--disabled !optional; + + [igxBottomNavHeaderIcon] { + @extend %igx-tab-icon !optional; + @extend %igx-tab-icon--disabled !optional; + } + + [igxBottomNavHeaderLabel] { + @extend %igx-tab-label !optional; + @extend %igx-tab-label--disabled !optional; + } + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/bottom-nav/_bottom-nav-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/bottom-nav/_bottom-nav-theme.scss new file mode 100644 index 00000000000..549b33c75ed --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/bottom-nav/_bottom-nav-theme.scss @@ -0,0 +1,165 @@ +@use 'sass:map'; +@use '../../base' as *; +@use 'igniteui-theming/sass/animations/easings' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin bottom-nav($theme) { + @include css-vars($theme); + + $variant: map.get($theme, '_meta', 'theme'); + $menu-height: rem(56px); + $item-min-width: rem(80px); + $item-max-width: rem(168px); + $item-padding: 0 pad-inline(rem(12px, 16px)); + + %igx-bottom-nav-panel { + display: block; + + &:focus { + outline-style: none; + } + + &:empty { + display: none; + } + } + + %igx-bottom-nav-menu { + display: flex; + position: fixed; + justify-content: center; + align-items: center; + inset-inline-start: 0; + inset-inline-end: 0; + height: $menu-height; + background: var-get($theme, 'background'); + border-top: rem(1px) solid var-get($theme, 'border-color'); + overflow: hidden; + z-index: 8; + } + + %igx-bottom-nav-menu--top { + inset-block-start: 0; + inset-block-end: inherit; + box-shadow: var-get($theme, 'elevation'); + } + + %igx-bottom-nav-menu--bottom { + inset-block-start: inherit; + inset-block-end: 0; + box-shadow: var-get($theme, 'elevation'); + } + + %igx-bottom-nav-menu-item { + display: flex; + position: relative; + flex-flow: column nowrap; + flex: 1; + gap: rem(4px); + align-items: center; + justify-content: center; + min-width: $item-min-width; + max-width: $item-max-width; + height: 100%; + cursor: pointer; + user-select: none; + overflow: hidden; + padding: $item-padding; + -webkit-tap-highlight-color: transparent; + outline-style: none; + color: var-get($theme, 'label-color'); + + igx-icon { + --component-size: 3; + + @if $variant == 'indigo' { + --size: rem(16px); + } + } + } + + %igx-bottom-nav-menu-item--disabled { + @if $variant != 'indigo' { + opacity: .5; + } + + cursor: default; + pointer-events: none; + } + + %igx-bottom-nav-menu-item--selected { + transition: color .15s $in-out-quad, opacity .25s $in-out-quad; + } + + %igx-tab-label { + @include ellipsis(); + + color: var-get($theme, 'label-color'); + max-width: 100%; + text-align: center; + transform: translateZ(0); + transition: transform .15s $in-out-quad; + z-index: 1; + } + + %igx-tab-label--disabled { + color: var-get($theme, 'label-disabled-color'); + } + + %igx-tab-icon { + display: flex; + position: relative; + justify-content: center; + color: var-get($theme, 'icon-color'); + + @if $variant != 'indigo' { + padding: 0 pad-inline(rem(8px)); + height: rem(24px); + transform: translateZ(0); + transition: transform .15s $in-out-quad; + } + + z-index: 1; + } + + %igx-tab-icon--disabled { + color: var-get($theme, 'icon-disabled-color'); + } + + %igx-tab-icon--selected { + color: var-get($theme, 'icon-selected-color'); + + inset-block-start: rem(-2px); + } + + %igx-tab-label--selected { + color: var-get($theme, 'label-selected-color'); + } + + @if $variant != 'indigo' { + %igx-tab-icon--selected { + transform: translateY(-2px); + } + + %igx-tab-label--selected { + transform: translateY(-2px) scale(1.166667); + } + } +} + +/// Adds typography styles for the igx-bottom-nav component. +/// Uses the 'caption' +/// category from the typographic scale. +/// @group typography +/// @param {Map} $categories [(label: 'caption')] - The categories from the typographic scale used for type styles. +@mixin bottom-nav-typography($categories: (label: 'caption')) { + $label: map.get($categories, 'label'); + + %igx-tab-label { + @include type-style($label) { + margin: 0 + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/button-group/_button-group-component.scss b/projects/igniteui-angular/core/src/core/styles/components/button-group/_button-group-component.scss new file mode 100644 index 00000000000..9e7251d2737 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/button-group/_button-group-component.scss @@ -0,0 +1,42 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin component { + @include b(igx-button-group) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: ( + igx-button, + ) + ); + + @extend %igx-group-display !optional; + + @include e(item) { + @extend %igx-group-item !optional; + } + + @include e(item, $m: selected) { + @extend %igx-group-item-selected !optional; + } + + @include e(item-content) { + @extend %igx-group-item-content !optional; + } + + @include e(button-text) { + @extend %igx-button-group__button-text !optional; + } + + @include m(vertical) { + @extend %igx-group-vertical !optional; + + @include e(item) { + @extend %igx-group-item-vertical !optional; + } + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/button-group/_button-group-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/button-group/_button-group-theme.scss new file mode 100644 index 00000000000..8757e17a0c0 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/button-group/_button-group-theme.scss @@ -0,0 +1,592 @@ +@use 'sass:map'; +@use '../../base' as *; +@use 'igniteui-theming/sass/animations/easings' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin button-group($theme) { + @include css-vars($theme); + + $group-item-border-thickness: rem(1px); + $group-items-margin: rem(10px, 16px); + $outline-btn-indent: rem(2px); + + $variant: map.get($theme, '_meta', 'theme'); + $bootstrap-theme: $variant == 'bootstrap'; + $indigo-theme: $variant == 'indigo'; + $group-item-min-width: map.get(( + 'material': rem(42px), + 'fluent': rem(42px), + 'bootstrap': rem(42px), + 'indigo': rem(32px), + ), $variant); + + %item-overlay { + &::before { + content: ''; + z-index: -1; + position: absolute; + pointer-events: none; + width: 100%; + height: 100%; + background: var-get($theme, 'item-focused-background'); + } + } + + %igx-group-display { + display: flex; + box-shadow: var-get($theme, 'elevation'); + border-radius: var-get($theme, 'border-radius'); + + button { + // The margin here is required to fix a bug in Safari #7858 + margin-top: 0; + margin-inline-end: 0; + margin-bottom: 0; + } + } + + %igx-group-item { + border: $group-item-border-thickness solid var-get($theme, 'item-border-color'); + color: var-get($theme, 'item-text-color'); + background: var-get($theme, 'item-background'); + min-width: $group-item-min-width; + display: flex; + flex: 1 0 0%; + justify-content: center; + align-items: center; + text-decoration: none; + cursor: pointer; + user-select: none; + position: relative; + z-index: 0; + + &[igxButton] { + border-radius: 0; + border-color: var-get($theme, 'item-border-color'); + } + + igx-icon { + color: var-get($theme, 'item-icon-color'); + } + + &:not(:nth-child(1)) { + margin-inline-start: rem(-1px); + } + + &:first-of-type { + border-start-start-radius: inherit; + border-end-start-radius: inherit; + } + + &:last-of-type { + border-start-end-radius: inherit; + border-end-end-radius: inherit; + } + + &[igxButton][disabled='true'] { + color: var-get($theme, 'disabled-text-color'); + background: var-get($theme, 'disabled-background-color'); + border-color: var-get($theme, 'item-disabled-border'); + + igx-icon { + color: var-get($theme, 'disabled-text-color'); + } + } + + &:hover { + color: var-get($theme, 'item-hover-text-color'); + background: var-get($theme, 'item-hover-background'); + border-color: var-get($theme, 'item-hover-border-color'); + + igx-icon { + color: var-get($theme, 'item-hover-icon-color'); + } + } + + @if $variant != 'fluent' { + &:active { + color: var-get($theme, 'item-hover-text-color'); + background: var-get($theme, 'item-hover-background'); + border-color: var-get($theme, 'item-hover-border-color'); + + igx-icon { + color: var-get($theme, 'item-hover-icon-color'); + } + } + } + + @if $variant == 'material' { + &:hover, + &:active { + @extend %item-overlay; + + background: var-get($theme, 'item-background'); + } + + &:hover { + &::before { + background: var-get($theme, 'item-hover-background'); + } + } + } + + @if $variant == 'bootstrap' { + &:active { + @extend %item-overlay; + } + } + + @if $variant == 'fluent' { + igx-icon { + $icon-size: rem(18px); + + --ig-icon-size: #{$icon-size}; + --igx-icon-size: #{$icon-size}; + } + + &:active { + background: var-get($theme, 'item-focused-background'); + color: var-get($theme, 'item-text-color'); + } + } + + @if $variant == 'indigo' { + padding-inline: pad-inline(rem(6px), rem(8px), rem(10px)); + + igx-icon { + $icon-size: rem(16px); + + --ig-icon-size: #{$icon-size}; + --igx-icon-size: #{$icon-size}; + } + + &:hover { + border-color: var-get($theme, 'item-hover-border-color'); + z-index: 1; + } + + &:active { + color: var-get($theme, 'item-hover-text-color'); + background: var-get($theme, 'item-hover-background'); + border-color: var-get($theme, 'item-hover-border-color'); + z-index: 2; + } + } + + &[igxButton].igx-button--focused { + &:hover { + color: var-get($theme, 'item-hover-text-color'); + background: var-get($theme, 'item-hover-background'); + + igx-icon { + color: var-get($theme, 'item-hover-icon-color'); + } + } + + @if $variant != 'fluent' { + color: var-get($theme, 'item-hover-text-color'); + background: var-get($theme, 'item-hover-background'); + border-color: var-get($theme, 'item-hover-border-color'); + + igx-icon { + color: var-get($theme, 'item-hover-icon-color'); + } + } + + @if $variant == 'material' { + @extend %item-overlay; + + background: var-get($theme, 'item-background'); + + &::before { + background: var-get($theme, 'item-hover-background'); + } + + &:hover { + background: var-get($theme, 'item-background'); + + &::before { + background: var-get($theme, 'item-focused-hover-background'); + } + } + + &:active { + background: var-get($theme, 'item-background'); + color: var-get($theme, 'item-hover-text-color'); + + igx-icon { + color: var-get($theme, 'item-hover-icon-color'); + } + + &::before { + background: var-get($theme, 'item-focused-background'); + } + } + } + + @if $variant == 'bootstrap' { + background: var-get($theme, 'item-background'); + z-index: 1; + box-shadow: 0 0 0 rem(4px) var-get($theme, 'idle-shadow-color'); + + &:active { + background: var-get($theme, 'item-hover-background'); + color: var-get($theme, 'item-hover-text-color'); + + igx-icon { + color: var-get($theme, 'item-hover-icon-color'); + } + } + } + + @if $variant == 'fluent' { + background: var-get($theme, 'item-background'); + color: var-get($theme, 'item-text-color'); + + igx-icon { + color: var-get($theme, 'item-icon-color'); + } + + &::after { + content: ''; + position: absolute; + inset-block-start: $outline-btn-indent; + inset-inline-start: $outline-btn-indent; + pointer-events: none; + width: calc(100% - (#{$outline-btn-indent} * 2)); + height: calc(100% - (#{$outline-btn-indent} * 2)); + box-shadow: 0 0 0 rem(1px) var-get($theme, 'item-focused-border-color'); + } + + &:active { + background: var-get($theme, 'item-focused-background'); + } + } + + @if $variant == 'indigo' { + color: var-get($theme, 'item-focused-text-color'); + background: var-get($theme, 'item-focused-background'); + border-color: var-get($theme, 'item-focused-border-color'); + box-shadow: 0 0 0 rem(3px) var-get($theme, 'idle-shadow-color'); + z-index: 2; + + igx-icon { + color: var-get($theme, 'item-icon-color'); + } + + &:hover { + border-color: var-get($theme, 'item-hover-border-color'); + } + + &:active { + color: var-get($theme, 'item-hover-text-color'); + background: var-get($theme, 'item-hover-background'); + border-color: var-get($theme, 'item-hover-border-color'); + + igx-icon { + color: var-get($theme, 'item-hover-icon-color'); + } + } + } + } + } + + %igx-group-item-vertical { + &:not(:nth-child(1)) { + margin-top: rem(-1px); + margin-inline-start: 0; + } + + &:first-of-type { + border-start-start-radius: inherit; + border-start-end-radius: inherit; + border-end-start-radius: 0; + border-end-end-radius: 0; + } + + &:last-of-type { + border-start-start-radius: 0; + border-start-end-radius: 0; + border-end-start-radius: inherit; + border-end-end-radius: inherit; + } + } + + %igx-group-item-selected { + color: var-get($theme, 'item-selected-text-color'); + background: var-get($theme, 'item-selected-background'); + border-color: var-get($theme, 'item-selected-border-color'); + position: relative; + z-index: 1; + + &[igxButton] { + border-color: var-get($theme, 'item-selected-border-color'); + } + + igx-icon { + color: var-get($theme, 'item-selected-icon-color'); + } + + &:hover { + border-color: var-get($theme, 'item-selected-hover-border-color'); + color: var-get($theme, 'item-selected-hover-text-color'); + background: var-get($theme, 'item-selected-hover-background'); + + igx-icon { + color: var-get($theme, 'item-selected-hover-icon-color'); + } + } + + @if $variant == 'material' { + &:hover { + @extend %item-overlay; + + background: var-get($theme, 'item-selected-background'); + + &::before { + background: var-get($theme, 'item-selected-hover-background'); + } + } + + &:active { + @extend %item-overlay; + + color: var-get($theme, 'item-selected-hover-text-color'); + background: var-get($theme, 'item-selected-background'); + border-color: var-get($theme, 'item-selected-border-color'); + + igx-icon { + color: var-get($theme, 'item-selected-hover-icon-color'); + } + + &::before { + background: var-get($theme, 'item-selected-focus-background'); + } + } + } + + @if $variant == 'bootstrap' { + &:active { + @extend %item-overlay; + + color: var-get($theme, 'item-selected-text-color'); + border-color: var-get($theme, 'item-selected-border-color'); + background: var-get($theme, 'item-selected-hover-background'); + + &::before { + background: var-get($theme, 'item-selected-focus-background'); + } + } + } + + @if $variant == 'fluent' { + &:hover { + background: var-get($theme, 'item-selected-background'); + color: var-get($theme, 'item-selected-text-color'); + + @extend %item-overlay; + + &::before { + background: var-get($theme, 'item-selected-hover-background'); + } + } + + &:active { + background: var-get($theme, 'item-selected-focus-background'); + color: var-get($theme, 'item-selected-text-color'); + + igx-icon { + color: var-get($theme, 'item-selected-icon-color'); + } + } + } + + @if $variant == 'indigo' { + &:active { + background: var-get($theme, 'item-selected-hover-background'); + color: var-get($theme, 'item-selected-hover-text-color'); + border-color: var-get($theme, 'item-selected-hover-border-color'); + + igx-icon { + color: var-get($theme, 'item-selected-hover-icon-color'); + } + } + } + + &[igxButton].igx-button--focused { + @if $variant != 'fluent' { + &:hover { + color: var-get($theme, 'item-selected-hover-text-color'); + background: var-get($theme, 'item-selected-hover-background'); + border-color: var-get($theme, 'item-selected-hover-border-color'); + } + + &:active { + background: var-get($theme, 'item-selected-hover-background'); + color: var-get($theme, 'item-selected-hover-text-color'); + + &::before { + background: var-get($theme, 'item-selected-focus-background'); + } + + igx-icon { + color: var-get($theme, 'item-selected-hover-icon-color'); + } + } + } + + @if $variant == 'material' or $variant == 'fluent' { + &:hover { + @extend %item-overlay; + + &::before { + background: var-get($theme, 'item-selected-focus-hover-background'); + } + } + } + + @if $variant == 'material' { + background: var-get($theme, 'item-selected-background'); + color: var-get($theme, 'item-selected-hover-text-color'); + border-color: var-get($theme, 'item-selected-hover-border-color'); + + &::before { + background: var-get($theme, 'item-selected-hover-background'); + } + + igx-icon { + color: var-get($theme, 'item-selected-hover-icon-color'); + } + + &:hover { + background: var-get($theme, 'item-selected-background'); + + igx-icon { + color: var-get($theme, 'item-selected-hover-icon-color'); + } + } + + &:active { + background: var-get($theme, 'item-selected-background'); + } + } + + @if $variant == 'bootstrap' { + color: var-get($theme, 'item-selected-text-color'); + border-color: var-get($theme, 'item-selected-border-color'); + background: var-get($theme, 'item-selected-background'); + box-shadow: 0 0 0 rem(4px) var-get($theme, 'selected-shadow-color'); + + igx-icon { + color: var-get($theme, 'item-selected-icon-color'); + } + } + + @if $variant == 'fluent' { + background: var-get($theme, 'item-selected-background'); + color: var-get($theme, 'item-selected-text-color'); + + igx-icon { + color: var-get($theme, 'item-selected-icon-color'); + } + + &:hover { + background: var-get($theme, 'item-selected-background'); + color: var-get($theme, 'item-selected-text-color'); + + igx-icon { + color: var-get($theme, 'item-selected-hover-icon-color'); + } + } + + &:active { + background: var-get($theme, 'item-selected-focus-background'); + } + } + + @if $variant == 'indigo' { + box-shadow: 0 0 0 rem(3px) var-get($theme, 'selected-shadow-color'); + border-color: var-get($theme, 'item-selected-border-color'); + background: var-get($theme, 'item-selected-background'); + color: var-get($theme, 'item-selected-text-color'); + + igx-icon { + color: var-get($theme, 'item-selected-icon-color'); + } + + &:hover, + &:active { + border-color: var-get($theme, 'item-selected-hover-border-color'); + + igx-icon { + color: var-get($theme, 'item-selected-hover-icon-color'); + } + } + } + } + + &[igxButton][disabled='true'] { + position: relative; + + &::before { + position: absolute; + content: ''; + top: 0; + bottom: 0; + inset-inline-end: 0; + inset-inline-start: 0; + background: var-get($theme, 'disabled-selected-background'); + } + + @if $variant == 'indigo' { + color: var-get($theme, 'disabled-selected-text-color'); + background: var-get($theme, 'disabled-selected-background'); + border: none; + + igx-icon { + color: var-get($theme, 'disabled-selected-icon-color'); + } + + &::before { + display: none; + } + } + } + } + + %igx-group-vertical { + flex-flow: column; + } + + %igx-group-item-content { + display: flex; + align-items: center; + flex-flow: row nowrap; + min-width: 0; + + * ~ * { + margin-inline-start: $group-items-margin; + } + } + + %igx-button-group__button-text { + width: 100%; + @include ellipsis(); + } +} + +/// Adds typography styles for the igx-button-group component. +/// Uses the 'button' category from the typographic scale. +/// @group typography +/// @param {String} $categories [(text: 'button')] - The category from the typographic scale used for type styles. +@mixin button-group-typography($categories: (text: 'button')) { + $text: map.get($categories, 'text'); + + %igx-group-item { + @include type-style($text) { + text-align: center; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/button/_button-component.scss b/projects/igniteui-angular/core/src/core/styles/components/button/_button-component.scss new file mode 100644 index 00000000000..819a5acb79c --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/button/_button-component.scss @@ -0,0 +1,58 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin component { + @include b(igx-button) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: () + ); + + @extend %igx-button-display !optional; + + // FLAT BUTTON + @include m(flat) { + @extend %igx-button--flat !optional; + } + + // CONTAINED BUTTON + @include m(contained) { + @extend %igx-button--contained !optional; + } + + // OUTLINED BUTTON + @include m(outlined) { + @extend %igx-button--outlined !optional; + } + + // FAB BUTTON + @include m(fab) { + @extend %igx-button--round !optional; + @extend %igx-button--fab !optional; + } + + @include mx(flat, focused) { + @extend %igx-button--flat-focused !optional; + } + + @include mx(contained, focused) { + @extend %igx-button--contained-focused !optional; + } + + @include mx(outlined, focused) { + @extend %igx-button--outlined-focused !optional; + } + + @include mx(fab, focused) { + @extend %igx-button--fab-focused !optional; + } + + // DISABLED BUTTON + @include m(disabled) { + @extend %igx-button--disabled !optional; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/button/_button-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/button/_button-theme.scss new file mode 100644 index 00000000000..ee01588453e --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/button/_button-theme.scss @@ -0,0 +1,878 @@ +@use 'sass:map'; +@use 'sass:meta'; +@use 'sass:list'; +@use 'sass:string'; +@use '../../base' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $flat [null] - The flat theme used to style the component. +/// @param {Map} $contained [null] - The contained theme used to style the component. +/// @param {Map} $outlined [null] - The outlined theme used to style the component. +/// @param {Map} $fab [null] - The fab theme used to style the component. +@mixin button($themes...) { + $button-width: rem(88px); + $flat-theme: null; + $contained-theme: null; + $outlined-theme: null; + $fab-theme: null; + $variant: 'material'; + + $required: ('flat', 'contained', 'outlined', 'fab'); + $added: (); + $missing: (); + + @each $key, $theme in meta.keywords($themes) { + $type: map.get($theme, _meta, type); + + $added: list.append($added, $key); + + @if $type == 'flat' { + $flat-theme: $theme; + } @else if $type == 'contained' { + $contained-theme: $theme; + } @else if $type == 'outlined' { + $outlined-theme: $theme; + } @else if $type == 'fab' { + $fab-theme: $theme; + } + + $variant: map.get($theme, '_meta', 'theme'); + @include css-vars($theme); + } + + @each $item in $required { + @if not(list.index($added, $item)) { + $missing: list.append($missing, '$#{$item}', $separator: comma); + } + } + + @if list.length($missing) != 0 { + @error meta.inspect(string.unquote("Missing theme properties:") #{$missing}); + } + + $time: map.get( + ( + 'material': 0.1s, + 'fluent': 0.1s, + 'bootstrap': 0.15s, + 'indigo': 0.15s, + ), + $variant + ); + + $button-transition: color var(--_init-transition, #{$time}) ease-in-out, + background-color var(--_init-transition, #{$time}) ease-in-out, + border-color var(--_init-transition, #{$time}) ease-in-out, + box-shadow var(--_init-transition, #{$time}) ease-in-out; + + $button-disabled-shadow: none; + + $button-floating-width: rem(56px); + $button-floating-height: $button-floating-width; + + $button-padding-inline: ( + comfortable: rem(16px, 16px), + cosy: rem(12px, 16px), + compact: rem(8px, 16px), + ); + + $button-padding-indigo-inline: ( + comfortable: rem(24px, 16px), + cosy: rem(16px, 16px), + compact: rem(10px, 16px), + ); + + $button-padding-material-block: ( + comfortable: rem(7px, 16px), + cosy: rem(4px, 16px), + compact: rem(1px, 16px), + ); + + $button-padding-fluent-block: ( + comfortable: 0, + cosy: 0, + compact: 0, + ); + + $button-padding-bootstrap-block: ( + comfortable: rem(6px, 16px), + cosy: rem(4px, 16px), + compact: rem(2px, 16px), + ); + + $button-padding-indigo-block: ( + comfortable: 0, + cosy: 0, + compact: 0, + ); + + $button-padding-inline: map.get( + ( + 'material': $button-padding-inline, + 'fluent': $button-padding-inline, + 'bootstrap': $button-padding-inline, + 'indigo': $button-padding-indigo-inline, + ), + $variant + ); + + $button-padding-block: map.get( + ( + 'material': $button-padding-material-block, + 'fluent': $button-padding-fluent-block, + 'bootstrap': $button-padding-bootstrap-block, + 'indigo': $button-padding-indigo-block, + ), + $variant + ); + + $outlined-button-padding-inline: map.get( + ( + 'material': $button-padding-inline, + 'fluent': $button-padding-inline, + 'bootstrap': $button-padding-inline, + 'indigo': $button-padding-indigo-inline, + ), + $variant + ); + + $outlined-button-padding-block: map.get( + ( + 'material': $button-padding-material-block, + 'fluent': $button-padding-fluent-block, + 'bootstrap': $button-padding-bootstrap-block, + 'indigo': $button-padding-indigo-block, + ), + $variant + ); + + $button-floating-padding-block: ( + comfortable: rem(8px), + cosy: rem(4px), + compact: 0, + ); + + $button-floating-padding-inline: ( + comfortable: rem(14px), + cosy: rem(10px), + compact: rem(6px), + ); + + $button-floating-padding-indigo-inline: ( + comfortable: rem(10px), + cosy: rem(8px), + compact: rem(6px), + ); + + $items-gap: ( + comfortable: rem(12px), + cosy: rem(8px), + compact: rem(4px), + ); + + $items-gap-indigo-comfortable: rem(8px); + + $filtering-row-button-size: ( + comfortable: rem(40px), + cosy: rem(30px), + compact: rem(21px), + ); + + $icon-sizes: map.get( + ( + 'material': rem(18px), + 'fluent': rem(18px), + 'bootstrap': rem(18px), + 'indigo': rem(16px), + ), + $variant + ); + + $icon-in-button-size: $icon-sizes; + + $contained-shadow: map.get( + ( + 'material': var-get($contained-theme, 'resting-elevation'), + 'fluent': none, + 'bootstrap': none, + 'indigo': none, + ), + $variant + ); + + $contained-shadow--hover: map.get( + ( + 'material': var-get($contained-theme, 'hover-elevation'), + 'fluent': none, + 'bootstrap': none, + 'indigo': none, + ), + $variant + ); + + $contained-shadow--focus: map.get( + ( + 'material': var-get($contained-theme, 'focus-elevation'), + 'fluent': none, + 'bootstrap': 0 0 0 rem(4px) + var-get($contained-theme, 'shadow-color'), + 'indigo': 0 0 0 rem(3px) var-get($contained-theme, 'shadow-color'), + ), + $variant + ); + + $contained-shadow--active: map.get( + ( + 'material': var-get($contained-theme, 'active-elevation'), + 'fluent': none, + 'bootstrap': 0 0 0 rem(4px) + var-get($contained-theme, 'shadow-color'), + 'indigo': 0 0 0 rem(3px) var-get($contained-theme, 'shadow-color'), + ), + $variant + ); + + $fab-shadow: map.get( + ( + 'material': var-get($fab-theme, 'resting-elevation'), + 'fluent': none, + 'bootstrap': none, + 'indigo': none, + ), + $variant + ); + + $fab-shadow--hover: map.get( + ( + 'material': var-get($fab-theme, 'hover-elevation'), + 'fluent': none, + 'bootstrap': none, + 'indigo': none, + ), + $variant + ); + + $fab-shadow--focus: map.get( + ( + 'material': var-get($fab-theme, 'focus-elevation'), + 'fluent': none, + 'bootstrap': 0 0 0 rem(4px) var-get($fab-theme, 'shadow-color'), + 'indigo': 0 0 0 rem(3px) var-get($fab-theme, 'shadow-color'), + ), + $variant + ); + + $fab-shadow--active: map.get( + ( + 'material': var-get($fab-theme, 'active-elevation'), + 'fluent': none, + 'bootstrap': 0 0 0 rem(4px) var-get($fab-theme, 'shadow-color'), + 'indigo': 0 0 0 rem(3px) var-get($fab-theme, 'shadow-color'), + ), + $variant + ); + + %fluent-border { + &::after { + $btn-indent: rem(2px); + content: ''; + position: absolute; + top: $btn-indent; + inset-inline-start: $btn-indent; + pointer-events: none; + width: calc(100% - (#{$btn-indent} * 2)); + height: calc(100% - (#{$btn-indent} * 2)); + } + } + + %igx-button-display { + @include sizable(); + + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: $button-width; + padding-inline: pad-inline( + map.get($button-padding-inline, 'compact'), + map.get($button-padding-inline, 'cosy'), + map.get($button-padding-inline, 'comfortable') + ); + padding-block: pad-block( + map.get($button-padding-block, 'compact'), + map.get($button-padding-block, 'cosy'), + map.get($button-padding-block, 'comfortable') + ); + min-height: var-get($flat-theme, 'size'); + border: rem(1px) solid var-get($flat-theme, 'border-color'); + cursor: pointer; + user-select: none; + outline-style: none; + -webkit-tap-highlight-color: transparent; + overflow: hidden; + white-space: nowrap; + transition: $button-transition; + gap: pad-inline( + map.get($items-gap, 'compact'), + map.get($items-gap, 'cosy'), + map.get($items-gap, 'comfortable') + ); + + @if $variant == 'indigo' { + min-width: rem(28px); + } + + igx-icon { + --component-size: var(--ig-size, var(--ig-size-large)); + display: flex; + justify-content: center; + width: var(--igx-icon-size, #{$icon-in-button-size}); + height: var(--igx-icon-size, #{$icon-in-button-size}); + font-size: var(--igx-icon-size, #{$icon-in-button-size}); + transition: $button-transition; + } + } + + igx-grid-filtering-row { + @if $variant == 'bootstrap' { + --filtering-row-button-size: #{sizable( + #{map.get($filtering-row-button-size, 'compact')}, + #{map.get($filtering-row-button-size, 'cosy')}, + #{map.get($filtering-row-button-size, 'comfortable')} + )}; + + %igx-button-display { + min-height: var(--filtering-row-button-size); + padding-block: 0; + + > * { + display: flex; + align-items: center; + height: rem(18px); + } + } + } + } + + %igx-button--flat { + --component-size: var(--ig-size, #{var-get($flat-theme, 'default-size')}); + background: var-get($flat-theme, 'background'); + color: var-get($flat-theme, 'foreground'); + border-radius: var-get($flat-theme, 'border-radius'); + + igx-icon { + color: var-get($flat-theme, 'icon-color'); + } + + &:hover { + background: var-get($flat-theme, 'hover-background'); + color: var-get($flat-theme, 'hover-foreground'); + border-color: var-get($flat-theme, 'hover-border-color'); + + igx-icon { + color: var-get($flat-theme, 'icon-color-hover'); + } + } + + &:active { + background: var-get($flat-theme, 'active-background'); + color: var-get($flat-theme, 'active-foreground'); + border-color: var-get($flat-theme, 'active-border-color'); + + igx-icon { + color: var-get($flat-theme, 'active-foreground'); + } + + @if $variant == 'indigo' { + igx-icon { + color: var-get($flat-theme, 'icon-color-hover'); + } + } + } + + @if $variant == 'indigo' { + border-width: rem(2px); + gap: pad-inline( + map.get($items-gap, 'compact'), + map.get($items-gap, 'cosy'), + $items-gap-indigo-comfortable + ); + } + } + + %igx-button--flat-focused { + background: var-get($flat-theme, 'focus-visible-background'); + color: var-get($flat-theme, 'focus-visible-foreground'); + border-color: var-get($flat-theme, 'focus-visible-border-color'); + + igx-icon { + @if $variant == 'material' { + color: var-get($flat-theme, 'icon-color-hover'); + } @else { + color: var-get($flat-theme, 'icon-color'); + } + } + + &:hover { + background: var-get($flat-theme, 'focus-hover-background'); + color: var-get($flat-theme, 'focus-hover-foreground'); + + igx-icon { + color: var-get($flat-theme, 'icon-color-hover'); + } + } + + &:active { + background: var-get($flat-theme, 'focus-background'); + color: var-get($flat-theme, 'focus-foreground'); + border-color: var-get($flat-theme, 'focus-border-color'); + + igx-icon { + color: var-get($flat-theme, 'focus-foreground'); + } + } + + @if $variant == 'bootstrap' { + box-shadow: 0 0 0 rem(4px) var-get($flat-theme, 'shadow-color'); + } + + @if $variant == 'fluent' { + border-color: var-get($flat-theme, 'active-border-color'); + + &::after { + @extend %fluent-border; + + box-shadow: 0 0 0 rem(1px) var-get($flat-theme, 'focus-visible-border-color'); + } + } + + @if $variant == 'indigo' { + box-shadow: 0 0 0 rem(3px) var-get($flat-theme, 'shadow-color'); + + &:active { + igx-icon { + color: var-get($flat-theme, 'icon-color-hover'); + } + } + } + } + + %igx-button--outlined { + --component-size: var(--ig-size, #{var-get($outlined-theme, 'default-size')}); + background: var-get($outlined-theme, 'background'); + color: var-get($outlined-theme, 'foreground'); + border-color: var-get($outlined-theme, 'border-color'); + border-radius: var-get($outlined-theme, 'border-radius'); + padding-block: pad-block( + map.get($outlined-button-padding-block, 'compact'), + map.get($outlined-button-padding-block, 'cosy'), + map.get($outlined-button-padding-block, 'comfortable') + ); + padding-inline: pad-inline( + map.get($outlined-button-padding-inline, 'compact'), + map.get($outlined-button-padding-inline, 'cosy'), + map.get($outlined-button-padding-inline, 'comfortable') + ); + + @if $variant == 'indigo' { + gap: pad-inline( + map.get($items-gap, 'compact'), + map.get($items-gap, 'cosy'), + $items-gap-indigo-comfortable + ); + + border: rem(2px) solid var-get($outlined-theme, 'border-color'); + } + + igx-icon { + color: var-get($outlined-theme, 'icon-color'); + } + + &:hover { + background: var-get($outlined-theme, 'hover-background'); + color: var-get($outlined-theme, 'hover-foreground'); + border-color: var-get($outlined-theme, 'hover-border-color'); + + igx-icon { + color: var-get($outlined-theme, 'icon-color-hover'); + } + } + + &:active { + background: var-get($outlined-theme, 'active-background'); + color: var-get($outlined-theme, 'active-foreground'); + border-color: var-get($outlined-theme, 'active-border-color'); + + igx-icon { + color: var-get($outlined-theme, 'active-foreground'); + } + + @if $variant == 'indigo' { + igx-icon { + color: var-get($outlined-theme, 'icon-color-hover'); + } + } + } + + @if $variant == 'fluent' { + border: rem(1px) solid var-get($flat-theme, 'border-color'); + } + } + + %igx-button--outlined-focused { + background: var-get($outlined-theme, 'focus-visible-background'); + color: var-get($outlined-theme, 'focus-visible-foreground'); + border-color: var-get($outlined-theme, 'focus-visible-border-color'); + + @if $variant == 'material' or $variant == 'bootstrap' { + igx-icon { + color: var-get($outlined-theme, 'icon-color-hover'); + } + } @else { + igx-icon { + color: var-get($outlined-theme, 'icon-color'); + } + } + + @if $variant == 'bootstrap' { + box-shadow: 0 0 0 rem(4px) var-get($outlined-theme, 'shadow-color'); + } @else if $variant == 'indigo' { + box-shadow: 0 0 0 rem(3px) var-get($outlined-theme, 'shadow-color'); + } + + &:hover { + background: var-get($outlined-theme, 'focus-hover-background'); + color: var-get($outlined-theme, 'focus-hover-foreground'); + border-color: var-get($outlined-theme, 'hover-border-color'); + + igx-icon { + color: var-get($outlined-theme, 'icon-color-hover'); + } + } + + &:active { + background: var-get($outlined-theme, 'focus-background'); + color: var-get($outlined-theme, 'focus-foreground'); + border-color: var-get($outlined-theme, 'focus-border-color'); + + igx-icon { + color: var-get($outlined-theme, 'focus-foreground'); + } + + @if $variant == 'indigo' { + igx-icon { + color: var-get($outlined-theme, 'icon-color-hover'); + } + } + } + + @if $variant == 'fluent' { + border-color: var-get($outlined-theme, 'focus-border-color'); + + &::after { + @extend %fluent-border; + + box-shadow: 0 0 0 rem(1px) var-get($outlined-theme, 'focus-visible-border-color'); + } + } + } + + %igx-button--contained { + --component-size: var(--ig-size, #{var-get($contained-theme, 'default-size')}); + color: var-get($contained-theme, 'foreground'); + background: var-get($contained-theme, 'background'); + border-color: var-get($contained-theme, 'border-color'); + border-radius: var-get($contained-theme, 'border-radius'); + + @if $variant == 'material' { + box-shadow: var-get($contained-theme, 'resting-elevation'); + } + + igx-icon { + color: var-get($contained-theme, 'icon-color'); + } + + &:hover { + color: var-get($contained-theme, 'hover-foreground'); + background: var-get($contained-theme, 'hover-background'); + border-color: var-get($contained-theme, 'hover-border-color'); + + @if $variant == 'material' { + box-shadow: var-get($contained-theme, 'hover-elevation'); + } + + igx-icon { + color: var-get($contained-theme, 'icon-color-hover'); + } + } + + &:active { + color: var-get($contained-theme, 'active-foreground'); + background: var-get($contained-theme, 'active-background'); + border-color: var-get($contained-theme, 'active-border-color'); + + @if $variant == 'material' { + box-shadow: var-get($contained-theme, 'active-elevation'); + } + + igx-icon { + color: var-get($contained-theme, 'active-foreground'); + } + } + + @if $variant == 'indigo' { + border-width: rem(2px); + gap: pad-inline( + map.get($items-gap, 'compact'), + map.get($items-gap, 'cosy'), + $items-gap-indigo-comfortable + ); + + &:active { + igx-icon { + color: var-get($outlined-theme, 'icon-color-hover'); + } + } + } + } + + %igx-button--contained-focused { + background: var-get($contained-theme, 'focus-visible-background'); + color: var-get($contained-theme, 'focus-visible-foreground'); + border-color: var-get($contained-theme, 'focus-visible-border-color'); + + igx-icon { + color: var-get($contained-theme, 'icon-color'); + } + + @if $variant == 'material' { + box-shadow: var-get($contained-theme, 'focus-elevation'); + } @else { + box-shadow: $contained-shadow--active; + } + + @if $variant == 'fluent' { + border-color: var-get($contained-theme, 'active-border-color'); + + &::after { + @extend %fluent-border; + + box-shadow: 0 0 0 rem(1px) var-get($contained-theme, 'focus-visible-border-color'); + } + } + + &:hover { + color: var-get($contained-theme, 'focus-hover-foreground'); + background: var-get($contained-theme, 'focus-hover-background'); + border-color: var-get($contained-theme, 'hover-border-color'); + + igx-icon { + color: var-get($contained-theme, 'icon-color-hover'); + } + + @if $variant == 'material' { + box-shadow: var-get($contained-theme, 'focus-elevation'); + } + } + + &:active { + color: var-get($contained-theme, 'focus-foreground'); + background: var-get($contained-theme, 'focus-background'); + border-color: var-get($contained-theme, 'focus-border-color'); + + igx-icon { + color: var-get($contained-theme, 'focus-foreground'); + } + + @if $variant == 'indigo' { + igx-icon { + color: var-get($outlined-theme, 'icon-color-hover'); + } + } + } + } + + %igx-button--round { + display: inline-flex; + position: relative; + flex-direction: row; + justify-content: center; + align-items: center; + outline: none; + cursor: pointer; + transition: $button-transition; + user-select: none; + -webkit-tap-highlight-color: transparent; + overflow: hidden; + // hack to allow circular overflow in safari... + filter: blur(0); + } + + %igx-button--fab { + --component-size: var(--ig-size, #{var-get($fab-theme, 'default-size')}); + padding-block: pad-block( + map.get($button-floating-padding-block, 'compact'), + map.get($button-floating-padding-block, 'cosy'), + map.get($button-floating-padding-block, 'comfortable') + ); + padding-inline: pad-inline( + map.get($button-floating-padding-inline, 'compact'), + map.get($button-floating-padding-inline, 'cosy'), + map.get($button-floating-padding-inline, 'comfortable') + ); + + @if $variant == 'indigo' { + padding-inline: pad-inline( + map.get($button-floating-padding-indigo-inline, 'compact'), + map.get($button-floating-padding-indigo-inline, 'cosy'), + map.get($button-floating-padding-indigo-inline, 'comfortable') + ); + } + + min-width: var-get($fab-theme, 'size'); + min-height: var-get($fab-theme, 'size'); + line-height: unset; + white-space: nowrap; + color: var-get($fab-theme, 'foreground'); + background: var-get($fab-theme, 'background'); + border-color: var-get($fab-theme, 'border-color'); + border-radius: var-get($fab-theme, 'border-radius'); + + @if $variant == 'material' { + box-shadow: var-get($fab-theme, 'resting-elevation'); + } + + igx-icon { + color: var-get($fab-theme, 'icon-color'); + } + + &:hover { + color: var-get($fab-theme, 'hover-foreground'); + background: var-get($fab-theme, 'hover-background'); + border-color: var-get($fab-theme, 'hover-border-color'); + + @if $variant == 'material' { + box-shadow: var-get($fab-theme, 'hover-elevation'); + } + + igx-icon { + color: var-get($fab-theme, 'icon-color-hover'); + } + } + + &:active { + color: var-get($fab-theme, 'active-foreground'); + background: var-get($fab-theme, 'active-background'); + border-color: var-get($fab-theme, 'active-border-color'); + + @if $variant == 'material' { + box-shadow: var-get($fab-theme, 'active-elevation'); + } + + igx-icon { + color: var-get($fab-theme, 'active-foreground'); + } + + @if $variant == 'indigo' { + igx-icon { + color: var-get($outlined-theme, 'icon-color-hover'); + } + } + } + } + + %igx-button--fab-focused { + background: var-get($fab-theme, 'focus-visible-background'); + color: var-get($fab-theme, 'focus-visible-foreground'); + border-color: var-get($fab-theme, 'focus-visible-border-color'); + + igx-icon { + color: var-get($fab-theme, 'icon-color'); + } + + @if $variant == 'material' { + box-shadow: var-get($fab-theme, 'focus-elevation'); + } @else { + box-shadow: $contained-shadow--focus; + } + + @if $variant == 'fluent' { + border-color: var-get($contained-theme, 'active-border-color'); + + &::after { + @extend %fluent-border; + $btn-indent: rem(2px); + border-radius: calc(#{var-get($fab-theme, 'border-radius')} - #{$btn-indent}); + box-shadow: 0 0 0 rem(1px) var-get($fab-theme, 'focus-visible-border-color'); + } + } + + &:hover { + color: var-get($fab-theme, 'focus-hover-foreground'); + background: var-get($fab-theme, 'focus-hover-background'); + border-color: var-get($fab-theme, 'hover-border-color'); + + igx-icon { + color: var-get($fab-theme, 'icon-color-hover'); + } + } + + &:active { + background: var-get($fab-theme, 'focus-background'); + color: var-get($fab-theme, 'focus-foreground'); + border-color: var-get($fab-theme, 'focus-border-color'); + + igx-icon { + color: var-get($contained-theme, 'focus-foreground'); + } + + @if $variant == 'indigo' { + igx-icon { + color: var-get($outlined-theme, 'icon-color-hover'); + } + } + } + } + + %igx-button--disabled { + background: var-get($flat-theme, 'disabled-background'); + color: var-get($flat-theme, 'disabled-foreground'); + border-color: var-get($flat-theme, 'disabled-border-color'); + pointer-events: none; + box-shadow: none; + + igx-icon { + color: var-get($flat-theme, 'disabled-icon-color'); + } + + &:focus { + box-shadow: none; + } + } +} + +/// Adds typography styles for the igx-button component. +/// Uses the 'button' category from the typographic scale. +/// @group typography +/// @param {String} $categories [(text: 'button')] - The category from the typographic scale used for type styles. +@mixin button-typography( + $categories: ( + text: 'button', + ) +) { + $text: map.get($categories, 'text'); + + %igx-button-display { + @include type-style($text) { + text-align: center; + } + } + + %igx-button--fab { + @include type-style($text) { + text-align: center; + margin: 0; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/calendar/_calendar-component.scss b/projects/igniteui-angular/core/src/core/styles/components/calendar/_calendar-component.scss new file mode 100644 index 00000000000..1efc74d8642 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/calendar/_calendar-component.scss @@ -0,0 +1,1351 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin component { + @include b(igx-calendar) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: ( + igx-icon, + ) + ); + + @extend %calendar !optional; + + @include e(header) { + @extend %header-display !optional; + } + + @include e(wrapper) { + @extend %calendar-wrapper !optional; + } + + @include e(wrapper, $m: 'vertical') { + @extend %calendar-wrapper--vertical !optional; + } + + @include e(pickers) { + @extend %pickers-display !optional; + } + + @include e(pickers, $m: 'vertical') { + @extend %pickers-display--vertical !optional; + } + + @include e(pickers, $m: 'days') { + @extend %pickers-display--days !optional; + } + + @include e(body) { + @extend %body-display !optional; + } + + @include e(body, $m: 'vertical') { + @extend %body-display--vertical !optional; + } + + @include e(header-year) { + @extend %header-year !optional; + } + + @include e(header-date) { + @extend %header-date !optional; + } + + @include m(vertical) { + @extend %calendar !optional; + @extend %calendar-vertical !optional; + + @include e(header) { + @extend %header-display-vertical !optional; + } + + @include e(header-date) { + @extend %header-date !optional; + @extend %header-date-vertical !optional; + } + } + + @include e(aria-off-screen) { + @extend %calendar__aria-off-screen !optional; + } + } + + @include b(igx-calendar-picker) { + @extend %views-navigation !optional; + + @include e(dates) { + @extend %picker-dates !optional; + } + + @include e(nav) { + @extend %picker__nav !optional; + } + + @include e(date) { + @extend %picker-date !optional; + } + + @include e(prev) { + @extend %picker-arrow !optional; + } + + @include e(next) { + @extend %picker-arrow !optional; + } + } + + @include b(igx-days-view) { + @extend %calendar !optional; + @extend %days-view !optional; + + @include e(grid) { + @extend %days-view-grid !optional; + } + + @include e(row) { + @extend %days-view-row !optional; + } + + // LABEL + @include e(label) { + @extend %date !optional; + @extend %weekday-label !optional; + } + + @include e(label, 'week-number') { + @extend %label-week-number !optional; + } + + @include e(label-inner) { + @extend %weekday-label-inner !optional; + } + + // DATE + @include e(date) { + @extend %date !optional; + } + + @include e(date-inner) { + @extend %date-inner !optional; + } + + @include e(date-inner, 'week-number') { + @extend %date-inner-week-number !optional; + } + + @include e(date, 'week-number') { + @extend %date-week-number !optional; + } + + @include e(date, 'weekend') { + %date-inner { + @extend %date-weekend !optional; + } + } + + @include e(date, 'inactive') { + %date-inner { + @extend %date-inactive !optional; + } + } + + // HIDDEN + @include e(date, 'hidden') { + @extend %date-hidden !optional; + } + + + + + + + + + + + // STATE STYLES + // ----------------------------------------------------------------------------------- + + // ACTIVE PLAYS ROLE FOR FOCUS + @include e(date, 'active') { + %date-inner { + @extend %date-focus !optional; + } + } + + // SELECTED + @include e(date, 'selected') { + %date-inner { + @extend %date-selected !optional; + } + } + + @include e(date, $mods: ('selected', 'active')) { + %date-inner { + @extend %date-selected--focus !optional; + } + } + + // CURRENT + @include e(date, 'current') { + %date-inner { + @extend %date-current !optional; + @extend %date-current-border-radius !optional; + } + } + + @include e(date, $mods: ('current', 'active')) { + %date-inner { + @extend %date-current--focus !optional; + } + } + + @include e(date, $mods: ('current', 'first', 'last')) { + @extend %date-current-border-radius !optional; + } + + @include e(date, $mods: ('current', 'selected'), $not: ('range')) { + %date-inner { + @extend %date-current--selected !optional; + } + } + + @include e(date, $mods: ('current', 'selected', 'active'), $not: ( 'range')) { + %date-inner { + @extend %date-current--selected-focus !optional; + } + } + + @include e(date, $mods: ('current', 'selected', 'first')) { + %date-inner { + @extend %date-current--selected !optional; + @extend %date-current--selected-first !optional; + } + } + + @include e(date, $mods: ('current', 'selected', 'active', 'first')) { + %date-inner { + @extend %date-current--selected-focus !optional; + @extend %date-current--selected-first-focus !optional; + } + } + + @include e(date, $mods: ('current', 'selected', 'last')) { + %date-inner { + @extend %date-current--selected !optional; + @extend %date-current--selected-last !optional; + } + } + + @include e(date, $mods: ('current', 'selected', 'active', 'last')) { + %date-inner { + @extend %date-current--selected-focus !optional; + @extend %date-current--selected-last-focus !optional; + } + } + + @include e(date, $mods: ('current', 'selected', 'range', 'first')) { + &::before { + @extend %date-current-border-radius !optional; + } + } + + @include e(date, $mods: ('current', 'selected', 'range', 'last')) { + &::before { + @extend %date-current-border-radius !optional; + } + } + + @include e(date, $mods: ('current', 'range'), $not: ('first', 'last')) { + %date-inner { + @extend %date-selected-current-range !optional; + } + } + + @include e(date, $mods: ('current', 'range', 'active'), $not: ('first', 'last')) { + %date-inner { + @extend %date-selected-current-focus !optional; + } + } + + // SPECIAL + @include e(date, 'special') { + %date-inner { + @extend %date-special !optional; + @extend %date-special-border-radius !optional; + } + } + + @include e(date, $mods: ('special', 'first', 'last')) { + @extend %date-special-border-radius !optional; + } + + @include e(date, $mods: ('special', 'active')) { + %date-inner { + @extend %date-special--focus !optional; + } + } + + @include e(date, $mods: ('special', 'selected'), $not: ('range')) { + %date-inner { + @extend %date-special--selected !optional; + } + } + + @include e(date, $mods: ('special', 'selected', 'first')) { + %date-inner { + @extend %date-special--selected !optional; + @extend %date-special--selected-first !optional; + } + } + + @include e(date, $mods: ('special', 'selected', 'last')) { + %date-inner { + @extend %date-special--selected !optional; + @extend %date-special--selected-last !optional; + } + } + + @include e(date, $mods: ('special', 'selected', 'active'), $not: ('range')) { + %date-inner { + @extend %date-special--selected-focus !optional; + } + } + + @include e(date, $mods: ('special', 'selected', 'active', 'first')) { + %date-inner { + @extend %date-special--selected-focus !optional; + @extend %date-special--selected-first-focus !optional; + } + } + + @include e(date, $mods: ('special', 'selected', 'active', 'last')) { + %date-inner { + @extend %date-special--selected-focus !optional; + @extend %date-special--selected-last-focus !optional; + } + } + + @include e(date, $mods: ('special', 'selected', 'range', 'first')) { + &::before { + @extend %date-special-border-radius !optional; + } + } + + @include e(date, $mods: ('special', 'selected', 'range', 'last')) { + &::before { + @extend %date-special-border-radius !optional; + } + } + + @include e(date, $mods: ('special', 'range'), $not: ('range-preview', 'first', 'last')) { + %date-inner { + @extend %date-special-range-not-preview !optional; + } + } + + @include e(date, $mods: ('special', 'range', 'active'), $not: ('range-preview', 'first', 'last')) { + %date-inner { + @extend %date-special-range-not-preview-focus !optional; + } + } + + @include e(date, $mods: ('special', 'range'), $not: ('first', 'last')) { + %date-inner { + @extend %date-special-range !optional; + } + } + + @include e(date, $mods: ('special', 'range', 'active'), $not: ('first', 'last')) { + %date-inner { + @extend %date-special-range-focus !optional; + } + } + + // SPECIAL + CURRENT + @include m(material) { + @include e(date, $mods: ('special', 'current')) { + %date-inner { + @extend %material-date-special-current !optional; + } + } + } + + // RANGE STYLES + @include e(date, 'range') { + @extend %date-range !optional; + } + + @include e(date, 'first') { + @extend %date-first !optional; + } + + @include e(date, 'last') { + @extend %date-last !optional; + } + + @include e(date, 'first', $not: ( 'current', 'special')) { + %date-inner { + @extend %date-range-border !optional; + } + } + + @include e(date, 'last', $not: ( 'current', 'special')) { + %date-inner { + @extend %date-range-border !optional; + } + } + + @include e(date, $mods: ('range', 'first')) { + @extend %date-range--first !optional; + } + + @include e(date, $mods: ('range', 'last')) { + @extend %date-range--last !optional; + } + + @include e(date, $mods: ('range', 'first'), $not: ( 'current', 'special')) { + @extend %date-wrapper-range-border !optional; + } + + @include e(date, $mods: ('range', 'last'), $not: ( 'current', 'special')) { + @extend %date-wrapper-range-border !optional; + } + + @include e(date, 'range', $not: ('first', 'last', 'current', 'special')) { + %date-inner { + @extend %date-range-middle !optional; + } + } + + @include e(date, $mods: ('range', 'active'), $not: ('first', 'last', 'current', 'special', 'range-preview')) { + %date-inner { + @extend %date-range-middle--focus !optional; + } + } + + // PREVIEW STYLES + @include e(date, 'range-preview') { + @extend %date-range-preview !optional; + } + + @include e(date, $mods:('range-preview', 'first')) { + @extend %date-preview--first !optional; + } + + @include e(date, $mods:('range-preview', 'last')) { + @extend %date-preview--last !optional; + } + + @include e(date, 'disabled') { + pointer-events: none; + cursor: not-allowed; + } + + // DISABLED + @include e(date, $mods: ('disabled', 'special')) { + %date-inner { + opacity: .38; + } + } + + @include e(date, $mods: ('disabled', 'current')) { + %date-inner { + opacity: .38; + } + } + + @include e(date, 'disabled', $not: ('special', 'current', 'range', 'first', 'last')) { + %date-inner { + @extend %date-disabled !optional; + } + } + + @include e(date, $mods: ('disabled', 'range'), $not: ('selected', 'special', 'current', 'range-preview', 'first', 'last')) { + %date-inner { + @extend %date-disabled-range !optional; + } + } + + @include e(date, 'range-preview', $not: ('special', 'current', 'first', 'last')) { + %date-inner { + @extend %date-disabled-range-preview !optional; + } + } + + // FLUENT THEME + @include m(fluent) { + // CURRENT + @include e(date, 'current') { + %date-inner { + @extend %fluent-date-current !optional; + } + } + + @include e(date, $mods: ('current', 'active')) { + %date-inner { + @extend %fluent-date-current-focus !optional; + } + } + + // CURRENT + SELECTED + @include e(date, $mods: ('current', 'selected')) { + %date-inner { + @extend %fluent-date-current-selected !optional; + } + } + + @include e(date, $mods: ('current', 'selected', 'active')) { + %date-inner { + @extend %fluent-date-current-selected-focus !optional; + } + } + + // SPECIAL + @include e(date, 'special') { + %date-inner { + @extend %fluent-date-special !optional; + } + } + + @include e(date, $mods: ('special', 'active')) { + %date-inner { + @extend %fluent-date-special-focus !optional; + } + } + + // SPECIAL + SELECTED + @include e(date, $mods: ('special', 'selected')) { + %date-inner { + @extend %fluent-date-special-selected !optional; + } + } + + @include e(date, $mods: ('special', 'selected', 'active')) { + %date-inner { + @extend %fluent-date-special-selected-focus !optional; + } + } + + + // CURRENT + SPECIAL + @include e(date, $mods: ('current', 'special')) { + %date-inner { + @extend %fluent-date-current-special !optional; + } + } + @include e(date, $mods: ('current', 'special', 'active')) { + %date-inner { + @extend %fluent-date-current-special-focus !optional; + } + } + + // CURRENT + SPECIAL + SELECTED + @include e(date, $mods: ('current', 'special', 'selected')) { + %date-inner { + @extend %fluent-date-current-special-selected !optional; + } + } + @include e(date, $mods: ('current', 'special', 'selected', 'active')) { + %date-inner { + @extend %fluent-date-current-special-selected-focus !optional; + } + } + + @include e(date, $mods: ('current', 'special', 'selected', 'range-preview', 'first')) { + %date-inner { + @extend %fluent-date-current-special-selected-range-preview-first-last !optional; + } + } + + @include e(date, $mods: ('current', 'special', 'selected', 'range-preview', 'last')) { + %date-inner { + @extend %fluent-date-current-special-selected-range-preview-first-last !optional; + } + } + + // FIRST + LAST + @include e(date, $mods: ('first', 'last'), $not: ('current', 'special')) { + %date-inner { + @extend %fluent-date-first-last !optional; + } + } + + @include e(date, $mods: ('first', 'last', 'active'), $not: ('current', 'special')) { + %date-inner { + @extend %fluent-date-first-last-focus !optional; + } + } + + // RANGE + @include e(date, range) { + @extend %fluent-date-range !optional; + @extend %fluent-date-range-plus !optional; + + } + + @include e(date, $mods: ('range', 'first')) { + @extend %fluent-date-range-first !optional; + } + + @include e(date, $mods: ('range', 'last')) { + @extend %fluent-date-range-last !optional; + } + + // PREVIEW + @include e(date, 'range-preview') { + @extend %fluent-date-range-preview-last-after !optional; + + %date-inner { + @extend %fluent-date-range-preview !optional; + } + } + + @include e(date, 'range-preview', $not: ('disabled', 'inactive', 'weekend')) { + %date-inner { + @extend %fluent-date-range-preview-not-disabled !optional; + } + } + + @include e(date, $mods: ('range-preview', 'inactive'), $not: ('disabled')) { + %date-inner { + @extend %fluent-date-range-preview-inactive !optional; + } + } + + @include e(date, $mods: ('range-preview', 'weekend'), $not: ('current', 'special', 'inactive', 'disabled')) { + %date-inner { + @extend %fluent-date-range-preview-weekend !optional; + } + } + + @include e(date, 'range-preview', $not: ('first', 'last', 'current', 'special')) { + %date-inner { + @extend %fluent-date-range-preview-middle !optional; + } + } + + @include e(date, $mods: ('range-preview', 'first')) { + @extend %fluent-date-range-preview-first !optional; + } + + @include e(date, $mods: ('range-preview', 'last')) { + @extend %fluent-date-range-preview-last !optional; + } + + @include e(date, $mods: ('range-preview', 'first', 'selected')) { + %date-inner { + @extend %fluent-date-range-preview-first-last-selected !optional; + } + } + + @include e(date, $mods: ('range-preview', 'last', 'selected')) { + %date-inner { + @extend %fluent-date-range-preview-first-last-selected !optional; + } + } + + // RESET HOVER/FOCUS STYLES IN PREVIEW + @include e(date, $mods: ('range-preview', 'special')) { + %date-inner { + @extend %fluent-date-range-preview-special !optional; + } + } + + @include e(date, $mods: ('range-preview', 'special', 'active')) { + %date-inner { + @extend %fluent-date-range-preview-special-focus !optional; + } + } + + @include e(date, $mods: ('range-preview', 'current')) { + %date-inner { + @extend %fluent-date-range-preview-current !optional; + } + } + + @include e(date, $mods: ('range-preview', 'current', 'active')) { + %date-inner { + @extend %fluent-date-range-preview-current-focus !optional; + } + } + + @include e(date, $mods: ('range-preview', 'current', 'special')) { + %date-inner { + @extend %fluent-date-range-preview-current-special !optional; + } + } + + @include e(date, $mods: ('range-preview', 'current', 'special', 'active')) { + %date-inner { + @extend %fluent-date-range-preview-current-special-focus !optional; + } + } + + @include e(date, $mods: ('selected', 'first', 'last')) { + %date-inner { + @extend %fluent-selected-first-last !optional; + } + } + + @include e(date, $mods: ('selected', 'first', 'last', 'special')) { + %date-inner { + @extend %fluent-selected-first-last-special !optional; + } + } + + @include e(date, $mods: ('selected', 'first', 'last', 'current')) { + %date-inner { + @extend %fluent-selected-first-last-current !optional; + } + } + + @include e(date, $mods: ('selected', 'first', 'last', 'current', 'special')) { + %date-inner { + @extend %fluent-selected-first-last-current-special !optional; + } + } + + @include e(date, $mods: ('selected', 'first', 'last', 'current', 'special', 'active')) { + %date-inner { + @extend %fluent-selected-first-last-current-special-focus !optional; + } + } + + @include e(date, $mods: ('range-preview', 'selected', 'special', 'first')) { + %date-inner { + @extend %fluent-date-range-preview-selected-special-first !optional; + } + } + + @include e(date, $mods: ('range-preview', 'selected', 'special', 'last')) { + %date-inner { + @extend %fluent-date-range-preview-selected-special-last !optional; + } + } + + @include e(date, $mods: ('range-preview', 'selected', 'current', 'first')) { + %date-inner { + @extend %fluent-date-range-preview-current-first-last !optional; + } + } + + @include e(date, $mods: ('range-preview', 'selected', 'current', 'last')) { + %date-inner { + @extend %fluent-date-range-preview-current-first-last !optional; + } + } + + // RANGE + SELECTED + @include e(date, $mods: ('range', 'selected')) { + @extend %fluent-date-range-selected !optional; + } + + @include e(date, $mods: ('range', 'selected', 'first')) { + @extend %fluent-date-range-selected-first-last !optional; + @extend %fluent-date-range-selected-first !optional; + } + + @include e(date, $mods: ('range', 'selected', 'last')) { + @extend %fluent-date-range-selected-first-last !optional; + @extend %fluent-date-range-selected-last !optional; + } + + @include e(date, $mods: ('range', 'selected'), $not: ('range-preview', 'special', 'current', 'disabled')) { + %date-inner { + @extend %fluent-date-range-selected-not-preview-disabled !optional; + } + } + + @include e(date, $mods: ('range', 'selected', 'active'), $not: ('range-preview', 'special', 'current', 'disabled')) { + %date-inner { + @extend %fluent-date-range-selected-not-preview-disabled-focus !optional; + } + } + + @include e(date, $mods: ('range', 'selected', 'inactive'), $not: ('range-preview')) { + %date-inner { + @extend %fluent-date-range-selected-not-preview !optional; + } + } + + @include e(date, $mods: ('range', 'selected', 'special'), $not: ('range-preview')) { + %date-inner { + @extend %fluent-date-range-selected-special-not-preview !optional; + } + } + + @include e(date, $mods: ('range', 'selected', 'current'), $not: ('range-preview')) { + %date-inner { + @extend %fluent-date-range-selected-current-not-preview !optional; + } + } + + @include e(date, $mods: ('range', 'special'), $not: ('range-preview')) { + %date-inner { + @extend %fluent-date-range-special-not-preview !optional; + } + } + + @include e(date, $mods: ('range', 'special', 'active'), $not: ('range-preview')) { + %date-inner { + @extend %fluent-date-range-special-not-preview-focus !optional; + } + } + + @include e(date, $mods: ('range', 'current'), $not: ('range-preview')) { + %date-inner { + @extend %fluent-date-range-current-not-preview !optional; + } + } + + @include e(date, $mods: ('range', 'current', 'active'), $not: ('range-preview')) { + %date-inner { + @extend %fluent-date-range-current-not-preview-focus !optional; + } + } + + @include e(date, $mods: ('range', 'current', 'special'), $not: ('range-preview')) { + %date-inner { + @extend %fluent-date-range-current-special-not-preview !optional; + } + } + + @include e(date, $mods: ('range', 'current', 'special', 'active'), $not: ('range-preview')) { + %date-inner { + @extend %fluent-date-range-current-special-not-preview-focus !optional; + } + } + + // DISABLED + @include e(date, $mods: ('range', 'special', 'disabled'), $not: ('range-preview')) { + %date-inner { + @extend %fluent-date-range-special-disabled !optional; + } + } + + @include e(date, $mods: ('range', 'current', 'disabled'), $not: ('range-preview')) { + %date-inner { + @extend %fluent-date-range-special-disabled !optional; + } + } + + @include e(date, $mods: ('range', 'special', 'current', 'disabled'), $not: ('range-preview')) { + %date-inner { + @extend %fluent-date-range-special-current-disabled !optional; + } + } + } + + // BOOTSTRAP THEME + @include m(bootstrap) { + // SPECIAL + @include e(date, 'special') { + %date-inner { + @extend %bootstrap-date-special !optional; + } + } + + @include e(date, $mods: ('special', 'first')) { + %date-inner { + @extend %bootstrap-date-special-first-last !optional; + } + } + + @include e(date, $mods: ('special', 'last')) { + %date-inner { + @extend %bootstrap-date-special-first-last !optional; + } + } + + @include e(date, $mods: ('special', 'range'), $not: ('range-preview', 'current', 'first', 'last')) { + %date-inner { + @extend %bootstrap-date-special-range !optional; + } + } + + @include e(date, $mods: ('special', 'range', 'active'), $not: ('range-preview', 'current', 'first', 'last')) { + %date-inner { + @extend %bootstrap-date-special-range-focus !optional; + } + } + + // CURRENT + @include e(date, $mods: ('current', 'first', 'last')) { + %date-inner { + @extend %bootstrap-date-current-first-last !optional; + } + } + + // CURRENT + SPECIAL + @include e(date, $mods: ('current', 'special'), $not: ('first', 'last')) { + %date-inner { + @extend %bootstrap-date-current-special !optional; + } + } + + @include e(date, $mods: ('current', 'special', 'active'), $not: ('first', 'last')) { + %date-inner { + @extend %bootstrap-date-current-special-focus !optional; + } + } + + + // CURRENT + SPECIAL + RANGE + @include e(date, $mods: ('current', 'special', 'range'), $not: ('first', 'last')) { + %date-inner { + @extend %bootstrap-date-current-special-range !optional; + } + } + + @include e(date, $mods: ('current', 'special', 'range', 'active'), $not: ('first', 'last')) { + %date-inner { + @extend %bootstrap-date-current-special-range-focus !optional; + } + } + + @include e(date, $mods: ('current', 'special', 'selected'), $not: ('range', 'first', 'last')) { + %date-inner { + @extend %bootstrap-date-current-special-selected-not-range !optional; + } + } + + @include e(date, $mods: ('current', 'special', 'selected', 'active'), $not: ('range', 'first', 'last')) { + %date-inner { + @extend %bootstrap-date-current-special-selected-not-range-focus !optional; + } + } + + // PREVIEW + @include e(date, 'range-preview') { + @extend %bootstrap-date-range-preview !optional; + } + + @include e(date, $mods:('range-preview', 'first')) { + @extend %bootstrap-date-preview--first-last !optional; + } + + @include e(date, $mods:('range-preview', 'last')) { + @extend %bootstrap-date-preview--first-last !optional; + } + + @include e(date, $mods:('range-preview', 'first', 'last')) { + %date-inner { + @extend %bootstrap-date-preview--first-and-last !optional; + } + } + + @include e(date, $mods:('range-preview', 'first', 'active')) { + @extend %bootstrap-date-preview--first-last-focus !optional; + } + + @include e(date, $mods:('range-preview', 'last', 'active')) { + @extend %bootstrap-date-preview--first-last-focus !optional; + } + + @include e(date, $mods: ('range-preview', 'current')) { + %date-inner { + @extend %bootstrap-date-range-preview-current !optional; + } + } + + @include e(date, $mods: ('range-preview', 'current', 'active')) { + %date-inner { + @extend %bootstrap-date-range-preview-current-focus !optional; + } + } + + @include e(date, $mods: ('range-preview', 'current', 'first')) { + %date-inner { + @extend %bootstrap-date-range-preview-current-first-last !optional; + } + } + + @include e(date, $mods: ('range-preview', 'current', 'last')) { + %date-inner { + @extend %bootstrap-date-range-preview-current-first-last !optional; + } + } + + @include e(date, $mods: ('range-preview', 'current', 'active', 'first')) { + %date-inner { + @extend %bootstrap-date-range-preview-current-focus-first-last !optional; + } + } + + @include e(date, $mods: ('range-preview', 'current', 'active', 'last')) { + %date-inner { + @extend %bootstrap-date-range-preview-current-focus-first-last !optional; + } + } + + @include e(date, $mods: ('range-preview', 'current', 'first')) { + %date-inner { + @extend %bootstrap-date-preview-special--first-last !optional; + } + } + + @include e(date, $mods: ('range-preview', 'current', 'last')) { + %date-inner { + @extend %bootstrap-date-preview-special--first-last !optional; + } + } + + @include e(date, $mods: ('range-preview', 'current', 'active', 'first')) { + %date-inner { + @extend %bootstrap-date-preview-special-focus--first-last !optional; + } + } + + @include e(date, $mods: ('range-preview', 'current', 'active', 'last')) { + %date-inner { + @extend %bootstrap-date-preview-special-focus--first-last !optional; + } + } + + @include e(date, $mods: ('range-preview', 'special', 'first')) { + %date-inner { + @extend %bootstrap-date-preview-special--first-last !optional; + } + } + + @include e(date, $mods: ('range-preview', 'special', 'last')) { + %date-inner { + @extend %bootstrap-date-preview-special--first-last !optional; + } + } + + @include e(date, $mods: ('range-preview', 'special', 'active', 'first')) { + %date-inner { + @extend %bootstrap-date-preview-special-focus--first-last !optional; + } + } + + @include e(date, $mods: ('range-preview', 'special', 'active', 'last')) { + %date-inner { + @extend %bootstrap-date-preview-special-focus--first-last !optional; + } + } + + @include e(date, $mods: ('range-preview', 'weekend'), $not: ('special', 'current', 'first', 'last')) { + %date-inner { + @extend %bootstrap-date-preview-weekend-inactive !optional; + } + } + + @include e(date, $mods: ('range-preview', 'weekend', 'active'), $not: ('special', 'current', 'first', 'last')) { + %date-inner { + @extend %bootstrap-date-preview-weekend-inactive-focus !optional; + } + } + + @include e(date, $mods: ('range-preview', 'inactive'), $not: ('special', 'current', 'first', 'last')) { + %date-inner { + @extend %bootstrap-date-preview-weekend-inactive !optional; + } + } + + @include e(date, $mods: ('range-preview', 'inactive', 'active'), $not: ('special', 'current', 'first', 'last')) { + %date-inner { + @extend %bootstrap-date-preview-weekend-inactive-focus !optional; + } + } + + + // RANGE + @include e(date, $mods: ('range', 'current', 'first')) { + %date-inner { + @extend %bootstrap-date-range-radius !optional; + } + } + + @include e(date, $mods: ('range', 'current', 'last')) { + %date-inner { + @extend %bootstrap-date-range-radius !optional; + } + } + + @include e(date, $mods: ('range', 'special', 'first')) { + %date-inner { + @extend %bootstrap-date-range-radius !optional; + } + } + + @include e(date, $mods: ('range', 'special', 'last')) { + %date-inner { + @extend %bootstrap-date-range-radius !optional; + } + } + + + // DISABLED PREVIEW + @include e(date, $mods: ('disabled', 'range-preview'), $not: ('special', 'current', 'first', 'last')) { + %date-inner { + @extend %bootstrap-date-disabled-range-preview !optional; + } + } + } + + // INDIGO THEME + @include m(indigo) { + @include e(label, 'week-number') { + @extend %indigo-label-week-number !optional; + } + + @include e(date-inner, 'week-number') { + @extend %indigo-date-inner-week-number !optional; + } + + @include e(date, 'special') { + %date-inner { + font-weight: 700; + } + } + + // SELECTED + CURRENT + FIRST/ LAST + @include e(date, $mods: ('current', 'selected', 'first')) { + %date-inner { + @extend %indigo-current-selected-first-last !optional; + } + } + + @include e(date, $mods: ('current', 'selected', 'last')) { + %date-inner { + @extend %indigo-current-selected-first-last !optional; + } + } + + @include e(date, $mods: ('current', 'selected', 'first', 'active')) { + %date-inner { + @extend %indigo-current-selected-first-last-focus !optional; + } + } + + @include e(date, $mods: ('current', 'selected', 'last', 'active')) { + %date-inner { + @extend %indigo-current-selected-first-last-focus !optional; + } + } + + // CURRENT + SELECTED RANGE + FIRST/ LAST + @include e(date, $mods: ('current', 'selected', 'range', 'first')) { + %date-inner { + @extend %indigo-current-selected-range-first-last !optional; + } + } + + @include e(date, $mods: ('current', 'selected', 'range', 'last')) { + %date-inner { + @extend %indigo-current-selected-range-first-last !optional; + } + } + + @include e(date, $mods: ('current', 'selected', 'range', 'first', 'active')) { + %date-inner { + @extend %indigo-current-selected-range-first-last-focus !optional; + } + } + + @include e(date, $mods: ('current', 'selected', 'range', 'last', 'active')) { + %date-inner { + @extend %indigo-current-selected-range-first-last-focus !optional; + } + } + + // SPECIAL + SELECTED FIRST/ LAST + @include e(date, $mods: ('special', 'selected', 'first')) { + %date-inner { + @extend %indigo-special-selected-first-last !optional; + + // stylelint-disable-next-line max-nesting-depth + &::after { + @extend %indigo-date-special-selected-inner-radius !optional; + } + } + } + + @include e(date, $mods: ('special', 'selected', 'last')) { + %date-inner { + @extend %indigo-special-selected-first-last !optional; + + // stylelint-disable-next-line max-nesting-depth + &::after { + @extend %indigo-date-special-selected-inner-radius !optional; + } + } + } + + @include e(date, $mods: ('special', 'selected', 'first', 'active')) { + %date-inner { + @extend %indigo-special-selected-first-last-focus !optional; + } + } + + @include e(date, $mods: ('special', 'selected', 'last', 'active')) { + %date-inner { + @extend %indigo-special-selected-first-last-focus !optional; + } + } + + // SPECIAL + SELECTED + @include e(date, $mods: ('special', 'selected'), $not: ( 'range', 'range-preview')) { + %date-inner { + // stylelint-disable-next-line max-nesting-depth + &::after { + @extend %indigo-date-special-selected-inner-radius !optional; + } + } + } + + // SPECIAL + CURRENT + RANGE + @include e(date, $mods: ('special', 'current')) { + %date-inner { + @extend %indigo-special-current !optional; + } + } + + @include e(date, $mods: ('special', 'current', 'range')) { + %date-inner { + @extend %indigo-special-current-range !optional; + } + } + + @include e(date, $mods: ('special', 'current', 'range', 'active')) { + %date-inner { + @extend %indigo-special-current-range-focus !optional; + } + } + + @include e(date, $mods: ('special', 'current', 'range'), $not: ('first', 'last')) { + %date-inner { + @extend %indigo-special-current-range-not-first-last !optional; + } + } + + @include e(date, $mods: ('special', 'current', 'range', 'active'), $not: ('first', 'last')) { + %date-inner { + @extend %indigo-special-current-range-not-first-last-focus !optional; + } + } + + + @include e(date, $mods: ('special', 'current', 'range', 'first')) { + %date-inner { + @extend %indigo-special-current-range-first-last !optional; + } + } + + @include e(date, $mods: ('special', 'current', 'range', 'last')) { + %date-inner { + @extend %indigo-special-current-range-first-last !optional; + } + } + + @include e(date, $mods: ('special', 'current', 'range', 'active', 'first')) { + %date-inner { + @extend %indigo-special-current-range-first-last-focus !optional; + } + } + + @include e(date, $mods: ('special', 'current', 'range', 'active', 'last')) { + %date-inner { + @extend %indigo-special-current-range-first-last-focus !optional; + } + } + + // SPECIAL + CURRENT + @include e(date, $mods: ('special', 'current'), $not: ('range')) { + %date-inner { + @extend %indigo-date-special-current-indented !optional; + + // stylelint-disable-next-line max-nesting-depth + &::after { + @extend %indigo-date-special-selected-inner-radius !optional; + } + } + } + + @include e(date, $mods: ('special', 'current', 'active'), $not: ('range')) { + %date-inner { + @extend %indigo-date-special-current-focus !optional; + } + } + + @include e(date, $mods: ('special', 'current'), $not: ('selected', 'range')) { + %date-inner { + @extend %indigo-date-special-current-not-selected !optional; + } + } + + @include e(date, $mods: ('special', 'current', 'active'), $not: ('selected', 'range')) { + %date-inner { + @extend %indigo-date-special-current-focus-not-selected !optional; + } + } + + @include e(date, $mods: ('special', 'current', 'selected'), $not: ( 'range')) { + %date-inner { + @extend %indigo-date-special-current-selected !optional; + } + } + + @include e(date, $mods: ('special', 'current', 'selected', active), $not: ( 'range')) { + %date-inner { + @extend %indigo-date-special-current-selected-focus !optional; + } + } + } + } + + @include b(igx-calendar-view) { + @extend %calendar-view !optional; + + @include e(items) { + @extend %calendar-items !optional; + } + + @include e(item) { + @extend %view-item !optional; + } + + @include e(item, 'current') { + @extend %calendar-view__item-current !optional; + } + + @include e(item, $mods: ('current', 'active')) { + @extend %calendar-view__item-current-active !optional; + } + + @include e(item, 'selected') { + @extend %calendar-view__item-selected !optional; + } + + @include e(item, $mods: ('selected', 'active')) { + @extend %calendar-view__item-selected-active !optional; + } + + @include e(item, $mods: ('selected', 'current')) { + @extend %calendar-view__item-selected-current !optional; + } + + @include e(item, $mods: ('selected', 'current', 'active')) { + @extend %calendar-view__item-selected-current-active !optional; + } + + @include e(item-inner) { + @extend %calendar-view__item-inner !optional; + + &:hover { + @extend %calendar-view__item-inner-hover !optional; + } + } + } + + @include b(igx-month-picker) { + @extend %month-picker !optional; + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/calendar/_calendar-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/calendar/_calendar-theme.scss new file mode 100644 index 00000000000..cf67ef36662 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/calendar/_calendar-theme.scss @@ -0,0 +1,2533 @@ +@use 'sass:map'; +@use 'sass:math'; +@use '../../base' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin calendar($theme) { + @include css-vars($theme); + + $variant: map.get($theme, '_meta', 'theme'); + + $cal-picker-padding: map.get(( + 'material': rem(16px), + 'fluent': rem(16px), + 'bootstrap': rem(8px), + 'indigo': rem(16px), + ), $variant); + + $date-size: map.get(( + 'material': var-get($theme, 'size'), + 'fluent': var-get($theme, 'size'), + 'bootstrap': var-get($theme, 'size'), + 'indigo': var-get($theme, 'size'), + ), $variant); + + $date-height: $date-size; + + $date-inner-size: map.get(( + 'material': var-get($theme, 'inner-size'), + 'fluent': var-get($theme, 'inner-size'), + 'bootstrap': var-get($theme, 'inner-size'), + 'indigo': var-get($theme, 'inner-size'), + ), $variant); + + $border-size: map.get(( + 'material': rem(1px), + 'fluent': rem(1px), + 'bootstrap': rem(1px), + 'indigo': rem(2px), + ), $variant); + + $header-padding: map.get(( + 'material': pad-block(rem(16px)) pad-inline(rem(24px)), + 'fluent': pad(rem(16px)), + 'bootstrap': pad(rem(16px)), + 'indigo': pad(rem(16px)), + ), $variant); + + $arrow-gap: map.get(( + 'material': rem(24px), + 'fluent': rem(8px), + 'bootstrap': rem(24px), + 'indigo': rem(8px) + ), $variant); + + $date-view-row-gap: rem(4px); + + $cal-row-padding: pad(rem(8px)); + $cal-row-margin: pad-block(rem(2px)) 0; + + $fake-bg-size: calc(50% + (var-get($theme, 'size') / 2)); + $fake-bg-position: calc(50% - (var-get($theme, 'size') / 2)); + + %parent-container { + color: var-get($theme, 'content-foreground'); + background: var-get($theme, 'content-background'); + box-shadow: 0 0 0 rem(1px) var-get($theme, 'border-color'); + border-radius: var-get($theme, 'border-radius'); + min-width: sizable(rem(290px), rem(314px), rem(360px)); + overflow: hidden; + outline: none; + } + + %calendar { + @extend %parent-container; + + @include sizable(); + + --component-size: var(--ig-size, #{var-get($theme, 'default-size')}); + --dropdown-size: var(--component-size); + + display: flex; + flex-flow: column nowrap; + width: 100%; + overflow: hidden; + + %days-view, + %calendar-view { + background: inherit; + color: inherit; + box-shadow: none; + } + + %days-view, + %calendar-view, + %views-navigation { + border-radius: 0; + } + } + + %days-view, + %calendar-view { + @include sizable(); + + --component-size: var(--ig-size, #{var-get($theme, 'default-size')}); + --dropdown-size: var(--component-size); + + color: var-get($theme, 'content-foreground'); + background: var-get($theme, 'content-background'); + box-shadow: 0 0 0 rem(1px) var-get($theme, 'border-color'); + border-radius: var-get($theme, 'border-radius'); + padding-block: pad-block(rem(16px)); + + &:focus, + &:focus-within, + &:focus-visible { + outline: none; + } + } + + %calendar-view { + display: flex; + justify-content: center; + position: relative; + z-index: 1; + grid-column: 1 / -1; + padding-inline: pad-inline(rem(8px), rem(8px), rem(16px)); + } + + %view-item { + display: flex; + justify-content: center; + align-items: center; + } + + @if $variant == 'bootstrap' { + %days-view { + padding-block-end: pad-block(rem(16px)); + padding-block-start: 0; + padding-inline: 0; + } + + %days-view + %days-view { + %days-view-row { + padding-inline-start: pad-inline(rem(22px)); + } + } + + %days-view:first-child, + %days-view:nth-child(even) { + %days-view-row { + padding-inline-end: pad-inline(rem(22px)); + } + } + + + %days-view:last-child, + %days-view:first-child:only-child { + %days-view-row { + padding-inline-end: pad-inline(rem(12px)); + } + } + + .igx-date-picker { + margin-block-end: 0; + + %views-navigation { + padding-bottom: 0; + } + + %picker-arrow { + top: $cal-picker-padding; + } + } + } @else { + %body-display:not(%body-display--vertical) { + %days-view + %days-view { + padding-inline-start: 0; + } + + %days-view:first-child, + %days-view:nth-child(even) { + padding-inline-end: 0; + } + + %days-view:last-child { + padding-inline-end: pad-inline(rem(12px)); + } + } + } + + %month-picker { + @extend %parent-container; + + display: flex; + + %calendar-view { + color: inherit; + background: inherit; + box-shadow: none; + border-radius: 0; + } + + @if $variant == 'bootstrap' { + %views-navigation { + border-block-end: rem(1px) solid var-get($theme, 'border-color'); + + } + } + } + + %calendar-vertical { + flex-flow: row nowrap; + } + + %header-display { + display: flex; + flex-direction: column; + color: var-get($theme, 'header-foreground'); + background: var-get($theme, 'header-background'); + padding: $header-padding; + position: relative; + + @if $variant == 'material' { + gap: rem(28px); + } + + @if $variant == 'indigo' { + gap: rem(2px); + + &::after { + content: ''; + position: absolute; + background: var-get($theme, 'border-color'); + inset-block-start: calc(100% - #{rem(1px)}); + inset-inline-start: 0; + height: rem(1px); + width: 100%; + } + } + } + + %header-display-vertical { + min-width: if($variant == 'indigo', rem(136px), rem(168px)); + width: if($variant == 'indigo', rem(136px), rem(168px)); + + @if $variant == 'indigo' { + &::after { + inset-inline-start: calc(100% - #{rem(1px)}); + inset-block-start: 0; + height: 100%; + width: rem(1px); + } + } + } + + %header-year { + margin: 0; + color: currentColor; + + @if $variant == 'bootstrap' { + min-height: rem(24px); + } + } + + %header-date { + display: flex; + + @if $variant == 'bootstrap' { + padding-block-end: pad-block(rem(8px)); + } @else { + margin: 0; + } + + > span { + @include ellipsis(); + } + } + + %header-date-vertical { + flex-wrap: wrap; + + > span { + white-space: nowrap; + text-overflow: initial; + overflow: initial; + } + } + + %calendar-wrapper { + display: flex; + width: 100%; + flex-direction: column; + outline-style: none; + } + + %calendar-wrapper--vertical { + display: grid; + grid-template-rows: repeat(#{calc(var(--calendar-months) * 2), auto}); + + %days-view { + grid-row: var(--calendar-row-start); + } + } + + %pickers-display { + display: flex; + flex-grow: 1; + grid-row: var(--calendar-row-start); + background: var-get($theme, 'picker-background'); + + &:focus-visible { + outline: none; + } + + > * { + flex-grow: 1; + } + } + + %body-display, + %pickers-display--days { + display: grid; + grid-template-columns: repeat(var(--calendar-months), 1fr); + } + + @if $variant != 'bootstrap' { + %body-display { + column-gap: rem(44px); + } + + %pickers-display--days { + gap: rem(40px); + } + } + + %pickers-display--vertical { + @if $variant != 'fluent' { + %picker-arrow { + transform: rotate(90deg) + } + } + } + + %pickers-display--vertical, + %body-display--vertical { + display: contents; + } + + %views-navigation { + display: flex; + align-items: center; + justify-content: space-between; + gap: rem(24px); + position: relative; + height: if($variant == 'indigo', rem(50px), rem(56px)); + padding-inline: pad-inline(if($variant == 'material', rem(24px), rem(16px))); + color: var-get($theme, 'picker-foreground'); + background: var-get($theme, 'picker-background'); + + &:focus, + &:focus-within { + outline: none; + } + + igx-icon { + --size: #{if($variant == 'indigo', rem(14px), rem(24px))}; + } + } + + %picker-dates { + display: flex; + gap: rem(4px); + } + + %days-view { + @if $variant == 'bootstrap' { + %days-view-row { + // This is the weekday labels row + &:first-of-type { + background: var-get($theme, 'header-background'); + border-block-end: rem(1px) solid var-get($theme, 'border-color'); + } + } + } @else { + padding-inline: pad-inline(rem(12px)); + } + + gap: $date-view-row-gap; + } + + %picker__nav { + display: flex; + gap: $arrow-gap; + position: absolute; + inset-inline-end: rem(16px); + } + + %picker-arrow { + display: flex; + align-items: center; + justify-content: center; + color: var-get($theme, 'navigation-color'); + user-select: none; + outline: none; + cursor: pointer; + + [dir='rtl'] & { + transform: scaleX(-1); + } + + @if $variant == 'indigo' { + padding: pad(rem(5px)); + } + + @if $variant == 'bootstrap' { + top: math.div($cal-picker-padding, 2); + } + + &:hover { + color: var-get($theme, 'navigation-hover-color'); + } + + &:focus { + color: var-get($theme, 'navigation-focus-color'); + } + } + + %picker-date { + color: var-get($theme, 'picker-foreground'); + text-align: center; + outline: none; + transition: color 150ms ease-in-out 0s; + + &:hover { + color: var-get($theme, 'picker-hover-foreground'); + cursor: pointer; + } + + &:focus { + color: var-get($theme, 'picker-focus-foreground'); + } + } + + %days-view-grid { + flex: 1 1 auto; + } + + %days-view-row { + display: flex; + justify-content: space-between; + + @if $variant == 'bootstrap' { + &:nth-child(2) { + %date-inner-week-number { + border-start-start-radius: var-get($theme, 'week-number-border-radius'); + border-start-end-radius: var-get($theme, 'week-number-border-radius'); + } + } + padding-inline: pad-inline(rem(12px)); + + &:last-of-type { + margin-block-end: 0; + } + } + + &:last-of-type { + %date-inner-week-number { + border-end-start-radius: var-get($theme, 'week-number-border-radius'); + border-end-end-radius: var-get($theme, 'week-number-border-radius'); + + &::before { + display: none; + } + } + } + } + + %label-week-number, + %date-inner-week-number { + position: relative; + border-radius: 0; + pointer-events: none; + z-index: 1; + + @if $variant == 'bootstrap' { + font-style: italic !important; + } + } + + %date-week-number { + pointer-events: none; + + &:hover, + &:focus, + &:focus-visible, + &:focus-within { + %date-inner-week-number { + cursor: default; + color: var-get($theme, 'week-number-foreground'); + } + } + } + + %label-week-number { + text-align: center; + + span { + display: flex; + justify-content: center; + align-items: center; + width: $date-size; + height: $date-height; + position: relative; + border-top-left-radius: var-get($theme, 'week-number-border-radius'); + border-top-right-radius: var-get($theme, 'week-number-border-radius'); + border: rem(1px) solid transparent; + + @if $variant == 'bootstrap' { + color: var-get($theme, 'weekday-color'); + } @else { + &::before { + content: ''; + position: absolute; + background: var-get($theme, 'week-number-background'); + inset-block-start: calc(100% + #{$border-size}); + height: $date-view-row-gap; + width: $date-size; + } + + color: var-get($theme, 'week-number-foreground'); + background: var-get($theme, 'week-number-background'); + } + + @if $variant == 'indigo' { + border: 0; + + &::before { + height: $date-view-row-gap; + inset-block-start: 100%; + inset-inline-start: 0; + border: 0; + } + } + + > i { + @include ellipsis(); + + padding: rem(4px); + font-style: normal; + } + } + } + + %calendar-items { + display: grid; + grid-template-columns: repeat(3, minmax(max-content, 1fr)); + row-gap: rem(4px); + column-gap: rem(8px); + width: 100%; + } + + %calendar-view__item-inner { + display: flex; + justify-content: center; + align-items: center; + height: $date-height; + width: 100%; + border-radius: var-get($theme, 'ym-border-radius'); + padding: 0 rem(12px); + outline: none; + cursor: pointer; + position: relative; + max-width: rem(240px); + + @if $variant == 'indigo' { + &::after { + content: ''; + position: absolute; + width: 100%; + height: 100%; + inset-inline-start: 0; + inset-block-start: 0; + z-index: 0; + border-radius: inherit; + border: $border-size solid transparent; + pointer-events: none; + } + } + } + + %calendar-view__item-selected-current-active { + %calendar-view__item-inner { + color: var-get($theme, 'ym-selected-current-hover-foreground'); + background: var-get($theme, 'ym-selected-current-hover-background'); + + @if $variant != 'indigo' { + box-shadow: inset 0 0 0 $border-size var-get($theme, 'ym-selected-current-outline-focus-color'); + } @else { + &::after { + border-color: var-get($theme, 'ym-selected-current-outline-focus-color'); + } + } + + @if $variant == 'fluent' { + box-shadow: 0 0 0 $border-size var-get($theme, 'ym-selected-current-outline-focus-color'); + } + + &:hover { + color: var-get($theme, 'ym-selected-current-hover-foreground'); + background: var-get($theme, 'ym-selected-current-hover-background'); + + // stylelint-disable-next-line + @if $variant != 'indigo' { + box-shadow: inset 0 0 0 $border-size var-get($theme, 'ym-selected-current-outline-focus-color'); + } @else { + &::after { + border-color: var-get($theme, 'ym-selected-current-outline-focus-color'); + } + } + + @if $variant == 'fluent' { + box-shadow: 0 0 0 $border-size var-get($theme, 'ym-selected-current-outline-focus-color'); + } + } + } + } + + %calendar-view__item-inner-hover { + color: var-get($theme, 'ym-hover-foreground'); + background: var-get($theme, 'ym-hover-background'); + } + + %calendar-view__item-current { + %calendar-view__item-inner { + color: var-get($theme, 'ym-current-foreground'); + background: var-get($theme, 'ym-current-background'); + box-shadow: inset 0 0 0 $border-size var-get($theme, 'ym-current-outline-color'); + + &:hover { + color: var-get($theme, 'ym-current-hover-foreground'); + background: var-get($theme, 'ym-current-hover-background'); + box-shadow: inset 0 0 0 $border-size var-get($theme, 'ym-current-outline-hover-color'); + } + } + } + + %calendar-view__item-current-active { + %calendar-view__item-inner { + @if $variant == 'fluent' or $variant == 'bootstrap' { + box-shadow: inset 0 0 0 $border-size var-get($theme, 'ym-current-outline-focus-color'); + } @else { + box-shadow: inset 0 0 0 $border-size var(--content-background), + 0 0 0 rem(1px) var-get($theme, 'ym-current-outline-focus-color'); + } + } + } + + %calendar-view__item-selected { + %calendar-view__item-inner { + color: var-get($theme, 'ym-selected-foreground'); + background: var-get($theme, 'ym-selected-background'); + box-shadow: inset 0 0 0 $border-size var-get($theme, 'ym-selected-outline-color'); + + &:hover { + color: var-get($theme, 'ym-selected-hover-foreground'); + background: var-get($theme, 'ym-selected-hover-background'); + box-shadow: inset 0 0 0 $border-size var-get($theme, 'ym-selected-hover-outline-color'); + } + } + } + + %calendar-view__item-selected-active { + %calendar-view__item-inner { + color: var-get($theme, 'ym-selected-hover-foreground'); + background: var-get($theme, 'ym-selected-hover-background'); + box-shadow: inset 0 0 0 $border-size var-get($theme, 'ym-selected-focus-outline-color'); + } + } + + %calendar-view__item-selected-current { + %calendar-view__item-inner { + color: var-get($theme, 'ym-selected-current-foreground'); + background: var-get($theme, 'ym-selected-current-background'); + + @if $variant != 'indigo' { + box-shadow: inset 0 0 0 $border-size var-get($theme, 'ym-selected-current-outline-color'); + } @else { + &::after { + border-color: var-get($theme, 'ym-selected-current-outline-color'); + } + } + + @if $variant == 'fluent' { + box-shadow: 0 0 0 $border-size var-get($theme, 'ym-selected-current-outline-color'); + } + + &:hover { + color: var-get($theme, 'ym-selected-current-hover-foreground'); + background: var-get($theme, 'ym-selected-current-hover-background'); + + // stylelint-disable-next-line + @if $variant != 'indigo' { + box-shadow: inset 0 0 0 $border-size var-get($theme, 'ym-selected-current-outline-hover-color'); + } @else { + // stylelint-disable-next-line + &::after { + border-color: var-get($theme, 'ym-selected-current-outline-hover-color'); + } + } + + + @if $variant == 'fluent' { + box-shadow: 0 0 0 $border-size var-get($theme, 'ym-selected-current-outline-hover-color'); + } + } + } + } + + // DATE, LABEL and WEEK NUMBERS + %date { + position: relative; + display: flex; + justify-content: center; + align-items: center; + color: inherit; + outline: none; + height: $date-size; + width: 100%; + border-block-start: rem(1px) solid transparent; + border-block-end: rem(1px) solid transparent; + } + + %label-week-number, + %date-week-number { + margin-inline-end: rem(4px); + justify-content: flex-start; + width: var-get($theme, 'size'); + } + + %date-inner { + position: relative; + display: inline-flex; + justify-content: center; + align-items: center; + width: $date-size; + min-width: $date-size; + height: $date-height; + border-radius: var-get($theme, 'date-border-radius'); + border: $border-size solid var-get($theme, 'date-border-color'); + z-index: 2; + } + + %date-inner-week-number { + min-width: auto; + width: $date-size; + color: var-get($theme, 'week-number-foreground'); + background: var-get($theme, 'week-number-background'); + border-color: transparent; + border-radius: 0; + + &::before { + content: ''; + position: absolute; + background: var-get($theme, 'week-number-background'); + + @if $variant != 'indigo' { + inset-block-start: calc(100% + #{$border-size}); + } @else { + inset-block-start: 100%; + } + + height: $date-view-row-gap; + width: $date-size; + } + } + + // Has to be after the %date placeholder do to specificity + %weekday-label { + @if $variant != 'bootstrap' { + height: $date-height; + } + + min-width: $date-size; + + color: var-get($theme, 'weekday-color'); + + &:hover, + &:focus { + color: var-get($theme, 'weekday-color'); + } + + border-radius: 0; + + @if $variant == 'bootstrap' { + cursor: default; + // Important is needed in order to override the typography styles + font-style: italic !important; + } + } + + %weekday-label-inner { + @include ellipsis(); + } + + + // DATE AND DATE STATES STYLES + // ---------------------------------------------------------------------------------------------- + %date-weekend { + color: var-get($theme, 'weekend-color'); + } + + %date-inactive { + color: var-get($theme, 'inactive-color'); + } + + %date-inner { + &:hover { + color: var-get($theme, 'date-hover-foreground'); + background: var-get($theme, 'date-hover-background'); + border-color: var-get($theme, 'date-hover-border-color'); + cursor: pointer; + } + } + + // ACTIVE + %date-focus { + color: var-get($theme, 'date-focus-foreground'); + background: var-get($theme, 'date-focus-background'); + border-color: var-get($theme, 'date-focus-border-color'); + } + + // SELECTED + %date-current, + %date-selected { + &::after { + @if $variant != 'fluent' { + width: $date-inner-size; + height: $date-inner-size; + } + } + } + + %date-selected { + color: var-get($theme, 'date-selected-foreground'); + background: var-get($theme, 'date-selected-background'); + border-color: var-get($theme, 'date-selected-border-color'); + + &:hover { + color: var-get($theme, 'date-selected-hover-foreground'); + background: var-get($theme, 'date-selected-hover-background'); + border-color: var-get($theme, 'date-selected-hover-border-color'); + } + } + + %date-selected--focus { + color: var-get($theme, 'date-selected-focus-foreground'); + background: var-get($theme, 'date-selected-focus-background'); + border-color: var-get($theme, 'date-selected-focus-border-color'); + } + + // CURRENT + %date-current { + color: var-get($theme, 'date-current-foreground'); + background: var-get($theme, 'date-current-background'); + border-color: var-get($theme, 'date-current-border-color'); + border-radius: inherit; + + &:hover { + color: var-get($theme, 'date-current-hover-foreground'); + background: var-get($theme, 'date-current-hover-background'); + border-color: var-get($theme, 'date-current-hover-border-color'); + } + } + + %date-current-border-radius { + border-radius: var-get($theme, 'date-current-border-radius'); + } + + %date-current--focus { + color: var-get($theme, 'date-current-focus-foreground'); + background: var-get($theme, 'date-current-focus-background'); + border-color: var-get($theme, 'date-current-focus-border-color'); + } + + %date-current--selected { + color: var-get($theme, 'date-selected-current-foreground'); + background: var-get($theme, 'date-selected-current-background'); + border-color: var-get($theme, 'date-selected-current-border-color'); + + &:hover { + color: var-get($theme, 'date-selected-current-hover-foreground'); + background: var-get($theme, 'date-selected-current-hover-background'); + border-color: var-get($theme, 'date-selected-current-hover-border-color'); + } + } + + %date-current--selected-first, + %date-current--selected-last { + color: var-get($theme, 'date-selected-foreground'); + background: var-get($theme, 'date-selected-background'); + + &:hover { + color: var-get($theme, 'date-selected-hover-foreground'); + background: var-get($theme, 'date-selected-hover-background'); + } + } + + %date-current--selected-focus { + color: var-get($theme, 'date-selected-current-focus-foreground'); + background: var-get($theme, 'date-selected-current-focus-background'); + border-color: var-get($theme, 'date-selected-current-focus-border-color'); + } + + %date-current--selected-first-focus, + %date-current--selected-last-focus { + color: var-get($theme, 'date-selected-focus-foreground'); + background: var-get($theme, 'date-selected-focus-background'); + } + + %date-selected-current-range { + color: var-get($theme, 'date-selected-current-range-foreground'); + background: var-get($theme, 'date-selected-current-range-background'); + border-color: var-get($theme, 'date-selected-current-border-color'); + + &:hover { + color: var-get($theme, 'date-selected-current-range-hover-foreground'); + background: var-get($theme, 'date-selected-current-range-hover-background'); + border-color: var-get($theme, 'date-selected-current-hover-border-color'); + } + } + + %date-selected-current-focus { + color: var-get($theme, 'date-selected-current-range-focus-foreground'); + background: var-get($theme, 'date-selected-current-range-focus-background'); + border-color: var-get($theme, 'date-selected-current-focus-border-color'); + } + + %wrapper-date-current--selected-range { + &::before { + border-radius: var-get($theme, 'date-current-border-radius'); + } + } + + // SPECIAL + %date-special { + color: var-get($theme, 'date-special-foreground'); + background: var-get($theme, 'date-special-background'); + + &::after { + content: ''; + position: absolute; + z-index: 0; + border: $border-size solid var-get($theme, 'date-special-border-color'); + border-radius: inherit; + width: var-get($theme, 'size'); + height: var-get($theme, 'size'); + box-sizing: border-box; + } + + &:hover { + color: var-get($theme, 'date-special-hover-foreground'); + background: var-get($theme, 'date-special-hover-background'); + + &::after { + border-color: var-get($theme, 'date-special-hover-border-color') + } + } + } + + %date-special-border-radius { + border-radius: var-get($theme, 'date-special-border-radius'); + } + + %date-special--focus { + color: var-get($theme, 'date-special-focus-foreground'); + background: var-get($theme, 'date-special-focus-background'); + + &::after { + border-color: var-get($theme, 'date-special-focus-border-color') + } + } + + %date-special--selected { + color: var-get($theme, 'date-selected-special-foreground'); + background: var-get($theme, 'date-selected-special-background'); + + &::after { + width: var-get($theme, 'inner-size'); + height: var-get($theme, 'inner-size'); + border-color: var-get($theme, 'date-selected-special-border-color'); + } + + &:hover { + color: var-get($theme, 'date-selected-special-hover-foreground'); + background: var-get($theme, 'date-selected-special-hover-background'); + + &::after { + border-color: var-get($theme, 'date-selected-special-hover-border-color'); + } + } + } + + %date-special--selected-first, + %date-special--selected-last { + color: var-get($theme, 'date-selected-foreground'); + background: var-get($theme, 'date-selected-background'); + + &:hover { + color: var-get($theme, 'date-selected-hover-foreground'); + background: var-get($theme, 'date-selected-hover-background'); + } + } + + %date-special--selected-focus { + color: var-get($theme, 'date-selected-special-focus-foreground'); + background: var-get($theme, 'date-selected-special-focus-background'); + + &::after { + border-color: var-get($theme, 'date-selected-special-focus-border-color'); + } + } + + %date-special--selected-first-focus, + %date-special--selected-last-focus { + color: var-get($theme, 'date-selected-focus-foreground'); + background: var-get($theme, 'date-selected-focus-background'); + + &::after { + border-color: var-get($theme, 'date-selected-special-focus-border-color'); + } + } + + %date-special-range { + color: var-get($theme, 'date-special-range-foreground'); + background: var-get($theme, 'date-special-range-background'); + + &::after { + border-color: var-get($theme, 'date-special-range-border-color'); + } + + &:hover { + color: var-get($theme, 'date-special-range-hover-foreground'); + background: var-get($theme, 'date-special-range-hover-background'); + + // stylelint-disable-next-line + &::after { + border-color: var-get($theme, 'date-special-range-hover-border-color'); + } + } + } + + %date-special-range-focus { + color: var-get($theme, 'date-special-range-focus-foreground'); + background: var-get($theme, 'date-special-range-focus-background'); + + // stylelint-disable-next-line + &::after { + border-color: var-get($theme, 'date-special-range-focus-border-color'); + } + } + + %date-special-range-not-preview { + color: var-get($theme, 'date-special-range-foreground'); + background: var-get($theme, 'date-special-range-background'); + + &:hover { + color: var-get($theme, 'date-special-range-hover-foreground'); + background: var-get($theme, 'date-special-range-hover-background'); + } + } + + %date-special-range-not-preview-focus { + color: var-get($theme, 'date-special-range-focus-foreground'); + background: var-get($theme, 'date-special-range-focus-background'); + } + + %wrapper-date-special--selected-range { + &::before { + border-radius: var-get($theme, 'date-special-border-radius'); + } + } + + %date-special--selected-range-all { + border-radius: var-get($theme, 'date-special-border-radius'); + } + + // SPECIAL + CURRENT + %material-date-special-current { + border-radius: var-get($theme, 'date-current-border-radius'); + + &::after { + width: var-get($theme, 'inner-size'); + height: var-get($theme, 'inner-size'); + border-radius: var-get($theme, 'date-special-border-radius'); + } + } + + // RANGE + %date-range { + border-block-color: var-get($theme, 'date-range-border-color'); + background: var-get($theme, 'date-selected-range-background'); + + %date-inner { + @if $variant == 'fluent' { + height: 100%; + } @else { + height: $date-height; + } + } + } + + %date-first { + &::after { + inset-inline-start: 50%; + } + } + + %date-last { + &::after { + inset-inline-end: 50%; + } + } + + %date-first, + %date-last { + &::after { + width: 50%; + height: var-get($theme, 'size'); + } + } + + %date-range-border { + border-radius: var-get($theme, 'date-range-border-radius'); + } + + // You can have first and last without range that's why we need this selector + %date-range--first, + %date-range--last { + background: transparent; + border-block-color: transparent; + + @if $variant == 'fluent' { + %date-inner { + background: transparent; + border-color: transparent; + + &:hover { + border-color: transparent; + } + } + } + + z-index: 0; + + &::after { + position: absolute; + content: ''; + z-index: -1; + background: var-get($theme, 'date-selected-range-background'); + border-block: rem(1px) solid var-get($theme, 'date-range-border-color'); + } + + &::before { + content: ''; + position: absolute; + height: $date-size; + width: $date-size; + background: var-get($theme, 'content-background'); + } + } + + %date-wrapper-range-border { + &::before { + border-radius: var-get($theme, 'date-range-border-radius'); + } + } + + %date-range-middle { + color: var-get($theme, 'date-selected-range-foreground'); + background: transparent; + border-color: transparent; + + &:hover { + color: var-get($theme, 'date-selected-range-hover-foreground'); + background: var-get($theme, 'date-selected-range-hover-background'); + } + } + + %date-range-middle--focus { + color: var-get($theme, 'date-selected-range-focus-foreground'); + background: var-get($theme, 'date-selected-range-focus-background'); + } + + // PREVIEW + %date-range-preview { + position: relative; + border-block-color: var-get($theme, 'date-range-preview-border-color'); + border-block-style: dashed; + + @if $variant == 'fluent' { + border-block-style: solid; + + %date-inner { + border-color: transparent; + + &:hover { + color: var-get($theme, 'content-foreground'); + background: transparent; + border-color: transparent; + } + } + } + } + + %date-preview--first, + %date-preview--last { + border-block-color: transparent; + + &::after { + content: ''; + position: absolute; + height: $date-size; + border-block-color: var-get($theme, 'date-range-preview-border-color'); + width: calc(50% + #{rem(1px)}); + border-width: rem(1px); + border-style: dashed; + border-inline-color: transparent; + + @if $variant == 'fluent' { + width: calc(50% + #{rem(2px)}); + border-style: solid; + border-inline-color: transparent; + } + } + } + + // DISABLED + %date-disabled { + color: var-get($theme, 'date-disabled-foreground'); + } + + %date-disabled-range { + color: var-get($theme, 'date-disabled-range-foreground'); + } + + %date-disabled-range-preview { + border-color: transparent; + } + + // OTHER + %date-hidden { + cursor: default; + visibility: hidden; + } + + %calendar__aria-off-screen { + position: absolute !important; + border: none !important; + height: 1px !important; + width: 1px !important; + inset-inline-start: 0 !important; + top: 0 !important; + overflow: hidden !important; + padding: 0 !important; + margin: 0 !important; + user-select: none; + pointer-events: none; + + &:focus { + outline: none; + } + } + + + /////////////////////////// + ////// FLUENT THEME ////// + ////////////////////////// + + // CURRENT + %fluent-date-current { + &::before { + content: ''; + position: absolute; + border: 1px solid var-get($theme, 'date-current-border-color'); + box-sizing: border-box; + width: var-get($theme, 'inner-size'); + height: var-get($theme, 'inner-size'); + background: var-get($theme, 'date-current-background'); + border-radius: var-get($theme, 'date-current-border-radius'); + z-index: -1; + } + + background: transparent; + border-color: var-get($theme, 'date-border-color'); + border-radius: var-get($theme, 'date-border-radius'); + + &:hover { + background: var-get($theme, 'date-hover-background'); + border-color: var-get($theme, 'date-hover-border-color'); + + &::before { + background: var-get($theme, 'date-current-hover-background'); + border-color: var-get($theme, 'date-current-hover-border-color'); + } + } + } + + %fluent-date-current-focus { + background: var-get($theme, 'date-focus-background'); + border-color: var-get($theme, 'date-focus-border-color'); + + &::before { + background: var-get($theme, 'date-current-focus-background'); + border-color: var-get($theme, 'date-current-focus-border-color'); + } + } + + // CURRENT + SELECTED + %fluent-date-current-selected { + color: var-get($theme, 'date-selected-current-foreground'); + background: var-get($theme, 'date-selected-background'); + border-color: var-get($theme, 'date-selected-border-color'); + + &::before { + border-color: var-get($theme, 'date-selected-current-border-color'); + background: var-get($theme, 'date-selected-current-background'); + } + + &:hover { + color: var-get($theme, 'date-selected-current-hover-foreground'); + background: var-get($theme, 'date-selected-hover-background'); + border-color: var-get($theme, 'date-selected-hover-border-color'); + + &::before { + border-color: var-get($theme, 'date-selected-current-hover-border-color'); + background: var-get($theme, 'date-selected-current-hover-background'); + } + } + } + + %fluent-date-current-selected-focus { + color: var-get($theme, 'date-selected-current-focus-foreground'); + background: var-get($theme, 'date-selected-focus-background'); + border-color: var-get($theme, 'date-selected-focus-border-color'); + + &::before { + border-color: var-get($theme, 'date-selected-current-focus-border-color'); + background: var-get($theme, 'date-selected-current-focus-background'); + } + } + + // SPECIAL + %fluent-date-special { + background: transparent; + border-color: var-get($theme, 'date-border-color'); + border-radius: var-get($theme, 'date-border-radius'); + + &::after { + width: var-get($theme, 'inner-size'); + height: var-get($theme, 'inner-size'); + background: var-get($theme, 'date-special-background'); + border-color: var-get($theme, 'date-special-border-color'); + border-radius: var-get($theme, 'date-special-border-radius'); + z-index: -1; + } + + &:hover { + background: var-get($theme, 'date-hover-background'); + border-color: var-get($theme, 'date-hover-border-color'); + + &::after { + background: var-get($theme, 'date-special-hover-background'); + border-color: var-get($theme, 'date-special-hover-border-color'); + } + } + } + + %fluent-date-special-focus { + background: var-get($theme, 'date-focus-background'); + border-color: var-get($theme, 'date-focus-border-color'); + + &::after { + background: var-get($theme, 'date-special-focus-background'); + border-color: var-get($theme, 'date-special-focus-border-color'); + } + } + + // SPECIAL + SELECTED + %fluent-date-special-selected { + color: var-get($theme, 'date-selected-special-foreground'); + background: var-get($theme, 'date-selected-background'); + border-color: var-get($theme, 'date-selected-border-color'); + + &::after { + background: var-get($theme, 'date-selected-special-background'); + border-color: var-get($theme, 'date-selected-special-border-color'); + border-radius: var-get($theme, 'date-special-border-radius'); + } + + &:hover { + color: var-get($theme, 'date-selected-special-hover-foreground'); + background: var-get($theme, 'date-selected-hover-background'); + border-color: var-get($theme, 'date-selected-hover-border-color'); + + &::after { + background: var-get($theme, 'date-selected-special-hover-background'); + border-color: var-get($theme, 'date-selected-special-hover-border-color'); + } + } + } + + %fluent-date-special-selected-focus { + color: var-get($theme, 'date-selected-special-focus-foreground'); + background: var-get($theme, 'date-selected-focus-background'); + border-color: var-get($theme, 'date-selected-focus-border-color'); + + &::after { + background: var-get($theme, 'date-selected-special-focus-background'); + border-color: var-get($theme, 'date-selected-special-focus-border-color'); + } + } + + // CURRENT + SPECIAL + %fluent-date-current-special { + color: var-get($theme, 'date-current-foreground'); + + &::after { + width: calc(var-get($theme, 'inner-size') - #{rem(4px)}); + height: calc(var-get($theme, 'inner-size') - #{rem(4px)}); + background: var-get($theme, 'date-current-background'); + border-color: var-get($theme, 'date-current-foreground'); + } + + &:hover { + color: var-get($theme, 'date-current-hover-foreground'); + + &::after { + background: var-get($theme, 'date-current-hover-background'); + border-color: var-get($theme, 'date-current-hover-foreground'); + } + } + } + + %fluent-date-current-special-focus { + color: var-get($theme, 'date-current-focus-foreground'); + + &::after { + background: var-get($theme, 'date-current-focus-background'); + border-color: var-get($theme, 'date-current-focus-foreground'); + } + } + + // CURRENT + SPECIAL + SELECTED + + %fluent-date-current-special-selected { + color: var-get($theme, 'date-selected-current-foreground'); + + &::after { + background: var-get($theme, 'date-selected-current-background'); + border-color: var-get($theme, 'date-selected-current-foreground'); + } + + &:hover { + color: var-get($theme, 'date-selected-current-hover-foreground'); + + &::after { + background: var-get($theme, 'date-selected-current-hover-background'); + border-color: var-get($theme, 'date-selected-current-hover-foreground'); + } + } + } + + %fluent-date-current-special-selected-focus { + color: var-get($theme, 'date-selected-current-focus-foreground'); + + &::after { + background: var-get($theme, 'date-selected-current-focus-background'); + border-color: var-get($theme, 'date-selected-current-focus-foreground'); + } + } + + // CURRENT + SPECIAL + SELECTED + PREVIEW + FIRST/LAST + %fluent-date-current-special-selected-range-preview-first-last { + &:hover { + color: var-get($theme, 'date-current-foreground'); + + &::after { + border-color: var-get($theme, 'date-current-foreground'); + } + } + } + + // FIRST + LAST + %fluent-date-first-last { + color: inherit; + background: transparent; + border-color: var-get($theme, 'date-range-preview-border-color'); + border-radius: var-get($theme, 'date-range-border-radius'); + + &:hover { + border-color: var-get($theme, 'date-range-preview-border-color'); + } + } + + %fluent-date-first-last-focus { + border-color: var-get($theme, 'date-range-preview-border-color'); + } + + // PREVIEW + %fluent-date-range-preview { + background: transparent; + border-color: transparent; + + &:hover { + background: transparent; + border-color: transparent; + } + } + + %fluent-date-range-preview-not-disabled { + color: inherit; + } + + %fluent-date-range-preview-inactive { + color: var-get($theme, 'inactive-color'); + + &:hover { + color: var-get($theme, 'inactive-color'); + } + } + + %fluent-date-range-preview-weekend { + color: var-get($theme, 'weekend-color'); + + &:hover { + color: var-get($theme, 'weekend-color'); + } + } + + %fluent-date-range-preview-middle { + border-color: transparent; + } + + %fluent-date-range-preview-first, + %fluent-date-range-preview-last { + color: inherit; + border-block-color: transparent; + + &::after { + background: transparent !important; + border-block-color: var-get($theme, 'date-range-preview-border-color'); + } + + %date-inner { + border-color: transparent; + border-radius: var-get($theme, 'date-range-border-radius'); + } + } + + %fluent-date-range-preview-last-after + %fluent-date-range-preview-last { + &::after { + inset-inline-end: $fake-bg-position; + } + } + + %fluent-date-range-preview-selected-special-first { + border-color: transparent; + } + + %fluent-date-range-preview-selected-special-last { + border-color: transparent; + } + + %fluent-date-range-preview-current-first-last { + &::before { + background: var-get($theme, 'date-current-background'); + border-color: var-get($theme, 'date-current-border-color'); + } + } + + // PREVIEW + SELECTED + %fluent-date-range-preview-first-last-selected { + &::after { + border-block-color: var-get($theme, 'date-range-preview-border-color'); + } + } + + // PREVIEW + SPECIAL, CURRENT - (START) + // This part revert the hover styles for special and current dates in preview mode + %fluent-date-range-preview-current-focus, + %fluent-date-range-preview-current-special-focus { + &::before { + background: var-get($theme, 'date-current-background'); + border-color: var-get($theme, 'date-current-border-color'); + } + } + + %fluent-date-range-preview-special, + %fluent-date-range-preview-special-focus { + color: var-get($theme, 'date-special-foreground'); + background: transparent; + + &::after { + background: var-get($theme, 'date-special-background'); + border-color: var-get($theme, 'date-special-border-color'); + } + + &:hover { + background: transparent; + color: var-get($theme, 'date-special-foreground'); + + &::after { + background: var-get($theme, 'date-special-background'); + border-color: var-get($theme, 'date-special-border-color'); + } + } + } + + %fluent-date-range-preview-current, + %fluent-date-range-preview-current-focus, + %fluent-date-range-preview-current-special, + %fluent-date-range-preview-current-special-focus { + color: var-get($theme, 'date-current-foreground'); + background: transparent; + border-color: transparent; + + &::after { + border-color: var-get($theme, 'date-current-foreground'); + } + + &:hover { + color: var-get($theme, 'date-current-foreground'); + background: transparent; + border-color: transparent; + + &::before { + background: var-get($theme, 'date-current-background'); + border-color: var-get($theme, 'date-current-border-color'); + } + + &::after { + border-color: var-get($theme, 'date-current-foreground'); + } + } + } + + %fluent-date-range-preview-current-special { + &::after { + background: var-get($theme, 'date-current-background'); + } + + &:hover { + &::after { + background: var-get($theme, 'date-current-background'); + } + } + } + + %fluent-date-range-preview-current-special-focus { + &::after { + background: var-get($theme, 'date-current-background'); + } + } + + %fluent-selected-first-last { + background: transparent; + border-color: var-get($theme, 'date-range-preview-border-color'); + border-radius: var-get($theme, 'date-range-border-radius'); + } + + %fluent-selected-first-last-current, + %fluent-selected-first-last-current-special { + color: var-get($theme, 'date-current-foreground'); + + &::before { + background: var-get($theme, 'date-current-background'); + border-color: var-get($theme, 'date-current-border-color'); + } + + &::after { + border-color: var-get($theme, 'date-current-foreground'); + } + + &:hover { + &::before { + background: var-get($theme, 'date-current-background'); + border-color: var-get($theme, 'date-current-border-color'); + } + + &::after { + border-color: var-get($theme, 'date-current-foreground'); + } + } + } + + + %fluent-selected-first-last-current-special { + &::after { + background: var-get($theme, 'date-current-background'); + border-color: var-get($theme, 'date-current-foreground'); + } + + &:hover { + &::after { + background: var-get($theme, 'date-current-background'); + border-color: var-get($theme, 'date-current-foreground'); + } + } + } + + %fluent-selected-first-last-current-special-focus { + &::after { + background: var-get($theme, 'date-current-background'); + border-color: var-get($theme, 'date-current-foreground'); + } + } + + %fluent-selected-first-last-special { + color: var-get($theme, 'date-special-foreground'); + + &::before { + border-color: transparent; + } + + &::after { + background: var-get($theme, 'date-special-background'); + border-color: var-get($theme, 'date-special-border-color'); + } + + &:hover { + &::after { + background: var-get($theme, 'date-special-background'); + border-color: var-get($theme, 'date-special-border-color'); + } + } + } + // PREVIEW + SPECIAL, CURRENT - (END) + + // RANGE + %fluent-date-range { + background: var-get($theme, 'date-selected-range-background'); + border-block-color: var-get($theme, 'date-range-border-color'); + } + + %fluent-date-range-first, + %fluent-date-range-last, + %fluent-date-range-preview-first, + %fluent-date-range-preview-last { + &::before, + &::after { + height: var-get($theme, 'size'); + } + + &::before { + content: ''; + position: absolute; + width: var-get($theme, 'size'); + z-index: 3; + pointer-events: none; + } + + &::after { + width: $fake-bg-size; + } + } + + %fluent-date-range-first, + %fluent-date-range-last { + &::after { + background: var-get($theme, 'date-selected-range-background'); + border-block: $border-size solid var-get($theme, 'date-range-border-color'); + } + } + + %fluent-date-range-preview-first, + %fluent-date-range-first { + &::after { + inset-inline-start: $fake-bg-position; + border-start-start-radius: var-get($theme, 'date-range-border-radius'); + border-end-start-radius: var-get($theme, 'date-range-border-radius'); + border-inline-end: 0; + } + + &::before { + inset-inline-end: initial; + border-start-start-radius: var-get($theme, 'date-range-border-radius'); + border-end-start-radius: var-get($theme, 'date-range-border-radius'); + border-start-end-radius: 0; + border-end-end-radius: 0; + } + } + + %fluent-date-range-preview-first { + &::after { + border-color: var-get($theme, 'date-range-preview-border-color'); + } + } + + %fluent-date-range-first { + &::after { + border-inline-start: $border-size solid transparent; + border-color: var-get($theme, 'date-range-border-color'); + } + } + + %fluent-date-range-preview-last, + %fluent-date-range-last { + &::after { + inset-inline-end: $fake-bg-position; + border-start-end-radius: var-get($theme, 'date-range-border-radius'); + border-end-end-radius: var-get($theme, 'date-range-border-radius'); + border-inline-start: 0; + } + + &::before { + inset-inline-start: initial; + border-start-end-radius: var-get($theme, 'date-range-border-radius'); + border-end-end-radius: var-get($theme, 'date-range-border-radius'); + border-start-start-radius: 0; + border-end-start-radius: 0; + } + } + + %fluent-date-range-preview-last { + &::after { + border-color: var-get($theme, 'date-range-preview-border-color'); + } + } + + %fluent-date-range-last { + &::after { + border-inline-end: $border-size solid transparent; + border-color: var-get($theme, 'date-range-border-color'); + } + } + + %fluent-date-range-selected-special-not-preview, + %fluent-date-range-selected-current-not-preview{ + border-color: transparent; + } + + %fluent-date-range-special-not-preview { + color: var-get($theme, 'date-special-range-foreground'); + background: transparent; + border-radius: var-get($theme, 'date-range-border-radius'); + + &::after { + background: var-get($theme, 'date-special-range-background'); + border-color: var-get($theme, 'date-special-range-border-color'); + } + + &:hover { + color: var-get($theme, 'date-special-range-hover-foreground'); + background: var-get($theme, 'date-selected-range-hover-background'); + + &::after { + background: var-get($theme, 'date-special-range-hover-background'); + border-color: var-get($theme, 'date-special-range-hover-border-color'); + } + } + } + + %fluent-date-range-special-not-preview-focus { + color: var-get($theme, 'date-special-range-focus-foreground'); + background: var-get($theme, 'date-selected-range-focus-background'); + + &::after { + background: var-get($theme, 'date-special-range-focus-background'); + border-color: var-get($theme, 'date-special-range-focus-border-color'); + } + } + + %fluent-date-range-current-not-preview { + color: var-get($theme, 'date-selected-current-range-foreground'); + background: transparent; + border-radius: var-get($theme, 'date-range-border-radius'); + + &::before { + background: var-get($theme, 'date-selected-current-range-background'); + border-color: var-get($theme, 'date-selected-current-border-color'); + } + + &:hover { + color: var-get($theme, 'date-selected-current-range-hover-foreground'); + background: var-get($theme, 'date-selected-range-hover-background'); + + &::before { + background: var-get($theme, 'date-selected-current-range-hover-background'); + border-color: var-get($theme, 'date-selected-current-hover-border-color'); + } + } + } + + %fluent-date-range-current-not-preview-focus { + color: var-get($theme, 'date-selected-current-range-focus-foreground'); + background: var-get($theme, 'date-selected-range-focus-background'); + + &::before { + background: var-get($theme, 'date-selected-current-range-focus-background'); + border-color: var-get($theme, 'date-selected-current-focus-border-color'); + } + } + + // RANGE + SELECTED + %fluent-date-range-selected { + background: var-get($theme, 'date-selected-range-background'); + } + + %fluent-date-range-selected-first-last { + background: transparent; + border-color: transparent; + + &::before { + background: transparent; + } + + &::after { + background: var-get($theme, 'date-selected-range-background'); + } + } + + %fluent-date-range-selected-first { + &::before { + border-inline-start: $border-size solid var-get($theme, 'date-range-border-color'); + border-inline-end: 0; + border-start-start-radius: var-get($theme, 'date-range-border-radius'); + border-end-start-radius: var-get($theme, 'date-range-border-radius'); + border-start-end-radius: 0; + border-end-end-radius: 0; + } + } + + %fluent-date-range-selected-last { + &::before { + border-inline-end: $border-size solid var-get($theme, 'date-range-border-color'); + border-inline-start: 0; + border-start-end-radius: var-get($theme, 'date-range-border-radius'); + border-end-end-radius: var-get($theme, 'date-range-border-radius'); + border-start-start-radius: 0; + border-end-start-radius: 0; + } + } + + %fluent-date-range-selected-not-preview { + color: var-get($theme, 'date-selected-range-foreground'); + + &:hover { + color: var-get($theme, 'date-selected-range-hover-foreground'); + background: var-get($theme, 'date-selected-range-hover-background'); + } + + &:focus { + color: var-get($theme, 'date-selected-range-focus-foreground'); + background: var-get($theme, 'date-selected-range-focus-background'); + } + + &::before { + background: var-get($theme, 'date-selected-range-background'); + } + } + + %fluent-date-range-selected-not-preview-disabled { + color: var-get($theme, 'date-selected-range-foreground'); + + &:hover { + color: var-get($theme, 'date-selected-range-hover-foreground'); + background: var-get($theme, 'date-selected-range-hover-background'); + } + } + + %fluent-date-range-selected-not-preview-disabled-focus { + color: var-get($theme, 'date-selected-range-focus-foreground'); + background: var-get($theme, 'date-selected-range-focus-background'); + } + + %fluent-date-range-current-special-not-preview { + color: var-get($theme, 'date-selected-current-range-foreground'); + + &::before { + background: var-get($theme, 'date-selected-current-range-background'); + border-color: var-get($theme, 'date-selected-current-border-color'); + } + + &::after { + background: var-get($theme, 'date-selected-current-range-background'); + border-color: var-get($theme, 'date-selected-current-foreground'); + } + + &:hover { + color: var-get($theme, 'date-selected-current-range-hover-foreground'); + + &::before { + background: var-get($theme, 'date-selected-current-range-hover-background'); + border-color: var-get($theme, 'date-selected-current-hover-border-color'); + } + + &::after { + background: var-get($theme, 'date-selected-current-range-hover-background'); + border-color: var-get($theme, 'date-selected-current-hover-foreground'); + } + } + } + + %fluent-date-range-current-special-not-preview-focus { + color: var-get($theme, 'date-selected-current-range-focus-foreground'); + + &::before { + background: var-get($theme, 'date-selected-current-range-focus-background'); + border-color: var-get($theme, 'date-selected-current-focus-border-color'); + } + + &::after { + background: var-get($theme, 'date-selected-current-range-focus-background'); + border-color: var-get($theme, 'date-selected-current-focus-foreground'); + } + } + + // DISABLED + %fluent-date-range-special-disabled, + %fluent-date-range-current-disabled { + border-color: transparent; + } + + %fluent-date-range-special-current-disabled { + &::after { + border-color: var-get($theme, 'date-current-foreground'); + } + } + + /////////////////////////////////// + ////////// BOOTSTRAP THEME /////// + ///////////////////////////////// + + // SPECIAL + %bootstrap-date-special { + border-radius: var-get($theme, 'date-border-radius'); + + &::after { + border-radius: var-get($theme, 'date-special-border-radius'); + } + } + + %bootstrap-date-special-first-last { + border-radius: var-get($theme, 'date-range-border-radius'); + } + + %bootstrap-date-special-range { + border-color: transparent; + + &:hover { + border-color: var-get($theme, 'date-hover-border-color'); + } + } + + %bootstrap-date-special-range-focus { + border-color: var-get($theme, 'date-focus-border-color'); + } + + // CURRENT + %bootstrap-date-current-first-last { + border-radius: var-get($theme, 'date-range-border-radius'); + } + + // CURRENT + SPECIAL + %bootstrap-date-current-special { + color: var-get($theme, 'date-current-foreground'); + background: var-get($theme, 'date-current-background'); + border-color: var-get($theme, 'date-current-border-color'); + border-radius: var-get($theme, 'date-current-border-radius'); + + &:hover { + color: var-get($theme, 'date-current-hover-foreground'); + background: var-get($theme, 'date-current-hover-background'); + border-color: var-get($theme, 'date-current-hover-border-color'); + } + + &::after { + width: var-get($theme, 'inner-size'); + height: var-get($theme, 'inner-size'); + } + } + + %bootstrap-date-current-special-focus { + color: var-get($theme, 'date-current-focus-foreground'); + background: var-get($theme, 'date-current-focus-background'); + border-color: var-get($theme, 'date-current-focus-border-color'); + } + + %bootstrap-date-current-special-range { + color: var-get($theme, 'date-selected-current-range-foreground'); + background: var-get($theme, 'date-selected-current-range-background'); + border-color: var-get($theme, 'date-current-border-color'); + + &:hover { + color: var-get($theme, 'date-selected-current-range-hover-foreground'); + background: var-get($theme, 'date-selected-current-range-hover-background'); + border-color: var-get($theme, 'date-current-hover-border-color'); + } + } + + %bootstrap-date-current-special-range-focus { + color: var-get($theme, 'date-selected-current-range-focus-foreground'); + background: var-get($theme, 'date-selected-current-range-focus-background'); + border-color: var-get($theme, 'date-current-focus-border-color'); + } + + %bootstrap-date-current-special-selected-not-range { + color: var-get($theme, 'date-selected-current-foreground'); + background: var-get($theme, 'date-selected-current-background'); + + &:hover { + color: var-get($theme, 'date-selected-current-hover-foreground'); + background: var-get($theme, 'date-selected-current-hover-background'); + } + } + + %bootstrap-date-current-special-selected-not-range-focus { + color: var-get($theme, 'date-selected-current-focus-foreground'); + background: var-get($theme, 'date-selected-current-focus-background'); + } + + // PREVIEW + %bootstrap-date-range-preview { + color: var-get($theme, 'date-selected-range-foreground'); + background: var-get($theme, 'date-selected-range-background'); + border-block-style: solid; + + &::after { + background: var-get($theme, 'date-selected-range-background'); + } + + %date-inner { + border-color: transparent; + + &:hover { + color: var-get($theme, 'date-selected-range-foreground'); + border-color: transparent; + } + } + } + + %bootstrap-date-preview--first-last { + background: transparent; + + &::after { + width: 50%; + border-style: solid; + border-inline: 0; + } + + %date-inner { + color: var-get($theme, 'date-selected-foreground'); + background: var-get($theme, 'date-selected-background'); + border-color: var-get($theme, 'date-selected-border-color'); + border-radius: var-get($theme, 'date-range-border-radius'); + + &:hover { + color: var-get($theme, 'date-selected-hover-foreground'); + background: var-get($theme, 'date-selected-hover-background'); + border-color: var-get($theme, 'date-selected-hover-border-color'); + } + + &::after { + width: var-get($theme, 'inner-size'); + height: var-get($theme, 'inner-size'); + } + } + } + + %bootstrap-date-preview--first-last-focus { + %date-inner { + color: var-get($theme, 'date-selected-focus-foreground'); + background: var-get($theme, 'date-selected-focus-background'); + border-color: var-get($theme, 'date-selected-focus-border-color'); + border-radius: var-get($theme, 'date-range-border-radius'); + } + } + + // range preview current + %bootstrap-date-range-preview-current { + color: var-get($theme, 'date-current-foreground'); + background: var-get($theme, 'date-current-background'); + border-color: var-get($theme, 'date-current-border-color'); + + &:hover { + color: var-get($theme, 'date-current-hover-foreground'); + background: var-get($theme, 'date-current-hover-background'); + } + } + + %bootstrap-date-range-preview-current-focus { + color: var-get($theme, 'date-current-focus-foreground'); + background: var-get($theme, 'date-current-focus-background'); + } + + // range preview current + firs/last + %bootstrap-date-range-preview-current-first-last { + color: var-get($theme, 'date-selected-foreground'); + background: var-get($theme, 'date-selected-background'); + border-color: var-get($theme, 'date-selected-current-border-color'); + + &:hover { + color: var-get($theme, 'date-selected-hover-foreground'); + background: var-get($theme, 'date-selected-hover-background'); + border-color: var-get($theme, 'date-selected-current-hover-border-color'); + } + } + + %bootstrap-date-range-preview-current-focus-first-last { + color: var-get($theme, 'date-selected-focus-foreground'); + background: var-get($theme, 'date-selected-focus-background'); + border-color: var-get($theme, 'date-selected-current-focus-border-color'); + } + + // range preview special + firs/last + %bootstrap-date-preview-special--first-last { + &::after { + width: var-get($theme, 'inner-size'); + height: var-get($theme, 'inner-size'); + border-color: var-get($theme, 'date-selected-special-border-color'); + } + + &:hover { + &::after { + border-color: var-get($theme, 'date-selected-special-hover-border-color'); + } + } + } + + %bootstrap-date-preview-special-focus--first-last { + &::after { + border-color: var-get($theme, 'date-selected-special-focus-border-color'); + } + } + + %bootstrap-date-preview-weekend-inactive { + color: var-get($theme, 'date-selected-range-foreground'); + + &:hover { + color: var-get($theme, 'date-selected-range-hover-foreground'); + } + } + + %bootstrap-date-preview-weekend-inactive-focus { + color: var-get($theme, 'date-selected-range-focus-foreground'); + } + + // RANGE + %bootstrap-date-range-radius { + border-radius: var-get($theme, 'date-range-border-radius'); + } + + // DISABLED + %bootstrap-date-disabled-range-preview { + color: var-get($theme, 'date-disabled-range-foreground'); + } + + + ////////////////////////////////////// + //////////// INDIGO THEME /////////// + //////////////////////////////////// + %indigo-label-week-number { + span { + border: 0; + + &::before { + height: $date-view-row-gap; + inset-block-start: 100%; + inset-inline-start: 0; + border: 0; + } + } + } + + %indigo-date-inner-week-number { + border: 0; + + &::before { + height: $date-view-row-gap; + inset-block-start: 100%; + inset-inline-start: 0; + border: 0; + } + } + + %indigo-date-special-selected-inner-radius { + border-radius: calc(var-get($theme, 'date-special-border-radius') - $border-size); + } + + %indigo-date-special-current-indented { + &::after { + width: calc(var-get($theme, 'inner-size') - #{rem(4px)}); + height: calc(var-get($theme, 'inner-size') - #{rem(4px)}); + } + } + + // CURRENT + SELECTED + FIRST/LAST + %indigo-current-selected-first-last, + %indigo-current-selected-range-first-last { + color:var-get($theme, 'date-selected-current-foreground'); + background: var-get($theme, 'date-selected-current-background'); + border-color: var-get($theme, 'date-selected-current-border-color'); + + &:hover { + color:var-get($theme, 'date-selected-current-hover-foreground'); + background: var-get($theme, 'date-selected-current-hover-background'); + border-color: var-get($theme, 'date-selected-current-hover-border-color'); + } + } + + %indigo-current-selected-first-last-focus, + %indigo-current-selected-range-first-last-focus { + color:var-get($theme, 'date-selected-current-focus-foreground'); + background: var-get($theme, 'date-selected-current-focus-background'); + border-color: var-get($theme, 'date-selected-current-focus-border-color'); + } + + // SPECIAL + SELECTED + FIRST/LAST + %indigo-special-selected-first-last { + color:var-get($theme, 'date-selected-special-foreground'); + background: var-get($theme, 'date-selected-special-background'); + + &:hover { + color:var-get($theme, 'date-selected-special-hover-foreground'); + background: var-get($theme, 'date-selected-special-hover-background'); + } + } + %indigo-special-selected-first-last-focus { + color:var-get($theme, 'date-selected-special-focus-foreground'); + background: var-get($theme, 'date-selected-special-focus-background'); + } + + // SPECIAL + CURRENT + %indigo-special-current { + border-radius: var-get($theme, 'date-current-border-radius'); + + &::after { + width: calc(var-get($theme, 'inner-size') - #{rem(4px)}); + height: calc(var-get($theme, 'inner-size') - #{rem(4px)}); + border-radius: calc(var-get($theme, 'date-special-border-radius') - $border-size); + } + } + + %indigo-date-special-current-focus { + color:var-get($theme, 'date-selected-special-focus-foreground'); + background: var-get($theme, 'date-selected-current-focus-background'); + border-color: var-get($theme, 'date-selected-special-focus-border-color'); + } + + %indigo-special-current-range { + color:var-get($theme, 'date-special-foreground'); + background: var-get($theme, 'date-selected-current-background'); + border-color: var-get($theme, 'date-selected-current-border-color'); + + &:hover { + color:var-get($theme, 'date-special-hover-foreground'); + background: var-get($theme, 'date-selected-current-hover-background'); + border-color: var-get($theme, 'date-selected-current-hover-border-color'); + } + } + + %indigo-special-current-range-focus { + color:var-get($theme, 'date-special-focus-foreground'); + background: var-get($theme, 'date-selected-current-focus-background'); + border-color: var-get($theme, 'date-selected-current-focus-border-color'); + } + + %indigo-special-current-range-not-first-last { + color:var-get($theme, 'date-special-range-foreground'); + background: var-get($theme, 'date-selected-current-range-background'); + border-color: var-get($theme, 'date-selected-current-border-color'); + + &:hover { + color:var-get($theme, 'date-special-range-hover-foreground'); + background: var-get($theme, 'date-selected-current-range-hover-background'); + border-color: var-get($theme, 'date-selected-current-hover-border-color'); + } + } + + %indigo-special-current-range-not-first-last-focus { + color:var-get($theme, 'date-special-range-focus-foreground'); + background: var-get($theme, 'date-selected-current-range-focus-background'); + border-color: var-get($theme, 'date-selected-current-focus-border-color'); + } + + %indigo-special-current-range-first-last, + %indigo-special-current-range-first-last { + color:var-get($theme, 'date-selected-special-foreground'); + background: var-get($theme, 'date-selected-current-background'); + border-color: var-get($theme, 'date-selected-current-border-color'); + + &:hover { + color:var-get($theme, 'date-selected-special-hover-foreground'); + background: var-get($theme, 'date-selected-current-hover-background'); + border-color: var-get($theme, 'date-selected-current-hover-border-color'); + } + } + + %indigo-special-current-range-first-last-focus, + %indigo-special-current-range-first-last-focus { + color:var-get($theme, 'date-selected-special-focus-foreground'); + background: var-get($theme, 'date-selected-current-focus-background'); + border-color: var-get($theme, 'date-selected-current-focus-border-color'); + } + + %indigo-date-special-current-selected { + color: var-get($theme, 'date-selected-special-foreground'); + background: var-get($theme, 'date-selected-current-background'); + border-color: var-get($theme, 'date-selected-current-border-color'); + + &::after { + border-color: var-get($theme, 'date-selected-special-border-color'); + } + + &:hover { + color: var-get($theme, 'date-selected-special-hover-foreground'); + background: var-get($theme, 'date-selected-current-hover-background'); + border-color: var-get($theme, 'date-selected-current-hover-border-color'); + + &::after { + border-color: var-get($theme, 'date-selected-special-hover-border-color'); + } + } + } + + %indigo-date-special-current-selected-focus { + color: var-get($theme, 'date-selected-special-focus-foreground'); + background: var-get($theme, 'date-selected-current-focus-background'); + border-color: var-get($theme, 'date-selected-current-focus-border-color'); + + &::after { + border-color: var-get($theme, 'date-selected-special-focus-border-color'); + } + } + + %indigo-date-special-current-not-selected { + color: var-get($theme, 'date-special-foreground'); + background: var-get($theme, 'date-current-background'); + border-color: var-get($theme, 'date-current-border-color'); + + &::after { + border-color: var-get($theme, 'date-special-border-color'); + } + + &:hover { + color: var-get($theme, 'date-special-hover-foreground'); + background: var-get($theme, 'date-current-hover-background'); + border-color: var-get($theme, 'date-current-hover-border-color'); + + &::after { + border-color: var-get($theme, 'date-special-hover-border-color'); + } + } + } + + %indigo-date-special-current-focus-not-selected { + color: var-get($theme, 'date-special-focus-foreground'); + background: var-get($theme, 'date-current-focus-background'); + border-color: var-get($theme, 'date-current-focus-border-color'); + + &::after { + border-color: var-get($theme, 'date-special-focus-border-color'); + } + } +} + +/// Adds typography styles for the igx-calendar component. +/// Uses the 'h4', 'subtitle-1' and 'body-1' +/// category from the typographic scale. +/// @group typography +/// @param {Map} $categories [(header-year: 'subtitle-1', header-date: 'h4', picker-date: 'subtitle-1', content: 'body-1')] - The categories from the typographic scale used for type styles. +@mixin calendar-typography( + $categories: ( + header-year: 'overline', + header-date: 'h4', + weekday-label: 'body-1', + picker-date: 'subtitle-1', + content: 'body-1', + ) +) { + $header-year: map.get($categories, 'header-year'); + $header-date: map.get($categories, 'header-date'); + $weekday-label: map.get($categories, 'weekday-label'); + $picker-date: map.get($categories, 'picker-date'); + $content: map.get($categories, 'content'); + + %header-year { + @include type-style($header-year) { + margin: 0; + } + } + + %header-date { + @include type-style($header-date) { + margin: 0; + } + ; + } + + %views-navigation, + %picker-date { + @include type-style($picker-date) { + margin: 0; + } + } + + %calendar-view, + %date-inner { + @include type-style($content, false) { + font-size: sizable(var(--ig-body-2-font-size), var(--ig-body-2-font-size), var(--ig-body-1-font-size)); + font-weight: sizable(var(--ig-body-2-font-weight), var(--ig-body-2-font-weight), var(--ig-body-1-font-weight)); + font-style: sizable(var(--ig-body-2-font-style), var(--ig-body-2-font-style), var(--ig-body-1-font-style)); + line-height: sizable(var(--ig-body-2-line-height), var(--ig-body-2-line-height), var(--ig-body-1-line-height)); + letter-spacing: sizable(var(--ig-body-2-letter-spacing), var(--ig-body-2-letter-spacing), var(--ig-body-1-letter-spacing)); + text-transform: sizable(var(--ig-body-2-text-transform), var(--ig-body-2-text-transform), var(--ig-body-1-text-transform)); + margin: 0; + } + } + + %weekday-label { + @include type-style($weekday-label, false) { + font-size: sizable(var(--ig-body-2-font-size), var(--ig-body-2-font-size), var(--ig-body-1-font-size)); + font-weight: sizable(var(--ig-body-2-font-weight), var(--ig-body-2-font-weight), var(--ig-body-1-font-weight)); + font-style: sizable(var(--ig-body-2-font-style), var(--ig-body-2-font-style), var(--ig-body-1-font-style)); + line-height: sizable(var(--ig-body-2-line-height), var(--ig-body-2-line-height), var(--ig-body-1-line-height)); + letter-spacing: sizable(var(--ig-body-2-letter-spacing), var(--ig-body-2-letter-spacing), var(--ig-body-1-letter-spacing)); + text-transform: sizable(var(--ig-body-2-text-transform), var(--ig-body-2-text-transform), var(--ig-body-1-text-transform)); + margin: 0; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/card/_card-component.scss b/projects/igniteui-angular/core/src/core/styles/components/card/_card-component.scss new file mode 100644 index 00000000000..c096efc6a26 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/card/_card-component.scss @@ -0,0 +1,140 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin component { + @include b(igx-card) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: ( + igx-icon, + igx-button, + ) + ); + + @extend %igx-card-display !optional; + + @include e(media) { + @extend %igx-card-media !optional; + } + + @include e(media, $m: right) { + @extend %igx-card-media--right !optional; + } + + @include m(elevated) { + @extend %igx-card--elevated !optional; + } + + @include m(horizontal) { + @extend %igx-card--horizontal !optional; + } + } + + @include b(igx-card-header) { + @extend %igx-card-header !optional; + + @include e(thumbnail) { + @extend %igx-card-header-thumbnail !optional; + } + + @include e(titles) { + @extend %igx-card-header-titles !optional; + } + + @include e(title) { + @extend %igx-card-header-title !optional; + } + + @include e(title, $m: small) { + @extend %igx-card-header-title !optional; + @extend %igx-card-header-title--small !optional; + } + + @include e(subtitle) { + @extend %igx-card-header-subtitle !optional; + } + + @include m(vertical) { + @extend %igx-card-header--vertical !optional; + } + } + + @include b(igx-card-content) { + @extend %igx-card-content !optional; + } + + @include b(igx-card-actions) { + @extend %igx-card-actions !optional; + + @include e(start) { + @extend %igx-card-actions__start !optional; + } + + @include e(end) { + @extend %igx-card-actions__end !optional; + } + + @include e(igroup) { + @extend %igx-card-actions-igroup !optional; + } + + @include e(igroup, $m: start) { + @extend %igx-card-actions-igroup !optional; + @extend %igx-card-actions-igroup--start !optional; + } + + @include e(igroup, $m: end) { + @extend %igx-card-actions-igroup !optional; + @extend %igx-card-actions-igroup--end !optional; + } + + @include e(bgroup) { + @extend %igx-card-actions-bgroup !optional; + } + + @include m(vertical) { + @extend %igx-card-actions--vertical !optional; + + @include e(start) { + @extend %igx-card-actions__start--vertical !optional; + } + + @include e(end) { + @extend %igx-card-actions__end--vertical !optional; + } + } + + @include m(reverse) { + @include e(start) { + @extend %igx-card-actions__start--reverse !optional; + } + + @include e(end) { + @extend %igx-card-actions__end--reverse !optional; + } + } + + @include mx(vertical, reverse) { + @include e(start) { + @extend %igx-card-actions__start--vertical-reverse !optional; + } + + @include e(end) { + @extend %igx-card-actions__end--vertical-reverse !optional; + } + } + + @include m(justify) { + @include e(start) { + @extend %igx-card-actions__start--justify !optional; + } + + @include e(end) { + @extend %igx-card-actions__end--justify !optional; + } + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/card/_card-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/card/_card-theme.scss new file mode 100644 index 00000000000..8c05c45c68f --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/card/_card-theme.scss @@ -0,0 +1,354 @@ +@use 'sass:map'; +@use '../../base' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin card($theme) { + @include css-vars($theme); + $variant: map.get($theme, '_meta', 'theme'); + $not-material-theme: $variant != 'material'; + + $card-heading-padding: pad(rem(16px, 16px)); + + $card-tgroup-margin: 0 em(16px); + + $card-transitions: box-shadow .3s cubic-bezier(.25, .8, .25, 1); + + %igx-card-display { + display: flex; + flex-direction: column; + overflow: hidden; + border-radius: var-get($theme, 'border-radius'); + background: var-get($theme, 'background'); + transition: $card-transitions; + backface-visibility: hidden; + border: rem(1px) solid var-get($theme, 'outline-color'); + + &:hover { + box-shadow: none; + } + } + + %igx-card--elevated { + box-shadow: var-get($theme, 'resting-elevation'); + + &:hover { + box-shadow: var-get($theme, 'hover-elevation'); + } + + @if $not-material-theme { + border: rem(1px) solid var-get($theme, 'outline-color'); + } + } + + %igx-card--horizontal { + flex-direction: row; + } + + %igx-card-header { + display: flex; + flex-flow: row wrap; + align-items: center; + width: 100%; + padding: $card-heading-padding; + color: var-get($theme, 'header-text-color'); + + &:empty { + display: block; + padding: 0; + } + } + + %igx-card-header--vertical { + flex-flow: column nowrap; + + %igx-card-header-titles { + text-align: center; + } + + %igx-card-header-thumbnail { + display: flex; + justify-content: center; + align-self: unset; + margin-inline-end: 0; + margin-bottom: rem(16px); + + @if $variant == 'indigo' { + margin-bottom: rem(8px); + } + } + } + + %igx-card-header-thumbnail { + margin-inline-end: rem(16px); + + @if $variant == 'indigo' { + margin-inline-end: rem(8px); + margin-block: auto; + } + + igx-avatar { + --ig-size: #{if($variant == 'indigo', 3, 1)}; + } + + &:empty { + display: none; + } + } + + %igx-card-header-titles { + display: flex; + flex-flow: column nowrap; + overflow: hidden; + flex: 1 1 auto; + justify-content: center; + + &:empty { + display: none; + } + + @if $variant == 'indigo' { + gap: rem(2px); + + %igx-card-header-subtitle { + margin-block-end: rem(2px) + } + } + } + + %igx-card-header-subtitle { + color: var-get($theme, 'subtitle-text-color'); + } + + %igx-card-content { + display: block; + width: 100%; + padding: pad(rem(16px)); + color: var-get($theme, 'content-text-color'); + overflow: auto; + } + + %igx-card-media { + display: block; + overflow: hidden; + line-height: 0; + + > * { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + %igx-card-media--right { + width: auto; + margin-inline-start: auto; + order: 9999; + } + + %igx-card-actions { + $card-actions-padding: map.get(( + 'material': pad-block(rem(8px)) pad-inline(rem(16px)), + 'fluent': pad-block(rem(8px)) pad-inline(rem(16px)), + 'bootstrap': pad(rem(16px)), + 'indigo': pad(rem(16px)), + ), $variant); + + + display: flex; + flex-flow: row wrap; + justify-content: space-between; + align-items: center; + padding: $card-actions-padding; + + @if $variant == 'indigo' { + [igxButton], + [igxIconButton] { + --ig-size: 2; + } + } + + &:first-child { + margin-block-end: auto; + } + + &:last-child { + margin-block-start: auto; + } + } + + @if $variant == 'bootstrap' { + %igx-card-content { + padding-block-end: pad-block(rem(24px)); + } + } + + @if $variant == 'indigo' { + %igx-card-content { + padding-block-end: pad-block(rem(8px)); + } + + %igx-card-content:last-child { + padding-block-end: pad-block(rem(16px)); + } + + %igx-card-header { + padding-block-end: 0; + } + + %igx-card-header:last-child, + %igx-card-header:first-child { + padding-block-end: pad-block(rem(16px)); + } + } + + %igx-card-actions--vertical { + flex-direction: column; + + &:is(:first-child, :last-child) { + margin-block: initial; + } + + [dir='rtl'] & { + order: -1; + } + } + + %igx-card-actions__end { + display: flex; + align-items: center; + order: 1; + margin-inline-start: auto; + gap: rem(8px); + + &:empty { + display: none; + } + } + + %igx-card-actions__start { + display: flex; + align-items: center; + order: 0; + gap: rem(8px); + + &:empty { + display: none; + } + } + + %igx-card-actions__start, + %igx-card-actions__end { + color: var-get($theme, 'actions-text-color'); + } + + %igx-card-actions__start--justify, + %igx-card-actions__end--justify { + justify-content: space-around; + flex-grow: 1; + + &:empty { + display: none; + } + } + + %igx-card-actions__end--vertical, + %igx-card-actions__start--vertical { + flex-direction: column; + + [igxButton] ~ [igxButton] { + margin-inline-start: 0; + margin-top: rem(8px); + } + } + + %igx-card-actions__end--vertical { + margin-top: auto; + margin-inline-start: 0; + } + + %igx-card-actions__end--reverse { + order: 0; + margin-inline-start: 0; + } + + %igx-card-actions__start--reverse { + order: 1; + margin-inline-start: auto; + } + + %igx-card-actions__end--vertical-reverse { + margin: 0; + margin-bottom: auto; + } + + %igx-card-actions__start--vertical-reverse { + margin: 0; + margin-top: auto; + } + + %igx-card-actions-bgroup { + display: flex; + flex-flow: row nowrap; + gap: rem(8px); + } + + %igx-card-actions-igroup { + display: flex; + flex-flow: row nowrap; + + %igx-icon-button-display { + color: var-get($theme, 'actions-text-color'); + } + } + + %igx-card-actions-igroup--start { + margin-inline-end: auto; + } + + %igx-card-actions-igroup--end { + margin-inline-start: auto; + } +} + +/// Adds typography styles for the igx-card component. +/// Uses the 'h6', 'subtitle-2' and 'body-2' +/// category from the typographic scale. +/// @group typography +/// @param {Map} $categories [(title: 'h6', title-small: 'subtitle-2', subtitle: 'subtitle-2', content: 'body-2')] - The categories from the typographic scale used for type styles. +@mixin card-typography($categories: ( + title: 'h6', + title-small: 'subtitle-2', + subtitle: 'subtitle-2', + content: 'body-2') +) { + $title: map.get($categories, 'title'); + $title-small: map.get($categories, 'title-small'); + $subtitle: map.get($categories, 'subtitle'); + $content: map.get($categories, 'content'); + + %igx-card-header-title { + @include type-style($title) { + margin: 0; + } + } + + %igx-card-header-title--small { + @include type-style($title-small) { + margin: 0; + } + } + + %igx-card-header-subtitle { + @include type-style($subtitle){ + margin: 0; + } + } + + %igx-card-content > *:not(igx-icon) { + @include type-style($content) { + margin: 0; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/carousel/_carousel-component.scss b/projects/igniteui-angular/core/src/core/styles/components/carousel/_carousel-component.scss new file mode 100644 index 00000000000..297a4a07ae9 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/carousel/_carousel-component.scss @@ -0,0 +1,97 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin _igx-carousel-indicators-partial { + @include b(igx-carousel-indicators) { + @extend %igx-carousel-indicators !optional; + + @include e(indicator) { + @extend %igx-carousel-indicator !optional; + } + + @include m(start) { + @extend %igx-carousel-indicators !optional; + @extend %igx-carousel-indicators--start !optional; + } + + @include m(end) { + @extend %igx-carousel-indicators !optional; + @extend %igx-carousel-indicators--end !optional; + } + + @include m(focused) { + @extend %igx-carousel-indicators !optional; + @extend %igx-carousel-indicators--focused !optional; + } + } + + @include b(igx-carousel-label-indicator) { + @extend %igx-carousel-label-indicator !optional; + } +} + +@mixin _igx-carousel-navigation-partial { + @include b(igx-nav-dot) { + @extend %igx-nav-dot !optional; + + @include m(active) { + @extend %igx-nav-dot--active !optional; + } + } +} + +@mixin _igx-carousel-slide-partial { + @include b(igx-slide) { + @extend %igx-carousel-slide !optional; + + @include m(current) { + @extend %igx-carousel-slide--current !optional; + } + + @include m(previous) { + @extend %igx-carousel-slide--previous !optional; + } + } +} + +@mixin component { + @include b(igx-carousel) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: ( + igx-icon, + ) + ); + + @extend %igx-carousel-display !optional; + + @include e(inner) { + @extend %igx-carousel-slide-wrapper !optional; + } + + @include e(label) { + @extend %igx-carousel-indicators-label !optional; + } + + @include e(arrow, $m: prev) { + @extend %igx-carousel-arrow !optional; + @extend %igx-carousel-arrow--prev !optional; + } + + @include e(arrow, $m: next) { + @extend %igx-carousel-arrow !optional; + @extend %igx-carousel-arrow--next !optional; + } + + @include m(vertical) { + @extend %igx-carousel--vertical !optional; + } + + @include _igx-carousel-indicators-partial(); + @include _igx-carousel-navigation-partial(); + @include _igx-carousel-slide-partial(); + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/carousel/_carousel-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/carousel/_carousel-theme.scss new file mode 100644 index 00000000000..d91da52b494 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/carousel/_carousel-theme.scss @@ -0,0 +1,481 @@ +@use 'sass:map'; +@use '../../base' as *; +@use 'igniteui-theming/sass/animations' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin carousel($theme) { + @include css-vars($theme); + @include scale-in-center(); + @include scale-out-center(); + + $indicator-border-style: rem(2px) solid; + $btn-indent: rem(3px); + + $variant: map.get($theme, '_meta', 'theme'); + $not-bootstrap-theme: $variant != 'bootstrap'; + + %igx-carousel-display { + --theme: #{$variant}; + --nav-btn-border-radius: #{var-get($theme, 'border-radius')}; + + display: flex; + position: relative; + justify-content: center; + width: 100%; + height: 100%; + align-items: center; + flex-flow: column nowrap; + } + + %igx-carousel-arrow { + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: 3; + outline: none; + user-select: none; + display: flex; + justify-content: center; + align-items: center; + min-width: unset; + width: rem(46px); + height: rem(46px); + cursor: pointer; + outline-style: none; + transition: all .15s ease-in-out; + background: var-get($theme, 'button-background'); + box-shadow: var-get($theme, 'button-elevation'); + border: rem(1px) solid var-get($theme, 'button-border-color'); + border-radius: var(--nav-btn-border-radius); + + igx-icon { + --component-size: 1; + color: var-get($theme, 'button-arrow-color'); + } + + @if $variant == 'indigo' { + width: rem(28px); + height: rem(28px); + border-width: rem(2px); + padding: initial; + + igx-icon { + --component-size: 2; + } + } + + &:hover { + background: var-get($theme, 'button-hover-background'); + border-color: var-get($theme, 'button-hover-border-color'); + + igx-icon { + color: var-get($theme, 'button-hover-arrow-color'); + } + } + + &[igxButton].igx-button--focused { + background: var-get($theme, 'button-background'); + border: rem(2px) solid var-get($theme, 'button-focus-border-color'); + + igx-icon { + color: var-get($theme, 'button-focus-arrow-color'); + } + + @if $variant == 'bootstrap' { + box-shadow: 0 0 0 rem(4px) var-get($theme, 'button-focus-border-color'); + border-color: var-get($theme, 'button-border-color'); + } + + @if $variant == 'fluent' { + border: none; + + &::after { + position: absolute; + content: ''; + pointer-events: none; + inset-block-start: $btn-indent; + inset-inline-start: $btn-indent; + width: calc(100% - (#{$btn-indent} * 2)); + height: calc(100% - (#{$btn-indent} * 2)); + box-shadow: 0 0 0 rem(1px) var-get($theme, 'button-focus-border-color'); + } + } + + @if $variant == 'indigo' { + box-shadow: 0 0 0 rem(3px) var-get($theme, 'indicator-focus-color'); + } + } + + &[igxButton].igx-button--disabled { + background: var-get($theme, 'button-disabled-background'); + color: var-get($theme, 'button-disabled-arrow-color'); + border-color: var-get($theme, 'button-disabled-border-color'); + pointer-events: none; + box-shadow: none; + + igx-icon { + color: currentColor; + } + } + } + + %igx-carousel-arrow--next { + inset-inline-end: 0; + margin-inline-end: rem(16px); + + @if $variant == 'indigo' { + %igx-nav-arrow { + &::after { + transform: rotate(-135deg); + } + } + } + } + + %igx-carousel-arrow--prev { + inset-inline-start: 0; + margin-inline-start: rem(16px); + + @if $variant == 'indigo' { + %igx-nav-arrow { + &::after { + transform: rotate(45deg); + } + } + } + } + + %igx-carousel-arrow--next, + %igx-carousel-arrow--prev { + [dir='rtl'] & { + transform: scaleX(-1); + } + } + + %igx-carousel-indicators { + position: absolute; + display: flex; + justify-content: center; + align-items: center; + margin: rem(16px) 0; + padding: pad-block(rem(4px)) pad-inline(rem(6px)); + gap: rem(8px); + list-style: none; + z-index: 10; + inset-inline-start: 50%; + transform: translateX(-50%); + background: var-get($theme, 'indicator-background'); + box-shadow: var-get($theme, 'button-elevation'); + border-radius: var-get($theme, 'border-radius'); + + [dir='rtl'] & { + transform: translateX(50%); + } + + @if $variant == 'indigo' { + gap: rem(4px); + padding: pad(rem(6px)); + } + } + + %igx-carousel-indicators--end { + bottom: 0; + } + + %igx-carousel-indicators--start { + top: 0; + } + + %igx-carousel-indicators-label { + align-items: center; + } + + %igx-carousel-label-indicator { + @include type-style('caption'); + + margin: rem(16px) 0; + min-width: rem(46px); + box-shadow: none; + border-radius: rem(4px); + color: var-get($theme, 'indicator-color'); + background: var-get($theme, 'label-indicator-background'); + + @if $variant == 'fluent' or $variant == 'indigo' { + border-radius: rem(2px); + } + + @if $variant == 'indigo' { + padding: pad-block(rem(4px)) pad-inline(rem(6px)); + min-width: rem(28px); + } + } + + %igx-carousel-indicator { + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + + &:focus-visible { + outline-style: none; + } + } + + %igx-nav-dot { + position: relative; + width: rem(12px); + height: rem(12px); + text-indent: rem(-9999px); + + @if $variant != 'indigo' { + border: $indicator-border-style; + border-color: var-get($theme, 'indicator-border-color'); + } @else { + width: rem(16px); + height: rem(16px); + } + + border-radius: border-radius(50%); + transition: all .15s $ease-out-quad; + + &::after { + content: ''; + position: absolute; + border-radius: inherit; + background: var-get($theme, 'indicator-dot-color'); + + @if $variant != 'indigo' { + inset: rem(1px); + } @else { + width: rem(8px); + height: rem(8px); + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + } + + &:hover { + border-color: var-get($theme, 'indicator-active-border-color'); + + &::after { + background: var-get($theme, 'indicator-hover-dot-color'); + } + + @if $variant == 'indigo' { + &::before { + position: absolute; + content: ''; + width: inherit; + height: inherit; + border: rem(2px) solid var-get($theme, 'indicator-hover-dot-color'); + inset-inline-start: 0; + top: 0; + border-radius: border-radius(50%); + } + } + } + } + + %igx-nav-dot--active { + @if $variant != 'indigo' { + border: $indicator-border-style; + border-color: var-get($theme, 'indicator-active-border-color'); + } @else { + &::before { + position: absolute; + content: ''; + width: inherit; + height: inherit; + border: rem(2px) solid var-get($theme, 'indicator-active-border-color'); + inset-inline-start: 0; + top: 0; + border-radius: border-radius(50%); + } + } + + &::after { + background: var-get($theme, 'indicator-active-dot-color'); + @if $variant != 'indigo' { + @include animation('scale-in-center' .15s $ease-out-quad forwards); + } + } + + &:hover { + border-color: var-get($theme, 'indicator-active-hover-dot-color'); + + &::after { + background: var-get($theme, 'indicator-active-hover-dot-color'); + } + + @if $variant == 'indigo' { + &::before { + border-color: var-get($theme, 'indicator-active-hover-dot-color'); + } + } + } + } + + %igx-carousel-indicators--focused { + &::after { + position: absolute; + content: ''; + pointer-events: none; + width: 100%; + height: 100%; + border-radius: inherit; + border: rem(2px) solid var-get($theme, 'indicator-focus-color'); + + @if $variant == 'bootstrap' { + border: none; + box-shadow: 0 0 0 rem(4px) var-get($theme, 'button-focus-border-color'); + } + + @if $variant == 'fluent' { + border: none; + inset-block-start: $btn-indent; + inset-inline-start: $btn-indent; + width: calc(100% - (#{$btn-indent} * 2)); + height: calc(100% - (#{$btn-indent} * 2)); + box-shadow: 0 0 0 rem(1px) var-get($theme, 'button-focus-border-color'); + border-radius: 0; + } + + @if $variant == 'indigo' { + border: none; + box-shadow: 0 0 0 rem(3px) var-get($theme, 'indicator-focus-color'); + } + } + + %igx-nav-dot--active { + border-color: var-get($theme, 'indicator-focus-color'); + + &::after { + background: var-get($theme, 'indicator-focus-color'); + } + + @if $variant == 'indigo' { + border-color: var-get($theme, 'indicator-active-dot-color'); + box-shadow: 0 0 0 rem(3px) var-get($theme, 'indicator-focus-color'); + + &::after { + background: var-get($theme, 'indicator-active-dot-color'); + } + + &:hover { + &::after { + background: var-get($theme, 'indicator-active-hover-dot-color'); + } + } + } + } + } + + %igx-carousel-slide-wrapper { + position: relative; + width: 100%; + height: inherit; + overflow: hidden; + outline-style: none; + min-height: rem(240px); + min-width: rem(300px); + } + + %igx-carousel-slide { + position: absolute; + width: 100%; + height: 100%; + inset: 0; + z-index: -1; + background: var-get($theme, 'slide-background'); + visibility: hidden; + } + + %igx-carousel-slide--previous { + z-index: 1; + visibility: visible; + } + + %igx-carousel-slide--current { + z-index: 2; + visibility: visible; + } + + %igx-carousel-slide img { + width: inherit; + height: inherit; + object-fit: cover; + touch-action: none; + pointer-events: none; + } + + %igx-carousel--vertical { + %igx-carousel-arrow { + inset-inline-start: unset; + inset-block-start: unset; + inset-inline-end: 0; + margin-inline-end: rem(16px); + transform: none; + + igx-icon { + transform: rotate(90deg); + } + } + + + %igx-carousel-arrow--prev { + inset-block-start: 0; + margin-block-start: rem(16px); + } + + + %igx-carousel-arrow--next { + inset-block-end: 0; + margin-block-end: rem(16px); + } + + + %igx-carousel-indicators { + inset-inline-end: 0; + inset-inline-start: unset; + flex-direction: column; + inset-block-start: 50%; + transform: translateY(-50%); + margin-block: 0; + margin-inline-end: rem(29px); + padding: pad-block(rem(6px)) pad-inline(rem(4px)); + bottom: unset; + } + + %igx-carousel-label-indicator { + margin-inline-end: rem(16px); + padding: pad-block(rem(4px)) pad-inline(rem(6px)); + } + + %igx-carousel-indicators--start { + inset-inline-end: unset; + inset-inline-start: 0; + margin-inline-start: rem(16px); + margin-inline-end: unset; + } + + @if $variant == 'indigo' { + %igx-carousel-indicators { + margin-inline-end: rem(16px); + padding: pad(rem(6px)); + } + + + %igx-carousel-label-indicator { + padding: pad-block(rem(4px)) pad-inline(rem(6px)); + margin-inline-end: rem(16px); + } + + %igx-carousel-indicators--start { + margin-inline-start: rem(16px); + } + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/charts/_category-chart-component.scss b/projects/igniteui-angular/core/src/core/styles/components/charts/_category-chart-component.scss new file mode 100644 index 00000000000..02665069a69 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/charts/_category-chart-component.scss @@ -0,0 +1,12 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Dilyana Dimova +@mixin component { + @include b(category-chart) { + // Register the component in the component registry + $this: bem--selector-to-string(&); + @include register-component(string.slice($this, 2, -1)); + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/charts/_data-chart-component.scss b/projects/igniteui-angular/core/src/core/styles/components/charts/_data-chart-component.scss new file mode 100644 index 00000000000..d5921df51c3 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/charts/_data-chart-component.scss @@ -0,0 +1,12 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin component { + @include b(data-chart) { + // Register the component in the component registry + $this: bem--selector-to-string(&); + @include register-component(string.slice($this, 2, -1)); + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/charts/_doughnut-chart-component.scss b/projects/igniteui-angular/core/src/core/styles/components/charts/_doughnut-chart-component.scss new file mode 100644 index 00000000000..432f9ccf336 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/charts/_doughnut-chart-component.scss @@ -0,0 +1,12 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Dilyana Dimova +@mixin component { + @include b(doughnut-chart) { + // Register the component in the component registry + $this: bem--selector-to-string(&); + @include register-component(string.slice($this, 2, -1)); + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/charts/_financial-chart-component.scss b/projects/igniteui-angular/core/src/core/styles/components/charts/_financial-chart-component.scss new file mode 100644 index 00000000000..c9ac5a87c69 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/charts/_financial-chart-component.scss @@ -0,0 +1,12 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Dilyana Dimova +@mixin component { + @include b(financial-chart) { + // Register the component in the component registry + $this: bem--selector-to-string(&); + @include register-component(string.slice($this, 2, -1)); + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/charts/_funnel-chart-component.scss b/projects/igniteui-angular/core/src/core/styles/components/charts/_funnel-chart-component.scss new file mode 100644 index 00000000000..e6749bd5fa2 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/charts/_funnel-chart-component.scss @@ -0,0 +1,12 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Dilyana Dimova +@mixin component { + @include b(funnel-chart) { + // Register the component in the component registry + $this: bem--selector-to-string(&); + @include register-component(string.slice($this, 2, -1)); + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/charts/_gauge-component.scss b/projects/igniteui-angular/core/src/core/styles/components/charts/_gauge-component.scss new file mode 100644 index 00000000000..e6d7681879c --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/charts/_gauge-component.scss @@ -0,0 +1,18 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Dilyana Dimova +@mixin component { + @include b(linear-gauge) { + // Register the component in the component registry + $this: bem--selector-to-string(&); + @include register-component(string.slice($this, 2, -1)); + } + + @include b(radial-gauge) { + // Register the component in the component registry + $this: bem--selector-to-string(&); + @include register-component(string.slice($this, 2, -1)); + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/charts/_geo-map-component.scss b/projects/igniteui-angular/core/src/core/styles/components/charts/_geo-map-component.scss new file mode 100644 index 00000000000..54485f0abb4 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/charts/_geo-map-component.scss @@ -0,0 +1,12 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Dilyana Dimova +@mixin component { + @include b(geo-map) { + // Register the component in the component registry + $this: bem--selector-to-string(&); + @include register-component(string.slice($this, 2, -1)); + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/charts/_graph-component.scss b/projects/igniteui-angular/core/src/core/styles/components/charts/_graph-component.scss new file mode 100644 index 00000000000..52e1e9ddccb --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/charts/_graph-component.scss @@ -0,0 +1,13 @@ +@use '../../base' as *; +@use 'sass:string'; + + +/// @access private +/// @author Dilyana Dimova +@mixin component { + @include b(bullet-graph) { + // Register the component in the component registry + $this: bem--selector-to-string(&); + @include register-component(string.slice($this, 2, -1)); + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/charts/_pie-chart-component.scss b/projects/igniteui-angular/core/src/core/styles/components/charts/_pie-chart-component.scss new file mode 100644 index 00000000000..798a0638f40 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/charts/_pie-chart-component.scss @@ -0,0 +1,12 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Dilyana Dimova +@mixin component { + @include b(pie-chart) { + // Register the component in the component registry + $this: bem--selector-to-string(&); + @include register-component(string.slice($this, 2, -1)); + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/charts/_shape-chart-component.scss b/projects/igniteui-angular/core/src/core/styles/components/charts/_shape-chart-component.scss new file mode 100644 index 00000000000..cdd73fbd0f6 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/charts/_shape-chart-component.scss @@ -0,0 +1,12 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Dilyana Dimova +@mixin component { + @include b(shape-chart) { + // Register the component in the component registry + $this: bem--selector-to-string(&); + @include register-component(string.slice($this, 2, -1)); + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/charts/_sparkline-component.scss b/projects/igniteui-angular/core/src/core/styles/components/charts/_sparkline-component.scss new file mode 100644 index 00000000000..cbc6e1c6f42 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/charts/_sparkline-component.scss @@ -0,0 +1,12 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Dilyana Dimova +@mixin component { + @include b(sparkline) { + // Register the component in the component registry + $this: bem--selector-to-string(&); + @include register-component(string.slice($this, 2, -1)); + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/checkbox/_checkbox-component.scss b/projects/igniteui-angular/core/src/core/styles/components/checkbox/_checkbox-component.scss new file mode 100644 index 00000000000..512d2335b64 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/checkbox/_checkbox-component.scss @@ -0,0 +1,383 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin component { + @include b(igx-checkbox) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: () + ); + + @extend %cbx-display !optional; + + &:hover { + @include e(label) { + @extend %cbx-label--hover !optional; + } + + @include e(label, $m: before) { + @extend %cbx-label--hover !optional; + } + + @include e(ripple) { + @extend %cbx-ripple--hover !optional; + } + + @include e(composite) { + @extend %cbx-composite--hover !optional; + } + + @include e(composite-mark) { + @extend %cbx-composite-mark--fluent !optional; + } + } + + &:active { + @include e(ripple) { + @extend %cbx-ripple--hover !optional; + @extend %cbx-ripple--pressed !optional; + } + } + + @include e(input) { + @extend %cbx-input !optional; + } + + @include e(label) { + @extend %cbx-label !optional; + @extend %cbx-label-pos--after !optional; + } + + @include e(label, $m: before) { + @extend %cbx-label !optional; + @extend %cbx-label-pos--before !optional; + } + + @include e(composite-wrapper) { + @extend %cbx-composite-wrapper !optional; + } + + @include e(composite) { + @extend %cbx-composite !optional; + } + + @include e(composite-mark) { + @extend %cbx-composite-mark !optional; + } + + @include e(ripple) { + @extend %cbx-ripple !optional; + } + + @include m(indigo) { + @include e(composite-mark) { + @extend %cbx-composite-mark-indigo !optional; + } + } + + @include m(invalid) { + @include e(composite) { + @extend %cbx-composite--invalid !optional; + } + + @include e(composite-wrapper) { + @extend %cbx-composite-wrapper--invalid !optional; + } + + @include e(label) { + @extend %cbx-label--invalid !optional; + } + + @include e(label, $m: before) { + @extend %cbx-label--invalid !optional; + } + + &:hover { + @include e(ripple) { + @extend %cbx-ripple--hover !optional; + @extend %cbx-ripple--hover-invalid !optional; + } + + @include e(composite) { + @extend %cbx-composite--invalid--hover !optional; + } + + @include e(composite-mark) { + @extend %cbx-composite-mark--invalid--fluent !optional; + } + + @include e(label) { + @extend %cbx-label--invalid !optional; + } + + @include e(label, $m: before) { + @extend %cbx-label--invalid !optional; + } + } + + &:active { + @include e(ripple) { + @extend %cbx-ripple--hover-invalid !optional; + } + } + } + + @include mx(invalid, checked) { + @include e(composite) { + @extend %cbx-composite--x--invalid !optional; + } + + &:hover { + @include e(composite) { + @extend %cbx-composite--x--invalid--hover !optional; + } + + @include e(composite-mark) { + @extend %cbx-composite-mark--x--fluent !optional; + } + } + } + + @include m(focused) { + @extend %igx-checkbox--focused !optional; + + @include e(ripple) { + @extend %cbx-ripple--focused !optional; + } + + &:hover { + @include e(ripple) { + @extend %cbx-ripple--focused !optional; + } + } + } + + @include mx(indigo, focused) { + @extend %igx-checkbox--focused-indigo !optional; + } + + @include mx(fluent, focused) { + @extend %igx-checkbox--focused-fluent !optional; + } + + @include mx(bootstrap, focused) { + @extend %igx-checkbox--focused-bootstrap !optional; + + &:hover { + @extend %igx-checkbox--focused-hovered-bootstrap !optional; + } + } + + @include mx(indigo, focused, checked) { + @extend %igx-checkbox--focused-checked-indigo !optional; + } + + @include mx(bootstrap, focused, checked) { + @extend %igx-checkbox--focused-checked-bootstrap !optional; + } + + @include mx(focused, checked) { + @extend %igx-checkbox--focused-checked !optional; + } + + @include mx(focused, invalid) { + @include e(ripple) { + @extend %cbx-ripple--focused-invalid !optional; + } + + &:hover { + @include e(ripple) { + @extend %cbx-ripple--hover-invalid !optional; + } + } + } + + @include mx(indigo, focused, invalid) { + @extend %igx-checkbox--focused-invalid-indigo !optional; + } + + @include mx(bootstrap, focused, invalid) { + @extend %igx-checkbox--focused-invalid-bootstrap !optional; + } + + @include m(indeterminate) { + @extend %igx-checkbox--indeterminate !optional; + + @include e(composite) { + @extend %cbx-composite--x !optional; + } + + @include e(composite-mark) { + @extend %cbx-composite-mark--in !optional; + } + + &:hover { + @include e(ripple) { + @extend %cbx-ripple--hover !optional; + @extend %cbx-ripple--hover-checked !optional; + } + + @include e(composite-mark) { + @extend %cbx-composite-mark--in--fluent !optional; + } + } + + &:active { + @include e(ripple) { + @extend %cbx-ripple--hover !optional; + @extend %cbx-ripple--hover-checked !optional; + @extend %cbx-ripple--pressed !optional; + } + } + } + + @include mx(indigo, indeterminate) { + @extend %igx-checkbox--indeterminate-indigo !optional; + } + + @include mx(fluent, indeterminate) { + @extend %igx-checkbox--indeterminate-fluent !optional; + } + + @include mx(invalid, indeterminate) { + @extend %igx-checkbox--indeterminate--invalid !optional; + + &:hover { + @include e(composite) { + @extend %cbx-composite--x--hover !optional; + } + + @include e(composite-mark) { + @extend %cbx-composite-mark--in--fluent !optional; + } + } + } + + @include mx(material, disabled, indeterminate) { + @extend %igx-checkbox--disabled-indeterminate !optional; + } + + @include mx(bootstrap, disabled, indeterminate) { + @extend %igx-checkbox--disabled-indeterminate !optional; + } + + @include mx(fluent, disabled, indeterminate) { + @extend %igx-checkbox--disabled-indeterminate-fluent !optional; + } + + @include mx(indigo, disabled, indeterminate) { + @include e(composite) { + @extend %igx-checkbox--disabled-indeterminate-indigo !optional; + } + } + + @include mx(indigo, focused, indeterminate) { + @extend %igx-checkbox--focused-checked-indigo !optional; + } + + @include mx(bootstrap, focused, indeterminate) { + @extend %igx-checkbox--focused-checked-bootstrap !optional; + } + + @include m(checked) { + @include e(composite) { + @extend %cbx-composite--x !optional; + } + + @include e(composite-mark) { + @extend %cbx-composite-mark--x !optional; + } + + @include e(composite-wrapper) { + @extend %cbx-composite-wrapper--x !optional; + } + + &:hover { + @include e(ripple) { + @extend %cbx-ripple--hover !optional; + @extend %cbx-ripple--hover-checked !optional; + } + + @include e(composite) { + @extend %cbx-composite--x--hover !optional; + } + + @include e(composite-mark) { + @extend %cbx-composite-mark--x--fluent !optional; + } + } + + &:active { + @include e(ripple) { + @extend %cbx-ripple--hover !optional; + @extend %cbx-ripple--hover-checked !optional; + @extend %cbx-ripple--pressed !optional; + } + } + } + + @include m(disabled) { + @extend %cbx-display--disabled !optional; + + @include e(label) { + @extend %cbx-label--disabled !optional; + } + + @include e(label, $m: before) { + @extend %cbx-label--disabled !optional; + } + + @include e(composite) { + @extend %cbx-composite--disabled !optional; + } + } + + @include m(plain) { + @extend %cbx-display--plain !optional; + } + + @include mx(focused, checked) { + @include e(ripple) { + @extend %cbx-ripple--focused !optional; + @extend %cbx-ripple--focused-checked !optional; + } + + &:hover { + @include e(ripple) { + @extend %cbx-ripple--focused !optional; + @extend %cbx-ripple--focused--hover-checked !optional; + } + } + } + + @include mx(focused, invalid, checked) { + &:hover { + @include e(ripple) { + @extend %cbx-ripple--hover-invalid !optional; + } + } + } + + @include mx(focused, indeterminate) { + @include e(ripple) { + @extend %cbx-ripple--focused !optional; + @extend %cbx-ripple--focused-checked !optional; + } + } + + @include mx(indeterminate, disabled) { + @include e(composite) { + @extend %cbx-composite--x--disabled !optional; + } + } + + @include mx(checked, disabled) { + @include e(composite) { + @extend %cbx-composite--x--disabled !optional; + } + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/checkbox/_checkbox-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/checkbox/_checkbox-theme.scss new file mode 100644 index 00000000000..3bcf97b1d4d --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/checkbox/_checkbox-theme.scss @@ -0,0 +1,599 @@ +/* stylelint-disable max-nesting-depth */ +@use 'sass:map'; +@use 'sass:math'; +@use '../../base' as *; +@use 'igniteui-theming/sass/animations' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin checkbox($theme) { + @include css-vars($theme); + $theme-variant: map.get($theme, '_meta', 'variant'); + $variant: map.get($theme, '_meta', 'theme'); + $material-theme: $variant == 'material'; + $bootstrap-theme: $variant == 'bootstrap'; + + @include scale-in-out($start-scale: .9); + + // If updating the WIDTH of the checkbox here, please update it in the grid theme as well. + // It is under the name of $cbx-size + $size: rem(20px); + $size-bs: rem(16px); + $checkbox-radius: math.div($size, 2); + + $size: map.get(( + 'material': $size, + 'fluent': $size, + 'bootstrap': $size-bs, + 'indigo': $size-bs, + ), $variant); + + $border-width: map.get(( + 'material': rem(2px), + 'fluent': rem(1px), + 'bootstrap': rem(1px), + 'indigo': rem(2px), + ), $variant); + + $ripple-display: map.get(( + 'material': block, + 'fluent': none, + 'bootstrap': none, + 'indigo': none, + ), $variant); + + $label-margin: map.get(( + 'material': rem(2px), + 'fluent': rem(8px), + 'bootstrap': rem(8px), + 'indigo': rem(8px), + ), $variant); + + $mark-offset: map.get(( + 'material': 0, + 'fluent': -1px, + 'bootstrap': 1px, + 'indigo': 1px, + ), $variant); + + $mark-length: 24; + $mark-x-factor: calc(#{var-get($theme, 'tick-width')} / $mark-length); + + $ripple-size: rem(40px); + $ripple-radius: math.div($ripple-size, 2); + + @include scale-in-center(); + + %cbx-display { + position: relative; + display: inline-flex; + flex-flow: row nowrap; + align-items: center; + outline-style: none; + cursor: pointer; + } + + %cbx-display--disabled { + user-select: none; + pointer-events: none; + cursor: initial; + } + + %cbx-input { + @include hide-default(); + } + + %cbx-composite-wrapper { + align-items: center; + justify-content: center; + display: flex; + position: relative; + width: $size; + height: $size; + + @if $variant == 'material' { + padding: pad(rem(20px)); + } + + //ripple color + --color: #{var-get($theme, 'empty-color')}; + } + + %cbx-composite-wrapper--x { + //ripple color + --color: #{var-get($theme, 'fill-color')}; + } + + %cbx-composite { + position: relative; + display: inline-block; + width: $size; + height: $size; + min-width: $size; + background: var-get($theme, 'empty-fill-color'); + border-width: $border-width; + border-style: solid; + border-color: var-get($theme, 'empty-color'); + border-radius: var-get($theme, 'border-radius'); + -webkit-tap-highlight-color: transparent; + transition: border-color .2s $ease-out-quad, background .2s $ease-out-quad; + overflow: hidden; + } + + %cbx-composite--hover { + @if $variant == 'bootstrap' or $variant == 'indigo' { + border-color: var-get($theme, 'empty-color-hover'); + } + } + + %cbx-composite--x { + border-color: var-get($theme, 'fill-color'); + background: var-get($theme, 'fill-color'); + } + + %cbx-composite--invalid { + border-color: var-get($theme, 'error-color'); + + @if $variant == 'bootstrap' and $theme-variant == 'dark' { + %cbx-composite-mark { + stroke: black; + } + } + } + + %cbx-composite-wrapper--invalid { + //ripple color + --color: #{var-get($theme, 'error-color')}; + } + + %cbx-composite--invalid--hover { + border-color: var-get($theme, 'error-color-hover'); + } + + %cbx-composite--x--invalid { + border-color: var-get($theme, 'error-color'); + background: var-get($theme, 'error-color'); + } + + %cbx-composite--x--hover { + border-color: var-get($theme, 'fill-color-hover'); + background: var-get($theme, 'fill-color-hover'); + } + + %cbx-composite--x--invalid--hover { + border-color: var-get($theme, 'error-color-hover'); + background: var-get($theme, 'error-color-hover'); + } + + %cbx-composite--disabled { + border-color: var-get($theme, 'disabled-color'); + + @if $variant == 'bootstrap' and $theme-variant == 'dark' { + background: color($color: 'surface', $variant: 500); + } + } + + %cbx-composite--x--disabled { + @if $variant == 'material' or $variant == 'fluent' { + background: var-get($theme, 'disabled-color'); + border-color: var-get($theme, 'disabled-color'); + } + + @if $variant == 'indigo' or $variant == 'bootstrap' { + background: var-get($theme, 'disabled-indeterminate-color'); + border-color: transparent; + } + + @if $variant != 'indigo' { + %cbx-composite-mark { + stroke: var-get($theme, 'disabled-tick-color'); + } + } @else { + %cbx-composite-mark { + stroke: unset; + fill: var-get($theme, 'disabled-tick-color'); + } + } + } + + %cbx-composite-mark { + position: absolute; + inset: 0; + stroke: var-get($theme, 'tick-color'); + stroke-linecap: square; + stroke-width: var-get($theme, 'tick-width'); + stroke-dasharray: $mark-length; + stroke-dashoffset: $mark-length; + fill: none; + opacity: 0; + z-index: 1; + } + + %cbx-composite-mark-material { + inset-inline-start: -.5px; + } + + %cbx-composite-mark-indigo { + stroke: unset; + stroke-linecap: unset; + stroke-width: unset; + stroke-dasharray: unset; + stroke-dashoffset: unset; + fill: var-get($theme, 'tick-color'); + transition: none !important; + + rect { + fill: none; + } + } + + %igx-checkbox--indeterminate { + %cbx-composite-mark { + top: $mark-offset; + margin-inline-start: $mark-offset; + } + + &:hover { + %cbx-composite { + border-color: var-get($theme, 'fill-color-hover'); + + @if $variant != 'fluent' { + background: var-get($theme, 'fill-color-hover'); + } @else { + background: transparent; + } + + &::before { + background: var-get($theme, 'fill-color-hover'); + } + } + } + } + + %igx-checkbox--indeterminate-indigo { + %cbx-composite-mark { + fill: none !important; + stroke-dashoffset: unset !important; + transform: none !important; + + rect { + fill: var-get($theme, 'tick-color'); + opacity: 1; + } + } + } + + %igx-checkbox--disabled-indeterminate-indigo { + @extend %cbx-composite--x--disabled; + + %cbx-composite-mark { + rect { + fill: var-get($theme, 'disabled-tick-color'); + } + } + } + + %igx-checkbox--indeterminate-fluent { + %cbx-composite-mark { + stroke: transparent; + } + + %cbx-composite { + background: transparent; + + &::before { + content: ''; + position: absolute; + top: calc($size / 2 - rem(6px)); + inset-inline-start: calc($size / 2 - rem(6px)); + width: rem(10px); + height: rem(10px); + border-radius: border-radius(rem(2px)); + background: var-get($theme, 'fill-color'); + z-index: 1; + } + } + + &:hover { + %cbx-composite { + &::before { + background: var-get($theme, 'fill-color-hover'); + } + } + } + } + + %igx-checkbox--disabled-indeterminate-fluent { + %cbx-composite-mark { + stroke: transparent; + } + + %cbx-composite--x--disabled { + border-color: var-get($theme, 'disabled-color'); + + &::before { + background: var-get($theme, 'disabled-color'); + } + } + } + + %igx-checkbox--disabled-indeterminate { + %cbx-composite--x--disabled { + border-color: var-get($theme, 'disabled-indeterminate-color'); + background: var-get($theme, 'disabled-indeterminate-color'); + } + } + + %igx-checkbox--indeterminate--invalid { + %cbx-composite--x { + border-color: var-get($theme, 'error-color'); + background: var-get($theme, 'error-color'); + } + + %cbx-composite--x--hover { + border-color: var-get($theme, 'error-color-hover'); + background: var-get($theme, 'error-color-hover'); + } + + @if $variant == 'fluent' { + %cbx-composite { + border-color: var-get($theme, 'error-color'); + + &::before { + background: var-get($theme, 'error-color'); + } + } + + %cbx-composite--x { + background: transparent; + } + + &:hover { + %cbx-composite { + background: transparent; + border-color: var-get($theme, 'error-color-hover'); + + &::before { + background: var-get($theme, 'error-color-hover'); + } + } + } + } + } + + %cbx-composite-mark--x { + stroke-dashoffset: 0; + opacity: 1; + transition: all .2s $ease-out-quad, opacity .2s $ease-out-quad; + } + + %cbx-composite-mark--in { + stroke-dashoffset: 41; /* length of path - adjacent line length */ + opacity: 1; + transform: rotate(45deg) translateX(calc(#{$mark-x-factor} * -1em)); + } + + %cbx-composite-mark--fluent { + @if $variant == 'fluent' { + @extend %cbx-composite-mark; + @extend %cbx-composite-mark--x; + stroke: var-get($theme, 'tick-color-hover'); + } + } + + %cbx-composite-mark--x--fluent { + @if $variant == 'fluent' { + stroke: var-get($theme, 'tick-color'); + } + } + + %cbx-composite-mark--invalid--fluent { + @if $variant == 'fluent' { + stroke: var-get($theme, 'error-color'); + } + + @if $variant == 'fluent' and $theme-variant == 'dark' { + stroke: color($color: 'error', $variant: 500); + } + } + + %cbx-composite-mark--in--fluent { + @if $variant == 'fluent' { + stroke: transparent; + } + } + + %cbx-label { + display: inline-block; + color: var-get($theme, 'label-color'); + user-select: none; + word-wrap: break-all; + transition: color .2s $ease-out-quad; + + &:empty { + margin: 0; + } + } + + %cbx-label--hover { + color: var-get($theme, 'label-color-hover'); + } + + %cbx-label-pos--before, + %cbx-label-pos--after { + &:empty { + margin: 0; + } + } + + %cbx-label-pos--after { + margin-inline-start: $label-margin; + } + + %cbx-label-pos--before { + margin-inline-end: $label-margin; + order: -1; + } + + %cbx-label--invalid { + @if $variant != 'indigo' { + color: var-get($theme, 'error-color'); + } + } + + %cbx-label--disabled { + color: var-get($theme, 'disabled-color-label'); + } + + %cbx-ripple { + display: $ripple-display; + position: absolute; + top: calc(50% - #{$ripple-radius}); + width: $ripple-size; + height: $ripple-size; + border-radius: var-get($theme, 'border-radius-ripple'); + overflow: hidden; + pointer-events: none; + filter: opacity(1); + } + + %cbx-ripple--hover { + background: var-get($theme, 'empty-color'); + transition: background .2s $ease-out-quad; + opacity: .06; + + @if $theme-variant == 'dark' { + opacity: .12; + } + } + + %cbx-ripple--hover-checked { + background: var-get($theme, 'fill-color-hover'); + } + + %cbx-ripple--hover-invalid { + background: var-get($theme, 'error-color-hover'); + } + + %igx-checkbox--focused-indigo { + %cbx-composite { + border-radius: var-get($theme, 'border-radius'); + box-shadow: 0 0 0 rem(3px) var-get($theme, 'focus-outline-color'); + } + } + + %igx-checkbox--focused-fluent { + position: relative; + $focus-outline-offset: rem(2px); + + &::after { + content: ''; + position: absolute; + inset: -$focus-outline-offset; + box-shadow: 0 0 0 rem(1px) var-get($theme, 'focus-outline-color'); + } + } + + %igx-checkbox--focused-bootstrap { + %cbx-composite { + border-radius: var-get($theme, 'border-radius'); + border-color: var-get($theme, 'focus-border-color'); + box-shadow: 0 0 0 rem(4px) var-get($theme, 'focus-outline-color'); + } + } + + %igx-checkbox--focused-hovered-bootstrap { + %cbx-composite { + border-color: hsl(from var-get($theme, 'focus-border-color') h calc(s * 1.12) calc(l * 0.82)); + } + } + + %igx-checkbox--focused-checked-indigo { + %cbx-composite { + border-radius: var-get($theme, 'border-radius'); + box-shadow: 0 0 0 rem(3px) var-get($theme, 'focus-outline-color-focused'); + } + } + + %igx-checkbox--focused-checked-bootstrap { + %cbx-composite { + border-color: transparent; + } + } + + %igx-checkbox--focused-invalid-indigo { + %cbx-composite { + box-shadow: 0 0 0 rem(3px) var-get($theme, 'focus-outline-color-error'); + } + } + + %igx-checkbox--focused-invalid-bootstrap { + %cbx-composite { + border-color: var-get($theme, 'error-color'); + box-shadow: 0 0 0 rem(4px) var-get($theme, 'focus-outline-color-error'); + } + + &:hover { + %cbx-composite { + border-color: var-get($theme, 'error-color-hover'); + } + } + } + + %cbx-ripple--focused { + background: var-get($theme, 'empty-color'); + transition: background .2s $ease-out-quad; + opacity: .12; + + @if $theme-variant == 'dark' { + opacity: .24; + } + } + + %cbx-ripple--focused-checked { + background: var-get($theme, 'fill-color'); + } + + %cbx-ripple--focused--hover-checked { + background: var-get($theme, 'fill-color-hover'); + } + + %cbx-ripple--focused-invalid { + background: var-get($theme, 'error-color'); + } + + %cbx-ripple--pressed { + opacity: .12; + + @if $theme-variant == 'dark' { + opacity: .24; + } + } + + %cbx-display--plain { + %cbx-composite, + %cbx-composite::after, + %cbx-composite-mark, + %cbx-composite-mark--x { + transition: none; + } + } +} + +/// Adds typography styles for the igx-checkbox component. +/// Uses the 'subtitle-1' category from the typographic scale. +/// @group typography +/// @param {Map} $categories [(label: 'subtitle-1')] - The categories from the typographic scale used for type styles. +@mixin checkbox-typography( + $categories: (label: 'subtitle-1') +) { + $label: map.get($categories, 'label'); + + %cbx-label { + @include type-style($label) { + margin-top: 0; + margin-bottom: 0; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/chip/_chip-component.scss b/projects/igniteui-angular/core/src/core/styles/components/chip/_chip-component.scss new file mode 100644 index 00000000000..1d918fa999f --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/chip/_chip-component.scss @@ -0,0 +1,102 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Marin Popov +@mixin component { + @include b(igx-chip-area) { + @extend %igx-chip-area !optional; + } + + @include b(igx-chip) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: ( + igx-icon, + ) + ); + + @extend %igx-chip !optional; + + @include e(item) { + @extend %igx-chip__item !optional; + } + + @include e(start) { + @extend %igx-chip__start !optional; + } + + @include e(end) { + @extend %igx-chip__end !optional; + } + + @include e(item, $m: 'selected') { + @extend %igx-chip__item--selected !optional; + } + + @include m(primary) { + @extend %igx-chip--primary !optional; + + @include e(item) { + @extend %igx-chip__item--primary !optional; + } + } + + @include m(info) { + @extend %igx-chip--info !optional; + + @include e(item) { + @extend %igx-chip__item--info !optional; + } + } + + @include m(success) { + @extend %igx-chip--success !optional; + + @include e(item) { + @extend %igx-chip__item--success !optional; + } + } + + @include m(warning) { + @extend %igx-chip--warning !optional; + + @include e(item) { + @extend %igx-chip__item--warning !optional; + } + } + + @include m(danger) { + @extend %igx-chip--danger !optional; + + @include e(item) { + @extend %igx-chip__item--danger !optional; + } + } + + @include e(content) { + @extend %igx-chip__content !optional; + } + + @include e(select) { + @extend %igx-chip__select !optional; + } + + @include e(remove) { + @extend %igx-chip__remove !optional; + } + + @include e(ghost) { + @extend %igx-chip__ghost !optional; + } + + @include m(disabled) { + @extend %igx-chip--disabled !optional; + + @include e(item) { + @extend %igx-chip__item--disabled !optional; + } + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/chip/_chip-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/chip/_chip-theme.scss new file mode 100644 index 00000000000..003a8ad71e2 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/chip/_chip-theme.scss @@ -0,0 +1,658 @@ +@use 'sass:map'; +@use '../../base' as *; +@use 'igniteui-theming/sass/animations/easings' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin chip($theme) { + @include css-vars($theme); + $chip-max-width: 32ch; + + $variant: map.get($theme, '_meta', 'theme'); + $theme-variant: map.get($theme, '_meta', 'variant'); + + $chip-padding: ( + comfortable: rem(if($variant != 'indigo', 12px, 7px)), + cosy: rem(if($variant != 'indigo', 6px, 5px)), + compact: rem(if($variant != 'indigo', 2px, 3px)) + ); + + $box-shadow-focus: map.get(( + 'material': null, + 'fluent': null, + 'bootstrap': 0 0 0 rem(4px) var-get($theme, 'focus-outline-color'), + 'indigo': 0 0 0 rem(3px) var-get($theme, 'focus-outline-color') + ), $variant); + + $box-shadow-focus-selected: map.get(( + 'material': null, + 'fluent': null, + 'bootstrap': 0 0 0 rem(4px) var-get($theme, 'focus-selected-outline-color'), + 'indigo': 0 0 0 rem(3px) var-get($theme, 'focus-selected-outline-color') + ), $variant); + + $border-size: rem(1px); + + %igx-chip-area { + display: flex; + align-items: center; + justify-content: flex-start; + flex-wrap: wrap; + width: 100%; + + @if $variant == 'indigo' { + padding: pad(rem(4px)); + gap: rem(8px); + } + + &:empty { + display: none; + } + } + + %igx-chip { + @include sizable(); + --component-size: var(--ig-size, #{var-get($theme, 'default-size')}); + --chip-size: var(--component-size); + + position: relative; + display: inline-flex; + flex-shrink: 0; + + // Fix: The grid resizing does not autosize the filter header cells with the default filter chips + // https://github.com/IgniteUI/igniteui-angular/pull/12770/files/efd2a274038c051e82561903f8799fd03265fd74#r1150993630 + min-width: max-content; + touch-action: none; + outline: none; + + // The focus and hover are build that way since the host is the focusable element + &:focus { + %igx-chip__item { + outline-style: none; + color: var-get($theme, 'focus-text-color'); + background: var-get($theme, 'focus-background'); + border-color: var-get($theme, 'focus-border-color'); + box-shadow: $box-shadow-focus; + } + + %igx-chip__item--selected { + color: var-get($theme, 'focus-selected-text-color'); + background: var-get($theme, 'focus-selected-background'); + border-color: var-get($theme, 'focus-selected-border-color'); + box-shadow: $box-shadow-focus-selected; + } + } + + &:hover { + %igx-chip__item { + color: var-get($theme, 'hover-text-color'); + background: var-get($theme, 'hover-background'); + border-color: var-get($theme, 'hover-border-color'); + } + + %igx-chip__item--selected { + color: var-get($theme, 'hover-selected-text-color'); + background: var-get($theme, 'hover-selected-background'); + border-color: var-get($theme, 'hover-selected-border-color'); + } + } + } + + %igx-chip__item { + display: grid; + grid-auto-flow: column; + grid-auto-columns: auto; + align-items: center; + justify-content: center; + text-align: center; + height: var-get($theme, 'size'); + padding-inline: pad-inline( + sizable( + map.get($chip-padding, 'compact'), + map.get($chip-padding, 'cosy'), + map.get($chip-padding, 'comfortable') + ) + ); + + gap: sizable(rem(3px), rem(6px), rem(8px)); + color: var-get($theme, 'text-color'); + background: var-get($theme, 'background'); + border: $border-size solid var-get($theme, 'border-color'); + border-radius: var-get($theme, 'border-radius'); + user-select: none; + overflow: hidden; + cursor: pointer; + filter: opacity(1); + + igx-avatar { + display: flex !important; + align-items: center; + justify-content: center; + font-size: 50%; + } + + igx-avatar, + igx-icon { + --component-size: var(--chip-size); + } + + igx-circular-bar { + --diameter: #{sizable(rem(14px), rem(18px), rem(24px))}; + } + + @if $variant == 'indigo' { + igx-icon { + --size: #{sizable(rem(14px), rem(14px), rem(16px))} + } + } + + igx-avatar { + max-height: 100%; + width: sizable(rem(14px), rem(18px), rem(24px)); + max-width: sizable(rem(14px), rem(18px), rem(24px)); + height: sizable(rem(14px), rem(18px), rem(24px)); + } + + igx-prefix, + [igxPrefix] { + @extend %igx-chip__prefix; + } + + igx-suffix, + [igxSuffix] { + @extend %igx-chip__suffix; + } + } + + %igx-chip__item--selected { + color: var-get($theme, 'selected-text-color'); + background: var-get($theme, 'selected-background'); + border-color: var-get($theme, 'selected-border-color'); + } + + %igx-chip__item--primary { + @if $variant == 'bootstrap' { + color: contrast-color($color: 'primary', $variant: 600); + } @else { + color: contrast-color($color: 'primary', $variant: 500); + } + background: color($color: 'primary', $variant: 500); + border-color: color($color: 'primary', $variant: 500); + } + + %igx-chip%igx-chip--primary { + &:focus { + %igx-chip__item { + @if $variant != 'bootstrap' and $variant != 'indigo' { + color: contrast-color($color: 'primary', $variant: 800); + background: color($color: 'primary', $variant: 800); + border-color: color($color: 'primary', $variant: 800); + } + + @if $variant == "bootstrap" { + color: contrast-color($color: 'primary', $variant: 500); + background: color($color: 'primary', $variant: 500); + border-color: color($color: 'primary', $variant: 500); + } + + @if $variant == 'indigo' { + color: contrast-color($color: 'primary', $variant: 900); + background: color($color: 'primary', $variant: 500); + border-color: color($color: 'primary', $variant: 500); + box-shadow: 0 0 0 rem(3px) color($color: 'primary', $variant: 400, $opacity: .5); + } + + @if $variant == 'bootstrap' { + box-shadow: 0 0 0 rem(4px) color($color: 'primary', $variant: 500, $opacity: .38); + } + } + } + + &:hover { + %igx-chip__item { + @if $variant == 'indigo' { + color: contrast-color($color: 'primary', $variant: 900); + background: color($color: 'primary', $variant: 400); + border-color: color($color: 'primary', $variant: 400); + } @else { + color: contrast-color($color: 'primary', $variant: 600); + background: color($color: 'primary', $variant: 600); + border-color: color($color: 'primary', $variant: 600); + } + } + } + } + + %igx-chip__item--info { + color: contrast-color($color: 'info', $variant: 500); + background: color($color: 'info', $variant: 500); + border-color: color($color: 'info', $variant: 500); + } + + %igx-chip%igx-chip--info { + &:focus { + %igx-chip__item { + color: contrast-color($color: 'info', $variant: 800); + background: color($color: 'info', $variant: 800); + border-color: color($color: 'info', $variant: 800); + + @if $variant == 'indigo' or $variant == 'bootstrap' { + color: contrast-color($color: 'info', $variant: 500); + background: color($color: 'info', $variant: 500); + border-color: color($color: 'info', $variant: 500); + } + + @if $variant == 'indigo' { + box-shadow: 0 0 0 rem(3px) color($color: 'info', $variant: if($theme-variant == 'light', 100, 800)); + } + + @if $variant == 'bootstrap' { + box-shadow: 0 0 0 rem(4px) color($color: 'info', $variant: 500, $opacity: .38); + } + } + } + + &:hover { + %igx-chip__item { + @if $variant == 'indigo' { + color: contrast-color($color: 'info', $variant: 400); + background: color($color: 'info', $variant: 400); + border-color: color($color: 'info', $variant: 400); + } @else { + color: contrast-color($color: 'info', $variant: 600); + background: color($color: 'info', $variant: 600); + border-color: color($color: 'info', $variant: 600); + } + } + } + } + + %igx-chip__item--success { + @if $variant == 'bootstrap' { + color: contrast-color($color: 'success', $variant: 600); + } @else { + color: contrast-color($color: 'success', $variant: if($variant == 'indigo', 900, 500)); + } + background: color($color: 'success', $variant: 500); + border-color: color($color: 'success', $variant: 500); + } + + %igx-chip%igx-chip--success { + &:focus { + %igx-chip__item { + color: contrast-color($color: 'success', $variant: 800); + background: color($color: 'success', $variant: 800); + border-color: color($color: 'success', $variant: 800); + + @if $variant == 'indigo' or $variant == 'bootstrap' { + background: color($color: 'success', $variant: 500); + border-color: color($color: 'success', $variant: 500); + box-shadow: 0 0 0 rem(3px) color($color: 'success', $variant: if($theme-variant == 'light', 200, 800)); + } + + @if $variant == 'indigo' { + color: contrast-color($color: 'success', $variant: 900); + } + + @if $variant == 'bootstrap' { + color: contrast-color($color: 'success', $variant: 600); + box-shadow: 0 0 0 rem(4px) color($color: 'success', $variant: 500, $opacity: .38); + } + } + } + + &:hover { + %igx-chip__item { + @if $variant == 'indigo' { + color: contrast-color($color: 'success', $variant: 900); + background: color($color: 'success', $variant: 400); + border-color: color($color: 'success', $variant: 400); + } @else { + color: contrast-color($color: 'success', $variant: 600); + background: color($color: 'success', $variant: 600); + border-color: color($color: 'success', $variant: 600); + } + } + } + } + + %igx-chip__item--warning { + color: contrast-color($color: 'warn', $variant: 900); + background: color($color: 'warn', $variant: 500); + border-color: color($color: 'warn', $variant: 500); + } + + %igx-chip%igx-chip--warning { + &:focus { + %igx-chip__item { + color: contrast-color($color: 'warn', $variant: 800); + background: color($color: 'warn', $variant: 800); + border-color: color($color: 'warn', $variant: 800); + + @if $variant == 'indigo' or $variant == 'bootstrap' { + color: contrast-color($color: 'warn', $variant: 900); + background: color($color: 'warn', $variant: 500); + border-color: color($color: 'warn', $variant: 500); + } + + @if $variant == 'indigo' { + box-shadow: 0 0 0 rem(3px) color($color: 'warn', $variant: if($theme-variant == 'light', 100, 900)); + } + + @if $variant == 'bootstrap' { + box-shadow: 0 0 0 rem(4px) color($color: 'warn', $variant: 500, $opacity: .38); + } + } + } + + &:hover { + %igx-chip__item { + @if $variant == 'indigo' { + color: contrast-color($color: 'warn', $variant: 900); + background: color($color: 'warn', $variant: 400); + border-color: color($color: 'warn', $variant: 400); + } @else { + color: contrast-color($color: 'warn', $variant: 600); + background: color($color: 'warn', $variant: 600); + border-color: color($color: 'warn', $variant: 600); + } + } + } + } + + %igx-chip__item--danger { + @if $variant == 'bootstrap' { + color: contrast-color($color: 'error', $variant: 600); + } @else { + color: contrast-color($color: 'error', $variant: if($variant == 'indigo', 900, 500)); + } + background: color($color: 'error', $variant: 500); + border-color: color($color: 'error', $variant: 500); + } + + %igx-chip%igx-chip--danger { + &:focus { + %igx-chip__item { + color: contrast-color($color: 'error', $variant: 800); + background: color($color: 'error', $variant: 800); + border-color: color($color: 'error', $variant: 800); + + @if $variant == 'indigo' or $variant == 'bootstrap' { + background: color($color: 'error', $variant: 500); + border-color: color($color: 'error', $variant: 500); + } + + @if $variant == 'indigo' { + color: contrast-color($color: 'error', $variant: 900); + box-shadow: 0 0 0 rem(3px) color($color: 'error', $variant: if($theme-variant == 'light', 100, 900)); + } + + @if $variant == 'bootstrap' { + color: contrast-color($color: 'error', $variant: 600); + box-shadow: 0 0 0 rem(4px) color($color: 'error', $variant: 500, $opacity: .38); + } + } + } + + &:hover { + %igx-chip__item { + @if $variant == 'indigo' { + color: contrast-color($color: 'error', $variant: 900); + background: color($color: 'error', $variant: 400); + border-color: color($color: 'error', $variant: 400); + } @else { + color: contrast-color($color: 'error', $variant: 600); + background: color($color: 'error', $variant: 600); + border-color: color($color: 'error', $variant: 600); + } + } + } + } + + %igx-chip__start { + > igx-avatar, + > igx-circular-bar { + &:first-child { + @if $variant != 'indigo' { + margin-inline-start: calc(#{sizable(rem(0), rem(4px), rem(8px))} * -1); + } @else { + margin-inline-start: calc(#{sizable(rem(1px), rem(3px), rem(4px))} * -1); + } + } + } + + [igxPrefix], + igx-prefix { + &:first-of-type { + igx-avatar, + igx-circular-bar { + @if $variant != 'indigo' { + margin-inline-start: calc(#{sizable(rem(0), rem(4px), rem(8px))} * -1); + } @else { + margin-inline-start: calc(#{sizable(rem(1px), rem(3px), rem(4px))} * -1); + } + } + } + } + } + + %igx-chip__end { + > igx-avatar, + > igx-circular-bar { + &:last-child { + @if $variant != 'indigo' { + margin-inline-end: calc(#{sizable(rem(0), rem(4px), rem(8px))} * -1); + } @else { + margin-inline-end: calc(#{sizable(rem(1px), rem(3px), rem(4px))} * -1); + } + } + } + + [igxPrefix], + igx-prefix { + &:first-of-type { + igx-avatar, + igx-circular-bar { + @if $variant != 'indigo' { + margin-inline-end: calc(#{sizable(rem(0), rem(4px), rem(8px))} * -1); + } @else { + margin-inline-end: calc(#{sizable(rem(1px), rem(3px), rem(4px))} * -1); + } + } + } + } + } + + %igx-chip__start, + %igx-chip__end { + display: flex; + align-items: center; + position: relative; + + &:empty { + display: none; + } + } + + %igx-chip__prefix, + %igx-chip__suffix { + @include ellipsis(); + + display: inline-block; + vertical-align: middle; + max-width: $chip-max-width; + + > igx-icon { + display: block; + } + } + + %igx-chip__content { + @include ellipsis(); + + max-width: $chip-max-width; + + &:empty { + display: none; + } + } + + %igx-chip__remove { + display: inline-flex; + color: var-get($theme, 'remove-icon-color', currentColor); + + &:empty { + display: none; + } + + // FIX IE11 and Edge focus styles. + // [focus-within] is not supported by IE & Edge. + &:focus { + igx-icon { + color: var-get($theme, 'remove-icon-color-focus'); + } + } + + igx-icon { + &:focus{ + outline-style: none; + } + } + } + + %igx-chip__select { + display: inline-flex; + align-items: center; + max-width: rem(24px); + opacity: 1; + z-index: 1; + transition: max-width .12s $in-out-quad, opacity .12s $in-out-quad .06s; + transition-behavior: allow-discrete; + + @starting-style { + max-width: 0; + opacity: 0; + } + } + + %igx-chip__ghost { + @extend %igx-chip; + + position: absolute; + box-shadow: var-get($theme, 'ghost-elevation'); + overflow: hidden; + color: var-get($theme, 'focus-text-color'); + background: var-get($theme, 'ghost-background'); + // If z-index is not set, + //the chip would hide behind it's parent grid as it has a z-index of 1 + z-index: 10; + + igx-avatar, + igx-circular-bar, + igx-icon { + --component-size: var(--chip-size); + } + + &:hover, + &:focus { + background: var-get($theme, 'ghost-background'); + } + } + + %igx-chip__item--disabled { + color: var-get($theme, 'disabled-text-color'); + background: var-get($theme, 'disabled-background'); + border-color: var-get($theme, 'disabled-border-color'); + } + + %igx-chip--disabled { + cursor: default; + pointer-events: none; + + @if $variant == 'indigo' { + %igx-chip__item--selected { + background: color($color: 'primary', $variant: 400, $opacity: .5); + border-color: transparent; + color: contrast-color($color: 'primary', $variant: 900, $opacity: if($theme-variant == 'light', .4, .2)); + } + + &%igx-chip--primary { + %igx-chip__item { + /* stylelint-disable max-nesting-depth */ + @if $theme-variant == 'light' { + color: contrast-color($color: 'primary', $variant: 900, $opacity: .4); + } @else { + color: contrast-color($color: 'primary', $variant: 900, $opacity: .2); + } + + background: color($color: 'primary', $variant: 400, $opacity: .5); + border-color: transparent; + /* stylelint-enable max-nesting-depth */ + } + } + + &%igx-chip--info { + %igx-chip__item { + color: contrast-color($color: 'info', $variant: 900); + background: color($color: 'info', $variant: 500); + border-color: color($color: 'info', $variant: 500); + } + } + + &%igx-chip--success { + %igx-chip__item { + color: contrast-color($color: 'success', $variant: 900); + background: color($color: 'success', $variant: 500); + border-color: color($color: 'success', $variant: 500); + } + } + + &%igx-chip--warning { + %igx-chip__item { + color: contrast-color($color: 'warn', $variant: 900); + background: color($color: 'warn', $variant: 500); + border-color: color($color: 'warn', $variant: 500); + } + } + + &%igx-chip--danger { + %igx-chip__item { + color: contrast-color($color: 'error', $variant: 900); + background: color($color: 'error', $variant: 500); + border-color: color($color: 'error', $variant: 500); + } + } + + &%igx-chip--info, + &%igx-chip--success, + &%igx-chip--warning, + &%igx-chip--danger { + %igx-chip__item { + opacity: .4; + } + } + } + + %igx-chip__end { + &:has(%igx-chip__remove:only-child) { + display: none; + } + } + } +} + +/// Adds typography styles for the igx-chip component. +/// Uses the 'body-2' +/// category from the typographic scale. +/// @group typography +/// @param {Map} $categories [(text: 'body-2')] - The categories from the typographic scale used for type styles. +@mixin chip-typography( + $categories: (text: 'body-2')) +{ + $text: map.get($categories, 'text'); + + %igx-chip__item { + @include type-style($text); + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/column-actions/_column-actions-component.scss b/projects/igniteui-angular/core/src/core/styles/components/column-actions/_column-actions-component.scss new file mode 100644 index 00000000000..b5023cb65b8 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/column-actions/_column-actions-component.scss @@ -0,0 +1,41 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +/// @author Marin Popov +@mixin component { + @include b(igx-column-actions) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: () + ); + + @extend %column-actions-display !optional; + + @include e(header) { + @extend %column-actions-header !optional; + } + + @include e(header-title) { + @extend %column-actions-title !optional; + } + + @include e(header-input) { + @extend %column-actions-input !optional; + } + + @include e(columns) { + @extend %column-actions-columns !optional; + } + + @include e(columns-item) { + @extend %column-actions-item !optional; + } + + @include e(buttons) { + @extend %column-actions-buttons !optional; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/column-actions/_column-actions-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/column-actions/_column-actions-theme.scss new file mode 100644 index 00000000000..319f935d6b3 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/column-actions/_column-actions-theme.scss @@ -0,0 +1,86 @@ +@use 'sass:map'; +@use '../../base' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin column-actions($theme) { + @include css-vars($theme); + $variant: map.get($theme, '_meta', 'theme'); + + %column-actions-display { + display: flex; + flex-flow: column nowrap; + background: var-get($theme, 'background-color'); + box-shadow: elevation(8); + width: 100%; + flex: 1 1 auto; + min-width: rem(180px); + + @if $variant == 'material' { + %cbx-composite-wrapper { + padding: 0; + } + + %cbx-label-pos--after { + margin-inline-start: rem(12px); + } + + %cbx-label-pos--before { + margin-inline-end: rem(12px); + } + + %cbx-label-pos--before, + %cbx-label-pos--after { + &:empty { + margin: 0; + } + } + } + } + + %column-actions-title { + color: var-get($theme, 'title-color'); + margin: 0; + padding: rem(16px) rem(16px) rem(8px); + } + + %column-actions-input { + font-size: rem(16px) !important; + margin: 0 !important; + padding: rem(8px) rem(16px); + } + + %column-actions-columns { + display: flex; + flex-flow: column nowrap; + overflow-y: auto; + outline-style: none; + } + + %column-actions-item { + padding: rem(4px) rem(16px); + min-height: rem(32px); + } + + %column-actions-buttons { + display: flex; + justify-content: flex-end; + padding: rem(8px) rem(16px); + } +} + +/// Adds typography styles for the igx-column-actions component. +/// Uses the 'subtitle-1' +/// category from the typographic scale. +/// @group typography +/// @param {Map} $categories [(title: 'subtitle-1')] - The categories from the typographic scale used for type styles. +@mixin column-actions-typography($categories: (title: 'subtitle-1')) { + $title: map.get($categories, 'title'); + + %column-actions-title { + @include type-style($title) { + margin: 0; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/combo/_combo-component.scss b/projects/igniteui-angular/core/src/core/styles/components/combo/_combo-component.scss new file mode 100644 index 00000000000..54bdbe1f85d --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/combo/_combo-component.scss @@ -0,0 +1,66 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +@mixin component { + @include b(igx-combo) { + @include sizable(); + + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: ( + igx-checkbox, + igx-drop-down, + igx-input-group, + igx-icon, + ) + ); + + @extend %igx-combo !optional; + + @include e(drop-down) { + @extend %igx-combo__drop-down !optional; + } + + @include e(case-icon) { + @extend %igx-combo__case-icon !optional; + } + + @include e(case-icon, $m: active) { + @extend %igx-combo__case-icon--active !optional; + } + + @include e(search) { + @extend %igx-combo__search !optional; + } + + @include e(checkbox) { + @extend %igx-combo__checkbox !optional; + } + + @include e(content) { + @extend %igx-combo__content !optional; + } + + @include e(add) { + @extend %igx-combo__add !optional; + } + + @include e(add-item) { + @extend %igx-combo__add-item !optional; + } + + @include e(empty) { + @extend %igx-combo__empty !optional; + } + + @include e(clear-button) { + @extend %igx-combo__clear-button !optional; + } + + @include e(toggle-button) { + @extend %igx-combo__toggle-button !optional; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/combo/_combo-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/combo/_combo-theme.scss new file mode 100644 index 00000000000..7cab448d4b7 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/combo/_combo-theme.scss @@ -0,0 +1,289 @@ +@use 'sass:map'; +@use '../../base' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin combo($theme) { + @include css-vars($theme); + + $variant: map.get($theme, '_meta', 'theme'); + + $search-input-inline-padding: map.get(( + 'material': pad-inline(rem(4px), rem(8px), rem(16px)), + 'fluent': pad-inline(rem(2px), rem(4px), rem(8px)), + 'bootstrap': pad-inline(rem(4px), rem(8px), rem(16px)), + 'indigo': pad-inline(rem(8px), rem(12px), rem(12px)) + ), $variant); + + $search-input-block-padding: map.get(( + 'material': pad-inline(rem(8px)), + 'fluent': pad-inline(rem(2px), rem(4px), rem(8px)), + 'bootstrap': pad-inline(rem(8px)), + 'indigo': pad-inline(rem(12px)) + ), $variant); + + %igx-combo { + position: relative; + display: block; + } + + %igx-combo__checkbox { + margin-inline-end: rem(8px); + + @if $variant == 'material' { + margin-inline-end: rem(16px); + + div { + padding: 0; + } + } + } + + %igx-combo__drop-down { + position: absolute; + width: 100%; + + .igx-drop-down { + width: 100%; + } + } + + %igx-combo__search { + padding-inline: $search-input-inline-padding; + padding-block: $search-input-block-padding; + margin: 0 !important; + z-index: 26; + border-bottom: rem(1px) dashed var-get($theme, 'search-separator-border-color'); + + .igx-input-group__bundle { + padding-block-start: 0; + height: auto; + } + + .igx-input-group__bundle-main { + padding-inline: 0; + } + + .igx-input-group__bundle-start, + .igx-input-group__bundle-end { + min-width: 0; + } + + igx-input-group { + --theme: #{if($variant == 'indigo', 'indigo', 'material')}; + --ig-size: #{if($variant == 'indigo', 2, 1)}; + + @if $variant == 'bootstrap' or $variant == 'indigo' { + input { + height: rem(28px); + } + } @else if $variant == 'fluent' { + input { + height: rem(32px); + } + } + } + } + + %igx-combo__case-icon, + %igx-combo__case-icon--active { + line-height: 0; + } + + // The wrapping element here is needed + // in order to override the !important rule of .igx-icon--inactive. + %igx-combo__case-icon { + igx-icon { + --igx-icon-disabled-color: var(--ig-gray-600); + + opacity: 1; + } + } + + %igx-combo__case-icon--active { + igx-icon { + color: color($color: 'primary') + } + } + + %igx-combo__content { + --item-count: 6; + + position: relative; + overflow: hidden; + max-height: calc(var(--size) * var(--item-count)); + + @if $variant == 'indigo' { + max-height: calc(var(--size) * var(--item-count) + rem(16px)); + } + + &:focus { + outline: transparent; + } + } + + %igx-combo__add { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + position: relative; + padding: rem(16px); + gap: rem(16px); + background: var-get($theme, 'empty-list-background'); + } + + %igx-combo__add-item { + height: auto !important; + background: var-get($theme, 'empty-list-background') !important; + justify-content: center; + outline: none !important; + } + + %igx-combo__empty { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + color: var-get($theme, 'empty-list-placeholder-color'); + padding: 0 rem(24px); + font-size: rem(13px); + } + + @if $variant == 'bootstrap' { + .igx-input-group__bundle::after { + height: rem(1px) !important; + } + } + + @if $variant == 'fluent' or $variant == 'bootstrap' { + %igx-combo__search { + --igx-input-group-input-suffix-background: transparent; + --igx-input-group-input-suffix-background--focused: transparent; + + .igx-input-group__bundle::after { + border-block-end-color: var(--border-color); + } + + .igx-input-group__bundle:hover::after { + border-block-end-color: #{if($variant == 'fluent', var(--hover-border-color), var(--border-color))};; + } + + .igx-input-group--focused .igx-input-group__bundle::after { + border-block-end-color: var(--focused-bottom-line-color); + } + } + } + + .igx-input-group { + %igx-combo__toggle-button { + background: var-get($theme, 'toggle-button-background'); + color: var-get($theme, 'toggle-button-foreground'); + } + + %igx-combo__clear-button { + @if $variant == 'bootstrap' { + border-inline-end: rem(1px) solid var(--border-color); + } + + &:empty { + padding: 0; + } + } + + &:not(.igx-input-group--disabled){ + %igx-combo__clear-button { + color: var-get($theme, 'clear-button-foreground'); + background: var-get($theme, 'clear-button-background'); + } + } + } + + %form-group-bundle:not(%form-group-bundle--disabled):focus-within { + %igx-combo__toggle-button { + color: var-get($theme, 'toggle-button-foreground-focus'); + background: var-get($theme, 'toggle-button-background-focus'); + } + + %igx-combo__clear-button { + color: var-get($theme, 'clear-button-foreground-focus'); + background: var-get($theme, 'clear-button-background-focus'); + } + + &%form-group-bundle--border { + %igx-combo__toggle-button { + background: var-get($theme, 'toggle-button-background-focus--border'); + } + } + } + + @if $variant == 'indigo' { + %form-group-bundle:not(%form-group-bundle--disabled):hover { + %igx-combo__toggle-button { + color: var-get($theme, 'toggle-button-foreground-focus'); + background: var-get($theme, 'toggle-button-background-focus'); + } + + %igx-combo__clear-button { + color: var-get($theme, 'clear-button-foreground-focus'); + background: var-get($theme, 'clear-button-background-focus'); + } + } + } + + .igx-input-group--filled { + %igx-combo__toggle-button { + color: var-get($theme, 'toggle-button-foreground-filled'); + } + + @if $variant == 'material' { + &.igx-input-group--focused { + %igx-combo__toggle-button { + color: var-get($theme, 'toggle-button-foreground-filled'); + } + } + } + } + + .igx-input-group--focused { + %igx-combo__toggle-button { + color: var-get($theme, 'toggle-button-foreground-focus'); + background: var-get($theme, 'toggle-button-background-focus'); + } + + %igx-combo__clear-button { + color: var-get($theme, 'clear-button-foreground-focus'); + background: var-get($theme, 'clear-button-background-focus'); + } + } + + .igx-input-group.igx-input-group--focused:not(.igx-input-group--box) { + @if $variant == 'material' { + %igx-combo__toggle-button { + background: var-get($theme, 'toggle-button-background-focus--border'); + } + } + } + + %form-group-bundle:not(%form-group-bundle--disabled) { + %igx-combo__clear-button:focus-visible, + %igx-combo__toggle-button:focus-visible { + color: color($color: 'secondary'); + background: var-get($theme, 'toggle-button-background'); + } + } + + .igx-input-group--disabled { + %igx-combo__toggle-button { + background: var-get($theme, 'toggle-button-background-disabled'); + color: var-get($theme, 'toggle-button-foreground-disabled'); + } + + %igx-combo__clear-button { + @if $variant == 'bootstrap' { + border-inline-end: 0; + } + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/date-picker/_date-picker-component.scss b/projects/igniteui-angular/core/src/core/styles/components/date-picker/_date-picker-component.scss new file mode 100644 index 00000000000..6a0e62ba5de --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/date-picker/_date-picker-component.scss @@ -0,0 +1,34 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin component { + @include b(igx-date-picker) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: ( + igx-calendar + ) + ); + + @extend %date-picker !optional; + + @include e(actions) { + @extend %date-picker__actions !optional; + } + + @include e(buttons) { + @extend %date-picker__buttons !optional; + } + + @include m(vertical) { + @extend %date-picker--vertical !optional; + } + + @include m(dropdown) { + @extend %date-picker--dropdown !optional; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/date-picker/_date-picker-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/date-picker/_date-picker-theme.scss new file mode 100644 index 00000000000..988b39fdde9 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/date-picker/_date-picker-theme.scss @@ -0,0 +1,87 @@ +@use 'sass:map'; +@use '../../base' as *; +@use '../../themes/schemas' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The calendar theme used to style the component. +@mixin date-picker($theme) { + $variant: map.get($theme, '_meta', 'theme'); + $theme-variant: map.get($theme, '_meta', 'variant'); + $bootstrap-theme: $variant == 'bootstrap'; + $border-shadow: 0 0 0 rem(1px) var-get($theme, 'border-color'); + + $action-area-height: map.get(( + 'material': (rem(40px), rem(46px), rem(52px)), + 'fluent': (rem(40px), rem(48px), rem(54px)), + 'bootstrap': (rem(47px), rem(54px), rem(64px)), + 'indigo': (rem(40px), rem(44px), rem(48px)), + ), $variant); + + %date-picker { + // TODO move the shadow in the schemas + box-shadow: $border-shadow, elevation(24); + border-radius: var-get($theme, 'border-radius'); + background: var-get($theme, 'content-background'); + overflow: hidden; + + @if $variant == 'indigo' and $theme-variant == 'light' { + box-shadow: $border-shadow, elevation(5); + } @else if $variant == 'indigo' and $theme-variant == 'dark' { + box-shadow: $border-shadow, elevation(7); + } + + igx-calendar, + %days-view, + %months-view, + %years-view { + box-shadow: none; + border-radius: 0; + } + + igx-divider { + --color: #{var-get($theme, 'actions-divider-color')}; + } + } + + %date-picker--dropdown { + display: flex; + flex: 1 0 0; + flex-direction: column; + box-shadow: $border-shadow, elevation(3); + + @if $variant == 'indigo' and $theme-variant == 'dark' { + box-shadow: $border-shadow, elevation(2); + } + } + + %date-picker__buttons { + display: flex; + align-items: center; + justify-content: flex-end; + padding: pad(rem(8px)); + } + + %date-picker__actions { + @include sizable(); + + --component-size: var(--ig-size, #{var-get($theme, 'default-size')}); + + min-height: sizable($action-area-height...); + display: flex; + justify-content: flex-end; + + @if $variant == 'indigo' { + padding: pad-block(rem(8px)) pad-inline(rem(16px)); + } @else { + padding: pad(rem(8px)); + } + + gap: rem(8px); + + // TODO remove this line after the override (--component-size: var(--ig-size, var(--ig-size-large)) is removed + [igxButton] { + --component-size: var(--ig-size, #{var-get($theme, 'default-size')}) !important; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/date-range-picker/_date-range-picker-component.scss b/projects/igniteui-angular/core/src/core/styles/components/date-range-picker/_date-range-picker-component.scss new file mode 100644 index 00000000000..ca100d03508 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/date-range-picker/_date-range-picker-component.scss @@ -0,0 +1,43 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Marin Popov +@mixin component { + @include b(igx-date-range-picker) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: ( + igx-calendar, + igx-input-group, + ) + ); + + @extend %igx-date-range-picker !optional; + + @include e(label) { + @extend %igx-date-range-picker__label !optional; + } + + @include e(start) { + @extend %igx-date-range-picker__start !optional; + } + + @include e(end) { + @extend %igx-date-range-picker__end !optional; + } + + @include m(cosy){ + @extend %igx-date-range-picker !optional; + } + + @include m(compact){ + @extend %igx-date-range-picker !optional; + } + } + + @include b(igx-date-range-picker-buttons) { + @extend %igx-date-range-picker-buttons !optional; + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/date-range-picker/_date-range-picker-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/date-range-picker/_date-range-picker-theme.scss new file mode 100644 index 00000000000..c1cc48bb7b6 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/date-range-picker/_date-range-picker-theme.scss @@ -0,0 +1,96 @@ +@use 'sass:map'; +@use '../../base' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin date-range-picker($theme) { + @include css-vars($theme); + $variant: map.get($theme, '_meta', 'theme'); + + %igx-date-range-picker { + @include sizable(); + --input-group-size: #{map.get($theme, 'size')}; + --component-size: var(--ig-size, #{var-get($theme, 'default-size')}); + display: flex; + + > igx-icon { + cursor: pointer; + } + + igx-input-group { + flex: 1; + } + + igx-calendar { + box-shadow: none; + } + } + + igx-date-range-start, + igx-date-range-end { + min-width: 0; + } + + igx-date-range-start, + igx-date-range-end, + %igx-date-range-picker__start, + %igx-date-range-picker__end { + --size: var(--input-group-size) !important; + + flex: 1 0 0%; + } + + %igx-date-range-picker__label { + display: flex; + align-items: center; + color: var-get($theme, 'label-color'); + margin: 0 rem(8px); + height: var(--input-group-size); + + @if $variant != 'material' { + align-self: flex-end; + + &.input-has-hint { + align-self: center; + } + } @else { + align-self: flex-start; + } + } + + %igx-date-range-picker-buttons { + display: flex; + justify-content: flex-end; + padding: 0 rem(16px) rem(16px); + + > * { + margin-inline-end: rem(8px); + + &:last-of-type { + margin-inline-end: 0; + } + } + + &:empty { + display: none; + } + } +} + +/// Adds typography styles for the igx-date-range-picker component. +/// Uses the 'caption' +/// categories from the typographic scale. +/// @group typography +/// @param {Map} $categories [(label: 'caption')] - The categories from the typographic scale used for type styles. +@mixin date-range-typography( + $categories: ( + label: 'caption', + ) +) { + $label: map.get($categories, 'label'); + + %igx-date-range-picker__label { + @include type-style($label); + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/dialog/_dialog-component.scss b/projects/igniteui-angular/core/src/core/styles/components/dialog/_dialog-component.scss new file mode 100644 index 00000000000..505ff12fb4c --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/dialog/_dialog-component.scss @@ -0,0 +1,43 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin component { + @include b(igx-dialog) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: ( + igx-button, + igx-overlay, + ) + ); + + @extend %igx-dialog-display !optional; + + @include e(window) { + @extend %igx-dialog-window !optional; + } + + @include e(window-title) { + @extend %igx-dialog-title !optional; + } + + @include e(window-content) { + @extend %igx-dialog-content !optional; + } + + @include e(window-message) { + @extend %igx-dialog-message !optional; + } + + @include e(window-actions) { + @extend %igx-dialog-actions !optional; + } + + @include m(hidden) { + @extend %igx-dialog--hidden !optional; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/dialog/_dialog-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/dialog/_dialog-theme.scss new file mode 100644 index 00000000000..b87b260406e --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/dialog/_dialog-theme.scss @@ -0,0 +1,144 @@ +@use 'sass:map'; +@use '../../base' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin dialog($theme) { + @include css-vars($theme, '.igx-dialog'); + + $variant: map.get($theme, '_meta', 'theme'); + $bootstrap-theme: map.get($theme, '_meta', 'theme') == 'bootstrap'; + + $dialog-min-width: map.get(( + 'material': rem(280px), + 'fluent': rem(288px), + 'bootstrap': rem(288px), + ), $variant); + + $dialog-title-padding: map.get(( + 'material': pad-block(rem(16px)) pad-inline(rem(24px)) 0, + 'fluent': pad-block(rem(16px)) pad-inline(rem(24px)) pad-inline(rem(24px)), + 'bootstrap': pad(rem(16px)), + 'indigo': pad-block(rem(24px)) pad-inline(rem(24px)) 0, + ), $variant); + + $dialog-message-padding: map.get(( + 'material': pad-block(rem(14px)) pad-inline(rem(24px)), + 'fluent': 0 pad-inline(rem(24px)) pad-block(rem(20px)), + 'bootstrap': pad(rem(16px)), + 'indigo': pad-block(rem(16px)) pad-inline(rem(24px)), + ), $variant); + + $dialog-actions-padding: map.get(( + 'material': 0 pad-inline(rem(8px)) pad-block(rem(8px)), + 'fluent': 0 pad-inline(rem(24px)) pad-block(rem(24px)), + 'bootstrap': pad(rem(16px)), + 'indigo': pad-block(rem(16px)) pad-inline(rem(24px)) pad-block(rem(24px)), + ), $variant); + + %igx-dialog-display { + outline-style: none; + } + + %igx-dialog--hidden { + display: none; + } + + %igx-dialog-window { + position: relative; + min-width: $dialog-min-width; + border: rem(1px) solid var-get($theme, 'border-color'); + border-radius: var-get($theme, 'border-radius'); + background: var-get($theme, 'background'); + box-shadow: var-get($theme, 'elevation'); + overflow: hidden; + + .igx-calendar { + min-width: rem(320px); + } + + .igx-calendar--vertical { + min-width: rem(496px); + } + } + + %igx-dialog-title { + display: flex; + color: var-get($theme, 'title-color'); + padding: $dialog-title-padding; + + @if $bootstrap-theme { + border-bottom: rem(1px) solid var-get($theme, 'border-color'); + } + } + + %igx-dialog-content { + color: var-get($theme, 'message-color'); + padding: $dialog-message-padding; + // The 2 rules below are related to https://github.com/IgniteUI/igniteui-angular/issues/11300 + position: relative; + z-index: 0; + } + + %igx-dialog-message { + display: inline-block; + max-width: 40ch; + + @media all and (-ms-high-contrast: none) + { + max-width: map.get(( + 'material': 62ch, + 'fluent': 48ch, + 'bootstrap': 60ch, + 'indigo': 48ch, + ), $variant); + } + } + + %igx-dialog-actions { + display: flex; + flex-flow: row nowrap; + justify-content: flex-end; + padding: $dialog-actions-padding; + gap: if($variant == 'indigo', rem(16px), rem(8px)); + + @if $bootstrap-theme { + border-top: rem(1px) solid var-get($theme, 'border-color'); + } + + @if $variant == 'indigo' { + .igx-button { + --ig-size: 2; + } + } + } +} + +/// Adds typography styles for the igx-dialog component. +/// Uses the 'h6' and 'body-1' category from the typographic scale. +/// @group typography +/// @param {Map} $categories [(title: 'h6', content: 'body-1')] - The categories from the typographic scale used for type styles. +@mixin dialog-typography($categories: ( + title: 'h6', + content: 'body-1') +) { + $title: map.get($categories, 'title'); + $content: map.get($categories, 'content'); + + %igx-dialog-title { + @include type-style($title) { + margin: 0; + } + } + + %igx-dialog-content { + @include type-style($content) { + margin: 0; + } + + > * { + letter-spacing: normal; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/divider/_divider-component.scss b/projects/igniteui-angular/core/src/core/styles/components/divider/_divider-component.scss new file mode 100644 index 00000000000..b92842072f0 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/divider/_divider-component.scss @@ -0,0 +1,33 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin component { + @include b(igx-divider) { + // Register the component in the component registry + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: () + ); + + @extend %igx-divider-display !optional; + + @include m(inset) { + @extend %igx-divider--inset !optional; + } + + @include m(dashed) { + @extend %igx-divider--dashed !optional; + } + + @include m(vertical) { + @extend %igx-divider--vertical !optional; + } + + @include mx(vertical, inset) { + @extend %igx-divider--vertical-inset !optional; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/divider/_divider-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/divider/_divider-theme.scss new file mode 100644 index 00000000000..e866e37f92b --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/divider/_divider-theme.scss @@ -0,0 +1,112 @@ +@use 'sass:map'; +@use '../../base' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin divider($theme) { + @include css-vars($theme); + + $variant: map.get($theme, '_meta', 'theme'); + + %igx-divider-display { + position: relative; + justify-content: center; + overflow: hidden; + + &::after { + content: ''; + position: absolute; + height: 100%; + width: 100%; + background: var-get($theme, 'color'); + } + } + + %igx-divider-display:not(%igx-divider--vertical) { + display: flex; + min-height: rem(1px); + min-width: rem(1px); + position: relative; + + &::after { + inset-inline-start: var-get($theme, 'inset'); + } + + &:not(%igx-divider--inset) { + &::after { + width: 100%; + } + } + } + + %igx-divider--inset:not(%igx-divider--vertical) { + &::after { + min-width: rem(4px); + width: calc(100% - (var-get($theme, 'inset') * 2)); + } + } + + %igx-divider--dashed:not(%igx-divider--vertical) { + &::after { + background: repeating-linear-gradient( + to right, + var-get($theme, 'color'), + var-get($theme, 'color') rem(10px), + transparent rem(10px), + transparent rem(20px) + ); + + @if $variant == 'indigo' { + background: repeating-linear-gradient( + to right, + var-get($theme, 'color'), + var-get($theme, 'color') rem(3px), + transparent rem(3px), + transparent rem(6px) + ); + } + } + } + + %igx-divider--dashed { + &::after { + background: repeating-linear-gradient( + to bottom, + var-get($theme, 'color'), + var-get($theme, 'color') rem(10px), + transparent rem(10px), + transparent rem(20px) + ); + + @if $variant == 'indigo' { + background: repeating-linear-gradient( + to bottom, + var-get($theme, 'color'), + var-get($theme, 'color') rem(3px), + transparent rem(3px), + transparent rem(6px) + ); + } + } + } + + %igx-divider--vertical { + display: inline-flex; + min-width: rem(1px); + width: rem(1px); + + &::after { + inset-block-start: var-get($theme, 'inset'); + width: 100%; + height: 100%; + } + } + + %igx-divider--vertical-inset { + &::after { + min-height: rem(4px); + height: calc(100% - (var-get($theme, 'inset') * 2)); + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/dock-manager/_dock-manager-component.scss b/projects/igniteui-angular/core/src/core/styles/components/dock-manager/_dock-manager-component.scss new file mode 100644 index 00000000000..8c206b5d897 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/dock-manager/_dock-manager-component.scss @@ -0,0 +1,17 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin component { + @include b(igc-dockmanager) { + // Register the component in the component registry + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: ( + igx-watermark + ) + ); + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/dock-manager/_dock-manager-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/dock-manager/_dock-manager-theme.scss new file mode 100644 index 00000000000..de98af72230 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/dock-manager/_dock-manager-theme.scss @@ -0,0 +1,18 @@ +@use 'sass:map'; +@use '../../base' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin dock-manager($theme) { + @include css-vars($theme); +} + +/// Adds typography styles for the dock manager component. +/// @access private +/// @group typography +@mixin dock-manager-typography() { + igx-dock-manager { + --igc-font-family: var(--ig-font-family, inherit); + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/drop-down/_drop-down-component.scss b/projects/igniteui-angular/core/src/core/styles/components/drop-down/_drop-down-component.scss new file mode 100644 index 00000000000..d4735debb17 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/drop-down/_drop-down-component.scss @@ -0,0 +1,66 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Marin Popov +@mixin component { + @include b(igx-drop-down) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: ( + igx-overlay, + ) + ); + + @extend %igx-drop-down !optional; + + @include e(list) { + @extend %igx-drop-down__list !optional; + } + + @include e(list-scroll) { + @extend %igx-drop-down__list-scroll !optional; + } + + @include e(item) { + @extend %igx-drop-down__item !optional; + } + + @include e(inner) { + @extend %igx-drop-down__inner !optional; + } + + @include e(content) { + @extend %igx-drop-down__content !optional; + } + + @include e(item, $m: focused) { + @extend %igx-drop-down__item--focused !optional; + } + + @include e(item, $m: selected) { + @extend %igx-drop-down__item--selected !optional; + } + + @include e(item, $mods: (selected, focused)) { + @extend %igx-drop-down__item--selected--focused !optional; + } + + @include e(item, $m: disabled) { + @extend %igx-drop-down__item--disabled !optional; + } + + @include e(header) { + @extend %igx-drop-down__header !optional; + } + + @include e(group) { + @extend %igx-drop-down__group !optional; + } + + @include e(group, $m: disabled) { + @extend %igx-drop-down__item--disabled !optional; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/drop-down/_drop-down-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/drop-down/_drop-down-theme.scss new file mode 100644 index 00000000000..abbe8002bb1 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/drop-down/_drop-down-theme.scss @@ -0,0 +1,245 @@ +@use 'sass:map'; +@use '../../base' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin drop-down($theme) { + @include css-vars($theme, '.igx-drop-down__list, .igx-grid-toolbar__dd-list'); + $variant: map.get($theme, '_meta', 'theme'); + + %igx-drop-down { + position: absolute; + } + + %igx-drop-down__list { + @include sizable(); + + --component-size: var(--ig-size, #{var-get($theme, 'default-size')}); + --dropdown-size: var(--component-size); + overflow: hidden; + border-radius: var-get($theme, 'border-radius'); + background: var-get($theme, 'background-color'); + box-shadow: var-get($theme, 'elevation'); + min-width: rem(128px); + border: var-get($theme, 'border-width') solid var-get($theme, 'border-color'); + + @if $variant == 'indigo' { + padding: pad(rem(3px)); + + %igx-drop-down__item { + margin-block: rem(2px); + + &:first-of-type, + &:last-of-type { + margin-block: initial; + } + } + + %igx-drop-down__header { + margin-block-end: rem(2px); + } + } + } + + %igx-drop-down__list-scroll { + overflow-y: auto; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; + position: relative; + + .igx-display-container--scrollbar { + padding-inline-end: var(--vhelper-scrollbar-size); + } + } + + %igx-drop-down__content { + display: flex; + width: 100%; + align-items: center; + gap: rem(8px) + } + + %igx-drop-down__inner { + display: block; + @include ellipsis(); + margin-inline-end: auto; + } + + %igx-drop-down__inner + [igxSuffix], + %igx-drop-down__inner + igx-suffix { + margin-inline-end: 0; + } + + %igx-drop-down__header, + %igx-drop-down__item { + display: flex; + justify-content: flex-start; + align-items: center; + width: 100%; + white-space: nowrap; + position: relative; + height: var-get($theme, 'size'); + + igx-divider { + position: absolute; + width: 100%; + inset-inline-start: 0; + bottom: 0; + } + + igx-icon, + igc-icon { + justify-content: center; + + @if $variant == 'indigo' { + $icon-size: sizable(rem(14px), rem(16px), rem(16px)); + + --size: #{$icon-size}; + } @else { + --component-size: 1; + } + } + } + + %igx-drop-down__item { + @include sizable(); + --component-size: var(--dropdown-size); + + color: var-get($theme, 'item-text-color'); + cursor: pointer; + padding-inline: pad-inline(rem(16px), rem(20px), rem(24px)); + border-radius: var-get($theme, 'item-border-radius'); + + igx-icon { + color: var-get($theme, 'item-icon-color'); + } + + @if $variant == 'indigo' { + padding-inline: pad-inline(rem(8px), rem(12px), rem(12px)); + } + + &:focus { + outline: 0; + outline-color: transparent; + background: var-get($theme, 'focused-item-background'); + color: var-get($theme, 'focused-item-text-color'); + } + + &:hover { + background: var-get($theme, 'hover-item-background'); + color: var-get($theme, 'hover-item-text-color'); + + igx-icon { + color: var-get($theme, 'hover-item-icon-color'); + } + } + } + + %igx-drop-down__header { + color: var-get($theme, 'header-text-color'); + pointer-events: none; + padding-inline: pad-inline(rem(8px), rem(12px), rem(16px)); + + @if $variant == 'indigo' { + padding-inline: pad-inline(rem(8px), rem(12px), rem(12px)); + padding-block: pad-inline(rem(4px), rem(6px), rem(8px)); + } + } + + %igx-drop-down__group { + pointer-events: auto; + + label { + @extend %igx-drop-down__header !optional; + } + } + + %igx-drop-down__item--focused { + background: var-get($theme, 'focused-item-background'); + color: var-get($theme, 'focused-item-text-color'); + + @if $variant == 'fluent' { + outline: rem(1px) solid var-get($theme, 'border-color'); + outline-offset: rem(-1px); + } + + @if $variant == 'indigo' { + outline: rem(2px) solid var-get($theme, 'focused-item-border-color'); + outline-offset: rem(-2px); + } + } + + %igx-drop-down__item--selected { + background: var-get($theme, 'selected-item-background'); + color: var-get($theme, 'selected-item-text-color'); + + igx-icon { + color: var-get($theme, 'selected-item-icon-color'); + } + } + + %igx-drop-down__item--selected--focused { + background: var-get($theme, 'selected-focus-item-background'); + color: var-get($theme, 'selected-focus-item-text-color'); + } + + %igx-drop-down__item--selected, + %igx-drop-down__item--selected--focused { + &:hover { + background: var-get($theme, 'selected-hover-item-background'); + color: var-get($theme, 'selected-hover-item-text-color'); + + igx-icon { + color: var-get($theme, 'selected-hover-item-icon-color'); + } + } + } + + %igx-drop-down__item--disabled { + background: var-get($theme, 'disabled-item-background'); + color: var-get($theme, 'disabled-item-text-color'); + cursor: default; + pointer-events: none; + + igx-icon { + color: var-get($theme, 'disabled-item-text-color'); + } + } +} + +/// Adds typography styles for the igx-drop-down component. +/// Uses the 'overline', 'body-2', 'subtitle-1' +/// categories from the typographic scale. +/// @group typography +/// @param {Map} $categories [(header: 'overline', item: 'body-2', select-item: 'body-2')] - The categories from the typographic scale used for type styles. +@mixin drop-down-typography( + $categories: ( + header: 'overline', + item: 'body-2', + select-item: 'body-2' + ) +) { + $header: map.get($categories, 'header'); + $item: map.get($categories, 'item'); + $select-item: map.get($categories, 'select-item'); + + %igx-drop-down__header, + %igx-drop-down__group > label { + @include type-style($header) { + margin: 0; + } + } + + %igx-drop-down__item { + @include type-style($item) { + margin: 0; + } + } + + %igx-drop-down__item--selected { + @include type-style($select-item) { + margin: 0; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/expansion-panel/_expansion-panel-component.scss b/projects/igniteui-angular/core/src/core/styles/components/expansion-panel/_expansion-panel-component.scss new file mode 100644 index 00000000000..46896ebea7a --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/expansion-panel/_expansion-panel-component.scss @@ -0,0 +1,62 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Marin Popov +@mixin component { + @include b(igx-expansion-panel) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: ( + igx-icon, + ) + ); + + @extend %igx-expansion-panel !optional; + + @include e(title-wrapper) { + @extend %igx-expansion-panel__title-wrapper !optional; + } + + @include e(header-inner) { + @extend %igx-expansion-panel__header-inner !optional; + } + + @include e(header-title) { + @extend %igx-expansion-panel__header-title !optional; + } + + @include e(header-description) { + @extend %igx-expansion-panel__header-description !optional; + } + + @include e(header-icon, $m: start) { + @extend %igx-expansion-panel__header-icon--start !optional; + } + + @include e(header-icon, $m: end) { + @extend %igx-expansion-panel__header-icon--end !optional; + } + + @include e(header-icon, $m: none) { + @extend %igx-expansion-panel__header-icon--none !optional; + } + + @include e(body) { + @extend %igx-expansion-panel__body !optional; + } + + @include m(disabled) { + @extend %igx-expansion-panel--disabled !optional; + } + + @include m(expanded) { + @extend %igx-expansion-panel--expanded !optional; + } + } + + @include b(igx-accordion) { + @extend %igx-accordion !optional; + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/expansion-panel/_expansion-panel-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/expansion-panel/_expansion-panel-theme.scss new file mode 100644 index 00000000000..fcc8daa06a4 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/expansion-panel/_expansion-panel-theme.scss @@ -0,0 +1,199 @@ +@use 'sass:map'; +@use '../../base' as *; +@use 'igniteui-theming/sass/animations/easings' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin expansion-panel($theme) { + @include css-vars($theme); + $variant: map.get($theme, '_meta', 'theme'); + + $panel-padding: pad-block(rem(16px)) pad-inline(rem(24px)); + $panel-padding-header-indigo: pad-block(rem(10px)) pad-inline(rem(16px)); + $panel-padding-body-indigo: pad-block(rem(4px)) pad-inline(rem(16px)) pad-block(rem(16px)); + + %igx-expansion-panel { + display: flex; + flex-direction: column; + border-radius: var-get($theme, 'border-radius'); + overflow: hidden; + transition: margin 350ms $out-quad; + } + + %igx-expansion-panel__header-title { + color: var-get($theme, 'header-title-color'); + margin-inline-end: rem(16px, 16px); + } + + %igx-expansion-panel__header-description { + color: var-get($theme, 'header-description-color'); + } + + %igx-expansion-panel__header-title, + %igx-expansion-panel__header-description { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + %igx-accordion { + overflow-y: auto; + + %igx-expansion-panel__header-title { + @include line-clamp(4, true, true); + + white-space: initial; + word-wrap: break-word; + } + + %igx-expansion-panel--expanded { + margin: var-get($theme, 'expanded-margin') 0; + + &:first-of-type { + margin-top: 0; + } + + &:last-of-type { + margin-bottom: 0; + } + } + } + + %igx-expansion-panel__header-inner { + display: flex; + align-items: center; + padding: $panel-padding; + cursor: pointer; + background: var-get($theme, 'header-background'); + + &:focus, + &:active + { + background: var-get($theme, 'header-focus-background'); + outline: transparent; + } + + @if $variant == 'indigo' { + padding: $panel-padding-header-indigo; + } + } + + %igx-expansion-panel__title-wrapper { + display: flex; + flex-direction: column; + justify-content: center; + flex: 1 0 0%; + overflow: hidden; + + @if $variant == 'indigo' { + %igx-expansion-panel__header-title { + margin-block-end: rem(2px); + } + } + } + + %igx-expansion-panel__header-icon--end { + order: 1; + margin-inline-start: rem(16px, 16px); + + @if $variant == 'indigo' { + margin-inline-start: rem(8px, 16px); + } + } + + %igx-expansion-panel__header-icon--start { + order: -1; + margin-inline-end: rem(16px, 16px); + + @if $variant == 'indigo' { + margin-inline-end: rem(8px, 16px); + } + } + + %igx-expansion-panel__header-icon--none { + display: none; + } + + %igx-expansion-panel__header-icon--end, + %igx-expansion-panel__header-icon--start { + display: flex; + align-content: center; + justify-content: center; + user-select: none; + + color: var-get($theme, 'header-icon-color'); + + igx-icon { + --component-size: 3; + color: var-get($theme, 'header-icon-color'); + } + + @if $variant == 'indigo' { + igx-icon { + --component-size: 2; + } + } + } + + %igx-expansion-panel__body { + color: var-get($theme, 'body-color'); + background: var-get($theme, 'body-background'); + overflow: hidden; + padding: $panel-padding; + + @if $variant == 'indigo' { + padding: $panel-padding-body-indigo; + } + } + + %igx-expansion-panel--disabled { + pointer-events: none; + + %igx-expansion-panel__header-title { + color: var-get($theme, 'disabled-text-color') + } + + %igx-expansion-panel__header-description { + color: var-get($theme, 'disabled-description-color') + } + + %igx-expansion-panel__header-icon--start, + %igx-expansion-panel__header-icon--end { + igx-icon { + color: var-get($theme, 'disabled-text-color'); + } + } + } +} + +/// Adds typography styles for the igx-expansion-panel component. +/// @group typography +/// @param {Map} $categories [(title: 'h5', description: 'subtitle-2', body: 'body-2')] - The categories from the typographic scale used for type styles. +@mixin expansion-panel-typography($categories: ( + title: 'h5', + description: 'subtitle-2', + body: 'body-2') +) { + $title: map.get($categories, 'title'); + $description: map.get($categories, 'description'); + $body: map.get($categories, 'body'); + + %igx-expansion-panel__header-title { + @include type-style($title) { + margin: 0; + } + } + + %igx-expansion-panel__header-description { + @include type-style($description) { + margin: 0; + } + } + + %igx-expansion-panel__body { + @include type-style($body) { + margin: 0; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/grid-summary/_grid-summary-component.scss b/projects/igniteui-angular/core/src/core/styles/components/grid-summary/_grid-summary-component.scss new file mode 100644 index 00000000000..e38475d869a --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/grid-summary/_grid-summary-component.scss @@ -0,0 +1,51 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Marin Popov +@mixin component { + @include b(igx-grid-summary) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: ( + igx-icon, + ) + ); + + @extend %igx-grid-summary !optional; + + @include e(item) { + @extend %igx-grid-summary__item !optional; + } + + @include e(label) { + @extend %igx-grid-summary__label !optional; + } + + @include e(result) { + @extend %igx-grid-summary__result !optional; + } + + @include m(pinned) { + @extend %igx-grid-summary--pinned !optional; + } + + @include m(pinned-last) { + @extend %igx-grid-summary--pinned-last !optional; + } + + @include m(pinned-first) { + @extend %igx-grid-summary--pinned-first !optional; + } + + // TODO check if we need to implement styling for .igx-grid-summary--empty selector + @include m(empty) { + @extend %igx-grid-summary--empty !optional; + } + + @include m(fw) { + @extend %grid-summary--fixed-width !optional; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/grid-summary/_grid-summary-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/grid-summary/_grid-summary-theme.scss new file mode 100644 index 00000000000..716d32d1ffc --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/grid-summary/_grid-summary-theme.scss @@ -0,0 +1,124 @@ +@use 'sass:map'; +@use '../../base' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin grid-summary($theme) { + @include css-vars($theme, '.igx-grid-summary'); + + $variant: map.get($theme, '_meta', 'theme'); + + $cell-pin: ( + style: var-get($theme, 'pinned-border-width') var-get($theme, 'pinned-border-style'), + color: var-get($theme, 'pinned-border-color') + ); + + $item-padding-block: ( + comfortable: rem(6px), + cosy: rem(2px), + compact: 0 + ); + + $summary-padding-inline: ( + comfortable: rem(24px), + cosy: rem(16px), + compact: rem(12px) + ); + + %igx-grid-summary { + position: relative; + display: flex; + flex-direction: column; + flex: 1 1 0%; + padding-block: 0; + + @if $variant != 'indigo' { + padding-inline: pad-inline(map.get($summary-padding-inline, 'compact'), map.get($summary-padding-inline, 'cosy'), map.get($summary-padding-inline, 'comfortable')); + } @else { + padding-inline: pad-inline(rem(8px), rem(12px), rem(16px)); + } + + background: var-get($theme, 'background-color', inherit); + overflow: hidden; + outline-style: none; + + @if $variant == 'indigo' { + border-top: rem(1px) solid var-get($theme, 'border-color'); + } + } + + %igx-grid-summary--pinned { + position: relative; + z-index: 1; + } + + %grid-summary--fixed-width { + flex-grow: 0; + } + + %igx-grid-summary--pinned-last { + border-inline-end: map.get($cell-pin, 'style') map.get($cell-pin, 'color'); + @media print { + border-inline-end: map.get($cell-pin, 'style') #999; + } + } + + %igx-grid-summary--pinned-first { + border-inline-start: map.get($cell-pin, 'style') map.get($cell-pin, 'color'); + @media print { + border-inline-start: map.get($cell-pin, 'style') #999; + } + } + + %igx-grid-summary__item { + display: flex; + align-items: center; + padding-block: pad(map.get($item-padding-block, 'compact'), map.get($item-padding-block, 'cosy'), map.get($item-padding-block, 'comfortable')); + padding-inline: 0; + + @if $variant != 'indigo' { + font-size: rem(12px); + } @else { + min-height: sizable(rem(24px), rem(30px), rem(36px)); + } + + position: relative; + } + + %igx-grid-summary__label { + color: var-get($theme, 'label-color'); + min-width: rem(30px); + margin-inline-end: rem(3px); + + @if $variant == 'indigo' { + @include type-style('caption'); + + margin-inline-end: initial; + } + + &:hover { + color: var-get($theme, 'label-hover-color'); + } + } + + %igx-grid-summary__result { + @if $variant == 'indigo' { + @include type-style('detail-2', false); + } + + color: var-get($theme, 'result-color'); + + @if $variant != 'indigo' { + font-weight: 600; + } + + flex: 1 1 auto; + text-align: end; + } + + %igx-grid-summary__label, + %igx-grid-summary__result { + @include ellipsis(); + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/grid-toolbar/_grid-toolbar-component.scss b/projects/igniteui-angular/core/src/core/styles/components/grid-toolbar/_grid-toolbar-component.scss new file mode 100644 index 00000000000..1b25fd7bca9 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/grid-toolbar/_grid-toolbar-component.scss @@ -0,0 +1,53 @@ +@use '../../base' as *; +@use 'sass:string'; + +//// +/// @access private +/// @author Marin Popov +//// + +@mixin component { + @include b(igx-grid-toolbar) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: ( + igx-button, + igx-column-actions, + igx-icon, + igx-progress-linear, + igx-drop-down + ) + ); + + @extend %igx-grid-toolbar !optional; + + @include e(title){ + @extend %igx-grid-toolbar__title !optional; + } + + @include e(custom-content){ + @extend %igx-grid-toolbar__custom-content !optional; + } + + @include e(actions){ + @extend %igx-grid-toolbar__actions !optional; + } + + @include e(progress-bar){ + @extend %igx-grid-toolbar__progress-bar !optional; + } + + @include e(dropdown){ + @extend %igx-grid-toolbar__dropdown !optional; + } + + @include e(dd-list-items){ + @extend %igx-grid-toolbar__dd-list-items !optional; + } + + @include e(dd-list){ + @extend %igx-grid-toolbar__dd-list !optional; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/grid-toolbar/_grid-toolbar-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/grid-toolbar/_grid-toolbar-theme.scss new file mode 100644 index 00000000000..dcc63f3d22c --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/grid-toolbar/_grid-toolbar-theme.scss @@ -0,0 +1,222 @@ +@use 'sass:map'; +@use '../../base' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin grid-toolbar($theme) { + @include css-vars($theme); + + $variant: map.get($theme, '_meta', 'theme'); + $bootstrap-theme: $variant == 'bootstrap'; + + // Caption + $grid-toolbar-fs: rem(16px); + + $grid-toolbar-padding-start: ( + comfortable: rem(24px), + cosy: rem(16px), + compact: rem(12px) + ); + + $grid-toolbar-padding-end: ( + comfortable: rem(16px), + cosy: rem(12px), + compact: rem(8px) + ); + + $grid-toolbar-padding-start-indigo: ( + comfortable: rem(24px), + cosy: rem(16px), + compact: rem(12px) + ); + + $grid-toolbar-padding-end-indigo: ( + comfortable: rem(24px), + cosy: rem(16px), + compact: rem(12px) + ); + + + $grid-toolbar-spacer: ( + comfortable: rem(16px), + cosy: rem(12px), + compact: rem(8px) + ); + + $grid-toolbar-spacer-indigo: ( + comfortable: rem(16px), + cosy: rem(16px), + compact: rem(16px) + ); + + $grid-toolbar-height: ( + comfortable: rem(58px), + cosy: rem(52px), + compact: rem(44px) + ); + + %igx-grid-toolbar { + @include sizable(); + --component-size: var(--ig-size, var(--ig-size-large)); + position: relative; + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + grid-row: 1; + font-size: $grid-toolbar-fs; + border-bottom: rem(1px) solid var-get($theme, 'border-color'); + background: var-get($theme, 'background-color'); + height: auto; + min-height: var-get($theme, 'size'); + padding-block: 0; + + @if $variant == 'indigo' { + padding-inline-start: pad-inline(map.get($grid-toolbar-padding-start-indigo, 'compact'), map.get($grid-toolbar-padding-start-indigo, 'cosy'), map.get($grid-toolbar-padding-start-indigo, 'comfortable')); + padding-inline-end: pad-inline(map.get($grid-toolbar-padding-end-indigo, 'compact'), map.get($grid-toolbar-padding-end-indigo, 'cosy'), map.get($grid-toolbar-padding-end-indigo, 'comfortable')); + } @else { + padding-inline-start: pad-inline(map.get($grid-toolbar-padding-start, 'compact'), map.get($grid-toolbar-padding-start, 'cosy'), map.get($grid-toolbar-padding-start, 'comfortable')); + padding-inline-end: pad-inline(map.get($grid-toolbar-padding-end, 'compact'), map.get($grid-toolbar-padding-end, 'cosy'), map.get($grid-toolbar-padding-end, 'comfortable')); + } + + [igxButton] { + margin-inline-start: pad-inline(map.get($grid-toolbar-spacer, 'compact'), map.get($grid-toolbar-spacer, 'cosy'), map.get($grid-toolbar-spacer, 'comfortable')); + + &:first-of-type { + margin-inline-start: 0; + } + + &:last-of-type { + margin-inline-end: 0; + } + } + + &[dir='rtl'] { + text-align: end; + + [igxButton] { + margin-inline-start: 0; + margin-inline-end: rem(8); + + &:last-child { + margin-inline-end: 0; + } + } + } + } + + %igx-grid-toolbar__title { + @if $variant == 'indigo' { + /* stylelint-disable scss/at-extend-no-missing-placeholder */ + @extend .ig-typography__h6; + /* stylelint-enable scss/at-extend-no-missing-placeholder */ + } + + color: var-get($theme, 'title-text-color'); + flex: 1 1 auto; + @include ellipsis(); + max-width: 40ch; + + @if $variant == 'indigo' { + margin-inline-end: map.get($grid-toolbar-spacer-indigo, 'comfortable') + } @else { + margin-inline-end: map.get($grid-toolbar-spacer, 'comfortable') + } + } + + %igx-grid-toolbar__custom-content { + display: flex; + flex-wrap: wrap; + flex-grow: 1; + justify-content: flex-end; + + @if $variant == 'indigo' { + margin-inline-end: map.get($grid-toolbar-spacer-indigo, 'comfortable') + } @else { + margin-inline-end: map.get($grid-toolbar-spacer, 'comfortable') + } + } + + %igx-grid-toolbar__actions { + display: flex; + align-items: center; + flex-flow: row wrap; + margin-inline-start: auto; + + @if $variant == 'indigo' { + gap: map.get($grid-toolbar-spacer-indigo, 'comfortable') + } @else { + gap: map.get($grid-toolbar-spacer, 'comfortable') + } + + > * { + display: flex; + } + } + + %igx-grid-toolbar__actions, + %igx-grid-toolbar__title, + %igx-grid-toolbar__custom-content { + &:empty { + display: none; + } + } + + %igx-grid-toolbar__title:empty + %igx-grid-toolbar__custom-content:empty { + + %igx-grid-toolbar__actions { + width: 100%; + margin-inline-start: 0; + justify-content: flex-end; + } + } + + %igx-grid-toolbar__progress-bar { + position: absolute; + width: 100%; + inset-inline-start: 0; + inset-inline-end: 0; + bottom: rem(-1px); + height: rem(2px); + overflow: hidden; + background: var-get($theme, 'background-color'); + + igx-linear-bar > * { + border-radius: 0; + + &:first-child > div { + background: color($color: 'secondary'); + } + } + } + + %igx-grid-toolbar__dropdown { + position: relative; + } + + %igx-grid-toolbar__dd-list { + list-style: none; + background: var-get($theme, 'dropdown-background'); + margin: 0; + padding: 0; + box-shadow: elevation(8); + } + + %igx-grid-toolbar__dd-list-items { + cursor: pointer; + position: relative; + padding: rem(8px) rem(16px); + color: var-get($theme, 'item-text-color'); + white-space: nowrap; + + &:hover { + background: var-get($theme, 'item-hover-background'); + color: var-get($theme, 'item-hover-text-color'); + } + + &:focus { + background: var-get($theme, 'item-focus-background'); + color: var-get($theme, 'item-focus-text-color'); + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/grid/_advanced-filtering-component.scss b/projects/igniteui-angular/core/src/core/styles/components/grid/_advanced-filtering-component.scss new file mode 100644 index 00000000000..138319fa3fc --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/grid/_advanced-filtering-component.scss @@ -0,0 +1,9 @@ +@use '../../base' as *; + +/// @access private +/// @author Simeon Simeonoff +@mixin advanced-filtering-partial { + @include b(igx-advanced-filter) { + @extend %advanced-filtering-dialog !optional; + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/grid/_excel-filtering-component.scss b/projects/igniteui-angular/core/src/core/styles/components/grid/_excel-filtering-component.scss new file mode 100644 index 00000000000..3e2c91e373a --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/grid/_excel-filtering-component.scss @@ -0,0 +1,182 @@ +@use '../../base' as *; + +/// @access private +/// @author Simeon Simeonoff +@mixin excel-filtering-partial { + @include b(igx-excel-filter) { + @extend %grid-excel-filter !optional; + + @include e(loading) { + @extend %igx-excel-filter__loading !optional; + } + + @include e(sizing) { + @extend %igx-excel-filter__sizing !optional; + } + + @include e(tree) { + @extend %igx-excel-filter__tree !optional; + } + + @include e(empty) { + @extend %igx-excel-filter__empty !optional; + } + + @include e(tree-alike) { + @extend %igx-excel-filter__tree-alike !optional; + } + + @include e(tree-alike-item) { + @extend %igx-excel-filter__tree-alike-item !optional; + } + + @include e(menu) { + @include sizable(); + @extend %grid-excel-menu !optional; + } + + @include e(menu, $m: 'cosy') { + @extend %grid-excel-menu--cosy !optional; + } + + @include e(menu, $m: 'compact') { + @extend %grid-excel-menu--compact !optional; + } + + @include e(icon) { + @extend %grid-excel-icon !optional; + } + + @include e(icon, $m: 'filtered') { + @extend %grid-excel-icon !optional; + @extend %grid-excel-icon--filtered !optional; + } + + @include e(menu-header) { + @extend %grid-excel-menu__header !optional; + } + + @include e(menu-header-actions) { + @extend %grid-excel-menu__header-actions !optional; + } + + @include e(menu-main) { + @extend %grid-excel-main !optional; + } + + @include e(menu-footer) { + @extend %grid-excel-menu__footer !optional; + } + + @include e(sort) { + @extend %grid-excel-sort !optional; + } + + @include e(move) { + @extend %grid-excel-move !optional; + } + + @include e(actions) { + @extend %grid-excel-actions !optional; + } + + @include e(actions-pin) { + @extend %grid-excel-actions__action !optional; + } + + @include e(actions-pin, $m: disabled) { + @extend %grid-excel-actions__action !optional; + @extend %grid-excel-actions__action--disabled !optional; + } + + @include e(actions-unpin) { + @extend %grid-excel-actions__action !optional; + } + + @include e(actions-hide) { + @extend %grid-excel-actions__action !optional; + } + + @include e(actions-select) { + @extend %grid-excel-actions__action !optional; + } + + @include e(actions-selected) { + @extend %grid-excel-actions__action !optional; + @extend %grid-excel-actions--selected !optional; + } + + @include e(actions-filter) { + @extend %grid-excel-actions__action !optional; + @extend %grid-excel-actions__action-filter !optional; + } + + @include e(actions-filter, $m: active) { + @extend %grid-excel-actions__action !optional; + @extend %grid-excel-actions__action--active !optional; + } + + @include e(actions-clear) { + @extend %grid-excel-actions__action !optional; + } + + @include e(actions-clear, $m: disabled) { + @extend %grid-excel-actions__action !optional; + @extend %grid-excel-actions__action--disabled !optional; + } + + @include e(secondary) { + @extend %grid-excel-menu__secondary !optional; + } + + @include e(secondary, $m: 'cosy') { + @extend %grid-excel-menu__secondary--cosy !optional; + } + + @include e(secondary, $m: 'compact') { + @extend %grid-excel-menu__secondary--compact !optional; + } + + @include e(secondary-header) { + @extend %grid-excel-menu__header !optional; + @extend %grid-excel-menu__secondary-header !optional; + } + + @include e(secondary-main) { + @extend %grid-excel-menu__secondary-main !optional; + } + + @include e(secondary-footer) { + @extend %grid-excel-menu__footer !optional; + @extend %grid-excel-menu__secondary-footer !optional; + } + + @include e(condition) { + @extend %grid-excel-menu__condition !optional; + } + + @include e(add-filter) { + @extend %grid-excel-menu__add-filter !optional; + } + + @include e(clear) { + @extend %grid-excel-filter__clear !optional; + } + + @include e(cancel) { + @extend %grid-excel-filter__cancel !optional; + } + + @include e(apply) { + @extend %grid-excel-filter__apply !optional; + } + + @include m(inline) { + @extend %grid-excel-filter--inline !optional; + } + + @include e(filter-results) { + @extend %grid-excel-menu__filter-results !optional; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/grid/_excel-filtering-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/grid/_excel-filtering-theme.scss new file mode 100644 index 00000000000..44063a0fbaf --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/grid/_excel-filtering-theme.scss @@ -0,0 +1,821 @@ +@use 'sass:map'; +@use '../../base' as *; +@use '../button-group/button-group-component' as *; +@use '../button-group/button-group-theme' as *; +@use '../tree/tree-theme' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The grid theme used to style the component. +@mixin excel-filtering($theme) { + $variant: map.get($theme, '_meta', 'variant'); + $theme-variant: map.get($theme, '_meta', 'theme-variant'); + $bootstrap-theme: $variant == 'bootstrap'; + + $tree-node-indent: ( + comfortable: rem(16px), + cosy: rem(8px), + compact: rem(4px) + ); + + $tree-node-expander-size: rem(20px); + + $tree-node-height: ( + comfortable: rem(40px), + cosy: rem(32px), + compact: rem(24px) + ); + + $checkbox-indent: ( + comfortable: calc(#{map.get($tree-node-indent, 'comfortable')} + #{$tree-node-expander-size} + #{rem(8px)}), + cosy: calc((#{map.get($tree-node-indent, 'cosy')} * 2) + #{$tree-node-expander-size}), + compact: calc((#{map.get($tree-node-indent, 'compact')} * 2) + #{$tree-node-expander-size}), + ); + + %grid-excel-filter { + --component-size: var(--ig-size, var(--ig-size-large)); + display: block; + width: rem(320px); + height: 100%; + flex-grow: 1; + + @if $variant == 'indigo' { + box-shadow: elevation(if($theme-variant == 'light', 3, 2)), 0 0 0 rem(1px) color(null, 'gray', if($theme-variant == 'light', 400, 100)); + + // TODO: The border-radius should not be hardcoded. + border-radius: border-radius(rem(4px)); + } @else { + box-shadow: elevation(12); + } + + overflow: auto; + min-width: rem(320px); + + } + + %igx-excel-filter__sizing { + @include sizable(); + + min-height: sizable( + rem(330px), + rem(465px), + rem(645px) + ); + max-height: sizable( + rem(405px), + rem(565px), + rem(775px) + ); + } + + %grid-excel-filter--inline { + box-shadow: none; + width: 100%; + } + + %igx-excel-filter__loading { + display: flex; + justify-content: center; + align-items: center; + } + + %grid-excel-icon { + display: flex; + cursor: pointer; + + @if $variant == 'indigo' { + opacity: if($theme-variant == 'light', .75, .85); + + &:hover { + opacity: 1; + } + } + + @if $variant != 'indigo' { + igx-icon { + --size: var(--igx-icon-size, #{rem(15px)}); + } + } + } + + %grid-excel-icon--filtered { + opacity: 1; + + igx-icon { + color: if($variant == 'indigo', color($color: 'primary', $variant: 500), color($color: 'secondary')); + } + + @if $variant == 'indigo' { + &:hover { + igx-icon { + color: color($color: 'primary', $variant: 400); + } + } + } + } + + %grid-excel-menu { + --component-size: var(--ig-size, var(--ig-size-large)); + + display: flex; + flex-direction: column; + height: 100%; + + // TODO: The border-radius should not be hardcoded. + border-radius: border-radius(rem(4px)); + + @if $variant != 'indigo' { + background: var-get($theme, 'filtering-row-background'); + + %igx-group-display { + --shadow: none; + --item-background: #{var-get($theme, 'filtering-row-background')}; + --item-hover-background: #{color($color: 'gray', $variant: 100)}; + --item-selected-background: #{color($color: 'gray', $variant: 100)}; + --item-text-color: #{color($color: 'gray', $variant: 700)}; + --item-icon-color: #{color($color: 'gray', $variant: 700)}; + --item-hover-text-color: #{color($color: 'gray', $variant: 800)}; + --item-hover-icon-color: #{color($color: 'gray', $variant: 800)}; + --item-selected-text-color: #{if( + $variant == 'indigo', + contrast-color($color: 'surface'), + color($color: 'secondary', $variant: 500) + )}; + --item-selected-icon-color: #{if( + $variant == 'indigo', + contrast-color($color: 'surface'), + color($color: 'secondary', $variant: 500) + )}; + --item-selected-hover-icon-color: #{if( + $variant == 'fluent', + color($color: 'secondary', $variant: 500), + contrast-color($color: 'gray', $variant: 50) + )}; + --item-border-color: transparent; + --item-hover-border-color: transparent; + --item-focused-border-color: #{if( + $variant == 'fluent', + color($color: 'gray', $variant: 700), + transparent + )}; + --item-selected-border-color: transparent; + --item-selected-hover-border-color: transparent; + --item-disabled-border: transparent; + --disabled-selected-border-color: transparent; + } + } @else { + @if $theme-variant == 'light' { + background: contrast-color($color: 'gray', $variant: 900); + } @else { + background: color($color: 'surface', $variant: 500); + } + + %igx-group-display { + --item-background: transparent; + --border-color: transparent; + --item-border-color: transparent; + --item-focused-border-color: transparent; + --item-hover-border-color: transparent; + --item-selected-border-color: transparent; + --item-selected-hover-border-color: transparent; + --item-disabled-border: transparent; + --disabled-selected-border-color: transparent; + --shadow: none; + } + + %igx-group-item { + &:not(:nth-child(1)) { + margin: 0; + } + } + } + + @include tree(tree-theme( + $background: color($color: 'surface'), + $background-selected: color($color: 'surface'), + $background-active: color($color: 'surface'), + $background-active-selected: color($color: 'surface'), + $foreground: contrast-color($color: 'surface'), + $foreground-selected: contrast-color($color: 'surface'), + $foreground-active: contrast-color($color: 'surface'), + $foreground-active-selected: contrast-color($color: 'surface'), + )); + + .igx-tree-node__wrapper { + padding: 0; + } + + igx-chips-area { + padding-inline: pad-inline(rem(4px), rem(8px), rem(16px)); + padding-block-start: pad-block(rem(4px), rem(8px), rem(16px)); + padding-block-end: 0; + gap: sizable(rem(4px), rem(4px), rem(8px)); + } + } + + %grid-excel-menu__header { + display: flex; + align-items: center; + + @if $variant == 'indigo' { + padding: pad-block(rem(16px)) pad-inline(rem(16px)) pad-block(sizable(rem(8px), rem(12px), rem(16px))); + } @else { + padding: pad(rem(4px), rem(8px), rem(16px)); + } + + color: var-get($theme, 'excel-filtering-header-foreground'); + } + + @if $variant == 'indigo' { + .ig-typography %grid-excel-menu--compact { + %grid-excel-menu__header { + > h4 { + @include type-style('h6') + } + } + } + } + + %grid-excel-menu__header-actions { + display: flex; + margin-inline-start: auto; + + [igxButton] + [igxButton] { + margin-inline-start: rem(4px); + } + + %grid-excel-actions__action { + padding: 0 !important; + margin: 0 !important; + } + + %grid-excel-actions__action, + %grid-excel-actions--selected { + justify-content: center; + } + } + + %grid-excel-menu__footer { + display: flex; + justify-content: space-between; + + %grid-excel-filter__apply, + %grid-excel-filter__cancel { + flex-grow: 1; + + [igxButton] { + width: 100%; + } + } + } + + %grid-excel-filter__clear { + flex-grow: 1; + } + + %grid-excel-filter__cancel + %grid-excel-filter__apply { + margin-inline-start: rem(16px); + } + + %grid-excel-sort, + %grid-excel-move { + display: block; + + @if $variant == 'indigo' { + padding-inline: pad-inline(rem(16px)); + } @else { + padding-block: pad-block(rem(4px), rem(8px), rem(8px)); + padding-inline: pad-inline(rem(4px), rem(8px), rem(16px)); + } + + header { + color: var-get($theme, 'excel-filtering-subheader-foreground'); + + @if $variant == 'indigo' { + margin-block-end: sizable(rem(0), rem(4px), rem(4px)) !important; + text-transform: capitalize !important; + } @else { + margin-block-end: rem(4px); + } + } + + igx-icon { + --component-size: #{if($variant == 'indigo', 2, 1)}; + + display: initial; + width: var(--size) !important; + height: var(--size) !important; + font-size: var(--size) !important; + } + } + + @if $variant == 'indigo' { + %grid-excel-move { + margin-block-end: sizable(rem(12px), rem(16px), rem(16px)); + } + + %grid-excel-sort + %grid-excel-move { + margin-block-start: sizable(rem(4px), rem(8px), rem(8px)); + } + + %grid-excel-sort { + padding-block-end: 0; + } + + %grid-excel-actions__action { + span { + @include type-style('body-2'); + } + } + } + + %grid-excel-action--compact { + display: flex; + align-items: center; + justify-content: space-between; + + header { + margin-inline-end: auto; + } + } + + %grid-excel-actions { + padding-block: pad-block(rem(4px), rem(8px), rem(8px)); + padding-inline: pad-inline(rem(4px), rem(16px), rem(16px)); + } + + %grid-excel-actions--selected { + igx-icon { + color: if( + $variant == 'indigo', + color($color: 'primary', $variant: 200), + color($color: 'secondary') + ); + } + } + + %grid-excel-move .igx-button-group { + [dir='rtl'] & { + flex-direction: row-reverse; + + igx-icon, + [igxButton] { + direction: ltr; + } + } + } + + %grid-excel-sort .igx-button-group { + [dir='rtl'] & { + flex-direction: row-reverse; + + igx-icon, + [igxButton] { + direction: ltr; + } + } + } + + %grid-excel-actions__action { + display: flex; + align-items: center; + justify-content: space-between; + + @if $variant == 'indigo' { + padding-block: pad-block(rem(6px)); + padding-inline: pad-inline(rem(12px)); + margin-inline: rem(8px); + margin-block-end: rem(4px); + border-radius: rem(4px); + } @else { + padding-block: pad-block(rem(4px), rem(8px), rem(8px)); + padding-inline: pad-inline(rem(4px), rem(8px), rem(16px)); + } + + cursor: pointer; + color: var-get($theme, 'excel-filtering-actions-foreground'); + outline-style: none; + + &:hover, + &:focus { + color: var-get($theme, 'excel-filtering-actions-hover-foreground'); + + @if $variant == 'indigo' { + @if $theme-variant == 'light' { + background: color($color: 'gray', $variant: 200); + } @else { + background: contrast-color($color: 'gray', $variant: 50, $opacity: .1); + } + + igx-icon { + /* stylelint-disable max-nesting-depth */ + @if $theme-variant == 'light' { + color: color($color: 'gray', $variant: 700); + } @else { + color: contrast-color($color: 'gray', $variant: 50, $opacity: .8); + } + /* stylelint-enable max-nesting-depth */ + } + } @else { + background: color($color: 'gray', $variant: 100); + } + } + + @if $variant == 'indigo' { + igx-icon { + --component-size: 2; + + @if $theme-variant == 'light' { + color: color($color: 'gray', $variant: 600); + } @else { + color: contrast-color($color: 'gray', $variant: 50, $opacity: .6); + } + } + } + + [dir='rtl'] & { + igx-icon { + transform: scaleX(-1); + } + } + } + + @if $variant == 'indigo' { + %grid-excel-actions__action-filter { + margin-block-end: 0; + } + } + + %grid-excel-actions__action--active { + background: color($color: 'gray', $variant: 100); + color: var-get($theme, 'excel-filtering-actions-hover-foreground'); + } + + %grid-excel-actions__action--disabled { + color: var-get($theme, 'excel-filtering-actions-disabled-foreground'); + pointer-events: none; + + @if $variant == 'indigo' { + igx-icon { + color: var-get($theme, 'excel-filtering-actions-disabled-foreground'); + } + } + } + + %igx-excel-filter__empty { + display: grid; + place-items: center; + height: 100%; + } + + %grid-excel-main { + display: flex; + flex-direction: column; + flex-grow: 1; + overflow: hidden; + + @if $variant == 'indigo' { + padding: pad(rem(16px)); + gap: sizable(rem(16px)); + } @else { + padding: pad(rem(4px), rem(8px), rem(16px)); + gap: sizable(rem(4px), rem(8px), rem(16px)); + } + + %igx-list { + flex-grow: 1; + overflow: hidden; + + @if $variant == 'indigo' { + --background: #{color($color: 'surface', $variant: 500)}; + + margin-inline: calc(sizable(rem(-16px)) * -1); + + // This is the only way to take the gap from the list, + // otherwise we have to hardcoded here + > div { + gap: inherit; + } + + igx-display-container { + display: flex; + flex-direction: column; + gap: inherit; + padding: pad(rem(8px)); + } + } @else { + margin-inline: calc(sizable(rem(-4px), rem(-8px), rem(-16px)) * -1); + } + + border: 0; + + @if $bootstrap-theme { + border-top: rem(1px) dashed color($color: 'gray', $variant: 100); + border-bottom: rem(1px) dashed color($color: 'gray', $variant: 100); + } @else { + border-top: rem(1px) dashed color($color: 'gray', $variant: 300); + border-bottom: rem(1px) dashed color($color: 'gray', $variant: 300); + } + + @if $variant == 'indigo' and $theme-variant == 'dark' { + border-top: rem(1px) dashed color($color: 'gray', $variant: 100); + border-bottom: rem(1px) dashed color($color: 'gray', $variant: 100); + } + } + + %igx-excel-filter__tree { + background: color($color: 'surface'); + overflow-y: auto; + margin-inline: calc(pad-inline(rem(-4px), rem(-8px), rem(-16px)) * -1); + margin-block: 0; + flex: 1; + + @if $bootstrap-theme { + border-top: rem(1px) dashed color($color: 'gray', $variant: 100); + border-bottom: rem(1px) dashed color($color: 'gray', $variant: 100); + } @else { + border-top: rem(1px) dashed color($color: 'gray', $variant: 300); + border-bottom: rem(1px) dashed color($color: 'gray', $variant: 300); + } + + @if $variant == 'indigo' and $theme-variant == 'dark' { + border-top: rem(1px) dashed color($color: 'gray', $variant: 100); + border-bottom: rem(1px) dashed color($color: 'gray', $variant: 100); + } + + igx-icon { + width: var(--igx-icon-size, #{$tree-node-expander-size}); + height: var(--igx-icon-size, #{$tree-node-expander-size}); + font-size: var(--igx-icon-size, #{$tree-node-expander-size}); + } + + > igx-checkbox, + .igx-tree-node__wrapper { + height: #{sizable( + map.get($tree-node-height, 'compact'), + map.get($tree-node-height, 'cosy'), + map.get($tree-node-height, 'comfortable') + )}; + min-height: #{sizable( + map.get($tree-node-height, 'compact'), + map.get($tree-node-height, 'cosy'), + map.get($tree-node-height, 'comfortable') + )}; + } + + .igx-tree-node__toggle-button { + min-width: rem(20px); + margin-inline-start: pad-inline( + map.get($tree-node-indent, 'compact'), + map.get($tree-node-indent, 'cosy'), + map.get($tree-node-indent, 'comfortable') + ); + margin-inline-end: pad-inline(rem(4px), rem(8px)); + } + + .igx-tree { + overflow-y: hidden; + } + } + + %igx-excel-filter__tree-alike { + background: color($color: 'surface'); + display: flex; + flex-direction: column; + z-index: 1; + } + + %igx-excel-filter__tree-alike-item { + display: flex; + align-items: center; + height: sizable( + map.get($tree-node-height, 'compact'), + map.get($tree-node-height, 'cosy'), + map.get($tree-node-height, 'comfortable') + ); + background: color($color: 'surface'); + + &:hover, + &:focus { + background: color($color: 'gray', $variant: 200); + } + + > igx-checkbox { + margin-inline-start: pad-inline( + map.get($checkbox-indent, 'compact'), + map.get($checkbox-indent, 'cosy'), + map.get($checkbox-indent, 'comfortable') + ); + } + } + } + + %grid-excel-menu--cosy { + + %grid-excel-menu__header { + justify-content: space-between; + } + } + + %grid-excel-menu--compact { + + %grid-excel-menu__header { + justify-content: space-between; + } + + %grid-excel-sort, + %grid-excel-move { + @extend %grid-excel-action--compact; + + igx-buttongroup { + width: rem(208px); + } + } + + @if $variant != 'indigo' { + %grid-excel-move { + margin-bottom: 0; + } + } + } + + %grid-excel-menu__secondary { + width: rem(520px); + min-width: rem(520px); + background: var-get($theme, 'filtering-row-background'); + box-shadow: elevation(12); + border-radius: border-radius(rem(4px)); + } + + %grid-excel-menu__condition { + display: flex; + flex-wrap: wrap; + align-items: center; + + @if $variant == 'indigo' { + padding-inline: pad-inline(rem(16px)); + } @else { + padding-inline: pad-inline(rem(4px), rem(8px), rem(16px)); + } + + padding-block: 0; + + igx-select { + flex-grow: 1; + flex-basis: 40%; + margin: rem(16px) 0; + + ~ igx-input-group, + ~ igx-date-picker, + ~ igx-time-picker { + margin-inline-start: rem(16px); + } + } + + igx-buttongroup { + min-width: 30%; + } + + [igxIconButton] { + --component-size: var(--grid-size); + margin-inline-start: rem(16px); + } + } + + %grid-excel-menu__add-filter { + margin-inline: pad-inline(rem(4px), rem(4px), rem(16px)); + margin-block-start: 0; + + @if $bootstrap-theme { + // important is needed to override the typography margins + margin-block-end: rem(4px) !important; + } + + igx-icon { + width: var(--igx-icon-size, #{rem(18px)}); + height: var(--igx-icon-size, #{rem(18px)}); + font-size: var(--igx-icon-size, #{rem(18px)}); + } + } + + %grid-excel-menu__secondary-header { + @if $bootstrap-theme { + border-bottom: rem(1px) solid color($color: 'gray', $variant: 100); + } @else { + border-bottom: rem(1px) solid color($color: 'gray', $variant: 300); + } + } + + %grid-excel-menu__secondary-main { + height: rem(232px); + overflow: auto; + } + + %grid-excel-menu__secondary-footer { + --ig-size: 2; + + padding-inline: pad-inline(if($variant != 'bootstrap', rem(24px), rem(16px))); + padding-block-end: pad-block(if($variant != 'bootstrap', rem(24px), rem(16px))); + + @if $bootstrap-theme { + padding-block-start: pad-block(rem(16px)); + border-top: rem(1px) solid color($color: 'gray', $variant: 300); + } + + %grid-excel-filter__apply, + %grid-excel-filter__cancel { + flex-grow: 0; + } + } + + %grid-excel-menu__filter-results { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + } + + igx-excel-style-filter-operations, + [igxExcelStyleFilterOperations] { + display: flex; + flex-direction: column; + flex-grow: 1; + overflow: hidden; + } +} + +/// Adds typography styles for the excel-style-filtering component. +/// Uses the 'body-1', 'caption' +/// category from the typographic scale. +/// @group typography +/// @access private +/// @param {Map} $categories [(haeder-comfortable: 'overline', header-compact: 'subtitle-1')] - The categories from the typographic scale used for type styles. +@mixin excel-filtering-typography($categories: ( + header-comfortable: 'overline', + header-compact: 'subtitle-1') +) { + $header-comfortable: map.get($categories, 'header-comfortable'); + $header-compact: map.get($categories, 'header-compact'); + + + %grid-excel-menu { + %grid-excel-menu__header > h4 { + @include type-style('h6') + } + } + + %grid-excel-menu__secondary { + %grid-excel-menu__header > h4 { + @include type-style('h6'); + } + } + + %grid-excel-sort, + %grid-excel-move { + header { + @include type-style('overline'); + } + } + + %grid-excel-menu--cosy { + %grid-excel-menu__header > h4 { + @include type-style('h6') + } + } + + %grid-excel-menu--compact { + %grid-excel-menu__header > h4 { + @include type-style('subtitle-1') + } + + %grid-excel-sort, + %grid-excel-move { + header { + @include type-style('body-2'); + text-transform: capitalize; + } + } + + %grid-excel-actions__action { + span { + @include type-style('body-2'); + } + } + + %cbx-label { + @include type-style('body-2'); + } + } + + %grid-excel-menu__secondary--cosy { + %grid-excel-menu__header > h4 { + @include type-style('h6'); + } + } + + %grid-excel-menu__secondary--compact { + %grid-excel-menu__header > h4 { + @include type-style('subtitle-1'); + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/grid/_grid-component.scss b/projects/igniteui-angular/core/src/core/styles/components/grid/_grid-component.scss new file mode 100644 index 00000000000..91b864e6c87 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/grid/_grid-component.scss @@ -0,0 +1,673 @@ +@use '../../base' as *; +@use './excel-filtering-component' as *; +@use './advanced-filtering-component' as *; +@use './group-by-area-component' as *; +@use './header-row-component' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +/// @requires {mixin} excel-filtering-partial +/// @requires {mixin} advanced-filtering-partial +@mixin component { + @include b(igx-grid) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: ( + igx-checkbox, + igx-chip, + igx-grid-summary, + igx-icon-button, + igx-input-group, + igx-grid-toolbar, + igx-paginator, + igx-watermark, + ) + ); + + @extend %grid-host !optional; + + @extend %grid-display !optional; + + @include e(caption) { + @extend %grid-caption !optional; + } + + @include e(tbody) { + @extend %grid-tbody-container !optional; + } + + @include e(tbody-content) { + @extend %grid-tbody !optional; + + &:focus { + @extend %disable-focus-styles !optional; + } + } + + @include e(tbody-message) { + @extend %grid-tbody-message !optional; + } + + @include e(loading) { + @extend %igx-grid__loading !optional; + } + + @include e(tbody-scrollbar) { + @extend %grid-tbody-scrollbar !optional; + } + + @include e(tbody-scrollbar-main) { + @extend %grid-tbody-scrollbar-main !optional; + } + + @include e(tbody-scrollbar-start) { + @extend %grid-tbody-scrollbar-start !optional; + } + + @include e(tbody-scrollbar-end) { + @extend %grid-tbody-scrollbar-end !optional; + } + + @include e(scroll) { + @extend %grid-scroll !optional; + } + + @include e(scroll-start) { + @extend %grid-scroll-start !optional; + } + + @include e(scroll-main) { + @extend %grid-scroll-main !optional; + } + + @include e(tfoot) { + @extend %grid-tfoot !optional; + + &:focus { + @extend %disable-focus-styles !optional; + } + } + + @include e(tfoot-thumb) { + @extend %grid-tfoot-thumb !optional; + } + + @include e(footer) { + @extend %grid-footer !optional; + } + + @include e(tr) { + @extend %grid-row !optional; + + igx-display-container { + @extend %grid-display-container-tr !optional; + } + } + + @include e(tr-action) { + @extend %igx-grid__tr-action !optional + } + + @include e(tr, $m: 'drag') { + @extend %igx-grid__tr--drag !optional; + } + + @include e(tr, $m: 'ghost') { + @extend %igx-grid__tr--ghost !optional; + } + + @include e(drag-indicator) { + @extend %igx-grid__drag-indicator !optional; + } + + @include e(drag-indicator, $m: 'header') { + @extend %igx-grid__drag-indicator--header !optional; + } + + @include e(drag-indicator, $m: 'off') { + @extend %igx-grid__drag-indicator--off !optional; + } + + @include e(tr, $m: 'mrl') { + @extend %grid-row--mrl !optional; + } + + @include e(tr, $mods: ('mrl', 'edit')) { + @extend %grid-row--edit-mrl !optional; + } + + @include e(summaries) { + @extend %grid-summaries !optional; + + igx-display-container { + @extend %grid-display-container-tr !optional; + } + } + + @include e(summaries, $m: 'body') { + @extend %grid-summaries !optional; + @extend %grid-summaries--body !optional; + + igx-display-container { + @extend %grid-display-container-tr !optional; + } + } + + @include e(summaries-patch) { + @extend %grid-summaries-patch !optional; + } + + @include e(tr, $m: odd) { + @extend %grid-row--odd !optional; + } + + @include e(tr, $m: even) { + @extend %grid-row--even !optional; + } + + @include e(tr, $m: selected) { + @extend %grid-row--selected !optional; + } + + @include e(tr, $m: edited) { + @extend %igx-grid__tr--edited !optional; + } + + @include e(tr, $m: deleted) { + @extend %igx-grid__tr--deleted !optional; + } + + @include e(tr, $m: highlighted) { + @extend %igx-grid__tr--highlighted !optional; + } + + @include e(tr, $m: edit) { + @extend %igx-grid__tr--edit !optional; + } + + @include e(tr, $m: add-animate) { + @extend %igx-grid__tr--add-animate !optional; + } + + @include e(tr, $m: inner) { + @extend %igx-grid__tr--inner !optional; + } + + @include e(tr, $m: header) { + @extend %igx-grid__tr--header !optional; + } + + @include e(tr, $m: group) { + @extend %grid-row--group !optional; + } + + @include e(tr, $m: mrl) { + @extend %grid-row--mrl !optional; + } + + @include e(tr-container) { + @extend %igx-grid__tr-container !optional; + } + + @include e(tr-container, $m: active) { + @extend %igx-grid__tr-container--active !optional; + } + + @include e(td) { + @extend %grid-cell-display !optional; + } + + @include e(td, $m: active) { + @extend %grid-cell--active !optional; + } + + @include e(td, $m: selected) { + @extend %grid-cell--selected !optional; + } + + @include e(td, $m: invalid) { + @extend %grid-cell--invalid !optional; + } + + @include e(td, $m: valid) { + @extend %grid-cell--valid !optional; + } + + @include e(td, $m: column-selected) { + @extend %grid-cell--column-selected !optional; + } + + @include e(td, $mods: (selected, column-selected)) { + @extend %grid-cell--cross-selected !optional; + } + + @include e(td, $m: bool) { + @extend %igx-grid__td--centered !optional; + @extend %igx-grid__td--bool !optional; + } + + @include e(td, $m: bool-true) { + @extend %igx-grid__td--bool-true !optional; + } + + @include e(td, $m: image) { + @extend %igx-grid__td--centered !optional; + } + + @include e(tr, $mods: (selected, filtered)) { + @extend %grid-row--selected--filtered !optional; + } + + @include e(tr, $m: filtered) { + @extend %igx-grid-row--filtered !optional; + } + + @include e(tr, $m: expanded) { + @extend %igx-grid__tr--expanded !optional; + } + + @include e(tr, $m: pinned) { + @extend %igx-grid__tr--pinned !optional; + } + + @include e(tr, $m: merged) { + @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; + } + + @include e(tr, $m: pinned-bottom) { + @extend %igx-grid__tr--pinned-bottom !optional; + } + + @include e(tree-grouping-indicator) { + @extend %igx-grid__tree-grouping-indicator !optional; + } + + @include e(tree-loading-indicator) { + @extend %igx-grid__tree-loading-indicator !optional; + } + + @include e(td, $m: new) { + @extend %igx-grid__td--new !optional; + } + + @include e(td, $m: edited) { + @extend %igx-grid__td--edited !optional; + } + + @include e(td, $m: merged) { + @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; + } + + @include e(td, $m: merged-hovered) { + @extend %igx-grid__td--merged-hovered !optional; + } + + @include e(td, $m: editing) { + @extend %igx-grid__td--editing !optional; + } + + @include e(td, $mods: (editing, valid)) { + @extend %igx-grid__td--editing--valid !optional; + } + + @include e(td, $mods: (editing, invalid)) { + @extend %igx-grid__td--editing--invalid !optional; + } + + @include e(tr, $m: disabled) { + @extend %igx-grid__tr--disabled !optional; + } + + @include e(td, $m: number) { + @extend %grid-cell-number !optional; + } + + @include e(td, $m: pinned) { + @extend %grid-cell--pinned !optional; + } + + @include e(td, $m: pinned-last) { + @extend %grid-cell--pinned !optional; + @extend %grid-cell--pinned-last !optional; + } + + @include e(td, $m: pinned-first) { + @extend %grid-cell--pinned !optional; + @extend %grid-cell--pinned-first !optional; + } + + @include e(td, $m: fw) { + @extend %grid-cell--fixed-width !optional; + } + + @include e(td, $mods: (pinned, selected)) { + @extend %grid-cell--pinned-selected !optional; + } + + @include e(td, $mods: (pinned, column-selected)) { + @extend %grid-cell--pinned--column-selected !optional; + } + + @include e(td, $m: row-pinned-first) { + @extend %grid-cell--row-pinned-first !optional; + } + + @include e(td, $m: pinned-chip) { + @extend %grid-cell--pinned-chip !optional; + } + + @include e(td-text) { + @extend %grid-cell-text !optional; + } + + @include e(cbx-padding) { + @extend %cbx-padding !optional; + } + + @include e(cbx-selection) { + @extend %grid__cbx-selection !optional; + } + + @include e(cbx-selection, $m: push) { + @extend %grid__cbx-selection--push !optional; + } + + @include e(group-row) { + @extend %igx-grid__group-row !optional; + } + + @include e(group-row, $m: active) { + @extend %igx-grid__group-row--active !optional; + } + + @include e(group-content) { + @extend %igx-grid__group-content !optional; + } + + @include e(row-indentation) { + @extend %igx-grid__row-indentation !optional; + } + + @include e(grouping-indicator) { + @extend %igx-grid__grouping-indicator !optional; + } + + @include e(scroll-on-drag-left) { + @extend %grid__scroll-on-drag-left !optional; + } + + @include e(scroll-on-drag-right) { + @extend %grid__scroll-on-drag-right !optional; + } + + @include e(scroll-on-drag-pinned) { + @extend %grid__scroll-on-drag-pinned !optional; + } + + @include e(drag-ghost-image) { + @extend %grid__drag-ghost-image !optional; + } + + @include e(drag-ghost-image-icon) { + @extend %grid__drag-ghost-image-icon !optional; + } + + @include e(drag-ghost-image-icon-group) { + @extend %grid__drag-ghost-image-icon-group !optional; + } + + @include e(drag-col-header) { + @extend %igx-grid__drag-col-header !optional; + } + + @include e(header-indentation) { + @extend %igx-grid__header-indentation !optional; + } + + @include e(header-indentation, $m: 'no-border') { + @extend %igx-grid__header-indentation--no-border !optional; + } + + @include e(group-expand-btn) { + @extend %igx-grid__group-expand-btn !optional; + } + + @include e(group-expand-btn, $m: 'push') { + @extend %igx-grid__group-expand-btn--push !optional; + } + + @include e(outlet) { + @extend %igx-grid__outlet !optional; + } + + @include e(loading-outlet) { + @extend %igx-grid__loading-outlet !optional; + } + + @include e(row-editing-outlet) { + @extend %igx-grid__row-editing-outlet !optional; + } + + @include e(addrow-snackbar) { + @extend %igx-grid__addrow-snackbar !optional; + } + + @include e(filtering-cell) { + @extend %igx-grid__filtering-cell !optional; + } + + @include e(filtering-cell, $m: 'selected') { + @extend %igx-grid__filtering-cell !optional; + @extend %igx-grid__filtering-cell--selected !optional; + } + + @include e(filtering-cell-indicator) { + @extend %igx-grid__filtering-cell-indicator !optional; + } + + @include e(filtering-cell-indicator, $m: 'hidden') { + @extend %igx-grid__filtering-cell-indicator !optional; + @extend %igx-grid__filtering-cell-indicator--hidden !optional; + } + + @include e(filtering-dropdown-items) { + @extend %igx-grid__filtering-dropdown-items !optional; + } + + @include e(filtering-dropdown-text) { + @extend %igx-grid__filtering-dropdown-text !optional; + } + + @include e(filtering-row) { + @extend %igx-grid__filtering-row !optional; + } + + @include e(filtering-row-editing-buttons) { + @extend %igx-grid__filtering-row-editing-buttons !optional; + } + + @include e(filtering-row-editing-buttons, $m: small) { + @extend %igx-grid__filtering-row-editing-buttons--small !optional; + } + + @include e(filtering-row-main) { + @extend %igx-grid__filtering-row-main !optional; + } + + @include e(filtering-row-scroll-start) { + @extend %igx-grid__filtering-scroll-start !optional; + } + + @include e(filtering-row-scroll-end) { + @extend %igx-grid__filtering-scroll-end !optional; + } + + @include e(hierarchical-indent) { + @extend %igx-grid__hierarchical-indent !optional; + } + + @include e(hierarchical-expander) { + @extend %igx-grid__hierarchical-expander !optional; + } + + @include e(hierarchical-expander, $m: empty) { + @extend %igx-grid__hierarchical-expander !optional; + @extend %igx-grid__hierarchical-expander--empty !optional; + } + + @include e(hierarchical-expander, $m: header) { + @extend %igx-grid__hierarchical-expander--header !optional; + } + + @include e(hierarchical-expander, $m: push) { + @extend %igx-grid__hierarchical-expander--push !optional; + } + + @include e(hierarchical-indent, $m: scroll) { + @extend %igx-grid__hierarchical-indent--scroll !optional; + } + + @include e(mrl-block) { + @extend %grid-mrl-block !optional; + } + + @for $i from 1 through 10 { + @include e(row-indentation, $m: level-#{$i}) { + @extend %igx-grid__row-indentation--level-#{$i} !optional; + } + + @include e(group-row, $m: padding-level-#{$i}) { + @extend %igx-grid__group-row--padding-level-#{$i} !optional; + } + } + + // Pivot start + @include e(pivot, $m: 'super-compact') { + @extend %igx-grid__pivot--super-compact !optional + } + + @include e(tr-pivot) { + @extend %igx-grid__tr-pivot !optional + } + + @include e(pivot-filter-toggle) { + @extend %igx-grid__pivot-filter-toggle !optional + } + + @include e(pivot-empty-chip-area) { + @extend %igx-grid__pivot-empty-chip-area !optional + } + + + + @include e(tr-pivot, $m: 'row-area') { + @extend %igx-grid__tr-pivot--row-area !optional + } + + @include e(tr-pivot, $m: 'filter-container') { + @extend %igx-grid__tr-pivot--filter-container !optional + } + + @include e(tr-pivot, $m: 'chip_drop_indicator') { + @extend %igx-grid__tr-pivot--chip_drop_indicator !optional + } + + @include e(tr-pivot, $m: 'drop-row-area') { + @extend %igx-grid__tr-pivot--drop-row-area !optional + } + + @include e(tr-pivot, $m: 'filter') { + @extend %igx-grid__tr-pivot--filter !optional + } + + @include e(tr-pivot-group) { + @extend %igx-grid__tr-pivot-group !optional + } + + @include e(tr-header-row) { + @extend %igx-grid__tr-header-row !optional; + } + + @include e(tr-pivot, $m: 'columnDimensionLeaf') { + @extend %igx-grid__tr-pivot--columnDimensionLeaf !optional + } + + @include e(tr-pivot, $m: 'columnMultiRowSpan') { + @extend %igx-grid__tr-pivot--columnMultiRowSpan !optional + } + + @include e(tbody-pivot-mrl-dimension) { + @extend %igx-grid__tbody-pivot-mrl-dimension !optional + } + + @include e(tr-pivot-toggle-icons) { + @extend %igx-grid__tr-pivot-toggle-icons !optional; + } + // pivot end + + @include excel-filtering-partial(); + @include advanced-filtering-partial(); + @include group-by-area(); + @include header-row(); + } + + @include b(igx-drop-area) { + @extend %igx-drop-area !optional; + + @include m(hover) { + @extend %igx-drop-area--hover !optional; + } + + @include e(icon) { + @extend %igx-drop-area__icon !optional; + } + + @include e(text) { + @extend %igx-drop-area__text !optional; + } + } + + @include b(igx-group-label) { + @extend %igx-group-label !optional; + + @include e(icon) { + @extend %igx-group-label__icon !optional; + } + + @include e(column-name) { + @extend %igx-group-label__column-name !optional; + } + + @include e(text) { + @extend %igx-group-label__text !optional; + } + + @include e(count-badge) { + @extend %igx-group-label__count-badge !optional; + } + } + + @include b(igx-grid-summary) { + @include m(active) { + @extend %igx-grid-summary--active !optional; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/grid/_grid-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/grid/_grid-theme.scss new file mode 100644 index 00000000000..83809224003 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/grid/_grid-theme.scss @@ -0,0 +1,3052 @@ +@use 'sass:map'; +@use 'sass:math'; +@use '../../base' as *; +@use './excel-filtering-theme' as *; +@use 'igniteui-theming/sass/animations' as *; + +@mixin _filtering-scroll-mask($theme, $dir) { + display: block; + position: absolute; + width: rem(10px); + content: ''; + inset-block: rem(-2px); + background: linear-gradient(to #{$dir}, var-get($theme, 'filtering-row-background'), transparent); +} + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +/// @requires {mixin} excel-filtering +@mixin grid($theme) { + @include css-vars($theme); + @include scale-in-ver-center(); + + $variant: map.get($theme, '_meta', 'theme'); + $theme-variant: map.get($theme, '_meta', 'variant'); + $bootstrap-theme: $variant == 'bootstrap'; + $not-bootstrap-theme: $variant != 'bootstrap'; + + $cbx-size: map.get(( + 'material': rem(20px), + 'fluent': rem(20px), + 'bootstrap': rem(14px), + 'indigo': rem(20px), + ), $variant); + $cbx-bs-size: rem(14px); + + $grid-shadow: var-get($theme, 'grid-elevation'); + + $grid-caption-fs: rem(20px); + $grid-caption-lh: rem(32px); + $grid-caption-padding: rem(16px) rem(24px); + + $grid-head-fs: rem(12px); + $grid-head-fw: 600; + $transition: all 120ms $ease-in-out-cubic; + + // Cell + $grid-cell-align-num: end; + $grid-cell-fs: rem(13px); + $grid-cell-lh: rem(16px); + $grid-cell-pinned-style: rem(1px) solid; + $grid-cell-pinned-border-color: color($color: 'gray', $variant: 300); + + $grid-header-border: var-get($theme, 'header-border-width') var-get($theme, 'header-border-style') var-get($theme, 'header-border-color'); + + $cell-pin: ( + style: var-get($theme, 'pinned-border-width') var-get($theme, 'pinned-border-style'), + color: var-get($theme, 'pinned-border-color') + ); + + $padding-comfortable: rem(24px); + $padding-cosy: rem(16px); + $padding-compact: rem(12px); + + $grid-header-padding-inline: ( + comfortable: $padding-comfortable, + cosy: $padding-cosy, + compact: $padding-compact + ); + + $pivot-row-aria-padding: ( + comfortable: $padding-comfortable, + cosy: $padding-cosy, + compact: $padding-compact + ); + + $grid-cbx-padding: ( + comfortable: $padding-comfortable, + cosy: $padding-cosy, + compact: $padding-compact + ); + + $cbx-padding: map.get($grid-cbx-padding, 'comfortable'); + $cbx-padding-cosy: map.get($grid-cbx-padding, 'cosy'); + $cbx-padding-compact: map.get($grid-cbx-padding, 'compact'); + + $grid-header-height: ( + comfortable: if($variant == 'indigo', rem(48px), rem(50px)), + cosy: if($variant == 'indigo', rem(40px), rem(40px)), + compact: if($variant == 'indigo', rem(32px), rem(32px)) + ); + + $drop-area-height: ( + comfortable: rem(32px), + cosy: rem(24px), + compact: rem(24px) + ); + + $cell-padding-comfortable: rem(24px); + $cell-padding-cosy: rem(16px); + $cell-padding-compact: rem(12px); + + $grid-cell-padding-inline: ( + comfortable: $cell-padding-comfortable, + cosy: $cell-padding-cosy, + compact: $cell-padding-compact + ); + + $hierarchical-grid-indent: ( + comfortable: rem(24px), + cosy: rem(16px), + compact: rem(12px) + ); + + $hierarchical-action-icon: if($variant == 'indigo', rem(16px), rem(24px)); + + $hierarchical-indent: ( + comfortable: calc(2 * #{map.get($hierarchical-grid-indent, 'comfortable')} + #{$hierarchical-action-icon}), + cosy: calc(2 * #{map.get($hierarchical-grid-indent, 'cosy')} + #{$hierarchical-action-icon}), + compact: calc(2 * #{map.get($hierarchical-grid-indent, 'compact')} + #{$hierarchical-action-icon}) + ); + + $hierarchical-indent-scroll: ( + comfortable: calc(#{map.get($hierarchical-grid-indent, 'comfortable')} + 18px), + cosy: calc(#{map.get($hierarchical-grid-indent, 'cosy')} + 18px), + compact: calc(#{map.get($hierarchical-grid-indent, 'compact')} + 18px) + ); + + $grouparea-padding-inline: ( + comfortable: rem(24px), + cosy: rem(16px), + compact: rem(12px) + ); + + $grouparea-min-height: ( + comfortable: if($variant == 'indigo', rem(56px), rem(57px)), + cosy: if($variant == 'indigo', rem(48px), rem(49px)), + compact: if($variant == 'indigo', rem(40px), rem(41px)) + ); + + $grid-grouping-indicator-padding: ( + comfortable: rem(24px), + cosy: rem(16px), + compact: rem(12px) + ); + + $indicator-icon-width: map.get(( + 'material': rem(24px), + 'fluent': rem(24px), + 'bootstrap': rem(24px), + 'indigo': rem(16px), + ), $variant); + + $drag-icon-size: rem(24px); + + $grid-header-weight: map.get(( + 'material': 600, + 'fluent': 800, + 'bootstrap': 700, + 'indigo': 600, + ), $variant); + + $editing-outline-width: rem(2px); + + $filtering-row-height: #{sizable( + map.get($grid-header-height, 'compact'), + map.get($grid-header-height, 'cosy'), + map.get($grid-header-height, 'comfortable') + )}; + + %cell-input-overrides { + // Have a more stable visual editing experience + > igx-input-group, + igx-combo, + igx-simple-combo, + igx-select, + igx-date-picker, + igx-time-picker { + position: relative; + height: auto; + width: 100% !important; + overflow: hidden; + } + + igx-input-group { + background: var-get($theme, 'cell-editing-background'); + + input { + height: 100%; + color: var-get($theme, 'cell-editing-foreground'); + } + + input:focus { + color: var-get($theme, 'cell-editing-focus-foreground'); + } + } + + igx-select, + igx-combo, + igx-simple-combo, + igx-time-picker, + igx-date-picker { + igx-input-group { + height: 100%; + } + } + + .igx-input-group__bundle { + background: transparent !important; + height: 100% !important; + min-height: 100% !important; + border: none !important; + + .igx-input-group__filler { + border: none !important; + } + + &::before { + content: none !important; + } + + &::after { + display: none; + } + } + + .igx-input-group--indigo .igx-input-group__bundle:hover, + .igx-input-group--indigo .igx-input-group__bundle:focus { + background: transparent; + } + + .igx-input-group__bundle-main, + .igx-input-group__bundle-start, + .igx-input-group__bundle-end { + height: auto; + border: none !important; + border-radius: 0 !important; + } + + .igx-input-group__bundle-main { + padding: 0; + } + + .igx-input-group__line { + display: none; + } + + igx-prefix, + igx-suffix { + background: transparent !important; + border-radius: 0 !important; + padding-top: 0 !important; + padding-bottom: 0 !important; + border: none !important; + padding-inline: sizable(rem(4px), rem(6px), rem(8px)) !important; + } + + .igx-input-group--indigo { + padding-inline: sizable(rem(6px), rem(8px), rem(12px)) !important; + + igx-prefix { + padding-inline-start: 0 !important; + } + + igx-suffix { + padding-inline-end: 0 !important; + } + } + + .igx-input-group__input { + padding-inline: sizable(rem(4px), rem(6px), rem(8px)) !important; + } + + igx-date-range-picker { + height: 100%; + } + + igx-time-picker [igxLabel] { + display: none; + } + + input { + margin: 0 auto; + max-width: 100%; + } + + %form-group-input { + // ignore global typography + font-size: $grid-cell-fs !important; + line-height: $grid-cell-lh !important; + } + + .igx-input-group__input, + .igx-input-group__file-input, + .igx-input-group__textarea { + box-shadow: none !important; + border: none !important; + } + + .igx-input-group--disabled, + .igx-input-group--disabled igx-prefix, + .igx-input-group--disabled igx-suffix { + color: var-get($theme, 'cell-disabled-color'); + } + } + + @if $variant != 'indigo' { + %filtering-row-input-overrides { + igx-input-group { + width: 100%; + max-width: rem(200px); + min-width: rem(140px); + + @if $variant != 'fluent' { + border: rem(1px) solid color($color: 'gray', $variant: 300); + } + + --size: calc(#{$filtering-row-height} - #{rem(8px)}); + + .igx-input-group__bundle, + .igx-input-group__bundle-start, + .igx-input-group__bundle-end, + igx-prefix, + igx-suffix { + background: transparent; + border-radius: 0; + + /* stylelint-disable-next-line */ + &:hover { + background: transparent; + } + } + + igx-prefix, + igx-suffix { + height: 100% !important; + padding: 0 sizable(rem(4px), rem(6px), rem(8px)); + } + + .igx-input-group__input { + font-size: sizable(rem(12px), rem(14px), rem(16px)); + padding-inline-start: 0; + padding-block: 0; + height: 100%; + } + + .igx-input-group__bundle, + .igx-input-group__bundle-start, + .igx-input-group__bundle-end, + .igx-input-group__input { + border: 0; + + /* stylelint-disable-next-line */ + &:hover { + border: 0; + box-shadow: none; + } + } + + .igx-input-group__bundle::after { + display: none; + } + + .igx-input-group__bundle-main { + padding-inline-start: 0; + } + + color: var-get($theme, 'filtering-row-text-color'); + + &:hover{ + color: var-get($theme, 'filtering-row-text-color'); + border-color: color($color: 'primary', $variant: 500); + } + } + + .igx-input-group--focused { + @if $variant != 'fluent' { + border-color: color($color: 'primary', $variant: 500); + border-width: rem(1px); + } + + color: var-get($theme, 'filtering-row-text-color'); + + .igx-input-group__bundle, + .igx-input-group__bundle-start, + .igx-input-group__bundle-end, + .igx-input-group__input { + border: 0 !important; + + @if $variant != 'fluent' { + box-shadow: none !important; + } + } + + .igx-input-group__bundle-main, + .igx-input-group__bundle-start, + .igx-input-group__bundle-end { + margin: 0 !important; + } + + .igx-input-group__bundle, + .igx-input-group__bundle-start, + .igx-input-group__bundle-end, + igx-prefix, + igx-suffix { + background: transparent !important; + border-radius: 0; + } + } + + .igx-input-group__line { + display: none; + } + + igx-prefix:focus { + color: color(map.get($theme, 'palette'), 'secondary'); + } + + igx-suffix { + igx-icon { + outline-style: none; + + &:focus { + color: color($color: 'secondary'); + } + + + igx-icon { + margin-inline-start: rem(4px); + } + } + } + } + } + + igx-grid, + igx-hierarchical-grid, + igx-pivot-grid, + igx-tree-grid { + @if $variant == 'material' { + @if $theme-variant == 'light' { + --igx-chip-disabled-text-color: #{color($color: 'gray', $variant: 500)}; + --igx-chip-disabled-background: #{color($color: 'gray', $variant: 300)}; + --igx-chip-disabled-border-color: #{color($color: 'gray', $variant: 300)}; + } + @if $theme-variant == 'dark' { + --igx-chip-disabled-text-color: #{color($color: 'gray', $variant: 300)}; + --igx-chip-disabled-background: #{color($color: 'gray', $variant: 200)}; + --igx-chip-disabled-border-color: #{color($color: 'gray', $variant: 200)}; + } + } + + @if $variant == 'fluent' { + @if $theme-variant == 'dark' { + --igx-chip-disabled-text-color: #{color($color: 'gray', $variant: 400)}; + --igx-chip-disabled-background: #{color($color: 'gray', $variant: 200)}; + --igx-chip-disabled-border-color: #{color($color: 'gray', $variant: 200)}; + } + } + + @if $variant == 'bootstrap' { + @if $theme-variant == 'dark' { + --igx-chip-disabled-text-color: #{color($color: 'gray', $variant: 400)}; + --igx-chip-disabled-background: #{color($color: 'gray', $variant: 200)}; + --igx-chip-disabled-border-color: #{color($color: 'gray', $variant: 200)}; + } + } + + @if $variant == 'indigo' { + @if $theme-variant == 'light' { + --igx-chip-disabled-text-color: #{color($color: 'gray', $variant: 500)}; + --igx-chip-disabled-background: #{color($color: 'gray', $variant: 200)}; + --igx-chip-disabled-border-color: #{color($color: 'gray', $variant: 300)}; + } + @if $theme-variant == 'dark' { + --igx-chip-disabled-text-color: #{color($color: 'gray', $variant: 300)}; + --igx-chip-disabled-background: #{color($color: 'gray', $variant: 200)}; + --igx-chip-disabled-border-color: #{color($color: 'gray', $variant: 200)}; + } + } + } + + %disable-focus-styles { + outline: 0; + } + + %grid-host { + @include sizable(); + + --component-size: var(--ig-size, var(--ig-size-large)); + --grid-size: var(--component-size); + } + + %grid-display { + --header-size: #{sizable( + map.get($grid-header-height, 'compact'), + map.get($grid-header-height, 'cosy'), + map.get($grid-header-height, 'comfortable') + )}; + + --grouparea-size: #{sizable( + map.get($grouparea-min-height, 'compact'), + map.get($grouparea-min-height, 'cosy'), + map.get($grouparea-min-height, 'comfortable') + )}; + + --igx-tree-indent-size: #{sizable(rem(12px), rem(16px), rem(24px))}; + + position: relative; + display: grid; + grid-template-rows: auto auto auto 1fr auto auto; + grid-template-columns: 100%; + overflow: hidden; + box-shadow: $grid-shadow; + + @if $variant == 'fluent' { + box-shadow: 0 0 0 rem(1px) var-get($theme, 'grid-border-color'); + } + + outline-style: none; + z-index: 1; + + %cbx-display { + min-width: $cbx-size; + + @if $variant == 'material' { + %cbx-composite-wrapper { + padding: 0; + } + + %cbx-label-pos--after { + margin-inline-start: rem(12px); + } + + %cbx-label-pos--before { + margin-inline-end: rem(12px); + } + + %cbx-label-pos--before, + %cbx-label-pos--after { + &:empty { + margin: 0; + } + } + } + } + } + + %grid-caption { + display: flex; + align-items: center; + font-size: $grid-caption-fs; + line-height: $grid-caption-lh; + padding: $grid-caption-padding; + grid-row: 1; + } + + %grid-thead, + %grid-tfoot { + position: relative; + display: flex; + background: var-get($theme, 'header-background'); + color: var-get($theme, 'header-text-color'); + overflow: hidden; + outline-style: none; + + %grid-row { + position: relative; + background: inherit; + color: inherit; + z-index: 2; + + &:hover { + background: inherit; + color: inherit; + } + } + + > [aria-activedescendant] { + outline-style: none; + } + } + + %grid-thead { + border-bottom: $grid-header-border; + + @if $bootstrap-theme { + border-bottom-width: rem(2px); + } + + z-index: 2; + + %grid__cbx-selection--push { + align-items: flex-start; + padding-block-start: pad-block( + math.div(map.get($grid-header-height, 'compact') - rem(20px), 2), + math.div(map.get($grid-header-height, 'cosy') - rem(20px), 2), + math.div(map.get($grid-header-height, 'comfortable') - rem(20px), 2) + ); + } + + %grid-row { + border-bottom: none; + } + } + + %grid-thead-container { + grid-row: 3; + display: flex; + overflow: hidden; + + %igx-grid__header-indentation { + igx-icon { + --component-size: #{if($variant == 'indigo', 2, 3)}; + } + + @if $variant == 'indigo' { + %igx-grid__group-expand-btn { + color: var-get($theme, 'expand-icon-color'); + } + + %igx-grid__group-expand-btn:hover{ + color: var-get($theme, 'expand-icon-hover-color'); + } + } + + } + + %igx-grid__drag-indicator { + cursor: default; + } + + %grid-row--mrl { + %igx-grid__hierarchical-expander--header, + %igx-grid__hierarchical-expander, + %igx-grid__header-indentation, + %igx-grid__row-indentation, + %grid__cbx-selection { + border-bottom: var-get($theme, 'header-border-width') var-get($theme, 'header-border-style') var-get($theme, 'header-border-color'); + } + } + + &:focus-visible { + outline-color: transparent; + } + } + + %grid-thead-title { + flex-basis: auto !important; + align-items: center !important; + border-bottom: $grid-header-border; + height: var(--header-size); + + @if $variant != 'indigo' { + padding-inline: pad-inline( + map.get($grid-cell-padding-inline, 'compact'), + map.get($grid-cell-padding-inline, 'cosy'), + map.get($grid-cell-padding-inline, 'comfortable') + ); + } @else { + padding-inline: pad-inline(rem(8px), rem(12px), rem(16px)); + + igx-icon { + opacity: if($theme-variant == 'light', .75, .85); + + &:hover { + opacity: 1; + cursor: pointer; + } + } + } + + padding-block: 0; + } + + %grid-thead-title--pinned { + border-inline-end: map.get($cell-pin, 'style') map.get($cell-pin, 'color') !important; + } + + %grid-thead-group { + display: flex; + flex-flow: row nowrap; + } + + /* We set those with position relative + so that the drop indicators be scoped + to their respective group. The item + being the topmost element, while the + subgroup encapsulates children of each + thead item and group. + */ + %grid-thead-item { + display: flex; + flex-flow: column nowrap; + + %grid-thead-group { + flex: 1 1 auto; + } + + %grid-cell-header { + flex: 1 1 auto; + } + + %grid-thead-title { + flex: 0 0 auto; + } + } + + %grid-thead-item, + %grid-thead-subgroup { + position: relative; + } + + %grid-tfoot { + grid-row: 5; + border-top: $grid-header-border; + z-index: 10001; + } + + %grid-footer { + grid-row: 7; + } + + %grid-display-container-thead { + width: 100%; + overflow: visible; + } + + %grid-display-container-tr { + width: 100%; + overflow: visible; + flex: 1; + // needed to override the min-width of the column headers + min-width: 0; + } + + %grid-mrl-block { + display: grid; + background: inherit; + position: relative; + + %grid-thead-item { + display: flex; + } + + %grid-cell-header { + align-items: center; + flex-grow: 1; + border-bottom: $grid-header-border; + } + + %grid-cell-display { + border-inline-end: rem(1px) solid var-get($theme, 'header-border-color'); + border-bottom: rem(1px) solid var-get($theme, 'header-border-color'); + } + } + + %grid-row--mrl { + &%grid-row { + border-bottom-color: transparent; + + @if $variant == 'indigo' { + %grid-cell-display { + border-inline-end: rem(1px) solid var-get($theme, 'row-border-color'); + border-bottom: rem(1px) solid var-get($theme, 'row-border-color'); + } + } + } + + %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'); + } + } + + %grid-tbody { + position: relative; + background: var-get($theme, 'content-background'); + color: var-get($theme, 'content-text-color'); + overflow: hidden; + z-index: 1; + outline-style: none; + } + + %grid-tbody-container { + position: relative; + display: flex; + grid-row: 4; + overflow: hidden; + } + + %grid-tbody-message { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + color: var-get($theme, 'content-text-color'); + flex-direction: column; + padding: rem(24px); + } + + %igx-grid__loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + min-height: rem(100px); + + > %circular-display { + width: rem(50); + height: rem(50); + } + } + + %grid-scroll { + grid-row: 6; + display: flex; + flex-flow: row nowrap; + width: 100%; + background: var-get($theme, 'header-background'); + z-index: 10001; + } + + %grid-thead-thumb { + background: var-get($theme, 'header-background'); + border-inline-start: rem(1px) solid var-get($theme, 'header-border-color'); + } + + %grid-tfoot-thumb { + position: absolute; + top: 0; + inset-inline-end: 0; + background: var-get($theme, 'header-background'); + border-inline-start: rem(1px) solid var-get($theme, 'header-border-color'); + } + + %grid-tbody-scrollbar { + background: var-get($theme, 'content-background'); + border-inline-start: rem(1px) solid var-get($theme, 'row-border-color'); + position: relative; + } + + %grid-tbody-scrollbar-start { + background: var-get($theme, 'header-background'); + } + + %grid-tbody-scrollbar-main { + position: relative; + } + + %grid-tbody-scrollbar-end { + background: var-get($theme, 'header-background'); + } + + %grid-scroll-start { + background: var-get($theme, 'header-background'); + } + + %grid-scroll-main { + igx-display-container { + height: 0; + } + + igx-horizontal-virtual-helper { + height: 100%; + } + } + + %grid-row { + display: flex; + background: var-get($theme, 'content-background'); + border-bottom: rem(1px) solid var-get($theme, 'row-border-color'); + outline-style: none; + position: relative; + background-clip: content-box !important; + + &:hover { + background: var-get($theme, 'row-hover-background'); + color: var-get($theme, 'row-hover-text-color'); + + %grid-cell--column-selected { + color: var-get($theme, 'row-selected-hover-text-color'); + background: var-get($theme, 'row-selected-hover-background'); + } + + %grid-cell--cross-selected { + color: var-get($theme, 'cell-selected-within-text-color'); + background: var-get($theme, 'cell-selected-within-background'); + } + } + + &%igx-grid__tr--ghost { + background: var-get($theme, 'row-ghost-background'); + color: var-get($theme, 'row-drag-color'); + z-index: 10002; + + @include css-vars(( + name: 'igx-grid-row', + row-ghost-background: map.get($theme, 'row-ghost-background'), + row-drag-color: map.get($theme, 'row-drag-color') + )); + } + } + + %igx-grid__drag-indicator { + display: flex; + align-items: center; + justify-content: center; + + @if $variant != 'indigo' { + padding-inline: pad-inline( + map.get($grid-cell-padding-inline, 'compact'), + map.get($grid-cell-padding-inline, 'cosy'), + map.get($grid-cell-padding-inline, 'comfortable') + ); + min-height: sizable( + rem(32px), + rem(40px), + rem(50px) + ); + } @else { + padding-inline: pad-inline(rem(8px), rem(12px), rem(16px)); + min-height: sizable( + rem(32px), + rem(40px), + rem(48px) + ); + + igx-icon { + opacity: if($theme-variant == 'light', .75, .85); + } + + &:hover { + igx-icon { + opacity: 1; + } + } + } + + padding-block: 0; + flex: 0 0 auto; + background: inherit; + z-index: 4; + cursor: move; + border-inline-end: rem(1px) solid transparent; + background-clip: border-box; + + igx-icon { + --component-size: #{if($variant == 'indigo', 2, 3)}; + } + } + + %igx-grid__drag-indicator--header { + border-inline-end: $grid-header-border; + } + + %igx-grid__drag-indicator--off { + color: var-get($theme, 'row-drag-color'); + } + + %igx-grid__tr--drag { + opacity: .5; + } + + %grid-row--odd { + background: var-get($theme, 'row-odd-background'); + color: var-get($theme, 'row-odd-text-color'); + } + + %grid-row--even { + background: var-get($theme, 'row-even-background'); + color: var-get($theme, 'row-even-text-color'); + } + + %igx-grid__tr--expanded { + border-bottom: none; + } + + %igx-grid__tr--pinned { + position: relative; + background: inherit; + z-index: 10000; + + %igx-grid__hierarchical-expander--empty { + border-inline-end: rem(1px) solid var-get($theme, 'header-border-color'); + } + } + + %igx-grid__tr--pinned-top { + border-bottom: map.get($cell-pin, 'style') map.get($cell-pin, 'color') !important; + } + + %igx-grid__tr--pinned-bottom { + border-top: map.get($cell-pin, 'style') map.get($cell-pin, 'color') !important; + position: absolute; + bottom: 0; + } + + %igx-grid__td--centered { + justify-content: center; + } + + %igx-grid__td--bool { + display: flex; + flex-grow: 1; + + igx-icon { + --component-size: #{if($variant == 'indigo', 2, 1)}; + } + + %igx-icon--error { + @if $variant == 'indigo' or $theme-variant == 'dark' { + color: color($color: 'gray', $variant: 500); + } @else { + color: color($color: 'gray', $variant: 600); + } + } + } + + %igx-grid__td--bool-true { + %igx-icon--success { + color: color($color: 'gray', $variant: 700); + } + } + + %igx-grid__tr--edit { + border-bottom: rem(1px) solid var-get($theme, 'edit-mode-color'); + position: relative; + + &::after { + content: ''; + position: absolute; + height: rem(1); + width: 100%; + top: rem(-1); + inset-inline-start: 0; + background: var-get($theme, 'edit-mode-color'); + } + + &%grid-row { + border-bottom: rem(1px) solid var-get($theme, 'edit-mode-color'); + } + + %igx-grid__td--editing { + border: none; + + %form-group-bundle--focus { + caret-color: var-get($theme, 'edit-mode-color') !important; + } + + %form-group-border { + background: var-get($theme, 'edit-mode-color') !important; + } + } + + [aria-readonly='true'] { + color: var-get($theme, 'cell-disabled-color'); + + igx-icon { + color: var-get($theme, 'cell-disabled-color'); + } + } + } + + %igx-grid__tr--inner { + display: flex; + background: inherit; + } + + %igx-grid__tr--header { + display: flex; + align-items: center; + + igx-icon { + --component-size: #{if($variant == 'indigo', 2, 3)}; + } + } + + %igx-grid__tr--add-animate { + @include animation(scale-in-ver-center .2s $ease-in-out-quad); + } + + %grid-row--edit-mrl { + &:first-of-type::after { + top: 0; + z-index: 5; + } + } + + %igx-grid__tr--edited { + &::before { + content: ''; + position: absolute; + width: if($variant == 'indigo', rem(4px), rem(2px)); + height: 100%; + z-index: 10000; + background: var-get($theme, 'edited-row-indicator'); + } + } + + %grid-row--group { + position: relative; + background: var-get($theme, 'header-background') !important; + } + + %igx-grid-row--filtered { + %grid-cell-text { + color: var-get($theme, 'tree-filtered-text-color'); + } + + %igx-grid__tree-grouping-indicator { + color: var-get($theme, 'tree-filtered-text-color'); + + &:hover { + color: var-get($theme, 'tree-filtered-text-color'); + } + } + + %grid-cell--selected { + %grid-cell-text { + color: var-get($theme, 'tree-selected-filtered-cell-text-color'); + } + + %igx-grid__tree-grouping-indicator { + color: var-get($theme, 'tree-selected-filtered-cell-text-color'); + + &:hover { + color: var-get($theme, 'tree-selected-filtered-cell-text-color'); + } + } + } + } + + %grid-row--selected--filtered { + %grid-cell-text { + color: var-get($theme, 'tree-selected-filtered-row-text-color'); + } + + %igx-grid__tree-grouping-indicator { + color: var-get($theme, 'tree-selected-filtered-row-text-color'); + + &:hover { + color: var-get($theme, 'tree-selected-filtered-row-text-color'); + } + } + + %grid-cell--selected { + %grid-cell-text { + color: var-get($theme, 'tree-selected-filtered-cell-text-color'); + } + + %igx-grid__tree-grouping-indicator { + color: var-get($theme, 'tree-selected-filtered-cell-text-color'); + + &:hover { + color: var-get($theme, 'tree-selected-filtered-cell-text-color'); + } + } + } + } + + %igx-grid__tree-grouping-indicator { + display: flex; + align-items: center; + justify-content: center; + user-select: none; + outline-style: none; + margin-inline-end: if($variant == 'indigo', rem(4px), rem(8)); + cursor: pointer; + + color: var-get($theme, 'expand-icon-color'); + + &:hover { + color: var-get($theme, 'expand-icon-hover-color') + } + + [dir='rtl'] & { + transform: scaleX(-1); + } + + igx-icon { + --component-size: #{if($variant == 'indigo', 2, 3)};; + } + } + + %igx-grid__tree-loading-indicator { + width: rem(24px, 16px); + height: rem(24px, 16px); + margin-inline-end: rem(8px, 16px); + + %circular-outer { + stroke: var-get($theme, 'expand-icon-color'); + } + + > %circular-display { + width: rem(24); + height: rem(24); + } + } + + %grid-cell-display { + position: relative; + display: flex; + flex: 1 1 0%; + align-items: center; + outline-style: none; + + @extend %cell-input-overrides; + + igx-input-group { + background: transparent; + } + + @if $variant != 'indigo' { + padding-inline: pad-inline( + map.get($grid-cell-padding-inline, 'compact'), + map.get($grid-cell-padding-inline, 'cosy'), + map.get($grid-cell-padding-inline, 'comfortable') + ); + } @else { + padding-inline: pad-inline(rem(8px), rem(12px), rem(16px)); + } + + padding-block: 0; + color: inherit; + text-align: start; + background-clip: border-box !important; + + @if $variant != 'indigo' { + font-size: $grid-cell-fs; + line-height: $grid-cell-lh; + min-height: sizable( + rem(32px), + rem(40px), + rem(50px) + ); + } @else { + @include type-style('detail-1', false); + + min-height: sizable( + rem(32px), + rem(40px), + rem(48px) + ); + } + } + + // This is no longer being extended and is left + // here for reference purposes only. It seems setting + // overflow: hidden on the cell prevents drag and selection + // of the cell. + // See github issue #9821 + %igx-grid__td--tree-cell { + overflow: hidden; + } + + %grid-cell-text { + @include ellipsis(); + + pointer-events: none; + } + + %grid-cell--fixed-width { + flex-grow: 0; + outline-style: none; + + igx-icon { + --component-size: #{if($variant == 'indigo', 2, 3)}; + } + } + + %grid-cell--active { + box-shadow: inset 0 0 0 rem(1px) var-get($theme, 'cell-active-border-color'); + + > %igx-grid__filtering-cell, + > %grid-cell-header { + border-inline-end-color: var-get($theme, 'cell-active-border-color'); + border-bottom-color: var-get($theme, 'cell-active-border-color'); + } + } + + %grid-cell--invalid { + padding-inline-end: rem(4px) !important; + + > igx-icon { + color: color($color: 'error'); + width: var(--igx-icon-size, rem(18px)); + height: var(--igx-icon-size, rem(18px)); + font-size: var(--igx-icon-size, rem(18px)); + } + + %grid-cell-text { + width: 100%; + } + + .igx-input-group__bundle { + &:focus-within { + &::after { + border: none !important; + } + } + } + } + + %grid-cell--valid { + box-shadow: inset 0 0 0 rem(2px) color($color: 'success') !important; + } + + %grid-cell--pinned-selected, + %grid-cell--selected { + color: var-get($theme, 'cell-selected-text-color'); + background: var-get($theme, 'cell-selected-background'); + // this is causing an issue https://github.com/IgniteUI/igniteui-angular/issues/4981 + // border-bottom: 0; + + %igx-grid__tree-grouping-indicator { + &:hover { + color: var-get($theme, 'cell-selected-text-color'); + } + } + } + + %grid-row--selected { + 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'); + background: var-get($theme, 'cell-selected-within-background'); + } + + &:hover { + background: var-get($theme, 'row-selected-hover-background'); + color: var-get($theme, 'row-selected-hover-text-color'); + + %grid-cell--column-selected { + color: var-get($theme, 'row-selected-hover-text-color'); + background: var-get($theme, 'row-selected-hover-background'); + } + } + + %igx-grid__tree-grouping-indicator { + color: var-get($theme, 'row-selected-text-color'); + + &:hover { + color: var-get($theme, 'row-selected-text-color'); + } + } + } + + %grid-cell--column-selected { + color: var-get($theme, 'row-selected-text-color'); + background: var-get($theme, 'row-selected-background'); + } + + %grid-cell--cross-selected { + color: var-get($theme, 'cell-selected-within-text-color'); + background: var-get($theme, 'cell-selected-within-background'); + } + + %igx-grid__td--new { + color: var-get($theme, 'cell-new-color'); + } + + %igx-grid__td--edited { + %grid-cell-text { + font-style: italic; + color: var-get($theme, 'cell-edited-value-color'); + padding: 0 rem(1px); + } + } + + %igx-grid__tr--merged { + border-block-end: 0; + } + + %igx-grid__tr--merged-top { + position: absolute; + width: 100%; + } + + %igx-grid__td--merged { + z-index: 1; + grid-row: 1 / -1; + } + + %igx-grid__td--merged-selected { + color: var-get($theme, 'row-selected-text-color'); + background: var-get($theme, 'row-selected-background') !important; + } + + %igx-grid__td--merged-hovered { + background: var-get($theme, 'row-hover-background') !important; + color: var-get($theme, 'row-hover-text-color'); + } + + %igx-grid__td--merged-selected-hovered { + background: var-get($theme, 'row-selected-hover-background') !important; + color: var-get($theme, 'row-selected-hover-text-color'); + } + + %igx-grid__tr--deleted { + %grid-cell-text { + font-style: italic; + color: color(map.get($theme, 'palette'), 'error'); + text-decoration: line-through; + } + } + + %igx-grid__tr--disabled { + %grid-cell-text { + color: var-get($theme, 'cell-disabled-color'); + } + } + + %igx-grid__td--editing { + background: var-get($theme, 'cell-editing-background') !important; + box-shadow: inset 0 0 0 $editing-outline-width var-get($theme, 'edit-mode-color'); + padding-inline: rem(4px); + + &.igx-grid__td--invalid { + box-shadow: inset 0 0 0 rem(2px) color($color: 'error') !important; + } + + &%grid-cell-number { + justify-content: flex-start !important; + } + } + + %grid-cell--pinned { + position: relative; + background: inherit; + z-index: 9999; + } + + %grid-cell--pinned--column-selected { + 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-text-color'); + } + } + + %grid-cell--pinned-last { + border-inline-end: map.get($cell-pin, 'style') map.get($cell-pin, 'color') !important; + + %igx-grid__filtering-cell, + %grid-cell-header { + border-inline-end: none; + } + + &%grid-cell--editing { + border-inline-end: map.get($cell-pin, 'style') var-get($theme, 'cell-selected-background') !important; + } + } + + %grid-cell--pinned-first { + border-inline-start: map.get($cell-pin, 'style') map.get($cell-pin, 'color') !important; + + &%grid-cell--editing { + border-inline-start: map.get($cell-pin, 'style') var-get($theme, 'cell-selected-background') !important; + } + } + + %grid-cell--row-pinned-first { + overflow: hidden; + } + + %grid-cell--pinned-chip { + margin-inline-end: pad-inline(rem(4px), rem(8px), rem(12px)); + } + + %grid-cell-header { + flex-flow: row nowrap; + justify-content: space-between; + align-items: flex-end; + + @if $variant != 'indigo' { + font-size: $grid-head-fs; + font-weight: $grid-head-fw; + + padding-inline: pad-inline( + map.get($grid-header-padding-inline, 'compact'), + map.get($grid-header-padding-inline, 'cosy'), + map.get($grid-header-padding-inline, 'comfortable') + ); + } @else { + padding-inline: pad-inline(rem(8px), rem(12px), rem(16px)); + } + + min-width: 0; + padding-block: 0; + border-inline-end: $grid-header-border; + min-height: var(--header-size); + outline-style: none; + overflow: hidden; + transition: color 250ms ease-in-out; + } + + %grid-cell-header--filtering { + background: var-get($theme, 'filtering-header-background'); + color: var-get($theme, 'filtering-header-text-color'); + z-index: 3; + } + + %grid-cell-header-title { + @include ellipsis(); + + @if $variant != 'indigo' { + font-weight: $grid-header-weight; + } @else { + @include type-style('detail-2', false); + } + + min-width: 3ch; + user-select: none; + cursor: initial; + flex-grow: 1; /* hey IE, the text should take most of the space */ + // align-self: flex-end; /* commenting out for now on external request */ + line-height: var(--header-size); + transition: color 250ms ease-in-out; + } + + %grid-cell-header-icons { + display: inline-flex; + align-items: center; + justify-content: flex-end; + user-select: none; + min-width: rem(30px); /* sort-icon + filter icon width */ + height: var(--header-size); + align-self: flex-end; + + &:empty { + min-width: 0; + } + + .sort-icon { + position: relative; + display: flex; + + + @if $variant != 'indigo' { + igx-icon { + --size: var(--igx-icon-size, #{rem(15px)}); + } + } + + &::after { + content: attr(data-sortIndex); + position: absolute; + top: rem(-5px); + inset-inline-end: rem(-1px); + font-size: rem(10px); + text-align: end; + font-family: sans-serif; + line-height: rem(10px); + } + } + } + + + %igx-grid-th__expander { + display: flex; + align-items: center; + justify-content: center; + margin-inline-end: rem(8px); + cursor: pointer; + + igx-icon { + @if $variant == 'indigo' { + --component-size: 2; + } + + color: var-get($theme, 'expand-icon-color'); + } + + &:hover { + igx-icon { + color: var-get($theme, 'expand-icon-hover-color'); + } + } + } + + %igx-grid-th__group-title { + @include ellipsis(); + + @if $variant == 'indigo' { + @include type-style('detail-2', false); + } + } + + %igx-grid-th--collapsible { + justify-content: normal; + } + + %igx-grid-th--selectable { + @if $variant != 'indigo' { + opacity: if($theme-variant == 'light', .75, .85); + + &%grid__drag-ghost-image { + opacity: 1; + } + } @else { + opacity: 1; + } + + .sort-icon { + color: var-get($theme, 'header-selected-text-color'); + + ::after { + background: var-get($theme, 'header-selected-background'); + } + } + } + + // TODO, remove igx-banner__row extra div if possible + @if $variant { + %igx-banner__row { + display: contents; + } + } + + %igx-grid-th--selected { + .sort-icon::after { + background: var-get($theme, 'header-selected-background'); + } + } + + %igx-grid-th--selectable, + %igx-grid-th--selected { + color: var-get($theme, 'header-selected-text-color'); + background: var-get($theme, 'header-selected-background'); + + &%igx-grid-th--sorted { + .sort-icon { + color: var-get($theme, 'header-selected-text-color'); + + > igx-icon { + color: inherit; + } + + &:focus, + &:hover { + color: var-get($theme, 'header-selected-text-color'); + + > igx-icon { + color: inherit; + } + } + } + } + + @if $variant == 'indigo' { + %grid-excel-icon--filtered, + %grid-excel-icon { + color: var-get($theme, 'header-selected-text-color'); + + igx-icon { + color: var-get($theme, 'header-selected-text-color'); + } + + &:focus, + &:hover { + color: var-get($theme, 'header-selected-text-color'); + + igx-icon { + color: var-get($theme, 'header-selected-text-color'); + } + } + } + } + } + + %igx-grid-th--active { + @extend %grid-cell--active; + + %igx-grid-th--selected, + %igx-grid-th--selectable { + @extend %grid-cell--active; + } + } + + %igx-grid-summary--active { + @extend %grid-cell--active !optional; + } + + %igx-grid-th--sortable { + .sort-icon { + cursor: pointer; + opacity: if($variant == 'indigo', if($theme-variant == 'light', .75, .85), .7); + + &:hover { + opacity: 1; + } + } + } + + %igx-grid-th--sorted { + .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; + } + } + } + } + + %igx-grid-th--filtrable { + %grid-cell-header-title { + @if $variant != 'indigo' { + opacity: .7; + } + } + } + + %igx-grid-th--filtrable-sortable { + .sort-icon { + cursor: pointer; + opacity: if($variant == 'indigo', 1, .7); + + &:hover { + opacity: 1; + } + } + } + + %grid-excel-icon--filtered, + %grid-excel-icon, + .sort-icon { + transition: all 250ms ease-in-out; + } + + %grid-cell-number { + text-align: $grid-cell-align-num; + justify-content: flex-end; + flex-grow: 1; + + %grid-cell-header-icons { + justify-content: flex-start; + order: -1; + + .sort-icon { + order: 1; + } + } + } + + %grid__cbx-selection { + display: flex; + justify-content: center; + align-items: center; + background: inherit; + z-index: 4; + background-clip: border-box; + } + + %cbx-padding { + display: flex; + align-items: center; + justify-content: center; + padding-inline: pad-inline($cbx-padding-compact, $cbx-padding-cosy, $cbx-padding); + } + + %grid__resize-handle { + position: absolute; + width: rem(4px); + top: 0; + inset-inline-end: rem(-2px); + height: 100%; + z-index: 2; + } + + %grid__resize-line { + position: absolute; + cursor: col-resize; + width: rem(4px); + background: var-get($theme, 'resize-line-color'); + z-index: 2; + + &::before, + &::after { + position: absolute; + content: ''; + height: 100%; + width: rem(96px); + } + + &::before { + inset-inline-end: 100%; + } + + &::after { + inset-inline-start: 100%; + } + } + + %grid-summaries { + display: flex; + overflow: hidden; + outline-style: none; + background-color: var-get($theme, 'summaries-patch-background'); + } + + %grid-summaries--body { + --summaries-patch-background: var(--ig-gray-100); + + border-bottom: rem(1px) dashed var-get($theme, 'row-border-color'); + background-color: var-get($theme, 'summaries-patch-background'); + + &:last-of-type { + border-bottom: none; + } + + .igx-grid-summary { + --background-color: inherit; + --result-color: #{adaptive-contrast(var(--background-color))}; + } + } + + %grid-summaries-patch { + position: relative; + background: inherit; + border-inline-end: rem(1px) solid var-get($theme, 'header-border-color'); + z-index: 1; + + @if $variant == 'indigo' { + border-top: rem(1px) solid var-get($theme, 'header-border-color'); + } + } + + // Column moving + %igx-grid-th__drop-indicator-left, + %igx-grid-th__drop-indicator-right { + position: absolute; + width: rem(1px); + height: 100%; + top: 0; + z-index: 1; + } + + %igx-grid-th__drop-indicator-left { + inset-inline-start: rem(-1px); + } + + %igx-grid-th__drop-indicator-right { + inset-inline-end: rem(-1px); + } + + %igx-grid-th__drop-indicator--active { + &%igx-grid-th__drop-indicator-left, + &%igx-grid-th__drop-indicator-right { + border-inline-end: rem(1px) solid var-get($theme, 'drop-indicator-color'); + } + + &::after, + &::before { + position: absolute; + content: ''; + width: 0; + height: 0; + border-style: solid; + inset-inline-start: rem(-3px); + } + + &::before { + bottom: 0; + border-width: 0 rem(4px) rem(4px); + border-color: transparent transparent var-get($theme, 'drop-indicator-color'); + } + + &::after { + top: 0; + border-width: rem(4px) rem(4px) 0; + border-color: var-get($theme, 'drop-indicator-color') transparent transparent; + } + } + + %grid__scroll-on-drag-left, + %grid__scroll-on-drag-right { + position: absolute; + width: rem(15px); + top: 0; + height: 100%; + z-index: 25; + } + + %grid__scroll-on-drag-left { + inset-inline-start: 0; + } + + %grid__scroll-on-drag-right { + inset-inline-end: 0; + } + + %grid__scroll-on-drag-pinned { + position: absolute; + width: rem(15px); + height: 100%; + top: 0; + z-index: 25; + } + + %grid__drag-ghost-image { + position: absolute; + display: flex; + align-items: center; + background: var-get($theme, 'ghost-header-background'); + color: var-get($theme, 'ghost-header-text-color'); + min-width: rem(168px); + max-width: rem(320px); + height: var(--header-size); + min-height: var(--header-size); + top: rem(-99999px); + inset-inline-start: rem(-99999px); + border: none; + box-shadow: var-get($theme, 'drag-elevation'); + overflow: hidden; + z-index: 20; + + %grid-cell-header-title { + @include ellipsis(); + flex: 1 0 0; + text-align: if($variant == 'indigo', start, end); + } + + %grid-cell-header-icons { + display: none; + } + + %grid-thead-title { + border: none; + } + } + + %grid__drag-ghost-image-icon { + color: var-get($theme, 'ghost-header-icon-color'); + margin-inline-end: if($variant == 'indigo', rem(8px), rem(12px)); + } + + %grid__drag-ghost-image-icon-group { + color: var-get($theme, 'ghost-header-icon-color'); + padding: $padding-comfortable; + padding-inline-end: 0; + margin-inline-end: rem(8); + } + + %igx-grid__drag-col-header { + background: var-get($theme, 'header-background'); + + %grid-cell-header { + > * { + opacity: .4; + } + } + } + + // Group by section + %igx-grid__group-row { + background: var-get($theme, 'group-row-background'); + display: flex; + outline-style: none; + border-bottom: rem(1px) solid var-get($theme, 'row-border-color'); + min-height: var(--header-size); + + %igx-grid__drag-indicator { + cursor: default; + flex-grow: 0; + } + + %grid__cbx-selection { + background: initial; + } + } + + %igx-grid__group-row--active { + background: var-get($theme, 'group-row-selected-background'); + @extend %grid-cell--active !optional; + + %igx-grid__grouping-indicator { + color: var-get($theme, 'expand-icon-color'); + } + + %igx-grid__drag-indicator { + border: rem(1px) solid var-get($theme, 'cell-active-border-color'); + border-inline-start-width: 0; + border-inline-end-width: 0; + box-shadow: inset rem(1px) 0 0 0 var-get($theme, 'cell-active-border-color'); + } + + &:hover { + background: var-get($theme, 'group-row-selected-background'); + } + } + + %igx-group-label { + display: flex; + align-items: center; + justify-content: flex-start; + line-height: rem(16px); + gap: rem(4px); + } + + %igx-group-label__icon { + @at-root igx-icon#{&} { + --component-size: #{if($variant == 'indigo', 2, 1)}; + + color: var-get($theme, 'group-label-icon'); + user-select: none; + } + } + + %igx-group-label__column-name { + @if $variant != 'indigo' { + font-weight: 600; + font-size: rem(12px); + } @else { + @include type-style('detail-2', false); + } + + color: var-get($theme, 'group-label-column-name-text'); + + } + + + %igx-group-label__count-badge { + --background-color: #{var-get($theme, 'group-count-background')}; + --text-color: #{var-get($theme, 'group-count-text-color')}; + + @if $variant == 'indigo' { + --shadow: none; + } + + > span { + font-size: $grid-head-fs; + } + } + + + %igx-group-label__text { + @if $variant != 'indigo' { + font-size: rem(13px); + } @else { + @include type-style('detail-1', false); + } + color: var-get($theme, 'group-label-text') + } + + [dir='rtl'] { + %igx-group-label { + > * { + margin-inline-start: rem(4px); + + &:last-child { + margin-inline-start: 0; + } + } + } + } + + %igx-grid__group-content { + display: flex; + align-items: center; + justify-content: flex-start; + flex: 1 1 auto; + @if $variant != 'indigo' { + padding-inline-start: pad-inline( + map.get($grid-grouping-indicator-padding, 'compact'), + map.get($grid-grouping-indicator-padding, 'cosy'), + map.get($grid-grouping-indicator-padding, 'comfortable') + ); + } @else { + padding-inline-start: 0; + } + + min-height: sizable( + map.get($grid-header-height, 'compact'), + map.get($grid-header-height, 'cosy'), + map.get($grid-header-height, 'comfortable') + ); + + &:focus { + outline: transparent; + } + } + + %igx-grid__row-indentation { + position: relative; + display: flex; + justify-content: center; + align-items: center; + padding-inline-start: pad-inline( + map.get($grid-grouping-indicator-padding, 'compact'), + map.get($grid-grouping-indicator-padding, 'cosy'), + map.get($grid-grouping-indicator-padding, 'comfortable') + ); + padding-inline-end: pad-inline( + map.get($grid-grouping-indicator-padding, 'compact'), + map.get($grid-grouping-indicator-padding, 'cosy'), + map.get($grid-grouping-indicator-padding, 'comfortable') + ); + border-inline-end: rem(1px) solid var-get($theme, 'header-border-color'); + background: inherit; + z-index: 1; + background-clip: border-box; + + &::after { + content: ''; + position: absolute; + width: 100%; + height: rem(1px); + bottom: rem(-1px); + inset-inline-start: 0; + background: transparent; + } + + %igx-icon-button-display { + width: rem(28px); + height: rem(28px); + color: var-get($theme, 'expand-all-icon-color'); + } + + &:focus, + &:hover { + %igx-icon-button-display { + color: var-get($theme, 'expand-all-icon-hover-color'); + } + } + } + + %igx-grid-grouparea { + grid-row: 2; + display: flex; + align-items: center; + justify-content: flex-start; + flex-wrap: wrap; + border-bottom: $grid-header-border; + background: var-get($theme, 'grouparea-background'); + color: var-get($theme, 'grouparea-color'); + min-height: var(--grouparea-size); + padding-inline: pad-inline( + map.get($grouparea-padding-inline, 'compact'), + map.get($grouparea-padding-inline, 'cosy'), + map.get($grouparea-padding-inline, 'comfortable') + ); + padding-block: 0; + z-index: 2; + height: 100%; + overflow: hidden; + + &:focus { + outline-style: none; + } + + %igx-chip { + margin-block: pad-block(rem(4px), rem(8px), rem(8px)); + } + } + + %igx-grid-grouparea__connector { + display: inline-flex; + justify-content: center; + align-items: center; + margin: 0 rem(4px); + + igx-icon { + width: var(--igx-icon-size, #{rem(16px)}); + height: var(--igx-icon-size, #{rem(16px)}); + font-size: var(--igx-icon-size, #{rem(16px)}); + } + + [dir='rtl'] & { + transform: scaleX(-1); + } + } + + %igx-drop-area { + display: flex; + align-items: center; + justify-content: flex-start; + min-width: rem(80px); + height: sizable( + map.get($drop-area-height, 'compact'), + map.get($drop-area-height, 'cosy'), + map.get($drop-area-height, 'comfortable') + ); + + @if $variant != 'indigo' { + padding-inline: pad-inline( + map.get($grid-cell-padding-inline, 'compact'), + map.get($grid-cell-padding-inline, 'cosy'), + map.get($grid-cell-padding-inline, 'comfortable') + ); + } @else { + padding-inline: pad-inline(rem(8px), rem(12px), rem(16px)); + } + + padding-block: 0; + flex: 1 0 0%; + background: var-get($theme, 'drop-area-background'); + border-radius: var-get($theme, 'drop-area-border-radius'); + + %igx-drop-area__icon { + color: var-get($theme, 'drop-area-icon-color'); + width: rem(16px); + height: rem(16px); + font-size: rem(16px); + margin-inline-end: rem(8px); + } + } + + %igx-drop-area--hover { + background: var-get($theme, 'drop-area-on-drop-background'); + } + + %igx-drop-area__text { + @include ellipsis(); + color: var-get($theme, 'drop-area-text-color'); + font-size: rem(13px); + } + + %igx-grid__grouping-indicator { + position: relative; + display: flex; + user-select: none; + justify-content: center; + align-items: center; + z-index: 1; + cursor: pointer; + padding-inline-end: if($variant == 'indigo', rem(16px), rem(12px)); + margin-inline-start: sizable( + #{map.get($grid-grouping-indicator-padding, 'compact')}, + #{map.get($grid-grouping-indicator-padding, 'cosy')}, + #{map.get($grid-grouping-indicator-padding, 'comfortable') + }); + min-height: var(--header-size); + + igx-icon { + --component-size: #{if($variant == 'indigo', 2, 3)}; + + color: var-get($theme, 'expand-icon-color'); + } + + &:hover, + &:focus { + outline-style: none; + + igx-icon { + color: var-get($theme, 'expand-icon-hover-color'); + } + } + + [dir='rtl'] & { + transform: scaleX(-1); + } + } + + %igx-grid__header-indentation { + position: relative; + padding-inline-end: sizable( + map.get($grid-grouping-indicator-padding, 'compact'), + map.get($grid-grouping-indicator-padding, 'cosy'), + map.get($grid-grouping-indicator-padding, 'comfortable') + ); + border-inline-end: rem(1px) solid var-get($theme, 'header-border-color'); + background: var-get($theme, 'header-background'); + z-index: 4; + + igx-icon { + --component-size: #{if($variant == 'indigo', 2, 3)}; + } + } + + %igx-grid__group-expand-btn { + position: absolute; + cursor: pointer; + user-select: none; + inset-block-start: calc(50% - #{$indicator-icon-width} / 2); + inset-inline-start: var(--indicator-inline-inset); + + &:hover { + color: var-get($theme, 'expand-icon-hover-color'); + } + + &%igx-grid__group-expand-btn--push { + inset-block-start: sizable( + math.div(map.get($grid-header-height, 'compact') - $indicator-icon-width, 2), + math.div(map.get($grid-header-height, 'cosy') - $indicator-icon-width, 2), + math.div(map.get($grid-header-height, 'comfortable') - $indicator-icon-width, 2) + ); + } + } + + @for $i from 1 through 10 { + $row-indentation-level: ( + comfortable: calc(#{$i * map.get($grid-grouping-indicator-padding, 'comfortable')} + #{$indicator-icon-width}), + cosy: calc(#{$i * map.get($grid-grouping-indicator-padding, 'cosy')} + #{$indicator-icon-width}), + compact: calc(#{$i * map.get($grid-grouping-indicator-padding, 'compact')} + #{$indicator-icon-width}) + ); + + $level--comfortable: map.get($row-indentation-level, 'comfortable'); + $level--cosy: map.get($row-indentation-level, 'cosy'); + $level--compact: map.get($row-indentation-level, 'compact'); + + %igx-grid__row-indentation--level-#{$i} { + --indicator-inline-inset: #{sizable( + map.get($grid-grouping-indicator-padding, 'compact'), + map.get($grid-grouping-indicator-padding, 'cosy'), + map.get($grid-grouping-indicator-padding, 'comfortable') + )}; + padding-inline-start: pad-inline($level--compact, $level--cosy, $level--comfortable); + } + + $indicator-padding--comfortable: #{$i * map.get($grid-grouping-indicator-padding, 'comfortable')}; + $indicator-padding--cosy: #{$i * map.get($grid-grouping-indicator-padding, 'cosy')}; + $indicator-padding--compact: #{$i * map.get($grid-grouping-indicator-padding, 'compact')}; + + %igx-grid__group-row--padding-level-#{$i} { + %igx-grid__grouping-indicator { + padding-inline-start: pad-inline($indicator-padding--compact, $indicator-padding--cosy, $indicator-padding--comfortable); + } + } + } + + %igx-grid__outlet { + --ig-size: var(--grid-size); + + z-index: 10002; + position: fixed; + } + + %igx-grid__loading-outlet { + z-index: 10003; + + > %overlay-wrapper--modal { + background: none; + } + + %circular-display { + width: rem(50); + height: rem(50); + } + } + + %igx-grid__row-editing-outlet { + z-index: 10000; + position: absolute; + + %overlay-wrapper { + /* Change wrapper position from 'fixed' to 'absolute' so that it is hidden when scrolled below the parent grid body content. */ + position: absolute; + } + } + + %igx-grid__addrow-snackbar { + position: absolute; + z-index: 5; + bottom: rem(24px); + inset-inline-start: 50%; + transform: translateX(-50%); + } + + %igx-grid__filtering-cell { + display: flex; + align-items: center; + border-inline-end: $grid-header-border; + border-top: $grid-header-border; + height: var(--header-size); + + @if $variant != 'indigo' { + padding-inline: pad-inline( + map.get($grid-header-padding-inline, 'compact'), + map.get($grid-header-padding-inline, 'cosy'), + map.get($grid-header-padding-inline, 'comfortable') + ); + } @else { + padding-inline: pad-inline(rem(8px), rem(12px), rem(16px)); + } + + padding-block: 0; + overflow: hidden; + + igx-chips-area { + transition: transform .25s $ease-out-back; + flex-wrap: nowrap; + + .igx-filtering-chips__connector { + font-size: rem(12px); + text-transform: uppercase; + font-weight: 600; + margin: 0 rem(8px); + } + } + } + + %igx-grid__filtering-cell--selected { + color: var-get($theme, 'header-selected-text-color'); + background: var-get($theme, 'header-selected-background'); + } + + %igx-grid__filtering-cell-indicator { + position: relative; + display: flex; + align-items: center; + justify-content: center; + padding-inline-end: rem(8px); + margin-inline-start: rem(8px); + cursor: pointer; + visibility: visible; + + igx-icon { + --component-size: #{if($variant == 'indigo', 2, 1)}; + } + + %igx-badge-display { + --size: #{rem(14px)}; + --font-size: #{rem(12px)}; + line-height: 0; + position: absolute; + inset-inline-end: 0; + } + } + + %igx-grid__filtering-cell-indicator--hidden { + visibility: hidden; + } + + %igx-grid__filtering-row { + position: absolute; + display: flex; + width: 100%; + height: $filtering-row-height; + padding-inline: pad-inline($cell-padding-compact, $cell-padding-cosy, $cell-padding-comfortable); + align-items: center; + justify-content: space-between; + background: var-get($theme, 'filtering-row-background'); + color: var-get($theme, 'filtering-row-text-color'); + inset-inline-start: 0; + bottom: 0; + z-index: 3; + + &::after { + display: block; + position: absolute; + content: ''; + background: inherit; + inset-inline-start: 0; + inset-inline-end: 0; + top: 0; + bottom: 0; + box-shadow: 0 rem(1px) 0 var-get($theme, 'filtering-row-background'), + 0 rem(4px) rem(10px) rgba(0, 0, 0, .12); + z-index: -1; + } + + @extend %filtering-row-input-overrides !optional; + + igx-input-group { + --ig-size: var(--grid-size) !important; + } + + [igxIconButton] { + --ig-size: 1; + } + + @if $variant == 'bootstrap' { + [igxButton] { + margin: rem(4px); + } + } + } + + %igx-grid__filtering-dropdown-items { + display: flex; + align-items: center; + } + + %igx-grid__filtering-dropdown-text { + margin-inline-start: rem(16px); + } + + %igx-grid__filtering-row-main { + display: flex; + flex: 1; + overflow: hidden; + max-width: calc(100% - 176px); + min-width: rem(56px); + + igx-chips-area { + transition: transform .25s $ease-out-back; + flex-wrap: nowrap; + margin-inline: if($variant == 'indigo', rem(12px), rem(8px)); + gap: rem(4px); + } + + @if $variant != 'indigo' { + igx-chip { + margin: 0 rem(4px); + } + + [igxButton] { + igx-icon { + position: absolute; + inset-inline-start: rem(12px); + // IE fix for vertical alignment + top: 50%; + transform: translateY(-50%); + } + + span { + margin-inline-start: rem(16px); + } + } + } + } + + %igx-grid__filtering-scroll-start { + &::after { + @include _filtering-scroll-mask($theme, right); + inset-inline-start: calc(100% + 6px); + } + + [dir='rtl'] & { + &::before { + @include _filtering-scroll-mask($theme, right); + inset-inline-end: calc(100% + 6px); + } + } + } + + %igx-grid__filtering-scroll-end { + &::before { + @include _filtering-scroll-mask($theme, left); + inset-inline-end: calc(100% + 6px); + } + + [dir='rtl'] & { + &::after { + @include _filtering-scroll-mask($theme, left); + inset-inline-start: calc(100% + 6px); + } + } + } + + %igx-grid__filtering-scroll-start, + %igx-grid__filtering-scroll-end { + width: rem(24px); + height: rem(24px); + position: relative; + overflow: visible; + margin: if($variant == 'indigo', rem(12px), rem(8px)); + z-index: 1; + + [dir='rtl'] & { + transform: scaleX(-1); + + &::after { + content: initial; + } + } + } + + %igx-grid__tr--highlighted { + position: relative; + + &::after { + content: ''; + position: absolute; + top: 0; + inset-inline-start: 0; + width: rem(4px); + height: 100%; + background: var-get($theme, 'row-highlight'); + z-index: 3; + } + + %igx-grid__tr--edited { + &::before { + inset-inline-start: rem(4px); + } + } + + &::before { + inset-inline-start: rem(4px); + } + } + + %igx-grid__tr-container { + overflow: auto; + width: 100%; + border-bottom: rem(1px) solid var-get($theme, 'row-border-color'); + } + + %igx-grid__tr-container--active { + @extend %grid-cell--active !optional; + } + + %igx-grid__hierarchical-expander { + user-select: none; + background: inherit; + padding-inline: pad-inline( + map.get($hierarchical-grid-indent, 'compact'), + map.get($hierarchical-grid-indent, 'cosy'), + map.get($hierarchical-grid-indent, 'comfortable') + ); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 3; + color: var-get($theme, 'expand-icon-color'); + background-clip: border-box; + + &:focus { + outline: none; + + igx-icon { + color: var-get($theme, 'expand-icon-hover-color'); + } + } + + &:hover { + igx-icon { + color: var-get($theme, 'expand-icon-hover-color'); + } + } + + igx-icon { + --component-size: #{if($variant == 'indigo', 2, 3)};; + + color: var-get($theme, 'expand-icon-color'); + max-width: $hierarchical-action-icon; + min-width: min-content; + } + + [dir='rtl'] & { + transform: scaleX(-1); + } + + &--empty { + cursor: default; + pointer-events: none; + } + } + + %igx-grid__hierarchical-expander--header { + background: inherit; + border-inline-end: rem(1px) solid var-get($theme, 'header-border-color'); + z-index: 3; + background-clip: border-box; + + igx-icon { + display: flex; + align-items: center; + } + } + + %igx-grid__hierarchical-expander--push { + align-items: flex-start; + + igx-icon { + min-height: var(--header-size); + max-height: var(--header-size); + } + } + + %igx-grid__header-indentation--no-border { + border-inline-end: rem(1px) solid transparent; + } + + %igx-grid__hierarchical-indent { + display: flex; + margin-inline-start: pad-inline( + map.get($hierarchical-indent, 'compact'), + map.get($hierarchical-indent, 'cosy'), + map.get($hierarchical-indent, 'comfortable') + ); + margin-inline-end: pad-inline( + map.get($hierarchical-grid-indent, 'compact'), + map.get($hierarchical-grid-indent, 'cosy'), + map.get($hierarchical-grid-indent, 'comfortable') + ); + margin-block: pad-block( + map.get($hierarchical-grid-indent, 'compact'), + map.get($hierarchical-grid-indent, 'cosy'), + map.get($hierarchical-grid-indent, 'comfortable') + ); + + &--scroll { + margin-inline-end: pad-inline( + map.get($hierarchical-indent-scroll, 'compact'), + map.get($hierarchical-indent-scroll, 'cosy'), + map.get($hierarchical-indent-scroll, 'comfortable') + ); + } + } + + @include excel-filtering($theme); + + %advanced-filtering-dialog { + @if $variant == 'indigo' { + $light-variant: contrast-color($color: 'gray', $variant: 900); + $dark-variant: color($color: 'gray', $variant: 50); + background: if($theme-variant == 'light', $light-variant, $dark-variant); + } @else { + background: color($color: 'surface', $variant: 500); + } + + box-shadow: elevation(if($variant == 'indigo', if($theme-variant == 'light', 24, 23), 24)); + + @if $variant == 'material' or $variant == 'bootstrap' { + border-radius: rem(4px); + } + + @if $variant == 'fluent' { + border-radius: rem(2px); + } + + @if $variant == 'indigo' { + border-radius: rem(10px); + } + + igx-query-builder { + box-shadow: none; + border: none; + border-radius: inherit; + } + + igx-query-builder-header { + cursor: grab; + } + } + + %igx-grid__filtering-row-editing-buttons--small, + %igx-grid__filtering-row-editing-buttons { + display: flex; + align-items: center; + + button { + transition: none; + } + } + + %igx-grid__filtering-row-editing-buttons--small { + button { + &:not([disabled]) { + igx-icon { + color: var-get($theme, 'sorted-header-icon-color'); + } + } + } + } + + %igx-grid__tr-action { + &:last-of-type { + border-inline-end: var-get($theme, 'header-border-width') var-get($theme, 'header-border-style') var-get($theme, 'header-border-color'); + @if $variant != 'indigo' { + min-height: sizable( + rem(32px), + rem(40px), + rem(50px) + ); + } @else { + min-height: sizable( + rem(32px), + rem(40px), + rem(48px) + ); + } + } + } + + igx-child-grid-row { + igx-child-grid-row { + %igx-grid__tr-action { + border-inline-end: var-get($theme, 'header-border-width') var-get($theme, 'header-border-style') var-get($theme, 'header-border-color'); + } + } + } + + // Pivot grid + %igx-grid__pivot--super-compact { + --ig-size: 1 !important; + %grid-cell-display, + %grid-cell-header { + padding: 0 if($variant != 'indigo', rem(4px), rem(6px)) !important; + min-height: rem(24px) !important; + height: rem(24px); + } + + %grid-cell-header { + > * { + line-height: normal; + align-self: initial; + max-height: 100%; + } + } + + %igx-grid__tr-pivot--row-area { + padding-bottom: rem(4px); + } + } + + %grid-thead--pivot { + display: flex; + + %grid-thead--virtualizationWrapper { + border-inline-start: var-get($theme, 'header-border-width') var-get($theme, 'header-border-style') var-get($theme, 'header-border-color'); + } + } + + %grid-thead--virtualizationWrapper { + height: 100%; + } + + %grid-thead--virtualizationContainer { + overflow: visible; + height: 100%; + } + + %igx-grid__tr-pivot { + display: flex; + align-items: center; + background: inherit; + overflow: hidden; + z-index: 3; + height: var(--header-size); + + @if $variant != 'indigo' { + padding-inline: pad-inline( + map.get($grid-header-padding-inline, 'compact'), + map.get($grid-header-padding-inline, 'cosy'), + map.get($grid-header-padding-inline, 'comfortable') + ); + } @else { + padding-inline: pad-inline(rem(8px), rem(12px), rem(16px)); + } + + padding-block: 0; + background-clip: border-box !important; + border-inline-start: var-get($theme, 'header-border-width') var-get($theme, 'header-border-style') var-get($theme, 'header-border-color'); + border-bottom: var-get($theme, 'header-border-width') var-get($theme, 'header-border-style') var-get($theme, 'header-border-color'); + + igx-chips-area { + flex-wrap: nowrap; + width: auto; + + > * { + margin-inline-end: rem(4px); + } + + &:last-child { + margin-inline-end: 0; + } + } + } + + %igx-grid__pivot-filter-toggle { + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + position: relative; + + > igx-badge { + position: absolute; + top: rem(-4px); + inset-inline-start: 60%; + width: rem(18px); + min-width: rem(18px); + height: rem(18px); + font-size: rem(10px); + pointer-events: none; + user-select: none; + } + } + + %igx-grid__pivot-empty-chip-area { + @if $variant != 'indigo' { + line-height: normal; + font-size: rem(14px); + } @else { + @include type-style('body-2'); + + @if $theme-variant == 'light' { + color: color($color: 'gray', $variant: 600); + } @else { + color: contrast-color($color: 'gray', $variant: 50, $opacity: .6); + } + } + + margin-inline-end: 0 !important; + } + + %igx-grid__tr-pivot--row-area { + height: auto !important; + align-items: flex-end; + padding-bottom: pad-block( + map.get($pivot-row-aria-padding, 'compact'), + map.get($pivot-row-aria-padding, 'cosy'), + map.get($pivot-row-aria-padding, 'comfortable') + ); + border-inline-start: 0; + border-bottom: 0; + } + + %igx-grid__tr-pivot--small-row-area { + height: var(--header-size); + align-items: flex-end; + + border-inline-start: 0; + border-inline-end: 0; + border-bottom: var-get($theme, 'header-border-width') var-get($theme, 'header-border-style') var-get($theme, 'header-border-color'); + } + + %igx-grid__tr-pivot--filter-container { + display: flex; + flex-direction: column; + } + + %igx-grid__tr-pivot--chip_drop_indicator { + width: rem(2px); + background: var-get($theme, 'resize-line-color'); + visibility: hidden; + } + + %igx-grid__tr-pivot--drop-row-area { + flex-grow: 1; + } + + %igx-grid__tr-pivot--filter { + height: var(--header-size); + + border-inline-start: 0; + border-inline-end: 0; + border-bottom: var-get($theme, 'header-border-width') var-get($theme, 'header-border-style') var-get($theme, 'header-border-color'); + } + + %igx-grid-thead__wrapper--pivot { + border-bottom: 0; + } + + %igx-grid__tr-pivot-group { + flex: 1; + } + + %igx-grid__tr-pivot-toggle-icons { + display: inline-flex !important; + } + + %igx-grid__tr-pivot--columnDimensionLeaf { + box-shadow: none; + + igx-grid-header { + border: none; + } + } + + %igx-grid__tr-pivot--columnMultiRowSpan { + igx-grid-header { + > * { + visibility: hidden; + } + } + } + + %igx-grid__tr-header-row { + igx-pivot-row-dimension-header-group { + igx-pivot-row-dimension-header { + align-items: center; + } + + @if $variant == 'indigo' { + igx-icon { + opacity: if($theme-variant == 'light', .75, .85); + + &:hover { + opacity: 1; + cursor: pointer; + } + } + } + } + + igx-pivot-row-header-group { + @if $variant != 'indigo' { + padding-inline-start: pad-inline( + map.get($grid-header-padding-inline, 'compact'), + map.get($grid-header-padding-inline, 'cosy'), + map.get($grid-header-padding-inline, 'comfortable') + ); + } @else { + padding-inline-start: pad-inline(rem(8px), rem(12px), rem(16px)); + } + + igx-pivot-row-dimension-header { + align-items: center; + + .igx-grid-th__icons { + @if $variant != 'indigo' { + padding-inline-end: pad-inline( + map.get($grid-header-padding-inline, 'compact'), + map.get($grid-header-padding-inline, 'cosy'), + map.get($grid-header-padding-inline, 'comfortable') + ); + } @else { + padding-inline-end: pad-inline(rem(8px), rem(12px), rem(16px)); + } + + align-self: center; + } + } + + &:last-of-type { + igx-pivot-row-dimension-header { + border-inline-end: 0; + } + } + } + } + + .igx-pivot-grid-row-filler__wrapper { + .igx-grid-thead__wrapper { + height: 100%; + border-bottom: initial; + + .igx-grid-th { + height: 100%; + } + } + } + + %igx-grid__tbody-pivot-mrl-dimension { + .igx-grid-th { + border-bottom: none; + } + } + + // Pivot grid END +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/grid/_group-by-area-component.scss b/projects/igniteui-angular/core/src/core/styles/components/grid/_group-by-area-component.scss new file mode 100644 index 00000000000..62b91322102 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/grid/_group-by-area-component.scss @@ -0,0 +1,12 @@ +@use '../../base' as *; + +/// @access private +@mixin group-by-area { + @include b(igx-grid-grouparea) { + @extend %igx-grid-grouparea !optional; + + @include e(connector) { + @extend %igx-grid-grouparea__connector !optional; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/grid/_header-row-component.scss b/projects/igniteui-angular/core/src/core/styles/components/grid/_header-row-component.scss new file mode 100644 index 00000000000..a640f0ca808 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/grid/_header-row-component.scss @@ -0,0 +1,159 @@ +@use '../../base' as *; + +/// @access private +@mixin header-row { + @include b(igx-grid-thead) { + @extend %grid-thead-container !optional; + + @include e(wrapper) { + @extend %grid-thead !optional; + + igx-display-container { + @extend %grid-display-container-thead !optional; + } + + &:focus { + @extend %disable-focus-styles !optional; + } + } + + @include e(wrapper, $m: 'pivot') { + @extend %igx-grid-thead__wrapper--pivot !optional; + } + + @include e(title) { + @extend %grid-cell-display !optional; + @extend %grid-cell-header !optional; + @extend %grid-thead-title !optional; + } + + @include e(title, $m: pinned-last) { + @extend %grid-thead-title--pinned !optional; + } + + @include e(group) { + @extend %grid-thead-group !optional; + } + + @include e(subgroup) { + @extend %grid-thead-subgroup !optional; + } + + @include e(item) { + @extend %grid-thead-item !optional; + } + + @include e(thumb) { + @extend %grid-thead-thumb !optional; + } + + @include m(pivot) { + @extend %grid-thead--pivot !optional; + } + + @include m(virtualizationWrapper) { + @extend %grid-thead--virtualizationWrapper !optional; + } + + @include m(virtualizationContainer) { + @extend %grid-thead--virtualizationContainer !optional; + } + } + + @include b(igx-grid-th) { + @extend %grid-cell-display !optional; + @extend %grid-cell-header !optional; + + @include e(expander) { + @extend %igx-grid-th__expander !optional + } + + @include e(group-title) { + @extend %igx-grid-th__group-title !optional + } + + @include e(title) { + @extend %grid-cell-header-title !optional; + } + + @include e(icons) { + @extend %grid-cell-header-icons !optional; + } + + @include e(resize-handle) { + @extend %grid__resize-handle !optional; + } + + @include e(resize-line) { + @extend %grid__resize-line !optional; + } + + @include m(collapsible) { + @extend %igx-grid-th--collapsible !optional; + } + + @include m(sortable) { + @extend %igx-grid-th--sortable !optional; + } + + @include m(selectable) { + @extend %igx-grid-th--selectable !optional; + } + + @include m(filtrable) { + @extend %igx-grid-th--filtrable !optional; + } + + @include mx(filtrable, sortable) { + @extend %igx-grid-th--filtrable-sortable !optional; + } + + @include m(sorted) { + @extend %igx-grid-th--sorted !optional; + } + + @include m(selected) { + @extend %igx-grid-th--selected !optional; + } + + @include m(active) { + @extend %igx-grid-th--active !optional; + } + + @include m(number) { + @extend %grid-cell-number !optional; + } + + @include m(pinned) { + @extend %grid-cell--pinned !optional; + } + + @include m(pinned-last) { + @extend %grid-cell--pinned-last !optional; + } + + @include m(pinned-first) { + @extend %grid-cell--pinned-first !optional; + } + + @include m(fw) { + @extend %grid-cell--fixed-width !optional; + } + + @include m(filtering) { + @extend %grid-cell-header--filtering !optional; + } + + @include e(drop-indicator-left) { + @extend %igx-grid-th__drop-indicator-left !optional; + } + + @include e(drop-indicator-right) { + @extend %igx-grid-th__drop-indicator-right !optional; + } + + @include e(drop-indicator, $m: active) { + @extend %igx-grid-th__drop-indicator--active !optional; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/grid/_pivot-data-selector-component.scss b/projects/igniteui-angular/core/src/core/styles/components/grid/_pivot-data-selector-component.scss new file mode 100644 index 00000000000..626138e8852 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/grid/_pivot-data-selector-component.scss @@ -0,0 +1,70 @@ +@use '../../base' as *; +@use 'sass:string'; + +@mixin component() { + @include b(igx-pivot-data-selector) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: () + ); + + @extend %selector-base !optional; + + @include e(header) { + @extend %selector-header !optional; + } + + @include e(header-extra) { + @extend %selector-header-extra !optional; + } + + @include e(filter) { + @extend %selector-filter !optional; + } + + @include e(item) { + @extend %selector-item !optional; + } + + @include e(item-ghost) { + @extend %selector-item-ghost !optional; + } + + @include e(item-ghost, $m: no-drop) { + @extend %selector-item-ghost--no-drop !optional; + } + + @include e(item-ghost-text) { + @extend %selector-item-ghost-text !optional; + } + + @include e(item-start) { + @extend %selector-item-start !optional; + } + + @include e(item-end) { + @extend %selector-item-end !optional; + } + + @include e(item-text) { + @extend %selector-item-text !optional; + } + + @include e(action-sort) { + @extend %selector-action-sort !optional; + } + + @include e(action-filter) { + @extend %selector-action-filter !optional; + } + + @include e(action-move) { + @extend %selector-action-move !optional; + } + + @include e(action-summary) { + @extend %selector-action-summary !optional; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/grid/_pivot-data-selector-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/grid/_pivot-data-selector-theme.scss new file mode 100644 index 00000000000..52d94dffac7 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/grid/_pivot-data-selector-theme.scss @@ -0,0 +1,264 @@ +@use 'sass:map'; +@use 'sass:meta'; +@use '../../base' as *; +@use '../../themes/schemas' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin pivot-data-selector($theme) { + @include css-vars($theme); + + $variant: map.get($theme, '_meta', 'theme'); + + $chip-height-material: ( + comfortable: rem(22px), + cosy: rem(20px), + compact: rem(18px) + ); + + $chip-item-padding: 0 #{rem(2px)}; + $panel-padding: rem(4px, 16px); + + %selector-base { + display: flex; + flex-direction: column; + max-width: rem(280px); + background: var-get($theme, 'background'); + z-index: 0; + + > igx-input-group { + flex: 0 1 auto; + } + + igx-display-container { + display: flex; + flex-direction: column; + } + + > igx-list { + igx-display-container { + padding: rem(4px); + } + + igx-list-item { + display: flex; + min-height: rem(28px); + } + + %cbx-label { + font-size: rem(13px); + } + } + + %cbx-composite-wrapper { + @if $variant == 'material' { + padding: 0; + } + } + + %form-group-input--box { + transform: none; + } + + %form-group-prefix { + @if $variant == 'material' { + padding-inline-end: rem(16px) !important; + } + + box-sizing: content-box; + } + + %form-group-bundle-main--box { + padding-top: 0 !important; + } + + %igx-expanded-panel-margin { + igx-expansion-panel[aria-expanded='true'] { + margin-top: 0; + margin-bottom: 0; + } + } + + %igx-expansion-panel__body { + position: relative; + height: rem(128px); + font-size: rem(14px); + padding: $panel-padding; + overflow-y: auto; + + > igx-list { + height: auto; + } + } + + %igx-expansion-panel__header-icon--start { + margin-inline-end: rem(8px); + } + + %igx-expansion-panel__header-title { + display: flex; + + > h6 { + font-size: rem(12px); + margin-bottom: 0; + } + } + + %igx-expansion-panel__header-inner { + background: var-get($theme, 'header-color'); + padding: $panel-padding; + + .dragOver & { + background: color($color: 'gray', $variant: 300); + box-shadow: inset 0 0 0 rem(1px) color($color: 'gray', $variant: 400); + } + } + } + + %selector-filter { + display: flex; + flex-direction: column; + overflow: hidden; + + igx-input-group { + @if $variant == 'bootstrap' { + padding: rem(4px); + } + } + + igx-list { + display: flex; + flex-direction: column; + padding: rem(8px) rem(4px); + min-height: rem(186px); + max-height: rem(208px); + overflow-y: auto; + } + + igx-list-item { + display: flex; + } + + igx-checkbox + span { + margin-inline-start: rem(8px); + line-height: rem(28px); + } + } + + %selector-header, + %selector-header-extra { + display: flex; + align-items: center; + } + + %selector-header-extra { + igx-icon { + padding: 0 rem(8px); + box-sizing: content-box; + } + + %igx-chip__item { + height: #{ + sizable( + map.get($chip-height-material, 'compact'), + map.get($chip-height-material, 'cosy'), + map.get($chip-height-material, 'comfortable') + )}; + } + + %igx-chip__content { + padding: $chip-item-padding; + } + } + + %selector-item { + display: flex; + align-items: center; + justify-content: space-between; + min-height: rem(32px); + width: 100%; + + .igx-drag--push & { + padding-top: rem(32px); + } + } + + %selector-item-ghost { + display: flex; + align-items: center; + justify-content: space-between; + font-size: rem(14px); + background: color($color: 'surface'); + min-height: rem(32px); + height: auto; + padding: 0 rem(2px) 0 rem(4px); + cursor: grabbing; + box-shadow: elevation(24); + border: rem(1px) solid color($color: 'gray', $variant: 100); + border-radius: border-radius(rem(2px)); + z-index: 10; + } + + %selector-base, + %selector-item-ghost { + igx-icon { + width: var(--igx-icon-size, #{rem(18px)}); + height: var(--igx-icon-size, #{rem(18px)}); + font-size: var(--igx-icon-size, #{rem(18px)}); + } + } + + %selector-item-ghost-text { + display: flex; + align-items: center; + + igx-icon { + margin-inline-end: rem(8px); + } + } + + %selector-item-ghost--no-drop { + cursor: no-drop; + } + + %selector-item-text { + @include ellipsis(); + max-width: calc(100% - rem(18px) + rem(8px)); + } + + %selector-item-text, + %selector-action-sort, + %selector-action-filter, + %selector-action-move, + %selector-action-summary { + user-select: none; + } + + %selector-action-sort, + %selector-action-summary, + %selector-action-filter { + cursor: pointer; + } + + %selector-action-move { + cursor: grab; + } + + %selector-item-start { + display: flex; + justify-content: space-between; + align-items: center; + flex: 0 1 100%; + margin-inline-end: rem(8px); + overflow: hidden; + } + + %selector-item-end { + display: flex; + + igx-icon + igx-icon { + margin-inline-start: rem(8px); + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/highlight/highlight-component.scss b/projects/igniteui-angular/core/src/core/styles/components/highlight/highlight-component.scss new file mode 100644 index 00000000000..7f41cc94dee --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/highlight/highlight-component.scss @@ -0,0 +1,22 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin component { + @include b(igx-highlight) { + // Register the component in the component registry + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: () + ); + + @extend %igx-highlight !optional; + + @include m(active) { + @extend %igx-highlight !optional; + @extend %igx-highlight--active !optional; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/highlight/highlight-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/highlight/highlight-theme.scss new file mode 100644 index 00000000000..3bc86edc412 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/highlight/highlight-theme.scss @@ -0,0 +1,19 @@ +@use 'sass:map'; +@use '../../base' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin highlight($theme) { + @include css-vars($theme, '.igx-highlight'); + + %igx-highlight { + color: var-get($theme, 'resting-color'); + background: var-get($theme, 'resting-background'); + } + + %igx-highlight--active { + color: var-get($theme, 'active-color'); + background: var-get($theme, 'active-background'); + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/icon-button/_icon-button-component.scss b/projects/igniteui-angular/core/src/core/styles/components/icon-button/_icon-button-component.scss new file mode 100644 index 00000000000..604c310e5d6 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/icon-button/_icon-button-component.scss @@ -0,0 +1,32 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Silvia Ivanova +@mixin component { + @include b(igx-icon-button) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: () + ); + + @extend %igx-icon-button-display !optional; + + @include m(flat) { + @extend %igx-icon-button--flat !optional; + } + + @include m(contained) { + @extend %igx-icon-button--contained !optional; + } + + @include m(outlined) { + @extend %igx-icon-button--outlined !optional; + } + + @include m(disabled) { + @extend %igx-button--disabled !optional; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/icon-button/_icon-button-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/icon-button/_icon-button-theme.scss new file mode 100644 index 00000000000..9d5216b6d40 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/icon-button/_icon-button-theme.scss @@ -0,0 +1,385 @@ +@use 'sass:map'; +@use 'sass:meta'; +@use 'sass:list'; +@use '../../base' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin icon-button($themes...) { + $flat-theme: null; + $contained-theme: null; + $outlined-theme: null; + $variant: 'material'; + + $required: ('flat', 'contained', 'outlined'); + $added: (); + $missing: (); + + @each $key, $theme in meta.keywords($themes) { + $type: map.get($theme, _meta, type); + + $added: list.append($added, $key); + + @if $type == 'flat' { + $flat-theme: $theme; + } @else if $type == 'contained' { + $contained-theme: $theme; + } @else if $type == 'outlined' { + $outlined-theme: $theme; + } + + $variant: map.get($theme, '_meta', 'theme'); + @include css-vars($theme); + } + + @each $item in $required { + @if not(list.index($added, $item)) { + $missing: list.append($missing, '$#{$item}', $separator: comma); + } + } + + @if list.length($missing) != 0 { + @error meta.inspect(string.unquote("Missing theme properties:") #{$missing}); + } + + $icon-sizes: map.get(( + 'material': rem(18px), + 'fluent': rem(18px), + 'bootstrap': rem(18px), + 'indigo': rem(16px), + ), $variant); + + $icon-in-button-size: $icon-sizes; + + $items-gap: ( + comfortable: rem(9px), + cosy: rem(6px), + compact: rem(3px) + ); + + $btn-indent: rem(2px); + + %igx-icon-button-display { + @include sizable(); + + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + user-select: none; + outline-style: none; + -webkit-tap-highlight-color: transparent; + overflow: hidden; + white-space: nowrap; + transition: + box-shadow var(--_init-transition, .2s) ease-in, + background var(--_init-transition, .15s) ease-out; + transition-delay: var(--_init-transition, .05s); + min-width: unset; + min-height: unset; + font-size: rem(24px, 24px); + padding: 0; + gap: pad-inline( + map.get($items-gap, 'compact'), + map.get($items-gap, 'cosy'), + map.get($items-gap, 'comfortable') + ); + + igx-icon { + --component-size: var(--ig-size, var(--ig-size-large)); + display: flex; + justify-content: center; + width: var(--igx-icon-size, #{$icon-in-button-size}); + height: var(--igx-icon-size, #{$icon-in-button-size}); + font-size: var(--igx-icon-size, #{$icon-in-button-size}); + } + + @if $variant == 'fluent' { + transition: + color var(--_init-transition, .15s) ease-out, + background var(--_init-transition, .15s) ease-out; + + &::after { + position: absolute; + content: ''; + pointer-events: none; + inset-block-start: $btn-indent; + inset-inline-start: $btn-indent; + width: calc(100% - (#{$btn-indent * 2})); + height: calc(100% - (#{$btn-indent * 2})); + } + } + + @if $variant == 'bootstrap' { + transition: + box-shadow var(--_init-transition, .15s) ease-out, + color var(--_init-transition, .15s) ease-out, + background var(--_init-transition, .15s) ease-out; + } + + @if $variant == 'indigo' { + transition: + color var(--_init-transition, .15s) ease-in-out, + box-shadow var(--_init-transition, .15s) ease-in-out, + background var(--_init-transition, .15s) ease-in-out, + border-color var(--_init-transition, .15s) ease-in-out; + } + } + + %igx-icon-button--flat { + --component-size: var(--ig-size, #{var-get($flat-theme, 'default-size')}); + width: var-get($flat-theme, 'size'); + height: var-get($flat-theme, 'size'); + background: var-get($flat-theme, 'background'); + color: var-get($flat-theme, 'foreground'); + border: rem(1px) solid var-get($flat-theme, 'border-color'); + border-radius: var-get($flat-theme, 'border-radius'); + + &:hover { + background: var-get($flat-theme, 'hover-background'); + color: var-get($flat-theme, 'hover-foreground'); + } + + @if $variant == 'material' { + &:active { + background: var-get($flat-theme, 'focus-background'); + color: var-get($flat-theme, 'focus-foreground'); + } + } @else { + &:active { + background: var-get($flat-theme, 'active-background'); + color: var-get($flat-theme, 'active-foreground'); + } + } + } + + %igx-icon-button--flat.igx-button--focused { + background: var-get($flat-theme, 'focus-background'); + color: var-get($flat-theme, 'focus-foreground'); + + &:hover { + background: var-get($flat-theme, 'focus-hover-background'); + color: var-get($flat-theme, 'focus-hover-foreground'); + } + + &:active { + background: var-get($flat-theme, 'active-background'); + color: var-get($flat-theme, 'active-foreground'); + } + + @if $variant == 'fluent' { + &::after { + box-shadow: 0 0 0 rem(1px) var-get($flat-theme, 'focus-border-color'); + } + } + + @if $variant == 'bootstrap' { + box-shadow: 0 0 0 rem(4px) var-get($flat-theme, 'shadow-color'); + } + + @if $variant == 'indigo' { + border-color: var-get($flat-theme, 'border-color'); + box-shadow: 0 0 0 rem(3px) var-get($flat-theme, 'focus-border-color'); + } + } + + %igx-icon-button--contained { + --component-size: var(--ig-size, #{var-get($contained-theme, 'default-size')}); + width: var-get($contained-theme, 'size'); + height: var-get($contained-theme, 'size'); + background: var-get($contained-theme, 'background'); + color: var-get($contained-theme, 'foreground'); + border: rem(1px) solid var-get($contained-theme, 'border-color'); + border-radius: var-get($contained-theme, 'border-radius'); + + &:hover { + background: var-get($contained-theme, 'hover-background'); + color: var-get($contained-theme, 'hover-foreground'); + } + + @if $variant == 'material' { + &:active { + background: var-get($contained-theme, 'focus-background'); + color: var-get($contained-theme, 'focus-foreground'); + } + } @else { + &:active { + background: var-get($contained-theme, 'active-background'); + color: var-get($contained-theme, 'active-foreground'); + } + } + } + + %igx-icon-button--contained.igx-button--focused { + background: var-get($contained-theme, 'focus-background'); + color: var-get($contained-theme, 'focus-foreground'); + + @if $variant != 'fluent' { + border-color: var-get($contained-theme, 'focus-border-color'); + } + + &:hover { + background: var-get($contained-theme, 'focus-hover-background'); + color: var-get($contained-theme, 'focus-hover-foreground'); + } + + &:active { + background: var-get($contained-theme, 'active-background'); + color: var-get($contained-theme, 'active-foreground'); + } + + @if $variant == 'fluent' { + &::after { + box-shadow: 0 0 0 rem(1px) var-get($contained-theme, 'focus-border-color'); + } + } + + @if $variant == 'bootstrap' { + box-shadow: 0 0 0 rem(4px) var-get($contained-theme, 'shadow-color'); + } + + @if $variant == 'indigo' { + border-color: var-get($contained-theme, 'border-color'); + box-shadow: 0 0 0 rem(3px) var-get($contained-theme, 'focus-border-color'); + } + } + + %igx-icon-button--outlined { + --component-size: var(--ig-size, #{var-get($outlined-theme, 'default-size')}); + width: var-get($outlined-theme, 'size'); + height: var-get($outlined-theme, 'size'); + background: var-get($outlined-theme, 'background'); + color: var-get($outlined-theme, 'foreground'); + border: rem(1px) solid var-get($outlined-theme, 'border-color'); + border-radius: var-get($outlined-theme, 'border-radius'); + + @if $variant == 'indigo' { + border-width: rem(2px); + + &:hover, + &:active { + border-color: var-get($outlined-theme, 'foreground'); + } + } + + &:hover { + background: var-get($outlined-theme, 'hover-background'); + color: var-get($outlined-theme, 'hover-foreground'); + + @if $variant == 'bootstrap' { + border-color: var-get($outlined-theme, 'hover-background'); + } + } + + @if $variant == 'material' { + border: none; + + &::after { + position: absolute; + content: ''; + inset: 0; + box-shadow: inset 0 0 0 rem(1px) var-get($outlined-theme, 'border-color'); + border-radius: inherit; + } + + &:active { + background: var-get($outlined-theme, 'focus-background'); + color: var-get($outlined-theme, 'focus-foreground'); + } + } @else { + &:active { + background: var-get($outlined-theme, 'active-background'); + color: var-get($outlined-theme, 'active-foreground'); + + @if $variant == 'bootstrap' { + border-color: var-get($outlined-theme, 'focus-border-color'); + } + } + } + } + + %igx-icon-button--outlined.igx-button--focused { + background: var-get($outlined-theme, 'focus-background'); + color: var-get($outlined-theme, 'focus-foreground'); + + @if $variant == 'material' { + border: none; + + &::after { + box-shadow: inset 0 0 0 rem(1px) var-get($outlined-theme, 'focus-border-color'); + } + } + + @if $variant != 'bootstrap' { + border-color: var-get($outlined-theme, 'focus-border-color'); + } + + &:hover { + background: var-get($contained-theme, 'focus-hover-background'); + color: var-get($contained-theme, 'focus-hover-foreground'); + + @if $variant == 'bootstrap' { + border-color: var-get($outlined-theme, 'focus-hover-background'); + } + } + + &:active { + background: var-get($outlined-theme, 'active-background'); + color: var-get($outlined-theme, 'active-foreground'); + + @if $variant == 'bootstrap' { + border-color: var-get($outlined-theme, 'focus-border-color'); + } + } + + @if $variant == 'fluent' { + border: rem(1px) solid var-get($outlined-theme, 'border-color'); + + &::after { + box-shadow: 0 0 0 rem(1px) var-get($outlined-theme, 'focus-border-color'); + } + } + + @if $variant == 'bootstrap' { + box-shadow: 0 0 0 rem(4px) var-get($outlined-theme, 'shadow-color'); + } + + @if $variant == 'indigo' { + border-color: var-get($outlined-theme, 'border-color'); + box-shadow: 0 0 0 rem(3px) var-get($outlined-theme, 'focus-border-color'); + + &:hover, + &:active { + border-color: var-get($outlined-theme, 'foreground'); + } + } + } + + %igx-button--disabled { + background: var-get($flat-theme, 'disabled-background'); + color: var-get($flat-theme, 'disabled-foreground'); + border-color: var-get($flat-theme, 'disabled-border-color'); + pointer-events: none; + box-shadow: none; + + &:focus { + box-shadow: none; + } + } +} + +/// Adds typography styles for the igx-icon-button component. +/// Uses the 'button' category from the typographic scale. +/// @group typography +/// @param {String} $categories [(text: 'button')] - The category from the typographic scale used for type styles. +@mixin icon-button-typography($categories: (text: 'button')) { + $text: map.get($categories, 'text'); + + %igx-icon-button-display { + @include type-style($text) { + text-align: center; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/icon/_icon-component.scss b/projects/igniteui-angular/core/src/core/styles/components/icon/_icon-component.scss new file mode 100644 index 00000000000..ec80606aad7 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/icon/_icon-component.scss @@ -0,0 +1,36 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin component { + @include b(igx-icon) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: () + ); + + @extend %igx-icon-display !optional; + + @include m(inactive) { + @extend %igx-icon--inactive !optional; + } + + @include m(success) { + @extend %igx-icon--success !optional; + } + + @include m(error) { + @extend %igx-icon--error !optional; + } + } + + @include b(igx-svg-container) { + visibility: hidden; + width: 0; + height: 0; + font-size: 0; + overflow: hidden; + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/icon/_icon-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/icon/_icon-theme.scss new file mode 100644 index 00000000000..c4181a79271 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/icon/_icon-theme.scss @@ -0,0 +1,57 @@ +// stylelint-disable font-family-no-missing-generic-family-keyword +@use 'sass:map'; +@use '../../base' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin icon($theme) { + @include css-vars($theme); + + $size: var-get($theme, 'size'); + + // The igx-icon tag selector in front of the placeholder is on purpose + // this approach effectively enables us to eliminate any potential style conflicts without the need of !important + %igx-icon-display { + @include sizable(); + --component-size: var(--ig-size, #{var-get($theme, 'default-size')}); + + display: inline-flex; + font-size: $size; + color: var-get($theme, 'color'); + direction: inherit; + + div, + svg { + display: block; + width: inherit; + height: inherit; + fill: currentColor; + } + + &[igxPrefix].material-icons, + &[igxSuffix].material-icons { + font-family: 'Material Icons'; + } + } + + // Using "em" unit here is on purpose see: + // https://github.com/IgniteUI/igniteui-angular/issues/13394#event-10241243103 + igx-icon%igx-icon-display { + width: 1em; + height: 1em; + } + + %igx-icon--success { + color: color($color: 'success'); + } + + %igx-icon--error { + color: color($color: 'error'); + } + + %igx-icon--inactive { + color: var-get($theme, 'disabled-color') !important; + opacity: .54; + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/input/_file-input-component.scss b/projects/igniteui-angular/core/src/core/styles/components/input/_file-input-component.scss new file mode 100644 index 00000000000..aebb911f782 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/input/_file-input-component.scss @@ -0,0 +1,72 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +@mixin component { + @include b(igx-file-input) { + $this: bem--selector-to-string(&); + + @include register-component( + $name: string.slice($this, 2, -1), + $deps: (igx-input-group) + ); + + @include e(file-names) { + @extend %file-names !optional; + } + + @include e(upload-button-wrapper) { + @extend %upload-button-wrapper !optional; + } + + @include e(upload-button) { + @extend %upload-button !optional; + } + + @include e(clear-icon) { + @extend %clear-icon !optional; + } + + @include m(filled) { + @include e(file-names) { + @extend %file-names--filled !optional; + } + + @include e(upload-button-wrapper) { + @extend %upload-button-wrapper--filled !optional; + } + + @include e(upload-button) { + @extend %upload-button--filled !optional; + } + } + + @include m(focused) { + @include e(file-names) { + @extend %file-names--focused !optional; + } + + @include e(upload-button-wrapper) { + @extend %upload-button-wrapper--focused !optional; + } + + @include e(upload-button) { + @extend %upload-button--focused !optional; + } + } + + @include m(disabled) { + @include e(file-names) { + @extend %file-names--disabled !optional; + } + + @include e(upload-button-wrapper) { + @extend %upload-button-wrapper--disabled !optional; + } + + @include e(upload-button) { + @extend %upload-button--disabled !optional; + } + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/input/_file-input-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/input/_file-input-theme.scss new file mode 100644 index 00000000000..6c692aaf588 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/input/_file-input-theme.scss @@ -0,0 +1,117 @@ +@use 'sass:map'; +@use 'sass:meta'; +@use '../../base' as *; +@use '../../themes/schemas' as *; +@use 'igniteui-theming/sass/animations/easings' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin file-input($theme) { + @include css-vars($theme, '.igx-file-input'); + $variant: map.get($theme, '_meta', 'theme'); + + %file-names { + color: var-get($theme, 'file-names-foreground'); + background-color: var-get($theme, 'file-names-background'); + } + + %file-names--filled { + color: var-get($theme, 'file-names-foreground--filled'); + background-color: var-get($theme, 'file-names-background--filled'); + } + + %file-names--focused { + color: var-get($theme, 'file-names-foreground--focused'); + background-color: var-get($theme, 'file-names-background--focused'); + } + + %file-names--disabled { + color: var-get($theme, 'file-names-foreground--disabled'); + background-color: var-get($theme, 'file-names-background--disabled'); + } + + %upload-button-wrapper { + background: #{var-get($theme, 'file-selector-button-background')} + } + + %upload-button { + border-inline: 0; + + // That button can't have :focus, :active, :hover states, that's why we dont need to override them here. + --foreground: #{var-get($theme, 'file-selector-button-foreground')}; + --background: transparent; + --resting-elevation: none; + --shadow-color: none; + --border-radius: 0; + } + + + %upload-button-wrapper--filled { + color: #{var-get($theme, 'file-selector-button-foreground--filled')}; + background: #{var-get($theme, 'file-selector-button-background--filled')}; + } + + %upload-button--filled { + --foreground: #{var-get($theme, 'file-selector-button-foreground--filled')}; + --background: transparent; + } + + %upload-button-wrapper--focused { + color: #{var-get($theme, 'file-selector-button-foreground--focused')}; + background: #{var-get($theme, 'file-selector-button-background--focused')}; + } + + %upload-button--focused { + --foreground: #{var-get($theme, 'file-selector-button-foreground--focused')}; + --background: transparent; + } + + %upload-button-wrapper--disabled { + color: #{var-get($theme, 'file-selector-button-foreground--disabled')}; + background: #{var-get($theme, 'file-selector-button-background--disabled')}; + } + + %upload-button--disabled { + --disabled-foreground: #{var-get($theme, 'file-selector-button-foreground--disabled')}; + --disabled-background: transparent; + } + + %clear-icon { + &:focus { + @if $variant == 'indigo' or $variant == 'fluent' { + background-color: color($color: 'primary', $variant: 500); + color: contrast-color($color: 'primary', $variant: 600); + } + + @if $variant == 'material' { + background-color: transparent; + color: color($color: 'secondary', $variant: 500); + } + + @if $variant == 'bootstrap' { + color: contrast-color($color: 'primary', $variant: 600); + background-color: color($color: 'primary', $variant: 500); + } + } + } +} + +/// Adds typography styles for the .igx-file-input. +/// Uses the 'subtitle-1', 'caption' +/// category from the typographic scale. +/// @group typography +/// @param {Map} $categories [(file-text: 'subtitle-1')] - The categories from the typographic scale used for type styles. +@mixin file-input-typography( + $categories: ( + file-text: 'subtitle-1' + ) +) { + $file-text: map.get($categories, 'file-text'); + + %file-names { + @include type-style($file-text) { + margin: 0; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/input/_input-group-component.scss b/projects/igniteui-angular/core/src/core/styles/components/input/_input-group-component.scss new file mode 100644 index 00000000000..31d41920020 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/input/_input-group-component.scss @@ -0,0 +1,1070 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin component { + @include b(igx-input-group) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: ( + igx-icon + ) + ); + + // BASE START + @extend %form-group-display !optional; + + @include e(label) { + @extend %form-group-label !optional; + } + + // Base bundle + @include e(bundle) { + @extend %form-group-bundle !optional; + + &:hover { + @extend %form-group-bundle--hover !optional; + } + } + + @include e(bundle-start) { + @extend %form-group-bundle-start !optional; + } + + @include e(bundle-end) { + @extend %form-group-bundle-end !optional; + } + + @include e(bundle-main) { + @extend %form-group-bundle-main !optional; + } + + @include e(notch) { + @extend %igx-input-group__notch !optional; + } + + @include e(filler) { + @extend %igx-input-group__filler !optional; + } + + @include e(input) { + @extend %form-group-input !optional; + @extend %autofill-background-fix !optional; + + &:hover { + @extend %form-group-input--hover !optional; + } + + &:focus { + @extend %form-group-input--focus !optional; + } + } + + @include e(file-input) { + @extend %form-group-file-input !optional; + + &:hover { + @extend %form-group-input--hover !optional; + } + + &:focus { + @extend %form-group-input--focus !optional; + } + } + + @include e(hint) { + @extend %form-group-helper !optional; + } + + @include e(hint-item, $m: start) { + @extend %form-group-helper-item !optional; + @extend %form-group-helper-item--start !optional; + } + + @include e(hint-item, $m: end) { + @extend %form-group-helper-item !optional; + @extend %form-group-helper-item--end !optional; + } + + // The animated bottom line of the LINE and BOX input types. + @include e(line) { + @extend %form-group-line !optional; + } + + // Textarea element + @include e(textarea) { + @extend %form-group-input !optional; + @extend %form-group-textarea !optional; + + &:hover { + @extend %form-group-input--hover !optional; + } + + &:focus { + @extend %form-group-input--focus !optional; + } + } + + @include m(suffixed) { + @extend %suffixed !optional; + } + + @include m(readonly) { + @extend %form-group-display--readonly !optional; + } + + // Textarea modifier + @include m(textarea-group) { + @extend %textarea-group !optional; + + @include e(bundle-main) { + @extend %form-group-textarea-group-bundle-main !optional; + } + + @include e(bundle) { + @extend %form-group-textarea-group-bundle !optional; + } + + @include e(label) { + @extend %form-group-textarea-label !optional; + } + } + + @include mx(textarea-group, border) { + @extend %textarea-group--outlined !optional; + } + + @include mx(textarea-group, box) { + @extend %textarea-group--box !optional; + } + + @include mx(textarea-group, focused) { + @include e(label) { + @extend %textarea-group-label--focused !optional; + } + } + + @include mx(textarea-group, filled) { + @include e(notch) { + @extend %textarea-group-notch--focused !optional; + } + + @include e(label) { + @extend %textarea-group-label--focused !optional; + } + } + + @include mx(textarea-group, placeholder) { + @extend %form-group-placeholder !optional; + + @include e(notch) { + @extend %textarea-group-notch--focused !optional; + } + + @include e(label) { + @extend %textarea-group-label--focused !optional; + } + } + + @include mx(textarea-group, filled, border) { + @include e(notch) { + @extend %textarea-group-notch--focused !optional; + } + + @include e(label) { + @extend %textarea-group-label--filled--border !optional; + } + } + + @include mx(textarea-group, placeholder, border) { + @extend %form-group-placeholder !optional; + + @include e(notch) { + @extend %textarea-group-notch--focused !optional; + } + + @include e(label) { + @extend %textarea-group-label--filled--border !optional; + } + } + + @include mx(textarea-group, focused, border) { + @include e(label) { + @extend %textarea-group-label--focused--border !optional; + } + } + + @include m(focused) { + @extend %form-group-display !optional; + @extend %form-group-display--focused !optional; + + @include e(bundle) { + @extend %form-group-bundle--focus !optional; + } + + @include e(label) { + @extend %form-group-label--float !optional; + @extend %form-group-label--focus !optional; + } + + @include e(line) { + @extend %form-group-line--focus !optional; + } + } + + @include m(placeholder) { + @extend %form-group-placeholder !optional; + + @include e(label) { + @extend %form-group-label--float !optional; + } + } + + @include m(filled) { + @extend %form-group-display--filled !optional; + + @include e(label) { + @extend %form-group-label--float !optional; + } + } + + @include m(file) { + @extend %form-group-display--file !optional; + + @include e(label) { + @extend %form-group-label--float !optional; + } + } + @include mx(file, focused) { + @extend %form-group-display--file-focused !optional; + } + + @include mx(file, border) { + @extend %form-group-display--file-border !optional; + } + + @include mx(file, border, focused) { + @extend %form-group-display--file-border-focused !optional; + } + + @include mx(file, border, valid) { + @extend %form-group-display--file-border-success !optional; + } + + @include mx(file, border, warning) { + @extend %form-group-display--file-border-warning !optional; + } + + @include mx(file, border, invalid) { + @extend %form-group-display--file-border-error !optional; + } + + @include m(required) { + @include e(label) { + @extend %form-group-label--required !optional; + } + } + + @include m(warning) { + @include e(label) { + @extend %form-group-label--warning !optional; + } + + @include e(line) { + @extend %form-group-line--warning !optional; + } + + @include e(hint) { + @extend %form-group-helper--warning !optional; + } + + @include e(bundle) { + @extend %form-group-bundle--warning !optional; + } + } + + @include m(invalid) { + @extend %form-group-display--invalid !optional; + + @include e(label) { + @extend %form-group-label--error !optional; + } + + @include e(line) { + @extend %form-group-line--error !optional; + } + + @include e(hint) { + @extend %form-group-helper--error !optional; + } + + @include e(bundle) { + @extend %form-group-bundle--error !optional; + } + } + + @include m(valid) { + @include e(label) { + @extend %form-group-label--success !optional; + } + + @include e(line) { + @extend %form-group-line--success !optional; + } + + @include e(hint) { + @extend %form-group-helper--success !optional; + } + + @include e(bundle) { + @extend %form-group-bundle--success !optional; + } + } + + @include m(disabled) { + @extend %form-group-display--disabled !optional; + + @include e(bundle) { + @extend %form-group-bundle--disabled !optional; + } + + @include e(label) { + @extend %form-group-label--disabled !optional; + } + + @include e(input) { + @extend %form-group-input--disabled !optional; + } + + @include e(textarea) { + @extend %form-group-textarea--disabled !optional; + } + + @include e(file-input) { + @extend %form-group-file-input--disabled !optional; + } + + @include e(hint) { + @extend %form-group-helper--disabled !optional; + } + } + + @include mx(disabled, required) { + @extend %form-group-display--disabled !optional; + + @include e(bundle) { + @extend %form-group-bundle--disabled !optional; + } + + @include e(input) { + @extend %form-group-input--disabled !optional; + } + + @include e(textarea) { + @extend %form-group-textarea--disabled !optional; + } + + @include e(file-input) { + @extend %form-group-input--disabled !optional; + } + } + + // Type box START + @include m(box) { + @extend %form-group-display--box !optional; + + @extend %form-group-display--no-margin !optional; + + @include e(wrapper) { + @extend %form-group-box-wrapper !optional; + } + + @include e(bundle) { + @extend %form-group-bundle--box !optional; + } + + @include e(input) { + @extend %form-group-input--box !optional; + } + + @include e(bundle-main) { + @extend %form-group-bundle-main--box !optional; + } + } + + @include mx(box, focused) { + @include e(bundle) { + @extend %form-group-bundle--box-focus !optional; + } + } + + @include mx(box, disabled) { + @include e(bundle) { + @extend %form-group-bundle--box-disabled !optional; + } + } + + @include mx(box, textarea-group, placeholder) { + @extend %form-group-placeholder !optional; + } + + // Type box END + + // Type border START + @include m(border) { + @extend %form-group-display--border !optional; + @extend %form-group-display--no-margin !optional; + + @include e(bundle) { + @extend %form-group-bundle--border !optional; + } + + @include e(bundle-main) { + @extend %form-group-bundle-main--border !optional; + } + + @include e(line) { + @extend %form-group-line--hidden !optional; + } + + @include e(input) { + @extend %form-group-input--border !optional; + } + + @include e(file-input) { + @extend %form-group-input--border !optional; + } + + @include e(label) { + @extend %form-group-label--border !optional; + } + + @include e(notch) { + @extend %igx-input-group__notch--border !optional; + } + } + + @include mx(border, filled) { + @extend %form-group-label--filled-border !optional; + + @include e(label) { + @extend %form-group-label--float-border !optional; + } + } + + @include mx(border, file) { + @extend %form-group-label--file-border !optional; + + @include e(label) { + @extend %form-group-label--float-border !optional; + } + } + + @include mx(border, focused) { + @extend %form-group-label--focused-border !optional; + + @include e(label) { + @extend %form-group-label--float-border !optional; + } + } + + @include mx(border, placeholder) { + @extend %form-group-label--placeholder-border !optional; + @extend %form-group-placeholder !optional; + + @include e(label) { + @extend %form-group-label--float-border !optional; + } + } + + @include mx(border, valid) { + @extend %form-group-border--success !optional; + } + + @include mx(border, invalid) { + @extend %form-group-border--error !optional; + } + + @include mx(border, warning) { + @extend %form-group-border--warning !optional; + } + + @include mx(border, disabled) { + @extend %form-group-border--disabled !optional; + + @include e(bundle) { + @extend %form-group-bundle-border--disabled !optional; + } + } + // Type border END + + // Type Search START + @include m(search) { + @extend %form-group-display--search !optional; + + @extend %form-group-display--no-margin !optional; + + @include e(bundle) { + @extend %form-group-bundle--search !optional; + + &:hover { + @extend %form-group-bundle-search--hover !optional; + } + } + + @include e(notch) { + @extend %igx-input-group__notch--search !optional; + } + + @include e(bundle-main) { + @extend %form-group-bundle-main--search !optional; + } + + @include e(line) { + @extend %form-group-line--hidden !optional; + } + + @include e(input) { + @extend %form-group-input--search !optional; + } + + @include e(label) { + @extend %form-group-label--search !optional; + } + } + + @include mx(search, focused) { + @include e(bundle) { + @extend %form-group-bundle-search--focus !optional; + } + } + + @include mx(search, disabled) { + @include e(bundle) { + @extend %form-group-bundle-search--disabled !optional; + } + } + + @include e(upload-button) { + @extend %upload-button !optional; + } + + // BASE END + + // ============================== // + + // FLUENT START + @include m(fluent) { + @extend %igx-input-group-fluent !optional; + + @include e(bundle) { + @extend %form-group-bundle--fluent !optional; + + &:hover { + @extend %form-group-bundle--fluent--hover !optional; + } + } + + @include e(bundle-main) { + @extend %form-group-bundle-main--fluent !optional; + } + + @include e(input) { + @extend %fluent-input !optional; + } + + @include e(textarea) { + @extend %fluent-textarea !optional; + } + + @include e(file-input) { + @extend %fluent-input !optional; + } + + @include e(label) { + @extend %fluent-label !optional; + } + } + + @include mx(fluent, placeholder) { + @extend %form-group-placeholder !optional; + + @include e(label) { + @extend %fluent-placeholder-label !optional; + } + } + + @include mx(fluent, disabled) { + @include e(bundle) { + @extend %form-group-bundle--fluent-disabled !optional; + + &:hover { + @extend %form-group-bundle--fluent--hover-disabled !optional; + } + } + + @include e(input) { + @extend %fluent-input-disabled !optional; + + &:hover { + @extend %fluent-input-disabled !optional; + } + + &:focus { + @extend %fluent-input-disabled !optional; + } + } + + @include e(file-input) { + @extend %fluent-input-disabled !optional; + + &:hover { + @extend %fluent-input-disabled !optional; + } + + &:focus { + @extend %fluent-input-disabled !optional; + } + } + + @include e(label) { + @extend %fluent-label-disabled !optional; + } + } + + @include mx(fluent, filled) { + @include e(label) { + @extend %fluent-label-filled !optional; + } + } + + @include mx(fluent, file) { + @include e(label) { + @extend %fluent-label-filled !optional; + } + + @include e(file-input) { + @extend %fluent-file-input !optional; + } + } + + @include mx(fluent, textarea-group) { + @include e(bundle) { + @extend %form-group-bundle-fluent--textarea !optional; + } + } + + @include mx(fluent, focused) { + @include e(bundle) { + @extend %form-group-bundle--fluent--focus !optional; + } + + @include e(label) { + @extend %fluent-label-focused !optional; + } + } + + @include mx(fluent, textarea-group) { + @include e(bundle-start) { + @extend %form-group-bundle-textarea-start--fluent !optional; + } + } + + @include mx(fluent, textarea-group) { + @include e(bundle-end) { + @extend %form-group-bundle-textarea-end--fluent !optional; + } + } + + @include mx(fluent, required) { + @include e(label) { + @extend %form-group-bundle-required--fluent !optional; + @extend %form-group-label-required--fluent !optional; + } + } + + @include mx(fluent, required, disabled) { + @include e(label) { + @extend %form-group-label-required--disabled--fluent !optional; + } + } + + @include mx(fluent, valid) { + @include e(bundle) { + @extend %form-group-bundle-success--fluent !optional; + + &:hover { + @extend %form-group-bundle-success--fluent--hover !optional; + } + + &:focus-within { + @extend %form-group-bundle-success--fluent--focus !optional; + } + } + + @include e(label) { + @extend %fluent-label-success !optional; + } + } + + @include mx(fluent, invalid) { + @include e(bundle) { + @extend %form-group-bundle-error--fluent !optional; + + &:hover { + @extend %form-group-bundle-error--fluent--hover !optional; + } + + &:focus-within { + @extend %form-group-bundle-error--fluent--focus !optional; + } + } + + @include e(label) { + @extend %fluent-label-error !optional; + } + } + + @include mx(fluent, search) { + @extend %igx-input-group-fluent-search !optional; + + @include e(bundle) { + @extend %form-group-bundle--fluent !optional; + + &:hover { + @extend %form-group-bundle--fluent--hover !optional; + } + } + + @include e(bundle-main) { + @extend %form-group-bundle-main--fluent !optional; + } + + @include e(input) { + @extend %fluent-input !optional; + } + + @include e(label) { + @extend %fluent-label !optional; + } + } + + @include mx(fluent, search, focused) { + @extend %igx-input-group-fluent-search--focused !optional; + } + // FLUENT END + + // ============================== // + + // INDIGO START + @include m(indigo) { + @extend %form-group-display--no-margin !optional; + + @include e(wrapper) { + @extend %form-group-box-wrapper !optional; + } + + @include e(bundle) { + @extend %form-group-bundle--indigo !optional; + } + + @include e(input) { + @extend %form-group-input--indigo !optional; + } + + @include e(file-input) { + @extend %form-group-input--indigo !optional; + } + + @include e(label) { + @extend %fluent-label !optional; + } + + @include e(textarea) { + @extend %indigo-textarea !optional; + } + } + + @include mx(indigo, focused) { + @include e(bundle) { + @extend %indigo--box-focused !optional; + } + + @include e(label) { + @extend %indigo-label--focused !optional; + } + } + + @include mx(indigo, disabled) { + @include e(bundle) { + @extend %form-group-bundle--indigo--disabled !optional; + } + + @include e(input) { + @extend %form-group-input--disabled !optional; + } + + @include e(file-input) { + @extend %form-group-input--disabled !optional; + } + } + + // INDIGO END + + // ============================== // + + // BOOTSTRAP START + @include m(bootstrap) { + @extend %form-group-display--bootstrap !optional; + + @include e(bundle) { + @extend %form-group-bundle--bootstrap !optional; + + &:hover { + @extend %form-group-bundle--bootstrap-hover !optional; + } + } + + @include e(label) { + @extend %bootstrap-label !optional; + } + + @include e(input) { + @extend %bootstrap-input !optional; + } + + @include e(file-input) { + @extend %bootstrap-file-input !optional; + } + + @include e(textarea) { + @extend %bootstrap-input !optional; + } + + @include e(bundle-start) { + @extend %form-group-bundle-start--bootstrap !optional; + } + + @include e(bundle-end) { + @extend %form-group-bundle-end--bootstrap !optional; + } + } + + @include mx(bootstrap, file) { + @extend %form-group-display--bootstrap-file !optional; + + @include e(input) { + @extend %bootstrap-input-file !optional; + } + + @include e(bundle-end) { + @extend %bootstrap-bundle-end !optional; + } + + @include e(upload-button) { + @extend %bootstrap-upload-button !optional; + } + } + + @include mx(bootstrap, file, disabled) { + @include e(upload-button) { + @extend %bootstrap-file-disabled-upload-button !optional; + } + } + + @include mx(bootstrap, file, focused) { + @extend %bootstrap-file-focused !optional; + } + + @include mx(bootstrap, file, valid, focused) { + @extend %bootstrap-file-valid !optional; + } + + @include mx(bootstrap, file, warning, focused) { + @extend %bootstrap-file-warning !optional; + } + + @include mx(bootstrap, file, invalid, focused) { + @extend %bootstrap-file-invalid !optional; + } + + @include mx(bootstrap, file, valid, focused) { + @extend %bootstrap-file-valid-focused !optional; + } + + @include mx(bootstrap, file, warning, focused) { + @extend %bootstrap-file-warning-focused !optional; + } + + @include mx(bootstrap, file, invalid, focused) { + @extend %bootstrap-file-invalid-focused !optional; + } + + @include mx(bootstrap, prefixed) { + @extend %form-group-display--bootstrap-prefixed !optional; + } + + @include mx(bootstrap, suffixed) { + @extend %form-group-display--bootstrap-suffixed !optional; + } + + @include mx(bootstrap, suffixed, focused) { + @extend %form-group-display--bootstrap-suffixed-focused !optional; + } + + @include mx(bootstrap, suffixed, valid) { + @extend %form-group-display--bootstrap-suffixed-valid !optional; + } + + @include mx(bootstrap, suffixed, invalid) { + @extend %form-group-display--bootstrap-suffixed-invalid !optional; + } + + @include mx(bootstrap, search) { + @include e(input) { + @extend %bootstrap-input--search !optional; + } + } + + @include mx(bootstrap, focused) { + @include e(bundle) { + @extend %form-group-bundle--bootstrap-focused !optional; + } + + @include e(input) { + @extend %bootstrap-input--focus !optional; + } + + @include e(file-input) { + @extend %bootstrap-input--focus !optional; + } + + @include e(label) { + @extend %bootstrap-label !optional; + } + + + @include e(textarea) { + @extend %bootstrap-input--focus !optional; + } + } + + @include mx(bootstrap, valid) { + @include e(input) { + @extend %bootstrap-input--success !optional; + + &:hover { + @extend %bootstrap-input--success !optional; + } + } + + @include e(file-input) { + @extend %bootstrap-input--success !optional; + + &:hover { + @extend %bootstrap-input--success !optional; + } + } + + @include e(label) { + @extend %bootstrap-label !optional; + } + + + @include e(textarea) { + @extend %bootstrap-input--success !optional; + + &:hover { + @extend %bootstrap-input--success !optional; + } + } + } + + @include mx(bootstrap, invalid) { + @include e(input) { + @extend %bootstrap-input--error !optional; + + &:hover { + @extend %bootstrap-input--error !optional; + } + } + + @include e(file-input) { + @extend %bootstrap-input--error !optional; + + &:hover { + @extend %bootstrap-input--error !optional; + } + } + + @include e(label) { + @extend %bootstrap-label !optional; + } + + @include e(textarea) { + @extend %bootstrap-input--error !optional; + + &:hover { + @extend %bootstrap-input--error !optional; + } + } + } + + @include mx(bootstrap, warning) { + @include e(input) { + @extend %bootstrap-input--warning !optional; + + &:hover { + @extend %bootstrap-input--warning !optional; + } + } + + @include e(file-input) { + @extend %bootstrap-input--warning !optional; + + &:hover { + @extend %bootstrap-input--warning !optional; + } + } + + @include e(label) { + @extend %bootstrap-label !optional; + } + + @include e(textarea) { + @extend %bootstrap-input--warning !optional; + + &:hover { + @extend %bootstrap-input--warning !optional; + } + } + } + + @include mx(bootstrap, textarea-group) { + @include e(bundle) { + @extend %form-group-bundle-bootstrap--textarea !optional; + } + } + + @include mx(bootstrap, disabled) { + @include e(bundle) { + @extend %form-group-display--disabled-bootstrap !optional; + } + + @include e(input) { + @extend %bootstrap-input--disabled !optional; + } + + @include e(file-input) { + @extend %bootstrap-file-input--disabled !optional; + } + + @include e(textarea) { + @extend %bootstrap-input--disabled !optional; + } + } + // BOOTSTRAP END + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/input/_input-group-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/input/_input-group-theme.scss new file mode 100644 index 00000000000..531dafb9b20 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/input/_input-group-theme.scss @@ -0,0 +1,2460 @@ +@use 'sass:map'; +@use '../../base' as *; +@use 'igniteui-theming/sass/animations/easings' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin input-group($theme) { + @include css-vars($theme); + + // The --variant CSS produced by css-vars is needed also + // when dynamically switching between the input `type` attribute. + $variant: map.get($theme, '_meta', 'theme'); + $transition-timing: .25s $out-cubic; + $material-theme: $variant == 'material'; + $indigo-theme: $variant == 'indigo'; + $fluent-theme: $variant == 'fluent'; + $bootstrap-theme: $variant == 'bootstrap'; + + $required-symbol: '*'; + $required-symbol-margin: rem(2px); + + $bootstrap-inline-padding: ( + 'comfortable': rem(14px), + 'cosy': rem(10px), + 'compact': rem(8px) + ); + + $bootstrap-block-padding: ( + 'comfortable': rem(8px), + 'cosy': rem(6px), + 'compact': rem(4px) + ); + + $input-top-padding: rem(20px); + $input-bottom-padding: rem(6px); + + $hint-spacing-block: map.get(( + 'material': rem(4px), + 'fluent': rem(5px), + 'bootstrap': rem(4px), + 'indigo': rem(4px), + ), $variant); + + $hint-spacing-inline: map.get(( + 'material': sizable(rem(12px), rem(14px), rem(16px)), + 'fluent': 0, + 'bootstrap': 0, + 'indigo': 0, + ), $variant); + + $hint-min-size: map.get(( + 'material': rem(18px), + 'fluent': rem(18px), + 'bootstrap': rem(20px), + 'indigo': rem(15px), + ), $variant); + + $material-box-top-padding: sizable(rem(16px), rem(20px), rem(24px)); + $material-border-top-padding: sizable(rem(8px), rem(12px), rem(16px)); + + $textarea-top-padding: map.get(( + 'material': rem(0px), + 'fluent': sizable(rem(6px), rem(10px), rem(14px)), + 'bootstrap': sizable(rem(4px), rem(8px), rem(12px)), + 'indigo': sizable(rem(4px), rem(6px), rem(8px)), + ), $variant); + + $textarea-font: map.get(( + 'material': 'var(--ig-subtitle-1-line-height)', + 'fluent': 'var(--ig-body-2-line-height)', + 'bootstrap': 'var(--ig-body-1-line-height)', + 'indigo': 'var(--ig-body-2-line-height)', + ), $variant); + + // Base Start + %form-group-prefix--upload { + padding: 0; + } + + %form-group-prefix { + color: var-get($theme, 'input-prefix-color'); + background: var-get($theme, 'input-prefix-background'); + grid-area: 1 / 1 / auto / auto; + } + + %form-group-suffix { + color: var-get($theme, 'input-suffix-color'); + background: var-get($theme, 'input-suffix-background'); + grid-area: 1 / 3 / auto / auto; + } + + %form-group-prefix, + %form-group-suffix { + position: relative; + display: inline-flex; + width: max-content; + align-items: center; + min-height: 100% !important; + transition: color $transition-timing, background $transition-timing; + + &:not(:empty) { + @if $material-theme { + padding-inline: pad-inline(rem(12px), rem(14px), rem(16px)); + } @else if $indigo-theme { + padding-inline: pad-inline(rem(2px), rem(4px), rem(6px)); + } @else { + padding-inline: pad-inline(rem(8px), rem(10px), rem(14px)); + } + } + } + + @if $variant == 'material' { + %form-group-display--border { + &:has(input:-webkit-autofill, input:autofill) { + %igx-input-group__notch--border { + border-block-start-color: transparent; + } + + %form-group-label { + --label-position: #{sizable(18px, 22px, 26px)}; + + transform: translateY(calc(var(--label-position) * -1)); + margin-top: 0; + overflow: hidden; + will-change: font-size, color, transform; + } + } + } + } + + %form-group-display--box { + %form-group-border { + margin-bottom: 0; + } + } + + %form-group-display { + @include sizable(); + --component-size: var(--ig-size, #{var-get($theme, 'default-size')}); + --input-size: var(--component-size); + --input-icon: #{sizable(rem(14px), rem(16px), rem(16px))}; + + position: relative; + display: block; + color: var-get($theme, 'idle-text-color'); + + igx-prefix, + [igxPrefix] { + @extend %form-group-prefix; + outline-style: none; + + &:first-child { + @if $variant == 'fluent' { + border-start-start-radius: calc(var-get($theme, 'border-border-radius') - var(--_fluent-input-border-size)); + border-end-start-radius: calc(var-get($theme, 'border-border-radius') - var(--_fluent-input-border-size)); + } @else if $variant == "indigo" { + border-start-start-radius: var-get($theme, 'box-border-radius'); + } + } + } + + igx-suffix, + [igxSuffix] { + @extend %form-group-suffix; + outline-style: none; + + &:last-child { + @if $variant == 'fluent' { + border-start-end-radius: calc(var-get($theme, 'border-border-radius') - var(--_fluent-input-border-size)); + border-end-end-radius: calc(var-get($theme, 'border-border-radius') - var(--_fluent-input-border-size)); + } @else if $variant == "indigo" { + border-start-end-radius: var-get($theme, 'box-border-radius'); + } + } + } + + input, + textarea { + font: inherit; + margin: 0; + } + + span { + font-family: inherit; + } + + textarea { + overflow: auto; + } + + input[type='number'] { + -moz-appearance: textfield; + } + + // Don't show the number spinner + input[type='number']::-webkit-inner-spin-button { + -webkit-appearance: none; + height: auto; + } + + input[type='search']::-webkit-search-cancel-button, + input[type='search']::-webkit-search-decoration { + -webkit-appearance: none; + } + + igx-icon, + igx-icon[igxPrefix], + igx-icon[igxSuffix] { + --component-size: var(--input-size); + + @if $variant == 'indigo' { + --size: var(--input-icon) !important; + } + } + + @if $variant == 'material' { + &:not(%form-group-display--border) { + &:has(input:-webkit-autofill, input:autofill) { + %form-group-label { + --floating-label-position: -73%; + + @include type-style('caption'); + + transform: translateY(var(--floating-label-position)); + } + } + } + + &:not(%form-group-display--focused, %form-group-display--filled) { + &:has(input:not(:placeholder-shown, [type='file'])) { + %form-group-label { + @include type-style('subtitle-1'); + transform: translateY(0); + } + } + } + } + } + + %form-group-placeholder { + &:has(input:placeholder-shown, textarea:placeholder-shown) { + %form-group-label { + transition: none !important; + } + } + } + + %form-group-display:not(%form-group-display--filled) { + %form-group-label { + transition: all $transition-timing; + } + } + + %form-group-display--no-margin { + margin-block-start: 0; + } + + %form-group-display--filled { + color: var-get($theme, 'input-prefix-color--filled'); + + igx-prefix, + [igxPrefix] { + color: var-get($theme, 'input-prefix-color--filled'); + background: var-get($theme, 'input-prefix-background--filled'); + } + + igx-suffix, + [igxSuffix] { + color: var-get($theme, 'input-suffix-color--filled'); + background: var-get($theme, 'input-suffix-background--filled'); + } + } + + %form-group-display--focused { + color: var-get($theme, 'input-prefix-color--focused'); + + igx-prefix, + [igxPrefix] { + color: var-get($theme, 'input-prefix-color--focused'); + background: var-get($theme, 'input-prefix-background--focused'); + } + + igx-suffix, + [igxSuffix] { + color: var-get($theme, 'input-suffix-color--focused'); + background: var-get($theme, 'input-suffix-background--focused'); + } + } + + %form-group-display--readonly:not(%form-group-display--file) { + igx-prefix, + [igxPrefix], + igx-suffix, + [igxSuffix] { + color: var-get($theme, 'disabled-text-color'); + + @if $variant == 'fluent' { + background: transparent; + } + + @if $variant == 'bootstrap' { + background: var-get($theme, 'border-disabled-background'); + } + } + + @if $variant == 'bootstrap' { + %form-group-input { + background: var-get($theme, 'border-disabled-background'); + } + } + + %form-group-bundle--hover::after { + @if $variant == 'material' { + border-block-end-color: var-get($theme, 'idle-bottom-line-color'); + } + } + + @if $variant == 'indigo' { + %form-group-bundle--hover:not(:focus-within) { + background: unset; + + &::after { + border-block-end-color: var-get($theme, 'disabled-text-color'); + } + } + } + + &%igx-input-group-fluent:not(:focus-within) { + %form-group-bundle--hover::before { + box-shadow: inset 0 0 0 var(--_fluent-input-border-size) var-get($theme, 'border-color'); + } + } + + &%form-group-display--box:not(%form-group-display--disabled) { + %form-group-bundle { + background: var-get($theme, 'box-background-focus'); + } + } + + &%form-group-display--border:not(%form-group-display--disabled) { + %form-group-bundle:hover:not(:focus-within) { + %form-group-bundle-start, + %igx-input-group__filler, + %form-group-bundle-end { + border-color: var-get($theme, 'border-color'); + } + + %igx-input-group__notch { + border-block-start-color: var-get($theme, 'border-color'); + border-block-end-color: var-get($theme, 'border-color'); + } + } + } + + &%form-group-display--search { + %form-group-bundle-search--hover:not(:focus-within) { + box-shadow: var-get($theme, 'search-resting-elevation'); + } + } + + &:hover { + %form-group-input--hover { + cursor: default; + color: var-get($theme, 'filled-text-color'); + + &:not(:focus-within) { + &::placeholder { + color: var-get($theme, 'placeholder-color'); + } + } + } + } + } + + %form-group-display--disabled { + pointer-events: none; + user-select: none; + color: var-get($theme, 'disabled-text-color'); + + igx-prefix, + [igxPrefix] { + @extend %form-group-prefix--disabled; + } + + igx-suffix, + [igxSuffix] { + @extend %form-group-suffix--disabled; + } + } + + %form-group-box-wrapper { + @if $variant != 'bootstrap' { + border-radius: var-get($theme, 'box-border-radius'); + } + + @if $variant == 'material' { + border-end-start-radius: 0; + border-end-end-radius: 0; + } + overflow: hidden; + } + + %form-group-bundle { + display: grid; + grid-template-columns: auto 1fr auto; + grid-area: 1 / 2 / span 1 / span 2; + height: var-get($theme, 'size'); + position: relative; + max-width: 100%; + font-size: rem(16px); + + &::after { + content: ''; + position: absolute; + bottom: 0%; + width: 100%; + border-block-end: rem(1px) solid var-get($theme, 'idle-bottom-line-color'); + transition: all $transition-timing + } + + %form-group-bundle-start { + border: { + start: { + start-radius: calc(var-get($theme, 'box-border-radius') - rem(1px)); + }; + } + } + + %form-group-bundle-end { + border: { + start: { + end-radius: calc(var-get($theme, 'box-border-radius') - rem(1px)); + } + } + } + } + + // We need to target the bundle :after here with classes since we have cases that + // The theme is for example indigo but the input stays fluent, that's the case in the grid. + .igx-input-group--fluent, + .igx-input-group--bootstrap { + %form-group-bundle { + &::after { + display: none; + } + } + } + + %form-group-bundle--hover { + &::after { + border-block-end-width: rem(1px); + border-block-end-color: var-get($theme, 'hover-bottom-line-color'); + } + } + + %form-group-bundle--focus { + &::after { + @if $variant != 'indigo' { + border-block-end-width: rem(2px); + } + border-block-end-color: var-get($theme, 'focused-bottom-line-color'); + } + + @if $variant == 'indigo' { + caret-color: var-get($theme, 'focused-bottom-line-color'); + } + } + + %form-group-bundle--success { + &::after { + border-block-end-color: var-get($theme, 'success-secondary-color'); + } + caret-color: initial; + } + + %form-group-bundle--warning { + &::after { + border-block-end-color: var-get($theme, 'warning-secondary-color'); + } + caret-color: initial; + } + + %form-group-bundle--disabled { + cursor: default; + + &::after { + border-block-end-color: var-get($theme, 'disabled-bottom-line-color'); + border-block-end-style: dashed; + } + } + + %form-group-bundle-start { + grid-area: 1 / 1; + } + + %form-group-bundle-main { + grid-area: 1 / 2 / span 1 / span 2; + flex-grow: 1; + position: relative; + max-width: inherit; + } + + @if $material-theme { + %form-group-display--file { + %form-group-file-input { + padding-inline: rem(4px); + } + } + + %form-group-display--file-border { + %form-group-input { + z-index: 2; + } + + // We need this otherwise we have to use !important + %form-group-bundle { + grid-template-columns: auto auto auto 1fr auto; + + %form-group-bundle-end { + grid-area: 1 / 5; + } + + &:hover { + %upload-button { + border-color: var-get($theme, 'hover-border-color'); + } + } + } + + %upload-button { + border-block: rem(1px) solid var-get($theme, 'border-color'); + + .igx-button { + border: none; + } + } + } + + %form-group-display--file-border-focused { + %upload-button { + border-width: rem(2px); + border-color: var-get($theme, 'focused-border-color'); + } + + %form-group-bundle { + &:hover { + %upload-button { + border-color: var-get($theme, 'focused-border-color'); + } + } + } + } + + %form-group-display--file-border-success { + %upload-button { + border-color: var-get($theme, 'success-secondary-color'); + } + + %form-group-bundle { + &:hover { + %upload-button { + border-color: var-get($theme, 'success-secondary-color'); + } + } + } + } + + %form-group-display--file-border-warning { + %upload-button { + border-color: var-get($theme, 'warning-secondary-color'); + } + + %form-group-bundle { + &:hover { + %upload-button { + border-color: var-get($theme, 'warning-secondary-color'); + } + } + } + } + + %form-group-display--file-border-error { + %upload-button { + border-color: var-get($theme, 'error-secondary-color'); + } + + %form-group-bundle { + &:hover { + %upload-button { + border-color: var-get($theme, 'error-secondary-color'); + } + } + } + } + } + + %form-group-display--file { + %form-group-bundle { + grid-template-columns: auto auto auto 1fr auto; + } + + %form-group-file-input { + grid-area: 1/3 / span 1 / span 2; + flex-grow: 1; + padding-inline: rem(4px); + cursor: pointer; + + @if $variant != 'material' { + display: flex; + align-items: center; + + span { + transform: revert; + inset: revert; + } + } + } + + %igx-input-group__notch { + grid-area: 1 / 3; + } + + %form-group-bundle-end { + grid-area: 1 / 5; + } + + %igx-input-group__filler { + grid-area: 1 / 4; + } + + %form-group-bundle-end { + grid-area: 1 / 5; + } + + %form-group-bundle-main { + display: contents; + } + + %form-group-input { + grid-column: 2 / -2; + grid-row: 1 / -1; + border: none; + inset: 0; + width: 100%; + appearance: none; + opacity: 0; + z-index: 2; + cursor: pointer; + } + + ::file-selector-button { + cursor: pointer; + } + } + + %upload-button { + display: flex; + align-items: center; + grid-area: 1 / 2; + pointer-events: none; + height: var-get($theme, 'size'); + cursor: pointer; + overflow: hidden; + } + + %form-group-display--bootstrap-file { + %form-group-bundle-start, + %form-group-bundle-end { + [igxPrefix], + igx-prefix { + height: var-get($theme, 'size'); + } + } + } + + %bootstrap-upload-button { + border: rem(1px) solid var-get($theme, 'border-color'); + border-inline-end: 0; + + igx-button { + border: 0; + } + } + + %bootstrap-file-disabled-upload-button { + border-color: var-get($theme, 'disabled-border-color'); + } + + %bootstrap-file-focused, + %bootstrap-file-valid, + %bootstrap-file-warning, + %bootstrap-file-invalid { + %form-group-bundle { + border-radius: var-get($theme, 'border-border-radius'); + transition: box-shadow .15s ease-out, border .15s ease-out; + + &:hover { + %form-group-file-input { + box-shadow: none; + } + } + } + + %form-group-file-input { + box-shadow: none; + } + } + + %bootstrap-file-focused { + %form-group-bundle-start, + %form-group-bundle-end, + %upload-button { + border-color: var-get($theme, 'focused-border-color'); + } + + %form-group-bundle { + box-shadow: 0 0 0 rem(4px) var-get($theme, 'focused-secondary-color'); + } + } + + %bootstrap-file-valid { + %form-group-bundle-start, + %form-group-bundle-end, + %upload-button { + border-color: var-get($theme, 'success-secondary-color'); + } + } + + %bootstrap-file-warning { + %form-group-bundle-start, + %form-group-bundle-end, + %upload-button { + border-color: var-get($theme, 'warning-secondary-color'); + } + } + + %bootstrap-file-invalid { + %form-group-bundle-start, + %form-group-bundle-end, + %upload-button { + border-color: var-get($theme, 'error-secondary-color'); + } + } + + %bootstrap-file-valid-focused { + %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'); + } + } + } + + %bootstrap-file-invalid-focused { + %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' { + %form-group-display--file-focused { + %form-group-bundle-start, + %form-group-bundle-end, + %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'); + } + } + } + + .igx-input-group--bootstrap:not(.igx-input-group--prefixed) { + .igx-input-group__upload-button { + border-radius: var-get($theme, 'border-border-radius') 0 0 var-get($theme, 'border-border-radius'); + } + + .igx-input-group__file-input { + border-start-start-radius: 0; + border-end-start-radius: 0; + } + } + + %form-group-bundle--box { + // padding 0 is needed here because of the search variant + padding: 0 !important; + + background: var-get($theme, 'box-background'); + + &:hover { + background: var-get($theme, 'box-background-hover'); + } + } + + %form-group-bundle--box-focus { + background: var-get($theme, 'box-background-focus'); + } + + %form-group-bundle--box-disabled { + background-color: var-get($theme, 'box-disabled-background'); + } + + @if $material-theme { + %form-group-bundle-main, + %igx-input-group__notch { + padding-inline: rem(4px); + } + } + + %igx-input-group__notch { + display: flex; + align-items: center; + width: auto; + min-width: 0; + height: 100%; + position: relative; + + grid-area: 1 / 2; + + %form-group-label { + color: var-get($theme, 'idle-secondary-color'); + } + } + + %igx-input-group__notch--border { + padding: 0 rem(4px); + } + + %igx-input-group__filler { + grid-area: 1 / 3; + } + + %form-group-bundle-end { + grid-area: 1 / 4; + } + + + %form-group-bundle-start, + %form-group-bundle-end { + display: flex; + align-items: center; + min-width: 0; + min-height: 100%; + + @if $variant == 'material' { + overflow: hidden; + min-width: pad(rem(8px), rem(10px), rem(12px)); + } + } + + %form-group-bundle--border { + grid-template-columns: auto auto 1fr auto; + display: grid; + align-items: initial; + padding: 0; + box-shadow: none; + border-radius: var-get($theme, 'border-border-radius'); + background: var-get($theme, 'border-background'); + + %form-group-bundle-start { + width: auto; + flex-shrink: 0; + + border: { + color: var-get($theme, 'border-color'); + style: solid; + + inline: { + start-width: rem(1px); + end-width: 0; + }; + + block: { + start-width: rem(1px); + end-width: rem(1px); + }; + + start: { + start-radius: var-get($theme, 'border-border-radius'); + }; + + end: { + start-radius: var-get($theme, 'border-border-radius'); + }; + } + } + + %igx-input-group__filler { + border: { + width: rem(1px); + style: solid; + color: var-get($theme, 'border-color'); + left: none; + right: none; + } + } + + %igx-input-group__notch { + border-block-start: rem(1px) solid var-get($theme, 'border-color'); + border-block-end: rem(1px) solid var-get($theme, 'border-color'); + overflow: visible; + + %form-group-label { + position: relative; + } + + &:empty { + display: none; + } + } + + %form-group-bundle-end { + display: flex; + justify-content: flex-end; + flex-grow: 1; + height: 100%; + grid-area: 1 / 4; + + border: { + color: var-get($theme, 'border-color'); + style: solid; + + inline: { + start-width: 0; + end-width: rem(1px); + }; + + block: { + start-width: rem(1px); + end-width: rem(1px); + }; + + start: { + end-radius: var-get($theme, 'border-border-radius'); + }; + + end: { + end-radius: var-get($theme, 'border-border-radius'); + }; + } + } + + %form-group-prefix, + %form-group-suffix { + height: 100%; + } + + &::after { + display: none; + } + + &:hover { + %form-group-bundle-start, + %igx-input-group__filler, + %form-group-bundle-end { + border-color: var-get($theme, 'hover-border-color'); + } + + %igx-input-group__notch { + border-block-start-color: var-get($theme, 'hover-border-color'); + border-block-end-color: var-get($theme, 'hover-border-color'); + } + } + } + + %form-group-bundle-border--disabled { + background: var-get($theme, 'border-disabled-background'); + } + + %form-group-input--border { + width: 100%; + height: 100% !important; + padding-block: 0; + border: none; + outline-style: none; + z-index: 1; + } + + /* stylelint-disable */ + %igx-input-group__notch--search, + %form-group-bundle-main--search { + @if $material-theme { + padding-inline-start: pad-inline(rem(14px), rem(16px), rem(18px)); + } + } + + %igx-input-group__notch--search, + %form-group-bundle-main--search { + @if $material-theme { + padding-inline-end: pad-inline(rem(14px), rem(16px), rem(18px)); + } + } + /* stylelint-enable */ + + %form-group-display--search { + %igx-input-group__notch--search, + %form-group-bundle-main--search { + @if $variant == 'material' { + padding-inline: rem(4px); + } + } + } + + %form-group-bundle--search { + background: var-get($theme, 'search-background'); + box-shadow: var-get($theme, 'search-resting-elevation'); + border-radius: var-get($theme, 'search-border-radius'); + + @if $variant != 'bootstrap' { + overflow: hidden; + } + + @if $variant != 'indigo' { + &::after { + display: none; + } + } + } + + %form-group-bundle-search--hover { + box-shadow: var-get($theme, 'search-hover-elevation'); + border-color: var-get($theme, 'hover-border-color'); + } + + %form-group-bundle-search--focus { + box-shadow: var-get($theme, 'search-hover-elevation'); + border-color: var-get($theme, 'hover-border-color'); + } + + %form-group-bundle-search--error { + box-shadow: var-get($theme, 'search-hover-elevation'); + border-color: var-get($theme, 'search-hover-elevation'); + } + + %form-group-bundle-search--success { + box-shadow: var-get($theme, 'search-hover-elevation'); + border-color: var-get($theme, 'search-hover-elevation'); + } + + %form-group-bundle-search--disabled { + background: var-get($theme, 'search-disabled-background'); + box-shadow: var-get($theme, 'search-disabled-elevation'); + border-color: var-get($theme, 'disabled-border-color'); + + igx-prefix, + [igxPrefix], + igx-suffix, + [igxSuffix] { + background: inherit; + color: var-get($theme, 'disabled-text-color'); + } + } + + %form-group-bundle-main--border { + background: transparent; + padding: 0 rem(4px); + font-size: rem(16px); + } + + %form-group-label { + backface-visibility: hidden; + will-change: transform; + transform-origin: top left; + } + + %form-group-label--border { + padding-inline-end: 0; + display: inline-block; + position: relative; + background: transparent; + } + + %form-group-label--border, + %form-group-label--search { + transform: translateY(0); + } + + %form-group-label--search { + + %form-group-input--search { + transform: translateY(0); + } + } + + @if $variant == 'material' { + %form-group-label--float { + --floating-label-position: -73%; + + @include type-style('caption'); + + transform: translateY(var(--floating-label-position)); + } + } + + %form-group-label--focused-border, + %form-group-label--filled-border, + %form-group-label--file-border { + %igx-input-group__notch { + border-block-start-color: transparent !important; + } + } + + %form-group-label--placeholder-border { + &:has(input:placeholder-shown, textarea:placeholder-shown) { + %igx-input-group__notch { + border-block-start-color: transparent !important; + } + } + } + + %form-group-label--focused-border { + %form-group-bundle-start { + border-inline-start-width: rem(2px); + border-block-start-width: rem(2px); + border-block-end-width: rem(2px); + border-inline-start-color: var-get($theme, 'focused-border-color'); + border-block-start-color: var-get($theme, 'focused-border-color'); + border-block-end-color: var-get($theme, 'focused-border-color'); + } + + %form-group-bundle-end { + border-inline-end-width: rem(2px); + border-block-start-width: rem(2px); + border-block-end-width: rem(2px); + border-inline-end-color: var-get($theme, 'focused-border-color'); + border-block-start-color: var-get($theme, 'focused-border-color'); + border-block-end-color: var-get($theme, 'focused-border-color'); + } + + %igx-input-group__filler, + %igx-input-group__notch { + border-block-width: rem(2px); + } + + %igx-input-group__filler { + border-block-color: var-get($theme, 'focused-border-color'); + } + + %igx-input-group__notch { + border-block-end-color: var-get($theme, 'focused-border-color'); + } + + %form-group-prefix { + &:first-child { + margin-inline-start: rem(-1px); + } + } + + %form-group-suffix { + &:last-child { + margin-inline-end: rem(-1px); + } + } + } + + %form-group-label--focused-border:not(:is( + %form-group-border--error, + %form-group-border--warning, + %form-group-border--success)) + { + &:hover { + %form-group-bundle-start, + %form-group-bundle-end, + %igx-input-group__filler, + %igx-input-group__notch { + border-color: var-get($theme, 'focused-border-color'); + } + } + } + + %form-group-label--float-border { + --label-position: #{sizable(18px, 22px, 26px)}; + + transform: translateY(calc(var(--label-position) * -1)); + margin-top: 0; + overflow: hidden; + will-change: font-size, color, transform; + } + + @if $variant == 'material' { + %textarea-group:not(%textarea-group--outlined) { + --textarea-box-padding: #{pad-block(rem(8px), rem(12px), rem(16px))}; + + &:has(%igx-input-group__notch:not(:empty)) { + --textarea-box-padding: #{pad-block(rem(16px), rem(20px), rem(24px))}; + } + + %form-group-textarea { + margin-block-end: rem(2px); + } + } + + %textarea-group:not(%suffixed) { + %form-group-bundle-main { + grid-area: 1 / 2 / span 1 / span 3; + padding-inline-end: 0; + } + + textarea { + padding-inline-end: #{pad-inline(rem(12px), rem(14px), rem(16px))}; + width: calc(100% - #{rem(1px)}); + } + } + + %textarea-group--outlined:not(%suffixed) { + textarea { + width: calc(100% - #{rem(2px)}); + } + } + + %textarea-group:not(%form-group-display--focused, %form-group-display--filled) { + &:has(textarea:not(:placeholder-shown)) { + %form-group-textarea-label:not(%textarea-group-label--focused) { + @include type-style('subtitle-1'); + + top: calc(#{$material-box-top-padding} - #{rem(3px)}); + transform: translateY(0); + margin-bottom: auto; + } + } + } + + %textarea-group:not(%form-group-display--focused, %form-group-display--filled) { + &:has(%form-group-display--border, textarea:not(:placeholder-shown)) { + %igx-input-group__notch { + border-block-start-width: rem(1px); + } + } + } + } + + %form-group-textarea-group-bundle { + height: auto !important; + + %form-group-label { + position: absolute; + } + } + + @if $material-theme { + %form-group-textarea-label { + top: calc(#{$material-box-top-padding} - #{rem(1px)}); + margin-block-end: auto; + } + + %textarea-group--outlined { + %form-group-textarea-label { + top: calc(#{$material-border-top-padding} - #{rem(3px)}); + } + } + + %textarea-group--box { + %form-group-textarea-label { + top: calc(#{$material-box-top-padding} - #{rem(2px)}); + } + } + + %textarea-group-label--focused { + transform: translateY(0); + top: calc(#{$material-box-top-padding} / 4); + } + + %textarea-group-label--filled--border, + %textarea-group-label--focused--border { + top: 0; + transform: translateY(-50%); + margin-block-end: auto !important; + } + + %textarea-group-notch--focused { + border-block-start-width: rem(2px); + } + } + + %form-group-label--focus { + color: var-get($theme, 'focused-secondary-color'); + } + + %form-group-label--warning { + color: var-get($theme, 'warning-secondary-color'); + } + + %form-group-label--success { + color: var-get($theme, 'success-secondary-color'); + } + + %form-group-label--required { + &::after { + content: '#{$required-symbol}'; + font-size: inherit; + vertical-align: top; + margin-inline-start: $required-symbol-margin; /* rem(2px) base is 16px */ + display: inline-block; + } + } + + %form-group-label--disabled { + color: var-get($theme, 'disabled-text-color') !important; + } + + %form-group-input { + position: relative; + display: block; + border: none; + padding-block-start: $input-top-padding; + padding-block-end: $input-bottom-padding; + padding-inline: 0; + height: calc(var-get($theme, 'size') - 2px); + width: 100%; + min-width: 0; + background: transparent; + color: var-get($theme, 'filled-text-color'); + outline-style: none; + box-shadow: none; + overflow: hidden; + text-overflow: ellipsis; + + &:not(%form-group-textarea, [type='date']) { + line-height: 0 !important; /* Reset typography */ + } + + &::-webkit-input-placeholder { + line-height: normal; + } + + &::placeholder { + color: var-get($theme, 'placeholder-color'); + opacity: 1; + line-height: normal; /* Fix placeholder position in Safari */ + } + + @if $variant == 'indigo' { + height: calc(var-get($theme, 'size') - 1px); + + &::placeholder { + font-style: italic; + } + } + } + + %igx-input-group__notch:empty + %form-group-bundle-main { + %form-group-input { + padding-block: 0; + } + } + + %form-group-file-input { + width: 100%; + max-width: 100%; + overflow: hidden; + padding-block-start: $input-top-padding; + padding-block-end: $input-bottom-padding; + height: var-get($theme, 'size'); + max-height: 100%; + color: var-get($theme, 'filled-text-color'); + + span { + @include ellipsis(); + + position: relative; + display: inline-block; + width: inherit; + max-width: inherit; + top: 50%; + transform: translateY(-50%); + } + + @if $variant == 'indigo' { + font-size: rem(12px); + line-height: rem(16px); + } + } + + // This is a hack that removes the autofill background and it's essential, + // otherwise the background is on top of the floating label in material theme. + // The !important flag is because bootstrap theme(and potentially feature themes) is overriding the transition delay. + %autofill-background-fix { + &:-webkit-autofill, + &:-webkit-autofill:hover, + &:-webkit-autofill:focus, + &:-webkit-autofill:active, + &:autofill, + &:autofill:hover, + &:autofill:focus, + &:autofill:active { + -webkit-transition-delay: 99999s !important; + transition-delay: 99999s !important; + } + } + + %form-group-file-input-indigo { + padding-block: 0; + } + + %form-group-input--hover { + cursor: pointer; + color: var-get($theme, 'filled-text-hover-color'); + + &::placeholder { + color: var-get($theme, 'hover-placeholder-color'); + } + } + + %form-group-input--focus { + cursor: text; + color: var-get($theme, 'focused-text-color'); + + &::placeholder { + color: var-get($theme, 'hover-placeholder-color'); + } + } + + %form-group-input--disabled { + cursor: default; + + color: var-get($theme, 'disabled-text-color'); + + &::placeholder { + color: var-get($theme, 'disabled-placeholder-color'); + } + } + + %form-group-file-input--disabled { + cursor: default; + + &::placeholder { + color: var-get($theme, 'disabled-placeholder-color'); + } + } + + %form-group-textarea { + height: auto; + resize: vertical; + overflow: hidden; + z-index: 1; + + @if $material-theme { + padding: 0; + } + } + + %form-group-textarea-group-bundle-main { + overflow: hidden; + + @if $material-theme or $indigo-theme { + height: calc(100% - #{rem(2px)}); + top: rem(1px); + } + + @if $material-theme { + padding-block-start: var(--textarea-box-padding); + } + } + + %textarea-group--outlined { + %form-group-textarea-group-bundle-main { + padding-block-start: #{$material-border-top-padding}; + } + + %form-group-textarea { + inset-block-start: rem(-2px); + } + } + + %form-group-textarea--disabled { + color: var-get($theme, 'disabled-text-color'); + cursor: default; + + &::placeholder { + color: var-get($theme, 'disabled-placeholder-color'); + } + } + + @if $material-theme { + %form-group-line { + position: absolute; + width: 100%; + inset-inline-end: 0; + height: rem(2px); + align-self: end; + transform: scaleX(0); + transform-origin: center; + background: var-get($theme, 'focused-bottom-line-color'); + z-index: 1; + } + } + + %form-group-line--success { + background: var-get($theme, 'success-secondary-color'); + } + + %form-group-border--success { + %form-group-bundle-start { + border-inline-start-color: var-get($theme, 'success-secondary-color'); + border-block-start-color: var-get($theme, 'success-secondary-color'); + border-block-end-color: var-get($theme, 'success-secondary-color'); + } + + %form-group-bundle-end { + border-inline-end-color: var-get($theme, 'success-secondary-color'); + border-block-start-color: var-get($theme, 'success-secondary-color'); + border-block-end-color: var-get($theme, 'success-secondary-color'); + } + + %igx-input-group__notch, + %igx-input-group__filler { + border-block-color: var-get($theme, 'success-secondary-color'); + } + + %form-group-bundle--border:hover { + %form-group-bundle-start, + %form-group-bundle-end, + %igx-input-group__filler, + %igx-input-group__notch { + border-color: var-get($theme, 'success-secondary-color'); + } + } + } + + %form-group-line--warning { + background: var-get($theme, 'warning-secondary-color'); + } + + %form-group-border--warning { + %form-group-bundle-start { + border-inline-start-color: var-get($theme, 'warning-secondary-color'); + border-block-start-color: var-get($theme, 'warning-secondary-color'); + border-block-end-color: var-get($theme, 'warning-secondary-color'); + } + + %form-group-bundle-end { + border-inline-end-color: var-get($theme, 'warning-secondary-color'); + border-block-start-color: var-get($theme, 'warning-secondary-color'); + border-block-end-color: var-get($theme, 'warning-secondary-color'); + } + + %igx-input-group__notch, + %igx-input-group__filler { + border-block-color: var-get($theme, 'warning-secondary-color'); + } + + %form-group-bundle--border:hover { + %form-group-bundle-start, + %form-group-bundle-end, + %igx-input-group__filler, + %igx-input-group__notch { + border-color: var-get($theme, 'warning-secondary-color'); + } + } + } + + %form-group-border--error:not(%form-group-display--readonly), + %form-group-border--error%form-group-display--file { + %form-group-bundle-start { + border-inline-start-color: var-get($theme, 'error-secondary-color'); + border-block-start-color: var-get($theme, 'error-secondary-color'); + border-block-end-color: var-get($theme, 'error-secondary-color'); + } + + %form-group-bundle-end { + border-inline-end-color: var-get($theme, 'error-secondary-color'); + border-block-start-color: var-get($theme, 'error-secondary-color'); + border-block-end-color: var-get($theme, 'error-secondary-color'); + } + + %igx-input-group__notch, + %igx-input-group__filler { + border-block-color: var-get($theme, 'error-secondary-color'); + } + + %form-group-bundle--border:hover { + %form-group-bundle-start, + %form-group-bundle-end, + %igx-input-group__filler, + %igx-input-group__notch { + border-color: var-get($theme, 'error-secondary-color'); + } + } + } + + %form-group-border--disabled { + %form-group-bundle-start { + border-inline-start-color: var-get($theme, 'disabled-border-color'); + border-block-start-color: var-get($theme, 'disabled-border-color'); + border-block-end-color: var-get($theme, 'disabled-border-color'); + } + + %form-group-bundle-end { + border-inline-end-color: var-get($theme, 'disabled-border-color'); + border-block-start-color: var-get($theme, 'disabled-border-color'); + border-block-end-color: var-get($theme, 'disabled-border-color'); + } + + %igx-input-group__notch, + %igx-input-group__filler { + border-block-color: var-get($theme, 'disabled-border-color'); + } + } + + %form-group-line--focus { + transform: scaleX(1); + transition: transform $transition-timing; + } + + // Hides the border for border type input + %form-group-line--hidden { + display: none; + } + + %form-group-helper { + --ig-caption-margin-top: #{$hint-spacing-block}; + --ig-caption-margin-bottom: 0; + --ig-body-2-margin-top: #{$hint-spacing-block}; + + color: var-get($theme, 'helper-text-color'); + position: relative; + display: grid; + grid-auto-rows: minmax($hint-min-size, auto); + padding-inline: pad-inline($hint-spacing-inline); + justify-content: space-between; + + > * { + margin-inline-end: rem(8px); + + &:last-child { + margin-inline-end: 0; + } + } + + &:empty { + display: none; + } + } + + @if $variant != 'indigo' { + %form-group-helper--success { + color: var-get($theme, 'success-secondary-color'); + } + + %form-group-helper--warning { + color: var-get($theme, 'warning-secondary-color'); + } + } + + %form-group-helper-item { + @include line-clamp(2, true, true); + + overflow-wrap: anywhere; + align-items: center; + position: relative; + } + + %form-group-helper-item--start { + justify-content: flex-start; + } + + %form-group-helper-item--end { + justify-content: flex-end; + } + + %form-group-helper-item--start, + %form-group-helper-item--end { + width: 100%; + } + + %form-group-prefix--disabled, + %form-group-suffix--disabled { + color: var-get($theme, 'disabled-text-color'); + background: inherit; + pointer-events: none; + } + + %form-group-helper--disabled { + color: var-get($theme, 'disabled-text-color'); + } + + // BASE END + + // ============================================== + + // INDIGO START + @if $variant == 'indigo' { + %form-group-display--search { + igx-prefix, + [igxPrefix], + igx-suffix, + [igxSuffix] { + &:not(:empty) { + padding-inline: pad-inline(rem(6px), rem(8px), rem(10px)); + } + } + } + + %form-group-display.igx-input-group--prefixed, + %form-group-display--search.igx-input-group--prefixed { + input, + textarea { + padding-inline-start: 0; + } + } + + %form-group-display.igx-input-group--suffixed, + %form-group-display--search.igx-input-group--suffixed { + input, + textarea { + padding-inline-end: 0; + } + } + } + + %form-group-bundle--indigo { + border-radius: var-get($theme, 'box-border-radius') var-get($theme, 'box-border-radius') 0 0; + transition: background $transition-timing; + padding-top: 0; + + &:hover, + &:focus { + background: var-get($theme, 'box-background-hover'); + } + } + + %indigo-label--focused { + color: var-get($theme, 'focused-secondary-color'); + } + + %form-group-input--indigo { + padding-block: rem(6px); + padding-inline: pad-inline(rem(2px), rem(4px), rem(6px)); + } + + @if $variant == 'indigo' { + %form-group-input--search { + padding-inline: pad-inline(rem(6px), rem(8px), rem(10px)); + } + } + + %indigo--box-focused { + background: var-get($theme, 'box-background-focus'); + } + + %form-group-bundle--indigo--disabled { + background: transparent; + + &:hover, + &:focus { + background: transparent; + } + + &::after { + border-bottom-style: solid; + } + } + // INDIGO END + + // ============================================== + + // FLUENT START + // Input + %igx-input-group-fluent { + igx-prefix, + [igxPrefix] { + @extend %form-group-prefix-fluent; + } + + igx-suffix, + [igxSuffix] { + @extend %form-group-suffix-fluent; + } + + igx-prefix, + [igxPrefix], + igx-suffix, + [igxSuffix] { + outline-style: none; + } + + select { + width: calc(100% + #{rem(8px)}); + margin-inline-start: rem(-8px) !important; + cursor: pointer !important; + } + } + + %igx-input-group-fluent-search { + display: flex; + flex-direction: column; + + igx-prefix, + [igxPrefix] { + overflow: hidden; + } + + igx-prefix, + [igxPrefix] { + background: var(--igx-input-group-input-prefix-background, transparent); + color: var(--igx-input-group-input-prefix-color, var(--ig-primary-500)); + } + + igx-suffix, + [igxSuffix] { + background: var(--igx-input-group-input-suffix-background, transparent); + color: var(--igx-input-group-input-suffix-color, var(--ig-primary-500)); + } + + &%form-group-display--readonly { + igx-prefix, + [igxPrefix], + igx-suffix, + [igxSuffix] { + color: var(--igx-input-group-disabled-text-color, var(--ig-gray-500)); + } + } + } + + %igx-input-group-fluent-search--focused { + igx-prefix, + [igxPrefix] { + display: none; + } + + igx-suffix, + [igxSuffix] { + color: var(--igx-input-group-input-suffix-color--focused, var(--ig-gray-900)); + } + + &%form-group-display--readonly { + igx-suffix, + [igxSuffix] { + color: var(--igx-input-group-input-suffix-color--focused, var(--ig-gray-900)); + } + } + } + + // Bundle + %form-group-bundle-required--fluent { + &::before { + content: '*'; + position: absolute; + top: rem(-8px); + inset-inline-start: calc(100% + #{rem(4px)}); + color: var-get($theme, 'error-secondary-color'); + } + } + + %form-group-bundle-bootstrap--textarea, + %form-group-bundle-fluent--textarea { + display: flex; + } + + %form-group-label-required--fluent { + &::after { + color: var-get($theme, 'error-secondary-color'); + } + } + + %form-group-label-required--disabled--fluent { + &::after { + color: var-get($theme, 'disabled-text-color'); + } + } + + %form-group-bundle--fluent { + --min-width: #{sizable(rem(4px), rem(6px), rem(8px))}; + --_fluent-input-border-size: #{rem(1px)}; + + min-height: var-get($theme, 'size'); + padding: 0; + background: var-get($theme, 'border-background'); + align-items: stretch; + overflow: visible; + border-radius: var-get($theme, 'border-border-radius'); + position: relative; + border: rem(1px) solid transparent; + + &::before { + content: ''; + position: absolute; + width: calc(100% + var(--_fluent-input-border-size) * 2); + height: calc(100% + var(--_fluent-input-border-size) * 2); + pointer-events: none; + user-select: none; + inset: calc(var(--_fluent-input-border-size) * -1); + z-index: 1; + box-shadow: inset 0 0 0 var(--_fluent-input-border-size) var-get($theme, 'border-color'); + border-radius: inherit; + } + } + + %form-group-bundle--fluent--hover { + &::before { + box-shadow: inset 0 0 0 var(--_fluent-input-border-size) var-get($theme, 'hover-border-color'); + } + } + + %form-group-bundle--fluent--focus { + --_fluent-input-border-size: #{rem(2px)}; + + &::before { + box-shadow: inset 0 0 0 var(--_fluent-input-border-size) var-get($theme, 'focused-border-color'); + } + + &:hover { + &::before { + box-shadow: inset 0 0 0 var(--_fluent-input-border-size) var-get($theme, 'focused-border-color'); + } + } + } + + %form-group-bundle-success--fluent, + %form-group-bundle-success--fluent--hover, + %form-group-bundle-success--fluent--focus { + &::before { + box-shadow: inset 0 0 0 var(--_fluent-input-border-size) var-get($theme, 'success-secondary-color'); + } + } + + %form-group-bundle--fluent--hover-disabled, + %form-group-bundle--fluent-disabled { + background: var-get($theme, 'border-disabled-background'); + + &::before { + box-shadow: inset 0 0 0 var(--_fluent-input-border-size) var-get($theme, 'disabled-border-color'); + } + } + + %form-group-bundle-main--fluent { + align-self: center; + cursor: default; + } + + %form-group-bundle-textarea-start--fluent, + %form-group-bundle-textarea-end--fluent { + &:empty { + display: none; + } + } + + %form-group-display--invalid:not(%form-group-display--readonly), + %form-group-display--invalid%form-group-display--file { + @if $variant != 'indigo' { + %form-group-label--error, + %form-group-helper--error { + color: var-get($theme, 'error-secondary-color'); + } + } + + %form-group-line--error { + background: var-get($theme, 'error-secondary-color'); + } + + %form-group-bundle--error { + &::after { + border-block-end-color: var-get($theme, 'error-secondary-color'); + } + + caret-color: initial; + } + + &%form-group-display--bootstrap { + %bootstrap-input--error { + border: rem(1px) solid var-get($theme, 'error-secondary-color'); + + &:focus { + box-shadow: 0 0 0 rem(4px) var-get($theme, 'error-shadow-color'); + } + } + } + + &%igx-input-group-fluent { + %form-group-bundle-error--fluent, + %form-group-bundle-error--fluent--hover, + %form-group-bundle-error--fluent--focus { + &::before { + box-shadow: inset 0 0 0 var(--_fluent-input-border-size) var-get($theme, 'error-secondary-color'); + } + } + } + } + + // Native input + %fluent-input { + font-size: rem(14px); + padding-block: 0; + padding-inline: rem(8px); + margin: 0; + border: none; + } + + %fluent-file-input { + padding-inline: 0; + } + + %indigo-textarea { + padding-block: $textarea-top-padding 0; + padding-inline: pad-inline(rem(2px), rem(4px), rem(6px)); + inset-block-end: rem(2px); + } + + %fluent-textarea { + padding-inline: pad-inline(rem(8px)); + padding-block: $textarea-top-padding 0; + } + + %fluent-input-disabled { + color: var-get($theme, 'disabled-text-color'); + } + + // Label + %fluent-label { + display: block; + position: static; + transform: translateY(0); + transform-origin: top left; + margin-top: 0 !important; + height: auto; + color: var-get($theme, 'idle-secondary-color'); + + @if $variant == 'fluent' { + margin-block-end: rem(5px); + } @else { + margin-block-end: rem(4px); + } + + [dir='rtl'] & { + transform-origin: top right; + } + } + + %fluent-label + %form-group-bundle-required--fluent { + &::before { + display: none; + } + } + + %fluent-label-success { + color: var-get($theme, 'idle-secondary-color'); + } + + %fluent-label-error { + color: var-get($theme, 'idle-secondary-color'); + } + + %fluent-label-disabled { + color: var-get($theme, 'disabled-text-color'); + } + + %fluent-label-filled { + transform: translateY(0); + } + + %fluent-label-focused { + transform: translateY(0) scale(1); + } + + %fluent-placeholder-label { + transform: translateY(0) scale(1); + } + + %form-group-prefix-fluent, + %form-group-suffix-fluent { + .ig-typography [igx-button], + .ig-typography igx-button, + .ig-typography button, + button { + border-radius: 0; + height: 100%; + } + } + + %form-group-prefix-fluent, + %form-group-suffix-fluent, + %form-group-prefix-fluent-search, + %form-group-suffix-fluent-search { + &:not(:empty) { + padding-inline: pad-inline(rem(8px), rem(10px), rem(14px)); + } + } + + // FLUENT END + + // ============================================== + + // === BOOTSTRAP START === // + // Input group host + %form-group-display--bootstrap-prefixed { + %bootstrap-file-input, + %bootstrap-input { + border: { + start: { + start-radius: 0; + }; + end: { + start-radius: 0; + }; + } + } + } + + %form-group-display--bootstrap-suffixed { + %bootstrap-file-input, + %bootstrap-input { + border: { + start: { + end-radius: 0; + }; + end: { + end-radius: 0; + }; + } + } + } + + %form-group-display--bootstrap-suffixed-focused { + .igx-input-group__clear-icon + igx-suffix, + .igx-input-group__clear-icon + [igxPrefix] { + border-color: var-get($theme, 'focused-border-color'); + } + } + + %form-group-display--bootstrap-suffixed-valid { + .igx-input-group__clear-icon + igx-suffix, + .igx-input-group__clear-icon + [igxPrefix] { + border-color: var-get($theme, 'success-secondary-color'); + } + } + + %form-group-display--bootstrap-suffixed-form-group-display--bootstrap-suffixed-warning { + .igx-input-group__clear-icon + igx-suffix, + .igx-input-group__clear-icon + [igxPrefix] { + border-color: var-get($theme, 'warning-secondary-color'); + } + } + + %form-group-display--bootstrap-suffixed-invalid { + .igx-input-group__clear-icon + igx-suffix, + .igx-input-group__clear-icon + [igxPrefix] { + border-color: var-get($theme, 'error-secondary-color'); + } + } + + %form-group-display--disabled-bootstrap { + background-image: none; + + igx-prefix, + [igxPrefix] { + @extend %form-group-prefix--disabled-bootstrap; + } + + igx-suffix, + [igxSuffix] { + @extend %form-group-suffix--disabled-bootstrap; + } + + %form-group-bundle-start--bootstrap, + %form-group-bundle-end--bootstrap { + border-color: var-get($theme, 'disabled-border-color'); + } + } + + // Bundle + %form-group-bundle--bootstrap { + padding: 0; + box-shadow: none; + z-index: 0; + } + + %form-group-bundle--bootstrap-focused, + %form-group-bundle--bootstrap-hover { + border: none; + box-shadow: none; + } + + %form-group-bundle-start--bootstrap, + %form-group-bundle-end--bootstrap { + flex: none; + border-width: rem(1px); + border-style: solid; + border-color: var-get($theme, 'border-color'); + overflow: hidden; + + &:empty { + display: none; + } + } + + %form-group-bundle-start--bootstrap { + grid-area: 1 / 1; + + border: { + inline: { + start-width: rem(1px); + end-width: 0; + }; + block: { + start-width: rem(1px); + end-width: rem(1px); + }; + start: { + start-radius: var-get($theme, 'border-border-radius'); + }; + end: { + start-radius: var-get($theme, 'border-border-radius'); + }; + } + } + %form-group-bundle-end--bootstrap { + grid-area: 1 / 3; + + border: { + inline: { + start-width: 0; + end-width: rem(1px); + }; + block: { + start-width: rem(1px); + end-width: rem(1px); + }; + start: { + end-radius: var-get($theme, 'border-border-radius'); + }; + end: { + end-radius: var-get($theme, 'border-border-radius'); + }; + } + } + + // Label + %bootstrap-label { + position: static; + display: block; + color: var-get($theme, 'idle-secondary-color'); + padding: 0; + transform: translateY(0); + transform-origin: top left; + margin-block-start: 0 !important; + margin-block-end: rem(4px); + height: auto; + + [dir='rtl'] & { + transform-origin: top right; + } + } + + // Native Input + %bootstrap-input { + height: auto; + line-height: 1.5; + grid-area: 1 / 2; + margin: 0; + z-index: 2; + padding-block: pad-inline( + map.get($bootstrap-block-padding, 'compact'), + map.get($bootstrap-block-padding, 'cosy'), + map.get($bootstrap-block-padding, 'comfortable') + ); + padding-inline: pad-inline( + map.get($bootstrap-inline-padding, 'compact'), + map.get($bootstrap-inline-padding, 'cosy'), + map.get($bootstrap-inline-padding, 'comfortable') + ); + + &:is(textarea) { + padding-block: $textarea-top-padding 0; + } + } + + // The :not selector is needed otherwise bootstrap will override the %autofill-background-fix + %form-group-display--bootstrap { + :not(:has(input:-webkit-autofill, input:autofill)) { + %bootstrap-input { + transition: box-shadow .15s ease-out, border .15s ease-out; + } + } + } + + %bootstrap-input, + %bootstrap-file-input { + border: rem(1px) solid var-get($theme, 'border-color'); + padding-block: pad-inline( + map.get($bootstrap-block-padding, 'compact'), + map.get($bootstrap-block-padding, 'cosy'), + map.get($bootstrap-block-padding, 'comfortable') + ); + padding-inline: pad-inline( + map.get($bootstrap-inline-padding, 'compact'), + map.get($bootstrap-inline-padding, 'cosy'), + map.get($bootstrap-inline-padding, 'comfortable') + ); + border-radius: var-get($theme, 'border-border-radius'); + + } + + %bootstrap-file-input { + height: inherit; + } + + %bootstrap-input-file { + grid-column: 2 / -2; + grid-row: 1 / -1; + height: var-get($theme, 'size'); + } + + %bootstrap-bundle-end { + grid-area: 1 / 5; + } + + %bootstrap-input--focus { + border: rem(1px) solid var-get($theme, 'focused-border-color'); + box-shadow: 0 0 0 rem(4px) var-get($theme, 'focused-secondary-color'); + } + + %bootstrap-input--success { + border: rem(1px) solid var-get($theme, 'success-secondary-color'); + + &:focus { + box-shadow: 0 0 0 rem(4px) var-get($theme, 'success-shadow-color'); + + + %bootstrap-file-input { + box-shadow: 0 0 0 rem(4px) var-get($theme, 'success-shadow-color'); + } + } + } + + %bootstrap-input--warning { + border: rem(1px) solid var-get($theme, 'warning-secondary-color'); + box-shadow: 0 0 0 rem(4px) color($color: 'warn', $variant: '500', $opacity: .38); + } + + %form-group-display:not(%form-group-display--file) { + %bootstrap-input--error { + border: rem(1px) solid var-get($theme, 'error-secondary-color'); + + &:focus { + box-shadow: 0 0 0 rem(4px) var-get($theme, 'error-shadow-color'); + + + %bootstrap-file-input { + box-shadow: 0 0 0 rem(4px) var-get($theme, 'error-shadow-color'); + } + } + } + } + + %bootstrap-input--warning { + border: rem(1px) solid var-get($theme, 'warning-secondary-color'); + + &:focus { + box-shadow: 0 0 0 rem(4px) color($color: 'warn', $variant: 500, $opacity: 0.38); + + + %bootstrap-file-input { + box-shadow: 0 0 0 rem(4px) color($color: 'warn', $variant: 500, $opacity: 0.38); + } + } + } + + %bootstrap-input--disabled { + background: var-get($theme, 'border-disabled-background'); + border: rem(1px) solid var-get($theme, 'disabled-border-color'); + box-shadow: none; + } + + %bootstrap-file-input--disabled { + border: rem(1px) solid var-get($theme, 'disabled-border-color'); + box-shadow: none; + } + + %bootstrap-input--search { + transform: translateY(0); + } + + %form-group-prefix--disabled-bootstrap, + %form-group-suffix--disabled-bootstrap { + background: var-get($theme, 'border-disabled-background'); + 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. +/// Uses the 'subtitle-1', 'caption' +/// category from the typographic scale. +/// @group typography +/// @param {Map} $categories [(helper-text: 'caption', input-text: 'subtitle-1')] - The categories from the typographic scale used for type styles. +@mixin input-group-typography( + $categories: ( + helper-text: 'caption', + input-text: 'subtitle-1' + ) +) { + $helper-text: map.get($categories, 'helper-text'); + $input-text: map.get($categories, 'input-text'); + + %form-group-input { + @include type-style($input-text) { + margin: 0; + } + } + + %form-group-helper { + @include type-style($helper-text); + } + + %form-group-prefix, + %form-group-suffix { + &:not(igx-icon) { + @include type-style($input-text) { + margin: 0; + } + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/label/_label-component.scss b/projects/igniteui-angular/core/src/core/styles/components/label/_label-component.scss new file mode 100644 index 00000000000..8de49ebc668 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/label/_label-component.scss @@ -0,0 +1,16 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff + +@mixin component { + @include register-component( + $name: 'igx-label', + $deps: () + ); + + [igxLabel] { + @extend %label-base !optional; + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/label/_label-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/label/_label-theme.scss new file mode 100644 index 00000000000..99ac7c9c939 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/label/_label-theme.scss @@ -0,0 +1,51 @@ +@use 'sass:map'; +@use 'sass:meta'; +@use '../../base' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin label($theme) { + // The --variant CSS produced by css-vars is needed also + // when dynamically switching between the input `type` attribute. + @include css-vars($theme, '[igxLabel]'); + + $variant: map.get($theme, '_meta', 'theme'); + + %label-base { + @include ellipsis(); + + position: relative; + color: var-get($theme, 'color'); + max-width: 100%; + line-height: normal; + + [dir='rtl'] & { + transform-origin: top right; + } + + @if $variant != 'material' { + height: auto; + } + } +} + +/// Adds typography styles for the igx-label component. +/// Uses the 'caption' +/// category from the typographic scale. +/// @group typography +/// @param {Map} $categories [(label: 'caption')] - The categories from the typographic scale used for type styles. +@mixin label-typography( + $categories: ( + label: 'subtitle-1', + ) +) { + $label: map.get($categories, 'label'); + + %label-base { + @include type-style($label) { + margin: 0; + } + } +} + diff --git a/projects/igniteui-angular/core/src/core/styles/components/list/_list-component.scss b/projects/igniteui-angular/core/src/core/styles/components/list/_list-component.scss new file mode 100644 index 00000000000..6f551506d24 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/list/_list-component.scss @@ -0,0 +1,89 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin component { + @include b(igx-list) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: () + ); + + @extend %igx-list !optional; + + // css class 'igx-list__header' + @include e(header) { + @extend %igx-list-header !optional; + } + + // css class 'igx-list__item-base' + @include e(item-base) { + @extend %igx-list-item-base !optional; + } + + // css class `igx-list__item-base--active + @include e(item-base, $m: active) { + @extend %igx-list-item-base--active !optional; + } + + // css class `igx-list__item-base--selected + @include e(item-base, $m: selected) { + @extend %igx-list-item-base--selected !optional; + } + + // css class 'igx-list__item-right' applied to the panning container shown when the list item is panned left + @include e(item-right) { + @extend %igx-list-item-pan !optional; + } + + // css class 'igx-list__item-left' applied to the panning container shown when the list item in panned right + @include e(item-left) { + @extend %igx-list-item-pan !optional; + } + + // css class 'igx-list__item-content' + @include e(item-content) { + @extend %igx-list-item-content !optional; + + &:active { + @extend %igx-list-item-content !optional; + @extend %igx-list-item-content--active !optional; + } + + &:not(:active) { + @extend %igx-list-item-content--inactive !optional; + } + } + + @include e(item-thumbnail) { + @extend %igx-list__item-thumbnail !optional; + } + + @include e(item-actions) { + @extend %igx-list__item-actions !optional; + } + + @include e(item-lines) { + @extend %igx-list__item-lines !optional; + } + + @include e(item-line-title) { + @extend %igx-list__item-line-title !optional; + } + + @include e(item-line-subtitle) { + @extend %igx-list__item-line-subtitle !optional; + } + + @include m(empty) { + @extend %igx-list !optional; + @extend %igx-list--empty !optional; + + @include e(message) { + @extend %igx-list__message--empty !optional; + } + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/list/_list-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/list/_list-theme.scss new file mode 100644 index 00000000000..d5ba5820585 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/list/_list-theme.scss @@ -0,0 +1,410 @@ +@use 'sass:map'; +@use '../../base' as *; +@use 'igniteui-theming/sass/animations/easings' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin list($theme) { + @include css-vars($theme); + + $variant: map.get($theme, '_meta', 'theme'); + $bootstrap-theme: $variant == 'bootstrap'; + + $theme-padding-block-l: map.get(( + 'material': rem(8px), + 'fluent': rem(8px), + 'bootstrap': rem(8px), + 'indigo': rem(8px), + ), $variant); + + $theme-padding-inline-l: map.get(( + 'material': rem(16px), + 'fluent': rem(16px), + 'bootstrap': rem(16px), + 'indigo': rem(16px), + ), $variant); + + $theme-padding-block-m: map.get(( + 'material': rem(4px), + 'fluent': rem(4px), + 'bootstrap': rem(4px), + 'indigo': rem(6px), + ), $variant); + + $theme-padding-inline-m: map.get(( + 'material': rem(8px), + 'fluent': rem(8px), + 'bootstrap': rem(8px), + 'indigo': rem(12px), + ), $variant); + + $theme-padding-block-s: map.get(( + 'material': rem(2px), + 'fluent': rem(2px), + 'bootstrap': rem(2px), + 'indigo': rem(4px), + ), $variant); + + $theme-padding-inline-s: map.get(( + 'material': rem(4px), + 'fluent': rem(4px), + 'bootstrap': rem(4px), + 'indigo': rem(8px), + ), $variant); + + %igx-list { + @include sizable(); + + --component-size: var(--ig-size, #{var-get($theme, 'default-size')}); + --list-size: var(--component-size); + position: relative; + display: flex; + flex-flow: column nowrap; + background: var-get($theme, 'background'); + height: auto; + overflow-y: auto; + overflow-x: hidden; + z-index: 0; + border-radius: var-get($theme, 'border-radius'); + + @if $variant == 'bootstrap' { + border: var-get($theme, 'border-width') solid var-get($theme, 'border-color'); + } + + &:focus-visible { + outline-style: none; + } + + @if $variant == 'indigo' { + gap: rem(4px); + } + + igx-avatar { + --ig-size: #{if($variant == 'indigo', 2, 1)}; + } + + @if $variant == 'material' { + %cbx-composite-wrapper { + padding: 0; + } + + %cbx-label-pos--after { + margin-inline-start: rem(12px); + } + + %cbx-label-pos--before { + margin-inline-end: rem(12px); + } + + %cbx-label-pos--before, + %cbx-label-pos--after { + &:empty { + margin: 0; + } + } + } + } + + %igx-list--empty { + justify-content: center; + align-items: center; + } + + %igx-list__message--empty { + text-align: center; + color: var-get($theme, 'item-text-color'); + padding: rem(16px); + z-index: 1; + } + + %igx-list-header { + --component-size: var(--list-size); + display: flex; + align-items: center; + color: var-get($theme, 'header-text-color'); + background: var-get($theme, 'header-background'); + user-select: none; + + @if $variant == 'indigo' { + min-height: sizable(rem(24), rem(28), rem(32)); + } + } + + %igx-list-item-base { + display: flex; + flex-flow: column wrap; + justify-content: center; + border-radius: var-get($theme, 'item-border-radius'); + color: var-get($theme, 'item-text-color'); + border-bottom: var-get($theme, 'border-width') solid var-get($theme, 'border-color'); + + &:last-of-type { + border-bottom: none; + } + } + + %igx-list-item-base:not(%igx-list-item-base--selected) { + &:hover, + &:focus-within { + %igx-list__item-lines { + color: currentColor; + } + + %igx-list-item-content:not(%igx-list-item-content--active) { + color: var-get($theme, 'item-text-color-hover'); + background: var-get($theme, 'item-background-hover'); + + %igx-list__item-line-title { + color: var-get($theme, 'item-title-color-hover'); + } + + %igx-list__item-line-subtitle { + color: var-get($theme, 'item-subtitle-color-hover'); + } + + %igx-list__item-actions { + color: var-get($theme, 'item-action-color-hover'); + + igx-icon, + igc-icon { + color: var-get($theme, 'item-action-color-hover') + } + } + + %igx-list__item-thumbnail { + color: var-get($theme, 'item-thumbnail-color-hover'); + + igx-icon, + igc-icon { + color: var-get($theme, 'item-thumbnail-color-hover') + } + } + } + } + } + + %igx-list-item-base--active { + %igx-list-item-content { + @extend %igx-list-item-content--active; + } + } + + %igx-list-item-base--selected { + %igx-list-item-content { + @extend %igx-list-item-content--selected; + } + } + + %igx-list-item-pan { + position: absolute; + visibility: hidden; + display: flex; + z-index: 1; + } + + %igx-list__item-lines { + color: currentColor; + display: flex; + flex-direction: column; + flex: 1 0 0%; + gap: rem(2px); + + &:empty { + display: none; + } + } + + %igx-list__item-line-subtitle { + color: var-get($theme, 'item-subtitle-color'); + } + + %igx-list__item-line-title { + color: var-get($theme, 'item-title-color'); + } + + %igx-list__item-actions { + display: flex; + align-items: center; + justify-content: center; + color: var-get($theme, 'item-action-color'); + gap: if($variant == 'indigo', sizable(rem(4), rem(6), rem(8)), rem(8px)); + + &:empty { + display: none; + } + + > *, + [class^='igx'] { + --component-size: #{if($variant == 'indigo', 2, var(--list-size))}; + } + + igc-icon, + igx-icon { + color: var-get($theme, 'item-action-color') + } + + [dir='rtl'] & { + igx-icon, + igc-icon { + transform: scaleX(-1); + } + } + } + + %igx-list-item-content { + --component-size: var(--list-size); + display: flex; + align-items: center; + position: relative; + border-radius: var-get($theme, 'item-border-radius'); + background: var-get($theme, 'item-background'); + z-index: 2; + gap: if($variant == 'indigo', rem(8px), rem(16px)); + } + + %igx-list-header, + %igx-list-item-content { + padding-inline: pad-inline($theme-padding-inline-s, $theme-padding-inline-m, $theme-padding-inline-l); + padding-block: pad-block($theme-padding-block-s, $theme-padding-block-m, $theme-padding-block-l); + } + + %igx-list__item-thumbnail { + display: flex; + align-items: center; + justify-content: center; + align-self: center; + padding: 0; + color: var-get($theme, 'item-thumbnail-color'); + gap: rem(8px); + + + > igx-icon, + > igc-icon { + --component-size: #{if($variant == 'indigo', 2, var(--list-size))}; + } + + igx-icon, + igc-icon { + color: var-get($theme, 'item-thumbnail-color'); + } + + &:empty { + display: none; + } + } + + %igx-list__item-thumbnail:not(:empty) + %igx-list__item-lines { + --component-size: var(--list-size); + } + + %igx-list-item-content--active { + color: var-get($theme, 'item-text-color-active'); + background: var-get($theme, 'item-background-active'); + z-index: 3; + + %igx-list__item-line-title { + color: var-get($theme, 'item-title-color-active') + } + + %igx-list__item-line-subtitle { + color: var-get($theme, 'item-subtitle-color-active') + } + + %igx-list__item-actions { + color: var-get($theme, 'item-action-color-active'); + + igx-icon, + igc-icon { + color: var-get($theme, 'item-action-color-active')} + } + + %igx-list__item-thumbnail { + color: var-get($theme, 'item-thumbnail-color-active'); + + igx-icon, + igc-icon { + color: var-get($theme, 'item-thumbnail-color-active') + } + } + } + + %igx-list-item-content--selected { + color: var-get($theme, 'item-text-color-selected'); + background: var-get($theme, 'item-background-selected'); + z-index: 3; + + %igx-list__item-line-title { + color: var-get($theme, 'item-title-color-selected') + } + + %igx-list__item-line-subtitle { + color: var-get($theme, 'item-subtitle-color-selected') + } + + %igx-list__item-actions { + color: var-get($theme, 'item-action-color-selected'); + + igx-icon, + igc-icon { + color: var-get($theme, 'item-action-color-selected')} + } + + %igx-list__item-thumbnail { + color: var-get($theme, 'item-thumbnail-color-selected'); + + igx-icon, + igc-icon { + color: var-get($theme, 'item-thumbnail-color-selected') + } + } + } + + %igx-list-item-content--inactive { + transition: transform .3s $out-quad; + } +} + +/// Adds typography styles for the igx-list component. +/// Uses the 'caption' and 'subtitle-1' +/// categories from the typographic scale. +/// @group typography +/// @param {Map} $categories [(header: 'overline', item: 'subtitle-1', title: 'subtitle-1', subtitle: 'body-2')] - The categories from the typographic scale used for type styles. +@mixin list-typography( + $categories: ( + header: 'overline', + item: 'subtitle-1', + title: 'subtitle-1', + subtitle: 'body-2' + ) +) { + $header: map.get($categories, 'header'); + $item: map.get($categories, 'item'); + $title: map.get($categories, 'title'); + $subtitle: map.get($categories, 'subtitle'); + + %igx-list-header { + @include type-style($header) { + margin: 0; + } + } + + %igx-list-item { + @include type-style($item) { + margin: 0; + } + } + + %igx-list__item-lines, + %igx-list__item-line-title { + @include type-style($title) { + margin: 0; + } + } + + %igx-list__item-line-subtitle { + @include type-style($subtitle) { + margin: 0; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/navbar/_navbar-component.scss b/projects/igniteui-angular/core/src/core/styles/components/navbar/_navbar-component.scss new file mode 100644 index 00000000000..887c1359bd8 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/navbar/_navbar-component.scss @@ -0,0 +1,34 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin component { + @include b(igx-navbar) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: ( + igx-icon, + ) + ); + + @extend %igx-navbar-display !optional; + + @include e(title) { + @extend %igx-navbar-title !optional; + } + + @include e(left) { + @extend %igx-navbar-left !optional; + } + + @include e(middle) { + @extend %igx-navbar-middle !optional; + } + + @include e(right) { + @extend %igx-navbar-right !optional; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/navbar/_navbar-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/navbar/_navbar-theme.scss new file mode 100644 index 00000000000..83ae92dc981 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/navbar/_navbar-theme.scss @@ -0,0 +1,169 @@ +@use 'sass:map'; +@use '../../base' as *; +@use 'igniteui-theming/sass/animations/easings' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin navbar($theme) { + @include css-vars($theme); + $variant: map.get($theme, '_meta', 'theme'); + + $navbar-padding: rem(16px); + $navbar-title-fz: rem(18px, 16px); + $navbar-title-lh: rem(18px, 16px); + + %igx-navbar-display { + display: flex; + position: relative; + flex-flow: row nowrap; + align-items: center; + gap: rem(16px); + width: 100%; + min-height: rem(56px); + max-height: rem(128px); + padding-inline: pad-inline($navbar-padding); + background: var-get($theme, 'background'); + color: var-get($theme, 'text-color'); + box-shadow: var-get($theme, 'elevation'); + z-index: 4; + overflow: hidden; + border-bottom: rem(1px) solid var-get($theme, 'border-color'); + + igx-avatar { + --ig-size: 1; + } + + @if $variant == 'material' { + igx-input-group { + --ig-size: 1; + } + + .igx-icon-button, + igc-icon-button { + --ig-size: 2; + } + } + + @if $variant == 'bootstrap' { + igc-input, + igc-icon-button { + --ig-size: 1; + } + + [igxButton], + igc-button, + [igxIconButton], + igx-input-group { + --ig-size: 2; + } + } + + @if $variant == 'fluent' { + igx-input-group { + --ig-size: 2; + } + } + + @if $variant == 'indigo' { + igx-avatar, + igc-icon-button, + [igxIconButton] { + --ig-size: 2; + } + } + } + + %igx-navbar-part { + display: flex; + align-items: center; + } + + %igx-navbar-title { + @include line-clamp(4, true, true); + margin: 0; + flex-grow: 1; + user-select: text; + display: flex; + flex-direction: row; + } + + %igx-navbar-left { + &:not(:empty) { + @if $variant != 'indigo' { + margin-inline-end: rem(16px) + } + } + } + + %igx-navbar-middle { + flex-grow: 1; + } + + %igx-navbar-right { + gap: if($variant == 'indigo', rem(8px), rem(16px)); + } + + %igx-navbar-left, + %igx-navbar-right { + &:empty { + display: none; + } + } + + %igx-navbar-left, + %igx-navbar-middle, + %igx-navbar-right { + display: flex; + align-items: center; + + igx-icon, + igc-icon { + --component-size: #{if($variant == 'indigo', 2, 3)}; + + cursor: pointer; + user-select: none; + transition: color .15s $out-quad; + + @if $variant == 'indigo' { + width: auto; + height: auto; + padding: rem(6px); + } + } + + > igx-icon, + > igc-icon { + color: var-get($theme, 'idle-icon-color'); + + &:hover { + color: var-get($theme, 'hover-icon-color'); + } + } + } + + igx-navbar-action, + [igxNavbarAction] { + @extend %igx-navbar-part; + } + + igx-navbar-title, + [igxNavbarTitle] { + @extend %igx-navbar-part; + @extend %igx-navbar-title !optional; + } +} + +/// Adds typography styles for the igx-navbar component. +/// Uses the 'body-1', 'caption' +/// category from the typographic scale. +/// @group typography +/// @param {Map} $categories [(title: 'h6')] - The categories from the typographic scale used for type styles. +@mixin navbar-typography($categories: (title: 'h6')) { + $title: map.get($categories, 'title'); + + %igx-navbar-title { + @include type-style($title); + margin-bottom: 0; + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/navdrawer/_navdrawer-component.scss b/projects/igniteui-angular/core/src/core/styles/components/navdrawer/_navdrawer-component.scss new file mode 100644 index 00000000000..9fa604294f1 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/navdrawer/_navdrawer-component.scss @@ -0,0 +1,105 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Marin Popov +@mixin component { + @include b(igx-nav-drawer) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: () + ); + + @extend %navdrawer-display !optional; + + @include m(pinned) { + @extend %navdrawer-display-pinned !optional; + } + + @include mx(mini, pinned) { + @extend %navdrawer-display-mini-pinned !optional; + } + + // Main aside element + @include e(aside) { + @extend %aside !optional; + + &.panning { + @extend %aside-panning !optional; + } + } + + @include e(aside, collapsed) { + @extend %aside--collapsed !optional; + + &.igx-nav-drawer__aside--right { + @extend %aside--collapsed--right !optional; + } + } + + @include e(aside, right) { + @extend %aside--right !optional; + } + + @include e(aside, mini) { + @extend %aside--mini !optional; + } + + @include e(aside, normal) { + @extend %aside--normal !optional; + } + + @include e(aside, pinned) { + @extend %aside--pinned !optional; + + &.igx-nav-drawer__aside--collapsed { + @extend %igx-nav-drawer__aside--collapsed !optional; + } + } + + // Overlay + @include e(overlay) { + @extend %overlay !optional; + + &.panning { + @extend %overlay-panning !optional; + + &.igx-nav-drawer__overlay--hidden { + @extend %overlay-panning--hidden !optional; + } + } + } + + @include e(overlay, hidden) { + @extend %overlay--hidden !optional; + } + + // Style Dummy(hidden) used for measures + @include e(style-dummy) { + @extend %style-dummy !optional; + } + + // igxDrawerItem Items + @include e(item) { + @extend %item !optional; + } + + @include e(item, active) { + @extend %item !optional; + @extend %item--active !optional; + } + + @include e(item, header) { + @extend %item--header !optional; + } + + @include e(item, disabled) { + @extend %item--disabled !optional; + } + + @include m(disable-animation) { + @extend %disable-animation !optional; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/navdrawer/_navdrawer-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/navdrawer/_navdrawer-theme.scss new file mode 100644 index 00000000000..fa032dc01b5 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/navdrawer/_navdrawer-theme.scss @@ -0,0 +1,406 @@ +@use 'sass:map'; +@use '../../base' as *; +@use 'igniteui-theming/sass/animations/easings' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin navdrawer($theme) { + @include css-vars($theme); + + $variant: map.get($theme, '_meta', 'theme'); + $drawer-icon-size: rem(24px); + + $aside-padding: map.get(( + 'material': rem(8px), + 'fluent': 0, + 'bootstrap': rem(16px), + 'indigo': rem(8px), + ), $variant); + + $item-padding: map.get(( + 'material': pad-block(rem(12px)) pad-inline(rem(8px)), + 'fluent': pad-block(rem(10px)) pad-inline(rem(8px)), + 'bootstrap': pad-block(rem(8px)) pad-inline(rem(16px)), + 'indigo': pad-block(rem(8px)) pad-inline(rem(16px)), + ), $variant); + + $item-gap: map.get(( + 'material': rem(32px), + 'fluent': rem(8px), + 'bootstrap': rem(8px), + 'indigo': rem(16px), + ), $variant); + + $item-min-height: map.get(( + 'material': rem(48px), + 'fluent': rem(44px), + 'bootstrap': rem(40px), + 'indigo': rem(32px), + ), $variant); + + $item-mini-size: map.get(( + 'material': rem(56px), + 'fluent': rem(40px), + 'bootstrap': rem(88px), + 'indigo': rem(48px), + ), $variant); + + %navdrawer-display { + --igx-nav-drawer-size: #{rem(240px)}; + --igx-nav-drawer-size--mini: #{$item-mini-size}; + + flex-basis: 0; + transition: flex-basis; + transition-duration: .3s; + transition-timing-function: $out-quad; + flex-shrink: 0; + } + + %navdrawer-display-pinned { + flex-basis: var(--igx-nav-drawer-size); + } + + %navdrawer-display-mini-pinned { + flex-basis: calc(var(--igx-mini-nav-drawer-size, #{$item-mini-size}) + rem(1px)); + } + + %aside { + position: fixed; + height: 100%; + min-height: 100%; + overflow-x: hidden; + background: var-get($theme, 'background'); + top: 0; + bottom: 0; + width: var(--igx-nav-drawer-size); + inset-inline-start: 0; + z-index: 999; + transition: width, padding, transform; + transition-timing-function: $in-out-quad; + box-shadow: var-get($theme, 'elevation'); + padding: $aside-padding; + + @if $variant != 'fluent' { + border-inline-end: rem(1px) solid var-get($theme, 'border-color'); + } @else { + border: rem(1px) solid var-get($theme, 'border-color'); + } + + border-radius: var-get($theme, 'border-radius'); + } + + %aside-panning { + overflow-x: hidden; + transition: none; + } + + %aside--pinned { + position: relative; + box-shadow: none; + z-index: 0; + } + + %aside--collapsed--right { + transform: translate3d(300px, 0, 0); + box-shadow: none; + + [dir='rtl'] & { + transform: translate3d(-300px, 0, 0); + } + } + + %igx-nav-drawer__aside--collapsed { + transform: none; + width: 0; + overflow: hidden; + border: none; + padding: 0; + } + + %aside--collapsed { + transform: translate3d(-300px, 0, 0); + + [dir='rtl'] & { + transform: translate3d(300px, 0, 0); + } + + box-shadow: none; + } + + %aside--right { + inset-inline-start: auto; + inset-inline-end: 0; + border-inline-end: none; + + @if $variant != 'fluent' { + border-inline-start: rem(1px) solid var-get($theme, 'border-color'); + } + } + + %aside--mini { + transition-duration: .3s; + width: var(--igx-nav-drawer-size--mini); + min-width: fit-content; + + %item { + justify-content: center; + min-width: fit-content; + + @if $variant == 'bootstrap' { + width: rem(56px); + } + + @if $variant == 'indigo' { + width: rem(32px); + + // important is needed to override the typography margins + margin: rem(4px) auto !important; + } + + igx-icon { + margin-inline-start: 0; + } + } + } + + %aside--normal { + transition-duration: .3s; + width: var(--igx-nav-drawer-size); + } + + %overlay { + opacity: 1; + background: color(null, 'gray', 500, .54); + transition: opacity, visibility; + transition-duration: .3s, .3s; + transition-timing-function: ease-in, step-start; + transition-delay: 0s, 0s; + position: absolute; + inset-inline-start: 0; + top: 0; + width: 100%; + height: 100%; + visibility: visible; + z-index: 999; + } + + %overlay-panning { + transform: translate3d(0, 0, 0); + transition: none; + } + + %overlay--hidden { + transition-timing-function: ease-in-out, step-end; + visibility: hidden; + opacity: 0; + } + + %overlay-panning--hidden { + /* must be visible during pan.. */ + visibility: visible; + } + + %item { + position: relative; + display: flex; + align-items: center; + flex-flow: row nowrap; + color: var-get($theme, 'item-text-color'); + max-height: $item-min-height; + gap: $item-gap; + padding: $item-padding; + + @if $variant == 'indigo' { + margin-block-end: rem(4px) !important; + } + + cursor: pointer; + user-select: none; + outline: transparent; + white-space: nowrap; + + // For material the radius is on the after element + @if $variant != 'material' { + border-radius: var-get($theme, 'item-border-radius'); + } + + text-decoration: none; + border: none; + justify-content: flex-start; + + igx-icon { + --component-size: #{if($variant == 'indigo', 2, 3)}; + + color: var-get($theme, 'item-icon-color'); + } + + // Need this to override the igx-buttons + &[igxButton] { + background: transparent; + border: none; + } + + &%igx-button--fab { + min-height: auto; + } + + &%igx-icon-button-display { + height: auto; + transition: none; + } + + @if $variant == 'material' { + $reduce-size: rem(8px); + + // The clip path here fixes a bug: https://github.com/IgniteUI/igniteui-angular/issues/14554 + clip-path: inset(calc($reduce-size / 2) 0 round var-get($theme, 'item-border-radius')); + + &::after { + content: ''; + position: absolute; + width: 100%; + inset-block-start: rem(4px); + inset-inline-start: 0; + height: calc(100% - #{$reduce-size}); + border-radius: var-get($theme, 'item-border-radius'); + z-index: -1; + } + } + + &:hover, + &:focus { + @if $variant == 'material' { + &::after { + background: var-get($theme, 'item-hover-background'); + } + } @else { + background: var-get($theme, 'item-hover-background'); + } + + color: var-get($theme, 'item-hover-text-color'); + box-shadow: none; + + igx-icon { + color: var-get($theme, 'item-hover-icon-color'); + } + } + } + + %item--active { + // should be app primary color + color: var-get($theme, 'item-active-text-color'); + + @if $variant == 'material' { + &::after { + background: var-get($theme, 'item-active-background'); + } + } @else { + background: var-get($theme, 'item-active-background'); + } + + + igx-icon { + color: var-get($theme, 'item-active-icon-color'); + } + + &:focus, + &:hover { + color: var-get($theme, 'item-active-text-color'); + + @if $variant == 'material' { + &::after { + background: var-get($theme, 'item-active-background'); + } + } @else { + background: var-get($theme, 'item-active-background'); + } + + igx-icon { + color: var-get($theme, 'item-active-icon-color'); + } + } + } + + %item, + %item--active { + // Need this to override the igx-buttons + &[igxButton] { + box-shadow: none; + + igx-icon { + width: var(--igx-icon-size, #{$drawer-icon-size}); + height: var(--igx-icon-size, #{$drawer-icon-size}); + font-size: var(--igx-icon-size, #{$drawer-icon-size}); + margin: 0; + } + + &:hover, + &:focus { + box-shadow: none; + border: none; + } + } + } + + %item--header { + display: flex; + align-items: center; + padding: $item-padding; + min-height: $item-min-height; + white-space: nowrap; + color: var-get($theme, 'item-header-text-color'); + + @if $variant == 'indigo' { + margin-block-end: rem(4px) !important; + } + } + + %item--disabled { + background: none; + color: var-get($theme, 'item-disabled-text-color'); + cursor: default; + pointer-events: none; + + igx-icon { + color: var-get($theme, 'item-disabled-icon-color'); + } + } + + %style-dummy { + height: 0; + background: none; + box-shadow: none; + transition: none; + visibility: hidden; + } + + %disable-animation { + transition-duration: 0s; + } +} + +/// Adds typography styles for the igx-navdrawer component. +/// Uses the 'subtitle-1', 'subtitle-2' +/// category from the typographic scale. +/// @group typography +/// @param {Map} $categories [(item: 'subtitle-2', header: 'subtitle-1')] - The categories from the typographic scale used for type styles. +@mixin navdrawer-typography( + $categories: ( + item: 'subtitle-2', + header: 'subtitle-1' + ) +) { + $item: map.get($categories, 'item'); + $header: map.get($categories, 'header'); + + %item { + @include type-style($item); + } + + %item--header { + @include type-style($header) { + margin: 0; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/overlay/_overlay-component.scss b/projects/igniteui-angular/core/src/core/styles/components/overlay/_overlay-component.scss new file mode 100644 index 00000000000..68525c6f0d2 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/overlay/_overlay-component.scss @@ -0,0 +1,64 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin component { + @include b(igx-overlay) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: () + ); + + @extend %overlay-display !optional; + + @include e(wrapper) { + @extend %overlay-wrapper !optional; + } + + @include e(wrapper, $m: modal) { + @extend %overlay-wrapper !optional; + @extend %overlay-wrapper--modal !optional; + } + + @include e(wrapper, $m: flex) { + @extend %overlay-wrapper !optional; + @extend %overlay-wrapper--flex !optional; + } + + @include e(wrapper, $m: flex-container) { + @extend %overlay-wrapper !optional; + @extend %overlay-wrapper--flex-container !optional; + } + + @include e(content) { + @extend %overlay-content !optional; + } + + @include e(content, $m: modal) { + @extend %overlay-content !optional; + @extend %overlay-content--modal !optional; + } + + @include e(content, $m: elastic) { + @extend %overlay-content !optional; + @extend %overlay-content--elastic !optional; + } + + @include e(content, $m: relative) { + @extend %overlay-content !optional; + @extend %overlay-content--relative !optional; + } + } + + @include b(igx-toggle) { + @include m(hidden) { + @extend %igx-toggle--hidden !optional; + } + + @include m(hidden-webkit) { + @extend %igx-toggle--hidden-webkit !optional; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/overlay/_overlay-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/overlay/_overlay-theme.scss new file mode 100644 index 00000000000..24027f0d3fc --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/overlay/_overlay-theme.scss @@ -0,0 +1,84 @@ +@use 'sass:map'; +@use '../../base' as *; +@use 'igniteui-theming/sass/animations/easings' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin overlay($theme) { + @include css-vars($theme, '.igx-overlay__wrapper--modal, .igx-overlay__content--modal'); + + %overlay-display { + width: 0; + height: 0; + box-sizing: content-box; + } + + %overlay-wrapper { + position: fixed; + inset: 0; + background: transparent; + transition: background .25s $in-out-quad; + pointer-events: none; + z-index: 10005; + box-sizing: content-box; + } + + %overlay-wrapper--modal { + background: var-get($theme, 'background-color'); + pointer-events: initial; + } + + %overlay-wrapper--flex { + display: flex; + } + + %overlay-wrapper--flex-container { + display: flex; + position: absolute; + } + + %overlay-content { + position: absolute; + pointer-events: all; + box-sizing: content-box; + } + + %overlay-content--modal { + pointer-events: initial; + } + + %overlay-content--elastic { + overflow: auto; + } + + %overlay-content--relative { + position: relative; + } + + %igx-toggle--hidden:not(%igx-toggle--hidden-webkit) { + display: none !important; + } + + %igx-toggle--hidden-webkit { + // WARN: This is a workaround around a bug in Safari. + position: absolute; + visibility: hidden; + // width/height/min-width to 0 needed for bug #14303 + width: 0; + min-width: 0; + height: 0; + // needed for bug #14302 + padding: 0 !important; + top: 0; + left: 0; + margin: -1px; + border: none; + clip: rect(0, 0, 0, 0); + outline: 0; + pointer-events: none; + overflow: hidden; + appearance: none; + z-index: -1; + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/paginator/_paginator-component.scss b/projects/igniteui-angular/core/src/core/styles/components/paginator/_paginator-component.scss new file mode 100644 index 00000000000..b58e41748bc --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/paginator/_paginator-component.scss @@ -0,0 +1,45 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin component { + @include b(igx-paginator) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: ( + igx-button, + igx-icon-button, + igx-input-group, + ) + ); + + @extend %igx-paginator !optional; + @extend %igx-paginator--sizable !optional; + } + + @include b(igx-page-nav) { + @extend %igx-page-nav !optional; + + @include e(text) { + @extend %igx-page-nav__text !optional; + } + } + + @include b(igx-page-size) { + @extend %igx-page-size !optional; + + @include e(label) { + @extend %igx-page-size__label !optional; + } + + @include e(select) { + @extend %igx-page-size__select !optional; + } + } + + @include b(igx-paginator-content) { + @extend %igx-paginator-content !optional; + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/paginator/_paginator-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/paginator/_paginator-theme.scss new file mode 100644 index 00000000000..5aeb1c6ed8b --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/paginator/_paginator-theme.scss @@ -0,0 +1,101 @@ +@use 'sass:map'; +@use '../../base' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin paginator($theme) { + @include css-vars($theme); + + $variant: map.get($theme, '_meta', 'theme'); + + $paginator-padding-inline: ( + comfortable: rem(24px), + cosy: rem(16px), + compact: rem(12px) + ); + + %igx-paginator { + --component-size: var(--ig-size, var(--ig-size-large)); + + display: flex; + justify-content: space-between; + align-items: center; + color: var-get($theme, 'text-color'); + background: var-get($theme, 'background-color'); + font-size: rem(12px); + border-top: rem(1px) solid var-get($theme, 'border-color'); + + @if $variant == 'indigo' { + font-size: rem(11px); + font-weight: 400; + line-height: rem(15px); + } + + z-index: 1; + padding-inline: pad-inline(map.get($paginator-padding-inline, 'compact'), map.get($paginator-padding-inline, 'cosy'), map.get($paginator-padding-inline, 'comfortable')); + padding-block: 0; + height: var-get($theme, 'size'); + width: 100%; + + &:empty { + padding: 0; + } + } + + %igx-paginator--sizable { + @include sizable(); + } + + %igx-paginator-content { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + } + + %igx-page-size { + display: flex; + justify-content: flex-start; + align-items: center; + flex: 1; + } + + %igx-page-size__label { + margin-inline-end: rem(8px); + @include ellipsis(); + } + + %igx-page-size__select { + display: flex; + max-width: rem(114px); + min-width: rem(100px); + + @if $variant != 'indigo' { + igx-select { + --ig-size: 1; + } + } + } + + %igx-page-nav { + display: flex; + justify-content: flex-end; + align-items: center; + flex: 1; + + > * { + margin-inline-start: rem(8px); + } + + [dir='rtl'] & { + igx-icon { + transform: scaleX(-1); + } + } + } + + %igx-page-nav__text { + display: flex; + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/progress/circular/_circular-component.scss b/projects/igniteui-angular/core/src/core/styles/components/progress/circular/_circular-component.scss new file mode 100644 index 00000000000..3143a559d61 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/progress/circular/_circular-component.scss @@ -0,0 +1,68 @@ +@use '../../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Marin Popov +@mixin component { + @include b(igx-circular-bar) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: () + ); + + @extend %display-circular !optional; + + @include e(inner) { + @extend %inner !optional; + } + + @include e(outer) { + @extend %outer !optional; + } + + @include e(text) { + @extend %text !optional; + } + + @include e(value, $m: fraction) { + @extend %text--fraction !optional; + } + + @include e(gradient-start) { + @extend %gradient-start !optional; + } + + @include e(gradient-end) { + @extend %gradient-end !optional; + } + + @include m(indeterminate) { + @extend %display-circular--indeterminate !optional; + + @include e(outer) { + @extend %outer--indeterminate !optional; + } + + @include e(text) { + @extend %text--hidden !optional; + } + } + + @each $modifier in ('danger', 'warning', 'info', 'success') { + @include m($modifier) { + @extend %display-circular--#{$modifier} !optional; + } + } + + @include m(animation-none) { + @extend %animation-none !optional; + } + + @include m(hide-counter) { + @include e(text) { + @extend %hide-counter !optional; + } + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/progress/circular/_circular-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/progress/circular/_circular-theme.scss new file mode 100644 index 00000000000..17c86b5e262 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/progress/circular/_circular-theme.scss @@ -0,0 +1,366 @@ +@use 'sass:map'; +@use '../../../base' as *; +@use 'igniteui-theming/sass/animations' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin progress-circular($theme) { + // Include rotate animation + @include rotate-center(); + @include css-vars($theme); + + $animation-direction: normal; + $animation-direction-rtl: reverse; + $diameter: calc(var(--circular-size) + var(--stroke-thickness)); + $radius: calc(var(--circular-size) / 2 - var(--stroke-thickness) * .5); + $circumference: calc(#{$radius} * 2 * 3.1416); + $variant: map.get($theme, '_meta', 'theme'); + + %display-circular { + --circular-size: calc(#{var-get($theme, 'diameter')} - var(--stroke-thickness)); + + display: inline-flex; + align-items: center; + justify-content: center; + position: relative; + + // Do not use rem values here + // This will break the component in Safari + // https://github.com/IgniteUI/igniteui-webcomponents/issues/377 + // TODO SEE if this restriction is necessary + @if $variant == 'material' { + --stroke-thickness: 1.3px; + --scale-factor: 2.95; + } + + @if $variant == 'bootstrap' { + --stroke-thickness: 2px; + --scale-factor: 3.05; + } + + @if $variant == 'fluent' { + --stroke-thickness: 2px; + --scale-factor: 2.75; + } + + @if $variant == 'indigo' { + --stroke-thickness: 3px; + --scale-factor: 3.4; + } + + svg { + width: $diameter; + height: $diameter; + transform: rotate(-90deg); + transform-origin: center; + + %outer { + stroke: var-get($theme, 'fill-color-default'); + + @if $variant != 'bootstrap' { + animation: igx-initial-dashoffset var(--_transition-duration) linear; + } @else { + stroke-width: var(--stroke-thickness); + } + + stroke-dasharray: #{$circumference} #{$circumference}; + stroke-dashoffset: calc(#{$circumference} - var(--_progress-percentage) * #{$circumference}); + } + } + } + + %display-circular:not(%display-circular--indeterminate) { + svg { + %outer { + animation: igx-initial-dashoffset var(--_transition-duration) linear; + stroke-dasharray: #{$circumference} #{$circumference}; + stroke-dashoffset: calc(#{$circumference} - var(--_progress-percentage) * #{$circumference}); + } + } + } + + %display-circular:dir(rtl):not(%display-circular--indeterminate) { + svg { + %outer { + animation: igx-initial-dashoffset-rtl var(--_transition-duration) linear; + animation-direction: reverse; + } + } + } + + %display-circular--indeterminate { + svg { + @if $variant != 'fluent' { + animation: 3s linear 0s infinite $animation-direction none running rotate-center; + } + + @if $variant != 'bootstrap' { + transform-origin: 50% 50%; + } @else { + animation-duration: .75s; + } + + %inner { + @if $variant == 'bootstrap' { + stroke: transparent; + } + } + + %outer { + stroke-dashoffset: calc(#{$circumference} * 2); + + @if $variant == 'fluent' { + stroke-linecap: round; + animation: 2s linear 0s infinite normal none running igx-indeterminate-circular-fluent + } @else { + animation: igx-indeterminate-accordion 1.5s cubic-bezier(0, 0.085, 0.68, 0.53) $animation-direction infinite; + } + + @if $variant == 'bootstrap' { + stroke-dashoffset: 60%; + animation: none; + } + } + } + } + + %display-circular--indeterminate:dir(rtl) { + svg { + animation-direction: reverse; + + %outer { + @if $variant == 'fluent' { + animation-name: igx-indeterminate-circular-fluent-rtl; + } + + @if $variant == 'bootstrap' { + stroke-dashoffset: 60%; + } + } + } + + animation-direction: $animation-direction-rtl; + + %inner { + animation-direction: $animation-direction-rtl; + } + + %outer { + stroke-dashoffset: calc(#{$circumference} + var(--_progress-percentage) * #{$circumference}); + + @if $variant != 'fluent' { + animation-direction: reverse; + } + } + } + + %inner { + stroke-width: var(--stroke-thickness); + stroke: var-get($theme, 'base-circle-color'); + } + + %outer { + --_progress-percentage: calc(var(--_progress-whole, 0) / 100); + + stroke-dasharray: #{$circumference} #{$circumference}; + stroke-dashoffset: calc(#{$circumference} - var(--_progress-whole, 0) * #{$circumference}); + transition: stroke-dashoffset var(--_transition-duration) linear; + + @if $variant == 'material' { + stroke-width: calc(var(--stroke-thickness) + rem(0.75px)); + } @else { + stroke-width: var(--stroke-thickness); + } + } + + @each $mod in ('success','info','warning','danger') { + %display-circular--#{$mod} { + svg %outer { + stroke: var-get($theme, 'fill-color-#{$mod}'); + } + } + } + + %outer--indeterminate { + stroke-dasharray: 289; + @include animation(igx-indeterminate-accordion var(--_transition-duration) cubic-bezier(0, .085, .68, .53) normal infinite); + } + + %inner, + %outer { + width: 100%; + height: 100%; + fill: transparent; + cx: calc(#{$diameter} / 2); + cy: calc(#{$diameter} / 2); + r: $radius; + transform-origin: center; + } + + %text { + position: absolute; + color: var-get($theme, 'text-color'); + font-size: round(calc(#{var-get($theme, 'diameter')} / var(--scale-factor) - var(--stroke-thickness)), 1px); + line-height: normal; + text-align: center; + font-weight: 600; + + @if $variant == 'bootstrap' or $variant == 'fluent' { + font-weight: 700; + } + + animation: igx-initial-counter var(--_transition-duration) ease-in-out; + counter-reset: + progress-integer var(--_progress-integer, 0) + progress-fraction var(--_progress-fraction, 0); + transition: + --_progress-integer var(--_transition-duration) ease-in-out, + --_progress-fraction var(--_transition-duration) ease-in-out; + } + + %text:not(%text--fraction) { + &::before { + content: counter(progress-integer) '%'; + } + } + + %text--fraction { + &::before { + content: counter(progress-integer) '.' counter(progress-fraction, decimal-leading-zero) '%'; + } + } + + %text--hidden { + visibility: hidden; + } + + %gradient-start { + stop-color: var-get($theme, 'fill-color-default-end'); + } + + %gradient-end { + stop-color: var-get($theme, 'fill-color-default-start'); + } + + @keyframes igx-indeterminate-accordion { + 0% { + stroke-dashoffset: calc(#{$circumference} * 2); + stroke-dasharray: calc(#{$circumference} * 9 / 10); + } + + 100% { + stroke-dashoffset: calc(#{$circumference} * 2 / 5); + } + } + + @keyframes igx-indeterminate-accordion-rtl { + 0% { + stroke-dashoffset: calc(#{$circumference} * -2); + stroke-dasharray: calc(#{$circumference} * 9 / 10); + } + + 100% { + stroke-dashoffset: calc(#{$circumference} * -2 / 5); + } + } + + // Fluent: Circular progress animation for indeterminate state. + // Dynamically changes stroke-dasharray and rotates for a smooth spinning effect. + @keyframes igx-indeterminate-circular-fluent { + 0% { + // Start the stroke at the correct position by adjusting the dasharray and dashoffset + stroke-dasharray: calc(#{$circumference} * 0.0001), #{$circumference}; + stroke-dashoffset: calc(-1 * #{$circumference} / 4); + + // Start at 12 o'clock + transform: rotate(-90deg); + } + + 50% { + stroke-dasharray: calc(#{$circumference} / 2), calc(#{$circumference} / 2); + + // Adjust to keep starting point correct + stroke-dashoffset: calc(-1 * #{$circumference} / 4); + + // Continue rotating smoothly + transform: rotate(360deg); + } + + 100% { + stroke-dasharray: calc(#{$circumference} * 0.0001), #{$circumference}; + + // Reset properly + stroke-dashoffset: calc(-1 * #{$circumference} / 4); + + // Complete the full rotation + transform: rotate(990deg); + } + } + + @keyframes igx-indeterminate-circular-fluent-rtl { + 0% { + stroke-dasharray: calc(#{$circumference} * 0.0001), #{$circumference}; + + // Positive offset for opposite direction + stroke-dashoffset: calc(#{$circumference} / 4); + transform: rotate(90deg); + } + + 50% { + stroke-dasharray: calc(#{$circumference} / 2), calc(#{$circumference} / 2); + + // Positive offset for opposite direction + stroke-dashoffset: calc(#{$circumference} / 4); + transform: rotate(-360deg); + } + + 100% { + stroke-dasharray: calc(#{$circumference} * 0.0001), #{$circumference}; + + // Positive offset for opposite direction + stroke-dashoffset: calc(#{$circumference} / 4); + transform: rotate(-990deg); + } + } + + @keyframes igx-initial-dashoffset { + from { + /* Start with no progress (0%) */ + stroke-dashoffset: #{$circumference}; + } + + to { + stroke-dashoffset: calc(#{$circumference} - var(--_progress-percentage) * #{$circumference}); + } + } + + // Generic animations + @keyframes igc-initial-counter { + from { + --_progress-integer: 0; + --_progress-fraction: 0; + } + } + + @keyframes igx-rotate-center { + 0% { + transform: rotate(0); + } + + 100% { + transform: rotate(360deg); + } + } + + // Reset the transition if the animate prop is set to false. + %animation-none:not(%display-circular--indeterminate) { + --_transition-duration: 0s !important; + } + + %hide-counter { + &::before { + display: none; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/progress/linear/_linear-component.scss b/projects/igniteui-angular/core/src/core/styles/components/progress/linear/_linear-component.scss new file mode 100644 index 00000000000..c46415ac6b6 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/progress/linear/_linear-component.scss @@ -0,0 +1,82 @@ +@use '../../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Marin Popov +@mixin component { + @include b(igx-linear-bar) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: () + ); + + @extend %display-linear !optional; + + @include e(base) { + @extend %base !optional; + } + + @include e(indicator) { + @extend %indicator !optional; + } + + @include e(indicator-secondary) { + @extend %indicator__secondary !optional; + } + + @include e(value) { + @extend %value !optional; + } + + @each $modifier in ('start', 'center', 'end', 'top', 'hidden') { + @include e(value, $m: $modifier) { + @extend %value--#{$modifier} !optional; + } + } + + @include e(value, $m: hidden) { + @extend %value--hidden !optional; + } + + @include e(value, $m: fraction) { + @extend %value--fraction !optional; + } + + @each $modifier in ('danger', 'warning', 'info', 'success') { + @include m($modifier) { + @extend %display-linear--#{$modifier} !optional; + } + } + + @include m(striped) { + @extend %display-linear--striped !optional; + } + + @include m(indeterminate) { + @extend %display-linear--indeterminate !optional; + + @include e(indicator) { + @extend %indicator__indeterminate !optional; + } + + @include e(indicator-secondary) { + @extend %indicator__indeterminate-secondary !optional; + } + + @include e(value) { + @extend %value--hidden !optional; + } + } + + @include m(animation-none) { + @extend %animation-none !optional; + } + + @include m(hide-counter) { + @include e(value) { + @extend %hide-counter !optional; + } + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/progress/linear/_linear-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/progress/linear/_linear-theme.scss new file mode 100644 index 00000000000..b5e78dcea58 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/progress/linear/_linear-theme.scss @@ -0,0 +1,414 @@ +@use 'sass:map'; +@use 'sass:math'; +@use 'sass:meta'; +@use 'sass:list'; +@use '../../../base/index' as *; +@use 'igniteui-theming/sass/animations' as *; + +@mixin striped-gradient($variant: default, $gradient-orientation, $stripe-color) { + $fill-color-default: if($variant == 'indigo', transparent, $stripe-color); + $stripes-color: if($variant == 'indigo', $stripe-color, transparent); + + & { + background-image: repeating-linear-gradient( + $gradient-orientation, + $stripes-color, + $stripes-color var(--stripe-size), + $fill-color-default var(--stripe-size), + $fill-color-default calc(var(--stripe-size) * 2) + ); + } +} + +$easing-curves: ( + // Primary translate easing curves + primary-translate-start: cubic-bezier(0.5, 0, 0.7017, 0.4958), + primary-translate-mid: cubic-bezier(0.3024, 0.3813, 0.55, 0.9563), + + // Primary scale easing curves + primary-scale-slow-start: cubic-bezier(0.3347, 0.124, 0.7858, 1), + primary-scale-quick-end: cubic-bezier(0.06, 0.11, 0.6, 1), + + // Secondary translate easing curves + secondary-translate-start: cubic-bezier(0.15, 0, 0.515, 0.4096), + secondary-translate-mid: cubic-bezier(0.31, 0.284, 0.8, 0.7337), + secondary-translate-end: cubic-bezier(0.4, 0.627, 0.6, 0.902), + + // Secondary scale easing curves + secondary-scale-slow-start: cubic-bezier(0.15, 0, 0.515, 0.4096), + secondary-scale-mid: cubic-bezier(0.31, 0.284, 0.8, 0.7337), + secondary-scale-smooth-end: cubic-bezier(0.4, 0.627, 0.6, 0.902) +); + +// Helper function to retrieve easing curves +@function get-easing($curve) { + @if not map.has-key($easing-curves, $curve) { + @warn 'Easing curve #{$curve} does not exist.'; + } + @return map.get($easing-curves, $curve); +} + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin progress-linear($theme) { + @include css-vars($theme); + + $variant: map.get($theme, '_meta', 'theme'); + + %display-linear { + position: relative; + display: flex; + width: 100%; + flex: 1 1 100%; + flex-direction: column; + + @if $variant == 'indigo' { + gap: rem(4px); + } @else if $variant == 'fluent' { + gap: rem(2px); + } + } + + %display-linear--striped:not(%display-linear--indeterminate) { + --linear-strips-orientation: -45deg; + + &:dir(rtl) { + --linear-strips-orientation: 45deg + } + + %indicator { + @include striped-gradient( + map.get($theme, 'variant'), + var(--linear-strips-orientation), + var-get($theme, 'stripes-color') + ); + } + } + + %base { + --stripe-size: #{var-get($theme, 'strip-size')}; + --linear-animation-duration: 2000ms; + + display: flex; + flex-direction: column; + position: relative; + width: inherit; + height: var-get($theme, 'track-height'); + background: var-get($theme, 'track-color'); + overflow: hidden; + border-radius: var-get($theme, 'track-border-radius'); + z-index: 0; + } + + %indicator { + width: calc(var(--_progress-whole) * 1%); + position: absolute; + height: 100%; + transition: width var(--_transition-duration) linear; + } + + %display-linear--indeterminate:dir(rtl) { + @if $variant != 'fluent' { + %base { + transform: rotateY(180deg); + } + } @else { + %indicator { + animation-name: igx-indeterminate-bar-fluent-rtl; + } + } + } + + %display-linear:not(%display-linear--indeterminate) { + %indicator { + animation: igx-initial-width var(--_transition-duration) linear; + background-color: var-get($theme, 'fill-color-default') + } + } + + %display-linear--indeterminate { + %value { + &::before { + display: none; + } + } + } + + %indicator__indeterminate, + %indicator__indeterminate-secondary { + @if $variant != 'fluent' { + transform-origin: top left; + width: 100% !important; + height: inherit; + position: absolute; + + &::after { + content: ''; + position: absolute; + top: 0; + inset-inline-start: 0; + width: inherit; + height: inherit; + backface-visibility: hidden; + background-color: var-get($theme, 'fill-color-default'); + } + } + } + + %indicator__indeterminate { + @if $variant != 'fluent' { + transform: scale3d(0, 1, 1); + animation: igx-indeterminate-primary var(--linear-animation-duration) infinite linear; + left: -145.1666%; + + &::after { + animation: igx-indeterminate-primary-scale var(--linear-animation-duration) infinite linear; + } + } @else { + width: 33% !important; + min-width: 33%; + animation-name: igx-indeterminate-bar-fluent; + animation-duration: var(--linear-animation-duration); + animation-timing-function: ease; + animation-iteration-count: infinite; + left: auto; + } + } + + %indicator__indeterminate-secondary { + animation: igx-indeterminate-secondary var(--linear-animation-duration) infinite linear; + left: -54.8888%; + + &::after { + animation: igx-indeterminate-secondary-scale var(--linear-animation-duration) infinite linear; + width: 100%; + height: inherit; + } + + @if $variant == 'fluent' { + display: none; + } + } + + @if $variant == 'fluent' { + %indicator__indeterminate { + background: linear-gradient(90deg, transparent 0%, var-get($theme, 'fill-color-default') 50%, transparent 100%) + } + } + + @each $mod in ('success','info','warning','danger') { + %display-linear--#{$mod}:not(%display-linear--indeterminate) { + %indicator, + %indicator__secondary { + background-color: var-get($theme, 'fill-color-#{$mod}') + } + } + + %display-linear--#{$mod} { + @if $variant != 'fluent' { + %indicator, + %indicator__secondary { + /* stylelint-disable max-nesting-depth */ + &::after { + background-color: var-get($theme, 'fill-color-#{$mod}'); + } + /* stylelint-enable max-nesting-depth */ + } + } @else { + %indicator { + background: linear-gradient(90deg, transparent 0%, var-get($theme, 'fill-color-#{$mod}') 50%, transparent 100%) + } + } + } + } + + %value { + color: var-get($theme, 'text-color'); + animation: initial-counter var(--_transition-duration) ease-in-out; + counter-reset: + progress-integer var(--_progress-integer, 0) + progress-fraction var(--_progress-fraction, 0); + transition: + --_progress-integer var(--_transition-duration) ease-in-out, + --_progress-fraction var(--_transition-duration) ease-in-out; + } + + %value--fraction { + &::before { + content: counter(progress-integer) '.' counter(progress-fraction, decimal-leading-zero) '%'; + } + } + + %value:not(%value--fraction) { + &::before { + content: counter(progress-integer) '%'; + } + } + + %value--start { + align-self: flex-start; + } + + %value--center { + align-self: center; + } + + %value--end { + align-self: flex-end; + } + + %value--top { + order: -1; + } + + %value--hidden { + display: none; + } + + %hide-counter { + &::before { + display: none; + } + } + + // Reset the transition if the animate prop is set to false. + %animation-none:not(%display-linear--indeterminate) { + --_transition-duration: 0s !important; + } + + // Primary animation + @keyframes igx-indeterminate-primary { + 0% { + transform: translateX(0); + } + + 20% { + animation-timing-function: get-easing('primary-translate-start'); + transform: translateX(0); + } + + 59.15% { + animation-timing-function: get-easing('primary-translate-mid'); + transform: translateX(83.671%); + } + + 100% { + transform: translateX(200.611%); + } + } + + @keyframes igx-indeterminate-primary-scale { + 0% { + transform: scaleX(0.08); + } + + 36.65% { + animation-timing-function: get-easing('primary-scale-slow-start'); + transform: scaleX(0.08); + } + + 69.15% { + animation-timing-function: get-easing('primary-scale-quick-end'); + transform: scaleX(0.6614); + } + + 100% { + transform: scaleX(0.08); + } + } + + // Secondary animation + @keyframes igx-indeterminate-secondary { + 0% { + animation-timing-function: get-easing('secondary-translate-start'); + transform: translateX(0); + } + + 25% { + animation-timing-function: get-easing('secondary-translate-mid'); + transform: translateX(37.6519%); + } + + 48.35% { + animation-timing-function: get-easing('secondary-translate-end'); + transform: translateX(84.3861%); + } + + 100% { + transform: translateX(160.2777%); + } + } + + @keyframes igx-indeterminate-secondary-scale { + 0% { + animation-timing-function: get-easing('secondary-scale-slow-start'); + transform: scaleX(0.08); + } + + 19.15% { + animation-timing-function: get-easing('secondary-scale-mid'); + transform: scaleX(0.4571); + } + + 44.15% { + animation-timing-function: get-easing('secondary-scale-smooth-end'); + transform: scaleX(0.727); + } + + 100% { + transform: scaleX(0.08); + } + } + + // Fluent linear animations + @keyframes igx-indeterminate-bar-fluent { + 0% { + transform: translateX(-100%); + transform-origin: left; + } + + 100% { + transform: translateX(310%); + transform-origin: right; + } + } + + @keyframes igx-indeterminate-bar-fluent-rtl { + 0% { + transform: translateX(100%); + transform-origin: right; + } + + 100% { + transform: translateX(-310%); + transform-origin: left; + } + } + + // Initial animations + @keyframes igx-initial-width { + from { + width: 0; + } + + to { + width: calc(var(--_progress-whole, 0) * 1%); + } + } +} + +/// Adds typography styles for the igx-linear-bar component. +/// Uses the 'subtitle-2' category from the typographic scale. +/// @group typography +/// @param {Map} $categories [(value: 'subtitle-2')] - The categories from the typographic scale used for type styles. +@mixin linear-bar-typography($categories: (value: 'subtitle-2')) { + $value: map.get($categories, 'value'); + + %value { + @include type-style($value) { + margin: 0; + } + } +} + diff --git a/projects/igniteui-angular/core/src/core/styles/components/query-builder/_query-builder-component.scss b/projects/igniteui-angular/core/src/core/styles/components/query-builder/_query-builder-component.scss new file mode 100644 index 00000000000..fce80b8f03d --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/query-builder/_query-builder-component.scss @@ -0,0 +1,166 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin component { + @include _advanced-filtering-tree(); + @include _query-builder-tree(); + @include _query-builder-dialog(); + + @include b(igx-query-builder) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: ( + igx-icon, + igx-button, + igx-chip, + igx-select, + igx-input-button, + igx-icon-button, + igx-overlay, + ) + ); + + @extend %advanced-filter !optional; + + @include e(header) { + @extend %advanced-filter__header !optional; + } + + @include e(title) { + @extend %advanced-filter__title !optional; + } + + @include e(label) { + @extend %advanced-filter__label !optional; + } + + @include e(main) { + @extend %advanced-filter__main !optional; + } + + @include e(root) { + @extend %advanced-filter__root !optional; + } + + @include e(root-actions) { + @extend %advanced-filter__root-actions !optional; + } + + @include e(outlet) { + @extend %advanced-filter__outlet !optional; + } + + @include m('inline') { + @extend %advanced-filter--inline !optional; + } + } +} + +@mixin _advanced-filtering-tree { + @include b(igx-filter-tree) { + @extend %filter-tree !optional; + + @include e(subquery) { + @extend %filter-tree__subquery !optional; + } + + @include e(section) { + @extend %filter-tree__section !optional; + } + + @include e(line) { + @extend %filter-tree__line !optional; + } + + @include e(line, $m: 'and') { + @extend %filter-tree__line--and !optional; + } + + @include e(line, $m: 'or') { + @extend %filter-tree__line--or !optional; + } + + @include e(button, $m: 'and') { + @extend %filter-tree__button--and !optional; + } + + @include e(button, $m: 'or') { + @extend %filter-tree__button--or !optional; + } + + @include e(expressions) { + @extend %filter-tree__expressions !optional; + } + + @include e(expression-context-menu) { + @extend %filter-tree__expression-context-menu !optional; + } + + @include e(expression-section) { + @extend %filter-tree__expression-section !optional; + } + + @include e(expression-item) { + @extend %filter-tree__expression-item !optional; + } + + @include e(expression-item-drop-ghost) { + @extend %filter-tree__expression-item-ghost !optional; + } + + @include e(expression-item-keyboard-ghost) { + @extend %filter-tree__expression-item-keyboard-ghost !optional; + } + + @include e(expression-column) { + @extend %filter-tree__expression-column !optional; + } + + @include e(expression-actions) { + @extend %filter-tree__expression-actions !optional; + } + + @include e(expression-condition) { + @extend %filter-tree__expression-condition !optional; + } + + @include e(buttons) { + @extend %filter-tree__buttons !optional; + } + + @include e(inputs) { + @extend %filter-tree__inputs !optional; + } + + @include e(inputs-field) { + @extend %filter-tree__inputs-field !optional; + } + + @include e(inputs-actions) { + @extend %filter-tree__inputs-actions !optional; + } + + @include e(details-button) { + @extend %filter-tree-details-button !optional;; + } + } +} + +@mixin _query-builder-tree { + @include b(igx-query-builder-tree) { + @extend %query-builder-tree !optional; + + @include m(level-0) { + @extend %query-level-0 !optional; + } + } +} + +@mixin _query-builder-dialog { + @include b(igx-query-builder-dialog) { + @extend %query-builder-dialog !optional;; + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/query-builder/_query-builder-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/query-builder/_query-builder-theme.scss new file mode 100644 index 00000000000..4ca1970f5ee --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/query-builder/_query-builder-theme.scss @@ -0,0 +1,581 @@ +@use 'sass:map'; +@use '../../base' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The grid theme used to style the component. +@mixin query-builder($theme) { + @include css-vars($theme); + + $variant: map.get($theme, '_meta', 'theme'); + $theme-variant: map.get($theme, '_meta', 'variant'); + $bootstrap-theme: $variant == 'bootstrap'; + $not-bootstrap-theme: not($bootstrap-theme); + + // Custom colors alpha + $alpha-hover: .08; + $alpha-focus: .16; + $alpha-focus-hover: .24; + + $border-radius: var-get($theme, 'border-radius'); + $icon-size: rem(18px); + + %advanced-filter { + @include sizable(); + + --_tree-scrollbar-gutter: #{rem(16px)}; + + @if $variant == 'bootstrap' { + --query-builder-outer-padding: #{rem(16px)}; + } @else { + --query-builder-outer-padding: #{rem(24px)}; + } + + width: auto; + min-width: rem(660px); + background-color: var-get($theme, 'background'); + border-radius: $border-radius; + box-shadow: var-get($theme, 'elevation'); + overflow: hidden; + + &:has(:not(igx-query-builder-header)) { + padding-block-start: var(--query-builder-outer-padding); + + %query-level-0 { + padding-block: 0 var(--query-builder-outer-padding); + } + } + + &:has(igx-query-builder-header) { + padding-block-start: 0; + + %query-level-0 { + padding-block: if($variant != 'bootstrap', 0, rem(16px)) var(--query-builder-outer-padding); + } + } + + .igx-chip__ghost { + position: relative; + } + } + + %query-builder-tree { + background: var-get($theme, 'background'); + + %query-builder-tree { + border-radius: var-get($theme, 'subquery-border-radius'); + } + } + + %query-level-0 { + display: block; + width: 100%; + + padding-inline: var(--query-builder-outer-padding); + + > %advanced-filter__main { + gap: rem(16px); + + > %filter-tree__section { + --sb-size: #{rem(10px)}; + + max-height: rem(570px); + overflow-y: auto; + overflow-x: hidden; + padding-inline-end: var(--_tree-scrollbar-gutter); + } + } + } + + %filter-tree__subquery { + max-width: rem(960px); + + // Add styles to the subquery itself only if there is a direct .igx-filter-tree__inputs inside it + &:has( > %filter-tree__inputs) { + background: var-get($theme, 'subquery-header-background'); + border: rem(1px) solid var-get($theme, 'subquery-border-color'); + border-radius: var-get($theme, 'subquery-border-radius'); + } + + // Hide the subquery itself if there is a tree with display none inside + &:has( > %query-builder-tree[style='display: none;']) { + display: none; + } + + > %filter-tree__inputs { + padding: rem(12px); + border-radius: inherit; + + > igx-input-group, + > igx-date-picker, + > igx-time-picker { + flex-grow: 1; + } + } + + %filter-tree__buttons { + margin-block-start: rem(8px); + } + + %query-builder-tree { + border-block-start: rem(1px) dashed var-get($theme, 'separator-color'); + padding: rem(12px); + } + + &:empty { + display: none; + } + + } + + %filter-tree-details-button { + margin-inline-start: auto; + } + + %query-builder-dialog { + display: flex; + flex-direction: column; + gap: rem(16px); + max-width: rem(310px); + + > * { + margin: 0 !important; + } + } + + %advanced-filter__header { + display: flex; + align-items: center; + background-color: var-get($theme, 'header-background'); + color: var-get($theme, 'header-foreground'); + user-select: none; + border-radius: $border-radius $border-radius 0 0; + margin-bottom: 0; + border-block-end: rem(1px) solid var-get($theme, 'header-border'); + + padding-inline: var(--query-builder-outer-padding); + padding-block: var(--query-builder-outer-padding) rem(16px); + } + + %advanced-filter__title { + &:empty { + display: none; + } + } + + %advanced-filter__main { + display: grid; + gap: rem(16px); + } + + %advanced-filter__root { + display: flex; + flex-direction: column; + flex-grow: 1; + + > * { + flex-grow: 1; + } + } + + %advanced-filter__root-actions { + display: flex; + gap: rem(8px); + padding-bottom: rem(16px); + } + + %advanced-filter__outlet { + igx-select-item > igx-icon { + width: var(--igx-icon-size, #{$icon-size}); + height: var(--igx-icon-size, #{$icon-size}); + font-size: var(--igx-icon-size, #{$icon-size}); + margin-inline-end: rem(8px); + } + } + + %filter-tree { + display: flex; + width: 100%; + + %filter-tree { + margin-block: rem(8px); + } + } + + %filter-tree__section { + display: flex; + flex-direction: column; + gap: rem(8px); + + > %filter-tree { + margin-block: 0; + } + } + + %filter-tree__expression-context-menu { + display: flex; + width: 100%; + + [igxbutton='flat'] { + --ig-size: 1; + + @if $variant == 'bootstrap' { + --ig-button-text-transform: capitalize; + } + + border: none; + min-width: auto; + } + } + + %filter-tree__line { + $size: rem(2px); + width: $size; + background-color: white; + margin-inline-end: calc(rem(8px) - $size); + outline-style: none; + } + + %filter-tree__line--and { + background: var-get($theme, 'color-expression-group-and'); + } + + %filter-tree__line--or { + background: var-get($theme, 'color-expression-group-or'); + } + + %filter-tree__button--and { + &[igxButton='flat'] { + @if $variant == 'material' or $variant == 'indigo' { + --focus-hover-background: hsl(from #{var-get($theme, 'color-expression-group-and')} h s l / #{$alpha-focus-hover}); + --focus-visible-background: hsl(from #{var-get($theme, 'color-expression-group-and')} h s l / #{$alpha-focus}); + --focus-background: hsl(from #{var-get($theme, 'color-expression-group-and')} h s l / #{$alpha-focus}); + --active-background: hsl(from #{var-get($theme, 'color-expression-group-and')} h s l / #{$alpha-focus}); + --hover-background: hsl(from #{var-get($theme, 'color-expression-group-and')} h s l / #{$alpha-hover}); + --background: transparent; + } + + @if $variant == 'fluent' { + --background: transparent; + --focus-hover-background: #{color($color: 'gray', $variant: if($theme-variant == 'light', 200, 50))}; + --focus-visible-background: transparent; + --focus-background: transparent; + --active-background: #{color($color: 'gray', $variant: if($theme-variant == 'light', 200, 50))}; + --hover-background: #{color($color: 'gray', $variant: if($theme-variant == 'light', 200, 50))}; + } + + --foreground: #{var-get($theme, 'color-expression-group-and')}; + --focus-visible-foreground: #{var-get($theme, 'color-expression-group-and')}; + --icon-color: #{var-get($theme, 'color-expression-group-and')}; + + @if $variant != 'bootstrap' { + --focus-foreground: #{var-get($theme, 'color-expression-group-and')}; + --hover-foreground: #{var-get($theme, 'color-expression-group-and')}; + --icon-color-hover: #{var-get($theme, 'color-expression-group-and')}; + --focus-hover-foreground: #{var-get($theme, 'color-expression-group-and')}; + --active-foreground: #{var-get($theme, 'color-expression-group-and')}; + } @else { + $bootstrap-foreground-lightness: if($theme-variant == 'light', 34%, 78% ); + + --focus-hover-background: transparent; + --focus-visible-background: transparent; + --focus-background: transparent; + --active-background: transparent; + --hover-background: transparent; + --background: transparent; + --shadow-color: hsl(from #{var-get($theme, 'color-expression-group-and')} h s l / .5); + + --icon-color-hover: hsl(from #{var-get($theme, 'color-expression-group-and')} h s #{$bootstrap-foreground-lightness} / 1); + --focus-foreground: hsl(from #{var-get($theme, 'color-expression-group-and')} h s #{$bootstrap-foreground-lightness} / 1); + --hover-foreground: hsl(from #{var-get($theme, 'color-expression-group-and')} h s #{$bootstrap-foreground-lightness} / 1); + --focus-hover-foreground: hsl(from #{var-get($theme, 'color-expression-group-and')} h s #{$bootstrap-foreground-lightness} / 1); + --active-foreground: hsl(from #{var-get($theme, 'color-expression-group-and')} h s #{$bootstrap-foreground-lightness} / 1); + } + } + } + + %filter-tree__button--or { + &[igxButton='flat'] { + @if $variant == 'material' or $variant == 'indigo' { + --focus-hover-background: hsl(from #{var-get($theme, 'color-expression-group-or')} h s l / #{$alpha-focus-hover}); + --focus-visible-background: hsl(from #{var-get($theme, 'color-expression-group-or')} h s l / #{$alpha-focus}); + --focus-background: hsl(from #{var-get($theme, 'color-expression-group-or')} h s l / #{$alpha-focus}); + --active-background: hsl(from #{var-get($theme, 'color-expression-group-or')} h s l / #{$alpha-focus}); + --hover-background: hsl(from #{var-get($theme, 'color-expression-group-or')} h s l / #{$alpha-hover}); + --background: transparent; + } + + @if $variant == 'fluent' { + --background: transparent; + --focus-hover-background: #{color($color: 'gray', $variant: if($theme-variant == 'light', 200, 50))}; + --focus-visible-background: transparent; + --focus-background: transparent; + --active-background: #{color($color: 'gray', $variant: if($theme-variant == 'light', 200, 50))}; + --hover-background: #{color($color: 'gray', $variant: if($theme-variant == 'light', 200, 50))}; + } + + --foreground: #{var-get($theme, 'color-expression-group-or')}; + --focus-visible-foreground: #{var-get($theme, 'color-expression-group-or')}; + --icon-color: #{var-get($theme, 'color-expression-group-or')}; + + @if $variant != 'bootstrap' { + --focus-foreground: #{var-get($theme, 'color-expression-group-or')}; + --hover-foreground: #{var-get($theme, 'color-expression-group-or')}; + --icon-color-hover: #{var-get($theme, 'color-expression-group-or')}; + --focus-hover-foreground: #{var-get($theme, 'color-expression-group-or')}; + --active-foreground: #{var-get($theme, 'color-expression-group-or')}; + } @else { + $bootstrap-foreground-lightness: if($theme-variant == 'light', 14%, 47%); + + --focus-hover-background: transparent; + --focus-visible-background: transparent; + --focus-background: transparent; + --active-background: transparent; + --hover-background: transparent; + --background: transparent; + --shadow-color: hsl(from #{var-get($theme, 'color-expression-group-or')} h s l / .5); + + --icon-color-hover: hsl(from #{var-get($theme, 'color-expression-group-or')} h s #{$bootstrap-foreground-lightness} / 1); + --focus-foreground: hsl(from #{var-get($theme, 'color-expression-group-or')} h s #{$bootstrap-foreground-lightness} / 1); + --hover-foreground: hsl(from #{var-get($theme, 'color-expression-group-or')} h s #{$bootstrap-foreground-lightness} / 1); + --focus-hover-foreground: hsl(from #{var-get($theme, 'color-expression-group-or')} h s #{$bootstrap-foreground-lightness} / 1); + --active-foreground: hsl(from #{var-get($theme, 'color-expression-group-or')} h s #{$bootstrap-foreground-lightness} / 1); + } + } + } + + %filter-tree__expressions { + display: flex; + flex-direction: column; + align-items: flex-start; + flex-grow: 1; + gap: rem(8px); + } + + %filter-tree__expression-section { + $spacing: rem(16px); + display: flex; + flex-direction: column; + width: calc(100% - #{$spacing}); + gap: rem(8px); + margin-inline-start: $spacing; + + &:empty { + display: none; + } + } + + %filter-tree__expression-item { + display: flex; + align-items: center; + width: 100%; + gap: rem(8px); + position: relative; + + igx-chip { + --ig-size: 3; + + @if $variant != 'indigo' { + igx-icon { + --component-size: 1; + } + } + } + + > igx-chip { + %filter-tree__expression-column { + padding-inline: pad-inline(rem(3px), rem(6px), rem(8px)); + } + + %filter-tree__expression-condition { + padding-inline-start: pad-inline(rem(3px), rem(6px), rem(8px)); + } + + igx-prefix { + display: flex; + } + + .igx-chip__end { + gap: sizable(rem(3px), rem(6px), rem(8px)); + } + } + } + + + %filter-tree__expression-item-ghost { + .igx-chip__item { + @include type-style('body-2'); + + --ig-body-2-text-transform: unset; + + padding-inline: rem(32px); + + color: color($color: 'gray', $variant: if($theme-variant == 'light', 600, 900)); + border: rem(1px) dashed color($color: 'gray', $variant: if($theme-variant == 'light', 600, 300)); + background: transparent; + } + } + + %filter-tree__expression-item-keyboard-ghost { + .igx-chip__item { + box-shadow: var(--ghost-shadow); + background: var(--ghost-background); + color: var(--focus-text-color); + } + + .igx-chip:hover { + .igx-chip__item { + box-shadow: var(--ghost-shadow); + background: var(--ghost-background); + color: var(--focus-text-color); + + @if $variant == 'indigo' { + border-color: var(--border-color); + } + } + } + } + + %filter-tree__expression-column { + padding: 0 rem(8px); + } + + %filter-tree__expression-actions { + display: inline-flex; + gap: if($variant != 'indigo', rem(16px), rem(8px)); + + span { + display: inline-flex; + } + + %igx-icon-button-display { + --ig-size: #{if($variant != 'bootstrap', 2, 1)}; + }; + } + + %filter-tree__expression-condition { + opacity: if($variant != 'indigo', .6, .8); + } + + %filter-tree__buttons { + --ig-size: 1; + + display: flex; + align-items: center; + gap: rem(8px); + + [igxbutton='flat'] { + padding-block: 0; + border: none + } + } + + %filter-tree__inputs { + --ig-size: #{if($variant == 'indigo' or $variant == 'bootstrap', 2, 1)}; + + display: flex; + align-items: flex-end; + gap: rem(16px); + width: 100%; + border-radius: inherit; + + &:empty { + display: none; + } + } + + %filter-tree__inputs-field { + display: flex; + flex-direction: column; + gap: rem(4px); + max-width: rem(250px); + width: 100%; + } + + %advanced-filter__label { + @include type-style('body-2'); + + color: var-get($theme, 'label-foreground'); + } + + %filter-tree__inputs-actions { + --ig-size: 2; + + display: flex; + gap: rem(8px); + align-items: center; + width: auto; + align-self: center; + + [igxIconButton] { + transition: none; + } + } + + %filter-con-menu__delete-btn { + color: color(null, 'error'); + @if $bootstrap-theme { + border-color: color(null, 'error'); + } + + &:hover, + &:focus { + @if $bootstrap-theme { + background: color(null, 'error'); + border-color: color(null, 'error'); + color: color(null, 'gray', 100); + } @else { + color: color(null, 'error'); + } + } + } + + %filter-con-menu__close-btn { + position: absolute; + top: 0; + inset-inline-start: 100%; + transform: translate(-50%, -50%); + background-color: var-get($theme, 'background'); + border: rem(1px) solid color(null, 'gray', 200); + + &:hover, + &:focus { + @if $not-bootstrap-theme { + background-color: var-get($theme, 'background'); + } + } + } + + %advanced-filter--inline { + display: flex; + flex-direction: column; + width: 100%; + max-width: 100%; + height: inherit; + max-height: inherit; + min-width: rem(480px); + box-shadow: none; + + %advanced-filter__main { + min-height: initial; + max-height: initial; + flex-grow: 1; + } + } +} + +/// Adds typography styles for the igx-query-builder component. +/// Uses the 'h6' category from the typographic scale. +/// @group typography +/// @param {Map} $categories [(title: 'h6')] - The categories from the typographic scale used for type styles. +@mixin query-builder-typography( + $categories: (title: 'h6') +) { + $title: map.get($categories, 'title'); + + %advanced-filter__title { + @include type-style($title) { + margin: 0; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/radio/_radio-component.scss b/projects/igniteui-angular/core/src/core/styles/components/radio/_radio-component.scss new file mode 100644 index 00000000000..0f3f7aa848d --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/radio/_radio-component.scss @@ -0,0 +1,237 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin component { + @include b(igx-radio) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: () + ); + + @extend %radio-display !optional; + + &:hover { + @include e(composite) { + @extend %igx-radio-hover__composite !optional; + } + + @include e(ripple) { + @extend %radio-ripple--hover !optional; + } + + @include e(label) { + @extend %radio-label--hover !optional; + } + } + + &:active { + @include e(composite) { + @extend %igx-radio-hover__composite !optional; + } + + @include e(ripple) { + @extend %radio-ripple--hover !optional; + @extend %radio-ripple--pressed !optional; + } + } + + @include e(input) { + @extend %radio-input !optional; + } + + @include e(composite) { + @extend %radio-composite !optional; + } + + @include e(label) { + @extend %radio-label !optional; + @extend %radio-label--after !optional; + } + + @include e(label, $m: before) { + @extend %radio-label !optional; + @extend %radio-label--before !optional; + } + + @include e(ripple) { + @extend %radio-ripple !optional; + } + + @include m(focused) { + @extend %igx-radio--focused !optional; + + @include e(ripple) { + @extend %radio-ripple--focused !optional; + } + + &:hover { + @extend %igx-checkbox--focused-hovered !optional; + + @include e(ripple) { + @extend %radio-ripple--focused !optional; + } + } + } + + @include m(checked) { + @include e(composite) { + @extend %radio-composite--x !optional; + } + + &:hover { + @include e(composite) { + @extend %igx-radio--checked-active__composite !optional; + } + + @include e(ripple) { + @extend %radio-ripple--hover !optional; + @extend %radio-ripple--hover-checked !optional; + } + } + + &:active { + @include e(composite) { + @extend %igx-radio--checked-active__composite !optional; + } + + @include e(ripple) { + @extend %radio-ripple--hover !optional; + @extend %radio-ripple--hover-checked !optional; + @extend %radio-ripple--pressed !optional; + } + } + } + + @include m(disabled) { + @extend %radio-display--disabled !optional; + + @include e(composite) { + @extend %radio-composite--disabled !optional; + } + + @include e(label) { + @extend %radio-label--disabled !optional; + } + + @include e(label, $m: before) { + @extend %radio-label--disabled !optional; + } + } + + @include m(invalid) { + @extend %radio-display--invalid !optional; + + @include e(composite) { + @extend %radio-composite--invalid !optional; + } + + @include e(label) { + @extend %radio-label--invalid !optional; + } + + &:hover { + @include e(label) { + @extend %radio-label--invalid--hover !optional; + } + + @include e(ripple) { + @extend %radio-ripple--hover !optional; + @extend %radio-ripple--hover-invalid !optional; + } + + @include e(composite) { + @extend %igx-radio-hover__composite--invalid !optional; + } + } + + &:active { + @include e(composite) { + @extend %igx-radio-hover__composite--invalid !optional; + } + + @include e(ripple) { + @extend %radio-ripple--hover !optional; + @extend %radio-ripple--hover-invalid !optional; + @extend %radio-ripple--pressed !optional; + } + } + } + + @include mx(focused, invalid) { + @extend %igx-radio--focused--invalid !optional; + + @include e(ripple) { + @extend %radio-ripple--focused !optional; + @extend %radio-ripple--focused-invalid !optional; + } + } + + @include mx(focused, checked) { + @extend %igx-radio--focused-checked !optional; + + &:hover { + @include e(composite) { + @extend %igx-radio--checked-active__composite !optional; + } + } + + &:active { + @include e(composite) { + @extend %igx-radio--checked-active__composite !optional; + } + } + + @include e(ripple) { + @extend %radio-ripple--focused !optional; + @extend %radio-ripple--focused-checked !optional; + } + } + + @include mx(checked, disabled) { + @include e(composite) { + @extend %radio-composite--x--disabled !optional; + } + } + + @include mx(checked, invalid) { + @include e(composite) { + @extend %radio-composite--x--invalid !optional; + } + + &:hover { + @include e(composite) { + @extend %radio-composite--x--invalid--hover !optional; + } + } + + &:active { + @include e(composite) { + @extend %radio-composite--x--invalid--hover !optional; + } + } + } + + @include mx(checked, invalid, focused) { + @extend %igx-radio--focused--invalid--checked !optional; + } + } + + @include b(igx-radio-group) { + @extend %radio-group-display !optional; + + @include m(vertical) { + @extend %radio-group-display--vertical !optional; + } + + @include m(before) { + @extend %radio-group-display--before !optional; + } + + @include m(disabled) { + @extend %radio-group-display--disabled !optional; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/radio/_radio-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/radio/_radio-theme.scss new file mode 100644 index 00000000000..87dc986ffd8 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/radio/_radio-theme.scss @@ -0,0 +1,558 @@ +@use 'sass:map'; +@use 'sass:math'; +@use '../../base' as *; +@use 'igniteui-theming/sass/animations' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin radio($theme) { + @include css-vars($theme); + @include scale-in-out($start-scale: .9); + + $theme-variant: map.get($theme, '_meta', 'variant'); + $variant: map.get($theme, '_meta', 'theme'); + $material-theme: $variant == 'material'; + $bootstrap-theme: $variant == 'bootstrap'; + $not-bootstrap-theme: $variant != 'bootstrap'; + + $label-margin: map.get(( + 'material': rem(2px), + 'fluent': rem(8px), + 'bootstrap': rem(8px), + 'indigo': rem(8px), + ), $variant); + + $size: map.get(( + 'material': rem(40px), + 'fluent': rem(20px), + 'bootstrap': rem(16px), + 'indigo': rem(16px), + ), $variant); + + $scale: map.get(( + 'material': scale(.5), + 'fluent': scale(.5), + 'bootstrap': scale(.4375), + 'indigo': scale(.5), + ), $variant); + + $border-width: map.get(( + 'material': rem(2px), + 'fluent': rem(1px), + 'bootstrap': rem(1px), + 'indigo': rem(2px), + ), $variant); + + $radio-hover-scale: map.get(( + 'material': null, + 'fluent': scale(.5), + ), $variant); + + $ripple-display: map.get(( + 'material': block, + 'bootstrap': none, + 'fluent': none, + 'indigo': none, + ), $variant); + + $horizontal-group-margin: map.get(( + 'material': rem(16px), + 'fluent': rem(12px), + 'bootstrap': rem(8px), + 'indigo': rem(16px), + ), $variant); + + $vertical-group-margin: map.get(( + 'material': 0, + 'fluent': rem(8px), + 'bootstrap': rem(8px), + 'indigo': rem(8px), + ), $variant); + + $border-style: solid; + $border-radius: border-radius(50%); + + $transition: all .2s ease-in; + + $ripple-size: rem(40px); + $ripple-radius: math.div($ripple-size, 2); + + %radio-display { + position: relative; + display: inline-flex; + flex-flow: row nowrap; + align-items: center; + color: var-get($theme, 'label-color'); + width: max-content; + cursor: pointer; + } + + %radio-input { + @include hide-default(); + } + + %radio-display--disabled { + pointer-events: none; + color: var-get($theme, 'disabled-label-color'); + user-select: none; + } + + %radio-composite { + position: relative; + display: inline-block; + width: $size; + height: $size; + min-width: $size; + line-height: $size; + color: var-get($theme, 'label-color'); + user-select: none; + + //ripple color + --color: #{var-get($theme, 'empty-color')}; + + &::before, + &::after { + position: absolute; + content: ''; + width: $size; + height: $size; + inset-inline-start: 0; + top: 0; + border-radius: $border-radius; + } + + @if $material-theme { + &::before, + &::after { + width: math.div($size, 2); + height: math.div($size, 2); + top: 25%; + inset-inline-start: 25%; + } + } + + &::before { + backface-visibility: hidden; + transform: scale(0); + + @if $not-bootstrap-theme { + transition: $transition; + } + + z-index: 1; + } + + &::after { + border: $border-width $border-style var-get($theme, 'empty-color'); + background: var-get($theme, 'empty-fill-color'); + } + } + + %radio-composite--x { + //ripple color + --color: #{var-get($theme, 'fill-color')}; + + &::before { + border: $border-width $border-style var-get($theme, 'fill-color'); + background: var-get($theme, 'fill-color'); + transform: $scale; + + @if $bootstrap-theme { + border-color: var-get($theme, 'fill-hover-border-color'); + background: var-get($theme, 'fill-hover-border-color'); + } + } + + &::after { + border: $border-width $border-style var-get($theme, 'fill-color'); + + @if $bootstrap-theme{ + background: var-get($theme, 'fill-color'); + } + } + } + + %igx-radio-hover__composite { + @if $variant != 'bootstrap' { + &::before { + background: var-get($theme, 'hover-color'); + transform: $radio-hover-scale; + } + } + + @if $variant == 'indigo' or $variant == 'bootstrap' { + &::after { + border: $border-width $border-style var-get($theme, 'hover-color'); + transition: $transition; + } + } + } + + %igx-radio-hover__composite--invalid { + &::before { + background: var-get($theme, 'error-color'); + } + + @if $variant != 'material' { + &::after { + border-color: var-get($theme, 'error-color-hover'); + } + } + + @if $variant == 'fluent' and $theme-variant == 'dark' { + &::before { + background: color($color: 'error', $variant: 500); + } + } + } + + %igx-radio--checked-active__composite { + @if $bootstrap-theme { + &::after { + background: var-get($theme, 'fill-color-hover'); + border-color: var-get($theme, 'fill-color-hover'); + } + } @else { + &::before { + background: var-get($theme, 'fill-color-hover'); + border-color: var-get($theme, 'fill-hover-border-color'); + } + + &::after { + border-color: var-get($theme, 'fill-hover-border-color'); + } + } + } + + %radio-composite--invalid { + //ripple color + --color: #{var-get($theme, 'error-color')}; + + &::after { + border: $border-width $border-style var-get($theme, 'error-color'); + } + } + + %radio-composite--x--invalid { + &::after { + border: $border-width $border-style var-get($theme, 'error-color'); + } + + &::before { + background: var-get($theme, 'error-color'); + border: $border-width $border-style transparent; + } + + @if $bootstrap-theme { + &::after { + background: var-get($theme, 'error-color'); + } + + &::before { + background: var-get($theme, 'fill-hover-border-color'); + } + } + } + + %radio-composite--disabled { + &::after { + border: $border-width $border-style var-get($theme, 'disabled-color'); + + @if $bootstrap-theme and $theme-variant == 'dark' { + background: color($color: 'surface'); + } + } + } + + %radio-composite--x--disabled { + &::after { + border: $border-width $border-style var-get($theme, 'disabled-fill-color'); + } + + @if $variant != 'bootstrap' { + &::before { + background: var-get($theme, 'disabled-fill-color'); + border: $border-width $border-style transparent; + } + } + + @if $bootstrap-theme { + &::after { + background: var-get($theme, 'disabled-fill-color'); + } + } + } + + %radio-label { + color: var-get($theme, 'label-color'); + user-select: none; + word-wrap: break-all; + + &:empty { + display: none; + } + } + + %radio-label--hover { + color: var-get($theme, 'label-color-hover'); + } + + %radio-label--invalid { + color: var-get($theme, 'error-color'); + + @if $variant == 'indigo' { + color: var-get($theme, 'label-color'); + } + } + + %radio-label--invalid--hover { + color: var-get($theme, 'error-color'); + + @if $variant == 'indigo' { + color: var-get($theme, 'label-color-hover'); + } + } + + %radio-label--disabled { + color: var-get($theme, 'disabled-label-color'); + } + + %radio-label--after { + margin-inline-start: $label-margin; + } + + %radio-label--before { + order: -1; + margin-inline-end: $label-margin; + } + + %radio-label--before, + %radio-label--after { + &:empty { + margin: 0; + } + } + + %radio-ripple { + display: $ripple-display; + position: absolute; + top: calc(50% - #{$ripple-radius}); + inset-inline-start: calc(50% - #{$ripple-radius}); + width: $ripple-size; + height: $ripple-size; + border-radius: border-radius(math.div($ripple-size, 2)); + overflow: hidden; + pointer-events: none; + filter: opacity(1); + } + + %igx-radio--focused { + @if $variant == 'fluent' { + position: relative; + $focus-outline-offset: rem(2px); + + &::after { + content: ''; + position: absolute; + inset: -$focus-outline-offset; + box-shadow: 0 0 0 rem(1px) var-get($theme, 'focus-outline-color'); + } + } + + @if $variant == 'bootstrap' { + %radio-composite { + border-radius: $border-radius; + box-shadow: 0 0 0 rem(4px) var-get($theme, 'focus-outline-color'); + + &::after { + border-color: var-get($theme, 'focus-border-color'); + } + } + } + + @if $variant == 'indigo' { + %radio-composite { + border-radius: $border-radius; + box-shadow: 0 0 0 rem(3px) var-get($theme, 'focus-outline-color'); + } + } + } + + %igx-checkbox--focused-hovered { + @if $variant == 'bootstrap' { + %radio-composite:after { + border-color: hsl(from var-get($theme, 'focus-border-color') h calc(s * 1.12) calc(l * 0.82)); + } + } + } + + %igx-radio--focused-checked { + @if $variant == 'bootstrap' { + %radio-composite::after { + border-color: transparent; + } + } + + @if $variant == 'indigo' { + %radio-composite { + box-shadow: 0 0 0 rem(3px) var-get($theme, 'focus-outline-color-filled'); + } + } + } + + %igx-radio--focused--invalid { + @if $variant == 'bootstrap' { + %radio-composite { + box-shadow: 0 0 0 rem(4px) var-get($theme, 'focus-outline-color-error'); + + &::after { + border: $border-width $border-style var-get($theme, 'error-color'); + } + } + + &:hover { + %radio-composite::after { + border: $border-width $border-style var-get($theme, 'error-color-hover'); + } + } + } @else if $variant == 'indigo' { + %radio-composite { + box-shadow: 0 0 0 rem(3px) var-get($theme, 'focus-outline-color-error'); + } + } + } + + %igx-radio--focused--invalid--checked { + %radio-composite { + &::after { + border: $border-width $border-style var-get($theme, 'error-color'); + } + + @if $variant != 'bootstrap' { + &::before { + background: var-get($theme, 'error-color'); + border-color: var-get($theme, 'error-color'); + } + } + } + } + + %radio-composite--x--invalid--hover { + @if $bootstrap-theme { + &::after { + background: var-get($theme, 'error-color-hover'); + border-color: var-get($theme, 'error-color-hover'); + } + } @else { + &::before { + background: var-get($theme, 'error-color-hover'); + border-color: var-get($theme, 'error-color-hover'); + } + + &::after { + border: $border-width $border-style var-get($theme, 'error-color-hover'); + } + } + } + + %radio-ripple--hover { + background: var-get($theme, 'empty-color'); + transition: background .2s $ease-out-quad; + opacity: .06; + + @if $theme-variant == 'dark' { + opacity: .12; + } + } + + %radio-ripple--hover-checked { + background: var-get($theme, 'fill-color'); + } + + %radio-ripple--hover-invalid { + background: var-get($theme, 'error-color'); + } + + %radio-ripple--focused { + background: var-get($theme, 'empty-color'); + transition: background .2s $ease-out-quad; + opacity: .12; + + @if $theme-variant == 'dark' { + opacity: .24; + } + } + + %radio-ripple--focused-checked { + background: var-get($theme, 'fill-color'); + } + + %radio-ripple--focused-invalid { + background: var-get($theme, 'error-color'); + } + + %radio-ripple--pressed { + opacity: .12; + + @if $theme-variant == 'dark' { + opacity: .24; + } + } + + %radio-group-display { + display: grid; + column-gap: $horizontal-group-margin; + align-items: start; + width: max-content; + } + + %radio-group-display:not(%radio-group-display--vertical) { + [igxLabel] { + margin-block-end: rem(16px); + } + + igx-radio { + grid-row: 2; + } + } + + %radio-group-display--vertical { + row-gap: $vertical-group-margin; + + [igxLabel] { + margin-block-end: if($variant == 'material', rem(16px), rem(8px)); + } + + &%radio-group-display--before { + igx-radio, + [igxLabel] { + justify-self: flex-end; + } + } + } + + %radio-group-display--disabled { + [igxLabel] { + color: var-get($theme, 'disabled-color'); + } + } +} + +/// Adds typography styles for the igx-radio component. +/// Uses the 'subtitle-1' category from the typographic scale. +/// @group typography +/// @param {Map} $categories [(label: 'subtitle-1')] - The categories from the typographic scale used for type styles. +@mixin radio-typography( + $categories: (label: 'subtitle-1') + +) { + $label: map.get($categories, 'label'); + + %radio-label { + @include type-style($label) { + margin-top: 0; + margin-bottom: 0; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/rating/_rating-component.scss b/projects/igniteui-angular/core/src/core/styles/components/rating/_rating-component.scss new file mode 100644 index 00000000000..f8edbc5f252 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/rating/_rating-component.scss @@ -0,0 +1,13 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Marin Popov +@mixin component() { + igc-rating { + // Register the component in the component registry + @include register-component( + $name: 'igc-rating', + ); + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/rating/_rating-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/rating/_rating-theme.scss new file mode 100644 index 00000000000..3246ed17ecf --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/rating/_rating-theme.scss @@ -0,0 +1,29 @@ +@use 'sass:map'; +@use '../../base' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin rating($theme) { + @include css-vars($theme, 'igc-rating'); + + igc-rating::part(label) { + color: var-get($theme, 'label-color'); + } + + igc-rating::part(value-label) { + color: var-get($theme, 'value-label'); + } + + igc-rating[disabled]::part(label), + igc-rating[disabled]::part(value-label) { + color: var-get($theme, 'disabled-label-color'); + } + + igc-rating[disabled] { + --symbol-empty-color: #{var-get($theme, 'disabled-empty-symbol-color')}; + --symbol-full-color: #{var-get($theme, 'disabled-full-symbol-color')}; + --disabled-symbol-empty-filter: #{var-get($theme, 'symbol-empty-filter')}; + --disabled-symbol-full-filter: #{var-get($theme, 'symbol-full-filter')}; + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/ripple/_ripple-component.scss b/projects/igniteui-angular/core/src/core/styles/components/ripple/_ripple-component.scss new file mode 100644 index 00000000000..d8f055d340f --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/ripple/_ripple-component.scss @@ -0,0 +1,20 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin component { + @include b(igx-ripple) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: () + ); + + @extend %igx-ripple-wrapper !optional; + + @include e(inner) { + @extend %igx-ripple-display !optional; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/ripple/_ripple-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/ripple/_ripple-theme.scss new file mode 100644 index 00000000000..6714a83f05c --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/ripple/_ripple-theme.scss @@ -0,0 +1,27 @@ +@use 'sass:map'; +@use '../../base' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin ripple($theme) { + @include css-vars($theme, '[igxRipple]'); + + %igx-ripple-display { + display: block; + position: absolute; + border-radius: border-radius(50%); + background: var-get($theme, 'color'); + pointer-events: none; + transform-origin: center; + transform: translate3d(0, 0, 0) scale(0); + will-change: opacity, transform; + opacity: .5; + margin: 0 !important; + border: none !important; + } + + %igx-ripple-wrapper { + overflow: hidden; + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/scrollbar/scrollbar-component.scss b/projects/igniteui-angular/core/src/core/styles/components/scrollbar/scrollbar-component.scss new file mode 100644 index 00000000000..cea489f7238 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/scrollbar/scrollbar-component.scss @@ -0,0 +1,17 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin component { + @include b(ig-scrollbar) { + // Register the component in the component registry + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: () + ); + + @extend %scrollbar-display !optional; + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/scrollbar/scrollbar-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/scrollbar/scrollbar-theme.scss new file mode 100644 index 00000000000..653f3a5e829 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/scrollbar/scrollbar-theme.scss @@ -0,0 +1,63 @@ +@use 'sass:map'; +@use 'sass:meta'; +@use '../../base' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin scrollbar($theme) { + @include css-vars($theme, '.ig-scrollbar'); + + %scrollbar-display { + // The @-moz-document rule is specifically for Firefox because it does not support the WebKit pseudo-selectors for scrollbar styling. + /* stylelint-disable-next-line at-rule-no-vendor-prefix */ + @-moz-document url-prefix() { + scrollbar-width: var-get($theme, 'sb-size'); + scrollbar-color: var-get($theme, 'sb-thumb-bg-color') var-get($theme, 'sb-track-bg-color'); + } + + ::-webkit-scrollbar { + @if meta.type-of(map.get($theme, 'sb-size') == 'string') { + width: var-get($theme, 'sb-size'); + height: var-get($theme, 'sb-size'); + } + } + + ::-webkit-scrollbar-track { + background: var-get($theme, 'sb-track-bg-color'); + } + + ::-webkit-scrollbar-track:hover, + ::-webkit-scrollbar-track:active { + background: var-get($theme, 'sb-track-bg-color-hover'); + } + + ::-webkit-scrollbar-thumb { + min-height: var-get($theme, 'sb-thumb-min-height'); + border-radius: var-get($theme, 'sb-thumb-border-radius'); + border: var-get($theme, 'sb-thumb-border-size') solid var-get($theme, 'sb-thumb-border-color'); + background-clip: content-box; + background-color: var-get($theme, 'sb-thumb-bg-color'); + } + + ::-webkit-scrollbar-thumb:hover { + background-color: var-get($theme, 'sb-thumb-bg-color-hover'); + } + + ::-webkit-scrollbar-corner { + background: var-get($theme, 'sb-corner-bg'); + border: var-get($theme, 'sb-corner-border-size') solid var-get($theme, 'sb-corner-border-color'); + } + + ::-webkit-scrollbar-track-piece { + border: var-get($theme, 'sb-track-border-size') solid var-get($theme, 'sb-track-border-color'); + } + } + + @media (hover: none) { + %scrollbar-display ::-webkit-scrollbar { + width: auto; + height: auto; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/select/_select-component.scss b/projects/igniteui-angular/core/src/core/styles/components/select/_select-component.scss new file mode 100644 index 00000000000..30ddcf9c097 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/select/_select-component.scss @@ -0,0 +1,24 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +@mixin component { + @include b(igx-select) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: ( + igx-checkbox, + igx-drop-down, + igx-input-group, + igx-icon + ) + ); + + @extend %igx-select !optional; + + @include e(toggle-button) { + @extend %igx-select__toggle-button !optional; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/select/_select-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/select/_select-theme.scss new file mode 100644 index 00000000000..55092b0b220 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/select/_select-theme.scss @@ -0,0 +1,63 @@ +@use 'sass:map'; +@use '../../base' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin select($theme) { + @include css-vars($theme); + $variant: map.get($theme, '_meta', 'theme'); + + %igx-select { + position: relative; + display: block; + } + + .igx-input-group { + %igx-select__toggle-button { + background: var-get($theme, 'toggle-button-background'); + color: var-get($theme, 'toggle-button-foreground'); + } + } + + %form-group-bundle:focus-within { + %igx-select__toggle-button { + color: var-get($theme, 'toggle-button-foreground-focus'); + } + } + + @if $variant == 'indigo' { + %form-group-bundle:hover { + %igx-select__toggle-button { + color: var-get($theme, 'toggle-button-foreground-focus'); + } + } + } + + .igx-input-group--filled { + %igx-select__toggle-button { + color: var-get($theme, 'toggle-button-foreground-filled'); + } + } + + .igx-input-group--focused %igx-select__toggle-button { + background: var-get($theme, 'toggle-button-background-focus'); + color: var-get($theme, 'toggle-button-foreground-focus'); + } + + .igx-input-group.igx-input-group--focused:not(.igx-input-group--box) { + @if $variant == 'material' { + %igx-select__toggle-button { + background: var-get($theme, 'toggle-button-background-focus--border'); + } + } + } + + .igx-input-group.igx-input-group--disabled.igx-input-group--filled, + .igx-input-group.igx-input-group--disabled { + %form-group-bundle %igx-select__toggle-button { + background: var-get($theme, 'toggle-button-background-disabled'); + color: var-get($theme, 'toggle-button-foreground-disabled'); + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/slider/_slider-component.scss b/projects/igniteui-angular/core/src/core/styles/components/slider/_slider-component.scss new file mode 100644 index 00000000000..bad9450a314 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/slider/_slider-component.scss @@ -0,0 +1,162 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff + +$_igx-slider-thumbs: 'from' 'to'; + +@mixin component { + /// Slider + @include b(igx-slider) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: () + ); + + @extend %igx-slider-display !optional; + + @include e(track) { + @extend %igx-slider-track !optional; + } + + @include e(track-inactive) { + @extend %igx-slider-track-inactive !optional; + } + + @include e(track-fill) { + @extend %igx-slider-track-fill !optional; + } + + @include e(ticks) { + @extend %igx-slider__ticks !optional; + } + + @include e(ticks, $m: tall) { + @extend %igx-slider__ticks--tall !optional; + } + + @include e(ticks, $m: top) { + @extend %igx-slider__ticks--top !optional; + } + + @include e(ticks-label) { + @extend %igx-slider__tick-label !optional; + } + + @include e(tick-label, $m: hidden) { + @extend %igx-slider__tick-label--hidden !optional; + } + + @include e(tick-labels, $m: top-bottom) { + @extend %igx-slider__tick-labels--top-bottom !optional; + } + + @include e(tick-labels, $m: bottom-top) { + @extend %igx-slider__tick-labels--bottom-top !optional; + } + + @include e(ticks-group) { + @extend %igx-slider__ticks-group !optional; + } + + @include e(ticks-group, $m: tall) { + @extend %igx-slider__ticks-group--tall !optional; + } + + @include e(ticks-tick) { + @extend %igx-slider__ticks-tick !optional; + } + + @include e(ticks-label) { + @extend %igx-slider__ticks-label !optional; + } + + @include e(thumbs) { + @extend %igx-slider-thumbs-container !optional; + } + + @include e(track-steps) { + @extend %igx-slider-track-steps !optional; + } + + @include m(disabled) { + @extend %igx-slider-disabled !optional; + + @include e(track) { + @extend %igx-slider-track--disabled !optional; + } + + @include e(track-fill) { + @extend %igx-slider-track-fill--disabled !optional; + } + + @include e(track-steps) { + @extend %igx-slider-track-steps--disabled !optional; + } + + @include e(ticks-tick) { + @extend %igx-slider__tick--disabled !optional; + } + + @include e(ticks-label) { + @extend %igx-slider__ticks-labels--disabled !optional; + } + } + } + + @each $t in $_igx-slider-thumbs { + @include b(igx-slider-thumb-#{$t}) { + @extend %igx-thumb-display !optional; + + @include e(dot) { + @extend %igx-slider-thumb__dot !optional; + } + + @include m(focused) { + @extend %igx-slider-thumb--focused !optional; + } + + @include m(pressed) { + @include e(dot){ + @extend %igx-slider-thumb__dot--pressed !optional; + } + } + + @include m(active) { + @include e(dot){ + @extend %igx-slider-thumb__dot--active !optional; + } + } + + @include m(disabled) { + @extend %igx-thumb--disabled !optional; + + @include e(dot){ + @extend %igx-slider-thumb__dot--disabled !optional; + } + } + } + + @include b(igx-slider-thumb-label-#{$t}) { + @extend %igx-label-display !optional; + + @include e(container) { + @extend %igx-slider-thumb-label__container !optional; + } + + @include m(pressed) { + @include e(container){ + @extend %igx-slider-thumb-label__container--pressed !optional; + } + } + + @include m(active) { + @include e(container){ + @extend %igx-slider-thumb-label__container--active !optional; + } + } + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/slider/_slider-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/slider/_slider-theme.scss new file mode 100644 index 00000000000..64de031b2ec --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/slider/_slider-theme.scss @@ -0,0 +1,578 @@ +@use 'sass:map'; +@use 'sass:math'; +@use '../../base' as *; +@use 'igniteui-theming/sass/animations/easings' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin slider($theme) { + @include css-vars($theme); + + $variant: map.get($theme, '_meta', 'theme'); + + $slider-height: rem(48px); + $ripple-size: rem(40px); + $ripple-radius: math.div($ripple-size, 2); + + $thumb-label-width: map.get(( + 'material': rem(40px), + 'fluent': rem(40px), + 'bootstrap': rem(40px), + 'indigo': rem(36px) + ), $variant); + + $thumb-label-height: map.get(( + 'material': rem(30px), + 'fluent': rem(30px), + 'bootstrap': rem(30px), + 'indigo': rem(22px) + ), $variant); + + $slider-track-height: map.get(( + 'material': 6px, + 'fluent': 4px, + 'bootstrap': 8px, + 'indigo': 2px + ), $variant); + + $slider-outline-width: map.get(( + 'material': 0, + 'fluent': 0, + 'bootstrap': 3px, + 'indigo': 3px + ), $variant); + + // Slider ticks + $base-tick-height: rem(8px); + $tick-height: $base-tick-height; + $tick-height--tall: $base-tick-height * 2; + $tick-width: rem(2px); + + // Slider Thumb + $thumb-size: map.get(( + 'material': 20px, + 'fluent': 16px, + 'bootstrap': 16px, + 'indigo': 12px + ), $variant); + $thumb-radius: math.div($thumb-size, 2); + + $thumb-indigo-idle: math.div($thumb-size , $thumb-size * 0 + 1); + $thumb-indigo-hover: calc(16 / $thumb-indigo-idle); + + $thumb-border-width: map.get(( + 'material': 0, + 'fluent': 2px, + 'bootstrap': 1px, + 'indigo': 2px + ), $variant); + + // Slider Steps + $steps-top-position: map.get(( + 'material': 2px, + 'fluent': 1px, + 'bootstrap': 3px, + 'indigo': 0 + ), $variant); + + // Slider Label + $slider-label-width: rem(36px); + $slider-label-height: $slider-label-width; + $slider-label-radius: math.div($slider-label-width, 2); + $slider-label-padding: 0 rem(2px); + + %igx-slider-display { + display: flex; + position: relative; + // Z-index 0 is needed to set the stacking context for the inner elements with z-index. + // https://github.com/IgniteUI/igniteui-angular/issues/11597 + z-index: 0; + height: $slider-height; + flex-grow: 1; + align-items: center; + transition: all .2s $out-quad; + touch-action: pan-y pinch-zoom; + + &:hover { + %igx-slider-track-fill { + background: var-get($theme, 'track-hover-color'); + } + + %igx-slider-track-inactive { + background: var-get($theme, 'base-track-hover-color'); + } + + @if $variant == 'fluent'{ + %igx-slider-thumb__dot::before { + border: rem($thumb-border-width) solid var-get($theme, 'thumb-focus-color'); + } + } + } + } + + %igx-slider-disabled { + pointer-events: none; + + %igx-slider-track-inactive { + background: var-get($theme, 'disabled-base-track-color'); + } + } + + %igx-slider-thumbs-container { + position: absolute; + width: 100%; + height: 0; + cursor: default; + z-index: 1; + inset-inline-start: 0; + } + + %igx-slider-track { + position: relative; + width: 100%; + height: rem($slider-track-height); + overflow: hidden; + border-radius: border-radius(rem(32px)); + + @if $variant == 'indigo' { + border-radius: border-radius(rem(4px)); + } + } + + %igx-slider-track-inactive { + position: absolute; + width: 100%; + height: inherit; + background: var-get($theme, 'base-track-color'); + transition: background .2s $out-quad; + border-radius: inherit; + + @if $variant == 'material' { + height: rem(4px); + top: 50%; + transform: translateY(-50%); + } + } + + %igx-slider-track-fill { + position: absolute; + width: 100%; + background: var-get($theme, 'track-color'); + transform-origin: left center; + transform: scaleX(0); + border-radius: inherit; + height: inherit; + + [dir='rtl'] & { + transform-origin: right center; + } + + @if $variant == 'bootstrap' { + display: none; + } + } + + %igx-slider-track-fill--disabled { + background: var-get($theme, 'disabled-fill-track-color'); + } + + %igx-slider__ticks { + width: 100%; + display: flex; + position: absolute; + bottom: 0; + justify-content: space-between; + + &%igx-slider__ticks--top { + bottom: auto; + top: 0; + align-items: flex-end; + } + + @if $variant == 'indigo' { + bottom: rem(3px); + + &%igx-slider__ticks--top { + top: rem(3px); + } + } + } + + %igx-slider__ticks-group { + display: flex; + flex-direction: column; + align-items: center; + position: relative; + + &:first-of-type { + margin-inline-start: rem(-1px); + } + + &:last-of-type { + margin-inline-end: rem(-1px); + } + } + + %igx-slider__ticks-label { + color: var-get($theme, 'tick-label-color'); + position: absolute; + top: $tick-height--tall; + transform: translate(-50%); + line-height: .7; + opacity: 1; + transition: opacity .2s $in-out-quad; + + [dir='rtl'] & { + left: 100%; + } + } + + %igx-slider__ticks-tick { + background: var-get($theme, 'tick-color'); + height: $tick-height; + width: $tick-width; + } + + %igx-slider__ticks--tall { + %igx-slider__ticks-label { + top: calc(#{$tick-height--tall} + #{$tick-height}); + + @if $variant == 'indigo' { + top: calc(#{$tick-height--tall} + (#{$tick-height} / 2)); + } + } + } + + %igx-slider__tick--disabled { + background: var-get($theme, 'disabled-base-track-color') !important; + } + + %igx-slider__ticks-labels--disabled { + color: var-get($theme, 'disabled-base-track-color') !important; + } + + %igx-slider__ticks-group--tall { + %igx-slider__ticks-tick { + height: $tick-height--tall; + } + + %igx-slider__ticks-label { + top: calc(#{$tick-height--tall} + #{$tick-height}); + + @if $variant == 'indigo' { + top: calc(#{$tick-height--tall} + (#{$tick-height} / 2)); + } + } + } + + %igx-slider__ticks--top { + %igx-slider__ticks-label { + bottom: calc(#{$tick-height} + #{$tick-height}); + top: auto; + } + + &%igx-slider__ticks--tall { + %igx-slider__ticks-label { + bottom: calc(#{$tick-height--tall} + #{$tick-height}); + top: auto; + } + } + } + + %igx-slider__tick-label--hidden { + opacity: 0; + } + + %igx-slider-track-steps { + position: absolute; + display: flex; + width: 100%; + height: rem(4px); + opacity: .85; + transition: opacity .2s ease-out; + top: 50%; + transform: translateY(-50%); + color: var-get($theme, 'track-step-color'); + + svg { + clip-path: inset(0 rem(3px) 0 rem(3px)); + } + + line { + stroke: currentColor; + stroke-width: var-get($theme, 'track-step-size'); + stroke-linecap: round; + } + } + + %igx-slider-track-steps--disabled { + @if $variant == 'indigo' { + color: transparent; + } + } + + %igx-slider__tick-labels--top-bottom { + %igx-slider__ticks-group { + display: block; + } + + %igx-slider__ticks-label { + writing-mode: vertical-rl; + transform: translate(-50%) rotate(0deg); + } + + %igx-slider__ticks--tall { + %igx-slider__ticks-label { + top: calc(#{$tick-height--tall} + #{rem(2px)}); + } + } + + &%igx-slider__ticks--top { + %igx-slider__ticks-label { + writing-mode: vertical-rl; + transform: translate(-50%) rotate(0deg); + } + + %igx-slider__ticks--tall { + %igx-slider__ticks-label { + bottom: calc(#{$tick-height--tall} + #{rem(2px)}); + } + } + } + } + + %igx-slider__tick-labels--bottom-top { + %igx-slider__ticks-group { + display: block; + } + + %igx-slider__ticks-label { + writing-mode: vertical-rl; + transform: translate(-50%) rotate(180deg); + } + + &%igx-slider__ticks--top { + %igx-slider__ticks-label { + writing-mode: vertical-rl; + transform: translate(-50%) rotate(180deg); + } + + %igx-slider__ticks--tall { + %igx-slider__ticks-label { + bottom: calc(#{$tick-height--tall} + #{rem(2px)}); + } + } + } + } + + %igx-thumb-display { + position: absolute; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + width: rem($thumb-size); + height: rem($thumb-size); + outline-style: none; + top: -#{rem($thumb-radius)}; + margin-inline-start: -#{rem($thumb-radius)}; + + @if $variant == 'material' { + &:hover div::after { + opacity: .12; + transform: scale(1); + } + + &:focus div::after { + opacity: .18; + transform: scale(1); + } + } + + @if $variant == 'indigo' { + transition: transform .2s $out-quad; + + &:hover, + &:active { + transform: scale($thumb-indigo-hover); + + div::before { + border-color: var-get($theme, 'thumb-border-hover-color'); + } + } + } + + &:focus div::before { + box-shadow: 0 0 0 rem($slider-outline-width) var-get($theme, 'thumb-focus-color'); + + @if $variant == 'bootstrap' { + border-color: var-get($theme, 'thumb-border-focus-color'); + } + } + } + + @if $variant == 'fluent' { + %igx-slider-thumb--focused { + div::after { + $focus-outline-offset: rem(2px); + + position: absolute; + content: ''; + pointer-events: none; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + box-shadow: 0 0 0 rem(1px) var-get($theme, 'thumb-border-focus-color'); + width: calc(rem($thumb-size) + (#{$focus-outline-offset} * 2)); + height: calc(rem($thumb-size) + (#{$focus-outline-offset} * 2)); + } + } + } + + %igx-thumb--disabled { + &:focus div::before { + box-shadow: none; + + @if $variant == 'fluent' or $variant == 'indigo' { + border-color: var-get($theme, 'thumb-disabled-border-color') !important; + } + } + + &:focus div::after { + transform: scale(0); + } + } + + %igx-label-display { + position: absolute; + pointer-events: none; + display: flex; + top: calc(((#{$thumb-label-height}) + rem(20px)) * -1); + height: $thumb-label-height; + + @if $variant == 'indigo' { + top: calc(((#{$thumb-label-height}) + rem(18px)) * -1); + } + } + + %igx-slider-thumb-label__container { + border-radius: rem(2px); + display: flex; + align-items: center; + justify-content: center; + white-space: nowrap; + margin-inline-start: -50%; + padding: 0 rem(8px); + background: var-get($theme, 'label-background-color'); + color: var-get($theme, 'label-text-color'); + min-width: $thumb-label-width; + opacity: 0; + + @if $variant == 'indigo' { + border-radius: rem(3px); + } + + &::after { + content: ''; + position: absolute; + top: 85%; + border-inline-start: rem(10px) solid transparent; + border-inline-end: rem(10px) solid transparent; + border-top: rem(10px) solid var-get($theme, 'label-background-color'); + + @if $variant == 'indigo' { + top: rem(16px); + border-top: rem(12px) solid var-get($theme, 'label-background-color'); + } + } + } + + %igx-slider-thumb__dot { + position: relative; + inset-inline-start: 0; + pointer-events: none; + + &::before { + position: absolute; + content: ''; + width: rem($thumb-size); + height: rem($thumb-size); + inset-inline-start: #{rem($thumb-radius) - math.div(rem($thumb-size), 2)}; + top: calc((#{$thumb-size} - #{$thumb-radius}) * -1); + margin-inline-start: calc((#{$thumb-size} - #{$thumb-radius}) * -1); + background: var-get($theme, 'thumb-color'); + border: rem($thumb-border-width) solid var-get($theme, 'thumb-border-color'); + transition: transform .1s $out-quad, border-radius .1s $out-quad; + border-radius: border-radius(rem($thumb-radius)); + } + + @if $variant == 'material' { + &::after { + position: absolute; + content: ''; + width: $ripple-size; + height: $ripple-size; + background: var-get($theme, 'thumb-color'); + top: calc(50% - #{$ripple-radius}); + inset-inline-start: calc(50% - #{$ripple-radius}); + opacity: 0; + transform: scale(0); + transform-origin: center center; + transition: transform .1s $out-quad, opacity .1s $out-quad; + border-radius: border-radius(50%); + overflow: hidden; + } + } + } + + %igx-slider-thumb__dot--disabled { + pointer-events: none; + + &::before { + background: var-get($theme, 'disabled-thumb-color'); + border-color: var-get($theme, 'thumb-disabled-border-color'); + border-radius: border-radius(rem($thumb-radius)); + } + } + + %igx-slider-thumb__dot--pressed { + @if $variant == 'material' { + &::after { + opacity: .24 !important; + transform: scale(1) !important; + } + } + } + + %igx-slider-thumb-label__container--active { + opacity: 1; + } + + %igx-slider-thumb-label__container--pressed { + z-index: 1; + } +} + +/// Adds typography styles for the igx-slider component. +/// Uses the 'caption' +/// categories from the typographic scale. +/// @group typography +/// @param {Map} $categories [(ticks-label: 'caption', thumb-label: 'caption')] - The categories from the typographic scale used for type styles. +@mixin slider-typography( + $categories: ( + ticks-label: 'caption', + thumb-label: 'caption', + ) +) { + $ticks-label: map.get($categories, 'ticks-label'); + $thumb-label: map.get($categories, 'thumb-label'); + + %igx-slider-thumb-label__container { + @include type-style($thumb-label) + } + + %igx-slider__tick-label { + @include type-style($ticks-label) + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/snackbar/_snackbar-component.scss b/projects/igniteui-angular/core/src/core/styles/components/snackbar/_snackbar-component.scss new file mode 100644 index 00000000000..78b6e01c184 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/snackbar/_snackbar-component.scss @@ -0,0 +1,27 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin component { + @include b(igx-snackbar) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: ( + igx-button, + igx-overlay, + ) + ); + + @extend %igx-snackbar-display !optional; + + @include e(message) { + @extend %igx-snackbar-message !optional; + } + + @include e(button) { + @extend %igx-snackbar-button !optional; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/snackbar/_snackbar-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/snackbar/_snackbar-theme.scss new file mode 100644 index 00000000000..c09d7a8ec6d --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/snackbar/_snackbar-theme.scss @@ -0,0 +1,70 @@ +@use 'sass:map'; +@use '../../base' as *; +@use 'igniteui-theming/sass/animations' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin snackbar($theme) { + @include css-vars($theme); + @include fade-in(); + + $variant: map.get($theme, '_meta', 'theme'); + + $snackbar-min-height: rem(48px); + $snackbar-padding: pad-block(rem(7px)) pad-inline(rem(24px)); + + %igx-snackbar-display { + position: relative; + display: flex; + flex-flow: row nowrap; + align-items: center; + justify-content: space-between; + min-height: $snackbar-min-height; + padding: $snackbar-padding; + margin: rem(8px); + gap: rem(24px); + color: var-get($theme, 'text-color'); + background: var-get($theme, 'background'); + backface-visibility: hidden; + box-shadow: var-get($theme, 'elevation'); + border-radius: var-get($theme, 'border-radius'); + backdrop-filter: blur(8px); + + [igxButton] { + @include animation(fade-in .35s ease-out); + + --ig-size: 2; + background: transparent; + color: var-get($theme, 'button-color'); + -webkit-tap-highlight-color: transparent; + box-shadow: none; + } + + @if $variant == 'indigo' { + padding: pad-block(rem(4px)) pad-inline(rem(16px)); + min-height: rem(36px); + } + } + + %igx-snackbar-button { + display: contents; + } + + %igx-snackbar-message { + @include animation(fade-in .35s ease-out); + } +} + +/// Adds typography styles for the igx-snackbar component. +/// Uses the 'body-2' +/// category from the typographic scale. +/// @group typography +/// @param {Map} $categories [(text: 'body-2')] - The categories from the typographic scale used for type styles. +@mixin snackbar-typography($categories: (text: 'body-2')) { + $text: map.get($categories, 'text'); + + %igx-snackbar-message { + @include type-style($text); + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/splitter/_splitter-component.scss b/projects/igniteui-angular/core/src/core/styles/components/splitter/_splitter-component.scss new file mode 100644 index 00000000000..1c86788d102 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/splitter/_splitter-component.scss @@ -0,0 +1,62 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +/// @author Maya Kirova +@mixin component { + @include b(igx-splitter) { + // Register the component in the component registry + $this: string.slice(bem--selector-to-string(&), 2, -1); + @include register-component( + $name: $this, + $deps: () + ); + + @extend %igx-splitter-base !optional; + + @include b(#{$this}-bar-host) { + &:focus { + @extend %igx-splitter-bar--focus !optional; + } + } + + @include b(#{$this}-bar) { + @extend %igx-splitter-bar !optional; + + @include e(handle) { + @extend %igx-splitter-handle !optional; + @extend %igx-splitter-handle--horizontal !optional; + } + + @include e(expander, 'start') { + @extend %igx-splitter-expander !optional; + @extend %igx-splitter-expander--start !optional; + } + + @include e(expander, 'end') { + @extend %igx-splitter-expander !optional; + @extend %igx-splitter-expander--end !optional; + } + + @include m('vertical') { + @extend %igx-splitter-bar--vertical !optional; + + @include e(handle) { + @extend %igx-splitter-handle !optional; + @extend %igx-splitter-handle--vertical !optional; + } + + @include e(expander, 'start') { + @extend %igx-splitter-expander !optional; + @extend %igx-splitter-expander--start-vertical !optional; + } + + @include e(expander, 'end') { + @extend %igx-splitter-expander !optional; + @extend %igx-splitter-expander--end-vertical !optional; + } + } + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/splitter/_splitter-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/splitter/_splitter-theme.scss new file mode 100644 index 00000000000..b45a2f5b4a0 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/splitter/_splitter-theme.scss @@ -0,0 +1,287 @@ +@use 'sass:map'; +@use 'sass:math'; +@use '../../base' as *; +@use 'igniteui-theming/sass/animations/easings' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin splitter($theme) { + @include css-vars($theme); + $splitter-color: var-get($theme, 'bar-color'); + $hitbox-size: rem(4px); + $debug-hitbox: false; + $hitbox-debug-color: rgba(coral, .24); + + $variant: map.get($theme, '_meta', 'theme'); + + //splitter-size + borders + $splitter-size: unitless(map.get($theme, 'size')) + 2; + + //calculate the value for the slim(indigo) splitter + $slim-splitter: calc( 1 / $splitter-size); + + %igx-splitter-base { + &[aria-orientation='horizontal'] { + [dir='rtl'] & { + flex-direction: row-reverse !important; + } + } + } + + %handle-area { + position: absolute; + content: ''; + width: 100%; + height: $hitbox-size; + background: if($debug-hitbox, $hitbox-debug-color, transparent); + + @if $variant == 'indigo' { + height: rem($splitter-size * 4px); + } + } + + %handle-area--vertical { + width: $hitbox-size; + height: 100%; + + @if $variant == 'indigo' { + width: rem($splitter-size * 4px); + } + } + + %hide-controls { + %igx-splitter-handle, + %igx-splitter-expander { + opacity: 0; + transition: opacity .25s .5s ease; + pointer-events: none; + } + } + + %show-controls { + %igx-splitter-handle, + %igx-splitter-expander { + opacity: 1; + transition: opacity .25s ease; + pointer-events: auto; + } + } + + %expand-bars { + [aria-orientation='horizontal'] & { + transform: scaleX(1); + } + + [aria-orientation='vertical'] & { + transform: scaleY(1); + } + transition-delay: 0s !important; + } + + %indigo-splitter-bar { + @extend %hide-controls; + + [aria-orientation='horizontal'] & { + transform: scaleX($slim-splitter); + } + + [aria-orientation='vertical'] & { + transform: scaleY($slim-splitter); + } + + &.igx-splitter-bar--collapsible { + transition: all .25s .5s $in-out-quad !important; + + &::before, + &::after { + transition-delay: 2s; + } + + &:hover { + @extend %show-controls; + @extend %expand-bars; + + &::before, + &::after { + height: $hitbox-size; + width: 100%; + transition-delay: .5s; + } + + &.igx-splitter-bar--vertical { + &::before, + &::after { + width: $hitbox-size; + height: 100%; + } + } + } + } + } + + %igx-splitter-bar { + position: relative; + display: flex; + flex-grow: 1; + justify-content: center; + align-items: center; + background: $splitter-color; + border: rem(1px) solid $splitter-color; + z-index: 99; + opacity: .68; + transition: opacity .15s $out-quad !important; + + @if $variant != 'indigo' { + @extend %hide-controls; + + &.igx-splitter-bar--collapsible { + @extend %show-controls; + } + } + + @if $variant == 'indigo' { + @extend %indigo-splitter-bar; + } + + &::before { + @extend %handle-area; + top: 100%; + } + + &::after { + @extend %handle-area; + bottom: 100%; + } + + &:hover { + transition: all .25s ease-out; + opacity: 1; + } + } + + %igx-splitter-bar--focus { + // Remove the default browser outline styles + outline: transparent solid rem(1px); + box-shadow: inset 0 0 0 rem(1px) var-get($theme, 'focus-color'); + + @if $variant == 'indigo' { + box-shadow: none; + + %indigo-splitter-bar { + background: var-get($theme, 'focus-color'); + border-color: var-get($theme, 'focus-color'); + + &.igx-splitter-bar--collapsible { + @extend %show-controls; + @extend %expand-bars; + } + } + } + } + + %igx-splitter-bar--vertical { + flex-direction: column; + height: 100%; + + &::before { + @extend %handle-area--vertical; + top: 0; + right: 100%; + } + + &::after { + @extend %handle-area--vertical; + top: 0; + left: 100%; + } + } + + %igx-splitter-handle { + background: var-get($theme, 'handle-color'); + border-radius: var-get($theme, 'border-radius'); + } + + %igx-splitter-handle--horizontal { + width: 25%; + height: var-get($theme, 'size'); + margin: 0 rem(48px); + } + + %igx-splitter-handle--vertical { + width: var-get($theme, 'size'); + height: 25%; + margin: rem(48px) 0; + } + + %igx-splitter-hitbox { + position: absolute; + content: ''; + background: if($debug-hitbox, $hitbox-debug-color, transparent); + } + + %igx-splitter-expander { + position: relative; + width: 0; + height: 0; + border-inline-end: var-get($theme, 'size') solid transparent; + border-inline-start: var-get($theme, 'size') solid transparent; + cursor: pointer; + z-index: 1; + } + + %igx-splitter-expander--start { + border-bottom: var-get($theme, 'size') solid var-get($theme, 'expander-color'); + + &::before { + @extend %igx-splitter-hitbox; + top: calc(100% - #{map.get($theme, 'size')}); + left: calc(100% - (#{map.get($theme, 'size')} * 2)); + width: calc(#{map.get($theme, 'size')} * 4); + height: calc(#{map.get($theme, 'size')} * 3); + } + } + + %igx-splitter-expander--end { + border-bottom: unset; + border-top: var-get($theme, 'size') solid var-get($theme, 'expander-color'); + + &::before { + @extend %igx-splitter-hitbox; + top: calc(100% - (#{map.get($theme, 'size')} * 2)); + left: calc(100% - (#{map.get($theme, 'size')} * 2)); + width: calc(#{map.get($theme, 'size')} * 4); + height: calc(#{map.get($theme, 'size')} * 3); + } + } + + %igx-splitter-expander--start-vertical { + border-top: var-get($theme, 'size') solid transparent; + border-inline-end: var-get($theme, 'size') solid var-get($theme, 'expander-color'); + border-bottom: var-get($theme, 'size') solid transparent; + border-inline-start: unset; + + &::before { + @extend %igx-splitter-hitbox; + top: calc(100% - (#{map.get($theme, 'size')} * 2)); + left: calc(100% - (#{map.get($theme, 'size')} * 2)); + width: calc(#{map.get($theme, 'size')} * 3); + height: calc(#{map.get($theme, 'size')} * 4); + } + } + + %igx-splitter-expander--end-vertical { + border-top: var-get($theme, 'size') solid transparent; + border-inline-end: unset; + border-bottom: var-get($theme, 'size') solid transparent; + border-inline-start: var-get($theme, 'size') solid var-get($theme, 'expander-color'); + + &::before { + @extend %igx-splitter-hitbox; + left: calc(100% - (#{map.get($theme, 'size')} * 2)); + top: calc(100% - (#{map.get($theme, 'size')} * 2)); + height: calc(#{map.get($theme, 'size')} * 4); + width: calc(#{map.get($theme, 'size')} * 3); + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/stepper/_stepper-component.scss b/projects/igniteui-angular/core/src/core/styles/components/stepper/_stepper-component.scss new file mode 100644 index 00000000000..f197022c6ba --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/stepper/_stepper-component.scss @@ -0,0 +1,101 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Marin Popov +@mixin component { + @include b(igx-stepper) { + $block: bem--selector-to-string(&); + @include register-component(string.slice($block, 2, -1)); + + @extend %stepper-display !optional; + + @include e(header) { + @extend %igx-stepper__header !optional; + } + + @include e(body) { + @extend %igx-stepper__body !optional; + } + + @include e(step) { + @extend %igx-stepper__step !optional; + } + + @include e(step, $m: simple) { + @extend %igx-stepper__step--simple !optional; + } + + @include e(step, $m: completed) { + @extend %igx-stepper__step--completed !optional; + } + + @include e(step, $m: disabled) { + @extend %igx-stepper__step--disabled !optional; + } + + @include e(step-header) { + @extend %igx-stepper__step-header !optional; + } + + @include e(step-header, $m: current) { + @extend %igx-stepper__step-header--current !optional; + } + + @include e(step-header, $m: invalid) { + @extend %igx-stepper__step-header--invalid !optional; + } + + @include e(step-content) { + @extend %igx-stepper__step-content !optional; + } + + @include e(step-content-wrapper) { + @extend %igx-stepper__step-content-wrapper !optional; + } + + @include e(step-indicator) { + @extend %igx-stepper__step-indicator !optional; + } + + @include e(step-title-wrapper) { + @extend %igx-stepper__step-title-wrapper !optional; + } + + @include e(step-title) { + @extend %igx-stepper__step-title !optional; + } + + @include e(step-subtitle) { + @extend %igx-stepper__step-subtitle !optional; + } + + @include e(step, $m: top) { + @extend %igx-stepper__step--top !optional; + } + + @include e(step, $m: bottom) { + @extend %igx-stepper__step--bottom !optional; + } + + @include e(step, $m: start) { + @extend %igx-stepper__step--start !optional; + } + + @include e(step, $m: end) { + @extend %igx-stepper__step--end !optional; + } + + @include m(horizontal) { + @extend %igx-stepper--horizontal !optional; + + @include e(body-content) { + @extend %igx-stepper__body-content !optional; + } + + @include e(body-content, $m: active) { + @extend %igx-stepper__body-content--active !optional; + } + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/stepper/_stepper-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/stepper/_stepper-theme.scss new file mode 100644 index 00000000000..558489d3864 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/stepper/_stepper-theme.scss @@ -0,0 +1,732 @@ +@use 'sass:map'; +@use '../../base' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin stepper($theme) { + @include css-vars($theme); + + $variant: map.get($theme, '_meta', 'theme'); + + $indicator-size: map.get(( + 'material': rem(24px), + 'fluent': rem(24px), + 'bootstrap': rem(40px), + 'indigo': rem(24px) + ), $variant); + + $step-header-padding: rem(8px); + + $step-header-padding-simple: map.get(( + 'material': rem(8px), + 'fluent': rem(8px), + 'bootstrap': rem(16px), + 'indigo': rem(8px) + ), $variant); + + $title-gap: rem(8px); + $indicator-gap: rem(4px); + $indicator-padding: rem(2px); + $v-line-indent: calc(#{$step-header-padding} + (#{$indicator-size} / 2)); + $separator-position: 50%; + + $outline-width: map.get(( + 'material': clamp(1px, rem(1px), rem(1px)), + 'fluent': clamp(1px, rem(1px), rem(1px)), + 'bootstrap': clamp(1px, rem(1px), rem(1px)), + 'indigo': clamp(1px, rem(1px), rem(1px)) + ), $variant); + + $separator-size: map.get(( + 'material': rem(1px), + 'fluent': rem(1px), + 'bootstrap': rem(8px), + 'indigo': rem(1px) + ), $variant); + + $separator-title-top: calc(100% - ((#{$indicator-size} / 2) + #{$step-header-padding} + (#{$separator-size} / 2))); + $separator-title-bottom: calc((#{$indicator-size} / 2) + #{$step-header-padding} - (#{$separator-size} / 2)); + + %stepper-display, + %igx-stepper__header, + %igx-stepper__body, + %igx-stepper__step { + display: flex; + } + + %stepper-display { + flex-direction: column; + width: 100%; + } + + %igx-stepper__header { + white-space: nowrap; + flex-direction: column; + width: 100%; + flex: none; + } + + %igx-stepper__body { + color: var-get($theme, 'content-foreground'); + position: relative; + flex-direction: column; + flex: 1 1 auto; + } + + %stepper-display, + %igx-stepper__body, + %igx-stepper__step-header, + %igx-stepper__step-title-wrapper { + overflow: hidden; + } + + %igx-stepper__step-title { + color: var-get($theme, 'title-color'); + } + + %igx-stepper__step-subtitle { + color: var-get($theme, 'subtitle-color'); + } + + %igx-stepper__step { + position: relative; + flex-direction: column; + align-content: center; + justify-content: center; + min-width: rem(100px); + + &:focus { + outline: none; + + %igx-stepper__step-header { + background: var-get($theme, 'step-focus-background'); + color: var-get($theme, 'title-focus-color'); + + %igx-stepper__step-title { + color: var-get($theme, 'title-focus-color'); + } + + %igx-stepper__step-subtitle { + color: var-get($theme, 'subtitle-focus-color'); + } + + @if $variant == 'bootstrap' { + box-shadow: inset 0 0 0 $outline-width var-get($theme, 'indicator-outline'); + } + } + + %igx-stepper__step-header--current { + background: var-get($theme, 'current-step-focus-background') !important; + + %igx-stepper__step-title { + color: var-get($theme, 'current-title-focus-color'); + } + + %igx-stepper__step-subtitle { + color: var-get($theme, 'current-subtitle-focus-color'); + } + } + + %igx-stepper__step-header--invalid { + background: var-get($theme, 'invalid-step-focus-background'); + + %igx-stepper__step-title { + color: var-get($theme, 'invalid-title-focus-color'); + } + + %igx-stepper__step-subtitle { + color: var-get($theme, 'invalid-subtitle-focus-color'); + } + } + } + + &:first-of-type { + %igx-stepper__step-header { + &::before { + visibility: hidden; + } + } + } + + &:last-of-type { + %igx-stepper__step-content-wrapper { + &::before { + display: none; + } + } + + %igx-stepper__step-header { + &::after { + visibility: hidden; + } + } + } + } + + %igx-stepper__step-header { + display: flex; + padding: $step-header-padding; + position: relative; + line-height: normal; + flex-direction: column; + align-items: flex-start; + gap: $title-gap; + cursor: pointer; + background: var-get($theme, 'step-background'); + border-radius: var-get($theme, 'border-radius-step-header'); + + &:hover { + background: var-get($theme, 'step-hover-background'); + + %igx-stepper__step-title { + color: var-get($theme, 'title-hover-color'); + } + + %igx-stepper__step-subtitle { + color: var-get($theme, 'subtitle-hover-color'); + } + } + + @if $variant != material { + .igx-ripple__inner { + display: none; + } + } + } + + %igx-stepper__step-indicator { + display: flex; + align-items: center; + justify-content: center; + position: relative; + font-size: rem(12px); + height: $indicator-size; + width: $indicator-size; + white-space: nowrap; + border-radius: var-get($theme, 'border-radius-indicator'); + color: var-get($theme, 'indicator-color'); + background: var-get($theme, 'indicator-background'); + box-shadow: 0 0 0 $outline-width var-get($theme, 'indicator-outline'); + + @if $variant == 'bootstrap' { + > igx-icon { + width: var(--igx-icon-size, #{rem(18px)}); + height: var(--igx-icon-size, #{rem(18px)}); + font-size: var(--igx-icon-size, #{rem(18px)}); + } + } @else if $variant == 'indigo' { + > igx-icon { + width: var(--igx-icon-size, #{rem(14px)}); + height: var(--igx-icon-size, #{rem(14px)}); + font-size: var(--igx-icon-size, #{rem(14px)}); + } + } @else { + > igx-icon { + width: var(--igx-icon-size, #{calc(#{$indicator-size} - #{rem(6px)})}); + height: var(--igx-icon-size, #{calc(#{$indicator-size} - #{rem(6px)})}); + font-size: var(--igx-icon-size, #{calc(#{$indicator-size} - #{rem(6px)})}); + color: inherit; + } + } + + div > igx-icon, + div > igx-avatar, + div > igx-circular-bar { + max-height: $indicator-size; + max-width: $indicator-size; + } + } + + %igx-stepper__step-header--current { + background: var-get($theme, 'current-step-background'); + color: var-get($theme, 'current-title-color'); + + %igx-stepper__step-indicator { + color: var-get($theme, 'current-indicator-color'); + background: var-get($theme, 'current-indicator-background'); + box-shadow: 0 0 0 $outline-width var-get($theme, 'current-indicator-outline'); + } + + %igx-stepper__step-title { + @if $variant == 'indigo' { + /* stylelint-disable scss/at-extend-no-missing-placeholder */ + @extend .ig-typography__subtitle-2; + /* stylelint-enable scss/at-extend-no-missing-placeholder */ + } + + color: var-get($theme, 'current-title-color'); + } + + %igx-stepper__step-subtitle { + color: var-get($theme, 'current-subtitle-color'); + } + + &:hover { + background: var-get($theme, 'current-step-hover-background'); + + %igx-stepper__step-title { + color: var-get($theme, 'current-title-hover-color'); + } + + %igx-stepper__step-subtitle { + color: var-get($theme, 'current-subtitle-hover-color'); + } + } + } + + %igx-stepper__step--disabled { + color: var-get($theme, 'disabled-title-color'); + pointer-events: none; + cursor: default; + + %igx-stepper__step-indicator { + color: var-get($theme, 'disabled-indicator-color'); + background: var-get($theme, 'disabled-indicator-background'); + box-shadow: 0 0 0 $outline-width var-get($theme, 'disabled-indicator-outline'); + } + + %igx-stepper__step-title { + color: var-get($theme, 'disabled-title-color'); + } + + %igx-stepper__step-subtitle { + color: var-get($theme, 'disabled-subtitle-color'); + } + } + + %igx-stepper__step-header--invalid { + background: var-get($theme, 'invalid-step-background'); + color: var-get($theme, 'invalid-title-color'); + + %igx-stepper__step-indicator { + color: var-get($theme, 'invalid-indicator-color'); + background: var-get($theme, 'invalid-indicator-background'); + box-shadow: 0 0 0 $outline-width var-get($theme, 'invalid-indicator-outline'); + } + + %igx-stepper__step-title { + color: var-get($theme, 'invalid-title-color'); + } + + %igx-stepper__step-subtitle { + color: var-get($theme, 'invalid-subtitle-color'); + } + + &:hover { + background: var-get($theme, 'invalid-step-hover-background'); + + %igx-stepper__step-title { + color: var-get($theme, 'invalid-title-hover-color'); + } + + %igx-stepper__step-subtitle { + color: var-get($theme, 'invalid-subtitle-hover-color'); + } + } + } + + %igx-stepper__body-content { + display: block; + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow-y: auto; + overflow-x: hidden; + z-index: -1; + } + + %igx-stepper__step-content-wrapper, + %igx-stepper__body-content { + padding: rem(16px); + } + + %igx-stepper__body-content--active { + z-index: 1; + position: relative; + } + + %igx-stepper__step-content-wrapper { + margin-inline-start: $v-line-indent; + position: relative; + min-height: if($variant == 'indigo', rem(24px), rem(32px)); + + &::before { + content: ''; + position: absolute; + inset-inline-start: calc(-#{$separator-size} / 2); + top: calc(-#{$step-header-padding} + #{$title-gap}); + bottom: calc(-#{$step-header-padding} + #{$title-gap}); + width: $separator-size; + border-inline-start: $separator-size var-get($theme, 'step-separator-style') var-get($theme, 'step-separator-color'); + } + } + + [aria-selected='true'] { + %igx-stepper__step-content-wrapper { + padding-inline-start: $v-line-indent; + } + } + + @if $variant == 'indigo' { + [aria-selected='true'] { + %igx-stepper__step-content-wrapper { + padding-block: rem(16px); + padding-inline-end: rem(16px); + } + } + + [aria-selected='false'] { + %igx-stepper__step-content-wrapper { + padding-block: 0; + } + } + } + + %igx-stepper__step-title-wrapper { + white-space: nowrap; + text-overflow: ellipsis; + min-width: rem(32px); + + &:empty { + display: none; + } + + > * { + display: block; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + } + + %igx-stepper__step--start, + %igx-stepper__step--end { + %igx-stepper__step-header { + flex-direction: row; + align-items: center; + //gap: $title-gap--horizontal; + } + } + + %igx-stepper__step--start, + %igx-stepper__step--top { + %igx-stepper__step-title-wrapper { + order: -1; + } + } + + %igx-stepper__step--completed { + %igx-stepper__step-header:not(%igx-stepper__step-header--current) { + background: var-get($theme, 'complete-step-background'); + + %igx-stepper__step-indicator { + color: var-get($theme, 'complete-indicator-color'); + background: var-get($theme, 'complete-indicator-background'); + box-shadow: 0 0 0 $outline-width var-get($theme, 'complete-indicator-outline'); + } + + %igx-stepper__step-title { + color: var-get($theme, 'complete-title-color'); + } + + %igx-stepper__step-subtitle { + color: var-get($theme, 'complete-subtitle-color'); + } + + &:hover { + background: var-get($theme, 'complete-step-hover-background'); + + %igx-stepper__step-title { + color: var-get($theme, 'complete-title-hover-color'); + } + + %igx-stepper__step-subtitle { + color: var-get($theme, 'complete-subtitle-hover-color'); + } + } + } + + %igx-stepper__step-header::after { + border-top-color: var-get($theme, 'complete-step-separator-color') !important; + border-top-style: var-get($theme, 'complete-step-separator-style') !important; + } + + &:focus { + %igx-stepper__step-header:not(%igx-stepper__step-header--current) { + background: var-get($theme, 'complete-step-focus-background'); + + %igx-stepper__step-title { + color: var-get($theme, 'complete-title-focus-color'); + } + + %igx-stepper__step-subtitle { + color: var-get($theme, 'complete-subtitle-focus-color'); + } + } + } + + %igx-stepper__step-content-wrapper { + &::before { + border-inline-start-style: var-get($theme, 'complete-step-separator-style'); + border-inline-start-color: var-get($theme, 'complete-step-separator-color'); + } + } + } + + %igx-stepper__step--completed + %igx-stepper__step { + &::before { + border-top-color: var-get($theme, 'complete-step-separator-color') !important; + border-top-style: var-get($theme, 'complete-step-separator-style') !important; + } + + %igx-stepper__step-header { + &::before { + border-top-color: var-get($theme, 'complete-step-separator-color') !important; + border-top-style: var-get($theme, 'complete-step-separator-style') !important; + } + } + } + + %igx-stepper__step--simple { + %igx-stepper__step-indicator { + min-width: $indicator-size; + min-height: $indicator-size; + width: initial; + height: initial; + + div > igx-icon, + div > igx-avatar, + div > igx-circular-bar { + max-width: initial; + max-height: initial; + } + } + } + + // HORIZONTAL MODE START + %igx-stepper--horizontal { + %igx-stepper__header { + flex-direction: row; + } + + %igx-stepper__step { + overflow: hidden; + flex-direction: row; + flex-grow: 1; + + &::before { + content: ''; + width: auto; + min-width: if($variant == 'indigo', rem(40px), rem(10px)); + height: $separator-size; + flex: 1; + position: relative; + z-index: -1; + top: $separator-title-bottom; + border-top: $separator-size var-get($theme, 'step-separator-style') var-get($theme, 'step-separator-color'); + } + + &:first-of-type { + flex-grow: 0; + min-width: fit-content; + + &::before { + display: none; + } + } + } + + %igx-stepper__step-header { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + + &::before, + &::after { + content: ''; + position: absolute; + z-index: -1; + height: $separator-size; + width: calc(50% - (#{$indicator-size} - #{$indicator-gap})); + top: $separator-title-bottom; + flex: 1; + border-top: $separator-size var-get($theme, 'step-separator-style') var-get($theme, 'step-separator-color'); + } + + &::before { + inset-inline-start: 0; + } + + &::after { + inset-inline-end: 0; + } + } + + %igx-stepper__step--simple { + text-align: center; + + %igx-stepper__step-header { + align-self: center; + padding: $step-header-padding-simple; + height: auto; + + &::before, + &::after { + display: none; + } + } + + &%igx-stepper__step { + &::before { + top: calc(50% - (#{$separator-size} / 2)); + } + } + } + + %igx-stepper__step-title-wrapper { + width: 100%; + } + + %igx-stepper__step--top { + %igx-stepper__step-header { + justify-content: flex-end; + + &::before, + &::after { + top: $separator-title-top; + } + } + + &%igx-stepper__step { + &::before { + border-top: $separator-size var-get($theme, 'step-separator-style') var-get($theme, 'step-separator-color'); + top: $separator-title-top; + } + } + } + + %igx-stepper__step--bottom { + %igx-stepper__step-header { + justify-content: flex-start; + } + } + + %igx-stepper__step--top, + %igx-stepper__step--bottom { + %igx-stepper__step-title-wrapper { + text-align: center; + } + + %igx-stepper__step-header { + flex-direction: column; + } + } + + %igx-stepper__step--start { + %igx-stepper__step-title-wrapper { + text-align: end; + } + } + + %igx-stepper__step--start, + %igx-stepper__step--end { + %igx-stepper__step-indicator { + flex: 1 0 auto; + } + + %igx-stepper__step-header { + @if $variant != 'fluent' { + padding: calc(#{$step-header-padding} / 2); + } + + &::before, + &::after { + display: none; + } + } + + &%igx-stepper__step { + &::before { + top: calc(50% - (#{$separator-size} / 2)); + } + } + } + + %igx-stepper__step-content { + flex-grow: 1; + + &:focus { + outline: none; + } + + &::before { + display: none; + } + } + + %igx-stepper__step-content-wrapper { + text-align: center; + } + + %igx-stepper__body-content { + display: flex; + } + } + // HORIZONTAL MODE END +} + +/// Adds typography styles for the igx-stepper component. +/// Uses the 'body-2' category from the typographic scale. +/// @group typography +/// @param {Map} $categories [(title: 'body-2')] - The categories from the typographic scale used for type styles. +@mixin stepper-typography( + $categories: ( + title: 'body-2', + subtitle: 'caption', + indicator: 'caption', + body-content: 'body-2' + ) +) { + $title: map.get($categories, 'title'); + $subtitle: map.get($categories, 'subtitle'); + $indicator: map.get($categories, 'indicator'); + $body-content: map.get($categories, 'body-content'); + + %igx-stepper__step-indicator { + @include type-style($indicator) { + margin-top: 0; + margin-bottom: 0; + } + } + + %igx-stepper__step-title { + @include type-style($title) { + margin-top: 0; + margin-bottom: 0; + } + } + + %igx-stepper__step-subtitle { + @include type-style($subtitle) { + margin-top: 0; + margin-bottom: 0; + } + } + + %igx-stepper__step-header--current { + %igx-stepper__step-title { + font-weight: 600; + } + } + + %igx-stepper__step-content-wrapper, + %igx-stepper__body-content { + @include type-style($body-content) { + margin-top: 0; + margin-bottom: 0; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/switch/_switch-component.scss b/projects/igniteui-angular/core/src/core/styles/components/switch/_switch-component.scss new file mode 100644 index 00000000000..d814253f8e5 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/switch/_switch-component.scss @@ -0,0 +1,162 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin component { + @include b(igx-switch) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: () + ); + + @extend %switch-display !optional; + + &:hover { + @include e(ripple) { + @extend %switch-ripple--hover !optional; + @extend %switch-ripple--hover-unchecked !optional; + } + + @include e(composite) { + @extend %switch-composite--hover !optional; + } + + @include e(label) { + @extend %switch-label--hover !optional; + } + + @include e(label, $m: before) { + @extend %switch-label--hover !optional; + } + } + + &:active { + @include e(ripple) { + @extend %switch-ripple--hover !optional; + @extend %switch-ripple--hover-unchecked !optional; + @extend %switch-ripple--pressed !optional; + } + } + + @include e(input) { + @extend %switch-input !optional; + } + + @include e(composite) { + @extend %switch-composite !optional; + } + + @include e(composite-thumb) { + @extend %switch-composite-thumb !optional; + } + + @include e(thumb) { + @extend %switch-thumb !optional; + } + + @include e(ripple) { + @extend %switch-ripple !optional; + } + + @include e(label) { + @extend %switch-label !optional; + @extend %switch-label--after !optional; + } + + @include e(label, $m: before) { + @extend %switch-label !optional; + @extend %switch-label--before !optional; + } + + @include m(focused) { + @extend %igx-switch--focused !optional; + + @include e(ripple) { + @extend %switch-ripple--focused !optional; + } + + &:hover { + @include e(composite) { + @extend %igx-switch--focused--hover !optional; + } + } + } + + @include mx(focused, checked) { + @extend %igx-switch--focused-checked !optional; + + &:hover { + @include e(composite) { + @extend %switch-composite--hover !optional; + @extend %switch-composite--x--hover !optional; + } + } + } + + @include mx(disabled, checked) { + @extend %igx-switch--disabled-checked !optional; + } + + @include m(checked) { + @include e(composite) { + @extend %switch-composite--x !optional; + } + + @include e(composite-thumb) { + @extend %switch-composite-thumb--x !optional; + } + + @include e(thumb) { + @extend %switch-thumb--x !optional; + } + + &:hover { + @include e(ripple) { + @extend %switch-ripple--hover !optional; + @extend %switch-ripple--hover-checked !optional; + } + + @include e(composite) { + @extend %switch-composite--x--hover !optional; + } + } + + &:active { + @include e(ripple) { + @extend %switch-ripple--hover !optional; + @extend %switch-ripple--hover-checked !optional; + @extend %switch-ripple--pressed !optional; + } + } + } + + @include m(disabled) { + @extend %switch-display--disabled !optional; + + @include e(composite) { + @extend %switch-composite--disabled !optional; + } + + @include e(thumb) { + @extend %switch-thumb--disabled !optional; + } + + @include e(label) { + @extend %switch-label--disabled !optional; + } + + @include e(label, $m: before) { + @extend %switch-label--disabled !optional; + } + } + + @include mx(focused, checked) { + @include e(ripple) { + @extend %switch-ripple--focused !optional; + @extend %switch-ripple--focused-checked !optional; + } + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/switch/_switch-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/switch/_switch-theme.scss new file mode 100644 index 00000000000..1fc56ec75e7 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/switch/_switch-theme.scss @@ -0,0 +1,413 @@ +@use 'sass:map'; +@use 'sass:math'; +@use '../../base' as *; +@use 'igniteui-theming/sass/animations' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin switch($theme) { + @include css-vars($theme); + @include scale-in-out($start-scale: .9); + + $variant: map.get($theme, '_meta', 'theme'); + $theme-variant: map.get($theme, '_meta', 'variant'); + + $switch-width: map.get(( + 'material': 36px, + 'fluent': 40px, + 'bootstrap': 32px, + 'indigo': 32px + ), $variant); + + $switch-height: map.get(( + 'material': 14px, + 'fluent': 20px, + 'bootstrap': 16px, + 'indigo': 16px + ), $variant); + + $switch-thumb-width: map.get(( + 'material': 20px, + 'fluent': 12px, + 'bootstrap': 10px, + 'indigo': 8px + ), $variant); + + $switch-on-offset: map.get(( + 'material': 1px, + 'fluent': 5px, + 'bootstrap': 4px, + 'indigo': 7px + ), $variant); + + $switch-off-offset: map.get(( + 'material': -1px, + 'fluent': 3px, + 'bootstrap': math.div($switch-thumb-width, 4), + 'indigo': math.div($switch-thumb-width, 3), + ), $variant); + + $ripple-display: map.get(( + 'material': block, + 'fluent': none, + 'bootstrap': none, + 'indigo': none + ), $variant); + + $thumb-resting-shadow: map.get(( + 'material': var-get($theme, 'resting-elevation'), + 'fluent': none, + 'bootstrap': none, + 'indigo': none + ), $variant); + + $thumb-hover-shadow: map.get(( + 'material': var-get($theme, 'hover-elevation'), + 'fluent': none, + 'bootstrap': none, + 'indigo': none + ), $variant); + + $thumb-disabled-shadow: map.get(( + 'material': var-get($theme, 'disabled-elevation'), + 'fluent': none, + 'bootstrap': none, + 'indigo': none + ), $variant); + + $switch-thumb-height: $switch-thumb-width; + + $ripple-size: rem(40px); + $ripple-radius: math.div($ripple-size, 2); + + $label-margin: map.get(( + 'material': rem(12px), + 'fluent': rem(8px), + 'bootstrap': rem(8px), + 'indigo': rem(8px) + ), $variant); + + $input-transition: all .2s $ease-in-out-quad; + + %switch-display { + position: relative; + display: inline-flex; + flex-flow: row nowrap; + align-items: center; + cursor: pointer; + } + + %switch-input { + @include hide-default(); + } + + %switch-display--disabled { + user-select: none; + pointer-events: none; + cursor: initial; + } + + %switch-composite { + display: flex; + align-items: center; + width: rem($switch-width); + height: rem($switch-height); + border: rem(1px) solid var-get($theme, 'border-color'); + border-radius: var-get($theme, 'border-radius-track'); + background: var-get($theme, 'track-off-color'); + user-select: none; + transition: $input-transition; + + @if $variant == 'indigo' { + border: rem(2px) solid var-get($theme, 'border-color'); + } + + //ripple color + --color: #{var-get($theme, 'track-off-color')} + } + + %switch-composite--hover { + border-color: var-get($theme, 'border-hover-color'); + + %switch-thumb { + background: var-get($theme, 'thumb-off-hover-color'); + } + + %switch-thumb--x { + background: var-get($theme, 'thumb-on-color'); + } + } + + %switch-composite--x { + background: var-get($theme, 'track-on-color'); + border-color: var-get($theme, 'border-on-color'); + + //ripple color + --color: #{var-get($theme, 'thumb-on-color')}; + } + + %switch-composite--x--hover { + background: var-get($theme, 'track-on-hover-color'); + border-color: var-get($theme, 'border-on-hover-color'); + + %switch-thumb { + background: var-get($theme, 'thumb-on-color'); + } + } + + %switch-composite--disabled { + background: var-get($theme, 'track-disabled-color'); + border-color: var-get($theme, 'border-disabled-color'); + } + + %switch-composite-thumb { + width: rem($switch-thumb-width); + height: $switch-thumb-height; + min-width: rem($switch-thumb-width); + border-radius: var-get($theme, 'border-radius-thumb'); + transition: $input-transition; + transform: translateX(#{rem($switch-off-offset)}); + + [dir='rtl'] & { + transform: translateX(#{rem(-1 * $switch-off-offset)}); + } + } + + %switch-composite-thumb--x { + transform: translateX(#{rem($switch-width) - rem($switch-thumb-width) - rem($switch-on-offset)}); + + [dir='rtl'] & { + transform: translateX(-#{rem($switch-width) - rem($switch-thumb-width) - rem($switch-on-offset)}); + } + } + + %switch-thumb { + position: relative; + display: block; + width: rem($switch-thumb-width); + height: $switch-thumb-height; + min-width: rem($switch-thumb-width); + border-radius: var-get($theme, 'border-radius-thumb'); + background: var-get($theme, 'thumb-off-color'); + transition: $input-transition; + + @if $variant == 'material' { + box-shadow: $thumb-resting-shadow; + + &:hover { + box-shadow: $thumb-hover-shadow; + } + } + } + + %switch-thumb--x { + background: var-get($theme, 'thumb-on-color'); + } + + %switch-thumb--disabled { + background: var-get($theme, 'thumb-disabled-color'); + box-shadow: $thumb-disabled-shadow; + } + + %switch-ripple { + display: $ripple-display; + position: absolute; + top: calc(50% - #{$ripple-radius}); + inset-inline-start: calc(50% - #{$ripple-radius}); + width: $ripple-size; + height: $ripple-size; + overflow: hidden; + pointer-events: none; + filter: opacity(1); + border-radius: var-get($theme, 'border-radius-ripple'); + } + + %igx-switch--focused { + @if $variant == 'fluent' { + %switch-composite { + position: relative; + $focus-outline-offset: rem(3px); + + &::after { + content: ''; + position: absolute; + inset: -$focus-outline-offset; + box-shadow: 0 0 0 rem(1px) var-get($theme, 'focus-outline-color'); + } + } + + &.igx-checkbox--focused::after { + opacity: 0; + } + } + + @if $variant == 'bootstrap' { + %switch-composite { + border-color: var-get($theme, 'focus-fill-color'); + box-shadow: 0 0 0 rem(4px) var-get($theme, 'focus-outline-color'); + } + + %switch-thumb { + background: var-get($theme, 'focus-fill-color'); + } + } + + @if $variant == 'indigo' { + %switch-composite { + box-shadow: 0 0 0 rem(3px) var-get($theme, 'focus-outline-color'); + } + } + } + + %igx-switch--focused--hover { + @if $variant == 'bootstrap' { + border-color: var-get($theme, 'focus-fill-hover-color'); + + %switch-thumb { + background: var-get($theme, 'focus-fill-hover-color'); + } + } + } + + %igx-switch--focused-checked { + @if $variant == 'bootstrap' { + %switch-composite { + border-color: var-get($theme, 'border-on-color'); + } + + %switch-thumb--x { + background: var-get($theme, 'thumb-on-color'); + } + } + + @if $variant == 'indigo' { + %switch-composite { + box-shadow: 0 0 0 rem(3px) var-get($theme, 'focus-outline-color-focused'); + } + } + } + + %igx-switch--disabled-checked { + %switch-composite--x { + background: var-get($theme, 'track-on-disabled-color'); + } + + %switch-thumb--x { + background: var-get($theme, 'thumb-on-disabled-color'); + } + + @if $variant == 'bootstrap' or $variant == 'fluent'{ + %switch-composite--x { + border-color: var-get($theme, 'track-on-disabled-color'); + } + } + + @if $variant == 'indigo' { + %switch-composite--x { + border-color: transparent; + } + } + } + + %switch-ripple--focused { + background: color($color: 'gray', $variant: 600); + transition: background .2s $ease-out-quad; + opacity: .12; + + @if $theme-variant == 'dark' { + opacity: .24; + } + } + + %switch-ripple--focused-checked { + background: var-get($theme, 'thumb-on-color'); + } + + %switch-label { + display: inline-block; + color: var-get($theme, 'label-color'); + user-select: none; + word-wrap: break-all; + + &:empty { + margin: 0; + } + } + + %switch-label--before, + %switch-label--after { + &:empty { + margin: 0; + } + } + + %switch-label--after { + margin-inline-start: $label-margin; + } + + %switch-label--before { + order: -1; + margin-inline-end: $label-margin; + } + + %switch-label--hover { + color: var-get($theme, 'label-hover-color'); + } + + %switch-label--disabled { + color: var-get($theme, 'label-disabled-color'); + } + + %switch-ripple--hover { + &::after { + position: absolute; + content: ''; + opacity: .06; + inset: 0; + + @if $theme-variant == 'dark' { + opacity: .12; + } + } + } + + %switch-ripple--hover-unchecked { + &::after { + background: color($color: 'gray', $variant: 600); + } + } + + %switch-ripple--hover-checked { + &::after { + background: var-get($theme, 'thumb-on-color'); + } + } + + %switch-ripple--pressed { + &::after { + opacity: .12; + + @if $theme-variant == 'dark' { + opacity: .24; + } + } + } +} + +/// Adds typography styles for the igx-switch component. +/// Uses the 'subtitle-1' category from the typographic scale. +/// @group typography +/// @param {Map} $categories [(label: 'subtitle-1')] - The categories from the typographic scale used for type styles. +@mixin switch-typography( + $categories: (label: 'subtitle-1') +) { + $label: map.get($categories, 'label'); + + %switch-label { + @include type-style($label) { + margin-top: 0; + margin-bottom: 0; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/tabs/_tabs-component.scss b/projects/igniteui-angular/core/src/core/styles/components/tabs/_tabs-component.scss new file mode 100644 index 00000000000..693be760c11 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/tabs/_tabs-component.scss @@ -0,0 +1,91 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin component { + @include b(igx-tabs) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: ( + igx-button, + igx-icon, + ) + ); + + @extend %tabs-display !optional; + + @include e(header) { + @extend %tabs-header !optional; + } + + @include e(header-button) { + @extend %tabs-header-button !optional; + } + + @include e(header-button, $m: hidden) { + @extend %tabs-header-button--hidden !optional; + } + + @include e(header-button, $m: none) { + @extend %tabs-header-button--none !optional; + } + + @include e(header-content) { + @extend %tabs-header-content !optional; + } + + @include e(header-wrapper) { + @extend %tabs-header-wrapper !optional; + } + + @include e(header-scroll) { + @extend %tabs-header-scroll !optional; + } + + @include e(header-scroll, $m: start) { + @extend %tabs-header-scroll--start !optional; + } + + @include e(header-scroll, $m: end) { + @extend %tabs-header-scroll--end !optional; + } + + @include e(header-scroll, $m: center) { + @extend %tabs-header-scroll--center !optional; + } + + @include e(header-scroll, $m: justify) { + @extend %tabs-header-scroll--justify !optional; + } + + @include e(header-item) { + @extend %tabs-header-item !optional; + } + + @include e(header-item, $m: selected) { + @extend %tabs-header-item--selected !optional; + } + + @include e(header-item, $m: disabled) { + @extend %tabs-header-item--disabled !optional; + } + + @include e(header-item-inner) { + @extend %tabs-header-item-inner !optional; + } + + @include e(header-active-indicator) { + @extend %tabs-header-active-indicator !optional; + } + + @include e(panels) { + @extend %tabs-panels !optional; + } + + @include e(panel) { + @extend %tabs-panel !optional; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/tabs/_tabs-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/tabs/_tabs-theme.scss new file mode 100644 index 00000000000..2ecc36cf428 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/tabs/_tabs-theme.scss @@ -0,0 +1,470 @@ +@use 'sass:map'; +@use 'sass:meta'; +@use '../../base' as *; +@use '../ripple/ripple-theme' as *; +@use '../ripple/ripple-component' as *; +@use '../../themes/schemas' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +/// @requires ripple-theme +/// @requires {mixin} ripple +@mixin tabs($theme) { + @include css-vars($theme); + + $variant: map.get($theme, '_meta', 'theme'); + $not-bootstrap-theme: $variant != 'bootstrap'; + $bootstrap-theme: $variant == 'bootstrap'; + $indigo-theme: $variant == 'indigo'; + $theme-variant: map.get($theme, '_meta', 'variant'); + + $item-min-width: rem(90px); + $item-max-width: rem(360px); + $item-min-height: map.get(( + 'material': rem(48px), + 'fluent': rem(44px), + 'bootstrap': rem(48px), + 'indigo': rem(40px) + ), $variant); + + $tabs-animation-function: cubic-bezier(.35, 0, .25, 1); + + $item-padding: pad-block(rem(11px)) pad-inline(rem(16px)); + + $tabs-ripple-theme: ripple-theme( + $schema: $light-material-schema, + $color: var-get($theme, 'tab-ripple-color') + ); + + $button-ripple-theme: ripple-theme( + $schema: $light-material-schema, + $color: var-get($theme, 'button-ripple-color') + ); + + %tabs-header, + %tabs-header-button, + %tabs-header-item-inner, + %tabs-header-content { + display: flex; + align-items: center; + } + + %tabs-display { + --nav-btn-border-color: #{var-get($theme, 'border-color')}; + + display: flex; + flex-direction: column; + overflow: hidden; + } + + %tabs-header { + overflow: hidden; + flex: 0 0 auto; + z-index: 1; + + @if $variant == 'material' or $variant == 'bootstrap' { + background: var-get($theme, 'item-background'); + } + + @if $bootstrap-theme { + position: relative; + + &::after { + content: ''; + position: absolute; + bottom: 0; + inset-inline-start: 0; + width: 100%; + height: rem(1px); + background: var-get($theme, 'border-color'); + z-index: 0; + } + } + } + + %tabs-header-content { + flex: 1 1 auto; + overflow: hidden; + scroll-behavior: smooth; + } + + %tabs-header-wrapper { + position: relative; + flex-grow: 1; + } + + %tabs-header-scroll { + display: flex; + height: 100%; + } + + %tabs-header-button { + align-items: center; + justify-content: center; + z-index: 1; + border: none; + padding: 0; + min-width: rem(48px); + width: rem(48px); + cursor: pointer; + position: relative; + background: var-get($theme, 'button-background'); + color: var-get($theme, 'button-color'); + outline: 0; + align-self: stretch; + height: auto; + transition: none; + border-radius: border-radius(0); + + &:hover { + background: var-get($theme, 'button-hover-background'); + color: var-get($theme, 'button-hover-color'); + } + + &:focus { + outline: 0; + + @if $variant != 'indigo' { + background: var-get($theme, 'button-hover-background'); + } + } + + &::-moz-focus-inner { + // remove focus dotted border in firefox + border: 0; + } + + &:disabled { + color: var-get($theme, 'button-disabled-color'); + cursor: default; + pointer-events: none; + } + + &--none { + display: none; + } + + @if $indigo-theme { + min-width: rem(40px); + width: rem(40px); + + &::after { + content: ''; + position: absolute; + pointer-events: none; + width: 100%; + height: 100%; + border-bottom: rem(1px) solid var(--nav-btn-border-color); + } + } + + @include ripple($button-ripple-theme); + @include css-vars($button-ripple-theme); + + igx-icon { + $icon-size: #{if($variant == 'indigo', rem(16px), rem(24px))}; + + --ig-icon-size: #{$icon-size}; + --igx-icon-size: #{$icon-size}; + } + + [dir='rtl'] & { + transform: scaleX(-1); + } + } + + %tabs-header-item { + display: inline-flex; + justify-content: center; + align-items: center; + min-width: $item-min-width; + max-width: $item-max-width; + min-height: $item-min-height; + word-wrap: break-word; + // Flex basis & shrink are Needed for IE11 + flex-basis: auto; + flex-shrink: 0; + padding: $item-padding; + overflow: hidden; + cursor: pointer; + position: relative; + user-select: none; + background: var-get($theme, 'item-background'); + color: var-get($theme, 'item-text-color'); + outline: 0; + + igx-icon { + color: var-get($theme, 'item-icon-color'); + } + + @if $bootstrap-theme { + padding-block: pad-block(rem(12px)); + border-start-start-radius: var-get($theme, 'border-radius'); + border-start-end-radius: var-get($theme, 'border-radius'); + } + + @if $not-bootstrap-theme { + transition: all .3s $tabs-animation-function; + border: rem(1px) solid var-get($theme, 'border-color'); + border-radius: var-get($theme, 'border-radius'); + + &:hover, + &:focus { + border: rem(1px) solid var-get($theme, 'border-color--hover'); + } + } + + @if $indigo-theme { + border-top: rem(1px) solid transparent; + border-inline: none; + + &:hover, + &:focus { + border-top: rem(1px) solid transparent; + border-inline: none; + border-bottom: rem(1px) solid var-get($theme, 'border-color--hover'); + } + + igx-icon { + --ig-size: 2; + } + } + + > * { + margin-inline-start: rem(if($variant != 'indigo', 12px, 8px)); + + &:first-child { + margin-inline-start: 0; + } + } + + &::-moz-focus-inner { + border: 0; + } + + &:focus { + background: var-get($theme, 'item-hover-background'); + color: var-get($theme, 'item-hover-color'); + border-bottom-color: transparent; + } + + &:hover { + background: var-get($theme, 'item-hover-background'); + color: var-get($theme, 'item-hover-color'); + + @if $bootstrap-theme { + box-shadow: inset 0 0 0 rem(1px) var-get($theme, 'border-color--hover'); + } + } + + &:hover, + &:focus { + igx-icon { + color: var-get($theme, 'item-hover-icon-color'); + } + } + + @include ripple($tabs-ripple-theme); + @include css-vars($tabs-ripple-theme); + } + + %tabs-header-item--selected { + outline: 0; + color: var-get($theme, 'item-active-color'); + background: var-get($theme, 'item-active-background'); + + &:hover, + &:focus { + background: var-get($theme, 'item-active-hover-background'); + color: var-get($theme, 'item-active-hover-color'); + + igx-icon { + color: var-get($theme, 'item-active-hover-icon-color'); + } + } + + igx-icon { + color: var-get($theme, 'item-active-icon-color'); + } + + @if $bootstrap-theme { + position: relative; + box-shadow: inset 0 0 0 rem(1px) var-get($theme, 'border-color'); + z-index: 1; + + &:not(:focus) { + &::before { + content: ''; + position: absolute; + bottom: 0; + inset-inline-start: 0; + width: 100%; + height: rem(1px); + background: linear-gradient( + to right, + var-get($theme, 'border-color') 1px, + var-get($theme, 'item-active-background') 1px, + var-get($theme, 'item-active-background') calc(100% - 1px), + var-get($theme, 'border-color') calc(100% - 1px) + ); + z-index: -1; + } + } + + &:hover { + box-shadow: inset 0 0 0 rem(1px) var-get($theme, 'border-color'); + + &::before { + background: linear-gradient( + to right, + var-get($theme, 'border-color') 1px, + var-get($theme, 'item-active-hover-background') 1px, + var-get($theme, 'item-active-hover-background') calc(100% - 1px), + var-get($theme, 'border-color') calc(100% - 1px) + ); + } + } + } + + @if $variant == 'fluent' { + %tabs-header-item-inner > [igxtabheaderlabel] { + font-weight: 600; + } + } + } + + %tabs-header-item:focus, + %tabs-header-item--selected:focus { + @if $bootstrap-theme { + border: none; + box-shadow: inset 0 0 0 rem(2px) var-get($theme, 'item-hover-color'); + border-bottom-left-radius: rem(4px); + border-bottom-right-radius: rem(4px); + z-index: 1; + + &::after { + display: none; + } + } + } + + %tabs-header-item--disabled { + outline: 0; + color: var-get($theme, 'item-disabled-color'); + cursor: default; + pointer-events: none; + + igx-icon { + color: var-get($theme, 'item-disabled-icon-color'); + } + } + + %tabs-header-item-inner { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + text-align: center; + + > [igxtabheaderlabel] { + @include line-clamp(2, true, true); + } + + > [igxtabheadericon] { + margin-bottom: rem(8px); + + &:last-child { + margin-bottom: 0; + } + } + } + + %tabs-header-active-indicator { + position: absolute; + bottom: 0; + // We need to explicitly set the default for IE 11 + left: 0; + transform: translateX(0); + height: rem(2px); + min-width: $item-min-width; + background: var-get($theme, 'indicator-color'); + transition: transform .3s $tabs-animation-function, width .2s $tabs-animation-function; + + @if $bootstrap-theme { + display: none; + } + + @if $indigo-theme { + height: rem(3px); + } + } + + %tabs-panels { + position: relative; + overflow: hidden; + display: flex; + flex-direction: column; + flex: 1 1 auto; + } + + %tabs-panel { + position: absolute; + inset: 0; + overflow-x: hidden; + overflow-y: auto; + flex: 1 1 auto; + + &::-moz-focus-inner { + // remove focus dotted border in firefox + border: 0; + } + + &:focus { + outline-width: 0; + } + + &[tabindex='0'] { + position: relative; + } + } + + %tabs-header-scroll--start { + justify-content: flex-start; + } + + %tabs-header-scroll--end { + justify-content: flex-end; + min-width: max-content; + } + + %tabs-header-scroll--center { + justify-content: center; + min-width: max-content; + } + + %tabs-header-scroll--justify { + %tabs-header-item { + flex-basis: 0; + flex-grow: 1; + max-width: 100%; + } + } +} + +/// Adds typography styles for the igx-tabs component. +/// Uses the 'subtitle-2' +/// category from the typographic scale. +/// @group typography +/// @param {Map} $categories [(label: 'button')] - The categories from the typographic scale used for type styles. +@mixin tabs-typography($categories: (label: 'button')) { + $label: map.get($categories, 'label'); + + %tabs-header-item-inner > [igxtabheaderlabel] { + @include type-style($label) { + margin-top: 0; + margin-bottom: 0; + @content; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/time-picker/_time-picker-component.scss b/projects/igniteui-angular/core/src/core/styles/components/time-picker/_time-picker-component.scss new file mode 100644 index 00000000000..7c574c4ce59 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/time-picker/_time-picker-component.scss @@ -0,0 +1,108 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin component { + @include b(igx-time-picker) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: ( + igx-icon, + igx-input-group, + ) + ); + + @extend %time-picker-display !optional; + + @include e(header) { + @extend %time-picker__header !optional; + } + + @include e(wrapper) { + @extend %time-picker__wrapper !optional; + } + + @include e(header-hour){ + @extend %time-picker__header-hour !optional; + } + + @include e(main) { + @extend %time-picker__main !optional; + } + + // COLUMN + @include e(column) { + @extend %time-picker__column !optional; + } + + @include e(item) { + @extend %time-picker__item !optional; + } + + @include e(item, $m: selected) { + @extend %time-picker__item--selected !optional; + } + + @include e(item, $m: active) { + @extend %time-picker__item--active !optional; + } + + @include e(item, $m: disabled) { + @extend %time-picker__item--disabled !optional; + } + + // HOUR + @include e(hourList) { + @extend %time-picker__hourList !optional; + } + + // MINUTE + @include e(minuteList) { + @extend %time-picker__minuteList !optional; + } + + // SECONDS + @include e(secondsList) { + @extend %time-picker__secondsList !optional; + } + + // AM PM + @include e(ampmList) { + @extend %time-picker__ampmList !optional; + } + + @include e(body) { + @extend %time-picker__body !optional; + } + + @include e(buttons) { + @extend %time-picker__buttons !optional; + } + + @include m(dropdown) { + @extend %time-picker--dropdown !optional; + } + + @include m(vertical) { + @extend %time-picker-display--vertical !optional; + + @include e(header) { + @extend %time-picker__header--vertical !optional; + } + + @include e(main) { + @extend %time-picker__main--vertical !optional; + } + + @include e(body) { + @extend %time-picker__body--vertical !optional; + } + + @include e(buttons) { + @extend %time-picker__buttons--vertical !optional; + } + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/time-picker/_time-picker-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/time-picker/_time-picker-theme.scss new file mode 100644 index 00000000000..42e22233e56 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/time-picker/_time-picker-theme.scss @@ -0,0 +1,274 @@ +@use 'sass:map'; +@use '../../base' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin time-picker($theme) { + @include css-vars($theme, '.igx-time-picker'); + + $variant: map.get($theme, '_meta', 'theme'); + + $picker-buttons-padding: map.get(( + 'material': rem(8px), + 'fluent': rem(8px), + 'bootstrap': rem(8px), + 'indigo': rem(8px) rem(16px), + ), $variant); + + $picker-header-padding: map.get(( + 'material': rem(16px) rem(24px), + 'fluent': rem(16px), + 'bootstrap': rem(16px), + 'indigo': rem(16px), + ), $variant); + + %time-picker-display { + @include sizable(); + + --component-size: var(--ig-size, #{var-get($theme, 'default-size')}); + + display: flex; + flex-flow: column nowrap; + border-radius: var-get($theme, 'border-radius'); + box-shadow: 0 0 0 rem(1px) var-get($theme, 'border-color'), + var-get($theme, 'modal-elevation'); + background: var-get($theme, 'background-color'); + overflow: hidden; + min-width: fit-content; + + igx-divider { + --color: #{var-get($theme, 'divider-color')}; + } + } + + %time-picker__main { + flex: 1 1 auto; + } + + %time-picker__main--vertical { + display: flex; + flex-grow: 1; + } + + %time-picker__body--vertical { + flex-grow: 1; + } + + %time-picker--dropdown { + min-width: sizable(rem(290px), rem(314px), rem(360px)); + box-shadow: inset 0 0 0 rem(1px) var-get($theme, 'border-color'), + var-get($theme, 'dropdown-elevation'); + + %time-picker__body { + min-width: auto; + } + } + + %time-picker__body { + display: flex; + min-width: sizable(rem(290px), rem(314px), rem(360px)); + padding-block: rem(16px); + justify-content: center; + } + + %time-picker__wrapper { + display: flex; + flex-direction: column; + } + + %time-picker-content { + width: 100%; + padding: 0; + color: inherit; + line-height: initial; + } + + %time-picker-dialog-title { + display: none; + } + + %time-picker__hourList { + text-align: end; + + [dir='rtl'] & { + order: 2; + } + } + + %time-picker__minuteList { + text-align: center; + + [dir='rtl'] & { + order: 1; + } + } + + %time-picker__secondsList { + text-align: center; + } + + %time-picker__ampmList { + display: flex; + flex-direction: column; + padding-top: rem(48px); + + [dir='rtl'] & { + order: 3; + } + } + + %time-picker__column { + width: if($variant == 'indigo', rem(54px), rem(64px)); + padding: 0; + cursor: pointer; + display: flex; + flex-flow: column nowrap; + justify-content: space-between; + align-items: center; + color: var-get($theme, 'text-color'); + overflow: hidden; + gap: rem(4px); + + &:focus, + &:active { + outline: none; + } + } + + %time-picker__item { + width: rem(46px); + padding: pad-block(rem(5px)) pad-inline(rem(10px)); + border-radius: var-get($theme, 'active-item-border-radius'); + height: var-get($theme, 'time-item-size'); + display: flex; + justify-content: center; + align-items: center; + + &:focus, + &:active { + outline: none; + } + + &:hover { + color: var-get($theme, 'hover-text-color'); + } + } + + %time-picker__item:not(%time-picker__item--selected) { + font-size: sizable(var(--ig-body-2-font-size), var(--ig-body-2-font-size), var(--ig-body-1-font-size)); + font-weight: sizable(var(--ig-body-2-font-weight), var(--ig-body-2-font-weight), var(--ig-body-1-font-weight)); + font-style: sizable(var(--ig-body-2-font-style), var(--ig-body-2-font-style), var(--ig-body-1-font-style)); + line-height: sizable(var(--ig-body-2-line-height), var(--ig-body-2-line-height), var(--ig-body-1-line-height)); + letter-spacing: sizable(var(--ig-body-2-letter-spacing), var(--ig-body-2-letter-spacing), var(--ig-body-1-letter-spacing)); + text-transform: sizable(var(--ig-body-2-text-transform), var(--ig-body-2-text-transform), var(--ig-body-1-text-transform)); + } + + %time-picker__item--selected { + color: var-get($theme, 'selected-text-color'); + } + + %time-picker__item--active { + color: var-get($theme, 'active-item-foreground'); + background: var-get($theme, 'active-item-background'); + + &:hover, + &:focus { + color: var-get($theme, 'active-item-foreground'); + } + } + + %time-picker__item--disabled { + color: var-get($theme, 'disabled-text-color'); + background: var-get($theme, 'disabled-item-background'); + pointer-events: none; + } + + %time-picker__header { + display: flex; + flex-direction: column; + + padding: $picker-header-padding; + + @if $variant == 'indigo' { + gap: rem(2px); + } + + background: var-get($theme, 'header-background'); + } + + %time-picker-display:not(%time-picker-display--vertical) { + %time-picker__header { + @if $variant == 'indigo' { + border-block-end: rem(1px) solid var-get($theme, 'divider-color'); + } + } + } + + %time-picker__header--vertical { + @if $variant == 'indigo' { + min-width: rem(136px); + border-inline-end: rem(1px) solid var-get($theme, 'divider-color'); + } @else { + min-width: rem(168px); + } + } + + %time-picker__header-hour { + display: flex; + color: var-get($theme, 'header-hour-text-color'); + margin: 0; + + [dir='rtl'] & { + flex-direction: row-reverse; + justify-content: flex-end; + } + } + + %time-picker__buttons { + display: flex; + min-height: sizable(rem(40px), rem(44px), rem(48px)); + justify-content: flex-end; + align-items: center; + padding: $picker-buttons-padding; + gap: rem(8px); + + @if $variant == 'indigo' { + [igxbutton] { + min-width: rem(88px); + } + } + } + + %time-picker__buttons--vertical { + align-items: flex-end; + } +} + +/// Adds typography styles for the igx-calendar component. +/// Uses the 'h4', 'subtitle-1' and 'body-1' +/// category from the typographic scale. +/// @group typography +/// @param {Map} $categories [(header-time-period: 'subtitle-1', header-hour: 'h4', content: 'body-1')] - The categories from the typographic scale used for type styles. +@mixin time-picker-typography($categories: ( + header-time-period: 'subtitle-1', + header-hour: 'h4', + selected-time: 'h5' +)) { + $header-hour: map.get($categories, 'header-hour'); + $selected-time: map.get($categories, 'selected-time'); + + %time-picker__header-hour { + @include type-style($header-hour, false) { + margin-top: 0; + margin-bottom: 0; + } + } + + %time-picker__item--selected { + @include type-style($selected-time) { + margin-top: 0; + margin-bottom: 0; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/toast/_toast-component.scss b/projects/igniteui-angular/core/src/core/styles/components/toast/_toast-component.scss new file mode 100644 index 00000000000..7e605838c1e --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/toast/_toast-component.scss @@ -0,0 +1,17 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin component { + @include b(igx-toast) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: ( + igx-overlay, + ) + ); + @extend %igx-toast-display !optional; + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/toast/_toast-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/toast/_toast-theme.scss new file mode 100644 index 00000000000..411989a9bba --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/toast/_toast-theme.scss @@ -0,0 +1,60 @@ +@use 'sass:map'; +@use '../../base' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin toast($theme) { + @include css-vars($theme); + + $variant: map.get($theme, '_meta', 'theme'); + $width: rem(52px); + $margin: rem(42px) auto; + + $padding: map.get(( + 'material': pad-block(rem(10px)) pad-inline(rem(16px)), + 'fluent': pad-block(rem(8px)) pad-inline(rem(12px)), + 'bootstrap': pad(rem(12px)), + 'indigo': pad-block(rem(10px)) pad-inline(rem(16px)) + ), $variant); + + %igx-toast-display { + display: inline-flex; + justify-content: center; + align-items: center; + // !important is needed to override the typography styles + margin: $margin !important; + padding: $padding; + min-width: $width; + color: var-get($theme, 'text-color'); + background: var-get($theme, 'background'); + border-radius: var-get($theme, 'border-radius'); + box-shadow: var-get($theme, 'elevation'); + backdrop-filter: blur(10px); + + &::after { + content: ''; + position: absolute; + width: 100%; + height: 100%; + border-radius: inherit; + box-shadow: inset 0 0 0 rem(1px) var-get($theme, 'border-color'); + } + } +} + +/// Adds typography styles for the igx-toast component. +/// Uses the 'body-2' +/// category from the typographic scale. +/// @group typography +/// @param {Map} $categories [(text: 'body-2')] - The categories from the typographic scale used for type styles. +@mixin toast-typography($categories: (text: 'body-2')) { + $text: map.get($categories, 'text'); + + %igx-toast-display, + %igx-toast-display > * { + @include type-style($text) { + margin: 0; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/tooltip/_tooltip-component.scss b/projects/igniteui-angular/core/src/core/styles/components/tooltip/_tooltip-component.scss new file mode 100644 index 00000000000..a58b1bb237a --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/tooltip/_tooltip-component.scss @@ -0,0 +1,33 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Simeon Simeonoff +@mixin component { + @include b(igx-tooltip) { + $this: bem--selector-to-string(&); + @include register-component(string.slice($this, 2, -1)); + + @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/core/src/core/styles/components/tooltip/_tooltip-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/tooltip/_tooltip-theme.scss new file mode 100644 index 00000000000..2e1f4a21a61 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/tooltip/_tooltip-theme.scss @@ -0,0 +1,92 @@ +@use 'sass:map'; +@use '../../base' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin tooltip($theme) { + @include css-vars($theme, '.igx-tooltip'); + $variant: map.get($theme, '_meta', 'theme'); + + $transparent-border: rem(4px) solid transparent; + $color-border: rem(4px) solid var-get($theme, 'background'); + + %tooltip-display { + 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: var-get($theme, 'elevation'); + padding: pad-block(rem(4px)) pad-inline(rem(8px)); + gap: rem(8px); + min-height: rem(24px); + min-width: rem(24px); + max-width: rem(200px); + width: fit-content; + + igx-icon { + --component-size: 1; + } + + igx-tooltip-close-button { + display: flex; + cursor: default; + } + + &:not([data-default]) { + max-width: initial; + } + } + + %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; + } +} + +/// Adds typography styles for the igx-tooltip component. +/// Uses custom typography. +/// @group typography +/// @param {Map} $categories [(tooltip-text: null] - The categories from the typographic scale used for type styles. +@mixin tooltip-typography( + $categories: (tooltip-text: null) +) { + $tooltip-text: map.get($categories, 'tooltip-text'); + + @if $tooltip-text { + %tooltip-display { + @include type-style($tooltip-text); + } + } @else { + %tooltip-display { + line-height: rem(16px); + font-size: rem(10px); + font-weight: 600; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/tree/_tree-component.scss b/projects/igniteui-angular/core/src/core/styles/components/tree/_tree-component.scss new file mode 100644 index 00000000000..1a6038f9792 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/tree/_tree-component.scss @@ -0,0 +1,77 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Marin Popov +@mixin component { + @include b(igx-tree) { + @extend %tree-display !optional; + $this: bem--selector-to-string(&); + + @include register-component( + $name: string.slice($this, 2, -1), + $deps: ( + igx-checkbox, + igx-icon, + ) + ); + } + + @include b(igx-tree-node) { + @extend %tree-node !optional; + + @include e(wrapper) { + @extend %node-wrapper !optional; + } + + // STATES START + @include e(wrapper, $m: selected) { + @extend %node-wrapper--selected !optional; + } + + @include e(wrapper, $m: active) { + @extend %node-wrapper--active !optional; + } + + @include e(wrapper, $mods: (active, selected)) { + @extend %node-wrapper--active-selected !optional; + } + + @include e(wrapper, $m: focused) { + @extend %node-wrapper--focused !optional; + } + + @include e(wrapper, $m: disabled) { + @extend %node-wrapper--disabled !optional; + } + // STATES END + + @include e(content) { + @extend %node-content !optional; + } + + @include e(spacer) { + @extend %node-spacer !optional; + } + + @include e(toggle-button) { + @extend %node-toggle-button !optional; + } + + @include e(toggle-button, $m: hidden) { + @extend %node-toggle-button--hidden !optional; + } + + @include e(drop-indicator) { + @extend %node-drop-indicator !optional; + } + + @include e(select) { + @extend %node-select !optional; + } + + @include e(group) { + @extend %node-group !optional; + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/tree/_tree-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/tree/_tree-theme.scss new file mode 100644 index 00000000000..6d4550ba331 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/tree/_tree-theme.scss @@ -0,0 +1,324 @@ +@use 'sass:map'; +@use '../../base' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin tree($theme) { + @include css-vars($theme); + + $variant: map.get($theme, '_meta', 'theme'); + $indigo-theme: $variant == 'indigo'; + + $node-indent-default: ( + comfortable: rem(24px), + cosy: rem(16px), + compact: rem(12px) + ); + + $node-indent-indigo: ( + comfortable: rem(16px), + cosy: rem(12px), + compact: rem(8px) + ); + + $icon-size-default: rem(24px); + $icon-space-default: rem(8px); + + $icon-size-indigo: rem(16px); + $icon-space-indigo: rem(4px); + + $node-indent: map.get(( + 'material': $node-indent-default, + 'fluent': $node-indent-default, + 'bootstrap': $node-indent-default, + 'indigo': $node-indent-indigo + ), $variant); + + $icon-size: map.get(( + 'material': $icon-size-default, + 'fluent': $icon-size-default, + 'bootstrap': $icon-size-default, + 'indigo': $icon-size-indigo + ), $variant); + + $icon-space: map.get(( + 'material': $icon-space-indigo, + 'fluent': $icon-space-default, + 'bootstrap': $icon-space-default, + 'indigo': $icon-space-indigo + ), $variant); + + $circular-bar-size: ( + comfortable: #{$icon-size}, + cosy: calc(#{$icon-size} - 4px), + compact: calc(#{$icon-size} - 6px) + ); + + $drop-indicator-width: ( + comfortable: calc(100% - ((#{map.get($node-indent, 'comfortable')} * 2) + (#{$icon-size} + #{$icon-space}))), + cosy: calc(100% - ((#{map.get($node-indent, 'cosy')} * 2) + (#{$icon-size} + #{$icon-space}))), + compact: calc(100% - ((#{map.get($node-indent, 'compact')} * 2) + (#{$icon-size} + #{$icon-space}))) + ); + + %tree-display { + display: block; + z-index: 0; + overflow-y: auto; + } + + %tree-node, + %node-wrapper, + %node-toggle-button, + %node-content, + %node-select { + display: flex; + } + + %tree-node { + @include sizable(); + + --component-size: var(--ig-size, #{var-get($theme, 'default-size')}); + --tree-size: var(--component-size); + flex-direction: column; + + %cbx-composite-wrapper { + @if $variant == 'material' { + padding: 0; + } + } + + @if $variant == 'indigo' { + border-radius: rem(4px); + } + } + + %node-wrapper, + %node-toggle-button, + %node-select { + align-items: center; + } + + %node-toggle-button, + %node-select { + margin-inline-end: $icon-space; + } + + @if $variant == 'material' { + %node-select { + margin-inline: rem(10px) rem(14px); + } + } + + @if $variant == 'indigo' { + %node-select { + margin-inline-end: rem(8px); + } + } + + %node-content, + %node-toggle-button, + %node-select { + z-index: 1; + } + + %node-toggle-button--hidden { + visibility: hidden; + } + + %node-wrapper { + min-height: var-get($theme, 'size'); + padding-inline: pad-inline(map.get($node-indent, 'compact'), map.get($node-indent, 'cosy'), map.get($node-indent, 'comfortable')); + padding-block: 0; + position: relative; + background: var-get($theme, 'background'); + color: var-get($theme, 'foreground'); + + @if $variant == 'indigo' { + border-radius: rem(4px); + margin-block-end: rem(4px); + + &::after { + border-radius: rem(4px); + } + + igx-icon { + color: var-get($theme, 'icon-color'); + } + } + + igx-icon { + --component-size: #{if($variant == 'indigo', 2, 3)}; + } + + [dir='rtl'] & { + igx-icon { + transform: scaleX(-1); + } + } + + &::after { + content: ''; + position: absolute; + top: 0; + inset-inline-start: 0; + width: 100%; + height: 100%; + background: transparent; + z-index: 0; + } + + &:hover { + &::after { + background: var-get($theme, 'hover-color'); + } + + @if $variant == 'indigo' { + color: var-get($theme, 'foreground-active'); + } + } + + &:focus { + outline-width: 0; + } + + igx-circular-bar { + --diameter: #{sizable(#{map.get($circular-bar-size, 'compact')}, #{map.get($circular-bar-size, 'cosy')}, #{map.get($circular-bar-size, 'comfortable')})}; + } + } + + %node-wrapper--selected { + background: var-get($theme, 'background-selected'); + color: var-get($theme, 'foreground-selected'); + + &:hover { + &::after { + background: var-get($theme, 'hover-selected-color'); + + @if $variant == 'indigo' { + background: var-get($theme, 'hover-color'); + } + } + } + } + + %node-wrapper--active { + background: var-get($theme, 'background-active'); + color: var-get($theme, 'foreground-active'); + + @if $variant == 'indigo' { + &:hover { + background: var-get($theme, 'hover-selected-color'); + + &::after { + background: transparent; + } + } + } + } + + %node-wrapper--active-selected { + background: var-get($theme, 'background-active-selected'); + color: var-get($theme, 'foreground-active-selected'); + + @if $variant == 'indigo' { + &:hover { + background: var-get($theme, 'hover-selected-color'); + } + + &::after { + background: transparent; + } + } + } + + %node-wrapper--focused { + box-shadow: inset 0 0 0 rem(1px) var-get($theme, 'border-color'); + + @if $variant == 'indigo' { + box-shadow: inset 0 0 0 rem(2px) var-get($theme, 'border-color'); + } + } + + %node-wrapper--disabled { + background: var-get($theme, 'background-disabled') !important; + color: var-get($theme, 'foreground-disabled') !important; + + @if $variant == 'indigo' { + igx-icon { + color: var-get($theme, 'foreground-disabled') !important; + } + } + + box-shadow: none !important; + + pointer-events: none; + + &::after { + display: none; + } + + %node-toggle-button { + color: var-get($theme, 'foreground-disabled') !important; + } + } + + %node-spacer { + --component-size: var(--tree-size); + --spacer: #{sizable(#{map.get($node-indent, 'compact')}, #{map.get($node-indent, 'cosy')}, #{map.get($node-indent, 'comfortable')})}; + + @if $variant == 'indigo' { + --spacer: #{$icon-size}; + } + + width: var(--spacer); + display: inline-block; + } + + %node-content { + display: block; + align-items: center; + flex: 1; + @include ellipsis(); + } + + %node-toggle-button { + justify-content: center; + cursor: pointer; + user-select: none; + min-width: $icon-size + } + + %node-drop-indicator { + display: flex; + visibility: hidden; + position: absolute; + inset-inline-end: pad(map.get($node-indent, 'compact'), map.get($node-indent, 'cosy'), map.get($node-indent, 'comfortable')); + bottom: 0; + width: pad(map.get($drop-indicator-width, 'compact'), map.get($drop-indicator-width, 'cosy'), map.get($drop-indicator-width, 'comfortable')); + + > div { + flex: 1; + height: rem(1px); + background: var-get($theme, 'drop-area-color'); + } + } + + %node-group { + overflow: hidden; + } +} + +/// Adds typography styles for the igx-tree component. +/// Uses the 'subtitle-1' category from the typographic scale. +/// @group typography +/// @param {Map} $categories [(label: 'subtitle-1')] - The categories from the typographic scale used for type styles. +@mixin tree-typography( + $categories: (label: 'body-2') +) { + $text: map.get($categories, 'label'); + + %node-content { + @include type-style($text) + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/watermark/_watermark-component.scss b/projects/igniteui-angular/core/src/core/styles/components/watermark/_watermark-component.scss new file mode 100644 index 00000000000..5df097ffba8 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/watermark/_watermark-component.scss @@ -0,0 +1,14 @@ +@use '../../base' as *; +@use 'sass:string'; + +/// @access private +/// @author Marin Popov +@mixin component { + @include b(igc-trial-watermark) { + $this: bem--selector-to-string(&); + @include register-component( + $name: string.slice($this, 2, -1), + $deps: () + ); + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/components/watermark/_watermark-theme.scss b/projects/igniteui-angular/core/src/core/styles/components/watermark/_watermark-theme.scss new file mode 100644 index 00000000000..774c2a07322 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/components/watermark/_watermark-theme.scss @@ -0,0 +1,13 @@ +@use 'sass:map'; +@use '../../base' as *; + +/// @deprecated Use the `css-vars` mixin instead. +/// @see {mixin} css-vars +/// @param {Map} $theme - The theme used to style the component. +@mixin watermark($theme) { + @include css-vars($theme, 'igc-trial-watermark'); + + igc-trial-watermark::part(link) { + font-family: var(--ig-font-family); + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/print/_index.scss b/projects/igniteui-angular/core/src/core/styles/print/_index.scss new file mode 100644 index 00000000000..21aca84c0d3 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/print/_index.scss @@ -0,0 +1,64 @@ +/// @access private +/// Defines printing styles for all components in the library. +@mixin layout { + @media print { + html, + body, + app-root { + min-height: 100vh; + min-width: 100vw; + margin: 0; + } + + app-root { + display: block; + } + + * { + -webkit-print-color-adjust: exact; + // Fix shadows if you print to PDF using chrome START + -webkit-filter: opacity(1); + filter: opacity(1); + // Fix shadows if you print to PDF using chrome END + print-color-adjust: exact; + text-shadow: none !important; + } + + a[href^='http']::after { + content: '[' attr(href) ']'; + color: blue; + } + + .igx-no-print { + display: none !important; + } + + // Forcing Grayscale Printing + .igx-bw-print { + // CSS3 filter, at the moment Webkit only. Prefix it for future implementations + -webkit-filter: grayscale(100%); + filter: grayscale(100%); /* future-proof */ + } + + igx-circular-bar, + igx-linear-bar, + .igx-carousel__indicators, + .igx-carousel__arrow--prev, + .igx-carousel__arrow--next, + .igx-ripple, + .igx-grid__tbody-scrollbar, + igx-switch__ripple, + igx-virtual-helper { + display: none !important; + } + + igx-grid { + .igx-grid-th__title, + .igx-grid__td-text { + white-space: unset !important; + text-overflow: initial !important; + overflow: visible !important; + } + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/spec/_functions.spec.scss b/projects/igniteui-angular/core/src/core/styles/spec/_functions.spec.scss new file mode 100644 index 00000000000..72d8534bdf8 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/spec/_functions.spec.scss @@ -0,0 +1,52 @@ +@use 'sass:map'; +@use 'sass:list'; +@use 'sass-true' as *; +@use '../base' as *; + +@include describe('Theme Module System') { + $components: ( + igx-checkbox: ( + 'usedBy': ( + igx-combo, + igx-grid + ) + ) + ) !global; + + @include it('should register a component') { + $result: ( + 'deps': (igx-drop-down), + 'usedBy': () + ); + + @include register-component($name: igx-test, $deps: (igx-drop-down)); + @include assert-equal(map.get($components, igx-test), $result); + } + + @include it('should build a dependency tree and register dependencies by extension') { + @include register-component($name: igx-drop-down, $deps: (igx-overlay)); + @include dependecy-tree($components); + + $usedBy: map.get($components, igx-overlay, 'usedBy'); + @include assert-equal(list.index($usedBy, igx-test) != null, true); + } + + @include it('should include theme if other themes depend on it') { + $excluded: (igx-checkbox, igx-combo); + $test: is-used( + $component: 'igx-checkbox', + $checklist: $excluded, + ); + @include assert-equal($test, true); + } + + @include it('should exclude theme if no themes depend on it') { + $excluded: (igx-checkbox, igx-grid, igx-combo); + $test: is-used( + $component: 'igx-checkbox', + $checklist: $excluded, + ); + + @include assert-equal($test, false); + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/spec/_index.scss b/projects/igniteui-angular/core/src/core/styles/spec/_index.scss new file mode 100644 index 00000000000..68664c41e60 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/spec/_index.scss @@ -0,0 +1,2 @@ +@use 'functions.spec' as *; +@use 'mixins.spec' as *; diff --git a/projects/igniteui-angular/core/src/core/styles/spec/_mixins.spec.scss b/projects/igniteui-angular/core/src/core/styles/spec/_mixins.spec.scss new file mode 100644 index 00000000000..aaea4ce6a31 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/spec/_mixins.spec.scss @@ -0,0 +1,37 @@ +@use 'sass:map'; +@use 'sass-true' as *; +@use '../base' as *; +@use '../components' as *; + +@include describe('Register Component') { + $components: () !default; + + @include it('should add new component inside components list') { + @include register-component('my-component'); + @include assert-true(map.has-key($components, 'my-component')); + } +} + +@include describe('Generate color class') { + @include it('should concatenate the name, variant, prefix, and suffix in the correct order.') { + $name: primary; + $variant: 500; + $prefix: igx; + $suffix: bg; + $selector: #{$prefix}-#{$name}-#{$variant}-#{$suffix}; + + @include assert() { + @include output() { + @include gen-color-class($name, $variant, $prefix, $suffix) { + content: 'test'; + } + } + + @include expect() { + .#{$selector}{ + content: 'test' + } + } + } + } +} diff --git a/projects/igniteui-angular/core/src/core/styles/spec/tests.mjs b/projects/igniteui-angular/core/src/core/styles/spec/tests.mjs new file mode 100644 index 00000000000..5fbaec9a349 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/spec/tests.mjs @@ -0,0 +1,8 @@ +import * as path from 'path'; +import * as sassTrue from 'sass-true'; +import { fileURLToPath } from "url"; +import {} from 'jasmine'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const file = path.join(__dirname, '_index.scss'); +sassTrue.runSass({ describe, it }, file, { loadPaths: ['node_modules'] }); diff --git a/projects/igniteui-angular/core/src/core/styles/themes/_core.scss b/projects/igniteui-angular/core/src/core/styles/themes/_core.scss new file mode 100644 index 00000000000..894b2b030e0 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/themes/_core.scss @@ -0,0 +1,212 @@ +//// +/// @group themes +/// @author Simeon Simeonoff +//// + +@use '../base'; + +// Used to configure color accessibility for charts +@use 'igniteui-theming/sass/color/functions' as color; +@use 'igniteui-theming/sass/themes' as theming; + +// Common components +@use '../components/_common/igx-control'; +@use '../components/_common/igx-display-container' as display-container; +@use '../components/_common/igx-drag' as drag; +@use '../components/_common/igx-vhelper' as vhelper; +@use '../components/ripple/ripple-component' as ripple; + +// Other components +@use '../components/action-strip/action-strip-component' as action-strip; +@use '../components/avatar/avatar-component' as avatar; +@use '../components/badge/badge-component' as badge; +@use '../components/bottom-nav/bottom-nav-component' as bottom-nav; +@use '../components/button/button-component' as button; +@use '../components/divider/divider-component' as divider; +@use '../components/button-group/button-group-component' as button-group; +@use '../components/banner/banner-component' as banner; +@use '../components/calendar/calendar-component' as calendar; +@use '../components/card/card-component' as card; +@use '../components/carousel/carousel-component' as carousel; +@use '../components/checkbox/checkbox-component' as checkbox; +@use '../components/chip/chip-component' as chip; +@use '../components/column-actions/column-actions-component' as column-actions; +@use '../components/combo/combo-component' as combo; +@use '../components/select/select-component' as select; +@use '../components/charts/category-chart-component' as category-chart; +@use '../components/charts/data-chart-component' as data-chart; +@use '../components/charts/doughnut-chart-component' as doughnut-chart; +@use '../components/charts/financial-chart-component' as financial-chart; +@use '../components/charts/funnel-chart-component' as funnel-chart; +@use '../components/charts/gauge-component' as gauge; +@use '../components/charts/geo-map-component' as geo-map; +@use '../components/charts/graph-component' as graph; +@use '../components/charts/pie-chart-component' as pie-chart; +@use '../components/charts/shape-chart-component' as shape-chart; +@use '../components/charts/sparkline-component' as sparkline; +@use '../components/date-picker/date-picker-component' as date-picker; +@use '../components/date-range-picker/date-range-picker-component' as date-range-picker; +@use '../components/dialog/dialog-component' as dialog; +@use '../components/dock-manager/dock-manager-component' as dock-manager; +@use '../components/rating/rating-component' as rating; +@use '../components/drop-down/drop-down-component' as drop-down; +@use '../components/expansion-panel/expansion-panel-component' as expansion-panel; +@use '../components/grid/grid-component' as grid; +@use '../components/grid/pivot-data-selector-component' as pivot-data-selector; +@use '../components/grid-summary/grid-summary-component' as grid-summary; +@use '../components/grid-toolbar/grid-toolbar-component' as grid-toolbar; +@use '../components/highlight/highlight-component' as highlight; +@use '../components/icon/icon-component' as icon; +@use '../components/icon-button/icon-button-component' as icon-button; +@use '../components/input/input-group-component' as input-group; +@use '../components/label/label-component' as label; +@use '../components/list/list-component' as list; +@use '../components/navbar/navbar-component' as navbar; +@use '../components/navdrawer/navdrawer-component' as navdrawer; +@use '../components/overlay/overlay-component' as overlay; +@use '../components/paginator/paginator-component' as paginator; +@use '../components/progress/linear/linear-component' as linear-progress; +@use '../components/progress/circular/circular-component' as circular-progress; +@use '../components/radio/radio-component' as radio; +@use '../components/query-builder/query-builder-component' as query-builder; +@use '../components/scrollbar/scrollbar-component' as scrollbar; +@use '../components/slider/slider-component' as slider; +@use '../components/splitter/splitter-component' as splitter; +@use '../components/snackbar/snackbar-component' as snackbar; +@use '../components/switch/switch-component' as switch; +@use '../components/stepper/stepper-component' as stepper; +@use '../components/tabs/tabs-component' as tabs; +@use '../components/toast/toast-component' as toast; +@use '../components/tooltip/tooltip-component' as tooltip; +@use '../components/time-picker/time-picker-component' as time-picker; +@use '../components/tree/tree-component' as tree; +@use '../components/watermark/watermark-component' as watermark; +@use '../components/input/file-input-component' as file-input; +@use '../print'; + +/// @param {boolean} $print-layout [true] - Activates the printing styles of the components. +/// @param {boolean} $enhanced-accesibility [false] - Switches component colors and other properties to more accessible values. +@mixin core( + $print-layout: true, + $enhanced-accessibility: false +) { + @include color.configure-colors($enhanced-accessibility); + + // Angular hack for binding to [hidden] property + // not working + [hidden] { + display: none !important; + } + + // Common styles + @include vhelper.component(); + @include display-container.component(); + @include drag.component(); + + // Includes the base for each theme. + [class^='igx-'], + [class^='ig-'] { + &, + *, + *::before, + *::after { + box-sizing: border-box; + } + } + + @include theming.spacing(); + + @property --_progress-integer { + syntax: ''; + initial-value: 0; + inherits: true; + } + + @property --_progress-fraction { + syntax: ''; + initial-value: 0; + inherits: true; + } + + @property --vhelper-scollbar-size { + syntax: ''; + initial-value: 16px; + inherits: true; + } + + // Component styles + @include ripple.component(); + @include action-strip.component(); + @include avatar.component(); + @include badge.component(); + @include banner.component(); + @include bottom-nav.component(); + @include button.component(); + @include button-group.component(); + @include divider.component(); + @include calendar.component(); + @include card.component(); + @include carousel.component(); + @include checkbox.component(); + @include chip.component(); + @include column-actions.component(); + @include combo.component(); + @include select.component(); + @include category-chart.component(); + @include data-chart.component(); + @include doughnut-chart.component(); + @include financial-chart.component(); + @include funnel-chart.component(); + @include gauge.component(); + @include geo-map.component(); + @include graph.component(); + @include pie-chart.component(); + @include shape-chart.component(); + @include sparkline.component(); + @include date-picker.component(); + @include date-range-picker.component(); + @include dialog.component(); + @include dock-manager.component(); + @include rating.component(); + @include drop-down.component(); + @include expansion-panel.component(); + @include grid.component(); + @include grid-summary.component(); + @include grid-toolbar.component(); + @include pivot-data-selector.component(); + @include highlight.component(); + @include icon.component(); + @include icon-button.component(); + @include input-group.component(); + @include label.component(); + @include list.component(); + @include navbar.component(); + @include navdrawer.component(); + @include overlay.component(); + @include paginator.component(); + @include linear-progress.component(); + @include circular-progress.component(); + @include radio.component(); + @include query-builder.component(); + @include scrollbar.component(); + @include slider.component(); + @include splitter.component(); + @include snackbar.component(); + @include switch.component(); + @include stepper.component(); + @include tabs.component(); + @include toast.component(); + @include tooltip.component(); + @include time-picker.component(); + @include tree.component(); + @include watermark.component(); + @include file-input.component(); + + // Build the component dependency-tree + @include base.dependecy-tree(base.$components); + + @if $print-layout == true { + @include print.layout(); + } +} + diff --git a/projects/igniteui-angular/core/src/core/styles/themes/_index.scss b/projects/igniteui-angular/core/src/core/styles/themes/_index.scss new file mode 100644 index 00000000000..fb14eb9b8fd --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/themes/_index.scss @@ -0,0 +1,8 @@ +@forward 'core'; +@forward 'generators'; +@forward 'utilities'; +@forward 'igniteui-theming/sass/color/presets'; +@forward 'igniteui-theming/sass/elevations/presets'; +@forward 'igniteui-theming/sass/typography/presets'; +@forward 'igniteui-theming/sass/animations'; +@forward 'igniteui-theming/sass/themes/components'; diff --git a/projects/igniteui-angular/core/src/core/styles/themes/_palettes.scss b/projects/igniteui-angular/core/src/core/styles/themes/_palettes.scss new file mode 100644 index 00000000000..1b76c04dd97 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/themes/_palettes.scss @@ -0,0 +1,88 @@ +@use 'igniteui-theming/sass/color/functions' as *; +@use 'igniteui-theming/sass/color/presets' as palettes; + +//// +/// @group palettes +/// @author Simeon Simeonoff +//// + +/// @access private +/// @deprecated +$default-palette: palettes.$light-material-palette; + +/// @access private +$default-dark-palette: palettes.$dark-material-palette; + +/// Same as $light-material-palette. +/// @type Map +/// @prop {Color} primary [#09f] - The 500 variant of the `primary` color (default). +/// @prop {Color} secondary [#e41c77] - The 500 variant of the `secondary` color (default). +/// @prop {Color} gray [#000] - The base color for the `gray` shades. +/// @prop {Color} surface [#fff] - The color used as a background in components, such as cards, sheets, and menus. +/// @prop {Color} info [#1377d5] - The `info` color. Default for every palette if not specified. +/// @prop {Color} success [#4eb862] - The `success` color. Default for every palette if not specified. +/// @prop {Color} warn [#fbb13c] - The `warn` color. Default for every palette if not specified. +/// @prop {Color} error [#ff134a] - The `error`. Default for every palette if not specified. +/// @access public +/// @group palettes +/// @deprecated +$light-palette: palettes.$light-material-palette; + +/// Same as $dark-material-palette but with modified gray and surface colors. +/// @type Map +/// @prop {Color} primary [#09f] - The 500 variant of the `primary` color (default). +/// @prop {Color} secondary [#e41c77] - The 500 variant of the `secondary` color (default). +/// @prop {Color} gray [#fff] - The base color for the `gray` shades. +/// @prop {Color} surface [#222] - The color used as a background in components, such as cards, sheets, and menus. +/// @prop {Color} info [#1377d5] - The `info` color. Default for every palette if not specified. +/// @prop {Color} success [#4eb862] - The `success` color. Default for every palette if not specified. +/// @prop {Color} warn [#fbb13c] - The `warn` color. Default for every palette if not specified. +/// @prop {Color} error [#ff134a] - The `error`. Default for every palette if not specified. +/// @access public +/// @group palettes +/// @deprecated +$dark-palette: palettes.$dark-material-palette; + +/// @access private +$green-palette: palettes.$light-green-palette; + +/// @access private +$green-dark-palette: palettes.$dark-green-palette; + +/// @access private +$purple-palette: palettes.$light-purple-palette; + +/// @access private +$purple-dark-palette: palettes.$dark-purple-palette; + +/// @access private +$fluent-excel-palette: palettes.$light-fluent-excel-palette; + +/// @access private +$fluent-excel-dark-palette: palettes.$dark-fluent-excel-palette; + +/// @access private +$fluent-word-palette: palettes.$light-fluent-word-palette; + +/// @access private +$fluent-word-dark-palette: palettes.$dark-fluent-word-palette; + +/// @access private +$fluent-palette: palettes.$light-fluent-palette; + +/// @access private +$fluent-dark-palette: palettes.$dark-fluent-palette; + +/// @access private +$bootstrap-palette: palettes.$light-bootstrap-palette; + +/// @access private +$bootstrap-dark-palette: palettes.$dark-bootstrap-palette; + +/// Global Overlay Color. +/// Assigned to the 500 variant of the gray color form the current palette. +/// @group palettes +/// @type Color +/// @access private +/// @deprecated +$overlay-color: color($light-palette, 'gray') !default; diff --git a/projects/igniteui-angular/core/src/core/styles/themes/_schemas.scss b/projects/igniteui-angular/core/src/core/styles/themes/_schemas.scss new file mode 100644 index 00000000000..fee5f3695c1 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/themes/_schemas.scss @@ -0,0 +1,5 @@ +@use 'igniteui-theming/sass/themes/schemas' as schemas; +@forward 'igniteui-theming/sass/themes/schemas'; + +$light-schema: schemas.$light-material-schema; +$dark-schema: schemas.$dark-material-schema; diff --git a/projects/igniteui-angular/core/src/core/styles/themes/_utilities.scss b/projects/igniteui-angular/core/src/core/styles/themes/_utilities.scss new file mode 100644 index 00000000000..d5936bfaa0a --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/themes/_utilities.scss @@ -0,0 +1,5 @@ +@forward '../base'; +@forward '../typography'; +@forward '../components'; +@forward 'schemas'; +@forward 'palettes'; diff --git a/projects/igniteui-angular/core/src/core/styles/themes/generators/_base.scss b/projects/igniteui-angular/core/src/core/styles/themes/generators/_base.scss new file mode 100644 index 00000000000..07df64e4335 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/themes/generators/_base.scss @@ -0,0 +1,815 @@ +@use 'sass:list'; +@use 'sass:map'; +@use 'sass:meta'; +@use 'sass:string'; +@use '../schemas' as *; +@use '../../base' as *; +@use '../../components' as *; +@use 'igniteui-theming/sass/color' as *; +@use 'igniteui-theming/sass/color/presets' as *; +@use 'igniteui-theming/sass/elevations' as *; +@use 'igniteui-theming/sass/elevations/presets' as elevations; +@use 'igniteui-theming/sass/utils' as *; +@use 'igniteui-theming/sass/themes' as *; +@use 'igniteui-theming/sass/themes/charts' as *; +@use 'igniteui-theming/sass/themes/schemas/components/light/button' as *; + +//// +/// @group themes +/// @access public +/// @author Simeon Simeonoff +//// + +/// Default noop handler for theme map transforms +/// @access private +@function theme-noop($arg) { + @return $arg; +} + +/// Generates an Ignite UI for Angular global theme. +/// @param {Map} $palette - An palette to be used by the global theme. +/// @param {Map} $schema [$light-material-schema] - The schema used as basis for styling the components. +/// @param {List} $exclude [( )] - A list of igx components to be excluded from the global theme styles. +/// @param {Number} $roundness [null] - Sets the global roundness factor (the value can be any decimal fraction between 0 and 1) for all components. +/// @param {Boolean} $elevation [true] - Turns on/off elevations for all components in the theme. +/// @param {Map} $elevations [$elevations] - The elevation map to be used by all component themes. +/// @requires $light-material-schema +/// @requires {function} is-component +/// @requires {function} is-used +@mixin theme( + $palette, + $schema: $light-material-schema, + $exclude: (), + $roundness: null, + $elevation: true, + $elevations: elevations.$material-elevations +) { + @include theme-internal( + $palette: $palette, + $schema: $schema, + $exclude: $exclude, + $roundness: $roundness, + $elevation: $elevation, + $elevations: $elevations + ); +} + +/// Generates an Ignite UI for Angular global theme. +/// @access private +/// @param {Map} $palette - An palette to be used by the global theme. +/// @param {Map} $schema [$light-schema] - The schema used as basis for styling the components. +/// @param {List} $exclude [( )] - A list of igx components to be excluded from the global theme styles. +/// @param {Number} $roundness [null] - Sets the global roundness factor (the value can be any decimal fraction between 0 and 1) for all components. +/// @param {Boolean} $elevation [true] - Turns on/off elevations for all components in the theme. +/// @param {Map} $elevations [$elevations] - The elevation map to be used by all component themes. +/// @requires $light-material-schema +/// @requires {function} is-component +/// @requires {function} is-used +@mixin theme-internal( + $palette, + $schema: $light-material-schema, + $exclude: (), + $roundness: null, + $elevation: true, + $elevations: elevations.$material-elevations, + $theme-handler: meta.get-function('theme-noop') +) { + // Stores all excluded component styles + $excluded: (); + $scope: if(is-root(), ':root', '&'); + $theme: map.get($schema, '_meta', 'theme'); + $variant: map.get($schema, '_meta', 'variant'); + + #{$scope} { + --ig-size-small: 1; + --ig-size-medium: 2; + --ig-size-large: 3; + } + + @if not(list.index($exclude, 'palette')) { + @include palette($palette); + } + + @if not(list.index($exclude, 'elevations')) { + @include elevations($elevations); + } + + @if $elevation == false { + #{$scope} { + --ig-elevation-factor: 0; + } + } + + @if $roundness { + #{$scope} { + --ig-radius-factor: #{$roundness}; + } + } + + @if $theme { + #{$scope} { + --ig-theme: #{$theme}; + --ig-theme-variant: #{$variant}; + } + } + + @if list.length($exclude) > 0 { + $excluded: is-component($exclude); + } + + @if is-used('igx-ripple', $exclude) { + $ripple-theme-map: ripple-theme( + $schema: $schema, + ); + $ripple-theme-map: meta.call($theme-handler, $ripple-theme-map); + @include ripple($ripple-theme-map); + } + + @if is-used('igx-avatar', $exclude) { + $avatar-theme-map: avatar-theme( + $schema: $schema, + ); + $avatar-theme-map: meta.call($theme-handler, $avatar-theme-map); + @include avatar($avatar-theme-map); + } + + @if is-used('igx-action-strip', $exclude) { + $action-strip-theme-map: action-strip-theme( + $schema: $schema, + ); + $action-strip-theme-map: meta.call( + $theme-handler, + $action-strip-theme-map + ); + @include action-strip($action-strip-theme-map); + } + + @if is-used('igx-badge', $exclude) { + $badge-theme-map: badge-theme( + $schema: $schema, + ); + $badge-theme-map: meta.call($theme-handler, $badge-theme-map); + @include badge($badge-theme-map); + } + + @if is-used('igx-bottom-nav', $exclude) { + $bottom-nav-theme-map: bottom-nav-theme( + $schema: $schema, + ); + $bottom-nav-theme-map: meta.call($theme-handler, $bottom-nav-theme-map); + @include bottom-nav($bottom-nav-theme-map); + } + + @if is-used('igx-button', $exclude) { + $flat-theme-map: flat-button-theme( + $schema: $schema, + ); + + $contained-theme-map: contained-button-theme( + $schema: $schema, + ); + + $outlined-theme-map: outlined-button-theme( + $schema: $schema, + ); + + $fab-theme-map: fab-button-theme( + $schema: $schema, + ); + + @include button( + $flat: meta.call($theme-handler, $flat-theme-map), + $contained: meta.call($theme-handler, $contained-theme-map), + $outlined: meta.call($theme-handler, $outlined-theme-map), + $fab:meta.call($theme-handler, $fab-theme-map) + ); + } + + @if is-used('igx-button-group', $exclude) { + $button-group-theme-map: button-group-theme( + $schema: $schema, + ); + $button-group-theme-map: meta.call( + $theme-handler, + $button-group-theme-map + ); + @include button-group($button-group-theme-map); + } + + @if is-used('igx-banner', $exclude) { + $banner-theme-map: banner-theme( + $schema: $schema, + ); + $banner-theme-map: meta.call($theme-handler, $banner-theme-map); + @include banner($banner-theme-map); + } + + @if is-used('igx-calendar', $exclude) { + $calendar-theme-map: calendar-theme( + $schema: $schema, + ); + $calendar-theme-map: meta.call($theme-handler, $calendar-theme-map); + @include calendar($calendar-theme-map); + } + + @if is-used('igx-card', $exclude) { + $card-theme-map: card-theme( + $schema: $schema, + ); + $card-theme-map: meta.call($theme-handler, $card-theme-map); + @include card($card-theme-map); + } + + @if is-used('igx-carousel', $exclude) { + $carousel-theme-map: carousel-theme( + $schema: $schema, + ); + $carousel-theme-map: meta.call($theme-handler, $carousel-theme-map); + @include carousel($carousel-theme-map); + } + + @if is-used('igx-splitter', $exclude) { + $splitter-theme-map: splitter-theme( + $schema: $schema, + ); + $splitter-theme-map: meta.call($theme-handler, $splitter-theme-map); + @include splitter($splitter-theme-map); + } + + @if is-used('data-chart', $exclude) { + $data-chart-theme-map: data-chart-theme( + $schema: $schema, + ); + $data-chart-theme-map: meta.call($theme-handler, $data-chart-theme-map); + @include css-vars($data-chart-theme-map); + } + + @if is-used('doughnut-chart', $exclude) { + $doughnut-chart-theme-map: doughnut-chart-theme( + $schema: $schema, + ); + $doughnut-chart-theme-map: meta.call( + $theme-handler, + $doughnut-chart-theme-map + ); + @include css-vars($doughnut-chart-theme-map); + } + + @if is-used('linear-gauge', $exclude) { + $linear-gauge-theme-map: linear-gauge-theme( + $schema: $schema, + ); + $linear-gauge-theme-map: meta.call( + $theme-handler, + $linear-gauge-theme-map + ); + @include css-vars($linear-gauge-theme-map); + } + + @if is-used('radial-gauge', $exclude) { + $radial-gauge-theme-map: radial-gauge-theme( + $schema: $schema, + ); + $radial-gauge-theme-map: meta.call( + $theme-handler, + $radial-gauge-theme-map + ); + @include css-vars($radial-gauge-theme-map); + } + + @if is-used('financial-chart', $exclude) { + $financial-chart-theme-map: financial-chart-theme( + $schema: $schema, + ); + $financial-chart-theme-map: meta.call( + $theme-handler, + $financial-chart-theme-map + ); + @include css-vars($financial-chart-theme-map); + } + + @if is-used('bullet-graph', $exclude) { + $bullet-graph-theme-map: bullet-graph-theme( + $schema: $schema, + ); + $bullet-graph-theme-map: meta.call( + $theme-handler, + $bullet-graph-theme-map + ); + @include css-vars($bullet-graph-theme-map); + } + + @if is-used('category-chart', $exclude) { + $category-chart-theme-map: category-chart-theme( + $schema: $schema, + ); + $category-chart-theme-map: meta.call( + $theme-handler, + $category-chart-theme-map + ); + @include css-vars($category-chart-theme-map); + } + + @if is-used('geo-map', $exclude) { + $geo-map-theme-map: geo-map-theme( + $schema: $schema, + ); + $geo-map-theme-map: meta.call($theme-handler, $geo-map-theme-map); + @include css-vars($geo-map-theme-map); + } + + @if is-used('pie-chart', $exclude) { + $pie-chart-theme-map: pie-chart-theme( + $schema: $schema, + ); + $pie-chart-theme-map: meta.call($theme-handler, $pie-chart-theme-map); + @include css-vars($pie-chart-theme-map); + } + + @if is-used('sparkline', $exclude) { + $sparkline-theme-map: sparkline-theme( + $schema: $schema, + ); + $sparkline-theme-map: meta.call($theme-handler, $sparkline-theme-map); + @include css-vars($sparkline-theme-map); + } + + @if is-used('funnel-chart', $exclude) { + $funnel-chart-theme-map: funnel-chart-theme( + $schema: $schema, + ); + $funnel-chart-theme-map: meta.call( + $theme-handler, + $funnel-chart-theme-map + ); + @include css-vars($funnel-chart-theme-map); + } + + @if is-used('shape-chart', $exclude) { + $shape-chart-theme-map: shape-chart-theme( + $schema: $schema, + ); + $shape-chart-theme-map: meta.call( + $theme-handler, + $shape-chart-theme-map + ); + @include css-vars($shape-chart-theme-map); + } + + @if is-used('igx-checkbox', $exclude) { + $checkbox-theme-map: checkbox-theme( + $schema: $schema, + ); + $checkbox-theme-map: meta.call($theme-handler, $checkbox-theme-map); + @include checkbox($checkbox-theme-map); + } + + @if is-used('igx-chip', $exclude) { + $chip-theme-map: chip-theme( + $schema: $schema, + ); + $chip-theme-map: meta.call($theme-handler, $chip-theme-map); + @include chip($chip-theme-map); + } + + @if is-used('igx-column-actions', $exclude) { + $column-actions-theme-map: column-actions-theme( + $schema: $schema, + ); + $column-actions-theme-map: meta.call( + $theme-handler, + $column-actions-theme-map + ); + @include column-actions($column-actions-theme-map); + } + + @if is-used('igx-combo', $exclude) { + $combo-theme-map: combo-theme( + $schema: $schema, + ); + $combo-theme-map: meta.call($theme-handler, $combo-theme-map); + @include combo($combo-theme-map); + } + + @if is-used('igx-select', $exclude) { + $select-theme-map: select-theme( + $schema: $schema, + ); + $select-theme-map: meta.call($theme-handler, $select-theme-map); + @include select($select-theme-map); + } + + @if is-used('igx-date-picker', $exclude) { + $calendar-theme-map: calendar-theme( + $schema: $schema, + ); + $calendar-theme-map: meta.call($theme-handler, $calendar-theme-map); + @include date-picker($calendar-theme-map); + } + + @if is-used('igx-date-range-picker', $exclude) { + $date-range-picker-theme-map: date-range-picker-theme( + $schema: $schema, + ); + $date-range-picker-theme-map: meta.call( + $theme-handler, + $date-range-picker-theme-map + ); + @include date-range-picker($date-range-picker-theme-map); + } + + @if is-used('igx-dialog', $exclude) { + $dialog-theme-map: dialog-theme( + $schema: $schema, + ); + $dialog-theme-map: meta.call($theme-handler, $dialog-theme-map); + @include dialog($dialog-theme-map); + } + + @if is-used('igx-divider', $exclude) { + $divider-theme-map: divider-theme( + $schema: $schema, + ); + $divider-theme-map: meta.call($theme-handler, $divider-theme-map); + @include divider($divider-theme-map); + } + + @if is-used('igc-dockmanager', $exclude) { + $dock-manager-theme-map: dock-manager-theme( + $schema: $schema, + ); + $dock-manager-theme-map: meta.call( + $theme-handler, + $dock-manager-theme-map + ); + @include dock-manager($dock-manager-theme-map); + } + + @if is-used('igc-rating', $exclude) { + $rating-theme-map: rating-theme( + $schema: $schema, + ); + $rating-theme-map: meta.call($theme-handler, $rating-theme-map); + @include rating($rating-theme-map); + } + + @if is-used('igx-drop-down', $exclude) { + $drop-down-theme-map: drop-down-theme( + $schema: $schema, + ); + $drop-down-theme-map: meta.call($theme-handler, $drop-down-theme-map); + @include drop-down($drop-down-theme-map); + } + + @if is-used('igx-expansion-panel', $exclude) { + $expansion-panel-theme-map: expansion-panel-theme( + $schema: $schema, + ); + $expansion-panel-theme-map: meta.call( + $theme-handler, + $expansion-panel-theme-map + ); + @include expansion-panel($expansion-panel-theme-map); + } + + @if is-used('igx-grid', $exclude) { + $grid-theme-map: grid-theme( + $schema: $schema, + ); + $grid-theme-map: meta.call($theme-handler, $grid-theme-map); + @include grid($grid-theme-map); + } + + @if is-used('igx-grid-summary', $exclude) { + $grid-summary-theme-map: grid-summary-theme( + $schema: $schema, + ); + $grid-summary-theme-map: meta.call( + $theme-handler, + $grid-summary-theme-map + ); + @include grid-summary($grid-summary-theme-map); + } + + @if is-used('igx-grid-toolbar', $exclude) { + $grid-toolbar-theme-map: grid-toolbar-theme( + $schema: $schema, + ); + $grid-toolbar-theme-map: meta.call( + $theme-handler, + $grid-toolbar-theme-map + ); + @include grid-toolbar($grid-toolbar-theme-map); + } + + @if is-used('igx-pivot-data-selector', $exclude) { + $pivot-data-selector-theme-map: pivot-data-selector-theme( + $schema: $schema, + ); + $pivot-data-selector-theme-map: meta.call( + $theme-handler, + $pivot-data-selector-theme-map + ); + @include pivot-data-selector($pivot-data-selector-theme-map); + } + + @if is-used('igx-highlight', $exclude) { + $highlight-theme-map: highlight-theme( + $schema: $schema, + ); + $highlight-theme-map: meta.call($theme-handler, $highlight-theme-map); + @include highlight($highlight-theme-map); + } + + @if is-used('igx-icon', $exclude) { + $icon-theme-map: icon-theme( + $schema: $schema, + ); + $icon-theme-map: meta.call($theme-handler, $icon-theme-map); + @include icon($icon-theme-map); + } + + @if is-used('igx-icon-button', $exclude) { + $flat-theme-map: flat-icon-button-theme( + $schema: $schema, + ); + + $contained-theme-map: contained-icon-button-theme( + $schema: $schema, + ); + + $outlined-theme-map: outlined-icon-button-theme( + $schema: $schema, + ); + + @include icon-button( + $flat: meta.call($theme-handler, $flat-theme-map), + $contained: meta.call($theme-handler, $contained-theme-map), + $outlined: meta.call($theme-handler, $outlined-theme-map), + ); + } + + @if is-used('igx-input-group', $exclude) { + $input-group-theme-map: input-group-theme( + $schema: $schema, + ); + $input-group-theme-map: meta.call( + $theme-handler, + $input-group-theme-map + ); + @include input-group($input-group-theme-map); + } + + @if is-used('igx-file-input', $exclude) { + $file-input-theme-map: file-input-theme( + $schema: $schema, + ); + $file-input-theme-map: meta.call( + $theme-handler, + $file-input-theme-map + ); + @include file-input($file-input-theme-map); + } + + @if is-used('igx-list', $exclude) { + $list-theme-map: list-theme( + $schema: $schema, + ); + $list-theme-map: meta.call($theme-handler, $list-theme-map); + @include list($list-theme-map); + } + + @if is-used('igx-label', $exclude) { + $label-theme-map: label-theme( + $schema: $schema, + ); + $label-theme-map: meta.call($theme-handler, $label-theme-map); + @include label($label-theme-map); + } + + @if is-used('igx-navbar', $exclude) { + $navbar-theme-map: navbar-theme( + $schema: $schema, + ); + $navbar-theme-map: meta.call($theme-handler, $navbar-theme-map); + @include navbar($navbar-theme-map); + } + + @if is-used('igx-nav-drawer', $exclude) { + $navdrawer-theme-map: navdrawer-theme( + $schema: $schema, + ); + $navdrawer-theme-map: meta.call($theme-handler, $navdrawer-theme-map); + @include navdrawer($navdrawer-theme-map); + } + + @if is-used('igx-overlay', $exclude) { + $overlay-theme-map: overlay-theme( + $schema: $schema, + ); + $overlay-theme-map: meta.call($theme-handler, $overlay-theme-map); + @include overlay($overlay-theme-map); + } + + @if is-used('igx-paginator', $exclude) { + $paginator-theme-map: paginator-theme( + $schema: $schema, + ); + $paginator-theme-map: meta.call($theme-handler, $paginator-theme-map); + @include paginator($paginator-theme-map); + } + + @if is-used('igx-circular-bar', $exclude) { + $progress-circular-theme-map: progress-circular-theme( + $schema: $schema, + ); + $progress-circular-theme-map: meta.call( + $theme-handler, + $progress-circular-theme-map + ); + @include progress-circular($progress-circular-theme-map); + } + + @if is-used('igx-linear-bar', $exclude) { + $progress-linear-theme-map: progress-linear-theme( + $schema: $schema, + ); + $progress-linear-theme-map: meta.call( + $theme-handler, + $progress-linear-theme-map + ); + @include progress-linear($progress-linear-theme-map); + } + + @if is-used('igx-radio', $exclude) { + $radio-theme-map: radio-theme( + $schema: $schema, + ); + $radio-theme-map: meta.call($theme-handler, $radio-theme-map); + @include radio($radio-theme-map); + } + + @if is-used('igx-query-builder', $exclude) { + $query-builder-theme-map: query-builder-theme( + $schema: $schema, + ); + $query-builder-theme-map: meta.call( + $theme-handler, + $query-builder-theme-map + ); + @include query-builder($query-builder-theme-map); + } + + @if is-used('ig-scrollbar', $exclude) { + $scrollbar-theme-map: scrollbar-theme( + $schema: $schema, + ); + $scrollbar-theme-map: meta.call($theme-handler, $scrollbar-theme-map); + @include scrollbar($scrollbar-theme-map); + } + + @if is-used('igx-slider', $exclude) { + $slider-theme-map: slider-theme( + $schema: $schema, + ); + $slider-theme-map: meta.call($theme-handler, $slider-theme-map); + @include slider($slider-theme-map); + } + + @if is-used('igx-snackbar', $exclude) { + $snackbar-theme-map: snackbar-theme( + $schema: $schema, + ); + $snackbar-theme-map: meta.call($theme-handler, $snackbar-theme-map); + @include snackbar($snackbar-theme-map); + } + + @if is-used('igx-switch', $exclude) { + $switch-theme-map: switch-theme( + $schema: $schema, + ); + $switch-theme-map: meta.call($theme-handler, $switch-theme-map); + @include switch($switch-theme-map); + } + + @if is-used('igx-tabs', $exclude) { + $tabs-theme-map: tabs-theme( + $schema: $schema, + ); + $tabs-theme-map: meta.call($theme-handler, $tabs-theme-map); + @include tabs($tabs-theme-map); + } + + @if is-used('igx-stepper', $exclude) { + $stepper-theme-map: stepper-theme( + $schema: $schema, + ); + $stepper-theme-map: meta.call($theme-handler, $stepper-theme-map); + @include stepper($stepper-theme-map); + } + + @if is-used('igx-toast', $exclude) { + $toast-theme-map: toast-theme( + $schema: $schema, + ); + $toast-theme-map: meta.call($theme-handler, $toast-theme-map); + @include toast($toast-theme-map); + } + + @if is-used('igx-tooltip', $exclude) { + $tooltip-theme-map: tooltip-theme( + $schema: $schema, + ); + $tooltip-theme-map: meta.call($theme-handler, $tooltip-theme-map); + @include tooltip($tooltip-theme-map); + } + + @if is-used('igx-time-picker', $exclude) { + $time-picker-theme-map: time-picker-theme( + $schema: $schema, + ); + $time-picker-theme-map: meta.call( + $theme-handler, + $time-picker-theme-map + ); + @include time-picker($time-picker-theme-map); + } + + @if is-used('igx-tree', $exclude) { + $tree-theme-map: tree-theme( + $schema: $schema, + ); + $tree-theme-map: meta.call($theme-handler, $tree-theme-map); + @include tree($tree-theme-map); + } + + @if is-used('igc-trial-watermark', $exclude) { + $watermark-theme-map: watermark-theme( + $schema: $schema, + ); + $watermark-theme-map: meta.call($theme-handler, $watermark-theme-map); + @include watermark($watermark-theme-map); + } + + @if list.length($dropped-themes) > 0 { + @warn string.unquote('You have excluded the following components from the theme: "#{$dropped-themes}".'); + } +} + +/// A wrapper around the theme mixin. Creates a global material theme that can be used with light backgrounds. +/// @param {Map} $palette - An palette to be used by the global theme. +/// @param {List} $exclude [( )] - A list of ig components to be excluded from the global theme styles. +/// @see {mixin} theme +/// @deprecated - Use the theme mixin instead. +@mixin light-theme($palette, $exclude: (), $roundness: null, $elevation: true) { + $gray: color($palette, gray); + $surface: color($palette, surface); + + $_light-palette: palette( + $primary: color($palette, primary), + $secondary: color($palette, secondary), + $info: color($palette, info), + $success: color($palette, success), + $warn: color($palette, warn), + $error: color($palette, error), + $surface: if($surface != #fff, $surface, #fff), + $gray: if($gray != #9e9e9e, $gray, #000), + ); + + @include theme( + $palette: $_light-palette, + $schema: $light-material-schema, + $exclude: $exclude, + $roundness: $roundness, + $elevation: $elevation + ); +} + +/// A wrapper around the theme mixin. Creates a global material theme that can be used with dark backgrounds. +/// @param {Map} $palette - An palette to be used by the global theme. +/// @param {List} $exclude [( )] - A list of igx components to be excluded from the global theme styles. +/// @see {mixin} theme +/// @deprecated - Use the theme mixin instead. +@mixin dark-theme($palette, $exclude: (), $roundness: null, $elevation: true) { + $gray: color($palette, gray); + $surface: color($palette, surface); + + $_dark-palette: palette( + $primary: color($palette, primary), + $secondary: color($palette, secondary), + $info: color($palette, info), + $success: color($palette, success), + $warn: color($palette, warn), + $error: color($palette, error), + $surface: if($surface != #fff, $surface, #222), + $gray: if($gray != #9e9e9e, $gray, #fff), + ); + + @include theme( + $palette: $_dark-palette, + $schema: $dark-material-schema, + $exclude: $exclude, + $roundness: $roundness, + $elevation: $elevation + ); +} diff --git a/projects/igniteui-angular/core/src/core/styles/themes/generators/_bootstrap.scss b/projects/igniteui-angular/core/src/core/styles/themes/generators/_bootstrap.scss new file mode 100644 index 00000000000..b2d67fc8ad5 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/themes/generators/_bootstrap.scss @@ -0,0 +1,86 @@ +@use 'base' as *; +@use '../schemas' as *; +@use 'igniteui-theming/sass/color' as *; +@use 'igniteui-theming/sass/color/presets' as *; + +//// +/// @group themes +/// @access public +/// @author Simeon Simeonoff +//// + +/// A wrapper around the theme mixin. Creates a global bootstrap-like theme that can be used with light backgrounds. +/// @param {Map} $palette - An palette to be used by the global theme. +/// @param {List} $exclude [( )] - A list of ig components to be excluded from the global theme styles. +/// @see {mixin} theme +/// @deprecated - Use the theme mixin instead. +@mixin bootstrap-light-theme( + $palette, + $exclude: (), + $roundness: null, + $elevation: true, +) { + $primary: color($palette, primary); + $secondary: color($palette, secondary); + $gray: color($light-bootstrap-palette, gray); + $surface: color($light-bootstrap-palette, surface); + $info: color($palette, info); + $success: color($palette, success); + $warn: color($palette, warn); + $error: color($palette, error); + + @include theme( + $palette: palette( + $primary, + $secondary, + $surface: $surface, + $gray: $gray, + $info: $info, + $success: $success, + $warn: $warn, + $error: $error + ), + $schema: $light-bootstrap-schema, + $exclude: $exclude, + $roundness: $roundness, + $elevation: $elevation, + ); +} + +/// A wrapper around the theme mixin. Creates a global bootstrap-like theme that can be used with dark backgrounds. +/// @param {Map} $palette - An palette to be used by the global theme. +/// @param {List} $exclude [( )] - A list of ig components to be excluded from the global theme styles. +/// @see {mixin} theme +/// @deprecated - Use the theme mixin instead. +@mixin bootstrap-dark-theme( + $palette, + $exclude: (), + $roundness: null, + $elevation: true, +) { + $primary: color($palette, primary); + $secondary: color($palette, secondary); + $gray: color($dark-bootstrap-palette, gray); + $surface: color($dark-bootstrap-palette, surface); + $info: color($palette, info); + $success: color($palette, success); + $warn: color($palette, warn); + $error: color($palette, error); + + @include theme( + $palette: palette( + $primary, + $secondary, + $surface: $surface, + $gray: $gray, + $info: $info, + $success: $success, + $warn: $warn, + $error: $error + ), + $schema: $dark-bootstrap-schema, + $exclude: $exclude, + $roundness: $roundness, + $elevation: $elevation, + ); +} diff --git a/projects/igniteui-angular/core/src/core/styles/themes/generators/_fluent.scss b/projects/igniteui-angular/core/src/core/styles/themes/generators/_fluent.scss new file mode 100644 index 00000000000..939ac05bd41 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/themes/generators/_fluent.scss @@ -0,0 +1,86 @@ +@use 'base' as *; +@use '../schemas' as *; +@use 'igniteui-theming/sass/color' as *; +@use 'igniteui-theming/sass/color/presets' as *; + +//// +/// @group themes +/// @access public +/// @author Simeon Simeonoff +//// + +/// A wrapper around the theme mixin. Creates a global fluent theme that can be used with light backgrounds. +/// @param {Map} $palette - An palette to be used by the global theme. +/// @param {List} $exclude [( )] - A list of ig components to be excluded from the global theme styles. +/// @see {mixin} theme +/// @deprecated - Use the theme mixin instead. +@mixin fluent-light-theme( + $palette, + $exclude: (), + $roundness: null, + $elevation: true, +) { + $primary: color($palette, primary); + $secondary: color($palette, secondary); + $gray: color($light-fluent-palette, gray); + $surface: color($light-fluent-palette, surface); + $info: color($palette, info); + $success: color($palette, success); + $warn: color($palette, warn); + $error: color($palette, error); + + @include theme( + $palette: palette( + $primary, + $secondary, + $surface: $surface, + $gray: $gray, + $info: $info, + $success: $success, + $warn: $warn, + $error: $error + ), + $schema: $light-fluent-schema, + $exclude: $exclude, + $roundness: $roundness, + $elevation: $elevation, + ); +} + +/// A wrapper around the theme mixin. Creates a global fluent theme that can be used with dark backgrounds. +/// @param {Map} $palette - An palette to be used by the global theme. +/// @param {List} $exclude [( )] - A list of ig components to be excluded from the global theme styles. +/// @see {mixin} theme +/// @deprecated - Use the theme mixin instead. +@mixin fluent-dark-theme( + $palette, + $exclude: (), + $roundness: null, + $elevation: true, +) { + $primary: color($palette, primary); + $secondary: color($palette, secondary); + $gray: color($dark-fluent-palette, gray); + $surface: color($dark-fluent-palette, surface); + $info: color($palette, info); + $success: color($palette, success); + $warn: color($palette, warn); + $error: color($palette, error); + + @include theme( + $palette: palette( + $primary, + $secondary, + $surface: $surface, + $gray: $gray, + $info: $info, + $success: $success, + $warn: $warn, + $error: $error + ), + $schema: $dark-fluent-schema, + $exclude: $exclude, + $roundness: $roundness, + $elevation: $elevation, + ); +} diff --git a/projects/igniteui-angular/core/src/core/styles/themes/generators/_index.scss b/projects/igniteui-angular/core/src/core/styles/themes/generators/_index.scss new file mode 100644 index 00000000000..2572c946726 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/themes/generators/_index.scss @@ -0,0 +1,4 @@ +@forward 'base'; +@forward 'bootstrap'; +@forward 'fluent'; +@forward 'indigo'; diff --git a/projects/igniteui-angular/core/src/core/styles/themes/generators/_indigo.scss b/projects/igniteui-angular/core/src/core/styles/themes/generators/_indigo.scss new file mode 100644 index 00000000000..db86ea6464a --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/themes/generators/_indigo.scss @@ -0,0 +1,89 @@ +@use 'base' as *; +@use '../schemas' as *; +@use 'igniteui-theming/sass/color' as *; +@use 'igniteui-theming/sass/color/presets' as *; +@use 'igniteui-theming/sass/elevations/presets' as elevations; + +//// +/// @group themes +/// @access public +/// @author Simeon Simeonoff +//// + +/// A wrapper around the theme mixin. Creates a global indigo theme that can be used with light backgrounds. +/// @param {Map} $palette - An palette to be used by the global theme. +/// @param {List} $exclude [( )] - A list of ig components to be excluded from the global theme styles. +/// @see {mixin} theme +/// @deprecated - Use the theme mixin instead. +@mixin indigo-light-theme( + $palette, + $exclude: (), + $roundness: null, + $elevation: true, +) { + $primary: color($palette, primary); + $secondary: color($palette, secondary); + $gray: color($light-indigo-palette, gray); + $surface: color($light-indigo-palette, surface); + $info: color($palette, info); + $success: color($palette, success); + $warn: color($palette, warn); + $error: color($palette, error); + + @include theme( + $palette: palette( + $primary, + $secondary, + $surface: $surface, + $gray: $gray, + $info: $info, + $success: $success, + $warn: $warn, + $error: $error + ), + $schema: $light-indigo-schema, + $exclude: $exclude, + $roundness: $roundness, + $elevation: $elevation, + $elevations: elevations.$indigo-elevations, + ); +} + +/// A wrapper around the theme mixin. Creates a global indigo theme that can be used with dark backgrounds. +/// @param {Map} $palette - An palette to be used by the global theme. +/// @param {List} $exclude [( )] - A list of ig components to be excluded from the global theme styles. +/// @see {mixin} theme +/// @deprecated - Use the theme mixin instead. +@mixin indigo-dark-theme( + $palette, + $exclude: (), + $roundness: null, + $elevation: true, +) { + $primary: color($palette, primary); + $secondary: color($palette, secondary); + $gray: color($dark-indigo-palette, gray); + $surface: color($dark-indigo-palette, surface); + $info: color($palette, info); + $success: color($palette, success); + $warn: color($palette, warn); + $error: color($palette, error); + + @include theme( + $palette: palette( + $primary, + $secondary, + $surface: $surface, + $gray: $gray, + $info: $info, + $success: $success, + $warn: $warn, + $error: $error + ), + $schema: $dark-indigo-schema, + $exclude: $exclude, + $roundness: $roundness, + $elevation: $elevation, + $elevations: elevations.$indigo-elevations, + ); +} diff --git a/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-angular-dark.scss b/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-angular-dark.scss new file mode 100644 index 00000000000..1abcc93a326 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-angular-dark.scss @@ -0,0 +1,11 @@ +@use '../../themes' as *; + +@include core(); +@include typography( + $font-family: $material-typeface, + $type-scale: $material-type-scale +); +@include theme( + $schema: $dark-material-schema, + $palette: $dark-material-palette, +); diff --git a/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-angular.scss b/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-angular.scss new file mode 100644 index 00000000000..06c50496586 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-angular.scss @@ -0,0 +1,11 @@ +@use '../../themes' as *; + +@include core(); +@include typography( + $font-family: $material-typeface, + $type-scale: $material-type-scale +); +@include theme( + $schema: $light-material-schema, + $palette: $light-material-palette, +); diff --git a/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-bootstrap-dark.scss b/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-bootstrap-dark.scss new file mode 100644 index 00000000000..d00f518d41b --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-bootstrap-dark.scss @@ -0,0 +1,11 @@ +@use '../../themes' as *; + +@include core(); +@include typography( + $font-family: $bootstrap-typeface, + $type-scale: $bootstrap-type-scale +); +@include theme( + $schema: $dark-bootstrap-schema, + $palette: $dark-bootstrap-palette, +); diff --git a/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-bootstrap-light.scss b/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-bootstrap-light.scss new file mode 100644 index 00000000000..ac40cce40ff --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-bootstrap-light.scss @@ -0,0 +1,11 @@ +@use '../../themes' as *; + +@include core(); +@include typography( + $font-family: $bootstrap-typeface, + $type-scale: $bootstrap-type-scale +); +@include theme( + $schema: $light-bootstrap-schema, + $palette: $light-bootstrap-palette, +); diff --git a/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-dark-green.scss b/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-dark-green.scss new file mode 100644 index 00000000000..c14a1f0d6bd --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-dark-green.scss @@ -0,0 +1,11 @@ +@use '../../themes' as *; + +@include core(); +@include typography( + $font-family: $material-typeface, + $type-scale: $material-type-scale +); +@include theme( + $schema: $dark-material-schema, + $palette: $dark-green-palette, +); diff --git a/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-fluent-dark-excel.scss b/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-fluent-dark-excel.scss new file mode 100644 index 00000000000..c9f6d6829bf --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-fluent-dark-excel.scss @@ -0,0 +1,11 @@ +@use '../../themes' as *; + +@include core(); +@include typography( + $font-family: $fluent-typeface, + $type-scale: $fluent-type-scale +); +@include theme( + $schema: $dark-fluent-schema, + $palette: $dark-fluent-excel-palette, +); diff --git a/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-fluent-dark-word.scss b/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-fluent-dark-word.scss new file mode 100644 index 00000000000..6acaabd7053 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-fluent-dark-word.scss @@ -0,0 +1,11 @@ +@use '../../themes' as *; + +@include core(); +@include typography( + $font-family: $fluent-typeface, + $type-scale: $fluent-type-scale +); +@include theme( + $schema: $dark-fluent-schema, + $palette: $dark-fluent-word-palette, +); diff --git a/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-fluent-dark.scss b/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-fluent-dark.scss new file mode 100644 index 00000000000..99e9039cff4 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-fluent-dark.scss @@ -0,0 +1,11 @@ +@use '../../themes' as *; + +@include core(); +@include typography( + $font-family: $fluent-typeface, + $type-scale: $fluent-type-scale +); +@include theme( + $schema: $dark-fluent-schema, + $palette: $dark-fluent-palette, +); diff --git a/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-fluent-light-excel.scss b/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-fluent-light-excel.scss new file mode 100644 index 00000000000..f5ac2ea5987 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-fluent-light-excel.scss @@ -0,0 +1,11 @@ +@use '../../themes' as *; + +@include core(); +@include typography( + $font-family: $fluent-typeface, + $type-scale: $fluent-type-scale +); +@include theme( + $schema: $light-fluent-schema, + $palette: $light-fluent-excel-palette, +); diff --git a/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-fluent-light-word.scss b/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-fluent-light-word.scss new file mode 100644 index 00000000000..d8eb01368ed --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-fluent-light-word.scss @@ -0,0 +1,11 @@ +@use '../../themes' as *; + +@include core(); +@include typography( + $font-family: $fluent-typeface, + $type-scale: $fluent-type-scale +); +@include theme( + $schema: $light-fluent-schema, + $palette: $light-fluent-word-palette, +); diff --git a/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-fluent-light.scss b/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-fluent-light.scss new file mode 100644 index 00000000000..794c94c6cad --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-fluent-light.scss @@ -0,0 +1,11 @@ +@use '../../themes' as *; + +@include core(); +@include typography( + $font-family: $fluent-typeface, + $type-scale: $fluent-type-scale +); +@include theme( + $schema: $light-fluent-schema, + $palette: $light-fluent-palette, +); diff --git a/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-indigo-dark.scss b/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-indigo-dark.scss new file mode 100644 index 00000000000..9090d4234c2 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-indigo-dark.scss @@ -0,0 +1,11 @@ +@use '../../themes' as *; + +@include core(); +@include typography( + $font-family: $indigo-typeface, + $type-scale: $indigo-type-scale +); +@include theme( + $schema: $dark-indigo-schema, + $palette: $dark-indigo-palette, +); diff --git a/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-indigo-light.scss b/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-indigo-light.scss new file mode 100644 index 00000000000..c1a770634e9 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/themes/presets/igniteui-indigo-light.scss @@ -0,0 +1,11 @@ +@use '../../themes' as *; + +@include core(); +@include typography( + $font-family: $indigo-typeface, + $type-scale: $indigo-type-scale +); +@include theme( + $schema: $light-indigo-schema, + $palette: $light-indigo-palette, +); diff --git a/projects/igniteui-angular/core/src/core/styles/typography/README.md b/projects/igniteui-angular/core/src/core/styles/typography/README.md new file mode 100644 index 00000000000..31587db49a5 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/typography/README.md @@ -0,0 +1,189 @@ +# Ignite UI for Angular Typography + +## Anatomy of the Typography in Ignite UI for Angular + +Ignite UI for Angular follows [The Type System](https://material.io/design/typography/the-type-system.html#) as described in the Material Design specification. The type system is a ***type scale*** consisting of ***13 different category type styles*** used across most components. All of the scale categories are completely reusable and adjustable by the end user. + +Here's a list of all 13 category styles as defined in Ignite UI for Angular: +- h1 +- h2 +- h3 +- h4 +- h5 +- h6 +- subtitle-1 +- subtitle-2 +- body-1 +- body-2 +- button +- caption +- overline + +An application can define multiple `scales` that may share scale categories with each other. A `scale category` is a set of `type styles`, containing information about `font-family`, `font-size`, `font-weight`, `line-height`, `letter-spacing`, and `text-transform`. + +Ignite UI for Angular defines a `$default-type-scale`, which is in turn used by the `typography` mixin to set the initial typography styles. The user can, however, pass a different type scale to be used by `typography` mixin. + +## Usage +By default we don't apply any typography styling. To use our typography in your application you have to set the `ig-typography` CSS class on a top-level element. All of its children will then use our typography styles. + +We have selected [Titillium Web](https://fonts.google.com/selection?selection.family=Titillium+Web:300,400,600,700) to be the default font for Ignite UI for Angular. To use it you have to host it yourself, or include it from Google Fonts: + +```html + +``` + +There are a several mixins and functions that are used to set and retrieve category styles to/from a type scale. Those are: + +- `type-style` [function] - Returns a set of style rules to be used by a type scale category. +- `type-scale` [function] - Returns a set of 13 style categories. +- `type-scale-category` [function] - Returns a map of style rules from a type scale and category. +- `type-style` [mixin] - Adds style rules to a selector from a specific type scale and category. +- `typography` [mixin] - Defines the global application typography styles. + + +Let's take a close look at what each one of the aforementioned mixins and functions do. + +### The type style +The `type-style` function is an interface-like function that simply ensures that certain arguments are passed as part of the style set for a scale category. Say, for instance, that we want to define a new set of style rules for the `h1` scale category. To do so, we would simply write: + +```scss +$h1-style: type-style( + $font-size: 112px, + $font-weight: 600, + $line-height: 96px +); +``` + +** Note that any properties that you do not pass, such as `$font-family`, `letter-spacing`, etc. will be automatically replaced with the default values as specified in the `$default-type-scale` for the category you want to use your style for. + + +### The type scale + +The type scale is a map of type styles for all 13 scale categories. To generate a new type map all you have to do is write. + +```scss +$my-type-scale: type-scale(); +``` + +This will produce a map, which is exactly the same as the `$default-scale-map` that the `typography` mixin uses by default. + +We can use the `$h1-style` we defined in our previous example to produce a slightly modified type scale. + +```scss +$my-type-scale: type-scale($h1: $h1-style); +``` + +Now `$my-type-scale` will store a modified type scale containing the modifications we specified for the `h1` category scale. + +** Note: You can modify as many of the 13 category scales as you want by passing type styles for each one of them. + +### The typography mixin + +The typography mixin defines the global typography styles for an application, including how the native h1-h6 and p elements look. + +It currently accepts 3 arguments: +- `$font-family` - The global font family to be used by the application. +- `$type-scale` - The default type scale to be used by the application. + +To overwrite the default typography, include the `typography` mixin anywhere after the `igx-core` mixin. Let's take advantage of the type scale `$my-type-scale` we defined above and make it the default type scale. + +```scss +@include typography( + $font-family: "'Roboto', sans-serif", + $type-scale: $my-type-scale +); +``` + +## Custom type styles +The `type-style` mixin can be used to retrieve the style rules for a scale category from a specific type scale. Further, it allows you to add additional style rules. + +```scss +.my-fancy-h1 { + @include type-style($my-type-scale, 'h1') { + color: royalblue; + } +} +``` + +The above code will produce a class style selector `.my-fancy-h1`, which contains all of the style rules for the `h1` scale category from `$my-type-scale` with the addition of the `color` property set to the `royalblue` color. Now, if you set the class of any element to `.my-fancy-h1`, it will look like any other `h1` element, with but be `royalblue` in color. + +## Customizing component typography + +Most of the components in Ignite UI for Angular use scale categories for styling the text. For instance, the `igx-card` component uses the following scale categories: +- `h5` - used for styling card title. +- `subtitle-2` - used for styling card subtitle and small title. +- `body-2` - used for styling card text content. + +There are two ways to change the text styles of a card. The first is by modifying the `h5`, `subtitle-2`, and/or `body-2` scales in the ***default type scale*** that we pass to the typography mixin. So if we wanted to make the title in a card smaller, all we have to do is change the font-size for the `h5` scale category. + +```scss +// Create a custom h5 scale category style +$my-h5: type-style($font-size: 18px); + +// Create a custom type scale with the modified h5 +$my-type-scale: type-scale($h5: $my-h5); + +// Pass the custom scale to the global typography mixin +@include typography($type-scale: $my-type-scale); +``` + +Note, however, that the above code will modify the `h5` scale category globally, which will affect the look and feel of all components that use the `h5` scale. This is done for consistency so that all `h5` elements look the same across your app. We understand that you may want to apply the modification for `h5` to specific components only, like the `igx-card` component in our case. This is why every component has its own typography mixin, which accepts a type scale itself, as well as a category configuration. + +```scss +// Create a custom h5 scale category style +$my-h5: type-style($font-size: 18px); + +// Create a custom type scale with the modified h5 +$my-type-scale: type-scale($h5: $my-h5); + +// Pass the custom scale to the card typography mixin only +@include igx-card-typography($type-scale: $my-type-scale); +``` + +We no longer include the `typography` mixin by passing it the `$my-type-scale` scale with our modification to the `h5` category. Now all we do is pass the custom scale we created to the `igx-card-typography` mixin. The only component that uses our `$my-type-scale` scale is the card now. + +Typography style mixins can be scope to specific selectors. Say we wanted our custom card typography styles to be applied for all `igx-card` components with class name of `my-cool-card`. + +```scss +//... +.my-cool-card { + @include igx-card-typography($type-scale: $my-type-scale); +} +``` + +The typography component mixins take a second argument - `$categories`. It is used to configure which parts of the component use what typography scale category. For instance, if we wanted the our custom card to use a different scale category for the title than `h5`, we could change it. + +```scss +@include igx-card-typography( + $type-scale: $my-type-scale, + $categories: ( + title: 'h6' + ) +); +``` + +Now the card component will use the `overline` scale category to style the title. The user can completely overhaul the entire card typography by assigning different type scales to the different text parts of the card. + +## CSS Classes + +In addition to adding text styles for all components based on type scale categories, we also style the default h1-h6 and p elements. We also separate semantics from styling. So fo instance, even though the `h1` tag has some default styling that we provide when using `ig-typography`, you can modify it to look like an `h3` by giving it a class of `ig-typography__h3`. + +```html +

Some text

+``` + +Here's a list of all CSS classes we provide by default: + +- `ig-typography__h1` +- `ig-typography__h2` +- `ig-typography__h3` +- `ig-typography__h4` +- `ig-typography__h5` +- `ig-typography__h6` +- `ig-typography__subtitle-1` +- `ig-typography__subtitle-2` +- `ig-typography__body-1` +- `ig-typography__body-2` +- `ig-typography__button` +- `ig-typography__caption` +- `ig-typography__overline` diff --git a/projects/igniteui-angular/core/src/core/styles/typography/_bootstrap.scss b/projects/igniteui-angular/core/src/core/styles/typography/_bootstrap.scss new file mode 100644 index 00000000000..fe8225fdea5 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/typography/_bootstrap.scss @@ -0,0 +1,126 @@ +@use 'sass:map'; +@use '../components/badge/badge-theme' as *; +@use '../components/banner/banner-theme' as *; +@use '../components/bottom-nav/bottom-nav-theme' as *; +@use '../components/button/button-theme' as *; +@use '../components/button-group/button-group-theme' as *; +@use '../components/calendar/calendar-theme' as *; +@use '../components/card/card-theme' as *; +@use '../components/checkbox/checkbox-theme' as *; +@use '../components/chip/chip-theme' as *; +@use '../components/column-actions/column-actions-theme' as *; +@use '../components/date-range-picker/date-range-picker-theme' as *; +@use '../components/dialog/dialog-theme' as *; +@use '../components/dock-manager/dock-manager-theme' as *; +@use '../components/drop-down/drop-down-theme' as *; +@use '../components/expansion-panel/expansion-panel-theme' as *; +@use '../components/grid/excel-filtering-theme' as *; +@use '../components/icon-button/icon-button-theme' as *; +@use '../components/input/input-group-theme' as *; +@use '../components/list/list-theme' as *; +@use '../components/navbar/navbar-theme' as *; +@use '../components/navdrawer/navdrawer-theme' as *; +@use '../components/progress/linear/linear-theme' as *; +@use '../components/radio/radio-theme' as *; +@use '../components/slider/slider-theme' as *; +@use '../components/snackbar/snackbar-theme' as *; +@use '../components/switch/switch-theme' as *; +@use '../components/stepper/stepper-theme' as *; +@use '../components/tabs/tabs-theme' as *; +@use '../components/time-picker/time-picker-theme' as *; +@use '../components/toast/toast-theme' as *; +@use '../components/tooltip/tooltip-theme' as *; +@use '../components/tree/tree-theme' as *; +@use '../components/label/label-theme' as *; +@use '../components/query-builder/query-builder-theme' as *; +@use '../components/input/file-input-theme' as *; + +@mixin typography($type-scale) { + @include badge-typography($theme: 'bootstrap'); + @include banner-typography(); + @include bottom-nav-typography(); + @include button-typography(); + @include button-group-typography(); + @include calendar-typography($categories: ( + header-year: 'body-2', + header-date: 'h4', + weekday-label: 'body-2', + picker-date: 'subtitle-1', + content: 'body-1', + )); + @include card-typography($categories: ( + title: 'h5', + title-small: 'subtitle-2', + subtitle: 'body-1', + content: 'body-1', + )); + @include checkbox-typography($categories: ( + label: 'body-1', + )); + @include chip-typography(); + @include column-actions-typography(); + @include date-range-typography($categories: ( + label: 'body-1', + )); + @include dialog-typography($categories: ( + title: 'h5', + content: 'body-1' + )); + @include dock-manager-typography(); + @include drop-down-typography($categories: ( + header: 'overline', + item: 'body-2', + select-item: 'body-2' + )); + @include expansion-panel-typography($categories: ( + title: 'h5', + description: 'subtitle-2', + body: 'body-2' + )); + @include excel-filtering-typography(); + @include icon-button-typography(); + @include input-group-typography($categories: ( + helper-text: 'body-2', + input-text: 'body-1' + )); + @include file-input-typography($categories: ( + file-text: 'body-1' + )); + @include linear-bar-typography(); + @include list-typography($categories: ( + item: 'body-2', + title: 'body-1', + subtitle: 'body-2', + header: 'overline', + )); + @include navbar-typography($categories: ( + title: 'h5' + )); + @include navdrawer-typography($categories: ( + item: 'body-2', + header: 'caption' + )); + @include radio-typography($categories: ( + label: 'body-1' + )); + @include slider-typography(); + @include snackbar-typography(); + @include switch-typography($categories: ( + label: 'body-1' + )); + @include tabs-typography(); + @include time-picker-typography($categories: ( + header-hour: 'h4', + selected-time: 'h4' + )); + @include stepper-typography(); + @include toast-typography(); + @include tooltip-typography(); + @include tree-typography(); + @include label-typography($categories: ( + label: 'body-1' + )); + @include query-builder-typography($categories: ( + title: 'h5' + )); +} diff --git a/projects/igniteui-angular/core/src/core/styles/typography/_fluent.scss b/projects/igniteui-angular/core/src/core/styles/typography/_fluent.scss new file mode 100644 index 00000000000..3f9ab803925 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/typography/_fluent.scss @@ -0,0 +1,129 @@ +@use '../components/badge/badge-theme' as *; +@use '../components/banner/banner-theme' as *; +@use '../components/bottom-nav/bottom-nav-theme' as *; +@use '../components/button/button-theme' as *; +@use '../components/button-group/button-group-theme' as *; +@use '../components/calendar/calendar-theme' as *; +@use '../components/card/card-theme' as *; +@use '../components/checkbox/checkbox-theme' as *; +@use '../components/chip/chip-theme' as *; +@use '../components/column-actions/column-actions-theme' as *; +@use '../components/date-range-picker/date-range-picker-theme' as *; +@use '../components/dialog/dialog-theme' as *; +@use '../components/dock-manager/dock-manager-theme' as *; +@use '../components/drop-down/drop-down-theme' as *; +@use '../components/expansion-panel/expansion-panel-theme' as *; +@use '../components/grid/excel-filtering-theme' as *; +@use '../components/icon-button/icon-button-theme' as *; +@use '../components/input/input-group-theme' as *; +@use '../components/list/list-theme' as *; +@use '../components/navbar/navbar-theme' as *; +@use '../components/navdrawer/navdrawer-theme' as *; +@use '../components/progress/linear/linear-theme' as *; +@use '../components/radio/radio-theme' as *; +@use '../components/slider/slider-theme' as *; +@use '../components/snackbar/snackbar-theme' as *; +@use '../components/switch/switch-theme' as *; +@use '../components/stepper/stepper-theme' as *; +@use '../components/tabs/tabs-theme' as *; +@use '../components/time-picker/time-picker-theme' as *; +@use '../components/toast/toast-theme' as *; +@use '../components/tooltip/tooltip-theme' as *; +@use '../components/tree/tree-theme' as *; +@use '../components/label/label-theme' as *; +@use '../components/query-builder/query-builder-theme' as *; +@use '../components/input/file-input-theme' as *; + +@mixin typography() { + @include badge-typography($theme: 'fluent'); + @include banner-typography($categories: ( + message: 'caption' + )); + @include bottom-nav-typography(); + @include button-typography(); + @include button-group-typography(); + @include calendar-typography($categories: ( + header-year: 'overline', + header-date: 'h4', + weekday-label: 'body-1', + picker-date: 'subtitle-2', + content: 'body-1', + )); + @include card-typography($categories: ( + title: 'subtitle-1', + title-small: 'subtitle-2', + subtitle: 'body-2', + content: 'body-2' + )); + @include checkbox-typography($categories: ( + label: 'body-2' + )); + @include chip-typography($categories: ( + text: 'subtitle-2' + )); + @include column-actions-typography(); + @include date-range-typography(); + @include dialog-typography($categories: ( + title: 'h6', + content: 'body-2' + )); + @include dock-manager-typography(); + @include drop-down-typography($categories: ( + header: 'subtitle-2', + item: 'body-2', + select-item: 'body-2' + )); + @include expansion-panel-typography($categories: ( + title: 'subtitle-1', + description: 'body-2', + body: 'caption' + )); + @include excel-filtering-typography(); + @include icon-button-typography(); + @include input-group-typography($categories: ( + helper-text: 'caption', + input-text: 'body-2' + )); + @include file-input-typography($categories: ( + file-text: 'body-2' + )); + @include linear-bar-typography(); + @include list-typography($categories: ( + header: 'overline', + item: 'caption', + title: 'caption', + subtitle: 'caption' + )); + @include navbar-typography($categories: ( + title: 'subtitle-2' + )); + @include navdrawer-typography($categories: ( + item: 'body-2', + header: 'subtitle-1' + )); + @include radio-typography($categories: ( + label: 'body-2' + )); + + @include slider-typography(); + @include snackbar-typography($categories: ( + text: 'caption' + )); + @include switch-typography($categories: ( + label: 'body-2' + )); + @include tabs-typography($categories: ( + label: 'body-2' + )); + @include time-picker-typography(); + @include stepper-typography(); + @include toast-typography($categories: ( + text: 'caption' + )); + @include tooltip-typography(); + @include tree-typography(); + @include label-typography($categories: ( + label: 'subtitle-2' + )); + @include query-builder-typography(); +} diff --git a/projects/igniteui-angular/core/src/core/styles/typography/_index.scss b/projects/igniteui-angular/core/src/core/styles/typography/_index.scss new file mode 100644 index 00000000000..13795189661 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/typography/_index.scss @@ -0,0 +1 @@ +@forward 'typography'; diff --git a/projects/igniteui-angular/core/src/core/styles/typography/_indigo.scss b/projects/igniteui-angular/core/src/core/styles/typography/_indigo.scss new file mode 100644 index 00000000000..5f1228dfb6e --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/typography/_indigo.scss @@ -0,0 +1,136 @@ +@use '../components/badge/badge-theme' as *; +@use '../components/banner/banner-theme' as *; +@use '../components/bottom-nav/bottom-nav-theme' as *; +@use '../components/button/button-theme' as *; +@use '../components/button-group/button-group-theme' as *; +@use '../components/calendar/calendar-theme' as *; +@use '../components/card/card-theme' as *; +@use '../components/checkbox/checkbox-theme' as *; +@use '../components/chip/chip-theme' as *; +@use '../components/column-actions/column-actions-theme' as *; +@use '../components/date-range-picker/date-range-picker-theme' as *; +@use '../components/dialog/dialog-theme' as *; +@use '../components/dock-manager/dock-manager-theme' as *; +@use '../components/drop-down/drop-down-theme' as *; +@use '../components/expansion-panel/expansion-panel-theme' as *; +@use '../components/grid/excel-filtering-theme' as *; +@use '../components/icon-button/icon-button-theme' as *; +@use '../components/input/input-group-theme' as *; +@use '../components/list/list-theme' as *; +@use '../components/navbar/navbar-theme' as *; +@use '../components/navdrawer/navdrawer-theme' as *; +@use '../components/progress/linear/linear-theme' as *; +@use '../components/radio/radio-theme' as *; +@use '../components/slider/slider-theme' as *; +@use '../components/snackbar/snackbar-theme' as *; +@use '../components/switch/switch-theme' as *; +@use '../components/stepper/stepper-theme' as *; +@use '../components/tabs/tabs-theme' as *; +@use '../components/time-picker/time-picker-theme' as *; +@use '../components/toast/toast-theme' as *; +@use '../components/tooltip/tooltip-theme' as *; +@use '../components/tree/tree-theme' as *; +@use '../components/label/label-theme' as *; +@use '../components/query-builder/query-builder-theme' as *; +@use '../components/input/file-input-theme' as *; + +@mixin typography($type-scale) { + @include badge-typography($theme: 'indigo'); + @include banner-typography(); + @include bottom-nav-typography(); + @include button-typography(); + @include button-group-typography($categories: ( + text: 'body-2', + )); + @include calendar-typography($categories: ( + header-year: 'body-2', + header-date: 'h5', + weekday-label: 'body-2', + picker-date: 'subtitle-2', + )); + @include card-typography($categories: ( + title: 'h6', + title-small: 'body-2', + subtitle: 'body-2', + content: 'body-2' + )); + @include checkbox-typography($categories: ( + label: 'body-2', + )); + @include chip-typography(); + @include column-actions-typography(); + @include date-range-typography(); + @include dialog-typography($categories: ( + title: 'h5', + content: 'body-1', + )); + @include dock-manager-typography(); + @include drop-down-typography(); + @include expansion-panel-typography($categories: ( + title: 'body-2', + body: 'body-2', + description: 'body-2', + )); + @include excel-filtering-typography(); + @include icon-button-typography(); + @include input-group-typography($categories: ( + helper-text: 'caption', + input-text: 'body-2' + )); + + @include file-input-typography($categories: ( + file-text: 'body-2' + )); + + @include linear-bar-typography(); + @include list-typography($categories: ( + header: 'overline', + item: 'body-2', + title: 'body-2', + subtitle: 'body-2' + )); + @include navbar-typography($categories: ( + title: 'h5', + )); + @include navdrawer-typography($categories: ( + item: 'subtitle-2', + header: 'overline' + )); + @include radio-typography($categories: ( + label: 'body-2' + )); + @include slider-typography($categories: ( + ticks-label: 'body-2', + thumb-label: 'subtitle-2', + )); + @include snackbar-typography(); + @include switch-typography($categories: ( + label: 'body-2' + )); + @include tabs-typography($categories: ( + label: 'subtitle-2', + )) { + --ig-subtitle-2-text-transform: uppercase; + }; + @include time-picker-typography($categories: ( + header-hour: 'h5', + selected-time: 'h6' + )); + @include stepper-typography($categories: ( + title: 'body-2', + subtitle: 'caption', + indicator: 'button', + body-content: 'body-2' + )); + @include toast-typography(); + @include tooltip-typography($categories: ( + tooltip-text: 'subtitle-2' + )); + @include tree-typography(); + @include label-typography($categories: ( + label: 'caption' + )); + @include query-builder-typography($categories: ( + title: 'h5' + )); +} diff --git a/projects/igniteui-angular/core/src/core/styles/typography/_material.scss b/projects/igniteui-angular/core/src/core/styles/typography/_material.scss new file mode 100644 index 00000000000..8a6dd101db6 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/typography/_material.scss @@ -0,0 +1,73 @@ +@use '../components/badge/badge-theme' as *; +@use '../components/banner/banner-theme' as *; +@use '../components/bottom-nav/bottom-nav-theme' as *; +@use '../components/button/button-theme' as *; +@use '../components/button-group/button-group-theme' as *; +@use '../components/calendar/calendar-theme' as *; +@use '../components/card/card-theme' as *; +@use '../components/checkbox/checkbox-theme' as *; +@use '../components/chip/chip-theme' as *; +@use '../components/column-actions/column-actions-theme' as *; +@use '../components/date-range-picker/date-range-picker-theme' as *; +@use '../components/dialog/dialog-theme' as *; +@use '../components/dock-manager/dock-manager-theme' as *; +@use '../components/drop-down/drop-down-theme' as *; +@use '../components/expansion-panel/expansion-panel-theme' as *; +@use '../components/grid/excel-filtering-theme' as *; +@use '../components/icon-button/icon-button-theme' as *; +@use '../components/input/input-group-theme' as *; +@use '../components/list/list-theme' as *; +@use '../components/navbar/navbar-theme' as *; +@use '../components/navdrawer/navdrawer-theme' as *; +@use '../components/progress/linear/linear-theme' as *; +@use '../components/radio/radio-theme' as *; +@use '../components/slider/slider-theme' as *; +@use '../components/snackbar/snackbar-theme' as *; +@use '../components/switch/switch-theme' as *; +@use '../components/stepper/stepper-theme' as *; +@use '../components/tabs/tabs-theme' as *; +@use '../components/time-picker/time-picker-theme' as *; +@use '../components/toast/toast-theme' as *; +@use '../components/tooltip/tooltip-theme' as *; +@use '../components/tree/tree-theme' as *; +@use '../components/label/label-theme' as *; +@use '../components/query-builder/query-builder-theme' as *; +@use '../components/input/file-input-theme' as *; + +@mixin typography() { + @include badge-typography($theme: 'material'); + @include banner-typography(); + @include bottom-nav-typography(); + @include button-typography(); + @include button-group-typography(); + @include calendar-typography(); + @include card-typography(); + @include checkbox-typography(); + @include chip-typography(); + @include column-actions-typography(); + @include date-range-typography(); + @include dialog-typography(); + @include dock-manager-typography(); + @include drop-down-typography(); + @include expansion-panel-typography(); + @include excel-filtering-typography(); + @include icon-button-typography(); + @include input-group-typography(); + @include linear-bar-typography(); + @include list-typography(); + @include navbar-typography(); + @include navdrawer-typography(); + @include radio-typography(); + @include slider-typography(); + @include snackbar-typography(); + @include switch-typography(); + @include tabs-typography(); + @include time-picker-typography(); + @include stepper-typography(); + @include toast-typography(); + @include tooltip-typography(); + @include tree-typography(); + @include label-typography(); + @include query-builder-typography(); + @include file-input-typography(); +} diff --git a/projects/igniteui-angular/core/src/core/styles/typography/_typography.scss b/projects/igniteui-angular/core/src/core/styles/typography/_typography.scss new file mode 100644 index 00000000000..08450762053 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/styles/typography/_typography.scss @@ -0,0 +1,64 @@ +//// +/// @group typography +/// @author Simeon Simeonoff +//// + +@use 'sass:map'; +@use 'sass:list'; +@use 'material'; +@use 'bootstrap'; +@use 'fluent'; +@use 'indigo'; +@use 'igniteui-theming/sass/utils/meta' as *; +@use 'igniteui-theming/sass/typography' as theming; +@use 'igniteui-theming/sass/typography/presets' as presets; +@use 'igniteui-theming/sass/typography/charts' as *; + +@mixin _component-typography($type-scale, $exclude) { + font-family: var(--ig-font-family); + + $variant: map.get(map.get($type-scale, '_meta'), 'variant'); + + @if not(list.index($exclude, 'charts')) { + @include charts-typography($type-scale); + } + + @if $variant == 'material' or not($variant) { + @include material.typography(); + } + + @if $variant == 'bootstrap' { + @include bootstrap.typography($type-scale); + } + + @if $variant == 'fluent' { + @include fluent.typography(); + } + + @if $variant == 'indigo' { + @include indigo.typography($type-scale); + } +} + +/// Adds typography styles for h1-h6, paragraph and creates custom typography class selectors. +/// The produces styles are based on the passed typeface and type scale. +/// If omitted the $material-typeface and $material-type-scale will be used. +/// @access public +/// @param {String} $font-family [$material-typeface] - The font family to be used across all typographic elements. +/// @param {Map} $type-scale [$material-type-scale] - A type scale map as produced by type-scale. +/// @param {Map} $exclude [null] - A list of typography styles to be excluded. +@mixin typography( + $font-family: presets.$material-typeface, + $type-scale: presets.$material-type-scale, + $exclude: null +) { + $_scope: if(is-root() or is-host(), '.ig-typography', '&'); + + #{$_scope} { + @include _component-typography($type-scale, $exclude); + } + + @if not(list.index($exclude, 'global')) { + @include theming.typography($font-family, $type-scale); + } +} diff --git a/projects/igniteui-angular/core/src/core/touch-annotations.ts b/projects/igniteui-angular/core/src/core/touch-annotations.ts new file mode 100644 index 00000000000..142683007a3 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/touch-annotations.ts @@ -0,0 +1,59 @@ +/** + * Stripped-down HammerJS annotations. + */ + +/** +* @hidden +* @internal +*/ +export interface HammerInput { + preventDefault: () => void; + deltaX: number; + deltaY: number; + center: { x: number; y: number; }; + pointerType: string; + distance: number; +} + +/** +* @hidden +* @internal +*/ +export interface HammerStatic { + new(element: HTMLElement | SVGElement, options?: any): HammerManager; + + Pan: Recognizer; + Swipe: Recognizer; + Tap: Recognizer; + TouchInput: HammerInput; + DIRECTION_HORIZONTAL: number; + DIRECTION_VERTICAL: number; +} + +/** +* @hidden +* @internal +*/ +export interface Recognizer { } + +/** +* @hidden +* @internal +*/ +export interface HammerManager { + set(options: any): HammerManager; + off(events: string, handler?: (event: HammerInput) => void): void; + on(events: string, handler: (event: HammerInput) => void): void; + destroy(): void; + get(event: string): HammerManager; +} + +/** +* @hidden +* @internal +*/ +export interface HammerOptions { + cssProps?: { [key: string]: string }; + recognizers?: any[]; + inputClass?: HammerInput; +} diff --git a/projects/igniteui-angular/core/src/core/touch.ts b/projects/igniteui-angular/core/src/core/touch.ts new file mode 100644 index 00000000000..36341f15bc7 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/touch.ts @@ -0,0 +1,170 @@ +import { Injectable, NgZone, DOCUMENT, inject } from '@angular/core'; +import { ɵgetDOM as getDOM } from '@angular/platform-browser'; +import { PlatformUtil } from './utils'; +import { HammerManager, HammerOptions, HammerStatic } from './touch-annotations'; + +const EVENT_SUFFIX = 'precise'; + +/** + * Touch gestures manager based on Hammer.js + * Use with caution, this will track references for single manager per element. Very TBD. Much TODO. + * + * @hidden + */ +@Injectable() +export class HammerGesturesManager { + private _zone = inject(NgZone); + private doc = inject(DOCUMENT); + private platformUtil = inject(PlatformUtil); + + public static Hammer: HammerStatic = typeof window !== 'undefined' ? (window as any).Hammer : null; + /** + * Event option defaults for each recognizer, see http://hammerjs.github.io/api/ for API listing. + */ + protected hammerOptions: HammerOptions = {}; + + private platformBrowser: boolean; + private _hammerManagers: Array<{ element: EventTarget; manager: HammerManager }> = []; + + constructor() { + this.platformBrowser = this.platformUtil.isBrowser; + if (this.platformBrowser && HammerGesturesManager.Hammer) { + this.hammerOptions = { + // D.P. #447 Force TouchInput due to PointerEventInput bug (https://github.com/hammerjs/hammer.js/issues/1065) + // see https://github.com/IgniteUI/igniteui-angular/issues/447#issuecomment-324601803 + inputClass: HammerGesturesManager.Hammer.TouchInput, + recognizers: [ + [HammerGesturesManager.Hammer.Pan, { threshold: 0 }], + [HammerGesturesManager.Hammer.Swipe, { direction: HammerGesturesManager.Hammer.DIRECTION_HORIZONTAL }], + [HammerGesturesManager.Hammer.Tap], + [HammerGesturesManager.Hammer.Tap, { event: 'doubletap', taps: 2 }, ['tap']] + ] + }; + } + } + + public supports(eventName: string): boolean { + return eventName.toLowerCase().endsWith('.' + EVENT_SUFFIX); + } + + /** + * Add listener extended with options for Hammer.js. Will use defaults if none are provided. + * Modeling after other event plugins for easy future modifications. + */ + public addEventListener( + element: HTMLElement, + eventName: string, + eventHandler: (eventObj) => void, + options: HammerOptions = null): () => void { + if (!this.platformBrowser) { + return; + } + + // Creating the manager bind events, must be done outside of angular + return this._zone.runOutsideAngular(() => { + if (!HammerGesturesManager.Hammer) { + //no hammer + return; + } + let mc: HammerManager = this.getManagerForElement(element); + if (mc === null) { + // new Hammer is a shortcut for Manager with defaults + mc = new HammerGesturesManager.Hammer(element, Object.assign(this.hammerOptions, options)); + this.addManagerForElement(element, mc); + } + const handler = (eventObj) => this._zone.run(() => eventHandler(eventObj)); + mc.on(eventName, handler); + return () => mc.off(eventName, handler); + }); + } + + /** + * Add listener extended with options for Hammer.js. Will use defaults if none are provided. + * Modeling after other event plugins for easy future modifications. + * + * @param target Can be one of either window, body or document(fallback default). + */ + public addGlobalEventListener(target: string, eventName: string, eventHandler: (eventObj) => void): () => void { + if (!this.platformBrowser || !HammerGesturesManager.Hammer) { + return; + } + + const element = this.getGlobalEventTarget(target); + + // Creating the manager bind events, must be done outside of angular + return this.addEventListener(element as HTMLElement, eventName, eventHandler); + } + + /** + * Exposes [Dom]Adapter.getGlobalEventTarget to get global event targets. + * Supported: window, document, body. Defaults to document for invalid args. + * + * @param target Target name + */ + public getGlobalEventTarget(target: string): EventTarget { + return getDOM().getGlobalEventTarget(this.doc, target); + } + + /** + * Set HammerManager options. + * + * @param element The DOM element used to create the manager on. + * + * ### Example + * + * ```ts + * manager.setManagerOption(myElem, "pan", { pointers: 1 }); + * ``` + */ + public setManagerOption(element: EventTarget, event: string, options: any) { + const manager = this.getManagerForElement(element); + manager.get(event).set(options); + } + + /** + * Add an element and manager map to the internal collection. + * + * @param element The DOM element used to create the manager on. + */ + public addManagerForElement(element: EventTarget, manager: HammerManager) { + this._hammerManagers.push({element, manager}); + } + + /** + * Get HammerManager for the element or null + * + * @param element The DOM element used to create the manager on. + */ + public getManagerForElement(element: EventTarget): HammerManager { + const result = this._hammerManagers.filter(value => value.element === element); + return result.length ? result[0].manager : null; + } + + /** + * Destroys the HammerManager for the element, removing event listeners in the process. + * + * @param element The DOM element used to create the manager on. + */ + public removeManagerForElement(element: HTMLElement) { + let index: number = null; + for (let i = 0; i < this._hammerManagers.length; i++) { + if (element === this._hammerManagers[i].element) { + index = i; + break; + } + } + if (index !== null) { + const item = this._hammerManagers.splice(index, 1)[0]; + // destroy also + item.manager.destroy(); + } + } + + /** Destroys all internally tracked HammerManagers, removing event listeners in the process. */ + public destroy() { + for (const item of this._hammerManagers) { + item.manager.destroy(); + } + this._hammerManagers = []; + } +} diff --git a/projects/igniteui-angular/core/src/core/types.ts b/projects/igniteui-angular/core/src/core/types.ts new file mode 100644 index 00000000000..541d7e15180 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/types.ts @@ -0,0 +1 @@ +export type KeyOfOrString = K extends keyof T ? K : string; diff --git a/projects/igniteui-angular/core/src/core/utils.spec.ts b/projects/igniteui-angular/core/src/core/utils.spec.ts new file mode 100644 index 00000000000..2a6bb842080 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/utils.spec.ts @@ -0,0 +1,256 @@ +import { SampleTestData } from 'igniteui-angular/test-utils/sample-test-data.spec'; +import { cloneValue, isObject, isDate } from './utils'; + +describe('Utils', () => { + const complexObject = { + Number: 0, + String: 'Some string', + Boolean: true, + Date: new Date(0), + Object10: { + Number: 10, + String: 'Some second level string 10', + Boolean: false, + Date: new Date(10 * 1000 * 60 * 60 * 24), + Object100: { + Number: 100, + String: 'Some third level string 100', + Boolean: false, + Date: new Date(100 * 1000 * 60 * 60 * 24), + }, + Object101: { + Number: 101, + String: 'Some third level string 101', + Boolean: false, + Date: new Date(101 * 1000 * 60 * 60 * 24), + } + }, + Object11: { + Number: 11, + String: 'Some second level string 11', + Boolean: false, + Date: new Date(11 * 1000 * 60 * 60 * 24), + Object110: { + Number: 110, + String: 'Some third level string 110', + Boolean: false, + Date: new Date(110 * 1000 * 60 * 60 * 24), + }, + Object111: { + Number: 111, + String: 'Some third level string 111', + Boolean: false, + Date: new Date(111 * 1000 * 60 * 60 * 24), + } + } + }; + + describe('Utils - cloneValue() unit tests', () => { + it('Should return primitive values', () => { + let input: any = 10; + let expected: any = 10; + expect(cloneValue(input)).toBe(expected); + + input = 0; + expected = 0; + expect(cloneValue(input)).toBe(expected); + + input = Infinity; + expected = Infinity; + expect(cloneValue(input)).toBe(expected); + + input = ''; + expected = ''; + expect(cloneValue(input)).toBe(expected); + + input = true; + expected = true; + expect(cloneValue(input)).toBe(expected); + + input = false; + expected = false; + expect(cloneValue(input)).toBe(expected); + + input = null; + expected = null; + expect(cloneValue(input)).toBe(expected); + + input = undefined; + expected = undefined; + expect(cloneValue(input)).toBe(expected); + }); + + it('Should not clone Map or Set', () => { + const mapInput: Map = new Map(); + mapInput.set('a', 0); + mapInput.set('b', 1); + mapInput.set('c', 2); + const mapClone = cloneValue(mapInput); + expect(mapInput).toBe(mapClone); + + const setInput: Set = new Set(); + setInput.add(0); + setInput.add(1); + setInput.add(2); + const setClone = cloneValue(setInput); + expect(setInput).toBe(setClone); + }); + + it('Should clone correctly dates', () => { + const input: Date = new Date(0); + const clone: Date = cloneValue(input); + expect(clone).not.toBe(input); + expect(clone.getTime()).toBe(input.getTime()); + + // change of the input should not change the clone + input.setDate(10); + expect(clone.getTime()).not.toBe(input.getTime()); + }); + + it('Should create shallow copy of array', () => { + const input: { Number: any; String: any; Boolean: any; Date: any }[] = SampleTestData.differentTypesData(); + const clone: { Number: any; String: any; Boolean: any; Date: any }[] = cloneValue(input); + expect(clone).not.toBe(input); + expect(clone.length).toBe(input.length); + expect(clone).toEqual(input); + + input[0].String = input[0].String + ' some additional value'; + input[0].Boolean = !input[0].Boolean; + input[0].Number *= 1000; + expect(clone).toEqual(input); + }); + + it('Should correctly deep clone objects', () => { + const input = complexObject; + const clone = cloneValue(input); + expect(input).toEqual(clone); + expect(input.Object10).toEqual(clone.Object10); + expect(input.Object11).toEqual(clone.Object11); + + expect(input.Date).toEqual(clone.Date); + expect(input.Date).not.toBe(clone.Date); + expect(input.Date.getTime()).toBe(clone.Date.getTime()); + + expect(input.Object10.Date).toEqual(clone.Object10.Date); + expect(input.Object10.Date).not.toBe(clone.Object10.Date); + expect(input.Object10.Date.getTime()).toBe(clone.Object10.Date.getTime()); + + expect(input.Object11.Object111.Date).toEqual(clone.Object11.Object111.Date); + expect(input.Object11.Object111.Date).not.toBe(clone.Object11.Object111.Date); + expect(input.Object11.Object111.Date.getTime()).toBe(clone.Object11.Object111.Date.getTime()); + + expect(input.Number).toBe(clone.Number); + expect(input.Object10.Number).toBe(clone.Object10.Number); + expect(input.Object11.Object111.Number).toBe(clone.Object11.Object111.Number); + + expect(input.String).toBe(clone.String); + expect(input.Object10.String).toBe(clone.Object10.String); + expect(input.Object11.Object111.String).toBe(clone.Object11.Object111.String); + + expect(input.Boolean).toBe(clone.Boolean); + expect(input.Object10.Boolean).toBe(clone.Object10.Boolean); + expect(input.Object11.Object111.Boolean).toBe(clone.Object11.Object111.Boolean); + }); + + it('Should correctly deep clone object with special values', () => { + const objectWithSpecialValues = {}; + objectWithSpecialValues['Null'] = null; + objectWithSpecialValues['Undefined'] = undefined; + const clone = cloneValue(objectWithSpecialValues); + + expect(clone.Null).toBeNull(); + expect(clone.undefined).toBeUndefined(); + }); + + it('Should correctly handle null and undefined values', () => { + const nullClone = cloneValue(null); + expect(nullClone).toBeNull(); + + const undefinedClone = cloneValue(undefined); + expect(undefinedClone).toBeUndefined(); + }); + }); + + describe('Utils - isObject() unit tests', () => { + it('Should correctly determine if variable is Object', () => { + let variable: any = {}; + expect(isObject(variable)).toBeTruthy(); + + variable = 10; + expect(isObject(variable)).toBeFalsy(); + + variable = 'Some string'; + expect(isObject(variable)).toBeFalsy(); + + variable = ''; + expect(isObject(variable)).toBeFalsy(); + + variable = true; + expect(isObject(variable)).toBeFalsy(); + + variable = false; + expect(isObject(variable)).toBeFalsy(); + + variable = new Date(0); + expect(isObject(variable)).toBeFalsy(); + + variable = null; + expect(isObject(variable)).toBeFalsy(); + + variable = undefined; + expect(isObject(variable)).toBeFalsy(); + + variable = []; + expect(isObject(variable)).toBeFalsy(); + + variable = new Map(); + expect(isObject(variable)).toBeFalsy(); + + variable = new Set(); + expect(isObject(variable)).toBeFalsy(); + }); + }); + + describe('Utils - isDate() unit tests', () => { + it('Should correctly determine if variable is Date', () => { + let variable: any = new Date(0); + expect(isDate(variable)).toBeTruthy(); + + variable = new Date('wrong date parameter'); + expect(isDate(variable)).toBeTruthy(); + + variable = 10; + expect(isDate(variable)).toBeFalsy(); + + variable = 'Some string'; + expect(isDate(variable)).toBeFalsy(); + + variable = ''; + expect(isDate(variable)).toBeFalsy(); + + variable = true; + expect(isDate(variable)).toBeFalsy(); + + variable = false; + expect(isDate(variable)).toBeFalsy(); + + variable = {}; + expect(isDate(variable)).toBeFalsy(); + + variable = null; + expect(isDate(variable)).toBeFalsy(); + + variable = undefined; + expect(isDate(variable)).toBeFalsy(); + + variable = []; + expect(isDate(variable)).toBeFalsy(); + + variable = new Map(); + expect(isDate(variable)).toBeFalsy(); + + variable = new Set(); + expect(isDate(variable)).toBeFalsy(); + }); + }); +}); diff --git a/projects/igniteui-angular/core/src/core/utils.ts b/projects/igniteui-angular/core/src/core/utils.ts new file mode 100644 index 00000000000..16791ab0379 --- /dev/null +++ b/projects/igniteui-angular/core/src/core/utils.ts @@ -0,0 +1,706 @@ +import { CurrencyPipe, formatDate as _formatDate, isPlatformBrowser } from '@angular/common'; +import { Injectable, InjectionToken, PLATFORM_ID, inject } from '@angular/core'; +import { mergeWith } from 'lodash-es'; +import { NEVER, Observable } from 'rxjs'; +import { setImmediate } from './setImmediate'; +import { isDevMode } from '@angular/core'; +import type { IgxTheme } from '../services/theme/theme.token'; + +/** @hidden @internal */ +export const ELEMENTS_TOKEN = /*@__PURE__*/new InjectionToken('elements environment'); + +/** + * @hidden + */ +export const showMessage = (message: string, isMessageShown: boolean): boolean => { + if (!isMessageShown && isDevMode()) { + console.warn(message); + } + + return true; +}; + +/** + * + * @hidden @internal + */ +export const getResizeObserver = () => globalThis.window?.ResizeObserver; + +/** + * @hidden + */ +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 + * + * @hidden + */ +export const cloneHierarchicalArray = (array: any[], childDataKey: any): any[] => { + const result: any[] = []; + if (!array) { + return result; + } + + for (const item of array) { + const clonedItem = cloneValue(item); + if (Array.isArray(item[childDataKey])) { + clonedItem[childDataKey] = cloneHierarchicalArray(clonedItem[childDataKey], childDataKey); + } + result.push(clonedItem); + } + return result; +}; + +/** + * Creates an object with prototype from provided source and copies + * all properties descriptors from provided source + * @param obj Source to copy prototype and descriptors from + * @returns New object with cloned prototype and property descriptors + */ +export const copyDescriptors = (obj) => { + if (obj) { + return Object.create( + Object.getPrototypeOf(obj), + Object.getOwnPropertyDescriptors(obj) + ); + } +} + + +/** + * Deep clones all first level keys of Obj2 and merges them to Obj1 + * + * @param obj1 Object to merge into + * @param obj2 Object to merge from + * @returns Obj1 with merged cloned keys from Obj2 + * @hidden + */ +export const mergeObjects = (obj1: any, obj2: any): any => mergeWith(obj1, obj2, (objValue, srcValue) => { + if (Array.isArray(srcValue)) { + return objValue = srcValue; + } +}); + +/** + * Creates deep clone of provided value. + * Supports primitive values, dates and objects. + * If passed value is array returns shallow copy of the array. + * + * @param value value to clone + * @returns Deep copy of provided value + * @hidden + */ +export const cloneValue = (value: any): any => { + if (isDate(value)) { + return new Date(value.getTime()); + } + if (Array.isArray(value)) { + return value.slice(); + } + + if (value instanceof Map || value instanceof Set) { + return value; + } + + if (isObject(value)) { + const result = {}; + + for (const key of Object.keys(value)) { + if (key === "externalObject") { + continue; + } + result[key] = cloneValue(value[key]); + } + return result; + } + return value; +}; + +/** + * Creates deep clone of provided value. + * Supports primitive values, dates and objects. + * If passed value is array returns shallow copy of the array. + * For Objects property values and references are cached and reused. + * This allows for circular references to same objects. + * + * @param value value to clone + * @param cache map of cached values already parsed + * @returns Deep copy of provided value + * @hidden + */ +export const cloneValueCached = (value: any, cache: Map): any => { + if (isDate(value)) { + return new Date(value.getTime()); + } + if (Array.isArray(value)) { + return [...value]; + } + + if (value instanceof Map || value instanceof Set) { + return value; + } + + if (isObject(value)) { + if (cache.has(value)) { + return cache.get(value); + } + + const result = {}; + cache.set(value, result); + + for (const key of Object.keys(value)) { + result[key] = cloneValueCached(value[key], cache); + } + return result; + } + return value; +}; + +/** + * Parse provided input to Date. + * + * @param value input to parse + * @returns Date if parse succeed or null + * @hidden + */ +export const parseDate = (value: any): Date | null => { + // if value is Invalid Date return null + if (isDate(value)) { + return !isNaN(value.getTime()) ? value : null; + } + return value ? new Date(value) : null; +}; + +/** + * Returns an array with unique dates only. + * + * @param columnValues collection of date values (might be numbers or ISO 8601 strings) + * @returns collection of unique dates. + * @hidden + */ +export const uniqueDates = (columnValues: any[]) => columnValues.reduce((a, c) => { + if (!a.cache[c.label]) { + a.result.push(c); + } + a.cache[c.label] = true; + return a; +}, { result: [], cache: {} }).result; + +/** + * Checks if provided variable is Object + * + * @param value Value to check + * @returns true if provided variable is Object + * @hidden + */ +export const isObject = (value: any): boolean => !!(value && value.toString() === '[object Object]'); + +/** + * Checks if provided variable is Date + * + * @param value Value to check + * @returns true if provided variable is Date + * @hidden + */ +export const isDate = (value: any): value is Date => { + return Object.prototype.toString.call(value) === "[object Date]"; +} + +/** + * Checks if the two passed arguments are equal + * Currently supports date objects + * + * @param obj1 + * @param obj2 + * @returns: `boolean` + * @hidden + */ +export const isEqual = (obj1, obj2): boolean => { + if (isDate(obj1) && isDate(obj2)) { + return obj1.getTime() === obj2.getTime(); + } + return obj1 === obj2; +}; + +/** + * Limits a number to a range between a minimum and a maximum value. + * + * @param number + * @param min + * @param max + * @returns: `number` + * @hidden + */ +export const clamp = (number: number, min: number, max: number) => + Math.max(min, Math.min(number, max)); + + +/** + * Utility service taking care of various utility functions such as + * detecting browser features, general cross browser DOM manipulation, etc. + * + * @hidden @internal + */ +@Injectable({ providedIn: 'root' }) +export class PlatformUtil { + private platformId = inject(PLATFORM_ID); + + public isBrowser: boolean = isPlatformBrowser(this.platformId); + public isIOS = this.isBrowser && /iPad|iPhone|iPod/.test(navigator.userAgent) && !('MSStream' in window); + public isSafari = this.isBrowser && /Safari[\/\s](\d+\.\d+)/.test(navigator.userAgent); + public isFirefox = this.isBrowser && /Firefox[\/\s](\d+\.\d+)/.test(navigator.userAgent); + public isEdge = this.isBrowser && /Edge[\/\s](\d+\.\d+)/.test(navigator.userAgent); + public isChromium = this.isBrowser && (/Chrom|e?ium/g.test(navigator.userAgent) || + /Google Inc/g.test(navigator.vendor)) && !/Edge/g.test(navigator.userAgent); + public browserVersion = this.isBrowser ? parseFloat(navigator.userAgent.match(/Version\/([\d.]+)/)?.at(1)) : 0; + + /** @hidden @internal */ + public isElements = inject(ELEMENTS_TOKEN, { optional: true }); + + public KEYMAP = { + ENTER: 'Enter', + SPACE: ' ', + ESCAPE: 'Escape', + ARROW_DOWN: 'ArrowDown', + ARROW_UP: 'ArrowUp', + ARROW_LEFT: 'ArrowLeft', + ARROW_RIGHT: 'ArrowRight', + END: 'End', + HOME: 'Home', + PAGE_DOWN: 'PageDown', + PAGE_UP: 'PageUp', + F2: 'F2', + TAB: 'Tab', + SEMICOLON: ';', + // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values#editing_keys + DELETE: 'Delete', + BACKSPACE: 'Backspace', + CONTROL: 'Control', + X: 'x', + Y: 'y', + Z: 'z' + } as const; + + /** + * @hidden @internal + * Returns the actual size of the node content, using Range + * ```typescript + * let range = document.createRange(); + * let column = this.grid.columnList.filter(c => c.field === 'ID')[0]; + * + * let size = getNodeSizeViaRange(range, column.cells[0].nativeElement); + * + * @remarks + * The last parameter is useful when the size of the element to measure is modified by a + * parent element that has explicit size. In such cases the calculated size is never lower + * and the function may instead remove the parent size while measuring to get the correct value. + * ``` + */ + public getNodeSizeViaRange(range: Range, node: HTMLElement, sizeHoldingNode?: HTMLElement) { + let overflow = null; + let nodeStyles: string[]; + + if (!this.isFirefox) { + overflow = node.style.overflow; + // we need that hack - otherwise content won't be measured correctly in IE/Edge + node.style.overflow = 'visible'; + } + + if (sizeHoldingNode) { + const style = sizeHoldingNode.style; + nodeStyles = [style.width, style.minWidth, style.flexBasis]; + style.width = ''; + style.minWidth = ''; + style.flexBasis = ''; + } + + range.selectNodeContents(node); + const scale = node.getBoundingClientRect().width / node.offsetWidth; + const width = range.getBoundingClientRect().width / scale; + + if (!this.isFirefox) { + // we need that hack - otherwise content won't be measured correctly in IE/Edge + node.style.overflow = overflow; + } + + if (sizeHoldingNode) { + sizeHoldingNode.style.width = nodeStyles[0]; + sizeHoldingNode.style.minWidth = nodeStyles[1]; + sizeHoldingNode.style.flexBasis = nodeStyles[2]; + } + + return width; + } + + + /** + * Returns true if the current keyboard event is an activation key (Enter/Space bar) + * + * @hidden + * @internal + * + * @memberof PlatformUtil + */ + public isActivationKey(event: KeyboardEvent) { + return event.key === this.KEYMAP.ENTER || event.key === this.KEYMAP.SPACE; + } + + /** + * Returns true if the current keyboard event is a combination that closes the filtering UI of the grid. (Escape/Ctrl+Shift+L) + * + * @hidden + * @internal + * @param event + * @memberof PlatformUtil + */ + public isFilteringKeyCombo(event: KeyboardEvent) { + return event.key === this.KEYMAP.ESCAPE || (event.ctrlKey && event.shiftKey && event.key.toLowerCase() === 'l'); + } + + /** + * @hidden @internal + */ + public isLeftClick(event: PointerEvent | MouseEvent) { + return event.button === 0; + } + + /** + * @hidden @internal + */ + public isNavigationKey(key: string) { + return [ + this.KEYMAP.HOME, this.KEYMAP.END, this.KEYMAP.SPACE, + this.KEYMAP.ARROW_DOWN, this.KEYMAP.ARROW_LEFT, this.KEYMAP.ARROW_RIGHT, this.KEYMAP.ARROW_UP + ].includes(key as any); + } +} + +/** + * @hidden + */ +export const flatten = (arr: any[]) => { + let result = []; + + arr.forEach(el => { + result.push(el); + if (el.children) { + const children = Array.isArray(el.children) ? el.children : el.children.toArray(); + result = result.concat(flatten(children)); + } + }); + return result; +}; + +export interface CancelableEventArgs { + /** + * Provides the ability to cancel the event. + */ + cancel: boolean; +} + +export interface IBaseEventArgs { + /** + * Provides reference to the owner component. + */ + owner?: any; +} + +export interface CancelableBrowserEventArgs extends CancelableEventArgs { + /* blazorSuppress */ + /** Browser event */ + event?: Event; +} + +export interface IBaseCancelableBrowserEventArgs extends CancelableBrowserEventArgs, IBaseEventArgs { } + +export interface IBaseCancelableEventArgs extends CancelableEventArgs, IBaseEventArgs { } + +export const HORIZONTAL_NAV_KEYS = new Set(['arrowleft', 'left', 'arrowright', 'right', 'home', 'end']); + +export const NAVIGATION_KEYS = new Set([ + 'down', + 'up', + 'left', + 'right', + 'arrowdown', + 'arrowup', + 'arrowleft', + 'arrowright', + 'home', + 'end', + 'space', + 'spacebar', + ' ' +]); +export const ACCORDION_NAVIGATION_KEYS = new Set('up down arrowup arrowdown home end'.split(' ')); +export const ROW_EXPAND_KEYS = new Set('right down arrowright arrowdown'.split(' ')); +export const ROW_COLLAPSE_KEYS = new Set('left up arrowleft arrowup'.split(' ')); +export const ROW_ADD_KEYS = new Set(['+', 'add', '≠', '±', '=']); +export const SUPPORTED_KEYS = new Set([...Array.from(NAVIGATION_KEYS), +...Array.from(ROW_ADD_KEYS), 'enter', 'f2', 'escape', 'esc', 'pagedown', 'pageup']); +export const HEADER_KEYS = new Set([...Array.from(NAVIGATION_KEYS), 'escape', 'esc', 'l', + /** This symbol corresponds to the Alt + L combination under MAC. */ + '¬']); + +/** + * @hidden + * @internal + * + * Creates a new ResizeObserver on `target` and returns it as an Observable. + * Run the resizeObservable outside angular zone, because it patches the MutationObserver which causes an infinite loop. + * Related issue: https://github.com/angular/angular/issues/31712 + */ +export const resizeObservable = (target: HTMLElement): Observable => { + const resizeObserver = getResizeObserver(); + // check whether we are on server env or client env + if (resizeObserver) { + return new Observable((observer) => { + const instance = new resizeObserver((entries: ResizeObserverEntry[]) => { + observer.next(entries); + }); + instance.observe(target); + const unsubscribe = () => instance.disconnect(); + return unsubscribe; + }); + } + // if on a server env return a empty observable that does not complete immediately + return NEVER; + +} + +/** + * @hidden + * @internal + * + * Compares two maps. + */ +export const compareMaps = (map1: Map, map2: Map): boolean => { + if (!map2) { + return !map1; + } + if (map1.size !== map2.size) { + return false; + } + let match = true; + const keys = Array.from(map2.keys()); + for (const key of keys) { + if (map1.has(key)) { + match = map1.get(key) === map2.get(key); + } else { + match = false; + } + if (!match) { + break; + } + } + return match; +}; + +function _isObject(entity: unknown): entity is object { + return entity != null && typeof entity === 'object'; +} + +export function columnFieldPath(path?: string): string[] { + return path?.split('.') ?? []; +} + +/** + * Given a property access path in the format `x.y.z` resolves and returns + * the value of the `z` property in the passed object. + * + * @hidden + * @internal + */ +export function resolveNestedPath(obj: unknown, pathParts: string[], defaultValue?: U): T | U | undefined { + if (!_isObject(obj) || pathParts.length < 1) { + return defaultValue; + } + + let current = obj; + + for (const key of pathParts) { + if (_isObject(current) && key in (current as T)) { + current = current[key]; + } else { + return defaultValue; + } + } + + return current as T; +} + +/** + * + * Given a property access path in the format `x.y.z` and a value + * this functions builds and returns an object following the access path. + * + * @example + * ```typescript + * console.log('x.y.z.', 42); + * >> { x: { y: { z: 42 } } } + * ``` + * + * @hidden + * @internal + */ +export const reverseMapper = (path: string, value: any) => { + const obj = {}; + const parts = path?.split('.') ?? []; + + let _prop = parts.shift(); + let mapping: any; + + // Initial binding for first level bindings + obj[_prop] = value; + mapping = obj; + + parts.forEach(prop => { + // Start building the hierarchy + mapping[_prop] = {}; + // Go down a level + mapping = mapping[_prop]; + // Bind the value and move the key + mapping[prop] = value; + _prop = prop; + }); + + return obj; +}; + +export const yieldingLoop = (count: number, chunkSize: number, callback: (index: number) => void, done: () => void) => { + let i = 0; + const chunk = () => { + const end = Math.min(i + chunkSize, count); + for (; i < end; ++i) { + callback(i); + } + if (i < count) { + setImmediate(chunk); + } else { + done(); + } + }; + chunk(); +}; + +export const isConstructor = (ref: any) => typeof ref === 'function' && Boolean(ref.prototype) && Boolean(ref.prototype.constructor); + +/** + * Similar to Angular's formatDate. However it will not throw on `undefined | null | ''` instead + * coalescing to an empty string. + */ +export const formatDate = (value: string | number | Date, format: string, locale: string, timezone?: string): string => { + if (value === null || value === undefined || value === '') { + return ''; + } + return _formatDate(value, format, locale, timezone); +}; + +export const formatCurrency = new CurrencyPipe(undefined).transform; + +/** Converts pixel values to their rem counterparts for a base value */ +export const rem = (value: number | string) => { + const base = parseFloat(globalThis.window?.getComputedStyle(globalThis.document?.documentElement).getPropertyValue('--ig-base-font-size')) + return Number(value) / base; +} + +/** Get the size of the component as derived from the CSS size variable */ +export function getComponentSize(el: Element) { + return globalThis.window?.getComputedStyle(el).getPropertyValue('--component-size'); +} + +/** Get the first item in an array */ +export function first(arr: T[]) { + return arr.at(0) as T; +} + +/** Get the last item in an array */ +export function last(arr: T[]) { + return arr.at(-1) as T; +} + +/** Calculates the modulo of two numbers, ensuring a non-negative result. */ +export function modulo(n: number, d: number) { + return ((n % d) + d) % d; +} + +/** + * Splits an array into chunks of length `size` and returns a generator + * yielding each chunk. + * The last chunk may contain less than `size` elements. + * + * @example + * ```typescript + * const arr = [0,1,2,3,4,5,6,7,8,9]; + * + * Array.from(chunk(arr, 2)) // [[0, 1], [2, 3], [4, 5], [6, 7], [8, 9]] + * Array.from(chunk(arr, 3)) // [[0, 1, 2], [3, 4, 5], [6, 7, 8], [9]] + * Array.from(chunk([], 3)) // [] + * Array.from(chunk(arr, -3)) // Error + * ``` + */ +export function* intoChunks(arr: T[], size: number) { + if (size < 1) { + throw new Error('size must be an integer >= 1'); + } + for (let i = 0; i < arr.length; i += size) { + yield arr.slice(i, i + size); + } +} + +/** + * @param size + * @returns string that represents the --component-size default value + */ +export function getComponentCssSizeVar(size: string) { + switch (size) { + case "1": + return 'var(--ig-size, var(--ig-size-small))'; + case "2": + return 'var(--ig-size, var(--ig-size-medium))'; + default: + return 'var(--ig-size, var(--ig-size-large))'; + } +} + +/** + * @param path - The URI path to be normalized. + * @returns string encoded using the encodeURI function. + */ +export function normalizeURI(path: string) { + return path?.split('/').map(encodeURI).join('/'); +} + +export function getComponentTheme(el: Element) { + return globalThis.window + ?.getComputedStyle(el) + .getPropertyValue('--theme') + .trim() as IgxTheme; +} + +/** + * Collection re-created w/ the built in track by identity will always log + * warning even for valid cases of recalculating all collection items. + * See https://github.com/angular/angular/blob/55581b4181639568fb496e91055142a1b489e988/packages/core/src/render3/instructions/control_flow.ts#L393-L409 + * Current solution explicit track function doing the same as suggested in: + * https://github.com/angular/angular/issues/56471#issuecomment-2180315803 + * This should be used with moderation and when necessary. + * @internal + */ +export function trackByIdentity(item: T): T { + return item; +} diff --git a/projects/igniteui-angular/core/src/data-operations/README-DATACONTAINER.md b/projects/igniteui-angular/core/src/data-operations/README-DATACONTAINER.md new file mode 100644 index 00000000000..a623b4fb28b --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/README-DATACONTAINER.md @@ -0,0 +1,70 @@ +# DataContainer + +## Description +**DataContainer** is a wrapper class of original data - array of JavaScript objects. It allows to apply in-memory data operations like - filtering, sorting, paging, CRUD(Create, Read, Update, Delete) operations. Original data is saved in property `data` and result data(on which data operations are applied) is saved in property `transformedData`. + +# API Summary + +## Properties +| Name | Type | Description | +|:----------|:-------------|:-------------| +| `data` | Array of JavaScript objects | represents original data | +| `transformedData` | Array of JavaScript objects | When function `process` is called data operations defined in argument data state are applied and result data is saved in property `transformedData` | +| `state`| object of type **DataState** | It defines which data operations should be applied when function `process` is called. | + +## Methods +| Name | Description | +|:----------|:-------------| +| `process` | Takes as argument object of type **DataState**(optional). If this argument is set then assign it to property `state`. The method sets value of `transformedData` to `data` and applies data operations defined in property `state`. Result of processed data is saved in property `transformedData`. The method returns instance of DataContainer(allows chaining). | +| `getIndexOfRecord` | Takes as arguments: object representing data record and variable of type **DataAccess**(with default value OriginalData). It searches in collection specified by dataAccess(original or transformed data) and returns index of the found record. If record is not found returns -1. | +| `getRecordByIndex` | Takes as arguments: index of record and variable of type **DataAccess**(with default value OriginalData). It searches in collection specified by dataAccess(original or transformed data) and returns object representing data record. If record is not found returns `undefined`. | +| `getRecordInfoByKeyValue` | Takes as arguments: column key, search value and variable of type **DataAccess**(with default value OriginalData). It searches in collection specified by dataAccess(original or transformed data) record with property specified in column key that has value equals to search value. It returns variable of type **RecordInfo** with properties `index` and `record`. If record is not found then `index` of result object is -1 and `record` is undefined. | +| `addRecord` | Takes as arguments: object representing data record and optional variable identifying position. If position is not set then adds it to the end of the original data otherwise at the specified position | +| `deleteRecord`| Takes as argument object representing data record. It tries to remove data record from the original data. If data record is found and removed from the array returns true, otherwise false | +| `deleteRecordByIndex`| Takes as argument index. It tries to remove data record specified by the index from the original data. If data record is found and removed from the array returns true, otherwise false | +| `updateRecordByIndex`| Takes as argument index and object representing new record properties. It finds a data record specified by the index from the original data and updates its properties specified by the second argument. It returns updated record | + + +# Usage +Code in .ts demonstrating how to use function `process`: +```typescript +items: Array = [ + { id: 1, text: "Item 1" }, + { id: 2, text: "Item 2" }, + { id: 3, text: "Item 3" }, + { id: 4, text: "Item 4" }, + { id: 5, text: "Item 5" }, + { id: 6, text: "Item 6" } + ]; +// apply filtering, sorting and paging + state:DataState = { + filtering: { + expressions: [{ + fieldName: "id", + condition: FilteringCondition.number.greaterThan, + searchVal: 1}] + }, + sorting: { + expressions: [ + { + fieldName: "text", + dir: SortingDirection.Desc + } + ] + }, + paging: { + index: 0, + recordsPerPage: 2 + } + }; + let dataContainer: DataContainer = new DataContainer(items); + dataContainer.process(state); + let res = dataContainer.transformedData; +``` +# Additional interfaces, enums and classes used in DataContainer + * **DataAccess** - enumeration representing which data should be taken from data container. Possible values are: + * **DataAccess.OriginalData** - takes original data + * **DataAccess.TransformedData** - takes transformed data +* **RecordInfo** - interface which represents reference to data record and and index of this data record in the collection. Its properties are: + * `index` - record index in data collection + * `record` - object representing data record \ No newline at end of file diff --git a/projects/igniteui-angular/core/src/data-operations/README-DATAUTIL.md b/projects/igniteui-angular/core/src/data-operations/README-DATAUTIL.md new file mode 100644 index 00000000000..1af43d7f718 --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/README-DATAUTIL.md @@ -0,0 +1,94 @@ +# DataUtil + +## Description +**DataUtil** is a static class which provides a set of helper functions for querying local data - array of JavaScript objects. +It can be used for applying data operations like - filtering, sorting, paging. + + +# API Methods +| Name | Description | +|:----------|:-------------| +| `sort` | Takes as arguments array of JavaScript objects(on which sorting is applied) and object of type **SortingState**. The method returns sorted data. Object of type **SortingState** is used to configure which column(s) to sort,sorting direction, sorting algorithm. | +| `filter` | Takes as arguments array of JavaScript objects(on which filtering should be applied) and object of type **FilteringState**. Returns filtered data(array of JavaScript objects). Object of type **FilteringState** is used to configure which column(s) to filter, search value, filtering algorithm. | +| `page` | Takes as arguments array of JavaScript objects and object of type **PagingState**. Returns paginated data. Object of type **PagingState** is used to configure how paging should be applied - which is the current page, records per page. NOTE: This method validates input arguments(e.g. page index should be positive number) and calculates number of pages. The result is saved in property of **PagingState** - `metadata`. | +| `process` | Takes as arguments array of JavaScript objects and object of type **DataState** and applies sorting/paging/filtering. Returns transformed data. Object of type **DataState** is used to configure which data-querying operation to be applied. | + + +# Usage +Code in .ts demonstrating how to use function `process`: +```typescript +items: Array = [ + { id: 1, text: "Item 1" }, + { id: 2, text: "Item 2" }, + { id: 3, text: "Item 3" }, + { id: 4, text: "Item 4" }, + { id: 5, text: "Item 5" }, + { id: 6, text: "Item 6" } + ]; +// apply filtering, sorting and paging + state:DataState = { + filtering: { + expressions: [{ + fieldName: "id", + condition: FilteringCondition.number.greaterThan, + searchVal: 1}] + }, + sorting: { + expressions: [ + { + fieldName: "text", + dir: SortingDirection.Desc + } + ] + }, + paging: { + index: 0, + recordsPerPage: 2 + } + }; + let res = DataUtil.process(items, state); +``` +# Additional interfaces, enums and classes used in DataUtil +* **SortingState** - interface, which defines how sorting should be applied. Its properties are: + + * `expressions` - array of objects of type **SortingExpression**. It defines which column(s) should be sorted, order of sorted columns and sorting direction. + * `strategy` - object of type **SortingStrategy**. It represents sorting algorithm. (optional) +* **FilteringState** - interface, which defines how filtering should be applied. Its properties are: + * `expressionsTree` - object of type **IFilteringExpressionsTree**. It defines which column(s) should be filtered, filtering conditions, filtering logic and (if any)search value on which filtering should be applied. + * `strategy` - object of type **FilteringStrategy**. It represents filtering algorithm. (optional) +* **PagingState** - interface, which defines how paging should be applied. Its properties are: + * `index` - identifies current page index(0 based positive number) + * `recordsPerPage` - identifies count of records per page. + * `metadata` - object which holds metadata information about paging(optional). Its properties are: + * `countPages` + * `error` - enum of type **PagingError**. Possible values are - **PagingError.None**, + **PagingError.IncorrectPageIndex**, + **PagingError.IncorrectRecordsPerPage** + * `countRecords` - total count of records +* **SortingExpression** - interface which defines how sorting should be applied per column. Its properties are: + * `fieldName` - specifies name of the column + * `dir` - identifies sorting direction. It is of type enum **SortingDirection**. Possible options are **SortingDirection.Asc** and **SortingDirection.Desc** + * `ignoreCase` - boolean property which identifies whether sorting is case-sensitive for string columns(optional) +* **FilteringExpression** - interface which defines how filtering should be applied for each column. Its properties are: + * `fieldName` - specifies name of the column + * `condtion` - specifies filtering condition. It should be function which accepts as arguments: + * `value` - value of the record on which filtering is applied + * `searchVal` - search value. There are filtering conditions which do not require searchVal. Example - FilteringCondition.Boolean.True.(optional) + * `ignoreCase` - boolean variable which specifies case-sensitivity for string columns(optional) + * `searchVal` - specifies value to search for.(optional) + * `ignoreCase` - boolean variable which specifies case-sensitivity for string columns(optional) +* **FilteringExpressionsTree** - class which implements **IFilteringExpressionsTree** interface. Describes the filtering state of a grid/column. Its properties and methods are: + * `filteringOperands` - an array of **IFilteringExpressionsTree** or **IFilteringExpression** objects which has the same filtering logic. If applied to a grid each object describes the filtering state of a grid's column. If applied to a column each object describes one filtering expression or a branch with filtering expressions with complex filtering logic. + * `operator` - object of type **FilteringLogic**. Defines the filtering logic for all objects in `filteringOperands` property. + * `fieldName` - (optional). Should not be set on a grid's level. It should be set for each **FilteringExpressionsTree** in the grid's `filterOperands`. That's how the filtering state for each column is defined. + * `find(fieldName: string)` - Returns the filtering state for a given column. Return type could be **IFilteringExpressionsTree** or **IFilteringExpression**. + * `findIndex(fieldName: string)` - Returns the index of the filtering state for a given column. +* **SortingStrategy** - class which implements **ISortingStrategy** interface. It specifies sorting algorithm. +* **FilteringStrategy** - class which implements **IFilteringStrategy** interface. It specifies filtering algorithm. +* **FilteringLogic** - class which describes the filtering logic between the different filtering expressions. Its values are **FilteringLogic.And**, **FilteringLogic.Or**. +* **GridColumnDataType** - enumeration which represent basic data types. Its values are: + * **GridColumnDataType.Boolean** + * **GridColumnDataType.Date** + * **GridColumnDataType.Number** + * **GridColumnDataType.String** + diff --git a/projects/igniteui-angular/core/src/data-operations/data-clone-strategy.ts b/projects/igniteui-angular/core/src/data-operations/data-clone-strategy.ts new file mode 100644 index 00000000000..a042c086b9a --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/data-clone-strategy.ts @@ -0,0 +1,30 @@ +import { cloneValue, cloneValueCached } from "../core/utils"; + +export interface IDataCloneStrategy { + /** + * Clones provided data + * @param data primitive value, date and object to be cloned + * @returns deep copy of provided value + */ + clone(data: any): any; +} + +/** + * Simplified data clone strategy that deep clones primitive values, dates and objects. + * Does not support circular references in objects. + */ +export class DefaultDataCloneStrategy implements IDataCloneStrategy { + public clone(data: any): any { + return cloneValue(data); + } +} + +/** + * Data clone strategy that is uses cache to deep clone primitive values, dates and objects. + * It allows using circular references inside object. + */ +export class CachedDataCloneStrategy implements IDataCloneStrategy { + public clone(data: any): any { + return cloneValueCached(data, new Map); + } +} diff --git a/projects/igniteui-angular/core/src/data-operations/data-util.spec.ts b/projects/igniteui-angular/core/src/data-operations/data-util.spec.ts new file mode 100644 index 00000000000..6069306a53e --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/data-util.spec.ts @@ -0,0 +1,634 @@ +import { waitForAsync } from '@angular/core/testing'; +import { DataGenerator } from './test-util/data-generator'; + +import { DefaultSortingStrategy, ISortingExpression, SortingDirection } from './sorting-strategy'; +import { cloneArray } from '../core/utils'; +import { DataUtil } from './data-util'; +import { IGroupByResult } from './grouping-result.interface'; +import { IGroupingState } from './groupby-state.interface'; +import { IGroupByRecord } from './groupby-record.interface'; +import { FilteringStrategy, FilterUtil } from './filtering-strategy'; +import { IFilteringExpressionsTree, FilteringExpressionsTree } from './filtering-expressions-tree'; +import { IFilteringState } from './filtering-state.interface'; +import { FilteringLogic } from './filtering-expression.interface'; +import { + IgxNumberFilteringOperand, + IgxStringFilteringOperand, + IgxDateFilteringOperand, + 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/public_api'; +import { DefaultDataCloneStrategy } from './data-clone-strategy'; + +/* Test sorting */ +const testSort = () => { + let data: any[] = []; + let dataGenerator: DataGenerator; + beforeEach(waitForAsync(() => { + dataGenerator = new DataGenerator(); + data = dataGenerator.data; + })); + describe('Test sorting', () => { + it('sorts descending column \'number\'', () => { + const se: ISortingExpression = { + dir: SortingDirection.Desc, + fieldName: 'number', + ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }; + const res = DataUtil.sort(data, [se]); + expect(dataGenerator.getValuesForColumn(res, 'number')) + .toEqual(dataGenerator.generateArray(4, 0)); + }); + it('sorts ascending column \'boolean\'', () => { + const se: ISortingExpression = { + dir: SortingDirection.Asc, + fieldName: 'boolean', + ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }; + const res = DataUtil.sort(data, [se]); + expect(dataGenerator.getValuesForColumn(res, 'boolean')) + .toEqual([false, false, false, true, true]); + }); + // test multiple sorting + it('sorts descending column \'boolean\', sorts \'date\' ascending', () => { + const se0: ISortingExpression = { + dir: SortingDirection.Desc, + fieldName: 'boolean', + ignoreCase: false, + strategy: DefaultSortingStrategy.instance() + }; + const se1: ISortingExpression = { + dir: SortingDirection.Asc, + fieldName: 'date', + ignoreCase: false, + strategy: DefaultSortingStrategy.instance() + }; + const res = DataUtil.sort(data, [se0, se1]); + expect(dataGenerator.getValuesForColumn(res, 'number')) + .toEqual([1, 3, 0, 2, 4]); + }); + it('sorts as applying default setting ignoreCase to false', () => { + data[4].string = data[4].string.toUpperCase(); + const se0: ISortingExpression = { + dir: SortingDirection.Desc, + fieldName: 'string', + ignoreCase: false, + strategy: DefaultSortingStrategy.instance() + }; + let res = DataUtil.sort(data, [se0]); + expect(dataGenerator.getValuesForColumn(res, 'number')) + .toEqual([3, 2, 1, 0, 4], 'expressionDefaults.ignoreCase = false'); + se0.ignoreCase = true; + res = DataUtil.sort(data, [se0]); + expect(dataGenerator.getValuesForColumn(res, 'number')) + .toEqual(dataGenerator.generateArray(4, 0)); + }); + }); +}; + +const testGroupBy = () => { + let data: any[] = []; + let dataGenerator: DataGenerator; + let expr: ISortingExpression; + let state: IGroupingState; + beforeEach(waitForAsync(() => { + dataGenerator = new DataGenerator(); + data = dataGenerator.data; + expr = { + dir: SortingDirection.Asc, + fieldName: 'boolean', + ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }; + state = { + expressions: [expr], + expansion: [], + defaultExpanded: true + }; + })); + describe('Test groupBy', () => { + it('groups by descending column "boolean", expanded', () => { + // sort + let result = DataUtil.sort(data, [expr]); + // group by + const groupResult = DataUtil.group(result, state); + result = groupResult.data; + expect(dataGenerator.getValuesForColumn(result, 'boolean')) + .toEqual([undefined, false, false, false, undefined, true, true]); + const groups: Array = dataGenerator.getGroupRecords(result); + const group1: IGroupByRecord = groupResult.metadata[1]; + const group2: IGroupByRecord = groupResult.metadata[5]; + expect(groups[0]).toEqual(null); + expect(result[0]).toEqual(group1); + expect(groups[4]).toEqual(null); + expect(result[4]).toEqual(group2); + expect(groupResult.metadata[1]).toEqual(groupResult.metadata[2]); + expect(groupResult.metadata[2]).toEqual(groupResult.metadata[3]); + expect(groupResult.metadata[5]).toEqual(groupResult.metadata[6]); + expect(group1.level).toEqual(0); + expect(group2.level).toEqual(0); + expect(group1.records).toEqual(result.slice(1, 4)); + expect(group2.records).toEqual(result.slice(5, 7)); + expect(group1.value).toEqual(false); + expect(group2.value).toEqual(true); + }); + + it('groups by descending column "boolean", collapsed', () => { + state.defaultExpanded = false; + // sort + const sorted = DataUtil.sort(data, [expr]); + // group by + const groupResult = DataUtil.group(sorted, state); + const result = groupResult.data; + expect(dataGenerator.getValuesForColumn(result, 'boolean')) + .toEqual([undefined, undefined]); + const groups: Array = dataGenerator.getGroupRecords(result); + expect(groups[0]).toEqual(null); + expect(groups[1]).toEqual(null); + expect(result[0].level).toEqual(0); + expect(result[1].level).toEqual(0); + expect(result[0].records).toEqual(sorted.slice(0, 3)); + expect(result[1].records).toEqual(sorted.slice(3, 5)); + expect(result[0].value).toEqual(false); + expect(result[1].value).toEqual(true); + }); + + it('groups by ascending column "boolean", partially collapsed', () => { + state.expansion.push({ + expanded: false, + hierarchy: [{ fieldName: 'boolean', value: false }] + }); + // sort + const sorted = DataUtil.sort(data, [expr]); + // group by + const groupRecords = DataUtil.group(sorted, state); + const result = groupRecords.data; + expect(dataGenerator.getValuesForColumn(result, 'boolean')) + .toEqual([undefined, undefined, true, true]); + const groups: Array = dataGenerator.getGroupRecords(result); + expect(groups[0]).toEqual(null); + expect(result[1]).toEqual(groupRecords.metadata[2]); + expect(result[0].level).toEqual(0); + expect(result[1].level).toEqual(0); + expect(result[0].records).toEqual(sorted.slice(0, 3)); + expect(result[1].records).toEqual(sorted.slice(3, 5)); + expect(result[0].value).toEqual(false); + expect(result[1].value).toEqual(true); + }); + + it('two level groups', () => { + const expr2 = { + fieldName: 'string', + dir: SortingDirection.Asc, + ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }; + state.expressions.push(expr2); + // sort + const sorted = DataUtil.sort(data, [expr, expr2]); + // group by + const groupRecords = DataUtil.group(sorted, state); + const result = groupRecords.data; + expect(dataGenerator.getValuesForColumn(result, 'boolean')) + .toEqual([undefined, undefined, false, undefined, false, + undefined, false, undefined, undefined, true, undefined, true]); + expect(dataGenerator.getValuesForColumn(result, 'string')) + .toEqual([undefined, undefined, 'row0, col1', undefined, 'row2, col1', + undefined, 'row4, col1', undefined, undefined, 'row1, col1', undefined, 'row3, col1']); + const group1: IGroupByRecord = groupRecords.metadata[2]; + const group2: IGroupByRecord = group1.groupParent; + const group3: IGroupByRecord = groupRecords.metadata[9]; + const group4: IGroupByRecord = group3.groupParent; + expect(group1).toEqual(result[1]); + expect(group2).toEqual(result[0]); + expect(group3).toEqual(result[8]); + expect(group4).toEqual(result[7]); + expect(group1.level).toEqual(1); + expect(group2.level).toEqual(0); + expect(group3.level).toEqual(1); + expect(group4.level).toEqual(0); + }); + + it('groups by descending column "boolean", paging', () => { + // sort + const sorted = DataUtil.sort(data, [expr]); + // group by + const groupResult = DataUtil.group(sorted, state); + // page + let paged: IGroupByResult = { + data: cloneArray(groupResult.data), + metadata: cloneArray(groupResult.metadata) + }; + paged.data = DataUtil.page(paged.data, { index: 0, recordsPerPage: 3 }); + paged.metadata = DataUtil.page(paged.metadata, { index: 0, recordsPerPage: 3 }); + expect(dataGenerator.getValuesForColumn(paged.data, 'boolean')) + .toEqual([undefined, false, false]); + let groups: Array = dataGenerator.getGroupRecords(paged.data); + const group1: IGroupByRecord = paged.metadata[1]; + expect(groups[0]).toEqual(null); + expect(paged.data[0]).toEqual(group1); + expect(paged.metadata[2]).toEqual(group1); + expect(group1.level).toEqual(0); + expect(group1.records).toEqual(sorted.slice(0, 3)); + expect(group1.value).toEqual(false); + + // page 2 + paged = { + data: cloneArray(groupResult.data), + metadata: cloneArray(groupResult.metadata) + }; + paged.data = DataUtil.page(paged.data, { index: 1, recordsPerPage: 3 }); + paged.metadata = DataUtil.page(paged.metadata, { index: 1, recordsPerPage: 3 }); + expect(dataGenerator.getValuesForColumn(paged.data, 'boolean')) + .toEqual([false, undefined, true]); + groups = dataGenerator.getGroupRecords(paged.data); + const group2: IGroupByRecord = paged.metadata[0]; + const group3: IGroupByRecord = paged.metadata[2]; + // group is split + expect(group2).toEqual(group1); + expect(paged.data[1]).toEqual(group3); + expect(groups[1]).toEqual(null); + expect(group2.value).toEqual(false); + expect(group3.value).toEqual(true); + expect(group2.records).toEqual(sorted.slice(0, 3)); + expect(group3.records).toEqual(sorted.slice(3, 5)); + }); + + it('provides groupsRecords array', () => { + const groupRecords = []; + // sort + const res = DataUtil.sort(data, [expr]); + // group by + DataUtil.group(res, state, undefined, null, groupRecords); + expect(groupRecords.length).toEqual(2); + expect(groupRecords[0].records.length).toEqual(3); + expect(groupRecords[1].records.length).toEqual(2); + expect(groupRecords[0].groups.length).toEqual(0); + expect(groupRecords[1].groups.length).toEqual(0); + const expr2 = { + fieldName: 'string', + dir: SortingDirection.Asc, + ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }; + state.expressions.push(expr2); + // sort + const sorted = DataUtil.sort(data, [expr, expr2]); + // group by + DataUtil.group(sorted, state, undefined, null, groupRecords); + expect(groupRecords.length).toEqual(2); + expect(groupRecords[0].records.length).toEqual(3); + expect(groupRecords[1].records.length).toEqual(2); + expect(groupRecords[0].groups.length).toEqual(3); + expect(groupRecords[1].groups.length).toEqual(2); + }); + + it('produces correct mixed collapse/expand state for three groups', () => { + const expr2 = { + fieldName: 'string', + dir: SortingDirection.Asc, + ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }; + const expr3 = { + fieldName: 'string', + dir: SortingDirection.Asc, + ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }; + state.expressions.push(expr2); + state.expressions.push(expr3); + state.expansion.push({ + expanded: true, + hierarchy: [{ fieldName: 'boolean', value: true }] + }); + state.defaultExpanded = false; + // sort + const sorted = DataUtil.sort(data, [expr, expr2, expr3]); + // group by + const groupResult = DataUtil.group(sorted, state); + const result = groupResult.data; + expect(result.length).toEqual(4); + expect(result[1].groups[0]).toEqual(result[2]); + expect(result[1].groups[1]).toEqual(result[3]); + }); + }); +}; +/* //Test sorting */ + +/* Test filtering */ +class CustomFilteringStrategy extends FilteringStrategy { + public override filter(data: T[], expressionsTree: IFilteringExpressionsTree): T[] { + const len = Math.ceil(data.length / 2); + const res: T[] = []; + let i; + let rec; + if (!expressionsTree || !expressionsTree.filteringOperands || expressionsTree.filteringOperands.length === 0 || !len) { + return data; + } + for (i = 0; i < len; i++) { + rec = data[i]; + if (this.matchRecord(rec, expressionsTree)) { + res.push(rec); + } + } + return res; + } +} + +const testFilter = () => { + const dataGenerator: DataGenerator = new DataGenerator(); + const data: any[] = dataGenerator.data; + describe('test filtering', () => { + it('filters \'number\' column greater than 3', () => { + const state: IFilteringState = { + expressionsTree: new FilteringExpressionsTree(FilteringLogic.And) + }; + state.expressionsTree.filteringOperands = [ + { + fieldName: 'number', + condition: IgxNumberFilteringOperand.instance().condition('greaterThan'), + conditionName: 'greaterThan', + searchVal: 3 + } + ]; + const res = FilterUtil.filter(data, state); + expect(dataGenerator.getValuesForColumn(res, 'number')) + .toEqual([4]); + }); + // test string filtering - with ignoreCase true/false + it('filters \'string\' column contains \'row\'', () => { + const state: IFilteringState = { + expressionsTree: new FilteringExpressionsTree(FilteringLogic.And) + }; + state.expressionsTree.filteringOperands = [ + { + condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains', + fieldName: 'string', + searchVal: 'row' + } + ]; + + const stateIgnoreCase: IFilteringState = { + expressionsTree: new FilteringExpressionsTree(FilteringLogic.And) + }; + stateIgnoreCase.expressionsTree.filteringOperands = [ + { + condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains', + fieldName: 'string', + ignoreCase: false, + searchVal: 'ROW' + } + ]; + + let res = FilterUtil.filter(data, state); + expect(dataGenerator.getValuesForColumn(res, 'number')) + .toEqual(dataGenerator.getValuesForColumn(data, 'number')); + (res[0] as { string: string }).string = 'ROW'; + // case-sensitive + res = FilterUtil.filter(res, stateIgnoreCase); + expect(dataGenerator.getValuesForColumn(res, 'number')) + .toEqual([0]); + }); + // test date + it('filters \'date\' column', () => { + const state: IFilteringState = { + expressionsTree: new FilteringExpressionsTree(FilteringLogic.And) + }; + state.expressionsTree.filteringOperands = [ + { + condition: IgxDateFilteringOperand.instance().condition('after'), + conditionName: 'after', + fieldName: 'date', + searchVal: new Date() + } + ]; + const res = FilterUtil.filter(data, state); + expect(dataGenerator.getValuesForColumn(res, 'number')) + .toEqual([1, 2, 3, 4]); + }); + it('filters \'bool\' column', () => { + const state: IFilteringState = { + expressionsTree: new FilteringExpressionsTree(FilteringLogic.And) + }; + state.expressionsTree.filteringOperands = [ + { + condition: IgxBooleanFilteringOperand.instance().condition('false'), + conditionName: 'false', + fieldName: 'boolean' + } + ]; + const res = FilterUtil.filter(data, state); + expect(dataGenerator.getValuesForColumn(res, 'number')) + .toEqual([0, 2, 4]); + }); + it('filters using custom filtering strategy', () => { + const state: IFilteringState = { + expressionsTree: new FilteringExpressionsTree(FilteringLogic.And), + strategy: new CustomFilteringStrategy() + }; + state.expressionsTree.filteringOperands = [ + { + condition: IgxBooleanFilteringOperand.instance().condition('false'), + conditionName: 'false', + fieldName: 'boolean' + } + ]; + const res = FilterUtil.filter(data, state); + expect(dataGenerator.getValuesForColumn(res, 'number')) + .toEqual([0, 2]); + }); + }); +}; +/* //Test filtering */ + +/* Test paging */ +const testPage = () => { + const dataGenerator: DataGenerator = new DataGenerator(); + const data: any[] = dataGenerator.data; + + describe('test paging', () => { + it('paginates data', () => { + let state: IPagingState = { index: 0, recordsPerPage: 3 }; + let res = DataUtil.page(data, state); + expect(state.metadata.error).toBe(PagingError.None); + expect(state.metadata.countPages).toBe(2); + expect(dataGenerator.getValuesForColumn(res, 'number')) + .toEqual([0, 1, 2]); + // go to second page + state = { index: 1, recordsPerPage: 3 }; + res = DataUtil.page(data, state); + expect(state.metadata.error).toBe(PagingError.None); + expect(state.metadata.countPages).toBe(2); + expect(dataGenerator.getValuesForColumn(res, 'number')) + .toEqual([3, 4]); + }); + it('tests paging errors', () => { + let state: IPagingState = { index: -1, recordsPerPage: 3 }; + let res = DataUtil.page(data, state); + expect(state.metadata.error).toBe(PagingError.IncorrectPageIndex); + state = { index: 3, recordsPerPage: 3 }; + res = DataUtil.page(data, state); + expect(state.metadata.error).toBe(PagingError.IncorrectPageIndex); + state = { index: 3, recordsPerPage: 0 }; + res = DataUtil.page(data, state); + expect(state.metadata.error).toBe(PagingError.IncorrectRecordsPerPage); + // test with paging state null + res = DataUtil.page(data, null); + expect(dataGenerator.getValuesForColumn(res, 'number')) + .toEqual(dataGenerator.generateArray(0, 4)); + }); + }); +}; +/* //Test paging */ + +/* Test merging */ +const testMerging = () => { + describe('Test merging', () => { + it('Should merge add transactions correctly', () => { + const data = SampleTestData.personIDNameData(); + const addRow4 = { ID: 4, IsEmployed: true, Name: 'Peter' }; + const addRow5 = { ID: 5, IsEmployed: true, Name: 'Mimi' }; + const addRow6 = { ID: 6, IsEmployed: false, 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 cloneStrategy = new DefaultDataCloneStrategy(); + 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', cloneStrategy, true); + expect(data.length).toBe(1); + expect(data[0]).toEqual(secondRow); + }); + + it('Should merge add hierarchical transactions correctly', () => { + const cloneStrategy = new DefaultDataCloneStrategy(); + 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', cloneStrategy, 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 cloneStrategy = new DefaultDataCloneStrategy(); + 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',cloneStrategy, 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 cloneStrategy = new DefaultDataCloneStrategy(); + 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', cloneStrategy, 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/core/src/data-operations/data-util.ts b/projects/igniteui-angular/core/src/data-operations/data-util.ts new file mode 100644 index 00000000000..93c6c54fbb3 --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/data-util.ts @@ -0,0 +1,285 @@ +import { IGroupByResult } from './grouping-result.interface'; + +import { IPagingState, PagingError } from './paging-state.interface'; + +import { IGroupByKey } from './groupby-expand-state.interface'; +import { IGroupByRecord } from './groupby-record.interface'; +import { IGroupingState } from './groupby-state.interface'; +import { cloneArray, mergeObjects } from '../core/utils'; +import { Transaction, TransactionType, HierarchicalTransaction } from '../services/transaction/transaction'; +import { getHierarchy, isHierarchyMatch } from './operations'; +import type { ColumnType, GridTypeBase, ITreeGridRecord } from './grid-types'; +import { ISortingExpression } from './sorting-strategy'; +import { + IGridSortingStrategy, + IGridGroupingStrategy, + IgxDataRecordSorting, + IgxSorting, + IgxGrouping +} from './grid-sorting-strategy'; +import { DefaultDataCloneStrategy, IDataCloneStrategy } from '../data-operations/data-clone-strategy'; +import { IGroupingExpression } from './grouping-expression.interface'; +import { DefaultMergeStrategy, IGridMergeStrategy } from './merge-strategy'; +import { IFilteringExpressionsTree } from './filtering-expressions-tree'; +import { FilteringStrategy, FilterUtil } from './filtering-strategy'; +import { GridColumnDataType } from './grid-types'; + +/** + * @hidden + */ +export class DataUtil { + public static sort(data: T[], expressions: ISortingExpression[], sorting: IGridSortingStrategy = new IgxSorting(), + grid?: GridTypeBase): T[] { + return sorting.sort(data, expressions, grid); + } + + public static treeGridSort(hierarchicalData: ITreeGridRecord[], + expressions: ISortingExpression[], + sorting: IGridSortingStrategy = new IgxDataRecordSorting(), + grid?: GridTypeBase): ITreeGridRecord[] { + const res: ITreeGridRecord[] = []; + const stack: { + original: ITreeGridRecord[]; + parent?: ITreeGridRecord; + result: ITreeGridRecord[]; + }[] = []; + + stack.push({ original: hierarchicalData, parent: null, result: res }); + + while (stack.length > 0) { + const { original, parent, result } = stack.pop()!; + + const clonedRecords: ITreeGridRecord[] = []; + + for (const treeRecord of original) { + const rec: ITreeGridRecord = DataUtil.cloneTreeGridRecord(treeRecord); + rec.parent = parent; + clonedRecords.push(rec); + + // If it has children, process them later + if (rec.children && rec.children.length > 0) { + const childClones: ITreeGridRecord[] = []; + rec.children = childClones; + stack.push({ + original: treeRecord.children, + parent: rec, + result: childClones + }); + } + } + + // Sort the clonedRecords before assigning to the result + const sorted = DataUtil.sort(clonedRecords, expressions, sorting, grid); + for (const item of sorted) { + result.push(item); + } + } + + return res; + } + + public static cloneTreeGridRecord(hierarchicalRecord: ITreeGridRecord) { + const rec: ITreeGridRecord = { + key: hierarchicalRecord.key, + data: hierarchicalRecord.data, + children: hierarchicalRecord.children, + isFilteredOutParent: hierarchicalRecord.isFilteredOutParent, + level: hierarchicalRecord.level, + expanded: hierarchicalRecord.expanded + }; + return rec; + } + + public static group(data: T[], state: IGroupingState, grouping: IGridGroupingStrategy = new IgxGrouping(), grid: GridTypeBase = null, + groupsRecords: any[] = [], fullResult: IGroupByResult = { data: [], metadata: [] }): IGroupByResult { + groupsRecords.splice(0, groupsRecords.length); + return grouping.groupBy(data, state, grid, groupsRecords, fullResult); + } + + public static merge(data: T[], columns: ColumnType[], strategy: IGridMergeStrategy = new DefaultMergeStrategy(), activeRowIndexes = [], grid: GridTypeBase = null, + ): any[] { + const result = []; + for (const col of columns) { + 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) { + return data; + } + const len = dataLength !== undefined ? dataLength : data.length; + const index = state.index; + const res = []; + const recordsPerPage = dataLength !== undefined && state.recordsPerPage > dataLength ? dataLength : state.recordsPerPage; + state.metadata = { + countPages: 0, + countRecords: len, + error: PagingError.None + }; + if (index < 0 || isNaN(index)) { + state.metadata.error = PagingError.IncorrectPageIndex; + return res; + } + if (recordsPerPage <= 0 || isNaN(recordsPerPage)) { + state.metadata.error = PagingError.IncorrectRecordsPerPage; + return res; + } + state.metadata.countPages = Math.ceil(len / recordsPerPage); + if (!len) { + return data; + } + if (index >= state.metadata.countPages) { + state.metadata.error = PagingError.IncorrectPageIndex; + return res; + } + return data.slice(index * recordsPerPage, (index + 1) * recordsPerPage); + } + + public static correctPagingState(state: IPagingState, length: number) { + const maxPage = Math.ceil(length / state.recordsPerPage) - 1; + if (!isNaN(maxPage) && state.index > maxPage) { + state.index = maxPage; + } + } + + public static getHierarchy(gRow: IGroupByRecord): Array { + return getHierarchy(gRow); + } + + public static isHierarchyMatch(h1: Array, h2: Array, expressions: IGroupingExpression[]): boolean { + return isHierarchyMatch(h1, h2, expressions); + } + + /** + * Merges all changes from provided transactions into provided data collection + * + * @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, cloneStrategy: IDataCloneStrategy = new DefaultDataCloneStrategy(), deleteRows = false): T[] { + data.forEach((item: any, index: number) => { + const rowId = primaryKey ? item[primaryKey] : item; + const transaction = transactions.find(t => t.id === rowId); + if (transaction && transaction.type === TransactionType.UPDATE) { + data[index] = mergeObjects(cloneStrategy.clone(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; + } + + /** + * 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, + cloneStrategy: IDataCloneStrategy = new DefaultDataCloneStrategy(), + deleteRows = 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(cloneStrategy.clone(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; + } + } else { + // if there is no path this is ADD row in root. Push the newValue to data + data.push(transaction.newValue); + } + } + return data; + } + + public static parseValue(dataType: GridColumnDataType, value: any): any { + if (dataType === GridColumnDataType.Number || dataType === GridColumnDataType.Currency || dataType === GridColumnDataType.Percent) { + value = parseFloat(value); + } + + return value; + } + + public static filterDataByExpressions(data: any[], expressionsTree: IFilteringExpressionsTree, grid: GridTypeBase): any { + if (expressionsTree.filteringOperands.length) { + const state = { expressionsTree, strategy: FilteringStrategy.instance() }; + data = FilterUtil.filter(cloneArray(data), state, grid); + } + + 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/core/src/data-operations/expressions-tree-util.spec.ts b/projects/igniteui-angular/core/src/data-operations/expressions-tree-util.spec.ts new file mode 100644 index 00000000000..528c18e79d7 --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/expressions-tree-util.spec.ts @@ -0,0 +1,480 @@ +import { FilteringLogic, IFilteringExpression } from './filtering-expression.interface'; +import { FilteringExpressionsTree, IFilteringExpressionsTree } from './filtering-expressions-tree'; +import { recreateTree, recreateTreeFromFields } from './expressions-tree-util'; +import { IgxBooleanFilteringOperand, IgxDateFilteringOperand, IgxDateTimeFilteringOperand, IgxNumberFilteringOperand, IgxStringFilteringOperand, IgxTimeFilteringOperand } from './filtering-condition'; +import type { EntityType, FieldType } from './grid-types'; + +function serialize(value: unknown, pretty = false) { + return pretty ? JSON.stringify(value, undefined, ' ') : JSON.stringify(value) +} + +function checkOp(op: IFilteringExpression, reconstructedOp: IFilteringExpression) { + expect(reconstructedOp.condition.logic).not.toBeNull(); + expect(reconstructedOp.condition.name).toBe(op.conditionName); + expect(reconstructedOp.conditionName).toBe(op.conditionName); + + if (op.searchTree) { + for (let index = 0; index < op.searchTree.filteringOperands.length; index++) { + const innerOp = op.searchTree.filteringOperands[index] as IFilteringExpression; + const reconstructedInnerOp = reconstructedOp.searchTree.filteringOperands[index] as IFilteringExpression; + checkOp(innerOp, reconstructedInnerOp); + } + } +} + +describe('Unit testing FilteringUtil', () => { + it('Expressions should resolve correctly when rehydrating with fields', () => { + const tree = new FilteringExpressionsTree(FilteringLogic.Or, 'myField', 'myEntity', ['*']); + const currDate = new Date(); + + const fields = [ + { field: 'Id', dataType: 'number' }, + { field: 'Name', dataType: 'string' }, + { field: 'Validated', dataType: 'boolean' }, + { field: 'Date created', dataType: 'date' }, + { field: 'Time created', dataType: 'time' }, + { field: 'DateTime created', dataType: 'dateTime' } + ] as FieldType[]; + + // number + tree.filteringOperands.push({ + fieldName: 'Id', + conditionName: 'equals', + searchVal: 100 + }); + + // boolean + tree.filteringOperands.push({ + fieldName: 'Validated', + conditionName: 'false' + }); + + // string + tree.filteringOperands.push({ + fieldName: 'Name', + conditionName: 'equals', + searchVal: 'test' + }); + + // DateTime + tree.filteringOperands.push({ + fieldName: 'DateTime created', + conditionName: 'equals', + searchVal: currDate + }); + + // Date + tree.filteringOperands.push({ + fieldName: 'Date created', + conditionName: 'equals', + searchVal: currDate + }); + + // Time + tree.filteringOperands.push({ + fieldName: 'Time created', + conditionName: 'at', + searchVal: currDate + }); + + // misc + tree.filteringOperands.push({ + fieldName: 'Id', + conditionName: 'inQuery' + }); + + tree.filteringOperands.push({ + fieldName: 'Id', + conditionName: 'notInQuery' + }); + + tree.filteringOperands.push({ + fieldName: 'Id', + conditionName: 'null' + }); + + tree.filteringOperands.push({ + fieldName: 'Id', + conditionName: 'notNull' + }); + + const serializedTree = serialize(tree, true); + const deserializedTree = recreateTreeFromFields(JSON.parse(serializedTree), fields); + + for (let index = 0; index < tree.filteringOperands.length; index++) { + checkOp(tree.filteringOperands[index] as IFilteringExpression, deserializedTree.filteringOperands[index] as IFilteringExpression); + } + }); + + it('Should rehydrate correctly from direct object', () => { + const fields = [ + { field: 'Id', dataType: 'number' }, + { field: 'Name', dataType: 'string' }, + { field: 'Validated', dataType: 'boolean' }, + { field: 'DateTime created', dataType: 'dateTime' }, + { field: 'In', dataType: null } + ] as FieldType[]; + + const innerFields = [ + { field: 'Id', dataType: 'number' } + ] as FieldType[]; + + const entities: EntityType[] = [ + { + name: 'myEntity', + fields: fields + }, + { + name: 'otherEntity', + fields: innerFields + } + ]; + + const tree = { + filteringOperands: [ + { + fieldName: 'Id', + conditionName: 'equals', + searchVal: 100 + }, + { + fieldName: 'Name', + conditionName: 'equals', + searchVal: 'test' + }, + { + fieldName: 'Validated', + conditionName: 'false' + }, + { + fieldName: 'DateTime created', + conditionName: 'equals', + searchVal: new Date().toISOString() + } + ], + operator: FilteringLogic.And, + entity: 'myEntity', + returnFields: ['*'] + }; + + const deserializedTree = recreateTree(tree, entities); + + for (let index = 0; index < tree.filteringOperands.length; index++) { + checkOp(tree.filteringOperands[index], deserializedTree.filteringOperands[index] as IFilteringExpression); + } + }); + + it('Should not modify a fully constructed tree with no entities given', () => { + const tree = { + filteringOperands: [ + { + fieldName: 'Id', + conditionName: 'equals', + condition: IgxNumberFilteringOperand.instance().condition('equals'), + searchVal: 100 + }, + { + fieldName: 'Name', + conditionName: 'equals', + condition: IgxStringFilteringOperand.instance().condition('equals'), + searchVal: 'test' + }, + { + fieldName: 'Validated', + conditionName: 'false', + condition: IgxBooleanFilteringOperand.instance().condition('false') + }, + { + fieldName: 'DateTime created', + conditionName: 'equals', + condition: IgxDateTimeFilteringOperand.instance().condition('equals'), + searchVal: new Date().toISOString() + } + ], + operator: FilteringLogic.And, + entity: 'myEntity', + returnFields: ['*'] + }; + + const deserializedTree = recreateTree(tree, []); + + expect(deserializedTree).toEqual(tree); + + for (let index = 0; index < tree.filteringOperands.length; index++) { + checkOp(tree.filteringOperands[index], deserializedTree.filteringOperands[index] as IFilteringExpression); + const reconstructedOp = deserializedTree.filteringOperands[index] as IFilteringExpression; + // Explicitly check the logic function + expect(tree.filteringOperands[index].condition.logic.toString()).toBe(reconstructedOp.condition.logic.toString()); + } + }); + + it('Should not modify a fully constructed tree with fields given', () => { + + const fields = [ + { field: 'Id', dataType: 'number' }, + { field: 'Name', dataType: 'string' }, + { field: 'Validated', dataType: 'boolean' }, + { field: 'DateTime created', dataType: 'dateTime' }, + ] as FieldType[]; + + const tree = { + filteringOperands: [ + { + fieldName: 'Id', + conditionName: 'equals', + condition: IgxNumberFilteringOperand.instance().condition('equals'), + searchVal: 100 + }, + { + fieldName: 'Name', + conditionName: 'equals', + condition: IgxStringFilteringOperand.instance().condition('equals'), + searchVal: 'test' + }, + { + fieldName: 'Validated', + conditionName: 'false', + condition: IgxBooleanFilteringOperand.instance().condition('false') + }, + { + fieldName: 'DateTime created', + conditionName: 'equals', + condition: IgxDateTimeFilteringOperand.instance().condition('equals'), + searchVal: new Date().toISOString() + } + ], + operator: FilteringLogic.And, + entity: 'myEntity', + returnFields: ['*'] + }; + + const deserializedTree = recreateTreeFromFields(tree, fields); + + expect(deserializedTree).toEqual(tree); + + for (let index = 0; index < tree.filteringOperands.length; index++) { + checkOp(tree.filteringOperands[index], deserializedTree.filteringOperands[index] as IFilteringExpression); + const reconstructedOp = deserializedTree.filteringOperands[index] as IFilteringExpression; + // Explicitly check the logic function + expect(tree.filteringOperands[index].condition.logic.toString()).toBe(reconstructedOp.condition.logic.toString()); + } + }); + + it('Sub-queries should deserialize correctly', () => { + const tree = new FilteringExpressionsTree(FilteringLogic.Or, undefined, 'myEntity', ['*']); + const innerTree = new FilteringExpressionsTree(FilteringLogic.And, undefined, 'otherEntity', ['*']); + const entities: EntityType[] = [ + { + name: 'myEntity', + fields: [ + { field: 'Id', dataType: null } + ] as any[], + }, + { + name: 'otherEntity', + fields: [ + { field: 'Bool', dataType: 'boolean' } + ] as any[], + } + ]; + + innerTree.filteringOperands.push({ + fieldName: 'Bool', + conditionName: 'true', + searchVal: true + }); + + tree.filteringOperands.push({ + fieldName: 'Id', + conditionName: 'inQuery', + searchTree: innerTree + }); + + const serializedTree = serialize(tree, true); + const deserializedTree = recreateTree(JSON.parse(serializedTree), entities); + const firstOperand = deserializedTree.filteringOperands[0] as IFilteringExpression; + const nestedOperand = firstOperand.searchTree.filteringOperands[0] as IFilteringExpression; + + expect(firstOperand.conditionName).toBe('inQuery'); + expect(firstOperand.condition.name).toBe('inQuery'); + expect(nestedOperand.condition.logic(true, nestedOperand.searchVal)).toBe(true); + expect(nestedOperand.conditionName).toBe('true'); + expect(nestedOperand.condition.name).toBe('true'); + }); + + it('Number search values should deserialize correctly', () => { + const tree = new FilteringExpressionsTree(FilteringLogic.Or, 'myField', 'myEntity', ['*']); + const entities: EntityType[] = [{ + name: 'myEntity', + fields: [ + { field: 'Id', dataType: 'number' } + ] as any, + }]; + + tree.filteringOperands.push({ + fieldName: 'Id', + conditionName: 'equals', + searchVal: 100 + }); + + const serializedTree = serialize(tree, true); + const deserializedTree = recreateTree(JSON.parse(serializedTree), entities); + const firstOperand = deserializedTree.filteringOperands[0] as IFilteringExpression; + + expect(firstOperand.condition.logic(100, firstOperand.searchVal)).toBe(true); + expect(firstOperand.condition).toBe(IgxNumberFilteringOperand.instance().condition('equals')); + }); + + it('Boolean search values should deserialize correctly', () => { + const tree = new FilteringExpressionsTree(FilteringLogic.Or, 'myField', 'myEntity', ['*']); + const entities: EntityType[] = [{ + name: 'myEntity', + fields: [ + { field: 'Id', dataType: 'boolean' } + ] as any, + }]; + tree.filteringOperands.push({ + fieldName: 'Id', + conditionName: 'false' + }); + + const serializedTree = serialize(tree, true); + const deserializedTree = recreateTree(JSON.parse(serializedTree), entities); + const firstOperand = deserializedTree.filteringOperands[0] as IFilteringExpression; + + expect(firstOperand.condition.logic(false)).toBe(true); + expect(firstOperand.condition).toBe(IgxBooleanFilteringOperand.instance().condition('false')); + }); + + it('String search values should deserialize correctly', () => { + const tree = new FilteringExpressionsTree(FilteringLogic.Or, 'myField', 'myEntity', ['*']); + const entities: EntityType[] = [{ + name: 'myEntity', + fields: [ + { field: 'Id', dataType: 'string' } + ] as any, + }]; + tree.filteringOperands.push({ + fieldName: 'Id', + conditionName: 'equals', + searchVal: 'potato' + }); + + const serializedTree = serialize(tree, true); + const deserializedTree = recreateTree(JSON.parse(serializedTree), entities); + const firstOperand = deserializedTree.filteringOperands[0] as IFilteringExpression; + + expect(firstOperand.condition.logic('potato', firstOperand.searchVal)).toBe(true); + expect(firstOperand.condition).toBe(IgxStringFilteringOperand.instance().condition('equals')); + }); + + it('Date search values should deserialize correctly', () => { + const tree = new FilteringExpressionsTree(FilteringLogic.Or, 'myField', 'myEntity', ['*']); + const entities: EntityType[] = [{ + name: 'myEntity', + fields: [ + { field: 'Id', dataType: 'date' } + ] as any, + }]; + tree.filteringOperands.push({ + fieldName: 'Id', + conditionName: 'equals', + searchVal: new Date(2022, 2, 3).toISOString() + }); + + const serializedTree = serialize(tree, true); + const deserializedTree = recreateTree(JSON.parse(serializedTree), entities); + const firstOperand = deserializedTree.filteringOperands[0] as IFilteringExpression; + + expect(firstOperand.condition.logic(new Date(2022, 2, 3), firstOperand.searchVal)).toBe(true); + expect(firstOperand.condition).toBe(IgxDateFilteringOperand.instance().condition('equals')); + }); + + it('DateTime search values should deserialize correctly', () => { + const tree = new FilteringExpressionsTree(FilteringLogic.Or, 'myField', 'myEntity', ['*']); + const entities: EntityType[] = [{ + name: 'myEntity', + fields: [ + { field: 'Id', dataType: 'dateTime' } + ] as any, + }]; + const currDate = new Date(); + tree.filteringOperands.push({ + fieldName: 'Id', + conditionName: 'equals', + searchVal: currDate.toISOString() + }); + + const serializedTree = serialize(tree, true); + const deserializedTree = recreateTree(JSON.parse(serializedTree), entities); + const firstOperand = deserializedTree.filteringOperands[0] as IFilteringExpression; + + expect(firstOperand.condition.logic(currDate, firstOperand.searchVal)).toBe(true); + expect(firstOperand.condition).toBe(IgxDateTimeFilteringOperand.instance().condition('equals')); + }); + + it('Time search values should deserialize correctly', () => { + const tree = new FilteringExpressionsTree(FilteringLogic.Or, 'myField', 'myEntity', ['*']); + const entities: EntityType[] = [{ + name: 'myEntity', + fields: [ + { field: 'Id', dataType: 'time' } + ] as any, + }]; + tree.filteringOperands.push({ + fieldName: 'Id', + conditionName: 'at', + searchVal: '18:30:00' + }); + + const serializedTree = serialize(tree, true); + const deserializedTree = recreateTree(JSON.parse(serializedTree), entities); + const firstOperand = deserializedTree.filteringOperands[0] as IFilteringExpression; + + expect(firstOperand.condition.logic(new Date(2020, 9, 2, 18, 30, 0, 0), firstOperand.searchVal)).toBe(true); + expect(firstOperand.condition).toBe(IgxTimeFilteringOperand.instance().condition('at')); + }); + + it('Nested tree should deserialize correctly', () => { + const tree = new FilteringExpressionsTree(FilteringLogic.Or, 'myField', 'myEntity', ['*']); + const subTree = new FilteringExpressionsTree(FilteringLogic.Or, 'myField2', 'myEntity2', ['*']); + const currDate = new Date(); + const entities: EntityType[] = [ + { + name: 'myEntity', + fields: [ + { field: 'date', dataType: 'date' } + ] as any[], + }, + { + name: 'myEntity2', + fields: [ + { field: 'id', dataType: 'number' } + ] as any[], + } + ]; + + tree.filteringOperands.push({ + fieldName: 'date', + conditionName: 'equals', + searchVal: currDate.toISOString() + }); + subTree.filteringOperands.push({ + fieldName: 'id', + conditionName: 'greaterThan', + searchVal: 123 + }); + tree.filteringOperands.push(subTree); + + const serializedTree = serialize(tree, true); + const deserializedTree = recreateTree(JSON.parse(serializedTree), entities); + const firstOperand = deserializedTree.filteringOperands[0] as IFilteringExpression; + const nestedCondition = (deserializedTree.filteringOperands[1] as IFilteringExpressionsTree).filteringOperands[0] as IFilteringExpression; + + expect(firstOperand.condition.name).toBe('equals'); + expect(firstOperand.condition.logic(currDate, firstOperand.searchVal)).toBe(true); + + expect(nestedCondition.condition.name).toBe('greaterThan'); + expect(nestedCondition.condition.logic(200, nestedCondition.searchVal)).toBe(true); + }); +}); diff --git a/projects/igniteui-angular/core/src/data-operations/expressions-tree-util.ts b/projects/igniteui-angular/core/src/data-operations/expressions-tree-util.ts new file mode 100644 index 00000000000..3f50f2de627 --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/expressions-tree-util.ts @@ -0,0 +1,221 @@ +import type { EntityType, FieldType } from './grid-types'; +import { GridColumnDataType } from './grid-types'; +import { IFilteringOperation, IgxBooleanFilteringOperand, IgxDateFilteringOperand, IgxDateTimeFilteringOperand, IgxFilteringOperand, IgxNumberFilteringOperand, IgxStringFilteringOperand, IgxTimeFilteringOperand } from './filtering-condition'; +import { IFilteringExpression } from './filtering-expression.interface'; +import { IExpressionTree, IFilteringExpressionsTree } from './filtering-expressions-tree'; +import { DateTimeUtil } from '../date-common/public_api'; + +export class ExpressionsTreeUtil { + /** + * Returns the filtering expression for a column with the provided tree and fieldName. + * ```typescript + * let filteringExpression = ExpressionsTreeUtil.find(gridExpressionTree, 'Column Field'); + * ``` + */ + public static find(tree: IFilteringExpressionsTree, fieldName: string): IFilteringExpressionsTree | IFilteringExpression { + const index = this.findIndex(tree, fieldName); + + if (index > -1) { + return tree.filteringOperands[index]; + } + + return null; + } + + /** + * Returns the index of the filtering expression for a column with the provided tree and fieldName. + * ```typescript + * let filteringExpressionIndex = ExpressionsTreeUtil.findIndex(gridExpressionTree, 'Column Field'); + * ``` + */ + public static findIndex(tree: IFilteringExpressionsTree, fieldName: string): number { + for (let i = 0; i < tree.filteringOperands.length; i++) { + const expr = tree.filteringOperands[i]; + if ((expr as IFilteringExpressionsTree).operator !== undefined) { + if (this.isFilteringExpressionsTreeForColumn(expr as IFilteringExpressionsTree, fieldName)) { + return i; + } + } else if ((expr as IFilteringExpression).fieldName === fieldName) { + return i; + } + } + + return -1; + } + + protected static isFilteringExpressionsTreeForColumn(expressionsTree: IFilteringExpressionsTree, fieldName: string): boolean { + if (expressionsTree.fieldName === fieldName) { + return true; + } + + for (const expr of expressionsTree.filteringOperands) { + if ((expr as IFilteringExpressionsTree).operator !== undefined) { + return this.isFilteringExpressionsTreeForColumn(expr as IFilteringExpressionsTree, fieldName); + } else if ((expr as IFilteringExpression).fieldName === fieldName) { + return true; + } + } + return false; + } +} + +/** + * Recreates the search value for a given expression. + * @param searchValue The search value to recreate. + * @param dataType The data type of the field. + * @returns The recreated search value. + */ +function recreateSearchValue(searchValue: any, dataType: string): any { + if (!dataType && !Array.isArray(searchValue)) { + return searchValue; + } + // In ESF, values are stored as a Set. + // Those values are converted to an array before returning string in the stringifyCallback + // now we need to convert those back to Set + if (Array.isArray(searchValue)) { + return new Set(searchValue); + } else if ((dataType.toLowerCase().includes('date') || dataType.toLowerCase().includes('time')) && !(searchValue instanceof Date)) { + return DateTimeUtil.parseIsoDate(searchValue) ?? searchValue; + } + + return searchValue; +} + +/** + * Returns the filtering logic function for a given dataType and condition (contains, greaterThan, etc.) + * @param dataType The data type of the field. + * @param name The name of the filtering condition. + * @returns The filtering logic function. + */ +function getFilteringCondition(dataType: string, name: string): IFilteringOperation { + let filters: IgxFilteringOperand; + switch (dataType) { + case GridColumnDataType.Boolean: + filters = IgxBooleanFilteringOperand.instance(); + break; + case GridColumnDataType.Number: + case GridColumnDataType.Currency: + case GridColumnDataType.Percent: + filters = IgxNumberFilteringOperand.instance(); + break; + case GridColumnDataType.Date: + filters = IgxDateFilteringOperand.instance(); + break; + case GridColumnDataType.Time: + filters = IgxTimeFilteringOperand.instance(); + break; + case GridColumnDataType.DateTime: + filters = IgxDateTimeFilteringOperand.instance(); + break; + case GridColumnDataType.String: + default: + filters = IgxStringFilteringOperand.instance(); + break; + } + return filters.condition(name); +} + +/** + * Recreates the IFilteringOperation for a given expression. + * If the `logic` is already populated - it will return the original IFilteringOperation + * of the expression. + * @param expression The expression for which to resolve the IFilteringOperation. + * @param dataType The data type of the field. + * @returns The IFilteringOperation for the given expression. + */ +function recreateOperatorFromDataType(expression: IFilteringExpression, dataType: string): IFilteringOperation { + if (!expression.condition?.logic) { + return getFilteringCondition(dataType, expression.conditionName || expression.condition?.name); + } + + return expression.condition; +} + +/** + * Recreates an expression from the given fields by applying the correct operands + * and adjusting the search value to be the correct type. + * @param expression The expression to recreate. + * @param fields An array of fields to use for recreating the expression. + * @returns The recreated expression. + */ +function recreateExpression(expression: IFilteringExpression, fields: FieldType[]): IFilteringExpression { + const field = fields?.find(f => f.field === expression.fieldName); + + if (field && !expression.condition?.logic) { + if (!field.filters) { + expression.condition = recreateOperatorFromDataType(expression, field.dataType); + } else { + expression.condition = field.filters.condition(expression.conditionName || expression.condition?.name); + } + } + + if (!expression.condition && expression.conditionName) { + throw Error('Wrong `conditionName`, `condition` or `field` provided! It is possible that there is a type mismatch between the condition type and field type.'); + } + + if (!expression.conditionName) { + expression.conditionName = expression.condition?.name; + } + + expression.searchVal = recreateSearchValue(expression.searchVal, field?.dataType); + + return expression; +} + +/** + * Checks if the given entry is an IExpressionTree. + * @param entry The entry to check. + * @returns True if the entry is an IExpressionTree, false otherwise. + */ +export function isTree(entry: IExpressionTree | IFilteringExpression): entry is IExpressionTree { + return 'operator' in entry; +} + +/** + * Recreates the tree from a given array of entities by applying the correct operands + * for each expression and adjusting the search values to be the correct type. + * @param tree The expression tree to recreate. + * @param entities An array of entities to use for recreating the tree. + * @returns The recreated expression tree. + */ +export function recreateTree(tree: IExpressionTree, entities: EntityType[], isRoot: boolean = false): IExpressionTree { + const entity = isRoot ? entities[0] : entities.find(e => e.name === tree.entity); + if (!entity) return tree; + + for (let i = 0; i < tree.filteringOperands.length; i++) { + const operand = tree.filteringOperands[i]; + if (isTree(operand)) { + tree.filteringOperands[i] = recreateTree(operand, entities); + } else { + if (operand.searchTree) { + operand.searchTree = recreateTree(operand.searchTree, entities[0].childEntities ?? entities); + } + tree.filteringOperands[i] = recreateExpression(operand, entity?.fields); + } + } + + return tree; +} + +/** + * Recreates the tree from a given array of fields by applying the correct operands. + * It is recommended to use `recreateTree` if there will be multiple entities in the tree + * with potentially colliding field names. + * @param tree The expression tree to recreate. + * @param fields An array of fields to use for recreating the tree. + */ +export function recreateTreeFromFields(tree: IExpressionTree, fields: FieldType[]): IExpressionTree { + for (let i = 0; i < tree.filteringOperands.length; i++) { + const operand = tree.filteringOperands[i]; + if (isTree(operand)) { + tree.filteringOperands[i] = recreateTreeFromFields(operand, fields); + } else { + if (operand.searchTree) { + operand.searchTree = recreateTreeFromFields(operand.searchTree, fields); + } + tree.filteringOperands[i] = recreateExpression(operand, fields); + } + } + + return tree; +} diff --git a/projects/igniteui-angular/core/src/data-operations/filtering-condition.spec.ts b/projects/igniteui-angular/core/src/data-operations/filtering-condition.spec.ts new file mode 100644 index 00000000000..a8f8d0ade0e --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/filtering-condition.spec.ts @@ -0,0 +1,163 @@ +import { IgxStringFilteringOperand, + IgxNumberFilteringOperand, + IgxDateFilteringOperand, + IgxBooleanFilteringOperand, + IgxFilteringOperand} from './filtering-condition'; + +describe('Unit testing FilteringCondition', () => { + it('tests string conditions', () => { + const fc = IgxStringFilteringOperand.instance(); + // contains + expect(fc.condition('contains').logic('test123', 'esT')) + .toBeFalsy('contains ignoreCase: false'); + expect(fc.condition('contains').logic('test123', 'esT', true)) + .toBeTruthy('contains ignoreCase: true'); + // does not contain + expect(fc.condition('doesNotContain').logic('test123', 'esT')) + .toBeTruthy('doesNotContain ignoreCase: false'); + expect(fc.condition('doesNotContain').logic('test123', 'esT', true)) + .toBeFalsy('doesNotContain ignoreCase: true'); + // startsWith + expect(fc.condition('startsWith').logic('test123', 'TesT')) + .toBeFalsy('startsWith ignoreCase: false'); + expect(fc.condition('startsWith').logic('test123', 'TesT', true)) + .toBeTruthy('startsWith ignoreCase: true'); + // endsWith + expect(fc.condition('endsWith').logic('test123', 'T123')) + .toBeFalsy('endsWith ignoreCase: false'); + expect(fc.condition('endsWith').logic('test123', 'sT123', true)) + .toBeTruthy('endsWith ignoreCase: true'); + // equals + expect(fc.condition('equals').logic('test123', 'Test123')) + .toBeFalsy(); + expect(fc.condition('equals').logic('test123', 'Test123', true)) + .toBeTruthy(); + // doesNotEqual + expect(fc.condition('doesNotEqual').logic('test123', 'Test123')) + .toBeTruthy('doesNotEqual ignoreCase: false'); + expect(fc.condition('doesNotEqual').logic('test123', 'Test123', true)) + .toBeFalsy('doesNotEqual ignoreCase: true'); + // empty + expect(!fc.condition('empty').logic('test') && fc.condition('empty').logic(null) && fc.condition('empty').logic(undefined)) + .toBeTruthy('empty'); + // notEmpty + expect(fc.condition('notEmpty').logic('test') && !fc.condition('notEmpty').logic(null) && !fc.condition('notEmpty') + .logic(undefined)).toBeTruthy('notEmpty'); + // null + expect(!fc.condition('null').logic('test') && fc.condition('null').logic(null) && !fc.condition('null').logic(undefined)) + .toBeTruthy('null'); + // notNull + expect(fc.condition('notNull').logic('test') && !fc.condition('notNull').logic(null) && fc.condition('notNull').logic(undefined)) + .toBeTruthy('notNull'); + }); + it('tests number conditions', () => { + const fn = IgxNumberFilteringOperand.instance(); + expect(fn.condition('doesNotEqual').logic(1, 2) && !fn.condition('doesNotEqual').logic(1, 1)) + .toBeTruthy('doesNotEqual'); + expect(fn.condition('empty').logic(null)) + .toBeTruthy('empty'); + expect(!fn.condition('equals').logic(1, 2) && fn.condition('equals').logic(1, 1)) + .toBeTruthy('equals'); + expect(!fn.condition('greaterThan').logic(1, 2) && fn.condition('greaterThan').logic(2, 1)) + .toBeTruthy('greaterThan'); + expect(!fn.condition('greaterThanOrEqualTo').logic(1, 2) && !fn.condition('greaterThanOrEqualTo').logic(1, 2) && + fn.condition('greaterThanOrEqualTo').logic(1, 1)) + .toBeTruthy('greaterThanOrEqualTo'); + expect(fn.condition('lessThan').logic(1, 2) && !fn.condition('lessThan').logic(2, 2) && + !fn.condition('lessThan').logic(3, 2)) + .toBeTruthy('lessThan'); + expect(fn.condition('lessThanOrEqualTo').logic(1, 2) && + fn.condition('lessThanOrEqualTo').logic(1, 1) && + !fn.condition('lessThanOrEqualTo').logic(3, 2)) + .toBeTruthy('lessThanOrEqualTo'); + expect(fn.condition('notEmpty').logic(1)) + .toBeTruthy('notEmpty'); + expect(fn.condition('empty').logic(null)) + .toBeTruthy('empty'); + expect(fn.condition('notNull').logic(1)) + .toBeTruthy('notNull'); + expect(fn.condition('null').logic(null)) + .toBeTruthy('null'); + }); + it('tests date conditions', () => { + const fd = IgxDateFilteringOperand.instance(); + const now = new Date(); + const cnow = new Date(); + const yesterday = ((d) => new Date(d.setDate(d.getDate() - 1)))(new Date()); + const lastMonth = ((d) => { + d.setDate(1); return new Date(d.setMonth(d.getMonth() - 1)); +})(new Date()); + const nextMonth = ((d) => { + d.setDate(1); return new Date(d.setMonth(d.getMonth() + 1)); +})(new Date()); + const lastYear = ((d) => new Date(d.setFullYear(d.getFullYear() - 1)))(new Date()); + const nextYear = ((d) => new Date(d.setFullYear(d.getFullYear() + 1)))(new Date()); + + expect(fd.condition('after').logic(now, yesterday) && !fd.condition('after').logic(now, nextYear)) + .toBeTruthy('after'); + expect(fd.condition('before').logic(yesterday, now) && !fd.condition('before').logic(now, lastYear)) + .toBeTruthy('before'); + expect(fd.condition('doesNotEqual').logic(now, yesterday) && fd.condition('doesNotEqual').logic(now, yesterday)) + .toBeTruthy('doesNotEqual'); + expect(fd.condition('empty').logic(null) && fd.condition('empty').logic(undefined) && !fd.condition('empty').logic(now)) + .toBeTruthy('empty'); + expect(!fd.condition('notEmpty').logic(null) && !fd.condition('notEmpty').logic(undefined) && fd.condition('notEmpty').logic(now)) + .toBeTruthy('notEmpty'); + expect(fd.condition('equals').logic(now, cnow) && !fd.condition('equals').logic(now, yesterday)) + .toBeTruthy('equals'); + expect(!fd.condition('lastMonth').logic(now) && fd.condition('lastMonth').logic(lastMonth)) + .toBeTruthy('lastMonth'); + expect(fd.condition('lastYear').logic(lastYear) && !fd.condition('lastYear').logic(now)) + .toBeTruthy('lastYear'); + expect(!fd.condition('nextMonth').logic(now) && fd.condition('nextMonth').logic(nextMonth)) + .toBeTruthy('nextMonth'); + expect(!fd.condition('nextYear').logic(now) && fd.condition('nextYear').logic(nextYear)) + .toBeTruthy('nextYear'); + expect(fd.condition('notEmpty').logic(now) && !fd.condition('notEmpty').logic(null) && !fd.condition('notEmpty').logic(undefined)) + .toBeTruthy('notEmpty'); + expect(fd.condition('notNull').logic(now) && !fd.condition('notNull').logic(null) && fd.condition('notNull').logic(undefined)) + .toBeTruthy('notNull'); + expect(fd.condition('null').logic(null) && !fd.condition('null').logic(now) && !fd.condition('null').logic(undefined)) + .toBeTruthy('null'); + expect(fd.condition('thisMonth').logic(now) && !fd.condition('thisMonth').logic(nextYear)) + .toBeTruthy('thisMonth'); + expect(fd.condition('thisYear').logic(now) && !fd.condition('thisYear').logic(nextYear)) + .toBeTruthy('thisYear'); + expect(fd.condition('today').logic(now) && !fd.condition('today').logic(nextYear)) + .toBeTruthy('today'); + expect(!fd.condition('yesterday').logic(now) && fd.condition('yesterday').logic(yesterday)) + .toBeTruthy('yesterday'); + }); + it('tests boolean conditions', () => { + const f = IgxBooleanFilteringOperand.instance(); + expect(f.condition('empty').logic(null) && f.condition('empty').logic(undefined) && !f.condition('empty').logic(false)) + .toBeTruthy('empty'); + expect(f.condition('false').logic(false) && !f.condition('false').logic(true)) + .toBeTruthy('false'); + expect(!f.condition('true').logic(false) && f.condition('true').logic(true)) + .toBeTruthy('true'); + expect(!f.condition('notEmpty').logic(null) && !f.condition('notEmpty').logic(undefined) && f.condition('notEmpty').logic(false)) + .toBeTruthy('notEmpty'); + expect(f.condition('null').logic(null) && !f.condition('null').logic(undefined) && !f.condition('null').logic(false)) + .toBeTruthy('null'); + expect(!f.condition('notNull').logic(null) && f.condition('notNull').logic(undefined) && f.condition('notNull').logic(false)) + .toBeTruthy('notNull'); + }); + it('tests custom conditions', () => { + const f = CustomFilter.instance(); + expect(f.condition('Custom').logic('Asd', 'asd')).toBeFalsy(); + expect(f.condition('Custom').logic('Asd', 'Asd')).toBeTruthy(); + }); +}); + +class CustomFilter extends IgxFilteringOperand { + private constructor() { + super(); + this.append({ + name: 'Custom', + logic: (value: any, searchVal: any) => value === searchVal, + isUnary: false, + iconName: 'starts-with' + }); + } +} diff --git a/projects/igniteui-angular/core/src/data-operations/filtering-condition.ts b/projects/igniteui-angular/core/src/data-operations/filtering-condition.ts new file mode 100644 index 00000000000..edf766bffcc --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/filtering-condition.ts @@ -0,0 +1,927 @@ +/** + * Provides base filtering operations + * Implementations should be Singleton + * + * @export + */ +export class IgxFilteringOperand { + protected static _instance: IgxFilteringOperand = null; + public operations: IFilteringOperation[]; + + constructor() { + this.operations = [{ + name: 'null', + isUnary: true, + iconName: 'filter_null', + logic: (target: any) => target === null + }, { + name: 'notNull', + isUnary: true, + iconName: 'filter_not_null', + logic: (target: any) => target !== null + }, { + name: 'in', + isUnary: false, + iconName: 'filter_in', + hidden: true, + logic: (target: any, searchVal: Set) => this.findValueInSet(target, searchVal) + }, { + name: 'inQuery', + isUnary: false, + isNestedQuery: true, + iconName: 'in', + logic: (target: any, searchVal: Set) => this.findValueInSet(target, searchVal) + }, { + name: 'notInQuery', + isUnary: false, + isNestedQuery: true, + iconName: 'not-in', + logic: (target: any, searchVal: Set) => !this.findValueInSet(target, searchVal) + }]; + } + + public static instance(): IgxFilteringOperand { + return this._instance || (this._instance = new this()); + } + + /** + * Returns an array of names of the conditions which are visible in the filtering UI + */ + public conditionList(): string[] { + return this.operations.filter(f => !f.hidden && !f.isNestedQuery).map((element) => element.name); + } + + /** + * Returns an array of names of the conditions which are visible in the UI, including "In" and "Not In", allowing the creation of sub-queries. + * @hidden @internal + */ + public extendedConditionList(): string[] { + return this.operations.filter(f => !f.hidden).map((element) => element.name); + } + + /** + * Returns an instance of the condition with the specified name. + * + * @param name The name of the condition. + */ + public condition(name: string): IFilteringOperation { + return this.operations.find((element) => element.name === name); + } + + /** + * Adds a new condition to the filtering operations. + * + * @param operation The filtering operation. + */ + public append(operation: IFilteringOperation) { + this.operations.push(operation); + } + + protected findValueInSet(target: any, searchVal: Set) { + return searchVal.has(target); + } +} + +/* blazorCSSuppress */ +/** + * Provides filtering operations for booleans + * + * @export + */ +export class IgxBooleanFilteringOperand extends IgxFilteringOperand { + protected constructor() { + super(); + const newOperations: IFilteringOperation[] = [{ + name: 'all', + isUnary: true, + iconName: 'filter_all', + logic: (_target: boolean) => true + }, { + name: 'true', + isUnary: true, + iconName: 'filter_true', + logic: (target: boolean) => !!(target && target !== null && target !== undefined) + }, { + name: 'false', + isUnary: true, + iconName: 'filter_false', + logic: (target: boolean) => !target && target !== null && target !== undefined + }, { + name: 'empty', + isUnary: true, + iconName: 'filter_empty', + logic: (target: boolean) => target === null || target === undefined + }, { + name: 'notEmpty', + isUnary: true, + iconName: 'filter_not_empty', + logic: (target: boolean) => target !== null && target !== undefined + }]; + + this.operations = newOperations.concat(this.operations); + } +} + +/* blazorCSSuppress */ +/** + * @internal + * @hidden + */ +class IgxBaseDateTimeFilteringOperand extends IgxFilteringOperand { + protected constructor() { + super(); + const newOperations: IFilteringOperation[] = [{ + name: 'empty', + isUnary: true, + iconName: 'filter_empty', + logic: (target: Date) => target === null || target === undefined + }, { + name: 'notEmpty', + isUnary: true, + iconName: 'filter_not_empty', + logic: (target: Date) => target !== null && target !== undefined + }]; + + this.operations = newOperations.concat(this.operations); + } + + /** + * Splits a Date object into parts + * + * @memberof IgxDateFilteringOperand + */ + public static getDateParts(date: Date, dateFormat?: string): IDateParts { + const res = { + day: null, + hours: null, + milliseconds: null, + minutes: null, + month: null, + seconds: null, + year: null + }; + if (!date || !dateFormat) { + return res; + } + if (dateFormat.indexOf('y') >= 0) { + res.year = date.getFullYear(); + } + if (dateFormat.indexOf('M') >= 0) { + res.month = date.getMonth(); + } + if (dateFormat.indexOf('d') >= 0) { + res.day = date.getDate(); + } + if (dateFormat.indexOf('h') >= 0) { + res.hours = date.getHours(); + } + if (dateFormat.indexOf('m') >= 0) { + res.minutes = date.getMinutes(); + } + if (dateFormat.indexOf('s') >= 0) { + res.seconds = date.getSeconds(); + } + if (dateFormat.indexOf('f') >= 0) { + res.milliseconds = date.getMilliseconds(); + } + return res; + } + + protected override findValueInSet(target: any, searchVal: Set) { + if (!target) { + return false; + } + return searchVal.has((target instanceof Date) ? target.toISOString() : target); + } + + protected validateInputData(target: Date) { + if (!(target instanceof Date)) { + throw new Error('Could not perform filtering on \'date\' column because the datasource object type is not \'Date\'.'); + } + } +} + +/* blazorCSSuppress */ +/** + * Provides filtering operations for Dates + * + * @export + */ +export class IgxDateFilteringOperand extends IgxBaseDateTimeFilteringOperand { + protected constructor() { + super(); + const newOperations: IFilteringOperation[] = [{ + name: 'equals', + isUnary: false, + iconName: 'filter_equal', + logic: (target: Date, searchVal: Date) => { + if (!target) { + return false; + } + + this.validateInputData(target); + + const targetp = IgxDateFilteringOperand.getDateParts(target, 'yMd'); + const searchp = IgxDateFilteringOperand.getDateParts(searchVal, 'yMd'); + return targetp.year === searchp.year && + targetp.month === searchp.month && + targetp.day === searchp.day; + } + }, { + name: 'doesNotEqual', + isUnary: false, + iconName: 'filter_not_equal', + logic: (target: Date, searchVal: Date) => { + if (!target) { + return true; + } + + this.validateInputData(target); + + const targetp = IgxDateFilteringOperand.getDateParts(target, 'yMd'); + const searchp = IgxDateFilteringOperand.getDateParts(searchVal, 'yMd'); + return targetp.year !== searchp.year || + targetp.month !== searchp.month || + targetp.day !== searchp.day; + } + }, { + name: 'before', + isUnary: false, + iconName: 'filter_before', + logic: (target: Date, searchVal: Date) => { + if (!target) { + return false; + } + + this.validateInputData(target); + + return target < searchVal; + } + }, { + name: 'after', + isUnary: false, + iconName: 'filter_after', + logic: (target: Date, searchVal: Date) => { + if (!target) { + return false; + } + + this.validateInputData(target); + + return target > searchVal; + } + }, { + name: 'today', + isUnary: true, + iconName: 'filter_today', + logic: (target: Date) => { + if (!target) { + return false; + } + + this.validateInputData(target); + + const d = IgxDateFilteringOperand.getDateParts(target, 'yMd'); + const now = IgxDateFilteringOperand.getDateParts(new Date(), 'yMd'); + return d.year === now.year && + d.month === now.month && + d.day === now.day; + } + }, { + name: 'yesterday', + isUnary: true, + iconName: 'filter_yesterday', + logic: (target: Date) => { + if (!target) { + return false; + } + + this.validateInputData(target); + + const td = IgxDateFilteringOperand.getDateParts(target, 'yMd'); + const y = ((d) => new Date(d.setDate(d.getDate() - 1)))(new Date()); + const yesterday = IgxDateFilteringOperand.getDateParts(y, 'yMd'); + return td.year === yesterday.year && + td.month === yesterday.month && + td.day === yesterday.day; + } + }, { + name: 'thisMonth', + isUnary: true, + iconName: 'filter_this_month', + logic: (target: Date) => { + if (!target) { + return false; + } + + this.validateInputData(target); + + const d = IgxDateFilteringOperand.getDateParts(target, 'yM'); + const now = IgxDateFilteringOperand.getDateParts(new Date(), 'yM'); + return d.year === now.year && + d.month === now.month; + } + }, { + name: 'lastMonth', + isUnary: true, + iconName: 'filter_last_month', + logic: (target: Date) => { + if (!target) { + return false; + } + + this.validateInputData(target); + + const d = IgxDateFilteringOperand.getDateParts(target, 'yM'); + const now = IgxDateFilteringOperand.getDateParts(new Date(), 'yM'); + if (!now.month) { + now.month = 11; + now.year -= 1; + } else { + now.month--; + } + return d.year === now.year && + d.month === now.month; + } + }, { + name: 'nextMonth', + isUnary: true, + iconName: 'filter_next_month', + logic: (target: Date) => { + if (!target) { + return false; + } + + this.validateInputData(target); + + const d = IgxDateFilteringOperand.getDateParts(target, 'yM'); + const now = IgxDateFilteringOperand.getDateParts(new Date(), 'yM'); + if (now.month === 11) { + now.month = 0; + now.year += 1; + } else { + now.month++; + } + return d.year === now.year && + d.month === now.month; + } + }, { + name: 'thisYear', + isUnary: true, + iconName: 'filter_this_year', + logic: (target: Date) => { + if (!target) { + return false; + } + + this.validateInputData(target); + + const d = IgxDateFilteringOperand.getDateParts(target, 'y'); + const now = IgxDateFilteringOperand.getDateParts(new Date(), 'y'); + return d.year === now.year; + } + }, { + name: 'lastYear', + isUnary: true, + iconName: 'filter_last_year', + logic: (target: Date) => { + if (!target) { + return false; + } + + this.validateInputData(target); + + const d = IgxDateFilteringOperand.getDateParts(target, 'y'); + const now = IgxDateFilteringOperand.getDateParts(new Date(), 'y'); + return d.year === now.year - 1; + } + }, { + name: 'nextYear', + isUnary: true, + iconName: 'filter_next_year', + logic: (target: Date) => { + if (!target) { + return false; + } + + this.validateInputData(target); + + const d = IgxDateFilteringOperand.getDateParts(target, 'y'); + const now = IgxDateFilteringOperand.getDateParts(new Date(), 'y'); + return d.year === now.year + 1; + } + }]; + + this.operations = newOperations.concat(this.operations); + } + + protected override findValueInSet(target: any, searchVal: Set) { + if (!target) { + return false; + } + + target = target.toDateString(); + return searchVal.has(target); + } +} + +/* blazorCSSuppress */ +export class IgxDateTimeFilteringOperand extends IgxBaseDateTimeFilteringOperand { + protected constructor() { + super(); + const newOperations: IFilteringOperation[] = [{ + name: 'equals', + isUnary: false, + iconName: 'filter_equal', + logic: (target: Date, searchVal: Date) => { + if (!target) { + return false; + } + this.validateInputData(target); + const targetp = IgxDateTimeFilteringOperand.getDateParts(target, 'yMdhms'); + const searchp = IgxDateTimeFilteringOperand.getDateParts(searchVal, 'yMdhms'); + return targetp.year === searchp.year && + targetp.month === searchp.month && + targetp.day === searchp.day && + targetp.hours === searchp.hours && + targetp.minutes === searchp.minutes && + targetp.seconds === searchp.seconds; + } + }, { + name: 'doesNotEqual', + isUnary: false, + iconName: 'filter_not_equal', + logic: (target: Date, searchVal: Date) => { + if (!target) { + return true; + } + this.validateInputData(target); + const targetp = IgxDateTimeFilteringOperand.getDateParts(target, 'yMdhms'); + const searchp = IgxDateTimeFilteringOperand.getDateParts(searchVal, 'yMdhms'); + return targetp.year !== searchp.year || + targetp.month !== searchp.month || + targetp.day !== searchp.day || + targetp.hours !== searchp.hours || + targetp.minutes !== searchp.minutes || + targetp.seconds !== searchp.seconds; + } + }, { + name: 'before', + isUnary: false, + iconName: 'filter_before', + logic: (target: Date, searchVal: Date) => { + if (!target) { + return false; + } + + this.validateInputData(target); + + return target < searchVal; + } + }, { + name: 'after', + isUnary: false, + iconName: 'filter_after', + logic: (target: Date, searchVal: Date) => { + if (!target) { + return false; + } + + this.validateInputData(target); + + return target > searchVal; + } + }, { + name: 'today', + isUnary: true, + iconName: 'filter_today', + logic: (target: Date) => { + if (!target) { + return false; + } + + this.validateInputData(target); + + const d = IgxDateTimeFilteringOperand.getDateParts(target, 'yMd'); + const now = IgxDateTimeFilteringOperand.getDateParts(new Date(), 'yMd'); + return d.year === now.year && + d.month === now.month && + d.day === now.day; + } + }, { + name: 'yesterday', + isUnary: true, + iconName: 'filter_yesterday', + logic: (target: Date) => { + if (!target) { + return false; + } + + this.validateInputData(target); + + const td = IgxDateTimeFilteringOperand.getDateParts(target, 'yMd'); + const y = ((d) => new Date(d.setDate(d.getDate() - 1)))(new Date()); + const yesterday = IgxDateTimeFilteringOperand.getDateParts(y, 'yMd'); + return td.year === yesterday.year && + td.month === yesterday.month && + td.day === yesterday.day; + } + }, { + name: 'thisMonth', + isUnary: true, + iconName: 'filter_this_month', + logic: (target: Date) => { + if (!target) { + return false; + } + + this.validateInputData(target); + + const d = IgxDateTimeFilteringOperand.getDateParts(target, 'yM'); + const now = IgxDateTimeFilteringOperand.getDateParts(new Date(), 'yM'); + return d.year === now.year && + d.month === now.month; + } + }, { + name: 'lastMonth', + isUnary: true, + iconName: 'filter_last_month', + logic: (target: Date) => { + if (!target) { + return false; + } + + this.validateInputData(target); + + const d = IgxDateTimeFilteringOperand.getDateParts(target, 'yM'); + const now = IgxDateTimeFilteringOperand.getDateParts(new Date(), 'yM'); + if (!now.month) { + now.month = 11; + now.year -= 1; + } else { + now.month--; + } + return d.year === now.year && + d.month === now.month; + } + }, { + name: 'nextMonth', + isUnary: true, + iconName: 'filter_next_month', + logic: (target: Date) => { + if (!target) { + return false; + } + + this.validateInputData(target); + + const d = IgxDateTimeFilteringOperand.getDateParts(target, 'yM'); + const now = IgxDateTimeFilteringOperand.getDateParts(new Date(), 'yM'); + if (now.month === 11) { + now.month = 0; + now.year += 1; + } else { + now.month++; + } + return d.year === now.year && + d.month === now.month; + } + }, { + name: 'thisYear', + isUnary: true, + iconName: 'filter_this_year', + logic: (target: Date) => { + if (!target) { + return false; + } + + this.validateInputData(target); + + const d = IgxDateTimeFilteringOperand.getDateParts(target, 'y'); + const now = IgxDateTimeFilteringOperand.getDateParts(new Date(), 'y'); + return d.year === now.year; + } + }, { + name: 'lastYear', + isUnary: true, + iconName: 'filter_last_year', + logic: (target: Date) => { + if (!target) { + return false; + } + + this.validateInputData(target); + + const d = IgxDateTimeFilteringOperand.getDateParts(target, 'y'); + const now = IgxDateTimeFilteringOperand.getDateParts(new Date(), 'y'); + return d.year === now.year - 1; + } + }, { + name: 'nextYear', + isUnary: true, + iconName: 'filter_next_year', + logic: (target: Date) => { + if (!target) { + return false; + } + + this.validateInputData(target); + + const d = IgxDateTimeFilteringOperand.getDateParts(target, 'y'); + const now = IgxDateTimeFilteringOperand.getDateParts(new Date(), 'y'); + return d.year === now.year + 1; + } + }]; + + this.operations = newOperations.concat(this.operations); + } +} + +/* blazorCSSuppress */ +export class IgxTimeFilteringOperand extends IgxBaseDateTimeFilteringOperand { + protected constructor() { + super(); + const newOperations: IFilteringOperation[] = [{ + name: 'at', + isUnary: false, + iconName: 'filter_equal', + logic: (target: Date, searchVal: Date) => { + if (!target) { + return false; + } + this.validateInputData(target); + const targetp = IgxTimeFilteringOperand.getDateParts(target, 'hms'); + const searchp = IgxTimeFilteringOperand.getDateParts(searchVal, 'hms'); + return targetp.hours === searchp.hours && + targetp.minutes === searchp.minutes && + targetp.seconds === searchp.seconds; + } + }, { + name: 'not_at', + isUnary: false, + iconName: 'filter_not_equal', + logic: (target: Date, searchVal: Date) => { + if (!target) { + return true; + } + this.validateInputData(target); + const targetp = IgxTimeFilteringOperand.getDateParts(target, 'hms'); + const searchp = IgxTimeFilteringOperand.getDateParts(searchVal, 'hms'); + return targetp.hours !== searchp.hours || + targetp.minutes !== searchp.minutes || + targetp.seconds !== searchp.seconds; + } + }, { + name: 'before', + isUnary: false, + iconName: 'filter_before', + logic: (target: Date, searchVal: Date) => { + if (!target) { + return false; + } + + this.validateInputData(target); + const targetn = IgxTimeFilteringOperand.getDateParts(target, 'hms'); + const search = IgxTimeFilteringOperand.getDateParts(searchVal, 'hms'); + + return targetn.hours < search.hours ? true : targetn.hours === search.hours && targetn.minutes < search.minutes ? + true : targetn.hours === search.hours && targetn.minutes === search.minutes && targetn.seconds < search.seconds; + } + }, { + name: 'after', + isUnary: false, + iconName: 'filter_after', + logic: (target: Date, searchVal: Date) => { + if (!target) { + return false; + } + + this.validateInputData(target); + const targetn = IgxTimeFilteringOperand.getDateParts(target, 'hms'); + const search = IgxTimeFilteringOperand.getDateParts(searchVal, 'hms'); + + return targetn.hours > search.hours ? true : targetn.hours === search.hours && targetn.minutes > search.minutes ? + true : targetn.hours === search.hours && targetn.minutes === search.minutes && targetn.seconds > search.seconds; + } + }, { + name: 'at_before', + isUnary: false, + iconName: 'filter_before', + logic: (target: Date, searchVal: Date) => { + if (!target) { + return false; + } + + this.validateInputData(target); + const targetn = IgxTimeFilteringOperand.getDateParts(target, 'hms'); + const search = IgxTimeFilteringOperand.getDateParts(searchVal, 'hms'); + return (targetn.hours === search.hours && targetn.minutes === search.minutes && targetn.seconds === search.seconds) || + targetn.hours < search.hours ? true : targetn.hours === search.hours && targetn.minutes < search.minutes ? + true : targetn.hours === search.hours && targetn.minutes === search.minutes && targetn.seconds < search.seconds; + } + }, { + name: 'at_after', + isUnary: false, + iconName: 'filter_after', + logic: (target: Date, searchVal: Date) => { + if (!target) { + return false; + } + + this.validateInputData(target); + const targetn = IgxTimeFilteringOperand.getDateParts(target, 'hms'); + const search = IgxTimeFilteringOperand.getDateParts(searchVal, 'hms'); + return (targetn.hours === search.hours && targetn.minutes === search.minutes && targetn.seconds === search.seconds) || + targetn.hours > search.hours ? true : targetn.hours === search.hours && targetn.minutes > search.minutes ? + true : targetn.hours === search.hours && targetn.minutes === search.minutes && targetn.seconds > search.seconds; + } + }]; + + this.operations = newOperations.concat(this.operations); + } + + protected override findValueInSet(target: any, searchVal: Set) { + if (!target) { + return false; + } + return searchVal.has(target.toLocaleTimeString()); + } +} + +/* blazorCSSuppress */ +/** + * Provides filtering operations for numbers + * + * @export + */ +export class IgxNumberFilteringOperand extends IgxFilteringOperand { + protected constructor() { + super(); + const newOperations: IFilteringOperation[] = [{ + name: 'equals', + isUnary: false, + iconName: 'filter_equal', + logic: (target: number, searchVal: number) => target === searchVal + }, { + name: 'doesNotEqual', + isUnary: false, + iconName: 'filter_not_equal', + logic: (target: number, searchVal: number) => target !== searchVal + }, { + name: 'greaterThan', + isUnary: false, + iconName: 'filter_greater_than', + logic: (target: number, searchVal: number) => target > searchVal + }, { + name: 'lessThan', + isUnary: false, + iconName: 'filter_less_than', + logic: (target: number, searchVal: number) => target < searchVal + }, { + name: 'greaterThanOrEqualTo', + isUnary: false, + iconName: 'filter_greater_than_or_equal', + logic: (target: number, searchVal: number) => target >= searchVal + }, { + name: 'lessThanOrEqualTo', + isUnary: false, + iconName: 'filter_less_than_or_equal', + logic: (target: number, searchVal: number) => target <= searchVal + }, { + name: 'empty', + isUnary: true, + iconName: 'filter_empty', + logic: (target: number) => target === null || target === undefined || isNaN(target) + }, { + name: 'notEmpty', + isUnary: true, + iconName: 'filter_not_empty', + logic: (target: number) => target !== null && target !== undefined && !isNaN(target) + }]; + + this.operations = newOperations.concat(this.operations); + } +} + +/* blazorCSSuppress */ +/** + * Provides filtering operations for strings + * + * @export + */ +export class IgxStringFilteringOperand extends IgxFilteringOperand { + protected constructor() { + super(); + const newOperations: IFilteringOperation[] = [{ + name: 'contains', + isUnary: false, + iconName: 'filter_contains', + logic: (target: string, searchVal: string, ignoreCase?: boolean) => { + const search = IgxStringFilteringOperand.applyIgnoreCase(searchVal, ignoreCase); + target = IgxStringFilteringOperand.applyIgnoreCase(target, ignoreCase); + return target.indexOf(search) !== -1; + } + }, { + name: 'doesNotContain', + isUnary: false, + iconName: 'filter_does_not_contain', + logic: (target: string, searchVal: string, ignoreCase?: boolean) => { + const search = IgxStringFilteringOperand.applyIgnoreCase(searchVal, ignoreCase); + target = IgxStringFilteringOperand.applyIgnoreCase(target, ignoreCase); + return target.indexOf(search) === -1; + } + }, { + name: 'startsWith', + isUnary: false, + iconName: 'filter_starts_with', + logic: (target: string, searchVal: string, ignoreCase?: boolean) => { + const search = IgxStringFilteringOperand.applyIgnoreCase(searchVal, ignoreCase); + target = IgxStringFilteringOperand.applyIgnoreCase(target, ignoreCase); + return target.startsWith(search); + } + }, { + name: 'endsWith', + isUnary: false, + iconName: 'filter_ends_with', + logic: (target: string, searchVal: string, ignoreCase?: boolean) => { + const search = IgxStringFilteringOperand.applyIgnoreCase(searchVal, ignoreCase); + target = IgxStringFilteringOperand.applyIgnoreCase(target, ignoreCase); + return target.endsWith(search); + } + }, { + name: 'equals', + isUnary: false, + iconName: 'filter_equal', + logic: (target: string, searchVal: string, ignoreCase?: boolean) => { + const search = IgxStringFilteringOperand.applyIgnoreCase(searchVal, ignoreCase); + target = IgxStringFilteringOperand.applyIgnoreCase(target, ignoreCase); + return target === search; + } + }, { + name: 'doesNotEqual', + isUnary: false, + iconName: 'filter_not_equal', + logic: (target: string, searchVal: string, ignoreCase?: boolean) => { + const search = IgxStringFilteringOperand.applyIgnoreCase(searchVal, ignoreCase); + target = IgxStringFilteringOperand.applyIgnoreCase(target, ignoreCase); + return target !== search; + } + }, { + name: 'empty', + isUnary: true, + iconName: 'filter_empty', + logic: (target: string) => target === null || target === undefined || target.length === 0 + }, { + name: 'notEmpty', + isUnary: true, + iconName: 'filter_not_empty', + logic: (target: string) => target !== null && target !== undefined && target.length > 0 + }]; + + this.operations = newOperations.concat(this.operations); + } + + /** + * Applies case sensitivity on strings if provided + * + * @memberof IgxStringFilteringOperand + */ + public static applyIgnoreCase(a: string, ignoreCase: boolean): string { + a = a ?? ''; + // bulletproof + return ignoreCase ? ('' + a).toLowerCase() : a; + } +} + +/* tsPlainInterface */ +/* marshalByValue */ +/** + * Interface describing filtering operations + * + * @export + */ +export interface IFilteringOperation { + name: string; + isUnary: boolean; + isNestedQuery?: boolean; + iconName: string; + hidden?: boolean; + /* blazorCSSuppress */ + /* blazorAlternateType: FilteringOperationLogicHandler */ + logic?: null | ((value: any, searchVal?: any, ignoreCase?: boolean) => boolean); +} + +/** + * Interface describing Date object in parts + * + * @export + */ +export interface IDateParts { + year: number; + month: number; + day: number; + hours: number; + minutes: number; + seconds: number; + milliseconds: number; +} diff --git a/projects/igniteui-angular/core/src/data-operations/filtering-expression.interface.ts b/projects/igniteui-angular/core/src/data-operations/filtering-expression.interface.ts new file mode 100644 index 00000000000..78076df46b3 --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/filtering-expression.interface.ts @@ -0,0 +1,21 @@ +import { IFilteringOperation } from './filtering-condition'; +import { IExpressionTree } from './filtering-expressions-tree'; + +/* mustCoerceToInt */ +export enum FilteringLogic { + And, + Or +} + +/* marshalByValue */ +/** + * Represents filtering expressions. + */ +export declare interface IFilteringExpression { + fieldName: string; + condition?: IFilteringOperation | null; + conditionName?: string | null; + searchVal?: any; + searchTree?: IExpressionTree | null; + ignoreCase?: boolean; +} diff --git a/projects/igniteui-angular/core/src/data-operations/filtering-expressions-tree.ts b/projects/igniteui-angular/core/src/data-operations/filtering-expressions-tree.ts new file mode 100644 index 00000000000..4a46dafc421 --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/filtering-expressions-tree.ts @@ -0,0 +1,175 @@ +import { FilteringLogic, IFilteringExpression } from './filtering-expression.interface'; +import { IBaseEventArgs } from '../core/utils'; +import { ExpressionsTreeUtil } from './expressions-tree-util'; + +/* mustCoerceToInt */ +export enum FilteringExpressionsTreeType { + Regular, + Advanced +} + +/* marshalByValue */ +export declare interface IExpressionTree { + filteringOperands: (IExpressionTree | IFilteringExpression)[]; + operator: FilteringLogic; + fieldName?: string | null; + entity?: string | null; + returnFields?: string[] | null; +} + +/* alternateBaseType: ExpressionTree */ +/* marshalByValue */ +export declare interface IFilteringExpressionsTree extends IBaseEventArgs, IExpressionTree { + filteringOperands: (IFilteringExpressionsTree | IFilteringExpression)[]; + /* alternateName: treeType */ + type?: FilteringExpressionsTreeType; + + /* blazorSuppress */ + /** + * @deprecated in version 18.2.0. Use `ExpressionsTreeUtil.find` instead. + */ + find?: (fieldName: string) => IFilteringExpressionsTree | IFilteringExpression; + + /* blazorSuppress */ + /** + * @deprecated in version 18.2.0. Use `ExpressionsTreeUtil.findIndex` instead. + */ + findIndex?: (fieldName: string) => number; +} + +/* marshalByValue */ +/* jsonAPIPlainObject */ +export class FilteringExpressionsTree implements IFilteringExpressionsTree { + + /** + * Sets/gets the filtering operands. + * ```typescript + * const gridExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + * const expression = [ + * { + * condition: IgxStringFilteringOperand.instance().condition('contains'), + * fieldName: 'Column Field', + * searchVal: 'Value', + * ignoreCase: false + * }]; + * gridExpressionsTree.filteringOperands.push(expression); + * this.grid.filteringExpressionsTree = gridExpressionsTree; + * ``` + * ```typescript + * let filteringOperands = gridExpressionsTree.filteringOperands; + * ``` + * + * @memberof FilteringExpressionsTree + */ + public filteringOperands: (IFilteringExpressionsTree | IFilteringExpression)[] = []; + + /** + * Sets/gets the operator. + * ```typescript + * gridExpressionsTree.operator = FilteringLogic.And; + * ``` + * ```typescript + * let operator = gridExpressionsTree.operator; + * ``` + * + * @memberof FilteringExpressionsTree + */ + public operator: FilteringLogic; + + /** + * Sets/gets the field name of the column where the filtering expression is placed. + * ```typescript + * gridExpressionTree.fieldName = 'Column Field'; + * ``` + * ```typescript + * let columnField = expressionTree.fieldName; + * ``` + * + * @memberof FilteringExpressionsTree + */ + public fieldName?: string; + + /* alternateName: treeType */ + /** + * Sets/gets the type of the filtering expressions tree. + * ```typescript + * gridExpressionTree.type = FilteringExpressionsTree.Advanced; + * ``` + * ```typescript + * let type = expressionTree.type; + * ``` + * + * @memberof FilteringExpressionsTree + */ + public type?: FilteringExpressionsTreeType; + + /** + * Sets/gets the entity. + * ```typescript + * gridExpressionsTree.entity = 'Entity A'; + * ``` + * ```typescript + * let entity = gridExpressionsTree.entity; + * ``` + * + * @memberof FilteringExpressionsTree + */ + public entity?: string; + + /** + * Sets/gets the return fields. + * ```typescript + * gridExpressionsTree.returnFields = ['Column Field 1', 'Column Field 2']; + * ``` + * ```typescript + * let returnFields = gridExpressionsTree.returnFields; + * ``` + * + * @memberof FilteringExpressionsTree + */ + public returnFields?: string[]; + + constructor(operator: FilteringLogic, fieldName?: string, entity?: string, returnFields?: string[]) { + this.operator = operator; + this.entity = entity; + this.returnFields = returnFields; + this.fieldName = fieldName; + } + + /** + * Checks if filtering expressions tree is empty. + * + * @param expressionTree filtering expressions tree. + */ + public static empty(expressionTree: IFilteringExpressionsTree): boolean { + return !expressionTree || !expressionTree.filteringOperands || !expressionTree.filteringOperands.length; + } + + /* blazorSuppress */ + /** + * Returns the filtering expression for a column with the provided fieldName. + * ```typescript + * let filteringExpression = gridExpressionTree.find('Column Field'); + * ``` + * + * @memberof FilteringExpressionsTree + * @deprecated in version 18.2.0. Use `ExpressionsTreeUtil.find` instead. + */ + public find(fieldName: string): IFilteringExpressionsTree | IFilteringExpression { + return ExpressionsTreeUtil.find(this, fieldName); + } + + /* blazorSuppress */ + /** + * Returns the index of the filtering expression for a column with the provided fieldName. + * ```typescript + * let filteringExpressionIndex = gridExpressionTree.findIndex('Column Field'); + * ``` + * + * @memberof FilteringExpressionsTree + * @deprecated in version 18.2.0. Use `ExpressionsTreeUtil.findIndex` instead. + */ + public findIndex(fieldName: string): number { + return ExpressionsTreeUtil.findIndex(this, fieldName); + } +} diff --git a/projects/igniteui-angular/core/src/data-operations/filtering-state.interface.ts b/projects/igniteui-angular/core/src/data-operations/filtering-state.interface.ts new file mode 100644 index 00000000000..8bc53bf792b --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/filtering-state.interface.ts @@ -0,0 +1,8 @@ +import { IFilteringExpressionsTree } from './filtering-expressions-tree'; +import { IFilteringStrategy } from './filtering-strategy'; + +export declare interface IFilteringState { + expressionsTree: IFilteringExpressionsTree; + advancedExpressionsTree?: IFilteringExpressionsTree; + strategy?: IFilteringStrategy; +} diff --git a/projects/igniteui-angular/core/src/data-operations/filtering-strategy.spec.ts b/projects/igniteui-angular/core/src/data-operations/filtering-strategy.spec.ts new file mode 100644 index 00000000000..962656c5f57 --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/filtering-strategy.spec.ts @@ -0,0 +1,101 @@ +import { DataGenerator } from './test-util/data-generator'; +import { FilteringStrategy } from './filtering-strategy'; +import { FilteringExpressionsTree } from './filtering-expressions-tree'; +import { FilteringLogic } from './filtering-expression.interface'; +import { IgxNumberFilteringOperand, IgxStringFilteringOperand, IgxBooleanFilteringOperand } from './filtering-condition'; + + +describe('Unit testing FilteringStrategy', () => { + let dataGenerator: DataGenerator; + let data: any[]; + let fs: FilteringStrategy; + beforeEach(() => { + dataGenerator = new DataGenerator(); + data = dataGenerator.data; + fs = new FilteringStrategy(); + }); + it ('tests `filter`', () => { + const expressionTree = new FilteringExpressionsTree(FilteringLogic.And); + expressionTree.filteringOperands = [ + { + condition: IgxNumberFilteringOperand.instance().condition('greaterThan'), + conditionName: 'greaterThan', + fieldName: 'number', + searchVal: 1 + } + ]; + const res = fs.filter(data, expressionTree, null, null); + expect(dataGenerator.getValuesForColumn(res, 'number')) + .toEqual([2, 3, 4]); + }); + it ('tests `matchRecordByExpressions`', () => { + const rec = data[0]; + const expressionTree = new FilteringExpressionsTree(FilteringLogic.Or); + expressionTree.filteringOperands = [ + { + condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains', + fieldName: 'string', + ignoreCase: false, + searchVal: 'ROW' + }, + { + condition: IgxNumberFilteringOperand.instance().condition('lessThan'), + conditionName: 'lessThan', + fieldName: 'number', + searchVal: 1 + } + ]; + const res = fs.matchRecord(rec, expressionTree); + expect(res).toBeTruthy(); + }); + it ('tests `findMatchByExpression` for working with filtering operands with missing condition', () => { + const rec = data[0]; + const expressionTree = JSON.parse('{"filteringOperands":[{"fieldName":"Missing","condition":{"name":"notEmpty","isUnary":true,"iconName":"filter_not_empty"},"conditionName":"notEmpty","ignoreCase":true,"searchVal":null,"searchTree":null}],"operator":0,"returnFields":[],"type":1}'); + + + const res = fs.matchRecord(rec, expressionTree); + expect(res).toBeFalsy(); + }); + + it ('no error when condition is missing in the filtering expressions tree', () => { + const rec = data[0]; + const expressionTree = new FilteringExpressionsTree(FilteringLogic.Or); + expressionTree.filteringOperands = [ + { + conditionName: 'contains', + fieldName: 'string', + ignoreCase: false, + searchVal: 'ROW' + } + ]; + const res = fs.matchRecord(rec, expressionTree); + expect(res).toBeFalsy(); + }); + + it ('tests `findMatch`', () => { + const rec = data[0]; + const res = fs.findMatchByExpression(rec, { + condition: IgxBooleanFilteringOperand.instance().condition('false'), + conditionName: 'false', + fieldName: 'boolean' + }); + expect(res).toBeTruthy(); + }); + it ('tests default settings', () => { + (data[0] as { string: string }).string = 'ROW'; + const filterstr = new FilteringStrategy(); + const expressionTree = new FilteringExpressionsTree(FilteringLogic.And); + expressionTree.filteringOperands = [ + { + condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains', + fieldName: 'string', + searchVal: 'ROW' + } + ]; + const res = filterstr.filter(data, expressionTree, null, null); + expect(dataGenerator.getValuesForColumn(res, 'number')) + .toEqual([0]); + }); +}); diff --git a/projects/igniteui-angular/core/src/data-operations/filtering-strategy.ts b/projects/igniteui-angular/core/src/data-operations/filtering-strategy.ts new file mode 100644 index 00000000000..503a0e108ff --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/filtering-strategy.ts @@ -0,0 +1,291 @@ +import { FilteringLogic, type IFilteringExpression } from './filtering-expression.interface'; +import { FilteringExpressionsTree, type IFilteringExpressionsTree } from './filtering-expressions-tree'; +import { resolveNestedPath, parseDate, formatDate, formatCurrency, columnFieldPath } from '../core/utils'; +import { GridColumnDataType, type ColumnType, type EntityType, type GridTypeBase } from './grid-types'; +import { DataUtil } from './data-util'; +import { SortingDirection } from './sorting-strategy'; +import { formatNumber, formatPercent, getLocaleCurrencyCode } from '@angular/common'; +import type { IFilteringState } from './filtering-state.interface'; +import { isTree } from './expressions-tree-util'; +import { IgxSorting } from './grid-sorting-strategy'; + +const DateType = 'date'; +const DateTimeType = 'dateTime'; +const TimeType = 'time'; + +export class FilterUtil { + public static filter(data: T[], state: IFilteringState, grid?: GridTypeBase): T[] { + if (!state.strategy) { + state.strategy = new FilteringStrategy(); + } + return state.strategy.filter(data, state.expressionsTree, state.advancedExpressionsTree, grid); + } +} + +export interface IFilteringStrategy { + filter(data: any[], expressionsTree: IFilteringExpressionsTree, advancedExpressionsTree?: IFilteringExpressionsTree, + grid?: GridTypeBase): any[]; + /* csSuppress */ + getFilterItems(column: ColumnType, tree: IFilteringExpressionsTree): Promise; +} + +/* csSuppress */ +export interface IgxFilterItem { + value: any; + label?: string; + children?: IgxFilterItem[]; +} + +/* csSuppress */ +export abstract class BaseFilteringStrategy implements IFilteringStrategy { + // protected + public findMatchByExpression(rec: any, expr: IFilteringExpression, isDate?: boolean, isTime?: boolean, grid?: GridTypeBase): boolean { + if (expr.searchTree) { + const records = rec[expr.searchTree.entity]; + const shouldMatchRecords = expr.conditionName === 'inQuery'; + if (!records) { // child grid is not yet created + return true; + } + + for (let index = 0; index < records.length; index++) { + const record = records[index]; + if ((shouldMatchRecords && this.matchRecord(record, expr.searchTree, grid, expr.searchTree.entity)) || + (!shouldMatchRecords && !this.matchRecord(record, expr.searchTree, grid, expr.searchTree.entity))) { + return true; + } + } + + return false; + } + + const val = this.getFieldValue(rec, expr.fieldName, isDate, isTime, grid); + if (expr.condition?.logic) { + return expr.condition.logic(val, expr.searchVal, expr.ignoreCase); + } + } + + // protected + public matchRecord(rec: any, expressions: IFilteringExpressionsTree | IFilteringExpression, grid?: GridTypeBase, entity?: string): boolean { + if (expressions) { + if (isTree(expressions)) { + const expressionsTree = expressions; + const operator = expressionsTree.operator as FilteringLogic; + let matchOperand; + + if (expressionsTree.filteringOperands?.length) { + for (const operand of expressionsTree.filteringOperands) { + matchOperand = this.matchRecord(rec, operand, grid, entity); + + // Return false if at least one operand does not match and the filtering logic is And + if (!matchOperand && operator === FilteringLogic.And) { + return false; + } + + // Return true if at least one operand matches and the filtering logic is Or + if (matchOperand && operator === FilteringLogic.Or) { + return true; + } + } + + return matchOperand; + } + + return true; + } else { + const expression = expressions; + let dataType = null; + if (!entity) { + const column = grid && grid.getColumnByName(expression.fieldName); + dataType = column?.dataType; + } else if (grid.type === 'hierarchical') { + const schema = grid.schema; + const entityMatch = this.findEntityByName(schema, entity); + dataType = entityMatch?.fields.find(f => f.field === expression.fieldName)?.dataType; + } + + const isDate = dataType ? dataType === DateType || dataType === DateTimeType : false; + const isTime = dataType ? dataType === TimeType : false; + + return this.findMatchByExpression(rec, expression, isDate, isTime, grid); + } + } + + return true; + } + + private findEntityByName(schema: EntityType[], name: string): EntityType | null { + for (const entity of schema) { + if (entity.name === name) { + return entity; + } + + if (entity.childEntities && entity.childEntities.length > 0) { + const found = this.findEntityByName(entity.childEntities, name); + if (found) { + return found; + } + } + } + return null; + } + + public getFilterItems(column: ColumnType, tree: IFilteringExpressionsTree): Promise { + const applyFormatter = column.formatter && this.shouldFormatFilterValues(column); + + const data = this.getFilteredData(column, tree); + + const pathParts = columnFieldPath(column.field) + const seenFormattedFilterItems = new Map() + + for (let i = 0; i < data.length; ++i) { + const record = data[i] + const rawValue = resolveNestedPath(record, pathParts); + const formattedValue = applyFormatter ? column.formatter(rawValue, record) : rawValue; + const { key, finalValue } = this.getFilterItemKeyValue(formattedValue, column); + // Deduplicate by normalized key + if (!seenFormattedFilterItems.has(key)) { + const label = this.getFilterItemLabel(column, finalValue, !applyFormatter, record); + seenFormattedFilterItems.set(key, { value: finalValue, label }); + } + } + + let filterItems: IgxFilterItem[] = Array.from(seenFormattedFilterItems.values()); + + filterItems = DataUtil.sort(filterItems, + [{ fieldName: 'value', dir: SortingDirection.Asc, ignoreCase: column.sortingIgnoreCase }], new IgxSorting()) + + return Promise.resolve(filterItems); + } + + protected getFilteredData(column: ColumnType, tree: IFilteringExpressionsTree) { + return column.grid.gridAPI.filterDataByExpressions(tree); + } + + protected getFilterItemLabel(column: ColumnType, value: any, applyFormatter: boolean, data: any) { + if (column.formatter) { + if (applyFormatter) { + return column.formatter(value, data); + } + return value; + } + + const { display, format, digitsInfo, currencyCode, timezone } = column.pipeArgs; + const locale = column.grid.locale; + + switch (column.dataType) { + case GridColumnDataType.Date: + case GridColumnDataType.DateTime: + case GridColumnDataType.Time: + return formatDate(value, format, locale, timezone); + case GridColumnDataType.Currency: + return formatCurrency(value, currencyCode || getLocaleCurrencyCode(locale), display, digitsInfo, locale); + case GridColumnDataType.Number: + return formatNumber(value, locale, digitsInfo); + case GridColumnDataType.Percent: + return formatPercent(value, locale, digitsInfo); + default: + return value; + } + } + + protected getFilterItemKeyValue(value: any, column: ColumnType) { + let key: any = value; + let finalValue = value; + if (column.dataType === GridColumnDataType.String && column.filteringIgnoreCase) { + key = key?.toString().toLowerCase(); + } else if (column.dataType === GridColumnDataType.DateTime) { + key = value?.toString(); + finalValue = key ? new Date(key) : key; + } else if (column.dataType === GridColumnDataType.Time) { + const date = key ? new Date(key) : key; + key = date + ? new Date().setHours( + date.getHours(), + date.getMinutes(), + date.getSeconds(), + date.getMilliseconds() + ) + : date; + finalValue = key ? new Date(key) : key; + } else if (column.dataType === GridColumnDataType.Date) { + const date = key ? new Date(key) : key; + key = date + ? new Date(date.getFullYear(), date.getMonth(), date.getDate()).toISOString() + : date; + finalValue = date; + } + return { key, finalValue }; + } + + protected shouldFormatFilterValues(_column: ColumnType): boolean { + return false; + } + + public abstract filter(data: any[], expressionsTree: IFilteringExpressionsTree, + advancedExpressionsTree?: IFilteringExpressionsTree, grid?: GridTypeBase): any[]; + + protected abstract getFieldValue(rec: any, fieldName: string, isDate?: boolean, isTime?: boolean, grid?: GridTypeBase): any; +} + +/* csSuppress */ +export class NoopFilteringStrategy extends BaseFilteringStrategy { + protected getFieldValue(rec: any, _fieldName: string) { + return rec; + } + private static _instance: NoopFilteringStrategy = null; + + public static instance() { + return this._instance || (this._instance = new NoopFilteringStrategy()); + } + + public filter(data: any[], _: IFilteringExpressionsTree, __?: IFilteringExpressionsTree): any[] { + return data; + } +} + + +export class FilteringStrategy extends BaseFilteringStrategy { + private static _instance: FilteringStrategy = null; + + + public static instance() { + return this._instance || (this._instance = new this()); + } + + public filter(data: T[], expressionsTree: IFilteringExpressionsTree, advancedExpressionsTree: IFilteringExpressionsTree, + grid: GridTypeBase): T[] { + + + if ((FilteringExpressionsTree.empty(expressionsTree) && FilteringExpressionsTree.empty(advancedExpressionsTree))) { + return data; + } + + return data.filter(record => this.matchRecord(record, expressionsTree, grid) && this.matchRecord(record, advancedExpressionsTree, grid)); + } + + protected getFieldValue(rec: any, fieldName: string, isDate = false, isTime = false, grid?: GridTypeBase): any { + const column = grid?.getColumnByName(fieldName); + let value = resolveNestedPath(rec, columnFieldPath(fieldName)); + + value = column?.formatter && this.shouldFormatFilterValues(column) ? + column.formatter(value, rec) : + value && (isDate || isTime) ? parseDate(value) : value; + + return value; + } +} +export class FormattedValuesFilteringStrategy extends FilteringStrategy { + /** + * Creates a new instance of FormattedValuesFilteringStrategy. + * + * @param fields An array of column field names that should be formatted. + * If omitted the values of all columns which has formatter will be formatted. + */ + constructor(private fields?: string[]) { + super(); + } + + protected override shouldFormatFilterValues(column: ColumnType): boolean { + return !this.fields || this.fields.length === 0 || this.fields.some(f => f === column.field); + } +} diff --git a/projects/igniteui-angular/core/src/data-operations/grid-sorting-strategy.ts b/projects/igniteui-angular/core/src/data-operations/grid-sorting-strategy.ts new file mode 100644 index 00000000000..c41c50759e1 --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/grid-sorting-strategy.ts @@ -0,0 +1,386 @@ +import { cloneArray, columnFieldPath, parseDate, resolveNestedPath } from '../core/utils'; +import { IGroupByExpandState } from './groupby-expand-state.interface'; +import { IGroupByRecord } from './groupby-record.interface'; +import { IGroupingState } from './groupby-state.interface'; +import { IGroupingExpression } from './grouping-expression.interface'; +import { IGroupByResult } from './grouping-result.interface'; +import { getHierarchy, isHierarchyMatch } from './operations'; +import { DefaultSortingStrategy, ISortingExpression, SortingDirection } from './sorting-strategy'; +import type { GridTypeBase } from './grid-types'; + +const DATE_TYPE = 'date'; +const TIME_TYPE = 'time'; +const DATE_TIME_TYPE = 'dateTime'; +const STRING_TYPE = 'string'; + +/** + * Represents a sorting strategy for the grid data + * Contains a single method sort that sorts the provided data based on the given sorting expressions + */ +export interface IGridSortingStrategy { + /* blazorCSSuppress */ + /** + * `data`: The array of data to be sorted. Could be of any type. + * `expressions`: An array of sorting expressions that define the sorting rules. The expression contains information like file name, whether the letter case should be taken into account, etc. + * `grid`: (Optional) The instance of the grid where the sorting is applied. + * Returns a new array with the data sorted according to the sorting expressions. + */ + sort(data: any[], expressions: ISortingExpression[], grid?: GridTypeBase): any[]; +} + +/** + * Represents a grouping strategy for the grid data, extending the Sorting Strategy interface (contains a sorting method). + */ +export interface IGridGroupingStrategy extends IGridSortingStrategy { + /* blazorCSSuppress */ + /** + * The method groups the provided data based on the given grouping state and returns the result. + * `data`: The array of data to be grouped. Could be of any type. + * `state`: The grouping state that defines the grouping settings and expressions. + * `grid`: (Optional) The instance of the grid where the grouping is applied. + * `groupsRecords`: (Optional) An array that holds the records for each group. + * `fullResult`: (Optional) The complete result of grouping including groups and summary data. + * Returns an object containing the result of the grouping operation. + */ + groupBy(data: any[], state: IGroupingState, grid?: any, groupsRecords?: any[], fullResult?: IGroupByResult): IGroupByResult; +} + +/** + * Represents internal sorting expression that extends the public one. + * Contains boolean properties that represent the type of the column that is being sorted. + * @internal + */ +interface IGridInternalSortingExpression extends ISortingExpression { + isDate: boolean; + isTime: boolean; + isString: boolean; +} + +/** + * Stack item represents a frame. + * Each frame needs: + * - data: The subset of records to process at this level. + * - level: The current grouping level. + * - parentGroup: The parent IGroupByRecord for groups created in this frame. + * - currentIndex: The index within 'data' to start processing. + * - isExpandingChildren: Flag to indicate if children generated by this group should be added to `result` and `metadata`. + * @internal + */ +interface StackFrame { + data: any[]; + level: number; + parentGroup: IGroupByRecord | null; + currentIndex: number; + isExpandingChildren: boolean; +} + +/** + * Represents a class implementing the IGridSortingStrategy interface. + * It provides sorting functionality for grid data based on sorting expressions. + */ +export class IgxSorting implements IGridSortingStrategy { + /* blazorSuppress */ + /** + * Sorts the provided data based on the given sorting expressions. + * `data`: The array of data to be sorted. + * `expressions`: An array of sorting expressions that define the sorting rules. The expression contains information like file name, whether the letter case should be taken into account, etc. + * `grid`: (Optional) The instance of the grid where the sorting is applied. + * Returns a new array with the data sorted according to the sorting expressions. + */ + public sort(data: any[], expressions: ISortingExpression[], grid?: GridTypeBase): any[] { + return this.sortData(data, expressions, grid); + } + + /** + * 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) { + const date = parseDate(resolvedValue); + if (date && isDate && isTime) { + resolvedValue = date; + } else if (date && isDate && !isTime) { + resolvedValue = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0); + } else if (date && isTime && !isDate) { + resolvedValue = new Date(new Date().setHours(date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds())); + } + } + return resolvedValue; + } + + /** + * Sorts the provided data array based on the given sorting expressions. + * The method can be used when multiple sorting is performed, going through each one + * Returns a new array with the data sorted according to the sorting expressions. + * @internal + */ + private sortData( + data: T[], + expressions: ISortingExpression[], + grid: GridTypeBase + ): T[] { + const sortingExpressions = this.prepareExpressions(expressions, grid); + + if (data.length <= 1) { + return data; + } + + for (let i = sortingExpressions.length - 1; i >= 0; i--) { + data = sortingExpressions[i].strategy.sort(data, sortingExpressions[i].fieldName, sortingExpressions[i].dir, sortingExpressions[i].ignoreCase, this.getFieldValue, sortingExpressions[i].isDate, sortingExpressions[i].isTime, grid) + } + + return data; + } + + private prepareExpressions(expressions: ISortingExpression[], grid: GridTypeBase): IGridInternalSortingExpression[] { + const multipleSortingExpressions: IGridInternalSortingExpression[] = []; + for (const expr of expressions) { + if (expr.dir === SortingDirection.None) { + continue; + } + if (!expr.strategy) { + expr.strategy = DefaultSortingStrategy.instance(); + } + const column = grid?.getColumnByName(expr.fieldName); + const isDate = column?.dataType === DATE_TYPE || column?.dataType === DATE_TIME_TYPE; + const isTime = column?.dataType === TIME_TYPE || column?.dataType === DATE_TIME_TYPE; + const isString = column?.dataType === STRING_TYPE; + multipleSortingExpressions.push({ ...expr, isDate, isTime, isString }) + } + return multipleSortingExpressions; + } +} + +/** + * Represents a class implementing the IGridGroupingStrategy interface and extending the IgxSorting class. + * It provides a method to group data based on the given grouping state. + */ +export class IgxGrouping extends IgxSorting implements IGridGroupingStrategy { + /* blazorSuppress */ + /** + * Groups the provided data based on the given grouping state. + * Returns an object containing the result of the grouping operation. + */ + public groupBy(data: any[], state: IGroupingState, grid?: any, + groupsRecords?: any[], fullResult: IGroupByResult = { data: [], metadata: [] }): IGroupByResult { + const grouping = this.groupData(data, state, grid, groupsRecords, fullResult); + grid?.groupingPerformedSubject.next(); + return { + data: grouping.data, + metadata: grouping.metadata + }; + } + + /** + * Groups the provided data based on the given grouping state. + * Changes groupsRecords and fullResult collections by reference. + * Returns an array containing the visible grouped result. + * @internal + */ + protected groupData( + data: any[], + state: IGroupingState, + grid: GridTypeBase = null, + groupsRecords: any[] = [], + fullResult: IGroupByResult + ): IGroupByResult { + + const expressions = state.expressions; + const expansion = state.expansion; + + // This holds the final visible data (the rows that are expanded). + const result: any[] = []; + + // This holds the group rows for each record in the result array. Used in grid for information when scrolling. + const metadata: IGroupByRecord[] = []; + + // Initialize the stack with the root level processing. + const initialFrame: StackFrame = { + data: data, + level: 0, + parentGroup: null, + currentIndex: 0, + isExpandingChildren: true + }; + const stack: StackFrame[] = [initialFrame]; + + while (stack.length > 0) { + const currentFrame = stack[stack.length - 1]; // Peek at the top of the stack + + const { data: currentData, level, parentGroup, currentIndex, isExpandingChildren } = currentFrame; + + // If we've processed all data in this frame, pop it. + if (currentIndex >= currentData.length) { + stack.pop(); + continue; + } + + // Process the next group at the current level + const column = grid ? grid.getColumnByName(expressions[level].fieldName) : null; + const isDate = column?.dataType === DATE_TYPE || column?.dataType === DATE_TIME_TYPE; + const isTime = column?.dataType === TIME_TYPE || column?.dataType === DATE_TIME_TYPE; + const isString = column?.dataType === STRING_TYPE; + + // Next block of grouped records for the expression of the current level + const group = this.groupedRecordsByExpression( + currentData, + currentIndex, + expressions[level], + isDate, + isTime, + isString, + column?.groupingComparer + ); + + // Create the group row + const groupRow: IGroupByRecord = { + expression: expressions[level], + level, + records: cloneArray(group), + value: this.getFieldValue(group[0], expressions[level].fieldName, isDate, isTime), + groupParent: parentGroup, + groups: [], + height: grid ? grid.renderedRowHeight : null, + column + }; + + // Link to parent's groups list + if (parentGroup) { + parentGroup.groups.push(groupRow); + } else { + groupsRecords.push(groupRow) + } + + // Determine expansion state for this groupRow + const hierarchy = getHierarchy(groupRow); + const expandState: IGroupByExpandState = expansion.find((s) => + isHierarchyMatch( + s.hierarchy || [{ fieldName: groupRow.expression.fieldName, value: groupRow.value }], + hierarchy, + expressions + ) + ); + const expandedForThisGroup = expandState ? expandState.expanded : state.defaultExpanded; + + // Add the group row to the full result set + fullResult.data.push(groupRow); + fullResult.metadata.push(null); + + // Add the group row to the visible results (if its parent was expanded or it's a root group) + if (isExpandingChildren) { + result.push(groupRow); + metadata.push(null); + } + + // Advance the current frame's index for the next iteration of its loop + currentFrame.currentIndex += group.length; + + if (level < expressions.length - 1) { + // If there are more levels to group, push a new frame onto the stack + const nextFrame: StackFrame = { + data: group, // The records of the current group become the data for the next level + level: level + 1, + parentGroup: groupRow, // The current group row is the parent for the next level + currentIndex: 0, + isExpandingChildren: isExpandingChildren && expandedForThisGroup // Children are expanded only if this group is expanded AND parent is expanded + }; + stack.push(nextFrame); + } else { + // This is the leaf level, add individual items to fullResult and conditionally to result/metadata + for (const groupItem of group) { + fullResult.metadata.push(groupRow); // The metadata for an item is its immediate parent group row. + fullResult.data.push(groupItem); + if (isExpandingChildren && expandedForThisGroup) { + // Add to result and metadata only if expanded + metadata.push(groupRow); + result.push(groupItem); + } + } + } + } + + return { data: result, metadata }; + } + + /** + * Groups the records in the provided data array based on the given grouping expression. + * `groupingComparer`: (Optional) A custom grouping comparator to determine the members of the group. + * Returns an array containing the records that belong to the group. + * @internal + */ + private groupedRecordsByExpression( + data: T[], + index: number, + expression: IGroupingExpression, + isDate = false, + isTime = false, + isString: boolean, + groupingComparer?: (a: any, b: any, currRec: any, groupRec: any) => number + ): T[] { + const res: T[] = []; + const key = expression.fieldName; + const len = data.length; + const groupRecord = data[index]; + let groupValue = this.getFieldValue(groupRecord, key, isDate, isTime); + if (expression.ignoreCase && isString && groupValue) { + // when column's dataType is string but the value is number + groupValue = groupValue.toString().toLowerCase(); + } + res.push(groupRecord); + const comparer = expression.groupingComparer || groupingComparer || DefaultSortingStrategy.instance().compareValues; + for (let i = index + 1; i < len; i++) { + const currRec = data[i]; + let fieldValue = this.getFieldValue(currRec, key, isDate, isTime); + if (expression.ignoreCase && isString && fieldValue) { + // when column's dataType is string but the value is number + fieldValue = fieldValue.toString().toLowerCase(); + } + if (comparer(fieldValue, groupValue, currRec, groupRecord) === 0) { + res.push(currRec); + } else { + break; + } + } + return res; + } +} + +/* csSuppress */ +/** + * Represents a class implementing the IGridSortingStrategy interface with a no-operation sorting strategy. + * It performs no sorting and returns the data as it is. + */ +export class NoopSortingStrategy implements IGridSortingStrategy { + private static _instance: NoopSortingStrategy = null; + + private constructor() { } + + public static instance(): NoopSortingStrategy { + return this._instance || (this._instance = new NoopSortingStrategy()); + } + + /* csSuppress */ + public sort(data: any[]): any[] { + return data; + } +} + +/** + * Represents a class extending the IgxSorting class + * Provides custom data record sorting. + */ +export class IgxDataRecordSorting extends IgxSorting { + /** + * Overrides the base method to retrieve the field value from the data object instead of the record object. + * Returns the value of the specified field in the data object. + */ + protected override getFieldValue(obj: any, key: string, isDate = false, isTime = false): any { + return super.getFieldValue(obj.data, key, isDate, isTime); + } +} diff --git a/projects/igniteui-angular/core/src/data-operations/grid-types.ts b/projects/igniteui-angular/core/src/data-operations/grid-types.ts new file mode 100644 index 00000000000..ff4ed9ae62b --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/grid-types.ts @@ -0,0 +1,495 @@ +/** + * Minimal type stubs for grid types to avoid circular dependencies. + * These are simple interfaces that core uses for typing only. + * The actual implementations are in igniteui-angular/grids. + */ + +import { QueryList, TemplateRef } from '@angular/core'; +import { WEEKDAYS } from '../core/enums'; +import { IgxFilteringOperand } from './filtering-condition'; +import { ISortingStrategy } from './sorting-strategy'; +import { FilteringExpressionsTree } from './filtering-expressions-tree'; + + +/* IgxGrid column types */ +export interface IFieldPipeArgs { + /** The date/time components that a date column will display, using predefined options or a custom format string. */ + format?: string; + /** A timezone offset (such as '+0430'), or a standard UTC/GMT or continental US timezone abbreviation. */ + timezone?: string; + /** + * Decimal representation options, specified by a string in the following format: + * `{minIntegerDigits}`.`{minFractionDigits}`-`{maxFractionDigits}`. + * `minIntegerDigits`: The minimum number of integer digits before the decimal point. Default is 1. + * `minFractionDigits`: The minimum number of digits after the decimal point. Default is 0. + * `maxFractionDigits`: The maximum number of digits after the decimal point. Default is 3. + */ + digitsInfo?: string; + /** The currency code of type string, default value undefined */ + currencyCode?: string; + /** + * Allow us to display currency 'symbol' or 'code' or 'symbol-narrow' or our own string. + * The value is of type string. By default is set to 'symbol' + */ + display?: string; + + /** The first week day to be displayed in calendar when filtering or editing a date column */ + weekStart?: WEEKDAYS | number; +} + +// D.P. Can't use `export type IColumnPipeArgs = IFieldPipeArgs` because TypeScripts Compiler API optimizes it away completely + +export interface IColumnPipeArgs extends IFieldPipeArgs {} + +export interface IFieldEditorOptions { + /** + * A custom input format string used for the built-in editors of date/time columns. + * See the Editing section under https://www.infragistics.com/products/ignite-ui-angular/angular/components/grid/column-types#datetime-date-and-time + */ + dateTimeFormat?: string; +} + +export interface IColumnEditorOptions extends IFieldEditorOptions {} + +export interface ISortingOptions { + mode: 'single' | 'multiple'; +} + +/** + * @hidden + * @internal + */ +export interface MRLColumnSizeInfo { + ref: ColumnType; + width: number; + colSpan: number; + colEnd: number; + widthSetByUser: boolean; +} + +/** + * @hidden + * @internal + */ +export interface MRLResizeColumnInfo { + target: ColumnType; + spanUsed: number; +} + +/* mustCoerceToInt */ +/** + * Enumeration representing the possible positions for pinning columns. + * - Start: Columns are pinned to the start of the grid. + * - End: Columns are pinned to the end of the grid. + */ +export enum ColumnPinningPosition { + Start, + End +} + +/** + * Stub type for GridType - minimal interface for typing in core + */ +export interface GridTypeBase { + primaryKey?: string; + id?: string; + data: any[] | null; + [key: string]: any; +} + +/** + * 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: GridColumnDataType; + + /** + * 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; +} + +/** + * Represents a column in the `GridType`. It is essentially the blueprint to a column object. + * Contains definitions of properties and methods, relevant to a column + */ +export interface ColumnType extends FieldType { + /** Represents the instance of the parent `GridType` that contains this column. */ + grid: GridTypeBase; + /** + * A list containing all the child columns under this column (if any). + * @deprecated in version 18.1.0. Use the `childColumns` property instead. + */ + children: QueryList; + /** + * A list containing all the child columns under this column (if any). + * Empty without children or if this column is not Group or Layout. + */ + get childColumns(): ColumnType[]; + /** @hidden @internal */ + allChildren: ColumnType[]; + /** @hidden @internal */ + headerGroup: any; + /** @hidden @internal */ + headerCell: any; + validators: any[]; + mergingComparer: (prevRecord: any, record: any, field: string) => boolean; + + /** + * The template reference for the custom header of the column + * It is of type TemplateRef, which represents an embedded template, used to instantiate embedded views + */ + headerTemplate: TemplateRef; + /** + * The template reference for the collapsible indicator of the column. + * It is of type TemplateRef, which represents an embedded template, used to instantiate embedded views + */ + collapsibleIndicatorTemplate?: TemplateRef; + /** Represents custom CSS classes applied to the header element. When added, they take different styling */ + headerClasses: any; + /** Represents custom CSS styles applied to the header element. When added, they take different styling */ + headerStyles: any; + /** Represents custom CSS classes applied to the header group. When added, they take different styling */ + headerGroupClasses: any; + /** Represents custom CSS styles applied to the header group. When added, they take different styling */ + headerGroupStyles: any; + + /** + * Custom CSS styling, applied to every column + * calcWidth, minWidthPx, maxWidthPx, minWidth, maxWidth, minWidthPercent, maxWidthPercent, resolvedWidth + */ + calcWidth: any; + minWidthPx: number; + maxWidthPx: number; + minWidth: string; + maxWidth: string; + minWidthPercent: number; + maxWidthPercent: number; + resolvedWidth: string; + + /** + * Optional + * Represents the header text of the column + */ + header?: string; + /** + * The index of the column within the grid. + * Includes the hidden columns when counting + */ + index: number; + /** + * Represents the type of data for the column: + * string, number, boolean, currency, date, time, etc. + */ + dataType: GridColumnDataType; + /** + * Sets properties on the default column editors + */ + editorOptions: IColumnEditorOptions; + /** + * The template reference for the custom inline editor of the column + * It is of type TemplateRef, which represents an embedded template, used to instantiate embedded views + */ + inlineEditorTemplate: TemplateRef; + /** + * The index of the column within the grid. + * Does not include the hidden columns when counting + */ + visibleIndex: number; + /** Optional + * Indicated whether the column can be collapsed. If the value is true, the column can be collapsed + * It is used in tree grid and for navigation + */ + collapsible?: boolean; + /** Indicated whether the column can be edited. If the value is true, the column can be edited */ + editable: boolean; + /** Specifies whether the column can be resized. If the value is true, the column can be resized */ + resizable: boolean; + /** Specifies whether the data of the column can be searched. If the value is true, the column data can be searched */ + searchable: boolean; + /** Specifies whether the column belongs to a group of columns. */ + columnGroup: boolean; + /** Indicates whether a column can be put in a group. If the value is true, the column can be put in a group */ + groupable: boolean; + /** Indicates whether a column can be sorted. If the value is true, the column can be sorted. */ + sortable: boolean; + /** Indicates whether a column can be filtered. If the value is true, the column can be filtered */ + filterable: boolean; + /** Indicates whether a column is currently hidden (not visible). If the value is true, the column is not visible */ + hidden: boolean; + /** Indicates whether a column can be pinned. If the value is true, the column cannot be pinned */ + disablePinning: boolean; + /** Indicates whether a column can be hidden. If the value is true, the column cannot be hidden */ + disableHiding: boolean; + /** + * The sorting strategy used for sorting this column. + * The interface contains a method sort that sorts the provided data based on the given sorting expressions + */ + sortStrategy: ISortingStrategy; + /** + * Indicates whether the search should match results, no matter the case of the letters (upper and lower) + * If the value is false, the result will depend on the case (example: `E` will not match `e`) + * If the value is true, the result will not depend on the case (example: `E` will match `e`) + */ + sortingIgnoreCase: boolean; + /** @hidden @internal */ + filterCell: any; + filteringIgnoreCase: boolean; + /** + * The filtering expressions for the column. + * The type contains properties and methods for filtering: filteringOperands, operator (logic), fieldName, etc. + */ + filteringExpressionsTree: FilteringExpressionsTree; + hasSummary: boolean; + summaries: any; + disabledSummaries?: string[]; + /** + * The template reference for a summary of the column + * It is of type TemplateRef, which represents an embedded template, used to instantiate embedded views + */ + summaryTemplate: TemplateRef; + /** Indicates if the column is currently pinned. If the value is true, the column is pinned */ + 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 */ + selectable: boolean; + columnLayout: boolean; + /** Represents the hierarchical level of the column in the column layout */ + level: number; + rowStart: number; + rowEnd: number; + colStart: number; + colEnd: number; + /** @hidden @internal */ + gridRowSpan: number; + /** @hidden @internal */ + gridColumnSpan: number; + columnLayoutChild: boolean; + width: string; + /** + * Optional + * The root parent of this column (if any). + * If there is no root parent, that means the current column is the root parent + */ + topLevelParent?: ColumnType; + /* alternateName: parentColumn */ + /** + * Optional + * The immediate parent (right above) column of this column (if any). + * If there is no parent, that means the current column is the root parent + */ + parent: ColumnType | null; + pipeArgs: IColumnPipeArgs; + hasNestedPath: boolean; + additionalTemplateContext: any; + /** Indicates whether the current column is the last to be pinned. + * If the value is false, there are columns, that have been pinned after the current */ + isLastPinned: boolean; + /** Indicates whether the current column is the first for the grid to be pinned. + * If the value is false, there are columns, that have been pinned before the current */ + isFirstPinned: boolean; + applySelectableClass: boolean; + /** The title of the column, used for accessibility purposes */ + title: string; + /* blazorSuppress */ + /** Represents a method with custom grouping comparator to determine the members of the group. */ + groupingComparer: (a: any, b: any) => number; + + /** + * Represents a custom template for filtering + * It is of type TemplateRef, which represents an embedded template, used to instantiate embedded views + */ + filterCellTemplate: TemplateRef; + + /** + * A method definition to move the column to the specified index. + * It takes the index of type number as a parameter + */ + move(index: number): void; + /** A method definition to retrieve the set CSS size */ + getAutoSize(): string; + getResizableColUnderEnd(): MRLResizeColumnInfo[]; + /** A method definition to retrieve the set CSS width of the cells under the column */ + getCellWidth(): string; + getGridTemplate(isRow: boolean): string; + /** A method definition to toggle column visibility (hidden or visible) */ + toggleVisibility(value?: boolean): void; + populateVisibleIndexes?(): void; + /** Pins the column at the specified index (if not already pinned). */ + pin(index?: number, pinningPosition?: ColumnPinningPosition): boolean; + /** Unpins the column at the specified index (if not already unpinned). */ + unpin(index?: number): boolean; +} + +/** + * 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[]; +} + +/* marshalByValue */ +export interface ITreeGridRecord { + key: any; + data: any; + children?: ITreeGridRecord[]; + /* blazorAlternateName: RecordParent */ + parent?: ITreeGridRecord; + level?: number; + isFilteredOutParent?: boolean; + expanded?: boolean; +} + +/** + * Stub type for IgxTreeGridAPIService - minimal interface for typing in core + */ +export interface IgxTreeGridAPIService { + get_row_id(rowData: any): any; + [key: string]: any; +} + + +/** Interface representing a segment of a path in a hierarchical grid. */ +export interface IPathSegment { + /** + * The unique identifier of the row within the segment. + * @deprecated since version 17.1.0. Use the `rowKey` property instead. + */ + rowID: any; + rowKey: any; + /** The key representing the row's 'hierarchical level. */ + rowIslandKey: string; +} + +/* tsPlainInterface * +/* marshalByValue */ +export interface ISummaryExpression { + fieldName: string; + /* blazorCSSuppress */ + customSummary?: any; +} + +/* tsPlainInterface */ +/* marshalByValue */ +export interface IgxSummaryResult { + key: string; + label: string; + /* blazorAlternateName: Result */ + summaryResult: any; + /** + * Apply default formatting based on the grid column type. + * ```typescript + * const result: IgxSummaryResult = { + * key: 'key', + * label: 'label', + * defaultFormatting: true + * } + * ``` + * + * @memberof IgxSummaryResult + */ + defaultFormatting?: boolean; +} + +export interface ISummaryRecord { + summaries: Map; + max?: number; + cellIndentation?: number; +} + +/** + * Enumeration representing different calculation modes for grid summaries. + * - rootLevelOnly: Summaries are calculated only for the root level. + * - childLevelsOnly: Summaries are calculated only for child levels. + * - rootAndChildLevels: Default value; Summaries are calculated for both root and child levels. + */ +export const GridSummaryCalculationMode = { + rootLevelOnly: 'rootLevelOnly', + childLevelsOnly: 'childLevelsOnly', + rootAndChildLevels: 'rootAndChildLevels' +} as const; +export type GridSummaryCalculationMode = (typeof GridSummaryCalculationMode)[keyof typeof GridSummaryCalculationMode]; + +/** + * @hidden + */ +export const GridColumnDataType = { + String: 'string', + Number: 'number', + Boolean: 'boolean', + Date: 'date', + DateTime: 'dateTime', + Time: 'time', + Currency: 'currency', + Percent: 'percent', + Image: 'image' +} as const; +export type GridColumnDataType = (typeof GridColumnDataType)[keyof typeof GridColumnDataType]; diff --git a/projects/igniteui-angular/core/src/data-operations/groupby-expand-state.interface.ts b/projects/igniteui-angular/core/src/data-operations/groupby-expand-state.interface.ts new file mode 100644 index 00000000000..f2924ce7c6e --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/groupby-expand-state.interface.ts @@ -0,0 +1,9 @@ +export interface IGroupByExpandState { + expanded: boolean; + hierarchy: Array; +} + +export interface IGroupByKey { + fieldName: string; + value: any; +} diff --git a/projects/igniteui-angular/core/src/data-operations/groupby-record.interface.ts b/projects/igniteui-angular/core/src/data-operations/groupby-record.interface.ts new file mode 100644 index 00000000000..df6522a2e02 --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/groupby-record.interface.ts @@ -0,0 +1,20 @@ +import type { ColumnType } from './grid-types'; +import { ISortingExpression } from './sorting-strategy'; + +/** + * @hidden + */ +export class GroupedRecords extends Array {} + +/* jsonAPIComplexObject */ +export interface IGroupByRecord { + expression: ISortingExpression; + level: number; + /* wcAlternateType: any[] */ + records: GroupedRecords; + value: any; + groupParent: IGroupByRecord; + groups?: IGroupByRecord[]; + height: number; + column?: ColumnType; + } diff --git a/projects/igniteui-angular/core/src/data-operations/groupby-state.interface.ts b/projects/igniteui-angular/core/src/data-operations/groupby-state.interface.ts new file mode 100644 index 00000000000..7430060598b --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/groupby-state.interface.ts @@ -0,0 +1,10 @@ +import { IGroupByExpandState } from './groupby-expand-state.interface'; +import { IGroupingExpression } from './grouping-expression.interface'; + +/* marshalByValue */ +/* tsPlainInterface */ +export interface IGroupingState { + expressions: IGroupingExpression[]; + expansion: IGroupByExpandState[]; + defaultExpanded: boolean; +} diff --git a/projects/igniteui-angular/core/src/data-operations/groupby-strategy.spec.ts b/projects/igniteui-angular/core/src/data-operations/groupby-strategy.spec.ts new file mode 100644 index 00000000000..d2683e40e29 --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/groupby-strategy.spec.ts @@ -0,0 +1,42 @@ +import { IgxGrouping } from './grid-sorting-strategy'; +import { IGroupByRecord } from './groupby-record.interface'; +import { DefaultSortingStrategy, SortingDirection } from './sorting-strategy'; +import { DataGenerator } from './test-util/data-generator'; + +describe('Unit testing GroupingStrategy', () => { + let dataGenerator: DataGenerator; + let data: any[]; + const grouping = new IgxGrouping(); + beforeEach(() => { + dataGenerator = new DataGenerator(); + data = dataGenerator.data; + }); + + it('should group by a field', () => { + const expr = [{ + dir: SortingDirection.Asc, + fieldName: 'boolean', + ignoreCase: false, + strategy: DefaultSortingStrategy.instance() + }]; + const result = grouping.sort(data, expr); + const groupResult = grouping.groupBy(result, { + expressions: expr, + expansion: [], + defaultExpanded: true + }); + expect(dataGenerator.getValuesForColumn(groupResult.data, 'boolean')) + .toEqual([undefined, false, false, false, undefined, true, true]); + const group1: IGroupByRecord = groupResult.metadata[1]; + const group2: IGroupByRecord = groupResult.metadata[5]; + expect(groupResult.metadata[2]).toEqual(group1); + expect(groupResult.metadata[3]).toEqual(group1); + expect(groupResult.metadata[6]).toEqual(group2); + expect(group1.level).toEqual(0); + expect(group2.level).toEqual(0); + expect(group1.records).toEqual(result.slice(0, 3)); + expect(group2.records).toEqual(result.slice(3, 5)); + expect(group1.value).toEqual(false); + expect(group2.value).toEqual(true); + }); +}); diff --git a/projects/igniteui-angular/core/src/data-operations/grouping-expression.interface.ts b/projects/igniteui-angular/core/src/data-operations/grouping-expression.interface.ts new file mode 100644 index 00000000000..97895645728 --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/grouping-expression.interface.ts @@ -0,0 +1,8 @@ +import { ISortingExpression } from './sorting-strategy'; + +/* marshalByValue */ +/* tsPlainInterface */ +export interface IGroupingExpression extends ISortingExpression { + /* blazorCSSuppress */ + groupingComparer?: (a: any, b: any, currRec?: any, groupRec?: any) => number; +} diff --git a/projects/igniteui-angular/core/src/data-operations/grouping-result.interface.ts b/projects/igniteui-angular/core/src/data-operations/grouping-result.interface.ts new file mode 100644 index 00000000000..269ced2bc4c --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/grouping-result.interface.ts @@ -0,0 +1,6 @@ +import { IGroupByRecord } from './groupby-record.interface'; + +export interface IGroupByResult { + data: any[]; + metadata: IGroupByRecord[]; +} diff --git a/projects/igniteui-angular/core/src/data-operations/merge-strategy.ts b/projects/igniteui-angular/core/src/data-operations/merge-strategy.ts new file mode 100644 index 00000000000..1f0c397054f --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/merge-strategy.ts @@ -0,0 +1,184 @@ +import { columnFieldPath, parseDate, resolveNestedPath } from '../core/utils'; +import type { GridTypeBase } from './grid-types'; + +export interface IMergeByResult { + rowSpan: number; + root?: any; + childRecords?: any[]; +} + +/** + * Merge strategy interface. + */ +export interface IGridMergeStrategy { + /* blazorCSSuppress */ + /** + * 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[], + /* The active row indexes, where merging should break the sequence. */ + activeRowIndexes: number[], + /* (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?: GridTypeBase + ) => any[]; + /** + * Function that compares values for merging. Returns true if same, false if different. + */ + comparer: (prevRecord: any, record: any, field: string) => boolean; +} + +export class DefaultMergeStrategy implements IGridMergeStrategy { + protected static _instance: DefaultMergeStrategy = null; + + public static instance(): DefaultMergeStrategy { + return this._instance || (this._instance = new this()); + } + + /* blazorCSSuppress */ + public merge( + data: any[], + field: string, + comparer: (prevRecord: any, record: any, field: string, isDate?: boolean, isTime?: boolean) => boolean = this.comparer, + result: any[], + activeRowIndexes: number[], + isDate = false, + isTime = false, + grid?: GridTypeBase + ) { + let prev = null; + let index = 0; + for (const rec of data) { + + 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) { + result.push(rec); + } + prev = null; + index++; + continue; + } + const recToUpdateData = recData ?? { recordRef: grid.isGhostRecord(rec) ? rec.recordRef : rec, cellMergeMeta: new Map(), ghostRecord: rec.ghostRecord }; + recToUpdateData.cellMergeMeta.set(field, { rowSpan: 1, childRecords: [] }); + 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; + root.cellMergeMeta.get(field).childRecords.push(recToUpdateData); + recToUpdateData.cellMergeMeta.get(field).root = root; + } + prev = recToUpdateData; + if (!recData) { + result.push(recToUpdateData); + } + index++; + } + return result; + } + + /* blazorCSSuppress */ + 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) { + if (bn) { + return true; + } + return false; + } else if (bn) { + return false; + } + 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) { + const 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().setHours(date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds()); + } + return resolvedValue; + } +} + + +export class DefaultTreeGridMergeStrategy extends DefaultMergeStrategy { + /* blazorCSSuppress */ + 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) { + if (bn) { + return true; + } + return false; + } else if (bn) { + return false; + } + return a === b; + } +} + +export class ByLevelTreeGridMergeStrategy extends DefaultMergeStrategy { + /* blazorCSSuppress */ + 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); + 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/core/src/data-operations/multi-row-layout.interfaces.ts b/projects/igniteui-angular/core/src/data-operations/multi-row-layout.interfaces.ts new file mode 100644 index 00000000000..fda47b5f53a --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/multi-row-layout.interfaces.ts @@ -0,0 +1,14 @@ +// import { IgxColumnComponent } from 'igniteui-angular/grids'; + +// export interface MRLColumnSizeInfo { +// ref: IgxColumnComponent; +// width: number; +// colSpan: number; +// colEnd: number; +// widthSetByUser: boolean; +// } + +// export interface MRLResizeColumnInfo { +// target: IgxColumnComponent; +// spanUsed: number; +// } diff --git a/projects/igniteui-angular/core/src/data-operations/operations.ts b/projects/igniteui-angular/core/src/data-operations/operations.ts new file mode 100644 index 00000000000..5f597c057dc --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/operations.ts @@ -0,0 +1,27 @@ +import { IGroupByKey } from './groupby-expand-state.interface'; +import { IGroupByRecord } from './groupby-record.interface'; +import { IGroupingExpression } from './grouping-expression.interface'; +import { DefaultSortingStrategy } from './sorting-strategy'; + +export const isHierarchyMatch = (h1: Array, h2: Array, expressions: IGroupingExpression[]): boolean => { + if (h1.length !== h2.length) { + return false; + } + return h1.every((level, index): boolean => { + const expr = expressions.find(e => e.fieldName === level.fieldName); + const comparer = expr.groupingComparer || DefaultSortingStrategy.instance().compareValues; + return level.fieldName === h2[index].fieldName && comparer(level.value, h2[index].value) === 0; + }); +}; + +export const getHierarchy = (gRow: IGroupByRecord): Array => { + const hierarchy: Array = []; + if (gRow !== undefined && gRow.expression) { + hierarchy.push({ fieldName: gRow.expression.fieldName, value: gRow.value }); + while (gRow.groupParent) { + gRow = gRow.groupParent; + hierarchy.unshift({ fieldName: gRow.expression.fieldName, value: gRow.value }); + } + } + return hierarchy; +}; diff --git a/projects/igniteui-angular/core/src/data-operations/paging-state.interface.ts b/projects/igniteui-angular/core/src/data-operations/paging-state.interface.ts new file mode 100644 index 00000000000..b2e0e6669f4 --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/paging-state.interface.ts @@ -0,0 +1,18 @@ +export enum PagingError { + None, + IncorrectPageIndex, + IncorrectRecordsPerPage +} + +/* marshalByValue */ +/* tsPlainInterface */ +export declare interface IPagingState { + index: number; + recordsPerPage: number; + /* blazorSuppress */ + metadata?: { + countPages: number; + error: PagingError; + countRecords: number; + }; +} diff --git a/projects/igniteui-angular/core/src/data-operations/record-info.interface.ts b/projects/igniteui-angular/core/src/data-operations/record-info.interface.ts new file mode 100644 index 00000000000..823a38185ea --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/record-info.interface.ts @@ -0,0 +1,8 @@ + +/** + * @hidden + */ +export interface IRecordInfo { + index: number; + record: any; +} diff --git a/projects/igniteui-angular/core/src/data-operations/sorting-strategy.spec.ts b/projects/igniteui-angular/core/src/data-operations/sorting-strategy.spec.ts new file mode 100644 index 00000000000..f4c1948e49f --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/sorting-strategy.spec.ts @@ -0,0 +1,69 @@ +import { DataGenerator } from './test-util/data-generator'; +import { DefaultSortingStrategy, SortingDirection } from './sorting-strategy'; +import { IgxSorting } from './grid-sorting-strategy'; + +describe('Unit testing SortingStrategy', () => { + let dataGenerator: DataGenerator; + let data: any[]; + const sorting = new IgxSorting(); + beforeEach(() => { + dataGenerator = new DataGenerator(); + data = dataGenerator.data; + }); + it('tests `sort`', () => { + const res = sorting.sort(data, [ + { + dir: SortingDirection.Asc, + fieldName: 'boolean', + ignoreCase: false, + strategy: DefaultSortingStrategy.instance() + }, { + dir: SortingDirection.Desc, + fieldName: 'number', + ignoreCase: false, + strategy: DefaultSortingStrategy.instance() + }]); + expect(dataGenerator.getValuesForColumn(res, 'number')) + .toEqual([4, 2, 0, 3, 1]); + }); + it('tests `compareObjects`', () => { + const strategy = DefaultSortingStrategy.instance(); + expect(strategy.compareValues(1, 0) === 1 && + strategy.compareValues(true, false) === 1 && + strategy.compareValues('bc', 'adfc') === 1) + .toBeTruthy('compare first argument greater than second'); + expect(strategy.compareValues(1, 2) === -1 && + strategy.compareValues('a', 'b') === -1 && + strategy.compareValues(false, true) === -1) + .toBeTruthy('compare 0, 1'); + expect(strategy.compareValues(0, 0) === 0 && + strategy.compareValues(true, true) === 0 && + strategy.compareValues('test', 'test') === 0 + ) + .toBeTruthy('Comare equal variables'); + }); + it('tests default settings', () => { + (data[4] as { string: string }).string = 'ROW'; + const res = sorting.sort(data, [{ + dir: SortingDirection.Asc, + fieldName: 'string', + ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }]); + expect(dataGenerator.getValuesForColumn(res, 'number')) + .toEqual([4, 0, 1, 2, 3]); + }); + + it('should not sort when sorting direction is None', () => { + const unsortedData = [{ number: 3 }, { number: 1 }, { number: 4 }, { number: 0 }, { number: 2 }]; + const res = sorting.sort(unsortedData, [{ + dir: SortingDirection.None, + fieldName: 'number', + ignoreCase: false, + strategy: DefaultSortingStrategy.instance() + }]); + expect(res.map(d => d.number)) + .toEqual([3, 1, 4, 0, 2]); + }); + +}); diff --git a/projects/igniteui-angular/core/src/data-operations/sorting-strategy.ts b/projects/igniteui-angular/core/src/data-operations/sorting-strategy.ts new file mode 100644 index 00000000000..e8d9eabbe5f --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/sorting-strategy.ts @@ -0,0 +1,208 @@ +import type { KeyOfOrString } from '../core/types'; +import { IBaseEventArgs } from '../core/utils'; +import type { GridTypeBase } from './grid-types'; + +/* mustCoerceToInt */ +export enum SortingDirection { + None = 0, + Asc = 1, + Desc = 2 +} + +/* marshalByValue */ +/* tsPlainInterface */ +export interface ISortingExpression extends IBaseEventArgs { + fieldName: KeyOfOrString & string; + /* mustCoerceToInt */ + dir: SortingDirection; + ignoreCase?: boolean; + strategy?: ISortingStrategy; +} + +export interface ISortingStrategy { + /* blazorSuppress */ + sort: ( + data: any[], + fieldName: string, + dir: SortingDirection, + ignoreCase: boolean, + valueResolver: (obj: any, key: string, isDate?: boolean) => any, + isDate?: boolean, + isTime?: boolean, + grid?: GridTypeBase + ) => any[]; +} + +export class DefaultSortingStrategy implements ISortingStrategy { + protected static _instance: DefaultSortingStrategy = null; + + protected constructor() { } + + public static instance(): DefaultSortingStrategy { + return this._instance || (this._instance = new this()); + } + + /* blazorSuppress */ + public sort( + data: any[], + fieldName: string, + dir: SortingDirection, + ignoreCase: boolean, + valueResolver: (obj: any, key: string, isDate?: boolean) => any, + isDate?: boolean, + isTime?: boolean + ) { + const key = fieldName; + const reverse = (dir === SortingDirection.Desc ? -1 : 1); + + /** + * Use Schwartizian transform on the data before sorting it so that the sorting value + * is not recomputed on every object compare which improves the number of comparisons from O(nlogn) to O(n) + * where n is the length of the datasource. + * This, on a very large dataset of 1 million records, gives a significant performance boost. + */ + const resolver = valueResolver.bind(this); + const preparedData = data.map(item => { + return { + original: item, + sortValue: this.prepareSortValue(resolver(item, key, isDate, isTime), ignoreCase) + } + }); + const compareFn = (a, b) => reverse * this.compareValues(a.sortValue, b.sortValue); + preparedData.sort(compareFn); + + return preparedData.map(item => item.original); + } + + public compareValues(a: any, b: any): number { + const aIsNullish = a == null; + const bIsNullish = b == null; + + if (aIsNullish && bIsNullish) return 0; + if (aIsNullish) return -1 + if (bIsNullish) return 1; + + return a > b ? 1 : a < b ? -1 : 0; + } + + protected compareObjects( + obj1: any, + obj2: any, + key: string, + reverse: number, + ignoreCase: boolean, + valueResolver: (obj: any, key: string, isDate?: boolean, isTime?: boolean) => any, + isDate: boolean, + isTime: boolean + ) { + let a = valueResolver.call(this, obj1, key, isDate, isTime); + let b = valueResolver.call(this, obj2, key, isDate, isTime); + if (ignoreCase) { + a = a && a.toLowerCase ? a.toLowerCase() : a; + b = b && b.toLowerCase ? b.toLowerCase() : b; + } + return reverse * this.compareValues(a, b); + } + + protected arraySort(data: any[], compareFn?: (arg0: any, arg1: any) => number): any[] { + return data.sort(compareFn); + } + + protected prepareSortValue(value: any, ignoreCase: boolean) { + return ignoreCase && typeof value === 'string' ? value.toLocaleLowerCase() : value; + } +} + +export class GroupMemberCountSortingStrategy implements ISortingStrategy { + protected static _instance: GroupMemberCountSortingStrategy = null; + + protected constructor() { } + + public static instance(): GroupMemberCountSortingStrategy { + return this._instance || (this._instance = new this()); + } + + public sort(data: any[], fieldName: string, dir: SortingDirection) { + const groupedArray = this.groupBy(data, fieldName); + const reverse = (dir === SortingDirection.Desc ? -1 : 1); + + const cmpFunc = (a, b) => { + return this.compareObjects(a, b, groupedArray, fieldName, reverse); + }; + + return data + .sort((a, b) => a[fieldName].localeCompare(b[fieldName])) + .sort(cmpFunc); + } + + public groupBy(data, key) { + return data.reduce((acc, curr) => { + (acc[curr[key]] = acc[curr[key]] || []).push(curr); + return acc; + }, {}) + } + + protected compareObjects(obj1: any, obj2: any, data: any[], fieldName: string, reverse: number) { + const firstItemValuesLength = data[obj1[fieldName]].length; + const secondItemValuesLength = data[obj2[fieldName]].length; + + return reverse * (firstItemValuesLength - secondItemValuesLength); + } +} + +export class FormattedValuesSortingStrategy extends DefaultSortingStrategy { + protected static override _instance: FormattedValuesSortingStrategy = null; + + constructor() { + super(); + } + + public static override instance(): FormattedValuesSortingStrategy { + return this._instance || (this._instance = new this()); + } + + public override sort( + data: any[], + fieldName: string, + dir: SortingDirection, + ignoreCase: boolean, + valueResolver: (obj: any, key: string, isDate?: boolean) => any, + isDate?: boolean, + isTime?: boolean, + grid?: GridTypeBase + ) { + const key = fieldName; + const reverse = (dir === SortingDirection.Desc ? -1 : 1); + const cmpFunc = (obj1: any, obj2: any) => this.compareObjects(obj1, obj2, key, reverse, ignoreCase, valueResolver, isDate, isTime, grid); + return this.arraySort(data, cmpFunc); + } + + protected override compareObjects( + obj1: any, + obj2: any, + key: string, + reverse: number, + ignoreCase: boolean, + valueResolver: (obj: any, key: string, isDate?: boolean, isTime?: boolean) => any, + isDate: boolean, + isTime: boolean, + grid?: GridTypeBase + ) { + let a = valueResolver.call(this, obj1, key, isDate, isTime); + let b = valueResolver.call(this, obj2, key, isDate, isTime); + + if (grid) { + const col = grid.getColumnByName(key); + if (col && col.formatter) { + a = col.formatter(a); + b = col.formatter(b); + } + } + + if (ignoreCase) { + a = a && a.toLowerCase ? a.toLowerCase() : a; + b = b && b.toLowerCase ? b.toLowerCase() : b; + } + return reverse * this.compareValues(a, b); + } +} diff --git a/projects/igniteui-angular/core/src/data-operations/test-util/data-generator.ts b/projects/igniteui-angular/core/src/data-operations/test-util/data-generator.ts new file mode 100644 index 00000000000..2394a023f16 --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/test-util/data-generator.ts @@ -0,0 +1,112 @@ +import { GridColumnDataType } from '../grid-types'; + +/** + * @hidden + */ +const COUNT_ROWS = 5; + +/** + * @hidden + */ +const COUNT_COLS = 4; + +/** + * @hidden + */ +export interface IDataColumn { + fieldName: string; + type: GridColumnDataType; +} + +/** + * @hidden + */ +export class DataGenerator { + public columns: IDataColumn[] = []; + public data: any[] = []; + constructor(countRows = COUNT_ROWS, countCols = COUNT_COLS) { + this.columns = this.generateColumns(countCols); + this.data = this.generateData(countRows); + } + public generateArray(startValue, endValue) { + const len = Math.abs(startValue - endValue); + const decrement = startValue > endValue; + return Array.from({ length: len + 1 }, (e, i) => decrement ? startValue - i : startValue + i); + } + public getValuesForColumn(data, fieldName) { + return data.map((x) => x[fieldName]); + } + public getGroupRecords(data) { + return data.map((x) => x['groupParent']); + } + public isSuperset(haystack, arr) { + return arr.every((val) => haystack.indexOf(val) >= 0); + } + private generateColumns(countCols): IDataColumn[] { + let i: number; + const defaultColumns: IDataColumn[] = [ + { + fieldName: 'number', + type: GridColumnDataType.Number + }, + { + fieldName: 'string', + type: GridColumnDataType.String + }, + { + fieldName: 'date', + type: GridColumnDataType.Date + }, + { + fieldName: 'boolean', + type: GridColumnDataType.Boolean + } + ]; + if (countCols <= 0) { + return defaultColumns; + } + if (countCols <= defaultColumns.length) { + return defaultColumns.slice(0, countCols); + } + const len = countCols - defaultColumns.length; + const res = defaultColumns; + for (i = 0; i < len; i++) { + res.push({ + fieldName: `col${i}`, + type: GridColumnDataType.String + }); + } + return res; + } + private generateData(countRows: number) { + let i; + let j; + let rec; + let val; + let col; + const data = []; + for (i = 0; i < countRows; i++) { + rec = {}; + for (j = 0; j < this.columns.length; j++) { + col = this.columns[j]; + switch (col.type) { + case GridColumnDataType.Number: + val = i; + break; + case GridColumnDataType.Date: + val = new Date(Date.now() + i * 24 * 60 * 60 * 1000); + break; + case GridColumnDataType.Boolean: + val = !!(i % 2); + break; + default: + val = `row${i}, col${j}`; + break; + } + rec[col.fieldName] = val; + } + data.push(rec); + } + return data; + } +} diff --git a/projects/igniteui-angular/core/src/data-operations/tree-grid-filtering-strategy.ts b/projects/igniteui-angular/core/src/data-operations/tree-grid-filtering-strategy.ts new file mode 100644 index 00000000000..447526bf011 --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/tree-grid-filtering-strategy.ts @@ -0,0 +1,173 @@ +import { columnFieldPath, parseDate, resolveNestedPath } from '../core/utils'; +import { DataUtil } from './data-util'; +import { FilteringExpressionsTree, type IFilteringExpressionsTree } from './filtering-expressions-tree'; +import { BaseFilteringStrategy, type IgxFilterItem } from './filtering-strategy'; +import { SortingDirection } from './sorting-strategy'; +import type { ColumnType, GridTypeBase, IgxTreeGridAPIService, ITreeGridRecord } from './grid-types'; + +export class TreeGridFilteringStrategy extends BaseFilteringStrategy { + + constructor(public hierarchicalFilterFields?: string[]) { + super(); + } + + public filter(data: ITreeGridRecord[], expressionsTree: IFilteringExpressionsTree, + advancedExpressionsTree?: IFilteringExpressionsTree, grid?: GridTypeBase): ITreeGridRecord[] { + return this.filterImpl(data, expressionsTree, advancedExpressionsTree, undefined, grid); + } + + protected getFieldValue(rec: any, fieldName: string, isDate = false, isTime = false, grid?: GridTypeBase): any { + const column = grid?.getColumnByName(fieldName); + const hierarchicalRecord = rec as ITreeGridRecord; + let value = this.isHierarchicalFilterField(fieldName) ? + this.getHierarchicalFieldValue(hierarchicalRecord, fieldName) : + resolveNestedPath(hierarchicalRecord.data, columnFieldPath(fieldName)); + + value = column?.formatter && this.shouldFormatFilterValues(column) ? + column.formatter(value, rec.data) : + value && (isDate || isTime) ? parseDate(value) : value; + + return value; + } + + private getHierarchicalFieldValue(record: ITreeGridRecord, field: string) { + const value = resolveNestedPath(record.data, columnFieldPath(field)); + + return record.parent ? + `${this.getHierarchicalFieldValue(record.parent, field)}${value ? `.[${value}]` : ''}` : + `[${value}]`; + } + + private filterImpl(data: ITreeGridRecord[], expressionsTree: IFilteringExpressionsTree, + advancedExpressionsTree: IFilteringExpressionsTree, parent: ITreeGridRecord, grid?: GridTypeBase): ITreeGridRecord[] { + let i: number; + let rec: ITreeGridRecord; + const len = data.length; + const res: ITreeGridRecord[] = []; + if ((FilteringExpressionsTree.empty(expressionsTree) && FilteringExpressionsTree.empty(advancedExpressionsTree)) || !len) { + return data; + } + for (i = 0; i < len; i++) { + rec = DataUtil.cloneTreeGridRecord(data[i]); + rec.parent = parent; + if (rec.children) { + const filteredChildren = this.filterImpl(rec.children, expressionsTree, advancedExpressionsTree, rec, grid); + rec.children = filteredChildren.length > 0 ? filteredChildren : null; + } + + if (this.matchRecord(rec, expressionsTree, grid) && this.matchRecord(rec, advancedExpressionsTree, grid)) { + res.push(rec); + } else if (rec.children && rec.children.length > 0) { + rec.isFilteredOutParent = true; + res.push(rec); + } + } + return res; + } + + private isHierarchicalFilterField(field: string) { + return this.hierarchicalFilterFields && this.hierarchicalFilterFields.indexOf(field) !== -1; + } + + public override getFilterItems(column: ColumnType, tree: IFilteringExpressionsTree): Promise { + if (!this.isHierarchicalFilterField(column.field)) { + return super.getFilterItems(column, tree); + } + + let data = (column.grid.gridAPI as IgxTreeGridAPIService).filterTreeDataByExpressions(tree); + data = DataUtil.treeGridSort( + data, + [{ fieldName: column.field, dir: SortingDirection.Asc, ignoreCase: column.sortingIgnoreCase }], + column.grid.sortStrategy, + column.grid); + + const items = this.getHierarchicalFilterItems(data, column); + + + return Promise.resolve(items); + } + + protected override getFilteredData(column: ColumnType, tree: IFilteringExpressionsTree) { + return DataUtil.filterDataByExpressions(column.grid.flatData, tree, column.grid); + } + + private getHierarchicalFilterItems(records: ITreeGridRecord[], column: ColumnType, parent?: IgxFilterItem): IgxFilterItem[] { + const pathParts = columnFieldPath(column.field); + return records?.map(record => { + let value = resolveNestedPath(record.data, pathParts); + const applyFormatter = column.formatter && this.shouldFormatFilterValues(column); + + value = applyFormatter ? + column.formatter(value, record.data) : + value; + + const hierarchicalValue = parent ? + (value || value === 0) ? `${parent.value}.[${value}]` : value : + `[${value}]`; + + const filterItem: IgxFilterItem = { value: hierarchicalValue }; + filterItem.label = this.getFilterItemLabel(column, value, !applyFormatter, record.data); + filterItem.children = this.getHierarchicalFilterItems(record.children, column, filterItem); + return filterItem; + }); + } +} + +export class TreeGridFormattedValuesFilteringStrategy extends TreeGridFilteringStrategy { + /** + * Creates a new instance of FormattedValuesFilteringStrategy. + * + * @param fields An array of column field names that should be formatted. + * If omitted the values of all columns which has formatter will be formatted. + */ + constructor(private fields?: string[]) { + super(); + } + + protected override shouldFormatFilterValues(column: ColumnType): boolean { + return !this.fields || this.fields.length === 0 || this.fields.some(f => f === column.field); + } +} + +export class TreeGridMatchingRecordsOnlyFilteringStrategy extends TreeGridFilteringStrategy { + public override filter(data: ITreeGridRecord[], expressionsTree: IFilteringExpressionsTree, + advancedExpressionsTree?: IFilteringExpressionsTree, grid?: GridTypeBase): ITreeGridRecord[] { + return this.filterImplementation(data, expressionsTree, advancedExpressionsTree, undefined, grid); + } + + private filterImplementation(data: ITreeGridRecord[], expressionsTree: IFilteringExpressionsTree, + advancedExpressionsTree: IFilteringExpressionsTree, parent: ITreeGridRecord, grid?: GridTypeBase): ITreeGridRecord[] { + let i: number; + let rec: ITreeGridRecord; + const len = data.length; + const res: ITreeGridRecord[] = []; + if ((FilteringExpressionsTree.empty(expressionsTree) && FilteringExpressionsTree.empty(advancedExpressionsTree)) || !len) { + return data; + } + for (i = 0; i < len; i++) { + rec = DataUtil.cloneTreeGridRecord(data[i]); + rec.parent = parent; + if (rec.children) { + const filteredChildren = this.filterImplementation(rec.children, expressionsTree, advancedExpressionsTree, rec, grid); + rec.children = filteredChildren.length > 0 ? filteredChildren : null; + } + if (this.matchRecord(rec, expressionsTree, grid) && this.matchRecord(rec, advancedExpressionsTree, grid)) { + res.push(rec); + } else if (rec.children && rec.children.length > 0) { + rec = this.setCorrectLevelToFilteredRecords(rec); + res.push(...rec.children); + } + } + return res; + } + + private setCorrectLevelToFilteredRecords(rec: ITreeGridRecord): ITreeGridRecord { + if (rec.children && rec.children.length > 0) { + rec.children.map(child => { + child.level = child.level - 1; + return this.setCorrectLevelToFilteredRecords(child); + }); + } + return rec; + } +} diff --git a/projects/igniteui-angular/core/src/date-common/date-parts.ts b/projects/igniteui-angular/core/src/date-common/date-parts.ts new file mode 100644 index 00000000000..9fe3c6a6408 --- /dev/null +++ b/projects/igniteui-angular/core/src/date-common/date-parts.ts @@ -0,0 +1,33 @@ +/** + * Specify a particular date, time or AmPm part. + */ +export enum DatePart { + Date = 'date', + Month = 'month', + Year = 'year', + Hours = 'hours', + Minutes = 'minutes', + Seconds = 'seconds', + FractionalSeconds = 'fractionalSeconds', + AmPm = 'ampm', + Literal = 'literal' +} + +/** @hidden @internal */ +export interface DatePartInfo { + type: DatePart; + start: number; + end: number; + format: string; +} + +/** Delta values used for spin actions. */ +export interface DatePartDeltas { + date?: number; + month?: number; + year?: number; + hours?: number; + minutes?: number; + seconds?: number; + fractionalSeconds?: number; +} diff --git a/projects/igniteui-angular/core/src/date-common/picker-icons.common.ts b/projects/igniteui-angular/core/src/date-common/picker-icons.common.ts new file mode 100644 index 00000000000..c3b3b0f6e80 --- /dev/null +++ b/projects/igniteui-angular/core/src/date-common/picker-icons.common.ts @@ -0,0 +1,68 @@ +import { Component, Output, EventEmitter, HostListener, Directive, TemplateRef, inject } from '@angular/core'; + +/** + * Templates the default toggle icon in the picker. + * + * @remarks Can be applied to IgxDatePickerComponent, IgxTimePickerComponent, IgxDateRangePickerComponent + * + * @example + * ```html + * + * + * calendar_view_day + * + * + * ``` + */ +@Component({ + template: ``, + selector: 'igx-picker-toggle', + standalone: true +}) +export class IgxPickerToggleComponent { + @Output() + public clicked = new EventEmitter(); + + @HostListener('click', ['$event']) + public onClick(event: MouseEvent) { + // do not focus input on click + event.stopPropagation(); + this.clicked.emit(); + } +} + +/** + * Templates the default clear icon in the picker. + * + * @remarks Can be applied to IgxDatePickerComponent, IgxTimePickerComponent, IgxDateRangePickerComponent + * + * @example + * ```html + * + * + * delete + * + * + * ``` + */ +@Component({ + template: ``, + selector: 'igx-picker-clear', + standalone: true +}) +export class IgxPickerClearComponent extends IgxPickerToggleComponent { } + +/** + * IgxPickerActionsDirective can be used to re-template the dropdown/dialog action buttons. + * + * @remarks Can be applied to IgxDatePickerComponent, IgxTimePickerComponent, IgxDateRangePickerComponent + * + */ +@Directive({ + selector: '[igxPickerActions]', + standalone: true +}) +export class IgxPickerActionsDirective { + public template = inject>(TemplateRef); +} + diff --git a/projects/igniteui-angular/core/src/date-common/public_api.ts b/projects/igniteui-angular/core/src/date-common/public_api.ts new file mode 100644 index 00000000000..83e03243462 --- /dev/null +++ b/projects/igniteui-angular/core/src/date-common/public_api.ts @@ -0,0 +1,6 @@ +export * from './picker-icons.common'; +export * from './types'; +export * from './date-parts'; +export * from './util/date-time.util'; +export * from './util/helpers'; +export * from './util/model'; diff --git a/projects/igniteui-angular/core/src/date-common/types.ts b/projects/igniteui-angular/core/src/date-common/types.ts new file mode 100644 index 00000000000..b22986d7208 --- /dev/null +++ b/projects/igniteui-angular/core/src/date-common/types.ts @@ -0,0 +1,48 @@ +/** Header orientation in `dialog` mode. */ +export const PickerHeaderOrientation = { + Horizontal: 'horizontal', + Vertical: 'vertical' +} as const; +export type PickerHeaderOrientation = (typeof PickerHeaderOrientation)[keyof typeof PickerHeaderOrientation]; + +/** Calendar orientation. */ +export const PickerCalendarOrientation = { + Horizontal: 'horizontal', + Vertical: 'vertical' +} as const; +export type PickerCalendarOrientation = (typeof PickerCalendarOrientation)[keyof typeof PickerCalendarOrientation]; + +/** + * This enumeration is used to configure whether the date/time picker has an editable input with drop down + * or is readonly - the date/time is selected only through a dialog. + */ +export const PickerInteractionMode = { + DropDown: 'dropdown', + Dialog: 'dialog' +} as const; +export type PickerInteractionMode = (typeof PickerInteractionMode)[keyof typeof PickerInteractionMode]; + +export type WeekDays = + | 'sunday' + | 'monday' + | 'tuesday' + | 'wednesday' + | 'thursday' + | 'friday' + | 'saturday'; + +export interface IgcCalendarBaseEventMap { + igcChange: CustomEvent; +} + +/** Represents a range between two dates. */ +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; +} diff --git a/projects/igniteui-angular/core/src/date-common/util/date-time.util.spec.ts b/projects/igniteui-angular/core/src/date-common/util/date-time.util.spec.ts new file mode 100644 index 00000000000..c412ec9ac56 --- /dev/null +++ b/projects/igniteui-angular/core/src/date-common/util/date-time.util.spec.ts @@ -0,0 +1,712 @@ +import { DateTimeUtil } from './date-time.util'; +import { GridColumnDataType } from '../../data-operations/grid-types'; +import { registerLocaleData } from '@angular/common'; +import localeBg from "@angular/common/locales/bg"; +import { DatePart, DatePartInfo } from '../date-parts'; + +const reduceToDictionary = (parts: DatePartInfo[]) => parts.reduce((obj, x) => { + obj[x.type] = x; + return obj; +}, {}); + +describe(`DateTimeUtil Unit tests`, () => { + registerLocaleData(localeBg); + describe('Date Time Parsing', () => { + it('should correctly parse all date time parts (base)', () => { + let result = DateTimeUtil.parseDateTimeFormat('dd/MM/yyyy HH:mm:ss:SS a'); + const expected = [ + { start: 0, end: 2, type: DatePart.Date, format: 'dd' }, + { start: 2, end: 3, type: DatePart.Literal, format: '/' }, + { start: 3, end: 5, type: DatePart.Month, format: 'MM' }, + { start: 5, end: 6, type: DatePart.Literal, format: '/' }, + { start: 6, end: 10, type: DatePart.Year, format: 'yyyy' }, + { start: 10, end: 11, type: DatePart.Literal, format: ' ' }, + { start: 11, end: 13, type: DatePart.Hours, format: 'HH' }, + { start: 13, end: 14, type: DatePart.Literal, format: ':' }, + { start: 14, end: 16, type: DatePart.Minutes, format: 'mm' }, + { start: 16, end: 17, type: DatePart.Literal, format: ':' }, + { start: 17, end: 19, type: DatePart.Seconds, format: 'ss' }, + { start: 19, end: 20, type: DatePart.Literal, format: ':' }, + { start: 20, end: 23, type: DatePart.FractionalSeconds, format: 'SSS' }, + { start: 23, end: 24, type: DatePart.Literal, format: ' ' }, + { start: 24, end: 26, type: DatePart.AmPm, format: 'aa' } + ]; + expect(JSON.stringify(result)).toEqual(JSON.stringify(expected)); + + result = DateTimeUtil.parseDateTimeFormat('dd/MM/yyyy HH:mm:ss:SS tt'); + expected[expected.length - 1] = { start: 24, end: 26, type: DatePart.AmPm, format: 'tt' } + expect(JSON.stringify(result)).toEqual(JSON.stringify(expected)); + }); + + it('should correctly parse date parts of with short formats', () => { + let result = DateTimeUtil.parseDateTimeFormat('MM/dd/yyyy'); + let resDict = reduceToDictionary(result); + expect(result.length).toEqual(5); + expect(resDict[DatePart.Month]).toEqual(jasmine.objectContaining({ start: 0, end: 2 })); + expect(resDict[DatePart.Date]).toEqual(jasmine.objectContaining({ start: 3, end: 5 })); + expect(resDict[DatePart.Year]).toEqual(jasmine.objectContaining({ start: 6, end: 10 })); + + // M/d/yy should be 00/00/00 + result = DateTimeUtil.parseDateTimeFormat('M/d/yy'); + resDict = reduceToDictionary(result); + expect(result.length).toEqual(5); + expect(resDict[DatePart.Month]).toEqual(jasmine.objectContaining({ start: 0, end: 2 })); + expect(resDict[DatePart.Date]).toEqual(jasmine.objectContaining({ start: 3, end: 5 })); + expect(resDict[DatePart.Year]).toEqual(jasmine.objectContaining({ start: 6, end: 8 })); + + // d/M/y should be 00/00/0000 + result = DateTimeUtil.parseDateTimeFormat('d/M/y'); + resDict = reduceToDictionary(result); + expect(result.length).toEqual(5); + expect(resDict[DatePart.Date]).toEqual(jasmine.objectContaining({ start: 0, end: 2 })); + expect(resDict[DatePart.Month]).toEqual(jasmine.objectContaining({ start: 3, end: 5 })); + expect(resDict[DatePart.Year]).toEqual(jasmine.objectContaining({ start: 6, end: 10 })); + + // d/M/yyy should be 00/00/0000 + result = DateTimeUtil.parseDateTimeFormat('d/M/yyy'); + resDict = reduceToDictionary(result); + expect(result.length).toEqual(5); + expect(resDict[DatePart.Date]).toEqual(jasmine.objectContaining({ start: 0, end: 2 })); + expect(resDict[DatePart.Month]).toEqual(jasmine.objectContaining({ start: 3, end: 5 })); + expect(resDict[DatePart.Year]).toEqual(jasmine.objectContaining({ start: 6, end: 10 })); + + + // d/M/yyyy should 00/00/0000 + result = DateTimeUtil.parseDateTimeFormat('d/M/yyyy'); + resDict = reduceToDictionary(result); + expect(result.length).toEqual(5); + expect(resDict[DatePart.Date]).toEqual(jasmine.objectContaining({ start: 0, end: 2 })); + expect(resDict[DatePart.Month]).toEqual(jasmine.objectContaining({ start: 3, end: 5 })); + expect(resDict[DatePart.Year]).toEqual(jasmine.objectContaining({ start: 6, end: 10 })); + + + // H:m:s should be 00:00:00 + result = DateTimeUtil.parseDateTimeFormat('H:m:s'); + resDict = reduceToDictionary(result); + expect(result.length).toEqual(5); + expect(resDict[DatePart.Hours]).toEqual(jasmine.objectContaining({ start: 0, end: 2 })); + expect(resDict[DatePart.Minutes]).toEqual(jasmine.objectContaining({ start: 3, end: 5 })); + expect(resDict[DatePart.Seconds]).toEqual(jasmine.objectContaining({ start: 6, end: 8 })); + + result = DateTimeUtil.parseDateTimeFormat('dd.MM.yyyy г.'); + resDict = reduceToDictionary(result); + expect(result.length).toEqual(6); + expect(resDict[DatePart.Date]).toEqual(jasmine.objectContaining({ start: 0, end: 2 })); + expect(resDict[DatePart.Month]).toEqual(jasmine.objectContaining({ start: 3, end: 5 })); + expect(resDict[DatePart.Year]).toEqual(jasmine.objectContaining({ start: 6, end: 10 })); + + // TODO + // result = DateTimeUtil.parseDateTimeFormat('dd.MM.yyyyг'); + // resDict = reduceToDictionary(result); + // expect(result.length).toEqual(6); + // expect(resDict[DatePart.Date]).toEqual(jasmine.objectContaining({ start: 0, end: 2 })); + // expect(resDict[DatePart.Month]).toEqual(jasmine.objectContaining({ start: 3, end: 5 })); + // expect(resDict[DatePart.Year]).toEqual(jasmine.objectContaining({ start: 6, end: 10 })); + // expect(result[5]?.format).toEqual('г'); + + // result = DateTimeUtil.parseDateTimeFormat('yyyy/MM/d'); + // resDict = reduceToDictionary(result); + // expect(result.length).toEqual(5); + // expect(resDict[DatePart.Year]).toEqual(jasmine.objectContaining({ start: 0, end: 4 })); + // expect(resDict[DatePart.Month]).toEqual(jasmine.objectContaining({ start: 5, end: 7 })); + // expect(resDict[DatePart.Date]).toEqual(jasmine.objectContaining({ start: 8, end: 10 })); + }); + + it('should correctly parse boundary dates', () => { + const parts = DateTimeUtil.parseDateTimeFormat('MM/dd/yyyy'); + let result = DateTimeUtil.parseValueFromMask('08/31/2020', parts); + expect(result).toEqual(new Date(2020, 7, 31)); + result = DateTimeUtil.parseValueFromMask('09/30/2020', parts); + expect(result).toEqual(new Date(2020, 8, 30)); + result = DateTimeUtil.parseValueFromMask('10/31/2020', parts); + expect(result).toEqual(new Date(2020, 9, 31)); + }); + + it('should correctly parse values in h:m:s a, aa,.. or h:m:s tt format', () => { + const verifyTime = (val: Date, hours = 0, minutes = 0, seconds = 0, milliseconds = 0) => { + expect(val.getHours()).toEqual(hours); + expect(val.getMinutes()).toEqual(minutes); + expect(val.getSeconds()).toEqual(seconds); + expect(val.getMilliseconds()).toEqual(milliseconds); + }; + + const runTestsForParts = (parts: DatePartInfo[]) => { + let result = DateTimeUtil.parseValueFromMask('11:34:12 AM', parts); + verifyTime(result, 11, 34, 12); + result = DateTimeUtil.parseValueFromMask('04:12:15 PM', parts); + verifyTime(result, 16, 12, 15); + result = DateTimeUtil.parseValueFromMask('11:00:00 AM', parts); + verifyTime(result, 11, 0, 0); + result = DateTimeUtil.parseValueFromMask('10:00:00 PM', parts); + verifyTime(result, 22, 0, 0); + result = DateTimeUtil.parseValueFromMask('12:00:00 PM', parts); + verifyTime(result, 12, 0, 0); + result = DateTimeUtil.parseValueFromMask('12:00:00 AM', parts); + verifyTime(result, 0, 0, 0); + } + + const inputFormat = 'h:m:s'; + let parts = DateTimeUtil.parseDateTimeFormat(`${inputFormat} tt`); + runTestsForParts(parts); + + for (let i = 0; i < 5; i++) { + parts = DateTimeUtil.parseDateTimeFormat(`${inputFormat} ${'a'.repeat(i + 1)}`); + runTestsForParts(parts); + } + }); + }); + + it('should correctly parse a date value from input', () => { + let input = '12/04/2012'; + let dateParts = [ + { start: 0, end: 2, type: DatePart.Date, format: 'dd' }, + { start: 2, end: 3, type: DatePart.Literal, format: '/' }, + { start: 3, end: 5, type: DatePart.Month, format: 'MM' }, + { start: 5, end: 6, type: DatePart.Literal, format: '/' }, + { start: 6, end: 10, type: DatePart.Year, format: 'yyyy' }, + { start: 10, end: 11, type: DatePart.Literal, format: ' ' } + ]; + + let expected = new Date(2012, 3, 12); + let result = DateTimeUtil.parseValueFromMask(input, dateParts); + expect(result.getTime()).toEqual(expected.getTime()); + + input = '04:12:23 PM'; + dateParts = [ + { start: 0, end: 2, type: DatePart.Hours, format: 'hh' }, + { start: 2, end: 3, type: DatePart.Literal, format: ':' }, + { start: 3, end: 5, type: DatePart.Minutes, format: 'mm' }, + { start: 5, end: 6, type: DatePart.Literal, format: ':' }, + { start: 6, end: 8, type: DatePart.Seconds, format: 'ss' }, + { start: 8, end: 9, type: DatePart.Literal, format: ' ' }, + { start: 9, end: 11, type: DatePart.AmPm, format: 'a' } + ]; + + result = DateTimeUtil.parseValueFromMask(input, dateParts); + expect(result.getHours()).toEqual(16); + expect(result.getMinutes()).toEqual(12); + expect(result.getSeconds()).toEqual(23); + + input = '12/10/2012 14:06:03'; + dateParts = [ + { start: 0, end: 2, type: DatePart.Date, format: 'dd' }, + { start: 2, end: 3, type: DatePart.Literal, format: '/' }, + { start: 3, end: 5, type: DatePart.Month, format: 'MM' }, + { start: 5, end: 6, type: DatePart.Literal, format: '/' }, + { start: 6, end: 10, type: DatePart.Year, format: 'yyyy' }, + { start: 10, end: 11, type: DatePart.Literal, format: ' ' }, + { start: 11, end: 13, type: DatePart.Hours, format: 'HH' }, + { start: 13, end: 14, type: DatePart.Literal, format: ':' }, + { start: 14, end: 16, type: DatePart.Minutes, format: 'mm' }, + { start: 16, end: 17, type: DatePart.Literal, format: ':' }, + { start: 17, end: 19, type: DatePart.Seconds, format: 'ss' } + ]; + + expected = new Date(2012, 9, 12, 14, 6, 3); + result = DateTimeUtil.parseValueFromMask(input, dateParts); + + expect(result.getDate()).toEqual(12); + expect(result.getMonth()).toEqual(9); + expect(result.getFullYear()).toEqual(2012); + expect(result.getHours()).toEqual(14); + expect(result.getMinutes()).toEqual(6); + expect(result.getSeconds()).toEqual(3); + }); + + it('should properly build input formats based on locale', () => { + spyOn(DateTimeUtil, 'getDefaultInputFormat').and.callThrough(); + let result = DateTimeUtil.getDefaultInputFormat('en-US'); + expect(result).toEqual('MM/dd/yyyy'); + + result = DateTimeUtil.getDefaultInputFormat('bg-BG'); + expect(result.normalize('NFKC')).toEqual('dd.MM.yyyy г.'); + + expect(() => { + result = DateTimeUtil.getDefaultInputFormat(null); + }).not.toThrow(); + expect(result).toEqual('MM/dd/yyyy'); + + expect(() => { + result = DateTimeUtil.getDefaultInputFormat(''); + }).not.toThrow(); + expect(result).toEqual('MM/dd/yyyy'); + + expect(() => { + result = DateTimeUtil.getDefaultInputFormat(undefined); + }).not.toThrow(); + expect(result).toEqual('MM/dd/yyyy'); + }); + + it('should properly build input formats based on locale for dateTime data type ', () => { + let result = DateTimeUtil.getDefaultInputFormat('en-US', GridColumnDataType.DateTime); + expect(result.normalize('NFKC')).toEqual('MM/dd/yyyy, hh:mm:ss tt'); + + result = DateTimeUtil.getDefaultInputFormat('bg-BG', GridColumnDataType.DateTime); + expect(result.normalize('NFKC')).toEqual('dd.MM.yyyy г., HH:mm:ss'); + + result = DateTimeUtil.getDefaultInputFormat('fr-FR', GridColumnDataType.DateTime); + expect(result).toEqual('dd/MM/yyyy HH:mm:ss'); + }); + + it('should properly build input formats based on locale for time data type ', () => { + let result = DateTimeUtil.getDefaultInputFormat('en-US', GridColumnDataType.Time); + expect(result.normalize('NFKC')).toEqual('hh:mm tt'); + + result = DateTimeUtil.getDefaultInputFormat('bg-BG', GridColumnDataType.Time); + expect(result.normalize('NFKC')).toEqual('HH:mm'); + + result = DateTimeUtil.getDefaultInputFormat('fr-FR', GridColumnDataType.Time); + expect(result).toEqual('HH:mm'); + }); + + it('should correctly distinguish date from time characters', () => { + expect(DateTimeUtil.isDateOrTimeChar('d')).toBeTrue(); + expect(DateTimeUtil.isDateOrTimeChar('M')).toBeTrue(); + expect(DateTimeUtil.isDateOrTimeChar('y')).toBeTrue(); + expect(DateTimeUtil.isDateOrTimeChar('H')).toBeTrue(); + expect(DateTimeUtil.isDateOrTimeChar('h')).toBeTrue(); + expect(DateTimeUtil.isDateOrTimeChar('m')).toBeTrue(); + expect(DateTimeUtil.isDateOrTimeChar('s')).toBeTrue(); + expect(DateTimeUtil.isDateOrTimeChar('S')).toBeTrue(); + expect(DateTimeUtil.isDateOrTimeChar(':')).toBeFalse(); + expect(DateTimeUtil.isDateOrTimeChar('/')).toBeFalse(); + expect(DateTimeUtil.isDateOrTimeChar('.')).toBeFalse(); + }); + + it('should spin date portions correctly', () => { + // base + let date = new Date(2015, 4, 20); + DateTimeUtil.spinDate(1, date, false); + expect(date.getTime()).toEqual(new Date(2015, 4, 21).getTime()); + DateTimeUtil.spinDate(-1, date, false); + expect(date.getTime()).toEqual(new Date(2015, 4, 20).getTime()); + + // delta !== 1 + DateTimeUtil.spinDate(5, date, false); + expect(date.getTime()).toEqual(new Date(2015, 4, 25).getTime()); + DateTimeUtil.spinDate(-6, date, false); + expect(date.getTime()).toEqual(new Date(2015, 4, 19).getTime()); + + // without looping over + date = new Date(2015, 4, 31); + DateTimeUtil.spinDate(1, date, false); + expect(date.getTime()).toEqual(new Date(2015, 4, 31).getTime()); + DateTimeUtil.spinDate(-50, date, false); + expect(date.getTime()).toEqual(new Date(2015, 4, 1).getTime()); + + // with looping over + DateTimeUtil.spinDate(31, date, true); + expect(date.getTime()).toEqual(new Date(2015, 4, 1).getTime()); + DateTimeUtil.spinDate(-5, date, true); + expect(date.getTime()).toEqual(new Date(2015, 4, 27).getTime()); + }); + + it('should spin month portions correctly', () => { + // base + let date = new Date(2015, 4, 20); + DateTimeUtil.spinMonth(1, date, false); + expect(date.getTime()).toEqual(new Date(2015, 5, 20).getTime()); + DateTimeUtil.spinMonth(-1, date, false); + expect(date.getTime()).toEqual(new Date(2015, 4, 20).getTime()); + + // delta !== 1 + DateTimeUtil.spinMonth(5, date, false); + expect(date.getTime()).toEqual(new Date(2015, 9, 20).getTime()); + DateTimeUtil.spinMonth(-6, date, false); + expect(date.getTime()).toEqual(new Date(2015, 3, 20).getTime()); + + // without looping over + date = new Date(2015, 11, 31); + DateTimeUtil.spinMonth(1, date, false); + expect(date.getTime()).toEqual(new Date(2015, 11, 31).getTime()); + DateTimeUtil.spinMonth(-50, date, false); + expect(date.getTime()).toEqual(new Date(2015, 0, 31).getTime()); + + // with looping over + date = new Date(2015, 11, 1); + DateTimeUtil.spinMonth(2, date, true); + expect(date.getTime()).toEqual(new Date(2015, 1, 1).getTime()); + date = new Date(2015, 0, 1); + DateTimeUtil.spinMonth(-1, date, true); + expect(date.getTime()).toEqual(new Date(2015, 11, 1).getTime()); + + // coerces date portion to be no greater than max date of current month + date = new Date(2020, 2, 31); + DateTimeUtil.spinMonth(-1, date, false); + expect(date.getTime()).toEqual(new Date(2020, 1, 29).getTime()); + DateTimeUtil.spinMonth(1, date, false); + expect(date.getTime()).toEqual(new Date(2020, 2, 29).getTime()); + date = new Date(2020, 4, 31); + DateTimeUtil.spinMonth(1, date, false); + expect(date.getTime()).toEqual(new Date(2020, 5, 30).getTime()); + }); + + it('should spin year portions correctly', () => { + // base + let date = new Date(2015, 4, 20); + DateTimeUtil.spinYear(1, date); + expect(date.getTime()).toEqual(new Date(2016, 4, 20).getTime()); + DateTimeUtil.spinYear(-1, date); + expect(date.getTime()).toEqual(new Date(2015, 4, 20).getTime()); + + // delta !== 1 + DateTimeUtil.spinYear(5, date); + expect(date.getTime()).toEqual(new Date(2020, 4, 20).getTime()); + DateTimeUtil.spinYear(-6, date); + expect(date.getTime()).toEqual(new Date(2014, 4, 20).getTime()); + + // coerces February to be 29 days on a leap year and 28 on a non leap year + date = new Date(2020, 1, 29); + DateTimeUtil.spinYear(1, date); + expect(date.getTime()).toEqual(new Date(2021, 1, 28).getTime()); + DateTimeUtil.spinYear(-1, date); + expect(date.getTime()).toEqual(new Date(2020, 1, 28).getTime()); + }); + + it('should spin hours portion correctly', () => { + // base + let date = new Date(2015, 4, 20, 6); + DateTimeUtil.spinHours(1, date, false); + expect(date.getTime()).toEqual(new Date(2015, 4, 20, 7).getTime()); + DateTimeUtil.spinHours(-1, date, false); + expect(date.getTime()).toEqual(new Date(2015, 4, 20, 6).getTime()); + + // delta !== 1 + DateTimeUtil.spinHours(5, date, false); + expect(date.getTime()).toEqual(new Date(2015, 4, 20, 11).getTime()); + DateTimeUtil.spinHours(-6, date, false); + expect(date.getTime()).toEqual(new Date(2015, 4, 20, 5).getTime()); + + // without looping over + date = new Date(2015, 4, 20, 23); + DateTimeUtil.spinHours(1, date, false); + expect(date.getTime()).toEqual(new Date(2015, 4, 20, 23).getTime()); + DateTimeUtil.spinHours(-30, date, false); + expect(date.getTime()).toEqual(new Date(2015, 4, 20, 0).getTime()); + + // with looping over (date is not affected) + DateTimeUtil.spinHours(25, date, true); + expect(date.getTime()).toEqual(new Date(2015, 4, 20, 1).getTime()); + DateTimeUtil.spinHours(-2, date, true); + expect(date.getTime()).toEqual(new Date(2015, 4, 20, 23).getTime()); + }); + + it('should spin minutes portion correctly', () => { + // base + let date = new Date(2015, 4, 20, 6, 10); + DateTimeUtil.spinMinutes(1, date, false); + expect(date.getTime()).toEqual(new Date(2015, 4, 20, 6, 11).getTime()); + DateTimeUtil.spinMinutes(-1, date, false); + expect(date.getTime()).toEqual(new Date(2015, 4, 20, 6, 10).getTime()); + + // delta !== 1 + DateTimeUtil.spinMinutes(5, date, false); + expect(date.getTime()).toEqual(new Date(2015, 4, 20, 6, 15).getTime()); + DateTimeUtil.spinMinutes(-6, date, false); + expect(date.getTime()).toEqual(new Date(2015, 4, 20, 6, 9).getTime()); + + // without looping over + date = new Date(2015, 4, 20, 12, 59); + DateTimeUtil.spinMinutes(1, date, false); + expect(date.getTime()).toEqual(new Date(2015, 4, 20, 12, 59).getTime()); + DateTimeUtil.spinMinutes(-70, date, false); + expect(date.getTime()).toEqual(new Date(2015, 4, 20, 12, 0).getTime()); + + // with looping over (hours are not affected) + DateTimeUtil.spinMinutes(61, date, true); + expect(date.getTime()).toEqual(new Date(2015, 4, 20, 12, 1).getTime()); + DateTimeUtil.spinMinutes(-5, date, true); + expect(date.getTime()).toEqual(new Date(2015, 4, 20, 12, 56).getTime()); + }); + + it('should spin seconds portion correctly', () => { + // base + let date = new Date(2015, 4, 20, 6, 10, 5); + DateTimeUtil.spinSeconds(1, date, false); + expect(date.getTime()).toEqual(new Date(2015, 4, 20, 6, 10, 6).getTime()); + DateTimeUtil.spinSeconds(-1, date, false); + expect(date.getTime()).toEqual(new Date(2015, 4, 20, 6, 10, 5).getTime()); + + // delta !== 1 + DateTimeUtil.spinSeconds(5, date, false); + expect(date.getTime()).toEqual(new Date(2015, 4, 20, 6, 10, 10).getTime()); + DateTimeUtil.spinSeconds(-6, date, false); + expect(date.getTime()).toEqual(new Date(2015, 4, 20, 6, 10, 4).getTime()); + + // without looping over + date = new Date(2015, 4, 20, 12, 59, 59); + DateTimeUtil.spinSeconds(1, date, false); + expect(date.getTime()).toEqual(new Date(2015, 4, 20, 12, 59, 59).getTime()); + DateTimeUtil.spinSeconds(-70, date, false); + expect(date.getTime()).toEqual(new Date(2015, 4, 20, 12, 59, 0).getTime()); + + // with looping over (minutes are not affected) + DateTimeUtil.spinSeconds(62, date, true); + expect(date.getTime()).toEqual(new Date(2015, 4, 20, 12, 59, 2).getTime()); + DateTimeUtil.spinSeconds(-5, date, true); + expect(date.getTime()).toEqual(new Date(2015, 4, 20, 12, 59, 57).getTime()); + }); + + it('should spin fractional seconds portion correctly', () => { + // base + let date = new Date(2024, 3, 10, 6, 10, 5, 555); + DateTimeUtil.spinFractionalSeconds(1, date, false); + expect(date.getTime()).toEqual(new Date(2024, 3, 10, 6, 10, 5, 556).getTime()); + DateTimeUtil.spinFractionalSeconds(-1, date, false); + expect(date.getTime()).toEqual(new Date(2024, 3, 10, 6, 10, 5, 555).getTime()); + + // delta !== 1 + DateTimeUtil.spinFractionalSeconds(5, date, false); + expect(date.getTime()).toEqual(new Date(2024, 3, 10, 6, 10, 5, 560).getTime()); + DateTimeUtil.spinFractionalSeconds(-6, date, false); + expect(date.getTime()).toEqual(new Date(2024, 3, 10, 6, 10, 5, 554).getTime()); + + // without looping over + date = new Date(2024, 3, 10, 6, 10, 5, 999); + DateTimeUtil.spinFractionalSeconds(1, date, false); + expect(date.getTime()).toEqual(new Date(2024, 3, 10, 6, 10, 5, 999).getTime()); + DateTimeUtil.spinFractionalSeconds(-1000, date, false); + expect(date.getTime()).toEqual(new Date(2024, 3, 10, 6, 10, 5, 0).getTime()); + + // with looping over (seconds are not affected) + DateTimeUtil.spinFractionalSeconds(1001, date, true); + expect(date.getTime()).toEqual(new Date(2024, 3, 10, 6, 10, 5, 1).getTime()); + DateTimeUtil.spinFractionalSeconds(-5, date, true); + expect(date.getTime()).toEqual(new Date(2024, 3, 10, 6, 10, 5, 996).getTime()); + }); + + it('should spin AM/PM and a/p portion correctly', () => { + const currentDate = new Date(2015, 4, 31, 4, 59, 59); + const newDate = new Date(2015, 4, 31, 4, 59, 59); + // spin from AM to PM + DateTimeUtil.spinAmPm(currentDate, newDate, 'PM'); + expect(currentDate.getHours()).toEqual(16); + + // spin from PM to AM + DateTimeUtil.spinAmPm(currentDate, newDate, 'AM'); + expect(currentDate.getHours()).toEqual(4); + + DateTimeUtil.spinAmPm(currentDate, newDate, 'p'); + expect(currentDate.getHours()).toEqual(16); + + DateTimeUtil.spinAmPm(currentDate, newDate, 'a'); + expect(currentDate.getHours()).toEqual(4); + }); + + it('should compare dates correctly', () => { + // base + let minValue = new Date(2010, 3, 2); + let maxValue = new Date(2010, 3, 7); + expect(DateTimeUtil.lessThanMinValue(new Date(2010, 3, 2), minValue)).toBeFalse(); + expect(DateTimeUtil.lessThanMinValue(new Date(2010, 3, 3), minValue)).toBeFalse(); + expect(DateTimeUtil.lessThanMinValue(new Date(2010, 3, 1), minValue)).toBeTrue(); + + expect(DateTimeUtil.greaterThanMaxValue(new Date(2010, 3, 7), maxValue)).toBeFalse(); + expect(DateTimeUtil.greaterThanMaxValue(new Date(2010, 3, 6), maxValue)).toBeFalse(); + expect(DateTimeUtil.greaterThanMaxValue(new Date(2010, 3, 8), maxValue)).toBeTrue(); + + // time variations + minValue = new Date(2010, 3, 2, 11, 10, 10); + maxValue = new Date(2010, 3, 2, 15, 15, 15); + expect(DateTimeUtil.lessThanMinValue(new Date(2010, 3, 2, 11, 10, 11), minValue)).toBeFalse(); + expect(DateTimeUtil.lessThanMinValue(new Date(2010, 3, 2, 11, 10, 10), minValue)).toBeFalse(); + expect(DateTimeUtil.lessThanMinValue(new Date(2010, 3, 2, 11, 10, 9), minValue)).toBeTrue(); + + expect(DateTimeUtil.lessThanMinValue(new Date(2010, 3, 2, 11, 11, 10), minValue)).toBeFalse(); + expect(DateTimeUtil.lessThanMinValue(new Date(2010, 3, 2, 11, 10, 10), minValue)).toBeFalse(); + expect(DateTimeUtil.lessThanMinValue(new Date(2010, 3, 2, 11, 9, 10), minValue)).toBeTrue(); + + expect(DateTimeUtil.lessThanMinValue(new Date(2010, 3, 2, 12, 10, 10), minValue)).toBeFalse(); + expect(DateTimeUtil.lessThanMinValue(new Date(2010, 3, 2, 11, 10, 10), minValue)).toBeFalse(); + expect(DateTimeUtil.lessThanMinValue(new Date(2010, 3, 2, 10, 10, 10), minValue)).toBeTrue(); + + expect(DateTimeUtil.lessThanMinValue(new Date(2010, 3, 3, 11, 10, 10), minValue)).toBeFalse(); + expect(DateTimeUtil.lessThanMinValue(new Date(2010, 3, 2, 11, 10, 10), minValue)).toBeFalse(); + expect(DateTimeUtil.lessThanMinValue(new Date(2010, 3, 1, 11, 10, 10), minValue)).toBeTrue(); + + expect(DateTimeUtil.lessThanMinValue(new Date(2010, 4, 2, 11, 10, 10), minValue)).toBeFalse(); + expect(DateTimeUtil.lessThanMinValue(new Date(2010, 3, 2, 11, 10, 10), minValue)).toBeFalse(); + expect(DateTimeUtil.lessThanMinValue(new Date(2010, 2, 2, 11, 10, 10), minValue)).toBeTrue(); + + expect(DateTimeUtil.lessThanMinValue(new Date(2011, 3, 2, 11, 10, 10), minValue)).toBeFalse(); + expect(DateTimeUtil.lessThanMinValue(new Date(2010, 3, 2, 11, 10, 10), minValue)).toBeFalse(); + expect(DateTimeUtil.lessThanMinValue(new Date(2009, 3, 2, 11, 10, 10), minValue)).toBeTrue(); + + expect(DateTimeUtil.greaterThanMaxValue(new Date(2010, 3, 2, 15, 15, 16), maxValue)).toBeTrue(); + expect(DateTimeUtil.greaterThanMaxValue(new Date(2010, 3, 2, 15, 15, 15), maxValue)).toBeFalse(); + expect(DateTimeUtil.greaterThanMaxValue(new Date(2010, 3, 2, 15, 15, 14), maxValue)).toBeFalse(); + + expect(DateTimeUtil.greaterThanMaxValue(new Date(2010, 3, 2, 15, 16, 15), maxValue)).toBeTrue(); + expect(DateTimeUtil.greaterThanMaxValue(new Date(2010, 3, 2, 15, 15, 15), maxValue)).toBeFalse(); + expect(DateTimeUtil.greaterThanMaxValue(new Date(2010, 3, 2, 15, 14, 15), maxValue)).toBeFalse(); + + expect(DateTimeUtil.greaterThanMaxValue(new Date(2010, 3, 2, 16, 15, 15), maxValue)).toBeTrue(); + expect(DateTimeUtil.greaterThanMaxValue(new Date(2010, 3, 2, 15, 15, 15), maxValue)).toBeFalse(); + expect(DateTimeUtil.greaterThanMaxValue(new Date(2010, 3, 2, 14, 15, 15), maxValue)).toBeFalse(); + + expect(DateTimeUtil.greaterThanMaxValue(new Date(2010, 3, 3, 15, 15, 15), maxValue)).toBeTrue(); + expect(DateTimeUtil.greaterThanMaxValue(new Date(2010, 3, 2, 15, 15, 15), maxValue)).toBeFalse(); + expect(DateTimeUtil.greaterThanMaxValue(new Date(2010, 3, 1, 15, 15, 15), maxValue)).toBeFalse(); + + expect(DateTimeUtil.greaterThanMaxValue(new Date(2010, 4, 2, 15, 15, 15), maxValue)).toBeTrue(); + expect(DateTimeUtil.greaterThanMaxValue(new Date(2010, 3, 2, 15, 15, 15), maxValue)).toBeFalse(); + expect(DateTimeUtil.greaterThanMaxValue(new Date(2010, 2, 2, 15, 15, 15), maxValue)).toBeFalse(); + + expect(DateTimeUtil.greaterThanMaxValue(new Date(2011, 3, 2, 15, 15, 15), maxValue)).toBeTrue(); + expect(DateTimeUtil.greaterThanMaxValue(new Date(2010, 3, 2, 15, 15, 15), maxValue)).toBeFalse(); + expect(DateTimeUtil.greaterThanMaxValue(new Date(2009, 3, 2, 15, 15, 15), maxValue)).toBeFalse(); + + // date excluded + expect(DateTimeUtil.lessThanMinValue(new Date(2030, 3, 2, 11, 10, 9), minValue, true, false)).toBeTrue(); + expect(DateTimeUtil.greaterThanMaxValue(new Date(2000, 3, 2, 15, 15, 16), minValue, true, false)).toBeTrue(); + + // time excluded + expect(DateTimeUtil.lessThanMinValue(new Date(2009, 3, 2, 11, 10, 10), minValue, false, true)).toBeTrue(); + expect(DateTimeUtil.greaterThanMaxValue(new Date(2011, 3, 2, 15, 15, 15), minValue, true, false)).toBeTrue(); + }); + + it('should return ValidationErrors for minValue and maxValue', () => { + let minValue = new Date(2010, 3, 2); + let maxValue = new Date(2010, 3, 7); + + expect(DateTimeUtil.validateMinMax(new Date(2010, 3, 4), minValue, maxValue)).toEqual({}); + expect(DateTimeUtil.validateMinMax(new Date(2010, 2, 7), minValue, maxValue)).toEqual({ minValue: true }); + expect(DateTimeUtil.validateMinMax(new Date(2010, 4, 2), minValue, maxValue)).toEqual({ maxValue: true }); + + minValue = new Date(2010, 3, 2, 10, 10, 10); + maxValue = new Date(2010, 3, 2, 15, 15, 15); + + expect(DateTimeUtil.validateMinMax(new Date(2010, 3, 2, 10, 10, 10), minValue, maxValue)).toEqual({}); + expect(DateTimeUtil.validateMinMax(new Date(2010, 3, 2, 9, 11, 11), minValue, maxValue)).toEqual({ minValue: true }); + expect(DateTimeUtil.validateMinMax(new Date(2010, 3, 2, 16, 11, 11), minValue, maxValue)).toEqual({ maxValue: true }); + + // ignore date portion + expect(DateTimeUtil.validateMinMax(new Date(2000, 0, 1, 10, 10, 10), minValue, maxValue, true, false)).toEqual({}); + expect(DateTimeUtil.validateMinMax( + new Date(2020, 10, 10, 9, 10, 10), minValue, maxValue, true, false)).toEqual({ minValue: true }); + expect(DateTimeUtil.validateMinMax( + new Date(2000, 0, 1, 16, 0, 0), minValue, maxValue, true, false)).toEqual({ maxValue: true }); + + // ignore time portion + expect(DateTimeUtil.validateMinMax(new Date(2010, 3, 2, 9, 0, 0), minValue, maxValue, false, true)).toEqual({}); + expect(DateTimeUtil.validateMinMax( + new Date(2009, 3, 2, 11, 11, 11), minValue, maxValue, false, true)).toEqual({ minValue: true }); + expect(DateTimeUtil.validateMinMax( + new Date(2020, 3, 2, 0, 0, 0), minValue, maxValue, false, true)).toEqual({ maxValue: true }); + }); + + it('should parse dates correctly with parseIsoDate', () => { + const updateDate = (dateValue: Date, stringValue: string): Date => { + const [datePart] = dateValue.toISOString().split('T'); + const newDate = new Date(`${datePart}T${stringValue}`); + newDate.setMilliseconds(0); + return newDate; + }; + + let date = new Date(); + date.setMilliseconds(0); + // full iso string + expect(DateTimeUtil.parseIsoDate(date.toISOString()).getTime()).toEqual(date.getTime()); + + // date only + expect(DateTimeUtil.parseIsoDate('2012-12-10').getTime()).toEqual(new Date('2012-12-10T00:00:00').getTime()); + expect(DateTimeUtil.parseIsoDate('2023-13-15').getTime()).toEqual(new Date('2023-13-15T00:00:00').getTime()); + expect(DateTimeUtil.parseIsoDate('1524-01-02').getTime()).toEqual(new Date('1524-01-02T00:00:00').getTime()); + expect(DateTimeUtil.parseIsoDate('2012').getTime()).toEqual(new Date('2012-01-01T00:00:00').getTime()); + expect(DateTimeUtil.parseIsoDate('2012-02').getTime()).toEqual(new Date('2012-02-01T00:00:00').getTime()); + + // time only + date = DateTimeUtil.parseIsoDate('12:14'); + date.setMilliseconds(0); + expect(date.getTime()).toEqual(updateDate(new Date(), '12:14').getTime()); + + date = DateTimeUtil.parseIsoDate('15:18'); + date.setMilliseconds(0); + expect(date.getTime()).toEqual(updateDate(new Date(), '15:18').getTime()); + + date = DateTimeUtil.parseIsoDate('06:03'); + date.setMilliseconds(0); + expect(date.getTime()).toEqual(updateDate(new Date(), '06:03').getTime()); + + date = DateTimeUtil.parseIsoDate('00:00'); + date.setMilliseconds(0); + expect(date.getTime()).toEqual(updateDate(new Date(), '00:00').getTime()); + + // falsy values + expect(DateTimeUtil.parseIsoDate('')).toEqual(null); + expect(DateTimeUtil.parseIsoDate('false')).toEqual(null); + expect(DateTimeUtil.parseIsoDate('true')).toEqual(null); + expect(DateTimeUtil.parseIsoDate('NaN')).toEqual(null); + expect(DateTimeUtil.parseIsoDate(undefined)).toEqual(null); + expect(DateTimeUtil.parseIsoDate(null)).toEqual(null); + expect(DateTimeUtil.parseIsoDate(new Date().getTime().toString()).getTime()).toEqual(NaN); + }); + + it('isValidDate should properly determine if a date is valid or not', () => { + expect(DateTimeUtil.isValidDate(new Date())).toBeTrue(); + expect(DateTimeUtil.isValidDate(new Date(NaN))).toBeFalse(); + expect(DateTimeUtil.isValidDate(new Date().getTime())).toBeFalse(); + expect(DateTimeUtil.isValidDate('')).toBeFalse(); + expect(DateTimeUtil.isValidDate({})).toBeFalse(); + expect(DateTimeUtil.isValidDate([])).toBeFalse(); + expect(DateTimeUtil.isValidDate(null)).toBeFalse(); + expect(DateTimeUtil.isValidDate(undefined)).toBeFalse(); + expect(DateTimeUtil.isValidDate(false)).toBeFalse(); + expect(DateTimeUtil.isValidDate(true)).toBeFalse(); + }); + + it('should correctly identify formats that would resolve to only numeric parts (and period) for the date/time parts', () => { + // test with locale covering non-ASCII characters as well + const locale = 'bg'; + + const numericFormats = ['y', 'yy', 'yyy', 'yyyy', 'M', 'MM', 'd', 'dd', 'h', 'hh', + 'H', 'HH', 'm', 'mm', 's', 'ss', 'S', 'SS', 'SSS', + 'dd-MM-yyyy', 'dd/M/yyyy HH:mm:ss tt', 'dd/M/yyyy HH:mm:ss:SS a', + // literals are allowed in the format + 'dd/MM/yyyy test hh:mm' + ]; + numericFormats.forEach(format => { + expect(DateTimeUtil.isFormatNumeric(locale, format)).withContext(`Format: ${format}`).toBeTrue(); + }); + + const nonNumericFormats = ['MMM', 'MMMM', 'MMMMM', 'medium', 'long', 'full', 'mediumDate', + 'longDate', 'fullDate', 'longTime', 'fullTime', 'dd-MMM-yyyy', 'E', 'EE']; + + nonNumericFormats.forEach(format => { + expect(DateTimeUtil.isFormatNumeric(locale, format)).withContext(`Format: ${format}`).toBeFalse(); + }); + }); + + it('getNumericInputFormat should return formats with date parts that the date-time editors can handle', () => { + let locale = 'en-US'; + + // returns the equivalent of the predefined numeric formats as date parts + // should be transformed as inputFormats for editing (numeric year, 2-digit parts for the rest) + expect(DateTimeUtil.getNumericInputFormat(locale, 'short')).toBe('MM/dd/yyyy, hh:mm tt'); + expect(DateTimeUtil.getNumericInputFormat(locale, 'shortDate')).toBe('MM/dd/yyyy'); + expect(DateTimeUtil.getNumericInputFormat(locale, 'shortTime').normalize('NFKD')).toBe('hh:mm tt'); + expect(DateTimeUtil.getNumericInputFormat(locale, 'mediumTime').normalize('NFKD')).toBe('hh:mm:ss tt'); + + // handle the predefined formats for different locales + locale = 'bg-BG'; + expect(DateTimeUtil.getNumericInputFormat(locale, 'short').normalize('NFKD')).toBe('dd.MM.yyyy г., HH:mm'); + expect(DateTimeUtil.getNumericInputFormat(locale, 'shortDate').normalize('NFKD')).toBe('dd.MM.yyyy г.'); + expect(DateTimeUtil.getNumericInputFormat(locale, 'shortTime').normalize('NFKD')).toBe('HH:mm'); + expect(DateTimeUtil.getNumericInputFormat(locale, 'mediumTime').normalize('NFKD')).toBe('HH:mm:ss'); + + locale = 'ja-JP'; + expect(DateTimeUtil.getNumericInputFormat(locale, 'short')).toBe('yyyy/MM/dd HH:mm'); + expect(DateTimeUtil.getNumericInputFormat(locale, 'shortDate')).toBe('yyyy/MM/dd'); + expect(DateTimeUtil.getNumericInputFormat(locale, 'shortTime').normalize('NFKD')).toBe('HH:mm'); + expect(DateTimeUtil.getNumericInputFormat(locale, 'mediumTime').normalize('NFKD')).toBe('HH:mm:ss'); + + // returns the same format if it is custom and numeric + expect(DateTimeUtil.getNumericInputFormat(locale, 'dd-MM-yyyy')).toBe('dd-MM-yyyy'); + expect(DateTimeUtil.getNumericInputFormat(locale, 'dd/M/yyyy hh:mm:ss:SS aa')).toBe('dd/M/yyyy hh:mm:ss:SS aa'); + + // returns empty string if predefined and not among the numeric ones + expect(DateTimeUtil.getNumericInputFormat(locale, 'medium')).toBe(''); + expect(DateTimeUtil.getNumericInputFormat(locale, 'mediumDate')).toBe(''); + expect(DateTimeUtil.getNumericInputFormat(locale, 'longTime')).toBe(''); + }); +}); diff --git a/projects/igniteui-angular/core/src/date-common/util/date-time.util.ts b/projects/igniteui-angular/core/src/date-common/util/date-time.util.ts new file mode 100644 index 00000000000..b4246077186 --- /dev/null +++ b/projects/igniteui-angular/core/src/date-common/util/date-time.util.ts @@ -0,0 +1,853 @@ +import { DatePart, DatePartInfo } from '../date-parts'; +import { formatDate, FormatWidth, getLocaleDateFormat } from '@angular/common'; +import { ValidationErrors } from '@angular/forms'; +import { isDate } from '../../core/utils'; +import { GridColumnDataType } from '../../data-operations/grid-types'; + +/** @hidden */ +const enum FormatDesc { + Numeric = 'numeric', + TwoDigits = '2-digit' +} + +const TIME_CHARS = ['h', 'H', 'm', 's', 'S', 't', 'T', 'a']; +const DATE_CHARS = ['d', 'D', 'M', 'y', 'Y']; + +/** @hidden */ +const enum AmPmValues { + AM = 'AM', + A = 'a', + PM = 'PM', + P = 'p' +} + +/** @hidden */ +const enum DateParts { + Day = 'day', + Month = 'month', + Year = 'year', + Hour = 'hour', + Minute = 'minute', + Second = 'second', + AmPm = 'dayPeriod' +} + +/** Maps of the pre-defined date-time format options supported by the Angular DatePipe + * - predefinedNumericFormats resolve to numeric parts only (and period) for the default 'en' culture + * - predefinedNonNumericFormats usually contain non-numeric date/time parts, which cannot be + * handled for editing by the date/time editors + * Ref: https://angular.dev/api/common/DatePipe?tab=usage-notes + * @hidden + */ +const predefinedNumericFormats = new Map([ + ['short', [DateParts.Month, DateParts.Day, DateParts.Year, DateParts.Hour, DateParts.Minute]], + ['shortDate', [DateParts.Month, DateParts.Day, DateParts.Year]], + ['shortTime', [DateParts.Hour, DateParts.Minute]], + ['mediumTime', [DateParts.Hour, DateParts.Minute, DateParts.Second]], +]); + +const predefinedNonNumericFormats = new Set([ + 'medium', 'long', 'full', 'mediumDate', 'longDate', 'fullDate', 'longTime', 'fullTime', +]) + +/** @hidden */ +export abstract class DateTimeUtil { + public static readonly DEFAULT_INPUT_FORMAT = 'MM/dd/yyyy'; + public static readonly DEFAULT_TIME_INPUT_FORMAT = 'hh:mm tt'; + private static readonly SEPARATOR = 'literal'; + private static readonly DEFAULT_LOCALE = 'en'; + + /** + * Parse a Date value from masked string input based on determined date parts + * + * @param inputData masked value to parse + * @param dateTimeParts Date parts array for the mask + */ + public static parseValueFromMask(inputData: string, dateTimeParts: DatePartInfo[], promptChar?: string): Date | null { + const parts: { [key in DatePart]: number } = {} as any; + dateTimeParts.forEach(dp => { + let value = parseInt(DateTimeUtil.getCleanVal(inputData, dp, promptChar), 10); + if (!value) { + value = dp.type === DatePart.Date || dp.type === DatePart.Month ? 1 : 0; + } + parts[dp.type] = value; + }); + parts[DatePart.Month] -= 1; + + if (parts[DatePart.Month] < 0 || 11 < parts[DatePart.Month]) { + return null; + } + + // TODO: Century threshold + if (parts[DatePart.Year] < 50) { + parts[DatePart.Year] += 2000; + } + + if (parts[DatePart.Date] > DateTimeUtil.daysInMonth(parts[DatePart.Year], parts[DatePart.Month])) { + return null; + } + + if (parts[DatePart.Hours] > 23 || parts[DatePart.Minutes] > 59 + || parts[DatePart.Seconds] > 59 || parts[DatePart.FractionalSeconds] > 999) { + return null; + } + + const amPm = dateTimeParts.find(p => p.type === DatePart.AmPm); + if (amPm) { + parts[DatePart.Hours] %= 12; + } + + if (amPm) { + const cleanVal = DateTimeUtil.getCleanVal(inputData, amPm, promptChar); + if (DateTimeUtil.isPm(cleanVal)) { + parts[DatePart.Hours] += 12; + } + } + + return new Date( + parts[DatePart.Year] || 2000, + parts[DatePart.Month] || 0, + parts[DatePart.Date] || 1, + parts[DatePart.Hours] || 0, + parts[DatePart.Minutes] || 0, + parts[DatePart.Seconds] || 0, + parts[DatePart.FractionalSeconds] || 0 + ); + } + + /** Parse the mask into date/time and literal parts */ + public static parseDateTimeFormat(mask: string, locale?: string): DatePartInfo[] { + const format = mask || DateTimeUtil.getDefaultInputFormat(locale); + const dateTimeParts: DatePartInfo[] = []; + const formatArray = Array.from(format); + let currentPart: DatePartInfo = null; + let position = 0; + let lastPartAdded = false; + for (let i = 0; i < formatArray.length; i++, position++) { + const type = DateTimeUtil.determineDatePart(formatArray[i]); + if (currentPart) { + if (currentPart.type === type) { + currentPart.format += formatArray[i]; + if (i < formatArray.length - 1) { + continue; + } + } + + if (currentPart.type === DatePart.AmPm && currentPart.format.indexOf('a') !== -1) { + currentPart = DateTimeUtil.simplifyAmPmFormat(currentPart); + } + DateTimeUtil.addCurrentPart(currentPart, dateTimeParts); + lastPartAdded = true; + position = currentPart.end; + if(i === formatArray.length - 1 && currentPart.type !== type) { + lastPartAdded = false; + } + } + + currentPart = { + start: position, + end: position + formatArray[i].length, + type, + format: formatArray[i] + }; + } + + // make sure the last member of a format like H:m:s is not omitted + if (!lastPartAdded) { + if (currentPart.type === DatePart.AmPm) { + currentPart = DateTimeUtil.simplifyAmPmFormat(currentPart); + } + DateTimeUtil.addCurrentPart(currentPart, dateTimeParts); + } + // formats like "y" or "yyy" are treated like "yyyy" while editing + const yearPart = dateTimeParts.filter(p => p.type === DatePart.Year)[0]; + if (yearPart && yearPart.format !== 'yy') { + yearPart.end += 4 - yearPart.format.length; + yearPart.format = 'yyyy'; + } + + return dateTimeParts; + } + + /** Simplifies the AmPm part to as many chars as will be displayed */ + private static simplifyAmPmFormat(currentPart: DatePartInfo){ + currentPart.format = currentPart.format.length === 5 ? 'a' : 'aa'; + currentPart.end = currentPart.start + currentPart.format.length; + return { ...currentPart }; + } + + public static getPartValue(value: Date, datePartInfo: DatePartInfo, partLength: number): string { + let maskedValue; + const datePart = datePartInfo.type; + switch (datePart) { + case DatePart.Date: + maskedValue = value.getDate(); + break; + case DatePart.Month: + // months are zero based + maskedValue = value.getMonth() + 1; + break; + case DatePart.Year: + if (partLength === 2) { + maskedValue = this.prependValue( + parseInt(value.getFullYear().toString().slice(-2), 10), partLength, '0'); + } else { + maskedValue = value.getFullYear(); + } + break; + case DatePart.Hours: + if (datePartInfo.format.indexOf('h') !== -1) { + maskedValue = this.prependValue( + this.toTwelveHourFormat(value.getHours().toString()), partLength, '0'); + } else { + maskedValue = value.getHours(); + } + break; + case DatePart.Minutes: + maskedValue = value.getMinutes(); + break; + case DatePart.Seconds: + maskedValue = value.getSeconds(); + break; + case DatePart.FractionalSeconds: + maskedValue = value.getMilliseconds(); + break; + case DatePart.AmPm: + maskedValue = DateTimeUtil.getAmPmValue(partLength, value.getHours() < 12); + break; + } + + if (datePartInfo.type !== DatePart.AmPm && datePartInfo.type !== DatePart.Literal) { + return this.prependValue(maskedValue, partLength, '0'); + } + + return maskedValue; + } + + /** Returns the AmPm part value depending on the part length and a + * conditional expression indicating whether the value is AM or PM. + */ + public static getAmPmValue(partLength: number, isAm: boolean) { + if (isAm) { + return partLength === 1 ? AmPmValues.A : AmPmValues.AM; + } else { + return partLength === 1 ? AmPmValues.P : AmPmValues.PM; + } + } + + /** Returns true if a string value indicates an AM period */ + public static isAm(value: string) { + value = value.toLowerCase(); + return (value === AmPmValues.AM.toLowerCase() || value === AmPmValues.A.toLowerCase()); + } + + /** Returns true if a string value indicates a PM period */ + public static isPm(value: string) { + value = value.toLowerCase(); + return (value === AmPmValues.PM.toLowerCase() || value === AmPmValues.P.toLowerCase()); + } + + /** Builds a date-time editor's default input format based on provided locale settings and data type. */ + public static getDefaultInputFormat(locale: string, dataType: GridColumnDataType = GridColumnDataType.Date): string { + locale = locale || DateTimeUtil.DEFAULT_LOCALE; + if (!Intl || !Intl.DateTimeFormat || !Intl.DateTimeFormat.prototype.formatToParts) { + // TODO: fallback with Intl.format for IE? + return DateTimeUtil.DEFAULT_INPUT_FORMAT; + } + const parts = DateTimeUtil.getDefaultLocaleMask(locale, dataType); + parts.forEach(p => { + if (p.type !== DatePart.Year && p.type !== DateTimeUtil.SEPARATOR && p.type !== DatePart.AmPm) { + p.formatType = FormatDesc.TwoDigits; + } + }); + + return DateTimeUtil.getMask(parts); + } + + /** Tries to format a date using Angular's DatePipe. Fallbacks to `Intl` if no locale settings have been loaded. */ + public static formatDate(value: number | Date, format: string, locale: string, timezone?: string): string { + let formattedDate: string; + try { + formattedDate = formatDate(value, format, locale, timezone).normalize("NFKD"); + } catch { + DateTimeUtil.logMissingLocaleSettings(locale); + const formatter = new Intl.DateTimeFormat(locale); + formattedDate = formatter.format(value); + } + + return formattedDate; + } + + /** + * Returns the date format based on a provided locale. + * Supports Angular's DatePipe format options such as `shortDate`, `longDate`. + */ + public static getLocaleDateFormat(locale: string, displayFormat?: string): string { + const formatKeys = Object.keys(FormatWidth) as (keyof FormatWidth)[]; + const targetKey = formatKeys.find(k => k.toLowerCase() === displayFormat?.toLowerCase().replace('date', '')); + if (!targetKey) { + // if displayFormat is not shortDate, longDate, etc. + // or if it is not set by the user + return displayFormat; + } + let format: string; + try { + format = getLocaleDateFormat(locale, FormatWidth[targetKey]); + } catch { + DateTimeUtil.logMissingLocaleSettings(locale); + format = DateTimeUtil.getDefaultInputFormat(locale); + } + + return format; + } + + /** Determines if a given character is `d/M/y` or `h/m/s`. */ + public static isDateOrTimeChar(char: string): boolean { + return DATE_CHARS.indexOf(char) !== -1 || TIME_CHARS.indexOf(char) !== -1; + } + + /** Spins the date portion in a date-time editor. */ + public static spinDate(delta: number, newDate: Date, spinLoop: boolean): void { + const maxDate = DateTimeUtil.daysInMonth(newDate.getFullYear(), newDate.getMonth()); + let date = newDate.getDate() + delta; + if (date > maxDate) { + date = spinLoop ? date % maxDate : maxDate; + } else if (date < 1) { + date = spinLoop ? maxDate + (date % maxDate) : 1; + } + + newDate.setDate(date); + } + + /** Spins the month portion in a date-time editor. */ + public static spinMonth(delta: number, newDate: Date, spinLoop: boolean): void { + const maxDate = DateTimeUtil.daysInMonth(newDate.getFullYear(), newDate.getMonth() + delta); + if (newDate.getDate() > maxDate) { + newDate.setDate(maxDate); + } + + const maxMonth = 11; + const minMonth = 0; + let month = newDate.getMonth() + delta; + if (month > maxMonth) { + month = spinLoop ? (month % maxMonth) - 1 : maxMonth; + } else if (month < minMonth) { + month = spinLoop ? maxMonth + (month % maxMonth) + 1 : minMonth; + } + + newDate.setMonth(month); + } + + /** Spins the year portion in a date-time editor. */ + public static spinYear(delta: number, newDate: Date): void { + const maxDate = DateTimeUtil.daysInMonth(newDate.getFullYear() + delta, newDate.getMonth()); + if (newDate.getDate() > maxDate) { + // clip to max to avoid leap year change shifting the entire value + newDate.setDate(maxDate); + } + newDate.setFullYear(newDate.getFullYear() + delta); + } + + /** Spins the hours portion in a date-time editor. */ + public static spinHours(delta: number, newDate: Date, spinLoop: boolean): void { + const maxHour = 23; + const minHour = 0; + let hours = newDate.getHours() + delta; + if (hours > maxHour) { + hours = spinLoop ? hours % maxHour - 1 : maxHour; + } else if (hours < minHour) { + hours = spinLoop ? maxHour + (hours % maxHour) + 1 : minHour; + } + + newDate.setHours(hours); + } + + /** Spins the minutes portion in a date-time editor. */ + public static spinMinutes(delta: number, newDate: Date, spinLoop: boolean): void { + const maxMinutes = 59; + const minMinutes = 0; + let minutes = newDate.getMinutes() + delta; + if (minutes > maxMinutes) { + minutes = spinLoop ? minutes % maxMinutes - 1 : maxMinutes; + } else if (minutes < minMinutes) { + minutes = spinLoop ? maxMinutes + (minutes % maxMinutes) + 1 : minMinutes; + } + + newDate.setMinutes(minutes); + } + + /** Spins the seconds portion in a date-time editor. */ + public static spinSeconds(delta: number, newDate: Date, spinLoop: boolean): void { + const maxSeconds = 59; + const minSeconds = 0; + let seconds = newDate.getSeconds() + delta; + if (seconds > maxSeconds) { + seconds = spinLoop ? seconds % maxSeconds - 1 : maxSeconds; + } else if (seconds < minSeconds) { + seconds = spinLoop ? maxSeconds + (seconds % maxSeconds) + 1 : minSeconds; + } + + newDate.setSeconds(seconds); + } + + /** Spins the fractional seconds (milliseconds) portion in a date-time editor. */ + public static spinFractionalSeconds(delta: number, newDate: Date, spinLoop: boolean) { + const maxMs = 999; + const minMs = 0; + let ms = newDate.getMilliseconds() + delta; + if (ms > maxMs) { + ms = spinLoop ? ms % maxMs - 1 : maxMs; + } else if (ms < minMs) { + ms = spinLoop ? maxMs + (ms % maxMs) + 1 : minMs; + } + + newDate.setMilliseconds(ms); + } + + /** Spins the AM/PM portion in a date-time editor. */ + public static spinAmPm(newDate: Date, currentDate: Date, amPmFromMask: string): Date { + if(DateTimeUtil.isAm(amPmFromMask)) { + newDate = new Date(newDate.setHours(newDate.getHours() + 12)); + } else if(DateTimeUtil.isPm(amPmFromMask)) { + newDate = new Date(newDate.setHours(newDate.getHours() - 12)); + } + + if (newDate.getDate() !== currentDate.getDate()) { + return currentDate; + } + + return newDate; + } + + /** + * Determines whether the provided value is greater than the provided max value. + * + * @param includeTime set to false if you want to exclude time portion of the two dates + * @param includeDate set to false if you want to exclude the date portion of the two dates + * @returns true if provided value is greater than provided maxValue + */ + public static greaterThanMaxValue(value: Date, maxValue: Date, includeTime = true, includeDate = true): boolean { + if (includeTime && includeDate) { + return value.getTime() > maxValue.getTime(); + } + + const _value = new Date(value.getTime()); + const _maxValue = new Date(maxValue.getTime()); + if (!includeTime) { + _value.setHours(0, 0, 0, 0); + _maxValue.setHours(0, 0, 0, 0); + } + if (!includeDate) { + _value.setFullYear(0, 0, 0); + _maxValue.setFullYear(0, 0, 0); + } + + return _value.getTime() > _maxValue.getTime(); + } + + /** + * Determines whether the provided value is less than the provided min value. + * + * @param includeTime set to false if you want to exclude time portion of the two dates + * @param includeDate set to false if you want to exclude the date portion of the two dates + * @returns true if provided value is less than provided minValue + */ + public static lessThanMinValue(value: Date, minValue: Date, includeTime = true, includeDate = true): boolean { + if (includeTime && includeDate) { + return value.getTime() < minValue.getTime(); + } + + const _value = new Date(value.getTime()); + const _minValue = new Date(minValue.getTime()); + if (!includeTime) { + _value.setHours(0, 0, 0, 0); + _minValue.setHours(0, 0, 0, 0); + } + if (!includeDate) { + _value.setFullYear(0, 0, 0); + _minValue.setFullYear(0, 0, 0); + } + + return _value.getTime() < _minValue.getTime(); + } + + /** + * Validates a value within a given min and max value range. + * + * @param value The value to validate + * @param minValue The lowest possible value that `value` can take + * @param maxValue The largest possible value that `value` can take + */ + public static validateMinMax(value: Date, minValue: Date | string, maxValue: Date | string, + includeTime = true, includeDate = true): ValidationErrors { + if (!value) { + return null; + } + const errors = {}; + const min = DateTimeUtil.isValidDate(minValue) ? minValue : DateTimeUtil.parseIsoDate(minValue); + const max = DateTimeUtil.isValidDate(maxValue) ? maxValue : DateTimeUtil.parseIsoDate(maxValue); + if (min && value && DateTimeUtil.lessThanMinValue(value, min, includeTime, includeDate)) { + Object.assign(errors, { minValue: true }); + } + if (max && value && DateTimeUtil.greaterThanMaxValue(value, max, includeTime, includeDate)) { + Object.assign(errors, { maxValue: true }); + } + + return errors; + } + + /** Parse an ISO string to a Date */ + public static parseIsoDate(value: string): Date | null { + let regex = /^\d{4}/g; + const timeLiteral = 'T'; + if (regex.test(value)) { + return new Date(value + `${value.indexOf(timeLiteral) === -1 ? 'T00:00:00' : ''}`); + } + + regex = /^\d{2}/g; + if (regex.test(value)) { + const dateNow = new Date().toISOString(); + // eslint-disable-next-line prefer-const + let [datePart, _timePart] = dateNow.split(timeLiteral); + return new Date(`${datePart}T${value}`); + } + + return null; + } + + /** + * Returns whether the input is valid date + * + * @param value input to check + * @returns true if provided input is a valid date + */ + public static isValidDate(value: any): value is Date { + if (isDate(value)) { + return !isNaN(value.getTime()); + } + + return false; + } + + public static isFormatNumeric(locale: string, inputFormat: string): boolean { + const dateParts = DateTimeUtil.parseDateTimeFormat(inputFormat); + if (predefinedNonNumericFormats.has(inputFormat) || dateParts.every(p => p.type === DatePart.Literal)) { + return false; + } + for (let i = 0; i < dateParts.length; i++) { + if (dateParts[i].type === DatePart.AmPm || dateParts[i].type === DatePart.Literal) { + continue; + } + const transformedValue = formatDate(new Date(), dateParts[i].format, locale); + // check if the transformed date/time part contains any kind of letter from any language + if (/\p{L}+/gu.test(transformedValue)) { + return false; + } + } + return true; + } + + /** + * Returns an input format that can be used by the date-time editors, as + * - if the format is already numeric, return it as is + * - if it is among the predefined numeric ones, return it as the equivalent locale-based format + * for the corresponding numeric date parts + * - otherwise, return an empty string + */ + public static getNumericInputFormat(locale: string, format: string): string { + let resultFormat = ''; + if (!format) { + return resultFormat; + } + if (predefinedNumericFormats.has(format)) { + resultFormat = DateTimeUtil.getLocaleInputFormatFromParts(locale, predefinedNumericFormats.get(format)); + + } else if (DateTimeUtil.isFormatNumeric(locale, format)) { + resultFormat = format; + } + return resultFormat; + } + + /** Gets the locale-based format from an array of date parts */ + private static getLocaleInputFormatFromParts(locale: string, dateParts: DateParts[]): string { + const options = {}; + dateParts.forEach(p => { + if (p === DateParts.Year) { + options[p] = FormatDesc.Numeric; + } else if (p !== DateParts.AmPm) { + options[p] = FormatDesc.TwoDigits; + } + }); + const formatter = new Intl.DateTimeFormat(locale, options); + const dateStruct = DateTimeUtil.getDateStructFromParts(formatter.formatToParts(new Date()), formatter); + DateTimeUtil.fillDatePartsPositions(dateStruct); + return DateTimeUtil.getMask(dateStruct); + } + + private static addCurrentPart(currentPart: DatePartInfo, dateTimeParts: DatePartInfo[]): void { + DateTimeUtil.ensureLeadingZero(currentPart); + currentPart.end = currentPart.start + currentPart.format.length; + dateTimeParts.push(currentPart); + } + + private static daysInMonth(fullYear: number, month: number): number { + return new Date(fullYear, month + 1, 0).getDate(); + } + + private static trimEmptyPlaceholders(value: string, promptChar?: string): string { + const result = value.replace(new RegExp(promptChar || '_', 'g'), ''); + return result; + } + + private static getMask(dateStruct: any[]): string { + const mask = []; + for (const part of dateStruct) { + if (part.formatType === FormatDesc.Numeric) { + switch (part.type) { + case DateParts.Day: + mask.push('d'); + break; + case DateParts.Month: + mask.push('M'); + break; + case DateParts.Year: + mask.push('yyyy'); + break; + case DateParts.Hour: + mask.push(part.hour12 ? 'h' : 'H'); + break; + case DateParts.Minute: + mask.push('m'); + break; + case DateParts.Second: + mask.push('s'); + break; + } + } else if (part.formatType === FormatDesc.TwoDigits) { + switch (part.type) { + case DateParts.Day: + mask.push('dd'); + break; + case DateParts.Month: + mask.push('MM'); + break; + case DateParts.Year: + mask.push('yy'); + break; + case DateParts.Hour: + mask.push(part.hour12 ? 'hh' : 'HH'); + break; + case DateParts.Minute: + mask.push('mm'); + break; + case DateParts.Second: + mask.push('ss'); + break; + } + } + + if (part.type === DateParts.AmPm) { + mask.push('tt'); + } + + if (part.type === DateTimeUtil.SEPARATOR) { + mask.push(part.value); + } + } + + return mask.join(''); + } + + private static logMissingLocaleSettings(locale: string): void { + console.warn(`Missing locale data for the locale ${locale}. Please refer to https://angular.io/guide/i18n#i18n-pipes`); + console.warn('Using default browser locale settings.'); + } + + private static prependValue(value: number, partLength: number, prependChar: string): string { + return (prependChar + value.toString()).slice(-partLength); + } + + private static toTwelveHourFormat(value: string, promptChar = '_'): number { + let hour = parseInt(value.replace(new RegExp(promptChar, 'g'), '0'), 10); + if (hour > 12) { + hour -= 12; + } else if (hour === 0) { + hour = 12; + } + + return hour; + } + + private static ensureLeadingZero(part: DatePartInfo) { + switch (part.type) { + case DatePart.Date: + case DatePart.Month: + case DatePart.Hours: + case DatePart.Minutes: + case DatePart.Seconds: + if (part.format.length === 1) { + part.format = part.format.repeat(2); + } + break; + case DatePart.FractionalSeconds: + part.format = part.format[0].repeat(3); + break; + } + } + + private static getCleanVal(inputData: string, datePart: DatePartInfo, promptChar?: string): string { + return DateTimeUtil.trimEmptyPlaceholders(inputData.substring(datePart.start, datePart.end), promptChar); + } + + private static determineDatePart(char: string): DatePart { + switch (char) { + case 'd': + case 'D': + return DatePart.Date; + case 'M': + return DatePart.Month; + case 'y': + case 'Y': + return DatePart.Year; + case 'h': + case 'H': + return DatePart.Hours; + case 'm': + return DatePart.Minutes; + case 's': + return DatePart.Seconds; + case 'S': + return DatePart.FractionalSeconds; + case 'a': + case 't': + case 'T': + return DatePart.AmPm; + default: + return DatePart.Literal; + } + } + + private static getFormatOptions(dataType: GridColumnDataType) { + const dateOptions = { + day: FormatDesc.TwoDigits, + month: FormatDesc.TwoDigits, + year: FormatDesc.Numeric + }; + const timeOptions = { + hour: FormatDesc.TwoDigits, + minute: FormatDesc.TwoDigits + }; + switch (dataType) { + case GridColumnDataType.Date: + return dateOptions; + case GridColumnDataType.Time: + return timeOptions; + case GridColumnDataType.DateTime: + return { + ...dateOptions, + ...timeOptions, + second: FormatDesc.TwoDigits + }; + default: + return { }; + } + } + + private static getDefaultLocaleMask(locale: string, dataType: GridColumnDataType = GridColumnDataType.Date) { + const options = DateTimeUtil.getFormatOptions(dataType); + const formatter = new Intl.DateTimeFormat(locale, options); + const formatToParts = formatter.formatToParts(new Date()); + const dateStruct = DateTimeUtil.getDateStructFromParts(formatToParts, formatter); + DateTimeUtil.fillDatePartsPositions(dateStruct); + return dateStruct; + } + + private static getDateStructFromParts(parts: Intl.DateTimeFormatPart[], formatter: Intl.DateTimeFormat): any[] { + const dateStruct = []; + for (const part of parts) { + if (part.type === DateTimeUtil.SEPARATOR) { + dateStruct.push({ + type: DateTimeUtil.SEPARATOR, + value: part.value + }); + } else { + dateStruct.push({ + type: part.type + }); + } + } + const formatterOptions = formatter.resolvedOptions(); + for (const part of dateStruct) { + switch (part.type) { + case DateParts.Day: { + part.formatType = formatterOptions.day; + break; + } + case DateParts.Month: { + part.formatType = formatterOptions.month; + break; + } + case DateParts.Year: { + part.formatType = formatterOptions.year; + break; + } + case DateParts.Hour: { + part.formatType = formatterOptions.hour; + if (formatterOptions.hour12) { + part.hour12 = true; + } + break; + } + case DateParts.Minute: { + part.formatType = formatterOptions.minute; + break; + } + case DateParts.Second: { + part.formatType = formatterOptions.second; + break; + } + case DateParts.AmPm: { + part.formatType = formatterOptions.dayPeriod; + break; + } + } + } + return dateStruct; + } + + private static fillDatePartsPositions(dateArray: any[]): void { + let currentPos = 0; + + for (const part of dateArray) { + // Day|Month|Hour|Minute|Second|AmPm part positions + if (part.type === DateParts.Day || part.type === DateParts.Month || + part.type === DateParts.Hour || part.type === DateParts.Minute || part.type === DateParts.Second || + part.type === DateParts.AmPm + ) { + // Offset 2 positions for number + part.position = [currentPos, currentPos + 2]; + currentPos += 2; + } else if (part.type === DateParts.Year) { + // Year part positions + switch (part.formatType) { + case FormatDesc.Numeric: { + // Offset 4 positions for full year + part.position = [currentPos, currentPos + 4]; + currentPos += 4; + break; + } + case FormatDesc.TwoDigits: { + // Offset 2 positions for short year + part.position = [currentPos, currentPos + 2]; + currentPos += 2; + break; + } + } + } else if (part.type === DateTimeUtil.SEPARATOR) { + // Separator positions + part.position = [currentPos, currentPos + 1]; + currentPos++; + } + } + } +} diff --git a/projects/igniteui-angular/core/src/date-common/util/helpers.spec.ts b/projects/igniteui-angular/core/src/date-common/util/helpers.spec.ts new file mode 100644 index 00000000000..b0a2f20b35b --- /dev/null +++ b/projects/igniteui-angular/core/src/date-common/util/helpers.spec.ts @@ -0,0 +1,240 @@ +import { DateRangeType } from "../../core/dates/dateRange"; +import { + areSameMonth, + getNextActiveDate, + getPreviousActiveDate, + getClosestActiveDate, + isNextMonth, + isPreviousMonth, + calendarRange, + isDateInRanges, + generateMonth, + getYearRange, + formatToParts, +} from "./helpers"; +import { CalendarDay } from "./model"; + +describe("Calendar Helpers", () => { + const date = new Date(2020, 0, 1); + const disabledDates = [ + { + type: DateRangeType.Between, + dateRange: [date, new Date(2020, 0, 14)], + }, + ]; + + it("should report if two dates are in the same month", () => { + const firstDate = new Date(2020, 0, 2); + const secondDate = new Date(2020, 1, 31); + expect(areSameMonth(date, firstDate)).toBe(true); + expect(areSameMonth(date, secondDate)).toBe(false); + }); + + it("should report if a date is in the next month", () => { + const firstDate = new Date(2020, 0, 2); + const secondDate = new Date(2019, 11, 31); + + expect(isNextMonth(date, firstDate)).toBe(false); + expect(isNextMonth(date, secondDate)).toBe(true); + }); + + it("should report if a date is in the previous month", () => { + const firstDate = new Date(2020, 0, 2); + const secondDate = new Date(2019, 11, 31); + + expect(isPreviousMonth(secondDate, date)).toBe(true); + expect(isPreviousMonth(firstDate, date)).toBe(false); + }); + + it("should get the next date for a given range", () => { + const nextDate = getNextActiveDate( + CalendarDay.from(date), + disabledDates, + ); + + expect(nextDate.native).toEqual(new Date(2020, 0, 15)); + }); + + it("should get the previous date for a given range", () => { + const nextDate = getPreviousActiveDate( + CalendarDay.from(date), + disabledDates, + ); + + expect(nextDate.native).toEqual(new Date(2019, 11, 31)); + }); + + it("should get the closest active date for a given offset and a range", () => { + let target = CalendarDay.from(date); + + // Offset 1 day in the future + let nextDate = getClosestActiveDate( + CalendarDay.from(date), + 1, + disabledDates, + ); + expect(nextDate.native).toEqual(new Date(2020, 0, 15)); + + // Offset 1 day in the past + nextDate = getClosestActiveDate( + CalendarDay.from(date), + -1, + disabledDates, + ); + expect(nextDate.native).toEqual(new Date(2019, 11, 31)); + + // Set the starting point to December 25th, 2019 + target = target.add("day", -7); + + // Offset 7 days in the future, should skip two whole weeks + // as the dates from the 1st to the 14th are disabled + nextDate = getClosestActiveDate(target, 7, disabledDates); + expect(nextDate.native).toEqual(new Date(2020, 0, 15)); + + // Set the starting point to January 15th, 2020 + target = target.add("day", 14); + + // Offset -7 days in the past, should skip two whole weeks + // as the dates from the 1st to the 14th are disabled + nextDate = getClosestActiveDate(target, -7, disabledDates); + expect(nextDate.native).toEqual(new Date(2019, 11, 25)); + }); + + it("should return an iterable range of dates between two dates (non-inclusive)", () => { + const start = CalendarDay.from(date); + const end = start.add("day", 7); + + // Generate all dates between January 1st and and January 7th + const range = Array.from(calendarRange({ start, end })); + expect(range.length).toBe(7); + + range.forEach((day) => { + expect( + isDateInRanges(day, [ + { + type: DateRangeType.Between, + dateRange: [start.native, end.native], + }, + ]), + ).toBe(true); + }); + }); + + it("should generate a range of 42 days from a starting point", () => { + // Generate all dates in January 2020 as well as leading/trailing days + // for December 2019 and February 2020 respectively with the week start + // set to Monday + const range = Array.from(generateMonth(date, 1)); + expect(range.length).toBe(42); + + range.forEach((day) => { + expect( + isDateInRanges(day, [ + { + type: DateRangeType.Between, + dateRange: [ + new Date(2019, 11, 30), + new Date(2020, 1, 9), + ], + }, + ]), + ).toBe(true); + }); + }); + + it("should return the first and last years in a range of years", () => { + const { start, end } = getYearRange(date, 15); + expect(start).toBe(2010); + expect(end).toBe(2024); + }); + + it("should assess if a date is in a range of dates", () => { + // Date is between range + expect(isDateInRanges(date, disabledDates)).toBe(true); + + // Date is after range + expect( + isDateInRanges(date, [ + { + type: DateRangeType.After, + dateRange: [new Date(2020, 0, 2)], + }, + ]), + ).toBe(false); + + // Date is before range + expect( + isDateInRanges(date, [ + { + type: DateRangeType.Before, + dateRange: [new Date(2020, 0, 2)], + }, + ]), + ).toBe(true); + + // Date is in a specific range + expect( + isDateInRanges(date, [ + { + type: DateRangeType.Specific, + dateRange: [new Date(2019, 11, 31), new Date(2020, 0, 2)], + }, + ]), + ).toBe(false); + + // Date is a weekday + expect( + isDateInRanges(date, [ + { + type: DateRangeType.Weekdays, + }, + ]), + ).toBe(true); + + // Date is not a weekend + expect( + isDateInRanges(date, [ + { + type: DateRangeType.Weekends, + }, + ]), + ).toBe(false); + }); + + it("should get formatted parts by a given locale for a date", () => { + const { + date: day, + full, + day: dayObject, + month: monthObject, + year: yearObject, + } = formatToParts( + date, + "en", + { day: "numeric", month: "long", year: "numeric" }, + ["day", "month", "year"], + ); + + expect(day).toEqual(date); + + expect(full).toEqual("January 1, 2020"); + + expect(dayObject).toEqual({ + value: "1", + literal: ", ", + combined: "1, ", + }); + + expect(monthObject).toEqual({ + value: "January", + literal: " ", + combined: "January ", + }); + + expect(yearObject).toEqual({ + value: "2020", + literal: "", + combined: "2020", + }); + }); +}); diff --git a/projects/igniteui-angular/core/src/date-common/util/helpers.ts b/projects/igniteui-angular/core/src/date-common/util/helpers.ts new file mode 100644 index 00000000000..ad1a11bfcee --- /dev/null +++ b/projects/igniteui-angular/core/src/date-common/util/helpers.ts @@ -0,0 +1,221 @@ +import { + CalendarDay, + CalendarRangeParams, + DayParameter, + daysInWeek, + toCalendarDay, +} from "./model"; +import { DateRangeDescriptor, DateRangeType } from '../../core/dates'; +import { first, last, modulo } from '../../core/utils'; + +interface IFormattedParts { + value: string; + literal: string; + combined: string; +} + +export function areSameMonth( + firstMonth: DayParameter, + secondMonth: DayParameter, +) { + const [a, b] = [toCalendarDay(firstMonth), toCalendarDay(secondMonth)]; + return a.year === b.year && a.month === b.month; +} + +export function isNextMonth(target: DayParameter, origin: DayParameter) { + const [a, b] = [toCalendarDay(target), toCalendarDay(origin)]; + return a.year === b.year ? a.month > b.month : a.year > b.year; +} + +export function isPreviousMonth(target: DayParameter, origin: DayParameter) { + const [a, b] = [toCalendarDay(target), toCalendarDay(origin)]; + return a.year === b.year ? a.month < b.month : a.year < b.year; +} + +/** Returns the next date starting from `start` that does not match the `disabled` descriptors */ +export function getNextActiveDate( + start: CalendarDay, + disabled: DateRangeDescriptor[] = [], +) { + while (isDateInRanges(start, disabled)) { + start = start.add("day", 1); + } + + return start; +} + +/** Returns the previous date starting from `start` that does not match the `disabled` descriptors */ +export function getPreviousActiveDate( + start: CalendarDay, + disabled: DateRangeDescriptor[] = [], +) { + while (isDateInRanges(start, disabled)) { + start = start.add("day", -1); + } + + return start; +} + +export function getClosestActiveDate( + start: CalendarDay, + delta: number, + disabled: DateRangeDescriptor[] = [], +): CalendarDay { + // TODO: implement a more robust logic for max attempts, + // i.e. the amount of days to jump between before giving up + // currently it will try to find the closest date for a year + const maxAttempts = 366; + let date = start; + let attempts = 0; + + while (attempts < maxAttempts) { + date = start.add("day", delta * (attempts + 1)); + + if (!isDateInRanges(date, disabled)) { + return date; + } + + attempts++; + } + + return date; +} + +/** + * Returns a generator yielding day values between `start` and `end` (non-inclusive) + * by a given `unit` as a step. + * + * @remarks + * By default, `unit` is set to 'day'. + */ +export function* calendarRange( + options: CalendarRangeParams +): Generator { + const { start, end, unit = 'day', inclusive = false } = options; + + let currentDate = toCalendarDay(start); + const endDate = + typeof end === 'number' + ? toCalendarDay(start).add(unit, end) + : toCalendarDay(end); + + const isReversed = endDate.lessThan(currentDate); + const step = isReversed ? -1 : 1; + + const shouldContinue = () => { + if (inclusive) { + return isReversed + ? currentDate.greaterThanOrEqual(endDate) + : currentDate.lessThanOrEqual(endDate); + } + return isReversed + ? currentDate.greaterThan(endDate) + : currentDate.lessThan(endDate); + }; + + while (shouldContinue()) { + yield currentDate; + currentDate = currentDate.add(unit, step); + } +} + +export function* generateMonth(value: DayParameter, firstWeekDay: number) { + const { year, month } = toCalendarDay(value); + + const start = new CalendarDay({ year, month }); + const offset = modulo(start.day - firstWeekDay, daysInWeek); + yield* calendarRange({ + start: start.add("day", -offset), + end: 42, + }); +} + +export function getYearRange(current: DayParameter, range: number) { + const year = toCalendarDay(current).year; + const start = Math.floor(year / range) * range; + return { start, end: start + range - 1 }; +} + +export function isDateInRanges( + date: DayParameter, + ranges: DateRangeDescriptor[], +) { + const value = toCalendarDay(date); + + return ranges.some((range) => { + const days = (range.dateRange ?? []).map((day) => toCalendarDay(day)); + + switch (range.type) { + case DateRangeType.After: + return value.greaterThan(first(days)); + + case DateRangeType.Before: + return value.lessThan(first(days)); + + case DateRangeType.Between: { + const min = Math.min( + first(days).timestamp, + last(days).timestamp, + ); + const max = Math.max( + first(days).timestamp, + last(days).timestamp, + ); + return value.timestamp >= min && value.timestamp <= max; + } + + case DateRangeType.Specific: + return days.some((day) => day.equalTo(value)); + + case DateRangeType.Weekdays: + return !value.weekend; + + case DateRangeType.Weekends: + return value.weekend; + + default: + return false; + } + }); +} + +export function formatToParts( + date: Date, + locale: string, + options: Intl.DateTimeFormatOptions, + parts: string[], +): Record { + const formatter = new Intl.DateTimeFormat(locale, options); + const result: Record = { + date, + full: formatter.format(date), + }; + + const getFormattedPart = ( + formattedParts: Intl.DateTimeFormatPart[], + partType: string, + ): IFormattedParts => { + const part = formattedParts.find(({ type }) => type === partType); + const nextPart = formattedParts[formattedParts.indexOf(part) + 1]; + const value = part?.value || ""; + const literal = nextPart?.type === "literal" ? nextPart.value : ""; + return { + value, + literal, + combined: value + literal, + }; + }; + + if ("formatToParts" in formatter) { + const formattedParts = formatter.formatToParts(date); + parts.forEach( + (part) => (result[part] = getFormattedPart(formattedParts, part)), + ); + } else { + parts.forEach( + (part) => (result[part] = { value: "", literal: "", combined: "" }), + ); + } + + return result; +} diff --git a/projects/igniteui-angular/core/src/date-common/util/model.spec.ts b/projects/igniteui-angular/core/src/date-common/util/model.spec.ts new file mode 100644 index 00000000000..53c4d69403e --- /dev/null +++ b/projects/igniteui-angular/core/src/date-common/util/model.spec.ts @@ -0,0 +1,319 @@ +import { DateRangeType } from '../../core/dates/dateRange'; +import { calendarRange, isDateInRanges } from "./helpers"; +import { CalendarDay } from "./model"; + +export function first(arr: T[]) { + return arr.at(0) as T; +} + +export function last(arr: T[]) { + return arr.at(-1) as T; +} + +describe("Calendar Day Model", () => { + let start = new CalendarDay({ year: 1987, month: 6, date: 17 }); + + describe("Basic API", () => { + const firstOfJan = new CalendarDay({ year: 2024, month: 0, date: 1 }); + + it("has correct properties", () => { + const { year, month, date } = firstOfJan; + expect([year, month, date]).toEqual([2024, 0, 1]); + + // First week of 2024 (ISO 8601 - January 1, 2024 is Monday, so Week 1) + expect(firstOfJan.week).toEqual(1); + + // Test week numbering with different week starts + expect(firstOfJan.getWeekNumber(1)).toEqual(1); // Monday start (ISO 8601) + expect(firstOfJan.getWeekNumber(0)).toBeGreaterThan(50); // Sunday start (belongs to prev year) + + // 2024/01/01 is a Monday + expect(firstOfJan.day).toEqual(1); + expect(firstOfJan.weekend).toBeFalse(); + }); + + it("comparators", () => { + const today = CalendarDay.today; + + expect(today.greaterThan(firstOfJan)).toBeTrue(); + expect(firstOfJan.lessThan(today)).toBeTrue(); + expect(today.equalTo(new Date(Date.now()))); + }); + + describe("Deltas", () => { + it("day", () => { + expect(firstOfJan.add("day", 0).equalTo(firstOfJan)).toBeTrue(); + expect(firstOfJan.add("day", 1).greaterThan(firstOfJan)).toBeTrue(); + expect(firstOfJan.add("day", -1).lessThan(firstOfJan)).toBeTrue(); + }); + + it("quarters", () => { + expect(firstOfJan.add("quarter", 0).equalTo(firstOfJan)).toBeTrue(); + const nextQ = firstOfJan.add("quarter", 1); + expect(nextQ.month).toEqual(3); + const prevQ = firstOfJan.add("quarter", -1); + expect(prevQ.year).toEqual(2023); + expect(prevQ.month).toEqual(9); + }); + }); + + it("`replace` correctly takes into account invalid time shifts", () => { + const leapFebruary = new CalendarDay({ + year: 2024, + month: 1, + date: 29, + }); + const nonLeapFebruary = leapFebruary.set({ year: 2023 }); + let { year, month, date } = nonLeapFebruary; + + // Shift to first day of next month -> 2024/03/01 + expect([year, month, date]).toEqual([2023, 2, 1]); + + const lastDayOfJuly = new CalendarDay({ + year: 2024, + month: 6, + date: 31, + }); + + const lastDayOfApril = lastDayOfJuly.set({ month: 3 }); + ({ year, month, date } = lastDayOfApril); + + // April does not have 31 days so shift to first day of May + expect([year, month, date]).toEqual([2024, 4, 1]); + }); + }); + + describe("Week numbering", () => { + it("should use ISO 8601 for Monday start and simple counting for others", () => { + // January 1, 2025 is a Wednesday + const jan1_2025 = new CalendarDay({ year: 2025, month: 0, date: 1 }); + expect(jan1_2025.day).toEqual(3); // Wednesday + + // Monday start: Uses ISO 8601 standard + expect(jan1_2025.getWeekNumber(1)).toEqual(1); // Week 1 contains Jan 1 + + // Sunday start: Uses simple counting - Jan 1 (Wed) belongs to prev year + expect(jan1_2025.getWeekNumber(0)).toBeGreaterThan(50); // Week 52 of 2024 + }); + + it("should handle ISO 8601 year boundaries for Monday start", () => { + // January 1, 2026 is a Thursday + const jan1_2026 = new CalendarDay({ year: 2026, month: 0, date: 1 }); + expect(jan1_2026.day).toEqual(4); // Thursday + + // Monday start: ISO 8601 logic applies + expect(jan1_2026.getWeekNumber(1)).toEqual(1); // Week 1 of 2026 + }); + + it("should handle previous year's last week for Monday start", () => { + // January 1, 2027 is a Friday + const jan1_2027 = new CalendarDay({ year: 2027, month: 0, date: 1 }); + expect(jan1_2027.day).toEqual(5); // Friday + + // Monday start: ISO 8601 logic - belongs to previous year + const actualWeek = jan1_2027.getWeekNumber(1); + expect(actualWeek).toBeGreaterThan(50); // Should be Week 52 or 53 of 2026 + }); + + it("should work correctly with custom week starts using appropriate logic", () => { + const testDate = new CalendarDay({ year: 2024, month: 2, date: 15 }); // March 15, 2024 (Friday) + + // Test different week start days + const mondayStart = testDate.getWeekNumber(1); // ISO 8601 + const tuesdayStart = testDate.getWeekNumber(2); // Simple counting + const wednesdayStart = testDate.getWeekNumber(3); // Simple counting + const thursdayStart = testDate.getWeekNumber(4); // Simple counting + const fridayStart = testDate.getWeekNumber(5); // Simple counting + const saturdayStart = testDate.getWeekNumber(6); // Simple counting + const sundayStart = testDate.getWeekNumber(0); // Simple counting + + // All should be valid week numbers (positive integers) + expect(mondayStart).toBeGreaterThan(0); + expect(tuesdayStart).toBeGreaterThan(0); + expect(wednesdayStart).toBeGreaterThan(0); + expect(thursdayStart).toBeGreaterThan(0); + expect(fridayStart).toBeGreaterThan(0); + expect(saturdayStart).toBeGreaterThan(0); + expect(sundayStart).toBeGreaterThan(0); + }); + + it("should apply ISO 8601 logic only for Monday start", () => { + // January 4, 2024 is a Thursday - always Week 1 in ISO 8601 + const jan4_2024 = new CalendarDay({ year: 2024, month: 0, date: 4 }); + expect(jan4_2024.day).toEqual(4); // Thursday + + // Only Monday start uses ISO 8601 + expect(jan4_2024.getWeekNumber(1)).toEqual(1); // Monday start: ISO 8601 + + // Other starts use simple counting, so results may vary + const sundayWeek = jan4_2024.getWeekNumber(0); + const tuesdayWeek = jan4_2024.getWeekNumber(2); + expect(sundayWeek).toBeGreaterThan(0); + expect(tuesdayWeek).toBeGreaterThan(0); + }); + + it("should handle December dates that belong to next year's Week 1 for Monday start", () => { + // December 30, 2024 is a Monday + const dec30_2024 = new CalendarDay({ year: 2024, month: 11, date: 30 }); + expect(dec30_2024.day).toEqual(1); // Monday + + // Monday start: This should be Week 1 of 2025 in ISO 8601 + expect(dec30_2024.getWeekNumber(1)).toEqual(1); // Week 1 of 2025 + }); + + it("should default to Monday start when no parameter provided", () => { + const testDate = new CalendarDay({ year: 2024, month: 0, date: 1 }); + + // Should default to Monday start (ISO 8601 standard) + expect(testDate.getWeekNumber()).toEqual(testDate.getWeekNumber(1)); + expect(testDate.week).toEqual(testDate.getWeekNumber(1)); + }); + + it("should handle leap years correctly", () => { + // Test February 29, 2024 (leap year) + const feb29_2024 = new CalendarDay({ year: 2024, month: 1, date: 29 }); + expect(feb29_2024.day).toEqual(4); // Thursday + + // Should calculate week number correctly for leap year date + const weekNumber = feb29_2024.getWeekNumber(1); + expect(weekNumber).toBeGreaterThan(0); + expect(weekNumber).toBeLessThan(54); // Valid week range + }); + + it("should correctly handle the January 2024 Sunday start case", () => { + // January 1, 2024 is a Monday, with Sunday start (0) + const jan1_2024 = new CalendarDay({ year: 2024, month: 0, date: 1 }); + const jan7_2024 = new CalendarDay({ year: 2024, month: 0, date: 7 }); // Sunday + const jan8_2024 = new CalendarDay({ year: 2024, month: 0, date: 8 }); // Monday + + expect(jan1_2024.day).toEqual(1); // Monday + expect(jan7_2024.day).toEqual(0); // Sunday + expect(jan8_2024.day).toEqual(1); // Monday + + // With Sunday start, Jan 1 should be in previous year's last week + expect(jan1_2024.getWeekNumber(0)).toBeGreaterThan(50); // Week 53 of 2023 + + // Jan 7 (first Sunday) should be Week 1 + expect(jan7_2024.getWeekNumber(0)).toEqual(1); + + // Jan 8 should also be Week 1 (same week as Jan 7) + expect(jan8_2024.getWeekNumber(0)).toEqual(1); + }); + }); + + describe("Date ranges", () => { + start = new CalendarDay({ year: 2024, month: 0, date: 11 }); + const endFuture = start.add("day", 7); + const endPast = start.add("day", -7); + const end = 7; + + it("generating date ranges (positive number)", () => { + const weekFuture = Array.from(calendarRange({ start, end })); + + expect(weekFuture.length).toEqual(end); + + expect(first(weekFuture).date).toEqual(start.date); + expect(last(weekFuture).date).toEqual(endFuture.date - 1); + }); + + it("generating date ranges (negative number)", () => { + const weekPast = Array.from(calendarRange({ start, end: -end })); + + expect(weekPast.length).toEqual(end); + + expect(first(weekPast).date).toEqual(start.date); + expect(last(weekPast).date).toEqual(endPast.date + 1); + }); + + it("generating date ranges (end > start)", () => { + const weekFuture = Array.from( + calendarRange({ start, end: endFuture }), + ); + + expect(weekFuture.length).toEqual(end); + + expect(first(weekFuture).date).toEqual(start.date); + expect(last(weekFuture).date).toEqual(endFuture.date - 1); + }); + + it("generating date ranges (end < start)", () => { + const weekPast = Array.from(calendarRange({ start, end: endPast })); + + expect(weekPast.length).toEqual(end); + + expect(first(weekPast).date).toEqual(start.date); + expect(last(weekPast).date).toEqual(endPast.date + 1); + }); + }); + + describe("Month generation", () => { + it("works", () => { + // const old = new Calendar(0); + // const oldMonth = old.monthdates(1987, 6, true); + // const newMonth = Array.from(generateFullMonth(start, 0)); + }); + }); + + describe("DateRange descriptors", () => { + const dayBefore = start.add("day", -1).native; + const dayAfter = start.add("day", 1).native; + const [begin, end] = [ + start.add("week", -1).native, + start.add("week", 1).native, + ]; + + it("After", () => { + expect( + isDateInRanges(start, [ + { type: DateRangeType.After, dateRange: [dayBefore] }, + ]), + ).toBeTrue(); + }); + + it("Before", () => { + expect( + isDateInRanges(start, [ + { type: DateRangeType.Before, dateRange: [dayAfter] }, + ]), + ).toBeTrue(); + }); + + it("Between", () => { + expect( + isDateInRanges(start, [ + { + type: DateRangeType.Between, + dateRange: [begin, end], + }, + ]), + ).toBeTrue(); + }); + + it("Specific", () => { + expect( + isDateInRanges(start, [ + { + type: DateRangeType.Specific, + dateRange: [], + }, + ]), + ).toBeFalse(); + }); + + it("Weekday", () => { + expect( + isDateInRanges(start, [{ type: DateRangeType.Weekdays }]), + ).toBeTrue(); + }); + + it("Weekends", () => { + expect( + isDateInRanges(start, [ + { + type: DateRangeType.Weekends, + }, + ]), + ).toBeFalse(); + }); + }); +}); diff --git a/projects/igniteui-angular/core/src/date-common/util/model.ts b/projects/igniteui-angular/core/src/date-common/util/model.ts new file mode 100644 index 00000000000..aac776d92ed --- /dev/null +++ b/projects/igniteui-angular/core/src/date-common/util/model.ts @@ -0,0 +1,256 @@ +import { isDate } from '../../core/utils'; + +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +export type DayParameter = CalendarDay | Date; + +export type CalendarRangeParams = { + start: DayParameter; + end: DayParameter | number; + unit?: DayInterval; + inclusive?: boolean; +}; + +type CalendarDayParams = { + year: number; + month: number; + date?: number; +}; + +export type DayInterval = "year" | "quarter" | "month" | "week" | "day"; + +export const daysInWeek = 7; +const millisecondsInDay = 86400000; + +export function toCalendarDay(date: DayParameter) { + return isDate(date) ? CalendarDay.from(date) : date; +} + +function checkRollover(original: CalendarDay, modified: CalendarDay) { + return original.date !== modified.date + ? modified.set({ date: 0 }) + : modified; +} + +export class CalendarDay { + private _date!: Date; + + /** Constructs and returns the current day. */ + public static get today() { + return CalendarDay.from(new Date()); + } + + /** Constructs a new CalendarDay instance from a Date object. */ + public static from(date: Date) { + return new CalendarDay({ + year: date.getFullYear(), + month: date.getMonth(), + date: date.getDate(), + }); + } + + constructor(args: CalendarDayParams) { + this._date = new Date(args.year, args.month, args.date ?? 1); + } + + /** Returns a copy of this instance. */ + public clone() { + return CalendarDay.from(this._date); + } + + /** + * Returns a new instance with values replaced. + */ + public set(args: Partial) { + return new CalendarDay({ + year: args.year ?? this.year, + month: args.month ?? this.month, + date: args.date ?? this.date, + }); + } + + public add(unit: DayInterval, value: number) { + const result = this.clone(); + switch (unit) { + case "year": + result._date.setFullYear(result.year + value); + return checkRollover(this, result); + case "quarter": + result._date.setMonth(result.month + 3 * value); + return checkRollover(this, result); + case "month": + result._date.setMonth(result.month + value); + return checkRollover(this, result); + case "week": + result._date.setDate(result.date + 7 * value); + return result; + case "day": + result._date.setDate(result.date + value); + return result; + default: + throw new Error("Invalid interval"); + } + } + + /** Returns the day of the week (Sunday = 0). */ + public get day() { + return this._date.getDay(); + } + + /** Returns the full year. */ + public get year() { + return this._date.getFullYear(); + } + + /** Returns the month. */ + public get month() { + return this._date.getMonth(); + } + + /** Returns the date */ + public get date() { + return this._date.getDate(); + } + + /** Returns the timestamp since epoch in milliseconds. */ + public get timestamp() { + return this._date.getTime(); + } + + /** Returns the ISO 8601 week number. */ + public get week() { + return this.getWeekNumber(); + } + + /** + * Gets the week number based on week start day. + * Uses ISO 8601 (first Thursday rule) only when weekStart is Monday (1). + * For other week starts, uses simple counting from January 1st. + */ + public getWeekNumber(weekStart: number = 1): number { + if (weekStart === 1) { + return this.calculateISO8601WeekNumber(); + } else { + return this.calculateSimpleWeekNumber(weekStart); + } + } + + /** + * Calculates week number using ISO 8601 standard (Monday start, first Thursday rule). + */ + private calculateISO8601WeekNumber(): number { + const currentThursday = this.getThursdayOfWeek(); + const firstWeekThursday = this.getFirstWeekThursday(currentThursday.year); + + const weeksDifference = this.getWeeksDifference(currentThursday, firstWeekThursday); + const weekNumber = weeksDifference + 1; + + // Handle dates that belong to the previous year's last week + if (weekNumber <= 0) { + return this.getPreviousYearLastWeek(currentThursday.year - 1); + } + + return weekNumber; + } + + /** + * Calculates week number using simple counting from January 1st. + */ + private calculateSimpleWeekNumber(weekStart: number): number { + const yearStart = new CalendarDay({ year: this.year, month: 0, date: 1 }); + const yearStartDay = yearStart.day; + + const daysUntilFirstWeek = (weekStart - yearStartDay + 7) % 7; + + if (daysUntilFirstWeek > 0) { + const firstWeekStart = yearStart.add('day', daysUntilFirstWeek); + + if (this.timestamp < firstWeekStart.timestamp) { + const prevYear = this.year - 1; + const prevYearDec31 = new CalendarDay({ year: prevYear, month: 11, date: 31 }); + return prevYearDec31.calculateSimpleWeekNumber(weekStart); + } + + const daysSinceFirstWeek = Math.floor((this.timestamp - firstWeekStart.timestamp) / millisecondsInDay); + return Math.floor(daysSinceFirstWeek / 7) + 1; + } else { + const daysSinceYearStart = Math.floor((this.timestamp - yearStart.timestamp) / millisecondsInDay); + return Math.floor(daysSinceYearStart / 7) + 1; + } + } + + /** + * Gets the Thursday of the current date's week (ISO 8601 helper). + */ + private getThursdayOfWeek(): CalendarDay { + const dayOffset = (this.day - 1 + 7) % 7; // Monday start + const thursdayOffset = 3; // Thursday is 3 days from Monday + return this.add('day', thursdayOffset - dayOffset); + } + + /** + * Gets the Thursday of the first week of the given year (ISO 8601 helper). + */ + private getFirstWeekThursday(year: number): CalendarDay { + const january4th = new CalendarDay({ year, month: 0, date: 4 }); + const dayOffset = (january4th.day - 1 + 7) % 7; // Monday start + const thursdayOffset = 3; // Thursday is 3 days from Monday + return january4th.add('day', thursdayOffset - dayOffset); + } + + /** + * Calculates the number of weeks between two Thursday dates (ISO 8601 helper). + */ + private getWeeksDifference(currentThursday: CalendarDay, firstWeekThursday: CalendarDay): number { + const daysDifference = Math.floor((currentThursday.timestamp - firstWeekThursday.timestamp) / millisecondsInDay); + return Math.floor(daysDifference / 7); + } + + /** + * Gets the last week number of the previous year (ISO 8601 helper). + */ + private getPreviousYearLastWeek(previousYear: number): number { + const december31st = new CalendarDay({ year: previousYear, month: 11, date: 31 }); + const lastWeekThursday = december31st.getThursdayOfWeek(); + const firstWeekThursday = this.getFirstWeekThursday(previousYear); + + return this.getWeeksDifference(lastWeekThursday, firstWeekThursday) + 1; + } + + /** Returns the underlying native date instance. */ + public get native() { + return new Date(this._date); + } + + /** + * Whether the current date is a weekend day. + * + * @remarks + * This is naive, since it does not account for locale specifics. + */ + public get weekend() { + return this.day < 1 || this.day > 5; + } + + public equalTo(value: DayParameter) { + return this.timestamp === toCalendarDay(value).timestamp; + } + + public greaterThan(value: DayParameter) { + return this.timestamp > toCalendarDay(value).timestamp; + } + public greaterThanOrEqual(value: DayParameter) { + return this.timestamp >= toCalendarDay(value).timestamp; + } + + public lessThan(value: DayParameter) { + return this.timestamp < toCalendarDay(value).timestamp; + } + + public lessThanOrEqual(value: DayParameter) { + return this.timestamp <= toCalendarDay(value).timestamp; + } + + public toString() { + return `${this.native}`; + } +} diff --git a/projects/igniteui-angular/core/src/grid-column-actions/token.ts b/projects/igniteui-angular/core/src/grid-column-actions/token.ts new file mode 100644 index 00000000000..a96afa26ce5 --- /dev/null +++ b/projects/igniteui-angular/core/src/grid-column-actions/token.ts @@ -0,0 +1,26 @@ +import { ChangeDetectorRef, QueryList } from '@angular/core'; +import { OverlaySettings } from '../services/overlay/utilities'; + +/* csSuppress */ +/** @hidden @internal */ +export abstract class IgxActionStripToken { + public abstract cdr: ChangeDetectorRef + public abstract context: any; + public abstract menuOverlaySettings: OverlaySettings; + public abstract get hideOnRowLeave(): boolean; + + public abstract show(context?: any): void; + public abstract hide(): void; +} + +/* csSuppress */ +/** + * Abstract class defining the contract for components that provide actions to the action strip. + * This allows the action strip to remain standalone and not be aware of specific implementations. + * @hidden @internal + */ +export abstract class IgxActionStripActionsToken { + public abstract asMenuItems: boolean; + public abstract buttons: QueryList; + public abstract strip: any | null; +} diff --git a/projects/igniteui-angular/core/src/performance.service.ts b/projects/igniteui-angular/core/src/performance.service.ts new file mode 100644 index 00000000000..e28c897424e --- /dev/null +++ b/projects/igniteui-angular/core/src/performance.service.ts @@ -0,0 +1,124 @@ +/* eslint-disable no-console */ +import { inject, Injectable, NgZone, isDevMode } from '@angular/core'; + + +interface igcPerformance { + startMeasure: typeof startMeasure; + getMeasures: typeof getMeasures; + clearMeasures: typeof clearMeasures, + clearAll: typeof clearAll +} + +declare global { + var $$igcPerformance: igcPerformance; +} + +function isInstrumented(): boolean { + return globalThis.performance && performance.measure && isDevMode(); +} + +function instrumentGlobalHelpers(): void { + if (!isInstrumented() || Object.hasOwn(globalThis, '$$igcPerformance')) { + return; + } + + globalThis.$$igcPerformance = { + startMeasure, + getMeasures, + clearMeasures, + clearAll, + }; + + console.debug('Performance helper functions attached @ `global.$$igcPerformance`'); + +} + +export function startMeasure(name: string, withLogging = false) { + if (!isInstrumented()) return () => { }; + + const startMark = `${name}:start`; + const endMark = `${name}:end`; + + performance.mark(startMark); + + return () => { + performance.mark(endMark); + performance.measure(name, startMark, endMark); + if (withLogging) { + const entry = performance.getEntriesByName(name).at(-1); + console.debug(`Performance Measure : ${entry.name} - Duration: ${entry.duration.toFixed(2)}ms`); + } + }; +} + +export function getMeasures(name?: string): PerformanceEntryList { + return name ? performance.getEntriesByName(name) : performance.getEntriesByType('measure'); +} + +export function clearMeasures(name?: string, withLogging = false): void { + performance.clearMeasures(name); + if (withLogging) { + console.debug(name ? 'Cleared all measures of type `${name}`' : 'Cleared all custom measures'); + } +} + +export function clearAll(withLogging = false): void { + performance.clearMarks(); + clearMeasures(); + if (withLogging) { + console.debug('Cleared all marks and custom measures'); + } +} + +@Injectable({ providedIn: 'root' }) +export class PerformanceService { + private readonly _ngZone = inject(NgZone); + private _logEnabled = false; + + constructor() { + instrumentGlobalHelpers(); + } + + public setLogEnabled(state: boolean): void { + this._logEnabled = state; + } + + public start(name: string) { + return startMeasure(name, this._logEnabled); + } + + public getMeasures(name?: string): PerformanceEntryList { + return getMeasures(name); + } + + public clearMeasures(name?: string): void { + clearMeasures(name, this._logEnabled); + } + + public clearAll(): void { + clearAll(this._logEnabled); + } + + public attachObserver(options?: PerformanceObserverInit) { + if (!isInstrumented()) return; + let observer: PerformanceObserver; + + options = options ?? { entryTypes: ['event', 'long-animation-frame', 'longtask', 'taskattribution'] }; + + this._ngZone.runOutsideAngular(() => { + observer = new PerformanceObserver((list) => { + if (this._logEnabled) { + for (const entry of list.getEntries()) { + console.debug(`Performance Entry: ${entry.name} (${entry.entryType}) - Duration: ${entry.duration.toFixed(2)}ms`); + } + } + }); + + observer.observe(options); + }); + + return () => { + observer.disconnect(); + }; + } +} diff --git a/projects/igniteui-angular/core/src/public_api.ts b/projects/igniteui-angular/core/src/public_api.ts new file mode 100644 index 00000000000..c76d6e8374d --- /dev/null +++ b/projects/igniteui-angular/core/src/public_api.ts @@ -0,0 +1,65 @@ +// Core utilities +export * from './core/navigation'; +export * from './core/dates'; +export { WEEKDAYS, Size as ɵSize } from './core/enums'; +export * from './core/utils'; +export * from './core/types'; +export * from './core/selection'; +export * from './core/edit-provider'; +export * from './core/touch'; +export * from './core/touch-annotations'; + +// Grid actions tokens +export * from './grid-column-actions/token'; + +// Date common +export * from './date-common/public_api'; + +// Data operations +export * from './data-operations/data-clone-strategy'; +export * from './data-operations/filtering-expression.interface'; +export * from './data-operations/filtering-expressions-tree'; +export * from './data-operations/filtering-condition'; +export * from './data-operations/filtering-state.interface'; +export * from './data-operations/filtering-strategy'; +export * from './data-operations/tree-grid-filtering-strategy'; +export * from './data-operations/merge-strategy'; +export * from './data-operations/expressions-tree-util'; +export * from './data-operations/groupby-expand-state.interface'; +export * from './data-operations/groupby-record.interface'; +export * from './data-operations/groupby-state.interface'; +export * from './data-operations/grouping-result.interface'; +export * from './data-operations/grouping-expression.interface'; +export * from './data-operations/sorting-strategy'; +export * from './data-operations/grid-sorting-strategy'; +export * from './data-operations/paging-state.interface'; +export * from './data-operations/data-util'; +export * from './data-operations/grid-types'; +export * from './data-operations/operations'; + +// Services +export * from './services/public_api'; + +// Performance service +export * from './performance.service'; + +// i18n +export * from './core/i18n/action-strip-resources'; +export * from './core/i18n/banner-resources'; +export * from './core/i18n/calendar-resources'; +export * from './core/i18n/carousel-resources'; +export * from './core/i18n/chip-resources'; +export * from './core/i18n/combo-resources'; +export * from './core/i18n/date-picker-resources'; +export * from './core/i18n/date-range-picker-resources'; +export * from './core/i18n/grid-resources'; +export * from './core/i18n/input-resources'; +export * from './core/i18n/list-resources'; +export * from './core/i18n/paginator-resources'; +export * from './core/i18n/query-builder-resources'; +export * from './core/i18n/resources'; +export * from './core/i18n/time-picker-resources'; +export * from './core/i18n/tree-resources'; + +// Types +export * from './core/global-types'; diff --git a/projects/igniteui-angular/core/src/services/animation/angular-animation-player.ts b/projects/igniteui-angular/core/src/services/animation/angular-animation-player.ts new file mode 100644 index 00000000000..6a568814688 --- /dev/null +++ b/projects/igniteui-angular/core/src/services/animation/angular-animation-player.ts @@ -0,0 +1,62 @@ +import { AnimationPlayer as AngularAnimationPlayer } from '@angular/animations'; +import { EventEmitter } from '@angular/core'; +import { IBaseEventArgs } from '../../core/utils'; +import { AnimationPlayer } from './animation'; + +export class IgxAngularAnimationPlayer implements AnimationPlayer { + private _innerPlayer: AngularAnimationPlayer; + public animationStart: EventEmitter = new EventEmitter(); + public animationEnd: EventEmitter = new EventEmitter(); + + public get position(): number { + return this._innerPlayer.getPosition(); + } + + public set position(value: number) { + this.internalPlayer.setPosition(value); + } + + constructor(private internalPlayer: AngularAnimationPlayer) { + this.internalPlayer.onDone(() => this.onDone()); + const innerRenderer = (this.internalPlayer as any)._renderer; + // We need inner player as Angular.AnimationPlayer.getPosition returns always 0. + // To workaround this we are getting the positions from the inner player. + // This is logged in Angular here - https://github.com/angular/angular/issues/18891 + // As soon as this is resolved we can remove this hack + const rendererEngine = innerRenderer.engine || innerRenderer.delegate.engine; + // A workaround because of Angular SSR is using some delegation. + this._innerPlayer = rendererEngine.players[rendererEngine.players.length - 1]; + } + + public init(): void { + this.internalPlayer.init(); + } + + public play(): void { + this.animationStart.emit({ owner: this }); + this.internalPlayer.play(); + } + + public finish(): void { + this.internalPlayer.finish(); + // TODO: when animation finish angular deletes all onDone handlers. Add handlers again if needed + } + + public reset(): void { + this.internalPlayer.reset(); + // calling reset does not change hasStarted to false. This is why we are doing it here via internal field + (this.internalPlayer as any)._started = false; + } + + public destroy(): void { + this.internalPlayer.destroy(); + } + + public hasStarted(): boolean { + return this.internalPlayer.hasStarted(); + } + + private onDone(): void { + this.animationEnd.emit({ owner: this }); + } +} diff --git a/projects/igniteui-angular/core/src/services/animation/angular-animation-service.ts b/projects/igniteui-angular/core/src/services/animation/angular-animation-service.ts new file mode 100644 index 00000000000..042a9609965 --- /dev/null +++ b/projects/igniteui-angular/core/src/services/animation/angular-animation-service.ts @@ -0,0 +1,18 @@ +import { AnimationBuilder, AnimationReferenceMetadata } from '@angular/animations'; +import { Injectable, inject } from '@angular/core'; +import { IgxAngularAnimationPlayer } from './angular-animation-player'; +import { AnimationService, AnimationPlayer } from './animation'; + +@Injectable({providedIn: 'root'}) +export class IgxAngularAnimationService implements AnimationService { + private builder = inject(AnimationBuilder); + + public buildAnimation(animationMetaData: AnimationReferenceMetadata, element: HTMLElement): AnimationPlayer { + if (!animationMetaData) { + return null; + } + const animationBuilder = this.builder.build(animationMetaData); + const player = new IgxAngularAnimationPlayer(animationBuilder.create(element)); + return player; + } +} diff --git a/projects/igniteui-angular/core/src/services/animation/animation.ts b/projects/igniteui-angular/core/src/services/animation/animation.ts new file mode 100644 index 00000000000..b03e507d7c8 --- /dev/null +++ b/projects/igniteui-angular/core/src/services/animation/animation.ts @@ -0,0 +1,52 @@ +import { AnimationReferenceMetadata } from '@angular/animations'; +import { EventEmitter } from '@angular/core'; +import { IBaseEventArgs } from '../../core/utils'; + +export interface AnimationService { + /** + * Creates an `AnimationPlayer` instance + * @param animation A set of options describing the animation + * @param element The DOM element on which animation will be applied + * @returns AnimationPlayer + */ + buildAnimation: (animationMetaData: AnimationReferenceMetadata, element: HTMLElement) => AnimationPlayer +} + +export interface AnimationPlayer { + /** + * Emits when the animation starts + */ + animationStart: EventEmitter; + /** + * Emits when the animation ends + */ + animationEnd: EventEmitter; + /** + * Current position of the animation. + */ + position: number; + /** + * Initialize the animation + */ + init(): void; + /** + * Runs the animation + */ + play(): void; + /** + * Ends the animation + */ + finish(): void; + /** + * Resets the animation to its initial state + */ + reset(): void; + /** + * Destroys the animation. + */ + destroy(): void; + /** + * Reports whether the animation has started. + */ + hasStarted(): boolean; +} diff --git a/projects/igniteui-angular/core/src/services/direction/directionality.spec.ts b/projects/igniteui-angular/core/src/services/direction/directionality.spec.ts new file mode 100644 index 00000000000..ad29ace4c56 --- /dev/null +++ b/projects/igniteui-angular/core/src/services/direction/directionality.spec.ts @@ -0,0 +1,96 @@ +import { TestBed, inject, waitForAsync } from '@angular/core/testing'; +import { Component, DOCUMENT, inject as inject_1 } from '@angular/core'; +import { IgxDirectionality, DIR_DOCUMENT } from './directionality'; + +interface FakeDoc { + body: { dir?: string }; + documentElement: { dir?: string }; +} + +describe('IgxDirectionality', () => { + describe('DI', () => { + beforeEach(waitForAsync(() => + TestBed.configureTestingModule({ + imports: [InjectsIgxDirectionalityComponent] + }).compileComponents() + )); + + it('should inject the document through the injectionToken properly', () => { + const injectionToken = TestBed.inject(DIR_DOCUMENT); + const document = TestBed.inject(DOCUMENT); + + expect(injectionToken).toEqual(document); + expect(injectionToken).toEqual(jasmine.any(Document)); + expect(document).toBeTruthy(jasmine.any(Document)); + }); + + it('should read dir from html if not specified on the body', inject([DOCUMENT], () => { + const fixture = TestBed.createComponent(InjectsIgxDirectionalityComponent); + const component = fixture.debugElement.componentInstance; + + expect(component.dir.document).not.toBeNull(); + expect(component.dir.document).not.toBeUndefined(); + expect(component.dir.document).toEqual(jasmine.any(Document)); + })); + + }); + + describe('RLT, LTR', () => { + let fakeDoc: FakeDoc; + + let expectedRes: string; + let dirInstance: IgxDirectionality; + + beforeEach(() => { + fakeDoc = { body: {}, documentElement: {} }; + + TestBed.configureTestingModule({ + providers: [ + { provide: DOCUMENT, useValue: fakeDoc }, + IgxDirectionality + ] + }); + }); + it('should read dir from html if not specified on the body', () => { + expectedRes = 'rtl'; + fakeDoc.documentElement.dir = expectedRes; + + dirInstance = TestBed.inject(IgxDirectionality); + expect(dirInstance.value).toEqual(expectedRes); + }); + it('should read dir from body even it is also specified on the html element', () => { + fakeDoc.documentElement.dir = 'ltr'; + expectedRes = 'rtl'; + fakeDoc.body.dir = expectedRes; + + dirInstance = TestBed.inject(IgxDirectionality); + expect(dirInstance.value).toEqual(expectedRes); + }); + + it('should default to ltr if nothing specified', () => { + expectedRes = 'ltr'; + + dirInstance = TestBed.inject(IgxDirectionality); + expect(dirInstance.value).toEqual(expectedRes); + }); + + it('should default to ltr if invalid values are set both on body or html elements', () => { + fakeDoc.documentElement.dir = 'none'; + fakeDoc.body.dir = 'irrelevant'; + + dirInstance = TestBed.inject(IgxDirectionality); + expect(dirInstance.value).toEqual('ltr'); + }); + }); +}); + +@Component({ + selector: 'igx-div-element', + template: ` +
element
+ `, + standalone: true +}) +class InjectsIgxDirectionalityComponent { + public dir = inject_1(IgxDirectionality); +} diff --git a/projects/igniteui-angular/core/src/services/direction/directionality.ts b/projects/igniteui-angular/core/src/services/direction/directionality.ts new file mode 100644 index 00000000000..6c7726caf40 --- /dev/null +++ b/projects/igniteui-angular/core/src/services/direction/directionality.ts @@ -0,0 +1,65 @@ +import { Injectable, InjectionToken, inject, DOCUMENT } from '@angular/core'; + +/** + * @hidden @internal + */ +export type Direction = 'ltr' | 'rtl'; + +/** + * @hidden + */ +function DIR_DOCUMENT_FACTORY(): Document { + return inject(DOCUMENT); +} + +/** + * Injection token is used to inject the document into Directionality + * which factory could be faked for testing purposes. + * + * We can't provide and mock the DOCUMENT token from platform-browser because configureTestingModule + * allows override of the default providers, directive, pipes, modules of the test injector + * which causes errors. + * + * @hidden @internal + */ +export const DIR_DOCUMENT = /*@__PURE__*/new InjectionToken('dir-doc', { + providedIn: 'root', + factory: DIR_DOCUMENT_FACTORY +}); + +/** + * @hidden @internal + * + * Bidirectional service that extracts the value of the direction attribute on the body or html elements. + * + * The dir attribute over the body element takes precedence. + */ +@Injectable({ + providedIn: 'root' +}) +export class IgxDirectionality { + private _dir: Direction; + private _document: Document; + + public get value(): Direction { + return this._dir; + } + + public get document() { + return this._document; + } + + public get rtl() { + return this._dir === 'rtl'; + } + + constructor() { + const document = inject(DIR_DOCUMENT); + + this._document = document; + const bodyDir = this._document.body ? this._document.body.dir : null; + const htmlDir = this._document.documentElement ? this._document.documentElement.dir : null; + const extractedDir = bodyDir || htmlDir; + this._dir = (extractedDir === 'ltr' || extractedDir === 'rtl') ? extractedDir : 'ltr'; + } +} diff --git a/projects/igniteui-angular/core/src/services/overlay/README.md b/projects/igniteui-angular/core/src/services/overlay/README.md new file mode 100644 index 00000000000..bff069549e8 --- /dev/null +++ b/projects/igniteui-angular/core/src/services/overlay/README.md @@ -0,0 +1,145 @@ +# igx-overlay + +The overlay service allows users to show components on overlay div above all other elements in the page. +A walk through of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/overlay-main) + +## Usage + +### With igxToggleDirective + +```html +
+
+``` +```typescript +@ViewChild(IgxToggleDirective) public igxToggle: IgxToggleDirective; +this.igxToggle.toggle(); +``` +---- + +### Directly through the service + +```typescript +this.overlay.show(component); +``` + +## Getting Started + +### Dependencies + +To use the IgxOverlay import the IgxOverlayService: + +```typescript +import { IgxOverlayService } from "igniteui-angular"; +``` + +Then initialize the overlay settings, only if you need some different from default ones. + +```typescript +overlaySettings: OverlaySettings = { + target: new Point(0, 0), + positionStrategy: new GlobalPositionStrategy(), + scrollStrategy: new NoOpScrollStrategy(), + modal: true, + closeOnOutsideClick: true +}; +``` + +Finally show the component you need with the overlay settings you need: +```typescript +this.overlay.show(component, overlaySettings); +``` + + +## API + +##### Interfaces + +###### IPositionStrategy + +| Name | Type | Description | +| :--- | :--- | :---------- | +| positionSettings | PositionSettings | Settings to apply to this position strategy | + +###### OverlaySettings + +| Name | Type | Description | +| :--- | :--- | :---------- | +| target | Point | HTMLElement | Attaching target for the component to show | +| positionStrategy | IPositionStrategy | Position strategy to use with this settings | +| scrollStrategy | IScrollStrategy | Scroll strategy to use with this settings | +| modal | boolean | Set if the overlay should be in modal mode | +| closeOnOutsideClick | boolean | Set if the overlay should closed on outside click | +| outlet | IgxOverlayOutletDirective or ElementRef | Set the outlet container to attach the overlay to | + +###### PositionSettings + +| Name | Type | Description | +| :--- | :--- | :---------- | +|horizontalDirection | HorizontalAlignment | Direction in which the component should show | +|verticalDirection | VerticalAlignment | Direction in which the component should show | +|horizontalStartPoint| HorizontalAlignment | Target's starting point | +|verticalStartPoint | VerticalAlignment | Target's starting point | +|openAnimation | AnimationMetadata | AnimationMetadata[] | Animation applied while overlay opens | +|closeAnimation | AnimationMetadata | AnimationMetadata[] | Animation applied while overlay closes | +|minSize | Size | The size up to which element may shrink when shown in elastic position strategy | + +###### OverlayCreateSettings extends OverlaySettings + +| Name | Type | Description | +| :--- | :--- | :---------- | +|injector | Injector | An `Injector` instance to add in the created component ref's injectors tree | + +##### Methods + +###### IgxOverlayService + +| Name | Description | Parameters | +|-----------------|---------------------------------------------------------------------------------|-----------------| +|attach | Generates Id. Provide this Id when call `show(id, settings?)` method |element, overlaySettings? | +|attach | Generates Id. Provide this Id when call `show(id, settings?)` method |component, overlayCreateSettings?, | +|attach | Generates Id. Provide this Id when call `show(id, settings?)` method |component, viewContainerRef, overlaySettings? | +|show | Shows the provided component on the overlay |id, overlaySettings?| +|hide | Hides the component with the ID provided as a parameter |id | +|hideAll | Hides all the components and the overlay |- | +|detach | Remove overlay with the provided id |id | +|detachAll | Remove all the overlays |- | +|reposition | Repositions the native element of the component with provided id |id | +|setOffset | Offsets the content along the corresponding axis by the provided amount with optional offsetMode that determines whether to add (by default) or set the offset values with OffsetMode.Add and OffsetMode.Set | id, deltaX, deltaY, offsetMode? | + +###### IPositionStrategy + +| Name | Description | Parameters | +|-----------------|---------------------------------------------------------------------------------|------------| +|position | Positions provided element |element | +|clone | Clones the position strategy and its settings |- | + +###### IScrollStrategy + +| Name | Description | Parameters | +|-----------------|---------------------------------------------------------------------------------|------------| +|initialize | Initialize the strategy. Should be called once |document, overlayService, id| +|attach | Attaches the strategy |- | +|detach | Detaches the strategy |- | + +###### static methods + +| Name | Description | Parameters | +|-----------------|---------------------------------------------------------------------------------|------------| +|getPointFromPositionsSettings| Calculates the point from which the overlay should start showing |settings | +|createAbsoluteOverlaySettings| Creates overlay settings with global or container position strategy based on a preset position settings |position?, outlet?| +|createRelativeOverlaySettings| Creates overlay settings with auto, connected or elastic position strategy based on a preset position settings |target, strategy?, position?| + + +##### Events + +###### IgxOverlayService +| Name | Description | Cancelable | Parameters | +|-------------------|-----------------------------------------------|------------|------------| +| opening | Emitted before overlay shows | true | | +| opened | Emitted after overlay shows | false | | +| closing | Emitted before overlay hides | true | | +| closed | Emitted after overlay hides | false | | +| contentAppending | Emitted before overlay's content is appended | false | | +| contentAppended | Emitted after overlay's content is appended | false | | +| animationStarting | Emitted before animation is started | false | | diff --git a/projects/igniteui-angular/core/src/services/overlay/overlay.spec.ts b/projects/igniteui-angular/core/src/services/overlay/overlay.spec.ts new file mode 100644 index 00000000000..2dd45ea03e2 --- /dev/null +++ b/projects/igniteui-angular/core/src/services/overlay/overlay.spec.ts @@ -0,0 +1,4775 @@ +import { Component, ComponentRef, ElementRef, HostBinding, Injector, ViewChild, ViewContainerRef, ViewEncapsulation, inject } from '@angular/core'; +import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { first } from 'rxjs/operators'; +import { UIInteractions } from '../../../../test-utils/ui-interactions.spec'; +import { IgxAngularAnimationService } from '../animation/angular-animation-service'; +import { IgxOverlayService } from './overlay'; +import { ContainerPositionStrategy } from './position'; +import { AutoPositionStrategy } from './position/auto-position-strategy'; +import { BaseFitPositionStrategy } from './position/base-fit-position-strategy'; +import { ConnectedPositioningStrategy } from './position/connected-positioning-strategy'; +import { ElasticPositionStrategy } from './position/elastic-position-strategy'; +import { GlobalPositionStrategy } from './position/global-position-strategy'; +import { IPositionStrategy } from './position/IPositionStrategy'; +import { AbsoluteScrollStrategy } from './scroll/absolute-scroll-strategy'; +import { BlockScrollStrategy } from './scroll/block-scroll-strategy'; +import { CloseScrollStrategy } from './scroll/close-scroll-strategy'; +import { NoOpScrollStrategy } from './scroll/NoOpScrollStrategy'; +import { + HorizontalAlignment, + IgxOverlayOutletDirective, + OffsetMode, + OverlayCancelableEventArgs, + OverlayEventArgs, + OverlaySettings, + Point, + PositionSettings, + VerticalAlignment +} from './utilities'; +import { scaleInVerTop, scaleOutVerTop } from 'igniteui-angular/animations'; +import { IgxCalendarContainerComponent } from 'igniteui-angular/date-picker'; +import { IgxAvatarComponent } from 'igniteui-angular/avatar'; +import { IgxCalendarComponent } from 'igniteui-angular/calendar'; +import { IgxToggleDirective } from 'igniteui-angular/directives'; +import { PlatformUtil } from 'igniteui-angular'; + +const CLASS_OVERLAY_CONTENT = 'igx-overlay__content'; +const CLASS_OVERLAY_CONTENT_MODAL = 'igx-overlay__content--modal'; +const CLASS_OVERLAY_CONTENT_RELATIVE = 'igx-overlay__content--relative'; +const CLASS_OVERLAY_WRAPPER = 'igx-overlay__wrapper'; +const CLASS_OVERLAY_WRAPPER_MODAL = 'igx-overlay__wrapper--modal'; +const CLASS_OVERLAY_WRAPPER_FLEX = 'igx-overlay__wrapper--flex'; +const CLASS_OVERLAY_MAIN = 'igx-overlay'; +const CLASS_SCROLLABLE_DIV = 'scrollableDiv'; +const DEBOUNCE_TIME = 16; + +// Utility function to get all applied to element css from all sources. +const css = (element) => { + const sheets = document.styleSheets; + const ret = []; + element.matches = + element.matches || + element.webkitMatchesSelector || + element.mozMatchesSelector || + element.msMatchesSelector || + element.oMatchesSelector; + for (const key in sheets) { + if (sheets.hasOwnProperty(key)) { + const sheet = sheets[key]; + const rules: any = sheet.cssRules; + + for (const r in rules) { + if (element.matches(rules[r].selectorText)) { + ret.push(rules[r].cssText); + } + } + } + } + return ret; +}; + +export const addScrollDivToElement = (parent) => { + const scrollDiv = document.createElement('div'); + scrollDiv.style.width = '100px'; + scrollDiv.style.height = '100px'; + scrollDiv.style.top = '10000px'; + scrollDiv.style.left = '10000px'; + scrollDiv.style.position = 'absolute'; + parent.appendChild(scrollDiv); +}; + +/** + * Returns the top left location of the shown element + * + * @param positionSettings Overlay setting to get location for + * @param targetRect Rectangle of overlaySettings.target + * @param wrapperRect Rectangle of shown element + * @param screenRect Rectangle of the visible area + * @param elastic Is elastic position strategy, defaults to false + */ +// const getOverlayWrapperLocation = ( +// positionSettings: PositionSettings, +// targetRect: ClientRect, +// wrapperRect: ClientRect, +// screenRect: ClientRect, +// elastic = false): Point => { +// const location: Point = new Point(0, 0); + +// location.x = +// targetRect.left + +// targetRect.width * (1 + positionSettings.horizontalStartPoint) + +// wrapperRect.width * positionSettings.horizontalDirection; +// if (location.x < screenRect.left) { +// if (elastic) { +// let offset = screenRect.left - location.x; +// if (offset > wrapperRect.width - positionSettings.minSize.width) { +// offset = wrapperRect.width - positionSettings.minSize.width; +// } +// location.x += offset; +// } else { +// const flipOffset = wrapperRect.width * (1 + positionSettings.horizontalDirection); +// if (positionSettings.horizontalStartPoint === HorizontalAlignment.Left) { +// location.x = Math.max(0, targetRect.right - flipOffset); +// } else if (positionSettings.horizontalStartPoint === HorizontalAlignment.Center) { +// location.x = +// Math.max(0, targetRect.left + targetRect.width / 2 - flipOffset); +// } else { +// location.x = Math.max(0, targetRect.left - flipOffset); +// } +// } +// } else if (location.x + wrapperRect.width > screenRect.right && !elastic) { +// const flipOffset = wrapperRect.width * (1 + positionSettings.horizontalDirection); +// if (positionSettings.horizontalStartPoint === HorizontalAlignment.Left) { +// location.x = Math.min(screenRect.right, targetRect.right - flipOffset); +// } else if (positionSettings.horizontalStartPoint === HorizontalAlignment.Center) { +// location.x = Math.min(screenRect.right, targetRect.left + targetRect.width / 2 - flipOffset); +// } else { +// location.x = Math.min(screenRect.right, targetRect.left - flipOffset); +// } +// } + +// location.y = +// targetRect.top + +// targetRect.height * (1 + positionSettings.verticalStartPoint) + +// wrapperRect.height * positionSettings.verticalDirection; +// if (location.y < screenRect.top) { +// if (elastic) { +// let offset = screenRect.top - location.y; +// if (offset > wrapperRect.height - positionSettings.minSize.height) { +// offset = wrapperRect.height - positionSettings.minSize.height; +// } +// location.y += offset; +// } else { +// const flipOffset = wrapperRect.height * (1 + positionSettings.verticalDirection); +// if (positionSettings.verticalStartPoint === VerticalAlignment.Top) { +// location.y = Math.max(0, targetRect.bottom - flipOffset); +// } else if (positionSettings.verticalStartPoint === VerticalAlignment.Middle) { +// location.y = Math.max(0, targetRect.top + targetRect.height / 2 - flipOffset); +// } else { +// location.y = Math.max(0, targetRect.top - flipOffset); +// } +// } +// } else if (location.y + wrapperRect.height > screenRect.bottom && !elastic) { +// const flipOffset = wrapperRect.height * (1 + positionSettings.verticalDirection); +// if (positionSettings.verticalStartPoint === VerticalAlignment.Top) { +// location.y = Math.min(screenRect.bottom, targetRect.bottom - flipOffset); +// } else if (positionSettings.verticalStartPoint === VerticalAlignment.Middle) { +// location.y = Math.min(screenRect.bottom, targetRect.top + targetRect.height / 2 - flipOffset); +// } else { +// location.y = Math.min(screenRect.bottom, targetRect.top - flipOffset); +// } +// } +// return location; +// }; + +/** + * Formats a string according to the given formatters + * + * @param inputString String to be formatted + * @param formatters Each formatter should include regex expressions and replacements to be applied on the inputString + */ +const formatString = (inputString: string, formatters: any[]) => { + formatters.forEach(formatter => inputString = inputString.replace(formatter.pattern, formatter.replacement)); + return inputString; +}; + +describe('igxOverlay', () => { + const formatters = [ + { pattern: /:\s/g, replacement: ':' }, + { pattern: /red;/, replacement: 'red' } + ]; + beforeEach(waitForAsync(() => { + UIInteractions.clearOverlay(); + })); + + afterAll(() => { + UIInteractions.clearOverlay(); + }); + + const verifyOverlayMargins = (overlaySettings: OverlaySettings, overlay: IgxOverlayService, fix, expectedMargin) => { + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(); + fix.detectChanges(); + const overlayWrapper = document.getElementsByClassName(CLASS_OVERLAY_WRAPPER)[0]; + const overlayContent = document.getElementsByClassName(CLASS_OVERLAY_CONTENT)[0]; + const overlayElement = overlayContent.children[0]; + const wrapperMargin = window.getComputedStyle(overlayWrapper, null).getPropertyValue('margin'); + const contentMargin = window.getComputedStyle(overlayContent, null).getPropertyValue('margin'); + const elementMargin = window.getComputedStyle(overlayElement, null).getPropertyValue('margin'); + expect(wrapperMargin).toEqual(expectedMargin); + expect(contentMargin).toEqual(expectedMargin); + expect(elementMargin).toEqual(expectedMargin); + + overlay.detachAll(); + }; + + describe('Pure Unit Test', () => { + let mockElement: any; + let mockElementRef: ElementRef; + let outlet: any; + let mockPlatformUtil: any; + let overlay: IgxOverlayService; + beforeEach(() => { + outlet = document.createElement("div"); + document.body.appendChild(outlet); + + mockElement = document.createElement("div"); + mockElementRef = new ElementRef(mockElement); + + mockPlatformUtil = { isIOS: false }; + + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], + providers: [ + { provide: PlatformUtil, useValue: mockPlatformUtil }, + IgxAngularAnimationService, + IgxOverlayService, + ] + }); + + overlay = TestBed.inject(IgxOverlayService); + }); + afterEach(() => { + outlet.remove(); + overlay.ngOnDestroy(); + }); + + it('Should set cursor to pointer on iOS', () => { + mockPlatformUtil.isIOS = true; + + const mockCursorStyle = { value: 'initialCursorValue' }; + + Object.defineProperty(document.body.style, 'cursor', { + get: () => mockCursorStyle.value, + set: (val: string) => { + mockCursorStyle.value = val; + }, + configurable: true + }); + + const mockOverlaySettings: OverlaySettings = { + modal: false, + positionStrategy: new GlobalPositionStrategy({ openAnimation: null, closeAnimation: null }) + }; + let id = overlay.attach(mockElementRef, mockOverlaySettings); + + overlay.show(id); + expect(document.body.style.cursor).toEqual('pointer'); + + overlay.hide(id); + overlay.detach(id); + expect(document.body.style.cursor).toEqual('initialCursorValue'); + + mockPlatformUtil.isIOS = false; + id = overlay.attach(mockElementRef, mockOverlaySettings); + + overlay.show(id); + expect(document.body.style.cursor).toEqual('initialCursorValue'); + + overlay.hide(id); + overlay.detach(id); + expect(document.body.style.cursor).toEqual('initialCursorValue'); + }); + + it('Should clear listener for escape key when overlay settings have outlet specified', () => { + const addEventSpy = spyOn(document, 'addEventListener').and.callThrough(); + const removeEventSpy = spyOn(document, 'removeEventListener').and.callThrough(); + const hideSpy = spyOn(overlay, 'hide').and.callThrough(); + + const mockOverlaySettings: OverlaySettings = { + modal: false, + closeOnEscape: true, + outlet, + positionStrategy: new GlobalPositionStrategy({ openAnimation: null, closeAnimation: null }) + }; + const id = overlay.attach(mockElementRef, mockOverlaySettings); + + // show the overlay + overlay.show(id); + + // expect escape listener to be added to document + expect(addEventSpy).toHaveBeenCalledWith('keydown', jasmine.any(Function), undefined); + + const listener = addEventSpy.calls.all() + .find(call => call.args[0] === 'keydown')?.args[1] as EventListener; + + expect(listener).toBeDefined(); + + listener!(new KeyboardEvent('keydown', { key: 'Escape' })); + + // expect hide to have been called + expect(hideSpy).toHaveBeenCalledTimes(1); + expect(removeEventSpy).not.toHaveBeenCalled(); + + overlay.detach(id); + expect(removeEventSpy).toHaveBeenCalled(); + + // the keydown listener is now removed + expect(removeEventSpy).toHaveBeenCalledWith('keydown', listener, undefined); + + // fire event again, expecting hide NOT to be fired again + listener!(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(hideSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('Unit Tests: ', () => { + let outlet: any; + let outletRef: ElementRef; + beforeEach(waitForAsync(() => { + outlet = document.createElement('div'); + outletRef = new ElementRef(outlet); + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, SimpleDynamicWithDirectiveComponent], + providers: [ + { provide: ElementRef, useValue: outletRef }, + IgxOverlayOutletDirective + ] + }).compileComponents(); + })); + + it('OverlayElement should return a div attached to Document\'s body.', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + fixture.componentInstance.buttonElement.nativeElement.click(); + tick(); + const overlayDiv = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_MAIN)[0] as HTMLElement; + expect(overlayDiv).toBeDefined(); + expect(overlayDiv).toHaveClass(CLASS_OVERLAY_MAIN); + + fixture.componentInstance.overlay.detachAll(); + })); + + it('Should attach to setting target or default to body', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + const button = fixture.componentInstance.buttonElement; + const overlay = fixture.componentInstance.overlay; + fixture.detectChanges(); + + let id = overlay.attach(SimpleDynamicComponent, { outlet: button, modal: false }); + overlay.show(id); + tick(); + + let wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER)[0] as HTMLElement; + expect(wrapperElement).toBeDefined(); + expect(wrapperElement.parentNode).toBe(button.nativeElement); + overlay.detach(id); + tick(); + + id = overlay.attach(SimpleDynamicComponent, { modal: false }); + overlay.show(id); + tick(); + wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER)[0] as HTMLElement; + expect(wrapperElement).toBeDefined(); + expect(wrapperElement.parentElement.classList).toContain(CLASS_OVERLAY_MAIN); + expect(wrapperElement.parentElement.parentElement).toBe(document.body); + overlay.detach(id); + tick(); + + fixture.debugElement.nativeElement.appendChild(outlet); + id = overlay.attach(SimpleDynamicComponent, { modal: false, outlet: TestBed.inject(IgxOverlayOutletDirective) }); + overlay.show(id); + tick(); + wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER)[0] as HTMLElement; + expect(wrapperElement).toBeDefined(); + expect(wrapperElement.parentNode).toBe(outlet); + + overlay.detachAll(); + })); + + it('Should show component passed to overlay.', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + fixture.componentInstance.buttonElement.nativeElement.click(); + tick(DEBOUNCE_TIME); + + const overlayElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_MAIN)[0] as HTMLElement; + expect(overlayElement).toBeDefined(); + expect(overlayElement.children.length).toEqual(1); + + const wrapperElement = overlayElement.children[0]; + expect(wrapperElement).toBeDefined(); + expect(wrapperElement).toHaveClass(CLASS_OVERLAY_WRAPPER_MODAL); + expect(wrapperElement.children[0].localName).toEqual('div'); + + const contentElement = wrapperElement.children[0]; + expect(contentElement).toBeDefined(); + expect(contentElement).toHaveClass(CLASS_OVERLAY_CONTENT_MODAL); + + fixture.componentInstance.overlay.detachAll(); + })); + + it('Should hide component and the overlay when Hide() is called.', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + const overlay = fixture.componentInstance.overlay; + + overlay.show(overlay.attach(SimpleDynamicComponent)); + tick(); + + overlay.show(overlay.attach(SimpleDynamicComponent)); + tick(); + + let overlayDiv = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_MAIN)[0] as HTMLElement; + expect(overlayDiv).toBeDefined(); + expect(overlayDiv.children.length).toEqual(2); + expect((overlayDiv.children[0] as HTMLElement).style.visibility).toEqual(''); + expect((overlayDiv.children[1] as HTMLElement).style.visibility).toEqual(''); + + overlay.hide('0'); + tick(); + + overlayDiv = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_MAIN)[0] as HTMLElement; + expect((overlayDiv.children[0] as HTMLElement).style.visibility).toEqual('hidden'); + expect((overlayDiv.children[1] as HTMLElement).style.visibility).toEqual(''); + + overlay.hide('1'); + tick(); + + overlayDiv = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_MAIN)[0] as HTMLElement; + expect((overlayDiv.children[0] as HTMLElement).style.visibility).toEqual('hidden'); + expect((overlayDiv.children[1] as HTMLElement).style.visibility).toEqual('hidden'); + + overlay.detachAll(); + })); + + it('Should hide all components and the overlay when HideAll() is called.', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + const overlay = fixture.componentInstance.overlay; + + overlay.show(overlay.attach(SimpleDynamicComponent)); + overlay.show(overlay.attach(SimpleDynamicComponent)); + tick(); + fixture.detectChanges(); + + let overlayDiv = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_MAIN)[0] as HTMLElement; + expect(overlayDiv).toBeDefined(); + expect(overlayDiv.children.length).toEqual(2); + expect((overlayDiv.children[0] as HTMLElement).style.visibility).toEqual(''); + expect((overlayDiv.children[1] as HTMLElement).style.visibility).toEqual(''); + + overlay.hideAll(); + tick(); + overlayDiv = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_MAIN)[0] as HTMLElement; + expect((overlayDiv.children[0] as HTMLElement).style.visibility).toEqual('hidden'); + expect((overlayDiv.children[1] as HTMLElement).style.visibility).toEqual('hidden'); + + overlay.detachAll(); + })); + + it('Should show and hide component via directive.', fakeAsync(() => { + const fixture = TestBed.createComponent(SimpleDynamicWithDirectiveComponent); + fixture.detectChanges(); + fixture.componentInstance.show(); + tick(); + + let overlayDiv = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_MAIN)[0] as HTMLElement; + expect(overlayDiv).toBeDefined(); + expect(overlayDiv.children.length).toEqual(1); + expect(overlayDiv.children[0].localName).toEqual('div'); + + fixture.componentInstance.hide(); + tick(); + overlayDiv = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_MAIN)[0] as HTMLElement; + expect(overlayDiv).toBeUndefined(); + })); + + it('Should properly emit events.', fakeAsync(() => { + const fixture = TestBed.createComponent(SimpleRefComponent); + fixture.detectChanges(); + const overlayInstance = fixture.componentInstance.overlay; + spyOn(overlayInstance.closed, 'emit'); + spyOn(overlayInstance.closing, 'emit'); + spyOn(overlayInstance.opened, 'emit'); + spyOn(overlayInstance.contentAppending, 'emit'); + spyOn(overlayInstance.contentAppended, 'emit'); + spyOn(overlayInstance.opening, 'emit'); + spyOn(overlayInstance.animationStarting, 'emit'); + + const firstCallId = overlayInstance.attach(SimpleDynamicComponent); + overlayInstance.show(firstCallId); + tick(); + + expect(overlayInstance.opening.emit).toHaveBeenCalledTimes(1); + expect(overlayInstance.opening.emit) + .toHaveBeenCalledWith({ id: firstCallId, componentRef: jasmine.any(ComponentRef) as any, cancel: false }); + const args: OverlayEventArgs = (overlayInstance.opening.emit as jasmine.Spy).calls.mostRecent().args[0]; + expect(args.componentRef.instance).toEqual(jasmine.any(SimpleDynamicComponent)); + expect(overlayInstance.contentAppending.emit).toHaveBeenCalledTimes(1); + expect(overlayInstance.contentAppended.emit).toHaveBeenCalledTimes(1); + expect(overlayInstance.animationStarting.emit).toHaveBeenCalledTimes(1); + + tick(); + expect(overlayInstance.opened.emit).toHaveBeenCalledTimes(1); + expect(overlayInstance.opened.emit).toHaveBeenCalledWith({ id: firstCallId, componentRef: jasmine.any(ComponentRef) as any }); + overlayInstance.hide(firstCallId); + + tick(); + expect(overlayInstance.closing.emit).toHaveBeenCalledTimes(1); + expect(overlayInstance.closing.emit) + .toHaveBeenCalledWith({ id: firstCallId, componentRef: jasmine.any(ComponentRef) as any, cancel: false, event: undefined }); + expect(overlayInstance.animationStarting.emit).toHaveBeenCalledTimes(2); + + tick(); + expect(overlayInstance.closed.emit).toHaveBeenCalledTimes(1); + expect(overlayInstance.closed.emit). + toHaveBeenCalledWith({ id: firstCallId, componentRef: jasmine.any(ComponentRef) as any, event: undefined }); + + const secondCallId = overlayInstance.attach(fixture.componentInstance.item); + overlayInstance.show(secondCallId); + tick(); + expect(overlayInstance.opening.emit).toHaveBeenCalledTimes(2); + expect(overlayInstance.opening.emit).toHaveBeenCalledWith({ componentRef: undefined, id: secondCallId, cancel: false }); + expect(overlayInstance.contentAppending.emit).toHaveBeenCalledTimes(2); + expect(overlayInstance.contentAppended.emit).toHaveBeenCalledTimes(2); + expect(overlayInstance.animationStarting.emit).toHaveBeenCalledTimes(3); + + tick(); + expect(overlayInstance.opened.emit).toHaveBeenCalledTimes(2); + expect(overlayInstance.opened.emit).toHaveBeenCalledWith({ componentRef: undefined, id: secondCallId }); + + overlayInstance.hide(secondCallId); + tick(); + expect(overlayInstance.closing.emit).toHaveBeenCalledTimes(2); + expect(overlayInstance.closing.emit). + toHaveBeenCalledWith({ componentRef: undefined, id: secondCallId, cancel: false, event: undefined }); + expect(overlayInstance.animationStarting.emit).toHaveBeenCalledTimes(4); + + tick(); + expect(overlayInstance.closed.emit).toHaveBeenCalledTimes(2); + expect(overlayInstance.closed.emit).toHaveBeenCalledWith({ componentRef: undefined, id: secondCallId, event: undefined }); + + overlayInstance.detachAll(); + })); + + it('Should properly emit contentAppending event', fakeAsync(() => { + const fixture = TestBed.createComponent(SimpleRefComponent); + fixture.detectChanges(); + const overlayInstance = fixture.componentInstance.overlay; + const os: OverlaySettings = { + excludeFromOutsideClick: [], + positionStrategy: new GlobalPositionStrategy(), + scrollStrategy: new NoOpScrollStrategy(), + modal: true, + closeOnOutsideClick: true, + closeOnEscape: false + }; + + spyOn(overlayInstance.contentAppending, 'emit'); + spyOn(overlayInstance.contentAppended, 'emit'); + + const firstCallId = overlayInstance.attach(SimpleDynamicComponent); + overlayInstance.show(firstCallId); + tick(); + + expect(overlayInstance.contentAppending.emit).toHaveBeenCalledTimes(1); + expect(overlayInstance.contentAppended.emit).toHaveBeenCalledTimes(1); + + expect(overlayInstance.contentAppending.emit).toHaveBeenCalledWith({ + id: firstCallId, elementRef: jasmine.any(Object), + componentRef: jasmine.any(ComponentRef) as any, settings: os + }) + })); + + it('Should properly be able to override OverlaySettings using contentAppending event args', fakeAsync(() => { + const fixture = TestBed.createComponent(SimpleRefComponent); + fixture.detectChanges(); + const overlayInstance = fixture.componentInstance.overlay; + const os: OverlaySettings = { + excludeFromOutsideClick: [], + positionStrategy: new GlobalPositionStrategy(), + scrollStrategy: new CloseScrollStrategy(), + modal: false, + closeOnOutsideClick: false, + closeOnEscape: true + }; + + overlayInstance.contentAppending.pipe(first()).subscribe((e: OverlayEventArgs) => { + // override the default settings + e.settings = os; + }); + overlayInstance.contentAppended.pipe(first()).subscribe((e: OverlayEventArgs) => { + const overlay = overlayInstance.getOverlayById(e.id); + expect(overlay.settings.closeOnEscape).toBeTrue(); + expect(overlay.settings.modal).toBeFalsy(); + expect(overlay.settings.closeOnOutsideClick).toBeFalsy(); + }); + + spyOn(overlayInstance.contentAppended, 'emit').and.callThrough(); + spyOn(overlayInstance.contentAppending, 'emit').and.callThrough(); + + const firstCallId = overlayInstance.attach(SimpleDynamicComponent); + overlayInstance.show(firstCallId); + tick(); + + expect(overlayInstance.contentAppending.emit).toHaveBeenCalledTimes(1); + expect(overlayInstance.contentAppended.emit).toHaveBeenCalledTimes(1); + })); + + it('Should properly set style on position method call - GlobalPosition.', () => { + const mockParent = document.createElement('div'); + const mockItem = document.createElement('div'); + mockParent.appendChild(mockItem); + + const mockPositioningSettings1: PositionSettings = { + horizontalDirection: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Bottom + }; + + const horAl = Object.keys(HorizontalAlignment).filter(key => !isNaN(Number(HorizontalAlignment[key]))); + const verAl = Object.keys(VerticalAlignment).filter(key => !isNaN(Number(VerticalAlignment[key]))); + + const mockDirection: string[] = ['flex-start', 'center', 'flex-end']; + + for (let i = 0; i < mockDirection.length; i++) { + for (let j = 0; j < mockDirection.length; j++) { + mockPositioningSettings1.horizontalDirection = HorizontalAlignment[horAl[i]]; + mockPositioningSettings1.verticalDirection = VerticalAlignment[verAl[j]]; + const globalStrat1 = new GlobalPositionStrategy(mockPositioningSettings1); + globalStrat1.position(mockItem); + expect(mockParent.style.justifyContent).toEqual(mockDirection[i]); + expect(mockParent.style.alignItems).toEqual(mockDirection[j]); + } + } + }); + + it('Should properly set style on position method call - ConnectedPosition.', () => { + const top = 0; + const left = 0; + const width = 200; + const right = 200; + const height = 200; + const bottom = 200; + const mockElement = document.createElement('div'); + spyOn(mockElement, 'getBoundingClientRect').and.callFake(() => ({ + left, top, width, height, right, bottom + } as DOMRect)); + + const mockItem = document.createElement('div'); + mockElement.append(mockItem); + spyOn(mockItem, 'getBoundingClientRect').and.callFake(() => new DOMRect(top, left, width, height)); + + const mockPositioningSettings1: PositionSettings = { + horizontalDirection: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Bottom, + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Top + }; + const connectedStrat1 = new ConnectedPositioningStrategy(mockPositioningSettings1); + connectedStrat1.position(mockItem, { width, height }, null, false, mockItem); + expect(mockItem.style.top).toEqual('0px'); + expect(mockItem.style.left).toEqual('0px'); + + connectedStrat1.settings.horizontalStartPoint = HorizontalAlignment.Center; + connectedStrat1.position(mockItem, { width, height }, null, false, mockItem); + expect(mockItem.style.top).toEqual('0px'); + expect(mockItem.style.left).toEqual('100px'); + + connectedStrat1.settings.horizontalStartPoint = HorizontalAlignment.Right; + connectedStrat1.position(mockItem, { width, height }, null, false, mockItem); + expect(mockItem.style.top).toEqual('0px'); + expect(mockItem.style.left).toEqual('200px'); + + connectedStrat1.settings.verticalStartPoint = VerticalAlignment.Middle; + connectedStrat1.position(mockItem, { width, height }, null, false, mockItem); + expect(mockItem.style.top).toEqual('100px'); + expect(mockItem.style.left).toEqual('200px'); + + connectedStrat1.settings.verticalStartPoint = VerticalAlignment.Bottom; + connectedStrat1.position(mockItem, { width, height }, null, false, mockItem); + expect(mockItem.style.top).toEqual('200px'); + expect(mockItem.style.left).toEqual('200px'); + + // If target is Point + connectedStrat1.position(mockItem, { width, height }, null, false, new Point(0, 0)); + expect(mockItem.style.top).toEqual('0px'); + expect(mockItem.style.left).toEqual('0px'); + + // If target is not point or html element, should fallback to new Point(0,0) + connectedStrat1.position(mockItem, { width, height }, null, false, 'g' as any); + expect(mockItem.style.top).toEqual('0px'); + expect(mockItem.style.left).toEqual('0px'); + }); + + it('Should properly call position method - AutoPosition.', () => { + spyOn(BaseFitPositionStrategy.prototype, 'position'); + spyOn(ConnectedPositioningStrategy.prototype, 'setStyle'); + const mockDiv = document.createElement('div'); + const autoStrat1 = new AutoPositionStrategy(); + autoStrat1.position(mockDiv, null, document, false); + expect(BaseFitPositionStrategy.prototype.position).toHaveBeenCalledTimes(1); + expect(BaseFitPositionStrategy.prototype.position).toHaveBeenCalledWith(mockDiv, null, document, false); + + const autoStrat2 = new AutoPositionStrategy(); + autoStrat2.position(mockDiv, null, document, false); + expect(BaseFitPositionStrategy.prototype.position).toHaveBeenCalledTimes(2); + + const autoStrat3 = new AutoPositionStrategy(); + autoStrat3.position(mockDiv, null, document, false); + expect(BaseFitPositionStrategy.prototype.position).toHaveBeenCalledTimes(3); + }); + + it('Should properly call position method - ElasticPosition.', () => { + spyOn(BaseFitPositionStrategy.prototype, 'position'); + spyOn(ConnectedPositioningStrategy.prototype, 'setStyle'); + const mockDiv = document.createElement('div'); + const autoStrat1 = new ElasticPositionStrategy(); + + autoStrat1.position(mockDiv, null, document, false); + expect(BaseFitPositionStrategy.prototype.position).toHaveBeenCalledTimes(1); + expect(BaseFitPositionStrategy.prototype.position).toHaveBeenCalledWith(mockDiv, null, document, false); + + const autoStrat2 = new ElasticPositionStrategy(); + autoStrat2.position(mockDiv, null, document, false); + expect(BaseFitPositionStrategy.prototype.position).toHaveBeenCalledTimes(2); + + const autoStrat3 = new ElasticPositionStrategy(); + autoStrat3.position(mockDiv, null, document, false); + expect(BaseFitPositionStrategy.prototype.position).toHaveBeenCalledTimes(3); + }); + + it('Should properly call setOffset method', fakeAsync(() => { + const fixture = TestBed.createComponent(WidthTestOverlayComponent); + const overlayInstance = fixture.componentInstance.overlay; + + const id = fixture.componentInstance.overlay.attach(SimpleRefComponent); + overlayInstance.show(id); + fixture.detectChanges(); + tick(); + + overlayInstance.setOffset(id, 40, 40); + const contentElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_CONTENT_MODAL)[0] as HTMLElement; + const componentElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName('simpleRef')[0] as HTMLElement; + const contentElementRect = contentElement.getBoundingClientRect(); + const componentElementRect = componentElement.getBoundingClientRect(); + let overlayContentTransform = contentElement.style.transform; + const firstTransform = 'translate(40px, 40px)'; + const secondTransform = 'translate(30px, 60px)'; + + expect(contentElementRect.top).toEqual(componentElementRect.top); + expect(contentElementRect.left).toEqual(componentElementRect.left); + expect(overlayContentTransform).toEqual(firstTransform); + + // Set the offset again and verify it is changed correctly + overlayInstance.setOffset(id, -10, 20); + fixture.detectChanges(); + tick(); + const contentElementRectNew = contentElement.getBoundingClientRect(); + const componentElementRectNew = componentElement.getBoundingClientRect(); + overlayContentTransform = contentElement.style.transform; + + expect(contentElementRectNew.top).toEqual(componentElementRectNew.top); + expect(contentElementRectNew.left).toEqual(componentElementRectNew.left); + + expect(contentElementRectNew.top).not.toEqual(contentElementRect.top); + expect(contentElementRectNew.left).not.toEqual(contentElementRect.left); + + expect(componentElementRectNew.top).not.toEqual(componentElementRect.top); + expect(componentElementRectNew.left).not.toEqual(componentElementRect.left); + expect(overlayContentTransform).toEqual(secondTransform); + + overlayInstance.detachAll(); + })); + + it('Should offset the overlay content correctly using setOffset method and optional offsetMode', fakeAsync(() => { + const fixture = TestBed.createComponent(WidthTestOverlayComponent); + const overlayInstance = fixture.componentInstance.overlay; + + const id = fixture.componentInstance.overlay.attach(SimpleRefComponent); + overlayInstance.show(id); + fixture.detectChanges(); + tick(); + + // Set initial offset + overlayInstance.setOffset(id, 20, 20); + const overlayInfo = overlayInstance.getOverlayById(id); + expect(overlayInfo.transformX).toEqual(20); + expect(overlayInfo.transformY).toEqual(20); + + // Add offset values to the existing ones (default behavior) + overlayInstance.setOffset(id, 10, 10); + expect(overlayInfo.transformX).toEqual(30); + expect(overlayInfo.transformY).toEqual(30); + + // Add offset values using OffsetMode.Add + overlayInstance.setOffset(id, 20, 20, OffsetMode.Add); + expect(overlayInfo.transformX).toEqual(50); + expect(overlayInfo.transformY).toEqual(50); + + // Set offset values using OffsetMode.Set + overlayInstance.setOffset(id, 10, 10, OffsetMode.Set); + expect(overlayInfo.transformX).toEqual(10); + expect(overlayInfo.transformY).toEqual(10); + + overlayInstance.detachAll(); + })); + + it('#1690 - click on second filter does not close first one.', fakeAsync(() => { + const fixture = TestBed.createComponent(TwoButtonsComponent); + const button1 = fixture.nativeElement.getElementsByClassName('buttonOne')[0]; + const button2 = fixture.nativeElement.getElementsByClassName('buttonTwo')[0]; + + button1.click(); + tick(); + + const overlayDiv = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_MAIN)[0] as HTMLElement; + const wrapperElement1 = overlayDiv.children[0] as HTMLElement; + expect(wrapperElement1.style.visibility).toEqual(''); + + button2.click(); + tick(); + const wrapperElement2 = overlayDiv.children[1] as HTMLElement; + expect(wrapperElement1.style.visibility).toEqual(''); + expect(wrapperElement2.style.visibility).toEqual(''); + + fixture.componentInstance.overlay.detachAll(); + })); + + it('#1692 - scroll strategy closes overlay when shown component is scrolled.', fakeAsync(() => { + const fixture = TestBed.createComponent(SimpleDynamicWithDirectiveComponent); + const overlaySettings: OverlaySettings = { scrollStrategy: new CloseScrollStrategy() }; + fixture.componentInstance.show(overlaySettings); + tick(); + + let overlayElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_MAIN)[0] as HTMLElement; + expect(overlayElement).toBeDefined(); + + const scrollableDiv = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_SCROLLABLE_DIV)[0] as HTMLElement; + scrollableDiv.scrollTop += 5; + scrollableDiv.dispatchEvent(new Event('scroll')); + tick(); + + overlayElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_MAIN)[0] as HTMLElement; + expect(overlayElement).toBeDefined(); + + scrollableDiv.scrollTop += 100; + scrollableDiv.dispatchEvent(new Event('scroll')); + tick(); + + overlayElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_MAIN)[0] as HTMLElement; + expect(overlayElement).toBeDefined(); + + fixture.componentInstance.hide(); + })); + + // TODO: refactor utilities to include all exported methods in a class + // it('#1799 - content div should reposition on window resize.', fakeAsync(() => { + // const rect: ClientRect = { + // bottom: 50, + // height: 0, + // left: 50, + // right: 50, + // top: 50, + // width: 0 + // }; + // const getPointSpy = spyOn(Util, 'getTargetRect').and.returnValue(rect); + // const fixture = TestBed.createComponent(FlexContainerComponent); + // fixture.detectChanges(); + // const overlayInstance = fixture.componentInstance.overlay; + // const buttonElement: HTMLElement = fixture.componentInstance.buttonElement.nativeElement; + + // const id = overlayInstance.attach( + // SimpleDynamicComponent, + // { positionStrategy: new ConnectedPositioningStrategy({ target: buttonElement }) }); + // overlayInstance.show(id); + // tick(DEBOUNCE_TIME); + + // let contentElement = (fixture.nativeElement as HTMLElement) + // .parentElement.getElementsByClassName(CLASS_OVERLAY_CONTENT_MODAL)[0] as HTMLElement; + // let contentRect = contentElement.getBoundingClientRect(); + + // expect(50).toEqual(contentRect.left); + // expect(50).toEqual(contentRect.top); + + // rect.left = 200; + // rect.right = 200; + // rect.top = 200; + // rect.bottom = 200; + // getPointSpy.and.callThrough().and.returnValue(rect); + // window.resizeBy(200, 200); + // window.dispatchEvent(new Event('resize')); + // tick(DEBOUNCE_TIME); + + // contentElement = (fixture.nativeElement as HTMLElement) + // .parentElement.getElementsByClassName(CLASS_OVERLAY_CONTENT_MODAL)[0] as HTMLElement; + // contentRect = contentElement.getBoundingClientRect(); + // expect(200).toEqual(contentRect.left); + // expect(200).toEqual(contentRect.top); + + // overlayInstance.hide(id); + // })); + + it('#2475 - An error is thrown for IgxOverlay when showing a component' + + 'instance that is not attached to the DOM', fakeAsync(() => { + const fixture = TestBed.createComponent(SimpleRefComponent); + fixture.detectChanges(); + const overlay = fixture.componentInstance.overlay; + + // remove SimpleRefComponent HTML element from the DOM tree + fixture.elementRef.nativeElement.parentElement.removeChild(fixture.elementRef.nativeElement); + overlay.show(overlay.attach(fixture.elementRef)); + tick(DEBOUNCE_TIME); + + const componentElement = fixture.nativeElement as HTMLElement; + expect(componentElement).toBeDefined(); + + const contentElement = componentElement.parentElement; + expect(contentElement).toBeDefined(); + expect(contentElement).toHaveClass(CLASS_OVERLAY_CONTENT_MODAL); + expect(contentElement).toHaveClass(CLASS_OVERLAY_CONTENT_RELATIVE); + + const wrapperElement = contentElement.parentElement; + expect(wrapperElement).toBeDefined(); + expect(wrapperElement).toHaveClass(CLASS_OVERLAY_WRAPPER_MODAL); + expect(wrapperElement).toHaveClass(CLASS_OVERLAY_WRAPPER_FLEX); + + const overlayElement = wrapperElement.parentElement; + expect(overlayElement).toBeDefined(); + expect(overlayElement).toHaveClass(CLASS_OVERLAY_MAIN); + + overlay.detachAll(); + })); + + it('#2486 - filtering dropdown is not correctly positioned', fakeAsync(() => { + const fixture = TestBed.createComponent(WidthTestOverlayComponent); + fixture.debugElement.nativeElement.style.transform = 'translatex(100px)'; + + fixture.detectChanges(); + tick(); + + fixture.componentInstance.overlaySettings.outlet = fixture.componentInstance.elementRef; + + const buttonElement: HTMLElement = fixture.componentInstance.buttonElement.nativeElement; + buttonElement.click(); + + fixture.detectChanges(); + tick(); + + const wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER)[0] as HTMLElement; + expect(wrapperElement.getBoundingClientRect().left).toBe(100); + expect(fixture.componentInstance.customComponent.nativeElement.getBoundingClientRect().left).toBe(400); + + fixture.componentInstance.overlay.detachAll(); + })); + + it('#2798 - Allow canceling of open and close of IgxDropDown through opening and closing events', fakeAsync(() => { + const fixture = TestBed.createComponent(SimpleRefComponent); + fixture.detectChanges(); + const overlayInstance = fixture.componentInstance.overlay; + + overlayInstance.closing.subscribe((e: OverlayCancelableEventArgs) => { + e.cancel = true; + }); + + spyOn(overlayInstance.closed, 'emit').and.callThrough(); + spyOn(overlayInstance.closing, 'emit').and.callThrough(); + spyOn(overlayInstance.opened, 'emit').and.callThrough(); + spyOn(overlayInstance.opening, 'emit').and.callThrough(); + + const firstCallId = overlayInstance.attach(SimpleDynamicComponent); + overlayInstance.show(firstCallId); + tick(); + + expect(overlayInstance.opening.emit).toHaveBeenCalledTimes(1); + expect(overlayInstance.opened.emit).toHaveBeenCalledTimes(1); + + overlayInstance.hide(firstCallId); + tick(); + + expect(overlayInstance.closing.emit).toHaveBeenCalledTimes(1); + expect(overlayInstance.closed.emit).toHaveBeenCalledTimes(0); + + overlayInstance.opening.subscribe((e: OverlayCancelableEventArgs) => { + e.cancel = true; + }); + + overlayInstance.show(firstCallId); + tick(); + + expect(overlayInstance.opening.emit).toHaveBeenCalledTimes(2); + expect(overlayInstance.opened.emit).toHaveBeenCalledTimes(1); + + overlayInstance.detachAll(); + })); + + it('#3673 - Should not close dropdown in dropdown', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + const button = fixture.componentInstance.buttonElement; + const overlay = fixture.componentInstance.overlay; + fixture.detectChanges(); + + const overlaySettings: OverlaySettings = { + target: button.nativeElement, + positionStrategy: new ConnectedPositioningStrategy(), + modal: false, + closeOnOutsideClick: true + }; + + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + overlaySettings.positionStrategy.settings.horizontalStartPoint = HorizontalAlignment.Right; + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + fixture.detectChanges(); + tick(); + + let overlayElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_MAIN)[0] as HTMLElement; + expect(overlayElement).toBeDefined(); + expect(overlayElement.childElementCount).toEqual(2); + expect((overlayElement.children[0] as HTMLElement).style.visibility).toEqual(''); + expect((overlayElement.children[1] as HTMLElement).style.visibility).toEqual(''); + + (overlay as any)._overlayInfos[0].elementRef.nativeElement.click(); + fixture.detectChanges(); + tick(); + + overlayElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_MAIN)[0] as HTMLElement; + expect(overlayElement.childElementCount).toEqual(2); + expect((overlayElement.children[0] as HTMLElement).style.visibility).toEqual(''); + expect((overlayElement.children[1] as HTMLElement).style.visibility).toEqual('hidden'); + + overlay.detachAll(); + })); + + it('#3743 - Reposition correctly resized element.', () => { + const fixture = TestBed.createComponent(TopLeftOffsetComponent); + fixture.detectChanges(); + + const componentElement = document.createElement('div'); + componentElement.setAttribute('style', 'width:100px; height:100px; color:green; border: 1px solid blue;'); + const contentElement = document.createElement('div'); + contentElement.classList.add('contentWrapper'); + contentElement.classList.add('no-height'); + contentElement.setAttribute('style', 'width:100px; position: absolute;'); + contentElement.appendChild(componentElement); + const wrapperElement = document.createElement('div'); + wrapperElement.setAttribute('style', 'position: fixed; width: 100%; height: 100%; top: 0; left: 0;'); + wrapperElement.appendChild(contentElement); + document.body.appendChild(wrapperElement); + + const targetElement: HTMLElement = fixture.componentInstance.buttonElement.nativeElement; + + fixture.detectChanges(); + const positionSettings: PositionSettings = { + horizontalDirection: HorizontalAlignment.Left, + verticalDirection: VerticalAlignment.Top, + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Top + }; + const strategy = new ConnectedPositioningStrategy(positionSettings); + strategy.position(contentElement, null, null, true, targetElement); + fixture.detectChanges(); + + const targetRect = targetElement.getBoundingClientRect(); + let contentElementRect = contentElement.getBoundingClientRect(); + expect(targetRect.top).toBe(contentElementRect.bottom); + expect(targetRect.left).toBe(contentElementRect.right); + + componentElement.setAttribute('style', 'width:100px; height:50px; color:green; border: 1px solid blue;'); + strategy.position(contentElement, null, null, false, targetElement); + fixture.detectChanges(); + contentElementRect = contentElement.getBoundingClientRect(); + expect(targetRect.top).toBe(contentElementRect.bottom); + expect(targetRect.left).toBe(contentElementRect.right); + + componentElement.setAttribute('style', 'width:100px; height:500px; color:green; border: 1px solid blue;'); + strategy.position(contentElement, null, null, false, targetElement); + fixture.detectChanges(); + contentElementRect = contentElement.getBoundingClientRect(); + expect(targetRect.top).toBe(contentElementRect.bottom); + expect(targetRect.left).toBe(contentElementRect.right); + + document.body.removeChild(wrapperElement); + }); + + it('#3988 - Should use viewContainerRef to create component', () => { + const fixture = TestBed.createComponent(EmptyPageComponent); + const overlay = fixture.componentInstance.overlay; + const viewContainerRef = fixture.componentInstance.viewContainerRef; + fixture.detectChanges(); + + const mockNativeElement = document.createElement('div'); + const mockComponent = { + hostView: fixture.componentRef.hostView, + changeDetectorRef: { detectChanges: () => { } }, + location: { nativeElement: mockNativeElement }, + destroy: () => { } + }; + spyOn(viewContainerRef, 'createComponent').and.returnValue(mockComponent as any); + const id = overlay.attach(SimpleDynamicComponent, viewContainerRef); + expect(viewContainerRef.createComponent).toHaveBeenCalledWith(SimpleDynamicComponent as any); + expect(overlay.getOverlayById(id).componentRef as any).toBe(mockComponent); + + overlay.detachAll(); + }); + + it('#14364 - Should provide injector to attach method', () => { + const fixture = TestBed.createComponent(EmptyPageComponent); + const overlay = fixture.componentInstance.overlay; + const injector = Injector.create({ + parent: fixture.componentInstance.injector, + providers: [ + { provide: 'SomeConst', useValue: 100 }, + ], + }); + fixture.detectChanges(); + + const id = overlay.attach(SimpleDynamicComponent, { injector }); + expect(id).toBeDefined(); + + const overlayInfo = overlay.getOverlayById(id); + expect(overlayInfo).toBeDefined(); + + const elementInjector = overlayInfo.componentRef.injector; + expect(elementInjector).toBeDefined(); + + const result = elementInjector.get('SomeConst'); + expect(result).toEqual(100); + + overlay.detachAll(); + }); + + it('#14364 - Should provide different injectors to attach method to each component', () => { + const fixture = TestBed.createComponent(EmptyPageComponent); + const overlay = fixture.componentInstance.overlay; + const injector1 = Injector.create({ + parent: fixture.componentInstance.injector, + providers: [ + { provide: 'SomeConst', useValue: 'First Value' }, + ], + }); + const injector2 = Injector.create({ + parent: fixture.componentInstance.injector, + providers: [ + { provide: 'SomeConst', useValue: 'Second Value' }, + ], + }); + fixture.detectChanges(); + + const id1 = overlay.attach(SimpleDynamicComponent, { injector: injector1 }); + expect(id1).toBeDefined(); + + const overlayInfo1 = overlay.getOverlayById(id1); + expect(overlayInfo1).toBeDefined(); + + const elementInjector1 = overlayInfo1.componentRef.injector; + expect(elementInjector1).toBeDefined(); + + const result1 = elementInjector1.get('SomeConst'); + expect(result1).toEqual('First Value'); + + const id2 = overlay.attach(SimpleDynamicComponent, { injector: injector2 }); + expect(id2).toBeDefined(); + + const overlayInfo2 = overlay.getOverlayById(id2); + expect(overlayInfo2).toBeDefined(); + + const elementInjector2 = overlayInfo2.componentRef.injector; + expect(elementInjector2).toBeDefined(); + + const result2 = elementInjector2.get('SomeConst'); + expect(result2).toEqual('Second Value'); + + overlay.detachAll(); + }); + + // it('##6474 - should calculate correctly position', () => { + // const elastic: ElasticPositionStrategy = new ElasticPositionStrategy(); + // const targetRect: ClientRect = { + // top: 100, + // bottom: 200, + // height: 100, + // left: 100, + // right: 200, + // width: 100 + // }; + // const elementRect: ClientRect = { + // top: 0, + // bottom: 300, + // height: 300, + // left: 0, + // right: 300, + // width: 300 + // }; + // const viewPortRect: ClientRect = { + // top: 1000, + // bottom: 1300, + // height: 300, + // left: 1000, + // right: 1300, + // width: 300 + // }; + // spyOn(elastic, 'setStyle').and.returnValue({}); + // spyOn(Util, 'getViewportRect').and.returnValue(viewPortRect); + // spyOn(Util, 'getTargetRect').and.returnValue(targetRect); + + // const mockElement = jasmine.createSpyObj('HTMLElement', ['getBoundingClientRect']); + // spyOn(mockElement, 'getBoundingClientRect').and.returnValue(elementRect); + // mockElement.classList = { add: () => { } }; + // mockElement.style = { width: '', height: '' }; + // elastic.position(mockElement, null, null, true); + + // expect(mockElement.style.width).toBe('200px'); + // expect(mockElement.style.height).toBe('100px'); + // }); + + it('should close overlay on outside click when target is point, #8297', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + const overlay = fixture.componentInstance.overlay; + fixture.detectChanges(); + + const overlaySettings: OverlaySettings = { + modal: false, + closeOnOutsideClick: true, + positionStrategy: new ConnectedPositioningStrategy() + }; + + overlaySettings.target = new Point(10, 10); + + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(); + fixture.detectChanges(); + + let wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER)[0] as HTMLElement; + expect(wrapperElement.style.visibility).toEqual(''); + + document.body.click(); + tick(); + fixture.detectChanges(); + + wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER)[0] as HTMLElement; + expect(wrapperElement.style.visibility).toEqual('hidden'); + + overlay.detachAll(); + })); + + it('should correctly handle close on outside click in shadow DOM', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageInShadowDomComponent); + const button = fixture.componentInstance.buttonElement; + const overlay = fixture.componentInstance.overlay; + outlet = fixture.componentInstance.outletElement; + + fixture.detectChanges(); + + const overlaySettings: OverlaySettings = { + modal: false, + closeOnOutsideClick: true, + positionStrategy: new ConnectedPositioningStrategy(), + target: button.nativeElement, + outlet + }; + + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(); + fixture.detectChanges(); + + const shadowRoot = fixture.nativeElement.shadowRoot; + let wrapperElement = shadowRoot.querySelector(`.${CLASS_OVERLAY_WRAPPER}`) as HTMLElement; + expect(wrapperElement.style.visibility).toEqual(''); + + const componentElement = shadowRoot.querySelector('test-simple-dynamic-component') as HTMLElement; + const toggledDiv = componentElement.children[0] as HTMLElement; + toggledDiv.click(); + + tick(); + fixture.detectChanges(); + + wrapperElement = shadowRoot.querySelector(`.${CLASS_OVERLAY_WRAPPER}`) as HTMLElement; + expect(wrapperElement.style.visibility).toEqual(''); + + document.body.click(); + tick(); + fixture.detectChanges(); + + wrapperElement = shadowRoot.querySelector(`.${CLASS_OVERLAY_WRAPPER}`) as HTMLElement; + expect(wrapperElement.style.visibility).toEqual('hidden'); + + overlay.detachAll(); + })); + + it('Should properly move computed size style to the overlay content container.', fakeAsync(() => { + const fixture = TestBed.createComponent(SimpleRefComponent); + fixture.detectChanges(); + + fixture.componentInstance.item.nativeElement.style.setProperty('--ig-size', 'var(--ig-size-small)'); + fixture.detectChanges(); + const overlayInstance = fixture.componentInstance.overlay; + const overlaySettings: OverlaySettings = { + positionStrategy: new ConnectedPositioningStrategy(), + modal: false, + closeOnOutsideClick: false + }; + const firstCallId = overlayInstance.attach(fixture.componentInstance.item, overlaySettings); + overlayInstance.show(firstCallId); + tick(); + + const overlayContent = document.getElementsByClassName(CLASS_OVERLAY_CONTENT)[0] as HTMLElement; + expect(overlayContent.style.getPropertyValue('--ig-size')).toEqual('1'); + overlayInstance.detach(firstCallId); + })); + + it('#15228 - Should use provided in show overlay settings ', fakeAsync(() => { + const fixture = TestBed.createComponent(SimpleRefComponent); + fixture.detectChanges(); + const overlayInstance = fixture.componentInstance.overlay; + const id = overlayInstance.attach(SimpleDynamicComponent); + const info = overlayInstance.getOverlayById(id); + const initialPositionSpy = spyOn(info.settings.positionStrategy, 'position').and.callThrough(); + + overlayInstance.show(id); + tick(); + + expect(initialPositionSpy).toHaveBeenCalledTimes(1); + overlayInstance.hide(id); + tick(); + + const os: OverlaySettings = { + excludeFromOutsideClick: [], + positionStrategy: new GlobalPositionStrategy(), + scrollStrategy: new CloseScrollStrategy(), + modal: false, + closeOnOutsideClick: false, + closeOnEscape: true + }; + const lastPositionSpy = spyOn(os.positionStrategy, 'position').and.callThrough(); + overlayInstance.show(id, os); + tick(); + + expect(lastPositionSpy).toHaveBeenCalledTimes(1); + expect(info.settings.scrollStrategy).toBe(os.scrollStrategy); + expect(info.settings.positionStrategy).toBe(os.positionStrategy); + })); + }); + + describe('Unit Tests - Scroll Strategies: ', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, SimpleDynamicWithDirectiveComponent] + }); + })); + it('Should properly initialize Scroll Strategy - Block.', fakeAsync(async () => { + TestBed.overrideComponent(EmptyPageComponent, { + set: { + styles: [`button { + position: absolute; + bottom: -200px; + }`] + } + }); + await TestBed.compileComponents(); + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + const scrollStrat = new BlockScrollStrategy(); + const overlaySettings: OverlaySettings = { + positionStrategy: new GlobalPositionStrategy(), + scrollStrategy: scrollStrat, + modal: false, + closeOnOutsideClick: false + }; + const overlay = fixture.componentInstance.overlay; + spyOn(scrollStrat, 'initialize').and.callThrough(); + spyOn(scrollStrat, 'attach').and.callThrough(); + spyOn(scrollStrat, 'detach').and.callThrough(); + const scrollSpy = spyOn(scrollStrat, 'onScroll').and.callThrough(); + const overlayId = overlay.attach(SimpleDynamicComponent, overlaySettings); + overlay.show(overlayId); + tick(); + + expect(scrollStrat.attach).toHaveBeenCalledTimes(1); + expect(scrollStrat.initialize).toHaveBeenCalledTimes(1); + expect(scrollStrat.detach).toHaveBeenCalledTimes(0); + document.documentElement.dispatchEvent(new Event('scroll')); + expect(scrollSpy).toHaveBeenCalledTimes(1); + + overlay.hide(overlayId); + overlay.detach(overlayId); + tick(); + expect(scrollStrat.detach).toHaveBeenCalledTimes(1); + })); + + it('Should properly initialize Scroll Strategy - Absolute.', fakeAsync(async () => { + TestBed.overrideComponent(EmptyPageComponent, { + set: { + styles: [`button { + position: absolute; + bottom: -200px; + }`] + } + }); + await TestBed.compileComponents(); + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + const scrollStrat = new AbsoluteScrollStrategy(); + const overlaySettings: OverlaySettings = { + positionStrategy: new GlobalPositionStrategy(), + scrollStrategy: scrollStrat, + modal: false, + closeOnOutsideClick: false + }; + const overlay = fixture.componentInstance.overlay; + spyOn(scrollStrat, 'initialize').and.callThrough(); + spyOn(scrollStrat, 'attach').and.callThrough(); + spyOn(scrollStrat, 'detach').and.callThrough(); + const scrollSpy = spyOn(scrollStrat, 'onScroll').and.callThrough(); + const id = overlay.attach(SimpleDynamicComponent, overlaySettings); + overlay.show(id); + tick(); + + expect(scrollStrat.attach).toHaveBeenCalledTimes(1); + expect(scrollStrat.initialize).toHaveBeenCalledTimes(1); + expect(scrollStrat.detach).toHaveBeenCalledTimes(0); + document.documentElement.dispatchEvent(new Event('scroll')); + expect(scrollSpy).toHaveBeenCalledTimes(1); + overlay.hide(id); + overlay.detach(id); + tick(); + expect(scrollStrat.detach).toHaveBeenCalledTimes(1); + })); + + it('Should only call reposition once on scroll - Absolute.', fakeAsync(async () => { + TestBed.overrideComponent(EmptyPageComponent, { + set: { + styles: [`button { + position: absolute; + bottom: -200px; + }`] + } + }); + await TestBed.compileComponents(); + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + const scrollStrat = new AbsoluteScrollStrategy(); + const overlaySettings: OverlaySettings = { + positionStrategy: new GlobalPositionStrategy(), + scrollStrategy: scrollStrat, + modal: false, + closeOnOutsideClick: false + }; + const overlay = fixture.componentInstance.overlay; + const scrollSpy = spyOn(scrollStrat, 'onScroll').and.callThrough(); + spyOn(overlay, 'reposition'); + + const id = overlay.attach(SimpleDynamicComponent, overlaySettings); + overlay.show(id); + tick(); + + const contentElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_CONTENT)[0] as HTMLElement; + contentElement.children[0].dispatchEvent(new Event('scroll')); + expect(scrollSpy).toHaveBeenCalledTimes(1); + expect(overlay.reposition).not.toHaveBeenCalled(); + + document.documentElement.dispatchEvent(new Event('scroll')); + expect(scrollSpy).toHaveBeenCalledTimes(2); + expect(overlay.reposition).toHaveBeenCalledTimes(1); + expect(overlay.reposition).toHaveBeenCalledWith(id); + + overlay.hide(id); + overlay.detach(id); + })); + + it('Should properly initialize Scroll Strategy - Close.', fakeAsync(async () => { + TestBed.overrideComponent(EmptyPageComponent, { + set: { + styles: [`button { + position: absolute; + bottom: -200px; + }`] + } + }); + await TestBed.compileComponents(); + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + const scrollStrat = new CloseScrollStrategy(); + const overlaySettings: OverlaySettings = { + positionStrategy: new GlobalPositionStrategy(), + scrollStrategy: scrollStrat, + modal: false, + closeOnOutsideClick: false + }; + const overlay = fixture.componentInstance.overlay; + spyOn(scrollStrat, 'initialize').and.callThrough(); + spyOn(scrollStrat, 'attach').and.callThrough(); + spyOn(scrollStrat, 'detach').and.callThrough(); + const scrollSpy = spyOn(scrollStrat, 'onScroll').and.callThrough(); + + const id = overlay.attach(SimpleDynamicComponent, overlaySettings); + overlay.show(id); + tick(); + + expect(scrollStrat.attach).toHaveBeenCalledTimes(1); + expect(scrollStrat.initialize).toHaveBeenCalledTimes(1); + expect(scrollStrat.detach).toHaveBeenCalledTimes(0); + + document.documentElement.dispatchEvent(new Event('scroll')); + expect(scrollSpy).toHaveBeenCalledTimes(1); + + overlay.hide(id); + overlay.detach(id); + tick(); + + expect(scrollStrat.detach).toHaveBeenCalledTimes(1); + })); + }); + + describe('Integration tests: ', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, SimpleDynamicWithDirectiveComponent] + }).compileComponents(); + })); + + // 1. Positioning Strategies + // 1.1 Global (show components in the window center - default). + it('Should correctly render igx-overlay', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + const overlay = fixture.componentInstance.overlay; + + const overlaySettings: OverlaySettings = { + positionStrategy: new GlobalPositionStrategy(), + scrollStrategy: new NoOpScrollStrategy(), + modal: false, + closeOnOutsideClick: false, + target: fixture.componentInstance.buttonElement.nativeElement + }; + const positionSettings: PositionSettings = { + horizontalDirection: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Bottom, + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Top + }; + overlaySettings.positionStrategy = new GlobalPositionStrategy(positionSettings); + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(); + fixture.detectChanges(); + const overlayElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_MAIN)[0] as HTMLElement; + const wrapperElement = overlayElement.children[0]; + expect(wrapperElement).toHaveClass(CLASS_OVERLAY_WRAPPER); + + overlay.detachAll(); + })); + + it('Should cover the whole window 100% width and height.', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + fixture.componentInstance.buttonElement.nativeElement.click(); + tick(DEBOUNCE_TIME); + + fixture.detectChanges(); + const wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER_MODAL)[0] as HTMLElement; + + const wrapperRect = wrapperElement.getBoundingClientRect(); + expect(wrapperRect.width).toEqual(window.innerWidth); + expect(wrapperRect.height).toEqual(window.innerHeight); + expect(wrapperRect.left).toEqual(0); + expect(wrapperRect.top).toEqual(0); + + fixture.componentInstance.overlay.detachAll(); + })); + + it('Should show the component inside the igx-overlay wrapper as a content last child.', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + const overlay = fixture.componentInstance.overlay; + + const overlaySettings: OverlaySettings = { + positionStrategy: new GlobalPositionStrategy(), + scrollStrategy: new NoOpScrollStrategy(), + modal: false, + closeOnOutsideClick: false + }; + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(); + const wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER)[0] as HTMLElement; + const contentElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_CONTENT)[0] as HTMLElement; + const componentElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByTagName('test-simple-dynamic-component')[0] as HTMLElement; + expect(wrapperElement.nodeName).toEqual('DIV'); + expect(contentElement.nodeName).toEqual('DIV'); + expect(componentElement.localName).toEqual('test-simple-dynamic-component'); + + overlay.detachAll(); + })); + + it('Should apply the corresponding inline css to the overlay wrapper div element for each alignment.', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + const overlay = fixture.componentInstance.overlay; + + const overlaySettings: OverlaySettings = { + target: new Point(0, 0), + modal: false, + closeOnOutsideClick: false + }; + const positionSettings: PositionSettings = { + horizontalDirection: HorizontalAlignment.Left, + verticalDirection: VerticalAlignment.Top, + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Top + }; + + const horAl = Object.keys(HorizontalAlignment).filter(key => !isNaN(Number(HorizontalAlignment[key]))); + const verAl = Object.keys(VerticalAlignment).filter(key => !isNaN(Number(VerticalAlignment[key]))); + const cssStyles: Array = ['flex-start', 'center', 'flex-end']; + + for (let i = 0; i < horAl.length; i++) { + positionSettings.horizontalDirection = HorizontalAlignment[horAl[i]]; + for (let j = 0; j < verAl.length; j++) { + positionSettings.verticalDirection = VerticalAlignment[verAl[j]]; + overlaySettings.positionStrategy = new GlobalPositionStrategy(positionSettings); + const id = overlay.attach(SimpleDynamicComponent, overlaySettings); + overlay.show(id); + tick(); + + const wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER)[0] as HTMLElement; + expect(wrapperElement.style.justifyContent).toBe(cssStyles[i]); + expect(wrapperElement.style.alignItems).toBe(cssStyles[j]); + overlay.detach(id); + tick(); + } + } + })); + + it('Should center the shown component in the igx-overlay (visible window) - default.', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + const overlay = fixture.componentInstance.overlay; + + overlay.show(overlay.attach(SimpleDynamicComponent)); + tick(); + const componentElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByTagName('test-simple-dynamic-component')[0] as HTMLElement; + const componentRect = componentElement.getBoundingClientRect(); + expect((window.innerWidth - componentRect.width) / 2).toEqual(componentRect.left); + expect((window.innerHeight - componentRect.height) / 2).toEqual(componentRect.top); + + overlay.detachAll(); + })); + + it('Should display a new instance of the same component/options exactly on top of the previous one.', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + const overlay = fixture.componentInstance.overlay; + + overlay.show(overlay.attach(SimpleDynamicComponent)); + overlay.show(overlay.attach(SimpleDynamicComponent)); + tick(DEBOUNCE_TIME); + const wrapperElements = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER_MODAL) as HTMLCollectionOf; + const wrapperElement1 = wrapperElements[0]; + const wrapperElement2 = wrapperElements[1]; + const componentElement1 = wrapperElement1.getElementsByTagName('test-simple-dynamic-component')[0] as HTMLElement; + const componentElement2 = wrapperElement2.getElementsByTagName('test-simple-dynamic-component')[0] as HTMLElement; + const componentRect1 = componentElement1.getBoundingClientRect(); + const componentRect2 = componentElement2.getBoundingClientRect(); + expect(componentRect1.left).toEqual(componentRect2.left); + expect(componentRect1.top).toEqual(componentRect2.top); + expect(componentRect1.width).toEqual(componentRect2.width); + expect(componentRect1.height).toEqual(componentRect2.height); + + overlay.detachAll(); + })); + + it('Should show a component bigger than the visible window as centered.', fakeAsync(() => { + + // overlay div is forced to has width and height equal to 0. This will prevent body + // to show any scrollbars whatever the size of the component is. + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + const overlay = fixture.componentInstance.overlay; + + overlay.show(overlay.attach(SimpleBigSizeComponent)); + tick(); + const wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER_FLEX)[0] as HTMLElement; + const componentElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByTagName('ng-component')[0] as HTMLElement; + const componentRect = componentElement.getBoundingClientRect(); + + expect(componentRect.left).toBe((wrapperElement.clientWidth - componentRect.width) / 2); + expect(componentRect.top).toBe((wrapperElement.clientHeight - componentRect.height) / 2); + + overlay.detachAll(); + })); + + // 1.1.1 Global Css + it('Should apply the css class on igx-overlay component div wrapper.' + + 'Test defaults: When no positionStrategy is passed use GlobalPositionStrategy with default PositionSettings and css class.', + fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + const overlay = fixture.componentInstance.overlay; + + overlay.show(overlay.attach(SimpleDynamicComponent)); + tick(DEBOUNCE_TIME); + fixture.detectChanges(); + + // overlay container IS NOT a child of the debugElement (attached to body, not app-root) + const wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER_MODAL)[0] as HTMLElement; + + expect(wrapperElement).toBeTruthy(); + expect(wrapperElement.localName).toEqual('div'); + + overlay.detachAll(); + }) + ); + + it('Should apply css class on igx-overlay component inner div wrapper.', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + const overlay = fixture.componentInstance.overlay; + const overlaySettings: OverlaySettings = { + positionStrategy: new GlobalPositionStrategy(), + scrollStrategy: new NoOpScrollStrategy(), + modal: false, + closeOnOutsideClick: false + }; + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(); + fixture.detectChanges(); + const contentElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_CONTENT)[0] as HTMLElement; + expect(contentElement).toBeTruthy(); + expect(contentElement.localName).toEqual('div'); + + overlay.detachAll(); + })); + + // 1.2 ConnectedPositioningStrategy(show components based on a specified position base point, horizontal and vertical alignment) + it('Should correctly render igx-overlay', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + const overlay = fixture.componentInstance.overlay; + const overlaySettings: OverlaySettings = { + positionStrategy: new ConnectedPositioningStrategy(), + scrollStrategy: new NoOpScrollStrategy(), + modal: false, + closeOnOutsideClick: false, + target: fixture.componentInstance.buttonElement.nativeElement + }; + const positionSettings: PositionSettings = { + horizontalDirection: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Bottom, + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Top + }; + overlaySettings.positionStrategy = new ConnectedPositioningStrategy(positionSettings); + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(); + + const wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER)[0] as HTMLElement; + expect(wrapperElement).toBeDefined(); + expect(wrapperElement).toHaveClass(CLASS_OVERLAY_WRAPPER); + + overlay.detachAll(); + })); + + it('Should cover the whole window 100% width and height.', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + const overlay = fixture.componentInstance.overlay; + const positionSettings: PositionSettings = { + horizontalDirection: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Bottom, + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Top + }; + const overlaySettings: OverlaySettings = { + target: fixture.componentInstance.buttonElement.nativeElement, + positionStrategy: new ConnectedPositioningStrategy(positionSettings), + scrollStrategy: new NoOpScrollStrategy(), + modal: false, + closeOnOutsideClick: false + }; + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(); + const wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER)[0] as HTMLElement; + expect(wrapperElement.clientHeight).toEqual(window.innerHeight); + expect(wrapperElement.clientWidth).toEqual(window.innerWidth); + + overlay.detachAll(); + })); + + it('It should position the shown component inside the igx-overlay wrapper as a content last child.', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + const overlay = fixture.componentInstance.overlay; + const overlaySettings: OverlaySettings = { + positionStrategy: new ConnectedPositioningStrategy(), + scrollStrategy: new NoOpScrollStrategy(), + modal: false, + closeOnOutsideClick: false + }; + + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(); + const wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER)[0] as HTMLElement; + const contentElement = wrapperElement.firstChild; + const componentElement = contentElement.lastChild.lastChild; // wrapped in 'NG-COMPONENT' + expect(wrapperElement.nodeName).toEqual('DIV'); + expect(wrapperElement.firstChild.nodeName).toEqual('DIV'); + expect(componentElement.nodeName).toEqual('DIV'); + + overlay.detachAll(); + })); + + it(`Should use StartPoint:Left/Bottom, Direction Right/Bottom and openAnimation: scaleInVerTop, + closeAnimation: scaleOutVerTop as default options when using a ConnectedPositioningStrategy without passing options.`, () => { + const strategy = new ConnectedPositioningStrategy(); + + const expectedDefaults = { + horizontalDirection: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Bottom, + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Bottom, + openAnimation: scaleInVerTop, + closeAnimation: scaleOutVerTop, + minSize: { width: 0, height: 0 } + }; + + expect(strategy.settings).toEqual(expectedDefaults); + }); + + // adding more than one component to show in igx-overlay: + it('Should render the component exactly on top of the previous one when adding a new instance with default settings.', + fakeAsync(() => { + const fixture = TestBed.createComponent(TopLeftOffsetComponent); + const overlay = fixture.componentInstance.overlay; + const overlaySettings: OverlaySettings = { + positionStrategy: new ConnectedPositioningStrategy() + }; + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(DEBOUNCE_TIME); + fixture.detectChanges(); + + const wrapperElements = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER_MODAL) as HTMLCollectionOf; + const wrapperElement1 = wrapperElements[0]; + const wrapperElement2 = wrapperElements[1]; + const componentElement1 = wrapperElement1.getElementsByTagName('test-simple-dynamic-component')[0] as HTMLElement; + const componentElement2 = wrapperElement2.getElementsByTagName('test-simple-dynamic-component')[0] as HTMLElement; + const componentRect1 = componentElement1.getBoundingClientRect(); + const componentRect2 = componentElement2.getBoundingClientRect(); + expect(componentRect1.left).toEqual(0); + expect(componentRect1.left).toEqual(componentRect2.left); + expect(componentRect1.top).toEqual(0); + expect(componentRect1.top).toEqual(componentRect2.top); + expect(componentRect1.width).toEqual(componentRect2.width); + expect(componentRect1.height).toEqual(componentRect2.height); + + overlay.detachAll(); + })); + + it('Should render the component exactly on top of the previous one when adding a new instance with the same options.', + fakeAsync(() => { + const fixture = TestBed.createComponent(TopLeftOffsetComponent); + const x = 200; + const y = 300; + + const overlay = fixture.componentInstance.overlay; + const positionSettings: PositionSettings = { + horizontalDirection: HorizontalAlignment.Left, + verticalDirection: VerticalAlignment.Top, + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Bottom, + }; + const overlaySettings: OverlaySettings = { + target: new Point(x, y), + positionStrategy: new ConnectedPositioningStrategy(positionSettings) + }; + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(DEBOUNCE_TIME); + fixture.detectChanges(); + + const wrapperElements = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER_MODAL) as HTMLCollectionOf; + const wrapperElement1 = wrapperElements[0]; + const wrapperElement2 = wrapperElements[1]; + const componentElement1 = wrapperElement1.getElementsByTagName('test-simple-dynamic-component')[0] as HTMLElement; + const componentElement2 = wrapperElement2.getElementsByTagName('test-simple-dynamic-component')[0] as HTMLElement; + const componentRect1 = componentElement1.getBoundingClientRect(); + const componentRect2 = componentElement2.getBoundingClientRect(); + expect(componentRect1.left).toEqual(x - componentRect1.width); + expect(componentRect1.left).toEqual(componentRect2.left); + expect(componentRect1.top).toEqual(y - componentRect1.height); + expect(componentRect1.top).toEqual(componentRect2.top); + expect(componentRect1.width).toEqual(componentRect2.width); + expect(componentRect1.height).toEqual(componentRect2.height); + + overlay.detachAll(); + })); + + it(`Should change the state of the component to closed when reaching threshold and closing scroll strategy is used.`, + fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + + // add one div far away to the right and to the bottom in order scrollbars to appear on page + addScrollDivToElement(fixture.nativeElement); + + const scrollStrat = new CloseScrollStrategy(); + fixture.detectChanges(); + const overlaySettings: OverlaySettings = { + scrollStrategy: scrollStrat, + modal: false, + closeOnOutsideClick: false + }; + const overlay = fixture.componentInstance.overlay; + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(); + + let wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER)[0] as HTMLElement; + expect(wrapperElement.style.visibility).toEqual(''); + expect(document.documentElement.scrollTop).toEqual(0); + + document.documentElement.scrollTop += 9; + document.documentElement.dispatchEvent(new Event('scroll')); + tick(); + + expect(document.documentElement.scrollTop).toEqual(9); + wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER)[0] as HTMLElement; + expect(wrapperElement.style.visibility).toEqual(''); + + document.documentElement.scrollTop += 25; + document.documentElement.dispatchEvent(new Event('scroll')); + tick(); + + expect(document.documentElement.scrollTop).toEqual(34); + wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER)[0] as HTMLElement; + expect(wrapperElement.style.visibility).toEqual('hidden'); + + overlay.detachAll() + })); + + it('Should scroll component with the scrolling container when absolute scroll strategy is used.', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + + // add one div far away to the right and to the bottom in order scrollbars to appear on page + addScrollDivToElement(fixture.nativeElement); + const scrollStrat = new AbsoluteScrollStrategy(); + fixture.detectChanges(); + const overlaySettings: OverlaySettings = { + positionStrategy: new ConnectedPositioningStrategy(), + scrollStrategy: scrollStrat, + modal: false, + closeOnOutsideClick: false + }; + const buttonElement = fixture.componentInstance.buttonElement.nativeElement; + const overlay = fixture.componentInstance.overlay; + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(); + + expect(document.documentElement.scrollTop).toEqual(0); + let contentElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_CONTENT)[0] as HTMLElement; + let overlayChildPosition = contentElement.lastElementChild.getBoundingClientRect(); + expect(overlayChildPosition.y).toEqual(0); + expect(buttonElement.getBoundingClientRect().y).toEqual(0); + + document.documentElement.dispatchEvent(new Event('scroll')); + tick(); + expect(document.documentElement.scrollTop).toEqual(0); + + document.documentElement.scrollTop += 25; + document.documentElement.dispatchEvent(new Event('scroll')); + tick(); + expect(document.documentElement.scrollTop).toEqual(25); + contentElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_CONTENT)[0] as HTMLElement; + overlayChildPosition = contentElement.lastElementChild.getBoundingClientRect(); + expect(overlayChildPosition.y).toEqual(0); + expect(buttonElement.getBoundingClientRect().y).toEqual(-25); + + document.documentElement.scrollTop += 500; + document.documentElement.dispatchEvent(new Event('scroll')); + tick(); + contentElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_CONTENT)[0] as HTMLElement; + overlayChildPosition = contentElement.lastElementChild.getBoundingClientRect(); + expect(overlayChildPosition.y).toEqual(0); + expect(buttonElement.getBoundingClientRect().y).toEqual(-525); + expect(document.documentElement.scrollTop).toEqual(525); + expect(document.getElementsByClassName(CLASS_OVERLAY_WRAPPER).length).toEqual(1); + scrollStrat.detach(); + document.documentElement.scrollTop = 0; + + overlay.detachAll(); + })); + + // 1.2.1 Connected Css + it('Should apply css class on igx-overlay component div wrapper.', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + const overlay = fixture.componentInstance.overlay; + const overlaySettings: OverlaySettings = { + positionStrategy: new ConnectedPositioningStrategy(), + scrollStrategy: new NoOpScrollStrategy(), + modal: false, + closeOnOutsideClick: false + }; + + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(); + + // overlay container IS NOT a child of the debugElement (attached to body, not app-root) + const wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER)[0] as HTMLElement; + expect(wrapperElement).toBeTruthy(); + expect(wrapperElement.localName).toEqual('div'); + + overlay.detachAll(); + })); + + // 1.2.2 Connected strategy position method + it('Should position component based on Point only when connected position strategy is used.', () => { + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + // for a Point(300,300); + const expectedTopForPoint: number[] = [240, 270, 300]; // top/middle/bottom/ + const expectedLeftForPoint: number[] = [240, 270, 300]; // left/center/right/ + + const size = { width: 60, height: 60 }; + const componentElement = document.createElement('div'); + componentElement.setAttribute('style', 'width:60px; height:60px; color:green;'); + const contentElement = document.createElement('div'); + contentElement.setAttribute('style', 'position: absolute; color:gray;'); + contentElement.classList.add('contentWrapper'); + contentElement.appendChild(componentElement); + const wrapperElement = document.createElement('div'); + wrapperElement.setAttribute('style', 'position: fixed; width: 100%; height: 100%; top: 0; left: 0;'); + wrapperElement.appendChild(contentElement); + document.body.appendChild(wrapperElement); + + const horAl = Object.keys(HorizontalAlignment).filter(key => !isNaN(Number(HorizontalAlignment[key]))); + const verAl = Object.keys(VerticalAlignment).filter(key => !isNaN(Number(VerticalAlignment[key]))); + + fixture.detectChanges(); + for (let horizontalDirection = 0; horizontalDirection < horAl.length; horizontalDirection++) { + for (let verticalDirection = 0; verticalDirection < verAl.length; verticalDirection++) { + + // start Point is static Top/Left at 300/300 + const positionSettings2 = { + horizontalDirection: HorizontalAlignment[horAl[horizontalDirection]], + verticalDirection: VerticalAlignment[verAl[verticalDirection]], + element: null, + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Top + }; + + const strategy = new ConnectedPositioningStrategy(positionSettings2); + strategy.position(contentElement, size, null, false, new Point(300, 300)); + fixture.detectChanges(); + + const left = expectedLeftForPoint[horizontalDirection]; + const top = expectedTopForPoint[verticalDirection]; + const contentElementRect = contentElement.getBoundingClientRect(); + expect(contentElementRect.left).toBe(left); + expect(contentElementRect.top).toBe(top); + } + } + document.body.removeChild(wrapperElement); + }); + + it('Should position component based on element and start point when connected position strategy is used.', () => { + const fixture = TestBed.createComponent(TopLeftOffsetComponent); + fixture.detectChanges(); + + // for a Point(300,300); + const expectedTopForPoint: Array = [240, 270, 300]; // top/middle/bottom/ + const expectedLeftForPoint: Array = [240, 270, 300]; // left/center/right/ + + const size = { width: 60, height: 60 }; + const componentElement = document.createElement('div'); + componentElement.setAttribute('style', 'width:60px; height:60px; color:green;'); + const contentElement = document.createElement('div'); + contentElement.setAttribute('style', 'color:gray; position: absolute;'); + contentElement.classList.add('contentWrapper'); + contentElement.appendChild(componentElement); + const wrapperElement = document.createElement('div'); + wrapperElement.setAttribute('style', 'position: fixed; width: 100%; height: 100%; top: 0; left: 0;'); + wrapperElement.appendChild(contentElement); + document.body.appendChild(wrapperElement); + + const horAl = Object.keys(HorizontalAlignment).filter(key => !isNaN(Number(HorizontalAlignment[key]))); + const verAl = Object.keys(VerticalAlignment).filter(key => !isNaN(Number(VerticalAlignment[key]))); + const targetEl: HTMLElement = document.getElementsByClassName('300_button')[0] as HTMLElement; + + fixture.detectChanges(); + + // loop trough and test all possible combinations (count 81) for StartPoint and Direction. + for (let horizontalStartPoint = 0; horizontalStartPoint < horAl.length; horizontalStartPoint++) { + for (let verticalStartPoint = 0; verticalStartPoint < verAl.length; verticalStartPoint++) { + for (let horizontalDirection = 0; horizontalDirection < horAl.length; horizontalDirection++) { + for (let verticalDirection = 0; verticalDirection < verAl.length; verticalDirection++) { + // TODO: add additional check for different start points + // start Point is static Top/Left at 300/300 + const positionSettings = { + horizontalDirection: HorizontalAlignment[horAl[horizontalDirection]], + verticalDirection: VerticalAlignment[verAl[verticalDirection]], + element: null, + horizontalStartPoint: HorizontalAlignment[horAl[horizontalStartPoint]], + verticalStartPoint: VerticalAlignment[verAl[verticalStartPoint]], + }; + const strategy = new ConnectedPositioningStrategy(positionSettings); + strategy.position(contentElement, size, null, false, targetEl); + fixture.detectChanges(); + const left = expectedLeftForPoint[horizontalDirection] + 50 * horizontalStartPoint; + const top = expectedTopForPoint[verticalDirection] + 30 * verticalStartPoint; + const contentElementRect = contentElement.getBoundingClientRect(); + expect(contentElementRect.left).toBe(left); + expect(contentElementRect.top).toBe(top); + } + } + } + } + document.body.removeChild(wrapperElement); + }); + + // 1.3 AutoPosition (fit the shown component into the visible window.) + it('Should correctly render igx-overlay', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + const overlay = fixture.componentInstance.overlay; + + const positionSettings: PositionSettings = { + horizontalDirection: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Bottom, + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Top + }; + const overlaySettings: OverlaySettings = { + target: fixture.componentInstance.buttonElement.nativeElement, + positionStrategy: new AutoPositionStrategy(positionSettings), + scrollStrategy: new NoOpScrollStrategy(), + modal: false, + closeOnOutsideClick: false + }; + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(); + fixture.detectChanges(); + const wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER)[0] as HTMLElement; + expect(wrapperElement).toBeDefined(); + expect(wrapperElement).toHaveClass(CLASS_OVERLAY_WRAPPER); + + overlay.detachAll(); + })); + + it('Should cover the whole window 100% width and height.', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + const overlay = fixture.componentInstance.overlay; + const positionSettings: PositionSettings = { + horizontalDirection: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Bottom, + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Top + }; + const overlaySettings: OverlaySettings = { + target: fixture.componentInstance.buttonElement.nativeElement, + positionStrategy: new AutoPositionStrategy(positionSettings), + scrollStrategy: new NoOpScrollStrategy(), + modal: false, + closeOnOutsideClick: false + }; + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(); + fixture.detectChanges(); + + const wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER)[0] as HTMLElement; + expect(wrapperElement.clientHeight).toEqual(window.innerHeight); + expect(wrapperElement.clientWidth).toEqual(window.innerWidth); + + overlay.detachAll(); + })); + + it('Should append the shown component inside the igx-overlay as a last child.', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + const overlay = fixture.componentInstance.overlay; + const positionSettings: PositionSettings = { + horizontalDirection: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Bottom, + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Top + }; + const overlaySettings: OverlaySettings = { + target: fixture.componentInstance.buttonElement.nativeElement, + positionStrategy: new AutoPositionStrategy(positionSettings), + scrollStrategy: new NoOpScrollStrategy(), + modal: false, + closeOnOutsideClick: false + }; + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(); + + fixture.detectChanges(); + const contentElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_CONTENT)[0] as HTMLElement; + expect(contentElement.children.length).toEqual(1); + + const componentElement = contentElement.getElementsByTagName('div')[0]; + let overlayStyle = componentElement.getAttribute('style'); + overlayStyle = formatString(overlayStyle, formatters); + expect(overlayStyle).toEqual('width:100px; height:100px; background-color:red'); + + overlay.detachAll(); + })); + + it('Should show the component inside of the viewport if it would normally be outside of bounds, BOTTOM + RIGHT.', fakeAsync(() => { + const fixture = TestBed.createComponent(DownRightButtonComponent); + fixture.detectChanges(); + + fixture.componentInstance.positionStrategy = new AutoPositionStrategy(); + const currentElement = fixture.componentInstance; + const buttonElement = fixture.componentInstance.buttonElement.nativeElement; + fixture.detectChanges(); + currentElement.ButtonPositioningSettings.horizontalDirection = HorizontalAlignment.Right; + currentElement.ButtonPositioningSettings.verticalDirection = VerticalAlignment.Bottom; + currentElement.ButtonPositioningSettings.verticalStartPoint = VerticalAlignment.Bottom; + currentElement.ButtonPositioningSettings.horizontalStartPoint = HorizontalAlignment.Right; + fixture.componentInstance.target = buttonElement; + buttonElement.click(); + fixture.detectChanges(); + tick(); + + fixture.detectChanges(); + const contentElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_CONTENT)[0] as HTMLElement; + expect(contentElement.children.length).toEqual(1); + + const componentElement = contentElement.getElementsByTagName('test-simple-dynamic-component')[0]; + const buttonLeft = buttonElement.offsetLeft; + const buttonTop = buttonElement.offsetTop; + const expectedLeft = buttonLeft - componentElement.clientWidth; + const expectedTop = buttonTop - componentElement.clientHeight; + const contentLeft = contentElement.getBoundingClientRect().left; + const contentTop = contentElement.getBoundingClientRect().top; + expect(contentTop).toEqual(expectedTop); + expect(contentLeft).toEqual(expectedLeft); + + const componentDiv = componentElement.getElementsByTagName('div')[0]; + const expectedStyle = 'width:100px; height:100px; background-color:red'; + let overlayStyle = componentDiv.getAttribute('style'); + overlayStyle = formatString(overlayStyle, formatters); + expect(overlayStyle).toEqual(expectedStyle); + + fixture.componentInstance.overlay.detachAll(); + })); + + // it('Should display each shown component based on the options specified if the component fits into the visible window.', + // fakeAsync(() => { + // const fixture = TestBed.createComponent(EmptyPageComponent); + // fixture.detectChanges(); + + // const overlay = fixture.componentInstance.overlay; + // const button = fixture.componentInstance.buttonElement.nativeElement; + // button.style.left = '150px'; + // button.style.top = '150px'; + // button.style.position = 'relative'; + + // const hAlignmentArray = Object.keys(HorizontalAlignment).filter(key => !isNaN(Number(HorizontalAlignment[key]))); + // const vAlignmentArray = Object.keys(VerticalAlignment).filter(key => !isNaN(Number(VerticalAlignment[key]))); + // hAlignmentArray.forEach(horizontalStartPoint => { + // vAlignmentArray.forEach(verticalStartPoint => { + // hAlignmentArray.forEach(horizontalDirection => { + // // do not check Center as we do nothing here + // if (horizontalDirection === 'Center') { + // return; + // } + // vAlignmentArray.forEach(verticalDirection => { + // // do not check Middle as we do nothing here + // if (verticalDirection === 'Middle') { + // return; + // } + // const positionSettings: PositionSettings = {}; + // positionSettings.horizontalStartPoint = HorizontalAlignment[horizontalStartPoint]; + // positionSettings.verticalStartPoint = VerticalAlignment[verticalStartPoint]; + // positionSettings.horizontalDirection = HorizontalAlignment[horizontalDirection]; + // positionSettings.verticalDirection = VerticalAlignment[verticalDirection]; + + // const overlaySettings: OverlaySettings = { + // target: button, + // positionStrategy: new AutoPositionStrategy(positionSettings), + // modal: false, + // closeOnOutsideClick: false + // }; + + // overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + // tick(); + // fixture.detectChanges(); + + // const targetRect = (overlaySettings.target as HTMLElement).getBoundingClientRect(); + // const componentElement = (fixture.debugElement.nativeElement as HTMLElement) + // .parentElement.getElementsByTagName('test-simple-dynamic-component')[0]; + // const componentRect = componentElement.getBoundingClientRect(); + // const screenRect: ClientRect = { + // left: 0, + // top: 0, + // right: window.innerWidth, + // bottom: window.innerHeight, + // width: window.innerWidth, + // height: window.innerHeight, + // }; + + // const location = getOverlayWrapperLocation(positionSettings, targetRect, componentRect, screenRect); + // expect(componentRect.top.toFixed(1)).toEqual(location.y.toFixed(1)); + // expect(componentRect.left.toFixed(1)).toEqual(location.x.toFixed(1)); + // expect(document.body.scrollHeight > document.body.clientHeight).toBeFalsy(); // check scrollbar + // fixture.componentInstance.overlay.detachAll(); + // tick(); + // fixture.detectChanges(); + // }); + // }); + // }); + // }); + // })); + + // it(`Should reposition the component and render it correctly in the window, even when the rendering options passed + // should result in otherwise a partially hidden component. No scrollbars should appear.`, + // fakeAsync(() => { + // const fixture = TestBed.createComponent(EmptyPageComponent); + // fixture.detectChanges(); + + // const button = fixture.componentInstance.buttonElement.nativeElement; + // const overlay = fixture.componentInstance.overlay; + // button.style.position = 'relative'; + // button.style.width = '50px'; + // button.style.height = '50px'; + // const buttonLocations = [ + // { left: `0px`, top: `0px` }, // topLeft + // { left: `${window.innerWidth - 200}px`, top: `0px` }, // topRight + // { left: `0px`, top: `${window.innerHeight - 200}px` }, // bottomLeft + // { left: `${window.innerWidth - 200}px`, top: `${window.innerHeight - 200}px` } // bottomRight + // ]; + // const hAlignmentArray = Object.keys(HorizontalAlignment).filter(key => !isNaN(Number(HorizontalAlignment[key]))); + // const vAlignmentArray = Object.keys(VerticalAlignment).filter(key => !isNaN(Number(VerticalAlignment[key]))); + // for (const buttonLocation of buttonLocations) { + // for (const horizontalStartPoint of hAlignmentArray) { + // for (const verticalStartPoint of vAlignmentArray) { + // for (const horizontalDirection of hAlignmentArray) { + // for (const verticalDirection of vAlignmentArray) { + + // const positionSettings: PositionSettings = {}; + // button.style.left = buttonLocation.left; + // button.style.top = buttonLocation.top; + + // positionSettings.horizontalStartPoint = HorizontalAlignment[horizontalStartPoint]; + // positionSettings.verticalStartPoint = VerticalAlignment[verticalStartPoint]; + // positionSettings.horizontalDirection = HorizontalAlignment[horizontalDirection]; + // positionSettings.verticalDirection = VerticalAlignment[verticalDirection]; + + // const overlaySettings: OverlaySettings = { + // target: button, + // positionStrategy: new AutoPositionStrategy(positionSettings), + // modal: false, + // closeOnOutsideClick: false + // }; + + // overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + // tick(); + // fixture.detectChanges(); + + // const targetRect = (overlaySettings.target as HTMLElement).getBoundingClientRect(); + // const contentElement = (fixture.nativeElement as HTMLElement) + // .parentElement.getElementsByClassName(CLASS_OVERLAY_CONTENT)[0] as HTMLElement; + // const contentRect = contentElement.getBoundingClientRect(); + // const screenRect: ClientRect = { + // left: 0, + // top: 0, + // right: window.innerWidth, + // bottom: window.innerHeight, + // width: window.innerWidth, + // height: window.innerHeight, + // }; + + // const loc = getOverlayWrapperLocation(positionSettings, targetRect, contentRect, screenRect); + // expect(contentRect.top.toFixed(1)) + // .withContext(`YYY HD: ${horizontalDirection}; VD: ${verticalDirection}; ` + + // `HSP: ${horizontalStartPoint}; VSP: ${verticalStartPoint}; ` + + // `BL: ${buttonLocation.left}; BT: ${buttonLocation.top}; ` + + // `STYLE: ${contentElement.getAttribute('style')};`) + // .toEqual(loc.y.toFixed(1)); + // expect(contentRect.left.toFixed(1)) + // .withContext(`XXX HD: ${horizontalDirection}; VD: ${verticalDirection}; ` + + // `HSP: ${horizontalStartPoint}; VSP: ${verticalStartPoint}; ` + + // `BL: ${buttonLocation.left}; BT: ${buttonLocation.top}; ` + + // `STYLE: ${contentElement.getAttribute('style')};`) + // .toEqual(loc.x.toFixed(1)); + // expect(document.body.scrollHeight > document.body.clientHeight).toBeFalsy(); // check scrollbar + // fixture.componentInstance.overlay.detachAll(); + // tick(); + // fixture.detectChanges(); + // } + // } + // } + // } + // } + // })); + + it('Should render margins correctly.', fakeAsync(() => { + const expectedMargin = '0px'; + const fixture = TestBed.createComponent(EmptyPageComponent); + const overlay = fixture.componentInstance.overlay; + + fixture.detectChanges(); + const button = fixture.componentInstance.buttonElement.nativeElement; + const hAlignmentArray = Object.keys(HorizontalAlignment).filter(key => !isNaN(Number(HorizontalAlignment[key]))); + const vAlignmentArray = Object.keys(VerticalAlignment).filter(key => !isNaN(Number(VerticalAlignment[key]))); + + hAlignmentArray.forEach(hDirection => { + vAlignmentArray.forEach(vDirection => { + hAlignmentArray.forEach(hAlignment => { + vAlignmentArray.forEach(vAlignment => { + const positionSettings: PositionSettings = { + horizontalDirection: hDirection as any, + verticalDirection: vDirection as any, + horizontalStartPoint: hAlignment as any, + verticalStartPoint: vAlignment as any + }; + const overlaySettings: OverlaySettings = { + target: button, + positionStrategy: new AutoPositionStrategy(positionSettings), + scrollStrategy: new NoOpScrollStrategy(), + modal: false, + closeOnOutsideClick: false + }; + verifyOverlayMargins(overlaySettings, overlay, fixture, expectedMargin); + }); + }); + }); + }); + })); + + // When adding more than one component to show in igx-overlay: + it('When the options used to fit the component in the window - adding a new instance of the component with the ' + + ' same options will render it on top of the previous one.', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + const overlay = fixture.componentInstance.overlay; + const button = fixture.componentInstance.buttonElement.nativeElement; + const positionSettings: PositionSettings = { + horizontalDirection: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Bottom, + horizontalStartPoint: HorizontalAlignment.Center, + verticalStartPoint: VerticalAlignment.Bottom + }; + const overlaySettings: OverlaySettings = { + target: button, + positionStrategy: new AutoPositionStrategy(positionSettings), + scrollStrategy: new NoOpScrollStrategy(), + modal: false, + closeOnOutsideClick: false + }; + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + fixture.detectChanges(); + tick(); + + const wrapperElements = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER) as HTMLCollectionOf; + const wrapperElement1 = wrapperElements[0]; + const wrapperElement2 = wrapperElements[1]; + const componentElement1 = wrapperElement1.getElementsByTagName('test-simple-dynamic-component')[0] as HTMLElement; + const componentElement2 = wrapperElement2.getElementsByTagName('test-simple-dynamic-component')[0] as HTMLElement; + const componentRect1 = componentElement1.getBoundingClientRect(); + const componentRect2 = componentElement2.getBoundingClientRect(); + const buttonRect = button.getBoundingClientRect(); + expect(componentRect1.left.toFixed(1)).toEqual((buttonRect.left + buttonRect.width / 2).toFixed(1)); + expect(componentRect1.left.toFixed(1)).toEqual(componentRect2.left.toFixed(1)); + expect(componentRect1.top.toFixed(1)).toEqual((buttonRect.top + buttonRect.height).toFixed(1)); + expect(componentRect1.top.toFixed(1)).toEqual(componentRect2.top.toFixed(1)); + expect(componentRect1.width.toFixed(1)).toEqual(componentRect2.width.toFixed(1)); + expect(componentRect1.height.toFixed(1)).toEqual(componentRect2.height.toFixed(1)); + + overlay.detachAll(); + })); + + // When adding more than one component to show in igx-overlay and the options used will not fit the component in the + // window, so AutoPosition is used. + it('When adding a new instance of the component with the same options, will render it on top of the previous one.', + fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + const overlay = fixture.componentInstance.overlay; + const button = fixture.componentInstance.buttonElement.nativeElement; + const positionSettings: PositionSettings = { + horizontalDirection: HorizontalAlignment.Left, + verticalDirection: VerticalAlignment.Top, + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Top + }; + const overlaySettings: OverlaySettings = { + target: button, + positionStrategy: new AutoPositionStrategy(positionSettings), + scrollStrategy: new NoOpScrollStrategy(), + modal: false, + closeOnOutsideClick: false + }; + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + fixture.detectChanges(); + tick(); + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + fixture.detectChanges(); + tick(); + const buttonRect = button.getBoundingClientRect(); + + const wrapperElements = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER) as HTMLCollectionOf; + const wrapperElement1 = wrapperElements[0]; + const wrapperElement2 = wrapperElements[1]; + const componentElement1 = wrapperElement1.getElementsByTagName('test-simple-dynamic-component')[0] as HTMLElement; + const componentElement2 = wrapperElement2.getElementsByTagName('test-simple-dynamic-component')[0] as HTMLElement; + const componentRect1 = componentElement1.getBoundingClientRect(); + const componentRect2 = componentElement2.getBoundingClientRect(); + + // Will be positioned on the right of the button + expect(Math.round(componentRect1.left)).toEqual(Math.round(buttonRect.right)); + expect(Math.round(componentRect1.left)).toEqual(Math.round(componentRect2.left)); // Are on the same spot + expect(Math.round(componentRect1.top)).toEqual(Math.round(componentRect2.top)); // Will have the same top + expect(Math.round(componentRect1.width)).toEqual(Math.round(componentRect2.width)); // Will have the same width + expect(Math.round(componentRect1.height)).toEqual(Math.round(componentRect2.height)); // Will have the same height + + overlay.detachAll(); + })); + + it(`Should persist the component's open state when scrolling, when scrolling and noOP scroll strategy is used + (expanded DropDown remains expanded).`, fakeAsync(() => { + // TO DO replace Spies with css class and/or getBoundingClientRect. + const fixture = TestBed.createComponent(EmptyPageComponent); + const scrollTolerance = 10; + const scrollStrategy = new BlockScrollStrategy(); + const overlay = fixture.componentInstance.overlay; + const overlaySettings: OverlaySettings = { + modal: false, + scrollStrategy, + positionStrategy: new GlobalPositionStrategy() + }; + + spyOn(scrollStrategy, 'initialize').and.callThrough(); + spyOn(scrollStrategy, 'attach').and.callThrough(); + spyOn(scrollStrategy, 'detach').and.callThrough(); + spyOn(overlay, 'hide').and.callThrough(); + + const scrollSpy = spyOn(scrollStrategy, 'onScroll').and.callThrough(); + + const overlayId = overlay.attach(SimpleDynamicComponent, overlaySettings); + overlay.show(overlayId); + tick(); + expect(scrollStrategy.initialize).toHaveBeenCalledTimes(1); + expect(scrollStrategy.attach).toHaveBeenCalledTimes(1); + expect(scrollStrategy.detach).toHaveBeenCalledTimes(0); + expect(overlay.hide).toHaveBeenCalledTimes(0); + document.documentElement.scrollTop += scrollTolerance; + document.documentElement.dispatchEvent(new Event('scroll')); + tick(); + expect(scrollSpy).toHaveBeenCalledTimes(1); + expect(overlay.hide).toHaveBeenCalledTimes(0); + expect(scrollStrategy.detach).toHaveBeenCalledTimes(0); + + overlay.detach(overlayId); + })); + + it('Should persist the component open state when scrolling and absolute scroll strategy is used.', fakeAsync(() => { + // TO DO replace Spies with css class and/or getBoundingClientRect. + const fixture = TestBed.createComponent(EmptyPageComponent); + const scrollTolerance = 10; + const scrollStrategy = new AbsoluteScrollStrategy(); + const overlay = fixture.componentInstance.overlay; + const overlaySettings: OverlaySettings = { + closeOnOutsideClick: false, + modal: false, + positionStrategy: new GlobalPositionStrategy(), + scrollStrategy + }; + + spyOn(scrollStrategy, 'initialize').and.callThrough(); + spyOn(scrollStrategy, 'attach').and.callThrough(); + spyOn(scrollStrategy, 'detach').and.callThrough(); + spyOn(overlay, 'hide').and.callThrough(); + + const scrollSpy = spyOn(scrollStrategy, 'onScroll').and.callThrough(); + + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(); + expect(scrollStrategy.initialize).toHaveBeenCalledTimes(1); + expect(scrollStrategy.attach).toHaveBeenCalledTimes(1); + + document.documentElement.scrollTop += scrollTolerance; + document.documentElement.dispatchEvent(new Event('scroll')); + tick(); + expect(scrollSpy).toHaveBeenCalledTimes(1); + expect(scrollStrategy.detach).toHaveBeenCalledTimes(0); + expect(overlay.hide).toHaveBeenCalledTimes(0); + + overlay.detachAll(); + })); + + // 1.4 ElasticPosition (resize shown component to fit into visible window) + it('Should correctly render igx-overlay', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + const overlay = fixture.componentInstance.overlay; + + const positionSettings: PositionSettings = { + horizontalDirection: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Bottom, + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Top + }; + const overlaySettings: OverlaySettings = { + target: fixture.componentInstance.buttonElement.nativeElement, + positionStrategy: new ElasticPositionStrategy(positionSettings), + scrollStrategy: new NoOpScrollStrategy(), + modal: false, + closeOnOutsideClick: false + }; + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(); + fixture.detectChanges(); + const wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER)[0] as HTMLElement; + expect(wrapperElement).toBeDefined(); + expect(wrapperElement).toHaveClass(CLASS_OVERLAY_WRAPPER); + + overlay.detachAll(); + })); + + it('Should cover the whole window 100% width and height.', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + const overlay = fixture.componentInstance.overlay; + + const positionSettings: PositionSettings = { + horizontalDirection: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Bottom, + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Top + }; + const overlaySettings: OverlaySettings = { + target: fixture.componentInstance.buttonElement.nativeElement, + positionStrategy: new ElasticPositionStrategy(positionSettings), + scrollStrategy: new NoOpScrollStrategy(), + modal: false, + closeOnOutsideClick: false + }; + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(); + fixture.detectChanges(); + + const wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER)[0] as HTMLElement; + expect(wrapperElement.clientHeight).toEqual(window.innerHeight); + expect(wrapperElement.clientWidth).toEqual(window.innerWidth); + + overlay.detachAll(); + })); + + it('Should append the shown component inside the igx-overlay as a last child.', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + const overlay = fixture.componentInstance.overlay; + + const positionSettings: PositionSettings = { + horizontalDirection: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Bottom, + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Top + }; + const overlaySettings: OverlaySettings = { + target: fixture.componentInstance.buttonElement.nativeElement, + positionStrategy: new ElasticPositionStrategy(positionSettings), + scrollStrategy: new NoOpScrollStrategy(), + modal: false, + closeOnOutsideClick: false + }; + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(); + + fixture.detectChanges(); + const contentElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_CONTENT)[0] as HTMLElement; + + expect(contentElement.children.length).toEqual(1); + + const componentElement = contentElement.getElementsByTagName('div')[0]; + let overlayStyle = componentElement.getAttribute('style'); + overlayStyle = formatString(overlayStyle, formatters); + expect(overlayStyle).toEqual('width:100px; height:100px; background-color:red'); + + overlay.detachAll(); + })); + + it('Should show the component inside of the viewport if it would normally be outside of bounds, BOTTOM + RIGHT.', fakeAsync(() => { + const fixture = TestBed.createComponent(DownRightButtonComponent); + fixture.detectChanges(); + + fixture.componentInstance.positionStrategy = new ElasticPositionStrategy(); + const component = fixture.componentInstance; + const buttonElement = fixture.componentInstance.buttonElement.nativeElement; + component.ButtonPositioningSettings.horizontalDirection = HorizontalAlignment.Right; + component.ButtonPositioningSettings.verticalDirection = VerticalAlignment.Bottom; + component.ButtonPositioningSettings.verticalStartPoint = VerticalAlignment.Bottom; + component.ButtonPositioningSettings.horizontalStartPoint = HorizontalAlignment.Right; + fixture.componentInstance.target = buttonElement; + component.ButtonPositioningSettings.minSize = { width: 80, height: 80 }; + buttonElement.click(); + tick(); + fixture.detectChanges(); + + const contentElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_CONTENT)[0] as HTMLElement; + const contentRect = contentElement.getBoundingClientRect(); + expect(contentRect.width).toEqual(80); + expect(contentRect.height).toEqual(80); + const expectedLeft = buttonElement.offsetLeft + buttonElement.offsetWidth; + const expectedTop = buttonElement.offsetTop + buttonElement.offsetHeight; + expect(contentRect.top).toEqual(expectedTop); + expect(contentRect.left).toEqual(expectedLeft); + + fixture.componentInstance.overlay.detachAll(); + })); + + // it('Should display each shown component based on the options specified if the component fits into the visible window.', + // fakeAsync(() => { + // const fixture = TestBed.createComponent(EmptyPageComponent); + // fixture.detectChanges(); + + // const overlay = fixture.componentInstance.overlay; + // const button = fixture.componentInstance.buttonElement.nativeElement; + // button.style.left = '150px'; + // button.style.top = '150px'; + // button.style.position = 'relative'; + + // const hAlignmentArray = Object.keys(HorizontalAlignment).filter(key => !isNaN(Number(HorizontalAlignment[key]))); + // const vAlignmentArray = Object.keys(VerticalAlignment).filter(key => !isNaN(Number(VerticalAlignment[key]))); + // hAlignmentArray.forEach(horizontalStartPoint => { + // vAlignmentArray.forEach(verticalStartPoint => { + // hAlignmentArray.forEach(horizontalDirection => { + // vAlignmentArray.forEach(verticalDirection => { + // const positionSettings: PositionSettings = {}; + // positionSettings.horizontalStartPoint = HorizontalAlignment[horizontalStartPoint]; + // positionSettings.verticalStartPoint = VerticalAlignment[verticalStartPoint]; + // positionSettings.horizontalDirection = HorizontalAlignment[horizontalDirection]; + // positionSettings.verticalDirection = VerticalAlignment[verticalDirection]; + // positionSettings.minSize = { width: 80, height: 80 }; + + // const overlaySettings: OverlaySettings = { + // target: button, + // positionStrategy: new ElasticPositionStrategy(positionSettings), + // modal: false, + // closeOnOutsideClick: false + // }; + + // overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + // tick(); + // fixture.detectChanges(); + + // const targetRect = (overlaySettings.target as HTMLElement).getBoundingClientRect() as ClientRect; + // // we need original rect of the wrapper element. After it was shown in overlay elastic may + // // set width and/or height. To get original rect remove width and height, get the rect and + // // restore width and height; + // const contentElement = (fixture.nativeElement as HTMLElement) + // .parentElement.getElementsByClassName(CLASS_OVERLAY_CONTENT)[0] as HTMLElement; + // const width = contentElement.style.width; + // contentElement.style.width = ''; + // const height = contentElement.style.height; + // contentElement.style.height = ''; + // let contentRect = contentElement.getBoundingClientRect() as ClientRect; + // contentElement.style.width = width; + // contentElement.style.height = height; + // const screenRect: ClientRect = { + // left: 0, + // top: 0, + // right: window.innerWidth, + // bottom: window.innerHeight, + // width: window.innerWidth, + // height: window.innerHeight, + // }; + + // const location = + // getOverlayWrapperLocation(positionSettings, targetRect, contentRect, screenRect, true); + // // now get the wrapper rect as it is after elastic was applied + // contentRect = contentElement.getBoundingClientRect() as ClientRect; + // expect(contentRect.top.toFixed(1)) + // .withContext(`HD: ${horizontalDirection}; HSP: ${horizontalStartPoint};` + + // `VD: ${verticalDirection}; VSP: ${verticalStartPoint};` + + // `STYLE: ${contentElement.getAttribute('style')};`) + // .toEqual(location.y.toFixed(1)); + // expect(contentRect.left.toFixed(1)) + // .withContext(`HD: ${horizontalDirection}; HSP: ${horizontalStartPoint};` + + // `VD: ${verticalDirection}; VSP: ${verticalStartPoint};` + + // `STYLE: ${contentElement.getAttribute('style')};`) + // .toEqual(location.x.toFixed(1)); + // fixture.componentInstance.overlay.detachAll(); + // tick(); + // fixture.detectChanges(); + // }); + // }); + // }); + // }); + // })); + + // it(`Should reposition the component and render it correctly in the window, even when the rendering options passed + // should result in otherwise a partially hidden component.No scrollbars should appear.`, + // fakeAsync(() => { + // const fixture = TestBed.createComponent(EmptyPageComponent); + // fixture.detectChanges(); + + // const overlay = fixture.componentInstance.overlay; + // const button = fixture.componentInstance.buttonElement.nativeElement; + // button.style.position = 'relative'; + // button.style.width = '50px'; + // button.style.height = '50px'; + // const buttonLocations = [ + // { left: `0px`, top: `0px` }, // topLeft + // { left: `${window.innerWidth - button.width} px`, top: `0px` }, // topRight + // { left: `0px`, top: `${window.innerHeight - button.height} px` }, // bottomLeft + // { left: `${window.innerWidth - button.width} px`, top: `${window.innerHeight - button.height} px` } // bottomRight + // ]; + // const hAlignmentArray = Object.keys(HorizontalAlignment).filter(key => !isNaN(Number(HorizontalAlignment[key]))); + // const vAlignmentArray = Object.keys(VerticalAlignment).filter(key => !isNaN(Number(VerticalAlignment[key]))); + // for (const buttonLocation of buttonLocations) { + // for (const horizontalStartPoint of hAlignmentArray) { + // for (const verticalStartPoint of vAlignmentArray) { + // for (const horizontalDirection of hAlignmentArray) { + // for (const verticalDirection of vAlignmentArray) { + // const positionSettings: PositionSettings = {}; + // button.style.left = buttonLocation.left; + // button.style.top = buttonLocation.top; + + // positionSettings.horizontalStartPoint = HorizontalAlignment[horizontalStartPoint]; + // positionSettings.verticalStartPoint = VerticalAlignment[verticalStartPoint]; + // positionSettings.horizontalDirection = HorizontalAlignment[horizontalDirection]; + // positionSettings.verticalDirection = VerticalAlignment[verticalDirection]; + // positionSettings.minSize = { width: 80, height: 80 }; + + // const overlaySettings: OverlaySettings = { + // target: button, + // positionStrategy: new ElasticPositionStrategy(positionSettings), + // modal: false, + // closeOnOutsideClick: false + // }; + + // overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + // tick(); + // fixture.detectChanges(); + + // const targetRect = (overlaySettings.target as HTMLElement).getBoundingClientRect() as ClientRect; + // // we need original rect of the wrapper element. After it was shown in overlay elastic may + // // set width and/or height. To get original rect remove width and height, get the rect and + // // restore width and height; + // const contentElement = (fixture.nativeElement as HTMLElement) + // .parentElement.getElementsByClassName(CLASS_OVERLAY_CONTENT)[0] as HTMLElement; + // const width = contentElement.style.width; + // contentElement.style.width = ''; + // const height = contentElement.style.height; + // contentElement.style.height = ''; + // let contentRect = contentElement.getBoundingClientRect(); + // contentElement.style.width = width; + // contentElement.style.height = height; + // const screenRect: ClientRect = { + // left: 0, + // top: 0, + // right: window.innerWidth, + // bottom: window.innerHeight, + // width: window.innerWidth, + // height: window.innerHeight, + // }; + + // const loc = + // getOverlayWrapperLocation(positionSettings, targetRect, contentRect, screenRect, true); + // // now get the wrapper rect as it is after elastic was applied + // contentRect = contentElement.getBoundingClientRect(); + // expect(contentRect.top.toFixed(1)) + // .withContext(`HD: ${horizontalDirection}; HSP: ${horizontalStartPoint};` + + // `VD: ${verticalDirection}; VSP: ${verticalStartPoint};` + + // `STYLE: ${contentElement.getAttribute('style')};`) + // .toEqual(loc.y.toFixed(1)); + // expect(contentRect.left.toFixed(1)) + // .withContext(`HD: ${horizontalDirection}; HSP: ${horizontalStartPoint};` + + // `VD: ${verticalDirection}; VSP: ${verticalStartPoint}` + + // `STYLE: ${contentElement.getAttribute('style')};`) + // .toEqual(loc.x.toFixed(1)); + // expect(document.body.scrollHeight > document.body.clientHeight).toBeFalsy(); // check scrollbars + // fixture.componentInstance.overlay.detachAll(); + // tick(); + // fixture.detectChanges(); + // } + // } + // } + // } + // } + // })); + + it('Should render margins correctly.', fakeAsync(() => { + const expectedMargin = '0px'; + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + const overlay = fixture.componentInstance.overlay; + const button = fixture.componentInstance.buttonElement.nativeElement; + const hAlignmentArray = Object.keys(HorizontalAlignment).filter(key => !isNaN(Number(HorizontalAlignment[key]))); + const vAlignmentArray = Object.keys(VerticalAlignment).filter(key => !isNaN(Number(VerticalAlignment[key]))); + + hAlignmentArray.forEach(hDirection => { + vAlignmentArray.forEach(vDirection => { + hAlignmentArray.forEach(hAlignment => { + vAlignmentArray.forEach(vAlignment => { + const positionSettings: PositionSettings = { + horizontalDirection: hDirection as any, + verticalDirection: vDirection as any, + horizontalStartPoint: hAlignment as any, + verticalStartPoint: vAlignment as any + }; + const overlaySettings: OverlaySettings = { + target: button, + positionStrategy: new ElasticPositionStrategy(positionSettings), + scrollStrategy: new NoOpScrollStrategy(), + modal: false, + closeOnOutsideClick: false + }; + verifyOverlayMargins(overlaySettings, overlay, fixture, expectedMargin); + }); + }); + }); + }); + })); + + // When adding more than one component to show in igx-overlay: + it('When the options used to fit the component in the window - adding a new instance of the component with the ' + + ' same options will render it on top of the previous one.', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + const overlay = fixture.componentInstance.overlay; + const button = fixture.componentInstance.buttonElement.nativeElement; + const positionSettings: PositionSettings = { + horizontalDirection: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Bottom, + horizontalStartPoint: HorizontalAlignment.Center, + verticalStartPoint: VerticalAlignment.Bottom + }; + const overlaySettings: OverlaySettings = { + target: button, + positionStrategy: new ElasticPositionStrategy(positionSettings), + scrollStrategy: new NoOpScrollStrategy(), + modal: false, + closeOnOutsideClick: false + }; + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + fixture.detectChanges(); + tick(); + + const buttonRect = button.getBoundingClientRect(); + const wrapperElements = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER) as HTMLCollectionOf; + const wrapperElement1 = wrapperElements[0]; + const wrapperElement2 = wrapperElements[1]; + const componentElement1 = wrapperElement1.getElementsByTagName('test-simple-dynamic-component')[0] as HTMLElement; + const componentElement2 = wrapperElement2.getElementsByTagName('test-simple-dynamic-component')[0] as HTMLElement; + const componentRect1 = componentElement1.getBoundingClientRect(); + const componentRect2 = componentElement2.getBoundingClientRect(); + expect(componentRect1.left.toFixed(1)).toEqual((buttonRect.left + buttonRect.width / 2).toFixed(1)); + expect(componentRect1.left.toFixed(1)).toEqual(componentRect2.left.toFixed(1)); + expect(componentRect1.top.toFixed(1)).toEqual((buttonRect.top + buttonRect.height).toFixed(1)); + expect(componentRect1.top.toFixed(1)).toEqual(componentRect2.top.toFixed(1)); + expect(componentRect1.width.toFixed(1)).toEqual(componentRect2.width.toFixed(1)); + expect(componentRect1.height.toFixed(1)).toEqual(componentRect2.height.toFixed(1)); + + overlay.detachAll(); + })); + + // When adding more than one component to show in igx-overlay and the options used will not fit the component in the + // window, so element is resized. + it('When adding a new instance of the component with the same options, will render it on top of the previous one.', + fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + const overlay = fixture.componentInstance.overlay; + const button = fixture.componentInstance.buttonElement.nativeElement; + const positionSettings: PositionSettings = { + horizontalDirection: HorizontalAlignment.Left, + verticalDirection: VerticalAlignment.Top, + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Top, + minSize: { width: 80, height: 80 } + }; + const overlaySettings: OverlaySettings = { + target: button, + positionStrategy: new ElasticPositionStrategy(positionSettings), + scrollStrategy: new NoOpScrollStrategy(), + modal: false, + closeOnOutsideClick: false + }; + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + fixture.detectChanges(); + tick(); + + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + fixture.detectChanges(); + tick(); + + const buttonRect = button.getBoundingClientRect(); + + const wrapperElements = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER) as HTMLCollectionOf; + const wrapperElement1 = wrapperElements[0]; + const wrapperElement2 = wrapperElements[1]; + const componentElement1 = wrapperElement1.getElementsByTagName('test-simple-dynamic-component')[0] as HTMLElement; + const componentElement2 = wrapperElement2.getElementsByTagName('test-simple-dynamic-component')[0] as HTMLElement; + const componentRect1 = componentElement1.getBoundingClientRect(); + const componentRect2 = componentElement2.getBoundingClientRect(); + expect(componentRect1.left).toEqual(buttonRect.left - positionSettings.minSize.width); + expect(componentRect1.left).toEqual(componentRect2.left); + expect(componentRect1.top).toEqual(componentRect2.top); + expect(componentRect1.width).toEqual(componentRect2.width); + expect(componentRect1.height).toEqual(componentRect2.height); + + overlay.detachAll(); + })); + + it(`Should persist the component's open state when scrolling, when scrolling and noOP scroll strategy is used + (expanded DropDown remains expanded).`, fakeAsync(() => { + // TO DO replace Spies with css class and/or getBoundingClientRect. + const fixture = TestBed.createComponent(EmptyPageComponent); + const scrollTolerance = 10; + const scrollStrategy = new BlockScrollStrategy(); + const overlay = fixture.componentInstance.overlay; + const overlaySettings: OverlaySettings = { + modal: false, + scrollStrategy, + positionStrategy: new ElasticPositionStrategy() + }; + + spyOn(scrollStrategy, 'initialize').and.callThrough(); + spyOn(scrollStrategy, 'attach').and.callThrough(); + spyOn(scrollStrategy, 'detach').and.callThrough(); + spyOn(overlay, 'hide').and.callThrough(); + + const scrollSpy = spyOn(scrollStrategy, 'onScroll').and.callThrough(); + + const overlayId = overlay.attach(SimpleDynamicComponent, overlaySettings); + overlay.show(overlayId); + tick(); + expect(scrollStrategy.initialize).toHaveBeenCalledTimes(1); + expect(scrollStrategy.attach).toHaveBeenCalledTimes(1); + expect(scrollStrategy.detach).toHaveBeenCalledTimes(0); + expect(overlay.hide).toHaveBeenCalledTimes(0); + document.documentElement.scrollTop += scrollTolerance; + document.documentElement.dispatchEvent(new Event('scroll')); + tick(); + expect(scrollSpy).toHaveBeenCalledTimes(1); + expect(overlay.hide).toHaveBeenCalledTimes(0); + expect(scrollStrategy.detach).toHaveBeenCalledTimes(0); + + overlay.detach(overlayId); + })); + + it('Should persist the component open state when scrolling and absolute scroll strategy is used.', fakeAsync(() => { + // TO DO replace Spies with css class and/or getBoundingClientRect. + const fixture = TestBed.createComponent(EmptyPageComponent); + const scrollTolerance = 10; + const scrollStrategy = new AbsoluteScrollStrategy(); + const overlay = fixture.componentInstance.overlay; + const overlaySettings: OverlaySettings = { + closeOnOutsideClick: false, + modal: false, + positionStrategy: new ElasticPositionStrategy(), + scrollStrategy + }; + + spyOn(scrollStrategy, 'initialize').and.callThrough(); + spyOn(scrollStrategy, 'attach').and.callThrough(); + spyOn(scrollStrategy, 'detach').and.callThrough(); + spyOn(overlay, 'hide').and.callThrough(); + + const scrollSpy = spyOn(scrollStrategy, 'onScroll').and.callThrough(); + + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(); + expect(scrollStrategy.initialize).toHaveBeenCalledTimes(1); + expect(scrollStrategy.attach).toHaveBeenCalledTimes(1); + + document.documentElement.scrollTop += scrollTolerance; + document.documentElement.dispatchEvent(new Event('scroll')); + tick(); + expect(scrollSpy).toHaveBeenCalledTimes(1); + expect(scrollStrategy.detach).toHaveBeenCalledTimes(0); + expect(overlay.hide).toHaveBeenCalledTimes(0); + + overlay.detachAll(); + })); + + // 1.5 GlobalContainer. + it('Should center the shown component in the outlet.', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + + const outlet = fixture.componentInstance.divElement; + const outletElement = outlet.nativeElement; + outletElement.style.width = '800px'; + outletElement.style.height = '600px'; + outletElement.style.position = 'fixed'; + outletElement.style.top = '100px'; + outletElement.style.left = '200px'; + outletElement.style.overflow = 'hidden'; + + fixture.detectChanges(); + const overlaySettings: OverlaySettings = { + outlet, + positionStrategy: new ContainerPositionStrategy() + }; + + const id = fixture.componentInstance.overlay.attach(SimpleDynamicComponent, overlaySettings); + fixture.componentInstance.overlay.show(id); + tick(); + + const overlayElement = outletElement.children[0]; + const overlayElementRect = overlayElement.getBoundingClientRect(); + expect(overlayElementRect.width).toEqual(800); + expect(overlayElementRect.height).toEqual(600); + + const wrapperElement = overlayElement.children[0] as HTMLElement; + const componentElement = wrapperElement.children[0].children[0]; + const componentRect = componentElement.getBoundingClientRect(); + + // left = outletLeft + (outletWidth - componentWidth) / 2 + // left = 200 + (800 - 100 ) / 2 + expect(componentRect.left).toEqual(550); + // top = outletTop + (outletHeight - componentHeight) / 2 + // top = 100 + (600 - 100 ) / 2 + expect(componentRect.top).toEqual(350); + + fixture.componentInstance.overlay.detachAll(); + })); + + // 3. Interaction + // 3.1 Modal + it('Should apply a greyed-out mask layers when is modal.', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + const overlay = fixture.componentInstance.overlay; + const overlaySettings: OverlaySettings = { + modal: true, + }; + + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(100); + + const wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER_MODAL)[0] as HTMLElement; + + expect(wrapperElement).toBeDefined(); + const styles = css(wrapperElement); + expect(styles.findIndex( + (e) => e.includes('--background-color: var(--igx-overlay-background-color, hsl(from var(--ig-gray-500) h s l/0.54));'))) + .toBeGreaterThan(-1); + expect(styles.findIndex( + (e) => e.includes('background: var(--background-color);'))) + .toBeGreaterThan(-1); + + fixture.componentInstance.overlay.detachAll(); + })); + + it('Should apply a greyed-out mask layers when is modal and has no animation.', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + const overlay = fixture.componentInstance.overlay; + const overlaySettings: OverlaySettings = { + modal: true, + positionStrategy: new GlobalPositionStrategy({ + openAnimation: null, + closeAnimation: null + }) + }; + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(100); + + const wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER_MODAL)[0] as HTMLElement; + expect(wrapperElement).toBeDefined(); + const styles = css(wrapperElement); + expect(styles.findIndex((e) => e.includes('--background-color: var(--igx-overlay-background-color, hsl(from var(--ig-gray-500) h s l/0.54));'))).toBeGreaterThan(-1); + expect(styles.findIndex((e) => e.includes('background: var(--background-color);'))).toBeGreaterThan(-1); + + fixture.componentInstance.overlay.detachAll(); + })); + + it('Should allow interaction only for the shown component when is modal.', fakeAsync(() => { + // Utility handler meant for later detachment + // TO DO replace Spies with css class and/or getBoundingClientRect. + const _handler = event => { + if (event.which === 1) { + fixture.detectChanges(); + tick(); + expect(button.click).toHaveBeenCalledTimes(0); + expect(button.onclick).toHaveBeenCalledTimes(0); + document.removeEventListener('click', _handler); + dummy.remove(); + } + return event; + }; + + const fixture = TestBed.createComponent(EmptyPageComponent); + const overlay = fixture.componentInstance.overlay; + const overlaySettings: OverlaySettings = { + modal: true, + closeOnOutsideClick: false, + positionStrategy: new GlobalPositionStrategy() + }; + const dummy = document.createElement('button'); + dummy.setAttribute('id', 'dummyButton'); + document.body.appendChild(dummy); + const button = document.getElementById('dummyButton'); + + button.addEventListener('click', _handler); + + spyOn(button, 'click').and.callThrough(); + spyOn(button, 'onclick').and.callThrough(); + spyOn(overlay, 'show').and.callThrough(); + spyOn(overlay, 'hide').and.callThrough(); + + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(); + expect(overlay.show).toHaveBeenCalledTimes(1); + expect(overlay.hide).toHaveBeenCalledTimes(0); + + button.dispatchEvent(new MouseEvent('click')); + + overlay.detachAll(); + })); + + it('Should close the component when esc key is pressed.', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + const overlay = fixture.componentInstance.overlay; + const overlaySettings: OverlaySettings = { + closeOnEscape: true, + }; + + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(DEBOUNCE_TIME); + + let wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER_MODAL)[0] as HTMLElement; + expect(wrapperElement.style.visibility).toEqual(''); + + UIInteractions.triggerKeyDownEvtUponElem('Escape', document); + tick(); + + wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER)[0] as HTMLElement; + expect(wrapperElement.style.visibility).toEqual('hidden'); + + overlay.detachAll(); + })); + + it('Should not close the component when esc key is pressed and closeOnEsc is false', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + const overlay = fixture.componentInstance.overlay; + const overlaySettings: OverlaySettings = { + closeOnEscape: false + }; + + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(DEBOUNCE_TIME); + + let wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER_MODAL)[0] as HTMLElement; + expect(wrapperElement.style.visibility).toEqual(''); + + UIInteractions.triggerKeyDownEvtUponElem('Escape', document); + tick(DEBOUNCE_TIME); + + wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER_MODAL)[0] as HTMLElement; + expect(wrapperElement.style.visibility).toEqual(''); + + overlay.detachAll(); + })); + + it('Should close the opened overlays consecutively on esc keypress', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + const overlay = fixture.componentInstance.overlay; + overlay.show(overlay.attach(SimpleDynamicComponent, { closeOnEscape: true })); + tick(); + overlay.show(overlay.attach(SimpleDynamicComponent, { closeOnEscape: true })); + tick(); + + const overlayElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_MAIN)[0] as HTMLElement; + expect(overlayElement.children.length).toBe(2); + expect((overlayElement.children[0] as HTMLElement).style.visibility).toEqual(''); + expect((overlayElement.children[1] as HTMLElement).style.visibility).toEqual(''); + + UIInteractions.triggerKeyDownEvtUponElem('Escape', document); + tick(); + expect((overlayElement.children[0] as HTMLElement).style.visibility).toEqual(''); + expect((overlayElement.children[1] as HTMLElement).style.visibility).toEqual('hidden'); + + UIInteractions.triggerKeyDownEvtUponElem('Escape', document); + tick(); + expect((overlayElement.children[0] as HTMLElement).style.visibility).toEqual('hidden'); + expect((overlayElement.children[1] as HTMLElement).style.visibility).toEqual('hidden'); + + overlay.detachAll(); + })); + + it('Should not close the opened overlays consecutively on esc keypress', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + const overlay = fixture.componentInstance.overlay; + overlay.show(overlay.attach(SimpleDynamicComponent, { closeOnEscape: true })); + tick(); + overlay.show(overlay.attach(SimpleDynamicComponent, { closeOnEscape: false })); + tick(); + overlay.show(overlay.attach(SimpleDynamicComponent, { closeOnEscape: true })); + tick(); + + const overlayElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_MAIN)[0] as HTMLElement; + expect(overlayElement.children.length).toBe(3); + expect((overlayElement.children[0] as HTMLElement).style.visibility).toEqual(''); + expect((overlayElement.children[1] as HTMLElement).style.visibility).toEqual(''); + expect((overlayElement.children[2] as HTMLElement).style.visibility).toEqual(''); + + UIInteractions.triggerKeyDownEvtUponElem('Escape', document); + tick(); + expect((overlayElement.children[0] as HTMLElement).style.visibility).toEqual(''); + expect((overlayElement.children[1] as HTMLElement).style.visibility).toEqual(''); + expect((overlayElement.children[2] as HTMLElement).style.visibility).toEqual('hidden'); + + UIInteractions.triggerKeyDownEvtUponElem('Escape', document); + tick(); + expect((overlayElement.children[0] as HTMLElement).style.visibility).toEqual(''); + expect((overlayElement.children[1] as HTMLElement).style.visibility).toEqual(''); + expect((overlayElement.children[2] as HTMLElement).style.visibility).toEqual('hidden'); + + overlay.detachAll(); + })); + + // Test #1883 #1820 + it('It should close the component when esc key is pressed and there were other keys pressed prior to esc.', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + const overlay = fixture.componentInstance.overlay; + const overlaySettings: OverlaySettings = { + closeOnEscape: true, + }; + + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(DEBOUNCE_TIME); + + let wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER_MODAL)[0] as HTMLElement; + expect(wrapperElement.style.visibility).toEqual(''); + + UIInteractions.triggerKeyDownEvtUponElem('Enter', document); + tick(DEBOUNCE_TIME); + wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER_MODAL)[0] as HTMLElement; + expect(wrapperElement.style.visibility).toEqual(''); + + UIInteractions.triggerKeyDownEvtUponElem('a', document); + tick(DEBOUNCE_TIME); + wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER_MODAL)[0] as HTMLElement; + expect(wrapperElement.style.visibility).toEqual(''); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', document); + tick(DEBOUNCE_TIME); + wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER_MODAL)[0] as HTMLElement; + expect(wrapperElement.style.visibility).toEqual(''); + + UIInteractions.triggerKeyDownEvtUponElem('Escape', document); + tick(); + wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER)[0] as HTMLElement; + expect(wrapperElement.style.visibility).toEqual('hidden'); + + overlay.detachAll(); + })); + + // 3.2 Non - Modal + it('Should not apply a greyed-out mask layer when is not modal', fakeAsync(() => { + const fixture = TestBed.createComponent(EmptyPageComponent); + const overlay = fixture.componentInstance.overlay; + const overlaySettings: OverlaySettings = { + modal: false + }; + + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + const wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER)[0] as HTMLElement; + tick(); + const styles = css(wrapperElement); + const expectedBackgroundColor = 'background-color: rgba(0, 0, 0, 0.38)'; + const appliedBackgroundStyles = styles[3]; + expect(appliedBackgroundStyles).not.toContain(expectedBackgroundColor); + + overlay.detachAll(); + })); + + it('Should not close when esc key is pressed and is not modal (DropDown, Dialog, etc.).', fakeAsync(() => { + + // Utility handler meant for later detachment + const _handler = event => { + if (event.key === targetButton) { + wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER)[0] as HTMLElement; + expect(wrapperElement).toBeTruthy(); + document.removeEventListener(targetEvent, _handler); + } + return event; + }; + + const fixture = TestBed.createComponent(EmptyPageComponent); + const overlay = fixture.componentInstance.overlay; + const overlaySettings: OverlaySettings = { + modal: false, + positionStrategy: new GlobalPositionStrategy() + }; + const targetEvent = 'keydown'; + const targetButton = 'Escape'; + const escEvent = new KeyboardEvent(targetEvent, { + key: targetButton + }); + + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(); + let wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER)[0] as HTMLElement; + wrapperElement.addEventListener(targetEvent, _handler); + + expect(wrapperElement).toBeTruthy(); + wrapperElement.dispatchEvent(escEvent); + + overlay.detachAll(); + })); + + // 4. Css + it('Should use component initial container\'s properties when is with 100% width and show in overlay element', + fakeAsync(() => { + const fixture = TestBed.createComponent(WidthTestOverlayComponent); + fixture.detectChanges(); + expect(fixture.componentInstance.customComponent).toBeDefined(); + expect(fixture.componentInstance.customComponent.nativeElement.style.width).toEqual('100%'); + expect(fixture.componentInstance.customComponent.nativeElement.getBoundingClientRect().width).toEqual(420); + expect(fixture.componentInstance.customComponent.nativeElement.style.height).toEqual('100%'); + expect(fixture.componentInstance.customComponent.nativeElement.getBoundingClientRect().height).toEqual(280); + fixture.componentInstance.buttonElement.nativeElement.click(); + tick(); + const componentElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName('customList')[0] as HTMLElement; + expect(componentElement).toBeDefined(); + expect(componentElement.style.width).toEqual('100%'); + expect(componentElement.getBoundingClientRect().width).toEqual(420); + // content element has no height, so the shown element will calculate its own height by itself + // expect(overlayChild.style.height).toEqual('100%'); + // expect(overlayChild.getBoundingClientRect().height).toEqual(280); + fixture.componentInstance.overlay.detachAll(); + })); + }); + + describe('Integration tests - Scroll Strategies: ', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, SimpleDynamicWithDirectiveComponent] + }); + })); + // If adding a component near the visible window borders(left,right,up,down) + // it should be partially hidden and based on scroll strategy: + it('Should not allow scrolling with scroll strategy is not passed.', fakeAsync(async () => { + TestBed.overrideComponent(EmptyPageComponent, { + set: { + styles: [`button { + position: absolute; + top: 850px; + left: -30px; + width: 100px; + height: 60px; + } `] + } + }); + await TestBed.compileComponents(); + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + const dummy = document.createElement('div'); + dummy.setAttribute('style', + 'width:60px; height:60px; color:green; position: absolute; top: 3000px; left: 3000px;'); + document.body.appendChild(dummy); + + const target = fixture.componentInstance.buttonElement.nativeElement; + const overlaySettings: OverlaySettings = { + target, + positionStrategy: new ConnectedPositioningStrategy(), + scrollStrategy: new NoOpScrollStrategy(), + modal: false, + closeOnOutsideClick: false + }; + const overlay = fixture.componentInstance.overlay; + + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + + tick(); + const componentElement = (fixture.debugElement.nativeElement as HTMLElement) + .parentElement.getElementsByTagName('test-simple-dynamic-component')[0]; + const componentRect = componentElement.getBoundingClientRect(); + + document.documentElement.scrollTop = 100; + document.documentElement.scrollLeft = 50; + document.documentElement.dispatchEvent(new Event('scroll')); + tick(); + + expect(componentRect).toEqual(componentElement.getBoundingClientRect()); + expect(document.documentElement.scrollTop).toEqual(100); + expect(document.documentElement.scrollLeft).toEqual(50); + document.body.removeChild(dummy); + + overlay.detachAll(); + })); + + it('Should retain the component state when scrolling and block scroll strategy is used.', fakeAsync(async () => { + TestBed.overrideComponent(EmptyPageComponent, { + set: { + styles: [`button { position: absolute; bottom: -2000px; } `] + } + }); + await TestBed.compileComponents(); + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + const scrollStrat = new BlockScrollStrategy(); + fixture.detectChanges(); + const overlaySettings: OverlaySettings = { + positionStrategy: new ConnectedPositioningStrategy(), + scrollStrategy: scrollStrat, + modal: false, + closeOnOutsideClick: false + }; + const overlay = fixture.componentInstance.overlay; + const overlayId = overlay.attach(SimpleDynamicComponent, overlaySettings); + overlay.show(overlayId); + tick(); + expect(document.documentElement.scrollTop).toEqual(0); + document.documentElement.dispatchEvent(new Event('scroll')); + tick(); + expect(document.documentElement.scrollTop).toEqual(0); + + document.documentElement.scrollTop += 25; + document.documentElement.dispatchEvent(new Event('scroll')); + tick(); + expect(document.documentElement.scrollTop).toEqual(0); + + document.documentElement.scrollTop += 1000; + document.documentElement.dispatchEvent(new Event('scroll')); + tick(); + + const wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER)[0] as HTMLElement; + expect(wrapperElement).toBeDefined(); + expect(wrapperElement.style.visibility).toEqual(''); + expect(document.documentElement.scrollTop).toEqual(0); + + overlay.detach(overlayId); + })); + + it(`Should show the component, AutoPositionStrategy, inside of the viewport if it would normally be outside of bounds, + TOP + LEFT.`, fakeAsync(async () => { + TestBed.overrideComponent(DownRightButtonComponent, { + set: { + styles: [`button { + position: absolute; + top: 16px; + left: 16px; + width: 84px; + height: 84px; + padding: 0px; + margin: 0px; + border: 0px; + } `] + } + }); + await TestBed.compileComponents(); + const fixture = TestBed.createComponent(DownRightButtonComponent); + fixture.detectChanges(); + + fixture.componentInstance.positionStrategy = new AutoPositionStrategy(); + UIInteractions.clearOverlay(); + fixture.detectChanges(); + const componentElement = fixture.componentInstance; + const buttonElement = fixture.componentInstance.buttonElement.nativeElement; + fixture.detectChanges(); + componentElement.ButtonPositioningSettings.horizontalDirection = HorizontalAlignment.Left; + componentElement.ButtonPositioningSettings.verticalDirection = VerticalAlignment.Top; + componentElement.ButtonPositioningSettings.verticalStartPoint = VerticalAlignment.Top; + componentElement.ButtonPositioningSettings.horizontalStartPoint = HorizontalAlignment.Left; + fixture.componentInstance.target = buttonElement; + buttonElement.click(); + fixture.detectChanges(); + tick(); + + fixture.detectChanges(); + const contentElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_CONTENT)[0] as HTMLElement; + const expectedStyle = 'width:100px; height:100px; background-color:red'; + let overlayStyle = contentElement.getElementsByTagName('div')[0].getAttribute('style'); + overlayStyle = formatString(overlayStyle, formatters); + expect(overlayStyle).toEqual(expectedStyle); + const buttonLeft = buttonElement.offsetLeft; + const buttonTop = buttonElement.offsetTop; + const expectedLeft = buttonLeft + buttonElement.clientWidth; // To the right of the button + const expectedTop = buttonTop + buttonElement.clientHeight; // Bottom of the button + const contentLeft = contentElement.getBoundingClientRect().left; + const contentTop = contentElement.getBoundingClientRect().top; + expect(contentTop).toEqual(expectedTop); + expect(contentLeft).toEqual(expectedLeft); + + fixture.componentInstance.overlay.detachAll(); + })); + + it(`Should show the component, AutoPositionStrategy, inside of the viewport if it would normally be outside of bounds, + TOP + RIGHT.`, fakeAsync(async () => { + TestBed.overrideComponent(DownRightButtonComponent, { + set: { + styles: [`button { + position: absolute; + top: 16px; + right: 16px; + width: 84px; + height: 84px; + padding: 0px; + margin: 0px; + border: 0px; + } `] + } + }); + await TestBed.compileComponents(); + const fixture = TestBed.createComponent(DownRightButtonComponent); + fixture.detectChanges(); + + fixture.componentInstance.positionStrategy = new AutoPositionStrategy(); + UIInteractions.clearOverlay(); + fixture.detectChanges(); + const componentElement = fixture.componentInstance; + const buttonElement = fixture.componentInstance.buttonElement.nativeElement; + fixture.detectChanges(); + componentElement.ButtonPositioningSettings.horizontalDirection = HorizontalAlignment.Left; + componentElement.ButtonPositioningSettings.verticalDirection = VerticalAlignment.Top; + componentElement.ButtonPositioningSettings.verticalStartPoint = VerticalAlignment.Top; + componentElement.ButtonPositioningSettings.horizontalStartPoint = HorizontalAlignment.Left; + fixture.componentInstance.target = buttonElement; + buttonElement.click(); + fixture.detectChanges(); + tick(); + + fixture.detectChanges(); + const contentElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_CONTENT)[0] as HTMLElement; + let overlayStyle = contentElement.getElementsByTagName('div')[0].getAttribute('style'); + const expectedStyle = 'width:100px; height:100px; background-color:red'; + overlayStyle = formatString(overlayStyle, formatters); + expect(overlayStyle).toEqual(expectedStyle); + const buttonLeft = buttonElement.offsetLeft; + const buttonTop = buttonElement.offsetTop; + const expectedRight = buttonLeft; // To the left of the button + const expectedTop = buttonTop + buttonElement.clientHeight; // Bottom of the button + const contentRight = contentElement.getBoundingClientRect().right; + const contentTop = contentElement.getBoundingClientRect().top; + expect(contentTop).toEqual(expectedTop); + expect(contentRight).toEqual(expectedRight); + + fixture.componentInstance.overlay.detachAll(); + })); + + it(`Should show the component, AutoPositionStrategy, inside of the viewport if it would normally be outside of bounds, + TOP + RIGHT.`, fakeAsync(async () => { + TestBed.overrideComponent(DownRightButtonComponent, { + set: { + styles: [`button { + position: absolute; + top: 16px; + right: 16px; + width: 84px; + height: 84px; + padding: 0px; + margin: 0px; + border: 0px; + } `] + } + }); + await TestBed.compileComponents(); + const fixture = TestBed.createComponent(DownRightButtonComponent); + fixture.detectChanges(); + + fixture.componentInstance.positionStrategy = new AutoPositionStrategy(); + UIInteractions.clearOverlay(); + fixture.detectChanges(); + const componentElement = fixture.componentInstance; + const buttonElement = fixture.componentInstance.buttonElement.nativeElement; + componentElement.ButtonPositioningSettings.horizontalDirection = HorizontalAlignment.Right; + componentElement.ButtonPositioningSettings.verticalDirection = VerticalAlignment.Top; + componentElement.ButtonPositioningSettings.verticalStartPoint = VerticalAlignment.Top; + componentElement.ButtonPositioningSettings.horizontalStartPoint = HorizontalAlignment.Right; + fixture.componentInstance.target = buttonElement; + buttonElement.click(); + fixture.detectChanges(); + tick(); + + fixture.detectChanges(); + const contentElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_CONTENT)[0] as HTMLElement; + const expectedStyle = 'width:100px; height:100px; background-color:red'; + let style = contentElement.getElementsByTagName('div')[0].getAttribute('style'); + style = formatString(style, formatters); + expect(style).toEqual(expectedStyle); + const expectedRight = buttonElement.offsetLeft; + const expectedTop = buttonElement.offsetTop + buttonElement.clientHeight; + const contentElementRect = contentElement.getBoundingClientRect(); + const contentRight = contentElementRect.right; + const contentTop = contentElementRect.top; + expect(contentTop).toEqual(expectedTop); + expect(contentRight).toEqual(expectedRight); + + fixture.componentInstance.overlay.detachAll(); + })); + + it(`Should show the component, AutoPositionStrategy, inside of the viewport if it would normally be outside of bounds, + BOTTOM + LEFT.`, fakeAsync(async () => { + TestBed.overrideComponent(DownRightButtonComponent, { + set: { + styles: [`button { + position: absolute; + bottom: 16px; + left: 16px; + width: 84px; + height: 84px; + padding: 0px; + margin: 0px; + border: 0px; + } `] + } + }); + await TestBed.compileComponents(); + const fixture = TestBed.createComponent(DownRightButtonComponent); + fixture.detectChanges(); + + fixture.componentInstance.positionStrategy = new AutoPositionStrategy(); + UIInteractions.clearOverlay(); + fixture.detectChanges(); + const componentElement = fixture.componentInstance; + const buttonElement = fixture.componentInstance.buttonElement.nativeElement; + fixture.detectChanges(); + componentElement.ButtonPositioningSettings.horizontalDirection = HorizontalAlignment.Left; + componentElement.ButtonPositioningSettings.verticalDirection = VerticalAlignment.Bottom; + componentElement.ButtonPositioningSettings.verticalStartPoint = VerticalAlignment.Bottom; + componentElement.ButtonPositioningSettings.horizontalStartPoint = HorizontalAlignment.Left; + fixture.componentInstance.target = buttonElement; + buttonElement.click(); + tick(); + fixture.detectChanges(); + + fixture.detectChanges(); + const contentElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_CONTENT)[0] as HTMLElement; + const expectedStyle = 'width:100px; height:100px; background-color:red'; + let style = contentElement.getElementsByTagName('div')[0].getAttribute('style'); + style = formatString(style, formatters); + expect(style).toEqual(expectedStyle); + const buttonLeft = buttonElement.offsetLeft; + const buttonTop = buttonElement.offsetTop; + const expectedLeft = buttonLeft + buttonElement.clientWidth; // To the right of the button + const expectedTop = buttonTop - contentElement.clientHeight; // On top of the button + const contentLeft = contentElement.getBoundingClientRect().left; + const contentTop = contentElement.getBoundingClientRect().top; + expect(contentTop).toEqual(expectedTop); + expect(contentLeft).toEqual(expectedLeft); + + fixture.componentInstance.overlay.detachAll(); + })); + + it(`Should show the component, ElasticPositionStrategy, inside of the viewport if it would normally be outside of bounds, + TOP + LEFT.`, fakeAsync(async () => { + TestBed.overrideComponent(DownRightButtonComponent, { + set: { + styles: [`button { + position: absolute; + top: 16px; + left: 16px; + width: 84px; + height: 84px; + padding: 0px; + margin: 0px; + border: 0px; + } `] + } + }); + await TestBed.compileComponents(); + const fixture = TestBed.createComponent(DownRightButtonComponent); + fixture.detectChanges(); + + fixture.componentInstance.positionStrategy = new ElasticPositionStrategy(); + UIInteractions.clearOverlay(); + fixture.detectChanges(); + const componentElement = fixture.componentInstance; + const buttonElement = fixture.componentInstance.buttonElement.nativeElement; + componentElement.ButtonPositioningSettings.horizontalDirection = HorizontalAlignment.Left; + componentElement.ButtonPositioningSettings.verticalDirection = VerticalAlignment.Top; + componentElement.ButtonPositioningSettings.verticalStartPoint = VerticalAlignment.Top; + componentElement.ButtonPositioningSettings.horizontalStartPoint = HorizontalAlignment.Left; + fixture.componentInstance.target = buttonElement; + componentElement.ButtonPositioningSettings.minSize = { width: 80, height: 80 }; + buttonElement.click(); + tick(); + fixture.detectChanges(); + + const contentElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_CONTENT)[0] as HTMLElement; + const expectedRight = buttonElement.offsetLeft; + const expectedBottom = buttonElement.offsetTop; + const componentRect = contentElement.getBoundingClientRect(); + expect(componentRect.right).toEqual(expectedRight); + expect(componentRect.bottom).toEqual(expectedBottom); + + fixture.componentInstance.overlay.detachAll(); + })); + + it(`Should show the component, ElasticPositionStrategy, inside of the viewport if it would normally be outside of bounds, + TOP + RIGHT.`, fakeAsync(async () => { + TestBed.overrideComponent(DownRightButtonComponent, { + set: { + styles: [`button { + position: absolute; + top: 16px; + right: 16px; + width: 84px; + height: 84px; + padding: 0px; + margin: 0px; + border: 0px; + } `] + } + }); + await TestBed.compileComponents(); + const fixture = TestBed.createComponent(DownRightButtonComponent); + fixture.detectChanges(); + + fixture.componentInstance.positionStrategy = new ElasticPositionStrategy(); + UIInteractions.clearOverlay(); + fixture.detectChanges(); + const componentElement = fixture.componentInstance; + const buttonElement = fixture.componentInstance.buttonElement.nativeElement; + fixture.detectChanges(); + componentElement.ButtonPositioningSettings.horizontalDirection = HorizontalAlignment.Right; + componentElement.ButtonPositioningSettings.verticalDirection = VerticalAlignment.Top; + componentElement.ButtonPositioningSettings.verticalStartPoint = VerticalAlignment.Top; + componentElement.ButtonPositioningSettings.horizontalStartPoint = HorizontalAlignment.Right; + fixture.componentInstance.target = buttonElement; + componentElement.ButtonPositioningSettings.minSize = { width: 80, height: 80 }; + buttonElement.click(); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + const contentElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_CONTENT)[0] as HTMLElement; + const expectedLeft = buttonElement.offsetLeft + buttonElement.clientWidth; + const expectedTop = buttonElement.offsetTop - componentElement.ButtonPositioningSettings.minSize.height; + const componentRect = contentElement.getBoundingClientRect(); + expect(componentRect.left).toEqual(expectedLeft); + expect(componentRect.top).toEqual(expectedTop); + + fixture.componentInstance.overlay.detachAll(); + })); + + it(`Should show the component, ElasticPositionStrategy, inside of the viewport if it would normally be outside of bounds, + BOTTOM + LEFT.`, fakeAsync(async () => { + TestBed.overrideComponent(DownRightButtonComponent, { + set: { + styles: [`button { + position: absolute; + bottom: 16px; + left: 16px; + width: 84px; + height: 84px; + padding: 0px; + margin: 0px; + border: 0px; + } `] + } + }); + await TestBed.compileComponents(); + const fixture = TestBed.createComponent(DownRightButtonComponent); + fixture.detectChanges(); + + fixture.componentInstance.positionStrategy = new ElasticPositionStrategy(); + UIInteractions.clearOverlay(); + fixture.detectChanges(); + const componentElement = fixture.componentInstance; + const buttonElement = fixture.componentInstance.buttonElement.nativeElement; + componentElement.ButtonPositioningSettings.horizontalDirection = HorizontalAlignment.Left; + componentElement.ButtonPositioningSettings.verticalDirection = VerticalAlignment.Bottom; + componentElement.ButtonPositioningSettings.verticalStartPoint = VerticalAlignment.Bottom; + componentElement.ButtonPositioningSettings.horizontalStartPoint = HorizontalAlignment.Left; + fixture.componentInstance.target = buttonElement; + componentElement.ButtonPositioningSettings.minSize = { width: 80, height: 80 }; + buttonElement.click(); + tick(); + fixture.detectChanges(); + + const contentElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_CONTENT)[0] as HTMLElement; + const expectedRight = buttonElement.offsetLeft; + const expectedTop = buttonElement.offsetTop + buttonElement.offsetHeight; + const contentRect = contentElement.getBoundingClientRect(); + expect(contentRect.right).toEqual(expectedRight); + expect(contentRect.top).toEqual(expectedTop); + + fixture.componentInstance.overlay.detachAll(); + })); + + // 2. Scroll Strategy (test with GlobalPositionStrategy(default)) + // 2.1. Scroll Strategy - None + it('Should not scroll component, nor the window when none scroll strategy is passed. No scrolling happens.', fakeAsync(async () => { + TestBed.overrideComponent(EmptyPageComponent, { + set: { + styles: [`button { + position: absolute; + top: 120%; + left: 120%; + } `] + } + }); + await TestBed.compileComponents(); + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + const overlaySettings: OverlaySettings = { + modal: false, + }; + const overlay = fixture.componentInstance.overlay; + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(); + const componentElement = (fixture.debugElement.nativeElement as HTMLElement) + .parentElement.getElementsByTagName('test-simple-dynamic-component')[0]; + const componentRect = componentElement.getBoundingClientRect(); + + document.documentElement.scrollTop = 100; + document.documentElement.scrollLeft = 50; + document.documentElement.dispatchEvent(new Event('scroll')); + tick(); + + expect(componentRect).toEqual(componentElement.getBoundingClientRect()); + expect(document.documentElement.scrollTop).toEqual(100); + expect(document.documentElement.scrollLeft).toEqual(50); + overlay.hideAll(); + + overlay.detachAll(); + })); + + it(`Should not close the shown component when none scroll strategy is passed. + (example: expanded DropDown stays expanded during a scrolling attempt.)`, + fakeAsync(async () => { + TestBed.overrideComponent(EmptyPageComponent, { + set: { + styles: [`button { + position: absolute; + top: 120%; + left: 120%; + } `] + } + }); + await TestBed.compileComponents(); + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + const overlaySettings: OverlaySettings = { + modal: false, + }; + const overlay = fixture.componentInstance.overlay; + + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(); + const componentElement = (fixture.debugElement.nativeElement as HTMLElement) + .parentElement.getElementsByTagName('test-simple-dynamic-component')[0]; + const componentRect = componentElement.getBoundingClientRect(); + + document.documentElement.scrollTop = 40; + document.documentElement.scrollLeft = 30; + document.documentElement.dispatchEvent(new Event('scroll')); + tick(); + + expect(componentRect).toEqual(componentElement.getBoundingClientRect()); + expect(document.documentElement.scrollTop).toEqual(40); + expect(document.documentElement.scrollLeft).toEqual(30); + expect(document.getElementsByClassName(CLASS_OVERLAY_WRAPPER).length).toEqual(1); + + overlay.detachAll(); + })); + + // 2.2 Scroll Strategy - Closing. (Uses a tolerance and closes an expanded component upon scrolling if the tolerance is exceeded.) + // (example: DropDown or Dialog component collapse/closes after scrolling 10px.) + it('Should scroll until the set threshold is exceeded, and closing scroll strategy is used.', + fakeAsync(async () => { + TestBed.overrideComponent(EmptyPageComponent, { + set: { + styles: [ + 'button { position: absolute; top: 100%; left: 90%; }' + ] + } + }); + await TestBed.compileComponents(); + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + const scrollTolerance = 10; + const scrollStrategy = new CloseScrollStrategy(); + const overlay = fixture.componentInstance.overlay; + const overlaySettings: OverlaySettings = { + positionStrategy: new GlobalPositionStrategy(), + scrollStrategy, + modal: false, + }; + + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(); + + document.documentElement.scrollTop = scrollTolerance; + document.documentElement.dispatchEvent(new Event('scroll')); + tick(); + + let wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER)[0] as HTMLElement; + expect(wrapperElement).toBeDefined(); + expect(wrapperElement.style.visibility).toEqual(''); + expect(document.documentElement.scrollTop).toEqual(scrollTolerance); + + document.documentElement.scrollTop += scrollTolerance * 2; + document.documentElement.dispatchEvent(new Event('scroll')); + tick(); + + wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER)[0] as HTMLElement; + expect(wrapperElement).toBeDefined(); + expect(wrapperElement.style.visibility).toEqual('hidden'); + + overlay.detachAll() + })); + + it(`Should not change the shown component shown state until it exceeds the scrolling tolerance set, + and closing scroll strategy is used.`, + fakeAsync(async () => { + TestBed.overrideComponent(EmptyPageComponent, { + set: { + styles: [ + 'button { position: absolute; top: 200%; left: 90%; }' + ] + } + }); + await TestBed.compileComponents(); + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + const scrollTolerance = 10; + const scrollStrategy = new CloseScrollStrategy(); + const overlay = fixture.componentInstance.overlay; + const overlaySettings: OverlaySettings = { + positionStrategy: new GlobalPositionStrategy(), + scrollStrategy, + closeOnOutsideClick: false, + modal: false, + }; + + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(); + expect(document.documentElement.scrollTop).toEqual(0); + + document.documentElement.scrollTop += scrollTolerance; + document.documentElement.dispatchEvent(new Event('scroll')); + tick(); + expect(document.documentElement.scrollTop).toEqual(scrollTolerance); + const wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER)[0] as HTMLElement; + expect(wrapperElement).toBeDefined(); + expect(wrapperElement.style.visibility).toEqual(''); + fixture.destroy(); + + overlay.detachAll(); + })); + + it(`Should close the shown component shown when it exceeds the scrolling threshold set, and closing scroll strategy is used. + (an expanded DropDown, Menu, DatePicker, etc.collapses).`, fakeAsync(async () => { + TestBed.overrideComponent(EmptyPageComponent, { + set: { + styles: [ + 'button { position: absolute; top: 100%; left: 90%; }' + ] + } + }); + await TestBed.compileComponents(); + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + const scrollTolerance = 10; + const scrollStrategy = new CloseScrollStrategy(); + const overlay = fixture.componentInstance.overlay; + const overlaySettings: OverlaySettings = { + positionStrategy: new GlobalPositionStrategy(), + scrollStrategy, + modal: false, + }; + + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(); + expect(document.documentElement.scrollTop).toEqual(0); + + document.documentElement.scrollTop += scrollTolerance; + document.documentElement.dispatchEvent(new Event('scroll')); + tick(); + let wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER)[0] as HTMLElement; + expect(wrapperElement).toBeDefined(); + expect(wrapperElement.style.visibility).toEqual(''); + expect(document.documentElement.scrollTop).toEqual(scrollTolerance); + + document.documentElement.scrollTop += scrollTolerance * 2; + document.documentElement.dispatchEvent(new Event('scroll')); + tick(); + + wrapperElement = (fixture.nativeElement as HTMLElement) + .parentElement.getElementsByClassName(CLASS_OVERLAY_WRAPPER)[0] as HTMLElement; + expect(wrapperElement).toBeDefined(); + expect(wrapperElement.style.visibility).toEqual('hidden'); + + overlay.detachAll(); + })); + + // 2.3 Scroll Strategy - NoOp. + it('Should retain the component static and only the background scrolls, when scrolling and noOP scroll strategy is used.', + fakeAsync(async () => { + TestBed.overrideComponent(EmptyPageComponent, { + set: { + styles: [ + 'button { position: absolute; top: 200%; left: 90%; }' + ] + } + }); + await TestBed.compileComponents(); + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + const scrollTolerance = 10; + const scrollStrategy = new NoOpScrollStrategy(); + const overlay = fixture.componentInstance.overlay; + const overlaySettings: OverlaySettings = { + modal: false, + scrollStrategy, + positionStrategy: new GlobalPositionStrategy() + }; + + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(); + expect(document.getElementsByClassName(CLASS_OVERLAY_WRAPPER).length).toEqual(1); + + const componentElement = (fixture.debugElement.nativeElement as HTMLElement) + .parentElement.getElementsByTagName('test-simple-dynamic-component')[0]; + const componentRect = componentElement.getBoundingClientRect(); + + document.documentElement.scrollTop += scrollTolerance; + document.documentElement.dispatchEvent(new Event('scroll')); + tick(); + expect(document.documentElement.scrollTop).toEqual(scrollTolerance); + expect(document.getElementsByClassName(CLASS_OVERLAY_WRAPPER).length).toEqual(1); + expect(componentElement.getBoundingClientRect()).toEqual(componentRect); + + overlay.detachAll(); + })); + + // 2.4. Scroll Strategy - Absolute. + it('Should scroll everything except component when scrolling and absolute scroll strategy is used.', fakeAsync(async () => { + + // Should behave as NoOpScrollStrategy + TestBed.overrideComponent(EmptyPageComponent, { + set: { + styles: [ + 'button { position: absolute; top:200%; left: 100%; }', + ] + } + }); + await TestBed.compileComponents(); + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + const scrollTolerance = 10; + const scrollStrategy = new NoOpScrollStrategy(); + const overlay = fixture.componentInstance.overlay; + const overlaySettings: OverlaySettings = { + closeOnOutsideClick: false, + modal: false, + positionStrategy: new ConnectedPositioningStrategy(), + scrollStrategy + }; + + overlay.show(overlay.attach(SimpleDynamicComponent, overlaySettings)); + tick(); + expect(document.getElementsByClassName(CLASS_OVERLAY_WRAPPER).length).toEqual(1); + + const componentElement = (fixture.debugElement.nativeElement as HTMLElement) + .parentElement.getElementsByTagName('test-simple-dynamic-component')[0]; + const componentRect = componentElement.getBoundingClientRect(); + + document.documentElement.scrollTop += scrollTolerance; + document.documentElement.dispatchEvent(new Event('scroll')); + tick(); + const newElementRect = componentElement.getBoundingClientRect(); + expect(document.documentElement.scrollTop).toEqual(scrollTolerance); + expect(newElementRect.top).toEqual(componentRect.top); + + overlay.detachAll(); + })); + + it('Should collapse/close the component when click outside it (DropDown, DatePicker, NavBar etc.)', fakeAsync(async () => { + TestBed.overrideComponent(EmptyPageComponent, { + set: { + styles: [ + 'button { position: absolute; top: 90%; left: 100%; }' + ] + } + }); + await TestBed.compileComponents(); + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + const overlay = fixture.componentInstance.overlay; + const overlaySettings: OverlaySettings = { + modal: false, + closeOnOutsideClick: true, + positionStrategy: new GlobalPositionStrategy() + }; + + spyOn(overlay, 'show').and.callThrough(); + spyOn(overlay.closing, 'emit'); + + const firstCallId = overlay.attach(SimpleDynamicComponent, overlaySettings); + overlay.show(firstCallId); + tick(); + expect(overlay.show).toHaveBeenCalledTimes(1); + expect(overlay.closing.emit).toHaveBeenCalledTimes(0); + + document.documentElement.click(); + tick(); + expect(overlay.closing.emit).toHaveBeenCalledTimes(1); + expect(overlay.closing.emit).toHaveBeenCalledWith({ + id: firstCallId, + componentRef: jasmine.any(ComponentRef) as any, + cancel: false, + event: jasmine.any(Event) as any + }); + + overlay.detachAll(); + })); + + it('Should remain opened when click is on an element contained in the excludeFromOutsideClick collection', fakeAsync(async () => { + const fixture = TestBed.createComponent(EmptyPageComponent); + fixture.detectChanges(); + + const overlay = fixture.componentInstance.overlay; + const divElement = fixture.componentInstance.divElement.nativeElement as HTMLElement; + const overlaySettings: OverlaySettings = { + modal: false, + closeOnOutsideClick: true, + positionStrategy: new GlobalPositionStrategy(), + excludeFromOutsideClick: [divElement] + }; + + spyOn(overlay, 'show').and.callThrough(); + spyOn(overlay.closing, 'emit'); + spyOn(overlay.closed, 'emit'); + + let callId = overlay.attach(SimpleDynamicComponent, overlaySettings); + overlay.show(callId); + tick(); + expect(overlay.show).toHaveBeenCalledTimes(1); + + divElement.click(); + tick(); + + expect(overlay.closing.emit).toHaveBeenCalledTimes(0); + expect(overlay.closed.emit).toHaveBeenCalledTimes(0); + + overlay.hideAll(); + tick(); + expect(overlay.closing.emit).toHaveBeenCalledTimes(1); + expect(overlay.closed.emit).toHaveBeenCalledTimes(1); + expect(overlay.closing.emit) + .toHaveBeenCalledWith({ + id: callId, + componentRef: jasmine.any(ComponentRef) as any, + cancel: false, + event: undefined + }); + overlay.detachAll(); + + overlaySettings.excludeFromOutsideClick = []; + tick(); + callId = overlay.attach(SimpleDynamicComponent, overlaySettings); + overlay.show(callId); + tick(); + + expect(overlay.show).toHaveBeenCalledTimes(2); + divElement.click(); + tick(); + + expect(overlay.closing.emit).toHaveBeenCalledTimes(2); + expect(overlay.closed.emit).toHaveBeenCalledTimes(2); + expect(overlay.closing.emit) + .toHaveBeenCalledWith({ + id: callId, + componentRef: jasmine.any(ComponentRef) as any, + cancel: false, + event: jasmine.any(Event) as any + }); + + overlay.detachAll(); + })); + }); + + describe('Integration tests p3 (IgniteUI components): ', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, SimpleDynamicWithDirectiveComponent] + }).compileComponents(); + })); + it(`Should properly be able to render components that have no initial content(IgxCalendar, IgxAvatar)`, fakeAsync(() => { + const fixture = TestBed.createComponent(SimpleRefComponent); + fixture.detectChanges(); + const IGX_CALENDAR_TAG = 'igx-calendar'; + const IGX_AVATAR_TAG = 'igx-avatar'; + const IGX_DATE_PICKER_TAG = 'igx-calendar-container'; + const overlay = fixture.componentInstance.overlay; + + expect((fixture.elementRef.nativeElement as HTMLElement) + .parentElement.getElementsByTagName(IGX_CALENDAR_TAG).length).toEqual(0); + expect((fixture.elementRef.nativeElement as HTMLElement) + .parentElement.getElementsByTagName(IGX_AVATAR_TAG).length).toEqual(0); + expect((fixture.elementRef.nativeElement as HTMLElement) + .parentElement.getElementsByTagName(IGX_DATE_PICKER_TAG).length).toEqual(0); + + let overlayId = overlay.attach(IgxCalendarComponent); + overlay.show(overlayId); + fixture.detectChanges(); + expect((fixture.elementRef.nativeElement as HTMLElement) + .parentElement.getElementsByTagName(IGX_CALENDAR_TAG).length).toEqual(1); + + overlay.hide(overlayId); + overlay.detach(overlayId); + tick(); + fixture.detectChanges(); + expect((fixture.elementRef.nativeElement as HTMLElement) + .parentElement.getElementsByTagName(IGX_CALENDAR_TAG).length).toEqual(0); + + overlayId = overlay.attach(IgxAvatarComponent); + overlay.show(overlayId); + fixture.detectChanges(); + expect((fixture.elementRef.nativeElement as HTMLElement) + .parentElement.getElementsByTagName(IGX_AVATAR_TAG).length).toEqual(1); + + overlay.hide(overlayId); + overlay.detach(overlayId); + tick(); + fixture.detectChanges(); + expect((fixture.elementRef.nativeElement as HTMLElement) + .parentElement.getElementsByTagName(IGX_AVATAR_TAG).length).toEqual(0); + + overlayId = overlay.attach(IgxCalendarContainerComponent); + overlay.show(overlayId); + fixture.detectChanges(); + expect((fixture.elementRef.nativeElement as HTMLElement) + .parentElement.getElementsByTagName(IGX_DATE_PICKER_TAG).length).toEqual(1); + + overlay.hide(overlayId); + overlay.detach(overlayId); + tick(); + fixture.detectChanges(); + expect((fixture.elementRef.nativeElement as HTMLElement) + .parentElement.getElementsByTagName(IGX_DATE_PICKER_TAG).length).toEqual(0); + + overlay.detachAll(); + })); + }); +}); + +@Component({ + selector: `test-simple-dynamic-component`, + template: `
`, + standalone: true +}) +export class SimpleDynamicComponent { + @HostBinding('style.display') + public hostDisplay = 'block'; + @HostBinding('style.width') + @HostBinding('style.height') + public hostDimensions = '100px'; +} + +@Component({ + template: `
`, + standalone: true +}) +export class SimpleRefComponent { + public overlay = inject(IgxOverlayService); + + @ViewChild('item', { static: true }) + public item: ElementRef; +} + +@Component({ + template: `
`, + standalone: true +}) +export class SimpleBigSizeComponent { + @HostBinding('style.display') + public hostDisplay = 'block'; + @HostBinding('style.height') + public hostHeight = '1000px'; + @HostBinding('style.width') + public hostWidth = '3000px'; +} + +@Component({ + template: ` +
+ @if (visible) { +
+

AAAAA

+

AAAAA

+

AAAAA

+

AAAAA

+

AAAAA

+

AAAAA

+

AAAAA

+

AAAAA

+

AAAAA

+
+ } +
`, + imports: [IgxToggleDirective] +}) +export class SimpleDynamicWithDirectiveComponent { + @ViewChild(IgxToggleDirective, { static: true }) + private _toggle: IgxToggleDirective; + + public visible = false; + + public get toggle(): IgxToggleDirective { + return this._toggle; + } + + public show(overlaySettings?: OverlaySettings) { + this.visible = true; + this.toggle.open(overlaySettings); + } + + public hide() { + this.visible = false; + this.toggle.close(); + } +} + +@Component({ + template: ` + +
+ `, + styles: [`button { + position: absolute; + top: 0; + left: 0; + width: 84px; + height: 84px; + padding: 0; + margin: 0; + border: none; + }`], + standalone: true +}) +export class EmptyPageComponent { + public overlay = inject(IgxOverlayService); + public viewContainerRef = inject(ViewContainerRef); + public injector = inject(Injector); + + @ViewChild('button', { static: true }) public buttonElement: ElementRef; + @ViewChild('div', { static: true }) public divElement: ElementRef; + + public click() { + this.overlay.show(this.overlay.attach(SimpleDynamicComponent)); + } +} + +@Component({ + template: ` + +
+ `, + encapsulation: ViewEncapsulation.ShadowDom, + standalone: true +}) +export class EmptyPageInShadowDomComponent { + public overlay = inject(IgxOverlayService); + + @ViewChild('button', { static: true }) public buttonElement: ElementRef; + @ViewChild('outlet', { static: true }) public outletElement: ElementRef; +} + +@Component({ + template: ``, + styles: [`button { + position: absolute; + bottom: 0px; + right: 0px; + width: 84px; + height: 84px; + padding: 0px; + margin: 0px; + border: 0px; + }`], + standalone: true +}) +export class DownRightButtonComponent { + public overlay = inject(IgxOverlayService); + + @ViewChild('button', { static: true }) public buttonElement: ElementRef; + + public positionStrategy: IPositionStrategy; + + public ButtonPositioningSettings: PositionSettings = { + horizontalDirection: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Bottom, + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Top + }; + + public target: Point | HTMLElement = null; + + public click() { + this.positionStrategy.settings = this.ButtonPositioningSettings; + this.overlay.show(this.overlay.attach(SimpleDynamicComponent, { + target: this.target, + positionStrategy: this.positionStrategy, + scrollStrategy: new NoOpScrollStrategy(), + modal: false, + closeOnOutsideClick: false + })); + } +} + +@Component({ + template: ``, + styles: [`button { + position: absolute; + top: 300px; + left: 300px; + width: 100px; + height: 60px; + border: 0px; + }`], + standalone: true +}) +export class TopLeftOffsetComponent { + public overlay = inject(IgxOverlayService); + + + @ViewChild('button', { static: true }) public buttonElement: ElementRef; + + public click() { + this.overlay.show(this.overlay.attach(SimpleDynamicComponent)); + } +} + +@Component({ + template: ` +
+ +
+
+ +
`, + standalone: true +}) +export class TwoButtonsComponent { + public overlay = inject(IgxOverlayService); + + public settings: OverlaySettings = { modal: false }; + + public clickOne() { + this.overlay.show(this.overlay.attach(SimpleDynamicComponent), this.settings); + } + + public clickTwo() { + this.overlay.show(this.overlay.attach(SimpleDynamicComponent), this.settings); + } + + public divClick(ev: Event) { + ev.stopPropagation(); + } +} + +@Component({ + template: ` +
+ +
+ Some Content +
+
`, + styles: [`button { + position: absolute; + top: 300px; + left: 300px; + width: 100px; + height: 60px; + border: 0; + }`], + standalone: true +}) +export class WidthTestOverlayComponent { + public overlay = inject(IgxOverlayService); + public elementRef = inject(ElementRef); + + + @ViewChild('button', { static: true }) public buttonElement: ElementRef; + @ViewChild('myCustomComponent', { static: true }) public customComponent: ElementRef; + public overlaySettings: OverlaySettings = {}; + + public click(_event: any) { + this.overlaySettings.positionStrategy = new ConnectedPositioningStrategy(); + this.overlaySettings.scrollStrategy = new NoOpScrollStrategy(); + this.overlaySettings.closeOnOutsideClick = true; + this.overlaySettings.modal = false; + + this.overlaySettings.target = this.buttonElement.nativeElement; + this.overlay.show(this.overlay.attach(this.customComponent, this.overlaySettings)); + } +} + +@Component({ + template: ` +
+ @if (visible) { +
+

AAAAA

+

AAAAA

+

AAAAA

+

AAAAA

+

AAAAA

+

AAAAA

+

AAAAA

+

AAAAA

+

AAAAA

+
+ } +
`, + imports: [] +}) +export class ScrollableComponent { + @ViewChild(IgxToggleDirective, { static: true }) + private _toggle: IgxToggleDirective; + + public visible = false; + + public get toggle(): IgxToggleDirective { + return this._toggle; + } + + public show() { + this.visible = true; + const settings: OverlaySettings = { scrollStrategy: new CloseScrollStrategy() }; + this.toggle.open(settings); + } + + public hide() { + this.toggle.close(); + this.visible = false; + } +} + +@Component({ + template: ` +
+ +
+ `, + standalone: true +}) +export class FlexContainerComponent { + public overlay = inject(IgxOverlayService); + + @ViewChild('button', { static: true }) public buttonElement: ElementRef; + public overlaySettings: OverlaySettings = {}; + + public click() { + this.overlay.show(this.overlay.attach(SimpleDynamicComponent), this.overlaySettings); + } +} diff --git a/projects/igniteui-angular/core/src/services/overlay/overlay.ts b/projects/igniteui-angular/core/src/services/overlay/overlay.ts new file mode 100644 index 00000000000..687d0aa143e --- /dev/null +++ b/projects/igniteui-angular/core/src/services/overlay/overlay.ts @@ -0,0 +1,1003 @@ +import { AnimationReferenceMetadata } from '@angular/animations'; +import { ApplicationRef, ComponentRef, createComponent, ElementRef, EventEmitter, Injectable, Injector, NgZone, OnDestroy, Type, ViewContainerRef, DOCUMENT, inject } from '@angular/core'; +import { fromEvent, Subject, Subscription } from 'rxjs'; +import { filter, takeUntil } from 'rxjs/operators'; + +import { fadeIn, fadeOut, IAnimationParams, scaleInHorLeft, scaleInHorRight, scaleInVerBottom, scaleInVerTop, scaleOutHorLeft, scaleOutHorRight, scaleOutVerBottom, scaleOutVerTop, slideInBottom, slideInTop, slideOutBottom, slideOutTop } from 'igniteui-angular/animations'; +import { PlatformUtil } from '../../core/utils'; +import { IgxOverlayOutletDirective } from './utilities'; +import { IgxAngularAnimationService } from '../animation/angular-animation-service'; +import { AnimationService } from '../animation/animation'; +import { AutoPositionStrategy } from './position/auto-position-strategy'; +import { ConnectedPositioningStrategy } from './position/connected-positioning-strategy'; +import { ContainerPositionStrategy } from './position/container-position-strategy'; +import { ElasticPositionStrategy } from './position/elastic-position-strategy'; +import { GlobalPositionStrategy } from './position/global-position-strategy'; +import { IPositionStrategy } from './position/IPositionStrategy'; +import { NoOpScrollStrategy } from './scroll/NoOpScrollStrategy'; +import { + AbsolutePosition, + HorizontalAlignment, + OffsetMode, + OverlayAnimationEventArgs, + OverlayCancelableEventArgs, + OverlayClosingEventArgs, + OverlayCreateSettings, + OverlayEventArgs, + OverlayInfo, + OverlaySettings, + Point, + PositionSettings, + RelativePosition, + RelativePositionStrategy, + VerticalAlignment +} from './utilities'; + +/** + * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/overlay-main) + * The overlay service allows users to show components on overlay div above all other elements in the page. + */ +@Injectable({ providedIn: 'root' }) +export class IgxOverlayService implements OnDestroy { + private _appRef = inject(ApplicationRef); + private document = inject(DOCUMENT); + private _zone = inject(NgZone); + protected platformUtil = inject(PlatformUtil); + private animationService = inject(IgxAngularAnimationService); + + /** + * Emitted just before the overlay content starts to open. + * ```typescript + * opening(event: OverlayCancelableEventArgs){ + * const opening = event; + * } + * ``` + */ + public opening = new EventEmitter(); + + /** + * Emitted after the overlay content is opened and all animations are finished. + * ```typescript + * opened(event: OverlayEventArgs){ + * const opened = event; + * } + * ``` + */ + public opened = new EventEmitter(); + + /** + * Emitted just before the overlay content starts to close. + * ```typescript + * closing(event: OverlayCancelableEventArgs){ + * const closing = event; + * } + * ``` + */ + public closing = new EventEmitter(); + + /** + * Emitted after the overlay content is closed and all animations are finished. + * ```typescript + * closed(event: OverlayEventArgs){ + * const closed = event; + * } + * ``` + */ + public closed = new EventEmitter(); + + /** + * Emitted before the content is appended to the overlay. + * ```typescript + * contentAppending(event: OverlayEventArgs){ + * const contentAppending = event; + * } + * ``` + */ + public contentAppending = new EventEmitter(); + + /** + * Emitted after the content is appended to the overlay, and before animations are started. + * ```typescript + * contentAppended(event: OverlayEventArgs){ + * const contentAppended = event; + * } + * ``` + */ + public contentAppended = new EventEmitter(); + + /** + * Emitted just before the overlay animation start. + * ```typescript + * animationStarting(event: OverlayAnimationEventArgs){ + * const animationStarting = event; + * } + * ``` + */ + public animationStarting = new EventEmitter(); + + private _componentId = 0; + private _overlayInfos: OverlayInfo[] = []; + private _overlayElement: HTMLElement; + private _document: Document; + private _keyPressEventListener: Subscription; + private destroy$ = new Subject(); + private _cursorStyleIsSet = false; + private _cursorOriginalValue: string; + + private _defaultSettings: OverlaySettings = { + excludeFromOutsideClick: [], + positionStrategy: new GlobalPositionStrategy(), + scrollStrategy: new NoOpScrollStrategy(), + modal: true, + closeOnOutsideClick: true, + closeOnEscape: false + }; + + constructor() { + this._document = this.document; + } + + /** + * Creates overlay settings with global or container position strategy and preset position settings + * + * @param position Preset position settings. Default position is 'center' + * @param outlet The outlet container to attach the overlay to + * @returns Non-modal overlay settings based on Global or Container position strategy and the provided position. + */ + public static createAbsoluteOverlaySettings( + position?: AbsolutePosition, outlet?: IgxOverlayOutletDirective | ElementRef): OverlaySettings { + const positionSettings = this.createAbsolutePositionSettings(position); + const strategy = outlet ? new ContainerPositionStrategy(positionSettings) : new GlobalPositionStrategy(positionSettings); + const overlaySettings: OverlaySettings = { + positionStrategy: strategy, + scrollStrategy: new NoOpScrollStrategy(), + modal: false, + closeOnOutsideClick: true, + outlet + }; + return overlaySettings; + } + + /** + * Creates overlay settings with auto, connected or elastic position strategy and preset position settings + * + * @param target Attaching target for the component to show + * @param strategy The relative position strategy to be applied to the overlay settings. Default is Auto positioning strategy. + * @param position Preset position settings. By default the element is positioned below the target, left aligned. + * @returns Non-modal overlay settings based on the provided target, strategy and position. + */ + public static createRelativeOverlaySettings( + target: Point | HTMLElement, + position?: RelativePosition, + strategy?: RelativePositionStrategy): + OverlaySettings { + const positionSettings = this.createRelativePositionSettings(position); + const overlaySettings: OverlaySettings = { + target, + positionStrategy: this.createPositionStrategy(strategy, positionSettings), + scrollStrategy: new NoOpScrollStrategy(), + modal: false, + closeOnOutsideClick: true + }; + return overlaySettings; + } + + private static createAbsolutePositionSettings(position: AbsolutePosition): PositionSettings { + let positionSettings: PositionSettings; + switch (position) { + case AbsolutePosition.Bottom: + positionSettings = { + horizontalDirection: HorizontalAlignment.Center, + verticalDirection: VerticalAlignment.Bottom, + openAnimation: slideInBottom, + closeAnimation: slideOutBottom + }; + break; + case AbsolutePosition.Top: + positionSettings = { + horizontalDirection: HorizontalAlignment.Center, + verticalDirection: VerticalAlignment.Top, + openAnimation: slideInTop, + closeAnimation: slideOutTop + }; + break; + case AbsolutePosition.Center: + default: + positionSettings = { + horizontalDirection: HorizontalAlignment.Center, + verticalDirection: VerticalAlignment.Middle, + openAnimation: fadeIn, + closeAnimation: fadeOut + }; + } + return positionSettings; + } + + private static createRelativePositionSettings(position: RelativePosition): PositionSettings { + let positionSettings: PositionSettings; + switch (position) { + case RelativePosition.Above: + positionSettings = { + horizontalStartPoint: HorizontalAlignment.Center, + verticalStartPoint: VerticalAlignment.Top, + horizontalDirection: HorizontalAlignment.Center, + verticalDirection: VerticalAlignment.Top, + openAnimation: scaleInVerBottom, + closeAnimation: scaleOutVerBottom, + }; + break; + case RelativePosition.Below: + positionSettings = { + horizontalStartPoint: HorizontalAlignment.Center, + verticalStartPoint: VerticalAlignment.Bottom, + horizontalDirection: HorizontalAlignment.Center, + verticalDirection: VerticalAlignment.Bottom, + openAnimation: scaleInVerTop, + closeAnimation: scaleOutVerTop + }; + break; + case RelativePosition.After: + positionSettings = { + horizontalStartPoint: HorizontalAlignment.Right, + verticalStartPoint: VerticalAlignment.Middle, + horizontalDirection: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Middle, + openAnimation: scaleInHorLeft, + closeAnimation: scaleOutHorLeft + }; + break; + case RelativePosition.Before: + positionSettings = { + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Middle, + horizontalDirection: HorizontalAlignment.Left, + verticalDirection: VerticalAlignment.Middle, + openAnimation: scaleInHorRight, + closeAnimation: scaleOutHorRight + }; + break; + case RelativePosition.Default: + default: + positionSettings = { + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Bottom, + horizontalDirection: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Bottom, + openAnimation: scaleInVerTop, + closeAnimation: scaleOutVerTop, + }; + break; + } + return positionSettings; + } + + private static createPositionStrategy(strategy: RelativePositionStrategy, positionSettings: PositionSettings): IPositionStrategy { + switch (strategy) { + case RelativePositionStrategy.Connected: + return new ConnectedPositioningStrategy(positionSettings); + case RelativePositionStrategy.Elastic: + return new ElasticPositionStrategy(positionSettings); + case RelativePositionStrategy.Auto: + default: + return new AutoPositionStrategy(positionSettings); + } + } + + /** + * Generates Id. Provide this Id when call `show(id)` method + * + * @param component ElementRef to show in overlay + * @param settings (optional): Display settings for the overlay, such as positioning and scroll/close behavior. + * @returns Id of the created overlay. Valid until `detach` is called. + */ + public attach(element: ElementRef, settings?: OverlaySettings): string; + /** + * Generates Id. Provide this Id when call `show(id)` method + * + * Note created instance is in root scope, prefer the `viewContainerRef` overload when local injection context is needed. + * + * @param component Component Type to show in overlay + * @param settings (optional): Create settings for the overlay, such as positioning and scroll/close behavior. + * Includes also an optional `Injector` to add to the created dynamic component's injectors. + * @returns Id of the created overlay. Valid until `detach` is called. + */ + public attach(component: Type, settings?: OverlayCreateSettings): string; + // TODO: change third parameter to OverlayCreateSettings and allow passing of Injector and so on. + /** + * Generates an Id. Provide this Id when calling the `show(id)` method + * + * @param component Component Type to show in overlay + * @param viewContainerRef Reference to the container where created component's host view will be inserted + * @param settings (optional): Display settings for the overlay, such as positioning and scroll/close behavior. + */ + public attach(component: Type, viewContainerRef: ViewContainerRef, settings?: OverlaySettings): string; + public attach( + componentOrElement: ElementRef | Type, + viewContainerRefOrSettings?: ViewContainerRef | OverlayCreateSettings, + settings?: OverlaySettings): string { + const info: OverlayInfo = this.getOverlayInfo(componentOrElement, viewContainerRefOrSettings, settings); + + if (!info) { + console.warn('Overlay was not able to attach provided component!'); + return null; + } + + info.id = (this._componentId++).toString(); + info.visible = false; + // Emit the contentAppending event before appending the content + const eventArgs = { id: info.id, elementRef: info.elementRef, componentRef: info.componentRef, settings: info.settings }; + this.contentAppending.emit(eventArgs); + // Append the content to the overlay + info.settings = eventArgs.settings; + this._overlayInfos.push(info); + info.hook = this.placeElementHook(info.elementRef.nativeElement); + const elementRect = info.elementRef.nativeElement.getBoundingClientRect(); + info.initialSize = { width: elementRect.width, height: elementRect.height }; + // Get the size before moving the container into the overlay so that it does not forget about inherited styles. + this.getComponentSize(info); + this.moveElementToOverlay(info); + // Update the container size after moving if there is size. + if (info.size) { + info.elementRef.nativeElement.parentElement.style.setProperty('--ig-size', info.size); + } + this.contentAppended.emit({ id: info.id, componentRef: info.componentRef }); + info.settings.scrollStrategy.initialize(this._document, this, info.id); + info.settings.scrollStrategy.attach(); + this.addOutsideClickListener(info); + this.addResizeHandler(); + this.addCloseOnEscapeListener(info); + this.buildAnimationPlayers(info); + return info.id; + } + + /** + * Remove overlay with the provided id. + * + * @param id Id of the overlay to remove + * ```typescript + * this.overlay.detach(id); + * ``` + */ + public detach(id: string) { + const info: OverlayInfo = this.getOverlayById(id); + + if (!info) { + console.warn('igxOverlay.detach was called with wrong id: ', id); + return; + } + info.detached = true; + this.finishAnimations(info); + info.settings.scrollStrategy.detach(); + this.removeOutsideClickListener(info); + this.removeResizeHandler(); + this.cleanUp(info); + } + + /** + * Remove all the overlays. + * ```typescript + * this.overlay.detachAll(); + * ``` + */ + public detachAll() { + for (let i = this._overlayInfos.length; i--;) { + this.detach(this._overlayInfos[i].id); + } + } + + /** + * Shows the overlay for provided id. + * + * @param id Id to show overlay for + * @param settings Display settings for the overlay, such as positioning and scroll/close behavior. + */ + public show(id: string, settings?: OverlaySettings): void { + const info: OverlayInfo = this.getOverlayById(id); + if (!info) { + console.warn('igxOverlay.show was called with wrong id: ', id); + return; + } + const eventArgs: OverlayCancelableEventArgs = { id, componentRef: info.componentRef, cancel: false }; + this.opening.emit(eventArgs); + if (eventArgs.cancel) { + return; + } + if (settings) { + const newScrollStrategy = settings.scrollStrategy && info.settings.scrollStrategy !== settings.scrollStrategy; + if (newScrollStrategy && info.settings.scrollStrategy) { + info.settings.scrollStrategy.detach(); + } + + settings.positionStrategy ??= info.settings.positionStrategy; + settings.scrollStrategy ??= info.settings.scrollStrategy; + info.settings = { ...info.settings, ...settings }; + + if (newScrollStrategy) { + info.settings.scrollStrategy.initialize(this._document, this, info.id); + info.settings.scrollStrategy.attach(); + } + } + this.updateSize(info); + const openAnimation = info.settings.positionStrategy.settings.openAnimation; + const closeAnimation = info.settings.positionStrategy.settings.closeAnimation; + info.settings.positionStrategy.position( + info.elementRef.nativeElement.parentElement, + { width: info.initialSize.width, height: info.initialSize.height }, + this._document, + true, + info.settings.target); + if (openAnimation !== info.settings.positionStrategy.settings.openAnimation || + closeAnimation !== info.settings.positionStrategy.settings.closeAnimation){ + this.buildAnimationPlayers(info); + } + this.addModalClasses(info); + if (info.settings.positionStrategy.settings.openAnimation) { + // TODO: should we build players again. This was already done in attach!!! + // this.buildAnimationPlayers(info); + this.playOpenAnimation(info); + } else { + // to eliminate flickering show the element just before opened fires + info.wrapperElement.style.visibility = ''; + info.visible = true; + this.opened.emit({ id: info.id, componentRef: info.componentRef }); + } + } + + /** + * Hides the component with the ID provided as a parameter. + * ```typescript + * this.overlay.hide(id); + * ``` + */ + public hide(id: string, event?: Event) { + this._hide(id, event); + } + + /** + * Hides all the components and the overlay. + * ```typescript + * this.overlay.hideAll(); + * ``` + */ + public hideAll() { + for (let i = this._overlayInfos.length; i--;) { + this.hide(this._overlayInfos[i].id); + } + } + + /** + * Repositions the component with ID provided as a parameter. + * + * @param id Id to reposition overlay for + * ```typescript + * this.overlay.reposition(id); + * ``` + */ + public reposition(id: string) { + const overlayInfo = this.getOverlayById(id); + if (!overlayInfo || !overlayInfo.settings) { + console.warn('Wrong id provided in overlay.reposition method. Id: ', id); + return; + } + if (!overlayInfo.visible) { + return; + } + const contentElement = overlayInfo.elementRef.nativeElement.parentElement; + const contentElementRect = contentElement.getBoundingClientRect(); + overlayInfo.settings.positionStrategy.position( + contentElement, + { + width: contentElementRect.width, + height: contentElementRect.height + }, + this._document, + false, + overlayInfo.settings.target); + } + + /** + * Offsets the content along the corresponding axis by the provided amount with optional offsetMode that determines whether to add (by default) or set the offset values + * + * @param id Id to offset overlay for + * @param deltaX Amount of offset in horizontal direction + * @param deltaY Amount of offset in vertical direction + * @param offsetMode Determines whether to add (by default) or set the offset values with OffsetMode.Add and OffsetMode.Set + * ```typescript + * this.overlay.setOffset(id, deltaX, deltaY, offsetMode); + * ``` + */ + public setOffset(id: string, deltaX: number, deltaY: number, offsetMode?: OffsetMode) { + const info: OverlayInfo = this.getOverlayById(id); + + if (!info) { + return; + } + + switch (offsetMode) { + case OffsetMode.Set: + info.transformX = deltaX; + info.transformY = deltaY; + break; + case OffsetMode.Add: + default: + info.transformX += deltaX; + info.transformY += deltaY; + break; + } + + const transformX = info.transformX; + const transformY = info.transformY; + + const translate = `translate(${transformX}px, ${transformY}px)`; + info.elementRef.nativeElement.parentElement.style.transform = translate; + } + + /** @hidden */ + public repositionAll = () => { + for (let i = this._overlayInfos.length; i--;) { + this.reposition(this._overlayInfos[i].id); + } + }; + + /** @hidden */ + public ngOnDestroy(): void { + this.detachAll(); + this.removeCloseOnEscapeListener(); + this.destroy$.next(true); + this.destroy$.complete(); + } + + /** @hidden @internal */ + public getOverlayById(id: string): OverlayInfo { + if (!id) { + return null; + } + const info = this._overlayInfos.find(e => e.id === id); + return info; + } + + private _hide(id: string, event?: Event) { + const info: OverlayInfo = this.getOverlayById(id); + if (!info) { + console.warn('igxOverlay.hide was called with wrong id: ', id); + return; + } + const eventArgs: OverlayClosingEventArgs = { id, componentRef: info.componentRef, cancel: false, event }; + this.closing.emit(eventArgs); + if (eventArgs.cancel) { + return; + } + this.removeModalClasses(info); + if (info.settings.positionStrategy.settings.closeAnimation) { + this.playCloseAnimation(info, event); + } else { + this.closeDone(info); + } + } + + /** + * Creates overlayInfo. Sets the info's `elementRef`, `componentRef`and `settings`. Also + * initialize info's `ngZone`, `transformX` and `transformY`. + * @param component ElementRef or Type. If type is provided dynamic component will be created + * @param viewContainerRefOrSettings (optional): If ElementRef is provided for `component` this + * parameter is OverlaySettings. Otherwise it could be ViewContainerRef or OverlayCreateSettings and will be + * used when dynamic component is created. + * @param settings (optional): OverlaySettings when `ViewContainerRef` is provided. + * @returns OverlayInfo + */ + private getOverlayInfo( + component: ElementRef | Type, + viewContainerRefOrSettings?: ViewContainerRef | OverlayCreateSettings, + settings?: OverlaySettings): OverlayInfo | null { + const info: OverlayInfo = { ngZone: this._zone, transformX: 0, transformY: 0 }; + let overlaySettings = settings; + if (component instanceof ElementRef) { + info.elementRef = component; + overlaySettings = viewContainerRefOrSettings as OverlaySettings; + } else { + let dynamicComponent: ComponentRef; + if (viewContainerRefOrSettings instanceof ViewContainerRef) { + const viewContainerRef = viewContainerRefOrSettings as ViewContainerRef; + dynamicComponent = viewContainerRef.createComponent(component); + } else { + const environmentInjector = this._appRef.injector; + const createSettings = viewContainerRefOrSettings as OverlayCreateSettings | undefined; + let elementInjector: Injector; + if (createSettings) { + ({ injector: elementInjector, ...overlaySettings } = createSettings); + } + dynamicComponent = createComponent(component, { environmentInjector, elementInjector }); + this._appRef.attachView(dynamicComponent.hostView); + } + if (dynamicComponent.onDestroy) { + dynamicComponent.onDestroy(() => { + if (!info.detached && this._overlayInfos.indexOf(info) !== -1) { + this.detach(info.id); + } + }) + } + + // If the element is newly created from a Component, it is wrapped in 'ng-component' tag - we do not want that. + const element = dynamicComponent.location.nativeElement; + info.elementRef = { nativeElement: element }; + info.componentRef = dynamicComponent; + } + info.settings = Object.assign({}, this._defaultSettings, overlaySettings); + return info; + } + + private placeElementHook(element: HTMLElement): HTMLElement { + if (!element.parentElement) { + return null; + } + const hook = this._document.createElement('div'); + hook.style.display = 'none'; + element.parentElement.insertBefore(hook, element); + return hook; + } + + private moveElementToOverlay(info: OverlayInfo) { + info.wrapperElement = this.getWrapperElement(); + const contentElement = this.getContentElement(info.wrapperElement, info.settings.modal); + this.getOverlayElement(info).appendChild(info.wrapperElement); + contentElement.appendChild(info.elementRef.nativeElement); + } + + private getWrapperElement(): HTMLElement { + const wrapper: HTMLElement = this._document.createElement('div'); + wrapper.classList.add('igx-overlay__wrapper'); + return wrapper; + } + + private getContentElement(wrapperElement: HTMLElement, modal: boolean): HTMLElement { + const content: HTMLElement = this._document.createElement('div'); + if (modal) { + content.classList.add('igx-overlay__content--modal'); + content.addEventListener('click', (ev: Event) => { + ev.stopPropagation(); + }); + } else { + content.classList.add('igx-overlay__content'); + } + content.addEventListener('scroll', (ev: Event) => { + ev.stopPropagation(); + }); + + // hide element to eliminate flickering. Show the element exactly before animation starts + wrapperElement.style.visibility = 'hidden'; + wrapperElement.appendChild(content); + return content; + } + + private getOverlayElement(info: OverlayInfo): HTMLElement { + if (info.settings.outlet) { + return info.settings.outlet.nativeElement || info.settings.outlet; + } + if (!this._overlayElement) { + this._overlayElement = this._document.createElement('div'); + this._overlayElement.classList.add('igx-overlay'); + this._document.body.appendChild(this._overlayElement); + } + return this._overlayElement; + } + + private updateSize(info: OverlayInfo) { + if (info.componentRef) { + // if we are positioning component this is first time it gets visible + // and we can finally get its size + info.componentRef.changeDetectorRef.detectChanges(); + info.initialSize = info.elementRef.nativeElement.getBoundingClientRect(); + } + + // set content div width only if element to show has width + if (info.initialSize.width !== 0) { + info.elementRef.nativeElement.parentElement.style.width = info.initialSize.width + 'px'; + } + } + + private closeDone(info: OverlayInfo) { + info.visible = false; + if (info.wrapperElement) { + // to eliminate flickering show the element just before animation start + info.wrapperElement.style.visibility = 'hidden'; + } + if (!info.closeAnimationDetaching) { + this.closed.emit({ id: info.id, componentRef: info.componentRef, event: info.event }); + } + delete info.event; + } + + private cleanUp(info: OverlayInfo) { + const child: HTMLElement = info.elementRef.nativeElement; + const outlet = this.getOverlayElement(info); + // if same element is shown in other overlay outlet will not contain + // the element and we should not remove it form outlet + if (outlet.contains(child)) { + outlet.removeChild(child.parentNode.parentNode); + } + if (info.componentRef) { + this._appRef.detachView(info.componentRef.hostView); + info.componentRef.destroy(); + delete info.componentRef; + } + if (info.hook) { + info.hook.parentElement.insertBefore(info.elementRef.nativeElement, info.hook); + info.hook.parentElement.removeChild(info.hook); + delete info.hook; + } + + const index = this._overlayInfos.indexOf(info); + this._overlayInfos.splice(index, 1); + + // this._overlayElement.parentElement check just for tests that manually delete the element + if (this._overlayInfos.length === 0) { + if (this._overlayElement && this._overlayElement.parentElement) { + this._overlayElement.parentElement.removeChild(this._overlayElement); + this._overlayElement = null; + } + this.removeCloseOnEscapeListener(); + } + + // clean all the resources attached to info + delete info.elementRef; + delete info.settings; + delete info.initialSize; + info.openAnimationDetaching = true; + info.openAnimationPlayer?.destroy(); + delete info.openAnimationPlayer; + info.closeAnimationDetaching = true; + info.closeAnimationPlayer?.destroy(); + delete info.closeAnimationPlayer; + delete info.ngZone; + delete info.wrapperElement; + info = null; + } + + private playOpenAnimation(info: OverlayInfo) { + // if there is opening animation already started do nothing + if (info.openAnimationPlayer?.hasStarted()) { + return; + } + if (info.closeAnimationPlayer?.hasStarted()) { + const position = info.closeAnimationPlayer.position; + info.closeAnimationPlayer.reset(); + info.openAnimationPlayer.init(); + info.openAnimationPlayer.position = 1 - position; + } + this.animationStarting.emit({ id: info.id, animationPlayer: info.openAnimationPlayer, animationType: 'open' }); + + // to eliminate flickering show the element just before animation start + info.wrapperElement.style.visibility = ''; + info.visible = true; + info.openAnimationPlayer.play(); + } + + private playCloseAnimation(info: OverlayInfo, event?: Event) { + // if there is closing animation already started do nothing + if (info.closeAnimationPlayer?.hasStarted()) { + return; + } + if (info.openAnimationPlayer?.hasStarted()) { + const position = info.openAnimationPlayer.position; + info.openAnimationPlayer.reset(); + info.closeAnimationPlayer.init(); + info.closeAnimationPlayer.position = 1 - position; + } + this.animationStarting.emit({ id: info.id, animationPlayer: info.closeAnimationPlayer, animationType: 'close' }); + info.event = event; + info.closeAnimationPlayer.play(); + } + + // TODO: check if applyAnimationParams will work with complex animations + private applyAnimationParams(wrapperElement: HTMLElement, animationOptions: AnimationReferenceMetadata) { + if (!animationOptions) { + wrapperElement.style.transitionDuration = '0ms'; + return; + } + if (!animationOptions.options || !animationOptions.options.params) { + return; + } + const params = animationOptions.options.params as IAnimationParams; + if (params.duration) { + wrapperElement.style.transitionDuration = params.duration; + } + if (params.easing) { + wrapperElement.style.transitionTimingFunction = params.easing; + } + } + + private documentClicked = (ev: MouseEvent) => { + // if we get to modal overlay just return - we should not close anything under it + // if we get to non-modal overlay do the next: + // 1. Check it has close on outside click. If not go on to next overlay; + // 2. If true check if click is on the element. If it is on the element we have closed + // already all previous non-modal with close on outside click elements, so we return. If + // not close the overlay and check next + for (let i = this._overlayInfos.length; i--;) { + const info = this._overlayInfos[i]; + if (info.settings.modal) { + return; + } + if (info.settings.closeOnOutsideClick) { + const target = ev.composed ? ev.composedPath()[0] : ev.target; + const overlayElement = info.elementRef.nativeElement; + // check if the click is on the overlay element or on an element from the exclusion list, and if so do not close the overlay + const excludeElements = info.settings.excludeFromOutsideClick ? + [...info.settings.excludeFromOutsideClick, overlayElement] : [overlayElement]; + const isInsideClick: boolean = excludeElements.some(e => e.contains(target as Node)); + if (isInsideClick) { + return; + // if the click is outside click, but close animation has started do nothing + } else if (!(info.closeAnimationPlayer?.hasStarted())) { + this._hide(info.id, ev); + } + } + } + }; + + private addOutsideClickListener(info: OverlayInfo) { + if (info.settings.closeOnOutsideClick) { + if (info.settings.modal) { + fromEvent(info.elementRef.nativeElement.parentElement.parentElement, 'click') + .pipe(takeUntil(this.destroy$)) + .subscribe((e: Event) => this._hide(info.id, e)); + } else if ( + // if all overlays minus closing overlays equals one add the handler + this._overlayInfos.filter(x => x.settings.closeOnOutsideClick && !x.settings.modal).length - + this._overlayInfos.filter(x => x.settings.closeOnOutsideClick && !x.settings.modal && + x.closeAnimationPlayer?.hasStarted()).length === 1) { + + // click event is not fired on iOS. To make element "clickable" we are + // setting the cursor to pointer + if (this.platformUtil.isIOS && !this._cursorStyleIsSet) { + this._cursorOriginalValue = this._document.body.style.cursor; + this._document.body.style.cursor = 'pointer'; + this._cursorStyleIsSet = true; + } + this._document.addEventListener('click', this.documentClicked, true); + } + } + } + + private removeOutsideClickListener(info: OverlayInfo) { + if (info.settings.modal === false) { + let shouldRemoveClickEventListener = true; + this._overlayInfos.forEach(o => { + if (o.settings.modal === false && o.id !== info.id) { + shouldRemoveClickEventListener = false; + } + }); + if (shouldRemoveClickEventListener) { + if (this._cursorStyleIsSet) { + this._document.body.style.cursor = this._cursorOriginalValue; + this._cursorOriginalValue = ''; + this._cursorStyleIsSet = false; + } + this._document.removeEventListener('click', this.documentClicked, true); + } + } + } + + private addResizeHandler() { + const closingOverlaysCount = + this._overlayInfos + .filter(o => o.closeAnimationPlayer?.hasStarted()) + .length; + if (this._overlayInfos.length - closingOverlaysCount === 1) { + this._document.defaultView.addEventListener('resize', this.repositionAll); + } + } + + private removeResizeHandler() { + const closingOverlaysCount = + this._overlayInfos + .filter(o => o.closeAnimationPlayer?.hasStarted()) + .length; + if (this._overlayInfos.length - closingOverlaysCount === 1) { + this._document.defaultView.removeEventListener('resize', this.repositionAll); + } + } + + private addCloseOnEscapeListener(info: OverlayInfo) { + if (info.settings.closeOnEscape && !this._keyPressEventListener) { + this._keyPressEventListener = fromEvent(this._document, 'keydown').pipe( + filter((ev: KeyboardEvent) => ev.key === 'Escape' || ev.key === 'Esc') + ).subscribe((ev) => { + const visibleOverlays = this._overlayInfos.filter(o => o.visible); + if (visibleOverlays.length < 1) { + return; + } + const targetOverlayInfo = visibleOverlays[visibleOverlays.length - 1]; + if (targetOverlayInfo.visible && targetOverlayInfo.settings.closeOnEscape) { + this.hide(targetOverlayInfo.id, ev); + } + }); + } + } + + private removeCloseOnEscapeListener() { + if (this._keyPressEventListener) { + this._keyPressEventListener.unsubscribe(); + this._keyPressEventListener = null; + } + } + + private addModalClasses(info: OverlayInfo) { + if (info.settings.modal) { + const wrapperElement = info.elementRef.nativeElement.parentElement.parentElement; + wrapperElement.classList.remove('igx-overlay__wrapper'); + this.applyAnimationParams(wrapperElement, info.settings.positionStrategy.settings.openAnimation); + requestAnimationFrame(() => { + wrapperElement.classList.add('igx-overlay__wrapper--modal'); + }); + } + } + + private removeModalClasses(info: OverlayInfo) { + if (info.settings.modal) { + const wrapperElement = info.elementRef.nativeElement.parentElement.parentElement; + this.applyAnimationParams(wrapperElement, info.settings.positionStrategy.settings.closeAnimation); + wrapperElement.classList.remove('igx-overlay__wrapper--modal'); + wrapperElement.classList.add('igx-overlay__wrapper'); + } + } + + private buildAnimationPlayers(info: OverlayInfo) { + if (info.settings.positionStrategy.settings.openAnimation) { + info.openAnimationPlayer = this.animationService + .buildAnimation(info.settings.positionStrategy.settings.openAnimation, info.elementRef.nativeElement); + info.openAnimationPlayer.animationEnd + .pipe(takeUntil(this.destroy$)) + .subscribe(() => this.openAnimationDone(info)); + } + if (info.settings.positionStrategy.settings.closeAnimation) { + info.closeAnimationPlayer = this.animationService + .buildAnimation(info.settings.positionStrategy.settings.closeAnimation, info.elementRef.nativeElement); + info.closeAnimationPlayer.animationEnd + .pipe(takeUntil(this.destroy$)) + .subscribe(() => this.closeAnimationDone(info)); + } + } + + private openAnimationDone(info: OverlayInfo) { + if (!info.openAnimationDetaching) { + this.opened.emit({ id: info.id, componentRef: info.componentRef }); + } + if (info.openAnimationPlayer) { + info.openAnimationPlayer.reset(); + } + if (info.closeAnimationPlayer?.hasStarted()) { + info.closeAnimationPlayer.reset(); + } + } + + private closeAnimationDone(info: OverlayInfo) { + if (info.closeAnimationPlayer) { + info.closeAnimationPlayer.reset(); + } + if (info.openAnimationPlayer?.hasStarted()) { + info.openAnimationPlayer.reset(); + } + this.closeDone(info); + } + + private finishAnimations(info: OverlayInfo) { + // // TODO: should we emit here opened or closed events + if (info.openAnimationPlayer?.hasStarted()) { + info.openAnimationPlayer.finish(); + } + if (info.closeAnimationPlayer?.hasStarted()) { + info.closeAnimationPlayer.finish(); + } + } + + private getComponentSize(info: OverlayInfo) { + if (info.elementRef?.nativeElement instanceof Element) { + const styles = this._document.defaultView.getComputedStyle(info.elementRef.nativeElement); + const componentSize = styles.getPropertyValue('--component-size'); + const globalSize = styles.getPropertyValue('--ig-size'); + const size = componentSize || globalSize; + info.size = size; + } + } +} diff --git a/projects/igniteui-angular/core/src/services/overlay/position/IPositionStrategy.ts b/projects/igniteui-angular/core/src/services/overlay/position/IPositionStrategy.ts new file mode 100644 index 00000000000..8f1ef360412 --- /dev/null +++ b/projects/igniteui-angular/core/src/services/overlay/position/IPositionStrategy.ts @@ -0,0 +1,35 @@ +import { PositionSettings, Size, Point } from './../utilities'; + +/** + * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/overlay-position) + * Position strategies determine where to display the component in the provided IgxOverlayService. + */ +export interface IPositionStrategy { + /** + * PositionSettings to use when position the component in the overlay + */ + settings: PositionSettings; + + /* blazorSuppress */ + /** + * Position the element based on the PositionStrategy implementing this interface. + * + * @param contentElement The HTML element to be positioned + * @param size Size of the element + * @param document reference to the Document object + * @param initialCall should be true if this is the initial call to the method + * @param target attaching target for the component to show + * ```typescript + * settings.positionStrategy.position(content, size, document, true); + * ``` + */ + position(contentElement: HTMLElement, size?: Size, document?: Document, initialCall?: boolean, target?: Point | HTMLElement): void; + + /** + * Clone the strategy instance. + * ```typescript + * settings.positionStrategy.clone(); + * ``` + */ + clone(): IPositionStrategy; +} diff --git a/projects/igniteui-angular/core/src/services/overlay/position/README.md b/projects/igniteui-angular/core/src/services/overlay/position/README.md new file mode 100644 index 00000000000..1e4cb4d7367 --- /dev/null +++ b/projects/igniteui-angular/core/src/services/overlay/position/README.md @@ -0,0 +1,82 @@ +# Position strategies + +Position strategies determine where to display the component in the provided IgxOverlayService. There are three position strategies: +1) **Global** - Positions the element based on the directions passed in trough PositionSettings. These are Top/Middle/Bottom for verticalDirection and Left/Center/Right for horizontalDirection. Defaults to: + +| horizontalDirection | verticalDirection | +|:---------------------------|:-------------------------| +| HorizontalAlignment.Center | VerticalAlignment.Middle | + +2) **Container** - Positions the element inside the containing outlet based on the directions passed in trough PositionSettings. These are Top/Middle/Bottom for verticalDirection and Left/Center/Right for horizontalDirection. Defaults to: + +| horizontalDirection | verticalDirection | +|:---------------------------|:-------------------------| +| HorizontalAlignment.Center | VerticalAlignment.Middle | + + +3) **Connected** - Positions the element based on the directions and start point passed in trough PositionSettings. It is possible to either pass a start point or an HTMLElement as a positioning base. Defaults to: + +| target | horizontalDirection | verticalDirection | horizontalStartPoint | verticalStartPoint | +|:----------------|:--------------------------|:-------------------------|:-------------------------|:-------------------------| +| new Point(0, 0) | HorizontalAlignment.Right | VerticalAlignment.Bottom | HorizontalAlignment.Left | VerticalAlignment.Bottom | + +4) **Auto** - Positions the element as in **Connected** positioning strategy and re-positions the element in the view port (calculating a different start point) in case the element is partially getting out of view. Defaults to: + +| target | horizontalDirection | verticalDirection | horizontalStartPoint | verticalStartPoint | +|:----------------|:--------------------------|:-------------------------|:-------------------------|:-------------------------| +| new Point(0, 0) | HorizontalAlignment.Right | VerticalAlignment.Bottom | HorizontalAlignment.Left | VerticalAlignment.Bottom | + +5) **Elastic** - Positions the element as in **Connected** positioning strategy and resize the element to fit in the view port in case the element is partially getting out of view. Defaults to: + +| target | horizontalDirection | verticalDirection | horizontalStartPoint | verticalStartPoint | minSize | +|:----------------|:--------------------------|:-------------------------|:-------------------------|:-------------------------|-------------------------| +| new Point(0, 0) | HorizontalAlignment.Right | VerticalAlignment.Bottom | HorizontalAlignment.Left | VerticalAlignment.Bottom | { width: 0, height: 0 } | + +## Usage +Position an element based on an existing button as a target, so it's start point is the button's Bottom/Left corner. +```typescript +const positionSettings: PositionSettings = { + target: buttonElement.nativeElement, + horizontalDirection: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Bottom, + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Bottom, + minSize: { width: 100, height: 300 } +}; + +const strategy = new ConnectedPositioningStrategy(positionSettings); +strategy.position(contentWrapper, size); +``` + +## Getting Started + +### Dependencies + +Import the desired position strategy if needed like: + +```typescript +import {AutoPositionStrategy, GlobalPositionStrategy, ConnectedPositioningStrategy } from './position/global-position-strategy'; +``` + +## API + +##### Methods +| Position Strategy | Name | Description | +|:------------------|:-------------------------------------------------------|:----------------------------------------------------------------------------------| +| Global | `position(contentElement)` | Positions the element, based on the horizontal and vertical directions. | +| Container | `position(contentElement)` | Positions the element inside the containing outlet based on the directions passed in trough PositionSettings. | +| Connected | `position(contentElement, size{})` | Positions the element, based on the position strategy used and the size passed in.| +| Auto | `position(contentElement, size{}, document?)` | Positions the element, based on the position strategy used and the size passed in.| +| Elastic | `position(contentElement, size{}, document?, minSize?)`| Positions the element, based on the position strategy used and the size passed in.| + +###### PositionSettings +| Name | Type | Description | +| :----------------- | :-------------------------- | :---------- | +|target | Point | HTMLElement | Attaching target for the component to show | +|horizontalDirection | HorizontalAlignment | Direction in which the component should show | +|verticalDirection | VerticalAlignment | Direction in which the component should show | +|horizontalStartPoint| HorizontalAlignment | Target's starting point | +|verticalStartPoint | VerticalAlignment | Target's starting point | +|openAnimation | AnimationReferenceMetadata | Animation applied while overlay opens | +|closeAnimation | AnimationReferenceMetadata | Animation applied while overlay closes | +|minSize | Size | The size up to which element could be reduced | diff --git a/projects/igniteui-angular/core/src/services/overlay/position/auto-position-strategy.ts b/projects/igniteui-angular/core/src/services/overlay/position/auto-position-strategy.ts new file mode 100644 index 00000000000..9f7f6b05dae --- /dev/null +++ b/projects/igniteui-angular/core/src/services/overlay/position/auto-position-strategy.ts @@ -0,0 +1,209 @@ +import { AnimationReferenceMetadata } from '@angular/animations'; +import { ConnectedFit, HorizontalAlignment, VerticalAlignment } from './../utilities'; +import { BaseFitPositionStrategy } from './base-fit-position-strategy'; +import { AnimationUtil } from 'igniteui-angular/animations'; + +/** + * Positions the element as in **Connected** positioning strategy and re-positions the element in + * the view port (calculating a different start point) in case the element is partially getting out of view + */ +export class AutoPositionStrategy extends BaseFitPositionStrategy { + + /** + * Fits the element into viewport according to the position settings + * + * @param element element to fit in viewport + * @param connectedFit connectedFit object containing all necessary parameters + */ + protected fitInViewport(element: HTMLElement, connectedFit: ConnectedFit) { + const transformString: string[] = []; + if (connectedFit.fitHorizontal.back < 0 || connectedFit.fitHorizontal.forward < 0) { + if (this.canFlipHorizontal(connectedFit)) { + this.flipHorizontal(); + this.flipAnimation(FlipDirection.Horizontal); + } else { + const horizontalPush = this.horizontalPush(connectedFit); + transformString.push(`translateX(${horizontalPush}px)`); + } + } + + if (connectedFit.fitVertical.back < 0 || connectedFit.fitVertical.forward < 0) { + if (this.canFlipVertical(connectedFit)) { + this.flipVertical(); + this.flipAnimation(FlipDirection.Vertical); + } else { + const verticalPush = this.verticalPush(connectedFit); + transformString.push(`translateY(${verticalPush}px)`); + } + } + + element.style.transform = transformString.join(' ').trim(); + } + + /** + * Checks if element can be flipped without get off the viewport + * + * @param connectedFit connectedFit object containing all necessary parameters + * @returns true if element can be flipped and stain in viewport + */ + private canFlipHorizontal(connectedFit: ConnectedFit): boolean { + // HorizontalAlignment can be Left = -1; Center = -0.5 or Right = 0. + // To virtually flip direction and start point (both are HorizontalAlignment) we can do this: + // flippedAlignment = (-1) * (HorizontalAlignment + 1) + // this way: + // (-1) * (Left + 1) = 0 = Right + // (-1) * (Center + 1) = -0.5 = Center + // (-1) * (Right + 1) = -1 = Left + const flippedStartPoint = (-1) * (this.settings.horizontalStartPoint + 1); + const flippedDirection = (-1) * (this.settings.horizontalDirection + 1); + + const leftBorder = this.calculateLeft( + connectedFit.targetRect, connectedFit.contentElementRect, flippedStartPoint, flippedDirection, 0); + const rightBorder = leftBorder + connectedFit.contentElementRect.width; + return 0 < leftBorder && rightBorder < connectedFit.viewPortRect.width; + } + + /** + * Checks if element can be flipped without get off the viewport + * + * @param connectedFit connectedFit object containing all necessary parameters + * @returns true if element can be flipped and stain in viewport + */ + private canFlipVertical(connectedFit: ConnectedFit): boolean { + const flippedStartPoint = (-1) * (this.settings.verticalStartPoint + 1); + const flippedDirection = (-1) * (this.settings.verticalDirection + 1); + + const topBorder = this.calculateTop( + connectedFit.targetRect, connectedFit.contentElementRect, flippedStartPoint, flippedDirection, 0); + const bottomBorder = topBorder + connectedFit.contentElementRect.height; + return 0 < topBorder && bottomBorder < connectedFit.viewPortRect.height; + } + + /** + * Flips direction and start point of the position settings + */ + private flipHorizontal() { + switch (this.settings.horizontalDirection) { + case HorizontalAlignment.Left: + this.settings.horizontalDirection = HorizontalAlignment.Right; + break; + case HorizontalAlignment.Right: + this.settings.horizontalDirection = HorizontalAlignment.Left; + break; + } + switch (this.settings.horizontalStartPoint) { + case HorizontalAlignment.Left: + this.settings.horizontalStartPoint = HorizontalAlignment.Right; + break; + case HorizontalAlignment.Right: + this.settings.horizontalStartPoint = HorizontalAlignment.Left; + break; + } + } + + /** + * Flips direction and start point of the position settings + */ + private flipVertical() { + switch (this.settings.verticalDirection) { + case VerticalAlignment.Top: + this.settings.verticalDirection = VerticalAlignment.Bottom; + break; + case VerticalAlignment.Bottom: + this.settings.verticalDirection = VerticalAlignment.Top; + break; + } + switch (this.settings.verticalStartPoint) { + case VerticalAlignment.Top: + this.settings.verticalStartPoint = VerticalAlignment.Bottom; + break; + case VerticalAlignment.Bottom: + this.settings.verticalStartPoint = VerticalAlignment.Top; + break; + } + } + + /** + * Calculates necessary horizontal push according to provided connectedFit + * + * @param connectedFit connectedFit object containing all necessary parameters + * @returns amount of necessary translation which will push the element into viewport + */ + private horizontalPush(connectedFit: ConnectedFit): number { + const leftExtend = connectedFit.left; + const rightExtend = connectedFit.right - connectedFit.viewPortRect.width; + // if leftExtend < 0 overlay goes beyond left end of the screen. We should push it back with exactly + // as much as it is beyond the screen. + // if rightExtend > 0 overlay goes beyond right end of the screen. We should push it back with the + // extend but with amount not bigger than what left between left border of screen and left border of + // overlay, e.g. leftExtend + if (leftExtend < 0) { + return Math.abs(leftExtend); + } else if (rightExtend > 0) { + return - Math.min(rightExtend, leftExtend); + } else { + return 0; + } + } + + /** + * Calculates necessary vertical push according to provided connectedFit + * + * @param connectedFit connectedFit object containing all necessary parameters + * @returns amount of necessary translation which will push the element into viewport + */ + private verticalPush(connectedFit: ConnectedFit): number { + const topExtend = connectedFit.top; + const bottomExtend = connectedFit.bottom - connectedFit.viewPortRect.height; + if (topExtend < 0) { + return Math.abs(topExtend); + } else if (bottomExtend > 0) { + return - Math.min(bottomExtend, topExtend); + } else { + return 0; + } + } + + /** + * Changes open and close animation with reverse animation if one exists + * + * @param flipDirection direction for which to change the animations + */ + private flipAnimation(flipDirection: FlipDirection): void { + if (this.settings.openAnimation) { + this.settings.openAnimation = this.updateAnimation(this.settings.openAnimation, flipDirection); + } + if (this.settings.closeAnimation) { + this.settings.closeAnimation = this.updateAnimation(this.settings.closeAnimation, flipDirection); + } + } + + /** + * Tries to find the reverse animation according to provided direction + * + * @param animation animation to update + * @param direction required animation direction + * @returns reverse animation in given direction if one exists + */ + private updateAnimation(animation: AnimationReferenceMetadata, direction: FlipDirection): AnimationReferenceMetadata { + switch (direction) { + case FlipDirection.Horizontal: + if (AnimationUtil.instance().isHorizontalAnimation(animation)) { + return AnimationUtil.instance().reverseAnimationResolver(animation); + } + break; + case FlipDirection.Vertical: + if (AnimationUtil.instance().isVerticalAnimation(animation)) { + return AnimationUtil.instance().reverseAnimationResolver(animation); + } + break; + } + + return animation; + } +} + +enum FlipDirection { + Horizontal, + Vertical +} diff --git a/projects/igniteui-angular/core/src/services/overlay/position/base-fit-position-strategy.ts b/projects/igniteui-angular/core/src/services/overlay/position/base-fit-position-strategy.ts new file mode 100644 index 00000000000..f0f36d33bbf --- /dev/null +++ b/projects/igniteui-angular/core/src/services/overlay/position/base-fit-position-strategy.ts @@ -0,0 +1,127 @@ +import { ConnectedFit, HorizontalAlignment, Point, PositionSettings, Size, Util, VerticalAlignment } from '../utilities'; +import { ConnectedPositioningStrategy } from './connected-positioning-strategy'; + +export abstract class BaseFitPositionStrategy extends ConnectedPositioningStrategy { + protected _initialSize: Size; + protected _initialSettings: PositionSettings; + + /** + * Position the element based on the PositionStrategy implementing this interface. + * + * @param contentElement The HTML element to be positioned + * @param size Size of the element + * @param document reference to the Document object + * @param initialCall should be true if this is the initial call to the method + * @param target attaching target for the component to show + * ```typescript + * settings.positionStrategy.position(content, size, document, true); + * ``` + */ + public override position( + contentElement: HTMLElement, size: Size, document?: Document, initialCall?: boolean, target?: Point | HTMLElement): void { + const rects = super.calculateElementRectangles(contentElement, target); + const connectedFit: ConnectedFit = {}; + if (initialCall) { + connectedFit.targetRect = rects.targetRect; + connectedFit.contentElementRect = rects.elementRect; + this._initialSettings = this._initialSettings || Object.assign({}, this.settings); + this.settings = Object.assign({}, this._initialSettings); + connectedFit.viewPortRect = Util.getViewportRect(document); + this.updateViewPortFit(connectedFit); + if (this.shouldFitInViewPort(connectedFit)) { + this.fitInViewport(contentElement, connectedFit); + } + } + this.setStyle(contentElement, rects.targetRect, rects.elementRect, connectedFit); + } + + /** + * Checks if element can fit in viewport and updates provided connectedFit + * with the result + * + * @param connectedFit connectedFit to update + */ + protected updateViewPortFit(connectedFit: ConnectedFit) { + const { horizontalOffset, verticalOffset } = super.getElementOffsets(connectedFit); + + connectedFit.left = this.calculateLeft( + connectedFit.targetRect, + connectedFit.contentElementRect, + this.settings.horizontalStartPoint, + this.settings.horizontalDirection, + horizontalOffset); + connectedFit.right = connectedFit.left + connectedFit.contentElementRect.width; + connectedFit.fitHorizontal = { + back: Math.round(connectedFit.left), + forward: Math.round(connectedFit.viewPortRect.width - connectedFit.right) + }; + + connectedFit.top = this.calculateTop( + connectedFit.targetRect, + connectedFit.contentElementRect, + this.settings.verticalStartPoint, + this.settings.verticalDirection, + verticalOffset); + connectedFit.bottom = connectedFit.top + connectedFit.contentElementRect.height; + connectedFit.fitVertical = { + back: Math.round(connectedFit.top), + forward: Math.round(connectedFit.viewPortRect.height - connectedFit.bottom) + }; + } + + /** + * Calculates the position of the left border of the element if it gets positioned + * with provided start point and direction + * + * @param targetRect Rectangle of the target where element is attached + * @param elementRect Rectangle of the element + * @param startPoint Start point of the target + * @param direction Direction in which to show the element + */ + protected calculateLeft( + targetRect: Partial, + elementRect: Partial, + startPoint: HorizontalAlignment, + direction: HorizontalAlignment, + offset?: number): number { + return targetRect.right + targetRect.width * startPoint + elementRect.width * direction + offset; + } + + /** + * Calculates the position of the top border of the element if it gets positioned + * with provided position settings related to the target + * + * @param targetRect Rectangle of the target where element is attached + * @param elementRect Rectangle of the element + * @param startPoint Start point of the target + * @param direction Direction in which to show the element + */ + protected calculateTop( + targetRect: Partial, + elementRect: Partial, + startPoint: VerticalAlignment, + direction: VerticalAlignment, + offset?: number): number { + return targetRect.bottom + targetRect.height * startPoint + elementRect.height * direction + offset; + } + + /** + * Returns whether the element should fit in viewport + * + * @param connectedFit connectedFit object containing all necessary parameters + */ + protected shouldFitInViewPort(connectedFit: ConnectedFit) { + return connectedFit.fitHorizontal.back < 0 || connectedFit.fitHorizontal.forward < 0 || + connectedFit.fitVertical.back < 0 || connectedFit.fitVertical.forward < 0; + } + + /** + * Fits the element into viewport according to the position settings + * + * @param element element to fit in viewport + * @param connectedFit connectedFit object containing all necessary parameters + */ + protected abstract fitInViewport( + element: HTMLElement, + connectedFit: ConnectedFit); +} diff --git a/projects/igniteui-angular/core/src/services/overlay/position/connected-positioning-strategy.ts b/projects/igniteui-angular/core/src/services/overlay/position/connected-positioning-strategy.ts new file mode 100644 index 00000000000..fa65bc94b56 --- /dev/null +++ b/projects/igniteui-angular/core/src/services/overlay/position/connected-positioning-strategy.ts @@ -0,0 +1,136 @@ +import { scaleInVerTop, scaleOutVerTop } from 'igniteui-angular/animations'; +import { ConnectedFit } from '../utilities'; +import { + HorizontalAlignment, + Point, + PositionSettings, + Size, + Util, + VerticalAlignment +} from './../utilities'; +import { IPositionStrategy } from './IPositionStrategy'; + +/** + * Positions the element based on the directions and start point passed in trough PositionSettings. + * It is possible to either pass a start point or an HTMLElement as a positioning base. + */ +export class ConnectedPositioningStrategy implements IPositionStrategy { + /** + * PositionSettings to use when position the component in the overlay + */ + public settings: PositionSettings; + + private _defaultSettings: PositionSettings = { + horizontalDirection: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Bottom, + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Bottom, + openAnimation: scaleInVerTop, + closeAnimation: scaleOutVerTop, + minSize: { width: 0, height: 0 } + }; + + constructor(settings?: PositionSettings) { + this.settings = Object.assign({}, this._defaultSettings, settings); + } + + /** + * Position the element based on the PositionStrategy implementing this interface. + * + * @param contentElement The HTML element to be positioned + * @param size Size of the element + * @param document reference to the Document object + * @param initialCall should be true if this is the initial call to the method + * @param target attaching target for the component to show + * ```typescript + * settings.positionStrategy.position(content, size, document, true); + * ``` + */ + public position(contentElement: HTMLElement, size: Size, document?: Document, initialCall?: boolean, target?: Point | HTMLElement): void { + const rects = this.calculateElementRectangles(contentElement, target); + this.setStyle(contentElement, rects.targetRect, rects.elementRect, {}); + } + + /** + * Creates clone of this position strategy + * @returns clone of this position strategy + */ + public clone(): IPositionStrategy { + return Util.cloneInstance(this); + } + + /** + * Obtains the DomRect objects for the required elements - target and element to position + * + * @returns target and element DomRect objects + */ + protected calculateElementRectangles(contentElement, target: Point | HTMLElement): + { targetRect: Partial; elementRect: Partial } { + return { + targetRect: Util.getTargetRect(target), + elementRect: contentElement.getBoundingClientRect() as DOMRect + }; + } + + /** + * Get element horizontal and vertical offsets by connectedFit + * or `this.settings` if connectedFit offset is not defined. + * + * @param connectedFit + * @returns horizontalOffset and verticalOffset + */ + protected getElementOffsets(connectedFit: ConnectedFit): { horizontalOffset: number; verticalOffset: number } { + return { + horizontalOffset: connectedFit.horizontalOffset ?? Util.getHorizontalOffset(this.settings), + verticalOffset: connectedFit.verticalOffset ?? Util.getVerticalOffset(this.settings) + } + } + + /** + * Sets element's style which effectively positions provided element according + * to provided position settings + * + * @param element Element to position + * @param targetRect Bounding rectangle of strategy target + * @param elementRect Bounding rectangle of the element + */ + protected setStyle(element: HTMLElement, targetRect: Partial, elementRect: Partial, connectedFit: ConnectedFit) { + const { horizontalOffset, verticalOffset } = this.getElementOffsets(connectedFit); + + const startPoint: Point = { + x: targetRect.right + targetRect.width * this.settings.horizontalStartPoint + horizontalOffset, + y: targetRect.bottom + targetRect.height * this.settings.verticalStartPoint + verticalOffset + }; + const wrapperRect: ClientRect = element.parentElement.getBoundingClientRect(); + + // clean up styles - if auto position strategy is chosen we may pass here several times + element.style.right = ''; + element.style.left = ''; + element.style.bottom = ''; + element.style.top = ''; + + switch (this.settings.horizontalDirection) { + case HorizontalAlignment.Left: + element.style.right = `${Math.round(wrapperRect.right - startPoint.x)}px`; + break; + case HorizontalAlignment.Center: + element.style.left = `${Math.round(startPoint.x - wrapperRect.left - elementRect.width / 2)}px`; + break; + case HorizontalAlignment.Right: + element.style.left = `${Math.round(startPoint.x - wrapperRect.left)}px`; + break; + } + + switch (this.settings.verticalDirection) { + case VerticalAlignment.Top: + element.style.bottom = `${Math.round(wrapperRect.bottom - startPoint.y)}px`; + break; + case VerticalAlignment.Middle: + element.style.top = `${Math.round(startPoint.y - wrapperRect.top - elementRect.height / 2)}px`; + break; + case VerticalAlignment.Bottom: + element.style.top = `${Math.round(startPoint.y - wrapperRect.top)}px`; + break; + } + } +} diff --git a/projects/igniteui-angular/core/src/services/overlay/position/container-position-strategy.ts b/projects/igniteui-angular/core/src/services/overlay/position/container-position-strategy.ts new file mode 100644 index 00000000000..d802b7a6a3a --- /dev/null +++ b/projects/igniteui-angular/core/src/services/overlay/position/container-position-strategy.ts @@ -0,0 +1,22 @@ +import { PositionSettings } from '../utilities'; +import { GlobalPositionStrategy } from './global-position-strategy'; + +/** + * Positions the element inside the containing outlet based on the directions passed in trough PositionSettings. + * These are Top/Middle/Bottom for verticalDirection and Left/Center/Right for horizontalDirection + */ +export class ContainerPositionStrategy extends GlobalPositionStrategy { + constructor(settings?: PositionSettings) { + super(settings); + } + + /** + * Position the element based on the PositionStrategy implementing this interface. + */ + public override position(contentElement: HTMLElement): void { + contentElement.classList.add('igx-overlay__content--relative'); + contentElement.parentElement.classList.add('igx-overlay__wrapper--flex-container'); + this.setPosition(contentElement); + } +} + diff --git a/projects/igniteui-angular/core/src/services/overlay/position/elastic-position-strategy.ts b/projects/igniteui-angular/core/src/services/overlay/position/elastic-position-strategy.ts new file mode 100644 index 00000000000..12b23619297 --- /dev/null +++ b/projects/igniteui-angular/core/src/services/overlay/position/elastic-position-strategy.ts @@ -0,0 +1,61 @@ +import { ConnectedFit, HorizontalAlignment, VerticalAlignment } from '../utilities'; +import { BaseFitPositionStrategy } from './base-fit-position-strategy'; + +/** + * Positions the element as in **Connected** positioning strategy and resize the element + * to fit in the view port in case the element is partially getting out of view + */ +export class ElasticPositionStrategy extends BaseFitPositionStrategy { + /** + * Fits the element into viewport according to the position settings + * + * @param element element to fit in viewport + * @param connectedFit connectedFit object containing all necessary parameters + */ + protected fitInViewport(element: HTMLElement, connectedFit: ConnectedFit) { + element.classList.add('igx-overlay__content--elastic'); + const transformString: string[] = []; + if (connectedFit.fitHorizontal.back < 0 || connectedFit.fitHorizontal.forward < 0) { + const maxReduction = Math.max(0, connectedFit.contentElementRect.width - this.settings.minSize.width); + const leftExtend = Math.max(0, -connectedFit.fitHorizontal.back); + const rightExtend = Math.max(0, -connectedFit.fitHorizontal.forward); + const reduction = Math.min(maxReduction, leftExtend + rightExtend); + element.style.width = `${connectedFit.contentElementRect.width - reduction}px`; + + // if direction is center and element goes off the screen in left direction we should push the + // element to the right. Prevents left still going out of view when normally positioned + if (this.settings.horizontalDirection === HorizontalAlignment.Center) { + // the amount of translation depends on whether element goes off the screen to the left, + // to the right or in both directions, as well as how much it goes of the screen and finally + // on the minSize. The translation should be proportional between left and right extend + // taken from the reduction + const translation = leftExtend * reduction / (leftExtend + rightExtend); + if (translation > 0) { + transformString.push(`translateX(${translation}px)`); + } + } + } + + if (connectedFit.fitVertical.back < 0 || connectedFit.fitVertical.forward < 0) { + const maxReduction = Math.max(0, connectedFit.contentElementRect.height - this.settings.minSize.height); + const topExtend = Math.max(0, -connectedFit.fitVertical.back); + const bottomExtend = Math.max(0, -connectedFit.fitVertical.forward); + const reduction = Math.min(maxReduction, topExtend + bottomExtend); + element.style.height = `${connectedFit.contentElementRect.height - reduction}px`; + + // if direction is middle and element goes off the screen in top direction we should push the + // element to the bottom. Prevents top still going out of view when normally positioned + if (this.settings.verticalDirection === VerticalAlignment.Middle) { + // the amount of translation depends on whether element goes off the screen to the top, + // to the bottom or in both directions, as well as how much it goes of the screen and finally + // on the minSize. The translation should be proportional between top and bottom extend + // taken from the reduction + const translation = topExtend * reduction / (topExtend + bottomExtend); + if (translation > 0) { + transformString.push(`translateY(${translation}px)`); + } + } + } + element.style.transform = transformString.join(' ').trim(); + } +} diff --git a/projects/igniteui-angular/core/src/services/overlay/position/global-position-strategy.ts b/projects/igniteui-angular/core/src/services/overlay/position/global-position-strategy.ts new file mode 100644 index 00000000000..21b55bef665 --- /dev/null +++ b/projects/igniteui-angular/core/src/services/overlay/position/global-position-strategy.ts @@ -0,0 +1,86 @@ +import { fadeIn, fadeOut } from 'igniteui-angular/animations'; +import { HorizontalAlignment, PositionSettings, Util, VerticalAlignment } from './../utilities'; +import { IPositionStrategy } from './IPositionStrategy'; + +/** + * Positions the element based on the directions passed in trough PositionSettings. + * These are Top/Middle/Bottom for verticalDirection and Left/Center/Right for horizontalDirection + */ +export class GlobalPositionStrategy implements IPositionStrategy { + /** + * PositionSettings to use when position the component in the overlay + */ + public settings: PositionSettings; + + protected _defaultSettings: PositionSettings = { + horizontalDirection: HorizontalAlignment.Center, + verticalDirection: VerticalAlignment.Middle, + horizontalStartPoint: HorizontalAlignment.Center, + verticalStartPoint: VerticalAlignment.Middle, + openAnimation: fadeIn, + closeAnimation: fadeOut, + minSize: { width: 0, height: 0 } + }; + + constructor(settings?: PositionSettings) { + this.settings = Object.assign({}, this._defaultSettings, settings); + } + + /** + * Position the element based on the PositionStrategy implementing this interface. + * + * @param contentElement The HTML element to be positioned + * @param size Size of the element + * @param document reference to the Document object + * @param initialCall should be true if this is the initial call to the method + * @param target attaching target for the component to show + * ```typescript + * settings.positionStrategy.position(content, size, document, true); + * ``` + */ + public position(contentElement: HTMLElement): void { + contentElement.classList.add('igx-overlay__content--relative'); + contentElement.parentElement.classList.add('igx-overlay__wrapper--flex'); + this.setPosition(contentElement); + } + + /** + * Clone the strategy instance. + * ```typescript + * settings.positionStrategy.clone(); + * ``` + */ + public clone(): IPositionStrategy { + return Util.cloneInstance(this); + } + + protected setPosition(contentElement: HTMLElement) { + switch (this.settings.horizontalDirection) { + case HorizontalAlignment.Left: + contentElement.parentElement.style.justifyContent = 'flex-start'; + break; + case HorizontalAlignment.Center: + contentElement.parentElement.style.justifyContent = 'center'; + break; + case HorizontalAlignment.Right: + contentElement.parentElement.style.justifyContent = 'flex-end'; + break; + default: + break; + } + + switch (this.settings.verticalDirection) { + case VerticalAlignment.Top: + contentElement.parentElement.style.alignItems = 'flex-start'; + break; + case VerticalAlignment.Middle: + contentElement.parentElement.style.alignItems = 'center'; + break; + case VerticalAlignment.Bottom: + contentElement.parentElement.style.alignItems = 'flex-end'; + break; + default: + break; + } + } +} diff --git a/projects/igniteui-angular/core/src/services/overlay/position/index.ts b/projects/igniteui-angular/core/src/services/overlay/position/index.ts new file mode 100644 index 00000000000..adcab45e031 --- /dev/null +++ b/projects/igniteui-angular/core/src/services/overlay/position/index.ts @@ -0,0 +1,8 @@ +// Export position strategies +export * from './IPositionStrategy'; +export * from './base-fit-position-strategy'; +export * from './global-position-strategy'; +export * from './container-position-strategy'; +export * from './connected-positioning-strategy'; +export * from './auto-position-strategy'; +export * from './elastic-position-strategy'; diff --git a/projects/igniteui-angular/core/src/services/overlay/scroll/IScrollStrategy.ts b/projects/igniteui-angular/core/src/services/overlay/scroll/IScrollStrategy.ts new file mode 100644 index 00000000000..36392d53701 --- /dev/null +++ b/projects/igniteui-angular/core/src/services/overlay/scroll/IScrollStrategy.ts @@ -0,0 +1,35 @@ +import { IgxOverlayService } from '../overlay'; +/** + * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/overlay-scroll). + * Scroll strategies determines how the scrolling will be handled in the provided IgxOverlayService. + */ +export interface IScrollStrategy { + /* blazorSuppress */ + /** + * Initializes the strategy. Should be called once + * + * @param document reference to Document object. + * @param overlayService IgxOverlay service to use in this strategy. + * @param id Unique id for this strategy. + * ```typescript + * settings.scrollStrategy.initialize(document, overlay, id); + * ``` + */ + initialize(document: Document, overlayService: IgxOverlayService, id: string); + + /** + * Attaches the strategy + * ```typescript + * settings.scrollStrategy.attach(); + * ``` + */ + attach(): void; + + /** + * Detaches the strategy + * ```typescript + * settings.scrollStrategy.detach(); + * ``` + */ + detach(): void; +} diff --git a/projects/igniteui-angular/core/src/services/overlay/scroll/NoOpScrollStrategy.ts b/projects/igniteui-angular/core/src/services/overlay/scroll/NoOpScrollStrategy.ts new file mode 100644 index 00000000000..4bc8d91a32d --- /dev/null +++ b/projects/igniteui-angular/core/src/services/overlay/scroll/NoOpScrollStrategy.ts @@ -0,0 +1,30 @@ +import { ScrollStrategy } from './scroll-strategy'; + +/** + * Empty scroll strategy. Does nothing. + */ +export class NoOpScrollStrategy extends ScrollStrategy { + constructor() { + super(); + } + /** + * Initializes the strategy. Should be called once + */ + public initialize() { } + + /** + * Detaches the strategy + * ```typescript + * settings.scrollStrategy.detach(); + * ``` + */ + public attach(): void { } + + /** + * Detaches the strategy + * ```typescript + * settings.scrollStrategy.detach(); + * ``` + */ + public detach(): void { } +} diff --git a/projects/igniteui-angular/core/src/services/overlay/scroll/README.md b/projects/igniteui-angular/core/src/services/overlay/scroll/README.md new file mode 100644 index 00000000000..43693fccd64 --- /dev/null +++ b/projects/igniteui-angular/core/src/services/overlay/scroll/README.md @@ -0,0 +1,39 @@ +# Scroll strategies + +Scroll strategies determines how the scrolling will be handled in the provided IgxOverlayService. A walk through of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/overlay-scroll). + +There are four scroll strategies: +1) **NoOperation** - does nothing. +2) **Block** - the component do not scroll with the window. The event is canceled. No scrolling happens. +3) **Close** - uses a tolerance and closes an expanded component upon scrolling if the tolerance is exceeded. +4) **Absolute** - scrolls everything. + +## Usage + +```typescript +this.scrollStrategy.initialize(document, overlayService, id); +this.scrollStrategy.attach(); +this.scrollStrategy.detach(); +``` + +## Getting Started + +### Dependencies + +To use the any of the scroll strategies import it like this: + +```typescript +import { NoOpScrollStrategy } from "./scroll/NoOpScrollStrategy"; +``` + +## API + +##### Methods + +###### IScrollStrategy + +| Name | Description | Parameters | +|-----------------|---------------------------------------------------------------------------------|------------| +|initialize | Initialize the strategy. Should be called once |document, overlayService, id| +|attach | Attaches the strategy |- | +|detach | Detaches the strategy |- | diff --git a/projects/igniteui-angular/core/src/services/overlay/scroll/absolute-scroll-strategy.ts b/projects/igniteui-angular/core/src/services/overlay/scroll/absolute-scroll-strategy.ts new file mode 100644 index 00000000000..d4d85d57306 --- /dev/null +++ b/projects/igniteui-angular/core/src/services/overlay/scroll/absolute-scroll-strategy.ts @@ -0,0 +1,92 @@ +import { NgZone } from '@angular/core'; +import { IgxOverlayService } from '../overlay'; +import { ScrollStrategy } from './scroll-strategy'; + +/** + * On scroll reposition the overlay content. + */ +export class AbsoluteScrollStrategy extends ScrollStrategy { + private _initialized = false; + private _document: Document; + private _overlayService: IgxOverlayService; + private _id: string; + private _scrollContainer: HTMLElement; + private _zone: NgZone; + + constructor(scrollContainer?: HTMLElement) { + super(); + this._scrollContainer = scrollContainer; + } + + /** + * Initializes the strategy. Should be called once + * + * @param document reference to Document object. + * @param overlayService IgxOverlay service to use in this strategy. + * @param id Unique id for this strategy. + * ```typescript + * settings.scrollStrategy.initialize(document, overlay, id); + * ``` + */ + public initialize(document: Document, overlayService: IgxOverlayService, id: string) { + if (this._initialized) { + return; + } + this._overlayService = overlayService; + this._id = id; + this._document = document; + this._zone = overlayService.getOverlayById(id).ngZone; + this._initialized = true; + } + + /** + * Attaches the strategy + * ```typescript + * settings.scrollStrategy.attach(); + * ``` + */ + public attach(): void { + if (this._zone) { + this._zone.runOutsideAngular(() => { + this.addScrollEventListener(); + }); + } else { + this.addScrollEventListener(); + } + } + + /** + * Detaches the strategy + * ```typescript + * settings.scrollStrategy.detach(); + * ``` + */ + public detach(): void { + if (this._scrollContainer) { + this._scrollContainer.removeEventListener('scroll', this.onScroll, true); + } else { + // Tired of this thing throwing every other time. Fix it ffs! + this._document?.removeEventListener('scroll', this.onScroll, true); + } + + this._initialized = false; + } + + private addScrollEventListener() { + if (this._scrollContainer) { + this._scrollContainer.addEventListener('scroll', this.onScroll, true); + } else { + this._document.addEventListener('scroll', this.onScroll, true); + } + } + + private onScroll = (e: Event) => { + const overlayInfo = this._overlayService.getOverlayById(this._id); + if (!overlayInfo) { + return; + } + if (!overlayInfo.elementRef.nativeElement.contains(e.target)) { + this._overlayService.reposition(this._id); + } + }; +} diff --git a/projects/igniteui-angular/core/src/services/overlay/scroll/block-scroll-strategy.ts b/projects/igniteui-angular/core/src/services/overlay/scroll/block-scroll-strategy.ts new file mode 100644 index 00000000000..1412795bceb --- /dev/null +++ b/projects/igniteui-angular/core/src/services/overlay/scroll/block-scroll-strategy.ts @@ -0,0 +1,65 @@ +import { ScrollStrategy } from './scroll-strategy'; + +/** + * Prevents scrolling while the overlay content is shown. + */ +export class BlockScrollStrategy extends ScrollStrategy { + private _initialized = false; + private _document: Document; + private _initialScrollTop: number; + private _initialScrollLeft: number; + private _sourceElement: Element; + + constructor() { + super(); + } + + /** + * Initializes the strategy. Should be called once + * + */ + public initialize(document: Document) { + if (this._initialized) { + return; + } + + this._document = document; + this._initialized = true; + } + + /** + * Attaches the strategy + * ```typescript + * settings.scrollStrategy.attach(); + * ``` + */ + public attach(): void { + this._document.addEventListener('scroll', this.onScroll, true); + } + + /** + * Detaches the strategy + * ```typescript + * settings.scrollStrategy.detach(); + * ``` + */ + public detach(): void { + this._document.removeEventListener('scroll', this.onScroll, true); + this._sourceElement = null; + this._initialScrollTop = 0; + this._initialScrollLeft = 0; + this._initialized = false; + } + + private onScroll = (ev: Event) => { + ev.preventDefault(); + if (!this._sourceElement || this._sourceElement !== ev.target) { + this._sourceElement = ev.target as Element; + this._initialScrollTop = this._sourceElement.scrollTop; + this._initialScrollLeft = this._sourceElement.scrollLeft; + } + + this._sourceElement.scrollTop = this._initialScrollTop; + this._sourceElement.scrollLeft = this._initialScrollLeft; + }; +} diff --git a/projects/igniteui-angular/core/src/services/overlay/scroll/close-scroll-strategy.ts b/projects/igniteui-angular/core/src/services/overlay/scroll/close-scroll-strategy.ts new file mode 100644 index 00000000000..f7dff177fee --- /dev/null +++ b/projects/igniteui-angular/core/src/services/overlay/scroll/close-scroll-strategy.ts @@ -0,0 +1,94 @@ +import { IgxOverlayService } from '../overlay'; +import { OverlayInfo } from '../utilities'; +import { ScrollStrategy } from './scroll-strategy'; + +/** + * Uses a tolerance and closes the shown component upon scrolling if the tolerance is exceeded + */ +export class CloseScrollStrategy extends ScrollStrategy { + private _document: Document; + private _overlayService: IgxOverlayService; + private _id: string; + private initialScrollTop: number; + private initialScrollLeft: number; + private _threshold: number; + private _initialized = false; + private _sourceElement: Element; + private _scrollContainer: HTMLElement; + private _overlayInfo: OverlayInfo; + + constructor(scrollContainer?: HTMLElement) { + super(); + this._scrollContainer = scrollContainer; + this._threshold = 10; + } + + /** + * Initializes the strategy. Should be called once + * + * @param document reference to Document object. + * @param overlayService IgxOverlay service to use in this strategy. + * @param id Unique id for this strategy. + * ```typescript + * settings.scrollStrategy.initialize(document, overlay, id); + * ``` + */ + public initialize(document: Document, overlayService: IgxOverlayService, id: string) { + if (this._initialized) { + return; + } + this._overlayService = overlayService; + this._id = id; + this._document = document; + this._initialized = true; + this._overlayInfo = overlayService.getOverlayById(id); + } + + /** + * Attaches the strategy + * ```typescript + * settings.scrollStrategy.attach(); + * ``` + */ + public attach(): void { + if (this._scrollContainer) { + this._scrollContainer.addEventListener('scroll', this.onScroll); + this._sourceElement = this._scrollContainer; + } else { + this._document.addEventListener('scroll', this.onScroll, true); + } + } + + /** + * Detaches the strategy + * ```typescript + * settings.scrollStrategy.detach(); + * ``` + */ + public detach(): void { + // TODO: check why event listener removes only on first call and remains on each next!!! + if (this._scrollContainer) { + this._scrollContainer.removeEventListener('scroll', this.onScroll); + } else { + this._document.removeEventListener('scroll', this.onScroll, true); + } + this._sourceElement = null; + this._initialized = false; + } + + private onScroll = (ev: Event) => { + if (!this._sourceElement) { + this._sourceElement = ev.target as any; + this.initialScrollTop = this._sourceElement.scrollTop; + this.initialScrollLeft = this._sourceElement.scrollLeft; + } + + if (this._overlayInfo.elementRef.nativeElement.contains(this._sourceElement)) { + return; + } + if (Math.abs(this._sourceElement.scrollTop - this.initialScrollTop) > this._threshold || + Math.abs(this._sourceElement.scrollLeft - this.initialScrollLeft) > this._threshold) { + this._overlayService.hide(this._id); + } + }; +} diff --git a/projects/igniteui-angular/core/src/services/overlay/scroll/index.ts b/projects/igniteui-angular/core/src/services/overlay/scroll/index.ts new file mode 100644 index 00000000000..77d2b5cab8f --- /dev/null +++ b/projects/igniteui-angular/core/src/services/overlay/scroll/index.ts @@ -0,0 +1,9 @@ +// Export scroll strategies +export * from './scroll-strategy'; +export * from './IScrollStrategy'; +export * from './absolute-scroll-strategy'; +export * from './block-scroll-strategy'; +export * from './close-scroll-strategy'; +export * from './NoOpScrollStrategy'; +export * from './close-scroll-strategy'; + diff --git a/projects/igniteui-angular/core/src/services/overlay/scroll/scroll-strategy.ts b/projects/igniteui-angular/core/src/services/overlay/scroll/scroll-strategy.ts new file mode 100644 index 00000000000..946afa27b83 --- /dev/null +++ b/projects/igniteui-angular/core/src/services/overlay/scroll/scroll-strategy.ts @@ -0,0 +1,32 @@ +import { IScrollStrategy } from './IScrollStrategy'; +import { IgxOverlayService } from '../overlay'; + +export abstract class ScrollStrategy implements IScrollStrategy { + /** + * Initializes the strategy. Should be called once + * + * @param document reference to Document object. + * @param overlayService IgxOverlay service to use in this strategy. + * @param id Unique id for this strategy. + * ```typescript + * settings.scrollStrategy.initialize(document, overlay, id); + * ``` + */ + public abstract initialize(document: Document, overlayService: IgxOverlayService, id: string); + + /** + * Attaches the strategy + * ```typescript + * settings.scrollStrategy.attach(); + * ``` + */ + public abstract attach(): void; + + /** + * Detaches the strategy + * ```typescript + * settings.scrollStrategy.detach(); + * ``` + */ + public abstract detach(): void; +} diff --git a/projects/igniteui-angular/core/src/services/overlay/utilities.ts b/projects/igniteui-angular/core/src/services/overlay/utilities.ts new file mode 100644 index 00000000000..08590065596 --- /dev/null +++ b/projects/igniteui-angular/core/src/services/overlay/utilities.ts @@ -0,0 +1,333 @@ +import { AnimationReferenceMetadata } from '@angular/animations'; +import { ComponentRef, Directive, ElementRef, inject, Injector, NgZone } from '@angular/core'; +import { CancelableBrowserEventArgs, CancelableEventArgs, cloneValue, IBaseEventArgs } from '../../core/utils'; +import { AnimationPlayer } from '../animation/animation'; +import { IPositionStrategy } from './position/IPositionStrategy'; +import { IScrollStrategy } from './scroll'; + +/** + * Mark an element as an igxOverlay outlet container. + * Directive instance is exported as `overlay-outlet` to be assigned to templates variables: + * ```html + *
+ * ``` + */ +@Directive({ + exportAs: 'overlay-outlet', + selector: '[igxOverlayOutlet]', + standalone: true +}) +export class IgxOverlayOutletDirective { + public element = inject>(ElementRef); + + /** @hidden */ + public get nativeElement() { + return this.element.nativeElement; + } +} + +/* blazorAlternateName: GridHorizontalAlignment */ +export enum HorizontalAlignment { + Left = -1, + Center = -0.5, + Right = 0 +} + +/* blazorAlternateName: GridVerticalAlignment */ +export enum VerticalAlignment { + Top = -1, + Middle = -0.5, + Bottom = 0 +} + +/** + * Defines the possible values of the overlays' position strategy. + */ +export enum RelativePositionStrategy { + Connected = 'connected', + Auto = 'auto', + Elastic = 'elastic' +} + +/** + * Defines the possible positions for the relative overlay settings presets. + */ +export enum RelativePosition { + Above = 'above', + Below = 'below', + Before = 'before', + After = 'after', + Default = 'default' +} + +/** + * Defines the possible positions for the absolute overlay settings presets. + */ +export enum AbsolutePosition { + Bottom = 'bottom', + Top = 'top', + Center = 'center' +} + +/** + * Determines whether to add or set the offset values. + */ +export enum OffsetMode { + Add, + Set +} + +// TODO: make this interface +export class Point { + constructor(public x: number, public y: number) { } +} + +/** @hidden */ +export interface OutOfViewPort { + /** Out of view port at Top or Left */ + back: number; + /** Out of view port at Bottom or Right */ + forward: number; +} + +export interface PositionSettings { + /** Direction in which the component should show */ + horizontalDirection?: HorizontalAlignment; + /** Direction in which the component should show */ + verticalDirection?: VerticalAlignment; + /** Target's starting point */ + horizontalStartPoint?: HorizontalAlignment; + /** Target's starting point */ + verticalStartPoint?: VerticalAlignment; + /* blazorSuppress */ + /** Animation applied while overlay opens */ + openAnimation?: AnimationReferenceMetadata; + /* blazorSuppress */ + /** Animation applied while overlay closes */ + closeAnimation?: AnimationReferenceMetadata; + /** The size up to which element may shrink when shown in elastic position strategy */ + minSize?: Size; + /** The offset of the element from the target in pixels */ + offset?: number; +} + +export interface OverlaySettings { + /** Attaching target for the component to show */ + target?: Point | HTMLElement; + /** Position strategy to use with these settings */ + positionStrategy?: IPositionStrategy; + /** Scroll strategy to use with these settings */ + scrollStrategy?: IScrollStrategy; + /** Set if the overlay should be in modal mode */ + modal?: boolean; + /** Set if the overlay should close on outside click */ + closeOnOutsideClick?: boolean; + /** Set if the overlay should close when `Esc` key is pressed */ + closeOnEscape?: boolean; + /* blazorSuppress */ + /** Set the outlet container to attach the overlay to */ + outlet?: IgxOverlayOutletDirective | ElementRef; + /** + * @hidden @internal + * Elements to be excluded for closeOnOutsideClick. + * Clicking on the elements in this collection will not close the overlay when closeOnOutsideClick = true. + */ + excludeFromOutsideClick?: HTMLElement[]; +} + +export interface OverlayEventArgs extends IBaseEventArgs { + /** Id of the overlay generated with `attach()` method */ + id: string; + /** Available when `Type` is provided to the `attach()` method and allows access to the created Component instance */ + componentRef?: ComponentRef; + /** Will provide the elementRef of the markup that will be displayed in the overlay */ + elementRef?: ElementRef; + /** Will provide the overlay settings which will be used when the component is attached */ + settings?: OverlaySettings; + /** Will provide the original keyboard event if closed from ESC or click */ + event?: Event; +} + +export interface OverlayCancelableEventArgs extends OverlayEventArgs, CancelableEventArgs { +} + +export interface OverlayClosingEventArgs extends OverlayEventArgs, CancelableBrowserEventArgs { +} + +export interface OverlayAnimationEventArgs extends IBaseEventArgs { + /** Id of the overlay generated with `attach()` method */ + id: string; + /** Animation player that will play the animation */ + animationPlayer: AnimationPlayer; + /** Type of animation to be played. It should be either 'open' or 'close' */ + animationType: 'open' | 'close'; +} + +export interface Size { + /** Gets or sets the horizontal component of Size */ + width: number; + + /** Gets or sets the vertical component of Size */ + height: number; +} + +/** @hidden */ +export interface OverlayInfo { + id?: string; + visible?: boolean; + detached?: boolean; + elementRef?: ElementRef; + componentRef?: ComponentRef; + settings?: OverlaySettings; + initialSize?: Size; + hook?: HTMLElement; + openAnimationPlayer?: AnimationPlayer; + // calling animation.destroy in detach fires animation.done. This should not happen + // this is why we should trace if animation ever started + openAnimationDetaching?: boolean; + closeAnimationPlayer?: AnimationPlayer; + // calling animation.destroy in detach fires animation.done. This should not happen + // this is why we should trace if animation ever started + closeAnimationDetaching?: boolean; + ngZone: NgZone; + transformX?: number; + transformY?: number; + event?: Event; + wrapperElement?: HTMLElement; + size?: string +} + +/** @hidden */ +export interface ConnectedFit { + contentElementRect?: Partial; + targetRect?: Partial; + viewPortRect?: Partial; + fitHorizontal?: OutOfViewPort; + fitVertical?: OutOfViewPort; + left?: number; + right?: number; + top?: number; + bottom?: number; + horizontalOffset?: number; + verticalOffset?: number; +} + +export interface OverlayCreateSettings extends OverlaySettings { + /** + * An `Injector` instance to add in the created component ref's injectors tree. + */ + injector?: Injector +} + +/** @hidden @internal */ +export class Util { + /** + * Calculates the rectangle of target for provided overlay settings. Defaults to 0,0,0,0,0,0 rectangle + * if no target is provided + * + * @param settings Overlay settings for which to calculate target rectangle + */ + public static getTargetRect(target?: Point | HTMLElement): Partial { + let targetRect: Partial = { + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0 + }; + if (target instanceof HTMLElement) { + targetRect = (target as HTMLElement).getBoundingClientRect(); + } else if (target instanceof Point) { + const targetPoint = target as Point; + targetRect = { + bottom: targetPoint.y, + height: 0, + left: targetPoint.x, + right: targetPoint.x, + top: targetPoint.y, + width: 0 + }; + } + return targetRect; + } + + public static getViewportRect(document: Document): Partial { + const width = document.documentElement.clientWidth; + const height = document.documentElement.clientHeight; + const scrollPosition = Util.getViewportScrollPosition(document); + + return { + top: scrollPosition.y, + left: scrollPosition.x, + right: scrollPosition.x + width, + bottom: scrollPosition.y + height, + width, + height, + }; + } + + public static getViewportScrollPosition(document: Document): Point { + const documentElement = document.documentElement; + const documentRect = documentElement.getBoundingClientRect(); + + const horizontalScrollPosition = + -documentRect.left || document.body.scrollLeft || window.scrollX || documentElement.scrollLeft || 0; + const verticalScrollPosition = -documentRect.top || document.body.scrollTop || window.scrollY || documentElement.scrollTop || 0; + + return new Point(horizontalScrollPosition, verticalScrollPosition); + } + + public static cloneInstance(object) { + const clonedObj = Object.assign(Object.create(Object.getPrototypeOf(object)), object); + clonedObj.settings = cloneValue(clonedObj.settings); + return clonedObj; + } + + /** + * Gets horizontal offset by position settings `offset`. + */ + public static getHorizontalOffset(settings: PositionSettings): number { + if (settings.offset == null) { + return 0; + } + + if ( + settings.horizontalDirection === HorizontalAlignment.Left && + settings.horizontalStartPoint === HorizontalAlignment.Left + ) { + return -settings.offset; + } else if ( + settings.horizontalDirection === HorizontalAlignment.Right && + settings.horizontalStartPoint === HorizontalAlignment.Right + ) { + return settings.offset; + } + + return 0; + } + + /** + * Gets vertical offset by position settings `offset`. + */ + public static getVerticalOffset(settings: PositionSettings): number { + if (settings.offset == null) { + return 0; + } + + if ( + settings.verticalDirection === VerticalAlignment.Top && + settings.verticalStartPoint === VerticalAlignment.Top + ) { + return -settings.offset; + } else if ( + settings.verticalDirection === VerticalAlignment.Bottom && + settings.verticalStartPoint === VerticalAlignment.Bottom + ) { + return settings.offset; + } + + return 0; + } +} + diff --git a/projects/igniteui-angular/core/src/services/public_api.ts b/projects/igniteui-angular/core/src/services/public_api.ts new file mode 100644 index 00000000000..69ae76e9358 --- /dev/null +++ b/projects/igniteui-angular/core/src/services/public_api.ts @@ -0,0 +1,21 @@ +// Export services +export * from './animation/angular-animation-player'; +export * from './animation/angular-animation-service'; +export * from './animation/animation'; +export { Direction as ɵDirection, DIR_DOCUMENT as ɵDIR_DOCUMENT, IgxDirectionality as ɵIgxDirectionality } from './direction/directionality'; +export * from './overlay/overlay'; +export * from './overlay/position'; +export * from './overlay/scroll'; +export { + AbsolutePosition, ConnectedFit, HorizontalAlignment, OffsetMode, OverlayAnimationEventArgs, OverlayCancelableEventArgs, OverlayClosingEventArgs, + OverlayCreateSettings, OverlayEventArgs, OverlaySettings, Point, PositionSettings, RelativePosition, RelativePositionStrategy, Size, VerticalAlignment, Util, + IgxOverlayOutletDirective +} from './overlay/utilities'; +export * from './transaction/base-transaction'; +export * from './transaction/hierarchical-transaction'; +export * from './transaction/igx-hierarchical-transaction'; +export * from './transaction/igx-transaction'; +export * from './transaction/transaction'; +export * from './transaction/transaction-factory.service'; +export * from './theme/theme.token'; + diff --git a/projects/igniteui-angular/core/src/services/theme/theme.token.ts b/projects/igniteui-angular/core/src/services/theme/theme.token.ts new file mode 100644 index 00000000000..7b93ddbe69d --- /dev/null +++ b/projects/igniteui-angular/core/src/services/theme/theme.token.ts @@ -0,0 +1,50 @@ +import { inject, InjectionToken, DOCUMENT } from "@angular/core"; +import { BehaviorSubject } from "rxjs"; + +export class ThemeToken { + private document = inject(DOCUMENT); + public subject: BehaviorSubject; + + constructor(private t?: IgxTheme) { + const globalTheme = globalThis.window + ?.getComputedStyle(this.document.body) + .getPropertyValue("--ig-theme") + .trim() || 'material' as IgxTheme; + + const _theme = t ?? globalTheme as IgxTheme; + this.subject = new BehaviorSubject(_theme); + } + + public onChange(callback: (theme: IgxTheme) => void) { + return this.subject.subscribe(callback); + } + + public set(theme: IgxTheme) { + this.subject.next(theme); + } + + public get theme() { + return this.subject.getValue(); + } + + public get preferToken() { + return !!this.t; + } +} + +export const THEME_TOKEN = new InjectionToken('ThemeToken', { + providedIn: 'root', + factory: () => new ThemeToken() +}); + +const Theme = { + Material: "material", + Fluent: "fluent", + Bootstrap: "bootstrap", + IndigoDesign: "indigo", +} as const; + +/** + * Determines the component theme. + */ +export type IgxTheme = (typeof Theme)[keyof typeof Theme]; diff --git a/projects/igniteui-angular/core/src/services/transaction/README.md b/projects/igniteui-angular/core/src/services/transaction/README.md new file mode 100644 index 00000000000..7b6b2b3e87d --- /dev/null +++ b/projects/igniteui-angular/core/src/services/transaction/README.md @@ -0,0 +1,55 @@ +# igx-transaction + +`TransactionService` allows the developers to plug a middleware between given component and its data source. While plugged in `TransactionService` should collect all the transactions performed in the component without send them to the data source. `TransactionService` should be able to update the data source and commit all the transactions when needed. +A walk through of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/transaction) + +## Usage + +```typescript +@Component({ + providers: [{ provide: IgxGridTransaction, useClass: IgxTransactionImplementation }] +}) +``` + +## Getting Started + +### Dependencies + +To use the `TransactionService` import the TransactionService: + +```typescript +import { IgxGridTransaction, IgxTransactionService } from "igniteui-angular"; +``` +and then inject it in the component's constructor: + +```typescript + constructor(@Inject(IgxGridTransaction) private _transactions: TransactionService) { }; +``` + +## API + +### TransactionService + + | Name | Description | Parameters | + |-----------------------|---------------------------------------------------------------|---------------------------| + |enabled | Returns whether transaction is enabled for this service | - | + |onStateUpdate | Event fired when transaction state has changed - add transaction, commit all transactions, undo and redo| - | + |canUndo | Returns if there are any transactions in the Undo stack | - | + |canRedo | Returns if there are any transactions in the Redo stack | - | + |add | Adds provided transaction with recordRef if any | transaction, recordRef? | + |getTransactionLog | Returns all recorded transactions in chronological order | id? | + |undo | Remove the last transaction if any | - | + |redo | Applies the last undone transaction if any | - | + |getAggregatedChanges | Returns aggregated changes from all transactions | mergeChanges | + |getState | Returns the state of the record with provided id | id, pending | + |getAggregatedValue | Returns value of the required id including all uncommitted changes| id, mergeChanges | + |commit | Applies all transactions over the provided data | data, id? | + |clear | Clears all transactions | id? | + |startPending | Starts pending transactions. All transactions passed after call to startPending will not be added to transaction log | - | + |endPending | Clears all pending transactions and aggregated pending state. If commit is set to true commits pending states as single transaction | commit | + +### HierarchicalTransactionService + + | Name | Description | Parameters | + |-----------------------|---------------------------------------------------------------|---------------------------| + |commit | Applies all transactions over the provided data | data, primaryKey, childDataKey, id? | diff --git a/projects/igniteui-angular/core/src/services/transaction/base-transaction.ts b/projects/igniteui-angular/core/src/services/transaction/base-transaction.ts new file mode 100644 index 00000000000..1bac4a0bfd0 --- /dev/null +++ b/projects/igniteui-angular/core/src/services/transaction/base-transaction.ts @@ -0,0 +1,252 @@ +import { TransactionService, Transaction, State, StateUpdateEvent, TransactionType } from './transaction'; +import { EventEmitter } from '@angular/core'; +import { isObject, mergeObjects } from '../../core/utils'; +import { DefaultDataCloneStrategy, IDataCloneStrategy } from '../../data-operations/data-clone-strategy'; + +export class IgxBaseTransactionService implements TransactionService { + /** + * Gets/Sets the data clone strategy used to clone data + */ + public get cloneStrategy(): IDataCloneStrategy { + return this._cloneStrategy; + } + + public set cloneStrategy(strategy: IDataCloneStrategy) { + if (strategy) { + this._cloneStrategy = strategy; + } + } + + /** + * @returns if there are any transactions in the Redo stack + */ + public get canRedo(): boolean { + return false; + } + + /** + * @returns if there are any transactions in the Undo stack + */ + public get canUndo(): boolean { + return false; + } + + /** + * Returns whether transaction is enabled for this service + */ + public get enabled(): boolean { + return this._isPending; + } + + /** + * Event fired when transaction state has changed - add transaction, commit all transactions, undo and redo + */ + public onStateUpdate = new EventEmitter(); + + protected _isPending = false; + protected _pendingTransactions: T[] = []; + protected _pendingStates: Map = new Map(); + private _cloneStrategy: IDataCloneStrategy = new DefaultDataCloneStrategy(); + + /** + * Adds provided transaction with recordRef if any + * + * @param transaction Transaction to be added + * @param recordRef Reference to the value of the record in the data source related to the changed item + */ + public add(transaction: T, recordRef?: any): void { + if (this._isPending) { + this.updateState(this._pendingStates, transaction, recordRef); + this._pendingTransactions.push(transaction); + } + } + + /** + * Returns all recorded transactions in chronological order + * + * @param id Optional record id to get transactions for + * @returns All transaction in the service or for the specified record + */ + public getTransactionLog(_id?: any): T[] { + return []; + } + + /** + * Remove the last transaction if any + */ + public undo(): void { } + + /** + * Applies the last undone transaction if any + */ + public redo(): void { } + + /** + * Returns aggregated changes from all transactions + * + * @param mergeChanges If set to true will merge each state's value over relate recordRef + * and will record resulting value in the related transaction + * @returns Collection of aggregated transactions for each changed record + */ + public getAggregatedChanges(mergeChanges: boolean): T[] { + const result: T[] = []; + this._pendingStates.forEach((state: S, key: any) => { + const value = mergeChanges ? this.getAggregatedValue(key, mergeChanges) : state.value; + result.push({ id: key, newValue: value, type: state.type } as T); + }); + return result; + } + + /** + * Returns the state of the record with provided id + * + * @param id The id of the record + * @param pending Should get pending state + * @returns State of the record if any + */ + public getState(id: any): S { + return this._pendingStates.get(id); + } + + /** + * Returns value of the required id including all uncommitted changes + * + * @param id The id of the record to return value for + * @param mergeChanges If set to true will merge state's value over relate recordRef + * and will return merged value + * @returns Value with changes or **null** + */ + public getAggregatedValue(id: any, mergeChanges: boolean): any { + const state = this._pendingStates.get(id); + if (!state) { + return null; + } + if (mergeChanges && state.recordRef) { + return this.updateValue(state); + } + return state.value; + } + + /** + * Applies all transactions over the provided data + * + * @param data Data source to update + * @param id Optional record id to commit transactions for + */ + public commit(_data: any[], _id?: any): void { } + + /** + * Clears all transactions + * + * @param id Optional record id to clear transactions for + */ + public clear(_id?: any): void { + this._pendingStates.clear(); + this._pendingTransactions = []; + } + + /** + * Starts pending transactions. All transactions passed after call to startPending + * will not be added to transaction log + */ + public startPending(): void { + this._isPending = true; + } + + /** + * Clears all pending transactions and aggregated pending state. If commit is set to true + * commits pending states as single transaction + * + * @param commit Should commit the pending states + */ + public endPending(_commit: boolean): void { + this._isPending = false; + this._pendingStates.clear(); + this._pendingTransactions = []; + } + + + /** + * Updates the provided states collection according to passed transaction and recordRef + * + * @param states States collection to apply the update to + * @param transaction Transaction to apply to the current state + * @param recordRef Reference to the value of the record in data source, if any, where transaction should be applied + */ + protected updateState(states: Map, transaction: T, recordRef?: any): void { + let state = states.get(transaction.id); + if (state) { + if (isObject(state.value)) { + mergeObjects(state.value, transaction.newValue); + } else { + state.value = transaction.newValue; + } + } else { + state = { value: this.cloneStrategy.clone(transaction.newValue), recordRef, type: transaction.type } as S; + states.set(transaction.id, state); + } + + this.cleanState(transaction.id, states); + } + + /** + * Updates the recordRef of the provided state with all the changes in the state. Accepts primitive and object value types + * + * @param state State to update value for + * @returns updated value including all the changes in provided state + */ + protected updateValue(state: S) { + return this.mergeValues(state.recordRef, state.value); + } + + /** + * Merges second values in first value and the result in empty object. If values are primitive type + * returns second value if exists, or first value. + * + * @param first Value to merge into + * @param second Value to merge + */ + protected mergeValues(first: U, second: U): U { + if (isObject(first) || isObject(second)) { + return mergeObjects(this.cloneStrategy.clone(first), second); + } else { + return second ? second : first; + } + } + + /** + * Compares the state with recordRef and clears all duplicated values. If any state ends as + * empty object removes it from states. + * + * @param state State to clean + */ + protected cleanState(id: any, states: Map): void { + const state = states.get(id); + // do nothing if + // there is no state, or + // there is no state value (e.g. DELETED transaction), or + // there is no recordRef (e.g. ADDED transaction) + if (state && state.value && state.recordRef) { + // if state's value is object compare each key with the ones in recordRef + // if values in any key are the same delete it from state's value + // if state's value is not object, simply compare with recordRef and remove + // the state if they are equal + if (isObject(state.recordRef)) { + for (const key of Object.keys(state.value)) { + if (JSON.stringify(state.recordRef[key]) === JSON.stringify(state.value[key])) { + delete state.value[key]; + } + } + + // if state's value is empty remove the state from the states, only if state is not DELETE type + if (state.type !== TransactionType.DELETE && Object.keys(state.value).length === 0) { + states.delete(id); + } + } else { + if (state.recordRef === state.value) { + states.delete(id); + } + } + } + } +} diff --git a/projects/igniteui-angular/core/src/services/transaction/hierarchical-transaction.ts b/projects/igniteui-angular/core/src/services/transaction/hierarchical-transaction.ts new file mode 100644 index 00000000000..0a9e0b05540 --- /dev/null +++ b/projects/igniteui-angular/core/src/services/transaction/hierarchical-transaction.ts @@ -0,0 +1,21 @@ +import { TransactionService, HierarchicalState, HierarchicalTransaction } from './transaction'; + +export interface HierarchicalTransactionService + extends TransactionService { + /** + * Applies all transactions over the provided data + * + * @param data Data source to update + * @param id Optional record id to commit transactions for + */ + commit(data: any[], id?: any): void; + /** + * Applies all transactions over the provided data + * + * @param data Data source to update + * @param primaryKey Primary key of the hierarchical data + * @param childDataKey Key of child data collection + * @param id Optional record id to commit transactions for + */ + commit(data: any[], primaryKey: any, childDataKey: any, id?: any): void; +} diff --git a/projects/igniteui-angular/core/src/services/transaction/igx-hierarchical-transaction.ts b/projects/igniteui-angular/core/src/services/transaction/igx-hierarchical-transaction.ts new file mode 100644 index 00000000000..f460699d1c0 --- /dev/null +++ b/projects/igniteui-angular/core/src/services/transaction/igx-hierarchical-transaction.ts @@ -0,0 +1,76 @@ +import { HierarchicalTransaction, HierarchicalState, TransactionType } from './transaction'; +import { IgxTransactionService } from './igx-transaction'; +import { DataUtil } from '../../data-operations/data-util'; +import { HierarchicalTransactionService } from './hierarchical-transaction'; + +/** @experimental @hidden */ +export class IgxHierarchicalTransactionService + extends IgxTransactionService implements HierarchicalTransactionService { + + public override getAggregatedChanges(mergeChanges: boolean): T[] { + const result: T[] = []; + this._states.forEach((state: S, key: any) => { + const value = mergeChanges ? this.mergeValues(state.recordRef, state.value) : this.cloneStrategy.clone(state.value); + this.clearArraysFromObject(value); + result.push({ id: key, path: state.path, newValue: value, type: state.type } as T); + }); + return result; + } + + public override commit(data: any[], primaryKeyOrId?: any, childDataKey?: any, id?: any): void { + if (childDataKey !== undefined) { + let transactions = this.getAggregatedChanges(true); + if (id !== undefined) { + transactions = transactions.filter(t => t.id === id); + } + DataUtil.mergeHierarchicalTransactions(data, transactions, childDataKey, primaryKeyOrId, this.cloneStrategy, true); + this.clear(id); + } else { + super.commit(data, primaryKeyOrId); + } + } + + protected override 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) { + 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; + } + } + }); + } + } + + // TODO: remove this method. Force cloning to strip child arrays when needed instead + private clearArraysFromObject(obj: any) { + if (obj) { + for (const prop of Object.keys(obj)) { + if (Array.isArray(obj[prop])) { + delete obj[prop]; + } + } + } + } +} + diff --git a/projects/igniteui-angular/core/src/services/transaction/igx-transaction.spec.ts b/projects/igniteui-angular/core/src/services/transaction/igx-transaction.spec.ts new file mode 100644 index 00000000000..1b2ac16f921 --- /dev/null +++ b/projects/igniteui-angular/core/src/services/transaction/igx-transaction.spec.ts @@ -0,0 +1,1055 @@ +import { IgxTransactionService } from './igx-transaction'; +import { Transaction, TransactionType, HierarchicalTransaction } from './transaction'; +import { SampleTestData } from '../../../../test-utils/sample-test-data.spec'; +import { IgxHierarchicalTransactionService } from './igx-hierarchical-transaction'; + +describe('IgxTransaction', () => { + describe('IgxTransaction UNIT tests', () => { + it('Should initialize transactions log properly', () => { + const trans = new IgxTransactionService(); + expect(trans).toBeDefined(); + expect(trans['_transactions']).toBeDefined(); + expect(trans['_transactions'].length).toEqual(0); + expect(trans['_redoStack']).toBeDefined(); + expect(trans['_redoStack'].length).toEqual(0); + expect(trans['_states']).toBeDefined(); + expect(trans['_states'].size).toEqual(0); + }); + + it('Should add transactions to the transactions log', () => { + const trans = new IgxTransactionService(); + const transactions: Transaction[] = [ + { id: '1', type: TransactionType.ADD, newValue: 1 }, + { id: '2', type: TransactionType.ADD, newValue: 2 }, + { id: '3', type: TransactionType.ADD, newValue: 3 }, + { id: '1', type: TransactionType.UPDATE, newValue: 4 }, + { id: '5', type: TransactionType.ADD, newValue: 5 }, + { id: '6', type: TransactionType.ADD, newValue: 6 }, + { id: '2', type: TransactionType.DELETE, newValue: 7 }, + { id: '8', type: TransactionType.ADD, newValue: 8 }, + { id: '9', type: TransactionType.ADD, newValue: 9 }, + { id: '8', type: TransactionType.UPDATE, newValue: 10 } + ]; + expect(trans['_transactions'].length).toEqual(0); + expect(trans['_redoStack'].length).toEqual(0); + let transactionIndex = 1; + transactions.forEach((transaction) => { + trans.add(transaction); + expect(trans.getTransactionLog(transaction.id).pop()).toEqual(transaction); + expect(trans['_transactions'].length).toEqual(transactionIndex); + expect(trans['_redoStack'].length).toEqual(0); + transactionIndex++; + }); + }); + + it('Should throw an error when trying to add duplicate transaction', () => { + const trans = new IgxTransactionService(); + const transactions: Transaction[] = [ + { id: '1', type: TransactionType.ADD, newValue: 1 }, + { id: '2', type: TransactionType.ADD, newValue: 2 }, + { id: '3', type: TransactionType.ADD, newValue: 3 }, + { id: '1', type: TransactionType.UPDATE, newValue: 4 }, + { id: '5', type: TransactionType.ADD, newValue: 5 }, + { id: '6', type: TransactionType.ADD, newValue: 6 }, + { id: '2', type: TransactionType.DELETE, newValue: 7 }, + { id: '8', type: TransactionType.ADD, newValue: 8 }, + { id: '9', type: TransactionType.ADD, newValue: 9 }, + { id: '8', type: TransactionType.UPDATE, newValue: 10 } + ]; + transactions.forEach(t => trans.add(t)); + + const transaction = { id: '6', type: TransactionType.ADD, newValue: 6 }; + expect(trans.getTransactionLog('6').pop()).toEqual(transaction); + const msg = `Cannot add this transaction. Transaction with id: ${transaction.id} has been already added.`; + expect(() => trans.add(transaction)).toThrowError(msg); + }); + + it('Should throw an error when trying to update transaction with no recordRef', () => { + const trans = new IgxTransactionService(); + const transactions: Transaction[] = [ + { id: '1', type: TransactionType.ADD, newValue: 1 }, + { id: '2', type: TransactionType.ADD, newValue: 2 }, + { id: '3', type: TransactionType.ADD, newValue: 3 }, + { id: '1', type: TransactionType.UPDATE, newValue: 4 }, + { id: '5', type: TransactionType.ADD, newValue: 5 }, + { id: '6', type: TransactionType.ADD, newValue: 6 }, + { id: '2', type: TransactionType.DELETE, newValue: 7 }, + { id: '8', type: TransactionType.ADD, newValue: 8 }, + { id: '9', type: TransactionType.ADD, newValue: 9 }, + { id: '8', type: TransactionType.UPDATE, newValue: 10 } + ]; + transactions.forEach(transaction => trans.add(transaction)); + + const updateTransaction = { id: '2', type: TransactionType.DELETE, newValue: 7 }; + expect(trans.getTransactionLog('2').pop()).toEqual(updateTransaction); + const msg = `Cannot add this transaction. This is first transaction of type ${updateTransaction.type} ` + + `for id ${updateTransaction.id}. For first transaction of this type recordRef is mandatory.`; + expect(() => { + updateTransaction.newValue = 107; + trans.add(updateTransaction); + }).toThrowError(msg); + }); + + it('Should throw an error when trying to delete an already deleted item', () => { + const trans = new IgxTransactionService(); + const recordRef = { key: 'Key1', value: 1 }; + const deleteTransaction: Transaction = { id: 'Key1', type: TransactionType.DELETE, newValue: null }; + trans.add(deleteTransaction, recordRef); + expect(trans.getTransactionLog('Key1').pop()).toEqual(deleteTransaction); + + const msg = `Cannot add this transaction. Transaction with id: ${deleteTransaction.id} has been already deleted.`; + expect(() => trans.add(deleteTransaction)).toThrowError(msg); + }); + + it('Should throw an error when trying to update an already deleted item', () => { + const trans = new IgxTransactionService(); + const recordRef = { key: 'Key1', value: 1 }; + const deleteTransaction: Transaction = { id: 'Key1', type: TransactionType.DELETE, newValue: null }; + trans.add(deleteTransaction, recordRef); + expect(trans.getTransactionLog('Key1').pop()).toEqual(deleteTransaction); + + const msg = `Cannot add this transaction. Transaction with id: ${deleteTransaction.id} has been already deleted.`; + expect(() => { + deleteTransaction.type = TransactionType.UPDATE; + deleteTransaction.newValue = 5; + trans.add(deleteTransaction); + }).toThrowError(msg); + }); + + it('Should get a transaction by transaction id', () => { + const trans = new IgxTransactionService(); + let transaction: Transaction = { id: '0', type: TransactionType.ADD, newValue: 0 }; + trans.add(transaction); + expect(trans.getTransactionLog('0').pop()).toEqual(transaction); + transaction = { id: '1', type: TransactionType.ADD, newValue: 1 }; + trans.add(transaction); + expect(trans.getTransactionLog('1').pop()).toEqual(transaction); + transaction = { id: '2', type: TransactionType.ADD, newValue: 2 }; + trans.add(transaction); + expect(trans.getTransactionLog('2').pop()).toEqual(transaction); + transaction = { id: '3', type: TransactionType.ADD, newValue: 3 }; + trans.add(transaction); + expect(trans.getTransactionLog('3').pop()).toEqual(transaction); + transaction = { id: '1', type: TransactionType.UPDATE, newValue: 4 }; + trans.add(transaction); + expect(trans.getTransactionLog('1').pop()).toEqual(transaction); + transaction = { id: '5', type: TransactionType.ADD, newValue: 5 }; + trans.add(transaction); + expect(trans.getTransactionLog('5').pop()).toEqual(transaction); + transaction = { id: '6', type: TransactionType.ADD, newValue: 6 }; + trans.add(transaction); + expect(trans.getTransactionLog('6').pop()).toEqual(transaction); + transaction = { id: '2', type: TransactionType.DELETE, newValue: 7 }; + trans.add(transaction); + expect(trans.getTransactionLog('2').pop()).toEqual(transaction); + transaction = { id: '8', type: TransactionType.ADD, newValue: 8 }; + trans.add(transaction); + expect(trans.getTransactionLog('8').pop()).toEqual(transaction); + transaction = { id: '9', type: TransactionType.ADD, newValue: 9 }; + trans.add(transaction); + expect(trans.getTransactionLog('9').pop()).toEqual(transaction); + transaction = { id: '8', type: TransactionType.UPDATE, newValue: 10 }; + trans.add(transaction); + expect(trans.getTransactionLog('8').pop()).toEqual(transaction); + + // Get nonexisting transaction + expect(trans.getTransactionLog('100').pop()).toEqual(undefined); + }); + + it('Should add ADD type transaction - all feasible paths, and correctly fires onStateUpdate', () => { + const trans = new IgxTransactionService(); + spyOn(trans.onStateUpdate, 'emit').and.callThrough(); + expect(trans).toBeDefined(); + + // ADD + const addTransaction: Transaction = { id: 0, type: TransactionType.ADD, newValue: 1 }; + trans.add(addTransaction); + expect(trans.getAggregatedValue(0, true)).toEqual(1); + expect(trans.getTransactionLog(0).pop()).toEqual(addTransaction); + expect(trans.getTransactionLog()).toEqual([addTransaction]); + expect(trans.getState(addTransaction.id)).toEqual({ + value: addTransaction.newValue, + recordRef: undefined, + type: addTransaction.type + }); + expect(trans.onStateUpdate.emit).toHaveBeenCalledTimes(1); + + trans.clear(); + expect(trans.getState(0)).toBeUndefined(); + expect(trans.getAggregatedValue(0, true)).toBeNull(); + expect(trans.getTransactionLog()).toEqual([]); + expect(trans.getAggregatedChanges(true)).toEqual([]); + expect(trans.onStateUpdate.emit).toHaveBeenCalledTimes(2); + + // ADD -> Undo + trans.add(addTransaction); + trans.undo(); + expect(trans.getTransactionLog()).toEqual([]); + expect(trans.getAggregatedChanges(true)).toEqual([]); + expect(trans.onStateUpdate.emit).toHaveBeenCalledTimes(4); + + trans.clear(); + expect(trans.onStateUpdate.emit).toHaveBeenCalledTimes(5); + + // ADD -> Undo -> Redo + trans.add(addTransaction); + trans.undo(); + trans.redo(); + expect(trans.getTransactionLog()).toEqual([addTransaction]); + expect(trans.getState(addTransaction.id)).toEqual({ + value: addTransaction.newValue, + recordRef: undefined, + type: addTransaction.type + }); + expect(trans.onStateUpdate.emit).toHaveBeenCalledTimes(8); + + trans.clear(); + expect(trans.onStateUpdate.emit).toHaveBeenCalledTimes(9); + + // ADD -> DELETE + trans.add(addTransaction); + const deleteTransaction: Transaction = { id: 0, type: TransactionType.DELETE, newValue: 1 }; + trans.add(deleteTransaction); + expect(trans.getTransactionLog()).toEqual([addTransaction, deleteTransaction]); + expect(trans.getAggregatedChanges(true)).toEqual([]); + expect(trans.onStateUpdate.emit).toHaveBeenCalledTimes(11); + + trans.clear(); + expect(trans.onStateUpdate.emit).toHaveBeenCalledTimes(12); + + // ADD -> DELETE -> Undo + trans.add(addTransaction); + trans.add(deleteTransaction); + trans.undo(); + expect(trans.getTransactionLog()).toEqual([addTransaction]); + expect(trans.getState(addTransaction.id)).toEqual({ + value: addTransaction.newValue, + recordRef: undefined, + type: addTransaction.type + }); + expect(trans.onStateUpdate.emit).toHaveBeenCalledTimes(15); + + trans.clear(); + expect(trans.onStateUpdate.emit).toHaveBeenCalledTimes(16); + + // ADD -> DELETE -> Undo -> Redo + trans.add(addTransaction); + trans.add(deleteTransaction); + trans.undo(); + trans.redo(); + expect(trans.getTransactionLog()).toEqual([addTransaction, deleteTransaction]); + expect(trans.getAggregatedChanges(true)).toEqual([]); + expect(trans.onStateUpdate.emit).toHaveBeenCalledTimes(20); + + trans.clear(); + expect(trans.onStateUpdate.emit).toHaveBeenCalledTimes(21); + + // ADD -> DELETE -> Undo -> Undo + trans.add(addTransaction); + trans.add(deleteTransaction); + trans.undo(); + trans.undo(); + expect(trans.getTransactionLog()).toEqual([]); + expect(trans.getAggregatedChanges(true)).toEqual([]); + expect(trans.onStateUpdate.emit).toHaveBeenCalledTimes(25); + + trans.clear(); + expect(trans.onStateUpdate.emit).toHaveBeenCalledTimes(26); + + // ADD -> UPDATE + trans.add(addTransaction); + const updateTransaction: Transaction = { id: 0, type: TransactionType.UPDATE, newValue: 2 }; + trans.add(updateTransaction); + expect(trans.getTransactionLog()).toEqual([addTransaction, updateTransaction]); + expect(trans.getState(addTransaction.id)).toEqual({ + value: updateTransaction.newValue, + recordRef: undefined, + type: addTransaction.type + }); + expect(trans.onStateUpdate.emit).toHaveBeenCalledTimes(28); + + trans.clear(); + expect(trans.onStateUpdate.emit).toHaveBeenCalledTimes(29); + + // ADD -> UPDATE -> Undo + trans.add(addTransaction); + trans.add(updateTransaction); + trans.undo(); + expect(trans.getTransactionLog()).toEqual([addTransaction]); + expect(trans.getState(addTransaction.id)).toEqual({ + value: addTransaction.newValue, + recordRef: undefined, + type: addTransaction.type + }); + expect(trans.onStateUpdate.emit).toHaveBeenCalledTimes(32); + + trans.clear(); + expect(trans.onStateUpdate.emit).toHaveBeenCalledTimes(33); + + // ADD -> UPDATE -> Undo -> Redo + trans.add(addTransaction); + trans.add(updateTransaction); + trans.undo(); + trans.redo(); + expect(trans.getTransactionLog()).toEqual([addTransaction, updateTransaction]); + expect(trans.getState(addTransaction.id)).toEqual({ + value: updateTransaction.newValue, + recordRef: undefined, + type: addTransaction.type + }); + expect(trans.onStateUpdate.emit).toHaveBeenCalledTimes(37); + + trans.clear(); + expect(trans.onStateUpdate.emit).toHaveBeenCalledTimes(38); + }); + + it('Should add DELETE type transaction - all feasible paths', () => { + const trans = new IgxTransactionService(); + expect(trans).toBeDefined(); + + // DELETE + const recordRef = { key: 'Key1', value: 1 }; + const deleteTransaction: Transaction = { id: 'Key1', type: TransactionType.DELETE, newValue: null }; + trans.add(deleteTransaction, recordRef); + expect(trans.getTransactionLog('Key1').pop()).toEqual(deleteTransaction); + expect(trans.getTransactionLog()).toEqual([deleteTransaction]); + expect(trans.getState(deleteTransaction.id)).toEqual({ + value: null, + recordRef, + type: deleteTransaction.type + }); + trans.clear(); + expect(trans.getTransactionLog()).toEqual([]); + expect(trans.getAggregatedChanges(true)).toEqual([]); + + // DELETE -> Undo + trans.add(deleteTransaction, recordRef); + trans.undo(); + expect(trans.getTransactionLog()).toEqual([]); + expect(trans.getAggregatedChanges(true)).toEqual([]); + trans.clear(); + + // DELETE -> Undo -> Redo + trans.add(deleteTransaction, recordRef); + trans.undo(); + trans.redo(); + expect(trans.getTransactionLog('Key1').pop()).toEqual(deleteTransaction); + expect(trans.getTransactionLog()).toEqual([deleteTransaction]); + expect(trans.getState(deleteTransaction.id)).toEqual({ + value: null, + recordRef, + type: deleteTransaction.type + }); + trans.clear(); + }); + + it('Should add UPDATE type transaction - all feasible paths', () => { + const trans = new IgxTransactionService(); + expect(trans).toBeDefined(); + + // UPDATE + const recordRef = { key: 'Key1', value: 1 }; + const newValue = { key: 'Key1', value: 2 }; + const updateTransaction: Transaction = { id: 'Key1', type: TransactionType.UPDATE, newValue }; + trans.add(updateTransaction, recordRef); + expect(trans.getState('Key1')).toBeTruthy(); + expect(trans.getAggregatedValue('Key1', true)).toEqual(newValue); + expect(trans.getTransactionLog('Key1').pop()).toEqual(updateTransaction); + expect(trans.getTransactionLog()).toEqual([updateTransaction]); + expect(trans.getState(updateTransaction.id)).toEqual({ + value: { value: 2 }, + recordRef, + type: updateTransaction.type + }); + trans.clear(); + expect(trans.getState('Key1')).toBeFalsy(); + expect(trans.getAggregatedValue('Key1', true)).toBeNull(); + expect(trans.getTransactionLog()).toEqual([]); + expect(trans.getAggregatedChanges(true)).toEqual([]); + + // UPDATE -> Undo + trans.add(updateTransaction, recordRef); + trans.undo(); + expect(trans.getTransactionLog()).toEqual([]); + expect(trans.getAggregatedChanges(true)).toEqual([]); + trans.clear(); + + // UPDATE -> Undo -> Redo + trans.add(updateTransaction, recordRef); + trans.undo(); + trans.redo(); + expect(trans.getTransactionLog('Key1').pop()).toEqual(updateTransaction); + expect(trans.getTransactionLog()).toEqual([updateTransaction]); + expect(trans.getState(updateTransaction.id)).toEqual({ + value: { value: 2 }, + recordRef, + type: updateTransaction.type + }); + trans.clear(); + + // UPDATE -> UPDATE + trans.add(updateTransaction, recordRef); + const newValue2 = { key: 'Key1', value: 3 }; + const updateTransaction2: Transaction = { id: 'Key1', type: TransactionType.UPDATE, newValue: newValue2 }; + trans.add(updateTransaction2, recordRef); + expect(trans.getTransactionLog('Key1').pop()).toEqual(updateTransaction2); + expect(trans.getTransactionLog()).toEqual([updateTransaction, updateTransaction2]); + expect(trans.getState(updateTransaction.id)).toEqual({ + value: { value: 3 }, + recordRef, + type: updateTransaction2.type + }); + trans.clear(); + + // UPDATE -> UPDATE (to initial recordRef) + trans.add(updateTransaction, recordRef); + const asRecordRefTransaction: Transaction = { id: 'Key1', type: TransactionType.UPDATE, newValue: recordRef }; + trans.add(asRecordRefTransaction, recordRef); + expect(trans.getTransactionLog('Key1').pop()).toEqual(asRecordRefTransaction); + expect(trans.getTransactionLog()).toEqual([updateTransaction, asRecordRefTransaction]); + expect(trans.getState(updateTransaction.id)).toBeUndefined(); + expect(trans.getAggregatedChanges(false)).toEqual([]); + trans.clear(); + + // UPDATE -> UPDATE -> Undo + trans.add(updateTransaction, recordRef); + trans.add(updateTransaction2, recordRef); + trans.undo(); + expect(trans.getTransactionLog('Key1').pop()).toEqual(updateTransaction); + expect(trans.getTransactionLog()).toEqual([updateTransaction]); + expect(trans.getState(updateTransaction.id)).toEqual({ + value: { value: 2 }, + recordRef, + type: updateTransaction.type + }); + trans.clear(); + + // UPDATE -> UPDATE -> Undo -> Redo + trans.add(updateTransaction, recordRef); + trans.add(updateTransaction2, recordRef); + trans.undo(); + trans.redo(); + expect(trans.getTransactionLog('Key1').pop()).toEqual(updateTransaction2); + expect(trans.getTransactionLog()).toEqual([updateTransaction, updateTransaction2]); + expect(trans.getState(updateTransaction.id)).toEqual({ + value: { value: 3 }, + recordRef, + type: updateTransaction2.type + }); + trans.clear(); + + // UPDATE -> DELETE + trans.add(updateTransaction, recordRef); + const deleteTransaction: Transaction = { id: 'Key1', type: TransactionType.DELETE, newValue: null }; + trans.add(deleteTransaction); + expect(trans.getTransactionLog('Key1').pop()).toEqual(deleteTransaction); + expect(trans.getTransactionLog()).toEqual([updateTransaction, deleteTransaction]); + expect(trans.getState(deleteTransaction.id)).toEqual({ + value: deleteTransaction.newValue, + recordRef, + type: deleteTransaction.type + }); + trans.clear(); + + // UPDATE -> DELETE -> Undo + trans.add(updateTransaction, recordRef); + trans.add(deleteTransaction); + trans.undo(); + expect(trans.getTransactionLog('Key1').pop()).toEqual(updateTransaction); + expect(trans.getTransactionLog()).toEqual([updateTransaction]); + expect(trans.getState(updateTransaction.id)).toEqual({ + value: { value: 2 }, + recordRef, + type: updateTransaction.type + }); + trans.clear(); + + // UPDATE -> DELETE -> Undo -> Redo + trans.add(updateTransaction, recordRef); + trans.add(deleteTransaction); + trans.undo(); + trans.redo(); + expect(trans.getTransactionLog('Key1').pop()).toEqual(deleteTransaction); + expect(trans.getTransactionLog()).toEqual([updateTransaction, deleteTransaction]); + expect(trans.getState(deleteTransaction.id)).toEqual({ + value: deleteTransaction.newValue, + recordRef, + type: deleteTransaction.type + }); + trans.clear(); + }); + + it('Should properly confirm the length of the undo/redo stacks', () => { + const transaction = new IgxTransactionService(); + expect(transaction).toBeDefined(); + // Stacks are clear by default + expect(transaction.canRedo).toBeFalsy(); + expect(transaction.canUndo).toBeFalsy(); + let addItem: Transaction = { id: 1, type: TransactionType.ADD, newValue: { Category: 'Something' } }; + transaction.add(addItem); + expect(transaction.canRedo).toBeFalsy(); + expect(transaction.canUndo).toBeTruthy(); + addItem = { id: 2, type: TransactionType.ADD, newValue: { Category: 'Something 2' } }; + transaction.add(addItem); + expect(transaction.canRedo).toBeFalsy(); + expect(transaction.canUndo).toBeTruthy(); + transaction.undo(); + expect(transaction.canRedo).toBeTruthy(); + expect(transaction.canUndo).toBeTruthy(); + transaction.undo(); + expect(transaction.canRedo).toBeTruthy(); + expect(transaction.canUndo).toBeFalsy(); + transaction.redo(); + expect(transaction.canRedo).toBeTruthy(); + expect(transaction.canUndo).toBeTruthy(); + transaction.redo(); + expect(transaction.canRedo).toBeFalsy(); + expect(transaction.canUndo).toBeTruthy(); + }); + + it('Should update data when data is list of objects', () => { + const originalData = SampleTestData.generateProductData(50); + const trans = new IgxTransactionService(); + expect(trans).toBeDefined(); + + const item0Update1: Transaction = { id: 1, type: TransactionType.UPDATE, newValue: { Category: 'Some new value' } }; + trans.add(item0Update1, originalData[1]); + + const item10Delete: Transaction = { id: 10, type: TransactionType.DELETE, newValue: null }; + trans.add(item10Delete, originalData[10]); + + const newItem1: Transaction = { + id: 'add1', type: TransactionType.ADD, newValue: { + ID: undefined, + Category: 'Category Added', + Downloads: 100, + Items: 'Items Added', + ProductName: 'ProductName Added', + ReleaseDate: new Date(), + Released: true, + Test: 'test Added' + } + }; + + trans.add(newItem1, undefined); + + trans.commit(originalData); + expect(originalData.find(i => i.ID === 1).Category).toBe('Some new value'); + expect(originalData.find(i => i.ID === 10)).toBeUndefined(); + expect(originalData.length).toBe(50); + expect(originalData[49]).toEqual(newItem1.newValue); + }); + + it('Should update data for provided id when data is list of objects', () => { + const originalData = SampleTestData.generateProductData(50); + const trans = new IgxTransactionService(); + expect(trans).toBeDefined(); + + const item0Update1: Transaction = { id: 0, type: TransactionType.UPDATE, newValue: { Category: 'Some new value' } }; + trans.add(item0Update1, originalData[1]); + + const item10Delete: Transaction = { id: 10, type: TransactionType.DELETE, newValue: null }; + trans.add(item10Delete, originalData[10]); + + const newItem1: Transaction = { + id: 'add1', type: TransactionType.ADD, newValue: { + ID: undefined, + Category: 'Category Added', + Downloads: 100, + Items: 'Items Added', + ProductName: 'ProductName Added', + ReleaseDate: new Date(), + Released: true, + Test: 'test Added' + } + }; + + trans.add(newItem1, undefined); + + trans.commit(originalData, 10); + expect(originalData.find(i => i.ID === 1).Category).toBe('Category1'); + expect(originalData.find(i => i.ID === 10)).toBeUndefined(); + expect(originalData.length).toBe(49); + + trans.commit(originalData, 'FAKE ID'); + expect(originalData.find(i => i.ID === 1).Category).toBe('Category1'); + expect(originalData.find(i => i.ID === 10)).toBeUndefined(); + expect(originalData.length).toBe(49); + + trans.commit(originalData, 20); + expect(originalData.find(i => i.ID === 1).Category).toBe('Category1'); + expect(originalData.find(i => i.ID === 10)).toBeUndefined(); + expect(originalData.length).toBe(49); + + trans.commit(originalData, 0); + expect(originalData.find(i => i.ID === 1).Category).toBe('Some new value'); + expect(originalData.find(i => i.ID === 10)).toBeUndefined(); + expect(originalData.length).toBe(49); + + trans.commit(originalData, 'add1'); + expect(originalData.find(i => i.ID === 1).Category).toBe('Some new value'); + expect(originalData.find(i => i.ID === 10)).toBeUndefined(); + expect(originalData.length).toBe(50); + expect(originalData[49]).toEqual(newItem1.newValue); + }); + + it('Should update data when data is list of primitives', () => { + const originalData = SampleTestData.generateListOfPrimitiveValues(50, 'String'); + const trans = new IgxTransactionService(); + expect(trans).toBeDefined(); + + const item0Update1: Transaction = { id: 1, type: TransactionType.UPDATE, newValue: 'Updated Row' }; + trans.add(item0Update1, originalData[1]); + + const item10Delete: Transaction = { id: 10, type: TransactionType.DELETE, newValue: null }; + trans.add(item10Delete, originalData[10]); + + const newItem1: Transaction = { + id: 'add1', type: TransactionType.ADD, newValue: 'Added Row' + }; + + trans.add(newItem1, undefined); + + trans.commit(originalData); + expect(originalData[1]).toBe('Updated Row'); + expect(originalData.find(i => i === 'Row 10')).toBeUndefined(); + expect(originalData.length).toBe(50); + expect(originalData[49]).toEqual('Added Row'); + }); + + it('Should update data for provided id when data is list of primitives', () => { + const originalData = SampleTestData.generateListOfPrimitiveValues(50, 'String'); + const trans = new IgxTransactionService(); + expect(trans).toBeDefined(); + + const item0Update1: Transaction = { id: 1, type: TransactionType.UPDATE, newValue: 'Updated Row' }; + trans.add(item0Update1, originalData[1]); + + const item10Delete: Transaction = { id: 10, type: TransactionType.DELETE, newValue: null }; + trans.add(item10Delete, originalData[10]); + + const newItem1: Transaction = { + id: 'add1', type: TransactionType.ADD, newValue: 'Added Row' + }; + + trans.add(newItem1, undefined); + + trans.commit(originalData, 10); + expect(originalData[1]).toBe('Row 1'); + expect(originalData.find(i => i.id === 'Row 10')).toBeUndefined(); + expect(originalData.length).toBe(49); + + trans.commit(originalData, 'FAKE ID'); + expect(originalData[1]).toBe('Row 1'); + expect(originalData.find(i => i.id === 'Row 10')).toBeUndefined(); + expect(originalData.length).toBe(49); + + trans.commit(originalData, 20); + expect(originalData[1]).toBe('Row 1'); + expect(originalData.find(i => i.id === 'Row 10')).toBeUndefined(); + expect(originalData.length).toBe(49); + + trans.commit(originalData, 1); + expect(originalData[1]).toBe('Updated Row'); + expect(originalData.find(i => i.id === 'Row 10')).toBeUndefined(); + expect(originalData.length).toBe(49); + + trans.commit(originalData, 'add1'); + expect(originalData[1]).toBe('Updated Row'); + expect(originalData.find(i => i.id === 'Row 10')).toBeUndefined(); + expect(originalData.length).toBe(50); + expect(originalData[49]).toEqual(newItem1.newValue); + }); + + it('Should add pending transaction and push it to transaction log, and correctly fires onStateUpdate', () => { + const trans = new IgxTransactionService(); + spyOn(trans.onStateUpdate, 'emit').and.callThrough(); + + expect(trans).toBeDefined(); + const recordRef = { key: 'Key1', value1: 1, value2: 2, value3: 3 }; + let newValue: any = { key: 'Key1', value1: 10 }; + let updateTransaction: Transaction = { id: 'Key1', type: TransactionType.UPDATE, newValue }; + + trans.startPending(); + trans.add(updateTransaction, recordRef); + + expect(trans.getState('Key1')).toBeUndefined(); + expect(trans.getAggregatedValue('Key1', true)).toEqual({ key: 'Key1', value1: 10, value2: 2, value3: 3 }); + expect(trans.getTransactionLog()).toEqual([]); + expect(trans.getAggregatedChanges(true)).toEqual([]); + + newValue = { key: 'Key1', value3: 30 }; + updateTransaction = { id: 'Key1', type: TransactionType.UPDATE, newValue }; + trans.add(updateTransaction, recordRef); + + expect(trans.getState('Key1')).toBeUndefined(); + expect(trans.getAggregatedValue('Key1', true)).toEqual({ key: 'Key1', value1: 10, value2: 2, value3: 30 }); + expect(trans.getTransactionLog()).toEqual([]); + expect(trans.getAggregatedChanges(true)).toEqual([]); + + trans.endPending(true); + + expect(trans.getState('Key1')).toBeTruthy(); + expect(trans.getAggregatedValue('Key1', true)).toEqual({ key: 'Key1', value1: 10, value2: 2, value3: 30 }); + expect(trans.getTransactionLog() as any).toEqual( + [ + { + id: 'Key1', + newValue: { key: 'Key1', value1: 10 }, + type: 'update' + }, { + id: 'Key1', + newValue: { key: 'Key1', value3: 30 }, + type: 'update' + } + ]); + expect(trans.getState(updateTransaction.id)).toEqual({ + value: { value1: 10, value3: 30 }, + recordRef, + type: updateTransaction.type + }); + expect(trans.onStateUpdate.emit).toHaveBeenCalledTimes(1); + }); + + it('Should not add pending transaction and push it to transaction log, and correctly fires onStateUpdate', () => { + const trans = new IgxTransactionService(); + spyOn(trans.onStateUpdate, 'emit').and.callThrough(); + + expect(trans).toBeDefined(); + const recordRef = { key: 'Key1', value1: 1, value2: 2, value3: 3 }; + let newValue: any = { key: 'Key1', value1: 10 }; + let updateTransaction: Transaction = { id: 'Key1', type: TransactionType.UPDATE, newValue }; + + trans.startPending(); + trans.add(updateTransaction, recordRef); + + expect(trans.getTransactionLog()).toEqual([]); + expect(trans.getAggregatedChanges(true)).toEqual([]); + + newValue = { key: 'Key1', value3: 30 }; + updateTransaction = { id: 'Key1', type: TransactionType.UPDATE, newValue }; + trans.add(updateTransaction, recordRef); + + expect(trans.getTransactionLog()).toEqual([]); + expect(trans.getAggregatedChanges(true)).toEqual([]); + + trans.endPending(false); + + expect(trans.getTransactionLog()).toEqual([]); + expect(trans.getAggregatedChanges(true)).toEqual([]); + expect(trans.onStateUpdate.emit).toHaveBeenCalledTimes(0); + }); + + it('Should not generate changes when updating a value to the original one', () => { + const originalData = SampleTestData.generateProductData(50); + const transaction = new IgxTransactionService(); + expect(transaction).toBeDefined(); + + transaction.startPending(); + + const itemUpdate1: Transaction = { id: 1, type: TransactionType.UPDATE, newValue: { Category: 'Some new value' } }; + transaction.add(itemUpdate1, originalData[1]); + + expect(transaction.getState(1, true)).toBeTruthy(); + expect(transaction.getAggregatedValue(1, false)).toEqual({ Category: 'Some new value' }); + + // update to original value + const itemUpdate2: Transaction = { id: 1, type: TransactionType.UPDATE, newValue: { Category: originalData[1].Category } }; + transaction.add(itemUpdate2, originalData[1]); + + expect(transaction.getState(1, true)).toBeUndefined(); + expect(transaction.getAggregatedValue(1, false)).toBeNull(); + + transaction.endPending(false); + + expect(transaction.getTransactionLog()).toEqual([]); + expect(transaction.getAggregatedChanges(true)).toEqual([]); + }); + + it('Should clear transactions for provided id', () => { + const originalData = SampleTestData.generateProductData(50); + const trans = new IgxTransactionService(); + expect(trans).toBeDefined(); + + let transaction: Transaction = { id: 1, type: TransactionType.UPDATE, newValue: { Category: 'Some new value' } }; + trans.add(transaction, originalData[1]); + + transaction = { id: 2, type: TransactionType.UPDATE, newValue: { Category: 'Some new value' } }; + trans.add(transaction, originalData[2]); + + transaction = { id: 2, type: TransactionType.UPDATE, newValue: { Items: 'Some new value' } }; + trans.add(transaction, originalData[2]); + + transaction = { id: 1, type: TransactionType.UPDATE, newValue: { Category: 'Some very new value' } }; + trans.add(transaction, originalData[1]); + + transaction = { id: 10, type: TransactionType.UPDATE, newValue: { Category: 'Some new value' } }; + trans.add(transaction, originalData[10]); + + expect(trans.getTransactionLog().length).toBe(5); + expect(trans.getAggregatedChanges(true).length).toBe(3); + expect(trans.canUndo).toBeTruthy(); + expect(trans.canRedo).toBeFalsy(); + + trans.clear(1); + expect(trans.getTransactionLog().length).toBe(3); + expect(trans.getAggregatedChanges(true).length).toBe(2); + expect(trans.canUndo).toBeTruthy(); + expect(trans.canRedo).toBeFalsy(); + + trans.clear('FAKE ID'); + expect(trans.getTransactionLog().length).toBe(3); + expect(trans.getAggregatedChanges(true).length).toBe(2); + expect(trans.canUndo).toBeTruthy(); + expect(trans.canRedo).toBeFalsy(); + + trans.clear(20); + expect(trans.getTransactionLog().length).toBe(3); + expect(trans.getAggregatedChanges(true).length).toBe(2); + expect(trans.canUndo).toBeTruthy(); + expect(trans.canRedo).toBeFalsy(); + + trans.clear(10); + expect(trans.getTransactionLog().length).toBe(2); + expect(trans.getAggregatedChanges(true).length).toBe(1); + expect(trans.canUndo).toBeTruthy(); + expect(trans.canRedo).toBeFalsy(); + }); + }); + + 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); + }); + + it('Should correctly call getAggregatedChanges without commit when recordRef is null', () => { + const transaction = new IgxHierarchicalTransactionService(); + expect(transaction).toBeDefined(); + + const deleteTransaction: HierarchicalTransaction = { id: 0, type: TransactionType.DELETE, newValue: null, path: [] }; + transaction.add(deleteTransaction, 'Deleted row'); + + expect(transaction.getAggregatedChanges(false)).toEqual([deleteTransaction]); + }); + + it('Should update data for provided id', () => { + const data = SampleTestData.employeeTreeData(); + + const transaction = new IgxHierarchicalTransactionService(); + expect(transaction).toBeDefined(); + + const addTransaction: HierarchicalTransaction = { + id: 0, + type: TransactionType.ADD, + newValue: { + ID: 999, + Name: 'Root Add Transaction', + HireDate: new Date(2018, 3, 20), + Age: 45, + OnPTO: false, + Employees: [] + }, + path: null + }; + transaction.add(addTransaction); + + const updateTransaction: HierarchicalTransaction = { + id: 475, + type: TransactionType.UPDATE, + newValue: { + Age: 60 + }, + path: [data[0].ID] + }; + transaction.add(updateTransaction, data[0].Employees[0]); + + const deleteTransaction: HierarchicalTransaction = { + id: 711, + type: TransactionType.DELETE, + newValue: {}, + path: [data[0].ID, data[0].Employees[2].ID] + }; + transaction.add(deleteTransaction, data[0].Employees[2].Employees[0]); + + updateTransaction.newValue = { Name: 'New Name'}; + transaction.add(updateTransaction, data[0].Employees[0]); + + expect(data.find(i => i.ID === 999)).toBeUndefined(); + expect(data.length).toBe(4); + transaction.commit(data, 'ID', 'Employees', 0); + expect(data.find(i => i.ID === 999)).toBeDefined(); + expect(data.find(i => i.ID === 999).Name).toBe('Root Add Transaction'); + expect(data.length).toBe(5); + expect(transaction.canUndo).toBeTruthy(); + expect(transaction.getAggregatedChanges(false).length).toBe(2); + + expect(data[0].Employees[0].Age).toBe(43); + expect(data[0].Employees[0].Name).toBe('Michael Langdon'); + transaction.commit(data, 'ID', 'Employees', 475); + expect(data[0].Employees[0].Age).toBe(60); + expect(data[0].Employees[0].Name).toBe('New Name'); + expect(transaction.canUndo).toBeTruthy(); + expect(transaction.getAggregatedChanges(false).length).toBe(1); + + expect(data[0].Employees[2].Employees.length).toBe(2); + transaction.commit(data, 'ID', 'Employees', 711); + expect(data[0].Employees[2].Employees.length).toBe(1); + expect(transaction.canUndo).toBeFalsy(); + expect(transaction.getAggregatedChanges(false).length).toBe(0); + }); + + it('Should not generate changes when updating a value to the original one', () => { + const originalData = SampleTestData.employeeTreeData(); + const transaction = new IgxHierarchicalTransactionService(); + expect(transaction).toBeDefined(); + + transaction.startPending(); + + // root record update + const rootUpdate1: HierarchicalTransaction = { + id: 147, + type: TransactionType.UPDATE, + newValue: { + Name: 'New Name' + }, + path: null + }; + transaction.add(rootUpdate1, originalData[0]); + + expect(transaction.getState(147, true)).toBeTruthy(); + expect(transaction.getAggregatedValue(147, false)).toEqual({ Name: 'New Name' }); + + // update to original value + const rootUpdate2: HierarchicalTransaction = { + id: 147, + type: TransactionType.UPDATE, + newValue: { + Name: originalData[0].Name + }, + path: null + }; + transaction.add(rootUpdate2, originalData[0]); + + expect(transaction.getState(147, true)).toBeUndefined(); + expect(transaction.getAggregatedValue(147, false)).toBeNull(); + + // child record update + const childUpdate1: HierarchicalTransaction = { + id: 475, + type: TransactionType.UPDATE, + newValue: { + Age: 60 + }, + path: [originalData[0].ID] + }; + transaction.add(childUpdate1, originalData[0].Employees[0]); + + expect(transaction.getState(475, true)).toBeTruthy(); + expect(transaction.getAggregatedValue(475, false)).toEqual({ Age: 60 }); + + // update to original value + const childUpdate2: HierarchicalTransaction = { + id: 475, + type: TransactionType.UPDATE, + newValue: { + Age: originalData[0].Employees[0].Age + }, + path: [originalData[0].ID] + }; + transaction.add(childUpdate2, originalData[0].Employees[0]); + + expect(transaction.getState(475, true)).toBeUndefined(); + expect(transaction.getAggregatedValue(475, false)).toBeNull(); + + transaction.endPending(false); + + expect(transaction.getTransactionLog()).toEqual([]); + expect(transaction.getAggregatedChanges(true)).toEqual([]); + }); + + it('Should emit onStateUpdate once when commiting a hierarchical transaction', () => { + const data = SampleTestData.employeeTreeData(); + const transaction = new IgxHierarchicalTransactionService(); + spyOn(transaction.onStateUpdate, 'emit').and.callThrough(); + expect(transaction).toBeDefined(); + + const updateTransaction: HierarchicalTransaction = { + id: 475, + type: TransactionType.UPDATE, + newValue: { + Age: 60 + }, + path: [data[0].ID] + }; + transaction.add(updateTransaction, data[0].Employees[0]); + expect(transaction.onStateUpdate.emit).toHaveBeenCalledTimes(1); + + transaction.commit(data, 'ID'); + expect(transaction.onStateUpdate.emit).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/projects/igniteui-angular/core/src/services/transaction/igx-transaction.ts b/projects/igniteui-angular/core/src/services/transaction/igx-transaction.ts new file mode 100644 index 00000000000..4d95b2e08f9 --- /dev/null +++ b/projects/igniteui-angular/core/src/services/transaction/igx-transaction.ts @@ -0,0 +1,335 @@ +import { Transaction, State, TransactionType, TransactionEventOrigin, Action } from './transaction'; +import { IgxBaseTransactionService } from './base-transaction'; +import { isObject, mergeObjects } from '../../core/utils'; + +export class IgxTransactionService extends IgxBaseTransactionService { + + protected _transactions: T[] = []; + protected _redoStack: Action[][] = []; + protected _undoStack: Action[][] = []; + protected _states: Map = new Map(); + + /** + * @returns if there are any transactions in the Undo stack + */ + public override get canUndo(): boolean { + return this._undoStack.length > 0; + } + + /** + * @returns if there are any transactions in the Redo stack + */ + public override get canRedo(): boolean { + return this._redoStack.length > 0; + } + + /** + * Adds provided transaction with recordRef if any + * + * @param transaction Transaction to be added + * @param recordRef Reference to the value of the record in the data source related to the changed item + */ + public override add(transaction: T, recordRef?: any): void { + const states = this._isPending ? this._pendingStates : this._states; + this.verifyAddedTransaction(states, transaction, recordRef); + this.addTransaction(transaction, states, recordRef); + } + + /** + * Returns all recorded transactions in chronological order + * + * @param id Optional record id to get transactions for + * @returns All transaction in the service or for the specified record + */ + public override getTransactionLog(id?: any): T[] { + if (id !== undefined) { + return this._transactions.filter(t => t.id === id); + } + return [...this._transactions]; + } + + /** + * Returns aggregated changes from all transactions + * + * @param mergeChanges If set to true will merge each state's value over relate recordRef + * and will record resulting value in the related transaction + * @returns Collection of aggregated transactions for each changed record + */ + public override getAggregatedChanges(mergeChanges: boolean): T[] { + const result: T[] = []; + this._states.forEach((state: S, key: any) => { + const value = mergeChanges ? this.mergeValues(state.recordRef, state.value) : state.value; + result.push({ id: key, newValue: value, type: state.type } as T); + }); + return result; + } + + /** + * Returns the state of the record with provided id + * + * @param id The id of the record + * @param pending Should get pending state + * @returns State of the record if any + */ + public override getState(id: any, pending = false): S { + return pending ? this._pendingStates.get(id) : this._states.get(id); + } + + /** + * Returns whether transaction is enabled for this service + */ + public override get enabled(): boolean { + return true; + } + + /** + * Returns value of the required id including all uncommitted changes + * + * @param id The id of the record to return value for + * @param mergeChanges If set to true will merge state's value over relate recordRef + * and will return merged value + * @returns Value with changes or **null** + */ + public override getAggregatedValue(id: any, mergeChanges: boolean): any { + const state = this._states.get(id); + const pendingState = super.getState(id); + + // if there is no state and there is no pending state return null + if (!state && !pendingState) { + return null; + } + + const pendingChange = super.getAggregatedValue(id, false); + const change = state && state.value; + let aggregatedValue = this.mergeValues(change, pendingChange); + if (mergeChanges) { + const originalValue = state ? state.recordRef : pendingState.recordRef; + aggregatedValue = this.mergeValues(originalValue, aggregatedValue); + } + return aggregatedValue; + } + + /** + * Clears all pending transactions and aggregated pending state. If commit is set to true + * commits pending states as single transaction + * + * @param commit Should commit the pending states + */ + public override endPending(commit: boolean): void { + this._isPending = false; + if (commit) { + const actions: Action[] = []; + // don't use addTransaction due to custom undo handling + 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 = []; + + this.onStateUpdate.emit({ origin: TransactionEventOrigin.END, actions }); + } + super.endPending(commit); + } + + /** + * Applies all transactions over the provided data + * + * @param data Data source to update + * @param id Optional record id to commit transactions for + */ + public override commit(data: any[], id?: any): void { + if (id !== undefined) { + const state = this.getState(id); + if (state) { + this.updateRecord(data, state); + } + } else { + this._states.forEach((s: S) => { + this.updateRecord(data, s); + }); + } + this.clear(id); + } + + /** + * Clears all transactions + * + * @param id Optional record id to clear transactions for + */ + public override clear(id?: any): void { + if (id !== undefined) { + this._transactions = this._transactions.filter(t => t.id !== id); + this._states.delete(id); + // Undo stack is an array of actions. Each action is array of transaction like objects + // We are going trough all the actions. For each action we are filtering out transactions + // with provided id. Finally if any action ends up as empty array we are removing it from + // undo stack + this._undoStack = this._undoStack.map(a => a.filter(t => t.transaction.id !== id)).filter(a => a.length > 0); + } else { + this._transactions = []; + this._states.clear(); + this._undoStack = []; + } + this._redoStack = []; + this.onStateUpdate.emit({ origin: TransactionEventOrigin.CLEAR, actions: [] }); + } + + /** + * Remove the last transaction if any + */ + public override undo(): void { + if (this._undoStack.length <= 0) { + return; + } + + const lastActions: Action[] = this._undoStack.pop(); + this._transactions.splice(this._transactions.length - lastActions.length); + this._redoStack.push(lastActions); + + this._states.clear(); + for (const currentActions of this._undoStack) { + for (const transaction of currentActions) { + this.updateState(this._states, transaction.transaction, transaction.recordRef); + } + } + + this.onStateUpdate.emit({ origin: TransactionEventOrigin.UNDO, actions: lastActions }); + } + + /** + * Applies the last undone transaction if any + */ + public override redo(): void { + if (this._redoStack.length > 0) { + const actions: Action[] = 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({ origin: TransactionEventOrigin.REDO, actions }); + } + } + + 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) { + const actions = [{ transaction, recordRef }]; + this._undoStack.push(actions); + this._redoStack = []; + this.onStateUpdate.emit({ origin: TransactionEventOrigin.ADD, actions }); + } + } + + /** + * Verifies if the passed transaction is correct. If not throws an exception. + * + * @param transaction Transaction to be verified + */ + protected verifyAddedTransaction(states: Map, transaction: T, recordRef?: any): void { + const state = states.get(transaction.id); + switch (transaction.type) { + case TransactionType.ADD: + if (state) { + // cannot add same item twice + throw new Error(`Cannot add this transaction. Transaction with id: ${transaction.id} has been already added.`); + } + break; + case TransactionType.DELETE: + case TransactionType.UPDATE: + if (state && state.type === TransactionType.DELETE) { + // cannot delete or update deleted items + throw new Error(`Cannot add this transaction. Transaction with id: ${transaction.id} has been already deleted.`); + } + if (!state && !recordRef && !this._isPending) { + // cannot initially add transaction or delete item with no recordRef + throw new Error(`Cannot add this transaction. This is first transaction of type ${transaction.type} ` + + `for id ${transaction.id}. For first transaction of this type recordRef is mandatory.`); + } + break; + } + } + + /** + * Updates the provided states collection according to passed transaction and recordRef + * + * @param states States collection to apply the update to + * @param transaction Transaction to apply to the current state + * @param recordRef Reference to the value of the record in data source, if any, where transaction should be applied + */ + protected override updateState(states: Map, transaction: T, recordRef?: any): void { + let state = states.get(transaction.id); + // if TransactionType is ADD simply add transaction to states; + // if TransactionType is DELETE: + // - if there is state with this id of type ADD remove it from the states; + // - if there is state with this id of type UPDATE change its type to DELETE; + // - if there is no state with this id add transaction to states; + // if TransactionType is UPDATE: + // - if there is state with this id of type ADD merge new value and state recordRef into state new value + // - if there is state with this id of type UPDATE merge new value into state new value + // - if there is state with this id and state type is DELETE change its type to UPDATE + // - if there is no state with this id add transaction to states; + if (state) { + switch (transaction.type) { + case TransactionType.DELETE: + if (state.type === TransactionType.ADD) { + states.delete(transaction.id); + } else if (state.type === TransactionType.UPDATE) { + state.value = transaction.newValue; + state.type = TransactionType.DELETE; + } + break; + case TransactionType.UPDATE: + if (isObject(state.value)) { + if (state.type === TransactionType.ADD) { + state.value = this.mergeValues(state.value, transaction.newValue); + } + if (state.type === TransactionType.UPDATE) { + mergeObjects(state.value, transaction.newValue); + } + } else { + state.value = transaction.newValue; + } + } + } else { + state = { value: this.cloneStrategy.clone(transaction.newValue), recordRef, type: transaction.type } as S; + states.set(transaction.id, state); + } + + this.cleanState(transaction.id, states); + } + + /** + * Updates state related record in the provided data + * + * @param data Data source to update + * @param state State to update data from + */ + protected updateRecord(data: any[], state: S) { + const index = data.findIndex(i => JSON.stringify(i) === JSON.stringify(state.recordRef || {})); + switch (state.type) { + case TransactionType.ADD: + data.push(state.value); + break; + case TransactionType.DELETE: + if (0 <= index && index < data.length) { + data.splice(index, 1); + } + break; + case TransactionType.UPDATE: + if (0 <= index && index < data.length) { + data[index] = this.updateValue(state); + } + break; + } + } +} diff --git a/projects/igniteui-angular/core/src/services/transaction/transaction-factory.service.ts b/projects/igniteui-angular/core/src/services/transaction/transaction-factory.service.ts new file mode 100644 index 00000000000..db27ad20227 --- /dev/null +++ b/projects/igniteui-angular/core/src/services/transaction/transaction-factory.service.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@angular/core'; +import { IgxBaseTransactionService } from './base-transaction'; +import { HierarchicalTransactionService } from './hierarchical-transaction'; +import { IgxHierarchicalTransactionService } from './igx-hierarchical-transaction'; +import { IgxTransactionService } from './igx-transaction'; +import { HierarchicalState, HierarchicalTransaction, State, Transaction, TransactionService } from './transaction'; + +/** + * The type of the transaction that should be provided. + * When batchEditing is disabled, `None` is provided. + * When enabled - `Base` is provided. + * An enum instead of a boolean value leaves room for extra scenarios in the future. + */ +export const enum TRANSACTION_TYPE { + 'None' = 'None', + 'Base' = 'Base' +} + +/** + * Factory service for instantiating TransactionServices + */ +@Injectable({ + providedIn: 'root' +}) +export class IgxFlatTransactionFactory { + + /** + * Creates a new Transaction service instance depending on the specified type. + * + * @param type The type of the transaction + * @returns a new instance of TransactionService + */ + public create(type: TRANSACTION_TYPE): TransactionService { + switch (type) { + case (TRANSACTION_TYPE.Base): + return new IgxTransactionService(); + default: + return new IgxBaseTransactionService(); + } + } +} + +/** + * Factory service for instantiating HierarchicalTransactionServices + */ +@Injectable({ + providedIn: 'root' +}) +export class IgxHierarchicalTransactionFactory extends IgxFlatTransactionFactory { + + /** + * Creates a new HierarchialTransaction service instance depending on the specified type. + * + * @param type The type of the transaction + * @returns a new instance of HierarchialTransaction + */ + public override create(type: TRANSACTION_TYPE): HierarchicalTransactionService { + switch (type) { + case (TRANSACTION_TYPE.Base): + return new IgxHierarchicalTransactionService(); + default: + return new IgxBaseTransactionService(); + } + } +} diff --git a/projects/igniteui-angular/core/src/services/transaction/transaction.ts b/projects/igniteui-angular/core/src/services/transaction/transaction.ts new file mode 100644 index 00000000000..3c877f49ee6 --- /dev/null +++ b/projects/igniteui-angular/core/src/services/transaction/transaction.ts @@ -0,0 +1,164 @@ +import { EventEmitter } from '@angular/core'; +import { IDataCloneStrategy } from '../../data-operations/data-clone-strategy'; + +export enum TransactionType { + ADD = 'add', + DELETE = 'delete', + UPDATE = 'update' +} + +export enum TransactionEventOrigin { + UNDO = 'undo', + REDO = 'redo', + CLEAR = 'clear', + ADD = 'add', + END = 'endPending' +} + +export interface Transaction { + id: any; + type: TransactionType; + newValue: any; +} + +/** + * @experimental + * @hidden + */ +export interface HierarchicalTransaction extends Transaction { + path?: any[]; +} + +export interface State { + value: any; + recordRef: any; + type: TransactionType; +} + +export interface Action { + transaction: T; + recordRef: any; +} + +export interface StateUpdateEvent { + origin: TransactionEventOrigin; + actions: Action[]; +} + +/** + * @experimental + * @hidden + */ +export interface HierarchicalState extends State { + path: any[]; +} + +export interface TransactionService { + /** + * Returns whether transaction is enabled for this service + */ + readonly enabled: boolean; + + /** + * Gets/Sets the data clone strategy used to clone data + */ + cloneStrategy: IDataCloneStrategy; + + /** + * Event fired when transaction state has changed - add transaction, commit all transactions, undo and redo + */ + onStateUpdate?: EventEmitter; + + /** + * @returns if there are any transactions in the Undo stack + */ + canUndo: boolean; + + /** + * @returns if there are any transactions in the Redo stack + */ + canRedo: boolean; + + /** + * Adds provided transaction with recordRef if any + * + * @param transaction Transaction to be added + * @param recordRef Reference to the value of the record in the data source related to the changed item + */ + add(transaction: T, recordRef?: any): void; + + /** + * Returns all recorded transactions in chronological order + * + * @param id Optional record id to get transactions for + * @returns All transaction in the service or for the specified record + */ + getTransactionLog(id?: any): T[]; + + /** + * Remove the last transaction if any + */ + undo(): void; + + /** + * Applies the last undone transaction if any + */ + redo(): void; + + /** + * Returns aggregated changes from all transactions + * + * @param mergeChanges If set to true will merge each state's value over relate recordRef + * and will record resulting value in the related transaction + * @returns Collection of aggregated transactions for each changed record + */ + getAggregatedChanges(mergeChanges: boolean): T[]; + + /** + * Returns the state of the record with provided id + * + * @param id The id of the record + * @param pending Should get pending state + * @returns State of the record if any + */ + getState(id: any, pending?: boolean): S; + + /** + * Returns value of the required id including all uncommitted changes + * + * @param id The id of the record to return value for + * @param mergeChanges If set to true will merge state's value over relate recordRef + * and will return merged value + * @returns Value with changes or **null** + */ + getAggregatedValue(id: any, mergeChanges: boolean): any; + + /** + * Applies all transactions over the provided data + * + * @param data Data source to update + * @param id Optional record id to commit transactions for + */ + commit(data: any[], id?: any): void; + + /** + * Clears all transactions + * + * @param id Optional record id to clear transactions for + */ + clear(id?: any): void; + + /** + * Starts pending transactions. All transactions passed after call to startPending + * will not be added to transaction log + */ + startPending(): void; + + /** + * Clears all pending transactions and aggregated pending state. If commit is set to true + * commits pending states as single transaction + * + * @param commit Should commit the pending states + */ + endPending(commit: boolean): void; +} diff --git a/projects/igniteui-angular/date-picker/README.md b/projects/igniteui-angular/date-picker/README.md new file mode 100644 index 00000000000..0f65bb9ef8f --- /dev/null +++ b/projects/igniteui-angular/date-picker/README.md @@ -0,0 +1,8 @@ +# date-picker + +Part of Ignite UI for Angular. + +## Components + +- [Date Picker](src/date-picker/README.md) +- [Date Range Picker](src/date-range-picker/README.md) diff --git a/projects/igniteui-angular/date-picker/index.ts b/projects/igniteui-angular/date-picker/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/date-picker/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/date-picker/ng-package.json b/projects/igniteui-angular/date-picker/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/date-picker/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/date-picker/src/date-picker/README.md b/projects/igniteui-angular/date-picker/src/date-picker/README.md new file mode 100644 index 00000000000..71c402fb224 --- /dev/null +++ b/projects/igniteui-angular/date-picker/src/date-picker/README.md @@ -0,0 +1,143 @@ +# igx-date-picker Component + +The **igx-date-picker** component allows you to choose date from calendar +which is presented into input field. +A walk through of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/date_picker.html) + +## Dependencies +In order to be able to use **igx-date-picker** you should keep in mind that it is dependent on **BrowserAnimationsModule**, +which must be imported **only once** in your application's AppModule, for example: +```typescript +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +@NgModule({ + imports: [ + ... + BrowserAnimationsModule + ... + ] +}) +export class AppModule { +} +``` + +# Usage +Import the `IgxDatePickerModule` in the module that you want to use it in: +```typescript +import { IgxDatePickerModule } from 'igniteui-angular'; + +@NgModule({ + imports: [IgxDatePickerModule] +}) +export class AppModule { } +``` + +Basic initialization +```html + +``` + +This will produce an `igx-date-picker` without a default label. If you want to add a label you can import the `IgxLabelModule` and then in your `HTML` you can project it like so: +```html + + + +``` + +Custom formats for the input field. +```html + + +``` +If the `inputFormat` is not set, it will default to the format used by the browser. The `displayFormat` accepts all supported formats by Angular's `DatePipe`. + + +DatePicker with cancel and today buttons +```html + + +``` +If the these two properties are not set, the `igx-date-picker` will have any buttons. + +Additionally, custom buttons can be templated in the `igx-date-picker` using the `igxPickerActions` directive: +```html + + +
+ +
+
+
+``` + +The date picker also supports binding through `ngModel` if two-way date-bind is needed. It is also a custom validator which triggers +```html + + + + This field is required. + + + Value is not in range. + + +``` + +# API + +###### Inputs +| Name | Description | Type | +|:-----|:------------|:----| +| `id` | `attr.id` of the picker. | string | +| `mode` | Sets whether `IgxDatePickerComponent` is in dialog or dropdown mode. | InteractionMode | +| `value` | The value of the editor. | Date | +| `minValue` | The minimum value required for the picker to remain valid. | Date \| string | +| `maxValue` | The maximum value required for the editor to remain valid. | Date \| string | +| `displayFormat` | The display value of the editor. | string | +| `inputFormat` | The format that the editor will use to display the date/time. | string | +| `calendarFormat` | The calendar's format options for the day view. | PickersFormatOptions | +| `specialDates` | Dates that will be marked as special in the calendar. | DateRangeDescriptor[] | +| `disabledDates` | Dates that will be disabled in the calendar. | DateRangeDescriptor[] | +| `formatViews` | Determines if `day`, `month` and `year` will be rendered in the calendar. `locale` and `formatOptions` are taken into account as well, if present. | PickersFormatViews | +| `displayMonthsCount` | Sets the number of displayed month views. Default is `2`. | number | +| `hideOutsideDays` | Sets whether dates that are not part of the current month will be displayed. Default is `false`. | boolean | +| `showWeekNumbers` | Shows or hides week numbers. | number | +| `tabindex` | The editor's tabindex. | number | +| `weekStart`| Sets the start day of the week. | number | +| `locale` | Locale settings used in `displayFormat` and for localizing the calendar. | string | +| `overlaySettings` | Changes the default overlay settings used by the `IgxDatePickerComponent`. | OverlaySettings | +| `placeholder` | Sets the placeholder text for empty input. | string | +| `disabled` | Disables or enables the picker. | boolean | +| `outlet` | The container used for the pop up element. | IgxOverlayOutletDirective \| ElementRef | +| `type` | Determines how the picker will be styled. | IgxInputGroupType | +| `spinLoop` | Determines if the currently spun date segment should loop over. | boolean | +| `spinDelta` | Delta values used to increment or decrement each editor date part on spin actions. All values default to `1`. | DatePartDeltas | +| `cancelButtonLabel` | The label of the `cancel` button. No button is rendered if there is no label provided. | string | +| `todayButtonLabel` | The label of the `select today` button. No button is rendered if there is no label provided. | string | +| `headerOrientation` | Determines whether the calendar's header renders in `vertical` or `horizontal` state. Applies only in `dialog` mode. | 'horizontal' \| 'vertical' | + +### Outputs +| Name | Description | Cancelable | Emitted with | +|------|-------------|------------|--------------| +| `opening` | Fired when the calendar has started opening. | true | IBaseCancelableBrowserEventArgs | +| `opened` | Fired after the calendar has opened. | false | IBaseEventArgs | +| `closing` | Fired when the calendar has started closing | true | IBaseCancelableBrowserEventArgs | +| `closed` | Fired after the calendar has closed. | false | IBaseEventArgs | +| `valueChange` | Emitted when the picker's value changes. Allows two-way binding of `value`. | false | Date | +| `validationFailed` | Emitted when a user enters an invalid date string or when the value is not within a min/max range. | false | IDatePickerValidationFailedEventArgs | + +### Methods +| Name | Description | Return type | +|:-----|:------------|:----| +| `select` | Accepts a Date object and selects the corresponding date from the calendar. | void | +| `clear` | Clears the editor's date. | void | +| `open` | Opens the calendar. | void | +| `close` | Closes the calendar. | void | +| `toggle` | Toggles the calendar between opened and closed states. | void | +| `increment` | Accepts a `DatePart` and increments it by one. If no value is provided, it defaults to the part at the position of the cursor. | void | +| `decrement` | Accepts a `DatePart` and decrements it by one. If no value is provided, it defaults to the part at the position of the cursor. | void | diff --git a/projects/igniteui-angular/date-picker/src/date-picker/calendar-container/calendar-container.component.html b/projects/igniteui-angular/date-picker/src/date-picker/calendar-container/calendar-container.component.html new file mode 100644 index 00000000000..72eafaf7a3f --- /dev/null +++ b/projects/igniteui-angular/date-picker/src/date-picker/calendar-container/calendar-container.component.html @@ -0,0 +1,61 @@ + + @if (closeButtonLabel || cancelButtonLabel || todayButtonLabel) { +
+ @if (cancelButtonLabel) { + + } + @if (closeButtonLabel) { + + } + @if (todayButtonLabel) { + + } +
+ } +
+ + + @if( usePredefinedRanges || (customRanges?.length || 0) > 0 ){ + + + } +@if (pickerActions?.template || (closeButtonLabel || todayButtonLabel)) { + +} +@if (pickerActions?.template || (closeButtonLabel || cancelButtonLabel || todayButtonLabel)) { +
+ + +
+} diff --git a/projects/igniteui-angular/date-picker/src/date-picker/calendar-container/calendar-container.component.spec.ts b/projects/igniteui-angular/date-picker/src/date-picker/calendar-container/calendar-container.component.spec.ts new file mode 100644 index 00000000000..7b20f510581 --- /dev/null +++ b/projects/igniteui-angular/date-picker/src/date-picker/calendar-container/calendar-container.component.spec.ts @@ -0,0 +1,81 @@ +import { Component, ViewChild } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxCalendarComponent } from '../../../../calendar/src/public_api'; +import { IgxButtonDirective } from '../../../../directives/src/directives/button/button.directive'; +import { IgxPickerActionsDirective } from '../../../../core/src/date-common/picker-icons.common'; +import { IgxCalendarContainerComponent } from './calendar-container.component'; + + +describe('Calendar Container', () => { + let fixture: ComponentFixture; + let container: IgxCalendarContainerComponent; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, IgxDatePickerTestComponent] + }).compileComponents(); + })); + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(IgxDatePickerTestComponent); + fixture.detectChanges(); + container = fixture.componentInstance.container; + })); + + it('should render calendar', () => { + fixture = TestBed.createComponent(IgxDatePickerTestComponent); + fixture.detectChanges(); + const calendar = fixture.debugElement.query(By.directive(IgxCalendarComponent)); + expect(calendar).toBeDefined(); + }); + + it('should render default actions', () => { + spyOn(container.calendarClose, 'emit'); + spyOn(container.todaySelection, 'emit'); + container.closeButtonLabel = 'cancel'; + fixture.detectChanges(); + let buttons = fixture.debugElement.queryAll(By.directive(IgxButtonDirective)); + expect(buttons).toHaveSize(1); + expect(buttons[0].nativeElement.innerText).toEqual('cancel'); + buttons[0].triggerEventHandler('click', {}); + expect(container.calendarClose.emit).toHaveBeenCalledTimes(1); + + container.todayButtonLabel = 'ok'; + fixture.detectChanges(); + buttons = fixture.debugElement.queryAll(By.directive(IgxButtonDirective)); + expect(buttons).toHaveSize(2); + expect(buttons[1].nativeElement.innerText).toEqual('ok'); + buttons[1].triggerEventHandler('click', {}); + expect(container.todaySelection.emit).toHaveBeenCalledTimes(1); + }); + + it('should render default toggle and clear icons', () => { + spyOn(fixture.componentInstance, 'doWork'); + container.pickerActions = fixture.componentInstance.actions; + fixture.detectChanges(); + + const calendar = fixture.debugElement.query(By.directive(IgxCalendarComponent)).componentInstance; + const buttons = fixture.debugElement.queryAll(By.directive(IgxButtonDirective)); + expect(buttons).toHaveSize(1); + expect(buttons[0].nativeElement.innerText).toEqual('action'); + buttons[0].triggerEventHandler('click', {}); + expect(fixture.componentInstance.doWork).toHaveBeenCalledWith(calendar); + }); +}); + +@Component({ + template: ` + + + + + + `, + imports: [IgxCalendarContainerComponent, IgxPickerActionsDirective, IgxButtonDirective] +}) +export class IgxDatePickerTestComponent { + @ViewChild(IgxCalendarContainerComponent) public container: IgxCalendarContainerComponent; + @ViewChild(IgxPickerActionsDirective) public actions: IgxPickerActionsDirective; + public doWork = (_calendar: any) => {}; +} diff --git a/projects/igniteui-angular/date-picker/src/date-picker/calendar-container/calendar-container.component.ts b/projects/igniteui-angular/date-picker/src/date-picker/calendar-container/calendar-container.component.ts new file mode 100644 index 00000000000..a4a904676cc --- /dev/null +++ b/projects/igniteui-angular/date-picker/src/date-picker/calendar-container/calendar-container.component.ts @@ -0,0 +1,76 @@ +import { NgTemplateOutlet } from '@angular/common'; +import { + Component, + ViewChild, + Output, EventEmitter, + HostListener, + HostBinding +} from '@angular/core'; +import { IgxButtonDirective, IgxRippleDirective } from 'igniteui-angular/directives'; +import { IgxCalendarComponent } from 'igniteui-angular/calendar'; +import { IgxDividerDirective } from 'igniteui-angular/directives'; +import { IBaseEventArgs, DateRange, CustomDateRange, PickerInteractionMode, IDateRangePickerResourceStrings, IgxPickerActionsDirective } from 'igniteui-angular/core'; +import { IgxPredefinedRangesAreaComponent } from '../../date-range-picker/predefined-ranges/predefined-ranges-area.component'; + +/** @hidden */ +@Component({ + selector: 'igx-calendar-container', + styles: [':host {display: block;}'], + templateUrl: 'calendar-container.component.html', + imports: [ + IgxButtonDirective, + IgxRippleDirective, + IgxCalendarComponent, + NgTemplateOutlet, + IgxDividerDirective, + IgxPredefinedRangesAreaComponent + ] +}) +export class IgxCalendarContainerComponent { + @ViewChild(IgxCalendarComponent, { static: true }) + public calendar: IgxCalendarComponent; + + @Output() + public calendarClose = new EventEmitter(); + + @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'; + + @HostBinding('class.igx-date-picker--dropdown') + public get dropdownCSS(): boolean { + 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; + + @HostListener('keydown.alt.arrowup', ['$event']) + public onEscape(event) { + event.preventDefault(); + this.calendarClose.emit(); + } + + public get isReadonly() { + return this.mode === PickerInteractionMode.Dialog; + } +} + +/** @hidden */ + diff --git a/projects/igniteui-angular/date-picker/src/date-picker/date-picker.common.ts b/projects/igniteui-angular/date-picker/src/date-picker/date-picker.common.ts new file mode 100644 index 00000000000..108572a4589 --- /dev/null +++ b/projects/igniteui-angular/date-picker/src/date-picker/date-picker.common.ts @@ -0,0 +1,10 @@ +import { IBaseEventArgs } from 'igniteui-angular/core'; + +/** + * Provides information about date picker reference and its previously valid value + * when onValidationFailed event is fired. + */ +export interface IDatePickerValidationFailedEventArgs extends IBaseEventArgs { + prevValue: Date | string; + currentValue: Date | string; +} diff --git a/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.html b/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.html new file mode 100644 index 00000000000..551816ddb1c --- /dev/null +++ b/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.html @@ -0,0 +1,44 @@ + + @if (!toggleComponents.length) { + + + + + } + + + + @if (!clearComponents.length && value) { + + + + } + + + + + + + + + + + + + + diff --git a/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.spec.ts b/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.spec.ts new file mode 100644 index 00000000000..c0433316246 --- /dev/null +++ b/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.spec.ts @@ -0,0 +1,1822 @@ +import { ComponentFixture, fakeAsync, flush, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { UntypedFormControl, UntypedFormGroup, FormsModule, NgForm, ReactiveFormsModule, Validators } from '@angular/forms'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { + IgxHintDirective, IgxInputGroupComponent, IgxInputState, IgxLabelDirective, IgxPrefixDirective, IgxSuffixDirective +} from '../../../input-group/src/public_api'; +import { IFormattingViews, IgxCalendarComponent, IgxCalendarHeaderTemplateDirective, IgxCalendarHeaderTitleTemplateDirective } from '../../../calendar/src/public_api'; +import { IgxCalendarContainerComponent } from './calendar-container/calendar-container.component'; +import { IgxDatePickerComponent } from './date-picker.component'; +import { + IgxOverlayOutletDirective, + IgxOverlayService, + OverlayCancelableEventArgs, OverlayClosingEventArgs, OverlayEventArgs, OverlaySettings, + WEEKDAYS +} from 'igniteui-angular/core'; +import { ChangeDetectorRef, Component, DebugElement, ElementRef, EventEmitter, Injector, QueryList, Renderer2, ViewChild } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { PickerCalendarOrientation, PickerHeaderOrientation, PickerInteractionMode } from '../../../core/src/date-common/types'; +import { DatePart } from '../../../core/src/date-common/public_api'; +import { DateRangeDescriptor, DateRangeType } from 'igniteui-angular/core'; +import { IgxPickerClearComponent, IgxPickerToggleComponent } from '../../../core/src/date-common/public_api'; +import { DateTimeUtil } from '../../../core/src/date-common/util/date-time.util'; +import { registerLocaleData } from "@angular/common"; +import localeES from "@angular/common/locales/es"; +import localeBg from "@angular/common/locales/bg"; +import { IgxDateTimeEditorDirective } from '../../../directives/src/directives/date-time-editor/date-time-editor.directive'; + +const CSS_CLASS_DATE_PICKER = 'igx-date-picker'; + +const DATE_PICKER_TOGGLE_ICON = 'calendar_today'; +const DATE_PICKER_CLEAR_ICON = 'clear'; + +const CSS_CLASS_INPUT_GROUP_REQUIRED = 'igx-input-group--required'; +const CSS_CLASS_INPUT_GROUP_INVALID = 'igx-input-group--invalid'; +const CSS_CLASS_CALENDAR_HEADER = '.igx-calendar__header'; +const CSS_CLASS_CALENDAR_WRAPPER_VERTICAL = 'igx-calendar__wrapper--vertical'; + +describe('IgxDatePicker', () => { + describe('Integration tests', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxDatePickerTestKbrdComponent, + IgxDatePickerTestComponent, + IgxDatePickerNgModelComponent, + IgxDatePickerWithProjectionsComponent, + IgxDatePickerWithTemplatesComponent, + IgxDatePickerInFormComponent, + IgxDatePickerReactiveFormComponent + ] + }).compileComponents(); + })); + + describe('Rendering', () => { + let fixture: ComponentFixture; + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(IgxDatePickerTestComponent); + fixture.detectChanges(); + })); + + it('Should render default toggle and clear icons', () => { + const inputGroup = fixture.debugElement.query(By.directive(IgxInputGroupComponent)); + const prefix = inputGroup.queryAll(By.directive(IgxPrefixDirective)); + expect(prefix).toHaveSize(1); + expect(prefix[0].nativeElement.innerText).toEqual(DATE_PICKER_TOGGLE_ICON); + const suffix = inputGroup.queryAll(By.directive(IgxSuffixDirective)); + expect(suffix).toHaveSize(1); + expect(suffix[0].nativeElement.innerText).toEqual(DATE_PICKER_CLEAR_ICON); + }); + + it('should hide the calendar header if hideHeader is true in dialog mode', fakeAsync(() => { + const datePicker = fixture.componentInstance.datePicker; + datePicker.mode = 'dialog'; + datePicker.hideHeader = true; + datePicker.open(); + tick(); + fixture.detectChanges(); + + expect(datePicker['_calendar'].hasHeader).toBeFalse(); + const calendarHeader = fixture.debugElement.query(By.css(CSS_CLASS_CALENDAR_HEADER)); + expect(calendarHeader).toBeFalsy('Calendar header should not be present'); + })); + + it('should set calendar orientation property', fakeAsync(() => { + const datePicker = fixture.componentInstance.datePicker; + datePicker.orientation = PickerCalendarOrientation.Horizontal; + datePicker.open(); + tick(); + fixture.detectChanges(); + + expect(datePicker['_calendar'].orientation).toEqual(PickerCalendarOrientation.Horizontal.toString()); + expect(datePicker['_calendar'].wrapper.nativeElement).not.toHaveClass(CSS_CLASS_CALENDAR_WRAPPER_VERTICAL); + datePicker.close(); + tick(); + fixture.detectChanges(); + + datePicker.orientation = PickerCalendarOrientation.Vertical; + datePicker.open(); + tick(); + fixture.detectChanges(); + + expect(datePicker['_calendar'].orientation).toEqual(PickerCalendarOrientation.Vertical.toString()); + expect(datePicker['_calendar'].wrapper.nativeElement).toHaveClass(CSS_CLASS_CALENDAR_WRAPPER_VERTICAL); + })); + + it('should initialize activeDate with current date, when not set', fakeAsync(() => { + const datePicker = fixture.componentInstance.datePicker; + datePicker.value = null; + fixture.detectChanges(); + const todayDate = new Date(); + const today = new Date(todayDate.setHours(0, 0, 0, 0)).getTime().toString(); + + expect(datePicker.activeDate).toEqual(todayDate); + + datePicker.open(); + fixture.detectChanges(); + + expect(datePicker['_calendar'].activeDate).toEqual(todayDate); + expect(datePicker['_calendar'].value).toBeUndefined(); + const wrapper = fixture.debugElement.query(By.css('.igx-calendar__wrapper')).nativeElement; + expect(wrapper.getAttribute('aria-activedescendant')).toEqual(today); + })); + + it('should initialize activeDate = value when it is not set, but value is', fakeAsync(() => { + const datePicker = fixture.componentInstance.datePicker; + const date = fixture.componentInstance.date; + + expect(datePicker.activeDate).toEqual(date); + datePicker.open(); + fixture.detectChanges(); + + const activeDescendantDate = new Date(date.setHours(0, 0, 0, 0)).getTime().toString(); + expect(datePicker['_calendar'].activeDate).toEqual(date); + expect(datePicker['_calendar'].value).toEqual(date); + const wrapper = fixture.debugElement.query(By.css('.igx-calendar__wrapper')).nativeElement; + expect(wrapper.getAttribute('aria-activedescendant')).toEqual(activeDescendantDate); + })); + + it('should set activeDate correctly', fakeAsync(() => { + const datePicker = fixture.componentInstance.datePicker; + const targetDate = new Date(2025, 0, 1); + datePicker.activeDate = new Date(targetDate); + fixture.detectChanges(); + + expect(datePicker.activeDate).toEqual(targetDate); + expect(datePicker.value).toEqual(fixture.componentInstance.date); + + datePicker.open(); + fixture.detectChanges(); + + const activeDescendantDate = new Date(targetDate.setHours(0, 0, 0, 0)).getTime().toString(); + expect(datePicker['_calendar'].activeDate).toEqual(targetDate); + expect(datePicker['_calendar'].viewDate.getMonth()).toEqual(targetDate.getMonth()); + expect(datePicker['_calendar'].value).toEqual(fixture.componentInstance.date); + const wrapper = fixture.debugElement.query(By.css('.igx-calendar__wrapper')).nativeElement; + expect(wrapper.getAttribute('aria-activedescendant')).toEqual(activeDescendantDate); + })); + + it('should set activeDate of the calendar to value of picker even when it is outside the enabled range, i.e. > maxValue', fakeAsync(() => { + const datePicker = fixture.componentInstance.datePicker; + const maxDate = new Date(2025, 7, 1); + datePicker.maxValue = maxDate; + fixture.detectChanges(); + + const valueGreaterThanMax = new Date(2025, 10, 1); + datePicker.value = valueGreaterThanMax; + fixture.detectChanges(); + + expect(datePicker.activeDate).toEqual(valueGreaterThanMax); + + datePicker.open(); + fixture.detectChanges(); + + const activeDescendantDate = new Date(valueGreaterThanMax.setHours(0, 0, 0, 0)).getTime().toString(); + expect(datePicker['_calendar'].activeDate).toEqual(valueGreaterThanMax); + expect(datePicker['_calendar'].viewDate.getMonth()).toEqual(valueGreaterThanMax.getMonth()); + expect(datePicker['_calendar'].value).toEqual(valueGreaterThanMax); + const wrapper = fixture.debugElement.query(By.css('.igx-calendar__wrapper')).nativeElement; + expect(wrapper.getAttribute('aria-activedescendant')).toEqual(activeDescendantDate); + })); + }); + + describe('Events', () => { + let fixture: ComponentFixture; + let datePicker: IgxDatePickerComponent; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(IgxDatePickerTestComponent); + fixture.detectChanges(); + datePicker = fixture.componentInstance.datePicker; + })); + + it('should be able to cancel opening/closing', fakeAsync(() => { + spyOn(datePicker.opening, 'emit').and.callThrough(); + spyOn(datePicker.opened, 'emit').and.callThrough(); + spyOn(datePicker.closing, 'emit').and.callThrough(); + spyOn(datePicker.closed, 'emit').and.callThrough(); + + const openingSub = datePicker.opening.subscribe((event) => event.cancel = true); + + datePicker.open(); + // wait for calendar animation.done timeout + tick(350); + fixture.detectChanges(); + expect(datePicker.collapsed).toBeTruthy(); + expect(datePicker.opening.emit).toHaveBeenCalled(); + expect(datePicker.opened.emit).not.toHaveBeenCalled(); + + openingSub.unsubscribe(); + + const closingSub = datePicker.closing.subscribe((event) => event.cancel = true); + + datePicker.open(); + // wait for calendar animation.done timeout + tick(350); + fixture.detectChanges(); + + datePicker.close(); + tick(350); + fixture.detectChanges(); + expect(datePicker.collapsed).toBeFalsy(); + expect(datePicker.closing.emit).toHaveBeenCalled(); + expect(datePicker.closed.emit).not.toHaveBeenCalled(); + + closingSub.unsubscribe(); + (datePicker as any)._overlayService.detachAll(); + })); + }); + + describe('Keyboard navigation', () => { + let fixture: ComponentFixture; + let datePicker: IgxDatePickerComponent; + let inputGroup: DebugElement; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(IgxDatePickerTestKbrdComponent); + fixture.detectChanges(); + datePicker = fixture.componentInstance.datePicker; + inputGroup = fixture.debugElement.query(By.directive(IgxInputGroupComponent)); + })); + + it('should toggle the calendar with ALT + DOWN/UP ARROW key', fakeAsync(() => { + spyOn(datePicker.opening, 'emit').and.callThrough(); + spyOn(datePicker.opened, 'emit').and.callThrough(); + spyOn(datePicker.closing, 'emit').and.callThrough(); + spyOn(datePicker.closed, 'emit').and.callThrough(); + expect(datePicker.collapsed).toBeTruthy(); + expect(datePicker.isFocused).toBeFalse(); + + const picker = fixture.debugElement.query(By.css(CSS_CLASS_DATE_PICKER)); + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', picker, true); + + tick(); + fixture.detectChanges(); + + expect(datePicker.collapsed).toBeFalsy(); + expect(datePicker.opening.emit).toHaveBeenCalledTimes(1); + expect(datePicker.opened.emit).toHaveBeenCalledTimes(1); + + const calendarWrapper = fixture.debugElement.query(By.css('.igx-calendar__wrapper')).nativeElement; + expect(calendarWrapper.contains(document.activeElement)) + .withContext('focus should move to calendar for KB nav') + .toBeTrue(); + expect(datePicker.isFocused).toBeTrue(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', calendarWrapper, true, true); + tick(350); + fixture.detectChanges(); + expect(datePicker.collapsed).toBeTruthy(); + expect(datePicker.closing.emit).toHaveBeenCalledTimes(1); + expect(datePicker.closed.emit).toHaveBeenCalledTimes(1); + expect(inputGroup.nativeElement.contains(document.activeElement)) + .withContext('focus should return to the picker input') + .toBeTrue(); + expect(datePicker.isFocused).toBeTrue(); + })); + + it('should open the calendar with SPACE key', fakeAsync(() => { + spyOn(datePicker.opening, 'emit').and.callThrough(); + spyOn(datePicker.opened, 'emit').and.callThrough(); + expect(datePicker.collapsed).toBeTruthy(); + + const picker = fixture.debugElement.query(By.css(CSS_CLASS_DATE_PICKER)); + UIInteractions.triggerEventHandlerKeyDown(' ', picker); + + tick(350); + fixture.detectChanges(); + + expect(datePicker.collapsed).toBeFalsy(); + expect(datePicker.opening.emit).toHaveBeenCalledTimes(1); + expect(datePicker.opened.emit).toHaveBeenCalledTimes(1); + + // wait datepicker to get destroyed and test to cleanup + tick(350); + })); + + it('should close the calendar with ESC', fakeAsync(() => { + datePicker.mode = 'dropdown'; + spyOn(datePicker.closing, 'emit').and.callThrough(); + spyOn(datePicker.closed, 'emit').and.callThrough(); + + expect(datePicker.collapsed).toBeTruthy(); + datePicker.open(); + tick(); + fixture.detectChanges(); + + expect(datePicker.collapsed).toBeFalsy(); + const calendarWrapper = fixture.debugElement.query(By.css('.igx-calendar__wrapper')).nativeElement; + expect(calendarWrapper.contains(document.activeElement)) + .withContext('focus should move to calendar for KB nav') + .toBeTrue(); + expect(datePicker.isFocused).toBeTrue(); + + + UIInteractions.triggerKeyDownEvtUponElem('Escape', calendarWrapper, true); + tick(350); + fixture.detectChanges(); + + expect(datePicker.collapsed).toBeTruthy(); + expect(datePicker.closing.emit).toHaveBeenCalledTimes(1); + expect(datePicker.closed.emit).toHaveBeenCalledTimes(1); + expect(inputGroup.nativeElement.contains(document.activeElement)) + .withContext('focus should return to the picker input') + .toBeTrue(); + expect(datePicker.isFocused).toBeTrue(); + })); + + it('should update the calendar selection on typing', fakeAsync(() => { + const date = new Date(2025, 0, 1); + datePicker.value = date; + datePicker.open(); + fixture.detectChanges(); + + const input = fixture.debugElement.query(By.css('.igx-input-group__input')); + input.nativeElement.focus(); + tick(); + fixture.detectChanges(); + + fixture.detectChanges(); + UIInteractions.simulateTyping('02/01/2025', input); + + const expectedDate = new Date(2025, 0, 2); + expect(datePicker.value).toEqual(expectedDate); + expect(datePicker.activeDate).toEqual(expectedDate); + + const activeDescendantDate = new Date(expectedDate.setHours(0, 0, 0, 0)).getTime().toString(); + expect(datePicker['_calendar'].activeDate).toEqual(expectedDate); + expect(datePicker['_calendar'].viewDate.getMonth()).toEqual(expectedDate.getMonth()); + expect(datePicker['_calendar'].value).toEqual(expectedDate); + const wrapper = fixture.debugElement.query(By.css('.igx-calendar__wrapper')).nativeElement; + expect(wrapper.getAttribute('aria-activedescendant')).toEqual(activeDescendantDate); + })); + + it('should update the calendar view and active date on typing a date that is not in the current view', fakeAsync(() => { + const date = new Date(2025, 0, 1); + datePicker.value = date; + datePicker.open(); + fixture.detectChanges(); + + const input = fixture.debugElement.query(By.css('.igx-input-group__input')); + input.nativeElement.focus(); + tick(); + fixture.detectChanges(); + + fixture.detectChanges(); + UIInteractions.simulateTyping('02/11/2025', input); + + const expectedDate = new Date(2025, 10, 2); + expect(datePicker.value).toEqual(expectedDate); + expect(datePicker.activeDate).toEqual(expectedDate); + + const activeDescendantDate = new Date(expectedDate.setHours(0, 0, 0, 0)).getTime().toString(); + expect(datePicker['_calendar'].activeDate).toEqual(expectedDate); + expect(datePicker['_calendar'].viewDate.getMonth()).toEqual(expectedDate.getMonth()); + expect(datePicker['_calendar'].value).toEqual(expectedDate); + const wrapper = fixture.debugElement.query(By.css('.igx-calendar__wrapper')).nativeElement; + expect(wrapper.getAttribute('aria-activedescendant')).toEqual(activeDescendantDate); + })); + }); + + describe('NgControl integration', () => { + let fixture: ComponentFixture; + let datePicker: IgxDatePickerComponent; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(IgxDatePickerNgModelComponent); + fixture.detectChanges(); + datePicker = fixture.componentInstance.datePicker; + })); + + it('should initialize date picker with required correctly', () => { + const inputGroup = (datePicker as any).inputGroup; + + expect(datePicker).toBeDefined(); + expect(inputGroup.isRequired).toBeTruthy(); + }); + + it('should update inputGroup isRequired correctly', () => { + const inputGroup = (datePicker as any).inputGroup; + + expect(datePicker).toBeDefined(); + expect(inputGroup.isRequired).toBeTruthy(); + + (fixture.componentInstance as IgxDatePickerNgModelComponent).isRequired = false; + fixture.detectChanges(); + + expect(inputGroup.isRequired).toBeFalsy(); + }); + + it('should set validity to initial when the form is reset', fakeAsync(() => { + fixture = TestBed.createComponent(IgxDatePickerInFormComponent); + fixture.detectChanges(); + datePicker = fixture.componentInstance.datePicker; + + const input = document.getElementsByClassName('igx-input-group__input')[0] as HTMLInputElement; + input.focus(); + tick(); + fixture.detectChanges(); + + datePicker.clear(); + expect((datePicker as any).inputDirective.valid).toEqual(IgxInputState.INVALID); + + (fixture.componentInstance as IgxDatePickerInFormComponent).form.resetForm(); + tick(); + expect((datePicker as any).inputDirective.valid).toEqual(IgxInputState.INITIAL); + })); + + it('should apply asterix properly when required validator is set dynamically', () => { + fixture = TestBed.createComponent(IgxDatePickerReactiveFormComponent); + fixture.detectChanges(); + datePicker = fixture.componentInstance.datePicker; + + let inputGroupRequiredClass = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT_GROUP_REQUIRED)); + let inputGroupInvalidClass = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT_GROUP_INVALID)); + // let asterisk = window. + // getComputedStyle(fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT_GROUP_LABEL)).nativeElement, ':after'). + // content; + // expect(asterisk).toBe('"*"'); + expect(inputGroupRequiredClass).toBeDefined(); + expect(inputGroupRequiredClass).not.toBeNull(); + + datePicker.clear(); + fixture.detectChanges(); + + inputGroupInvalidClass = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT_GROUP_INVALID)); + expect(inputGroupInvalidClass).not.toBeNull(); + expect(inputGroupInvalidClass).not.toBeUndefined(); + + inputGroupRequiredClass = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT_GROUP_REQUIRED)); + expect(inputGroupRequiredClass).not.toBeNull(); + expect(inputGroupRequiredClass).not.toBeUndefined(); + + (fixture.componentInstance as IgxDatePickerReactiveFormComponent).removeValidators(); + fixture.detectChanges(); + + inputGroupRequiredClass = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT_GROUP_REQUIRED)); + // asterisk = window. + // getComputedStyle(fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT_GROUP_LABEL)).nativeElement, ':after'). + // content; + expect(inputGroupRequiredClass).toBeNull(); + // expect(asterisk).toBe('none'); + + (fixture.componentInstance as IgxDatePickerReactiveFormComponent).addValidators(); + fixture.detectChanges(); + + inputGroupRequiredClass = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT_GROUP_REQUIRED)); + // asterisk = window. + // getComputedStyle(fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT_GROUP_LABEL)).nativeElement, ':after'). + // content; + expect(inputGroupRequiredClass).toBeDefined(); + expect(inputGroupRequiredClass).not.toBeNull(); + // expect(asterisk).toBe('"*"'); + }); + + it('Should the weekStart property takes precedence over locale.', fakeAsync(() => { + fixture = TestBed.createComponent(IgxDatePickerReactiveFormComponent); + fixture.detectChanges(); + datePicker = fixture.componentInstance.datePicker; + + datePicker.locale = 'en'; + fixture.detectChanges(); + + expect(datePicker.weekStart).toEqual(0); + + datePicker.weekStart = WEEKDAYS.FRIDAY; + expect(datePicker.weekStart).toEqual(5); + + datePicker.locale = 'fr'; + fixture.detectChanges(); + + expect(datePicker.weekStart).toEqual(5); + + flush(); + })); + + it('Should passing invalid value for locale, then setting weekStart must be respected.', fakeAsync(() => { + fixture = TestBed.createComponent(IgxDatePickerReactiveFormComponent); + fixture.detectChanges(); + datePicker = fixture.componentInstance.datePicker; + + const locale = 'en-US'; + datePicker.locale = locale; + fixture.detectChanges(); + + expect(datePicker.locale).toEqual(locale); + expect(datePicker.weekStart).toEqual(WEEKDAYS.SUNDAY) + + datePicker.locale = 'frrr'; + datePicker.weekStart = WEEKDAYS.FRIDAY; + fixture.detectChanges(); + + expect(datePicker.locale).toEqual('en-US'); + expect(datePicker.weekStart).toEqual(WEEKDAYS.FRIDAY); + })); + + it('should set initial validity state when the form group is disabled', () => { + fixture = TestBed.createComponent(IgxDatePickerReactiveFormComponent); + fixture.detectChanges(); + datePicker = fixture.componentInstance.datePicker; + + (fixture.componentInstance as IgxDatePickerReactiveFormComponent).markAsTouched(); + fixture.detectChanges(); + expect((datePicker as any).inputDirective.valid).toBe(IgxInputState.INVALID); + + (fixture.componentInstance as IgxDatePickerReactiveFormComponent).disableForm(); + fixture.detectChanges(); + expect((datePicker as any).inputDirective.valid).toBe(IgxInputState.INITIAL); + }); + + it('should update validity state when programmatically setting errors on reactive form controls', () => { + fixture = TestBed.createComponent(IgxDatePickerReactiveFormComponent); + fixture.detectChanges(); + datePicker = fixture.componentInstance.datePicker; + const form = (fixture.componentInstance as IgxDatePickerReactiveFormComponent).form as UntypedFormGroup; + + // the form control has validators + form.markAllAsTouched(); + form.get('date').setErrors({ error: true }); + fixture.detectChanges(); + + expect((datePicker as any).inputDirective.valid).toBe(IgxInputState.INVALID); + expect((datePicker as any).inputGroup.element.nativeElement.classList.contains(CSS_CLASS_INPUT_GROUP_INVALID)).toBe(true); + expect((datePicker as any).inputGroup.element.nativeElement.classList.contains(CSS_CLASS_INPUT_GROUP_REQUIRED)).toBe(true); + + // remove the validators and set errors + (fixture.componentInstance as IgxDatePickerReactiveFormComponent).removeValidators(); + form.markAsUntouched(); + fixture.detectChanges(); + + form.markAllAsTouched(); + form.get('date').setErrors({ error: true }); + fixture.detectChanges(); + + // no validator, but there is a set error + expect((datePicker as any).inputDirective.valid).toBe(IgxInputState.INVALID); + expect((datePicker as any).inputGroup.element.nativeElement.classList.contains(CSS_CLASS_INPUT_GROUP_INVALID)).toBe(true); + expect((datePicker as any).inputGroup.element.nativeElement.classList.contains(CSS_CLASS_INPUT_GROUP_REQUIRED)).toBe(false); + }); + }); + + describe('Projected elements', () => { + let fixture: ComponentFixture; + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(IgxDatePickerWithProjectionsComponent); + fixture.detectChanges(); + })); + + it('Should project label/hint and additional prefix/suffix in the correct location', () => { + fixture.componentInstance.datePicker.value = new Date(); + fixture.detectChanges(); + const inputGroup = fixture.debugElement.query(By.directive(IgxInputGroupComponent)); + + const label = inputGroup.queryAll(By.directive(IgxLabelDirective)); + expect(label).toHaveSize(1); + expect(label[0].nativeElement.innerText).toEqual('Label'); + const hint = inputGroup.queryAll(By.directive(IgxHintDirective)); + expect(hint).toHaveSize(1); + expect(hint[0].nativeElement.innerText).toEqual('Hint'); + + const prefix = inputGroup.queryAll(By.directive(IgxPrefixDirective)); + expect(prefix).toHaveSize(2); + expect(prefix[0].nativeElement.innerText).toEqual(DATE_PICKER_TOGGLE_ICON); + expect(prefix[1].nativeElement.innerText).toEqual('Prefix'); + const suffix = inputGroup.queryAll(By.directive(IgxSuffixDirective)); + expect(suffix).toHaveSize(2); + expect(suffix[0].nativeElement.innerText).toEqual(DATE_PICKER_CLEAR_ICON); + expect(suffix[1].nativeElement.innerText).toEqual('Suffix'); + }); + + it('Should project custom toggle/clear and hide defaults', () => { + fixture.componentInstance.datePicker.value = new Date(); + fixture.componentInstance.showCustomClear = true; + fixture.componentInstance.showCustomToggle = true; + fixture.detectChanges(); + const inputGroup = fixture.debugElement.query(By.directive(IgxInputGroupComponent)); + + const prefix = inputGroup.queryAll(By.directive(IgxPrefixDirective)); + expect(prefix).toHaveSize(2); + expect(prefix[0].nativeElement.innerText).toEqual('CustomToggle'); + expect(prefix[1].nativeElement.innerText).toEqual('Prefix'); + const suffix = inputGroup.queryAll(By.directive(IgxSuffixDirective)); + expect(suffix).toHaveSize(2); + expect(suffix[0].nativeElement.innerText).toEqual('CustomClear'); + expect(suffix[1].nativeElement.innerText).toEqual('Suffix'); + }); + + it('Should correctly sub/unsub to custom toggle and clear', () => { + const datePicker = fixture.componentInstance.datePicker; + datePicker.value = new Date(); + fixture.componentInstance.showCustomClear = true; + fixture.componentInstance.showCustomToggle = true; + fixture.detectChanges(); + spyOn(datePicker, 'open'); + spyOn(datePicker, 'clear'); + + const inputGroup = fixture.debugElement.query(By.directive(IgxInputGroupComponent)); + const toggleElem = inputGroup.query(By.directive(IgxPickerToggleComponent)); + const clearElem = inputGroup.query(By.directive(IgxPickerClearComponent)); + let toggle = fixture.componentInstance.customToggle; + let clear = fixture.componentInstance.customClear; + + expect(toggle.clicked.observers).toHaveSize(1); + expect(clear.clicked.observers).toHaveSize(1); + const event = jasmine.createSpyObj('event', ['stopPropagation']); + toggleElem.triggerEventHandler('click', event); + expect(datePicker.open).toHaveBeenCalledTimes(1); + clearElem.triggerEventHandler('click', event); + expect(datePicker.clear).toHaveBeenCalledTimes(1); + + // hide + fixture.componentInstance.showCustomToggle = false; + fixture.detectChanges(); + expect(toggle.clicked.observers).toHaveSize(0); + expect(clear.clicked.observers).toHaveSize(1); + fixture.componentInstance.showCustomClear = false; + fixture.detectChanges(); + expect(toggle.clicked.observers).toHaveSize(0); + expect(clear.clicked.observers).toHaveSize(0); + + // show again + fixture.componentInstance.showCustomClear = true; + fixture.componentInstance.showCustomToggle = true; + fixture.detectChanges(); + toggle = fixture.componentInstance.customToggle; + clear = fixture.componentInstance.customClear; + expect(toggle.clicked.observers).toHaveSize(1); + expect(clear.clicked.observers).toHaveSize(1); + + datePicker.ngOnDestroy(); + expect(toggle.clicked.observers).toHaveSize(0); + expect(clear.clicked.observers).toHaveSize(0); + }); + }); + + describe('Templated Header', () => { + let fixture: ComponentFixture; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [IgxDatePickerWithTemplatesComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(IgxDatePickerWithTemplatesComponent); + fixture.detectChanges(); + })); + + it('Should use the custom template for header title', fakeAsync(() => { + const testDate = new Date(2024, 10, 11); + fixture.componentInstance.datePicker.value = testDate; + fixture.componentInstance.datePicker.open(); + tick(); + fixture.detectChanges(); + + const headerTitleElement = fixture.debugElement.query(By.css('.igx-calendar__header-year')); + expect(headerTitleElement).toBeTruthy('Header title element should be present'); + if (headerTitleElement) { + expect(headerTitleElement.nativeElement.textContent.trim()).toBe('2024'); + } + })); + + it('Should use the custom template for header', fakeAsync(() => { + const testDate = new Date(2024, 10, 11); + fixture.componentInstance.datePicker.value = testDate; + fixture.componentInstance.datePicker.open(); + tick(); + fixture.detectChanges(); + + const headerElement = fixture.debugElement.query(By.css('.igx-calendar__header-date')); + expect(headerElement).toBeTruthy('Header element should be present'); + if (headerElement) { + expect(headerElement.nativeElement.textContent.trim()).toBe('Nov'); + } + })); + }); + + describe('UI Interaction', () => { + let fixture: ComponentFixture; + let datePicker: IgxDatePickerComponent; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(IgxDatePickerTestComponent); + fixture.detectChanges(); + datePicker = fixture.componentInstance.datePicker; + })); + + it('should activate today\'s date when reopening the calendar', fakeAsync(() => { + datePicker.clear(); + datePicker.open(); + expect(datePicker.value).toEqual(null); + expect(datePicker.collapsed).toBeFalsy(); + + datePicker.close(); + tick(); + fixture.detectChanges(); + expect(datePicker.collapsed).toBeTruthy(); + + datePicker.open(); + tick(); + fixture.detectChanges(); + expect(datePicker.collapsed).toBeFalsy(); + + const today = new Date(new Date().setHours(0, 0, 0, 0)).getTime().toString(); + const wrapper = fixture.debugElement.query(By.css('.igx-calendar__wrapper')).nativeElement; + expect(wrapper.getAttribute('aria-activedescendant')).toEqual(today); + expect(wrapper.contains(document.activeElement)) + .withContext('focus should move to calendar for KB nav') + .toBeTrue(); + })); + + it('should focus today\'s date when an invalid date is selected', fakeAsync(() => { + datePicker.clear(); + expect(datePicker.value).toEqual(null); + expect(datePicker.collapsed).toBeTruthy(); + + datePicker.select(new Date('test')); + datePicker.open(); + tick(); + fixture.detectChanges(); + expect(datePicker.collapsed).toBeFalsy(); + + const today = new Date(new Date().setHours(0, 0, 0, 0)).getTime().toString(); + const wrapper = fixture.debugElement.query(By.css('.igx-calendar__wrapper')).nativeElement; + expect(wrapper.getAttribute('aria-activedescendant')).toEqual(today); + expect(wrapper.contains(document.activeElement)) + .withContext('focus should move to calendar for KB nav') + .toBeTrue(); + })); + + it('should return focus to date picker input after calendar click select', fakeAsync(() => { + const inputGroup = fixture.debugElement.query(By.directive(IgxInputGroupComponent)).nativeElement; + + datePicker.clear(); + datePicker.open(); + tick(); + fixture.detectChanges(); + + const today = new Date(new Date().setHours(0, 0, 0, 0)); + const todayTime = today.getTime().toString(); + const todayDayItem = fixture.debugElement.query(By.css(`#${CSS.escape(todayTime)}`)).nativeElement; + + todayDayItem.click(); + tick(); + fixture.detectChanges(); + expect(datePicker.value).toEqual(today); + expect(inputGroup.contains(document.activeElement)) + .withContext('focus should return to the picker input') + .toBeTrue(); + })); + }); + + describe('Input and Display formats', () => { + let fixture: ComponentFixture; + let datePicker: IgxDatePickerComponent; + let dateTimeEditor: IgxDateTimeEditorDirective; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(IgxDatePickerTestComponent); + registerLocaleData(localeBg); + registerLocaleData(localeES); + fixture.detectChanges(); + datePicker = fixture.componentInstance.datePicker; + dateTimeEditor = fixture.debugElement.query(By.directive(IgxDateTimeEditorDirective)). + injector.get(IgxDateTimeEditorDirective); + })); + + it('should set default inputFormat, if none, to the editor with parts for day, month and year based on locale', () => { + datePicker.locale = 'en-US'; + fixture.detectChanges(); + expect(dateTimeEditor.inputFormat).toEqual('MM/dd/yyyy'); + + datePicker.locale = 'bg-BG'; + fixture.detectChanges(); + expect(dateTimeEditor.inputFormat.normalize('NFKC')).toEqual('dd.MM.yyyy г.'); + + datePicker.locale = 'es-ES'; + fixture.detectChanges(); + expect(dateTimeEditor.inputFormat).toEqual('dd/MM/yyyy'); + }); + + it('should resolve inputFormat, if not set, to the editor to the value of displayFormat if it contains only numeric date/time parts', fakeAsync(() => { + datePicker.locale = 'en-US'; + fixture.detectChanges(); + + datePicker.displayFormat = 'dd/MM/yyyy'; + fixture.detectChanges(); + expect(dateTimeEditor.inputFormat).toEqual('dd/MM/yyyy'); + + datePicker.displayFormat = 'shortDate'; + fixture.detectChanges(); + expect(dateTimeEditor.inputFormat).toEqual('MM/dd/yyyy'); + })); + + it('should resolve to the default locale-based input format for the editor in case inputFormat is not set and displayFormat contains non-numeric date/time parts', fakeAsync(() => { + datePicker.locale = 'en-US'; + datePicker.displayFormat = 'MMM d, y, h:mm:ss a'; + fixture.detectChanges(); + expect(dateTimeEditor.inputFormat).toEqual('MM/dd/yyyy'); + + datePicker.locale = 'bg-BG'; + datePicker.displayFormat = 'full'; + fixture.detectChanges(); + expect(dateTimeEditor.inputFormat.normalize('NFKC')).toEqual('dd.MM.yyyy г.'); + + datePicker.locale = 'es-ES'; + datePicker.displayFormat = 'MMM d, y'; + fixture.detectChanges(); + expect(dateTimeEditor.inputFormat).toEqual('dd/MM/yyyy'); + })); + }); + }); + + describe('Unit Tests', () => { + let overlay: IgxOverlayService; + let mockOverlayEventArgs: OverlayEventArgs & OverlayCancelableEventArgs; + let mockInjector; + let mockCdr; + let mockInputGroup: Partial; + let datePicker: IgxDatePickerComponent; + let mockDateEditor: any; + let mockCalendar: Partial; + let mockInputDirective: any; + const viewsContainerRef = {} as any; + const mockOverlayId = '1'; + const today = new Date(); + const elementRef = { + nativeElement: jasmine.createSpyObj('mockElement', ['blur', 'click', 'focus']) + }; + let mockNgControl: any; + let mockControlInstance: any; + let renderer2: Renderer2; + + beforeEach(() => { + renderer2 = jasmine.createSpyObj('Renderer2', ['setAttribute'], [{}, 'aria-labelledby', 'test-label-id-1']); + mockControlInstance = { + _touched: false, + get touched() { + return this._touched; + }, + set touched(val: boolean) { + this._touched = val; + }, + _dirty: false, + get dirty() { + return this._dirty; + }, + set dirty(val: boolean) { + this._dirty = val; + }, + _asyncValidator: () => { }, + get asyncValidator() { + return this._asyncValidator; + }, + set asyncValidator(val: () => boolean) { + this._asyncValidator = val; + }, + _validator: () => { }, + get validator() { + return this._validator; + }, + set validator(val: () => boolean) { + this._validator = val; + } + }; + mockNgControl = { + registerOnChangeCb: () => { }, + registerOnTouchedCb: () => { }, + registerOnValidatorChangeCb: () => { }, + statusChanges: new EventEmitter(), + _control: mockControlInstance, + get control() { + return this._control; + }, + set control(val: any) { + this._control = val; + }, + valid: true + }; + mockInjector = jasmine.createSpyObj('Injector', { + get: mockNgControl + }); + + mockCdr = jasmine.createSpyObj('ChangeDetectorRef', ['detectChanges']); + + mockCalendar = { selected: new EventEmitter(), selectDate: () => {} }; + const mockComponentInstance = { + calendar: mockCalendar, + todaySelection: new EventEmitter(), + calendarClose: new EventEmitter() + }; + const mockComponentRef = { + instance: mockComponentInstance, + location: { nativeElement: undefined } + } as any; + mockOverlayEventArgs = { + id: mockOverlayId, + componentRef: mockComponentRef, + cancel: false + }; + overlay = { + opening: new EventEmitter(), + opened: new EventEmitter(), + closed: new EventEmitter(), + closing: new EventEmitter(), + show(..._args) { + this.opening.emit(Object.assign({}, mockOverlayEventArgs, { cancel: false })); + this.opened.emit(mockOverlayEventArgs); + }, + hide(..._args) { + this.closing.emit(Object.assign({}, mockOverlayEventArgs, { cancel: false })); + this.closed.emit(mockOverlayEventArgs); + }, + detach: (..._args) => { }, + attach: (..._args) => mockOverlayId + } as any; + mockDateEditor = { + _value: null, + get value() { + return this._value; + }, + clear() { + this.valueChange.emit(null); + }, + set value(val: any) { + this._value = val; + }, + valueChange: new EventEmitter(), + validationFailed: new EventEmitter() + }; + mockInputGroup = { + _isFocused: false, + get isFocused() { + return this._isFocused; + }, + set isFocused(val: boolean) { + this._isFocused = val; + }, + _isRequired: false, + get isRequired() { + return this._isRequired; + }, + set isRequired(val: boolean) { + this._isRequired = val; + }, + element: { + nativeElement: jasmine.createSpyObj('mockElement', + ['focus', 'blur', 'click', 'addEventListener', 'removeEventListener']) + } + } as any; + mockInputDirective = { + valid: IgxInputState.INITIAL, + nativeElement: { + _listeners: { + none: [] + }, + addEventListener(event: string, cb: () => void) { + let target = this._listeners[event]; + if (!target) { + this._listeners[event] = []; + target = this._listeners[event]; + } + target.push(cb); + }, + removeEventListener(event: string, cb: () => void) { + const target = this._listeners[event]; + if (!target) { + return; + } + const index = target.indexOf(cb); + if (index !== -1) { + target.splice(index, 1); + } + }, + dispatchEvent(event: string) { + const target = this._listeners[event]; + if (!target) { + return; + } + target.forEach(e => { + e(); + }); + }, + focus() { + this.dispatchEvent('focus'); + }, + click() { + this.dispatchEvent('click'); + }, + blur() { + this.dispatchEvent('blur'); + } + }, + focus: () => { } + }; + + TestBed.configureTestingModule({ + providers: [ + { provide: ElementRef, useValue: elementRef }, + { provide: IgxOverlayService, useValue: overlay }, + { provide: Injector, useValue: mockInjector }, + { provide: Renderer2, useValue: renderer2 }, + { provide: ChangeDetectorRef, useValue: mockCdr }, + IgxDatePickerComponent + ] + }); + + datePicker = TestBed.inject(IgxDatePickerComponent); + (datePicker as any).inputGroup = mockInputGroup; + (datePicker as any).inputDirective = mockInputDirective; + (datePicker as any).dateTimeEditor = mockDateEditor; + (datePicker as any).viewContainerRef = viewsContainerRef; + // TODO: TEMP workaround for afterViewInit call in unit tests: + datePicker.clearComponents = new QueryList(); + datePicker.toggleComponents = new QueryList(); + }); + + afterEach(() => { + datePicker?.ngOnDestroy(); + UIInteractions.clearOverlay(); + }); + describe('API tests', () => { + registerLocaleData(localeES); + it('Should initialize and update all inputs properly', () => { + // no ngControl initialized + expect(datePicker.required).toEqual(false); + datePicker.ngOnInit(); + datePicker.ngAfterViewInit(); + expect(datePicker.collapsed).toBeTruthy(); + expect(datePicker.disabled).toBeFalsy(); + expect(datePicker.disabledDates).toEqual(null); + expect(datePicker.displayFormat).toEqual(undefined); + expect(datePicker.calendarFormat).toEqual(undefined); + expect(datePicker.displayMonthsCount).toEqual(1); + expect(datePicker.formatViews).toEqual(undefined); + expect(datePicker.headerOrientation).toEqual(PickerHeaderOrientation.Horizontal); + expect(datePicker.hideOutsideDays).toEqual(undefined); + expect(datePicker.inputFormat).toEqual(undefined); + expect(datePicker.mode).toEqual(PickerInteractionMode.DropDown); + expect(datePicker.isDropdown).toEqual(true); + expect(datePicker.minValue).toEqual(undefined); + expect(datePicker.maxValue).toEqual(undefined); + expect(datePicker.outlet).toEqual(undefined); + expect(datePicker.specialDates).toEqual(null); + expect(datePicker.spinDelta).toEqual(undefined); + expect(datePicker.spinLoop).toEqual(true); + expect(datePicker.tabIndex).toEqual(undefined); + expect(datePicker.overlaySettings).toEqual(undefined); + expect(datePicker.locale).toEqual('en-US'); + expect(datePicker.placeholder).toEqual(''); + expect(datePicker.readOnly).toEqual(false); + expect(datePicker.value).toEqual(undefined); + expect(datePicker.formatter).toEqual(undefined); + expect(() => datePicker.displayValue.transform(today)).toThrow(); + // set + datePicker.open(); + overlay.opened.emit(mockOverlayEventArgs); + expect(datePicker.collapsed).toBeFalsy(); + datePicker.disabled = true; + expect(datePicker.disabled).toBeTruthy(); + datePicker.disabled = false; + datePicker.setDisabledState(true); + expect(datePicker.disabled).toBeTruthy(); + datePicker.disabled = false; + const mockDisabledDates: DateRangeDescriptor[] = [{ type: DateRangeType.Weekdays }, + { type: DateRangeType.Before, dateRange: [today] }]; + datePicker.disabledDates = mockDisabledDates; + expect(datePicker.disabledDates).toEqual(mockDisabledDates); + datePicker.displayFormat = 'MM/yy/DD'; + expect(datePicker.displayFormat).toEqual('MM/yy/DD'); + datePicker.displayMonthsCount = Infinity; + expect(datePicker.displayMonthsCount).toEqual(Infinity); + datePicker.displayMonthsCount = 0; + expect(datePicker.displayMonthsCount).toEqual(0); + datePicker.displayMonthsCount = 12; + expect(datePicker.displayMonthsCount).toEqual(12); + let newFormat: any = { day: '2-digit' }; + datePicker.calendarFormat = newFormat; + // this SHOULD NOT mutate the underlying base settings + expect((datePicker as any).pickerCalendarFormat).toEqual({ + day: '2-digit', + month: 'short', + weekday: 'short', + year: 'numeric' + }); + newFormat = { month: 'numeric' }; + datePicker.calendarFormat = newFormat; + expect((datePicker as any).pickerCalendarFormat).toEqual({ + day: 'numeric', + month: 'numeric', + weekday: 'short', + year: 'numeric' + }); + datePicker.formatViews = null; + expect((datePicker as any).pickerFormatViews).toEqual({ day: false, month: true, year: false }); + const formatViewVal: IFormattingViews = {}; + datePicker.formatViews = formatViewVal; + expect((datePicker as any).pickerFormatViews).toEqual({ day: false, month: true, year: false }); + formatViewVal.day = true; + datePicker.formatViews = formatViewVal; + expect((datePicker as any).pickerFormatViews).toEqual({ day: true, month: true, year: false }); + formatViewVal.year = true; + datePicker.formatViews = formatViewVal; + expect((datePicker as any).pickerFormatViews).toEqual({ day: true, month: true, year: true }); + formatViewVal.month = false; + datePicker.formatViews = formatViewVal; + expect((datePicker as any).pickerFormatViews).toEqual({ day: true, month: false, year: true }); + datePicker.headerOrientation = PickerHeaderOrientation.Vertical; + expect(datePicker.headerOrientation).toEqual(PickerHeaderOrientation.Vertical); + datePicker.hideOutsideDays = false; + expect(datePicker.hideOutsideDays).toEqual(false); + datePicker.hideOutsideDays = true; + expect(datePicker.hideOutsideDays).toEqual(true); + datePicker.inputFormat = 'dd/MM/YY'; + expect(datePicker.inputFormat).toEqual('dd/MM/YY'); + datePicker.mode = PickerInteractionMode.Dialog; + expect(datePicker.mode).toEqual(PickerInteractionMode.Dialog); + expect(datePicker.isDropdown).toEqual(false); + datePicker.minValue = 'Test'; + expect(datePicker.minValue).toEqual('Test'); + datePicker.minValue = today; + expect(datePicker.minValue).toEqual(today); + datePicker.minValue = '12/12/1998'; + expect(datePicker.minValue).toEqual('12/12/1998'); + datePicker.maxValue = 'Test'; + expect(datePicker.maxValue).toEqual('Test'); + datePicker.maxValue = today; + expect(datePicker.maxValue).toEqual(today); + datePicker.maxValue = '12/12/1998'; + expect(datePicker.maxValue).toEqual('12/12/1998'); + datePicker.outlet = null; + expect(datePicker.outlet).toEqual(null); + const mockEl: ElementRef = jasmine.createSpyObj('mockEl', ['nativeElement']); + datePicker.outlet = mockEl; + expect(datePicker.outlet).toEqual(mockEl); + const mockOverlayDirective: IgxOverlayOutletDirective = + jasmine.createSpyObj('mockEl', ['nativeElement']); + datePicker.outlet = mockOverlayDirective; + expect(datePicker.outlet).toEqual(mockOverlayDirective); + const specialDates: DateRangeDescriptor[] = [{ type: DateRangeType.Weekdays }, + { type: DateRangeType.Before, dateRange: [today] }]; + datePicker.specialDates = specialDates; + expect(datePicker.specialDates).toEqual(specialDates); + const spinDeltaSettings = { date: Infinity, month: Infinity }; + datePicker.spinDelta = spinDeltaSettings; + expect(datePicker.spinDelta).toEqual(spinDeltaSettings); + datePicker.spinLoop = false; + expect(datePicker.spinLoop).toEqual(false); + datePicker.tabIndex = 0; + expect(datePicker.tabIndex).toEqual(0); + datePicker.tabIndex = -1; + expect(datePicker.tabIndex).toEqual(-1); + const customSettings: OverlaySettings = { + modal: true, + closeOnEscape: true + }; + datePicker.overlaySettings = customSettings; + expect(datePicker.overlaySettings).toEqual(customSettings); + datePicker.locale = 'ES'; + expect(datePicker.locale).toEqual('ES'); + datePicker.placeholder = 'Buenos dias, muchachos'; + expect(datePicker.placeholder).toEqual('Buenos dias, muchachos'); + datePicker.readOnly = true; + expect(datePicker.readOnly).toEqual(true); + spyOn(datePicker.valueChange, 'emit').and.callThrough(); + datePicker.value = today; + expect(datePicker.value).toEqual(today); + expect(mockDateEditor.value).toEqual(today); + expect(datePicker.valueChange.emit).toHaveBeenCalledWith(today); + const newDate = new Date('02/02/2002'); + const boundObject = { + date: newDate + }; + datePicker.value = boundObject.date; + expect(datePicker.value).toEqual(newDate); + expect(mockDateEditor.value).toEqual(newDate); + expect(datePicker.valueChange.emit).toHaveBeenCalledWith(newDate); + expect(boundObject.date).toEqual(newDate); + datePicker.value = '2003-03-03'; + expect(datePicker.value).toEqual('2003-03-03'); + // expect(mockDateEditor.value).toEqual('03/03/2003'); + expect(datePicker.valueChange.emit).not.toHaveBeenCalledWith('2003-03-03' as any); + const customFormatter: (val: Date) => string = (val: Date) => val.getFullYear().toString(); + datePicker.formatter = customFormatter; + expect(datePicker.formatter).toEqual(customFormatter); + expect(datePicker.displayValue.transform(today)).toEqual(today.getFullYear().toString()); + }); + + it('Should properly set date w/ `selectToday` methods', () => { + spyOn(datePicker, 'select'); + spyOn(datePicker, 'close'); + const now = new Date(); + now.setHours(0); + now.setMinutes(0); + now.setSeconds(0); + now.setMilliseconds(0); + datePicker.selectToday(); + expect(datePicker.select).toHaveBeenCalledWith(now); + expect(datePicker.close).toHaveBeenCalled(); + }); + + it('Should call underlying dateEditor decrement and increment methods', () => { + mockDateEditor.decrement = jasmine.createSpy(); + mockDateEditor.increment = jasmine.createSpy(); + datePicker.decrement(); + expect(mockDateEditor.decrement).toHaveBeenCalledWith(undefined, undefined); + const mockDatePart = {} as DatePart; + datePicker.decrement(mockDatePart); + expect(mockDateEditor.decrement).toHaveBeenCalledWith(mockDatePart, undefined); + datePicker.decrement(mockDatePart, 0); + expect(mockDateEditor.decrement).toHaveBeenCalledWith(mockDatePart, 0); + datePicker.decrement(mockDatePart, Infinity); + expect(mockDateEditor.decrement).toHaveBeenCalledWith(mockDatePart, Infinity); + datePicker.increment(); + expect(mockDateEditor.increment).toHaveBeenCalledWith(undefined, undefined); + datePicker.increment(mockDatePart); + expect(mockDateEditor.increment).toHaveBeenCalledWith(mockDatePart, undefined); + datePicker.increment(mockDatePart, 0); + expect(mockDateEditor.increment).toHaveBeenCalledWith(mockDatePart, 0); + datePicker.increment(mockDatePart, Infinity); + expect(mockDateEditor.increment).toHaveBeenCalledWith(mockDatePart, Infinity); + }); + + it('Should call underlying overlay `open` and `attach` methods with proper settings', () => { + spyOn(overlay, 'attach').and.returnValue(mockOverlayId); + spyOn(overlay, 'detach'); + spyOn(overlay, 'show'); + spyOn(overlay, 'hide'); + + const baseDialogSettings: OverlaySettings = Object.assign( + {}, + (datePicker as any)._dialogOverlaySettings + ); + const baseDropdownSettings: OverlaySettings = Object.assign( + {}, + (datePicker as any)._dropDownOverlaySettings, + { + target: mockInputGroup.element.nativeElement + } + ); + + const collapsedSpy = spyOnProperty(datePicker, 'collapsed', 'get'); + collapsedSpy.and.returnValue(false); + datePicker.disabled = false; + datePicker.open(); + expect(overlay.attach).not.toHaveBeenCalled(); + collapsedSpy.and.returnValue(true); + datePicker.disabled = true; + datePicker.open(); + expect(overlay.attach).not.toHaveBeenCalled(); + collapsedSpy.and.returnValue(false); + datePicker.open(); + expect(overlay.attach).not.toHaveBeenCalled(); + collapsedSpy.and.returnValue(true); + datePicker.disabled = false; + const isDropdownSpy = spyOnProperty(datePicker, 'isDropdown', 'get'); + isDropdownSpy.and.returnValue(false); + datePicker.open(); + expect(overlay.attach).toHaveBeenCalledWith(IgxCalendarContainerComponent, viewsContainerRef, baseDialogSettings); + expect(overlay.show).toHaveBeenCalledWith(mockOverlayId); + isDropdownSpy.and.returnValue(true); + datePicker.open(); + expect(overlay.attach).toHaveBeenCalledWith(IgxCalendarContainerComponent, viewsContainerRef, baseDropdownSettings); + expect(overlay.show).toHaveBeenCalledWith(mockOverlayId); + const mockOutlet = {} as any; + datePicker.outlet = mockOutlet; + datePicker.open(); + expect(overlay.attach).toHaveBeenCalledWith( + IgxCalendarContainerComponent, + viewsContainerRef, + Object.assign({}, baseDropdownSettings, { outlet: mockOutlet }), + ); + expect(overlay.show).toHaveBeenCalledWith(mockOverlayId); + let mockSettings: OverlaySettings = { + closeOnEscape: true, + closeOnOutsideClick: true, + modal: false + }; + datePicker.outlet = null; + datePicker.open(mockSettings); + expect(overlay.attach).toHaveBeenCalledWith( + IgxCalendarContainerComponent, + viewsContainerRef, + Object.assign({}, baseDropdownSettings, mockSettings), + ); + expect(overlay.show).toHaveBeenCalledWith(mockOverlayId); + isDropdownSpy.and.returnValue(false); + mockSettings = { + closeOnEscape: false, + closeOnOutsideClick: false, + modal: false + }; + datePicker.open(mockSettings); + expect(overlay.attach).toHaveBeenCalledWith( + IgxCalendarContainerComponent, + viewsContainerRef, + Object.assign({}, baseDialogSettings, mockSettings), + ); + expect(overlay.show).toHaveBeenCalledWith(mockOverlayId); + isDropdownSpy.and.returnValue(true); + datePicker.overlaySettings = { + modal: false + }; + mockSettings = { + modal: true + }; + datePicker.open(mockSettings); + expect(overlay.attach).toHaveBeenCalledWith( + IgxCalendarContainerComponent, + viewsContainerRef, + Object.assign({}, baseDropdownSettings, { modal: true }), + ); + }); + + it('Should call underlying overlay `close` and `detach` methods with proper settings', () => { + spyOn(overlay, 'attach').and.returnValue(mockOverlayId); + spyOn(overlay, 'detach'); + spyOn(overlay, 'show'); + spyOn(overlay, 'hide'); + + // init subscriptions + datePicker.ngAfterViewInit(); + + // assign overlayId + const collapsedSpy = spyOnProperty(datePicker, 'collapsed', 'get'); + collapsedSpy.and.returnValue(true); + datePicker.open(); + datePicker.close(); + expect(overlay.hide).not.toHaveBeenCalled(); + expect(overlay.detach).not.toHaveBeenCalled(); + collapsedSpy.and.returnValue(false); + datePicker.close(); + expect(overlay.hide).toHaveBeenCalled(); + expect(overlay.hide).toHaveBeenCalledWith(mockOverlayId); + expect(overlay.detach).not.toHaveBeenCalled(); + overlay.closed.emit(mockOverlayEventArgs); + expect(overlay.detach).toHaveBeenCalledWith(mockOverlayId); + }); + + it('Should try to set input label depending on label directive', () => { + expect(renderer2.setAttribute).not.toHaveBeenCalled(); + datePicker.ngAfterViewChecked(); + expect(renderer2.setAttribute).not.toHaveBeenCalled(); + (datePicker as any).labelDirective = jasmine.createSpyObj('mockLabel', ['any'], { + id: 'mock-id' + }); + datePicker.ngAfterViewChecked(); + expect(renderer2.setAttribute).toHaveBeenCalledWith(mockInputDirective.nativeElement, 'aria-labelledby', 'mock-id'); + }); + + it('Should properly handle click on editor provider', () => { + datePicker.ngAfterViewInit(); + spyOn(datePicker, 'open'); + expect(datePicker.open).not.toHaveBeenCalled(); + expect(datePicker.getEditElement()).toEqual(mockInputDirective.nativeElement); + expect(datePicker.isDropdown).toBeTruthy(); + datePicker.getEditElement().dispatchEvent('click' as any); + // does not call open when in DD mode + expect(datePicker.open).toHaveBeenCalledTimes(0); + spyOnProperty(datePicker, 'isDropdown', 'get').and.returnValue(false); + datePicker.getEditElement().dispatchEvent('click' as any); + expect(datePicker.open).toHaveBeenCalledTimes(1); + }); + + //#region API Methods + it('should properly update the collapsed state with open/close/toggle methods', () => { + datePicker.ngAfterViewInit(); + + datePicker.open(); + expect(datePicker.collapsed).toBeFalse(); + + datePicker.close(); + expect(datePicker.collapsed).toBeTrue(); + + datePicker.toggle(); + expect(datePicker.collapsed).toBeFalse(); + + datePicker.toggle(); + expect(datePicker.collapsed).toBeTrue(); + }); + + it('should update the picker\'s value with the select method', () => { + spyOn(datePicker.valueChange, 'emit'); + + (datePicker as any).dateTimeEditor = { value: null, clear: () => { } }; + datePicker.value = new Date(); + + const newDate = new Date(2012, 10, 5); + datePicker.select(newDate); + expect(datePicker.valueChange.emit).toHaveBeenCalledWith(newDate); + expect(datePicker.value).toEqual(newDate); + }); + + it('should clear the picker\'s value with the clear method', () => { + spyOn(datePicker.valueChange, 'emit'); + + datePicker.value = today; + + datePicker.ngAfterViewInit(); + + datePicker.clear(); + expect(datePicker.valueChange.emit).toHaveBeenCalledWith(null); + expect(datePicker.value).toEqual(null); + }); + + //#endregion + + //#region Events + it('should properly emit open/close events', () => { + spyOn(datePicker.opened, 'emit'); + spyOn(datePicker.closed, 'emit'); + + datePicker.ngAfterViewInit(); + + datePicker.open(); + expect(datePicker.opened.emit).toHaveBeenCalledWith({ owner: datePicker }); + + datePicker.close(); + expect(datePicker.closed.emit).toHaveBeenCalledWith({ owner: datePicker }); + }); + + it('should properly emit opening/closing events', () => { + spyOn(datePicker.opening, 'emit'); + spyOn(datePicker.closing, 'emit'); + + datePicker.ngAfterViewInit(); + + datePicker.open(); + expect(datePicker.opening.emit).toHaveBeenCalledWith({ owner: datePicker, event: undefined, cancel: false }); + + datePicker.close(); + expect(datePicker.closing.emit).toHaveBeenCalledWith({ owner: datePicker, event: undefined, cancel: false }); + }); + + it('should emit valueChange when the value changes', () => { + spyOn(datePicker.valueChange, 'emit'); + + datePicker.ngAfterViewInit(); + + (datePicker as any).dateTimeEditor = { value: null, clear: () => { } }; + const newDate = new Date(); + datePicker.value = newDate; + expect(datePicker.valueChange.emit).toHaveBeenCalledWith(newDate); + }); + + it('should emit validationFailed if a value outside of a given range was provided', () => { + spyOn(datePicker.validationFailed, 'emit'); + const validDate = new Date(2012, 5, 7); + const invalidDate = new Date(2012, 6, 1); + spyOnProperty(mockDateEditor, 'value', 'set').and.callFake((val: Date) => { + if (val === invalidDate) { + mockDateEditor.validationFailed.emit({ oldValue: validDate }); + return; + } + mockDateEditor._value = val; + mockDateEditor.valueChange.emit(val); + }); + + datePicker.ngAfterViewInit(); + + datePicker.minValue = new Date(2012, 5, 4); + datePicker.maxValue = new Date(2012, 5, 10); + datePicker.value = validDate; + expect(datePicker.validationFailed.emit).not.toHaveBeenCalled(); + datePicker.value = invalidDate; + expect(datePicker.validationFailed.emit).toHaveBeenCalledWith( + { owner: datePicker, prevValue: validDate, currentValue: invalidDate }); + }); + + it('Should change own value if value of underlying dateEditor changes', () => { + const validDate = new Date(2012, 5, 7); + spyOn(datePicker.valueChange, 'emit'); + + datePicker.ngAfterViewInit(); + expect(datePicker.value).not.toEqual(validDate); + mockDateEditor.valueChange.emit(validDate); + expect(datePicker.valueChange.emit).toHaveBeenCalledTimes(1); + expect(datePicker.valueChange.emit).toHaveBeenCalledWith(validDate); + + const secondDate = new Date(); + mockDateEditor.valueChange.emit(secondDate); + expect(datePicker.valueChange.emit).toHaveBeenCalledTimes(2); + expect(datePicker.valueChange.emit).toHaveBeenCalledWith(secondDate); + }); + + it(`Should initialize and subscribe to underlying calendar's selected event and call proper methods`, () => { + datePicker.mode = PickerInteractionMode.Dialog; + spyOn(overlay, 'show'); + // assign overlay id + datePicker.open(); + datePicker.ngAfterViewInit(); + overlay.opening.emit(mockOverlayEventArgs); + spyOn(datePicker, 'close'); + expect(datePicker.close).not.toHaveBeenCalled(); + // calendar instance is initialized properly + expect(mockCalendar.hasHeader).toEqual(!datePicker.isDropdown); + expect(mockCalendar.formatOptions).toEqual((datePicker as any).pickerCalendarFormat); + expect(mockCalendar.formatViews).toEqual((datePicker as any).pickerFormatViews); + expect(mockCalendar.locale).toEqual(datePicker.locale); + expect(mockCalendar.headerOrientation).toEqual(datePicker.headerOrientation); + expect(mockCalendar.weekStart).toEqual(datePicker.weekStart); + expect(mockCalendar.specialDates).toEqual(datePicker.specialDates); + expect(mockCalendar.hideOutsideDays).toEqual(datePicker.hideOutsideDays); + expect(mockCalendar.monthsViewNumber).toEqual(datePicker.displayMonthsCount); + expect(mockCalendar.showWeekNumbers).toEqual(datePicker.showWeekNumbers); + expect(mockCalendar.disabledDates).toEqual([]); + + // check how calendar.selected is handler + const init = DateTimeUtil.parseIsoDate; + const mockDate1 = jasmine.createSpyObj( + 'date', + ['setHours', 'setMinutes', 'setSeconds', 'setMilliseconds'] + ); + const mockDate2 = jasmine.createSpyObj( + 'date', + ['getHours', 'getMinutes', 'getSeconds', 'getMilliseconds', 'getTime'] + ); + mockDate2.getHours.and.returnValue(999); + mockDate2.getMinutes.and.returnValue(999); + mockDate2.getSeconds.and.returnValue(999); + mockDate2.getMilliseconds.and.returnValue(999); + mockDate2.getTime.and.returnValue(999); + const parseIsoDate = spyOn(DateTimeUtil, 'parseIsoDate'); + parseIsoDate.and.callFake((val: string) => { + if (val === undefined || mockDate1) { + return mockDate2; + } else { + return init(val); + } + }); + datePicker.value = undefined; + expect(datePicker.close).not.toHaveBeenCalled(); + // this will call DateTimeUtil.parseIsoDate and set the value to mockDate2 + expect(mockDate2.getHours).not.toHaveBeenCalled(); + expect(mockDate2.getMinutes).not.toHaveBeenCalled(); + expect(mockDate2.getSeconds).not.toHaveBeenCalled(); + expect(mockDate2.getMilliseconds).not.toHaveBeenCalled(); + expect(mockDate1.setHours).not.toHaveBeenCalled(); + expect(mockDate1.setMinutes).not.toHaveBeenCalled(); + expect(mockDate1.setSeconds).not.toHaveBeenCalled(); + expect(mockDate1.setMilliseconds).not.toHaveBeenCalled(); + mockCalendar.selected.emit(mockDate1); + // if the value is falsy or InvalidDate, hours, minutes and seconds will not be mapped + expect(mockDate2.getHours).not.toHaveBeenCalled(); + expect(mockDate2.getMinutes).not.toHaveBeenCalled(); + expect(mockDate2.getSeconds).not.toHaveBeenCalled(); + expect(mockDate2.getMilliseconds).not.toHaveBeenCalled(); + expect(mockDate1.setHours).not.toHaveBeenCalledWith(999); + expect(mockDate1.setMinutes).not.toHaveBeenCalledWith(999); + expect(mockDate1.setSeconds).not.toHaveBeenCalledWith(999); + expect(mockDate1.setMilliseconds).not.toHaveBeenCalledWith(999); + expect(datePicker.close).toHaveBeenCalled(); + + parseIsoDate.and.callFake(init); + + const mockMinValue = new Date('02/02/2002'); + const mockMaxValue = new Date('03/03/2003'); + const defaultCheck = DateTimeUtil.isValidDate; + const mockCheck = (value: Date): value is Date => { + if (value === mockMinValue || value === mockMaxValue) { + return true; + } else { + return defaultCheck(value); + } + }; + spyOn(DateTimeUtil, 'isValidDate').and.callFake(mockCheck); + expect(datePicker.disabledDates).toEqual(null); + expect(datePicker.minValue).toBeUndefined(); + expect(datePicker.maxValue).toBeUndefined(); + overlay.opening.emit(mockOverlayEventArgs); + expect(mockCalendar.disabledDates).toEqual([]); + datePicker.maxValue = mockMaxValue; + overlay.opening.emit(mockOverlayEventArgs); + expect(mockCalendar.disabledDates).toEqual([{ type: DateRangeType.After, dateRange: [mockMaxValue] }]); + mockCalendar.disabledDates = []; + datePicker.maxValue = undefined; + datePicker.minValue = mockMinValue; + overlay.opening.emit(mockOverlayEventArgs); + expect(mockCalendar.disabledDates).toEqual([{ type: DateRangeType.Before, dateRange: [mockMinValue] }]); + mockCalendar.disabledDates = []; + datePicker.maxValue = mockMaxValue; + overlay.opening.emit(mockOverlayEventArgs); + expect(mockCalendar.disabledDates).toEqual([ + { type: DateRangeType.Before, dateRange: [mockMinValue] }, + { type: DateRangeType.After, dateRange: [mockMaxValue] } + ]); + mockCalendar.disabledDates = []; + datePicker.minValue = undefined; + datePicker.maxValue = undefined; + datePicker.disabledDates = [ + { type: DateRangeType.Before, dateRange: [mockMinValue] }, + { type: DateRangeType.After, dateRange: [mockMaxValue] } + ]; + overlay.opening.emit(mockOverlayEventArgs); + expect(mockCalendar.disabledDates).toEqual([ + { type: DateRangeType.Before, dateRange: [mockMinValue] }, + { type: DateRangeType.After, dateRange: [mockMaxValue] } + ]); + mockCalendar.disabledDates = []; + datePicker.minValue = mockMinValue; + datePicker.maxValue = mockMaxValue; + datePicker.disabledDates = [ + { type: DateRangeType.Before, dateRange: [mockMinValue] }, + { type: DateRangeType.After, dateRange: [mockMaxValue] } + ]; + overlay.opening.emit(mockOverlayEventArgs); + expect(mockCalendar.disabledDates).toEqual([ + { type: DateRangeType.Before, dateRange: [mockMinValue] }, + { type: DateRangeType.After, dateRange: [mockMaxValue] }, + { type: DateRangeType.Before, dateRange: [mockMinValue] }, + { type: DateRangeType.After, dateRange: [mockMaxValue] } + ]); + mockDate2.getTime.and.returnValue(Infinity); + mockCalendar.disabledDates = []; + datePicker.minValue = mockMinValue; + datePicker.maxValue = mockMaxValue; + overlay.opening.emit(mockOverlayEventArgs); + // if _calendar already has disabled dates, min + max are added anyway + expect(mockCalendar.disabledDates).toEqual([ + { type: DateRangeType.Before, dateRange: [mockMinValue] }, + { type: DateRangeType.After, dateRange: [mockMaxValue] }, + { type: DateRangeType.Before, dateRange: [mockMinValue] }, + { type: DateRangeType.After, dateRange: [mockMaxValue] } + ]); + }); + //#endregion + }); + + describe('Control Value Accessor', () => { + it('Should properly handle `statusChanged` event when bound to ngModel', () => { + datePicker.ngOnInit(); + datePicker.ngAfterViewInit(); + mockControlInstance.touched = false; + mockControlInstance.dirty = false; + mockControlInstance.validator = null; + mockControlInstance.asyncValidator = null; + // initial value + expect(mockInputDirective.valid).toEqual(IgxInputState.INITIAL); + mockNgControl.statusChanges.emit(); + expect(mockInputDirective.valid).toEqual(IgxInputState.INITIAL); + mockControlInstance.touched = true; + mockNgControl.statusChanges.emit(); + expect(mockInputDirective.valid).toEqual(IgxInputState.INITIAL); + mockControlInstance.validator = () => { }; + mockNgControl.statusChanges.emit(); + expect(mockInputDirective.valid).toEqual(IgxInputState.INITIAL); + mockNgControl.valid = false; + mockNgControl.statusChanges.emit(); + expect(mockInputDirective.valid).toEqual(IgxInputState.INVALID); + mockInputGroup.isFocused = true; + mockNgControl.valid = true; + mockNgControl.statusChanges.emit(); + expect(mockInputDirective.valid).toEqual(IgxInputState.VALID); + mockNgControl.valid = false; + mockNgControl.statusChanges.emit(); + expect(mockInputDirective.valid).toEqual(IgxInputState.INVALID); + }); + }); + }); +}); +@Component({ + template: ` + + `, + imports: [IgxDatePickerComponent] +}) +export class IgxDatePickerTestComponent { + @ViewChild(IgxDatePickerComponent) public datePicker: IgxDatePickerComponent; + public mode: PickerInteractionMode = PickerInteractionMode.DropDown; + public date = new Date(2021, 24, 2, 11, 45, 0); + public minValue; + public maxValue; +} + +@Component({ + template: ` + + `, + imports: [IgxDatePickerComponent, FormsModule] +}) +export class IgxDatePickerNgModelComponent { + @ViewChild(IgxDatePickerComponent) public datePicker: IgxDatePickerComponent; + public mode: PickerInteractionMode = PickerInteractionMode.DropDown; + public date = new Date(2021, 24, 2, 11, 45, 0); + public minValue; + public maxValue; + public isRequired = true; +} + +@Component({ + template: ` + + + + `, + imports: [IgxDatePickerComponent, IgxLabelDirective] +}) +export class IgxDatePickerTestKbrdComponent { + @ViewChild(IgxDatePickerComponent) public datePicker: IgxDatePickerComponent; + public mode: PickerInteractionMode = PickerInteractionMode.DropDown; + public date = new Date(2021, 24, 2, 11, 45, 0); +} + +@Component({ + template: ` + + + @if (showCustomToggle) { + CustomToggle + } + Prefix + @if (showCustomClear) { + CustomClear + } + Suffix + Hint + `, + imports: [IgxDatePickerComponent, IgxPickerToggleComponent, IgxPrefixDirective, IgxPickerClearComponent, IgxLabelDirective, IgxSuffixDirective, IgxHintDirective] +}) +export class IgxDatePickerWithProjectionsComponent { + @ViewChild(IgxDatePickerComponent) public datePicker: IgxDatePickerComponent; + @ViewChild(IgxPickerToggleComponent) public customToggle: IgxPickerToggleComponent; + @ViewChild(IgxPickerClearComponent) public customClear: IgxPickerClearComponent; + public mode: PickerInteractionMode = PickerInteractionMode.DropDown; + public showCustomToggle = false; + public showCustomClear = false; +} + +@Component({ + template: ` + + {{ formatCalendar.year.value }} + {{ formatCalendar.month.value }} + `, + imports: [IgxDatePickerComponent, IgxCalendarHeaderTemplateDirective, IgxCalendarHeaderTitleTemplateDirective] +}) +export class IgxDatePickerWithTemplatesComponent { + @ViewChild(IgxDatePickerComponent) public datePicker: IgxDatePickerComponent; + public mode: PickerInteractionMode = PickerInteractionMode.Dialog; +} + +@Component({ + template: ` +
+ +
+ `, + imports: [IgxDatePickerComponent, FormsModule] +}) +export class IgxDatePickerInFormComponent { + @ViewChild('form') + public form: NgForm; + + @ViewChild(IgxDatePickerComponent) + public datePicker: IgxDatePickerComponent; + + public date: Date = new Date(2012, 5, 3); +} + +@Component({ + template: ` +
+
+ + + +
+
+ `, + imports: [IgxDatePickerComponent, ReactiveFormsModule, IgxLabelDirective] +}) +export class IgxDatePickerReactiveFormComponent { + @ViewChild(IgxDatePickerComponent) + public datePicker: IgxDatePickerComponent; + + public date: Date = new Date(2012, 5, 3); + + public form: UntypedFormGroup = new UntypedFormGroup({ + date: new UntypedFormControl(null, Validators.required) + }); + + public removeValidators() { + this.form.get('date').clearValidators(); + this.form.get('date').updateValueAndValidity(); + } + + public addValidators() { + this.form.get('date').setValidators(Validators.required); + this.form.get('date').updateValueAndValidity(); + } + + public markAsTouched() { + this.form.get('date').markAsTouched(); + this.form.get('date').updateValueAndValidity(); + } + + public disableForm() { + this.form.disable(); + } +} diff --git a/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.ts b/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.ts new file mode 100644 index 00000000000..8b17fd03c56 --- /dev/null +++ b/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.ts @@ -0,0 +1,1010 @@ +import { + AfterViewChecked, + AfterViewInit, + AfterContentChecked, + ChangeDetectorRef, + Component, + ContentChild, + ElementRef, + EventEmitter, + HostBinding, + HostListener, + Injector, + Input, + OnDestroy, + OnInit, + Output, + PipeTransform, + Renderer2, + ViewChild, + ViewContainerRef, + booleanAttribute, + inject +} from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + NgControl, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator +} from '@angular/forms'; +import { + IgxCalendarComponent, IgxCalendarHeaderTemplateDirective, IgxCalendarHeaderTitleTemplateDirective, IgxCalendarSubheaderTemplateDirective, + IFormattingViews, IFormattingOptions +} from 'igniteui-angular/calendar'; +import { + IgxLabelDirective, IgxInputState, IgxInputGroupComponent, IgxPrefixDirective, IgxInputDirective, IgxSuffixDirective, + IgxReadOnlyInputDirective +} from 'igniteui-angular/input-group'; +import { fromEvent, Subscription, noop, MonoTypeOperatorFunction } from 'rxjs'; +import { filter, takeUntil } from 'rxjs/operators'; + +import { IgxDateTimeEditorDirective, IgxTextSelectionDirective } from 'igniteui-angular/directives'; +import { + AbsoluteScrollStrategy, + AutoPositionStrategy, + IgxOverlayService, + OverlayCancelableEventArgs, + OverlayEventArgs, + OverlaySettings, + IgxPickerActionsDirective, + DatePickerResourceStringsEN, + IDatePickerResourceStrings, + DateRangeDescriptor, + DateRangeType, + IBaseCancelableBrowserEventArgs, + isDate, + PlatformUtil, + getCurrentResourceStrings, + PickerCalendarOrientation, + DateTimeUtil, + DatePartDeltas, + DatePart, + isDateInRanges, + IgxOverlayOutletDirective +} from 'igniteui-angular/core'; +import { IDatePickerValidationFailedEventArgs } from './date-picker.common'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { fadeIn, fadeOut } from 'igniteui-angular/animations'; +import { PickerBaseDirective } from './picker-base.directive'; +import { IgxCalendarContainerComponent } from './calendar-container/calendar-container.component'; + +let NEXT_ID = 0; + +/** + * Date Picker displays a popup calendar that lets users select a single date. + * + * @igxModule IgxDatePickerModule + * @igxTheme igx-calendar-theme, igx-icon-theme + * @igxGroup Scheduling + * @igxKeywords datepicker, calendar, schedule, date + * @example + * ```html + * + * ``` + */ +@Component({ + providers: [ + { provide: NG_VALUE_ACCESSOR, useExisting: IgxDatePickerComponent, multi: true }, + { provide: NG_VALIDATORS, useExisting: IgxDatePickerComponent, multi: true } + ], + selector: 'igx-date-picker', + templateUrl: 'date-picker.component.html', + styles: [':host { display: block; }'], + imports: [ + IgxInputGroupComponent, + IgxPrefixDirective, + IgxIconComponent, + IgxInputDirective, + IgxReadOnlyInputDirective, + IgxDateTimeEditorDirective, + IgxTextSelectionDirective, + IgxSuffixDirective + ] +}) +export class IgxDatePickerComponent extends PickerBaseDirective implements ControlValueAccessor, Validator, + OnInit, AfterViewInit, OnDestroy, AfterViewChecked, AfterContentChecked { + private _overlayService = inject(IgxOverlayService); + private _injector = inject(Injector); + private _renderer = inject(Renderer2); + private platform = inject(PlatformUtil); + private cdr = inject(ChangeDetectorRef); + + + /** + * Gets/Sets whether the inactive dates will be hidden. + * + * @remarks + * Applies to dates that are out of the current month. + * Default value is `false`. + * @example + * ```html + * + * ``` + * @example + * ```typescript + * let hideOutsideDays = this.datePicker.hideOutsideDays; + * ``` + */ + @Input({ transform: booleanAttribute }) + public hideOutsideDays: boolean; + + /** + * Gets/Sets the number of month views displayed. + * + * @remarks + * Default value is `1`. + * + * @example + * ```html + * + * ``` + * @example + * ```typescript + * let monthViewsDisplayed = this.datePicker.displayMonthsCount; + * ``` + */ + @Input() + public displayMonthsCount = 1; + + /** + * Gets/Sets the orientation of the multiple months displayed in the picker's calendar's days view. + * + * @example + * + */ + @Input() + public orientation: PickerCalendarOrientation = PickerCalendarOrientation.Horizontal; + + /** + * Show/hide week numbers + * + * @example + * ```html + * + * `` + */ + @Input({ transform: booleanAttribute }) + public showWeekNumbers: boolean; + + + /** + * Gets/Sets the date which is shown in the calendar picker and is highlighted. + * By default it is the current date, or the value of the picker, if set. + */ + @Input() + public get activeDate(): Date { + const today = new Date(new Date().setHours(0, 0, 0, 0)); + const dateValue = DateTimeUtil.isValidDate(this._dateValue) ? new Date(this._dateValue.setHours(0, 0, 0, 0)) : null; + return this._activeDate ?? dateValue ?? this._calendar?.activeDate ?? today; + } + + public set activeDate(value: Date) { + this._activeDate = value; + } + + /** + * Gets/Sets a custom formatter function on the selected or passed date. + * + * @example + * ```html + * + * ``` + */ + @Input() + public formatter: (val: Date) => string; + + /** + * Gets/Sets the today button's label. + * + * @example + * ```html + * + * ``` + */ + @Input() + public todayButtonLabel: string; + + /** + * Gets/Sets the cancel button's label. + * + * @example + * ```html + * + * ``` + */ + @Input() + public cancelButtonLabel: string; + + /** + * Specify if the currently spun date segment should loop over. + * + * @example + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public spinLoop = true; + + /** + * Delta values used to increment or decrement each editor date part on spin actions. + * All values default to `1`. + * + * @example + * ```html + * + * ``` + */ + @Input() + public spinDelta: Pick; + + /** + * Gets/Sets the container used for the popup element. + * + * @remarks + * `outlet` is an instance of `IgxOverlayOutletDirective` or an `ElementRef`. + * @example + * ```html + *
+ * //.. + * + * //.. + * ``` + */ + @Input() + public override outlet: IgxOverlayOutletDirective | ElementRef; + + /** + * Gets/Sets the value of `id` attribute. + * + * @remarks If not provided it will be automatically generated. + * @example + * ```html + * + * ``` + */ + @Input() + @HostBinding('attr.id') + public id = `igx-date-picker-${NEXT_ID++}`; + + //#region calendar members + + /** + * Gets/Sets the format views of the `IgxDatePickerComponent`. + * + * @example + * ```typescript + * let formatViews = this.datePicker.formatViews; + * this.datePicker.formatViews = {day:false, month: false, year:false}; + * ``` + */ + @Input() + public formatViews: IFormattingViews; + + /** + * Gets/Sets the disabled dates descriptors. + * + * @example + * ```typescript + * let disabledDates = this.datepicker.disabledDates; + * this.datePicker.disabledDates = [ {type: DateRangeType.Weekends}, ...]; + * ``` + */ + @Input() + public get disabledDates(): DateRangeDescriptor[] { + return this._disabledDates; + } + public set disabledDates(value: DateRangeDescriptor[]) { + this._disabledDates = value; + this._onValidatorChange(); + } + + /** + * Gets/Sets the special dates descriptors. + * + * @example + * ```typescript + * let specialDates = this.datepicker.specialDates; + * this.datePicker.specialDates = [ {type: DateRangeType.Weekends}, ... ]; + * ``` + */ + @Input() + public get specialDates(): DateRangeDescriptor[] { + return this._specialDates; + } + public set specialDates(value: DateRangeDescriptor[]) { + this._specialDates = value; + } + + + /** + * Gets/Sets the format options of the `IgxDatePickerComponent`. + * + * @example + * ```typescript + * this.datePicker.calendarFormat = {day: "numeric", month: "long", weekday: "long", year: "numeric"}; + * ``` + */ + @Input() + public calendarFormat: IFormattingOptions; + + //#endregion + + /** + * Gets/Sets the selected date. + * + * @example + * ```html + * + * ``` + */ + @Input() + public get value(): Date | string { + return this._value; + } + public set value(date: Date | string) { + this._value = date; + this.setDateValue(date); + if (this.dateTimeEditor.value !== date) { + this.dateTimeEditor.value = this._dateValue; + } + this.valueChange.emit(this.dateValue); + this._onChangeCallback(this.dateValue); + } + + /** + * The minimum value the picker will accept. + * + * @example + * + */ + @Input() + public set minValue(value: Date | string) { + this._minValue = value; + this._onValidatorChange(); + } + + public get minValue(): Date | string { + return this._minValue; + } + + /** + * The maximum value the picker will accept. + * + * @example + * + */ + @Input() + public set maxValue(value: Date | string) { + this._maxValue = value; + this._onValidatorChange(); + } + + public get maxValue(): Date | string { + return this._maxValue; + } + + /** + * Gets/Sets the resource strings for the picker's default toggle icon. + * By default it uses EN resources. + */ + @Input() + public resourceStrings: IDatePickerResourceStrings; + + /** @hidden @internal */ + @Input({ transform: booleanAttribute }) + public readOnly = false; + + /** + * Emitted when the picker's value changes. + * + * @remarks + * Used for `two-way` bindings. + * + * @example + * ```html + * + * ``` + */ + @Output() + public valueChange = new EventEmitter(); + + /** + * Emitted when the user types/spins invalid date in the date-picker editor. + * + * @example + * ```html + * + * ``` + */ + @Output() + public validationFailed = new EventEmitter(); + + /** @hidden @internal */ + @ContentChild(IgxLabelDirective) + public label: IgxLabelDirective; + + @ContentChild(IgxCalendarHeaderTitleTemplateDirective) + private headerTitleTemplate: IgxCalendarHeaderTitleTemplateDirective; + + @ContentChild(IgxCalendarHeaderTemplateDirective) + private headerTemplate: IgxCalendarHeaderTemplateDirective; + + @ViewChild(IgxDateTimeEditorDirective, { static: true }) + private dateTimeEditor: IgxDateTimeEditorDirective; + + @ViewChild(IgxInputGroupComponent, { read: ViewContainerRef }) + private viewContainerRef: ViewContainerRef; + + @ViewChild(IgxLabelDirective) + private labelDirective: IgxLabelDirective; + + @ViewChild(IgxInputDirective) + private inputDirective: IgxInputDirective; + + @ContentChild(IgxCalendarSubheaderTemplateDirective) + private subheaderTemplate: IgxCalendarSubheaderTemplateDirective; + + @ContentChild(IgxPickerActionsDirective) + private pickerActions: IgxPickerActionsDirective; + + private get dialogOverlaySettings(): OverlaySettings { + return Object.assign({}, this._dialogOverlaySettings, this.overlaySettings); + } + + private get dropDownOverlaySettings(): OverlaySettings { + return Object.assign({}, this._dropDownOverlaySettings, this.overlaySettings); + } + + private get inputGroupElement(): HTMLElement { + return this.inputGroup?.element.nativeElement; + } + + private get dateValue(): Date { + return this._dateValue; + } + + private get pickerFormatViews(): IFormattingViews { + return Object.assign({}, this._defFormatViews, this.formatViews); + } + + private get pickerCalendarFormat(): IFormattingOptions { + return Object.assign({}, this._calendarFormat, this.calendarFormat); + } + + /** @hidden @internal */ + public displayValue: PipeTransform = { transform: (date: Date) => this.formatter(date) }; + + private _resourceStrings = getCurrentResourceStrings(DatePickerResourceStringsEN); + private _dateValue: Date; + private _overlayId: string; + private _value: Date | string; + private _ngControl: NgControl = null; + private _statusChanges$: Subscription; + private _calendar: IgxCalendarComponent; + private _calendarContainer?: HTMLElement; + private _specialDates: DateRangeDescriptor[] = null; + private _disabledDates: DateRangeDescriptor[] = null; + private _activeDate: Date = null; + private _overlaySubFilter: + [MonoTypeOperatorFunction, + MonoTypeOperatorFunction] = [ + filter(x => x.id === this._overlayId), + takeUntil(this._destroy$) + ]; + private _dropDownOverlaySettings: OverlaySettings = { + target: this.inputGroupElement, + closeOnOutsideClick: true, + modal: false, + closeOnEscape: true, + scrollStrategy: new AbsoluteScrollStrategy(), + positionStrategy: new AutoPositionStrategy({ + openAnimation: fadeIn, + closeAnimation: fadeOut + }) + }; + private _dialogOverlaySettings: OverlaySettings = { + closeOnOutsideClick: true, + modal: true, + closeOnEscape: true + }; + private _calendarFormat: IFormattingOptions = { + day: 'numeric', + month: 'short', + weekday: 'short', + year: 'numeric' + }; + private _defFormatViews: IFormattingViews = { + day: false, + month: true, + year: false + }; + private _onChangeCallback: (_: Date) => void = noop; + private _onTouchedCallback: () => void = noop; + private _onValidatorChange: () => void = noop; + + constructor() { + super(); + this.locale = this.locale || this._localeId; + } + + /** @hidden @internal */ + public get required(): boolean { + if (this._ngControl && this._ngControl.control && this._ngControl.control.validator) { + // Run the validation with empty object to check if required is enabled. + const error = this._ngControl.control.validator({} as AbstractControl); + return error && error.required; + } + + return false; + } + + /** @hidden @internal */ + public get pickerResourceStrings(): IDatePickerResourceStrings { + return Object.assign({}, this._resourceStrings, this.resourceStrings); + } + + protected override get toggleContainer(): HTMLElement | undefined { + return this._calendarContainer; + } + + /** @hidden @internal */ + @HostListener('keydown', ['$event']) + public onKeyDown(event: KeyboardEvent) { + switch (event.key) { + case this.platform.KEYMAP.ARROW_UP: + if (event.altKey) { + this.close(); + } + break; + case this.platform.KEYMAP.ARROW_DOWN: + if (event.altKey) { + this.open(); + } + break; + case this.platform.KEYMAP.SPACE: + event.preventDefault(); + this.open(); + break; + } + } + + /** + * Opens the picker's dropdown or dialog. + * + * @example + * ```html + * + * + * + * ``` + */ + public open(settings?: OverlaySettings): void { + if (!this.collapsed || this.disabled || this.readOnly) { + return; + } + + const overlaySettings = Object.assign({}, this.isDropdown + ? this.dropDownOverlaySettings + : this.dialogOverlaySettings + , settings); + + if (this.isDropdown && this.inputGroupElement) { + overlaySettings.target = this.inputGroupElement; + } + if (this.outlet) { + overlaySettings.outlet = this.outlet; + } + this._overlayId = this._overlayService + .attach(IgxCalendarContainerComponent, this.viewContainerRef, overlaySettings); + this._overlayService.show(this._overlayId); + } + + /** + * Toggles the picker's dropdown or dialog + * + * @example + * ```html + * + * + * + * ``` + */ + public toggle(settings?: OverlaySettings): void { + if (this.collapsed) { + this.open(settings); + } else { + this.close(); + } + } + + /** + * Closes the picker's dropdown or dialog. + * + * @example + * ```html + * + * + * + * ``` + */ + public close(): void { + if (!this.collapsed) { + this._overlayService.hide(this._overlayId); + } + } + + /** + * Selects a date. + * + * @remarks Updates the value in the input field. + * + * @example + * ```typescript + * this.datePicker.select(date); + * ``` + * @param date passed date that has to be set to the calendar. + */ + public select(value: Date): void { + this.value = value; + } + + /** + * Selects today's date and closes the picker. + * + * @example + * ```html + * + * + * + * ``` + * */ + public selectToday(): void { + const today = new Date(); + today.setHours(0); + today.setMinutes(0); + today.setSeconds(0); + today.setMilliseconds(0); + this.select(today); + this.close(); + } + + /** + * Clears the input field and the picker's value. + * + * @example + * ```typescript + * this.datePicker.clear(); + * ``` + */ + public clear(): void { + if (!this.disabled || !this.readOnly) { + this._calendar?.deselectDate(); + this.dateTimeEditor.clear(); + } + } + + /** + * Increment a specified `DatePart`. + * + * @param datePart The optional DatePart to increment. Defaults to Date. + * @param delta The optional delta to increment by. Overrides `spinDelta`. + * @example + * ```typescript + * this.datePicker.increment(DatePart.Date); + * ``` + */ + public increment(datePart?: DatePart, delta?: number): void { + this.dateTimeEditor.increment(datePart, delta); + } + + /** + * Decrement a specified `DatePart` + * + * @param datePart The optional DatePart to decrement. Defaults to Date. + * @param delta The optional delta to decrement by. Overrides `spinDelta`. + * @example + * ```typescript + * this.datePicker.decrement(DatePart.Date); + * ``` + */ + public decrement(datePart?: DatePart, delta?: number): void { + this.dateTimeEditor.decrement(datePart, delta); + } + + //#region Control Value Accessor + /** @hidden @internal */ + public writeValue(value: Date | string) { + this._value = value; + this.setDateValue(value); + if (this.dateTimeEditor.value !== value) { + this.dateTimeEditor.value = this._dateValue; + } + } + + /** @hidden @internal */ + public registerOnChange(fn: any) { + this._onChangeCallback = fn; + } + + /** @hidden @internal */ + public registerOnTouched(fn: any) { + this._onTouchedCallback = fn; + } + + /** @hidden @internal */ + public setDisabledState?(isDisabled: boolean): void { + this.disabled = isDisabled; + } + //#endregion + + //#region Validator + /** @hidden @internal */ + public registerOnValidatorChange(fn: any) { + this._onValidatorChange = fn; + } + + /** @hidden @internal */ + public validate(control: AbstractControl): ValidationErrors | null { + if (!control.value) { + return null; + } + // InvalidDate handling + if (isDate(control.value) && !DateTimeUtil.isValidDate(control.value)) { + return { value: true }; + } + + const errors = {}; + const value = DateTimeUtil.isValidDate(control.value) ? control.value : DateTimeUtil.parseIsoDate(control.value); + if (value && this.disabledDates && isDateInRanges(value, this.disabledDates)) { + Object.assign(errors, { dateIsDisabled: true }); + } + Object.assign(errors, DateTimeUtil.validateMinMax(value, this.minValue, this.maxValue, false)); + + return Object.keys(errors).length > 0 ? errors : null; + } + //#endregion + + /** @hidden @internal */ + public ngOnInit(): void { + this._ngControl = this._injector.get(NgControl, null); + + this.locale = this.locale || this._localeId; + } + + /** @hidden @internal */ + public override ngAfterViewInit() { + super.ngAfterViewInit(); + this.subscribeToClick(); + this.subscribeToOverlayEvents(); + this.subscribeToDateEditorEvents(); + + this._dropDownOverlaySettings.excludeFromOutsideClick = [this.inputGroup.element.nativeElement]; + + fromEvent(this.inputDirective.nativeElement, 'blur') + .pipe(takeUntil(this._destroy$)) + .subscribe(() => { + if (this.collapsed) { + this._onTouchedCallback(); + this.updateValidity(); + } + }); + + if (this._ngControl) { + this._statusChanges$ = + this._ngControl.statusChanges.subscribe(this.onStatusChanged.bind(this)); + if (this._ngControl.control.validator) { + this.inputGroup.isRequired = this.required; + this.cdr.detectChanges(); + } + } + } + + /** @hidden @internal */ + public ngAfterViewChecked() { + if (this.labelDirective) { + this._renderer.setAttribute(this.inputDirective.nativeElement, 'aria-labelledby', this.labelDirective.id); + } + } + + /** @hidden @internal */ + public override ngOnDestroy(): void { + super.ngOnDestroy(); + if (this._statusChanges$) { + this._statusChanges$.unsubscribe(); + } + if (this._overlayId) { + this._overlayService.detach(this._overlayId); + delete this._overlayId; + } + } + + /** @hidden @internal */ + public getEditElement(): HTMLInputElement { + return this.inputDirective.nativeElement; + } + + private subscribeToClick() { + fromEvent(this.getEditElement(), 'click') + .pipe(takeUntil(this._destroy$)) + .subscribe(() => { + if (!this.isDropdown) { + this.toggle(); + } + }); + } + + private setDateValue(value: Date | string) { + if (isDate(value) && isNaN(value.getTime())) { + this._dateValue = value; + return; + } + this._dateValue = DateTimeUtil.isValidDate(value) ? value : DateTimeUtil.parseIsoDate(value); + if (this._calendar) { + this._calendar.selectDate(this._dateValue); + this._calendar.activeDate = this.activeDate; + this._calendar.viewDate = this.activeDate; + this.cdr.detectChanges(); + } + } + + private updateValidity() { + // B.P. 18 May 2021: IgxDatePicker does not reset its state upon resetForm #9526 + if (this._ngControl && !this.disabled && this.isTouchedOrDirty) { + if (this.hasValidators && this.inputGroup.isFocused) { + this.inputDirective.valid = this._ngControl.valid ? IgxInputState.VALID : IgxInputState.INVALID; + } else { + this.inputDirective.valid = this._ngControl.valid ? IgxInputState.INITIAL : IgxInputState.INVALID; + } + } else { + this.inputDirective.valid = IgxInputState.INITIAL; + } + } + + private get isTouchedOrDirty(): boolean { + return (this._ngControl.control.touched || this._ngControl.control.dirty); + } + + private get hasValidators(): boolean { + return (!!this._ngControl.control.validator || !!this._ngControl.control.asyncValidator); + } + + private onStatusChanged = () => { + this.disabled = this._ngControl.disabled; + this.updateValidity(); + this.inputGroup.isRequired = this.required; + }; + + private handleSelection(date: Date): void { + if (this.dateValue && DateTimeUtil.isValidDate(this.dateValue)) { + date.setHours(this.dateValue.getHours()); + date.setMinutes(this.dateValue.getMinutes()); + date.setSeconds(this.dateValue.getSeconds()); + date.setMilliseconds(this.dateValue.getMilliseconds()); + } + this.value = date; + if (this._calendar) { + this._calendar.activeDate = this.activeDate; + this._calendar.viewDate = this.activeDate; + } + this.close(); + } + + private subscribeToDateEditorEvents(): void { + this.dateTimeEditor.valueChange.pipe( + takeUntil(this._destroy$)).subscribe(val => { + this.value = val; + }); + this.dateTimeEditor.validationFailed.pipe( + takeUntil(this._destroy$)).subscribe((event) => { + this.validationFailed.emit({ + owner: this, + prevValue: event.oldValue, + currentValue: this.value + }); + }); + } + + private subscribeToOverlayEvents() { + this._overlayService.opening.pipe(...this._overlaySubFilter).subscribe((e: OverlayCancelableEventArgs) => { + const args: IBaseCancelableBrowserEventArgs = { owner: this, event: e.event, cancel: e.cancel }; + this.opening.emit(args); + e.cancel = args.cancel; + if (args.cancel) { + this._overlayService.detach(this._overlayId); + return; + } + + this._initializeCalendarContainer(e.componentRef.instance); + this._calendarContainer = e.componentRef.location.nativeElement; + this._collapsed = false; + }); + + this._overlayService.opened.pipe(...this._overlaySubFilter).subscribe(() => { + this.opened.emit({ owner: this }); + + this._calendar.wrapper?.nativeElement?.focus(); + }); + + this._overlayService.closing.pipe(...this._overlaySubFilter).subscribe((e: OverlayCancelableEventArgs) => { + const args: IBaseCancelableBrowserEventArgs = { owner: this, event: e.event, cancel: e.cancel }; + this.closing.emit(args); + e.cancel = args.cancel; + if (args.cancel) { + return; + } + // do not focus the input if clicking outside in dropdown mode + const outsideEvent = args.event && (args.event as KeyboardEvent).key !== this.platform.KEYMAP.ESCAPE; + if (this.getEditElement() && !(outsideEvent && this.isDropdown)) { + this.inputDirective.focus(); + } else { + this._onTouchedCallback(); + this.updateValidity(); + } + }); + + this._overlayService.closed.pipe(...this._overlaySubFilter).subscribe(() => { + this.closed.emit({ owner: this }); + this._overlayService.detach(this._overlayId); + this._collapsed = true; + this._overlayId = null; + this._calendar = null; + this._calendarContainer = undefined; + }); + } + + private getMinMaxDates() { + const minValue = DateTimeUtil.isValidDate(this.minValue) ? this.minValue : DateTimeUtil.parseIsoDate(this.minValue); + const maxValue = DateTimeUtil.isValidDate(this.maxValue) ? this.maxValue : DateTimeUtil.parseIsoDate(this.maxValue); + return { minValue, maxValue }; + } + + private setDisabledDates(): void { + this._calendar.disabledDates = this.disabledDates ? [...this.disabledDates] : []; + const { minValue, maxValue } = this.getMinMaxDates(); + if (minValue) { + this._calendar.disabledDates.push({ type: DateRangeType.Before, dateRange: [minValue] }); + } + if (maxValue) { + this._calendar.disabledDates.push({ type: DateRangeType.After, dateRange: [maxValue] }); + } + } + + private _initializeCalendarContainer(componentInstance: IgxCalendarContainerComponent) { + this._calendar = componentInstance.calendar; + this._calendar.hasHeader = !this.isDropdown && !this.hideHeader; + this._calendar.formatOptions = this.pickerCalendarFormat; + this._calendar.formatViews = this.pickerFormatViews; + this._calendar.locale = this.locale; + this._calendar.weekStart = this.weekStart; + this._calendar.specialDates = this.specialDates; + this._calendar.headerTitleTemplate = this.headerTitleTemplate; + this._calendar.headerTemplate = this.headerTemplate; + this._calendar.subheaderTemplate = this.subheaderTemplate; + this._calendar.headerOrientation = this.headerOrientation; + this._calendar.hideOutsideDays = this.hideOutsideDays; + this._calendar.monthsViewNumber = this.displayMonthsCount; + this._calendar.showWeekNumbers = this.showWeekNumbers; + this._calendar.orientation = this.orientation; + this._calendar.selected.pipe(takeUntil(this._destroy$)).subscribe((ev: Date) => this.handleSelection(ev)); + this.setDisabledDates(); + + if (DateTimeUtil.isValidDate(this.dateValue)) { + // calendar will throw if the picker's value is InvalidDate #9208 + this._calendar.value = this.dateValue; + } + this._calendar.activeDate = this.activeDate; + this._calendar.viewDate = this.activeDate; + + componentInstance.mode = this.mode; + componentInstance.closeButtonLabel = this.cancelButtonLabel; + componentInstance.todayButtonLabel = this.todayButtonLabel; + componentInstance.pickerActions = this.pickerActions; + + componentInstance.calendarClose.pipe(takeUntil(this._destroy$)).subscribe(() => this.close()); + componentInstance.todaySelection.pipe(takeUntil(this._destroy$)).subscribe(() => this.selectToday()); + } +} diff --git a/projects/igniteui-angular/date-picker/src/date-picker/date-picker.module.ts b/projects/igniteui-angular/date-picker/src/date-picker/date-picker.module.ts new file mode 100644 index 00000000000..b7adeacfada --- /dev/null +++ b/projects/igniteui-angular/date-picker/src/date-picker/date-picker.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { IGX_DATE_PICKER_DIRECTIVES } from './public_api'; + +/** + * @hidden + * @deprecated + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + ...IGX_DATE_PICKER_DIRECTIVES + ], + exports: [ + ...IGX_DATE_PICKER_DIRECTIVES + ] +}) +export class IgxDatePickerModule { } diff --git a/projects/igniteui-angular/date-picker/src/date-picker/picker-base.directive.ts b/projects/igniteui-angular/date-picker/src/date-picker/picker-base.directive.ts new file mode 100644 index 00000000000..270274efafd --- /dev/null +++ b/projects/igniteui-angular/date-picker/src/date-picker/picker-base.directive.ts @@ -0,0 +1,365 @@ +import { + AfterContentChecked, + AfterViewInit, booleanAttribute, ContentChildren, Directive, ElementRef, EventEmitter, + inject, + Input, LOCALE_ID, OnDestroy, Output, QueryList, ViewChild +} from '@angular/core'; +import { getLocaleFirstDayOfWeek } from "@angular/common"; + +import { merge, Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { IGX_INPUT_GROUP_TYPE, IgxInputGroupComponent, IgxInputGroupType, IgxPrefixDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; +import { DateRange, EditorProvider, IBaseCancelableBrowserEventArgs, IBaseEventArgs, IgxOverlayOutletDirective, IgxPickerClearComponent, IgxPickerToggleComponent, IToggleView, OverlaySettings, PickerHeaderOrientation, PickerInteractionMode, WEEKDAYS } from 'igniteui-angular/core'; + +@Directive() +export abstract class PickerBaseDirective implements IToggleView, EditorProvider, AfterViewInit, AfterContentChecked, OnDestroy { + public element = inject(ElementRef); + protected _localeId = inject(LOCALE_ID); + protected _inputGroupType = inject(IGX_INPUT_GROUP_TYPE, { optional: true }); + + /** + * The editor's input mask. + * + * @remarks + * Also used as a placeholder when none is provided. + * + * @example + * ```html + * + * ``` + */ + @Input() + public inputFormat: string; + + /** + * The format used to display the picker's value when it's not being edited. + * + * @remarks + * Uses Angular's DatePipe. + * + * @example + * ```html + * + * ``` + * + */ + @Input() + public displayFormat: string; + + /** + * Sets the `placeholder` of the picker's input. + * + * @example + * ```html + * + * ``` + */ + @Input() + public placeholder = ''; + + /** + * Can be `dropdown` with editable input field or `dialog` with readonly input field. + * + * @remarks + * Default mode is `dropdown` + * + * @example + * ```html + * + * ``` + */ + @Input() + public mode: PickerInteractionMode = PickerInteractionMode.DropDown; + + /** + * Gets/Sets the orientation of the `IgxDatePickerComponent` header. + * + * @example + * ```html + * + * ``` + */ + @Input() + public headerOrientation: PickerHeaderOrientation = PickerHeaderOrientation.Horizontal; + + /** + * Gets/Sets whether the header is hidden in dialog mode. + * + * @example + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public hideHeader = false; + + /** + * Overlay settings used to display the pop-up element. + * + * @example + * ```html + * + * ``` + */ + @Input() + public overlaySettings: OverlaySettings; + + /** + * Enables or disables the picker. + * + * @example + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public disabled = false; + + /** + * @example + * ```html + * + * ``` + */ + /** + * Gets the `locale` of the date-picker. + * If not set, defaults to applciation's locale.. + */ + @Input() + public get locale(): string { + return this._locale; + } + + /** + * Sets the `locale` of the date-picker. + * Expects a valid BCP 47 language tag. + */ + public set locale(value: string) { + this._locale = value; + // if value is invalid, set it back to _localeId + try { + getLocaleFirstDayOfWeek(this._locale); + } catch (e) { + this._locale = this._localeId; + } + } + + /** + * Gets the start day of the week. + * Can return a numeric or an enum representation of the week day. + * If not set, defaults to the first day of the week for the application locale. + */ + @Input() + public get weekStart(): WEEKDAYS | number { + return this._weekStart ?? getLocaleFirstDayOfWeek(this._locale); + } + + /** + * Sets the start day of the week. + * Can be assigned to a numeric value or to `WEEKDAYS` enum value. + */ + public set weekStart(value: WEEKDAYS | number) { + this._weekStart = value; + } + + /** + * The container used for the pop-up element. + * + * @example + * ```html + *
+ * + * + * + * ``` + */ + @Input() + public outlet: IgxOverlayOutletDirective | ElementRef; + + /** + * Determines how the picker's input will be styled. + * + * @remarks + * Default is `box`. + * + * @example + * ```html + * + * ``` + */ + @Input() + public set type(val: IgxInputGroupType) { + this._type = val; + } + public get type(): IgxInputGroupType { + return this._type || this._inputGroupType; + } + + /** + * Gets/Sets the default template editor's tabindex. + * + * @example + * ```html + * + * ``` + */ + @Input() + public tabIndex: number | string; + + /** + * Emitted when the calendar has started opening, cancelable. + * + * @example + * ```html + * + * ``` + */ + @Output() + public opening = new EventEmitter(); + + /** + * Emitted after the calendar has opened. + * + * @example + * ```html + * + * ``` + */ + @Output() + public opened = new EventEmitter(); + + /** + * Emitted when the calendar has started closing, cancelable. + * + * @example + * ```html + * + * ``` + */ + @Output() + public closing = new EventEmitter(); + + /** + * Emitted after the calendar has closed. + * + * @example + * ```html + * + * ``` + */ + @Output() + public closed = new EventEmitter(); + + /** @hidden @internal */ + @ContentChildren(IgxPickerToggleComponent, { descendants: true }) + public toggleComponents: QueryList; + + /** @hidden @internal */ + @ContentChildren(IgxPickerClearComponent, { descendants: true }) + public clearComponents: QueryList; + + @ContentChildren(IgxPrefixDirective, { descendants: true }) + protected prefixes: QueryList; + + @ContentChildren(IgxSuffixDirective, { descendants: true }) + protected suffixes: QueryList; + + @ViewChild(IgxInputGroupComponent) + protected inputGroup: IgxInputGroupComponent; + + protected _locale: string; + protected _collapsed = true; + protected _type: IgxInputGroupType; + protected _minValue: Date | string; + protected _maxValue: Date | string; + protected _weekStart: WEEKDAYS | number; + protected abstract get toggleContainer(): HTMLElement | undefined; + + /** + * Gets the picker's pop-up state. + * + * @example + * ```typescript + * const state = this.picker.collapsed; + * ``` + */ + public get collapsed(): boolean { + return this._collapsed; + } + + /** @hidden @internal */ + public get isDropdown(): boolean { + return this.mode === PickerInteractionMode.DropDown; + } + + /** + * Returns if there's focus within the picker's element OR popup container + * @hidden @internal + */ + public get isFocused(): boolean { + const document = this.element.nativeElement?.getRootNode() as Document | ShadowRoot; + if (!document?.activeElement) return false; + + return this.element.nativeElement.contains(document.activeElement) + || !this.collapsed && this.toggleContainer.contains(document.activeElement); + } + + protected _destroy$ = new Subject(); + + // D.P. EventEmitter throws on strict checks for more restrictive overrides + // w/ TS2416 Type 'string | Date ...' not assignable to type 'DateRange' due to observer method check + public abstract valueChange: EventEmitter; + + constructor() { + this.locale = this.locale || this._localeId; + } + + /** @hidden @internal */ + public ngAfterViewInit(): void { + this.subToIconsClicked(this.toggleComponents, () => this.toggle()); + this.subToIconsClicked(this.clearComponents, () => this.clear()); + } + + /** @hidden @internal */ + public ngAfterContentChecked(): void { + if (this.inputGroup && this.prefixes?.length > 0) { + this.inputGroup.prefixes = this.prefixes; + } + + if (this.inputGroup && this.suffixes?.length > 0) { + this.inputGroup.suffixes = this.suffixes; + } + } + + /** @hidden @internal */ + public ngOnDestroy(): void { + this._destroy$.next(); + this._destroy$.complete(); + } + + /** Subscribes to the click events of toggle/clear icons in a query */ + private subToIconsClicked( + components: QueryList, + handler: () => void + ): void { + const subscribeToClick = componentList => { + componentList.forEach(component => { + component.clicked + .pipe(takeUntil(merge(componentList.changes, this._destroy$))) + .subscribe(handler); + }); + }; + + subscribeToClick(components); + + components.changes.pipe(takeUntil(this._destroy$)) + .subscribe(() => subscribeToClick(components)); + } + + public abstract select(value: Date | DateRange | string): void; + public abstract open(settings?: OverlaySettings): void; + public abstract toggle(settings?: OverlaySettings): void; + public abstract close(): void; + public abstract clear(): void; + public abstract getEditElement(): HTMLInputElement; +} diff --git a/projects/igniteui-angular/date-picker/src/date-picker/public_api.ts b/projects/igniteui-angular/date-picker/src/date-picker/public_api.ts new file mode 100644 index 00000000000..ac5f3883392 --- /dev/null +++ b/projects/igniteui-angular/date-picker/src/date-picker/public_api.ts @@ -0,0 +1,24 @@ +import { IgxPickerActionsDirective, IgxPickerClearComponent, IgxPickerToggleComponent } from 'igniteui-angular/core'; +import { IgxDatePickerComponent } from './date-picker.component'; +import { IgxHintDirective, IgxLabelDirective, IgxPrefixDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; +import { IgxCalendarHeaderTemplateDirective, IgxCalendarSubheaderTemplateDirective, IgxCalendarHeaderTitleTemplateDirective } from 'igniteui-angular/calendar'; + +export * from './date-picker.common'; +export * from './date-picker.component'; +export * from './calendar-container/calendar-container.component'; +export * from './picker-base.directive'; + +/* NOTE: Date picker directives collection for ease-of-use import in standalone components scenario */ +export const IGX_DATE_PICKER_DIRECTIVES = [ + IgxDatePickerComponent, + IgxPickerToggleComponent, + IgxPickerClearComponent, + IgxPickerActionsDirective, + IgxLabelDirective, + IgxPrefixDirective, + IgxSuffixDirective, + IgxHintDirective, + IgxCalendarHeaderTemplateDirective, + IgxCalendarSubheaderTemplateDirective, + IgxCalendarHeaderTitleTemplateDirective +] as const; diff --git a/projects/igniteui-angular/date-picker/src/date-range-picker/README.md b/projects/igniteui-angular/date-picker/src/date-range-picker/README.md new file mode 100644 index 00000000000..ca7d824b83d --- /dev/null +++ b/projects/igniteui-angular/date-picker/src/date-range-picker/README.md @@ -0,0 +1,140 @@ +# igx-date-range-picker Component + +The `igx-date-range-picker` component allows you to select a range of dates from a calendar UI or editable input fields. + +A getting started guide can be found [here](). + +## Dependencies +In order to use the `igx-date-range-picker` component you must import the `BrowserAnimationModule` **once** in your application: +```typescript +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +@NgModule({ + imports: [ + ... + BrowserAnimationsModule + ... + ] +}) +export class AppModule { } +``` + +# Usage +Import the `IgxDateRangePickerModule` in the module that you want to use it in: +```typescript +import { IgxDateRangePickerModule } from 'igniteui-angular'; + +@NgModule({ + imports: [IgxDateRangePickerModule] +}) +export class AppModule { } +``` + +As for the component that you want to use `igx-date-range-picker` in, import `IgxDateRangePickerComponent`: + +```typescript +import { IgxDateRangePickerComponent, DateRange } from 'igniteui-angular'; + +@Component({ + selector: 'my-component', + template: ``, + +}) +export class MyComponent { + @ViewChild(IgxDateRangePickerComponent, { read: IgxDateRangePickerComponent }) + public dateRange: IgxDateRangePickerComponent; + public range: DateRange; +} +``` + +The default initialization produces a single *readonly* input: +```html + +``` + +With `IgxDateRangeStartComponent`, `IgxDateRangeEndComponent` and `IgxDateTimeEditorDirective` two *editable* inputs can be projected: +```html + + + + + + + + +``` + +`IgxDateRangePickerComponent` supports templating of its calendar icon: + +The default template: +```html + + + calendar_view_day + + +``` + +With projected inputs: +```html + + + + calendar_view_day + + + + + +``` + +`IgxDateRangePicker` with first day of the week set to `Monday` and handler when a range selection is made: +```html + +``` + +`IgxDateRangePicker` that opens a calendar with more than `2` views and also hides days that are not part of each month: +```html + +``` + +`IgxDateRangePicker` in a `drop-down` mode. +```html + +``` + + +# API + +### Inputs +| Name | Type | Description | +|:-----------------|:-------------------|:------------| +| doneButtonText | string | Changes the default text of the `done` button. It will show up only in `dialog` mode. Default value is `Done`. | +| displayMonthsCount | number | Sets the number displayed month views. Default is `2`. | +| formatter | function => string | Applies a custom formatter function on the selected or passed date. | +| hideOutsideDays | boolean | Sets whether dates that are not part of the current month will be displayed. Default value is `false`. | +| locale | string | Gets the `locale` of the calendar. Default value is `"en"`. | +| mode | PickerInteractionMode | Sets whether `IgxDateRangePickerComponent` is in dialog or dropdown mode. Default is `dialog` | +| minValue | Date \| string | The minimum value in a valid range. | +| maxValue | Date \| string | The maximum value in a valid range. | +| outlet | IgxOverlayOutletDirective \| ElementRef | Gets/Sets the container used for the popup element. +| overlaySettings | OverlaySettings | Changes the default overlay settings used by the `IgxDateRangePickerComponent`. | +| placeholder | string | Sets the `placeholder` for single-input `IgxDateRangePickerComponent`. | +| weekStart | number | Sets the start day of the week. Can be assigned to a numeric value or to `WEEKDAYS` enum value. | +| showWeekNumbers | number | Shows or hides week numbers. | + +### Outputs +| Name | Description | Cancelable | Emitted with | +|:------------|:-------------------------------------------------------------------|------------|:--------------------------------| +| valueChange | Emitted when the picker's value changes. Used for two-way binding. | false | DateRange | +| opening | Emitted when the calendar starts opening, cancelable. | true | IBaseCancelableBrowserEventArgs | +| opened | Emitted when the `IgxDateRangePickerComponent` is opened. | false | IBaseEventArgs | +| closing | Emitted when the calendar starts closing, cancelable. | true | IBaseCancelableBrowserEventArgs | +| closed | Emitted when the `IgxDateRangePickerComponent` is closed. | false | IBaseEventArgs | + +### Methods +| Name | Arguments | Return Type | Description | +|:-----------:|:--------------|:------------|:------------| +| open | n/a | void | Opens the date picker's dropdown or dialog. | +| close | n/a | void | Closes the date picker's dropdown or dialog. | +| value | n/a | DateRange | Gets/sets the currently selected value / range from the calendar. | +| select | startDate, endDate | void | Selects a range of dates, clears previous selection. | diff --git a/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker-inputs.common.ts b/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker-inputs.common.ts new file mode 100644 index 00000000000..785dde88d48 --- /dev/null +++ b/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker-inputs.common.ts @@ -0,0 +1,187 @@ +import { Component, ContentChild, Pipe, PipeTransform, Directive } from '@angular/core'; +import { NgControl } from '@angular/forms'; +import { IgxInputDirective, IgxInputGroupBase, IgxInputGroupComponent, IgxInputState, IgxPrefixDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; +import { IgxButtonDirective, IgxDateTimeEditorDirective } from 'igniteui-angular/directives'; +import { isDate, DateRange, DateTimeUtil } from 'igniteui-angular/core'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { NgTemplateOutlet } from '@angular/common'; + +/** @hidden @internal */ +@Pipe({ + name: 'dateRange', + standalone: true +}) +export class DateRangePickerFormatPipe implements PipeTransform { + public transform(values: DateRange, appliedFormat?: string, + locale?: string, formatter?: (_: DateRange) => string): string { + if (!values || !values.start && !values.end) { + return ''; + } + if (formatter) { + return formatter(values); + } + let { start, end } = values; + if (!isDate(start)) { + start = DateTimeUtil.parseIsoDate(start); + } + if (!isDate(end)) { + end = DateTimeUtil.parseIsoDate(end); + } + const startDate = appliedFormat ? DateTimeUtil.formatDate(start, appliedFormat, locale || 'en') : start?.toLocaleDateString(); + const endDate = appliedFormat ? DateTimeUtil.formatDate(end, appliedFormat, locale || 'en') : end?.toLocaleDateString(); + let formatted; + if (start) { + formatted = `${startDate} - `; + if (end) { + formatted += endDate; + } + } + + return formatted ? formatted : ''; + } +} + +/** @hidden @internal */ +@Component({ + template: ``, + selector: `igx-date-range-base`, + providers: [{ provide: IgxInputGroupBase, useExisting: IgxDateRangeInputsBaseComponent }], + standalone: true +}) +export class IgxDateRangeInputsBaseComponent extends IgxInputGroupComponent { + @ContentChild(IgxDateTimeEditorDirective) + public dateTimeEditor: IgxDateTimeEditorDirective; + + @ContentChild(IgxInputDirective) + public inputDirective: IgxInputDirective; + + @ContentChild(NgControl) + protected ngControl: NgControl; + + /** @hidden @internal */ + public get nativeElement() { + return this.element.nativeElement; + } + + /** @hidden @internal */ + public setFocus(): void { + this.input.focus(); + } + + /** @hidden @internal */ + public updateInputValue(value: Date) { + if (this.ngControl) { + this.ngControl.control.setValue(value); + } else { + this.dateTimeEditor.value = value; + } + } + + /** @hidden @internal */ + public updateInputValidity(state: IgxInputState) { + this.inputDirective.valid = state; + } +} + +/** + * Defines the start input for a date range picker + * + * @igxModule IgxDateRangePickerModule + * + * @igxTheme igx-input-group-theme, igx-calendar-theme, igx-date-range-picker-theme + * + * @igxKeywords date, range, date range, date picker + * + * @igxGroup scheduling + * + * @remarks + * When templating, start input has to be templated separately + * + * @example + * ```html + * + * + * + * + * ... + * + * ``` + */ +@Component({ + selector: 'igx-date-range-start', + templateUrl: '../../../input-group/src/input-group/input-group.component.html', + providers: [ + { provide: IgxInputGroupBase, useExisting: IgxDateRangeStartComponent }, + { provide: IgxDateRangeInputsBaseComponent, useExisting: IgxDateRangeStartComponent } + ], + imports: [NgTemplateOutlet, IgxPrefixDirective, IgxButtonDirective, IgxSuffixDirective, IgxIconComponent] +}) +export class IgxDateRangeStartComponent extends IgxDateRangeInputsBaseComponent { } + +/** + * Defines the end input for a date range picker + * + * @igxModule IgxDateRangePickerModule + * + * @igxTheme igx-input-group-theme, igx-calendar-theme, igx-date-range-picker-theme + * + * @igxKeywords date, range, date range, date picker + * + * @igxGroup scheduling + * + * @remarks + * When templating, end input has to be template separately + * + * @example + * ```html + * + * ... + * + * + * + * + * ``` + */ +@Component({ + selector: 'igx-date-range-end', + templateUrl: '../../../input-group/src/input-group/input-group.component.html', + providers: [ + { provide: IgxInputGroupBase, useExisting: IgxDateRangeEndComponent }, + { provide: IgxDateRangeInputsBaseComponent, useExisting: IgxDateRangeEndComponent } + ], + imports: [NgTemplateOutlet, IgxPrefixDirective, IgxButtonDirective, IgxSuffixDirective, IgxIconComponent] +}) +export class IgxDateRangeEndComponent extends IgxDateRangeInputsBaseComponent { } + +/** + * Replaces the default separator `to` with the provided value + * + * @igxModule IgxDateRangePickerModule + * + * @igxTheme igx-date-range-picker-theme + * + * @igxKeywords date, range, date range, date picker + * + * @igxGroup scheduling + * + * @example + * ```html + * + * + * + * + * + * - + * + * + * + * + * ... + * + * ``` + */ +@Directive({ + selector: '[igxDateRangeSeparator]', + standalone: true +}) +export class IgxDateRangeSeparatorDirective { } diff --git a/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.html b/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.html new file mode 100644 index 00000000000..80b9243cd6b --- /dev/null +++ b/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.html @@ -0,0 +1,60 @@ + + + +
+ +
+
+ + + +
+ + +
+ +
+ + + + + + + + + +{{ dateSeparator }} + + + + + + + @if (!toggleComponents.length) { + + + + } + + @if (!clearComponents.length && value) { + + + + } + + + + + + + + + + + + + + + diff --git a/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.spec.ts b/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.spec.ts new file mode 100644 index 00000000000..f9fb858e552 --- /dev/null +++ b/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.spec.ts @@ -0,0 +1,2537 @@ +import { ComponentFixture, TestBed, fakeAsync, tick, waitForAsync, flush } from '@angular/core/testing'; +import { Component, OnInit, ViewChild, DebugElement, ChangeDetectionStrategy, inject, ChangeDetectorRef, ElementRef } from '@angular/core'; +import { IgxInputDirective, IgxInputGroupComponent, IgxInputState, IgxLabelDirective, IgxPrefixDirective, IgxSuffixDirective } from '../../../input-group/src/public_api'; +import { CustomDateRange, DateRange, PickerCalendarOrientation, PickerHeaderOrientation, PickerInteractionMode } from '../../../core/src/date-common/types'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { FormsModule, ReactiveFormsModule, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +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, WEEKDAYS } from 'igniteui-angular/core'; +import { IgxDateRangeSeparatorDirective, IgxDateRangeStartComponent } from './date-range-picker-inputs.common'; +import { IgxDateTimeEditorDirective } from '../../../directives/src/directives/date-time-editor/date-time-editor.directive'; +import { DateRangeType } from 'igniteui-angular/core'; +import { IgxDateRangePickerComponent, IgxDateRangeEndComponent } from './public_api'; +import { AutoPositionStrategy, IgxOverlayService } from 'igniteui-angular/core'; +import { Subject } from 'rxjs'; +import { AsyncPipe } from '@angular/common'; +import { IgxAngularAnimationService } from 'igniteui-angular/core'; +import { IgxPickerClearComponent, IgxPickerToggleComponent } from '../../../core/src/date-common/picker-icons.common'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { registerLocaleData } from "@angular/common"; +import localeJa from "@angular/common/locales/ja"; +import localeBg from "@angular/common/locales/bg"; +import { CalendarDay } from 'igniteui-angular/core'; +import { IgxCalendarComponent, IgxCalendarHeaderTemplateDirective, IgxCalendarHeaderTitleTemplateDirective, IgxCalendarSubheaderTemplateDirective } from 'igniteui-angular/calendar'; +import { KeyboardNavigationService } from 'igniteui-angular/calendar/src/calendar/calendar.services'; + +// The number of milliseconds in one day +const DEBOUNCE_TIME = 16; +const DEFAULT_ICON_TEXT = 'date_range'; +const CLEAR_ICON_TEXT = 'clear'; +const DEFAULT_FORMAT_OPTIONS = { day: '2-digit', month: '2-digit', year: 'numeric' }; +const CSS_CLASS_INPUT_BUNDLE = '.igx-input-group__bundle'; +const CSS_CLASS_INPUT_START = '.igx-input-group__bundle-start' +const CSS_CLASS_INPUT_END = '.igx-input-group__bundle-end' +const CSS_CLASS_INPUT = '.igx-input-group__input'; +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_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'; +const CSS_CLASS_CALENDAR_DATE = 'igx-days-view__date'; +const CSS_CLASS_INACTIVE_DATE = 'igx-days-view__date--inactive'; +const CSS_CLASS_CALENDAR_HEADER_TEMPLATE = '.igx-calendar__header-date'; +const CSS_CLASS_CALENDAR_HEADER_TITLE = '.igx-calendar__header-year'; +const CSS_CLASS_CALENDAR_SUBHEADER = '.igx-calendar-picker__dates'; +const CSS_CLASS_CALENDAR_HEADER = '.igx-calendar__header'; +const CSS_CLASS_CALENDAR_WRAPPER_VERTICAL = 'igx-calendar__wrapper--vertical'; +describe('IgxDateRangePicker', () => { + describe('Unit tests: ', () => { + let mockElement: any; + let mockCalendar: IgxCalendarComponent; + let mockDaysView: any; + let mockCdr: any; + let fixture: any; + let dateRange: IgxDateRangePickerComponent; + const elementRef = { nativeElement: null }; + const mockNgControl = jasmine.createSpyObj('NgControl', + ['registerOnChangeCb', + 'registerOnTouchedCb', + 'registerOnValidatorChangeCb']); + /* eslint-disable @typescript-eslint/no-unused-vars */ + beforeEach(() => { + mockElement = { + style: { visibility: '', cursor: '', transitionDuration: '' }, + classList: { add: () => { }, remove: () => { } }, + appendChild: () => { }, + removeChild: () => { }, + addEventListener: (type: string, listener: (this: HTMLElement, ev: MouseEvent) => any) => { }, + removeEventListener: (type: string, listener: (this: HTMLElement, ev: MouseEvent) => any) => { }, + getBoundingClientRect: () => ({ width: 10, height: 10 }), + insertBefore: (newChild: HTMLDivElement, refChild: Node) => { }, + contains: () => { } + }; + mockElement.parent = mockElement; + mockElement.parentElement = mockElement; + + mockCdr = jasmine.createSpyObj('ChangeDetectorRef', { + detectChanges: () => { } + }); + + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], + providers: [ + { provide: ElementRef, useValue: elementRef }, + IgxAngularAnimationService, + IgxOverlayService, + IgxCalendarComponent, + KeyboardNavigationService, + ChangeDetectorRef, + ] + }); + + fixture = TestBed.createComponent(IgxDateRangePickerComponent); + dateRange = fixture.componentInstance; + (dateRange as any)._cdr = mockCdr; + + mockCalendar = TestBed.inject(IgxCalendarComponent); + + mockDaysView = { + focusActiveDate: jasmine.createSpy() + } as any; + mockCalendar.daysView = mockDaysView; + }); + /* eslint-enable @typescript-eslint/no-unused-vars */ + it('should set range dates correctly through selectRange method', () => { + //const dateRange = TestBed.inject(IgxDateRangePickerComponent); + // dateRange.calendar = calendar; + let startDate = new Date(2020, 3, 7); + const endDate = new Date(2020, 6, 27); + + // select range + dateRange.select(startDate, endDate); + expect(dateRange.value.start).toEqual(startDate); + expect(dateRange.value.end).toEqual(endDate); + + // select startDate only + startDate = new Date(2023, 2, 11); + dateRange.select(startDate); + expect(dateRange.value.start).toEqual(startDate); + expect(dateRange.value.end).toEqual(startDate); + }); + + it('should emit valueChange on selection', () => { + //const dateRange = TestBed.inject(IgxDateRangePickerComponent); + // dateRange.calendar = calendar; + spyOn(dateRange.valueChange, 'emit'); + let startDate = new Date(2017, 4, 5); + const endDate = new Date(2017, 11, 22); + + // select range + dateRange.select(startDate, endDate); + expect(dateRange.value.start).toEqual(startDate); + expect(dateRange.value.end).toEqual(endDate); + expect(dateRange.valueChange.emit).toHaveBeenCalledTimes(1); + expect(dateRange.valueChange.emit).toHaveBeenCalledWith({ start: startDate, end: endDate }); + + // select startDate only + startDate = new Date(2024, 12, 15); + dateRange.select(startDate); + expect(dateRange.value.start).toEqual(startDate); + expect(dateRange.value.end).toEqual(startDate); + expect(dateRange.valueChange.emit).toHaveBeenCalledTimes(2); + expect(dateRange.valueChange.emit).toHaveBeenCalledWith({ start: startDate, end: startDate }); + }); + + it('should correctly implement interface methods - ControlValueAccessor', () => { + const range = { start: new Date(2020, 1, 18), end: new Date(2020, 1, 28) }; + const rangeUpdate = { start: new Date(2020, 2, 22), end: new Date(2020, 2, 25) }; + + // init + dateRange.registerOnChange(mockNgControl.registerOnChangeCb); + dateRange.registerOnTouched(mockNgControl.registerOnTouchedCb); + spyOn(dateRange as any, 'handleSelection').and.callThrough(); + + // writeValue + expect(dateRange.value).toBeUndefined(); + expect(mockNgControl.registerOnChangeCb).not.toHaveBeenCalled(); + dateRange.writeValue(range); + expect(dateRange.value).toBe(range); + + // set value & handleSelection call _onChangeCallback + dateRange.value = rangeUpdate; + expect(mockNgControl.registerOnChangeCb).toHaveBeenCalledWith(rangeUpdate); + + (dateRange as any).handleSelection([range.start]); + expect((dateRange as any).handleSelection).toHaveBeenCalledWith([range.start]); + expect((dateRange as any).handleSelection).toHaveBeenCalledTimes(1); + expect(mockNgControl.registerOnChangeCb).toHaveBeenCalledWith({ start: range.start, end: range.start }); + + // awaiting implementation - OnTouched callback + // Docs: changes the value, turning the control dirty; or blurs the form control element, setting the control to touched. + // when handleSelection fires should be touched&dirty // when input is blurred(two inputs), should be touched. + (dateRange as any).handleSelection([range.start]); + (dateRange as any).updateValidityOnBlur(); + expect(mockNgControl.registerOnTouchedCb).toHaveBeenCalledTimes(1); + + dateRange.setDisabledState(true); + expect(dateRange.disabled).toBe(true); + dateRange.setDisabledState(false); + expect(dateRange.disabled).toBe(false); + }); + + it('should validate correctly minValue and maxValue', () => { + dateRange.ngOnInit(); + + dateRange.registerOnChange(mockNgControl.registerOnChangeCb); + dateRange.registerOnValidatorChange(mockNgControl.registerOnValidatorChangeCb); + + dateRange.minValue = new Date(2020, 4, 7); + expect(mockNgControl.registerOnValidatorChangeCb).toHaveBeenCalledTimes(1); + dateRange.maxValue = new Date(2020, 8, 7); + expect(mockNgControl.registerOnValidatorChangeCb).toHaveBeenCalledTimes(2); + + const range = { start: new Date(2020, 4, 18), end: new Date(2020, 6, 28) }; + dateRange.writeValue(range); + const mockFormControl = new UntypedFormControl(dateRange.value); + expect(dateRange.validate(mockFormControl)).toBeNull(); + + range.start.setMonth(2); + expect(dateRange.validate(mockFormControl)).toEqual({ minValue: true }); + + range.end.setMonth(10); + expect(dateRange.validate(mockFormControl)).toEqual({ minValue: true, maxValue: true }); + }); + + it('should disable calendar dates when min and/or max values as dates are provided', () => { + dateRange.ngOnInit(); + + spyOnProperty((dateRange as any), 'calendar').and.returnValue(mockCalendar); + dateRange.minValue = new Date(2000, 10, 1); + dateRange.maxValue = new Date(2000, 10, 20); + + (dateRange as any).updateCalendar(); + expect(mockCalendar.disabledDates.length).toEqual(2); + expect(mockCalendar.disabledDates[0].type).toEqual(DateRangeType.Before); + expect(mockCalendar.disabledDates[0].dateRange[0]).toEqual(dateRange.minValue); + expect(mockCalendar.disabledDates[1].type).toEqual(DateRangeType.After); + expect(mockCalendar.disabledDates[1].dateRange[0]).toEqual(dateRange.maxValue); + }); + + it('should disable calendar dates when min and/or max values as strings are provided', fakeAsync(() => { + dateRange.ngOnInit(); + + spyOnProperty((dateRange as any), 'calendar').and.returnValue(mockCalendar); + dateRange.minValue = '2000/10/1'; + dateRange.maxValue = '2000/10/30'; + + spyOn((dateRange as any).calendar, 'deselectDate').and.returnValue(null); + (dateRange as any).updateCalendar(); + expect((dateRange as any).calendar.disabledDates.length).toEqual(2); + expect((dateRange as any).calendar.disabledDates[0].type).toEqual(DateRangeType.Before); + expect((dateRange as any).calendar.disabledDates[0].dateRange[0]).toEqual(new Date(dateRange.minValue)); + expect((dateRange as any).calendar.disabledDates[1].type).toEqual(DateRangeType.After); + expect((dateRange as any).calendar.disabledDates[1].dateRange[0]).toEqual(new Date(dateRange.maxValue)); + })); + + it('should validate correctly when disabledDates are set', () => { + dateRange.ngOnInit(); + + dateRange.registerOnChange(mockNgControl.registerOnChangeCb); + dateRange.registerOnValidatorChange(mockNgControl.registerOnValidatorChangeCb); + mockNgControl.registerOnValidatorChangeCb.calls.reset(); + spyOnProperty((dateRange as any), 'calendar').and.returnValue(mockCalendar); + + const start = new Date(new Date().getFullYear(), new Date().getMonth(), 10); + const end = new Date(new Date().getFullYear(), new Date().getMonth(), 18); + + const disabledDates = [{ + type: DateRangeType.Between, dateRange: [ start, end ] + }]; + dateRange.disabledDates = disabledDates; + expect(mockNgControl.registerOnValidatorChangeCb).toHaveBeenCalledTimes(1); + + + const validRange = { + start: new Date(new Date().getFullYear(), new Date().getMonth(), 2), + end: new Date(new Date().getFullYear(), new Date().getMonth(), 5), + }; + dateRange.writeValue(validRange); + const mockFormControl = new UntypedFormControl(dateRange.value); + expect(dateRange.validate(mockFormControl)).toBeNull(); + + (dateRange as any).updateCalendar(); + expect((dateRange as any).calendar.disabledDates.length).toEqual(1); + expect((dateRange as any).calendar.disabledDates[0].type).toEqual(DateRangeType.Between); + + start.setDate(start.getDate() - 2); + dateRange.writeValue({ start, end }); + expect(dateRange.validate(mockFormControl)).toEqual({ dateIsDisabled: true }); + }); + }); + + describe('Integration tests', () => { + let fixture: ComponentFixture; + let dateRange: IgxDateRangePickerComponent; + let startDate: Date; + let endDate: Date; + let calendar: DebugElement | Element; + let calendarDays: DebugElement[] | HTMLCollectionOf; + + const selectDateRangeFromCalendar = (sDate: Date, eDate: Date, autoClose:boolean = true) => { + dateRange.open(); + fixture.detectChanges(); + calendarDays = document.getElementsByClassName(CSS_CLASS_CALENDAR_DATE); + const nodesArray = Array.from(calendarDays); + const findNodeIndex: (d: Date) => number = + (d: Date) => nodesArray + .findIndex( + n => n.attributes['aria-label'].value === d.toDateString() + && !n.classList.contains(CSS_CLASS_INACTIVE_DATE) + ); + const startIndex = findNodeIndex(sDate); + const endIndex = findNodeIndex(eDate); + if (startIndex === -1) { + throw new Error('Start date not found in calendar. Aborting.'); + } + UIInteractions.simulateClickAndSelectEvent(calendarDays[startIndex].firstChild as HTMLElement); + if (endIndex !== -1 && endIndex !== startIndex) { // do not click same date twice + UIInteractions.simulateClickAndSelectEvent(calendarDays[endIndex].firstChild as HTMLElement); + } + + fixture.detectChanges(); + + if (autoClose){ + dateRange.close(); + fixture.detectChanges(); + } + }; + + describe('Single Input', () => { + let singleInputElement: DebugElement; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + ReactiveFormsModule, + DateRangeDefaultComponent, + DateRangeDisabledComponent, + DateRangeReactiveFormComponent + ] + }).compileComponents(); + })); + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(DateRangeDefaultComponent); + fixture.detectChanges(); + dateRange = fixture.componentInstance.dateRange; + singleInputElement = fixture.debugElement.query(By.css(CSS_CLASS_INPUT)); + })); + + const verifyDateRangeInSingleInput = () => { + expect(dateRange.value.start).toEqual(startDate); + expect(dateRange.value.end).toEqual(endDate); + const inputStartDate = [startDate.getMonth() + 1, startDate.getDate(), startDate.getFullYear()].join('/'); + const inputEndDate = endDate ? [endDate.getMonth() + 1, endDate.getDate(), endDate.getFullYear()].join('/') : ''; + expect(singleInputElement.nativeElement.value).toEqual(`${inputStartDate} - ${inputEndDate}`); + }; + + describe('Selection tests', () => { + it('should assign range dates to the input when selecting a range from the calendar', () => { + fixture.componentInstance.mode = PickerInteractionMode.DropDown; + fixture.componentInstance.dateRange.displayFormat = 'M/d/yyyy'; + fixture.detectChanges(); + + const dayRange = 15; + const today = new Date(); + startDate = new Date(today.getFullYear(), today.getMonth(), 10, 0, 0, 0); + endDate = new Date(startDate); + endDate.setDate(endDate.getDate() + dayRange); + selectDateRangeFromCalendar(startDate, endDate); + verifyDateRangeInSingleInput(); + }); + + it('should assign range values correctly when selecting dates in reversed order', () => { + fixture.componentInstance.mode = PickerInteractionMode.DropDown; + fixture.componentInstance.dateRange.displayFormat = 'M/d/yyyy'; + fixture.detectChanges(); + + const today = new Date(); + startDate = new Date(today.getFullYear(), today.getMonth(), 5, 0, 0, 0); + endDate = new Date(today.getFullYear(), today.getMonth(), 10, 0, 0, 0); + selectDateRangeFromCalendar(endDate, startDate); + verifyDateRangeInSingleInput(); + }); + + it('should set start and end dates on single date selection', () => { + fixture.componentInstance.mode = PickerInteractionMode.DropDown; + fixture.componentInstance.dateRange.displayFormat = 'M/d/yyyy'; + fixture.detectChanges(); + + const today = new Date(); + startDate = new Date(today.getFullYear(), today.getMonth(), 10, 0, 0, 0); + endDate = startDate; + selectDateRangeFromCalendar(startDate, endDate); + verifyDateRangeInSingleInput(); + }); + + it('should update input correctly on first and last date selection', () => { + fixture.componentInstance.dateRange.displayFormat = 'M/d/yyyy'; + fixture.detectChanges(); + const today = new Date(); + startDate = new Date(today.getFullYear(), today.getMonth(), 1, 0, 0, 0); + endDate = new Date(today.getFullYear(), today.getMonth() + 2, 0, 0, 0, 0); + selectDateRangeFromCalendar(startDate, endDate); + verifyDateRangeInSingleInput(); + }); + + it('should assign range values correctly when selecting through API', () => { + fixture.componentInstance.dateRange.displayFormat = 'M/d/yyyy'; + fixture.detectChanges(); + startDate = new Date(2020, 10, 8, 0, 0, 0); + endDate = new Date(2020, 11, 8, 0, 0, 0); + dateRange.select(startDate, endDate); + fixture.detectChanges(); + verifyDateRangeInSingleInput(); + + startDate = new Date(2006, 5, 18, 0, 0, 0); + endDate = new Date(2006, 8, 18, 0, 0, 0); + dateRange.select(startDate, endDate); + fixture.detectChanges(); + verifyDateRangeInSingleInput(); + }); + }); + + describe('Clear tests', () => { + const range = { start: new Date(2025, 1, 1), end: new Date(2025, 1, 2) }; + + describe('Default clear icon', () => { + it('should display default clear icon when value is set', () => { + const inputGroup = fixture.debugElement.query(By.directive(IgxInputGroupComponent)); + let suffix = inputGroup.query(By.directive(IgxSuffixDirective)); + expect(suffix).toBeNull(); + + dateRange.value = range; + fixture.detectChanges(); + + suffix = inputGroup.query(By.directive(IgxSuffixDirective)); + const icon = suffix.query(By.css(CSS_CLASS_ICON)); + expect(icon).not.toBeNull(); + expect(icon.nativeElement.textContent.trim()).toEqual(CLEAR_ICON_TEXT); + }); + + it('should clear the value when clicking the default clear icon (suffix)', fakeAsync(() => { + dateRange.value = range; + fixture.detectChanges(); + + const inputGroup = fixture.debugElement.query(By.directive(IgxInputGroupComponent)); + let suffix = inputGroup.query(By.directive(IgxSuffixDirective)); + spyOn(dateRange.valueChange, 'emit'); + + UIInteractions.simulateClickAndSelectEvent(suffix.nativeElement); + tick(); + fixture.detectChanges(); + + expect(dateRange.value).toBeNull(); + suffix = inputGroup.query(By.directive(IgxSuffixDirective)); + expect(suffix).toBeNull(); + expect(dateRange.valueChange.emit).toHaveBeenCalledOnceWith(null); + })); + + it('should not clear the value when clicking element in the suffix that is not the clear icon', fakeAsync(() => { + fixture = TestBed.createComponent(DateRangeTemplatesComponent); + fixture.detectChanges(); + + dateRange = fixture.debugElement.queryAll(By.directive(IgxDateRangePickerComponent))[0].componentInstance; + dateRange.value = range; + fixture.detectChanges(); + + const suffixIconText = 'flight_land'; + const inputGroupsEnd = fixture.debugElement.queryAll(By.css(CSS_CLASS_INPUT_END)); + + const customSuffix = inputGroupsEnd[1]; + expect(customSuffix.children[0].nativeElement.innerText).toBe(suffixIconText); + expect(customSuffix.children[0].children[0].classes[CSS_CLASS_ICON]).toBeTruthy(); + + const suffix = inputGroupsEnd[0]; + const icon = suffix.query(By.css(CSS_CLASS_ICON)); + expect(icon).not.toBeNull(); + expect(icon.nativeElement.textContent.trim()).toEqual(CLEAR_ICON_TEXT); + + UIInteractions.simulateClickAndSelectEvent(customSuffix.nativeElement); + tick(); + fixture.detectChanges(); + + expect(dateRange.value).toEqual(range); + })); + }); + + describe('Projected clear icon', () => { + it('should clear the value when clicking the projected clear icon', fakeAsync(() => { + fixture = TestBed.createComponent(DateRangeTemplatesComponent); + fixture.detectChanges(); + + dateRange = fixture.debugElement.queryAll(By.directive(IgxDateRangePickerComponent))[4].componentInstance; + + const pickerClear = fixture.debugElement.queryAll(By.directive(IgxPickerClearComponent))[0]; + // Projected clear icon is rendered even if value is unassigned + expect(pickerClear).not.toBeNull(); + + const suffixes = dateRange.element.nativeElement.querySelectorAll(CSS_CLASS_INPUT_END); + expect(suffixes.length).toBe(1); + // the default clear icon is overridden by the projected one + expect(suffixes[0].textContent.trim()).toEqual('delete'); + + dateRange.value = range; + fixture.detectChanges(); + + spyOn(dateRange.valueChange, 'emit'); + UIInteractions.simulateClickAndSelectEvent(pickerClear.nativeElement); + tick(); + fixture.detectChanges(); + + expect(dateRange.value).toBeNull(); + expect(dateRange.valueChange.emit).toHaveBeenCalledOnceWith(null); + })); + }); + }); + + describe('Properties & events tests', () => { + it('should display placeholder', () => { + fixture.detectChanges(); + expect(singleInputElement.nativeElement.placeholder).toEqual('MM/dd/yyyy - MM/dd/yyyy'); + + const placeholder = 'Some placeholder'; + fixture.componentInstance.dateRange.placeholder = placeholder; + fixture.detectChanges(); + expect(singleInputElement.nativeElement.placeholder).toEqual(placeholder); + }); + + it('should support different display and input formats', () => { + dateRange.inputFormat = 'dd/MM/yy'; // should not be registered + dateRange.displayFormat = 'longDate'; + fixture.detectChanges(); + expect(dateRange.inputDirective.placeholder).toEqual(`MMMM d, y - MMMM d, y`); + const today = new Date(); + startDate = new Date(today.getFullYear(), today.getMonth(), 1, 0, 0, 0); + endDate = new Date(today.getFullYear(), today.getMonth(), 5, 0, 0, 0); + dateRange.select(startDate, endDate); + fixture.detectChanges(); + const longDateOptions = { month: 'long', day: 'numeric' }; + let inputStartDate = `${ControlsFunction.formatDate(startDate, longDateOptions)}, ${startDate.getFullYear()}`; + let inputEndDate = `${ControlsFunction.formatDate(endDate, longDateOptions)}, ${endDate.getFullYear()}`; + expect(singleInputElement.nativeElement.value).toEqual(`${inputStartDate} - ${inputEndDate}`); + + dateRange.value = null; + dateRange.displayFormat = 'shortDate'; + fixture.detectChanges(); + + expect(dateRange.inputDirective.placeholder).toEqual(`M/d/yy - M/d/yy`); + startDate.setDate(2); + endDate.setDate(19); + dateRange.select(startDate, endDate); + fixture.detectChanges(); + + const shortDateOptions = { day: 'numeric', month: 'numeric', year: '2-digit' }; + inputStartDate = ControlsFunction.formatDate(startDate, shortDateOptions); + inputEndDate = ControlsFunction.formatDate(endDate, shortDateOptions); + expect(singleInputElement.nativeElement.value).toEqual(`${inputStartDate} - ${inputEndDate}`); + + dateRange.value = null; + dateRange.displayFormat = 'fullDate'; + fixture.detectChanges(); + + expect(dateRange.inputDirective.placeholder).toEqual(`EEEE, MMMM d, y - EEEE, MMMM d, y`); + startDate.setDate(12); + endDate.setDate(23); + dateRange.select(startDate, endDate); + fixture.detectChanges(); + + const fullDateOptions = { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }; + inputStartDate = ControlsFunction.formatDate(startDate, fullDateOptions); + inputEndDate = ControlsFunction.formatDate(endDate, fullDateOptions); + expect(singleInputElement.nativeElement.value).toEqual(`${inputStartDate} - ${inputEndDate}`); + + dateRange.value = null; + dateRange.displayFormat = 'dd-MM-yy'; + fixture.detectChanges(); + + startDate.setDate(9); + endDate.setDate(13); + dateRange.select(startDate, endDate); + fixture.detectChanges(); + + const customFormatOptions = { day: 'numeric', month: 'numeric', year: '2-digit' }; + inputStartDate = ControlsFunction.formatDate(startDate, customFormatOptions, 'en-GB'). + replace(/\//g, '-'); + inputEndDate = ControlsFunction.formatDate(endDate, customFormatOptions, 'en-GB'). + replace(/\//g, '-'); + expect(singleInputElement.nativeElement.value).toEqual(`${inputStartDate} - ${inputEndDate}`); + }); + + it('should close the calendar with the "Done" button', fakeAsync(() => { + fixture.componentInstance.mode = PickerInteractionMode.Dialog; + fixture.componentInstance.dateRange.displayFormat = 'M/d/yyyy'; + fixture.detectChanges(); + spyOn(dateRange.closing, 'emit').and.callThrough(); + spyOn(dateRange.closed, 'emit').and.callThrough(); + + dateRange.open(); + tick(); + fixture.detectChanges(); + expect(dateRange.collapsed).toBeFalsy(); + + const doneBtn = document.getElementsByClassName(CSS_CLASS_DIALOG_BUTTON)[0]; + UIInteractions.simulateClickAndSelectEvent(doneBtn); + 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 }); + })); + + 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_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(); + + fixture.componentInstance.mode = PickerInteractionMode.DropDown; + fixture.detectChanges(); + + dateRange.open(); + tick(); + fixture.detectChanges(); + 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(() => { + fixture.componentInstance.mode = PickerInteractionMode.Dialog; + fixture.detectChanges(); + + dateRange.toggle(); + fixture.detectChanges(); + 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_DIALOG_BUTTON)[1]; + cancelBtn = document.getElementsByClassName(CSS_CLASS_DIALOG_BUTTON)[0]; + expect(doneBtn.textContent.trim()).toEqual('Close'); + })); + + it('should emit open/close events - open/close methods', fakeAsync(() => { + fixture.componentInstance.dateRange.displayFormat = 'M/d/yyyy'; + fixture.detectChanges(); + spyOn(dateRange.opening, 'emit').and.callThrough(); + spyOn(dateRange.opened, 'emit').and.callThrough(); + spyOn(dateRange.closing, 'emit').and.callThrough(); + spyOn(dateRange.closed, 'emit').and.callThrough(); + + dateRange.open(); + tick(DEBOUNCE_TIME); + fixture.detectChanges(); + expect(dateRange.collapsed).toBeFalsy(); + expect(dateRange.opening.emit).toHaveBeenCalledTimes(1); + expect(dateRange.opening.emit).toHaveBeenCalledWith({ owner: dateRange, cancel: false, event: undefined }); + expect(dateRange.opened.emit).toHaveBeenCalledTimes(1); + expect(dateRange.opened.emit).toHaveBeenCalledWith({ owner: dateRange }); + + dateRange.close(); + tick(); + fixture.detectChanges(); + + expect(dateRange.collapsed).toBeTruthy(); + 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 }); + })); + + it('should emit open/close events - toggle method', fakeAsync(() => { + spyOn(dateRange.opening, 'emit').and.callThrough(); + spyOn(dateRange.opened, 'emit').and.callThrough(); + spyOn(dateRange.closing, 'emit').and.callThrough(); + spyOn(dateRange.closed, 'emit').and.callThrough(); + + dateRange.toggle(); + tick(DEBOUNCE_TIME); + fixture.detectChanges(); + + expect(dateRange.collapsed).toBeFalsy(); + expect(dateRange.opening.emit).toHaveBeenCalledTimes(1); + expect(dateRange.opening.emit).toHaveBeenCalledWith({ owner: dateRange, cancel: false, event: undefined }); + expect(dateRange.opened.emit).toHaveBeenCalledTimes(1); + expect(dateRange.opened.emit).toHaveBeenCalledWith({ owner: dateRange }); + + dateRange.toggle(); + tick(); + fixture.detectChanges(); + + tick(); + fixture.detectChanges(); + expect(dateRange.collapsed).toBeTruthy(); + 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 }); + })); + + it('should not close calendar if closing event is canceled', fakeAsync(() => { + spyOn(dateRange.closing, 'emit').and.callThrough(); + spyOn(dateRange.closed, 'emit').and.callThrough(); + dateRange.closing.subscribe((e: CancelableEventArgs) => e.cancel = true); + + dateRange.toggle(); + tick(DEBOUNCE_TIME); + fixture.detectChanges(); + expect(dateRange.collapsed).toBeFalsy(); + + const dayRange = 6; + const today = new Date(); + startDate = new Date(today.getFullYear(), today.getMonth(), 14, 0, 0, 0); + endDate = new Date(startDate); + endDate.setDate(endDate.getDate() + dayRange); + dateRange.select(startDate, endDate); + + dateRange.close(); + tick(); + fixture.detectChanges(); + expect(dateRange.collapsed).toBeFalsy(); + expect(dateRange.closing.emit).toHaveBeenCalled(); + expect(dateRange.closed.emit).not.toHaveBeenCalled(); + })); + }); + + describe('Keyboard navigation', () => { + it('should toggle the calendar with ALT + DOWN/UP ARROW key', fakeAsync(() => { + spyOn(dateRange.opening, 'emit').and.callThrough(); + spyOn(dateRange.opened, 'emit').and.callThrough(); + spyOn(dateRange.closing, 'emit').and.callThrough(); + spyOn(dateRange.closed, 'emit').and.callThrough(); + expect(dateRange.collapsed).toBeTruthy(); + expect(dateRange.isFocused).toBeFalse(); + + const range = fixture.debugElement.query(By.css(CSS_CLASS_DATE_RANGE)); + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', range, true); + + tick(DEBOUNCE_TIME * 2); + fixture.detectChanges(); + + expect(dateRange.collapsed).toBeFalsy(); + expect(dateRange.opening.emit).toHaveBeenCalledTimes(1); + expect(dateRange.opened.emit).toHaveBeenCalledTimes(1); + + const calendarWrapper = fixture.debugElement.query(By.css('.igx-calendar__wrapper')).nativeElement; + expect(calendarWrapper.contains(document.activeElement)) + .withContext('focus should move to calendar for KB nav') + .toBeTrue(); + expect(dateRange.isFocused).toBeTrue(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', calendarWrapper, true, true); + tick(); + fixture.detectChanges(); + expect(dateRange.collapsed).toBeTruthy(); + expect(dateRange.closing.emit).toHaveBeenCalledTimes(1); + expect(dateRange.closed.emit).toHaveBeenCalledTimes(1); + expect(dateRange.inputDirective.nativeElement.contains(document.activeElement)) + .withContext('focus should return to the picker input') + .toBeTrue(); + expect(dateRange.isFocused).toBeTrue(); + })); + + it('should close the calendar with ESC', fakeAsync(() => { + spyOn(dateRange.closing, 'emit').and.callThrough(); + spyOn(dateRange.closed, 'emit').and.callThrough(); + dateRange.mode = 'dropdown'; + + expect(dateRange.collapsed).toBeTruthy(); + dateRange.open(); + tick(); + fixture.detectChanges(); + expect(dateRange.collapsed).toBeFalsy(); + + const calendarWrapper = fixture.debugElement.query(By.css('.igx-calendar__wrapper')).nativeElement; + expect(calendarWrapper.contains(document.activeElement)) + .withContext('focus should move to calendar for KB nav') + .toBeTrue(); + expect(dateRange.isFocused).toBeTrue(); + + UIInteractions.triggerKeyDownEvtUponElem('Escape', calendarWrapper, true); + tick(); + fixture.detectChanges(); + + expect(dateRange.collapsed).toBeTruthy(); + expect(dateRange.closing.emit).toHaveBeenCalledTimes(1); + expect(dateRange.closed.emit).toHaveBeenCalledTimes(1); + expect(dateRange.inputDirective.nativeElement.contains(document.activeElement)) + .withContext('focus should return to the picker input') + .toBeTrue(); + expect(dateRange.isFocused).toBeTrue(); + })); + + it('should not open calendar with ALT + DOWN ARROW key if disabled is set to true', fakeAsync(() => { + fixture.componentInstance.mode = PickerInteractionMode.DropDown; + fixture.componentInstance.disabled = true; + fixture.detectChanges(); + + spyOn(dateRange.opening, 'emit').and.callThrough(); + spyOn(dateRange.opened, 'emit').and.callThrough(); + + const input = document.getElementsByClassName('igx-input-group__input')[0]; + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', input, true, true); + tick(); + fixture.detectChanges(); + expect(dateRange.collapsed).toBeTruthy(); + expect(dateRange.opening.emit).toHaveBeenCalledTimes(0); + expect(dateRange.opened.emit).toHaveBeenCalledTimes(0); + })); + }); + + it('should expand the calendar if the default icon (prefix) is clicked', fakeAsync(() => { + const prefix = fixture.debugElement.query(By.directive(IgxPrefixDirective)); + prefix.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + tick(); + fixture.detectChanges(); + expect(fixture.componentInstance.dateRange.collapsed).toBeFalsy(); + })); + + it('should not expand the calendar if the input is clicked in dropdown mode', fakeAsync(() => { + UIInteractions.simulateClickAndSelectEvent(dateRange.getEditElement()); + tick(); + fixture.detectChanges(); + expect(fixture.componentInstance.dateRange.collapsed).toBeTruthy(); + })); + + it('should expand the calendar if the input is clicked in dialog mode', fakeAsync(() => { + dateRange.mode = PickerInteractionMode.Dialog; + fixture.detectChanges(); + UIInteractions.simulateClickAndSelectEvent(dateRange.getEditElement()); + fixture.detectChanges(); + tick(); + expect(fixture.componentInstance.dateRange.collapsed).toBeFalsy(); + })); + + it('should not expand the calendar if the default icon (in prefix) is clicked when disabled is set to true', fakeAsync(() => { + fixture.componentInstance.disabled = true; + fixture.detectChanges(); + const prefix = fixture.debugElement.query(By.directive(IgxPrefixDirective)); + prefix.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + tick(); + fixture.detectChanges(); + expect(fixture.componentInstance.dateRange.collapsed).toBeTruthy(); + })); + + it('should properly set/update disabled when ChangeDetectionStrategy.OnPush is used', fakeAsync(() => { + const testFixture = TestBed + .createComponent(DateRangeDisabledComponent) as ComponentFixture; + testFixture.detectChanges(); + dateRange = testFixture.componentInstance.dateRange; + const disabled$ = testFixture.componentInstance.disabled$; + + disabled$.next(true); + testFixture.detectChanges(); + expect(dateRange.inputDirective.disabled).toBeTrue(); + + disabled$.next(false); + testFixture.detectChanges(); + expect(dateRange.inputDirective.disabled).toBeFalse(); + + disabled$.next(true); + testFixture.detectChanges(); + expect(dateRange.inputDirective.disabled).toBeTrue(); + + disabled$.complete(); + })); + + it('should update the calendar while it\'s open and the value has been updated', fakeAsync(() => { + dateRange.open(); + tick(); + fixture.detectChanges(); + + const range = { start: new Date(), end: new Date(new Date().setDate(new Date().getDate() + 1)) }; + dateRange.value = range; + fixture.detectChanges(); + + expect((dateRange as any).calendar.selectedDates.length).toBeGreaterThan(0); + + // clean up test + tick(350); + })); + + it('should set initial validity state when the form group is disabled', () => { + const fix = TestBed.createComponent(DateRangeReactiveFormComponent); + fix.detectChanges(); + const dateRangePicker = fix.componentInstance.dateRange; + + fix.componentInstance.markAsTouched(); + fix.detectChanges(); + expect(dateRangePicker.inputDirective.valid).toBe(IgxInputState.INVALID); + + fix.componentInstance.disableForm(); + fix.detectChanges(); + expect(dateRangePicker.inputDirective.valid).toBe(IgxInputState.INITIAL); + }); + + it('should update validity state when programmatically setting errors on reactive form controls', fakeAsync(() => { + const fix = TestBed.createComponent(DateRangeReactiveFormComponent); + tick(500); + fix.detectChanges(); + const dateRangePicker = fix.componentInstance.dateRange; + const form = fix.componentInstance.form; + + // the form control has validators + form.markAllAsTouched(); + form.get('range').setErrors({ error: true }); + tick(); + fix.detectChanges(); + + expect((dateRangePicker as any).inputDirective.valid).toBe(IgxInputState.INVALID); + expect((dateRangePicker as any).inputGroup.element.nativeElement.classList.contains(CSS_CLASS_INPUT_GROUP_INVALID)).toBe(true); + expect((dateRangePicker as any).inputGroup.element.nativeElement.classList.contains(CSS_CLASS_INPUT_GROUP_REQUIRED)).toBe(true); + expect((dateRangePicker as any).required).toBe(true); + + // remove the validators and set errors + form.controls['range'].clearValidators(); + form.controls['range'].updateValueAndValidity(); + + form.markAllAsTouched(); + form.get('range').setErrors({ error: true }); + tick(500); + fix.detectChanges(); + tick(); + + expect((dateRangePicker as any).inputDirective.valid).toBe(IgxInputState.INVALID); + expect((dateRangePicker as any).inputGroup.element.nativeElement.classList.contains(CSS_CLASS_INPUT_GROUP_INVALID)).toBe(true); + expect((dateRangePicker as any).inputGroup.element.nativeElement.classList.contains(CSS_CLASS_INPUT_GROUP_REQUIRED)).toBe(false); + })); + }); + + describe('Two Inputs', () => { + let startInput: DebugElement; + let endInput: DebugElement; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + ReactiveFormsModule, + DateRangeTwoInputsTestComponent, + DateRangeTwoInputsNgModelTestComponent, + DateRangeDisabledComponent, + DateRangeTwoInputsDisabledComponent, + DateRangeReactiveFormComponent + ] + }).compileComponents(); + })); + beforeEach(async () => { + fixture = TestBed.createComponent(DateRangeTwoInputsTestComponent); + fixture.detectChanges(); + dateRange = fixture.componentInstance.dateRange; + dateRange.value = { start: new Date('2/2/2020'), end: new Date('3/3/2020') }; + startInput = fixture.debugElement.query(By.css('input')); + endInput = fixture.debugElement.queryAll(By.css('input'))[1]; + calendar = fixture.debugElement.query(By.css(CSS_CLASS_CALENDAR)); + calendarDays = fixture.debugElement.queryAll(By.css(HelperTestFunctions.CURRENT_MONTH_DATES)); + }); + + const verifyDateRange = () => { + expect(dateRange.value.start).toEqual(startDate); + expect(dateRange.value.end).toEqual(endDate); + expect(startInput.nativeElement.value).toEqual(ControlsFunction.formatDate(startDate, DEFAULT_FORMAT_OPTIONS)); + const expectedEndDate = endDate ? ControlsFunction.formatDate(endDate, DEFAULT_FORMAT_OPTIONS) : ''; + expect(endInput.nativeElement.value).toEqual(expectedEndDate); + }; + + describe('Selection tests', () => { + it('should assign range values correctly when selecting dates from the calendar', () => { + fixture.componentInstance.mode = PickerInteractionMode.DropDown; + fixture.detectChanges(); + + let dayRange = 15; + const today = new Date(); + startDate = new Date(today.getFullYear(), today.getMonth(), 10, 0, 0, 0); + endDate = new Date(startDate); + endDate.setDate(endDate.getDate() + dayRange); + selectDateRangeFromCalendar(startDate, endDate); + verifyDateRange(); + + dayRange = 13; + startDate = new Date(today.getFullYear(), today.getMonth(), 6, 0, 0, 0); + endDate = new Date(startDate); + endDate.setDate(endDate.getDate() + dayRange); + selectDateRangeFromCalendar(startDate, endDate); + verifyDateRange(); + }); + + it('should assign range values correctly when selecting dates in reversed order', () => { + fixture.componentInstance.mode = PickerInteractionMode.DropDown; + fixture.detectChanges(); + + const today = new Date(); + startDate = new Date(today.getFullYear(), today.getMonth(), 10, 0, 0, 0); + endDate = new Date(today.getFullYear(), today.getMonth(), 20, 0, 0, 0); + selectDateRangeFromCalendar(endDate, startDate); + verifyDateRange(); + }); + + it('should apply selection to start and end dates when single date is selected', () => { + fixture.componentInstance.mode = PickerInteractionMode.DropDown; + fixture.detectChanges(); + + const today = new Date(); + startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 0, 0, 0); + endDate = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 0, 0, 0); // startDate; + + selectDateRangeFromCalendar(startDate, endDate); + verifyDateRange(); + }); + + it('should update inputs correctly on first and last date selection', () => { + dateRange.hideOutsideDays = true; + fixture.detectChanges(); + const today = new Date(); + startDate = new Date(today.getFullYear(), today.getMonth(), 1, 0, 0, 0); + endDate = new Date(today.getFullYear(), today.getMonth() + 2, 0, 0, 0, 0); + selectDateRangeFromCalendar(startDate, endDate); + verifyDateRange(); + }); + + it('should assign range values correctly when selecting through API', () => { + startDate = new Date(2020, 10, 8, 0, 0, 0); + endDate = new Date(2020, 11, 8, 0, 0, 0); + dateRange.select(startDate, endDate); + fixture.detectChanges(); + verifyDateRange(); + + startDate = new Date(2003, 5, 18, 0, 0, 0); + endDate = new Date(2003, 8, 18, 0, 0, 0); + dateRange.select(startDate, endDate); + fixture.detectChanges(); + verifyDateRange(); + }); + it('should support different input and display formats', () => { + let inputFormat = 'dd/MM/yy'; + let displayFormat = 'longDate'; + fixture.componentInstance.inputFormat = inputFormat; + fixture.componentInstance.displayFormat = displayFormat; + fixture.detectChanges(); + + const startInputEditor = startInput.injector.get(IgxDateTimeEditorDirective); + const endInputEditor = endInput.injector.get(IgxDateTimeEditorDirective); + expect(startInputEditor.inputFormat).toEqual(inputFormat); + expect(startInputEditor.displayFormat).toEqual(displayFormat); + expect(endInputEditor.inputFormat).toEqual(inputFormat); + expect(endInputEditor.displayFormat).toEqual(displayFormat); + + inputFormat = 'yy-MM-dd'; + displayFormat = 'shortDate'; + fixture.componentInstance.inputFormat = inputFormat; + fixture.componentInstance.displayFormat = displayFormat; + fixture.detectChanges(); + + expect(startInputEditor.inputFormat).toEqual(inputFormat); + expect(startInputEditor.displayFormat).toEqual(displayFormat); + expect(endInputEditor.inputFormat).toEqual(inputFormat); + expect(endInputEditor.displayFormat).toEqual(displayFormat); + + inputFormat = 'EE/MM/yy'; + displayFormat = 'fullDate'; + fixture.componentInstance.inputFormat = inputFormat; + fixture.componentInstance.displayFormat = displayFormat; + fixture.detectChanges(); + + expect(startInputEditor.inputFormat).toEqual(inputFormat); + expect(startInputEditor.displayFormat).toEqual(displayFormat); + expect(endInputEditor.inputFormat).toEqual(inputFormat); + expect(endInputEditor.displayFormat).toEqual(displayFormat); + + inputFormat = 'MMM, yy'; + displayFormat = 'MMMM, yyyy'; + fixture.componentInstance.inputFormat = inputFormat; + fixture.componentInstance.displayFormat = displayFormat; + fixture.detectChanges(); + + expect(startInputEditor.inputFormat).toEqual(inputFormat); + expect(startInputEditor.displayFormat).toEqual(displayFormat); + expect(endInputEditor.inputFormat).toEqual(inputFormat); + expect(endInputEditor.displayFormat).toEqual(displayFormat); + }); + + it('should set default inputFormat to the start/end editors with parts for day, month and year based on locale ', fakeAsync(() => { + registerLocaleData(localeBg); + registerLocaleData(localeJa); + + expect(fixture.componentInstance.inputFormat).toEqual(undefined); + expect(dateRange.locale).toEqual('en-US'); + + const startInputEditor = startInput.injector.get(IgxDateTimeEditorDirective); + const endInputEditor = endInput.injector.get(IgxDateTimeEditorDirective); + expect(startInputEditor.inputFormat).toEqual('MM/dd/yyyy'); + expect(endInputEditor.inputFormat).toEqual('MM/dd/yyyy'); + + dateRange.locale = 'ja-JP'; + fixture.detectChanges(); + tick(); + + expect(startInputEditor.inputFormat).toEqual('yyyy/MM/dd'); + expect(startInputEditor.nativeElement.placeholder).toEqual('yyyy/MM/dd'); + expect(endInputEditor.inputFormat).toEqual('yyyy/MM/dd'); + + dateRange.locale = 'bg-BG'; + fixture.detectChanges(); + tick(); + + expect(startInputEditor.inputFormat.normalize('NFKC')).toEqual('dd.MM.yyyy г.'); + expect(startInputEditor.nativeElement.placeholder.normalize('NFKC')).toEqual('dd.MM.yyyy г.'); + expect(endInputEditor.inputFormat.normalize('NFKC')).toEqual('dd.MM.yyyy г.'); + })); + it('should resolve inputFormat, if not set, to the value of displayFormat if it contains only numeric date/time parts', fakeAsync(() => { + const startInputEditor = startInput.injector.get(IgxDateTimeEditorDirective); + const endInputEditor = endInput.injector.get(IgxDateTimeEditorDirective); + + fixture.componentInstance.displayFormat = 'MM-dd-yyyy'; + fixture.detectChanges(); + tick(); + + expect(startInputEditor.displayFormat.normalize('NFKC')).toEqual('MM-dd-yyyy'); + expect(startInputEditor.nativeElement.placeholder.normalize('NFKC')).toEqual('MM-dd-yyyy'); + expect(endInputEditor.inputFormat.normalize('NFKC')).toEqual('MM-dd-yyyy'); + + fixture.componentInstance.displayFormat = 'shortDate'; + fixture.detectChanges(); + tick(); + + expect(startInputEditor.displayFormat.normalize('NFKC')).toEqual('shortDate'); + expect(startInputEditor.nativeElement.placeholder.normalize('NFKC')).toEqual('MM/dd/yyyy'); + expect(endInputEditor.inputFormat.normalize('NFKC')).toEqual('MM/dd/yyyy'); + })); + it('should resolve to the default locale-based input format in case inputFormat is not set and displayFormat contains non-numeric date/time parts', fakeAsync(() => { + registerLocaleData(localeBg); + const startInputEditor = startInput.injector.get(IgxDateTimeEditorDirective); + const endInputEditor = endInput.injector.get(IgxDateTimeEditorDirective); + + dateRange.locale = 'bg-BG'; + fixture.detectChanges(); + tick(); + + fixture.componentInstance.displayFormat = 'full'; + fixture.detectChanges(); + tick(); + + expect(startInputEditor.displayFormat.normalize('NFKC')).toEqual('full'); + expect(startInputEditor.nativeElement.placeholder.normalize('NFKC')).toEqual('dd.MM.yyyy г.'); + expect(endInputEditor.inputFormat.normalize('NFKC')).toEqual('dd.MM.yyyy г.'); + + fixture.componentInstance.displayFormat = 'MMM-dd-yyyy'; + fixture.detectChanges(); + tick(); + + expect(startInputEditor.displayFormat.normalize('NFKC')).toEqual('MMM-dd-yyyy'); + expect(startInputEditor.nativeElement.placeholder.normalize('NFKC')).toEqual('dd.MM.yyyy г.'); + expect(endInputEditor.inputFormat.normalize('NFKC')).toEqual('dd.MM.yyyy г.'); + })); + + it('should display dates according to the applied display format', () => { + const today = new Date(); + startDate = new Date(today.getFullYear(), today.getMonth(), 1, 0, 0, 0); + endDate = new Date(today.getFullYear(), today.getMonth(), 5, 0, 0, 0); + dateRange.select(startDate, endDate); + fixture.detectChanges(); + expect(startInput.nativeElement.value).toEqual(ControlsFunction.formatDate(startDate, DEFAULT_FORMAT_OPTIONS)); + expect(endInput.nativeElement.value).toEqual(ControlsFunction.formatDate(endDate, DEFAULT_FORMAT_OPTIONS)); + + fixture.componentInstance.displayFormat = 'shortDate'; + fixture.detectChanges(); + + startDate.setDate(2); + endDate.setDate(19); + dateRange.select(startDate, endDate); + fixture.detectChanges(); + + const shortDateFormatOptions = { day: 'numeric', month: 'numeric', year: '2-digit' }; + expect(startInput.nativeElement.value).toEqual(ControlsFunction.formatDate(startDate, shortDateFormatOptions)); + expect(endInput.nativeElement.value).toEqual(ControlsFunction.formatDate(endDate, shortDateFormatOptions)); + + fixture.componentInstance.displayFormat = 'fullDate'; + fixture.detectChanges(); + + startDate.setDate(12); + endDate.setDate(23); + dateRange.select(startDate, endDate); + fixture.detectChanges(); + + const fullDateFormatOptions = { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }; + expect(startInput.nativeElement.value).toEqual(ControlsFunction.formatDate(startDate, fullDateFormatOptions)); + expect(endInput.nativeElement.value).toEqual(ControlsFunction.formatDate(endDate, fullDateFormatOptions)); + + fixture.componentInstance.displayFormat = 'dd-MM-yy'; + fixture.detectChanges(); + + startDate.setDate(9); + endDate.setDate(13); + dateRange.select(startDate, endDate); + fixture.detectChanges(); + + const customFormatOptions = { day: 'numeric', month: 'numeric', year: '2-digit' }; + const inputStartDate = ControlsFunction.formatDate(startDate, customFormatOptions, 'en-GB'). + replace(/\//g, '-'); + const inputEndDate = ControlsFunction.formatDate(endDate, customFormatOptions, 'en-GB'). + replace(/\//g, '-'); + expect(startInput.nativeElement.value).toEqual(inputStartDate); + expect(endInput.nativeElement.value).toEqual(inputEndDate); + }); + + it('should select a range from the calendar only when any of the two inputs are filled in', fakeAsync(() => { + // refactored to any of the two inputs, instead of both, to match the behavior in WC - #16131 + startInput.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('11/10/2015', startInput); + + fixture.componentInstance.dateRange.open(); + tick(DEBOUNCE_TIME); + fixture.detectChanges(); + const rangePicker = fixture.componentInstance.dateRange; + expect((rangePicker as any).calendar.selectedDates.length).toBe(1); + + calendar = document.getElementsByClassName(CSS_CLASS_CALENDAR)[0]; + UIInteractions.triggerKeyDownEvtUponElem('Escape', calendar); + tick(DEBOUNCE_TIME); + fixture.detectChanges(); + + endInput.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('11/16/2015', endInput); + + fixture.componentInstance.dateRange.open(); + tick(DEBOUNCE_TIME); + fixture.detectChanges(); + expect((rangePicker as any).calendar.selectedDates.length).toBe(7); + flush(); + })); + + it('should set initial validity state when the form group is disabled', () => { + const fix = TestBed.createComponent(DateRangeReactiveFormComponent); + fix.detectChanges(); + const dateRangePicker = fix.componentInstance.dateRangeWithTwoInputs; + + fix.componentInstance.markAsTouched(); + fix.detectChanges(); + expect(dateRangePicker.projectedInputs.first.inputDirective.valid).toBe(IgxInputState.INVALID); + expect(dateRangePicker.projectedInputs.last.inputDirective.valid).toBe(IgxInputState.INVALID); + + fix.componentInstance.disableForm(); + fix.detectChanges(); + expect(dateRangePicker.projectedInputs.first.inputDirective.valid).toBe(IgxInputState.INITIAL); + expect(dateRangePicker.projectedInputs.last.inputDirective.valid).toBe(IgxInputState.INITIAL); + }); + }); + + describe('Keyboard navigation', () => { + it('should toggle the calendar with ALT + DOWN/UP ARROW key - dropdown mode', fakeAsync(() => { + expect(dateRange.collapsed).toBeTruthy(); + expect(dateRange.isFocused).toBeFalse(); + + spyOn(dateRange.opening, 'emit').and.callThrough(); + spyOn(dateRange.opened, 'emit').and.callThrough(); + spyOn(dateRange.closing, 'emit').and.callThrough(); + spyOn(dateRange.closed, 'emit').and.callThrough(); + + expect(dateRange.collapsed).toBeTruthy(); + startInput.nativeElement.focus(); + tick(); + const range = fixture.debugElement.query(By.css(CSS_CLASS_DATE_RANGE)); + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', range, true); + tick(DEBOUNCE_TIME * 2); + fixture.detectChanges(); + expect(dateRange.collapsed).toBeFalsy(); + expect(dateRange.opening.emit).toHaveBeenCalledTimes(1); + expect(dateRange.opened.emit).toHaveBeenCalledTimes(1); + + let calendarWrapper = document.getElementsByClassName('igx-calendar__wrapper')[0]; + expect(calendarWrapper.contains(document.activeElement)) + .withContext('focus should move to calendar for KB nav') + .toBeTrue(); + expect(dateRange.isFocused).toBeTrue(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', calendarWrapper, true, true); + tick(); + fixture.detectChanges(); + expect(dateRange.collapsed).toBeTruthy(); + expect(dateRange.closing.emit).toHaveBeenCalledTimes(1); + expect(dateRange.closed.emit).toHaveBeenCalledTimes(1); + expect(startInput.nativeElement.contains(document.activeElement)) + .withContext('focus should return to the picker input') + .toBeTrue(); + expect(dateRange.isFocused).toBeTrue(); + + // reopen and close again + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', range, true); + tick(DEBOUNCE_TIME * 2); + fixture.detectChanges(); + + calendarWrapper = document.getElementsByClassName('igx-calendar__wrapper')[0]; + expect(calendarWrapper.contains(document.activeElement)) + .withContext('focus should move to calendar for KB nav') + .toBeTrue(); + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', calendarWrapper, true, true); + tick(); + fixture.detectChanges(); + + expect(startInput.nativeElement.contains(document.activeElement)) + .withContext('focus should return to the picker input') + .toBeTrue(); + expect(dateRange.isFocused).toBeTrue(); + })); + + it('should toggle the calendar with ALT + DOWN/UP ARROW key - dialog mode', fakeAsync(() => { + fixture.componentInstance.mode = PickerInteractionMode.Dialog; + fixture.detectChanges(); + expect(dateRange.collapsed).toBeTruthy(); + + spyOn(dateRange.opening, 'emit').and.callThrough(); + spyOn(dateRange.opened, 'emit').and.callThrough(); + spyOn(dateRange.closing, 'emit').and.callThrough(); + spyOn(dateRange.closed, 'emit').and.callThrough(); + + expect(dateRange.collapsed).toBeTruthy(); + const range = fixture.debugElement.query(By.css(CSS_CLASS_DATE_RANGE)); + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', range, true); + tick(DEBOUNCE_TIME * 2); + fixture.detectChanges(); + expect(dateRange.collapsed).toBeFalsy(); + expect(dateRange.opening.emit).toHaveBeenCalledTimes(1); + expect(dateRange.opened.emit).toHaveBeenCalledTimes(1); + + calendar = document.getElementsByClassName(CSS_CLASS_CALENDAR)[0]; + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', calendar, true, true); + tick(); + fixture.detectChanges(); + expect(dateRange.collapsed).toBeTruthy(); + expect(dateRange.closing.emit).toHaveBeenCalledTimes(1); + expect(dateRange.closed.emit).toHaveBeenCalledTimes(1); + })); + + it('should close the calendar with ESC', fakeAsync(() => { + spyOn(dateRange.closing, 'emit').and.callThrough(); + spyOn(dateRange.closed, 'emit').and.callThrough(); + dateRange.mode = 'dropdown'; + startInput.nativeElement.focus(); + + expect(dateRange.collapsed).toBeTruthy(); + dateRange.open(); + tick(); + fixture.detectChanges(); + expect(dateRange.collapsed).toBeFalsy(); + + let calendarWrapper = document.getElementsByClassName('igx-calendar__wrapper')[0]; + expect(calendarWrapper.contains(document.activeElement)) + .withContext('focus should move to calendar for KB nav') + .toBeTrue(); + expect(dateRange.isFocused).toBeTrue(); + + UIInteractions.triggerKeyDownEvtUponElem('Escape', calendarWrapper, true); + tick(); + fixture.detectChanges(); + + expect(dateRange.collapsed).toBeTruthy(); + expect(dateRange.closing.emit).toHaveBeenCalledTimes(1); + expect(dateRange.closed.emit).toHaveBeenCalledTimes(1); + expect(startInput.nativeElement.contains(document.activeElement)) + .withContext('focus should return to the picker input') + .toBeTrue(); + expect(dateRange.isFocused).toBeTrue(); + + // reopen and close again + dateRange.open(); + tick(); + fixture.detectChanges(); + + calendarWrapper = document.getElementsByClassName('igx-calendar__wrapper')[0]; + expect(calendarWrapper.contains(document.activeElement)) + .withContext('focus should move to calendar for KB nav') + .toBeTrue(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', calendarWrapper, true, true); + tick(); + fixture.detectChanges(); + expect(startInput.nativeElement.contains(document.activeElement)) + .withContext('focus should return to the picker input') + .toBeTrue(); + expect(dateRange.isFocused).toBeTrue(); + })); + + it('should not open calendar with ALT + DOWN ARROW key if disabled is set to true', fakeAsync(() => { + fixture.componentInstance.disabled = true; + fixture.detectChanges(); + + spyOn(dateRange.opening, 'emit').and.callThrough(); + spyOn(dateRange.opened, 'emit').and.callThrough(); + + // UIInteractions.triggerEventHandlerKeyDown('ArrowDown', calendar, true); + tick(DEBOUNCE_TIME * 2); + fixture.detectChanges(); + expect(dateRange.collapsed).toBeTruthy(); + 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 update the calendar selection on typing', fakeAsync(() => { + const range = { start: new Date(2025, 0, 16), end: new Date(2025, 0, 20) }; + dateRange.value = range; + fixture.detectChanges(); + dateRange.open(); + fixture.detectChanges(); + + expect((dateRange['_calendar'].value as Date[]).length).toBe(5); + + startInput.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('01/18/2025', startInput); + + tick(DEBOUNCE_TIME); + fixture.detectChanges(); + + expect((dateRange['_calendar'].value as Date[]).length).toBe(3); + + startDate = new Date(2025, 0, 18); + const expectedRange = { start: startDate, end: new Date(2025, 0, 20) }; + expect(dateRange.value).toEqual(expectedRange); + expect(dateRange.activeDate).toEqual(expectedRange.start); + + const activeDescendantDate = new Date(startDate.setHours(0, 0, 0, 0)).getTime().toString(); + expect(dateRange['_calendar'].activeDate).toEqual(startDate); + expect(dateRange['_calendar'].viewDate.getMonth()).toEqual(startDate.getMonth()); + expect(dateRange['_calendar'].value[0]).toEqual(startDate); + expect(dateRange['_calendar'].wrapper.nativeElement.getAttribute('aria-activedescendant')).toEqual(activeDescendantDate); + })); + + it('should update the calendar view and active date on typing a date that is not in the current view', fakeAsync(() => { + const range = { start: new Date(2025, 0, 16), end: new Date(2025, 0, 20) }; + dateRange.value = range; + fixture.detectChanges(); + dateRange.open(); + fixture.detectChanges(); + + expect((dateRange['_calendar'].value as Date[]).length).toBe(5); + + startInput.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('11/18/2025', startInput); + + tick(DEBOUNCE_TIME); + fixture.detectChanges(); + + startDate = new Date(2025, 10, 18); + + const activeDescendantDate = new Date(startDate.setHours(0, 0, 0, 0)).getTime().toString(); + expect(dateRange['_calendar'].activeDate).toEqual(startDate); + expect(dateRange['_calendar'].viewDate.getMonth()).toEqual(startDate.getMonth()); + expect(dateRange['_calendar'].wrapper.nativeElement.getAttribute('aria-activedescendant')).toEqual(activeDescendantDate); + })); + }); + + it('should focus the last focused input after the calendar closes - dropdown', fakeAsync(() => { + endInput = fixture.debugElement.queryAll(By.css('.igx-input-group'))[1]; + endInput.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + + dateRange.open(); + tick(); + fixture.detectChanges(); + + dateRange.close(); + tick(); + fixture.detectChanges(); + + const input = fixture.componentInstance.dateRange.projectedInputs.find(i => i instanceof IgxDateRangeEndComponent); + expect(input.isFocused).toBeTruthy(); + })); + + it('should focus the last focused input after the calendar closes - dialog', fakeAsync(() => { + fixture.componentInstance.mode = PickerInteractionMode.Dialog; + fixture.detectChanges(); + endInput = fixture.debugElement.queryAll(By.css('.igx-input-group'))[1]; + UIInteractions.simulateClickAndSelectEvent(endInput.nativeElement); + fixture.detectChanges(); + + dateRange.open(); + tick(); + fixture.detectChanges(); + + dateRange.close(); + tick(); + fixture.detectChanges(); + + expect(fixture.componentInstance.dateRange.projectedInputs + .find(i => i instanceof IgxDateRangeEndComponent).isFocused) + .toBeTruthy(); + })); + + it('should expand the calendar if the default icon is clicked', fakeAsync(() => { + const icon = fixture.debugElement.query(By.css('igx-picker-toggle')); + icon.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + tick(); + fixture.detectChanges(); + expect(fixture.componentInstance.dateRange.collapsed).toBeFalsy(); + })); + + it('should expand the calendar if any of the inputs is clicked in dialog mode', fakeAsync(() => { + fixture.componentInstance.mode = PickerInteractionMode.Dialog; + fixture.detectChanges(); + endInput = fixture.debugElement.queryAll(By.css(CSS_CLASS_INPUT))[1]; + endInput.nativeElement.dispatchEvent(new Event('click')); + fixture.detectChanges(); + tick(); + expect(fixture.componentInstance.dateRange.collapsed).toBeFalsy(); + })); + + it('should not expand the calendar if the default icon is clicked when disabled is set to true', fakeAsync(() => { + fixture.componentInstance.disabled = true; + fixture.detectChanges(); + const icon = fixture.debugElement.query(By.css('igx-picker-toggle')); + icon.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + tick(); + fixture.detectChanges(); + expect(fixture.componentInstance.dateRange.collapsed).toBeTruthy(); + })); + + it('should properly set/update disabled when ChangeDetectionStrategy.OnPush is used', fakeAsync(() => { + const testFixture = TestBed + .createComponent(DateRangeTwoInputsDisabledComponent) as ComponentFixture; + testFixture.detectChanges(); + dateRange = testFixture.componentInstance.dateRange; + const disabled$ = testFixture.componentInstance.disabled$; + + disabled$.next(true); + testFixture.detectChanges(); + expect(dateRange.projectedInputs.first.inputDirective.disabled).toBeTrue(); + expect(dateRange.projectedInputs.last.inputDirective.disabled).toBeTrue(); + + disabled$.next(false); + testFixture.detectChanges(); + expect(dateRange.projectedInputs.first.inputDirective.disabled).toBeFalse(); + expect(dateRange.projectedInputs.last.disabled).toBeFalse(); + + disabled$.next(true); + testFixture.detectChanges(); + expect(dateRange.projectedInputs.first.inputDirective.disabled).toBeTrue(); + expect(dateRange.projectedInputs.last.inputDirective.disabled).toBeTrue(); + + disabled$.complete(); + })); + + it('should update the calendar while it\'s open and the value has been updated', fakeAsync(() => { + dateRange.open(); + tick(); + fixture.detectChanges(); + + const range = { start: new Date(), end: new Date(new Date().setDate(new Date().getDate() + 1)) }; + dateRange.value = range; + fixture.detectChanges(); + + expect((dateRange as any).calendar.selectedDates.length).toBeGreaterThan(0); + })); + + describe('Data binding', () => { + it('should properly update component value with ngModel bound to projected inputs - #7353', fakeAsync(() => { + fixture = TestBed.createComponent(DateRangeTwoInputsNgModelTestComponent); + fixture.detectChanges(); + const range = (fixture.componentInstance as DateRangeTwoInputsNgModelTestComponent).range; + fixture.componentInstance.dateRange.open(); + fixture.detectChanges(); + tick(); + expect((fixture.componentInstance.dateRange.value.start as Date).getTime()).toEqual(range.start.getTime()); + 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'); + + 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', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + DateRangeDefaultComponent, + DateRangeCustomComponent, + DateRangeTemplatesComponent, + DateRangeTwoInputsTestComponent + ] + }).compileComponents(); + })); + + it('should render range separator', () => { + fixture = TestBed.createComponent(DateRangeTwoInputsTestComponent); + fixture.detectChanges(); + + const range = fixture.debugElement.query(By.css(CSS_CLASS_DATE_RANGE)); + expect(range.children[1].nativeElement.innerText).toBe('-'); + }); + + it('should render default toggle icon', () => { + fixture = TestBed.createComponent(DateRangeDefaultComponent); + fixture.detectChanges(); + + const inputGroupsStart = fixture.debugElement.query(By.css(CSS_CLASS_INPUT_START)); + expect(inputGroupsStart.children[0].nativeElement.innerText).toBe(DEFAULT_ICON_TEXT); + expect(inputGroupsStart.children[0].children[0].classes[CSS_CLASS_ICON]).toBeTruthy(); + }); + + it('should be able to set toggle icon', () => { + const prefixIconText = 'flight_takeoff'; + const suffixIconText = 'flight_land'; + const additionalIconText = 'calendar_view_day'; + fixture = TestBed.createComponent(DateRangeTemplatesComponent); + fixture.detectChanges(); + + const inputGroupsStart = fixture.debugElement.queryAll(By.css(CSS_CLASS_INPUT_START)); + const inputGroupsEnd = fixture.debugElement.queryAll(By.css(CSS_CLASS_INPUT_END)); + + const prefixSingleRangeInput = inputGroupsStart[0]; + expect(prefixSingleRangeInput.children[0].nativeElement.innerText).toBe(prefixIconText); + expect(prefixSingleRangeInput.children[0].children[0].classes[CSS_CLASS_ICON]).toBeTruthy(); + + const suffixSingleRangeInput = inputGroupsEnd[1]; + expect(suffixSingleRangeInput.children[0].nativeElement.innerText).toBe(suffixIconText); + expect(suffixSingleRangeInput.children[0].children[0].classes[CSS_CLASS_ICON]).toBeTruthy(); + + const addPrefixSingleRangeInput = inputGroupsStart[2]; + expect(addPrefixSingleRangeInput.children[0].nativeElement.innerText).toBe(DEFAULT_ICON_TEXT); + expect(addPrefixSingleRangeInput.children[0].children[0].classes[CSS_CLASS_ICON]).toBeTruthy(); + expect(addPrefixSingleRangeInput.children[1].nativeElement.innerText).toBe(additionalIconText); + expect(addPrefixSingleRangeInput.children[1].children[0].classes[CSS_CLASS_ICON]).toBeTruthy(); + + const prefixRangeInput = inputGroupsStart[3]; + expect(prefixRangeInput.children[0].nativeElement.innerText).toBe(prefixIconText); + expect(prefixRangeInput.children[0].children[0].classes[CSS_CLASS_ICON]).toBeTruthy(); + + const suffixRangeInput = inputGroupsEnd[4]; + expect(suffixRangeInput.children[0].nativeElement.innerText).toBe(suffixIconText); + expect(suffixRangeInput.children[0].children[0].classes[CSS_CLASS_ICON]).toBeTruthy(); + expect(suffixRangeInput.children[1].nativeElement.innerText).toBe(additionalIconText); + expect(suffixRangeInput.children[1].children[0].classes[CSS_CLASS_ICON]).toBeTruthy(); + }); + + it('should render aria attributes properly', fakeAsync(() => { + fixture = TestBed.createComponent(DateRangeCustomComponent); + fixture.detectChanges(); + dateRange = fixture.componentInstance.dateRange; + const toggleBtn = fixture.debugElement.query(By.css(`.${CSS_CLASS_ICON}`)); + const singleInputElement = fixture.debugElement.query(By.css(CSS_CLASS_INPUT)); + startDate = new Date(2020, 1, 1); + endDate = new Date(2020, 1, 4); + const expectedLabelID = dateRange.label.id; + const expectedPlaceholder = 'MM/dd/yyyy - MM/dd/yyyy'; + + expect(singleInputElement.nativeElement.getAttribute('role')).toEqual('combobox'); + expect(singleInputElement.nativeElement.getAttribute('placeholder').trim()).toEqual(expectedPlaceholder); + expect(singleInputElement.nativeElement.getAttribute('aria-haspopup')).toEqual('grid'); + expect(singleInputElement.nativeElement.getAttribute('aria-expanded')).toEqual('false'); + expect(toggleBtn.nativeElement.getAttribute('aria-hidden')).toEqual('true'); + expect(singleInputElement.nativeElement.getAttribute('aria-labelledby')).toEqual(expectedLabelID); + + dateRange.toggle(); + tick(); + fixture.detectChanges(); + + expect(singleInputElement.nativeElement.getAttribute('aria-expanded')).toEqual('true'); + expect(toggleBtn.nativeElement.getAttribute('aria-hidden')).toEqual('true'); + + dateRange.select(startDate, endDate); + fixture.detectChanges(); + expect(singleInputElement.nativeElement.getAttribute('placeholder')).toEqual(''); + + // clean up test + tick(350); + })); + + it('should render custom label', () => { + fixture = TestBed.createComponent(DateRangeCustomComponent); + fixture.detectChanges(); + + const inputGroup = fixture.debugElement.query(By.css(CSS_CLASS_INPUT_BUNDLE)); + expect(inputGroup.children[1].children[0].classes[CSS_CLASS_LABEL]).toBeTruthy(); + expect(inputGroup.children[1].children[0].nativeElement.textContent).toEqual('Select Date'); + }); + + it('should be able to apply custom format', () => { + fixture = TestBed.createComponent(DateRangeCustomComponent); + fixture.detectChanges(); + dateRange = fixture.componentInstance.dateRange; + const singleInputElement = fixture.debugElement.query(By.css(CSS_CLASS_INPUT)); + + startDate = new Date(2020, 10, 8, 0, 0, 0); + endDate = new Date(2020, 11, 8, 0, 0, 0); + dateRange.select(startDate, endDate); + fixture.detectChanges(); + + const result = fixture.componentInstance.formatter({ start: startDate, end: endDate }); + expect(singleInputElement.nativeElement.value).toEqual(result); + }); + + it('should invoke AutoPositionStrategy by default with proper arguments', fakeAsync(() => { + fixture = TestBed.createComponent(DateRangeDefaultComponent); + fixture.detectChanges(); + spyOn(AutoPositionStrategy.prototype, 'position'); + + dateRange = fixture.componentInstance.dateRange; + dateRange.open(); + tick(); + fixture.detectChanges(); + + const overlayContent = document.getElementsByClassName(CSS_CLASS_OVERLAY_CONTENT)[0] as HTMLElement; + expect(AutoPositionStrategy.prototype.position).toHaveBeenCalledTimes(1); + expect(AutoPositionStrategy.prototype.position) + .toHaveBeenCalledWith(overlayContent, jasmine.anything(), document, + jasmine.anything(), dateRange.element.nativeElement); + })); + it('Should the weekStart property takes precedence over locale.', fakeAsync(() => { + fixture = TestBed.createComponent(DateRangeCustomComponent); + fixture.detectChanges(); + dateRange = fixture.componentInstance.dateRange; + + dateRange.locale = 'en'; + fixture.detectChanges(); + + expect(dateRange.weekStart).toEqual(0); + + dateRange.weekStart = WEEKDAYS.FRIDAY; + expect(dateRange.weekStart).toEqual(5); + + dateRange.locale = 'fr'; + fixture.detectChanges(); + + expect(dateRange.weekStart).toEqual(5); + + flush(); + })); + + it('Should passing invalid value for locale, then setting weekStart must be respected.', fakeAsync(() => { + fixture = TestBed.createComponent(DateRangeCustomComponent); + fixture.detectChanges(); + dateRange = fixture.componentInstance.dateRange; + + const locale = 'en-US'; + dateRange.locale = locale; + fixture.detectChanges(); + + expect(dateRange.locale).toEqual(locale); + expect(dateRange.weekStart).toEqual(WEEKDAYS.SUNDAY) + + dateRange.locale = 'frrr'; + dateRange.weekStart = WEEKDAYS.FRIDAY; + fixture.detectChanges(); + + expect(dateRange.locale).toEqual('en-US'); + expect(dateRange.weekStart).toEqual(WEEKDAYS.FRIDAY); + })); + + it('Should render calendar with header in dialog mode by default', fakeAsync(() => { + fixture = TestBed.createComponent(DateRangeDefaultComponent); + fixture.detectChanges(); + dateRange = fixture.componentInstance.dateRange; + dateRange.mode = 'dialog'; + dateRange.open(); + tick(); + fixture.detectChanges(); + + expect(dateRange['_calendar'].hasHeader).toBeTrue(); + const calendarHeader = fixture.debugElement.query(By.css(CSS_CLASS_CALENDAR_HEADER_TEMPLATE)); + expect(calendarHeader).toBeTruthy('Calendar header should be present'); + })); + + it('should set calendar headerOrientation prop in dialog mode', fakeAsync(() => { + fixture = TestBed.createComponent(DateRangeDefaultComponent); + fixture.detectChanges(); + dateRange = fixture.componentInstance.dateRange; + + dateRange.mode = 'dialog'; + dateRange.open(); + tick(); + fixture.detectChanges(); + + expect(dateRange['_calendar'].headerOrientation).toBe(PickerHeaderOrientation.Horizontal); + + dateRange.close(); + tick(); + fixture.detectChanges(); + + dateRange.headerOrientation = PickerHeaderOrientation.Vertical; + dateRange.open(); + tick(); + fixture.detectChanges(); + + expect(dateRange['_calendar'].headerOrientation).toBe(PickerHeaderOrientation.Vertical); + })); + + it('should hide the calendar header if hideHeader is true in dialog mode', fakeAsync(() => { + fixture = TestBed.createComponent(DateRangeDefaultComponent); + fixture.detectChanges(); + dateRange = fixture.componentInstance.dateRange; + + dateRange.mode = 'dialog'; + dateRange.hideHeader = true; + dateRange.open(); + tick(); + fixture.detectChanges(); + + expect(dateRange['_calendar'].hasHeader).toBeFalse(); + const calendarHeader = fixture.debugElement.query(By.css(CSS_CLASS_CALENDAR_HEADER)); + expect(calendarHeader).toBeFalsy('Calendar header should not be present'); + })); + + it('should set calendar orientation property', fakeAsync(() => { + fixture = TestBed.createComponent(DateRangeDefaultComponent); + fixture.detectChanges(); + dateRange = fixture.componentInstance.dateRange; + dateRange.open(); + tick(); + fixture.detectChanges(); + + expect(dateRange['_calendar'].orientation).toEqual(PickerCalendarOrientation.Horizontal.toString()); + expect(dateRange['_calendar'].wrapper.nativeElement).not.toHaveClass(CSS_CLASS_CALENDAR_WRAPPER_VERTICAL); + dateRange.close(); + tick(); + fixture.detectChanges(); + + dateRange.orientation = PickerCalendarOrientation.Vertical; + dateRange.open(); + tick(); + fixture.detectChanges(); + + expect(dateRange['_calendar'].orientation).toEqual(PickerCalendarOrientation.Vertical.toString()); + expect(dateRange['_calendar'].wrapper.nativeElement).toHaveClass(CSS_CLASS_CALENDAR_WRAPPER_VERTICAL); + })); + + it('should limit the displayMonthsCount property between 1 and 2', fakeAsync(() => { + fixture = TestBed.createComponent(DateRangeDefaultComponent); + fixture.detectChanges(); + dateRange = fixture.componentInstance.dateRange; + dateRange.open(); + tick(); + + dateRange.displayMonthsCount = 3; + fixture.detectChanges(); + + expect(dateRange.displayMonthsCount).toBe(2); + + dateRange.displayMonthsCount = -1; + fixture.detectChanges(); + + expect(dateRange.displayMonthsCount).toBe(1); + })); + + it('should set the specialDates of the calendar', fakeAsync(() => { + fixture = TestBed.createComponent(DateRangeDefaultComponent); + fixture.detectChanges(); + dateRange = fixture.componentInstance.dateRange; + + const specialDates = [{ + type: DateRangeType.Between, dateRange: [ + new Date(new Date().getFullYear(), new Date().getMonth(), 3), + new Date(new Date().getFullYear(), new Date().getMonth(), 8) + ] + }]; + dateRange.specialDates = specialDates; + fixture.detectChanges(); + + dateRange.open(); + tick(); + fixture.detectChanges(); + + expect(dateRange['_calendar'].specialDates).toEqual(specialDates); + })); + + it('should set the disabledDates of the calendar', fakeAsync(() => { + fixture = TestBed.createComponent(DateRangeDefaultComponent); + fixture.detectChanges(); + dateRange = fixture.componentInstance.dateRange; + + const disabledDates = [{ + type: DateRangeType.Between, dateRange: [ + new Date(new Date().getFullYear(), new Date().getMonth(), 3), + new Date(new Date().getFullYear(), new Date().getMonth(), 8) + ] + }]; + dateRange.disabledDates = disabledDates; + fixture.detectChanges(); + + dateRange.open(); + tick(); + fixture.detectChanges(); + + expect(dateRange['_calendar'].disabledDates).toEqual(disabledDates); + })); + + it('should initialize activeDate with current date, when not set', fakeAsync(() => { + fixture = TestBed.createComponent(DateRangeDefaultComponent); + fixture.detectChanges(); + dateRange = fixture.componentInstance.dateRange; + const todayDate = new Date(); + const today = new Date(todayDate.setHours(0, 0, 0, 0)).getTime().toString(); + + expect(dateRange.activeDate).toEqual(todayDate); + + dateRange.open(); + fixture.detectChanges(); + + expect(dateRange['_calendar'].activeDate).toEqual(todayDate); + expect(dateRange['_calendar'].value).toEqual([]); + const wrapper = fixture.debugElement.query(By.css('.igx-calendar__wrapper')).nativeElement; + expect(wrapper.getAttribute('aria-activedescendant')).toEqual(today); + })); + + it('should initialize activeDate = first defined in value (start/end) when it is not set, but value is', fakeAsync(() => { + fixture = TestBed.createComponent(DateRangeDefaultComponent); + fixture.detectChanges(); + dateRange = fixture.componentInstance.dateRange; + let range = { start: new Date(2025, 0, 1), end: new Date(2025, 0, 5) }; + dateRange.value = range; + fixture.detectChanges(); + + expect(dateRange.activeDate).toEqual(range.start); + dateRange.open(); + fixture.detectChanges(); + + const activeDescendantDate = new Date(range.start.setHours(0, 0, 0, 0)).getTime().toString(); + expect(dateRange['_calendar'].activeDate).toEqual(range.start); + expect(dateRange['_calendar'].value[0]).toEqual(range.start); + const wrapper = fixture.debugElement.query(By.css('.igx-calendar__wrapper')).nativeElement; + expect(wrapper.getAttribute('aria-activedescendant')).toEqual(activeDescendantDate); + + range = { ...range, start: null}; + dateRange.value = range; + fixture.detectChanges(); + + expect(dateRange.activeDate).toEqual(range.end); + })); + + it('should set activeDate correctly', fakeAsync(() => { + const targetDate = new Date(2025, 11, 1); + fixture = TestBed.createComponent(DateRangeDefaultComponent); + fixture.detectChanges(); + dateRange = fixture.componentInstance.dateRange; + const range = { start: new Date(2025, 0, 1), end: new Date(2025, 0, 5) }; + dateRange.value = range; + dateRange.activeDate = targetDate; + fixture.detectChanges(); + + expect(dateRange.activeDate).toEqual(targetDate); + dateRange.open(); + fixture.detectChanges(); + + const activeDescendantDate = new Date(targetDate.setHours(0, 0, 0, 0)).getTime().toString(); + expect(dateRange['_calendar'].activeDate).toEqual(targetDate); + expect(dateRange['_calendar'].value[0]).toEqual(range.start); + const wrapper = fixture.debugElement.query(By.css('.igx-calendar__wrapper')).nativeElement; + expect(wrapper.getAttribute('aria-activedescendant')).toEqual(activeDescendantDate); + })); + + describe('Templated Calendar Header', () => { + let dateRangeDebugEl: DebugElement; + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [DateRangeTemplatesComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(DateRangeTemplatesComponent); + fixture.detectChanges(); + dateRangeDebugEl = fixture.debugElement.queryAll(By.directive(IgxDateRangePickerComponent))[0]; + dateRange = dateRangeDebugEl.componentInstance; + dateRange.mode = 'dialog'; + dateRange.open(); + tick(); + fixture.detectChanges(); + })); + + it('Should use the custom template for header title', fakeAsync(() => { + const headerTitleElement = dateRangeDebugEl.query(By.css(CSS_CLASS_CALENDAR_HEADER_TITLE)); + expect(headerTitleElement).toBeTruthy('Header title element should be present'); + if (headerTitleElement) { + expect(headerTitleElement.nativeElement.textContent.trim()).toBe('Test header title'); + } + })); + + it('Should use the custom template for header', fakeAsync(() => { + const headerElement = dateRangeDebugEl.query(By.css(CSS_CLASS_CALENDAR_HEADER_TEMPLATE)); + expect(headerElement).toBeTruthy('Header element should be present'); + if (headerElement) { + expect(headerElement.nativeElement.textContent.trim()).toBe('Test header'); + } + })); + + it('Should use the custom template for subheader', fakeAsync(() => { + const headerElement = dateRangeDebugEl.query(By.css(CSS_CLASS_CALENDAR_SUBHEADER)); + expect(headerElement).toBeTruthy('Subheader element should be present'); + if (headerElement) { + expect(headerElement.nativeElement.textContent.trim()).toBe('Test subheader'); + } + })); + }); + + it('should render projected clear icons which clear the range on click', () => { + fixture = TestBed.createComponent(DateRangeTwoInputsClearComponent); + fixture.detectChanges(); + + const drp = fixture.debugElement.query(By.directive(IgxDateRangePickerComponent)).componentInstance; + const start = fixture.debugElement.query(By.directive(IgxDateRangeStartComponent)); + const end = fixture.debugElement.query(By.directive(IgxDateRangeEndComponent)); + + const startSuffix = start.nativeElement.querySelectorAll(CSS_CLASS_INPUT_END)[0]; + const endSuffix = end.nativeElement.querySelectorAll(CSS_CLASS_INPUT_END)[0]; + + expect(startSuffix.innerText).toBe('delete'); + expect(endSuffix.innerText).toBe('delete'); + + const pickerClearComponents = fixture.debugElement.queryAll(By.directive(IgxPickerClearComponent)); + + drp.value = { start: new Date(2025, 0, 1), end: new Date(2025, 0, 2) }; + fixture.detectChanges(); + + UIInteractions.simulateClickAndSelectEvent(pickerClearComponents[0].nativeNode); + fixture.detectChanges(); + + expect(drp.value).toEqual(null); + + drp.value = { start: new Date(2025, 0, 1), end: new Date(2025, 0, 2) }; + fixture.detectChanges(); + + UIInteractions.simulateClickAndSelectEvent(pickerClearComponents[1].nativeNode); + fixture.detectChanges(); + + expect(drp.value).toEqual(null); + }); + }); + }); +}); + +@Component({ + selector: 'igx-date-range-test', + template: '', + standalone: true +}) +export class DateRangeTestComponent implements OnInit { + [x: string]: any; + @ViewChild(IgxDateRangePickerComponent, { static: true }) + public dateRange: IgxDateRangePickerComponent; + + public doneButtonText: string; + public mode: PickerInteractionMode = PickerInteractionMode.DropDown; + public disabled = false; + public minValue: Date | string; + public maxValue: Date | string; + + public ngOnInit(): void { + this.doneButtonText = 'Done'; + } +} + +@Component({ + selector: 'igx-date-range-single-input-test', + template: ` + + + `, + imports: [IgxDateRangePickerComponent] +}) +export class DateRangeDefaultComponent extends DateRangeTestComponent { + public override disabled = false; +} + +@Component({ + selector: 'igx-date-range-two-inputs-test', + template: ` + + + + calendar_view_day + + + + - + + + + + `, + imports: [ + IgxDateRangePickerComponent, + IgxDateRangeStartComponent, + IgxDateRangeEndComponent, + IgxPickerToggleComponent, + IgxIconComponent, + IgxPrefixDirective, + IgxInputDirective, + IgxDateTimeEditorDirective, + IgxDateRangeSeparatorDirective, + FormsModule + ] +}) +export class DateRangeTwoInputsTestComponent extends DateRangeTestComponent { + public range; + 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', + template: ` + + + + + + + + `, + imports: [IgxDateRangePickerComponent, IgxDateRangeStartComponent, IgxDateRangeEndComponent, IgxInputDirective, IgxDateTimeEditorDirective, FormsModule] +}) +export class DateRangeTwoInputsNgModelTestComponent extends DateRangeTestComponent { + public range = { start: new Date(2020, 1, 1), end: new Date(2020, 1, 4) }; +} + +@Component({ + selector: 'igx-date-range-two-inputs-clear', + template: ` + + + + + delete + + + + + + delete + + + `, + imports: [IgxDateRangePickerComponent, IgxDateRangeStartComponent, IgxDateRangeEndComponent, IgxInputDirective, + IgxDateTimeEditorDirective, FormsModule, IgxPickerClearComponent, IgxIconComponent, IgxSuffixDirective] +}) +export class DateRangeTwoInputsClearComponent extends DateRangeTestComponent { +} + +@Component({ + selector: 'igx-date-range-single-input-label-test', + template: ` + + + + `, + imports: [IgxDateRangePickerComponent, IgxLabelDirective] +}) +export class DateRangeCustomComponent extends DateRangeTestComponent { + public date: DateRange; + private monthFormatter = new Intl.DateTimeFormat('en', { month: 'long' }); + + public formatter = (date: DateRange) => { + const startDate = `${this.monthFormatter + .format(date.start as Date)} ${(date.start as Date).getDate()}, ${(date.start as Date).getFullYear()}`; + const endDate = `${this.monthFormatter + .format(date.end as Date)} ${(date + .end as Date).getDate()}, ${(date.end as Date).getFullYear()}`; + return `You selected ${startDate}-${endDate}`; + }; +} +@Component({ + selector: 'igx-date-range-templates-test', + template: ` + + + flight_takeoff + + Test header + Test header title + Test subheader + + + + flight_land + + + + + + calendar_view_day + + + + + + + flight_takeoff + + + + + + + flight_land + + + + calendar_view_day + + + + + + + delete + + + `, + imports: [ + IgxDateRangePickerComponent, + IgxDateRangeStartComponent, + IgxDateRangeEndComponent, + IgxPickerToggleComponent, + IgxPickerClearComponent, + IgxIconComponent, + FormsModule, + IgxInputDirective, + IgxDateTimeEditorDirective, + IgxPrefixDirective, + IgxSuffixDirective, + IgxCalendarHeaderTemplateDirective, + IgxCalendarHeaderTitleTemplateDirective, + IgxCalendarSubheaderTemplateDirective + ] +}) +export class DateRangeTemplatesComponent extends DateRangeTestComponent { + public range; +} + +@Component({ + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [IgxDateRangePickerComponent, AsyncPipe] +}) +export class DateRangeDisabledComponent extends DateRangeTestComponent { + public disabled$ = new Subject(); + + constructor() { + super(); + this.disabled$.subscribe({ next: (v) => v }); + } +} + +@Component({ + template: ` + + + + + + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [IgxDateRangePickerComponent, IgxDateRangeStartComponent, IgxDateRangeEndComponent, IgxInputDirective, IgxDateTimeEditorDirective, AsyncPipe] +}) +export class DateRangeTwoInputsDisabledComponent extends DateRangeDisabledComponent { } + +@Component({ + template: ` +
+ + + + + + + + + + + +
`, + imports: [ + IgxDateRangePickerComponent, + IgxDateRangeStartComponent, + IgxDateRangeEndComponent, + IgxInputDirective, + IgxLabelDirective, + IgxDateTimeEditorDirective, + ReactiveFormsModule + ] +}) +export class DateRangeReactiveFormComponent { + private fb = inject(UntypedFormBuilder); + + @ViewChild('range', { read: IgxDateRangePickerComponent }) public dateRange: IgxDateRangePickerComponent; + @ViewChild('twoInputs', { read: IgxDateRangePickerComponent }) public dateRangeWithTwoInputs: IgxDateRangePickerComponent; + + public form = this.fb.group({ + range: ['', Validators.required], + twoInputs: ['', Validators.required] + }); + + public markAsTouched() { + if (!this.form.valid) { + for (const key in this.form.controls) { + if (this.form.controls[key]) { + this.form.controls[key].markAsTouched(); + this.form.controls[key].updateValueAndValidity(); + } + } + } + } + + public disableForm() { + this.form.disable(); + } +} diff --git a/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.ts b/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.ts new file mode 100644 index 00000000000..c67394209ec --- /dev/null +++ b/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.component.ts @@ -0,0 +1,1385 @@ +import { AfterViewInit, booleanAttribute, ChangeDetectorRef, Component, ContentChild, ContentChildren, ElementRef, EventEmitter, HostBinding, HostListener, Injector, Input, OnChanges, OnDestroy, OnInit, Output, QueryList, SimpleChanges, TemplateRef, ViewChild, ViewContainerRef, inject } from '@angular/core'; +import { NgTemplateOutlet, getLocaleFirstDayOfWeek } from '@angular/common'; +import { + AbstractControl, ControlValueAccessor, NgControl, + NG_VALIDATORS, NG_VALUE_ACCESSOR, ValidationErrors, Validator +} from '@angular/forms'; + +import { fromEvent, merge, MonoTypeOperatorFunction, noop, Subscription } from 'rxjs'; +import { filter, takeUntil } from 'rxjs/operators'; + +import { CalendarSelection, IgxCalendarComponent, IgxCalendarHeaderTemplateDirective, IgxCalendarHeaderTitleTemplateDirective, IgxCalendarSubheaderTemplateDirective } from 'igniteui-angular/calendar'; +import { + DateRangeDescriptor, + DateRangeType, + DateRangePickerResourceStringsEN, + IDateRangePickerResourceStrings, + clamp, + IBaseCancelableBrowserEventArgs, + isDate, + parseDate, + PlatformUtil, + getCurrentResourceStrings, + AutoPositionStrategy, + IgxOverlayService, + OverlayCancelableEventArgs, + OverlayEventArgs, + OverlaySettings, + PositionSettings, + calendarRange, + CustomDateRange, + DateRange, + DateTimeUtil, + IgxPickerActionsDirective, + isDateInRanges, + PickerCalendarOrientation, + IgxOverlayOutletDirective +} from 'igniteui-angular/core'; +import { IgxCalendarContainerComponent } from '../date-picker/calendar-container/calendar-container.component'; +import { PickerBaseDirective } from '../date-picker/picker-base.directive'; +import { + IgxInputDirective, + IgxInputGroupComponent, + IgxInputState, + IgxLabelDirective, + IgxSuffixDirective, + IgxPrefixDirective, + IgxReadOnlyInputDirective, + IgxHintDirective +} from 'igniteui-angular/input-group'; +import { + IgxDateRangeEndComponent, + IgxDateRangeInputsBaseComponent, + IgxDateRangeSeparatorDirective, + IgxDateRangeStartComponent, + DateRangePickerFormatPipe, +} from './date-range-picker-inputs.common'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { fadeIn, fadeOut } from 'igniteui-angular/animations'; + +const SingleInputDatesConcatenationString = ' - '; + +/** + * Provides the ability to select a range of dates from a calendar UI or editable inputs. + * + * @igxModule IgxDateRangeModule + * + * @igxTheme igx-input-group-theme, igx-calendar-theme, igx-date-range-picker-theme + * + * @igxKeywords date, range, date range, date picker + * + * @igxGroup scheduling + * + * @remarks + * It displays the range selection in a single or two input fields. + * The default template displays a single *readonly* input field + * while projecting `igx-date-range-start` and `igx-date-range-end` + * displays two *editable* input fields. + * + * @example + * ```html + * + * ``` + */ +@Component({ + selector: 'igx-date-range-picker', + templateUrl: './date-range-picker.component.html', + providers: [ + { provide: NG_VALUE_ACCESSOR, useExisting: IgxDateRangePickerComponent, multi: true }, + { provide: NG_VALIDATORS, useExisting: IgxDateRangePickerComponent, multi: true } + ], + imports: [ + NgTemplateOutlet, + IgxIconComponent, + IgxInputGroupComponent, + IgxInputDirective, + IgxPrefixDirective, + IgxSuffixDirective, + IgxReadOnlyInputDirective, + DateRangePickerFormatPipe + ] +}) +export class IgxDateRangePickerComponent extends PickerBaseDirective + implements OnChanges, OnInit, AfterViewInit, OnDestroy, ControlValueAccessor, Validator { + protected platform = inject(PlatformUtil); + private _injector = inject(Injector); + private _cdr = inject(ChangeDetectorRef); + private _overlayService = inject(IgxOverlayService); + + + /** + * The number of displayed month views. + * + * @remarks + * Default is `2`. + * + * @example + * ```html + * + * ``` + */ + @Input() + public get displayMonthsCount(): number { + return this._displayMonthsCount; + } + + public set displayMonthsCount(value: number) { + this._displayMonthsCount = clamp(value, 1, 2); + } + + /** + * Gets/Sets the orientation of the multiple months displayed in the picker's calendar's days view. + * + * @example + * + */ + @Input() + public orientation: PickerCalendarOrientation = PickerCalendarOrientation.Horizontal; + + /** + * Gets/Sets whether dates that are not part of the current month will be displayed. + * + * @remarks + * Default value is `false`. + * + * @example + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public hideOutsideDays: boolean; + + /** + * A custom formatter function, applied on the selected or passed in date. + * + * @example + * ```typescript + * private dayFormatter = new Intl.DateTimeFormat("en", { weekday: "long" }); + * private monthFormatter = new Intl.DateTimeFormat("en", { month: "long" }); + * + * public formatter(date: Date): string { + * return `${this.dayFormatter.format(date)} - ${this.monthFormatter.format(date)} - ${date.getFullYear()}`; + * } + * ``` + * ```html + * + * ``` + */ + @Input() + public formatter: (val: DateRange) => string; + + /** + * Overrides the default text of the calendar dialog **Done** button. + * + * @remarks + * Defaults to the value from resource strings, `"Done"` for the built-in EN. + * The button will only show up in `dialog` mode. + * + * @example + * ```html + * + * ``` + */ + @Input() + public set doneButtonText(value: string) { + this._doneButtonText = value; + } + + public get doneButtonText(): string { + if (this._doneButtonText === null) { + return this.resourceStrings.igx_date_range_picker_done_button; + } + 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. + * + * @example + * ```html + * + * ``` + */ + @Input() + public override overlaySettings: OverlaySettings; + + /** + * The format used when editable inputs are not focused. + * + * @remarks + * Uses Angular's DatePipe. + * + * @example + * ```html + * + * ``` + * + */ + @Input() + public override displayFormat: string; + + /** + * The expected user input format and placeholder. + * + * @example + * ```html + * + * ``` + */ + @Input() + public override inputFormat: string; + + /** + * The minimum value in a valid range. + * + * @example + * + */ + @Input() + public set minValue(value: Date | string) { + this._minValue = value; + this.onValidatorChange(); + } + + public get minValue(): Date | string { + return this._minValue; + } + + /** + * The maximum value in a valid range. + * + * @example + * + */ + @Input() + public set maxValue(value: Date | string) { + this._maxValue = value; + this.onValidatorChange(); + } + + public get maxValue(): Date | string { + return this._maxValue; + } + + /** + * Gets/Sets the disabled dates descriptors. + * + * @example + * ```typescript + * let disabledDates = this.dateRangePicker.disabledDates; + * this.dateRangePicker.disabledDates = [ {type: DateRangeType.Weekends}, ...]; + * ``` + */ + @Input() + public get disabledDates(): DateRangeDescriptor[] { + return this._disabledDates; + } + public set disabledDates(value: DateRangeDescriptor[]) { + this._disabledDates = value; + this.onValidatorChange(); + } + + /** + * Gets/Sets the special dates descriptors. + * + * @example + * ```typescript + * let specialDates = this.dateRangePicker.specialDates; + * this.dateRangePicker.specialDates = [ {type: DateRangeType.Weekends}, ... ]; + * ``` + */ + @Input() + public get specialDates(): DateRangeDescriptor[] { + return this._specialDates; + } + public set specialDates(value: DateRangeDescriptor[]) { + this._specialDates = value; + } + + /** + * An accessor that sets the resource strings. + * By default it uses EN resources. + */ + @Input() + public set resourceStrings(value: IDateRangePickerResourceStrings) { + this._resourceStrings = Object.assign({}, this._resourceStrings, value); + } + + /** + * An accessor that returns the resource strings. + */ + public get resourceStrings(): IDateRangePickerResourceStrings { + return this._resourceStrings; + } + + /** + * Sets the `placeholder` for single-input `IgxDateRangePickerComponent`. + * + * @example + * ```html + * + * ``` + */ + @Input() + public override placeholder = ''; + + /** + * Gets/Sets the container used for the popup element. + * + * @remarks + * `outlet` is an instance of `IgxOverlayOutletDirective` or an `ElementRef`. + * @example + * ```html + *
+ * //.. + * + * //.. + * ``` + */ + @Input() + public override outlet: IgxOverlayOutletDirective | ElementRef; + + /** + * Show/hide week numbers + * + * @remarks + * Default is `false`. + * + * @example + * ```html + * + * `` + */ + @Input({ transform: booleanAttribute }) + public showWeekNumbers = false; + + /** @hidden @internal */ + @Input({ transform: booleanAttribute }) + public readOnly = false; + + /** + * Emitted when the picker's value changes. Used for two-way binding. + * + * @example + * ```html + * + * ``` + */ + + /** + * 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(); + + /** @hidden @internal */ + @HostBinding('class.igx-date-range-picker') + public cssClass = 'igx-date-range-picker'; + + @ViewChild(IgxInputGroupComponent, { read: ViewContainerRef }) + private viewContainerRef: ViewContainerRef; + + /** @hidden @internal */ + @ViewChild(IgxInputDirective) + public inputDirective: IgxInputDirective; + + /** @hidden @internal */ + @ContentChildren(IgxDateRangeInputsBaseComponent) + public projectedInputs: QueryList; + + @ContentChild(IgxLabelDirective) + public label: IgxLabelDirective; + + @ContentChild(IgxHintDirective) + public hint: IgxHintDirective; + + @ContentChild(IgxPickerActionsDirective) + public pickerActions: IgxPickerActionsDirective; + + /** @hidden @internal */ + @ContentChild(IgxDateRangeSeparatorDirective, { read: TemplateRef }) + public dateSeparatorTemplate: TemplateRef; + + + @ContentChild(IgxCalendarHeaderTitleTemplateDirective) + private headerTitleTemplate: IgxCalendarHeaderTitleTemplateDirective; + + @ContentChild(IgxCalendarHeaderTemplateDirective) + private headerTemplate: IgxCalendarHeaderTemplateDirective; + + @ContentChild(IgxCalendarSubheaderTemplateDirective) + private subheaderTemplate: IgxCalendarSubheaderTemplateDirective; + + /** @hidden @internal */ + public get dateSeparator(): string { + if (this._dateSeparator === null) { + return this.resourceStrings.igx_date_range_picker_date_separator; + } + return this._dateSeparator; + } + + /** @hidden @internal */ + public get appliedFormat(): string { + return DateTimeUtil.getLocaleDateFormat(this.locale, this.displayFormat) + || DateTimeUtil.DEFAULT_INPUT_FORMAT; + } + + /** + * Gets/Sets the date which is shown in the calendar picker and is highlighted. + * By default it is the current date, or the value of the picker, if set. + */ + @Input() + public get activeDate(): Date { + const today = new Date(new Date().setHours(0, 0, 0, 0)); + const dateValue = DateTimeUtil.isValidDate(this._firstDefinedInRange) ? new Date(this._firstDefinedInRange.setHours(0, 0, 0, 0)) : null; + return this._activeDate ?? dateValue ?? this._calendar?.activeDate ?? today; + } + + public set activeDate(value: Date) { + this._activeDate = value; + } + + /** + * @example + * ```html + * + * ``` + */ + /** + * Gets the `locale` of the date-range-picker. + * If not set, defaults to application's locale. + */ + @Input() + public override get locale(): string { + return this._locale; + } + + /** + * Sets the `locale` of the date-picker. + * Expects a valid BCP 47 language tag. + */ + public override set locale(value: string) { + this._locale = value; + // if value is invalid, set it back to _localeId + try { + getLocaleFirstDayOfWeek(this._locale); + } catch (e) { + this._locale = this._localeId; + } + if (this.hasProjectedInputs) { + this.updateInputLocale(); + this.updateDisplayFormat(); + } + } + + /** @hidden @internal */ + public get singleInputFormat(): string { + if (this.placeholder !== '') { + return this.placeholder; + } + + const format = this.appliedFormat; + return `${format}${SingleInputDatesConcatenationString}${format}`; + } + + /** + * Gets calendar state. + * + * ```typescript + * let state = this.dateRange.collapsed; + * ``` + */ + public override get collapsed(): boolean { + return this._collapsed; + } + + /** + * The currently selected value / range from the calendar + * + * @remarks + * The current value is of type `DateRange` + * + * @example + * ```typescript + * const newValue: DateRange = { start: new Date("2/2/2012"), end: new Date("3/3/2013")}; + * this.dateRangePicker.value = newValue; + * ``` + */ + public get value(): DateRange | null { + return this._value; + } + + @Input() + public set value(value: DateRange | null) { + this.updateValue(value); + this.onChangeCallback(value); + this.valueChange.emit(value); + } + + /** @hidden @internal */ + public get hasProjectedInputs(): boolean { + return this.projectedInputs?.length > 0; + } + + /** @hidden @internal */ + public get separatorClass(): string { + const classes = ['igx-date-range-picker__label']; + if (this.hint) classes.push('input-has-hint'); + return classes.join(' '); + } + + protected override get toggleContainer(): HTMLElement | undefined { + return this._calendarContainer; + } + + private get required(): boolean { + if (this._ngControl && this._ngControl.control && this._ngControl.control.validator) { + const error = this._ngControl.control.validator({} as AbstractControl); + return (error && error.required) ? true : false; + } + + return false; + } + + private get calendar(): IgxCalendarComponent { + return this._calendar; + } + + private get dropdownOverlaySettings(): OverlaySettings { + return Object.assign({}, this._dropDownOverlaySettings, this.overlaySettings); + } + + private get dialogOverlaySettings(): OverlaySettings { + return Object.assign({}, this._dialogOverlaySettings, this.overlaySettings); + } + + private get _firstDefinedInRange(): Date | null { + if (!this.value) { + return null; + } + const range = this.toRangeOfDates(this.value); + return range?.start ?? range?.end ?? null; + } + + 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; + private _calendar: IgxCalendarComponent; + private _calendarContainer?: HTMLElement; + private _positionSettings: PositionSettings; + private _focusedInput: IgxDateRangeInputsBaseComponent; + private _displayMonthsCount = 2; + private _specialDates: DateRangeDescriptor[] = null; + private _disabledDates: DateRangeDescriptor[] = null; + private _activeDate: Date | null = null; + private _overlaySubFilter: + [MonoTypeOperatorFunction, MonoTypeOperatorFunction] = [ + filter(x => x.id === this._overlayId), + takeUntil(merge(this._destroy$, this.closed)) + ]; + private _dialogOverlaySettings: OverlaySettings = { + closeOnOutsideClick: true, + modal: true, + closeOnEscape: true + }; + private _dropDownOverlaySettings: OverlaySettings = { + closeOnOutsideClick: true, + modal: false, + closeOnEscape: true + }; + private onChangeCallback: (dateRange: DateRange) => void = noop; + private onTouchCallback: () => void = noop; + private onValidatorChange: () => void = noop; + + constructor() { + super(); + this.locale = this.locale || this._localeId; + } + + /** @hidden @internal */ + @HostListener('keydown', ['$event']) + /** @hidden @internal */ + public onKeyDown(event: KeyboardEvent): void { + switch (event.key) { + case this.platform.KEYMAP.ARROW_UP: + if (event.altKey) { + this.close(); + } + break; + case this.platform.KEYMAP.ARROW_DOWN: + if (event.altKey) { + this.open(); + } + break; + } + } + + /** + * Opens the date range picker's dropdown or dialog. + * + * @example + * ```html + * + * + * + * + * + * ``` + */ + public close(): void { + if (!this.collapsed) { + this._overlayService.hide(this._overlayId); + } + } + + /** + * Toggles the date range picker's dropdown or dialog + * + * @example + * ```html + * + * + * + * ``` + */ + public toggle(overlaySettings?: OverlaySettings): void { + if (!this.collapsed) { + this.close(); + } else { + this.open(overlaySettings); + } + } + + /** + * Selects a range of dates. If no `endDate` is passed, range is 1 day (only `startDate`) + * + * @example + * ```typescript + * public selectFiveDayRange() { + * const today = new Date(); + * const inFiveDays = new Date(new Date().setDate(today.getDate() + 5)); + * this.dateRange.select(today, inFiveDays); + * } + * ``` + */ + public select(startDate: Date, endDate?: Date): void { + endDate = endDate ?? startDate; + const dateRange = [startDate, endDate]; + this.handleSelection(dateRange); + } + + /** + * Clears the input field(s) and the picker's value. + * + * @example + * ```typescript + * this.dateRangePicker.clear(); + * ``` + */ + public clear(): void { + if (this.disabled) { + return; + } + + this.value = null; + this._calendar?.deselectDate(); + if (this.hasProjectedInputs) { + this.projectedInputs.forEach((i) => { + i.inputDirective.clear(); + }); + } else { + this.inputDirective.clear(); + } + } + + /** @hidden @internal */ + public writeValue(value: DateRange): void { + this.updateValue(value); + } + + /** @hidden @internal */ + public registerOnChange(fn: any): void { + this.onChangeCallback = fn; + } + + /** @hidden @internal */ + public registerOnTouched(fn: any): void { + this.onTouchCallback = fn; + } + + /** @hidden @internal */ + public validate(control: AbstractControl): ValidationErrors | null { + const value: DateRange = control.value; + const errors = {}; + if (value) { + if (this.hasProjectedInputs) { + const startInput = this.projectedInputs.find(i => i instanceof IgxDateRangeStartComponent) as IgxDateRangeStartComponent; + const endInput = this.projectedInputs.find(i => i instanceof IgxDateRangeEndComponent) as IgxDateRangeEndComponent; + if (!startInput.dateTimeEditor.value) { + Object.assign(errors, { startValue: true }); + } + if (!endInput.dateTimeEditor.value) { + Object.assign(errors, { endValue: true }); + } + } + + if (this._isValueInDisabledRange(value)) { + Object.assign(errors, { dateIsDisabled: true }); + } + + const { minValue, maxValue } = this._getMinMaxDates(); + const start = parseDate(value.start); + const end = parseDate(value.end); + if ((minValue && start && DateTimeUtil.lessThanMinValue(start, minValue, false)) + || (minValue && end && DateTimeUtil.lessThanMinValue(end, minValue, false))) { + Object.assign(errors, { minValue: true }); + } + if ((maxValue && start && DateTimeUtil.greaterThanMaxValue(start, maxValue, false)) + || (maxValue && end && DateTimeUtil.greaterThanMaxValue(end, maxValue, false))) { + Object.assign(errors, { maxValue: true }); + } + } + + return Object.keys(errors).length > 0 ? errors : null; + } + + /** @hidden @internal */ + public registerOnValidatorChange?(fn: any): void { + this.onValidatorChange = fn; + } + + /** @hidden @internal */ + public setDisabledState?(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + /** @hidden */ + public ngOnInit(): void { + this._ngControl = this._injector.get(NgControl, null); + + this.locale = this.locale || this._localeId; + } + + /** @hidden */ + public override ngAfterViewInit(): void { + super.ngAfterViewInit(); + this.subscribeToDateEditorEvents(); + this.subscribeToClick(); + this.configPositionStrategy(); + this.configOverlaySettings(); + this.cacheFocusedInput(); + this.attachOnTouched(); + + this.setRequiredToInputs(); + + if (this._ngControl) { + this._statusChanges$ = this._ngControl.statusChanges.subscribe(this.onStatusChanged.bind(this)); + } + + // delay invocations until the current change detection cycle has completed + Promise.resolve().then(() => { + this.updateDisabledState(); + this.initialSetValue(); + this.updateInputs(); + // B.P. 07 July 2021 - IgxDateRangePicker not showing initial disabled state with ChangeDetectionStrategy.OnPush #9776 + /** + * if disabled is placed on the range picker element and there are projected inputs + * run change detection since igxInput will initially set the projected inputs' disabled to false + */ + if (this.hasProjectedInputs && this.disabled) { + this._cdr.markForCheck(); + } + }); + this.updateDisplayFormat(); + this.updateInputFormat(); + } + + /** @hidden @internal */ + public ngOnChanges(changes: SimpleChanges): void { + if (changes['displayFormat'] && this.hasProjectedInputs) { + this.updateDisplayFormat(); + } + if (changes['inputFormat'] && this.hasProjectedInputs) { + this.updateInputFormat(); + } + if (changes['disabled']) { + this.updateDisabledState(); + } + } + + /** @hidden @internal */ + public override ngOnDestroy(): void { + super.ngOnDestroy(); + if (this._statusChanges$) { + this._statusChanges$.unsubscribe(); + } + if (this._overlayId) { + this._overlayService.detach(this._overlayId); + } + } + + /** @hidden @internal */ + public getEditElement(): HTMLInputElement { + return this.inputDirective!.nativeElement; + } + + protected onStatusChanged = () => { + if (this.inputGroup) { + this.setValidityState(this.inputDirective, this.inputGroup.isFocused); + } else if (this.hasProjectedInputs) { + this.projectedInputs + .forEach((i) => { + this.setValidityState(i.inputDirective, i.isFocused); + }); + } + this.setRequiredToInputs(); + }; + + private setValidityState(inputDirective: IgxInputDirective, isFocused: boolean) { + if (this._ngControl && !this._ngControl.disabled && this.isTouchedOrDirty) { + if (this.hasValidators && isFocused) { + inputDirective.valid = this._ngControl.valid ? IgxInputState.VALID : IgxInputState.INVALID; + } else { + inputDirective.valid = this._ngControl.valid ? IgxInputState.INITIAL : IgxInputState.INVALID; + } + } else { + inputDirective.valid = IgxInputState.INITIAL; + } + } + + private get isTouchedOrDirty(): boolean { + return (this._ngControl.control.touched || this._ngControl.control.dirty); + } + + private get hasValidators(): boolean { + return (!!this._ngControl.control.validator || !!this._ngControl.control.asyncValidator); + } + + private handleSelection(selectionData: Date[]): void { + let newValue = this.extractRange(selectionData); + if (!newValue.start && !newValue.end) { + newValue = null; + } + this.value = newValue; + if (this.isDropdown && selectionData?.length > 1) { + this.close(); + } + this._setCalendarActiveDate(); + } + + private handleClosing(e: IBaseCancelableBrowserEventArgs): void { + const args = { owner: this, cancel: e?.cancel, event: e?.event }; + this.closing.emit(args); + e.cancel = args.cancel; + if (args.cancel) { + return; + } + + if (this.isDropdown && e?.event && !this.isFocused) { + // outside click + this.updateValidityOnBlur(); + } else { + this.onTouchCallback(); + // input click + if (this.hasProjectedInputs && this._focusedInput) { + this._focusedInput.setFocus(); + } + if (this.inputDirective) { + this.inputDirective.focus(); + } + } + } + + private subscribeToOverlayEvents() { + this._overlayService.opening.pipe(...this._overlaySubFilter).subscribe((e) => { + const overlayEvent = e as OverlayCancelableEventArgs; + const args = { owner: this, cancel: overlayEvent?.cancel, event: e.event }; + this.opening.emit(args); + if (args.cancel) { + this._overlayService.detach(this._overlayId); + overlayEvent.cancel = true; + return; + } + + this._initializeCalendarContainer(e.componentRef.instance); + this._calendarContainer = e.componentRef.location.nativeElement; + this._collapsed = false; + this.updateCalendar(); + }); + + this._overlayService.opened.pipe(...this._overlaySubFilter).subscribe(() => { + this.calendar.wrapper.nativeElement.focus(); + this.opened.emit({ owner: this }); + }); + + 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); + }); + + this._overlayService.closed.pipe(...this._overlaySubFilter).subscribe(() => { + this._overlayService.detach(this._overlayId); + this._collapsed = true; + this._overlayId = null; + this._calendar = null; + this._calendarContainer = undefined; + this.closed.emit({ owner: this }); + }); + } + + 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(); + this.updateCalendar(); + } + + private updateValidityOnBlur() { + this._focusedInput = null; + this.onTouchCallback(); + if (this._ngControl) { + if (this.hasProjectedInputs) { + this.projectedInputs.forEach(i => { + if (!this._ngControl.valid) { + i.updateInputValidity(IgxInputState.INVALID); + } else { + i.updateInputValidity(IgxInputState.INITIAL); + } + }); + } + + if (this.inputDirective) { + if (!this._ngControl.valid) { + this.inputDirective.valid = IgxInputState.INVALID; + } else { + this.inputDirective.valid = IgxInputState.INITIAL; + } + } + } + } + + private updateDisabledState() { + if (this.hasProjectedInputs) { + const start = this.projectedInputs.find(i => i instanceof IgxDateRangeStartComponent) as IgxDateRangeStartComponent; + const end = this.projectedInputs.find(i => i instanceof IgxDateRangeEndComponent) as IgxDateRangeEndComponent; + start.inputDirective.disabled = this.disabled; + end.inputDirective.disabled = this.disabled; + return; + } + } + + private setRequiredToInputs(): void { + // workaround for igxInput setting required + Promise.resolve().then(() => { + const isRequired = this.required; + if (this.inputGroup && this.inputGroup.isRequired !== isRequired) { + this.inputGroup.isRequired = isRequired; + } else if (this.hasProjectedInputs && this._ngControl) { + this.projectedInputs.forEach(i => i.isRequired = isRequired); + } + }); + } + + private parseMinValue(value: string | Date): Date | null { + let minValue: Date = parseDate(value); + if (!minValue && this.hasProjectedInputs) { + const start = this.projectedInputs.filter(i => i instanceof IgxDateRangeStartComponent)[0]; + if (start) { + minValue = parseDate(start.dateTimeEditor.minValue); + } + } + + return minValue; + } + + private parseMaxValue(value: string | Date): Date | null { + let maxValue: Date = parseDate(value); + if (!maxValue && this.projectedInputs) { + const end = this.projectedInputs.filter(i => i instanceof IgxDateRangeEndComponent)[0]; + if (end) { + maxValue = parseDate(end.dateTimeEditor.maxValue); + } + } + + return maxValue; + } + + private updateCalendar(): void { + if (!this.calendar) { + return; + } + this._setDisabledDates(); + + const range: Date[] = []; + if (this.value) { + const _value = this.toRangeOfDates(this.value); + if (_value.start && _value.end) { + if (DateTimeUtil.greaterThanMaxValue(_value.start, _value.end)) { + this.swapEditorDates(); + } + } + if (_value.start) { + range.push(_value.start); + } + if (_value.end) { + range.push(_value.end); + } + } + + if (range.length > 0) { + this.calendar.selectDate(range); + } else if (range.length === 0 && this.calendar.monthViews) { + this.calendar.deselectDate(); + } + this._setCalendarActiveDate(); + this._cdr.detectChanges(); + } + + private swapEditorDates(): void { + if (this.hasProjectedInputs) { + const start = this.projectedInputs.find(i => i instanceof IgxDateRangeStartComponent) as IgxDateRangeStartComponent; + const end = this.projectedInputs.find(i => i instanceof IgxDateRangeEndComponent) as IgxDateRangeEndComponent; + [start.dateTimeEditor.value, end.dateTimeEditor.value] = [end.dateTimeEditor.value, start.dateTimeEditor.value]; + [this.value.start, this.value.end] = [this.value.end, this.value.start]; + } + } + + private extractRange(selection: Date[]): DateRange { + return { + start: selection[0] || null, + end: selection.length > 0 ? selection[selection.length - 1] : null + }; + } + + private toRangeOfDates(range: DateRange): { start: Date; end: Date } { + let start; + let end; + if (!isDate(range.start)) { + start = DateTimeUtil.parseIsoDate(range.start); + } + if (!isDate(range.end)) { + end = DateTimeUtil.parseIsoDate(range.end); + } + + if (start || end) { + return { start, end }; + } + + return { start: range.start as Date, end: range.end as Date }; + } + + private subscribeToClick() { + const inputs = this.hasProjectedInputs + ? this.projectedInputs.map(i => i.inputDirective.nativeElement) + : [this.getEditElement()]; + inputs.forEach(input => { + fromEvent(input, 'click') + .pipe(takeUntil(this._destroy$)) + .subscribe(() => { + if (!this.isDropdown) { + this.toggle(); + } + }); + }); + } + + private subscribeToDateEditorEvents(): void { + if (this.hasProjectedInputs) { + const start = this.projectedInputs.find(i => i instanceof IgxDateRangeStartComponent) as IgxDateRangeStartComponent; + const end = this.projectedInputs.find(i => i instanceof IgxDateRangeEndComponent) as IgxDateRangeEndComponent; + if (start && end) { + start.dateTimeEditor.valueChange + .pipe(takeUntil(this._destroy$)) + .subscribe(value => { + if (this.value) { + this.value = { start: value, end: this.value.end }; + } else { + this.value = { start: value, end: null }; + } + if (this.calendar) { + this._setCalendarActiveDate(parseDate(value)); + this._cdr.detectChanges(); + } + }); + end.dateTimeEditor.valueChange + .pipe(takeUntil(this._destroy$)) + .subscribe(value => { + if (this.value) { + this.value = { start: this.value.start, end: value as Date }; + } else { + this.value = { start: null, end: value as Date }; + } + if (this.calendar) { + this._setCalendarActiveDate(parseDate(value)); + this._cdr.detectChanges(); + } + }); + } + } + } + + private attachOnTouched(): void { + if (this.hasProjectedInputs) { + this.projectedInputs.forEach(i => { + fromEvent(i.dateTimeEditor.nativeElement, 'blur') + .pipe(takeUntil(this._destroy$)) + .subscribe(() => { + if (this.collapsed) { + this.updateValidityOnBlur(); + } + }); + }); + } else { + fromEvent(this.inputDirective.nativeElement, 'blur') + .pipe(takeUntil(this._destroy$)) + .subscribe(() => { + if (this.collapsed) { + this.updateValidityOnBlur(); + } + }); + } + } + + private cacheFocusedInput(): void { + if (this.hasProjectedInputs) { + this.projectedInputs.forEach(i => { + fromEvent(i.dateTimeEditor.nativeElement, 'focus') + .pipe(takeUntil(this._destroy$)) + .subscribe(() => this._focusedInput = i); + }); + } + } + + private configPositionStrategy(): void { + this._positionSettings = { + openAnimation: fadeIn, + closeAnimation: fadeOut + }; + this._dropDownOverlaySettings.positionStrategy = new AutoPositionStrategy(this._positionSettings); + this._dropDownOverlaySettings.target = this.element.nativeElement; + } + + private configOverlaySettings(): void { + if (this.overlaySettings !== null) { + this._dropDownOverlaySettings = Object.assign({}, this._dropDownOverlaySettings, this.overlaySettings); + this._dialogOverlaySettings = Object.assign({}, this._dialogOverlaySettings, this.overlaySettings); + } + } + + private initialSetValue() { + // if there is no value and no ngControl on the picker but we have inputs we may have value set through + // their ngModels - we should generate our initial control value + if ((!this.value || (!this.value.start && !this.value.end)) && this.hasProjectedInputs && !this._ngControl) { + const start = this.projectedInputs.find(i => i instanceof IgxDateRangeStartComponent); + const end = this.projectedInputs.find(i => i instanceof IgxDateRangeEndComponent); + this._value = { + start: start.dateTimeEditor.value as Date, + end: end.dateTimeEditor.value as Date + }; + } + } + + private updateInputs(): void { + const start = this.projectedInputs?.find(i => i instanceof IgxDateRangeStartComponent) as IgxDateRangeStartComponent; + const end = this.projectedInputs?.find(i => i instanceof IgxDateRangeEndComponent) as IgxDateRangeEndComponent; + if (start && end) { + const _value = this.value ? this.toRangeOfDates(this.value) : null; + start.updateInputValue(_value?.start || null); + end.updateInputValue(_value?.end || null); + } + } + + private updateDisplayFormat(): void { + this.projectedInputs.forEach(i => { + const input = i as IgxDateRangeInputsBaseComponent; + input.dateTimeEditor.displayFormat = this.displayFormat; + }); + } + + private updateInputFormat(): void { + this.projectedInputs.forEach(i => { + const input = i as IgxDateRangeInputsBaseComponent; + if (input.dateTimeEditor.inputFormat !== this.inputFormat) { + input.dateTimeEditor.inputFormat = this.inputFormat; + } + }); + } + + private updateInputLocale(): void { + this.projectedInputs.forEach(i => { + const input = i as IgxDateRangeInputsBaseComponent; + input.dateTimeEditor.locale = this.locale; + }); + } + + private _initializeCalendarContainer(componentInstance: IgxCalendarContainerComponent) { + this._calendar = componentInstance.calendar; + this._calendar.hasHeader = !this.isDropdown && !this.hideHeader; + this._calendar.locale = this.locale; + this._calendar.selection = CalendarSelection.RANGE; + this._calendar.weekStart = this.weekStart; + this._calendar.hideOutsideDays = this.hideOutsideDays; + this._calendar.monthsViewNumber = this._displayMonthsCount; + this._calendar.showWeekNumbers = this.showWeekNumbers; + this._calendar.headerTitleTemplate = this.headerTitleTemplate; + this._calendar.headerTemplate = this.headerTemplate; + this._calendar.subheaderTemplate = this.subheaderTemplate; + this._calendar.headerOrientation = this.headerOrientation; + this._calendar.orientation = this.orientation; + this._calendar.specialDates = this.specialDates; + this._calendar.selected.pipe(takeUntil(this._destroy$)).subscribe((ev: Date[]) => this.handleSelection(ev)); + + this._setDisabledDates(); + this._setCalendarActiveDate(); + + 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(); + } + }); + } + + private _setDisabledDates(): void { + if (!this.calendar) { + return; + } + this.calendar.disabledDates = this.disabledDates ? [...this.disabledDates] : []; + const { minValue, maxValue } = this._getMinMaxDates(); + if (minValue) { + this.calendar.disabledDates.push({ type: DateRangeType.Before, dateRange: [minValue] }); + } + if (maxValue) { + this.calendar.disabledDates.push({ type: DateRangeType.After, dateRange: [maxValue] }); + } + } + + private _getMinMaxDates() { + const minValue = this.parseMinValue(this.minValue); + const maxValue = this.parseMaxValue(this.maxValue); + return { minValue, maxValue }; + } + + private _isValueInDisabledRange(value: DateRange) { + if (value && value.start && value.end && this.disabledDates) { + const isOutsideDisabledRange = Array.from( + calendarRange({ + start: parseDate(this.value.start), + end: parseDate(this.value.end), + inclusive: true + })).every((date) => !isDateInRanges(date, this.disabledDates)); + return !isOutsideDisabledRange; + } + return false; + } + + private _setCalendarActiveDate(value = null): void { + if (this._calendar) { + this._calendar.activeDate = value ?? this.activeDate; + this._calendar.viewDate = value ?? this.activeDate; + } + } +} diff --git a/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.module.ts b/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.module.ts new file mode 100644 index 00000000000..f56d628a4d3 --- /dev/null +++ b/projects/igniteui-angular/date-picker/src/date-range-picker/date-range-picker.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { IGX_DATE_RANGE_PICKER_DIRECTIVES } from './public_api'; + +/** + * @hidden + * @deprecated + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + ...IGX_DATE_RANGE_PICKER_DIRECTIVES + ], + exports: [ + ...IGX_DATE_RANGE_PICKER_DIRECTIVES + ] +}) +export class IgxDateRangePickerModule { } diff --git a/projects/igniteui-angular/date-picker/src/date-range-picker/predefined-ranges/predefined-ranges-area-component.html b/projects/igniteui-angular/date-picker/src/date-range-picker/predefined-ranges/predefined-ranges-area-component.html new file mode 100644 index 00000000000..4462f89684d --- /dev/null +++ b/projects/igniteui-angular/date-picker/src/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/date-picker/src/date-range-picker/predefined-ranges/predefined-ranges-area-component.spec.ts b/projects/igniteui-angular/date-picker/src/date-range-picker/predefined-ranges/predefined-ranges-area-component.spec.ts new file mode 100644 index 00000000000..d070babf807 --- /dev/null +++ b/projects/igniteui-angular/date-picker/src/date-range-picker/predefined-ranges/predefined-ranges-area-component.spec.ts @@ -0,0 +1,156 @@ +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { IgxPredefinedRangesAreaComponent } from './predefined-ranges-area.component'; +import { CalendarDay, CustomDateRange } from 'igniteui-angular/core'; +import { IDateRangePickerResourceStrings } from '../../../../core/src/core/i18n/date-range-picker-resources'; +import { IgxChipComponent } from '../../../../chips/src/chips/chip.component'; +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, 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/date-picker/src/date-range-picker/predefined-ranges/predefined-ranges-area.component.ts b/projects/igniteui-angular/date-picker/src/date-range-picker/predefined-ranges/predefined-ranges-area.component.ts new file mode 100644 index 00000000000..c90bd4fffd9 --- /dev/null +++ b/projects/igniteui-angular/date-picker/src/date-range-picker/predefined-ranges/predefined-ranges-area.component.ts @@ -0,0 +1,70 @@ +import { Component, EventEmitter, Input, Output, ChangeDetectionStrategy } from '@angular/core'; +import { IgxChipComponent } from 'igniteui-angular/chips'; +import { CalendarDay, CustomDateRange, DateRange, DateRangePickerResourceStringsEN, IDateRangePickerResourceStrings } from 'igniteui-angular/core'; + + +type PredefinedRangeKey = 'last7Days' | 'currentMonth' | 'last30Days' | 'yearToDate'; + +@Component({ + selector: 'igx-predefined-ranges-area', + standalone: true, + imports: [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/date-picker/src/date-range-picker/public_api.ts b/projects/igniteui-angular/date-picker/src/date-range-picker/public_api.ts new file mode 100644 index 00000000000..7997575389e --- /dev/null +++ b/projects/igniteui-angular/date-picker/src/date-range-picker/public_api.ts @@ -0,0 +1,25 @@ +import { IgxPickerClearComponent, IgxPickerToggleComponent } from 'igniteui-angular/core'; +import { IgxDateRangeEndComponent, IgxDateRangeSeparatorDirective, IgxDateRangeStartComponent } from './date-range-picker-inputs.common'; +import { IgxDateRangePickerComponent } from './date-range-picker.component'; +import { IgxHintDirective, IgxLabelDirective, IgxPrefixDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; +import { IgxCalendarHeaderTemplateDirective, IgxCalendarHeaderTitleTemplateDirective, IgxCalendarSubheaderTemplateDirective } from 'igniteui-angular/calendar'; + +export * from './date-range-picker-inputs.common'; +export * from './date-range-picker.component'; + +/* NOTE: Date-range picker directives collection for ease-of-use import in standalone components scenario */ +export const IGX_DATE_RANGE_PICKER_DIRECTIVES = [ + IgxDateRangePickerComponent, + IgxPickerToggleComponent, + IgxPickerClearComponent, + IgxDateRangeStartComponent, + IgxDateRangeEndComponent, + IgxDateRangeSeparatorDirective, + IgxLabelDirective, + IgxPrefixDirective, + IgxSuffixDirective, + IgxHintDirective, + IgxCalendarHeaderTemplateDirective, + IgxCalendarSubheaderTemplateDirective, + IgxCalendarHeaderTitleTemplateDirective +] as const; diff --git a/projects/igniteui-angular/date-picker/src/public_api.ts b/projects/igniteui-angular/date-picker/src/public_api.ts new file mode 100644 index 00000000000..fa521e30d4e --- /dev/null +++ b/projects/igniteui-angular/date-picker/src/public_api.ts @@ -0,0 +1,5 @@ +export * from './date-picker/public_api'; +export * from './date-range-picker/public_api'; + +export * from './date-picker/date-picker.module'; +export * from './date-range-picker/date-range-picker.module'; diff --git a/projects/igniteui-angular/dialog/README.md b/projects/igniteui-angular/dialog/README.md new file mode 100644 index 00000000000..1d03c8b46f9 --- /dev/null +++ b/projects/igniteui-angular/dialog/README.md @@ -0,0 +1,115 @@ +#igx-dialog + +**igx-dialog** supports dialog component that opens centered on top of the app content. + +With the igx-dialog you can create **alerts**, **dialogs** and **custom dialogs**. +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/dialog.html) + +# Usage + +## Alerts are done by adding title, message and button label. + +```html + + +``` +You can set title to the alert `title="TitleofTheAlert"` + +You can be more descriptive and set message `message="Your email has been sent successfully!"` + +You can attach to the left button select event `(leftButtonSelect)="alert.close()"` + + +##Dialogs are done by adding another button. + +```html + + +``` + +You can access all properties of the button component with the following attributes: + +`leftButtonLabel` + +`leftButtonType` + +`leftButtonColor` + +`leftButtonBackgroundColor` + +`leftButtonRipple` + + +##Custom Dialogs are done by adding any mark up in the igx-dialog tag. +When you are using Custom Dialogs you don't have a message property set. + +```HTML + +
+ + +
+
+ + +
+
+``` + +You can make the dialog dismissible `closeOnOutsideSelect="true"`` + +##Dialog Title area and dialog actions area are customizable throught igxDialogTitle and igxDialogActions directives. +Both directives can contain html elements, strings, icons or even other components. +```HTML + + +
TITLE
+
+ +
BUTTONS
+
+
+``` +or +```HTML + +
TITLE
+
BUTTONS
+
+``` + +You can now set set the position and animation settings used by the dialog by using `positionSettings` @Input + +```typescript +import { slideInLeft, slideOutRight } from 'igniteui-angular'; +... +@ViewChild('alert', { static: true }) public alert: IgxDialogComponent; + public newPositionSettings: PositionSettings = { + openAnimation: useAnimation(slideInTop, { params: { duration: '2000ms' } }), + closeAnimation: useAnimation(slideOutBottom, { params: { duration: '2000ms'} }), + horizontalDirection: HorizontalAlignment.Left, + verticalDirection: VerticalAlignment.Middle, + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Middle, + minSize: { height: 100, width: 100 } + }; + +this.alert.positionSettings = this.newPositionSettings; +``` + diff --git a/projects/igniteui-angular/dialog/index.ts b/projects/igniteui-angular/dialog/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/dialog/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/dialog/ng-package.json b/projects/igniteui-angular/dialog/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/dialog/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/dialog/src/dialog/dialog-content.component.html b/projects/igniteui-angular/dialog/src/dialog/dialog-content.component.html new file mode 100644 index 00000000000..efe481f28d9 --- /dev/null +++ b/projects/igniteui-angular/dialog/src/dialog/dialog-content.component.html @@ -0,0 +1,65 @@ +
+
+ @if (title) { +
+ {{ title }} +
+ } + @if (!title) { + + } + +
+ @if (message) { + + {{ message }} + + } + @if (!message) { + + } +
+ + @if (leftButtonLabel || rightButtonLabel) { +
+ @if (leftButtonLabel) { + + } + @if (rightButtonLabel) { + + } +
+ } + @if (!leftButtonLabel && !rightButtonLabel) { + + } +
+
diff --git a/projects/igniteui-angular/dialog/src/dialog/dialog.component.spec.ts b/projects/igniteui-angular/dialog/src/dialog/dialog.component.spec.ts new file mode 100644 index 00000000000..fea22acc8d9 --- /dev/null +++ b/projects/igniteui-angular/dialog/src/dialog/dialog.component.spec.ts @@ -0,0 +1,687 @@ +import { Component, ViewChild } from '@angular/core'; +import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { IDialogCancellableEventArgs, IDialogEventArgs, IgxDialogComponent } from './dialog.component'; +import { useAnimation } from '@angular/animations'; +import { PositionSettings, HorizontalAlignment, VerticalAlignment } from 'igniteui-angular/core'; +import { IgxToggleDirective } from '../../../directives/src/directives/toggle/toggle.directive'; +import { IgxDialogActionsDirective, IgxDialogTitleDirective } from './dialog.directives'; +import { slideInTop, slideOutBottom } from 'igniteui-angular/animations'; + +const OVERLAY_MAIN_CLASS = 'igx-overlay'; +const OVERLAY_WRAPPER_CLASS = `${OVERLAY_MAIN_CLASS}__wrapper--flex`; +const OVERLAY_MODAL_WRAPPER_CLASS = `${OVERLAY_MAIN_CLASS}__wrapper--modal`; +const CLASS_OVERLAY_CONTENT_MODAL = `${OVERLAY_MAIN_CLASS}__content--modal`; + +describe('Dialog', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + AlertComponent, + DialogComponent, + CustomDialogComponent, + NestedDialogsComponent, + CustomTemplates1DialogComponent, + CustomTemplates2DialogComponent, + DialogSampleComponent, + PositionSettingsDialogComponent, + DialogTwoWayDataBindingComponent + ] + }).compileComponents(); + })); + + afterEach(() => { + UIInteractions.clearOverlay(); + }); + + it('Initialize a datepicker component with id', () => { + const fixture = TestBed.createComponent(AlertComponent); + fixture.detectChanges(); + + const dialog = fixture.componentInstance.dialog; + const domDialog = fixture.debugElement.query(By.css('igx-dialog')).nativeElement; + + expect(dialog.id).toContain('igx-dialog-'); + expect(domDialog.id).toContain('igx-dialog-'); + + dialog.id = 'customDialog'; + fixture.detectChanges(); + + expect(dialog.id).toBe('customDialog'); + expect(domDialog.id).toBe('customDialog'); + }); + + it('Should set dialog title.', () => { + const fixture = TestBed.createComponent(AlertComponent); + const dialog = fixture.componentInstance.dialog; + const expectedTitle = 'alert'; + + dialog.open(); + fixture.detectChanges(); + + expect(dialog.title).toEqual(expectedTitle); + const titleDebugElement = fixture.debugElement.query(By.css('.igx-dialog__window-title')); + expect(titleDebugElement.nativeElement.textContent.trim()).toEqual(expectedTitle); + dialog.close(); + }); + + it('Should set dialog message.', () => { + const fixture = TestBed.createComponent(AlertComponent); + const dialog = fixture.componentInstance.dialog; + const expectedMessage = 'message'; + + dialog.open(); + fixture.detectChanges(); + + expect(dialog.message).toEqual(expectedMessage); + const messageDebugElement = fixture.debugElement.query(By.css('.igx-dialog__window-content')); + expect(messageDebugElement.nativeElement.textContent.trim()).toEqual(expectedMessage); + }); + + it('Should focus focusable elements in dialog on Tab key pressed', () => { + const fix = TestBed.createComponent(DialogComponent); + fix.detectChanges(); + + const dialog = fix.componentInstance.dialog; + dialog.open(); + fix.detectChanges(); + + const buttons = fix.debugElement.queryAll(By.css('button')); + const toggle = fix.debugElement.query(By.directive(IgxToggleDirective)); + + UIInteractions.triggerEventHandlerKeyDown('Tab', toggle); + fix.detectChanges(); + expect(document.activeElement).toEqual(buttons[0].nativeElement); + + UIInteractions.triggerEventHandlerKeyDown('Tab', toggle); + fix.detectChanges(); + expect(document.activeElement).toEqual(buttons[1].nativeElement); + + UIInteractions.triggerEventHandlerKeyDown('Tab', toggle); + fix.detectChanges(); + expect(document.activeElement).toEqual(buttons[0].nativeElement); + + UIInteractions.triggerEventHandlerKeyDown('Tab', toggle, false, true); + fix.detectChanges(); + expect(document.activeElement).toEqual(buttons[1].nativeElement); + + UIInteractions.triggerEventHandlerKeyDown('Tab', toggle, false, true); + fix.detectChanges(); + expect(document.activeElement).toEqual(buttons[0].nativeElement); + + UIInteractions.triggerEventHandlerKeyDown('Tab', toggle, false, true); + fix.detectChanges(); + expect(document.activeElement).toEqual(buttons[1].nativeElement); + }); + + it('should trap focus on dialog modal with non-focusable elements', () => { + const fix = TestBed.createComponent(AlertComponent); + fix.detectChanges(); + + const dialog = fix.componentInstance.dialog; + dialog.leftButtonLabel = ''; + fix.detectChanges(); + + dialog.open(); + fix.detectChanges(); + + const toggle = fix.debugElement.query(By.directive(IgxToggleDirective)); + + UIInteractions.triggerEventHandlerKeyDown('Tab', toggle); + fix.detectChanges(); + expect(document.activeElement).toEqual(toggle.nativeElement); + + UIInteractions.triggerEventHandlerKeyDown('Tab', toggle, false, true); + fix.detectChanges(); + expect(document.activeElement).toEqual(toggle.nativeElement); + + UIInteractions.triggerEventHandlerKeyDown('Tab', toggle); + fix.detectChanges(); + expect(document.activeElement).toEqual(toggle.nativeElement); + }); + + it('Should open and close dialog when set values to IsOpen', fakeAsync(() => { + const fixture = TestBed.createComponent(AlertComponent); + const dialog = fixture.componentInstance.dialog; + + dialog.isOpen = true; + tick(100); + fixture.detectChanges(); + expect(dialog.isOpen).toEqual(true); + + dialog.close(); + tick(); + fixture.detectChanges(); + expect(dialog.isOpen).toEqual(false); + })); + + it('Should open and close dialog with isOpen two way data binding', fakeAsync(() => { + const fixture = TestBed.createComponent(DialogTwoWayDataBindingComponent); + const dialog = fixture.componentInstance.dialog; + fixture.detectChanges(); + + fixture.componentInstance.myDialog = true; + + fixture.detectChanges(); + tick(100); + expect(dialog.isOpen).toEqual(true); + + + fixture.componentInstance.myDialog = false; + + fixture.detectChanges(); + tick(); + expect(dialog.isOpen).toEqual(false); + })); + + it('Should set custom modal message.', () => { + const fixture = TestBed.createComponent(CustomDialogComponent); + const dialog = fixture.componentInstance.dialog; + + dialog.open(); + fixture.detectChanges(); + + const dialogWindow = fixture.debugElement.query(By.css('.igx-dialog__window')); + const customContent = fixture.debugElement.query(By.css('.custom-dialog__content')); + expect(customContent).toBeTruthy(); + expect(dialogWindow.children.length).toEqual(2); + expect(customContent.children.length).toEqual(1); + }); + + it('Should set left and right button properties.', () => { + const fixture = TestBed.createComponent(DialogComponent); + const dialog = fixture.componentInstance.dialog; + + fixture.detectChanges(); + + dialog.open(); + expect(dialog.leftButtonLabel).toEqual('left button'); + expect(dialog.leftButtonType).toEqual('contained'); + expect(dialog.leftButtonRipple).toEqual('pink'); + + expect(dialog.rightButtonLabel).toEqual('right button'); + expect(dialog.rightButtonType).toEqual('contained'); + expect(dialog.rightButtonRipple).toEqual('white'); + }); + + it('Should execute open/close methods.', fakeAsync(() => { + const fixture = TestBed.createComponent(AlertComponent); + const dialog = fixture.componentInstance.dialog; + fixture.detectChanges(); + expect(dialog.isOpen).toEqual(false); + + dialog.open(); + fixture.detectChanges(); + tick(); + expect(dialog.isOpen).toEqual(true); + + dialog.close(); + tick(); + fixture.detectChanges(); + expect(dialog.isOpen).toEqual(false); + })); + + it('Should set closeOnOutsideSelect.', fakeAsync(() => { + const fixture = TestBed.createComponent(AlertComponent); + fixture.detectChanges(); + const dialog = fixture.componentInstance.dialog; + dialog.open(); + tick(); + fixture.detectChanges(); + + const dialogElem = fixture.debugElement.query(By.css('.igx-dialog')).nativeElement; + dialogElem.click(); + tick(); + fixture.detectChanges(); + + expect(dialog.isOpen).toEqual(false); + + dialog.closeOnOutsideSelect = false; + dialog.open(); + tick(); + fixture.detectChanges(); + + dialogElem.click(); + tick(); + fixture.detectChanges(); + expect(dialog.isOpen).toEqual(true); + })); + + it('Should test events.', fakeAsync(() => { + const fixture = TestBed.createComponent(DialogSampleComponent); + const dialog = fixture.componentInstance.dialog; + const args: IDialogEventArgs = { + dialog, + event: undefined, + }; + let cancellableArgs: IDialogCancellableEventArgs = { + dialog, + event: null, + cancel: false + }; + + spyOn(dialog.opening, 'emit'); + spyOn(dialog.opened, 'emit'); + spyOn(dialog.isOpenChange, 'emit'); + spyOn(dialog.closing, 'emit'); + spyOn(dialog.closed, 'emit'); + + dialog.open(); + tick(); + fixture.detectChanges(); + + expect(dialog.opening.emit).toHaveBeenCalledWith(cancellableArgs); + expect(dialog.isOpenChange.emit).toHaveBeenCalledWith(true); + // expect(dialog.onOpened.emit).toHaveBeenCalled(); + + dialog.close(); + tick(); + fixture.detectChanges(); + + cancellableArgs = { dialog, event: undefined, cancel: false }; + expect(dialog.closing.emit).toHaveBeenCalledWith(cancellableArgs); + expect(dialog.closed.emit).toHaveBeenCalledWith(args); + expect(dialog.isOpenChange.emit).toHaveBeenCalledWith(false); + + dialog.open(); + tick(); + fixture.detectChanges(); + const buttons = document.querySelectorAll('button'); + const leftButton = buttons[0]; + const rightButton = buttons[1]; + + spyOn(dialog.leftButtonSelect, 'emit'); + dispatchEvent(leftButton, 'click'); + expect(dialog.leftButtonSelect.emit).toHaveBeenCalled(); + + spyOn(dialog.rightButtonSelect, 'emit'); + dispatchEvent(rightButton, 'click'); + tick(); + expect(dialog.rightButtonSelect.emit).toHaveBeenCalled(); + })); + + it('Should set ARIA attributes.', () => { + const alertFixture = TestBed.createComponent(AlertComponent); + const alert = alertFixture.componentInstance.dialog; + + alert.open(); + alertFixture.detectChanges(); + expect(alert.role).toEqual('alertdialog'); + + const dialogFixture = TestBed.createComponent(DialogComponent); + const dialog = dialogFixture.componentInstance.dialog; + + dialog.open(); + dialogFixture.detectChanges(); + expect(dialog.role).toEqual('dialog'); + const titleWrapper = dialogFixture.debugElement.query(By.css('.igx-dialog__window-title')); + const dialogWindow = dialogFixture.debugElement.query(By.css('.igx-dialog__window')); + expect(titleWrapper.attributes.id).toEqual(dialogWindow.attributes['aria-labelledby']); + }); + + it('Should close only inner dialog on closeOnOutsideSelect.', fakeAsync(() => { + const fixture = TestBed.createComponent(NestedDialogsComponent); + fixture.detectChanges(); + + const mainDialog = fixture.componentInstance.main; + const childDialog = fixture.componentInstance.child; + + mainDialog.open(); + tick(); + + childDialog.open(); + tick(); + fixture.detectChanges(); + + const dialogs = fixture.debugElement.queryAll(By.css('.igx-dialog')); + const maindDialogElem = dialogs[0].nativeElement; + const childDialogElem = dialogs[1].nativeElement; + + childDialogElem.click(); + tick(); + fixture.detectChanges(); + + expect(mainDialog.isOpen).toEqual(true); + expect(childDialog.isOpen).toEqual(false); + + maindDialogElem.click(); + tick(); + fixture.detectChanges(); + + expect(mainDialog.isOpen).toEqual(false); + expect(childDialog.isOpen).toEqual(false); + })); + + it('Should initialize igx-dialog custom title and actions', () => { + const data = [{ + component: CustomTemplates1DialogComponent + }, { + component: CustomTemplates2DialogComponent + }]; + + data.forEach((item) => { + const fixture = TestBed.createComponent(item.component); + const dialog = fixture.componentInstance.dialog; + + dialog.open(); + fixture.detectChanges(); + + const dialogWindow = fixture.debugElement.query(By.css('.igx-dialog__window')); + expect(dialogWindow.children.length).toEqual(3); + + expect(dialogWindow.children[0].nativeElement.innerText.toString()).toContain('TITLE'); + expect(dialogWindow.children[2].nativeElement.innerText.toString()).toContain('BUTTONS'); + + dialog.close(); + }); + + }); + + it('When modal mode is changed, overlay should be informed', fakeAsync(() => { + const fix = TestBed.createComponent(AlertComponent); + fix.detectChanges(); + + const dialog = fix.componentInstance.dialog; + + dialog.open(); + tick(); + fix.detectChanges(); + + let overlaydiv = document.getElementsByClassName(OVERLAY_MAIN_CLASS)[0]; + let overlayWrapper = overlaydiv.children[0]; + expect(overlayWrapper.classList.contains(OVERLAY_WRAPPER_CLASS)).toBe(true); + expect(overlayWrapper.classList.contains(OVERLAY_MODAL_WRAPPER_CLASS)).toBe(false); + + dialog.close(); + tick(); + fix.detectChanges(); + + fix.componentInstance.isModal = true; + fix.detectChanges(); + + dialog.open(); + tick(16); + fix.detectChanges(); + + overlaydiv = document.getElementsByClassName(OVERLAY_MAIN_CLASS)[0]; + overlayWrapper = overlaydiv.children[0]; + expect(overlayWrapper.classList.contains(OVERLAY_MODAL_WRAPPER_CLASS)).toBe(true); + expect(overlayWrapper.classList.contains(OVERLAY_WRAPPER_CLASS)).toBe(true); + })); + + it('Default button of the dialog is focused after opening the dialog and can be closed with keyboard.', fakeAsync(() => { + const fix = TestBed.createComponent(DialogComponent); + fix.detectChanges(); + + const dialog: IgxDialogComponent = fix.componentInstance.dialog as IgxDialogComponent; + dialog.open(); + tick(100); + fix.detectChanges(); + tick(100); + + // Verify dialog is opened and its default right button is focused + const dialogDOM = fix.debugElement.query(By.css('.igx-dialog')); + const rightButton = dialogDOM.queryAll(By.css('button')).filter((b) => b.nativeElement.innerText === 'right button')[0]; + expect(document.activeElement).toBe(rightButton.nativeElement); + expect(dialog.isOpen).toEqual(true); + + // Press 'escape' key + UIInteractions.triggerKeyDownEvtUponElem('Escape', document.activeElement); + tick(100); + fix.detectChanges(); + + // Verify dialog is closed and its default right button is no longer focused + expect(document.activeElement).not.toBe(rightButton.nativeElement); + expect(dialog.isOpen).toEqual(false); + })); + + describe('Position settings', () => { + let fix; + let dialog; + + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(PositionSettingsDialogComponent); + fix.detectChanges(); + dialog = fix.componentInstance.dialog; + })); + + it('Define different position settings ', fakeAsync(() => { + const currentElement = fix.componentInstance; + dialog.open(); + tick(16); + fix.detectChanges(); + + expect(dialog.isOpen).toEqual(true); + const firstContentRect = document.getElementsByClassName(CLASS_OVERLAY_CONTENT_MODAL)[0].getBoundingClientRect(); + const middleDialogPosition = document.documentElement.offsetHeight / 2 - firstContentRect.height / 2; + expect(firstContentRect.left).toEqual(0, 'OffsetLeft position check'); + expect(firstContentRect.top).toBeGreaterThanOrEqual(middleDialogPosition - 2, 'OffsetTop position check'); + expect(firstContentRect.top).toBeLessThanOrEqual(middleDialogPosition + 2, 'OffsetTop position check'); + + dialog.close(); + tick(16); + fix.detectChanges(); + + expect(dialog.isOpen).toEqual(false); + dialog.positionSettings = currentElement.newPositionSettings; + tick(16); + fix.detectChanges(); + + dialog.open(); + tick(16); + fix.detectChanges(); + + expect(dialog.isOpen).toEqual(true); + const secondContentRect = document.getElementsByClassName(CLASS_OVERLAY_CONTENT_MODAL)[0].getBoundingClientRect(); + const topDialogPosition = document.documentElement.offsetWidth / 2 - secondContentRect.width / 2; + expect(secondContentRect.top).toEqual(0, 'OffsetTop position check'); + expect(secondContentRect.left).toBeGreaterThanOrEqual(topDialogPosition - 2, 'OffsetLeft position check'); + expect(secondContentRect.left).toBeLessThanOrEqual(topDialogPosition + 2, 'OffsetLeft position check'); + + dialog.close(); + tick(16); + fix.detectChanges(); + + expect(dialog.isOpen).toEqual(false); + })); + + it('Set animation settings', () => { + const currentElement = fix.componentInstance; + + // Check initial animation settings + expect(dialog.positionSettings.openAnimation.animation.type).toEqual(8, 'Animation type is set'); + expect(dialog.positionSettings.openAnimation.options.params.duration).toEqual('200ms', 'Animation duration is set to 200ms'); + + expect(dialog.positionSettings.closeAnimation.animation.type).toEqual(8, 'Animation type is set'); + expect(dialog.positionSettings.closeAnimation.options.params.duration).toEqual('200ms', 'Animation duration is set to 200ms'); + + dialog.positionSettings = currentElement.animationSettings; + fix.detectChanges(); + + // Check the new animation settings + expect(dialog.positionSettings.openAnimation.options.params.duration).toEqual('800ms', 'Animation duration is set to 800ms'); + expect(dialog.positionSettings.closeAnimation.options.params.duration).toEqual('700ms', 'Animation duration is set to 700ms'); + }); + }); + + const dispatchEvent = (element: HTMLElement, eventType: string) => { + const event = new Event(eventType); + element.dispatchEvent(event); + }; +}); + +@Component({ + template: ` +
+ + +
`, + imports: [IgxDialogComponent] +}) +class AlertComponent { + @ViewChild('dialog', { static: true }) public dialog: IgxDialogComponent; + public isModal = false; +} + +@Component({ + template: ` +
+ + +
`, + imports: [IgxDialogComponent] +}) +class DialogComponent { + @ViewChild('dialog', { static: true }) public dialog: IgxDialogComponent; +} + +@Component({ + template: ` +
+ + +
`, + imports: [IgxDialogComponent] +}) +class DialogTwoWayDataBindingComponent { + @ViewChild('dialog', { static: true }) public dialog: IgxDialogComponent; + public myDialog = false; +} + +@Component({ + template: ` +
+ +
+

Custom Sample

+
+
+
`, + imports: [IgxDialogComponent] +}) +class DialogSampleComponent { + @ViewChild('dialog', { static: true }) public dialog: IgxDialogComponent; +} +@Component({ + template: ` +
+ +
+ +
+
+
`, + imports: [IgxDialogComponent] +}) +class CustomDialogComponent { + @ViewChild('dialog', { static: true }) public dialog: IgxDialogComponent; +} + +@Component({ + template: ` + + + + + `, + imports: [IgxDialogComponent] +}) +class NestedDialogsComponent { + @ViewChild('child', { static: true }) public child: IgxDialogComponent; + @ViewChild('main', { static: true }) public main: IgxDialogComponent; +} + +@Component({ + template: ` + + +
TITLE 1
+
+ +
BUTTONS 1
+
+
`, + imports: [IgxDialogComponent, IgxDialogTitleDirective, IgxDialogActionsDirective] +}) +class CustomTemplates1DialogComponent { + @ViewChild('dialog', { static: true }) public dialog: IgxDialogComponent; +} + +@Component({ + template: ` + +
TITLE 2
+
BUTTONS 2
+
`, + imports: [IgxDialogComponent, IgxDialogTitleDirective, IgxDialogActionsDirective] +}) +class CustomTemplates2DialogComponent { + @ViewChild('dialog', { static: true }) public dialog: IgxDialogComponent; +} + + +@Component({ + template: ` + + `, + imports: [IgxDialogComponent] +}) +class PositionSettingsDialogComponent { + @ViewChild('dialog', { static: true }) public dialog: IgxDialogComponent; + + public positionSettings: PositionSettings = { + horizontalDirection: HorizontalAlignment.Left, + verticalDirection: VerticalAlignment.Middle, + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Middle, + openAnimation: useAnimation(slideInTop, { params: { duration: '200ms' } }), + closeAnimation: useAnimation(slideOutBottom, { params: { duration: '200ms' } }) + }; + + public newPositionSettings: PositionSettings = { + horizontalDirection: HorizontalAlignment.Center, + verticalDirection: VerticalAlignment.Top + }; + + public animationSettings: PositionSettings = { + openAnimation: useAnimation(slideInTop, { params: { duration: '800ms' } }), + closeAnimation: useAnimation(slideOutBottom, { params: { duration: '700ms' } }) + }; + +} diff --git a/projects/igniteui-angular/dialog/src/dialog/dialog.component.ts b/projects/igniteui-angular/dialog/src/dialog/dialog.component.ts new file mode 100644 index 00000000000..c6f95f29f63 --- /dev/null +++ b/projects/igniteui-angular/dialog/src/dialog/dialog.component.ts @@ -0,0 +1,577 @@ +import { Component, ElementRef, EventEmitter, HostBinding, Input, OnDestroy, OnInit, Output, ViewChild, AfterContentInit, booleanAttribute, inject } from '@angular/core'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { IgxNavigationService, IToggleView } from 'igniteui-angular/core'; +import { IgxButtonType, IgxButtonDirective } from 'igniteui-angular/directives'; +import { IgxRippleDirective } from 'igniteui-angular/directives'; +import { IgxToggleDirective } from 'igniteui-angular/directives'; +import { OverlaySettings, GlobalPositionStrategy, NoOpScrollStrategy, PositionSettings } from 'igniteui-angular/core'; +import { IgxFocusDirective } from 'igniteui-angular/directives'; +import { IgxFocusTrapDirective } from 'igniteui-angular/directives'; +import { CancelableEventArgs, IBaseEventArgs } from 'igniteui-angular/core'; +import { fadeIn, fadeOut } from 'igniteui-angular/animations'; + +let DIALOG_ID = 0; +/** + * **Ignite UI for Angular Dialog Window** - + * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/dialog.html) + * + * The Ignite UI Dialog Window presents a dialog window to the user which can simply display messages or display + * more complicated visuals such as a user sign-in form. It also provides a right and left button + * which can be used for custom actions. + * + * Example: + * ```html + * + * + *
+ * + * + * + * + *
+ *
+ * + * + * + * + *
+ *
+ * ``` + */ +@Component({ + selector: 'igx-dialog', + templateUrl: 'dialog-content.component.html', + imports: [IgxToggleDirective, IgxFocusTrapDirective, IgxFocusDirective, IgxButtonDirective, IgxRippleDirective] +}) +export class IgxDialogComponent implements IToggleView, OnInit, OnDestroy, AfterContentInit { + private elementRef = inject(ElementRef); + private navService = inject(IgxNavigationService, { optional: true }); + + private static NEXT_ID = 1; + private static readonly DIALOG_CLASS = 'igx-dialog'; + + + + @ViewChild(IgxToggleDirective, { static: true }) + public toggleRef: IgxToggleDirective; + + /** + * Sets the value of the `id` attribute. If not provided it will be automatically generated. + * ```html + * + * + * ``` + */ + @HostBinding('attr.id') + @Input() + public id = `igx-dialog-${DIALOG_ID++}`; + + /** + * Controls whether the dialog should be shown as modal. Defaults to `true` + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public get isModal() { + return this._isModal; + } + + public set isModal(val: boolean) { + this._overlayDefaultSettings.modal = val; + this._isModal = val; + } + + /** + * Controls whether the dialog should close when `Esc` key is pressed. Defaults to `true` + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public get closeOnEscape() { + return this._closeOnEscape; + } + + public set closeOnEscape(val: boolean) { + this._overlayDefaultSettings.closeOnEscape = val; + this._closeOnEscape = val; + } + + /** + * Set whether the Tab key focus is trapped within the dialog when opened. + * Defaults to `true`. + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public focusTrap = true; + + /** + * Sets the title of the dialog. + * ```html + * + * ``` + */ + @Input() + public title = ''; + + /** + * Sets the message text of the dialog. + * ```html + * + * ``` + */ + @Input() + public message = ''; + + /** + * Sets the `label` of the left button of the dialog. + * ```html + * + * ``` + */ + @Input() + public leftButtonLabel = ''; + + /** + * Sets the left button `type`. The types are `flat`, `contained` and `fab`. + * The `flat` type button is a rectangle and doesn't have a shadow.
+ * The `contained` type button is also a rectangle but has a shadow.
+ * The `fab` type button is a circle with a shadow.
+ * The default value is `flat`. + * ```html + * + * ``` + */ + @Input() + public leftButtonType: IgxButtonType = 'flat'; + + /** + * Sets the left button `ripple`. The `ripple` animates a click/tap to a component as a series of fading waves. + * The property accepts all valid CSS color property values. + * ```html + * + * ``` + */ + @Input() + public leftButtonRipple = ''; + + /** + * Sets the `label` of the right button of the dialog. + * ```html + * + * ``` + */ + @Input() + public rightButtonLabel = ''; + + /** + * Sets the right button `type`. The types are `flat`, `contained` and `fab`. + * The `flat` type button is a rectangle and doesn't have a shadow.
+ * The `contained` type button is also a rectangle but has a shadow.
+ * The `fab` type button is a circle with a shadow.
+ * The default value is `flat`. + * ```html + * + * ``` + */ + @Input() + public rightButtonType: IgxButtonType = 'flat'; + + /** + * Sets the right button `ripple`. + * ```html + * + * ``` + */ + @Input() + public rightButtonRipple = ''; + + /** + * Gets/Sets whether the dialog should close on click outside the component. By default it's false. + * ```html + * + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public get closeOnOutsideSelect() { + return this._closeOnOutsideSelect; + } + + public set closeOnOutsideSelect(val: boolean) { + this._overlayDefaultSettings.closeOnOutsideClick = val; + this._closeOnOutsideSelect = val; + } + + /** + * Get the position and animation settings used by the dialog. + * ```typescript + * @ViewChild('alert', { static: true }) public alert: IgxDialogComponent; + * let currentPosition: PositionSettings = this.alert.positionSettings + * ``` + */ + @Input() + public get positionSettings(): PositionSettings { + return this._positionSettings; + } + + /** + * Set the position and animation settings used by the dialog. + * ```typescript + * import { slideInLeft, slideOutRight } from 'igniteui-angular'; + * ... + * @ViewChild('alert', { static: true }) public alert: IgxDialogComponent; + * public newPositionSettings: PositionSettings = { + * openAnimation: useAnimation(slideInTop, { params: { duration: '2000ms' } }), + * closeAnimation: useAnimation(slideOutBottom, { params: { duration: '2000ms'} }), + * horizontalDirection: HorizontalAlignment.Left, + * verticalDirection: VerticalAlignment.Middle, + * horizontalStartPoint: HorizontalAlignment.Left, + * verticalStartPoint: VerticalAlignment.Middle, + * minSize: { height: 100, width: 100 } + * }; + * this.alert.positionSettings = this.newPositionSettings; + * ``` + */ + public set positionSettings(settings: PositionSettings) { + this._positionSettings = settings; + this._overlayDefaultSettings.positionStrategy = new GlobalPositionStrategy(this._positionSettings); + } + + /** + * The default `tabindex` attribute for the component + * + * @hidden + */ + @HostBinding('attr.tabindex') + public tabindex = -1; + + /** + * An event that is emitted before the dialog is opened. + * ```html + * + * + * ``` + */ + @Output() + public opening = new EventEmitter(); + + /** + * An event that is emitted after the dialog is opened. + * ```html + * + * + * ``` + */ + @Output() + public opened = new EventEmitter(); + + /** + * An event that is emitted before the dialog is closed. + * ```html + * + * + * ``` + */ + @Output() + public closing = new EventEmitter(); + + /** + * An event that is emitted after the dialog is closed. + * ```html + * + * + * ``` + */ + @Output() + public closed = new EventEmitter(); + + /** + * An event that is emitted when the left button is clicked. + * ```html + * + * + * ``` + */ + @Output() + public leftButtonSelect = new EventEmitter(); + + /** + * An event that is emitted when the right button is clicked. + * ```html + * + * + * ``` + */ + @Output() + public rightButtonSelect = new EventEmitter(); + + /** + * @hidden + */ + @Output() public isOpenChange = new EventEmitter(); + + /** + * @hidden + */ + public get element() { + return this.elementRef.nativeElement; + } + + /** + * Returns the value of state. Possible state values are "open" or "close". + * ```typescript + * @ViewChild("MyDialog") + * public dialog: IgxDialogComponent; + * ngAfterViewInit() { + * let dialogState = this.dialog.state; + * } + * ``` + */ + public get state(): string { + return this.isOpen ? 'open' : 'close'; + } + + /** + * State of the dialog. + * + * ```typescript + * // get + * let dialogIsOpen = this.dialog.isOpen; + * ``` + * + * ```html + * + * + * ``` + * + * Two-way data binding. + * ```html + * + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public get isOpen() { + return this.toggleRef ? !this.toggleRef.collapsed : false; + } + public set isOpen(value: boolean) { + if (value !== this.isOpen) { + this.isOpenChange.emit(value); + if (value) { + requestAnimationFrame(() => { + this.open(); + }); + } else { + this.close(); + } + } + } + + @HostBinding('class.igx-dialog--hidden') + public get isCollapsed() { + return this.toggleRef.collapsed; + } + + /** + * Returns the value of the role of the dialog. The valid values are `dialog`, `alertdialog`, `alert`. + * ```typescript + * @ViewChild("MyDialog") + * public dialog: IgxDialogComponent; + * ngAfterViewInit() { + * let dialogRole = this.dialog.role; + * } + * ``` + */ + @Input() + public get role() { + if (this.leftButtonLabel !== '' && this.rightButtonLabel !== '') { + return 'dialog'; + } else if ( + this.leftButtonLabel !== '' || + this.rightButtonLabel !== '' + ) { + return 'alertdialog'; + } else { + return 'alert'; + } + } + + /** + * Returns the value of the title id. + * ```typescript + * @ViewChild("MyDialog") + * public dialog: IgxDialogComponent; + * ngAfterViewInit() { + * let dialogTitle = this.dialog.titleId; + * } + * ``` + */ + @Input() + public get titleId() { + return this._titleId; + } + + protected destroy$ = new Subject(); + + private _positionSettings: PositionSettings = { + openAnimation: fadeIn, + closeAnimation: fadeOut + }; + + private _overlayDefaultSettings: OverlaySettings; + private _closeOnOutsideSelect = false; + private _closeOnEscape = true; + private _isModal = true; + private _titleId: string; + + constructor() { + this._titleId = IgxDialogComponent.NEXT_ID++ + '_title'; + + this._overlayDefaultSettings = { + positionStrategy: new GlobalPositionStrategy(this._positionSettings), + scrollStrategy: new NoOpScrollStrategy(), + modal: this.isModal, + closeOnEscape: this._closeOnEscape, + closeOnOutsideClick: this.closeOnOutsideSelect + }; + } + + public ngAfterContentInit() { + this.toggleRef.closing.pipe(takeUntil(this.destroy$)).subscribe((eventArgs) => this.emitCloseFromDialog(eventArgs)); + this.toggleRef.closed.pipe(takeUntil(this.destroy$)).subscribe((eventArgs) => this.emitClosedFromDialog(eventArgs)); + this.toggleRef.opened.pipe(takeUntil(this.destroy$)).subscribe((eventArgs) => this.emitOpenedFromDialog(eventArgs)); + } + + /** + * A method that opens the dialog. + * + * @memberOf {@link IgxDialogComponent} + * ```html + * + * + * ``` + */ + public open(overlaySettings: OverlaySettings = this._overlayDefaultSettings) { + const eventArgs: IDialogCancellableEventArgs = { dialog: this, event: null, cancel: false }; + this.opening.emit(eventArgs); + if (!eventArgs.cancel) { + overlaySettings = { ...{}, ... this._overlayDefaultSettings, ...overlaySettings }; + this.toggleRef.open(overlaySettings); + this.isOpenChange.emit(true); + if (!this.leftButtonLabel && !this.rightButtonLabel) { + this.toggleRef.element.focus(); + } + } + + } + + /** + * A method that that closes the dialog. + * + * @memberOf {@link IgxDialogComponent} + * ```html + * + * + * ``` + */ + public close() { + // `closing` will emit from `toggleRef.closing` subscription + this.toggleRef?.close(); + } + + + /** + * A method that opens/closes the dialog. + * + * @memberOf {@link IgxDialogComponent} + * ```html + * + * + * ``` + */ + public toggle() { + if (this.isOpen) { + this.close(); + } else { + this.open(); + } + } + + /** + * @hidden + */ + public onDialogSelected(event) { + event.stopPropagation(); + if ( + this.isOpen && + this.closeOnOutsideSelect && + event.target.classList.contains(IgxDialogComponent.DIALOG_CLASS) + ) { + this.close(); + } + } + + /** + * @hidden + */ + public onInternalLeftButtonSelect(event) { + this.leftButtonSelect.emit({ dialog: this, event }); + } + + /** + * @hidden + */ + public onInternalRightButtonSelect(event) { + this.rightButtonSelect.emit({ dialog: this, event }); + } + + /** + * @hidden + */ + public ngOnInit() { + if (this.navService && this.id) { + this.navService.add(this.id, this); + } + } + /** + * @hidden + */ + public ngOnDestroy() { + if (this.navService && this.id) { + this.navService.remove(this.id); + } + } + + private emitCloseFromDialog(eventArgs) { + const dialogEventsArgs = { dialog: this, event: eventArgs.event, cancel: eventArgs.cancel }; + this.closing.emit(dialogEventsArgs); + eventArgs.cancel = dialogEventsArgs.cancel; + if (!eventArgs.cancel) { + this.isOpenChange.emit(false); + } + } + + private emitClosedFromDialog(eventArgs) { + this.closed.emit({ dialog: this, event: eventArgs.event }); + } + + private emitOpenedFromDialog(eventArgs) { + this.opened.emit({ dialog: this, event: eventArgs.event }); + } +} + +export interface IDialogEventArgs extends IBaseEventArgs { + dialog: IgxDialogComponent; + event: Event; +} + +export interface IDialogCancellableEventArgs extends IDialogEventArgs, CancelableEventArgs { } diff --git a/projects/igniteui-angular/dialog/src/dialog/dialog.directives.ts b/projects/igniteui-angular/dialog/src/dialog/dialog.directives.ts new file mode 100644 index 00000000000..0df18988c47 --- /dev/null +++ b/projects/igniteui-angular/dialog/src/dialog/dialog.directives.ts @@ -0,0 +1,27 @@ +import { Directive, HostBinding } from '@angular/core'; + +/** + * @hidden + */ +@Directive({ + selector: 'igx-dialog-title,[igxDialogTitle]', + standalone: true +}) +export class IgxDialogTitleDirective { + + @HostBinding('class.igx-dialog__window-title') + public defaultStyle = true; + } + +/** + * @hidden + */ +@Directive({ + selector: 'igx-dialog-actions,[igxDialogActions]', + standalone: true +}) +export class IgxDialogActionsDirective { + + @HostBinding('class.igx-dialog__window-actions') + public defaultClass = true; + } diff --git a/projects/igniteui-angular/dialog/src/dialog/dialog.module.ts b/projects/igniteui-angular/dialog/src/dialog/dialog.module.ts new file mode 100644 index 00000000000..e5e6dccb86c --- /dev/null +++ b/projects/igniteui-angular/dialog/src/dialog/dialog.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { IGX_DIALOG_DIRECTIVES } from './public_api'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + ...IGX_DIALOG_DIRECTIVES + ], + exports: [ + ...IGX_DIALOG_DIRECTIVES + ] +}) +export class IgxDialogModule { } diff --git a/projects/igniteui-angular/dialog/src/dialog/public_api.ts b/projects/igniteui-angular/dialog/src/dialog/public_api.ts new file mode 100644 index 00000000000..9842d637b6c --- /dev/null +++ b/projects/igniteui-angular/dialog/src/dialog/public_api.ts @@ -0,0 +1,12 @@ +import { IgxDialogComponent } from './dialog.component'; +import { IgxDialogActionsDirective, IgxDialogTitleDirective } from './dialog.directives'; + +export * from './dialog.component'; +export * from './dialog.directives'; + +/* NOTE: Dialog directives collection for ease-of-use import in standalone components scenario */ +export const IGX_DIALOG_DIRECTIVES = [ + IgxDialogComponent, + IgxDialogTitleDirective, + IgxDialogActionsDirective +] as const; diff --git a/projects/igniteui-angular/dialog/src/public_api.ts b/projects/igniteui-angular/dialog/src/public_api.ts new file mode 100644 index 00000000000..b98d9f3c755 --- /dev/null +++ b/projects/igniteui-angular/dialog/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './dialog/public_api'; +export * from './dialog/dialog.module'; diff --git a/projects/igniteui-angular/directives/README.md b/projects/igniteui-angular/directives/README.md new file mode 100644 index 00000000000..5fce3eac997 --- /dev/null +++ b/projects/igniteui-angular/directives/README.md @@ -0,0 +1,23 @@ +# Directives + +Directives for Ignite UI for Angular. + +This entry point provides all standalone directives and directive modules. + +## Directive Documentation + +- [Button](src/directives/button/README.md) +- [Date Time Editor](src/directives/date-time-editor/README.md) +- [Divider](src/directives/divider/README.md) +- [Drag and Drop](src/directives/drag-drop/README.md) +- [Filter](src/directives/filter/README-FILTER.md) +- [Focus Trap](src/directives/focus-trap/README.md) +- [Form Control](src/directives/form-control/README.md) +- [For Of](src/directives/for-of/README.md) +- [Layout](src/directives/layout/README.md) +- [Mask](src/directives/mask/README.md) +- [Ripple](src/directives/ripple/README.md) +- [Text Highlight](src/directives/text-highlight/README.md) +- [Text Selection](src/directives/text-selection/README.md) +- [Toggle](src/directives/toggle/README.md) +- [Tooltip](src/directives/tooltip/README.md) diff --git a/projects/igniteui-angular/directives/index.ts b/projects/igniteui-angular/directives/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/directives/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/directives/ng-package.json b/projects/igniteui-angular/directives/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/directives/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/directives/src/directives/button/README.md b/projects/igniteui-angular/directives/src/directives/button/README.md new file mode 100644 index 00000000000..33cd329940b --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/button/README.md @@ -0,0 +1,31 @@ +# igxButton + +The **igxButton** directive is intended to be used on any button, span, div, input, or anchor element to turn it into a fully functional button. +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/button.html) + +# Usage +```html +Click me +``` + +# API Summary +| Name | Type | Description | +|:----------|:-------------:|:------| +| `igxButton` | string | Set the type of igxButton to be used. Default is set to flat. | +| `buttonSelected` | EventEmitter | Emitted only when a button gets selected, or deselected, and not on initialization. | +| `selected` | boolean | Gets or sets whether the button is selected. Mainly used in the IgxButtonGroup component and it will have no effect if set separately. | + +# Button types +| Name | Description | +|:----------|:-------------:| +| `flat` | The default button type. Uses transparent background and the secondary theme color from the palette color for the text. | +| `outlined` | Very similar to the flat button type but with a thin outline around the edges of the button. | +| `contained` | As the name implies, this button type features a subtle shadow. Uses the secondary theme color from the palette for background. | +| `fab` | Floating action button type. Circular with secondary theme color for background. | +| `icon` | This is the simplest of button types. Use it whenever you need to use an icon as button. | + +# Examples +Using `igxButton` to turn a span element into an for Angular styled button. +```html +Click me +``` diff --git a/projects/igniteui-angular/directives/src/directives/button/button-base.ts b/projects/igniteui-angular/directives/src/directives/button/button-base.ts new file mode 100644 index 00000000000..63ab95cb2ea --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/button/button-base.ts @@ -0,0 +1,135 @@ +import { + Directive, + ElementRef, + EventEmitter, + HostBinding, + HostListener, + Input, + Output, + booleanAttribute, + inject, + AfterViewInit, +} from '@angular/core'; +import { PlatformUtil } from 'igniteui-angular/core'; + +export const IgxBaseButtonType = { + Flat: 'flat', + Contained: 'contained', + Outlined: 'outlined' +} as const; + + +@Directive() +export abstract class IgxButtonBaseDirective implements AfterViewInit{ + private _platformUtil = inject(PlatformUtil); + public element = inject(ElementRef); + private _viewInit = false; + + /** + * Emitted when the button is clicked. + */ + @Output() + public buttonClick = new EventEmitter(); + + /** + * Sets/gets the `role` attribute. + * + * @example + * ```typescript + * this.button.role = 'navbutton'; + * let buttonRole = this.button.role; + * ``` + */ + @HostBinding('attr.role') + public role = 'button'; + + /** + * @hidden + * @internal + */ + @HostListener('click', ['$event']) + public onClick(ev: MouseEvent) { + this.buttonClick.emit(ev); + this.focused = false; + } + + /** + * @hidden + * @internal + */ + @HostListener('blur') + protected onBlur() { + this.focused = false; + } + + /** + * Sets/gets whether the button component is on focus. + * Default value is `false`. + * ```typescript + * this.button.focus = true; + * ``` + * ```typescript + * let isFocused = this.button.focused; + * ``` + */ + @HostBinding('class.igx-button--focused') + protected focused = false; + + /** + * Enables/disables the button. + * + * @example + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + @HostBinding('class.igx-button--disabled') + public disabled = false; + + /** + * @hidden + * @internal + */ + @HostBinding('attr.disabled') + public get disabledAttribute() { + return this.disabled || null; + } + + protected constructor() { + // In browser, set via native API for immediate effect (no-op on server). + // In SSR there is no paint, so there’s no visual rendering or transitions to suppress. + // Fix style flickering https://github.com/IgniteUI/igniteui-angular/issues/14759 + if (this._platformUtil.isBrowser) { + this.element.nativeElement.style.setProperty('--_init-transition', '0s'); + } + } + + public ngAfterViewInit(): void { + if (this._platformUtil.isBrowser && !this._viewInit) { + this._viewInit = true; + + requestAnimationFrame(() => { + this.element.nativeElement.style.removeProperty('--_init-transition'); + }); + } + } + + /** + * @hidden + * @internal + */ + @HostListener('keyup', ['$event']) + protected updateOnKeyUp(event: KeyboardEvent) { + if (event.key === "Tab") { + this.focused = true; + } + } + + /** + * Returns the underlying DOM element. + */ + public get nativeElement() { + return this.element.nativeElement; + } +} diff --git a/projects/igniteui-angular/directives/src/directives/button/button.directive.spec.ts b/projects/igniteui-angular/directives/src/directives/button/button.directive.spec.ts new file mode 100644 index 00000000000..bfceddb34f1 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/button/button.directive.spec.ts @@ -0,0 +1,121 @@ +import { Component, ViewChild } from '@angular/core'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { IgxButtonDirective } from './button.directive'; + +import { IgxRippleDirective } from '../ripple/ripple.directive'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +const BUTTON_COMFORTABLE = 'igx-button'; + +describe('IgxButton', () => { + + const baseClass = BUTTON_COMFORTABLE; + const classes = { + flat: `${baseClass}--flat`, + contained: `${baseClass}--contained`, + outlined: `${baseClass}--outlined`, + fab: `${baseClass}--fab`, + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + InitButtonComponent, + ButtonWithAttribsComponent + ] + }).compileComponents(); + })); + + it('Initializes a button', () => { + const fixture = TestBed.createComponent(InitButtonComponent); + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.css('span.igx-button--flat'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('i.material-icons'))).toBeTruthy(); + }); + + it('Button with properties', () => { + const fixture = TestBed.createComponent(ButtonWithAttribsComponent); + fixture.detectChanges(); + + const button = fixture.debugElement.query(By.css('span')).nativeElement; + + expect(button).toBeTruthy(); + expect(button.classList.contains('igx-button--contained')).toBe(true); + expect(button.classList.contains('igx-button--disabled')).toBe(true); + + fixture.componentInstance.disabled = false; + fixture.detectChanges(); + + expect(button.classList.contains('igx-button--disabled')).toBe(false); + + fixture.detectChanges(); + }); + + it('Should set the correct CSS class on the element using the "type" input', () => { + const fixture = TestBed.createComponent(InitButtonComponent); + fixture.detectChanges(); + const theButton = fixture.componentInstance.button; + const theButtonNativeEl = theButton.nativeElement; + expect(theButtonNativeEl.classList.length).toEqual(2); + expect(theButtonNativeEl.classList).toContain(classes.flat); + + theButton.type = 'contained'; + fixture.detectChanges(); + expect(theButtonNativeEl.classList.length).toEqual(2); + expect(theButtonNativeEl.classList).toContain(classes.contained); + + theButton.type = 'outlined'; + fixture.detectChanges(); + expect(theButtonNativeEl.classList.length).toEqual(2); + expect(theButtonNativeEl.classList).toContain(classes.outlined); + + theButton.type = 'fab'; + fixture.detectChanges(); + expect(theButtonNativeEl.classList.length).toEqual(2); + expect(theButtonNativeEl.classList).toContain(classes.fab); + + theButton.type = 'flat'; + fixture.detectChanges(); + expect(theButtonNativeEl.classList.length).toEqual(2); + expect(theButtonNativeEl.classList).toContain(classes.flat); + }); + + it('Should emit the buttonSelected event only on user interaction, not on initialization', () => { + const fixture = TestBed.createComponent(InitButtonComponent); + fixture.detectChanges(); + const button = fixture.componentInstance.button; + spyOn(button.buttonSelected, 'emit'); + + expect(button.buttonSelected.emit).not.toHaveBeenCalled(); + + button.nativeElement.click(); + fixture.detectChanges(); + expect(button.buttonSelected.emit).toHaveBeenCalledTimes(1); + + button.nativeElement.click(); + fixture.detectChanges(); + expect(button.buttonSelected.emit).toHaveBeenCalledTimes(2); + }); +}); + +@Component({ + template: ` + add + `, + imports: [IgxButtonDirective, IgxRippleDirective] +}) +class InitButtonComponent { + @ViewChild(IgxButtonDirective, { read: IgxButtonDirective, static: true }) + public button: IgxButtonDirective; +} + +@Component({ + template: `Test`, + imports: [IgxButtonDirective] +}) +class ButtonWithAttribsComponent { + public disabled = true; +} diff --git a/projects/igniteui-angular/directives/src/directives/button/button.directive.ts b/projects/igniteui-angular/directives/src/directives/button/button.directive.ts new file mode 100644 index 00000000000..15714511be0 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/button/button.directive.ts @@ -0,0 +1,214 @@ +import { + Directive, + EventEmitter, + HostBinding, + HostListener, + Input, + Output, + Renderer2, + booleanAttribute, + inject +} from '@angular/core'; +import { IBaseEventArgs } from 'igniteui-angular/core'; +import { IgxBaseButtonType, IgxButtonBaseDirective } from './button-base'; + +const IgxButtonType = { + ...IgxBaseButtonType, + FAB: 'fab' +} as const; + +/** + * Determines the Button type. + */ +export type IgxButtonType = typeof IgxButtonType[keyof typeof IgxButtonType]; + +/** + * The Button directive provides the Ignite UI Button functionality to every component that's intended to be used as a button. + * + * @igxModule IgxButtonModule + * + * @igxParent Data Entry & Display + * + * @igxTheme igx-button-theme + * + * @igxKeywords button, span, div, click + * + * @remarks + * The Ignite UI Button directive is intended to be used by any button, span or div and turn it into a fully functional button. + * + * @example + * ```html + * + * ``` + */ +@Directive({ + selector: '[igxButton]', + standalone: true +}) +export class IgxButtonDirective extends IgxButtonBaseDirective { + private _renderer = inject(Renderer2); + + private static ngAcceptInputType_type: IgxButtonType | ''; + + /** + * Called when the button is selected. + */ + @Output() + public buttonSelected = new EventEmitter(); + + /** + * @hidden + * @internal + */ + @HostBinding('class.igx-button') + public _cssClass = 'igx-button'; + + /** + * @hidden + * @internal + */ + private _type: IgxButtonType; + + /** + * @hidden + * @internal + */ + private _color: string; + + /** + * @hidden + * @internal + */ + private _label: string; + + /** + * @hidden + * @internal + */ + private _backgroundColor: string; + + /** + * @hidden + * @internal + */ + private _selected = false; + + @HostListener('click') + protected emitSelected() { + this.buttonSelected.emit({ + button: this + }); + } + + /** + * Gets or sets whether the button is selected. + * Mainly used in the IgxButtonGroup component and it will have no effect if set separately. + * + * @example + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public set selected(value: boolean) { + if (this._selected !== value) { + this._selected = value; + this._renderer.setAttribute(this.nativeElement, 'data-selected', value.toString()); + } + } + + public get selected(): boolean { + return this._selected; + } + + constructor() { + super(); + } + + /** + * Sets the type of the button. + * + * @example + * ```html + * + * ``` + */ + @Input('igxButton') + public set type(type: IgxButtonType) { + const t = type ? type : IgxButtonType.Flat; + if (this._type !== t) { + this._type = t; + } + } + + /** + * Sets the `aria-label` attribute. + * + * @example + * ```html + * + * ``` + */ + @Input('igxLabel') + public set label(value: string) { + this._label = value || this._label; + this._renderer.setAttribute(this.nativeElement, 'aria-label', this._label); + } + + /** + * @hidden + * @internal + */ + @HostBinding('class.igx-button--flat') + public get flat(): boolean { + return this._type === IgxButtonType.Flat; + } + + /** + * @hidden + * @internal + */ + @HostBinding('class.igx-button--contained') + public get contained(): boolean { + return this._type === IgxButtonType.Contained; + } + + /** + * @hidden + * @internal + */ + @HostBinding('class.igx-button--outlined') + public get outlined(): boolean { + return this._type === IgxButtonType.Outlined; + } + + /** + * @hidden + * @internal + */ + @HostBinding('class.igx-button--fab') + public get fab(): boolean { + return this._type === IgxButtonType.FAB; + } + + /** + * @hidden + * @internal + */ + public select() { + this.selected = true; + } + + /** + * @hidden + * @internal + */ + public deselect() { + this.selected = false; + this.focused = false; + } +} + +export interface IButtonEventArgs extends IBaseEventArgs { + button: IgxButtonDirective; +} diff --git a/projects/igniteui-angular/directives/src/directives/button/button.module.ts b/projects/igniteui-angular/directives/src/directives/button/button.module.ts new file mode 100644 index 00000000000..5326d441b3d --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/button/button.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from '@angular/core'; +import { IgxButtonDirective } from './button.directive'; +import { IgxIconButtonDirective } from './icon-button.directive'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [IgxButtonDirective, IgxIconButtonDirective], + exports: [IgxButtonDirective, IgxIconButtonDirective] +}) +export class IgxButtonModule {} diff --git a/projects/igniteui-angular/directives/src/directives/button/icon-button.directive.spec.ts b/projects/igniteui-angular/directives/src/directives/button/icon-button.directive.spec.ts new file mode 100644 index 00000000000..bcdf76e8910 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/button/icon-button.directive.spec.ts @@ -0,0 +1,88 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Component, ViewChild } from '@angular/core'; +import { IgxIconButtonDirective } from './icon-button.directive'; +import { IgxRippleDirective } from '../ripple/ripple.directive'; +import { By } from '@angular/platform-browser'; +import { IgxIconComponent } from '../../../../icon/src/icon/icon.component'; + +describe('IgxIconButton', () => { + + const baseClass = 'igx-icon-button'; + const classes = { + flat: `${baseClass}--flat`, + contained: `${baseClass}--contained`, + outlined: `${baseClass}--outlined`, + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IconButtonComponent + ] + }).compileComponents(); + })); + + it('Should properly initialize an icon button', () => { + const fixture = TestBed.createComponent(IconButtonComponent); + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.css('button.igx-icon-button--contained'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('igx-icon.material-icons'))).toBeTruthy(); + }); + + it('Should properly disabled/enable an icon button', () => { + const fixture = TestBed.createComponent(IconButtonComponent); + fixture.detectChanges(); + + const button = fixture.componentInstance.button; + expect(button.nativeElement.classList.contains('igx-button--disabled')).toBe(false); + + button.disabled = true; + fixture.detectChanges(); + + expect(button.nativeElement.classList.contains('igx-button--disabled')).toBe(true); + + button.disabled = false; + fixture.detectChanges(); + + expect(button.nativeElement.classList.contains('igx-button--disabled')).toBe(false); + }); + + it('Should properly set the correct CSS class on the element using the type input', () => { + const fixture = TestBed.createComponent(IconButtonComponent); + fixture.detectChanges(); + + const button = fixture.componentInstance.button; + const buttonNativeEl = button.nativeElement; + expect(buttonNativeEl.classList.length).toEqual(2); + expect(buttonNativeEl.classList).toContain(classes.contained); + + button.type = 'flat'; + fixture.detectChanges(); + expect(buttonNativeEl.classList.length).toEqual(2); + expect(buttonNativeEl.classList).toContain(classes.flat); + + button.type = 'outlined'; + fixture.detectChanges(); + expect(buttonNativeEl.classList.length).toEqual(2); + expect(buttonNativeEl.classList).toContain(classes.outlined); + + button.type = 'contained'; + fixture.detectChanges(); + expect(buttonNativeEl.classList.length).toEqual(2); + expect(buttonNativeEl.classList).toContain(classes.contained); + }); +}); + +@Component({ + template: ``, + imports: [IgxIconButtonDirective, IgxRippleDirective, IgxIconComponent] +}) +class IconButtonComponent { + @ViewChild(IgxIconButtonDirective, { read: IgxIconButtonDirective, static: true }) + public button: IgxIconButtonDirective; +} diff --git a/projects/igniteui-angular/directives/src/directives/button/icon-button.directive.ts b/projects/igniteui-angular/directives/src/directives/button/icon-button.directive.ts new file mode 100644 index 00000000000..7cad2ca3ae8 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/button/icon-button.directive.ts @@ -0,0 +1,85 @@ +import {Directive, HostBinding, Input} from '@angular/core'; +import { IgxBaseButtonType, IgxButtonBaseDirective } from './button-base'; + +/** + * Determines the Icon Button type. + */ +export type IgxIconButtonType = typeof IgxBaseButtonType[keyof typeof IgxBaseButtonType]; + +/** + * The IgxIconButtonDirective provides a way to use an icon as a fully functional button. + * + * @example + * ```html + * + * ``` + */ +@Directive({ + selector: '[igxIconButton]', + standalone: true +}) +export class IgxIconButtonDirective extends IgxButtonBaseDirective { + private static ngAcceptInputType_type: IgxIconButtonType | ''; + + constructor() { + super(); + } + + /** + * @hidden + * @internal + */ + @HostBinding('class.igx-icon-button') + protected _cssClass = 'igx-icon-button'; + + /** + * @hidden + * @internal + */ + private _type: IgxIconButtonType; + + /** + * Sets the type of the icon button. + * + * @example + * ```html + * + * ``` + */ + @Input('igxIconButton') + public set type(type: IgxIconButtonType) { + const t = type ? type : IgxBaseButtonType.Contained; + if (this._type !== t) { + this._type = t; + } + } + + /** + * @hidden + * @internal + */ + @HostBinding('class.igx-icon-button--flat') + public get flat(): boolean { + return this._type === IgxBaseButtonType.Flat; + } + + /** + * @hidden + * @internal + */ + @HostBinding('class.igx-icon-button--contained') + public get contained(): boolean { + return this._type === IgxBaseButtonType.Contained; + } + + /** + * @hidden + * @internal + */ + @HostBinding('class.igx-icon-button--outlined') + public get outlined(): boolean { + return this._type === IgxBaseButtonType.Outlined; + } +} diff --git a/projects/igniteui-angular/directives/src/directives/checkbox/checkbox-base.directive.ts b/projects/igniteui-angular/directives/src/directives/checkbox/checkbox-base.directive.ts new file mode 100644 index 00000000000..e37e78cc1ba --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/checkbox/checkbox-base.directive.ts @@ -0,0 +1,493 @@ +import { Directive, EventEmitter, HostListener, HostBinding, Input, Output, ViewChild, ElementRef, ChangeDetectorRef, booleanAttribute, inject, DestroyRef, AfterViewInit } from '@angular/core'; +import { NgControl, Validators } from '@angular/forms'; +import { IBaseEventArgs, getComponentTheme } from 'igniteui-angular/core'; +import { noop, Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { + IgxTheme, + THEME_TOKEN, + ThemeToken, +} from 'igniteui-angular/core'; + +export const LabelPosition = { + BEFORE: 'before', + AFTER: 'after' +} as const; +export type LabelPosition = typeof LabelPosition[keyof typeof LabelPosition]; + +export interface IChangeCheckboxEventArgs extends IBaseEventArgs { + checked: boolean; + value?: any; +} + +let nextId = 0; + +@Directive() +export class CheckboxBaseDirective implements AfterViewInit { + protected cdr = inject(ChangeDetectorRef); + protected themeToken = inject(THEME_TOKEN); + public ngControl = inject(NgControl, { optional: true, self: true }); + + /** + * An event that is emitted after the checkbox state is changed. + * Provides references to the `IgxCheckboxComponent` and the `checked` property as event arguments. + */ + // eslint-disable-next-line @angular-eslint/no-output-native + @Output() public readonly change: EventEmitter = + new EventEmitter(); + + /** + * @hidden + * @internal + */ + public destroy$ = new Subject(); + + /** + * Returns reference to the native checkbox element. + * + * @example + * ```typescript + * let checkboxElement = this.component.checkboxElement; + * ``` + */ + @ViewChild('checkbox', { static: true }) + public nativeInput: ElementRef; + + /** + * Returns reference to the native label element. + * ```typescript + * + * @example + * let labelElement = this.component.nativeLabel; + * ``` + */ + @ViewChild('label', { static: true }) + public nativeLabel: ElementRef; + + public cssClass: string; + public disabled: boolean; + public readonly: boolean; + public indeterminate: boolean; + public focused: boolean; + public invalid: boolean; + + @Input({ transform: booleanAttribute }) + public get checked() { + return this._checked; + } + + public set checked(value: boolean) { + if (this._checked !== value) { + this._checked = value; + this._onChangeCallback(this._checked); + } + } + + /** + * Returns reference to the `nativeElement` of the igx-checkbox/igx-switch. + * + * @example + * ```typescript + * let nativeElement = this.component.nativeElement; + * ``` + */ + public get nativeElement() { + return this.nativeInput.nativeElement; + } + + /** + * Returns reference to the label placeholder element. + * ```typescript + * + * @example + * let labelPlaceholder = this.component.placeholderLabel; + * ``` + */ + @ViewChild('placeholderLabel', { static: true }) + public placeholderLabel: ElementRef; + + /** + * Sets/gets the `id` of the checkbox component. + * If not set, the `id` of the first checkbox component will be `"igx-checkbox-0"`. + * + * @example + * ```html + * + * ``` + * ```typescript + * let checkboxId = this.checkbox.id; + * ``` + */ + @HostBinding('attr.id') + @Input() + public id = `igx-checkbox-${nextId++}`; + + /** + * Sets/gets the id of the `label` element. + * If not set, the id of the `label` in the first checkbox component will be `"igx-checkbox-0-label"`. + * + * @example + * ```html + * + * ``` + * ```typescript + * let labelId = this.component.labelId; + * ``` + */ + @Input() public labelId = `${this.id}-label`; + + /** + * Sets/gets the `value` attribute. + * + * @example + * ```html + * + * ``` + * ```typescript + * let value = this.checkbox.value; + * ``` + */ + @Input() public value: any; + + /** + * Sets/gets the `name` attribute. + * + * @example + * ```html + * + * ``` + * ```typescript + * let name = this.checkbox.name; + * ``` + */ + @Input() public name: string; + + /** + * Sets/gets the value of the `tabindex` attribute. + * + * @example + * ```html + * + * ``` + * ```typescript + * let tabIndex = this.checkbox.tabindex; + * ``` + */ + @Input() public tabindex: number = null; + + /** + * Sets/gets the position of the `label`. + * If not set, the `labelPosition` will have value `"after"`. + * + * @example + * ```html + * + * ``` + * ```typescript + * let labelPosition = this.checkbox.labelPosition; + * ``` + */ + @Input() + public labelPosition: LabelPosition | string = LabelPosition.AFTER; + + /** + * Enables/Disables the ripple effect. + * If not set, `disableRipple` will have value `false`. + * + * @example + * ```html + * + * ``` + * ```typescript + * let isRippleDisabled = this.checkbox.desableRipple; + * ``` + */ + @Input({ transform: booleanAttribute }) + public disableRipple = false; + + /** + * Sets/gets the `aria-labelledby` attribute. + * If not set, the `aria-labelledby` will be equal to the value of `labelId` attribute. + * + * @example + * ```html + * + * ``` + * ```typescript + * let ariaLabelledBy = this.checkbox.ariaLabelledBy; + * ``` + */ + @Input('aria-labelledby') + public ariaLabelledBy = this.labelId; + + /** + * Sets/gets the value of the `aria-label` attribute. + * + * @example + * ```html + * + * ``` + * ```typescript + * let ariaLabel = this.checkbox.ariaLabel; + * ``` + */ + @Input('aria-label') + public ariaLabel: string | null = null; + + constructor() { + if (this.ngControl !== null) { + this.ngControl.valueAccessor = this; + } + + this.theme = this.themeToken.theme; + + const themeChange = this.themeToken.onChange((theme) => { + if (this.theme !== theme) { + this.theme = theme; + this.cdr.detectChanges(); + } + }); + + this.destroyRef.onDestroy(() => themeChange.unsubscribe()); + } + + /** + * Sets/gets whether the checkbox is required. + * If not set, `required` will have value `false`. + * + * @example + * ```html + * + * ``` + * ```typescript + * let isRequired = this.checkbox.required; + * ``` + */ + @Input({ transform: booleanAttribute }) + public get required(): boolean { + return this._required || this.nativeElement.hasAttribute('required'); + } + public set required(value: boolean) { + if (!value) { + this.nativeElement.removeAttribute('required'); + } + this._required = value; + } + + /** + * @hidden + * @internal + */ + public ngAfterViewInit() { + if (this.ngControl) { + this.ngControl.statusChanges + .pipe(takeUntil(this.destroy$)) + .subscribe(this.updateValidityState.bind(this)); + + if ( + this.ngControl.control.validator || + this.ngControl.control.asyncValidator + ) { + this._required = this.ngControl?.control?.hasValidator( + Validators.required + ); + this.cdr.detectChanges(); + } + } + + this.setComponentTheme(); + } + + /** + * @hidden + * @internal + */ + public inputId = `${this.id}-input`; + + /** + * @hidden + */ + protected _onChangeCallback: (_: any) => void = noop; + + /** + * @hidden + */ + private _onTouchedCallback: () => void = noop; + + /** + * @hidden + * @internal + */ + protected _checked = false; + + /** + * @hidden + * @internal + */ + protected theme: IgxTheme; + + /** + * @hidden + * @internal + */ + public _required = false; + private elRef = inject(ElementRef); + protected destroyRef = inject(DestroyRef); + + private setComponentTheme() { + if (!this.themeToken.preferToken) { + const theme = getComponentTheme(this.elRef.nativeElement); + + if (theme && theme !== this.theme) { + this.theme = theme; + this.cdr.markForCheck(); + } + } + } + + /** @hidden @internal */ + @HostListener('keyup', ['$event']) + public onKeyUp(event: KeyboardEvent) { + event.stopPropagation(); + this.focused = true; + } + + /** @hidden @internal */ + @HostListener('click', ['$event']) + public _onCheckboxClick(event: PointerEvent | MouseEvent) { + // Since the original checkbox is hidden and the label + // is used for styling and to change the checked state of the checkbox, + // we need to prevent the checkbox click event from bubbling up + // as it gets triggered on label click + // NOTE: The above is no longer valid, as the native checkbox is not labeled + // by the SVG anymore. + if (this.disabled || this.readonly) { + // readonly prevents the component from changing state (see toggle() method). + // However, the native checkbox can still be activated through user interaction (focus + space, label click) + // Prevent the native change so the input remains in sync + event.preventDefault(); + return; + } + + this.nativeElement.focus(); + + this.indeterminate = false; + this.checked = !this.checked; + this.updateValidityState(); + + // K.D. March 23, 2021 Emitting on click and not on the setter because otherwise every component + // bound on change would have to perform self checks for weather the value has changed because + // of the initial set on initialization + this.change.emit({ + checked: this.checked, + value: this.value, + owner: this, + }); + } + + /** + * @hidden + * @internal + */ + public get ariaChecked() { + if (this.indeterminate) { + return 'mixed'; + } else { + return this.checked; + } + } + + /** @hidden @internal */ + public _onCheckboxChange(event: Event) { + // We have to stop the original checkbox change event + // from bubbling up since we emit our own change event + event.stopPropagation(); + } + + /** @hidden @internal */ + @HostListener('blur') + public onBlur() { + this.focused = false; + this._onTouchedCallback(); + this.updateValidityState(); + } + + /** @hidden @internal */ + public writeValue(value: boolean) { + this._checked = value; + } + + /** @hidden @internal */ + public get labelClass(): string { + switch (this.labelPosition) { + case LabelPosition.BEFORE: + return `${this.cssClass}__label--before`; + case LabelPosition.AFTER: + default: + return `${this.cssClass}__label`; + } + } + + /** @hidden @internal */ + public registerOnChange(fn: (_: any) => void) { + this._onChangeCallback = fn; + } + + /** @hidden @internal */ + public registerOnTouched(fn: () => void) { + this._onTouchedCallback = fn; + } + + /** @hidden @internal */ + public setDisabledState(isDisabled: boolean) { + this.disabled = isDisabled; + } + + /** @hidden @internal */ + public getEditElement() { + return this.nativeInput.nativeElement; + } + + /** + * @hidden + * @internal + */ + protected updateValidityState() { + if (this.ngControl) { + if ( + !this.disabled && + !this.readonly && + (this.ngControl.control.touched || this.ngControl.control.dirty) + ) { + // the control is not disabled and is touched or dirty + this.invalid = this.ngControl.invalid; + } else { + // if the control is untouched, pristine, or disabled, its state is initial. This is when the user did not interact + // with the checkbox or when the form/control is reset + this.invalid = false; + } + } else { + this.checkNativeValidity(); + } + } + + /** + * A function to assign a native validity property of a checkbox. + * This should be used when there's no ngControl + * + * @hidden + * @internal + */ + private checkNativeValidity() { + if ( + !this.disabled && + this._required && + !this.checked && + !this.readonly + ) { + this.invalid = true; + } else { + this.invalid = false; + } + } +} diff --git a/projects/igniteui-angular/directives/src/directives/date-time-editor/README.md b/projects/igniteui-angular/directives/src/directives/date-time-editor/README.md new file mode 100644 index 00000000000..4993c98b488 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/date-time-editor/README.md @@ -0,0 +1,118 @@ +# igxDateTimeEditor Directive + +The `igxDateTimeEditor` allows the user to set and edit date and time in a chosen input element. The user can edit date or time portion, using an editable masked input. Additionally, can specify a desired display and input format, as well as min and max values to help validation. + +## Usage +Import the `IgxDateTimeEditorModule` in the module that you want to use it in: + +```typescript +... +import { IgxDateTimeEditorModule } from 'igniteui-angular'; + +@NgModule({ + ... + imports: [..., IgxDateTimeEditorModule ], + ... +}) +export class AppModule {} +``` + +To use an input as a date time editor, set an `igxDateTimeEditor` directive and a valid date object as value. In order to have complete editor look and feel, wrap the input in an `input group`. This will allow you to not only take advantage of the following directives `igxInput`, `igxLabel`, `igx-prefix`, `igx-suffix`, `igx-hint`, but cover common scenarios when dealing with form inputs as well. + +### Binding +A basic configuration scenario setting a Date object as a `value`: +```typescript +public date = new Date(); +``` + +```html + + + +``` + +To create a two-way data-binding, set an ngModel: +```html + + + +``` + +### Features +#### Date Format +To set specific display and input format. +```html + + + +``` + +#### Min and Max Value +You can specify `minValue` and `maxValue` properties to restrict input and control the validity of the ngModel. +```typescript +public minDate = new Date(2020, 1, 15); +public maxDate = new Date(2020, 12, 1); +``` +```html + + + + +``` + +#### Increment and Decrement +`igxDateTimeEditor` directive exposes public `increment` and `decrement` methods, that increment or decrement a specific `DatePart` of the currently set date and time. +```typescript +public date = new Date(); +public datePart: typeof DatePart = DatePart; +``` +```html + + + + keyboard_arrow_up + keyboard_arrow_down + + +``` + +#### Keyboard Navigation +`igxDateTimeEditor` directive has intuitive keyboard navigation that makes it easy to jump through different `DateParts`, increment, decrement, etc. without having to touch the mouse. + +| Key combination | Effect | +|--|--| +| `Left Arrow` | Move one character to the left. | +| `Right Arrow` | Move one character to the left. | +| `Home` | Move to the beginning. | +| `End` | Move to the end. | +| `CTRL/COMMAND` + `Left Arrow` | Move to the beginning of the date/time section - current one or left one. | +| `CTRL/COMMAND` + `Right Arrow` | Move to the end of the date/time section - current on or right one. | +| `Down Arrow` | On a date/time section should decrement that part of the edited date. | +| `Up Arrow` | On a date/time section should increment that part of the edited date. | +| `CTRL/COMMAND` + `;` | Sets the current date and time as the value of the editor. | + +### API +| Name | Type | Description | +|:-----|:----|:------------| +| `value` | Date \| string | The value of the editor. | +| `displayFormat` | string | The display value of the editor. | +| `inputFormat` | string | The format that the editor will use to display the date/time. | +| `minValue` | Date \| string | Sets the minimum value required for the editor to remain valid. | +| `maxValue` | Date \| string | Sets the maximum value required for the editor to remain valid. | +| `spinLoop` | boolean | Loop over the currently spun segment. | +| `spinDelta` | DatePartDeltas | Delta values used to increment or decrement each editor date part on spin actions. All values default to `1`. +| `promptChar` | string | Defines the empty characters in the mask. | +| `locale` | string | Locale settings used in displayFormat. | + +#### Methods +| Name | Type | Description | +|:-----|:----|:------------| +| `clear` | void | Clears the input element of user input. | +| `increment` | void | Increments default OR specified time portion. | +| `decrement` | void | Decrements default OR specified time portion. | + +#### Events +| Name | Type | Description | +|:-----|:----|:------------| +| `valueChanged` | custom | Fired when the editor's value has changed. | +| `validationFailed` | custom | Fired when the editor is not within a specified range. Can revert back to a previously valid state by changing the `newValue` property of the `args` parameter. | diff --git a/projects/igniteui-angular/directives/src/directives/date-time-editor/date-time-editor.common.ts b/projects/igniteui-angular/directives/src/directives/date-time-editor/date-time-editor.common.ts new file mode 100644 index 00000000000..0ac542e07b1 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/date-time-editor/date-time-editor.common.ts @@ -0,0 +1,5 @@ +export interface IgxDateTimeEditorEventArgs { + readonly oldValue?: Date; + newValue?: Date; + readonly userInput: string; +} diff --git a/projects/igniteui-angular/directives/src/directives/date-time-editor/date-time-editor.directive.spec.ts b/projects/igniteui-angular/directives/src/directives/date-time-editor/date-time-editor.directive.spec.ts new file mode 100644 index 00000000000..fb4daf1ea45 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/date-time-editor/date-time-editor.directive.spec.ts @@ -0,0 +1,1481 @@ +import { IgxDateTimeEditorDirective } from './date-time-editor.directive'; +import { formatDate, registerLocaleData } from '@angular/common'; +import { Component, ViewChild, DebugElement, EventEmitter, Output, SimpleChange, SimpleChanges, DOCUMENT, inject, Renderer2, ElementRef } from '@angular/core'; +import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { FormsModule, UntypedFormGroup, UntypedFormBuilder, ReactiveFormsModule, Validators, NgControl } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxInputGroupComponent, IgxInputDirective } from '../../../../input-group/src/public_api'; +import { ControlsFunction } from '../../../../test-utils/controls-functions.spec'; +import { UIInteractions } from '../../../../test-utils/ui-interactions.spec'; +import { ViewEncapsulation } from '@angular/core'; +import localeJa from "@angular/common/locales/ja"; +import localeBg from "@angular/common/locales/bg"; +import { DatePart } from 'igniteui-angular/core'; +import { MaskParsingService } from '../mask/mask-parsing.service'; + +describe('IgxDateTimeEditor', () => { + let dateTimeEditor: IgxDateTimeEditorDirective; + describe('Unit tests', () => { + let maskParsingService: jasmine.SpyObj; + let renderer2: jasmine.SpyObj; + let locale = 'en'; + let elementRef: ElementRef; + let inputFormat: string; + let displayFormat: string; + let inputDate: string; + const initializeDateTimeEditor = (_control?: NgControl) => { + // const injector = { get: () => control }; + dateTimeEditor = TestBed.inject(IgxDateTimeEditorDirective); + dateTimeEditor.inputFormat = inputFormat; + dateTimeEditor.ngOnInit(); + + const change: SimpleChange = new SimpleChange(undefined, inputFormat, true); + const changes: SimpleChanges = { inputFormat: change }; + dateTimeEditor.ngOnChanges(changes); + }; + + beforeEach(() => { + const mockNativeEl = document.createElement("div"); + (mockNativeEl as any).setSelectionRange = () => {}; + maskParsingService = jasmine.createSpyObj('MaskParsingService', + ['parseMask', 'restoreValueFromMask', 'parseMaskValue', 'applyMask', 'parseValueFromMask']); + renderer2 = jasmine.createSpyObj('Renderer2', ['setAttribute']); + elementRef = { nativeElement: mockNativeEl }; + + TestBed.configureTestingModule({ + providers: [ + { provide: MaskParsingService, useValue: maskParsingService }, + { provide: Renderer2, useValue: renderer2 }, + { provide: ElementRef, useValue: elementRef }, + { provide: DOCUMENT, useValue: document }, + { provide: NgControl, useValue: null }, + IgxDateTimeEditorDirective, + ] + }); + }); + describe('Properties & Events', () => { + it('should emit valueChange event on clear()', () => { + inputFormat = 'dd/M/yy'; + inputDate = '6/6/2000'; + elementRef = { nativeElement: { value: inputDate, setSelectionRange: () => { } } }; + initializeDateTimeEditor(); + + const date = new Date(2000, 5, 6); + dateTimeEditor.value = date; + spyOn(dateTimeEditor.valueChange, 'emit'); + spyOnProperty((dateTimeEditor as any), 'inputValue', 'get').and.returnValue(inputDate); + + dateTimeEditor.clear(); + expect(dateTimeEditor.value).toBeNull(); + expect(dateTimeEditor.valueChange.emit).toHaveBeenCalledTimes(1); + expect(dateTimeEditor.valueChange.emit).toHaveBeenCalledWith(null); + }); + + it('should update mask according to the input format', () => { + inputFormat = 'd/M/yy'; + inputDate = ''; + elementRef = { nativeElement: { value: inputDate } }; + initializeDateTimeEditor(); + + dateTimeEditor.inputFormat = inputFormat; + expect(dateTimeEditor.mask).toEqual('00/00/00'); + + dateTimeEditor.inputFormat = 'dd-MM-yyyy HH:mm:ss:SS'; + expect(dateTimeEditor.mask).toEqual('00-00-0000 00:00:00:000'); + + dateTimeEditor.inputFormat = 'H:m:s:S'; + expect(dateTimeEditor.mask).toEqual('00:00:00:000'); + }); + + it('should set default inputFormat with parts for day, month, year based on locale', () => { + registerLocaleData(localeBg); + registerLocaleData(localeJa); + locale = 'en-US'; + inputFormat = undefined; + elementRef = { nativeElement: { value: inputDate } }; + initializeDateTimeEditor(); + + expect(dateTimeEditor.locale).toEqual('en-US'); + expect(dateTimeEditor.inputFormat.normalize('NFKC')).toEqual('MM/dd/yyyy'); + + dateTimeEditor.locale = 'bg-BG'; + let change: SimpleChange = new SimpleChange('en-US', 'bg-BG', false); + let changes: SimpleChanges = { locale: change }; + dateTimeEditor.ngOnChanges(changes); + expect(dateTimeEditor.inputFormat.normalize('NFKC')).toEqual('dd.MM.yyyy г.'); + + dateTimeEditor.locale = 'ja-JP'; + change = new SimpleChange('bg-BG', 'ja-JP', false); + changes = { locale: change }; + dateTimeEditor.ngOnChanges(changes); + expect(dateTimeEditor.inputFormat).toEqual('yyyy/MM/dd'); + }); + + it('should resolve inputFormat, if not set, to the value of displayFormat if it contains only numeric date/time parts', () => { + inputFormat = undefined; + displayFormat = 'shortDate'; + elementRef = { nativeElement: { value: inputDate } }; + initializeDateTimeEditor(); + + dateTimeEditor.displayFormat = displayFormat; + const change: SimpleChange = new SimpleChange(undefined, displayFormat, false); + const changes: SimpleChanges = { displayFormat: change }; + dateTimeEditor.ngOnChanges(changes); + + expect(dateTimeEditor.inputFormat).toEqual('MM/dd/yyyy'); + }); + + it('should resolve to the default locale-based input format in case inputFormat is not set and displayFormat contains non-numeric date/time parts', () => { + registerLocaleData(localeBg); + locale = 'en-US'; + displayFormat = undefined; + elementRef = { nativeElement: { value: inputDate } }; + initializeDateTimeEditor(); + + expect(dateTimeEditor.locale).toEqual('en-US'); + + let oldDisplayFormat = inputFormat; + displayFormat = 'MMM d, y, h:mm:ss a'; + dateTimeEditor.displayFormat = displayFormat; + let change: SimpleChange = new SimpleChange(oldDisplayFormat, displayFormat, false); + let changes: SimpleChanges = { displayFormat: change }; + dateTimeEditor.ngOnChanges(changes); + + expect(dateTimeEditor.inputFormat.normalize('NFKC')).toEqual('MM/dd/yyyy'); + + oldDisplayFormat = displayFormat; + displayFormat = 'full'; + dateTimeEditor.locale = 'bg-BG'; + change = new SimpleChange('en-US', 'bg-BG', false); + const changeInputFormat = new SimpleChange(oldDisplayFormat, displayFormat, false); + changes = { locale: change, displayFormat: changeInputFormat }; + dateTimeEditor.ngOnChanges(changes); + + expect(dateTimeEditor.displayFormat.normalize('NFKC')).toEqual('MMM d, y, h:mm:ss a'); + expect(dateTimeEditor.inputFormat.normalize('NFKC')).toEqual('dd.MM.yyyy г.'); + }); + + it('should set the default input format as per the defaultFormatType property', () => { + inputFormat = undefined; + displayFormat = undefined; + elementRef = { nativeElement: { value: inputDate } }; + initializeDateTimeEditor(); + + expect(dateTimeEditor.defaultFormatType).toEqual('date'); + expect(dateTimeEditor.inputFormat.normalize('NFKC')).toEqual('MM/dd/yyyy'); + + dateTimeEditor.defaultFormatType = 'dateTime'; + let change: SimpleChange = new SimpleChange('date', 'dateTime', false); + let changes: SimpleChanges = { defaultFormatType: change }; + dateTimeEditor.ngOnChanges(changes); + expect(dateTimeEditor.inputFormat.normalize('NFKC')).toEqual('MM/dd/yyyy, hh:mm:ss tt'); + + dateTimeEditor.defaultFormatType = 'time'; + change = new SimpleChange('dateTime', 'time', false); + changes = { defaultFormatType: change }; + dateTimeEditor.ngOnChanges(changes); + expect(dateTimeEditor.inputFormat.normalize('NFKC')).toEqual('hh:mm tt'); + }); + }); + + describe('Date portions spinning', () => { + it('should correctly increment / decrement date portions with passed in DatePart', () => { + inputFormat = 'dd/M/yy'; + inputDate = '12/10/2015'; + elementRef = { nativeElement: { value: inputDate } }; + initializeDateTimeEditor(); + + dateTimeEditor.value = new Date(2015, 11, 12); + spyOnProperty((dateTimeEditor as any), 'inputValue', 'get').and.returnValue(inputDate); + const date = dateTimeEditor.value.getDate(); + const month = dateTimeEditor.value.getMonth(); + + dateTimeEditor.increment(DatePart.Date); + expect(dateTimeEditor.value.getDate()).toBeGreaterThan(date); + + dateTimeEditor.decrement(DatePart.Month); + expect(dateTimeEditor.value.getMonth()).toBeLessThan(month); + }); + + it('should correctly increment / decrement date portions without passed in DatePart', () => { + inputFormat = 'dd/MM/yyyy'; + inputDate = '12/10/2015'; + elementRef = { nativeElement: { value: inputDate } }; + initializeDateTimeEditor(); + + dateTimeEditor.value = new Date(2015, 11, 12); + spyOnProperty((dateTimeEditor as any), 'inputValue', 'get').and.returnValue(inputDate); + const date = dateTimeEditor.value.getDate(); + + dateTimeEditor.increment(); + expect(dateTimeEditor.value.getDate()).toBeGreaterThan(date); + + dateTimeEditor.decrement(); + expect(dateTimeEditor.value.getDate()).toEqual(date); + }); + + it('should correctly increment / decrement date portions with passed in spinDelta', () => { + inputFormat = 'dd/MM/yyyy'; + inputDate = '12/10/2015'; + elementRef = { nativeElement: { value: inputDate } }; + initializeDateTimeEditor(); + + const date = new Date(2015, 11, 12, 14, 35, 12); + dateTimeEditor.value = date; + dateTimeEditor.spinDelta = { date: 2, month: 2, year: 2, hours: 2, minutes: 2, seconds: 2 }; + spyOnProperty((dateTimeEditor as any), 'inputValue', 'get').and.returnValue(inputDate); + + dateTimeEditor.increment(); + expect(dateTimeEditor.value.getDate()).toEqual(14); + + dateTimeEditor.decrement(); + expect(dateTimeEditor.value.getDate()).toEqual(12); + + dateTimeEditor.increment(DatePart.Minutes); + expect(dateTimeEditor.value.getMinutes()).toEqual(date.getMinutes() + 2); + + dateTimeEditor.decrement(DatePart.Hours); + expect(dateTimeEditor.value.getHours()).toEqual(date.getHours() - 2); + }); + + it('should not loop over to next month when incrementing date', () => { + inputFormat = 'dd/MM/yyyy'; + inputDate = '29/02/2020'; + elementRef = { nativeElement: { value: inputDate } }; + initializeDateTimeEditor(); + + dateTimeEditor.value = new Date(2020, 1, 29); + spyOnProperty((dateTimeEditor as any), 'inputValue', 'get').and.returnValue(inputDate); + + dateTimeEditor.increment(); + expect(dateTimeEditor.value.getDate()).toEqual(1); + expect(dateTimeEditor.value.getMonth()).toEqual(1); + }); + + it('should not loop over to next year when incrementing month', () => { + inputFormat = 'dd/MM/yyyy'; + inputDate = '29/12/2020'; + elementRef = { nativeElement: { value: inputDate } }; + initializeDateTimeEditor(); + + dateTimeEditor.value = new Date(2020, 11, 29); + spyOnProperty((dateTimeEditor as any), 'inputValue', 'get').and.returnValue(inputDate); + + dateTimeEditor.increment(DatePart.Month); + expect(dateTimeEditor.value.getMonth()).toEqual(0); + expect(dateTimeEditor.value.getFullYear()).toEqual(2020); + }); + + it('should update date part if next/previous month\'s max date is less than the current one\'s', () => { + inputFormat = 'dd/MM/yyyy'; + inputDate = '31/01/2020'; + elementRef = { nativeElement: { value: inputDate } }; + initializeDateTimeEditor(); + + dateTimeEditor.value = new Date(2020, 0, 31); + spyOnProperty((dateTimeEditor as any), 'inputValue', 'get').and.returnValue(inputDate); + + dateTimeEditor.increment(DatePart.Month); + expect(dateTimeEditor.value.getDate()).toEqual(29); + expect(dateTimeEditor.value.getMonth()).toEqual(1); + }); + + it('should prioritize Date for spinning, if it is set in format', () => { + inputFormat = 'dd/M/yy HH:mm:ss tt'; + inputDate = '11/03/2020 00:00:00 AM'; + elementRef = { nativeElement: { value: inputDate } }; + initializeDateTimeEditor(); + + dateTimeEditor.value = new Date(2020, 2, 11); + spyOnProperty((dateTimeEditor as any), 'inputValue', 'get').and.returnValue(inputDate); + + dateTimeEditor.increment(); + expect(dateTimeEditor.value.getDate()).toEqual(12); + + dateTimeEditor.decrement(); + expect(dateTimeEditor.value.getDate()).toEqual(11); + }); + + it('should not loop over when isSpinLoop is false', () => { + inputFormat = 'dd/MM/yyyy'; + inputDate = '31/03/2020'; + elementRef = { nativeElement: { value: inputDate } }; + initializeDateTimeEditor(); + dateTimeEditor.spinLoop = false; + + dateTimeEditor.value = new Date(2020, 2, 31); + spyOnProperty((dateTimeEditor as any), 'inputValue', 'get').and.returnValue(inputDate); + + dateTimeEditor.increment(DatePart.Date); + expect(dateTimeEditor.value.getDate()).toEqual(31); + + dateTimeEditor.value = new Date(2020, 1, 31); + dateTimeEditor.decrement(DatePart.Month); + expect(dateTimeEditor.value.getMonth()).toEqual(1); + }); + + it('should loop over when isSpinLoop is true (default)', () => { + inputFormat = 'dd/MM/yyyy'; + inputDate = '31/03/2020'; + elementRef = { nativeElement: { value: inputDate } }; + initializeDateTimeEditor(); + + dateTimeEditor.value = new Date(2020, 2, 31); + spyOnProperty((dateTimeEditor as any), 'inputValue', 'get').and.returnValue(inputDate); + dateTimeEditor.increment(DatePart.Date); + expect(dateTimeEditor.value.getDate()).toEqual(1); + + dateTimeEditor.value = new Date(2020, 0, 31); + dateTimeEditor.decrement(DatePart.Month); + expect(dateTimeEditor.value.getMonth()).toEqual(11); + }); + }); + + describe('Time portions spinning', () => { + it('should correctly increment / decrement time portions with passed in DatePart', () => { + inputFormat = 'dd/MM/yyyy HH:mm:ss:SS'; + inputDate = '10/10/2010 12:10:34:555'; + elementRef = { nativeElement: { value: inputDate } }; + initializeDateTimeEditor(); + + dateTimeEditor.value = new Date(2010, 11, 10, 12, 10, 34, 555); + spyOnProperty((dateTimeEditor as any), 'inputValue', 'get').and.returnValue(inputDate); + const minutes = dateTimeEditor.value.getMinutes(); + const seconds = dateTimeEditor.value.getSeconds(); + const ms = dateTimeEditor.value.getMilliseconds(); + + dateTimeEditor.increment(DatePart.Minutes); + expect(dateTimeEditor.value.getMinutes()).toBeGreaterThan(minutes); + + dateTimeEditor.decrement(DatePart.Seconds); + expect(dateTimeEditor.value.getSeconds()).toBeLessThan(seconds); + + dateTimeEditor.increment(DatePart.FractionalSeconds); + expect(dateTimeEditor.value.getMilliseconds()).toBeGreaterThan(ms); + }); + + it('should correctly increment / decrement time portions without passed in DatePart', () => { + inputFormat = 'HH:mm:ss:SS aa'; + inputDate = ''; + elementRef = { nativeElement: { value: inputDate } }; + initializeDateTimeEditor(); + /* + * format must be set because the editor will prioritize Date if Hours is not set + * and no DatePart is provided to increment / decrement + */ + // do not use new Date. This test will fail if run between 23:00 and 23:59 + dateTimeEditor.value = new Date(1900, 1, 1, 12, 0, 0, 0); + spyOnProperty((dateTimeEditor as any), 'inputValue', 'get').and.returnValue(inputDate); + const hours = dateTimeEditor.value.getHours(); + + dateTimeEditor.increment(); + expect(dateTimeEditor.value.getHours()).toBeGreaterThan(hours); + + dateTimeEditor.decrement(); + expect(dateTimeEditor.value.getHours()).toEqual(hours); + }); + + it('should not loop over to next minute when incrementing seconds', () => { + inputFormat = 'dd/MM/yyyy HH:mm:ss'; + inputDate = '20/01/2019 20:05:59'; + elementRef = { nativeElement: { value: inputDate } }; + initializeDateTimeEditor(); + + dateTimeEditor.value = new Date(2019, 1, 20, 20, 5, 59); + spyOnProperty((dateTimeEditor as any), 'inputValue', 'get').and.returnValue(inputDate); + + dateTimeEditor.increment(DatePart.Seconds); + expect(dateTimeEditor.value.getMinutes()).toEqual(5); + expect(dateTimeEditor.value.getSeconds()).toEqual(0); + }); + + it('should not loop over to next hour when incrementing minutes', () => { + inputFormat = 'dd/MM/yyyy HH:mm:ss'; + inputDate = '20/01/2019 20:59:12'; + elementRef = { nativeElement: { value: inputDate } }; + initializeDateTimeEditor(); + + dateTimeEditor.value = new Date(2019, 1, 20, 20, 59, 12); + spyOnProperty((dateTimeEditor as any), 'inputValue', 'get').and.returnValue(inputDate); + + dateTimeEditor.increment(DatePart.Minutes); + expect(dateTimeEditor.value.getHours()).toEqual(20); + expect(dateTimeEditor.value.getMinutes()).toEqual(0); + }); + + it('should not loop over to next day when incrementing hours', () => { + inputFormat = 'dd/MM/yyyy HH:mm:ss'; + inputDate = '20/01/2019 23:13:12'; + elementRef = { nativeElement: { value: inputDate } }; + initializeDateTimeEditor(); + + dateTimeEditor.value = new Date(2019, 1, 20, 23, 13, 12); + spyOnProperty((dateTimeEditor as any), 'inputValue', 'get').and.returnValue(inputDate); + + dateTimeEditor.increment(DatePart.Hours); + expect(dateTimeEditor.value.getDate()).toEqual(20); + expect(dateTimeEditor.value.getHours()).toEqual(0); + }); + + it('should not loop over when isSpinLoop is false', () => { + inputFormat = 'dd/MM/yyyy HH:mm:ss'; + inputDate = '20/01/2019 23:13:12'; + elementRef = { nativeElement: { value: inputDate } }; + initializeDateTimeEditor(); + dateTimeEditor.spinLoop = false; + dateTimeEditor.value = new Date(2019, 1, 20, 23, 0, 12); + spyOnProperty((dateTimeEditor as any), 'inputValue', 'get').and.returnValue(inputDate); + + dateTimeEditor.increment(DatePart.Hours); + expect(dateTimeEditor.value.getHours()).toEqual(23); + + dateTimeEditor.decrement(DatePart.Minutes); + expect(dateTimeEditor.value.getMinutes()).toEqual(0); + }); + + it('should loop over when isSpinLoop is true (default)', () => { + inputFormat = 'dd/MM/yyyy HH:mm:ss'; + inputDate = '20/02/2019 23:15:12'; + elementRef = { nativeElement: { value: inputDate } }; + initializeDateTimeEditor(); + + dateTimeEditor.value = new Date(2019, 1, 20, 23, 15, 0); + spyOnProperty((dateTimeEditor as any), 'inputValue', 'get').and.returnValue(inputDate); + + dateTimeEditor.increment(DatePart.Hours); + expect(dateTimeEditor.value.getHours()).toEqual(0); + + dateTimeEditor.decrement(DatePart.Seconds); + expect(dateTimeEditor.value.getSeconds()).toEqual(59); + }); + + it('should properly parse AM/PM no matter where it is in the format', () => { + inputFormat = 'dd tt yyyy-MM mm-ss-hh'; + inputDate = '12 AM 2020-06 14-15-11'; + elementRef = { nativeElement: { value: inputDate } }; + initializeDateTimeEditor(); + + dateTimeEditor.inputFormat = inputFormat; + expect(dateTimeEditor.mask).toEqual('00 LL 0000-00 00-00-00'); + + dateTimeEditor.value = new Date(2020, 5, 12, 11, 15, 14); + spyOnProperty((dateTimeEditor as any), 'inputValue', 'get').and.returnValue(inputDate); + + dateTimeEditor.increment(DatePart.AmPm); + expect(dateTimeEditor.value).toEqual(new Date(2020, 5, 12, 23, 15, 14)); + + inputFormat = 'dd aa yyyy-MM mm-ss-hh'; + + dateTimeEditor.inputFormat = inputFormat; + expect(dateTimeEditor.mask).toEqual('00 LL 0000-00 00-00-00'); + + dateTimeEditor.value = new Date(2020, 5, 12, 11, 15, 14); + + dateTimeEditor.increment(DatePart.AmPm); + expect(dateTimeEditor.value).toEqual(new Date(2020, 5, 12, 23, 15, 14)); + }); + + it('should support AM/PM part formats as Angular\'s DatePipe Period - a, aa, aaa, aaaa & aaaaa', () => { + inputFormat = 'HH:mm:ss '; + elementRef = { nativeElement: { value: inputDate } }; + initializeDateTimeEditor(); + + for (let i = 0; i < 5; i++) { + inputFormat += 'a'; + dateTimeEditor.inputFormat = inputFormat; + const expectedMask = '00:00:00 ' + 'L'.repeat(i === 4 ? 1 : 2); + expect(dateTimeEditor.mask).toEqual(expectedMask); + } + // make sure it works for multiple occurrences of the AmPm part variations at once and at last position + inputFormat = 'a aaa aa aaaaa HH:mm:ss a'; + const expectedMask = 'LL LL LL L 00:00:00 LL'; + dateTimeEditor.inputFormat = inputFormat; + expect(dateTimeEditor.mask).toEqual(expectedMask); + }); + + it('should use \'tt\' format as an alias to a, aa, etc. Period formats', () => { + inputFormat = 'HH:mm:ss tt'; + elementRef = { nativeElement: { value: inputDate } }; + initializeDateTimeEditor(); + const expectedMask = '00:00:00 LL'; + dateTimeEditor.inputFormat = inputFormat; + expect(dateTimeEditor.mask).toEqual(expectedMask); + }); + }); + }); + + describe('Integration tests', () => { + const dateTimeOptions = { + day: '2-digit', month: '2-digit', year: 'numeric', + hour: 'numeric', minute: 'numeric', second: 'numeric', fractionalSecondDigits: 3 + }; + let fixture; + let inputElement: DebugElement; + let dateTimeEditorDirective: IgxDateTimeEditorDirective; + describe('Key interaction tests', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxDateTimeEditorSampleComponent, + IgxDateTimeEditorBaseTestComponent, + IgxDateTimeEditorShadowDomComponent + ] + }).compileComponents(); + })); + beforeEach(async () => { + fixture = TestBed.createComponent(IgxDateTimeEditorSampleComponent); + fixture.detectChanges(); + inputElement = fixture.debugElement.query(By.css('input')); + dateTimeEditorDirective = inputElement.injector.get(IgxDateTimeEditorDirective); + }); + + it('should properly update mask with inputFormat onInit', () => { + fixture = TestBed.createComponent(IgxDateTimeEditorBaseTestComponent); + fixture.detectChanges(); + expect(fixture.componentInstance.dateEditor.elementRef.nativeElement.value).toEqual('09/11/2009'); + }); + + it('should update value and mask according to the display format and ISO string date as value', () => { + fixture.componentInstance.dateTimeFormat = 'dd/MM/yy'; + fixture.componentInstance.displayFormat = 'shortDate'; + dateTimeEditorDirective.value = new Date(2003, 3, 5).toISOString(); + fixture.detectChanges(); + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + expect(dateTimeEditorDirective.mask).toEqual('00/00/00'); + expect(inputElement.nativeElement.value).toEqual('05/04/03'); + UIInteractions.simulateTyping('1', inputElement); + expect(inputElement.nativeElement.value).toEqual('15/04/03'); + }); + + it('should correctly display input format during user input', () => { + fixture.componentInstance.dateTimeFormat = 'dd/MM/yy'; + fixture.detectChanges(); + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('1', inputElement); + expect(inputElement.nativeElement.value).toEqual('1_/__/__'); + UIInteractions.simulateTyping('9', inputElement, 1, 1); + expect(inputElement.nativeElement.value).toEqual('19/__/__'); + UIInteractions.simulateTyping('1', inputElement, 2, 2); + expect(inputElement.nativeElement.value).toEqual('19/1_/__'); + UIInteractions.simulateTyping('2', inputElement, 4, 4); + expect(inputElement.nativeElement.value).toEqual('19/12/__'); + UIInteractions.simulateTyping('0', inputElement, 5, 5); + expect(inputElement.nativeElement.value).toEqual('19/12/0_'); + UIInteractions.simulateTyping('8', inputElement, 7, 7); + expect(inputElement.nativeElement.value).toEqual('19/12/08'); + }); + it('should not accept invalid date.', () => { + fixture.componentInstance.dateTimeFormat = 'dd/MM/yy'; + fixture.detectChanges(); + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('333333', inputElement); + expect(inputElement.nativeElement.value).toEqual('33/33/33'); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + expect(inputElement.nativeElement.value).toEqual(''); + }); + it('should autofill missing date/time segments on blur.', () => { + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('8', inputElement); + expect(inputElement.nativeElement.value).toEqual('8_/__/____ __:__:__:___'); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + const date = new Date(2000, 0, 8, 0, 0, 0); + let result = ControlsFunction.formatDate(date, dateTimeOptions, 'en-GB').replace(/,/g, '').replace(/\./g, ':'); + expect(inputElement.nativeElement.value).toEqual(result); + + dateTimeEditorDirective.clear(); + fixture.detectChanges(); + + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('5', inputElement, 7, 7); + expect(inputElement.nativeElement.value).toEqual('__/__/_5__ __:__:__:___'); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + date.setFullYear(2005); + date.setDate(1); + result = ControlsFunction.formatDate(date, dateTimeOptions, 'en-GB').replace(/,/g, '').replace(/\./g, ':'); + expect(inputElement.nativeElement.value).toEqual(result); + + dateTimeEditorDirective.clear(); + fixture.detectChanges(); + + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('3', inputElement, 11, 11); + expect(inputElement.nativeElement.value).toEqual('__/__/____ 3_:__:__:___'); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + date.setFullYear(2000); + date.setHours(3); + result = ControlsFunction.formatDate(date, dateTimeOptions, 'en-GB').replace(/,/g, '').replace(/\./g, ':'); + expect(inputElement.nativeElement.value).toEqual(result); + }); + it('should not accept invalid date and time parts.', () => { + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('63', inputElement); + expect(inputElement.nativeElement.value).toEqual('63/__/____ __:__:__:___'); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + expect(inputElement.nativeElement.value).toEqual(''); + + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('63', inputElement, 3, 3); + expect(inputElement.nativeElement.value).toEqual('__/63/____ __:__:__:___'); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + expect(inputElement.nativeElement.value).toEqual(''); + + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('25', inputElement, 11, 11); + expect(inputElement.nativeElement.value).toEqual('__/__/____ 25:__:__:___'); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + expect(inputElement.nativeElement.value).toEqual(''); + + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('78', inputElement, 14, 14); + expect(inputElement.nativeElement.value).toEqual('__/__/____ __:78:__:___'); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + expect(inputElement.nativeElement.value).toEqual(''); + + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('78', inputElement, 17, 17); + expect(inputElement.nativeElement.value).toEqual('__/__/____ __:__:78:___'); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + expect(inputElement.nativeElement.value).toEqual(''); + }); + it('should correctly show year based on century threshold.', () => { + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('0000', inputElement, 6, 6); + expect(inputElement.nativeElement.value).toEqual('__/__/0000 __:__:__:___'); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + const date = new Date(2000, 0, 1, 0, 0, 0); + let result = ControlsFunction.formatDate(date, dateTimeOptions, 'en-GB').replace(/,/g, '').replace(/\./g, ':'); + expect(inputElement.nativeElement.value).toEqual(result); + + dateTimeEditorDirective.clear(); + fixture.detectChanges(); + + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('5', inputElement, 6, 6); + expect(inputElement.nativeElement.value).toEqual('__/__/5___ __:__:__:___'); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + date.setFullYear(2005); + result = ControlsFunction.formatDate(date, dateTimeOptions, 'en-GB').replace(/,/g, '').replace(/\./g, ':'); + expect(inputElement.nativeElement.value).toEqual(result); + + dateTimeEditorDirective.clear(); + fixture.detectChanges(); + + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('16', inputElement, 6, 6); + expect(inputElement.nativeElement.value).toEqual('__/__/16__ __:__:__:___'); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + date.setFullYear(2016); + result = ControlsFunction.formatDate(date, dateTimeOptions, 'en-GB').replace(/,/g, '').replace(/\./g, ':'); + expect(inputElement.nativeElement.value).toEqual(result); + + dateTimeEditorDirective.clear(); + fixture.detectChanges(); + + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('169', inputElement, 6, 6); + expect(inputElement.nativeElement.value).toEqual('__/__/169_ __:__:__:___'); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + date.setFullYear(169); + result = ControlsFunction.formatDate(date, dateTimeOptions, 'en-GB').replace(/169,/g, '0169').replace(/\./g, ':'); + expect(inputElement.nativeElement.value).toEqual(result); + }); + it('should support different display and input formats.', () => { + fixture.componentInstance.displayFormat = 'longDate'; + fixture.detectChanges(); + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('9', inputElement); + expect(inputElement.nativeElement.value).toEqual('9_/__/____ __:__:__:___'); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + let date = new Date(2000, 0, 9, 0, 0, 0); + const options = { month: 'long', day: 'numeric' }; + let result = `${ControlsFunction.formatDate(date, options)}, ${date.getFullYear()}`; + expect(inputElement.nativeElement.value).toEqual(result); + + dateTimeEditorDirective.clear(); + fixture.detectChanges(); + + fixture.componentInstance.displayFormat = 'dd/MM/yyy'; + fixture.detectChanges(); + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('169', inputElement, 6, 6); + expect(inputElement.nativeElement.value).toEqual('__/__/169_ __:__:__:___'); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + date = new Date(169, 0, 1, 0, 0, 0); + const customOptions = { day: '2-digit', month: '2-digit', year: 'numeric' }; + result = ControlsFunction.formatDate(date, customOptions); + expect(inputElement.nativeElement.value).toEqual(result); + }); + it('should support long and short date formats', () => { + fixture.componentInstance.displayFormat = 'longDate'; + fixture.detectChanges(); + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('9', inputElement); + expect(inputElement.nativeElement.value).toEqual('9_/__/____ __:__:__:___'); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + let date = new Date(2000, 0, 9, 0, 0, 0); + const longDateOptions = { month: 'long', day: 'numeric' }; + let result = `${ControlsFunction.formatDate(date, longDateOptions)}, ${date.getFullYear()}`; + expect(inputElement.nativeElement.value).toEqual(result); + + dateTimeEditorDirective.clear(); + fixture.detectChanges(); + + fixture.componentInstance.displayFormat = 'shortDate'; + fixture.detectChanges(); + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('9', inputElement); + expect(inputElement.nativeElement.value).toEqual('9_/__/____ __:__:__:___'); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + const shortDateOptions = { day: 'numeric', month: 'numeric', year: '2-digit' }; + result = ControlsFunction.formatDate(date, shortDateOptions); + expect(inputElement.nativeElement.value).toEqual(result); + + dateTimeEditorDirective.clear(); + fixture.detectChanges(); + + fixture.componentInstance.displayFormat = 'fullDate'; + fixture.detectChanges(); + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('9', inputElement); + expect(inputElement.nativeElement.value).toEqual('9_/__/____ __:__:__:___'); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + const fullDateOptions = { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }; + result = ControlsFunction.formatDate(date, fullDateOptions); + expect(inputElement.nativeElement.value).toEqual(result); + + dateTimeEditorDirective.clear(); + fixture.detectChanges(); + + fixture.componentInstance.displayFormat = 'shortTime'; + fixture.detectChanges(); + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('1', inputElement, 11, 11); + expect(inputElement.nativeElement.value).toEqual('__/__/____ 1_:__:__:___'); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + date = new Date(0, 0, 0, 1, 0, 0); + const shortTimeOptions = { hour: 'numeric', minute: 'numeric', hour12: true }; + result = ControlsFunction.formatDate(date, shortTimeOptions); + expect(inputElement.nativeElement.value).toEqual(result); + + dateTimeEditorDirective.clear(); + fixture.detectChanges(); + + fixture.componentInstance.displayFormat = 'longTime'; + fixture.detectChanges(); + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('2', inputElement, 11, 11); + expect(inputElement.nativeElement.value).toEqual('__/__/____ 2_:__:__:___'); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + date = new Date(2000, 0, 1, 2, 0, 0); + result = formatDate(date, 'longTime', 'en-US').normalize("NFKD"); + expect(inputElement.nativeElement.value).toEqual(result); + }); + it('should be able to apply custom display format.', fakeAsync(() => { + // default format + const date = new Date(2003, 3, 5, 0, 0, 0, 0); + fixture.componentInstance.date = new Date(2003, 3, 5, 0, 0, 0, 0); + fixture.detectChanges(); + tick(); + let result = ControlsFunction.formatDate(date, dateTimeOptions, 'en-GB').replace(/,/g, '').replace(/\./g, ':'); + expect(inputElement.nativeElement.value).toEqual(result); + + // custom format + fixture.componentInstance.displayFormat = 'EEEE d MMMM y h:mm a'; + fixture.detectChanges(); + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('1', inputElement); + date.setDate(15); + result = ControlsFunction.formatDate(date, dateTimeOptions, 'en-GB').replace(/,/g, '').replace(/\./g, ':'); + expect(inputElement.nativeElement.value).toEqual(result); + + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + date.setMonth(3); + const shortTimeOptions = { hour: 'numeric', minute: 'numeric', hour12: true }; + const dateOptions = { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }; + const resultDate = ControlsFunction.formatDate(date, dateOptions, 'en-GB').replace(/,/g, ''); + result = `${resultDate} ${ControlsFunction.formatDate(date, shortTimeOptions)}`; + expect(inputElement.nativeElement.value).toEqual(result); + })); + it('should convert dates correctly on paste when different display and input formats are set.', () => { + // display format = input format + let date = new Date(2020, 10, 10, 10, 10, 10); + let inputDate = ControlsFunction.formatDate(date, dateTimeOptions, 'en-GB').replace(/,/g, '').replace(/\./g, ':'); + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulatePaste(inputDate, inputElement, 0, 19); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + expect(inputElement.nativeElement.value).toEqual(inputDate); + + // display format != input format + fixture.componentInstance.displayFormat = 'd/M/yy'; + fixture.detectChanges(); + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulatePaste(inputDate, inputElement, 0, 19); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + const shortDateOptions = { day: 'numeric', month: 'numeric', year: '2-digit' }; + const result = ControlsFunction.formatDate(date, shortDateOptions, 'en-GB'); + expect(inputElement.nativeElement.value).toEqual(result); + + inputDate = '6/7/28'; + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulatePaste(inputDate, inputElement, 0, 19); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + expect(inputElement.nativeElement.value).toEqual(''); + + fixture.componentInstance.dateTimeFormat = 'd/M/yy'; + fixture.detectChanges(); + fixture.componentInstance.displayFormat = 'dd/MM/yyyy'; + fixture.detectChanges(); + + // inputElement.triggerEventHandler('focus', {}); + // fixture.detectChanges(); + // UIInteractions.simulatePaste(inputDate, inputElement, 0, 19); + // fixture.detectChanges(); + // inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + // fixture.detectChanges(); + // expect(inputElement.nativeElement.value).toEqual('__/__/__'); + + date = new Date(2028, 7, 16, 0, 0, 0); + inputDate = '16/07/28'; + const longDateOptions = { day: '2-digit', month: '2-digit', year: '2-digit' }; + inputDate = ControlsFunction.formatDate(date, longDateOptions, 'en-GB'); + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulatePaste(inputDate, inputElement, 0, 19); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + longDateOptions.year = 'numeric'; + inputDate = ControlsFunction.formatDate(date, longDateOptions, 'en-GB'); + expect(inputElement.nativeElement.value).toEqual(inputDate); + }); + it('should clear input date on clear()', fakeAsync(() => { + const date = new Date(2003, 3, 5); + fixture.componentInstance.date = date; + fixture.detectChanges(); + tick(); + const result = ControlsFunction.formatDate(date, dateTimeOptions, 'en-GB').replace(/,/g, '').replace(/\./g, ':'); + expect(inputElement.nativeElement.value).toEqual(result); + + dateTimeEditorDirective.clear(); + expect(inputElement.nativeElement.value).toEqual(''); + })); + it('should move the caret to the start/end of the portion with CTRL + arrow left/right keys.', fakeAsync(() => { + const date = new Date(2003, 4, 5); + fixture.componentInstance.date = date; + fixture.detectChanges(); + tick(); + const result = ControlsFunction.formatDate(date, dateTimeOptions, 'en-GB').replace(/,/g, '').replace(/\./g, ':'); + expect(inputElement.nativeElement.value).toEqual(result); + + const inputHTMLElement = inputElement.nativeElement as HTMLInputElement; + inputHTMLElement.setSelectionRange(0, 0); + fixture.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', inputElement, false, false, true); + fixture.detectChanges(); + expect(inputHTMLElement.selectionStart).toEqual(2); + expect(inputHTMLElement.selectionEnd).toEqual(2); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', inputElement, false, false, true); + fixture.detectChanges(); + expect(inputHTMLElement.selectionStart).toEqual(0); + expect(inputHTMLElement.selectionEnd).toEqual(0); + + inputHTMLElement.setSelectionRange(8, 8); + fixture.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', inputElement, false, false, true); + fixture.detectChanges(); + expect(inputHTMLElement.selectionStart).toEqual(6); + expect(inputHTMLElement.selectionEnd).toEqual(6); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', inputElement, false, false, true); + fixture.detectChanges(); + expect(inputHTMLElement.selectionStart).toEqual(10); + expect(inputHTMLElement.selectionEnd).toEqual(10); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', inputElement, false, false, true); + fixture.detectChanges(); + expect(inputHTMLElement.selectionStart).toEqual(13); + expect(inputHTMLElement.selectionEnd).toEqual(13); + + inputHTMLElement.setSelectionRange(15, 15); + fixture.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', inputElement, false, false, true); + fixture.detectChanges(); + expect(inputHTMLElement.selectionStart).toEqual(16); + expect(inputHTMLElement.selectionEnd).toEqual(16); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', inputElement, false, false, true); + fixture.detectChanges(); + expect(inputHTMLElement.selectionStart).toEqual(14); + expect(inputHTMLElement.selectionEnd).toEqual(14); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', inputElement, false, false, true); + fixture.detectChanges(); + expect(inputHTMLElement.selectionStart).toEqual(16); + expect(inputHTMLElement.selectionEnd).toEqual(16); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', inputElement, false, false, true); + fixture.detectChanges(); + expect(inputHTMLElement.selectionStart).toEqual(19); + expect(inputHTMLElement.selectionEnd).toEqual(19); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', inputElement, false, false, true); + fixture.detectChanges(); + expect(inputHTMLElement.selectionStart).toEqual(17); + expect(inputHTMLElement.selectionEnd).toEqual(17); + })); + it('should not block the user from typing/pasting dates outside of min/max range', () => { + fixture.componentInstance.minDate = '01/01/2000'; + fixture.componentInstance.maxDate = '31/12/2000'; + fixture.detectChanges(); + + let date = new Date(2009, 10, 10, 10, 10, 10); + let inputDate = ControlsFunction.formatDate(date, dateTimeOptions, 'en-GB').replace(/,/g, '').replace(/\./g, ':'); + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulatePaste(inputDate, inputElement, 0, 19); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + expect(inputElement.nativeElement.value).toEqual(inputDate); + + dateTimeEditorDirective.clear(); + fixture.detectChanges(); + + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('27', inputElement, 8, 8); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + date = new Date(2027, 0, 1, 0, 0, 0); + inputDate = ControlsFunction.formatDate(date, dateTimeOptions, 'en-GB').replace(/,/g, '').replace(/\./g, ':'); + expect(inputElement.nativeElement.value).toEqual(inputDate); + }); + it('should be able to customize prompt char.', () => { + fixture.componentInstance.promptChar = '.'; + fixture.detectChanges(); + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + expect(inputElement.nativeElement.value).toEqual('../../.... ..:..:..:...'); + }); + it('should be en/disabled when the input is en/disabled.', fakeAsync(() => { + spyOn(dateTimeEditorDirective, 'setDisabledState'); + fixture.componentInstance.disabled = true; + fixture.detectChanges(); + tick(); + expect(dateTimeEditorDirective.setDisabledState).toHaveBeenCalledTimes(1); + expect(dateTimeEditorDirective.setDisabledState).toHaveBeenCalledWith(true); + + fixture.componentInstance.disabled = false; + fixture.detectChanges(); + tick(); + expect(dateTimeEditorDirective.setDisabledState).toHaveBeenCalledTimes(2); + expect(dateTimeEditorDirective.setDisabledState).toHaveBeenCalledWith(false); + })); + it('should emit valueChange event on blur', () => { + const newDate = new Date(2004, 11, 18); + fixture.componentInstance.dateTimeFormat = 'dd/MM/yy'; + fixture.detectChanges(); + spyOn(dateTimeEditorDirective.valueChange, 'emit'); + + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('18124', inputElement); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + + const options = { day: '2-digit', month: '2-digit', year: '2-digit' }; + const result = ControlsFunction.formatDate(newDate, options, 'en-GB'); + expect(inputElement.nativeElement.value).toEqual(result); + expect(dateTimeEditorDirective.valueChange.emit).toHaveBeenCalledTimes(1); + expect(dateTimeEditorDirective.valueChange.emit).toHaveBeenCalledWith(newDate); + }); + it('should emit valueChange event after input is complete', () => { + const newDate = new Date(2012, 11, 12); + fixture.componentInstance.dateTimeFormat = 'dd/MM/yy'; + fixture.detectChanges(); + spyOn(dateTimeEditorDirective.valueChange, 'emit'); + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('121212', inputElement); + fixture.detectChanges(); + expect(dateTimeEditorDirective.valueChange.emit).toHaveBeenCalledTimes(1); + expect(dateTimeEditorDirective.valueChange.emit).toHaveBeenCalledWith(newDate); + }); + it('should fire validationFailed when input date is outside date range.', () => { + fixture.componentInstance.dateTimeFormat = 'dd-MM-yyyy'; + fixture.componentInstance.minDate = new Date(2020, 1, 20); + fixture.componentInstance.maxDate = new Date(2020, 1, 25); + fixture.detectChanges(); + spyOn(dateTimeEditorDirective.validationFailed, 'emit'); + + // date within the range + let inputDate = '22-02-2020'; + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulatePaste(inputDate, inputElement, 0, 10); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + expect(inputElement.nativeElement.value).toEqual(inputDate); + expect(dateTimeEditorDirective.validationFailed.emit).not.toHaveBeenCalled(); + + // date > maxValue + let oldDate = new Date(2020, 1, 22); + let newDate = new Date(2020, 1, 26); + inputDate = '26-02-2020'; + let args = { oldValue: oldDate, newValue: newDate, userInput: inputDate }; + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulatePaste(inputDate, inputElement, 0, 19); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + expect(inputElement.nativeElement.value).toEqual(inputDate); + expect(dateTimeEditorDirective.validationFailed.emit).toHaveBeenCalledTimes(1); + expect(dateTimeEditorDirective.validationFailed.emit).toHaveBeenCalledWith(args); + + // date < minValue + oldDate = newDate; + newDate = new Date(2020, 1, 12); + inputDate = '12-02-2020'; + args = { oldValue: oldDate, newValue: newDate, userInput: inputDate }; + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulatePaste(inputDate, inputElement, 0, 19); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + expect(inputElement.nativeElement.value).toEqual(inputDate); + expect(dateTimeEditorDirective.validationFailed.emit).toHaveBeenCalledTimes(2); + expect(dateTimeEditorDirective.validationFailed.emit).toHaveBeenCalledWith(args); + }); + it('should fire validationFailed when input date is invalid.', () => { + fixture.componentInstance.dateTimeFormat = 'dd-MM-yyyy'; + fixture.componentInstance.minDate = new Date(2000, 1, 1); + fixture.componentInstance.maxDate = new Date(2050, 1, 25); + fixture.detectChanges(); + spyOn(dateTimeEditorDirective.validationFailed, 'emit').and.callThrough(); + + // valid date + let inputDate = '22-02-2020'; + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulatePaste(inputDate, inputElement, 0, 10); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + expect(inputElement.nativeElement.value).toEqual(inputDate); + expect(dateTimeEditorDirective.validationFailed.emit).not.toHaveBeenCalled(); + + // invalid date + const oldDate = new Date(2020, 1, 22); + inputDate = '99-99-2020'; + const args = { oldValue: oldDate, newValue: null, userInput: inputDate }; + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulatePaste(inputDate, inputElement, 0, 10); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + expect(inputElement.nativeElement.value).toEqual(''); + expect(dateTimeEditorDirective.validationFailed.emit).toHaveBeenCalledTimes(1); + expect(dateTimeEditorDirective.validationFailed.emit).toHaveBeenCalledWith(args); + }); + it('should properly increment/decrement date-time portions on wheel', fakeAsync(() => { + fixture.componentInstance.dateTimeFormat = 'dd-MM-yyyy'; + fixture.detectChanges(); + const today = new Date(2021, 12, 12); + dateTimeEditorDirective.value = today; + + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + dateTimeEditorDirective.nativeElement.setSelectionRange(1, 1); + // typical wheel scrolls are 120px and the date-editor employs touchpad-friendly implementation + // that accumulates to 50 before incrementing/decrementing + // we'll test the behavior by doing two scrolls with the first one not expected to trigger a change + inputElement.triggerEventHandler('wheel', new WheelEvent('wheel', { deltaY: 20 })); + fixture.detectChanges(); + expect(dateTimeEditorDirective.value.getDate()).toEqual(today.getDate()); + inputElement.triggerEventHandler('wheel', new WheelEvent('wheel', { deltaY: 40 })); + fixture.detectChanges(); + expect(dateTimeEditorDirective.value.getDate()).toEqual(today.getDate() - 1); + })); + it('should properly set placeholder with inputFormat applied', () => { + fixture.componentInstance.placeholder = 'Date:'; + fixture.detectChanges(); + expect(dateTimeEditorDirective.nativeElement.placeholder).toEqual('Date:'); + }); + it('should be able to switch placeholders at runtime', () => { + let placeholder = 'Placeholder'; + fixture.componentInstance.placeholder = placeholder; + fixture.detectChanges(); + expect(dateTimeEditorDirective.nativeElement.placeholder).toEqual(placeholder); + + placeholder = 'Placeholder1'; + fixture.componentInstance.placeholder = placeholder; + fixture.detectChanges(); + expect(dateTimeEditorDirective.nativeElement.placeholder).toEqual(placeholder); + + // when an empty placeholder (incl. null, undefined) is provided, at run-time, we do not default to the inputFormat + placeholder = ''; + fixture.componentInstance.placeholder = placeholder; + fixture.detectChanges(); + expect(dateTimeEditorDirective.nativeElement.placeholder).toEqual(placeholder); + }); + it('should convert correctly full-width characters after blur', () => { + const fullWidthText = '191208'; + fixture.componentInstance.dateTimeFormat = 'dd/MM/yy'; + fixture.detectChanges(); + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateCompositionEvent(fullWidthText, inputElement, 0, 8); + fixture.detectChanges(); + expect(inputElement.nativeElement.value).toEqual('19/12/08'); + }); + it('should convert correctly full-width characters after enter', () => { + const fullWidthText = '130948'; + fixture.componentInstance.dateTimeFormat = 'dd/MM/yy'; + fixture.detectChanges(); + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateCompositionEvent(fullWidthText, inputElement, 0, 8, false); + fixture.detectChanges(); + expect(inputElement.nativeElement.value).toEqual('13/09/48'); + }); + it('should convert correctly full-width characters on paste', () => { + fixture.componentInstance.dateTimeFormat = 'dd/MM/yy'; + fixture.detectChanges(); + const inputDate = '070520'; + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulatePaste(inputDate, inputElement, 0, 8); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + expect(inputElement.nativeElement.value).toEqual('07/05/20'); + }); + it('should properly increment/decrement date-time portions with arrow up/down keys in shadow DOM', () => { + fixture = TestBed.createComponent(IgxDateTimeEditorShadowDomComponent); + fixture.detectChanges(); + + fixture.componentInstance.dateTimeFormat = 'dd-MM-yyyy hh:mm:ss:SS'; + fixture.detectChanges(); + inputElement = fixture.debugElement.query(By.css('input')); + dateTimeEditorDirective = inputElement.injector.get(IgxDateTimeEditorDirective); + + const today = new Date(2022, 5, 12, 14, 35, 12, 555); + dateTimeEditorDirective.value = today; + + inputElement.nativeElement.focus(); + fixture.detectChanges(); + + dateTimeEditorDirective.nativeElement.setSelectionRange(1, 1); + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', inputElement, false, false, true); + fixture.detectChanges(); + expect(dateTimeEditorDirective.value.getDate()).toEqual(today.getDate() + 1); + + dateTimeEditorDirective.nativeElement.setSelectionRange(1, 1); + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', inputElement, false, false, true); + fixture.detectChanges(); + expect(dateTimeEditorDirective.value.getDate()).toEqual(today.getDate()); + + dateTimeEditorDirective.nativeElement.setSelectionRange(4, 4); + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', inputElement, false, false, true); + fixture.detectChanges(); + expect(dateTimeEditorDirective.value.getMonth()).toEqual(today.getMonth() + 1); + + dateTimeEditorDirective.nativeElement.setSelectionRange(4, 4); + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', inputElement, false, false, true); + fixture.detectChanges(); + expect(dateTimeEditorDirective.value.getMonth()).toEqual(today.getMonth()); + + dateTimeEditorDirective.nativeElement.setSelectionRange(9, 9); + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', inputElement, false, false, true); + fixture.detectChanges(); + expect(dateTimeEditorDirective.value.getFullYear()).toEqual(today.getFullYear() + 1); + + dateTimeEditorDirective.nativeElement.setSelectionRange(9, 9); + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', inputElement, false, false, true); + fixture.detectChanges(); + expect(dateTimeEditorDirective.value.getFullYear()).toEqual(today.getFullYear()); + + dateTimeEditorDirective.nativeElement.setSelectionRange(12, 12); + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', inputElement, false, false, true); + fixture.detectChanges(); + expect(dateTimeEditorDirective.value.getHours()).toEqual(today.getHours() + 1); + + dateTimeEditorDirective.nativeElement.setSelectionRange(12, 12); + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', inputElement, false, false, true); + fixture.detectChanges(); + expect(dateTimeEditorDirective.value.getHours()).toEqual(today.getHours()); + + dateTimeEditorDirective.nativeElement.setSelectionRange(15, 15); + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', inputElement, false, false, true); + fixture.detectChanges(); + expect(dateTimeEditorDirective.value.getMinutes()).toEqual(today.getMinutes() + 1); + + dateTimeEditorDirective.nativeElement.setSelectionRange(15, 15); + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', inputElement, false, false, true); + fixture.detectChanges(); + expect(dateTimeEditorDirective.value.getMinutes()).toEqual(today.getMinutes()); + + dateTimeEditorDirective.nativeElement.setSelectionRange(18, 18); + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', inputElement, false, false, true); + fixture.detectChanges(); + expect(dateTimeEditorDirective.value.getSeconds()).toEqual(today.getSeconds() + 1); + + dateTimeEditorDirective.nativeElement.setSelectionRange(18, 18); + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', inputElement, false, false, true); + fixture.detectChanges(); + expect(dateTimeEditorDirective.value.getSeconds()).toEqual(today.getSeconds()); + + dateTimeEditorDirective.nativeElement.setSelectionRange(21, 21); + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', inputElement, false, false, true); + fixture.detectChanges(); + expect(dateTimeEditorDirective.value.getMilliseconds()).toEqual(today.getMilliseconds() + 1); + + dateTimeEditorDirective.nativeElement.setSelectionRange(21, 21); + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', inputElement, false, false, true); + fixture.detectChanges(); + expect(dateTimeEditorDirective.value.getMilliseconds()).toEqual(today.getMilliseconds()); + }); + + it('should update the displayed value on locale change when both inputFormat and displayFormat are set', () => { + registerLocaleData(localeBg); + dateTimeEditorDirective.inputFormat = 'dd/MM/yyyy'; + dateTimeEditorDirective.displayFormat = 'shortDate'; + dateTimeEditorDirective.value = new Date(2023, 1, 1); + fixture.detectChanges(); + + expect(inputElement.nativeElement.value.normalize('NFKC')).toBe('2/1/23'); + + fixture.componentInstance.locale = 'bg-BG'; + fixture.detectChanges(); + + expect(inputElement.nativeElement.value.normalize('NFKC')).toBe('1.02.23 г.'); + }); + }); + + describe('Form control tests: ', () => { + let form: UntypedFormGroup; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxDateTimeEditorFormComponent + ] + }).compileComponents(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(IgxDateTimeEditorFormComponent); + fixture.detectChanges(); + form = fixture.componentInstance.reactiveForm; + inputElement = fixture.debugElement.query(By.css('input')); + dateTimeEditorDirective = inputElement.injector.get(IgxDateTimeEditorDirective); + }); + it('should validate properly when used as form control.', () => { + spyOn(dateTimeEditorDirective.validationFailed, 'emit').and.callThrough(); + const dateEditor = form.controls['dateEditor']; + const inputDate = '99-99-9999'; + + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulatePaste(inputDate, inputElement, 0, 10); + fixture.detectChanges(); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + + expect(inputElement.nativeElement.value).toEqual(''); + expect(form.valid).toBeFalsy(); + expect(dateEditor.valid).toBeFalsy(); + expect(dateTimeEditorDirective.validationFailed.emit).toHaveBeenCalledTimes(1); + // expect(dateTimeEditorDirective.validationFailed.emit).toHaveBeenCalledWith(args); + }); + it('should validate properly min/max value when used as form control.', () => { + fixture.componentInstance.minDate = new Date(2020, 2, 20); + fixture.componentInstance.maxDate = new Date(2020, 2, 25); + spyOn(dateTimeEditorDirective.validationFailed, 'emit'); + const dateEditor = form.controls['dateEditor']; + + let inputDate = '21-03-2020'; + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulatePaste(inputDate, inputElement, 0, 10); + fixture.detectChanges(); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + expect(inputElement.nativeElement.value).toEqual(inputDate); + expect(form.valid).toBeTruthy(); + expect(dateEditor.valid).toBeTruthy(); + expect(dateTimeEditorDirective.validationFailed.emit).not.toHaveBeenCalled(); + + inputDate = '21-02-2020'; + const args = { oldValue: new Date(2020, 2, 21), newValue: new Date(2020, 1, 21), userInput: inputDate }; + inputElement.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulatePaste(inputDate, inputElement, 0, 10); + fixture.detectChanges(); + inputElement.triggerEventHandler('blur', { target: inputElement.nativeElement }); + fixture.detectChanges(); + expect(inputElement.nativeElement.value).toEqual(inputDate); + expect(form.valid).toBeFalsy(); + expect(dateEditor.valid).toBeFalsy(); + expect(dateTimeEditorDirective.validationFailed.emit).toHaveBeenCalledTimes(1); + expect(dateTimeEditorDirective.validationFailed.emit).toHaveBeenCalledWith(args); + }); + it('should properly submit values when used as a form control', () => { + const inputDate = '09-04-2020'; + expect(form.valid).toBeFalsy(); + form.controls['dateEditor'].setValue(inputDate); + expect(form.valid).toBeTruthy(); + + let result: string; + fixture.componentInstance.submitted.subscribe((value) => result = value); + fixture.componentInstance.submit(); + expect(result).toBe(inputDate); + }); + it('should default to inputFormat as placeholder if none is provided', () => { + fixture.componentInstance.dateTimeFormat = 'dd/MM/yyyy'; + fixture.detectChanges(); + expect(dateTimeEditorDirective.nativeElement.placeholder).toEqual('dd/MM/yyyy'); + }); + }); + }); +}); + +@Component({ + template: ` + + + + `, + imports: [IgxInputGroupComponent, IgxInputDirective, IgxDateTimeEditorDirective] +}) +export class IgxDateTimeEditorBaseTestComponent { + @ViewChild(IgxDateTimeEditorDirective) + public dateEditor: IgxDateTimeEditorDirective; + public date = new Date(2009, 10, 9); +} + +@Component({ + template: ` + + + + + +`, + imports: [IgxInputGroupComponent, IgxInputDirective, IgxDateTimeEditorDirective, FormsModule] +}) +export class IgxDateTimeEditorSampleComponent { + @ViewChild('igxInputGroup', { static: true }) public igxInputGroup: IgxInputGroupComponent; + public date: Date; + public dateTimeFormat = 'dd/MM/yyyy HH:mm:ss:SSS'; + public displayFormat: string; + public minDate: string | Date; + public maxDate: string | Date; + public promptChar = '_'; + public disabled = false; + public readonly = false; + public placeholder = null; + public locale = 'en-US'; +} + +@Component({ + template: ` +
+ + + +
+`, + imports: [IgxInputGroupComponent, IgxInputDirective, IgxDateTimeEditorDirective, ReactiveFormsModule] +}) +class IgxDateTimeEditorFormComponent { + @ViewChild('dateEditor', { read: IgxInputDirective, static: true }) + public formInput: IgxInputDirective; + @Output() + public submitted = new EventEmitter(); + public reactiveForm: UntypedFormGroup; + public dateTimeFormat = 'dd-MM-yyyy'; + public minDate: Date; + public maxDate: Date; + + constructor() { + const fb = inject(UntypedFormBuilder); + + this.reactiveForm = fb.group({ + dateEditor: ['', Validators.required] + }); + } + + public submit() { + if (this.reactiveForm.valid) { + this.submitted.emit(this.reactiveForm.value.dateEditor); + } + } +} + +@Component({ + template: ` + + + + `, + encapsulation: ViewEncapsulation.ShadowDom, + imports: [IgxInputGroupComponent, IgxInputDirective, IgxDateTimeEditorDirective] +}) +export class IgxDateTimeEditorShadowDomComponent { + public dateTimeFormat = 'dd/MM/yyyy hh:mm:ss:SSS'; +} diff --git a/projects/igniteui-angular/directives/src/directives/date-time-editor/date-time-editor.directive.ts b/projects/igniteui-angular/directives/src/directives/date-time-editor/date-time-editor.directive.ts new file mode 100644 index 00000000000..cd6ff647175 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/date-time-editor/date-time-editor.directive.ts @@ -0,0 +1,798 @@ +import { Directive, Input, DOCUMENT, Output, EventEmitter, LOCALE_ID, OnChanges, SimpleChanges, HostListener, OnInit, booleanAttribute, inject } from '@angular/core'; +import { + ControlValueAccessor, + Validator, AbstractControl, ValidationErrors, NG_VALIDATORS, NG_VALUE_ACCESSOR, +} from '@angular/forms'; +import { IgxMaskDirective } from '../mask/mask.directive'; +import { isDate, DatePartInfo, DatePart, DatePartDeltas, DateTimeUtil } from 'igniteui-angular/core'; +import { IgxDateTimeEditorEventArgs } from './date-time-editor.common'; +import { noop } from 'rxjs'; + +/** + * Date Time Editor provides a functionality to input, edit and format date and time. + * + * @igxModule IgxDateTimeEditorModule + * + * @igxParent IgxInputGroup + * + * @igxTheme igx-input-theme + * + * @igxKeywords date, time, editor + * + * @igxGroup Scheduling + * + * @remarks + * + * The Ignite UI Date Time Editor Directive makes it easy for developers to manipulate date/time user input. + * It requires input in a specified or default input format which is visible in the input element as a placeholder. + * It allows the input of only date (ex: 'dd/MM/yyyy'), only time (ex:'HH:mm tt') or both at once, if needed. + * Supports display format that may differ from the input format. + * Provides methods to increment and decrement any specific/targeted `DatePart`. + * + * **Note:** This directive uses the Mask Directive internally and requires `type="text"` on the input element. + * Input elements with `type="date"` or other date/time types are not supported, as they do not allow + * programmatic cursor positioning and text selection required for mask functionality. + * + * @example + * ```html + * + * + * + * ``` + */ +@Directive({ + selector: '[igxDateTimeEditor]', + exportAs: 'igxDateTimeEditor', + providers: [ + { provide: NG_VALUE_ACCESSOR, useExisting: IgxDateTimeEditorDirective, multi: true }, + { provide: NG_VALIDATORS, useExisting: IgxDateTimeEditorDirective, multi: true } + ], + standalone: true +}) +export class IgxDateTimeEditorDirective extends IgxMaskDirective implements OnChanges, OnInit, Validator, ControlValueAccessor { + private _document = inject(DOCUMENT); + private _locale = inject(LOCALE_ID); + + /** + * Locale settings used for value formatting. + * + * @remarks + * Uses Angular's `LOCALE_ID` by default. Affects both input mask and display format if those are not set. + * If a `locale` is set, it must be registered via `registerLocaleData`. + * Please refer to https://angular.io/guide/i18n#i18n-pipes. + * If it is not registered, `Intl` will be used for formatting. + * + * @example + * ```html + * + * ``` + */ + @Input() + public locale: string; + + /** + * Minimum value required for the editor to remain valid. + * + * @remarks + * If a `string` value is passed, it must be in the defined input format. + * + * @example + * ```html + * + * ``` + */ + public get minValue(): string | Date { + return this._minValue; + } + + @Input() + public set minValue(value: string | Date) { + this._minValue = value; + this._onValidatorChange(); + } + + /** + * Maximum value required for the editor to remain valid. + * + * @remarks + * If a `string` value is passed in, it must be in the defined input format. + * + * @example + * ```html + * + * ``` + */ + public get maxValue(): string | Date { + return this._maxValue; + } + + @Input() + public set maxValue(value: string | Date) { + this._maxValue = value; + this._onValidatorChange(); + } + + /** + * Specify if the currently spun date segment should loop over. + * + * @example + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public spinLoop = true; + + /** + * Set both pre-defined format options such as `shortDate` and `longDate`, + * as well as constructed format string using characters supported by `DatePipe`, e.g. `EE/MM/yyyy`. + * + * @example + * ```html + * + * ``` + */ + @Input() + public set displayFormat(value: string) { + this._displayFormat = value; + this.updateDefaultFormat(); + } + + public get displayFormat(): string { + return this._displayFormat || this.inputFormat; + } + + /** + * Expected user input format (and placeholder). + * + * @example + * ```html + * + * ``` + */ + @Input(`igxDateTimeEditor`) + public set inputFormat(value: string) { + if (value) { + this.setMask(value); + this._inputFormat = value; + } + } + + public get inputFormat(): string { + return this._inputFormat || this._defaultInputFormat; + } + + /** + * Editor value. + * + * @example + * ```html + * + * ``` + */ + @Input() + public set value(value: Date | string | undefined | null) { + this._value = value; + this.setDateValue(value); + this.onChangeCallback(value); + this.updateMask(); + } + + public get value(): Date | string | undefined | null { + return this._value; + } + + /** + * Specify the default input format type. Defaults to `date`, which includes + * only date parts for editing. Other valid options are `time` and `dateTime`. + * + * @example + * ```html + * + * ``` + */ + @Input() + public defaultFormatType: 'date' | 'time' | 'dateTime' = 'date'; + + /** + * Delta values used to increment or decrement each editor date part on spin actions. + * All values default to `1`. + * + * @example + * ```html + * + * ``` + */ + @Input() + public spinDelta: DatePartDeltas; + + /** + * Emitted when the editor's value has changed. + * + * @example + * ```html + * + * ``` + */ + @Output() + public valueChange = new EventEmitter(); + + /** + * Emitted when the editor is not within a specified range or when the editor's value is in an invalid state. + * + * @example + * ```html + * + * ``` + */ + @Output() + public validationFailed = new EventEmitter(); + + + private readonly SCROLL_THRESHOLD = 50; + private _inputFormat: string; + private _scrollAccumulator = 0; + private _displayFormat: string; + private _oldValue: Date; + private _dateValue: Date; + private _onClear: boolean; + private document: Document; + private _defaultInputFormat: string; + private _value?: Date | string; + private _minValue: Date | string; + private _maxValue: Date | string; + private _inputDateParts: DatePartInfo[]; + private _datePartDeltas: DatePartDeltas = { + date: 1, + month: 1, + year: 1, + hours: 1, + minutes: 1, + seconds: 1, + fractionalSeconds: 1 + }; + + private onChangeCallback: (...args: any[]) => void = noop; + private _onValidatorChange: (...args: any[]) => void = noop; + + private get datePartDeltas(): DatePartDeltas { + return Object.assign({}, this._datePartDeltas, this.spinDelta); + } + + private get emptyMask(): string { + return this.maskParser.applyMask(null, this.maskOptions); + } + + private get targetDatePart(): DatePart { + // V.K. May 16th, 2022 #11554 Get correct date part in shadow DOM + if (this.document.activeElement === this.nativeElement || + this.document.activeElement?.shadowRoot?.activeElement === this.nativeElement) { + return this._inputDateParts + .find(p => p.start <= this.selectionStart && this.selectionStart <= p.end && p.type !== DatePart.Literal)?.type; + } else { + if (this._inputDateParts.some(p => p.type === DatePart.Date)) { + return DatePart.Date; + } else if (this._inputDateParts.some(p => p.type === DatePart.Hours)) { + return DatePart.Hours; + } + } + } + + private get hasDateParts(): boolean { + return this._inputDateParts.some( + p => p.type === DatePart.Date + || p.type === DatePart.Month + || p.type === DatePart.Year); + } + + private get hasTimeParts(): boolean { + return this._inputDateParts.some( + p => p.type === DatePart.Hours + || p.type === DatePart.Minutes + || p.type === DatePart.Seconds + || p.type === DatePart.FractionalSeconds); + } + + private get dateValue(): Date { + return this._dateValue; + } + + constructor() { + super(); + this.document = this._document as Document; + this.locale = this.locale || this._locale; + } + + @HostListener('wheel', ['$event']) + public onWheel(event: WheelEvent): void { + if (!this._focused) { + return; + } + event.preventDefault(); + event.stopPropagation(); + this._scrollAccumulator += event.deltaY; + if (Math.abs(this._scrollAccumulator) > this.SCROLL_THRESHOLD) { + if (this._scrollAccumulator > 0) { + this.decrement(); + } else { + this.increment(); + } + this._scrollAccumulator = 0; + } + } + + public override ngOnInit(): void { + this.updateDefaultFormat(); + this.setMask(this.inputFormat); + this.updateMask(); + } + + /** @hidden @internal */ + public ngOnChanges(changes: SimpleChanges): void { + if (changes['locale'] && !changes['locale'].firstChange || + changes['defaultFormatType'] && !changes['defaultFormatType'].firstChange + ) { + this.updateDefaultFormat(); + this.setMask(this.inputFormat); + this.updateMask(); + } + if (changes['inputFormat'] && !changes['inputFormat'].firstChange) { + this.updateMask(); + } + } + + + /** Clear the input element value. */ + public clear(): void { + this._onClear = true; + this.updateValue(null); + this.setSelectionRange(0, this.inputValue.length); + this._onClear = false; + } + + /** + * Increment specified DatePart. + * + * @param datePart The optional DatePart to increment. Defaults to Date or Hours (when Date is absent from the inputFormat - ex:'HH:mm'). + * @param delta The optional delta to increment by. Overrides `spinDelta`. + */ + public increment(datePart?: DatePart, delta?: number): void { + const targetPart = datePart || this.targetDatePart; + if (!targetPart) { + return; + } + const newValue = this.trySpinValue(targetPart, delta); + this.updateValue(newValue); + } + + /** + * Decrement specified DatePart. + * + * @param datePart The optional DatePart to decrement. Defaults to Date or Hours (when Date is absent from the inputFormat - ex:'HH:mm'). + * @param delta The optional delta to decrement by. Overrides `spinDelta`. + */ + public decrement(datePart?: DatePart, delta?: number): void { + const targetPart = datePart || this.targetDatePart; + if (!targetPart) { + return; + } + const newValue = this.trySpinValue(targetPart, delta, true); + this.updateValue(newValue); + } + + /** @hidden @internal */ + public override writeValue(value: any): void { + this._value = value; + this.setDateValue(value); + this.updateMask(); + } + + /** @hidden @internal */ + public validate(control: AbstractControl): ValidationErrors | null { + if (!control.value) { + return null; + } + // InvalidDate handling + if (isDate(control.value) && !DateTimeUtil.isValidDate(control.value)) { + return { value: true }; + } + + let errors = {}; + const value = DateTimeUtil.isValidDate(control.value) ? control.value : DateTimeUtil.parseIsoDate(control.value); + const minValueDate = DateTimeUtil.isValidDate(this.minValue) ? this.minValue : this.parseDate(this.minValue); + const maxValueDate = DateTimeUtil.isValidDate(this.maxValue) ? this.maxValue : this.parseDate(this.maxValue); + if (minValueDate || maxValueDate) { + errors = DateTimeUtil.validateMinMax(value, + minValueDate, maxValueDate, + this.hasTimeParts, this.hasDateParts); + } + + return Object.keys(errors).length > 0 ? errors : null; + } + + /** @hidden @internal */ + public registerOnValidatorChange?(fn: () => void): void { + this._onValidatorChange = fn; + } + + /** @hidden @internal */ + public override registerOnChange(fn: any): void { + this.onChangeCallback = fn; + } + + /** @hidden @internal */ + public override registerOnTouched(fn: any): void { + this._onTouchedCallback = fn; + } + + /** @hidden @internal */ + public setDisabledState?(_isDisabled: boolean): void { } + + /** @hidden @internal */ + public override onCompositionEnd(): void { + super.onCompositionEnd(); + + this.updateValue(this.parseDate(this.inputValue)); + this.updateMask(); + } + + /** @hidden @internal */ + public override onInputChanged(event): void { + super.onInputChanged(event); + if (this._composing) { + return; + } + + if (this.inputIsComplete()) { + const parsedDate = this.parseDate(this.inputValue); + if (DateTimeUtil.isValidDate(parsedDate)) { + this.updateValue(parsedDate); + } else { + const oldValue = this.value && new Date(this.dateValue.getTime()); + const args: IgxDateTimeEditorEventArgs = { oldValue, newValue: parsedDate, userInput: this.inputValue }; + this.validationFailed.emit(args); + if (DateTimeUtil.isValidDate(args.newValue)) { + this.updateValue(args.newValue); + } else { + this.updateValue(null); + } + } + } else { + this.updateValue(null); + } + } + + /** @hidden @internal */ + public override onKeyDown(event: KeyboardEvent): void { + if (this.nativeElement.readOnly) { + return; + } + super.onKeyDown(event); + const key = event.key; + + if (event.altKey) { + return; + } + + if (key === this.platform.KEYMAP.ARROW_DOWN || key === this.platform.KEYMAP.ARROW_UP) { + this.spin(event); + return; + } + + if (event.ctrlKey && key === this.platform.KEYMAP.SEMICOLON) { + this.updateValue(new Date()); + } + + this.moveCursor(event); + } + + /** @hidden @internal */ + public override onFocus(): void { + if (this.nativeElement.readOnly) { + return; + } + this._focused = true; + this._onTouchedCallback(); + this.updateMask(); + super.onFocus(); + this.nativeElement.select(); + } + + /** @hidden @internal */ + public override onBlur(event: FocusEvent): void { + this._focused = false; + if (!this.inputIsComplete() && this.inputValue !== this.emptyMask) { + this.updateValue(this.parseDate(this.inputValue)); + } else { + this.updateMask(); + } + + // TODO: think of a better way to set displayValuePipe in mask directive + if (this.displayValuePipe) { + return; + } + + super.onBlur(event); + } + + // the date editor sets its own inputFormat as its placeholder if none is provided + /** @hidden */ + protected override setPlaceholder(_value: string): void { } + + private updateDefaultFormat(): void { + this._defaultInputFormat = DateTimeUtil.getNumericInputFormat(this.locale, this._displayFormat) + || DateTimeUtil.getDefaultInputFormat(this.locale, this.defaultFormatType); + this.setMask(this.inputFormat); + } + + private updateMask(): void { + if (this._focused) { + // store the cursor position as it will be moved during masking + const cursor = this.selectionEnd; + this.inputValue = this.getMaskedValue(); + this.setSelectionRange(cursor); + } else { + if (!this.dateValue || !DateTimeUtil.isValidDate(this.dateValue)) { + this.inputValue = ''; + return; + } + if (this.displayValuePipe) { + // TODO: remove when formatter func has been deleted + this.inputValue = this.displayValuePipe.transform(this.value); + return; + } + const format = this.displayFormat || this.inputFormat; + if (format) { + this.inputValue = DateTimeUtil.formatDate(this.dateValue, format.replace('tt', 'aa'), this.locale); + } else { + this.inputValue = this.dateValue.toLocaleString(); + } + } + } + + private setMask(inputFormat: string): void { + const oldFormat = this._inputDateParts?.map(p => p.format).join(''); + this._inputDateParts = DateTimeUtil.parseDateTimeFormat(inputFormat); + inputFormat = this._inputDateParts.map(p => p.format).join(''); + const mask = (inputFormat || this._defaultInputFormat) + .replace(new RegExp(/(?=[^at])[\w]/, 'g'), '0'); + this.mask = mask.replaceAll(/(a{1,2})|tt/g, match => 'L'.repeat(match.length === 1 ? 1 : 2)); + const placeholder = this.nativeElement.placeholder; + if (!placeholder || oldFormat === placeholder) { + this.renderer.setAttribute(this.nativeElement, 'placeholder', inputFormat); + } + } + + private parseDate(val: string): Date | null { + if (!val) { + return null; + } + + return DateTimeUtil.parseValueFromMask(val, this._inputDateParts, this.promptChar); + } + + private getMaskedValue(): string { + let mask = this.emptyMask; + if (DateTimeUtil.isValidDate(this.value) || DateTimeUtil.parseIsoDate(this.value)) { + for (const part of this._inputDateParts) { + if (part.type === DatePart.Literal) { + continue; + } + const targetValue = this.getPartValue(part, part.format.length); + mask = this.maskParser.replaceInMask(mask, targetValue, this.maskOptions, part.start, part.end).value; + } + return mask; + } + if (!this.inputIsComplete() || !this._onClear) { + return this.inputValue; + } + return mask; + } + + + private valueInRange(value: Date): boolean { + if (!value) { + return false; + } + + let errors = {}; + const minValueDate = DateTimeUtil.isValidDate(this.minValue) ? this.minValue : this.parseDate(this.minValue); + const maxValueDate = DateTimeUtil.isValidDate(this.maxValue) ? this.maxValue : this.parseDate(this.maxValue); + if (minValueDate || maxValueDate) { + errors = DateTimeUtil.validateMinMax(value, + this.minValue, this.maxValue, + this.hasTimeParts, this.hasDateParts); + } + + return Object.keys(errors).length === 0; + } + + private spinValue(datePart: DatePart, delta: number): Date { + if (!this.dateValue || !DateTimeUtil.isValidDate(this.dateValue)) { + return null; + } + const newDate = new Date(this.dateValue.getTime()); + let formatPart; + let amPmFromMask; + switch (datePart) { + case DatePart.Date: + DateTimeUtil.spinDate(delta, newDate, this.spinLoop); + break; + case DatePart.Month: + DateTimeUtil.spinMonth(delta, newDate, this.spinLoop); + break; + case DatePart.Year: + DateTimeUtil.spinYear(delta, newDate); + break; + case DatePart.Hours: + DateTimeUtil.spinHours(delta, newDate, this.spinLoop); + break; + case DatePart.Minutes: + DateTimeUtil.spinMinutes(delta, newDate, this.spinLoop); + break; + case DatePart.Seconds: + DateTimeUtil.spinSeconds(delta, newDate, this.spinLoop); + break; + case DatePart.FractionalSeconds: + DateTimeUtil.spinFractionalSeconds(delta, newDate, this.spinLoop); + break; + case DatePart.AmPm: + formatPart = this._inputDateParts.find(dp => dp.type === DatePart.AmPm); + amPmFromMask = this.inputValue.substring(formatPart.start, formatPart.end); + return DateTimeUtil.spinAmPm(newDate, this.dateValue, amPmFromMask); + } + + return newDate; + } + + private trySpinValue(datePart: DatePart, delta?: number, negative = false): Date { + if (!delta) { + // default to 1 if a delta is set to 0 or any other falsy value + delta = this.datePartDeltas[datePart] || 1; + } + const spinValue = negative ? -Math.abs(delta) : Math.abs(delta); + return this.spinValue(datePart, spinValue) || new Date(); + } + + private setDateValue(value: Date | string): void { + this._dateValue = DateTimeUtil.isValidDate(value) + ? value + : DateTimeUtil.parseIsoDate(value); + } + + private updateValue(newDate: Date): void { + this._oldValue = this.dateValue; + this.value = newDate; + + // TODO: should we emit events here? + if (this.inputIsComplete() || this.inputValue === this.emptyMask) { + this.valueChange.emit(this.dateValue); + } + if (this.dateValue && !this.valueInRange(this.dateValue)) { + this.validationFailed.emit({ oldValue: this._oldValue, newValue: this.dateValue, userInput: this.inputValue }); + } + } + + private toTwelveHourFormat(value: string): number { + let hour = parseInt(value.replace(new RegExp(this.promptChar, 'g'), '0'), 10); + if (hour > 12) { + hour -= 12; + } else if (hour === 0) { + hour = 12; + } + + return hour; + } + + private getPartValue(datePartInfo: DatePartInfo, partLength: number): string { + let maskedValue; + const datePart = datePartInfo.type; + switch (datePart) { + case DatePart.Date: + maskedValue = this.dateValue.getDate(); + break; + case DatePart.Month: + // months are zero based + maskedValue = this.dateValue.getMonth() + 1; + break; + case DatePart.Year: + if (partLength === 2) { + maskedValue = this.prependValue( + parseInt(this.dateValue.getFullYear().toString().slice(-2), 10), partLength, '0'); + } else { + maskedValue = this.dateValue.getFullYear(); + } + break; + case DatePart.Hours: + if (datePartInfo.format.indexOf('h') !== -1) { + maskedValue = this.prependValue( + this.toTwelveHourFormat(this.dateValue.getHours().toString()), partLength, '0'); + } else { + maskedValue = this.dateValue.getHours(); + } + break; + case DatePart.Minutes: + maskedValue = this.dateValue.getMinutes(); + break; + case DatePart.Seconds: + maskedValue = this.dateValue.getSeconds(); + break; + case DatePart.FractionalSeconds: + partLength = 3; + maskedValue = this.prependValue(this.dateValue.getMilliseconds(), 3, '00'); + break; + case DatePart.AmPm: + maskedValue = DateTimeUtil.getAmPmValue(partLength, this.dateValue.getHours() < 12); + break; + } + + if (datePartInfo.type !== DatePart.AmPm) { + return this.prependValue(maskedValue, partLength, '0'); + } + + return maskedValue; + } + + private prependValue(value: number, partLength: number, prependChar: string): string { + return (prependChar + value.toString()).slice(-partLength); + } + + private spin(event: KeyboardEvent): void { + event.preventDefault(); + switch (event.key) { + case this.platform.KEYMAP.ARROW_UP: + this.increment(); + break; + case this.platform.KEYMAP.ARROW_DOWN: + this.decrement(); + break; + } + } + + private inputIsComplete(): boolean { + return this.inputValue.indexOf(this.promptChar) === -1; + } + + private moveCursor(event: KeyboardEvent): void { + const value = (event.target as HTMLInputElement).value; + switch (event.key) { + case this.platform.KEYMAP.ARROW_LEFT: + if (event.ctrlKey) { + event.preventDefault(); + this.setSelectionRange(this.getNewPosition(value)); + } + break; + case this.platform.KEYMAP.ARROW_RIGHT: + if (event.ctrlKey) { + event.preventDefault(); + this.setSelectionRange(this.getNewPosition(value, 1)); + } + break; + } + } + + /** + * Move the cursor in a specific direction until it reaches a date/time separator. + * Then return its index. + * + * @param value The string it operates on. + * @param direction 0 is left, 1 is right. Default is 0. + */ + private getNewPosition(value: string, direction = 0): number { + const literals = this._inputDateParts.filter(p => p.type === DatePart.Literal); + let cursorPos = this.selectionStart; + if (!direction) { + do { + cursorPos = cursorPos > 0 ? --cursorPos : cursorPos; + } while (!literals.some(l => l.end === cursorPos) && cursorPos > 0); + return cursorPos; + } else { + do { + cursorPos++; + } while (!literals.some(l => l.start === cursorPos) && cursorPos < value.length); + return cursorPos; + } + } +} + + diff --git a/projects/igniteui-angular/directives/src/directives/date-time-editor/date-time-editor.module.ts b/projects/igniteui-angular/directives/src/directives/date-time-editor/date-time-editor.module.ts new file mode 100644 index 00000000000..5a79033aaf0 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/date-time-editor/date-time-editor.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { IgxDateTimeEditorDirective } from './date-time-editor.directive'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [IgxDateTimeEditorDirective], + exports: [IgxDateTimeEditorDirective] +}) +export class IgxDateTimeEditorModule { } diff --git a/projects/igniteui-angular/directives/src/directives/date-time-editor/public_api.ts b/projects/igniteui-angular/directives/src/directives/date-time-editor/public_api.ts new file mode 100644 index 00000000000..6c0701a7df0 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/date-time-editor/public_api.ts @@ -0,0 +1,2 @@ +export { IgxDateTimeEditorEventArgs } from './date-time-editor.common'; +export * from './date-time-editor.directive'; diff --git a/projects/igniteui-angular/directives/src/directives/divider/README.md b/projects/igniteui-angular/directives/src/directives/divider/README.md new file mode 100644 index 00000000000..8dbdab3baf5 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/divider/README.md @@ -0,0 +1,43 @@ +# igx-divider + +The **igx-divider** is a directive that groups content in lists and layout. + +## Examples + +### Basic Divider +```html + +``` + +### Dashed Divider +```html + + +``` + +### Vertical Divider +```html + +``` + +### Inset Divider +```html + + +``` + +### Middle Inset Divider +```html + + +``` + +# API Summary +| Name | Type | Description | +|:----------|:-------------:|:------| +| `id` | string | Unique identifier of the component. If not provided it will be automatically generated.| +| `role` | string | The role attribute of the divider. By default it's set to `separator`. | +| `type` | IgxDividerType | The type of the divider. Can be `solid` or `dashed`. | +| `inset` | string | The space between the separator and the surrounding container. Provide the value in `px`, `%`, or relative units(`em`, `rem`). | +| `middle` | boolean | When set to `true`, the divider will be set in on both sides when an `inset` value is provided. | +| `vertical` | boolean | Whether the divider should be vertically layed out. | diff --git a/projects/igniteui-angular/directives/src/directives/divider/divider.directive.ts b/projects/igniteui-angular/directives/src/directives/divider/divider.directive.ts new file mode 100644 index 00000000000..7ea174113cf --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/divider/divider.directive.ts @@ -0,0 +1,121 @@ +import { Directive, HostBinding, Input, booleanAttribute } from '@angular/core'; + +export const IgxDividerType = { + SOLID: 'solid', + DASHED: 'dashed' +} as const; +export type IgxDividerType = (typeof IgxDividerType)[keyof typeof IgxDividerType]; + +let NEXT_ID = 0; + +@Directive({ + selector: 'igx-divider', + standalone: true +}) +export class IgxDividerDirective { + /** + * Sets/gets the `id` of the divider. + * If not set, `id` will have value `"igx-divider-0"`; + * ```html + * + * ``` + * ```typescript + * let dividerId = this.divider.id; + * ``` + */ + @HostBinding('attr.id') + @Input() + public id = `igx-divider-${NEXT_ID++}`; + + /** + * Sets the value of `role` attribute. + * If not the default value of `separator` will be used. + */ + @HostBinding('attr.role') + @Input() + public role = 'separator'; + + /** + * Sets the type of the divider. The default value + * is `default`. The divider can also be `dashed`; + * ```html + * + * ``` + */ + @HostBinding('class.igx-divider') + @Input() + public type: IgxDividerType | string = IgxDividerType.SOLID; + + @HostBinding('class.igx-divider--dashed') + public get isDashed() { + return this.type === IgxDividerType.DASHED; + } + + /** + * If set to `true` and an `inset` value has been provided, + * the divider will start shrinking from both ends. + * ```html + * + * ``` + */ + @HostBinding('class.igx-divider--inset') + @Input({ transform: booleanAttribute }) + public middle = false; + + /** + * Sets the divider in vertical orientation. + * ```html + * + * ``` + */ + @HostBinding('class.igx-divider--vertical') + @Input({ transform: booleanAttribute }) + public vertical = false; + + /** + * Sets the inset of the divider from the side(s). + * If the divider attribute `middle` is set to `true`, + * it will inset the divider on both sides. + * ```typescript + * this.divider.inset = '32px'; + * ``` + */ + @HostBinding('style.--inset') + @Input() + public set inset(value: string) { + this._inset = value; + } + + /** + * Gets the current divider inset in terms of + * inset-inline-start representation as applied to the divider. + * ```typescript + * const inset = this.divider.inset; + * ``` + */ + public get inset() { + return this._inset; + } + + /** + * Sets the value of the `inset` attribute. + * If not provided it will be set to `'0'`. + * ```html + * + * ``` + */ + private _inset = '0'; + + /** + * A getter that returns `true` if the type of the divider is `default`; + * ```typescript + * const isDefault = this.divider.isDefault; + * ``` + */ + public get isSolid() { + return this.type === IgxDividerType.SOLID; + } + +} + + diff --git a/projects/igniteui-angular/directives/src/directives/divider/divider.module.ts b/projects/igniteui-angular/directives/src/directives/divider/divider.module.ts new file mode 100644 index 00000000000..d27b919c126 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/divider/divider.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { IgxDividerDirective } from './divider.directive'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [IgxDividerDirective], + exports: [IgxDividerDirective] +}) +export class IgxDividerModule { } diff --git a/projects/igniteui-angular/directives/src/directives/divider/divider.spec.ts b/projects/igniteui-angular/directives/src/directives/divider/divider.spec.ts new file mode 100644 index 00000000000..3c6eaa204ce --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/divider/divider.spec.ts @@ -0,0 +1,109 @@ +import { Component } from '@angular/core'; +import { TestBed, ComponentFixture, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { IgxDividerDirective, IgxDividerType } from './divider.directive'; + +describe('Divider', () => { + const baseClass = 'igx-divider'; + + const classes = { + dashed: `${baseClass}--dashed`, + vertical: `${baseClass}--vertical`, + inset: `${baseClass}--inset` + }; + + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TestDividerComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TestDividerComponent); + }); + + it('should initialize default divider', () => { + const divider = fixture.debugElement.query(By.css('igx-divider')); + fixture.componentInstance.type = IgxDividerType.SOLID; + fixture.detectChanges(); + + expect(divider.nativeElement).toBeDefined(); + expect(divider.nativeElement).toHaveClass(baseClass); + }); + + it('should initialize dashed divider', () => { + const divider = fixture.debugElement.query(By.css('igx-divider')); + fixture.componentInstance.type = IgxDividerType.DASHED; + fixture.detectChanges(); + + expect(divider.nativeElement).toHaveClass(baseClass); + expect(divider.nativeElement).toHaveClass(classes.dashed); + }); + + it('should initialize vertical divider', () => { + const divider = fixture.debugElement.query(By.css('igx-divider')); + fixture.componentInstance.vertical = true; + fixture.detectChanges(); + + expect(divider.nativeElement).toHaveClass(classes.vertical); + }); + + it('should initialize middle divider', () => { + const divider = fixture.debugElement.query(By.css('igx-divider')); + fixture.componentInstance.middle = true; + fixture.detectChanges(); + + expect(divider.nativeElement).not.toHaveClass(classes.vertical); + expect(divider.nativeElement).toHaveClass(classes.inset); + }); + + it('should initialize middle, vertical divider', () => { + const divider = fixture.debugElement.query(By.css('igx-divider')); + fixture.componentInstance.vertical = true; + fixture.componentInstance.middle = true; + fixture.detectChanges(); + + expect(divider.nativeElement).toHaveClass(classes.vertical); + expect(divider.nativeElement).toHaveClass(classes.inset); + }); + + it('should inset the divider by the specified amount', () => { + const inset = '16px'; + const divider = fixture.debugElement.query(By.css('igx-divider')); + const insetVar = () => window.getComputedStyle(divider.nativeElement).getPropertyValue('--inset'); + fixture.componentInstance.inset = inset; + fixture.detectChanges(); + + expect(insetVar()).toEqual(`${inset}`); + }); + + it('should change the role of the divider to the specified value', () => { + const role = 'foo'; + const divider = fixture.debugElement.query(By.css('igx-divider')); + fixture.componentInstance.role = role; + fixture.detectChanges(); + + expect(divider.nativeElement.getAttribute('role')).toEqual(role); + }); +}); + +@Component({ + template: ` + `, + imports: [IgxDividerDirective] +}) +class TestDividerComponent { + public type: string; + public vertical: boolean; + public middle: boolean; + public inset: string; + public role: string; +} diff --git a/projects/igniteui-angular/directives/src/directives/drag-drop/README.md b/projects/igniteui-angular/directives/src/directives/drag-drop/README.md new file mode 100644 index 00000000000..33f5c80ea4b --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/drag-drop/README.md @@ -0,0 +1,296 @@ +# igxDrag +**igxDrag** is a directive that enables dragging of elements around the page. + +## Usage +```html +
+ {{elem.label}} +
+``` + +## Getting Started + +### Introduction + +The `igxDrag` directive can be instantiated on any type of element. It can be used on its own without depending on the `igxDrop`. It should provide enough functionality so the user could determine where it has been released and so implements a custom logic. + +Specific data can be stored inside the `igxDrag` for various purposes like identifying it among other draggable elements and etc. It can be specified by assigning it on the initialization tag `[igxDrag]` or by using the data input where it is stored: + +```html +
Drag me!
+``` + +By default the dragging will not start immediately in order to provide some room for error as well as not interrupt if the user wants to click the element instead. The tolerance for it is `5px` in any direction and if it is exceeded then the dragging would start. This can be configured using the `dragTolerance` input. + +#### Dragging with ghost element + +The `ghost` input is set to `true` by default which means that the base element the `igxDrag` directive is initialized will keep its position and a ghost will be rendered under the user pointer once the dragging starts. While still holding and moving the ghost created will move along the user pointer instead of the base element. + +* *Customizing the ghost* + + The ghost element by default is a copy of the base element the `igxDrag` is used on. It can be customized by providing a template reference to the `ghostTemplate` input directly. The template itself can be position anyway, since the only thing provided is reference to it. It can be done the following way: + + ```html +
+ Drag me! +
+ +
I can fly!
+
+ ``` + +* *Customizing the base* + + Since when using a ghost element leaves us with the base element being still rendered at its original location we can hide it by setting applying custom visibility style when dragging starts or by completely replacing its content using `ngIf`. + + Hiding the base element: + ```html +
+ Drag me! +
+ ``` + + Customizing the base content: + + ```html +
+
Drag me!
+ Origin! +
+ ``` + +#### Dragging without ghost + +If `ghost` input is set to false the dragging logic for the `igxDrag` provides dragging ability for the initialized element itself. This means that it can freely move an element around by click and hold and when released it will keep its position where it was dragged. + +#### Dragging using a handle + +The user can specify an element that is a child of the `igxDrag` by which to drag since by default the whole element is used to perform that action. It can be done using the `igxDragHandle` directive and can be applied to multiple elements inside the `igxDrag`. + +When multiple channels are applied to an `igxDrag` and one of them matches one of applied channels to an `igxDrop`, then all events and applied behaviors would be executed when that element is dropped. + +*Example:* + +```html +
+
X
+ Drag me! +
+``` + +#### Linking Drag to Drop element + +Using the `dragChannel` and `dropChannel` input on respectively `igxDrag` and `igxDrop` directives the user can link different elements to interact only between each other. For example if an `igxDrag` element needs to be constrained so it can be dropped on specific `igxDrop` element and not all available this can easily be achieved by assigning them same channel. + +>**When assigning either single or multiple channels using an array, each channel is compared using the `Strict Equality` comparison.** + +*Example:* + +```html +
Human
+
Dolphin
+
Butterfly
+
Ant
+ +
Mammals
+
Insects
+
Land
+``` + +As displayed above only `Human` and `Dolphin` can be dropped in the 'Mammals' class but not in the 'Insects' class, where only the `Butterfly` and `Bee` can be dropped. Same for the 'Land' drop area where only `Ant` and `Human` can be dropped. + +#### Animations + +By default when an element is being dragged there are no animations applied. + +The user can apply transition animation to the `igxDrag` any time, but it is advised to use it when dragging ends or the element is not currently dragged. This can be achieved using the `transitionToOrigin` and the `transitionTo` methods. + +The `transitionToOrigin` method as the name suggest animates the currently dragged element or its ghost to the start position where the dragging began. The `transitionTo` method animates the element to a specific location relative to the page (i.e. `pageX` and `pageY`) or to the position of a specified element. If the element is not being currently dragged it will animate anyway or create ghost and animate it to the desired position. + +Both function have arguments that the user can set to customize the transition animation and set duration, timing function or delay. If specific start location is set it will animate the element starting from there. + +When the transition animation ends if a ghost is created it will be removed and the `igxDrag` directive will return to its initial state or if no ghost is created it will keep its position. In both cases then the `transitioned` event will be triggered depending on how long the animation lasts. If no animation is applied it will triggered instantly. + +If the user want to have other types of animations that involve element transformations he can do that like any other element either using the Angular Animations or straight CSS Animations to either the base `igxDrag` element or its ghost. If he wants to apply them to the ghost he would need to define a custom ghost and apply animations to that element. + +## API + +### Inputs + +| Name | Type | Default Value | Description | +| :--- | :--- | :--- | :--- | +| `igxDrag` | any | - | Input used to save data inside the `igxDrag` directive. This can be set when instancing `igxDrag` on an element. | +| `dragTolerance` | number | 5 | Indicates when the drag should start (in pixels). By default the drag starts after the draggable element is moved by 5px | +| `ghostHost` | any | null | Sets the element to which the dragged element will be appended. +| `ghostClass` | string | '' | Sets a custom class that will be added to the `igxDrag` element. | + +### Outputs + +| Name | Description | Cancelable | Parameters | +|------|-------------|------------|------------| +| `dragStart` | Event triggered when any movement starts. | true | `IDragBaseEventArgs` | +| `dragMove` | Event triggered for every frame where the `igxDrag` element has been dragged. | true | `IDragMoveEventArgs` | +| `dragEnd` | Event triggered when the user releases the element area that is not inside an `igxDrop`. This is triggered before any animation starts. | false | `IDragBaseEventArgs` | +| `click` | Even triggered when the user performs a click and not dragging. This is the native event. | false | MouseEvent | +| `transitioned` | Event triggered after any movement of the drag element has ended. This is triggered after all animations have ended and before the ghost is removed. | false | `IDragBaseEventArgs` | +| `ghostCreate` | Event triggered right before the ghost element is created | false | `IDragGhostBaseEventArgs` | +| `ghostDestroy` | Event triggered right before the ghost element is destroyed | false | `IDragGhostBaseEventArgs` | + +### Properties + +| Name | Description | Type | +|------|-------------|------| +| `location` | Gets the current location of the element relative to the page. If ghost is enabled it will get the location of the ghost, if the user is not currently dragging it will return the location of the base element. | [`IgxDragLocation`](#IgxDragLocation) | +| `originLocation` | Gets the origin location of the element before dragging started. If ghost is enabled it will get the location of the base. | [`IgxDragLocation`](#IgxDragLocation) | + +
+ +### Methods + +| Name | Description | Parameters | Return Type | +|------|-------------|------------|-------------| +| `setLocation` | Sets new location for the igxDrag directive. When ghost is enable and it is not rendered it will be ignored. | `newLocation?:` [`IgxDragLocation`](#IgxDragLocation) | void | +| `transitionToOrigin` | Animates the element from its current location to its initial position. If it was not moved or no start location is specified nothing would happen . | customTransitionArgs?: [`IDragCustomTransitionArgs`](#IDragCustomTransitionArgs), `startLocation?:` [`IgxDragLocation`](#IgxDragLocation), | void | +| `transitionTo` | Animates the element from its current location to specific location or DOM element. If it was not moved or no start location is specified nothing would happen. | `target:` [`IgxDragLocation`](#IgxDragLocation)\|ElementRef, customTransitionArgs?: [`IDragCustomTransitionArgs`](#IDragCustomTransitionArgs), `startLocation?:` [`IgxDragLocation`](#IgxDragLocation) | void | + +# igxDrop + +`igxDrop` directive is used in combination with the `igxDrag` directive to add behavior when element needs to be dropped in an area. + +## Usage +````html +
+ Drag here. + Release to put element here. +
+```` + +````ts +//App component... +public onAreaEnter() { + this.elementInsideArea = true; + this.changeDetectionRef.detectChanges(); +} +public onAreaLeave() { + this.elementInsideArea = false; + this.changeDetectionRef.detectChanges(); +} +//... +```` + +## Getting Started + +### Introduction + +For achieving a drop functionality with the `igxDrag` directive the `igxDrop` directive should be used. It can be applied on any kind of element and it specifies an area where the `igxDrag` can be dropped. + +By default the `igxDrop` does not apply any logic to the dragged element when it is dropped onto it. The user could choose between a few different drop strategies if he would like the `igxDrop` to perform some action or he could implement his own drop logic using the provided `dropped` event. + +#### Drop Strategies + +The `igxDrop` comes with 4 drop strategies which are defined in the enum `IgxDropStrategy` and has the following values - `Default`, `Append`, `Prepend`, `Insert`: + +* The `Default` strategy does not perform any action when an element is dropped onto an IgxDrop element and is implemented as a class named `IgxDefaultDropStrategy`. + +* As the names suggest the first `Append` strategy inserts the dropped element as a last child and is implemented as a class named `IgxAppendDropStrategy`. + +* The `Prepend` strategy inserts the dropped element as first child and is implemented as a class named `IgxPrependDropStrategy`. + +* The `Insert` strategy inserts the dragged element at the dropped position. If there is a child under the element when it was dropped, the `igxDrag` instanced element will be inserted at that child's index. It is implemented as a class named `IgxInsertDropStrategy` + +The way a strategy can be applied is by setting the `dropStrategy` input to one of the listed classes above. The value provided has to be e type and not an instance, since the `igxDrop` has to create the instance itself. + +**Example:** + +TypeScript: +```typescript +public insertStrategy = IgxInsertDropStrategy; +``` + +HTML: +```html +
+``` + +#### Canceling a Drop Strategy + +When using a specific drop strategy, its behavior can be canceled in the [`dropped`]({environment:angularApiUrl}/classes/igxdropdirective.html#dropped) events by setting the `cancel` property to true. The `dropped` event is specific to the `igxDrop`. If you does not have drop strategy applied to the `igxDrop` canceling the event would have no side effects. + +*Example:* + +HTML +```html +
+ +
+``` + +TypeScript +```typescript +public onDropped(event) { + event.cancel = true; +} +``` + +If the user would like to implement its own drop logic it can easily be done by binding to `dropped` and executing their logic when the event is triggered or extending the default drop strategy. + +#### Animations + +If the user decides that he want to use transition animations when dropping an element he can do that by using transition animations that can be applied to the `igxDrag` by calling the `transitionToOrigin` or `transitionTo` methods whenever he wants. Preferably that should be done when dragging of an element ends or when it is dropped onto a `igxDrop` instanced element. + +*Example:* + +HTML +```html +
Products:
+
+
+ {{product} +
+
+ +
Basket:
+
+
{{product}}
+
+``` + +TypeScript +```typescript +public availableProducts = ["milk", "cheese", "banana"]; +public basketProducts = []; + +public onDragEnd(event) { + event.owner.transitionToOrigin(); +} +public onDragDropped(event) { + event.drag.transitionTo(event.dropDirective.element); +} +public onDragAnimationEnd(event) { + const removeIndex = event.owner.data.index; + const removedElem = availableProducts.splice(removeIndex, 1); + basketProducts.push(removedElem); +} +``` + +## API + +### Inputs + +| Name | Description | Type | Default value | +|------|-------------|------|---------------| +| `data` | Sets information to be stored in the directive. | any | undefined | +| `dropChannel` | Specifies channel or multiple channels to which the element is linked to and can interact with only those `igxDrag` elements in those channels | number \| string \| number[] \| string[] | undefined | +| `dropStrategy` | Sets a drop strategy that should be applied once an element is dropped into the current `igxDrop` element. | class reference | IgxDefaultDropStrategy | + +### Outputs + +| Name | Description | Cancelable | Type | +|------|-------------|------------|------------| +| `enter` | Event triggered once an `IgxDrag` instanced element enters the boundaries of the drop area. Similar to *MouseEnter*. | false | [`IDropBaseEventArgs`](#IDropBaseEventArgs) | +| `over` | Event triggered when an `IgxDrag` instanced element moves inside the boundaries of the drop area similar to *MouseOver*. | false | [`IDropBaseEventArgs`](#IDropBaseEventArgs) | +| `leave` | Event triggered once an `IgxDrag` instanced element leaves the boundaries of the drop area. Similar to *MouseLeave*. | false | [`IDropBaseEventArgs`](#IDropBaseEventArgs) | +| `dropped` | Event triggered once an `IgxDrag` instanced element inside the drop area is released. | true | [`IDropDragDropEventArgs`](#IDropDragDropEventArgs) | diff --git a/projects/igniteui-angular/directives/src/directives/drag-drop/drag-drop.directive.ts b/projects/igniteui-angular/directives/src/directives/drag-drop/drag-drop.directive.ts new file mode 100644 index 00000000000..23dd0b25719 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/drag-drop/drag-drop.directive.ts @@ -0,0 +1,1991 @@ +import { + Directive, + ElementRef, + EventEmitter, + HostBinding, + HostListener, + Input, + NgZone, + OnDestroy, + OnInit, + Output, + Renderer2, + ChangeDetectorRef, + ViewContainerRef, + AfterContentInit, + TemplateRef, + ContentChildren, + QueryList, + RendererStyleFlags2, + booleanAttribute, + EmbeddedViewRef, + inject, + DOCUMENT +} from '@angular/core'; +import { animationFrameScheduler, fromEvent, interval, Subject } from 'rxjs'; +import { takeUntil, throttle } from 'rxjs/operators'; +import { IBaseEventArgs, PlatformUtil } from 'igniteui-angular/core'; +import { IDropStrategy, IgxDefaultDropStrategy } from './drag-drop.strategy'; + +enum DragScrollDirection { + UP, + DOWN, + LEFT, + RIGHT +} + +export enum DragDirection { + VERTICAL, + HORIZONTAL, + BOTH +} + +export interface IgxDragCustomEventDetails { + startX: number; + startY: number; + pageX: number; + pageY: number; + owner: IgxDragDirective; + originalEvent: any; +} + +export interface IDropBaseEventArgs extends IBaseEventArgs { + /** + * Reference to the original event that caused the draggable element to enter the igxDrop element. + * Can be PointerEvent, TouchEvent or MouseEvent. + */ + originalEvent: any; + /** The owner igxDrop directive that triggered this event. */ + owner: IgxDropDirective; + /** The igxDrag directive instanced on an element that entered the area of the igxDrop element */ + drag: IgxDragDirective; + /** The data contained for the draggable element in igxDrag directive. */ + dragData: any; + /** The initial position of the pointer on X axis when the dragged element began moving */ + startX: number; + /** The initial position of the pointer on Y axis when the dragged element began moving */ + startY: number; + /** + * The current position of the pointer on X axis when the event was triggered. + * Note: The browser might trigger the event with some delay and pointer would be already inside the igxDrop. + */ + pageX: number; + /** + * The current position of the pointer on Y axis when the event was triggered. + * Note: The browser might trigger the event with some delay and pointer would be already inside the igxDrop. + */ + pageY: number; + /** + * The current position of the pointer on X axis relative to the container that initializes the igxDrop. + * Note: The browser might trigger the event with some delay and pointer would be already inside the igxDrop. + */ + offsetX: number; + /** + * The current position of the pointer on Y axis relative to the container that initializes the igxDrop. + * Note: The browser might trigger the event with some delay and pointer would be already inside the igxDrop. + */ + offsetY: number; +} + +export interface IDropDroppedEventArgs extends IDropBaseEventArgs { + /** Specifies if the default drop logic related to the event should be canceled. */ + cancel: boolean; +} + +export interface IDragBaseEventArgs extends IBaseEventArgs { + /** + * Reference to the original event that caused the interaction with the element. + * Can be PointerEvent, TouchEvent or MouseEvent. + */ + originalEvent: PointerEvent | MouseEvent | TouchEvent; + /** The owner igxDrag directive that triggered this event. */ + owner: IgxDragDirective; + /** The initial position of the pointer on X axis when the dragged element began moving */ + startX: number; + /** The initial position of the pointer on Y axis when the dragged element began moving */ + startY: number; + /** + * The current position of the pointer on X axis when the event was triggered. + * Note: The browser might trigger the event with some delay and pointer would be already inside the igxDrop. + */ + pageX: number; + /** + * The current position of the pointer on Y axis when the event was triggered. + * Note: The browser might trigger the event with some delay and pointer would be already inside the igxDrop. + */ + pageY: number; +} + +export interface IDragStartEventArgs extends IDragBaseEventArgs { + /** Set if the the dragging should be canceled. */ + cancel: boolean; +} + +export interface IDragMoveEventArgs extends IDragStartEventArgs { + /** The new pageX position of the pointer that the igxDrag will use. It can be overridden to limit dragged element X movement. */ + nextPageX: number; + /** The new pageX position of the pointer that the igxDrag will use. It can be overridden to limit dragged element Y movement. */ + nextPageY: number; +} + + +export interface IDragGhostBaseEventArgs extends IBaseEventArgs { + /** The owner igxDrag directive that triggered this event. */ + owner: IgxDragDirective; + /** Instance to the ghost element that is created when dragging starts. */ + ghostElement: any; + /** Set if the ghost creation/destruction should be canceled. */ + cancel: boolean; +} + +export interface IDragCustomTransitionArgs { + duration?: number; + timingFunction?: string; + delay?: number; +} + +export class IgxDragLocation { + public pageX: number; + public pageY: number; + + constructor(private _pageX, private _pageY) { + this.pageX = parseFloat(_pageX); + this.pageY = parseFloat(_pageY); + } +} + +@Directive({ + selector: '[igxDragHandle]', + standalone: true +}) +export class IgxDragHandleDirective { + public element = inject(ElementRef); + + @HostBinding('class.igx-drag__handle') + public baseClass = true; + + /** + * @hidden + */ + public parentDragElement: HTMLElement = null; +} + +@Directive({ + selector: '[igxDragIgnore]', + standalone: true +}) +export class IgxDragIgnoreDirective { + public element = inject(ElementRef); + + @HostBinding('class.igx-drag__ignore') + public baseClass = true; +} + +@Directive({ + exportAs: 'drag', + selector: '[igxDrag]', + standalone: true +}) +export class IgxDragDirective implements AfterContentInit, OnDestroy { + /** + * - Save data inside the `igxDrag` directive. This can be set when instancing `igxDrag` on an element. + * ```html + *
+ * ``` + * + * @memberof IgxDragDirective + */ + @Input('igxDrag') + public set data(value: any) { + this._data = value; + } + + public get data(): any { + return this._data; + } + + /** + * Sets the tolerance in pixels before drag starts. + * By default the drag starts after the draggable element is moved by 5px. + * ```html + *
+ * Drag Me! + *
+ * ``` + * + * @memberof IgxDragDirective + */ + @Input() + public dragTolerance = 5; + + /** + * Sets the directions that the element can be dragged. + * By default it is set to both horizontal and vertical directions. + * ```html + *
+ * Drag Me! + *
+ * ``` + * ```typescript + * public dragDir = DragDirection.HORIZONTAL; + * ``` + * + * @memberof IgxDragDirective + */ + @Input() + public dragDirection = DragDirection.BOTH; + + /** + * A property that provides a way for igxDrag and igxDrop to be linked through channels. + * It accepts single value or an array of values and evaluates then using strict equality. + * ```html + *
+ * 95 + *
+ *
+ * Numbers drop area! + *
+ * ``` + * + * @memberof IgxDragDirective + */ + @Input() + public dragChannel: number | string | number[] | string[]; + + /** + * Sets whether the base element should be moved, or a ghost element should be rendered that represents it instead. + * By default it is set to `true`. + * If it is set to `false` when dragging the base element is moved instead and no ghost elements are rendered. + * ```html + *
+ * Drag Me! + *
+ * ``` + * + * @memberof IgxDragDirective + */ + @Input({ transform: booleanAttribute }) + public ghost = true; + + /** + * Sets a custom class that will be added to the `ghostElement` element. + * ```html + *
+ * Drag Me! + *
+ * ``` + * + * @memberof IgxDragDirective + */ + @Input() + public ghostClass = ''; + + /** + * Set styles that will be added to the `ghostElement` element. + * ```html + *
+ * Drag Me! + *
+ * ``` + * + * @memberof IgxDragDirective + */ + @Input() + public ghostStyle = {}; + + /** + * Specifies a template for the ghost element created when dragging starts and `ghost` is true. + * By default a clone of the base element the igxDrag is instanced is created. + * ```html + *
+ * Drag Me! + *
+ * + *
+ * I am being dragged! + *
+ *
+ * ``` + * + * @memberof IgxDragDirective + */ + @Input() + public ghostTemplate: TemplateRef; + + /** + * Sets the element to which the dragged element will be appended. + * By default it's set to null and the dragged element is appended to the body. + * ```html + *
+ *
+ * Drag Me! + *
+ * ``` + * + * @memberof IgxDragDirective + */ + @Input() + public ghostHost; + + /** + * Overrides the scroll container of the dragged element. By default its the window. + */ + @Input() + public scrollContainer: HTMLElement = null + + /** + * Event triggered when the draggable element drag starts. + * ```html + *
+ * Drag Me! + *
+ * ``` + * ```typescript + * public onDragStart(){ + * alert("The drag has stared!"); + * } + * ``` + * + * @memberof IgxDragDirective + */ + @Output() + public dragStart = new EventEmitter(); + + /** + * Event triggered when the draggable element has been moved. + * ```html + *
+ * Drag Me! + *
+ * ``` + * ```typescript + * public onDragMove(){ + * alert("The element has moved!"); + * } + * ``` + * + * @memberof IgxDragDirective + */ + @Output() + public dragMove = new EventEmitter(); + + /** + * Event triggered when the draggable element is released. + * ```html + *
+ * Drag Me! + *
+ * ``` + * ```typescript + * public onDragEnd(){ + * alert("The drag has ended!"); + * } + * ``` + * + * @memberof IgxDragDirective + */ + @Output() + public dragEnd = new EventEmitter(); + + /** + * Event triggered when the draggable element is clicked. + * ```html + *
+ * Drag Me! + *
+ * ``` + * ```typescript + * public onDragClick(){ + * alert("The element has been clicked!"); + * } + * ``` + * + * @memberof IgxDragDirective + */ + @Output() + public dragClick = new EventEmitter(); + + /** + * Event triggered when the drag ghost element is created. + * ```html + *
+ * Drag Me! + *
+ * ``` + * ```typescript + * public ghostCreated(){ + * alert("The ghost has been created!"); + * } + * ``` + * + * @memberof IgxDragDirective + */ + @Output() + public ghostCreate = new EventEmitter(); + + /** + * Event triggered when the drag ghost element is created. + * ```html + *
+ * Drag Me! + *
+ * ``` + * ```typescript + * public ghostDestroyed(){ + * alert("The ghost has been destroyed!"); + * } + * ``` + * + * @memberof IgxDragDirective + */ + @Output() + public ghostDestroy = new EventEmitter(); + + /** + * Event triggered after the draggable element is released and after its animation has finished. + * ```html + *
+ * Drag Me! + *
+ * ``` + * ```typescript + * public onMoveEnd(){ + * alert("The move has ended!"); + * } + * ``` + * + * @memberof IgxDragDirective + */ + @Output() + public transitioned = new EventEmitter(); + + /** + * @hidden + */ + @ContentChildren(IgxDragHandleDirective, { descendants: true }) + public dragHandles: QueryList; + + /** + * @hidden + */ + @ContentChildren(IgxDragIgnoreDirective, { descendants: true }) + public dragIgnoredElems: QueryList; + + /** + * @hidden + */ + @HostBinding('class.igx-drag') + public baseClass = true; + + /** + * @hidden + */ + @HostBinding('class.igx-drag--select-disabled') + public selectDisabled = false; + + + /** + * Gets the current location of the element relative to the page. + */ + public get location(): IgxDragLocation { + return new IgxDragLocation(this.pageX, this.pageY); + } + + /** + * Gets the original location of the element before dragging started. + */ + public get originLocation(): IgxDragLocation { + return new IgxDragLocation(this.baseOriginLeft, this.baseOriginTop); + } + + /** + * @hidden + */ + public get pointerEventsEnabled() { + return typeof PointerEvent !== 'undefined'; + } + + /** + * @hidden + */ + public get touchEventsEnabled() { + return 'ontouchstart' in window; + } + + /** + * @hidden + */ + public get pageX() { + if (this.ghost && this.ghostElement) { + return this.ghostLeft; + } + return this.baseLeft + this.windowScrollLeft; + } + + /** + * @hidden + */ + public get pageY() { + if (this.ghost && this.ghostElement) { + return this.ghostTop; + } + return this.baseTop + this.windowScrollTop; + } + + protected get baseLeft(): number { + return this.element.nativeElement.getBoundingClientRect().left; + } + + protected get baseTop(): number { + return this.element.nativeElement.getBoundingClientRect().top; + } + + protected get baseOriginLeft(): number { + return this.baseLeft - this.getTransformX(this.element.nativeElement); + } + + protected get baseOriginTop(): number { + return this.baseTop - this.getTransformY(this.element.nativeElement); + } + + protected set ghostLeft(pageX: number) { + if (this.ghostElement) { + // We need to take into account marginLeft, since top style does not include margin, but pageX includes the margin. + const ghostMarginLeft = parseInt(this.document.defaultView.getComputedStyle(this.ghostElement)['margin-left'], 10); + // If ghost host is defined it needs to be taken into account. + this.ghostElement.style.left = (pageX - ghostMarginLeft - this._ghostHostX) + 'px'; + } + } + + protected get ghostLeft() { + if (this.ghostElement) { + return parseInt(this.ghostElement.style.left, 10) + this._ghostHostX; + } + } + + protected set ghostTop(pageY: number) { + if (this.ghostElement) { + // We need to take into account marginTop, since top style does not include margin, but pageY includes the margin. + const ghostMarginTop = parseInt(this.document.defaultView.getComputedStyle(this.ghostElement)['margin-top'], 10); + // If ghost host is defined it needs to be taken into account. + this.ghostElement.style.top = (pageY - ghostMarginTop - this._ghostHostY) + 'px'; + } + } + + protected get ghostTop() { + if (this.ghostElement) { + return parseInt(this.ghostElement.style.top, 10) + this._ghostHostY; + } + } + + protected get windowScrollTop() { + return this.document.documentElement.scrollTop || window.scrollY; + } + + protected get windowScrollLeft() { + return this.document.documentElement.scrollLeft || window.scrollX; + } + + protected get windowScrollHeight() { + return this.document.documentElement.scrollHeight; + } + + protected get windowScrollWidth() { + return this.document.documentElement.scrollWidth; + } + + /** + * @hidden + */ + public defaultReturnDuration = '0.5s'; + + /** + * @hidden + */ + public ghostElement; + + /** + * @hidden + */ + public animInProgress = false; + + protected ghostContext: any = null; + protected _startX = 0; + protected _startY = 0; + protected _lastX = 0; + protected _lastY = 0; + protected _dragStarted = false; + + /** Drag ghost related properties */ + protected _defaultOffsetX; + protected _defaultOffsetY; + protected _offsetX; + protected _offsetY; + protected _ghostStartX; + protected _ghostStartY; + protected _ghostHostX = 0; + protected _ghostHostY = 0; + protected _dynamicGhostRef: EmbeddedViewRef; + + protected _pointerDownId = null; + protected _clicked = false; + protected _lastDropArea = null; + + protected _destroy = new Subject(); + protected _removeOnDestroy = true; + protected _data: any; + protected _scrollContainer = null; + protected _originalScrollContainerWidth = 0; + protected _originalScrollContainerHeight = 0; + protected _scrollContainerStep = 5; + protected _scrollContainerStepMs = 10; + protected _scrollContainerThreshold = 25; + protected _containerScrollIntervalId = null; + private document = inject(DOCUMENT); + + /** + * Sets the offset of the dragged element relative to the mouse in pixels. + * By default it's taking the relative position to the mouse when the drag started and keeps it the same. + * ```html + *
+ *
+ * Drag Me! + *
+ * ``` + * + * @memberof IgxDragDirective + */ + @Input() + public set ghostOffsetX(value) { + this._offsetX = parseInt(value, 10); + } + + public get ghostOffsetX() { + return this._offsetX !== undefined ? this._offsetX : this._defaultOffsetX; + } + + /** + * Sets the offset of the dragged element relative to the mouse in pixels. + * By default it's taking the relative position to the mouse when the drag started and keeps it the same. + * ```html + *
+ *
+ * Drag Me! + *
+ * ``` + * + * @memberof IgxDragDirective + */ + @Input() + public set ghostOffsetY(value) { + this._offsetY = parseInt(value, 10); + } + + public get ghostOffsetY() { + return this._offsetY !== undefined ? this._offsetY : this._defaultOffsetY; + } + + public cdr = inject(ChangeDetectorRef); + public element = inject(ElementRef); + public viewContainer = inject(ViewContainerRef); + public zone = inject(NgZone); + public renderer = inject(Renderer2); + protected platformUtil = inject(PlatformUtil); + + constructor() { + this.onTransitionEnd = this.onTransitionEnd.bind(this); + this.onPointerMove = this.onPointerMove.bind(this); + this.onPointerUp = this.onPointerUp.bind(this); + this.onPointerLost = this.onPointerLost.bind(this); + } + + /** + * @hidden + */ + public ngAfterContentInit() { + if (!this.dragHandles || !this.dragHandles.length) { + // Set user select none to the whole draggable element if no drag handles are defined. + this.selectDisabled = true; + } + + // Bind events + this.zone.runOutsideAngular(() => { + if (!this.platformUtil.isBrowser) { + return; + } + const targetElements = this.dragHandles && this.dragHandles.length + ? this.dragHandles + .filter(item => item.parentDragElement === null) + .map(item => { + item.parentDragElement = this.element.nativeElement; + return item.element.nativeElement; + }) + : [this.element.nativeElement]; + targetElements.forEach((element) => { + if (this.pointerEventsEnabled) { + fromEvent(element, 'pointerdown').pipe(takeUntil(this._destroy)) + .subscribe((res) => this.onPointerDown(res)); + + fromEvent(element, 'pointermove').pipe( + throttle(() => interval(0, animationFrameScheduler)), + takeUntil(this._destroy) + ).subscribe((res) => this.onPointerMove(res)); + + fromEvent(element, 'pointerup').pipe(takeUntil(this._destroy)) + .subscribe((res) => this.onPointerUp(res)); + + if (!this.ghost) { + // Do not bind `lostpointercapture` to the target, because we will bind it on the ghost later. + fromEvent(element, 'lostpointercapture').pipe(takeUntil(this._destroy)) + .subscribe((res) => this.onPointerLost(res)); + } + } else if (this.touchEventsEnabled) { + fromEvent(element, 'touchstart').pipe(takeUntil(this._destroy)) + .subscribe((res) => this.onPointerDown(res)); + } else { + // We don't have pointer events and touch events. Use then mouse events. + fromEvent(element, 'mousedown').pipe(takeUntil(this._destroy)) + .subscribe((res) => this.onPointerDown(res)); + } + }); + + // We should bind to document events only once when there are no pointer events. + if (!this.pointerEventsEnabled && this.touchEventsEnabled) { + fromEvent(this.document.defaultView, 'touchmove').pipe( + throttle(() => interval(0, animationFrameScheduler)), + takeUntil(this._destroy) + ).subscribe((res) => this.onPointerMove(res)); + + fromEvent(this.document.defaultView, 'touchend').pipe(takeUntil(this._destroy)) + .subscribe((res) => this.onPointerUp(res)); + } else if (!this.pointerEventsEnabled) { + fromEvent(this.document.defaultView, 'mousemove').pipe( + throttle(() => interval(0, animationFrameScheduler)), + takeUntil(this._destroy) + ).subscribe((res) => this.onPointerMove(res)); + + fromEvent(this.document.defaultView, 'mouseup').pipe(takeUntil(this._destroy)) + .subscribe((res) => this.onPointerUp(res)); + } + this.element.nativeElement.addEventListener('transitionend', this.onTransitionEnd); + }); + + // Set transition duration to 0s. This also helps with setting `visibility: hidden` to the base to not lag. + this.element.nativeElement.style.transitionDuration = '0.0s'; + } + + /** + * @hidden + */ + public ngOnDestroy() { + this._destroy.next(true); + this._destroy.complete(); + + if (this.ghostElement) { + if (this._removeOnDestroy) { + this.clearGhost(); + } else { + this.detachGhost(); + } + } + + this.element.nativeElement.removeEventListener('transitionend', this.onTransitionEnd); + + if (this._containerScrollIntervalId) { + clearInterval(this._containerScrollIntervalId); + this._containerScrollIntervalId = null; + } + } + + /** + * Sets desired location of the base element or ghost element if rended relative to the document. + * + * @param newLocation New location that should be applied. It is advised to get new location using getBoundingClientRects() + scroll. + */ + public setLocation(newLocation: IgxDragLocation) { + // We do not subtract marginLeft and marginTop here because here we calculate deltas. + if (this.ghost && this.ghostElement) { + this.ghostLeft = newLocation.pageX + this.windowScrollLeft; + this.ghostTop = newLocation.pageY + this.windowScrollTop; + } else if (!this.ghost) { + const deltaX = newLocation.pageX - this.pageX; + const deltaY = newLocation.pageY - this.pageY; + const transformX = this.getTransformX(this.element.nativeElement); + const transformY = this.getTransformY(this.element.nativeElement); + this.setTransformXY(transformX + deltaX, transformY + deltaY); + } + + this._startX = this.baseLeft; + this._startY = this.baseTop; + } + + /** + * Animates the base or ghost element depending on the `ghost` input to its initial location. + * If `ghost` is true but there is not ghost rendered, it will be created and animated. + * If the base element has changed its DOM position its initial location will be changed accordingly. + * + * @param customAnimArgs Custom transition properties that will be applied when performing the transition. + * @param startLocation Start location from where the transition should start. + */ + public transitionToOrigin(customAnimArgs?: IDragCustomTransitionArgs, startLocation?: IgxDragLocation) { + if ((!!startLocation && startLocation.pageX === this.baseOriginLeft && startLocation.pageY === this.baseOriginLeft) || + (!startLocation && this.ghost && !this.ghostElement)) { + return; + } + + if (!!startLocation && startLocation.pageX !== this.pageX && startLocation.pageY !== this.pageY) { + if (this.ghost && !this.ghostElement) { + this._startX = startLocation.pageX; + this._startY = startLocation.pageY; + this._ghostStartX = this._startX; + this._ghostStartY = this._startY; + this.createGhost(this._startX, this._startY); + } + + this.setLocation(startLocation); + } + + this.animInProgress = true; + // Use setTimeout because we need to be sure that the element is positioned first correctly if there is start location. + setTimeout(() => { + if (this.ghost) { + this.ghostElement.style.transitionProperty = 'top, left'; + this.ghostElement.style.transitionDuration = + customAnimArgs && customAnimArgs.duration ? customAnimArgs.duration + 's' : this.defaultReturnDuration; + this.ghostElement.style.transitionTimingFunction = + customAnimArgs && customAnimArgs.timingFunction ? customAnimArgs.timingFunction : ''; + this.ghostElement.style.transitionDelay = customAnimArgs && customAnimArgs.delay ? customAnimArgs.delay + 's' : ''; + this.setLocation(new IgxDragLocation(this.baseLeft, this.baseTop)); + } else if (!this.ghost) { + this.element.nativeElement.style.transitionProperty = 'transform'; + this.element.nativeElement.style.transitionDuration = + customAnimArgs && customAnimArgs.duration ? customAnimArgs.duration + 's' : this.defaultReturnDuration; + this.element.nativeElement.style.transitionTimingFunction = + customAnimArgs && customAnimArgs.timingFunction ? customAnimArgs.timingFunction : ''; + this.element.nativeElement.style.transitionDelay = customAnimArgs && customAnimArgs.delay ? customAnimArgs.delay + 's' : ''; + this._startX = this.baseLeft; + this._startY = this.baseTop; + this.setTransformXY(0, 0); + } + }, 0); + } + + /** + * Animates the base or ghost element to a specific target location or other element using transition. + * If `ghost` is true but there is not ghost rendered, it will be created and animated. + * It is recommended to use 'getBoundingClientRects() + pageScroll' when determining desired location. + * + * @param target Target that the base or ghost will transition to. It can be either location in the page or another HTML element. + * @param customAnimArgs Custom transition properties that will be applied when performing the transition. + * @param startLocation Start location from where the transition should start. + */ + public transitionTo(target: IgxDragLocation | ElementRef, customAnimArgs?: IDragCustomTransitionArgs, startLocation?: IgxDragLocation) { + if (!!startLocation && this.ghost && !this.ghostElement) { + this._startX = startLocation.pageX; + this._startY = startLocation.pageY; + this._ghostStartX = this._startX; + this._ghostStartY = this._startY; + } else if (!!startLocation && (!this.ghost || this.ghostElement)) { + this.setLocation(startLocation); + } else if (this.ghost && !this.ghostElement) { + this._startX = this.baseLeft; + this._startY = this.baseTop; + this._ghostStartX = this._startX + this.windowScrollLeft; + this._ghostStartY = this._startY + this.windowScrollTop; + } + + if (this.ghost && !this.ghostElement) { + this.createGhost(this._startX, this._startY); + } + + this.animInProgress = true; + // Use setTimeout because we need to be sure that the element is positioned first correctly if there is start location. + setTimeout(() => { + const movedElem = this.ghost ? this.ghostElement : this.element.nativeElement; + movedElem.style.transitionProperty = this.ghost && this.ghostElement ? 'left, top' : 'transform'; + movedElem.style.transitionDuration = + customAnimArgs && customAnimArgs.duration ? customAnimArgs.duration + 's' : this.defaultReturnDuration; + movedElem.style.transitionTimingFunction = + customAnimArgs && customAnimArgs.timingFunction ? customAnimArgs.timingFunction : ''; + movedElem.style.transitionDelay = customAnimArgs && customAnimArgs.delay ? customAnimArgs.delay + 's' : ''; + + if (target instanceof IgxDragLocation) { + this.setLocation(new IgxDragLocation(target.pageX, target.pageY)); + } else { + const targetRects = target.nativeElement.getBoundingClientRect(); + this.setLocation(new IgxDragLocation( + targetRects.left - this.windowScrollLeft, + targetRects.top - this.windowScrollTop + )); + } + }, 0); + } + + /** + * @hidden + * Method bound to the PointerDown event of the base element igxDrag is initialized. + * @param event PointerDown event captured + */ + public onPointerDown(event) { + const ignoredElement = this.dragIgnoredElems.find(elem => elem.element.nativeElement === event.target); + if (ignoredElement) { + return; + } + + // Set pointer capture so we detect pointermove even if mouse is out of bounds until ghostElement is created. + const handleFound = this.dragHandles.find(handle => handle.element.nativeElement === event.target); + const targetElement = handleFound ? handleFound.element.nativeElement : event.target || this.element.nativeElement; + if (this.pointerEventsEnabled && targetElement.isConnected) { + this._pointerDownId = event.pointerId; + targetElement.setPointerCapture(this._pointerDownId); + } else if (targetElement.isConnected) { + targetElement.focus(); + event.preventDefault(); + } else { + return; + } + + this._clicked = true; + if (this.pointerEventsEnabled || !this.touchEventsEnabled) { + // Check first for pointer events or non touch, because we can have pointer events and touch events at once. + this._startX = event.pageX; + this._startY = event.pageY; + } else if (this.touchEventsEnabled) { + this._startX = event.touches[0].pageX; + this._startY = event.touches[0].pageY; + } + + this._defaultOffsetX = this.baseLeft - this._startX + this.windowScrollLeft; + this._defaultOffsetY = this.baseTop - this._startY + this.windowScrollTop; + this._ghostStartX = this._startX + this.ghostOffsetX; + this._ghostStartY = this._startY + this.ghostOffsetY; + this._lastX = this._startX; + this._lastY = this._startY; + } + + /** + * @hidden + * Perform drag move logic when dragging and dispatching events if there is igxDrop under the pointer. + * This method is bound at first at the base element. + * If dragging starts and after the ghostElement is rendered the pointerId is reassigned it. Then this method is bound to it. + * @param event PointerMove event captured + */ + public onPointerMove(event) { + if (this._clicked) { + let pageX; let pageY; + if (this.pointerEventsEnabled || !this.touchEventsEnabled) { + // Check first for pointer events or non touch, because we can have pointer events and touch events at once. + pageX = event.pageX; + pageY = event.pageY; + } else if (this.touchEventsEnabled) { + pageX = event.touches[0].pageX; + pageY = event.touches[0].pageY; + + // Prevent scrolling on touch while dragging + event.preventDefault(); + } + + const totalMovedX = pageX - this._startX; + const totalMovedY = pageY - this._startY; + if (!this._dragStarted && + (Math.abs(totalMovedX) > this.dragTolerance || Math.abs(totalMovedY) > this.dragTolerance)) { + const dragStartArgs: IDragStartEventArgs = { + originalEvent: event, + owner: this, + startX: pageX - totalMovedX, + startY: pageY - totalMovedY, + pageX, + pageY, + cancel: false + }; + this.zone.run(() => { + this.dragStart.emit(dragStartArgs); + }); + + if (!dragStartArgs.cancel) { + this._dragStarted = true; + if (this.ghost) { + // We moved enough so ghostElement can be rendered and actual dragging to start. + // When creating it will take into account any offset set by the user by default. + this.createGhost(pageX, pageY); + } else if (this._offsetX !== undefined || this._offsetY !== undefined) { + // There is no need for ghost, but we will need to position initially the base element to reflect any offset. + const transformX = (this._offsetX !== undefined ? this._offsetX - this._defaultOffsetX : 0) + + this.getTransformX(this.element.nativeElement); + const transformY = (this._offsetY !== undefined ? this._offsetY - this._defaultOffsetY : 0) + + this.getTransformY(this.element.nativeElement); + this.setTransformXY(transformX, transformY); + } + } else { + return; + } + } else if (!this._dragStarted) { + return; + } + + const moveArgs: IDragMoveEventArgs = { + originalEvent: event, + owner: this, + startX: this._startX, + startY: this._startY, + pageX: this._lastX, + pageY: this._lastY, + nextPageX: pageX, + nextPageY: pageY, + cancel: false + }; + this.dragMove.emit(moveArgs); + + const setPageX = moveArgs.nextPageX; + const setPageY = moveArgs.nextPageY; + if (!moveArgs.cancel) { + // Scroll root container if the user reaches its boundaries. + this.onScrollContainer(); + + // Move the actual element around + if (this.ghost) { + const updatedTotalMovedX = this.dragDirection === DragDirection.VERTICAL ? 0 : setPageX - this._startX; + const updatedTotalMovedY = this.dragDirection === DragDirection.HORIZONTAL ? 0 : setPageY - this._startY; + this.ghostLeft = this._ghostStartX + updatedTotalMovedX; + this.ghostTop = this._ghostStartY + updatedTotalMovedY; + } else { + const lastMovedX = this.dragDirection === DragDirection.VERTICAL ? 0 : setPageX - this._lastX; + const lastMovedY = this.dragDirection === DragDirection.HORIZONTAL ? 0 : setPageY - this._lastY; + const translateX = this.getTransformX(this.element.nativeElement) + lastMovedX; + const translateY = this.getTransformY(this.element.nativeElement) + lastMovedY; + this.setTransformXY(translateX, translateY); + } + this.dispatchDragEvents(pageX, pageY, event); + } + + this._lastX = setPageX; + this._lastY = setPageY; + } + } + + /** + * @hidden + * Perform drag end logic when releasing the ghostElement and dispatching drop event if igxDrop is under the pointer. + * This method is bound at first at the base element. + * If dragging starts and after the ghostElement is rendered the pointerId is reassigned to it. Then this method is bound to it. + * @param event PointerUp event captured + */ + public onPointerUp(event) { + if (!this._clicked) { + return; + } + + let pageX; let pageY; + if (this.pointerEventsEnabled || !this.touchEventsEnabled) { + // Check first for pointer events or non touch, because we can have pointer events and touch events at once. + pageX = event.pageX; + pageY = event.pageY; + } else if (this.touchEventsEnabled) { + pageX = event.touches[0].pageX; + pageY = event.touches[0].pageY; + + // Prevent scrolling on touch while dragging + event.preventDefault(); + } + + const eventArgs: IDragBaseEventArgs = { + originalEvent: event, + owner: this, + startX: this._startX, + startY: this._startY, + pageX, + pageY + }; + this._pointerDownId = null; + this._clicked = false; + if (this._dragStarted) { + if (this._lastDropArea && this._lastDropArea !== this.element.nativeElement) { + this.dispatchDropEvent(event.pageX, event.pageY, event); + } + + this.zone.run(() => { + this.dragEnd.emit(eventArgs); + }); + + if (!this.animInProgress) { + this.onTransitionEnd(null); + } + } else { + // Trigger our own click event because when there is no ghost, native click cannot be prevented when dragging. + this.zone.run(() => { + this.dragClick.emit(eventArgs); + }); + } + + if (this._containerScrollIntervalId) { + clearInterval(this._containerScrollIntervalId); + this._containerScrollIntervalId = null; + } + } + + /** + * @hidden + * Execute this method whe the pointer capture has been lost. + * This means that during dragging the user has performed other action like right clicking and then clicking somewhere else. + * This method will ensure that the drag state is being reset in this case as if the user released the dragged element. + * @param event Event captured + */ + public onPointerLost(event) { + if (!this._clicked) { + return; + } + + // When the base element is moved to previous index, angular reattaches the ghost template as a sibling by default. + // This is the defaut place for the EmbededViewRef when recreated. + // That's why we need to move it to the proper place and set pointer capture again. + if (this._pointerDownId && this.ghostElement && this._dynamicGhostRef && !this._dynamicGhostRef.destroyed) { + let ghostReattached = false; + if (this.ghostHost && !Array.from(this.ghostHost.children).includes(this.ghostElement)) { + ghostReattached = true; + this.ghostHost.appendChild(this.ghostElement); + } else if (!this.ghostHost && !Array.from(this.document.body.children).includes(this.ghostElement)) { + ghostReattached = true; + this.document.body.appendChild(this.ghostElement); + } + + if (ghostReattached) { + this.ghostElement.setPointerCapture(this._pointerDownId); + return; + } + } + + const eventArgs = { + originalEvent: event, + owner: this, + startX: this._startX, + startY: this._startY, + pageX: event.pageX, + pageY: event.pageY + }; + this._pointerDownId = null; + this._clicked = false; + if (this._dragStarted) { + this.zone.run(() => { + this.dragEnd.emit(eventArgs); + }); + if (!this.animInProgress) { + this.onTransitionEnd(null); + } + } + } + + /** + * @hidden + */ + public onTransitionEnd(event) { + if ((!this._dragStarted && !this.animInProgress) || this._clicked) { + // Return if no dragging started and there is no animation in progress. + return; + } + + if (this.ghost && this.ghostElement) { + this._ghostStartX = this.baseLeft + this.windowScrollLeft; + this._ghostStartY = this.baseTop + this.windowScrollTop; + + const ghostDestroyArgs: IDragGhostBaseEventArgs = { + owner: this, + ghostElement: this.ghostElement, + cancel: false + }; + this.ghostDestroy.emit(ghostDestroyArgs); + if (ghostDestroyArgs.cancel) { + return; + } + this.clearGhost(); + } else if (!this.ghost) { + this.element.nativeElement.style.transitionProperty = ''; + this.element.nativeElement.style.transitionDuration = '0.0s'; + this.element.nativeElement.style.transitionTimingFunction = ''; + this.element.nativeElement.style.transitionDelay = ''; + } + this.animInProgress = false; + this._dragStarted = false; + + // Execute transitioned after everything is reset so if the user sets new location on the base now it would work as expected. + this.zone.run(() => { + this.transitioned.emit({ + originalEvent: event, + owner: this, + startX: this._startX, + startY: this._startY, + pageX: this._startX, + pageY: this._startY + }); + }); + } + + protected detachGhost() { + this.ghostElement.removeEventListener('pointermove', this.onPointerMove); + this.ghostElement.removeEventListener('pointerup', this.onPointerUp); + this.ghostElement.removeEventListener('lostpointercapture', this.onPointerLost); + this.ghostElement.removeEventListener('transitionend', this.onTransitionEnd); + } + + protected clearGhost() { + this.detachGhost(); + this.ghostElement.remove(); + this.ghostElement = null; + + if (this._dynamicGhostRef) { + this._dynamicGhostRef.destroy(); + this._dynamicGhostRef = null; + } + } + + /** + * @hidden + * Create ghost element - if a Node object is provided it creates a clone of that node, + * otherwise it clones the host element. + * Bind all needed events. + * @param pageX Latest pointer position on the X axis relative to the page. + * @param pageY Latest pointer position on the Y axis relative to the page. + * @param node The Node object to be cloned. + */ + protected createGhost(pageX, pageY, node: any = null) { + if (!this.ghost) { + return; + } + + if (this.ghostTemplate) { + this.zone.run(() => { + // Create template in zone, so it gets updated by it automatically. + this._dynamicGhostRef = this.viewContainer.createEmbeddedView(this.ghostTemplate, this.ghostContext); + }); + if (this._dynamicGhostRef.rootNodes[0].style.display === 'contents') { + // Change the display to default since display contents does not position the element absolutely. + this._dynamicGhostRef.rootNodes[0].style.display = 'block'; + } + this.ghostElement = this._dynamicGhostRef.rootNodes[0]; + } else { + this.ghostElement = node ? node.cloneNode(true) : this.element.nativeElement.cloneNode(true); + } + + const totalMovedX = pageX - this._startX; + const totalMovedY = pageY - this._startY; + this._ghostHostX = this.getGhostHostBaseOffsetX(); + this._ghostHostY = this.getGhostHostBaseOffsetY(); + + this.ghostElement.style.transitionDuration = '0.0s'; + this.ghostElement.style.position = 'absolute'; + + if (this.ghostClass) { + this.ghostElement.classList.add(this.ghostClass); + } + + if (this.ghostStyle) { + Object.entries(this.ghostStyle).map(([name, value]) => { + this.renderer.setStyle(this.ghostElement, name, value, RendererStyleFlags2.DashCase); + }); + } + + const createEventArgs = { + owner: this, + ghostElement: this.ghostElement, + cancel: false + }; + this.ghostCreate.emit(createEventArgs); + if (createEventArgs.cancel) { + this.ghostElement = null; + if (this.ghostTemplate && this._dynamicGhostRef) { + this._dynamicGhostRef.destroy(); + } + return; + } + + if (this.ghostHost) { + this.ghostHost.appendChild(this.ghostElement); + } else { + this.document.body.appendChild(this.ghostElement); + } + + const ghostMarginLeft = parseInt(this.document.defaultView.getComputedStyle(this.ghostElement)['margin-left'], 10); + const ghostMarginTop = parseInt(this.document.defaultView.getComputedStyle(this.ghostElement)['margin-top'], 10); + this.ghostElement.style.left = (this._ghostStartX - ghostMarginLeft + totalMovedX - this._ghostHostX) + 'px'; + this.ghostElement.style.top = (this._ghostStartY - ghostMarginTop + totalMovedY - this._ghostHostY) + 'px'; + + if (this.pointerEventsEnabled) { + // The ghostElement takes control for moving and dragging after it has been rendered. + if (this._pointerDownId !== null) { + this.ghostElement.setPointerCapture(this._pointerDownId); + } + this.ghostElement.addEventListener('pointermove', this.onPointerMove); + this.ghostElement.addEventListener('pointerup', this.onPointerUp); + this.ghostElement.addEventListener('lostpointercapture', this.onPointerLost); + } + + // Transition animation when the ghostElement is released and it returns to it's original position. + this.ghostElement.addEventListener('transitionend', this.onTransitionEnd); + + this.cdr.detectChanges(); + } + + /** + * @hidden + * Dispatch custom igxDragEnter/igxDragLeave events based on current pointer position and if drop area is under. + */ + protected dispatchDragEvents(pageX: number, pageY: number, originalEvent) { + let topDropArea; + const customEventArgs: IgxDragCustomEventDetails = { + startX: this._startX, + startY: this._startY, + pageX, + pageY, + owner: this, + originalEvent + }; + + const elementsFromPoint = this.getElementsAtPoint(pageX, pageY); + let targetElements = []; + // Check for shadowRoot instance and use it if present + for (const elFromPoint of elementsFromPoint) { + if (elFromPoint?.shadowRoot) { + targetElements = targetElements.concat(this.getFromShadowRoot(elFromPoint, pageX, pageY, elementsFromPoint)); + } else if (targetElements.indexOf(elFromPoint) === -1) { + targetElements.push(elFromPoint); + } + } + + for (const element of targetElements) { + if (element.getAttribute('droppable') === 'true' && + element !== this.ghostElement && element !== this.element.nativeElement) { + topDropArea = element; + break; + } + } + + if (topDropArea && + (!this._lastDropArea || (this._lastDropArea && this._lastDropArea !== topDropArea))) { + if (this._lastDropArea) { + this.dispatchEvent(this._lastDropArea, 'igxDragLeave', customEventArgs); + } + + this._lastDropArea = topDropArea; + this.dispatchEvent(this._lastDropArea, 'igxDragEnter', customEventArgs); + } else if (!topDropArea && this._lastDropArea) { + this.dispatchEvent(this._lastDropArea, 'igxDragLeave', customEventArgs); + this._lastDropArea = null; + return; + } + + if (topDropArea) { + this.dispatchEvent(topDropArea, 'igxDragOver', customEventArgs); + } + } + + /** + * @hidden + * Traverse shadow dom in depth. + */ + protected getFromShadowRoot(elem, pageX, pageY, parentDomElems) { + const elementsFromPoint = elem.shadowRoot.elementsFromPoint(pageX, pageY); + const shadowElements = elementsFromPoint.filter(cur => parentDomElems.indexOf(cur) === -1); + let res = []; + for (const elFromPoint of shadowElements) { + if (!!elFromPoint?.shadowRoot && elFromPoint.shadowRoot !== elem.shadowRoot) { + res = res.concat(this.getFromShadowRoot(elFromPoint, pageX, pageY, elementsFromPoint)); + } + res.push(elFromPoint); + } + return res; + } + + /** + * @hidden + * Dispatch custom igxDrop event based on current pointer position if there is last recorder drop area under the pointer. + * Last recorder drop area is updated in @dispatchDragEvents method. + */ + protected dispatchDropEvent(pageX: number, pageY: number, originalEvent) { + const eventArgs: IgxDragCustomEventDetails = { + startX: this._startX, + startY: this._startY, + pageX, + pageY, + owner: this, + originalEvent + }; + + this.dispatchEvent(this._lastDropArea, 'igxDrop', eventArgs); + this.dispatchEvent(this._lastDropArea, 'igxDragLeave', eventArgs); + this._lastDropArea = null; + } + + /** + * @hidden + */ + protected getElementsAtPoint(pageX: number, pageY: number) { + // correct the coordinates with the current scroll position, because + // document.elementsFromPoint consider position within the current viewport + // window.pageXOffset == window.scrollX; // always true + // using window.pageXOffset for IE9 compatibility + const viewPortX = pageX - window.pageXOffset; + const viewPortY = pageY - window.pageYOffset; + if (this.document['msElementsFromPoint']) { + // Edge and IE special snowflakes + const elements = this.document['msElementsFromPoint'](viewPortX, viewPortY); + return elements === null ? [] : elements; + } else { + // Other browsers like Chrome, Firefox, Opera + return this.document.elementsFromPoint(viewPortX, viewPortY); + } + } + + /** + * @hidden + */ + protected dispatchEvent(target, eventName: string, eventArgs: IgxDragCustomEventDetails) { + // This way is IE11 compatible. + // const dragLeaveEvent = document.createEvent('CustomEvent'); + // dragLeaveEvent.initCustomEvent(eventName, false, false, eventArgs); + // target.dispatchEvent(dragLeaveEvent); + // Otherwise can be used `target.dispatchEvent(new CustomEvent(eventName, eventArgs));` + target.dispatchEvent(new CustomEvent(eventName, { detail: eventArgs })); + } + + protected getTransformX(elem) { + let posX = 0; + if (elem.style.transform) { + const matrix = elem.style.transform; + const values = matrix ? matrix.match(/-?[\d\.]+/g) : undefined; + posX = values ? Number(values[1]) : 0; + } + + return posX; + } + + protected getTransformY(elem) { + let posY = 0; + if (elem.style.transform) { + const matrix = elem.style.transform; + const values = matrix ? matrix.match(/-?[\d\.]+/g) : undefined; + posY = values ? Number(values[2]) : 0; + } + + return posY; + } + + /** Method setting transformation to the base draggable element. */ + protected setTransformXY(x: number, y: number) { + if(x === 0 && y === 0) { + this.element.nativeElement.style.transform = ''; + return; + } + this.element.nativeElement.style.transform = 'translate3d(' + x + 'px, ' + y + 'px, 0px)'; + } + + /** + * Since we are using absolute position to move the ghost, the ghost host might not have position: relative. + * Combined with position static, this means that the absolute position in the browser is relative to the offsetParent. + * The offsetParent is pretty much the closes parent that has position: relative, or if no such until it reaches the body. + * That's why if this is the case, we need to know how much we should compensate for the ghostHost being offset from + * its offsetParent. + * + * OffsetParent can be null in the case of position: fixed applied to the ghost host or display: none. In that case + * just get the clientRects of the ghost host. + */ + protected getGhostHostBaseOffsetX() { + if (!this.ghostHost) return 0; + + const ghostPosition = this.document.defaultView.getComputedStyle(this.ghostHost).getPropertyValue('position'); + if (ghostPosition === 'static' && this.ghostHost.offsetParent && this.ghostHost.offsetParent === this.document.body) { + return 0; + } else if (ghostPosition === 'static' && this.ghostHost.offsetParent) { + return this.ghostHost.offsetParent.getBoundingClientRect().left + this.windowScrollLeft; + } + return this.ghostHost.getBoundingClientRect().left + this.windowScrollLeft; + } + + protected getGhostHostBaseOffsetY() { + if (!this.ghostHost) return 0; + + const ghostPosition = this.document.defaultView.getComputedStyle(this.ghostHost).getPropertyValue('position'); + if (ghostPosition === 'static' && this.ghostHost.offsetParent && this.ghostHost.offsetParent === this.document.body) { + return 0; + } else if (ghostPosition === 'static' && this.ghostHost.offsetParent) { + return this.ghostHost.offsetParent.getBoundingClientRect().top + this.windowScrollTop; + } + return this.ghostHost.getBoundingClientRect().top + this.windowScrollTop; + } + + protected getContainerScrollDirection() { + const containerBounds = this.scrollContainer ? this.scrollContainer.getBoundingClientRect() : null; + const scrolledX = !this.scrollContainer ? this.windowScrollLeft > 0 : this.scrollContainer.scrollLeft > 0; + const scrolledY = !this.scrollContainer ? this.windowScrollTop > 0 : this.scrollContainer.scrollTop > 0; + // Take into account window scroll top because we do not use fixed positioning to the window. + const topBorder = (!this.scrollContainer ? 0 : containerBounds.top) + this.windowScrollTop + this._scrollContainerThreshold; + // Subtract the element height because we position it from top left corner. + const elementHeight = this.ghost && this.ghostElement ? this.ghostElement.offsetHeight : this.element.nativeElement.offsetHeight; + const bottomBorder = (!this.scrollContainer ? window.innerHeight : containerBounds.bottom) + + this.windowScrollTop - this._scrollContainerThreshold - elementHeight; + // Same for window scroll left + const leftBorder = (!this.scrollContainer ? 0 : containerBounds.left) + this.windowScrollLeft + this._scrollContainerThreshold; + // Subtract the element width again because we position it from top left corner. + const elementWidth = this.ghost && this.ghostElement ? this.ghostElement.offsetWidth : this.element.nativeElement.offsetWidth; + const rightBorder = (!this.scrollContainer ? window.innerWidth : containerBounds.right) + + this.windowScrollLeft - this._scrollContainerThreshold - elementWidth + + if (this.pageY <= topBorder && scrolledY) { + return DragScrollDirection.UP; + } else if (this.pageY > bottomBorder) { + return DragScrollDirection.DOWN; + } else if (this.pageX < leftBorder && scrolledX) { + return DragScrollDirection.LEFT; + } else if (this.pageX > rightBorder) { + return DragScrollDirection.RIGHT; + } + return null; + } + + protected onScrollContainerStep(scrollDir: DragScrollDirection) { + animationFrameScheduler.schedule(() => { + + let xDir = scrollDir == DragScrollDirection.LEFT ? -1 : (scrollDir == DragScrollDirection.RIGHT ? 1 : 0); + let yDir = scrollDir == DragScrollDirection.UP ? -1 : (scrollDir == DragScrollDirection.DOWN ? 1 : 0); + if (!this.scrollContainer) { + // Cap scrolling so we don't scroll past the window max scroll position. + const maxScrollX = this._originalScrollContainerWidth - this.document.documentElement.clientWidth; + const maxScrollY = this._originalScrollContainerHeight - this.document.documentElement.clientHeight; + xDir = (this.windowScrollLeft <= 0 && xDir < 0) || (this.windowScrollLeft >= maxScrollX && xDir > 0) ? 0 : xDir; + yDir = (this.windowScrollTop <= 0 && yDir < 0) || (this.windowScrollTop >= maxScrollY && yDir > 0) ? 0 : yDir; + } else { + // Cap scrolling so we don't scroll past the container max scroll position. + const maxScrollX = this._originalScrollContainerWidth - this.scrollContainer.clientWidth; + const maxScrollY = this._originalScrollContainerHeight - this.scrollContainer.clientHeight; + xDir = (this.scrollContainer.scrollLeft <= 0 && xDir < 0) || (this.scrollContainer.scrollLeft >= maxScrollX && xDir > 0) ? 0 : xDir; + yDir = (this.scrollContainer.scrollTop <= 0 && yDir < 0) || (this.scrollContainer.scrollTop >= maxScrollY && yDir > 0) ? 0 : yDir; + } + + const scrollByX = xDir * this._scrollContainerStep; + const scrollByY = yDir * this._scrollContainerStep; + + // Scroll the corresponding window or container. + if (!this.scrollContainer) { + window.scrollBy(scrollByX, scrollByY); + } else { + this.scrollContainer.scrollLeft += scrollByX; + this.scrollContainer.scrollTop += scrollByY; + } + + if (this.ghost && !this.scrollContainer) { + // Scroll the ghost only when there is no container specifies. + // If it has container the ghost pretty much stays in the same position while the container is scrolled since e use top/left position. + // Otherwise increase the position the same amount we have scrolled the window + this.ghostLeft += scrollByX; + this.ghostTop += scrollByY; + } else if (!this.ghost) { + // Move the base element the same amount we moved the window/container because we use transformations. + const translateX = this.getTransformX(this.element.nativeElement) + scrollByX; + const translateY = this.getTransformY(this.element.nativeElement) + scrollByY; + this.setTransformXY(translateX, translateY); + if (!this.scrollContainer) { + this._lastX += scrollByX; + this._lastY += scrollByY; + } + } + }) + } + + protected onScrollContainer() { + const scrollDir = this.getContainerScrollDirection(); + if (scrollDir !== null && scrollDir !== undefined && !this._containerScrollIntervalId) { + // Save original container sizes to ensure that we don't increase scroll sizes infinitely when out of bounds. + this._originalScrollContainerWidth = this.scrollContainer ? this.scrollContainer.scrollWidth : this.windowScrollWidth; + this._originalScrollContainerHeight = this.scrollContainer ? this.scrollContainer.scrollHeight : this.windowScrollHeight; + + this._containerScrollIntervalId = setInterval(() => this.onScrollContainerStep(scrollDir), this._scrollContainerStepMs); + } else if ((scrollDir === null || scrollDir === undefined) && this._containerScrollIntervalId) { + // We moved out of end bounds and there is interval started + clearInterval(this._containerScrollIntervalId); + this._containerScrollIntervalId = null; + } + } +} + +@Directive({ + exportAs: 'drop', + selector: '[igxDrop]', + standalone: true +}) +export class IgxDropDirective implements OnInit, OnDestroy { + /** + * - Save data inside the `igxDrop` directive. This can be set when instancing `igxDrop` on an element. + * ```html + *
+ * ``` + * + * @memberof IgxDropDirective + */ + @Input('igxDrop') + public set data(v: any) { + this._data = v; + } + + public get data(): any { + return this._data; + } + + /** + * A property that provides a way for igxDrag and igxDrop to be linked through channels. + * It accepts single value or an array of values and evaluates then using strict equality. + * ```html + *
+ * 95 + *
+ *
+ * Numbers drop area! + *
+ * ``` + * + * @memberof IgxDropDirective + */ + @Input() + public dropChannel: number | string | number[] | string[]; + + /** + * Sets a drop strategy type that will be executed when an `IgxDrag` element is released inside + * the current drop area. The provided strategies are: + * - IgxDefaultDropStrategy - This is the default base strategy and it doesn't perform any actions. + * - IgxAppendDropStrategy - Appends the dropped element to last position as a direct child to the `igxDrop`. + * - IgxPrependDropStrategy - Prepends the dropped element to first position as a direct child to the `igxDrop`. + * - IgxInsertDropStrategy - If the dropped element is released above a child element of the `igxDrop`, it will be inserted + * at that position. Otherwise the dropped element will be appended if released outside any child of the `igxDrop`. + * ```html + *
+ * DragMe + *
+ *
+ * Numbers drop area! + *
+ * ``` + * ```typescript + * import { IgxAppendDropStrategy } from 'igniteui-angular'; + * + * export class App { + * public myDropStrategy = IgxAppendDropStrategy; + * } + * ``` + * + * @memberof IgxDropDirective + */ + @Input() + public set dropStrategy(classRef: any) { + this._dropStrategy = new classRef(this._renderer); + } + + public get dropStrategy() { + return this._dropStrategy; + } + + /** + * Event triggered when dragged element enters the area of the element. + * ```html + *
+ *
+ * ``` + * ```typescript + * public dragEnter(){ + * alert("A draggable element has entered the chip area!"); + * } + * ``` + * + * @memberof IgxDropDirective + */ + @Output() + public enter = new EventEmitter(); + + /** + * Event triggered when dragged element enters the area of the element. + * ```html + *
+ *
+ * ``` + * ```typescript + * public dragEnter(){ + * alert("A draggable element has entered the chip area!"); + * } + * ``` + * + * @memberof IgxDropDirective + */ + @Output() + public over = new EventEmitter(); + + /** + * Event triggered when dragged element leaves the area of the element. + * ```html + *
+ *
+ * ``` + * ```typescript + * public dragLeave(){ + * alert("A draggable element has left the chip area!"); + * } + * ``` + * + * @memberof IgxDropDirective + */ + @Output() + public leave = new EventEmitter(); + + /** + * Event triggered when dragged element is dropped in the area of the element. + * Since the `igxDrop` has default logic that appends the dropped element as a child, it can be canceled here. + * To cancel the default logic the `cancel` property of the event needs to be set to true. + * ```html + *
+ *
+ * ``` + * ```typescript + * public dragDrop(){ + * alert("A draggable element has been dropped in the chip area!"); + * } + * ``` + * + * @memberof IgxDropDirective + */ + @Output() + public dropped = new EventEmitter(); + + /** + * @hidden + */ + @HostBinding('attr.droppable') + public droppable = true; + + /** + * @hidden + */ + @HostBinding('class.dragOver') + public dragover = false; + + /** + * @hidden + */ + protected _destroy = new Subject(); + protected _dropStrategy: IDropStrategy; + + private _data: any; + + public element = inject(ElementRef); + protected _renderer = inject(Renderer2); + private _zone = inject(NgZone); + + constructor() { + this._dropStrategy = new IgxDefaultDropStrategy(); + } + + /** + * @hidden + */ + @HostListener('igxDrop', ['$event']) + public onDragDrop(event) { + if (!this.isDragLinked(event.detail.owner)) { + return; + } + + const elementPosX = this.element.nativeElement.getBoundingClientRect().left + this.getWindowScrollLeft(); + const elementPosY = this.element.nativeElement.getBoundingClientRect().top + this.getWindowScrollTop(); + const offsetX = event.detail.pageX - elementPosX; + const offsetY = event.detail.pageY - elementPosY; + const args: IDropDroppedEventArgs = { + owner: this, + originalEvent: event.detail.originalEvent, + drag: event.detail.owner, + dragData: event.detail.owner.data, + startX: event.detail.startX, + startY: event.detail.startY, + pageX: event.detail.pageX, + pageY: event.detail.pageY, + offsetX, + offsetY, + cancel: false + }; + this._zone.run(() => { + this.dropped.emit(args); + }); + + if (this._dropStrategy && !args.cancel) { + const elementsAtPoint = event.detail.owner.getElementsAtPoint(event.detail.pageX, event.detail.pageY); + const insertIndex = this.getInsertIndexAt(event.detail.owner, elementsAtPoint); + this._dropStrategy.dropAction(event.detail.owner, this, insertIndex); + } + } + + /** + * @hidden + */ + public ngOnInit() { + this._zone.runOutsideAngular(() => { + fromEvent(this.element.nativeElement, 'igxDragEnter').pipe(takeUntil(this._destroy)) + .subscribe((res) => this.onDragEnter(res as CustomEvent)); + + fromEvent(this.element.nativeElement, 'igxDragLeave').pipe(takeUntil(this._destroy)).subscribe((res) => this.onDragLeave(res)); + fromEvent(this.element.nativeElement, 'igxDragOver').pipe(takeUntil(this._destroy)).subscribe((res) => this.onDragOver(res)); + }); + } + + /** + * @hidden + */ + public ngOnDestroy() { + this._destroy.next(true); + this._destroy.complete(); + } + + /** + * @hidden + */ + public onDragOver(event) { + const elementPosX = this.element.nativeElement.getBoundingClientRect().left + this.getWindowScrollLeft(); + const elementPosY = this.element.nativeElement.getBoundingClientRect().top + this.getWindowScrollTop(); + const offsetX = event.detail.pageX - elementPosX; + const offsetY = event.detail.pageY - elementPosY; + const eventArgs: IDropBaseEventArgs = { + originalEvent: event.detail.originalEvent, + owner: this, + drag: event.detail.owner, + dragData: event.detail.owner.data, + startX: event.detail.startX, + startY: event.detail.startY, + pageX: event.detail.pageX, + pageY: event.detail.pageY, + offsetX, + offsetY + }; + + this.over.emit(eventArgs); + } + + /** + * @hidden + */ + public onDragEnter(event: CustomEvent) { + if (!this.isDragLinked(event.detail.owner)) { + return; + } + + this.dragover = true; + const elementPosX = this.element.nativeElement.getBoundingClientRect().left + this.getWindowScrollLeft(); + const elementPosY = this.element.nativeElement.getBoundingClientRect().top + this.getWindowScrollTop(); + const offsetX = event.detail.pageX - elementPosX; + const offsetY = event.detail.pageY - elementPosY; + const eventArgs: IDropBaseEventArgs = { + originalEvent: event.detail.originalEvent, + owner: this, + drag: event.detail.owner, + dragData: event.detail.owner.data, + startX: event.detail.startX, + startY: event.detail.startY, + pageX: event.detail.pageX, + pageY: event.detail.pageY, + offsetX, + offsetY + }; + this._zone.run(() => { + this.enter.emit(eventArgs); + }); + } + + /** + * @hidden + */ + public onDragLeave(event) { + if (!this.isDragLinked(event.detail.owner)) { + return; + } + + this.dragover = false; + const elementPosX = this.element.nativeElement.getBoundingClientRect().left + this.getWindowScrollLeft(); + const elementPosY = this.element.nativeElement.getBoundingClientRect().top + this.getWindowScrollTop(); + const offsetX = event.detail.pageX - elementPosX; + const offsetY = event.detail.pageY - elementPosY; + const eventArgs: IDropBaseEventArgs = { + originalEvent: event.detail.originalEvent, + owner: this, + drag: event.detail.owner, + dragData: event.detail.owner.data, + startX: event.detail.startX, + startY: event.detail.startY, + pageX: event.detail.pageX, + pageY: event.detail.pageY, + offsetX, + offsetY + }; + this._zone.run(() => { + this.leave.emit(eventArgs); + }); + } + + protected getWindowScrollTop() { + return window.scrollY ? window.scrollY : (window.pageYOffset ? window.pageYOffset : 0); + } + + protected getWindowScrollLeft() { + return window.scrollX ? window.scrollX : (window.pageXOffset ? window.pageXOffset : 0); + } + + protected isDragLinked(drag: IgxDragDirective): boolean { + const dragLinkArray = drag.dragChannel instanceof Array; + const dropLinkArray = this.dropChannel instanceof Array; + + if (!dragLinkArray && !dropLinkArray) { + return this.dropChannel === drag.dragChannel; + } else if (!dragLinkArray && dropLinkArray) { + const dropLinks = this.dropChannel as any[]; + for (const link of dropLinks) { + if (link === drag.dragChannel) { + return true; + } + } + } else if (dragLinkArray && !dropLinkArray) { + const dragLinks = drag.dragChannel as any[]; + for (const link of dragLinks) { + if (link === this.dropChannel) { + return true; + } + } + } else { + const dragLinks = drag.dragChannel as any[]; + const dropLinks = this.dropChannel as any[]; + for (const draglink of dragLinks) { + for (const droplink of dropLinks) { + if (draglink === droplink) { + return true; + } + } + } + } + + return false; + } + + protected getInsertIndexAt(draggedDir: IgxDragDirective, elementsAtPoint: any[]): number { + let insertIndex = -1; + const dropChildren = Array.prototype.slice.call(this.element.nativeElement.children); + if (!dropChildren.length) { + return insertIndex; + } + + let i = 0; + let childUnder = null; + while (!childUnder && i < elementsAtPoint.length) { + if (elementsAtPoint[i].parentElement === this.element.nativeElement) { + childUnder = elementsAtPoint[i]; + } + i++; + } + + const draggedElemIndex = dropChildren.indexOf(draggedDir.element.nativeElement); + insertIndex = dropChildren.indexOf(childUnder); + if (draggedElemIndex !== -1 && draggedElemIndex < insertIndex) { + insertIndex++; + } + + return insertIndex; + } +} + diff --git a/projects/igniteui-angular/directives/src/directives/drag-drop/drag-drop.module.ts b/projects/igniteui-angular/directives/src/directives/drag-drop/drag-drop.module.ts new file mode 100644 index 00000000000..a020fd48834 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/drag-drop/drag-drop.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { IgxDragDirective, IgxDragHandleDirective, IgxDragIgnoreDirective, IgxDropDirective } from './drag-drop.directive'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [IgxDragDirective, IgxDropDirective, IgxDragHandleDirective, IgxDragIgnoreDirective], + exports: [IgxDragDirective, IgxDropDirective, IgxDragHandleDirective, IgxDragIgnoreDirective] +}) +export class IgxDragDropModule { } diff --git a/projects/igniteui-angular/directives/src/directives/drag-drop/drag-drop.spec.ts b/projects/igniteui-angular/directives/src/directives/drag-drop/drag-drop.spec.ts new file mode 100644 index 00000000000..ba3fd6937b1 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/drag-drop/drag-drop.spec.ts @@ -0,0 +1,2151 @@ +import { Component, ViewChildren, QueryList, ViewChild, ElementRef, TemplateRef, Renderer2, inject } from '@angular/core'; +import { TestBed, ComponentFixture, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { UIInteractions, wait} from '../../../../test-utils/ui-interactions.spec'; +import { first } from 'rxjs/operators'; +import { IgxInsertDropStrategy, IgxAppendDropStrategy, IgxPrependDropStrategy } from './drag-drop.strategy'; +import { + IgxDragDirective, + IgxDropDirective, + IgxDragLocation, + IDropDroppedEventArgs, + DragDirection, + IgxDragHandleDirective, + IgxDragIgnoreDirective +} from './drag-drop.directive'; +import { IgxIconComponent } from '../../../../icon/src/icon/icon.component'; + +describe('General igxDrag/igxDrop', () => { + let fix: ComponentFixture; + let dropArea: IgxDropDirective; + let dropAreaRects = { top: 0, left: 0, right: 0, bottom: 0}; + let dragDirsRects = [{ top: 0, left: 0, right: 0, bottom: 0}]; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TestDragDropComponent] + }) + .compileComponents(); + })); + + beforeEach(() => { + fix = TestBed.createComponent(TestDragDropComponent); + fix.detectChanges(); + + dragDirsRects = getDragDirsRects(fix.componentInstance.dragElems); + dropArea = fix.componentInstance.dropArea; + dropAreaRects = getElemRects(dropArea.element.nativeElement); + }); + + afterEach(() => { + fix = null; + dragDirsRects = null; + dropArea = null; + dropAreaRects = null; + }); + + it('should correctly initialize drag and drop directives.', () => { + const ignoredElem = fix.debugElement.query(By.css('.ignoredElem')).nativeElement; + + expect(fix.componentInstance.dragElems.length).toEqual(3); + expect(fix.componentInstance.dragElems.last.data).toEqual({ key: 3 }); + expect(fix.componentInstance.dropArea).toBeTruthy(); + expect(fix.componentInstance.dropArea.data).toEqual({ key: 333 }); + expect(fix.componentInstance.dragElems.last.dragIgnoredElems.length).toEqual(1); + expect(fix.componentInstance.dragElems.last.dragIgnoredElems.first.element.nativeElement).toEqual(ignoredElem); + }); + + it('should create drag ghost element and trigger ghostCreate/ghostDestroy.', async () => { + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + + spyOn(firstDrag.ghostCreate, 'emit'); + spyOn(firstDrag.ghostDestroy, 'emit'); + expect(document.getElementsByClassName('dragElem').length).toEqual(3); + + // Step 1. + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + expect(firstDrag.ghostCreate.emit).not.toHaveBeenCalled(); + expect(firstDrag.ghostDestroy.emit).not.toHaveBeenCalled(); + + // Step 2. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + expect(firstDrag.ghostCreate.emit).toHaveBeenCalled(); + expect(firstDrag.ghostDestroy.emit).not.toHaveBeenCalled(); + expect(firstDrag.ghostElement).toBeDefined(); + expect(firstDrag.ghostElement.id).toEqual('firstDrag'); + expect(firstDrag.ghostElement.className).toEqual('dragElem igx-drag igx-drag--select-disabled'); + expect(document.getElementsByClassName('dragElem').length).toEqual(4); + + // Step 3. + // We need to trigger the pointerup on the ghostElement because this is the element we move and is under the mouse + UIInteractions.simulatePointerEvent('pointerup', firstDrag.ghostElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(); + + expect(firstDrag.ghostElement).toBeNull(); + expect(document.getElementsByClassName('dragElem').length).toEqual(3); + expect(firstDrag.ghostCreate.emit).toHaveBeenCalled(); + expect(firstDrag.ghostDestroy.emit).toHaveBeenCalled(); + }); + + it('should trigger dragStart/dragMove/dragEnd events in that order.', async () => { + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + + spyOn(firstDrag.dragStart, 'emit'); + spyOn(firstDrag.dragMove, 'emit'); + spyOn(firstDrag.dragEnd, 'emit'); + + // Step 1. + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + expect(firstDrag.dragStart.emit).not.toHaveBeenCalled(); + expect(firstDrag.dragMove.emit).not.toHaveBeenCalled(); + expect(firstDrag.dragEnd.emit).not.toHaveBeenCalled(); + + // Step 2. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + expect(firstDrag.dragStart.emit).toHaveBeenCalled(); + expect(firstDrag.dragMove.emit).toHaveBeenCalled(); + expect(firstDrag.dragEnd.emit).not.toHaveBeenCalled(); + + // Step 3. + UIInteractions.simulatePointerEvent('pointermove', firstDrag.ghostElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(100); + + expect(firstDrag.dragStart.emit).toHaveBeenCalled(); + expect(firstDrag.dragMove.emit).toHaveBeenCalled(); + expect(firstDrag.dragEnd.emit).not.toHaveBeenCalled(); + + // Step 4. + // We need to trigger the pointerup on the ghostElement because this is the element we move and is under the mouse + UIInteractions.simulatePointerEvent('pointerup', firstDrag.ghostElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(); + + expect(firstDrag.dragStart.emit).toHaveBeenCalled(); + expect(firstDrag.dragMove.emit).toHaveBeenCalled(); + expect(firstDrag.dragEnd.emit).toHaveBeenCalled(); + }); + + it('should trigger dragStart/dragMove/dragEnd events in that order when pointer is lost', async () => { + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + + spyOn(firstDrag.dragStart, 'emit'); + spyOn(firstDrag.dragMove, 'emit'); + spyOn(firstDrag.dragEnd, 'emit'); + + // Step 1. + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + expect(firstDrag.dragStart.emit).not.toHaveBeenCalled(); + expect(firstDrag.dragMove.emit).not.toHaveBeenCalled(); + expect(firstDrag.dragEnd.emit).not.toHaveBeenCalled(); + + // Step 2. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + expect(firstDrag.dragStart.emit).toHaveBeenCalled(); + expect(firstDrag.dragMove.emit).toHaveBeenCalled(); + expect(firstDrag.dragEnd.emit).not.toHaveBeenCalled(); + + // Step 3. + UIInteractions.simulatePointerEvent('pointermove', firstDrag.ghostElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(100); + + expect(firstDrag.dragStart.emit).toHaveBeenCalled(); + expect(firstDrag.dragMove.emit).toHaveBeenCalled(); + expect(firstDrag.dragEnd.emit).not.toHaveBeenCalled(); + + // Step 4. + // We need to trigger the pointerup on the ghostElement because this is the element we move and is under the mouse + UIInteractions.simulatePointerEvent('lostpointercapture', firstDrag.ghostElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(); + + expect(firstDrag.dragStart.emit).toHaveBeenCalled(); + expect(firstDrag.dragMove.emit).toHaveBeenCalled(); + expect(firstDrag.dragEnd.emit).toHaveBeenCalled(); + }); + + it('should not create drag ghost element when the dragged amount is less than dragTolerance.', async () => { + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + firstDrag.dragTolerance = 15; + + spyOn(firstDrag.ghostCreate, 'emit'); + spyOn(firstDrag.ghostDestroy, 'emit'); + spyOn(firstDrag.dragClick, 'emit'); + expect(document.getElementsByClassName('dragElem').length).toEqual(3); + + // Step 1. + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + expect(firstDrag.ghostCreate.emit).not.toHaveBeenCalled(); + expect(firstDrag.ghostDestroy.emit).not.toHaveBeenCalled(); + expect(firstDrag.dragClick.emit).not.toHaveBeenCalled(); + + // Step 2. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + expect(firstDrag.ghostElement).not.toBeDefined(); + expect(document.getElementsByClassName('dragElem').length).toEqual(3); + expect(firstDrag.ghostCreate.emit).not.toHaveBeenCalled(); + expect(firstDrag.ghostDestroy.emit).not.toHaveBeenCalled(); + expect(firstDrag.dragClick.emit).not.toHaveBeenCalled(); + + // Step 3. + // We need to trigger the pointerup on the ghostElement because this is the element we move and is under the mouse + UIInteractions.simulatePointerEvent('pointerup', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(); + + expect(firstDrag.ghostElement).not.toBeDefined(); + expect(document.getElementsByClassName('dragElem').length).toEqual(3); + expect(firstDrag.ghostCreate.emit).not.toHaveBeenCalled(); + expect(firstDrag.ghostDestroy.emit).not.toHaveBeenCalled(); + expect(firstDrag.dragClick.emit).toHaveBeenCalled(); + }); + + it('should position ghost at the same position relative to the mouse when drag started.', async () => { + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + + // Step 1. + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + // Step 2. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + // Step 3. + UIInteractions.simulatePointerEvent('pointermove', firstDrag.ghostElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(100); + + // We compare the base position and the new position + how much the mouse has moved. + expect(firstDrag.ghostElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left + 20); + expect(firstDrag.ghostElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top + 20); + + // Step 4. + UIInteractions.simulatePointerEvent('pointerup', firstDrag.ghostElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(); + }); + + it('should move ghost only horizontally when drag direction is set to horizontal.', async () => { + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + firstDrag.dragDirection = DragDirection.HORIZONTAL; + + // Step 1. + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + // Step 2. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + // Step 3. + UIInteractions.simulatePointerEvent('pointermove', firstDrag.ghostElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(100); + + // We compare the base position and the new position + how much the mouse has moved. + expect(firstDrag.ghostElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left + 20); + expect(firstDrag.ghostElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top); + + // Step 4. + UIInteractions.simulatePointerEvent('pointerup', firstDrag.ghostElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(); + }); + + it('should move ghost only vertically when drag direction is set to vertical.', async () => { + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + firstDrag.dragDirection = DragDirection.VERTICAL; + + // Step 1. + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + // Step 2. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + // Step 3. + UIInteractions.simulatePointerEvent('pointermove', firstDrag.ghostElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(100); + + // We compare the base position and the new position + how much the mouse has moved. + expect(firstDrag.ghostElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left); + expect(firstDrag.ghostElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top + 20); + + // Step 4. + UIInteractions.simulatePointerEvent('pointerup', firstDrag.ghostElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(); + }); + + it('should position ghost relative to the mouse using offsetX and offsetY correctly.', async () => { + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + firstDrag.ghostOffsetX = 0; + firstDrag.ghostOffsetY = 0; + + // Step 1. + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(50); + + // Step 2. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + // Step 3. + UIInteractions.simulatePointerEvent('pointermove', firstDrag.ghostElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(100); + + expect(firstDrag.ghostElement.getBoundingClientRect().left).toEqual(startingX + 20); + expect(firstDrag.ghostElement.getBoundingClientRect().top).toEqual(startingY + 20); + + // Step 4. + UIInteractions.simulatePointerEvent('pointerup', firstDrag.ghostElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(); + }); + + it('should position ghost at the same position relative to the mouse when drag started when host is defined.', async () => { + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + + firstDrag.ghostHost = firstElement.parentElement; + + // Step 1. + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + // Step 2. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + // Step 3. + UIInteractions.simulatePointerEvent('pointermove', firstDrag.ghostElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(100); + + // We compare the base position and the new position + how much the mouse has moved. + expect(firstDrag.ghostElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left + 20); + expect(firstDrag.ghostElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top + 20); + + // Step 4. + UIInteractions.simulatePointerEvent('pointerup', firstDrag.ghostElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(); + }); + + it(`should create ghost at the same position relative to the mouse + when drag started when host has custom style position.`, async () => { + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + + firstElement.parentElement.style.left = '50px'; + firstElement.parentElement.style.top = '50px'; + firstElement.parentElement.style.position = 'relative'; + firstDrag.ghostHost = firstElement.parentElement; + + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + (firstDrag as any).createGhost(startingX + 10, startingY + 10); + fix.detectChanges(); + + expect(firstDrag.ghostElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left + 60); + expect(firstDrag.ghostElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top + 60); + }); + + it('should allow customizing of ghost element by passing template reference and position it correctly.', async () => { + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + firstDrag.ghostTemplate = fix.componentInstance.ghostTemplate; + + // Step 1. + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + // Step 2. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + // Step 3. + UIInteractions.simulatePointerEvent('pointermove', firstDrag.ghostElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(100); + + // We compare the base position and the new position + how much the mouse has moved. + expect(firstDrag.ghostElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left + 20); + expect(firstDrag.ghostElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top + 20); + expect(firstDrag.ghostElement.innerText).toEqual('Drag Template'); + expect(firstDrag.ghostElement.className).toEqual('ghostElement'); + + // Step 4. + UIInteractions.simulatePointerEvent('pointerup', firstDrag.ghostElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(); + }); + + it('should position custom ghost relative to the mouse using offsetX and offsetY correctly.', async () => { + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + firstDrag.ghostOffsetX = 0; + firstDrag.ghostOffsetY = 0; + firstDrag.ghostTemplate = fix.componentInstance.ghostTemplate; + + // Step 1. + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + // Step 2. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + // Step 3. + UIInteractions.simulatePointerEvent('pointermove', firstDrag.ghostElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(100); + + // We compare the base position and the new position + how much the mouse has moved. + // + 10 margin to the final ghost position + expect(firstDrag.ghostElement.getBoundingClientRect().left).toEqual(startingX + 20); + expect(firstDrag.ghostElement.getBoundingClientRect().top).toEqual(startingY + 20); + expect(firstDrag.ghostElement.innerText).toEqual('Drag Template'); + expect(firstDrag.ghostElement.className).toEqual('ghostElement'); + + // Step 4. + UIInteractions.simulatePointerEvent('pointerup', firstDrag.ghostElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(); + }); + + it(`should take first child when creating ghost from template that has display content`, async () => { + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + firstDrag.ghostOffsetX = 0; + firstDrag.ghostOffsetY = 0; + firstDrag.ghostTemplate = fix.componentInstance.ghostTemplateContents; + + // Step 1. + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + // Step 2. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + // Step 3. + UIInteractions.simulatePointerEvent('pointermove', firstDrag.ghostElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(100); + + // We compare the base position and the new position + how much the mouse has moved. + // + 10 margin to the final ghost position + expect(firstDrag.ghostElement.getBoundingClientRect().left).toEqual(startingX + 20); + expect(firstDrag.ghostElement.getBoundingClientRect().top).toEqual(startingY + 20); + expect(firstDrag.ghostElement.innerText).toEqual('Drag Template Content'); + expect(firstDrag.ghostElement.id).toEqual('contentsTemplate'); + expect(firstDrag.ghostElement.style.display).toEqual('block'); + + // Step 4. + UIInteractions.simulatePointerEvent('pointerup', firstDrag.ghostElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(); + }); + + it('should correctly move igxDrag element when ghost is disabled and trigger dragStart/dragMove/dragEnd events.', async () => { + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + firstDrag.ghost = false; + + spyOn(firstDrag.dragStart, 'emit'); + spyOn(firstDrag.dragMove, 'emit'); + spyOn(firstDrag.dragEnd, 'emit'); + + // Step 1. + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + expect(firstDrag.dragStart.emit).not.toHaveBeenCalled(); + expect(firstDrag.dragMove.emit).not.toHaveBeenCalled(); + expect(firstDrag.dragEnd.emit).not.toHaveBeenCalled(); + + // Step 2. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + expect(firstDrag.ghostElement).not.toBeDefined(); + expect(firstElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left + 10); + expect(firstElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top + 10); + expect(firstDrag.dragStart.emit).toHaveBeenCalled(); + expect(firstDrag.dragMove.emit).toHaveBeenCalled(); + expect(firstDrag.dragEnd.emit).not.toHaveBeenCalled(); + + // Step 3. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(100); + + expect(firstDrag.ghostElement).not.toBeDefined(); + expect(firstElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left + 20); + expect(firstElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top + 20); + expect(firstDrag.dragStart.emit).toHaveBeenCalled(); + expect(firstDrag.dragMove.emit).toHaveBeenCalled(); + expect(firstDrag.dragEnd.emit).not.toHaveBeenCalled(); + + // Step 4. + UIInteractions.simulatePointerEvent('pointerup', firstElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(); + + expect(firstElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left + 20); + expect(firstElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top + 20); + expect(firstDrag.dragStart.emit).toHaveBeenCalled(); + expect(firstDrag.dragMove.emit).toHaveBeenCalled(); + expect(firstDrag.dragEnd.emit).toHaveBeenCalled(); + }); + + it('should move igxDrag element only horizontally when ghost is disabled and direction is set to horizontal.', async () => { + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + firstDrag.ghost = false; + firstDrag.dragDirection = DragDirection.HORIZONTAL; + fix.detectChanges(); + + // Step 1. + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + // Step 2. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + expect(firstDrag.ghostElement).not.toBeDefined(); + expect(firstElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left + 10); + expect(firstElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top); + + // Step 3. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(100); + + expect(firstDrag.ghostElement).not.toBeDefined(); + expect(firstElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left + 20); + expect(firstElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top); + + // Step 4. + UIInteractions.simulatePointerEvent('pointerup', firstElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(); + + expect(firstElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left + 20); + expect(firstElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top); + }); + + it('should move igxDrag element only vertically when ghost is disabled and direction is set to vertical.', async () => { + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + firstDrag.ghost = false; + firstDrag.dragDirection = DragDirection.VERTICAL; + fix.detectChanges(); + + // Step 1. + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + // Step 2. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + expect(firstDrag.ghostElement).not.toBeDefined(); + expect(firstElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left); + expect(firstElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top + 10); + + // Step 3. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(100); + + expect(firstDrag.ghostElement).not.toBeDefined(); + expect(firstElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left); + expect(firstElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top + 20); + + // Step 4. + UIInteractions.simulatePointerEvent('pointerup', firstElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(); + + expect(firstElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left); + expect(firstElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top + 20); + }); + + it('should prevent dragging if it does not exceed dragTolerance and ghost is disabled.', async () => { + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + firstDrag.ghost = false; + firstDrag.dragTolerance = 25; + + spyOn(firstDrag.dragStart, 'emit'); + spyOn(firstDrag.dragClick, 'emit'); + + // Step 1. + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + expect(firstDrag.dragStart.emit).not.toHaveBeenCalled(); + + // Step 2. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + expect(firstDrag.ghostElement).not.toBeDefined(); + expect(firstElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left); + expect(firstElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top); + expect(firstDrag.dragStart.emit).not.toHaveBeenCalled(); + expect(firstDrag.dragClick.emit).not.toHaveBeenCalled(); + + // Step 3. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(100); + + expect(firstDrag.ghostElement).not.toBeDefined(); + expect(firstElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left); + expect(firstElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top); + expect(firstDrag.dragStart.emit).not.toHaveBeenCalled(); + expect(firstDrag.dragClick.emit).not.toHaveBeenCalled(); + + // Step 4. + UIInteractions.simulatePointerEvent('pointerup', firstElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(); + + expect(firstElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left); + expect(firstElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top); + expect(firstDrag.dragStart.emit).not.toHaveBeenCalled(); + expect(firstDrag.dragClick.emit).toHaveBeenCalled(); + }); + + it('should correctly apply dragTolerance of 0 when it is set to 0 and ghost is disabled.', async () => { + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + firstDrag.ghost = false; + firstDrag.dragTolerance = 0; + + spyOn(firstDrag.dragStart, 'emit'); + + // Step 1. + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + expect(firstDrag.dragStart.emit).not.toHaveBeenCalled(); + + // Step 2. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 3, startingY + 3); + fix.detectChanges(); + await wait(100); + + expect(firstDrag.ghostElement).not.toBeDefined(); + expect(firstElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left + 3); + expect(firstElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top + 3); + expect(firstDrag.dragStart.emit).toHaveBeenCalled(); + + // Step 3. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 4, startingY + 4); + fix.detectChanges(); + await wait(100); + + expect(firstDrag.ghostElement).not.toBeDefined(); + expect(firstElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left + 4); + expect(firstElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top + 4); + expect(firstDrag.dragStart.emit).toHaveBeenCalled(); + + // Step 4. + UIInteractions.simulatePointerEvent('pointerup', firstElement, startingX + 4, startingY + 4); + fix.detectChanges(); + await wait(); + + expect(firstElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left + 4); + expect(firstElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top + 4); + expect(firstDrag.dragStart.emit).toHaveBeenCalled(); + }); + + it('should position the base element relative to the mouse using offsetX and offsetY correctly.', async () => { + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + firstDrag.ghost = false; + firstDrag.ghostOffsetX = 0; + firstDrag.ghostOffsetY = 0; + firstDrag.ghostTemplate = fix.componentInstance.ghostTemplate; + + // Step 1. + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + // Step 2. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + // Step 3. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(100); + + // We compare the base position and the new position + how much the mouse has moved. + // + 10 margin to the final ghost position + expect(firstElement.getBoundingClientRect().left).toEqual(startingX + 20); + expect(firstElement.getBoundingClientRect().top).toEqual(startingY + 20); + expect(firstElement.innerText).toEqual('Drag 1'); + + // Step 4. + UIInteractions.simulatePointerEvent('pointerup', firstElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(); + }); + + it('should correctly set location using setLocation() method when ghost is disabled', async () => { + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + const initialPageX = firstDrag.pageX; + const initialPageY = firstDrag.pageY; + firstDrag.ghost = false; + + expect(initialPageX).toEqual(dragDirsRects[0].left); + expect(initialPageY).toEqual(dragDirsRects[0].top); + + // Step 1. + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + // Step 2. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + expect(firstDrag.ghostElement).not.toBeDefined(); + expect(firstElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left + 10); + expect(firstElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top + 10); + + // Step 3. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(100); + + expect(firstDrag.ghostElement).not.toBeDefined(); + expect(firstElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left + 20); + expect(firstElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top + 20); + + // Step 4. + UIInteractions.simulatePointerEvent('pointerup', firstElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(); + + expect(firstElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left + 20); + expect(firstElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top + 20); + + firstDrag.setLocation(new IgxDragLocation(initialPageX, initialPageY)); + + expect(firstElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left); + expect(firstElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top); + }); + + it('should correctly set location using setLocation() method when ghost is rendered.', async () => { + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + const initialPageX = firstDrag.pageX; + const initialPageY = firstDrag.pageY; + + expect(initialPageX).toEqual(dragDirsRects[0].left); + expect(initialPageY).toEqual(dragDirsRects[0].top); + + // Step 1. + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + // Step 2. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + expect(firstDrag.ghostElement).toBeTruthy(); + expect(firstDrag.ghostElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left + 10); + expect(firstDrag.ghostElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top + 10); + + // Step 3. + UIInteractions.simulatePointerEvent('pointermove', firstDrag.ghostElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(100); + + firstDrag.setLocation(new IgxDragLocation(initialPageX, initialPageY)); + fix.detectChanges(); + await wait(100); + + expect(firstDrag.ghostElement).toBeTruthy(); + expect(firstDrag.ghostElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left); + expect(firstDrag.ghostElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top); + + // Step 4. + UIInteractions.simulatePointerEvent('pointerup', firstDrag.ghostElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(); + + expect(firstDrag.ghostElement).not.toBeTruthy(); + }); + + it('should correctly drag using drag handle and not the whole element', async () => { + const thirdDrag = fix.componentInstance.dragElems.last; + const thirdElement = thirdDrag.element.nativeElement; + const startingX = (dragDirsRects[2].left + dragDirsRects[2].right) / 2; + const startingY = (dragDirsRects[2].top + dragDirsRects[2].bottom) / 2; + thirdDrag.ghost = false; + thirdDrag.dragTolerance = 0; + + spyOn(thirdDrag.dragStart, 'emit'); + + // Check if drag element itself is not draggable. + UIInteractions.simulatePointerEvent('pointerdown', thirdElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + UIInteractions.simulatePointerEvent('pointermove', thirdElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + UIInteractions.simulatePointerEvent('pointermove', thirdElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(100); + + UIInteractions.simulatePointerEvent('pointerup', thirdElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(); + + expect(thirdElement.getBoundingClientRect().left).toEqual(dragDirsRects[2].left); + expect(thirdElement.getBoundingClientRect().top).toEqual(dragDirsRects[2].top); + expect(thirdDrag.dragStart.emit).not.toHaveBeenCalled(); + + // Try dragging through drag handle. + + const dragHandle = thirdElement.children[0]; + const dragHandleRects = dragHandle.getBoundingClientRect(); + const handleStartX = (dragHandleRects.left + dragHandleRects.right) / 2; + const handleStartY = (dragHandleRects.top + dragHandleRects.bottom) / 2; + UIInteractions.simulatePointerEvent('pointerdown', dragHandle, handleStartX, handleStartY); + fix.detectChanges(); + await wait(); + + UIInteractions.simulatePointerEvent('pointermove', dragHandle, handleStartX + 10, handleStartY + 10); + fix.detectChanges(); + await wait(100); + + UIInteractions.simulatePointerEvent('pointermove', dragHandle, handleStartX + 20, handleStartY + 20); + fix.detectChanges(); + await wait(100); + + UIInteractions.simulatePointerEvent('pointerup', dragHandle, handleStartX + 20, handleStartY + 20); + fix.detectChanges(); + await wait(); + + expect(thirdElement.getBoundingClientRect().left).toEqual(dragDirsRects[2].left + 20); + expect(thirdElement.getBoundingClientRect().top).toEqual(dragDirsRects[2].top + 20); + expect(thirdDrag.dragStart.emit).toHaveBeenCalled(); + }); + + it('should trigger enter, dropped and leave events when element is dropped inside igxDrop element.', async () => { + fix.componentInstance.dropArea.dropStrategy = IgxInsertDropStrategy; + fix.detectChanges(); + + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + + spyOn(dropArea.enter, 'emit'); + spyOn(dropArea.leave, 'emit'); + spyOn(dropArea.dropped, 'emit'); + + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + expect(fix.componentInstance.container.nativeElement.children.length).toEqual(3); + expect(dropArea.element.nativeElement.children.length).toEqual(0); + + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(); + + const event = UIInteractions.simulatePointerEvent('pointermove', + firstDrag.ghostElement, + dropAreaRects.left + 100, + dropAreaRects.top + 5 + ); + fix.detectChanges(); + await wait(100); + + expect(dropArea.enter.emit).toHaveBeenCalledWith({ + originalEvent: event, + owner: dropArea, + drag: firstDrag, + dragData: firstDrag.data, + startX: startingX, + startY: startingY, + pageX: dropAreaRects.left + 100, + pageY: dropAreaRects.top + 5, + offsetX: 100, + offsetY: 5 + }); + + // We need to trigger the pointerup on the ghostElement because this is the element we move and is under the mouse + const eventUp = UIInteractions.simulatePointerEvent('pointerup', + firstDrag.ghostElement, + dropAreaRects.left + 100, + dropAreaRects.top + 20 + ); + fix.detectChanges(); + await wait(); + + expect(dropArea.dropped.emit).toHaveBeenCalledWith({ + originalEvent: eventUp, + owner: dropArea, + drag: firstDrag, + dragData: firstDrag.data, + startX: startingX, + startY: startingY, + pageX: dropAreaRects.left + 100, + pageY: dropAreaRects.top + 20, + offsetX: 100, + offsetY: 20, + cancel: false + }); + expect(dropArea.leave.emit).toHaveBeenCalledWith({ + originalEvent: eventUp, + owner: dropArea, + drag: firstDrag, + dragData: firstDrag.data, + startX: startingX, + startY: startingY, + pageX: dropAreaRects.left + 100, + pageY: dropAreaRects.top + 20, + offsetX: 100, + offsetY: 20 + }); + expect(fix.componentInstance.container.nativeElement.children.length).toEqual(2); + expect(dropArea.element.nativeElement.children.length).toEqual(1); + }); + + it('should return the base element to its original position with transitionToOrigin() after dragging.', async () => { + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + firstDrag.ghost = false; + + firstDrag.dragEnd.pipe(first()).subscribe(() => { + firstDrag.transitionToOrigin(); + }); + + firstDrag.transitioned.pipe(first()).subscribe(() => { + expect(firstDrag.originLocation.pageX).toEqual(dragDirsRects[0].left); + expect(firstDrag.originLocation.pageY).toEqual(dragDirsRects[0].top); + expect(firstDrag.element.nativeElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left); + expect(firstDrag.element.nativeElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top); + }); + + expect(firstDrag.originLocation.pageX).toEqual(dragDirsRects[0].left); + expect(firstDrag.originLocation.pageY).toEqual(dragDirsRects[0].top); + expect(firstDrag.element.nativeElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left); + expect(firstDrag.element.nativeElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top); + + // Step 1. + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + // Step 2. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + // Step 3. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(100); + + let currLeft = firstDrag.element.nativeElement.getBoundingClientRect().left; + let currTop = firstDrag.element.nativeElement.getBoundingClientRect().top; + + expect(firstDrag.location.pageX).toEqual(currLeft); + expect(firstDrag.location.pageY).toEqual(currTop); + expect(firstDrag.originLocation.pageX).toEqual(dragDirsRects[0].left); + expect(firstDrag.originLocation.pageY).toEqual(dragDirsRects[0].top); + expect(currLeft).toEqual(dragDirsRects[0].left + 20); + expect(currTop).toEqual(dragDirsRects[0].top + 20); + + // Step 4. + UIInteractions.simulatePointerEvent('pointerup', firstElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(100); + + + currLeft = firstDrag.element.nativeElement.getBoundingClientRect().left; + currTop = firstDrag.element.nativeElement.getBoundingClientRect().top; + expect(dragDirsRects[0].left < currLeft && currLeft <= (dragDirsRects[0].left + 20)).toBeTruthy(); + expect(dragDirsRects[0].top < currTop && currTop <= (dragDirsRects[0].top + 20)).toBeTruthy(); + + }); + + it('should return the ghost element to its original position with transitionToOrigin() after dragging.', async () => { + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + + firstDrag.dragEnd.pipe(first()).subscribe(() => { + firstDrag.transitionToOrigin(); + }); + + firstDrag.transitioned.pipe(first()).subscribe(() => { + expect(firstDrag.ghostElement).not.toBeTruthy(); + }); + + expect(firstDrag.ghostElement).not.toBeTruthy(); + + // Step 1. + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + // Step 2. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + // Step 3. + UIInteractions.simulatePointerEvent('pointermove', firstDrag.ghostElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(100); + + expect(firstDrag.ghostElement).toBeTruthy(); + expect(firstDrag.ghostElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left + 20); + expect(firstDrag.ghostElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top + 20); + + // Step 4. + UIInteractions.simulatePointerEvent('pointerup', firstDrag.ghostElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(100); + + expect(firstDrag.ghostElement).toBeTruthy(); + + const currLeft = firstDrag.ghostElement.getBoundingClientRect().left; + const currTop = firstDrag.ghostElement.getBoundingClientRect().top; + expect(dragDirsRects[0].left < currLeft && currLeft <= (dragDirsRects[0].left + 20)).toBeTruthy(); + expect(dragDirsRects[0].top < currTop && currTop <= (dragDirsRects[0].top + 20)).toBeTruthy(); + }); + + it('should not create ghost element when executing transitionToOrigin() when no dragging is performed without start.', async () => { + const firstDrag = fix.componentInstance.dragElems.first; + + spyOn(firstDrag.transitioned, 'emit'); + + expect(firstDrag.ghostElement).not.toBeTruthy(); + + firstDrag.transitionToOrigin(); + await wait(); + + expect(firstDrag.ghostElement).not.toBeTruthy(); + expect(firstDrag.transitioned.emit).not.toHaveBeenCalled(); + }); + + + it('should create ghost element when executing transitionToOrigin() when no dragging is performed with start.', async () => { + const firstDrag = fix.componentInstance.dragElems.first; + + firstDrag.transitioned.pipe(first()).subscribe(() => { + expect(firstDrag.ghostElement).not.toBeTruthy(); + }); + + expect(firstDrag.ghostElement).not.toBeTruthy(); + + firstDrag.transitionToOrigin({}, new IgxDragLocation(dragDirsRects[0].left + 50, dragDirsRects[0].top + 50)); + await wait(); + + expect(firstDrag.ghostElement).toBeTruthy(); + + const currLeft = firstDrag.ghostElement.getBoundingClientRect().left; + const currTop = firstDrag.ghostElement.getBoundingClientRect().top; + + // origin left < current left <= start left + expect(dragDirsRects[0].left).toBeLessThan(currLeft); + expect(currLeft).toBeLessThanOrEqual(dragDirsRects[0].left + 50); + + // origin top < current top <= start top + expect(dragDirsRects[0].top).toBeLessThan(currTop); + expect(currTop).toBeLessThanOrEqual(dragDirsRects[0].top + 50); + }); + + it('should transition the base element to location with transitionTo().', async () => { + const firstDrag = fix.componentInstance.dragElems.first; + firstDrag.ghost = false; + + firstDrag.transitioned.pipe(first()).subscribe(() => { + expect(firstDrag.element.nativeElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left + 50); + expect(firstDrag.element.nativeElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top + 50); + }); + + expect(firstDrag.element.nativeElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left); + expect(firstDrag.element.nativeElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top); + + firstDrag.transitionTo(new IgxDragLocation(dragDirsRects[0].left + 50, dragDirsRects[0].top + 50)); + await wait(); + + const currLeft = firstDrag.element.nativeElement.getBoundingClientRect().left; + const currTop = firstDrag.element.nativeElement.getBoundingClientRect().top; + + // start left <= current left < target left + expect(dragDirsRects[0].left).toBeLessThanOrEqual(currLeft); + expect(currLeft).toBeLessThan(dragDirsRects[0].left + 50); + + // start top <= current top < target top + expect(dragDirsRects[0].top).toBeLessThanOrEqual(currTop); + expect(currTop).toBeLessThan(dragDirsRects[0].top + 50); + }); + + it('should transition the base element to location with transitionTo() with starting location.', async () => { + const firstDrag = fix.componentInstance.dragElems.first; + firstDrag.ghost = false; + + firstDrag.transitioned.pipe(first()).subscribe(() => { + expect(firstDrag.element.nativeElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left + 50); + expect(firstDrag.element.nativeElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top + 50); + }); + + expect(firstDrag.element.nativeElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left); + expect(firstDrag.element.nativeElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top); + + firstDrag.transitionTo( + new IgxDragLocation(dragDirsRects[0].left + 50, dragDirsRects[0].top + 50), + {}, + new IgxDragLocation(dragDirsRects[0].left + 100, dragDirsRects[0].top + 100) + ); + await wait(); + + const currLeft = firstDrag.element.nativeElement.getBoundingClientRect().left; + const currTop = firstDrag.element.nativeElement.getBoundingClientRect().top; + + // target left < current left <= start left + expect(dragDirsRects[0].left + 50).toBeLessThan(currLeft); + expect(currLeft).toBeLessThanOrEqual(dragDirsRects[0].left + 100); + + // target top < current top <= start top + expect(dragDirsRects[0].top + 50).toBeLessThan(currTop); + expect(currTop).toBeLessThanOrEqual(dragDirsRects[0].top + 100); + }); + + it('should transition the ghost element to location with transitionTo() after dragging.', async () => { + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + + firstDrag.dragEnd.pipe(first()).subscribe(() => { + firstDrag.transitionTo(new IgxDragLocation(dragDirsRects[0].left + 50, dragDirsRects[0].top + 50)); + }); + + firstDrag.transitioned.pipe(first()).subscribe(() => { + expect(firstDrag.ghostElement).not.toBeTruthy(); + }); + + expect(firstDrag.ghostElement).not.toBeTruthy(); + + // Step 1. + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + // Step 2. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + // Step 3. + UIInteractions.simulatePointerEvent('pointermove', firstDrag.ghostElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(100); + + expect(firstDrag.ghostElement).toBeTruthy(); + expect(firstDrag.ghostElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left + 20); + expect(firstDrag.ghostElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top + 20); + + // Step 4. + UIInteractions.simulatePointerEvent('pointerup', firstDrag.ghostElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(100); + + expect(firstDrag.ghostElement).toBeTruthy(); + + const currLeft = firstDrag.ghostElement.getBoundingClientRect().left; + const currTop = firstDrag.ghostElement.getBoundingClientRect().top; + + // last left < current left <= target left + expect(dragDirsRects[0].left + 20).toBeLessThanOrEqual(currLeft); + expect(currLeft).toBeLessThan(dragDirsRects[0].left + 50); + + // last top < current top <= target top + expect(dragDirsRects[0].top + 20).toBeLessThanOrEqual(currTop); + expect(currTop).toBeLessThan(dragDirsRects[0].top + 50); + }); + + it('should transition the ghost element to location with transitionTo() after dragging with start location.', async () => { + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + + firstDrag.dragEnd.pipe(first()).subscribe(() => { + firstDrag.transitionTo( + new IgxDragLocation(dragDirsRects[0].left + 50, dragDirsRects[0].top + 50), + {}, + new IgxDragLocation(dragDirsRects[0].left + 100, dragDirsRects[0].top + 100) + ); + }); + + firstDrag.transitioned.pipe(first()).subscribe(() => { + expect(firstDrag.ghostElement).not.toBeTruthy(); + }); + + expect(firstDrag.ghostElement).not.toBeTruthy(); + + // Step 1. + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + // Step 2. + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + // Step 3. + UIInteractions.simulatePointerEvent('pointermove', firstDrag.ghostElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(100); + + expect(firstDrag.ghostElement).toBeTruthy(); + expect(firstDrag.ghostElement.getBoundingClientRect().left).toEqual(dragDirsRects[0].left + 20); + expect(firstDrag.ghostElement.getBoundingClientRect().top).toEqual(dragDirsRects[0].top + 20); + + // Step 4. + UIInteractions.simulatePointerEvent('pointerup', firstDrag.ghostElement, startingX + 20, startingY + 20); + fix.detectChanges(); + await wait(100); + + expect(firstDrag.ghostElement).toBeTruthy(); + + const currLeft = firstDrag.ghostElement.getBoundingClientRect().left; + const currTop = firstDrag.ghostElement.getBoundingClientRect().top; + + // target left < current left <= start left + expect(dragDirsRects[0].left + 50).toBeLessThan(currLeft); + expect(currLeft).toBeLessThanOrEqual(dragDirsRects[0].left + 100); + + // target top < current top <= start top + expect(dragDirsRects[0].top + 50).toBeLessThan(currTop); + expect(currTop).toBeLessThanOrEqual(dragDirsRects[0].top + 100); + }); + + it('should create ghost element to location with transitionTo() and start location set.', async () => { + const firstDrag = fix.componentInstance.dragElems.first; + + firstDrag.transitioned.pipe(first()).subscribe(() => { + expect(firstDrag.ghostElement).not.toBeTruthy(); + }); + + expect(firstDrag.ghostElement).not.toBeTruthy(); + + firstDrag.transitionTo( + new IgxDragLocation(dragDirsRects[0].left + 50, dragDirsRects[0].top + 50), + {}, + new IgxDragLocation(dragDirsRects[0].left + 100, dragDirsRects[0].top + 100) + ); + await wait(); + + expect(firstDrag.ghostElement).toBeTruthy(); + + const currLeft = firstDrag.ghostElement.getBoundingClientRect().left; + const currTop = firstDrag.ghostElement.getBoundingClientRect().top; + + // target left < current left <= start left + expect(dragDirsRects[0].left + 50).toBeLessThan(currLeft); + expect(currLeft).toBeLessThanOrEqual(dragDirsRects[0].left + 100); + + // target left < current left <= start left + expect(dragDirsRects[0].top + 50).toBeLessThan(currTop); + expect(currTop).toBeLessThanOrEqual(dragDirsRects[0].top + 100); + }); +}); + +describe('Linked igxDrag/igxDrop ', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + TestDragDropLinkedSingleComponent, + TestDragDropLinkedMixedComponent, + TestDragDropStrategiesComponent + ] + }) + .compileComponents(); + })); + + it('should trigger enter/onDrop/leave events when element is dropped inside and is linked with it.', async () => { + const fix = TestBed.createComponent(TestDragDropLinkedSingleComponent); + fix.componentInstance.dropArea.dropStrategy = IgxInsertDropStrategy; + fix.detectChanges(); + + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const dragDirsRects = getDragDirsRects(fix.componentInstance.dragElems); + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + + const dropArea = fix.componentInstance.dropArea; + const dropAreaRects = getElemRects(dropArea.element.nativeElement); + + spyOn(dropArea.enter, 'emit'); + spyOn(dropArea.leave, 'emit'); + spyOn(dropArea.dropped, 'emit'); + + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + expect(fix.componentInstance.container.nativeElement.children.length).toEqual(3); + expect(dropArea.element.nativeElement.children.length).toEqual(0); + + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + UIInteractions.simulatePointerEvent('pointermove', firstDrag.ghostElement, dropAreaRects.left + 100, dropAreaRects.top + 5); + await wait(100); + + expect(dropArea.enter.emit).toHaveBeenCalled(); + + // We need to trigger the pointerup on the ghostElement because this is the element we move and is under the mouse + UIInteractions.simulatePointerEvent('pointerup', firstDrag.ghostElement, dropAreaRects.left + 100, dropAreaRects.top + 20 ); + await wait(); + + expect(dropArea.dropped.emit).toHaveBeenCalled(); + expect(dropArea.leave.emit).toHaveBeenCalled(); + expect(fix.componentInstance.container.nativeElement.children.length).toEqual(2); + expect(dropArea.element.nativeElement.children.length).toEqual(1); + }); + + it('should not trigger enter/onDrop/leave events when element is dropped inside and is not linked with it.', async () => { + const fix = TestBed.createComponent(TestDragDropLinkedSingleComponent); + fix.detectChanges(); + + const secondDrag = fix.componentInstance.dragElems.toArray()[1]; + const firstElement = secondDrag.element.nativeElement; + const dragDirsRects = getDragDirsRects(fix.componentInstance.dragElems); + const startingX = (dragDirsRects[1].left + dragDirsRects[1].right) / 2; + const startingY = (dragDirsRects[1].top + dragDirsRects[1].bottom) / 2; + + const dropArea = fix.componentInstance.dropArea; + const dropAreaRects = getElemRects(dropArea.element.nativeElement); + + spyOn(dropArea.enter, 'emit'); + spyOn(dropArea.leave, 'emit'); + spyOn(dropArea.dropped, 'emit'); + + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + expect(fix.componentInstance.container.nativeElement.children.length).toEqual(3); + expect(dropArea.element.nativeElement.children.length).toEqual(0); + + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + UIInteractions.simulatePointerEvent('pointermove', secondDrag.ghostElement, dropAreaRects.left + 100, dropAreaRects.top + 5); + await wait(100); + + expect(dropArea.enter.emit).not.toHaveBeenCalled(); + + // We need to trigger the pointerup on the ghostElement because this is the element we move and is under the mouse + UIInteractions.simulatePointerEvent('pointerup', secondDrag.ghostElement, dropAreaRects.left + 100, dropAreaRects.top + 20 ); + await wait(); + + expect(dropArea.dropped.emit).not.toHaveBeenCalled(); + expect(dropArea.leave.emit).not.toHaveBeenCalled(); + expect(fix.componentInstance.container.nativeElement.children.length).toEqual(3); + expect(dropArea.element.nativeElement.children.length).toEqual(0); + }); + + it(`should not trigger enter/onDrop/leave events when element is dropped inside and is not linked with it + but linked with multiple other types of channels.`, async () => { + const fix = TestBed.createComponent(TestDragDropLinkedMixedComponent); + fix.detectChanges(); + + const secondDrag = fix.componentInstance.dragElems.toArray()[1]; + const firstElement = secondDrag.element.nativeElement; + const dragDirsRects = getDragDirsRects(fix.componentInstance.dragElems); + const startingX = (dragDirsRects[1].left + dragDirsRects[1].right) / 2; + const startingY = (dragDirsRects[1].top + dragDirsRects[1].bottom) / 2; + + const dropArea = fix.componentInstance.dropArea; + const dropAreaRects = getElemRects(dropArea.element.nativeElement); + + spyOn(dropArea.enter, 'emit'); + spyOn(dropArea.leave, 'emit'); + spyOn(dropArea.dropped, 'emit'); + + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + expect(fix.componentInstance.container.nativeElement.children.length).toEqual(3); + expect(dropArea.element.nativeElement.children.length).toEqual(0); + + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + UIInteractions.simulatePointerEvent('pointermove', secondDrag.ghostElement, dropAreaRects.left + 100, dropAreaRects.top + 5); + await wait(100); + + expect(dropArea.enter.emit).not.toHaveBeenCalled(); + + // We need to trigger the pointerup on the ghostElement because this is the element we move and is under the mouse + UIInteractions.simulatePointerEvent('pointerup', secondDrag.ghostElement, dropAreaRects.left + 100, dropAreaRects.top + 20 ); + await wait(); + + expect(dropArea.dropped.emit).not.toHaveBeenCalled(); + expect(dropArea.leave.emit).not.toHaveBeenCalled(); + expect(fix.componentInstance.container.nativeElement.children.length).toEqual(3); + expect(dropArea.element.nativeElement.children.length).toEqual(0); + }); + + it('Should not perform any action by default when an element is dropped inside.', async () => { + const fix = TestBed.createComponent(TestDragDropStrategiesComponent); + fix.detectChanges(); + + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const dragDirsRects = getDragDirsRects(fix.componentInstance.dragElems); + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + + const dropArea = fix.componentInstance.dropArea; + const dropAreaRects = getElemRects(dropArea.element.nativeElement); + + spyOn(dropArea.enter, 'emit'); + spyOn(dropArea.leave, 'emit'); + spyOn(dropArea.dropped, 'emit'); + + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + expect(fix.componentInstance.container.nativeElement.children.length).toEqual(1); + expect(dropArea.element.nativeElement.children.length).toEqual(2); + + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + UIInteractions.simulatePointerEvent('pointermove', firstDrag.ghostElement, dropAreaRects.left + 100, dropAreaRects.top + 5); + await wait(100); + + expect(dropArea.enter.emit).toHaveBeenCalled(); + + // We need to trigger the pointerup on the ghostElement because this is the element we move and is under the mouse + UIInteractions.simulatePointerEvent('pointerup', firstDrag.ghostElement, dropAreaRects.left + 100, dropAreaRects.top + 20 ); + await wait(); + + expect(dropArea.dropped.emit).toHaveBeenCalled(); + expect(dropArea.leave.emit).toHaveBeenCalled(); + expect(fix.componentInstance.container.nativeElement.children.length).toEqual(1); + expect(dropArea.element.nativeElement.children.length).toEqual(2); + + expect(fix.componentInstance.container.nativeElement.children[0]).toEqual(firstDrag.element.nativeElement); + expect(dropArea.element.nativeElement.children[0]).not.toEqual(firstDrag.element.nativeElement); + expect(dropArea.element.nativeElement.children[1]).not.toEqual(firstDrag.element.nativeElement); + }); + + it('Should put dropped element as a last child when Append drop strategy is used.', async () => { + const fix = TestBed.createComponent(TestDragDropStrategiesComponent); + fix.componentInstance.dropArea.dropStrategy = IgxAppendDropStrategy; + fix.detectChanges(); + + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const dragDirsRects = getDragDirsRects(fix.componentInstance.dragElems); + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + + const dropArea = fix.componentInstance.dropArea; + const dropAreaRects = getElemRects(dropArea.element.nativeElement); + + spyOn(dropArea.enter, 'emit'); + spyOn(dropArea.leave, 'emit'); + spyOn(dropArea.dropped, 'emit'); + + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + expect(fix.componentInstance.container.nativeElement.children.length).toEqual(1); + expect(dropArea.element.nativeElement.children.length).toEqual(2); + + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + UIInteractions.simulatePointerEvent('pointermove', firstDrag.ghostElement, dropAreaRects.left + 100, dropAreaRects.top + 5); + await wait(100); + + expect(dropArea.enter.emit).toHaveBeenCalled(); + + // We need to trigger the pointerup on the ghostElement because this is the element we move and is under the mouse + UIInteractions.simulatePointerEvent('pointerup', firstDrag.ghostElement, dropAreaRects.left + 100, dropAreaRects.top + 20 ); + await wait(); + + expect(dropArea.dropped.emit).toHaveBeenCalled(); + expect(dropArea.leave.emit).toHaveBeenCalled(); + expect(fix.componentInstance.container.nativeElement.children.length).toEqual(0); + expect(dropArea.element.nativeElement.children.length).toEqual(3); + // Should be appended at the end + expect(dropArea.element.nativeElement.children[2]).toEqual(firstDrag.element.nativeElement); + }); + + it('Should put dropped element as a first child when Prepend drop strategy is used.', async () => { + const fix = TestBed.createComponent(TestDragDropStrategiesComponent); + fix.componentInstance.dropArea.dropStrategy = IgxPrependDropStrategy; + fix.detectChanges(); + + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const dragDirsRects = getDragDirsRects(fix.componentInstance.dragElems); + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + + const dropArea = fix.componentInstance.dropArea; + const dropAreaRects = getElemRects(dropArea.element.nativeElement); + + spyOn(dropArea.enter, 'emit'); + spyOn(dropArea.leave, 'emit'); + spyOn(dropArea.dropped, 'emit'); + + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + expect(fix.componentInstance.container.nativeElement.children.length).toEqual(1); + expect(dropArea.element.nativeElement.children.length).toEqual(2); + + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + UIInteractions.simulatePointerEvent('pointermove', firstDrag.ghostElement, dropAreaRects.left + 100, dropAreaRects.top + 5); + await wait(100); + + expect(dropArea.enter.emit).toHaveBeenCalled(); + + // We need to trigger the pointerup on the ghostElement because this is the element we move and is under the mouse + UIInteractions.simulatePointerEvent('pointerup', firstDrag.ghostElement, dropAreaRects.left + 100, dropAreaRects.top + 20 ); + await wait(); + + expect(dropArea.dropped.emit).toHaveBeenCalled(); + expect(dropArea.leave.emit).toHaveBeenCalled(); + expect(fix.componentInstance.container.nativeElement.children.length).toEqual(0); + expect(dropArea.element.nativeElement.children.length).toEqual(3); + // Should be appended at the end + expect(dropArea.element.nativeElement.children[0]).toEqual(firstDrag.element.nativeElement); + }); + + it(`Should put dropped element as a second child when Insert drop strategy is used + and element is dropped over the second child already in the igxDrop area.`, async () => { + const fix = TestBed.createComponent(TestDragDropStrategiesComponent); + fix.componentInstance.dropArea.dropStrategy = IgxInsertDropStrategy; + fix.detectChanges(); + + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const dragDirsRects = getDragDirsRects(fix.componentInstance.dragElems); + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + + const dropArea = fix.componentInstance.dropArea; + const dropAreaRects = getElemRects(dropArea.element.nativeElement); + + spyOn(dropArea.enter, 'emit'); + spyOn(dropArea.leave, 'emit'); + spyOn(dropArea.dropped, 'emit'); + + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + expect(fix.componentInstance.container.nativeElement.children.length).toEqual(1); + expect(dropArea.element.nativeElement.children.length).toEqual(2); + + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + UIInteractions.simulatePointerEvent('pointermove', firstDrag.ghostElement, dropAreaRects.left + 150, dropAreaRects.top + 5); + await wait(100); + + expect(dropArea.enter.emit).toHaveBeenCalled(); + + // We need to trigger the pointerup on the ghostElement because this is the element we move and is under the mouse + UIInteractions.simulatePointerEvent('pointerup', firstDrag.ghostElement, dropAreaRects.left + 150, dropAreaRects.top + 20 ); + await wait(); + + expect(dropArea.dropped.emit).toHaveBeenCalled(); + expect(dropArea.leave.emit).toHaveBeenCalled(); + expect(fix.componentInstance.container.nativeElement.children.length).toEqual(0); + expect(dropArea.element.nativeElement.children.length).toEqual(3); + // Should be inserted between other chips + expect(dropArea.element.nativeElement.children[1]).toEqual(firstDrag.element.nativeElement); + }); + + it('Should cancel drop strategy when the dropped event is canceled.', async () => { + const fix = TestBed.createComponent(TestDragDropStrategiesComponent); + fix.componentInstance.dropArea.dropStrategy = IgxInsertDropStrategy; + fix.detectChanges(); + + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const dragDirsRects = getDragDirsRects(fix.componentInstance.dragElems); + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + + const dropArea = fix.componentInstance.dropArea; + const dropAreaRects = getElemRects(dropArea.element.nativeElement); + + spyOn(dropArea.enter, 'emit'); + spyOn(dropArea.leave, 'emit'); + + fix.componentInstance.dropArea.dropped.pipe(first()).subscribe(((e: IDropDroppedEventArgs) => e.cancel = true)); + + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + expect(fix.componentInstance.container.nativeElement.children.length).toEqual(1); + expect(dropArea.element.nativeElement.children.length).toEqual(2); + + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + UIInteractions.simulatePointerEvent('pointermove', firstDrag.ghostElement, dropAreaRects.left + 100, dropAreaRects.top + 5); + await wait(100); + + expect(dropArea.enter.emit).toHaveBeenCalled(); + + // We need to trigger the pointerup on the ghostElement because this is the element we move and is under the mouse + UIInteractions.simulatePointerEvent('pointerup', + firstDrag.ghostElement, + dropAreaRects.left + 100, + dropAreaRects.top + 20 + ); + fix.detectChanges(); + await wait(100); + + expect(dropArea.leave.emit).toHaveBeenCalled(); + expect(fix.componentInstance.container.nativeElement.children.length).toEqual(1); + expect(dropArea.element.nativeElement.children.length).toEqual(2); + + expect(fix.componentInstance.container.nativeElement.children[0]).toEqual(firstDrag.element.nativeElement); + expect(dropArea.element.nativeElement.children[0]).not.toEqual(firstDrag.element.nativeElement); + expect(dropArea.element.nativeElement.children[1]).not.toEqual(firstDrag.element.nativeElement); + }); + + + it('Should allow dragging when the dragChannel is array and dropChannel is primitive.', async () => { + const fix = TestBed.createComponent(TestDragDropStrategiesComponent); + fix.componentInstance.dropArea.dropStrategy = IgxAppendDropStrategy; + fix.detectChanges(); + + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const dragDirsRects = getDragDirsRects(fix.componentInstance.dragElems); + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + + firstDrag.dragChannel = [1, 2, 3]; + fix.detectChanges(); + + const dropArea = fix.componentInstance.dropArea; + const dropAreaRects = getElemRects(dropArea.element.nativeElement); + + spyOn(dropArea.enter, 'emit'); + spyOn(dropArea.leave, 'emit'); + spyOn(dropArea.dropped, 'emit'); + + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + expect(fix.componentInstance.container.nativeElement.children.length).toEqual(1); + expect(dropArea.element.nativeElement.children.length).toEqual(2); + + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + UIInteractions.simulatePointerEvent('pointermove', firstDrag.ghostElement, dropAreaRects.left + 100, dropAreaRects.top + 5); + await wait(100); + + expect(dropArea.enter.emit).toHaveBeenCalled(); + + // We need to trigger the pointerup on the ghostElement because this is the element we move and is under the mouse + UIInteractions.simulatePointerEvent('pointerup', firstDrag.ghostElement, dropAreaRects.left + 100, dropAreaRects.top + 20 ); + await wait(); + + expect(dropArea.dropped.emit).toHaveBeenCalled(); + expect(dropArea.leave.emit).toHaveBeenCalled(); + expect(fix.componentInstance.container.nativeElement.children.length).toEqual(0); + expect(dropArea.element.nativeElement.children.length).toEqual(3); + // Should be appended at the end + expect(dropArea.element.nativeElement.children[2]).toEqual(firstDrag.element.nativeElement); + }); + + it('Should allow dragging when the dragChannel is primitive and dropChannel is array.', async () => { + const fix = TestBed.createComponent(TestDragDropStrategiesComponent); + fix.componentInstance.dropArea.dropStrategy = IgxAppendDropStrategy; + fix.componentInstance.dropArea.dropChannel = [1, 2, 3]; + fix.detectChanges(); + + const firstDrag = fix.componentInstance.dragElems.first; + const firstElement = firstDrag.element.nativeElement; + const dragDirsRects = getDragDirsRects(fix.componentInstance.dragElems); + const startingX = (dragDirsRects[0].left + dragDirsRects[0].right) / 2; + const startingY = (dragDirsRects[0].top + dragDirsRects[0].bottom) / 2; + + fix.detectChanges(); + + const dropArea = fix.componentInstance.dropArea; + const dropAreaRects = getElemRects(dropArea.element.nativeElement); + + spyOn(dropArea.enter, 'emit'); + spyOn(dropArea.leave, 'emit'); + spyOn(dropArea.dropped, 'emit'); + + UIInteractions.simulatePointerEvent('pointerdown', firstElement, startingX, startingY); + fix.detectChanges(); + await wait(); + + expect(fix.componentInstance.container.nativeElement.children.length).toEqual(1); + expect(dropArea.element.nativeElement.children.length).toEqual(2); + + UIInteractions.simulatePointerEvent('pointermove', firstElement, startingX + 10, startingY + 10); + fix.detectChanges(); + await wait(100); + + UIInteractions.simulatePointerEvent('pointermove', firstDrag.ghostElement, dropAreaRects.left + 100, dropAreaRects.top + 5); + await wait(100); + + expect(dropArea.enter.emit).toHaveBeenCalled(); + + // We need to trigger the pointerup on the ghostElement because this is the element we move and is under the mouse + UIInteractions.simulatePointerEvent('pointerup', firstDrag.ghostElement, dropAreaRects.left + 100, dropAreaRects.top + 20 ); + await wait(); + + expect(dropArea.dropped.emit).toHaveBeenCalled(); + expect(dropArea.leave.emit).toHaveBeenCalled(); + expect(fix.componentInstance.container.nativeElement.children.length).toEqual(0); + expect(dropArea.element.nativeElement.children.length).toEqual(3); + // Should be appended at the end + expect(dropArea.element.nativeElement.children[2]).toEqual(firstDrag.element.nativeElement); + }); +}); + +describe('Nested igxDrag elements', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TestDragDropNestedComponent] + }) + .compileComponents(); + })); + + it('should correctly move nested element using drag handle.', async () => { + const fix = TestBed.createComponent(TestDragDropNestedComponent); + fix.detectChanges(); + + const rootList = fix.componentInstance.dragElems.get(0); + const firstCategory = fix.componentInstance.dragElems.get(1); + const firstMovie = fix.componentInstance.dragElems.get(2); + const thirdElement = firstMovie.element.nativeElement; + const dragDirsRects = getElemRects(thirdElement); + firstMovie.ghost = false; + firstMovie.dragTolerance = 0; + + spyOn(rootList.dragStart, 'emit'); + spyOn(firstCategory.dragStart, 'emit'); + spyOn(firstMovie.dragStart, 'emit'); + + const dragHandle = thirdElement.children[0].children[0]; + const dragHandleRects = dragHandle.getBoundingClientRect(); + const handleStartX = (dragHandleRects.left + dragHandleRects.right) / 2; + const handleStartY = (dragHandleRects.top + dragHandleRects.bottom) / 2; + UIInteractions.simulatePointerEvent('pointerdown', dragHandle, handleStartX, handleStartY); + fix.detectChanges(); + await wait(); + + UIInteractions.simulatePointerEvent('pointermove', dragHandle, handleStartX + 10, handleStartY + 10); + fix.detectChanges(); + await wait(100); + + UIInteractions.simulatePointerEvent('pointermove', dragHandle, handleStartX + 20, handleStartY + 20); + fix.detectChanges(); + await wait(100); + + UIInteractions.simulatePointerEvent('pointerup', dragHandle, handleStartX + 20, handleStartY + 20); + fix.detectChanges(); + await wait(); + + expect(thirdElement.getBoundingClientRect().left).toEqual(dragDirsRects.left + 20); + expect(thirdElement.getBoundingClientRect().top).toEqual(dragDirsRects.top + 20); + expect(firstMovie.dragStart.emit).toHaveBeenCalled(); + expect(rootList.dragStart.emit).not.toHaveBeenCalled(); + expect(firstCategory.dragStart.emit).not.toHaveBeenCalled(); + }); +}) + +const getDragDirsRects = (dragDirs: QueryList) => { + const dragDirsRects = []; + dragDirs.forEach((dragDir) => { + const dragElem = dragDir.element.nativeElement; + dragDirsRects.push(getElemRects(dragElem)); + }); + + return dragDirsRects; +}; + +const getElemRects = (nativeElem) => ({ + top: nativeElem.getBoundingClientRect().top, + left: nativeElem.getBoundingClientRect().left, + right: nativeElem.getBoundingClientRect().right, + bottom: nativeElem.getBoundingClientRect().bottom +}); + + +const generalStyles = [` + .container { + width: 500px; + height: 100px; + display: flex; + flex-flow: row; + } + .dragElem { + width: 100px; + height: 50px; + margin: 10px; + background-color: #66cc99; + text-align: center; + user-select: none; + } + .ghostElement { + width: 100px; + height: 50px; + margin: 10px; + background-color: #66cc99; + text-align: center; + user-select: none; + } + .dropAreaStyle { + width: 500px; + height: 100px; + background-color: #cccccc; + display: flex; + flex-flow: row; + } + .dragHandle { + width: 10px; + height: 10px; + background-color: red; + float: right; + margin: 5px; + } + .rootList { + width: 300px; + height: 800px; + } + .movieListItem { + padding: 5px; + margin-top: 5px; + margin-left: 15px; + border-radius: 5px; + box-shadow: 0 2px 6px 0 gray; + background-color: rgba(232, 232, 232, .5); + } +`]; + +@Component({ + styles: generalStyles, + template: ` +

Draggable elements:

+
+
Drag 1
+
Drag 2
+
+ Drag 3 +
+
+
+
+
+ +
Drag Template
+
+ +
+ Drag Template Content +
+
+
+
+

Drop area:

+
+ `, + imports: [IgxDragDirective, IgxDropDirective, IgxDragHandleDirective, IgxDragIgnoreDirective] +}) +class TestDragDropComponent { + public renderer = inject(Renderer2); + + @ViewChildren(IgxDragDirective) + public dragElems: QueryList; + + @ViewChild('dropArea', { read: IgxDropDirective, static: true }) + public dropArea: IgxDropDirective; + + @ViewChild('container', { read: ElementRef, static: true }) + public container: ElementRef; + + @ViewChild('ghostTemplate', { read: TemplateRef, static: true }) + public ghostTemplate: TemplateRef; + + @ViewChild('ghostTemplateContents', { read: TemplateRef, static: true }) + public ghostTemplateContents: TemplateRef; +} + +@Component({ + styles: generalStyles, + template: ` +

Draggable elements:

+
+
Drag 1
+
Drag 2
+
Drag 3
+ +
Drag Template
+
+
+
+

Drop area:

+
+ `, + imports: [IgxDragDirective, IgxDropDirective] +}) +class TestDragDropLinkedSingleComponent extends TestDragDropComponent { } + +@Component({ + styles: generalStyles, + template: ` +

Draggable elements:

+
+
Drag 1
+
Drag 2
+
Drag 3
+ +
Drag Template
+
+
+
+

Drop area:

+
+ `, + imports: [IgxDragDirective, IgxDropDirective] +}) +class TestDragDropLinkedMixedComponent extends TestDragDropComponent { } + +@Component({ + styles: generalStyles, + template: ` +

Draggable elements:

+
+
Drag 1
+ +
Drag Template
+
+
+
+

Drop area:

+
+
Drag 2
+
Drag 3
+
+ `, + imports: [IgxDragDirective, IgxDropDirective] +}) +class TestDragDropStrategiesComponent extends TestDragDropLinkedSingleComponent { } + +@Component({ + styles: generalStyles, + template: ` +
+
+ drag_indicator + Movies list +
+ @for (category of categoriesNotes; track category.text) { +
+
+ drag_indicator + {{category.text}} +
+ @for (note of getCategoryMovies(category.text); track note.text) { +
+
+ drag_indicator + {{note.text}} +
+
+ } +
+ } +
+ `, + imports: [IgxIconComponent, IgxDragDirective, IgxDragHandleDirective] +}) +class TestDragDropNestedComponent extends TestDragDropComponent { + protected categoriesNotes = [ + { text: 'Action', dragged: false }, + { text: 'Fantasy', dragged: false } + ]; + protected listNotes = [ + { text: 'Avengers: Endgame', category: 'Action', dragged: false }, + { text: 'Avatar', category: 'Fantasy', dragged: false }, + { text: 'Titanic', category: 'Drama', dragged: false }, + { text: 'Star Wars: The Force Awakens', category: 'Fantasy', dragged: false }, + { text: 'Avengers: Infinity War', category: 'Action', dragged: false }, + { text: 'Jurassic World', category: 'Fantasy', dragged: false }, + { text: 'The Avengers', category: 'Action', dragged: false } + ]; + + protected getCategoryMovies(inCategory: string){ + return this.listNotes.filter(item => item.category === inCategory); + } + } diff --git a/projects/igniteui-angular/directives/src/directives/drag-drop/drag-drop.strategy.ts b/projects/igniteui-angular/directives/src/directives/drag-drop/drag-drop.strategy.ts new file mode 100644 index 00000000000..f22361f1fbd --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/drag-drop/drag-drop.strategy.ts @@ -0,0 +1,64 @@ +import { Renderer2 } from '@angular/core'; +import { IgxDragDirective, IgxDropDirective } from './drag-drop.directive'; + + +export interface IDropStrategy { + dropAction: (drag: IgxDragDirective, drop: IgxDropDirective, atIndex: number) => void; +} + +// @dynamic +export class IgxDefaultDropStrategy implements IDropStrategy { + + public dropAction(_drag: IgxDragDirective, _drop: IgxDropDirective, _atIndex: number) { } +} + +// @dynamic +export class IgxAppendDropStrategy implements IDropStrategy { + + constructor(private _renderer: Renderer2) { } + + public dropAction(drag: IgxDragDirective, drop: IgxDropDirective, _atIndex: number) { + const dragElement = drag.element.nativeElement; + const dropAreaElement = drop.element.nativeElement; + this._renderer.removeChild(dragElement.parentNode, dragElement); + this._renderer.appendChild(dropAreaElement, dragElement); + } +} + +// @dynamic +export class IgxPrependDropStrategy implements IDropStrategy { + + constructor(private _renderer: Renderer2) { } + + public dropAction(drag: IgxDragDirective, drop: IgxDropDirective, _atIndex: number) { + const dragElement = drag.element.nativeElement; + const dropAreaElement = drop.element.nativeElement; + this._renderer.removeChild(dragElement.parentNode, dragElement); + if (dropAreaElement.children.length) { + this._renderer.insertBefore(dropAreaElement, dragElement, dropAreaElement.children[0]); + } else { + this._renderer.appendChild(dropAreaElement, dragElement); + } + } +} + +// @dynamic +export class IgxInsertDropStrategy implements IDropStrategy { + + constructor(private _renderer: Renderer2) { } + + public dropAction(drag: IgxDragDirective, drop: IgxDropDirective, atIndex: number) { + if (drag.element.nativeElement.parentElement === drop.element.nativeElement && atIndex === -1) { + return; + } + + const dragElement = drag.element.nativeElement; + const dropAreaElement = drop.element.nativeElement; + this._renderer.removeChild(dragElement.parentNode, dragElement); + if (atIndex !== -1 && dropAreaElement.children.length > atIndex) { + this._renderer.insertBefore(dropAreaElement, dragElement, dropAreaElement.children[atIndex]); + } else { + this._renderer.appendChild(dropAreaElement, dragElement); + } + } +} diff --git a/projects/igniteui-angular/directives/src/directives/drag-drop/public_api.ts b/projects/igniteui-angular/directives/src/directives/drag-drop/public_api.ts new file mode 100644 index 00000000000..c32397aaf7e --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/drag-drop/public_api.ts @@ -0,0 +1,12 @@ +import { IgxDragDirective, IgxDragHandleDirective, IgxDragIgnoreDirective, IgxDropDirective } from './drag-drop.directive'; + +export * from './drag-drop.strategy'; +export * from './drag-drop.directive'; + +/* NOTE: Drag and drop directives collection for ease-of-use import in standalone components scenario */ +export const IGX_DRAG_DROP_DIRECTIVES = [ + IgxDragDirective, + IgxDropDirective, + IgxDragHandleDirective, + IgxDragIgnoreDirective +] as const; diff --git a/projects/igniteui-angular/directives/src/directives/filter/README-FILTER.md b/projects/igniteui-angular/directives/src/directives/filter/README-FILTER.md new file mode 100644 index 00000000000..ec10085e9ec --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/filter/README-FILTER.md @@ -0,0 +1,80 @@ +# igxFilter + +#### Category +_Directives_ + +## Description +_Filters a datasource or items in list-based widget._ + +### More Info +igxFilter can be used to filter data source of list-base widgets, like list, tabbar, carousel, etc. It can be applied as a pipe or as a directive. +igxFilter is not coupled to any specific widget and it can filters any widget that represent list of items. +The widgets that cannot use igxFilter are those which purpose is more visual, than data representative. _Example: avatar, checkbox, progressbas, etc._ + +## Options + +**Filter options** collects all configurations of igxFilter: + + * `inputValue` - Input text value that will be used as a filtering pattern (matching condition is based on it) + * `key` - Item property, which value should be used for filtering + * `items` - Represent items of the list. It should be used in scenarios where filter is directive + * `get_value` - Function - get value to be tested from the item. Default behavior: if key is provided - returns item value, otherwise returns item text content + * `formatter` - Function - formats the original text before matching process. Default behavior: returns text to lower case + * `matchFn`- Function - determines whether the item met the condition. Default behavior: "contains" + * `metConditionFn` - Function - executed after matching test for every matched item. Default behavior: shows the item + * `overdueConditionFn` - Function - executed for every NOT matched item after matching test. Default behavior: hides the item + +## Events + + * `filtering` - Triggered before the filtering process. +The handler of this event will be executed synchronously (the filtering will be processed after the handler is executed). Gives you a chance to cancel the filtering before it is processed, by the Boolean property `cancel` of the input object. + + * `filtered` - Triggered after the filtering is done. Returns an object with property `filteredItems` - the result of the filtering. + +## Usage + +### As a pipe + +Code in template: + + + + {{item.text}} + + +Code in .ts: + + navItems: Array = [ + { id:"1", text: "Item 1" }, + { id:"2", text: "Item 2" }, + { id:"3", text: "Item 3" }, + { id:"4", text: "Item 4" } + ]; + + get fo() { + var _fo = new IgxFilterOptions(); + _fo.key = "text"; + _fo.inputValue = this.search1; + return _fo; + } + +### As a directive + +Code in template: + + + + Mildly Sweet + Golden Delicious + Cosmic Crisp + Pinova + + +Code in .ts: + + get fo() { + var _fo = new IgxFilterOptions(); + _fo.key = "text"; + _fo.inputValue = this.search1; + return _fo; + } \ No newline at end of file diff --git a/projects/igniteui-angular/directives/src/directives/filter/filter.directive.spec.ts b/projects/igniteui-angular/directives/src/directives/filter/filter.directive.spec.ts new file mode 100644 index 00000000000..f87edf25810 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/filter/filter.directive.spec.ts @@ -0,0 +1,243 @@ +import { Component, ViewChild } from '@angular/core'; +import { ComponentFixtureAutoDetect, TestBed, waitForAsync } from '@angular/core/testing'; +import { IgxListComponent, IgxListItemComponent } from 'igniteui-angular/list'; +import { IgxFilterDirective, IgxFilterOptions, IgxFilterPipe } from './filter.directive'; + +describe('Filter', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [DeclarativeListTestComponent, DynamicListTestComponent], + providers: [ + { provide: ComponentFixtureAutoDetect, useValue: true } + ] + }).compileComponents(); + })); + + it('should filter declaratively created list', () => { + const fixture = TestBed.createComponent(DeclarativeListTestComponent); + const list = fixture.componentInstance.list; + let visibleItems; + + fixture.detectChanges(); + expect(list.items.length).toBe(3); + const items = list.items; + + for (const item of items) { + expect(item instanceof IgxListItemComponent).toBeTruthy(); + } + + visibleItems = items.filter((listItem) => !listItem.hidden); + expect(visibleItems.length).toBe(3); + + fixture.componentInstance.filterValue = '1'; + fixture.detectChanges(); + + visibleItems = items.filter((listItem) => !listItem.hidden); + expect(visibleItems.length).toBe(1); + expect(visibleItems[0] instanceof IgxListItemComponent).toBeTruthy(); + + fixture.componentInstance.filterValue = ''; + fixture.detectChanges(); + + visibleItems = items.filter((listItem) => !listItem.hidden); + expect(visibleItems.length).toBe(3); + }); + + it('should filter dynamically created list', () => { + const fixture = TestBed.createComponent(DynamicListTestComponent); + const list = fixture.componentInstance.list; + + fixture.detectChanges(); + expect(list.items.length).toBe(4); + + for (const item of list.items) { + expect(item instanceof IgxListItemComponent).toBeTruthy(); + } + + expect(list.items.length).toBe(4); + + fixture.componentInstance.filterValue = '1'; + fixture.detectChanges(); + + expect(list.items.length).toBe(1); + expect(list.items[0] instanceof IgxListItemComponent).toBeTruthy(); + + fixture.componentInstance.filterValue = ''; + fixture.detectChanges(); + + expect(list.items.length).toBe(4); + }); + + it('should filter a list by multiple keys', () => { + const fixture = TestBed.createComponent(DynamicListTestComponent); + const list = fixture.componentInstance.list; + + fixture.detectChanges(); + expect(list.items.length).toBe(4); + const items = list.items; + + for (const item of items) { + expect(item instanceof IgxListItemComponent).toBeTruthy(); + } + + fixture.componentInstance.fo.key = ['key', 'text']; + fixture.componentInstance.filterValue = '1'; + fixture.detectChanges(); + + expect(list.items.length).toBe(1); + expect(list.items[0] instanceof IgxListItemComponent).toBeTruthy(); + + fixture.componentInstance.filterValue = ''; + fixture.detectChanges(); + + expect(list.items.length).toBe(4); + + fixture.componentInstance.filterValue = 'Nav3'; + fixture.detectChanges(); + + expect(list.items.length).toBe(1); + }); + + it('should emit filter events on declaratively created list', () => { + let visibleItems; + const fixture = TestBed.createComponent(DeclarativeListTestComponent); + const list = fixture.componentInstance.list; + const logInput = fixture.componentInstance.logInput; + + fixture.detectChanges(); + visibleItems = list.items.filter((listItem) => !(listItem as IgxListItemComponent).hidden); + expect(list.items.length).toBe(3); + expect(visibleItems.length).toBe(3); + + logInput.nativeElement.value = ''; + fixture.componentInstance.filteredArgs = undefined; + fixture.componentInstance.filteringArgs = undefined; + fixture.componentInstance.filterValue = '2'; + fixture.detectChanges(); + + visibleItems = list.items.filter((listItem) => !(listItem as IgxListItemComponent).hidden); + expect(visibleItems.length).toBe(1); + + expect(logInput.nativeElement.value).toBe('filtering;filtered;'); + expect(fixture.componentInstance.filteringArgs).toBeDefined(); + expect(fixture.componentInstance.filteringArgs.cancel).toBeDefined(); + expect(fixture.componentInstance.filteringArgs.cancel).toBeFalsy(); + expect(fixture.componentInstance.filteringArgs.items).toBeDefined(); + expect(fixture.componentInstance.filteringArgs.items instanceof Array).toBeTruthy(); + expect(fixture.componentInstance.filteringArgs.items.length).toBe(3); + + expect(fixture.componentInstance.filteredArgs).toBeDefined(); + expect(fixture.componentInstance.filteredArgs.filteredItems).toBeDefined(); + expect(fixture.componentInstance.filteredArgs.filteredItems instanceof Array).toBeTruthy(); + expect(fixture.componentInstance.filteredArgs.filteredItems.length).toBe(1); + expect(fixture.componentInstance.filteredArgs.filteredItems[0]).toBe(visibleItems[0]); + }); + + it('should cancel filtering on declaratively created list', () => { + let visibleItems; + const fixture = TestBed.createComponent(DeclarativeListTestComponent); + const list = fixture.componentInstance.list; + const logInput = fixture.componentInstance.logInput; + + fixture.detectChanges(); + visibleItems = list.items.filter((listItem) => !(listItem as IgxListItemComponent).hidden); + expect(list.items.length).toBe(3); + expect(visibleItems.length).toBe(3); + + logInput.nativeElement.value = ''; + fixture.componentInstance.filteredArgs = undefined; + fixture.componentInstance.filteringArgs = undefined; + fixture.componentInstance.isCanceled = true; + fixture.componentInstance.filterValue = '2'; + fixture.detectChanges(); + + visibleItems = list.items.filter((listItem) => !(listItem as IgxListItemComponent).hidden); + expect(visibleItems.length).toBe(3); + + expect(logInput.nativeElement.value).toBe('filtering;'); + expect(fixture.componentInstance.filteringArgs).toBeDefined(); + expect(fixture.componentInstance.filteringArgs.cancel).toBeDefined(); + expect(fixture.componentInstance.filteringArgs.cancel).toBeTruthy(); + expect(fixture.componentInstance.filteringArgs.items).toBeDefined(); + expect(fixture.componentInstance.filteringArgs.items instanceof Array).toBeTruthy(); + expect(fixture.componentInstance.filteringArgs.items.length).toBe(3); + + expect(fixture.componentInstance.filteredArgs).toBeUndefined(); + }); +}); + +@Component({ + template: ` + + Header + Item 1 + Item 2 + Item 3 + + `, + imports: [IgxListComponent, IgxListItemComponent, IgxFilterDirective] +}) +class DeclarativeListTestComponent { + @ViewChild(IgxListComponent, { static: true }) public list: IgxListComponent; + @ViewChild('logInput', { static: true }) public logInput: any; + + public filterValue: string; + public isCanceled = false; + public filteringArgs: FilteringArgs; + public filteredArgs: FilteringArgs; + + public get fo() { + const options = new IgxFilterOptions(); + options.items = this.list.items; + options.inputValue = this.filterValue; + + return options; + } + + public filteringHandler = function(args) { + args.cancel = this.isCanceled; + this.logInput.nativeElement.value += 'filtering;'; + this.filteringArgs = args; + }; + + public filteredHandler = function(args) { + this.logInput.nativeElement.value += 'filtered;'; + this.filteredArgs = args; + }; +} + +@Component({ + template: ` + + @for (item of dataSourceItems | igxFilter: fo; track item.key) { + {{item.text}} + } + `, + imports: [IgxListComponent, IgxListItemComponent, IgxFilterPipe] +}) +class DynamicListTestComponent { + @ViewChild(IgxListComponent, { static: true }) public list: IgxListComponent; + + public filterValue: string; + public isCanceled = false; + + protected dataSourceItems = [ + { key: '1', text: 'Nav1' }, + { key: '2', text: 'Nav2' }, + { key: '3', text: 'Nav3' }, + { key: '4', text: 'Nav4' } + ]; + + public get fo() { + const options = new IgxFilterOptions(); + options.inputValue = this.filterValue; + options.key = 'text'; + return options; + } +} + +class FilteringArgs { + public cancel: boolean; + public items: IgxListItemComponent[]; + public filteredItems: IgxListItemComponent[]; +} diff --git a/projects/igniteui-angular/directives/src/directives/filter/filter.directive.ts b/projects/igniteui-angular/directives/src/directives/filter/filter.directive.ts new file mode 100644 index 00000000000..411b9357ef4 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/filter/filter.directive.ts @@ -0,0 +1,171 @@ +import { + Directive, + EventEmitter, + Input, + OnChanges, + Output, + Pipe, + PipeTransform, + SimpleChanges +} from '@angular/core'; + +export class IgxFilterOptions { + // Input text value that will be used as a filtering pattern (matching condition is based on it) + public inputValue = ''; + + // Item property, which value should be used for filtering + public key: string | string[]; + + // Represent items of the list. It should be used to handle declaratively defined widgets + public items: any[]; + + // Function - get value to be tested from the item + // item - single item of the list to be filtered + // key - property name of item, which value should be tested + // Default behavior - returns "key"- named property value of item if key is provided, + // otherwise textContent of the item's html element + public get_value(item: any, key: string): string { + let result = ''; + + if (key && item[key]) { + result = item[key].toString(); + } else if (item.element) { + if (item.element.nativeElement) { + result = item.element.nativeElement.textContent.trim(); + // Check if element doesn't return the DOM element directly + } else if (item.element.textContent) { + result = item.element.textContent.trim(); + } + } + + return result; + } + + // Function - formats the original text before matching process + // Default behavior - returns text to lower case + public formatter(valueToTest: string): string { + return valueToTest.toLowerCase(); + } + + // Function - determines whether the item met the condition + // valueToTest - text value that should be tested + // inputValue - text value from input that condition is based on + // Default behavior - "contains" + public matchFn(valueToTest: string, inputValue: string): boolean { + return valueToTest.indexOf(inputValue && inputValue.toLowerCase() || '') > -1; + } + + // Function - executed after matching test for every matched item + // Default behavior - shows the item + public metConditionFn(item: any) { + if (item.hasOwnProperty('hidden')) { + item.hidden = false; + } + } + + // Function - executed for every NOT matched item after matching test + // Default behavior - hides the item + public overdueConditionFn(item: any) { + if (item.hasOwnProperty('hidden')) { + item.hidden = true; + } + } +} + + +@Directive({ + selector: '[igxFilter]', + standalone: true +}) +export class IgxFilterDirective implements OnChanges { + @Output() public filtering = new EventEmitter(false); // synchronous event emitter + @Output() public filtered = new EventEmitter(); + + @Input('igxFilter') public filterOptions: IgxFilterOptions; + + constructor() { + } + + public ngOnChanges(changes: SimpleChanges) { + // Detect only changes of input value + if (changes.filterOptions && + changes.filterOptions.currentValue && + changes.filterOptions.currentValue.inputValue !== undefined && + changes.filterOptions.previousValue && + changes.filterOptions.currentValue.inputValue !== changes.filterOptions.previousValue.inputValue) { + this.filter(); + } + } + + private filter() { + if (!this.filterOptions.items) { + return; + } + + const args = { cancel: false, items: this.filterOptions.items }; + this.filtering.emit(args); + + if (args.cancel) { + return; + } + + const pipe = new IgxFilterPipe(); + + const filtered = pipe.transform(this.filterOptions.items, this.filterOptions); + this.filtered.emit({ filteredItems: filtered }); + } +} + +@Pipe({ + name: 'igxFilter', + pure: false, + standalone: true +}) +export class IgxFilterPipe implements PipeTransform { + private findMatchByKey(item: any, options: IgxFilterOptions, key: string) { + const match = options.matchFn(options.formatter(options.get_value(item, key)), options.inputValue); + + if (match) { + if (options.metConditionFn) { + options.metConditionFn(item); + } + } else { + if (options.overdueConditionFn) { + options.overdueConditionFn(item); + } + } + + return match; + } + + public transform(items: any[], + // options - initial settings of filter functionality + options: IgxFilterOptions) { + + let result = []; + + if (!items || !items.length || !options) { + return; + } + + if (options.items) { + items = options.items; + } + + result = items.filter((item: any) => { + if (!Array.isArray(options.key)) { + return this.findMatchByKey(item, options, options.key); + } else { + let isMatch = false; + options.key.forEach(key => { + if (this.findMatchByKey(item, options, key)) { + isMatch = true; + } + }); + return isMatch; + } + }); + + return result; + } +} diff --git a/projects/igniteui-angular/directives/src/directives/filter/filter.module.ts b/projects/igniteui-angular/directives/src/directives/filter/filter.module.ts new file mode 100644 index 00000000000..920781dad9a --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/filter/filter.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from '@angular/core'; +import { IgxFilterDirective, IgxFilterPipe } from './filter.directive'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [IgxFilterDirective, IgxFilterPipe], + exports: [IgxFilterDirective, IgxFilterPipe] +}) +export class IgxFilterModule { +} diff --git a/projects/igniteui-angular/directives/src/directives/focus-trap/README.md b/projects/igniteui-angular/directives/src/directives/focus-trap/README.md new file mode 100644 index 00000000000..358d5e5841f --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/focus-trap/README.md @@ -0,0 +1,17 @@ +# IgxFocusTrap Directive + +The **IgxFocusTrap** directive provides functionality to trap the focus within an element. The focus should not leave the element when the user keeps tabbing through the focusable elements. Typically, when the focus leaves the last element, it should move to the first element. And vice versa, when SHIFT + TAB is pressed, when the focus leaves the first element, the last element should be focused. In case the element does not contain any focusable elements, the focus will be trapped on the element itself. + +#Usage +```typescript +import { IgxFocusTrapModule } from "igniteui-angular"; +``` + +Basic initialization +```html +
+ + + +
+``` diff --git a/projects/igniteui-angular/directives/src/directives/focus-trap/focus-trap.directive.spec.ts b/projects/igniteui-angular/directives/src/directives/focus-trap/focus-trap.directive.spec.ts new file mode 100644 index 00000000000..9c32b9de424 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/focus-trap/focus-trap.directive.spec.ts @@ -0,0 +1,229 @@ +import { Component } from '@angular/core'; +import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { IgxFocusTrapDirective } from './focus-trap.directive'; + +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { UIInteractions } from '../../../../test-utils/ui-interactions.spec'; +import { IgxTimePickerComponent } from '../../../../time-picker/src/time-picker/time-picker.component'; + +describe('igxFocusTrap', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, TrapFocusTestComponent] + }).compileComponents(); + })); + + afterEach(() => { + UIInteractions.clearOverlay(); + }); + + it('should focus focusable elements on Tab key pressed', () => { + const fix = TestBed.createComponent(TrapFocusTestComponent); + fix.detectChanges(); + + const focusTrap = fix.debugElement.query(By.directive(IgxFocusTrapDirective)); + const button = fix.debugElement.query(By.css('button')); + const inputs = fix.debugElement.queryAll(By.css('input')); + + UIInteractions.triggerEventHandlerKeyDown('Tab', focusTrap); + fix.detectChanges(); + expect(document.activeElement).toEqual(inputs[0].nativeElement); + + UIInteractions.triggerEventHandlerKeyDown('Tab', focusTrap); + fix.detectChanges(); + expect(document.activeElement).toEqual(inputs[1].nativeElement); + + UIInteractions.triggerEventHandlerKeyDown('Tab', focusTrap); + fix.detectChanges(); + expect(document.activeElement).toEqual(button.nativeElement); + + UIInteractions.triggerEventHandlerKeyDown('Tab', focusTrap); + fix.detectChanges(); + expect(document.activeElement).toEqual(inputs[0].nativeElement); + }); + + it('should focus focusable elements in reversed order on Shift + Tab key pressed', () => { + const fix = TestBed.createComponent(TrapFocusTestComponent); + fix.detectChanges(); + + const focusTrap = fix.debugElement.query(By.directive(IgxFocusTrapDirective)); + const button = fix.debugElement.query(By.css('button')); + const inputs = fix.debugElement.queryAll(By.css('input')); + + UIInteractions.triggerEventHandlerKeyDown('Tab', focusTrap, false, true); + fix.detectChanges(); + expect(document.activeElement).toEqual(button.nativeElement); + + UIInteractions.triggerEventHandlerKeyDown('Tab', focusTrap, false, true); + fix.detectChanges(); + expect(document.activeElement).toEqual(inputs[1].nativeElement); + + UIInteractions.triggerEventHandlerKeyDown('Tab', focusTrap, false, true); + fix.detectChanges(); + expect(document.activeElement).toEqual(inputs[0].nativeElement); + + UIInteractions.triggerEventHandlerKeyDown('Tab', focusTrap, false, true); + fix.detectChanges(); + expect(document.activeElement).toEqual(button.nativeElement); + }); + + it('should trap focus on element when there is only one focusable element', () => { + const fix = TestBed.createComponent(TrapFocusTestComponent); + fix.detectChanges(); + + fix.componentInstance.showInput = false; + fix.detectChanges(); + + const focusTrap = fix.debugElement.query(By.directive(IgxFocusTrapDirective)); + const button = fix.debugElement.query(By.css('button')); + + UIInteractions.triggerEventHandlerKeyDown('Tab', focusTrap); + fix.detectChanges(); + expect(document.activeElement).toEqual(button.nativeElement); + + UIInteractions.triggerEventHandlerKeyDown('Tab', focusTrap, false, true); + fix.detectChanges(); + expect(document.activeElement).toEqual(button.nativeElement); + + UIInteractions.triggerEventHandlerKeyDown('Tab', focusTrap); + fix.detectChanges(); + expect(document.activeElement).toEqual(button.nativeElement); + }); + + it('should trap focus on element with non-focusable elements', fakeAsync(() => { + const fix = TestBed.createComponent(TrapFocusTestComponent); + fix.detectChanges(); + + fix.componentInstance.showInput = false; + fix.componentInstance.showButton = false; + fix.detectChanges(); + + const focusTrap = fix.debugElement.query(By.directive(IgxFocusTrapDirective)); + + UIInteractions.triggerEventHandlerKeyDown('Tab', focusTrap); + tick(); + fix.detectChanges(); + expect(document.activeElement).toEqual(focusTrap.nativeElement); + + UIInteractions.triggerEventHandlerKeyDown('Tab', focusTrap, false, true); + tick(); + fix.detectChanges(); + expect(document.activeElement).toEqual(focusTrap.nativeElement); + + UIInteractions.triggerEventHandlerKeyDown('Tab', focusTrap); + tick(); + fix.detectChanges(); + expect(document.activeElement).toEqual(focusTrap.nativeElement); + })); + + it('should be able to set focusTrap dynamically', fakeAsync(() => { + const fix = TestBed.createComponent(TrapFocusTestComponent); + fix.detectChanges(); + + const focusTrap = fix.debugElement.query(By.directive(IgxFocusTrapDirective)); + const button = fix.debugElement.query(By.css('button')); + const inputs = fix.debugElement.queryAll(By.css('input')); + + UIInteractions.triggerEventHandlerKeyDown('Tab', focusTrap); + fix.detectChanges(); + expect(document.activeElement).toEqual(inputs[0].nativeElement); + + UIInteractions.triggerEventHandlerKeyDown('Tab', focusTrap); + fix.detectChanges(); + expect(document.activeElement).toEqual(inputs[1].nativeElement); + + UIInteractions.triggerEventHandlerKeyDown('Tab', focusTrap); + fix.detectChanges(); + expect(document.activeElement).toEqual(button.nativeElement); + + button.nativeElement.blur(); + fix.detectChanges(); + + fix.componentInstance.focusTrap = false; + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('Tab', focusTrap); + fix.detectChanges(); + expect(document.activeElement).not.toEqual(inputs[0].nativeElement); + + UIInteractions.triggerEventHandlerKeyDown('Tab', focusTrap, false, true); + fix.detectChanges(); + expect(document.activeElement).not.toEqual(inputs[1].nativeElement); + + fix.componentInstance.focusTrap = true; + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('Tab', focusTrap); + fix.detectChanges(); + expect(document.activeElement).toEqual(inputs[0].nativeElement); + + UIInteractions.triggerEventHandlerKeyDown('Tab', focusTrap, false, true); + fix.detectChanges(); + expect(document.activeElement).toEqual(button.nativeElement); + })); + + it('should focus only visible focusable elements on Tab key pressed', () => { + const fix = TestBed.createComponent(TrapFocusTestComponent); + fix.detectChanges(); + + fix.componentInstance.showTimePicker = true; + fix.detectChanges(); + + const focusTrap = fix.debugElement.query(By.directive(IgxFocusTrapDirective)); + const buttons = fix.debugElement.queryAll(By.css('button')); + const inputs = fix.debugElement.queryAll(By.css('input')); + const timePickerInput = fix.debugElement.query(By.css('.igx-input-group__input')); + + UIInteractions.triggerEventHandlerKeyDown('Tab', focusTrap); + fix.detectChanges(); + expect(document.activeElement).toEqual(inputs[0].nativeElement); + + UIInteractions.triggerEventHandlerKeyDown('Tab', focusTrap); + fix.detectChanges(); + expect(document.activeElement).toEqual(inputs[1].nativeElement); + + UIInteractions.triggerEventHandlerKeyDown('Tab', focusTrap); + fix.detectChanges(); + expect(document.activeElement).toEqual(timePickerInput.nativeElement); + + UIInteractions.triggerEventHandlerKeyDown('Tab', focusTrap); + fix.detectChanges(); + expect(document.activeElement).toEqual(buttons[buttons.length - 1].nativeElement); + + UIInteractions.triggerEventHandlerKeyDown('Tab', focusTrap); + fix.detectChanges(); + expect(document.activeElement).toEqual(inputs[0].nativeElement); + }); +}); + + +@Component({ + template: ` +
+
+ @if (showInput) { + + } +
+
+ @if (showInput) { + + } +
+ @if (showTimePicker) { + + } +
+ @if (showButton) { + + } +
`, + imports: [IgxFocusTrapDirective, IgxTimePickerComponent] +}) +class TrapFocusTestComponent { + public showInput = true; + public showButton = true; + public focusTrap = true; + public showTimePicker = false; +} diff --git a/projects/igniteui-angular/directives/src/directives/focus-trap/focus-trap.directive.ts b/projects/igniteui-angular/directives/src/directives/focus-trap/focus-trap.directive.ts new file mode 100644 index 00000000000..9f0c0790e71 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/focus-trap/focus-trap.directive.ts @@ -0,0 +1,100 @@ +import { AfterViewInit, Directive, ElementRef, Input, OnDestroy, booleanAttribute, inject } from '@angular/core'; +import { fromEvent, Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { PlatformUtil } from 'igniteui-angular/core'; + +@Directive({ + selector: '[igxFocusTrap]', + standalone: true +}) +export class IgxFocusTrapDirective implements AfterViewInit, OnDestroy { + private elementRef = inject(ElementRef); + protected platformUtil = inject(PlatformUtil); + + /** @hidden */ + public get element(): HTMLElement | null { + return this.elementRef.nativeElement; + } + + private destroy$ = new Subject(); + private _focusTrap = true; + + /** + * Sets whether the Tab key focus is trapped within the element. + * + * @example + * ```html + *
+ * ``` + */ + @Input({ alias: 'igxFocusTrap', transform: booleanAttribute }) + public set focusTrap(focusTrap: boolean) { + this._focusTrap = focusTrap; + } + + /** @hidden */ + public get focusTrap(): boolean { + return this._focusTrap; + } + + /** @hidden */ + public ngAfterViewInit(): void { + fromEvent(this.element, 'keydown') + .pipe(takeUntil(this.destroy$)) + .subscribe((event: KeyboardEvent) => { + if (this._focusTrap && event.key === this.platformUtil.KEYMAP.TAB) { + this.handleTab(event); + } + }); + } + + /** @hidden */ + public ngOnDestroy() { + this.destroy$.complete(); + } + + private handleTab(event) { + const elements = this.getFocusableElements(this.element); + if (elements.length > 0) { + const focusedElement = this.getFocusedElement(); + const focusedElementIndex = elements.findIndex((element) => element as HTMLElement === focusedElement); + const direction = event.shiftKey ? -1 : 1; + let nextFocusableElementIndex = focusedElementIndex + direction; + if (nextFocusableElementIndex < 0) { + nextFocusableElementIndex = elements.length - 1; + } + if (nextFocusableElementIndex >= elements.length) { + nextFocusableElementIndex = 0; + } + (elements[nextFocusableElementIndex] as HTMLElement).focus(); + } else { + this.element.focus(); + } + + event.preventDefault(); + } + + private getFocusableElements(element: Element) { + return Array.from(element.querySelectorAll( + 'a[href], button, input, textarea, select, details,[tabindex]:not([tabindex="-1"])' + )).filter(el => !el.hasAttribute('disabled') && !el.closest('[aria-hidden="true"]')); + } + + private getFocusedElement(): HTMLElement | null { + let activeElement = + typeof document !== 'undefined' && document + ? (document.activeElement as HTMLElement | null) + : null; + + while (activeElement && activeElement.shadowRoot) { + const newActiveElement = activeElement.shadowRoot.activeElement as HTMLElement | null; + if (newActiveElement === activeElement) { + break; + } else { + activeElement = newActiveElement; + } + } + + return activeElement; + } +} diff --git a/projects/igniteui-angular/directives/src/directives/focus-trap/focus-trap.module.ts b/projects/igniteui-angular/directives/src/directives/focus-trap/focus-trap.module.ts new file mode 100644 index 00000000000..2aa5afcf7dd --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/focus-trap/focus-trap.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { IgxFocusTrapDirective } from './focus-trap.directive'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [IgxFocusTrapDirective], + exports: [IgxFocusTrapDirective] +}) +export class IgxFocusTrapModule { } diff --git a/projects/igniteui-angular/directives/src/directives/focus/focus.directive.spec.ts b/projects/igniteui-angular/directives/src/directives/focus/focus.directive.spec.ts new file mode 100644 index 00000000000..9c0a9de5b04 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/focus/focus.directive.spec.ts @@ -0,0 +1,166 @@ +import { Component, DebugElement, ElementRef, ViewChild } from '@angular/core'; +import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { IgxFocusDirective } from './focus.directive'; + +import { EDITOR_PROVIDER, EditorProvider } from '../../../../core/src/core/edit-provider'; +import { IgxCheckboxComponent } from '../../../../checkbox/src/checkbox/checkbox.component'; +import { IgxDatePickerComponent } from '../../../../date-picker/src/public_api'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxRadioComponent } from '../../../../radio/src/radio/radio.component'; +import { IgxSwitchComponent } from '../../../../switch/src/switch/switch.component'; + +describe('igxFocus', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + SetFocusComponent, + NoFocusComponent, + TriggerFocusOnClickComponent, + CheckboxPickerComponent + ] + }).compileComponents(); + })); + + it('The second element should be focused', fakeAsync(() => { + const fix = TestBed.createComponent(SetFocusComponent); + fix.detectChanges(); + + const secondElem: HTMLElement = fix.debugElement.queryAll(By.all())[1].nativeElement; + + tick(16); + fix.detectChanges(); + expect(document.activeElement).toBe(secondElem); + })); + + it('Should select the last input element when click the button', fakeAsync(() => { + const fix = TestBed.createComponent(TriggerFocusOnClickComponent); + fix.detectChanges(); + + const button: DebugElement = fix.debugElement.query(By.css('button')); + const divs = fix.debugElement.queryAll(By.css('div')); + const lastDiv = divs[divs.length - 1].nativeElement; + + button.triggerEventHandler('click', null); + tick(16); + expect(document.activeElement).toBe(lastDiv); + })); + + it('Should not focus when the focus state is set to false', fakeAsync(() => { + const fix = TestBed.createComponent(NoFocusComponent); + fix.detectChanges(); + tick(16); + const input = fix.debugElement.queryAll(By.css('input'))[0].nativeElement; + + expect(document.activeElement).not.toBe(input); + expect(document.activeElement).toBe(document.body); + })); + + it('Should return EditorProvider element to focus', () => { + const elementRef = { nativeElement: document.createElement('button') }; + const providerElem = document.createElement('input'); + + const provider: EditorProvider = { + getEditElement: () => providerElem + }; + + TestBed.configureTestingModule({ + providers: [ + { provide: ElementRef, useValue: elementRef }, + { provide: EDITOR_PROVIDER, useValue: [provider] }, + IgxFocusDirective + ] + }); + + const directive = TestBed.inject(IgxFocusDirective); + expect(directive.nativeElement).toEqual(providerElem); + }); + + it('Should fallback to ElementRef.nativeElement if no EDITOR_PROVIDER', () => { + const elementRef = { nativeElement: document.createElement('button') }; + + TestBed.configureTestingModule({ + providers: [ + { provide: ElementRef, useValue: elementRef }, + { provide: EDITOR_PROVIDER, useValue: null }, + IgxFocusDirective + ] + }); + + const directivew = TestBed.inject(IgxFocusDirective); + expect(directivew.nativeElement).toBe(elementRef.nativeElement); + }); + + it('Should correctly focus igx-checkbox, igx-radio, igx-switch and igx-date-picker', fakeAsync(() => { + const fix = TestBed.createComponent(CheckboxPickerComponent); + fix.detectChanges(); + tick(16); + expect(document.activeElement).toBe(fix.componentInstance.checkbox.getEditElement()); + + fix.componentInstance.radioFocusRef.trigger(); + tick(16); + expect(document.activeElement).toBe(fix.componentInstance.radio.getEditElement()); + + fix.componentInstance.switchFocusRef.trigger(); + tick(16); + expect(document.activeElement).toBe(fix.componentInstance.switch.getEditElement()); + + fix.componentInstance.pickerFocusRef.trigger(); + tick(16); + expect(document.activeElement).toBe(fix.componentInstance.picker.getEditElement()); + })); +}); + +@Component({ + template: ` + + + + `, + imports: [IgxFocusDirective] +}) +class SetFocusComponent { } + +@Component({ + template: ``, + imports: [IgxFocusDirective] +}) +class NoFocusComponent { } + +@Component({ + template: ` +
First
+
Second
+
Third
+ + `, + imports: [IgxFocusDirective] +}) +class TriggerFocusOnClickComponent { + @ViewChild(IgxFocusDirective, { static: true }) public focusRef: IgxFocusDirective; + + public focus() { + this.focusRef.trigger(); + } + +} + +@Component({ + template: ` + + + + + `, + imports: [IgxFocusDirective, IgxCheckboxComponent, IgxSwitchComponent, IgxRadioComponent, IgxDatePickerComponent] +}) +class CheckboxPickerComponent { + @ViewChild(IgxCheckboxComponent, { static: true }) public checkbox: IgxCheckboxComponent; + @ViewChild(IgxRadioComponent, { static: true }) public radio: IgxRadioComponent; + @ViewChild(IgxSwitchComponent, { static: true }) public switch: IgxSwitchComponent; + @ViewChild(IgxDatePickerComponent, { static: true }) public picker: IgxDatePickerComponent; + @ViewChild('radio', { read: IgxFocusDirective, static: true }) public radioFocusRef: IgxFocusDirective; + @ViewChild('switch', { read: IgxFocusDirective, static: true }) public switchFocusRef: IgxFocusDirective; + @ViewChild('picker', { read: IgxFocusDirective, static: true }) public pickerFocusRef: IgxFocusDirective; +} diff --git a/projects/igniteui-angular/directives/src/directives/focus/focus.directive.ts b/projects/igniteui-angular/directives/src/directives/focus/focus.directive.ts new file mode 100644 index 00000000000..7ce6ad51901 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/focus/focus.directive.ts @@ -0,0 +1,85 @@ +import { Directive, ElementRef, Input, booleanAttribute, inject } from '@angular/core'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; +import { EditorProvider, EDITOR_PROVIDER } from 'igniteui-angular/core'; + +@Directive({ + exportAs: 'igxFocus', + selector: '[igxFocus]', + standalone: true +}) +export class IgxFocusDirective { + private element = inject(ElementRef); + private comp = inject(NG_VALUE_ACCESSOR, { self: true, optional: true }); + private control = inject(EDITOR_PROVIDER, { self: true, optional: true }); + + + private focusState = true; + + /** + * Returns the state of the igxFocus. + * ```typescript + * @ViewChild('focusContainer', {read: IgxFocusDirective}) + * public igxFocus: IgxFocusDirective; + * let isFocusOn = this.igxFocus.focused; + * ``` + * + * @memberof IgxFocusDirective + */ + @Input({ alias: 'igxFocus', transform: booleanAttribute }) + public get focused(): boolean { + return this.focusState; + } + + /** + * Sets the state of the igxFocus. + * ```html + * + * + * + * ``` + * + * @memberof IgxFocusDirective + */ + public set focused(val: boolean) { + this.focusState = val; + this.trigger(); + } + + /** + * Gets the native element of the igxFocus. + * ```typescript + * @ViewChild('focusContainer', {read: IgxFocusDirective}) + * public igxFocus: IgxFocusDirective; + * let igxFocusNativeElement = this.igxFocus.nativeElement; + * ``` + * + * @memberof IgxFocusDirective + */ + public get nativeElement() { + if (this.comp && this.comp[0] && this.comp[0].getEditElement) { + return (this.comp[0] as EditorProvider).getEditElement(); + } + + if (this.control && this.control[0] && this.control[0].getEditElement) { + return this.control[0].getEditElement(); + } + + return this.element.nativeElement; + } + + /** + * Triggers the igxFocus state. + * ```typescript + * @ViewChild('focusContainer', {read: IgxFocusDirective}) + * public igxFocus: IgxFocusDirective; + * this.igxFocus.trigger(); + * ``` + * + * @memberof IgxFocusDirective + */ + public trigger() { + if (this.focusState) { + requestAnimationFrame(() => this.nativeElement.focus({ preventScroll: true })); + } + } +} diff --git a/projects/igniteui-angular/directives/src/directives/focus/focus.module.ts b/projects/igniteui-angular/directives/src/directives/focus/focus.module.ts new file mode 100644 index 00000000000..c0c6c4365cb --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/focus/focus.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { IgxFocusDirective } from './focus.directive'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [IgxFocusDirective], + exports: [IgxFocusDirective] +}) +export class IgxFocusModule { } diff --git a/projects/igniteui-angular/directives/src/directives/for-of/README.md b/projects/igniteui-angular/directives/src/directives/for-of/README.md new file mode 100644 index 00000000000..8c43a503375 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/for-of/README.md @@ -0,0 +1,114 @@ +# igxForOf +**igxForOf** directive extends `ngForOf` adding the ability to virtualize the iterable items over their grow direction. +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/for_of.html) + +## Usage +```html + +
+ + {{item.text}} + +
+
+``` + +## Getting Started + +### Introduction + +If you have a big iterable list that you need a template for in your Angular application and the structure directive `ngForOf` is not usable due to the size of the data `igxForOf` may be the right approach. It can be used to virtualize any such template by only ever rendering a subset of the data so that the application is never bottlenecked by slow rendering and object model creation calls. + +### Basic configuration + +`igxForOf` can be used in most scenarios you would use `ngForOf` in. +```html +
  • + {{i}}/{{users.length}}. {{user}} default +
  • +``` + +Unlike `ngForOf` using `igxForOf` requires specifying the size of the rendering container for the chosen grow direction (either `'vertical'` or `'horizontal'`) and the size of the items inside. An important difference between `vertical` and `horizontal` scrolling is that for the former, a single `itemSize` is required, while horizontally items may have individual widths. The directive will read their `width` property and decide on a page size dynamically. + +An example for horizontally scrolling `igxForOf`: +```html + + {{user}} + +``` + +### DOM Behavior + +Using `igxFor` will do the following changes to the templated DOM: + +- All items rendered by the directive will be wrapped in a display container element that holds the necessary dimensions and styles allowing for seemless scrolling +- A virtual scrollbar will be rendered as a sibling of the display container along the direction of scrolling specified +- A number of items will be rendered that is enough to cover the dimensions of the container +- When the end-user scrolls the rendered items will be reused but their bindings will be updated to refer to items that would usually be visible in the new scroll position + +***Note:*** As of version 5.3.0, `igxFor` will simulate smooth scrolling by utilizing offset positioning of its display container. This requires that its parent element has the appropriate dimensions and `overflow: hidden; position: relative;` rules applied for the best end-user experience. + + +### Change Propagation + +When the contents of the iterator changes, `igxForOf` makes the corresponding changes to the DOM: + +- When an item is added, a new instance of the template is added if it should be immediately visible in the current scrolling position. In this case the last visible item will no longer be available. +- When an item is removed and its template is currently visible, it will be removed. Another item is rendered at the end to keep the page size consistent. +- When items are reordered, if the reordering affects the current view, adding and/or removing of items will be applied so that it adheres to the new order. +- If a bound property of the template is changed and the templated item is currently visible, its template will be redrawn. + +## API + +### Inputs + +| Name | Type | Description | +| :--- |:--- | :--- | +| igxForItemSize | string | The px-affixed size of the item along the axis of scrolling | +| igxForScrollContainer | string | Only the strings `vertical` and `horizontal` are valid and specify the scroll orientation | +| igxForContainerSize | string | The px-affixed size of the container along the axis of scrolling | +| igxForScrollContainer | IgxForOf | Optionally pass the parent `igxForOf` instance to create a virtual template scrolling both horizontally and vertically | +| igxForTotalItemCount | number | The total count of the virtual data items, when using remote service. This is exposed to allow setting the count of the items through the template | + +### Outputs + +| Name | Description | +| :--- | :--- | +| *Event emitters* | *Notify for a change* | +| chunkLoad | Used on chunk loaded. Emits after a new chunk has been loaded. | +| chunkPreload | Used on chunk loading to emit the current state information - startIndex, chunkSize. Can be used for implementing remote load on demand for the igxFor data. | + +### Accessors + +List of public accessors that the developers may use to get information from the `igxForOf`: +| Name | Type | Description | +| :--------------- |:----------- | :--------------------------------------------------------------------------- | +| id | string | Unique identifier of the directive | +| state | IgxForState | The current state of the directive. It contains `startIndex` and `chunkSize` | +| state.startIndex | number | The index of the item at which the current visible chunk begins | +| state.chunkSize | number | The number of items the current visible chunk holds | +| totalItemCount | number | The total count of the virtual data items, when using remote service | + +### Local Variables + +List of exported values by the `igxForOf` that can be aliased to local variables: +| Name | Type | Description | +| :--------- |:------- | :---------------------------------------------------- | +| $implicit | T | The value of the individual items in the iterable | +| index | number | The index of the current item in the iterable. | + +
    + +### Methods + +| Signature | Description | +| :--------------- | :------------------------------------------------------------------------------------- | +| scrollNext() | Scrolls by one item into the appropriate next direction | +| scrollPrev() | Scrolls by one item into the appropriate previous direction | +| scrollNextPage() | Scrolls by one page into the appropriate next direction | +| scrollPrevPage() | Scrolls by one page into the appropriate previous direction | +| scrollTo(index) | Scrolls to the specified index. Current index can be obtained from `state.startIndex`. | + + + + diff --git a/projects/igniteui-angular/directives/src/directives/for-of/base.helper.component.ts b/projects/igniteui-angular/directives/src/directives/for-of/base.helper.component.ts new file mode 100644 index 00000000000..7df0cac709d --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/for-of/base.helper.component.ts @@ -0,0 +1,154 @@ +import { + HostListener, + ElementRef, + ChangeDetectorRef, + OnDestroy, + Directive, + AfterViewInit, + NgZone, + Renderer2, + PLATFORM_ID, + inject +} from '@angular/core'; +import { Subject } from 'rxjs'; +import { takeUntil, throttleTime } from 'rxjs/operators'; +import { resizeObservable, PlatformUtil } from 'igniteui-angular/core'; +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; + +@Directive({ + selector: '[igxVirtualHelperBase]', + standalone: true +}) +export class VirtualHelperBaseDirective implements OnDestroy, AfterViewInit { + public elementRef = inject>(ElementRef); + public cdr = inject(ChangeDetectorRef); + protected _zone = inject(NgZone); + public document = inject(DOCUMENT); + protected platformUtil = inject(PlatformUtil); + + public scrollAmount = 0; + public _size = 0; + public destroyed; + + protected destroy$ = new Subject(); + + private _afterViewInit = false; + private _scrollNativeSize: number; + private _detached = false; + protected renderer = inject(Renderer2); + protected platformId = inject(PLATFORM_ID); + protected ngZone = inject(NgZone); + + constructor() { + this._scrollNativeSize = this.calculateScrollNativeSize(); + } + + @HostListener('scroll', ['$event']) + public onScroll(event) { + this.scrollAmount = event.target.scrollTop || event.target.scrollLeft; + } + + + public ngAfterViewInit() { + this._afterViewInit = true; + if (!this.platformUtil.isBrowser) { + return; + } + const delayTime = 0; + this._zone.runOutsideAngular(() => { + resizeObservable(this.nativeElement).pipe( + throttleTime(delayTime), + takeUntil(this.destroy$)).subscribe((event) => this.handleMutations(event)); + }); + } + + public get nativeElement() { + return this.elementRef.nativeElement; + } + + public ngOnDestroy() { + this.destroyed = true; + this.destroy$.next(true); + this.destroy$.complete(); + } + + public calculateScrollNativeSize() { + const div = this.document.createElement('div'); + const style = div.style; + style.width = '100px'; + style.height = '100px'; + style.position = 'absolute'; + style.top = '-10000px'; + style.top = '-10000px'; + style.overflow = 'scroll'; + this.document.body.appendChild(div); + const scrollWidth = div.offsetWidth - div.clientWidth; + this.document.body.removeChild(div); + return scrollWidth ? scrollWidth + 1 : 1; + } + + public set size(value) { + if (this.destroyed) { + return; + } + this._size = value; + if (this._afterViewInit) { + this.cdr.detectChanges(); + } + } + + public get size() { + return this._size; + } + + public get scrollNativeSize() { + return this._scrollNativeSize; + } + + protected get isAttachedToDom(): boolean { + return this.document.body.contains(this.nativeElement); + } + + private toggleClass(element: HTMLElement, className: string, shouldHaveClass: boolean): void { + if (shouldHaveClass) { + this.renderer.addClass(element, className); + } else { + this.renderer.removeClass(element, className); + } + } + + private updateScrollbarClass() { + if (!isPlatformBrowser(this.platformId)) { + return; + } + + this.ngZone.runOutsideAngular(() => { + requestAnimationFrame(() => { + const el = this.nativeElement; + const hasScrollbar = el.scrollHeight > el.clientHeight; + const prevSibling = el.previousElementSibling as HTMLElement | null; + const scrollbarClass = 'igx-display-container--scrollbar'; + + if (prevSibling?.tagName.toLowerCase() === 'igx-display-container') { + this.toggleClass(prevSibling, scrollbarClass, hasScrollbar); + } + }); + }); + } + + + protected handleMutations(event) { + const hasSize = !(event[0].contentRect.height === 0 && event[0].contentRect.width === 0); + if (!hasSize && !this.isAttachedToDom) { + // scroll bar detached from DOM + this._detached = true; + } else if (this._detached && hasSize && this.isAttachedToDom) { + // attached back now. + this.restoreScroll(); + } + + this.updateScrollbarClass(); + } + + protected restoreScroll() {} +} diff --git a/projects/igniteui-angular/directives/src/directives/for-of/display.container.ts b/projects/igniteui-angular/directives/src/directives/for-of/display.container.ts new file mode 100644 index 00000000000..764807e6f32 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/for-of/display.container.ts @@ -0,0 +1,35 @@ +import { ChangeDetectorRef, Component, HostBinding, ViewChild, ViewContainerRef, inject } from '@angular/core'; +import { IgxScrollInertiaDirective } from '../scroll-inertia/scroll_inertia.directive'; + +@Component({ + selector: 'igx-display-container', + template: ` + + + `, + imports: [IgxScrollInertiaDirective] +}) +export class DisplayContainerComponent { + public cdr = inject(ChangeDetectorRef); + public _viewContainer = inject(ViewContainerRef); + + @ViewChild('display_container', { read: ViewContainerRef, static: true }) + public _vcr; + + @ViewChild('display_container', { read: IgxScrollInertiaDirective, static: true }) + public _scrollInertia: IgxScrollInertiaDirective; + + @HostBinding('class') + public cssClass = 'igx-display-container'; + + @HostBinding('class.igx-display-container--inactive') + public notVirtual = true; + + public scrollDirection: string; + + public scrollContainer; +} diff --git a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts new file mode 100644 index 00000000000..68a390706b4 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts @@ -0,0 +1,1910 @@ +import { AsyncPipe, NgClass, NgForOfContext } from '@angular/common'; +import { AfterViewInit, ChangeDetectorRef, Component, Directive, Injectable, IterableDiffers, NgZone, OnInit, QueryList, TemplateRef, ViewChild, ViewChildren, ViewContainerRef, DebugElement, Pipe, PipeTransform, inject } from '@angular/core'; +import { TestBed, ComponentFixture, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { IForOfState, IgxForOfDirective } from './for_of.directive'; +import { UIInteractions, wait } from '../../../../test-utils/ui-interactions.spec'; + +import { IgxForOfScrollSyncService } from './for_of.sync.service'; + +describe('IgxForOf directive -', () => { + const INACTIVE_VIRT_CONTAINER = 'igx-display-container--inactive'; + const DISPLAY_CONTAINER = 'igx-display-container'; + const VERTICAL_SCROLLER = 'igx-virtual-helper'; + let displayContainer: HTMLElement; + let verticalScroller: HTMLElement; + let horizontalScroller: HTMLElement; + + let dg: DataGenerator; + + beforeAll(() => { + dg = new DataGenerator(); + }); + + describe('empty virtual component', () => { + beforeEach(waitForAsync(() => { + return TestBed.configureTestingModule({ + imports: [EmptyVirtualComponent] + }).compileComponents(); + })); + + it('should initialize empty directive', () => { + const fix = TestBed.createComponent(EmptyVirtualComponent); + fix.detectChanges(); + displayContainer = fix.nativeElement.querySelector(DISPLAY_CONTAINER); + expect(displayContainer).not.toBeNull(); + }); + }); + + describe('horizontal virtual component', () => { + let fix: ComponentFixture; + + beforeEach(waitForAsync(() => { + return TestBed.configureTestingModule({ + imports: [HorizontalVirtualComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fix = TestBed.createComponent(HorizontalVirtualComponent); + dg.generateData(300, 5, fix.componentInstance); + fix.componentRef.hostView.detectChanges(); + fix.detectChanges(); + displayContainer = fix.nativeElement.querySelector(DISPLAY_CONTAINER); + verticalScroller = fix.nativeElement.querySelector(VERTICAL_SCROLLER); + horizontalScroller = fix.nativeElement.querySelector('igx-horizontal-virtual-helper'); + }); + + it('should initialize directive with horizontal virtualization', () => { + expect(displayContainer).not.toBeNull(); + expect(verticalScroller).toBeNull(); + expect(horizontalScroller).not.toBeNull(); + + fix.componentInstance.scrollLeft(150); + fix.detectChanges(); + + const firstRecChildren = displayContainer.children; + for (let i = 0; i < firstRecChildren.length; i++) { + expect(firstRecChildren[i].textContent) + .toBe(fix.componentInstance.data[0][i + 1].toString()); + } + + const secondRecChildren = fix.nativeElement.querySelectorAll(DISPLAY_CONTAINER)[1].children; + for (let i = 0; i < secondRecChildren.length; i++) { + expect(secondRecChildren[i].textContent) + .toBe(fix.componentInstance.data[1][i + 1].toString()); + } + }); + + it('should always fill available space for last chunk size calculation - horizontal virtualization', () => { + fix.componentInstance.width = '1900px'; + fix.componentInstance.cols = [ + { field: '1', width: 100 }, + { field: '2', width: 1800 }, + { field: '3', width: 200 }, + { field: '4', width: 200 }, + { field: '5', width: 300 }, + { field: '6', width: 100 }, + { field: '7', width: 100 }, + { field: '8', width: 100 }, + { field: '9', width: 150 }, + { field: '10', width: 150 } + ]; + fix.componentRef.hostView.detectChanges(); + fix.detectChanges(); + const firstRecChildren = displayContainer.children; + + let chunkSize = firstRecChildren.length; + expect(chunkSize).toEqual(9); + + fix.componentInstance.width = '1900px'; + fix.componentInstance.cols = [ + { field: '1', width: 1800 }, + { field: '2', width: 100 }, + { field: '3', width: 200 }, + { field: '4', width: 200 }, + { field: '5', width: 300 }, + { field: '6', width: 100 }, + { field: '7', width: 100 }, + { field: '8', width: 100 }, + { field: '9', width: 150 }, + { field: '10', width: 150 } + ]; + fix.componentRef.hostView.detectChanges(); + fix.detectChanges(); + + chunkSize = firstRecChildren.length; + expect(chunkSize).toEqual(10); + }); + + it('should update horizontal scroll offsets if igxForOf changes. ', () => { + fix.componentInstance.width = '500px'; + fix.componentInstance.cols = [ + { field: '1', width: 100 }, + { field: '2', width: 200 }, + { field: '3', width: 200 }, + { field: '4', width: 200 }, + { field: '5', width: 300 } + ]; + fix.componentRef.hostView.detectChanges(); + fix.detectChanges(); + + fix.componentInstance.scrollLeft(50); + fix.detectChanges(); + + expect(parseInt(displayContainer.style.left, 10)).toEqual(-50); + + fix.componentInstance.cols = [{ field: '1', width: 100 }]; + fix.detectChanges(); + + expect(parseInt(displayContainer.style.left, 10)).toEqual(0); + }); + + it('should allow scroll in rtl direction.', () => { + fix.debugElement.nativeElement.dir = 'rtl'; + fix.detectChanges(); + + fix.componentInstance.width = '500px'; + fix.componentInstance.cols = [ + { field: '1', width: 100 }, + { field: '2', width: 200 }, + { field: '3', width: 200 }, + { field: '4', width: 200 }, + { field: '5', width: 300 } + ]; + fix.componentRef.hostView.detectChanges(); + fix.detectChanges(); + + fix.componentInstance.scrollLeft(-50); + fix.detectChanges(); + expect(parseInt(displayContainer.style.left, 10)).toEqual(50); + + fix.componentInstance.scrollLeft(-250); + fix.detectChanges(); + const state = fix.componentInstance.childVirtDirs.toArray()[0].state; + expect(state.startIndex).toBe(1); + }); + + it('should display the correct chunk items on resizing the container', async () => { + // initially the container's width is narrow enough to be scrollable + fix.componentInstance.width = '200px'; + fix.componentInstance.cols = [ + { field: '1', width: 100 }, + { field: '2', width: 100 }, + { field: '3', width: 100 }, + { field: '4', width: 100 }, + { field: '5', width: 100 } + ]; + fix.detectChanges(); + + expect(displayContainer).not.toBeNull(); + + // scroll the container so that at least the first col is out of view + fix.componentInstance.scrollLeft(displayContainer.clientWidth); + fix.detectChanges(); + + fix.componentInstance.childVirtDirs.toArray().forEach(element => { + expect(element.state.startIndex).not.toBe(0); + }); + + // the container's width is assigned as wide as to display all cols + fix.componentInstance.width = '600px'; + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + const secondRecChildren = fix.nativeElement.querySelectorAll(DISPLAY_CONTAINER)[1].children; + for (let i = 0; i < secondRecChildren.length; i++) { + expect(secondRecChildren[i].textContent) + .toBe(fix.componentInstance.data[1][i + 1].toString()); + } + }); + }); + + describe('vertical virtual component', () => { + let fix: ComponentFixture; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + VerticalVirtualNoDataComponent, VerticalVirtualComponent + ] + }).compileComponents(); + })); + + beforeEach(() => { + fix = TestBed.createComponent(VerticalVirtualComponent); + fix.componentInstance.data = dg.generateVerticalData(fix.componentInstance.cols); + fix.componentRef.hostView.detectChanges(); + fix.detectChanges(); + displayContainer = fix.nativeElement.querySelector(DISPLAY_CONTAINER); + verticalScroller = fix.nativeElement.querySelector(VERTICAL_SCROLLER); + horizontalScroller = fix.nativeElement.querySelector('igx-horizontal-virtual-helper'); + }); + + afterEach(() => { + displayContainer = null; + verticalScroller = null; + horizontalScroller = null; + }); + + it('should initialize directive with vertical virtualization', async () => { + expect(displayContainer).not.toBeNull(); + expect(verticalScroller).not.toBeNull(); + expect(horizontalScroller).toBeNull(); + /* The height of the row is set to 50px so scrolling by 100px should render the third record */ + fix.componentInstance.scrollTop(100); + fix.detectChanges(); + + const firstRecChildren = displayContainer.children[0].children; + let i = 0; + const thirdRecord = fix.componentInstance.data[2]; + for (const item in thirdRecord) { + if (thirdRecord.hasOwnProperty(item)) { + expect(thirdRecord[item].toString()) + .toBe(firstRecChildren[i++].textContent); + } + } + }); + + it('should update vertical scroll offsets if igxForOf changes. ', () => { + fix.componentInstance.scrollTop(5); + fix.detectChanges(); + + expect(parseInt(displayContainer.style.top, 10)).toEqual(-5); + + spyOn(fix.componentInstance.parentVirtDir.chunkLoad, 'emit'); + + fix.componentInstance.data = [{ 1: 1, 2: 2, 3: 3, 4: 4 }]; + fix.detectChanges(); + + expect(parseInt(displayContainer.style.top, 10)).toEqual(0); + expect(fix.componentInstance.parentVirtDir.chunkLoad.emit).toHaveBeenCalledTimes(1); + }); + + it('should apply the changes when itemSize is changed.', () => { + const firstRecChildren = displayContainer.children[0].children; + for (let i = 0; i < firstRecChildren.length; i++) { + expect(firstRecChildren[i].clientHeight) + .toBe(parseInt(fix.componentInstance.parentVirtDir.igxForItemSize, 10)); + } + + fix.componentInstance.itemSize = '100px'; + fix.detectChanges(); + for (let i = 0; i < firstRecChildren.length; i++) { + expect(firstRecChildren[i].clientHeight) + .toBe(parseInt(fix.componentInstance.parentVirtDir.igxForItemSize, 10)); + } + }); + + it('should not throw error when itemSize is changed while data is null/undefined.', () => { + let errorMessage = ''; + fix.componentInstance.data = null; + fix.detectChanges(); + try { + fix.componentInstance.itemSize = '100px'; + fix.detectChanges(); + } catch (ex) { + errorMessage = ex.message; + } + expect(errorMessage).toBe(''); + }); + + it('should always fill available space for last chunk size calculation - vertical virtualization', async () => { + fix.componentInstance.height = '1900px'; + const virtualContainer = fix.componentInstance.parentVirtDir; + virtualContainer.igxForSizePropName = 'height'; + fix.componentInstance.data = [ + { 1: '1', height: '100px' }, + { 1: '2', height: '1800px' }, + { 1: '3', height: '200px' }, + { 1: '4', height: '200px' }, + { 1: '5', height: '300px' }, + { 1: '6', height: '100px' }, + { 1: '7', height: '100px' }, + { 1: '8', height: '100px' }, + { 1: '9', height: '150px' }, + { 1: '10', height: '150px' } + ]; + fix.detectChanges(); + await wait(50); + let chunkSize = (virtualContainer as any)._calcMaxChunkSize(); + expect(chunkSize).toEqual(9); + + fix.componentInstance.height = '1900px'; + fix.componentInstance.data = [ + { 1: '1', height: '1800px' }, + { 1: '2', height: '100px' }, + { 1: '3', height: '200px' }, + { 1: '4', height: '200px' }, + { 1: '5', height: '300px' }, + { 1: '6', height: '100px' }, + { 1: '7', height: '100px' }, + { 1: '8', height: '100px' }, + { 1: '9', height: '150px' }, + { 1: '10', height: '150px' } + ]; + fix.detectChanges(); + await wait(); + chunkSize = (virtualContainer as any)._calcMaxChunkSize(); + expect(chunkSize).toEqual(10); + }); + + it('should take item margins into account when calculating the size cache', async () => { + fix.componentInstance.height = '600px'; + fix.componentInstance.itemSize = '100px'; + const virtualContainer = fix.componentInstance.parentVirtDir; + virtualContainer.igxForSizePropName = 'height'; + fix.componentInstance.data = [ + { 1: '1', height: '100px', margin: '30px' }, + { 1: '2', height: '100px', margin: '0px' }, + { 1: '3', height: '100px', margin: '0px' }, + { 1: '4', height: '100px', margin: '0px' }, + { 1: '5', height: '100px', margin: '0px' }, + { 1: '6', height: '100px', margin: '0px' }, + { 1: '7', height: '100px', margin: '0px' }, + { 1: '8', height: '100px', margin: '30px' }, + { 1: '9', height: '100px', margin: '30px' }, + { 1: '10', height: '100px', margin: '30px' } + ]; + fix.detectChanges(); + await wait(200); + const cache = (fix.componentInstance.parentVirtDir as any).individualSizeCache; + expect(cache).toEqual([130, 100, 100, 100, 100, 100, 100, 100, 100, 100]); + fix.componentInstance.scrollTop(400); + fix.detectChanges(); + await wait(200); + expect(cache).toEqual([130, 100, 100, 100, 100, 100, 100, 130, 130, 130]); + }); + + it('should render no more that initial chunk size elements when set if no containerSize', () => { + fix.componentInstance.height = undefined; + fix.componentInstance.initialChunkSize = 3; + fix.detectChanges(); + expect(displayContainer).not.toBeNull(); + expect(verticalScroller).not.toBeNull(); + expect(horizontalScroller).toBeNull(); + expect(displayContainer.children.length).toBe(3); + }); + }); + + describe('vertical virtual component no data', () => { + let fix: ComponentFixture; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + VerticalVirtualNoDataComponent, VerticalVirtualComponent + ] + }).compileComponents(); + })); + + beforeEach(() => { + fix = TestBed.createComponent(VerticalVirtualNoDataComponent); + }); + + it('should allow initially undefined value for igxForOf and then detect changes correctly once the value is updated', () => { + expect(() => { + fix.detectChanges(); + }).not.toThrow(); + displayContainer = fix.nativeElement.querySelector(DISPLAY_CONTAINER); + verticalScroller = fix.nativeElement.querySelector(VERTICAL_SCROLLER); + expect(displayContainer).not.toBeNull(); + expect(verticalScroller).not.toBeNull(); + + fix.componentInstance.height = '400px'; + fix.detectChanges(); + fix.componentInstance.height = '500px'; + fix.detectChanges(); + + let rowsRendered = displayContainer.querySelectorAll('div'); + expect(rowsRendered.length).toBe(0); + fix.componentInstance.data = dg.generateVerticalData(fix.componentInstance.cols); + fix.detectChanges(); + rowsRendered = displayContainer.querySelectorAll('div'); + expect(rowsRendered.length).not.toBe(0); + }); + }); + + describe('vertical and horizontal virtual component', () => { + let fix: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + VirtualComponent + ] + }).compileComponents(); + })); + + beforeEach(() => { + fix = TestBed.createComponent(VirtualComponent); + dg.generateData300x50000(fix.componentInstance); + fix.detectChanges(); + displayContainer = fix.nativeElement.querySelector(DISPLAY_CONTAINER); + verticalScroller = fix.nativeElement.querySelector(VERTICAL_SCROLLER); + horizontalScroller = fix.nativeElement.querySelector('igx-horizontal-virtual-helper'); + expect(displayContainer).not.toBeNull(); + expect(verticalScroller).not.toBeNull(); + expect(horizontalScroller).not.toBeNull(); + }); + + afterEach(() => { + displayContainer = null; + verticalScroller = null; + horizontalScroller = null; + }); + + it('should initialize directive with vertical and horizontal virtualization', () => { + /* The height of the row is set to 50px so scrolling by 100px should render the third record */ + fix.componentInstance.scrollTop(100); + + const firstInnerDisplayContainer = displayContainer.children[0].querySelector(DISPLAY_CONTAINER); + expect(firstInnerDisplayContainer).not.toBeNull(); + + fix.detectChanges(); + + const firstRecChildren = firstInnerDisplayContainer.children; + for (let i = 0; i < firstRecChildren.length; i++) { + expect(firstInnerDisplayContainer.children[i].textContent) + .toBe(fix.componentInstance.data[2][i].toString()); + } + }); + + it('should allow scrolling at certain amount down and then to the top renders correct rows and cols', () => { + fix.componentInstance.scrollTop(5000); + fix.detectChanges(); + + fix.componentInstance.scrollTop(0); + fix.detectChanges(); + + const firstInnerDisplayContainer = displayContainer.children[0].querySelector(DISPLAY_CONTAINER); + expect(firstInnerDisplayContainer).not.toBeNull(); + + const firstRecChildren = firstInnerDisplayContainer.children; + for (let i = 0; i < firstRecChildren.length; i++) { + expect(firstInnerDisplayContainer.children[i].textContent) + .toBe(fix.componentInstance.data[0][i].toString()); + } + }); + + it('should scroll to bottom and correct rows and columns should be rendered', () => { + fix.componentInstance.scrollTop(2500000); + + const rows = displayContainer.children; + const lastInnerDisplayContainer = rows[rows.length - 1].querySelector(DISPLAY_CONTAINER); + expect(lastInnerDisplayContainer).not.toBeNull(); + + fix.detectChanges(); + + const lastRecChildren = lastInnerDisplayContainer.children; + const data = fix.componentInstance.data; + for (let i = 0; i < lastRecChildren.length; i++) { + expect(lastInnerDisplayContainer.children[i].textContent) + .toBe(data[data.length - 1][i].toString()); + } + }); + + it('should scroll to wheel event correctly', async () => { + fix.componentInstance.parentVirtDir.dc.instance._scrollInertia.smoothingDuration = 0; + /* 120 is default mousewheel on Chrome, scroll 2 records down */ + await UIInteractions.simulateWheelEvent(displayContainer, 0, - 1 * 2 * 120); + fix.detectChanges(); + await wait(); + + const firstInnerDisplayContainer = displayContainer.children[0].querySelector(DISPLAY_CONTAINER); + expect(firstInnerDisplayContainer).not.toBeNull(); + + const firstRecChildren = firstInnerDisplayContainer.children; + for (let i = 0; i < firstRecChildren.length; i++) { + expect(firstInnerDisplayContainer.children[i].textContent) + .toBe(fix.componentInstance.data[2][i].toString()); + } + }); + + it('should scroll to the far right and last column should be visible', () => { + // scroll to the last right pos + fix.componentInstance.scrollLeft(90000); + fix.detectChanges(); + + const rowChildren = displayContainer.querySelectorAll(DISPLAY_CONTAINER); + for (let i = 0; i < rowChildren.length; i++) { + expect(rowChildren[i].children.length).toBe(7); + expect(rowChildren[i].children[5].textContent) + .toBe(fix.componentInstance.data[i][298].toString()); + expect(rowChildren[i].children[6].textContent) + .toBe(fix.componentInstance.data[i][299].toString()); + } + }); + + it('should detect width change and update initially rendered columns', () => { + let rows = displayContainer.querySelectorAll(DISPLAY_CONTAINER); + expect(rows.length).toBe(9); + for (let i = 0; i < rows.length; i++) { + expect(rows[i].children.length).toBe(7); + expect(rows[i].children[3].textContent) + .toBe(fix.componentInstance.data[i][3].toString()); + } + + // scroll to the last right pos + fix.componentInstance.width = '1200px'; + fix.detectChanges(); + + rows = displayContainer.querySelectorAll(DISPLAY_CONTAINER); + expect(rows.length).toBe(9); + for (let i = 0; i < rows.length; i++) { + expect(rows[i].children.length).toBe(9); + expect(rows[i].children[4].textContent) + .toBe(fix.componentInstance.data[i][4].toString()); + } + }); + + it('should detect height change and update initially rendered rows', () => { + let rows = displayContainer.querySelectorAll(DISPLAY_CONTAINER); + expect(rows.length).toBe(9); + for (let i = 0; i < rows.length; i++) { + expect(rows[i].children.length).toBe(7); + expect(rows[i].children[2].textContent) + .toBe(fix.componentInstance.data[i][2].toString()); + } + + // scroll to the last right pos + fix.componentInstance.height = '700px'; + fix.detectChanges(); + + rows = displayContainer.querySelectorAll(DISPLAY_CONTAINER); + expect(rows.length).toBe(15); + for (let i = 0; i < rows.length; i++) { + expect(rows[i].children.length).toBe(7); + expect(rows[i].children[2].textContent) + .toBe(fix.componentInstance.data[i][2].toString()); + } + }); + + it('should not render vertical scrollbar when number of rows change to 5', () => { + let rowsRendered = displayContainer.querySelectorAll(DISPLAY_CONTAINER); + + expect(displayContainer).not.toBeNull(); + expect(verticalScroller).not.toBeNull(); + expect(horizontalScroller).not.toBeNull(); + expect(fix.componentInstance.isVerticalScrollbarVisible()).toBe(true); + expect(fix.componentInstance.isHorizontalScrollbarVisible()).toBe(true); + expect(rowsRendered.length).toBe(9); + + /** Step 1. Lower the amount of rows to 5. The vertical scrollbar then should not be rendered */ + dg.generateData(300, 5, fix.componentInstance); + fix.detectChanges(); + + fix.componentInstance.scrollTop(verticalScroller.scrollTop); + fix.detectChanges(); + + rowsRendered = displayContainer.querySelectorAll(DISPLAY_CONTAINER); + expect(fix.componentInstance.isVerticalScrollbarVisible()).toBe(false); + expect(fix.componentInstance.isHorizontalScrollbarVisible()).toBe(true); + expect(rowsRendered.length).toBe(5); + + /** Step 2. Scroll to the left. There should be no errors then and everything should be still the same */ + fix.componentInstance.scrollLeft(1000); + fix.detectChanges(); + + rowsRendered = displayContainer.querySelectorAll(DISPLAY_CONTAINER); + expect(fix.componentInstance.isVerticalScrollbarVisible()).toBe(false); + expect(fix.componentInstance.isHorizontalScrollbarVisible()).toBe(true); + expect(rowsRendered.length).toBe(5); + + /** Step 3. Increase the amount of rows back and vertical scrollbar should be rendered back */ + dg.generateData300x50000(fix.componentInstance); + fix.detectChanges(); + + // We trigger scrollTop with the current scroll position because otherwise the scroll events are not fired during a test. + fix.componentInstance.scrollTop(verticalScroller.scrollTop); + fix.detectChanges(); + + rowsRendered = displayContainer.querySelectorAll(DISPLAY_CONTAINER); + expect(horizontalScroller.scrollLeft).toBe(1000); + expect(fix.componentInstance.isVerticalScrollbarVisible()).toBe(true); + expect(fix.componentInstance.isHorizontalScrollbarVisible()).toBe(true); + expect(rowsRendered.length).toBe(9); + }); + + it('should not render vertical scrollbars when number of rows change to 0 after scrolling down', () => { + let rowsRendered = displayContainer.querySelectorAll(DISPLAY_CONTAINER); + + expect(displayContainer).not.toBeNull(); + expect(verticalScroller).not.toBeNull(); + expect(horizontalScroller).not.toBeNull(); + expect(fix.componentInstance.isVerticalScrollbarVisible()).toBe(true); + expect(fix.componentInstance.isHorizontalScrollbarVisible()).toBe(true); + expect(rowsRendered.length).toBe(9); + + dg.generateData300x50000(fix.componentInstance); + fix.detectChanges(); + + /** Step 1. Scroll to the bottom. */ + fix.componentInstance.scrollTop(100000); + fix.detectChanges(); + + /** Step 2. Lower the amount of rows to 5. The vertical scrollbar then should not be rendered */ + fix.componentInstance.data = []; + fix.detectChanges(); + + rowsRendered = displayContainer.querySelectorAll(DISPLAY_CONTAINER); + expect(fix.componentInstance.isVerticalScrollbarVisible()).toBe(false); + expect(fix.componentInstance.isHorizontalScrollbarVisible()).toBe(true); + expect(rowsRendered.length).toBe(0); + + /** + * Step 3. Set the amount of rows back and vertical scrollbar should be rendered back then. + * It should reset the scroll position. + */ + dg.generateData300x50000(fix.componentInstance); + fix.detectChanges(); + + rowsRendered = displayContainer.querySelectorAll(DISPLAY_CONTAINER); + expect(verticalScroller.scrollTop).toBe(0); + expect(fix.componentInstance.isVerticalScrollbarVisible()).toBe(true); + expect(fix.componentInstance.isHorizontalScrollbarVisible()).toBe(true); + expect(rowsRendered.length).toBe(9); + }); + + it('should not render vertical scrollbar when number of rows change to 0 after scrolling right', async () => { + let rowsRendered = displayContainer.querySelectorAll(DISPLAY_CONTAINER); + let colsRendered = rowsRendered[0].children; + + expect(fix.componentInstance.isVerticalScrollbarVisible()).toBe(true); + expect(fix.componentInstance.isHorizontalScrollbarVisible()).toBe(true); + expect(rowsRendered.length).toBe(9); + expect(colsRendered.length).toBe(7); + + /** Step 1. Scroll to the right. */ + fix.componentInstance.scrollLeft(1000); + fix.detectChanges(); + await wait(); + + rowsRendered = displayContainer.querySelectorAll(DISPLAY_CONTAINER); + for (let i = 0; i < rowsRendered.length; i++) { + // Check only the second col, no need for the others + expect(rowsRendered[i].children[1].textContent) + .toBe(fix.componentInstance.data[i][5].toString()); + } + + /** Step 2. Lower the amount of cols to 0 so there would be no horizontal scrollbar */ + fix.componentInstance.data = []; + fix.detectChanges(); + + rowsRendered = displayContainer.querySelectorAll(DISPLAY_CONTAINER); + + expect(fix.componentInstance.isVerticalScrollbarVisible()).toBe(false); + expect(rowsRendered.length).toBe(0); + + /** Step 3. Set the data back to and it should render both scrollbars. It should reset the scroll position */ + dg.generateData300x50000(fix.componentInstance); + fix.detectChanges(); + await wait(); + + rowsRendered = displayContainer.querySelectorAll(DISPLAY_CONTAINER); + colsRendered = rowsRendered[0].children; + + expect(fix.componentInstance.isVerticalScrollbarVisible()).toBe(true); + expect(fix.componentInstance.isHorizontalScrollbarVisible()).toBe(true); + expect(rowsRendered.length).toBe(9); + + for (let i = 0; i < rowsRendered.length; i++) { + // Check only the second col, no need for the others + expect(rowsRendered[i].children[1].textContent) + .toBe(fix.componentInstance.data[i][5].toString()); + } + }); + + it('should not render horizontal scrollbars when number of cols change to 3', () => { + let rowsRendered = displayContainer.querySelectorAll(DISPLAY_CONTAINER); + let colsRendered = rowsRendered[0].children; + + expect(fix.componentInstance.isVerticalScrollbarVisible()).toBe(true); + expect(fix.componentInstance.isHorizontalScrollbarVisible()).toBe(true); + expect(rowsRendered.length).toBe(9); + expect(colsRendered.length).toBe(7); + + /** Step 1. Lower the amount of cols to 3 so there would be no horizontal scrollbar */ + dg.generateData(3, 50000, fix.componentInstance); + fix.detectChanges(); + + // We trigger scrollTop with the current scroll position because otherwise the scroll events are not fired during a test. + fix.componentInstance.scrollTop(verticalScroller.scrollTop); + fix.detectChanges(); + rowsRendered = displayContainer.querySelectorAll(DISPLAY_CONTAINER); + colsRendered = rowsRendered[0].children; + + expect(fix.componentInstance.isVerticalScrollbarVisible()).toBe(true); + expect(fix.componentInstance.isHorizontalScrollbarVisible()).toBe(false); + expect(rowsRendered.length).toBe(9); + expect(colsRendered.length).toBe(3); + + /** Step 2. Scroll down. There should be no errors then and everything should be still the same */ + fix.componentInstance.scrollTop(1000); + fix.detectChanges(); + + // We trigger scrollTop with the current scroll position because otherwise the scroll events are not fired during a test. + fix.componentInstance.scrollTop(verticalScroller.scrollTop); + fix.detectChanges(); + + rowsRendered = displayContainer.querySelectorAll(DISPLAY_CONTAINER); + colsRendered = rowsRendered[0].children; + + expect(fix.componentInstance.isVerticalScrollbarVisible()).toBe(true); + expect(fix.componentInstance.isHorizontalScrollbarVisible()).toBe(false); + expect(rowsRendered.length).toBe(9); + expect(colsRendered.length).toBe(3); + + /** Step 3. Set the data back to have 300 columns and the horizontal scrollbar should render now. */ + dg.generateData300x50000(fix.componentInstance); + fix.detectChanges(); + + // We trigger scrollTop with the current scroll position because otherwise the scroll events are not fired during a test. + fix.componentInstance.scrollTop(verticalScroller.scrollTop); + fix.detectChanges(); + rowsRendered = displayContainer.querySelectorAll(DISPLAY_CONTAINER); + colsRendered = rowsRendered[0].children; + + expect(verticalScroller.scrollTop).toBe(1000); + expect(fix.componentInstance.isVerticalScrollbarVisible()).toBe(true); + expect(fix.componentInstance.isHorizontalScrollbarVisible()).toBe(true); + expect(rowsRendered.length).toBe(9); + expect(colsRendered.length).toBe(7); + }); + + it('should scroll down when using touch events', async () => { + let rowsRendered = displayContainer.querySelectorAll(DISPLAY_CONTAINER); + for (let i = 0; i < rowsRendered.length; i++) { + // Check only the second col, no need for the others + expect(rowsRendered[i].children[1].textContent) + .toBe(fix.componentInstance.data[i][1].toString()); + } + + const dcElem = fix.componentInstance.parentVirtDir.dc.instance._viewContainer.element.nativeElement; + UIInteractions.simulateTouchStartEvent(dcElem, 200, 200); + UIInteractions.simulateTouchMoveEvent(dcElem, 200, -300); + fix.detectChanges(); + await wait(); + + rowsRendered = displayContainer.querySelectorAll(DISPLAY_CONTAINER); + for (let i = 0; i < rowsRendered.length; i++) { + // Check only the second col, no need for the others + expect(rowsRendered[i].children[1].textContent) + .toBe(fix.componentInstance.data[10 + i][1].toString()); + } + }); + + xit('should apply inertia when swiping via touch interaction.', async () => { + const dcElem = fix.componentInstance.parentVirtDir.dc.instance._viewContainer.element.nativeElement; + // spyOn(fix.componentInstance.parentVirtDir, 'onScroll'); + await UIInteractions.simulateTouchStartEvent( + dcElem, + 0, + -150 + ); + await wait(1); + await UIInteractions.simulateTouchMoveEvent(dcElem, 0, -180); + await UIInteractions.simulateTouchEndEvent(dcElem, 0, -200); + fix.detectChanges(); + + // wait for inertia to complete + await wait(1500); + fix.detectChanges(); + const scrStepArray = fix.componentInstance.parentVirtDir.scrStepArray; + expect(scrStepArray.length).toBeGreaterThan(55); + + // check if inertia first accelerates then decelerate + const first = scrStepArray[0]; + const mid = scrStepArray[10]; + const end = scrStepArray[60]; + + expect(first).toBeLessThan(mid); + expect(end).toBeLessThan(mid); + }); + + it('should scroll left when using touch events', () => { + let rowsRendered = displayContainer.querySelectorAll(DISPLAY_CONTAINER); + for (let i = 0; i < rowsRendered.length; i++) { + // Check only the second col, no need for the others + expect(rowsRendered[i].children[1].textContent) + .toBe(fix.componentInstance.data[i][1].toString()); + } + + const dcElem = fix.componentInstance.childVirtDirs.first.dc.instance._viewContainer.element.nativeElement; + UIInteractions.simulateTouchStartEvent(dcElem, 200, 200); + UIInteractions.simulateTouchMoveEvent(dcElem, -800, 0); + + // Trigger onScroll + fix.componentInstance.scrollLeft(horizontalScroller.scrollLeft); + fix.detectChanges(); + + rowsRendered = displayContainer.querySelectorAll(DISPLAY_CONTAINER); + for (let i = 0; i < rowsRendered.length; i++) { + // Check only the second col, no need for the others + expect(rowsRendered[i].children[1].textContent) + .toBe(fix.componentInstance.data[i][5].toString()); + } + }); + + it('should load next row and remove first row when using scrollNext method', () => { + let rowsRendered = displayContainer.querySelectorAll(DISPLAY_CONTAINER); + for (let i = 0; i < rowsRendered.length; i++) { + // Check only the second col, no need for the others + expect(rowsRendered[i].children[1].textContent) + .toBe(fix.componentInstance.data[i][1].toString()); + } + + fix.componentInstance.parentVirtDir.scrollNext(); + fix.componentInstance.scrollTop(verticalScroller.scrollTop); // Trigger onScroll manually. + fix.detectChanges(); + + rowsRendered = displayContainer.querySelectorAll(DISPLAY_CONTAINER); + for (let i = 0; i < rowsRendered.length; i++) { + // Check only the second col, no need for the others + expect(rowsRendered[i].children[1].textContent) + .toBe(fix.componentInstance.data[1 + i][1].toString()); + } + }); + + it('should load previous row and remove last row when using scrollPrev method', () => { + /** Step 1. Scroll down 500px first so we then have what to load previously */ + fix.componentInstance.scrollTop(500); + fix.detectChanges(); + + let rowsRendered = displayContainer.querySelectorAll(DISPLAY_CONTAINER); + for (let i = 0; i < rowsRendered.length; i++) { + // Check only the second col, no need for the others + expect(rowsRendered[i].children[1].textContent) + .toBe(fix.componentInstance.data[10 + i][1].toString()); + } + + /** Step 2. Execute scrollPrev to load previous row */ + fix.componentInstance.parentVirtDir.scrollPrev(); + fix.componentInstance.scrollTop(verticalScroller.scrollTop); // Trigger onScroll manually. + fix.detectChanges(); + + rowsRendered = displayContainer.querySelectorAll(DISPLAY_CONTAINER); + for (let i = 0; i < rowsRendered.length; i++) { + // Check only the second col, no need for the others + expect(rowsRendered[i].children[1].textContent) + .toBe(fix.componentInstance.data[9 + i][1].toString()); + } + }); + + it('should not wrap around with scrollNext and scrollPrev', async () => { + const forOf = fix.componentInstance.parentVirtDir; + + forOf.scrollPrev(); + fix.detectChanges(); + await wait(200); + + expect(forOf.state.startIndex).toEqual(0); + + forOf.scrollTo(forOf.igxForOf.length - 1); + fix.detectChanges(); + await wait(200); + + expect(forOf.state.startIndex).toEqual(forOf.igxForOf.length - forOf.state.chunkSize); + + forOf.scrollNext(); + fix.detectChanges(); + await wait(200); + + expect(forOf.state.startIndex).toEqual(forOf.igxForOf.length - forOf.state.chunkSize); + }); + + it('should prevent scrollTo() when called with numbers outside the scope of the data records.', () => { + fix.componentInstance.parentVirtDir.scrollTo(-1); + expect(fix.componentInstance.parentVirtDir.state.startIndex).toBe(0); + + fix.componentInstance.parentVirtDir.scrollTo(fix.componentInstance.data.length + 1); + expect(fix.componentInstance.parentVirtDir.state.startIndex).toBe(0); + }); + + it('should set correct left offset when scrolling to right, clearing data and then setting new data', async () => { + /** Scroll left 1500px */ + fix.componentInstance.scrollLeft(1500); + fix.detectChanges(); + await wait(); + + /** Timeout for scroll event to trigger during test */ + let firstRowDisplayContainer = fix.nativeElement.querySelectorAll(DISPLAY_CONTAINER)[1]; + expect(firstRowDisplayContainer.style.left).toEqual('-82px'); + + dg.generateData(300, 0, fix.componentInstance); + fix.detectChanges(); + + dg.generateData300x50000(fix.componentInstance); + fix.detectChanges(); + await wait(); + + /** Offset should be equal to the offset before so there is no misalignment */ + firstRowDisplayContainer = fix.nativeElement.querySelectorAll(DISPLAY_CONTAINER)[1]; + expect(firstRowDisplayContainer.style.left).toEqual('-82px'); + }); + + it('should correctly scroll to the last element when using the scrollTo method', () => { + spyOn(fix.componentInstance.parentVirtDir.chunkLoad, 'emit'); + + /** Scroll to the last 49999 row. */ + fix.componentInstance.parentVirtDir.scrollTo(49999); + fix.componentInstance.scrollTop(verticalScroller.scrollTop); + fix.detectChanges(); + + expect(fix.componentInstance.parentVirtDir.chunkLoad.emit).toHaveBeenCalledTimes(1); + + const rowsRendered = displayContainer.querySelectorAll(DISPLAY_CONTAINER); + for (let i = 0; i < 8; i++) { + expect(rowsRendered[i].children[1].textContent) + .toBe(fix.componentInstance.data[49991 + i][1].toString()); + } + }); + + it('should return correct value for getItemCountInView API. ', async () => { + /** Scroll left 1500px and top 105px */ + + fix.componentInstance.scrollLeft(1500); + fix.componentInstance.scrollTop(105); + fix.detectChanges(); + fix.componentInstance.parentVirtDir.cdr.detectChanges(); + await wait(); + + expect(fix.componentInstance.parentVirtDir.getItemCountInView()).toBe(7); + const hDirective = fix.componentInstance.childVirtDirs.toArray()[0]; + expect(hDirective.getItemCountInView()).toBe(2); + }); + + it('should emit the chunkPreload/chunkLoad only when startIndex or chunkSize have changed.', async () => { + const verticalDir = fix.componentInstance.parentVirtDir; + const chunkLoadSpy = spyOn(verticalDir.chunkLoad, 'emit').and.callThrough(); + const chunkPreLoadSpy = spyOn(verticalDir.chunkPreload, 'emit').and.callThrough(); + // scroll so that start index does not change. + fix.componentInstance.scrollTop(1); + fix.detectChanges(); + await wait(); + expect(chunkLoadSpy).toHaveBeenCalledTimes(0); + expect(chunkPreLoadSpy).toHaveBeenCalledTimes(0); + + // scroll so that start index changes. + fix.componentInstance.scrollTop(100); + fix.detectChanges(); + await wait(); + + expect(chunkLoadSpy).toHaveBeenCalledTimes(1); + expect(chunkPreLoadSpy).toHaveBeenCalledTimes(1); + + // change size so that chunk size does not change + fix.componentInstance.height = '399px'; + fix.detectChanges(); + await wait(); + + expect(chunkLoadSpy).toHaveBeenCalledTimes(1); + + // change size so that chunk size changes + fix.componentInstance.height = '1500px'; + fix.detectChanges(); + await wait(); + + expect(chunkLoadSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe('variable size component', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + VirtualVariableSizeComponent + ] + }).compileComponents(); + })); + + it('should update display container classes when content state changes from virtualized to non-virtualized.', () => { + const fix = TestBed.createComponent(VirtualVariableSizeComponent); + fix.detectChanges(); + + let displayContainerDebugEl: DebugElement = fix.debugElement.query(By.css(DISPLAY_CONTAINER)); + // No size and no data - display container should be inactive + expect(displayContainerDebugEl.classes[INACTIVE_VIRT_CONTAINER]).toBeTruthy(); + + // set size + fix.componentInstance.height = '500px'; + fix.detectChanges(); + + displayContainerDebugEl = fix.debugElement.query(By.css(DISPLAY_CONTAINER)); + // Has size but no data - display container should be inactive + expect(displayContainerDebugEl.classes[INACTIVE_VIRT_CONTAINER]).toBeTruthy(); + + // set data with 1 rec. + fix.componentInstance.data = fix.componentInstance.generateData(1); + fix.detectChanges(); + + displayContainerDebugEl = fix.debugElement.query(By.css(DISPLAY_CONTAINER)); + // Has size but not enough data to be virtualized - display container should be inactive + expect(displayContainerDebugEl.classes[INACTIVE_VIRT_CONTAINER]).toBeTruthy(); + + // set data with 1000 recs. + fix.componentInstance.data = fix.componentInstance.generateData(1000); + fix.detectChanges(); + + displayContainerDebugEl = fix.debugElement.query(By.css(DISPLAY_CONTAINER)); + // Has size and enough data to be virtualized - display container should be active. + expect(displayContainerDebugEl.classes[INACTIVE_VIRT_CONTAINER]).toBeFalsy(); + }); + }); + + describe('remote virtual component', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + RemoteVirtualizationComponent + ] + }).compileComponents(); + })); + + it('should allow remote virtualization', async () => { + const fix = TestBed.createComponent(RemoteVirtualizationComponent); + fix.componentRef.hostView.detectChanges(); + fix.detectChanges(); + + displayContainer = fix.nativeElement.querySelector(DISPLAY_CONTAINER); + verticalScroller = fix.nativeElement.querySelector(VERTICAL_SCROLLER); + // verify data is loaded + let rowsRendered = displayContainer.children; + let data = fix.componentInstance.data.source.getValue(); + for (let i = 0; i < rowsRendered.length; i++) { + expect(rowsRendered[i].textContent.trim()) + .toBe(data[i].toString()); + } + + // scroll down + verticalScroller.scrollTop = 10000; + fix.detectChanges(); + await wait(); + + // verify data is loaded + rowsRendered = displayContainer.children; + data = fix.componentInstance.data.source.getValue(); + for (let i = fix.componentInstance.parentVirtDir.state.startIndex; i < rowsRendered.length; i++) { + expect(rowsRendered[i].textContent.trim()) + .toBe(data[i].toString()); + } + }); + }); + + describe('remote virtual component with specified igxForTotalItemCount', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + RemoteVirtCountComponent + ] + }).compileComponents(); + })); + + it('should apply remote virtualization correctly', async () => { + const fix = TestBed.createComponent(RemoteVirtCountComponent); + fix.componentRef.hostView.detectChanges(); + fix.detectChanges(); + + displayContainer = fix.nativeElement.querySelector(DISPLAY_CONTAINER); + verticalScroller = fix.nativeElement.querySelector(VERTICAL_SCROLLER); + // verify data is loaded + let rowsRendered = displayContainer.children; + let data = fix.componentInstance.data.source.getValue(); + for (let i = 0; i < rowsRendered.length; i++) { + expect(rowsRendered[i].textContent.trim()) + .toBe(data[i].toString()); + } + + // scroll down + verticalScroller.scrollTop = 10000; + fix.detectChanges(); + await wait(); + + // verify data is loaded + rowsRendered = displayContainer.children; + data = fix.componentInstance.data.source.getValue(); + for (let i = fix.componentInstance.parentVirtDir.state.startIndex; i < rowsRendered.length; i++) { + expect(rowsRendered[i].textContent.trim()) + .toBe(data[i].toString()); + } + }); + }); + + describe('no width and height component', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoWidthAndHeightComponent + ] + }).compileComponents(); + })); + + it('should use itemSize when no width or height are provided', () => { + const fix = TestBed.createComponent(NoWidthAndHeightComponent); + fix.componentRef.hostView.detectChanges(); + fix.detectChanges(); + + const children = fix.componentInstance.childVirtDirs; + const instance = fix.componentInstance; + const expectedElementsLength = (parseInt(instance.width, 10) / instance.itemSize) + 1; + expect(children.length).toEqual(expectedElementsLength); + }); + }); + + describe('even odd first last functions', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + LocalVariablesComponent + ] + }).compileComponents(); + })); + + it('should differentiate even odd items', () => { + const fix = TestBed.createComponent(LocalVariablesComponent); + fix.detectChanges(); + const allItems: DebugElement[] = fix.debugElement.queryAll(By.css(DISPLAY_CONTAINER))[0].children; + expect(allItems.length).toEqual(100); + for (let i = 0; i < allItems.length; i++) { + if (i === 0) { + expect(allItems[i].classes['first']).toBe(true); + } + if (i === allItems.length - 1) { + expect(allItems[i].classes['last']).toBe(true); + } + if (i % 2 === 0) { + expect(allItems[i].classes['even']).toBe(true); + } else { + expect(allItems[i].classes['odd']).toBe(true); + } + } + }); + }); + + describe('`as` syntax', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + LocalVariablesAsComponent + ] + }).compileComponents(); + })); + + it('should get correct data using `as` syntax', () => { + const fix = TestBed.createComponent(LocalVariablesAsComponent); + fix.detectChanges(); + const allItems: DebugElement[] = fix.debugElement.queryAll(By.css(DISPLAY_CONTAINER))[0].children; + expect(allItems.length).toEqual(50); + for (let i = 0; i < allItems.length; i++) { + const itemElems = allItems[i].nativeElement.textContent.split(":"); + expect(itemElems[1].trim()).toEqual(itemElems[2].trim()); + } + }); + }); + + describe('on destroy', () => { + let fix: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + VerticalVirtualDestroyComponent + ] + }).compileComponents(); + })); + + beforeEach(() => { + fix = TestBed.createComponent(VerticalVirtualDestroyComponent); + fix.componentInstance.data = dg.generateVerticalData(fix.componentInstance.cols); + fix.componentRef.hostView.detectChanges(); + fix.detectChanges(); + }); + it('should reset scroll position if component is destroyed and recreated.', async () => { + let scrollComponent = fix.debugElement.query(By.css(VERTICAL_SCROLLER)).componentInstance; + expect(scrollComponent.scrollAmount).toBe(0); + expect(scrollComponent.destroyed).toBeFalsy(); + + scrollComponent.nativeElement.scrollTop = 500; + fix.detectChanges(); + await wait(); + expect(scrollComponent.scrollAmount).toBe(500); + + fix.componentInstance.exists = false; + fix.detectChanges(); + await wait(); + + expect(scrollComponent.destroyed).toBeTruthy(); + + fix.componentInstance.exists = true; + fix.detectChanges(); + await wait(); + + scrollComponent = fix.debugElement.query(By.css(VERTICAL_SCROLLER)).componentInstance; + expect(scrollComponent.scrollAmount).toBe(0); + expect(scrollComponent.destroyed).toBeFalsy(); + + displayContainer = fix.nativeElement.querySelector(DISPLAY_CONTAINER); + const firstInnerDisplayContainer = displayContainer.children[0]; + expect(firstInnerDisplayContainer.children[0].textContent).toBe('0'); + }); + }); + + describe('on create new instance', () => { + let fix: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + VerticalVirtualCreateComponent + ] + }).compileComponents(); + })); + + beforeEach(() => { + fix = TestBed.createComponent(VerticalVirtualCreateComponent); + fix.componentInstance.data = dg.generateVerticalData(fix.componentInstance.cols); + fix.componentRef.hostView.detectChanges(); + fix.detectChanges(); + }); + + it('should reset scroll position if new component is created.', async () => { + const scrollComponent = fix.debugElement.query(By.css(VERTICAL_SCROLLER)).componentInstance; + expect(scrollComponent.scrollAmount).toBe(0); + expect(scrollComponent.destroyed).toBeFalsy(); + + scrollComponent.nativeElement.scrollTop = 500; + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + expect(scrollComponent.scrollAmount).toBe(500); + + fix.componentInstance.exists = true; + fix.detectChanges(); + await wait(); + const secondForOf = fix.componentInstance.secondForOfDir; + expect(secondForOf.state.startIndex).toBe(0); + }); + }); +}); + +class DataGenerator { + public verticalData: any[] = []; + public data300x50000: any[] = []; + public cols300: any[] = []; + + constructor() { } + + public generateVerticalData(cols) { + if (this.verticalData.length !== 0) { + return this.verticalData; + } + const dummyData = []; + for (let i = 0; i < 50000; i++) { + const obj = {}; + for (let j = 0; j < cols.length; j++) { + const col = cols[j].field; + obj[col] = 10 * i * j; + } + dummyData.push(obj); + } + + return this.verticalData = dummyData; + } + + public generateData(numCols: number, numRows: number, instance?) { + const dummyData = []; + const cols = []; + for (let j = 0; j < numCols; j++) { + cols.push({ + field: j.toString(), + width: j % 8 < 2 ? 100 : Math.floor((j % 6 + 0.25) * 125) + }); + } + + for (let i = 0; i < numRows; i++) { + const obj = {}; + for (let j = 0; j < cols.length; j++) { + const col = cols[j].field; + obj[col] = 10 * i * j; + } + dummyData.push(obj); + } + + if (instance) { + instance.cols = cols; + instance.data = dummyData; + } else { + return { data: dummyData, cols }; + } + } + + public generateData300x50000(instance) { + if (this.data300x50000.length !== 0) { + instance.cols = this.cols300; + instance.data = this.data300x50000; + } else { + const result = this.generateData(300, 50000); + this.data300x50000 = result.data; + this.cols300 = result.cols; + + instance.cols = this.cols300; + instance.data = this.data300x50000; + } + } +} + +/** igxFor for testing */ +@Directive({ + selector: '[igxForTest]', + standalone: true +}) +export class TestIgxForOfDirective extends IgxForOfDirective { + public viewContainer: ViewContainerRef; + public template: TemplateRef>; + public differs: IterableDiffers; + public changeDet: ChangeDetectorRef; + public zone: NgZone; + protected syncService: IgxForOfScrollSyncService; + + public scrStepArray = []; + public scrTopArray = []; + + public override onScroll(evt) { + const ind = this.scrTopArray.length - 1; + const prevScrTop = ind < 0 ? 0 : this.scrTopArray[ind]; + this.scrTopArray.push(evt.target.scrollTop); + const calcScrollStep = evt.target.scrollTop - prevScrTop; + this.scrStepArray.push(calcScrollStep); + super.onScroll(evt); + } + + public testOnScroll(target) { + const event = new Event('scroll'); + Object.defineProperty(event, 'target', { value: target, enumerable: true }); + super.onScroll(event); + } + + public testOnHScroll(target) { + const event = new Event('scroll'); + Object.defineProperty(event, 'target', { value: target, enumerable: true }); + super.onHScroll(event); + } + + public testCalculateChunkSize(): number { + return super._calculateChunkSize(); + } + + public testInitHCache(cols: any[]): number { + return super.initSizesCache(cols); + } + + public testGetHorizontalScroll(viewref, nodeName) { + return super.getElement(viewref, nodeName); + } + + public testGetHorizontalIndexAt(left, set) { + super.getIndexAt(left, set); + } +} + +/** Empty virtualized component */ +@Component({ + template: ` + + + + `, + imports: [TestIgxForOfDirective] +}) +export class EmptyVirtualComponent { + + @ViewChild('container', { static: true }) public container; + public data = []; +} + + +/** Both vertically and horizontally virtualized component */ +@Component({ + template: ` +
    + +
    + +
    {{rowData[col.field]}}
    +
    +
    +
    +
    + `, + imports: [TestIgxForOfDirective] +}) +export class VirtualComponent { + @ViewChild('container', { read: ViewContainerRef, static: true }) + public container: ViewContainerRef; + + @ViewChild('scrollContainer', { read: TestIgxForOfDirective, static: true }) + public parentVirtDir: TestIgxForOfDirective; + + @ViewChildren('childContainer', { read: TestIgxForOfDirective }) + public childVirtDirs: QueryList>; + + public width = '800px'; + public height = '400px'; + public cols = []; + public data = []; + + public scrollTop(newScrollTop) { + const verticalScrollbar = this.container.element.nativeElement.querySelector('igx-virtual-helper'); + verticalScrollbar.scrollTop = newScrollTop; + + this.parentVirtDir.testOnScroll(verticalScrollbar); + } + + public scrollLeft(newScrollLeft) { + const horizontalScrollbar = this.container.element.nativeElement.querySelector('igx-horizontal-virtual-helper'); + horizontalScrollbar.scrollLeft = newScrollLeft; + + this.childVirtDirs.forEach((item) => { + item.testOnHScroll(horizontalScrollbar); + }); + } + + public isVerticalScrollbarVisible() { + const verticalScrollbar = this.container.element.nativeElement.querySelector('igx-virtual-helper'); + /** + * Due to current implementation the height is set explicitly. + * That's why we check if the content is bigger than the vertical scrollbar height + */ + return verticalScrollbar.offsetHeight < verticalScrollbar.children[0].offsetHeight; + } + + public isHorizontalScrollbarVisible() { + const horizontalScrollbar = this.container.element.nativeElement.querySelector('igx-horizontal-virtual-helper'); + /** + * Due to current implementation the height is automatically calculated. + * That's why when it's less than 16 there is no scrollbar + */ + return horizontalScrollbar.offsetHeight >= 16; + } +} + +/** Only vertically virtualized component */ +@Component({ + template: ` +
    + +
    +
    {{rowData['1']}}
    +
    {{rowData['2']}}
    +
    {{rowData['3']}}
    +
    {{rowData['4']}}
    +
    {{rowData['5']}}
    +
    +
    +
    + `, + selector: 'igx-vertical-virtual', + imports: [TestIgxForOfDirective] +}) +export class VerticalVirtualComponent extends VirtualComponent { + public override width = '450px'; + public override height = '300px'; + public override cols = [ + { field: '1', width: '150px' }, + { field: '2', width: '70px' }, + { field: '3', width: '50px' }, + { field: '4', width: '80px' }, + { field: '5', width: '100px' } + ]; + public override data = []; + public itemSize = '50px'; + public initialChunkSize; +} + +@Component({ + template: ` + @if (exists) { +
    + +
    +
    {{rowData['1']}}
    +
    {{rowData['2']}}
    +
    {{rowData['3']}}
    +
    {{rowData['4']}}
    +
    {{rowData['5']}}
    +
    +
    +
    + } + `, + imports: [TestIgxForOfDirective] +}) +export class VerticalVirtualDestroyComponent extends VerticalVirtualComponent { + public exists = true; +} + +@Component({ + template: ` +
    + +
    +
    {{rowData['1']}}
    +
    {{rowData['2']}}
    +
    {{rowData['3']}}
    +
    {{rowData['4']}}
    +
    {{rowData['5']}}
    +
    +
    +
    + @if (exists) { +
    + +
    +
    {{rowData['1']}}
    +
    {{rowData['2']}}
    +
    {{rowData['3']}}
    +
    {{rowData['4']}}
    +
    {{rowData['5']}}
    +
    +
    +
    + } + `, + imports: [IgxForOfDirective] +}) +export class VerticalVirtualCreateComponent extends VerticalVirtualComponent { + @ViewChild('scrollContainer2', { read: IgxForOfDirective, static: false }) + public secondForOfDir: IgxForOfDirective; + + public exists = false; +} + +/** Only horizontally virtualized component */ +@Component({ + template: ` +
    +
    + @for (rowData of data; track rowData) { +
    + +
    {{rowData[col.field]}}
    +
    +
    + } +
    +
    + `, + imports: [TestIgxForOfDirective] +}) +export class HorizontalVirtualComponent extends VirtualComponent { + public override width = '800px'; + public override height = '400px'; + public override cols = []; + public override data = []; +} + +/** Only vertically virtualized component */ +@Component({ + template: ` +
    + +
    + {{rowData}} +
    +
    +
    + `, + imports: [TestIgxForOfDirective] +}) +export class VirtualVariableSizeComponent { + @ViewChild('container', { static: true }) + public container; + + @ViewChild('scrollContainer', { read: TestIgxForOfDirective, static: true }) + public parentVirtDir: TestIgxForOfDirective; + + public height = '0px'; + public data = []; + + public generateData(count) { + const dummyData = []; + for (let i = 0; i < count; i++) { + dummyData.push(10 * i); + } + return dummyData; + } +} + +/** Vertically virtualized component with no initial data */ +@Component({ + template: ` +
    + +
    + {{rowData['1']}} +
    +
    +
    + `, + selector: 'igx-vertical-virtual-no-data', + imports: [TestIgxForOfDirective] +}) +export class VerticalVirtualNoDataComponent extends VerticalVirtualComponent { +} + +@Injectable() +export class LocalService { + public records: Observable; + public count: Observable; + private _records: BehaviorSubject; + private dataStore: any[]; + private _count: BehaviorSubject; + + constructor() { + this.dataStore = []; + this._records = new BehaviorSubject([]); + this.records = this._records.asObservable(); + this._count = new BehaviorSubject(null); + this.count = this._count.asObservable(); + } + + public getData(data?: IForOfState, cb?: (any) => void): any { + const size = data.chunkSize === 0 ? 10 : data.chunkSize; + this.dataStore = this.generateData(data.startIndex, data.startIndex + size); + this._records.next(this.dataStore); + const count = 1000; + if (cb) { + cb(count); + } + } + + public getCount() { + const count = 1000; + this._count.next(count); + } + + public generateData(start, end) { + const dummyData = []; + for (let i = start; i < end; i++) { + dummyData.push(10 * i); + } + return dummyData; + } +} + +/** Vertically virtualized component with remote virtualization */ +@Component({ + template: ` +
    + +
    + {{rowData}} +
    +
    +
    + `, + providers: [LocalService], + imports: [TestIgxForOfDirective, AsyncPipe] +}) +export class RemoteVirtualizationComponent implements OnInit, AfterViewInit { + private localService = inject(LocalService); + + @ViewChild('scrollContainer', { read: TestIgxForOfDirective, static: true }) + public parentVirtDir: TestIgxForOfDirective; + + @ViewChild('container', { read: ViewContainerRef, static: true }) + public container: ViewContainerRef; + + public height = '500px'; + public data; + public ngOnInit(): void { + this.data = this.localService.records; + } + + public ngAfterViewInit() { + this.localService.getData(this.parentVirtDir.state, (count) => { + this.parentVirtDir.totalItemCount = count; + }); + } + + public dataLoading(evt) { + this.localService.getData(evt, () => { + this.parentVirtDir.cdr.detectChanges(); + }); + } +} + +@Component({ + template: ` +
    + +
    + {{rowData}} +
    +
    +
    + `, + providers: [LocalService], + imports: [TestIgxForOfDirective, AsyncPipe] +}) +export class RemoteVirtCountComponent implements OnInit, AfterViewInit { + private localService = inject(LocalService); + + @ViewChild('scrollContainer', { read: TestIgxForOfDirective, static: true }) + public parentVirtDir: TestIgxForOfDirective; + + @ViewChild('container', { read: ViewContainerRef, static: true }) + public container: ViewContainerRef; + + public height = '500px'; + public data; + public count: Observable; + public ngOnInit(): void { + this.data = this.localService.records; + this.count = this.localService.count; + } + + public ngAfterViewInit() { + this.localService.getCount(); + this.localService.getData(this.parentVirtDir.state); + } + + public dataLoading(evt) { + this.localService.getData(evt, () => { + this.parentVirtDir.cdr.detectChanges(); + }); + } +} + +@Component({ + template: ` +
    + +
    {{item.text}}
    +
    +
    + `, + styles: [`.container { + display: flex; + flex-flow: column; + position: relative; + width: 300px; + height: 300px; + overflow: hidden; + border: 1px solid #000; + }`, `.forOfElement { + flex: 0 0 60px; + border-right: 1px solid #888; + }`], + imports: [TestIgxForOfDirective] +}) + +export class NoWidthAndHeightComponent { + @ViewChildren('child') + public childVirtDirs: QueryList; + + public items = []; + public width = '300px'; + public itemSize = 60; + public height = '300px'; + + constructor() { + for (let i = 0; i < 100; i++) { + this.items.push({ text: i + '' }); + } + } +} + +@Component({ + template: ` +
    + + +
    + {{rowIndex}} : {{item.text}} +
    +
    +
    + `, + imports: [IgxForOfDirective, NgClass] +}) +export class LocalVariablesComponent { + public data = []; + + constructor() { + for (let i = 0; i < 100; i++) { + this.data.push({ text: i + '' }); + } + } +} + + +@Pipe({ + name: "customSlice", + standalone: true +}) +export class CustomSlicePipe implements PipeTransform { + public transform(value: any[], start: number, end: number): any[] { + return value.slice(start, end); + } +} + +@Component({ + template: ` +
    +
    + {{rowIndex}} : {{item.text}} : {{localData[rowIndex].text}} +
    +
    + `, + imports: [IgxForOfDirective, CustomSlicePipe] +}) +export class LocalVariablesAsComponent { + public data = []; + + constructor() { + for (let i = 0; i < 100; i++) { + this.data.push({ text: i + '' }); + } + } +} diff --git a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts new file mode 100644 index 00000000000..7d035ae2888 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts @@ -0,0 +1,1852 @@ +import { NgForOfContext } from '@angular/common'; +import { ChangeDetectorRef, ComponentRef, Directive, DoCheck, EmbeddedViewRef, EventEmitter, Input, IterableChanges, IterableDiffer, IterableDiffers, NgZone, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, TemplateRef, TrackByFunction, ViewContainerRef, AfterViewInit, booleanAttribute, DOCUMENT, inject } from '@angular/core'; + +import { DisplayContainerComponent } from './display.container'; +import { HVirtualHelperComponent } from './horizontal.virtual.helper.component'; +import { VirtualHelperComponent } from './virtual.helper.component'; + +import { IgxForOfSyncService, IgxForOfScrollSyncService } from './for_of.sync.service'; +import { Subject } from 'rxjs'; +import { takeUntil, filter, throttleTime, first } from 'rxjs/operators'; +import { getResizeObserver } from 'igniteui-angular/core'; +import { IBaseEventArgs, PlatformUtil } from 'igniteui-angular/core'; +import { VirtualHelperBaseDirective } from './base.helper.component'; + +const MAX_PERF_SCROLL_DIFF = 4; + +/** + * @publicApi + */ +export class IgxForOfContext { + constructor( + public $implicit: T, + public igxForOf: U, + public index: number, + public count: number + ) { } + + /** + * A function that returns whether the element is the first or not + */ + public get first(): boolean { + return this.index === 0; + } + + /** + * A function that returns whether the element is the last or not + */ + public get last(): boolean { + return this.index === this.count - 1; + } + + /** + * A function that returns whether the element is even or not + */ + public get even(): boolean { + return this.index % 2 === 0; + } + + /** + * A function that returns whether the element is odd or not + */ + public get odd(): boolean { + return !this.even; + } + +} + +/** @hidden @internal */ +export abstract class IgxForOfToken { + public abstract igxForOf: U & T[] | null; + public abstract state: IForOfState; + public abstract totalItemCount: number; + public abstract scrollPosition: number; + + public abstract chunkLoad: EventEmitter; + public abstract chunkPreload: EventEmitter; + + public abstract scrollTo(index: number): void; + public abstract getScrollForIndex(index: number, bottom?: boolean): number; + public abstract getScroll(): HTMLElement | undefined; + + // TODO: Re-evaluate use for this internally, better expose through separate API + public abstract igxForItemSize: any; + public abstract igxForContainerSize: any; + /** @hidden */ + public abstract dc: ComponentRef +} + +@Directive({ + selector: '[igxFor][igxForOf]', + providers: [ + IgxForOfScrollSyncService, + { provide: IgxForOfToken, useExisting: IgxForOfDirective } + ], + standalone: true +}) +export class IgxForOfDirective extends IgxForOfToken implements OnInit, OnChanges, DoCheck, OnDestroy, AfterViewInit { + private _viewContainer = inject(ViewContainerRef); + protected _template = inject>>(TemplateRef); + protected _differs = inject(IterableDiffers); + public cdr = inject(ChangeDetectorRef); + protected _zone = inject(NgZone); + protected syncScrollService = inject(IgxForOfScrollSyncService); + protected platformUtil = inject(PlatformUtil); + protected document = inject(DOCUMENT); + + + /** + * Sets the data to be rendered. + * ```html + * + * ``` + */ + @Input() + public igxForOf: U & T[] | null; + + /** + * Sets the property name from which to read the size in the data object. + */ + @Input() + public igxForSizePropName; + + /** + * Specifies the scroll orientation. + * Scroll orientation can be "vertical" or "horizontal". + * ```html + * + * ``` + */ + @Input() + public igxForScrollOrientation = 'vertical'; + + /** + * Optionally pass the parent `igxFor` instance to create a virtual template scrolling both horizontally and vertically. + * ```html + * + *
    + * + *
    {{rowIndex}} : {{item.text}}
    + *
    + *
    + *
    + * ``` + */ + @Input() + public igxForScrollContainer: any; + + /** + * Sets the px-affixed size of the container along the axis of scrolling. + * For "horizontal" orientation this value is the width of the container and for "vertical" is the height. + * ```html + * + * + * ``` + */ + @Input() + public igxForContainerSize: any; + + /** + * @hidden + * @internal + * Initial chunk size if no container size is passed. If container size is passed then the igxForOf calculates its chunk size + */ + @Input() + public igxForInitialChunkSize: any; + + /** + * Sets the px-affixed size of the item along the axis of scrolling. + * For "horizontal" orientation this value is the width of the column and for "vertical" is the height or the row. + * ```html + * + * ``` + */ + @Input() + public igxForItemSize: any; + + /** + * An event that is emitted after a new chunk has been loaded. + * ```html + * + * ``` + * ```typescript + * loadChunk(e){ + * alert("chunk loaded!"); + * } + * ``` + */ + @Output() + public chunkLoad = new EventEmitter(); + + /** + * @hidden @internal + * An event that is emitted when scrollbar visibility has changed. + */ + @Output() + public scrollbarVisibilityChanged = new EventEmitter(); + + /** + * An event that is emitted after the rendered content size of the igxForOf has been changed. + */ + @Output() + public contentSizeChange = new EventEmitter(); + + /** + * An event that is emitted after data has been changed. + * ```html + * + * ``` + * ```typescript + * dataChanged(e){ + * alert("data changed!"); + * } + * ``` + */ + @Output() + public dataChanged = new EventEmitter(); + + @Output() + public beforeViewDestroyed = new EventEmitter>(); + + /** + * An event that is emitted on chunk loading to emit the current state information - startIndex, endIndex, totalCount. + * Can be used for implementing remote load on demand for the igxFor data. + * ```html + * + * ``` + * ```typescript + * chunkPreload(e){ + * alert("chunk is loading!"); + * } + * ``` + */ + @Output() + public chunkPreload = new EventEmitter(); + + /** + * @hidden + */ + public dc: ComponentRef; + + /** + * The current state of the directive. It contains `startIndex` and `chunkSize`. + * state.startIndex - The index of the item at which the current visible chunk begins. + * state.chunkSize - The number of items the current visible chunk holds. + * These options can be used when implementing remote virtualization as they provide the necessary state information. + * ```typescript + * const gridState = this.parentVirtDir.state; + * ``` + */ + public state: IForOfState = { + startIndex: 0, + chunkSize: 0 + }; + + protected func; + protected _sizesCache: number[] = []; + protected scrollComponent: VirtualHelperBaseDirective; + protected _differ: IterableDiffer | null = null; + protected _trackByFn: TrackByFunction; + protected individualSizeCache: number[] = []; + /** Internal track for scroll top that is being virtualized */ + protected _virtScrollPosition = 0; + /** If the next onScroll event is triggered due to internal setting of scrollTop */ + protected _bScrollInternal = false; + // End properties related to virtual height handling + protected _embeddedViews: Array> = []; + protected contentResizeNotify = new Subject(); + protected contentObserver: ResizeObserver; + /** Size that is being virtualized. */ + protected _virtSize = 0; + /** + * @hidden + */ + protected destroy$ = new Subject(); + + private _totalItemCount: number = null; + private _adjustToIndex; + // Start properties related to virtual size handling due to browser limitation + /** Maximum size for an element of the browser. */ + private _maxSize; + /** + * Ratio for height that's being virtualizaed and the one visible + * If _virtHeightRatio = 1, the visible height and the virtualized are the same, also _maxSize > _virtHeight. + */ + private _virtRatio = 1; + + /** + * The total count of the virtual data items, when using remote service. + * Similar to the property totalItemCount, but this will allow setting the data count into the template. + * ```html + * + * ``` + */ + @Input() + public get igxForTotalItemCount(): number { + return this.totalItemCount; + } + public set igxForTotalItemCount(value: number) { + this.totalItemCount = value; + } + + /** + * The total count of the virtual data items, when using remote service. + * ```typescript + * this.parentVirtDir.totalItemCount = data.Count; + * ``` + */ + public get totalItemCount() { + return this._totalItemCount; + } + + public set totalItemCount(val) { + if (this._totalItemCount !== val) { + this._totalItemCount = val; + // update sizes in case total count changes. + const newSize = this.initSizesCache(this.igxForOf); + const sizeDiff = this.scrollComponent.size - newSize; + this.scrollComponent.size = newSize; + const lastChunkExceeded = this.state.startIndex + this.state.chunkSize > val; + if (lastChunkExceeded) { + this.state.startIndex = val - this.state.chunkSize; + } + this._adjustScrollPositionAfterSizeChange(sizeDiff); + } + } + + public get displayContainer(): HTMLElement | undefined { + return this.dc?.instance?._viewContainer?.element?.nativeElement; + } + + public get virtualHelper() { + return this.scrollComponent.nativeElement; + } + + /** + * @hidden + */ + public get isRemote(): boolean { + return this.totalItemCount !== null; + } + + /** + * + * Gets/Sets the scroll position. + * ```typescript + * const position = directive.scrollPosition; + * directive.scrollPosition = value; + * ``` + */ + public get scrollPosition(): number { + return this.scrollComponent.scrollAmount; + } + public set scrollPosition(val: number) { + if (val === this.scrollComponent.scrollAmount) { + return; + } + if (this.igxForScrollOrientation === 'horizontal' && this.scrollComponent) { + this.scrollComponent.nativeElement.scrollLeft = this.isRTL ? -val : val; + } else if (this.scrollComponent) { + this.scrollComponent.nativeElement.scrollTop = val; + } + } + + /** + * @hidden + */ + protected get isRTL() { + const dir = window.getComputedStyle(this.dc.instance._viewContainer.element.nativeElement).getPropertyValue('direction'); + return dir === 'rtl'; + } + + protected get sizesCache(): number[] { + return this._sizesCache; + } + protected set sizesCache(value: number[]) { + this._sizesCache = value; + } + + private get _isScrolledToBottom() { + if (!this.getScroll()) { + return true; + } + const scrollHeight = this.getScroll().scrollHeight; + // Use === and not >= because `scrollTop + container size` can't be bigger than `scrollHeight`, unless something isn't updated. + // Also use Math.round because Chrome has some inconsistencies and `scrollTop + container` can be float when zooming the page. + return Math.round(this.getScroll().scrollTop + this.igxForContainerSize) === scrollHeight; + } + + private get _isAtBottomIndex() { + return this.igxForOf && this.state.startIndex + this.state.chunkSize > this.igxForOf.length; + } + + public verticalScrollHandler(event) { + this.onScroll(event); + } + + public isScrollable() { + return this.scrollComponent.size > parseInt(this.igxForContainerSize, 10); + } + + /** + * @hidden + */ + public ngOnInit(): void { + const vc = this.igxForScrollContainer ? this.igxForScrollContainer._viewContainer : this._viewContainer; + this.igxForSizePropName = this.igxForSizePropName || 'width'; + this.dc = this._viewContainer.createComponent(DisplayContainerComponent, { index: 0 }); + this.dc.instance.scrollDirection = this.igxForScrollOrientation; + if (this.igxForOf && this.igxForOf.length) { + this.scrollComponent = this.syncScrollService.getScrollMaster(this.igxForScrollOrientation); + this.state.chunkSize = this._calculateChunkSize(); + this.dc.instance.notVirtual = !(this.igxForContainerSize && this.state.chunkSize < this.igxForOf.length); + if (this.scrollComponent && !this.scrollComponent.destroyed) { + this.state.startIndex = Math.min(this.getIndexAt(this.scrollPosition, this.sizesCache), + this.igxForOf.length - this.state.chunkSize); + } + for (let i = this.state.startIndex; i < this.state.startIndex + this.state.chunkSize && + this.igxForOf[i] !== undefined; i++) { + const input = this.igxForOf[i]; + const embeddedView = this.dc.instance._vcr.createEmbeddedView( + this._template, + new IgxForOfContext(input, this.igxForOf, this.getContextIndex(input), this.igxForOf.length) + ); + this._embeddedViews.push(embeddedView); + } + } + this._maxSize = this._calcMaxBrowserSize(); + if (this.igxForScrollOrientation === 'vertical') { + this.dc.instance._viewContainer.element.nativeElement.style.top = '0px'; + this.scrollComponent = this.syncScrollService.getScrollMaster(this.igxForScrollOrientation); + if (!this.scrollComponent || this.scrollComponent.destroyed) { + this.scrollComponent = vc.createComponent(VirtualHelperComponent).instance; + } + + this.scrollComponent.size = this.igxForOf ? this._calcSize() : 0; + this.syncScrollService.setScrollMaster(this.igxForScrollOrientation, this.scrollComponent); + this._zone.runOutsideAngular(() => { + this.verticalScrollHandler = this.verticalScrollHandler.bind(this); + this.scrollComponent.nativeElement.addEventListener('scroll', this.verticalScrollHandler); + this.dc.instance.scrollContainer = this.scrollComponent.nativeElement; + }); + const destructor = takeUntil(this.destroy$); + this.contentResizeNotify.pipe( + filter(() => this.igxForContainerSize && this.igxForOf && this.igxForOf.length > 0), + throttleTime(40, undefined, { leading: false, trailing: true }), + destructor + ).subscribe(() => this._zone.runTask(() => this.updateSizes())); + } + + if (this.igxForScrollOrientation === 'horizontal') { + this.func = (evt) => this.onHScroll(evt); + this.scrollComponent = this.syncScrollService.getScrollMaster(this.igxForScrollOrientation); + if (!this.scrollComponent) { + this.scrollComponent = vc.createComponent(HVirtualHelperComponent).instance; + this.scrollComponent.size = this.igxForOf ? this._calcSize() : 0; + this.syncScrollService.setScrollMaster(this.igxForScrollOrientation, this.scrollComponent); + this._zone.runOutsideAngular(() => { + this.scrollComponent.nativeElement.addEventListener('scroll', this.func); + this.dc.instance.scrollContainer = this.scrollComponent.nativeElement; + }); + } else { + this._zone.runOutsideAngular(() => { + this.scrollComponent.nativeElement.addEventListener('scroll', this.func); + this.dc.instance.scrollContainer = this.scrollComponent.nativeElement; + }); + } + this._updateScrollOffset(); + } + } + + public ngAfterViewInit(): void { + if (this.igxForScrollOrientation === 'vertical') { + this._zone.runOutsideAngular(() => { + if (this.platformUtil.isBrowser) { + this.contentObserver = new (getResizeObserver())(() => this.contentResizeNotify.next()); + this.contentObserver.observe(this.dc.instance._viewContainer.element.nativeElement); + } + }); + } + } + + /** + * @hidden + */ + public ngOnDestroy() { + this.removeScrollEventListeners(); + this.destroy$.next(true); + this.destroy$.complete(); + if (this.contentObserver) { + this.contentObserver.disconnect(); + } + } + + /** + * @hidden @internal + * Asserts the correct type of the context for the template that `igxForOf` will render. + * + * The presence of this method is a signal to the Ivy template type-check compiler that the + * `IgxForOf` structural directive renders its template with a specific context type. + */ + public static ngTemplateContextGuard(dir: IgxForOfDirective, ctx: any): + ctx is IgxForOfContext { + return true; + } + + /** + * @hidden + */ + public ngOnChanges(changes: SimpleChanges): void { + const forOf = 'igxForOf'; + if (forOf in changes) { + const value = changes[forOf].currentValue; + if (!this._differ && value) { + try { + this._differ = this._differs.find(value).create(this.igxForTrackBy); + } catch (e) { + throw new Error( + `Cannot find a differ supporting object "${value}" of type "${getTypeNameForDebugging(value)}". + NgFor only supports binding to Iterables such as Arrays.`); + } + } + } + const defaultItemSize = 'igxForItemSize'; + if (defaultItemSize in changes && !changes[defaultItemSize].firstChange && this.igxForOf) { + // handle default item size changed. + this.initSizesCache(this.igxForOf); + this._applyChanges(); + } + const containerSize = 'igxForContainerSize'; + if (containerSize in changes && !changes[containerSize].firstChange && this.igxForOf) { + const prevSize = parseInt(changes[containerSize].previousValue, 10); + const newSize = parseInt(changes[containerSize].currentValue, 10); + this._recalcOnContainerChange({prevSize, newSize}); + } + } + + /** + * @hidden + */ + public ngDoCheck(): void { + if (this._differ) { + const changes = this._differ.diff(this.igxForOf); + if (changes) { + // re-init cache. + if (!this.igxForOf) { + this.igxForOf = [] as U; + } + this._updateSizeCache(); + this._zone.run(() => { + this._applyChanges(); + this.cdr.markForCheck(); + this._updateScrollOffset(); + const args: IForOfDataChangingEventArgs = { + containerSize: this.igxForContainerSize, + state: this.state + }; + this.dataChanged.emit(args); + }); + } + } + } + + + /** + * Shifts the scroll thumb position. + * ```typescript + * this.parentVirtDir.addScroll(5); + * ``` + * + * @param addTop negative value to scroll up and positive to scroll down; + */ + public addScrollTop(add: number): boolean { + return this.addScroll(add); + } + + /** + * Shifts the scroll thumb position. + * ```typescript + * this.parentVirtDir.addScroll(5); + * ``` + * + * @param add negative value to scroll previous and positive to scroll next; + */ + public addScroll(add: number): boolean { + if (add === 0) { + return false; + } + const originalVirtScrollTop = this._virtScrollPosition; + const containerSize = parseInt(this.igxForContainerSize, 10); + const maxVirtScrollTop = this._virtSize - containerSize; + + this._bScrollInternal = true; + this._virtScrollPosition += add; + this._virtScrollPosition = this._virtScrollPosition > 0 ? + (this._virtScrollPosition < maxVirtScrollTop ? this._virtScrollPosition : maxVirtScrollTop) : + 0; + + this.scrollPosition += add / this._virtRatio; + if (Math.abs(add / this._virtRatio) < 1) { + // Actual scroll delta that was added is smaller than 1 and onScroll handler doesn't trigger when scrolling < 1px + const scrollOffset = this.fixedUpdateAllElements(this._virtScrollPosition); + // scrollOffset = scrollOffset !== parseInt(this.igxForItemSize, 10) ? scrollOffset : 0; + this.dc.instance._viewContainer.element.nativeElement.style.top = -(scrollOffset) + 'px'; + } + + const maxRealScrollTop = this.scrollComponent.nativeElement.scrollHeight - containerSize; + if ((this._virtScrollPosition > 0 && this.scrollPosition === 0) || + (this._virtScrollPosition < maxVirtScrollTop && this.scrollPosition === maxRealScrollTop)) { + // Actual scroll position is at the top or bottom, but virtual one is not at the top or bottom (there's more to scroll) + // Recalculate actual scroll position based on the virtual scroll. + this.scrollPosition = this._virtScrollPosition / this._virtRatio; + } else if (this._virtScrollPosition === 0 && this.scrollPosition > 0) { + // Actual scroll position is not at the top, but virtual scroll is. Just update the actual scroll + this.scrollPosition = 0; + } else if (this._virtScrollPosition === maxVirtScrollTop && this.scrollPosition < maxRealScrollTop) { + // Actual scroll position is not at the bottom, but virtual scroll is. Just update the acual scroll + this.scrollPosition = maxRealScrollTop; + } + return this._virtScrollPosition !== originalVirtScrollTop; + } + + /** + * Scrolls to the specified index. + * ```typescript + * this.parentVirtDir.scrollTo(5); + * ``` + * + * @param index + */ + public scrollTo(index: number) { + if (index < 0 || index > (this.isRemote ? this.totalItemCount : this.igxForOf.length) - 1) { + return; + } + const containerSize = parseInt(this.igxForContainerSize, 10); + const isPrevItem = index < this.state.startIndex || this.scrollPosition > this.sizesCache[index]; + let nextScroll = isPrevItem ? this.sizesCache[index] : this.sizesCache[index + 1] - containerSize; + if (nextScroll < 0) { + return; + } + const maxVirtScrollTop = this._virtSize - containerSize; + if (nextScroll > maxVirtScrollTop) { + nextScroll = maxVirtScrollTop; + } + this._bScrollInternal = true; + this._virtScrollPosition = nextScroll; + this.scrollPosition = this._virtScrollPosition / this._virtRatio; + this._adjustToIndex = !isPrevItem ? index : null; + } + + /** + * Scrolls by one item into the appropriate next direction. + * For "horizontal" orientation that will be the right column and for "vertical" that is the lower row. + * ```typescript + * this.parentVirtDir.scrollNext(); + * ``` + */ + public scrollNext() { + const scr = Math.abs(Math.ceil(this.scrollPosition)); + const endIndex = this.getIndexAt(scr + parseInt(this.igxForContainerSize, 10), this.sizesCache); + this.scrollTo(endIndex); + } + + /** + * Scrolls by one item into the appropriate previous direction. + * For "horizontal" orientation that will be the left column and for "vertical" that is the upper row. + * ```typescript + * this.parentVirtDir.scrollPrev(); + * ``` + */ + public scrollPrev() { + this.scrollTo(this.state.startIndex - 1); + } + + /** + * Scrolls by one page into the appropriate next direction. + * For "horizontal" orientation that will be one view to the right and for "vertical" that is one view to the bottom. + * ```typescript + * this.parentVirtDir.scrollNextPage(); + * ``` + */ + public scrollNextPage() { + this.addScroll(parseInt(this.igxForContainerSize, 10)); + } + + /** + * Scrolls by one page into the appropriate previous direction. + * For "horizontal" orientation that will be one view to the left and for "vertical" that is one view to the top. + * ```typescript + * this.parentVirtDir.scrollPrevPage(); + * ``` + */ + public scrollPrevPage() { + const containerSize = (parseInt(this.igxForContainerSize, 10)); + this.addScroll(-containerSize); + } + + /** + * @hidden + */ + public getColumnScrollLeft(colIndex) { + return this.sizesCache[colIndex]; + } + + /** + * Returns the total number of items that are fully visible. + * ```typescript + * this.parentVirtDir.getItemCountInView(); + * ``` + */ + public getItemCountInView() { + let startIndex = this.getIndexAt(this.scrollPosition, this.sizesCache); + if (this.scrollPosition - this.sizesCache[startIndex] > 0) { + // fisrt item is not fully in view + startIndex++; + } + const endIndex = this.getIndexAt(this.scrollPosition + parseInt(this.igxForContainerSize, 10), this.sizesCache); + return endIndex - startIndex; + } + + /** + * Returns a reference to the scrollbar DOM element. + * This is either a vertical or horizontal scrollbar depending on the specified igxForScrollOrientation. + * ```typescript + * dir.getScroll(); + * ``` + */ + public getScroll() { + return this.scrollComponent?.nativeElement; + } + /** + * Returns the size of the element at the specified index. + * ```typescript + * this.parentVirtDir.getSizeAt(1); + * ``` + */ + public getSizeAt(index: number) { + return this.sizesCache[index + 1] - this.sizesCache[index]; + } + + /** + * @hidden + * Function that is called to get the native scrollbar size that the browsers renders. + */ + public getScrollNativeSize() { + return this.scrollComponent ? this.scrollComponent.scrollNativeSize : 0; + } + + /** + * Returns the scroll offset of the element at the specified index. + * ```typescript + * this.parentVirtDir.getScrollForIndex(1); + * ``` + */ + public getScrollForIndex(index: number, bottom?: boolean) { + const containerSize = parseInt(this.igxForContainerSize, 10); + const scroll = bottom ? Math.max(0, this.sizesCache[index + 1] - containerSize) : this.sizesCache[index]; + return scroll; + } + + /** + * Returns the index of the element at the specified offset. + * ```typescript + * this.parentVirtDir.getIndexAtScroll(100); + * ``` + */ + public getIndexAtScroll(scrollOffset: number) { + return this.getIndexAt(scrollOffset, this.sizesCache); + } + /** + * Returns whether the target index is outside the view. + * ```typescript + * this.parentVirtDir.isIndexOutsideView(10); + * ``` + */ + public isIndexOutsideView(index: number) { + const targetNode = index >= this.state.startIndex && index <= this.state.startIndex + this.state.chunkSize ? + this._embeddedViews.map(view => + view.rootNodes.find(node => node.nodeType === Node.ELEMENT_NODE) || view.rootNodes[0].nextElementSibling)[index - this.state.startIndex] : null; + const rowHeight = this.getSizeAt(index); + const containerSize = parseInt(this.igxForContainerSize, 10); + const containerOffset = -(this.scrollPosition - this.sizesCache[this.state.startIndex]); + const endTopOffset = targetNode ? targetNode.offsetTop + rowHeight + containerOffset : containerSize + rowHeight; + return !targetNode || targetNode.offsetTop < Math.abs(containerOffset) + || containerSize && endTopOffset - containerSize > 5; + } + + /** + * @hidden + * Function that recalculates and updates cache sizes. + */ + public recalcUpdateSizes() { + const dimension = this.igxForScrollOrientation === 'horizontal' ? + this.igxForSizePropName : 'height'; + const diffs = []; + let totalDiff = 0; + const l = this._embeddedViews.length; + const rNodes = this._embeddedViews.map(view => + view.rootNodes.find(node => node.nodeType === Node.ELEMENT_NODE) || view.rootNodes[0].nextElementSibling); + for (let i = 0; i < l; i++) { + const rNode = rNodes[i]; + if (rNode) { + const height = window.getComputedStyle(rNode).getPropertyValue('height'); + const h = parseFloat(height) || parseInt(this.igxForItemSize, 10); + const index = this.state.startIndex + i; + if (!this.isRemote && !this.igxForOf[index]) { + continue; + } + const margin = this.getMargin(rNode, dimension); + const oldVal = this.individualSizeCache[index]; + const newVal = (dimension === 'height' ? h : rNode.clientWidth) + margin; + this.individualSizeCache[index] = newVal; + const currDiff = newVal - oldVal; + diffs.push(currDiff); + totalDiff += currDiff; + this.sizesCache[index + 1] = (this.sizesCache[index] || 0) + newVal; + } + } + // update cache + if (Math.abs(totalDiff) > 0) { + for (let j = this.state.startIndex + this.state.chunkSize + 1; j < this.sizesCache.length; j++) { + this.sizesCache[j] = (this.sizesCache[j] || 0) + totalDiff; + } + + // update scrBar heights/widths + const reducer = (acc, val) => acc + val; + + const hSum = this.individualSizeCache.reduce(reducer); + if (hSum > this._maxSize) { + this._virtRatio = hSum / this._maxSize; + } + this.scrollComponent.size = Math.min(this.scrollComponent.size + totalDiff, this._maxSize); + this._virtSize = hSum; + if (!this.scrollComponent.destroyed) { + this.scrollComponent.cdr.detectChanges(); + } + const scrToBottom = this._isScrolledToBottom && !this.dc.instance.notVirtual; + if (scrToBottom && !this._isAtBottomIndex) { + const containerSize = parseInt(this.igxForContainerSize, 10); + const maxVirtScrollTop = this._virtSize - containerSize; + this._bScrollInternal = true; + this._virtScrollPosition = maxVirtScrollTop; + this.scrollPosition = maxVirtScrollTop; + return; + } + if (this._adjustToIndex) { + // in case scrolled to specific index where after scroll heights are changed + // need to adjust the offsets so that item is last in view. + const updatesToIndex = this._adjustToIndex - this.state.startIndex + 1; + const sumDiffs = diffs.slice(0, updatesToIndex).reduce(reducer); + if (sumDiffs !== 0) { + this.addScroll(sumDiffs); + } + this._adjustToIndex = null; + } + } + } + + /** + * @hidden + * Reset scroll position. + * Needed in case scrollbar is hidden/detached but we still need to reset it. + */ + public resetScrollPosition() { + this.scrollPosition = 0; + this.scrollComponent.scrollAmount = 0; + } + + /** + * @hidden + */ + protected removeScrollEventListeners() { + if (this.igxForScrollOrientation === 'horizontal') { + this._zone.runOutsideAngular(() => this.scrollComponent?.nativeElement?.removeEventListener('scroll', this.func)); + } else { + this._zone.runOutsideAngular(() => + this.scrollComponent?.nativeElement?.removeEventListener('scroll', this.verticalScrollHandler) + ); + } + } + + /** + * @hidden + * Function that is called when scrolling vertically + */ + protected onScroll(event) { + /* in certain situations this may be called when no scrollbar is visible */ + if (!parseInt(this.scrollComponent.nativeElement.style.height, 10)) { + return; + } + if (!this._bScrollInternal) { + this._calcVirtualScrollPosition(event.target.scrollTop); + } else { + this._bScrollInternal = false; + } + const prevStartIndex = this.state.startIndex; + const scrollOffset = this.fixedUpdateAllElements(this._virtScrollPosition); + + this.dc.instance._viewContainer.element.nativeElement.style.top = -(scrollOffset) + 'px'; + + this._zone.onStable.pipe(first()).subscribe(this.recalcUpdateSizes.bind(this)); + + this.dc.changeDetectorRef.detectChanges(); + if (prevStartIndex !== this.state.startIndex) { + this.chunkLoad.emit(this.state); + } + } + + + /** + * @hidden + * @internal + */ + public updateScroll(): void { + if (this.igxForScrollOrientation === "horizontal") { + const scrollAmount = this.scrollComponent.nativeElement["scrollLeft"]; + this.scrollComponent.scrollAmount = scrollAmount; + this._updateScrollOffset(); + } + } + + protected updateSizes() { + if (!this.scrollComponent.nativeElement.isConnected) return; + const scrollable = this.isScrollable(); + this.recalcUpdateSizes(); + this._applyChanges(); + this._updateScrollOffset(); + if (scrollable !== this.isScrollable()) { + this.scrollbarVisibilityChanged.emit(); + } else { + this.contentSizeChange.emit(); + } + } + + /** + * @hidden + */ + protected fixedUpdateAllElements(inScrollTop: number): number { + const count = this.isRemote ? this.totalItemCount : this.igxForOf.length; + let newStart = this.getIndexAt(inScrollTop, this.sizesCache); + + if (newStart + this.state.chunkSize > count) { + newStart = count - this.state.chunkSize; + } + + const prevStart = this.state.startIndex; + const diff = newStart - this.state.startIndex; + this.state.startIndex = newStart; + + if (diff) { + this.chunkPreload.emit(this.state); + if (!this.isRemote) { + + // recalculate and apply page size. + if (diff && Math.abs(diff) <= MAX_PERF_SCROLL_DIFF) { + if (diff > 0) { + this.moveApplyScrollNext(prevStart); + } else { + this.moveApplyScrollPrev(prevStart); + } + } else { + this.fixedApplyScroll(); + } + } + } + + return inScrollTop - this.sizesCache[this.state.startIndex]; + } + + /** + * @hidden + * The function applies an optimized state change for scrolling down/right employing context change with view rearrangement + */ + protected moveApplyScrollNext(prevIndex: number): void { + const start = prevIndex + this.state.chunkSize; + const end = start + this.state.startIndex - prevIndex; + const container = this.dc.instance._vcr as ViewContainerRef; + + for (let i = start; i < end && this.igxForOf[i] !== undefined; i++) { + const embView = this._embeddedViews.shift(); + if (!embView.destroyed) { + this.scrollFocus(embView.rootNodes.find(node => node.nodeType === Node.ELEMENT_NODE) + || embView.rootNodes[0].nextElementSibling); + const view = container.detach(0); + + this.updateTemplateContext(embView.context, i); + container.insert(view); + this._embeddedViews.push(embView); + } + } + } + + /** + * @hidden + * The function applies an optimized state change for scrolling up/left employing context change with view rearrangement + */ + protected moveApplyScrollPrev(prevIndex: number): void { + const container = this.dc.instance._vcr as ViewContainerRef; + for (let i = prevIndex - 1; i >= this.state.startIndex && this.igxForOf[i] !== undefined; i--) { + const embView = this._embeddedViews.pop(); + if (!embView.destroyed) { + this.scrollFocus(embView.rootNodes.find(node => node.nodeType === Node.ELEMENT_NODE) + || embView.rootNodes[0].nextElementSibling); + const view = container.detach(container.length - 1); + + this.updateTemplateContext(embView.context, i); + container.insert(view, 0); + this._embeddedViews.unshift(embView); + } + } + } + + /** + * @hidden + */ + protected getContextIndex(input) { + return this.isRemote ? this.state.startIndex + this.igxForOf.indexOf(input) : this.igxForOf.indexOf(input); + } + + /** + * @hidden + * Function which updates the passed context of an embedded view with the provided index + * from the view container. + * Often, called while handling a scroll event. + */ + protected updateTemplateContext(context: any, index = 0): void { + context.$implicit = this.igxForOf[index]; + context.index = this.getContextIndex(this.igxForOf[index]); + context.count = this.igxForOf.length; + } + + /** + * @hidden + * The function applies an optimized state change through context change for each view + */ + protected fixedApplyScroll(): void { + let j = 0; + const endIndex = this.state.startIndex + this.state.chunkSize; + for (let i = this.state.startIndex; i < endIndex && this.igxForOf[i] !== undefined; i++) { + const embView = this._embeddedViews[j++]; + this.updateTemplateContext(embView.context, i); + } + } + + /** + * @hidden + * @internal + * + * Clears focus inside the virtualized container on small scroll swaps. + */ + protected scrollFocus(node?: HTMLElement): void { + if (!node) { + return; + } + const document = node.getRootNode() as Document | ShadowRoot; + const activeElement = document.activeElement as HTMLElement; + + // Remove focus in case the the active element is inside the view container. + // Otherwise we hit an exception while doing the 'small' scrolls swapping. + // For more information: + // + // https://developer.mozilla.org/en-US/docs/Web/API/Node/removeChild + // https://bugs.chromium.org/p/chromium/issues/detail?id=432392 + if (node && node.contains(activeElement)) { + activeElement.blur(); + } + } + + /** + * @hidden + * Function that is called when scrolling horizontally + */ + protected onHScroll(event) { + /* in certain situations this may be called when no scrollbar is visible */ + const firstScrollChild = this.scrollComponent.nativeElement.children.item(0) as HTMLElement; + if (!parseInt(firstScrollChild.style.width, 10)) { + return; + } + if (!this._bScrollInternal) { + this._calcVirtualScrollPosition(event.target.scrollLeft); + } else { + this._bScrollInternal = false; + } + const prevStartIndex = this.state.startIndex; + const scrLeft = event.target.scrollLeft; + // Updating horizontal chunks + const scrollOffset = this.fixedUpdateAllElements(Math.abs(this._virtScrollPosition)); + if (scrLeft < 0) { + // RTL + this.dc.instance._viewContainer.element.nativeElement.style.left = scrollOffset + 'px'; + } else { + this.dc.instance._viewContainer.element.nativeElement.style.left = -scrollOffset + 'px'; + } + this._zone.onStable.pipe(first()).subscribe(this.recalcUpdateSizes.bind(this)); + + this.dc.changeDetectorRef.detectChanges(); + if (prevStartIndex !== this.state.startIndex) { + this.chunkLoad.emit(this.state); + } + } + + /** + * Gets the function used to track changes in the items collection. + * By default the object references are compared. However this can be optimized if you have unique identifier + * value that can be used for the comparison instead of the object ref or if you have some other property values + * in the item object that should be tracked for changes. + * This option is similar to ngForTrackBy. + * ```typescript + * const trackFunc = this.parentVirtDir.igxForTrackBy; + * ``` + */ + @Input() + public get igxForTrackBy(): TrackByFunction { + return this._trackByFn; + } + + /** + * Sets the function used to track changes in the items collection. + * This function can be set in scenarios where you want to optimize or + * customize the tracking of changes for the items in the collection. + * The igxForTrackBy function takes the index and the current item as arguments and needs to return the unique identifier for this item. + * ```typescript + * this.parentVirtDir.igxForTrackBy = (index, item) => { + * return item.id + item.width; + * }; + * ``` + */ + public set igxForTrackBy(fn: TrackByFunction) { + this._trackByFn = fn; + } + + /** + * @hidden + */ + protected _applyChanges() { + const prevChunkSize = this.state.chunkSize; + this.applyChunkSizeChange(); + this._recalcScrollBarSize(); + if (this.igxForOf && this.igxForOf.length && this.dc) { + const embeddedViewCopy = Object.assign([], this._embeddedViews); + let startIndex = this.state.startIndex; + let endIndex = this.state.chunkSize + this.state.startIndex; + if (this.isRemote) { + startIndex = 0; + endIndex = this.igxForOf.length; + } + for (let i = startIndex; i < endIndex && this.igxForOf[i] !== undefined; i++) { + const embView = embeddedViewCopy.shift(); + this.updateTemplateContext(embView.context, i); + } + if (prevChunkSize !== this.state.chunkSize) { + this.chunkLoad.emit(this.state); + } + } + } + + /** + * @hidden + */ + protected _calcMaxBrowserSize(): number { + if (!this.platformUtil.isBrowser) { + return 0; + } + const div = this.document.createElement('div'); + const style = div.style; + style.position = 'absolute'; + const dir = this.igxForScrollOrientation === 'horizontal' ? 'left' : 'top'; + style[dir] = '9999999999999999px'; + this.document.body.appendChild(div); + const size = Math.abs(div.getBoundingClientRect()[dir]); + this.document.body.removeChild(div); + return size; + } + + /** + * @hidden + * Recalculates the chunkSize based on current startIndex and returns the new size. + * This should be called after this.state.startIndex is updated, not before. + */ + protected _calculateChunkSize(): number { + let chunkSize = 0; + if (this.igxForContainerSize !== null && this.igxForContainerSize !== undefined) { + if (!this.sizesCache || this.sizesCache.length === 0) { + this.initSizesCache(this.igxForOf); + } + chunkSize = this._calcMaxChunkSize(); + if (this.igxForOf && chunkSize > this.igxForOf.length) { + chunkSize = this.igxForOf.length; + } + } else { + if (this.igxForOf) { + chunkSize = Math.min(this.igxForInitialChunkSize || this.igxForOf.length, this.igxForOf.length); + } + } + return chunkSize; + } + + /** + * @hidden + */ + protected getElement(viewref, nodeName) { + const elem = viewref.element.nativeElement.parentNode.getElementsByTagName(nodeName); + return elem.length > 0 ? elem[0] : null; + } + + /** + * @hidden + */ + protected initSizesCache(items: U): number { + let totalSize = 0; + let size = 0; + const dimension = this.igxForSizePropName || 'height'; + let i = 0; + this.sizesCache = []; + this.individualSizeCache = []; + this.sizesCache.push(0); + const count = this.isRemote ? this.totalItemCount : items.length; + for (i; i < count; i++) { + size = this._getItemSize(items[i], dimension); + this.individualSizeCache.push(size); + totalSize += size; + this.sizesCache.push(totalSize); + } + return totalSize; + } + + protected _updateSizeCache() { + if (this.igxForScrollOrientation === 'horizontal') { + this.initSizesCache(this.igxForOf); + return; + } + const oldHeight = this.individualSizeCache.length > 0 ? this.individualSizeCache.reduce((acc, val) => acc + val) : 0; + const newHeight = this.initSizesCache(this.igxForOf); + + const diff = oldHeight - newHeight; + this._adjustScrollPositionAfterSizeChange(diff); + } + + /** + * @hidden + */ + protected _calcMaxChunkSize(): number { + let i = 0; + let length = 0; + let maxLength = 0; + const arr = []; + let sum = 0; + const availableSize = parseInt(this.igxForContainerSize, 10); + if (!availableSize) { + return 0; + } + const dimension = this.igxForScrollOrientation === 'horizontal' ? + this.igxForSizePropName : 'height'; + const reducer = (accumulator, currentItem) => accumulator + this._getItemSize(currentItem, dimension); + for (i; i < this.igxForOf.length; i++) { + let item: T | { value: T, height: number } = this.igxForOf[i]; + if (dimension === 'height') { + item = { value: this.igxForOf[i], height: this.individualSizeCache[i] }; + } + const size = dimension === 'height' ? + this.individualSizeCache[i] : + this._getItemSize(item, dimension); + sum = arr.reduce(reducer, size); + if (sum < availableSize) { + arr.push(item); + length = arr.length; + if (i === this.igxForOf.length - 1) { + // reached end without exceeding + // include prev items until size is filled or first item is reached. + let curItem = dimension === 'height' ? arr[0].value : arr[0]; + let prevIndex = this.igxForOf.indexOf(curItem) - 1; + while (prevIndex >= 0 && sum <= availableSize) { + curItem = dimension === 'height' ? arr[0].value : arr[0]; + prevIndex = this.igxForOf.indexOf(curItem) - 1; + const prevItem = this.igxForOf[prevIndex]; + const prevSize = dimension === 'height' ? + this.individualSizeCache[prevIndex] : + parseInt(prevItem[dimension], 10); + sum = arr.reduce(reducer, prevSize); + arr.unshift(prevItem); + length = arr.length; + } + } + } else { + arr.push(item); + length = arr.length + 1; + arr.shift(); + } + if (length > maxLength) { + maxLength = length; + } + } + return maxLength; + } + + /** + * @hidden + */ + protected getIndexAt(left, set) { + let start = 0; + let end = set.length - 1; + if (left === 0) { + return 0; + } + while (start <= end) { + const midIdx = Math.floor((start + end) / 2); + const midLeft = set[midIdx]; + const cmp = left - midLeft; + if (cmp > 0) { + start = midIdx + 1; + } else if (cmp < 0) { + end = midIdx - 1; + } else { + return midIdx; + } + } + return end; + } + + protected _recalcScrollBarSize(containerSizeInfo = null) { + const count = this.isRemote ? this.totalItemCount : (this.igxForOf ? this.igxForOf.length : 0); + this.dc.instance.notVirtual = !(this.igxForContainerSize && this.dc && this.state.chunkSize < count); + const scrollable = containerSizeInfo ? this.scrollComponent.size > containerSizeInfo.prevSize : this.isScrollable(); + if (this.igxForScrollOrientation === 'horizontal') { + const totalWidth = parseInt(this.igxForContainerSize, 10) > 0 ? this._calcSize() : 0; + if (totalWidth <= parseInt(this.igxForContainerSize, 10)) { + this.resetScrollPosition(); + } + this.scrollComponent.nativeElement.style.width = this.igxForContainerSize + 'px'; + this.scrollComponent.size = totalWidth; + } + if (this.igxForScrollOrientation === 'vertical') { + const totalHeight = this._calcSize(); + if (totalHeight <= parseInt(this.igxForContainerSize, 10)) { + this.resetScrollPosition(); + } + this.scrollComponent.nativeElement.style.height = parseInt(this.igxForContainerSize, 10) + 'px'; + this.scrollComponent.size = totalHeight; + } + if (scrollable !== this.isScrollable()) { + // scrollbar visibility has changed + this.scrollbarVisibilityChanged.emit(); + } + } + + protected _calcSize(): number { + let size; + if (this.individualSizeCache && this.individualSizeCache.length > 0) { + size = this.individualSizeCache.reduce((acc, val) => acc + val, 0); + } else { + size = this.initSizesCache(this.igxForOf); + } + this._virtSize = size; + if (size > this._maxSize) { + this._virtRatio = size / this._maxSize; + size = this._maxSize; + } + return size; + } + + protected _recalcOnContainerChange(containerSizeInfo = null) { + const prevChunkSize = this.state.chunkSize; + this.applyChunkSizeChange(); + this._recalcScrollBarSize(containerSizeInfo); + if (prevChunkSize !== this.state.chunkSize) { + this.chunkLoad.emit(this.state); + } + } + + /** + * @hidden + * Removes an element from the embedded views and updates chunkSize. + */ + protected removeLastElem() { + const oldElem = this._embeddedViews.pop(); + this.beforeViewDestroyed.emit(oldElem); + // also detach from ViewContainerRef to make absolutely sure this is removed from the view container. + this.dc.instance._vcr.detach(this.dc.instance._vcr.length - 1); + oldElem.destroy(); + + this.state.chunkSize--; + } + + /** + * @hidden + * If there exists an element that we can create embedded view for creates it, appends it and updates chunkSize + */ + protected addLastElem() { + let elemIndex = this.state.startIndex + this.state.chunkSize; + if (!this.isRemote && !this.igxForOf) { + return; + } + + if (elemIndex >= this.igxForOf.length) { + elemIndex = this.igxForOf.length - this.state.chunkSize; + } + const input = this.igxForOf[elemIndex]; + const embeddedView = this.dc.instance._vcr.createEmbeddedView( + this._template, + new IgxForOfContext(input, this.igxForOf, this.getContextIndex(input), this.igxForOf.length) + ); + + this._embeddedViews.push(embeddedView); + this.state.chunkSize++; + + this._zone.run(() => this.cdr.markForCheck()); + } + + /** + * Recalculates chunkSize and adds/removes elements if need due to the change. + * this.state.chunkSize is updated in @addLastElem() or @removeLastElem() + */ + protected applyChunkSizeChange() { + const chunkSize = this.isRemote ? (this.igxForOf ? this.igxForOf.length : 0) : this._calculateChunkSize(); + if (chunkSize > this.state.chunkSize) { + const diff = chunkSize - this.state.chunkSize; + for (let i = 0; i < diff; i++) { + this.addLastElem(); + } + } else if (chunkSize < this.state.chunkSize) { + const diff = this.state.chunkSize - chunkSize; + for (let i = 0; i < diff; i++) { + this.removeLastElem(); + } + } + } + + protected _calcVirtualScrollPosition(scrollPosition: number) { + const containerSize = parseInt(this.igxForContainerSize, 10); + const maxRealScrollPosition = this.scrollComponent.size - containerSize; + const realPercentScrolled = maxRealScrollPosition !== 0 ? scrollPosition / maxRealScrollPosition : 0; + const maxVirtScroll = this._virtSize - containerSize; + this._virtScrollPosition = realPercentScrolled * maxVirtScroll; + } + + protected _getItemSize(item, dimension: string): number { + const dim = item ? item[dimension] : null; + return typeof dim === 'number' ? dim : parseInt(this.igxForItemSize, 10) || 0; + } + + protected _updateScrollOffset() { + let scrollOffset = 0; + let currentScroll = this.scrollPosition; + if (this._virtRatio !== 1) { + this._calcVirtualScrollPosition(this.scrollPosition); + currentScroll = this._virtScrollPosition; + } + const scroll = this.scrollComponent.nativeElement; + scrollOffset = scroll && this.scrollComponent.size ? + currentScroll - this.sizesCache[this.state.startIndex] : 0; + const dir = this.igxForScrollOrientation === 'horizontal' ? 'left' : 'top'; + this.dc.instance._viewContainer.element.nativeElement.style[dir] = -(scrollOffset) + 'px'; + } + + protected _adjustScrollPositionAfterSizeChange(sizeDiff) { + // if data has been changed while container is scrolled + // should update scroll top/left according to change so that same startIndex is in view + if (Math.abs(sizeDiff) > 0 && this.scrollPosition > 0) { + this.recalcUpdateSizes(); + const offset = this.igxForScrollOrientation === 'horizontal' ? + parseInt(this.dc.instance._viewContainer.element.nativeElement.style.left, 10) : + parseInt(this.dc.instance._viewContainer.element.nativeElement.style.top, 10); + const newSize = this.sizesCache[this.state.startIndex] - offset; + this.scrollPosition = newSize; + if (this.scrollPosition !== newSize) { + this.scrollComponent.scrollAmount = newSize; + } + } + } + + private getMargin(node, dimension: string): number { + const styles = window.getComputedStyle(node); + if (dimension === 'height') { + return parseFloat(styles['marginTop']) + + parseFloat(styles['marginBottom']) || 0; + } + return parseFloat(styles['marginLeft']) + + parseFloat(styles['marginRight']) || 0; + } +} + +export const getTypeNameForDebugging = (type: any): string => type.name || typeof type; + +export interface IForOfState extends IBaseEventArgs { + startIndex?: number; + chunkSize?: number; +} + +/** + * @deprecated in 19.2.7. Use `IForOfDataChangeEventArgs` instead. + */ +export interface IForOfDataChangingEventArgs extends IBaseEventArgs { + containerSize: number; + state: IForOfState; +} + +export interface IForOfDataChangeEventArgs extends IForOfDataChangingEventArgs {} + +export class IgxGridForOfContext extends IgxForOfContext { + constructor( + $implicit: T, + public igxGridForOf: U, + index: number, + count: number + ) { + super($implicit, igxGridForOf, index, count); + } +} + +@Directive({ + selector: '[igxGridFor][igxGridForOf]', + standalone: true +}) +export class IgxGridForOfDirective extends IgxForOfDirective implements OnInit, OnChanges, DoCheck { + protected syncService = inject(IgxForOfSyncService); + + @Input() + public set igxGridForOf(value: U & T[] | null) { + this.igxForOf = value; + } + + public get igxGridForOf() { + return this.igxForOf; + } + + @Input({ transform: booleanAttribute }) + public igxGridForOfUniqueSizeCache = false; + + @Input({ transform: booleanAttribute }) + public igxGridForOfVariableSizes = true; + + /** + * @hidden + * @internal + */ + public override get sizesCache(): number[] { + if (this.igxForScrollOrientation === 'horizontal') { + if (this.igxGridForOfUniqueSizeCache || this.syncService.isMaster(this)) { + return this._sizesCache; + } + return this.syncService.sizesCache(this.igxForScrollOrientation); + } else { + return this._sizesCache; + } + } + /** + * @hidden + * @internal + */ + public override set sizesCache(value: number[]) { + this._sizesCache = value; + } + + protected get itemsDimension() { + return this.igxForSizePropName || 'height'; + } + + public override recalcUpdateSizes() { + if (this.igxGridForOfVariableSizes && this.igxForScrollOrientation === 'vertical') { + super.recalcUpdateSizes(); + } + } + + /** + * @hidden @internal + * An event that is emitted after data has been changed but before the view is refreshed + */ + @Output() + public dataChanging = new EventEmitter(); + + /** + * @hidden @internal + * Asserts the correct type of the context for the template that `IgxGridForOfDirective` will render. + * + * The presence of this method is a signal to the Ivy template type-check compiler that the + * `IgxGridForOfDirective` structural directive renders its template with a specific context type. + */ + public static override ngTemplateContextGuard(dir: IgxGridForOfDirective, ctx: any): + ctx is IgxGridForOfContext { + return true; + } + + public override ngOnInit() { + this.syncService.setMaster(this); + super.ngOnInit(); + this.removeScrollEventListeners(); + } + + public override ngOnChanges(changes: SimpleChanges) { + const forOf = 'igxGridForOf'; + this.syncService.setMaster(this); + if (forOf in changes) { + const value = changes[forOf].currentValue; + if (!this._differ && value) { + try { + this._differ = this._differs.find(value).create(this.igxForTrackBy); + } catch (e) { + throw new Error( + `Cannot find a differ supporting object "${value}" of type "${getTypeNameForDebugging(value)}". + NgFor only supports binding to Iterables such as Arrays.`); + } + } + if (this.igxForScrollOrientation === 'horizontal') { + // in case collection has changes, reset sync service + this.syncService.setMaster(this, this.igxGridForOfUniqueSizeCache); + } + } + const defaultItemSize = 'igxForItemSize'; + if (defaultItemSize in changes && !changes[defaultItemSize].firstChange && + this.igxForScrollOrientation === 'vertical' && this.igxForOf) { + // handle default item size changed. + this.initSizesCache(this.igxForOf); + } + const containerSize = 'igxForContainerSize'; + if (containerSize in changes && !changes[containerSize].firstChange && this.igxForOf) { + const prevSize = parseInt(changes[containerSize].previousValue, 10); + const newSize = parseInt(changes[containerSize].currentValue, 10); + this._recalcOnContainerChange({prevSize, newSize}); + } + } + + /** + * @hidden + * @internal + */ + public assumeMaster(): void { + this._sizesCache = this.syncService.sizesCache(this.igxForScrollOrientation); + this.syncService.setMaster(this, true); + } + + public override ngDoCheck() { + if (this._differ) { + const changes = this._differ.diff(this.igxForOf); + if (changes) { + const args: IForOfDataChangingEventArgs = { + containerSize: this.igxForContainerSize, + state: this.state + }; + this.dataChanging.emit(args); + // re-init cache. + if (!this.igxForOf) { + this.igxForOf = [] as U; + } + /* we need to reset the master dir if all rows are removed + (e.g. because of filtering); if all columns are hidden, rows are + still rendered empty, so we should not reset master */ + if (!this.igxForOf.length && + this.igxForScrollOrientation === 'vertical') { + this.syncService.resetMaster(); + } + this.syncService.setMaster(this); + this.igxForContainerSize = args.containerSize; + const sizeDiff = this._updateSizeCache(changes); + this._applyChanges(); + if (sizeDiff) { + this._adjustScrollPositionAfterSizeChange(sizeDiff); + } + this._updateScrollOffset(); + this.dataChanged.emit(args); + } + } + } + + public override onScroll(event) { + if (!parseInt(this.scrollComponent.nativeElement.style.height, 10)) { + return; + } + if (!this._bScrollInternal) { + this._calcVirtualScrollPosition(event.target.scrollTop); + } else { + this._bScrollInternal = false; + } + const scrollOffset = this.fixedUpdateAllElements(this._virtScrollPosition); + + this.dc.instance._viewContainer.element.nativeElement.style.top = -(scrollOffset) + 'px'; + + this._zone.onStable.pipe(first()).subscribe(this.recalcUpdateSizes.bind(this)); + this.cdr.markForCheck(); + } + + public override onHScroll(scrollAmount) { + /* in certain situations this may be called when no scrollbar is visible */ + const firstScrollChild = this.scrollComponent.nativeElement.children.item(0) as HTMLElement; + if (!this.scrollComponent || !parseInt(firstScrollChild.style.width, 10)) { + return; + } + // Updating horizontal chunks + const scrollOffset = this.fixedUpdateAllElements(Math.abs(scrollAmount)); + if (scrollAmount < 0) { + // RTL + this.dc.instance._viewContainer.element.nativeElement.style.left = scrollOffset + 'px'; + } else { + // LTR + this.dc.instance._viewContainer.element.nativeElement.style.left = -scrollOffset + 'px'; + } + } + + protected getItemSize(item) { + let size = 0; + const dimension = this.igxForSizePropName || 'height'; + if (this.igxForScrollOrientation === 'vertical') { + size = this._getItemSize(item, dimension); + if (item && item.summaries) { + size = item.max; + } else if (item && item.groups && item.height) { + size = item.height; + } + } else { + size = parseInt(item[dimension], 10) || 0; + } + return size; + } + + protected override initSizesCache(items: U): number { + if (!this.syncService.isMaster(this) && this.igxForScrollOrientation === 'horizontal') { + const masterSizesCache = this.syncService.sizesCache(this.igxForScrollOrientation); + return masterSizesCache[masterSizesCache.length - 1]; + } + let totalSize = 0; + let size = 0; + let i = 0; + this.sizesCache = []; + this.individualSizeCache = []; + this.sizesCache.push(0); + const count = this.isRemote ? this.totalItemCount : items.length; + for (i; i < count; i++) { + size = this.getItemSize(items[i]); + this.individualSizeCache.push(size); + totalSize += size; + this.sizesCache.push(totalSize); + } + return totalSize; + } + + protected override _updateSizeCache(changes: IterableChanges = null) { + const oldSize = this.individualSizeCache.length > 0 ? this.individualSizeCache.reduce((acc, val) => acc + val) : 0; + let newSize = oldSize; + if (changes && !this.isRemote) { + newSize = this.handleCacheChanges(changes); + } else { + return; + } + + const diff = oldSize - newSize; + return diff; + } + + protected handleCacheChanges(changes: IterableChanges) { + const identityChanges = []; + const newHeightCache = []; + const newSizesCache = []; + newSizesCache.push(0); + let newHeight = 0; + + // When there are more than one removed items the changes are not reliable so those with identity change should be default size. + let numRemovedItems = 0; + changes.forEachRemovedItem(() => numRemovedItems++); + + // Get the identity changes to determine later if those that have changed their indexes should be assigned default item size. + changes.forEachIdentityChange((item) => { + if (item.currentIndex !== item.previousIndex) { + // Filter out ones that have not changed their index. + identityChanges[item.currentIndex] = item; + } + }); + + // Processing each item that is passed to the igxForOf so far seem to be most reliable. We parse the updated list of items. + changes.forEachItem((item) => { + if (item.previousIndex !== null && + (numRemovedItems < 2 || !identityChanges.length || identityChanges[item.currentIndex]) + && this.igxForScrollOrientation !== "horizontal" && this.individualSizeCache.length > 0) { + // Reuse cache on those who have previousIndex. + // When there are more than one removed items currently the changes are not readable so ones with identity change + // should be racalculated. + newHeightCache[item.currentIndex] = this.individualSizeCache[item.previousIndex]; + } else { + // Assign default item size. + newHeightCache[item.currentIndex] = this.getItemSize(item.item); + } + newSizesCache[item.currentIndex + 1] = newSizesCache[item.currentIndex] + newHeightCache[item.currentIndex]; + newHeight += newHeightCache[item.currentIndex]; + }); + this.individualSizeCache = newHeightCache; + this.sizesCache = newSizesCache; + return newHeight; + } + + protected override addLastElem() { + let elemIndex = this.state.startIndex + this.state.chunkSize; + if (!this.isRemote && !this.igxForOf) { + return; + } + + if (elemIndex >= this.igxForOf.length) { + elemIndex = this.igxForOf.length - this.state.chunkSize; + } + const input = this.igxForOf[elemIndex]; + const embeddedView = this.dc.instance._vcr.createEmbeddedView( + this._template, + new IgxGridForOfContext(input, this.igxForOf, this.getContextIndex(input), this.igxForOf.length) + ); + + this._embeddedViews.push(embeddedView); + this.state.chunkSize++; + } + + protected _updateViews(prevChunkSize) { + if (this.igxForOf && this.igxForOf.length && this.dc) { + const embeddedViewCopy = Object.assign([], this._embeddedViews); + let startIndex; + let endIndex; + if (this.isRemote) { + startIndex = 0; + endIndex = this.igxForOf.length; + } else { + startIndex = this.getIndexAt(this.scrollPosition, this.sizesCache); + if (startIndex + this.state.chunkSize > this.igxForOf.length) { + startIndex = this.igxForOf.length - this.state.chunkSize; + } + this.state.startIndex = startIndex; + endIndex = this.state.chunkSize + this.state.startIndex; + } + + for (let i = startIndex; i < endIndex && this.igxForOf[i] !== undefined; i++) { + const embView = embeddedViewCopy.shift(); + this.updateTemplateContext(embView.context, i); + } + if (prevChunkSize !== this.state.chunkSize) { + this.chunkLoad.emit(this.state); + } + } + } + protected override _applyChanges() { + const prevChunkSize = this.state.chunkSize; + this.applyChunkSizeChange(); + this._recalcScrollBarSize(); + this._updateViews(prevChunkSize); + } + + /** + * @hidden + */ + protected override _calcMaxChunkSize(): number { + if (this.igxForScrollOrientation === 'horizontal') { + if (this.syncService.isMaster(this)) { + return super._calcMaxChunkSize(); + } + return this.syncService.chunkSize(this.igxForScrollOrientation); + } else { + return super._calcMaxChunkSize(); + } + + } +} diff --git a/projects/igniteui-angular/directives/src/directives/for-of/for_of.module.ts b/projects/igniteui-angular/directives/src/directives/for-of/for_of.module.ts new file mode 100644 index 00000000000..7cc382ed3b3 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/for-of/for_of.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from '@angular/core'; +import { IgxForOfDirective } from './for_of.directive'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [IgxForOfDirective], + exports: [IgxForOfDirective] +}) +export class IgxForOfModule { +} diff --git a/projects/igniteui-angular/directives/src/directives/for-of/for_of.sync.service.ts b/projects/igniteui-angular/directives/src/directives/for-of/for_of.sync.service.ts new file mode 100644 index 00000000000..9599a551e5f --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/for-of/for_of.sync.service.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@angular/core'; +import { IgxGridForOfDirective } from './for_of.directive'; +import { VirtualHelperBaseDirective } from './base.helper.component'; + +@Injectable({ + providedIn: 'root', +}) +export class IgxForOfSyncService { + + private _master: Map> = new Map>(); + + /** + * @hidden + */ + public isMaster(directive: IgxGridForOfDirective): boolean { + return this._master.get(directive.igxForScrollOrientation) === directive; + } + + /** + * @hidden + */ + public setMaster(directive: IgxGridForOfDirective, forced = false) { + const orientation = directive.igxForScrollOrientation; + // in case master is not in dom, set a new master + const isMasterInDom = this._master.get(orientation)?.dc?.instance?._viewContainer.element.nativeElement.isConnected; + if (!isMasterInDom) { + forced = true; + } + if (orientation && (forced || !this._master.has(orientation))) { + this._master.set(orientation, directive); + } + } + + /** + * @hidden + */ + public resetMaster() { + this._master.clear(); + } + + /** + * @hidden + */ + public sizesCache(dir: string): number[] { + return this._master.get(dir).sizesCache; + } + + /** + * @hidden + */ + public chunkSize(dir: string): number { + return this._master.get(dir).state.chunkSize; + } +} + +@Injectable({ + providedIn: 'root', +}) +export class IgxForOfScrollSyncService { + private _masterScroll: Map = new Map(); + public setScrollMaster(dir: string, scroll: VirtualHelperBaseDirective) { + this._masterScroll.set(dir, scroll); + } + + public getScrollMaster(dir: string) { + return this._masterScroll.get(dir); + } +} diff --git a/projects/igniteui-angular/directives/src/directives/for-of/horizontal.virtual.helper.component.ts b/projects/igniteui-angular/directives/src/directives/for-of/horizontal.virtual.helper.component.ts new file mode 100644 index 00000000000..71981c495ef --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/for-of/horizontal.virtual.helper.component.ts @@ -0,0 +1,23 @@ +import { Component, HostBinding, Input, ViewChild, ViewContainerRef } from '@angular/core'; +import { VirtualHelperBaseDirective } from './base.helper.component'; + +/** + * @hidden + */ +@Component({ + selector: 'igx-horizontal-virtual-helper', + template: '
    ', + standalone: true +}) +export class HVirtualHelperComponent extends VirtualHelperBaseDirective { + @ViewChild('horizontal_container', { read: ViewContainerRef, static: true }) public _vcr; + + @Input() public width: number; + + @HostBinding('class') + public cssClasses = 'igx-vhelper--horizontal'; + + protected override restoreScroll() { + this.nativeElement.scrollLeft = this.scrollAmount; + } +} diff --git a/projects/igniteui-angular/directives/src/directives/for-of/virtual.helper.component.ts b/projects/igniteui-angular/directives/src/directives/for-of/virtual.helper.component.ts new file mode 100644 index 00000000000..17263004f61 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/for-of/virtual.helper.component.ts @@ -0,0 +1,33 @@ +import { Component, HostBinding, Input, ViewChild, ViewContainerRef, + OnDestroy, OnInit} from '@angular/core'; +import { VirtualHelperBaseDirective } from './base.helper.component'; + +@Component({ + selector: 'igx-virtual-helper', + template: '
    ', + standalone: true +}) +export class VirtualHelperComponent extends VirtualHelperBaseDirective implements OnInit, OnDestroy { + @HostBinding('scrollTop') + public scrollTop; + + public scrollWidth; + + @ViewChild('container', { read: ViewContainerRef, static: true }) public _vcr; + @Input() public itemsLength: number; + + @HostBinding('class') + public cssClasses = 'igx-vhelper--vertical'; + + public ngOnInit() { + this.scrollWidth = this.scrollNativeSize; + this.document.documentElement.style.setProperty( + '--vhelper-scrollbar-size', + `${this.scrollNativeSize}px` + ); + } + + protected override restoreScroll() { + this.nativeElement.scrollTop = this.scrollAmount; + } +} diff --git a/projects/igniteui-angular/directives/src/directives/form-control/README.md b/projects/igniteui-angular/directives/src/directives/form-control/README.md new file mode 100644 index 00000000000..f2c864409ff --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/form-control/README.md @@ -0,0 +1,42 @@ +# igcFormControlDirective + +The `IgcFormControl` directive is designed to attach to form `igc-` elements from Ignite UI for WebComponents and provide `ValueAccessor` implementation so that they can be used in Angular templates and reactive forms with support for `ngModel` and `formControlName` directives. + +The directive doesn't require a specific attribute and instead uses the element's name. This means that users only need to import it for it to take effect. + +```html + +``` + +```typescript +import { IgcFormsModule } from 'igniteui-angular'; + +@NgModule({ + declarations: AppComponent, + imports: [ + IgcFormsModule + ] +}) +export class AppModule { } +``` + +## Supported Components + +1. `igc-rating` + +## Notes + +- Users still need to define their Ignite UI Web Components before use as the directive doesn't do that for them. This can be achieved by using the `defineComponents` function inside your Angular component's .ts file using an Ignite UI Web Component. + ```typescript + import { Component } from '@angular/core'; + import { defineComponents, IgcRatingComponent } from 'igniteui-webcomponents'; + + defineComponents(IgcRatingComponent); + + @Component({ + selector: 'rating-sample', + styleUrls: ['rating.sample.css'], + templateUrl: 'rating.sample.html' + }) + export class RatingSampleComponent { } + ``` \ No newline at end of file diff --git a/projects/igniteui-angular/directives/src/directives/form-control/form-control.directive.spec.ts b/projects/igniteui-angular/directives/src/directives/form-control/form-control.directive.spec.ts new file mode 100644 index 00000000000..7de8dfb9ae5 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/form-control/form-control.directive.spec.ts @@ -0,0 +1,133 @@ +import { Component, DebugElement, ElementRef, Renderer2, ViewChild } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { defineComponents, IgcRatingComponent } from 'igniteui-webcomponents'; + +import { IgcFormControlDirective } from './form-control.directive'; + +describe('IgcFormControlDirective - ', () => { + + let fixture: ComponentFixture; + let directive: IgcFormControlDirective; + let input: DebugElement; + let rating: IgcRatingComponent; + + describe('Unit tests: ', () => { + + beforeEach(waitForAsync(() => { + defineComponents(IgcRatingComponent); + + TestBed.configureTestingModule({ + providers: [ + { provide: ElementRef, useValue: elementRef }, + { provide: Renderer2, useValue: renderer2Mock }, + IgcFormControlDirective + ] + }); + })); + + const elementRef = { nativeElement: document.createElement('igc-rating') }; + + const mockNgControl = jasmine.createSpyObj('NgControl', [ + 'writeValue', + 'onChange', + 'setDisabledState', + 'onChange', + 'registerOnChangeCb', + 'registerOnTouchedCb' + ]); + + const renderer2Mock = jasmine.createSpyObj('renderer2Mock', [ + 'setProperty' + ]); + + it('should correctly implement interface methods - ControlValueAccessor ', () => { + directive = TestBed.inject(IgcFormControlDirective); + directive.registerOnChange(mockNgControl.registerOnChangeCb); + directive.registerOnTouched(mockNgControl.registerOnTouchedCb); + + // value setter + expect(elementRef.nativeElement.value).toBeUndefined(); + directive.writeValue(8); + expect(mockNgControl.registerOnChangeCb).toHaveBeenCalledTimes(0); + expect(elementRef.nativeElement.value).toBe(8); + + // listening for value change + directive.listenForValueChange(5); + expect(mockNgControl.registerOnChangeCb).toHaveBeenCalledWith(5); + + // setDisabledState + directive.setDisabledState(true); + expect(renderer2Mock.setProperty).toHaveBeenCalledWith(elementRef.nativeElement, 'disabled', true); + + // OnTouched callback + directive.onBlur(); + expect(mockNgControl.registerOnTouchedCb).toHaveBeenCalledTimes(1); + }); + }); + + describe('ngModel two-way binding tests: ', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + IgxFormsControlComponent + ] + }).compileComponents(); + defineComponents(IgcRatingComponent); + })); + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(IgxFormsControlComponent); + fixture.detectChanges(); + input = fixture.debugElement.query(By.css(`#basicModelRating`)); + rating = fixture.debugElement.query(By.directive(IgcFormControlDirective)).nativeElement; + tick(); + fixture.detectChanges(); + })); + + it('Should properly init for igc-rating.', () => { + directive = fixture.componentInstance.directive; + expect(directive).toBeTruthy(); + }); + + it('Should reflect ngModel change to rating', async () => { + input.nativeElement.value = 8; + input.nativeElement.dispatchEvent(new Event('input')); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(rating.value).toEqual(8); + }); + + it('Should reflect ngModel change from rating', async () => { + rating.setAttribute('value', '8'); + rating.dispatchEvent(new CustomEvent('igcChange', { detail: 8 })); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(input.nativeElement.value).toEqual('8'); + }); + }); +}); + +@Component({ + template: ` +
    + + +
    + `, + imports: [IgcFormControlDirective, FormsModule] +}) +class IgxFormsControlComponent { + + @ViewChild(IgcFormControlDirective, { static: true }) + public directive: IgcFormControlDirective; + + public model = { + Name: 'BMW M3', + Rating: 5 + }; +} + diff --git a/projects/igniteui-angular/directives/src/directives/form-control/form-control.directive.ts b/projects/igniteui-angular/directives/src/directives/form-control/form-control.directive.ts new file mode 100644 index 00000000000..91d7b6f1478 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/form-control/form-control.directive.ts @@ -0,0 +1,58 @@ +import { Directive, forwardRef, ElementRef, HostListener, Renderer2, inject } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +@Directive({ + selector: 'igc-rating[ngModel],igc-rating[formControlName]', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => IgcFormControlDirective), + multi: true + } + ], + standalone: true +}) +export class IgcFormControlDirective implements ControlValueAccessor { + private elementRef = inject(ElementRef); + private renderer = inject(Renderer2); + + /** @hidden @internal */ + private onChange: any = () => { }; + /** @hidden @internal */ + private onTouched: any = () => { }; + + /** @hidden @internal */ + @HostListener('blur') + public onBlur() { + this.onTouched(); + } + + /** @hidden @internal */ + @HostListener('igcChange', ['$event.detail']) + public listenForValueChange(value) { + this.onChange(value); + } + + /** @hidden @internal */ + public writeValue(value): void { + if (value) { + this.elementRef.nativeElement.value = value; + } + } + + /** @hidden @internal */ + public registerOnChange(fn): void { + this.onChange = fn; + } + + /** @hidden @internal */ + public registerOnTouched(fn): void { + this.onTouched = fn; + } + + /** @hidden @internal */ + public setDisabledState(isDisabled: boolean): void { + this.renderer.setProperty(this.elementRef.nativeElement, 'disabled', isDisabled); + } +} + diff --git a/projects/igniteui-angular/directives/src/directives/form-control/form-control.module.ts b/projects/igniteui-angular/directives/src/directives/form-control/form-control.module.ts new file mode 100644 index 00000000000..d6dd922ed69 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/form-control/form-control.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { IgcFormControlDirective } from './form-control.directive'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [IgcFormControlDirective], + exports: [IgcFormControlDirective] +}) +export class IgcFormsModule { } diff --git a/projects/igniteui-angular/directives/src/directives/layout/README.md b/projects/igniteui-angular/directives/src/directives/layout/README.md new file mode 100644 index 00000000000..673507f364a --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/layout/README.md @@ -0,0 +1,29 @@ +# igxLayout + +Use the **igxLayout** directive on a container element to specify the layout +direction for its children: horizontally with `igxLayoutDir="row"` or vertically with +`igxLayoutDir="column"`. +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/layout.html) + +**Note**: the `igxLayout` directive affects the flow directions for that +container's **immediate** children. + +# API Summary +| Name | Type | Description | +|:----------|:-------------:|:------| +| `igxLayoutDir` | string | Sets the default flow direction of the container's children. Defaults to `rows`. | +| `igxLayoutReverse` | boolean | Defines the direction flex children are placed in the flex container. When set to `true`, the `rows` direction goes right to left and `columns` goes bottom to top. | +| `igxLayoutWrap` | string | By default the immediate children will all try to fit onto one line. The default value `nowrap` sets this behavior. Other accepted values are `wrap` and `wrap-reverse`| +| `igxLayoutJustify` | string | Defines the alignment along the main axis. Defaults to `flex-start` which packs the children toward the start line. Other possible values are `flex-end`, `center`, `space-between`, `space-around`| +| `igxLayoutItemAlign` | string | Defines the default behavior for how children are laid out along the corss axis of the current line. Defaults to `flex-start`. Other possible values are `flex-end`, `center`, `baseline`, and `stretch` | + + +# igx-flex +Use the `igxFlex` directive for elements inside an `igxLayout` parent to control specific flexbox properties. + +# API Summary +| Name | Type | Description | +|:----------|:-------------:|:------| +| `igxFlexOrder` | number | Controls in what order are the elements laid out in the flex container. Defaults to `0`. | +| `igxFlexGrow` | number | Sets whether an item should grow in a propotion to its peers inside the flex container. Defaults to `1`. | +| `igxFlexShrink` | number | Sets whether an items should shrink in a propotion to its peers. Defaults to `1` and negative number are not accepted. | diff --git a/projects/igniteui-angular/directives/src/directives/layout/layout.directive.spec.ts b/projects/igniteui-angular/directives/src/directives/layout/layout.directive.spec.ts new file mode 100644 index 00000000000..0a416c690ae --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/layout/layout.directive.spec.ts @@ -0,0 +1,147 @@ +import { Component, ViewChild } from '@angular/core'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { IgxFlexDirective, IgxLayoutDirective } from './layout.directive'; + +describe('IgxLayoutDirective', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TestFlexLayoutComponent] + }).compileComponents(); + })); + + it('should initialize with flex defaults', () => { + const fixture = TestBed.createComponent(TestFlexLayoutComponent); + fixture.detectChanges(); + + const instance = fixture.componentInstance.instance; + const el = fixture.debugElement.query(By.directive(IgxLayoutDirective)).nativeElement; + + expect(instance.display).toEqual('flex'); + expect(instance.dir).toEqual('row'); + expect(instance.wrap).toEqual('nowrap'); + expect(instance.align).toEqual('stretch'); + expect(instance.justify).toEqual('flex-start'); + expect(instance.reverse).toEqual(false); + + expect(el.style.display).toEqual('flex'); + expect(el.style.flexDirection).toEqual('row'); + expect(el.style.flexWrap).toEqual('nowrap'); + expect(el.style.alignItems).toEqual('stretch'); + expect(el.style.justifyContent).toEqual('flex-start'); + }); + + it('should set flex direction', () => { + const fixture = TestBed.createComponent(TestFlexLayoutComponent); + + const instance = fixture.componentInstance.instance; + const el = fixture.debugElement.query(By.directive(IgxLayoutDirective)).nativeElement; + + instance.dir = 'column'; + + fixture.detectChanges(); + + expect(el.style.flexDirection).toEqual('column'); + }); + + it('should set flex wrap', () => { + const fixture = TestBed.createComponent(TestFlexLayoutComponent); + + const instance = fixture.componentInstance.instance; + const el = fixture.debugElement.query(By.directive(IgxLayoutDirective)).nativeElement; + + instance.wrap = 'wrap'; + + fixture.detectChanges(); + + expect(el.style.flexWrap).toEqual('wrap'); + }); + + it('should set flex align', () => { + const fixture = TestBed.createComponent(TestFlexLayoutComponent); + + const instance = fixture.componentInstance.instance; + const el = fixture.debugElement.query(By.directive(IgxLayoutDirective)).nativeElement; + + instance.itemAlign = 'flex-start'; + + fixture.detectChanges(); + + expect(el.style.alignItems).toEqual('flex-start'); + }); + + it('should set flex justify', () => { + const fixture = TestBed.createComponent(TestFlexLayoutComponent); + + const instance = fixture.componentInstance.instance; + const el = fixture.debugElement.query(By.directive(IgxLayoutDirective)).nativeElement; + + instance.justify = 'flex-start'; + + fixture.detectChanges(); + + expect(el.style.justifyContent).toEqual('flex-start'); + }); + + it('should reverse flex direction', () => { + const fixture = TestBed.createComponent(TestFlexLayoutComponent); + + const instance = fixture.componentInstance.instance; + const el = fixture.debugElement.query(By.directive(IgxLayoutDirective)).nativeElement; + + instance.reverse = true; + fixture.detectChanges(); + expect(el.style.flexDirection).toEqual('row-reverse'); + + instance.dir = 'column'; + fixture.detectChanges(); + expect(el.style.flexDirection).toEqual('column-reverse'); + }); + + it('should initialize child flex element with defaults', () => { + const fixture = TestBed.createComponent(TestFlexLayoutComponent); + + const instance = fixture.componentInstance.inner; + const el = fixture.debugElement.query(By.directive(IgxFlexDirective)).nativeElement; + fixture.detectChanges(); + + expect(instance.flex).toBeFalsy(); + expect(instance.style).toEqual('1 1 auto'); + expect(instance.order).toEqual(0); + + expect(el.style.flex).toEqual('1 1 auto'); + expect(el.style.order).toEqual('0'); + }); + + it('should set flex shrink, grow, basis, and order', () => { + const fixture = TestBed.createComponent(TestFlexLayoutComponent); + + const instance = fixture.componentInstance.inner; + const el = fixture.debugElement.query(By.directive(IgxFlexDirective)).nativeElement; + + instance.grow = 0; + instance.shrink = 0; + instance.basis = '100%'; + instance.order = 2; + + fixture.detectChanges(); + expect(instance.style).toEqual('0 0 100%'); + expect(instance.order).toEqual(2); + + expect(el.style.flex).toEqual('0 0 100%'); + expect(el.style.order).toEqual('2'); + }); +}); + +@Component({ + template: ` +
    +
    +
    + `, + imports: [IgxLayoutDirective, IgxFlexDirective] +}) +class TestFlexLayoutComponent { + @ViewChild(IgxLayoutDirective, { static: true }) public instance: IgxLayoutDirective; + @ViewChild(IgxFlexDirective, { static: true }) public inner: IgxFlexDirective; +} diff --git a/projects/igniteui-angular/directives/src/directives/layout/layout.directive.ts b/projects/igniteui-angular/directives/src/directives/layout/layout.directive.ts new file mode 100644 index 00000000000..c8fb2346e38 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/layout/layout.directive.ts @@ -0,0 +1,236 @@ +import { Directive, HostBinding, Input, booleanAttribute } from '@angular/core'; + +@Directive({ + selector: '[igxLayout]', + standalone: true +}) +export class IgxLayoutDirective { + /** + * Sets the default flow direction of the container's children. + * + * Defaults to `rows`. + * + * ```html + *
    + *
    1
    + *
    2
    + *
    3
    + *
    + * ``` + */ + @Input('igxLayoutDir') public dir = 'row'; + + /** + * Defines the direction flex children are placed in the flex container. + * + * When set to `true`, the `rows` direction goes right to left and `columns` goes bottom to top. + * + * ```html + *
    + *
    1
    + *
    2
    + *
    3
    + *
    + * ``` + */ + @Input({ alias: 'igxLayoutReverse', transform: booleanAttribute }) public reverse = false; + + /** + * By default the immediate children will all try to fit onto one line. + * + * The default value `nowrap` sets this behavior. + * + * Other accepted values are `wrap` and `wrap-reverse`. + * + * ```html + *
    + *
    1
    + *
    2
    + *
    3
    + *
    + * ``` + */ + @Input('igxLayoutWrap') public wrap = 'nowrap'; + + /** + * Defines the alignment along the main axis. + * + * Defaults to `flex-start` which packs the children toward the start line. + * + * Other possible values are `flex-end`, `center`, `space-between`, `space-around`. + * + * ```html + *
    + *
    1
    + *
    2
    + *
    3
    + *
    + * ``` + */ + @Input('igxLayoutJustify') public justify = 'flex-start'; + + /** + * Defines the default behavior for how children are laid out along the corss axis of the current line. + * + * Defaults to `flex-start`. + * + * Other possible values are `flex-end`, `center`, `baseline`, and `stretch`. + * + * ```html + *
    + *
    1
    + *
    2
    + *
    3
    + *
    + * ``` + */ + @Input('igxLayoutItemAlign') public itemAlign = 'stretch'; + + /** + * @hidden + */ + @HostBinding('style.display') public display = 'flex'; + + /** + * @hidden + */ + @HostBinding('style.flex-wrap') + public get flexwrap() { + return this.wrap; + } + + /** + * @hidden + */ + @HostBinding('style.justify-content') + public get justifycontent() { + return this.justify; + } + + /** + * @hidden + */ + @HostBinding('style.align-items') + public get align() { + return this.itemAlign; + } + + /** + * @hidden + */ + @HostBinding('style.flex-direction') + public get direction() { + if (this.reverse) { + return (this.dir === 'row') ? 'row-reverse' : 'column-reverse'; + } + return (this.dir === 'row') ? 'row' : 'column'; + } +} + +@Directive({ + selector: '[igxFlex]', + standalone: true +}) +export class IgxFlexDirective { + + /** + * Applies the `grow` attribute to an element that uses the directive. + * + * Default value is `1`. + * + * ```html + *
    + *
    Content1
    + *
    Content2
    + *
    Content3
    + *
    + * ``` + */ + @Input('igxFlexGrow') public grow = 1; + + /** + * Applies the `shrink` attribute to an element that uses the directive. + * + * Default value is `1`. + * + * ```html + *
    + *
    Content1
    + *
    Content2
    + *
    Content3
    + *
    + * ``` + */ + @Input('igxFlexShrink') public shrink = 1; + + /** + * Applies the directive to an element. + * + * Possible values include `igxFlexGrow`, `igxFlexShrink`, `igxFlexOrder`, `igxFlexBasis`. + * + * ```html + *
    Content
    + * ``` + */ + @Input('igxFlex') public flex = ''; + + /** + * Applies the `order` attribute to an element that uses the directive. + * + * Default value is `0`. + * + * ```html + *
    + *
    Content1
    + *
    Content2
    + *
    Content3
    + *
    + * ``` + */ + @Input('igxFlexOrder') public order = 0; + + /** + * Applies the `flex-basis` attribute to an element that uses the directive. + * + * Default value is `auto`. + * + * Other possible values include `content`, `max-content`, `min-content`, `fit-content`. + * + * ```html + *
    Content
    + * ``` + */ + @Input('igxFlexBasis') public basis = 'auto'; + + /** + * @hidden + */ + @HostBinding('style.flex') + public get style() { + if (this.flex) { + return `${this.flex}`; + } + return `${this.grow} ${this.shrink} ${this.basis}`; + } + + /** + * @hidden + */ + @HostBinding('style.order') + public get itemorder() { + return this.order || 0; + } +} diff --git a/projects/igniteui-angular/directives/src/directives/layout/layout.module.ts b/projects/igniteui-angular/directives/src/directives/layout/layout.module.ts new file mode 100644 index 00000000000..77ccdb94f25 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/layout/layout.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { IgxFlexDirective, IgxLayoutDirective } from './layout.directive'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [IgxFlexDirective, IgxLayoutDirective], + exports: [IgxFlexDirective, IgxLayoutDirective] +}) +export class IgxLayoutModule { } diff --git a/projects/igniteui-angular/directives/src/directives/mask/README.md b/projects/igniteui-angular/directives/src/directives/mask/README.md new file mode 100644 index 00000000000..d358c39417a --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/mask/README.md @@ -0,0 +1,91 @@ +# igxMask + +The **igxMask** directive is intended to provide means for controlling user input and formatting the visible value based on a configurable mask rules. + +The following built-in mask rules should be supported: + + 0: requires a digit (0-9). + 9: requires a digit (0-9) or a space. + #: requires a digit (0-9), plus (+), or minus (-) sign. + L: requires a letter (a-Z). + ?: Requires a letter (a-Z) or a space. + A: requires an alphanumeric (0-9, a-Z). + a: requires an alphanumeric (0-9, a-Z) or a space. + &: any keyboard character (excluding space). + C: any keyboard character. + +Static symbols (literals) in the mask pattern are also supported. + +# Usage +```typescript +import { IgxMaskModule } from "igniteui-angular"; +``` + +Use the `igxMask` input property on an input element to apply a mask. The **igxMask** directive is fully supported only on an input element of type **text**. +```html + +``` + +Use the `includeLiterals` input property to include/exclude the mask literals from the raw value. +```typescript +public myValue = "1234567890"; +public myMask = "(000) 0000-000"; +public includeLiterals = true; +``` +```html + +``` + +Attach to the `valueChanged` event to implement custom logic when the value changes. Both, raw and formatted value, are accessible through the event payload. +```typescript +let raw: string; +let formatted: string; + +handleValueChanged(event) { + this.raw = event.rawValue; + this.formatted = event.formattedValue; +} +``` +```html + +``` + +Use the `placeholder` input property to specify the placeholder attribute of the host input element that the `igxMask` is applied on. +```typescript +placeholder = 'hello'; +``` +```html + +``` + +Use the `focusedValuePipe` and `displayValuePipe` input properties to additionally transform the value on focus and blur. +```typescript +@Pipe({ name: "displayFormat" }) +export class DisplayFormatPipe implements PipeTransform { + transform(value: any): string { + return value.toLowerCase(); + } +} + +displayFormat = new DisplayFormatPipe(); +``` +```html + +``` + +### API + +### Inputs +| Name | Type | Description | +|:----------:|:-------------|:------| +| `mask`| `String` | Represents the current mask. | +| `promptChar`| `String` | Character representing a fillable spot in the mask. | +| `includeLiterals`| `Boolean` | Include or exclude literals in the raw value. | +| `placeholder`| `string` | Specifies a short hint that describes the expected value. | +| `displayValuePipe`| `PipeTransform` | A pipe to transform the input value on blur. | +| `focusedValuePipe`| `PipeTransform` | A pipe to transform the input value on focus. | + +### Outputs +| Name | Return Type | Description | +|:--:|:---|:---| +| `valueChanged` | `void` | Fires each time the value changes. | diff --git a/projects/igniteui-angular/directives/src/directives/mask/mask-parsing.service.ts b/projects/igniteui-angular/directives/src/directives/mask/mask-parsing.service.ts new file mode 100644 index 00000000000..7bfd0169e04 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/mask/mask-parsing.service.ts @@ -0,0 +1,199 @@ +import { Injectable } from '@angular/core'; + + +const FLAGS = new Set('aACL09#&?'); +const REGEX = new Map([ + ['C', /(?!^$)/u], // Non-empty + ['&', /[^\p{Separator}]/u], // Non-whitespace + ['a', /[\p{Letter}\d\p{Separator}]/u], // Alphanumeric & whitespace + ['A', /[\p{Letter}\d]/u], // Alphanumeric + ['?', /[\p{Letter}\p{Separator}]/u], // Alpha & whitespace + ['L', /\p{Letter}/u], // Alpha + ['0', /\d/], // Numeric + ['9', /[\d\p{Separator}]/u], // Numeric & whitespace + ['#', /[\d\-+]/], // Numeric and sign +]); + +/** @hidden */ +export interface MaskOptions { + format: string; + promptChar: string; +} + +/** @hidden */ +export interface Replaced { + value: string; + end: number; +} + +interface ParsedMask { + literals: Map, + mask: string +} + +const replaceCharAt = (string: string, idx: number, char: string) => + `${string.substring(0, idx)}${char}${string.substring(idx + 1)}`; + + +export function parseMask(format: string): ParsedMask { + const literals = new Map(); + let mask = format; + + for (let i = 0, j = 0; i < format.length; i++, j++) { + const [current, next] = [format.charAt(i), format.charAt(i + 1)]; + + if (current === '\\' && FLAGS.has(next)) { + mask = replaceCharAt(mask, j, ''); + literals.set(j, next); + i++; + } else { + if (!FLAGS.has(current)) { + literals.set(j, current); + } + } + } + + return { literals, mask }; +} + +/** @hidden */ +@Injectable({ + providedIn: 'root' +}) +export class MaskParsingService { + + public applyMask(inputVal: string, maskOptions: MaskOptions, pos = 0): string { + let outputVal = ''; + let value = ''; + const { literals, mask } = parseMask(maskOptions.format); + const literalKeys: number[] = Array.from(literals.keys()); + const nonLiteralIndices: number[] = this.getNonLiteralIndices(mask, literalKeys); + const literalValues: string[] = Array.from(literals.values()); + + if (inputVal != null) { + value = inputVal.toString(); + } + + for (const _maskSym of mask) { + outputVal += maskOptions.promptChar; + } + + literals.forEach((val: string, key: number) => { + outputVal = replaceCharAt(outputVal, key, val); + }); + + if (!value) { + return outputVal; + } + + const nonLiteralValues: string[] = this.getNonLiteralValues(value, literalValues); + + for (let i = 0; i < nonLiteralValues.length; i++) { + const char = nonLiteralValues[i]; + const isCharValid = this.validateCharOnPosition(char, nonLiteralIndices[i], mask); + + if (!isCharValid && char !== maskOptions.promptChar) { + nonLiteralValues[i] = maskOptions.promptChar; + } + } + + if (nonLiteralValues.length > nonLiteralIndices.length) { + nonLiteralValues.splice(nonLiteralIndices.length); + } + + for (const nonLiteralValue of nonLiteralValues) { + const char = nonLiteralValue; + outputVal = replaceCharAt(outputVal, nonLiteralIndices[pos++], char); + } + + return outputVal; + } + + public parseValueFromMask(maskedValue: string, maskOptions: MaskOptions): string { + let outputVal = ''; + const literalValues: string[] = Array.from(parseMask(maskOptions.format).literals.values()); + + for (const val of maskedValue) { + if (literalValues.indexOf(val) === -1) { + if (val !== maskOptions.promptChar) { + outputVal += val; + } + } + } + + return outputVal; + } + + public replaceInMask(maskedValue: string, value: string, maskOptions: MaskOptions, start: number, end: number): Replaced { + const { literals, mask } = parseMask(maskOptions.format); + const literalsPositions = Array.from(literals.keys()); + value = this.replaceIMENumbers(value); + const chars = Array.from(value); + let cursor = start; + end = Math.min(end, maskedValue.length); + + for (let i = start; i < end || (chars.length && i < maskedValue.length); i++) { + if (literalsPositions.indexOf(i) !== -1) { + if (chars[0] === maskedValue[i] || value.length < 1) { + cursor = i + 1; + chars.shift(); + } + continue; + } + if (chars[0] + && !this.validateCharOnPosition(chars[0], i, mask) + && chars[0] !== maskOptions.promptChar) { + break; + } + + let char = maskOptions.promptChar; + if (chars.length) { + cursor = i + 1; + char = chars.shift(); + } + if (value.length < 1) { + // on `delete` the cursor should move forward + cursor++; + } + maskedValue = replaceCharAt(maskedValue, i, char); + } + + return { value: maskedValue, end: cursor }; + } + + /** Validates only non literal positions. */ + private validateCharOnPosition(inputChar: string, position: number, mask: string): boolean { + const regex = REGEX.get(mask.charAt(position)); + return regex ? regex.test(inputChar) : false; + } + + private getNonLiteralIndices(mask: string, literalKeys: number[]): number[] { + const nonLiteralsIndices: number[] = []; + + for (let i = 0; i < mask.length; i++) { + if (literalKeys.indexOf(i) === -1) { + nonLiteralsIndices.push(i); + } + } + + return nonLiteralsIndices; + } + private getNonLiteralValues(value: string, literalValues: string[]): string[] { + const nonLiteralValues: string[] = []; + + for (const val of value) { + if (literalValues.indexOf(val) === -1) { + nonLiteralValues.push(val); + } + } + + return nonLiteralValues; + } + + private replaceIMENumbers(value: string): string { + return value.replace(/[0123456789]/g, (num) => ({ + '1': '1', '2': '2', '3': '3', '4': '4', '5': '5', + '6': '6', '7': '7', '8': '8', '9': '9', '0': '0' + }[num])); + } +} diff --git a/projects/igniteui-angular/directives/src/directives/mask/mask.directive.spec.ts b/projects/igniteui-angular/directives/src/directives/mask/mask.directive.spec.ts new file mode 100644 index 00000000000..cd08b66bcd9 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/mask/mask.directive.spec.ts @@ -0,0 +1,917 @@ +import { Component, Input, ViewChild, ElementRef, Pipe, PipeTransform, Renderer2 } from '@angular/core'; +import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { IgxMaskDirective } from './mask.directive'; + +import { UIInteractions } from '../../../../test-utils/ui-interactions.spec'; +import { MaskParsingService, Replaced } from './mask-parsing.service'; +import { By } from '@angular/platform-browser'; +import { IgxInputGroupComponent } from '../../../../input-group/src/input-group/input-group.component'; +import { IgxInputDirective } from 'igniteui-angular/input-group'; +import { PlatformUtil } from 'igniteui-angular/core'; + +describe('igxMask', () => { + // TODO: Refactor tests to reuse components + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + AlphanumSpaceMaskComponent, + AnyCharMaskComponent, + DefMaskComponent, + DigitPlusMinusMaskComponent, + DigitSpaceMaskComponent, + EventFiringComponent, + IncludeLiteralsComponent, + LetterSpaceMaskComponent, + MaskComponent, + OneWayBindComponent, + PipesMaskComponent, + PlaceholderMaskComponent, + MaskTestComponent, + ReadonlyMaskTestComponent + ] + }).compileComponents(); + })); + + it('Initializes an input with default mask', () => { + const fixture = TestBed.createComponent(DefMaskComponent); + fixture.detectChanges(); + const input = fixture.componentInstance.input; + + expect(input.nativeElement.value).toEqual(''); + expect(input.nativeElement.getAttribute('placeholder')).toEqual('CCCCCCCCCC'); + + input.nativeElement.dispatchEvent(new Event('click')); + + input.nativeElement.value = '@#$YUA123'; + fixture.detectChanges(); + input.nativeElement.dispatchEvent(new Event('input')); + + input.nativeElement.dispatchEvent(new Event('focus')); + + expect(input.nativeElement.value).toEqual('@#$YUA123_'); + }); + + it('Initialize an input with escaped mask', () => { + const fixture = TestBed.createComponent(DefMaskComponent); + fixture.detectChanges(); + + const { input, maskDirective } = fixture.componentInstance; + + maskDirective.mask = '+\\9 000 000'; + fixture.detectChanges(); + + input.nativeElement.dispatchEvent(new Event('focus')); + fixture.detectChanges(); + + expect(input.nativeElement.value).toEqual('+9 ___ ___'); + }); + + it('Escaped mask - advanced escaped patterns with input', () => { + const fixture = TestBed.createComponent(DefMaskComponent); + fixture.detectChanges(); + + const { input, maskDirective } = fixture.componentInstance; + maskDirective.mask = '\\C\\C CCCC - \\0\\00 - X\\9\\9'; + fixture.detectChanges(); + + input.nativeElement.dispatchEvent(new Event('focus')); + fixture.detectChanges(); + + expect(input.nativeElement.value).toEqual('CC ____ - 00_ - X99'); + + input.nativeElement.value = 'abcdefgh'; + input.nativeElement.dispatchEvent(new Event('input')); + fixture.detectChanges(); + + expect(input.nativeElement.value).toEqual('CC abcd - 00_ - X99'); + }); + + it('Mask rules - digit (0-9) or a space', fakeAsync(() => { + const fixture = TestBed.createComponent(DigitSpaceMaskComponent); + fixture.detectChanges(); + + const input = fixture.componentInstance.input; + + input.nativeElement.dispatchEvent(new Event('focus')); + tick(); + fixture.detectChanges(); + + expect(input.nativeElement.value).toEqual('555 55'); + + })); + + it('Mask rules - digit (0-9), plus (+), or minus (-) sign', fakeAsync(() => { + const fixture = TestBed.createComponent(DigitPlusMinusMaskComponent); + fixture.detectChanges(); + + const input = fixture.componentInstance.input; + + input.nativeElement.dispatchEvent(new Event('focus')); + tick(); + fixture.detectChanges(); + + expect(input.nativeElement.value).toEqual('+359-884 19 08 54'); + })); + + it('Mask rules - letter (a-Z) or a space', fakeAsync(() => { + const fixture = TestBed.createComponent(LetterSpaceMaskComponent); + fixture.detectChanges(); + + const input = fixture.componentInstance.input; + + input.nativeElement.dispatchEvent(new Event('focus')); + tick(); + + expect(input.nativeElement.value).toEqual('AB _CD E'); + })); + + it('Mask rules - alphanumeric (0-9, a-Z) or a space', fakeAsync(() => { + const fixture = TestBed.createComponent(AlphanumSpaceMaskComponent); + fixture.detectChanges(); + + const input = fixture.componentInstance.input; + + input.nativeElement.dispatchEvent(new Event('focus')); + tick(); + + expect(input.nativeElement.value).toEqual('7c_ 8u'); + })); + + it('Mask rules - any keyboard character', fakeAsync(() => { + const fixture = TestBed.createComponent(AnyCharMaskComponent); + fixture.detectChanges(); + + const input = fixture.componentInstance.input; + + input.nativeElement.dispatchEvent(new Event('focus')); + tick(); + + expect(input.nativeElement.value).toEqual('_=%. p]'); + })); + + it('Enter value with a preset mask and value', fakeAsync(() => { + const fixture = TestBed.createComponent(MaskComponent); + fixture.detectChanges(); + tick(); // NgModel updateValue Promise + + const comp = fixture.componentInstance; + const input = comp.input; + + + expect(input.nativeElement.value).toEqual('(123) 4567-890'); + expect(comp.value).toEqual('1234567890'); + + comp.value = '7777'; + fixture.detectChanges(); + tick(); + + expect(input.nativeElement.value).toEqual('(777) 7___-___'); + expect(comp.value).toEqual('7777'); + })); + + it('Should be able to type full-width numbers', fakeAsync(() => { + const fixture = TestBed.createComponent(MaskComponent); + fixture.componentInstance.mask = '00/00/0000'; + fixture.detectChanges(); + tick(); + + const input = fixture.debugElement.query(By.css('input')); + input.triggerEventHandler('focus', {}); + fixture.detectChanges(); + + UIInteractions.simulateCompositionEvent('09062021', input, 0, 10); + fixture.detectChanges(); + + input.triggerEventHandler('blur', { target: input.nativeElement }); + tick(); + fixture.detectChanges(); + + expect(input.nativeElement.value).toEqual('09/06/2021'); + })); + + it('Should be able to type full-width characters', fakeAsync(() => { + const fixture = TestBed.createComponent(DefMaskComponent); + fixture.componentInstance.mask = 'CCC'; + fixture.detectChanges(); + tick(); + + const input = fixture.debugElement.query(By.css('input')); + input.triggerEventHandler('focus', {}); + fixture.detectChanges(); + + UIInteractions.simulateCompositionEvent('あんs', input, 0, 3); + fixture.detectChanges(); + + input.triggerEventHandler('blur', { target: input.nativeElement }); + tick(); + fixture.detectChanges(); + + expect(input.nativeElement.value).toEqual('あんs'); + })); + + it('Should move the cursor to the next character if the same character is typed', fakeAsync(() => { + const fixture = TestBed.createComponent(MaskComponent); + fixture.componentInstance.mask = '00/00/0000'; + fixture.detectChanges(); + tick(); + + const input = fixture.componentInstance.input; + + input.nativeElement.dispatchEvent(new Event('focus')); + tick(); + + input.nativeElement.value = '22222222'; + fixture.detectChanges(); + input.nativeElement.dispatchEvent(new Event('input')); + tick(); + + input.nativeElement.dispatchEvent(new Event('focus')); + tick(); + fixture.detectChanges(); + + expect(input.nativeElement.value).toEqual('22/22/2222'); + + input.nativeElement.dispatchEvent(new Event('focus')); + tick(); + + const target = fixture.debugElement.query(By.css('input')); + target.triggerEventHandler('focus', {}); + fixture.detectChanges(); + + UIInteractions.simulatePaste('2', target, 0, 1); + fixture.detectChanges(); + tick(); + + target.triggerEventHandler('blur', { target: input.nativeElement }); + tick(); + fixture.detectChanges(); + + expect(input.nativeElement.selectionEnd).toEqual(1); + })); + + it('Should handle the input of invalid values', fakeAsync(() => { + const fixture = TestBed.createComponent(MaskComponent); + fixture.detectChanges(); + const input = fixture.componentInstance.input; + + input.nativeElement.dispatchEvent(new Event('focus')); + tick(); + + input.nativeElement.value = 'abc4569d12'; + fixture.detectChanges(); + input.nativeElement.dispatchEvent(new Event('input')); + tick(); + + input.nativeElement.dispatchEvent(new Event('focus')); + tick(); + fixture.detectChanges(); + + expect(input.nativeElement.value).toEqual('(___) 4569-_12'); + })); + + it('Enter incorrect value with a preset mask', fakeAsync(() => { + pending('This must be remade into a typing test.'); + const fixture = TestBed.createComponent(MaskComponent); + fixture.detectChanges(); + const input = fixture.componentInstance.input; + + input.nativeElement.dispatchEvent(new Event('focus')); + tick(); + + input.nativeElement.value = 'abc4569d12'; + input.nativeElement.dispatchEvent(new Event('input')); + tick(); + + input.nativeElement.dispatchEvent(new Event('focus')); + tick(); + fixture.detectChanges(); + + expect(input.nativeElement.value).toEqual('(456) 912_-___'); + + input.nativeElement.dispatchEvent(new Event('focus')); + tick(); + + input.nativeElement.value = '1111111111111111111'; + fixture.detectChanges(); + input.nativeElement.dispatchEvent(new Event('input')); + tick(); + + input.nativeElement.dispatchEvent(new Event('focus')); + tick(); + + expect(input.nativeElement.value).toEqual('(111) 1111-111'); + })); + + it('Include literals in component value', fakeAsync(() => { + const fixture = TestBed.createComponent(IncludeLiteralsComponent); + fixture.detectChanges(); + + const input = fixture.componentInstance.input; + + input.nativeElement.dispatchEvent(new Event('focus')); + tick(); + + expect(input.nativeElement.value).toEqual('(555) 55__-___'); + })); + + it('Correct event firing', fakeAsync(() => { + const fixture = TestBed.createComponent(EventFiringComponent); + fixture.detectChanges(); + + const input = fixture.componentInstance.input; + + input.nativeElement.dispatchEvent(new Event('focus')); + tick(); + + input.nativeElement.value = '123'; + fixture.detectChanges(); + input.nativeElement.dispatchEvent(new Event('input')); + tick(); + + input.nativeElement.dispatchEvent(new Event('focus')); + tick(); + + expect(input.nativeElement.value).toEqual('(123) ____-___'); + expect(fixture.componentInstance.raw).toEqual('123'); + })); + + it('One way binding', fakeAsync(() => { + const fixture = TestBed.createComponent(OneWayBindComponent); + fixture.detectChanges(); + + const comp = fixture.componentInstance; + const input = comp.input; + + expect(input.nativeElement.value).toEqual('3456____'); + + input.nativeElement.dispatchEvent(new Event('focus')); + tick(); + + expect(input.nativeElement.value).toEqual('3456****'); + expect(comp.value).toEqual(3456); + + input.nativeElement.value = 'A'; + fixture.detectChanges(); + input.nativeElement.dispatchEvent(new Event('input')); + tick(); + + input.nativeElement.dispatchEvent(new Event('focus')); + tick(); + + expect(input.nativeElement.value).toEqual('A*******'); + })); + + it('Selection', fakeAsync(() => { + const fixture = TestBed.createComponent(MaskComponent); + fixture.detectChanges(); + + const input = fixture.componentInstance.input; + + input.nativeElement.focus(); + tick(); + + input.nativeElement.select(); + tick(); + + const keyEvent = new KeyboardEvent('keydown', { key: '57' }); + input.nativeElement.dispatchEvent(keyEvent); + tick(); + + input.nativeElement.value = ''; + input.nativeElement.dispatchEvent(new Event('input')); + tick(); + + input.nativeElement.dispatchEvent(new Event('focus')); + tick(); + + expect(input.nativeElement.value).toEqual('(___) ____-___'); + })); + + it('Enter value over literal', fakeAsync(() => { + const fixture = TestBed.createComponent(MaskComponent); + fixture.detectChanges(); + const input = fixture.componentInstance.input; + + input.nativeElement.focus(); + tick(); + + input.nativeElement.select(); + tick(); + + input.nativeElement.value = ''; + fixture.detectChanges(); + input.nativeElement.dispatchEvent(new Event('input')); + tick(); + + input.nativeElement.dispatchEvent(new Event('focus')); + tick(); + + expect(input.nativeElement.value).toEqual('(___) ____-___'); + + input.nativeElement.value = '6666'; + fixture.detectChanges(); + input.nativeElement.dispatchEvent(new Event('input')); + fixture.detectChanges(); + tick(); + + input.nativeElement.dispatchEvent(new Event('focus')); + tick(); + + expect(input.nativeElement.value).toEqual('(666) 6___-___'); + })); + + it('Should successfully drop text in the input', fakeAsync(() => { + const fixture = TestBed.createComponent(MaskComponent); + fixture.detectChanges(); + const input = fixture.componentInstance.input; + + input.nativeElement.focus(); + tick(); + input.nativeElement.select(); + tick(); + + input.nativeElement.value = '4576'; + UIInteractions.simulateDropEvent(input.nativeElement, '4576', 'text'); + fixture.detectChanges(); + tick(); + + input.nativeElement.dispatchEvent(new Event('focus')); + tick(); + + expect(input.nativeElement.value).toEqual('(457) 6___-___'); + })); + + it('Should display mask on dragenter and remove it on dragleave', fakeAsync(() => { + const fixture = TestBed.createComponent(MaskTestComponent); + fixture.detectChanges(); + const input = fixture.componentInstance.input; + + expect(input.nativeElement.value).toEqual(''); + expect(input.nativeElement.placeholder).toEqual('CCCCCCCCCC'); + + input.nativeElement.dispatchEvent(new DragEvent('dragenter')); + expect(input.nativeElement.value).toEqual('__________'); + + input.nativeElement.dispatchEvent(new DragEvent('dragleave')); + expect(input.nativeElement.value).toEqual(''); + + // should preserve state on dragenter + input.nativeElement.dispatchEvent(new Event('focus')); + UIInteractions.simulatePaste('76', fixture.debugElement.query(By.css('.igx-input-group__input')), 3, 3); + fixture.detectChanges(); + + input.nativeElement.dispatchEvent(new Event('blur')); + expect(input.nativeElement.value).toEqual('___76_____'); + + input.nativeElement.dispatchEvent(new DragEvent('dragenter')); + expect(input.nativeElement.value).toEqual('___76_____'); + })); + + it('Apply display and input pipes on blur and focus.', fakeAsync(() => { + const fixture = TestBed.createComponent(PipesMaskComponent); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + const input = fixture.componentInstance.input; + + input.nativeElement.dispatchEvent(new Event('focus')); + tick(); + fixture.detectChanges(); + + expect(input.nativeElement.value).toEqual('SSS'); + + input.nativeElement.dispatchEvent(new Event('blur')); + tick(); + fixture.detectChanges(); + + expect(input.nativeElement.value).toEqual('sss'); + })); + + it('Apply placeholder when value is not defined.', fakeAsync(() => { + const fixture = TestBed.createComponent(PlaceholderMaskComponent); + fixture.detectChanges(); + + const input = fixture.componentInstance.input; + + expect(input.nativeElement.value).toEqual(''); + expect(input.nativeElement.placeholder).toEqual('hello'); + + input.nativeElement.dispatchEvent(new Event('focus')); + fixture.detectChanges(); + + expect(input.nativeElement.value).toEqual('(__) (__)'); + expect(input.nativeElement.placeholder).toEqual('hello'); + + input.nativeElement.dispatchEvent(new Event('blur')); + fixture.detectChanges(); + + expect(input.nativeElement.value).toEqual(''); + expect(input.nativeElement.placeholder).toEqual('hello'); + })); + + it('should not enter edit mode if it is marked as readonly', fakeAsync(() => { + const fixture = TestBed.createComponent(ReadonlyMaskTestComponent); + fixture.detectChanges(); + + const maskDirective = fixture.componentInstance.mask; + spyOn(maskDirective, 'onFocus').and.callThrough(); + spyOn(maskDirective, 'showMask').and.callThrough(); + + const input = fixture.debugElement.query(By.css('.igx-input-group__input')); + input.triggerEventHandler('focus', {}); + fixture.detectChanges(); + + expect(maskDirective.onFocus).toHaveBeenCalledTimes(1); + expect((maskDirective as any).showMask).toHaveBeenCalledTimes(0); + expect((maskDirective as any).inputValue).toEqual(''); + })); + + it('should be able to update the mask dynamically', fakeAsync(() => { + const fixture = TestBed.createComponent(DefMaskComponent); + fixture.detectChanges(); + const input = fixture.componentInstance.input; + + expect(input.nativeElement.value).toEqual(''); + expect(input.nativeElement.placeholder).toEqual('CCCCCCCCCC'); + + fixture.componentInstance.mask = '00-00-00'; + fixture.detectChanges(); + expect(fixture.componentInstance.maskDirective.mask).toEqual('00-00-00'); + expect(input.nativeElement.placeholder).toEqual('00-00-00'); + + fixture.componentInstance.mask = '0'; + fixture.detectChanges(); + expect(fixture.componentInstance.maskDirective.mask).toEqual('0'); + expect(input.nativeElement.placeholder).toEqual('0'); + + fixture.componentInstance.mask = undefined; + fixture.detectChanges(); + expect(fixture.componentInstance.maskDirective.mask).toEqual('CCCCCCCCCC'); + expect(input.nativeElement.placeholder).toEqual('CCCCCCCCCC'); + + fixture.componentInstance.mask = ''; + fixture.detectChanges(); + expect(fixture.componentInstance.maskDirective.mask).toEqual('CCCCCCCCCC'); + expect(input.nativeElement.placeholder).toEqual('CCCCCCCCCC'); + + fixture.componentInstance.mask = '##.##'; + fixture.detectChanges(); + expect(fixture.componentInstance.maskDirective.mask).toEqual('##.##'); + expect(input.nativeElement.placeholder).toEqual('##.##'); + })); + + it('should update input properly on selection with DELETE', () => { + const fixture = TestBed.createComponent(MaskComponent); + fixture.detectChanges(); + const inputElement = fixture.debugElement.query(By.css('input')); + inputElement.triggerEventHandler('focus'); + UIInteractions.simulatePaste('1234567890', inputElement, 1, 1); + fixture.detectChanges(); + expect(inputElement.nativeElement.value).toEqual('(123) 4567-890'); + + const inputHTMLElement = inputElement.nativeElement as HTMLInputElement; + inputHTMLElement.setSelectionRange(6, 8); + fixture.detectChanges(); + expect(inputElement.nativeElement.selectionStart).toEqual(6); + expect(inputElement.nativeElement.selectionEnd).toEqual(8); + + UIInteractions.triggerEventHandlerKeyDown('Delete', inputElement); + inputElement.nativeElement.dispatchEvent(new Event('input')); + fixture.detectChanges(); + expect(inputElement.nativeElement.selectionStart).toEqual(8); + expect(inputElement.nativeElement.selectionEnd).toEqual(8); + expect(inputHTMLElement.value).toEqual('(123) __67-890'); + }); +}); + +describe('igxMaskDirective ControlValueAccessor Unit', () => { + let mask: IgxMaskDirective; + let renderer2: Renderer2; + it('Should correctly implement interface methods', () => { + const mockNgControl = jasmine.createSpyObj('NgControl', ['registerOnChangeCb', 'registerOnTouchedCb']); + const platformMock = { + isIE: false, + KEYMAP: { + BACKSPACE: 'Backspace', + DELETE: 'Delete', + Y: 'y', + Z: 'z' + } + }; + + const mockParser = jasmine.createSpyObj('MaskParsingService', { + applyMask: 'test____', + replaceInMask: { value: 'test_2__', end: 6 } as Replaced, + parseValueFromMask: 'test2' + }); + const format = 'CCCCCCCC'; + + // init + renderer2 = jasmine.createSpyObj('Renderer2', ['setAttribute']); + + TestBed.configureTestingModule({ + providers: [ + { provide: ElementRef, useValue: { nativeElement: {} } }, + { provide: MaskParsingService, useValue: mockParser }, + { provide: Renderer2, useValue: renderer2 }, + { provide: PlatformUtil, useValue: platformMock }, + IgxMaskDirective + ] + }); + + mask = TestBed.inject(IgxMaskDirective); + mask.mask = format; + mask.registerOnChange(mockNgControl.registerOnChangeCb); + mask.registerOnTouched(mockNgControl.registerOnTouchedCb); + spyOn(mask.valueChanged, 'emit'); + const inputGet = spyOnProperty(mask as any, 'inputValue', 'get'); + const inputSet = spyOnProperty(mask as any, 'inputValue', 'set'); + + // writeValue + inputGet.and.returnValue('formatted'); + mask.writeValue('test'); + expect(mockParser.applyMask).toHaveBeenCalledWith('test', jasmine.objectContaining({ format })); + expect(inputSet).toHaveBeenCalledWith('test____'); + expect(mockNgControl.registerOnChangeCb).not.toHaveBeenCalled(); + expect(mask.valueChanged.emit).toHaveBeenCalledWith({ rawValue: 'test', formattedValue: 'formatted' }); + + // OnChange callback + inputGet.and.returnValue('test_2___'); + spyOnProperty(mask as any, 'selectionEnd').and.returnValue(6); + const setSelectionSpy = spyOn(mask as any, 'setSelectionRange'); + mask.onInputChanged(false); + expect(mockParser.replaceInMask).toHaveBeenCalledWith('', 'test_2', jasmine.objectContaining({ format }), 0, 0); + expect(inputSet).toHaveBeenCalledWith('test_2__'); + expect(setSelectionSpy).toHaveBeenCalledWith(6); + expect(mockNgControl.registerOnChangeCb).toHaveBeenCalledWith('test2'); + + // OnTouched callback + mask.onFocus(); + expect(mockNgControl.registerOnTouchedCb).not.toHaveBeenCalled(); + const mockBlur = { target: { value: '' } }; + mask.onBlur(mockBlur as unknown as FocusEvent); + expect(mockNgControl.registerOnTouchedCb).toHaveBeenCalledTimes(1); + }); +}); + + +@Pipe({ name: 'inputFormat', standalone: true }) +export class InputFormatPipe implements PipeTransform { + public transform(value: any): string { + return value.toUpperCase(); + } +} + +@Pipe({ name: 'displayFormat', standalone: true }) +export class DisplayFormatPipe implements PipeTransform { + public transform(value: any): string { + return value.toLowerCase(); + } +} + + +@Component({ + template: ` + + + + `, + selector: 'igx-def-mask', + imports: [FormsModule, IgxInputGroupComponent, IgxInputDirective, IgxMaskDirective] +}) +class DefMaskComponent { + @ViewChild('input', { static: true }) + public input: ElementRef; + + @ViewChild(IgxMaskDirective) + public maskDirective: IgxMaskDirective; + + public mask; + public value; +} + +@Component({ + template: ` + + `, + selector: 'igx-mask-test', + imports: [FormsModule, IgxInputGroupComponent, IgxInputDirective, IgxMaskDirective] +}) +class MaskComponent { + + @ViewChild('input', { static: true }) + public input: ElementRef; + public mask = '(000) 0000-000'; + public value = '1234567890'; +} + +@Component({ + template: ` + + + + + `, + selector: 'igx-incl-literals', + imports: [FormsModule, IgxInputGroupComponent, IgxInputDirective, IgxMaskDirective] +}) +class IncludeLiteralsComponent { + @Input() public value = '55555'; + + @ViewChild('input', { static: true }) + public input: ElementRef; + + @ViewChild('input1', { static: true }) + public input1: ElementRef; + public mask = '(000) 0000-000'; +} + +@Component({ + template: ` + + `, + selector: 'igx-digit-space-mask', + imports: [FormsModule, IgxInputGroupComponent, IgxInputDirective, IgxMaskDirective] +}) +class DigitSpaceMaskComponent { + @ViewChild('input', { static: true }) + public input: ElementRef; + + public mask = '999999'; + public value = '555 555'; +} + +@Component({ + template: ` + + `, + selector: 'igx-digital-plus-minus-mask', + imports: [FormsModule, IgxInputGroupComponent, IgxInputDirective, IgxMaskDirective] +}) +class DigitPlusMinusMaskComponent { + @ViewChild('input', { static: true }) + public input: ElementRef; + + public mask = '####-### ## ## ##'; + public value = '+359884190854'; +} + +@Component({ + template: ` + + `, + selector: 'igx-letter-space-mask', + imports: [FormsModule, IgxInputGroupComponent, IgxInputDirective, IgxMaskDirective] +}) +class LetterSpaceMaskComponent { + @ViewChild('input', { static: true }) + public input: ElementRef; + + public mask = 'LL??LL??'; + public value = 'AB 2CD E'; +} + +@Component({ + template: ` + + `, + selector: 'igx-alphanum-space-mask', + imports: [FormsModule, IgxInputGroupComponent, IgxInputDirective, IgxMaskDirective] +}) +class AlphanumSpaceMaskComponent { + @ViewChild('input', { static: true }) + public input: ElementRef; + + public mask = 'AAAaaa'; + public value = '7c 8u'; +} + +@Component({ + template: ` + + + + `, + selector: 'igx-any-char-mask', + imports: [FormsModule, IgxInputGroupComponent, IgxInputDirective, IgxMaskDirective] +}) +class AnyCharMaskComponent { + @ViewChild('input', { static: true }) + public input: ElementRef; + + public mask = '&&&.CCC'; + public value = ' =% p]'; +} + +@Component({ + template: ` + + + + `, + imports: [FormsModule, IgxInputGroupComponent, IgxInputDirective, IgxMaskDirective] +}) +class EventFiringComponent { + + @ViewChild('input', { static: true }) + public input: ElementRef; + public myValue = ''; + public myMask = '(000) 0000-000'; + public raw: string; + public formatted: string; + + public handleValueChanged(event) { + this.raw = event.rawValue; + this.formatted = event.formattedValue; + } +} + +@Component({ + template: ` + + + + `, + imports: [FormsModule, IgxInputGroupComponent, IgxInputDirective, IgxMaskDirective] +}) +class OneWayBindComponent { + @ViewChild('input', { static: true }) + public input: ElementRef; + + public myMask = 'AAAAAAAA'; + public value = 3456; +} + +@Component({ + template: ` + + + + `, + imports: [FormsModule, IgxInputGroupComponent, IgxInputDirective, IgxMaskDirective] +}) +class PlaceholderMaskComponent { + @ViewChild('input', { static: true }) + public input: ElementRef; + + public mask = '(00) (00)'; + public value = null; +} + +@Component({ + template: ` + + + `, + imports: [FormsModule, IgxInputGroupComponent, IgxInputDirective, IgxMaskDirective] +}) +class PipesMaskComponent { + + @ViewChild('input', { static: true }) + public input: ElementRef; + + public mask = 'CCC'; + public value = 'SSS'; + + public displayFormat = new DisplayFormatPipe(); + public inputFormat = new InputFormatPipe(); +} + +@Component({ + template: ` + + + + `, + imports: [FormsModule, IgxInputGroupComponent, IgxInputDirective, IgxMaskDirective] +}) +class MaskTestComponent { + @ViewChild('input', { static: true }) + public input: ElementRef; +} + +@Component({ + template: ` + + + `, + imports: [FormsModule, IgxInputGroupComponent, IgxInputDirective, IgxMaskDirective] +}) +class ReadonlyMaskTestComponent { + @ViewChild(IgxMaskDirective) + public mask: IgxMaskDirective; +} diff --git a/projects/igniteui-angular/directives/src/directives/mask/mask.directive.ts b/projects/igniteui-angular/directives/src/directives/mask/mask.directive.ts new file mode 100644 index 00000000000..b92a9cb15d3 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/mask/mask.directive.ts @@ -0,0 +1,421 @@ +import { Directive, ElementRef, EventEmitter, HostListener, Output, PipeTransform, Renderer2, Input, OnInit, AfterViewChecked, booleanAttribute, inject } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { MaskParsingService, MaskOptions, parseMask } from './mask-parsing.service'; +import { IBaseEventArgs, PlatformUtil } from 'igniteui-angular/core'; +import { noop } from 'rxjs'; + +@Directive({ + providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: IgxMaskDirective, multi: true }], + selector: '[igxMask]', + exportAs: 'igxMask', + standalone: true +}) +export class IgxMaskDirective implements OnInit, AfterViewChecked, ControlValueAccessor { + protected elementRef = inject>(ElementRef); + protected maskParser = inject(MaskParsingService); + protected renderer = inject(Renderer2); + protected platform = inject(PlatformUtil); + + /** + * Sets the input mask. + * ```html + * + * ``` + */ + @Input('igxMask') + public get mask(): string { + return this._mask || this.defaultMask; + } + + public set mask(val: string) { + // B.P. 9th June 2021 #7490 + if (val !== this._mask) { + const cleanInputValue = this.maskParser.parseValueFromMask(this.inputValue, this.maskOptions); + this.setPlaceholder(val); + this._mask = val; + this.updateInputValue(cleanInputValue); + } + } + + /** + * Sets the character representing a fillable spot in the input mask. + * Default value is "'_'". + * ```html + * + * ``` + */ + @Input() + public promptChar = '_'; + + /** + * Specifies if the bound value includes the formatting symbols. + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public includeLiterals: boolean; + + /** + * Specifies a pipe to be used on blur. + * ```html + * + * ``` + */ + @Input() + public displayValuePipe: PipeTransform; + + /** + * Specifies a pipe to be used on focus. + * ```html + * + * ``` + */ + @Input() + public focusedValuePipe: PipeTransform; + + /** + * Emits an event each time the value changes. + * Provides `rawValue: string` and `formattedValue: string` as event arguments. + * ```html + * + * ``` + */ + @Output() + public valueChanged = new EventEmitter(); + + /** @hidden */ + public get nativeElement(): HTMLInputElement { + return this.elementRef.nativeElement; + } + + /** @hidden @internal; */ + protected get inputValue(): string { + return this.nativeElement.value; + } + + /** @hidden @internal */ + protected set inputValue(val: string) { + this.nativeElement.value = val; + } + + /** @hidden */ + protected get maskOptions(): MaskOptions { + const format = this.mask || this.defaultMask; + const promptChar = this.promptChar && this.promptChar.substring(0, 1); + return { format, promptChar }; + } + + /** @hidden */ + protected get selectionStart(): number { + // Edge(classic) and FF don't select text on drop + return this.nativeElement.selectionStart === this.nativeElement.selectionEnd && this._hasDropAction ? + this.nativeElement.selectionEnd - this._droppedData.length : + this.nativeElement.selectionStart; + } + + /** @hidden */ + protected get selectionEnd(): number { + return this.nativeElement.selectionEnd; + } + + /** @hidden */ + protected get start(): number { + return this._start; + } + + /** @hidden */ + protected get end(): number { + return this._end; + } + + protected _composing: boolean; + protected _compositionStartIndex: number; + protected _focused = false; + private _compositionValue: string; + private _end = 0; + private _start = 0; + private _key: string; + private _mask: string; + private _oldText = ''; + private _dataValue = ''; + private _droppedData: string; + private _hasDropAction: boolean; + + private readonly defaultMask = 'CCCCCCCCCC'; + + protected _onTouchedCallback: () => void = noop; + protected _onChangeCallback: (_: any) => void = noop; + + /** @hidden */ + @HostListener('keydown', ['$event']) + public onKeyDown(event: KeyboardEvent): void { + const key = event.key; + if (!key) { + return; + } + + if ((event.ctrlKey && (key === this.platform.KEYMAP.Z || key === this.platform.KEYMAP.Y))) { + event.preventDefault(); + } + + this._key = key; + this._start = this.selectionStart; + this._end = this.selectionEnd; + } + + /** @hidden @internal */ + @HostListener('compositionstart') + public onCompositionStart(): void { + if (!this._composing) { + this._compositionStartIndex = this._start; + this._composing = true; + } + } + + /** @hidden @internal */ + @HostListener('compositionend') + public onCompositionEnd(): void { + this._start = this._compositionStartIndex; + const end = this.selectionEnd; + const valueToParse = this.inputValue.substring(this._start, end); + this.updateInput(valueToParse); + this._end = this.selectionEnd; + this._compositionValue = this.inputValue; + } + + /** @hidden @internal */ + @HostListener('input', ['$event']) + public onInputChanged(event): void { + /** + * '!this._focused' is a fix for #8165 + * On page load IE triggers input events before focus events and + * it does so for every single input on the page. + * The mask needs to be prevented from doing anything while this is happening because + * the end user will be unable to blur the input. + * https://stackoverflow.com/questions/21406138/input-event-triggered-on-internet-explorer-when-placeholder-changed + */ + + if (this._composing) { + if (this.inputValue.length < this._oldText.length) { + // software keyboard input delete + this._key = this.platform.KEYMAP.BACKSPACE; + } + return; + } + + // After the compositionend event Chromium triggers input events of type 'deleteContentBackward' and + // we need to adjust the start and end indexes to include mask literals + if (event.inputType === 'deleteContentBackward' && this._key !== this.platform.KEYMAP.BACKSPACE) { + const isInputComplete = this._compositionStartIndex === 0 && this._end === this.mask.length; + let numberOfMaskLiterals = 0; + const literalPos = parseMask(this.maskOptions.format).literals.keys(); + for (const index of literalPos) { + if (index >= this._compositionStartIndex && index <= this._end) { + numberOfMaskLiterals++; + } + } + this.inputValue = isInputComplete ? + this.inputValue.substring(0, this.selectionEnd - numberOfMaskLiterals) + this.inputValue.substring(this.selectionEnd) + : this._compositionValue?.substring(0, this._compositionStartIndex) || this.inputValue; + + if (this._compositionValue) { + this._start = this.selectionStart; + this._end = this.selectionEnd; + this.nativeElement.selectionStart = isInputComplete ? this._start - numberOfMaskLiterals : this._compositionStartIndex; + this.nativeElement.selectionEnd = this._end - numberOfMaskLiterals; + this.nativeElement.selectionEnd = this._end; + this._start = this.selectionStart; + this._end = this.selectionEnd; + } + } + + if (this._hasDropAction) { + this._start = this.selectionStart; + } + + let valueToParse = ''; + switch (this._key) { + case this.platform.KEYMAP.DELETE: + this._end = this._start === this._end ? ++this._end : this._end; + break; + case this.platform.KEYMAP.BACKSPACE: + this._start = this.selectionStart; + break; + default: + valueToParse = this.inputValue.substring(this._start, this.selectionEnd); + break; + } + + this.updateInput(valueToParse); + } + + /** @hidden */ + @HostListener('paste') + public onPaste(): void { + this._oldText = this.inputValue; + this._start = this.selectionStart; + } + + /** @hidden */ + @HostListener('focus') + public onFocus(): void { + if (this.nativeElement.readOnly) { + return; + } + this._focused = true; + this.showMask(this.inputValue); + } + + /** @hidden */ + @HostListener('blur', ['$event']) + public onBlur(event: FocusEvent): void { + const value = event.target['value']; + this._focused = false; + this.showDisplayValue(value); + this._onTouchedCallback(); + } + + /** @hidden */ + @HostListener('dragenter') + public onDragEnter(): void { + if (!this._focused && !this._dataValue) { + this.showMask(this._dataValue); + } + } + + /** @hidden */ + @HostListener('dragleave') + public onDragLeave(): void { + if (!this._focused) { + this.showDisplayValue(this.inputValue); + } + } + + /** @hidden */ + @HostListener('drop', ['$event']) + public onDrop(event: DragEvent): void { + this._hasDropAction = true; + this._droppedData = event.dataTransfer.getData('text'); + } + + /** @hidden */ + public ngOnInit(): void { + this.setPlaceholder(this.maskOptions.format); + } + + /** + * TODO: Remove after date/time picker integration refactor + * + * @hidden + */ + public ngAfterViewChecked(): void { + if (this._composing) { + return; + } + this._oldText = this.inputValue; + } + + /** @hidden */ + public writeValue(value: string): void { + if (this.promptChar && this.promptChar.length > 1) { + this.maskOptions.promptChar = this.promptChar.substring(0, 1); + } + + this.inputValue = value ? this.maskParser.applyMask(value, this.maskOptions) : ''; + if (this.displayValuePipe) { + this.inputValue = this.displayValuePipe.transform(this.inputValue); + } + + this._dataValue = this.includeLiterals ? this.inputValue : value; + + this.valueChanged.emit({ rawValue: value, formattedValue: this.inputValue }); + } + + /** @hidden */ + public registerOnChange(fn: (_: any) => void): void { + this._onChangeCallback = fn; + } + + /** @hidden */ + public registerOnTouched(fn: () => void): void { + this._onTouchedCallback = fn; + } + + /** @hidden */ + protected showMask(value: string): void { + if (this.focusedValuePipe) { + // TODO(D.P.): focusedValuePipe should be deprecated or force-checked to match mask format + this.inputValue = this.focusedValuePipe.transform(value); + } else { + this.inputValue = this.maskParser.applyMask(value, this.maskOptions); + } + + this._oldText = this.inputValue; + } + + /** @hidden */ + protected setSelectionRange(start: number, end: number = start): void { + this.nativeElement.setSelectionRange(start, end); + } + + /** @hidden */ + protected afterInput(): void { + this._oldText = this.inputValue; + this._hasDropAction = false; + this._start = 0; + this._end = 0; + this._key = null; + this._composing = false; + } + + /** @hidden */ + protected setPlaceholder(value: string): void { + const placeholder = this.nativeElement.placeholder; + if (!placeholder || placeholder === this.mask) { + this.renderer.setAttribute(this.nativeElement, 'placeholder', parseMask(value ?? '').mask || this.defaultMask); + } + } + + private updateInputValue(value: string) { + if (this._focused) { + this.showMask(value); + } else if (!this.displayValuePipe) { + this.inputValue = this.inputValue ? this.maskParser.applyMask(value, this.maskOptions) : ''; + } + } + + private updateInput(valueToParse: string) { + const replacedData = this.maskParser.replaceInMask(this._oldText, valueToParse, this.maskOptions, this._start, this._end); + this.inputValue = replacedData.value; + if (this._key === this.platform.KEYMAP.BACKSPACE) { + replacedData.end = this._start; + } + + this.setSelectionRange(replacedData.end); + + const rawVal = this.maskParser.parseValueFromMask(this.inputValue, this.maskOptions); + this._dataValue = this.includeLiterals ? this.inputValue : rawVal; + this._onChangeCallback(this._dataValue); + + this.valueChanged.emit({ rawValue: rawVal, formattedValue: this.inputValue }); + this.afterInput(); + } + + private showDisplayValue(value: string) { + if (this.displayValuePipe) { + this.inputValue = this.displayValuePipe.transform(value); + } else if (value === this.maskParser.applyMask(null, this.maskOptions)) { + this.inputValue = ''; + } + } +} + +/** + * The IgxMaskModule provides the {@link IgxMaskDirective} inside your application. + */ +export interface IMaskEventArgs extends IBaseEventArgs { + rawValue: string; + formattedValue: string; +} + diff --git a/projects/igniteui-angular/directives/src/directives/mask/mask.module.ts b/projects/igniteui-angular/directives/src/directives/mask/mask.module.ts new file mode 100644 index 00000000000..321a3b91654 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/mask/mask.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { IgxMaskDirective } from './mask.directive'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [IgxMaskDirective], + exports: [IgxMaskDirective] +}) +export class IgxMaskModule { } diff --git a/projects/igniteui-angular/directives/src/directives/notification/notifications.directive.ts b/projects/igniteui-angular/directives/src/directives/notification/notifications.directive.ts new file mode 100644 index 00000000000..0df270be7f5 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/notification/notifications.directive.ts @@ -0,0 +1,108 @@ +import { Directive, ElementRef, HostBinding, Input, OnDestroy, booleanAttribute } from '@angular/core'; +import { IgxOverlayOutletDirective, IToggleView } from 'igniteui-angular/core'; +import { IPositionStrategy, OverlaySettings } from 'igniteui-angular/core'; +import { IgxToggleDirective } from '../toggle/toggle.directive'; + +@Directive() +export abstract class IgxNotificationsDirective extends IgxToggleDirective + implements IToggleView, OnDestroy { + /** + * Sets/gets the `aria-live` attribute. + * If not set, `aria-live` will have value `"polite"`. + */ + @HostBinding('attr.aria-live') + @Input() + public ariaLive = 'polite'; + + /** + * Sets/gets whether the element will be hidden after the `displayTime` is over. + * Default value is `true`. + */ + @Input({ transform: booleanAttribute }) + public autoHide = true; + + /** + * Sets/gets the duration of time span (in milliseconds) which the element will be visible + * after it is being shown. + * Default value is `4000`. + */ + @Input() + public displayTime = 4000; + + /** + * Gets/Sets the container used for the element. + * + * @remarks + * `outlet` is an instance of `IgxOverlayOutletDirective` or an `ElementRef`. + */ + @Input() + public outlet: IgxOverlayOutletDirective | ElementRef; + + /** + * Enables/Disables the visibility of the element. + * If not set, the `isVisible` attribute will have value `false`. + */ + @Input({ transform: booleanAttribute }) + public get isVisible() { + return !this.collapsed; + } + + public set isVisible(value) { + if (value !== this.isVisible) { + if (value) { + requestAnimationFrame(() => { + this.open(); + }); + } else { + this.close(); + } + } + } + + /** + * @hidden + * @internal + */ + public textMessage = ''; + + /** + * @hidden + */ + public timeoutId: number; + + /** + * @hidden + */ + protected strategy: IPositionStrategy; + + /** + * @hidden + */ + public override open() { + clearInterval(this.timeoutId); + + const overlaySettings: OverlaySettings = { + positionStrategy: this.strategy, + closeOnEscape: false, + closeOnOutsideClick: false, + modal: false, + outlet: this.outlet + }; + + super.open(overlaySettings); + + if (this.autoHide) { + this.timeoutId = window.setTimeout(() => { + this.close(); + }, this.displayTime); + } + } + + /** + * Hides the element. + */ + public override close() { + clearTimeout(this.timeoutId); + super.close(); + } +} diff --git a/projects/igniteui-angular/directives/src/directives/ripple/README.md b/projects/igniteui-angular/directives/src/directives/ripple/README.md new file mode 100644 index 00000000000..0a1f9c34591 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/ripple/README.md @@ -0,0 +1,47 @@ +# igxRipple + +**igxRipple** defines an area in which the ripple animates in response to a +user action. + +By default a ripple is activated when the host element of the `igxRipple` directive +receives a mouse or touch event. On `mousedown`/`touchstart` the ripple animation +starts. +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/ripple.html) + +# Usage +```html +Click me +``` + +# API Summary +| Name | Type | Description | +|:----------|:-------------:|:------| +| `igxRipple` | string | The color of the ripple animation | +| `igxRippleTarget` | string | Set the ripple to activate on a child element inside the parent of the `igxRipple`. Accepts a CSS selector. Defaults to the parent of the `igxRipple`. | +| `igxRippleCentered` | boolean | If true, the ripple animation originates from the center of the element rather than the location of the click event. | +| `igxRippleDuration` | number | The duration of the ripple animation. Defaults to 600 milliseconds. | +| `igxRippleDisabled` | boolean | Specify whether the ripple instance should be disabled. | + +# Examples + +Using `igxRippleTarget` to attach a ripple effect to a specific element inside a +more complex component. +```html + + + {{ item.text }} + + +``` + +Setting a centered ripple effect with custom color. +```html + + edit + +``` + +The `igxRipple` uses the Web Animation API and runs natively on +[browsers that support it.](http://caniuse.com/#feat=web-animation) +The `web-animations.min.js` polyfill is [available](https://github.com/web-animations/web-animations-js) +for other browsers. diff --git a/projects/igniteui-angular/directives/src/directives/ripple/ripple.directive.ts b/projects/igniteui-angular/directives/src/directives/ripple/ripple.directive.ts new file mode 100644 index 00000000000..d7e0dc61fd8 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/ripple/ripple.directive.ts @@ -0,0 +1,168 @@ +import { Directive, ElementRef, HostListener, Input, NgZone, Renderer2, booleanAttribute, inject } from '@angular/core'; +import { AnimationBuilder, style, animate } from '@angular/animations'; + +@Directive({ + selector: '[igxRipple]', + standalone: true +}) +export class IgxRippleDirective { + protected builder = inject(AnimationBuilder); + protected elementRef = inject(ElementRef); + protected renderer = inject(Renderer2); + private zone = inject(NgZone); + + /** + * Sets/gets the ripple target. + * ```html + *
    + * ``` + * ```typescript + * @ViewChild('rippleContainer', {read: IgxRippleDirective}) + * public ripple: IgxRippleDirective; + * let rippleTarget = this.ripple.rippleTarget; + * ``` + * Can set the ripple to activate on a child element inside the parent where igxRipple is defined. + * ```html + *
    + * + *
    + * ``` + * + * @memberof IgxRippleDirective + */ + @Input('igxRippleTarget') + public rippleTarget = ''; + /** + * Sets/gets the ripple color. + * ```html + * + * ``` + * ```typescript + * @ViewChild('rippleContainer', {read: IgxRippleDirective}) + * public ripple: IgxRippleDirective; + * let rippleColor = this.ripple.rippleColor; + * ``` + * + * @memberof IgxRippleDirective + */ + @Input('igxRipple') + public rippleColor: string; + /** + * Sets/gets the ripple duration(in milliseconds). + * Default value is `600`. + * ```html + * + * ``` + * ```typescript + * @ViewChild('rippleContainer', {read: IgxRippleDirective}) + * public ripple: IgxRippleDirective; + * let rippleDuration = this.ripple.rippleDuration; + * ``` + * + * @memberof IgxRippleDirective + */ + @Input('igxRippleDuration') + public rippleDuration = 600; + /** + * Enables/disables the ripple to be centered. + * ```html + * + * ``` + * + * @memberof IgxRippleDirective + */ + @Input({ alias: 'igxRippleCentered', transform: booleanAttribute }) + public set centered(value: boolean) { + this._centered = value || this.centered; + } + /** + * Sets/gets whether the ripple is disabled. + * Default value is `false`. + * ```html + * + * ``` + * ```typescript + * @ViewChild('rippleContainer', {read: IgxRippleDirective}) + * public ripple: IgxRippleDirective; + * let isRippleDisabled = this.ripple.rippleDisabled; + * ``` + * + * @memberof IgxRippleDirective + */ + @Input({ alias: 'igxRippleDisabled', transform: booleanAttribute }) + public rippleDisabled = false; + + protected get nativeElement(): HTMLElement { + return this.elementRef.nativeElement; + } + + private rippleElementClass = 'igx-ripple__inner'; + private rippleHostClass = 'igx-ripple'; + private _centered = false; + private animationQueue = []; + /** + * @hidden + */ + @HostListener('mousedown', ['$event']) + public onMouseDown(event) { + this.zone.runOutsideAngular(() => this._ripple(event)); + } + + private setStyles(rippleElement: HTMLElement, styleParams: any) { + this.renderer.addClass(rippleElement, this.rippleElementClass); + this.renderer.setStyle(rippleElement, 'width', `${styleParams.radius}px`); + this.renderer.setStyle(rippleElement, 'height', `${styleParams.radius}px`); + this.renderer.setStyle(rippleElement, 'top', `${styleParams.top}px`); + this.renderer.setStyle(rippleElement, 'left', `${styleParams.left}px`); + if (this.rippleColor) { + this.renderer.setStyle(rippleElement, 'background', this.rippleColor); + } + } + + private _ripple(event) { + if (this.rippleDisabled) { + return; + } + + const target = (this.rippleTarget ? this.nativeElement.querySelector(this.rippleTarget) || this.nativeElement : this.nativeElement); + + const rectBounds = target.getBoundingClientRect(); + const radius = Math.max(rectBounds.width, rectBounds.height); + let left = Math.round(event.clientX - rectBounds.left - radius / 2); + let top = Math.round(event.clientY - rectBounds.top - radius / 2); + + if (this._centered) { + left = top = 0; + } + + const dimensions = { + radius, + top, + left + }; + + const rippleElement = this.renderer.createElement('span'); + + this.setStyles(rippleElement, dimensions); + this.renderer.addClass(target, this.rippleHostClass); + this.renderer.appendChild(target, rippleElement); + + const animation = this.builder.build([ + style({ opacity: 0.5, transform: 'scale(.3)' }), + animate(this.rippleDuration, style({ opacity: 0, transform: 'scale(2)' })) + ]).create(rippleElement); + + this.animationQueue.push(animation); + + animation.onDone(() => { + this.animationQueue.splice(this.animationQueue.indexOf(animation), 1); + target.removeChild(rippleElement); + if (this.animationQueue.length < 1) { + this.renderer.removeClass(target, this.rippleHostClass); + } + }); + + animation.play(); + + } +} diff --git a/projects/igniteui-angular/directives/src/directives/ripple/ripple.module.ts b/projects/igniteui-angular/directives/src/directives/ripple/ripple.module.ts new file mode 100644 index 00000000000..1e74d02b266 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/ripple/ripple.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { IgxRippleDirective } from './ripple.directive'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [IgxRippleDirective], + exports: [IgxRippleDirective] +}) +export class IgxRippleModule { } diff --git a/projects/igniteui-angular/directives/src/directives/scroll-inertia/scroll_inertia.directive.spec.ts b/projects/igniteui-angular/directives/src/directives/scroll-inertia/scroll_inertia.directive.spec.ts new file mode 100644 index 00000000000..251e29ea7d8 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/scroll-inertia/scroll_inertia.directive.spec.ts @@ -0,0 +1,380 @@ +import { Component, Directive, OnInit, ViewChild, ElementRef } from '@angular/core'; +import { TestBed, ComponentFixture, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { IgxScrollInertiaDirective } from './scroll_inertia.directive'; + +import { wait } from '../../../../test-utils/ui-interactions.spec'; + +describe('Scroll Inertia Directive - Rendering', () => { + let fix: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + IgxTestScrollInertiaDirective, + ScrollInertiaComponent + ] + }).compileComponents(); + })); + + beforeEach(() => { + fix = TestBed.createComponent(ScrollInertiaComponent); + fix.detectChanges(); + }); + + afterEach(() => { + fix = null; + }); + + it('should initialize directive on non-scrollable container.', async () => { + expect(fix.componentInstance.scrInertiaDir).toBeDefined('scroll inertia initializing through markup failed'); + await fix.whenStable(); + }); + + // Unit tests for inertia function. + it('inertia should accelerate and then deccelerate vertically.', async () => { + pending('This should be tested in the e2e test'); + const scrInertiaDir = fix.componentInstance.scrInertiaDir; + + // vertical inertia + scrInertiaDir._inertiaInit(0, 1); + + await wait(1500); + const scrTopStepArray = fix.componentInstance.scrTopStepArray; + expect(scrTopStepArray.length).toEqual(57); + + const first = scrTopStepArray[0]; + const mid = scrTopStepArray[9]; + const end = scrTopStepArray[56]; + + expect(first).toBeLessThan(mid); + expect(end).toBeLessThan(mid); + }); + + it('inertia should accelerate and then deccelerate horizontally.', async () => { + pending('This should be tested in the e2e test'); + const scrInertiaDir = fix.componentInstance.scrInertiaDir; + + // horizontal inertia + scrInertiaDir._inertiaInit(1, 0); + + await wait(1500); + const scrLeftStepArray = fix.componentInstance.scrLeftStepArray; + expect(scrLeftStepArray.length).toEqual(57); + + const first = scrLeftStepArray[0]; + const mid = scrLeftStepArray[9]; + const end = scrLeftStepArray[56]; + + expect(first).toBeLessThan(mid); + expect(end).toBeLessThan(mid); + }); +}); + +describe('Scroll Inertia Directive - Scrolling', () => { + let scrollInertiaDir: IgxTestScrollInertiaDirective; + let scrollContainerMock; + + beforeEach(() => { + scrollContainerMock = { + scrollLeft: 0, + scrollTop: 0, + offsetHeight: 500, + children: [{ style: { width: '50px', height: '500px', scrollHeight: 100 } }] + }; + + TestBed.configureTestingModule({ + providers: [ + { provide: ElementRef, useValue: null }, + IgxTestScrollInertiaDirective + ] + }); + + scrollInertiaDir = TestBed.inject(IgxTestScrollInertiaDirective); + scrollInertiaDir.IgxScrollInertiaScrollContainer = scrollContainerMock; + scrollInertiaDir.smoothingDuration = 0; + }); + + afterEach(() => { + scrollInertiaDir.ngOnDestroy(); + }); + + // Unit test for wheel - wheelDelataY/wheelDeltaX supported on Chrome, Safari, Opera. + it('should change scroll top for related scrollbar if onWheel is executed with wheelDeltaY.', () => { + scrollInertiaDir.IgxScrollInertiaDirection = 'vertical'; + const evt = { wheelDeltaY: -240, preventDefault: () => { } }; + scrollInertiaDir.onWheel(evt); + expect(scrollContainerMock.scrollTop).toEqual(2 * scrollInertiaDir.wheelStep); + }); + + it('should change scroll left for related scrollbar if onWheel is executed with wheelDeltaX.', () => { + scrollInertiaDir.IgxScrollInertiaDirection = 'horizontal'; + const evt = { wheelDeltaX: -240, preventDefault: () => { } }; + scrollInertiaDir.onWheel(evt); + + expect(scrollContainerMock.scrollLeft).toEqual(2 * scrollInertiaDir.wheelStep); + }); + + // Unit tests for wheel on other browsers that don't provide wheelDelta - use deltaX and deltaY. + it('should change scroll top for related scrollbar if onWheel is executed with deltaY.', () => { + scrollInertiaDir.IgxScrollInertiaDirection = 'vertical'; + const evt = { deltaY: 1, preventDefault: () => { } }; + scrollInertiaDir.onWheel(evt); + expect(scrollContainerMock.scrollTop).toEqual(scrollInertiaDir.wheelStep); + }); + + it('should change scroll left for related scrollbar if onWheel is executed with deltaX.', () => { + scrollInertiaDir.IgxScrollInertiaDirection = 'horizontal'; + const evt = { deltaX: 1, preventDefault: () => { } }; + scrollInertiaDir.onWheel(evt); + + expect(scrollContainerMock.scrollLeft).toEqual(scrollInertiaDir.wheelStep); + }); + + it('should not throw error if there is no associated scrollbar and wheel event is called.', () => { + scrollInertiaDir.IgxScrollInertiaScrollContainer = null; + const evt = { preventDefault: () => { } }; + expect(() => scrollInertiaDir.onWheel(evt)).not.toThrow(); + }); + + + it('should change scroll left when shift + wheel is triggered', () => { + scrollInertiaDir.IgxScrollInertiaDirection = 'horizontal'; + const evt = { shiftKey: true, wheelDeltaY: -240, preventDefault: () => { } }; + scrollInertiaDir.onWheel(evt); + + expect(scrollContainerMock.scrollTop).toEqual(0); + expect(scrollContainerMock.scrollLeft).toEqual(2 * scrollInertiaDir.wheelStep); + }); + + it('should be able to scroll to left/right when shift + wheel is triggered', () => { + scrollInertiaDir.IgxScrollInertiaDirection = 'horizontal'; + let evt = { shiftKey: true, wheelDeltaY: -240, preventDefault: () => { } }; + scrollInertiaDir.onWheel(evt); + + expect(scrollContainerMock.scrollTop).toEqual(0); + expect(scrollContainerMock.scrollLeft).toEqual(2 * scrollInertiaDir.wheelStep); + + evt = { shiftKey: true, wheelDeltaY: 120, preventDefault: () => { } }; + scrollInertiaDir.onWheel(evt); + + expect(scrollContainerMock.scrollTop).toEqual(0); + expect(scrollContainerMock.scrollLeft).toEqual(scrollInertiaDir.wheelStep); + }); + + it('should change scroll left when shift + wheel is called with with deltaY', () => { + scrollInertiaDir.IgxScrollInertiaDirection = 'horizontal'; + const evt = { shiftKey: true, deltaY: 1, preventDefault: () => { } }; + scrollInertiaDir.onWheel(evt); + + expect(scrollContainerMock.scrollTop).toEqual(0); + expect(scrollContainerMock.scrollLeft).toEqual(scrollInertiaDir.wheelStep); + }); + + it('should be able to scroll to left/right when shift + wheel is called with with deltaY', () => { + scrollInertiaDir.IgxScrollInertiaDirection = 'horizontal'; + let evt = { shiftKey: true, deltaY: 1, preventDefault: () => { } }; + scrollInertiaDir.onWheel(evt); + + expect(scrollContainerMock.scrollTop).toEqual(0); + expect(scrollContainerMock.scrollLeft).toEqual(scrollInertiaDir.wheelStep); + + evt = { shiftKey: true, deltaY: -1, preventDefault: () => { } }; + scrollInertiaDir.onWheel(evt); + + expect(scrollContainerMock.scrollTop).toEqual(0); + expect(scrollContainerMock.scrollLeft).toEqual(0); + }); + + // Unit tests for touch events with inertia - Chrome, FireFox, Safari. + it('should change scroll top for related scrollbar on touch start/move/end', fakeAsync(() => { + let evt = { + touches: [{ + pageX: 0, + pageY: 0 + }], + preventDefault: () => { } + }; + scrollInertiaDir.onTouchStart(evt); + + evt = { + touches: [{ + pageX: 0, + pageY: -100 + }], + preventDefault: () => { } + }; + tick(10); + scrollInertiaDir.onTouchMove(evt); + + scrollInertiaDir.onTouchEnd(evt); + // wait for inertia to complete + tick(300); + expect(scrollContainerMock.scrollTop).toBeGreaterThan(3000); + })); + + it('should stop inertia if another touch event is initiated while inertia is executing.', fakeAsync(() => { + let evt = { + touches: [{ + pageX: 0, + pageY: 0 + }], + preventDefault: () => { } + }; + scrollInertiaDir.onTouchStart(evt); + + evt = { + touches: [{ + pageX: 0, + pageY: -100 + }], + preventDefault: () => { } + }; + tick(10); + scrollInertiaDir.onTouchMove(evt); + + scrollInertiaDir.onTouchEnd(evt); + tick(10); + + // don't wait for inertia to end. Instead start another touch interaction. + evt = { + touches: [{ + pageX: 0, + pageY: 0 + }], + preventDefault: () => { } + }; + scrollInertiaDir.onTouchStart(evt); + + expect(scrollContainerMock.scrollTop).toBeLessThan(1000); + })); + + it('should honor the defined swipeToleranceX.', fakeAsync(() => { + // if scroll is initiated on Y and on X within the defined tolerance no scrolling should occur on X. + let evt = { + touches: [{ + pageX: 0, + pageY: 0 + }], + preventDefault: () => { } + }; + scrollInertiaDir.onTouchStart(evt); + evt = { + touches: [{ + pageX: -10, + pageY: -50 + }], + preventDefault: () => { } + }; + tick(10); + scrollInertiaDir.onTouchMove(evt); + + scrollInertiaDir.onTouchEnd(evt); + + tick(300); + expect(scrollContainerMock.scrollLeft).toEqual(0); + expect(scrollContainerMock.scrollTop).toBeGreaterThan(100); + })); + + it('should change scroll left for related scrollbar on touch start/move/end', fakeAsync(() => { + let evt = { + touches: [{ + pageX: 0, + pageY: 0 + }], + preventDefault: () => { } + }; + scrollInertiaDir.onTouchStart(evt); + + evt = { + touches: [{ + pageX: -100, + pageY: 0 + }], + preventDefault: () => { } + }; + tick(10); + scrollInertiaDir.onTouchMove(evt); + + scrollInertiaDir.onTouchEnd(evt); + // wait for inertia to complete + tick(300); + expect(scrollContainerMock.scrollLeft).toBeGreaterThan(3000); + + })); + it('should not throw errors on touch start/move/end if no scrollbar is associated.', () => { + scrollInertiaDir.IgxScrollInertiaScrollContainer = null; + const evt = { preventDefault: () => { } }; + expect(() => scrollInertiaDir.onTouchStart(evt)).not.toThrow(); + expect(() => scrollInertiaDir.onTouchMove(evt)).not.toThrow(); + expect(() => scrollInertiaDir.onTouchEnd(evt)).not.toThrow(); + }); +}); + +/** igxScroll inertia for testing */ +@Directive({ + selector: '[igxTestScrollInertia]', + standalone: true +}) +export class IgxTestScrollInertiaDirective extends IgxScrollInertiaDirective { + + public override onWheel(evt) { + super.onWheel(evt); + } + + public override onTouchStart(evt) { + return super.onTouchStart(evt); + } + public override onTouchEnd(evt) { + super.onTouchEnd(evt); + } + public override onTouchMove(evt) { + return super.onTouchMove(evt); + } + + public override _inertiaInit(speedX, speedY) { + super._inertiaInit(speedX, speedY); + } +} + +/** igxScroll inertia component */ +@Component({ + template: ` +
    + +
    +
    +
    +
    + `, + imports: [IgxTestScrollInertiaDirective] +}) +export class ScrollInertiaComponent implements OnInit { + @ViewChild('container', { static: true }) public container: ElementRef; + @ViewChild('scrBar', { static: true }) public scrollContainer: ElementRef; + @ViewChild('scrInertiaContainer', { read: IgxTestScrollInertiaDirective, static: true }) + public scrInertiaDir: IgxTestScrollInertiaDirective; + + public height = '500px'; + public innerHeight = '5000px'; + public innerWidth = '5000px'; + public scrTopArray = []; + public scrTopStepArray = []; + public scrLeftArray = []; + public scrLeftStepArray = []; + + public ngOnInit() { + this.scrInertiaDir.IgxScrollInertiaScrollContainer = this.scrollContainer.nativeElement; + } + + public onScroll(evt) { + const ind = this.scrTopArray.length - 1; + const prevScrTop = ind < 0 ? 0 : this.scrTopArray[ind]; + const prevScrLeft = ind < 0 ? 0 : this.scrLeftArray[ind]; + this.scrTopArray.push(evt.target.scrollTop); + this.scrLeftArray.push(evt.target.scrollLeft); + const calcScrollStep = evt.target.scrollTop - prevScrTop; + const calcScrollLeftStep = evt.target.scrollLeft - prevScrLeft; + this.scrTopStepArray.push(calcScrollStep); + this.scrLeftStepArray.push(calcScrollLeftStep); + } +} diff --git a/projects/igniteui-angular/directives/src/directives/scroll-inertia/scroll_inertia.directive.ts b/projects/igniteui-angular/directives/src/directives/scroll-inertia/scroll_inertia.directive.ts new file mode 100644 index 00000000000..867f72f585a --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/scroll-inertia/scroll_inertia.directive.ts @@ -0,0 +1,465 @@ +import { Directive, Input, ElementRef, NgZone, OnInit, OnDestroy, inject } from '@angular/core'; + +/** + * @hidden + */ +@Directive({ + selector: '[igxScrollInertia]', + standalone: true +}) +export class IgxScrollInertiaDirective implements OnInit, OnDestroy { + private element = inject(ElementRef); + private _zone = inject(NgZone); + + + @Input() + public IgxScrollInertiaDirection: string; + + @Input() + public IgxScrollInertiaScrollContainer: any; + + @Input() + public wheelStep = 50; + + @Input() + public inertiaStep = 1.5; + + @Input() + public smoothingStep = 1.5; + + @Input() + public smoothingDuration = 0.5; + + @Input() + public swipeToleranceX = 20; + + @Input() + public inertiaDeltaY = 3; + + @Input() + public inertiaDeltaX = 2; + + @Input() + public inertiaDuration = 0.5; + + private _touchInertiaAnimID; + private _startX; + private _startY; + private _touchStartX; + private _touchStartY; + private _lastTouchEnd; + private _lastTouchX; + private _lastTouchY; + private _savedSpeedsX = []; + private _savedSpeedsY; + private _totalMovedX; + private _offsetRecorded; + private _offsetDirection; + private _lastMovedX; + private _lastMovedY; + private _nextX; + private _nextY; + private parentElement; + private baseDeltaMultiplier = 1 / 120; + private firefoxDeltaMultiplier = 1 / 30; + + public ngOnInit(): void { + this._zone.runOutsideAngular(() => { + this.parentElement = this.element.nativeElement.parentElement || this.element.nativeElement.parentNode; + if (!this.parentElement) { + return; + } + const targetElem = this.parentElement; + this.onWheel = this.onWheel.bind(this); + this.onTouchStart = this.onTouchStart.bind(this); + this.onTouchMove = this.onTouchMove.bind(this); + this.onTouchEnd = this.onTouchEnd.bind(this); + targetElem.addEventListener('wheel', this.onWheel, { passive: false }); + targetElem.addEventListener('touchstart', this.onTouchStart, { passive: false }); + targetElem.addEventListener('touchmove', this.onTouchMove, { passive: false }); + targetElem.addEventListener('touchend', this.onTouchEnd, { passive: false }); + }); + } + + public ngOnDestroy() { + this._zone.runOutsideAngular(() => { + const targetElem = this.parentElement; + if (!targetElem) { + return; + } + targetElem.removeEventListener('wheel', this.onWheel); + targetElem.removeEventListener('touchstart', this.onTouchStart); + targetElem.removeEventListener('touchmove', this.onTouchMove); + targetElem.removeEventListener('touchend', this.onTouchEnd); + }); + } + + /** + * @hidden + * Function that is called when scrolling with the mouse wheel or using touchpad + */ + protected onWheel(evt) { + // if no scrollbar return + if (!this.IgxScrollInertiaScrollContainer) { + return; + } + // if ctrl key is pressed and the user want to zoom in/out the page + if (evt.ctrlKey) { + return; + } + let scrollDeltaX; + let scrollDeltaY; + const scrollStep = this.wheelStep; + const minWheelStep = 1 / this.wheelStep; + const smoothing = this.smoothingDuration !== 0; + + this._startX = this.IgxScrollInertiaScrollContainer.scrollLeft; + this._startY = this.IgxScrollInertiaScrollContainer.scrollTop; + + if (evt.wheelDeltaX) { + /* Option supported on Chrome, Safari, Opera. + /* 120 is default for mousewheel on these browsers. Other values are for trackpads */ + scrollDeltaX = -evt.wheelDeltaX * this.baseDeltaMultiplier; + + if (-minWheelStep < scrollDeltaX && scrollDeltaX < minWheelStep) { + scrollDeltaX = Math.sign(scrollDeltaX) * minWheelStep; + } + } else if (evt.deltaX) { + /* For other browsers that don't provide wheelDelta, use the deltaY to determine direction and pass default values. */ + const deltaScaledX = evt.deltaX * (evt.deltaMode === 0 ? this.firefoxDeltaMultiplier : 1); + scrollDeltaX = this.calcAxisCoords(deltaScaledX, -1, 1); + } + + /** Get delta for the Y axis */ + if (evt.wheelDeltaY) { + /* Option supported on Chrome, Safari, Opera. + /* 120 is default for mousewheel on these browsers. Other values are for trackpads */ + scrollDeltaY = -evt.wheelDeltaY * this.baseDeltaMultiplier; + + if (-minWheelStep < scrollDeltaY && scrollDeltaY < minWheelStep) { + scrollDeltaY = Math.sign(scrollDeltaY) * minWheelStep; + } + } else if (evt.deltaY) { + /* For other browsers that don't provide wheelDelta, use the deltaY to determine direction and pass default values. */ + const deltaScaledY = evt.deltaY * (evt.deltaMode === 0 ? this.firefoxDeltaMultiplier : 1); + scrollDeltaY = this.calcAxisCoords(deltaScaledY, -1, 1); + } + + if (evt.composedPath && this.didChildScroll(evt, scrollDeltaX, scrollDeltaY)) { + return; + } + + if (scrollDeltaX && this.IgxScrollInertiaDirection === 'horizontal') { + const nextLeft = this._startX + scrollDeltaX * scrollStep; + if (!smoothing) { + this._scrollToX(nextLeft); + } else { + this._smoothWheelScroll(scrollDeltaX); + } + const maxScrollLeft = parseInt(this.IgxScrollInertiaScrollContainer.children[0].style.width, 10); + if (0 < nextLeft && nextLeft < maxScrollLeft) { + // Prevent navigating through pages when scrolling on Mac + evt.preventDefault(); + } + } else if (evt.shiftKey && scrollDeltaY && this.IgxScrollInertiaDirection === 'horizontal') { + if (!smoothing) { + const step = this._startX + scrollDeltaY * scrollStep; + this._scrollToX(step); + } else { + this._smoothWheelScroll(scrollDeltaY); + } + } else if (!evt.shiftKey && scrollDeltaY && this.IgxScrollInertiaDirection === 'vertical') { + const nextTop = this._startY + scrollDeltaY * scrollStep; + if (!smoothing) { + this._scrollToY(nextTop); + } else { + this._smoothWheelScroll(scrollDeltaY); + } + this.preventParentScroll(evt, true, nextTop); + } + } + + /** + * @hidden + * When there is still room to scroll up/down prevent the parent elements from scrolling too. + */ + protected preventParentScroll(evt, preventDefault, nextTop = 0) { + const curScrollTop = nextTop === 0 ? this.IgxScrollInertiaScrollContainer.scrollTop : nextTop; + const maxScrollTop = this.IgxScrollInertiaScrollContainer.children[0].scrollHeight - + this.IgxScrollInertiaScrollContainer.offsetHeight; + if (0 < curScrollTop && curScrollTop < maxScrollTop) { + if (preventDefault) { + evt.preventDefault(); + } + if (evt.stopPropagation) { + evt.stopPropagation(); + } + } + } + + /** + * @hidden + * Checks if the wheel event would have scrolled an element under the display container + * in DOM tree so that it can correctly be ignored until that element can no longer be scrolled. + */ + protected didChildScroll(evt, scrollDeltaX, scrollDeltaY): boolean { + const path = evt.composedPath(); + let i = 0; + while (i < path.length && path[i].localName !== 'igx-display-container') { + const e = path[i++]; + if (e.scrollHeight > e.clientHeight) { + const overflowY = window.getComputedStyle(e)['overflow-y']; + if (overflowY === 'auto' || overflowY === 'scroll') { + if (scrollDeltaY > 0 && e.scrollHeight - Math.abs(Math.round(e.scrollTop)) !== e.clientHeight) { + return true; + } + if (scrollDeltaY < 0 && e.scrollTop !== 0) { + return true; + } + } + } + if (e.scrollWidth > e.clientWidth) { + const overflowX = window.getComputedStyle(e)['overflow-x']; + if (overflowX === 'auto' || overflowX === 'scroll') { + if (scrollDeltaX > 0 && e.scrollWidth - Math.abs(Math.round(e.scrollLeft)) !== e.clientWidth) { + return true; + } + if (scrollDeltaX < 0 && e.scrollLeft !== 0) { + return true; + } + } + } + } + return false; + } + + /** + * @hidden + * Function that is called the first moment we start interacting with the content on a touch device + */ + protected onTouchStart(event) { + if (!this.IgxScrollInertiaScrollContainer) { + return false; + } + + // stops any current ongoing inertia + cancelAnimationFrame(this._touchInertiaAnimID); + + const touch = event.touches[0]; + + this._startX = this.IgxScrollInertiaScrollContainer.scrollLeft; + + this._startY = this.IgxScrollInertiaScrollContainer.scrollTop; + + this._touchStartX = touch.pageX; + this._touchStartY = touch.pageY; + + this._lastTouchEnd = new Date().getTime(); + this._lastTouchX = touch.pageX; + this._lastTouchY = touch.pageY; + this._savedSpeedsX = []; + this._savedSpeedsY = []; + + // Vars regarding swipe offset + this._totalMovedX = 0; + this._offsetRecorded = false; + this._offsetDirection = 0; + + if (this.IgxScrollInertiaDirection === 'vertical') { + this.preventParentScroll(event, false); + } + } + + /** + * @hidden + * Function that is called when we need to scroll the content based on touch interactions + */ + protected onTouchMove(event) { + if (!this.IgxScrollInertiaScrollContainer) { + return; + } + + const touch = event.touches[0]; + const destX = this._startX + (this._touchStartX - touch.pageX) * Math.sign(this.inertiaStep); + const destY = this._startY + (this._touchStartY - touch.pageY) * Math.sign(this.inertiaStep); + + /* Handle complex touchmoves when swipe stops but the toch doesn't end and then a swipe is initiated again */ + /* **********************************************************/ + + + const timeFromLastTouch = (new Date().getTime()) - this._lastTouchEnd; + if (timeFromLastTouch !== 0 && timeFromLastTouch < 100) { + const speedX = (this._lastTouchX - touch.pageX) / timeFromLastTouch; + const speedY = (this._lastTouchY - touch.pageY) / timeFromLastTouch; + + // Save the last 5 speeds between two touchmoves on X axis + if (this._savedSpeedsX.length < 5) { + this._savedSpeedsX.push(speedX); + } else { + this._savedSpeedsX.shift(); + this._savedSpeedsX.push(speedX); + } + + // Save the last 5 speeds between two touchmoves on Y axis + if (this._savedSpeedsY.length < 5) { + this._savedSpeedsY.push(speedY); + } else { + this._savedSpeedsY.shift(); + this._savedSpeedsY.push(speedY); + } + } + this._lastTouchEnd = new Date().getTime(); + this._lastMovedX = this._lastTouchX - touch.pageX; + this._lastMovedY = this._lastTouchY - touch.pageY; + this._lastTouchX = touch.pageX; + this._lastTouchY = touch.pageY; + + this._totalMovedX += this._lastMovedX; + + /* Do not scroll using touch untill out of the swipeToleranceX bounds */ + if (Math.abs(this._totalMovedX) < this.swipeToleranceX && !this._offsetRecorded) { + this._scrollTo(this._startX, destY); + } else { + /* Record the direction the first time we are out of the swipeToleranceX bounds. + * That way we know which direction we apply the offset so it doesn't hickup when moving out of the swipeToleranceX bounds */ + if (!this._offsetRecorded) { + this._offsetDirection = Math.sign(destX - this._startX); + this._offsetRecorded = true; + } + + /* Scroll with offset ammout of swipeToleranceX in the direction we have exited the bounds and + don't change it after that ever until touchend and again touchstart */ + this._scrollTo(destX - this._offsetDirection * this.swipeToleranceX, destY); + } + + // On Safari preventing the touchmove would prevent default page scroll behaviour even if there is the element doesn't have overflow + if (this.IgxScrollInertiaDirection === 'vertical') { + this.preventParentScroll(event, true); + } + } + + protected onTouchEnd(event) { + let speedX = 0; + let speedY = 0; + + // savedSpeedsX and savedSpeedsY have same length + for (let i = 0; i < this._savedSpeedsX.length; i++) { + speedX += this._savedSpeedsX[i]; + speedY += this._savedSpeedsY[i]; + } + speedX = this._savedSpeedsX.length ? speedX / this._savedSpeedsX.length : 0; + speedY = this._savedSpeedsX.length ? speedY / this._savedSpeedsY.length : 0; + + // Use the lastMovedX and lastMovedY to determine if the swipe stops without lifting the finger so we don't start inertia + if ((Math.abs(speedX) > 0.1 || Math.abs(speedY) > 0.1) && + (Math.abs(this._lastMovedX) > 2 || Math.abs(this._lastMovedY) > 2)) { + this._inertiaInit(speedX, speedY); + } + if (this.IgxScrollInertiaDirection === 'vertical') { + this.preventParentScroll(event, false); + } + } + + protected _smoothWheelScroll(delta) { + this._nextY = this.IgxScrollInertiaScrollContainer.scrollTop; + this._nextX = this.IgxScrollInertiaScrollContainer.scrollLeft; + let x = -1; + let wheelInertialAnimation = null; + const inertiaWheelStep = () => { + if (x > 1) { + cancelAnimationFrame(wheelInertialAnimation); + return; + } + const nextScroll = ((-3 * x * x + 3) * delta * 2) * this.smoothingStep; + if (this.IgxScrollInertiaDirection === 'vertical') { + this._nextY += nextScroll; + this._scrollToY(this._nextY); + } else { + this._nextX += nextScroll; + this._scrollToX(this._nextX); + } + //continue the inertia + x += 0.08 * (1 / this.smoothingDuration); + wheelInertialAnimation = requestAnimationFrame(inertiaWheelStep); + }; + wheelInertialAnimation = requestAnimationFrame(inertiaWheelStep); + } + + protected _inertiaInit(speedX, speedY) { + const stepModifer = this.inertiaStep; + const inertiaDuration = this.inertiaDuration; + let x = 0; + this._nextX = this.IgxScrollInertiaScrollContainer.scrollLeft; + this._nextY = this.IgxScrollInertiaScrollContainer.scrollTop; + + // Sets timeout until executing next movement iteration of the inertia + const inertiaStep = () => { + if (x > 6) { + cancelAnimationFrame(this._touchInertiaAnimID); + return; + } + + if (Math.abs(speedX) > Math.abs(speedY)) { + x += 0.05 / (1 * inertiaDuration); + } else { + x += 0.05 / (1 * inertiaDuration); + } + + if (x <= 1) { + // We use constant quation to determine the offset without speed falloff befor x reaches 1 + if (Math.abs(speedY) <= Math.abs(speedX) * this.inertiaDeltaY) { + this._nextX += 1 * speedX * 15 * stepModifer; + } + if (Math.abs(speedY) >= Math.abs(speedX) * this.inertiaDeltaX) { + this._nextY += 1 * speedY * 15 * stepModifer; + } + } else { + // We use the quation "y = 2 / (x + 0.55) - 0.3" to determine the offset + if (Math.abs(speedY) <= Math.abs(speedX) * this.inertiaDeltaY) { + this._nextX += Math.abs(2 / (x + 0.55) - 0.3) * speedX * 15 * stepModifer; + } + if (Math.abs(speedY) >= Math.abs(speedX) * this.inertiaDeltaX) { + this._nextY += Math.abs(2 / (x + 0.55) - 0.3) * speedY * 15 * stepModifer; + } + } + + // If we have mixed environment we use the default behaviour. i.e. touchscreen + mouse + this._scrollTo(this._nextX, this._nextY); + + this._touchInertiaAnimID = requestAnimationFrame(inertiaStep); + }; + + // Start inertia and continue it recursively + this._touchInertiaAnimID = requestAnimationFrame(inertiaStep); + } + + private calcAxisCoords(target, min, max) { + if (target === undefined || target < min) { + target = min; + } else if (target > max) { + target = max; + } + + return target; + } + + private _scrollTo(destX, destY) { + // TODO Trigger scrolling event? + const scrolledX = this._scrollToX(destX); + const scrolledY = this._scrollToY(destY); + + return { x: scrolledX, y: scrolledY }; + } + private _scrollToX(dest) { + this.IgxScrollInertiaScrollContainer.scrollLeft = dest; + } + private _scrollToY(dest) { + this.IgxScrollInertiaScrollContainer.scrollTop = dest; + } +} + +/** + * @hidden + */ + + diff --git a/projects/igniteui-angular/directives/src/directives/scroll-inertia/scroll_inertia.module.ts b/projects/igniteui-angular/directives/src/directives/scroll-inertia/scroll_inertia.module.ts new file mode 100644 index 00000000000..4e9772fcb60 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/scroll-inertia/scroll_inertia.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from '@angular/core'; +import { IgxScrollInertiaDirective } from './scroll_inertia.directive'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [IgxScrollInertiaDirective], + exports: [IgxScrollInertiaDirective] +}) +export class IgxScrollInertiaModule { +} diff --git a/projects/igniteui-angular/directives/src/directives/size/ig-size.directive.spec.ts b/projects/igniteui-angular/directives/src/directives/size/ig-size.directive.spec.ts new file mode 100644 index 00000000000..f40061fac5e --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/size/ig-size.directive.spec.ts @@ -0,0 +1,45 @@ +import { Component } from '@angular/core'; +import { IgSizeDirective } from './ig-size.directive'; +import { TestBed, ComponentFixture } from '@angular/core/testing'; + +@Component({ + template: `
    Test Element
    `, + imports: [IgSizeDirective], +}) +class TestComponent { + public size: 'small' | 'medium' | 'large'; +} + +describe('IgSizeDirective', () => { + let fixture: ComponentFixture; + let testComponent: TestComponent; + let divElement: HTMLElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TestComponent], + }); + + fixture = TestBed.createComponent(TestComponent); + testComponent = fixture.componentInstance; + divElement = fixture.nativeElement.querySelector('div'); + fixture.detectChanges(); + }); + + it('should apply the correct --ig-size inline style for each size', () => { + const sizeMap = { + small: 'var(--ig-size-small)', + medium: 'var(--ig-size-medium)', + large: 'var(--ig-size-large)', + }; + + // Loop through each size and verify the inline style + for (const size in sizeMap) { + testComponent.size = size as 'small' | 'medium' | 'large'; + fixture.detectChanges(); + + // Check if the --ig-size style is applied correctly + expect(divElement.style.getPropertyValue('--ig-size')).toBe(sizeMap[size]); + } + }); +}); diff --git a/projects/igniteui-angular/directives/src/directives/size/ig-size.directive.ts b/projects/igniteui-angular/directives/src/directives/size/ig-size.directive.ts new file mode 100644 index 00000000000..a0018248334 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/size/ig-size.directive.ts @@ -0,0 +1,18 @@ +import { Directive, HostBinding, Input } from '@angular/core'; + +@Directive({ + selector: '[igSize]', +}) +export class IgSizeDirective { + private _size: string; + + @Input() + @HostBinding('style.--ig-size') + public get igSize(): string { + return this._size; + } + + public set igSize(value: 'small' | 'medium' | 'large') { + this._size = `var(--ig-size-${value})`; + } +} diff --git a/projects/igniteui-angular/directives/src/directives/template-outlet/template_outlet.directive.ts b/projects/igniteui-angular/directives/src/directives/template-outlet/template_outlet.directive.ts new file mode 100644 index 00000000000..7c300dc67dd --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/template-outlet/template_outlet.directive.ts @@ -0,0 +1,219 @@ +import { Directive, EmbeddedViewRef, Input, OnChanges, ChangeDetectorRef, SimpleChange, SimpleChanges, TemplateRef, ViewContainerRef, NgZone, Output, EventEmitter, inject } from '@angular/core'; + +import { IBaseEventArgs } from 'igniteui-angular/core'; + +/** + * @hidden + */ +@Directive({ + selector: '[igxTemplateOutlet]', + standalone: true +}) +export class IgxTemplateOutletDirective implements OnChanges { + public _viewContainerRef = inject(ViewContainerRef); + private _zone = inject(NgZone); + public cdr = inject(ChangeDetectorRef); + + @Input() public igxTemplateOutletContext !: any; + + @Input() public igxTemplateOutlet !: TemplateRef; + + @Output() + public viewCreated = new EventEmitter(); + + @Output() + public viewMoved = new EventEmitter(); + + @Output() + public cachedViewLoaded = new EventEmitter(); + + @Output() + public beforeViewDetach = new EventEmitter(); + + private _viewRef !: EmbeddedViewRef; + + /** + * The embedded views cache. Collection is key-value paired. + * Key is the template type, value is another key-value paired collection + * where the key is the template id and value is the embedded view for the related template. + */ + private _embeddedViewsMap: Map>> = new Map(); + + public ngOnChanges(changes: SimpleChanges) { + const actionType: TemplateOutletAction = this._getActionType(changes); + switch (actionType) { + case TemplateOutletAction.CreateView: this._recreateView(); break; + case TemplateOutletAction.MoveView: this._moveView(); break; + case TemplateOutletAction.UseCachedView: this._useCachedView(); break; + case TemplateOutletAction.UpdateViewContext: this._updateExistingContext(this.igxTemplateOutletContext); break; + } + } + + public cleanCache() { + this._embeddedViewsMap.forEach((collection) => { + collection.forEach((item => { + if (!item.destroyed) { + item.destroy(); + } + })); + collection.clear(); + }); + this._embeddedViewsMap.clear(); + } + + public cleanView(tmplID) { + const embViewCollection = this._embeddedViewsMap.get(tmplID.type); + const embView = embViewCollection?.get(tmplID.id); + if (embView) { + embView.destroy(); + this._embeddedViewsMap.get(tmplID.type).delete(tmplID.id); + } + } + + private _recreateView() { + const prevIndex = this._viewRef ? this._viewContainerRef.indexOf(this._viewRef) : -1; + // detach old and create new + if (prevIndex !== -1) { + this.beforeViewDetach.emit({ owner: this, view: this._viewRef, context: this.igxTemplateOutletContext }); + this._viewContainerRef.detach(prevIndex); + } + if (this.igxTemplateOutlet) { + this._viewRef = this._viewContainerRef.createEmbeddedView( + this.igxTemplateOutlet, this.igxTemplateOutletContext); + this.viewCreated.emit({ owner: this, view: this._viewRef, context: this.igxTemplateOutletContext }); + const tmplId = this.igxTemplateOutletContext['templateID']; + if (tmplId) { + // if context contains a template id, check if we have a view for that template already stored in the cache + // if not create a copy and add it to the cache in detached state. + // Note: Views in detached state do not appear in the DOM, however they remain stored in memory. + const resCollection = this._embeddedViewsMap.get(this.igxTemplateOutletContext['templateID'].type); + const res = resCollection?.get(this.igxTemplateOutletContext['templateID'].id); + if (!res) { + this._embeddedViewsMap.set(this.igxTemplateOutletContext['templateID'].type, + new Map([[this.igxTemplateOutletContext['templateID'].id, this._viewRef]])); + } + } + } + } + + private _moveView() { + // using external view and inserting it in current view. + const view = this.igxTemplateOutletContext['moveView']; + const owner = this.igxTemplateOutletContext['owner']; + if (view !== this._viewRef) { + if (owner._viewContainerRef.indexOf(view) !== -1) { + // detach in case view it is attached somewhere else at the moment. + this.beforeViewDetach.emit({ owner: this, view: this._viewRef, context: this.igxTemplateOutletContext }); + owner._viewContainerRef.detach(owner._viewContainerRef.indexOf(view)); + } + if (this._viewRef && this._viewContainerRef.indexOf(this._viewRef) !== -1) { + this.beforeViewDetach.emit({ owner: this, view: this._viewRef, context: this.igxTemplateOutletContext }); + this._viewContainerRef.detach(this._viewContainerRef.indexOf(this._viewRef)); + } + this._viewRef = view; + this._viewContainerRef.insert(view, 0); + this._updateExistingContext(this.igxTemplateOutletContext); + this.viewMoved.emit({ owner: this, view: this._viewRef, context: this.igxTemplateOutletContext }); + } else { + this._updateExistingContext(this.igxTemplateOutletContext); + } + } + private _useCachedView() { + // use view for specific template cached in the current template outlet + const tmplID = this.igxTemplateOutletContext['templateID']; + const cachedView = tmplID ? + this._embeddedViewsMap.get(tmplID.type)?.get(tmplID.id) : + null; + // if view exists, but template has been changed and there is a view in the cache with the related template + // then detach old view and insert the stored one with the matching template + // after that update its context. + if (this._viewContainerRef.length > 0) { + this.beforeViewDetach.emit({ owner: this, view: this._viewRef, context: this.igxTemplateOutletContext }); + this._viewContainerRef.detach(this._viewContainerRef.indexOf(this._viewRef)); + } + + this._viewRef = cachedView; + const oldContext = this._cloneContext(cachedView.context); + this._viewContainerRef.insert(this._viewRef, 0); + this._updateExistingContext(this.igxTemplateOutletContext); + this.cachedViewLoaded.emit({ owner: this, view: this._viewRef, context: this.igxTemplateOutletContext, oldContext }); + } + + private _shouldRecreateView(changes: SimpleChanges): boolean { + const ctxChange = changes['igxTemplateOutletContext']; + return !!changes['igxTemplateOutlet'] || (ctxChange && this._hasContextShapeChanged(ctxChange)); + } + + private _hasContextShapeChanged(ctxChange: SimpleChange): boolean { + const prevCtxKeys = Object.keys(ctxChange.previousValue || {}); + const currCtxKeys = Object.keys(ctxChange.currentValue || {}); + + if (prevCtxKeys.length === currCtxKeys.length) { + for (const propName of currCtxKeys) { + if (prevCtxKeys.indexOf(propName) === -1) { + return true; + } + } + return false; + } else { + return true; + } + } + + private _updateExistingContext(ctx: any): void { + for (const propName of Object.keys(ctx)) { + this._viewRef.context[propName] = this.igxTemplateOutletContext[propName]; + } + } + + private _cloneContext(ctx: any): any { + const clone = {}; + for (const propName of Object.keys(ctx)) { + clone[propName] = ctx[propName]; + } + return clone; + } + + private _getActionType(changes: SimpleChanges) { + const movedView = this.igxTemplateOutletContext['moveView']; + const tmplID = this.igxTemplateOutletContext['templateID']; + const cachedView = tmplID ? + this._embeddedViewsMap.get(tmplID.type)?.get(tmplID.id) : + null; + const shouldRecreate = this._shouldRecreateView(changes); + if (movedView) { + // view is moved from external source + return TemplateOutletAction.MoveView; + } else if (shouldRecreate && cachedView) { + // should recreate (template or context change) and there is a matching template in cache + return TemplateOutletAction.UseCachedView; + } else if (!this._viewRef || shouldRecreate) { + // no view or should recreate + return TemplateOutletAction.CreateView; + } else if (this.igxTemplateOutletContext) { + // has context, update context + return TemplateOutletAction.UpdateViewContext; + } + } +} +enum TemplateOutletAction { + CreateView, + MoveView, + UseCachedView, + UpdateViewContext +} + +export interface IViewChangeEventArgs extends IBaseEventArgs { + owner: IgxTemplateOutletDirective; + view: EmbeddedViewRef; + context: any; +} + +export interface ICachedViewLoadedEventArgs extends IViewChangeEventArgs { + oldContext: any; +} + +/** + * @hidden + */ + diff --git a/projects/igniteui-angular/directives/src/directives/text-highlight/README.md b/projects/igniteui-angular/directives/src/directives/text-highlight/README.md new file mode 100644 index 00000000000..402472e3f31 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/text-highlight/README.md @@ -0,0 +1,37 @@ +# IgxTextHighlightDirective + +#### Category +_Directives_ + +## Description + +Provides a way to highlight text elements. + +## Usage + +```html +
    + {{html}} +
    +``` + +## API + +### Inputs +| Name | Type | Description | +| :--- |:--- | :--- | +| cssClass| string | Determines the CSS class of the highlight elements, allowing the developer to provide custom CSS to customize the highlight. | +| activeCssClass | string | Determines the CSS class of the active highlight element, allowing the developer to provide custom CSS to customize the active highlight. +| groupName | string | Identifies the highlight within a unique group, allowing to have several different highlight groups each having their own active highlight. +| value | any | The underlying value of the element that will be highlighted | +| row | number | The index of the row on which the directive is currently on | +| column | number | The index of the column on which the directive is currently on | +| page | number | The index of the page on which the directive is currently on (used when the component containing the directive supports paging) | + +### Methods +| Name | Type | Arguments | Description | +| :--- |:--- | :--- | :--- | +| highlight | number | The text that should be highlighted and, optionally, if the search should be case sensitive and/or an exact match (both default to false if they aren't specified). | Clears the existing highlight and highlight the searched text. Returns how many times the element contains the searched text. | +| clearHighlight | void | N/A | Clears any existing highlight | +| activateIfNecessary | void | N/A | Activates the highlight if it is on the currently active row, column and page | +| setActiveHighlight (static)| void| The highlight group, the column, row and page of the directive and the index of the highlight | Activates the highlight at a given index (if such highlight exists) | diff --git a/projects/igniteui-angular/directives/src/directives/text-highlight/text-highlight.directive.spec.ts b/projects/igniteui-angular/directives/src/directives/text-highlight/text-highlight.directive.spec.ts new file mode 100644 index 00000000000..98e10d64095 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/text-highlight/text-highlight.directive.spec.ts @@ -0,0 +1,353 @@ +import { Component, ViewChild, inject } from '@angular/core'; +import { TestBed, waitForAsync } from '@angular/core/testing'; + +import { IgxTextHighlightDirective, IActiveHighlightInfo} from './text-highlight.directive'; + +import { IgxTextHighlightService } from './text-highlight.service'; + +describe('IgxHighlight', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + HighlightLoremIpsumComponent + ] + }); + })); + + it('Highlight inputs should have the proper values', () => { + const fix = TestBed.createComponent(HighlightLoremIpsumComponent); + fix.detectChanges(); + + const component: HighlightLoremIpsumComponent = fix.debugElement.componentInstance; + + expect(component.highlight.cssClass).toBe('igx-highlight'); + expect(component.highlight.activeCssClass).toBe('igx-highlight__active'); + expect(component.highlight.groupName).toBe('test'); + expect(component.highlight.value).toBe(component.html); + expect(component.highlight.row).toBe(0); + expect(component.highlight.column).toBe(0); + expect(component.highlight.containerClass).toBe('test'); + }); + + it('Should highlight all instances of text', () => { + const fix = TestBed.createComponent(HighlightLoremIpsumComponent); + fix.detectChanges(); + + const component: HighlightLoremIpsumComponent = fix.debugElement.componentInstance; + let count = component.highlightText('a'); + + let spans = fix.debugElement.nativeElement.querySelectorAll('.' + component.highlightClass); + expect(spans.length).toBe(4); + expect(count).toBe(4); + + count = component.highlightText('AM'); + + spans = fix.debugElement.nativeElement.querySelectorAll('.' + component.highlightClass); + expect(spans.length).toBe(1); + expect(count).toBe(1); + + count = component.highlightText('amxsxd'); + + spans = fix.debugElement.nativeElement.querySelectorAll('.' + component.highlightClass); + expect(spans.length).toBe(0); + expect(count).toBe(0); + }); + + it('Should highlight all instances of text case sensitive', () => { + const fix = TestBed.createComponent(HighlightLoremIpsumComponent); + fix.detectChanges(); + + const component: HighlightLoremIpsumComponent = fix.debugElement.componentInstance; + let count = component.highlightText('Lorem', true); + + let spans = fix.debugElement.nativeElement.querySelectorAll('.' + component.highlightClass); + expect(spans.length).toBe(1); + expect(count).toBe(1); + + count = component.highlightText('quisque', true); + + spans = fix.debugElement.nativeElement.querySelectorAll('.' + component.highlightClass); + expect(spans.length).toBe(0); + expect(count).toBe(0); + }); + + it('Should not highlight anything when there is no exact match, regardless of case sensitivity.', () => { + const fix = TestBed.createComponent(HighlightLoremIpsumComponent); + fix.detectChanges(); + + const component: HighlightLoremIpsumComponent = fix.debugElement.componentInstance; + let count = component.highlightText('Lorem', false, true); + + let spans = fix.debugElement.nativeElement.querySelectorAll('.' + component.highlightClass); + expect(spans.length).toBe(0); + expect(count).toBe(0); + + count = component.highlightText('Lorem', false, false); + + spans = fix.debugElement.nativeElement.querySelectorAll('.' + component.highlightClass); + expect(spans.length).toBe(2); + expect(count).toBe(2); + + count = component.highlightText('Lorem', true, true); + + spans = fix.debugElement.nativeElement.querySelectorAll('.' + component.highlightClass); + expect(spans.length).toBe(0); + expect(count).toBe(0); + }); + + it('Should not highlight with exact match when the group text has changed.', () => { + const fix = TestBed.createComponent(HighlightLoremIpsumComponent); + fix.detectChanges(); + + const component: HighlightLoremIpsumComponent = fix.debugElement.componentInstance; + const count = component.highlightText( + 'LoReM ipsuM dolor sit AMET, consectetur adipiscing elit. Vestibulum vulputate LucTUS dui ut maximus.' + + ' Quisque sed suscipit lorem. Vestibulum sit.', + false, true); + + let spans = fix.debugElement.nativeElement.querySelectorAll('.' + component.highlightClass); + expect(spans.length).toBe(1); + expect(count).toBe(1); + + component.html += ' additionalText'; + fix.detectChanges(); + spans = fix.debugElement.nativeElement.querySelectorAll('.' + component.highlightClass); + expect(spans.length).toBe(0); + }); + + it('Should clear all highlights', () => { + const fix = TestBed.createComponent(HighlightLoremIpsumComponent); + fix.detectChanges(); + + const component: HighlightLoremIpsumComponent = fix.debugElement.componentInstance; + const count = component.highlightText('a'); + + let spans = fix.debugElement.nativeElement.querySelectorAll('.' + component.highlightClass); + expect(spans.length).toBe(4); + expect(count).toBe(4); + + component.clearHighlight(); + + spans = fix.debugElement.nativeElement.querySelectorAll('.' + component.highlightClass); + expect(spans.length).toBe(0); + }); + + it('Should keep the text content of the DIV intact', () => { + const fix = TestBed.createComponent(HighlightLoremIpsumComponent); + fix.detectChanges(); + + const component: HighlightLoremIpsumComponent = fix.debugElement.componentInstance; + const originalTextContent = component.textContent; + + component.highlightText('Lorem'); + + const loremText = component.textContent; + expect(loremText).toBe(originalTextContent); + + component.clearHighlight(); + + const clearedText = component.textContent; + expect(clearedText).toBe(originalTextContent); + }); + + it('Should activate the correct span', () => { + const fix = TestBed.createComponent(HighlightLoremIpsumComponent); + fix.detectChanges(); + + const component: HighlightLoremIpsumComponent = fix.debugElement.componentInstance; + component.highlightText('a'); + component.activate(0); + + + let spans = fix.debugElement.nativeElement.querySelectorAll('.' + component.highlightClass); + let activeSpan = fix.debugElement.nativeElement.querySelector('.' + component.activeHighlightClass); + + expect(activeSpan).toBe(spans[0]); + + component.activate(1); + + + spans = fix.debugElement.nativeElement.querySelectorAll('.' + component.highlightClass); + activeSpan = fix.debugElement.nativeElement.querySelector('.' + component.activeHighlightClass); + + expect(activeSpan).toBe(spans[1]); + + const allActiveSpans = fix.debugElement.nativeElement.querySelectorAll('.' + component.activeHighlightClass); + expect(allActiveSpans.length).toBe(1); + + spans = fix.debugElement.nativeElement.querySelectorAll('.' + component.highlightClass); + activeSpan = fix.debugElement.nativeElement.querySelector('.' + component.activeHighlightClass); + + component.clearHighlight(); + activeSpan = fix.debugElement.nativeElement.querySelector('.' + component.activeHighlightClass); + + expect(activeSpan).toBeNull(); + }); + + it('Should properly handle null and undefined searches', () => { + const fix = TestBed.createComponent(HighlightLoremIpsumComponent); + fix.detectChanges(); + + const component: HighlightLoremIpsumComponent = fix.debugElement.componentInstance; + component.highlightText('a'); + + + let count = component.highlightText(null); + + + let spans = fix.debugElement.nativeElement.querySelectorAll('.' + component.highlightClass); + expect(spans.length).toBe(0); + expect(count).toBe(0); + + component.highlightText('a'); + + + count = component.highlightText(undefined); + + + spans = fix.debugElement.nativeElement.querySelectorAll('.' + component.highlightClass); + expect(spans.length).toBe(0); + expect(count).toBe(0); + + component.highlightText('a'); + + + count = component.highlightText(''); + + + spans = fix.debugElement.nativeElement.querySelectorAll('.' + component.highlightClass); + expect(spans.length).toBe(0); + expect(count).toBe(0); + }); + + it('Should properly handle value changes', () => { + const fix = TestBed.createComponent(HighlightLoremIpsumComponent); + fix.detectChanges(); + + const component: HighlightLoremIpsumComponent = fix.debugElement.componentInstance; + component.highlightText('a'); + component.html = 'zzzzzzzzz'; + fix.detectChanges(); + + const spans = fix.debugElement.nativeElement.querySelectorAll('.' + component.highlightClass); + expect(spans.length).toBe(0); + }); + + it('Should properly handle value changes - case sensitive', () => { + const fix = TestBed.createComponent(HighlightLoremIpsumComponent); + fix.detectChanges(); + + const component: HighlightLoremIpsumComponent = fix.debugElement.componentInstance; + component.highlightText('a', true); + component.html = 'AAAAAAAAAA'; + fix.detectChanges(); + + const spans = fix.debugElement.nativeElement.querySelectorAll('.' + component.highlightClass); + expect(spans.length).toBe(0); + }); + + it('Should properly handle empty or null values', () => { + const fix = TestBed.createComponent(HighlightLoremIpsumComponent); + fix.detectChanges(); + + const component: HighlightLoremIpsumComponent = fix.debugElement.componentInstance; + + component.html = null; + component.highlightText('z', true); + fix.detectChanges(); + expect(component.textContent).toBe(''); + + component.clearHighlight(); + + expect(component.textContent).toBe(''); + + component.html = undefined; + component.highlightText('z', true); + fix.detectChanges(); + expect(component.textContent).toBe(''); + + component.clearHighlight(); + + expect(component.textContent).toBe(''); + }); + + it('Should apply correct styles on the highlight and active highlight spans', () => { + const fix = TestBed.createComponent(HighlightLoremIpsumComponent); + fix.detectChanges(); + + const component: HighlightLoremIpsumComponent = fix.debugElement.componentInstance; + component.highlightText('a'); + component.activate(0); + + const spans = fix.debugElement.nativeElement.querySelectorAll('.' + component.highlightClass); + const activeSpans = fix.debugElement.nativeElement.querySelectorAll('.' + component.activeHighlightClass); + expect(spans.length).toBe(4); + expect(activeSpans.length).toBe(1); + }); + + it('Should not throw error when active highlight is not set', () => { + const fix = TestBed.createComponent(HighlightLoremIpsumComponent); + fix.detectChanges(); + + const component: HighlightLoremIpsumComponent = fix.debugElement.componentInstance; + component.highlight.row = undefined; + component.highlight.column = undefined; + component.highlightText('a'); + + expect(() => component.highlight.activateIfNecessary()).not.toThrowError(); + }); + + it('Should not throw when attempting to activate a non-existing group.', () => { + const fix = TestBed.createComponent(HighlightLoremIpsumComponent); + fix.detectChanges(); + + const component: HighlightLoremIpsumComponent = fix.debugElement.componentInstance; + component.highlightText('a'); + component.groupName = 'test1'; + fix.detectChanges(); + + expect(() => component.highlight.activateIfNecessary()).not.toThrowError(); + }); +}); + +@Component({ + template: ` +
    + {{html}} +
    + `, + imports: [IgxTextHighlightDirective] +}) +class HighlightLoremIpsumComponent { + private highlightService = inject(IgxTextHighlightService); + + @ViewChild(IgxTextHighlightDirective, { read: IgxTextHighlightDirective, static: true }) + public highlight: IgxTextHighlightDirective; + + public highlightClass = 'igx-highlight'; + public activeHighlightClass = 'igx-highlight__active'; + public groupName = 'test'; + + public html = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum vulputate luctus dui ut maximus. Quisque sed suscipit lorem. Vestibulum sit.'; + + public highlightText(text: string, caseSensitive?: boolean, exactMatch?: boolean) { + return this.highlight.highlight(text, caseSensitive, exactMatch); + } + + public clearHighlight() { + this.highlight.clearHighlight(); + } + + public get textContent(): string { + return this.highlight.parentElement.innerText; + } + + public activate(index: number) { + const activeHighlightInfo: IActiveHighlightInfo = { + row: 0, + column: 0, + index + }; + this.highlightService.setActiveHighlight(this.groupName, activeHighlightInfo); + } +} diff --git a/projects/igniteui-angular/directives/src/directives/text-highlight/text-highlight.directive.ts b/projects/igniteui-angular/directives/src/directives/text-highlight/text-highlight.directive.ts new file mode 100644 index 00000000000..d1c8f7d226b --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/text-highlight/text-highlight.directive.ts @@ -0,0 +1,476 @@ +import { AfterViewInit, Directive, ElementRef, Input, OnChanges, OnDestroy, Renderer2, SimpleChanges, AfterViewChecked, inject } from '@angular/core'; +import { takeUntil } from 'rxjs/operators'; +import { Subject } from 'rxjs'; +import { compareMaps } from 'igniteui-angular/core'; +import { IgxTextHighlightService } from './text-highlight.service'; + +export interface IBaseSearchInfo { + searchText: string; + caseSensitive: boolean; + exactMatch: boolean; + matchCount: number; + content: string; +} + +/** + * An interface describing information for the active highlight. + */ +export interface IActiveHighlightInfo { + /** + * The row of the highlight. + */ + row?: any; + /** + * The column of the highlight. + */ + column?: any; + /** + * The index of the highlight. + */ + index: number; + /** + * Additional, custom checks to perform prior an element highlighting. + */ + metadata?: Map; +} + +@Directive({ + selector: '[igxTextHighlight]', + standalone: true +}) +export class IgxTextHighlightDirective implements AfterViewInit, AfterViewChecked, OnDestroy, OnChanges { + private element = inject(ElementRef); + private service = inject(IgxTextHighlightService); + private renderer = inject(Renderer2); + + /** + * Determines the `CSS` class of the highlight elements. + * This allows the developer to provide custom `CSS` to customize the highlight. + * + * ```html + *
    + *
    + * ``` + */ + @Input() + public cssClass: string; + + /** + * Determines the `CSS` class of the active highlight element. + * This allows the developer to provide custom `CSS` to customize the highlight. + * + * ```html + *
    + *
    + * ``` + */ + @Input() + public activeCssClass: string; + + /** + * @hidden + */ + @Input() + public containerClass: string; + + /** + * Identifies the highlight within a unique group. + * This allows it to have several different highlight groups, + * with each of them having their own active highlight. + * + * ```html + *
    + *
    + * ``` + */ + @Input() + public groupName = ''; + + /** + * The underlying value of the element that will be highlighted. + * + * ```typescript + * // get + * const elementValue = this.textHighlight.value; + * ``` + * + * ```html + * + *
    + *
    + * ``` + */ + @Input('value') + public get value(): any { + return this._value; + } + public set value(value: any) { + if (value === undefined || value === null) { + this._value = ''; + } else { + this._value = value; + } + } + + /** + * The identifier of the row on which the directive is currently on. + * + * ```html + *
    + *
    + * ``` + */ + @Input() + public row: any; + + /** + * The identifier of the column on which the directive is currently on. + * + * ```html + *
    + *
    + * ``` + */ + @Input() + public column: any; + + /** + * A map that contains all additional conditions, that you need to activate a highlighted + * element. To activate the condition, you will have to add a new metadata key to + * the `metadata` property of the IActiveHighlightInfo interface. + * + * @example + * ```typescript + * // Set a property, which would disable the highlight for a given element on a certain condition + * const metadata = new Map(); + * metadata.set('highlightElement', false); + * ``` + * ```html + *
    + *
    + * ``` + */ + @Input() + public metadata: Map; + + /** + * @hidden + */ + public get lastSearchInfo(): IBaseSearchInfo { + return this._lastSearchInfo; + } + + /** + * @hidden + */ + public parentElement: any; + + private _container: any; + + private destroy$ = new Subject(); + private _value = ''; + private _lastSearchInfo: IBaseSearchInfo; + private _div = null; + private _observer: MutationObserver = null; + private _nodeWasRemoved = false; + private _forceEvaluation = false; + private _activeElementIndex = -1; + private _valueChanged: boolean; + private _defaultCssClass = 'igx-highlight'; + private _defaultActiveCssClass = 'igx-highlight--active'; + + constructor() { + this.service.onActiveElementChanged.pipe(takeUntil(this.destroy$)).subscribe((groupName) => { + if (this.groupName === groupName) { + if (this._activeElementIndex !== -1) { + this.deactivate(); + } + this.activateIfNecessary(); + } + }); + } + + /** + * @hidden + */ + public ngOnDestroy() { + this.clearHighlight(); + + if (this._observer !== null) { + this._observer.disconnect(); + } + this.destroy$.next(true); + this.destroy$.complete(); + } + + /** + * @hidden + */ + public ngOnChanges(changes: SimpleChanges) { + if (changes.value && !changes.value.firstChange) { + this._valueChanged = true; + } else if ((changes.row !== undefined && !changes.row.firstChange) || + (changes.column !== undefined && !changes.column.firstChange) || + (changes.page !== undefined && !changes.page.firstChange)) { + if (this._activeElementIndex !== -1) { + this.deactivate(); + } + this.activateIfNecessary(); + } + } + + /** + * @hidden + */ + public ngAfterViewInit() { + this.parentElement = this.renderer.parentNode(this.element.nativeElement); + + if (this.service.highlightGroupsMap.has(this.groupName) === false) { + this.service.highlightGroupsMap.set(this.groupName, { + index: -1 + }); + } + + this._lastSearchInfo = { + searchText: '', + content: this.value, + matchCount: 0, + caseSensitive: false, + exactMatch: false + }; + + this._container = this.parentElement.firstElementChild; + } + + /** + * @hidden + */ + public ngAfterViewChecked() { + if (this._valueChanged) { + this.highlight(this._lastSearchInfo.searchText, this._lastSearchInfo.caseSensitive, this._lastSearchInfo.exactMatch); + this.activateIfNecessary(); + this._valueChanged = false; + } + } + + /** + * Clears the existing highlight and highlights the searched text. + * Returns how many times the element contains the searched text. + */ + public highlight(text: string, caseSensitive?: boolean, exactMatch?: boolean): number { + const caseSensitiveResolved = caseSensitive ? true : false; + const exactMatchResolved = exactMatch ? true : false; + + if (this.searchNeedsEvaluation(text, caseSensitiveResolved, exactMatchResolved)) { + this._lastSearchInfo.searchText = text; + this._lastSearchInfo.caseSensitive = caseSensitiveResolved; + this._lastSearchInfo.exactMatch = exactMatchResolved; + this._lastSearchInfo.content = this.value; + + if (text === '' || text === undefined || text === null) { + this.clearHighlight(); + } else { + this.clearChildElements(true); + this._lastSearchInfo.matchCount = this.getHighlightedText(text, caseSensitive, exactMatch); + } + } else if (this._nodeWasRemoved) { + this._lastSearchInfo.searchText = text; + this._lastSearchInfo.caseSensitive = caseSensitiveResolved; + this._lastSearchInfo.exactMatch = exactMatchResolved; + } + + return this._lastSearchInfo.matchCount; + } + + /** + * Clears any existing highlight. + */ + public clearHighlight(): void { + this.clearChildElements(false); + + this._lastSearchInfo.searchText = ''; + this._lastSearchInfo.matchCount = 0; + } + + /** + * Activates the highlight if it is on the currently active row and column. + */ + public activateIfNecessary(): void { + const group = this.service.highlightGroupsMap.get(this.groupName); + + if (group && group.index >= 0 && group.column === this.column && group.row === this.row && compareMaps(this.metadata, group.metadata)) { + this.activate(group.index); + } + } + + /** + * Attaches a MutationObserver to the parentElement and watches for when the container element is removed/readded to the DOM. + * Should be used only when necessary as using many observers may lead to performance degradation. + */ + public observe(): void { + if (this._observer === null) { + const callback = (mutationList) => { + mutationList.forEach((mutation) => { + const removedNodes = Array.from(mutation.removedNodes); + removedNodes.forEach((n) => { + if (n === this._container) { + this._nodeWasRemoved = true; + this.clearChildElements(false); + } + }); + + const addedNodes = Array.from(mutation.addedNodes); + addedNodes.forEach((n) => { + if (n === this.parentElement.firstElementChild && this._nodeWasRemoved) { + this._container = this.parentElement.firstElementChild; + this._nodeWasRemoved = false; + + this._forceEvaluation = true; + this.highlight(this._lastSearchInfo.searchText, + this._lastSearchInfo.caseSensitive, + this._lastSearchInfo.exactMatch); + this._forceEvaluation = false; + + this.activateIfNecessary(); + this._observer.disconnect(); + this._observer = null; + } + }); + }); + }; + + this._observer = new MutationObserver(callback); + this._observer.observe(this.parentElement, {childList: true}); + } + } + + private activate(index: number) { + this.deactivate(); + + if (this._div !== null) { + const spans = this._div.querySelectorAll('span'); + this._activeElementIndex = index; + + if (spans.length <= index) { + return; + } + + const elementToActivate = spans[index]; + this.renderer.addClass(elementToActivate, this._defaultActiveCssClass); + this.renderer.addClass(elementToActivate, this.activeCssClass); + } + } + + private deactivate() { + if (this._activeElementIndex === -1) { + return; + } + + const spans = this._div.querySelectorAll('span'); + + if (spans.length <= this._activeElementIndex) { + this._activeElementIndex = -1; + return; + } + + const elementToDeactivate = spans[this._activeElementIndex]; + this.renderer.removeClass(elementToDeactivate, this._defaultActiveCssClass); + this.renderer.removeClass(elementToDeactivate, this.activeCssClass); + this._activeElementIndex = -1; + } + + private clearChildElements(originalContentHidden: boolean): void { + this.renderer.setProperty(this.element.nativeElement, 'hidden', originalContentHidden); + + if (this._div !== null) { + this.renderer.removeChild(this.parentElement, this._div); + + this._div = null; + this._activeElementIndex = -1; + } + } + + private getHighlightedText(searchText: string, caseSensitive: boolean, exactMatch: boolean) { + this.appendDiv(); + + const stringValue = String(this.value); + const contentStringResolved = !caseSensitive ? stringValue.toLowerCase() : stringValue; + const searchTextResolved = !caseSensitive ? searchText.toLowerCase() : searchText; + + let matchCount = 0; + + if (exactMatch) { + if (contentStringResolved === searchTextResolved) { + this.appendSpan(`${stringValue}`); + matchCount++; + } else { + this.appendText(stringValue); + } + } else { + let foundIndex = contentStringResolved.indexOf(searchTextResolved, 0); + let previousMatchEnd = 0; + + while (foundIndex !== -1) { + const start = foundIndex; + const end = foundIndex + searchTextResolved.length; + + this.appendText(stringValue.substring(previousMatchEnd, start)); + this.appendSpan(`${stringValue.substring(start, end)}`); + + previousMatchEnd = end; + matchCount++; + + foundIndex = contentStringResolved.indexOf(searchTextResolved, end); + } + + this.appendText(stringValue.substring(previousMatchEnd, stringValue.length)); + } + + return matchCount; + } + + private appendText(text: string) { + const textElement = this.renderer.createText(text); + this.renderer.appendChild(this._div, textElement); + } + + private appendSpan(outerHTML: string) { + const span = this.renderer.createElement('span'); + this.renderer.appendChild(this._div, span); + this.renderer.setProperty(span, 'outerHTML', outerHTML); + } + + private appendDiv() { + this._div = this.renderer.createElement('div'); + if ( this.containerClass) { + this.renderer.addClass(this._div, this.containerClass); + } + this.renderer.appendChild(this.parentElement, this._div); + } + + private searchNeedsEvaluation(text: string, caseSensitive: boolean, exactMatch: boolean): boolean { + const searchedText = this._lastSearchInfo.searchText; + + return !this._nodeWasRemoved && + (searchedText === null || + searchedText !== text || + this._lastSearchInfo.content !== this.value || + this._lastSearchInfo.caseSensitive !== caseSensitive || + this._lastSearchInfo.exactMatch !== exactMatch || + this._forceEvaluation); + } +} diff --git a/projects/igniteui-angular/directives/src/directives/text-highlight/text-highlight.module.ts b/projects/igniteui-angular/directives/src/directives/text-highlight/text-highlight.module.ts new file mode 100644 index 00000000000..3d69cad8f8b --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/text-highlight/text-highlight.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { IgxTextHighlightDirective } from './text-highlight.directive'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [IgxTextHighlightDirective], + exports: [IgxTextHighlightDirective] +}) +export class IgxTextHighlightModule { } diff --git a/projects/igniteui-angular/directives/src/directives/text-highlight/text-highlight.service.ts b/projects/igniteui-angular/directives/src/directives/text-highlight/text-highlight.service.ts new file mode 100644 index 00000000000..ce96c3b7f89 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/text-highlight/text-highlight.service.ts @@ -0,0 +1,38 @@ +import { EventEmitter, Injectable } from '@angular/core'; +import { IActiveHighlightInfo } from './text-highlight.directive'; + +@Injectable({ + providedIn: 'root' +}) +export class IgxTextHighlightService { + public highlightGroupsMap = new Map(); + public onActiveElementChanged = new EventEmitter(); + + constructor() { } + + /** + * Activates the highlight at a given index. + * (if such index exists) + */ + public setActiveHighlight(groupName: string, highlight: IActiveHighlightInfo) { + this.highlightGroupsMap.set(groupName, highlight); + this.onActiveElementChanged.emit(groupName); + } + + /** + * Clears any existing highlight. + */ + public clearActiveHighlight(groupName) { + this.highlightGroupsMap.set(groupName, { + index: -1 + }); + this.onActiveElementChanged.emit(groupName); + } + + /** + * Destroys a highlight group. + */ + public destroyGroup(groupName: string) { + this.highlightGroupsMap.delete(groupName); + } +} diff --git a/projects/igniteui-angular/directives/src/directives/text-selection/README.md b/projects/igniteui-angular/directives/src/directives/text-selection/README.md new file mode 100644 index 00000000000..7767ac4e7ec --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/text-selection/README.md @@ -0,0 +1,32 @@ +# IgxTextSelection + +#### Category + +_Directives_ + +## Description + +Provides a way to trigger selection of text inside input elements. + +## Usage +```html + +``` + +## API + +### Inputs +| Name | Type | Description | +| :--- |:--- | :--- | +| IgxTextSelection| boolean | Determines whether the input element should be selectable through the directive. + +### Accessors +| Name | Type | Description | +| :--- |:--- | :--- | +| selected | boolean | Returns whether the element is selected or not. +| nativeElement | ElementRef | Returns the nativeElement of the element where the directive was applied. + +### Methods +| Name | Type | Description | +| :--- |:--- | :--- | +| trigger | void | Triggers the selection of the element. \ No newline at end of file diff --git a/projects/igniteui-angular/directives/src/directives/text-selection/text-selection.directive.spec.ts b/projects/igniteui-angular/directives/src/directives/text-selection/text-selection.directive.spec.ts new file mode 100644 index 00000000000..2446d1c1eb1 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/text-selection/text-selection.directive.spec.ts @@ -0,0 +1,197 @@ +import { Component, DebugElement, Directive, ElementRef, HostListener, ViewChild, inject } from '@angular/core'; +import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { IgxTextSelectionDirective } from './text-selection.directive'; + +describe('IgxSelection', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + TriggerTextSelectionComponent, + TriggerTextSelectionOnClickComponent, + TextSelectionWithMultipleFocusHandlersComponent, + IgxTestFocusDirective + ] + }); + })); + + + it('Should select the text which is into the input', fakeAsync(() => { + const fix = TestBed.createComponent(TriggerTextSelectionComponent); + fix.detectChanges(); + + const input = fix.debugElement.query(By.css('input')).nativeElement; + input.focus(); + tick(16); + expect(input.selectionEnd).toEqual(input.value.length); + expect(input.value.substring(input.selectionStart, input.selectionEnd)).toEqual(input.value); + })); + + it('Should select the text when the input is clicked', fakeAsync(()=> { + const fix = TestBed.createComponent(TriggerTextSelectionOnClickComponent); + fix.detectChanges(); + + const input: DebugElement = fix.debugElement.query(By.css('input')); + const inputNativeElem = input.nativeElement; + const inputElem: HTMLElement = input.nativeElement; + + inputElem.click(); // might need to change to .focus + fix.detectChanges(); + tick(16); + expect(inputNativeElem.selectionEnd).toEqual(inputNativeElem.value.length); + expect(inputNativeElem.value.substring(inputNativeElem.selectionStart, inputNativeElem.selectionEnd)) + .toEqual(inputNativeElem.value); + })); + + it('Should check if the value is selected if based on input type', fakeAsync(() => { + const fix = TestBed.createComponent(TriggerTextSelectionOnClickComponent); + const selectableTypes: Types[] = [ + { "text" : "Some Values!" }, + { "search": "Search!" }, + { "password": "********" }, + { "tel": '+(359)554-587-415' }, + { "url": "www.infragistics.com" }, + { "number": 2136512312 } + ]; + + const nonSelectableTypes: Types[] = [ + {'date': new Date() }, + {'datetime-local': "2018-06-12T19:30" }, + {'email': 'JohnSmith@gmail.com'}, + {'month': "2018-05" }, + {'time': "13:30"}, + {'week': "2017-W01"} + ]; + + //skipped on purpose, if needed feel free to add to any of the above categories + //const irrelevantTypes = ['button', 'checkbox', 'color', 'file', 'hidden', 'image', 'radio', 'range', 'reset', 'submit'] + + const input = fix.debugElement.query(By.css('input')); + const inputNativeElem = input.nativeElement; + const inputElem: HTMLElement = input.nativeElement; + + selectableTypes.forEach( el => { + const type = Object.keys(el)[0]; + const val = el[type]; + fix.componentInstance.inputType = type; + fix.componentInstance.inputValue = val; + fix.detectChanges(); + + inputElem.click(); + fix.detectChanges(); + tick(16); + + if(type !== 'number'){ + expect(inputNativeElem.selectionEnd).toEqual(inputNativeElem.value.length); + expect(inputNativeElem.value.substring(inputNativeElem.selectionStart, inputNativeElem.selectionEnd)) + .toEqual(val); + } + + if(type === 'number'){ + const selection = document.getSelection().toString(); + tick(1000); + expect((String(val)).length).toBe(selection.length); + } + }); + + nonSelectableTypes.forEach( el => { + const type = Object.keys(el)[0]; + const val = el[type]; + fix.componentInstance.inputType = type; + fix.componentInstance.inputValue = val; + fix.detectChanges(); + + inputElem.focus(); + fix.detectChanges(); + tick(16); + expect(inputNativeElem.selectionStart).toEqual(inputNativeElem.selectionEnd); + }); + })); + + + + it('Shouldn\'t make a selection when the state is set to false', () => { + const fix = TestBed.createComponent(TriggerTextSelectionOnClickComponent); + fix.componentInstance.selectValue = false; + fix.componentInstance.inputType = "text"; + fix.componentInstance.inputValue = "4444444"; + fix.detectChanges(); + + const input = fix.debugElement.query(By.css('input')); + const inputNativeElem = input.nativeElement; + const inputElem: HTMLElement = input.nativeElement; + + + inputElem.focus(); + fix.detectChanges(); + expect(inputNativeElem.selectionStart).toEqual(inputNativeElem.selectionEnd); + }); + + it('should apply selection properly if present on an element with multiple focus handlers', fakeAsync(() => { + const fix = TestBed.createComponent(TextSelectionWithMultipleFocusHandlersComponent); + fix.detectChanges(); + + const input = fix.debugElement.query(By.css('input')).nativeElement; + input.focus(); + tick(16); + expect(input.selectionEnd).toEqual(input.value.length); + expect(input.value.substring(input.selectionStart, input.selectionEnd)).toEqual(input.value); + })); +}); + +@Directive({ + selector: '[igxTestFocusDirective]', + standalone: true +}) +class IgxTestFocusDirective { + private element = inject(ElementRef); + + + @HostListener('focus') + public onFocus() { + this.element.nativeElement.value = `$${this.element.nativeElement.value}`; + } +} + +@Component({ + template: ` + + `, + imports: [IgxTextSelectionDirective] +}) +class TriggerTextSelectionComponent { } + +@Component({ + template: ` + + `, + imports: [IgxTextSelectionDirective] +}) +class TriggerTextSelectionOnClickComponent { + public selectValue = true; + public inputType: any = "text"; + public inputValue: any = "Some custom V!" + + @ViewChild('input',{read: HTMLInputElement, static:true}) public input: HTMLInputElement; + + public waitForOneSecond() { + return new Promise(resolve => { + setTimeout(() => { + resolve("I promise to return after one second!"); + }, 1000); + }); + } +} + +@Component({ + template: ``, + imports: [IgxTextSelectionDirective, IgxTestFocusDirective] +}) + class TextSelectionWithMultipleFocusHandlersComponent { + public inputValue: any = "12-34-56"; + } + +interface Types { + [key: string]: any; +} diff --git a/projects/igniteui-angular/directives/src/directives/text-selection/text-selection.directive.ts b/projects/igniteui-angular/directives/src/directives/text-selection/text-selection.directive.ts new file mode 100644 index 00000000000..06828898bb4 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/text-selection/text-selection.directive.ts @@ -0,0 +1,100 @@ +import { Directive, ElementRef, HostListener, Input, booleanAttribute, inject } from '@angular/core'; + +@Directive({ + exportAs: 'igxTextSelection', + selector: '[igxTextSelection]', + standalone: true +}) +export class IgxTextSelectionDirective { + private element = inject(ElementRef); + + /** + * Determines whether the input element could be selected through the directive. + * + * ```html + * + * + * + * + * + * + * ``` + */ + @Input({ alias: 'igxTextSelection', transform: booleanAttribute }) + public selected = true; + + /** + * Returns the nativeElement of the element where the directive was applied. + * + * ```html + * + * + * ``` + * + * ```typescript + * @ViewChild('firstName', + * {read: IgxTextSelectionDirective}) + * public inputElement: IgxTextSelectionDirective; + * + * public getNativeElement() { + * return this.inputElement.nativeElement; + * } + * ``` + */ + public get nativeElement() { + return this.element.nativeElement; + } + + /** + * @hidden + */ + @HostListener('focus') + public onFocus() { + this.trigger(); + } + + /** + * Triggers the selection of the element if it is marked as selectable. + * + * ```html + * + * + * ``` + * + * ```typescript + * @ViewChild('firstName', + * {read: IgxTextSelectionDirective}) + * public inputElement: IgxTextSelectionDirective; + * + * public triggerElementSelection() { + * this.inputElement.trigger(); + * } + * ``` + */ + + public trigger() { + if (this.selected && this.nativeElement.value.length) { + // delay the select call to avoid race conditions in case the directive is applied + // to an element with its own focus handler + requestAnimationFrame(() => this.nativeElement.select()); + } + } +} + +/** + * @hidden + */ + diff --git a/projects/igniteui-angular/directives/src/directives/text-selection/text-selection.module.ts b/projects/igniteui-angular/directives/src/directives/text-selection/text-selection.module.ts new file mode 100644 index 00000000000..a07cd261dc4 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/text-selection/text-selection.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { IgxTextSelectionDirective } from './text-selection.directive'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [IgxTextSelectionDirective], + exports: [IgxTextSelectionDirective] +}) +export class IgxTextSelectionModule { } diff --git a/projects/igniteui-angular/directives/src/directives/toggle/README.md b/projects/igniteui-angular/directives/src/directives/toggle/README.md new file mode 100644 index 00000000000..c2529f6605e --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/toggle/README.md @@ -0,0 +1,120 @@ +# IgxToggle Directive + +The **IgxToggle** provides a way for user to make a given content togglable. +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/toggle) + +#Usage +```typescript +import { IgxToggleModule } from "igniteui-angular"; +``` + +Basic initialization +```html +
    +

    Some content that user would like to make it togglable.

    +
    +``` + +Open/Close toggle through public methods that are provided by exporting the directive with name **toggle**. +```html + + +
    +

    Some content that user would like to make it togglable.

    +
    +``` + +Open/Close the directive only through one trigger by exporting it with name **toggle** and subscription for event +handlers when the toggle is opened and respectively closed. +```html + +
    +

    Some content that user would like to make it togglable.

    +
    +``` + +## API Summary + +### Outputs +| Name | Description | Cancelable | Emitted with | +|:------------:|:---------------------------------------------------------|:----------:|:--------------------------------| +| `appended` | Emits an event after content is appended to the overlay. | false | `ToggleViewEventArgs` | +| `opening` | Emits an event before the toggle container is opened. | true | `ToggleViewCancelableEventArgs` | +| `opened` | Emits an event after the toggle container is opened. | false | `ToggleViewEventArgs` | +| `closing` | Emits an event before the toggle container is closed. | true | `ToggleViewCancelableEventArgs` | +| `closed` | Emits an event after the toggle container is closed. | false | `ToggleViewEventArgs` | +### Methods +| Name | Arguments | Return Type | Description | +|:----------:|:------|:------|:------| +| `open` | overlaySettings?: `OverlaySettings` | `void` | Opens the toggle. | +| `close` | --- | `void` | Closes the toggle. | +| `toggle` | overlaySettings?: `OverlaySettings` | `void` | Closes the toggle. | +| `reposition` | --- | `void` | Repositions the toggle. | +| `setOffset` | deltaX: `number`, deltaY: `number`, offsetMode?: `OffsetMode` | `void` | Offsets the content along the corresponding axis by the provided amount with optional offsetMode that determines whether to add (by default) or set the offset values with OffsetMode.Add and OffsetMode.Set. | + + + +# IgxToggleAction Directive + +The **IgxToggleAction** provides a way for user to Open/Close(toggle) every Component/Directive which implements **IToggleView** interface by providing the reference to this particular Component/Directive or ID which is registered into **IgxNavigationService**. It is also applicable upon **IgxToggle**. When applied **IgxToggleAction** will set its host element as the position strategy target. + +You can see it in action [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/toggle) + +## Usage +```typescript +import { IgxToggleModule } from "igniteui-angular"; +``` + +Basic initialization +```html + + +
    +

    Some content that user would like to make it togglable.

    +
    +``` + +Passing registered component into **IgxNavigationService** by ID. +```html + +
    +

    Some content that user would like to make it togglable.

    +
    +``` + +Providing reference from custom component which has already implemented **IToggleView** interface. +```html + + +``` + +Providing reference from custom component which has already been registered into **IgxNavigationService**. +```html + + +``` + +## API Summary + +### Inputs +| Name | Type | Description | +|:----------:|:-------------|:------| +| `igxToggleAction`| `IToggleView` \| `string` | Determines the target that have to be controled. | +| `overlaySettings` | `OverlaySettings`| Passes `igxOverlay` settings for applicable targets (`igxToggle`) that control positioning, interaction and scroll behavior. +| `igxToggleOutlet` | `IgxOverlayOutletDirective` \| `ElementRef`| Determines where the target overlay element should be attached. Shortcut for `overlaySettings.outlet`. + +# IgxOverlayOutlet Directive + +The **IgxOverlayOutlet** provides a way to mark an element as an `igxOverlay` outlet container through the component template only. +Directive instance is exported as `overlay-outlet`, so it can be assigned within the template: + +```html +
    +``` +This allows to provide the `outlet` templates variable as a setting to the toggle action: +```html + + +``` diff --git a/projects/igniteui-angular/directives/src/directives/toggle/toggle.directive.spec.ts b/projects/igniteui-angular/directives/src/directives/toggle/toggle.directive.spec.ts new file mode 100644 index 00000000000..7db9cfdc34c --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/toggle/toggle.directive.spec.ts @@ -0,0 +1,779 @@ +import { ChangeDetectionStrategy, Component, DebugElement, ViewChild, ElementRef, OnInit, inject } from '@angular/core'; +import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxToggleActionDirective, IgxToggleDirective } from './toggle.directive'; + +import { first } from 'rxjs/operators'; +import { AbsoluteScrollStrategy, AutoPositionStrategy, CancelableEventArgs, ConnectedPositioningStrategy, HorizontalAlignment, IgxOverlayOutletDirective, IgxOverlayService, OffsetMode, OverlaySettings } from 'igniteui-angular/core'; + +describe('IgxToggle', () => { + const HIDDEN_TOGGLER_CLASS = 'igx-toggle--hidden'; + const TOGGLER_CLASS = 'igx-toggle'; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxToggleActionTestComponent, + IgxToggleOutletComponent, + IgxToggleServiceInjectComponent, + IgxOverlayServiceComponent, + IgxToggleTestComponent, + TestWithOnPushComponent, + TestWithThreeToggleActionsComponent + ] + }).compileComponents(); + })); + + it('IgxToggleDirective is defined', () => { + const fixture = TestBed.createComponent(IgxToggleTestComponent); + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.directive(IgxToggleDirective))).toBeDefined(); + expect(fixture.debugElement.query(By.css('ul'))).toBeDefined(); + expect(fixture.debugElement.queryAll(By.css('li')).length).toBe(4); + }); + + it('verify that initially toggled content is hidden', () => { + const fixture = TestBed.createComponent(IgxToggleTestComponent); + fixture.detectChanges(); + const divEl = fixture.debugElement.query(By.directive(IgxToggleDirective)).nativeElement; + expect(fixture.componentInstance.toggle.collapsed).toBe(true); + expect(divEl.classList.contains(HIDDEN_TOGGLER_CLASS)).toBe(true); + }); + + it('should show and hide content according \'collapsed\' attribute', () => { + const fixture = TestBed.createComponent(IgxToggleTestComponent); + fixture.detectChanges(); + + const divEl = fixture.debugElement.query(By.directive(IgxToggleDirective)).nativeElement; + expect(fixture.componentInstance.toggle.collapsed).toBe(true); + expect(divEl.classList.contains(HIDDEN_TOGGLER_CLASS)).toBe(true); + fixture.componentInstance.toggle.open(); + fixture.detectChanges(); + + expect(fixture.componentInstance.toggle.collapsed).toBe(false); + expect(divEl.classList.contains(TOGGLER_CLASS)).toBeTruthy(); + }); + + it('should emit \'opening\' and \'opened\' events', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxToggleTestComponent); + fixture.detectChanges(); + + const toggle = fixture.componentInstance.toggle; + spyOn(toggle.opening, 'emit'); + spyOn(toggle.opened, 'emit'); + toggle.open(); + tick(); + fixture.detectChanges(); + + expect(toggle.opening.emit).toHaveBeenCalled(); + expect(toggle.opened.emit).toHaveBeenCalled(); + })); + + it('should emit \'appended\' event', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxToggleTestComponent); + fixture.detectChanges(); + + const toggle = fixture.componentInstance.toggle; + spyOn(toggle.appended, 'emit'); + toggle.open(); + tick(); + fixture.detectChanges(); + + expect(toggle.appended.emit).toHaveBeenCalledTimes(1); + + toggle.close(); + tick(); + fixture.detectChanges(); + toggle.open(); + tick(); + fixture.detectChanges(); + + expect(toggle.appended.emit).toHaveBeenCalledTimes(2); + })); + + it('should emit \'onClosing\' and \'closed\' events', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxToggleTestComponent); + fixture.detectChanges(); + + const toggle = fixture.componentInstance.toggle; + fixture.componentInstance.toggle.open(); + tick(); + fixture.detectChanges(); + + spyOn(toggle.closing, 'emit'); + spyOn(toggle.closed, 'emit'); + toggle.close(); + tick(); + fixture.detectChanges(); + + expect(toggle.closing.emit).toHaveBeenCalledTimes(1); + expect(toggle.closed.emit).toHaveBeenCalledTimes(1); + })); + + it('should propagate IgxOverlay opened/closed events', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxOverlayServiceComponent); + fixture.detectChanges(); + + const toggle = fixture.componentInstance.toggle; + const overlay = fixture.componentInstance.overlay; + spyOn(toggle.opened, 'emit'); + spyOn(toggle.closed, 'emit'); + + toggle.open(); + tick(); + expect(toggle.opened.emit).toHaveBeenCalledTimes(1); + expect(toggle.collapsed).toBe(false); + toggle.close(); + tick(); + expect(toggle.closed.emit).toHaveBeenCalledTimes(1); + expect(toggle.collapsed).toBe(true); + + toggle.open(); + tick(); + expect(toggle.opened.emit).toHaveBeenCalledTimes(2); + const otherId = overlay.attach(fixture.componentInstance.other); + overlay.show(otherId); + overlay.hide(otherId); + tick(); + expect(toggle.closed.emit).toHaveBeenCalledTimes(1); + expect(toggle.collapsed).toBe(false); + overlay.hideAll(); // as if outside click + tick(); + expect(toggle.closed.emit).toHaveBeenCalledTimes(2); + expect(toggle.collapsed).toBe(true); + })); + + it('should open toggle when IgxToggleActionDirective is clicked and toggle is closed', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxToggleActionTestComponent); + fixture.detectChanges(); + + const button: DebugElement = fixture.debugElement.query(By.directive(IgxToggleActionDirective)); + const divEl: DebugElement = fixture.debugElement.query(By.directive(IgxToggleDirective)); + expect(fixture.componentInstance.toggle.collapsed).toBe(true); + expect(divEl.classes[HIDDEN_TOGGLER_CLASS]).toBe(true); + button.triggerEventHandler('click', null); + tick(); + fixture.detectChanges(); + + expect(fixture.componentInstance.toggle.collapsed).toBe(false); + expect(divEl.classes[TOGGLER_CLASS]).toBe(true); + })); + + it('should close toggle when IgxToggleActionDirective is clicked and toggle is opened', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxToggleActionTestComponent); + fixture.detectChanges(); + fixture.componentInstance.toggle.open(); + tick(); + + const divEl = fixture.debugElement.query(By.directive(IgxToggleDirective)).nativeElement; + const button: DebugElement = fixture.debugElement.query(By.directive(IgxToggleActionDirective)); + + expect(divEl.classList.contains(TOGGLER_CLASS)).toBe(true); + + button.triggerEventHandler('click', null); + + tick(); + fixture.detectChanges(); + expect(divEl.classList.contains(HIDDEN_TOGGLER_CLASS)).toBeTruthy(); + })); + + it('should hide content and emit \'closed\' event when you click outside the toggle\'s content', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxToggleActionTestComponent); + fixture.detectChanges(); + + const divEl = fixture.debugElement.query(By.directive(IgxToggleDirective)).nativeElement; + const toggle = fixture.componentInstance.toggle; + const p = fixture.debugElement.query(By.css('p')); + spyOn(toggle.opening, 'emit'); + spyOn(toggle.opened, 'emit'); + + fixture.componentInstance.toggleAction.onClick(); + tick(); + expect(toggle.opening.emit).toHaveBeenCalled(); + expect(toggle.opened.emit).toHaveBeenCalled(); + + expect(fixture.componentInstance.toggle.collapsed).toBe(false); + expect(divEl.classList.contains(TOGGLER_CLASS)).toBe(true); + spyOn(toggle.closing, 'emit'); + spyOn(toggle.closed, 'emit'); + + p.nativeElement.click(); + tick(); + fixture.detectChanges(); + + expect(toggle.closing.emit).toHaveBeenCalled(); + expect(toggle.closed.emit).toHaveBeenCalled(); + })); + + it('should offset the toggle content correctly using setOffset method and optional offsetMode', () => { + const fixture = TestBed.createComponent(IgxToggleActionTestComponent); + const component = fixture.componentInstance; + const toggle = component.toggle; + const overlayId = toggle.overlayId; + fixture.detectChanges(); + + const overlayService = TestBed.inject(IgxOverlayService); + spyOn(overlayService, 'setOffset').and.callThrough(); + fixture.detectChanges(); + + // Set initial offset + toggle.setOffset(20, 20); + expect(overlayService.setOffset).toHaveBeenCalledWith(overlayId, 20, 20, undefined); + + // Add offset values to the existing ones (default behavior) + toggle.setOffset(10, 10); + expect(overlayService.setOffset).toHaveBeenCalledWith(overlayId, 10, 10, undefined); + + // Add offset values using OffsetMode.Add + toggle.setOffset(20, 20, OffsetMode.Add); + expect(overlayService.setOffset).toHaveBeenCalledWith(overlayId, 20, 20, OffsetMode.Add); + + // Set offset values using OffsetMode.Set + toggle.setOffset(10, 10, OffsetMode.Set); + expect(overlayService.setOffset).toHaveBeenCalledWith(overlayId, 10, 10, OffsetMode.Set); + }); + + it('Toggle should be registered into navigationService if it is passed through identifier', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxToggleServiceInjectComponent); + fixture.detectChanges(); + + const toggleFromComponent = fixture.componentInstance.toggle; + const toggleFromService = fixture.componentInstance.toggleAction.target as IgxToggleDirective; + + expect(toggleFromService instanceof IgxToggleDirective).toBe(true); + expect(toggleFromService.id).toEqual(toggleFromComponent.id); + })); + + it('Toggle should working with parent component and OnPush strategy applied.', fakeAsync(() => { + const fix = TestBed.createComponent(TestWithOnPushComponent); + fix.detectChanges(); + + const toggle = fix.componentInstance.toggle; + const toggleElm = fix.debugElement.query(By.directive(IgxToggleDirective)).nativeElement; + const button: DebugElement = fix.debugElement.query(By.css('button')); + + spyOn(toggle.opened, 'emit'); + spyOn(toggle.closed, 'emit'); + button.triggerEventHandler('click', null); + + tick(); + fix.detectChanges(); + + expect(toggle.opened.emit).toHaveBeenCalled(); + expect(toggleElm.classList.contains(TOGGLER_CLASS)).toBe(true); + button.triggerEventHandler('click', null); + + tick(); + fix.detectChanges(); + + expect(toggle.closed.emit).toHaveBeenCalled(); + expect(toggleElm.classList.contains(HIDDEN_TOGGLER_CLASS)).toBe(true); + })); + + it('fix for #2798 - Allow canceling of open and close of IgxDropDown through opening and closing events', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxToggleTestComponent); + fixture.detectChanges(); + + const toggle = fixture.componentInstance.toggle; + + spyOn(toggle.opening, 'emit').and.callThrough(); + spyOn(toggle.opened, 'emit').and.callThrough(); + spyOn(toggle.closing, 'emit').and.callThrough(); + spyOn(toggle.closed, 'emit').and.callThrough(); + + let cancelClosing = true; + toggle.closing.pipe(first()).subscribe((e: CancelableEventArgs) => e.cancel = cancelClosing); + + toggle.open(); + fixture.detectChanges(); + tick(); + + expect(toggle.opening.emit).toHaveBeenCalledTimes(1); + expect(toggle.opened.emit).toHaveBeenCalledTimes(1); + + toggle.close(); + fixture.detectChanges(); + tick(); + + expect(toggle.closing.emit).toHaveBeenCalledTimes(1); + expect(toggle.closed.emit).toHaveBeenCalledTimes(0); + + cancelClosing = false; + toggle.close(); + fixture.detectChanges(); + tick(); + + expect(toggle.closing.emit).toHaveBeenCalledTimes(2); + expect(toggle.closed.emit).toHaveBeenCalledTimes(1); + + toggle.opening.subscribe((e: CancelableEventArgs) => e.cancel = true); + toggle.open(); + fixture.detectChanges(); + tick(); + + expect(toggle.opening.emit).toHaveBeenCalledTimes(2); + expect(toggle.opened.emit).toHaveBeenCalledTimes(1); + })); + + it('fix for #3636 - ToggleAction should provide its element as target', fakeAsync(() => { + const fixture = TestBed.createComponent(TestWithThreeToggleActionsComponent); + fixture.detectChanges(); + fixture.debugElement.componentInstance.overlaySettings.positionStrategy.settings.horizontalDirection = HorizontalAlignment.Right; + + let button = fixture.componentInstance.button1.nativeElement; + button.click(); + tick(); + fixture.detectChanges(); + + let toggle = fixture.debugElement.query(By.css('#toggle1')); + let toggleRect = toggle.nativeElement.getBoundingClientRect(); + let buttonRect = button.getBoundingClientRect(); + expect(Math.round(toggleRect.left)).toBe(Math.round(buttonRect.right)); + expect(Math.round(toggleRect.top)).toBe(Math.round(buttonRect.bottom)); + + button = fixture.componentInstance.button2.nativeElement; + button.click(); + fixture.detectChanges(); + + toggle = fixture.debugElement.query(By.css('#toggle2')); + toggleRect = toggle.nativeElement.getBoundingClientRect(); + buttonRect = button.getBoundingClientRect(); + expect(Math.round(toggleRect.left)).toBe(Math.round(buttonRect.right)); + expect(Math.round(toggleRect.top)).toBe(Math.round(buttonRect.bottom)); + + button = fixture.componentInstance.button3.nativeElement; + button.click(); + tick(); + fixture.detectChanges(); + + toggle = fixture.debugElement.query(By.css('#toggle3')); + toggleRect = toggle.nativeElement.getBoundingClientRect(); + buttonRect = button.getBoundingClientRect(); + expect(Math.round(toggleRect.left)).toBe(Math.round(buttonRect.right)); + expect(Math.round(toggleRect.top)).toBe(Math.round(buttonRect.bottom)); + })); + + it('fix for #3636 - All toggles should scroll correctly', fakeAsync(() => { + const fixture = TestBed.createComponent(TestWithThreeToggleActionsComponent); + fixture.detectChanges(); + fixture.debugElement.componentInstance.overlaySettings.positionStrategy.settings.horizontalDirection = HorizontalAlignment.Right; + + let button = fixture.componentInstance.button1.nativeElement; + button.click(); + button = fixture.componentInstance.button2.nativeElement; + button.click(); + button = fixture.componentInstance.button3.nativeElement; + button.click(); + fixture.detectChanges(); + tick(); + + let toggle = fixture.debugElement.query(By.css('#toggle1')); + let toggleRect = toggle.nativeElement.getBoundingClientRect(); + button = fixture.componentInstance.button1.nativeElement; + let buttonRect = button.getBoundingClientRect(); + expect(Math.round(toggleRect.left)).toBe(Math.round(buttonRect.right)); + expect(Math.round(toggleRect.top)).toBe(Math.round(buttonRect.bottom)); + + toggle = fixture.debugElement.query(By.css('#toggle2')); + toggleRect = toggle.nativeElement.getBoundingClientRect(); + button = fixture.componentInstance.button2.nativeElement; + buttonRect = button.getBoundingClientRect(); + expect(Math.round(toggleRect.left)).toBe(Math.round(buttonRect.right)); + expect(Math.round(toggleRect.top)).toBe(Math.round(buttonRect.bottom)); + + toggle = fixture.debugElement.query(By.css('#toggle3')); + toggleRect = toggle.nativeElement.getBoundingClientRect(); + button = fixture.componentInstance.button3.nativeElement; + buttonRect = button.getBoundingClientRect(); + expect(Math.round(toggleRect.left)).toBe(Math.round(buttonRect.right)); + expect(Math.round(toggleRect.top)).toBe(Math.round(buttonRect.bottom)); + + document.documentElement.scrollTop += 100; + document.dispatchEvent(new Event('scroll')); + tick(); + + toggle = fixture.debugElement.query(By.css('#toggle1')); + toggleRect = toggle.nativeElement.getBoundingClientRect(); + button = fixture.componentInstance.button1.nativeElement; + buttonRect = button.getBoundingClientRect(); + expect(Math.round(toggleRect.left)).toBe(Math.round(buttonRect.right)); + expect(Math.round(toggleRect.top)).toBe(Math.round(buttonRect.bottom)); + + toggle = fixture.debugElement.query(By.css('#toggle2')); + toggleRect = toggle.nativeElement.getBoundingClientRect(); + button = fixture.componentInstance.button2.nativeElement; + buttonRect = button.getBoundingClientRect(); + expect(Math.round(toggleRect.left)).toBe(Math.round(buttonRect.right)); + expect(Math.round(toggleRect.top)).toBe(Math.round(buttonRect.bottom)); + + toggle = fixture.debugElement.query(By.css('#toggle3')); + toggleRect = toggle.nativeElement.getBoundingClientRect(); + button = fixture.componentInstance.button3.nativeElement; + buttonRect = button.getBoundingClientRect(); + expect(Math.round(toggleRect.left)).toBe(Math.round(buttonRect.right)); + expect(Math.round(toggleRect.top)).toBe(Math.round(buttonRect.bottom)); + })); + + it('fix for #3810 - Should not open toggle when already opened', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxToggleTestComponent); + fixture.detectChanges(); + + const toggle = fixture.componentInstance.toggle; + spyOn(toggle.opening, 'emit'); + spyOn(toggle.opened, 'emit'); + toggle.open(); + tick(); + fixture.detectChanges(); + + expect(toggle.opening.emit).toHaveBeenCalledTimes(1); + expect(toggle.opened.emit).toHaveBeenCalledTimes(1); + + toggle.open(); + tick(); + fixture.detectChanges(); + + expect(toggle.opening.emit).toHaveBeenCalledTimes(1); + expect(toggle.opened.emit).toHaveBeenCalledTimes(1); + })); + + it('fix for #3810 - Should not close toggle when not open', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxToggleTestComponent); + fixture.detectChanges(); + + const toggle = fixture.componentInstance.toggle; + spyOn(toggle.opening, 'emit'); + spyOn(toggle.opened, 'emit'); + toggle.open(); + tick(); + fixture.detectChanges(); + + expect(toggle.opening.emit).toHaveBeenCalledTimes(1); + expect(toggle.opened.emit).toHaveBeenCalledTimes(1); + + spyOn(toggle.closing, 'emit'); + spyOn(toggle.closed, 'emit'); + toggle.close(); + tick(); + fixture.detectChanges(); + + expect(toggle.closing.emit).toHaveBeenCalledTimes(1); + expect(toggle.closed.emit).toHaveBeenCalledTimes(1); + + toggle.close(); + tick(); + fixture.detectChanges(); + + expect(toggle.closing.emit).toHaveBeenCalledTimes(1); + expect(toggle.closed.emit).toHaveBeenCalledTimes(1); + })); + + it('fix for #4222 - Should emit closed when closed second time', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxToggleTestComponent); + fixture.detectChanges(); + + const toggle = fixture.componentInstance.toggle; + toggle.closed.subscribe(() => { + toggle.open(); + }); + + spyOn(toggle.opening, 'emit'); + spyOn(toggle.closed, 'emit').and.callThrough(); + + toggle.open(); + tick(); + fixture.detectChanges(); + expect(toggle.opening.emit).toHaveBeenCalledTimes(1); + + toggle.close(); + tick(); + fixture.detectChanges(); + expect(toggle.closed.emit).toHaveBeenCalledTimes(1); + expect(toggle.opening.emit).toHaveBeenCalledTimes(2); + + toggle.close(); + tick(); + fixture.detectChanges(); + expect(toggle.closed.emit).toHaveBeenCalledTimes(2); + expect(toggle.opening.emit).toHaveBeenCalledTimes(3); + })); + + describe('overlay settings', () => { + it('should pass correct defaults from IgxToggleActionDirective and respect outsideClickClose', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxToggleActionTestComponent); + fixture.detectChanges(); + spyOn(IgxToggleDirective.prototype, 'toggle'); + const button = fixture.debugElement.query(By.directive(IgxToggleActionDirective)).nativeElement as HTMLElement; + + const defaults: OverlaySettings = { + target: button, + positionStrategy: jasmine.any(ConnectedPositioningStrategy) as any, + closeOnOutsideClick: true, + modal: false, + scrollStrategy: jasmine.any(AbsoluteScrollStrategy) as any, + excludeFromOutsideClick: [button] + }; + + fixture.componentInstance.toggleAction.onClick(); + expect(IgxToggleDirective.prototype.toggle).toHaveBeenCalledWith(defaults); + + fixture.componentInstance.settings.closeOnOutsideClick = false; + fixture.detectChanges(); + fixture.componentInstance.toggleAction.onClick(); + defaults.closeOnOutsideClick = false; + expect(IgxToggleDirective.prototype.toggle).toHaveBeenCalledWith(defaults); + })); + + it('should pass overlaySettings input from IgxToggleActionDirective and respect outsideClickClose', () => { + const fixture = TestBed.createComponent(IgxToggleActionTestComponent); + fixture.detectChanges(); + spyOn(IgxToggleDirective.prototype, 'toggle'); + const button = fixture.debugElement.query(By.directive(IgxToggleActionDirective)).nativeElement; + + const settings: OverlaySettings = { + target: button, + positionStrategy: jasmine.any(ConnectedPositioningStrategy) as any, + closeOnOutsideClick: true, + modal: false, + scrollStrategy: jasmine.any(AbsoluteScrollStrategy) as any, + excludeFromOutsideClick: [button] + }; + + // defaults + fixture.componentInstance.toggleAction.onClick(); + expect(IgxToggleDirective.prototype.toggle).toHaveBeenCalledWith(settings); + + // override modal and strategy + fixture.componentInstance.settings.modal = true; + fixture.componentInstance.settings.positionStrategy = new AutoPositionStrategy(); + settings.modal = true; + settings.positionStrategy = jasmine.any(AutoPositionStrategy) as any; + fixture.detectChanges(); + fixture.componentInstance.toggleAction.onClick(); + expect(IgxToggleDirective.prototype.toggle).toHaveBeenCalledWith(settings); + + // override close on click + fixture.componentInstance.settings.closeOnOutsideClick = false; + settings.closeOnOutsideClick = false; + fixture.detectChanges(); + fixture.componentInstance.toggleAction.onClick(); + expect(IgxToggleDirective.prototype.toggle).toHaveBeenCalledWith(settings); + }); + + it('should pass input overlaySettings from igxToggleAction and set position target if not provided', () => { + const fixture = TestBed.createComponent(IgxToggleActionTestComponent); + fixture.detectChanges(); + const toggleSpy = spyOn(IgxToggleDirective.prototype, 'toggle'); + const button = fixture.debugElement.query(By.directive(IgxToggleActionDirective)).nativeElement; + + const settings: OverlaySettings = { + positionStrategy: jasmine.any(ConnectedPositioningStrategy) as any, + closeOnOutsideClick: true, + modal: false, + scrollStrategy: jasmine.any(AbsoluteScrollStrategy) as any, + excludeFromOutsideClick: [button] + }; + fixture.componentInstance.settings.positionStrategy = new ConnectedPositioningStrategy(); + fixture.detectChanges(); + + fixture.componentInstance.toggleAction.onClick(); + settings.target = button; + expect(toggleSpy).toHaveBeenCalledWith(settings); + }); + + it('Should fire toggle "closing" event when closing through closeOnOutsideClick', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxToggleActionTestComponent); + fixture.detectChanges(); + + const toggle = fixture.componentInstance.toggle; + + spyOn(toggle, 'toggle').and.callThrough(); + spyOn(toggle.closed, 'emit').and.callThrough(); + spyOn(toggle.closing, 'emit').and.callThrough(); + spyOn(toggle.opening, 'emit').and.callThrough(); + spyOn(toggle.opened, 'emit').and.callThrough(); + + const button = fixture.debugElement.query(By.css('button')).nativeElement; + button.click(); + tick(); + fixture.detectChanges(); + + expect(toggle.opening.emit).toHaveBeenCalledTimes(1); + expect(toggle.opened.emit).toHaveBeenCalledTimes(1); + + document.documentElement.dispatchEvent(new Event('click')); + tick(); + fixture.detectChanges(); + + expect(toggle.closing.emit).toHaveBeenCalledTimes(1); + expect(toggle.closing.emit).toHaveBeenCalledWith({ id: '0', owner: toggle, cancel: false, event: new Event('click') }); + expect(toggle.closed.emit).toHaveBeenCalledTimes(1); + })); + + it('should pass IgxOverlayOutletDirective input from IgxToggleActionDirective', () => { + const fixture = TestBed.createComponent(IgxToggleOutletComponent); + const outlet = fixture.debugElement.query(By.css('.outlet-container')).nativeElement; + const toggleSpy = spyOn(IgxToggleDirective.prototype, 'toggle'); + const button = fixture.debugElement.query(By.directive(IgxToggleActionDirective)).nativeElement; + fixture.detectChanges(); + + const settings: OverlaySettings = { + target: button, + positionStrategy: jasmine.any(ConnectedPositioningStrategy) as any, + closeOnOutsideClick: true, + modal: false, + scrollStrategy: jasmine.any(AbsoluteScrollStrategy) as any, + outlet: jasmine.any(IgxOverlayOutletDirective) as any, + excludeFromOutsideClick: [button] + }; + + fixture.componentInstance.toggleAction.onClick(); + expect(IgxToggleDirective.prototype.toggle).toHaveBeenCalledWith(settings); + const directive = toggleSpy.calls.mostRecent().args[0].outlet as IgxOverlayOutletDirective; + expect(directive.nativeElement).toBe(outlet); + }); + }); +}); + +@Component({ + template: ` +
    +
      +
    • 1
    • +
    • 2
    • +
    • 3
    • +
    • 4
    • +
    +
    + `, + imports: [IgxToggleDirective] +}) +export class IgxToggleTestComponent { + @ViewChild(IgxToggleDirective, { static: true }) public toggle: IgxToggleDirective; + public open() { } + public close() { } +} +@Component({ + template: ` +

    Test

    + +
    +
      +
    • 1
    • +
    • 2
    • +
    • 3
    • +
    • 4
    • +
    +
    + `, + imports: [IgxToggleDirective, IgxToggleActionDirective] +}) +export class IgxToggleActionTestComponent { + @ViewChild(IgxToggleDirective, { static: true }) public toggle: IgxToggleDirective; + @ViewChild(IgxToggleActionDirective, { static: true }) public toggleAction: IgxToggleActionDirective; + public settings: OverlaySettings = {}; + constructor() { + this.settings.closeOnOutsideClick = true; + } +} + +@Component({ + template: ` + +
    +
    + `, + imports: [IgxToggleActionDirective, IgxToggleDirective, IgxOverlayOutletDirective] +}) +export class IgxToggleOutletComponent extends IgxToggleActionTestComponent { } + +@Component({ + template: ` + +
    + Some content +
    + `, + selector: 'igx-toggle-service-inject', + imports: [IgxToggleActionDirective, IgxToggleDirective] +}) +export class IgxToggleServiceInjectComponent { + @ViewChild(IgxToggleDirective, { static: true }) public toggle: IgxToggleDirective; + @ViewChild(IgxToggleActionDirective, { static: true }) public toggleAction: IgxToggleActionDirective; +} + +@Component({ + template: ` +
    + Some content +
    +
    + Some more content +
    + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [IgxToggleDirective] +}) +export class IgxOverlayServiceComponent { + public overlay = inject(IgxOverlayService); + + @ViewChild(IgxToggleDirective, { static: true }) public toggle: IgxToggleDirective; + @ViewChild(`other`, { static: true }) public other: ElementRef; +} + +@Component({ + template: ` + +
    + Some content +
    + `, + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'igx-test-with-on-push', + imports: [IgxToggleActionDirective, IgxToggleDirective] +}) +export class TestWithOnPushComponent { + @ViewChild(IgxToggleDirective, { static: true }) public toggle: IgxToggleDirective; +} + +@Component({ + template: ` + +
    + Toggle 1 +
    + + +
    + Toggle 2 +
    + + +
    + Toggle 3 +
    + `, + imports: [IgxToggleActionDirective, IgxToggleDirective] +}) +export class TestWithThreeToggleActionsComponent implements OnInit { + @ViewChild('button1', { static: true }) public button1: ElementRef; + @ViewChild('button2', { static: true }) public button2: ElementRef; + @ViewChild('button3', { static: true }) public button3: ElementRef; + + public overlaySettings: OverlaySettings = {}; + + public ngOnInit(): void { + this.overlaySettings.positionStrategy = new ConnectedPositioningStrategy({ + horizontalDirection: HorizontalAlignment.Left, + horizontalStartPoint: HorizontalAlignment.Right + }); + this.overlaySettings.closeOnOutsideClick = false; + } +} diff --git a/projects/igniteui-angular/directives/src/directives/toggle/toggle.directive.ts b/projects/igniteui-angular/directives/src/directives/toggle/toggle.directive.ts new file mode 100644 index 00000000000..b49747cacd6 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/toggle/toggle.directive.ts @@ -0,0 +1,522 @@ +import { + ChangeDetectorRef, + Directive, + ElementRef, + EventEmitter, + HostListener, + inject, + Input, + OnDestroy, + OnInit, + Output, +} from '@angular/core'; +import { AbsoluteScrollStrategy, IgxOverlayOutletDirective } from 'igniteui-angular/core'; +import { CancelableBrowserEventArgs, IBaseEventArgs, PlatformUtil } from 'igniteui-angular/core'; +import { ConnectedPositioningStrategy } from 'igniteui-angular/core'; +import { filter, first, takeUntil } from 'rxjs/operators'; +import { IgxNavigationService, IToggleView } from 'igniteui-angular/core'; +import { IgxOverlayService } from 'igniteui-angular/core'; +import { IPositionStrategy } from 'igniteui-angular/core'; +import { OffsetMode, OverlayClosingEventArgs, OverlayEventArgs, OverlaySettings } from 'igniteui-angular/core'; +import { Subscription, Subject, MonoTypeOperatorFunction } from 'rxjs'; + +export interface ToggleViewEventArgs extends IBaseEventArgs { + /** Id of the toggle view */ + id: string; + /** Provides reference to the owner component (from IBaseEventArgs) */ + owner?: any; + /* blazorSuppress */ + event?: Event; +} + +export interface ToggleViewCancelableEventArgs extends ToggleViewEventArgs, CancelableBrowserEventArgs { + cancel: boolean; +} + +@Directive({ + exportAs: 'toggle', + selector: '[igxToggle]', + standalone: true, + host: { + '[class.igx-toggle--hidden]': 'hiddenClass', + '[attr.aria-hidden]': 'hiddenClass', + '[class.igx-toggle--hidden-webkit]': 'hiddenWebkitClass', + '[class.igx-toggle]': 'defaultClass' + } +}) +export class IgxToggleDirective implements IToggleView, OnInit, OnDestroy { + private elementRef = inject(ElementRef); + private cdr = inject(ChangeDetectorRef); + protected overlayService = inject(IgxOverlayService); + private navigationService = inject(IgxNavigationService, { optional: true }); + private platform = inject(PlatformUtil, { optional: true }); + + /** + * Emits an event after the toggle container is opened. + * + * ```typescript + * onToggleOpened(event) { + * alert("Toggle opened!"); + * } + * ``` + * + * ```html + *
    + *
    + * ``` + */ + @Output() + public opened = new EventEmitter(); + + /** + * Emits an event before the toggle container is opened. + * + * ```typescript + * onToggleOpening(event) { + * alert("Toggle opening!"); + * } + * ``` + * + * ```html + *
    + *
    + * ``` + */ + @Output() + public opening = new EventEmitter(); + + /** + * Emits an event after the toggle container is closed. + * + * ```typescript + * onToggleClosed(event) { + * alert("Toggle closed!"); + * } + * ``` + * + * ```html + *
    + *
    + * ``` + */ + @Output() + public closed = new EventEmitter(); + + /** + * Emits an event before the toggle container is closed. + * + * ```typescript + * onToggleClosing(event) { + * alert("Toggle closing!"); + * } + * ``` + * + * ```html + *
    + *
    + * ``` + */ + @Output() + public closing = new EventEmitter(); + + /** + * Emits an event after the toggle element is appended to the overlay container. + * + * ```typescript + * onAppended() { + * alert("Content appended!"); + * } + * ``` + * + * ```html + *
    + *
    + * ``` + */ + @Output() + public appended = new EventEmitter(); + + /** + * @hidden + */ + public get collapsed(): boolean { + return this._collapsed; + } + + /** + * Identifier which is registered into `IgxNavigationService` + * + * ```typescript + * let myToggleId = this.toggle.id; + * ``` + */ + @Input() + public id: string; + + /** + * @hidden + */ + public get element(): HTMLElement { + return this.elementRef.nativeElement; + } + + /** + * @hidden + */ + public get hiddenClass() { + return this.collapsed; + } + + /** + * @hidden + */ + public get hiddenWebkitClass() { + const isSafari = this.platform?.isSafari; + const browserVersion = this.platform?.browserVersion; + + return this.collapsed && isSafari && !!browserVersion && browserVersion < 17.5; + } + + /** + * @hidden + */ + public get defaultClass() { + return !this.collapsed; + } + + protected _overlayId: string; + + private _collapsed = true; + protected destroy$ = new Subject(); + private _overlaySubFilter: [MonoTypeOperatorFunction, MonoTypeOperatorFunction] = [ + filter(x => x.id === this._overlayId), + takeUntil(this.destroy$) + ]; + private _overlayOpenedSub: Subscription; + private _overlayClosingSub: Subscription; + private _overlayClosedSub: Subscription; + private _overlayContentAppendedSub: Subscription; + + /** + * Opens the toggle. + * + * ```typescript + * this.myToggle.open(); + * ``` + */ + public open(overlaySettings?: OverlaySettings) { + // if there is open animation do nothing + // if toggle is not collapsed and there is no close animation do nothing + const info = this.overlayService.getOverlayById(this._overlayId); + const openAnimationStarted = info?.openAnimationPlayer?.hasStarted() ?? false; + const closeAnimationStarted = info?.closeAnimationPlayer?.hasStarted() ?? false; + if (openAnimationStarted || !(this._collapsed || closeAnimationStarted)) { + return; + } + + this._collapsed = false; + + // TODO: this is a workaround for the issue introduced by Angular's with Ivy renderer. + // When calling detectChanges(), Angular marks the element for check, but does not update the classes + // immediately, which causes the overlay to calculate incorrect dimensions of target element. + // Overlay show should be called in the next tick to ensure the classes are updated and target element is measured correctly. + // Note: across the codebase, each host binding should be checked and similar fix applied if needed!!! + this.elementRef.nativeElement.className = this.elementRef.nativeElement.className.replace('igx-toggle--hidden', 'igx-toggle'); + this.elementRef.nativeElement.className = this.elementRef.nativeElement.className.replace('igx-toggle--hidden-webkit', 'igx-toggle'); + this.elementRef.nativeElement.removeAttribute('aria-hidden'); + + this.cdr.detectChanges(); + + if (!info) { + this.unsubscribe(); + this.subscribe(); + this._overlayId = this.overlayService.attach(this.elementRef, overlaySettings); + } + + const args: ToggleViewCancelableEventArgs = { cancel: false, owner: this, id: this._overlayId }; + this.opening.emit(args); + if (args.cancel) { + this.unsubscribe(); + this.overlayService.detach(this._overlayId); + this._collapsed = true; + delete this._overlayId; + this.cdr.detectChanges(); + return; + } + this.overlayService.show(this._overlayId, overlaySettings); + } + + /** + * Closes the toggle. + * + * ```typescript + * this.myToggle.close(); + * ``` + */ + public close(event?: Event) { + // if toggle is collapsed do nothing + // if there is close animation do nothing, toggle will close anyway + const info = this.overlayService.getOverlayById(this._overlayId); + const closeAnimationStarted = info?.closeAnimationPlayer?.hasStarted() || false; + if (this._collapsed || closeAnimationStarted) { + return; + } + + this.overlayService.hide(this._overlayId, event); + } + + /** + * Opens or closes the toggle, depending on its current state. + * + * ```typescript + * this.myToggle.toggle(); + * ``` + */ + public toggle(overlaySettings?: OverlaySettings) { + // if toggle is collapsed call open + // if there is running close animation call open + if (this.collapsed || this.isClosing) { + this.open(overlaySettings); + } else { + this.close(); + } + } + + /** @hidden @internal */ + public get isClosing() { + const info = this.overlayService.getOverlayById(this._overlayId); + return info ? info.closeAnimationPlayer?.hasStarted() : false; + } + + /** + * Returns the id of the overlay the content is rendered in. + * ```typescript + * this.myToggle.overlayId; + * ``` + */ + public get overlayId() { + return this._overlayId; + } + + /** + * Repositions the toggle. + * ```typescript + * this.myToggle.reposition(); + * ``` + */ + public reposition() { + this.overlayService.reposition(this._overlayId); + } + + /** + * Offsets the content along the corresponding axis by the provided amount with optional + * offsetMode that determines whether to add (by default) or set the offset values with OffsetMode.Add and OffsetMode.Set + */ + public setOffset(deltaX: number, deltaY: number, offsetMode?: OffsetMode) { + this.overlayService.setOffset(this._overlayId, deltaX, deltaY, offsetMode); + } + + /** + * @hidden + */ + public ngOnInit() { + if (this.navigationService && this.id) { + this.navigationService.add(this.id, this); + } + } + + /** + * @hidden + */ + public ngOnDestroy() { + if (this.navigationService && this.id) { + this.navigationService.remove(this.id); + } + if (this._overlayId) { + this.overlayService.detach(this._overlayId); + } + this.unsubscribe(); + this.destroy$.next(true); + this.destroy$.complete(); + } + + private overlayClosed = (e) => { + this._collapsed = true; + this.cdr.detectChanges(); + this.unsubscribe(); + this.overlayService.detach(this.overlayId); + const args: ToggleViewEventArgs = { owner: this, id: this._overlayId, event: e.event }; + delete this._overlayId; + this.closed.emit(args); + this.cdr.markForCheck(); + }; + + private subscribe() { + this._overlayContentAppendedSub = this.overlayService + .contentAppended + .pipe(first(), takeUntil(this.destroy$)) + .subscribe(() => { + const args: ToggleViewEventArgs = { owner: this, id: this._overlayId }; + this.appended.emit(args); + }); + + this._overlayOpenedSub = this.overlayService + .opened + .pipe(...this._overlaySubFilter) + .subscribe(() => { + const args: ToggleViewEventArgs = { owner: this, id: this._overlayId }; + this.opened.emit(args); + }); + + this._overlayClosingSub = this.overlayService + .closing + .pipe(...this._overlaySubFilter) + .subscribe((e: OverlayClosingEventArgs) => { + const args: ToggleViewCancelableEventArgs = { cancel: false, event: e.event, owner: this, id: this._overlayId }; + this.closing.emit(args); + e.cancel = args.cancel; + + // in case event is not canceled this will close the toggle and we need to unsubscribe. + // Otherwise if for some reason, e.g. close on outside click, close() gets called before + // closed was fired we will end with calling closing more than once + if (!e.cancel) { + this.clearSubscription(this._overlayClosingSub); + } + }); + + this._overlayClosedSub = this.overlayService + .closed + .pipe(...this._overlaySubFilter) + .subscribe(this.overlayClosed); + } + + private unsubscribe() { + this.clearSubscription(this._overlayOpenedSub); + this.clearSubscription(this._overlayClosingSub); + this.clearSubscription(this._overlayClosedSub); + this.clearSubscription(this._overlayContentAppendedSub); + } + + private clearSubscription(subscription: Subscription) { + if (subscription && !subscription.closed) { + subscription.unsubscribe(); + } + } +} + +@Directive({ + exportAs: 'toggle-action', + selector: '[igxToggleAction]', + standalone: true +}) +export class IgxToggleActionDirective implements OnInit { + protected element = inject(ElementRef); + protected navigationService = inject(IgxNavigationService, { optional: true }); + + /** + * Provide settings that control the toggle overlay positioning, interaction and scroll behavior. + * ```typescript + * const settings: OverlaySettings = { + * closeOnOutsideClick: false, + * modal: false + * } + * ``` + * --- + * ```html + * + *
    + * ``` + */ + @Input() + public overlaySettings: OverlaySettings; + + /** + * Determines where the toggle element overlay should be attached. + * + * ```html + * + *
    + * ``` + * Where `outlet` in an instance of `IgxOverlayOutletDirective` or an `ElementRef` + */ + @Input('igxToggleOutlet') + public outlet: IgxOverlayOutletDirective | ElementRef; + + /** + * @hidden + */ + @Input('igxToggleAction') + public set target(target: any) { + if (target !== null && target !== '') { + this._target = target; + } + } + + /** + * @hidden + */ + public get target(): any { + if (typeof this._target === 'string') { + return this.navigationService.get(this._target); + } + return this._target; + } + + protected _overlayDefaults: OverlaySettings; + protected _target: IToggleView | string; + + /** + * @hidden + */ + @HostListener('click') + public onClick() { + if (this.outlet) { + this._overlayDefaults.outlet = this.outlet; + } + + const clonedSettings = Object.assign({}, this._overlayDefaults, this.overlaySettings); + this.updateOverlaySettings(clonedSettings); + this.target.toggle(clonedSettings); + } + + /** + * @hidden + */ + public ngOnInit() { + const targetElement = this.element.nativeElement; + this._overlayDefaults = { + target: targetElement, + positionStrategy: new ConnectedPositioningStrategy(), + scrollStrategy: new AbsoluteScrollStrategy(), + closeOnOutsideClick: true, + modal: false, + excludeFromOutsideClick: [targetElement as HTMLElement] + }; + } + + /** + * Updates provided overlay settings + * + * @param settings settings to update + * @returns returns updated copy of provided overlay settings + */ + protected updateOverlaySettings(settings: OverlaySettings): OverlaySettings { + if (settings && settings.positionStrategy) { + const positionStrategyClone: IPositionStrategy = settings.positionStrategy.clone(); + settings.target = this.element.nativeElement; + settings.positionStrategy = positionStrategyClone; + } + + return settings; + } +} diff --git a/projects/igniteui-angular/directives/src/directives/toggle/toggle.module.ts b/projects/igniteui-angular/directives/src/directives/toggle/toggle.module.ts new file mode 100644 index 00000000000..dbc4a79d4d5 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/toggle/toggle.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { IgxToggleActionDirective, IgxToggleDirective } from './toggle.directive'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [IgxToggleDirective, IgxToggleActionDirective], + exports: [IgxToggleDirective, IgxToggleActionDirective] +}) +export class IgxToggleModule { } diff --git a/projects/igniteui-angular/directives/src/directives/tooltip/README.md b/projects/igniteui-angular/directives/src/directives/tooltip/README.md new file mode 100644 index 00000000000..90684e30b8e --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/tooltip/README.md @@ -0,0 +1,198 @@ +# Igx-Tooltip + +#### Category +_Directives_ + +## Description +The **IgxTooltip** directive provides us a way to make a given element a tooltip. Then we can assign it to be a tooltip for another element (for example a button) by using the **IgxTooltipTarget** directive. +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/tooltip). + +## Usage +First we will have to import the IgxTooltipModule. +```typescript +import { IgxTooltipModule } from "igniteui-angular"; +``` + +- The **IgxTooltip** directive is used to make a given element a tooltip. (exported with the name **tooltip**) This directive extends the **IgxToggle** directive and shares its functionality, since the tooltip is basically a togglable element. +- The **IgxTooltipTarget** directive is used to mark an element as one that has a tooltip. (exported with the name **tooltipTarget**) This directive extends the **IgxToggleAction** directive and shares most of its functionality as well as adding some of its own (for example the hover/unhover behavior which is tooltip specific). + +By exporting the IgxTooltip directive and assigning it to the IgxTooltipTarget property, we assign the tooltip to a specific element. + + +### Simple tooltip + +Let's say we have a button and we would like it to have a tooltip that provides some additional text information. +```html + + +
    + Hello there, this is a tooltip! +
    +``` + +### Content rich tooltip + +Since the tooltip itself is a simple DOM element, we can inject whatever content we want inside of it and it will be displayed as an ordinary tooltip. + +```html + + +
    +
    tooltip's header.
    + +
    tooltip's footer
    +
    +``` + +## Configuration + +### Delay settings +The **IgxTooltipTarget** directive exposes `showDelay` and `hideDelay` inputs, which can be used to set the amount of time (in milliseconds) that has to pass before showing and hiding the tooltip respectively. + +```html + + +
    + Hello there, this is a tooltip! +
    +``` + +### Manually showing and hiding the tooltip +While the tooltip's default behavior is to show when its target is hovered and hide when its target is unhovered, we can also do this manually by using the `showTooltip` and the `hideTooltip` methods of the IgxTooltipTarget directive. + +```html + + + + + +
    + Hello there, this is a tooltip! +
    +``` + +## API Summary + +## IgxTooltipDirective + +### Properties +| Name | Type | Description | +| :--- |:--- | :--- | +| context | any | Specifies the context of the tooltip. (Used to store and access any tooltip related data.) | + +Since the **IgxTooltip** directive extends the **IgxToggle** directive and there is no specific functionality it adds apart from some styling classes and attributes in combination with the properties from above, you can refer to the [IgxToggle API](https://github.com/IgniteUI/igniteui-angular/blob/master/projects/igniteui-angular/src/lib/directives/toggle/README.md) for additional details. + +## IgxTooltipTargetDirective + +### Properties +| Name | Type | Description | +| :--- |:--- | :--- | +| showDelay | number | Specifies the amount of milliseconds that should pass before showing the tooltip. | +| hideDelay | number | Specifies the amount of milliseconds that should pass before hiding the tooltip. | +| 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. | +| 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/directives/src/directives/tooltip/public_api.ts b/projects/igniteui-angular/directives/src/directives/tooltip/public_api.ts new file mode 100644 index 00000000000..787c162c2f9 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/tooltip/public_api.ts @@ -0,0 +1,12 @@ +import { IgxTooltipTargetDirective } from './tooltip-target.directive'; +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 = [ + IgxTooltipDirective, + IgxTooltipTargetDirective +] as const; diff --git a/projects/igniteui-angular/directives/src/directives/tooltip/tooltip-close-button.component.ts b/projects/igniteui-angular/directives/src/directives/tooltip/tooltip-close-button.component.ts new file mode 100644 index 00000000000..eb870b4d787 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/tooltip/tooltip-close-button.component.ts @@ -0,0 +1,27 @@ +import { Component, Output, EventEmitter, HostListener, Input, TemplateRef } from '@angular/core'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'igx-tooltip-close-button', + template: ` + @if (customTemplate) { + + } @else { + + } + `, + 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/directives/src/directives/tooltip/tooltip-target.directive.ts b/projects/igniteui-angular/directives/src/directives/tooltip/tooltip-target.directive.ts new file mode 100644 index 00000000000..fb6d03c3b76 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/tooltip/tooltip-target.directive.ts @@ -0,0 +1,640 @@ +import { + Directive, OnInit, OnDestroy, Output, ViewContainerRef, HostListener, + Input, EventEmitter, booleanAttribute, inject, TemplateRef, ComponentRef, Renderer2, + EnvironmentInjector, + createComponent, + AfterViewInit, +} from '@angular/core'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { IBaseEventArgs } from 'igniteui-angular/core'; +import { PositionSettings } from 'igniteui-angular/core'; +import { IgxToggleActionDirective } from '../toggle/toggle.directive'; +import { IgxTooltipComponent } from './tooltip.component'; +import { IgxTooltipDirective } from './tooltip.directive'; +import { IgxTooltipCloseButtonComponent } from './tooltip-close-button.component'; +import { TooltipPositionSettings, TooltipPositionStrategy } from './tooltip.common'; + +export interface ITooltipShowEventArgs extends IBaseEventArgs { + target: IgxTooltipTargetDirective; + tooltip: IgxTooltipDirective; + cancel: boolean; +} +export interface ITooltipHideEventArgs extends IBaseEventArgs { + target: IgxTooltipTargetDirective; + tooltip: IgxTooltipDirective; + cancel: boolean; +} + +/** + * **Ignite UI for Angular Tooltip Target** - + * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/tooltip) + * + * The Ignite UI for Angular Tooltip Target directive is used to mark an HTML element in the markup as one that has a tooltip. + * The tooltip target is used in combination with the Ignite UI for Angular Tooltip by assigning the exported tooltip reference to the + * target's selector property. + * + * Example: + * ```html + * + * Hello there, I am a tooltip! + * ``` + */ +@Directive({ + exportAs: 'tooltipTarget', + selector: '[igxTooltipTarget]', + standalone: true +}) +export class IgxTooltipTargetDirective extends IgxToggleActionDirective implements OnInit, AfterViewInit, OnDestroy { + private _viewContainerRef = inject(ViewContainerRef); + private _renderer = inject(Renderer2); + private _envInjector = inject(EnvironmentInjector); + + /** + * Gets/sets the amount of milliseconds that should pass before showing the tooltip. + * + * ```typescript + * // get + * let tooltipShowDelay = this.tooltipTarget.showDelay; + * ``` + * + * ```html + * + * + * Hello there, I am a tooltip! + * ``` + */ + @Input() + public showDelay = 200; + + /** + * Gets/sets the amount of milliseconds that should pass before hiding the tooltip. + * + * ```typescript + * // get + * let tooltipHideDelay = this.tooltipTarget.hideDelay; + * ``` + * + * ```html + * + * + * Hello there, I am a tooltip! + * ``` + */ + @Input() + 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) { + 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) + * While setting this property to 'true' will disable the user interactions that shows/hides the tooltip, + * the developer will still be able to show/hide the tooltip through the API. + * + * ```typescript + * // get + * let tooltipDisabledValue = this.tooltipTarget.tooltipDisabled; + * ``` + * + * ```html + * + * + * Hello there, I am a tooltip! + * ``` + */ + @Input({ transform: booleanAttribute }) + public tooltipDisabled = false; + + /** + * @hidden + */ + @Input('igxTooltipTarget') + public override set target(target: any) { + if (target instanceof IgxTooltipDirective) { + this._target = target; + } + } + + /** + * @hidden + */ + public override get target(): any { + if (typeof this._target === 'string') { + return this.navigationService.get(this._target); + } + return this._target; + } + + /** + * @hidden + */ + @Input() + public set tooltip(content: any) { + if (!this.target && (typeof content === 'string' || content instanceof String)) { + const tooltipComponent = this._viewContainerRef.createComponent(IgxTooltipComponent); + tooltipComponent.instance.content = content as string; + + this._target = tooltipComponent.instance.tooltip; + } + } + + /** + * Gets the respective native element of the directive. + * + * ```typescript + * let tooltipTargetElement = this.tooltipTarget.nativeElement; + * ``` + */ + public get nativeElement() { + return this.element.nativeElement; + } + + /** + * Indicates if the tooltip that is is associated with this target is currently hidden. + * + * ```typescript + * let tooltipHiddenValue = this.tooltipTarget.tooltipHidden; + * ``` + */ + public get tooltipHidden(): boolean { + return !this.target || this.target.collapsed; + } + + /** + * Emits an event when the tooltip that is associated with this target starts showing. + * This event is fired before the start of the countdown to showing the tooltip. + * + * ```typescript + * tooltipShowing(args: ITooltipShowEventArgs) { + * alert("Tooltip started showing!"); + * } + * ``` + * + * ```html + * + * Hello there, I am a tooltip! + * ``` + */ + @Output() + public tooltipShow = new EventEmitter(); + + /** + * Emits an event when the tooltip that is associated with this target starts hiding. + * This event is fired before the start of the countdown to hiding the tooltip. + * + * ```typescript + * tooltipHiding(args: ITooltipHideEventArgs) { + * alert("Tooltip started hiding!"); + * } + * ``` + * + * ```html + * + * Hello there, I am a tooltip! + * ``` + */ + @Output() + public tooltipHide = new EventEmitter(); + + 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; + + /** + * @hidden + */ + @HostListener('click') + public override onClick() { + if (!this.target.collapsed) { + this._hideOnInteraction(); + } else if (this.target.timeoutId) { + clearTimeout(this.target.timeoutId); + this.target.timeoutId = null; + } + } + + /** + * @hidden + */ + @HostListener('mouseenter') + public onMouseEnter() { + this._checksBeforeShowing(() => this._showOnInteraction()); + } + + /** + * @hidden + */ + @HostListener('mouseleave') + public onMouseLeave() { + if (this.tooltipDisabled) { + return; + } + + this._checkOutletAndOutsideClick(); + this._hideOnInteraction(); + } + + /** + * @hidden + */ + public onTouchStart() { + this._checksBeforeShowing(() => this._showOnInteraction()); + } + + /** + * @hidden + */ + public onDocumentTouchStart(event) { + if (this.tooltipDisabled || this?.target?.tooltipTarget !== this) { + return; + } + + if (this.nativeElement !== event.target && + !this.nativeElement.contains(event.target) + ) { + this._hideOnInteraction(); + } + } + + /** + * @hidden + */ + public override ngOnInit() { + super.ngOnInit(); + + this._overlayDefaults.positionStrategy = new TooltipPositionStrategy(this._positionSettings); + this._overlayDefaults.closeOnOutsideClick = false; + this._overlayDefaults.closeOnEscape = true; + + this.target.closing.pipe(takeUntil(this._destroy$)).subscribe((event) => { + if (this.target.tooltipTarget !== this) { + return; + } + + const hidingArgs = { target: this, tooltip: this.target, cancel: false }; + this.tooltipHide.emit(hidingArgs); + + if (hidingArgs.cancel) { + event.cancel = true; + } + }); + + this.nativeElement.addEventListener('touchstart', this.onTouchStart = this.onTouchStart.bind(this), { passive: true }); + } + + /** + * @hidden + */ + public ngAfterViewInit(): void { + if (this.target && this.target.arrow) { + this.target.arrow.style.display = this.hasArrow ? '' : 'none'; + } + } + + /** + * @hidden + */ + public ngOnDestroy() { + this.hideTooltip(); + this.nativeElement.removeEventListener('touchstart', this.onTouchStart); + this._destroyCloseButton(); + this._destroy$.next(); + this._destroy$.complete(); + } + + /** + * Shows the tooltip if not already shown. + * + * ```typescript + * this.tooltipTarget.showTooltip(); + * ``` + */ + public showTooltip() { + this._checksBeforeShowing(() => this._showTooltip(false, true)); + } + + /** + * Hides the tooltip if not already hidden. + * + * ```typescript + * this.tooltipTarget.hideTooltip(); + * ``` + */ + public hideTooltip() { + this._hideTooltip(false); + } + + private get _mergedOverlaySettings() { + return Object.assign({}, this._overlayDefaults, this.overlaySettings); + } + + private _checkOutletAndOutsideClick(): void { + if (this.outlet) { + this._overlayDefaults.outlet = this.outlet; + } + } + + /** + * 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.timeoutId = setTimeout(() => { + // Call close() of IgxTooltipDirective + this.target.close(); + }, withDelay ? this.hideDelay : 0); + } + + 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 _showOnInteraction(): void { + this._stopTimeoutAndAnimation(); + this._showTooltip(true, true); + } + + private _hideOnInteraction(): void { + if (this.target?.tooltipTarget?.sticky) { + return; + } + + 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. + */ + 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(); + } + + // If the tooltip is shown for one target and the user interacts with another target, + // the tooltip is instantly hidden for the first target. + clearTimeout(this.target.timeoutId); + this.target.forceClose(this._mergedOverlaySettings); + + this.target.tooltipTarget = this; + this._isForceClosed = true; + } + } + + /** + * 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(); + } + } + } + + /** + * 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; + } + } + + /** + * 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" + } + } + + /** + * 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/directives/src/directives/tooltip/tooltip.common.ts b/projects/igniteui-angular/directives/src/directives/tooltip/tooltip.common.ts new file mode 100644 index 00000000000..a37ed61f72e --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/tooltip/tooltip.common.ts @@ -0,0 +1,332 @@ +import { first } from 'igniteui-angular/core'; +import { AutoPositionStrategy } from 'igniteui-angular/core'; +import { ConnectedFit, HorizontalAlignment, Point, PositionSettings, Size, VerticalAlignment } from 'igniteui-angular/core'; +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/directives/src/directives/tooltip/tooltip.component.html b/projects/igniteui-angular/directives/src/directives/tooltip/tooltip.component.html new file mode 100644 index 00000000000..d1e984f1251 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/tooltip/tooltip.component.html @@ -0,0 +1 @@ +{{content}} diff --git a/projects/igniteui-angular/directives/src/directives/tooltip/tooltip.component.ts b/projects/igniteui-angular/directives/src/directives/tooltip/tooltip.component.ts new file mode 100644 index 00000000000..a049fc55dc6 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/tooltip/tooltip.component.ts @@ -0,0 +1,16 @@ +import { Component, ViewChild } from '@angular/core'; +import { IgxTooltipDirective } from './tooltip.directive'; + +@Component({ + selector: 'igx-tooltip', + templateUrl: 'tooltip.component.html', + imports: [IgxTooltipDirective] +}) + +export class IgxTooltipComponent { + + @ViewChild(IgxTooltipDirective, { static: true }) + public tooltip: IgxTooltipDirective; + + public content: string; +} \ No newline at end of file diff --git a/projects/igniteui-angular/directives/src/directives/tooltip/tooltip.directive.spec.ts b/projects/igniteui-angular/directives/src/directives/tooltip/tooltip.directive.spec.ts new file mode 100644 index 00000000000..4ed7795d0b3 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/tooltip/tooltip.directive.spec.ts @@ -0,0 +1,1206 @@ +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, IgxTooltipWithCloseButtonComponent, IgxTooltipWithNestedContentComponent } from '../../../../test-utils/tooltip-components.spec'; +import { UIInteractions } from '../../../../test-utils/ui-interactions.spec'; +import { HorizontalAlignment, VerticalAlignment, AutoPositionStrategy } from '../../../../core/src/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; + let tooltipNativeElement: HTMLElement; + let tooltipTarget: IgxTooltipTargetDirective; + let button: DebugElement; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxTooltipSingleTargetComponent, + IgxTooltipMultipleTargetsComponent, + IgxTooltipPlainStringComponent, + IgxTooltipWithToggleActionComponent, + IgxTooltipWithCloseButtonComponent, + IgxTooltipWithNestedContentComponent + ] + }).compileComponents(); + UIInteractions.clearOverlay(); + })); + + afterEach(() => { + UIInteractions.clearOverlay(); + }); + + describe('Single target with single tooltip', () => { + 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)); + })); + + it('IgxTooltipTargetDirective default values', () => { + expect(tooltipTarget.showDelay).toBe(200); + expect(tooltipTarget.hideDelay).toBe(300); + expect(tooltipTarget.tooltipDisabled).toBe(false); + expect(tooltipTarget.overlaySettings).toBeUndefined(); + }); + + it('IgxTooltipTargetDirective updated values', () => { + tooltipTarget.showDelay = 740; + fix.detectChanges(); + expect(tooltipTarget.showDelay).toBe(740); + + tooltipTarget.hideDelay = 725; + fix.detectChanges(); + expect(tooltipTarget.hideDelay).toBe(725); + + tooltipTarget.tooltipDisabled = true; + fix.detectChanges(); + expect(tooltipTarget.tooltipDisabled).toBe(true); + }); + + it('IgxTooltip is initially hidden', () => { + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); + }); + + it('IgxTooltip is shown/hidden when hovering/unhovering its target', fakeAsync(() => { + hoverElement(button); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + + unhoverElement(button); + flush(); + + 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(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + expect(tooltipNativeElement.textContent.trim()).toEqual('Hello, I am a tooltip!'); + })); + + it('verify tooltip default position', fakeAsync(() => { + hoverElement(button); + flush(); + verifyTooltipPosition(tooltipNativeElement, button); + })); + + it('IgxTooltip is not shown when is disabled and hovering its target', fakeAsync(() => { + tooltipTarget.tooltipDisabled = true; + fix.detectChanges(); + + hoverElement(button); + flush(); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); + + tooltipTarget.tooltipDisabled = false; + fix.detectChanges(); + + hoverElement(button); + flush(); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + })); + + it('IgxTooltip mouse interaction respects showDelay', fakeAsync(() => { + tooltipTarget.showDelay = 900; + fix.detectChanges(); + + hoverElement(button); + + tick(500); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); + + tick(300); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); + + tick(100); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + })); + + it('IgxTooltip mouse interaction respects hideDelay', fakeAsync(() => { + tooltipTarget.hideDelay = 700; + fix.detectChanges(); + + hoverElement(button); + flush(); + + unhoverElement(button); + tick(HIDE_DELAY); + tick(400); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + + tick(100); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + + tick(200); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); + })); + + it('IgxTooltip is shown/hidden when invoking respective API methods', fakeAsync(() => { + tooltipTarget.showTooltip(); + flush(); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + + tooltipTarget.hideTooltip(); + flush(); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); + })); + + it('showing tooltip through API does NOT respect showDelay', fakeAsync(() => { + tooltipTarget.showDelay = 400; + fix.detectChanges(); + + tooltipTarget.showTooltip(); + + tick(300); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + })); + + it('hiding tooltip through API does NOT respect hideDelay', fakeAsync(() => { + tooltipTarget.hideDelay = 450; + fix.detectChanges(); + + tooltipTarget.showTooltip(); + flush(); + + tooltipTarget.hideTooltip(); + + tick(400); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); + })); + + it('IgxTooltip respects the passed overlaySettings', fakeAsync(() => { + // Hover the button. + hoverElement(button); + flush(); + // Verify default position of the tooltip. + verifyTooltipPosition(tooltipNativeElement, button); + unhoverElement(button); + flush(); + + // Use custom overlaySettings. + tooltipTarget.overlaySettings = /**/ { + target: tooltipTarget.nativeElement, + positionStrategy: new AutoPositionStrategy({ + horizontalStartPoint: HorizontalAlignment.Right, + verticalStartPoint: VerticalAlignment.Bottom, + horizontalDirection: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Bottom + }) + }; + fix.detectChanges(); + + // Hover the button again. + hoverElement(button); + flush(); + // Verify that the position of the tooltip is changed. + verifyTooltipPosition(tooltipNativeElement, button, false); + const targetRect = tooltipTarget.nativeElement.getBoundingClientRect(); + const tooltipRect = tooltipNativeElement.getBoundingClientRect(); + expect(Math.abs(tooltipRect.top - targetRect.bottom) <= 0.5).toBe(true); + expect(Math.abs(tooltipRect.left - targetRect.right) <= 0.5).toBe(true); + unhoverElement(button); + flush(); + })); + + it('IgxTooltip closes when the target is clicked', fakeAsync(() => { + hoverElement(button); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + + UIInteractions.simulateClickAndSelectEvent(button); + flush(); + + 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(); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + + UIInteractions.triggerKeyDownEvtUponElem('Escape', document.documentElement); + + flush(); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); + })); + + it('IgxTooltip is hidden when its target is destroyed', fakeAsync(() => { + hoverElement(button); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + + fix.componentInstance.showButton = false; + fix.detectChanges(); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); + })); + + describe('Tooltip events', () => { + it('should emit the proper events when hovering/unhovering target', fakeAsync(() => { + spyOn(tooltipTarget.tooltipShow, 'emit'); + spyOn(tooltipTarget.tooltipHide, 'emit'); + + hoverElement(button); + expect(tooltipTarget.tooltipShow.emit).toHaveBeenCalled(); + flush(); + + unhoverElement(button); + tick(500); + expect(tooltipTarget.tooltipHide.emit).toHaveBeenCalled(); + flush(); + })); + + it('should emit the proper events when showing/hiding tooltip through API', fakeAsync(() => { + spyOn(tooltipTarget.tooltipShow, 'emit'); + spyOn(tooltipTarget.tooltipHide, 'emit'); + + tooltipTarget.showTooltip(); + expect(tooltipTarget.tooltipShow.emit).toHaveBeenCalled(); + flush(); + + tooltipTarget.hideTooltip(); + tick(500); + expect(tooltipTarget.tooltipHide.emit).toHaveBeenCalled(); + flush(); + })); + + it('should emit the proper events with correct eventArgs when hover/unhover', fakeAsync(() => { + spyOn(tooltipTarget.tooltipShow, 'emit'); + spyOn(tooltipTarget.tooltipHide, 'emit'); + + const tooltipShowArgs = { target: tooltipTarget, tooltip: fix.componentInstance.tooltip, cancel: false }; + const tooltipHideArgs = { target: tooltipTarget, tooltip: fix.componentInstance.tooltip, cancel: false }; + + hoverElement(button); + expect(tooltipTarget.tooltipShow.emit).toHaveBeenCalledWith(tooltipShowArgs); + flush(); + + unhoverElement(button); + tick(500); + expect(tooltipTarget.tooltipHide.emit).toHaveBeenCalledWith(tooltipHideArgs); + flush(); + })); + + it('should emit the proper events with correct eventArgs when show/hide through API', fakeAsync(() => { + spyOn(tooltipTarget.tooltipShow, 'emit'); + spyOn(tooltipTarget.tooltipHide, 'emit'); + + const tooltipShowArgs = { target: tooltipTarget, tooltip: fix.componentInstance.tooltip, cancel: false }; + const tooltipHideArgs = { target: tooltipTarget, tooltip: fix.componentInstance.tooltip, cancel: false }; + + tooltipTarget.showTooltip(); + expect(tooltipTarget.tooltipShow.emit).toHaveBeenCalledWith(tooltipShowArgs); + flush(); + + tooltipTarget.hideTooltip(); + tick(500); + expect(tooltipTarget.tooltipHide.emit).toHaveBeenCalledWith(tooltipHideArgs); + flush(); + })); + + it('should cancel the showing event when hover', fakeAsync(() => { + fix.componentInstance.cancelShowing = true; + + hoverElement(button); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); + })); + + it('should cancel the hiding event when unhover', fakeAsync(() => { + fix.componentInstance.cancelHiding = true; + + hoverElement(button); + flush(); + + unhoverElement(button); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + })); + + it('should cancel the showing event when show through API', fakeAsync(() => { + fix.componentInstance.cancelShowing = true; + + tooltipTarget.showTooltip(); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); + })); + + it('should cancel the hiding event when hide through API', fakeAsync(() => { + fix.componentInstance.cancelHiding = true; + + tooltipTarget.showTooltip(); + flush(); + + tooltipTarget.hideTooltip(); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + })); + }); + + describe('Tooltip touch', () => { + it('IgxTooltip is shown/hidden when touching/untouching its target', fakeAsync(() => { + touchElement(button); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + + const dummyDiv = fix.debugElement.query(By.css('.dummyDiv')); + touchElement(dummyDiv); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); + })); + + it('IgxTooltip is not shown when is disabled and touching its target', fakeAsync(() => { + tooltipTarget.tooltipDisabled = true; + fix.detectChanges(); + + touchElement(button); + flush(); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); + + tooltipTarget.tooltipDisabled = false; + fix.detectChanges(); + + touchElement(button); + flush(); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + })); + + it('IgxTooltip touch interaction respects showDelay', fakeAsync(() => { + tooltipTarget.showDelay = 900; + fix.detectChanges(); + + touchElement(button); + + tick(500); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); + + tick(300); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); + + tick(100); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + })); + + it('IgxTooltip touch interaction respects hideDelay', fakeAsync(() => { + tooltipTarget.hideDelay = 700; + fix.detectChanges(); + + touchElement(button); + flush(); + + const dummyDiv = fix.debugElement.query(By.css('.dummyDiv')); + touchElement(dummyDiv); + tick(HIDE_DELAY); + tick(400); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + + tick(100); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + + tick(200); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); + })); + }); + }); + + describe('Plain string tooltip input', () => { + beforeEach(waitForAsync(() => { + fix = TestBed.createComponent(IgxTooltipPlainStringComponent); + fix.detectChanges(); + button = fix.debugElement.query(By.directive(IgxTooltipTargetDirective)); + tooltipTarget = fix.componentInstance.tooltipTarget; + tooltipNativeElement = fix.debugElement.query(By.directive(IgxTooltipDirective)).nativeElement; + })); + + it('IgxTooltip is initially hidden', fakeAsync(() => { + unhoverElement(button); + flush(); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); + })); + + it('IgxTooltip is shown/hidden when hovering/unhovering its target', fakeAsync(() => { + hoverElement(button); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + + unhoverElement(button); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); + })); + + it('Should respect default max-width constraint for plain string tooltip', fakeAsync(() => { + hoverElement(button); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + + const maxWidth = getComputedStyle(tooltipNativeElement).maxWidth; + expect(maxWidth).toBe('200px'); + })); + }); + + describe('Custom content tooltip', () => { + beforeEach(waitForAsync(() => { + fix = TestBed.createComponent(IgxTooltipWithNestedContentComponent); + fix.detectChanges(); + button = fix.debugElement.query(By.directive(IgxTooltipTargetDirective)); + tooltipTarget = fix.componentInstance.tooltipTarget; + tooltipNativeElement = fix.debugElement.query(By.directive(IgxTooltipDirective)).nativeElement; + })); + + it('Should not have max-width constraint for custom content tooltip', fakeAsync(() => { + hoverElement(button); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + + const maxWidth = getComputedStyle(tooltipNativeElement).maxWidth; + expect(maxWidth).toBe('none'); + })); + }); + + describe('Multiple targets with single tooltip', () => { + let targetOne: IgxTooltipTargetDirective; + let targetTwo: IgxTooltipTargetDirective; + let buttonOne; + let buttonTwo; + + beforeEach(waitForAsync(() => { + fix = TestBed.createComponent(IgxTooltipMultipleTargetsComponent); + fix.detectChanges(); + tooltipNativeElement = fix.debugElement.query(By.directive(IgxTooltipDirective)).nativeElement; + targetOne = fix.componentInstance.targetOne; + targetTwo = fix.componentInstance.targetTwo; + buttonOne = fix.debugElement.query(By.css('.buttonOne')); + buttonTwo = fix.debugElement.query(By.css('.buttonTwo')); + })); + + it('Same tooltip shows on different targets depending on which target is hovered', fakeAsync(() => { + hoverElement(buttonOne); + flush(); + + // Tooltip is positioned relative to buttonOne and NOT relative to buttonTwo + verifyTooltipVisibility(tooltipNativeElement, targetOne, true); + verifyTooltipPosition(tooltipNativeElement, buttonOne); + verifyTooltipPosition(tooltipNativeElement, buttonTwo, false); + + unhoverElement(buttonOne); + flush(); + hoverElement(buttonTwo); + flush(); + + // Tooltip is positioned relative to buttonTwo and NOT relative to buttonOne + verifyTooltipVisibility(tooltipNativeElement, targetTwo, true); + verifyTooltipPosition(tooltipNativeElement, buttonTwo); + verifyTooltipPosition(tooltipNativeElement, buttonOne, false); + })); + + it('Same tooltip shows on a second target when hovering it without closing from first target\'s logic', fakeAsync(() => { + targetOne.hideDelay = 700; + fix.detectChanges(); + + hoverElement(buttonOne); + flush(); + + unhoverElement(buttonOne); + tick(300); + hoverElement(buttonTwo); + tick(500); + + // 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); + verifyTooltipPosition(tooltipNativeElement, buttonOne, false); + flush(); + })); + + it('Should position relative to its target when having no close animation - #16288', fakeAsync(() => { + targetOne.positionSettings = targetTwo.positionSettings = { + openAnimation: undefined, + closeAnimation: undefined + }; + fix.detectChanges(); + + hoverElement(buttonOne); + tick(targetOne.showDelay); + + verifyTooltipVisibility(tooltipNativeElement, targetOne, true); + verifyTooltipPosition(tooltipNativeElement, buttonOne, true); + + unhoverElement(buttonOne); + + hoverElement(buttonTwo); + tick(targetTwo.showDelay); + + // Tooltip is visible and positioned relative to buttonTwo + verifyTooltipVisibility(tooltipNativeElement, targetTwo, true); + verifyTooltipPosition(tooltipNativeElement, buttonTwo); + // Tooltip is NOT visible and positioned relative to buttonOne + verifyTooltipPosition(tooltipNativeElement, buttonOne, false); + })); + + it('Hovering first target briefly and then hovering second target leads to tooltip showing for second target', fakeAsync(() => { + targetOne.showDelay = 600; + fix.detectChanges(); + + hoverElement(buttonOne); + tick(400); + + verifyTooltipVisibility(tooltipNativeElement, targetOne, false); + verifyTooltipPosition(tooltipNativeElement, buttonOne, false); + + unhoverElement(buttonOne); + tick(100); + + hoverElement(buttonTwo); + flush(); + + // Tooltip is visible and positioned relative to buttonTwo + verifyTooltipVisibility(tooltipNativeElement, targetTwo, 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, '_hideOnInteraction').and.callThrough(); + spyOn(targetTwo, '_hideOnInteraction').and.callThrough(); + + touchElement(buttonOne); + tick(500); + + const dummyDiv = fix.debugElement.query(By.css('.dummyDiv')); + touchElement(dummyDiv); + flush(); + + expect(targetOne['_hideOnInteraction']).toHaveBeenCalledTimes(1); + expect(targetTwo['_hideOnInteraction']).not.toHaveBeenCalled(); + })); + + it('should not emit tooltipHide event multiple times', fakeAsync(() => { + spyOn(targetOne.tooltipHide, 'emit'); + spyOn(targetTwo.tooltipHide, 'emit'); + + hoverElement(buttonOne); + flush(); + + const tooltipHideArgsTargetOne = { target: targetOne, tooltip: fix.componentInstance.tooltip, cancel: false }; + const tooltipHideArgsTargetTwo = { target: targetTwo, tooltip: fix.componentInstance.tooltip, cancel: false }; + + unhoverElement(buttonOne); + tick(500); + expect(targetOne.tooltipHide.emit).toHaveBeenCalledOnceWith(tooltipHideArgsTargetOne); + expect(targetTwo.tooltipHide.emit).not.toHaveBeenCalled(); + flush(); + + hoverElement(buttonTwo); + flush(); + + unhoverElement(buttonTwo); + tick(500); + expect(targetOne.tooltipHide.emit).toHaveBeenCalledOnceWith(tooltipHideArgsTargetOne); + expect(targetTwo.tooltipHide.emit).toHaveBeenCalledOnceWith(tooltipHideArgsTargetTwo); + flush(); + })); + + + it('IgxTooltip hides when touch one target, then another, then outside', fakeAsync(() => { + touchElement(targetOne); + flush(); + verifyTooltipVisibility(tooltipNativeElement, targetOne, true); + verifyTooltipPosition(tooltipNativeElement, targetOne, true); + + touchElement(targetTwo); + flush(); + verifyTooltipVisibility(tooltipNativeElement, targetTwo, true); + verifyTooltipPosition(tooltipNativeElement, targetTwo, true); + + touchElement(fix.debugElement); + 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', () => { + let targetOne: IgxTooltipTargetDirective; + + let tooltipOne: IgxTooltipDirective; + let tooltipTwo: IgxTooltipDirective; + + beforeEach(waitForAsync(() => { + fix = TestBed.createComponent(IgxTooltipMultipleTooltipsComponent); + fix.detectChanges(); + targetOne = fix.componentInstance.targetOne; + tooltipOne = fix.componentInstance.tooltipOne; + tooltipTwo = fix.componentInstance.tooltipTwo; + })); + + it('should not add multiple document:touchstart event listeners when having multiple igxTooltip instances - #16100', fakeAsync(() => { + spyOn(tooltipOne, 'onDocumentTouchStart').and.callThrough(); + spyOn(tooltipTwo, 'onDocumentTouchStart').and.callThrough(); + + touchElement(targetOne); + tick(500); + + const dummyDiv = fix.debugElement.query(By.css('.dummyDiv')); + touchElement(dummyDiv); + flush(); + + expect(tooltipOne['onDocumentTouchStart']).toHaveBeenCalledTimes(1); + expect(tooltipTwo['onDocumentTouchStart']).not.toHaveBeenCalled(); + })); + }); + + describe('Tooltip integration', () => { + beforeEach(waitForAsync(() => { + fix = TestBed.createComponent(IgxTooltipWithToggleActionComponent); + fix.detectChanges(); + tooltipNativeElement = fix.debugElement.query(By.directive(IgxTooltipDirective)).nativeElement; + tooltipTarget = fix.componentInstance.tooltipTarget as IgxTooltipTargetDirective; + button = fix.debugElement.query(By.directive(IgxTooltipTargetDirective)); + })); + + it('Correctly sets tooltip target when defined before igxToggleAction directive on same host - issue #14196', fakeAsync(() => { + expect(tooltipTarget.target.element).toBe(tooltipNativeElement); + expect(fix.componentInstance.toggleDir.collapsed).toBe(true); + + hoverElement(button); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + + UIInteractions.simulateClickEvent(button.nativeElement); + tick(HIDE_DELAY); + tick(300); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); + + UIInteractions.simulateClickEvent(button.nativeElement); + fix.detectChanges(); + + 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 { + nativeElement: HTMLElement +} + +const hoverElement = (element: ElementRefLike) => element.nativeElement.dispatchEvent(new MouseEvent('mouseenter')); + +const unhoverElement = (element: ElementRefLike) => element.nativeElement.dispatchEvent(new MouseEvent('mouseleave')); + +const touchElement = (element: ElementRefLike) => element.nativeElement.dispatchEvent(new TouchEvent('touchstart', { bubbles: true })); + +const verifyTooltipVisibility = (tooltipNativeElement, tooltipTarget, shouldBeVisible: boolean) => { + expect(tooltipNativeElement.classList.contains(TOOLTIP_CLASS)).toBe(shouldBeVisible); + expect(tooltipNativeElement.classList.contains(HIDDEN_TOOLTIP_CLASS)).not.toBe(shouldBeVisible); + expect(tooltipTarget?.tooltipHidden).not.toBe(shouldBeVisible); +}; + +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; + } + + + // --- alignment check --- + if (placement.startsWith('top') || placement.startsWith('bottom')) { + alignmentCheckPassed = horizontalAlignmentMatches(tooltip, target, placement); + } else { + alignmentCheckPassed = verticalAlignmentMatches(tooltip, target, placement); + } + + const result = directionCheckPassed && alignmentCheckPassed; + + if (shouldAlign) { + expect(result).toBeTruthy( + `Tooltip misaligned for "${placement}": actual offset=${actualOffset}, wanted offset=${offset}, accurate placement=${directionCheckPassed}, accurate alignment=${alignmentCheckPassed}` + ); + } else { + 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/directives/src/directives/tooltip/tooltip.directive.ts b/projects/igniteui-angular/directives/src/directives/tooltip/tooltip.directive.ts new file mode 100644 index 00000000000..626dab2ad25 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/tooltip/tooltip.directive.ts @@ -0,0 +1,229 @@ +import { + Directive, Input, HostBinding, + OnDestroy, inject, DOCUMENT, HostListener, + Renderer2, + AfterViewInit, +} from '@angular/core'; +import { OverlaySettings, PlatformUtil } from 'igniteui-angular/core'; +import { IgxToggleDirective } from '../toggle/toggle.directive'; +import { IgxTooltipTargetDirective } from './tooltip-target.directive'; +import { Subject, takeUntil } from 'rxjs'; + +let NEXT_ID = 0; +/** + * **Ignite UI for Angular Tooltip** - + * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/tooltip) + * + * The Ignite UI for Angular Tooltip directive is used to mark an HTML element in the markup as one that should behave as a tooltip. + * The tooltip is used in combination with the Ignite UI for Angular Tooltip Target by assigning the exported tooltip reference to the + * respective target's selector property. + * + * Example: + * ```html + * + * Hello there, I am a tooltip! + * ``` + */ +@Directive({ + exportAs: 'tooltip', + selector: '[igxTooltip]', + standalone: true +}) +export class IgxTooltipDirective extends IgxToggleDirective implements AfterViewInit, OnDestroy { + /** + * @hidden + */ + @HostBinding('class.igx-tooltip--hidden') + public override get hiddenClass() { + return this.collapsed; + } + + /** + * @hidden + */ + @HostBinding('class.igx-tooltip') + public override get defaultClass() { + return !this.collapsed; + } + + /** + * Gets/sets any tooltip related data. + * The 'context' can be used for storing any information that is necessary + * to access when working with the tooltip. + * + * ```typescript + * // get + * let tooltipContext = this.tooltip.context; + * ``` + * + * ```typescript + * // set + * this.tooltip.context = "Tooltip's context"; + * ``` + */ + @Input() + public context; + + /** + * Identifier for the tooltip. + * If this is property is not explicitly set, it will be automatically generated. + * + * ```typescript + * let tooltipId = this.tooltip.id; + * ``` + */ + @HostBinding('attr.id') + @Input() + public override id = `igx-tooltip-${NEXT_ID++}`; + + /** + * Get the role attribute of the tooltip. + * + * ```typescript + * let tooltipRole = this.tooltip.role; + * ``` + */ + @HostBinding('attr.role') + @Input() + public set role(value: "tooltip" | "status"){ + this._role = value; + } + public get role() { + return this._role; + } + + /** + * Get the arrow element of the tooltip. + * + * ```typescript + * let tooltipArrow = this.tooltip.arrow; + * ``` + */ + public get arrow(): HTMLElement { + return this._arrowEl; + } + + /** + * @hidden + */ + public timeoutId; + + /** + * @hidden + */ + public tooltipTarget: IgxTooltipTargetDirective; + + private _arrowEl: HTMLElement; + private _role: 'tooltip' | 'status' = 'tooltip'; + private _destroy$ = new Subject(); + private _document = inject(DOCUMENT); + private _renderer = inject(Renderer2); + private _platformUtil = inject(PlatformUtil); + + /** @hidden */ + constructor() { + super(); + + this.onDocumentTouchStart = this.onDocumentTouchStart.bind(this); + this.opening.pipe(takeUntil(this._destroy$)).subscribe(() => { + this._document.addEventListener('touchstart', this.onDocumentTouchStart, { passive: true }); + }); + this.closed.pipe(takeUntil(this._destroy$)).subscribe(() => { + this._document.removeEventListener('touchstart', this.onDocumentTouchStart); + }); + } + + /** @hidden */ + public ngAfterViewInit(): void { + if (this._platformUtil.isBrowser) { + this._createArrow(); + } + } + + /** @hidden */ + public override ngOnDestroy() { + super.ngOnDestroy(); + + this._document.removeEventListener('touchstart', this.onDocumentTouchStart); + this._destroy$.next(true); + this._destroy$.complete(); + + if (this.arrow) { + this._removeArrow(); + } + } + + /** + * @hidden + */ + @HostListener('mouseenter') + public onMouseEnter() { + this.tooltipTarget?.onMouseEnter(); + } + + /** + * @hidden + */ + @HostListener('mouseleave') + public onMouseLeave() { + this.tooltipTarget?.onMouseLeave(); + } + + /** + * If there is an animation in progress, this method will reset it to its initial state. + * Allows hovering over the tooltip while an open/close animation is running. + * Stops the animation and immediately shows the tooltip. + * + * @hidden + */ + public stopAnimations(): void { + const info = this.overlayService.getOverlayById(this._overlayId); + + if (!info) return; + + if (info.openAnimationPlayer) { + info.openAnimationPlayer.reset(); + } + if (info.closeAnimationPlayer) { + info.closeAnimationPlayer.reset(); + } + } + + /** + * If there is a close animation in progress, this method will end it. + * If there is no close animation in progress, this method will close the tooltip with no animation. + * + * @param overlaySettings settings to use for closing the tooltip + * @hidden + */ + public forceClose(overlaySettings: OverlaySettings) { + const info = this.overlayService.getOverlayById(this._overlayId); + + if (info && info.closeAnimationPlayer) { + info.closeAnimationPlayer.finish(); + 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; + } + } + + private _createArrow(): void { + this._arrowEl = this._renderer.createElement('span'); + this._renderer.setStyle(this._arrowEl, 'position', 'absolute'); + this._renderer.setAttribute(this._arrowEl, 'data-arrow', 'true'); + this._renderer.appendChild(this.element, this._arrowEl); + } + + private _removeArrow(): void { + this._arrowEl.remove(); + this._arrowEl = null; + } + + private onDocumentTouchStart(event) { + this.tooltipTarget?.onDocumentTouchStart(event); + } +} diff --git a/projects/igniteui-angular/directives/src/directives/tooltip/tooltip.module.ts b/projects/igniteui-angular/directives/src/directives/tooltip/tooltip.module.ts new file mode 100644 index 00000000000..022d4df94e4 --- /dev/null +++ b/projects/igniteui-angular/directives/src/directives/tooltip/tooltip.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { IGX_TOOLTIP_DIRECTIVES } from './public_api'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ + @NgModule({ + imports: [...IGX_TOOLTIP_DIRECTIVES], + exports: [...IGX_TOOLTIP_DIRECTIVES] +}) +export class IgxTooltipModule { } diff --git a/projects/igniteui-angular/directives/src/public_api.ts b/projects/igniteui-angular/directives/src/public_api.ts new file mode 100644 index 00000000000..4deead82703 --- /dev/null +++ b/projects/igniteui-angular/directives/src/public_api.ts @@ -0,0 +1,65 @@ +// Directives +// Note: Autocomplete moved to drop-down entry point in v21.0.0 +// Users should now import from 'igniteui-angular/drop-down' instead +// Removed re-export to avoid circular dependency +export * from './directives/button/button.directive'; +export * from './directives/checkbox/checkbox-base.directive'; +export * from './directives/divider/divider.directive'; +export * from './directives/drag-drop/public_api'; +export * from './directives/filter/filter.directive'; +export * from './directives/focus/focus.directive'; +export * from './directives/focus-trap/focus-trap.directive'; +export { + IForOfDataChangeEventArgs, + IForOfDataChangingEventArgs, + IForOfState, + IgxForOfContext, + IgxForOfDirective, + IgxGridForOfContext, + IgxGridForOfDirective, + IgxForOfToken +} from './directives/for-of/for_of.directive'; +export { IgxForOfSyncService, IgxForOfScrollSyncService } from './directives/for-of/for_of.sync.service'; +export * from './directives/button/icon-button.directive'; +export * from './directives/layout/layout.directive'; +export * from './directives/mask/mask.directive'; +// Note: Radio-group directive moved to radio entry point in v21.0.0 +// Users should now import from 'igniteui-angular/radio' instead +// export { IgxRadioGroupDirective } from 'igniteui-angular/radio'; +export * from './directives/ripple/ripple.directive'; +export * from './directives/scroll-inertia/scroll_inertia.directive'; +export * from './directives/size/ig-size.directive'; +export * from './directives/text-highlight/text-highlight.directive'; +export * from './directives/text-selection/text-selection.directive'; +export * from './directives/template-outlet/template_outlet.directive'; +export * from './directives/toggle/toggle.directive'; +export * from './directives/tooltip/public_api'; +export * from './directives/date-time-editor/public_api'; +export * from './directives/form-control/form-control.directive'; +export * from './directives/notification/notifications.directive'; +export * from './directives/text-highlight/text-highlight.service'; + +// NOTE: Input-related directives (IgxHintDirective, IgxInputDirective, IgxLabelDirective, +// IgxPrefixDirective, IgxSuffixDirective, IgxReadonlyInputDirective) have been moved +// to igniteui-angular/input-group entry point. +// Import them from 'igniteui-angular/input-group' instead of 'igniteui-angular/directives' + +// Directive modules for backwards compatibility +export * from './directives/button/button.module'; +export * from './directives/date-time-editor/date-time-editor.module'; +export * from './directives/divider/divider.module'; +export * from './directives/drag-drop/drag-drop.module'; +export * from './directives/filter/filter.module'; +export * from './directives/focus/focus.module'; +export * from './directives/focus-trap/focus-trap.module'; +export * from './directives/for-of/for_of.module'; +export * from './directives/form-control/form-control.module'; +export * from './directives/layout/layout.module'; +export * from './directives/mask/mask.module'; +// export { IgxRadioModule } from 'igniteui-angular/radio'; +export * from './directives/ripple/ripple.module'; +export * from './directives/scroll-inertia/scroll_inertia.module'; +export * from './directives/text-highlight/text-highlight.module'; +export * from './directives/text-selection/text-selection.module'; +export * from './directives/toggle/toggle.module'; +export * from './directives/tooltip/tooltip.module'; diff --git a/projects/igniteui-angular/drop-down/README.md b/projects/igniteui-angular/drop-down/README.md new file mode 100644 index 00000000000..3bbcc0db303 --- /dev/null +++ b/projects/igniteui-angular/drop-down/README.md @@ -0,0 +1,183 @@ +# igxDropDown + + +**igxDropDown** displays a scrollable list of items which may be visually grouped and supports selection of a single item. Clicking or tapping an item selects it and closes the Drop Down. + +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/drop-down) + +# Usage +## Drop downs are done by adding **igxDropDownListItems** to **igxDropDown** component + +```html + + + {{ item.field }} + + +``` + +To provide more useful visual information, use `isHeader` to group items semantically, or `disabled` to display an item as non-interactive. + +```html + + + {{ item.field }} + + +``` + +## Grouping items +The ***igx-drop-down-item-group*** component can be used inside of the ***igx-drop-down*** to group ***igx-drop-down-items***. The example below illustrates how to display hierarchical data in drop down groups: +```typescript + // in example.component.ts + export class MyExampleComponent { + ... + foods: any[] = [{ + name: 'Vegetables', + entries: [{ + name: 'Cucumber', + refNo: `00000` + }, { + name: 'Lettuce', + refNo: `00001` + }, + ... + ] + }, { + name: 'Fruits', + entries: [{ + name: 'Banana', + refNo: `10000` + }, { + name: 'Tomato', + refNo: `10001` + }, + ... + ] + }]; + } +``` +```html + + + + + {{ food.name }} + + + +``` + +***NOTE:*** The ***igx-drop-down-item-group*** tag can be used for grouping of ***igx-drop-down-item*** only an will forfeit any other content passed to it. + +## Virtualized item list +The `igx-drop-down` supports the use of `IgxForOf` directive for displaying very large lists of data. To use a virtualized list of items in the drop-down, follow the steps below: + +### Import IgxForOfModule +```typescript + import { ..., IgxForOfModule } from 'igniteui-angular'; + ... + @NgModule({ + imports: [..., IgxForOfModule] + }) +``` + +### Properly configure the template +Configure the drop-down to use `*igxFor` instead of `ngFor`. Some additional configuration must be passed: + - scrollOrientation - should be `'vertical'` + - containerSize - should be set to the height that the items container will have, as `number`. E.g. `public itemsMaxHeight = 480;` + - itemSize - should be set to the height of the **smallest** item that the list will have, as `number`. E.g. `public itemHeight = 32;` +```html + +
    + + {{ item.data }} + +
    +
    +``` +Furthermore, when using `*igxFor` in the drop-down template, items must have `value` and `index` bound. The `value` property should be unique for each item. + +### Styling the container +In order for the drop-down list to properly display, the drop-down items must be wrapped in a container element (e.g. `
    `). +The container element must have the following styles: + - `overflow: hidden;` + - `height` property set to the same as `itemsMaxHeight` in the template, in `px`. E.g. `height: 480px` + +# API Summary +The following table summarizes some of the useful **igx-drop-down** component inputs, outputs and methods. + +## Inputs +The following inputs are available in the **igx-drop-down** component: + +| Name | Type | Description | +| :--- | :--- | :--- | +| `width` | string | Sets the tab width of the control. | +| `height` | string | Sets the tab height of the control. | +| `maxHeight` | string | defines drop down maximum height | +| `allowItemsFocus` | boolean | Allows items to take focus. | +| `id` | string | Sets the drop down's id. | + +
    + +## Outputs +The following outputs are available in the **igx-drop-down** component: + +| Name | Cancelable | Description | Parameters +| :--- | :--- | :--- | :--- | +| `selectionChanging` | false | Emitted when item selection is changing, before the selection completes. | `ISelectionEventArgs` | +| `opening` | true | Emitted before the dropdown is opened. | `IBaseCancelableBrowserEventArgs` | +| `opened` | false | Emitted when a dropdown is being opened. | `IBaseEventArgs` | +| `closing` | true | Emitted before the dropdown is closed. | `IBaseCancelableBrowserEventArgs` | +| `closed` | false | Emitted when a dropdown is being closed. | `IBaseEventArgs` | + +***NOTE:*** The using `*igxFor` to virtualize `igx-drop-down-item`s, `selectionChanging` will emit `newSeleciton` and `oldSelection` as type `{ value: any, index: number }`. + +## Methods +The following methods are available in the **igx-drop-down** component: + +| Signature | Description | +| :--- | :--- | :--- | +| `toggle()` | Toggles the drop down opened/closed. | +| `setSelectedItem(index: number)` | Selects dropdown item by index. | +| `open()` | Opens the dropdown. | +| `close()` | Closes the dropdown. | +| `clearSelection()` | Deselects the selected dropdown item. | + +## Getters +The following getters are available on the **igx-drop-down** component: + +| Name | Type | Description | +| :--- | :--- | :--- | +| `selectedItem` | `any` | Gets the selected item. | +| `collapsed` | `boolean` | Gets if the drop down is collapsed. | +| `items` | `IgxDropDownItemComponent[]` | Gets all of the items but headers. | +| `headers` | `IgxDropDownItemComponent[]` | Gets header items. | +| `element`| `ElementRef` | Get dropdown html element. | +| `scrollContainer`| `ElementRef` | Get drop down's html element of its scroll container. | + +***NOTE:*** The using `*igxFor` to virtualize `igx-drop-down-item`s, `selectedItem` will return type `{ value: any, index: number }`, where `value` is the item's bound `value` property and `index` is the item's index property in the data set. + +The following table summarizes some of the useful **igx-drop-down-item** component inputs, outputs and methods. + +## Inputs +The following inputs are available in the **igx-drop-down-item** component: + +| Name | Type | Description | +| :--- | :--- | :--- | +| `selected` | boolean| Defines if the item is the selected item. Only one item can be selected at time. | +| `isHeader` | boolean| Defines if the item is a group header. | +| `disabled` | boolean| Disables the given item. | +| `index` | number | The data index of the drop down item. | +| `focused` | boolean| Defines if the given item is focused. | +| `value` | any | The value of the drop-down item. | + +## Getters +The following getters are available on the **igx-drop-down-item** component: + +| Name | Type | Description | +| :--- | :--- | :--- | +| `elementHeight` | `number` | Gets item element height. | +| `element`| `ElementRef` | Get item's html element. | diff --git a/projects/igniteui-angular/drop-down/index.ts b/projects/igniteui-angular/drop-down/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/drop-down/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/drop-down/ng-package.json b/projects/igniteui-angular/drop-down/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/drop-down/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/drop-down/src/drop-down/autocomplete/README.md b/projects/igniteui-angular/drop-down/src/drop-down/autocomplete/README.md new file mode 100644 index 00000000000..e3edcc21f01 --- /dev/null +++ b/projects/igniteui-angular/drop-down/src/drop-down/autocomplete/README.md @@ -0,0 +1,217 @@ +# igxAutocomplete + +The `igxAutocomplete` directive provides a way to enhance a text input by showing a panel of suggested options provided by the developer. + +# Usage + +The simplest use-case for an end-user should be attaching the directive to an input element and providing to a template for the drop down. +```html + + + + {{town}} + + +``` + +# Features + +## Keyboard navigation + +The following keyboards can be used when navigating through the drop down items: + + - `Arrow Down`, `Arrow Up`, `Alt` + `Arrow Down`, `Alt` + `Arrow Up` will open drop down, if closed. + - Typing in the input will open drop down, if closed. + - `Arrow Down` - will move to next drop down item, if drop down opened. + - `Arrow Up` - will move to previous drop down item, if drop down opened. + - `Enter` will confirm the already selected item and will close the drop down. + - `Esc` will close the drop down. + +> Note: When autocomplete is opened an then the first item in the list is automatically selected. The same is valid when list is filtered. + +## Selection and model binding + +When value is selected in the drop down, then input element value is automatically updated. +In order to achieve that define the value property of the drop down item and bind it. Then on selection, the autocomplete will update the bound input value: + +```html + + + + + + + {{town}} + + +``` + +```typescript +@Component({ + selector: 'app-autocomplete-sample', + styleUrls: ['autocomplete.sample.css'], + templateUrl: `autocomplete.sample.html` +}) +export class AutocompleteSampleComponent { + townSelected; + constructor() { + this.towns = [ 'Sofia', 'Plovdiv', 'Varna', 'Burgas']; + } +} +``` + +## Enable/Disable autocomplete drop down + +The following sample defines `igxAutocompleteDisabled`, which allows to dynamically enable and disable the autocomplete drop down: + +```html + + + + + + + {{town}} + + + +``` + +```typescript +@Component({ + selector: 'app-autocomplete-sample', + styleUrls: ['autocomplete.sample.css'], + templateUrl: `autocomplete.sample.html` +}) +export class AutocompleteSampleComponent { + disabled; + constructor() { + this.towns = [ 'Sofia', 'Plovdiv', 'Varna', 'Burgas']; + } +} +``` + +> Note: When autocomplete is dynamically disabled, then it will be automatically closed. + +### Drop Down settings +The igx-autocomplete drop down positioning, scrolling strategy and outlet can be configured using, the `igxAutocompleteSettings` option. It allows values from type `AutocompleteOverlaySettings`. + +The following example displays that the positioning of the drop down can be set to be always above the input, where the directive is applied. It also disables opening and closing animations. For that purpose the `ConnectedPositioningStrategy` is used: + +```html + + + + + + + {{town}} + + +``` + +```typescript +@Component({ + selector: 'app-autocomplete-sample', + styleUrls: ['autocomplete.sample.css'], + templateUrl: `autocomplete.sample.html` +}) +export class AutocompleteSampleComponent { + constructor() { + this.towns = [ 'Sofia', 'Plovdiv', 'Varna', 'Burgas']; + } + @ViewChild('inputGroup', { read: IgxInputGroupComponent }) inputGroup: IgxInputGroupComponent; + + this.settings = { + positionStrategy: new ConnectedPositioningStrategy({ + closeAnimation: null, + openAnimation: null, + verticalDirection: VerticalAlignment.Top + }) + }; +} +``` + +> Note: The default positioning strategy is `AutoPositionStrategy` and drop down is opened according to the available space. + +## Compatibility support + +Applying the `igxAutocomplete` directive will decorate the element with the following aria attributes: + - role="combobox" - role of the element, where the directive is applied. + - aria-haspopup="listbox" attribute to indicate that igxAutocomplete can popup a container to suggest values. + - aria-owns="dropDownID" - id of the drop down used for displaying suggestions. + - aria-expanded="true"/"false" - value depending on the collapsed state of the drop down. + +The drop-down component, used as provider for suggestions, will expose the following aria attributes: + - role="listbox" - applied on the `igx-drop-down` component container + - role="group" - applied on the `igx-drop-down-item-group` component container + - role="option" - applied on the `igx-drop-down-item` component container + - aria-disabled="true"/"false" applied on `igx-drop-down-item`, `igx-drop-down-item-group` component containers when they are disabled. + +# API Summary + +Properties + +| Name | Type | Description | +|:----------|:------|:------| +| `igxAutocomplete` | `IgxDropDownComponent` | reference to the template providing a markup for the drop down | +| `igxAutocompleteDisabled` | `boolean` | enables/disables the directive. Does not affect the host | +| `igxAutocompleteSettings` | `AutocompleteOverlaySettings` | Settings to configure drop down overlay | + +Methods + +| Name | Description | +|:----------|:------| +| `open` | opens the autocomplete | +| `close` | closes the autocomplete | + +Events + +| Name | Description | Cancelable | +|:----------|:------|:------| +| `selectionChanging` | list of options to choose from | true + + +# Examples + +## Defining autocomplete with filtering +Using the `igxDropDown` as `igxAutocomplete` options provider allows the developer to define a custom options panel. Drop down features like grouping, custom templating and using all drop down options allows modifications on the drop down applied as autocomplete provider. The following sample demonstrates custom filtering applied to the drop down items: + +```html + + place + + + + + + {{town}} + + +``` + +```typescript +@Component({ + selector: 'app-autocomplete-sample', + styleUrls: ['autocomplete.sample.css'], + templateUrl: `autocomplete.sample.html` +}) +export class AutocompleteSampleComponent { + constructor() { + this.towns = [ 'Sofia', 'Plovdiv', 'Varna', 'Burgas']; + } +} + +@Pipe({ name: 'startsWith' }) +export class IgxAutocompletePipeStartsWith implements PipeTransform { + public transform(collection: any[], term = '') { + return collection.filter(item => item.toString().toLowerCase().startsWith(term.toString().toLowerCase()); + } +} +``` diff --git a/projects/igniteui-angular/drop-down/src/drop-down/autocomplete/autocomplete.directive.spec.ts b/projects/igniteui-angular/drop-down/src/drop-down/autocomplete/autocomplete.directive.spec.ts new file mode 100644 index 00000000000..5d61ef65741 --- /dev/null +++ b/projects/igniteui-angular/drop-down/src/drop-down/autocomplete/autocomplete.directive.spec.ts @@ -0,0 +1,1077 @@ +import { Component, ViewChild, Pipe, PipeTransform, ElementRef, inject } from '@angular/core'; +import { TestBed, tick, fakeAsync, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxAutocompleteDirective, AutocompleteOverlaySettings } from './autocomplete.directive'; +import { UIInteractions } from '../../../../test-utils/ui-interactions.spec'; +import { IgxDropDownComponent, IgxDropDownItemComponent, IgxDropDownItemNavigationDirective } from '../../drop-down/public_api'; +import { FormsModule, ReactiveFormsModule, UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; +import { ConnectedPositioningStrategy, VerticalAlignment, HorizontalAlignment } from '../../../../core/src/services/public_api'; +import { IgxRippleDirective } from '../../../../directives/src/directives/ripple/ripple.directive'; +import { IgxIconComponent } from '../../../../icon/src/icon/icon.component'; +import { IgxInputDirective, IgxInputGroupComponent, IgxLabelDirective, IgxPrefixDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; + +const CSS_CLASS_DROPDOWNLIST = 'igx-drop-down__list'; +const CSS_CLASS_DROPDOWNLIST_SCROLL = 'igx-drop-down__list-scroll'; +const CSS_CLASS_DROP_DOWN_ITEM = 'igx-drop-down__item'; +const CSS_CLASS_DROP_DOWN_ITEM_FOCUSED = 'igx-drop-down__item--focused'; +const INPUT_CSS_CLASS = 'igx-input-group__input'; + +describe('IgxAutocomplete', () => { + let fixture; + let autocomplete: IgxAutocompleteDirective; + let group: IgxInputGroupComponent; + let input: IgxInputDirective; + let dropDown: IgxDropDownComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + AutocompleteComponent, + AutocompleteInputComponent, + AutocompleteFormComponent + ] + }).compileComponents(); + })); + describe('General tests: ', () => { + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(AutocompleteComponent); + fixture.detectChanges(); + autocomplete = fixture.componentInstance.autocomplete; + group = fixture.componentInstance.group; + input = fixture.componentInstance.input; + dropDown = fixture.componentInstance.dropDown; + })); + it('Should open/close dropdown properly', fakeAsync(() => { + UIInteractions.setInputElementValue(input, 's', fixture); + fixture.detectChanges(); + expect(dropDown.collapsed).toBeFalsy(); + + UIInteractions.triggerKeyDownEvtUponElem('escape', input.nativeElement, true); + tick(); + fixture.detectChanges(); + expect(dropDown.collapsed).toBeTruthy(); + + input.nativeElement.click(); + UIInteractions.setInputElementValue(input, 'a', fixture); + fixture.detectChanges(); + expect(dropDown.collapsed).toBeFalsy(); + + autocomplete.onTab(); + tick(); + fixture.detectChanges(); + expect(dropDown.collapsed).toBeTruthy(); + + autocomplete.open(); + tick(); + fixture.detectChanges(); + expect(dropDown.collapsed).toBeFalsy(); + + autocomplete.close(); + tick(); + fixture.detectChanges(); + expect(dropDown.collapsed).toBeTruthy(); + })); + it('Should open drop down on (Alt+)ArrowUp/ArrowDown', fakeAsync(() => { + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', input.nativeElement, true); + tick(); + fixture.detectChanges(); + expect(dropDown.collapsed).toBeFalsy(); + expect(dropDown.items[0].focused).toBeTruthy(); + + UIInteractions.triggerKeyDownEvtUponElem('escape', input.nativeElement, true); + tick(); + fixture.detectChanges(); + expect(dropDown.collapsed).toBeTruthy(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', input.nativeElement, true); + tick(); + fixture.detectChanges(); + expect(dropDown.collapsed).toBeFalsy(); + expect(dropDown.items[0].focused).toBeTruthy(); + + UIInteractions.triggerKeyDownEvtUponElem('escape', input.nativeElement, true); + tick(); + fixture.detectChanges(); + expect(dropDown.collapsed).toBeTruthy(); + + const altKey = true; + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', input.nativeElement, true, altKey); + tick(); + fixture.detectChanges(); + expect(dropDown.collapsed).toBeFalsy(); + expect(dropDown.items[0].focused).toBeTruthy(); + + UIInteractions.triggerKeyDownEvtUponElem('escape', input.nativeElement, true); + tick(); + fixture.detectChanges(); + expect(dropDown.collapsed).toBeTruthy(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', input.nativeElement, true, altKey); + tick(); + fixture.detectChanges(); + expect(dropDown.collapsed).toBeFalsy(); + expect(dropDown.items[0].focused).toBeTruthy(); + + UIInteractions.triggerKeyDownEvtUponElem('escape', input.nativeElement, true); + tick(); + fixture.detectChanges(); + expect(dropDown.collapsed).toBeTruthy(); + })); + it('Should close the dropdown when no items match the filter', fakeAsync(() => { + expect((autocomplete as any).collapsed).toEqual(true); + spyOn(autocomplete.target, 'close').and.callThrough(); + spyOn(autocomplete, 'open').and.callThrough(); + spyOn(autocomplete.target, 'open').and.callThrough(); + expect(autocomplete.target.close).not.toHaveBeenCalled(); + UIInteractions.setInputElementValue(input, 'a', fixture); + tick(); + expect(autocomplete.open).toHaveBeenCalledTimes(1); + expect(autocomplete.target.open).toHaveBeenCalledTimes(1); + expect(autocomplete.target.collapsed).toEqual(false); + + UIInteractions.setInputElementValue(input, 'ax', fixture); + tick(); + expect(autocomplete.target.close).toHaveBeenCalledTimes(1); + expect(autocomplete.open).toHaveBeenCalledTimes(2); + expect(autocomplete.target.open).toHaveBeenCalledTimes(1); + expect(autocomplete.target.collapsed).toEqual(true); + + + // Should not try to reopen if no items + UIInteractions.setInputElementValue(input, 'axx', fixture); + tick(); + expect(autocomplete.target.close).toHaveBeenCalledTimes(1); + expect(autocomplete.open).toHaveBeenCalledTimes(3); + expect(autocomplete.target.open).toHaveBeenCalledTimes(1); + expect(autocomplete.target.collapsed).toEqual(true); + + + })); + it('Should close the dropdown when disabled dynamically', fakeAsync(() => { + spyOn(autocomplete.target, 'open').and.callThrough(); + spyOn(autocomplete.target, 'close').and.callThrough(); + + UIInteractions.setInputElementValue(input, 's', fixture); + tick(); + fixture.detectChanges(); + tick(); + expect(dropDown.collapsed).toBeFalsy(); + expect(autocomplete.target.open).toHaveBeenCalledTimes(1); + + autocomplete.disabled = true; + autocomplete.close(); + fixture.detectChanges(); + tick(); + expect(dropDown.collapsed).toBeTruthy(); + expect(autocomplete.target.close).toHaveBeenCalledTimes(1); + UIInteractions.setInputElementValue(input, 's', fixture); + tick(); + fixture.detectChanges(); + tick(); + expect(dropDown.collapsed).toBeTruthy(); + expect(autocomplete.target.open).toHaveBeenCalledTimes(1); + })); + it('Should not close the dropdown when clicked on a input or the group', fakeAsync(() => { + UIInteractions.setInputElementValue(input, 's', fixture); + fixture.detectChanges(); + expect(dropDown.collapsed).toBeFalsy(); + + input.nativeElement.click(); + tick(); + fixture.detectChanges(); + expect(dropDown.collapsed).toBeFalsy(); + + group.element.nativeElement.click(); + tick(); + fixture.detectChanges(); + expect(dropDown.collapsed).toBeFalsy(); + + // Click in center of the body. + const bodyRect = document.body.getBoundingClientRect(); + UIInteractions.simulateMouseEvent('click', document.body, + bodyRect.left + bodyRect.width / 2, + bodyRect.top + bodyRect.height / 2); + tick(); + fixture.detectChanges(); + expect(dropDown.collapsed).toBeTruthy(); + })); + it('Should select item and close dropdown with ENTER and do not close it with SPACE key', fakeAsync(() => { + let startsWith = 's'; + let filteredTowns = fixture.componentInstance.filterTowns(startsWith); + UIInteractions.setInputElementValue(input, startsWith, fixture); + fixture.detectChanges(); + tick(); + expect(dropDown.collapsed).toBeFalsy(); + + UIInteractions.triggerKeyDownEvtUponElem('enter', input.nativeElement, true); + fixture.detectChanges(); + tick(); + expect(dropDown.collapsed).toBeTruthy(); + expect(fixture.componentInstance.townSelected).toBe(filteredTowns[0]); + expect(input.value).toBe(filteredTowns[0]); + + startsWith = 'bu'; + filteredTowns = fixture.componentInstance.filterTowns(startsWith); + UIInteractions.setInputElementValue(input, startsWith, fixture); + fixture.detectChanges(); + tick(); + expect(dropDown.collapsed).toBeFalsy(); + + UIInteractions.triggerKeyDownEvtUponElem('space', input.nativeElement, true); + tick(); + fixture.detectChanges(); + expect(dropDown.collapsed).toBeFalsy(); + expect(fixture.componentInstance.townSelected).toBe('bu'); + expect(input.value).toBe('bu'); + })); + it('Should not open dropdown with ENTER key', fakeAsync(() => { + let startsWith = 's'; + const filteredTowns = fixture.componentInstance.filterTowns(startsWith); + + UIInteractions.triggerKeyDownEvtUponElem('enter', input.nativeElement, true); + fixture.detectChanges(); + tick(); + expect(dropDown.collapsed).toBeTruthy(); + expect(input.value).toBe(''); + + UIInteractions.setInputElementValue(input, startsWith, fixture); + tick(); + UIInteractions.triggerKeyDownEvtUponElem('enter', input.nativeElement, true); + fixture.detectChanges(); + tick(); + expect(dropDown.collapsed).toBeTruthy(); + expect(input.value).toBe(filteredTowns[0]); + + UIInteractions.triggerKeyDownEvtUponElem('enter', input.nativeElement, true); + fixture.detectChanges(); + tick(); + expect(dropDown.collapsed).toBeTruthy(); + expect(input.value).toBe(filteredTowns[0]); + + startsWith = ''; + UIInteractions.setInputElementValue(input, startsWith, fixture); + tick(); + UIInteractions.triggerKeyDownEvtUponElem('enter', input.nativeElement, true); + fixture.detectChanges(); + tick(); + expect(dropDown.collapsed).toBeTruthy(); + expect(input.value).toBe(fixture.componentInstance.towns[0]); + })); + it('Should not open dropdown and select items with SPACE key', fakeAsync(() => { + let startsWith = 'd'; + const filteredTowns = fixture.componentInstance.filterTowns(startsWith); + + UIInteractions.triggerKeyDownEvtUponElem('space', input.nativeElement, true); + fixture.detectChanges(); + tick(); + expect(dropDown.collapsed).toBeTruthy(); + expect(input.value).toBe(''); + + UIInteractions.setInputElementValue(input, startsWith, fixture); + tick(); + UIInteractions.triggerKeyDownEvtUponElem('enter', input.nativeElement, true); + fixture.detectChanges(); + tick(); + expect(dropDown.collapsed).toBeTruthy(); + expect(input.value).toBe(filteredTowns[0]); + + UIInteractions.triggerKeyDownEvtUponElem('space', input.nativeElement, true); + fixture.detectChanges(); + tick(); + expect(dropDown.collapsed).toBeTruthy(); + expect(input.value).toBe(filteredTowns[0]); + + startsWith = ''; + UIInteractions.setInputElementValue(input, startsWith, fixture); + tick(); + UIInteractions.triggerKeyDownEvtUponElem('space', input.nativeElement, true); + fixture.detectChanges(); + tick(); + expect(dropDown.collapsed).toBeFalsy(); + expect(input.value).toBe(startsWith); + })); + it('Should not open dropdown on input focusing', () => { + input.nativeElement.focus(); + fixture.detectChanges(); + expect(dropDown.collapsed).toBeTruthy(); + const dropdownList = fixture.debugElement.query(By.css('.' + CSS_CLASS_DROPDOWNLIST)); + const dropdownListScrollElement = fixture.debugElement.query(By.css('.' + CSS_CLASS_DROPDOWNLIST_SCROLL)); + expect(dropdownList.nativeElement.attributes['aria-hidden'].value).toEqual('true'); + expect(dropdownListScrollElement.children.length).toEqual(0); + }); + it('Should not open dropdown on input clicking', () => { + input.nativeElement.click(); + fixture.detectChanges(); + expect(dropDown.collapsed).toBeTruthy(); + const dropdownList = fixture.debugElement.query(By.css('.' + CSS_CLASS_DROPDOWNLIST)); + const dropdownListScrollElement = fixture.debugElement.query(By.css('.' + CSS_CLASS_DROPDOWNLIST_SCROLL)); + expect(dropdownList.nativeElement.attributes['aria-hidden'].value).toEqual('true'); + expect(dropdownListScrollElement.children.length).toEqual(0); + }); + it('Should not open dropdown when disabled', fakeAsync(() => { + fixture.detectChanges(); + spyOn(autocomplete.target, 'open').and.callThrough(); + const dropdownListScrollElement = fixture.debugElement.query(By.css('.' + CSS_CLASS_DROPDOWNLIST_SCROLL)); + + autocomplete.disabled = true; + fixture.detectChanges(); + + UIInteractions.setInputElementValue(input, 's', fixture); + fixture.detectChanges(); + expect(dropDown.collapsed).toBeTruthy(); + expect(dropdownListScrollElement.children.length).toEqual(0); + expect(autocomplete.target.open).toHaveBeenCalledTimes(0); + + autocomplete.open(); + fixture.detectChanges(); + expect(dropDown.collapsed).toBeTruthy(); + expect(dropdownListScrollElement.children.length).toEqual(0); + expect(autocomplete.target.open).toHaveBeenCalledTimes(0); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', input.nativeElement, true); + tick(); + fixture.detectChanges(); + expect(dropDown.collapsed).toBeTruthy(); + expect(dropdownListScrollElement.children.length).toEqual(0); + expect(autocomplete.target.open).toHaveBeenCalledTimes(0); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', input.nativeElement, true); + tick(); + fixture.detectChanges(); + expect(dropDown.collapsed).toBeTruthy(); + expect(dropdownListScrollElement.children.length).toEqual(0); + expect(autocomplete.target.open).toHaveBeenCalledTimes(0); + + const altKey = true; + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', input.nativeElement, true, altKey); + tick(); + fixture.detectChanges(); + expect(dropDown.collapsed).toBeTruthy(); + expect(dropdownListScrollElement.children.length).toEqual(0); + expect(autocomplete.target.open).toHaveBeenCalledTimes(0); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', input.nativeElement, true, altKey); + tick(); + fixture.detectChanges(); + expect(dropDown.collapsed).toBeTruthy(); + expect(dropdownListScrollElement.children.length).toEqual(0); + expect(autocomplete.target.open).toHaveBeenCalledTimes(0); + })); + it('Should select item when drop down item is clicked', fakeAsync(() => { + input.nativeElement.focus(); + expect(input.nativeElement as Element).toBe(document.activeElement); + const startsWith = 's'; + const filteredTowns = fixture.componentInstance.filterTowns(startsWith); + UIInteractions.setInputElementValue(input, startsWith, fixture); + tick(); + expect(dropDown.collapsed).toBeFalsy(); + const targetElement = fixture.debugElement.queryAll(By.css('.' + CSS_CLASS_DROP_DOWN_ITEM))[0]; + targetElement.nativeElement.click(); + fixture.detectChanges(); + tick(); + expect(dropDown.collapsed).toBeTruthy(); + expect(fixture.componentInstance.townSelected).toBe(filteredTowns[0]); + expect(input.value).toBe(filteredTowns[0]); + expect(input.nativeElement as Element).toBe(document.activeElement); + })); + it('Should filter and select duplicated items properly', fakeAsync(() => { + fixture.componentInstance.towns.push('Sofia', 'Sofia'); + fixture.detectChanges(); + const dropdownListScrollElement = fixture.debugElement.query(By.css('.' + CSS_CLASS_DROPDOWNLIST_SCROLL)); + let startsWith = 'so'; + let filteredTowns = fixture.componentInstance.filterTowns(startsWith); + + const verifyDropdownItems = () => { + expect(dropdownListScrollElement.children.length).toEqual(filteredTowns.length); + for (let itemIndex = 0; itemIndex < filteredTowns.length; itemIndex++) { + const itemElement = dropdownListScrollElement.children[itemIndex].nativeElement; + expect(itemElement.textContent.trim()). + toEqual(filteredTowns[itemIndex]); + const isFocused = itemIndex === 0 ? true : false; + const hasFocusedClass = + itemElement.classList.contains(CSS_CLASS_DROP_DOWN_ITEM_FOCUSED); + if (isFocused) { + expect(hasFocusedClass).toBeTruthy(); + } else { + expect(hasFocusedClass).toBeFalsy(); + } + expect(dropDown.items[itemIndex].focused).toEqual(isFocused); + } + }; + + UIInteractions.setInputElementValue(input, startsWith, fixture); + fixture.detectChanges(); + tick(); + verifyDropdownItems(); + + startsWith = 'sof'; + filteredTowns = fixture.componentInstance.filterTowns(startsWith); + UIInteractions.setInputElementValue(input, startsWith, fixture); + fixture.detectChanges(); + tick(); + verifyDropdownItems(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', input.nativeElement, true); + fixture.detectChanges(); + UIInteractions.triggerKeyDownEvtUponElem('enter', input.nativeElement, true); + tick(); + fixture.detectChanges(); + expect(dropDown.collapsed).toBeTruthy(); + expect(input.value).toEqual(filteredTowns[1]); + expect(fixture.componentInstance.townSelected).toEqual(filteredTowns[1]); + + startsWith = 'sof'; + filteredTowns = fixture.componentInstance.filterTowns(startsWith); + UIInteractions.setInputElementValue(input, startsWith, fixture); + fixture.detectChanges(); + tick(); + verifyDropdownItems(); + + startsWith = 'so'; + filteredTowns = fixture.componentInstance.filterTowns(startsWith); + UIInteractions.setInputElementValue(input, startsWith, fixture); + fixture.detectChanges(); + tick(); + verifyDropdownItems(); + + })); + it('Should filter and populate dropdown list with matching values on every key stroke', () => { + const dropdownListScrollElement = fixture.debugElement.query(By.css('.' + CSS_CLASS_DROPDOWNLIST_SCROLL)); + const verifyDropdownItems = () => { + const filteredTowns = fixture.componentInstance.filterTowns(startsWith); + UIInteractions.setInputElementValue(input, startsWith, fixture); + fixture.detectChanges(); + expect(dropdownListScrollElement.children.length).toEqual(filteredTowns.length); + for (let itemIndex = 0; itemIndex < filteredTowns.length; itemIndex++) { + expect(dropdownListScrollElement.children[itemIndex].nativeElement.textContent.trim()).toBe(filteredTowns[itemIndex]); + } + }; + + let startsWith = 's'; + verifyDropdownItems(); + + startsWith = 'st'; + verifyDropdownItems(); + + startsWith = 'sta'; + verifyDropdownItems(); + + startsWith = 'star'; + verifyDropdownItems(); + + startsWith = 'sta'; + verifyDropdownItems(); + + startsWith = 'st'; + verifyDropdownItems(); + + startsWith = 'str'; + verifyDropdownItems(); + + startsWith = 'st'; + verifyDropdownItems(); + + startsWith = 's'; + verifyDropdownItems(); + + startsWith = 'w'; + verifyDropdownItems(); + + startsWith = 't'; + verifyDropdownItems(); + }); + it('Should not populate dropdown list on non-matching values typing', () => { + let startsWith = ' '; + const dropdownListScrollElement = fixture.debugElement.query(By.css('.' + CSS_CLASS_DROPDOWNLIST_SCROLL)); + UIInteractions.setInputElementValue(input, startsWith, fixture); + fixture.detectChanges(); + expect(dropdownListScrollElement.children.length).toEqual(0); + + startsWith = ' '; + UIInteractions.setInputElementValue(input, startsWith, fixture); + fixture.detectChanges(); + expect(dropdownListScrollElement.children.length).toEqual(0); + + startsWith = 'w'; + UIInteractions.setInputElementValue(input, startsWith, fixture); + fixture.detectChanges(); + expect(dropdownListScrollElement.children.length).toEqual(0); + + startsWith = 't'; + const filteredTowns = fixture.componentInstance.filterTowns(startsWith); + UIInteractions.setInputElementValue(input, startsWith, fixture); + fixture.detectChanges(); + expect(dropdownListScrollElement.children.length).toEqual(filteredTowns.length); + + startsWith = 'tp'; + UIInteractions.setInputElementValue(input, startsWith, fixture); + fixture.detectChanges(); + expect(dropdownListScrollElement.children.length).toEqual(0); + }); + it('Should not preserve selected value', fakeAsync(() => { + let startsWith = 'q'; + const dropdownListScrollElement = fixture.debugElement.query(By.css('.' + CSS_CLASS_DROPDOWNLIST_SCROLL)); + + UIInteractions.setInputElementValue(input, startsWith, fixture); + tick(); + expect(dropdownListScrollElement.children.length).toEqual(0); + expect(input.nativeElement.value).toEqual(startsWith); + + UIInteractions.triggerKeyDownEvtUponElem('enter', input.nativeElement, true); + fixture.detectChanges(); + tick(); + expect(input.nativeElement.value).toEqual(startsWith); + + startsWith = 'd'; + const filteredTowns = fixture.componentInstance.filterTowns(startsWith); + UIInteractions.setInputElementValue(input, startsWith, fixture); + tick(); + expect(dropdownListScrollElement.children.length).toEqual(filteredTowns.length); + expect(input.nativeElement.value).toEqual(startsWith); + + UIInteractions.triggerKeyDownEvtUponElem('enter', input.nativeElement, true); + fixture.detectChanges(); + tick(); + expect(dropDown.collapsed).toBeTruthy(); + expect(fixture.componentInstance.townSelected).toBe(filteredTowns[0]); + expect(input.value).toBe(filteredTowns[0]); + + startsWith = 'q'; + UIInteractions.setInputElementValue(input, startsWith, fixture); + tick(); + expect(dropdownListScrollElement.children.length).toEqual(0); + expect(input.nativeElement.value).toEqual(startsWith); + + UIInteractions.triggerKeyDownEvtUponElem('enter', input.nativeElement, true); + tick(); + fixture.detectChanges(); + expect(input.nativeElement.value).toEqual(startsWith); + expect(fixture.componentInstance.townSelected).toBe(startsWith); + })); + it('Should auto-highlight first suggestion', fakeAsync(() => { + let startsWith = 's'; + let filteredTowns = fixture.componentInstance.filterTowns(startsWith); + UIInteractions.setInputElementValue(input, startsWith, fixture); + tick(); + expect(dropDown.children.first.focused).toBeTruthy(); + expect(dropDown.items[0].focused).toBeTruthy(); + expect(dropDown.items[0].value).toBe(filteredTowns[0]); + + UIInteractions.triggerKeyDownEvtUponElem('enter', input.nativeElement, true); + fixture.detectChanges(); + tick(); + expect(fixture.componentInstance.townSelected).toBe(filteredTowns[0]); + + startsWith = 'st'; + filteredTowns = fixture.componentInstance.filterTowns(startsWith); + UIInteractions.setInputElementValue(input, startsWith, fixture); + tick(); + expect(dropDown.children.first.focused).toBeTruthy(); + expect(dropDown.items[0].focused).toBeTruthy(); + expect(dropDown.items[0].value).toBe(filteredTowns[0]); + + startsWith = 's'; + filteredTowns = fixture.componentInstance.filterTowns(startsWith); + UIInteractions.setInputElementValue(input, startsWith, fixture); + tick(); + expect(dropDown.children.first.focused).toBeTruthy(); + expect(dropDown.items[0].focused).toBeTruthy(); + expect(dropDown.items[0].value).toBe(filteredTowns[0]); + expect(dropDown.items[1].focused).toBeFalsy(); + expect(dropDown.items[1].value).toBe(filteredTowns[1]); + })); + it('Should trigger selectionChanging event on item selection', fakeAsync(() => { + let startsWith = 'st'; + let filteredTowns = fixture.componentInstance.filterTowns(startsWith); + spyOn(autocomplete.selectionChanging, 'emit').and.callThrough(); + UIInteractions.setInputElementValue(input, startsWith, fixture); + tick(); + + UIInteractions.triggerKeyDownEvtUponElem('enter', input.nativeElement, true); + fixture.detectChanges(); + tick(); + expect(fixture.componentInstance.townSelected).toBe(filteredTowns[0]); + expect(autocomplete.selectionChanging.emit).toHaveBeenCalledTimes(1); + + startsWith = 't'; + filteredTowns = fixture.componentInstance.filterTowns(startsWith); + UIInteractions.setInputElementValue(input, startsWith, fixture); + tick(); + + UIInteractions.triggerKeyDownEvtUponElem('enter', input.nativeElement, true); + fixture.detectChanges(); + tick(); + expect(fixture.componentInstance.townSelected).toBe(filteredTowns[0]); + expect(autocomplete.selectionChanging.emit).toHaveBeenCalledTimes(2); + expect(autocomplete.selectionChanging.emit).toHaveBeenCalledWith({ value: 'Stara Zagora', cancel: false }); + + fixture.componentInstance.selectionChanging = (args) => { + args.cancel = true; + }; + UIInteractions.setInputElementValue(input, 's', fixture); + tick(); + UIInteractions.triggerKeyDownEvtUponElem('enter', input.nativeElement, true); + expect(fixture.componentInstance.townSelected).toBe('s'); + })); + it('Should trigger selectionChanging only once when the event is cancelled (issue #7483)', fakeAsync(() => { + spyOn(autocomplete.selectionChanging, 'emit').and.callThrough(); + + fixture.componentInstance.selectionChanging = (args) => { + args.cancel = true; + }; + UIInteractions.setInputElementValue(input, 's', fixture); + fixture.detectChanges(); + tick(); + UIInteractions.triggerKeyDownEvtUponElem('enter', input.nativeElement, true); + expect(fixture.componentInstance.townSelected).toBe('s'); + tick(); + fixture.detectChanges(); + expect(autocomplete.selectionChanging.emit).toHaveBeenCalledTimes(1); + expect(autocomplete.selectionChanging.emit).toHaveBeenCalledWith({ value: 'Sofia', cancel: true }); + + fixture.componentInstance.selectionChanging = (args) => { + args.cancel = true; + }; + UIInteractions.setInputElementValue(input, 's', fixture); + fixture.detectChanges(); + tick(); + UIInteractions.triggerKeyDownEvtUponElem('enter', input.nativeElement, true); + expect(fixture.componentInstance.townSelected).toBe('s'); + tick(); + fixture.detectChanges(); + expect(autocomplete.selectionChanging.emit).toHaveBeenCalledTimes(2); + expect(autocomplete.selectionChanging.emit).toHaveBeenCalledWith({ value: 'Sofia', cancel: true }); + })); + it('Should call onInput/open/close methods properly', fakeAsync(() => { + let startsWith = 'g'; + spyOn(autocomplete, 'onInput').and.callThrough(); + spyOn(autocomplete, 'handleKeyDown').and.callThrough(); + spyOn(autocomplete, 'close').and.callThrough(); + spyOn(autocomplete.target, 'close').and.callThrough(); + spyOn(autocomplete.target, 'open').and.callThrough(); + spyOn(autocomplete.target.opening, 'emit').and.callThrough(); + + UIInteractions.setInputElementValue(input, startsWith, fixture); + tick(); + fixture.detectChanges(); + tick(); + expect(autocomplete.onInput).toHaveBeenCalledTimes(1); + + startsWith = 'ga'; + UIInteractions.setInputElementValue(input, startsWith, fixture); + tick(); + fixture.detectChanges(); + tick(); + expect(autocomplete.onInput).toHaveBeenCalledTimes(2); + expect(autocomplete.target.open).toHaveBeenCalledTimes(1); + // opening is emitted once, so no impact on UX + expect(autocomplete.target.opening.emit).toHaveBeenCalledTimes(1); + // keeps dropdown opened + expect(autocomplete.close).toHaveBeenCalledTimes(0); + expect(autocomplete.target.close).toHaveBeenCalledTimes(0); + + UIInteractions.triggerKeyDownEvtUponElem('enter', input.nativeElement, true); + fixture.detectChanges(); + tick(); + expect(autocomplete.handleKeyDown).toHaveBeenCalledTimes(1); + expect(autocomplete.onInput).toHaveBeenCalledTimes(2); + expect(autocomplete.close).toHaveBeenCalledTimes(1); + expect(autocomplete.target.close).toHaveBeenCalledTimes(2); + + // IgxDropDownItemNavigationDirective handleKeyDown is not called when dropdown is closed + spyOn(IgxDropDownItemNavigationDirective.prototype, 'handleKeyDown').and.callThrough(); + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', input.nativeElement, true); + fixture.detectChanges(); + tick(); + expect(autocomplete.handleKeyDown).toHaveBeenCalledTimes(2); + expect(IgxDropDownItemNavigationDirective.prototype.handleKeyDown).toHaveBeenCalledTimes(0); + + startsWith = 'w'; + UIInteractions.setInputElementValue(input, startsWith, fixture); + tick(); + fixture.detectChanges(); + tick(); + expect(autocomplete.onInput).toHaveBeenCalledTimes(3); + // initially calls open 2 times. This has no effect on UX, as dropdown.opening is not emitted + expect(autocomplete.target.opening.emit).toHaveBeenCalledTimes(2); + expect(autocomplete.target.open).toHaveBeenCalledTimes(2); + })); + it('Should navigate through dropdown items with arrow up/down keys', fakeAsync(() => { + UIInteractions.setInputElementValue(input, 'a', fixture); + tick(); + expect(dropDown.items[0].focused).toBeTruthy(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', input.nativeElement, true); + fixture.detectChanges(); + expect(dropDown.items[1].focused).toBeTruthy(); + expect(dropDown.items[0].focused).toBeFalsy(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', input.nativeElement, true); + fixture.detectChanges(); + expect(dropDown.items[0].focused).toBeTruthy(); + expect(dropDown.items[1].focused).toBeFalsy(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', input.nativeElement, true); + fixture.detectChanges(); + expect(dropDown.items[0].focused).toBeTruthy(); + expect(dropDown.items[dropDown.items.length - 1].focused).toBeFalsy(); + })); + it('Should not overwrite browser functionality for Home/End keys', () => { + UIInteractions.setInputElementValue(input, 'r', fixture); + fixture.detectChanges(); + expect(input.nativeElement.selectionEnd).toBe(1); + + const mockObj = { + key: 'Home', + code: 'Home', + preventDefault: () => {} + }; + spyOn(mockObj, 'preventDefault'); + const inputDebug = fixture.debugElement.queryAll(By.css('.' + INPUT_CSS_CLASS))[0]; + inputDebug.triggerEventHandler('keydown', mockObj); + expect(mockObj.preventDefault).not.toHaveBeenCalled(); + + mockObj.key = 'End'; + mockObj.code = 'End'; + inputDebug.triggerEventHandler('keydown', mockObj); + expect(mockObj.preventDefault).not.toHaveBeenCalled(); + }); + it('Should apply default width to both input and dropdown list elements', () => { + UIInteractions.setInputElementValue(input, 's', fixture); + fixture.detectChanges(); + const dropDownAny = dropDown as any; + expect(dropDownAny.scrollContainer.getBoundingClientRect().width) + .toEqual(group.element.nativeElement.getBoundingClientRect().width); + }); + it('Should apply width to dropdown list if set', () => { + UIInteractions.setInputElementValue(input, 's', fixture); + fixture.componentInstance.ddWidth = '600px'; + fixture.detectChanges(); + const dropDownAny = dropDown as any; + expect(dropDownAny.scrollContainer.getBoundingClientRect().width).toEqual(600); + }); + it('Should render aria attributes properly', fakeAsync(() => { + expect(input.nativeElement.attributes['autocomplete'].value).toEqual('off'); + expect(input.nativeElement.attributes['role'].value).toEqual('combobox'); + expect(input.nativeElement.attributes['aria-autocomplete'].value).toEqual('list'); + expect(input.nativeElement.attributes['aria-haspopup'].value).toEqual('listbox'); + expect(input.nativeElement.attributes['aria-owns'].value).toEqual(dropDown.listId); + expect(input.nativeElement.attributes['aria-expanded'].value).toEqual('false'); + expect(input.nativeElement.attributes['aria-activedescendant']).toBeUndefined(); + UIInteractions.setInputElementValue(input, 's', fixture); + tick(); + expect(input.nativeElement.attributes['aria-expanded'].value).toEqual('true'); + expect(input.nativeElement.attributes['aria-activedescendant'].value).toEqual(dropDown.focusedItem.id); + autocomplete.close(); + tick(); + fixture.detectChanges(); + expect(input.nativeElement.attributes['aria-expanded'].value).toEqual('false'); + })); + it('Should accept Japanese input', fakeAsync(() => { + UIInteractions.setInputElementValue(input, '東京', fixture); + fixture.detectChanges(); + tick(); + UIInteractions.triggerKeyDownEvtUponElem('enter', input.nativeElement, true); + fixture.detectChanges(); + expect(input.value).toBe('東京'); + })); + }); + describe('Positioning settings tests', () => { + it('Panel settings - direction and startPoint: top', fakeAsync(() => { + fixture = TestBed.createComponent(AutocompleteComponent); + fixture.componentInstance.settings = { + positionStrategy: new ConnectedPositioningStrategy({ + closeAnimation: null, + openAnimation: null, + verticalDirection: VerticalAlignment.Top, + verticalStartPoint: VerticalAlignment.Top + }) + }; + fixture.detectChanges(); + autocomplete = fixture.componentInstance.autocomplete; + group = fixture.componentInstance.group; + input = fixture.componentInstance.input; + dropDown = fixture.componentInstance.dropDown; + input.nativeElement.click(); + + UIInteractions.setInputElementValue(input, 's', fixture); + fixture.detectChanges(); + tick(); + const dropdownListElement = fixture.debugElement.query(By.css('.' + CSS_CLASS_DROPDOWNLIST)); + const ddRect = dropdownListElement.nativeElement.getBoundingClientRect(); + const gRect = group.element.nativeElement.getBoundingClientRect(); + expect(ddRect.bottom).toEqual(gRect.top); + expect(ddRect.left).toEqual(gRect.left); + })); + + it('Panel settings - direction: left; StartPoint: right', fakeAsync(() => { + fixture = TestBed.createComponent(AutocompleteComponent); + fixture.componentInstance.settings = { + positionStrategy: new ConnectedPositioningStrategy({ + closeAnimation: null, + openAnimation: null, + horizontalDirection: HorizontalAlignment.Left, + horizontalStartPoint: HorizontalAlignment.Right + }) + }; + fixture.detectChanges(); + autocomplete = fixture.componentInstance.autocomplete; + group = fixture.componentInstance.group; + input = fixture.componentInstance.input; + dropDown = fixture.componentInstance.dropDown; + input.nativeElement.click(); + + UIInteractions.setInputElementValue(input, 's', fixture); + fixture.detectChanges(); + tick(); + const dropdownListElement = fixture.debugElement.query(By.css('.' + CSS_CLASS_DROPDOWNLIST)); + const ddRect = dropdownListElement.nativeElement.getBoundingClientRect(); + const gRect = group.element.nativeElement.getBoundingClientRect(); + expect(ddRect.left).toEqual(gRect.left); + expect(ddRect.right).toEqual(gRect.right); + expect(ddRect.width).toEqual(gRect.width); + })); + }); + describe('Other elements integration tests', () => { + it('Should be instantiated properly on HTML input', fakeAsync(() => { + fixture = TestBed.createComponent(AutocompleteInputComponent); + fixture.detectChanges(); + autocomplete = fixture.componentInstance.autocomplete; + const plainInput = fixture.componentInstance.plainInput; + dropDown = fixture.componentInstance.dropDown; + expect(autocomplete).toBeDefined(); + expect(dropDown).toBeDefined(); + + const startsWith = 's'; + const filteredTowns = fixture.componentInstance.filterTowns(startsWith); + const dropdownListScrollElement = fixture.debugElement.query(By.css('.' + CSS_CLASS_DROPDOWNLIST_SCROLL)); + UIInteractions.setInputElementValue(plainInput, startsWith, fixture); + tick(); + expect(dropDown.collapsed).toBeFalsy(); + expect(dropdownListScrollElement.children.length).toEqual(filteredTowns.length); + expect(dropDown.children.first.focused).toBeTruthy(); + expect(dropDown.items[0].focused).toBeTruthy(); + expect(dropDown.items[0].value).toBe(filteredTowns[0]); + + UIInteractions.triggerKeyDownEvtUponElem('enter', plainInput.nativeElement, true); + fixture.detectChanges(); + tick(); + expect(dropDown.collapsed).toBeTruthy(); + expect(dropdownListScrollElement.children.length).toEqual(0); + expect(plainInput.nativeElement.value).toBe(filteredTowns[0]); + })); + it('Should be instantiated properly on HTML textarea', fakeAsync(() => { + fixture = TestBed.createComponent(AutocompleteInputComponent); + fixture.detectChanges(); + autocomplete = fixture.componentInstance.autocomplete; + const textarea = fixture.componentInstance.textarea; + dropDown = fixture.componentInstance.dropDown; + expect(autocomplete).toBeDefined(); + expect(dropDown).toBeDefined(); + + const startsWith = 't'; + const filteredTowns = fixture.componentInstance.filterTowns(startsWith); + const dropdownListScrollElement = fixture.debugElement.query(By.css('.' + CSS_CLASS_DROPDOWNLIST_SCROLL)); + UIInteractions.setInputElementValue(textarea, startsWith, fixture); + tick(); + expect(dropDown.collapsed).toBeFalsy(); + expect(dropdownListScrollElement.children.length).toEqual(filteredTowns.length); + expect(dropDown.children.first.focused).toBeTruthy(); + expect(dropDown.items[0].focused).toBeTruthy(); + expect(dropDown.items[0].value).toBe(filteredTowns[0]); + + UIInteractions.triggerKeyDownEvtUponElem('enter', textarea.nativeElement, true); + fixture.detectChanges(); + tick(); + expect(dropDown.collapsed).toBeTruthy(); + expect(dropdownListScrollElement.children.length).toEqual(0); + expect(textarea.nativeElement.value).toBe(filteredTowns[0]); + })); + it('Should be instantiated properly on ReactiveForm', fakeAsync(() => { + fixture = TestBed.createComponent(AutocompleteFormComponent); + fixture.detectChanges(); + tick(); + autocomplete = fixture.componentInstance.autocomplete; + input = fixture.componentInstance.input; + group = fixture.componentInstance.group; + dropDown = fixture.componentInstance.dropDown; + input.nativeElement.click(); + input.nativeElement.focus(); + UIInteractions.clickAndSendInputElementValue(input, 's', fixture); + tick(); + expect(dropDown.collapsed).toBeFalsy(); + expect(dropDown.children.first.focused).toBeTruthy(); + expect(dropDown.items[0].focused).toBeTruthy(); + expect(dropDown.items[0].value).toBe('Sofia'); + + UIInteractions.triggerKeyDownEvtUponElem('enter', input.nativeElement, true); + fixture.detectChanges(); + tick(); + expect(dropDown.collapsed).toBeTruthy(); + expect(input.nativeElement.value).toBe('Sofia'); + expect(group.element.nativeElement.classList.contains('igx-input-group--valid')).toBeTruthy(); + + fixture.componentInstance.plainInput.nativeElement.focus(); + fixture.detectChanges(); + tick(); + expect(group.element.nativeElement.classList.contains('igx-input-group--valid')).toBeFalsy(); + })); + }); +}); + +@Pipe({ + name: 'startsWith', + standalone: true +}) +export class IgxAutocompletePipeStartsWith implements PipeTransform { + public transform(collection: any[], term = '', key?: string) { + return collection.filter(item => { + const currItem = key ? item[key] : item; + return currItem.toString().toLowerCase().startsWith(term.toString().toLowerCase()); + }); + } +} + +@Component({ + template: ` + + home + + + clear + + + @for (town of towns | startsWith:townSelected; track town+$index) { + + {{town}} + + } + `, + imports: [ + FormsModule, + IgxInputGroupComponent, + IgxPrefixDirective, + IgxSuffixDirective, + IgxInputDirective, + IgxDropDownComponent, + IgxDropDownItemComponent, + IgxIconComponent, + IgxAutocompleteDirective, + IgxAutocompletePipeStartsWith, + ] +}) +class AutocompleteComponent { + @ViewChild(IgxAutocompleteDirective, { static: true }) public autocomplete: IgxAutocompleteDirective; + @ViewChild(IgxInputGroupComponent, { static: true }) public group: IgxInputGroupComponent; + @ViewChild(IgxInputDirective, { static: true }) public input: IgxInputDirective; + @ViewChild(IgxDropDownComponent, { static: true }) public dropDown: IgxDropDownComponent; + public townSelected; + public towns; + public ddWidth = null; + public settings: AutocompleteOverlaySettings = null; + + constructor() { + this.towns = [ + 'Sofia', 'Plovdiv', 'Varna', 'Burgas', 'Ruse', 'Stara Zagora', 'Pleven', 'Dobrich', 'Sliven', 'Shumen', 'Pernik', 'Haskovo', 'Yambol', 'Pazardzhik', 'Blagoevgrad', 'Veliko Tarnovo', 'Vratsa', 'Gabrovo', 'Asenovgrad', 'Vidin', 'Kazanlak', 'Kyustendil', 'Kardzhali', 'Montana', 'Dimitrovgrad', 'Targovishte', 'Lovech', 'Silistra', 'Dupnitsa', 'Svishtov', 'Razgrad', 'Gorna Oryahovitsa', 'Smolyan', 'Petrich', 'Sandanski', 'Samokov', 'Sevlievo', 'Lom', 'Karlovo', 'Velingrad', 'Nova Zagora', 'Troyan', 'Aytos', 'Botevgrad', 'Gotse Delchev', 'Peshtera', 'Harmanli', 'Karnobat', 'Svilengrad', 'Panagyurishte', 'Chirpan', 'Popovo', 'Rakovski', 'Radomir', 'Novi Iskar', 'Kozloduy', 'Parvomay', 'Berkovitsa', 'Cherven Bryag', 'Pomorie', 'Ihtiman', 'Radnevo', 'Provadiya', 'Novi Pazar', 'Razlog', 'Byala Slatina', 'Nesebar', 'Balchik', 'Kostinbrod', 'Stamboliyski', 'Kavarna', 'Knezha', 'Pavlikeni', 'Mezdra', 'Etropole', 'Levski', 'Teteven', 'Elhovo', 'Bankya', 'Tryavna', 'Lukovit', 'Tutrakan', 'Sredets', 'Sopot', 'Byala', 'Veliki Preslav', 'Isperih', 'Belene', 'Omurtag', 'Bansko', 'Krichim', 'Galabovo', 'Devnya', 'Septemvri', 'Rakitovo', 'Lyaskovets', 'Svoge', 'Aksakovo', 'Kubrat', 'Dryanovo', 'Beloslav', 'Pirdop', 'Lyubimets', 'Momchilgrad', 'Slivnitsa', 'Hisarya', 'Zlatograd', 'Kostenets', 'Devin', 'General Toshevo', 'Simeonovgrad', 'Simitli', 'Elin Pelin', 'Dolni Chiflik', 'Tervel', 'Dulovo', 'Varshets', 'Kotel', 'Madan', 'Straldzha', 'Saedinenie', 'Bobov Dol', 'Tsarevo', 'Kuklen', 'Tvarditsa', 'Yakoruda', 'Elena', 'Topolovgrad', 'Bozhurishte', 'Chepelare', 'Oryahovo', 'Sozopol', 'Belogradchik', 'Perushtitsa', 'Zlatitsa', 'Strazhitsa', 'Krumovgrad', 'Kameno', 'Dalgopol', 'Vetovo', 'Suvorovo', 'Dolni Dabnik', 'Dolna Banya', 'Pravets', 'Nedelino', 'Polski Trambesh', 'Trastenik', 'Bratsigovo', 'Koynare', 'Godech', 'Slavyanovo', 'Dve Mogili', 'Kostandovo', 'Debelets', 'Strelcha', 'Sapareva Banya', 'Ignatievo', 'Smyadovo', 'Breznik', 'Sveti Vlas', 'Nikopol', 'Shivachevo', 'Belovo', 'Tsar Kaloyan', 'Ivaylovgrad', 'Valchedram', 'Marten', 'Glodzhevo', 'Sarnitsa', 'Letnitsa', 'Varbitsa', 'Iskar', 'Ardino', 'Shabla', 'Rudozem', 'Vetren', 'Kresna', 'Banya', 'Batak', 'Maglizh', 'Valchi Dol', 'Gulyantsi', 'Dragoman', 'Zavet', 'Kran', 'Miziya', 'Primorsko', 'Sungurlare', 'Dolna Mitropoliya', 'Krivodol', 'Kula', 'Kalofer', 'Slivo Pole', 'Kaspichan', 'Apriltsi', 'Belitsa', 'Roman', 'Dzhebel', 'Dolna Oryahovitsa', 'Buhovo', 'Gurkovo', 'Pavel Banya', 'Nikolaevo', 'Yablanitsa', 'Kableshkovo', 'Opaka', 'Rila', 'Ugarchin', 'Dunavtsi', 'Dobrinishte', 'Hadzhidimovo', 'Bregovo', 'Byala Cherkva', 'Zlataritsa', 'Kocherinovo', 'Dospat', 'Tran', 'Sadovo', 'Laki', 'Koprivshtitsa', 'Malko Tarnovo', 'Loznitsa', 'Obzor', 'Kilifarevo', 'Borovo', 'Batanovtsi', 'Chernomorets', 'Aheloy', 'Pordim', 'Suhindol', 'Merichleri', 'Glavinitsa', 'Chiprovtsi', 'Kermen', 'Brezovo', 'Plachkovtsi', 'Zemen', 'Balgarovo', 'Alfatar', 'Boychinovtsi', 'Gramada', 'Senovo', 'Momin Prohod', 'Kaolinovo', 'Shipka', 'Antonovo', 'Ahtopol', 'Boboshevo', 'Bolyarovo', 'Brusartsi', 'Klisura', 'Dimovo', 'Kiten', 'Pliska', 'Madzharovo', 'Melnik' + ]; + } + public selectionChanging() { } + + public filterTowns(startsWith: string) { + return this.towns.filter(city => city.toString().toLowerCase().startsWith(startsWith.toLowerCase())); + } +} + +@Component({ + template: ` + + + + + @for (town of towns | startsWith:townSelected; track town+$index) { + + {{town}} + + } + `, + imports: [ + FormsModule, + IgxAutocompleteDirective, + IgxLabelDirective, + IgxDropDownComponent, + IgxDropDownItemComponent, + IgxAutocompletePipeStartsWith, + ] +}) +class AutocompleteInputComponent extends AutocompleteComponent { + @ViewChild('plainInput', { static: true }) public plainInput: ElementRef; + @ViewChild('textarea', { static: true }) public textarea: ElementRef; +} + +@Component({ + template: ` +
    + + home + + + clear + + + @for (town of towns | startsWith:townSelected; track town+$index) { + + {{town}} + + } + + + +
    + `, + imports: [ + ReactiveFormsModule, + IgxInputGroupComponent, + IgxInputDirective, + IgxLabelDirective, + IgxPrefixDirective, + IgxRippleDirective, + IgxDropDownComponent, + IgxDropDownItemComponent, + IgxIconComponent, + IgxAutocompleteDirective, + IgxAutocompletePipeStartsWith, + ] +}) + +class AutocompleteFormComponent { + @ViewChild(IgxAutocompleteDirective, { static: true }) public autocomplete: IgxAutocompleteDirective; + @ViewChild(IgxInputGroupComponent, { static: true }) public group: IgxInputGroupComponent; + @ViewChild(IgxInputDirective, { static: true }) public input: IgxInputDirective; + @ViewChild(IgxDropDownComponent, { static: true }) public dropDown: IgxDropDownComponent; + @ViewChild('plainInput', { static: true }) public plainInput: ElementRef; + public towns: string[]; + + public reactiveForm: UntypedFormGroup; + + constructor() { + const fb = inject(UntypedFormBuilder); + + + this.towns = [ + 'Sofia', 'Plovdiv', 'Varna', 'Burgas', 'Ruse', 'Stara Zagora', 'Pleven', 'Dobrich', 'Sliven', 'Shumen', 'Pernik', 'Haskovo', 'Yambol', 'Pazardzhik', 'Blagoevgrad', 'Veliko Tarnovo', 'Vratsa', 'Gabrovo', 'Asenovgrad', 'Vidin', 'Kazanlak', 'Kyustendil', 'Kardzhali', 'Montana', 'Dimitrovgrad', 'Targovishte', 'Lovech', 'Silistra', 'Dupnitsa', 'Svishtov', 'Razgrad', 'Gorna Oryahovitsa', 'Smolyan', 'Petrich', 'Sandanski', 'Samokov', 'Sevlievo', 'Lom', 'Karlovo', 'Velingrad', 'Nova Zagora', 'Troyan', 'Aytos', 'Botevgrad', 'Gotse Delchev', 'Peshtera', 'Harmanli', 'Karnobat', 'Svilengrad', 'Panagyurishte', 'Chirpan', 'Popovo', 'Rakovski', 'Radomir', 'Novi Iskar', 'Kozloduy', 'Parvomay', 'Berkovitsa', 'Cherven Bryag', 'Pomorie', 'Ihtiman', 'Radnevo', 'Provadiya', 'Novi Pazar', 'Razlog', 'Byala Slatina', 'Nesebar', 'Balchik', 'Kostinbrod', 'Stamboliyski', 'Kavarna', 'Knezha', 'Pavlikeni', 'Mezdra', 'Etropole', 'Levski', 'Teteven', 'Elhovo', 'Bankya', 'Tryavna', 'Lukovit', 'Tutrakan', 'Sredets', 'Sopot', 'Byala', 'Veliki Preslav', 'Isperih', 'Belene', 'Omurtag', 'Bansko', 'Krichim', 'Galabovo', 'Devnya', 'Septemvri', 'Rakitovo', 'Lyaskovets', 'Svoge', 'Aksakovo', 'Kubrat', 'Dryanovo', 'Beloslav', 'Pirdop', 'Lyubimets', 'Momchilgrad', 'Slivnitsa', 'Hisarya', 'Zlatograd', 'Kostenets', 'Devin', 'General Toshevo', 'Simeonovgrad', 'Simitli', 'Elin Pelin', 'Dolni Chiflik', 'Tervel', 'Dulovo', 'Varshets', 'Kotel', 'Madan', 'Straldzha', 'Saedinenie', 'Bobov Dol', 'Tsarevo', 'Kuklen', 'Tvarditsa', 'Yakoruda', 'Elena', 'Topolovgrad', 'Bozhurishte', 'Chepelare', 'Oryahovo', 'Sozopol', 'Belogradchik', 'Perushtitsa', 'Zlatitsa', 'Strazhitsa', 'Krumovgrad', 'Kameno', 'Dalgopol', 'Vetovo', 'Suvorovo', 'Dolni Dabnik', 'Dolna Banya', 'Pravets', 'Nedelino', 'Polski Trambesh', 'Trastenik', 'Bratsigovo', 'Koynare', 'Godech', 'Slavyanovo', 'Dve Mogili', 'Kostandovo', 'Debelets', 'Strelcha', 'Sapareva Banya', 'Ignatievo', 'Smyadovo', 'Breznik', 'Sveti Vlas', 'Nikopol', 'Shivachevo', 'Belovo', 'Tsar Kaloyan', 'Ivaylovgrad', 'Valchedram', 'Marten', 'Glodzhevo', 'Sarnitsa', 'Letnitsa', 'Varbitsa', 'Iskar', 'Ardino', 'Shabla', 'Rudozem', 'Vetren', 'Kresna', 'Banya', 'Batak', 'Maglizh', 'Valchi Dol', 'Gulyantsi', 'Dragoman', 'Zavet', 'Kran', 'Miziya', 'Primorsko', 'Sungurlare', 'Dolna Mitropoliya', 'Krivodol', 'Kula', 'Kalofer', 'Slivo Pole', 'Kaspichan', 'Apriltsi', 'Belitsa', 'Roman', 'Dzhebel', 'Dolna Oryahovitsa', 'Buhovo', 'Gurkovo', 'Pavel Banya', 'Nikolaevo', 'Yablanitsa', 'Kableshkovo', 'Opaka', 'Rila', 'Ugarchin', 'Dunavtsi', 'Dobrinishte', 'Hadzhidimovo', 'Bregovo', 'Byala Cherkva', 'Zlataritsa', 'Kocherinovo', 'Dospat', 'Tran', 'Sadovo', 'Laki', 'Koprivshtitsa', 'Malko Tarnovo', 'Loznitsa', 'Obzor', 'Kilifarevo', 'Borovo', 'Batanovtsi', 'Chernomorets', 'Aheloy', 'Pordim', 'Suhindol', 'Merichleri', 'Glavinitsa', 'Chiprovtsi', 'Kermen', 'Brezovo', 'Plachkovtsi', 'Zemen', 'Balgarovo', 'Alfatar', 'Boychinovtsi', 'Gramada', 'Senovo', 'Momin Prohod', 'Kaolinovo', 'Shipka', 'Antonovo', 'Ahtopol', 'Boboshevo', 'Bolyarovo', 'Brusartsi', 'Klisura', 'Dimovo', 'Kiten', 'Pliska', 'Madzharovo', 'Melnik' + ]; + this.reactiveForm = fb.group({ + towns: ['', Validators.required] + }); + + } + public onSubmitReactive() { } +} diff --git a/projects/igniteui-angular/drop-down/src/drop-down/autocomplete/autocomplete.directive.ts b/projects/igniteui-angular/drop-down/src/drop-down/autocomplete/autocomplete.directive.ts new file mode 100644 index 00000000000..4388c106310 --- /dev/null +++ b/projects/igniteui-angular/drop-down/src/drop-down/autocomplete/autocomplete.directive.ts @@ -0,0 +1,376 @@ +import { ChangeDetectorRef, Directive, ElementRef, EventEmitter, HostBinding, HostListener, Input, OnDestroy, Output, AfterViewInit, OnInit, booleanAttribute, inject } from '@angular/core'; +import { NgModel, FormControlName } from '@angular/forms'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { CancelableEventArgs, IBaseEventArgs, IgxOverlayOutletDirective } from 'igniteui-angular/core'; +import { + AbsoluteScrollStrategy, + AutoPositionStrategy, + IPositionStrategy, + IScrollStrategy, + OverlaySettings +} from 'igniteui-angular/core'; +import { IgxDropDownComponent } from '../drop-down.component'; +import { IgxDropDownItemNavigationDirective } from '../drop-down-navigation.directive'; +import { ISelectionEventArgs } from '../drop-down.common'; +import { IgxInputGroupComponent } from 'igniteui-angular/input-group'; + +/** + * Interface that encapsulates onItemSelection event arguments - new value and cancel selection. + * + * @export + */ +export interface AutocompleteSelectionChangingEventArgs extends CancelableEventArgs, IBaseEventArgs { + /** + * New value selected from the drop down + */ + value: string; +} + +export interface AutocompleteOverlaySettings { + /** Position strategy to use with this settings */ + positionStrategy?: IPositionStrategy; + /** Scroll strategy to use with this settings */ + scrollStrategy?: IScrollStrategy; + /** Set the outlet container to attach the overlay to */ + outlet?: IgxOverlayOutletDirective | ElementRef; +} + +/** + * **Ignite UI for Angular Autocomplete** - + * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/autocomplete.html) + * + * The igxAutocomplete directive provides a way to enhance a text input + * by showing a drop down of suggested options, provided by the developer. + * + * Example: + * ```html + * + * + * + * {{town}} + * + * + * ``` + */ +@Directive({ + selector: '[igxAutocomplete]', + exportAs: 'igxAutocomplete', + standalone: true +}) +export class IgxAutocompleteDirective extends IgxDropDownItemNavigationDirective implements OnDestroy, AfterViewInit, OnInit { + protected ngModel = inject(NgModel, { self: true, optional: true }); + protected formControl = inject(FormControlName, { self: true, optional: true }); + protected group = inject(IgxInputGroupComponent, { optional: true }); + protected elementRef = inject(ElementRef); + protected cdr = inject(ChangeDetectorRef); + + /** + * Sets the target of the autocomplete directive + * + * ```html + * + * + * ... + * + * ... + * + * ``` + */ + @Input('igxAutocomplete') + public override get target(): IgxDropDownComponent { + return this._target as IgxDropDownComponent; + } + public override set target(v: IgxDropDownComponent) { + this._target = v; + } + + /** + * Provide overlay settings for the autocomplete drop down + * + * ```typescript + * // get + * let settings = this.autocomplete.autocompleteSettings; + * ``` + * ```html + * + * + * ``` + * ```typescript + * // set + * this.settings = { + * positionStrategy: new ConnectedPositioningStrategy({ + * closeAnimation: null, + * openAnimation: null + * }) + * }; + * ``` + */ + @Input('igxAutocompleteSettings') + public autocompleteSettings: AutocompleteOverlaySettings; + + /** @hidden @internal */ + @HostBinding('attr.autocomplete') + public autofill = 'off'; + + /** @hidden @internal */ + @HostBinding('attr.role') + public role = 'combobox'; + + /** + * Enables/disables autocomplete component + * + * ```typescript + * // get + * let disabled = this.autocomplete.disabled; + * ``` + * ```html + * + * + * ``` + * ```typescript + * // set + * public disabled = true; + * ``` + */ + @Input({ alias: 'igxAutocompleteDisabled', transform: booleanAttribute }) + public disabled = false; + + /** + * Emitted after item from the drop down is selected + * + * ```html + * + * ``` + */ + @Output() + public selectionChanging = new EventEmitter(); + + /** @hidden @internal */ + public get nativeElement(): HTMLInputElement { + return this.elementRef.nativeElement; + } + + /** @hidden @internal */ + public get parentElement(): HTMLElement { + return this.group ? this.group.element.nativeElement : this.nativeElement; + } + + private get settings(): OverlaySettings { + const settings = Object.assign({}, this.defaultSettings, this.autocompleteSettings); + if (!settings.target) { + const positionStrategyClone: IPositionStrategy = settings.positionStrategy.clone(); + settings.target = this.parentElement; + settings.positionStrategy = positionStrategyClone; + } + return settings; + } + + /** @hidden @internal */ + @HostBinding('attr.aria-expanded') + public get ariaExpanded() { + return !this.collapsed; + } + + /** @hidden @internal */ + @HostBinding('attr.aria-haspopup') + public get hasPopUp() { + return 'listbox'; + } + + /** @hidden @internal */ + @HostBinding('attr.aria-owns') + public get ariaOwns() { + return this.target.listId; + } + + /** @hidden @internal */ + @HostBinding('attr.aria-activedescendant') + public get ariaActiveDescendant() { + return !this.target.collapsed && this.target.focusedItem ? this.target.focusedItem.id : null; + } + + /** @hidden @internal */ + @HostBinding('attr.aria-autocomplete') + public get ariaAutocomplete() { + return 'list'; + } + + protected _composing: boolean; + protected id: string; + protected get model() { + return this.ngModel || this.formControl; + } + + private _shouldBeOpen = false; + private destroy$ = new Subject(); + private defaultSettings: OverlaySettings; + + /** @hidden @internal */ + @HostListener('input') + public onInput() { + this.open(); + } + + /** @hidden @internal */ + @HostListener('compositionstart') + public onCompositionStart(): void { + if (!this._composing) { + this._composing = true; + } + } + + /** @hidden @internal */ + @HostListener('compositionend') + public onCompositionEnd(): void { + this._composing = false; + } + + /** @hidden @internal */ + @HostListener('keydown.ArrowDown', ['$event']) + @HostListener('keydown.Alt.ArrowDown', ['$event']) + @HostListener('keydown.ArrowUp', ['$event']) + @HostListener('keydown.Alt.ArrowUp', ['$event']) + public onArrowDown(event: Event) { + event.preventDefault(); + this.open(); + } + + /** @hidden @internal */ + @HostListener('keydown.Tab') + @HostListener('keydown.Shift.Tab') + public onTab() { + this.close(); + } + + /** @hidden @internal */ + public override handleKeyDown(event) { + if (!this.collapsed && !this._composing) { + switch (event.key.toLowerCase()) { + case 'space': + case 'spacebar': + case ' ': + case 'home': + case 'end': + return; + default: + super.handleKeyDown(event); + } + } + } + + /** @hidden @internal */ + public override onArrowDownKeyDown() { + super.onArrowDownKeyDown(); + } + + /** @hidden @internal */ + public override onArrowUpKeyDown() { + super.onArrowUpKeyDown(); + } + + /** @hidden @internal */ + public override onEndKeyDown() { + super.onEndKeyDown(); + } + + /** @hidden @internal */ + public override onHomeKeyDown() { + super.onHomeKeyDown(); + } + + /** + * Closes autocomplete drop down + */ + public close() { + this._shouldBeOpen = false; + if (this.collapsed) { + return; + } + this.target.close(); + } + + /** + * Opens autocomplete drop down + */ + public open() { + this._shouldBeOpen = true; + if (this.disabled || !this.collapsed || this.target.children.length === 0) { + return; + } + // if no drop-down width is set, the drop-down will be as wide as the autocomplete input; + this.target.width = this.target.width || (this.parentElement.clientWidth + 'px'); + this.target.open(this.settings); + this.highlightFirstItem(); + } + + /** @hidden @internal */ + public ngOnInit() { + const targetElement = this.parentElement; + this.defaultSettings = { + target: targetElement, + modal: false, + scrollStrategy: new AbsoluteScrollStrategy(), + positionStrategy: new AutoPositionStrategy(), + excludeFromOutsideClick: [targetElement] + }; + } + + /** @hidden */ + public ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + public ngAfterViewInit() { + this.target.children.changes.pipe(takeUntil(this.destroy$)).subscribe(() => { + if (this.target.children.length) { + if (!this.collapsed) { + this.highlightFirstItem(); + } else if (this._shouldBeOpen) { + this.open(); + } + } else { + // _shouldBeOpen flag should remain unchanged since this state change doesn't come from outside of the component + // (like in the case of public API or user interaction). + this.target.close(); + } + }); + this.target.selectionChanging.pipe(takeUntil(this.destroy$)).subscribe(this.select.bind(this)); + } + + private get collapsed(): boolean { + return this.target ? this.target.collapsed : true; + } + + private select(value: ISelectionEventArgs) { + if (!value.newSelection) { + return; + } + value.cancel = true; // Disable selection in the drop down, because in autocomplete we do not save selection. + const newValue = value.newSelection.value; + const args: AutocompleteSelectionChangingEventArgs = { value: newValue, cancel: false }; + this.selectionChanging.emit(args); + if (args.cancel) { + return; + } + this.close(); + + // Update model after the input is re-focused, in order to have proper valid styling. + // Otherwise when item is selected using mouse (and input is blurred), then valid style will be removed. + if (this.model) { + this.model.control.setValue(newValue); + } else { + this.nativeElement.value = newValue; + } + } + + private highlightFirstItem() { + if (this.target.focusedItem) { + this.target.focusedItem.focused = false; + this.target.focusedItem = null; + } + this.target.navigateFirst(); + this.cdr.detectChanges(); + } +} diff --git a/projects/igniteui-angular/drop-down/src/drop-down/autocomplete/autocomplete.module.ts b/projects/igniteui-angular/drop-down/src/drop-down/autocomplete/autocomplete.module.ts new file mode 100644 index 00000000000..346ba28d6b4 --- /dev/null +++ b/projects/igniteui-angular/drop-down/src/drop-down/autocomplete/autocomplete.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { IgxAutocompleteDirective } from './autocomplete.directive'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [IgxAutocompleteDirective], + exports: [IgxAutocompleteDirective] +}) +export class IgxAutocompleteModule { } diff --git a/projects/igniteui-angular/drop-down/src/drop-down/drop-down-group.component.ts b/projects/igniteui-angular/drop-down/src/drop-down/drop-down-group.component.ts new file mode 100644 index 00000000000..ef5ad79ba16 --- /dev/null +++ b/projects/igniteui-angular/drop-down/src/drop-down/drop-down-group.component.ts @@ -0,0 +1,93 @@ +import { Component, Input, HostBinding, booleanAttribute } from '@angular/core'; + +let NEXT_ID = 0; +/** + * The `` is a container intended for row items in + * a `` container. + */ +@Component({ + selector: 'igx-drop-down-item-group', + template: ` + + + `, + standalone: true +}) +export class IgxDropDownGroupComponent { + /** + * @hidden @internal + */ + public get labelId(): string { + return `igx-item-group-label-${this._id}`; + } + + @HostBinding(`attr.aria-labelledby`) + public get labelledBy(): string { + return this.labelId; + } + + /** + * @hidden @internal + */ + @HostBinding('attr.role') + public role = 'group'; + + /** @hidden @internal */ + @HostBinding('class.igx-drop-down__group') + public groupClass = true; + /** + * Sets/gets if the item group is disabled + * + * ```typescript + * const myDropDownGroup: IgxDropDownGroupComponent = this.dropdownGroup; + * // get + * ... + * const groupState: boolean = myDropDownGroup.disabled; + * ... + * //set + * ... + * myDropDownGroup,disabled = false; + * ... + * ``` + * + * ```html + * + * + * {{ item.text }} + * + * + * ``` + * + * **NOTE:** All items inside of a disabled drop down group will be treated as disabled + */ + @Input({ transform: booleanAttribute }) + @HostBinding(`attr.aria-disabled`) + @HostBinding('class.igx-drop-down__group--disabled') + public disabled = false; + + /** + * Sets/gets the label of the item group + * + * ```typescript + * const myDropDownGroup: IgxDropDownGroupComponent = this.dropdownGroup; + * // get + * ... + * const myLabel: string = myDropDownGroup.label; + * ... + * // set + * ... + * myDropDownGroup.label = 'My New Label'; + * ... + * ``` + * + * ```html + * + * ... + * + * ``` + */ + @Input() + public label: string; + + private _id = NEXT_ID++; +} diff --git a/projects/igniteui-angular/drop-down/src/drop-down/drop-down-item.base.ts b/projects/igniteui-angular/drop-down/src/drop-down/drop-down-item.base.ts new file mode 100644 index 00000000000..4d6d57664bb --- /dev/null +++ b/projects/igniteui-angular/drop-down/src/drop-down/drop-down-item.base.ts @@ -0,0 +1,312 @@ +import { IDropDownBase, IGX_DROPDOWN_BASE } from './drop-down.common'; +import { Directive, Input, HostBinding, HostListener, ElementRef, Output, EventEmitter, booleanAttribute, DoCheck, inject } from '@angular/core'; +import { IgxSelectionAPIService } from 'igniteui-angular/core'; +import { IgxDropDownGroupComponent } from './drop-down-group.component'; + +let NEXT_ID = 0; + +/** + * An abstract class defining a drop-down item: + * With properties / styles for selection, highlight, height + * Bindable property for passing data (`value: any`) + * Parent component (has to be used under a parent with type `IDropDownBase`) + * Method for handling click on Host() + */ +@Directive({ + selector: '[igxDropDownItemBase]', + standalone: true +}) +export class IgxDropDownItemBaseDirective implements DoCheck { + protected dropDown = inject(IGX_DROPDOWN_BASE); + protected elementRef = inject(ElementRef); + protected group = inject(IgxDropDownGroupComponent, { optional: true }); + protected selection? = inject(IgxSelectionAPIService, { optional: true }); + + /** + * Sets/gets the `id` of the item. + * ```html + * + * ``` + * ```typescript + * let itemId = this.item.id; + * ``` + * + * @memberof IgxSelectItemComponent + */ + @HostBinding('attr.id') + @Input() + public id = `igx-drop-down-item-${NEXT_ID++}`; + + @HostBinding('attr.aria-label') + @Input() + public get ariaLabel(): string | null{ + return this._label ? this._label : this.value ? this.value : null; + } + + public set ariaLabel(value: string | null) { + this._label = value; + } + + /** + * @hidden @internal + */ + public get itemID() { + return this; + } + + /** + * The data index of the dropdown item. + * + * ```typescript + * // get the data index of the selected dropdown item + * let selectedItemIndex = this.dropdown.selectedItem.index + * ``` + */ + @Input() + public get index(): number { + if (this._index === null) { + return this.itemIndex; + } + return this._index; + } + + public set index(value) { + this._index = value; + } + + /** + * Gets/sets the value of the item if the item is databound + * + * ```typescript + * // usage in IgxDropDownItemComponent + * // get + * let mySelectedItemValue = this.dropdown.selectedItem.value; + * + * // set + * let mySelectedItem = this.dropdown.selectedItem; + * mySelectedItem.value = { id: 123, name: 'Example Name' } + * + * // usage in IgxComboItemComponent + * // get + * let myComboItemValue = this.combo.items[0].value; + * ``` + */ + @Input() + public value: any; + + /** + * @hidden @internal + */ + @HostBinding('class.igx-drop-down__item') + public get itemStyle(): boolean { + return !this.isHeader; + } + + /** + * Sets/Gets if the item is the currently selected one in the dropdown + * + * ```typescript + * let mySelectedItem = this.dropdown.selectedItem; + * let isMyItemSelected = mySelectedItem.selected; // true + * ``` + * + * Two-way data binding + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + @HostBinding('attr.aria-selected') + @HostBinding('class.igx-drop-down__item--selected') + public get selected(): boolean { + return this._selected; + } + + public set selected(value: boolean) { + if (this.isHeader) { + return; + } + this._selected = value; + this.selectedChange.emit(this._selected); + } + + /** + * @hidden + */ + @Output() + public selectedChange = new EventEmitter(); + + /** + * Sets/gets if the given item is focused + * ```typescript + * let mySelectedItem = this.dropdown.selectedItem; + * let isMyItemFocused = mySelectedItem.focused; + * ``` + */ + @HostBinding('class.igx-drop-down__item--focused') + public get focused(): boolean { + return this.isSelectable && this._focused; + } + + /** + * ```html + * + *
    + * {{item.field}} + *
    + *
    + * ``` + */ + public set focused(value: boolean) { + this._focused = value; + } + + /** + * Sets/gets if the given item is header + * ```typescript + * // get + * let mySelectedItem = this.dropdown.selectedItem; + * let isMyItemHeader = mySelectedItem.isHeader; + * ``` + * + * ```html + * + * + *
    + * {{item.field}} + *
    + *
    + * ``` + */ + @Input({ transform: booleanAttribute }) + @HostBinding('class.igx-drop-down__header') + public isHeader: boolean; + + /** + * Sets/gets if the given item is disabled + * + * ```typescript + * // get + * let mySelectedItem = this.dropdown.selectedItem; + * let myItemIsDisabled = mySelectedItem.disabled; + * ``` + * + * ```html + * + *
    + * {{item.field}} + *
    + *
    + * ``` + * **NOTE:** Drop-down items inside of a disabled `IgxDropDownGroup` will always count as disabled + */ + @Input({ transform: booleanAttribute }) + @HostBinding('attr.aria-disabled') + @HostBinding('class.igx-drop-down__item--disabled') + public get disabled(): boolean { + return this.group ? this.group.disabled || this._disabled : this._disabled; + } + + public set disabled(value: boolean) { + this._disabled = value; + } + + /** + * Gets/sets the `role` attribute of the item. Default is 'option'. + * + * ```html + * + * ``` + */ + @Input() + @HostBinding('attr.role') + public role = 'option'; + + /** + * Gets item index + * + * @hidden @internal + */ + public get itemIndex(): number { + return this.dropDown.items.indexOf(this); + } + + /** + * Gets item element height + * + * @hidden @internal + */ + public get elementHeight(): number { + return this.elementRef.nativeElement.clientHeight; + } + + /** + * Get item html element + * + * @hidden @internal + */ + public get element(): ElementRef { + return this.elementRef; + } + + protected get hasIndex(): boolean { + return this._index !== null && this._index !== undefined; + } + + /** + * @hidden + */ + protected _focused = false; + protected _selected = false; + protected _index = null; + protected _disabled = false; + protected _label = null; + + /** + * @hidden + * @internal + */ + @HostListener('click', ['$event']) + public clicked(event): void { // eslint-disable-line + } + + /** + * @hidden + * @internal + */ + @HostListener('mousedown', ['$event']) + public handleMousedown(event: MouseEvent): void { + if (!this.dropDown.allowItemsFocus) { + event.preventDefault(); + } + } + + public ngDoCheck(): void { + if (this._selected) { + const dropDownSelectedItem = this.dropDown.selectedItem; + if (!dropDownSelectedItem) { + this.dropDown.selectItem(this, undefined, false); + } else if (this.hasIndex + ? this._index !== dropDownSelectedItem.index || this.value !== dropDownSelectedItem.value : + this !== dropDownSelectedItem) { + this.dropDown.selectItem(this, undefined, false); + } + } + } + + /** Returns true if the items is not a header or disabled */ + protected get isSelectable(): boolean { + return !(this.disabled || this.isHeader); + } + + /** If `allowItemsFocus` is enabled, keep the browser focus on the active item */ + protected ensureItemFocus() { + if (this.dropDown.allowItemsFocus) { + const focusedItem = this.dropDown.items.find((item) => item.focused); + if (!focusedItem) { + return; + } + focusedItem.element.nativeElement.focus({ preventScroll: true }); + } + } +} diff --git a/projects/igniteui-angular/drop-down/src/drop-down/drop-down-item.component.html b/projects/igniteui-angular/drop-down/src/drop-down/drop-down-item.component.html new file mode 100644 index 00000000000..2c15401c6f1 --- /dev/null +++ b/projects/igniteui-angular/drop-down/src/drop-down/drop-down-item.component.html @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/projects/igniteui-angular/drop-down/src/drop-down/drop-down-item.component.ts b/projects/igniteui-angular/drop-down/src/drop-down/drop-down-item.component.ts new file mode 100644 index 00000000000..451e117226e --- /dev/null +++ b/projects/igniteui-angular/drop-down/src/drop-down/drop-down-item.component.ts @@ -0,0 +1,98 @@ +import { + Component, + HostBinding +} from '@angular/core'; +import { IgxDropDownItemBaseDirective } from './drop-down-item.base'; + +/** + * The `` is a container intended for row items in + * a `` container. + */ +@Component({ + selector: 'igx-drop-down-item', + templateUrl: 'drop-down-item.component.html', + standalone: true +}) +export class IgxDropDownItemComponent extends IgxDropDownItemBaseDirective { + /** + * Sets/gets if the given item is focused + * ```typescript + * let mySelectedItem = this.dropdown.selectedItem; + * let isMyItemFocused = mySelectedItem.focused; + * ``` + */ + public override get focused(): boolean { + let focusedState = this._focused; + if (this.hasIndex) { + const focusedItem = this.selection.first_item(`${this.dropDown.id}-active`); + const focusedIndex = focusedItem ? focusedItem.index : -1; + focusedState = this._index === focusedIndex; + } + return this.isSelectable && focusedState; + } + + /** + * Sets/gets if the given item is focused + * ```typescript + * let mySelectedItem = this.dropdown.selectedItem; + * let isMyItemFocused = mySelectedItem.focused; + * ``` + */ + public override set focused(value: boolean) { + this._focused = value; + } + /** + * Sets/Gets if the item is the currently selected one in the dropdown + * + * ```typescript + * let mySelectedItem = this.dropdown.selectedItem; + * let isMyItemSelected = mySelectedItem.selected; // true + * ``` + * + * Two-way data binding + * ```html + * + * ``` + */ + public override get selected(): boolean { + if (this.hasIndex) { + const item = this.selection.first_item(`${this.dropDown.id}`); + return item ? item.index === this._index && item.value === this.value : false; + } + return this._selected; + } + + /** + * Sets/Gets if the item is the currently selected one in the dropdown + * + */ + public override set selected(value: boolean) { + if (this.isHeader) { + return; + } + this._selected = value; + this.selectedChange.emit(this._selected); + } + /** + * @hidden @internal + */ + @HostBinding('attr.tabindex') + public get setTabIndex() { + const shouldSetTabIndex = this.dropDown.allowItemsFocus && this.isSelectable; + if (shouldSetTabIndex) { + return 0; + } else { + return null; + } + } + + public override clicked(event): void { + if (!this.isSelectable) { + this.ensureItemFocus(); + return; + } + if (this.selection) { + this.dropDown.selectItem(this, event); + } + } +} diff --git a/projects/igniteui-angular/drop-down/src/drop-down/drop-down-navigation.directive.ts b/projects/igniteui-angular/drop-down/src/drop-down/drop-down-navigation.directive.ts new file mode 100644 index 00000000000..88be5af2b87 --- /dev/null +++ b/projects/igniteui-angular/drop-down/src/drop-down/drop-down-navigation.directive.ts @@ -0,0 +1,142 @@ +import { Directive, Input, HostListener, inject, HostBinding } from '@angular/core'; +import { IGX_DROPDOWN_BASE } from './drop-down.common'; +import { IDropDownNavigationDirective } from './drop-down.common'; +import { IgxDropDownBaseDirective } from './drop-down.base'; +import { DropDownActionKey } from './drop-down.common'; + +/** + * Navigation Directive that handles keyboard events on its host and controls a targeted IgxDropDownBaseDirective component + */ +@Directive({ + selector: '[igxDropDownItemNavigation]', + standalone: true +}) +export class IgxDropDownItemNavigationDirective implements IDropDownNavigationDirective { + public dropdown = inject(IGX_DROPDOWN_BASE, { self: true, optional: true }); + + + protected _target: IgxDropDownBaseDirective = null; + + /** + * Gets the target of the navigation directive; + * + * ```typescript + * // Get + * export class MyComponent { + * ... + * @ContentChild(IgxDropDownNavigationDirective) + * navDirective: IgxDropDownNavigationDirective = null + * ... + * const navTarget: IgxDropDownBaseDirective = navDirective.navTarget + * } + * ``` + */ + public get target(): IgxDropDownBaseDirective { + return this._target; + } + + /** + * Sets the target of the navigation directive; + * If no valid target is passed, it falls back to the drop down context + * + * ```html + * + * + * ... + * + * ... + * + * ``` + */ + @Input('igxDropDownItemNavigation') + public set target(target: IgxDropDownBaseDirective) { + this._target = target ? target : this.dropdown; + } + + @HostBinding('attr.aria-activedescendant') + public get activeDescendant(): string { + return this._target?.activeDescendant; + } + + /** + * Captures keydown events and calls the appropriate handlers on the target component + */ + @HostListener('keydown', ['$event']) + public handleKeyDown(event: KeyboardEvent) { + if (event) { + const key = event.key.toLowerCase(); + if (!this.target.collapsed) { // If dropdown is opened + const navKeys = ['esc', 'escape', 'enter', 'space', 'spacebar', ' ', + 'arrowup', 'up', 'arrowdown', 'down', 'home', 'end', 'tab']; + if (navKeys.indexOf(key) === -1) { // If key has appropriate function in DD + return; + } + event.preventDefault(); + event.stopPropagation(); + } else { // If dropdown is closed, do nothing + return; + } + switch (key) { + case 'esc': + case 'escape': + this.target.onItemActionKey(DropDownActionKey.ESCAPE, event); + break; + case 'enter': + this.target.onItemActionKey(DropDownActionKey.ENTER, event); + break; + case 'space': + case 'spacebar': + case ' ': + this.target.onItemActionKey(DropDownActionKey.SPACE, event); + break; + case 'arrowup': + case 'up': + this.onArrowUpKeyDown(); + break; + case 'arrowdown': + case 'down': + this.onArrowDownKeyDown(); + break; + case 'home': + this.onHomeKeyDown(); + break; + case 'end': + this.onEndKeyDown(); + break; + case 'tab': + this.target.onItemActionKey(DropDownActionKey.TAB, event); + break; + default: + return; + } + } + } + + /** + * Navigates to previous item + */ + public onArrowDownKeyDown() { + this.target.navigateNext(); + } + + /** + * Navigates to previous item + */ + public onArrowUpKeyDown() { + this.target.navigatePrev(); + } + + /** + * Navigates to target's last item + */ + public onEndKeyDown() { + this.target.navigateLast(); + } + + /** + * Navigates to target's first item + */ + public onHomeKeyDown() { + this.target.navigateFirst(); + } +} diff --git a/projects/igniteui-angular/drop-down/src/drop-down/drop-down.base.ts b/projects/igniteui-angular/drop-down/src/drop-down/drop-down.base.ts new file mode 100644 index 00000000000..6a65ef09274 --- /dev/null +++ b/projects/igniteui-angular/drop-down/src/drop-down/drop-down.base.ts @@ -0,0 +1,322 @@ +import { + Input, HostBinding, ElementRef, QueryList, Output, EventEmitter, ChangeDetectorRef, Directive, + OnInit, + DOCUMENT, + inject +} from '@angular/core'; + +import { Navigate, ISelectionEventArgs } from './drop-down.common'; +import { IDropDownList } from './drop-down.common'; +import { DropDownActionKey } from './drop-down.common'; +import { IgxDropDownItemBaseDirective } from './drop-down-item.base'; + +let NEXT_ID = 0; + +/** + * An abstract class, defining a drop-down component, with: + * Properties for display styles and classes + * A collection items of type `IgxDropDownItemBaseDirective` + * Properties and methods for navigating (highlighting/focusing) items from the collection + * Properties and methods for selecting items from the collection + */ +@Directive() +export abstract class IgxDropDownBaseDirective implements IDropDownList, OnInit { + protected elementRef = inject(ElementRef); + protected cdr = inject(ChangeDetectorRef); + public document = inject(DOCUMENT); + + /** + * Emitted when item selection is changing, before the selection completes + * + * ```html + * + * ``` + */ + @Output() + public selectionChanging = new EventEmitter(); + + /** + * Gets/Sets the width of the drop down + * + * ```typescript + * // get + * let myDropDownCurrentWidth = this.dropdown.width; + * ``` + * ```html + * + * + * ``` + */ + @Input() + public width: string; + + /** + * Gets/Sets the height of the drop down + * + * ```typescript + * // get + * let myDropDownCurrentHeight = this.dropdown.height; + * ``` + * ```html + * + * + * ``` + */ + @Input() + public height: string; + + /** + * Gets/Sets the drop down's id + * + * ```typescript + * // get + * let myDropDownCurrentId = this.dropdown.id; + * ``` + * ```html + * + * + * ``` + */ + @HostBinding('attr.id') + @Input() + public get id(): string { + return this._id; + } + public set id(value: string) { + this._id = value; + } + + /** + * Gets/Sets the drop down's container max height. + * + * ```typescript + * // get + * let maxHeight = this.dropdown.maxHeight; + * ``` + * ```html + * + * + * ``` + */ + @Input() + @HostBinding('style.maxHeight') + public maxHeight = null; + + /** + * @hidden @internal + */ + @HostBinding('class.igx-drop-down') + public cssClass = true; + + /** + * Get all non-header items + * + * ```typescript + * let myDropDownItems = this.dropdown.items; + * ``` + */ + public get items(): IgxDropDownItemBaseDirective[] { + const items: IgxDropDownItemBaseDirective[] = []; + if (this.children !== undefined) { + for (const child of this.children.toArray()) { + if (!child.isHeader) { + items.push(child); + } + } + } + + return items; + } + + /** + * Get all header items + * + * ```typescript + * let myDropDownHeaderItems = this.dropdown.headers; + * ``` + */ + public get headers(): IgxDropDownItemBaseDirective[] { + const headers: IgxDropDownItemBaseDirective[] = []; + if (this.children !== undefined) { + for (const child of this.children.toArray()) { + if (child.isHeader) { + headers.push(child); + } + } + } + + return headers; + } + + /** + * Get dropdown html element + * + * ```typescript + * let myDropDownElement = this.dropdown.element; + * ``` + */ + public get element() { + return this.elementRef.nativeElement; + } + /** + * @hidden @internal + * Get dropdown's html element of its scroll container + */ + public get scrollContainer(): HTMLElement { + return this.element; + } + + /** + * @hidden @internal + * Gets the id of the focused item during dropdown navigation. + * This is used to update the `aria-activedescendant` attribute of + * the IgxDropDownNavigationDirective host element. + */ + public get activeDescendant (): string { + return this.focusedItem ? this.focusedItem.id : null; + } + + /** + * @hidden + * @internal + */ + public children: QueryList; + + protected _width; + protected _height; + protected _focusedItem: any = null; + protected _id = `igx-drop-down-${NEXT_ID++}`; + protected computedStyles; + + /** + * Gets if the dropdown is collapsed + */ + public abstract readonly collapsed: boolean; + + public ngOnInit(): void { + this.computedStyles = this.document.defaultView.getComputedStyle(this.elementRef.nativeElement); + } + + /** Keydown Handler */ + public onItemActionKey(key: DropDownActionKey, event?: Event) { + switch (key) { + case DropDownActionKey.ENTER: + case DropDownActionKey.SPACE: + this.selectItem(this.focusedItem, event); + break; + case DropDownActionKey.ESCAPE: + case DropDownActionKey.TAB: + } + } + + /** + * Emits selectionChanging with the target item & event + * + * @hidden @internal + * @param newSelection the item selected + * @param event the event that triggered the call + */ + public selectItem(newSelection?: IgxDropDownItemBaseDirective, event?: Event, emit = true) { // eslint-disable-line + this.selectionChanging.emit({ + newSelection, + oldSelection: null, + cancel: false + }); + } + + /** + * @hidden @internal + */ + public get focusedItem(): IgxDropDownItemBaseDirective { + return this._focusedItem; + } + + /** + * @hidden @internal + */ + public set focusedItem(item: IgxDropDownItemBaseDirective) { + this._focusedItem = item; + } + + /** + * Navigates to the item on the specified index + * + * @param newIndex number - the index of the item in the `items` collection + */ + public navigateItem(newIndex: number) { + if (newIndex !== -1) { + const oldItem = this._focusedItem; + const newItem = this.items[newIndex]; + if (oldItem) { + oldItem.focused = false; + } + this.focusedItem = newItem; + this.scrollToHiddenItem(newItem); + this.focusedItem.focused = true; + } + } + + /** + * @hidden @internal + */ + public navigateFirst() { + this.navigate(Navigate.Down, -1); + } + + /** + * @hidden @internal + */ + public navigateLast() { + this.navigate(Navigate.Up, this.items.length); + } + + /** + * @hidden @internal + */ + public navigateNext() { + this.navigate(Navigate.Down); + } + + /** + * @hidden @internal + */ + public navigatePrev() { + this.navigate(Navigate.Up); + } + + protected scrollToHiddenItem(newItem: IgxDropDownItemBaseDirective) { + const elementRect = newItem.element.nativeElement.getBoundingClientRect(); + const parentRect = this.scrollContainer.getBoundingClientRect(); + if (parentRect.top > elementRect.top) { + this.scrollContainer.scrollTop -= (parentRect.top - elementRect.top); + } + + if (parentRect.bottom < elementRect.bottom) { + this.scrollContainer.scrollTop += (elementRect.bottom - parentRect.bottom); + } + } + + protected navigate(direction: Navigate, currentIndex?: number) { + let index = -1; + if (this._focusedItem) { + index = currentIndex ? currentIndex : this.focusedItem.itemIndex; + } + const newIndex = this.getNearestSiblingFocusableItemIndex(index, direction); + this.navigateItem(newIndex); + } + + protected getNearestSiblingFocusableItemIndex(startIndex: number, direction: Navigate): number { + let index = startIndex; + const items = this.items; + while (items[index + direction] && items[index + direction].disabled) { + index += direction; + } + + index += direction; + if (index >= 0 && index < items.length) { + return index; + } else { + return -1; + } + } +} diff --git a/projects/igniteui-angular/drop-down/src/drop-down/drop-down.common.ts b/projects/igniteui-angular/drop-down/src/drop-down/drop-down.common.ts new file mode 100644 index 00000000000..5a2d064c592 --- /dev/null +++ b/projects/igniteui-angular/drop-down/src/drop-down/drop-down.common.ts @@ -0,0 +1,81 @@ +import { CancelableEventArgs, CancelableBrowserEventArgs, IBaseEventArgs } from 'igniteui-angular/core'; +import { IgxDropDownItemBaseDirective } from './drop-down-item.base'; +import { IToggleView } from 'igniteui-angular/core'; +import { EventEmitter, InjectionToken } from '@angular/core'; + +/** @hidden */ +export enum Navigate { + Up = -1, + Down = 1 +} + +/** Key actions that have designated handlers in IgxDropDownComponent */ +export const DropDownActionKey = { + ESCAPE: 'escape', + ENTER: 'enter', + SPACE: 'space', + TAB: 'tab' +} as const; +export type DropDownActionKey = (typeof DropDownActionKey)[keyof typeof DropDownActionKey]; + +/** + * Interface that encapsulates selectionChanging event arguments - old selection, new selection and cancel selection. + * + * @export + */ +export interface ISelectionEventArgs extends CancelableEventArgs, IBaseEventArgs { + oldSelection: IgxDropDownItemBaseDirective; + newSelection: IgxDropDownItemBaseDirective; +} + +/** + * Interface for an instance of IgxDropDownNavigationDirective + * + * @export + */ +export interface IDropDownNavigationDirective { + target: any; + handleKeyDown(event: KeyboardEvent): void; + onArrowDownKeyDown(event?: KeyboardEvent): void; + onArrowUpKeyDown(event?: KeyboardEvent): void; + onEndKeyDown(event?: KeyboardEvent): void; + onHomeKeyDown(event?: KeyboardEvent): void; +} + +export const IGX_DROPDOWN_BASE = /*@__PURE__*/new InjectionToken('IgxDropDownBaseToken'); + +/** + * @hidden + */ +export interface IDropDownList { + selectionChanging: EventEmitter; + width: string; + height: string; + id: string; + maxHeight: string; + collapsed: boolean; + items: IgxDropDownItemBaseDirective[]; + headers: IgxDropDownItemBaseDirective[]; + focusedItem: IgxDropDownItemBaseDirective; + navigateFirst(): void; + navigateLast(): void; + navigateNext(): void; + navigatePrev(): void; + navigateItem(newIndex: number, direction?: Navigate): void; + onItemActionKey(key: DropDownActionKey, event?: Event): void; +} + +/** + * @hidden + */ +export interface IDropDownBase extends IDropDownList, IToggleView { + selectedItem: any; + opening: EventEmitter; + opened: EventEmitter; + closing: EventEmitter; + closed: EventEmitter; + allowItemsFocus?: boolean; + setSelectedItem(index: number): void; + selectItem(item: IgxDropDownItemBaseDirective, event?: Event, emit?: boolean): void; +} + diff --git a/projects/igniteui-angular/drop-down/src/drop-down/drop-down.component.html b/projects/igniteui-angular/drop-down/src/drop-down/drop-down.component.html new file mode 100644 index 00000000000..a89e2163282 --- /dev/null +++ b/projects/igniteui-angular/drop-down/src/drop-down/drop-down.component.html @@ -0,0 +1,13 @@ +
    +
    + @if (!collapsed) { + + } +
    +
    diff --git a/projects/igniteui-angular/drop-down/src/drop-down/drop-down.component.spec.ts b/projects/igniteui-angular/drop-down/src/drop-down/drop-down.component.spec.ts new file mode 100644 index 00000000000..cd854d289a5 --- /dev/null +++ b/projects/igniteui-angular/drop-down/src/drop-down/drop-down.component.spec.ts @@ -0,0 +1,1620 @@ +import { Component, ViewChild, OnInit, ElementRef, ViewChildren, QueryList, ChangeDetectorRef, DOCUMENT } from '@angular/core'; +import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxToggleActionDirective, IgxToggleDirective } from '../../../directives/src/directives/toggle/toggle.directive'; +import { IgxDropDownItemComponent } from './drop-down-item.component'; +import { IgxDropDownComponent, IgxDropDownItemNavigationDirective } from './public_api'; +import { ISelectionEventArgs } from './drop-down.common'; +import { IgxTabContentComponent, IgxTabHeaderComponent, IgxTabItemComponent, IgxTabsComponent } from 'igniteui-angular/tabs'; +import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; +import { CancelableEventArgs, IBaseCancelableBrowserEventArgs } from 'igniteui-angular/core'; +import { take } from 'rxjs/operators'; +import { IgxDropDownGroupComponent } from './drop-down-group.component'; +import { IgxForOfDirective } from '../../../directives/src/directives/for-of/for_of.directive'; +import { IgxDropDownItemBaseDirective } from './drop-down-item.base'; +import { IgxSelectionAPIService } from 'igniteui-angular/core'; +import { IgxButtonDirective } from '../../../directives/src/directives/button/button.directive'; +import { ConnectedPositioningStrategy, HorizontalAlignment, OverlaySettings, VerticalAlignment } from 'igniteui-angular/core'; + +const CSS_CLASS_DROP_DOWN_BASE = 'igx-drop-down'; +const CSS_CLASS_LIST = 'igx-drop-down__list'; +const CSS_CLASS_SCROLL = 'igx-drop-down__list-scroll'; +const CSS_CLASS_ITEM = 'igx-drop-down__item'; +const CSS_CLASS_INNER_SPAN = 'igx-drop-down__inner'; +const CSS_CLASS_GROUP_ITEM = 'igx-drop-down__group'; +const CSS_CLASS_FOCUSED = 'igx-drop-down__item--focused'; +const CSS_CLASS_SELECTED = 'igx-drop-down__item--selected'; +const CSS_CLASS_DISABLED = 'igx-drop-down__item--disabled'; +const CSS_CLASS_HEADER = 'igx-drop-down__header'; +const CSS_CLASS_TABS = '.igx-tabs__header-item'; + +describe('IgxDropDown ', () => { + let fixture; + let dropdown: IgxDropDownComponent; + describe('Unit tests', () => { + const data = [ + { value: 'Item0', index: 0 } as IgxDropDownItemComponent, + { value: 'Item1', index: 1 } as IgxDropDownItemComponent, + { value: 'Item2', index: 2 } as IgxDropDownItemComponent, + { value: 'Item3', index: 3 } as IgxDropDownItemComponent, + { value: 'Item4', index: 4 } as IgxDropDownItemComponent, + { value: 'Item5', index: 5 } as IgxDropDownItemComponent]; + const mockSelection: { + [key: string]: jasmine.Spy; + } = jasmine.createSpyObj('IgxSelectionAPIService', ['get', 'set', 'add_items', 'select_items', 'delete']); + const mockCdr = jasmine.createSpyObj('ChangeDetectorRef', ['markForCheck', 'detectChanges']); + mockSelection.get.and.returnValue(new Set([])); + const mockForOf = jasmine.createSpyObj('IgxForOfDirective', ['totalItemCount']); + const mockDocument = jasmine.createSpyObj('DOCUMENT', [], { 'defaultView': { getComputedStyle: () => null }}); + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { provide: ElementRef, useValue: { nativeElement: null } }, + { provide: ChangeDetectorRef, useValue: mockCdr }, + { provide: DOCUMENT, useValue: mockDocument }, + IgxSelectionAPIService, + IgxDropDownComponent + ] + }); + + dropdown = TestBed.inject(IgxDropDownComponent); + }); + it('should notify when selection has changed', () => { + (dropdown as any).virtDir = mockForOf; + spyOnProperty(dropdown, 'items', 'get').and.returnValue(data); + spyOn(dropdown.selectionChanging, 'emit').and.callThrough(); + + dropdown.selectItem(data[0]); + expect(dropdown.selectedItem).toEqual(data[0]); + expect(dropdown.selectionChanging.emit).toHaveBeenCalledTimes(1); + + dropdown.selectItem(data[4]); + expect(dropdown.selectedItem).toEqual(data[4]); + expect(dropdown.selectionChanging.emit).toHaveBeenCalledTimes(2); + }); + it('should fire selectionChanging with correct args', () => { + (dropdown as any).virtDir = mockForOf; + spyOnProperty(dropdown, 'items', 'get').and.returnValue(data); + spyOn(dropdown.selectionChanging, 'emit').and.callThrough(); + + const selectionArgs: ISelectionEventArgs = { + newSelection: dropdown.items[1], + oldSelection: null, + cancel: false, + owner: dropdown + }; + dropdown.selectItem(data[1]); + expect(dropdown.selectionChanging.emit).toHaveBeenCalledWith(selectionArgs); + + const newSelectionArgs: ISelectionEventArgs = { + newSelection: dropdown.items[4], + oldSelection: dropdown.items[1], + cancel: false, + owner: dropdown + }; + dropdown.selectItem(data[4]); + expect(dropdown.selectionChanging.emit).toHaveBeenCalledWith(newSelectionArgs); + }); + it('should notify when selection is cleared', () => { + (dropdown as any).virtDir = mockForOf; + spyOnProperty(dropdown, 'items', 'get').and.returnValue(data); + spyOn(dropdown.selectionChanging, 'emit').and.callThrough(); + spyOn(dropdown.closed, 'emit').and.callThrough(); + + dropdown.selectItem(data[1]); + const selected = dropdown.selectedItem; + expect(dropdown.selectedItem).toEqual(data[1]); + expect(dropdown.selectionChanging.emit).toHaveBeenCalledTimes(1); + let args: ISelectionEventArgs = { + oldSelection: null, + newSelection: data[1], + cancel: false, + owner: dropdown + }; + expect(dropdown.selectionChanging.emit).toHaveBeenCalledWith(args); + + dropdown.clearSelection(); + expect(dropdown.selectedItem).toBeNull(); + expect(dropdown.selectionChanging.emit).toHaveBeenCalledTimes(2); + args = { + oldSelection: selected, + newSelection: null, + cancel: false, + owner: dropdown + }; + expect(dropdown.selectionChanging.emit).toHaveBeenCalledWith(args); + }); + it('setSelectedItem should return selected item', () => { + (dropdown as any).virtDir = mockForOf; + (dropdown as any).virtDir.igxForOf = data; + spyOnProperty(dropdown, 'items', 'get').and.returnValue(data); + + expect(dropdown.selectedItem).toBeNull(); + + dropdown.setSelectedItem(3); + const selectedItem = dropdown.selectedItem; + expect(selectedItem).toBeTruthy(); + expect(selectedItem.index).toEqual(3); + }); + it('setSelectedItem should return null when selection is cleared', () => { + (dropdown as any).virtDir = mockForOf; + (dropdown as any).virtDir.igxForOf = data; + spyOnProperty(dropdown, 'items', 'get').and.returnValue(data); + + dropdown.setSelectedItem(3); + expect(dropdown.selectedItem).toBeTruthy(); + expect(dropdown.selectedItem.index).toEqual(3); + + dropdown.clearSelection(); + expect(dropdown.selectedItem).toBeNull(); + }); + it('toggle should call open method when dropdown is collapsed', () => { + (dropdown as any).virtDir = mockForOf; + spyOnProperty(dropdown, 'items', 'get').and.returnValue(data); + spyOnProperty(dropdown, 'collapsed', 'get').and.returnValue(true); + spyOn(dropdown, 'open'); + + dropdown.toggle(); + expect(dropdown.open).toHaveBeenCalledTimes(1); + }); + it('toggle should call close method when dropdown is opened', () => { + (dropdown as any).virtDir = mockForOf; + const mockToggle = jasmine.createSpyObj('IgxToggleDirective', ['open']); + mockToggle.isClosing = false; + (dropdown as any).toggleDirective = mockToggle; + spyOnProperty(dropdown, 'items', 'get').and.returnValue(data); + spyOnProperty(dropdown, 'collapsed', 'get').and.returnValue(false); + spyOn(dropdown, 'close'); + + dropdown.toggle(); + expect(dropdown.close).toHaveBeenCalledTimes(1); + }); + it('should remove selection on destroy', () => { + const selectionService = TestBed.inject(IgxSelectionAPIService); + const selectionDeleteSpy = spyOn(selectionService, 'delete'); + dropdown.ngOnDestroy(); + expect(selectionDeleteSpy).toHaveBeenCalled(); + }); + }); + describe('User interaction tests', () => { + describe('Selection & key navigation', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxDropDownTestComponent + ] + }).compileComponents(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(IgxDropDownTestComponent); + fixture.detectChanges(); + dropdown = fixture.componentInstance.dropdown; + }); + it('should toggle drop down on open/close methods call', fakeAsync(() => { + spyOn(dropdown, 'onToggleOpening'); + spyOn(dropdown, 'onToggleOpened'); + spyOn(dropdown, 'onToggleClosing'); + spyOn(dropdown, 'onToggleClosed'); + + expect(dropdown.collapsed).toBeTruthy(); + dropdown.open(); + tick(); + fixture.detectChanges(); + expect(dropdown.collapsed).toBeFalsy(); + expect(dropdown.onToggleOpening).toHaveBeenCalledTimes(1); + expect(dropdown.onToggleOpened).toHaveBeenCalledTimes(1); + + dropdown.close(); + tick(); + fixture.detectChanges(); + expect(dropdown.collapsed).toBeTruthy(); + expect(dropdown.onToggleClosing).toHaveBeenCalledTimes(1); + expect(dropdown.onToggleClosed).toHaveBeenCalledTimes(1); + })); + it('#3810 - should not emit events when calling open on opened dropdown', fakeAsync(() => { + spyOn(dropdown.opening, 'emit').and.callThrough(); + spyOn(dropdown.opened, 'emit').and.callThrough(); + + dropdown.open(); + tick(); + fixture.detectChanges(); + + expect(dropdown.opening.emit).toHaveBeenCalledTimes(1); + expect(dropdown.opened.emit).toHaveBeenCalledTimes(1); + + dropdown.open(); + tick(); + fixture.detectChanges(); + + expect(dropdown.opening.emit).toHaveBeenCalledTimes(1); + expect(dropdown.opened.emit).toHaveBeenCalledTimes(1); + })); + it('should use default overlay settings if none are provided', () => { + const toggle: IgxToggleDirective = (dropdown as any).toggleDirective; + + spyOn(toggle, 'open').and.callThrough(); + + dropdown.open(); + fixture.detectChanges(); + expect(toggle.open).toHaveBeenCalledTimes(1); + + const appliedSettings = (toggle.open as jasmine.Spy).calls.mostRecent().args[0]; + expect(appliedSettings.closeOnOutsideClick).toBe(true); + expect(appliedSettings.modal).toBe(false); + expect(appliedSettings.positionStrategy instanceof ConnectedPositioningStrategy).toBe(true); + + const positionStrategy = appliedSettings.positionStrategy as ConnectedPositioningStrategy; + expect(positionStrategy.settings.horizontalStartPoint).toBe(HorizontalAlignment.Left); + expect(positionStrategy.settings.verticalStartPoint).toBe(VerticalAlignment.Bottom); + expect(positionStrategy.settings.horizontalDirection).toBe(HorizontalAlignment.Right); + expect(positionStrategy.settings.verticalDirection).toBe(VerticalAlignment.Bottom); + }); + it('should apply custom overlay settings if provided', () => { + const toggle: IgxToggleDirective = (dropdown as any).toggleDirective; + const customOverlaySettings: OverlaySettings = { + closeOnOutsideClick: false, + modal: true, + positionStrategy: new ConnectedPositioningStrategy({ + horizontalStartPoint: HorizontalAlignment.Right, + verticalStartPoint: VerticalAlignment.Top, + horizontalDirection: HorizontalAlignment.Left, + verticalDirection: VerticalAlignment.Top + }) + }; + + spyOn(toggle, 'open').and.callThrough(); + + dropdown.open(customOverlaySettings); + fixture.detectChanges(); + expect(toggle.open).toHaveBeenCalledTimes(1); + + const appliedSettings = (toggle.open as jasmine.Spy).calls.mostRecent().args[0]; + expect(appliedSettings.closeOnOutsideClick).toBe(customOverlaySettings.closeOnOutsideClick); + expect(appliedSettings.modal).toBe(customOverlaySettings.modal); + expect(appliedSettings.positionStrategy instanceof ConnectedPositioningStrategy).toBe(true); + + const positionStrategy = appliedSettings.positionStrategy as ConnectedPositioningStrategy; + expect(positionStrategy.settings.horizontalStartPoint).toBe(HorizontalAlignment.Right); + expect(positionStrategy.settings.verticalStartPoint).toBe(VerticalAlignment.Top); + expect(positionStrategy.settings.horizontalDirection).toBe(HorizontalAlignment.Left); + expect(positionStrategy.settings.verticalDirection).toBe(VerticalAlignment.Top); + }); + it('#2798 - should allow canceling of open/close through opening/closing events', fakeAsync(() => { + const toggle: IgxToggleDirective = (dropdown as any).toggleDirective; + const onOpeningSpy = spyOn(dropdown.opening, 'emit').and.callThrough(); + const onOpenedSpy = spyOn(dropdown.opened, 'emit').and.callThrough(); + spyOn(dropdown.closing, 'emit').and.callThrough(); + spyOn(dropdown.closed, 'emit').and.callThrough(); + + dropdown.closing.pipe(take(1)).subscribe((e: CancelableEventArgs) => e.cancel = true); + + dropdown.toggle(); + tick(); + fixture.detectChanges(); + + expect(dropdown.opening.emit).toHaveBeenCalledTimes(1); + expect(dropdown.opened.emit).toHaveBeenCalledTimes(1); + + dropdown.toggle(); + tick(); + fixture.detectChanges(); + + expect(dropdown.closing.emit).toHaveBeenCalledTimes(1); + expect(dropdown.closed.emit).toHaveBeenCalledTimes(0); + + toggle.close(); + fixture.detectChanges(); + onOpeningSpy.calls.reset(); + onOpenedSpy.calls.reset(); + + dropdown.opening.pipe(take(1)).subscribe((e: CancelableEventArgs) => e.cancel = true); + dropdown.toggle(); + tick(); + fixture.detectChanges(); + expect(dropdown.opening.emit).toHaveBeenCalledTimes(1); + expect(dropdown.opened.emit).toHaveBeenCalledTimes(0); + })); + it('should select item by SPACE/ENTER keys', fakeAsync(() => { + dropdown.toggle(); + tick(); + fixture.detectChanges(); + let focusedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_FOCUSED}`)); + expect(focusedItem.componentInstance.itemIndex).toEqual(0); + expect(dropdown.collapsed).toEqual(false); + + let dropdownElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROP_DOWN_BASE}`)); + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', dropdownElement); + tick(); + fixture.detectChanges(); + focusedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_FOCUSED}`)); + expect(focusedItem).toBeDefined(); + expect(focusedItem.componentInstance.itemIndex).toEqual(1); + expect(dropdown.selectedItem).toBeFalsy(); + + UIInteractions.triggerEventHandlerKeyDown('Space', dropdownElement); + tick(); + fixture.detectChanges(); + expect(dropdown.selectedItem).toEqual(dropdown.items[1]); + expect(dropdown.collapsed).toEqual(true); + + dropdown.toggle(); + tick(); + fixture.detectChanges(); + dropdownElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROP_DOWN_BASE}`)); + focusedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_FOCUSED}`)); + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', dropdownElement); + tick(); + fixture.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('Enter', dropdownElement); + tick(); + fixture.detectChanges(); + expect(dropdown.collapsed).toEqual(true); + expect(dropdown.selectedItem).toEqual(dropdown.items[2]); + + dropdown.toggle(); + tick(); + fixture.detectChanges(); + expect(dropdown.collapsed).toEqual(false); + })); + it('should close the dropdown and not change selection by pressing ESC key', fakeAsync(() => { + spyOn(dropdown.selectionChanging, 'emit').and.callThrough(); + spyOn(dropdown.opening, 'emit').and.callThrough(); + spyOn(dropdown.opened, 'emit').and.callThrough(); + spyOn(dropdown.closing, 'emit').and.callThrough(); + spyOn(dropdown.closed, 'emit').and.callThrough(); + + dropdown.toggle(); + tick(); + fixture.detectChanges(); + let focusedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_FOCUSED}`)); + expect(focusedItem).toBeDefined(); + + const dropdownElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROP_DOWN_BASE}`)); + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', dropdownElement); + fixture.detectChanges(); + focusedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_FOCUSED}`)); + expect(focusedItem).toBeDefined(); + expect(focusedItem.componentInstance.itemIndex).toEqual(1); + + UIInteractions.triggerEventHandlerKeyDown('Escape', dropdownElement); + tick(); + fixture.detectChanges(); + expect(dropdown.collapsed).toEqual(true); + expect(dropdown.opening.emit).toHaveBeenCalledTimes(1); + expect(dropdown.opened.emit).toHaveBeenCalledTimes(1); + expect(dropdown.selectionChanging.emit).toHaveBeenCalledTimes(0); + expect(dropdown.closing.emit).toHaveBeenCalledTimes(1); + expect(dropdown.closed.emit).toHaveBeenCalledTimes(1); + })); + it('should navigate through items using Up/Down/Home/End keys', fakeAsync(() => { + dropdown.toggle(); + tick(); + fixture.detectChanges(); + const dropdownElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROP_DOWN_BASE}`)); + dropdownElement.triggerEventHandler('keydown', UIInteractions.getKeyboardEvent('keydown', 'ArrowDown')); + tick(); + fixture.detectChanges(); + let focusedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_FOCUSED}`)); + expect(focusedItem.componentInstance.itemIndex).toEqual(1); + + UIInteractions.triggerEventHandlerKeyDown('End', dropdownElement); + tick(); + fixture.detectChanges(); + focusedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_FOCUSED}`)); + expect(focusedItem.componentInstance.itemIndex).toEqual(14); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', dropdownElement); + tick(); + fixture.detectChanges(); + focusedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_FOCUSED}`)); + expect(focusedItem.componentInstance.itemIndex).toEqual(13); + + UIInteractions.triggerEventHandlerKeyDown('Home', dropdownElement); + tick(); + fixture.detectChanges(); + focusedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_FOCUSED}`)); + expect(focusedItem.componentInstance.itemIndex).toEqual(0); + })); + it('should not change selection when setting it to non-existing item', fakeAsync(() => { + dropdown.toggle(); + tick(); + fixture.detectChanges(); + dropdown.setSelectedItem(0); + fixture.detectChanges(); + let selectedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_SELECTED}`)); + expect(selectedItem.componentInstance.itemIndex).toEqual(0); + + dropdown.setSelectedItem(-4); + fixture.detectChanges(); + expect(dropdown.items[0].selected).toBeTruthy(); + selectedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_SELECTED}`)); + expect(selectedItem.componentInstance.itemIndex).toEqual(0); + + dropdown.setSelectedItem(24); + fixture.detectChanges(); + expect(dropdown.items[0].selected).toBeTruthy(); + selectedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_SELECTED}`)); + expect(selectedItem.componentInstance.itemIndex).toEqual(0); + + dropdown.setSelectedItem(5); + fixture.detectChanges(); + expect(dropdown.items[5].selected).toBeTruthy(); + selectedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_SELECTED}`)); + expect(selectedItem.componentInstance.itemIndex).toEqual(5); + + // Verify selecting the already selected element is not affecting selection + dropdown.setSelectedItem(5); + fixture.detectChanges(); + expect(dropdown.items[5].selected).toBeTruthy(); + selectedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_SELECTED}`)); + expect(selectedItem.componentInstance.itemIndex).toEqual(5); + })); + it('should focus the first enabled item by pressing HOME key', fakeAsync(() => { + dropdown.items[0].disabled = true; + dropdown.items[1].isHeader = true; + dropdown.items[3].disabled = true; + dropdown.items[4].isHeader = true; + dropdown.items[7].disabled = true; + dropdown.items[10].selected = true; + fixture.detectChanges(); + dropdown.toggle(); + tick(); + fixture.detectChanges(); + + const selectedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_SELECTED}`)); + expect(selectedItem.componentInstance.itemIndex).toEqual(10); + const dropdownElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROP_DOWN_BASE}`)); + UIInteractions.triggerEventHandlerKeyDown('Home', dropdownElement); + tick(); + fixture.detectChanges(); + expect(dropdown.items[1].focused).toBeTruthy(); + const focusedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_FOCUSED}`)); + expect(focusedItem.componentInstance.itemIndex).toEqual(1); + })); + it('should set isSelected via igxDropDownIteComponent', fakeAsync(() => { + dropdown.toggle(); + tick(); + fixture.detectChanges(); + expect(dropdown.selectedItem).toBeNull(); + + const items = dropdown.items as IgxDropDownItemComponent[]; + items[2].selected = true; + tick(); + fixture.detectChanges(); + expect(items[2].selected).toBeTruthy(); + expect(dropdown.selectedItem.itemIndex).toEqual(2); + + items[1].selected = true; + tick(); + fixture.detectChanges(); + expect(items[2].selected).toBeFalsy(); + expect(items[1].selected).toBeTruthy(); + expect(dropdown.selectedItem.itemIndex).toEqual(1); + + dropdown.toggle(); + tick(); + fixture.detectChanges(); + expect(dropdown.selectedItem.itemIndex).toEqual(1); + })); + it('should not set isSelected via igxDropDownItemBase on header items', fakeAsync(() => { + dropdown.items[0].disabled = true; + dropdown.items[1].isHeader = true; + dropdown.items[3].disabled = true; + dropdown.items[4].isHeader = true; + dropdown.items[7].disabled = true; + dropdown.items[10].isHeader = true; + fixture.detectChanges(); + dropdown.toggle(); + tick(); + fixture.detectChanges(); + + expect(dropdown.selectedItem).toBeNull(); + const items = dropdown.items as IgxDropDownItemComponent[]; + const headerItems = dropdown.headers as IgxDropDownItemComponent[]; + + // Try to select header item + headerItems[0].selected = true; + tick(); + fixture.detectChanges(); + expect(headerItems[0].selected).toBeFalsy(); + expect(dropdown.selectedItem).toBeNull(); + + // Try to select disabled item + items[2].selected = true; + tick(); + fixture.detectChanges(); + expect(items[2].selected).toBeTruthy(); + expect(dropdown.selectedItem.itemIndex).toEqual(2); + + // Try to select header item + headerItems[1].selected = true; + expect(headerItems[1].selected).toBeFalsy(); + expect(dropdown.selectedItem.itemIndex).toEqual(2); + + dropdown.toggle(); + tick(); + fixture.detectChanges(); + expect(dropdown.selectedItem.itemIndex).toEqual(2); + })); + it('should return the proper eventArgs if selection has been cancelled', fakeAsync(() => { + spyOn(dropdown.selectionChanging, 'emit').and.callThrough(); + + dropdown.toggle(); + tick(); + fixture.detectChanges(); + + let selectedItem = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_ITEM}`))[3]; + selectedItem.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + const selectionArgs: ISelectionEventArgs = { + oldSelection: null, + newSelection: dropdown.items[3], + cancel: false, + owner: dropdown + }; + expect(dropdown.selectionChanging.emit).toHaveBeenCalledWith(selectionArgs); + + dropdown.selectionChanging.pipe(take(1)).subscribe((e: CancelableEventArgs) => e.cancel = true); + selectedItem = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_ITEM}`))[1]; + selectedItem.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + const canceledSelectionArgs: ISelectionEventArgs = { + oldSelection: dropdown.items[3], + newSelection: dropdown.items[1], + cancel: true, + owner: dropdown + }; + expect(dropdown.selectionChanging.emit).toHaveBeenCalledWith(canceledSelectionArgs); + })); + it('should provide correct event argument when closing through keyboard', fakeAsync(() => { + spyOn(dropdown.closing, 'emit').and.callThrough(); + const dropdownElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROP_DOWN_BASE}`)); + + dropdown.toggle(); + tick(); + fixture.detectChanges(); + let focusedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_FOCUSED}`)); + expect(focusedItem).toBeDefined(); + + let eventArgs: IBaseCancelableBrowserEventArgs; + dropdown.closing.pipe(take(1)).subscribe((args: IBaseCancelableBrowserEventArgs) => { + eventArgs = args; + }); + + UIInteractions.triggerEventHandlerKeyDown('escape', dropdownElement); + tick(); + fixture.detectChanges(); + expect(dropdown.closing.emit).toHaveBeenCalledTimes(1); + expect(eventArgs.event).toBeDefined(); + expect((eventArgs.event as KeyboardEvent).type).toEqual('keydown'); + expect((eventArgs.event as KeyboardEvent).key).toEqual('escape'); + + dropdown.toggle(); + tick(); + fixture.detectChanges(); + focusedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_FOCUSED}`)); + expect(focusedItem).toBeDefined(); + + dropdown.closing.pipe(take(1)).subscribe((args: IBaseCancelableBrowserEventArgs) => { + eventArgs = args; + }); + + UIInteractions.triggerEventHandlerKeyDown('enter', dropdownElement); + tick(); + fixture.detectChanges(); + expect(dropdown.closing.emit).toHaveBeenCalledTimes(2); + expect(eventArgs.event).toBeDefined(); + expect((eventArgs.event as KeyboardEvent).type).toEqual('keydown'); + expect((eventArgs.event as KeyboardEvent).key).toEqual('enter'); + })); + it('should be able to change selection when manipulating ISelectionEventArgs', fakeAsync(() => { + expect(dropdown.selectedItem).toEqual(null); + dropdown.toggle(); + tick(); + fixture.detectChanges(); + + // Overwrite selection args + let expectedSelected = dropdown.items[4]; + const calledSelected = dropdown.items[1]; + const subscription = dropdown.selectionChanging.subscribe((e: ISelectionEventArgs) => { + expect(e.newSelection).toEqual(calledSelected); + e.newSelection = expectedSelected; + }); + dropdown.selectItem(calledSelected); + tick(); + expect(dropdown.selectedItem).toEqual(expectedSelected); + + // Clear selection + expectedSelected = null; + dropdown.selectItem(calledSelected); + tick(); + expect(dropdown.selectedItem).toEqual(expectedSelected); + + // Set header - error + dropdown.toggle(); + tick(); + fixture.detectChanges(); + + expectedSelected = dropdown.items[4]; + dropdown.items[4].isHeader = true; + + spyOn(dropdown, 'selectItem').and.callThrough(); + expect(() => { + dropdown.selectItem(calledSelected); + }).toThrow(); + + // Set non-IgxDropDownItemBaseDirective + expectedSelected = 7 as any; + expect(() => { + dropdown.selectItem(calledSelected); + }).toThrow(); + + subscription.unsubscribe(); + })); + it('should not take focus when allowItemsFocus is set to false', fakeAsync(() => { + dropdown.toggle(); + tick(); + fixture.detectChanges(); + const focusedItem = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_ITEM}`))[0].nativeElement; + expect(document.activeElement).toEqual(focusedItem); + + dropdown.toggle(); + tick(); + fixture.detectChanges(); + + dropdown.allowItemsFocus = false; + tick(); + fixture.detectChanges(); + + const button = fixture.debugElement.query(By.css('button')).nativeElement; + button.focus(); + button.click(); + tick(); + fixture.detectChanges(); + expect(document.activeElement).toEqual(button); + })); + it('should not be able to select disabled and header items', fakeAsync(() => { + dropdown.items[2].isHeader = true; + dropdown.items[4].disabled = true; + fixture.detectChanges(); + + dropdown.toggle(); + tick(); + fixture.detectChanges(); + const currentItem = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DISABLED}`))[0]; + const headerItem = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_HEADER}`))[0]; + expect(currentItem.componentInstance.itemIndex).toEqual(4); + expect(headerItem.componentInstance).toEqual(dropdown.headers[0]); + + currentItem.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + expect(dropdown.selectedItem).toBeNull(); + + headerItem.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + expect(dropdown.selectedItem).toBeNull(); + + // clicking on header item should not close the drop down + expect(dropdown.collapsed).toEqual(false); + })); + it('should be possible to enable/disable items at runtime', fakeAsync(() => { + dropdown.items[3].disabled = true; + dropdown.items[7].disabled = true; + dropdown.items[11].disabled = true; + fixture.detectChanges(); + + dropdown.toggle(); + tick(); + fixture.detectChanges(); + let disabledItems = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DISABLED}`)); + expect(disabledItems.length).toEqual(3); + expect(dropdown.items[4].disabled).toBeFalsy(); + + dropdown.items[4].disabled = true; + fixture.detectChanges(); + disabledItems = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DISABLED}`)); + expect(disabledItems.length).toEqual(4); + expect(dropdown.items[4].disabled).toBeTruthy(); + })); + it('should focus the last enabled item by pressing END key', fakeAsync(() => { + dropdown.items[0].disabled = true; + dropdown.items[1].isHeader = true; + dropdown.items[3].disabled = true; + dropdown.items[4].isHeader = true; + dropdown.items[7].disabled = true; + dropdown.items[10].selected = true; + dropdown.items[12].disabled = true; + fixture.detectChanges(); + dropdown.toggle(); + tick(); + fixture.detectChanges(); + + const selectedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_SELECTED}`)); + expect(selectedItem.componentInstance.itemIndex).toEqual(10); + + const dropdownElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROP_DOWN_BASE}`)); + UIInteractions.triggerEventHandlerKeyDown('End', dropdownElement); + tick(); + fixture.detectChanges(); + expect(dropdown.items[11].focused).toBeTruthy(); + let focusedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_FOCUSED}`)); + expect(focusedItem.componentInstance.itemIndex).toEqual(11); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', dropdownElement); + tick(); + fixture.detectChanges(); + expect(dropdown.items[11].focused).toBeTruthy(); + focusedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_FOCUSED}`)); + expect(focusedItem.componentInstance.itemIndex).toEqual(11); + })); + it('should skip disabled/header items on key navigation', fakeAsync(() => { + dropdown.items[0].disabled = true; + dropdown.items[1].isHeader = true; + dropdown.items[3].disabled = true; + dropdown.items[8].isHeader = true; + dropdown.items[9].disabled = true; + dropdown.items[10].selected = true; + dropdown.items[12].disabled = true; + fixture.detectChanges(); + dropdown.toggle(); + tick(); + fixture.detectChanges(); + + const dropdownElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROP_DOWN_BASE}`)); + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', dropdownElement); + tick(); + fixture.detectChanges(); + expect(dropdown.items[11].focused).toBeTruthy(); + let focusedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_FOCUSED}`)); + expect(focusedItem.componentInstance.itemIndex).toEqual(11); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', dropdownElement); + tick(); + fixture.detectChanges(); + expect(dropdown.items[10].focused).toBeTruthy(); + focusedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_FOCUSED}`)); + expect(focusedItem.componentInstance.itemIndex).toEqual(10); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', dropdownElement); + tick(); + fixture.detectChanges(); + expect(dropdown.items[8].focused).toBeTruthy(); + focusedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_FOCUSED}`)); + expect(focusedItem.componentInstance.itemIndex).toEqual(8); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', dropdownElement); + tick(); + fixture.detectChanges(); + expect(dropdown.items[7].focused).toBeTruthy(); + focusedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_FOCUSED}`)); + expect(focusedItem.componentInstance.itemIndex).toEqual(7); + })); + it('should select disabled items via code behind', fakeAsync(() => { + dropdown.items[0].disabled = true; + dropdown.items[1].isHeader = true; + fixture.detectChanges(); + dropdown.setSelectedItem(0); + fixture.detectChanges(); + dropdown.toggle(); + tick(); + fixture.detectChanges(); + expect(dropdown.items[0].selected).toBeTruthy(); + })); + it('should not move the focus when clicking a disabled item', fakeAsync(() => { + dropdown.items[0].disabled = true; + dropdown.items[1].isHeader = true; + dropdown.items[3].disabled = true; + dropdown.items[4].isHeader = true; + dropdown.items[7].disabled = true; + dropdown.items[10].selected = true; + fixture.detectChanges(); + dropdown.toggle(); + tick(); + fixture.detectChanges(); + expect(dropdown.items[10].focused).toEqual(true); + + const dropdownElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROP_DOWN_BASE}`)); + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', dropdownElement); + fixture.detectChanges(); + expect(dropdown.items[11].focused).toEqual(true); + + const firstItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_ITEM}`)); + firstItem.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + expect(dropdown.items[11].focused).toEqual(true); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', dropdownElement); + fixture.detectChanges(); + expect(dropdown.items[12].focused).toEqual(true); + })); + }); + describe('Other', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + DoubleIgxDropDownComponent, + InputWithDropDownDirectiveComponent + ] + }).compileComponents(); + })); + it('should call preventDefault on a mousedown event when allowItemsFocus is disabled', () => { + fixture = TestBed.createComponent(InputWithDropDownDirectiveComponent); + fixture.detectChanges(); + + dropdown = fixture.componentInstance.dropdown; + dropdown.allowItemsFocus = false; + fixture.detectChanges(); + + dropdown.open(); + fixture.detectChanges(); + + const itemToClick = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_ITEM}`))[0]; + + const event = new Event('mousedown', { }); + spyOn(event, 'preventDefault'); + itemToClick.triggerEventHandler('mousedown', event); + + fixture.detectChanges(); + expect(event.preventDefault).toHaveBeenCalled(); + }); + it('should properly handle OnEnterKeyDown when the dropdown is not visible', fakeAsync(() => { + fixture = TestBed.createComponent(InputWithDropDownDirectiveComponent); + fixture.detectChanges(); + dropdown = fixture.componentInstance.dropdown; + const input = fixture.debugElement.query(By.css('input')); + spyOn(dropdown, 'selectItem').and.callThrough(); + + expect(dropdown).toBeDefined(); + expect(dropdown.focusedItem).toEqual(null); + expect(dropdown.selectedItem).toEqual(null); + expect(dropdown.selectItem).toHaveBeenCalledTimes(0); + expect(dropdown.collapsed).toEqual(true); + + input.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + tick(); + fixture.detectChanges(); + expect(dropdown.collapsed).toEqual(true); + expect(dropdown.selectItem).toHaveBeenCalledTimes(0); + expect(dropdown.focusedItem).toEqual(null); + + UIInteractions.triggerEventHandlerKeyDown('Enter', input); + tick(); + fixture.detectChanges(); + // does not attempt to select item on keydown if DD is closed; + expect(dropdown.selectItem).toHaveBeenCalledTimes(0); + expect(dropdown.selectedItem).toEqual(null); + expect(dropdown.collapsed).toEqual(true); + + dropdown.toggle(); + tick(); + fixture.detectChanges(); + expect(dropdown.collapsed).toEqual(false); + expect(dropdown.focusedItem).toEqual(dropdown.items[0]); + + const dropdownItem = dropdown.items[0]; + input.triggerEventHandler('keydown', UIInteractions.getKeyboardEvent('keydown', 'Enter')); + tick(); + fixture.detectChanges(); + expect(dropdown.selectItem).toHaveBeenCalledTimes(1); + expect(dropdown.selectItem).toHaveBeenCalledWith(dropdownItem, UIInteractions.getKeyboardEvent('keydown', 'Enter')); + expect(dropdown.selectedItem).toEqual(dropdownItem); + expect(dropdown.collapsed).toEqual(true); + })); + it('should keep selection per instance', () => { + fixture = TestBed.createComponent(DoubleIgxDropDownComponent); + fixture.detectChanges(); + const dropdown1 = fixture.componentInstance.dropdown1; + const dropdown2 = fixture.componentInstance.dropdown2; + dropdown1.setSelectedItem(1); + expect(dropdown1.selectedItem).toEqual(dropdown1.items[1]); + expect(dropdown2.selectedItem).toEqual(null); + dropdown2.setSelectedItem(3); + expect(dropdown1.selectedItem).toEqual(dropdown1.items[1]); + expect(dropdown2.selectedItem).toEqual(dropdown2.items[3]); + dropdown1.setSelectedItem(5); + expect(dropdown1.selectedItem).toEqual(dropdown1.items[5]); + expect(dropdown2.selectedItem).toEqual(dropdown2.items[3]); + }); + }); + }); + describe('Virtualization tests', () => { + let scroll: IgxForOfDirective; + let items; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + VirtualizedDropDownComponent + ] + }).compileComponents(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(VirtualizedDropDownComponent); + fixture.detectChanges(); + dropdown = fixture.componentInstance.dropdown; + scroll = fixture.componentInstance.virtualScroll; + items = fixture.componentInstance.dropdownItems; + }); + it('should properly scroll when virtualized', async () => { + dropdown.toggle(); + fixture.detectChanges(); + await wait(50); + let firstItemElement = fixture.componentInstance.dropdownItems.first.element.nativeElement; + let lastItemElement = fixture.componentInstance.dropdownItems.last.element.nativeElement; + expect(lastItemElement.textContent.trim()).toEqual('Item 11'); + expect(firstItemElement.textContent.trim()).toEqual('Item 1'); + scroll.getScroll().scrollTop = scroll.getScroll().scrollHeight; + fixture.detectChanges(); + await wait(50); + firstItemElement = fixture.componentInstance.dropdownItems.first.element.nativeElement; + lastItemElement = fixture.componentInstance.dropdownItems.last.element.nativeElement; + expect(firstItemElement.textContent.trim()).toEqual('Item 1990'); + expect(lastItemElement.textContent.trim()).toEqual('Item 2000'); + }); + xit('Should properly handle keyboard navigation when virtualized', async () => { + pending('does not have time to focus last item on navigateLast()'); + // dropdown.toggle(); + // fixture.detectChanges(); + // dropdown.navigateFirst(); + // expect(scroll.state.startIndex).toEqual(0); + // expect(items.first.focused).toEqual(true); + // dropdown.navigateLast(); + // await wait(200); + // fixture.detectChanges(); + // expect(scroll.state.startIndex).toEqual(2000 - scroll.state.chunkSize); + // expect(items.last.focused).toEqual(true); + // const toggleBtn = fixture.debugElement.query(By.css('button')); + // UIInteractions.triggerEventHandlerKeyDown('ArrowUp', toggleBtn); + // await wait(30); + // fixture.detectChanges(); + // expect(scroll.state.startIndex).toEqual(2000 - scroll.state.chunkSize); + // expect(items.toArray()[items.toArray().length - 2].focused).toEqual(true); + }); + it('should persist selection on scrolling', async () => { + dropdown.toggle(); + expect(dropdown.selectedItem).toBe(null); + dropdown.selectItem(dropdown.items[5]); + fixture.detectChanges(); + expect(dropdown.selectedItem.value).toEqual({ name: fixture.componentInstance.items[5].name, id: 5 }); + expect(dropdown.items[5].selected).toBeTruthy(); + scroll.scrollTo(412); + await wait(50); + fixture.detectChanges(); + expect(items.toArray()[5].selected).toBeFalsy(); + expect(document.getElementsByClassName(CSS_CLASS_SELECTED).length).toEqual(0); + scroll.scrollTo(0); + await wait(50); + fixture.detectChanges(); + expect(items.toArray()[5].selected).toBeTruthy(); + expect(document.getElementsByClassName(CSS_CLASS_SELECTED).length).toEqual(1); + }); + it('should properly select items both inside and outside of the virtual view', async () => { + dropdown.toggle(); + expect(dropdown.selectedItem).toBe(null); + let selectedItem = { value: fixture.componentInstance.items[5], index: 5 } as IgxDropDownItemBaseDirective; + dropdown.selectItem(selectedItem); + fixture.detectChanges(); + expect(dropdown.selectedItem as any).toEqual(selectedItem); + expect(items.toArray()[5].selected).toEqual(true); + selectedItem = { value: fixture.componentInstance.items[412], index: 412 } as IgxDropDownItemBaseDirective; + dropdown.selectItem(selectedItem); + fixture.detectChanges(); + expect(dropdown.selectedItem as any).toEqual(selectedItem); + expect(items.toArray()[5].selected).toEqual(false); + scroll.scrollTo(412); + await wait(50); + fixture.detectChanges(); + const selectedEntry = items.find(e => e.value === selectedItem.value && e.index === selectedItem.index); + expect(selectedEntry).toBeTruthy(); + expect(selectedEntry.selected).toBeTruthy(); + }); + it('should scroll selected item into view when virtualized', async () => { + dropdown.toggle(); + expect(dropdown.selectedItem).toBe(null); + const virtualScroll = fixture.componentInstance.virtualScroll; + const selectedItem = { value: fixture.componentInstance.items[1000], index: 1000 } as IgxDropDownItemBaseDirective; + dropdown.selectItem(selectedItem); + fixture.detectChanges(); + dropdown.toggle(); + await wait(50); + dropdown.toggle(); + await wait(50); + const itemsInView = virtualScroll.igxForContainerSize / virtualScroll.igxForItemSize; + const expectedScroll = virtualScroll.getScrollForIndex(selectedItem.index) + - (itemsInView / 2 - 1) * virtualScroll.igxForItemSize; + const acceptableDelta = virtualScroll.igxForItemSize; + const scrollTop = virtualScroll.getScroll().scrollTop; + expect(expectedScroll - acceptableDelta < scrollTop && expectedScroll + acceptableDelta > scrollTop).toBe(true); + }); + }); + describe('Rendering', () => { + describe('Accessibility', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxDropDownTestComponent, + ] + }).compileComponents(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(IgxDropDownTestComponent); + fixture.detectChanges(); + dropdown = fixture.componentInstance.dropdown; + }); + it('should set the aria-label property correctly', () => { + // Initially aria-label should be null + dropdown.toggle(); + fixture.detectChanges(); + let items = document.querySelectorAll(`.${CSS_CLASS_ITEM}`); + items.forEach(item => { + expect(item.getAttribute('aria-label')).toBeNull(); + }); + + // Set value and check if aria-label reflects it + dropdown.toggle(); + fixture.detectChanges(); + dropdown.items.forEach((item, index) => item.value = `value ${index}`); + dropdown.toggle(); + fixture.detectChanges(); + items = document.querySelectorAll(`.${CSS_CLASS_ITEM}`); + items.forEach((item, index) => { + expect(item.getAttribute('aria-label')).toBe(`value ${index}`); + }); + + // Phase 3: Set explicit ariaLabel and verify it overrides value + dropdown.toggle(); + fixture.detectChanges(); + dropdown.items.forEach((item, index) => item.ariaLabel = `label ${index}`); + dropdown.toggle(); + fixture.detectChanges(); + items = document.querySelectorAll(`.${CSS_CLASS_ITEM}`); + items.forEach((item, index) => { + expect(item.getAttribute('aria-label')).toBe(`label ${index}`); + }); + }); + it('should update aria-activedescendant to the id of the focused item', fakeAsync(() => { + dropdown.toggle(); + tick(); + fixture.detectChanges(); + + const dropdownElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROP_DOWN_BASE}`)).nativeElement; + let focusedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_FOCUSED}`)).nativeElement; + + expect(focusedItem).toBeTruthy(); + let focusedItemId = focusedItem.getAttribute('id'); + expect(focusedItemId).toBeTruthy(); + expect(dropdownElement.getAttribute('aria-activedescendant')).toBe(focusedItemId); + + dropdown.toggle(); + tick(); + fixture.detectChanges(); + dropdown.toggle(); + tick(); + fixture.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', fixture.debugElement.query(By.css(`.${CSS_CLASS_DROP_DOWN_BASE}`))); + tick(); + fixture.detectChanges(); + + focusedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_FOCUSED}`)).nativeElement; + focusedItemId = focusedItem.getAttribute('id'); + + expect(dropdownElement.getAttribute('aria-activedescendant')).toBe(focusedItemId); + })); + }); + describe('Grouped items', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + GroupDropDownComponent + ] + }).compileComponents(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(GroupDropDownComponent); + fixture.detectChanges(); + dropdown = fixture.componentInstance.dropDown; + }); + it('should properly render item groups aria attributes', () => { + const groups = fixture.componentInstance.groups; + expect(dropdown.collapsed).toBeTruthy(); + dropdown.toggle(); + fixture.detectChanges(); + const groupItems = document.querySelectorAll(`.${CSS_CLASS_GROUP_ITEM}`); + for (let i = 0; i < groupItems.length; i++) { + const elemAttr = groupItems[i].attributes; + expect(elemAttr['aria-disabled'].value).toEqual('false'); + expect(elemAttr['aria-labelledby'].value).toEqual(`igx-item-group-label-${i}`); + expect(elemAttr['role'].value).toEqual(`group`); + } + groups.first.disabled = true; + fixture.detectChanges(); + expect(document.querySelectorAll(`.${CSS_CLASS_GROUP_ITEM}`)[0].attributes['aria-disabled'].value).toEqual('true'); + }); + it('should properly display items within dropdown groups', () => { + const items = fixture.componentInstance.data; + dropdown.toggle(); + fixture.detectChanges(); + expect(dropdown.collapsed).toBeFalsy(); + const dropdownItems = document.querySelectorAll(`.${CSS_CLASS_INNER_SPAN}`); + expect(dropdownItems.length).toEqual(9); + expect(dropdown.items.length).toEqual(9); + for (let i = 0; i < dropdownItems.length; i++) { + const currentIndex = Math.floor(i / 3); + expect(dropdownItems[i].innerHTML.trim()).toEqual(items[currentIndex].children[i % 3].name); + expect(dropdown.items[i].value).toEqual(items[currentIndex].children[i % 3].value); + } + }); + it('should properly disable all items within a disabled group', () => { + const groups = fixture.componentInstance.groups; + const items = fixture.componentInstance.items; + dropdown.toggle(); + fixture.detectChanges(); + groups.first.disabled = true; + fixture.detectChanges(); + const dropdownItems = document.querySelectorAll(`.${CSS_CLASS_ITEM}`); + const disabledItems = document.querySelectorAll(`.${CSS_CLASS_DISABLED}`); + expect(dropdownItems.length).toEqual(9); + expect(dropdown.items.length).toEqual(9); + expect(disabledItems.length).toEqual(3); + const disabledGroup = [...items.toArray()].splice(0, 3); + for (const group of disabledGroup) { + expect(group.disabled).toEqual(true); + } + }); + }); + describe('Style and display density', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxDropDownTestComponent + ] + }).compileComponents(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(IgxDropDownTestComponent); + fixture.detectChanges(); + dropdown = fixture.componentInstance.dropdown; + }); + it('should apply selected item class', fakeAsync(() => { + dropdown.toggle(); + tick(); + fixture.detectChanges(); + const selectedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_ITEM}`)); + expect(selectedItem.classes[CSS_CLASS_SELECTED]).toBeFalsy(); + + dropdown.setSelectedItem(0); + tick(); + fixture.detectChanges(); + expect(selectedItem.classes[CSS_CLASS_SELECTED]).toBeTruthy(); + + dropdown.clearSelection(); + tick(); + fixture.detectChanges(); + expect(selectedItem.classes[CSS_CLASS_SELECTED]).toBeFalsy(); + })); + }); + describe('Input properties', () => { + const customDDId = 'test-id-list'; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxDropDownTestComponent + ] + }).compileComponents(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(IgxDropDownTestComponent); + fixture.detectChanges(); + dropdown = fixture.componentInstance.dropdown; + }); + it('should return items/headers property correctly', fakeAsync(() => { + dropdown.toggle(); + tick(); + fixture.detectChanges(); + expect(dropdown.items.length).toEqual(15); + expect(dropdown.headers).toEqual([]); + + dropdown.toggle(); + tick(); + fixture.detectChanges(); + dropdown.items[0].disabled = true; + dropdown.items[1].isHeader = true; + dropdown.items[3].disabled = true; + dropdown.items[4].isHeader = true; + dropdown.items[7].disabled = true; + dropdown.items[10].isHeader = true; + fixture.detectChanges(); + dropdown.toggle(); + tick(); + fixture.detectChanges(); + expect(dropdown.items.length).toEqual(12); + expect(dropdown.headers).toBeTruthy(); + expect(dropdown.headers.length).toEqual(3); + })); + it('should properly set maxHeight option', () => { + fixture.componentInstance.maxHeight = '100px'; + fixture.detectChanges(); + dropdown.toggle(); + fixture.detectChanges(); + const ddList = fixture.debugElement.query(By.css(`.${CSS_CLASS_SCROLL}`)).nativeElement; + expect(parseInt(ddList.style.maxHeight, 10)).toEqual(ddList.offsetHeight); + expect(ddList.style.maxHeight).toBe('100px'); + }); + it('should properly set maxHeight option when maxHeight value is larger than needed)', () => { + fixture.componentInstance.maxHeight = '700px'; + fixture.detectChanges(); + dropdown.toggle(); + fixture.detectChanges(); + const ddList = fixture.debugElement.query(By.css(`.${CSS_CLASS_SCROLL}`)).nativeElement; + expect(parseInt(ddList.style.maxHeight, 10)).toBeGreaterThan(ddList.offsetHeight); + expect(ddList.style.maxHeight).toBe('700px'); + }); + it('should properly set role option', () => { + const ddList = fixture.debugElement.query(By.css(`.${CSS_CLASS_SCROLL}`)).nativeElement; + expect(ddList.getAttribute('role')).toBe('listbox'); + dropdown.role = 'menu'; + fixture.detectChanges(); + expect(ddList.getAttribute('role')).toBe('menu'); + + }); + it('should set custom id, width/height properties runtime', () => { + fixture.componentInstance.dropdown.width = '80%'; + fixture.componentInstance.dropdown.height = '400px'; + + dropdown.toggle(); + fixture.detectChanges(); + const ddList = fixture.debugElement.query(By.css(`.${CSS_CLASS_LIST}`)).nativeElement; + const ddListScroll = fixture.debugElement.query(By.css(`.${CSS_CLASS_SCROLL}`)).nativeElement; + expect(ddListScroll.style.height).toBe('400px'); + expect(ddList.style.width).toBe('80%'); + expect(ddListScroll.id).toEqual(customDDId); + }); + }); + describe('Anchor element', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxDropDownAnchorTestComponent + ] + }).compileComponents(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(IgxDropDownAnchorTestComponent); + fixture.detectChanges(); + dropdown = fixture.componentInstance.dropdown; + }); + it('should bind to different anchor elements', fakeAsync(() => { + const tabs = fixture.debugElement.query(By.css(CSS_CLASS_TABS)); + const input = fixture.debugElement.query(By.css('input')); + const img = fixture.debugElement.query(By.css('img')); + spyOn(dropdown.opening, 'emit').and.callThrough(); + spyOn(dropdown.opened, 'emit').and.callThrough(); + spyOn(dropdown.closing, 'emit').and.callThrough(); + spyOn(dropdown.closed, 'emit').and.callThrough(); + tabs.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + tick(); + fixture.detectChanges(); + expect(dropdown.opening.emit).toHaveBeenCalledTimes(1); + expect(dropdown.opened.emit).toHaveBeenCalledTimes(1); + let dropdownItems = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_ITEM}`)); + dropdownItems[2].triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + tick(); + fixture.detectChanges(); + expect(dropdown.closing.emit).toHaveBeenCalledTimes(1); + expect(dropdown.closed.emit).toHaveBeenCalledTimes(1); + + input.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + tick(); + fixture.detectChanges(); + expect(dropdown.opening.emit).toHaveBeenCalledTimes(2); + expect(dropdown.opened.emit).toHaveBeenCalledTimes(2); + dropdownItems = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_ITEM}`)); + dropdownItems[1].triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + tick(); + fixture.detectChanges(); + expect(dropdown.closing.emit).toHaveBeenCalledTimes(2); + expect(dropdown.closed.emit).toHaveBeenCalledTimes(2); + + img.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + tick(); + fixture.detectChanges(); + expect(dropdown.opening.emit).toHaveBeenCalledTimes(3); + expect(dropdown.opened.emit).toHaveBeenCalledTimes(3); + dropdownItems = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_ITEM}`)); + dropdownItems[0].triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + tick(); + fixture.detectChanges(); + expect(dropdown.closing.emit).toHaveBeenCalledTimes(3); + expect(dropdown.closed.emit).toHaveBeenCalledTimes(3); + })); + it('#15137 - should bind to custom target if provided', fakeAsync(() => { + const input = fixture.debugElement.query(By.css('input')); + dropdown.open({ target: input.nativeElement }); + tick(); + fixture.detectChanges(); + + const dropdownItems = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_ITEM}`)); + expect(dropdownItems).not.toBeUndefined(); + + const inputRect = input.nativeElement.getBoundingClientRect(); + let dropdownRect = dropdownItems[0].nativeElement.getBoundingClientRect(); + expect(dropdownRect.left).toBe(inputRect.left); + expect(dropdownRect.top).toBe(inputRect.bottom); + + dropdown.close(); + tick(); + fixture.detectChanges(); + dropdown.open(); + tick(); + fixture.detectChanges(); + + dropdownRect = dropdownItems[0].nativeElement.getBoundingClientRect(); + expect(dropdownRect.left).toBe(0); + expect(dropdownRect.top).toBe(0); + })); + }); + }); +}); + +@Component({ + template: ` + + + @for (item of items; track item.field) { + + {{item.field}} + + } + `, + imports: [IgxDropDownComponent, IgxDropDownItemComponent, IgxDropDownItemNavigationDirective] +}) +class IgxDropDownTestComponent { + + @ViewChild(IgxDropDownComponent, { read: IgxDropDownComponent, static: true }) + public dropdown: IgxDropDownComponent; + public maxHeight: string; + + public items: any[] = [ + { field: 'Item 1' }, + { field: 'Item 2' }, + { field: 'Item 3' }, + { field: 'Item 4' }, + { field: 'Item 5' }, + { field: 'Item 6' }, + { field: 'Item 7' }, + { field: 'Item 8' }, + { field: 'Item 9' }, + { field: 'Item 10' }, + { field: 'Item 11' }, + { field: 'Item 12' }, + { field: 'Item 13' }, + { field: 'Item 14' }, + { field: 'Item 15' } + ]; + + public toggleDropDown() { + this.dropdown.toggle(); + } +} +@Component({ + template: ` + + + @for (item of items; track item.field) { + + {{ item.field }} + + } + + + @for (item of items; track item.field) { + + {{ item.field }} + + } + + `, + imports: [IgxDropDownComponent, IgxDropDownItemComponent] +}) +class DoubleIgxDropDownComponent implements OnInit { + + @ViewChild('dropdown1', { read: IgxDropDownComponent, static: true }) + public dropdown1: IgxDropDownComponent; + + @ViewChild('dropdown2', { read: IgxDropDownComponent, static: true }) + public dropdown2: IgxDropDownComponent; + + public items: any[] = []; + + public ngOnInit() { + for (let index = 1; index < 100; index++) { + this.items.push({ field: 'Item ' + index }); + } + } +} +@Component({ + template: ` + + + + + + Tab111111111111111111111111 + + +

    Tab 1 Content

    +
    +
    + + + Tab 2 + + +

    Tab 2 Content

    +
    +
    + + + Tab 3 + + +

    Tab 3 Content

    +
    +
    +
    + + @for (item of items; track item.field) { + + {{ item.field }} + + } + `, + imports: [IgxDropDownComponent, IgxDropDownItemComponent, IgxDropDownItemNavigationDirective, IgxTabsComponent, IgxTabItemComponent, IgxTabHeaderComponent, IgxTabContentComponent] +}) +class IgxDropDownAnchorTestComponent { + @ViewChild(IgxTabsComponent, { static: true }) + public tabs: IgxTabsComponent; + @ViewChild(IgxDropDownComponent, { read: IgxDropDownComponent, static: true }) + public dropdown: IgxDropDownComponent; + + public items: any[] = [ + { field: 'Nav1' }, + { field: 'Nav2' }, + { field: 'Nav3' }, + { field: 'Nav4' } + ]; + + public toggleDropDown() { + this.dropdown.toggle(); + } + + public selectionChanging(ev) { } // eslint-disable-line + + public onToggleOpening() { } + + public onToggleOpened() { } + + public onToggleClosing() { } + + public onToggleClosed() { } +} +@Component({ + template: ` + + @for (item of items; track item.field) { + + {{ item.field }} + + } + `, + imports: [IgxDropDownComponent, IgxDropDownItemComponent, IgxDropDownItemNavigationDirective] +}) +class InputWithDropDownDirectiveComponent { + @ViewChild(IgxDropDownComponent, { read: IgxDropDownComponent, static: true }) + public dropdown: IgxDropDownComponent; + + @ViewChild(`inputElement`, { static: true }) + public inputElement: ElementRef; + + public items: any[] = [ + { field: 'Nav1' }, + { field: 'Nav2' }, + { field: 'Nav3' }, + { field: 'Nav4' } + ]; +} +@Component({ + template: ` + + @for (parent of data; track parent.name) { + + @for (child of parent.children; track child.value) { + + {{ child.name }} + + } + + } + `, + imports: [IgxDropDownComponent, IgxDropDownItemComponent, IgxDropDownGroupComponent] +}) +class GroupDropDownComponent { + @ViewChild(IgxDropDownComponent, { read: IgxDropDownComponent, static: true }) + public dropDown: IgxDropDownComponent; + @ViewChildren(IgxDropDownGroupComponent, { read: IgxDropDownGroupComponent }) + public groups: QueryList; + @ViewChildren(IgxDropDownItemComponent, { read: IgxDropDownItemComponent }) + public items: QueryList; + public data = []; + constructor() { + for (let i = 0; i < 3; i++) { + this.data.push({ + name: `Parent ${i + 1}`, + children: [] + }); + for (let j = 0; j < 3; j++) { + this.data[i].children.push({ + name: `Child ${j + 1} of Parent ${i + 1}`, + value: `custom-${i + '_' + j}` + }); + } + } + } +} +@Component({ + template: ` + + +
    + + {{ item.name }} + +
    +
    + `, + styles: [` + .wrapping-div { + overflow: hidden; + height: 400px; + } + `], + imports: [IgxDropDownComponent, IgxDropDownItemComponent, IgxForOfDirective, IgxButtonDirective, IgxDropDownItemNavigationDirective, IgxToggleActionDirective] +}) +class VirtualizedDropDownComponent { + @ViewChild('toggleButton', { read: ElementRef, static: true }) + public toggleButton: ElementRef; + @ViewChild(IgxDropDownComponent, { static: true }) + public dropdown: IgxDropDownComponent; + @ViewChild(IgxForOfDirective, { read: IgxForOfDirective, static: true }) + public virtualScroll: IgxForOfDirective; + @ViewChildren(IgxDropDownItemComponent) + public dropdownItems: QueryList; + public items = []; + public itemsMaxHeight = 400; + public itemHeight = 40; + constructor() { + this.items = Array.apply(null, { length: 2000 }).map((e, i) => ({ + name: `Item ${i + 1}`, + id: i + })); + } +} diff --git a/projects/igniteui-angular/drop-down/src/drop-down/drop-down.component.ts b/projects/igniteui-angular/drop-down/src/drop-down/drop-down.component.ts new file mode 100644 index 00000000000..6b579877603 --- /dev/null +++ b/projects/igniteui-angular/drop-down/src/drop-down/drop-down.component.ts @@ -0,0 +1,639 @@ +import { + Component, + ContentChildren, + ElementRef, + forwardRef, + QueryList, + OnChanges, + Input, + OnDestroy, + ViewChild, + ContentChild, + AfterViewInit, + Output, + EventEmitter, + SimpleChanges, + booleanAttribute, + inject} from '@angular/core'; +import { IgxToggleDirective, ToggleViewEventArgs } from 'igniteui-angular/directives'; +import { IgxDropDownItemComponent } from './drop-down-item.component'; +import { IgxDropDownBaseDirective } from './drop-down.base'; +import { DropDownActionKey, Navigate } from './drop-down.common'; +import { IGX_DROPDOWN_BASE, IDropDownBase } from './drop-down.common'; +import { ISelectionEventArgs } from './drop-down.common'; +import { IBaseCancelableBrowserEventArgs, IBaseEventArgs } from 'igniteui-angular/core'; +import { IgxSelectionAPIService } from 'igniteui-angular/core'; +import { Subject } from 'rxjs'; +import { IgxDropDownItemBaseDirective } from './drop-down-item.base'; +import { IgxForOfToken } from 'igniteui-angular/directives'; +import { take } from 'rxjs/operators'; +import { OverlaySettings } from 'igniteui-angular/core'; +import { ConnectedPositioningStrategy } from 'igniteui-angular/core'; + +/** + * **Ignite UI for Angular DropDown** - + * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/drop-down) + * + * The Ignite UI for Angular Drop Down displays a scrollable list of items which may be visually grouped and + * supports selection of a single item. Clicking or tapping an item selects it and closes the Drop Down + * + * Example: + * ```html + * + * + * {{ item.value }} + * + * + * ``` + */ + +@Component({ + selector: 'igx-drop-down', + templateUrl: './drop-down.component.html', + providers: [{ provide: IGX_DROPDOWN_BASE, useExisting: IgxDropDownComponent }], + imports: [IgxToggleDirective] +}) +export class IgxDropDownComponent extends IgxDropDownBaseDirective implements IDropDownBase, OnChanges, AfterViewInit, OnDestroy { + protected selection = inject(IgxSelectionAPIService); + + /** + * @hidden + * @internal + */ + @ContentChildren(forwardRef(() => IgxDropDownItemComponent), { descendants: true }) + public override children: QueryList; + + /** + * Emitted before the dropdown is opened + * + * ```html + * + * ``` + */ + @Output() + public opening = new EventEmitter(); + + /** + * Emitted after the dropdown is opened + * + * ```html + * + * ``` + */ + @Output() + public opened = new EventEmitter(); + + /** + * Emitted before the dropdown is closed + * + * ```html + * + * ``` + */ + @Output() + public closing = new EventEmitter(); + + /** + * Emitted after the dropdown is closed + * + * ```html + * + * ``` + */ + @Output() + public closed = new EventEmitter(); + + /** + * Gets/sets whether items take focus. Disabled by default. + * When enabled, drop down items gain tab index and are focused when active - + * this includes activating the selected item when opening the drop down and moving with keyboard navigation. + * + * Note: Keep that focus shift in mind when using the igxDropDownItemNavigation directive + * and ensure it's placed either on each focusable item or a common ancestor to allow it to handle keyboard events. + * + * ```typescript + * // get + * let dropDownAllowsItemFocus = this.dropdown.allowItemsFocus; + * ``` + * + * ```html + * + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public allowItemsFocus = false; + + /** + * Sets aria-labelledby attribute value. + * ```html + * + * ``` + */ + @Input() + public labelledBy: string; + + /** + * Gets/sets the `role` attribute of the drop down. Default is 'listbox'. + * + * ```html + *
    + * ``` + */ + @Input() + public role = 'listbox'; + + @ContentChild(IgxForOfToken) + protected virtDir: IgxForOfToken; + + @ViewChild(IgxToggleDirective, { static: true }) + protected toggleDirective: IgxToggleDirective; + + @ViewChild('scrollContainer', { static: true }) + protected scrollContainerRef: ElementRef; + + /** + * @hidden @internal + */ + public override get focusedItem(): IgxDropDownItemBaseDirective { + if (this.virtDir) { + return this._focusedItem && this._focusedItem.index !== -1 ? + (this.children.find(e => e.index === this._focusedItem.index) || null) : + null; + } + return this._focusedItem; + } + + public override set focusedItem(value: IgxDropDownItemBaseDirective) { + if (!value) { + this.selection.clear(`${this.id}-active`); + this._focusedItem = null; + return; + } + this._focusedItem = value; + if (this.virtDir) { + this._focusedItem = { + value: value.value, + index: value.index + } as IgxDropDownItemBaseDirective; + } + this.selection.set(`${this.id}-active`, new Set([this._focusedItem])); + } + + public override get id(): string { + return this._id; + } + public override set id(value: string) { + this.selection.set(value, this.selection.get(this.id)); + this.selection.clear(this.id); + this.selection.set(value, this.selection.get(`${this.id}-active`)); + this.selection.clear(`${this.id}-active`); + this._id = value; + } + + /** Id of the internal listbox of the drop down */ + public get listId() { + return this.id + '-list'; + } + + /** + * Get currently selected item + * + * ```typescript + * let currentItem = this.dropdown.selectedItem; + * ``` + */ + public get selectedItem(): IgxDropDownItemBaseDirective { + const selectedItem = this.selection.first_item(this.id); + if (selectedItem) { + return selectedItem; + } + return null; + } + + /** + * Gets if the dropdown is collapsed + * + * ```typescript + * let isCollapsed = this.dropdown.collapsed; + * ``` + */ + public get collapsed(): boolean { + return this.toggleDirective.collapsed; + } + + /** @hidden @internal */ + public override get scrollContainer(): HTMLElement { + return this.scrollContainerRef.nativeElement; + } + + protected get collectionLength() { + if (this.virtDir) { + return this.virtDir.totalItemCount || this.virtDir.igxForOf.length; + } + } + + protected destroy$ = new Subject(); + protected _scrollPosition: number; + + /** + * Opens the dropdown + * + * ```typescript + * this.dropdown.open(); + * ``` + */ + public open(overlaySettings?: OverlaySettings) { + const settings = { ... {}, ...this.getDefaultOverlaySettings(), ...overlaySettings }; + this.toggleDirective.open(settings); + this.updateScrollPosition(); + } + + /** + * @hidden @internal + */ + public getDefaultOverlaySettings(): OverlaySettings { + return { + closeOnOutsideClick: true, + modal: false, + positionStrategy: new ConnectedPositioningStrategy() + }; + } + + /** + * Closes the dropdown + * + * ```typescript + * this.dropdown.close(); + * ``` + */ + public close(event?: Event) { + this.toggleDirective.close(event); + } + + /** + * Toggles the dropdown + * + * ```typescript + * this.dropdown.toggle(); + * ``` + */ + public toggle(overlaySettings?: OverlaySettings) { + if (this.collapsed || this.toggleDirective.isClosing) { + this.open(overlaySettings); + } else { + this.close(); + } + } + + /** + * Select an item by index + * + * @param index of the item to select; If the drop down uses *igxFor, pass the index in data + */ + public setSelectedItem(index: number) { + if (index < 0 || index >= this.items.length) { + return; + } + let newSelection: IgxDropDownItemBaseDirective; + if (this.virtDir) { + newSelection = { + value: this.virtDir.igxForOf[index], + index + } as IgxDropDownItemBaseDirective; + } else { + newSelection = this.items[index]; + } + this.selectItem(newSelection); + } + + /** + * Navigates to the item on the specified index + * If the data in the drop-down is virtualized, pass the index of the item in the virtualized data. + * + * @param newIndex number + */ + public override navigateItem(index: number) { + if (this.virtDir) { + if (index === -1 || index >= this.collectionLength) { + return; + } + const direction = index > (this.focusedItem ? this.focusedItem.index : -1) ? Navigate.Down : Navigate.Up; + const subRequired = this.isIndexOutOfBounds(index, direction); + this.focusedItem = { + value: this.virtDir.igxForOf[index], + index + } as IgxDropDownItemBaseDirective; + if (subRequired) { + this.virtDir.scrollTo(index); + } + if (subRequired) { + this.virtDir.chunkLoad.pipe(take(1)).subscribe(() => { + this.skipHeader(direction); + }); + } else { + this.skipHeader(direction); + } + } else { + super.navigateItem(index); + } + if (this.allowItemsFocus && this.focusedItem) { + this.focusedItem.element.nativeElement.focus(); + this.cdr.markForCheck(); + } + } + + /** + * @hidden @internal + */ + public updateScrollPosition() { + if (!this.virtDir) { + return; + } + if (!this.selectedItem) { + this.virtDir.scrollTo(0); + return; + } + let targetScroll = this.virtDir.getScrollForIndex(this.selectedItem.index); + // TODO: This logic _cannot_ be right, those are optional user-provided inputs that can be strings with units, refactor: + const itemsInView = this.virtDir.igxForContainerSize / this.virtDir.igxForItemSize; + targetScroll -= (itemsInView / 2 - 1) * this.virtDir.igxForItemSize; + this.virtDir.getScroll().scrollTop = targetScroll; + } + + /** + * @hidden @internal + */ + public onToggleOpening(e: IBaseCancelableBrowserEventArgs) { + const args: IBaseCancelableBrowserEventArgs = { owner: this, event: e.event, cancel: false }; + this.opening.emit(args); + e.cancel = args.cancel; + if (e.cancel) { + return; + } + + if (this.virtDir) { + this.virtDir.scrollPosition = this._scrollPosition; + } + } + + /** + * @hidden @internal + */ + public onToggleContentAppended(_event: ToggleViewEventArgs) { + if (!this.virtDir && this.selectedItem) { + this.scrollToItem(this.selectedItem); + } + } + + /** + * @hidden @internal + */ + public onToggleOpened() { + this.updateItemFocus(); + this.opened.emit({ owner: this }); + } + + /** + * @hidden @internal + */ + public onToggleClosing(e: IBaseCancelableBrowserEventArgs) { + const args: IBaseCancelableBrowserEventArgs = { owner: this, event: e.event, cancel: false }; + this.closing.emit(args); + e.cancel = args.cancel; + if (e.cancel) { + return; + } + if (this.virtDir) { + this._scrollPosition = this.virtDir.scrollPosition; + } + } + + /** + * @hidden @internal + */ + public onToggleClosed() { + this.focusItem(false); + this.closed.emit({ owner: this }); + } + + /** + * @hidden @internal + */ + public ngOnDestroy() { + this.destroy$.next(true); + this.destroy$.complete(); + this.selection.delete(this.id); + this.selection.delete(`${this.id}-active`); + } + + /** @hidden @internal */ + public calculateScrollPosition(item: IgxDropDownItemBaseDirective): number { + if (!item) { + return 0; + } + + const elementRect = item.element.nativeElement.getBoundingClientRect(); + const parentRect = this.scrollContainer.getBoundingClientRect(); + const scrollDelta = parentRect.top - elementRect.top; + let scrollPosition = this.scrollContainer.scrollTop - scrollDelta; + + const dropDownHeight = this.scrollContainer.clientHeight; + scrollPosition -= dropDownHeight / 2; + scrollPosition += item.elementHeight / 2; + + return Math.floor(scrollPosition); + } + + /** + * @hidden @internal + */ + public ngOnChanges(changes: SimpleChanges) { + if (changes.id) { + // temp workaround until fix --> https://github.com/angular/angular/issues/34992 + this.toggleDirective.id = changes.id.currentValue; + } + } + + public ngAfterViewInit() { + if (this.virtDir) { + this.virtDir.igxForItemSize = 28; + } + } + + /** Keydown Handler */ + public override onItemActionKey(key: DropDownActionKey, event?: Event) { + super.onItemActionKey(key, event); + this.close(event); + } + + /** + * Virtual scroll implementation + * + * @hidden @internal + */ + public override navigateFirst() { + if (this.virtDir) { + this.navigateItem(0); + } else { + super.navigateFirst(); + } + } + + /** + * @hidden @internal + */ + public override navigateLast() { + if (this.virtDir) { + this.navigateItem(this.virtDir.totalItemCount ? this.virtDir.totalItemCount - 1 : this.virtDir.igxForOf.length - 1); + } else { + super.navigateLast(); + } + } + + /** + * @hidden @internal + */ + public override navigateNext() { + if (this.virtDir) { + this.navigateItem(this._focusedItem ? this._focusedItem.index + 1 : 0); + } else { + super.navigateNext(); + } + } + + /** + * @hidden @internal + */ + public override navigatePrev() { + if (this.virtDir) { + this.navigateItem(this._focusedItem ? this._focusedItem.index - 1 : 0); + } else { + super.navigatePrev(); + } + } + + /** + * Handles the `selectionChanging` emit and the drop down toggle when selection changes + * + * @hidden + * @internal + * @param newSelection + * @param emit + * @param event + */ + public override selectItem(newSelection?: IgxDropDownItemBaseDirective, event?: Event, emit = true) { + const oldSelection = this.selectedItem; + if (!newSelection) { + newSelection = this.focusedItem; + } + if (newSelection === null) { + return; + } + if (newSelection instanceof IgxDropDownItemBaseDirective && newSelection.isHeader) { + return; + } + if (this.virtDir) { + newSelection = { + value: newSelection.value, + index: newSelection.index + } as IgxDropDownItemBaseDirective; + } + const args: ISelectionEventArgs = { oldSelection, newSelection, cancel: false, owner: this }; + + if (emit) { + this.selectionChanging.emit(args); + } + + if (!args.cancel) { + if (this.isSelectionValid(args.newSelection)) { + this.selection.set(this.id, new Set([args.newSelection])); + if (!this.virtDir) { + if (oldSelection) { + oldSelection.selected = false; + } + if (args.newSelection) { + args.newSelection.selected = true; + } + } + if (event) { + this.toggleDirective.close(event); + } + } else { + throw new Error('Please provide a valid drop-down item for the selection!'); + } + } + } + + /** + * Clears the selection of the dropdown + * ```typescript + * this.dropdown.clearSelection(); + * ``` + */ + public clearSelection() { + const oldSelection = this.selectedItem; + const newSelection: IgxDropDownItemBaseDirective = null; + const args: ISelectionEventArgs = { oldSelection, newSelection, cancel: false, owner: this }; + this.selectionChanging.emit(args); + if (this.selectedItem && !args.cancel) { + this.selectedItem.selected = false; + this.selection.clear(this.id); + } + } + + /** + * Checks whether the selection is valid + * `null` - the selection should be emptied + * Virtual? - the selection should at least have and `index` and `value` property + * Non-virtual? - the selection should be a valid drop-down item and **not** be a header + */ + protected isSelectionValid(selection: any): boolean { + return selection === null + || (this.virtDir && selection.hasOwnProperty('value') && selection.hasOwnProperty('index')) + || (selection instanceof IgxDropDownItemComponent && !selection.isHeader); + } + + protected scrollToItem(item: IgxDropDownItemBaseDirective) { + this.scrollContainer.scrollTop = this.calculateScrollPosition(item); + } + + protected focusItem(value: boolean) { + if (value || this._focusedItem) { + this._focusedItem.focused = value; + } + } + + protected updateItemFocus() { + if (this.selectedItem) { + this.focusedItem = this.selectedItem; + this.focusItem(true); + } else if (this.allowItemsFocus) { + this.navigateFirst(); + } + } + + protected skipHeader(direction: Navigate) { + if (!this.focusedItem) { + return; + } + if (this.focusedItem.isHeader || this.focusedItem.disabled) { + if (direction === Navigate.Up) { + this.navigatePrev(); + } else { + this.navigateNext(); + } + } + } + + private isIndexOutOfBounds(index: number, direction: Navigate) { + const virtState = this.virtDir.state; + const currentPosition = this.virtDir.getScroll().scrollTop; + const itemPosition = this.virtDir.getScrollForIndex(index, direction === Navigate.Down); + const indexOutOfChunk = index < virtState.startIndex || index > virtState.chunkSize + virtState.startIndex; + const scrollNeeded = direction === Navigate.Down ? currentPosition < itemPosition : currentPosition > itemPosition; + const subRequired = indexOutOfChunk || scrollNeeded; + return subRequired; + } +} + diff --git a/projects/igniteui-angular/drop-down/src/drop-down/drop-down.module.ts b/projects/igniteui-angular/drop-down/src/drop-down/drop-down.module.ts new file mode 100644 index 00000000000..698d3356a86 --- /dev/null +++ b/projects/igniteui-angular/drop-down/src/drop-down/drop-down.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { IGX_DROP_DOWN_DIRECTIVES } from './public_api'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + ...IGX_DROP_DOWN_DIRECTIVES + ], + exports: [ + ...IGX_DROP_DOWN_DIRECTIVES + ] +}) +export class IgxDropDownModule { } diff --git a/projects/igniteui-angular/drop-down/src/drop-down/public_api.ts b/projects/igniteui-angular/drop-down/src/drop-down/public_api.ts new file mode 100644 index 00000000000..e627fa1a5c9 --- /dev/null +++ b/projects/igniteui-angular/drop-down/src/drop-down/public_api.ts @@ -0,0 +1,23 @@ +import { IgxDropDownGroupComponent } from './drop-down-group.component'; +import { IgxDropDownItemComponent } from './drop-down-item.component'; +import { IgxDropDownItemNavigationDirective } from './drop-down-navigation.directive'; +import { IgxDropDownComponent } from './drop-down.component'; + +export * from './drop-down.component'; +export * from './drop-down.base'; +export * from './drop-down-item.base'; +export * from './drop-down.common' +export * from './drop-down-item.component'; +export { ISelectionEventArgs, IDropDownNavigationDirective } from './drop-down.common'; +export * from './drop-down-navigation.directive'; +export * from './drop-down-group.component'; +export * from './autocomplete/autocomplete.directive'; +export * from './autocomplete/autocomplete.module'; + +/* NOTE: Drop down directives collection for ease-of-use import in standalone components scenario */ +export const IGX_DROP_DOWN_DIRECTIVES = [ + IgxDropDownComponent, + IgxDropDownItemComponent, + IgxDropDownGroupComponent, + IgxDropDownItemNavigationDirective +] as const; diff --git a/projects/igniteui-angular/drop-down/src/public_api.ts b/projects/igniteui-angular/drop-down/src/public_api.ts new file mode 100644 index 00000000000..95a64ece82e --- /dev/null +++ b/projects/igniteui-angular/drop-down/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './drop-down/public_api'; +export * from './drop-down/drop-down.module'; diff --git a/projects/igniteui-angular/expansion-panel/README.md b/projects/igniteui-angular/expansion-panel/README.md new file mode 100644 index 00000000000..b02f2cd7ead --- /dev/null +++ b/projects/igniteui-angular/expansion-panel/README.md @@ -0,0 +1,104 @@ +# IgxExpansionPanel + + +**IgxExpansionPanel** is a light and highly templateable component that allows you to dynamically display content. + +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/expansion-panel) + +# Usage + +```html + + + + Title + + + Description + + + +

    Lengthier and more detailed description. Only visible when the panel is expanded

    +
    +
    +``` + +## igx-expansion-panel-header +The header of the `igx-expansion-panel` is **always** visible - this is the part of the component which handles user interaction. + +### igx-expansion-panel-title +The `title` part of the header is **always** visible and will always be placed in the beginning of the header (after the icon, depending on settings) +The title should be used to describe the content of the panel's body. + +### igx-expansion-panel-description +The `description` part of the header is **always** visible and will always be placed in the middle of the header (after the title). +The description can be used to provide a very short and concise explanation, further expanding upon the title, on the content of the panel's body. + +## igx-panel-body +The `igx-expansion-panel-body` contains all of the content in the `igx-expansion-panel` which should not be initially visible. The `body` is **sometimes** visible - only when the expansion panel is **not** `collapsed` + +# API Summary +The following tables summarize the **igx-expansion-panel**, **igx-expansion-panel-header** and **igx-expansion-panel-body** inputs, outputs and methods. + +## IgxExpansionPanelComponent + +### Inputs +The following inputs are available in the **igx-expansion-panel** component: + +| Name | Type | Description | +| :--- | :--- | :--- | +| `animationSettings` | `AnimationSettings` | Specifies the settings for the open and close animations of the panel | +| `id` | `string` | The id of the panel's host component | +| `collapsed` | `boolean` | Whether the component is collapsed (body is hidden) or not. Does not trigger animation. | + +### Outputs +The following outputs are available in the **igx-expansion-panel** component: + +| Name | Cancelable | Description | Parameters +| :--- | :--- | :--- | :--- | +| `contentCollapsed` | `false` | Emitted when the panel is collapsed | `IExpansionPanelEventArgs` | +| `contentCollapsing` | `true` | Emitted when the panel begins collapsing | `IExpansionPanelCancelableEventArgs` | +| `contentExpanded` | `false` | Emitted when the panel is expanded | `IExpansionPanelEventArgs` | +| `contentExpanding` | `true` | Emitted when the panel begins expanding | `IExpansionPanelCancelableEventArgs` | + + +### Methods +The following methods are available in the **igx-expansion-panel** component: + +| Name | Signature | Description | +| :--- | :--- | :--- | +| `collapse` | `(event?: Event ): void` | Collapses the panel, triggering animations | +| `expand` | `(event?: Event ): void` | Expands the panel, triggering animation | +| `toggle` | `(event?: Event ): void` | Toggles the panel (calls `collapse(event)` or `expand(event)` depending on `collapsed`) | + + +## IgxExpansionPanelHeaderComponent +### Inputs +The following inputs are available in the **igx-expansion-panel-header** component: + +| Name | Type | Description | +| :--- | :--- | :--- | +| `id` | `string` | The id of the panel header | +| `lv` | `string` | The `aria-level` attribute of the header | +| `role` | `string` | The `role` attribute of the header | +| `iconPosition` | `string` | The position of the expand/collapse icon of the header | +| `disabled` | `boolean` | Gets/sets whether the panel header is disabled (blocking user interaction) or not | +| `iconRef` | `ElementRef` | Gets the reference to the element being used as expand/collapse indicator. If `iconPosition` is `NONE` - return `null` | + + +### Outputs +The following outputs are available in the **igx-expansion-panel-header** component: + +| Name | Cancelable | Description | Parameters +| :--- | :--- | :--- | :--- | +| `interaction` | `true` | Emitted when a user interacts with the header host | `IExpansionPanelCancelableEventArgs` | + +## IgxExpansionPanelBodyComponent +### Inputs +The following inputs are available in the **igx-expansion-panel-body** component: + +| Name | Type | Description | +| :--- | :--- | :--- | +| `labelledBy` | `string` | The `aria-labelledby` attribute of the panel body | +| `label` | `string` | The `aria-label` attribute of the panel body | +| `role` | `string` | The `role` attribute of the panel body | diff --git a/projects/igniteui-angular/expansion-panel/index.ts b/projects/igniteui-angular/expansion-panel/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/expansion-panel/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/expansion-panel/ng-package.json b/projects/igniteui-angular/expansion-panel/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/expansion-panel/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel-body.component.ts b/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel-body.component.ts new file mode 100644 index 00000000000..84902e18e43 --- /dev/null +++ b/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel-body.component.ts @@ -0,0 +1,93 @@ +import { Component, HostBinding, ElementRef, Input, ChangeDetectorRef, inject } from '@angular/core'; +import { IgxExpansionPanelBase, IGX_EXPANSION_PANEL_COMPONENT } from './expansion-panel.common'; + +@Component({ + selector: 'igx-expansion-panel-body', + template: ``, + standalone: true +}) +export class IgxExpansionPanelBodyComponent { + public panel = inject(IGX_EXPANSION_PANEL_COMPONENT); + public element = inject(ElementRef); + public cdr = inject(ChangeDetectorRef); + + /** + * @hidden + */ + @HostBinding('class.igx-expansion-panel__body') + public cssClass = `igx-expansion-panel__body`; + + /** + * Gets/sets the `role` attribute of the panel body + * Default is 'region'; + * Get + * ```typescript + * const currentRole = this.panel.body.role; + * ``` + * Set + * ```typescript + * this.panel.body.role = 'content'; + * ``` + * ```html + * + * ``` + */ + @Input() + @HostBinding('attr.role') + public role = 'region'; + + private _labelledBy = ''; + private _label = ''; + + /** + * Gets the `aria-label` attribute of the panel body + * Defaults to the panel id with '-region' in the end; + * Get + * ```typescript + * const currentLabel = this.panel.body.label; + * ``` + */ + @Input() + @HostBinding('attr.aria-label') + public get label(): string { + return this._label || this.panel.id + '-region'; + } + /** + * Sets the `aria-label` attribute of the panel body + * ```typescript + * this.panel.body.label = 'my-custom-label'; + * ``` + * ```html + * + * ``` + */ + public set label(val: string) { + this._label = val; + } + + /** + * Gets the `aria-labelledby` attribute of the panel body + * Defaults to the panel header id; + * Get + * ```typescript + * const currentLabel = this.panel.body.labelledBy; + * ``` + */ + @Input() + @HostBinding('attr.aria-labelledby') + public get labelledBy(): string { + return this._labelledBy; + } + /** + * Sets the `aria-labelledby` attribute of the panel body + * ```typescript + * this.panel.body.labelledBy = 'my-custom-id'; + * ``` + * ```html + * + * ``` + */ + public set labelledBy(val: string) { + this._labelledBy = val; + } +} diff --git a/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel-header.component.html b/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel-header.component.html new file mode 100644 index 00000000000..79c26c1e310 --- /dev/null +++ b/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel-header.component.html @@ -0,0 +1,19 @@ +
    +
    + + +
    + +
    + @if (iconTemplate) { + + } + @if (!iconTemplate) { + + + } +
    +
    diff --git a/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel-header.component.ts b/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel-header.component.ts new file mode 100644 index 00000000000..2a89c02649a --- /dev/null +++ b/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel-header.component.ts @@ -0,0 +1,272 @@ +import { Component, ChangeDetectorRef, ElementRef, HostBinding, HostListener, Input, EventEmitter, Output, ContentChild, ViewChild, booleanAttribute, inject } from '@angular/core'; +import { IgxExpansionPanelIconDirective } from './expansion-panel.directives'; +import { IGX_EXPANSION_PANEL_COMPONENT, IgxExpansionPanelBase, IExpansionPanelCancelableEventArgs } from './expansion-panel.common'; +import { IgxIconComponent } from 'igniteui-angular/icon'; + +/** + * @hidden + */ +export const ExpansionPanelHeaderIconPosition = { + LEFT: 'left', + NONE: 'none', + RIGHT: 'right' +} as const; +export type ExpansionPanelHeaderIconPosition = (typeof ExpansionPanelHeaderIconPosition)[keyof typeof ExpansionPanelHeaderIconPosition]; + + +@Component({ + selector: 'igx-expansion-panel-header', + templateUrl: 'expansion-panel-header.component.html', + imports: [IgxIconComponent] +}) +export class IgxExpansionPanelHeaderComponent { + public panel = inject(IGX_EXPANSION_PANEL_COMPONENT, { host: true }); + public cdr = inject(ChangeDetectorRef); + public elementRef = inject(ElementRef); + + /** + * Returns a reference to the `igx-expansion-panel-icon` element; + * If `iconPosition` is `NONE` - return null; + */ + public get iconRef(): ElementRef { + const renderedTemplate = this.customIconRef ?? this.defaultIconRef; + return this.iconPosition !== ExpansionPanelHeaderIconPosition.NONE ? renderedTemplate : null; + } + + /** + * @hidden + */ + @ContentChild(IgxExpansionPanelIconDirective) + public set iconTemplate(val: boolean) { + this._iconTemplate = val; + } + + /** + * @hidden + */ + public get iconTemplate(): boolean { + return this._iconTemplate; + } + + /** + * Gets/sets the `aria-level` attribute of the header + * Get + * ```typescript + * const currentAriaLevel = this.panel.header.lv; + * ``` + * Set + * ```typescript + * this.panel.header.lv = '5'; + * ``` + * ```html + * + * ``` + */ + @HostBinding('attr.aria-level') + @Input() + public lv = '3'; + + /** + * Gets/sets the `role` attribute of the header + * Get + * ```typescript + * const currentRole = this.panel.header.role; + * ``` + * Set + * ```typescript + * this.panel.header.role = '5'; + * ``` + * ```html + * + * ``` + */ + @HostBinding('attr.role') + @Input() + public role = 'heading'; + + /** + * @hidden + */ + public get controls(): string { + return this.panel.id; + } + + /** + * @hidden @internal + */ + public get innerElement() { + return this.elementRef.nativeElement.children[0]; + } + + /** + * Gets/sets the position of the expansion-panel-header expand/collapse icon + * Accepts `left`, `right` or `none` + * ```typescript + * const currentIconPosition = this.panel.header.iconPosition; + * ``` + * Set + * ```typescript + * this.panel.header.iconPosition = 'left'; + * ``` + * ```html + * + * ``` + */ + @Input() + public iconPosition: ExpansionPanelHeaderIconPosition = ExpansionPanelHeaderIconPosition.LEFT; + + /** + * Emitted whenever a user interacts with the header host + * ```typescript + * handleInteraction(event: IExpansionPanelCancelableEventArgs) { + * ... + * } + * ``` + * ```html + * + * ... + * + * ``` + */ + @Output() + public interaction = new EventEmitter(); + + /** + * @hidden + */ + @HostBinding('class.igx-expansion-panel__header') + public cssClass = 'igx-expansion-panel__header'; + + /** + * @hidden + */ + @HostBinding('class.igx-expansion-panel__header--expanded') + public get isExpanded() { + return !this.panel.collapsed; + } + + /** + * Gets/sets the whether the header is disabled + * When disabled, the header will not handle user events and will stop their propagation + * + * ```typescript + * const isDisabled = this.panel.header.disabled; + * ``` + * Set + * ```typescript + * this.panel.header.disabled = true; + * ``` + * ```html + * + * ... + * + * ``` + */ + @Input({ transform: booleanAttribute }) + @HostBinding('class.igx-expansion-panel--disabled') + public get disabled(): boolean { + return this._disabled; + } + + public set disabled(val: boolean) { + this._disabled = val; + if (val) { + // V.S. June 11th, 2021: #9696 TabIndex should be removed when panel is disabled + delete this.tabIndex; + } else { + this.tabIndex = 0; + } + } + + /** @hidden @internal */ + @ContentChild(IgxExpansionPanelIconDirective, { read: ElementRef }) + private customIconRef: ElementRef; + + /** @hidden @internal */ + @ViewChild(IgxIconComponent, { read: ElementRef }) + private defaultIconRef: ElementRef; + + /** + * Sets/gets the `id` of the expansion panel header. + * ```typescript + * let panelHeaderId = this.panel.header.id; + * ``` + * + * @memberof IgxExpansionPanelComponent + */ + public id = ''; + + /** @hidden @internal */ + public tabIndex = 0; + + // properties section + private _iconTemplate = false; + private _disabled = false; + + constructor() { + this.id = `${this.panel.id}-header`; + } + + /** + * @hidden + */ + @HostListener('keydown.Enter', ['$event']) + @HostListener('keydown.Space', ['$event']) + @HostListener('keydown.Spacebar', ['$event']) + @HostListener('click', ['$event']) + public onAction(evt?: Event) { + if (this.disabled) { + evt.stopPropagation(); + return; + } + const eventArgs: IExpansionPanelCancelableEventArgs = { event: evt, owner: this.panel, cancel: false }; + this.interaction.emit(eventArgs); + if (eventArgs.cancel === true) { + return; + } + this.panel.toggle(evt); + evt.preventDefault(); + } + + /** @hidden @internal */ + @HostListener('keydown.alt.arrowdown', ['$event']) + public openPanel(event: KeyboardEvent) { + if (event.altKey) { + const eventArgs: IExpansionPanelCancelableEventArgs = { event, owner: this.panel, cancel: false }; + this.interaction.emit(eventArgs); + if (eventArgs.cancel === true) { + return; + } + this.panel.expand(event); + } + } + + /** @hidden @internal */ + @HostListener('keydown.alt.arrowup', ['$event']) + public closePanel(event: KeyboardEvent) { + if (event.altKey) { + const eventArgs: IExpansionPanelCancelableEventArgs = { event, owner: this.panel, cancel: false }; + this.interaction.emit(eventArgs); + if (eventArgs.cancel === true) { + return; + } + this.panel.collapse(event); + } + } + + /** + * @hidden + */ + public get iconPositionClass(): string { + switch (this.iconPosition) { + case (ExpansionPanelHeaderIconPosition.LEFT): + return `igx-expansion-panel__header-icon--start`; + case (ExpansionPanelHeaderIconPosition.RIGHT): + return `igx-expansion-panel__header-icon--end`; + case (ExpansionPanelHeaderIconPosition.NONE): + return `igx-expansion-panel__header-icon--none`; + default: + return ''; + } + } +} diff --git a/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel.common.ts b/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel.common.ts new file mode 100644 index 00000000000..ee08acffd3e --- /dev/null +++ b/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel.common.ts @@ -0,0 +1,65 @@ +import { Directive, ElementRef, EventEmitter, inject, InjectionToken } from '@angular/core'; +import { AnimationReferenceMetadata } from '@angular/animations'; +import { CancelableEventArgs, IBaseEventArgs } from 'igniteui-angular/core'; + +export interface IgxExpansionPanelBase { + id: string; + cssClass: string; + /** @hidden @internal */ + headerId: string; + collapsed: boolean; + animationSettings: { openAnimation: AnimationReferenceMetadata; closeAnimation: AnimationReferenceMetadata }; + contentCollapsed: EventEmitter; + contentCollapsing: EventEmitter; + contentExpanded: EventEmitter; + contentExpanding: EventEmitter; + collapse(evt?: Event); + expand(evt?: Event); + toggle(evt?: Event); +} + +/** @hidden */ +export const IGX_EXPANSION_PANEL_COMPONENT = /*@__PURE__*/new InjectionToken('IgxExpansionPanelToken'); + +export interface IExpansionPanelEventArgs extends IBaseEventArgs { + event: Event; +} + +export interface IExpansionPanelCancelableEventArgs extends IExpansionPanelEventArgs, CancelableEventArgs {} + +@Directive() +export abstract class HeaderContentBaseDirective { + protected element = inject(ElementRef); + + /** + * Returns the `textContent` of an element + * + * ```html + * + * Tooltip content + * + * ``` + * + * or the `title` content + * + * ```html + * + * + * ``` + * + * If both are provided, returns the `title` content. + * + * @param element + * @returns tooltip content for an element + */ + public getTooltipContent = (element: ElementRef): string => { + if (element.nativeElement.title) { + return element.nativeElement.title; + } + if (element.nativeElement.textContent) { + return element.nativeElement.textContent.trim(); + } + + return null; + }; +} diff --git a/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel.component.html b/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel.component.html new file mode 100644 index 00000000000..da28c2f0e1e --- /dev/null +++ b/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel.component.html @@ -0,0 +1,4 @@ + +@if (!collapsed) { + +} diff --git a/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel.component.ts b/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel.component.ts new file mode 100644 index 00000000000..cdd36288ff4 --- /dev/null +++ b/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel.component.ts @@ -0,0 +1,317 @@ +import { + AfterContentInit, + ChangeDetectorRef, + Component, + ContentChild, + ElementRef, + EventEmitter, + HostBinding, + inject, + Input, + Output, + booleanAttribute +} from '@angular/core'; +import { IgxExpansionPanelBodyComponent } from './expansion-panel-body.component'; +import { IgxExpansionPanelHeaderComponent } from './expansion-panel-header.component'; +import { + IExpansionPanelCancelableEventArgs, + IExpansionPanelEventArgs, + IgxExpansionPanelBase, + IGX_EXPANSION_PANEL_COMPONENT +} from './expansion-panel.common'; +import { ToggleAnimationPlayer, ToggleAnimationSettings } from './toggle-animation-component'; + +let NEXT_ID = 0; + +@Component({ + selector: 'igx-expansion-panel', + templateUrl: 'expansion-panel.component.html', + providers: [{ provide: IGX_EXPANSION_PANEL_COMPONENT, useExisting: IgxExpansionPanelComponent }], + imports: [] +}) +export class IgxExpansionPanelComponent extends ToggleAnimationPlayer implements IgxExpansionPanelBase, AfterContentInit { + private cdr = inject(ChangeDetectorRef); + private elementRef = inject(ElementRef); + + /** + * Sets/gets the animation settings of the expansion panel component + * Open and Close animation should be passed + * + * Get + * ```typescript + * const currentAnimations = this.panel.animationSettings; + * ``` + * Set + * ```typescript + * import { slideInLeft, slideOutRight } from 'igniteui-angular'; + * ... + * this.panel.animationsSettings = { + * openAnimation: slideInLeft, + * closeAnimation: slideOutRight + * }; + * ``` + * or via template + * ```typescript + * import { slideInLeft, slideOutRight } from 'igniteui-angular'; + * ... + * myCustomAnimationObject = { + * openAnimation: slideInLeft, + * closeAnimation: slideOutRight + * }; + * ```html + * + * ... + * + * ``` + */ + @Input() + public override get animationSettings(): ToggleAnimationSettings { + return this._animationSettings; + } + public override set animationSettings(value: ToggleAnimationSettings) { + this._animationSettings = value; + } + + /** + * Sets/gets the `id` of the expansion panel component. + * If not set, `id` will have value `"igx-expansion-panel-0"`; + * ```html + * + * ``` + * ```typescript + * let panelId = this.panel.id; + * ``` + * + * @memberof IgxExpansionPanelComponent + */ + @HostBinding('attr.id') + @Input() + public id = `igx-expansion-panel-${NEXT_ID++}`; + + /** + * @hidden + */ + @HostBinding('class.igx-expansion-panel') + public cssClass = 'igx-expansion-panel'; + + /** + * @hidden + */ + @HostBinding('class.igx-expansion-panel--expanded') + protected opened = false; + + /** + * @hidden @internal + */ + @HostBinding('attr.aria-expanded') + public get panelExpanded() { + return !this.collapsed; + } + + /** + * Gets/sets whether the component is collapsed (its content is hidden) + * Get + * ```typescript + * const myPanelState: boolean = this.panel.collapsed; + * ``` + * Set + * ```html + * this.panel.collapsed = true; + * ``` + * + * Two-way data binding: + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public collapsed = true; + + /** + * @hidden + */ + @Output() + public collapsedChange = new EventEmitter(); + + /** + * Emitted when the expansion panel starts collapsing + * ```typescript + * handleCollapsing(event: IExpansionPanelCancelableEventArgs) + * ``` + * ```html + * + * ... + * + * ``` + */ + @Output() + public contentCollapsing = new EventEmitter(); + + /** + * Emitted when the expansion panel finishes collapsing + * ```typescript + * handleCollapsed(event: IExpansionPanelEventArgs) + * ``` + * ```html + * + * ... + * + * ``` + */ + @Output() + public contentCollapsed = new EventEmitter(); + + /** + * Emitted when the expansion panel starts expanding + * ```typescript + * handleExpanding(event: IExpansionPanelCancelableEventArgs) + * ``` + * ```html + * + * ... + * + * ``` + */ + @Output() + public contentExpanding = new EventEmitter(); + + /** + * Emitted when the expansion panel finishes expanding + * ```typescript + * handleExpanded(event: IExpansionPanelEventArgs) + * ``` + * ```html + * + * ... + * + * ``` + */ + @Output() + public contentExpanded = new EventEmitter(); + + /** + * @hidden + */ + public get headerId() { + return this.header ? `${this.id}-header` : ''; + } + + /** + * @hidden @internal + */ + public get nativeElement() { + return this.elementRef.nativeElement; + } + + /** + * @hidden + */ + @ContentChild(IgxExpansionPanelBodyComponent, { read: IgxExpansionPanelBodyComponent }) + public body: IgxExpansionPanelBodyComponent; + + /** + * @hidden + */ + @ContentChild(IgxExpansionPanelHeaderComponent, { read: IgxExpansionPanelHeaderComponent }) + public header: IgxExpansionPanelHeaderComponent; + + /** @hidden */ + public ngAfterContentInit(): void { + if (this.body && this.header) { + // schedule at end of turn: + Promise.resolve().then(() => { + this.body.labelledBy = this.body.labelledBy || this.headerId; + this.body.label = this.body.label || this.id + '-region'; + }); + } + } + + /** + * Collapses the panel + * + * ```html + * + * ... + * + * + * ``` + */ + public collapse(evt?: Event) { + // If expansion panel is already collapsed or is collapsing, do nothing + if (this.collapsed || this.closeAnimationPlayer) { + return; + } + const args = { event: evt, panel: this, owner: this, cancel: false }; + this.contentCollapsing.emit(args); + if (args.cancel === true) { + return; + } + this.opened = false; + this.playCloseAnimation( + this.body?.element, + () => { + this.contentCollapsed.emit({ event: evt, owner: this }); + this.collapsed = true; + this.collapsedChange.emit(true); + this.cdr.markForCheck(); + } + ); + } + + /** + * Expands the panel + * + * ```html + * + * ... + * + * + * ``` + */ + public expand(evt?: Event) { + if (!this.collapsed && !this.closeAnimationPlayer) { // Check if the panel is currently collapsing or already expanded + return; + } + const args = { event: evt, panel: this, owner: this, cancel: false }; + this.contentExpanding.emit(args); + if (args.cancel === true) { + return; + } + this.collapsed = false; + this.opened = true; + this.collapsedChange.emit(false); + this.cdr.detectChanges(); + this.playOpenAnimation( + this.body?.element, + () => { + this.contentExpanded.emit({ event: evt, owner: this }); + } + ); + } + + /** + * Toggles the panel + * + * ```html + * + * ... + * + * + * ``` + */ + public toggle(evt?: Event) { + if (this.collapsed) { + this.open(evt); + } else { + this.close(evt); + } + } + + public open(evt?: Event) { + this.expand(evt); + } + + public close(evt?: Event) { + this.collapse(evt); + } +} diff --git a/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel.directives.ts b/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel.directives.ts new file mode 100644 index 00000000000..c4adb9c6d64 --- /dev/null +++ b/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel.directives.ts @@ -0,0 +1,39 @@ +import { Directive, HostBinding } from '@angular/core'; +import { HeaderContentBaseDirective } from './expansion-panel.common'; + +/** @hidden @internal */ +@Directive({ + selector: 'igx-expansion-panel-title', + standalone: true +}) +export class IgxExpansionPanelTitleDirective extends HeaderContentBaseDirective { + @HostBinding('class.igx-expansion-panel__header-title') + public cssClass = `igx-expansion-panel__header-title`; + + @HostBinding('attr.title') + private get title(): string { + return this.getTooltipContent(this.element); + } +} + +/** @hidden @internal */ +@Directive({ + selector: 'igx-expansion-panel-description', + standalone: true +}) +export class IgxExpansionPanelDescriptionDirective extends HeaderContentBaseDirective { + @HostBinding('class.igx-expansion-panel__header-description') + public cssClass = `igx-expansion-panel__header-description`; + + @HostBinding('attr.title') + private get title(): string { + return this.getTooltipContent(this.element); + } +} + +/** @hidden @internal */ +@Directive({ + selector: 'igx-expansion-panel-icon', + standalone: true +}) +export class IgxExpansionPanelIconDirective { } diff --git a/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel.module.ts b/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel.module.ts new file mode 100644 index 00000000000..516a5c64345 --- /dev/null +++ b/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { IGX_EXPANSION_PANEL_DIRECTIVES } from './public_api'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + ...IGX_EXPANSION_PANEL_DIRECTIVES + ], + exports: [ + ...IGX_EXPANSION_PANEL_DIRECTIVES + ] +}) +export class IgxExpansionPanelModule { } diff --git a/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel.spec.ts b/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel.spec.ts new file mode 100644 index 00000000000..6e6b570d4e7 --- /dev/null +++ b/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel.spec.ts @@ -0,0 +1,1449 @@ + +import { Component, DebugElement, ViewChild } from '@angular/core'; +import { TestBed, ComponentFixture, tick, fakeAsync, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxExpansionPanelComponent } from './expansion-panel.component'; +import { ExpansionPanelHeaderIconPosition, IgxExpansionPanelHeaderComponent } from './expansion-panel-header.component'; +import { IgxExpansionPanelDescriptionDirective, IgxExpansionPanelIconDirective, IgxExpansionPanelTitleDirective } from './expansion-panel.directives'; +import { By } from '@angular/platform-browser'; +import { IgxExpansionPanelBodyComponent } from './expansion-panel-body.component'; +import { IgxListComponent } from 'igniteui-angular/list'; +import { IgxListItemComponent } from 'igniteui-angular/list'; +import { IGX_EXPANSION_PANEL_DIRECTIVES } from './public_api'; +import { IgxGridComponent } from 'igniteui-angular/grids/grid'; + +const CSS_CLASS_EXPANSION_PANEL = 'igx-expansion-panel'; +const CSS_CLASS_PANEL_HEADER = 'igx-expansion-panel__header'; +const CSS_CLASS_PANEL_HEADER_TITLE = 'igx-expansion-panel__header-title'; +const CSS_CLASS_PANEL_HEADER_DESCRIPTION = 'igx-expansion-panel__header-description'; +const CSS_CLASS_PANEL_TITLE_WRAPPER = 'igx-expansion-panel__title-wrapper'; +const CSS_CLASS_PANEL_BODY = 'igx-expansion-panel-body'; +const CSS_CLASS_HEADER_EXPANDED = 'igx-expansion-panel__header--expanded'; +const CSS_CLASS_HEADER_ICON_START = 'igx-expansion-panel__header-icon--start'; +const CSS_CLASS_HEADER_ICON_END = 'igx-expansion-panel__header-icon--end'; +const CSS_CLASS_HEADER_ICON_NONE = 'igx-expansion-panel__header-icon--none'; +const CSS_CLASS_PANEL_ICON = 'igx-icon'; +const CSS_CLASS_LIST = 'igx-list'; +const CSS_CLASS_GRID = 'igx-grid'; +const enum IconPositionClass { + LEFT = 'igx-expansion-panel__header-icon--start', + RIGHT = 'igx-expansion-panel__header-icon--end', + NONE = 'igx-expansion-panel__header-icon--none', +} + +describe('igxExpansionPanel', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxExpansionPanelGridComponent, + IgxExpansionPanelListComponent, + IgxExpansionPanelSampleComponent, + IgxExpansionPanelImageComponent, + IgxExpansionPanelTooltipComponent + ] + }).compileComponents(); + })); + + + describe('General tests: ', () => { + it('Should initialize the expansion panel component properly', () => { + const fixture: ComponentFixture = TestBed.createComponent(IgxExpansionPanelListComponent); + fixture.detectChanges(); + const panel = fixture.componentInstance.expansionPanel; + const header = fixture.componentInstance.header; + expect(fixture.componentInstance).toBeDefined(); + expect(panel).toBeDefined(); + // expect(panel.toggleBtn).toBeDefined(); + expect(header.disabled).toBeDefined(); + expect(header.disabled).toEqual(false); + // expect(panel.ariaLabelledBy).toBeDefined(); + // expect(panel.ariaLabelledBy).toEqual(''); + expect(panel.animationSettings).toBeDefined(); + expect(panel.collapsed).toBeDefined(); + expect(panel.collapsed).toBeTruthy(); + panel.toggle(); + fixture.detectChanges(); + expect(panel.collapsed).toEqual(false); + }); + it('Should properly accept input properties', () => { + const fixture = TestBed.createComponent(IgxExpansionPanelListComponent); + fixture.detectChanges(); + const panel = fixture.componentInstance.expansionPanel; + // expect(panel.disabled).toEqual(false); + // expect(panel.collapsed).toEqual(true); + // // expect(panel.ariaLabelledBy).toEqual(''); + // panel.disabled = true; + // expect(panel.disabled).toEqual(true); + panel.collapsed = false; + expect(panel.collapsed).toEqual(false); + // panel.labelledby = 'test label area'; + // expect(panel.labelledby).toEqual('test label area'); + }); + it('Should properly set base classes', () => { + const fixture = TestBed.createComponent(IgxExpansionPanelListComponent); + fixture.detectChanges(); + const header = document.getElementsByClassName(CSS_CLASS_PANEL_HEADER); + const headerExpanded = document.getElementsByClassName(CSS_CLASS_HEADER_EXPANDED); + const panelClass = document.getElementsByClassName(CSS_CLASS_EXPANSION_PANEL); + expect(header.length).toEqual(1); + expect(headerExpanded.length).toEqual(0); + expect(panelClass.length).toEqual(1); + }); + + it('Should properly emit events', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxExpansionPanelSampleComponent); + fixture.detectChanges(); + const panel = fixture.componentInstance.panel; + const header = fixture.componentInstance.header; + const mockEvent = new Event('click'); + expect(panel).toBeTruthy(); + expect(header).toBeTruthy(); + expect(header.disabled).toEqual(false); + expect(header.panel).toEqual(panel); + expect(header.interaction).toBeDefined(); + + spyOn(panel.contentCollapsed, 'emit'); + spyOn(panel.contentExpanded, 'emit'); + spyOn(panel.contentCollapsing, 'emit'); + spyOn(panel.contentExpanding, 'emit'); + spyOn(header.interaction, 'emit').and.callThrough(); + spyOn(panel, 'toggle').and.callThrough(); + spyOn(panel, 'expand').and.callThrough(); + spyOn(panel, 'collapse').and.callThrough(); + + header.onAction(mockEvent); + tick(); + fixture.detectChanges(); + expect(panel.contentCollapsed.emit).toHaveBeenCalledTimes(0); // Initially collapsed + expect(panel.contentCollapsing.emit).toHaveBeenCalledTimes(0); + expect(header.interaction.emit).toHaveBeenCalledTimes(1); + expect(panel.toggle).toHaveBeenCalledTimes(1); + expect(panel.toggle).toHaveBeenCalledWith(mockEvent); + expect(panel.expand).toHaveBeenCalledTimes(1); + expect(panel.expand).toHaveBeenCalledWith(mockEvent); + expect(panel.collapse).toHaveBeenCalledTimes(0); + expect(panel.contentExpanded.emit).toHaveBeenCalledTimes(1); + expect(panel.contentExpanding.emit).toHaveBeenCalledTimes(1); + expect(header.interaction.emit).toHaveBeenCalledWith({ + event: mockEvent, owner: header.panel, cancel: false + }); + + header.onAction(mockEvent); + tick(); + fixture.detectChanges(); + expect(panel.contentCollapsed.emit).toHaveBeenCalledTimes(1); // First Collapse + expect(panel.contentCollapsing.emit).toHaveBeenCalledTimes(1); + expect(header.interaction.emit).toHaveBeenCalledTimes(2); + expect(panel.toggle).toHaveBeenCalledTimes(2); + expect(panel.toggle).toHaveBeenCalledWith(mockEvent); + expect(panel.expand).toHaveBeenCalledTimes(1); + expect(panel.collapse).toHaveBeenCalledTimes(1); + expect(panel.collapse).toHaveBeenCalledWith(mockEvent); + + header.disabled = true; + header.onAction(mockEvent); + tick(); + fixture.detectChanges(); + + // No additional calls, because panel.disabled === true + expect(panel.contentCollapsed.emit).toHaveBeenCalledTimes(1); + expect(panel.contentCollapsing.emit).toHaveBeenCalledTimes(1); + expect(header.interaction.emit).toHaveBeenCalledTimes(2); + expect(panel.contentExpanded.emit).toHaveBeenCalledTimes(1); + expect(panel.contentExpanding.emit).toHaveBeenCalledTimes(1); + + // cancel event + header.disabled = false; + const headerSub = header.interaction.subscribe((event) => { + event.cancel = true; + }); + + // currently collapsed + expect(panel.collapsed).toBeTruthy(); + header.onAction(mockEvent); + tick(); + fixture.detectChanges(); + + // still collapsed, no additional contentExpanded calls + expect(panel.collapsed).toBeTruthy(); + expect(header.interaction.emit).toHaveBeenCalledTimes(3); + expect(panel.contentExpanded.emit).toHaveBeenCalledTimes(1); + expect(panel.contentExpanding.emit).toHaveBeenCalledTimes(1); + + // expand via API + panel.expand(); + tick(); + fixture.detectChanges(); + + // currently expanded + expect(panel.collapsed).toBeFalsy(); + header.onAction(mockEvent); + tick(); + fixture.detectChanges(); + + // still expanded, no additional contentCollapsed calls + headerSub.unsubscribe(); + expect(panel.collapsed).toBeFalsy(); + expect(header.interaction.emit).toHaveBeenCalledTimes(4); + expect(panel.contentCollapsed.emit).toHaveBeenCalledTimes(1); + + // collapse when the panel has already started collapsing + header.onAction(mockEvent); + header.onAction(mockEvent); + tick(); + fixture.detectChanges(); + + expect(panel.collapsed).toBeTruthy(); + expect(header.interaction.emit).toHaveBeenCalledTimes(6); + expect(panel.contentCollapsing.emit).toHaveBeenCalledTimes(2); + })); + + it('Should expand/collapse without animation when animationSettings === null', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxExpansionPanelSampleComponent); + fixture.detectChanges(); + const panel = fixture.componentInstance.panel; + panel.animationSettings = null; + expect(panel).toBeTruthy(); + + spyOn(panel.contentCollapsed, 'emit'); + spyOn(panel.contentExpanded, 'emit'); + spyOn(panel.contentCollapsing, 'emit'); + spyOn(panel.contentExpanding, 'emit'); + + panel.toggle(); + tick(); + fixture.detectChanges(); + expect(panel.contentCollapsed.emit).toHaveBeenCalledTimes(0); // Initially collapsed + expect(panel.contentCollapsing.emit).toHaveBeenCalledTimes(0); + expect(panel.contentExpanded.emit).toHaveBeenCalledTimes(1); + expect(panel.contentExpanding.emit).toHaveBeenCalledTimes(1); + expect(panel.contentExpanding.emit).toHaveBeenCalledBefore(panel.contentExpanded.emit); + expect(panel.collapsed).toBeFalsy(); + + panel.toggle(); + tick(); + fixture.detectChanges(); + expect(panel.contentCollapsed.emit).toHaveBeenCalledTimes(1); + expect(panel.contentCollapsing.emit).toHaveBeenCalledTimes(1); + expect(panel.contentExpanded.emit).toHaveBeenCalledTimes(1); + expect(panel.contentExpanding.emit).toHaveBeenCalledTimes(1); + expect(panel.contentCollapsing.emit).toHaveBeenCalledBefore(panel.contentCollapsed.emit); + expect(panel.collapsed).toBeTruthy(); + })); + + it('Should allow expanding and collapsing events to be cancelled', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxExpansionPanelSampleComponent); + fixture.detectChanges(); + const panel = fixture.componentInstance.panel; + spyOn(panel.contentExpanded, 'emit').and.callThrough(); + spyOn(panel.contentCollapsed, 'emit').and.callThrough(); + expect(panel).toBeDefined(); + expect(panel.collapsed).toBeTruthy(); + expect(panel.contentExpanded.emit).not.toHaveBeenCalled(); + expect(panel.contentCollapsed.emit).not.toHaveBeenCalled(); + let sub = panel.contentExpanding.subscribe((e) => e.cancel = true); + panel.expand(); + tick(); + fixture.detectChanges(); + expect(panel.collapsed).toBeTruthy(); + expect(panel.contentExpanded.emit).not.toHaveBeenCalled(); + sub.unsubscribe(); + panel.expand(); + tick(); + fixture.detectChanges(); + expect(panel.collapsed).toBeFalsy(); + expect(panel.contentExpanded.emit).toHaveBeenCalledTimes(1); + sub = panel.contentCollapsing.subscribe((e) => e.cancel = true); + panel.collapse(); + tick(); + fixture.detectChanges(); + expect(panel.collapsed).toBeFalsy(); + expect(panel.contentCollapsed.emit).not.toHaveBeenCalled(); + sub.unsubscribe(); + panel.collapse(); + tick(); + fixture.detectChanges(); + expect(panel.collapsed).toBeTruthy(); + expect(panel.contentCollapsed.emit).toHaveBeenCalledTimes(1); + })); + + it('Should NOT assign tabIndex to header when disabled', () => { + const fixture = TestBed.createComponent(IgxExpansionPanelSampleComponent); + fixture.detectChanges(); + const panelHeader = fixture.componentInstance.header; + expect(panelHeader).toBeDefined(); + expect(panelHeader.disabled).toBeFalsy(); + let innerElement = fixture.debugElement.queryAll(By.css('.igx-expansion-panel__header-inner'))[0]; + expect(innerElement).toBeDefined(); + expect(innerElement.nativeElement.attributes['tabindex'].value).toBe('0'); + panelHeader.disabled = true; + fixture.detectChanges(); + innerElement = fixture.debugElement.queryAll(By.css('.igx-expansion-panel__header-inner'))[0]; + expect(innerElement).toBeDefined(); + expect(innerElement.nativeElement.attributes['tabindex']).toBeUndefined(); + panelHeader.disabled = false; + fixture.detectChanges(); + innerElement = fixture.debugElement.queryAll(By.css('.igx-expansion-panel__header-inner'))[0]; + expect(innerElement).toBeDefined(); + expect(innerElement.nativeElement.attributes['tabindex'].value).toBe('0'); + }); + }); + + describe('Expansion tests: ', () => { + const verifyPanelExpansionState = ( + collapsed: boolean, + panel: IgxExpansionPanelComponent, + panelContainer: any, + panelHeader: HTMLElement, + button: DebugElement, + timesCollapsed = 0, + timesExpanded = 0) => { + expect(panel.collapsed).toEqual(collapsed); + const ariaExpanded = collapsed ? 'false' : 'true'; + expect(panelHeader.querySelector('div [role = \'button\']').getAttribute('aria-expanded')).toMatch(ariaExpanded); + expect(panelHeader.classList.contains(CSS_CLASS_HEADER_EXPANDED)).toEqual(!collapsed); + if (button.children.length > 1) { + const iconName = collapsed ? 'expand_more' : 'expand_less'; + expect(button.componentInstance.iconName).toMatch(iconName); + } + if (collapsed) { + expect(panelContainer.lastElementChild.nodeName).toEqual('IGX-EXPANSION-PANEL-HEADER'); + } else { + const panelBody = panelContainer.getElementsByTagName(CSS_CLASS_PANEL_BODY)[0]; + expect(panelBody).toBeDefined(); + const list = panelBody.getElementsByTagName(CSS_CLASS_LIST)[0]; + expect(list).toBeDefined(); + } + expect(panel.contentExpanded.emit).toHaveBeenCalledTimes(timesExpanded); + expect(panel.contentCollapsed.emit).toHaveBeenCalledTimes(timesCollapsed); + }; + + it('Should change panel expansion state on header interaction', fakeAsync(() => { + const fixture: ComponentFixture = TestBed.createComponent(IgxExpansionPanelListComponent); + fixture.detectChanges(); + const panel = fixture.componentInstance.expansionPanel; + const header = fixture.componentInstance.header; + const panelContainer = fixture.nativeElement.querySelector('.' + CSS_CLASS_EXPANSION_PANEL); + const panelHeader = fixture.nativeElement.querySelector('.' + CSS_CLASS_PANEL_HEADER) as HTMLElement; + const button = fixture.debugElement.query(By.css('.' + CSS_CLASS_PANEL_ICON)) as DebugElement; + + let timesCollapsed = 0; + let timesExpanded = 0; + spyOn(panel.contentCollapsed, 'emit').and.callThrough(); + spyOn(panel.contentExpanded, 'emit').and.callThrough(); + spyOn(header.interaction, 'emit'); + verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + + panelHeader.click(); + tick(); + fixture.detectChanges(); + tick(); + timesExpanded++; + verifyPanelExpansionState(false, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(1); + + panelHeader.click(); + tick(); + fixture.detectChanges(); + tick(); + timesCollapsed++; + verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(2); + + panelHeader.click(); + tick(); + fixture.detectChanges(); + tick(); + timesExpanded++; + verifyPanelExpansionState(false, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(3); + + // Remove expand/collapse button + header.iconPosition = ExpansionPanelHeaderIconPosition.NONE; + tick(); + fixture.detectChanges(); + tick(); + panelHeader.click(); + tick(); + fixture.detectChanges(); + tick(); + timesCollapsed++; + verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(4); + + panelHeader.click(); + tick(); + fixture.detectChanges(); + tick(); + timesExpanded++; + verifyPanelExpansionState(false, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(5); + })); + it('Should change panel expansion state on button clicking', fakeAsync(() => { + const fixture: ComponentFixture = TestBed.createComponent(IgxExpansionPanelListComponent); + fixture.detectChanges(); + const panel = fixture.componentInstance.expansionPanel; + const header = fixture.componentInstance.header; + const panelContainer = fixture.nativeElement.querySelector('.' + CSS_CLASS_EXPANSION_PANEL); + const panelHeader = fixture.nativeElement.querySelector('.' + CSS_CLASS_PANEL_HEADER) as HTMLElement; + let button = fixture.debugElement.query(By.css('.' + CSS_CLASS_PANEL_ICON)) as DebugElement; + + let timesCollapsed = 0; + let timesExpanded = 0; + spyOn(panel.contentCollapsed, 'emit').and.callThrough(); + spyOn(panel.contentExpanded, 'emit').and.callThrough(); + spyOn(header.interaction, 'emit'); + verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + + button.nativeElement.click() + tick(); + fixture.detectChanges(); + tick(); + timesExpanded++; + verifyPanelExpansionState(false, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(1); + + button.nativeElement.click() + tick(); + fixture.detectChanges(); + tick(); + timesCollapsed++; + verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(2); + + button.nativeElement.click() + tick(); + fixture.detectChanges(); + tick(); + timesExpanded++; + verifyPanelExpansionState(false, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(3); + + // Change expand/collapse button position + header.iconPosition = ExpansionPanelHeaderIconPosition.RIGHT; + tick(); + fixture.detectChanges(); + tick(); + + button = fixture.debugElement.query(By.css('.' + CSS_CLASS_PANEL_ICON)) as DebugElement; + button.nativeElement.click() + tick(); + fixture.detectChanges(); + tick(); + timesCollapsed++; + verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(4); + + button.nativeElement.click() + tick(); + fixture.detectChanges(); + tick(); + timesExpanded++; + verifyPanelExpansionState(false, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(5); + + button.nativeElement.click() + tick(); + fixture.detectChanges(); + tick(); + timesCollapsed++; + verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(6); + })); + it('Should change panel expansion state on collapsed property setting', fakeAsync(() => { + const fixture: ComponentFixture = TestBed.createComponent(IgxExpansionPanelListComponent); + fixture.detectChanges(); + const panel = fixture.componentInstance.expansionPanel; + const panelContainer = fixture.nativeElement.querySelector('.' + CSS_CLASS_EXPANSION_PANEL); + const panelHeader = fixture.nativeElement.querySelector('.' + CSS_CLASS_PANEL_HEADER) as HTMLElement; + const button = fixture.debugElement.query(By.css('.' + CSS_CLASS_PANEL_ICON)) as DebugElement; + spyOn(panel.contentCollapsed, 'emit').and.callThrough(); + spyOn(panel.contentExpanded, 'emit').and.callThrough(); + verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button); + + panel.collapsed = false; + tick(); + fixture.detectChanges(); + tick(); + verifyPanelExpansionState(false, panel, panelContainer, panelHeader, button); + + panel.collapsed = true; + tick(); + fixture.detectChanges(); + tick(); + verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button); + + panel.collapsed = false; + tick(); + fixture.detectChanges(); + tick(); + verifyPanelExpansionState(false, panel, panelContainer, panelHeader, button); + })); + it('Should change panel expansion state using API methods', fakeAsync(() => { + const fixture: ComponentFixture = TestBed.createComponent(IgxExpansionPanelListComponent); + fixture.detectChanges(); + const panel = fixture.componentInstance.expansionPanel; + const panelContainer = fixture.nativeElement.querySelector('.' + CSS_CLASS_EXPANSION_PANEL); + const panelHeader = fixture.nativeElement.querySelector('.' + CSS_CLASS_PANEL_HEADER) as HTMLElement; + const button = fixture.debugElement.query(By.css('.' + CSS_CLASS_PANEL_ICON)) as DebugElement; + + let timesCollapsed = 0; + let timesExpanded = 0; + spyOn(panel.contentCollapsed, 'emit').and.callThrough(); + spyOn(panel.contentExpanded, 'emit').and.callThrough(); + verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + + panel.expand(); + tick(); + fixture.detectChanges(); + tick(); + timesExpanded++; + verifyPanelExpansionState(false, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + + panel.collapse(); + tick(); + fixture.detectChanges(); + tick(); + timesCollapsed++; + verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + + panel.expand(); + tick(); + fixture.detectChanges(); + tick(); + timesExpanded++; + verifyPanelExpansionState(false, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + + panel.collapse(); + tick(); + fixture.detectChanges(); + tick(); + timesCollapsed++; + verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + })); + it('Should change panel expansion state using toggle method', fakeAsync(() => { + const fixture: ComponentFixture = TestBed.createComponent(IgxExpansionPanelListComponent); + fixture.detectChanges(); + const panel = fixture.componentInstance.expansionPanel; + const panelContainer = fixture.nativeElement.querySelector('.' + CSS_CLASS_EXPANSION_PANEL); + const panelHeader = fixture.nativeElement.querySelector('.' + CSS_CLASS_PANEL_HEADER) as HTMLElement; + const button = fixture.debugElement.query(By.css('.' + CSS_CLASS_PANEL_ICON)) as DebugElement; + + let timesCollapsed = 0; + let timesExpanded = 0; + spyOn(panel.contentCollapsed, 'emit').and.callThrough(); + spyOn(panel.contentExpanded, 'emit').and.callThrough(); + verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + + panel.toggle(); + tick(); + fixture.detectChanges(); + tick(); + timesExpanded++; + verifyPanelExpansionState(false, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + + panel.toggle(); + tick(); + fixture.detectChanges(); + tick(); + timesCollapsed++; + verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + + panel.toggle(); + tick(); + fixture.detectChanges(); + tick(); + timesExpanded++; + verifyPanelExpansionState(false, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + + panel.toggle(); + tick(); + fixture.detectChanges(); + tick(); + timesCollapsed++; + verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + })); + it('Should change panel expansion state on key interaction', fakeAsync(() => { + const enterEvent = new KeyboardEvent('keydown', { key: 'Enter' }); + const spaceEvent = new KeyboardEvent('keydown', { key: 'Space' }); + const arrowUpEvent = new KeyboardEvent('keydown', { key: 'ArrowUp', altKey: true }); + const arrowDownEvent = new KeyboardEvent('keydown', { key: 'ArrowDown', altKey: true }); + const fixture: ComponentFixture = TestBed.createComponent(IgxExpansionPanelListComponent); + fixture.detectChanges(); + const panel = fixture.componentInstance.expansionPanel; + const header = fixture.componentInstance.header; + const panelContainer = fixture.nativeElement.querySelector('.' + CSS_CLASS_EXPANSION_PANEL); + const panelHeader = fixture.nativeElement.querySelector('.' + CSS_CLASS_PANEL_HEADER) as HTMLElement; + const button = fixture.debugElement.query(By.css('.' + CSS_CLASS_PANEL_ICON)) as DebugElement; + + let timesCollapsed = 0; + let timesExpanded = 0; + spyOn(panel.contentCollapsed, 'emit').and.callThrough(); + spyOn(panel.contentExpanded, 'emit').and.callThrough(); + spyOn(header.interaction, 'emit').and.callThrough(); + verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + + panelHeader.dispatchEvent(enterEvent); + tick(); + fixture.detectChanges(); + tick(); + timesExpanded++; + verifyPanelExpansionState(false, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(1); + + panelHeader.dispatchEvent(spaceEvent); + tick(); + fixture.detectChanges(); + tick(); + timesCollapsed++; + verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(2); + + panelHeader.dispatchEvent(enterEvent); + tick(); + fixture.detectChanges(); + tick(); + timesExpanded++; + verifyPanelExpansionState(false, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(3); + + panelHeader.dispatchEvent(spaceEvent); + tick(); + fixture.detectChanges(); + tick(); + timesCollapsed++; + verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(4); + + panelHeader.dispatchEvent(arrowUpEvent); + tick(); + fixture.detectChanges(); + tick(); + verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(5); + + panelHeader.dispatchEvent(arrowDownEvent); + tick(); + fixture.detectChanges(); + tick(); + timesExpanded++; + verifyPanelExpansionState(false, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(6); + + panelHeader.dispatchEvent(arrowDownEvent); + tick(); + fixture.detectChanges(); + tick(); + verifyPanelExpansionState(false, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(7); + + panelHeader.dispatchEvent(arrowUpEvent); + tick(); + fixture.detectChanges(); + tick(); + timesCollapsed++; + verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(8); + + // disabled interaction + const headerSub = header.interaction.subscribe((event) => { + event.cancel = true; + }); + + // currently collapsed + expect(panel.collapsed).toEqual(true); + + // cancel openening + panelHeader.dispatchEvent(arrowDownEvent); + fixture.detectChanges(); + tick(); + // do not iterate timesExpanded + verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(9); + + // open through API + panel.expand(); + timesExpanded++; + tick(); + fixture.detectChanges(); + + // currently expanded + expect(panel.collapsed).toEqual(false); + + // cancel closing + panelHeader.dispatchEvent(arrowUpEvent); + fixture.detectChanges(); + tick(); + // do not iterate timesCollapsed + verifyPanelExpansionState(false, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(10); + + headerSub.unsubscribe(); + })); + it('Should change panel expansion when using different methods', fakeAsync(() => { + const fixture: ComponentFixture = TestBed.createComponent(IgxExpansionPanelListComponent); + fixture.detectChanges(); + const panel = fixture.componentInstance.expansionPanel; + const header = fixture.componentInstance.header; + const panelContainer = fixture.nativeElement.querySelector('.' + CSS_CLASS_EXPANSION_PANEL); + const panelHeader = fixture.nativeElement.querySelector('.' + CSS_CLASS_PANEL_HEADER) as HTMLElement; + const button = fixture.debugElement.query(By.css('.' + CSS_CLASS_PANEL_ICON)) as DebugElement; + + let timesCollapsed = 0; + let timesExpanded = 0; + spyOn(panel.contentCollapsed, 'emit').and.callThrough(); + spyOn(panel.contentExpanded, 'emit').and.callThrough(); + spyOn(header.interaction, 'emit'); + verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + + panel.expand(); + tick(); + fixture.detectChanges(); + tick(); + timesExpanded++; + verifyPanelExpansionState(false, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + + panelHeader.click(); + tick(); + fixture.detectChanges(); + tick(); + timesCollapsed++; + verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(1); + + button.nativeElement.click() + tick(); + fixture.detectChanges(); + tick(); + timesExpanded++; + verifyPanelExpansionState(false, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(2); + + panelHeader.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + tick(); + fixture.detectChanges(); + tick(); + timesCollapsed++; + verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(3); + + panel.collapsed = false; + tick(); + fixture.detectChanges(); + tick(); + verifyPanelExpansionState(false, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + + panel.toggle(); + tick(); + fixture.detectChanges(); + tick(); + timesCollapsed++; + verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + + panelHeader.dispatchEvent(new KeyboardEvent('keydown', { key: 'Space' })); + tick(); + fixture.detectChanges(); + tick(); + timesExpanded++; + verifyPanelExpansionState(false, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(4); + + panel.collapse(); + tick(); + fixture.detectChanges(); + tick(); + timesCollapsed++; + verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + })); + it('Should not be interactable when disabled', fakeAsync(() => { + const fixture: ComponentFixture = TestBed.createComponent(IgxExpansionPanelListComponent); + fixture.detectChanges(); + const panel = fixture.componentInstance.expansionPanel; + const header = fixture.componentInstance.header; + const panelContainer = fixture.nativeElement.querySelector('.' + CSS_CLASS_EXPANSION_PANEL); + const panelHeader = fixture.nativeElement.querySelector('.' + CSS_CLASS_PANEL_HEADER) as HTMLElement; + const button = fixture.debugElement.query(By.css('.' + CSS_CLASS_PANEL_ICON)) as DebugElement; + const headerButton = panelHeader.querySelector('div [role = \'button\']'); + + let timesCollapsed = 0; + const timesExpanded = 1; + spyOn(panel.contentCollapsed, 'emit').and.callThrough(); + spyOn(panel.contentExpanded, 'emit').and.callThrough(); + spyOn(header.interaction, 'emit'); + + panel.expand(); + tick(); + fixture.detectChanges(); + tick(); + expect(headerButton.getAttribute('aria-disabled')).toMatch('false'); + verifyPanelExpansionState(false, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + + header.disabled = true; + tick(); + fixture.detectChanges(); + tick(); + expect(headerButton.getAttribute('aria-disabled')).toMatch('true'); + + panelHeader.click(); + tick(); + fixture.detectChanges(); + tick(); + verifyPanelExpansionState(false, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(0); + + button.nativeElement.click() + tick(); + fixture.detectChanges(); + tick(); + verifyPanelExpansionState(false, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(0); + + panelHeader.dispatchEvent(new KeyboardEvent('keydown', { key: 'Space' })); + tick(); + fixture.detectChanges(); + tick(); + verifyPanelExpansionState(false, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(0); + + panel.toggle(); + tick(); + fixture.detectChanges(); + tick(); + expect(headerButton.getAttribute('aria-disabled')).toMatch('true'); + timesCollapsed++; + verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + + panelHeader.click(); + tick(); + fixture.detectChanges(); + tick(); + verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(0); + + button.nativeElement.click() + tick(); + fixture.detectChanges(); + tick(); + verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(0); + + panelHeader.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + tick(); + fixture.detectChanges(); + tick(); + verifyPanelExpansionState(true, panel, panelContainer, panelHeader, button, timesCollapsed, timesExpanded); + expect(header.interaction.emit).toHaveBeenCalledTimes(0); + + header.disabled = false; + tick(); + fixture.detectChanges(); + tick(); + expect(headerButton.getAttribute('aria-disabled')).toMatch('false'); + })); + it('Should display expand/collapse button according to its position', () => { + const fixture: ComponentFixture = TestBed.createComponent(IgxExpansionPanelListComponent); + fixture.detectChanges(); + const header = fixture.componentInstance.header; + const panelHeader = fixture.nativeElement.querySelector('.' + CSS_CLASS_PANEL_HEADER) as HTMLElement; + const headerButton = panelHeader.querySelector('div [role = \'button\']'); + + expect(header.iconPosition).toEqual('left'); + expect(headerButton.children[0].className).toEqual(CSS_CLASS_PANEL_TITLE_WRAPPER); + expect(headerButton.children[1].className).toEqual(CSS_CLASS_HEADER_ICON_START); + expect(headerButton.children[1].getBoundingClientRect().left). + toBeLessThan(headerButton.children[0].getBoundingClientRect().left); + + header.iconPosition = ExpansionPanelHeaderIconPosition.NONE; + fixture.detectChanges(); + expect(header.iconPosition).toEqual('none'); + expect(headerButton.children[1].className).toEqual(CSS_CLASS_HEADER_ICON_NONE); + + header.iconPosition = ExpansionPanelHeaderIconPosition.RIGHT; + fixture.detectChanges(); + expect(header.iconPosition).toEqual('right'); + expect(headerButton.children[0].className).toEqual(CSS_CLASS_PANEL_TITLE_WRAPPER); + expect(headerButton.children[1].className).toEqual(CSS_CLASS_HEADER_ICON_END); + expect(headerButton.children[0].getBoundingClientRect().left). + toBeLessThan(headerButton.children[1].getBoundingClientRect().left); + + header.iconPosition = ExpansionPanelHeaderIconPosition.NONE; + fixture.detectChanges(); + expect(header.iconPosition).toEqual('none'); + expect(headerButton.children[1].className).toEqual(CSS_CLASS_HEADER_ICON_NONE); + + header.iconPosition = ExpansionPanelHeaderIconPosition.LEFT; + fixture.detectChanges(); + expect(header.iconPosition).toEqual('left'); + expect(headerButton.children[0].className).toEqual(CSS_CLASS_PANEL_TITLE_WRAPPER); + expect(headerButton.children[1].className).toEqual(CSS_CLASS_HEADER_ICON_START); + expect(headerButton.children[1].getBoundingClientRect().left). + toBeLessThan(headerButton.children[0].getBoundingClientRect().left); + }); + + it('Should override the default icon when an icon template is passed', () => { + const fixture = TestBed.createComponent(IgxExpansionPanelSampleComponent); + fixture.detectChanges(); + const header = fixture.componentInstance.header; + const panelHeader = fixture.nativeElement.querySelector('.' + CSS_CLASS_PANEL_HEADER) as HTMLElement; + const headerButton = panelHeader.querySelector('div [role = \'button\']'); + header.iconPosition = ExpansionPanelHeaderIconPosition.LEFT; + fixture.detectChanges(); + + // Buttons are wrapper in wrapper div to hold positioning class + const iconContainer = headerButton.children[1]; + const titleContainer = headerButton.children[0]; + expect(headerButton.children.length).toEqual(2); + expect(iconContainer.firstElementChild.nodeName).toEqual('IGX-ICON'); + expect(titleContainer.firstElementChild.nodeName).toEqual('IGX-EXPANSION-PANEL-TITLE'); + expect(header.iconRef).not.toBe(null); + expect(header.iconRef.nativeElement).toEqual(iconContainer.firstElementChild); + + fixture.componentInstance.customIcon = true; + fixture.detectChanges(); + + expect(iconContainer.firstElementChild.nodeName).toEqual('IGX-EXPANSION-PANEL-ICON'); + expect(titleContainer.firstElementChild.nodeName).toEqual('IGX-EXPANSION-PANEL-TITLE'); + expect(header.iconRef).not.toBe(null); + expect(header.iconRef.nativeElement).toEqual(iconContainer.firstElementChild); + + fixture.componentInstance.header.iconPosition = ExpansionPanelHeaderIconPosition.NONE; + fixture.detectChanges(); + expect(header.iconRef).toEqual(null); + + fixture.componentInstance.customIcon = false; + fixture.detectChanges(); + expect(header.iconRef).toEqual(null); + + fixture.componentInstance.header.iconPosition = ExpansionPanelHeaderIconPosition.LEFT; + fixture.detectChanges(); + expect(header.iconRef).not.toBe(null); + + expect(iconContainer.firstElementChild.nodeName).toEqual('IGX-ICON'); + expect(titleContainer.firstElementChild.nodeName).toEqual('IGX-EXPANSION-PANEL-TITLE'); + }); + + it('Should properly appy positioning classes to icon', () => { + const fixture = TestBed.createComponent(IgxExpansionPanelSampleComponent); + fixture.detectChanges(); + const header = fixture.componentInstance.header; + const panelHeader = fixture.nativeElement.querySelector('.' + CSS_CLASS_PANEL_HEADER) as HTMLElement; + const headerButton = panelHeader.querySelector('div [role = \'button\']'); + header.iconPosition = ExpansionPanelHeaderIconPosition.LEFT; + fixture.detectChanges(); + expect(headerButton.children[1].classList).toContain(IconPositionClass.LEFT); + + header.iconPosition = ExpansionPanelHeaderIconPosition.RIGHT; + fixture.detectChanges(); + expect(headerButton.children[1].classList).toContain(IconPositionClass.RIGHT); + + header.iconPosition = ExpansionPanelHeaderIconPosition.NONE; + fixture.detectChanges(); + expect(headerButton.children[1].classList).toContain(IconPositionClass.NONE); + }); + + it('Should not call animate method when `collapse` is called on a collapsed panel', () => { + const fixture = TestBed.createComponent(IgxExpansionPanelSampleComponent); + fixture.detectChanges(); + const panel = fixture.componentInstance.panel; + const animationSpy = spyOn(panel, 'playCloseAnimation'); + panel.collapse(); + expect(animationSpy).not.toHaveBeenCalled(); + }); + + it('Should not call animate method when `expand` is called on an expanded panel', () => { + const fixture = TestBed.createComponent(IgxExpansionPanelSampleComponent); + fixture.detectChanges(); + const panel = fixture.componentInstance.panel; + const animationSpy = spyOn(panel, 'playOpenAnimation'); + panel.collapse(); + expect(animationSpy).not.toHaveBeenCalled(); + }); + }); + + describe('Aria tests', () => { + it('Should properly apply default aria properties', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxExpansionPanelListComponent); + fixture.detectChanges(); + const panel = fixture.componentInstance.expansionPanel; + const panelElement = fixture.debugElement.query(By.css('igx-expansion-panel')).nativeElement; + const header = fixture.componentInstance.header; + const headerElement = header.elementRef.nativeElement; + const title = fixture.componentInstance.expansionPanel.header; + fixture.detectChanges(); + + // IgxExpansionPanelHeaderComponent host + expect(headerElement.getAttribute('aria-level')).toEqual('3'); + expect(headerElement.getAttribute('role')).toEqual('heading'); + + // Body of IgxExpansionPanelComponent + panel.expand(); + tick(); + fixture.detectChanges(); + tick(); + expect(panelElement.lastElementChild.getAttribute('role')).toEqual('region'); + expect(panelElement.lastElementChild.getAttribute('aria-labelledby')).toEqual(title.id); + + // Button of IgxExpansionPanelHeaderComponent + expect(headerElement.lastElementChild.getAttribute('role')).toEqual('button'); + expect(headerElement.firstElementChild.getAttribute('aria-controls')).toEqual(panel.id); + expect(headerElement.firstElementChild.getAttribute('aria-expanded')).toEqual('true'); + expect(headerElement.firstElementChild.getAttribute('aria-disabled')).toEqual('false'); + + // Disabled + header.disabled = true; + expect(headerElement.firstElementChild.getAttribute('aria-disabled')).toEqual('false'); + })); + + it('Should properly apply aria properties if no header is shown', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxExpansionPanelSampleComponent); + fixture.detectChanges(); + fixture.componentInstance.showBody = true; + fixture.componentInstance.showHeader = false; + fixture.componentInstance.showTitle = false; + fixture.detectChanges(); + const panel = fixture.componentInstance.panel; + const panelElement = fixture.debugElement.query(By.css('igx-expansion-panel')).nativeElement; + const header = fixture.componentInstance.header; + expect(header).toBeFalsy(); + const title = fixture.componentInstance.title; + expect(title).toBeFalsy(); + panel.expand(); + tick(); + fixture.detectChanges(); + tick(); + + // Body of IgxExpansionPanelComponent + expect(panelElement.lastElementChild.getAttribute('role')).toEqual('region'); + expect(panelElement.lastElementChild.getAttribute('aria-labelledby')).toEqual(''); + expect(panelElement.lastElementChild.getAttribute('aria-label')).toEqual(`${panelElement.id}-region`); + panel.expand(); + tick(); + fixture.detectChanges(); + })); + + it('Should update aria properties recording to external change', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxExpansionPanelListComponent); + fixture.detectChanges(); + const panel = fixture.componentInstance.expansionPanel; + const panelElement = fixture.debugElement.query(By.css('igx-expansion-panel')).nativeElement; + const header = fixture.componentInstance.header; + const headerElement = header.elementRef.nativeElement; + const title = panel.header; + panel.expand(); + tick(); + fixture.detectChanges(); + tick(); + + // Body of IgxExpansionPanelComponent + expect(panelElement.lastElementChild.getAttribute('aria-labelledby')).toEqual(title.id); + + // Button of IgxExpansionPanelHeaderComponent + expect(headerElement.firstElementChild.getAttribute('aria-controls')).toEqual(panel.id); + panel.id = 'example-test-panel-id'; + tick(); + fixture.detectChanges(); + tick(); + expect(headerElement.firstElementChild.getAttribute('aria-controls')).toEqual('example-test-panel-id'); + // title.id = 'example-title-id'; // not probable + // tick(); + // fixture.detectChanges(); + // tick(); + // expect(panelElement.lastElementChild.getAttribute('aria-labelledby')).toEqual('example-title-id'); + })); + + it('Should properly label the control region', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxExpansionPanelListComponent); + fixture.detectChanges(); + const panel = fixture.componentInstance.expansionPanel; + const panelElement = fixture.debugElement.query(By.css('igx-expansion-panel')).nativeElement; + const title = fixture.componentInstance.expansionPanel.header; + panel.expand(); + tick(); + fixture.detectChanges(); + tick(); + expect(panelElement.lastElementChild.getAttribute('aria-labelledby')).toEqual(title.id); + expect(panelElement.lastElementChild.getAttribute('aria-label')).toEqual(`${panel.id}-region`); + // fixture.componentInstance.showTitle = false; + // tick(); + // fixture.detectChanges(); + // tick(); + // expect(panelElement.lastElementChild.getAttribute('aria-labelledby')).toEqual(''); + // expect(panelElement.lastElementChild.getAttribute('aria-label')).toEqual(`${panel.id}-region`); + panel.body.label = 'custom-test-label'; + tick(); + fixture.detectChanges(); + expect(panelElement.lastElementChild.getAttribute('aria-labelledby')).toEqual(title.id); + expect(panelElement.lastElementChild.getAttribute('aria-label')).toEqual(`custom-test-label`); + panel.body.label = ''; + tick(); + fixture.detectChanges(); + expect(panelElement.lastElementChild.getAttribute('aria-labelledby')).toEqual(title.id); + expect(panelElement.lastElementChild.getAttribute('aria-label')).toEqual(`${panel.id}-region`); + })); + }); + + describe('Rendering tests: ', () => { + it('Should apply all appropriate classes on combo initialization', fakeAsync(() => { + const fixture: ComponentFixture = TestBed.createComponent(IgxExpansionPanelSampleComponent); + fixture.detectChanges(); + const panel = fixture.componentInstance.panel; + const panelContainer = fixture.nativeElement.querySelector('.' + CSS_CLASS_EXPANSION_PANEL); + expect(panelContainer).not.toBeNull(); + expect(panelContainer.attributes.getNamedItem('id').nodeValue).toEqual(panel.id); + expect(panelContainer.childElementCount).toEqual(1); + + const header = panelContainer.children[0]; + expect(header.attributes.getNamedItem('class').nodeValue).toContain(CSS_CLASS_PANEL_HEADER); + expect(header.attributes.getNamedItem('role').nodeValue).toEqual('heading'); + expect(header.attributes.getNamedItem('aria-level').nodeValue).toEqual('3'); + expect(header.childElementCount).toEqual(1); + + const headerBtn = header.children[0]; + expect(headerBtn.attributes.getNamedItem('role').nodeValue).toEqual('button'); + expect(headerBtn.attributes.getNamedItem('tabindex').nodeValue).toEqual('0'); + expect(headerBtn.attributes.getNamedItem('aria-disabled').nodeValue).toEqual('false'); + expect(headerBtn.attributes.getNamedItem('aria-expanded').nodeValue).toEqual('false'); + expect(headerBtn.attributes.getNamedItem('aria-controls').nodeValue).toEqual(panel.id); + expect(headerBtn.childElementCount).toEqual(2); // 2 Children - Title Wrapper + Icon + + const icon = headerBtn.children[1].firstElementChild; // Icon is wrapped in div + expect(headerBtn.children[1].attributes.getNamedItem('class').nodeValue).toContain('igx-expansion-panel__header-icon--start'); + expect(icon.classList).toContain('material-icons'); + expect(icon.classList).toContain('igx-icon'); + // expect(icon.attributes.getNamedItem('ng-reflect-icon-name').nodeValue).toEqual('expand_more'); + expect(icon.attributes.getNamedItem('aria-hidden').nodeValue).toEqual('true'); + expect(icon.childElementCount).toEqual(0); + + const title = headerBtn.firstElementChild.firstElementChild; + // expect(title.attributes.getNamedItem('id').nodeValue).toEqual('igx-expansion-panel-header-title-0'); + expect(title.textContent).toEqual('Example Title'); + + const description = headerBtn.firstElementChild.lastElementChild; + expect(description.textContent).toEqual('Example Description'); + + // expect(bodyWrapper.attributes.getNamedItem('aria-labelledby').nodeValue).toEqual('igx-expansion-panel-header-title-0'); + expect(panelContainer.childElementCount).toEqual(1); + + header.click(); + tick(); + fixture.detectChanges(); + tick(); + const bodyWrapper = panelContainer.children[1]; + expect(bodyWrapper.attributes.getNamedItem('role').nodeValue).toEqual('region'); + expect(bodyWrapper.attributes.getNamedItem('aria-label').nodeValue).toEqual(panel.id + '-region'); + expect(bodyWrapper.childElementCount).toEqual(0); + expect(bodyWrapper.textContent.trim()).toEqual('Example body'); + })); + it('Should apply all appropriate classes on initialization_grid content', fakeAsync(() => { + const fixture: ComponentFixture = TestBed.createComponent(IgxExpansionPanelGridComponent); + fixture.detectChanges(); + const panel = fixture.componentInstance.expansionPanel; + const panelContainer = fixture.nativeElement.querySelector('.' + CSS_CLASS_EXPANSION_PANEL); + expect(panelContainer).not.toBeNull(); + expect(panelContainer.attributes.getNamedItem('id').nodeValue).toEqual(panel.id); + expect(panelContainer.childElementCount).toEqual(1); + + const header = panelContainer.children[0]; + expect(header.attributes.getNamedItem('class').nodeValue).toContain(CSS_CLASS_PANEL_HEADER); + expect(header.attributes.getNamedItem('role').nodeValue).toEqual('heading'); + expect(header.attributes.getNamedItem('aria-level').nodeValue).toEqual('3'); + expect(header.childElementCount).toEqual(1); + + const headerBtn = header.children[0]; + expect(headerBtn.attributes.getNamedItem('role').nodeValue).toEqual('button'); + expect(headerBtn.attributes.getNamedItem('tabindex').nodeValue).toEqual('0'); + expect(headerBtn.attributes.getNamedItem('aria-disabled').nodeValue).toEqual('false'); + expect(headerBtn.attributes.getNamedItem('aria-expanded').nodeValue).toEqual('false'); + expect(headerBtn.attributes.getNamedItem('aria-controls').nodeValue).toEqual(panel.id); + expect(headerBtn.childElementCount).toEqual(2); // 2 Children - Title Wrapper + Icon + + const icon = headerBtn.children[1].firstElementChild; // Icon is wrapped in div + expect(headerBtn.children[1].attributes.getNamedItem('class').nodeValue).toContain('igx-expansion-panel__header-icon--start'); + expect(icon.classList).toContain('material-icons'); + expect(icon.classList).toContain('igx-icon'); + // expect(icon.attributes.getNamedItem('ng-reflect-icon-name').nodeValue).toEqual('expand_more'); + expect(icon.attributes.getNamedItem('aria-hidden').nodeValue).toEqual('true'); + expect(icon.childElementCount).toEqual(0); + + const title = headerBtn.firstElementChild.firstElementChild; + // expect(title.attributes.getNamedItem('id').nodeValue).toEqual('igx-expansion-panel-header-title-0'); + expect(title.textContent).toEqual('Product orders'); + + const description = headerBtn.firstElementChild.lastElementChild; + expect(description.textContent).toEqual('Product orders details'); + + // expect(bodyWrapper.attributes.getNamedItem('aria-labelledby').nodeValue).toEqual('igx-expansion-panel-header-title-0'); + expect(panelContainer.childElementCount).toEqual(1); + + header.click(); + tick(); + fixture.detectChanges(); + tick(); + const bodyWrapper = panelContainer.children[1]; + expect(bodyWrapper.attributes.getNamedItem('role').nodeValue).toEqual('region'); + expect(bodyWrapper.attributes.getNamedItem('aria-label').nodeValue).toEqual(panel.id + '-region'); + expect(bodyWrapper.firstElementChild.nodeName).toEqual('IGX-GRID'); + const grid = bodyWrapper.firstElementChild; // wrapping div + expect(grid.attributes.getNamedItem('class').nodeValue).toContain(CSS_CLASS_GRID); + expect(grid.attributes.getNamedItem('role').nodeValue).toEqual('grid'); + expect(grid.attributes.getNamedItem('id').nodeValue).toEqual(fixture.componentInstance.grid1.id); + expect(grid.attributes.getNamedItem('tabindex').nodeValue).toEqual('0'); + expect(grid.childElementCount).toEqual(6); + })); + it('Should apply all appropriate classes on combo initialization_image + text content', fakeAsync(() => { + const fixture: ComponentFixture = TestBed.createComponent(IgxExpansionPanelImageComponent); + fixture.detectChanges(); + const panel = fixture.componentInstance.panel; + const panelContainer = fixture.nativeElement.querySelector('.' + CSS_CLASS_EXPANSION_PANEL); + expect(panelContainer).not.toBeNull(); + expect(panelContainer.attributes.getNamedItem('id').nodeValue).toEqual(panel.id); + expect(panelContainer.childElementCount).toEqual(1); + + const header = panelContainer.children[0]; + expect(header.attributes.getNamedItem('class').nodeValue).toContain(CSS_CLASS_PANEL_HEADER); + expect(header.attributes.getNamedItem('role').nodeValue).toEqual('heading'); + expect(header.attributes.getNamedItem('aria-level').nodeValue).toEqual('3'); + expect(header.childElementCount).toEqual(1); + + const headerBtn = header.children[0]; + expect(headerBtn.attributes.getNamedItem('role').nodeValue).toEqual('button'); + expect(headerBtn.attributes.getNamedItem('tabindex').nodeValue).toEqual('0'); + expect(headerBtn.attributes.getNamedItem('aria-disabled').nodeValue).toEqual('false'); + expect(headerBtn.attributes.getNamedItem('aria-expanded').nodeValue).toEqual('false'); + expect(headerBtn.attributes.getNamedItem('aria-controls').nodeValue).toEqual(panel.id); + expect(headerBtn.childElementCount).toEqual(2); // 2 Children - Title Wrapper + Icon + + const icon = headerBtn.children[1].firstElementChild; // Icon is wrapped in div; Icon is the second element + expect(headerBtn.children[1].attributes.getNamedItem('class').nodeValue).toContain('igx-expansion-panel__header-icon--start'); + expect(icon.classList).toContain('material-icons'); + expect(icon.classList).toContain('igx-icon'); + // expect(icon.attributes.getNamedItem('ng-reflect-icon-name').nodeValue).toEqual('expand_more'); + expect(icon.attributes.getNamedItem('aria-hidden').nodeValue).toEqual('true'); + expect(icon.childElementCount).toEqual(0); + + const title = headerBtn.firstElementChild.firstElementChild; + // expect(title.attributes.getNamedItem('id').nodeValue).toEqual('igx-expansion-panel-header-title-0'); + expect(title.textContent).toEqual('Frogs'); + + const description = headerBtn.firstElementChild.lastElementChild; + expect(description.textContent).toEqual('Frog description'); + // expect(bodyWrapper.attributes.getNamedItem('aria-labelledby').nodeValue).toEqual('igx-expansion-panel-header-title-0'); + expect(panelContainer.childElementCount).toEqual(1); + + header.click(); + tick(); + fixture.detectChanges(); + tick(); + const bodyWrapper = panelContainer.children[1]; + expect(bodyWrapper.attributes.getNamedItem('role').nodeValue).toEqual('region'); + expect(bodyWrapper.attributes.getNamedItem('aria-label').nodeValue).toEqual(panel.id + '-region'); + expect(bodyWrapper.childElementCount).toEqual(1); + const textWrapper = bodyWrapper.firstElementChild; // wrapping div + expect(textWrapper.attributes.getNamedItem('class').nodeValue).toContain('sample-wrapper'); + expect(textWrapper.childElementCount).toEqual(1); + const image = textWrapper.children[0] as HTMLElement; + expect(image.tagName).toEqual('IMG'); + expect (textWrapper.textContent.trim()).toEqual(fixture.componentInstance.text); + })); + it('Should display tooltip with the title and description text content', () => { + const fixture: ComponentFixture = TestBed.createComponent(IgxExpansionPanelTooltipComponent); + fixture.detectChanges(); + + const headerTitle = fixture.nativeElement.querySelector('.' + CSS_CLASS_PANEL_HEADER_TITLE); + const headerDescription = fixture.nativeElement.querySelector('.' + CSS_CLASS_PANEL_HEADER_DESCRIPTION); + + const headerTitleTooltip = headerTitle.getAttribute('title'); + const headerDescriptionTooltip = headerDescription.getAttribute('title'); + + expect(headerTitleTooltip).toEqual(headerTitle.textContent.trim()); + expect(headerDescriptionTooltip).toEqual(headerDescription.textContent.trim()); + }); + it('Should display tooltip with the attr.title text content', () => { + const fixture: ComponentFixture = TestBed.createComponent(IgxExpansionPanelTooltipComponent); + + fixture.componentInstance.titleTooltip = 'Custom Title Tooltip'; + fixture.componentInstance.descriptionTooltip = 'Custom Description Tooltip'; + fixture.detectChanges(); + + const headerTitle = fixture.nativeElement.querySelector('.' + CSS_CLASS_PANEL_HEADER_TITLE); + const headerDescription = fixture.nativeElement.querySelector('.' + CSS_CLASS_PANEL_HEADER_DESCRIPTION); + + const headerTitleTooltip = headerTitle.getAttribute('title'); + const headerDescriptionTooltip = headerDescription.getAttribute('title'); + + expect(headerTitleTooltip).toEqual('Custom Title Tooltip'); + expect(headerDescriptionTooltip).toEqual('Custom Description Tooltip'); + }); + }); +}); + + +@Component({ + template: ` + + + Product orders + Product orders details + + + + + + `, + imports: [IgxExpansionPanelComponent, IgxExpansionPanelHeaderComponent, IgxExpansionPanelBodyComponent, IgxGridComponent, IgxExpansionPanelTitleDirective, IgxExpansionPanelDescriptionDirective] +}) +export class IgxExpansionPanelGridComponent { + + @ViewChild('expansionPanel', { read: IgxExpansionPanelComponent, static: true }) + public expansionPanel: IgxExpansionPanelComponent; + @ViewChild('grid1', { read: IgxGridComponent, static: true }) + public grid1: IgxGridComponent; + + public width = '800px'; + public height = '600px'; + + public data = [ + { ProductID: 1, ProductName: 'Chai', InStock: true, UnitsInStock: 2760, OrderDate: '2005-03-21' }, + { ProductID: 2, ProductName: 'Aniseed Syrup', InStock: false, UnitsInStock: 198, OrderDate: '2008-01-15' }, + { ProductID: 3, ProductName: 'Chef Antons Cajun Seasoning', InStock: true, UnitsInStock: 52, OrderDate: '2010-11-20' }, + { ProductID: 4, ProductName: 'Grandmas Boysenberry Spread', InStock: false, UnitsInStock: 0, OrderDate: '2007-10-11' }, + { ProductID: 5, ProductName: 'Uncle Bobs Dried Pears', InStock: false, UnitsInStock: 0, OrderDate: '2001-07-27' }, + { ProductID: 6, ProductName: 'Northwoods Cranberry Sauce', InStock: true, UnitsInStock: 1098, OrderDate: '1990-05-17' }, + { ProductID: 7, ProductName: 'Queso Cabrales', InStock: false, UnitsInStock: 0, OrderDate: '2005-03-03' }, + { ProductID: 8, ProductName: 'Tofu', InStock: true, UnitsInStock: 7898, OrderDate: '2017-09-09' }, + { ProductID: 9, ProductName: 'Teatime Chocolate Biscuits', InStock: true, UnitsInStock: 6998, OrderDate: '2025-12-25' }, + { ProductID: 10, ProductName: 'Chocolate', InStock: true, UnitsInStock: 20000, OrderDate: '2018-03-01' } + ]; +} + +@Component({ + template: ` +
    + + + + Product List + + + + + Products + Product 1 + Product 2 + Product 3 + Product 4 + Product 5 + + + +
    + `, + imports: [IgxExpansionPanelComponent, IgxExpansionPanelHeaderComponent, IgxExpansionPanelBodyComponent, IgxListComponent, IgxListItemComponent, IgxExpansionPanelTitleDirective] +}) +export class IgxExpansionPanelListComponent { + @ViewChild(IgxExpansionPanelHeaderComponent, { read: IgxExpansionPanelHeaderComponent, static: true }) + public header: IgxExpansionPanelHeaderComponent; + @ViewChild(IgxExpansionPanelComponent, { read: IgxExpansionPanelComponent, static: true }) + public expansionPanel: IgxExpansionPanelComponent; +} + + +@Component({ + template: ` + + @if (showHeader) { + + @if (showTitle) { + Example Title + } + Example Description + @if (customIcon) { + + TEST_ICON + + } + + } + @if (showBody) { + + Example body + + } + + `, + imports: [IgxExpansionPanelComponent, IgxExpansionPanelHeaderComponent, IgxExpansionPanelBodyComponent, IgxExpansionPanelTitleDirective, IgxExpansionPanelDescriptionDirective, IgxExpansionPanelIconDirective] +}) +export class IgxExpansionPanelSampleComponent { + @ViewChild(IgxExpansionPanelHeaderComponent, { read: IgxExpansionPanelHeaderComponent }) + public header: IgxExpansionPanelHeaderComponent; + @ViewChild(IgxExpansionPanelComponent, { read: IgxExpansionPanelComponent, static: true }) + public panel: IgxExpansionPanelComponent; + @ViewChild(IgxExpansionPanelTitleDirective, { read: IgxExpansionPanelTitleDirective }) + public title: IgxExpansionPanelTitleDirective; + public disabled = false; + public collapsed = true; + public showTitle = true; + public showBody = true; + public showHeader = true; + public customIcon = false; + public handleExpanded() { + } + public handleCollapsed() { + } + public handleInterraction() { + } +} + +@Component({ + template: ` + + + Frogs + Frog description + + +

    + + {{text}} +

    +
    +
    + `, + imports: [IgxExpansionPanelComponent, IgxExpansionPanelHeaderComponent, IgxExpansionPanelBodyComponent, IgxExpansionPanelTitleDirective, IgxExpansionPanelDescriptionDirective] +}) +export class IgxExpansionPanelImageComponent { + @ViewChild(IgxExpansionPanelHeaderComponent, { read: IgxExpansionPanelHeaderComponent, static: true }) + public header: IgxExpansionPanelHeaderComponent; + @ViewChild(IgxExpansionPanelComponent, { read: IgxExpansionPanelComponent, static: true }) + public panel: IgxExpansionPanelComponent; + + public imagePath = 'http://milewalk.com/wp-content/uploads/2016/01/My-2-Morning-Tricks-to-Eating-the-Frog.jpg'; + public text = 'A frog is any member of a diverse and largely carnivorous group of short-bodied, tailless amphibians composing the order Anura. The oldest fossil \"proto-frog\" appeared in the early Triassic of Madagascar, but molecular clock dating suggests their origins may extend further back to the Permian, 265 million years ago. Frogs are widely distributed, ranging from the tropics to subarctic regions, but the greatest concentration of species diversity is in tropical rainforests. There are approximately 4,800 recorded species, accounting for over 85% of extant amphibian species. They are also one of the five most diverse vertebrate orders. The body plan of an adult frog is generally characterized by a stout body, protruding eyes, cleft tongue, limbs folded underneath, and the absence of a tail. Besides living in fresh water and on dry land, the adults of some species are adapted for living underground or in trees. The skins of frogs are glandular, with secretions ranging from distasteful to toxic. Warty species of frog tend to be called toads but the distinction between frogs and toads is based on informal naming conventions concentrating on the warts rather than taxonomy or evolutionary history.'; +} + +@Component({ + template: ` + + + + Example Title + + + Example Description + + + + Example Body + + + `, + imports: [IGX_EXPANSION_PANEL_DIRECTIVES] +}) +export class IgxExpansionPanelTooltipComponent { + public titleTooltip = ''; + public descriptionTooltip = ''; +} + diff --git a/projects/igniteui-angular/expansion-panel/src/expansion-panel/public_api.ts b/projects/igniteui-angular/expansion-panel/src/expansion-panel/public_api.ts new file mode 100644 index 00000000000..1b602691977 --- /dev/null +++ b/projects/igniteui-angular/expansion-panel/src/expansion-panel/public_api.ts @@ -0,0 +1,26 @@ +import { IgxExpansionPanelBodyComponent } from './expansion-panel-body.component'; +import { IgxExpansionPanelHeaderComponent } from './expansion-panel-header.component'; +import { IgxExpansionPanelComponent } from './expansion-panel.component'; +import { IgxExpansionPanelDescriptionDirective, IgxExpansionPanelIconDirective, IgxExpansionPanelTitleDirective } from './expansion-panel.directives'; + +export { IExpansionPanelEventArgs, IExpansionPanelCancelableEventArgs, IgxExpansionPanelBase } from './expansion-panel.common'; +export { IgxExpansionPanelHeaderComponent } from './expansion-panel-header.component'; +export { IgxExpansionPanelBodyComponent } from './expansion-panel-body.component'; +export { IgxExpansionPanelComponent } from './expansion-panel.component'; +export { + IgxExpansionPanelDescriptionDirective, + IgxExpansionPanelIconDirective, + IgxExpansionPanelTitleDirective +} from './expansion-panel.directives'; +export { ExpansionPanelHeaderIconPosition } from './expansion-panel-header.component'; +export { ToggleAnimationSettings, ToggleAnimationPlayer } from './toggle-animation-component'; + +/* NOTE: Expansion panel directives collection for ease-of-use import in standalone components scenario */ +export const IGX_EXPANSION_PANEL_DIRECTIVES = [ + IgxExpansionPanelComponent, + IgxExpansionPanelHeaderComponent, + IgxExpansionPanelBodyComponent, + IgxExpansionPanelDescriptionDirective, + IgxExpansionPanelTitleDirective, + IgxExpansionPanelIconDirective +] as const; diff --git a/projects/igniteui-angular/expansion-panel/src/expansion-panel/toggle-animation-component.spec.ts b/projects/igniteui-angular/expansion-panel/src/expansion-panel/toggle-animation-component.spec.ts new file mode 100644 index 00000000000..f4905be4d77 --- /dev/null +++ b/projects/igniteui-angular/expansion-panel/src/expansion-panel/toggle-animation-component.spec.ts @@ -0,0 +1,82 @@ +import { TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { noop } from 'rxjs'; +import { IgxAngularAnimationService } from 'igniteui-angular/core'; +import { ANIMATION_TYPE, ToggleAnimationPlayer } from './toggle-animation-component'; +import { growVerIn, growVerOut } from 'igniteui-angular/animations'; + +class MockTogglePlayer extends ToggleAnimationPlayer { +} + +describe('Toggle animation component', () => { + const mockBuilder = jasmine.createSpyObj('mockBuilder', ['build'], {}); + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule + ], + providers: [ + { provide: IgxAngularAnimationService, useValue: mockBuilder }, + MockTogglePlayer + ] + }).compileComponents(); + }); + describe('Unit tests', () => { + it('Should initialize player with give settings', () => { + const player = TestBed.inject(MockTogglePlayer); + const startPlayerSpy = spyOn(player, 'startPlayer'); + const mockEl = jasmine.createSpyObj('mockRef', ['focus'], {}); + player.playOpenAnimation(mockEl); + expect(startPlayerSpy).toHaveBeenCalledWith(ANIMATION_TYPE.OPEN, mockEl, noop); + player.playCloseAnimation(mockEl); + expect(startPlayerSpy).toHaveBeenCalledWith(ANIMATION_TYPE.CLOSE, mockEl, noop); + const mockCB = () => {}; + player.playOpenAnimation(mockEl, mockCB); + expect(startPlayerSpy).toHaveBeenCalledWith(ANIMATION_TYPE.OPEN, mockEl, mockCB); + player.playCloseAnimation(mockEl, mockCB); + expect(startPlayerSpy).toHaveBeenCalledWith(ANIMATION_TYPE.CLOSE, mockEl, mockCB); + player.playOpenAnimation(null, mockCB); + expect(startPlayerSpy).toHaveBeenCalledWith(ANIMATION_TYPE.OPEN, null, mockCB); + player.playCloseAnimation(null, mockCB); + expect(startPlayerSpy).toHaveBeenCalledWith(ANIMATION_TYPE.CLOSE, null, mockCB); + }); + + it('Should allow overwriting animation setting with falsy value', () => { + const player = TestBed.inject(MockTogglePlayer); + expect(player.animationSettings).toEqual({ + openAnimation: growVerIn, + closeAnimation: growVerOut + }); + player.animationSettings = null; + expect(player.animationSettings).toEqual(null); + }); + + it('Should not throw if called with a falsy animationSettings value', () => { + const player = TestBed.inject(MockTogglePlayer); + player.animationSettings = null; + const mockCb = jasmine.createSpy('mockCb'); + const mockElement = jasmine.createSpy('element'); + spyOn(player.openAnimationStart, 'emit'); + spyOn(player.openAnimationDone, 'emit'); + spyOn(player.closeAnimationStart, 'emit'); + spyOn(player.closeAnimationDone, 'emit'); + + player.playOpenAnimation({ nativeElement: mockElement }, mockCb); + expect(player.openAnimationStart.emit).toHaveBeenCalledTimes(1); + expect(player.openAnimationDone.emit).toHaveBeenCalledTimes(1); + expect(player.closeAnimationStart.emit).toHaveBeenCalledTimes(0); + expect(player.closeAnimationDone.emit).toHaveBeenCalledTimes(0); + expect(player.openAnimationStart.emit).toHaveBeenCalledBefore(player.openAnimationDone.emit); + expect(mockCb).toHaveBeenCalledTimes(1); + + player.playCloseAnimation({ nativeElement: mockElement }, mockCb); + expect(player.openAnimationStart.emit).toHaveBeenCalledTimes(1); + expect(player.openAnimationDone.emit).toHaveBeenCalledTimes(1); + expect(player.closeAnimationStart.emit).toHaveBeenCalledTimes(1); + expect(player.closeAnimationDone.emit).toHaveBeenCalledTimes(1); + expect(player.closeAnimationStart.emit).toHaveBeenCalledBefore(player.closeAnimationDone.emit); + + expect(mockCb).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/projects/igniteui-angular/expansion-panel/src/expansion-panel/toggle-animation-component.ts b/projects/igniteui-angular/expansion-panel/src/expansion-panel/toggle-animation-component.ts new file mode 100644 index 00000000000..32dfd401d97 --- /dev/null +++ b/projects/igniteui-angular/expansion-panel/src/expansion-panel/toggle-animation-component.ts @@ -0,0 +1,202 @@ +import { AnimationReferenceMetadata } from '@angular/animations'; +import { Directive, ElementRef, EventEmitter, inject, OnDestroy } from '@angular/core'; +import { noop, Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +import { IgxAngularAnimationService } from 'igniteui-angular/core'; +import { AnimationPlayer, AnimationService } from 'igniteui-angular/core'; +import { growVerIn, growVerOut } from 'igniteui-angular/animations'; + +/**@hidden @internal */ +export interface ToggleAnimationSettings { + openAnimation: AnimationReferenceMetadata; + closeAnimation: AnimationReferenceMetadata; +} + +export interface ToggleAnimationOwner { + animationSettings: ToggleAnimationSettings; + openAnimationStart: EventEmitter; + openAnimationDone: EventEmitter; + closeAnimationStart: EventEmitter; + closeAnimationDone: EventEmitter; + openAnimationPlayer: AnimationPlayer; + closeAnimationPlayer: AnimationPlayer; + playOpenAnimation(element: ElementRef, onDone: () => void): void; + playCloseAnimation(element: ElementRef, onDone: () => void): void; +} + +/** @hidden @internal */ +export enum ANIMATION_TYPE { + OPEN = 'open', + CLOSE = 'close', +} + +/**@hidden @internal */ +@Directive() +export abstract class ToggleAnimationPlayer implements ToggleAnimationOwner, OnDestroy { + protected animationService = inject(IgxAngularAnimationService); + + /** @hidden @internal */ + public openAnimationDone: EventEmitter = new EventEmitter(); + /** @hidden @internal */ + public closeAnimationDone: EventEmitter = new EventEmitter(); + /** @hidden @internal */ + public openAnimationStart: EventEmitter = new EventEmitter(); + /** @hidden @internal */ + public closeAnimationStart: EventEmitter = new EventEmitter(); + + public get animationSettings(): ToggleAnimationSettings { + return this._animationSettings; + } + public set animationSettings(value: ToggleAnimationSettings) { + this._animationSettings = value; + } + + /** @hidden @internal */ + public openAnimationPlayer: AnimationPlayer = null; + + /** @hidden @internal */ + public closeAnimationPlayer: AnimationPlayer = null; + + protected destroy$: Subject = new Subject(); + protected players: Map = new Map(); + protected _animationSettings: ToggleAnimationSettings = { + openAnimation: growVerIn, + closeAnimation: growVerOut + }; + + private closeInterrupted = false; + private openInterrupted = false; + + private _defaultClosedCallback = noop; + private _defaultOpenedCallback = noop; + private onClosedCallback: () => any = this._defaultClosedCallback; + private onOpenedCallback: () => any = this._defaultOpenedCallback; + + /** @hidden @internal */ + public playOpenAnimation(targetElement: ElementRef, onDone?: () => void): void { + this.startPlayer(ANIMATION_TYPE.OPEN, targetElement, onDone || this._defaultOpenedCallback); + } + + /** @hidden @internal */ + public playCloseAnimation(targetElement: ElementRef, onDone?: () => void): void { + this.startPlayer(ANIMATION_TYPE.CLOSE, targetElement, onDone || this._defaultClosedCallback); + } + + /** @hidden @internal */ + public ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + private startPlayer(type: ANIMATION_TYPE, targetElement: ElementRef, callback: () => void): void { + if (!targetElement) { // if no element is passed, there is nothing to animate + return; + } + let target = this.getPlayer(type); + if (!target) { + target = this.initializePlayer(type, targetElement, callback); + } + // V.S. Jun 28th, 2021 #9783: player will NOT be initialized w/ null settings + // events will already be emitted + if (!target || target.hasStarted()) { + return; + } + const targetEmitter = type === ANIMATION_TYPE.OPEN ? this.openAnimationStart : this.closeAnimationStart; + targetEmitter.emit(); + if (target) { + target.play(); + } + } + + private initializePlayer(type: ANIMATION_TYPE, targetElement: ElementRef, callback: () => void): AnimationPlayer { + const oppositeType = type === ANIMATION_TYPE.OPEN ? ANIMATION_TYPE.CLOSE : ANIMATION_TYPE.OPEN; + // V.S. Jun 28th, 2021 #9783: Treat falsy animation settings as disabled animations + const targetAnimationSettings = this.animationSettings || { closeAnimation: null, openAnimation: null }; + const animationSettings = type === ANIMATION_TYPE.OPEN ? + targetAnimationSettings.openAnimation : targetAnimationSettings.closeAnimation; + // V.S. Jun 28th, 2021 #9783: When no animation in target direction, emit start and done events and return + if (!animationSettings) { + this.setCallback(type, callback); + const targetEmitter = type === ANIMATION_TYPE.OPEN ? this.openAnimationStart : this.closeAnimationStart; + targetEmitter.emit(); + this.onDoneHandler(type); + return; + } + const opposite = this.getPlayer(oppositeType); + let oppositePosition = 1; + if (opposite) { + oppositePosition = opposite.position; + this.cleanUpPlayer(oppositeType); + } + if (type === ANIMATION_TYPE.OPEN) { + this.openAnimationPlayer = this.animationService.buildAnimation(animationSettings, targetElement.nativeElement); + } else if (type === ANIMATION_TYPE.CLOSE) { + this.closeAnimationPlayer = this.animationService.buildAnimation(animationSettings, targetElement.nativeElement); + } + const target = this.getPlayer(type); + target.init(); + this.getPlayer(type).position = 1 - oppositePosition; + this.setCallback(type, callback); + target.animationEnd.pipe(takeUntil(this.destroy$)).subscribe(() => { + this.onDoneHandler(type); + }); + return target; + } + + private onDoneHandler(type) { + const targetEmitter = type === ANIMATION_TYPE.OPEN ? this.openAnimationDone : this.closeAnimationDone; + const targetCallback = type === ANIMATION_TYPE.OPEN ? this.onOpenedCallback : this.onClosedCallback; + targetCallback(); + if (!(type === ANIMATION_TYPE.OPEN ? this.openInterrupted : this.closeInterrupted)) { + targetEmitter.emit(); + } + this.cleanUpPlayer(type); + } + + private setCallback(type: ANIMATION_TYPE, callback: () => void) { + if (type === ANIMATION_TYPE.OPEN) { + this.onOpenedCallback = callback; + this.openInterrupted = false; + } else if (type === ANIMATION_TYPE.CLOSE) { + this.onClosedCallback = callback; + this.closeInterrupted = false; + } + } + + private cleanUpPlayer(target: ANIMATION_TYPE) { + switch (target) { + case ANIMATION_TYPE.CLOSE: + if (this.closeAnimationPlayer != null) { + this.closeAnimationPlayer.reset(); + this.closeAnimationPlayer.destroy(); + this.closeAnimationPlayer = null; + } + this.closeInterrupted = true; + this.onClosedCallback = this._defaultClosedCallback; + break; + case ANIMATION_TYPE.OPEN: + if (this.openAnimationPlayer != null) { + this.openAnimationPlayer.reset(); + this.openAnimationPlayer.destroy(); + this.openAnimationPlayer = null; + } + this.openInterrupted = true; + this.onOpenedCallback = this._defaultOpenedCallback; + break; + default: + break; + } + } + + private getPlayer(type: ANIMATION_TYPE): AnimationPlayer { + switch (type) { + case ANIMATION_TYPE.OPEN: + return this.openAnimationPlayer; + case ANIMATION_TYPE.CLOSE: + return this.closeAnimationPlayer; + default: + return null; + } + } +} diff --git a/projects/igniteui-angular/expansion-panel/src/public_api.ts b/projects/igniteui-angular/expansion-panel/src/public_api.ts new file mode 100644 index 00000000000..a81332b4a69 --- /dev/null +++ b/projects/igniteui-angular/expansion-panel/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './expansion-panel/public_api'; +export * from './expansion-panel/expansion-panel.module'; diff --git a/projects/igniteui-angular/grids/core/index.ts b/projects/igniteui-angular/grids/core/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/grids/core/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/grids/core/ng-package.json b/projects/igniteui-angular/grids/core/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/grids/core/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/grids/core/src/api.service.ts b/projects/igniteui-angular/grids/core/src/api.service.ts new file mode 100644 index 00000000000..2a9ac35fe6a --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/api.service.ts @@ -0,0 +1,639 @@ +import { inject, Injectable } from '@angular/core'; +import { Subject } from 'rxjs'; +import { + cloneArray, + reverseMapper, + mergeObjects, + Transaction, + TransactionType, + State, + DataUtil, + FilterUtil, + GridColumnDataType, + IFilteringExpressionsTree, + IGroupingExpression, + ISortingExpression, + SortingDirection, + ColumnType +} from 'igniteui-angular/core'; +import { IgxCell, IgxGridCRUDService, IgxEditRow } from './common/crud.service'; +import { CellType, GridServiceType, GridType, RowType } from './common/grid.interface'; +import { IGridEditEventArgs, IPinRowEventArgs, IRowToggleEventArgs } from './common/events'; +import { IgxColumnMovingService } from './moving/moving.service'; + +/** + * @hidden + */ +@Injectable() +export class GridBaseAPIService implements GridServiceType { + + public crudService = inject(IgxGridCRUDService); + public cms = inject(IgxColumnMovingService) + + public grid: T; + protected destroyMap: Map> = new Map>(); + + public get_column_by_name(name: string): ColumnType { + return this.grid.columns.find((col: ColumnType) => col.field === name); + } + + public get_summary_data(): any[] | null { + const grid = this.grid; + let data = grid.filteredData; + if (data && grid.hasPinnedRecords) { + data = grid._filteredUnpinnedData; + } + if (!data) { + if (grid.transactions.enabled) { + data = DataUtil.mergeTransactions( + cloneArray(grid.data), + grid.transactions.getAggregatedChanges(true), + grid.primaryKey, + grid.dataCloneStrategy + ); + const deletedRows = grid.transactions.getTransactionLog().filter(t => t.type === TransactionType.DELETE).map(t => t.id); + deletedRows.forEach(rowID => { + const tempData = grid.primaryKey ? data.map(rec => rec[grid.primaryKey]) : data; + const index = tempData.indexOf(rowID); + if (index !== -1) { + data.splice(index, 1); + } + }); + } else { + data = grid.data; + } + } + return data; + } + + /** + * @hidden + * @internal + */ + public getRowData(rowID: any) { + const data = this.get_all_data(this.grid.transactions.enabled); + const index = this.get_row_index_in_data(rowID, data); + return data[index]; + } + + public get_row_index_in_data(rowID: any, dataCollection?: any[]): number { + const grid = this.grid; + if (!grid) { + return -1; + } + const data = dataCollection ?? this.get_all_data(grid.transactions.enabled); + return grid.primaryKey ? data.findIndex(record => record.recordRef ? record.recordRef[grid.primaryKey] === rowID + : record[grid.primaryKey] === rowID) : data.indexOf(rowID); + } + + public get_row_by_key(rowSelector: any): RowType { + if (!this.grid) { + return null; + } + const primaryKey = this.grid.primaryKey; + if (primaryKey !== undefined && primaryKey !== null) { + return this.grid.dataRowList.find((row) => row.data[primaryKey] === rowSelector); + } else { + return this.grid.dataRowList.find((row) => row.data === rowSelector); + } + } + + public get_row_by_index(rowIndex: number): RowType { + return this.grid.rowList.find((row) => row.index === rowIndex); + } + + /** + * Gets the rowID of the record at the specified data view index + * + * @param index + * @param dataCollection + */ + public get_rec_id_by_index(index: number, dataCollection?: any[]): any { + dataCollection = dataCollection || this.grid.data; + if (index >= 0 && index < dataCollection.length) { + const rec = dataCollection[index]; + return this.grid.primaryKey ? rec[this.grid.primaryKey] : rec; + } + return null; + } + + public get_cell_by_key(rowSelector: any, field: string): CellType { + const row = this.get_row_by_key(rowSelector); + if (row && row.cells) { + return row.cells.find((cell) => cell.column.field === field); + } + } + + public get_cell_by_index(rowIndex: number, columnID: number | string): CellType { + const row = this.get_row_by_index(rowIndex); + const hasCells = row && row.cells; + if (hasCells && typeof columnID === 'number') { + return row.cells.find((cell) => cell.column.index === columnID); + } + if (hasCells && typeof columnID === 'string') { + return row.cells.find((cell) => cell.column.field === columnID); + } + + } + + public get_cell_by_visible_index(rowIndex: number, columnIndex: number): CellType { + const row = this.get_row_by_index(rowIndex); + if (row && row.cells) { + return row.cells.find((cell) => cell.visibleColumnIndex === columnIndex); + } + } + + public update_cell(cell: IgxCell): IGridEditEventArgs { + if (!cell) { + return; + } + const args = cell.createCellEditEventArgs(true); + if (!this.grid.crudService.row) { // should not recalculate summaries when there is row in edit mode + this.grid.summaryService.clearSummaryCache(args); + } + const data = this.getRowData(cell.id.rowID); + const newRowData = reverseMapper(cell.column.field, args.newValue); + this.updateData(this.grid, cell.id.rowID, data, cell.rowData, newRowData); + if (!this.grid.crudService.row) { + this.grid.validation.update(cell.id.rowID, newRowData); + } + if (this.grid.primaryKey === cell.column.field) { + if (this.grid.pinnedRecords.length > 0) { + const rowIndex = this.grid.pinnedRecords.indexOf(cell.rowData); + if (rowIndex !== -1) { + const previousRowId = cell.value; + const rowType = this.grid.getRowByIndex(cell.rowIndex); + this.unpin_row(previousRowId, rowType); + this.pin_row(args.newValue, rowIndex, rowType); + } + } + if (this.grid.selectionService.isRowSelected(cell.id.rowID)) { + this.grid.selectionService.deselectRow(cell.id.rowID); + this.grid.selectionService.selectRowById(args.newValue); + } + if (this.grid.hasSummarizedColumns) { + this.grid.summaryService.removeSummaries(cell.id.rowID); + } + } + if (!this.grid.rowEditable || !this.crudService.row || + this.crudService.row.id !== cell.id.rowID || !this.grid.transactions.enabled) { + this.grid.summaryService.clearSummaryCache(args); + this.grid.pipeTrigger++; + } + + return args; + } + + // TODO: CRUD refactor to not emit editing evts. + public update_row(row: IgxEditRow, value: any, event?: Event) { + const grid = this.grid; + const selected = grid.selectionService.isRowSelected(row.id); + const rowInEditMode = this.crudService.row; + const data = this.get_all_data(grid.transactions.enabled); + const index = this.get_row_index_in_data(row.id, data); + const hasSummarized = grid.hasSummarizedColumns; + this.crudService.updateRowEditData(row, value); + + const args = row.createRowEditEventArgs(true, event); + + // If no valid row is found + if (index === -1) { + return args; + } + + if (rowInEditMode) { + const hasChanges = grid.transactions.getState(args.rowID, true); + grid.transactions.endPending(false); + if (!hasChanges) { + return args; + } + } + + if (!args.newValue) { + return args; + } + + if (hasSummarized) { + grid.summaryService.removeSummaries(args.rowID); + } + + this.updateData(grid, row.id, data[index], args.oldValue, args.newValue); + this.grid.validation.update(row.id, args.newValue); + const newId = grid.primaryKey ? args.newValue[grid.primaryKey] : args.newValue; + if (selected) { + grid.selectionService.deselectRow(row.id); + grid.selectionService.selectRowById(newId); + } + // make sure selection is handled prior to updating the row.id + row.id = newId; + if (hasSummarized) { + grid.summaryService.removeSummaries(newId); + } + grid.pipeTrigger++; + + return args; + } + + public sort(expression: ISortingExpression): void { + if (expression.dir === SortingDirection.None) { + this.remove_grouping_expression(expression.fieldName); + } + const sortingState = cloneArray(this.grid.sortingExpressions); + this.prepare_sorting_expression([sortingState], expression); + this.grid.sortingExpressions = sortingState; + } + + public sort_decoupled(expression: IGroupingExpression): void { + if (expression.dir === SortingDirection.None) { + this.remove_grouping_expression(expression.fieldName); + } + const groupingState = cloneArray((this.grid as any).groupingExpressions); + this.prepare_grouping_expression([groupingState], expression); + (this.grid as any).groupingExpressions = groupingState; + } + + public sort_multiple(expressions: ISortingExpression[]): void { + const sortingState = cloneArray(this.grid.sortingExpressions); + + for (const each of expressions) { + if (each.dir === SortingDirection.None) { + this.remove_grouping_expression(each.fieldName); + } + this.prepare_sorting_expression([sortingState], each); + } + + this.grid.sortingExpressions = sortingState; + } + + public sort_groupBy_multiple(expressions: ISortingExpression[]): void { + const groupingState = cloneArray((this.grid as any).groupingExpressions); + + for (const each of expressions) { + if (each.dir === SortingDirection.None) { + this.remove_grouping_expression(each.fieldName); + } + this.prepare_grouping_expression([groupingState], each); + } + } + + public clear_sort(fieldName: string) { + const sortingState = this.grid.sortingExpressions; + const index = sortingState.findIndex((expr) => expr.fieldName === fieldName); + if (index > -1) { + sortingState.splice(index, 1); + this.grid.sortingExpressions = sortingState; + } + } + + public clear_groupby(_name?: string | Array) { + } + + public should_apply_number_style(column: ColumnType): boolean { + return column.dataType === GridColumnDataType.Number; + } + + public get_data(): any[] { + const grid = this.grid; + const data = grid.data ? grid.data : []; + return data; + } + + public get_all_data(includeTransactions = false): any[] { + const grid = this.grid; + let data = grid && grid.data ? grid.data : []; + data = includeTransactions ? grid.dataWithAddedInTransactionRows : data; + return data; + } + + public get_filtered_data(): any[] { + return this.grid.filteredData; + } + + public addRowToData(rowData: any, _parentID?: any) { + // Add row goes to transactions and if rowEditable is properly implemented, added rows will go to pending transactions + // If there is a row in edit - > commit and close + const grid = this.grid; + const rowId = grid.primaryKey ? rowData[grid.primaryKey] : rowData; + if (grid.transactions.enabled) { + const transaction: Transaction = { id: rowId, type: TransactionType.ADD, newValue: rowData }; + grid.transactions.add(transaction); + } else { + grid.data.push(rowData); + } + grid.validation.markAsTouched(rowId); + grid.validation.update(rowId, rowData); + } + + public deleteRowFromData(rowID: any, index: number) { + // if there is a row (index !== 0) delete it + // if there is a row in ADD or UPDATE state change it's state to DELETE + const grid = this.grid; + if (index !== -1) { + if (grid.transactions.enabled) { + const transaction: Transaction = { id: rowID, type: TransactionType.DELETE, newValue: null }; + grid.transactions.add(transaction, grid.data[index]); + } else { + grid.data.splice(index, 1); + } + } else { + const state: State = grid.transactions.getState(rowID); + grid.transactions.add({ id: rowID, type: TransactionType.DELETE, newValue: null }, state && state.recordRef); + } + grid.validation.clear(rowID); + } + + public deleteRowById(rowId: any): any { + let index: number; + const grid = this.grid; + const data = this.get_all_data(grid.transactions.enabled); + if (grid.primaryKey) { + index = data.map((record) => record[grid.primaryKey]).indexOf(rowId); + } else { + index = data.indexOf(rowId); + } + const state: State = grid.transactions.getState(rowId); + const hasRowInNonDeletedState = state && state.type !== TransactionType.DELETE; + + // if there is a row (index !== -1) and the we have cell in edit mode on same row exit edit mode + // if there is no row (index === -1), but there is a row in ADD or UPDATE state do as above + // Otherwise just exit - there is nothing to delete + if (index !== -1 || hasRowInNonDeletedState) { + // Always exit edit when row is deleted + this.crudService.endEdit(true); + } else { + return; + } + + const record = data[index]; + const key = record ? record[grid.primaryKey] : undefined; + grid.rowDeletedNotifier.next({ data: record, rowData: record, owner: grid, primaryKey: key, rowKey: key }); + + this.deleteRowFromData(rowId, index); + + if (grid.selectionService.isRowSelected(rowId)) { + grid.selectionService.deselectRowsWithNoEvent([rowId]); + } else { + grid.selectionService.clearHeaderCBState(); + } + grid.pipeTrigger++; + grid.notifyChanges(); + // Data needs to be recalculated if transactions are in place + // If no transactions, `data` will be a reference to the grid getter, otherwise it will be stale + const dataAfterDelete = grid.transactions.enabled ? grid.dataWithAddedInTransactionRows : data; + grid.refreshSearch(); + if (dataAfterDelete.length % grid.perPage === 0 && dataAfterDelete.length / grid.perPage - 1 < grid.page && grid.page !== 0) { + grid.page--; + } + + return record; + } + + public get_row_id(rowData) { + return this.grid.primaryKey ? rowData[this.grid.primaryKey] : rowData; + } + + public row_deleted_transaction(rowID: any): boolean { + const grid = this.grid; + if (!grid) { + return false; + } + if (!grid.transactions.enabled) { + return false; + } + const state = grid.transactions.getState(rowID); + if (state) { + return state.type === TransactionType.DELETE; + } + + return false; + } + + public get_row_expansion_state(record: any): boolean { + const grid = this.grid; + const states = grid.expansionStates; + const rowID = grid.primaryKey ? record[grid.primaryKey] : record; + const expanded = states.get(rowID); + + if (expanded !== undefined) { + return expanded; + } else { + return grid.getDefaultExpandState(record); + } + } + + public set_row_expansion_state(rowID: any, expanded: boolean, event?: Event) { + const grid = this.grid; + const expandedStates = grid.expansionStates; + + if (!this.allow_expansion_state_change(rowID, expanded)) { + return; + } + + const args: IRowToggleEventArgs = { + rowKey: rowID, + rowID, + expanded, + event, + cancel: false + }; + + grid.rowToggle.emit(args); + + if (args.cancel) { + return; + } + expandedStates.set(rowID, expanded); + grid.expansionStates = expandedStates; + // K.D. 28 Feb, 2022 #10634 Don't trigger endEdit/commit upon row expansion state change + // this.crudService.endEdit(false); + } + + public get_rec_by_id(rowID) { + return this.grid.primaryKey ? this.getRowData(rowID) : rowID; + } + + /** + * Returns the index of the record in the data view by pk or -1 if not found or primaryKey is not set. + * + * @param pk + * @param dataCollection + */ + public get_rec_index_by_id(pk: string | number, dataCollection?: any[]): number { + dataCollection = dataCollection || this.grid.data; + return this.grid.primaryKey ? dataCollection.findIndex(rec => rec[this.grid.primaryKey] === pk) : -1; + } + + public allow_expansion_state_change(rowID, expanded) { + return this.grid.expansionStates.get(rowID) !== expanded; + } + + public prepare_sorting_expression(stateCollections: Array>, expression: ISortingExpression) { + if (expression.dir === SortingDirection.None) { + stateCollections.forEach(state => { + state.splice(state.findIndex((expr) => expr.fieldName === expression.fieldName), 1); + }); + return; + } + + /** + * We need to make sure the states in each collection with same fields point to the same object reference. + * If the different state collections provided have different sizes we need to get the largest one. + * That way we can get the state reference from the largest one that has the same fieldName as the expression to prepare. + */ + let maxCollection = stateCollections[0]; + for (let i = 1; i < stateCollections.length; i++) { + if (maxCollection.length < stateCollections[i].length) { + maxCollection = stateCollections[i]; + } + } + const maxExpr = maxCollection.find((expr) => expr.fieldName === expression.fieldName); + + stateCollections.forEach(collection => { + const myExpr = collection.find((expr) => expr.fieldName === expression.fieldName); + if (!myExpr && !maxExpr) { + // Expression with this fieldName is missing from the current and the max collection. + collection.push(expression); + } else if (!myExpr && maxExpr) { + // Expression with this fieldName is missing from the current and but the max collection has. + collection.push(maxExpr); + Object.assign(maxExpr, expression); + } else { + // The current collection has the expression so just update it. + Object.assign(myExpr, expression); + } + }); + } + + public prepare_grouping_expression(stateCollections: Array>, expression: IGroupingExpression) { + if (expression.dir === SortingDirection.None) { + stateCollections.forEach(state => { + state.splice(state.findIndex((expr) => expr.fieldName === expression.fieldName), 1); + }); + return; + } + + /** + * We need to make sure the states in each collection with same fields point to the same object reference. + * If the different state collections provided have different sizes we need to get the largest one. + * That way we can get the state reference from the largest one that has the same fieldName as the expression to prepare. + */ + let maxCollection = stateCollections[0]; + for (let i = 1; i < stateCollections.length; i++) { + if (maxCollection.length < stateCollections[i].length) { + maxCollection = stateCollections[i]; + } + } + const maxExpr = maxCollection.find((expr) => expr.fieldName === expression.fieldName); + + stateCollections.forEach(collection => { + const myExpr = collection.find((expr) => expr.fieldName === expression.fieldName); + if (!myExpr && !maxExpr) { + // Expression with this fieldName is missing from the current and the max collection. + collection.push(expression); + } else if (!myExpr && maxExpr) { + // Expression with this fieldName is missing from the current and but the max collection has. + collection.push(maxExpr); + Object.assign(maxExpr, expression); + } else { + // The current collection has the expression so just update it. + Object.assign(myExpr, expression); + } + }); + } + + public remove_grouping_expression(_fieldName) { + } + + public filterDataByExpressions(expressionsTree: IFilteringExpressionsTree): any[] { + let data = this.get_all_data(); + + if (expressionsTree.filteringOperands.length) { + const state = { expressionsTree, strategy: this.grid.filterStrategy }; + data = FilterUtil.filter(cloneArray(data), state, this.grid); + } + + return data; + } + + public sortDataByExpressions(data: any[], expressions: ISortingExpression[]) { + return DataUtil.sort(cloneArray(data), expressions, this.grid.sortStrategy, this.grid); + } + + public pin_row(rowID: any, index?: number, row?: RowType): void { + const grid = (this.grid as any); + if (grid._pinnedRecordIDs.indexOf(rowID) !== -1) { + return; + } + const eventArgs = this.get_pin_row_event_args(rowID, index, row, true); + grid.rowPinning.emit(eventArgs); + + if (eventArgs.cancel) { + return; + } + const insertIndex = typeof eventArgs.insertAtIndex === 'number' ? eventArgs.insertAtIndex : grid._pinnedRecordIDs.length; + grid._pinnedRecordIDs.splice(insertIndex, 0, rowID); + } + + public unpin_row(rowID: any, row: RowType): void { + const grid = (this.grid as any); + const index = grid._pinnedRecordIDs.indexOf(rowID); + if (index === -1) { + return; + } + const eventArgs = this.get_pin_row_event_args(rowID, null , row, false); + grid.rowPinning.emit(eventArgs); + + if (eventArgs.cancel) { + return; + } + grid._pinnedRecordIDs.splice(index, 1); + } + + public get_pin_row_event_args(rowID: any, index?: number, row?: RowType, pinned?: boolean) { + const eventArgs: IPinRowEventArgs = { + isPinned: pinned ? true : false, + rowKey: rowID, + rowID, + row, + cancel: false + } + if (typeof index === 'number') { + eventArgs.insertAtIndex = index <= this.grid.pinnedRecords.length ? index : this.grid.pinnedRecords.length; + } + return eventArgs; + } + + /** + * 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); + } + } + + + protected update_row_in_array(value: any, rowID: any, index: number) { + const grid = this.grid; + grid.data[index] = value; + } + + protected getSortStrategyPerColumn(fieldName: string) { + return this.get_column_by_name(fieldName) ? + this.get_column_by_name(fieldName).sortStrategy : undefined; + } + +} diff --git a/projects/igniteui-angular/grids/core/src/cell.component.html b/projects/igniteui-angular/grids/core/src/cell.component.html new file mode 100644 index 00000000000..4bbce226c42 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/cell.component.html @@ -0,0 +1,267 @@ + + @if (displayPinnedChip) { + {{ grid.resourceStrings.igx_grid_pinned_row_indicator }} + } + + + @if (column.dataType !== 'boolean' && column.dataType !== 'image' || (column.dataType === 'boolean' && this.formatter)) { +
    {{ + formatter + ? (value | columnFormatter:formatter:rowData:columnData) + : column.dataType === "number" + ? (value | number:column.pipeArgs.digitsInfo:grid.locale) + : (column.dataType === 'date' || column.dataType === 'time' || column.dataType === 'dateTime') + ? (value | date:column.pipeArgs.format:column.pipeArgs.timezone:grid.locale) + : column.dataType === 'currency' + ? (value | currency:currencyCode:column.pipeArgs.display:column.pipeArgs.digitsInfo:grid.locale) + : column.dataType === 'percent' + ? (value | percent:column.pipeArgs.digitsInfo:grid.locale) + : value + }}
    + } + @if (column.dataType === 'boolean' && !this.formatter) { + + + } + @if (column.dataType === 'image') { + + } +
    + + + + @if (column.dataType !== 'boolean' || (column.dataType === 'boolean' && this.formatter)) { +
    {{ + !isEmptyAddRowCell ? value : (column.header || column.field) + }}
    + } +
    + + @if (column.dataType === 'string' || column.dataType === 'image') { + + + + + + } + @if (column.dataType === 'number') { + + + + } + @if (column.dataType === 'boolean') { + + + + } + @if (column.dataType === 'date') { + + + + + } + @if (column.dataType === 'time') { + + + + } + @if (column.dataType === 'dateTime') { + + + + } + @if (column.dataType === 'currency') { + + @if (grid.currencyPositionLeft) { + {{ currencyCodeSymbol }} + } + + @if (!grid.currencyPositionLeft) { + {{ currencyCodeSymbol }} + } + + } + @if (column.dataType === 'percent') { + + + {{ editValue | percent:column.pipeArgs.digitsInfo:grid.locale }} + + } + + + + + +@if (isInvalid) { + + +
    +
    + +
    +
    +} + + + @let errors = formControl.errors; + @if (errors?.['required']) { +
    + {{grid.resourceStrings.igx_grid_required_validation_error}} +
    + } + @if (errors?.['minlength']) { +
    + {{grid.resourceStrings.igx_grid_min_length_validation_error | igxStringReplace:'{0}':errors.minlength.requiredLength }} +
    + } + @if (errors?.['maxlength']) { +
    + {{grid.resourceStrings.igx_grid_max_length_validation_error | igxStringReplace:'{0}':errors.maxlength.requiredLength }} +
    + } + @if (errors?.['min']) { +
    + {{grid.resourceStrings.igx_grid_min_validation_error | igxStringReplace:'{0}':errors.min.min }} +
    + } + @if (errors?.['max']) { +
    + {{grid.resourceStrings.igx_grid_max_validation_error | igxStringReplace:'{0}':errors.max.max }} +
    + } + @if (errors?.['email']) { +
    + {{grid.resourceStrings.igx_grid_email_validation_error }} +
    + } + @if (errors?.['pattern']) { +
    + {{grid.resourceStrings.igx_grid_pattern_validation_error}} +
    + } +
    diff --git a/projects/igniteui-angular/grids/core/src/cell.component.ts b/projects/igniteui-angular/grids/core/src/cell.component.ts new file mode 100644 index 00000000000..db9ab8e82e5 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/cell.component.ts @@ -0,0 +1,1298 @@ +import { useAnimation } from '@angular/animations'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + HostBinding, + HostListener, + Input, + TemplateRef, + ViewChild, + NgZone, + OnInit, + OnDestroy, + OnChanges, + SimpleChanges, + ViewChildren, + QueryList, + AfterViewInit, + booleanAttribute, + inject +} from '@angular/core'; +import { formatPercent, NgClass, NgTemplateOutlet, DecimalPipe, PercentPipe, CurrencyPipe, DatePipe, getLocaleCurrencyCode, getCurrencySymbol } from '@angular/common'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; + +import { first, takeUntil, takeWhile } from 'rxjs/operators'; +import { Subject } from 'rxjs'; + +import { + formatCurrency, + formatDate, + PlatformUtil, + AutoPositionStrategy, + HorizontalAlignment, + IgxOverlayService, + GridColumnDataType, + ColumnType +} from 'igniteui-angular/core'; +import { IgxGridSelectionService } from './selection/selection.service'; +import { HammerGesturesManager } from 'igniteui-angular/core'; +import { GridSelectionMode } from './common/enums'; +import { CellType, IgxCellTemplateContext, IGX_GRID_BASE, RowType } from './common/grid.interface'; +import { IgxRowDirective } from './row.directive'; +import { ISearchInfo } from './common/events'; +import { IgxGridCell } from './grid-public-cell'; +import { ISelectionNode } from './common/types'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxGridCellImageAltPipe, IgxStringReplacePipe, IgxColumnFormatterPipe } from './common/pipes'; +import { + IgxTooltipDirective, + IgxTooltipTargetDirective, + IgxDateTimeEditorDirective, + IgxTextSelectionDirective, + IgxFocusDirective, + IgxTextHighlightDirective + } from 'igniteui-angular/directives'; +import { fadeOut, scaleInCenter } from 'igniteui-angular/animations'; +import { IgxChipComponent } from 'igniteui-angular/chips'; +import { IgxInputDirective, IgxInputGroupComponent, IgxPrefixDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; +import { IgxCheckboxComponent } from 'igniteui-angular/checkbox'; +import { IgxDatePickerComponent } from 'igniteui-angular/date-picker'; +import { IgxTimePickerComponent } from 'igniteui-angular/time-picker'; + +/** + * Providing reference to `IgxGridCellComponent`: + * ```typescript + * @ViewChild('grid', { read: IgxGridComponent }) + * public grid: IgxGridComponent; + * ``` + * ```typescript + * let column = this.grid.columnList.first; + * ``` + * ```typescript + * let cell = column.cells[0]; + * ``` + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'igx-grid-cell', + templateUrl: './cell.component.html', + providers: [HammerGesturesManager], + imports: [ + NgClass, + NgTemplateOutlet, + DecimalPipe, + PercentPipe, + CurrencyPipe, + DatePipe, + ReactiveFormsModule, + IgxChipComponent, + IgxTextHighlightDirective, + IgxIconComponent, + IgxInputGroupComponent, + IgxInputDirective, + IgxFocusDirective, + IgxTextSelectionDirective, + IgxCheckboxComponent, + IgxDatePickerComponent, + IgxTimePickerComponent, + IgxDateTimeEditorDirective, + IgxPrefixDirective, + IgxSuffixDirective, + IgxTooltipTargetDirective, + IgxTooltipDirective, + IgxGridCellImageAltPipe, + IgxStringReplacePipe, + IgxColumnFormatterPipe + ] +}) +export class IgxGridCellComponent implements OnInit, OnChanges, OnDestroy, CellType, AfterViewInit { + protected selectionService = inject(IgxGridSelectionService); + public grid = inject(IGX_GRID_BASE); + protected overlayService = inject(IgxOverlayService); + public cdr = inject(ChangeDetectorRef); + private element = inject(ElementRef); + protected zone = inject(NgZone); + private touchManager = inject(HammerGesturesManager); + protected platformUtil = inject(PlatformUtil); + + private _destroy$ = new Subject(); + /** + * @hidden + * @internal + */ + @HostBinding('class.igx-grid__td--new') + public get isEmptyAddRowCell() { + return this.intRow.addRowUI && (this.value === undefined || this.value === null); + } + + /** + * @hidden + * @internal + */ + @ViewChildren('error', { read: IgxTooltipDirective }) + public errorTooltip: QueryList; + + /** + * @hidden + * @internal + */ + @ViewChild('errorIcon', { read: IgxIconComponent, static: false }) + public errorIcon: IgxIconComponent; + + /** + * Gets the default error template. + * @hidden @internal + */ + @ViewChild('defaultError', { read: TemplateRef, static: true }) + public defaultErrorTemplate: TemplateRef; + + /** + * Gets the column of the cell. + * ```typescript + * let cellColumn = this.cell.column; + * ``` + * + * @memberof IgxGridCellComponent + */ + @Input() + public column: ColumnType; + + /** + * @hidden + * @internal + */ + @Input() + public isPlaceholder: boolean; + + /** + Gets whether this cell is a merged cell. + */ + @Input() + public isMerged: boolean; + + /** + * @hidden + * @internal + */ + protected get formGroup(): FormGroup { + return this.grid.validation.getFormGroup(this.intRow.key); + } + + /** + * @hidden + * @internal + */ + @Input() + public intRow: IgxRowDirective; + + /** + * Gets the row of the cell. + * ```typescript + * let cellRow = this.cell.row; + * ``` + * + * @memberof IgxGridCellComponent + */ + @Input() + public get row(): RowType { + return this.grid.createRow(this.intRow.index); + } + + /** + * Gets the data of the row of the cell. + * ```typescript + * let rowData = this.cell.rowData; + * ``` + * + * @memberof IgxGridCellComponent + */ + @Input() + public rowData: any; + + /** + * @hidden + * @internal + */ + @Input() + public columnData: any; + + /** + * Sets/gets the template of the cell. + * ```html + * + *
    + * {{value}} + *
    + *
    + * ``` + * ```typescript + * @ViewChild('cellTemplate',{read: TemplateRef}) + * cellTemplate: TemplateRef; + * ``` + * ```typescript + * this.cell.cellTemplate = this.cellTemplate; + * ``` + * ```typescript + * let template = this.cell.cellTemplate; + * ``` + * + * @memberof IgxGridCellComponent + */ + @Input() + public cellTemplate: TemplateRef; + + @Input() + public cellValidationErrorTemplate: TemplateRef; + + @Input() + public pinnedIndicator: TemplateRef; + + /** + * Sets/gets the cell value. + * ```typescript + * this.cell.value = "Cell Value"; + * ``` + * ```typescript + * let cellValue = this.cell.value; + * ``` + * + * @memberof IgxGridCellComponent + */ + @Input() + public value: any; + + /** + * Gets the cell formatter. + * ```typescript + * let cellForamatter = this.cell.formatter; + * ``` + * + * @memberof IgxGridCellComponent + */ + @Input() + public formatter: (value: any, rowData?: any, columnData?: any) => any; + + /** + * Gets the cell template context object. + * ```typescript + * let context = this.cell.context(); + * ``` + * + * @memberof IgxGridCellComponent + */ + public get context(): IgxCellTemplateContext { + const getCellType = () => this.getCellType(true); + const ctx: IgxCellTemplateContext = { + $implicit: this.value, + additionalTemplateContext: this.column.additionalTemplateContext, + get cell() { + /* Turns the `cell` property from the template context object into lazy-evaluated one. + * Otherwise on each detection cycle the cell template is recreating N cell instances where + * N = number of visible cells in the grid, leading to massive performance degradation in large grids. + */ + return getCellType(); + } + }; + if (this.editMode) { + ctx.formControl = this.formControl; + } + if (this.isInvalid) { + ctx.defaultErrorTemplate = this.defaultErrorTemplate; + } + return ctx; + } + + /** + * Gets the cell template. + * ```typescript + * let template = this.cell.template; + * ``` + * + * @memberof IgxGridCellComponent + */ + public get template(): TemplateRef { + if (this.isPlaceholder) { + return this.emptyCellTemplate; + } + if (this.editMode && this.formGroup) { + const inlineEditorTemplate = this.column.inlineEditorTemplate; + return inlineEditorTemplate ? inlineEditorTemplate : this.inlineEditorTemplate; + } + if (this.cellTemplate) { + return this.cellTemplate; + } + if (this.grid.rowEditable && this.intRow.addRowUI) { + return this.addRowCellTemplate; + } + return this.defaultCellTemplate; + } + + /** + * Gets the pinned indicator template. + * ```typescript + * let template = this.cell.pinnedIndicatorTemplate; + * ``` + * + * @memberof IgxGridCellComponent + */ + public get pinnedIndicatorTemplate() { + if (this.pinnedIndicator) { + return this.pinnedIndicator; + } + return this.defaultPinnedIndicator; + } + + /** + * Gets the `id` of the grid in which the cell is stored. + * ```typescript + * let gridId = this.cell.gridID; + * ``` + * + * @memberof IgxGridCellComponent + */ + public get gridID(): any { + return this.intRow.gridID; + } + + + /** + * Gets the `index` of the row where the cell is stored. + * ```typescript + * let rowIndex = this.cell.rowIndex; + * ``` + * + * @memberof IgxGridCellComponent + */ + @HostBinding('attr.data-rowIndex') + public get rowIndex(): number { + return this.intRow.index; + } + + /** + * Gets the `index` of the cell column. + * ```typescript + * let columnIndex = this.cell.columnIndex; + * ``` + * + * @memberof IgxGridCellComponent + */ + public get columnIndex(): number { + return this.column.index; + } + + /** + * Returns the column visible index. + * ```typescript + * let visibleColumnIndex = this.cell.visibleColumnIndex; + * ``` + * + * @memberof IgxGridCellComponent + */ + @HostBinding('attr.data-visibleIndex') + @Input() + public get visibleColumnIndex() { + return this.column.columnLayoutChild ? this.column.visibleIndex : this._vIndex; + } + + public set visibleColumnIndex(val) { + this._vIndex = val; + } + + /** + * Gets the ID of the cell. + * ```typescript + * let cellID = this.cell.cellID; + * ``` + * + * @memberof IgxGridCellComponent + */ + public get cellID() { + const primaryKey = this.grid.primaryKey; + const rowID = primaryKey ? this.rowData[primaryKey] : this.rowData; + return { rowID, columnID: this.columnIndex, rowIndex: this.rowIndex }; + } + + @HostBinding('attr.id') + public get attrCellID() { + return `${this.intRow.gridID}_${this.rowIndex}_${this.visibleColumnIndex}`; + } + + @HostBinding('attr.title') + public get title() { + if (this.editMode || this.cellTemplate || this.errorShowing) { + return ''; + } + + if (this.formatter) { + return this.formatter(this.value, this.rowData, this.columnData); + } + + const args = this.column.pipeArgs; + const locale = this.grid.locale; + + switch (this.column.dataType) { + case GridColumnDataType.Percent: + return formatPercent(this.value, locale, args.digitsInfo); + case GridColumnDataType.Currency: + return formatCurrency(this.value, this.currencyCode, args.display, args.digitsInfo, locale); + case GridColumnDataType.Date: + case GridColumnDataType.DateTime: + case GridColumnDataType.Time: + return formatDate(this.value, args.format, locale, args.timezone); + } + return this.value; + } + + @HostBinding('class.igx-grid__td--bool-true') + public get booleanClass() { + return this.column.dataType === 'boolean' && this.value; + } + + /** + * Returns a reference to the nativeElement of the cell. + * ```typescript + * let cellNativeElement = this.cell.nativeElement; + * ``` + * + * @memberof IgxGridCellComponent + */ + public get nativeElement(): HTMLElement { + return this.element.nativeElement; + } + + /** + * @hidden + * @internal + */ + @Input() + public get cellSelectionMode() { + return this._cellSelection; + } + + public set cellSelectionMode(value) { + if (this._cellSelection === value) { + return; + } + this.zone.runOutsideAngular(() => { + if (value === GridSelectionMode.multiple) { + this.addPointerListeners(value); + } else { + this.removePointerListeners(this._cellSelection); + } + }); + this._cellSelection = value; + } + + /** + * @hidden + * @internal + */ + @Input() + public set lastSearchInfo(value: ISearchInfo) { + this._lastSearchInfo = value; + this.highlightText(this._lastSearchInfo.searchText, this._lastSearchInfo.caseSensitive, this._lastSearchInfo.exactMatch); + } + + /** + * @hidden + * @internal + */ + @Input() + @HostBinding('class.igx-grid__td--pinned-last') + public lastPinned = false; + + /** + * @hidden + * @internal + */ + @Input() + @HostBinding('class.igx-grid__td--pinned-first') + public firstPinned = false; + + /** + * Returns whether the cell is in edit mode. + */ + @Input({ transform: booleanAttribute }) + @HostBinding('class.igx-grid__td--editing') + public editMode = false; + + /** + * Sets/get the `role` property of the cell. + * Default value is `"gridcell"`. + * ```typescript + * this.cell.role = 'grid-cell'; + * ``` + * ```typescript + * let cellRole = this.cell.role; + * ``` + * + * @memberof IgxGridCellComponent + */ + @HostBinding('attr.role') + public role = 'gridcell'; + + /** + * Gets whether the cell is editable. + * ```typescript + * let isCellReadonly = this.cell.readonly; + * ``` + * + * @memberof IgxGridCellComponent + */ + @HostBinding('attr.aria-readonly') + public get readonly(): boolean { + return !this.editable; + } + + /** @hidden @internal */ + @HostBinding('attr.aria-describedby') + public get ariaDescribeBy() { + return this.isInvalid ? this.ariaErrorMessage : null; + } + + /** @hidden @internal */ + public get ariaErrorMessage() { + return this.grid.id + '_' + this.column.field + '_' + this.intRow.index + '_error'; + } + + /** + * @hidden + * @internal + */ + @HostBinding('class.igx-grid__td--invalid') + @HostBinding('attr.aria-invalid') + public get isInvalid() { + if (this.formGroup) { + const isInvalid = this.grid.validation?.isFieldInvalid(this.formGroup, this.column?.field); + return !this.intRow.deleted && isInvalid; + } + return false; + } + + /** + * @hidden + * @internal + */ + @HostBinding('class.igx-grid__td--valid') + public get isValidAfterEdit() { + if (this.formGroup) { + const isValidAfterEdit = this.grid.validation?.isFieldValidAfterEdit(this.formGroup, this.column?.field); + return this.editMode && isValidAfterEdit; + } + return false; + } + + /** + * Gets the formControl responsible for value changes and validation for this cell. + */ + protected get formControl(): FormControl { + return this.grid.validation.getFormControl(this.intRow.key, this.column.field) as FormControl; + } + + public get gridRowSpan(): number { + return this.column.gridRowSpan; + } + + public get gridColumnSpan(): number { + return this.column.gridColumnSpan; + } + + public get rowEnd(): number { + return this.column.rowEnd; + } + + public get colEnd(): number { + return this.column.colEnd; + } + + public get rowStart(): number { + return this.column.rowStart; + } + + public get colStart(): number { + return this.column.colStart; + } + + /** + * Gets the width of the cell. + * ```typescript + * let cellWidth = this.cell.width; + * ``` + * + * @memberof IgxGridCellComponent + */ + @Input() + public width = ''; + + /** + * @hidden + */ + @Input() + @HostBinding('class.igx-grid__td--active') + public active = false; + + @HostBinding('attr.aria-selected') + public get ariaSelected() { + return this.selected || this.column.selected || this.intRow.selected; + } + + /** + * Gets whether the cell is selected. + * ```typescript + * let isSelected = this.cell.selected; + * ``` + * + * @memberof IgxGridCellComponent + */ + @HostBinding('class.igx-grid__td--selected') + public get selected() { + return this.selectionService.selected(this.selectionNode); + } + + /** + * Selects/deselects the cell. + * ```typescript + * this.cell.selected = true. + * ``` + * + * @memberof IgxGridCellComponent + */ + public set selected(val: boolean) { + const node = this.selectionNode; + if (val) { + this.selectionService.add(node); + } else { + this.selectionService.remove(node); + } + this.grid.notifyChanges(); + } + + /** + * Gets whether the cell column is selected. + * ```typescript + * let isCellColumnSelected = this.cell.columnSelected; + * ``` + * + * @memberof IgxGridCellComponent + */ + @HostBinding('class.igx-grid__td--column-selected') + public get columnSelected() { + return this.selectionService.isColumnSelected(this.column.field); + } + + /** + * Sets the current edit value while a cell is in edit mode. + * Only for cell editing mode. + * ```typescript + * this.cell.editValue = value; + * ``` + * + * @memberof IgxGridCellComponent + */ + public set editValue(value) { + if (this.grid.crudService.cellInEditMode) { + this.grid.crudService.cell.editValue = value; + } + } + + /** + * Gets the current edit value while a cell is in edit mode. + * Only for cell editing mode. + * ```typescript + * let editValue = this.cell.editValue; + * ``` + * + * @memberof IgxGridCellComponent + */ + public get editValue() { + if (this.grid.crudService.cellInEditMode) { + return this.grid.crudService.cell.editValue; + } + } + + /** + * Returns whether the cell is editable. + */ + public get editable(): boolean { + return this.column.editable && !this.intRow.disabled; + } + + /** + * @hidden + */ + @Input() + @HostBinding('class.igx-grid__td--row-pinned-first') + public displayPinnedChip = false; + + @HostBinding('style.min-height.px') + protected get minHeight() { + if ((this.grid as any).isCustomSetRowHeight) { + return this.grid.renderedRowHeight; + } + } + + @HostBinding('attr.aria-rowindex') + protected get ariaRowIndex(): number { + // +2 because aria-rowindex is 1-based and the first row is the header + return this.rowIndex + 2; + } + + @HostBinding('attr.aria-colindex') + protected get ariaColIndex(): number { + return this.column.index + 1; + } + + @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; + + @ViewChild('inlineEditor', { read: TemplateRef, static: true }) + protected inlineEditorTemplate: TemplateRef; + + @ViewChild('addRowCell', { read: TemplateRef, static: true }) + protected addRowCellTemplate: TemplateRef; + + @ViewChild(IgxTextHighlightDirective, { read: IgxTextHighlightDirective }) + protected set highlight(value: IgxTextHighlightDirective) { + this._highlight = value; + + if (this._highlight && this.grid.lastSearchInfo.searchText) { + this._highlight.highlight(this.grid.lastSearchInfo.searchText, + this.grid.lastSearchInfo.caseSensitive, + this.grid.lastSearchInfo.exactMatch); + this._highlight.activateIfNecessary(); + } + } + + protected get highlight() { + return this._highlight; + } + + protected get selectionNode(): ISelectionNode { + return { + row: this.rowIndex, + column: this.column.columnLayoutChild ? this.column.parent.visibleIndex : this.visibleColumnIndex, + layout: this.column.columnLayoutChild ? { + rowStart: this.column.rowStart, + colStart: this.column.colStart, + rowEnd: this.column.rowEnd, + colEnd: this.column.colEnd, + columnVisibleIndex: this.visibleColumnIndex + } : null + }; + } + + /** + * Sets/gets the highlight class of the cell. + * Default value is `"igx-highlight"`. + * ```typescript + * let highlightClass = this.cell.highlightClass; + * ``` + * ```typescript + * this.cell.highlightClass = 'igx-cell-highlight'; + * ``` + * + * @memberof IgxGridCellComponent + */ + public highlightClass = 'igx-highlight'; + + /** + * Sets/gets the active highlight class class of the cell. + * Default value is `"igx-highlight__active"`. + * ```typescript + * let activeHighlightClass = this.cell.activeHighlightClass; + * ``` + * ```typescript + * this.cell.activeHighlightClass = 'igx-cell-highlight_active'; + * ``` + * + * @memberof IgxGridCellComponent + */ + public activeHighlightClass = 'igx-highlight__active'; + + /** @hidden @internal */ + public get step(): number { + const digitsInfo = this.column.pipeArgs.digitsInfo; + if (!digitsInfo) { + return 1; + } + const step = +digitsInfo.substr(digitsInfo.indexOf('.') + 1, 1); + return 1 / (Math.pow(10, step)); + } + + /** @hidden @internal */ + public get currencyCode(): string { + return this.column.pipeArgs.currencyCode ? + this.column.pipeArgs.currencyCode : getLocaleCurrencyCode(this.grid.locale); + } + + /** @hidden @internal */ + public get currencyCodeSymbol(): string { + return getCurrencySymbol(this.currencyCode, 'wide', this.grid.locale); + } + + protected _lastSearchInfo: ISearchInfo; + private _highlight: IgxTextHighlightDirective; + private _cellSelection: GridSelectionMode = GridSelectionMode.multiple; + private _vIndex = -1; + + + + /** + * @hidden + * @internal + */ + @HostListener('dblclick', ['$event']) + public onDoubleClick = (event: MouseEvent) => { + if (event.type === 'doubletap') { + // prevent double-tap to zoom on iOS + event.preventDefault(); + } + if (this.editable && !this.editMode && !this.intRow.deleted && !this.grid.crudService.rowEditingBlocked) { + this.grid.crudService.enterEditMode(this, event as Event); + } + + this.grid.doubleClick.emit({ + cell: this.getCellType(), + event + }); + }; + + /** + * @hidden + * @internal + */ + @HostListener('click', ['$event']) + public onClick(event: MouseEvent) { + this.grid.cellClick.emit({ + cell: this.getCellType(), + event + }); + } + + /** + * @hidden + * @internal + */ + public ngOnInit() { + this.zone.runOutsideAngular(() => { + this.nativeElement.addEventListener('pointerdown', this.pointerdown); + this.addPointerListeners(this.cellSelectionMode); + }); + if (this.platformUtil.isIOS) { + this.touchManager.addEventListener(this.nativeElement, 'doubletap', this.onDoubleClick, { + cssProps: {} /* don't disable user-select, etc */ + }); + } + + } + + public ngAfterViewInit() { + this.errorTooltip.changes.pipe(takeUntil(this._destroy$)).subscribe(() => { + if (this.errorTooltip.length > 0 && this.active) { + // error ocurred + this.cdr.detectChanges(); + this.openErrorTooltip(); + } + }); + } + + /** + * @hidden + * @internal + */ + public errorShowing = false; + + private openErrorTooltip() { + const tooltip = this.errorTooltip.first; + tooltip.open( + { + target: this.errorIcon.el.nativeElement, + closeOnOutsideClick: true, + excludeFromOutsideClick: [this.nativeElement], + closeOnEscape: false, + outlet: this.grid.outlet, + modal: false, + positionStrategy: new AutoPositionStrategy({ + horizontalStartPoint: HorizontalAlignment.Center, + horizontalDirection: HorizontalAlignment.Center, + openAnimation: useAnimation(scaleInCenter, { params: { duration: '150ms' } }), + closeAnimation: useAnimation(fadeOut, { params: { duration: '75ms' } }) + }) + } + ); + } + + /** + * @hidden + * @internal + */ + public ngOnDestroy() { + this.zone.runOutsideAngular(() => { + this.nativeElement.removeEventListener('pointerdown', this.pointerdown); + this.removePointerListeners(this.cellSelectionMode); + }); + this.touchManager.destroy(); + this._destroy$.next(); + this._destroy$.complete(); + } + + /** + * @hidden + * @internal + */ + public ngOnChanges(changes: SimpleChanges): void { + if (changes.editMode && changes.editMode.currentValue && this.formControl) { + // ensure when values change, form control is forced to be marked as touche. + this.formControl.valueChanges.pipe(takeWhile(() => this.editMode)).subscribe(() => this.formControl.markAsTouched()); + // while in edit mode subscribe to value changes on the current form control and set to editValue + this.formControl.statusChanges.pipe(takeWhile(() => this.editMode)).subscribe(status => { + if (status === 'INVALID' && this.errorTooltip.length > 0) { + this.cdr.detectChanges(); + const tooltip = this.errorTooltip.first; + this.resizeAndRepositionOverlayById(tooltip.overlayId, this.errorTooltip.first.element.offsetWidth); + } + }); + } + if (changes.value && !changes.value.firstChange) { + if (this.highlight) { + this.highlight.lastSearchInfo.searchText = this.grid.lastSearchInfo.searchText; + this.highlight.lastSearchInfo.caseSensitive = this.grid.lastSearchInfo.caseSensitive; + this.highlight.lastSearchInfo.exactMatch = this.grid.lastSearchInfo.exactMatch; + } + const isInEdit = this.grid.rowEditable ? this.row.inEditMode : this.editMode; + if (this.formControl && this.formControl.value !== changes.value.currentValue && !isInEdit) { + this.formControl.setValue(changes.value.currentValue); + } + } + } + + + + /** + * @hidden @internal + */ + private resizeAndRepositionOverlayById(overlayId: string, newSize: number) { + const overlay = this.overlayService.getOverlayById(overlayId); + if (!overlay) return; + overlay.initialSize.width = newSize; + overlay.elementRef.nativeElement.parentElement.style.width = newSize + 'px'; + this.overlayService.reposition(overlayId); + } + + /** + * Starts/ends edit mode for the cell. + * + * ```typescript + * cell.setEditMode(true); + * ``` + */ + public setEditMode(value: boolean): void { + if (this.intRow.deleted) { + return; + } + if (this.editable && value) { + if (this.grid.crudService.cellInEditMode) { + this.grid.gridAPI.update_cell(this.grid.crudService.cell); + this.grid.crudService.endCellEdit(); + } + this.grid.crudService.enterEditMode(this); + } else { + this.grid.crudService.endCellEdit(); + } + this.grid.notifyChanges(); + } + + /** + * Sets new value to the cell. + * ```typescript + * this.cell.update('New Value'); + * ``` + * + * @memberof IgxGridCellComponent + */ + // TODO: Refactor + public update(val: any) { + if (this.intRow.deleted) { + return; + } + + let cell = this.grid.crudService.cell; + if (!cell) { + cell = this.grid.crudService.createCell(this); + } + cell.editValue = val; + this.grid.gridAPI.update_cell(cell); + this.grid.crudService.endCellEdit(); + this.cdr.markForCheck(); + } + + /** + * + * @hidden + * @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; + } + if (!this.platformUtil.isLeftClick(event)) { + event.preventDefault(); + this.grid.navigation.setActiveNode({ rowIndex: this.rowIndex, colIndex: this.visibleColumnIndex }); + this.selectionService.addKeyboardRange(); + this.selectionService.initKeyboardState(); + this.selectionService.primaryButton = false; + // Ensure RMB Click on edited cell does not end cell editing + if (!this.selected) { + this.grid.crudService.updateCell(true, event); + } + return; + } else { + this.selectionService.primaryButton = true; + } + this.selectionService.pointerDown(this.selectionNode, event.shiftKey, event.ctrlKey); + this.activate(event); + }; + + /** + * + * @hidden + * @internal + */ + public pointerenter = (event: PointerEvent) => { + const isHierarchicalGrid = this.grid.type === 'hierarchical'; + if (isHierarchicalGrid && (!this.grid.navigation?.activeNode?.gridID || this.grid.navigation.activeNode.gridID !== this.gridID)) { + return; + } + const dragMode = this.selectionService.pointerEnter(this.selectionNode, event); + if (dragMode) { + this.grid.cdr.detectChanges(); + } + }; + + /** + * @hidden + * @internal + */ + public focusout = () => { + this.closeErrorTooltip(); + } + + private closeErrorTooltip() { + const tooltip = this.errorTooltip.first; + if (tooltip) { + tooltip.close(); + } + } + + /** + * @hidden + * @internal + */ + public pointerup = (event: PointerEvent) => { + const isHierarchicalGrid = this.grid.type === 'hierarchical'; + if (!this.platformUtil.isLeftClick(event) || (isHierarchicalGrid && (!this.grid.navigation?.activeNode?.gridID || + this.grid.navigation.activeNode.gridID !== this.gridID))) { + return; + } + if (this.selectionService.pointerUp(this.selectionNode, this.grid.rangeSelected)) { + this.grid.cdr.detectChanges(); + } + }; + + /** + * @hidden + * @internal + */ + public activate(event: FocusEvent | KeyboardEvent) { + const node = this.selectionNode; + let shouldEmitSelection = !this.selectionService.isActiveNode(node); + + if (this.selectionService.primaryButton) { + const currentActive = this.selectionService.activeElement; + if (this.cellSelectionMode === GridSelectionMode.single && (event as any)?.ctrlKey && this.selected) { + this.selectionService.activeElement = null; + shouldEmitSelection = true; + } else { + this.selectionService.activeElement = node; + } + const cancel = this._updateCRUDStatus(event); + if (cancel) { + this.selectionService.activeElement = currentActive; + return; + } + + const activeElement = this.selectionService.activeElement; + const row = activeElement ? this.grid.gridAPI.get_row_by_index(activeElement.row) : null; + if (this.grid.crudService.rowEditingBlocked && row && this.intRow.key !== row.key) { + return; + } + + } else { + this.selectionService.activeElement = null; + if (this.grid.crudService.cellInEditMode && !this.editMode) { + this.grid.crudService.updateCell(true, event); + } + } + + this.grid.navigation.setActiveNode({ row: this.rowIndex, column: this.visibleColumnIndex }); + + const isTargetErrorIcon = event && event.target && event.target === this.errorIcon?.el.nativeElement + if (this.isInvalid && !isTargetErrorIcon) { + this.cdr.detectChanges(); + this.openErrorTooltip(); + this.grid.activeNodeChange.pipe(first()).subscribe(() => { + this.closeErrorTooltip(); + }); + } + this.selectionService.primaryButton = true; + if (this.cellSelectionMode === GridSelectionMode.multiple && this.selectionService.activeElement) { + if (this.selectionService.isInMap(this.selectionService.activeElement) && (event as any)?.ctrlKey && !(event as any)?.shiftKey) { + this.selectionService.remove(this.selectionService.activeElement); + shouldEmitSelection = true; + } else { + this.selectionService.add(this.selectionService.activeElement, false); // pointer events handle range generation + this.selectionService.keyboardStateOnFocus(node, this.grid.rangeSelected, this.nativeElement); + } + } + if (this.grid.isCellSelectable && shouldEmitSelection) { + this.zone.run(() => this.grid.selected.emit({ cell: this.getCellType(), event })); + } + } + + /** + * If the provided string matches the text in the cell, the text gets highlighted. + * ```typescript + * this.cell.highlightText('Cell Value', true); + * ``` + * + * @memberof IgxGridCellComponent + */ + public highlightText(text: string, caseSensitive?: boolean, exactMatch?: boolean): number { + return this.highlight && this.column.searchable ? this.highlight.highlight(text, caseSensitive, exactMatch) : 0; + } + + /** + * Clears the highlight of the text in the cell. + * ```typescript + * this.cell.clearHighLight(); + * ``` + * + * @memberof IgxGridCellComponent + */ + public clearHighlight() { + if (this.highlight && this.column.searchable) { + this.highlight.clearHighlight(); + } + } + + /** + * @hidden + * @internal + */ + public calculateSizeToFit(range: any): number { + return this.platformUtil.getNodeSizeViaRange(range, this.nativeElement); + } + + /** + * @hidden + * @internal + */ + public get searchMetadata() { + const meta = new Map(); + meta.set('pinned', this.grid.isRecordPinnedByViewIndex(this.intRow.index)); + return meta; + } + + /** + * @hidden + * @internal + */ + private _updateCRUDStatus(event?: Event) { + if (this.editMode) { + return; + } + + let editableArgs; + const crud = this.grid.crudService; + const editableCell = this.grid.crudService.cell; + const editMode = !!(crud.row || crud.cell); + + if (this.editable && editMode && !this.intRow.deleted) { + if (editableCell) { + editableArgs = this.grid.crudService.updateCell(false, event); + + /* This check is related with the following issue #6517: + * when edit cell that belongs to a column which is sorted and press tab, + * the next cell in edit mode is with wrong value /its context is not updated/; + * So we reapply sorting before the next cell enters edit mode. + * Also we need to keep the notifyChanges below, because of the current + * change detection cycle when we have editing with enabled transactions + */ + if (this.grid.sortingExpressions.length && this.grid.sortingExpressions.indexOf(editableCell.column.field)) { + this.grid.cdr.detectChanges(); + } + + if (editableArgs && editableArgs.cancel) { + return true; + } + + crud.exitCellEdit(event); + } + this.grid.tbody.nativeElement.focus({ preventScroll: true }); + this.grid.notifyChanges(); + crud.enterEditMode(this, event); + return false; + } + + if (editableCell && crud.sameRow(this.cellID.rowID)) { + this.grid.crudService.updateCell(true, event); + } else if (editMode && !crud.sameRow(this.cellID.rowID)) { + this.grid.crudService.endEdit(true, event); + } + } + + private addPointerListeners(selection) { + if (selection !== GridSelectionMode.multiple) { + return; + } + this.nativeElement.addEventListener('pointerenter', this.pointerenter); + this.nativeElement.addEventListener('pointerup', this.pointerup); + this.nativeElement.addEventListener('focusout', this.focusout); + } + + private removePointerListeners(selection) { + if (selection !== GridSelectionMode.multiple) { + return; + } + this.nativeElement.removeEventListener('pointerenter', this.pointerenter); + this.nativeElement.removeEventListener('pointerup', this.pointerup); + this.nativeElement.removeEventListener('focusout', this.focusout); + } + + private getCellType(useRow?: boolean): CellType { + const rowID = useRow ? this.grid.createRow(this.intRow.index, this.intRow.data) : this.intRow.index; + return new IgxGridCell(this.grid, rowID, this.column); + } +} diff --git a/projects/igniteui-angular/grids/core/src/column-actions/column-actions-base.directive.ts b/projects/igniteui-angular/grids/core/src/column-actions/column-actions-base.directive.ts new file mode 100644 index 00000000000..0443873bb07 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/column-actions/column-actions-base.directive.ts @@ -0,0 +1,41 @@ +import { Directive } from '@angular/core'; +import { ColumnType } from 'igniteui-angular/core'; + +@Directive() +export abstract class IgxColumnActionsBaseDirective { + + /** @hidden @internal */ + public abstract actionEnabledColumnsFilter: ( + value: ColumnType, + index: number, + array: ColumnType[] + ) => boolean; + + /** + * @hidden @internal + */ + public abstract get checkAllLabel(): string; + + /** + * @hidden @internal + */ + public abstract get uncheckAllLabel(): string; + + /** @hidden @internal */ + public abstract columnChecked(column: ColumnType): boolean; + + /** @hidden @internal */ + public abstract toggleColumn(column: ColumnType): void; + + /** @hidden @internal */ + public abstract uncheckAll(): void; + + /** @hidden @internal */ + public abstract checkAll(): void; + + /** @hidden @internal */ + public abstract get allChecked(): boolean; + + /** @hidden @internal */ + public abstract get allUnchecked(): boolean; +} diff --git a/projects/igniteui-angular/grids/core/src/column-actions/column-actions.component.html b/projects/igniteui-angular/grids/core/src/column-actions/column-actions.component.html new file mode 100644 index 00000000000..071b64a2ac8 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/column-actions/column-actions.component.html @@ -0,0 +1,40 @@ +
    + @if (title) { +

    {{ title }}

    + } + + @if (!hideFilter) { + + + + } +
    + +
    + @for ( + column of $any(grid)?._columns + | columnActionEnabled:actionsDirective.actionEnabledColumnsFilter:pipeTrigger + | filterActionColumns:filterCriteria:pipeTrigger + | sortActionColumns:columnDisplayOrder:pipeTrigger; track column + ) { + + {{ column.header || column.field }} + + } +
    + +
    + + +
    diff --git a/projects/igniteui-angular/grids/core/src/column-actions/column-actions.component.ts b/projects/igniteui-angular/grids/core/src/column-actions/column-actions.component.ts new file mode 100644 index 00000000000..0c474bc8585 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/column-actions/column-actions.component.ts @@ -0,0 +1,460 @@ +import { Component, DoCheck, EventEmitter, HostBinding, Input, IterableDiffer, IterableDiffers, Output, Pipe, PipeTransform, QueryList, ViewChildren, booleanAttribute, forwardRef, inject } from '@angular/core'; +import { ColumnDisplayOrder } from '../common/enums'; +import { GridType } from '../common/grid.interface'; +import { IColumnToggledEventArgs } from '../common/events'; +import { IgxColumnActionsBaseDirective } from './column-actions-base.directive'; +import { FormsModule } from '@angular/forms'; +import { IgxInputDirective, IgxInputGroupComponent } from 'igniteui-angular/input-group'; +import { IgxCheckboxComponent } from 'igniteui-angular/checkbox'; +import { IgxButtonDirective, IgxRippleDirective } from 'igniteui-angular/directives'; +import { ColumnType } from 'igniteui-angular/core'; + +let NEXT_ID = 0; +/** + * Providing reference to `IgxColumnActionsComponent`: + * ```typescript + * @ViewChild('columnActions', { read: IgxColumnActionsComponent }) + * public columnActions: IgxColumnActionsComponent; + */ +@Component({ + selector: 'igx-column-actions', + templateUrl: './column-actions.component.html', + imports: [IgxInputGroupComponent, FormsModule, IgxInputDirective, IgxCheckboxComponent, IgxButtonDirective, IgxRippleDirective, forwardRef(() => IgxColumnActionEnabledPipe), forwardRef(() => IgxFilterActionColumnsPipe), forwardRef(() => IgxSortActionColumnsPipe)] +}) +export class IgxColumnActionsComponent implements DoCheck { + private differs = inject(IterableDiffers); + + + /** + * Gets/Sets the grid to provide column actions for. + * + * @example + * ```typescript + * let grid = this.columnActions.grid; + * ``` + */ + @Input() + public grid: GridType; + /** + * Gets/sets the indentation of columns in the column list based on their hierarchy level. + * + * @example + * ``` + * + * ``` + */ + @Input() + public indentation = 30; + /** + * Sets/Gets the css class selector. + * By default the value of the `class` attribute is `"igx-column-actions"`. + * ```typescript + * let cssCLass = this.columnHidingUI.cssClass; + * ``` + * ```typescript + * this.columnHidingUI.cssClass = 'column-chooser'; + * ``` + */ + @HostBinding('class') + public cssClass = 'igx-column-actions'; + /** + * Gets/sets the max height of the columns area. + * + * @remarks + * The default max height is 100%. + * @example + * ```html + * + * ``` + */ + @Input() + public columnsAreaMaxHeight = '100%'; + /** + * Shows/hides the columns filtering input from the UI. + * + * @example + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public hideFilter = false; + /** + * Gets the checkbox components representing column items currently present in the dropdown + * + * @example + * ```typescript + * let columnItems = this.columnActions.columnItems; + * ``` + */ + @ViewChildren(IgxCheckboxComponent) + public columnItems: QueryList; + /** + * Gets/sets the title of the column actions component. + * + * @example + * ```html + * + * ``` + */ + @Input() + public title = ''; + + /** + * An event that is emitted after a column's checked state is changed. + * Provides references to the `column` and the `checked` properties as event arguments. + * ```html + * + * ``` + */ + @Output() + public columnToggled = new EventEmitter(); + + /** + * @hidden @internal + */ + public actionableColumns: ColumnType[] = []; + + /** + * @hidden @internal + */ + public filteredColumns: ColumnType[] = []; + + /** + * @hidden @internal + */ + public pipeTrigger = 0; + + /** + * @hidden @internal + */ + public actionsDirective: IgxColumnActionsBaseDirective; + + protected _differ: IterableDiffer | null = null; + + /** + * @hidden @internal + */ + private _filterColumnsPrompt = ''; + + /** + * @hidden @internal + */ + private _filterCriteria = ''; + + /** + * @hidden @internal + */ + private _columnDisplayOrder: ColumnDisplayOrder = ColumnDisplayOrder.DisplayOrder; + + /** + * @hidden @internal + */ + private _uncheckAllText: string; + + /** + * @hidden @internal + */ + private _checkAllText: string; + + /** + * @hidden @internal + */ + private _id = `igx-column-actions-${NEXT_ID++}`; + + constructor() { + this._differ = this.differs.find([]).create(this.trackChanges); + } + + /** + * Gets the prompt that is displayed in the filter input. + * + * @example + * ```typescript + * let filterColumnsPrompt = this.columnActions.filterColumnsPrompt; + * ``` + */ + @Input() + public get filterColumnsPrompt(): string { + return this._filterColumnsPrompt; + } + /** + * Sets the prompt that is displayed in the filter input. + * + * @example + * ```html + * + * ``` + */ + public set filterColumnsPrompt(value: string) { + this._filterColumnsPrompt = value || ''; + } + /** + * Gets the value which filters the columns list. + * + * @example + * ```typescript + * let filterCriteria = this.columnActions.filterCriteria; + * ``` + */ + @Input() + public get filterCriteria() { + return this._filterCriteria; + } + /** + * Sets the value which filters the columns list. + * + * @example + * ```html + * + * ``` + */ + public set filterCriteria(value: string) { + value = value || ''; + if (value !== this._filterCriteria) { + this._filterCriteria = value; + this.pipeTrigger++; + } + } + /** + * Gets the display order of the columns. + * + * @example + * ```typescript + * let columnDisplayOrder = this.columnActions.columnDisplayOrder; + * ``` + */ + @Input() + public get columnDisplayOrder() { + return this._columnDisplayOrder; + } + /** + * Sets the display order of the columns. + * + * @example + * ```typescript + * this.columnActions.columnDisplayOrder = ColumnDisplayOrder.Alphabetical; + * ``` + */ + public set columnDisplayOrder(value: ColumnDisplayOrder) { + if (value && value !== this._columnDisplayOrder) { + this._columnDisplayOrder = value; + this.pipeTrigger++; + } + } + /** + * Gets the text of the button that unchecks all columns. + * + * @remarks + * If unset it is obtained from the IgxColumnActionsBased derived directive applied. + * @example + * ```typescript + * let uncheckAllText = this.columnActions.uncheckAllText; + * ``` + */ + @Input() + public get uncheckAllText() { + return this._uncheckAllText || this.actionsDirective.uncheckAllLabel; + } + /** + * Sets the text of the button that unchecks all columns. + * + * @example + * ```html + * + * ``` + */ + public set uncheckAllText(value: string) { + this._uncheckAllText = value; + } + /** + * Gets the text of the button that checks all columns. + * + * @remarks + * If unset it is obtained from the IgxColumnActionsBased derived directive applied. + * @example + * ```typescript + * let uncheckAllText = this.columnActions.uncheckAllText; + * ``` + */ + @Input() + public get checkAllText() { + return this._checkAllText || this.actionsDirective.checkAllLabel; + } + /** + * Sets the text of the button that checks all columns. + * + * @remarks + * If unset it is obtained from the IgxColumnActionsBased derived directive applied. + * @example + * ```html + * + * ``` + */ + public set checkAllText(value: string) { + this._checkAllText = value; + } + + /** + * @hidden @internal + */ + public get checkAllDisabled(): boolean { + return this.actionsDirective.allUnchecked; + + } + /** + * @hidden @internal + */ + public get uncheckAllDisabled(): boolean { + return this.actionsDirective.allChecked; + } + + /** + * Gets/Sets the value of the `id` attribute. + * + * @remarks + * If not provided it will be automatically generated. + * @example + * ```html + * + * ``` + */ + @HostBinding('attr.id') + @Input() + public get id(): string { + return this._id; + } + public set id(value: string) { + this._id = value; + } + + /** + * @hidden @internal + */ + public get titleID() { + return this.id + '_title'; + } + + /** + * @hidden @internal + */ + public trackChanges = (index, col) => col.field + '_' + this.actionsDirective.actionEnabledColumnsFilter(col, index, []); + + /** + * @hidden @internal + */ + public ngDoCheck() { + if (this._differ) { + const changes = this._differ.diff(this.grid?.columnList); + if (changes) { + this.pipeTrigger++; + } + } + } + + /** + * Unchecks all columns and performs the appropriate action. + * + * @example + * ```typescript + * this.columnActions.uncheckAllColumns(); + * ``` + */ + public uncheckAllColumns() { + this.actionsDirective.uncheckAll(); + } + + /** + * Checks all columns and performs the appropriate action. + * + * @example + * ```typescript + * this.columnActions.checkAllColumns(); + * ``` + */ + public checkAllColumns() { + this.actionsDirective.checkAll(); + } + + /** + * @hidden @internal + */ + public toggleColumn(column: ColumnType) { + this.actionsDirective.toggleColumn(column); + + this.columnToggled.emit({ column: column as any, checked: this.actionsDirective.columnChecked(column) }); + } +} + +@Pipe({ + name: 'columnActionEnabled', + standalone: true +}) +export class IgxColumnActionEnabledPipe implements PipeTransform { + protected columnActions = inject(IgxColumnActionsComponent); + + + public transform( + collection: ColumnType[], + actionFilter: (value: ColumnType, index: number, array: ColumnType[]) => boolean, + _pipeTrigger: number + ): ColumnType[] { + if (!collection) { + return collection; + } + let copy = collection.slice(0); + if (copy.length && copy[0].grid.hasColumnLayouts) { + copy = copy.filter(c => c.columnLayout); + } + if (actionFilter) { + copy = copy.filter(actionFilter); + } + // Preserve the actionable collection for use in the component + this.columnActions.actionableColumns = copy as any; + return copy; + } +} + +@Pipe({ + name: 'filterActionColumns', + standalone: true +}) +export class IgxFilterActionColumnsPipe implements PipeTransform { + protected columnActions = inject(IgxColumnActionsComponent); + + + public transform(collection: ColumnType[], filterCriteria: string, _pipeTrigger: number): ColumnType[] { + if (!collection) { + return collection; + } + let copy = collection.slice(0); + if (filterCriteria && filterCriteria.length > 0) { + const filterFunc = (c) => { + const filterText = c.header || c.field; + if (!filterText) { + return false; + } + return filterText.toLocaleLowerCase().indexOf(filterCriteria.toLocaleLowerCase()) >= 0 || + (c.children?.some(filterFunc) ?? false); + }; + copy = collection.filter(filterFunc); + } + // Preserve the filtered collection for use in the component + this.columnActions.filteredColumns = copy as any; + return copy; + } +} + +@Pipe({ + name: 'sortActionColumns', + standalone: true +}) +export class IgxSortActionColumnsPipe implements PipeTransform { + + public transform(collection: ColumnType[], displayOrder: ColumnDisplayOrder, _pipeTrigger: number): ColumnType[] { + if (displayOrder === ColumnDisplayOrder.Alphabetical) { + return collection.sort((a, b) => (a.header || a.field).localeCompare(b.header || b.field)); + } + return collection; + } +} diff --git a/projects/igniteui-angular/grids/core/src/column-actions/column-hiding.directive.ts b/projects/igniteui-angular/grids/core/src/column-actions/column-hiding.directive.ts new file mode 100644 index 00000000000..2492480ee71 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/column-actions/column-hiding.directive.ts @@ -0,0 +1,75 @@ +import { Directive, inject } from '@angular/core'; +import { IgxColumnActionsBaseDirective } from './column-actions-base.directive'; +import { IgxColumnActionsComponent } from './column-actions.component'; +import { ColumnType } from 'igniteui-angular/core'; + +@Directive({ + selector: '[igxColumnHiding]', + standalone: true +}) +export class IgxColumnHidingDirective extends IgxColumnActionsBaseDirective { + protected columnActions = inject(IgxColumnActionsComponent); + + + constructor() { + super(); + const columnActions = this.columnActions; + + columnActions.actionsDirective = this; + } + + /** + * @hidden @internal + */ + public get checkAllLabel(): string { + return this.columnActions.grid?.resourceStrings.igx_grid_hiding_check_all_label ?? 'Show All'; + } + + /** + * @hidden @internal + */ + public get uncheckAllLabel(): string { + return this.columnActions.grid?.resourceStrings.igx_grid_hiding_uncheck_all_label ?? 'Hide All'; + } + /** + * @hidden @internal + */ + public checkAll() { + this.columnActions.filteredColumns.forEach(c => c.toggleVisibility(false)); + + } + + /** + * @hidden @internal + */ + public uncheckAll() { + this.columnActions.filteredColumns.forEach(c => c.toggleVisibility(true)); + } + + /** + * @hidden @internal + */ + public actionEnabledColumnsFilter = c => !c.disableHiding; + + /** + * @hidden @internal + */ + public columnChecked(column: ColumnType): boolean { + return !column.hidden; + } + + /** + * @hidden @internal + */ + public toggleColumn(column: ColumnType) { + column.toggleVisibility(); + } + + public get allChecked() { + return this.columnActions.filteredColumns.every(col => !this.columnChecked(col)); + } + + public get allUnchecked() { + return this.columnActions.filteredColumns.every(col => this.columnChecked(col)); + } +} diff --git a/projects/igniteui-angular/grids/core/src/column-actions/column-pinning.directive.ts b/projects/igniteui-angular/grids/core/src/column-actions/column-pinning.directive.ts new file mode 100644 index 00000000000..742b4d1b6ab --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/column-actions/column-pinning.directive.ts @@ -0,0 +1,74 @@ +import { Directive, inject } from '@angular/core'; +import { IgxColumnActionsBaseDirective } from './column-actions-base.directive'; +import { IgxColumnActionsComponent } from './column-actions.component'; +import { ColumnType } from 'igniteui-angular/core'; + +@Directive({ + selector: '[igxColumnPinning]', + standalone: true +}) +export class IgxColumnPinningDirective extends IgxColumnActionsBaseDirective { + protected columnActions = inject(IgxColumnActionsComponent); + + + constructor() { + super(); + const columnActions = this.columnActions; + + columnActions.actionsDirective = this; + } + + /** + * @hidden @internal + */ + public get checkAllLabel(): string { + return this.columnActions.grid?.resourceStrings.igx_grid_pinning_check_all_label ?? 'Pin All'; + } + + /** + * @hidden @internal + */ + public get uncheckAllLabel(): string { + return this.columnActions.grid?.resourceStrings.igx_grid_pinning_uncheck_all_label ?? 'Unpin All'; + } + /** + * @hidden @internal + */ + public checkAll() { + this.columnActions.filteredColumns.forEach(c => c.pinned = true); + } + + /** + * @hidden @internal + */ + public uncheckAll() { + this.columnActions.filteredColumns.forEach(c => c.pinned = false); + } + + /** + * @hidden @internal + */ + public actionEnabledColumnsFilter = (c: ColumnType) => !c.disablePinning && !c.level; + + /** + * @hidden @internal + */ + public columnChecked(column: ColumnType): boolean { + return column.pinned; + } + + /** + * @hidden @internal + */ + public toggleColumn(column: ColumnType) { + column.pinned = !column.pinned; + } + + public get allUnchecked() { + return !this.columnActions.filteredColumns.some(col => !this.columnChecked(col)); + } + + public get allChecked() { + return !this.columnActions.filteredColumns.some(col => this.columnChecked(col)); + } +} diff --git a/projects/igniteui-angular/grids/core/src/column-actions/public_api.ts b/projects/igniteui-angular/grids/core/src/column-actions/public_api.ts new file mode 100644 index 00000000000..59bed895a89 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/column-actions/public_api.ts @@ -0,0 +1,15 @@ +// import { IgxColumnActionsComponent } from './column-actions.component'; +// import { IgxColumnHidingDirective } from './column-hiding.directive'; +// import { IgxColumnPinningDirective } from './column-pinning.directive'; + +export { IgxColumnActionsComponent } from './column-actions.component'; +export { IgxColumnHidingDirective } from './column-hiding.directive'; +export { IgxColumnPinningDirective } from './column-pinning.directive'; +export { IgxColumnActionsBaseDirective } from './column-actions-base.directive'; + +/* NOTE: Grid column actions directives collection for ease-of-use import in standalone components scenario */ +// export const IGX_GRID_COLUMN_ACTIONS_DIRECTIVES = [ +// IgxColumnActionsComponent, +// IgxColumnHidingDirective, +// IgxColumnPinningDirective +// ] as const; diff --git a/projects/igniteui-angular/grids/core/src/columns/column-group.component.ts b/projects/igniteui-angular/grids/core/src/columns/column-group.component.ts new file mode 100644 index 00000000000..32899668f9f --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/columns/column-group.component.ts @@ -0,0 +1,432 @@ +import { + AfterContentInit, + Component, + ContentChildren, + ChangeDetectionStrategy, + Input, + forwardRef, + QueryList, + TemplateRef, + booleanAttribute +} from '@angular/core'; +import { takeUntil } from 'rxjs/operators'; + +import { IgxColumnComponent } from './column.component'; +import { ColumnType, flatten } from 'igniteui-angular/core'; +import { CellType, IgxColumnTemplateContext } from '../common/grid.interface'; + +/* blazorElement */ +/* omitModule */ +/* wcElementTag: igc-column-group */ +/* additionalIdentifier: Children.Field */ +/* jsonAPIManageCollectionInMarkup */ +/* blazorIndirectRender */ +/** + * **Ignite UI for Angular Column Group** + * + * @igxParent IgxGridComponent, IgxTreeGridComponent, IgxHierarchicalGridComponent, IgxColumnGroupComponent, IgxRowIslandComponent + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [{ provide: IgxColumnComponent, useExisting: forwardRef(() => IgxColumnGroupComponent) }], + selector: 'igx-column-group', + template: `@if (platform.isElements) { + + }`, + styles: `:host { display: none }`, + standalone: true +}) +export class IgxColumnGroupComponent extends IgxColumnComponent implements AfterContentInit { + + /* blazorInclude */ + /* contentChildren */ + /* blazorTreatAsCollection */ + /* blazorCollectionName: ColumnCollection */ + /* blazorCollectionItemName: Column */ + /* alternateType: HTMLCollection */ + /** + * @deprecated in version 18.1.0. Use the `childColumns` property instead. + */ + @ContentChildren(IgxColumnComponent, { read: IgxColumnComponent, }) + public override children = new QueryList(); + + /** + * Set if the column group is collapsible. + * Default value is `false` + * ```html + * + * ``` + * + * @memberof IgxColumnGroupComponent + */ + @Input({ transform: booleanAttribute }) + public override set collapsible(value: boolean) { + this._collapsible = value; + this.collapsibleChange.emit(this._collapsible); + if (this.children && !this.hidden) { + if (this._collapsible) { + this.setExpandCollapseState(); + } else { + this.children.forEach(child => child.hidden = false); + } + } + } + public override get collapsible() { + return this._collapsible && this.checkCollapsibleState(); + } + + /** + * Set whether the group is expanded or collapsed initially. + * Applied only if the collapsible property is set to `true` + * Default value is `true` + * ```html + * const state = false + * + * ``` + * + * @memberof IgxColumnGroupComponent + */ + @Input({ transform: booleanAttribute }) + public override set expanded(value: boolean) { + this._expanded = value; + this.expandedChange.emit(this._expanded); + if (!this.collapsible) { + return; + } + if (!this.hidden && this.children) { + this.setExpandCollapseState(); + } + } + public override get expanded() { + return this._expanded; + } + + /** + * Gets the column group `summaries`. + * ```typescript + * let columnGroupSummaries = this.columnGroup.summaries; + * ``` + * + * @memberof IgxColumnGroupComponent + */ + @Input() + public override get summaries(): any { + return this._summaries; + } + + /* blazorSuppress */ + /** + * Sets the column group `summaries`. + * ```typescript + * this.columnGroup.summaries = IgxNumberSummaryOperand; + * ``` + * + * @memberof IgxColumnGroupComponent + */ + public override set summaries(classRef: any) { } + + /* blazorSuppress */ + /** + * Sets/gets whether the column group is `searchable`. + * Default value is `true`. + * ```typescript + * let isSearchable = this.columnGroup.searchable; + * ``` + * ```html + * + * ``` + * + * @memberof IgxColumnGroupComponent + */ + @Input({ transform: booleanAttribute }) + public override searchable = true; + /** + * Gets the column group `filters`. + * ```typescript + * let columnGroupFilters = this.columnGroup.filters; + * ``` + * + * @memberof IgxColumnGroupComponent + */ + @Input() + public override get filters(): any { + return this._filters; + } + + /* blazorSuppress */ + /** + * Sets the column group `filters`. + * ```typescript + * this.columnGroup.filters = IgxStringFilteringOperand; + * ``` + * + * @memberof IgxColumnGroupComponent + */ + public override set filters(classRef: any) { } + + /** + * Returns if the column group is selectable + * ```typescript + * let columnGroupSelectable = this.columnGroup.selectable; + * ``` + * + * @memberof IgxColumnGroupComponent + */ + public override get selectable(): boolean { + return this.children && this.children.some(child => child.selectable); + } + + /** + * @hidden + */ + public override set selectable(value: boolean) { } + + /** + * @hidden + */ + public override get bodyTemplate(): TemplateRef { + return this._bodyTemplate; + } + /** + * @hidden + */ + public override set bodyTemplate(template: TemplateRef) { } + + /** + * Allows you to define a custom template for expand/collapse indicator + * + * @memberof IgxColumnGroupComponent + */ + @Input() + public override collapsibleIndicatorTemplate: TemplateRef; + + /** + * @hidden + */ + public override get inlineEditorTemplate(): TemplateRef { + return this._inlineEditorTemplate; + } + /** + * @hidden + */ + public override set inlineEditorTemplate(template: TemplateRef) { } + /** + * @hidden @internal + */ + public override get cells(): CellType[] { + return []; + } + /** + * Gets whether the column group is hidden. + * ```typescript + * let isHidden = this.columnGroup.hidden; + * ``` + * + * @memberof IgxColumnGroupComponent + */ + @Input({ transform: booleanAttribute }) + public override get hidden() { + return this.allChildren.every(c => c.hidden); + } + + /* blazorSuppress */ + /** + * Sets the column group hidden property. + * ```html + * + * ``` + * + * Two-way data binding + * ```html + * + * ``` + * + * @memberof IgxColumnGroupComponent + */ + public override set hidden(value: boolean) { + this._hidden = value; + this.hiddenChange.emit(this._hidden); + if (this._hidden || !this.collapsible) { + this.children.forEach(child => child.hidden = this._hidden); + } else { + this.children.forEach(c => { + if (c.visibleWhenCollapsed === undefined) { + c.hidden = false; return; + } + c.hidden = this.expanded ? c.visibleWhenCollapsed : !c.visibleWhenCollapsed; + }); + } + } + + /** + * Returns if the column group is selected. + * ```typescript + * let isSelected = this.columnGroup.selected; + * ``` + * + * @memberof IgxColumnGroupComponent + */ + public override get selected(): boolean { + const selectableChildren = this.allChildren.filter(c => !c.columnGroup && c.selectable && !c.hidden); + return selectableChildren.length > 0 && selectableChildren.every(c => c.selected); + } + + /* blazorSuppress */ + /** + * Select/deselect the column group. + * ```typescript + * this.columnGroup.selected = true; + * ``` + * + * @memberof IgxColumnGroupComponent + */ + public override set selected(value: boolean) { + if (this.selectable) { + this.children.forEach(c => { + c.selected = value; + }); + } + } + + /** + * @hidden + */ + public override ngAfterContentInit() { + /* + @ContentChildren with descendants still returns the `parent` + component in the query list. + */ + if (this.headTemplate && this.headTemplate.length) { + this._headerTemplate = this.headTemplate.toArray()[0].template; + } + if (this.collapseIndicatorTemplate) { + this.collapsibleIndicatorTemplate = this.collapseIndicatorTemplate.template; + } + // currently only ivy fixes the issue, we have to slice only if the first child is group + if (this.children.first === this) { + this.children.reset(this.children.toArray().slice(1)); + } + this.children.forEach(child => { + child.parent = this; + if (this.pinned) { + child.pinned = this.pinned; + } + if (this._hidden) { + child.hidden = this._hidden; + } + }); + if (this.collapsible) { + this.setExpandCollapseState(); + } + + this.children.changes + .pipe(takeUntil(this.destroy$)) + .subscribe((change: QueryList) => { + let shouldReinitPinning = false; + change.forEach(x => { + x.parent = this; + if (this.pinned && x.pinned !== this.pinned) { + shouldReinitPinning = true; + x.pinned = this.pinned; + } + }); + if (this.collapsible) { + this.setExpandCollapseState(); + } + if (shouldReinitPinning) { + (this.grid as any).initPinning(); + } + }); + + } + + /** + * A list containing all the child columns under this column (if any). + * Empty without children or if this column is not Group or Layout. + */ + public override get childColumns(): ColumnType[] { + return this.children.toArray(); + } + + /** @hidden @internal **/ + public override get allChildren(): IgxColumnComponent[] { + return flatten(this.children.toArray()); + } + /** + * Returns a boolean indicating if the column is a `ColumnGroup`. + * ```typescript + * let isColumnGroup = this.columnGroup.columnGroup + * ``` + * + * @memberof IgxColumnGroupComponent + */ + public override get columnGroup() { + return true; + } + /** + * Returns a boolean indicating if the column is a `ColumnLayout` for multi-row layout. + * ```typescript + * let columnGroup = this.column.columnGroup; + * ``` + * + * @memberof IgxColumnComponent + */ + public override get columnLayout() { + return false; + } + /** + * Gets the width of the column group. + * ```typescript + * let columnGroupWidth = this.columnGroup.width; + * ``` + * + * @memberof IgxColumnGroupComponent + */ + public override get width() { + const width = `${this.children.reduce((acc, val) => { + if (val.hidden) { + return acc; + } + return acc + parseFloat(val.calcWidth); + }, 0)}`; + return width + 'px'; + } + + /* blazorSuppress */ + public override set width(val) { } + + /** @hidden @internal **/ + public override get resolvedWidth() { + return this.width; + } + + /** + * @hidden + */ + public override get applySelectableClass(): boolean { + return this._applySelectableClass; + } + + /** + * @hidden + */ + public override set applySelectableClass(value: boolean) { + if (this.selectable) { + this._applySelectableClass = value; + this.children.forEach(c => { + c.applySelectableClass = value; + }); + } + } + + /** + * @hidden + * Calculates the number of visible columns, based on indexes of first and last visible columns. + */ + public override calcChildren(): number { + const visibleChildren = this.allChildren.filter(c => c.visibleIndex > -1); + const fi = visibleChildren[0].visibleIndex; + const li = visibleChildren[visibleChildren.length - 1].visibleIndex; + return li - fi + 1; + } +} diff --git a/projects/igniteui-angular/grids/core/src/columns/column-layout.component.ts b/projects/igniteui-angular/grids/core/src/columns/column-layout.component.ts new file mode 100644 index 00000000000..86781e45269 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/columns/column-layout.component.ts @@ -0,0 +1,171 @@ +import { + AfterContentInit, + Component, + ChangeDetectionStrategy, + forwardRef, + Input, + booleanAttribute +} from '@angular/core'; +import { IgxColumnComponent } from './column.component'; +import { IgxColumnGroupComponent } from './column-group.component'; + +/* blazorIndirectRender */ +/* blazorElement */ +/* omitModule */ +/* wcElementTag: igc-column-layout */ +/* additionalIdentifier: Children.Field */ +/* jsonAPIManageCollectionInMarkup */ +/** + * Column layout for declaration of Multi-row Layout + * + * @igxParent IgxGridComponent + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [{ provide: IgxColumnComponent, useExisting: forwardRef(() => IgxColumnLayoutComponent) }], + selector: 'igx-column-layout', + template: `@if (platform.isElements) { + + }`, + styles: `:host { display: none }`, + standalone: true +}) +export class IgxColumnLayoutComponent extends IgxColumnGroupComponent implements AfterContentInit { + /** @hidden @internal **/ + public childrenVisibleIndexes = []; + /** + * Gets the width of the column layout. + * ```typescript + * let columnGroupWidth = this.columnGroup.width; + * ``` + * + * @memberof IgxColumnGroupComponent + */ + public override get width(): any { + const width = this.getFilledChildColumnSizes(this.children).reduce((acc, val) => acc + parseFloat(val), 0); + return width; + } + + /* blazorSuppress */ + public override set width(val: any) { } + + public override get columnLayout() { + return true; + } + + /** + * @hidden + */ + public override getCalcWidth(): any { + let borderWidth = 0; + + if (this.headerGroup && this.headerGroup.hasLastPinnedChildColumn) { + const headerStyles = this.grid.document.defaultView.getComputedStyle(this.headerGroup.nativeElement.children[0]); + borderWidth = parseFloat(headerStyles.borderRightWidth); + } + + return super.getCalcWidth() + borderWidth; + } + + /** + * Gets the column visible index. + * If the column is not visible, returns `-1`. + * ```typescript + * let visibleColumnIndex = this.column.visibleIndex; + * ``` + * + * @memberof IgxColumnComponent + */ + public override get visibleIndex(): number { + if (!isNaN(this._vIndex)) { + return this._vIndex; + } + + const unpinnedColumns = this.grid.unpinnedColumns.filter(c => c.columnLayout && !c.hidden); + 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); + const vIndex = ordered.indexOf(this); + this._vIndex = vIndex; + return vIndex; + } + + /* + * Gets whether the column layout is hidden. + * ```typescript + * let isHidden = this.columnGroup.hidden; + * ``` + * @memberof IgxColumnGroupComponent + */ + @Input({ transform: booleanAttribute }) + public override get hidden() { + return this._hidden; + } + + /* blazorSuppress */ + /** + * Sets the column layout hidden property. + * ```typescript + * + * ``` + * + * @memberof IgxColumnGroupComponent + */ + public override set hidden(value: boolean) { + this._hidden = value; + this.children.forEach(child => child.hidden = value); + if (this.grid && this.grid.columns && this.grid.columns.length > 0) { + // reset indexes in case columns are hidden/shown runtime + const columns = this.grid && this.grid.pinnedColumns && this.grid.unpinnedColumns ? + this.grid.pinnedColumns.concat(this.grid.unpinnedColumns) : []; + if (!this._hidden && !columns.find(c => c.field === this.field)) { + this.grid.resetColumnCollections(); + } + this.grid.columns.filter(x => x.columnLayout).forEach(x => x.populateVisibleIndexes()); + } + } + + /** + * @hidden + */ + public override ngAfterContentInit() { + super.ngAfterContentInit(); + if (!this.hidden) { + this.hidden = this.allChildren.some(x => x.hidden); + } else { + this.children.forEach(child => child.hidden = this.hidden); + } + } + + /** @hidden @internal **/ + public get hasLastPinnedChildColumn() { + return this.children.some(child => child.isLastPinned); + } + + /** @hidden @internal **/ + public get hasFirstPinnedChildColumn() { + return this.children.some(child => child.isFirstPinned); + } + + /** + * @hidden + */ + public override populateVisibleIndexes() { + this.childrenVisibleIndexes = []; + const columns = this.grid?.pinnedColumns && this.grid?.unpinnedColumns + ? this.grid.pinnedStartColumns.concat(this.grid.unpinnedColumns, this.grid.pinnedEndColumns) + : []; + const orderedCols = columns + .filter(x => !x.columnGroup && !x.hidden) + .sort((a, b) => a.rowStart - b.rowStart || columns.indexOf(a.parent) - columns.indexOf(b.parent) || a.colStart - b.colStart); + this.children.forEach(child => { + const rs = child.rowStart || 1; + let vIndex = 0; + // filter out all cols with larger rowStart + const cols = orderedCols.filter(c => + !c.columnGroup && (c.rowStart || 1) <= rs); + vIndex = cols.indexOf(child); + this.childrenVisibleIndexes.push({ column: child, index: vIndex }); + }); + } +} diff --git a/projects/igniteui-angular/grids/core/src/columns/column.component.ts b/projects/igniteui-angular/grids/core/src/columns/column.component.ts new file mode 100644 index 00000000000..12768f29090 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/columns/column.component.ts @@ -0,0 +1,2765 @@ +import { Subject } from 'rxjs'; +import { isEqual } from 'lodash-es'; +import { AfterContentInit, ChangeDetectorRef, ChangeDetectionStrategy, Component, ContentChild, ContentChildren, Input, QueryList, TemplateRef, Output, EventEmitter, OnDestroy, booleanAttribute, inject } from '@angular/core'; +import { notifyChanges } from '../watch-changes'; +import { WatchColumnChanges } from '../watch-changes'; +import { IgxRowDirective } from '../row.directive'; +import { CellType, GridType, IgxCellTemplateContext, IgxColumnTemplateContext, IgxSummaryTemplateContext, IGX_GRID_BASE } from '../common/grid.interface'; +import { IgxGridHeaderComponent } from '../headers/grid-header.component'; +import { IgxGridFilteringCellComponent } from '../filtering/base/grid-filtering-cell.component'; +import { IgxGridHeaderGroupComponent } from '../headers/grid-header-group.component'; +import { + IgxSummaryOperand, IgxNumberSummaryOperand, IgxDateSummaryOperand, IgxTimeSummaryOperand +} from '../summaries/grid-summary'; +import { + IgxCellTemplateDirective, + IgxCellHeaderTemplateDirective, + IgxCellEditorTemplateDirective, + IgxCollapsibleIndicatorTemplateDirective, + IgxFilterCellTemplateDirective, + IgxSummaryTemplateDirective, + IgxCellValidationErrorDirective +} from './templates.directive'; +import { DropPosition } from '../moving/moving.service'; +import { IColumnVisibilityChangingEventArgs, IPinColumnCancellableEventArgs, IPinColumnEventArgs } from '../common/events'; +import { IgxGridCell } from '../grid-public-cell'; +import { NG_VALIDATORS, Validator } from '@angular/forms'; +import { ColumnPinningPosition, ColumnType, DefaultSortingStrategy, ExpressionsTreeUtil, FilteringExpressionsTree, GridColumnDataType, IColumnEditorOptions, IColumnPipeArgs, IgxBooleanFilteringOperand, IgxDateFilteringOperand, IgxDateTimeFilteringOperand, IgxFilteringOperand, IgxNumberFilteringOperand, IgxStringFilteringOperand, IgxSummaryResult, IgxTimeFilteringOperand, isConstructor, ISortingStrategy, MRLColumnSizeInfo, MRLResizeColumnInfo, PlatformUtil, ɵSize } from 'igniteui-angular/core'; +import type { IgxColumnLayoutComponent } from './column-layout.component'; + +const DEFAULT_DATE_FORMAT = 'mediumDate'; +const DEFAULT_TIME_FORMAT = 'mediumTime'; +const DEFAULT_DATE_TIME_FORMAT = 'medium'; +const DEFAULT_DIGITS_INFO = '1.0-3'; + +/* blazorElement */ +/* contentParent: ColumnGroup */ +/* wcElementTag: igc-column */ +/* additionalIdentifier: Field */ +/* blazorIndirectRender */ +/** + * **Ignite UI for Angular Column** - + * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/grid/grid#columns-configuration) + * + * The Ignite UI Column is used within an `igx-grid` element to define what data the column will show. Features such as sorting, + * filtering & editing are enabled at the column level. You can also provide a template containing custom content inside + * the column using `ng-template` which will be used for all cells within the column. + * + * @igxParent IgxGridComponent, IgxTreeGridComponent, IgxHierarchicalGridComponent, IgxPivotGridComponent, IgxRowIslandComponent, IgxColumnGroupComponent, IgxColumnLayoutComponent + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'igx-column', + template: ``, + styles: `:host { display: none }`, + standalone: true +}) +export class IgxColumnComponent implements AfterContentInit, OnDestroy, ColumnType { + public grid = inject(IGX_GRID_BASE); + private _validators = inject(NG_VALIDATORS, { optional: true, self: true }); + + /** @hidden @internal **/ + public cdr = inject(ChangeDetectorRef); + protected platform = inject(PlatformUtil); + + /** + * Sets/gets the `field` value. + * ```typescript + * let columnField = this.column.field; + * ``` + * ```html + * + * ``` + * + * @memberof IgxColumnComponent + */ + @Input() + public set field(value: string) { + this._field = value; + this.hasNestedPath = value?.includes('.'); + } + public get field(): string { + return this._field; + } + + /** + * Sets/gets whether to merge cells in this column. + * ```html + * + * ``` + * + */ + @Input() + 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; + } + if (value !== this._merge) { + this._merge = value; + if (this.grid) { + this.grid.resetColumnCollections(); + this.grid.notifyChanges(); + } + } + } + + /** + * @hidden @internal + */ + public validators: Validator[] = this._validators; + + /** + * Sets/gets the `header` value. + * ```typescript + * let columnHeader = this.column.header; + * ``` + * ```html + * + * ``` + * + * @memberof IgxColumnComponent + */ + @notifyChanges() + @WatchColumnChanges() + @Input() + public header = ''; + /** + * Sets/gets the `title` value. + * ```typescript + * let title = this.column.title; + * ``` + * ```html + * + * ``` + * + * @memberof IgxColumnComponent + */ + @notifyChanges() + @WatchColumnChanges() + @Input() + public title = ''; + /** + * Sets/gets whether the column is sortable. + * Default value is `false`. + * ```typescript + * let isSortable = this.column.sortable; + * ``` + * ```html + * + * ``` + * + * @memberof IgxColumnComponent + */ + @WatchColumnChanges() + @Input({ transform: booleanAttribute }) + public sortable = false; + /** + * Returns if the column is selectable. + * ```typescript + * let columnSelectable = this.column.selectable; + * ``` + * + * @memberof IgxColumnComponent + */ + @WatchColumnChanges() + @Input() + public get selectable(): boolean { + return this._selectable; + } + + /** + * Sets if the column is selectable. + * Default value is `true`. + * ```html + * + * ``` + * + * @memberof IgxColumnComponent + */ + public set selectable(value: boolean) { + this._selectable = value; + } + + /** + * Sets/gets whether the column is groupable. + * Default value is `false`. + * ```typescript + * let isGroupable = this.column.groupable; + * ``` + * ```html + * + * ``` + * + * @memberof IgxColumnComponent + */ + @notifyChanges(true) + @WatchColumnChanges() + @Input({ transform: booleanAttribute }) + public get groupable(): boolean { + return this._groupable; + } + public set groupable(value: boolean) { + this._groupable = value; + this.grid.groupablePipeTrigger++; + } + /** + * Gets whether the column is editable. + * Default value is `false`. + * ```typescript + * let isEditable = this.column.editable; + * ``` + * + * @memberof IgxColumnComponent + */ + @WatchColumnChanges() + @Input({ transform: booleanAttribute }) + public get editable(): boolean { + // Updating the primary key when grid has transactions (incl. row edit) + // should not be allowed, as that can corrupt transaction state. + const rowEditable = this.grid && this.grid.rowEditable; + const hasTransactions = this.grid && this.grid.transactions.enabled; + + if (this.isPrimaryColumn && (rowEditable || hasTransactions)) { + return false; + } + + if (this._editable !== undefined) { + return this._editable; + } else { + return rowEditable; + } + } + /** + * Sets whether the column is editable. + * ```typescript + * this.column.editable = true; + * ``` + * ```html + * + * ``` + * + * @memberof IgxColumnComponent + */ + public set editable(editable: boolean) { + this._editable = editable; + } + /** + * Sets/gets whether the column is filterable. + * Default value is `true`. + * ```typescript + * let isFilterable = this.column.filterable; + * ``` + * ```html + * + * ``` + * + * @memberof IgxColumnComponent + */ + @notifyChanges() + @WatchColumnChanges() + @Input({ transform: booleanAttribute }) + public filterable = true; + /** + * Sets/gets whether the column is resizable. + * Default value is `false`. + * ```typescript + * let isResizable = this.column.resizable; + * ``` + * ```html + * + * ``` + * + * @memberof IgxColumnComponent + */ + @WatchColumnChanges() + @Input({ transform: booleanAttribute }) + public resizable = false; + + /** + * Sets/gets whether the column header is included in autosize logic. + * Useful when template for a column header is sized based on parent, for example a default `div`. + * Default value is `false`. + * ```typescript + * let isResizable = this.column.resizable; + * ``` + * ```html + * + * ``` + * + * @memberof IgxColumnComponent + */ + @WatchColumnChanges() + @Input({ transform: booleanAttribute }) + public autosizeHeader = true; + + /** + * Gets a value indicating whether the summary for the column is enabled. + * ```typescript + * let hasSummary = this.column.hasSummary; + * ``` + * + * @memberof IgxColumnComponent + */ + @notifyChanges(true) + @WatchColumnChanges() + @Input({ transform: booleanAttribute }) + public get hasSummary() { + return this._hasSummary; + } + /** + * Sets a value indicating whether the summary for the column is enabled. + * Default value is `false`. + * ```html + * + * ``` + * + * @memberof IgxColumnComponent + */ + public set hasSummary(value) { + this._hasSummary = value; + + if (this.grid) { + this.grid.summaryService.resetSummaryHeight(); + } + } + /** + * Gets whether the column is hidden. + * ```typescript + * let isHidden = this.column.hidden; + * ``` + * + * @memberof IgxColumnComponent + */ + @notifyChanges(true) + @WatchColumnChanges() + @Input({ transform: booleanAttribute }) + public get hidden(): boolean { + return this._hidden; + } + /** + * Sets the column hidden property. + * Default value is `false`. + * ```html + * + * ``` + * + * Two-way data binding. + * ```html + * + * ``` + * + * @memberof IgxColumnComponent + */ + public set hidden(value: boolean) { + if (this._hidden !== value) { + this._hidden = value; + this.hiddenChange.emit(this._hidden); + if (this.columnLayoutChild && this.parent.hidden !== value) { + this.parent.hidden = value; + return; + } + if (this.grid) { + this.grid.crudService.endEdit(false); + this.grid.summaryService.resetSummaryHeight(); + this.grid.filteringService.refreshExpressions(); + this.grid.filteringService.hideFilteringRowOnColumnVisibilityChange(this); + this.grid.notifyChanges(); + } + } + } + + /** + * Returns if the column is selected. + * ```typescript + * let isSelected = this.column.selected; + * ``` + * + * @memberof IgxColumnComponent + */ + public get selected(): boolean { + return this.grid.selectionService.isColumnSelected(this.field); + } + + /** + * Select/deselect a column. + * Default value is `false`. + * ```typescript + * this.column.selected = true; + * ``` + * + * @memberof IgxColumnComponent + */ + public set selected(value: boolean) { + if (this.selectable && value !== this.selected) { + if (value) { + this.grid.selectionService.selectColumnsWithNoEvent([this.field]); + } else { + this.grid.selectionService.deselectColumnsWithNoEvent([this.field]); + } + this.grid.notifyChanges(); + } + } + + /** + * Emitted when the column is hidden or shown. + * + * ```html + * + * + * ``` + * + */ + @Output() + public hiddenChange = new EventEmitter(); + + /** + * Emitted when the column expanded or collapsed. + * + * ```html + * + * + * ``` + * + */ + @Output() + public expandedChange = new EventEmitter(); + + /** @hidden */ + @Output() + public collapsibleChange = new EventEmitter(); + + /** @hidden */ + @Output() + public visibleWhenCollapsedChange = new EventEmitter(); + + /** @hidden @internal */ + @Output() + public columnChange = new EventEmitter(); + + /** + * Gets whether the hiding is disabled. + * ```typescript + * let isHidingDisabled = this.column.disableHiding; + * ``` + * + * @memberof IgxColumnComponent + */ + @notifyChanges() + @WatchColumnChanges() + @Input({ transform: booleanAttribute }) + public disableHiding = false; + /** + * Gets whether the pinning is disabled. + * ```typescript + * let isPinningDisabled = this.column.disablePinning; + * ``` + * + * @memberof IgxColumnComponent + */ + @notifyChanges() + @WatchColumnChanges() + @Input({ transform: booleanAttribute }) + public disablePinning = false; + + /** + * Gets the `width` of the column. + * ```typescript + * let columnWidth = this.column.width; + * ``` + * + * @memberof IgxColumnComponent + */ + @notifyChanges(true) + @WatchColumnChanges() + @Input() + public get width(): string { + const isAutoWidth = this._width && typeof this._width === 'string' && this._width === 'auto'; + if (isAutoWidth) { + if (!this.autoSize) { + return 'fit-content'; + } else { + return this.autoSize + 'px'; + } + + } + return this.widthSetByUser ? this._width : this.defaultWidth; + } + + /** + * Sets the `width` of the column. + * ```html + * + * ``` + * + * Two-way data binding. + * ```html + * + * ``` + * + * @memberof IgxColumnComponent + */ + public set width(value: string) { + if (value) { + this._calcWidth = null; + this.calcPixelWidth = NaN; + this.widthSetByUser = true; + // width could be passed as number from the template + // host bindings are not px affixed so we need to ensure we affix simple number strings + if (typeof (value) === 'number' || value.match(/^[0-9]*$/)) { + value = value + 'px'; + } + if (value === 'fit-content') { + value = 'auto'; + } + this._width = value; + if (this.grid) { + this.cacheCalcWidth(); + } + this.widthChange.emit(this._width); + } + } + + /** @hidden @internal **/ + public autoSize: number; + + /** + * Sets/gets the maximum `width` of the column. + * ```typescript + * let columnMaxWidth = this.column.width; + * ``` + * ```html + * + * ``` + * + * @memberof IgxColumnComponent + */ + @WatchColumnChanges() + @Input() + public set maxWidth(value: string) { + this._maxWidth = value; + + this.grid.notifyChanges(true); + } + public get maxWidth(): string { + return this._maxWidth; + } + /** + * Sets/gets the class selector of the column header. + * ```typescript + * let columnHeaderClass = this.column.headerClasses; + * ``` + * ```html + * + * ``` + * + * @memberof IgxColumnComponent + */ + @notifyChanges() + @WatchColumnChanges() + @Input() + public headerClasses = ''; + + /** + * Sets conditional style properties on the column header. + * Similar to `ngStyle` it accepts an object literal where the keys are + * the style properties and the value is the expression to be evaluated. + * ```typescript + * styles = { + * background: 'royalblue', + * color: (column) => column.pinned ? 'red': 'inherit' + * } + * ``` + * ```html + * + * ``` + * + * @memberof IgxColumnComponent + */ + @notifyChanges() + @WatchColumnChanges() + @Input() + public headerStyles = null; + + /** + * Sets/gets the class selector of the column group header. + * ```typescript + * let columnHeaderClass = this.column.headerGroupClasses; + * ``` + * ```html + * + * ``` + * + * @memberof IgxColumnComponent + */ + @notifyChanges() + @WatchColumnChanges() + @Input() + public headerGroupClasses = ''; + + /** + * Sets conditional style properties on the column header group wrapper. + * Similar to `ngStyle` it accepts an object literal where the keys are + * the style properties and the value is the expression to be evaluated. + * ```typescript + * styles = { + * background: 'royalblue', + * color: (column) => column.pinned ? 'red': 'inherit' + * } + * ``` + * ```html + * + * ``` + * + * @memberof IgxColumnComponent + */ + @notifyChanges() + @WatchColumnChanges() + @Input() + public headerGroupStyles = null; + + /* treatAsRef */ + /** + * Sets a conditional class selector of the column cells. + * Accepts an object literal, containing key-value pairs, + * where the key is the name of the CSS class, while the + * value is either a callback function that returns a boolean, + * or boolean, like so: + * ```typescript + * callback = (rowData, columnKey, cellValue, rowIndex) => { return rowData[columnKey] > 6; } + * cellClasses = { 'className' : this.callback }; + * ``` + * ```html + * + * + * ``` + * + * @memberof IgxColumnComponent + */ + @notifyChanges() + @WatchColumnChanges() + @Input() + public cellClasses: any; + + /* treatAsRef */ + /** + * Sets conditional style properties on the column cells. + * Similar to `ngStyle` it accepts an object literal where the keys are + * the style properties and the value is the expression to be evaluated. + * As with `cellClasses` it accepts a callback function. + * ```typescript + * styles = { + * background: 'royalblue', + * color: (rowData, columnKey, cellValue, rowIndex) => value.startsWith('Important') ? 'red': 'inherit' + * } + * ``` + * ```html + * + * ``` + * + * @memberof IgxColumnComponent + */ + @notifyChanges() + @WatchColumnChanges() + @Input() + public cellStyles = null; + + /* blazorAlternateType: CellValueFormatterEventHandler */ + /* blazorOnlyScript */ + /** + * Applies display format to cell values in the column. Does not modify the underlying data. + * + * @remarks + * Note: As the formatter is used in places like the Excel style filtering dialog, in certain + * scenarios (remote filtering for example), the row data argument can be `undefined`. + * + * + * In this example, we check to see if the column name is Salary, and then provide a method as the column formatter + * to format the value into a currency string. + * + * @example + * ```typescript + * columnInit(column: IgxColumnComponent) { + * if (column.field == "Salary") { + * column.formatter = (salary => this.format(salary)); + * } + * } + * + * format(value: number) : string { + * return formatCurrency(value, "en-us", "$"); + * } + * ``` + * + * @example + * ```typescript + * const column = this.grid.getColumnByName('Address'); + * const addressFormatter = (address: string, rowData: any) => data.privacyEnabled ? 'unknown' : address; + * column.formatter = addressFormatter; + * ``` + * + * @memberof IgxColumnComponent + */ + @notifyChanges() + @WatchColumnChanges() + @Input() + public formatter: (value: any, rowData?: any) => any; + + /* blazorAlternateType: SummaryValueFormatterEventHandler */ + /* blazorOnlyScript */ + /* forceCastDelegate */ + /** + * The summaryFormatter is used to format the display of the column summaries. + * + * In this example, we check to see if the column name is OrderDate, and then provide a method as the summaryFormatter + * to change the locale for the dates to 'fr-FR'. The summaries with the count key are skipped so they are displayed as numbers. + * + * ```typescript + * columnInit(column: IgxColumnComponent) { + * if (column.field == "OrderDate") { + * column.summaryFormatter = this.summaryFormat; + * } + * } + * + * summaryFormat(summary: IgxSummaryResult, summaryOperand: IgxSummaryOperand): string { + * const result = summary.summaryResult; + * if(summaryResult.key !== 'count' && result !== null && result !== undefined) { + * const pipe = new DatePipe('fr-FR'); + * return pipe.transform(result,'mediumDate'); + * } + * return result; + * } + * ``` + * + * @memberof IgxColumnComponent + */ + @notifyChanges() + @WatchColumnChanges() + @Input() + public summaryFormatter: (summary: IgxSummaryResult, summaryOperand: IgxSummaryOperand) => any; + + /** + * Sets/gets whether the column filtering should be case sensitive. + * Default value is `true`. + * ```typescript + * let filteringIgnoreCase = this.column.filteringIgnoreCase; + * ``` + * ```html + * + * ``` + * + * @memberof IgxColumnComponent + */ + @WatchColumnChanges() + @Input({ transform: booleanAttribute }) + public filteringIgnoreCase = true; + /** + * Sets/gets whether the column sorting should be case sensitive. + * Default value is `true`. + * ```typescript + * let sortingIgnoreCase = this.column.sortingIgnoreCase; + * ``` + * ```html + * + * ``` + * + * @memberof IgxColumnComponent + */ + @WatchColumnChanges() + @Input({ transform: booleanAttribute }) + public sortingIgnoreCase = true; + /** + * Sets/gets whether the column is `searchable`. + * Default value is `true`. + * ```typescript + * let isSearchable = this.column.searchable'; + * ``` + * ```html + * + * ``` + * + * @memberof IgxColumnComponent + */ + @notifyChanges() + @WatchColumnChanges() + @Input({ transform: booleanAttribute }) + public searchable = true; + /** + * Sets/gets the data type of the column values. + * Default value is `string`. + * ```typescript + * let columnDataType = this.column.dataType; + * ``` + * ```html + * + * ``` + * + * @memberof IgxColumnComponent + */ + @Input() + public dataType: GridColumnDataType = GridColumnDataType.String; + + /** @hidden */ + @Input() + public collapsibleIndicatorTemplate: TemplateRef; + + /** + * Row index where the current field should end. + * The amount of rows between rowStart and rowEnd will determine the amount of spanning rows to that field + * ```html + * + * + * + * ``` + * + * @memberof IgxColumnComponent + */ + @Input() + public rowEnd: number; + + /** + * Column index where the current field should end. + * The amount of columns between colStart and colEnd will determine the amount of spanning columns to that field + * ```html + * + * + * + * ``` + * + * @memberof IgxColumnComponent + */ + @Input() + public colEnd: number; + + /** + * Row index from which the field is starting. + * ```html + * + * + * + * ``` + * + * @memberof IgxColumnComponent + */ + @Input() + public rowStart: number; + + /** + * Column index from which the field is starting. + * ```html + * + * + * + * ``` + * + * @memberof IgxColumnComponent + */ + @Input() + public colStart: number; + + /** + * Sets/gets custom properties provided in additional template context. + * + * ```html + * + * + * {{ props }} + * + * + * ``` + * + * @memberof IgxColumnComponent + */ + @Input() + public additionalTemplateContext: any; + + /** + * Emitted when the column width changes. + * + * ```html + * + * + * ``` + * + */ + @Output() + public widthChange = new EventEmitter(); + + /** + * Emitted when the column is pinned/unpinned. + * + * ```html + * + * + * ``` + * + */ + @Output() + public pinnedChange = new EventEmitter(); + /** + * @hidden + */ + @ContentChild(IgxFilterCellTemplateDirective, { read: IgxFilterCellTemplateDirective }) + public filterCellTemplateDirective: IgxFilterCellTemplateDirective; + /** + * @hidden + */ + @ContentChild(IgxSummaryTemplateDirective, { read: IgxSummaryTemplateDirective }) + protected summaryTemplateDirective: IgxSummaryTemplateDirective; + /** + * @hidden + * @see {@link bodyTemplate} + */ + @ContentChild(IgxCellTemplateDirective, { read: IgxCellTemplateDirective }) + protected cellTemplate: IgxCellTemplateDirective; + /** + * @hidden + */ + @ContentChild(IgxCellValidationErrorDirective, { read: IgxCellValidationErrorDirective }) + protected cellValidationErrorTemplate: IgxCellValidationErrorDirective; + /** + * @hidden + */ + @ContentChildren(IgxCellHeaderTemplateDirective, { read: IgxCellHeaderTemplateDirective, descendants: false }) + protected headTemplate: QueryList; + /** + * @hidden + */ + @ContentChild(IgxCellEditorTemplateDirective, { read: IgxCellEditorTemplateDirective }) + protected editorTemplate: IgxCellEditorTemplateDirective; + /** + * @hidden + */ + @ContentChild(IgxCollapsibleIndicatorTemplateDirective, { read: IgxCollapsibleIndicatorTemplateDirective, static: false }) + protected collapseIndicatorTemplate: IgxCollapsibleIndicatorTemplateDirective; + /** + * @hidden + */ + public get calcWidth(): any { + return this.getCalcWidth(); + } + + /** @hidden @internal **/ + public calcPixelWidth: number; + + /** + * @hidden + */ + public get maxWidthPx() { + const gridAvailableSize = this.grid.calcWidth; + const isPercentageWidth = this.maxWidth && typeof this.maxWidth === 'string' && this.maxWidth.indexOf('%') !== -1; + return isPercentageWidth ? parseFloat(this.maxWidth) / 100 * gridAvailableSize : parseFloat(this.maxWidth); + } + + /** + * @hidden + */ + public get maxWidthPercent() { + const gridAvailableSize = this.grid.calcWidth; + const isPercentageWidth = this.maxWidth && typeof this.maxWidth === 'string' && this.maxWidth.indexOf('%') !== -1; + return isPercentageWidth ? parseFloat(this.maxWidth) : parseFloat(this.maxWidth) / gridAvailableSize * 100; + } + + /** + * @hidden + */ + public get minWidthPx() { + const gridAvailableSize = this.grid.calcWidth; + const minWidth = this.minWidth || this.defaultMinWidth; + const isPercentageWidth = minWidth && typeof minWidth === 'string' && minWidth.indexOf('%') !== -1; + return isPercentageWidth ? parseFloat(minWidth) / 100 * gridAvailableSize : parseFloat(minWidth); + } + + /** + * @hidden + */ + public get userSetMinWidthPx() { + const gridAvailableSize = this.grid.calcWidth; + const isPercentageWidth = this._defaultMinWidth && typeof this._defaultMinWidth === 'string' && this._defaultMinWidth.indexOf('%') !== -1; + return isPercentageWidth ? parseFloat(this._defaultMinWidth) / 100 * gridAvailableSize : parseFloat(this._defaultMinWidth); + } + + /** + * @hidden + */ + public get minWidthPercent() { + const gridAvailableSize = this.grid.calcWidth; + const minWidth = this.minWidth || this.defaultMinWidth; + const isPercentageWidth = minWidth && typeof minWidth === 'string' && minWidth.indexOf('%') !== -1; + return isPercentageWidth ? parseFloat(minWidth) : parseFloat(minWidth) / gridAvailableSize * 100; + } + + + /** + * Sets/gets the minimum `width` of the column. + * Default value is `88`; + * ```typescript + * let columnMinWidth = this.column.minWidth; + * ``` + * ```html + * + * ``` + * + * @memberof IgxColumnComponent + */ + @notifyChanges() + @WatchColumnChanges() + @Input() + public set minWidth(value: string) { + const minVal = parseFloat(value); + if (Number.isNaN(minVal)) { + return; + } + this._defaultMinWidth = value; + this.grid.notifyChanges(true); + } + public get minWidth(): string { + return this._defaultMinWidth; + } + + /** @hidden @internal **/ + public get resolvedWidth(): string { + if (this.columnLayoutChild) { + return ''; + } + const isAutoWidth = this._width && typeof this._width === 'string' && this._width === 'auto'; + return isAutoWidth ? this.width : this.calcPixelWidth + 'px'; + } + + /** + * Gets the column index. + * ```typescript + * let columnIndex = this.column.index; + * ``` + * + * @memberof IgxColumnComponent + */ + public get index(): number { + return (this.grid as any)._columns.indexOf(this); + } + + /* mustCoerceToInt */ + /** + * 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 + * let isPinned = this.column.pinned; + * ``` + * + * @memberof IgxColumnComponent + */ + @WatchColumnChanges() + @Input({ transform: booleanAttribute }) + public get pinned(): boolean { + return this._pinned; + } + /** + * Sets whether the column is pinned. + * Default value is `false`. + * ```html + * + * ``` + * + * Two-way data binding. + * ```html + * + * ``` + * + * @memberof IgxColumnComponent + */ + public set pinned(value: boolean) { + if (this._pinned !== value) { + const isAutoWidth = this.width && typeof this.width === 'string' && this.width === 'fit-content'; + if (this.grid && this.width && (isAutoWidth || !isNaN(parseInt(this.width, 10)))) { + if (value) { + this.pin(); + } else { + this.unpin(); + } + return; + } + /* No grid/width available at initialization. `initPinning` in the grid + will re-init the group (if present) + */ + this._pinned = value; + this.pinnedChange.emit(this._pinned); + } + } + + /* treatAsRef */ + /** + * Gets the column `summaries`. + * ```typescript + * let columnSummaries = this.column.summaries; + * ``` + * + * @memberof IgxColumnComponent + */ + @notifyChanges(true) + @WatchColumnChanges() + @Input() + public get summaries(): any { + return this._summaries; + } + + /* treatAsRef */ + /** + * Sets the column `summaries`. + * ```typescript + * this.column.summaries = IgxNumberSummaryOperand; + * ``` + * + * @memberof IgxColumnComponent + */ + public set summaries(classRef: any) { + if (isConstructor(classRef)) { + this._summaries = new classRef(); + } + + if (this.grid) { + this.grid.summaryService.removeSummariesCachePerColumn(this.field); + this.grid.summaryPipeTrigger++; + this.grid.summaryService.resetSummaryHeight(); + } + } + + /** + * Sets/gets the summary operands to exclude from display. + * Accepts an array of string keys representing the summary types to disable, such as 'Min', 'Max', 'Count' etc. + * ```typescript + * let disabledSummaries = this.column.disabledSummaries; + * ``` + * ```html + * + * ``` + * + * @memberof IgxColumnComponent + */ + @WatchColumnChanges() + @Input() + public get disabledSummaries(): string[] { + return this._disabledSummaries; + } + + public set disabledSummaries(value: string[]) { + if (isEqual(this._disabledSummaries, value)) { + return; + } + this._disabledSummaries = value; + if (this.grid) { + this.grid.summaryService.removeSummariesCachePerColumn(this.field); + this.grid.summaryPipeTrigger++; + this.grid.summaryService.resetSummaryHeight(); + } + } + + /** + * Gets the column `filters`. + * ```typescript + * let columnFilters = this.column.filters' + * ``` + * + * @memberof IgxColumnComponent + */ + @Input() + public get filters(): IgxFilteringOperand { + return this._filters; + } + /** + * Sets the column `filters`. + * ```typescript + * this.column.filters = IgxBooleanFilteringOperand.instance(). + * ``` + * + * @memberof IgxColumnComponent + */ + public set filters(instance: IgxFilteringOperand) { + this._filters = instance; + } + /** + * Gets the column `sortStrategy`. + * ```typescript + * let sortStrategy = this.column.sortStrategy + * ``` + * + * @memberof IgxColumnComponent + */ + @Input() + public get sortStrategy(): ISortingStrategy { + return this._sortStrategy; + } + /** + * Sets the column `sortStrategy`. + * ```typescript + * this.column.sortStrategy = new CustomSortingStrategy(). + * class CustomSortingStrategy extends SortingStrategy {...} + * ``` + * + * @memberof IgxColumnComponent + */ + public set sortStrategy(classRef: ISortingStrategy) { + 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. + * ```typescript + * let groupingComparer = this.column.groupingComparer' + * ``` + * + * @memberof IgxColumnComponent + */ + @Input() + public get groupingComparer(): (a: any, b: any, currRec?: any, groupRec?: any) => number { + return this._groupingComparer; + } + + /* blazorSuppress */ + /** + * Sets a custom function to compare values for grouping. + * Subsequent values in the sorted data that the function returns 0 for are grouped. + * ```typescript + * this.column.groupingComparer = (a: any, b: any, currRec?: any, groupRec?: any) => { return a === b ? 0 : -1; } + * ``` + * + * @memberof IgxColumnComponent + */ + public set groupingComparer(funcRef: (a: any, b: any, currRec?: any, groupRec?: any) => number) { + this._groupingComparer = funcRef; + } + /** + * @hidden @internal + */ + public get defaultMinWidth(): string { + if (!this.grid) { + return '80'; + } + switch (this.grid.gridSize) { + case ɵSize.Medium: + return '64'; + case ɵSize.Small: + return '56'; + default: + return '80'; + } + } + /** + * Returns a reference to the `summaryTemplate`. + * ```typescript + * let summaryTemplate = this.column.summaryTemplate; + * ``` + * + * @memberof IgxColumnComponent + */ + @notifyChanges() + @WatchColumnChanges() + @Input() + public get summaryTemplate(): TemplateRef { + return this._summaryTemplate; + } + /** + * Sets the summary template. + * ```html + * + *

    {{ summaryResults[0].label }}: {{ summaryResults[0].summaryResult }}

    + *

    {{ summaryResults[1].label }}: {{ summaryResults[1].summaryResult }}

    + *
    + * ``` + * ```typescript + * @ViewChild("'summaryTemplate'", {read: TemplateRef }) + * public summaryTemplate: TemplateRef; + * this.column.summaryTemplate = this.summaryTemplate; + * ``` + * + * @memberof IgxColumnComponent + */ + public set summaryTemplate(template: TemplateRef) { + this._summaryTemplate = template; + } + + /** + * Returns a reference to the `bodyTemplate`. + * ```typescript + * let bodyTemplate = this.column.bodyTemplate; + * ``` + * + * @memberof IgxColumnComponent + */ + @notifyChanges() + @WatchColumnChanges() + @Input('cellTemplate') + public get bodyTemplate(): TemplateRef { + return this._bodyTemplate; + } + /** + * Sets the body template. + * ```html + * + *
    + * {{val}} + *
    + *
    + * ``` + * ```typescript + * @ViewChild("'bodyTemplate'", {read: TemplateRef }) + * public bodyTemplate: TemplateRef; + * this.column.bodyTemplate = this.bodyTemplate; + * ``` + * + * @memberof IgxColumnComponent + */ + public set bodyTemplate(template: TemplateRef) { + this._bodyTemplate = template; + } + /** + * Returns a reference to the header template. + * ```typescript + * let headerTemplate = this.column.headerTemplate; + * ``` + * + * @memberof IgxColumnComponent + */ + @notifyChanges() + @WatchColumnChanges() + @Input() + public get headerTemplate(): TemplateRef { + return this._headerTemplate; + } + /** + * Sets the header template. + * Note that the column header height is fixed and any content bigger than it will be cut off. + * ```html + * + *
    + * {{column.field}} + *
    + *
    + * ``` + * ```typescript + * @ViewChild("'headerTemplate'", {read: TemplateRef }) + * public headerTemplate: TemplateRef; + * this.column.headerTemplate = this.headerTemplate; + * ``` + * + * @memberof IgxColumnComponent + */ + public set headerTemplate(template: TemplateRef) { + this._headerTemplate = template; + } + /** + * Returns a reference to the inline editor template. + * ```typescript + * let inlineEditorTemplate = this.column.inlineEditorTemplate; + * ``` + * + * @memberof IgxColumnComponent + */ + @notifyChanges() + @WatchColumnChanges() + @Input('cellEditorTemplate') + public get inlineEditorTemplate(): TemplateRef { + return this._inlineEditorTemplate; + } + /** + * Sets the inline editor template. + * ```html + * + * + * + * ``` + * ```typescript + * @ViewChild("'inlineEditorTemplate'", {read: TemplateRef }) + * public inlineEditorTemplate: TemplateRef; + * this.column.inlineEditorTemplate = this.inlineEditorTemplate; + * ``` + * + * @memberof IgxColumnComponent + */ + public set inlineEditorTemplate(template: TemplateRef) { + this._inlineEditorTemplate = template; + } + + /** + * Returns a reference to the validation error template. + * ```typescript + * let errorTemplate = this.column.errorTemplate; + * ``` + */ + @notifyChanges() + @WatchColumnChanges() + @Input('errorTemplate') + public get errorTemplate(): TemplateRef { + return this._errorTemplate; + } + /** + * Sets the error template. + * ```html + * + *
    + * This name is forbidden. + *
    + *
    + * ``` + * ```typescript + * @ViewChild("'errorTemplate'", {read: TemplateRef }) + * public errorTemplate: TemplateRef; + * this.column.errorTemplate = this.errorTemplate; + * ``` + */ + public set errorTemplate(template: TemplateRef) { + this._errorTemplate = template; + } + + /** + * Returns a reference to the `filterCellTemplate`. + * ```typescript + * let filterCellTemplate = this.column.filterCellTemplate; + * ``` + * + * @memberof IgxColumnComponent + */ + @notifyChanges() + @WatchColumnChanges() + @Input('filterCellTemplate') + public get filterCellTemplate(): TemplateRef { + return this._filterCellTemplate; + } + /** + * Sets the quick filter template. + * ```html + * + * + * + * ``` + * ```typescript + * @ViewChild("'filterCellTemplate'", {read: TemplateRef }) + * public filterCellTemplate: TemplateRef; + * this.column.filterCellTemplate = this.filterCellTemplate; + * ``` + * + * @memberof IgxColumnComponent + */ + public set filterCellTemplate(template: TemplateRef) { + this._filterCellTemplate = template; + } + + /** + * @hidden @internal + */ + public get cells(): CellType[] { + return this.grid.dataView + .map((rec, index) => { + if (!this.grid.isGroupByRecord(rec) && !this.grid.isSummaryRow(rec)) { + this.grid.pagingMode === 'remote' && this.grid.page !== 0 ? + index = index + this.grid.perPage * this.grid.page : index = this.grid.dataRowList.first.index + index; + const cell = new IgxGridCell(this.grid as any, index, this); + return cell; + } + }).filter(cell => cell); + } + + + /** + * @hidden @internal + */ + public get _cells(): CellType[] { + return this.grid.rowList.filter((row) => row instanceof IgxRowDirective) + .map((row) => { + if (row._cells) { + return row._cells.filter((cell) => cell.columnIndex === this.index); + } + }).reduce((a, b) => a.concat(b), []); + } + + /** + * Gets the column visible index. + * If the column is not visible, returns `-1`. + * ```typescript + * let visibleColumnIndex = this.column.visibleIndex; + * ``` + */ + public get visibleIndex(): number { + if (!isNaN(this._vIndex)) { + return this._vIndex; + } + const unpinnedColumns = this.grid.unpinnedColumns.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; + + if (this.columnGroup) { + col = this.allChildren.filter(c => !c.columnGroup && !c.hidden)[0] as any; + } + if (this.columnLayoutChild) { + // TODO: Refactor/redo/remove this + return (this.parent as IgxColumnLayoutComponent).childrenVisibleIndexes.find(x => x.column === this).index; + } + + if (!this.pinned) { + const indexInCollection = unpinnedColumns.indexOf(col); + vIndex = indexInCollection === -1 ? + -1 : pinnedStartColumns.length + indexInCollection; + } else { + const indexInCollection = this.pinningPosition === ColumnPinningPosition.Start ? + pinnedStartColumns.indexOf(col) : pinnedEndColumns.indexOf(col); + vIndex = this.pinningPosition === ColumnPinningPosition.Start ? + indexInCollection : + pinnedStartColumns.length + unpinnedColumns.length + indexInCollection; + } + this._vIndex = vIndex; + return vIndex; + } + + /* blazorCSSuppress - Blazor doesn't carry over the ColumnType interface + should translate as static bool value */ + /** + * Returns a boolean indicating if the column is a `ColumnGroup`. + * ```typescript + * let columnGroup = this.column.columnGroup; + * ``` + * + * @memberof IgxColumnComponent + */ + public get columnGroup() { + return false; + } + + /* blazorCSSuppress - Blazor doesn't carry over the ColumnType interface + should translate as static bool value */ + /** + * Returns a boolean indicating if the column is a `ColumnLayout` for multi-row layout. + * ```typescript + * let columnGroup = this.column.columnGroup; + * ``` + * + * @memberof IgxColumnComponent + */ + public get columnLayout() { + return false; + } + + /** + * Returns a boolean indicating if the column is a child of a `ColumnLayout` for multi-row layout. + * ```typescript + * let columnLayoutChild = this.column.columnLayoutChild; + * ``` + * + * @memberof IgxColumnComponent + */ + public get columnLayoutChild(): boolean { + return this.parent && this.parent.columnLayout; + } + + /** + * A list containing all the child columns under this column (if any). + * Empty without children or if this column is not Group or Layout. + */ + public get childColumns(): ColumnType[] { + return []; + } + + /** @hidden @internal **/ + public get allChildren(): IgxColumnComponent[] { + return []; + } + /** + * Returns the level of the column in a column group. + * Returns `0` if the column doesn't have a `parent`. + * ```typescript + * let columnLevel = this.column.level; + * ``` + * + * @memberof IgxColumnComponent + */ + public get level() { + let ptr = this.parent; + let lvl = 0; + + while (ptr) { + lvl++; + ptr = ptr.parent; + } + return lvl; + } + + /** @hidden @internal **/ + public get isLastPinned(): boolean { + return this.pinningPosition === ColumnPinningPosition.Start && + this.grid.pinnedStartColumns[this.grid.pinnedStartColumns.length - 1] === this; + } + + /** @hidden @internal **/ + public get isFirstPinned(): boolean { + const pinnedCols = this.grid.pinnedEndColumns.filter(x => !x.columnGroup); + return this.pinningPosition === ColumnPinningPosition.End && pinnedCols[0] === this; + } + + /** @hidden @internal **/ + public get gridRowSpan(): number { + return this.rowEnd && this.rowStart ? this.rowEnd - this.rowStart : 1; + } + /** @hidden @internal **/ + public get gridColumnSpan(): number { + return this.colEnd && this.colStart ? this.colEnd - this.colStart : 1; + } + + /** + * Indicates whether the column will be visible when its parent is collapsed. + * ```html + * + * + * + * ``` + * + * @memberof IgxColumnComponent + */ + @notifyChanges(true) + @Input({ transform: booleanAttribute }) + public set visibleWhenCollapsed(value: boolean) { + this._visibleWhenCollapsed = value; + this.visibleWhenCollapsedChange.emit(this._visibleWhenCollapsed); + if (this.parent) { + // TODO: Refactor/redo/remove this + (this.parent as IgxColumnLayoutComponent)?.setExpandCollapseState?.(); + } + } + + public get visibleWhenCollapsed(): boolean { + return this._visibleWhenCollapsed; + } + + /* mustSetInCodePlatforms: WebComponents;Blazor;React */ + /** + * @remarks + * Pass optional parameters for DatePipe and/or DecimalPipe to format the display value for date and numeric columns. + * Accepts an `IColumnPipeArgs` object with any of the `format`, `timezone` and `digitsInfo` properties. + * For more details see https://angular.io/api/common/DatePipe and https://angular.io/api/common/DecimalPipe + * @example + * ```typescript + * const pipeArgs: IColumnPipeArgs = { + * format: 'longDate', + * timezone: 'UTC', + * digitsInfo: '1.1-2' + * } + * ``` + * ```html + * + * + * ``` + * @memberof IgxColumnComponent + */ + @notifyChanges() + @WatchColumnChanges() + @Input() + public set pipeArgs(value: IColumnPipeArgs) { + this._columnPipeArgs = Object.assign(this._columnPipeArgs, value); + this.grid.summaryService.clearSummaryCache(); + this.grid.pipeTrigger++; + } + /* mustSetInCodePlatforms: WebComponents;Blazor */ + public get pipeArgs(): IColumnPipeArgs { + return this._columnPipeArgs; + } + + /** + * Pass optional properties for the default column editors. + * @remarks + * Options may be applicable only to specific column type editors. + * @example + * ```typescript + * const editorOptions: IColumnEditorOptions = { + * dateTimeFormat: 'MM/dd/YYYY', + * } + * ``` + * ```html + * + * ``` + * @memberof IgxColumnComponent + */ + @notifyChanges() + @WatchColumnChanges() + @Input() + public set editorOptions(value: IColumnEditorOptions) { + this._editorOptions = value; + } + public get editorOptions(): IColumnEditorOptions { + return this._editorOptions; + } + + /** + * @hidden + * @internal + */ + public get collapsible() { + return false; + } + public set collapsible(_value: boolean) { } + + /** + * @hidden + * @internal + */ + public get expanded() { + return true; + } + public set expanded(_value: boolean) { } + + /** + * @hidden + */ + public defaultWidth: string; + + /** + * @hidden + */ + public widthSetByUser: boolean; + + /** + * @hidden + */ + public hasNestedPath: boolean; + + /** + * @hidden + * @internal + */ + public defaultTimeFormat = 'hh:mm:ss a'; + + /** + * @hidden + * @internal + */ + public defaultDateTimeFormat = 'dd/MM/yyyy HH:mm:ss a'; + + + /** + * Returns the filteringExpressionsTree of the column. + * ```typescript + * let tree = this.column.filteringExpressionsTree; + * ``` + * + * @memberof IgxColumnComponent + */ + public get filteringExpressionsTree(): FilteringExpressionsTree { + return ExpressionsTreeUtil.find(this.grid.filteringExpressionsTree, this.field) as FilteringExpressionsTree; + } + + /* alternateName: parentColumn */ + /** + * Sets/gets the parent column. + * ```typescript + * let parentColumn = this.column.parent; + * ``` + * ```typescript + * this.column.parent = higherLevelColumn; + * ``` + */ + public parent: ColumnType | null = null; + + /* blazorSuppress */ + /** + * Sets/gets the children columns. + * ```typescript + * let columnChildren = this.column.children; + * ``` + * + * @deprecated in version 18.1.0. Use the `childColumns` property instead. + */ + public children: QueryList; + /** + * @hidden + */ + public destroy$ = new Subject(); + + /** + * @hidden + */ + public widthConstrained = false; + + /** + * @hidden + */ + protected _applySelectableClass = false; + + protected _vIndex = NaN; + protected _pinningPosition = null; + /** + * @hidden + */ + protected _pinned = false; + /** + * @hidden + */ + protected _bodyTemplate: TemplateRef; + /** + * @hidden + */ + protected _errorTemplate: TemplateRef; + /** + * @hidden + */ + protected _headerTemplate: TemplateRef; + /** + * @hidden + */ + protected _summaryTemplate: TemplateRef; + /** + * @hidden + */ + protected _inlineEditorTemplate: TemplateRef; + /** + * @hidden + */ + protected _filterCellTemplate: TemplateRef; + /** + * @hidden + */ + protected _summaries = null; + /** + * @hidden + */ + private _disabledSummaries: string[] = []; + /** + * @hidden + */ + protected _filters = null; + /** + * @hidden + */ + protected _sortStrategy: ISortingStrategy = DefaultSortingStrategy.instance(); + /** + * @hidden + */ + protected _groupingComparer: (a: any, b: any, currRec?: any, groupRec?: any) => number; + + protected _mergingComparer: (prevRecord: any, record: any, field: string) => boolean; + /** + * @hidden + */ + protected _hidden = false; + /** + * @hidden + */ + protected _index: number; + /** + * @hidden + */ + protected _disablePinning = false; + /** + * @hidden + */ + protected _width: string; + /** + * @hidden + */ + protected _defaultMinWidth = ''; + /** + * @hidden + */ + protected _maxWidth; + /** + * @hidden + */ + protected _hasSummary = false; + /** + * @hidden + */ + protected _editable: boolean; + /** + * @hidden + */ + protected _groupable = false; + /** + * @hidden + */ + protected _merge = false; + /** + * @hidden + */ + protected _visibleWhenCollapsed; + /** + * @hidden + */ + protected _collapsible = false; + /** + * @hidden + */ + protected _expanded = true; + /** + * @hidden + */ + protected _selectable = true; + /** + * @hidden + */ + protected get isPrimaryColumn(): boolean { + return this.field !== undefined && this.grid !== undefined && this.field === this.grid.primaryKey; + } + + private _field: string; + private _calcWidth = null; + private _columnPipeArgs: IColumnPipeArgs = { digitsInfo: DEFAULT_DIGITS_INFO }; + private _editorOptions: IColumnEditorOptions = { }; + + /** + * @hidden + * @internal + */ + public resetCaches() { + this._vIndex = NaN; + if (this.grid) { + this.cacheCalcWidth(); + } + } + + /** + * @hidden + */ + public ngOnDestroy() { + this.destroy$.next(true); + this.destroy$.complete(); + } + /** + * @hidden + */ + public ngAfterContentInit(): void { + if (this.summaryTemplateDirective) { + this._summaryTemplate = this.summaryTemplateDirective.template; + } + if (this.cellTemplate) { + this._bodyTemplate = this.cellTemplate.template; + } + if (this.cellValidationErrorTemplate) { + this._errorTemplate = this.cellValidationErrorTemplate.template; + } + if (this.headTemplate && this.headTemplate.length) { + this._headerTemplate = this.headTemplate.toArray()[0].template; + } + if (this.editorTemplate) { + this._inlineEditorTemplate = this.editorTemplate.template; + } + if (this.filterCellTemplateDirective) { + this._filterCellTemplate = this.filterCellTemplateDirective.template; + } + if (!this._columnPipeArgs.format) { + this._columnPipeArgs.format = this.dataType === GridColumnDataType.Time ? + DEFAULT_TIME_FORMAT : this.dataType === GridColumnDataType.DateTime ? + DEFAULT_DATE_TIME_FORMAT : DEFAULT_DATE_FORMAT; + } + if (!this.summaries) { + switch (this.dataType) { + case GridColumnDataType.Number: + case GridColumnDataType.Currency: + case GridColumnDataType.Percent: + this.summaries = IgxNumberSummaryOperand; + break; + case GridColumnDataType.Date: + case GridColumnDataType.DateTime: + this.summaries = IgxDateSummaryOperand; + break; + case GridColumnDataType.Time: + this.summaries = IgxTimeSummaryOperand; + break; + + case GridColumnDataType.String: + case GridColumnDataType.Boolean: + default: + this.summaries = IgxSummaryOperand; + break; + } + } + if (!this.filters) { + switch (this.dataType) { + case GridColumnDataType.Boolean: + this.filters = IgxBooleanFilteringOperand.instance(); + break; + case GridColumnDataType.Number: + case GridColumnDataType.Currency: + case GridColumnDataType.Percent: + this.filters = IgxNumberFilteringOperand.instance(); + break; + case GridColumnDataType.Date: + this.filters = IgxDateFilteringOperand.instance(); + break; + case GridColumnDataType.Time: + this.filters = IgxTimeFilteringOperand.instance(); + break; + case GridColumnDataType.DateTime: + this.filters = IgxDateTimeFilteringOperand.instance(); + break; + case GridColumnDataType.Image: + this.filterable = false; + break; + case GridColumnDataType.String: + default: + this.filters = IgxStringFilteringOperand.instance(); + break; + } + } + } + + /** + * @hidden + */ + public getGridTemplate(isRow: boolean): string { + if (isRow) { + const rowsCount = this.grid.type !== 'pivot' ? this.grid.multiRowLayoutRowSize : this.children.length - 1; + return `repeat(${rowsCount},1fr)`; + } else { + return this.getColumnSizesString(this.children); + } + } + + /** @hidden @internal **/ + public getInitialChildColumnSizes(children: QueryList): Array { + const columnSizes: MRLColumnSizeInfo[] = []; + // find the smallest col spans + children.forEach(col => { + if (!col.colStart) { + return; + } + const newWidthSet = col.widthSetByUser && columnSizes[col.colStart - 1] && !columnSizes[col.colStart - 1].widthSetByUser; + const newSpanSmaller = columnSizes[col.colStart - 1] && columnSizes[col.colStart - 1].colSpan > col.gridColumnSpan; + const bothWidthsSet = col.widthSetByUser && columnSizes[col.colStart - 1] && columnSizes[col.colStart - 1].widthSetByUser; + const bothWidthsNotSet = !col.widthSetByUser && columnSizes[col.colStart - 1] && !columnSizes[col.colStart - 1].widthSetByUser; + + if (columnSizes[col.colStart - 1] === undefined) { + // If nothing is defined yet take any column at first + // We use colEnd to know where the column actually ends, because not always it starts where we have it set in columnSizes. + columnSizes[col.colStart - 1] = { + ref: col, + width: col.width === 'fit-content' ? col.autoSize : + col.widthSetByUser || this.grid.columnWidthSetByUser ? parseFloat(col.calcWidth) : null, + colSpan: col.gridColumnSpan, + colEnd: col.colStart + col.gridColumnSpan, + widthSetByUser: col.widthSetByUser + }; + } else if (newWidthSet || (newSpanSmaller && ((bothWidthsSet) || (bothWidthsNotSet)))) { + // If a column is set already it should either not have width defined or have width with bigger span than the new one. + + /** + * If replaced column has bigger span, we want to fill the remaining columns + * that the replacing column does not fill with the old one. + */ + if (bothWidthsSet && newSpanSmaller) { + // Start from where the new column set would end and apply the old column to the rest depending on how much it spans. + // We have not yet replaced it so we can use it directly from the columnSizes collection. + // This is where colEnd is used because the colStart of the old column is not actually i + 1. + for (let i = col.colStart - 1 + col.gridColumnSpan; i < columnSizes[col.colStart - 1].colEnd - 1; i++) { + if (!columnSizes[i] || !columnSizes[i].widthSetByUser) { + columnSizes[i] = columnSizes[col.colStart - 1]; + } else { + break; + } + } + } + + // Replace the old column with the new one. + columnSizes[col.colStart - 1] = { + ref: col, + width: col.width === 'fit-content' ? col.autoSize : + col.widthSetByUser || this.grid.columnWidthSetByUser ? parseFloat(col.calcWidth) : null, + colSpan: col.gridColumnSpan, + colEnd: col.colStart + col.gridColumnSpan, + widthSetByUser: col.widthSetByUser + }; + } else if (bothWidthsSet && columnSizes[col.colStart - 1].colSpan < col.gridColumnSpan) { + // If the column already in the columnSizes has smaller span, we still need to fill any empty places with the current col. + // Start from where the smaller column set would end and apply the bigger column to the rest depending on how much it spans. + // Since here we do not have it in columnSizes we set it as a new column keeping the same colSpan. + for (let i = col.colStart - 1 + columnSizes[col.colStart - 1].colSpan; i < col.colStart - 1 + col.gridColumnSpan; i++) { + if (!columnSizes[i] || !columnSizes[i].widthSetByUser) { + columnSizes[i] = { + ref: col, + width: col.width === 'fit-content' ? col.autoSize : + col.widthSetByUser || this.grid.columnWidthSetByUser ? parseFloat(col.calcWidth) : null, + colSpan: col.gridColumnSpan, + colEnd: col.colStart + col.gridColumnSpan, + widthSetByUser: col.widthSetByUser + }; + } else { + break; + } + } + } + }); + + // Flatten columnSizes so there are not columns with colSpan > 1 + for (let i = 0; i < columnSizes.length; i++) { + if (columnSizes[i] && columnSizes[i].colSpan > 1) { + let j = 1; + + // Replace all empty places depending on how much the current column spans starting from next col. + for (; j < columnSizes[i].colSpan && i + j + 1 < columnSizes[i].colEnd; j++) { + if (columnSizes[i + j] && + ((!columnSizes[i].width && columnSizes[i + j].width) || + (!columnSizes[i].width && !columnSizes[i + j].width && columnSizes[i + j].colSpan <= columnSizes[i].colSpan) || + (!!columnSizes[i + j].width && columnSizes[i + j].colSpan <= columnSizes[i].colSpan))) { + // If we reach an already defined column that has width and the current doesn't have or + // if the reached column has bigger colSpan we stop. + break; + } else { + const width = columnSizes[i].widthSetByUser ? + columnSizes[i].width / columnSizes[i].colSpan : + columnSizes[i].width; + columnSizes[i + j] = { + ref: columnSizes[i].ref, + width, + colSpan: 1, + colEnd: columnSizes[i].colEnd, + widthSetByUser: columnSizes[i].widthSetByUser + }; + } + } + + // Update the current column width so it is divided between all columns it spans and set it to 1. + columnSizes[i].width = columnSizes[i].widthSetByUser ? + columnSizes[i].width / columnSizes[i].colSpan : + columnSizes[i].width; + columnSizes[i].colSpan = 1; + + // Update the index based on how much we have replaced. Subtract 1 because we started from 1. + i += j - 1; + } + } + + return columnSizes; + } + + /** @hidden @internal **/ + public getFilledChildColumnSizes(children: QueryList): Array { + const columnSizes = this.getInitialChildColumnSizes(children); + + // fill the gaps if there are any + const result: string[] = []; + for (const size of columnSizes) { + if (size && !!size.width) { + result.push(size.width + 'px'); + } else { + const currentWidth = parseFloat(this.grid.getPossibleColumnWidth()); + result.push((this.getConstrainedSizePx(currentWidth)) + 'px'); + } + } + return result; + } + + /** @hidden @internal **/ + public getResizableColUnderEnd(): MRLResizeColumnInfo[] { + if (this.columnLayout || !this.columnLayoutChild || this.columnGroup) { + return [{ target: this, spanUsed: 1 }]; + } + + const columnSized = this.getInitialChildColumnSizes(this.parent.children as QueryList); + const targets: MRLResizeColumnInfo[] = []; + const colEnd = this.colEnd ? this.colEnd : this.colStart + 1; + + for (let i = 0; i < columnSized.length; i++) { + if (this.colStart <= i + 1 && i + 1 < colEnd) { + targets.push({ target: columnSized[i].ref, spanUsed: 1 }); + } + } + + const targetsSquashed: MRLResizeColumnInfo[] = []; + for (const target of targets) { + if (targetsSquashed.length && targetsSquashed[targetsSquashed.length - 1].target.field === target.target.field) { + targetsSquashed[targetsSquashed.length - 1].spanUsed++; + } else { + targetsSquashed.push(target); + } + } + + return targetsSquashed; + } + + /** + * 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 + * ```typescript + * let success = this.column.pin(); + * ``` + * + * @memberof IgxColumnComponent + */ + 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); + if (this._pinned) { + return false; + } + + if (this.parent && !this.parent.pinned) { + 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; + } + + if (!this.parent && !this.pinnable) { + return false; + } + + 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); + + if (args.cancel) { + return; + } + + 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 === pinningCollection.length ? + pinningCollection[args.insertAtIndex - 1] : pinningCollection[args.insertAtIndex]; + + if (pinningCollection.indexOf(this) === -1) { + if (!grid.hasColumnGroups) { + pinningCollection.splice(args.insertAtIndex, 0, this); + grid._pinnedColumns = grid._pinnedStartColumns.concat(grid._pinnedEndColumns); + } else { + // insert based only on root collection + if (this.level === 0) { + rootPinnedCols.splice(args.insertAtIndex, 0, this); + } + let allPinned = []; + // FIX: this is duplicated on every step in the hierarchy.... + // re-create hierarchy + rootPinnedCols.forEach(group => { + allPinned.push(group); + 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) { + const childrenCount = this.allChildren.length; + grid._unpinnedColumns.splice(grid._unpinnedColumns.indexOf(this), 1 + childrenCount); + } + } + + if (hasIndex) { + 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(null, targetPinPosition)); + grid.reinitPinStates(); + } + + grid.resetCaches(); + grid.notifyChanges(); + if (this.columnLayoutChild) { + this.grid.columns.filter(x => x.columnLayout).forEach(x => x.populateVisibleIndexes()); + } + this.grid.filteringService.refreshExpressions(); + const eventArgs: IPinColumnEventArgs = { column: this, insertAtIndex: index, isPinned: true }; + this.grid.columnPinned.emit(eventArgs); + return true; + } + /** + * Unpins the column and place it at the provided index in the unpinned area. + * Defaults to index `0` if not provided, or to the initial index in the unpinned area. + * Returns `true` if the column is successfully unpinned. Returns `false` if the column cannot be unpinned. + * Column cannot be unpinned if: + * - Is already unpinned + * - index argument is out of range + * ```typescript + * let success = this.column.unpin(); + * ``` + * + * @memberof IgxColumnComponent + */ + public unpin(index?: number): boolean { + const grid = (this.grid as any); + if (!this._pinned) { + return false; + } + + if (this.parent && this.parent.pinned) { + return this.topLevelParent.unpin(index); + } + const hasIndex = index !== undefined && index !== null; + if (hasIndex && (index < 0 || index > grid._unpinnedColumns.length)) { + return false; + } + + // estimate the exact index at which column will be inserted + // takes into account initial unpinned index of the column + if (!hasIndex) { + const indices = grid._unpinnedColumns.map(col => col.index); + indices.push(this.index); + indices.sort((a, b) => a - b); + index = indices.indexOf(this.index); + } + + const args: IPinColumnCancellableEventArgs = { column: this, insertAtIndex: index, isPinned: true, cancel: false }; + this.grid.columnPin.emit(args); + + if (args.cancel) { + return; + } + + this.grid.crudService.endEdit(false); + + this._pinned = false; + 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._unpinnedColumns.length ? + grid._unpinnedColumns[args.insertAtIndex - 1] : grid._unpinnedColumns[args.insertAtIndex]; + + if (!hasIndex) { + grid._unpinnedColumns.splice(index, 0, this); + 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) { + grid.moveColumn(this, targetColumn); + } + + if (this.columnGroup) { + this.allChildren.forEach(child => child.unpin()); + } + + grid.reinitPinStates(); + grid.resetCaches(); + + grid.notifyChanges(); + if (this.columnLayoutChild) { + this.grid.columns.filter(x => x.columnLayout).forEach(x => x.populateVisibleIndexes()); + } + this.grid.filteringService.refreshExpressions(); + + this.grid.columnPinned.emit({ column: this, insertAtIndex: index, isPinned: false }); + + return true; + } + + /** + * Moves a column to the specified visible index. + * If passed index is invalid, or if column would receive a different visible index after moving, moving is not performed. + * If passed index would move the column to a different column group. moving is not performed. + * + * @example + * ```typescript + * column.move(index); + * ``` + * @memberof IgxColumnComponent + */ + public move(index: number) { + let target; + let columns = this.grid.columns.filter(c => c.visibleIndex > -1); + // grid last visible index + const li = columns.map(c => c.visibleIndex).reduce((a, b) => Math.max(a, b)); + const parent = this.parent; + const isPreceding = this.visibleIndex < index; + + if (index === this.visibleIndex || index < 0 || index > li) { + return; + } + + if (parent) { + columns = columns.filter(c => c.level >= this.level && c !== this && c.parent !== this && + c.topLevelParent === this.topLevelParent); + } + + // If isPreceding, find a target such that when the current column is placed after it, current colummn will receive a visibleIndex === index. This takes into account visible children of the columns. + // If !isPreceding, finds a column of the same level and visible index that equals the passed index agument (c.visibleIndex === index). No need to consider the children here. + + if (isPreceding) { + columns = columns.filter(c => c.visibleIndex > this.visibleIndex); + target = columns.find(c => c.level === this.level && c.visibleIndex + (c as any).calcChildren() - this.calcChildren() === index); + } else { + columns = columns.filter(c => c.visibleIndex < this.visibleIndex); + target = columns.find(c => c.level === this.level && c.visibleIndex === index); + } + + if (!target || (target.pinned && this.disablePinning)) { + return; + } + + const pos = isPreceding ? DropPosition.AfterDropTarget : DropPosition.BeforeDropTarget; + this.grid.moveColumn(this, target as IgxColumnComponent, pos); + } + + /** + * No children for the column, so will returns 1 or 0, if the column is hidden. + * + * @hidden + */ + public calcChildren(): number { + const children = this.hidden ? 0 : 1; + return children; + } + + /** + * Toggles column vibisility and emits the respective event. + * + * @hidden + */ + public toggleVisibility(value?: boolean) { + const newValue = value ?? !this.hidden; + const eventArgs: IColumnVisibilityChangingEventArgs = { column: this, newValue, cancel: false }; + this.grid.columnVisibilityChanging.emit(eventArgs); + + if (eventArgs.cancel) { + return; + } + this.hidden = newValue; + this.grid.columnVisibilityChanged.emit({ column: this, newValue }); + } + + /** + * Returns a reference to the top level parent column. + * ```typescript + * let topLevelParent = this.column.topLevelParent; + * ``` + */ + public get topLevelParent(): ColumnType | undefined { + let parent = this.parent; + while (parent && parent.parent) { + parent = parent.parent; + } + return parent ?? undefined; + } + + /** + * @hidden @internal + */ + public get headerCell(): IgxGridHeaderComponent { + return this.grid.headerCellList.find((header) => header.column === this); + } + + /** + * @hidden @internal + */ + public get filterCell(): IgxGridFilteringCellComponent { + return this.grid.filterCellList.find((filterCell) => filterCell.column === this); + } + + /** + * @hidden @internal + */ + public get headerGroup(): IgxGridHeaderGroupComponent { + return this.grid.headerGroupsList.find(group => group.column === this); + } + + /** + * Autosize the column to the longest currently visible cell value, including the header cell. + * ```typescript + * @ViewChild('grid') grid: IgxGridComponent; + * let column = this.grid.columnList.filter(c => c.field === 'ID')[0]; + * column.autosize(); + * ``` + * + * @memberof IgxColumnComponent + * @param byHeaderOnly Set if column should be autosized based only on the header content. + */ + public autosize(byHeaderOnly = false) { + if (!this.columnGroup) { + this.width = this.getAutoSize(byHeaderOnly); + this.grid.reflow(); + } + } + + /** + * @hidden + */ + public getAutoSize(byHeader = false): string { + const size = !byHeader ? this.getLargestCellWidth() : + (Object.values(this.getHeaderCellWidths()).reduce((a, b) => a + b) + 'px'); + const isPercentageWidth = this.width && typeof this.width === 'string' && this.width.indexOf('%') !== -1; + + let newWidth; + if (isPercentageWidth) { + const gridAvailableSize = this.grid.calcWidth; + const percentageSize = parseFloat(size) / gridAvailableSize * 100; + newWidth = percentageSize + '%'; + } else { + newWidth = size; + } + + const maxWidth = isPercentageWidth ? this.maxWidthPercent : this.maxWidthPx; + const minWidth = isPercentageWidth ? this.minWidthPercent : this.minWidthPx; + if (this.maxWidth && (parseFloat(newWidth) > maxWidth)) { + newWidth = isPercentageWidth ? maxWidth + '%' : maxWidth + 'px'; + } else if (parseFloat(newWidth) < minWidth) { + newWidth = isPercentageWidth ? minWidth + '%' : minWidth + 'px'; + } + + return newWidth; + } + + /** + * @hidden + */ + public getCalcWidth(): any { + if (this._calcWidth && !isNaN(this.calcPixelWidth)) { + return this._calcWidth; + } + this.cacheCalcWidth(); + return this._calcWidth; + } + + + /** + * @hidden + * Returns the width and padding of a header cell. + */ + public getHeaderCellWidths() { + return this.grid.getHeaderCellWidth(this.headerCell.nativeElement); + } + + /** + * @hidden + * Returns the size (in pixels) of the longest currently visible cell, including the header cell. + * ```typescript + * @ViewChild('grid') grid: IgxGridComponent; + * + * let column = this.grid.columnList.filter(c => c.field === 'ID')[0]; + * let size = column.getLargestCellWidth(); + * ``` + * @memberof IgxColumnComponent + */ + public getLargestCellWidth(): string { + const range = this.grid.document.createRange(); + const largest = new Map(); + + if (this._cells.length > 0) { + const cellsContentWidths = []; + this._cells.forEach((cell) => cellsContentWidths.push(cell.calculateSizeToFit(range))); + + const index = cellsContentWidths.indexOf(Math.max(...cellsContentWidths)); + const cellStyle = this.grid.document.defaultView.getComputedStyle(this._cells[index].nativeElement); + const cellPadding = parseFloat(cellStyle.paddingLeft) + parseFloat(cellStyle.paddingRight) + + parseFloat(cellStyle.borderLeftWidth) + parseFloat(cellStyle.borderRightWidth); + + largest.set(Math.max(...cellsContentWidths), cellPadding); + } + + if (this.headerCell && this.autosizeHeader) { + const headerCellWidths = this.getHeaderCellWidths(); + largest.set(headerCellWidths.width, headerCellWidths.padding); + } + + const largestCell = Math.max(...Array.from(largest.keys())); + const width = Math.ceil(largestCell + largest.get(largestCell)); + + if (Number.isNaN(width)) { + return this.width; + } else { + return width + 'px'; + } + } + + /** + * @hidden + */ + public getCellWidth() { + const colWidth = this.width; + const isPercentageWidth = colWidth && typeof colWidth === 'string' && colWidth.indexOf('%') !== -1; + + if (this.columnLayoutChild) { + return ''; + } + + if (colWidth && !isPercentageWidth) { + + let cellWidth = colWidth; + if (typeof cellWidth !== 'string' || cellWidth.endsWith('px') === false) { + cellWidth += 'px'; + } + + return cellWidth; + } else { + return colWidth; + } + } + + /** + * @hidden + */ + public populateVisibleIndexes() { } + + protected getColumnSizesString(children: QueryList): string { + const res = this.getFilledChildColumnSizes(children); + return res.join(' '); + } + + /** + * @hidden + * @internal + */ + public getConstrainedSizePx(newSize){ + if (this.maxWidth && newSize > this.maxWidthPx) { + this.widthConstrained = true; + return this.maxWidthPx; + } else if (this.minWidth && newSize < this.userSetMinWidthPx) { + this.widthConstrained = true; + return this.userSetMinWidthPx; + } else { + this.widthConstrained = false; + return newSize; + } + } + + /** + * @hidden + * @internal + */ + protected cacheCalcWidth(): any { + const colWidth = this.width; + const isPercentageWidth = colWidth && typeof colWidth === 'string' && colWidth.indexOf('%') !== -1; + const isAutoWidth = colWidth && typeof colWidth === 'string' && colWidth === 'fit-content'; + if (isPercentageWidth && this.grid.isColumnWidthSum) { + this._calcWidth = this.userSetMinWidthPx ? this.userSetMinWidthPx : this.grid.minColumnWidth; + } else if (isPercentageWidth) { + const currentCalcWidth = parseFloat(colWidth) / 100 * this.grid.calcWidth; + this._calcWidth = this.grid.calcWidth ? this.getConstrainedSizePx(currentCalcWidth) : 0; + } else if (!colWidth || isAutoWidth && !this.autoSize) { + // no width + const currentCalcWidth = this.defaultWidth || this.grid.getPossibleColumnWidth(); + this._calcWidth = this.getConstrainedSizePx(currentCalcWidth); + } else { + 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); + } + + /** + * @hidden + * @internal + */ + protected setExpandCollapseState() { + this.children.filter(col => (col.visibleWhenCollapsed !== undefined)).forEach(c => { + if (!this.collapsible) { + c.hidden = this.hidden; return; + } + c.hidden = this._expanded ? c.visibleWhenCollapsed : !c.visibleWhenCollapsed; + }); + } + /** + * @hidden + * @internal + */ + protected checkCollapsibleState() { + if (!this.children) { + return false; + } + const cols = this.children.map(child => child.visibleWhenCollapsed); + return (cols.some(c => c === true) && cols.some(c => c === false)); + } + + /** + * @hidden + */ + public get pinnable() { + return (this.grid as any)._init || !this.pinned; + } + + /** + * @hidden + */ + public get applySelectableClass(): boolean { + return this._applySelectableClass; + } + + /** + * @hidden + */ + public set applySelectableClass(value: boolean) { + if (this.selectable) { + this._applySelectableClass = value; + } + } +} diff --git a/projects/igniteui-angular/grids/core/src/columns/public_api.ts b/projects/igniteui-angular/grids/core/src/columns/public_api.ts new file mode 100644 index 00000000000..bbecc39ab22 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/columns/public_api.ts @@ -0,0 +1,54 @@ +// import { IgxColumnGroupComponent } from './column-group.component'; +// import { IgxColumnLayoutComponent } from './column-layout.component'; +// import { IgxColumnComponent } from './column.component'; +// import { +// IgxCellEditorTemplateDirective, +// IgxCellFooterTemplateDirective, +// IgxCellHeaderTemplateDirective, +// IgxCellTemplateDirective, +// IgxCellValidationErrorDirective, +// IgxCollapsibleIndicatorTemplateDirective, +// IgxFilterCellTemplateDirective, +// IgxSummaryTemplateDirective +// } from './templates.directive'; +import { + IgxColumnMaxLengthValidatorDirective, + IgxColumnEmailValidatorDirective, + IgxColumnMaxValidatorDirective, + IgxColumnMinLengthValidatorDirective, + IgxColumnMinValidatorDirective, + IgxColumnRequiredValidatorDirective, + IgxColumnPatternValidatorDirective +} from './validators.directive'; + +export * from './column.component'; +export * from './column-group.component'; +export * from './column-layout.component'; +export * from './templates.directive'; +export * from './validators.directive'; + +/* NOTE: Grid column validation directives collection for ease-of-use import in standalone components scenario */ +export const IGX_GRID_VALIDATION_DIRECTIVES = [ + IgxColumnRequiredValidatorDirective, + IgxColumnMinValidatorDirective, + IgxColumnMaxValidatorDirective, + IgxColumnEmailValidatorDirective, + IgxColumnMinLengthValidatorDirective, + IgxColumnMaxLengthValidatorDirective, + IgxColumnPatternValidatorDirective +] as const; + +/* NOTE: Grid column validation directives collection for ease-of-use import in standalone components scenario */ +// export const IGX_GRID_COLUMN_DIRECTIVES = [ +// IgxFilterCellTemplateDirective, +// IgxSummaryTemplateDirective, +// IgxCellTemplateDirective, +// IgxCellValidationErrorDirective, +// IgxCellHeaderTemplateDirective, +// IgxCellFooterTemplateDirective, +// IgxCellEditorTemplateDirective, +// IgxCollapsibleIndicatorTemplateDirective, +// IgxColumnComponent, +// IgxColumnGroupComponent, +// IgxColumnLayoutComponent +// ] as const; diff --git a/projects/igniteui-angular/grids/core/src/columns/templates.directive.ts b/projects/igniteui-angular/grids/core/src/columns/templates.directive.ts new file mode 100644 index 00000000000..bd48d8792b4 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/columns/templates.directive.ts @@ -0,0 +1,111 @@ +import { Directive, TemplateRef, inject } from '@angular/core'; +import { IgxCellTemplateContext, IgxColumnTemplateContext, IgxSummaryTemplateContext } from '../common/grid.interface'; + +@Directive({ + selector: '[igxFilterCellTemplate]', + standalone: true +}) +export class IgxFilterCellTemplateDirective { + public template = inject>(TemplateRef); + + + public static ngTemplateContextGuard(_directive: IgxFilterCellTemplateDirective, + context: unknown): context is IgxColumnTemplateContext { + return true; + } +} + +@Directive({ + selector: '[igxCell]', + standalone: true +}) +export class IgxCellTemplateDirective { + public template = inject>(TemplateRef); + + + public static ngTemplateContextGuard(_directive: IgxCellTemplateDirective, + context: unknown): context is IgxCellTemplateContext { + return true; + } +} + +@Directive({ + selector: '[igxCellValidationError]', + standalone: true +}) +export class IgxCellValidationErrorDirective { + public template = inject>(TemplateRef); + + + public static ngTemplateContextGuard(_directive: IgxCellValidationErrorDirective, + context: unknown): context is IgxCellTemplateContext { + return true; + } +} + +@Directive({ + selector: '[igxHeader]', + standalone: true +}) +export class IgxCellHeaderTemplateDirective { + public template = inject>(TemplateRef); + + + public static ngTemplateContextGuard(_directive: IgxCellHeaderTemplateDirective, + context: unknown): context is IgxColumnTemplateContext { + return true; + } +} + +/** + * @hidden + */ +@Directive({ + selector: '[igxFooter]', + standalone: true +}) +export class IgxCellFooterTemplateDirective { + public template = inject>(TemplateRef); +} + +@Directive({ + selector: '[igxCellEditor]', + standalone: true +}) +export class IgxCellEditorTemplateDirective { + public template = inject>(TemplateRef); + + + public static ngTemplateContextGuard(_directive: IgxCellEditorTemplateDirective, + context: unknown): context is IgxCellTemplateContext { + return true; + } +} + +@Directive({ + selector: '[igxCollapsibleIndicator]', + standalone: true +}) +export class IgxCollapsibleIndicatorTemplateDirective { + public template = inject>(TemplateRef); + + + public static ngTemplateContextGuard(_directive: IgxCollapsibleIndicatorTemplateDirective, + context: unknown): context is IgxColumnTemplateContext { + return true; + } +} + +@Directive({ + selector: '[igxSummary]', + standalone: true +}) +export class IgxSummaryTemplateDirective { + public template = inject>(TemplateRef); + + + public static ngTemplateContextGuard(_directive: IgxSummaryTemplateDirective, + context: unknown): context is IgxSummaryTemplateContext { + return true; + } +} diff --git a/projects/igniteui-angular/grids/core/src/columns/validators.directive.ts b/projects/igniteui-angular/grids/core/src/columns/validators.directive.ts new file mode 100644 index 00000000000..729b9eb8829 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/columns/validators.directive.ts @@ -0,0 +1,92 @@ +import { Directive } from '@angular/core'; +import { RequiredValidator, NG_VALIDATORS, MinValidator, MaxValidator, EmailValidator, MinLengthValidator, MaxLengthValidator, PatternValidator } from '@angular/forms'; + +@Directive({ + + selector: 'igx-column[required]', + providers: [{ + provide: NG_VALIDATORS, + useExisting: IgxColumnRequiredValidatorDirective, + multi: true + }], + standalone: true +}) +export class IgxColumnRequiredValidatorDirective extends RequiredValidator { +} + +@Directive({ + + selector: 'igx-column[min]', + providers: [{ + provide: NG_VALIDATORS, + useExisting: IgxColumnMinValidatorDirective, + multi: true + }], + standalone: true +}) +export class IgxColumnMinValidatorDirective extends MinValidator { } + + +@Directive({ + + selector: 'igx-column[max]', + providers: [{ + provide: NG_VALIDATORS, + useExisting: IgxColumnMaxValidatorDirective, + multi: true + }], + standalone: true +}) +export class IgxColumnMaxValidatorDirective extends MaxValidator { } + + +@Directive({ + + selector: 'igx-column[email]', + providers: [{ + provide: NG_VALIDATORS, + useExisting: IgxColumnEmailValidatorDirective, + multi: true + }], + standalone: true +}) +export class IgxColumnEmailValidatorDirective extends EmailValidator { } + + +@Directive({ + + selector: 'igx-column[minlength]', + providers: [{ + provide: NG_VALIDATORS, + useExisting: IgxColumnMinLengthValidatorDirective, + multi: true + }], + standalone: true +}) +export class IgxColumnMinLengthValidatorDirective extends MinLengthValidator { } + +@Directive({ + + selector: 'igx-column[maxlength]', + providers: [{ + provide: NG_VALIDATORS, + useExisting: IgxColumnMaxLengthValidatorDirective, + multi: true + }], + standalone: true +}) +export class IgxColumnMaxLengthValidatorDirective extends MaxLengthValidator { +} + +@Directive({ + + selector: 'igx-column[pattern]', + providers: [{ + provide: NG_VALIDATORS, + useExisting: IgxColumnPatternValidatorDirective, + multi: true + }], + standalone: true +}) +export class IgxColumnPatternValidatorDirective extends PatternValidator { +} diff --git a/projects/igniteui-angular/grids/core/src/common/crud.service.ts b/projects/igniteui-angular/grids/core/src/common/crud.service.ts new file mode 100644 index 00000000000..256a8e14385 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/common/crud.service.ts @@ -0,0 +1,777 @@ +import { Injectable } from '@angular/core'; +import { first } from 'rxjs/operators'; +import { IGridEditDoneEventArgs, IGridEditEventArgs, IRowDataCancelableEventArgs, IRowDataEventArgs } from '../common/events'; +import { GridType, RowType } from './grid.interface'; +import { Subject } from 'rxjs'; +import { FormGroup } from '@angular/forms'; +import { copyDescriptors, DateTimeUtil, isDate, isEqual } from 'igniteui-angular/core'; + +export class IgxEditRow { + public transactionState: any; + public state: any; + public newData: any; + public rowFormGroup = new FormGroup({}); + + constructor(public id: any, public index: number, public data: any, public grid: GridType) { + this.rowFormGroup = this.grid.validation.create(id, data); + } + + public createRowEditEventArgs(includeNewValue = true, event?: Event): IGridEditEventArgs { + const args: IGridEditEventArgs = { + primaryKey: this.id, + rowID: this.id, + rowKey: this.id, + rowData: this.data, + oldValue: this.data, + cancel: false, + owner: this.grid, + isAddRow: false, + valid: this.rowFormGroup.valid, + event + }; + if (includeNewValue) { + args.newValue = this.newData ?? this.data; + } + return args; + } + + public createRowDataEventArgs(event?: Event): IRowDataCancelableEventArgs { + const args: IRowDataCancelableEventArgs = { + rowID: this.id, + primaryKey: this.id, + rowKey: this.id, + rowData: this.newData ?? this.data, + data: this.newData ?? this.data, + oldValue: this.data, + cancel: false, + owner: this.grid, + isAddRow: true, + valid: this.rowFormGroup.valid, + event + }; + return args; + } + + public createRowEditDoneEventArgs(cachedRowData: any, event?: Event): IGridEditDoneEventArgs { + const updatedData = this.grid.transactions.enabled ? + this.grid.transactions.getAggregatedValue(this.id, true) : this.grid.gridAPI.getRowData(this.id); + const rowData = updatedData ?? this.grid.gridAPI.getRowData(this.id); + const args: IGridEditDoneEventArgs = { + primaryKey: this.id, + rowID: this.id, + rowKey: this.id, + rowData, + oldValue: cachedRowData, + newValue: updatedData, + owner: this.grid, + isAddRow: false, + valid: true, + event + }; + + return args; + } + + public get isAddRow(): boolean { + return false; + } +} + +export class IgxAddRow extends IgxEditRow { + constructor(id: any, + index: number, + data: any, + public recordRef: any, + grid: GridType) { + super(id, index, data, grid); + } + + public override createRowEditEventArgs(includeNewValue = true, event?: Event): IGridEditEventArgs { + const args = super.createRowEditEventArgs(includeNewValue, event); + args.oldValue = null; + args.isAddRow = true; + args.rowData = this.newData ?? this.data; + return args; + } + + public override createRowEditDoneEventArgs(cachedRowData: any, event?: Event): IGridEditDoneEventArgs { + const args = super.createRowEditDoneEventArgs(null, event); + args.isAddRow = true; + return args; + } + + public override get isAddRow(): boolean { + return true; + } +} + +export interface IgxAddRowParent { + /** + * @deprecated since version 17.1.0. Use `rowKey` instead + */ + rowID: string; + rowKey: any; + index: number; + asChild: boolean; + isPinned: boolean; +} + +export class IgxCell { + public primaryKey: any; + public state: any; + public pendingValue: any; + + constructor( + public id, + public rowIndex: number, + public column, + public value: any, + public _editValue: any, + public rowData: any, + public grid: GridType) { + this.grid.validation.create(id.rowID, rowData); + } + + public get editValue() { + const formControl = this.grid.validation.getFormControl(this.id.rowID, this.column.field); + if (formControl) { + return formControl.value; + } + } + + public set editValue(value) { + const formControl = this.grid.validation.getFormControl(this.id.rowID, this.column.field); + + if (this.grid.validationTrigger === 'change') { + // in case trigger is change, mark as touched. + formControl.setValue(value); + formControl.markAsTouched(); + } else { + this.pendingValue = value; + } + } + + public castToNumber(value: any): any { + if (this.column.dataType === 'number' && !this.column.inlineEditorTemplate) { + const v = parseFloat(value); + return !isNaN(v) && isFinite(v) ? v : 0; + } + return value; + } + + public createCellEditEventArgs(includeNewValue = true, event?: Event): IGridEditEventArgs { + const formControl = this.grid.validation.getFormControl(this.id.rowID, this.column.field); + const args: IGridEditEventArgs = { + primaryKey: this.id.rowID, + rowID: this.id.rowID, + rowKey: this.id.rowID, + cellID: this.id, + rowData: this.rowData, + oldValue: this.value, + cancel: false, + column: this.column, + owner: this.grid, + valid: formControl ? formControl.valid : true, + event + }; + if (includeNewValue) { + args.newValue = this.castToNumber(this.editValue); + } + return args; + } + + public createCellEditDoneEventArgs(value: any, event?: Event): IGridEditDoneEventArgs { + const updatedData = this.grid.transactions.enabled ? + this.grid.transactions.getAggregatedValue(this.id.rowID, true) : this.rowData; + const rowData = updatedData === null ? this.grid.gridAPI.getRowData(this.id.rowID) : updatedData; + const formControl = this.grid.validation.getFormControl(this.id.rowID, this.column.field); + const args: IGridEditDoneEventArgs = { + primaryKey: this.id.rowID, + rowID: this.id.rowID, + rowKey: this.id.rowID, + cellID: this.id, + // rowData - should be the updated/committed rowData - this effectively should be the newValue + // the only case we use this.rowData directly, is when there is no rowEditing or transactions enabled + rowData, + oldValue: this.value, + valid: formControl ? formControl.valid : true, + newValue: value, + column: this.column, + owner: this.grid, + event + }; + return args; + } +} + +export class IgxCellCrudState { + public grid: GridType; + public cell: IgxCell | null = null; + public row: IgxEditRow | null = null; + public isInCompositionMode = false; + + public createCell(cell): IgxCell { + return this.cell = new IgxCell(cell.cellID || cell.id, cell.row.index, cell.column, cell.value, cell.value, + cell.row.data, cell.grid); + } + + public createRow(cell: IgxCell): IgxEditRow { + return this.row = new IgxEditRow(cell.id.rowID, cell.rowIndex, cell.rowData, cell.grid); + } + + public sameRow(rowID): boolean { + return this.row && this.row.id === rowID; + } + + public sameCell(cell: IgxCell): boolean { + return (this.cell.id.rowID === cell.id.rowID && + this.cell.id.columnID === cell.id.columnID); + } + + public get cellInEditMode(): boolean { + return !!this.cell; + } + + public beginCellEdit(event?: Event) { + const args = this.cell.createCellEditEventArgs(false, event); + this.grid.cellEditEnter.emit(args); + + if (args.cancel) { + this.endCellEdit(); + } + + } + + public cellEdit(event?: Event) { + const args = this.cell.createCellEditEventArgs(true, event); + this.grid.cellEdit.emit(args); + return args; + } + + public updateCell(exit: boolean, event?: Event): IGridEditEventArgs { + if (!this.cell) { + return; + } + // this is needed when we are not using ngModel to update the editValue + // so that the change event of the inlineEditorTemplate is hit before + // trying to update any cell + const cellNode = this.grid.gridAPI.get_cell_by_index(this.cell.id.rowIndex, this.cell.column.field)?.nativeElement; + let activeElement; + if (cellNode) { + const document = cellNode.getRootNode() as Document | ShadowRoot; + if (cellNode.contains(document.activeElement)) { + activeElement = document.activeElement as HTMLElement; + this.grid.tbody.nativeElement.focus(); + } + } + + const formControl = this.grid.validation.getFormControl(this.cell.id.rowID, this.cell.column.field); + if (this.grid.validationTrigger === 'blur' && this.cell.pendingValue !== undefined) { + // in case trigger is blur, update value if there's a pending one and mark as touched. + formControl.setValue(this.cell.pendingValue); + formControl.markAsTouched(); + } + + if (this.grid.validationTrigger === 'blur') { + this.grid.tbody.nativeElement.focus({ preventScroll: true }); + } + + let doneArgs; + if (this.cell.column.dataType === 'date' && !isDate(this.cell.value)) { + if (isEqual(DateTimeUtil.parseIsoDate(this.cell.value), this.cell.editValue)) { + doneArgs = this.exitCellEdit(event); + return doneArgs; + } + } + if (isEqual(this.cell.value, this.cell.editValue)) { + doneArgs = this.exitCellEdit(event); + return doneArgs; + } + + const args = this.cellEdit(event); + if (args.cancel) { + // the focus is needed when we cancel the cellEdit so that the activeElement stays on the editor template + activeElement?.focus(); + return args; + } + + this.grid.gridAPI.update_cell(this.cell); + + doneArgs = this.cellEditDone(event, false); + if (exit) { + doneArgs = this.exitCellEdit(event); + } + + return { ...args, ...doneArgs }; + } + + public cellEditDone(event, addRow: boolean): IGridEditDoneEventArgs { + const newValue = this.cell.castToNumber(this.cell.editValue); + const doneArgs = this.cell.createCellEditDoneEventArgs(newValue, event); + this.grid.cellEditDone.emit(doneArgs); + if (addRow) { + doneArgs.rowData = this.row.data; + } + return doneArgs; + } + + /** Exit cell edit mode */ + public exitCellEdit(event?: Event): IGridEditDoneEventArgs { + if (!this.cell) { + return; + } + const newValue = this.cell.castToNumber(this.cell.editValue); + const args = this.cell?.createCellEditDoneEventArgs(newValue, event); + + this.cell.value = newValue; + this.grid.cellEditExit.emit(args); + this.endCellEdit(); + return args; + } + + + /** Clears cell editing state */ + public endCellEdit(restoreFocus: boolean = false) { + this.cell = null; + if (restoreFocus) { + this.grid.tbody.nativeElement.focus(); + } + } + + /** Returns whether the targeted cell is in edit mode */ + public targetInEdit(rowIndex: number, columnIndex: number): boolean { + if (!this.cell) { + return false; + } + const res = this.cell.column.index === columnIndex && this.cell.rowIndex === rowIndex; + return res; + } +} +export class IgxRowCrudState extends IgxCellCrudState { + public closeRowEditingOverlay = new Subject(); + + private _rowEditingBlocked = false; + private _rowEditingStarted = false; + + public get primaryKey(): any { + return this.grid.primaryKey; + } + + public get rowInEditMode(): RowType { + const editRowState = this.row; + return editRowState !== null ? this.grid.rowList.find(e => e.key === editRowState.id) : null; + } + + public get rowEditing(): boolean { + return this.grid.rowEditable; + } + + public get nonEditable(): boolean { + return this.grid.rowEditable && (this.grid.primaryKey === undefined || this.grid.primaryKey === null); + } + + public get rowEditingBlocked() { + return this._rowEditingBlocked; + } + + public set rowEditingBlocked(val: boolean) { + this._rowEditingBlocked = val; + } + + /** Enters row edit mode */ + public beginRowEdit(event?: Event) { + if (!this.row || this.row.isAddRow) { + if (!this.row) { + this.createRow(this.cell); + } + + if (!this._rowEditingStarted) { + const rowArgs = this.row.createRowEditEventArgs(false, event); + + this.grid.rowEditEnter.emit(rowArgs); + if (rowArgs.cancel) { + this.endEditMode(); + return true; + } + + this._rowEditingStarted = true; + } + + this.row.transactionState = this.grid.transactions.getAggregatedValue(this.row.id, true); + this.grid.transactions.startPending(); + this.grid.openRowOverlay(this.row.id); + } + } + + public rowEdit(event: Event): IGridEditEventArgs { + const args = this.row.createRowEditEventArgs(true, event); + this.grid.rowEdit.emit(args); + return args; + } + + public updateRow(commit: boolean, event?: Event): IGridEditEventArgs { + if (!this.grid.rowEditable || + this.grid.rowEditingOverlay && + this.grid.rowEditingOverlay.collapsed || !this.row) { + return {} as IGridEditEventArgs; + } + + let args; + if (commit) { + this.row.newData = this.grid.transactions.getAggregatedValue(this.row.id, true); + this.updateRowEditData(this.row, this.row.newData); + args = this.rowEdit(event); + if (args.cancel) { + return args; + } + } + + args = this.endRowTransaction(commit, event); + + return args; + } + + /** + * @hidden @internal + */ + public endRowTransaction(commit: boolean, event?: Event): IGridEditEventArgs | IRowDataCancelableEventArgs { + this.row.newData = this.grid.transactions.getAggregatedValue(this.row.id, true); + let rowEditArgs = this.row.createRowEditEventArgs(true, event); + + let nonCancelableArgs; + if (!commit) { + this.grid.transactions.endPending(false); + const isAddRow = this.row && this.row.isAddRow; + const id = this.row ? this.row.id : this.cell.id.rowID; + if (isAddRow) { + this.grid.validation.clear(id); + } else { + this.grid.validation.update(id, rowEditArgs.oldValue); + } + } else if (!this.row.isAddRow) { + rowEditArgs = this.grid.gridAPI.update_row(this.row, this.row.newData, event); + nonCancelableArgs = this.rowEditDone(rowEditArgs.oldValue, event); + } else { + const rowAddArgs = this.row.createRowDataEventArgs(event); + this.grid.rowAdd.emit(rowAddArgs); + if (rowAddArgs.cancel) { + return rowAddArgs; + } + + this.grid.transactions.endPending(false); + + const parentId = this.getParentRowId(); + this.grid.gridAPI.addRowToData(this.row.newData ?? this.row.data, parentId); + this.grid.triggerPipes(); + + nonCancelableArgs = this.rowEditDone(null, event); + } + + nonCancelableArgs = this.exitRowEdit(rowEditArgs.oldValue, event); + + return { ...nonCancelableArgs, ...rowEditArgs }; + } + + public rowEditDone(cachedRowData, event: Event) { + const doneArgs = this.row.createRowEditDoneEventArgs(cachedRowData, event); + this.grid.rowEditDone.emit(doneArgs); + return doneArgs; + } + + + /** Exit row edit mode */ + public exitRowEdit(cachedRowData, event?: Event): IGridEditDoneEventArgs { + const nonCancelableArgs = this.row.createRowEditDoneEventArgs(cachedRowData, event); + this.grid.rowEditExit.emit(nonCancelableArgs); + this.grid.closeRowEditingOverlay(); + + this.endRowEdit(); + return nonCancelableArgs; + } + + /** Clears row editing state */ + public endRowEdit() { + this.row = null; + this.rowEditingBlocked = false; + this._rowEditingStarted = false; + } + + /** Clears cell and row editing state and closes row editing template if it is open */ + public endEditMode() { + this.endCellEdit(); + if (this.grid.rowEditable) { + this.endRowEdit(); + this.grid.closeRowEditingOverlay(); + } + } + + public updateRowEditData(row: IgxEditRow, value?: any) { + const grid = this.grid; + + const rowInEditMode = grid.gridAPI.crudService.row; + row.newData = value ?? rowInEditMode.transactionState; + + + if (rowInEditMode && row.id === rowInEditMode.id) { + // do not use spread operator here as it will copy everything over an empty object with no descriptors + row.data = Object.assign(copyDescriptors(row.data), row.data, rowInEditMode.transactionState); + // TODO: Workaround for updating a row in edit mode through the API + } else if (this.grid.transactions.enabled) { + const state = grid.transactions.getState(row.id); + row.data = state ? Object.assign({}, row.data, state.value) : row.data; + } + } + + protected getParentRowId() { + return null; + } +} + +export class IgxRowAddCrudState extends IgxRowCrudState { + public addRowParent: IgxAddRowParent = null; + + /** + * @hidden @internal + */ + public createAddRow(parentRow: RowType, asChild?: boolean) { + this.createAddRowParent(parentRow, asChild); + + const newRec = this.grid.getEmptyRecordObjectFor(parentRow); + const addRowIndex = this.addRowParent.index + 1; + return this.row = new IgxAddRow(newRec.rowID, addRowIndex, newRec.data, newRec.recordRef, this.grid); + } + + /** + * @hidden @internal + */ + public createAddRowParent(row: RowType, newRowAsChild?: boolean) { + const rowIndex = row ? row.index : -1; + const rowId = row ? row.key : (rowIndex >= 0 ? this.grid.rowList.last.key : null); + + const isInPinnedArea = this.grid.isRecordPinnedByViewIndex(rowIndex); + const pinIndex = this.grid.pinnedRecords.findIndex(x => x[this.primaryKey] === rowId); + const unpinIndex = this.grid.getUnpinnedIndexById(rowId); + this.addRowParent = { + rowID: rowId, + rowKey: rowId, + index: isInPinnedArea ? pinIndex : unpinIndex, + asChild: newRowAsChild, + isPinned: isInPinnedArea + }; + } + + /** + * @hidden @internal + */ + public override endRowTransaction(commit: boolean, event?: Event): IGridEditEventArgs | IRowDataCancelableEventArgs { + const isAddRow = this.row && this.row.isAddRow; + if (isAddRow) { + this.grid.rowAdded.pipe(first()).subscribe((addRowArgs: IRowDataEventArgs) => { + const rowData = addRowArgs.data; + const pinnedIndex = this.grid.pinnedRecords.findIndex(x => x[this.primaryKey] === rowData[this.primaryKey]); + // A check whether the row is in the current view + const viewIndex = pinnedIndex !== -1 ? pinnedIndex : this._findRecordIndexInView(rowData); + const dataIndex = this.grid.filteredSortedData.findIndex(data => data[this.primaryKey] === rowData[this.primaryKey]); + const isInView = viewIndex !== -1 && !this.grid.navigation.shouldPerformVerticalScroll(viewIndex, 0); + const showIndex = isInView ? -1 : dataIndex; + this.grid.showSnackbarFor(showIndex); + }); + } + + const args = super.endRowTransaction(commit, event); + if (args.cancel) { + return args; + } + + if (isAddRow) { + this.endAddRow(); + if (commit) { + const rowAddedEventArgs: IRowDataEventArgs = { + data: args.rowData, + rowData: args.rowData, + owner: this.grid, + primaryKey: args.rowData[this.grid.primaryKey], + rowKey: args.rowData[this.grid.primaryKey], + } + this.grid.rowAddedNotifier.next(rowAddedEventArgs); + this.grid.rowAdded.emit(rowAddedEventArgs); + } + } + + return args; + } + + /** + * @hidden @internal + */ + public endAddRow() { + this.addRowParent = null; + this.grid.triggerPipes(); + } + + /** + * @hidden + * @internal + * TODO: consider changing modifier + */ + public _findRecordIndexInView(rec) { + return this.grid.dataView.findIndex(data => data[this.primaryKey] === rec[this.primaryKey]); + } + + protected override getParentRowId() { + if (this.addRowParent.asChild) { + return this.addRowParent.asChild ? this.addRowParent.rowID : undefined; + } else if (this.addRowParent.rowID !== null && this.addRowParent.rowID !== undefined) { + const spawnedForRecord = this.grid.gridAPI.get_rec_by_id(this.addRowParent.rowID); + return spawnedForRecord?.parent?.rowID; + } + } +} + +@Injectable() +export class IgxGridCRUDService extends IgxRowAddCrudState { + + public enterEditMode(cell, event?: Event) { + if (this.isInCompositionMode) { + return; + } + + if (this.nonEditable) { + console.warn('The grid must have a `primaryKey` specified when using `rowEditable`!'); + return; + } + + if (this.cellInEditMode) { + // TODO: case solely for f2/enter nav that uses enterEditMode as toggle. Refactor. + const canceled = this.endEdit(true, event); + + if (!canceled || !this.cell) { + this.grid.tbody.nativeElement.focus(); + } + } else { + if (this.rowEditing) { + // TODO rowData + if (this.row && !this.sameRow(cell?.cellID?.rowID)) { + this.rowEditingBlocked = this.endEdit(true, event); + if (this.rowEditingBlocked) { + return true; + } + + this.rowEditingBlocked = false; + this.endRowEdit(); + } + this.createCell(cell); + + const canceled = this.beginRowEdit(event); + if (!canceled) { + this.beginCellEdit(event); + } + + } else { + this.createCell(cell); + this.beginCellEdit(event); + } + } + } + + /** + * Enters add row mode by creating temporary dummy so the user can fill in new row cells. + * + * @param parentRow Parent row after which the Add Row UI will be rendered. + * If `null` will show it at the bottom after all rows (or top if there are not rows). + * @param asChild Specifies if the new row should be added as a child to a tree row. + * @param event Base event that triggered the add row mode. + */ + public enterAddRowMode(parentRow: RowType, asChild?: boolean, event?: Event) { + if (!this.rowEditing && (this.grid.primaryKey === undefined || this.grid.primaryKey === null)) { + console.warn('The grid must use row edit mode to perform row adding! Please set rowEditable to true.'); + return; + } + this.endEdit(true, event); + // work with copy of original row, since context may change on collapse. + const parentRowCopy = parentRow ? Object.assign(copyDescriptors(parentRow), parentRow) : null; + if (parentRowCopy != null && this.grid.expansionStates.get(parentRowCopy.key)) { + this.grid.collapseRow(parentRowCopy.key); + } + + this.createAddRow(parentRowCopy, asChild); + + this.grid.transactions.startPending(); + if (this.addRowParent.isPinned) { + // If parent is pinned, add the new row to pinned records + (this.grid as any)._pinnedRecordIDs.splice(this.row.index, 0, this.row.id); + } + + this.grid.triggerPipes(); + this.grid.notifyChanges(true); + + this.grid.navigateTo(this.row.index, -1); + // when selecting the dummy row we need to adjust for top pinned rows + const indexAdjust = this.grid.isRowPinningToTop ? + (!this.addRowParent.isPinned ? this.grid.pinnedRows.length : 0) : + (!this.addRowParent.isPinned ? 0 : this.grid.unpinnedRecords.length); + + // TODO: Type this without shoving a bunch of internal properties in the row type + const dummyRow = this.grid.gridAPI.get_row_by_index(this.row.index + indexAdjust) as any; + dummyRow.triggerAddAnimation(); + dummyRow.cdr.detectChanges(); + dummyRow.addAnimationEnd.pipe(first()).subscribe(() => { + const cell = dummyRow.cells.find(c => c.editable); + if (cell) { + this.grid.gridAPI.update_cell(this.cell); + this.enterEditMode(cell, event); + cell.activate(); + } + }); + } + + /** + * Finishes the row transactions on the current row and returns whether the grid editing was canceled. + * + * @remarks + * If `commit === true`, passes them from the pending state to the data (or transaction service) + * @example + * ```html + * + * ``` + * @param commit + */ + // TODO: Implement the same representation of the method without evt emission. + public endEdit(commit = true, event?: Event): boolean { + if (!this.row && !this.cell) { + return; + } + + let args; + if (commit) { + args = this.updateCell(true, event); + if (args && args.cancel) { + return args.cancel; + } + } else { + // needede because this.cell is null after exitCellEdit + // thus the next if is always false + const cell = this.cell; + this.exitCellEdit(event); + if (!this.grid.rowEditable && cell) { + const value = this.grid.transactions.getAggregatedValue(cell.id.rowID, true) || cell.rowData; + this.grid.validation.update(cell.id.rowID, value); + } + } + + args = this.updateRow(commit, event); + this.rowEditingBlocked = args.cancel; + if (args.cancel) { + return true; + } + + const activeCell = this.grid.selectionService.activeElement; + if (event && activeCell) { + const rowIndex = activeCell.row; + const visibleColIndex = activeCell.layout ? activeCell.layout.columnVisibleIndex : activeCell.column; + this.grid.navigateTo(rowIndex, visibleColIndex); + } + + return false; + } +} diff --git a/projects/igniteui-angular/grids/core/src/common/enums.ts b/projects/igniteui-angular/grids/core/src/common/enums.ts new file mode 100644 index 00000000000..ab044101af7 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/common/enums.ts @@ -0,0 +1,103 @@ + +/** + * Enumeration representing different filter modes for grid filtering. + * - quickFilter: Default mode with a filter row UI between the column headers and the first row of records. + * - excelStyleFilter: Filter mode where an Excel-style filter is used. + */ +export const FilterMode = { + quickFilter: 'quickFilter', + excelStyleFilter: 'excelStyleFilter' +} as const; +export type FilterMode = (typeof FilterMode)[keyof typeof FilterMode]; + +/** + * Enumeration representing the position of grid summary rows. + * - top: Default value; Summary rows are displayed at the top of the grid. + * - bottom: Summary rows are displayed at the bottom of the grid. + */ +export const GridSummaryPosition = { + top: 'top', + bottom: 'bottom' +} as const; +export type GridSummaryPosition = (typeof GridSummaryPosition)[keyof typeof GridSummaryPosition]; + +/** + * Type representing the triggers for grid cell validation. + * - 'change': Validation is triggered when the cell value changes. + * - 'blur': Validation is triggered when the cell loses focus. + */ +export type GridValidationTrigger = 'change' | 'blur' ; + +/** + * Type representing the type of the target object (elements of the grid) for keydown (fired when a key is pressed) events in the grid. + * - 'dataCell': Represents a data cell within the grid. It contains and displays individual data values + * - 'summaryCell': Summary cells display aggregated/summarized data at the bottom of the grid. They provide insights like total record count, min/max values, etc. + * - 'groupRow': Group row within the grid. Group rows are used to group related data rows by columns. Contains the related group expression, level, sub-records and group value. + * - 'hierarchicalRow': Hierarchical rows are similar to group rows, but represent a more complex hierarchical structure, allowing for nested grouping + * - 'headerCell': Represents a header cell within the grid. Header cells are used to display column headers, providing context and labels for the columns. + * - 'masterDetailRow': Represents a grid row that can be expanded in order to show additional information + */ +export type GridKeydownTargetType = + 'dataCell' | + 'summaryCell' | + 'groupRow' | + 'hierarchicalRow' | + 'headerCell' | + 'masterDetailRow'; + +/** + * Enumeration representing different selection modes for the grid elements if can be selected. + * - 'none': No selection is allowed. Default row and column selection mode. + * - 'single': Only one element can be selected at a time. Selecting a new one will deselect the previously selected one. + * - 'multiple': Default cell selection mode. More than one element can be selected at a time. + * - 'multipleCascade': Similar to multiple selection. It is used in hierarchical or tree grids. Allows selection not only to an individual item but also all its related or nested items in a single action + */ +export const GridSelectionMode = { + none: 'none', + single: 'single', + multiple: 'multiple', + multipleCascade: 'multipleCascade' +} 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 = { + 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', + DisplayOrder: 'DisplayOrder' +} as const; +export type ColumnDisplayOrder = (typeof ColumnDisplayOrder)[keyof typeof ColumnDisplayOrder]; + +/* mustCoerceToInt */ +/** + * Enumeration representing the possible positions for pinning rows. + * - Top: Rows are pinned to the top of the grid. + * - Bottom: Rows are pinned to the bottom of the grid. + */ +export enum RowPinningPosition { + Top, + Bottom +} + +/** + * Enumeration representing different paging modes for the grid. + * - Local: The grid will use local data to extract pages during paging. + * - Remote: The grid will expect pages to be delivered from a remote location and will only raise events during paging interactions. + */ +export const GridPagingMode = { + Local: 'local', + Remote: 'remote' +} as const; +export type GridPagingMode = (typeof GridPagingMode)[keyof typeof GridPagingMode]; diff --git a/projects/igniteui-angular/grids/core/src/common/events.ts b/projects/igniteui-angular/grids/core/src/common/events.ts new file mode 100644 index 00000000000..f1e9b6d4d9c --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/common/events.ts @@ -0,0 +1,550 @@ +import { CancelableEventArgs, ColumnType, IBaseEventArgs, IFilteringExpressionsTree, IGroupingExpression, ISortingExpression } from 'igniteui-angular/core'; +import { GridKeydownTargetType } from './enums'; +import { CellType, GridType, RowType } from './grid.interface'; +import { IBaseSearchInfo } from 'igniteui-angular/directives'; +import { IgxBaseExporter } from '../services/exporter-common/base-export-service'; +import { IgxExporterOptionsBase } from '../services/exporter-common/exporter-options-base'; + +/** The event arguments when data from a grid is being copied. */ +export interface IGridClipboardEvent { + /** `data` can be of any type and refers to the data that is being copied/stored to the clipboard */ + data: any[]; + /** + * `cancel` returns whether an external event has intercepted the copying + * If the value becomes "true", it returns/exits from the method, instantiating the interface + */ + cancel: boolean; +} + +/** Represents an event argument related to grid cell interactions. */ +export interface IGridCellEventArgs extends IBaseEventArgs { + /** Represents the grid cell that triggered the event. */ + cell: CellType; + /* blazorCSSuppress */ + /** + * Represents the original event that occurred + * Examples of such events include: selecting, clicking, double clicking, etc. + */ + event: Event; +} + +/** Represents an event argument related to grid row interactions. */ +export interface IGridRowEventArgs extends IBaseEventArgs { + /** Represents the grid row that triggered the event. */ + row: RowType; + /** + * Represents the original event that occurred + * Examples of such events include: selecting, clicking, double clicking, etc. + */ + event: Event; +} + +/** Represents an event argument for the grid contextMenu output */ +export interface IGridContextMenuEventArgs extends IGridCellEventArgs, IGridRowEventArgs {} + +/** Represents event arguments related to grid editing completion. */ +export interface IGridEditDoneEventArgs extends IBaseEventArgs { + /** + * @deprecated since version 17.1.0. Use the `rowKey` property instead. + */ + rowID: any; + /** + * @deprecated since version 17.1.0. Use the `rowKey` property instead. + */ + primaryKey: any; + rowKey: any; + cellID?: { + rowID: any; + columnID: any; + rowIndex: number; + }; + /** + * `rowData` represents the updated/committed data of the row after the edit (newValue) + * The only case rowData (of the current object) is used directly, is when there is no rowEditing or transactions enabled + */ + rowData: any; + /** + * Represents the previous (before editing) value of the edited cell. + * It's used when the event has been stopped/exited. + */ + oldValue: any; + /** + * Optional + * Represents the value, that is being entered in the edited cell + * When there is no `newValue` and the event has ended, the value of the cell returns to the `oldValue` + */ + newValue?: any; + /* blazorSuppress */ + /** + * Optional + * Represents the original event, that has triggered the edit + */ + event?: Event; + /** + * Optional + * Represents the column information of the edited cell + */ + column?: ColumnType; + /** + * Optional + * Represents the grid instance that owns the edit event. + */ + owner?: GridType; + /** + * Optional + * Indicates if the editing consists of adding a new row + */ + isAddRow?: boolean; + /** + * Optional + * Indicates if the new value would be valid. + * It can be set to return the result of the methods for validation of the grid + */ + valid?: boolean; +} + + +/** + * Represents event arguments related to grid editing. + * The event is cancelable + * It contains information about the row and the column, as well as the old and nwe value of the element/cell + */ +export interface IGridEditEventArgs extends CancelableEventArgs, IGridEditDoneEventArgs { +} + +export interface IRowDataCancelableEventArgs extends IRowDataEventArgs, IGridEditEventArgs { + /** + * @deprecated + */ + cellID?: { + rowID: any; + columnID: any; + rowIndex: number; + }; + /** + * @deprecated + */ + oldValue: any; + /** + * @deprecated + */ + newValue?: any; + /** + * @deprecated + */ + isAddRow?: boolean; + owner: GridType; +} + +/** + * The event arguments after a column's pin state is changed. + * `insertAtIndex`specifies at which index in the pinned/unpinned area the column was inserted. + * `isPinned` returns the actual pin state of the column after the operation completed. + */ +export interface IPinColumnEventArgs extends IBaseEventArgs { + column: ColumnType; + /** + * If pinned, specifies at which index in the pinned area the column is inserted. + * If unpinned, specifies at which index in the unpinned area the column is inserted. + */ + insertAtIndex: number; + /** + * Returns the actual pin state of the column. + * If pinning/unpinning is successful, value of `isPinned` will change accordingly when read in the "-ing" and "-ed" event. + */ + isPinned: boolean; +} + +/** + * The event arguments before a column's pin state is changed. + * `insertAtIndex`specifies at which index in the pinned/unpinned area the column is inserted. + * Can be changed in the `columnPin` event. + * `isPinned` returns the actual pin state of the column. When pinning/unpinning is successful, + * the value of `isPinned` will change accordingly when read in the "-ing" and "-ed" event. + */ +export interface IPinColumnCancellableEventArgs extends IPinColumnEventArgs, CancelableEventArgs { +} + +/** + * Represents event arguments related to events, that can occur for rows in a grid + * Example for events: adding, deleting, selection, transaction, etc. + */ +export interface IRowDataEventArgs extends IBaseEventArgs { + /** + * @deprecated since version 17.1.0. Use the `rowData` property instead. + */ + data: any; + rowData: any + /** + * Represents the unique key, the row can be associated with. + * Available if `primaryKey` exists + * @deprecated since version 17.1.0. Use the `rowKey` property instead. + */ + primaryKey: any; + rowKey: any; + /* blazorSuppress */ + /** Represents the grid instance that owns the edit event. */ + owner: GridType; +} + + +/** The event arguments when a column is being resized */ +export interface IColumnResizeEventArgs extends IBaseEventArgs { + /** Represents the information of the column that is being resized */ + column: ColumnType; + /** Represents the old width of the column before the resizing */ + prevWidth: string; + /** Represents the new width, the column is being resized to */ + newWidth: string; +} + +/** + * The event arguments when a column is being resized + * It contains information about the column, it's old and new width + * The event can be canceled + */ +export interface IColumnResizingEventArgs extends IColumnResizeEventArgs, CancelableEventArgs { +} + +/** + * The event arguments when the selection state of a row is being changed + * The event is cancelable + */ +export interface IRowSelectionEventArgs extends CancelableEventArgs, IBaseEventArgs { + /** Represents an array of rows, that have already been selected */ + readonly oldSelection: any[]; + /** Represents the newly selected rows */ + newSelection: any[]; + /** + * Represents an array of all added rows + * Whenever a row has been selected, the array is "refreshed" with the selected rows + */ + readonly added: any[]; + /** + * Represents an array of all rows, removed from the selection + * Whenever a row has been deselected, the array is "refreshed" with the rows, + * that have been previously selected, but are no longer + */ + readonly removed: any[]; + /* blazorSuppress */ + /** + * Represents the original event, that has triggered the selection change + * selecting, deselecting + */ + readonly event?: Event; + /** Indicates whether or not all rows of the grid have been selected */ + readonly allRowsSelected?: boolean; +} + +/** + * The event arguments when the selection state of a column is being changed + * The event is cancelable + */ +export interface IColumnSelectionEventArgs extends CancelableEventArgs, IBaseEventArgs { + /** Represents an array of columns, that have already been selected */ + readonly oldSelection: string[]; + /** Represents the newly selected columns */ + newSelection: string[]; + /** + * Represents an array of all added columns + * Whenever a column has been selected, the array is "refreshed" with the selected columns + */ + readonly added: string[]; + /** + * Represents an array of all columns, removed from the selection + * Whenever a column has been deselected, the array is "refreshed" with the columns, that have been previously selected, but are no longer + */ + readonly removed: string[]; + /* blazorSuppress */ + /** + * Represents the original event, that has triggered the selection change + * selecting, deselecting + */ + readonly event?: Event; +} + +export interface ISearchInfo extends IBaseSearchInfo { + matchInfoCache: any[]; + activeMatchIndex: number; +} + +/* jsonAPIPlainObject */ +/* tsPlainInterface */ +/** + * Represents the arguments for the grid toolbar export event. + * It provides information about the grid instance, exporter service, export options, + * and allows the event to be canceled. + */ +export interface IGridToolbarExportEventArgs extends IBaseEventArgs { + /** `grid` represents a reference to the instance of the grid te event originated from */ + grid: GridType; + /** + * The `exporter` is a base service. + * The type (an abstract class `IgxBaseExporter`) has it's own properties and methods + * It is used to define the format and options of the export, the exported element + * and methods for preparing the data from the elements for exporting + */ + exporter: IgxBaseExporter; + /** + * Represents the different settings, that can be given to an export + * The type (an abstract class `IgxExporterOptionsBase`) has properties for column settings + * (whether they should be ignored) as well as method for generating a file name + */ + options: IgxExporterOptionsBase; + /** + * `cancel` returns whether the event has been intercepted and stopped + * If the value becomes "true", it returns/exits from the method, instantiating the interface + */ + cancel: boolean; +} + +/** Represents event arguments related to the start of a column moving operation in a grid. */ +export interface IColumnMovingStartEventArgs extends IBaseEventArgs { + /** + * Represents the column that is being moved. + * The `ColumnType` contains the information (the grid it belongs to, css data, settings, etc.) of the column in its properties + */ + source: ColumnType; +} + +/** Represents event arguments related to a column moving operation in a grid */ +export interface IColumnMovingEventArgs extends IBaseEventArgs { + /** + * Represents the column that is being moved. + * The `ColumnType` contains the information (the grid it belongs to, css data, settings, etc.) of the column in its properties + */ + source: ColumnType; + /** + * `cancel` returns whether the event has been intercepted and stopped + * If the value becomes "true", it returns/exits from the method, instantiating the interface + */ + cancel: boolean; +} + +/** Represents event arguments related to the end of a column moving operation in a grid */ +export interface IColumnMovingEndEventArgs extends IBaseEventArgs { + /** + * The source of the event represents the column that is being moved. + * The `ColumnType` contains the information (the grid it belongs to, css data, settings, etc.) of the column in its properties + */ + source: ColumnType; + /** + * The target of the event represents the column, the source is being moved to. + * The `ColumnType` contains the information (the grid it belongs to, css data, settings, etc.) of the column in its properties + */ + target: ColumnType; + /** + * `cancel` returns whether the event has been intercepted and stopped + * If the value becomes "true", it returns/exits from the method, instantiating the interface + */ + cancel: boolean; +} + +/** + * Represents an event, emitted when keydown is triggered over element inside grid's body + * This event is fired only if the key combination is supported in the grid. + */ +export interface IGridKeydownEventArgs extends IBaseEventArgs { + /** The `targetType` represents the type of the targeted object. For example a cell or a row */ + targetType: GridKeydownTargetType; + /** Represents the information and details of the object itself */ + target: any; + /* blazorCSSuppress */ + /** Represents the original event, that occurred. */ + event: Event; + /** + * The event is cancelable + * `cancel` returns whether the event has been intercepted and stopped + * If the value becomes "true", it returns/exits from the method, instantiating the interface + */ + cancel: boolean; +} + +/** The event is triggered when getting the current position of a certain cell */ +export interface ICellPosition { + /* doNotStringify */ + /** It returns the position (index) of the row, the cell is in */ + rowIndex: number; + /* doNotStringify */ + /** + * It returns the position (index) of the column, the cell is in + * Counts only the visible (non hidden) columns + */ + visibleColumnIndex: number; +} + +/** Emitted when a dragging operation is finished (when the row is dropped) */ +export interface IRowDragEndEventArgs extends IBaseEventArgs { + /** Represents the drag directive or information associated with the drag operation */ + dragDirective: any; + /** Represents the information of the row that is being dragged. */ + dragData: RowType; + /* blazorSuppress */ + /** Represents the HTML element itself */ + dragElement: HTMLElement; + /** `animation` returns whether the event is animated */ + animation: boolean; +} + +/** + * Emitted when a dragging operation is starting (when the row is "picked") + * The event is cancelable + */ +export interface IRowDragStartEventArgs extends CancelableEventArgs, IBaseEventArgs { + /** Represents the drag directive or information associated with the drag operation */ + dragDirective: any; + /** Represents the information of the row that is being dragged. */ + dragData: RowType; + /* blazorSuppress */ + /** Represents the HTML element itself */ + dragElement: HTMLElement; +} + +/** Represents event arguments related to the row's expansion state being changed in a grid */ +export interface IRowToggleEventArgs extends IBaseEventArgs { + /** + * Represents the ID of the row that emitted the event (which state is changed) + * @deprecated since version 17.1.0. Use the `rowKey` property instead. + */ + rowID: any; + rowKey: any; + /** + * Returns the state of the row after the operation has ended + * Indicating whether the row is being expanded (true) or collapsed (false) + */ + expanded: boolean; + /* blazorSuppress */ + /** + * Optional + * Represents the original event, that has triggered the expansion/collapse + */ + event?: Event; + /** + * The event is cancelable + * `cancel` returns whether the event has been intercepted and stopped + * If the value becomes "true", it returns/exits from the method, instantiating the interface + */ + cancel: boolean; +} + +/** + * Event emitted when a row's pin state changes. + * The event is cancelable + */ +export interface IPinRowEventArgs extends IBaseEventArgs, CancelableEventArgs { + /** + * The ID of the row, that was pinned/unpinned. + * ID is either the primaryKey value or the data record instance. + * @deprecated since version 17.1.0. Use the `rowKey` property instead. + */ + readonly rowID: any; + readonly rowKey: any; + row?: RowType; + /** The index at which to pin the row in the pinned rows collection. */ + insertAtIndex?: number; + /** Whether or not the row is pinned or unpinned. */ + readonly isPinned: boolean; +} + +/** + * Event emitted when a grid is scrolled. + */ +export interface IGridScrollEventArgs extends IBaseEventArgs { + /** The scroll direction - vertical or horizontal. */ + direction: string; + /* blazorCSSuppress */ + /** The original browser scroll event. */ + event: Event; + /** The new scroll position */ + scrollPosition: number; +} + +/** + * Event emitted when a checkbox in the checkbox + * list of an IgxColumnActions component is clicked. + */ +export interface IColumnToggledEventArgs extends IBaseEventArgs { + /** The column that is toggled. */ + column: ColumnType; + /** The checked state after the action. */ + checked: boolean; +} + +/** Emitted when the active node is changed */ +export interface IActiveNodeChangeEventArgs extends IBaseEventArgs { + /** Represents the row index of the active node */ + row: number; + /** Represents the column index of the active node */ + column: number; + /** + * Optional + * Represents the hierarchical level of the active node + */ + level?: number; + /** + * Represents the type of the active node. + * The GridKeydownTargetType is an enum or that specifies the possible target types + */ + tag: GridKeydownTargetType; +} + +/** + * Represents event arguments related to sorting and grouping operations + * The event is cancelable + */ +export interface ISortingEventArgs extends IBaseEventArgs, CancelableEventArgs { + /** + * Optional + * Represents the sorting expressions applied to the grid. + * It can be a single sorting expression or an array of them + * The expression contains information like file name, whether the letter case should be taken into account, etc. + */ + sortingExpressions?: ISortingExpression | Array; + /** + * Optional + * Represents the grouping expressions applied to the grid. + * It can be a single grouping expression or an array of them + * The expression contains information like the sorting expression and criteria by which the elements will be grouped + */ + groupingExpressions?: IGroupingExpression | Array; +} + +/* blazorInclude */ +/** @hidden @internal */ +export interface IColumnsAutoGeneratedEventArgs extends IBaseEventArgs { + /* blazorTreatAsCollection */ + /* blazorCollectionName: ColumnCollection */ + columns?: ColumnType[] +} + +/** + * Represents event arguments related to filtering operations + * The event is cancelable + */ +export interface IFilteringEventArgs extends IBaseEventArgs, CancelableEventArgs { + /** + * Represents the filtering expressions applied to the grid. + * The expression contains information like filtering operands and operator, an expression or condition, etc. + */ + filteringExpressions: IFilteringExpressionsTree; +} + +/** The event arguments after a column's visibility is changed. */ +export interface IColumnVisibilityChangedEventArgs extends IBaseEventArgs { + /** Represents the column the event originated from */ + column: any; + /** + * The new hidden state that the column will have, if operation is successful. + * Will be `true` when hiding and `false` when showing. + */ + newValue: boolean; +} + +/** + * The event arguments when a column's visibility is changed. + * The event is cancelable + * It contains information about the column and the it's visibility after the operation (will be `true` when hiding and `false` when showing) + */ +export interface IColumnVisibilityChangingEventArgs extends IColumnVisibilityChangedEventArgs, CancelableEventArgs { +} + diff --git a/projects/igniteui-angular/grids/core/src/common/grid.interface.ts b/projects/igniteui-angular/grids/core/src/common/grid.interface.ts new file mode 100644 index 00000000000..b407679e009 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/common/grid.interface.ts @@ -0,0 +1,1242 @@ +import { FilterMode, GridCellMergeMode, GridPagingMode, GridSelectionMode, GridSummaryPosition, GridValidationTrigger, RowPinningPosition } from './enums'; +import { + ISearchInfo, IGridCellEventArgs, IRowSelectionEventArgs, IColumnSelectionEventArgs, + IPinColumnCancellableEventArgs, IColumnVisibilityChangedEventArgs, IColumnVisibilityChangingEventArgs, + IRowDragEndEventArgs, IColumnMovingStartEventArgs, IColumnMovingEndEventArgs, + IRowDataEventArgs, IGridKeydownEventArgs, IRowDragStartEventArgs, + IColumnMovingEventArgs, IPinColumnEventArgs, + IActiveNodeChangeEventArgs, + ICellPosition, IFilteringEventArgs, IColumnResizeEventArgs, IRowToggleEventArgs, IGridToolbarExportEventArgs, IPinRowEventArgs, + IGridRowEventArgs, IGridEditEventArgs, IRowDataCancelableEventArgs, IGridEditDoneEventArgs, + IGridContextMenuEventArgs +} from '../common/events'; +import { ChangeDetectorRef, ElementRef, EventEmitter, InjectionToken, QueryList, TemplateRef, ViewContainerRef } from '@angular/core'; +import { IgxCell, IgxEditRow } from './crud.service'; +import { GridSelectionRange } from './types'; +import { DropPosition, IgxColumnMovingService } from '../moving/moving.service'; +import { Observable, Subject } from 'rxjs'; +import { ColumnPinningPosition, ColumnType, FilteringExpressionsTree, FilteringLogic, GridColumnDataType, GridSummaryCalculationMode, GridTypeBase, IDataCloneStrategy, IFilteringExpressionsTree, IFilteringStrategy, IGridGroupingStrategy, IGridMergeStrategy, IGridResourceStrings, IGridSortingStrategy, IGroupByExpandState, IGroupByRecord, IGroupingExpression, IgxSummaryResult, IPathSegment, ISortingExpression, ISortingOptions, ITreeGridRecord, OverlaySettings, ɵSize, SortingDirection, State, Transaction, TransactionService, type IgxOverlayOutletDirective } from 'igniteui-angular/core'; +import { FormControl, FormGroup, ValidationErrors } from '@angular/forms'; +import type { IForOfState, IgxGridForOfDirective, IgxToggleDirective } from 'igniteui-angular/directives'; +import type { IgxPaginatorComponent } from 'igniteui-angular/paginator'; +import { IgxGridValidationService } from '../grid-validation.service'; +import { IDimensionsChange, IPivotConfiguration, IPivotDimension, IPivotKeys, IPivotUISettings, IPivotValue, IValuesChange, PivotDimensionType } from '../pivot-grid.interface'; + +export const IGX_GRID_BASE = /*@__PURE__*/new InjectionToken('IgxGridBaseToken'); +export const IGX_GRID_SERVICE_BASE = /*@__PURE__*/new InjectionToken('IgxGridServiceBaseToken'); + +export interface IGridDataBindable extends GridTypeBase { + data: any[] | null; + get filteredData(): any[] | null; +} + +/* marshalByValue */ +/* jsonAPIComplexObject */ +/** + * Interface representing a cell in the grid. It is essentially the blueprint to a cell object. + * Contains definitions of properties and methods, relevant to a cell + */ +export interface CellType { + /** The current value of the cell. */ + value: any; + /** The value to display when the cell is in edit mode. */ + editValue: any; + /** Indicates whether the cell is currently selected. It is false, if the sell is not selected, and true, if it is. */ + selected: boolean; + /** Indicates whether the cell is currently active (focused). */ + active: boolean; + /** Indicates whether the cell can be edited. */ + editable: boolean; + /** Indicates whether the cell is currently in edit mode. */ + editMode: boolean; + /* blazorSuppress */ + /** Represents the native HTML element of the cell itself */ + nativeElement?: HTMLElement; + /** Represents the column that the cell belongs to. */ + column: ColumnType; + /* blazorCSSuppress */ + /** Represents the row that the cell belongs to */ + row: RowType; + /** Represents the grid instance containing the cell */ + grid: GridType; + /** Optional; An object identifying the cell. It contains rowID, columnID, and rowIndex of the cell. */ + id?: { rowID: any; columnID: number; rowIndex: number }; + /** Optional; The `cellID` is the unique key, used to identify the cell */ + cellID?: any; + /** + * Optional; An object representing the validation state of the cell. + * Whether it's valid or invalid, and if it has errors + */ + readonly validation?: IGridValidationState; + readonly?: boolean; + /** An optional title to display for the cell */ + title?: any; + /** The CSS width of the cell as a string. */ + width: string; + /** The index of the column that the cell belongs to. It counts only the visible (not hidden) columns */ + visibleColumnIndex?: number; + /** A method definition to update the value of the cell. */ + update: (value: any) => void; + /** A method definition to start or end the edit mode of the cell. It takes a boolean value as an argument*/ + setEditMode?(value: boolean): void; + /** + * Optional; + * A method definition to calculate the size of the cell to fit the content + * The method can be used to calculate the size of the cell with the longest content and resize all cells to that size + */ + calculateSizeToFit?(range: any): number; + /* blazorSuppress */ + /** + * Optional + * A method to activate the cell. + * It takes a focus or keyboard event as an argument + */ + activate?(event: FocusEvent | KeyboardEvent): void; + /* blazorSuppress */ + /** + * Optional + * A method to handle double-click events on the cell + * It takes a mouse event as an argument + */ + onDoubleClick?(event: MouseEvent): void; + /* blazorSuppress */ + /** + * Optional + * A method to handle click events on the cell + * It takes a mouse event as an argument + */ + onClick?(event: MouseEvent): void; +} + +/** + * Interface representing a header cell in the grid. It is essentially the blueprint to a header cell object. + * Contains definitions of properties, relevant to the header + */ +export interface HeaderType { + /* blazorSuppress */ + /** Represents the native HTML element of the cell itself */ + nativeElement: HTMLElement; + /** The column that the header cell represents. */ + column: ColumnType; + /** Indicates whether the column is currently sorted. */ + sorted: boolean; + /** Indicates whether the cell can be selected */ + selectable: boolean; + /** Indicates whether the cell is currently selected */ + selected: boolean; + /** Indicates whether the column header is a title cell. */ + title: boolean; + /** Represents the sorting direction of the column (ascending, descending or none). */ + sortDirection: SortingDirection; +} + +/* jsonAPIComplexObject */ +/* marshalByValue */ +/** + * Interface representing a row in the grid. It is essentially the blueprint to a row object. + * Contains definitions of properties and methods, relevant to a row + */ +export interface RowType { + /* blazorSuppress */ + /** Represents the native HTML element of the row itself */ + nativeElement?: HTMLElement; + /** The index of the row within the grid */ + index: number; + viewIndex: number; + /** Indicates whether the row is grouped. */ + isGroupByRow?: boolean; + isSummaryRow?: boolean; + /* blazorSuppress */ + /** + * Optional + * A map of column field names to the summary results for the row. + */ + summaries?: Map; + groupRow?: IGroupByRecord; + key?: any; + readonly validation?: IGridValidationState; + data?: any; + /** + * Optional + * A list or an array of cells, that belong to the row + */ + cells?: QueryList | CellType[]; + /** + * Optional + * Indicates whether the current row is disabled + */ + disabled?: boolean; + /* blazorSuppress */ + /** + * Optional + * Virtualization state of data record added from cache + */ + virtDirRow?: IgxGridForOfDirective; + /** + * Optional + * Indicates whether the current row is pinned. + */ + pinned?: boolean; + /** + * Optional + * Indicates whether the current row is selected + */ + selected?: boolean; + /** + * Optional + * Indicates whether the current row is expanded. + * The value is true, if the row is expanded and false, if it is collapsed + */ + expanded?: boolean; + /** + * Optional + * Indicates whether the row is marked for deletion. + */ + deleted?: boolean; + /** + * Optional + * Indicates whether the row is currently being edited. + */ + inEditMode?: boolean; + /** + * Optional + * Contains the child rows of the current row, if there are any. + */ + children?: RowType[]; + /* blazorAlternateName: RowParent */ + /** + * Optional + * Contains the parent row of the current row, if it has one. + * If the parent row exist, it means that the current row is a child row + */ + parent?: RowType; + /** + * Optional + * Indicates whether the current row has any child rows + */ + hasChildren?: boolean; + /** + * Optional + * Represents the hierarchical record associated with the row (for tree grids). + * It is of type ITreeGridRecord, which contains the data, children, the hierarchical level, etc. + */ + treeRow?: ITreeGridRecord; + addRowUI?: boolean; + /** + * Optional + * Indicates whether the row is currently focused. + */ + focused?: boolean; + /** Represent the grid instance, the row belongs to */ + grid: GridType; + /* blazorSuppress */ + onRowSelectorClick?: (event: MouseEvent) => void; + /* blazorSuppress */ + /** + * Optional + * A method to handle click event on the row + * It takes a `MouseEvent` as an argument + */ + onClick?: (event: MouseEvent) => void; + /* blazorSuppress */ + /** + * Optional + * A method to handle adding a new row + */ + beginAddRow?: () => void; + /** + * Optional + * A method to handle changing the value of elements of the row + * It takes the new value as an argument + */ + update?: (value: any) => void; + /** + * Optional + * A method to handle deleting rows + */ + delete?: () => any; + /** + * Optional + * A method to handle pinning a row + */ + pin?: () => void; + /** + * Optional + * A method to handle unpinning a row, that has been pinned + */ + unpin?: () => void; +} + +/** + * Interface representing the event arguments when a form group is created in the grid. + * - formGroup: The form group that is created. + * - owner: The grid instance that owns the form group. + */ +export interface IGridFormGroupCreatedEventArgs { + /* blazorSuppress */ + formGroup: FormGroup, + owner: GridType +} + +/** + * Interface representing the event arguments for the grid validation status change event. + * - status: The validation status ('VALID' or 'INVALID'). + * - owner: The grid instance that owns the validation state. + */ +export interface IGridValidationStatusEventArgs { + status: ValidationStatus, + owner: GridType +} + +/** + * Type representing the validation status. + * - 'VALID': The validation status is valid. + * - 'INVALID': The validation status is invalid. + */ +export type ValidationStatus = 'VALID' | 'INVALID'; + +/** + * Interface representing the validation state of a grid. + * - status: The validation status ('VALID' or 'INVALID'). + * - errors: The validation errors if any. + */ +export interface IGridValidationState { + readonly status: ValidationStatus; + readonly errors?: ValidationErrors; +} + +/** + * Interface representing the validation state of a record in the grid. + * - `key`: The unique identifier of the record. + * - `fields`: An array of the validation state of individual fields in the record. + */ +export interface IRecordValidationState extends IGridValidationState { + key: any; + fields: IFieldValidationState[]; +} + +/** + * Interface representing the validation state of a field in the grid. + * -`field`: The name of the field (property) being validated. + */ +export interface IFieldValidationState extends IGridValidationState { + field: string +} + +/** + * Represents the service interface for interacting with the grid. + */ +export interface GridServiceType { + + /** The reference to the parent `GridType` that contains the service. */ + grid: GridType; + /** Represents the type of the CRUD service (Create, Read, Update, Delete) operations on the grid data. */ + crudService: any; + /** A service responsible for handling column moving within the grid. It contains a reference to the column, its icon, and indicator for cancelation. */ + cms: IgxColumnMovingService; + + /** Represents a method declaration for retrieving the data used in the grid. The returned values could be of any type */ + get_data(): any[]; + /** + * Represents a method declaration for retrieving all the data available in the grid, including any transactional data. + * `includeTransactions`: Optional parameter. Specifies whether to include transactional data if present. + * Returns an array containing all the data available in the grid. + */ + get_all_data(includeTransactions?: boolean): any[]; + /** Represents a method declaration for retrieving a column object by its name, taken as a parameter. */ + get_column_by_name(name: string): ColumnType; + /** Represents a method declaration for retrieving the data associated with a specific row by its unique identifier (of any type, taken as a parameter). */ + getRowData(id: any): any; + /** Represents a method declaration for retrieving the data associated with a specific record by its unique identifier (of any type, taken as a parameter). */ + get_rec_by_id(id: any): any; + /** Represents a method declaration for retrieving the unique identifier of a specific row by its data. */ + get_row_id(rowData: any): any; + /** Represents a method declaration for retrieving the row object associated with a specific index (taken as a parameter) in the grid */ + get_row_by_index(rowSelector: any): RowType; + /** Represents a method declaration for retrieving the row object associated with a specific key (taken as a parameter) in the grid */ + get_row_by_key(rowSelector: any): RowType; + /** Represents a method declaration for retrieving the index of a record in the grid's data collection using its unique identifier. */ + get_rec_index_by_id(pk: string | number, dataCollection?: any[]): number; + /** Represents a method declaration for retrieving the index of a record in the grid's data collection using its index. */ + get_rec_id_by_index(index: number, dataCollection?: any[]): any; + get_row_index_in_data(rowID: any, dataCollection?: any[]): number; + /** Represents a method declaration for retrieving the cell object associated with a specific row and column in the grid. */ + get_cell_by_key(rowSelector: any, field: string): CellType; + /** Represents a method declaration for retrieving the cell object associated with a specific row and column using their indexes. */ + get_cell_by_index(rowIndex: number, columnID: number | string): CellType; + /** + * Represents a method declaration for retrieving the cell object associated with a specific row and column using their indexes. + * It counts only the indexes of the visible columns and rows + */ + get_cell_by_visible_index(rowIndex: number, columnIndex: number); + /** Represents a method declaration that sets the expansion state of a group row (used for tree grids) + * It takes the value for the expansion as a parameter (expanded or collapsed) + */ + set_grouprow_expansion_state?(groupRow: IGroupByRecord, value: boolean): void; + row_deleted_transaction(id: any): boolean; + /** + * Represents a method declaration for adding a new row to the grid. + * It takes the row's data and the identifier of the parent row if applicable (used for tree grids) + */ + addRowToData(rowData: any, parentID?: any): void; + /** Represents a method declaration for deleting a row, specified by it's identifier (taken as a parameter) */ + deleteRowById(id: any): any; + /** Represents a method declaration for retrieving the row's current state of expansion (used for tree grids)*/ + get_row_expansion_state(id: any): boolean; + /** Represents a method declaration for setting a new expansion state. It can be triggered by an event */ + set_row_expansion_state(id: any, expanded: boolean, event?: Event): void; + get_summary_data(): any[] | null; + + prepare_sorting_expression(stateCollections: Array>, expression: ISortingExpression): void; + /** + * Represents a method declaration for sorting by only one expression + * The expression contains fieldName, sorting directory, whether case should be ignored and optional sorting strategy + */ + sort(expression: ISortingExpression): void; + /** + * Represents a method declaration for sorting by multiple expressions + * The expressions contains fieldName, sorting directory, whether case should be ignored and optional sorting strategy + */ + sort_multiple(expressions: ISortingExpression[]): void; + /** Represents a method declaration for resetting the sorting */ + clear_sort(fieldName: string): void; + + /** Represents an event, triggered when the pin state is changed */ + get_pin_row_event_args(rowID: any, index?: number, row?: RowType, pinned?: boolean): IPinRowEventArgs; + + filterDataByExpressions(expressionsTree: IFilteringExpressionsTree): any[]; + sortDataByExpressions(data: any[], expressions: ISortingExpression[]): any[]; + + update_cell(cell: IgxCell): IGridEditEventArgs; + update_row(row: IgxEditRow, value: any, event?: Event): IGridEditEventArgs; + + expand_path_to_record?(record: ITreeGridRecord): void; + get_selected_children?(record: ITreeGridRecord, selectedRowIDs: any[]): void; + get_groupBy_record_id?(gRow: IGroupByRecord): string; + remove_grouping_expression?(fieldName: string): void; + clear_groupby?(field: string | any): void; + getParentRowId?(child: GridType): any; + getChildGrids?(inDepth?: boolean): GridType[]; + getChildGrid?(path: IPathSegment[]): GridType | undefined; + + unsetChildRowIsland?(rowIsland: GridType): void; + registerChildRowIsland?(rowIsland: GridType): void; +} + + +/** + * Interface representing a grid type. It is essentially the blueprint to a grid object. + * Contains definitions of properties and methods, relevant to a grid + * Extends `IGridDataBindable` + */ +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 */ + nativeElement: HTMLElement; + /** Indicates whether rows in the grid are editable. If te value is true, the rows can be edited */ + rowEditable: boolean; + rootSummariesEnabled: boolean; + /** Indicates whether filtering in the grid is enabled. If te value is true, the grid can be filtered */ + allowFiltering: boolean; + /** Indicates whether rows in the grid can be dragged. If te value is true, the rows can be dragged */ + rowDraggable: boolean; + /** Represents the unique primary key used for identifying rows in the grid */ + primaryKey: string; + /** Represents the unique identifier of the grid. */ + id: string; + /** The height of the visible rows in the grid. */ + renderedRowHeight: number; + pipeTrigger: number; + summaryPipeTrigger: number; + /** @hidden @internal */ + columnsToMerge: ColumnType[], + /** @hidden @internal */ + groupablePipeTrigger: number; + filteringPipeTrigger: number; + /** @hidden @internal */ + hasColumnLayouts: boolean; + /** Indicates whether the grid is currently in a moving state. */ + moving: boolean; + isLoading: boolean; + /** @hidden @internal */ + gridSize: ɵSize; + /** @hidden @internal */ + isColumnWidthSum: boolean; + /** @hidden @internal */ + minColumnWidth: number; + /** @hidden @internal */ + hoverIndex?: number; + /** Strategy, used for cloning the provided data. The type has one method, that takes any type of data */ + dataCloneStrategy: IDataCloneStrategy; + + /** Represents the grid service type providing API methods for the grid */ + readonly gridAPI: GridServiceType; + + /** The filter mode for the grid. It can be quick filter of excel-style filter */ + filterMode: FilterMode; + + // TYPE + /** @hidden @internal */ + theadRow: any; + /** @hidden @internal */ + groupArea: any; + /** @hidden @internal */ + filterCellList: any[]; + /** @hidden @internal */ + filteringRow: any; + /** @hidden @internal */ + actionStrip: any; + /** @hidden @internal */ + resizeLine: any; + + /** @hidden @internal */ + tfoot: ElementRef; + /** @hidden @internal */ + paginator?: IgxPaginatorComponent; + /** @hidden @internal */ + paginatorList?: QueryList; + /** @hidden @internal */ + crudService: any; + /** @hidden @internal */ + summaryService: any; + + + + /** Represents the state of virtualization for the grid. It has an owner, start index and chunk size */ + virtualizationState: IForOfState; + // TYPE + /** @hidden @internal */ + /** The service handling selection in the grid. Selecting, deselecting elements */ + selectionService: any; + navigation: any; + /** @hidden @internal */ + filteringService: any; + outlet: any; + /** Indicates whether the grid has columns that can be moved */ + /** @hidden @internal */ + hasMovableColumns: boolean; + /** Indicates whether the grid's rows can be selected */ + isRowSelectable: boolean; + /** Indicates whether the selectors of the rows are visible */ + showRowSelectors: boolean; + /** Indicates if the column of the grid is in drag mode */ + columnInDrag: any; + /** @hidden @internal */ + /** 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; + /** The CSS margin of the summaries */ + summariesMargin: number; + headSelectorBaseAriaLabel: string; + + /** Indicates whether the grid has columns that are shown */ + hasVisibleColumns: boolean; + /** + * Optional + * Indicates whether the grid has expandable children (hierarchical and tree grid) + */ + hasExpandableChildren?: boolean; + /** + * Optional + * Indicates whether collapsed grid elements should be expanded + */ + showExpandAll?: boolean; + + /** Represents the count of only the hidden (not visible) columns */ + hiddenColumnsCount: number; + /** Represents the count of only the pinned columns */ + pinnedColumnsCount: number; + + /** + * Optional + * The template for grid icons. + * It is of type TemplateRef, which represents an embedded template, used to instantiate embedded views + */ + iconTemplate?: TemplateRef; + /** + * Optional + * The template for group-by rows. + * It is of type TemplateRef, which represents an embedded template, used to instantiate embedded views + */ + groupRowTemplate?: TemplateRef; + /** + * Optional + * The template for the group row selector. + * It is of type TemplateRef, which represents an embedded template, used to instantiate embedded views + */ + groupByRowSelectorTemplate?: TemplateRef; + /** + * Optional + * The template for row loading indicators. + * It is of type TemplateRef, which represents an embedded template, used to instantiate embedded views + */ + rowLoadingIndicatorTemplate?: TemplateRef; + /** + * The template for the header selector. + * It is of type TemplateRef, which represents an embedded template, used to instantiate embedded views + */ + headSelectorTemplate: TemplateRef; + /** + * The template for row selectors. + * It is of type TemplateRef, which represents an embedded template, used to instantiate embedded views + */ + rowSelectorTemplate: TemplateRef; + /** + * The template for sort header icons. + * It is of type TemplateRef, which represents an embedded template, used to instantiate embedded views + */ + sortHeaderIconTemplate: TemplateRef; + /** + * The template for ascending sort header icons. + * It is of type TemplateRef, which represents an embedded template, used to instantiate embedded views + */ + sortAscendingHeaderIconTemplate: TemplateRef; + /** + * The template for descending sort header icons. + * It is of type TemplateRef, which represents an embedded template, used to instantiate embedded views + */ + sortDescendingHeaderIconTemplate: TemplateRef; + /** + * The template for header collapsed indicators. + * It is of type TemplateRef, which represents an embedded template, used to instantiate embedded views + */ + headerCollapsedIndicatorTemplate: TemplateRef; + /** + * The template for header expanded indicators. + * It is of type TemplateRef, which represents an embedded template, used to instantiate embedded views + */ + headerExpandedIndicatorTemplate: TemplateRef; + /** The template for drag indicator icons. Could be of any type */ + dragIndicatorIconTemplate: any; + /** The base drag indicator icon. Could be of any type */ + dragIndicatorIconBase: any; + /** Indicates whether transitions are disabled for the grid. */ + disableTransitions: boolean; + /** Indicates whether the currency symbol is positioned to the left of values. */ + currencyPositionLeft: boolean; + + /** Indicates whether the width of the column is set by the user, or is configured automatically. */ + columnWidthSetByUser: boolean; + headerFeaturesWidth: number; + /** CSS styling calculated for an element: calcHeight, calcWidth, outerWidth */ + calcHeight: number; + calcWidth: number; + outerWidth: number; + /** The height of each row in the grid. Setting a constant height can solve problems with not showing all elements when scrolling */ + rowHeight: number; + multiRowLayoutRowSize: number; + defaultRowHeight: number; + /** The default font size, calculated for each element */ + _baseFontSize?: number; + scrollSize: number; + + /** The trigger for grid validation. It's value can either be `change` or `blur` */ + validationTrigger: GridValidationTrigger; + /** + * The configuration for columns and rows pinning in the grid + * It's of type IPinningConfig, which can have value for columns (start, end) and for rows (top, bottom) + */ + pinning: IPinningConfig; + /* blazorSuppress */ + expansionStates: Map; + parentVirtDir: any; + tbody: any; + verticalScrollContainer: any; + dataRowList: any; + rowList: any; + /** An unmodifiable list, containing all the columns of the grid. */ + columnList: QueryList; + columns: ColumnType[]; + /** An array of columns, but it counts only the ones visible (not hidden) in the view */ + visibleColumns: ColumnType[]; + /** An array of columns, but it counts only the ones that are not pinned */ + 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[]; + /** @hidden @internal */ + headerGroups: any[]; + /** @hidden @internal */ + headerGroupsList: any[]; + summariesRowList: any; + /** @hidden @internal */ + headerContainer: any; + /** Indicates whether cells are selectable in the grid */ + isCellSelectable: boolean; + /** Indicates whether it is allowed to select more than one row in the grid */ + isMultiRowSelectionEnabled: boolean; + hasPinnedRecords: boolean; + pinnedRecordsCount: number; + pinnedRecords: any[]; + unpinnedRecords: any[]; + /** @hidden @internal */ + pinnedDataView: any[]; + pinnedRows: any[]; + dataView: any[]; + _filteredUnpinnedData: any[]; + _filteredSortedUnpinnedData: any[]; + filteredSortedData: any[] | null; + dataWithAddedInTransactionRows: any[]; + /** Represents the transaction service for the grid. */ + readonly transactions: TransactionService; + /** Represents the validation service for the grid. The type contains properties and methods (logic) for validating records */ + readonly validation: IgxGridValidationService; + defaultSummaryHeight: number; + summaryRowHeight: number; + rowEditingOverlay: IgxToggleDirective; + totalRowsCountAfterFilter: number; + _totalRecords: number; + /** + * Represents the paging of the grid. It can be either 'Local' or 'Remote' + * - Local: Default value; The grid will paginate the data source based on the page + */ + pagingMode: GridPagingMode; + /** The paging state for the grid; Used to configure how paging should be applied - which is the current page, records per page */ + /** @hidden */ + pagingState: any; + + rowEditTabs: any; + /** Represents the last search in the grid + * It contains the search text (the user has entered), the match and some settings for the search + */ + readonly lastSearchInfo: ISearchInfo; + /** @hidden @internal */ + page: number; + /** @hidden @internal */ + perPage: number; + /** The ID of the row currently being dragged in the grid. */ + /** @hidden @internal */ + dragRowID: any; + /** Indicates whether a row is currently being dragged */ + rowDragging: boolean; + + firstEditableColumnIndex: number; + lastEditableColumnIndex: number; + isRowPinningToTop: boolean; + hasDetails: boolean; + /** @hidden @internal */ + hasSummarizedColumns: boolean; + /** @hidden @internal */ + hasColumnGroups: boolean; + /** @hidden @internal */ + hasEditableColumns: boolean; + /* blazorCSSuppress */ + /** Property, that provides a callback for loading unique column values on demand. + * If this property is provided, the unique values it generates will be used by the Excel Style Filtering */ + uniqueColumnValuesStrategy: (column: ColumnType, tree: FilteringExpressionsTree, done: (values: any[]) => void) => void; + /* blazorSuppress */ + /** Property, that gets the header cell inner width for auto-sizing. */ + getHeaderCellWidth: (element: HTMLElement) => ISizeInfo; + + /* blazorSuppress */ + /** + * Provides change detection functionality. + * A change-detection tree collects all views that are to be checked for changes. + * The property cannot be changed (readonly) */ + readonly cdr: ChangeDetectorRef; + /** @hidden @internal */ + document: Document; + /** + * The template for expanded row indicators. + * It is of type TemplateRef, which represents an embedded template, used to instantiate embedded views + */ + rowExpandedIndicatorTemplate: TemplateRef; + /** + * The template for collapsed row indicators. + * It is of type TemplateRef, which represents an embedded template, used to instantiate embedded views + */ + rowCollapsedIndicatorTemplate: TemplateRef; + /** + * The template for header icon + * It is of type TemplateRef, which represents an embedded template, used to instantiate embedded views + */ + excelStyleHeaderIconTemplate: TemplateRef; + + selectRowOnClick: boolean; + /** Represents the selection mode for cells: 'none','single', 'multiple', 'multipleCascade' */ + cellSelection: GridSelectionMode; + /** Represents the selection mode for rows: 'none','single', 'multiple', 'multipleCascade' */ + rowSelection: GridSelectionMode; + /** Represents the selection mode for columns: 'none','single', 'multiple', 'multipleCascade' */ + columnSelection: GridSelectionMode; + /** Represents the calculation mode for summaries: 'rootLevelOnly', 'childLevelsOnly', 'rootAndChildLevels' */ + summaryCalculationMode: GridSummaryCalculationMode; + /** Represents the position of summaries: 'top', 'bottom' */ + summaryPosition: GridSummaryPosition; + + // XXX: Work around till we fixed the injection tokens + lastChildGrid?: GridType; + /** @hidden @internal */ + toolbarOutlet?: ViewContainerRef; + /** @hidden @internal */ + paginatorOutlet?: ViewContainerRef; + flatData?: any[] | null; + /** @hidden @internal */ + childRow?: any; + expansionDepth?: number; + childDataKey?: any; + foreignKey?: any; + cascadeOnDelete?: boolean; + /* blazorSuppress */ + loadChildrenOnDemand?: (parentID: any, done: (children: any[]) => void) => void; + hasChildrenKey?: any; + /* blazorSuppress */ + loadingRows?: Set; + /* blazorAlternateName: GridParent */ + parent?: GridType; + highlightedRowID?: any; + updateOnRender?: boolean; + childLayoutKeys?: any[]; + childLayoutList?: QueryList; + rootGrid?: GridType; + processedRootRecords?: ITreeGridRecord[]; + rootRecords?: ITreeGridRecord[]; + /* blazorSuppress */ + records?: Map; + processedExpandedFlatData?: any[] | null; + /* blazorSuppress */ + processedRecords?: Map; + treeGroupArea?: any; + + activeNodeChange: EventEmitter; + gridKeydown: EventEmitter; + cellClick: EventEmitter; + rowClick: EventEmitter; + doubleClick: EventEmitter; + contextMenu: EventEmitter; + selected: EventEmitter; + rangeSelected: EventEmitter; + rowSelectionChanging: EventEmitter; + localeChange: EventEmitter; + filtering: EventEmitter; + filteringDone: EventEmitter; + columnPinned: EventEmitter; + columnResized: EventEmitter; + columnMovingEnd: EventEmitter; + columnSelectionChanging: EventEmitter; + columnMoving: EventEmitter; + columnMovingStart: EventEmitter; + columnPin: EventEmitter; + columnVisibilityChanging: EventEmitter; + columnVisibilityChanged: EventEmitter; + batchEditingChange?: EventEmitter; + rowAdd: EventEmitter; + rowAdded: EventEmitter; + /* blazorSuppress */ + rowAddedNotifier: Subject; + rowDelete: EventEmitter; + rowDeleted: EventEmitter; + /* blazorSuppress */ + rowDeletedNotifier: Subject; + cellEditEnter: EventEmitter; + cellEdit: EventEmitter; + cellEditDone: EventEmitter; + cellEditExit: EventEmitter; + rowEditEnter: EventEmitter; + rowEdit: EventEmitter; + rowEditDone: EventEmitter; + rowEditExit: EventEmitter; + rowDragStart: EventEmitter; + rowDragEnd: EventEmitter; + rowToggle: EventEmitter; + formGroupCreated: EventEmitter; + validationStatusChange: EventEmitter; + + toolbarExporting: EventEmitter; + /* blazorSuppress */ + rendered$: Observable; + /* blazorSuppress */ + resizeNotify: Subject; + + sortStrategy: IGridSortingStrategy; + groupStrategy?: IGridGroupingStrategy; + filteringLogic: FilteringLogic; + filterStrategy: IFilteringStrategy; + allowAdvancedFiltering: boolean; + sortingExpressions: ISortingExpression[]; + sortingExpressionsChange: EventEmitter; + filteringExpressionsTree: IFilteringExpressionsTree; + filteringExpressionsTreeChange: EventEmitter; + advancedFilteringExpressionsTree: IFilteringExpressionsTree; + advancedFilteringExpressionsTreeChange: EventEmitter; + sortingOptions: ISortingOptions; + + batchEditing: boolean; + groupingExpansionState?: IGroupByExpandState[]; + groupingExpressions?: IGroupingExpression[]; + groupingExpressionsChange?: EventEmitter; + groupsExpanded?: boolean; + readonly groupsRecords?: IGroupByRecord[]; + groupingFlatResult?: any[]; + groupingResult?: any[]; + groupingMetadata?: any[]; + selectedCells?: CellType[]; + selectedRows: any[]; + /** @hidden @internal */ + activeDescendant?: string; + /** @hidden @internal */ + readonly type: 'flat' | 'tree' | 'hierarchical' | 'pivot'; + + toggleGroup?(groupRow: IGroupByRecord): void; + clearGrouping?(field: string): void; + groupBy?(expression: IGroupingExpression | Array): void; + resolveOutlet?(): IgxOverlayOutletDirective; + updateColumns(columns: ColumnType[]): void; + getSelectedRanges(): GridSelectionRange[]; + deselectAllColumns(): void; + deselectColumns(columns: string[] | ColumnType[]): void; + selectColumns(columns: string[] | ColumnType[]): void; + selectedColumns(): ColumnType[]; + refreshSearch(): void; + getDefaultExpandState(record: any): boolean; + trackColumnChanges(index: number, column: any): any; + getPossibleColumnWidth(baseWidth?: number, minColumnWidth?: number): string; + resetHorizontalVirtualization(): void; + hasVerticalScroll(): boolean; + getVisibleContentHeight(): number; + /* blazorSuppress */ + getDragGhostCustomTemplate(): TemplateRef | null; + openRowOverlay(id: any): void; + openAdvancedFilteringDialog(overlaySettings?: OverlaySettings): void; + showSnackbarFor(index: number): void; + getColumnByName(name: string): any; + getColumnByVisibleIndex(index: number): ColumnType; + getHeaderGroupWidth(column: ColumnType): string; + getRowByKey?(key: any): RowType; + getRowByIndex?(index: number): RowType; + setFilteredData(data: any, pinned: boolean): void; + setFilteredSortedData(data: any, pinned: boolean): void; + sort(expression: ISortingExpression | ISortingExpression[]): void; + clearSort(name?: string): void; + pinRow(id: any, index?: number, row?: RowType): boolean; + unpinRow(id: any, row?: RowType): boolean; + getUnpinnedIndexById(id: any): number; + 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; + isDetailRecord(rec: any): boolean; + isGroupByRecord(rec: any): boolean; + isGhostRecord(rec: any): boolean; + isTreeRow?(rec: any): boolean; + isChildGridRecord?(rec: any): boolean; + getChildGrids?(inDepth?: boolean): any[]; + isHierarchicalRecord?(record: any): boolean; + columnToVisibleIndex(key: string | number): number; + moveColumn(column: ColumnType, target: ColumnType, pos: DropPosition): void; + /* blazorSuppress */ + navigateTo(rowIndex: number, visibleColumnIndex: number, callback?: (e: any) => any): void; + /* blazorSuppress */ + getPreviousCell(currRowIndex: number, curVisibleColIndex: number, callback: (c: ColumnType) => boolean): ICellPosition; + /* blazorSuppress */ + getNextCell(currRowIndex: number, curVisibleColIndex: number, callback: (c: ColumnType) => boolean): ICellPosition; + clearCellSelection(): void; + selectRange(range: GridSelectionRange | GridSelectionRange[]): void; + selectRows(rowIDs: any[], clearCurrentSelection?: boolean): void; + deselectRows(rowIDs: any[]): void; + selectAllRows(onlyFilterData?: boolean): void; + deselectAllRows(onlyFilterData?: boolean): void; + setUpPaginator(): void; + createFilterDropdown(column: ColumnType, options: OverlaySettings): any; + updateCell(value: any, rowSelector: any, column: string): void; + // Type to RowType + createRow?(index: number, data?: any): RowType; + deleteRow(id: any): any; + deleteRowById(id: any): any; + updateRow(value: any, rowSelector: any): void; + collapseRow(id: any): void; + notifyChanges(repaint?: boolean): void; + resetColumnCollections(): void; + triggerPipes(): void; + repositionRowEditingOverlay(row: RowType): void; + closeRowEditingOverlay(): void; + reflow(): void; + + // TODO: Maybe move them to FlatGridType, but then will we need another token? + isExpandedGroup(group: IGroupByRecord): boolean; + createColumnsList?(cols: ColumnType[]): void; + toggleAllGroupRows?(): void; + toggleAll?(): void; + generateRowPath?(rowId: any): any[]; + preventHeaderScroll?(args: any): void; +} + +/** + * An interface describing a Flat Grid type. It is essentially the blueprint to a grid kind + * Contains definitions of properties and methods, relevant to a grid kind + * Extends from `GridType` + */ +export interface FlatGridType extends GridType { + groupingExpansionState: IGroupByExpandState[]; + groupingExpressions: IGroupingExpression[]; + groupingExpressionsChange: EventEmitter; + + toggleGroup(groupRow: IGroupByRecord): void; + clearGrouping(field: string): void; + groupBy(expression: IGroupingExpression | Array): void; +} + +/** + * An interface describing a Tree Grid type. It is essentially the blueprint to a grid kind + * Contains definitions of properties and methods, relevant to a grid kind + * Extends from `GridType` + */ +export interface TreeGridType extends GridType { + /* blazorSuppress */ + records: Map; + isTreeRow(rec: any): boolean; +} + +/** + * An interface describing a Hierarchical Grid type. It is essentially the blueprint to a grid kind + * Contains definitions of properties and methods, relevant to a grid kind + * Extends from `GridType` + */ +export interface HierarchicalGridType extends GridType { + childLayoutKeys: any[]; +} + +/** + * An interface describing a Pivot Grid type. It is essentially the blueprint to a grid kind + * Contains definitions of properties and methods, relevant to a grid kind + * Extends from `GridType` + */ +export interface PivotGridType extends GridType { + /** + * The configuration settings for the pivot grid. + * it includes dimension strategy for rows and columns, filters and data keys + */ + pivotConfiguration: IPivotConfiguration; + /** + * An array of all dimensions (rows and columns) in the pivot grid. + * it includes hierarchical level, filters and sorting, dimensional level, etc. + */ + allDimensions: IPivotDimension[], + /** Specifies whether to show the pivot configuration UI in the grid. */ + pivotUI: IPivotUISettings; + /** @hidden @internal */ + columnDimensions: IPivotDimension[]; + /** @hidden @internal */ + rowDimensions: IPivotDimension[]; + rowDimensionResizing: boolean; + /** @hidden @internal */ + visibleRowDimensions: IPivotDimension[]; + /** @hidden @internal */ + hasHorizontalLayout: boolean; + /** @hidden @internal */ + values: IPivotValue[]; + /** @hidden @internal */ + filterDimensions: IPivotDimension[]; + /** @hidden @internal */ + dimensionDataColumns: ColumnType[]; + pivotRowWidths: number; + getRowDimensionByName(name: string): IPivotDimension; + /** Represents a method declaration for setting up the columns for the pivot grid based on the pivot configuration */ + setupColumns(): void; + /** Represents a method declaration that allows toggle of expansion state of a row (taken as a parameter) in the pivot grid */ + toggleRow(rowID: any): void; + /** + * Represents a method declaration for resolving the data type for a specific field (column). + * It takes the field as a parameter and returns it's type + */ + resolveDataTypes(field: any): GridColumnDataType; + /** + * Represents a method declaration for moving dimension from its currently collection to the specified target collection + * by type (Row, Column or Filter) at specified index or at the collection's end + */ + moveDimension(dimension: IPivotDimension, targetCollectionType: PivotDimensionType, index?: number); + getDimensionsByType(dimension: PivotDimensionType); + /** Toggles the dimension's enabled state on or off. The dimension remains in its current collection */ + toggleDimension(dimension: IPivotDimension); + /** Sort the dimension and its children in the provided direction (ascending, descending or none). */ + sortDimension(dimension: IPivotDimension, sortDirection: SortingDirection); + /** Toggles the value's enabled state on or off. The value remains in its current collection. */ + toggleValue(value: IPivotValue); + /** Move value from its currently at specified index or at the end. + * If the parameter is not set, it will add it to the end of the collection. */ + moveValue(value: IPivotValue, index?: number); + rowDimensionWidth(dim: IPivotDimension): string; + rowDimensionWidthToPixels(dim: IPivotDimension): number; + /** Emits an event when the dimensions in the pivot grid change. */ + dimensionsChange: EventEmitter; + /** Emits an event when the values in the pivot grid change. */ + valuesChange: EventEmitter; + /** Emits an event when the a dimension is sorted. */ + dimensionsSortingExpressionsChange: EventEmitter; + /** @hidden @internal */ + pivotKeys: IPivotKeys; + hasMultipleValues: boolean; + excelStyleFilterMaxHeight: string; + excelStyleFilterMinHeight: string; + valueChipTemplate: TemplateRef; + rowDimensionHeaderTemplate: TemplateRef; +} + +export interface GridSVGIcon { + name: string; + value: string; +} + +export interface ISizeInfo { + width: number, + padding: number +} + +export interface IgxGridMasterDetailContext { + $implicit: any; + index: number; +} + +export interface IgxGroupByRowTemplateContext { + $implicit: IGroupByRecord; +} + +export interface IgxGridTemplateContext { + $implicit: GridType +} + +export interface IgxGridRowTemplateContext { + $implicit: RowType +} + +export interface IgxGridRowDragGhostContext { + $implicit: any, // this is the row data + data: any, // this is also the row data for some reason. + grid: GridType +} + +export interface IgxGridEmptyTemplateContext { + /* blazorSuppress */ + $implicit: undefined +} + +export interface IgxGridRowEditTemplateContext { + $implicit: undefined, + rowChangesCount: number, + endEdit: (commit: boolean, event?: Event) => void +} + +export interface IgxGridRowEditTextTemplateContext { + $implicit: number +} + +export interface IgxGridRowEditActionsTemplateContext { + /* blazorCSSuppress */ + /* blazorAlternateType: RowEditActionsImplicit */ + $implicit: (commit: boolean, event?: Event) => void +} + +export interface IgxGridHeaderTemplateContext { + $implicit: HeaderType +} + +export interface IgxColumnTemplateContext { + $implicit: ColumnType, + column: ColumnType +} + +export interface IgxCellTemplateContext { + $implicit: any, + additionalTemplateContext: any, + /* blazorSuppress */ + formControl?: FormControl, + /* blazorSuppress */ + defaultErrorTemplate?: TemplateRef, + cell: CellType +} + +/* jsonAPIComplexObject */ +export interface IgxRowSelectorTemplateDetails { + index: number; + /** + * @deprecated in version 15.1.0. Use the `key` property instead. + */ + rowID: any; + key: any; + selected: boolean; + select?: () => void; + deselect?: () => void; +} + +export interface IgxRowSelectorTemplateContext { + $implicit: IgxRowSelectorTemplateDetails; +} + +/* jsonAPIComplexObject */ +export interface IgxGroupByRowSelectorTemplateDetails { + selectedCount: number; + totalCount: number; + groupRow: IGroupByRecord; +} +export interface IgxGroupByRowSelectorTemplateContext { + $implicit: IgxGroupByRowSelectorTemplateDetails; +} + +/* jsonAPIComplexObject */ +export interface IgxHeadSelectorTemplateDetails { + selectedCount: number; + totalCount: number; + selectAll?: () => void; + deselectAll?: () => void; +} +export interface IgxHeadSelectorTemplateContext { + $implicit: IgxHeadSelectorTemplateDetails; +} + +export interface IgxSummaryTemplateContext { + $implicit: IgxSummaryResult[] +} + +export interface IgxGridPaginatorTemplateContext { + $implicit: GridType; +} + +/* marshalByValue */ +/* tsPlainInterface */ +/** + * An interface describing settings for row/column pinning position. + */ +export interface IPinningConfig { + columns?: ColumnPinningPosition; + rows?: RowPinningPosition; +} + +/** + * An interface describing settings for clipboard options + */ +export interface IClipboardOptions { + /** + * Enables/disables the copy behavior + */ + enabled: boolean; + /** + * Include the columns headers in the clipboard output. + */ + copyHeaders: boolean; + /** + * Apply the columns formatters (if any) on the data in the clipboard output. + */ + copyFormatters: boolean; + /** + * The separator used for formatting the copy output. Defaults to `\t`. + */ + separator: string; +} diff --git a/projects/igniteui-angular/grids/core/src/common/pipes.ts b/projects/igniteui-angular/grids/core/src/common/pipes.ts new file mode 100644 index 00000000000..9a7d14242fa --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/common/pipes.ts @@ -0,0 +1,415 @@ +import { Pipe, PipeTransform, inject } from '@angular/core'; +import { GridType, IGX_GRID_BASE, RowType } from './grid.interface'; +import { IgxAddRow } from './crud.service'; +import { IgxSummaryOperand } from '../summaries/grid-summary'; +import { IgxGridRow } from '../grid-public-row'; +import { cloneArray, columnFieldPath, DataUtil, IgxSummaryResult, resolveNestedPath } from 'igniteui-angular/core'; + +interface GridStyleCSSProperty { + [prop: string]: any; +} + +/** + * @hidden + * @internal + */ +@Pipe({ + name: 'igxCellStyleClasses', + standalone: true +}) +export class IgxGridCellStyleClassesPipe implements PipeTransform { + + public transform(cssClasses: GridStyleCSSProperty, _: any, data: any, field: string, index: number, __: number): string { + if (!cssClasses) { + return ''; + } + + const result = []; + const pathParts = columnFieldPath(field); + + for (const cssClass of Object.keys(cssClasses)) { + const callbackOrValue = cssClasses[cssClass]; + const apply = typeof callbackOrValue === 'function' ? + callbackOrValue(data, field, resolveNestedPath(data, pathParts), index) : callbackOrValue; + if (apply) { + result.push(cssClass); + } + } + + return result.join(' '); + } +} + +/** + * @hidden + * @internal + */ +@Pipe({ + name: 'igxCellStyles', + standalone: true +}) +export class IgxGridCellStylesPipe implements PipeTransform { + + public transform(styles: GridStyleCSSProperty, _: any, data: any, field: string, index: number, __: number): + GridStyleCSSProperty { + const css = {}; + if (!styles) { + return css; + } + + const pathParts = columnFieldPath(field); + + for (const prop of Object.keys(styles)) { + const res = styles[prop]; + css[prop] = typeof res === 'function' ? res(data, field, resolveNestedPath(data, pathParts), index) : res; + } + + return css; + } +} + +/** + * @hidden + * @internal + */ +@Pipe({ + name: 'igxCellImageAlt', + standalone: true +}) +export class IgxGridCellImageAltPipe implements PipeTransform { + + public transform(value: string): string { + if (value) { + const val = value.split('/'); + const imagename = val[val.length - 1].split('.'); + return imagename.length ? imagename[0] : ''; + } + return value; + } +} + + +/** + * @hidden + * @internal + */ +@Pipe({ + name: 'igxGridRowClasses', + standalone: true +}) +export class IgxGridRowClassesPipe implements PipeTransform { + private grid = inject(IGX_GRID_BASE); + + public row: RowType; + + constructor() { + this.row = new IgxGridRow(this.grid as any, -1, {}); + } + + public transform( + cssClasses: GridStyleCSSProperty, + row: RowType, + editMode: boolean, + selected: boolean, + dirty: boolean, + deleted: boolean, + dragging: boolean, + index: number, + mrl: boolean, + merged: boolean, + filteredOut: boolean, + _rowData: any, + _: number + ) { + const result = new Set(['igx-grid__tr', index % 2 ? 'igx-grid__tr--even' : 'igx-grid__tr--odd']); + const mapping = [ + [selected, 'igx-grid__tr--selected'], + [editMode, 'igx-grid__tr--edit'], + [dirty, 'igx-grid__tr--edited'], + [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'] + ]; + + for (const [state, _class] of mapping) { + if (state) { + result.add(_class as string); + } + } + + for (const cssClass of Object.keys(cssClasses ?? {})) { + const callbackOrValue = cssClasses[cssClass]; + this.row.index = index; + (this.row as any)._data = row.data; + const apply = typeof callbackOrValue === 'function' ? callbackOrValue(this.row) : callbackOrValue; + if (apply) { + result.add(cssClass); + } + } + return result; + } +} + +/** + * @hidden + * @internal + */ +@Pipe({ + name: 'igxGridRowStyles', + standalone: true +}) +export class IgxGridRowStylesPipe implements PipeTransform { + private grid = inject(IGX_GRID_BASE); + + + public transform(styles: GridStyleCSSProperty, rowData: any, index: number, __: number): GridStyleCSSProperty { + const css = {}; + if (!styles) { + return css; + } + for (const prop of Object.keys(styles)) { + const cb = styles[prop]; + const data = this.grid.isTreeRow && this.grid.isTreeRow(rowData) ? rowData.data : rowData; + const row = new IgxGridRow((this.grid as any), index, data); + css[prop] = typeof cb === 'function' ? cb(row) : cb; + } + return css; + } +} + +/** + * @hidden + * @internal + */ +@Pipe({ + name: 'igxNotGrouped', + standalone: true +}) +export class IgxGridNotGroupedPipe implements PipeTransform { + + public transform(value: any[]): any[] { + return value.filter(item => !item.columnGroup); + } +} + +/** + * @hidden + * @internal + */ +@Pipe({ + name: 'igxTopLevel', + standalone: true +}) +export class IgxGridTopLevelColumns implements PipeTransform { + + public transform(value: any[]): any[] { + return value.filter(item => item.level === 0); + } +} + +/** + * @hidden + * @internal + */ +@Pipe({ + name: 'filterCondition', + pure: true, + standalone: true +}) +export class IgxGridFilterConditionPipe implements PipeTransform { + + public transform(value: string): string { + return value.split(/(?=[A-Z])/).join(' '); + } +} + +/** + * @hidden + * @internal + */ +@Pipe({ + name: 'gridTransaction', + standalone: true +}) +export class IgxGridTransactionPipe implements PipeTransform { + private grid = inject(IGX_GRID_BASE); + + + public transform(collection: any[], _id: string, _pipeTrigger: number) { + + if (this.grid.transactions.enabled) { + const result = DataUtil.mergeTransactions( + cloneArray(collection), + this.grid.transactions.getAggregatedChanges(true), + this.grid.primaryKey, + this.grid.dataCloneStrategy); + return result; + } + return collection; + } +} + +/** + * @hidden + * @internal + */ +@Pipe({ + name: 'paginatorOptions', + standalone: true +}) +export class IgxGridPaginatorOptionsPipe implements PipeTransform { + public transform(values: Array) { + return Array.from(new Set([...values])).sort((a, b) => a - b); + } +} + +/** + * @hidden + * @internal + */ +@Pipe({ + name: 'visibleColumns', + standalone: true +}) +export class IgxHasVisibleColumnsPipe implements PipeTransform { + public transform(values: any[], hasVisibleColumns) { + if (!(values && values.length)) { + return values; + } + return hasVisibleColumns ? values : []; + } + +} + +/** @hidden @internal */ +function buildDataView(): MethodDecorator { + return function (_target: unknown, _propertyKey: string, descriptor: PropertyDescriptor) { + const original = descriptor.value; + descriptor.value = function (...args: unknown[]) { + const result = original.apply(this, args); + this.grid.buildDataView(); + return result; + } + return descriptor; + } +} + +/** + * @hidden + */ +@Pipe({ + name: 'gridRowPinning', + standalone: true +}) +export class IgxGridRowPinningPipe implements PipeTransform { + private grid = inject(IGX_GRID_BASE); + + + @buildDataView() + public transform(collection: any[], id: string, isPinned = false, _pipeTrigger: number) { + + if (this.grid.hasPinnedRecords && isPinned) { + const result = collection.filter(rec => !this.grid.isSummaryRow(rec) && this.grid.isRecordPinned(rec)); + result.sort((rec1, rec2) => this.grid.getInitialPinnedIndex(rec1) - this.grid.getInitialPinnedIndex(rec2)); + return result; + } + + this.grid.unpinnedRecords = collection; + if (!this.grid.hasPinnedRecords) { + this.grid.pinnedRecords = []; + return isPinned ? [] : collection; + } + + return collection.map((rec) => !this.grid.isSummaryRow(rec) && + this.grid.isRecordPinned(rec) ? { recordRef: rec, ghostRecord: true } : rec); + } +} + +@Pipe({ + name: 'dataMapper', + standalone: true +}) +export class IgxGridDataMapperPipe implements PipeTransform { + + public transform(data: any[], field: string, _: number, val: any, isNestedPath: boolean) { + return isNestedPath ? resolveNestedPath(data, columnFieldPath(field)) : val; + } +} + +@Pipe({ + name: 'igxStringReplace', + standalone: true +}) +export class IgxStringReplacePipe implements PipeTransform { + + public transform(value: string, search: string | RegExp, replacement: string): string { + return value.replace(search, replacement); + } +} + +@Pipe({ + name: 'transactionState', + standalone: true +}) +export class IgxGridTransactionStatePipe implements PipeTransform { + + public transform(row_id: any, field: string, rowEditable: boolean, transactions: any, _: any, __: any, ___: any) { + if (rowEditable) { + const rowCurrentState = transactions.getAggregatedValue(row_id, false); + if (rowCurrentState) { + const value = resolveNestedPath(rowCurrentState, columnFieldPath(field)); + return value !== undefined && value !== null; + } + } else { + const transaction = transactions.getState(row_id); + const value = resolveNestedPath(transaction?.value ?? {}, columnFieldPath(field)); + return transaction?.value && (value || value === 0 || value === false); + } + } +} + +@Pipe({ + name: 'columnFormatter', + standalone: true +}) +export class IgxColumnFormatterPipe implements PipeTransform { + + public transform(value: any, formatter: (v: any, data: any, columnData?: any) => any, rowData: any, columnData?: any) { + return formatter(value, rowData, columnData); + } +} + +@Pipe({ + name: 'summaryFormatter', + standalone: true +}) +export class IgxSummaryFormatterPipe implements PipeTransform { + + public transform(summaryResult: IgxSummaryResult, summaryOperand: IgxSummaryOperand, + summaryFormatter: (s: IgxSummaryResult, o: IgxSummaryOperand) => any) { + return summaryFormatter(summaryResult, summaryOperand); + } +} + +@Pipe({ + name: 'gridAddRow', + standalone: true +}) +export class IgxGridAddRowPipe implements PipeTransform { + private grid = inject(IGX_GRID_BASE); + + + public transform(collection: any, isPinned = false, _pipeTrigger: number) { + if (!this.grid.rowEditable || !this.grid.crudService.row || !this.grid.crudService.row.isAddRow || + !this.grid.crudService.addRowParent || isPinned !== this.grid.crudService.addRowParent.isPinned) { + return collection; + } + const copy = collection.slice(0); + const rec = (this.grid.crudService.row as IgxAddRow).recordRef; + copy.splice(this.grid.crudService.row.index, 0, rec); + return copy; + } +} diff --git a/projects/igniteui-angular/grids/core/src/common/pivot-strategy.ts b/projects/igniteui-angular/grids/core/src/common/pivot-strategy.ts new file mode 100644 index 00000000000..69b6b23c9c9 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/common/pivot-strategy.ts @@ -0,0 +1,191 @@ + +import type { PivotGridType } from './grid.interface'; +import { type ColumnType, FilteringStrategy, IgxFilterItem } from 'igniteui-angular/core'; +import { cloneArray } from 'igniteui-angular/core'; +import { IFilteringExpressionsTree } from 'igniteui-angular/core'; +import { IDataCloneStrategy } from 'igniteui-angular/core'; +import { DEFAULT_PIVOT_KEYS, IPivotDimension, IPivotDimensionStrategy, IPivotGridRecord, IPivotKeys, IPivotValue, PivotDimensionType } from '../pivot-grid.interface'; +import { PivotUtil } from '../pivot-util'; + +/* csSuppress */ +export class NoopPivotDimensionsStrategy implements IPivotDimensionStrategy { + private static _instance: NoopPivotDimensionsStrategy = null; + + public static instance(): NoopPivotDimensionsStrategy { + return this._instance || (this._instance = new NoopPivotDimensionsStrategy()); + } + + public process(collection: any[], _: IPivotDimension[], __: IPivotValue[]): any[] { + return collection; + } +} + + +export class PivotRowDimensionsStrategy implements IPivotDimensionStrategy { + private static _instance: PivotRowDimensionsStrategy = null; + + public static instance() { + return this._instance || (this._instance = new PivotRowDimensionsStrategy()); + } + + public process( + collection: any, + rows: IPivotDimension[], + values: IPivotValue[], + cloneStrategy: IDataCloneStrategy, + pivotKeys: IPivotKeys = DEFAULT_PIVOT_KEYS + ): IPivotGridRecord[] { + let hierarchies; + let data: IPivotGridRecord[]; + const prevRowDims = []; + const currRows = cloneArray(rows, true); + PivotUtil.assignLevels(currRows); + + if (currRows.length === 0) { + hierarchies = PivotUtil.getFieldsHierarchy(collection, [{ memberName: '', enabled: true }], PivotDimensionType.Row, pivotKeys, cloneStrategy); + // generate flat data from the hierarchies + data = PivotUtil.processHierarchy(hierarchies, pivotKeys, 0, true); + return data; + } + + for (const row of currRows) { + if (!data) { + // build hierarchies - groups and subgroups + hierarchies = PivotUtil.getFieldsHierarchy(collection, [row], PivotDimensionType.Row, pivotKeys, cloneStrategy); + // generate flat data from the hierarchies + data = PivotUtil.processHierarchy(hierarchies, pivotKeys, 0, true); + prevRowDims.push(row); + } else { + PivotUtil.processGroups(data, row, pivotKeys, cloneStrategy); + } + } + return data; + } +} + +export class PivotColumnDimensionsStrategy implements IPivotDimensionStrategy { + private static _instance: PivotRowDimensionsStrategy = null; + + public static instance() { + return this._instance || (this._instance = new PivotColumnDimensionsStrategy()); + } + + public process( + collection: IPivotGridRecord[], + columns: IPivotDimension[], + values: IPivotValue[], + cloneStrategy: IDataCloneStrategy, + pivotKeys: IPivotKeys = DEFAULT_PIVOT_KEYS + ): any[] { + const res = this.processHierarchy(collection, columns, values, pivotKeys, cloneStrategy); + return res; + } + + private processHierarchy(collection: IPivotGridRecord[], columns: IPivotDimension[], values, pivotKeys, cloneStrategy) { + const result: IPivotGridRecord[] = []; + collection.forEach(rec => { + // apply aggregations based on the created groups and generate column fields based on the hierarchies + this.groupColumns(rec, columns, values, pivotKeys, cloneStrategy); + result.push(rec); + }); + return result; + } + + private groupColumns(rec: IPivotGridRecord, columns, values, pivotKeys, cloneStrategy) { + const children = rec.children; + if (children && children.size > 0) { + children.forEach((childRecs) => { + if (childRecs) { + childRecs.forEach(child => { + this.groupColumns(child, columns, values, pivotKeys, cloneStrategy); + }) + } + }); + } + this.applyAggregates(rec, columns, values, pivotKeys, cloneStrategy); + } + + private applyAggregates(rec, columns, values, pivotKeys, cloneStrategy) { + const leafRecords = this.getLeafs(rec.records, pivotKeys); + const hierarchy = PivotUtil.getFieldsHierarchy(leafRecords, columns, PivotDimensionType.Column, pivotKeys, cloneStrategy); + PivotUtil.applyAggregations(rec, hierarchy, values, pivotKeys) + } + + private getLeafs(records, pivotKeys) { + let leafs = []; + for (const rec of records) { + if (rec[pivotKeys.records]) { + leafs = leafs.concat(this.getLeafs(rec[pivotKeys.records], pivotKeys)); + } else { + leafs.push(rec); + } + } + return leafs; + } +} + +export class DimensionValuesFilteringStrategy extends FilteringStrategy { + + /** + * Creates a new instance of FormattedValuesFilteringStrategy. + * + * @param fields An array of column field names that should be formatted. + * If omitted the values of all columns which has formatter will be formatted. + */ + constructor(private fields?: string[]) { + super(); + } + + protected override getFieldValue(rec: any, fieldName: string, _isDate = false, _isTime = false, + grid?: PivotGridType): any { + const allDimensions = grid.allDimensions; + const enabledDimensions = allDimensions.filter(x => x && x.enabled); + const dim :IPivotDimension = PivotUtil.flatten(enabledDimensions).find(x => x.memberName === fieldName); + const value = dim.childLevel ? this._getDimensionValueHierarchy(dim, rec).map(x => `[` + x +`]`).join('.') : PivotUtil.extractValueFromDimension(dim, rec); + return value; + } + + public override getFilterItems(column: ColumnType, tree: IFilteringExpressionsTree): Promise { + const grid = (column.grid as any); + const enabledDimensions = grid.allDimensions.filter(x => x && x.enabled); + const data = column.grid.gridAPI.filterDataByExpressions(tree); + const dim = enabledDimensions.find(x => x.memberName === column.field); + const allValuesHierarchy = PivotUtil.getFieldsHierarchy( + data, + [dim], + PivotDimensionType.Column, + grid.pivotKeys, + grid.pivotValueCloneStrategy + ); + const isNoop = grid.pivotConfiguration.columnStrategy instanceof NoopPivotDimensionsStrategy || grid.pivotConfiguration.rowStrategy instanceof NoopPivotDimensionsStrategy; + const items: IgxFilterItem[] = !isNoop ? this._getFilterItems(allValuesHierarchy, grid.pivotKeys) : [{value : ''}]; + return Promise.resolve(items); + } + + private _getFilterItems(hierarchy: Map, pivotKeys: IPivotKeys) : IgxFilterItem[] { + const items: IgxFilterItem[] = []; + hierarchy.forEach((value) => { + const val = value.value; + const path = val.split(pivotKeys.columnDimensionSeparator); + const hierarchicalValue = path.length > 1 ? path.map(x => `[` + x +`]`).join('.') : val; + const text = path[path.length -1]; + items.push({ + value: hierarchicalValue, + label: text, + children: this._getFilterItems(value.children, pivotKeys) + }); + }); + return items; + } + + private _getDimensionValueHierarchy(dim: IPivotDimension, rec: any) : string[] { + let path = []; + const value = PivotUtil.extractValueFromDimension(dim, rec); + path.push(value); + if (dim.childLevel) { + const childVals = this._getDimensionValueHierarchy(dim.childLevel, rec); + path = path.concat(childVals); + } + return path; + } +} diff --git a/projects/igniteui-angular/grids/core/src/common/public_api.ts b/projects/igniteui-angular/grids/core/src/common/public_api.ts new file mode 100644 index 00000000000..e7ce68a19e5 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/common/public_api.ts @@ -0,0 +1,8 @@ +export * from './enums'; +export * from './events'; +export * from './grid.interface'; +export * from './types'; +export * from './random'; +export * from './pipes'; +export * from './crud.service'; +export * from './pivot-strategy'; diff --git a/projects/igniteui-angular/grids/core/src/common/random.spec.ts b/projects/igniteui-angular/grids/core/src/common/random.spec.ts new file mode 100644 index 00000000000..1f6a6fc56d3 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/common/random.spec.ts @@ -0,0 +1,26 @@ +import { getUUID } from './random'; + +describe('Random (crypto.randomUuid()) fallback unit tests', () => { + const originalRandomUuid = crypto.randomUUID; + + beforeAll(() => { + crypto.randomUUID = null; // Mock crypto.randomUUID to simulate a non-secure context + }); + + it('should generate a valid UUID', () => { + const uuid = getUUID(); + expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + }); + + it('should generate unique UUIDs', () => { + const uuids = new Set(); + for (let i = 0; i < 100; i++) { + uuids.add(getUUID()); + } + expect(uuids.size).toBe(100); // All UUIDs should be unique + }); + + afterAll(() => { + crypto.randomUUID = originalRandomUuid; // Restore the original function + }); +}); \ No newline at end of file diff --git a/projects/igniteui-angular/grids/core/src/common/random.ts b/projects/igniteui-angular/grids/core/src/common/random.ts new file mode 100644 index 00000000000..4c7d0684653 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/common/random.ts @@ -0,0 +1,19 @@ +/** + * Use the function to get a random UUID string when secure context is not guaranteed making crypto.randomUUID unavailable. + * @returns A random UUID string. + */ +export function getUUID(): `${string}-${string}-${string}-${string}-${string}` { + if (typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + // Secure fallback using crypto.getRandomValues() + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + + // Set version (4) and variant (RFC 4122) + bytes[6] = (bytes[6] & 0x0f) | 0x40; // Version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // Variant 1 + + const a = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join(''); + return `${a.slice(0, 8)}-${a.slice(8, 12)}-${a.slice(12, 16)}-${a.slice(16, 20)}-${a.slice(20)}`; +} diff --git a/projects/igniteui-angular/grids/core/src/common/types.ts b/projects/igniteui-angular/grids/core/src/common/types.ts new file mode 100644 index 00000000000..8d566c654ac --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/common/types.ts @@ -0,0 +1,107 @@ +import { InjectionToken } from '@angular/core'; +import { State, Transaction, TransactionService } from 'igniteui-angular/core'; + +/* tsPlainInterface */ +/* marshalByValue */ +/** + * Represents a range selection between certain rows and columns of the grid. + * Range selection can be made either through drag selection or through keyboard selection. + */ +export interface GridSelectionRange { + /** The index of the starting row of the selection range. */ + rowStart: number; + /** The index of the ending row of the selection range. */ + rowEnd: number; + /* blazorAlternateType: double */ + /** + * The identifier or index of the starting column of the selection range. + * It can be either a string representing the column's field name or a numeric index. + */ + columnStart: string | number; + /* blazorAlternateType: double */ + /** + * The identifier or index of the ending column of the selection range. + * It can be either a string representing the column's field name or a numeric index. + */ + columnEnd: string | number; +} + +/** + * Represents a single selected cell or node in a grid. + */ +export interface ISelectionNode { + /** + * The index of the selected row. + */ + row: number; + /** + * The index of the selected column. + */ + column: number; + /** + * (Optional) + * Additional layout information for multi-row selection nodes. + */ + layout?: IMultiRowLayoutNode; + /** + * (Optional) + * Indicates if the selected node is a summary row. + * This property is true if the selected row is a summary row; otherwise, it is false. + */ + isSummaryRow?: boolean; +} + +export interface IMultiRowLayoutNode { + rowStart: number; + colStart: number; + rowEnd: number; + colEnd: number; + columnVisibleIndex: number; +} + +/** + * Represents the state of the keyboard when selecting. + */ +export interface ISelectionKeyboardState { + /** The selected node in the grid, if any. Can be null if no node is selected. */ + node: null | ISelectionNode; + /** Indicates whether the Shift key is currently pressed during the selection. */ + shift: boolean; + /** The range of the selected cells in the grid. Can be null when resetting the selection. */ + range: GridSelectionRange; + /** Indicates whether the selection is currently active (being performed). `False` when resetting the selection. */ + active: boolean; +} + +/** + * Represents the state of the grid selection using pointer interactions (mouse). + * Extends ISelectionKeyboardState to include pointer-specific properties. + */ +export interface ISelectionPointerState extends ISelectionKeyboardState { + /** Indicates whether the Ctrl key is currently pressed during the selection. */ + ctrl: boolean; + /** Indicates whether the primary pointer button is pressed during the selection (clicked). */ + primaryButton: boolean; +} + +/** + * Represents the state of the columns in the grid. + */ +export interface IColumnSelectionState { + /** Represents the field name of the selected column, if any. Can be null if no column is selected. */ + field: null | string; + /** An array of strings representing the ranges of selected columns in the grid. */ + range: string[]; +} + +/** + * Represents the overall state of grid selection, combining both keyboard and pointer interaction states. + * It can be either an ISelectionKeyboardState or an ISelectionPointerState. + */ +export type SelectionState = ISelectionKeyboardState | ISelectionPointerState; + +/** + * Injection token for accessing the grid transaction object. + * This allows injecting the grid transaction object into components or services. + */ +export const IgxGridTransaction = /*@__PURE__*/new InjectionToken>('IgxGridTransaction'); diff --git a/projects/igniteui-angular/grids/core/src/filtering/advanced-filtering/advanced-filtering-dialog.component.html b/projects/igniteui-angular/grids/core/src/filtering/advanced-filtering/advanced-filtering-dialog.component.html new file mode 100644 index 00000000000..eb11d0aaa6c --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/advanced-filtering/advanced-filtering-dialog.component.html @@ -0,0 +1,38 @@ +@if (grid) { +
    + + + + + +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    +} diff --git a/projects/igniteui-angular/grids/core/src/filtering/advanced-filtering/advanced-filtering-dialog.component.ts b/projects/igniteui-angular/grids/core/src/filtering/advanced-filtering/advanced-filtering-dialog.component.ts new file mode 100644 index 00000000000..8de843ac1e9 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/advanced-filtering/advanced-filtering-dialog.component.ts @@ -0,0 +1,238 @@ +import { Component, Input, ViewChild, ChangeDetectorRef, AfterViewInit, OnDestroy, HostBinding, inject } from '@angular/core'; +import { Subject } from 'rxjs'; +import { IActiveNode } from '../../grid-navigation.service'; +import { GridType } from '../../common/grid.interface'; +import { NgClass } from '@angular/common'; +import { IDragStartEventArgs, IgxButtonDirective, IgxDragDirective, IgxDragHandleDirective } from 'igniteui-angular/directives'; +import { IgxQueryBuilderComponent, IgxQueryBuilderHeaderComponent } from 'igniteui-angular/query-builder'; +import { EntityType, FieldType, getCurrentResourceStrings, GridResourceStringsEN, IFilteringExpressionsTree, IgxOverlayService, PlatformUtil, QueryBuilderResourceStringsEN } from 'igniteui-angular/core'; + +/** + * A component used for presenting advanced filtering UI for a Grid. + * It is used internally in the Grid, but could also be hosted in a container outside of it. + * + * Example: + * ```html + * + * + * ``` + */ +@Component({ + selector: 'igx-advanced-filtering-dialog', + templateUrl: './advanced-filtering-dialog.component.html', + imports: [IgxDragDirective, NgClass, IgxQueryBuilderComponent, IgxQueryBuilderHeaderComponent, IgxDragHandleDirective, IgxButtonDirective] +}) +export class IgxAdvancedFilteringDialogComponent implements AfterViewInit, OnDestroy { + public cdr = inject(ChangeDetectorRef); + protected platform = inject(PlatformUtil); + + /** + * @hidden @internal + */ + @ViewChild('queryBuilder', { read: IgxQueryBuilderComponent }) + public queryBuilder: IgxQueryBuilderComponent; + + /** + * @hidden @internal + */ + @HostBinding('style.display') + public display = 'block'; + + /** + * @hidden @internal + */ + public inline = true; + + /** + * @hidden @internal + */ + public lastActiveNode = {} as IActiveNode; + + private destroy$ = new Subject(); + private _overlayComponentId: string; + private _overlayService: IgxOverlayService; + private _grid: GridType; + /** + * @hidden @internal + */ + public ngAfterViewInit(): void { + this.queryBuilder.setPickerOutlet(this.grid.outlet); + } + + /** + * @hidden @internal + */ + public ngOnDestroy(): void { + this.destroy$.next(true); + this.destroy$.complete(); + } + + /** + * Assigns the grid instance corresponding to the advanced filtering dialog instance. + */ + @Input() + public set grid(grid: GridType) { + this._grid = grid; + + if (this._grid) { + this._grid.filteringService.registerSVGIcons(); + } + + this.assignResourceStrings(); + } + + /** + * Returns the grid. + */ + public get grid(): GridType { + return this._grid; + } + + /** + * @hidden @internal + */ + public get filterableFields(): FieldType[] { + return this.grid.columns.filter((column) => !column.columnGroup && column.filterable) + } + + /** + * @hidden @internal + */ + public dragStart(dragArgs: IDragStartEventArgs) { + if (!this._overlayComponentId) { + dragArgs.cancel = true; + return; + } + } + + /** + * @hidden @internal + */ + public onDragMove(e) { + const deltaX = e.nextPageX - e.pageX; + const deltaY = e.nextPageY - e.pageY; + e.cancel = true; + this._overlayService.setOffset(this._overlayComponentId, deltaX, deltaY); + } + + /** + * @hidden @internal + */ + public onKeyDown(eventArgs: KeyboardEvent) { + eventArgs.stopPropagation(); + const key = eventArgs.key; + if (key === this.platform.KEYMAP.ESCAPE) { + this.closeDialog(); + } + } + + /** + * @hidden @internal + */ + public initialize(grid: GridType, overlayService: IgxOverlayService, + overlayComponentId: string) { + this.inline = false; + this.grid = grid; + this._overlayService = overlayService; + this._overlayComponentId = overlayComponentId; + } + + /** + * @hidden @internal + */ + public onClearButtonClick(event?: Event) { + this.grid.crudService.endEdit(false, event); + this.queryBuilder.expressionTree = this.grid.advancedFilteringExpressionsTree = null; + } + + /** + * @hidden @internal + */ + public closeDialog() { + if (this._overlayComponentId) { + this._overlayService.hide(this._overlayComponentId); + } + this.grid.navigation.activeNode = this.lastActiveNode; + if (this.grid.navigation.activeNode && this.grid.navigation.activeNode.row === -1) { + (this.grid as any).theadRow.nativeElement.focus(); + } + } + + /** + * @hidden @internal + */ + public applyChanges(event?: Event) { + this.grid.crudService.endEdit(false, event); + this.queryBuilder.exitOperandEdit(); + this.grid.advancedFilteringExpressionsTree = this.queryBuilder.expressionTree as IFilteringExpressionsTree; + } + + /** + * @hidden @internal + */ + public cancelChanges() { + this.closeDialog(); + } + + /** + * @hidden @internal + */ + public onApplyButtonClick(event?: Event) { + this.applyChanges(event); + this.closeDialog(); + } + + /** + * @hidden @internal + */ + public generateEntity() { + if (this.queryBuilder?.entities) { + return this.queryBuilder?.entities; + } else if (this.grid.type === 'hierarchical') { + return this.grid.schema; + } else { + const entities: EntityType[] = [ + { + name: null, + fields: this.filterableFields.map(f => ({ + field: f.field, + dataType: f.dataType, + label: f.label, + header: f.header, + editorOptions: f.editorOptions, + filters: f.filters, + pipeArgs: f.pipeArgs, + defaultTimeFormat: f.defaultTimeFormat, + defaultDateTimeFormat: f.defaultDateTimeFormat + })) as FieldType[] + } + ]; + return entities; + } + } + + private assignResourceStrings() { + // If grid has custom resource strings set for the advanced filtering, + // they are passed to the query builder resource strings. + const gridRS = this.grid.resourceStrings; + + if (gridRS !== GridResourceStringsEN) { + const queryBuilderRS = getCurrentResourceStrings(QueryBuilderResourceStringsEN); + Object.keys(gridRS).forEach((prop) => { + const reg = /^igx_grid_(advanced_)?filter_(row_)?/; + if (!reg.test(prop)) { + return; + } + const affix = prop.replace(reg, ''); + const filterProp = `igx_query_builder_filter_${affix}`; + const generalProp = `igx_query_builder_${affix}` + if (queryBuilderRS[filterProp] !== undefined) { + queryBuilderRS[filterProp] = gridRS[prop]; + } else if (queryBuilderRS[generalProp] !== undefined) { + queryBuilderRS[generalProp] = gridRS[prop]; + } + }); + } + } +} diff --git a/projects/igniteui-angular/grids/core/src/filtering/base/grid-filtering-cell.component.html b/projects/igniteui-angular/grids/core/src/filtering/base/grid-filtering-cell.component.html new file mode 100644 index 00000000000..3fb62f3b3b1 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/base/grid-filtering-cell.component.html @@ -0,0 +1,53 @@ + + + + + {{filteringService.grid.resourceStrings.igx_grid_filter}} + + + + + + + + @for (item of expressionsList; track item.expression; let last = $last; let index = $index) { + @if (isChipVisible(index)) { + + + + + {{filteringService.getChipLabel(item.expression)}} + + + } + @if (!last && isChipVisible(index + 1)) { + {{filteringService.getOperatorAsString(item.afterOperator)}} + } + } +
    + + +
    +
    +
    + + + + + {{filteringService.grid.resourceStrings.igx_grid_complex_filter}} + + + + diff --git a/projects/igniteui-angular/grids/core/src/filtering/base/grid-filtering-cell.component.ts b/projects/igniteui-angular/grids/core/src/filtering/base/grid-filtering-cell.component.ts new file mode 100644 index 00000000000..c99c34d3355 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/base/grid-filtering-cell.component.ts @@ -0,0 +1,252 @@ +import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, DoCheck, ElementRef, HostBinding, Input, OnInit, TemplateRef, ViewChild, inject } from '@angular/core'; +import { IgxFilteringService } from '../grid-filtering.service'; +import { ExpressionUI } from '../excel-style/common'; +import { NgClass, NgTemplateOutlet } from '@angular/common'; +import { IBaseChipEventArgs, IgxChipComponent, IgxChipsAreaComponent } from 'igniteui-angular/chips'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxPrefixDirective } from 'igniteui-angular/input-group'; +import { IgxBadgeComponent } from 'igniteui-angular/badge'; +import { ColumnType, IFilteringExpression, ɵSize } from 'igniteui-angular/core'; + +/** + * @hidden + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'igx-grid-filtering-cell', + templateUrl: './grid-filtering-cell.component.html', + imports: [ + IgxChipsAreaComponent, + IgxChipComponent, + IgxIconComponent, + IgxPrefixDirective, + NgClass, + IgxBadgeComponent, + NgTemplateOutlet + ] +}) +export class IgxGridFilteringCellComponent implements AfterViewInit, OnInit, DoCheck { + public cdr = inject(ChangeDetectorRef); + public filteringService = inject(IgxFilteringService); + + @Input() + public column: ColumnType; + + @ViewChild('emptyFilter', { read: TemplateRef, static: true }) + protected emptyFilter: TemplateRef; + + @ViewChild('defaultFilter', { read: TemplateRef, static: true }) + protected defaultFilter: TemplateRef; + + @ViewChild('complexFilter', { read: TemplateRef, static: true }) + protected complexFilter: TemplateRef; + + @ViewChild('chipsArea', { read: IgxChipsAreaComponent }) + protected chipsArea: IgxChipsAreaComponent; + + @ViewChild('moreIcon', { read: ElementRef }) + protected moreIcon: ElementRef; + + @ViewChild('ghostChip', { read: IgxChipComponent }) + protected ghostChip: IgxChipComponent; + + @ViewChild('complexChip', { read: IgxChipComponent }) + protected complexChip: IgxChipComponent; + + + @HostBinding('class') + public get styleClasses(): string { + return this.column && this.column.selected ? + 'igx-grid__filtering-cell--selected' : + 'igx-grid__filtering-cell'; + } + + public expressionsList: ExpressionUI[]; + public moreFiltersCount = 0; + + @HostBinding('class.igx-grid-th--pinned') + public get pinnedCss() { + return this.column.pinned; + } + + @HostBinding('class.igx-grid-th--pinned-last') + public get pinnedLastCss() { + return !this.column.grid.hasColumnLayouts ? this.column.isLastPinned : false; + } + + @HostBinding('class.igx-grid-th--pinned-first') + public get pinnedFirstCSS() { + return !this.column.grid.hasColumnLayouts ? this.column.isFirstPinned : false;; + } + + private baseClass = 'igx-grid__filtering-cell-indicator'; + + constructor() { + this.filteringService.subscribeToEvents(); + } + + public ngOnInit(): void { + this.filteringService.columnToMoreIconHidden.set(this.column.field, true); + } + + public ngAfterViewInit(): void { + this.updateFilterCellArea(); + } + + public ngDoCheck() { + this.updateFilterCellArea(); + } + + /** + * Returns whether a chip with a given index is visible or not. + */ + public isChipVisible(index: number) { + const expression = this.expressionsList[index]; + return !!(expression && expression.isVisible); + } + + /** + * Updates the filtering cell area. + */ + public updateFilterCellArea() { + this.expressionsList = this.filteringService.getExpressions(this.column.field); + this.updateVisibleFilters(); + } + + public get template(): TemplateRef { + if (!this.column.filterable) { + return null; + } + if (this.column.filterCellTemplate) { + return this.column.filterCellTemplate; + } + const expressionTree = this.column.filteringExpressionsTree; + if (!expressionTree || expressionTree.filteringOperands.length === 0) { + return this.emptyFilter; + } + if (this.filteringService.isFilterComplex(this.column.field)) { + return this.complexFilter; + } + return this.defaultFilter; + } + + /** + * Gets the context passed to the filter template. + * + * @memberof IgxGridFilteringCellComponent + */ + public get context() { + return { $implicit: this.column, column: this.column}; + } + + /** + * Chip clicked event handler. + */ + public onChipClicked(expression?: IFilteringExpression) { + if (expression) { + this.expressionsList.forEach((item) => { + item.isSelected = (item.expression === expression); + }); + } else if (this.expressionsList.length > 0) { + this.expressionsList.forEach((item) => { + item.isSelected = false; + }); + this.expressionsList[0].isSelected = true; + } + this.filteringService.grid.navigation.performHorizontalScrollToCell(this.column.visibleIndex); + this.filteringService.filteredColumn = this.column; + this.filteringService.isFilterRowVisible = true; + this.filteringService.selectedExpression = expression; + } + + /** + * Chip removed event handler. + */ + public onChipRemoved(eventArgs: IBaseChipEventArgs, item: ExpressionUI): void { + const indexToRemove = this.expressionsList.indexOf(item); + this.removeExpression(indexToRemove); + this.filteringService.grid.theadRow.nativeElement.focus(); + } + + /** + * Clears the filtering. + */ + public clearFiltering(): void { + this.filteringService.clearFilter(this.column.field); + this.cdr.detectChanges(); + } + + /** + * Returns the filtering indicator class. + */ + public filteringIndicatorClass() { + return { + [this.baseClass]: !this.isMoreIconHidden(), + [`${this.baseClass}--hidden`]: this.isMoreIconHidden() + }; + } + + protected get filteringElementsSize(): ɵSize { + return this.column.grid.gridSize === ɵSize.Large ? ɵSize.Medium : this.column.grid.gridSize; + } + + private removeExpression(indexToRemove: number) { + if (indexToRemove === 0 && this.expressionsList.length === 1) { + this.clearFiltering(); + return; + } + + this.filteringService.removeExpression(this.column.field, indexToRemove); + + this.updateVisibleFilters(); + this.filteringService.filterInternal(this.column.field); + } + + private isMoreIconHidden(): boolean { + return this.filteringService.columnToMoreIconHidden.get(this.column.field); + } + + private updateVisibleFilters() { + this.expressionsList.forEach((ex) => ex.isVisible = true); + + if (this.moreIcon) { + this.filteringService.columnToMoreIconHidden.set(this.column.field, true); + } + this.cdr.detectChanges(); + + if (this.chipsArea && this.expressionsList.length > 1) { + const areaWidth = this.chipsArea.element.nativeElement.offsetWidth; + let viewWidth = 0; + const chipsAreaElements = this.chipsArea.element.nativeElement.children; + let visibleChipsCount = 0; + const moreIconWidth = this.moreIcon.nativeElement.offsetWidth - + parseInt(this.column?.grid.document.defaultView.getComputedStyle(this.moreIcon.nativeElement)['margin-left'], 10); + + for (let index = 0; index < chipsAreaElements.length - 1; index++) { + if (viewWidth + chipsAreaElements[index].offsetWidth < areaWidth) { + viewWidth += chipsAreaElements[index].offsetWidth; + if (index % 2 === 0) { + visibleChipsCount++; + } else { + viewWidth += parseInt(this.column?.grid.document.defaultView.getComputedStyle(chipsAreaElements[index])['margin-left'], 10); + viewWidth += parseInt(this.column?.grid.document.defaultView.getComputedStyle(chipsAreaElements[index])['margin-right'], 10); + } + } else { + if (index % 2 !== 0 && viewWidth + moreIconWidth > areaWidth) { + visibleChipsCount--; + } else if (visibleChipsCount > 0 && viewWidth - chipsAreaElements[index - 1].offsetWidth + moreIconWidth > areaWidth) { + visibleChipsCount--; + } + this.moreFiltersCount = this.expressionsList.length - visibleChipsCount; + this.filteringService.columnToMoreIconHidden.set(this.column.field, false); + break; + } + } + + for (let i = visibleChipsCount; i < this.expressionsList.length; i++) { + this.expressionsList[i].isVisible = false; + } + this.cdr.detectChanges(); + } + } +} diff --git a/projects/igniteui-angular/grids/core/src/filtering/base/grid-filtering-row.component.html b/projects/igniteui-angular/grids/core/src/filtering/base/grid-filtering-row.component.html new file mode 100644 index 00000000000..ce397fa1313 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/base/grid-filtering-row.component.html @@ -0,0 +1,274 @@ + + + @for (condition of conditions; track condition) { + +
    + + {{ translateCondition(condition) }} +
    +
    + } +
    + + + + + + + + @if (value || value === 0) { + + + + + + + } + + + + + + + + + @if (value) { + + + + + + + } + + + + + + + + + + + + @if (value) { + + + + + + + } + + + + + + + + + + + + + @if (value || value === 0) { + + + + + + + } + + + + + +@if (showArrows) { + +} + +
    +
    + + + @for (item of expressionsList; track item.expression; let i = $index; let last = $last) { + + + + + {{filteringService.getChipLabel(item.expression)}} + + @if (!last) { + + + + {{filteringService.grid.resourceStrings.igx_grid_filter_operator_and}} + {{filteringService.grid.resourceStrings.igx_grid_filter_operator_or}} + + + } + + } + +
    +
    + +@if (showArrows) { + +} + +
    + @if (!isNarrowWidth) { + + + } + @if (isNarrowWidth) { + + + } +
    diff --git a/projects/igniteui-angular/grids/core/src/filtering/base/grid-filtering-row.component.ts b/projects/igniteui-angular/grids/core/src/filtering/base/grid-filtering-row.component.ts new file mode 100644 index 00000000000..5343a747db8 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/base/grid-filtering-row.component.ts @@ -0,0 +1,907 @@ +import { + AfterViewInit, + ChangeDetectorRef, + Component, + Input, + TemplateRef, + ViewChild, + ViewChildren, + QueryList, + ElementRef, + HostBinding, + ChangeDetectionStrategy, + ViewRef, + HostListener, + OnDestroy, + InjectionToken, + inject, + OnInit} from '@angular/core'; +import { IgxFilteringService } from '../grid-filtering.service'; +import { Subject } from 'rxjs'; +import { debounceTime, takeUntil } from 'rxjs/operators'; +import { ExpressionUI } from '../excel-style/common'; +import { NgTemplateOutlet, NgClass } from '@angular/common'; +import { IgxDropDownComponent, IgxDropDownItemComponent, IgxDropDownItemNavigationDirective, ISelectionEventArgs } from 'igniteui-angular/drop-down'; +import { IBaseChipEventArgs, IgxChipComponent, IgxChipsAreaComponent } from 'igniteui-angular/chips'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxInputDirective, IgxInputGroupComponent, IgxPrefixDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; +import { IgxDatePickerComponent } from 'igniteui-angular/date-picker'; +import { AbsoluteScrollStrategy, ColumnType, ConnectedPositioningStrategy, DataUtil, FilteringLogic, GridColumnDataType, HorizontalAlignment, IFilteringExpression, IFilteringOperation, IgxPickerClearComponent, IgxPickerToggleComponent, isEqual, OverlaySettings, PlatformUtil, ɵSize, VerticalAlignment } from 'igniteui-angular/core'; +import { IgxTimePickerComponent } from 'igniteui-angular/time-picker'; +import { IgxButtonDirective, IgxDateTimeEditorDirective, IgxIconButtonDirective, IgxRippleDirective } from 'igniteui-angular/directives'; + +/** + * Injection token for setting the debounce time used in filtering row inputs. + * @hidden + */ +export const INPUT_DEBOUNCE_TIME = /*@__PURE__*/new InjectionToken('INPUT_DEBOUNCE_TIME', { + factory: () => 350 +}); + +/** + * @hidden + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'igx-grid-filtering-row', + templateUrl: './grid-filtering-row.component.html', + imports: [ + IgxDropDownComponent, + IgxDropDownItemComponent, + IgxChipsAreaComponent, + IgxChipComponent, + IgxIconComponent, + IgxInputGroupComponent, + IgxPrefixDirective, + IgxDropDownItemNavigationDirective, + IgxInputDirective, + IgxSuffixDirective, + IgxDatePickerComponent, + IgxPickerToggleComponent, + IgxPickerClearComponent, + IgxTimePickerComponent, + IgxDateTimeEditorDirective, + NgTemplateOutlet, + IgxButtonDirective, + NgClass, + IgxRippleDirective, + IgxIconButtonDirective + ] +}) +export class IgxGridFilteringRowComponent implements OnInit, AfterViewInit, OnDestroy { + public filteringService = inject(IgxFilteringService); + public ref = inject>(ElementRef); + public cdr = inject(ChangeDetectorRef); + protected platform = inject(PlatformUtil); + + @Input() + public get column(): ColumnType { + return this._column; + } + + public set column(val) { + if (this._column) { + this.expressionsList.forEach(exp => exp.isSelected = false); + } + if (val) { + this._column = val; + + this.expressionsList = this.filteringService.getExpressions(this._column.field); + this.resetExpression(); + + this.chipAreaScrollOffset = 0; + this.transform(this.chipAreaScrollOffset); + } + } + + @Input() + public get value(): any { + return this._value; + } + + public set value(val) { + if (!val && val !== 0 && (this.expression.searchVal || this.expression.searchVal === 0)) { + this.expression.searchVal = null; + this._value = null; + const index = this.expressionsList.findIndex(item => item.expression === this.expression); + if (index === 0 && this.expressionsList.length === 1 && !this.expression.condition.isUnary) { + this.filteringService.clearFilter(this.column.field); + } + } else { + if (val === '') { + return; + } + const oldValue = this.expression.searchVal; + if (isEqual(oldValue, val)) { + return; + } + + this._value = val; + this.expression.searchVal = DataUtil.parseValue(this.column.dataType, val); + if (this.expressionsList.find(item => item.expression === this.expression) === undefined) { + this.addExpression(true); + } + this.filter(); + } + } + + protected get filteringElementsSize(): ɵSize { + // needed because we want the size of the chips to be either Medium or Small + return this.column.grid.gridSize === ɵSize.Large ? ɵSize.Medium : this.column.grid.gridSize; + } + + @HostBinding('class.igx-grid__filtering-row') + public defaultCSSClass = true; + + @ViewChild('defaultFilterUI', { read: TemplateRef, static: true }) + protected defaultFilterUI: TemplateRef; + + @ViewChild('defaultDateUI', { read: TemplateRef, static: true }) + protected defaultDateUI: TemplateRef; + + @ViewChild('defaultTimeUI', { read: TemplateRef, static: true }) + protected defaultTimeUI: TemplateRef; + + @ViewChild('defaultDateTimeUI', { read: TemplateRef, static: true }) + protected defaultDateTimeUI: TemplateRef; + + @ViewChild('input', { read: ElementRef }) + protected input: ElementRef; + + @ViewChild('inputGroupConditions', { read: IgxDropDownComponent, static: true }) + protected dropDownConditions: IgxDropDownComponent; + + @ViewChild('chipsArea', { read: IgxChipsAreaComponent, static: true }) + protected chipsArea: IgxChipsAreaComponent; + + @ViewChildren('operators', { read: IgxDropDownComponent }) + protected dropDownOperators: QueryList; + + @ViewChild('inputGroup', { read: ElementRef }) + protected inputGroup: ElementRef; + + @ViewChild('picker') + protected picker: IgxDatePickerComponent | IgxTimePickerComponent; + + @ViewChild('inputGroupPrefix', { read: ElementRef }) + protected inputGroupPrefix: ElementRef; + + @ViewChild('container', { static: true }) + protected container: ElementRef; + + @ViewChild('operand') + protected operand: ElementRef; + + @ViewChild('closeButton', { static: true }) + protected closeButton: ElementRef; + + public get nativeElement() { + return this.ref.nativeElement; + } + + public showArrows: boolean; + public expression: IFilteringExpression; + public expressionsList: Array; + + private _positionSettings = { + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Bottom + }; + + private _conditionsOverlaySettings: OverlaySettings = { + closeOnOutsideClick: true, + modal: false, + scrollStrategy: new AbsoluteScrollStrategy(), + positionStrategy: new ConnectedPositioningStrategy(this._positionSettings) + }; + + private _operatorsOverlaySettings: OverlaySettings = { + closeOnOutsideClick: true, + modal: false, + scrollStrategy: new AbsoluteScrollStrategy(), + positionStrategy: new ConnectedPositioningStrategy(this._positionSettings) + }; + + private chipsAreaWidth: number; + private chipAreaScrollOffset = 0; + private _column = null; + private isKeyPressed = false; + private isComposing = false; + private _cancelChipClick = false; + private _value = null; + + /** switch to icon buttons when width is below 432px */ + private readonly NARROW_WIDTH_THRESHOLD = 432; + + private inputSubject: Subject = new Subject(); + + private $destroyer = new Subject(); + private readonly DEBOUNCE_TIME = inject(INPUT_DEBOUNCE_TIME); + + public ngOnInit(): void { + this.inputSubject.pipe( + debounceTime(this.DEBOUNCE_TIME), + takeUntil(this.$destroyer) + ).subscribe(event => { + this.handleInputChange(event); + this.cdr.markForCheck(); // ChangeDetectionStrategy.OnPush is not picking the latest changes of the updated value because of the async pipe + debounce. + }); + } + + @HostListener('keydown', ['$event']) + public onKeydownHandler(evt: KeyboardEvent) { + if (this.platform.isFilteringKeyCombo(evt)) { + evt.preventDefault(); + evt.stopPropagation(); + this.close(); + } + } + + public ngAfterViewInit() { + this._conditionsOverlaySettings.outlet = this.column.grid.outlet; + this._operatorsOverlaySettings.outlet = this.column.grid.outlet; + + const selectedItem = this.expressionsList.find(expr => expr.isSelected === true); + if (selectedItem) { + this.expression = selectedItem.expression; + this._value = this.expression.searchVal; + } + + this.filteringService.grid.localeChange + .pipe(takeUntil(this.$destroyer)) + .subscribe(() => { + this.cdr.markForCheck(); + }); + + requestAnimationFrame(() => this.focusEditElement()); + } + + public get disabled(): boolean { + return !(this.column.filteringExpressionsTree && this.column.filteringExpressionsTree.filteringOperands.length > 0); + } + + public get template(): TemplateRef { + if (this.column.dataType === GridColumnDataType.Date) { + return this.defaultDateUI; + } + if (this.column.dataType === GridColumnDataType.Time) { + return this.defaultTimeUI; + } + if (this.column.dataType === GridColumnDataType.DateTime) { + return this.defaultDateTimeUI; + } + return this.defaultFilterUI; + } + + public get type() { + switch (this.column.dataType) { + case GridColumnDataType.String: + case GridColumnDataType.Boolean: + return 'text'; + case GridColumnDataType.Number: + case GridColumnDataType.Currency: + return 'number'; + } + } + + public get conditions(): any { + return this.column.filters.conditionList(); + } + + public get isUnaryCondition(): boolean { + if (this.expression.condition) { + return this.expression.condition.isUnary; + } else { + return true; + } + } + + public get placeholder(): string { + if (this.expression.condition && this.expression.condition.isUnary) { + return this.filteringService.getChipLabel(this.expression); + } else if (this.column.dataType === GridColumnDataType.Date) { + return this.filteringService.grid.resourceStrings.igx_grid_filter_row_date_placeholder; + } else if (this.column.dataType === GridColumnDataType.Boolean) { + return this.filteringService.grid.resourceStrings.igx_grid_filter_row_boolean_placeholder; + } else { + return this.filteringService.grid.resourceStrings.igx_grid_filter_row_placeholder; + } + } + + /** + * Event handler for keydown on the input group's prefix. + */ + public onPrefixKeyDown(event: KeyboardEvent) { + if (this.platform.isActivationKey(event) && this.dropDownConditions.collapsed) { + this.toggleConditionsDropDown(this.inputGroupPrefix.nativeElement); + event.stopImmediatePropagation(); + } else if (event.key === this.platform.KEYMAP.TAB && !this.dropDownConditions.collapsed) { + this.toggleConditionsDropDown(this.inputGroupPrefix.nativeElement); + } + } + + /** + * Event handler for keydown on the input. + */ + public onInputKeyDown(event: KeyboardEvent) { + this.isKeyPressed = true; + event.stopPropagation(); + if (this.column.dataType === GridColumnDataType.Boolean) { + if (this.platform.isActivationKey(event)) { + this.inputGroupPrefix.nativeElement.focus(); + this.toggleConditionsDropDown(this.inputGroupPrefix.nativeElement); + return; + } + } + if (event.key === this.platform.KEYMAP.ENTER) { + if (this.isComposing) { + return; + } + this.commitInput(); + } else if (event.altKey && (event.key === this.platform.KEYMAP.ARROW_DOWN)) { + this.inputGroupPrefix.nativeElement.focus(); + this.toggleConditionsDropDown(this.inputGroupPrefix.nativeElement); + } else if (this.platform.isFilteringKeyCombo(event)) { + event.preventDefault(); + this.close(); + } + } + + /** + * Event handler for keyup on the input. + */ + public onInputKeyUp() { + this.isKeyPressed = false; + } + + /** + * Event handler for input on the input. + */ + public onInput(eventArgs) { + this.inputSubject.next(eventArgs); + } + + private handleInputChange(eventArgs) { + if (!eventArgs) { + return; + } + + // The 'iskeyPressed' flag is needed for a case in IE, because the input event is fired on focus and for some reason, + // when you have a japanese character as a placeholder, on init the value here is empty string . + const target = eventArgs.target; + if (this.column.dataType === GridColumnDataType.DateTime) { + this.value = eventArgs; + return; + } + if (this.platform.isEdge && target.type !== 'number' + || this.isKeyPressed || target.value || target.checkValidity()) { + this.value = target.value; + } + } + + /** + * Event handler for compositionstart on the input. + */ + public onCompositionStart() { + this.isComposing = true; + } + + /** + * Event handler for compositionend on the input. + */ + public onCompositionEnd() { + this.isComposing = false; + } + + /** + * Event handler for input click event. + */ + public onInputClick() { + if (this.column.dataType === GridColumnDataType.Boolean && this.dropDownConditions.collapsed) { + this.inputGroupPrefix.nativeElement.focus(); + this.toggleConditionsDropDown(this.inputGroupPrefix.nativeElement); + } + } + + /** + * Returns the filtering operation condition for a given value. + */ + public getCondition(value: string): IFilteringOperation { + return this.column.filters.condition(value); + } + + /** + * Returns the translated condition name for a given value. + */ + public translateCondition(value: string): string { + return this.filteringService.grid.resourceStrings[`igx_grid_filter_${this.getCondition(value).name}`] || value; + } + + /** + * Returns the icon name of the current condition. + */ + public getIconName(): string { + if (this.column.dataType === GridColumnDataType.Boolean && this.expression.condition === null) { + return this.getCondition(this.conditions[0]).iconName; + } else { + return this.expression.condition.iconName; + } + } + + /** + * Returns whether a given condition is selected in dropdown. + */ + public isConditionSelected(conditionName: string): boolean { + if (this.expression.condition) { + return this.expression.condition.name === conditionName; + } else { + return false; + } + } + + /** + * Clears the current filtering. + */ + public clearFiltering() { + this.filteringService.clearFilter(this.column.field); + this.resetExpression(); + if (this.input) { + this.input.nativeElement.focus(); + } + this.cdr.detectChanges(); + + this.chipAreaScrollOffset = 0; + this.transform(this.chipAreaScrollOffset); + } + + /** + * Commits the value of the input. + */ + public commitInput() { + const selectedItem = this.expressionsList.filter(ex => ex.isSelected === true); + selectedItem.forEach(e => e.isSelected = false); + + let indexToDeselect = -1; + for (let index = 0; index < this.expressionsList.length; index++) { + const expression = this.expressionsList[index].expression; + if (expression.searchVal === null && !expression.condition.isUnary) { + indexToDeselect = index; + } + } + if (indexToDeselect !== -1) { + this.removeExpression(indexToDeselect, this.expression); + } + this.resetExpression(); + this._value = this.expression.searchVal; + this.scrollChipsWhenAddingExpression(); + } + + /** + * Clears the value of the input. + */ + public clearInput(event?: MouseEvent) { + event?.stopPropagation(); + this.value = null; + } + + /** + * Event handler for keydown on clear button. + */ + public onClearKeyDown(eventArgs: KeyboardEvent) { + if (this.platform.isActivationKey(eventArgs)) { + eventArgs.preventDefault(); + this.clearInput(); + this.focusEditElement(); + } + } + + /** + * Event handler for click on clear button. + */ + public onClearClick() { + this.clearInput(); + this.focusEditElement(); + } + + /** + * Event handler for keydown on commit button. + */ + public onCommitKeyDown(eventArgs: KeyboardEvent) { + if (this.platform.isActivationKey(eventArgs)) { + eventArgs.preventDefault(); + this.commitInput(); + this.focusEditElement(); + } + } + + /** + * Event handler for click on commit button. + */ + public onCommitClick(event?: MouseEvent) { + event?.stopPropagation(); + this.commitInput(); + this.focusEditElement(); + } + + /** + * Event handler for focusout on the input group. + */ + public onInputGroupFocusout() { + if (!this.value && this.value !== 0 && + this.expression.condition && !this.expression.condition.isUnary) { + return; + } + requestAnimationFrame(() => { + const focusedElement = this.column?.grid.document.activeElement; + + if (focusedElement.classList.contains('igx-chip__remove')) { + return; + } + + if (!(focusedElement && this.editorFocused(focusedElement)) + && this.dropDownConditions.collapsed) { + this.commitInput(); + } + }); + } + + /** + * Closes the filtering edit row. + */ + public close() { + if (this.expressionsList.length === 1 && + this.expressionsList[0].expression.searchVal === null && + this.expressionsList[0].expression.condition.isUnary === false) { + this.filteringService.getExpressions(this.column.field).pop(); + + this.filter(); + } else { + const condToRemove = this.expressionsList.filter(ex => ex.expression.searchVal === null && !ex.expression.condition.isUnary); + if (condToRemove && condToRemove.length > 0) { + condToRemove.forEach(c => this.filteringService.removeExpression(this.column.field, this.expressionsList.indexOf(c))); + this.filter(); + } + } + + this.filteringService.isFilterRowVisible = false; + this.filteringService.updateFilteringCell(this.column); + this.filteringService.filteredColumn = null; + this.filteringService.selectedExpression = null; + this.filteringService.grid.theadRow.nativeElement.focus(); + + this.chipAreaScrollOffset = 0; + this.transform(this.chipAreaScrollOffset); + } + + /** + * Event handler for date picker's selection. + */ + public onDateSelected(value: Date) { + this.value = value; + } + + /** @hidden @internal */ + public inputGroupPrefixClick(event: MouseEvent) { + event.stopPropagation(); + (event.currentTarget as HTMLElement).focus(); + this.toggleConditionsDropDown(event.currentTarget); + } + + /** + * Opens the conditions dropdown. + */ + public toggleConditionsDropDown(target: any) { + this._conditionsOverlaySettings.target = target; + this._conditionsOverlaySettings.excludeFromOutsideClick = [target as HTMLElement]; + this.dropDownConditions.toggle(this._conditionsOverlaySettings); + } + + /** + * Opens the logic operators dropdown. + */ + public toggleOperatorsDropDown(eventArgs, index) { + this._operatorsOverlaySettings.target = eventArgs.target.parentElement; + this._operatorsOverlaySettings.excludeFromOutsideClick = [eventArgs.target.parentElement as HTMLElement]; + this.dropDownOperators.toArray()[index].toggle(this._operatorsOverlaySettings); + } + + /** + * Event handler for change event in conditions dropdown. + */ + public onConditionsChanged(eventArgs) { + const value = (eventArgs.newSelection as IgxDropDownItemComponent).value; + this.expression.condition = this.getCondition(value); + if (this.expression.condition.isUnary) { + // update grid's filtering on the next cycle to ensure the drop-down is closed + // if the drop-down is not closed this event handler will be invoked multiple times + requestAnimationFrame(() => this.unaryConditionChangedCallback()); + } else { + requestAnimationFrame(() => this.conditionChangedCallback()); + } + + // Add requestAnimationFrame because of an issue in IE, where you are still able to write in the input, + // if it has been focused and then set to readonly. + requestAnimationFrame(() => this.focusEditElement()); + } + + + public onChipPointerdown(args, chip: IgxChipComponent) { + const activeElement = this.column?.grid.document.activeElement; + this._cancelChipClick = chip.selected + && activeElement && this.editorFocused(activeElement); + } + + public onChipClick(args, item: ExpressionUI) { + if (this._cancelChipClick) { + this._cancelChipClick = false; + return; + } + + this.expressionsList.forEach(ex => ex.isSelected = false); + + this.toggleChip(item); + } + + public toggleChip(item: ExpressionUI) { + item.isSelected = !item.isSelected; + if (item.isSelected) { + this.expression = item.expression; + this._value = this.expression.searchVal; + this.focusEditElement(); + } + } + + /** + * Event handler for chip keydown event. + */ + public onChipKeyDown(eventArgs: KeyboardEvent, item: ExpressionUI) { + if (eventArgs.key === this.platform.KEYMAP.ENTER) { + eventArgs.preventDefault(); + + this.toggleChip(item); + } + } + + /** + * Scrolls the first chip into view if the tab key is pressed on the left arrow. + */ + public onLeftArrowKeyDown(event: KeyboardEvent) { + if (event.key === this.platform.KEYMAP.TAB) { + this.chipAreaScrollOffset = 0; + this.transform(this.chipAreaScrollOffset); + } + } + + /** + * Event handler for chip removed event. + */ + public onChipRemoved(eventArgs: IBaseChipEventArgs, item: ExpressionUI) { + const indexToRemove = this.expressionsList.indexOf(item); + this.removeExpression(indexToRemove, item.expression); + + this.scrollChipsOnRemove(); + } + + /** + * Event handler for logic operator changed event. + */ + public onLogicOperatorChanged(eventArgs: ISelectionEventArgs, expression: ExpressionUI) { + if (eventArgs.oldSelection) { + expression.afterOperator = (eventArgs.newSelection as IgxDropDownItemComponent).value; + this.expressionsList[this.expressionsList.indexOf(expression) + 1].beforeOperator = expression.afterOperator; + + // update grid's filtering on the next cycle to ensure the drop-down is closed + // if the drop-down is not closed this event handler will be invoked multiple times + requestAnimationFrame(() => this.filter()); + } + } + + /** + * Scrolls the chips into the chip area when left or right arrows are pressed. + */ + public scrollChipsOnArrowPress(arrowPosition: string) { + let count = 0; + const chipAraeChildren = this.chipsArea.element.nativeElement.children; + const containerRect = this.container.nativeElement.getBoundingClientRect(); + + if (arrowPosition === 'right') { + for (const chip of chipAraeChildren) { + if (Math.ceil(chip.getBoundingClientRect().right) < Math.ceil(containerRect.right)) { + count++; + } + } + + if (count < chipAraeChildren.length) { + this.chipAreaScrollOffset -= Math.ceil(chipAraeChildren[count].getBoundingClientRect().right) - + Math.ceil(containerRect.right) + 1; + this.transform(this.chipAreaScrollOffset); + } + } + + if (arrowPosition === 'left') { + for (const chip of chipAraeChildren) { + if (Math.ceil(chip.getBoundingClientRect().left) < Math.ceil(containerRect.left)) { + count++; + } + } + + if (count > 0) { + this.chipAreaScrollOffset += Math.ceil(containerRect.left) - + Math.ceil(chipAraeChildren[count - 1].getBoundingClientRect().left) + 1; + this.transform(this.chipAreaScrollOffset); + } + } + } + + /** + * @hidden + * Resets the chips area + * @memberof IgxGridFilteringRowComponent + */ + public resetChipsArea() { + this.chipAreaScrollOffset = 0; + this.transform(this.chipAreaScrollOffset); + this.showHideArrowButtons(); + } + + /** @hidden @internal */ + public focusEditElement() { + if (this.input) { + this.input.nativeElement.focus(); + } else if (this.picker) { + this.picker.getEditElement().focus(); + } + } + + public ngOnDestroy() { + this.$destroyer.next(); + } + + private showHideArrowButtons() { + requestAnimationFrame(() => { + if (this.filteringService.isFilterRowVisible) { + const containerWidth = this.container.nativeElement.getBoundingClientRect().width; + this.chipsAreaWidth = this.chipsArea.element.nativeElement.getBoundingClientRect().width; + + this.showArrows = this.chipsAreaWidth >= containerWidth && this.isColumnFiltered; + + // TODO: revise the cdr.detectChanges() usage here + if (!(this.cdr as ViewRef).destroyed) { + this.cdr.detectChanges(); + } + } + }); + } + + private addExpression(isSelected: boolean) { + const exprUI = new ExpressionUI(); + exprUI.expression = this.expression; + exprUI.beforeOperator = this.expressionsList.length > 0 ? FilteringLogic.And : null; + exprUI.isSelected = isSelected; + + this.expressionsList.push(exprUI); + + const length = this.expressionsList.length; + if (this.expressionsList[length - 2]) { + this.expressionsList[length - 2].afterOperator = this.expressionsList[length - 1].beforeOperator; + } + + this.showHideArrowButtons(); + } + + private removeExpression(indexToRemove: number, expression: IFilteringExpression) { + if (indexToRemove === 0 && this.expressionsList.length === 1) { + this.clearFiltering(); + return; + } + + this.filteringService.removeExpression(this.column.field, indexToRemove); + + this.filter(); + + if (this.expression === expression) { + this.resetExpression(); + } + + this.showHideArrowButtons(); + } + + private resetExpression(condition?: string) { + this.expression = { + fieldName: this.column.field, + condition: null, + conditionName: null, + searchVal: null, + ignoreCase: this.column.filteringIgnoreCase + }; + + if (this.column.dataType !== GridColumnDataType.Boolean) { + this.expression.condition = this.getCondition(condition ?? this.conditions[0]); + } + + if (this.column.dataType === GridColumnDataType.Date && this.input) { + this.input.nativeElement.value = null; + } + + this.showHideArrowButtons(); + } + + private scrollChipsWhenAddingExpression() { + const chipAraeChildren = this.chipsArea.element.nativeElement.children; + if (!chipAraeChildren || chipAraeChildren.length === 0) { + return; + } + + const chipsContainerWidth = this.container.nativeElement.offsetWidth; + const chipsAreaWidth = this.chipsArea.element.nativeElement.offsetWidth; + + if (chipsAreaWidth > chipsContainerWidth) { + this.chipAreaScrollOffset = chipsContainerWidth - chipsAreaWidth; + this.transform(this.chipAreaScrollOffset); + } + } + + private transform(offset: number) { + requestAnimationFrame(() => { + this.chipsArea.element.nativeElement.style.transform = `translate(${offset}px)`; + }); + } + + private scrollChipsOnRemove() { + let count = 0; + const chipAraeChildren = this.chipsArea.element.nativeElement.children; + const containerRect = this.container.nativeElement.getBoundingClientRect(); + + for (const chip of chipAraeChildren) { + if (Math.ceil(chip.getBoundingClientRect().right) < Math.ceil(containerRect.left)) { + count++; + } + } + + if (count <= 2) { + this.chipAreaScrollOffset = 0; + } else { + const dif = chipAraeChildren[count].id === 'chip' ? count - 2 : count - 1; + this.chipAreaScrollOffset += Math.ceil(containerRect.left) - Math.ceil(chipAraeChildren[dif].getBoundingClientRect().left) + 1; + } + + this.transform(this.chipAreaScrollOffset); + } + + private conditionChangedCallback() { + if (!!this.expression.searchVal || this.expression.searchVal === 0) { + this.filter(); + } else if (this.value) { + this.value = null; + } + } + + private unaryConditionChangedCallback() { + if (this.value) { + this.value = null; + } + if (this.expressionsList.find(item => item.expression === this.expression) === undefined) { + this.addExpression(true); + } + this.filter(); + } + + private filter() { + this.filteringService.filterInternal(this.column.field); + } + + private editorFocused(activeElement: Element): boolean { + // if the first check is false and the second is undefined this will return undefined + // make sure it always returns boolean + return !!(this.inputGroup && this.inputGroup.nativeElement.contains(activeElement) + || this.picker && this.picker.isFocused); + } + + private get isColumnFiltered() { + return this.column.filteringExpressionsTree && this.column.filteringExpressionsTree.filteringOperands.length > 0; + } + + public get isNarrowWidth(): boolean { + return this.nativeElement.offsetWidth < this.NARROW_WIDTH_THRESHOLD; + } +} diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/base-filtering.component.ts b/projects/igniteui-angular/grids/core/src/filtering/excel-style/base-filtering.component.ts new file mode 100644 index 00000000000..df20c0571a0 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/base-filtering.component.ts @@ -0,0 +1,39 @@ +import { ChangeDetectorRef, Directive, ElementRef, EventEmitter, inject } from '@angular/core'; +import { ExpressionUI, FilterListItem } from './common'; +import { IgxOverlayService, PlatformUtil } from 'igniteui-angular/core'; + + + +@Directive() +export abstract class BaseFilteringComponent { + protected cdr = inject(ChangeDetectorRef); + public element = inject(ElementRef); + protected platform = inject(PlatformUtil); + + + public abstract column: any; + public abstract get grid(): any; + + public abstract overlayComponentId: string; + public abstract mainDropdown: ElementRef; + public abstract expressionsList: ExpressionUI[]; + public abstract listData: FilterListItem[]; + public abstract isHierarchical: boolean; + + public abstract loadingStart: EventEmitter; + public abstract loadingEnd: EventEmitter; + public abstract initialized: EventEmitter; + public abstract columnChange: EventEmitter; + public abstract sortingChanged: EventEmitter; + public abstract listDataLoaded: EventEmitter; + public abstract filterCleared: EventEmitter; + + public abstract initialize(column: any, overlayService: IgxOverlayService): void; + public abstract detectChanges(): void; + public abstract hide(): void; + public abstract closeDropdown(): void; + public abstract onSelect(): void; + public abstract onPin(): void; + public abstract onHideToggle(): void; + public abstract cancel(): void; +} diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/common.ts b/projects/igniteui-angular/grids/core/src/filtering/excel-style/common.ts new file mode 100644 index 00000000000..1eeb325b252 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/common.ts @@ -0,0 +1,83 @@ +import { FilteringLogic, IFilteringExpression, IFilteringExpressionsTree, isTree } from 'igniteui-angular/core'; +import { getUUID } from '../../common/random'; + +/** + * @hidden @internal + */ +export class FilterListItem { + public value: any; + public label: any; + public isSelected: boolean; + public indeterminate: boolean; + public isFiltered: boolean; + public isSpecial = false; + public isBlanks = false; + public children?: Array; + public parent?: FilterListItem; +} + +/** + * @hidden + */ +export class ExpressionUI { + public expressionId: string; + public expression: IFilteringExpression; + public beforeOperator: FilteringLogic; + public afterOperator: FilteringLogic; + public isSelected = false; + public isVisible = true; + + constructor() { + // Use IDs to identify expressions clearly and use to track them in template @for cycles. + this.expressionId = getUUID(); + } +} + +/** + * @hidden @internal + */ +export class ActiveElement { + public index: number; + public id: string; + public checked: boolean; +} + +export function generateExpressionsList(expressions: IFilteringExpressionsTree | IFilteringExpression, + operator: FilteringLogic, + expressionsUIs: ExpressionUI[]): void { + generateExpressionsListRecursive(expressions, operator, expressionsUIs); + + // The beforeOperator of the first expression and the afterOperator of the last expression should be null + if (expressionsUIs.length) { + expressionsUIs[expressionsUIs.length - 1].afterOperator = null; + } +} + + +function generateExpressionsListRecursive(expressions: IFilteringExpressionsTree | IFilteringExpression, + operator: FilteringLogic, + expressionsUIs: ExpressionUI[]): void { + if (!expressions) { + return; + } + + if (isTree(expressions)) { + for (const operand of expressions.filteringOperands) { + generateExpressionsListRecursive(operand, expressions.operator, expressionsUIs); + } + if (expressionsUIs.length) { + expressionsUIs[expressionsUIs.length - 1].afterOperator = operator; + } + } else { + const exprUI = new ExpressionUI(); + exprUI.expression = expressions; + exprUI.afterOperator = operator; + + const prevExprUI = expressionsUIs[expressionsUIs.length - 1]; + if (prevExprUI) { + exprUI.beforeOperator = prevExprUI.afterOperator; + } + + expressionsUIs.push(exprUI); + } +} diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-clear-filters.component.html b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-clear-filters.component.html new file mode 100644 index 00000000000..a724862428e --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-clear-filters.component.html @@ -0,0 +1,12 @@ +@if (esf.column) { +
    + {{ esf.grid.resourceStrings.igx_grid_excel_filter_clear }} + +
    +} diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-clear-filters.component.ts b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-clear-filters.component.ts new file mode 100644 index 00000000000..d8bb213392e --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-clear-filters.component.ts @@ -0,0 +1,56 @@ +import { Component, inject } from '@angular/core'; +import { BaseFilteringComponent } from './base-filtering.component'; +import { NgClass } from '@angular/common'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { PlatformUtil } from 'igniteui-angular/core'; + +/** + * A component used for presenting Excel style clear filters UI. + */ +@Component({ + selector: 'igx-excel-style-clear-filters', + templateUrl: './excel-style-clear-filters.component.html', + imports: [NgClass, IgxIconComponent] +}) +export class IgxExcelStyleClearFiltersComponent { + public esf = inject(BaseFilteringComponent); + protected platform = inject(PlatformUtil); + + + /** + * @hidden @internal + */ + public clearFilterClass() { + if (this.esf.column.filteringExpressionsTree) { + return 'igx-excel-filter__actions-clear'; + } + + return 'igx-excel-filter__actions-clear--disabled'; + } + + /** + * @hidden @internal + */ + public clearFilter() { + this.esf.grid.filteringService.clearFilter(this.esf.column.field); + this.esf.filterCleared.emit(); + this.selectAllFilterItems(); + } + + /** + * @hidden @internal + */ + public onClearFilterKeyDown(eventArgs: KeyboardEvent) { + if (eventArgs.key === this.platform.KEYMAP.ENTER) { + this.clearFilter(); + } + } + + private selectAllFilterItems() { + this.esf.listData.forEach(filterListItem => { + filterListItem.isSelected = true; + filterListItem.indeterminate = false; + }); + this.esf.detectChanges(); + } +} diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-conditional-filter.component.html b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-conditional-filter.component.html new file mode 100644 index 00000000000..89fbb46e625 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-conditional-filter.component.html @@ -0,0 +1,53 @@ +@if (esf.column) { + + + +
    + @for (condition of conditions; track condition) { + +
    + + {{ translateCondition(condition) }} +
    +
    + } + @if (showCustomFilterItem()) { + +
    + + {{ esf.grid.resourceStrings.igx_grid_excel_custom_filter }} +
    +
    + } +
    +
    + + + +} diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-conditional-filter.component.ts b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-conditional-filter.component.ts new file mode 100644 index 00000000000..5ed40340e94 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-conditional-filter.component.ts @@ -0,0 +1,198 @@ +import { Component, OnDestroy, ViewChild, inject } from '@angular/core'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { IgxExcelStyleCustomDialogComponent } from './excel-style-custom-dialog.component'; +import { BaseFilteringComponent } from './base-filtering.component'; +import { NgClass } from '@angular/common'; +import { IgxDropDownComponent, IgxDropDownItemComponent, IgxDropDownItemNavigationDirective, ISelectionEventArgs } from 'igniteui-angular/drop-down'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { AbsoluteScrollStrategy, AutoPositionStrategy, GridColumnDataType, HorizontalAlignment, IFilteringExpression, IFilteringOperation, OverlaySettings, PlatformUtil, VerticalAlignment } from 'igniteui-angular/core'; + + +/** + * A component used for presenting Excel style conditional filter UI. + */ +@Component({ + selector: 'igx-excel-style-conditional-filter', + templateUrl: './excel-style-conditional-filter.component.html', + imports: [NgClass, IgxDropDownItemNavigationDirective, IgxIconComponent, IgxDropDownComponent, IgxDropDownItemComponent, IgxExcelStyleCustomDialogComponent] +}) +export class IgxExcelStyleConditionalFilterComponent implements OnDestroy { + public esf = inject(BaseFilteringComponent); + protected platform = inject(PlatformUtil); + + /** + * @hidden @internal + */ + @ViewChild('customDialog', { read: IgxExcelStyleCustomDialogComponent }) + public customDialog: IgxExcelStyleCustomDialogComponent; + + /** + * @hidden @internal + */ + @ViewChild('subMenu', { read: IgxDropDownComponent }) + public subMenu: IgxDropDownComponent; + + protected get filterNumber() { + return this.esf.expressionsList.filter(e => e.expression.condition).length; + } + + private shouldOpenSubMenu = true; + private destroy$ = new Subject(); + + private _subMenuPositionSettings = { + verticalStartPoint: VerticalAlignment.Top + }; + + private _subMenuOverlaySettings: OverlaySettings = { + closeOnOutsideClick: true, + modal: false, + positionStrategy: new AutoPositionStrategy(this._subMenuPositionSettings), + scrollStrategy: new AbsoluteScrollStrategy() + }; + + constructor() { + this.esf.columnChange.pipe(takeUntil(this.destroy$)).subscribe(() => { + if (this.esf.grid) { + this.shouldOpenSubMenu = true; + this._subMenuOverlaySettings.outlet = this.esf.grid.outlet; + } + }); + + if (this.esf.grid) { + this._subMenuOverlaySettings.outlet = this.esf.grid.outlet; + } + } + + public ngOnDestroy(): void { + this.destroy$.next(true); + this.destroy$.complete(); + } + + /** + * @hidden @internal + */ + public onTextFilterKeyDown(eventArgs: KeyboardEvent) { + if (eventArgs.key === this.platform.KEYMAP.ENTER) { + this.onTextFilterClick(eventArgs); + } else if (eventArgs.key === this.platform.KEYMAP.TAB) { + this.subMenu.close(); + } + } + + /** + * @hidden @internal + */ + public onTextFilterClick(eventArgs) { + if (this.shouldOpenSubMenu) { + this._subMenuOverlaySettings.target = eventArgs.currentTarget; + + const gridRect = this.esf.grid.nativeElement.getBoundingClientRect(); + const dropdownRect = this.esf.mainDropdown.nativeElement.getBoundingClientRect(); + + let x = dropdownRect.left + dropdownRect.width; + let x1 = gridRect.left + gridRect.width; + x += window.pageXOffset; + x1 += window.pageXOffset; + if (Math.abs(x - x1) < 200) { + this._subMenuOverlaySettings.positionStrategy.settings.horizontalDirection = HorizontalAlignment.Left; + this._subMenuOverlaySettings.positionStrategy.settings.horizontalStartPoint = HorizontalAlignment.Left; + } else { + this._subMenuOverlaySettings.positionStrategy.settings.horizontalDirection = HorizontalAlignment.Right; + this._subMenuOverlaySettings.positionStrategy.settings.horizontalStartPoint = HorizontalAlignment.Right; + } + + this.subMenu.open(this._subMenuOverlaySettings); + this.shouldOpenSubMenu = false; + } + } + + /** + * @hidden @internal + */ + public getCondition(value: string): IFilteringOperation { + return this.esf.column.filters.condition(value); + } + + protected getSelectedCondition(condition: string): boolean { + const expressions = this.esf.expressionsList; + if (expressions.length < 1) { + return false; + } + return expressions.length === 1 ? expressions[0].expression.condition.name === condition : condition === 'custom'; + } + + /** + * @hidden @internal + */ + public translateCondition(value: string): string { + return this.esf.grid.resourceStrings[`igx_grid_filter_${this.getCondition(value).name}`] || value; + } + + /** + * @hidden @internal + */ + public onSubMenuSelection(eventArgs: ISelectionEventArgs) { + if (this.esf.expressionsList && this.esf.expressionsList.length && + this.esf.expressionsList[0].expression.condition.name !== 'in') { + this.customDialog.expressionsList = this.esf.expressionsList; + } else { + this.customDialog.expressionsList = this.customDialog.expressionsList.filter(e => e.expression.fieldName === this.esf.column.field && e.expression.condition); + } + + this.customDialog.selectedOperator = eventArgs.newSelection.value; + eventArgs.cancel = true; + if (this.esf.overlayComponentId) { + this.esf.hide(); + } + this.subMenu.close(); + this.customDialog.open(this.esf.mainDropdown.nativeElement); + } + + /** + * @hidden @internal + */ + public onSubMenuClosed() { + requestAnimationFrame(() => { + this.shouldOpenSubMenu = true; + }); + } + + /** + * @hidden @internal + */ + public showCustomFilterItem(): boolean { + const exprTree = this.esf.column.filteringExpressionsTree; + return exprTree && exprTree.filteringOperands && exprTree.filteringOperands.length && + !((exprTree.filteringOperands[0] as IFilteringExpression).condition && + (exprTree.filteringOperands[0] as IFilteringExpression).condition.name === 'in'); + } + + /** + * @hidden @internal + */ + public get subMenuText() { + switch (this.esf.column.dataType) { + case GridColumnDataType.Boolean: + return this.esf.grid.resourceStrings.igx_grid_excel_boolean_filter; + case GridColumnDataType.Number: + case GridColumnDataType.Percent: + return this.esf.grid.resourceStrings.igx_grid_excel_number_filter; + case GridColumnDataType.Date: + case GridColumnDataType.DateTime: + case GridColumnDataType.Time: + return this.esf.grid.resourceStrings.igx_grid_excel_date_filter; + case GridColumnDataType.Currency: + return this.esf.grid.resourceStrings.igx_grid_excel_currency_filter; + default: + return this.esf.grid.resourceStrings.igx_grid_excel_text_filter; + } + } + + /** + * @hidden @internal + */ + public get conditions() { + return this.esf.column.filters.conditionList(); + } +} diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-custom-dialog.component.html b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-custom-dialog.component.html new file mode 100644 index 00000000000..9998443fd1f --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-custom-dialog.component.html @@ -0,0 +1,77 @@ +
    +
    +

    + {{ grid.resourceStrings.igx_grid_excel_custom_dialog_header }}{{ column.header || column.field }} +

    +
    + +
    + @if (column.dataType === 'date' || column.dataType === 'dateTime' || column.dataType === 'time') { + @for (expression of expressionsList; track expression.expressionId) { + + + } + } + + @if (column.dataType !== 'date' && column.dataType !== 'dateTime' && column.dataType !== 'time') { + @for (expression of expressionsList; track expression.expressionId) { + + + } + } + + +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-custom-dialog.component.ts b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-custom-dialog.component.ts new file mode 100644 index 00000000000..0abbe0633f2 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-custom-dialog.component.ts @@ -0,0 +1,265 @@ +import { Component, Input, ChangeDetectorRef, ViewChild, AfterViewInit, TemplateRef, ViewChildren, QueryList, ElementRef, inject } from '@angular/core'; +import { IgxFilteringService } from '../grid-filtering.service'; +import { ILogicOperatorChangedArgs, IgxExcelStyleDefaultExpressionComponent } from './excel-style-default-expression.component'; +import { IgxExcelStyleDateExpressionComponent } from './excel-style-date-expression.component'; +import { ExpressionUI } from './common'; +import { NgClass } from '@angular/common'; +import { BaseFilteringComponent } from './base-filtering.component'; +import { IgxButtonDirective, IgxToggleDirective } from 'igniteui-angular/directives'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { AbsoluteScrollStrategy, AutoPositionStrategy, ColumnType, FilteringLogic, GridColumnDataType, HorizontalAlignment, IgxBooleanFilteringOperand, IgxDateFilteringOperand, IgxDateTimeFilteringOperand, IgxNumberFilteringOperand, IgxOverlayService, IgxStringFilteringOperand, IgxTimeFilteringOperand, OverlaySettings, PlatformUtil, PositionSettings, VerticalAlignment } from 'igniteui-angular/core'; + +/** + * @hidden + */ +@Component({ + selector: 'igx-excel-style-custom-dialog', + templateUrl: './excel-style-custom-dialog.component.html', + imports: [IgxToggleDirective, NgClass, IgxExcelStyleDateExpressionComponent, IgxExcelStyleDefaultExpressionComponent, IgxButtonDirective, IgxIconComponent] +}) +export class IgxExcelStyleCustomDialogComponent implements AfterViewInit { + protected overlayService = inject(IgxOverlayService); + private cdr = inject(ChangeDetectorRef); + protected platform = inject(PlatformUtil); + public esf = inject(BaseFilteringComponent); + + @Input() + public expressionsList = new Array(); + + @Input() + public column: ColumnType; + + @Input() + public selectedOperator: string; + + @Input() + public filteringService: IgxFilteringService; + + @Input() + public overlayComponentId: string; + + @ViewChild('toggle', { read: IgxToggleDirective, static: true }) + public toggle: IgxToggleDirective; + + @ViewChild('defaultExpressionTemplate', { read: TemplateRef }) + protected defaultExpressionTemplate: TemplateRef; + + @ViewChild('dateExpressionTemplate', { read: TemplateRef }) + protected dateExpressionTemplate: TemplateRef; + + @ViewChild('expressionsContainer', { static: true }) + protected expressionsContainer: ElementRef; + + @ViewChildren(IgxExcelStyleDefaultExpressionComponent) + private expressionComponents: QueryList; + + @ViewChildren(IgxExcelStyleDateExpressionComponent) + private expressionDateComponents: QueryList; + + private _customDialogPositionSettings: PositionSettings = { + verticalDirection: VerticalAlignment.Middle, + horizontalDirection: HorizontalAlignment.Center, + horizontalStartPoint: HorizontalAlignment.Center, + verticalStartPoint: VerticalAlignment.Middle + }; + + private _customDialogOverlaySettings: OverlaySettings = { + closeOnOutsideClick: true, + modal: false, + positionStrategy: new AutoPositionStrategy(this._customDialogPositionSettings), + scrollStrategy: new AbsoluteScrollStrategy() + }; + + public ngAfterViewInit(): void { + this._customDialogOverlaySettings.outlet = this.grid.outlet; + } + + public get template(): TemplateRef { + if (this.column.dataType === GridColumnDataType.Date) { + return this.dateExpressionTemplate; + } + + return this.defaultExpressionTemplate; + } + + public get grid(): any { + return this.filteringService.grid; + } + + public onCustomDialogOpening() { + if (this.selectedOperator) { + this.createInitialExpressionUIElement(); + } + } + + public onCustomDialogOpened() { + if (this.expressionComponents.first) { + this.expressionComponents.first.focus(); + } + } + + public open(esf) { + this._customDialogOverlaySettings.target = + this.overlayComponentId ? + this.grid.rootGrid ? this.grid.rootGrid.nativeElement : this.grid.nativeElement : + esf; + this.toggle.open(this._customDialogOverlaySettings); + this.overlayComponentId = this.toggle.overlayId; + } + + public onClearButtonClick() { + this.filteringService.clearFilter(this.column.field); + this.selectedOperator = null; + this.createInitialExpressionUIElement(); + this.cdr.detectChanges(); + } + + public closeDialog() { + if (this.overlayComponentId) { + this.overlayService.hide(this.overlayComponentId); + this.overlayComponentId = null; + } else { + this.toggle.close(); + } + } + + public cancelDialog() { + this.esf.cancel(); + this.closeDialog(); + } + + public onApplyButtonClick() { + this.expressionsList = this.expressionsList.filter( + element => element.expression.condition && + (element.expression.searchVal || element.expression.searchVal === 0 || element.expression.condition.isUnary)); + + if (this.expressionsList.length > 0) { + this.expressionsList[0].beforeOperator = null; + this.expressionsList[this.expressionsList.length - 1].afterOperator = null; + } + + this.filteringService.filterInternal(this.column.field, this.expressionsList); + this.closeDialog(); + } + + public onAddButtonClick() { + const exprUI = new ExpressionUI(); + exprUI.expression = { + condition: null, + conditionName: null, + fieldName: this.column.field, + ignoreCase: this.column.filteringIgnoreCase, + searchVal: null + }; + + this.expressionsList[this.expressionsList.length - 1].afterOperator = FilteringLogic.And; + exprUI.beforeOperator = this.expressionsList[this.expressionsList.length - 1].afterOperator; + + this.expressionsList.push(exprUI); + + this.markChildrenForCheck(); + this.scrollToBottom(); + } + + public onExpressionRemoved(event: ExpressionUI) { + const indexToRemove = this.expressionsList.indexOf(event); + + if (indexToRemove === 0 && this.expressionsList.length > 1) { + this.expressionsList[1].beforeOperator = null; + } else if (indexToRemove === this.expressionsList.length - 1) { + this.expressionsList[indexToRemove - 1].afterOperator = null; + } else { + this.expressionsList[indexToRemove - 1].afterOperator = this.expressionsList[indexToRemove + 1].beforeOperator; + this.expressionsList[0].beforeOperator = null; + this.expressionsList[this.expressionsList.length - 1].afterOperator = null; + } + + this.expressionsList.splice(indexToRemove, 1); + + this.cdr.detectChanges(); + + this.markChildrenForCheck(); + } + + public onLogicOperatorChanged(event: ILogicOperatorChangedArgs) { + const index = this.expressionsList.indexOf(event.target); + event.target.afterOperator = event.newValue; + if (index + 1 < this.expressionsList.length) { + this.expressionsList[index + 1].beforeOperator = event.newValue; + } + } + + public onKeyDown(eventArgs: KeyboardEvent) { + eventArgs.stopPropagation(); + } + + public onApplyButtonKeyDown(eventArgs: KeyboardEvent) { + if (eventArgs.key === this.platform.KEYMAP.TAB && !eventArgs.shiftKey) { + eventArgs.stopPropagation(); + eventArgs.preventDefault(); + } + } + + private createCondition(conditionName: string) { + switch (this.column.dataType) { + case GridColumnDataType.Boolean: + return IgxBooleanFilteringOperand.instance().condition(conditionName); + case GridColumnDataType.Number: + case GridColumnDataType.Currency: + case GridColumnDataType.Percent: + return IgxNumberFilteringOperand.instance().condition(conditionName); + case GridColumnDataType.Date: + return IgxDateFilteringOperand.instance().condition(conditionName); + case GridColumnDataType.Time: + return IgxTimeFilteringOperand.instance().condition(conditionName); + case GridColumnDataType.DateTime: + return IgxDateTimeFilteringOperand.instance().condition(conditionName); + default: + return IgxStringFilteringOperand.instance().condition(conditionName); + } + } + + private markChildrenForCheck() { + this.expressionComponents.forEach(x => x.cdr.markForCheck()); + this.expressionDateComponents.forEach(x => x.cdr.markForCheck()); + } + + private createInitialExpressionUIElement() { + let firstExprUI = new ExpressionUI(); + if (this.expressionsList.length == 1 && this.expressionsList[0].expression.condition?.name === this.selectedOperator) { + firstExprUI = this.expressionsList.pop(); + } else { + this.expressionsList = []; + const cond = this.createCondition(this.selectedOperator); + firstExprUI.expression = { + condition: cond, + conditionName: cond?.name, + fieldName: this.column.field, + ignoreCase: this.column.filteringIgnoreCase, + searchVal: null + }; + } + + firstExprUI.afterOperator = FilteringLogic.And; + this.expressionsList.push(firstExprUI); + + const secondExprUI = new ExpressionUI(); + secondExprUI.expression = { + condition: null, + conditionName: null, + fieldName: this.column.field, + ignoreCase: this.column.filteringIgnoreCase, + searchVal: null + }; + + secondExprUI.beforeOperator = FilteringLogic.And; + + this.expressionsList.push(secondExprUI); + } + + private scrollToBottom() { + requestAnimationFrame(() => { + this.expressionsContainer.nativeElement.scrollTop = this.expressionsContainer.nativeElement.scrollHeight; + }); + } +} diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-date-expression.component.html b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-date-expression.component.html new file mode 100644 index 00000000000..2f08599dcda --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-date-expression.component.html @@ -0,0 +1,104 @@ + + + @if (expressionUI.expression.condition) { + + } + @if (!expressionUI.expression.condition) { + + } + + @for (condition of conditions; track condition) { + + + {{translateCondition(condition)}} + + } + + +@if (column.dataType === 'date') { + + + + + +} + +@if (column.dataType === 'time') { + + + + + +} + +@if (column.dataType === 'dateTime') { + + + +} + +@if (!isSingle) { + +} + +@if (!isLast) { + + + {{ grid.resourceStrings.igx_grid_filter_operator_and }} + + + {{ grid.resourceStrings.igx_grid_filter_operator_or }} + + +} + +
    +
    diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-date-expression.component.ts b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-date-expression.component.ts new file mode 100644 index 00000000000..417379410f9 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-date-expression.component.ts @@ -0,0 +1,53 @@ +import { Component, Input, ViewChild } from '@angular/core'; +import { IgxExcelStyleDefaultExpressionComponent } from './excel-style-default-expression.component'; +import { getLocaleFirstDayOfWeek } from "@angular/common"; +import { FormsModule } from '@angular/forms'; +import { IgxSelectComponent, IgxSelectItemComponent } from 'igniteui-angular/select'; +import { IgxInputDirective, IgxInputGroupComponent, IgxPrefixDirective } from 'igniteui-angular/input-group'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxDatePickerComponent } from 'igniteui-angular/date-picker'; +import { IgxOverlayOutletDirective, IgxPickerClearComponent, IgxPickerToggleComponent } from 'igniteui-angular/core'; +import { IgxTimePickerComponent } from 'igniteui-angular/time-picker'; +import { IgxButtonDirective, IgxDateTimeEditorDirective, IgxIconButtonDirective } from 'igniteui-angular/directives'; +import { IgxButtonGroupComponent } from 'igniteui-angular/button-group'; + +/** + * @hidden + */ +@Component({ + selector: 'igx-excel-style-date-expression', + templateUrl: './excel-style-date-expression.component.html', + imports: [IgxSelectComponent, IgxPrefixDirective, IgxIconComponent, IgxSelectItemComponent, IgxDatePickerComponent, IgxPickerToggleComponent, IgxPickerClearComponent, IgxTimePickerComponent, IgxInputGroupComponent, FormsModule, IgxInputDirective, IgxDateTimeEditorDirective, IgxButtonDirective, IgxButtonGroupComponent, IgxOverlayOutletDirective, IgxIconButtonDirective] +}) +export class IgxExcelStyleDateExpressionComponent extends IgxExcelStyleDefaultExpressionComponent { + @ViewChild('input', { read: IgxInputDirective, static: false }) + private input: IgxInputDirective; + + @ViewChild('picker') + private picker: IgxDatePickerComponent | IgxTimePickerComponent; + + @Input() + public get searchVal(): any { + return this.expressionUI.expression.searchVal; + } + + public set searchVal(value: any) { + this.expressionUI.expression.searchVal = value ? new Date(Date.parse(value.toString())) : null; + } + + protected override get inputValuesElement() { + return this.picker?.getEditElement() || this.input?.nativeElement; + } + + public get inputDatePlaceholder(): string { + return this.grid.resourceStrings['igx_grid_filter_row_date_placeholder']; + } + + public get inputTimePlaceholder(): string { + return this.grid.resourceStrings['igx_grid_filter_row_time_placeholder']; + } + + public get weekStart(): number { + return getLocaleFirstDayOfWeek(this.grid.locale); + } +} diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-default-expression.component.html b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-default-expression.component.html new file mode 100644 index 00000000000..bd551204afb --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-default-expression.component.html @@ -0,0 +1,70 @@ + + + @if (expressionUI.expression.condition) { + + } + @if (!expressionUI.expression.condition) { + + } + + @for (condition of conditions; track condition) { + +
    + + {{translateCondition(condition)}} +
    +
    + } +
    + + + + + +@if (!isSingle) { + +} + +@if (!isLast) { + + + {{ grid.resourceStrings.igx_grid_filter_operator_and }} + + + {{ grid.resourceStrings.igx_grid_filter_operator_or }} + + +} + +
    +
    diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-default-expression.component.ts b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-default-expression.component.ts new file mode 100644 index 00000000000..391ee0f4f15 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-default-expression.component.ts @@ -0,0 +1,181 @@ +import { Component, AfterViewInit, Input, Output, EventEmitter, ChangeDetectorRef, ViewChild, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ExpressionUI } from './common'; +import { AbsoluteScrollStrategy, ColumnType, ConnectedPositioningStrategy, DataUtil, FilteringLogic, GridColumnDataType, IBaseEventArgs, IFilteringOperation, IgxOverlayOutletDirective, OverlaySettings, PlatformUtil } from 'igniteui-angular/core'; +import { IgxSelectComponent, IgxSelectItemComponent } from 'igniteui-angular/select'; +import { IgxInputDirective, IgxInputGroupComponent, IgxPrefixDirective } from 'igniteui-angular/input-group'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxButtonDirective, IgxIconButtonDirective } from 'igniteui-angular/directives'; +import { IgxButtonGroupComponent } from 'igniteui-angular/button-group'; + +/** + * @hidden + */ +export interface ILogicOperatorChangedArgs extends IBaseEventArgs { + target: ExpressionUI; + newValue: FilteringLogic; +} + +/** + * @hidden + */ +@Component({ + selector: 'igx-excel-style-default-expression', + templateUrl: './excel-style-default-expression.component.html', + imports: [FormsModule, IgxSelectComponent, IgxPrefixDirective, IgxIconComponent, IgxSelectItemComponent, IgxInputGroupComponent, IgxInputDirective, IgxButtonDirective, IgxButtonGroupComponent, IgxOverlayOutletDirective, IgxIconButtonDirective] +}) +export class IgxExcelStyleDefaultExpressionComponent implements AfterViewInit { + public cdr = inject(ChangeDetectorRef); + protected platform = inject(PlatformUtil); + + @Input() + public column: ColumnType; + + @Input() + public expressionUI: ExpressionUI; + + @Input() + public expressionsList: Array; + + @Input() + public grid: any; + + @Output() + public expressionRemoved = new EventEmitter(); + + @Output() + public logicOperatorChanged = new EventEmitter(); + + @ViewChild('overlayOutlet', { read: IgxOverlayOutletDirective, static: true }) + public overlayOutlet: IgxOverlayOutletDirective; + + @ViewChild('dropdownConditions', { read: IgxSelectComponent, static: true }) + protected dropdownConditions: IgxSelectComponent; + + @ViewChild('logicOperatorButtonGroup', { read: IgxButtonGroupComponent }) + protected logicOperatorButtonGroup: IgxButtonGroupComponent; + + @ViewChild('inputValues', { read: IgxInputDirective, static: true }) + protected inputValuesDirective: IgxInputDirective; + + public dropDownOverlaySettings: OverlaySettings = { + scrollStrategy: new AbsoluteScrollStrategy(), + modal: false, + closeOnOutsideClick: true + }; + + public get isLast(): boolean { + return this.expressionsList[this.expressionsList.length - 1] === this.expressionUI; + } + + public get isSingle(): boolean { + return this.expressionsList.length === 1; + } + + public get conditionsPlaceholder(): string { + return this.grid.resourceStrings['igx_grid_filter_condition_placeholder']; + } + + public get inputValuePlaceholder(): string { + return this.grid.resourceStrings['igx_grid_filter_row_placeholder']; + } + + public get type() { + switch (this.column.dataType) { + case GridColumnDataType.Number: + case GridColumnDataType.Currency: + case GridColumnDataType.Percent: + return 'number'; + default: + return 'text'; + } + } + + public get conditions() { + return this.column.filters.conditionList(); + } + + protected get inputValuesElement() { + return this.inputValuesDirective.nativeElement; + } + + public ngAfterViewInit(): void { + this.dropDownOverlaySettings.outlet = this.overlayOutlet; + this.dropDownOverlaySettings.target = this.dropdownConditions.inputGroup.element.nativeElement; + this.dropDownOverlaySettings.excludeFromOutsideClick = [this.dropdownConditions.inputGroup.element.nativeElement as HTMLElement]; + this.dropDownOverlaySettings.positionStrategy = new ConnectedPositioningStrategy(); + } + + public focus() { + // use requestAnimationFrame to focus the values input because when initializing the component + // datepicker's input group is not yet fully initialized + requestAnimationFrame(() => this.inputValuesElement.focus()); + } + + public translateCondition(value: string): string { + return this.grid.resourceStrings[`igx_grid_filter_${this.getCondition(value).name}`] || value; + } + + public getIconName(): string { + if (this.column.dataType === GridColumnDataType.Boolean && this.expressionUI.expression.condition === null) { + return this.getCondition(this.conditions[0]).iconName; + } else if (!this.expressionUI.expression.condition) { + return 'filter_list'; + } else { + return this.expressionUI.expression.condition.iconName; + } + } + + public isConditionSelected(conditionName: string): boolean { + return this.expressionUI.expression.condition && this.expressionUI.expression.condition.name === conditionName; + } + + public onConditionsChanged(eventArgs: any) { + const value = (eventArgs.newSelection as IgxSelectComponent).value; + this.expressionUI.expression.condition = this.getCondition(value); + + this.focus(); + } + + public getCondition(value: string): IFilteringOperation { + return this.column.filters.condition(value); + } + + public getConditionFriendlyName(name: string): string { + return this.grid.resourceStrings[`igx_grid_filter_${name}`] || name; + } + + public updateSearchValueOnBlur(eventArgs) { + this.expressionUI.expression.searchVal = DataUtil.parseValue(this.column.dataType, eventArgs.target.value); + } + + public onLogicOperatorButtonClicked(eventArgs, buttonIndex: number) { + if (this.logicOperatorButtonGroup.selectedButtons.length === 0) { + eventArgs.stopPropagation(); + this.logicOperatorButtonGroup.selectButton(buttonIndex); + } else { + this.logicOperatorChanged.emit({ + target: this.expressionUI, + newValue: buttonIndex as FilteringLogic + }); + } + } + + public onLogicOperatorKeyDown(eventArgs: KeyboardEvent, buttonIndex: number) { + if (eventArgs.key === this.platform.KEYMAP.ENTER) { + this.logicOperatorButtonGroup.selectButton(buttonIndex); + this.logicOperatorChanged.emit({ + target: this.expressionUI, + newValue: buttonIndex as FilteringLogic + }); + } + } + + public onRemoveButtonClick() { + this.expressionRemoved.emit(this.expressionUI); + } + + public onOutletPointerDown(event) { + event.preventDefault(); + } +} diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-filtering.component.html b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-filtering.component.html new file mode 100644 index 00000000000..828f432bacd --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-filtering.component.html @@ -0,0 +1,50 @@ + diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-filtering.component.ts b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-filtering.component.ts new file mode 100644 index 00000000000..8d53de8b8e3 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-filtering.component.ts @@ -0,0 +1,756 @@ +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ContentChild, + Directive, + ElementRef, + EventEmitter, + forwardRef, + HostBinding, + Input, + OnDestroy, + Output, + TemplateRef, + ViewChild, + ViewRef, + DOCUMENT, + inject +} from '@angular/core'; +import { Subscription } from 'rxjs'; +import { GridSelectionMode } from '../../common/enums'; +import { formatCurrency, formatDate, formatNumber, formatPercent, getLocaleCurrencyCode, NgClass } from '@angular/common'; +import { BaseFilteringComponent } from './base-filtering.component'; +import { ExpressionUI, FilterListItem, generateExpressionsList } from './common'; +import { GridType, IGX_GRID_BASE } from '../../common/grid.interface'; +import { IgxExcelStyleSearchComponent } from './excel-style-search.component'; +import { IgxExcelStyleConditionalFilterComponent } from './excel-style-conditional-filter.component'; +import { IgxExcelStyleClearFiltersComponent } from './excel-style-clear-filters.component'; +import { IgxExcelStyleSelectingComponent } from './excel-style-selecting.component'; +import { IgxExcelStyleHidingComponent } from './excel-style-hiding.component'; +import { IgxExcelStylePinningComponent } from './excel-style-pinning.component'; +import { IgxExcelStyleMovingComponent } from './excel-style-moving.component'; +import { IgxExcelStyleSortingComponent } from './excel-style-sorting.component'; +import { IgxExcelStyleHeaderComponent } from './excel-style-header.component'; +import { ColumnType, FilteringExpressionsTree, GridColumnDataType, GridTypeBase, IFilteringExpressionsTree, IgxFilterItem, IgxOverlayService, isTree, SortingDirection } from 'igniteui-angular/core'; + +@Directive({ + selector: 'igx-excel-style-column-operations,[igxExcelStyleColumnOperations]', + standalone: true +}) +export class IgxExcelStyleColumnOperationsTemplateDirective { } + +@Directive({ + selector: 'igx-excel-style-filter-operations,[igxExcelStyleFilterOperations]', + standalone: true +}) +export class IgxExcelStyleFilterOperationsTemplateDirective { } + +/** + * A component used for presenting Excel style filtering UI for a specific column. + * It is used internally in the Grid, but could also be hosted in a container outside of it. + * + * Example: + * ```html + * + * + * ``` + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [{ provide: BaseFilteringComponent, useExisting: forwardRef(() => IgxGridExcelStyleFilteringComponent) }], + selector: 'igx-grid-excel-style-filtering', + templateUrl: './excel-style-filtering.component.html', + imports: [IgxExcelStyleHeaderComponent, IgxExcelStyleSortingComponent, IgxExcelStyleMovingComponent, IgxExcelStylePinningComponent, IgxExcelStyleHidingComponent, IgxExcelStyleSelectingComponent, IgxExcelStyleClearFiltersComponent, IgxExcelStyleConditionalFilterComponent, IgxExcelStyleSearchComponent, NgClass] +}) +export class IgxGridExcelStyleFilteringComponent extends BaseFilteringComponent implements AfterViewInit, OnDestroy { + private document = inject(DOCUMENT); + protected gridAPI? = inject(IGX_GRID_BASE, { host: true, optional: true }); + + + /** + * @hidden @internal + */ + @HostBinding('class.igx-excel-filter') + public defaultClass = true; + + @HostBinding('class.igx-excel-filter__sizing') + protected get shouldApplySizes(): boolean { + return !(this._minHeight || this._maxHeight); + } + + /** + * @hidden @internal + */ + @HostBinding('class.igx-excel-filter--inline') + public inline = true; + + /** + * @hidden @internal + */ + @Output() + public loadingStart = new EventEmitter(); + + /** + * @hidden @internal + */ + @Output() + public loadingEnd = new EventEmitter(); + + /** + * @hidden @internal + */ + @Output() + public initialized = new EventEmitter(); + + /** + * @hidden @internal + */ + @Output() + public sortingChanged = new EventEmitter(); + + /** + * @hidden @internal + */ + @Output() + public columnChange = new EventEmitter(); + + /** + * @hidden @internal + */ + @Output() + public listDataLoaded = new EventEmitter(); + + /** + * @hidden @internal + */ + @Output() + public filterCleared = new EventEmitter(); + + @ViewChild('mainDropdown', { read: ElementRef }) + public mainDropdown: ElementRef; + + /** + * @hidden @internal + */ + @ContentChild(IgxExcelStyleColumnOperationsTemplateDirective, { read: IgxExcelStyleColumnOperationsTemplateDirective }) + public excelColumnOperationsDirective: IgxExcelStyleColumnOperationsTemplateDirective; + + /** + * @hidden @internal + */ + @ContentChild(IgxExcelStyleFilterOperationsTemplateDirective, { read: IgxExcelStyleFilterOperationsTemplateDirective }) + public excelFilterOperationsDirective: IgxExcelStyleFilterOperationsTemplateDirective; + + /** + * @hidden @internal + */ + @ViewChild('defaultExcelColumnOperations', { read: TemplateRef, static: true }) + protected defaultExcelColumnOperations: TemplateRef; + + /** + * @hidden @internal + */ + @ViewChild('defaultExcelFilterOperations', { read: TemplateRef, static: true }) + protected defaultExcelFilterOperations: TemplateRef; + + /** + * Sets the column. + */ + @Input() + public set column(value: ColumnType) { + if (value) { + this._column = value; + this.columnChange.emit(this._column); + if (this.inline) { + // In case external filtering + this.populateData(); + } + } + } + + /** + * Returns the current column. + */ + public get column(): ColumnType { + return this._column; + } + + /** + * @hidden @internal + */ + public expressionsList = new Array(); + /** + * @hidden @internal + */ + public listData = new Array(); + /** + * @hidden @internal + */ + public uniqueValues: IgxFilterItem[] = []; + /** + * @hidden @internal + */ + public overlayService: IgxOverlayService; + /** + * @hidden @internal + */ + public overlayComponentId: string; + /** + * @hidden @internal + */ + public isHierarchical = false; + + private _minHeight; + + /** + * Gets the minimum height. + * + * Setting value in template: + * ```ts + * [minHeight]="''" + * ``` + * + * Example for setting a value: + * ```ts + * [minHeight]="'700px'" + * ``` + */ + @Input() + public get minHeight(): string { + if (this._minHeight || this._minHeight === 0) { + return this._minHeight; + } + } + + /** + * Sets the minimum height. + */ + public set minHeight(value: string) { + this._minHeight = value; + } + + + private _maxHeight: string; + private containsNullOrEmpty = false; + private selectAllSelected = true; + private selectAllIndeterminate = false; + private filterValues = new Set(); + private _column: ColumnType; + private subscriptions: Subscription; + private _originalDisplay: string; + + /** + * Gets the maximum height. + * + * Setting value in template: + * ```ts + * [maxHeight]="''" + * ``` + * + * Example for setting a value: + * ```ts + * [maxHeight]="'700px'" + * ``` + */ + @Input() + @HostBinding('style.max-height') + public get maxHeight(): string { + if (this._maxHeight) { + return this._maxHeight; + } + } + + /** + * Sets the maximum height. + */ + public set maxHeight(value: string) { + this._maxHeight = value; + } + + /** + * @hidden @internal + */ + public get grid(): GridTypeBase { + return this.column?.grid ?? this.gridAPI; + } + + /** + * @hidden @internal + */ + public ngOnDestroy(): void { + this.subscriptions?.unsubscribe(); + delete this.overlayComponentId; + } + + /** + * @hidden @internal + */ + public ngAfterViewInit(): void { + this.computedStyles = this.document.defaultView.getComputedStyle(this.element.nativeElement); + } + + + /** + * @hidden @internal + */ + public initialize(column: ColumnType, overlayService: IgxOverlayService) { + this.inline = false; + this.column = column; + this.overlayService = overlayService; + + } + + /** + * @hidden @internal + */ + public populateData() { + if (this.column) { + this.afterColumnChange(); + } + if (this._originalDisplay) { + this.element.nativeElement.style.display = this._originalDisplay; + } + + this.initialized.emit(); + this.subscriptions.add(this.grid.columnMoving.subscribe(() => this.closeDropdown())); + } + + /** + * @hidden @internal + */ + public onPin() { + this.closeDropdown(); + this.column.pinned = !this.column.pinned; + } + + /** + * @hidden @internal + */ + public onSelect() { + if (!this.column.selected) { + this.grid.selectionService.selectColumn(this.column.field, this.grid.columnSelection === GridSelectionMode.single); + } else { + this.grid.selectionService.deselectColumn(this.column.field); + } + this.grid.notifyChanges(); + } + + /** + * @hidden @internal + */ + public columnSelectable() { + return this.grid?.columnSelection !== GridSelectionMode.none && this.column?.selectable; + } + + /** + * @hidden @internal + */ + public onHideToggle() { + this.column.toggleVisibility(); + this.closeDropdown(); + } + + /** + * @hidden @internal + */ + public cancel() { + if (!this.overlayComponentId) { + this.init(); + } + this.closeDropdown(); + } + + /** + * @hidden @internal + */ + public closeDropdown() { + if (this.overlayComponentId) { + this.overlayService.hide(this.overlayComponentId); + this.overlayComponentId = null; + } + } + + /** + * @hidden @internal + */ + public onKeyDown(eventArgs: KeyboardEvent) { + if (this.platform.isFilteringKeyCombo(eventArgs)) { + eventArgs.preventDefault(); + this.closeDropdown(); + } + eventArgs.stopPropagation(); + } + + /** + * @hidden @internal + */ + public hide() { + this._originalDisplay = this.computedStyles.display; + this.element.nativeElement.style.display = 'none'; + } + + /** + * @hidden @internal + */ + public detectChanges() { + this.cdr.detectChanges(); + } + + protected computedStyles; + + protected get size(): string { + return this.computedStyles?.getPropertyValue('--component-size'); + } + + protected afterColumnChange() { + this.listData = new Array(); + this.subscriptions?.unsubscribe(); + + if (this._column) { + this.grid.filteringService.registerSVGIcons(); + this.init(); + this.sortingChanged.emit(); + + this.subscriptions = this.grid.columnPin.subscribe(() => { + requestAnimationFrame(() => { + if (!(this.cdr as ViewRef).destroyed) { + this.cdr.detectChanges(); + } + }); + }); + + this.subscriptions.add(this.grid.columnVisibilityChanged.subscribe(() => this.detectChanges())); + this.subscriptions.add(this.grid.sortingExpressionsChange.subscribe(() => this.sortingChanged.emit())); + this.subscriptions.add(this.grid.filteringExpressionsTreeChange.subscribe(() => { + this.expressionsList = new Array(); + generateExpressionsList(this.column.filteringExpressionsTree, this.grid.filteringLogic, this.expressionsList); + this.cdr.detectChanges(); + })); + this.subscriptions.add(this.grid.columnMovingEnd.subscribe(() => this.cdr.markForCheck())); + } + } + + private init() { + this.expressionsList = new Array(); + generateExpressionsList(this.column.filteringExpressionsTree, this.grid.filteringLogic, this.expressionsList); + this.populateColumnData(); + } + + private areExpressionsSelectable() { + if (this.expressionsList.length === 1 && + (this.expressionsList[0].expression.condition.name === 'equals' || + this.expressionsList[0].expression.condition.name === 'at' || + this.expressionsList[0].expression.condition.name === 'true' || + this.expressionsList[0].expression.condition.name === 'false' || + this.expressionsList[0].expression.condition.name === 'empty' || + this.expressionsList[0].expression.condition.name === 'in')) { + return true; + } + + const selectableExpressionsCount = this.expressionsList.filter(exp => + (exp.beforeOperator === 1 || exp.afterOperator === 1) && + (exp.expression.condition.name === 'equals' || + exp.expression.condition.name === 'at' || + exp.expression.condition.name === 'true' || + exp.expression.condition.name === 'false' || + exp.expression.condition.name === 'empty' || + exp.expression.condition.name === 'in')).length; + + return selectableExpressionsCount === this.expressionsList.length; + } + + private populateColumnData() { + this.cdr.detectChanges(); + + if (this.grid.uniqueColumnValuesStrategy) { + this.renderColumnValuesRemotely(); + } else { + this.renderColumnValuesFromData(); + } + } + + private renderColumnValuesRemotely() { + this.loadingStart.emit(); + const expressionsTree: FilteringExpressionsTree = this.getColumnFilterExpressionsTree(); + + const prevColumn = this.column; + this.grid.uniqueColumnValuesStrategy(this.column, expressionsTree, (values: any[]) => { + if (!this.column || this.column !== prevColumn) { + return; + } + + const items = values.map(v => ({ + value: v + })); + + this.uniqueValues = this.column.sortStrategy.sort(items, 'value', SortingDirection.Asc, this.column.sortingIgnoreCase, + (obj, key) => { + let resolvedValue = obj[key]; + if (this.column.dataType === GridColumnDataType.Time) { + resolvedValue = new Date().setHours( + resolvedValue.getHours(), + resolvedValue.getMinutes(), + resolvedValue.getSeconds(), + resolvedValue.getMilliseconds()); + } + + return resolvedValue; + }); + + this.renderValues(); + this.loadingEnd.emit(); + }); + } + + private renderColumnValuesFromData() { + this.loadingStart.emit(); + + const expressionsTree = this.getColumnFilterExpressionsTree(); + const promise = this.grid.filterStrategy.getFilterItems(this.column, expressionsTree); + promise.then((items) => { + this.isHierarchical = items.length > 0 && items.some(i => i.children && i.children.length > 0); + this.uniqueValues = items; + this.renderValues(); + this.loadingEnd.emit(); + this.sortingChanged.emit(); + }); + } + + private renderValues() { + this.filterValues = this.generateFilterValues(); + this.generateListData(); + } + + private generateFilterValues() { + const formatValue = (value: any): any => { + if (!value) return value; + + switch (this.column.dataType) { + case GridColumnDataType.Date: + return new Date(value).toDateString(); + case GridColumnDataType.DateTime: + return new Date(value).toISOString(); + case GridColumnDataType.Time: + return typeof value === 'string' ? value : new Date(value).toLocaleTimeString(); + default: + return value; + } + }; + + const processExpression = (arr: any[], e: any): any[] => { + if (e.expression.condition.name === 'in') { + return [...arr, ...Array.from((e.expression.searchVal as Set).values()).map(v => formatValue(v))]; + } + return [...arr, formatValue(e.expression.searchVal)]; + }; + + const filterValues = new Set(this.expressionsList.reduce(processExpression, [])); + + return filterValues; + } + + private generateListData() { + this.listData = new Array(); + const shouldUpdateSelection = this.areExpressionsSelectable(); + + if (this.column.dataType === GridColumnDataType.Boolean) { + this.addBooleanItems(); + } else { + this.addItems(shouldUpdateSelection); + } + + if (!this.isHierarchical && this.containsNullOrEmpty) { + const blanksItem = this.generateBlanksItem(shouldUpdateSelection); + this.listData.unshift(blanksItem); + } + + if (this.listData.length > 0) { + this.addSelectAllItem(); + } + + if (!(this.cdr as any).destroyed) { + this.cdr.detectChanges(); + } + + this.listDataLoaded.emit(); + } + + private getColumnFilterExpressionsTree() { + const gridExpressionsTree: IFilteringExpressionsTree = this.grid.filteringExpressionsTree; + const expressionsTree = new FilteringExpressionsTree(gridExpressionsTree.operator, gridExpressionsTree.fieldName); + + for (const operand of gridExpressionsTree.filteringOperands) { + if (isTree(operand)) { + const columnExprTree = operand as FilteringExpressionsTree; + if (columnExprTree.fieldName === this.column.field) { + continue; + } + } + expressionsTree.filteringOperands.push(operand); + } + + return expressionsTree; + } + + private addBooleanItems() { + this.selectAllSelected = true; + this.selectAllIndeterminate = false; + this.uniqueValues.forEach(element => { + const value = element.value; + const filterListItem = new FilterListItem(); + if (value !== undefined && value !== null && value !== '') { + if (this.column.filteringExpressionsTree) { + if (value === true && this.expressionsList.find(exp => exp.expression.condition.name === 'true')) { + filterListItem.isSelected = true; + filterListItem.isFiltered = true; + this.selectAllIndeterminate = true; + } else if (value === false && this.expressionsList.find(exp => exp.expression.condition.name === 'false')) { + filterListItem.isSelected = true; + filterListItem.isFiltered = true; + this.selectAllIndeterminate = true; + } else { + filterListItem.isSelected = false; + filterListItem.isFiltered = false; + } + } else { + filterListItem.isSelected = true; + filterListItem.isFiltered = true; + } + filterListItem.value = value; + filterListItem.label = value ? + this.grid.resourceStrings.igx_grid_filter_true : + this.grid.resourceStrings.igx_grid_filter_false; + filterListItem.indeterminate = false; + this.listData.push(filterListItem); + } else { + this.containsNullOrEmpty = true; + } + }); + } + + private addItems(shouldUpdateSelection: boolean) { + this.selectAllSelected = true; + this.selectAllIndeterminate = false; + this.containsNullOrEmpty = false; + this.listData = this.generateFilterListItems(this.uniqueValues, shouldUpdateSelection); + this.containsNullOrEmpty = this.uniqueValues.length > this.listData.length; + } + + private generateFilterListItems(values: IgxFilterItem[], shouldUpdateSelection: boolean, parent?: FilterListItem) { + const filterListItems = []; + values?.forEach(element => { + const value = element.value; + const hasValue = value !== undefined && value !== null && value !== ''; + + if (hasValue) { + const filterListItem = new FilterListItem(); + filterListItem.parent = parent; + filterListItem.value = value; + filterListItem.label = element.label !== undefined ? + element.label : + this.getFilterItemLabel(value); + filterListItem.indeterminate = false; + filterListItem.isSelected = true; + filterListItem.isFiltered = true; + + if (this.column.filteringExpressionsTree) { + filterListItem.isSelected = false; + filterListItem.isFiltered = false; + + if (shouldUpdateSelection) { + const exprValue = this.getExpressionValue(value); + if (this.filterValues.has(exprValue)) { + filterListItem.isSelected = true; + filterListItem.isFiltered = true; + } + this.selectAllIndeterminate = true; + } else { + this.selectAllSelected = false; + } + } + + filterListItem.children = this.generateFilterListItems(element.children ?? element.value?.children, shouldUpdateSelection, filterListItem); + filterListItems.push(filterListItem); + } + }); + + return filterListItems; + } + + private addSelectAllItem() { + const selectAll = new FilterListItem(); + selectAll.isSelected = this.selectAllSelected; + selectAll.value = this.grid.resourceStrings.igx_grid_excel_select_all; + selectAll.label = this.grid.resourceStrings.igx_grid_excel_select_all; + selectAll.indeterminate = this.selectAllIndeterminate; + selectAll.isSpecial = true; + selectAll.isFiltered = this.selectAllSelected; + this.listData.unshift(selectAll); + } + + private generateBlanksItem(shouldUpdateSelection) { + const blanks = new FilterListItem(); + if (this.column.filteringExpressionsTree) { + if (shouldUpdateSelection) { + if (this.filterValues.has(null)) { + blanks.isSelected = true; + blanks.isFiltered = true; + } else { + blanks.isSelected = false; + blanks.isFiltered = false; + } + } + } else { + blanks.isSelected = true; + blanks.isFiltered = true; + } + blanks.value = null; + blanks.label = this.grid.resourceStrings.igx_grid_excel_blanks; + blanks.indeterminate = false; + blanks.isSpecial = true; + blanks.isBlanks = true; + + return blanks; + } + + private getFilterItemLabel(value: any, applyFormatter = true, data?: any) { + if (this.column.formatter) { + if (applyFormatter) { + return this.column.formatter(value, data); + } + return value; + } + + const { display, format, digitsInfo, currencyCode, timezone } = this.column.pipeArgs; + const locale = this.grid.locale; + + switch (this.column.dataType) { + case GridColumnDataType.Date: + case GridColumnDataType.DateTime: + case GridColumnDataType.Time: + return formatDate(value, format, locale, timezone); + case GridColumnDataType.Currency: + return formatCurrency(value, currencyCode || getLocaleCurrencyCode(locale), display, digitsInfo, locale); + case GridColumnDataType.Number: + return formatNumber(value, locale, digitsInfo); + case GridColumnDataType.Percent: + return formatPercent(value, locale, digitsInfo); + default: + return value; + } + } + + private getExpressionValue(value: any): string { + if (this.column.dataType === GridColumnDataType.Date) { + value = value ? new Date(value).toDateString() : value; + } else if (this.column.dataType === GridColumnDataType.DateTime) { + value = value ? new Date(value).toISOString() : value; + } else if (this.column.dataType === GridColumnDataType.Time) { + value = value ? new Date(value).toLocaleTimeString() : value; + } + + return value; + } +} diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-header.component.html b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-header.component.html new file mode 100644 index 00000000000..7aa8df61600 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-header.component.html @@ -0,0 +1,38 @@ +@if (esf.column) { +
    +

    {{ esf.column.header || esf.column.field }}

    +
    + @if (showSelecting) { + + } + @if (showPinning) { + + } + @if (showHiding) { + + } +
    +
    +} diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-header.component.ts b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-header.component.ts new file mode 100644 index 00000000000..f01f0eee140 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-header.component.ts @@ -0,0 +1,53 @@ +import { Component, Input, booleanAttribute, inject } from '@angular/core'; +import { BaseFilteringComponent } from './base-filtering.component'; +import { NgClass } from '@angular/common'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxIconButtonDirective } from 'igniteui-angular/directives'; + +/** + * A component used for presenting Excel style header UI. + */ +@Component({ + selector: 'igx-excel-style-header', + templateUrl: './excel-style-header.component.html', + imports: [NgClass, IgxIconComponent, IgxIconButtonDirective] +}) +export class IgxExcelStyleHeaderComponent { + public esf = inject(BaseFilteringComponent); + + /** + * Sets whether the column pinning icon should be shown in the header. + * Default value is `false`. + * + * @example + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public showPinning: boolean; + + /** + * Sets whether the column selecting icon should be shown in the header. + * Default value is `false`. + * + * @example + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public showSelecting: boolean; + + /** + * Sets whether the column hiding icon should be shown in the header. + * Default value is `false`. + * + * @example + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public showHiding: boolean; +} diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-hiding.component.html b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-hiding.component.html new file mode 100644 index 00000000000..5f24451e259 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-hiding.component.html @@ -0,0 +1,10 @@ +@if (esf.column) { + +} diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-hiding.component.ts b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-hiding.component.ts new file mode 100644 index 00000000000..5db40be03d4 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-hiding.component.ts @@ -0,0 +1,15 @@ +import { Component, inject } from '@angular/core'; +import { BaseFilteringComponent } from './base-filtering.component'; +import { IgxIconComponent } from 'igniteui-angular/icon'; + +/** + * A component used for presenting Excel style column hiding UI. + */ +@Component({ + selector: 'igx-excel-style-hiding', + templateUrl: './excel-style-hiding.component.html', + imports: [IgxIconComponent] +}) +export class IgxExcelStyleHidingComponent { + public esf = inject(BaseFilteringComponent); +} diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-moving.component.html b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-moving.component.html new file mode 100644 index 00000000000..a85b1c229eb --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-moving.component.html @@ -0,0 +1,31 @@ +@if (esf.column) { +
    + {{ esf.grid.resourceStrings.igx_grid_excel_filter_moving_header }} +
    + + + + +} diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-moving.component.ts b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-moving.component.ts new file mode 100644 index 00000000000..8eb6ff10f33 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-moving.component.ts @@ -0,0 +1,103 @@ +import { Component, HostBinding, inject } from '@angular/core'; +import { BaseFilteringComponent } from './base-filtering.component'; +import { IgxButtonGroupComponent } from 'igniteui-angular/button-group'; +import { IgxButtonDirective } from 'igniteui-angular/directives'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { ColumnPinningPosition, ColumnType } from 'igniteui-angular/core'; + +/** + * A component used for presenting Excel style column moving UI. + */ +@Component({ + selector: 'igx-excel-style-moving', + templateUrl: './excel-style-moving.component.html', + imports: [IgxButtonGroupComponent, IgxButtonDirective, IgxIconComponent] +}) +export class IgxExcelStyleMovingComponent { + public esf = inject(BaseFilteringComponent); + + /** + * @hidden @internal + */ + @HostBinding('class.igx-excel-filter__move') + public defaultClass = true; + + private get visibleColumns() { + return this.esf.grid.visibleColumns.filter(col => !col.columnGroup); + } + + /** + * @hidden @internal + */ + public get canNotMoveLeft() { + return this.esf.column.visibleIndex === 0 || + (this.esf.grid.unpinnedColumns.indexOf(this.esf.column) === 0 && this.esf.column.disablePinning) || + (this.esf.column.level !== 0 && !this.findColumn(0, this.visibleColumns)); + } + + /** + * @hidden @internal + */ + public get canNotMoveRight() { + return this.esf.column.visibleIndex === this.visibleColumns.length - 1 || + (this.esf.column.level !== 0 && !this.findColumn(1, this.visibleColumns)); + } + + /** + * @hidden @internal + */ + public onMoveButtonClicked(moveDirection) { + let targetColumn; + if (this.esf.column.pinned) { + 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.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) { + // 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) { + // 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); + } + this.esf.grid.moveColumn(this.esf.column, targetColumn, moveDirection); + } + + protected get esfSize(): string { + const esf = this.esf as any; + return esf.size; + } + + private findColumn(moveDirection: number, columns: ColumnType[]) { + let index = columns.indexOf(this.esf.column); + if (moveDirection === 0) { + while (index > 0) { + index--; + if (columns[index].level === this.esf.column.level && columns[index].parent === this.esf.column.parent) { + return columns[index]; + } + } + return columns[0]; + } else { + while (index < columns.length - 1) { + index++; + if (columns[index].level === this.esf.column.level && columns[index].parent === this.esf.column.parent) { + return columns[index]; + } + } + } + } +} diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-pinning.component.html b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-pinning.component.html new file mode 100644 index 00000000000..47360003197 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-pinning.component.html @@ -0,0 +1,10 @@ +@if (esf.column) { +
    + {{ esf.column.pinned ? esf.grid.resourceStrings.igx_grid_excel_unpin : esf.grid.resourceStrings.igx_grid_excel_pin }} + +
    +} diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-pinning.component.ts b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-pinning.component.ts new file mode 100644 index 00000000000..51aeaf963eb --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-pinning.component.ts @@ -0,0 +1,16 @@ +import { Component, inject } from '@angular/core'; +import { BaseFilteringComponent } from './base-filtering.component'; +import { NgClass } from '@angular/common'; +import { IgxIconComponent } from 'igniteui-angular/icon'; + +/** + * A component used for presenting Excel style column pinning UI. + */ +@Component({ + selector: 'igx-excel-style-pinning', + templateUrl: './excel-style-pinning.component.html', + imports: [NgClass, IgxIconComponent] +}) +export class IgxExcelStylePinningComponent { + public esf = inject(BaseFilteringComponent); +} diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-position-strategy.ts b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-position-strategy.ts new file mode 100644 index 00000000000..28079348d2e --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-position-strategy.ts @@ -0,0 +1,23 @@ +import { AutoPositionStrategy, ConnectedFit } from 'igniteui-angular/core'; + + +/** @hidden */ +export class ExcelStylePositionStrategy extends AutoPositionStrategy { + protected override shouldFitInViewPort() { + return true; + } + + protected override fitInViewport(element: HTMLElement, connectedFit: ConnectedFit) { + const heightOverflow = connectedFit.contentElementRect.height - connectedFit.viewPortRect.height; + if (heightOverflow > 0) { + element.style.width = 'auto'; + element.style.height = `${connectedFit.viewPortRect.height}px`; + } else { + element.style.height = `${Math.max( + connectedFit.viewPortRect.height - connectedFit.targetRect.bottom - 1, + connectedFit.contentElementRect.height)}px`; + } + + super.fitInViewport(element, connectedFit); + } +} diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-search.component.html b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-search.component.html new file mode 100644 index 00000000000..a011c9a7d04 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-search.component.html @@ -0,0 +1,176 @@ + + + + @if (searchValue || searchValue === 0) { + + + + + } + + +@if (!isHierarchical()) { + +
    + + + {{ item.label }} + + +
    + +
    + + +
    +
    + + + +
    + {{ esf.grid?.resourceStrings.igx_grid_excel_matches_count.replace('{0}', matchesCount) }} +
    +
    +} + +@if (isHierarchical()) { +
    + @if (!isTreeEmpty()) { +
    +
    + + {{ selectAllItem.label }} + +
    + @if (searchValue) { +
    + + {{ addToCurrentFilterItem.label }} + +
    + } +
    + } + + @for (item of displayedListData; track item.value) { + +
    {{item.label}}
    + @for (childLevel1 of item.children; track childLevel1.value) { + +
    {{childLevel1.label}}
    + @for (childLevel2 of childLevel1.children; track childLevel2.value) { + +
    {{childLevel2.label}}
    + @for (childLevel3 of childLevel2.children; track childLevel3.value) { + +
    {{childLevel3.label}}
    + @for (childLevel4 of childLevel3.children; track childLevel4.value) { + +
    {{childLevel4.label}}
    + @for (childLevel5 of childLevel4.children; track childLevel5.value) { + +
    {{childLevel5.label}}
    + @for (childLevel6 of childLevel5.children; track childLevel6.value) { + +
    {{childLevel6.label}}
    + @for (childLevel7 of childLevel6.children; track childLevel7.value) { + +
    {{childLevel7.label}}
    + @for (childLevel8 of childLevel7.children; track childLevel8.value) { + +
    {{childLevel8.label}}
    + @for (childLevel9 of childLevel8.children; track childLevel9.value) { + +
    {{childLevel9.label}}
    +
    + } +
    + } +
    + } +
    + } +
    + } +
    + } +
    + } +
    + } +
    + } +
    + } +
    + +
    + + +
    +
    + @if (isTreeEmpty()) { + + } +
    +} + + +
    + {{esf.grid?.resourceStrings.igx_grid_excel_no_matches}} +
    +
    + + + + + + +
    +
    + +
    +
    + +
    +
    diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-search.component.ts b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-search.component.ts new file mode 100644 index 00000000000..54a59cb4783 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-search.component.ts @@ -0,0 +1,852 @@ +import { AfterViewInit, Component, ViewChild, ChangeDetectorRef, TemplateRef, Directive, OnDestroy, HostBinding, Input, inject } from '@angular/core'; +import { Subject } from 'rxjs'; +import { IChangeCheckboxEventArgs, IgxCheckboxComponent } from 'igniteui-angular/checkbox'; +import { takeUntil } from 'rxjs/operators'; +import { BaseFilteringComponent } from './base-filtering.component'; +import { ActiveElement, ExpressionUI, FilterListItem } from './common'; +import { NgTemplateOutlet } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { IgxInputDirective, IgxInputGroupComponent, IgxPrefixDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxDataLoadingTemplateDirective, IgxEmptyListTemplateDirective, IgxListComponent, IgxListItemComponent } from 'igniteui-angular/list'; +import { IgxButtonDirective, IgxForOfDirective } from 'igniteui-angular/directives'; +import { IgxTreeComponent, IgxTreeNodeComponent, ITreeNodeSelectionEvent } from 'igniteui-angular/tree'; +import { IgxCircularProgressBarComponent } from 'igniteui-angular/progressbar'; +import { cloneHierarchicalArray, FilteringExpressionsTree, FilteringLogic, GridColumnDataType, IgxBooleanFilteringOperand, IgxDateFilteringOperand, IgxDateTimeFilteringOperand, IgxNumberFilteringOperand, IgxStringFilteringOperand, IgxTimeFilteringOperand, PlatformUtil, ɵSize } from 'igniteui-angular/core'; +import { Navigate } from 'igniteui-angular/drop-down'; + +@Directive({ + selector: '[igxExcelStyleLoading]', + standalone: true +}) +export class IgxExcelStyleLoadingValuesTemplateDirective { + public template = inject>(TemplateRef); + + public static ngTemplateContextGuard(_dir: IgxExcelStyleLoadingValuesTemplateDirective, + ctx: unknown): ctx is undefined { + return true + } +} + +let NEXT_ID = 0; +/** + * A component used for presenting Excel style search UI. + */ +@Component({ + selector: 'igx-excel-style-search', + templateUrl: './excel-style-search.component.html', + imports: [IgxInputGroupComponent, IgxIconComponent, IgxPrefixDirective, FormsModule, IgxInputDirective, IgxSuffixDirective, IgxListComponent, IgxForOfDirective, IgxListItemComponent, IgxCheckboxComponent, IgxDataLoadingTemplateDirective, NgTemplateOutlet, IgxEmptyListTemplateDirective, IgxTreeComponent, IgxTreeNodeComponent, IgxCircularProgressBarComponent, IgxButtonDirective] +}) +export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy { + public cdr = inject(ChangeDetectorRef); + public esf = inject(BaseFilteringComponent); + protected platform = inject(PlatformUtil); + + private static readonly filterOptimizationThreshold = 2; + + /** + * @hidden @internal + */ + @HostBinding('class.igx-excel-filter__menu-main') + public defaultClass = true; + + /** + * @hidden @internal + */ + @ViewChild('input', { read: IgxInputDirective, static: true }) + public searchInput: IgxInputDirective; + + @ViewChild('cancelButton', { read: IgxButtonDirective, static: true }) + protected cancelButton: IgxButtonDirective; + + /** + * @hidden @internal + */ + @ViewChild('list', { read: IgxListComponent, static: false }) + public list: IgxListComponent; + + /** + * @hidden @internal + */ + @ViewChild('selectAllCheckbox', { read: IgxCheckboxComponent, static: false }) + public selectAllCheckbox: IgxCheckboxComponent; + + /** + * @hidden @internal + */ + @ViewChild('addToCurrentFilterCheckbox', { read: IgxCheckboxComponent, static: false }) + public addToCurrentFilterCheckbox: IgxCheckboxComponent; + + /** + * @hidden @internal + */ + @ViewChild('tree', { read: IgxTreeComponent, static: false }) + public tree: IgxTreeComponent; + + /** + * @hidden @internal + */ + @ViewChild(IgxForOfDirective) + protected virtDir: IgxForOfDirective; + + /** + * @hidden @internal + */ + @ViewChild('defaultExcelStyleLoadingValuesTemplate', { read: TemplateRef }) + protected defaultExcelStyleLoadingValuesTemplate: TemplateRef; + + /** + * @hidden @internal + */ + public get selectAllItem(): FilterListItem { + if (!this._selectAllItem) { + const selectAllItem = { + isSelected: false, + isFiltered: false, + indeterminate: false, + isSpecial: true, + isBlanks: false, + value: this.esf.grid.resourceStrings.igx_grid_excel_select_all, + label: this.esf.grid.resourceStrings.igx_grid_excel_select_all + }; + + this._selectAllItem = selectAllItem; + } + + return this._selectAllItem; + } + + /** + * @hidden @internal + */ + public get addToCurrentFilterItem(): FilterListItem { + if (!this._addToCurrentFilterItem) { + const addToCurrentFilterItem = { + isSelected: false, + isFiltered: false, + indeterminate: false, + isSpecial: true, + isBlanks: false, + value: this.esf.grid.resourceStrings.igx_grid_excel_add_to_filter, + label: this.esf.grid.resourceStrings.igx_grid_excel_add_to_filter + }; + + this._addToCurrentFilterItem = addToCurrentFilterItem; + } + + return this._addToCurrentFilterItem; + } + + /** + * @hidden @internal + */ + public get isLoading() { + return this._isLoading; + } + + /** + * @hidden @internal + */ + public set isLoading(value: boolean) { + this._isLoading = value; + if (!(this.cdr as any).destroyed) { + this.cdr.detectChanges(); + } + } + + /** + * @hidden @internal + */ + public searchValue: any; + + /** + * @hidden @internal + */ + public displayedListData: FilterListItem[] = []; + + /** + * @hidden @internal + */ + public matchesCount: number; + + /** + * @hidden @internal + */ + public get valuesLoadingTemplate() { + if (this.esf.grid?.excelStyleLoadingValuesTemplateDirective) { + return this.esf.grid.excelStyleLoadingValuesTemplateDirective.template; + } else { + return this.defaultExcelStyleLoadingValuesTemplate; + } + } + + protected activeDescendant = ''; + + private _id = `igx-excel-style-search-${NEXT_ID++}`; + private _isLoading = true; + private _addToCurrentFilterItem: FilterListItem; + private _selectAllItem: FilterListItem; + private _hierarchicalSelectedItems: FilterListItem[]; + private _focusedItem: ActiveElement = null; + private destroy$ = new Subject(); + + constructor() { + const esf = this.esf; + + esf.loadingStart.pipe(takeUntil(this.destroy$)).subscribe(() => { + this.displayedListData = []; + this.isLoading = true; + }); + esf.loadingEnd.pipe(takeUntil(this.destroy$)).subscribe(() => { + this.refreshSize(); + this.isLoading = false; + }); + esf.initialized.pipe(takeUntil(this.destroy$)).subscribe(() => { + requestAnimationFrame(() => { + this.refreshSize(); + this.searchInput.nativeElement.focus(); + }); + }); + esf.columnChange.pipe(takeUntil(this.destroy$)).subscribe(() => { + this.virtDir?.resetScrollPosition(); + + if (this.virtDir) { + this.virtDir.state.startIndex = 0; + } + }); + + esf.listDataLoaded.pipe(takeUntil(this.destroy$)).subscribe(() => { + this._selectAllItem = this.esf.listData[0]; + if (this.isHierarchical() && this.esf.listData[0].isSpecial) { + this.esf.listData.splice(0, 1); + } + + if (this.searchValue) { + this.clearInput(); + } else { + this.filterListData(); + } + + this.cdr.detectChanges(); + requestAnimationFrame(() => { + this.refreshSize(); + this.searchInput.nativeElement.focus(); + }); + }); + + esf.filterCleared.pipe(takeUntil(this.destroy$)).subscribe(() => { + this.clearInput(); + }); + } + + public ngAfterViewInit() { + if (this.platform.isBrowser) { + // SSR workaround + requestAnimationFrame(this.refreshSize); + } + } + + public ngOnDestroy(): void { + this.destroy$.next(true); + this.destroy$.complete(); + } + + /** + * @hidden @internal + */ + public refreshSize = () => { + if (this.virtDir) { + this.virtDir.igxForContainerSize = this.containerSize; + this.virtDir.igxForItemSize = this.itemSize; + this.virtDir.recalcUpdateSizes(); + this.cdr.detectChanges(); + } + } + + /** + * @hidden @internal + */ + public clearInput() { + this.searchValue = null; + this.filterListData(); + } + + /** + * @hidden @internal + */ + public onCheckboxChange(eventArgs: IChangeCheckboxEventArgs) { + const selectedIndex = this.displayedListData.indexOf(eventArgs.owner.value); + const selectAllBtn = this.displayedListData[0]; + + if (selectedIndex === 0) { + this.displayedListData.forEach(element => { + if (element === this.addToCurrentFilterItem) { + return; + } + element.isSelected = eventArgs.checked; + }); + + selectAllBtn.indeterminate = false; + } else { + eventArgs.owner.value.isSelected = eventArgs.checked; + const indexToStartSlicing = this.displayedListData.indexOf(this.addToCurrentFilterItem) > -1 ? 2 : 1; + + const slicedArray = + this.displayedListData.slice(indexToStartSlicing, this.displayedListData.length); + + if (!slicedArray.find(el => el.isSelected === false)) { + selectAllBtn.indeterminate = false; + selectAllBtn.isSelected = true; + } else if (!slicedArray.find(el => el.isSelected === true)) { + selectAllBtn.indeterminate = false; + selectAllBtn.isSelected = false; + } else { + selectAllBtn.indeterminate = true; + } + } + } + + /** + * @hidden @internal + */ + public onSelectAllCheckboxChange(eventArgs: IChangeCheckboxEventArgs) { + this._selectAllItem.isSelected = eventArgs.checked; + this._selectAllItem.indeterminate = false; + const treeNodes = this.tree.nodes; + treeNodes.forEach(node => (node.data as FilterListItem).isSelected = eventArgs.checked); + } + + /** + * @hidden @internal + */ + public onNodeSelectionChange(eventArgs: ITreeNodeSelectionEvent) { + eventArgs.added.forEach(node => { + (node.data as FilterListItem).isSelected = true; + }); + eventArgs.removed.forEach(node => { + (node.data as FilterListItem).isSelected = false; + }); + + this._hierarchicalSelectedItems = eventArgs.newSelection.map(item => item.data as FilterListItem); + const selectAllBtn = this.selectAllItem; + if (this._hierarchicalSelectedItems.length === 0) { + selectAllBtn.indeterminate = false; + selectAllBtn.isSelected = false; + } else if (this._hierarchicalSelectedItems.length === this.tree.nodes.length) { + selectAllBtn.indeterminate = false; + selectAllBtn.isSelected = true; + } else { + selectAllBtn.indeterminate = true; + selectAllBtn.isSelected = false; + } + } + + /** + * @hidden @internal + */ + public get itemSize() { + let itemSize = '40px'; + const esf = this.esf as any; + switch (esf.size) { + case ɵSize.Medium: itemSize = '32px'; break; + case ɵSize.Small: itemSize = '24px'; break; + default: break; + } + return itemSize; + } + + /** + * @hidden @internal + */ + public get containerSize() { + if (this.esf.listData.length) { + return this.list?.element.nativeElement.offsetHeight; + } + + // GE Nov 1st, 2021 #10355 Return a numeric value, so the chunk size is calculated properly. + // If we skip this branch, on applying the filter the _calculateChunkSize() method off the ForOfDirective receives + // an igxForContainerSize = undefined, thus assigns the chunkSize to the igxForOf.length which leads to performance issues. + return 0; + } + + @HostBinding('attr.id') + @Input() + protected get id(): string { + return this._id; + } + protected set id(value: string) { + this._id = value; + } + + protected getItemId(index: number): string { + return `${this.id}-item-${index}`; + } + + protected setActiveDescendant(): void { + this.activeDescendant = this.focusedItem?.id || ''; + } + + protected get focusedItem(): ActiveElement { + return this._focusedItem; + } + + protected set focusedItem(val: ActiveElement) { + this._focusedItem = val; + } + + /** + * @hidden @internal + */ + public get applyButtonDisabled(): boolean { + return (this._selectAllItem && !this._selectAllItem.isSelected && !this._selectAllItem.indeterminate) || + (this.displayedListData && this.displayedListData.length === 0); + } + + /** + * @hidden @internal + */ + public onInputKeyDown(event: KeyboardEvent): void { + switch (event.key) { + case this.platform.KEYMAP.ENTER: + event.preventDefault(); + this.applyFilter(); + + return; + case this.platform.KEYMAP.ESCAPE: + if (this.searchValue) { + event.stopPropagation(); + this.clearInput(); + } + + return; + } + } + + /** + * @hidden @internal + */ + public filterListData(): void { + if (this.esf.column?.dataType === GridColumnDataType.Number || + this.esf.column?.dataType === GridColumnDataType.Currency || + this.esf.column?.dataType === GridColumnDataType.Percent) { + this.rejectNonNumericalEntries(); + } + + if (!this.esf.listData || !this.esf.listData.length) { + this.displayedListData = []; + + return; + } + + let selectAllBtn; + if (this._selectAllItem) { + selectAllBtn = this._selectAllItem; + } else { + selectAllBtn = this.esf.listData[0]; + } + + if (!this.searchValue) { + let anyFiltered = this.esf.listData.some(i => i.isFiltered); + let anyUnfiltered = this.esf.listData.some(i => !i.isFiltered); + selectAllBtn.indeterminate = anyFiltered && anyUnfiltered; + if (this.isHierarchical() && this.tree) { + this._hierarchicalSelectedItems = this.tree.nodes.map(n => n.data as FilterListItem).filter(item => item.isFiltered); + this.tree.collapseAll(); + } + + this.esf.listData.forEach(i => i.isSelected = i.isFiltered); + if (this.displayedListData !== this.esf.listData) { + this.displayedListData = this.esf.listData; + if (this.isHierarchical()) { + this.cdr.detectChanges(); + this.tree.nodes.forEach(n => { + const item = n.data as FilterListItem; + n.selected = item.isSelected || item.isFiltered; + anyFiltered = anyFiltered || n.selected; + anyUnfiltered = anyUnfiltered || !n.selected; + }); + selectAllBtn.indeterminate = anyFiltered && anyUnfiltered; + } + } + selectAllBtn.label = this.esf.grid.resourceStrings.igx_grid_excel_select_all; + this.matchesCount = this.displayedListData.length - 1; + this.cdr.detectChanges(); + + return; + } + + const searchVal = this.searchValue.toLowerCase(); + if (this.isHierarchical()) { + this._hierarchicalSelectedItems = []; + this.esf.listData.forEach(i => i.isSelected = false); + const matchedData = cloneHierarchicalArray(this.esf.listData, 'children'); + this.displayedListData = this.hierarchicalSelectMatches(matchedData, searchVal); + this.cdr.detectChanges(); + this.tree.nodes.forEach(n => { + n.selected = true; + if ((n.data as FilterListItem).label.toString().toLowerCase().indexOf(searchVal) > -1) { + this.expandAllParentNodes(n); + } + }); + } else { + this.displayedListData = this.esf.listData.filter((it, i) => (i === 0 && it.isSpecial) || + (it.label !== null && it.label !== undefined) && + !it.isBlanks && + it.label.toString().toLowerCase().indexOf(searchVal) > -1); + + this.esf.listData.forEach(i => i.isSelected = false); + this.displayedListData.forEach(i => i.isSelected = true); + this.displayedListData.splice(1, 0, this.addToCurrentFilterItem); + if (this.displayedListData.length === 2) { + this.displayedListData = []; + } + } + + if (this.displayedListData.length > 2) { + this.matchesCount = this.displayedListData.length - 2; + } else { + this.matchesCount = 0; + } + + selectAllBtn.indeterminate = false; + selectAllBtn.isSelected = true; + selectAllBtn.label = this.esf.grid.resourceStrings.igx_grid_excel_select_all_search_results; + this.cdr.detectChanges(); + } + + /** + * @hidden @internal + */ + public applyFilter() { + const filterTree = new FilteringExpressionsTree(FilteringLogic.Or, this.esf.column.field); + + let selectedItems = []; + if (this.isHierarchical()) { + if (this.addToCurrentFilterCheckbox && this.addToCurrentFilterCheckbox.checked) { + this.addFilteredToSelectedItems(this.esf.listData); + } + + selectedItems = this._hierarchicalSelectedItems; + } else { + const item = this.displayedListData[1]; + const addToCurrentFilterOptionVisible = item === this.addToCurrentFilterItem; + selectedItems = addToCurrentFilterOptionVisible && item.isSelected ? + this.esf.listData.slice(1, this.esf.listData.length).filter(el => el.isSelected || el.isFiltered) : + this.esf.listData.slice(1, this.esf.listData.length).filter(el => el.isSelected); + } + + let unselectedItem; + if (this.isHierarchical()) { + unselectedItem = this.esf.listData.find(el => el.isSelected === false); + } else { + unselectedItem = this.esf.listData.slice(1, this.esf.listData.length).find(el => el.isSelected === false); + } + + if (unselectedItem) { + if (selectedItems.length <= IgxExcelStyleSearchComponent.filterOptimizationThreshold) { + selectedItems.forEach(element => { + let condition = null; + if (element.value !== null && element.value !== undefined) { + if (this.esf.column.dataType === GridColumnDataType.Boolean) { + condition = this.createCondition(element.value.toString()); + } else { + const filterCondition = this.esf.column.dataType === GridColumnDataType.Time ? 'at' : 'equals'; + condition = this.createCondition(filterCondition); + } + } else { + condition = this.createCondition('empty'); + } + filterTree.filteringOperands.push({ + condition, + conditionName: condition.name, + fieldName: this.esf.column.field, + ignoreCase: this.esf.column.filteringIgnoreCase, + searchVal: element.value + }); + }); + } else { + const blanksItemIndex = selectedItems.findIndex(e => e.value === null || e.value === undefined); + let blanksItem: any; + if (blanksItemIndex >= 0) { + blanksItem = selectedItems[blanksItemIndex]; + selectedItems.splice(blanksItemIndex, 1); + } + let searchVal; + switch (this.esf.column.dataType) { + case GridColumnDataType.Date: + searchVal = new Set(selectedItems.map(d => d.value.toDateString())); + break; + case GridColumnDataType.DateTime: + searchVal = new Set(selectedItems.map(d => d.value.toISOString())); + break; + case GridColumnDataType.Time: + searchVal = new Set(selectedItems.map(e => e.value.toLocaleTimeString())); + break; + case GridColumnDataType.String: + if (this.esf.column.filteringIgnoreCase) { + const selectedValues = new Set(selectedItems.map(item => item.value.toLowerCase())); + searchVal = new Set(); + + this.esf.grid.data.forEach(item => { + if (typeof item[this.esf.column.field] === "string" && selectedValues.has(item[this.esf.column.field]?.toLowerCase())) { + searchVal.add(item[this.esf.column.field]); + } + }); + break; + } + default: + searchVal = new Set(selectedItems.map(e => e.value)) + } + filterTree.filteringOperands.push({ + condition: this.createCondition('in'), + conditionName: 'in', + fieldName: this.esf.column.field, + ignoreCase: this.esf.column.filteringIgnoreCase, + searchVal + }); + + if (blanksItem) { + filterTree.filteringOperands.push({ + condition: this.createCondition('empty'), + conditionName: 'empty', + fieldName: this.esf.column.field, + ignoreCase: this.esf.column.filteringIgnoreCase, + searchVal: blanksItem.value + }); + } + } + const grid = this.esf.grid; + const col = this.esf.column; + grid.filteringService.filterInternal(col.field, filterTree); + this.esf.expressionsList = new Array(); + grid.filteringService.generateExpressionsList(col.filteringExpressionsTree, + grid.filteringLogic, this.esf.expressionsList); + } else { + this.esf.grid.filteringService.clearFilter(this.esf.column.field); + } + + this.esf.closeDropdown(); + } + + protected handleKeyDown(event: KeyboardEvent) { + if (event) { + const key = event.key.toLowerCase(); + const navKeys = ['space', 'spacebar', ' ', + 'arrowup', 'up', 'arrowdown', 'down', 'home', 'end']; + if (navKeys.indexOf(key) === -1) { // If key has appropriate function in DD + return; + } + event.preventDefault(); + event.stopPropagation(); + switch (key) { + case 'arrowup': + case 'up': + this.onArrowUpKeyDown(); + break; + case 'arrowdown': + case 'down': + this.onArrowDownKeyDown(); + break; + case 'home': + this.onHomeKeyDown(); + break; + case 'end': + this.onEndKeyDown(); + break; + case 'space': + case 'spacebar': + case ' ': + this.onActionKeyDown(); + break; + default: + return; + } + } + } + + protected onFocus() { + const firstIndexInView = this.virtDir.state.startIndex; + if (this.virtDir.igxForOf.length > 0) { + this.focusedItem = { + id: this.getItemId(firstIndexInView), + index: firstIndexInView, + checked: this.virtDir.igxForOf[firstIndexInView].isSelected + }; + } + this.setActiveDescendant(); + } + + protected onFocusOut() { + this.focusedItem = null; + this.setActiveDescendant(); + } + + /** + * @hidden @internal + */ + public isHierarchical() { + return this.esf.isHierarchical; + } + + /** + * @hidden @internal + */ + public isTreeEmpty() { + return this.esf.isHierarchical && this.displayedListData.length === 0; + } + + private hierarchicalSelectMatches(data: FilterListItem[], searchVal: string) { + data.forEach(element => { + element.indeterminate = false; + element.isSelected = false; + const node = this.tree.nodes.filter(n => (n.data as FilterListItem).label === element.label)[0]; + if (node) { + node.expanded = false; + } + + if (element.label.toString().toLowerCase().indexOf(searchVal) > -1) { + element.isSelected = true; + this.hierarchicalSelectAllChildren(element); + this._hierarchicalSelectedItems.push(element); + } else if (element.children.length > 0) { + element.children = this.hierarchicalSelectMatches(element.children, searchVal); + if (element.children.length > 0) { + element.isSelected = true; + if (node) { + node.expanded = true; + } + } + } + }); + + return data.filter(element => element.isSelected === true); + } + + private hierarchicalSelectAllChildren(element: FilterListItem) { + element.children.forEach(child => { + child.indeterminate = false; + child.isSelected = true; + this._hierarchicalSelectedItems.push(child); + if (child.children) { + this.hierarchicalSelectAllChildren(child); + } + }) + } + + private expandAllParentNodes(node: any) { + if (node.parentNode) { + node.parentNode.expanded = true; + this.expandAllParentNodes(node.parentNode); + } + } + + private addFilteredToSelectedItems(records: FilterListItem[]) { + records.forEach(record => { + if (record.children) { + this.addFilteredToSelectedItems(record.children); + } + + if (record.isFiltered && this._hierarchicalSelectedItems.indexOf(record) < 0) { + this._hierarchicalSelectedItems.push(record); + } + }) + } + + private createCondition(conditionName: string) { + switch (this.esf.column.dataType) { + case GridColumnDataType.Boolean: + return IgxBooleanFilteringOperand.instance().condition(conditionName); + case GridColumnDataType.Number: + case GridColumnDataType.Currency: + case GridColumnDataType.Percent: + return IgxNumberFilteringOperand.instance().condition(conditionName); + case GridColumnDataType.Date: + return IgxDateFilteringOperand.instance().condition(conditionName); + case GridColumnDataType.Time: + return IgxTimeFilteringOperand.instance().condition(conditionName); + case GridColumnDataType.DateTime: + return IgxDateTimeFilteringOperand.instance().condition(conditionName); + default: + return IgxStringFilteringOperand.instance().condition(conditionName); + } + } + + /** + * @hidden @internal + */ + private rejectNonNumericalEntries(): void { + const regExp = /[^0-9\.,eE\-]/g; + if (this.searchValue && regExp.test(this.searchValue)) { + this.searchInput.value = this.searchValue.replace(regExp, ''); + this.searchValue = this.searchInput.value; + } + } + + private onArrowUpKeyDown() { + if (this.focusedItem && this.focusedItem.index === 0 && this.virtDir.state.startIndex === 0) { + // on ArrowUp the focus stays on the same element if it is the first focused + return; + } else { + this.navigateItem(this.focusedItem ? this.focusedItem.index - 1 : 0); + } + this.setActiveDescendant(); + } + + private onArrowDownKeyDown() { + const lastIndex = this.virtDir.igxForOf.length - 1; + if (this.focusedItem && this.focusedItem.index === lastIndex) { + // on ArrowDown the focus stays on the same element if it is the last focused + return; + } else { + this.navigateItem(this.focusedItem ? this.focusedItem.index + 1 : 0); + } + this.setActiveDescendant(); + } + + private onHomeKeyDown() { + this.navigateItem(0); + this.setActiveDescendant(); + } + + private onEndKeyDown() { + this.navigateItem(this.virtDir.igxForOf.length - 1); + this.setActiveDescendant(); + } + + private onActionKeyDown() { + const dataItem = this.displayedListData[this.focusedItem.index]; + const args: IChangeCheckboxEventArgs = { + checked: !dataItem.isSelected, + owner: { + value: dataItem + } + } + this.onCheckboxChange(args); + } + + private navigateItem(index: number) { + if (index === -1 || index >= this.virtDir.igxForOf.length) { + return; + } + const direction = index > (this.focusedItem ? this.focusedItem.index : -1) ? Navigate.Down : Navigate.Up; + const scrollRequired = this.isIndexOutOfBounds(index, direction); + this.focusedItem = { + id: this.getItemId(index), + index: index, + checked: this.virtDir.igxForOf[index].isSelected + }; + if (scrollRequired) { + this.virtDir.scrollTo(index); + } + } + + private isIndexOutOfBounds(index: number, direction: Navigate) { + const virtState = this.virtDir.state; + const currentPosition = this.virtDir.getScroll().scrollTop; + const itemPosition = this.virtDir.getScrollForIndex(index, direction === Navigate.Down); + const indexOutOfChunk = index < virtState.startIndex || index > virtState.chunkSize + virtState.startIndex; + const scrollNeeded = direction === Navigate.Down ? currentPosition < itemPosition : currentPosition > itemPosition; + const subRequired = indexOutOfChunk || scrollNeeded; + return subRequired; + } +} diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-selecting.component.html b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-selecting.component.html new file mode 100644 index 00000000000..34b3b651719 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-selecting.component.html @@ -0,0 +1,11 @@ +@if (esf.column) { +
    + {{esf.grid.resourceStrings.igx_grid_excel_select }} + +
    +} diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-selecting.component.ts b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-selecting.component.ts new file mode 100644 index 00000000000..718ee62708c --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-selecting.component.ts @@ -0,0 +1,16 @@ +import { Component, inject } from '@angular/core'; +import { BaseFilteringComponent } from './base-filtering.component'; +import { NgClass } from '@angular/common'; +import { IgxIconComponent } from 'igniteui-angular/icon'; + +/** + * A component used for presenting Excel style conditional filter UI. + */ +@Component({ + selector: 'igx-excel-style-selecting', + templateUrl: './excel-style-selecting.component.html', + imports: [NgClass, IgxIconComponent] +}) +export class IgxExcelStyleSelectingComponent { + public esf = inject(BaseFilteringComponent); +} diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-sorting.component.html b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-sorting.component.html new file mode 100644 index 00000000000..35cf79678e6 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-sorting.component.html @@ -0,0 +1,32 @@ +@if (esf.column) { +
    + {{ esf.grid.resourceStrings.igx_grid_excel_filter_sorting_header }} +
    + + + + +} diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-sorting.component.ts b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-sorting.component.ts new file mode 100644 index 00000000000..f42af3a270d --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-sorting.component.ts @@ -0,0 +1,79 @@ +import { Component, ViewChild, OnDestroy, HostBinding, ChangeDetectorRef, inject } from '@angular/core'; +import { takeUntil } from 'rxjs/operators'; +import { Subject } from 'rxjs'; +import { BaseFilteringComponent } from './base-filtering.component'; +import { IgxButtonGroupComponent } from 'igniteui-angular/button-group'; +import { IgxButtonDirective } from 'igniteui-angular/directives'; +import { IgxIconComponent } from 'igniteui-angular/icon'; + +/** + * A component used for presenting Excel style column sorting UI. + */ +@Component({ + selector: 'igx-excel-style-sorting', + templateUrl: './excel-style-sorting.component.html', + imports: [IgxButtonGroupComponent, IgxButtonDirective, IgxIconComponent] +}) +export class IgxExcelStyleSortingComponent implements OnDestroy { + public esf = inject(BaseFilteringComponent); + private cdr = inject(ChangeDetectorRef); + + /** + * @hidden @internal + */ + @HostBinding('class.igx-excel-filter__sort') + public defaultClass = true; + + /** + * @hidden @internal + */ + @ViewChild('sortButtonGroup', { read: IgxButtonGroupComponent }) + public sortButtonGroup: IgxButtonGroupComponent; + + private destroy$ = new Subject(); + + constructor() { + this.esf.sortingChanged.pipe(takeUntil(this.destroy$)).subscribe(() => { + this.updateSelectedButtons(this.esf.column.field); + }); + } + + public ngOnDestroy(): void { + this.destroy$.next(true); + this.destroy$.complete(); + } + + /** + * @hidden @internal + */ + public onSortButtonClicked(sortDirection) { + if (this.sortButtonGroup.buttons.filter(b => b.selected).length === 0) { + if (this.esf.grid.isColumnGrouped(this.esf.column.field)) { + this.sortButtonGroup.selectButton(sortDirection - 1); + } else { + this.esf.grid.clearSort(this.esf.column.field); + } + } else { + this.esf.grid.sort({ fieldName: this.esf.column.field, dir: sortDirection, ignoreCase: true }); + } + } + + protected get esfSize(): string { + const esf = this.esf as any; + return esf.size; + } + + private updateSelectedButtons(fieldName: string) { + const sortIndex = this.esf.grid.sortingExpressions.findIndex(s => s.fieldName === fieldName); + + this.cdr.detectChanges(); + this.sortButtonGroup.buttons.forEach((b, i) => { + this.sortButtonGroup.deselectButton(i); + }); + + if (sortIndex !== -1 ) { + const sortDirection = this.esf.grid.sortingExpressions[sortIndex].dir; + this.sortButtonGroup.selectButton(sortDirection - 1); + } + } +} diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/public_api.ts b/projects/igniteui-angular/grids/core/src/filtering/excel-style/public_api.ts new file mode 100644 index 00000000000..58a448c5069 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/public_api.ts @@ -0,0 +1,40 @@ +// import { IgxExcelStyleClearFiltersComponent } from './excel-style-clear-filters.component'; +// import { IgxExcelStyleConditionalFilterComponent } from './excel-style-conditional-filter.component'; +// import { IgxExcelStyleColumnOperationsTemplateDirective, IgxExcelStyleFilterOperationsTemplateDirective, IgxGridExcelStyleFilteringComponent } from './excel-style-filtering.component'; +// import { IgxExcelStyleHeaderComponent } from './excel-style-header.component'; +// import { IgxExcelStyleHidingComponent } from './excel-style-hiding.component'; +// import { IgxExcelStyleMovingComponent } from './excel-style-moving.component'; +// import { IgxExcelStylePinningComponent } from './excel-style-pinning.component'; +// import { IgxExcelStyleLoadingValuesTemplateDirective, IgxExcelStyleSearchComponent } from './excel-style-search.component'; +// import { IgxExcelStyleSelectingComponent } from './excel-style-selecting.component'; +// import { IgxExcelStyleSortingComponent } from './excel-style-sorting.component'; + +export * from './excel-style-clear-filters.component'; +export * from './excel-style-conditional-filter.component'; +export * from './excel-style-header.component'; +export * from './excel-style-hiding.component'; +export * from './excel-style-moving.component'; +export * from './excel-style-pinning.component'; +export * from './excel-style-search.component'; +export * from './excel-style-selecting.component'; +export * from './excel-style-sorting.component'; +export * from './excel-style-filtering.component'; +export * from './excel-style-date-expression.component'; +export * from './common'; + +/* NOTE: Grid excel-style filtering directives collection for ease-of-use import in standalone components scenario */ +// export const IGX_GRID_EXCEL_STYLE_FILTER_DIRECTIVES = [ +// IgxGridExcelStyleFilteringComponent, +// IgxExcelStyleHeaderComponent, +// IgxExcelStyleSortingComponent, +// IgxExcelStylePinningComponent, +// IgxExcelStyleHidingComponent, +// IgxExcelStyleSelectingComponent, +// IgxExcelStyleClearFiltersComponent, +// IgxExcelStyleConditionalFilterComponent, +// IgxExcelStyleMovingComponent, +// IgxExcelStyleSearchComponent, +// IgxExcelStyleColumnOperationsTemplateDirective, +// IgxExcelStyleFilterOperationsTemplateDirective, +// IgxExcelStyleLoadingValuesTemplateDirective +// ] as const; diff --git a/projects/igniteui-angular/grids/core/src/filtering/grid-filtering.service.ts b/projects/igniteui-angular/grids/core/src/filtering/grid-filtering.service.ts new file mode 100644 index 00000000000..639798d6e51 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/filtering/grid-filtering.service.ts @@ -0,0 +1,598 @@ +import { Injectable, OnDestroy, inject } from '@angular/core'; +import { Subject } from 'rxjs'; +import { takeUntil, first } from 'rxjs/operators'; +import { IColumnResizeEventArgs, IFilteringEventArgs } from '../common/events'; +import { useAnimation } from '@angular/animations'; +import { editor, pinLeft, unpinLeft } from '@igniteui/material-icons-extended'; +import { ExpressionUI, generateExpressionsList } from './excel-style/common'; +import { GridType } from '../common/grid.interface'; +import { ExcelStylePositionStrategy } from './excel-style/excel-style-position-strategy'; +import { fadeIn } from 'igniteui-angular/animations'; +import { AbsoluteScrollStrategy, ColumnType, ExpressionsTreeUtil, FilteringExpressionsTree, FilteringLogic, formatDate, IFilteringExpression, IFilteringExpressionsTree, IFilteringOperation, IgxOverlayService, isTree, OverlayCancelableEventArgs, OverlayEventArgs, OverlaySettings, VerticalAlignment } from 'igniteui-angular/core'; +import { IgxIconService } from 'igniteui-angular/icon'; +import { IForOfState } from 'igniteui-angular/directives'; + + +/** + * @hidden + */ +@Injectable() +export class IgxFilteringService implements OnDestroy { + private iconService = inject(IgxIconService); + protected _overlayService = inject(IgxOverlayService); + + public isFilterRowVisible = false; + public filteredColumn: ColumnType = null; + public selectedExpression: IFilteringExpression = null; + public columnToMoreIconHidden = new Map(); + public activeFilterCell = 0; + public grid: GridType; + + private columnsWithComplexFilter = new Set(); + private areEventsSubscribed = false; + protected destroy$ = new Subject(); + private isFiltering = false; + private columnToExpressionsMap = new Map(); + private columnStartIndex = -1; + protected _filterMenuOverlaySettings: OverlaySettings = { + closeOnEscape: true, + closeOnOutsideClick: true, + modal: false, + positionStrategy: new ExcelStylePositionStrategy({ + verticalStartPoint: VerticalAlignment.Bottom, + openAnimation: useAnimation(fadeIn, { params: { duration: '250ms' } }), + closeAnimation: null + }), + scrollStrategy: new AbsoluteScrollStrategy() + }; + protected lastActiveNode; + + public ngOnDestroy(): void { + this.destroy$.next(true); + this.destroy$.complete(); + } + + public toggleFilterDropdown(element: HTMLElement, column: ColumnType) { + + const filterIcon = column.filteringExpressionsTree ? 'igx-excel-filter__icon--filtered' : 'igx-excel-filter__icon'; + const filterIconTarget = element.querySelector(`.${filterIcon}`) as HTMLElement || element; + + const id = this.grid.createFilterDropdown(column, { + ...this._filterMenuOverlaySettings, + ...{ target: filterIconTarget } + }); + + this._overlayService.opening + .pipe( + first(overlay => overlay.id === id), + takeUntil(this.destroy$) + ) + .subscribe((event: OverlayCancelableEventArgs) => { + if (event.componentRef) { + event.componentRef.instance.initialize(column, this._overlayService); + event.componentRef.instance.overlayComponentId = id; + } + this.lastActiveNode = this.grid.navigation.activeNode; + }); + + this._overlayService.opened.pipe(first(overlay => overlay.id === id), takeUntil(this.destroy$)).subscribe((event: OverlayEventArgs) => { + if (event.componentRef) { + event.componentRef.instance.populateData(); + } + }); + + this._overlayService.closed + .pipe( + first(overlay => overlay.id === id), + takeUntil(this.destroy$) + ) + .subscribe(() => { + this._overlayService.detach(id); + this.grid.navigation.activeNode = this.lastActiveNode; + this.grid.theadRow.nativeElement.focus(); + }); + + this._overlayService.show(id); + } + + /** + * Subscribe to grid's events. + */ + public subscribeToEvents() { + if (!this.areEventsSubscribed) { + this.areEventsSubscribed = true; + + this.grid.columnResized.pipe(takeUntil(this.destroy$)).subscribe((eventArgs: IColumnResizeEventArgs) => { + this.updateFilteringCell(eventArgs.column); + }); + + this.grid.parentVirtDir.chunkLoad.pipe(takeUntil(this.destroy$)).subscribe((eventArgs: IForOfState) => { + if (eventArgs.startIndex !== this.columnStartIndex) { + this.columnStartIndex = eventArgs.startIndex; + this.grid.filterCellList.forEach((filterCell) => { + filterCell.updateFilterCellArea(); + }); + } + }); + + this.grid.columnMovingEnd.pipe(takeUntil(this.destroy$)).subscribe(() => { + this.grid.filterCellList.forEach((filterCell) => { + filterCell.updateFilterCellArea(); + }); + }); + } + } + + /** + * Close filtering row if a column is hidden. + */ + public hideFilteringRowOnColumnVisibilityChange(col: ColumnType) { + const filteringRow = this.grid?.filteringRow; + + if (filteringRow && filteringRow.column && filteringRow.column === col) { + filteringRow.close(); + } + } + + /** + * Internal method to create expressionsTree and filter grid used in both filter modes. + */ + public filterInternal(field: string, expressions: FilteringExpressionsTree | Array = null): void { + this.isFiltering = true; + + let expressionsTree; + if (expressions && 'operator' in expressions) { + expressionsTree = expressions; + } else { + expressionsTree = this.createSimpleFilteringTree(field, expressions); + } + + if (expressionsTree.filteringOperands.length === 0) { + this.clearFilter(field); + } else { + this.filter(field, null, expressionsTree); + } + + this.isFiltering = false; + } + + /** + * Execute filtering on the grid. + */ + public filter(field: string, value: any, conditionOrExpressionTree?: IFilteringOperation | IFilteringExpressionsTree, + ignoreCase?: boolean) { + + const grid = this.grid; + + const col = grid.getColumnByName(field); + const filteringIgnoreCase = ignoreCase || (col ? col.filteringIgnoreCase : false); + + const filteringTree = grid.filteringExpressionsTree; + const columnFilteringExpressionsTree = ExpressionsTreeUtil.find(filteringTree, field) as IFilteringExpressionsTree; + conditionOrExpressionTree = conditionOrExpressionTree ?? columnFilteringExpressionsTree; + const fieldFilterIndex = ExpressionsTreeUtil.findIndex(filteringTree, field); + + const newFilteringTree: FilteringExpressionsTree = + this.prepare_filtering_expression(filteringTree, field, value, conditionOrExpressionTree, + filteringIgnoreCase, fieldFilterIndex, true); + + const eventArgs: IFilteringEventArgs = { + owner: grid, + filteringExpressions: ExpressionsTreeUtil.find(newFilteringTree, field) as FilteringExpressionsTree, cancel: false + }; + this.grid.filtering.emit(eventArgs); + + if (eventArgs.cancel) { + return; + } + + if (conditionOrExpressionTree) { + this.filter_internal(field, value, conditionOrExpressionTree, filteringIgnoreCase); + } else { + const expressionsTreeForColumn = ExpressionsTreeUtil.find(this.grid.filteringExpressionsTree, field); + if (!expressionsTreeForColumn) { + throw new Error('Invalid condition or Expression Tree!'); + } else if (isTree(expressionsTreeForColumn)) { + this.filter_internal(field, value, expressionsTreeForColumn, filteringIgnoreCase); + } else { + this.filter_internal(field, value, expressionsTreeForColumn.condition, filteringIgnoreCase); + } + } + const doneEventArgs = ExpressionsTreeUtil.find(this.grid.filteringExpressionsTree, field) as FilteringExpressionsTree; + // Wait for the change detection to update filtered data through the pipes and then emit the event. + requestAnimationFrame(() => this.grid.filteringDone.emit(doneEventArgs)); + } + + public filter_global(term, condition, ignoreCase) { + if (!condition) { + return; + } + + const filteringTree = this.grid.filteringExpressionsTree; + this.grid.crudService.endEdit(false); + this.grid.page = 0; + + filteringTree.filteringOperands = []; + for (const column of this.grid.columns) { + this.prepare_filtering_expression(filteringTree, column.field, term, + condition, ignoreCase || column.filteringIgnoreCase); + } + + this.grid.filteringExpressionsTree = filteringTree; + } + + /** + * Clears the filter of a given column if name is provided. Otherwise clears the filters of all columns. + */ + public clearFilter(field: string): void { + if (field) { + const column = this.grid.getColumnByName(field); + if (!column) { + return; + } + } + + const emptyFilter = new FilteringExpressionsTree(null, field); + const onFilteringEventArgs: IFilteringEventArgs = { + owner: this.grid, + filteringExpressions: emptyFilter, + cancel: false + }; + + this.grid.filtering.emit(onFilteringEventArgs); + + if (onFilteringEventArgs.cancel) { + return; + } + + this.isFiltering = true; + this.clear_filter(field); + + // Wait for the change detection to update filtered data through the pipes and then emit the event. + requestAnimationFrame(() => this.grid.filteringDone.emit(emptyFilter)); + + if (field) { + const expressions = this.getExpressions(field); + expressions.length = 0; + } else { + this.grid.columns.forEach(c => { + const expressions = this.getExpressions(c.field); + expressions.length = 0; + }); + } + + this.isFiltering = false; + } + + public clear_filter(fieldName: string) { + const grid = this.grid; + grid.crudService.endEdit(false); + const filteringState = grid.filteringExpressionsTree; + const index = ExpressionsTreeUtil.findIndex(filteringState, fieldName); + + if (index > -1) { + filteringState.filteringOperands.splice(index, 1); + } else if (!fieldName) { + filteringState.filteringOperands = []; + } + + grid.filteringExpressionsTree = filteringState; + } + + /** + * Filters all the `IgxColumnComponent` in the `IgxGridComponent` with the same condition. + * @deprecated in version 19.0.0. + */ + public filterGlobal(value: any, condition, ignoreCase?) { + if (!condition) { + return; + } + + const filteringTree = this.grid.filteringExpressionsTree; + const newFilteringTree = new FilteringExpressionsTree(filteringTree.operator, filteringTree.fieldName); + + for (const column of this.grid.columns) { + this.prepare_filtering_expression(newFilteringTree, column.field, value, condition, + ignoreCase || column.filteringIgnoreCase); + } + + const eventArgs: IFilteringEventArgs = { owner: this.grid, filteringExpressions: newFilteringTree, cancel: false }; + this.grid.filtering.emit(eventArgs); + if (eventArgs.cancel) { + return; + } + + this.grid.crudService.endEdit(false); + this.grid.page = 0; + this.grid.filteringExpressionsTree = newFilteringTree; + + // Wait for the change detection to update filtered data through the pipes and then emit the event. + requestAnimationFrame(() => this.grid.filteringDone.emit(this.grid.filteringExpressionsTree)); + } + + /** + * Register filtering SVG icons in the icon service. + */ + public registerSVGIcons(): void { + const editorIcons = editor as any[]; + editorIcons.forEach(icon => { + this.iconService.addSvgIconFromText(icon.name, icon.value, 'imx-icons', true); + }); + this.iconService.addSvgIconFromText(pinLeft.name, pinLeft.value, 'imx-icons', true); + this.iconService.addSvgIconFromText(unpinLeft.name, unpinLeft.value, 'imx-icons', true); + } + + /** + * Returns the ExpressionUI array for a given column. + */ + public getExpressions(columnId: string): ExpressionUI[] { + if (!this.columnToExpressionsMap.has(columnId)) { + const column = this.grid.columns.find((col) => col.field === columnId); + const expressionUIs = new Array(); + if (column) { + this.generateExpressionsList(column.filteringExpressionsTree, this.grid.filteringExpressionsTree.operator, expressionUIs); + this.columnToExpressionsMap.set(columnId, expressionUIs); + } + return expressionUIs; + } + + return this.columnToExpressionsMap.get(columnId); + } + + /** + * Recreates all ExpressionUIs for all columns. Executed after filtering to refresh the cache. + */ + public refreshExpressions() { + if (!this.isFiltering) { + this.columnsWithComplexFilter.clear(); + + this.columnToExpressionsMap.forEach((value: ExpressionUI[], key: string) => { + const column = this.grid.columns.find((col) => col.field === key); + if (column) { + value.length = 0; + + this.generateExpressionsList(column.filteringExpressionsTree, this.grid.filteringExpressionsTree.operator, value); + + const isComplex = this.isFilteringTreeComplex(column.filteringExpressionsTree); + if (isComplex) { + this.columnsWithComplexFilter.add(key); + } + + this.updateFilteringCell(column); + } else { + this.columnToExpressionsMap.delete(key); + } + }); + } + } + + /** + * Remove an ExpressionUI for a given column. + */ + public removeExpression(columnId: string, indexToRemove: number) { + const expressionsList = this.getExpressions(columnId); + + if (indexToRemove === 0 && expressionsList.length > 1) { + expressionsList[1].beforeOperator = null; + } else if (indexToRemove === expressionsList.length - 1) { + expressionsList[indexToRemove - 1].afterOperator = null; + } else { + expressionsList[indexToRemove - 1].afterOperator = expressionsList[indexToRemove + 1].beforeOperator; + expressionsList[0].beforeOperator = null; + expressionsList[expressionsList.length - 1].afterOperator = null; + } + + expressionsList.splice(indexToRemove, 1); + } + + /** + * Generate filtering tree for a given column from existing ExpressionUIs. + */ + public createSimpleFilteringTree(columnId: string, expressionUIList = null): FilteringExpressionsTree { + const expressionsList = expressionUIList ? expressionUIList : this.getExpressions(columnId); + const expressionsTree = new FilteringExpressionsTree(FilteringLogic.Or, columnId); + let currAndBranch: FilteringExpressionsTree; + + for (const currExpressionUI of expressionsList) { + if (!currExpressionUI.expression.condition.isUnary && currExpressionUI.expression.searchVal === null) { + if (currExpressionUI.afterOperator === FilteringLogic.And && !currAndBranch) { + currAndBranch = new FilteringExpressionsTree(FilteringLogic.And, columnId); + expressionsTree.filteringOperands.push(currAndBranch); + } + continue; + } + + if ((currExpressionUI.beforeOperator === undefined || currExpressionUI.beforeOperator === null || + currExpressionUI.beforeOperator === FilteringLogic.Or) && + currExpressionUI.afterOperator === FilteringLogic.And) { + + currAndBranch = new FilteringExpressionsTree(FilteringLogic.And, columnId); + expressionsTree.filteringOperands.push(currAndBranch); + currAndBranch.filteringOperands.push(currExpressionUI.expression); + + } else if (currExpressionUI.beforeOperator === FilteringLogic.And) { + currAndBranch.filteringOperands.push(currExpressionUI.expression); + } else { + expressionsTree.filteringOperands.push(currExpressionUI.expression); + currAndBranch = null; + } + } + + return expressionsTree; + } + + /** + * Returns whether a complex filter is applied to a given column. + */ + public isFilterComplex(columnId: string) { + if (this.columnsWithComplexFilter.has(columnId)) { + return true; + } + + const column = this.grid.columns.find((col) => col.field === columnId); + const isComplex = column && this.isFilteringTreeComplex(column.filteringExpressionsTree); + if (isComplex) { + this.columnsWithComplexFilter.add(columnId); + } + + return isComplex; + } + + /** + * Returns the string representation of the FilteringLogic operator. + */ + public getOperatorAsString(operator: FilteringLogic): any { + if (operator === 0) { + return this.grid.resourceStrings.igx_grid_filter_operator_and; + } else { + return this.grid.resourceStrings.igx_grid_filter_operator_or; + } + } + + /** + * Generate the label of a chip from a given filtering expression. + */ + public getChipLabel(expression: IFilteringExpression): any { + if (expression.condition.isUnary) { + return this.grid.resourceStrings[`igx_grid_filter_${expression.condition.name}`] || expression.condition.name; + } else if (expression.searchVal instanceof Date) { + const column = this.grid.getColumnByName(expression.fieldName); + const formatter = column.formatter; + if (formatter) { + return formatter(expression.searchVal, undefined); + } + const pipeArgs = column.pipeArgs; + return formatDate(expression.searchVal, pipeArgs.format, this.grid.locale); + } else { + return expression.searchVal; + } + } + + /** + * Updates the content of a filterCell. + */ + public updateFilteringCell(column: ColumnType) { + const filterCell = column.filterCell; + if (filterCell) { + filterCell.updateFilterCellArea(); + } + } + + public generateExpressionsList(expressions: IFilteringExpressionsTree | IFilteringExpression, + operator: FilteringLogic, + expressionsUIs: ExpressionUI[]): void { + generateExpressionsList(expressions, operator, expressionsUIs); + } + + public isFilteringExpressionsTreeEmpty(expressionTree: IFilteringExpressionsTree): boolean { + if (FilteringExpressionsTree.empty(expressionTree)) { + return true; + } + + for (const expr of expressionTree.filteringOperands) { + if (isTree(expr)) { + if (expr.filteringOperands && expr.filteringOperands.length) { + return false; + } + } else { + return false; + } + } + return true; + } + + protected filter_internal(fieldName: string, term, conditionOrExpressionsTree: IFilteringOperation | IFilteringExpressionsTree, + ignoreCase: boolean) { + const filteringTree = this.grid.filteringExpressionsTree; + this.grid.crudService.endEdit(false); + this.grid.page = 0; + + const fieldFilterIndex = ExpressionsTreeUtil.findIndex(filteringTree, fieldName); + this.prepare_filtering_expression(filteringTree, fieldName, term, conditionOrExpressionsTree, ignoreCase, fieldFilterIndex); + this.grid.filteringExpressionsTree = filteringTree; + } + + /** Modifies the filteringState object to contain the newly added filtering conditions/expressions. + * If createNewTree is true, filteringState will not be modified (because it directly affects the grid.filteringExpressionsTree), + * but a new object is created and returned. + */ + protected prepare_filtering_expression( + filteringState: IFilteringExpressionsTree, + fieldName: string, + searchVal, + conditionOrExpressionsTree: IFilteringOperation | IFilteringExpressionsTree, + ignoreCase: boolean, + insertAtIndex = -1, + createNewTree = false): FilteringExpressionsTree { + + let expressionsTree = conditionOrExpressionsTree && 'operator' in conditionOrExpressionsTree ? + conditionOrExpressionsTree : null; + const condition = conditionOrExpressionsTree && 'operator' in conditionOrExpressionsTree ? + null : conditionOrExpressionsTree as IFilteringOperation; + + let newExpressionsTree = filteringState as FilteringExpressionsTree; + + if (createNewTree) { + newExpressionsTree = new FilteringExpressionsTree(filteringState.operator, filteringState.fieldName); + newExpressionsTree.filteringOperands = [...filteringState.filteringOperands]; + } + + if (condition) { + const newExpression: IFilteringExpression = { fieldName: fieldName, searchVal, condition, conditionName: condition.name, ignoreCase }; + expressionsTree = new FilteringExpressionsTree(filteringState.operator, fieldName); + expressionsTree.filteringOperands.push(newExpression); + } + + if (expressionsTree) { + if (insertAtIndex > -1) { + newExpressionsTree.filteringOperands[insertAtIndex] = expressionsTree; + } else { + newExpressionsTree.filteringOperands.push(expressionsTree); + } + } + + return newExpressionsTree; + } + + + private isFilteringTreeComplex(expressions: IFilteringExpressionsTree | IFilteringExpression): boolean { + if (!expressions) { + return false; + } + + if (isTree(expressions)) { + if (expressions.operator === FilteringLogic.Or) { + const andOperatorsCount = this.getChildAndOperatorsCount(expressions); + + // having more than one 'And' operator in the sub-tree means that the filter could not be represented without parentheses. + return andOperatorsCount > 1; + } + + let isComplex = false; + for (const operand of expressions.filteringOperands) { + isComplex = isComplex || this.isFilteringTreeComplex(operand); + } + + return isComplex; + } + + return false; + } + + private getChildAndOperatorsCount(expressions: IFilteringExpressionsTree): number { + let count = 0; + let operand; + for (let i = 0; i < expressions.filteringOperands.length; i++) { + operand = expressions[i]; + if (operand && isTree(operand)) { + if (operand.operator === FilteringLogic.And) { + count++; + } + + count = count + this.getChildAndOperatorsCount(operand as IFilteringExpressionsTree); + } + } + + return count; + } +} diff --git a/projects/igniteui-angular/grids/core/src/grid-actions/grid-action-button.component.html b/projects/igniteui-angular/grids/core/src/grid-actions/grid-action-button.component.html new file mode 100644 index 00000000000..6eafb5e80b0 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/grid-actions/grid-action-button.component.html @@ -0,0 +1,24 @@ +@if (!asMenuItem) { + +} + + + @if (asMenuItem) { +
    + @if (iconSet) { + {{iconName}} + } + @if (!iconSet) { + {{iconName}} + } + +
    + } +
    diff --git a/projects/igniteui-angular/grids/core/src/grid-actions/grid-action-button.component.ts b/projects/igniteui-angular/grids/core/src/grid-actions/grid-action-button.component.ts new file mode 100644 index 00000000000..76b3654a8a7 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/grid-actions/grid-action-button.component.ts @@ -0,0 +1,93 @@ +import { Component, Input, TemplateRef, ViewChild, Output, EventEmitter, ElementRef, booleanAttribute } from '@angular/core'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxRippleDirective } from 'igniteui-angular/directives'; +import { IgxIconButtonDirective } from 'igniteui-angular/directives'; + +/* blazorElement */ +/* wcElementTag: igc-grid-action-button */ +/* blazorIndirectRender */ +@Component({ + selector: 'igx-grid-action-button', + templateUrl: 'grid-action-button.component.html', + imports: [IgxRippleDirective, IgxIconComponent, IgxIconButtonDirective] +}) +export class IgxGridActionButtonComponent { + + /* blazorSuppress */ + @ViewChild('container') + public container: ElementRef; + + /* blazorSuppress */ + /** + * Event emitted when action button is clicked. + * + * @example + * ```html + * + * ``` + */ + @Output() + public actionClick = new EventEmitter(); + + /** + * Reference to the current template. + * + * @hidden + * @internal + */ + @ViewChild('menuItemTemplate') + public templateRef: TemplateRef; + + /** + * Whether button action is rendered in menu and should container text label. + */ + @Input({ transform: booleanAttribute }) + public asMenuItem = false; + + /** + * Name of the icon to display in the button. + */ + @Input() + public iconName: string; + + /** + * Additional Menu item container element classes. + */ + @Input() + public classNames: string; + + /** @hidden @internal */ + public get containerClass(): string { + return 'igx-action-strip__menu-button ' + (this.classNames || ''); + } + + /** + * The name of the icon set. Used in case the icon is from a different icon set. + */ + @Input() + public iconSet: string; + + /** + * The text of the label. + */ + @Input() + public labelText: string; + + /** + * @hidden + * @internal + */ + public handleClick(event) { + this.actionClick.emit(event); + } + + /** + * @hidden @internal + */ + public preventEvent(event) { + if (event) { + event.stopPropagation(); + event.preventDefault(); + } + } +} diff --git a/projects/igniteui-angular/grids/core/src/grid-actions/grid-actions-base.directive.ts b/projects/igniteui-angular/grids/core/src/grid-actions/grid-actions-base.directive.ts new file mode 100644 index 00000000000..c3bbb0b3e47 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/grid-actions/grid-actions-base.directive.ts @@ -0,0 +1,79 @@ +import { IgxGridActionButtonComponent } from './grid-action-button.component'; +import { Directive, Input, AfterViewInit, QueryList, ViewChildren, IterableDiffers, booleanAttribute, inject } from '@angular/core'; +import { IgxIconService } from 'igniteui-angular/icon'; +import { IgxRowDirective } from '../row.directive'; +import { IgxActionStripToken } from 'igniteui-angular/core'; + +/* blazorElement */ +/* contentParent: ActionStrip */ +/* wcElementTag: igc-grid-action-base-directive */ +/* jsonAPIManageCollectionInMarkup */ +/* blazorIndirectRender */ +@Directive({ + selector: '[igxGridActionsBase]', + standalone: true +}) +export class IgxGridActionsBaseDirective implements AfterViewInit { + protected iconService = inject(IgxIconService); + protected differs = inject(IterableDiffers); + + /** @hidden @internal **/ + @ViewChildren(IgxGridActionButtonComponent) + public buttons: QueryList; + + /** + * Gets/Sets if the action buttons will be rendered as menu items. When in menu, items will be rendered with text label. + * + * @example + * ```html + * + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public asMenuItems = false; + + /** @hidden @internal **/ + public strip: IgxActionStripToken; + + /** + * @hidden + * @internal + */ + public get grid() { + return this.strip.context.grid; + } + + /** + * Getter to be used in template + * + * @hidden + * @internal + */ + public get isRowContext(): boolean { + return this.isRow(this.strip?.context) && !this.strip.context.inEditMode; + } + + /** + * @hidden + * @internal + */ + public ngAfterViewInit() { + if (this.asMenuItems) { + this.buttons.changes.subscribe(() => { + this.strip.cdr.detectChanges(); + }); + } + } + + /** + * Check if the param is a row from a grid + * + * @hidden + * @internal + * @param context + */ + protected isRow(context): context is IgxRowDirective { + return context && context instanceof IgxRowDirective; + } +} diff --git a/projects/igniteui-angular/grids/core/src/grid-actions/grid-editing-actions.component.html b/projects/igniteui-angular/grids/core/src/grid-actions/grid-editing-actions.component.html new file mode 100644 index 00000000000..fd7de1c4a90 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/grid-actions/grid-editing-actions.component.html @@ -0,0 +1,16 @@ + +@if (isRowContext) { + @if (!disabled && editRow) { + + } + @if (addRow && isRootRow) { + + } + @if (addChild && hasChildren) { + + } + @if (!disabled && deleteRow) { + + } +} + diff --git a/projects/igniteui-angular/grids/core/src/grid-actions/grid-editing-actions.component.spec.ts b/projects/igniteui-angular/grids/core/src/grid-actions/grid-editing-actions.component.spec.ts new file mode 100644 index 00000000000..52e08f45b4f --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/grid-actions/grid-editing-actions.component.spec.ts @@ -0,0 +1,582 @@ +import { Component, ViewChild, OnInit } from '@angular/core'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { UIInteractions } from '../../../../test-utils/ui-interactions.spec'; +import { IgxHierarchicalGridActionStripComponent } from '../../../../test-utils/hierarchical-grid-components.spec'; +import { IgxTreeGridEditActionsComponent } from '../../../../test-utils/tree-grid-components.spec'; +import { IgxGridEditingActionsComponent } from './grid-editing-actions.component'; +import { IgxGridPinningActionsComponent } from './grid-pinning-actions.component'; +import { SampleTestData } from '../../../../test-utils/sample-test-data.spec'; +import { IgxActionStripComponent } from 'igniteui-angular/action-strip'; +import { IgxGridComponent } from 'igniteui-angular/grids/grid'; +import { SortingDirection } from 'igniteui-angular/core'; +import { IgxHierarchicalGridComponent } from 'igniteui-angular/grids/hierarchical-grid'; +import { IgxHierarchicalRowComponent } from 'igniteui-angular/grids/hierarchical-grid/src/hierarchical-row.component'; +import { IgxTreeGridComponent } from 'igniteui-angular/grids/tree-grid'; +import { IRowDataCancelableEventArgs } from '../common/events'; +import { IgxColumnComponent } from '../columns/column.component'; +import { IgxGridNavigationService } from 'igniteui-angular'; + +describe('igxGridEditingActions #grid ', () => { + let fixture; + let actionStrip: IgxActionStripComponent; + let grid: IgxGridComponent; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxHierarchicalGridActionStripComponent, + IgxTreeGridEditActionsComponent, + IgxActionStripTestingComponent, + IgxActionStripPinEditComponent, + IgxActionStripEditMenuComponent, + IgxActionStripOneRowComponent, + IgxActionStripMenuOneRowComponent + ], + providers: [ + IgxGridNavigationService + ] + }).compileComponents(); + })); + + describe('Base ', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxActionStripTestingComponent); + fixture.detectChanges(); + actionStrip = fixture.componentInstance.actionStrip; + grid = fixture.componentInstance.grid; + }); + + it('should allow editing and deleting row', () => { + let deleteIcon; + actionStrip.show(grid.rowList.first); + fixture.detectChanges(); + const editIcon = fixture.debugElement.queryAll(By.css(`igx-grid-editing-actions igx-icon`))[0]; + expect(editIcon.nativeElement.innerText).toBe('edit'); + editIcon.parent.triggerEventHandler('click', new Event('click')); + fixture.detectChanges(); + expect(grid.gridAPI.crudService.rowInEditMode).not.toBeNull(); + expect(grid.rowList.first.inEditMode).toBe(true); + + expect(grid.rowList.first.data['ID']).toBe('ALFKI'); + const dataLenght = grid.dataLength; + actionStrip.show(grid.rowList.first); + fixture.detectChanges(); + deleteIcon = fixture.debugElement.queryAll(By.css(`igx-grid-editing-actions igx-icon`))[1]; + // grid actions should not showing when the row is in edit mode # + expect(deleteIcon).toBeUndefined(); + grid.gridAPI.crudService.endEdit(); + actionStrip.show(grid.rowList.first); + fixture.detectChanges(); + deleteIcon = fixture.debugElement.queryAll(By.css(`igx-grid-editing-actions igx-icon`))[1]; + expect(deleteIcon.nativeElement.innerText).toBe('delete'); + deleteIcon.parent.triggerEventHandler('click', new Event('click')); + actionStrip.hide(); + fixture.detectChanges(); + expect(grid.rowList.first.data['ID']).toBe('ANATR'); + expect(dataLenght - 1).toBe(grid.dataLength); + }); + + it('should focus the first cell when editing mode is cell', () => { + fixture.detectChanges(); + grid.selectRange({rowStart: 0, rowEnd: 0, columnStart: 'ContactName', columnEnd: 'ContactName'}); + fixture.detectChanges(); + grid.actionStrip.show(grid.rowList.first); + fixture.detectChanges(); + const editIcon = fixture.debugElement.queryAll(By.css(`igx-grid-editing-actions igx-icon`))[0]; + expect(editIcon.nativeElement.innerText).toBe('edit'); + editIcon.parent.triggerEventHandler('click', new Event('click')); + fixture.detectChanges(); + // first cell of the row should be the active one, excluding ID as primaryKey + expect(grid.selectionService.activeElement.column).toBe(1); + expect(grid.selectionService.activeElement.row).toBe(0); + }); + + it('should allow hiding/showing the edit/delete actions via the related property.', () => { + const editActions = fixture.componentInstance.actionStrip.actionButtons.first; + editActions.editRow = false; + fixture.detectChanges(); + + grid.actionStrip.show(grid.rowList.first); + fixture.detectChanges(); + let icons = fixture.debugElement.queryAll(By.css(`igx-grid-editing-actions igx-icon`)); + let iconsText = icons.map(x => x.nativeElement.innerText); + expect(iconsText).toEqual(['delete']); + + editActions.editRow = true; + editActions.deleteRow = false; + fixture.detectChanges(); + + icons = fixture.debugElement.queryAll(By.css(`igx-grid-editing-actions igx-icon`)); + iconsText = icons.map(x => x.nativeElement.innerText); + expect(iconsText).toEqual(['edit']); + }); + }); + + describe('Menu ', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxActionStripEditMenuComponent); + fixture.detectChanges(); + actionStrip = fixture.componentInstance.actionStrip; + grid = fixture.componentInstance.grid; + }); + it('should allow editing and deleting row via menu', async () => { + const row = grid.rowList.toArray()[0]; + actionStrip.show(row); + fixture.detectChanges(); + + actionStrip.menu.open(); + fixture.detectChanges(); + expect(actionStrip.menu.items.length).toBe(2); + const editMenuItem = actionStrip.menu.items[0]; + + // select edit + actionStrip.menu.selectItem(editMenuItem); + fixture.detectChanges(); + + expect(row.inEditMode).toBeTrue(); + + grid.gridAPI.crudService.endEdit(); + fixture.detectChanges(); + actionStrip.menu.open(); + fixture.detectChanges(); + const deleteMenuItem = actionStrip.menu.items[1]; + + // select delete + actionStrip.menu.selectItem(deleteMenuItem); + fixture.detectChanges(); + + expect(grid.rowList.first.data['ID']).toBe('ANATR'); + }); + it('should not auto-hide on mouse leave of row if action strip is menu', () => { + fixture = TestBed.createComponent(IgxActionStripMenuOneRowComponent); + fixture.detectChanges(); + actionStrip = fixture.componentInstance.actionStrip; + grid = fixture.componentInstance.grid; + + const row = grid.getRowByIndex(0); + row.pin(); + const rowElem = grid.pinnedRows[0]; + row.unpin(); + + actionStrip.show(row); + fixture.detectChanges(); + + actionStrip.menu.open(); + fixture.detectChanges(); + + UIInteractions.simulateMouseEvent('mouseleave', rowElem.element.nativeElement, 0, 200); + fixture.detectChanges(); + + expect(actionStrip.hidden).toBeFalse(); + }); + }); + + describe('integration with pinning actions ', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxActionStripPinEditComponent); + fixture.detectChanges(); + actionStrip = fixture.componentInstance.actionStrip; + grid = fixture.componentInstance.grid; + }); + it('should remove editing actions on disabled rows', () => { + grid.rowList.first.pin(); + fixture.detectChanges(); + actionStrip.show(grid.rowList.toArray()[1]); + fixture.detectChanges(); + const editingIcons = fixture.debugElement.queryAll(By.css(`igx-grid-editing-actions button`)); + const pinningIcons = fixture.debugElement.queryAll(By.css(`igx-grid-pinning-actions button`)); + expect(editingIcons.length).toBe(0); + expect(pinningIcons.length).toBe(1); + expect(pinningIcons[0].nativeElement.className.indexOf('igx-button--disabled') === -1).toBeTruthy(); + }); + + it('should emit correct rowPinning arguments with pinning actions', () => { + spyOn(grid.rowPinning, 'emit').and.callThrough(); + const row = grid.getRowByIndex(1); + + actionStrip.show(grid.rowList.toArray()[1]); + fixture.detectChanges(); + let pinningIcon = fixture.debugElement.queryAll(By.css(`igx-grid-pinning-actions igx-icon`))[0]; + + pinningIcon.parent.triggerEventHandler('click', new Event('click')); + fixture.detectChanges(); + + expect(grid.rowPinning.emit).toHaveBeenCalledTimes(1); + expect(grid.rowPinning.emit).toHaveBeenCalledWith({ + rowID : row.key, + rowKey: row.key, + insertAtIndex: 0, + isPinned: true, + row, + cancel: false + }); + + const row5 = grid.getRowByIndex(4); + actionStrip.show(grid.rowList.toArray()[4]); + fixture.detectChanges(); + pinningIcon = fixture.debugElement.queryAll(By.css(`igx-grid-pinning-actions igx-icon`))[0]; + + pinningIcon.parent.triggerEventHandler('click', new Event('click')); + fixture.detectChanges(); + + expect(grid.rowPinning.emit).toHaveBeenCalledTimes(2); + expect(grid.rowPinning.emit).toHaveBeenCalledWith({ + rowID : row5.key, + rowKey: row5.key, + insertAtIndex: 1, + isPinned: true, + row: row5, + cancel: false + }); + }); + }); + + describe('auto show/hide', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxActionStripPinEditComponent); + fixture.detectChanges(); + actionStrip = fixture.componentInstance.actionStrip; + grid = fixture.componentInstance.grid; + }); + it('should auto-show on mouse enter of row.', () => { + const row = grid.gridAPI.get_row_by_index(0); + const rowElem = row.nativeElement; + UIInteractions.simulateMouseEvent('mouseenter', rowElem, 0, 0); + fixture.detectChanges(); + + expect(actionStrip.context).toBe(row); + expect(actionStrip.hidden).toBeFalse(); + }); + it('should auto-hide on mouse leave of row.', async () => { + fixture = TestBed.createComponent(IgxActionStripOneRowComponent); + fixture.detectChanges(); + actionStrip = fixture.componentInstance.actionStrip; + grid = fixture.componentInstance.grid; + + const row = grid.getRowByIndex(0); + row.pin(); + const rowElem = grid.pinnedRows[0]; + + actionStrip.show(row); + fixture.detectChanges(); + + expect(actionStrip.hidden).toBeFalse(); + UIInteractions.simulateMouseEvent('mouseleave', rowElem.element.nativeElement, 0, 200); + fixture.detectChanges(); + + expect(actionStrip.hidden).toBeTrue(); + }); + it('should auto-hide on mouse leave of grid.', () => { + const row = grid.getRowByIndex(0); + actionStrip.show(row); + fixture.detectChanges(); + + expect(actionStrip.hidden).toBeFalse(); + UIInteractions.simulateMouseEvent('mouseleave', grid.nativeElement, 0, 0); + fixture.detectChanges(); + + expect(actionStrip.hidden).toBeTrue(); + }); + + it('should auto-hide on delete action click.', () => { + const row = grid.rowList.toArray()[0]; + actionStrip.show(row); + fixture.detectChanges(); + + expect(actionStrip.hidden).toBeFalse(); + + const deleteIcon = fixture.debugElement.queryAll(By.css(`igx-grid-editing-actions igx-icon`))[1]; + expect(deleteIcon.nativeElement.innerText).toBe('delete'); + deleteIcon.parent.triggerEventHandler('click', new Event('click')); + fixture.detectChanges(); + + expect(actionStrip.hidden).toBeTrue(); + + }); + + it('should auto-hide if context row is destroyed.', () => { + const row = grid.rowList.toArray()[0]; + actionStrip.show(row); + fixture.detectChanges(); + + expect(actionStrip.hidden).toBeFalse(); + + // bind to no data, which removes all rows. + grid.data = []; + grid.cdr.detectChanges(); + + expect((row.cdr as any).destroyed).toBeTrue(); + expect(actionStrip.hidden).toBeTrue(); + }); + + it('should auto-hide if context row is cached.', () => { + // create group rows + grid.groupBy({ fieldName: 'ContactTitle', dir: SortingDirection.Desc, ignoreCase: false }); + fixture.detectChanges(); + + // show for first data row + const row = grid.dataRowList.toArray()[0]; + actionStrip.show(row); + fixture.detectChanges(); + + // collapse all groups to cache data rows + grid.toggleAllGroupRows(); + fixture.detectChanges(); + + // not destroyed, but not in DOM anymore + expect((row.cdr as any).destroyed).toBeFalse(); + expect(row.element.nativeElement.isConnected).toBe(false); + + // action strip should be hidden + expect(actionStrip.hidden).toBeTrue(); + }); + }); + + describe('auto show/hide in HierarchicalGrid', () => { + let actionStripRoot; let actionStripChild; let hierarchicalGrid: IgxHierarchicalGridComponent; + beforeEach(() => { + fixture = TestBed.createComponent(IgxHierarchicalGridActionStripComponent); + fixture.detectChanges(); + actionStripRoot = fixture.componentInstance.actionStripRoot; + actionStripChild = fixture.componentInstance.actionStripChild; + hierarchicalGrid = fixture.componentInstance.hgrid; + }); + + it('should auto-show root actionStrip on mouse enter of root row.', () => { + const row = hierarchicalGrid.gridAPI.get_row_by_index(0); + const rowElem = row.nativeElement; + UIInteractions.simulateMouseEvent('mouseenter', rowElem, 0, 0); + fixture.detectChanges(); + + expect(actionStripRoot.context).toBe(row); + expect(actionStripRoot.hidden).toBeFalse(); + expect(actionStripChild.context).toBeUndefined(); + }); + + it('should auto-show row island actionStrip on mouse enter of child row.', () => { + const row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + row.toggle(); + fixture.detectChanges(); + + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[1]; + + const childRow = childGrid.gridAPI.get_row_by_index(0); + const rowElem = childRow.nativeElement; + UIInteractions.simulateMouseEvent('mouseenter', rowElem, 0, 0); + fixture.detectChanges(); + + expect(actionStripChild.context).toBe(childRow); + expect(actionStripChild.hidden).toBeFalse(); + + expect(actionStripRoot.context).toBeUndefined(); + }); + + it('should auto-hide all actionStrip on mouse leave of root grid.', () => { + const row = hierarchicalGrid.getRowByIndex(0); + row.expanded = !row.expanded; + fixture.detectChanges(); + + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const childRow = childGrid.gridAPI.get_row_by_index(0); + + actionStripRoot.show(row); + actionStripChild.show(childRow); + fixture.detectChanges(); + + UIInteractions.simulateMouseEvent('mouseleave', hierarchicalGrid.nativeElement, 0, 0); + fixture.detectChanges(); + + expect(actionStripRoot.hidden).toBeTrue(); + expect(actionStripChild.hidden).toBeTrue(); + }); + }); + + describe('TreeGrid - action strip', () => { + let treeGrid: IgxTreeGridComponent; + beforeEach(() => { + fixture = TestBed.createComponent(IgxTreeGridEditActionsComponent); + fixture.detectChanges(); + treeGrid = fixture.componentInstance.treeGrid; + actionStrip = fixture.componentInstance.actionStrip; + }); + + it('should allow deleting row', () => { + spyOn(treeGrid.rowDelete, 'emit').and.callThrough(); + spyOn(treeGrid.rowDeleted, 'emit').and.callThrough(); + const row = treeGrid.rowList.toArray()[0]; + actionStrip.show(row); + fixture.detectChanges(); + + const editActions = fixture.debugElement.queryAll(By.css(`igx-grid-action-button`)); + expect(editActions[3].componentInstance.iconName).toBe('delete'); + const deleteChildBtn = editActions[3].componentInstance; + + const rowDeleteArgs: IRowDataCancelableEventArgs = { + rowID: row.key, + primaryKey: row.key, + rowKey: row.key, + cancel: false, + rowData: treeGrid.getRowData(row.key), + data: treeGrid.getRowData(row.key), + oldValue: null, + owner: treeGrid, + }; + + const rowDeletedArgs = { + data: treeGrid.getRowData(row.key), + rowData: treeGrid.getRowData(row.key), + primaryKey: row.key, + rowKey: row.key, + owner: treeGrid + }; + + // select delete + deleteChildBtn.actionClick.emit(); + fixture.detectChanges(); + + expect(treeGrid.rowDelete.emit).toHaveBeenCalledOnceWith(rowDeleteArgs); + expect(treeGrid.rowDeleted.emit).toHaveBeenCalledOnceWith(rowDeletedArgs); + expect(treeGrid.rowList.first.data['ID']).toBe(6); + }); + }); +}); + +@Component({ + template: ` + + @for (c of columns; track c.field) { + + + } + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxActionStripComponent, IgxGridEditingActionsComponent] +}) +class IgxActionStripTestingComponent implements OnInit { + @ViewChild('actionStrip', { read: IgxActionStripComponent, static: true }) + public actionStrip: IgxActionStripComponent; + + @ViewChild('grid', { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + + public data: any[]; + public dataOneRow: any[]; + public columns: any[]; + + public ngOnInit() { + + this.columns = [ + { field: 'ID', width: '200px', hidden: false }, + { field: 'CompanyName', width: '200px' }, + { field: 'ContactName', width: '200px', pinned: false }, + { field: 'ContactTitle', width: '300px', pinned: false }, + { field: 'Address', width: '250px' }, + { field: 'City', width: '200px' }, + { field: 'Region', width: '300px' }, + { field: 'PostalCode', width: '150px' }, + { field: 'Phone', width: '200px' }, + { field: 'Fax', width: '200px' } + ]; + + this.data = SampleTestData.contactInfoDataFull(); + + this.dataOneRow = [ + { ID: 'ALFKI', CompanyName: 'Alfreds Futterkiste', ContactName: 'Maria Anders', ContactTitle: 'Sales Representative', Address: 'Obere Str. 57', City: 'Berlin', Region: null, PostalCode: '12209', Country: 'Germany', Phone: '030-0074321', Fax: '030-0076545' }, + ]; + } +} + +@Component({ + template: ` + + @for (c of columns; track c.field) { + + + } + + + + + + + `, + selector: 'igx-action-strip-pin-edit-component', + imports: [IgxGridComponent, IgxColumnComponent, IgxActionStripComponent, IgxGridPinningActionsComponent, IgxGridEditingActionsComponent] +}) +class IgxActionStripPinEditComponent extends IgxActionStripTestingComponent { +} + +@Component({ + template: ` + + @for (c of columns; track c.field) { + + + } + + + + + + `, + selector: 'igx-action-strip-edit-menu-component', + imports: [IgxGridComponent, IgxColumnComponent, IgxActionStripComponent, IgxGridEditingActionsComponent] +}) +class IgxActionStripEditMenuComponent extends IgxActionStripTestingComponent { +} + +@Component({ + template: ` + + @for (c of columns; track c.field) { + + + } + + + + + + + `, + selector: 'igx-action-strip-one-row-component', + imports: [IgxGridComponent, IgxColumnComponent, IgxActionStripComponent, IgxGridEditingActionsComponent, IgxGridPinningActionsComponent] +}) +class IgxActionStripOneRowComponent extends IgxActionStripTestingComponent { +} + +@Component({ + template: ` + + @for (c of columns; track c.field) { + + + } + + + + + + `, + selector: 'igx-action-strip-menu-one-row-component', + imports: [IgxGridComponent, IgxColumnComponent, IgxActionStripComponent, IgxGridEditingActionsComponent] +}) +class IgxActionStripMenuOneRowComponent extends IgxActionStripTestingComponent { +} diff --git a/projects/igniteui-angular/grids/core/src/grid-actions/grid-editing-actions.component.ts b/projects/igniteui-angular/grids/core/src/grid-actions/grid-editing-actions.component.ts new file mode 100644 index 00000000000..bccbbcb60c2 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/grid-actions/grid-editing-actions.component.ts @@ -0,0 +1,179 @@ +import { Component, HostBinding, Input, booleanAttribute } from '@angular/core'; +import { IgxGridActionsBaseDirective } from './grid-actions-base.directive'; +import { addRow, addChild } from '@igniteui/material-icons-extended'; +import { IgxGridActionButtonComponent } from './grid-action-button.component'; +import { IgxActionStripActionsToken, showMessage } from 'igniteui-angular/core'; + + +/* blazorElement */ +/* wcElementTag: igc-grid-editing-actions */ +/* blazorIndirectRender */ +/* singleInstanceIdentifier */ +/** + * Grid Editing Actions for the Action Strip + * + * @igxParent IgxActionStripComponent + */ +@Component({ + selector: 'igx-grid-editing-actions', + templateUrl: 'grid-editing-actions.component.html', + providers: [{ provide: IgxActionStripActionsToken, useExisting: IgxGridEditingActionsComponent }], + imports: [IgxGridActionButtonComponent] +}) +export class IgxGridEditingActionsComponent extends IgxGridActionsBaseDirective { + + /** + * Host `class.igx-action-strip` binding. + * + * @hidden + * @internal + */ + @HostBinding('class.igx-action-strip__editing-actions') + public cssClass = 'igx-action-strip__editing-actions'; + + /** + * An input to enable/disable action strip row adding button + */ + @Input({ transform: booleanAttribute }) + public set addRow(value: boolean) { + this._addRow = value; + } + public get addRow(): boolean { + if (!this.iconsRendered) { + this.registerIcons(); + this.iconsRendered = true; + } + return this._addRow; + } + + /** + * An input to enable/disable action strip row editing button + */ + @Input({ transform: booleanAttribute }) + public editRow = true; + + /** + * An input to enable/disable action strip row deleting button + */ + @Input({ transform: booleanAttribute }) + public deleteRow = true; + + /** + * Getter if the row is disabled + * + * @hidden + * @internal + */ + public get disabled(): boolean { + if (!this.isRow(this.strip.context)) { + return; + } + return this.strip.context.disabled; + } + + /** + * Getter if the row is root. + * + * @hidden + * @internal + */ + public get isRootRow(): boolean { + if (!this.isRow(this.strip.context)) { + return false; + } + return this.strip.context.isRoot; + } + + public get hasChildren(): boolean { + if (!this.isRow(this.strip.context)) { + return false; + } + return this.strip.context.hasChildren; + } + + /** + * An input to enable/disable action strip child row adding button + */ + @Input({ transform: booleanAttribute }) + public addChild = false; + + private isMessageShown = false; + private _addRow = false; + private iconsRendered = false; + + /** + * Enter row or cell edit mode depending the grid rowEditable option + * + * @example + * ```typescript + * this.gridEditingActions.startEdit(); + * ``` + */ + public startEdit(event?): void { + if (event) { + event.stopPropagation(); + } + if (!this.isRow(this.strip.context)) { + return; + } + const row = this.strip.context; + const firstEditable = row.cells.filter(cell => cell.editable)[0]; + const grid = row.grid; + if (!grid.hasEditableColumns) { + this.isMessageShown = showMessage( + 'The grid should be editable in order to use IgxGridEditingActionsComponent', + this.isMessageShown); + return; + } + // be sure row is in view + if (grid.rowList.filter(r => r === row).length !== 0) { + grid.gridAPI.crudService.enterEditMode(firstEditable, event); + if (!grid.gridAPI.crudService.nonEditable) { + firstEditable.activate(event); + } + } + this.strip.hide(); + } + + /** @hidden @internal **/ + public deleteRowHandler(event?): void { + if (event) { + event.stopPropagation(); + } + if (!this.isRow(this.strip.context)) { + return; + } + const context = this.strip.context; + const grid = context.grid; + grid.deleteRow(context.key); + + this.strip.hide(); + } + + /** @hidden @internal **/ + public addRowHandler(event?, asChild?: boolean): void { + if (event) { + event.stopPropagation(); + } + if (!this.isRow(this.strip.context)) { + return; + } + const context = this.strip.context; + const grid = context.grid; + if (!grid.rowEditable) { + console.warn('The grid must use row edit mode to perform row adding! Please set rowEditable to true.'); + return; + } + grid.gridAPI.crudService.enterAddRowMode(context, asChild, event); + this.strip.hide(); + } + + /** + * @hidden + * @internal + */ + private registerIcons() { + this.iconService.addSvgIconFromText(addRow.name, addRow.value, 'imx-icons', true,); + this.iconService.addSvgIconFromText(addChild.name, addChild.value, 'imx-icons', true); + } +} diff --git a/projects/igniteui-angular/grids/core/src/grid-actions/grid-pinning-actions.component.html b/projects/igniteui-angular/grids/core/src/grid-actions/grid-pinning-actions.component.html new file mode 100644 index 00000000000..bdfde5466cb --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/grid-actions/grid-pinning-actions.component.html @@ -0,0 +1,14 @@ +@if (isRowContext) { + @if (inPinnedArea && pinnedTop) { + + } + @if (inPinnedArea && !pinnedTop) { + + } + @if (!pinned) { + + } + @if (pinned) { + + } +} diff --git a/projects/igniteui-angular/grids/core/src/grid-actions/grid-pinning-actions.component.spec.ts b/projects/igniteui-angular/grids/core/src/grid-actions/grid-pinning-actions.component.spec.ts new file mode 100644 index 00000000000..f5e53ee37a2 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/grid-actions/grid-pinning-actions.component.spec.ts @@ -0,0 +1,164 @@ +import { Component, ViewChild, OnInit } from '@angular/core'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { By } from '@angular/platform-browser'; +import { wait } from '../../../../test-utils/ui-interactions.spec'; +import { IgxGridPinningActionsComponent } from './grid-pinning-actions.component'; +import { IgxColumnComponent } from '../public_api'; +import { SampleTestData } from '../../../../test-utils/sample-test-data.spec'; +import { IgxActionStripComponent } from 'igniteui-angular/action-strip'; +import { IgxGridComponent } from 'igniteui-angular/grids/grid'; + + +describe('igxGridPinningActions #grid ', () => { + let fixture; + let actionStrip: IgxActionStripComponent; + let grid: IgxGridComponent; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxActionStripTestingComponent, + IgxActionStripPinMenuComponent + ] + }).compileComponents(); + })); + + describe('Base ', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxActionStripTestingComponent); + fixture.detectChanges(); + actionStrip = fixture.componentInstance.actionStrip; + grid = fixture.componentInstance.grid; + }); + + it('should allow pinning and unpinning rows in a grid', () => { + actionStrip.show(grid.rowList.first); + fixture.detectChanges(); + let pinningButtons = fixture.debugElement.queryAll(By.css(`igx-grid-pinning-actions button`)); + expect(pinningButtons.length).toBe(1); + expect(pinningButtons[0].componentInstance.iconName).toBe('pin'); + pinningButtons[0].triggerEventHandler('click', new Event('click')); + actionStrip.hide(); + fixture.detectChanges(); + expect(grid.pinnedRows.length).toBe(1); + + actionStrip.show(grid.pinnedRows[0]); + fixture.detectChanges(); + pinningButtons = fixture.debugElement.queryAll(By.css(`igx-grid-pinning-actions button`)); + expect(pinningButtons.length).toBe(2); + expect(pinningButtons[1].componentInstance.iconName).toBe('unpin'); + pinningButtons[1].triggerEventHandler('click', new Event('click')); + actionStrip.hide(); + fixture.detectChanges(); + expect(grid.pinnedRows.length).toBe(0); + }); + + it('should allow navigating to disabled row in unpinned area', async () => { + grid.pinRow('FAMIA'); + fixture.detectChanges(); + + actionStrip.show(grid.pinnedRows[0]); + fixture.detectChanges(); + const pinningButtons = fixture.debugElement.queryAll(By.css(`igx-grid-pinning-actions button`)); + const jumpButton = pinningButtons[0]; + jumpButton.triggerEventHandler('click', new Event('click')); + await wait(); + fixture.detectChanges(); + await wait(); + fixture.detectChanges(); + + const secondToLastVisible = grid.rowList.toArray()[grid.rowList.length - 2]; + expect(secondToLastVisible.key).toEqual('FAMIA'); + }); + }); + + describe('Menu ', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxActionStripPinMenuComponent); + fixture.detectChanges(); + actionStrip = fixture.componentInstance.actionStrip; + grid = fixture.componentInstance.grid; + }); + it('should allow pinning row via menu', async () => { + const row = grid.rowList.toArray()[0]; + actionStrip.show(row); + fixture.detectChanges(); + + actionStrip.menu.open(); + fixture.detectChanges(); + expect(actionStrip.menu.items.length).toBe(1); + const pinMenuItem = actionStrip.menu.items[0]; + // select pin + actionStrip.menu.selectItem(pinMenuItem); + fixture.detectChanges(); + expect(grid.pinnedRows.length).toBe(1); + }); + }); +}); + +@Component({ + template: ` + + @for (c of columns; track c.field) { + + + } + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxActionStripComponent, IgxGridPinningActionsComponent] +}) +class IgxActionStripTestingComponent implements OnInit { + @ViewChild('actionStrip', { read: IgxActionStripComponent, static: true }) + public actionStrip: IgxActionStripComponent; + + @ViewChild('grid', { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + + private data: any[]; + private columns: any[]; + + public ngOnInit() { + + this.columns = [ + { field: 'ID', width: '200px', hidden: false }, + { field: 'CompanyName', width: '200px' }, + { field: 'ContactName', width: '200px', pinned: false }, + { field: 'ContactTitle', width: '300px', pinned: false }, + { field: 'Address', width: '250px' }, + { field: 'City', width: '200px' }, + { field: 'Region', width: '300px' }, + { field: 'PostalCode', width: '150px' }, + { field: 'Phone', width: '200px' }, + { field: 'Fax', width: '200px' } + ]; + + this.data = SampleTestData.contactInfoDataFull(); + } +} + +@Component({ + template: ` + + @for (c of columns; track c.field) { + + + } + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxActionStripComponent, IgxGridPinningActionsComponent] +}) +class IgxActionStripPinMenuComponent extends IgxActionStripTestingComponent { +} diff --git a/projects/igniteui-angular/grids/core/src/grid-actions/grid-pinning-actions.component.ts b/projects/igniteui-angular/grids/core/src/grid-actions/grid-pinning-actions.component.ts new file mode 100644 index 00000000000..5ea7dfe006b --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/grid-actions/grid-pinning-actions.component.ts @@ -0,0 +1,145 @@ +import { Component, HostBinding } from '@angular/core'; +import { IgxGridActionsBaseDirective } from './grid-actions-base.directive'; +import { pinLeft, unpinLeft, jumpDown, jumpUp } from '@igniteui/material-icons-extended'; +import { IgxGridActionButtonComponent } from './grid-action-button.component'; +import { IgxActionStripActionsToken } from 'igniteui-angular/core'; + +/* blazorElement */ +/* wcElementTag: igc-grid-pinning-actions */ +/* blazorIndirectRender */ +/* singleInstanceIdentifier */ +/** + * Grid Pinning Actions for the Action Strip + * + * @igxParent IgxActionStripComponent + */ +@Component({ + selector: 'igx-grid-pinning-actions', + templateUrl: 'grid-pinning-actions.component.html', + providers: [{ provide: IgxActionStripActionsToken, useExisting: IgxGridPinningActionsComponent }], + imports: [IgxGridActionButtonComponent] +}) + +export class IgxGridPinningActionsComponent extends IgxGridActionsBaseDirective { + /** + * Host `class.igx-action-strip` binding. + * + * @hidden + * @internal + */ + @HostBinding('class.igx-action-strip__pinning-actions') + public cssClass = 'igx-action-strip__pinning-actions'; + + private iconsRendered = false; + + /** + * Getter to know if the row is pinned + * + * @hidden + * @internal + */ + public get pinned(): boolean { + if (!this.isRow(this.strip.context)) { + return; + } + const context = this.strip.context; + if (context && !this.iconsRendered) { + this.registerSVGIcons(); + this.iconsRendered = true; + } + return context && context.pinned; + } + + /** + * Getter to know if the row is in pinned and ghost + * + * @hidden + * @internal + */ + public get inPinnedArea(): boolean { + if (!this.isRow(this.strip.context)) { + return; + } + const context = this.strip.context; + return this.pinned && !context.disabled; + } + + /** + * Getter to know if the row pinning is set to top or bottom + * + * @hidden + * @internal + */ + public get pinnedTop(): boolean { + if (!this.isRow(this.strip.context)) { + return; + } + return this.strip.context.grid.isRowPinningToTop; + } + + /** + * Pin the row according to the context. + * + * @example + * ```typescript + * this.gridPinningActions.pin(); + * ``` + */ + public pin(event?): void { + if (event) { + event.stopPropagation(); + } + if (!this.isRow(this.strip.context)) { + return; + } + const row = this.strip.context; + const grid = row.grid; + grid.pinRow(row.key, grid.pinnedRecords.length); + this.strip.hide(); + } + + /** + * Unpin the row according to the context. + * + * @example + * ```typescript + * this.gridPinningActions.unpin(); + * ``` + */ + public unpin(event?): void { + if (event) { + event.stopPropagation(); + } + if (!this.isRow(this.strip.context)) { + return; + } + const row = this.strip.context; + const grid = row.grid; + grid.unpinRow(row.key); + this.strip.hide(); + } + + public scrollToRow(event) { + if (event) { + event.stopPropagation(); + } + const context = this.strip.context; + const grid = context.grid; + grid.scrollTo(context.data, 0); + this.strip.hide(); + } + + private registerSVGIcons(): void { + if (!this.isRow(this.strip.context)) { + return; + } + const context = this.strip.context; + const grid = context.grid; + if (grid) { + this.iconService.addSvgIconFromText(pinLeft.name, pinLeft.value, 'imx-icons', true); + this.iconService.addSvgIconFromText(unpinLeft.name, unpinLeft.value, 'imx-icons', true); + this.iconService.addSvgIconFromText(jumpDown.name, jumpDown.value, 'imx-icons', true); + this.iconService.addSvgIconFromText(jumpUp.name, jumpUp.value, 'imx-icons', true); + } + } +} diff --git a/projects/igniteui-angular/grids/core/src/grid-actions/public_api.ts b/projects/igniteui-angular/grids/core/src/grid-actions/public_api.ts new file mode 100644 index 00000000000..1d452e148ae --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/grid-actions/public_api.ts @@ -0,0 +1,4 @@ +export * from './grid-actions-base.directive'; +export * from './grid-editing-actions.component'; +export * from './grid-pinning-actions.component'; +export * from './grid-action-button.component'; diff --git a/projects/igniteui-angular/grids/core/src/grid-footer/grid-footer.component.ts b/projects/igniteui-angular/grids/core/src/grid-footer/grid-footer.component.ts new file mode 100644 index 00000000000..4e49030561c --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/grid-footer/grid-footer.component.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'igx-grid-footer', + template: '', + standalone: true +}) +export class IgxGridFooterComponent { +} diff --git a/projects/igniteui-angular/grids/core/src/grid-mrl-navigation.service.ts b/projects/igniteui-angular/grids/core/src/grid-mrl-navigation.service.ts new file mode 100644 index 00000000000..681f5ee4001 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/grid-mrl-navigation.service.ts @@ -0,0 +1,362 @@ +import { Injectable } from '@angular/core'; +import { first } from 'rxjs/operators'; +import { IgxGridNavigationService } from './grid-navigation.service'; +import { HORIZONTAL_NAV_KEYS, HEADER_KEYS, ColumnType } from 'igniteui-angular/core'; +import { GridKeydownTargetType } from './common/enums'; + +/** @hidden */ +@Injectable() +export class IgxGridMRLNavigationService extends IgxGridNavigationService { + + public override isValidPosition(rowIndex: number, colIndex: number): boolean { + if (rowIndex < 0 || colIndex < 0 || this.grid.dataView.length - 1 < rowIndex || + Math.max(...this.grid.visibleColumns.map(col => col.visibleIndex)) < colIndex || + (this.activeNode.column !== colIndex && !this.isDataRow(rowIndex, true))) { + return false; + } + return true; + } + + public override shouldPerformVerticalScroll(targetRowIndex: number, visibleColIndex: number): boolean { + if (!super.shouldPerformVerticalScroll(targetRowIndex, visibleColIndex)) { + return false; + } + if (!this.isDataRow(targetRowIndex) || visibleColIndex < 0) { + return super.shouldPerformVerticalScroll(targetRowIndex, visibleColIndex); + } + + const targetRow = super.getRowElementByIndex(targetRowIndex); + const containerHeight = this.grid.calcHeight ? Math.ceil(this.grid.calcHeight) : 0; + const scrollPos = this.getVerticalScrollPositions(targetRowIndex, visibleColIndex); + return (!targetRow || targetRow.offsetTop + scrollPos.topOffset < Math.abs(this.containerTopOffset) + || containerHeight && containerHeight < scrollPos.rowBottom - Math.ceil(this.scrollTop)); + } + + public override isColumnFullyVisible(visibleColIndex: number): boolean { + const targetCol = this.grid.getColumnByVisibleIndex(visibleColIndex); + if (this.isParentColumnFullyVisible(targetCol?.parent) || super.isColumnPinned(visibleColIndex, this.forOfDir())) { + return true; + } + + const scrollPos = this.getChildColumnScrollPositions(visibleColIndex); + const colWidth = scrollPos.rightScroll - scrollPos.leftScroll; + if (this.displayContainerWidth < colWidth && this.displayContainerScrollLeft === scrollPos.leftScroll) { + return true; + } + return this.displayContainerWidth >= scrollPos.rightScroll - this.displayContainerScrollLeft && + this.displayContainerScrollLeft <= scrollPos.leftScroll; + } + + public getVerticalScrollPositions(rowIndex: number, visibleIndex: number) { + const targetCol = this.grid.getColumnByVisibleIndex(visibleIndex); + const rowSpan = targetCol.rowEnd && targetCol.rowEnd - targetCol.rowStart ? targetCol.rowEnd - targetCol.rowStart : 1; + const topOffset = this.grid.defaultRowHeight * (targetCol.rowStart - 1); + const rowTop = this.grid.verticalScrollContainer.sizesCache[rowIndex] + topOffset; + return { topOffset, rowTop, rowBottom: rowTop + (this.grid.defaultRowHeight * rowSpan) }; + } + + public override performHorizontalScrollToCell(visibleColumnIndex: number, cb?: () => void) { + if (!this.shouldPerformHorizontalScroll(visibleColumnIndex)) { + return; + } + const scrollPos = this.getChildColumnScrollPositions(visibleColumnIndex); + const startScroll = scrollPos.rightScroll - this.displayContainerScrollLeft; + const nextScroll = !(this.displayContainerScrollLeft <= scrollPos.leftScroll) && this.displayContainerWidth >= startScroll ? + scrollPos.leftScroll : scrollPos.rightScroll - this.displayContainerWidth; + this.forOfDir().getScroll().scrollLeft = nextScroll; + this.grid.parentVirtDir.chunkLoad + .pipe(first()) + .subscribe(() => { + if (cb) { + cb(); + } + }); + } + + public override performVerticalScrollToCell(rowIndex: number, visibleColIndex: number, cb?: () => void) { + const children = this.parentByChildIndex(visibleColIndex || 0)?.children; + if (!super.isDataRow(rowIndex) || (children && children.length < 2) || visibleColIndex < 0) { + return super.performVerticalScrollToCell(rowIndex, visibleColIndex, cb); + } + + const containerHeight = this.grid.calcHeight ? Math.ceil(this.grid.calcHeight) : 0; + const pos = this.getVerticalScrollPositions(rowIndex, visibleColIndex); + const row = super.getRowElementByIndex(rowIndex); + if ((this.scrollTop > pos.rowTop) && (!row || row.offsetTop + pos.topOffset < 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(); + } + }); + } + + public getNextHorizontalCellPosition(previous = false) { + const parent = this.parentByChildIndex(this.activeNode.column); + if (!this.hasNextHorizontalPosition(previous, parent)) { + return { row: this.activeNode.row, column: this.activeNode.column }; + } + const columns = previous ? parent.children.filter(c => c.rowStart <= this.activeNode.layout.rowStart) + .sort((a, b) => b.visibleIndex - a.visibleIndex) : parent.children.filter(c => c.rowStart <= this.activeNode.layout.rowStart); + let column = columns.find((col) => previous ? + col.visibleIndex < this.activeNode.column && this.rowEnd(col) > this.activeNode.layout.rowStart : + col.visibleIndex > this.activeNode.column && col.colStart > this.activeNode.layout.colStart); + if (!column || (previous && this.activeNode.layout.colStart === 1)) { + const index = previous ? parent.visibleIndex - 1 : parent.visibleIndex + 1; + const children = this.grid.columns.find(cols => cols.columnLayout && cols.visibleIndex === index).children; + column = previous ? children.toArray().reverse().find(child => child.rowStart <= this.activeNode.layout.rowStart) : + children.find(child => this.rowEnd(child) > this.activeNode.layout.rowStart && child.colStart === 1); + } + return { row: this.activeNode.row, column: column.visibleIndex }; + } + + public getNextVerticalPosition(previous = false) { + this.activeNode.column = this.activeNode.column || 0; + if (!this.hasNextVerticalPosition(previous)) { + return { row: this.activeNode.row, column: this.activeNode.column }; + } + const currentRowStart = this.grid.getColumnByVisibleIndex(this.activeNode.column).rowStart; + const nextBlock = !this.isDataRow(this.activeNode.row) || + (previous ? currentRowStart === 1 : currentRowStart === this.lastRowStartPerBlock()); + const nextRI = previous ? this.activeNode.row - 1 : this.activeNode.row + 1; + if (nextBlock && !this.isDataRow(nextRI)) { + return {row: nextRI, column: this.activeNode.column}; + } + const children = this.parentByChildIndex(this.activeNode.column).children; + const col = previous ? this.getPreviousRowIndex(children, nextBlock) : this.getNextRowIndex(children, nextBlock); + return { row: nextBlock ? nextRI : this.activeNode.row, column: col.visibleIndex }; + } + + public override headerNavigation(event: KeyboardEvent) { + const key = event.key.toLowerCase(); + if (!HEADER_KEYS.has(key)) { + return; + } + event.preventDefault(); + if (!this.activeNode.layout) { + this.activeNode.layout = this.layout(this.activeNode.column || 0); + } + const alt = event.altKey; + const ctrl = event.ctrlKey; + this.performHeaderKeyCombination(this.grid.getColumnByVisibleIndex(this.activeNode.column), key, event.shiftKey, ctrl, alt, event); + if (!ctrl && !alt && (key.includes('down') || key.includes('up'))) { + const children = this.parentByChildIndex(this.activeNode.column).children; + const col = key.includes('down') ? this.getNextRowIndex(children, false) : this.getPreviousRowIndex(children, false); + if (!col) { + return; + } + this.activeNode.column = col.visibleIndex; + const layout = this.layout(this.activeNode.column); + const nextLayout = {...this.activeNode.layout, rowStart: layout.rowStart, rowEnd: layout.rowEnd}; + this.setActiveNode({row: this.activeNode.row, layout: nextLayout}); + return; + } + this.horizontalNav(event, key, -1, 'headerCell'); + } + + /** + * @hidden + * @internal + */ + public layout(visibleIndex) { + const column = this.grid.getColumnByVisibleIndex(visibleIndex); + return {colStart: column.colStart, rowStart: column.rowStart, + colEnd: column.colEnd, rowEnd: column.rowEnd, columnVisibleIndex: column.visibleIndex }; + } + + protected override getNextPosition(rowIndex: number, colIndex: number, key: string, shift: boolean, ctrl: boolean, event: KeyboardEvent) { + if (!this.activeNode.layout) { + this.activeNode.layout = this.layout(this.activeNode.column || 0); + } + switch (key) { + case 'tab': + case ' ': + case 'spacebar': + case 'space': + case 'escape': + case 'esc': + case 'enter': + case 'f2': + super.getNextPosition(rowIndex, colIndex, key, shift, ctrl, event); + break; + case 'end': + rowIndex = ctrl ? this.findLastDataRowIndex() : this.activeNode.row; + colIndex = ctrl ? this.lastColIndexPerMRLBlock(this.lastIndexPerRow) : this.lastIndexPerRow; + break; + case 'home': + rowIndex = ctrl ? this.findFirstDataRowIndex() : this.activeNode.row; + colIndex = ctrl ? 0 : this.firstIndexPerRow; + break; + case 'arrowleft': + case 'left': + colIndex = ctrl ? this.firstIndexPerRow : this.getNextHorizontalCellPosition(true).column; + break; + case 'arrowright': + case 'right': + colIndex = ctrl ? this.lastIndexPerRow : this.getNextHorizontalCellPosition().column; + break; + case 'arrowup': + case 'up': + const prevPos = this.getNextVerticalPosition(true); + colIndex = ctrl ? this.activeNode.column : prevPos.column; + rowIndex = ctrl ? this.findFirstDataRowIndex() : prevPos.row; + break; + case 'arrowdown': + case 'down': + const nextPos = this.getNextVerticalPosition(); + colIndex = ctrl ? this.activeNode.column : nextPos.column; + rowIndex = ctrl ? this.findLastDataRowIndex() : nextPos.row; + break; + default: + return; + } + const nextLayout = this.layout(colIndex); + const newLayout = key.includes('up') || key.includes('down') ? {rowStart: nextLayout.rowStart} : {colStart: nextLayout.colStart}; + Object.assign(this.activeNode.layout, newLayout, {rowEnd: nextLayout.rowEnd}); + + if (ctrl && (key === 'home' || key === 'end')) { + this.activeNode.layout = nextLayout; + } + return { rowIndex, colIndex }; + } + + protected override horizontalNav(event: KeyboardEvent, key: string, rowIndex: number, tag: GridKeydownTargetType) { + const ctrl = event.ctrlKey; + if (!HORIZONTAL_NAV_KEYS.has(key) || event.altKey) { + return; + } + this.activeNode.row = rowIndex; + + const newActiveNode = { + column: this.activeNode.column, + mchCache: { + level: this.activeNode.level, + visibleIndex: this.activeNode.column + } + }; + + if ((key.includes('left') || key === 'home') && this.activeNode.column > 0) { + newActiveNode.column = ctrl || key === 'home' ? this.firstIndexPerRow : this.getNextHorizontalCellPosition(true).column; + } + if ((key.includes('right') || key === 'end') && this.activeNode.column !== this.lastIndexPerRow) { + newActiveNode.column = ctrl || key === 'end' ? this.lastIndexPerRow : this.getNextHorizontalCellPosition().column; + } + + if (tag === 'headerCell') { + const column = this.grid.getColumnByVisibleIndex(newActiveNode.column); + newActiveNode.mchCache.level = column.level; + newActiveNode.mchCache.visibleIndex = column.visibleIndex; + } + + const layout = this.layout(newActiveNode.column); + const newLayout = {...this.activeNode.layout, colStart: layout.colStart, rowEnd: layout.rowEnd}; + this.setActiveNode({row: this.activeNode.row, column: newActiveNode.column, + layout: newLayout, mchCache: newActiveNode.mchCache}); + this.performHorizontalScrollToCell(newActiveNode.column); + } + + private isParentColumnFullyVisible(parent: ColumnType): boolean { + if (!this.forOfDir().getScroll().clientWidth || parent?.pinned) { + return true; + } + + const index = this.forOfDir().igxForOf.indexOf(parent); + return this.displayContainerWidth >= this.forOfDir().getColumnScrollLeft(index + 1) - this.displayContainerScrollLeft && + this.displayContainerScrollLeft <= this.forOfDir().getColumnScrollLeft(index); + } + + private getChildColumnScrollPositions(visibleColIndex: number) { + const targetCol = this.grid.getColumnByVisibleIndex(visibleColIndex); + const parentVIndex = this.forOfDir().igxForOf.indexOf(targetCol.parent); + let leftScroll = this.forOfDir().getColumnScrollLeft(parentVIndex); + let rightScroll = this.forOfDir().getColumnScrollLeft(parentVIndex + 1); + targetCol.parent.children.forEach((c) => { + if (c.rowStart >= targetCol.rowStart && c.visibleIndex < targetCol.visibleIndex) { + leftScroll += parseInt(c.width, 10); + } + if (c.rowStart <= targetCol.rowStart && c.visibleIndex > targetCol.visibleIndex) { + rightScroll -= parseInt(c.width, 10); + } + }); + return { leftScroll, rightScroll }; + } + + private getNextRowIndex(children, next) { + const rowStart = next ? 1 : this.rowEnd(this.grid.getColumnByVisibleIndex(this.activeNode.column)); + const col = children.filter(c => c.rowStart === rowStart); + return col.find(co => co.colStart === this.activeNode.layout.colStart) || + col.sort((a, b) => b.visibleIndex - a.visibleIndex).find(co => co.colStart <= this.activeNode.layout.colStart); +} + + private getPreviousRowIndex(children, prev) { + const end = prev ? Math.max(...children.map(c => this.rowEnd(c))) : + this.grid.getColumnByVisibleIndex(this.activeNode.column).rowStart; + const col = children.filter(c => this.rowEnd(c) === end); + return col.find(co => co.colStart === this.activeNode.layout.colStart) || + col.sort((a, b) => b.visibleIndex - a.visibleIndex).find(co => co.colStart <= this.activeNode.layout.colStart); + } + + private get lastIndexPerRow(): number { + const children = this.grid.visibleColumns.find(c => c.visibleIndex === this.lastLayoutIndex && c.columnLayout) + .children.toArray().reverse(); + const column = children.find(co => co.rowStart === this.activeNode.layout.rowStart) || + children.find(co => co.rowStart <= this.activeNode.layout.rowStart); + return column.visibleIndex; + } + + private get firstIndexPerRow(): number { + const children = this.grid.visibleColumns.find(c => c.visibleIndex === 0 && c.columnLayout).children; + const column = children.find(co => co.rowStart === this.activeNode.layout.rowStart) || + children.find(co => co.rowStart <= this.activeNode.layout.rowStart); + return column.visibleIndex; + } + + private get lastLayoutIndex(): number { + return Math.max(...this.grid.visibleColumns.filter(c => c.columnLayout).map(col => col.visibleIndex)); + } + + private get scrollTop(): number { + return Math.abs(this.grid.verticalScrollContainer.getScroll().scrollTop); + } + + private lastColIndexPerMRLBlock(visibleIndex = this.activeNode.column): number { + return this.parentByChildIndex(visibleIndex).children.last.visibleIndex; + } + + private lastRowStartPerBlock(visibleIndex = this.activeNode.column) { + return Math.max(...this.parentByChildIndex(visibleIndex).children.map(c => c.rowStart)); + } + + private rowEnd(column): number { + return column.rowEnd && column.rowEnd - column.rowStart ? column.rowStart + column.rowEnd - column.rowStart : column.rowStart + 1; + } + + private parentByChildIndex(visibleIndex) { + return this.grid.getColumnByVisibleIndex(visibleIndex)?.parent; + + } + + private hasNextHorizontalPosition(previous = false, parent) { + if (previous && parent.visibleIndex === 0 && this.activeNode.layout.colStart === 1 || + !previous && parent.visibleIndex === this.lastLayoutIndex && this.activeNode.column === this.lastIndexPerRow) { + return false; + } + return true; + } + + private hasNextVerticalPosition(prev = false) { + if ((prev && this.activeNode.row === 0 && (!this.isDataRow(this.activeNode.row) || this.activeNode.layout.rowStart === 1)) || + (!prev && this.activeNode.row >= this.grid.dataView.length - 1 && this.activeNode.column === this.lastColIndexPerMRLBlock())) { + return false; + } + return true; + } +} diff --git a/projects/igniteui-angular/grids/core/src/grid-navigation.service.ts b/projects/igniteui-angular/grids/core/src/grid-navigation.service.ts new file mode 100644 index 00000000000..bc2ea569236 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/grid-navigation.service.ts @@ -0,0 +1,835 @@ +import { inject, Injectable } from '@angular/core'; +import { first, throttleTime } from 'rxjs/operators'; +import { IgxForOfDirective } from 'igniteui-angular/directives'; +import { GridType } from './common/grid.interface'; +import { + NAVIGATION_KEYS, + ROW_COLLAPSE_KEYS, + ROW_EXPAND_KEYS, + SUPPORTED_KEYS, + HORIZONTAL_NAV_KEYS, + HEADER_KEYS, + ROW_ADD_KEYS, + PlatformUtil, + SortingDirection +} from 'igniteui-angular/core'; +import { GridKeydownTargetType, GridSelectionMode, FilterMode } from './common/enums'; +import { IActiveNodeChangeEventArgs } from './common/events'; +import { IMultiRowLayoutNode } from './common/types'; +import { animationFrameScheduler, Subject } from 'rxjs'; + +export interface ColumnGroupsCache { + level: number; + visibleIndex: number; +} +export interface IActiveNode { + gridID?: string; + row: number; + column?: number; + level?: number; + mchCache?: ColumnGroupsCache; + layout?: IMultiRowLayoutNode; +} + +/** @hidden */ +@Injectable() +export class IgxGridNavigationService { + protected platform = inject(PlatformUtil); + public grid: GridType; + public _activeNode: IActiveNode = {} as IActiveNode; + public lastActiveNode: IActiveNode = {} as IActiveNode; + protected pendingNavigation = false; + protected keydownNotify = new Subject(); + + public get activeNode() { + return this._activeNode; + } + + public set activeNode(value: IActiveNode) { + this._activeNode = value; + } + + constructor() { + this.keydownNotify.pipe( + throttleTime(30, animationFrameScheduler), + ) + .subscribe((event: KeyboardEvent) => { + this.dispatchEvent(event); + }); + + } + + public handleNavigation(event: KeyboardEvent) { + const key = event.key.toLowerCase(); + if (NAVIGATION_KEYS.has(key)) { + event.stopPropagation(); + } + if (this.grid.crudService.cell && NAVIGATION_KEYS.has(key)) { + return; + } + if (event.repeat && SUPPORTED_KEYS.has(key) || (key === 'tab' && this.grid.crudService.cell)) { + event.preventDefault(); + } + if (event.repeat) { + this.keydownNotify.next(event); + } else { + this.dispatchEvent(event); + } + } + + public dispatchEvent(event: KeyboardEvent) { + const key = event.key.toLowerCase(); + const cellOrRowInEdit = this.grid.crudService.cell || this.grid.crudService.row; + if (!this.activeNode || !(SUPPORTED_KEYS.has(key) || (key === 'tab' && cellOrRowInEdit))) { + return; + } + const shift = event.shiftKey; + const ctrl = event.ctrlKey; + if (NAVIGATION_KEYS.has(key) && this.pendingNavigation) { + event.preventDefault(); + return; + } + + const type = this.isDataRow(this.activeNode.row) ? 'dataCell' : + this.isDataRow(this.activeNode.row, true) ? 'summaryCell' : 'groupRow'; + if (this.emitKeyDown(type, this.activeNode.row, event)) { + return; + } + if (event.altKey) { + this.handleAlt(key, event); + return; + } + if ([' ', 'spacebar', 'space'].indexOf(key) === -1) { + this.grid.selectionService.keyboardStateOnKeydown(this.activeNode, shift, shift && key === 'tab'); + } + const position = this.getNextPosition(this.activeNode.row, this.activeNode.column, key, shift, ctrl, event); + if (NAVIGATION_KEYS.has(key)) { + event.preventDefault(); + this.navigateInBody(position.rowIndex, position.colIndex, (obj) => { + obj.target.activate(event); + }); + } + } + + public summaryNav(event: KeyboardEvent) { + if (this.grid.hasSummarizedColumns) { + this.horizontalNav(event, event.key.toLowerCase(), this.grid.dataView.length, 'summaryCell'); + } + } + + public headerNavigation(event: KeyboardEvent) { + const key = event.key.toLowerCase(); + if (!HEADER_KEYS.has(key) || this.activeNode?.row !== -1) { + return; + } + event.preventDefault(); + + const ctrl = event.ctrlKey; + const shift = event.shiftKey; + const alt = event.altKey; + + this.performHeaderKeyCombination(this.currentActiveColumn, key, shift, ctrl, alt, event); + if (shift || alt || (ctrl && (key.includes('down') || key.includes('down')))) { + return; + } + if (this.grid.hasColumnGroups) { + this.handleMCHeaderNav(key, ctrl); + } else { + this.horizontalNav(event, key, -1, 'headerCell'); + } + } + + public focusTbody(event) { + const gridRows = this.grid.verticalScrollContainer.totalItemCount ?? this.grid.dataView.length; + if (gridRows < 1) { + this.activeNode = null; + return; + } + if (!this.activeNode || !Object.keys(this.activeNode).length || this.activeNode.row < 0 || this.activeNode.row > gridRows - 1) { + const hasLastActiveNode = Object.keys(this.lastActiveNode).length; + const shouldClearSelection = hasLastActiveNode && (this.lastActiveNode.row < 0 || this.lastActiveNode.row > gridRows - 1); + this.setActiveNode(this.lastActiveNode.row >= 0 && this.lastActiveNode.row < gridRows ? + this.firstVisibleNode(this.lastActiveNode.row) : this.firstVisibleNode()); + if (shouldClearSelection || (this.grid.cellSelection !== GridSelectionMode.multiple)) { + this.grid.clearCellSelection(); + this.grid.navigateTo(this.activeNode.row, this.activeNode.column, (obj) => { + obj.target?.activate(event); + }); + } else { + if (hasLastActiveNode && !this.grid.selectionService.selected(this.lastActiveNode)) { + return; + } + const range = { + rowStart: this.activeNode.row, rowEnd: this.activeNode.row, + columnStart: this.activeNode.column, columnEnd: this.activeNode.column + }; + this.grid.selectRange(range); + this.grid.notifyChanges(); + } + } + } + + public focusFirstCell(header = true) { + if ((header || this.grid.dataView.length) && this.activeNode && + (this.activeNode.row === -1 || this.activeNode.row === this.grid.dataView.length || + (!header && !this.grid.hasSummarizedColumns))) { + return; + } + const shouldScrollIntoView = this.lastActiveNode && (header && this.lastActiveNode.row !== -1) || + (!header && this.lastActiveNode.row !== this.grid.dataView.length); + this.setActiveNode(this.firstVisibleNode(header ? -1 : this.grid.dataView.length)); + if (shouldScrollIntoView) { + this.performHorizontalScrollToCell(this.activeNode.column); + } + } + + public isColumnFullyVisible(columnIndex: number) { + if (columnIndex < 0 || this.isColumnPinned(columnIndex, this.forOfDir())) { + return true; + } + const index = this.getColumnUnpinnedIndex(columnIndex); + const width = this.forOfDir().getColumnScrollLeft(index + 1) - this.forOfDir().getColumnScrollLeft(index); + if (this.displayContainerWidth < width && this.displayContainerScrollLeft === this.forOfDir().getColumnScrollLeft(index)) { + return true; + } + return this.displayContainerWidth >= this.forOfDir().getColumnScrollLeft(index + 1) - this.displayContainerScrollLeft && + this.displayContainerScrollLeft <= this.forOfDir().getColumnScrollLeft(index); + } + + public shouldPerformHorizontalScroll(visibleColIndex: number, rowIndex = -1) { + if (visibleColIndex < 0 || visibleColIndex > this.grid.visibleColumns.length - 1) { + return false; + } + if (rowIndex < 0 || rowIndex > this.grid.dataView.length - 1) { + return !this.isColumnFullyVisible(visibleColIndex); + } + const row = this.grid.dataView[rowIndex]; + return row.expression || row.detailsData ? false : !this.isColumnFullyVisible(visibleColIndex); + } + + public shouldPerformVerticalScroll(targetRowIndex: number, _visibleColIndex: number): boolean { + if (this.grid.isRecordPinnedByViewIndex(targetRowIndex)) { + return false; + } + const scrollRowIndex = this.grid.hasPinnedRecords && this.grid.isRowPinningToTop ? + targetRowIndex - this.grid.pinnedDataView.length : targetRowIndex; + const targetRow = this.getRowElementByIndex(targetRowIndex); + const rowHeight = this.grid.verticalScrollContainer.getSizeAt(scrollRowIndex); + const containerHeight = this.grid.calcHeight ? Math.ceil(this.grid.calcHeight) : 0; + const endTopOffset = targetRow ? targetRow.offsetTop + rowHeight + this.containerTopOffset : containerHeight + rowHeight; + // this is workaround: endTopOffset - containerHeight > 5 and should be replaced with: containerHeight < endTopOffset + // when the page is zoomed the grid does not scroll the row completely in the view + return !targetRow || targetRow.offsetTop < Math.abs(this.containerTopOffset) + || containerHeight && endTopOffset - containerHeight > 5; + } + + public performVerticalScrollToCell(rowIndex: number, visibleColIndex = -1, cb?: () => void) { + if (!this.shouldPerformVerticalScroll(rowIndex, visibleColIndex)) { + if (cb) { + cb(); + } + return; + } + this.pendingNavigation = true; + // Only for top pinning we need to subtract pinned count because virtualization indexing doesn't count pinned rows. + const scrollRowIndex = this.grid.hasPinnedRecords && this.grid.isRowPinningToTop ? + rowIndex - this.grid.pinnedDataView.length : rowIndex; + this.grid.verticalScrollContainer.scrollTo(scrollRowIndex); + this.grid.verticalScrollContainer.chunkLoad + .pipe(first()).subscribe(() => { + this.pendingNavigation = false; + if (cb) { + cb(); + } + }); + } + + public performHorizontalScrollToCell(visibleColumnIndex: number, cb?: () => void) { + if (this.grid.rowList < 1 && this.grid.summariesRowList.length < 1 && this.grid.hasColumnGroups) { + let column = this.grid.getColumnByVisibleIndex(visibleColumnIndex); + while (column.parent) { + column = column.parent; + } + visibleColumnIndex = this.forOfDir().igxForOf.indexOf(column); + } + if (!this.shouldPerformHorizontalScroll(visibleColumnIndex)) { + return; + } + this.pendingNavigation = true; + this.grid.parentVirtDir.chunkLoad + .pipe(first()) + .subscribe(() => { + this.pendingNavigation = false; + if (cb) { + cb(); + } + }); + this.forOfDir().scrollTo(this.getColumnUnpinnedIndex(visibleColumnIndex)); + } + + public isDataRow(rowIndex: number, includeSummary = false) { + let curRow: any; + + if (rowIndex < 0 || rowIndex > this.grid.dataView.length - 1) { + curRow = this.grid.dataView[rowIndex - this.grid.virtualizationState.startIndex]; + if (!curRow) { + // if data is remote, record might not be in the view yet. + return this.grid.verticalScrollContainer.isRemote && rowIndex >= 0 && rowIndex <= (this.grid as any).totalItemCount - 1; + } + } else { + curRow = this.grid.dataView[rowIndex]; + } + return curRow && !this.grid.isGroupByRecord(curRow) && !this.grid.isDetailRecord(curRow) + && !curRow.childGridsData && (includeSummary || !curRow.summaries); + } + + public isGroupRow(rowIndex: number): boolean { + if (rowIndex < 0 || rowIndex > this.grid.dataView.length - 1) { + return false; + } + const curRow = this.grid.dataView[rowIndex]; + return curRow && this.grid.isGroupByRecord(curRow); + } + + public setActiveNode(activeNode: IActiveNode) { + if (!this.isActiveNodeChanged(activeNode)) { + return; + } + + if (!this.activeNode) { + this.activeNode = activeNode; + } + + Object.assign(this.activeNode, activeNode); + + const currRow = this.grid.dataView[activeNode.row]; + const type: GridKeydownTargetType = activeNode.row < 0 ? 'headerCell' : + this.isDataRow(activeNode.row) ? 'dataCell' : + currRow && this.grid.isGroupByRecord(currRow) ? 'groupRow' : + currRow && this.grid.isDetailRecord(currRow) ? 'masterDetailRow' : 'summaryCell'; + + const args: IActiveNodeChangeEventArgs = { + row: this.activeNode.row, + column: this.activeNode.column, + level: this.activeNode.level, + tag: type + }; + + this.grid.activeNodeChange.emit(args); + } + + public isActiveNodeChanged(activeNode: IActiveNode) { + let isChanged = false; + const checkInnerProp = (aciveNode: ColumnGroupsCache | IMultiRowLayoutNode, prop) => { + if (!aciveNode) { + isChanged = true; + return; + } + + props = Object.getOwnPropertyNames(aciveNode); + for (const propName of props) { + if (this.activeNode[prop][propName] !== aciveNode[propName]) { + isChanged = true; + } + } + }; + + if (!this.activeNode) { + return isChanged = true; + } + + let props = Object.getOwnPropertyNames(activeNode); + for (const propName of props) { + if (!!this.activeNode[propName] && typeof this.activeNode[propName] === 'object') { + checkInnerProp(activeNode[propName], propName); + } else if (this.activeNode[propName] !== activeNode[propName]) { + isChanged = true; + } + } + + return isChanged; + } + + /** Focus the Grid section (header, body, footer) depending on the current activeNode */ + public restoreActiveNodeFocus() { + if (!this.activeNode || !Object.keys(this.activeNode).length) { + return; + } + + if (this.activeNode.row >= 0 && this.activeNode.row < this.grid.dataView.length) { + this.grid.tbody.nativeElement.focus(); + } + if (this.activeNode.row === -1) { + this.grid.theadRow.nativeElement.focus(); + } + if (this.activeNode.row === this.grid.dataView.length) { + this.grid.tfoot.nativeElement.focus(); + } + } + + protected getNextPosition(rowIndex: number, colIndex: number, key: string, shift: boolean, ctrl: boolean, event: KeyboardEvent) { + if (!this.isDataRow(rowIndex, true) && (key.indexOf('down') < 0 || key.indexOf('up') < 0) && ctrl) { + return { rowIndex, colIndex }; + } + switch (key) { + case 'pagedown': + case 'pageup': + event.preventDefault(); + if (key === 'pagedown') { + this.grid.verticalScrollContainer.scrollNextPage(); + } else { + this.grid.verticalScrollContainer.scrollPrevPage(); + } + const editCell = this.grid.crudService.cell; + this.grid.verticalScrollContainer.chunkLoad + .pipe(first()).subscribe(() => { + if (editCell && this.grid.rowList.map(r => r.index).indexOf(editCell.rowIndex) < 0) { + this.grid.tbody.nativeElement.focus({ preventScroll: true }); + } + }); + break; + case 'tab': + this.handleEditing(shift, 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 'arrowleft': + case 'left': + colIndex = ctrl ? 0 : this.activeNode.column - 1; + break; + case 'arrowright': + case 'right': + colIndex = ctrl ? this.lastColumnIndex : this.activeNode.column + 1; + break; + case 'arrowup': + case 'up': + if (ctrl && !this.isDataRow(rowIndex) || (this.grid.rowEditable && this.grid.crudService.rowEditingBlocked)) { + break; + } + colIndex = this.activeNode.column !== undefined ? this.activeNode.column : 0; + rowIndex = ctrl ? this.findFirstDataRowIndex() : this.activeNode.row - 1; + break; + case 'arrowdown': + case 'down': + if ((ctrl && !this.isDataRow(rowIndex)) || (this.grid.rowEditable && this.grid.crudService.rowEditingBlocked)) { + break; + } + colIndex = this.activeNode.column !== undefined ? this.activeNode.column : 0; + rowIndex = ctrl ? this.findLastDataRowIndex() : this.activeNode.row + 1; + break; + case 'enter': + case 'f2': + const cell = this.grid.gridAPI.get_cell_by_visible_index(this.activeNode.row, this.activeNode.column); + if (!this.isDataRow(rowIndex) || !cell.editable) { + break; + } + this.grid.crudService.enterEditMode(cell, event); + break; + case 'escape': + case 'esc': + if (!this.isDataRow(rowIndex)) { + break; + } + + if (this.grid.crudService.isInCompositionMode) { + return; + } + + if (this.grid.crudService.cellInEditMode || this.grid.crudService.rowInEditMode) { + this.grid.crudService.endEdit(false, event); + if (this.platform.isEdge) { + this.grid.cdr.detectChanges(); + } + this.grid.tbody.nativeElement.focus(); + } + break; + case ' ': + case 'spacebar': + case 'space': + const rowObj = this.grid.gridAPI.get_row_by_index(this.activeNode.row); + if (this.grid.isRowSelectable && rowObj) { + if (this.isDataRow(rowIndex)) { + if (rowObj.selected) { + this.grid.selectionService.deselectRow(rowObj.key, event); + } else { + this.grid.selectionService.selectRowById(rowObj.key, false, event); + } + } + if (this.isGroupRow(rowIndex)) { + (rowObj as any).onGroupSelectorClick(event); + } + } + break; + default: + return; + } + return { rowIndex, colIndex }; + } + + protected horizontalNav(event: KeyboardEvent, key: string, rowIndex: number, tag: GridKeydownTargetType) { + const ctrl = event.ctrlKey; + if (!HORIZONTAL_NAV_KEYS.has(event.key.toLowerCase())) { + return; + } + event.preventDefault(); + this.activeNode.row = rowIndex; + if (rowIndex > 0) { + if (this.emitKeyDown('summaryCell', this.activeNode.row, event)) { + return; + } + } + + const newActiveNode = { + column: this.activeNode.column, + mchCache: { + level: this.activeNode.level, + visibleIndex: this.activeNode.column + } + }; + + if ((key.includes('left') || key === 'home') && this.activeNode.column > 0) { + newActiveNode.column = ctrl || key === 'home' ? 0 : this.activeNode.column - 1; + } + if ((key.includes('right') || key === 'end') && this.activeNode.column < this.lastColumnIndex) { + newActiveNode.column = ctrl || key === 'end' ? this.lastColumnIndex : this.activeNode.column + 1; + } + + if (tag === 'headerCell') { + const column = this.grid.getColumnByVisibleIndex(newActiveNode.column); + newActiveNode.mchCache.level = column.level; + newActiveNode.mchCache.visibleIndex = column.visibleIndex; + } + + this.setActiveNode({ row: this.activeNode.row, column: newActiveNode.column, mchCache: newActiveNode.mchCache }); + this.performHorizontalScrollToCell(this.activeNode.column); + } + + public get lastColumnIndex() { + return Math.max(...this.grid.visibleColumns.map(col => col.visibleIndex)); + } + public get displayContainerWidth() { + return Math.round(this.grid.parentVirtDir.dc.instance._viewContainer.element.nativeElement.offsetWidth); + } + public get displayContainerScrollLeft() { + return Math.ceil(this.grid.headerContainer.scrollPosition); + } + public get containerTopOffset() { + return parseInt(this.grid.verticalScrollContainer.dc.instance._viewContainer.element.nativeElement.style.top, 10); + } + + protected getColumnUnpinnedIndex(visibleColumnIndex: number) { + const column = this.grid.unpinnedColumns.find((col) => !col.columnGroup && col.visibleIndex === visibleColumnIndex); + return this.grid.pinnedColumns.length ? this.grid.unpinnedColumns.filter((c) => !c.columnGroup).indexOf(column) : + visibleColumnIndex; + } + + protected forOfDir(): IgxForOfDirective { + const forOfDir = this.grid.dataRowList.length > 0 ? this.grid.dataRowList.first.virtDirRow : this.grid.summariesRowList.length ? + this.grid.summariesRowList.first.virtDirRow : this.grid.headerContainer; + return forOfDir as IgxForOfDirective; + } + + protected handleAlt(key: string, event: KeyboardEvent) { + event.preventDefault(); + // todo TODO ROW + const row = this.grid.gridAPI.get_row_by_index(this.activeNode.row); + + if (!(this.isToggleKey(key) || this.isAddKey(key)) || !row) { + return; + } + if (this.isAddKey(key)) { + if (!this.grid.rowEditable) { + console.warn('The grid must be in row edit mode to perform row adding!'); + return; + } + + if (event.shiftKey && row.treeRow !== undefined) { + this.grid.crudService.enterAddRowMode(row, true, event); + } else if (!event.shiftKey) { + this.grid.crudService.enterAddRowMode(row, false, event); + } + } else if (!row.expanded && ROW_EXPAND_KEYS.has(key)) { + if (row.key === undefined) { + // TODO use expanded row.expanded = !row.expanded; + (row as any).toggle(); + } else { + this.grid.gridAPI.set_row_expansion_state(row.key, true, event); + } + } else if (row.expanded && ROW_COLLAPSE_KEYS.has(key)) { + if (row.key === undefined) { + // TODO use expanded row.expanded = !row.expanded; + (row as any).toggle(); + } else { + this.grid.gridAPI.set_row_expansion_state(row.key, false, event); + } + } + this.grid.notifyChanges(); + } + + protected handleEditing(shift: boolean, event: KeyboardEvent) { + const next = shift ? this.grid.getPreviousCell(this.activeNode.row, this.activeNode.column, col => col.editable) : + this.grid.getNextCell(this.activeNode.row, this.activeNode.column, col => col.editable); + if (!this.grid.crudService.rowInEditMode && this.isActiveNode(next.rowIndex, next.visibleColumnIndex)) { + this.grid.crudService.endEdit(true, event); + this.grid.tbody.nativeElement.focus(); + return; + } + event.preventDefault(); + if ((this.grid.crudService.rowInEditMode && this.grid.rowEditTabs.length) && + (this.activeNode.row !== next.rowIndex || this.isActiveNode(next.rowIndex, next.visibleColumnIndex))) { + const args = this.grid.crudService.updateCell(true, event); + if (args.cancel) { + return; + } else if (shift) { + this.grid.rowEditTabs.last.element.nativeElement.focus(); + } else { + this.grid.rowEditTabs.first.element.nativeElement.focus(); + } + return; + } + + if (this.grid.crudService.rowInEditMode && !this.grid.rowEditTabs.length) { + if (shift && next.rowIndex === this.activeNode.row && next.visibleColumnIndex === this.activeNode.column) { + next.visibleColumnIndex = this.grid.lastEditableColumnIndex; + } else if (!shift && next.rowIndex === this.activeNode.row && next.visibleColumnIndex === this.activeNode.column) { + next.visibleColumnIndex = this.grid.firstEditableColumnIndex; + } else { + next.rowIndex = this.activeNode.row; + } + } + + this.navigateInBody(next.rowIndex, next.visibleColumnIndex, (obj) => { + obj.target.activate(event); + }); + } + + protected navigateInBody(rowIndex, visibleColIndex, cb: (arg: any) => void = null): void { + if (!this.isValidPosition(rowIndex, visibleColIndex) || this.isActiveNode(rowIndex, visibleColIndex)) { + return; + } + this.grid.navigateTo(rowIndex, visibleColIndex, cb); + } + + + protected emitKeyDown(type: GridKeydownTargetType, rowIndex, event) { + const row = this.grid.summariesRowList.toArray().concat(this.grid.rowList.toArray()).find(r => r.index === rowIndex); + if (!row) { + return; + } + + const target = type === 'groupRow' ? row : + type === 'dataCell' ? row.cells?.find(c => c.visibleColumnIndex === this.activeNode.column) : + row.summaryCells?.find(c => c.visibleColumnIndex === this.activeNode.column); + const keydownArgs = { targetType: type, event, cancel: false, target }; + this.grid.gridKeydown.emit(keydownArgs); + if (keydownArgs.cancel && type === 'dataCell') { + this.grid.selectionService.clear(); + this.grid.selectionService.keyboardState.active = true; + return keydownArgs.cancel; + } + } + + protected isColumnPinned(columnIndex: number, forOfDir: IgxForOfDirective): boolean { + const horizontalScroll = forOfDir.getScroll(); + return (!horizontalScroll.clientWidth || this.grid.getColumnByVisibleIndex(columnIndex)?.pinned); + } + + protected findFirstDataRowIndex(): number { + return this.grid.dataView.findIndex(rec => !this.grid.isGroupByRecord(rec) && !this.grid.isDetailRecord(rec) && !rec.summaries); + } + + protected findLastDataRowIndex(): number { + if ((this.grid as any).totalItemCount) { + return (this.grid as any).totalItemCount - 1; + } + let i = this.grid.dataView.length; + while (i--) { + if (this.isDataRow(i)) { + return i; + } + } + } + + protected getRowElementByIndex(index) { + if (this.grid.hasDetails) { + const detail = this.grid.nativeElement.querySelector(`[detail="true"][data-rowindex="${index}"]`); + if (detail) { + return detail; + } + } + return this.grid.rowList.toArray().concat(this.grid.summariesRowList.toArray()).find(r => r.index === index)?.nativeElement; + } + + protected isValidPosition(rowIndex: number, colIndex: number): boolean { + const length = (this.grid as any).totalItemCount ?? this.grid.dataView.length; + if (rowIndex < 0 || colIndex < 0 || length - 1 < rowIndex || this.lastColumnIndex < colIndex) { + return false; + } + return this.activeNode.column !== colIndex && !this.isDataRow(rowIndex, true) ? false : true; + } + protected performHeaderKeyCombination(column, key, shift, ctrl, alt, event) { + let direction = this.grid.sortingExpressions.find(expr => expr.fieldName === column.field)?.dir; + if (ctrl && key.includes('up') && column.sortable && !column.columnGroup) { + direction = direction === SortingDirection.Asc ? SortingDirection.None : SortingDirection.Asc; + this.grid.sort({ fieldName: column.field, dir: direction, ignoreCase: false }); + return; + } + if (ctrl && key.includes('down') && column.sortable && !column.columnGroup) { + direction = direction === SortingDirection.Desc ? SortingDirection.None : SortingDirection.Desc; + this.grid.sort({ fieldName: column.field, dir: direction, ignoreCase: false }); + return; + } + if (shift && alt && this.isToggleKey(key) && !column.columnGroup && column.groupable) { + direction = direction || SortingDirection.Asc; + if (key.includes('right')) { + (this.grid as any).groupBy({ + fieldName: column.field, + dir: direction, + ignoreCase: column.sortingIgnoreCase, + strategy: column.sortStrategy, + groupingComparer: column.groupingComparer, + }); + } else { + (this.grid as any).clearGrouping(column.field); + } + this.activeNode.column = key.includes('right') && (this.grid as any).hideGroupedColumns && + column.visibleIndex === this.lastColumnIndex ? this.lastColumnIndex - 1 : this.activeNode.column; + return; + } + if (alt && (ROW_EXPAND_KEYS.has(key) || ROW_COLLAPSE_KEYS.has(key))) { + this.handleMCHExpandCollapse(key, column); + return; + } + if ([' ', 'spacebar', 'space'].indexOf(key) !== -1) { + this.handleColumnSelection(column, event); + } + if (alt && (key === 'l' || key === '¬') && this.grid.allowAdvancedFiltering) { + this.grid.openAdvancedFilteringDialog(); + } + if (ctrl && shift && key === 'l' && this.grid.allowFiltering && !column.columnGroup && column.filterable) { + if (this.grid.filterMode === FilterMode.excelStyleFilter) { + const headerEl = this.grid.headerGroups.find(g => g.active).nativeElement; + this.grid.filteringService.toggleFilterDropdown(headerEl, column); + } else { + this.performHorizontalScrollToCell(column.visibleIndex); + this.grid.filteringService.filteredColumn = column; + this.grid.filteringService.isFilterRowVisible = true; + } + } + } + + private firstVisibleNode(rowIndex?) { + const colIndex = this.lastActiveNode.column !== undefined ? this.lastActiveNode.column : + this.grid.visibleColumns.sort((c1, c2) => c1.visibleIndex - c2.visibleIndex) + .find(c => this.isColumnFullyVisible(c.visibleIndex))?.visibleIndex; + const column = this.grid.visibleColumns.find((col) => !col.columnLayout && col.visibleIndex === colIndex); + const rowInd = rowIndex ? rowIndex : this.grid.rowList.find(r => !this.shouldPerformVerticalScroll(r.index, colIndex))?.index; + const node = { + row: rowInd ?? 0, + column: column?.visibleIndex ?? 0, level: column?.level ?? 0, + mchCache: column ? { level: column.level, visibleIndex: column.visibleIndex } : {} as ColumnGroupsCache, + layout: column && column.columnLayoutChild ? { + rowStart: column.rowStart, colStart: column.colStart, + rowEnd: column.rowEnd, colEnd: column.colEnd, columnVisibleIndex: column.visibleIndex + } : null + }; + return node; + } + + private handleMCHeaderNav(key: string, ctrl: boolean) { + const newHeaderNode: ColumnGroupsCache = { + visibleIndex: this.activeNode.mchCache.visibleIndex, + level: this.activeNode.mchCache.level + }; + const activeCol = this.currentActiveColumn; + const lastGroupIndex = Math.max(... this.grid.visibleColumns. + filter(c => c.level <= this.activeNode.level).map(col => col.visibleIndex)); + let nextCol = activeCol; + if ((key.includes('left') || key === 'home') && this.activeNode.column > 0) { + const index = ctrl || key === 'home' ? 0 : this.activeNode.column - 1; + nextCol = this.getNextColumnMCH(index); + newHeaderNode.visibleIndex = nextCol.visibleIndex; + } + if ((key.includes('right') || key === 'end') && activeCol.visibleIndex < lastGroupIndex) { + const nextVIndex = activeCol.children ? Math.max(...activeCol.allChildren.map(c => c.visibleIndex)) + 1 : + activeCol.visibleIndex + 1; + nextCol = ctrl || key === 'end' ? this.getNextColumnMCH(this.lastColumnIndex) : this.getNextColumnMCH(nextVIndex); + newHeaderNode.visibleIndex = nextCol.visibleIndex; + } + if (!ctrl && key.includes('up') && this.activeNode.level > 0) { + nextCol = activeCol.parent; + newHeaderNode.level = nextCol.level; + } + if (!ctrl && key.includes('down') && activeCol.children) { + nextCol = activeCol.children.find(c => c.visibleIndex === newHeaderNode.visibleIndex) || + activeCol.children.toArray().sort((a, b) => b.visibleIndex - a.visibleIndex) + .filter(col => col.visibleIndex < newHeaderNode.visibleIndex)[0]; + newHeaderNode.level = nextCol.level; + } + + this.setActiveNode({ + row: this.activeNode.row, + column: nextCol.visibleIndex, + level: nextCol.level, + mchCache: newHeaderNode + }); + this.performHorizontalScrollToCell(nextCol.visibleIndex); + } + + private handleMCHExpandCollapse(key, column) { + if (!column.children || !column.collapsible) { + return; + } + if (!column.expanded && ROW_EXPAND_KEYS.has(key)) { + column.expanded = true; + } else if (column.expanded && ROW_COLLAPSE_KEYS.has(key)) { + column.expanded = false; + } + } + + private handleColumnSelection(column, event) { + if (!column.selectable || this.grid.columnSelection === GridSelectionMode.none) { + return; + } + const clearSelection = this.grid.columnSelection === GridSelectionMode.single; + const columnsToSelect = !column.children ? [column.field] : + column.allChildren.filter(c => !c.hidden && c.selectable && !c.columnGroup).map(c => c.field); + if (column.selected) { + this.grid.selectionService.deselectColumns(columnsToSelect, event); + } else { + this.grid.selectionService.selectColumns(columnsToSelect, clearSelection, false, event); + } + } + + private getNextColumnMCH(visibleIndex) { + let col = this.grid.getColumnByVisibleIndex(visibleIndex); + let parent = col.parent; + while (parent && col.level > this.activeNode.mchCache.level) { + col = col.parent; + parent = col.parent; + } + return col; + } + + private get currentActiveColumn() { + return this.grid.visibleColumns.find(c => c.visibleIndex === this.activeNode.column && c.level === this.activeNode.level); + } + + private isActiveNode(rIndex: number, cIndex: number): boolean { + return this.activeNode ? this.activeNode.row === rIndex && this.activeNode.column === cIndex : false; + } + + private isToggleKey(key: string): boolean { + return ROW_COLLAPSE_KEYS.has(key) || ROW_EXPAND_KEYS.has(key); + } + + private isAddKey(key: string): boolean { + return ROW_ADD_KEYS.has(key); + } +} diff --git a/projects/igniteui-angular/grids/core/src/grid-public-cell.ts b/projects/igniteui-angular/grids/core/src/grid-public-cell.ts new file mode 100644 index 00000000000..d3eb8d61314 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/grid-public-cell.ts @@ -0,0 +1,289 @@ +import type { CellType, GridType, IGridValidationState, RowType, ValidationStatus } from './common/grid.interface'; +import type { ISelectionNode } from './common/types'; +import { columnFieldPath, type ColumnType, resolveNestedPath } from 'igniteui-angular/core'; + +export class IgxGridCell implements CellType { + + + + /** + * Returns the grid containing the cell. + * + * @memberof IgxGridCell + */ + public grid: GridType; + private _row: RowType; + private _rowIndex: number; + private _column: ColumnType; + + /** + * @hidden + */ + constructor( + grid: GridType, + row: number | RowType, + column: ColumnType) { + this.grid = grid; + if (typeof row === 'number') { + this._rowIndex = row; + } else { + this._row = row; + this._rowIndex = row.index; + } + this._column = column; + } + + /** + * Returns the row containing the cell. + * ```typescript + * let row = this.cell.row; + * ``` + * + * @memberof IgxGridCell + */ + public get row(): RowType { + return this._row || this.grid.createRow(this._rowIndex); + } + + /** + * Returns the column of the cell. + * ```typescript + * let column = this.cell.column; + * ``` + * + * @memberof IgxGridCell + */ + public get column(): ColumnType { + return this._column; + } + + /** + * Gets the current edit value while a cell is in edit mode. + * ```typescript + * let editValue = this.cell.editValue; + * ``` + * + * @memberof IgxGridCell + */ + public get editValue(): any { + if (this.isCellInEditMode()) { + return this.grid.crudService.cell.editValue; + } + } + + /** + * Sets the current edit value while a cell is in edit mode. + * Only for cell editing mode. + * ```typescript + * this.cell.editValue = value; + * ``` + * + * @memberof IgxGridCell + */ + public set editValue(value: any) { + if (this.isCellInEditMode()) { + this.grid.crudService.cell.editValue = value; + } + } + + /** + * Gets the validation status and errors, if any. + * ```typescript + * let validation = this.cell.validation; + * let errors = validation.errors; + * ``` + */ + + public get validation(): IGridValidationState { + const form = this.grid.validation.getFormControl(this.row.key, this.column.field); + return { status: form?.status as ValidationStatus || 'VALID', errors: form?.errors } as const; + } + + /** + * Returns whether the cell is editable.. + * + * @memberof IgxGridCell + */ + public get editable(): boolean { + return this.column.editable && !this.row?.disabled; + } + + /** + * Gets the width of the cell. + * ```typescript + * let cellWidth = this.cell.width; + * ``` + * + * @memberof IgxGridCell + */ + public get width(): string { + return this.column.width; + } + + /** + * Returns the cell value. + * + * @memberof IgxGridCell + */ + public get value(): any { + // will return undefined for a column layout, because getCellByColumnVisibleIndex may return the column layout at that index. + // getCellByColumnVisibleIndex is deprecated and will be removed in future version + return this.column.field ? + this.column.hasNestedPath ? resolveNestedPath(this.row?.data, columnFieldPath(this.column.field)) : this.row?.data[this.column.field] + : undefined; + } + + /** + * Updates the cell value. + * + * @memberof IgxGridCell + */ + public set value(val: any) { + this.update(val); + } + + /** + * Gets the cell id. + * A cell in the grid is identified by: + * - rowID - primaryKey data value or the whole rowData, if the primaryKey is omitted. + * - rowIndex - the row index + * - columnID - column index + * + * ```typescript + * let cellID = cell.id; + * ``` + * + * @memberof IgxGridCell + */ + public get id(): any { + const primaryKey = this.grid.primaryKey; + const rowID = primaryKey ? this.row?.data[primaryKey] : this.row?.data; + return { rowID, columnID: this.column.index, rowIndex: this._rowIndex || this.row?.index }; + } + + /** + * Returns if the row is currently in edit mode. + * + * @memberof IgxGridCell + */ + public get editMode(): boolean { + return this.isCellInEditMode(); + } + + /** + * Starts/ends edit mode for the cell. + * + * ```typescript + * cell.editMode = !cell.editMode; + * ``` + * + * @memberof IgxGridCell + */ + public set editMode(value: boolean) { + const isInEditMode = this.isCellInEditMode(); + if (!this.row || this.row?.deleted || isInEditMode === value) { + return; + } + if (this.editable && value) { + this.endEdit(); + // TODO possibly define similar method in gridAPI, which does not emit event + this.grid.crudService.enterEditMode(this); + } else { + this.grid.crudService.endCellEdit(true); + } + this.grid.notifyChanges(); + } + + /** + * Gets whether the cell is selected. + * ```typescript + * let isSelected = this.cell.selected; + * ``` + * + * + * @memberof IgxGridCell + */ + public get selected(): boolean { + return this.grid.selectionService.selected(this.selectionNode); + } + + /** + * Selects/deselects the cell. + * ```typescript + * this.cell.selected = true. + * ``` + * + * + * @memberof IgxGridCell + */ + public set selected(val: boolean) { + const node = this.selectionNode; + if (val) { + this.grid.selectionService.add(node); + } else { + this.grid.selectionService.remove(node); + } + this.grid.notifyChanges(); + } + + public get active() { + const node = this.grid.navigation.activeNode; + return node ? node.row === this.row?.index && node.column === this.column.visibleIndex : false; + } + + + /** + * Updates the cell value. + * + * ```typescript + * cell.update(newValue); + * ``` + * + * @memberof IgxGridCell + */ + public update(val: any): void { + if (this.row?.deleted) { + return; + } + + this.endEdit(); + + const cell = this.isCellInEditMode() ? this.grid.crudService.cell : this.grid.crudService.createCell(this); + cell.editValue = val; + this.grid.gridAPI.update_cell(cell); + this.grid.crudService.endCellEdit(); + this.grid.notifyChanges(); + } + + protected get selectionNode(): ISelectionNode { + return { + row: this.row?.index, + column: this.column.columnLayoutChild ? this.column.parent.visibleIndex : this.column.visibleIndex, + layout: this.column.columnLayoutChild ? { + rowStart: this.column.rowStart, + colStart: this.column.colStart, + rowEnd: this.column.rowEnd, + colEnd: this.column.colEnd, + columnVisibleIndex: this.column.visibleIndex + } : null + }; + } + + private isCellInEditMode(): boolean { + if (this.grid.crudService.cellInEditMode) { + const cellInEditMode = this.grid.crudService.cell.id; + const isCurrentCell = cellInEditMode.rowID === this.id.rowID && + cellInEditMode.rowIndex === this.id.rowIndex && + cellInEditMode.columnID === this.id.columnID; + return isCurrentCell; + } + return false; + } + + private endEdit(): void { + if (!this.isCellInEditMode()) { + this.grid.gridAPI.update_cell(this.grid.crudService.cell); + this.grid.crudService.endCellEdit(); + } + } +} diff --git a/projects/igniteui-angular/grids/core/src/grid-public-row.ts b/projects/igniteui-angular/grids/core/src/grid-public-row.ts new file mode 100644 index 00000000000..0b4557d6d2f --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/grid-public-row.ts @@ -0,0 +1,791 @@ +import { IgxEditRow } from './common/crud.service'; +import { GridSummaryPosition } from './common/enums'; +import { IgxGridCell } from './grid-public-cell'; +import { mergeWith } from 'lodash-es'; +import { CellType, GridServiceType, GridType, IGridValidationState, RowType, ValidationStatus } from './common/grid.interface'; +import { GridSummaryCalculationMode, IGroupByRecord, IgxSummaryResult, ITreeGridRecord } from 'igniteui-angular/core'; + +abstract class BaseRow implements RowType { + public index: number; + /** + * The grid that contains the row. + */ + public grid: GridType; + protected _data?: any; + + /** + * Returns the view index calculated per the grid page. + */ + public get viewIndex(): number { + return this.index + this.grid.page * this.grid.perPage; + } + + /** + * Gets the row key. + * A row in the grid is identified either by: + * - primaryKey data value, + * - the whole rowData, if the primaryKey is omitted. + * + * ```typescript + * let rowKey = row.key; + * ``` + */ + public get key(): any { + const data = this._data ?? this.grid.dataView[this.index]; + const primaryKey = this.grid.primaryKey; + return primaryKey ? data[primaryKey] : data; + } + + /** + * Gets if this represents add row UI + * + * ```typescript + * let isAddRow = row.addRowUI; + * ``` + */ + public get addRowUI(): boolean { + return !!this.grid.crudService.row && + this.grid.crudService.row.isAddRow && + this.grid.crudService.row.id === this.key; + } + + /** Gets the validation status and errors, if any. + * ```typescript + * let validation = row.validation; + * let errors = validation.errors; + * ``` + */ + public get validation(): IGridValidationState { + const formGroup = this.grid.validation.getFormGroup(this.key); + return { status: formGroup?.status as ValidationStatus || 'VALID', errors: formGroup?.errors } as const; + } + + /** + * The data record that populates the row. + * + * ```typescript + * let rowData = row.data; + * ``` + */ + public get data(): any { + if (this.inEditMode) { + return mergeWith(this.grid.dataCloneStrategy.clone(this._data ?? this.grid.dataView[this.index]), + this.grid.transactions.getAggregatedValue(this.key, false), + (objValue, srcValue) => { + if (Array.isArray(srcValue)) { + return objValue = srcValue; + } + }); + } + return this._data ?? this.grid.dataView[this.index]; + } + + /** + * Returns if the row is currently in edit mode. + */ + public get inEditMode(): boolean { + if (this.grid.rowEditable) { + const editRowState = this.grid.crudService.row; + return (editRowState && editRowState.id === this.key) || false; + } else { + return false; + } + } + + /** + * Gets whether the row is pinned. + * Default value is `false`. + * ```typescript + * const isPinned = row.pinned; + * ``` + */ + public get pinned(): boolean { + return this.grid.isRecordPinned(this.data); + } + + /** + * Sets whether the row is pinned. + * Default value is `false`. + * ```typescript + * row.pinned = !row.pinned; + * ``` + */ + public set pinned(val: boolean) { + if (val) { + this.pin(); + } else { + this.unpin(); + } + } + + /** + * Gets the row expanded/collapsed state. + * + * ```typescript + * const isExpanded = row.expanded; + * ``` + */ + public get expanded(): boolean { + return this.grid.gridAPI.get_row_expansion_state(this.data); + } + + /** + * Expands/collapses the row. + * + * ```typescript + * row.expanded = true; + * ``` + */ + public set expanded(val: boolean) { + this.grid.gridAPI.set_row_expansion_state(this.key, val); + } + + /** + * Gets whether the row is selected. + * Default value is `false`. + * ```typescript + * row.selected = true; + * ``` + */ + public get selected(): boolean { + return this.grid.selectionService.isRowSelected(this.key); + } + + /** + * Sets whether the row is selected. + * Default value is `false`. + * ```typescript + * row.selected = !row.selected; + * ``` + */ + public set selected(val: boolean) { + if (val) { + this.grid.selectionService.selectRowsWithNoEvent([this.key]); + } else { + this.grid.selectionService.deselectRowsWithNoEvent([this.key]); + } + this.grid.cdr.markForCheck(); + } + + /** + * Returns if the row is in delete state. + */ + public get deleted(): boolean { + return this.grid.gridAPI.row_deleted_transaction(this.key); + } + + /** + * Returns if the row has child rows. Always return false for IgxGridRow. + */ + public get hasChildren(): boolean { + return false; + } + + public get disabled(): boolean { + return this.grid.isGhostRecord(this.data); + } + + /** + * Gets the rendered cells in the row component. + */ + public get cells(): CellType[] { + const res: CellType[] = []; + this.grid.columns.forEach(col => { + const cell: CellType = new IgxGridCell(this.grid, this.index, col); + res.push(cell); + }); + return res; + } + + /** + * Pins the specified row. + * This method emits `onRowPinning` event. + * + * ```typescript + * // pin the selected row from the grid + * this.grid.selectedRows[0].pin(); + * ``` + */ + public pin(): boolean { + return this.grid.pinRow(this.key, this.index); + } + + /** + * Unpins the specified row. + * This method emits `onRowPinning` event. + * + * ```typescript + * // unpin the selected row from the grid + * this.grid.selectedRows[0].unpin(); + * ``` + */ + public unpin(): boolean { + return this.grid.unpinRow(this.key); + } + + /** + * Updates the specified row object and the data source record with the passed value. + * + * ```typescript + * // update the second selected row's value + * let newValue = "Apple"; + * this.grid.selectedRows[1].update(newValue); + * ``` + */ + public update(value: any): void { + const crudService = this.grid.crudService; + if (crudService.cellInEditMode && crudService.cell.id.rowID === this.key) { + this.grid.transactions.endPending(false); + } + const row = new IgxEditRow(this.key, this.index, this.data, this.grid); + this.grid.gridAPI.update_row(row, value); + this.grid.notifyChanges(); + } + + /** + * Removes the specified row from the grid's data source. + * This method emits `onRowDeleted` event. + * + * ```typescript + * // delete the third selected row from the grid + * this.grid.selectedRows[2].delete(); + * ``` + */ + public delete(): void { + this.grid.deleteRowById(this.key); + } +} + +export class IgxGridRow extends BaseRow implements RowType { + /** + * @hidden + */ + constructor( + public override grid: GridType, + public override index: number, data?: any + ) { + super(); + this._data = data && data.addRow && data.recordRef ? data.recordRef : data; + } + + /** + * Returns the view index calculated per the grid page. + */ + public override get viewIndex(): number { + if (this.grid.paginator) { + const precedingDetailRows = []; + const precedingGroupRows = []; + const firstRow = this.grid.dataView[0]; + const hasDetailRows = this.grid.expansionStates.size; + const hasGroupedRows = this.grid.groupingExpressions.length; + let precedingSummaryRows = 0; + const firstRowInd = this.grid.groupingFlatResult.indexOf(firstRow); + + // from groupingFlatResult, resolve two other collections: + // precedingGroupedRows -> use it to resolve summaryRow for each group in previous pages + // precedingDetailRows -> ise it to resolve the detail row for each expanded grid row in previous pages + if (hasDetailRows || hasGroupedRows) { + this.grid.groupingFlatResult.forEach((r, ind) => { + const rowID = this.grid.primaryKey ? r[this.grid.primaryKey] : r; + if (hasGroupedRows && ind < firstRowInd && this.grid.isGroupByRecord(r)) { + precedingGroupRows.push(r); + } + if (this.grid.expansionStates.get(rowID) && ind < firstRowInd && !this.grid.isGroupByRecord(r)) { + precedingDetailRows.push(r); + } + }); + } + + if (this.grid.summaryCalculationMode !== GridSummaryCalculationMode.rootLevelOnly) { + // if firstRow is a child of the last item in precedingGroupRows, + // then summaryRow for this given groupedRecord is rendered after firstRow, + // i.e. need to decrease firstRowInd to account for the above. + precedingSummaryRows = precedingGroupRows.filter(gr => this.grid.isExpandedGroup(gr)).length; + if (this.grid.summaryPosition === GridSummaryPosition.bottom && precedingGroupRows.length && + precedingGroupRows[precedingGroupRows.length - 1].records.indexOf(firstRow) > -1) { + precedingSummaryRows += -1; + } + } + + return precedingDetailRows.length + precedingSummaryRows + firstRowInd + this.index; + } else { + return this.index; + } + } + + /** + * Returns the parent row, if grid is grouped. + */ + public get parent(): RowType { + let parent: IgxGroupByRow; + if (!this.grid.groupingExpressions.length) { + return undefined; + } + + let i = this.index - 1; + while (i >= 0 && !parent) { + const rec = this.grid.dataView[i]; + if (this.grid.isGroupByRecord(rec)) { + parent = new IgxGroupByRow(this.grid, i, rec); + } + i--; + } + return parent; + } +} + +export class IgxTreeGridRow extends BaseRow implements RowType { + /** + * @hidden + */ + constructor( + public override grid: GridType, + public override index: number, data?: any, private _treeRow?: ITreeGridRecord + ) { + super(); + this._data = data && data.addRow && data.recordRef ? data.recordRef : data; + } + + /** + * Returns the view index calculated per the grid page. + */ + public override get viewIndex(): number { + if (this.grid.hasSummarizedColumns && this.grid.page > 0) { + if (this.grid.summaryCalculationMode !== GridSummaryCalculationMode.rootLevelOnly) { + const firstRowIndex = this.grid.processedExpandedFlatData.indexOf(this.grid.dataView[0].data); + // firstRowIndex is based on data result after all pipes triggered, excluding summary pipe + const precedingSummaryRows = this.grid.summaryPosition === GridSummaryPosition.bottom ? + this.grid.rootRecords.indexOf(this.getRootParent(this.grid.dataView[0])) : + this.grid.rootRecords.indexOf(this.getRootParent(this.grid.dataView[0])) + 1; + // there is a summary row for each root record, so we calculate how many root records are rendered before the current row + return firstRowIndex + precedingSummaryRows + this.index; + } + } + return this.index + this.grid.page * this.grid.perPage; + } + + /** + * The data passed to the row component. + * + * ```typescript + * let selectedRowData = this.grid.selectedRows[0].data; + * ``` + */ + public override get data(): any { + if (this.inEditMode) { + return mergeWith(this.grid.dataCloneStrategy.clone(this._data ?? this.grid.dataView[this.index]), + this.grid.transactions.getAggregatedValue(this.key, false), + (objValue, srcValue) => { + if (Array.isArray(srcValue)) { + return objValue = srcValue; + } + }); + } + const rec = this.grid.dataView[this.index]; + return this._data ? this._data : this.grid.isTreeRow(rec) ? rec.data : rec; + } + + /** + * Returns the child rows. + */ + public get children(): RowType[] { + const children: IgxTreeGridRow[] = []; + if (this.treeRow.expanded) { + this.treeRow.children.forEach((rec, i) => { + const row = new IgxTreeGridRow(this.grid, this.index + 1 + i, rec.data); + children.push(row); + }); + } + return children; + } + + /** + * Returns the parent row. + */ + public get parent(): RowType { + const row = this.grid.getRowByKey(this.treeRow.parent?.key); + return row; + } + + /** + * Returns true if child rows exist. Always return false for IgxGridRow. + */ + public override get hasChildren(): boolean { + if (this.treeRow.children) { + return this.treeRow.children.length > 0; + } else { + return false; + } + } + + /** + * The `ITreeGridRecord` with metadata about the row in the context of the tree grid. + * + * ```typescript + * const rowParent = this.treeGrid.getRowByKey(1).treeRow.parent; + * ``` + */ + public get treeRow(): ITreeGridRecord { + return this._treeRow ?? this.grid.records.get(this.key); + } + + /** + * Gets whether the row is pinned. + * + * ```typescript + * let isPinned = row.pinned; + * ``` + */ + public override get pinned(): boolean { + return this.grid.isRecordPinned(this); + } + + /** + * Sets whether the row is pinned. + * Default value is `false`. + * ```typescript + * row.pinned = !row.pinned; + * ``` + */ + public override set pinned(val: boolean) { + if (val) { + this.pin(); + } else { + this.unpin(); + } + } + + /** + * Gets whether the row is expanded. + * + * ```typescript + * let esExpanded = row.expanded; + * ``` + */ + public override get expanded(): boolean { + return this.grid.gridAPI.get_row_expansion_state(this.treeRow); + } + + /** + * Expands/collapses the row. + * + * ```typescript + * row.expanded = true; + * ``` + */ + public override set expanded(val: boolean) { + this.grid.gridAPI.set_row_expansion_state(this.key, val); + } + + public override get disabled(): boolean { + // TODO cell + return this.grid.isGhostRecord(this.data) ? this.treeRow.isFilteredOutParent === undefined : false; + } + + private getRootParent(row: ITreeGridRecord): ITreeGridRecord { + while (row.parent) { + row = row.parent; + } + return row; + } +} + +export class IgxHierarchicalGridRow extends BaseRow implements RowType { + /** + * @hidden + */ + constructor( + public override grid: GridType, + public override index: number, data?: any + ) { + super(); + this._data = data && data.addRow && data.recordRef ? data.recordRef : data; + } + + /** + * Returns true if row islands exist. + */ + public override get hasChildren(): boolean { + return !!this.grid.childLayoutKeys.length; + } + + /** + * Returns the view index calculated per the grid page. + */ + public override get viewIndex() { + const firstRowInd = this.grid.filteredSortedData.indexOf(this.grid.dataView[0]); + const expandedRows = this.grid.filteredSortedData.filter((rec, ind) => { + const rowID = this.grid.primaryKey ? rec[this.grid.primaryKey] : rec; + return this.grid.expansionStates.get(rowID) && ind < firstRowInd; + }); + return firstRowInd + expandedRows.length + this.index; + } + + /** + * Gets the rendered cells in the row component. + */ + public override get cells(): CellType[] { + const res: CellType[] = []; + this.grid.columns.forEach(col => { + const cell: CellType = new IgxGridCell(this.grid, this.index, col); + res.push(cell); + }); + return res; + } +} + +export class IgxGroupByRow implements RowType { + /** + * Returns the row index. + */ + public index: number; + + /** + * The grid that contains the row. + */ + public grid: GridType; + + /** + * Returns always true, because this is in instance of an IgxGroupByRow. + */ + public isGroupByRow: boolean; + + /** + * The IGroupByRecord object, representing the group record, if the row is a GroupByRow. + */ + public get groupRow(): IGroupByRecord { + return this._groupRow ? this._groupRow : this.grid.dataView[this.index]; + } + + /** + * Returns the child rows. + */ + public get children(): RowType[] { + const children: IgxGridRow[] = []; + this.groupRow.records.forEach((rec, i) => { + const row = new IgxGridRow(this.grid, this.index + 1 + i, rec); + children.push(row); + }); + return children; + } + + /** + * Returns the view index calculated per the grid page. + */ + public get viewIndex(): number { + if (this.grid.page) { + const precedingDetailRows = []; + const precedingGroupRows = []; + const firstRow = this.grid.dataView[0]; + const hasDetailRows = this.grid.expansionStates.size; + const hasGroupedRows = this.grid.groupingExpressions.length; + let precedingSummaryRows = 0; + const firstRowInd = this.grid.groupingFlatResult.indexOf(firstRow); + + // from groupingFlatResult, resolve two other collections: + // precedingGroupedRows -> use it to resolve summaryRow for each group in previous pages + // precedingDetailRows -> ise it to resolve the detail row for each expanded grid row in previous pages + if (hasDetailRows || hasGroupedRows) { + this.grid.groupingFlatResult.forEach((r, ind) => { + const rowID = this.grid.primaryKey ? r[this.grid.primaryKey] : r; + if (hasGroupedRows && ind < firstRowInd && this.grid.isGroupByRecord(r)) { + precedingGroupRows.push(r); + } + if (this.grid.expansionStates.get(rowID) && ind < firstRowInd && !this.grid.isGroupByRecord(r)) { + precedingDetailRows.push(r); + } + }); + } + + if (this.grid.summaryCalculationMode !== GridSummaryCalculationMode.rootLevelOnly) { + // if firstRow is a child of the last item in precedingGroupRows, + // then summaryRow for this given groupedRecord is rendered after firstRow, + // i.e. need to decrease firstRowInd to account for the above. + precedingSummaryRows = precedingGroupRows.filter(gr => this.grid.isExpandedGroup(gr)).length; + if (this.grid.summaryPosition === GridSummaryPosition.bottom && precedingGroupRows.length && + precedingGroupRows[precedingGroupRows.length - 1].records.indexOf(firstRow) > -1) { + precedingSummaryRows += -1; + } + } + + return precedingDetailRows.length + precedingSummaryRows + firstRowInd + this.index; + } else { + return this.index; + } + } + + /** + * @hidden + */ + constructor(grid: GridType, index: number, private _groupRow?: IGroupByRecord) { + this.grid = grid; + this.index = index; + this.isGroupByRow = true; + } + + /** + * Gets whether the row is selected. + * Default value is `false`. + * ```typescript + * row.selected = true; + * ``` + */ + public get selected(): boolean { + return this.children.every(row => row.selected); + } + + /** + * Sets whether the row is selected. + * Default value is `false`. + * ```typescript + * row.selected = !row.selected; + * ``` + */ + public set selected(val: boolean) { + if (val) { + this.children.forEach(row => { + this.grid.selectionService.selectRowsWithNoEvent([row.key]); + }); + } else { + this.children.forEach(row => { + this.grid.selectionService.deselectRowsWithNoEvent([row.key]); + }); + } + this.grid.cdr.markForCheck(); + } + + /** + * Gets/sets whether the group row is expanded. + * ```typescript + * const groupRowExpanded = groupRow.expanded; + * ``` + */ + public get expanded(): boolean { + return this.grid.isExpandedGroup(this.groupRow); + } + + public set expanded(value: boolean) { + this.gridAPI.set_grouprow_expansion_state(this.groupRow, value); + } + + public isActive(): boolean { + return this.grid.navigation.activeNode ? this.grid.navigation.activeNode.row === this.index : false; + } + + /** + * Toggles the group row expanded/collapsed state. + * ```typescript + * groupRow.toggle() + * ``` + */ + public toggle(): void { + this.grid.toggleGroup(this.groupRow); + } + + private get gridAPI(): GridServiceType { + return this.grid.gridAPI as GridServiceType; + } +} + +export class IgxSummaryRow implements RowType { + /** + * Returns the row index. + */ + public index: number; + + /** + * The grid that contains the row. + */ + public grid: GridType; + + /** + * Returns always true, because this is in instance of an IgxGroupByRow. + */ + public isSummaryRow: boolean; + + /** + * The IGroupByRecord object, representing the group record, if the row is a GroupByRow. + */ + public get summaries(): Map { + return this._summaries ? this._summaries : this.grid.dataView[this.index].summaries; + } + + /** + * Returns the view index calculated per the grid page. + */ + public get viewIndex(): number { + if (this.grid.hasSummarizedColumns && this.grid.page > 0) { + if (this.grid.type === 'flat') { + if (this.grid.page) { + const precedingDetailRows = []; + const precedingGroupRows = []; + const firstRow = this.grid.dataView[0]; + const hasDetailRows = this.grid.expansionStates.size; + const hasGroupedRows = this.grid.groupingExpressions.length; + let precedingSummaryRows = 0; + const firstRowInd = this.grid.groupingFlatResult.indexOf(firstRow); + + // from groupingFlatResult, resolve two other collections: + // precedingGroupedRows -> use it to resolve summaryRow for each group in previous pages + // precedingDetailRows -> ise it to resolve the detail row for each expanded grid row in previous pages + if (hasDetailRows || hasGroupedRows) { + this.grid.groupingFlatResult.forEach((r, ind) => { + const rowID = this.grid.primaryKey ? r[this.grid.primaryKey] : r; + if (hasGroupedRows && ind < firstRowInd && this.grid.isGroupByRecord(r)) { + precedingGroupRows.push(r); + } + if (this.grid.expansionStates.get(rowID) && ind < firstRowInd && + !this.grid.isGroupByRecord(r)) { + precedingDetailRows.push(r); + } + }); + } + + if (this.grid.summaryCalculationMode !== GridSummaryCalculationMode.rootLevelOnly) { + // if firstRow is a child of the last item in precedingGroupRows, + // then summaryRow for this given groupedRecord is rendered after firstRow, + // i.e. need to decrease firstRowInd to account for the above. + precedingSummaryRows = precedingGroupRows.filter(gr => this.grid.isExpandedGroup(gr)).length; + if (this.grid.summaryPosition === GridSummaryPosition.bottom && precedingGroupRows.length && + precedingGroupRows[precedingGroupRows.length - 1].records.indexOf(firstRow) > -1) { + precedingSummaryRows += -1; + } + } + + return precedingDetailRows.length + precedingSummaryRows + firstRowInd + this.index; + } else { + return this.index; + } + } else if (this.grid.type === 'tree') { + if (this.grid.summaryCalculationMode !== GridSummaryCalculationMode.rootLevelOnly) { + const firstRowIndex = this.grid.processedExpandedFlatData.indexOf(this.grid.dataView[0].data); + const precedingSummaryRows = this.grid.summaryPosition === GridSummaryPosition.bottom ? + this.grid.rootRecords.indexOf(this.getRootParent(this.grid.dataView[0])) : + this.grid.rootRecords.indexOf(this.getRootParent(this.grid.dataView[0])) + 1; + return firstRowIndex + precedingSummaryRows + this.index; + } + } + } + + return this.index + this.grid.page * this.grid.perPage; + } + + /** + * @hidden + */ + constructor( + grid: GridType, + index: number, private _summaries?: Map, + ) { + this.grid = grid; + this.index = index; + this.isSummaryRow = true; + } + + private getRootParent(row: ITreeGridRecord): ITreeGridRecord { + while (row.parent) { + row = row.parent; + } + return row; + } +} diff --git a/projects/igniteui-angular/grids/core/src/grid-validation.service.ts b/projects/igniteui-angular/grids/core/src/grid-validation.service.ts new file mode 100644 index 00000000000..bb23dc85f87 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/grid-validation.service.ts @@ -0,0 +1,223 @@ +import { Injectable } from '@angular/core'; +import { FormControl, FormGroup } from '@angular/forms'; +import { columnFieldPath, type ColumnType, resolveNestedPath } from 'igniteui-angular/core'; +import { GridType, IFieldValidationState, IGridFormGroupCreatedEventArgs, IRecordValidationState, ValidationStatus } from './common/grid.interface'; + +@Injectable() +export class IgxGridValidationService { + /** + * @hidden + * @internal + */ + public grid: GridType; + private _validityStates = new Map(); + private _valid = true; + + + /** Gets whether state is valid. + */ + public get valid(): boolean { + return this._valid; + } + + /** + * @hidden + * @internal + */ + public create(rowId, data) { + let formGroup = this.getFormGroup(rowId); + if (!formGroup) { + formGroup = new FormGroup({}); + for (const col of this.grid.columns) { + this.addFormControl(formGroup, data, col); + } + const args: IGridFormGroupCreatedEventArgs = { + formGroup, + owner: this.grid + }; + this.grid.formGroupCreated.emit(args); + formGroup.patchValue(data); + this.add(rowId, formGroup); + } else { + // reset to pristine. + for (const col of this.grid.columns) { + const formControl = formGroup.get(col.field); + if (formControl) { + formControl.markAsPristine(); + } else { + this.addFormControl(formGroup, data, col); + } + } + } + + return formGroup; + } + + /** + * @hidden + * @internal + */ + private addFormControl(formGroup: FormGroup, data: any, column: ColumnType) { + const value = resolveNestedPath(data || {}, columnFieldPath(column.field)); + const control = new FormControl(value, { updateOn: this.grid.validationTrigger }); + control.addValidators(column.validators); + formGroup.addControl(column.field, control); + control.setValue(value); + } + + /** + * @hidden + * @internal + Wraps the provided path into an array. This way FormGroup.get will return proper result. + Otherwise, if the path is a string (e.g. 'address.street'), FormGroup.get will treat it as there is a nested structure + and will look for control with a name of 'address' which returns undefined. + */ + private getFormControlPath(path: string): (string)[] { + return [path]; + } + + /** + * @hidden + * @internal + */ + public getFormGroup(id: any) { + return this._validityStates.get(id); + } + + /** + * @hidden + * @internal + */ + public getFormControl(rowId: any, columnKey: string) { + const formControl = this.getFormGroup(rowId); + const path = this.getFormControlPath(columnKey); + return formControl?.get(path); + } + + /** + * @hidden + * @internal + */ + public add(rowId: any, form: FormGroup) { + this._validityStates.set(rowId, form); + } + + /** + * Checks the validity of the native ngControl + */ + public isFieldInvalid(formGroup: FormGroup, fieldName: string): boolean { + const path = this.getFormControlPath(fieldName); + return formGroup.get(path)?.invalid && formGroup.get(path)?.touched; + } + + /** + * Checks the validity of the native ngControl after edit + */ + public isFieldValidAfterEdit(formGroup: FormGroup, fieldName: string): boolean { + const path = this.getFormControlPath(fieldName); + return !formGroup.get(path)?.invalid && formGroup.get(path)?.dirty; + } + + /** + * @hidden + * @internal + */ + private getValidity(): IRecordValidationState[] { + const states: IRecordValidationState[] = []; + this._validityStates.forEach((formGroup, key) => { + const state: IFieldValidationState[] = []; + for (const col of this.grid.columns) { + const path = this.getFormControlPath(col.field); + const control = formGroup.get(path); + if (control) { + state.push({ field: col.field, status: control.status as ValidationStatus, errors: control.errors }) + } + } + states.push({ key: key, status: formGroup.status as ValidationStatus, fields: state, errors: formGroup.errors }); + }); + return states; + } + + /** + * Returns all invalid record states. + */ + public getInvalid(): IRecordValidationState[] { + const validity = this.getValidity(); + return validity.filter(x => x.status === 'INVALID'); + } + + /** + * @hidden + * @internal + */ + public update(rowId: any, rowData: any) { + if (!rowData) return; + const keys = Object.keys(rowData); + const rowGroup = this.getFormGroup(rowId); + for (const key of keys) { + const path = this.getFormControlPath(key); + const control = rowGroup?.get(path); + if (control && control.value !== rowData[key]) { + control.setValue(rowData[key], { emitEvent: false }); + } + } + + this.updateStatus(); + } + + /** + * @hidden + * @internal + * Update validity based on new data. + */ + public updateAll(newData: any) { + if (!newData || this._validityStates.size === 0) return; + for (const rec of newData) { + const rowId = rec[this.grid.primaryKey] || rec; + if (this.getFormGroup(rowId)) { + const recAggregatedData = this.grid.transactions.getAggregatedValue(rowId, true) || rec; + this.update(rowId, recAggregatedData); + } + } + } + + /** Marks the associated record or field as touched. + * @param key The id of the record that will be marked as touched. + * @param field Optional. The field from the record that will be marked as touched. If not provided all fields will be touched. + */ + public markAsTouched(key: any, field?: string) { + const rowGroup = this.getFormGroup(key); + if (!rowGroup) return; + rowGroup.markAsTouched(); + const fields = field ? [field] : this.grid.columns.map(x => x.field); + for (const currField of fields) { + const path = this.getFormControlPath(currField); + rowGroup?.get(path)?.markAsTouched(); + } + } + + /** + * @hidden + * @internal + */ + private updateStatus() { + const currentValid = this.valid; + this._valid = this.getInvalid().length === 0; + if (this.valid !== currentValid) { + this.grid.validationStatusChange.emit({ status: this.valid ? 'VALID' : 'INVALID', owner: this.grid }); + } + } + + /** Clears validation state by key or all states if none is provided. + * @param key Optional. The key of the record for which to clear state. + */ + public clear(key?: any) { + if (key !== undefined) { + this._validityStates.delete(key); + } else { + this._validityStates.clear(); + } + this.updateStatus(); + } + +} diff --git a/projects/igniteui-angular/grids/core/src/grid.common.ts b/projects/igniteui-angular/grids/core/src/grid.common.ts new file mode 100644 index 00000000000..a9fe7b7f59c --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/grid.common.ts @@ -0,0 +1,52 @@ +import { Directive } from '@angular/core'; +import { ConnectedPositioningStrategy } from 'igniteui-angular/core'; +import { VerticalAlignment, PositionSettings, Point } from 'igniteui-angular/core'; +import { IgxForOfSyncService } from 'igniteui-angular/directives'; +import { scaleInVerBottom, scaleInVerTop } from 'igniteui-angular/animations'; + + +@Directive({ + selector: '[igxGridBody]', + providers: [IgxForOfSyncService], + standalone: true +}) +export class IgxGridBodyDirective {} + + +/** + * @hidden + */ +export interface RowEditPositionSettings extends PositionSettings { + container?: HTMLElement; +} + +/** + * @hidden + */ +export class RowEditPositionStrategy extends ConnectedPositioningStrategy { + public isTop = false; + public isTopInitialPosition = null; + public override settings: RowEditPositionSettings; + public override position(contentElement: HTMLElement, size: { width: number; height: number }, document?: Document, initialCall?: boolean, + target?: Point | HTMLElement): void { + const container = this.settings.container; // grid.tbody + const targetElement: HTMLElement = target as HTMLElement; // current grid.row + + // Position of the overlay depends on the available space in the grid. + // If the bottom space is not enough then the the row overlay will show at the top of the row. + // Once shown, either top or bottom, then this position stays until the overlay is closed (isTopInitialPosition property), + // which means that when scrolling then overlay may hide, while the row is still visible (UX requirement). + this.isTop = this.isTopInitialPosition !== null ? + this.isTopInitialPosition : + container.getBoundingClientRect().bottom < + targetElement.getBoundingClientRect().bottom + contentElement.getBoundingClientRect().height; + + // Set width of the row editing overlay to equal row width, otherwise it fits 100% of the grid. + contentElement.style.width = targetElement.clientWidth + 'px'; + this.settings.verticalStartPoint = this.settings.verticalDirection = this.isTop ? VerticalAlignment.Top : VerticalAlignment.Bottom; + this.settings.openAnimation = this.isTop ? scaleInVerBottom : scaleInVerTop; + + super.position(contentElement, { width: targetElement.clientWidth, height: targetElement.clientHeight }, + document, initialCall, targetElement); + } +} diff --git a/projects/igniteui-angular/grids/core/src/grid.directives.ts b/projects/igniteui-angular/grids/core/src/grid.directives.ts new file mode 100644 index 00000000000..04b402b3eb4 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/grid.directives.ts @@ -0,0 +1,241 @@ +import { Directive, HostBinding, TemplateRef, inject } from '@angular/core'; +import { IgxDropDirective } from 'igniteui-angular/directives'; +import { IgxColumnMovingDragDirective } from './moving/moving.drag.directive'; +import { IgxGroupByAreaDirective } from './grouping/group-by-area.directive'; +import { + IgxGridMasterDetailContext, + IgxGroupByRowTemplateContext, + IgxGridHeaderTemplateContext, + IgxGridRowTemplateContext, + IgxGridTemplateContext +} from './common/grid.interface'; +import { ColumnType } from 'igniteui-angular/core'; + +/** + * @hidden + */ +@Directive({ + selector: '[igxGroupByRow]', + standalone: true +}) +export class IgxGroupByRowTemplateDirective { + public template = inject(TemplateRef) + public static ngTemplateContextGuard(_dir: IgxGroupByRowTemplateDirective, + ctx: unknown): ctx is IgxGroupByRowTemplateContext { + return true + } +} + +/** + * @hidden + */ +@Directive({ + selector: '[igxGridDetail]', + standalone: true +}) +export class IgxGridDetailTemplateDirective { + public static ngTemplateContextGuard(_dir: IgxGridDetailTemplateDirective, + ctx: unknown): ctx is IgxGridMasterDetailContext { + return true + } +} + +/** + * @hidden + */ +@Directive({ + selector: '[igxRowExpandedIndicator]', + standalone: true +}) +export class IgxRowExpandedIndicatorDirective { + public static ngTemplateContextGuard(_directive: IgxRowExpandedIndicatorDirective, + context: unknown): context is IgxGridRowTemplateContext { + return true + } +} + +/** + * @hidden + */ +@Directive({ + selector: '[igxRowCollapsedIndicator]', + standalone: true +}) +export class IgxRowCollapsedIndicatorDirective { + public static ngTemplateContextGuard(_directive: IgxRowCollapsedIndicatorDirective, + context: unknown): context is IgxGridRowTemplateContext { + return true + } +} + + +/** + * @hidden + */ +@Directive({ + selector: '[igxHeaderExpandedIndicator]', + standalone: true +}) +export class IgxHeaderExpandedIndicatorDirective { + public static ngTemplateContextGuard(_directive: IgxHeaderExpandedIndicatorDirective, + context: unknown): context is IgxGridTemplateContext { + return true + } +} + +/** + * @hidden + */ +@Directive({ + selector: '[igxHeaderCollapsedIndicator]', + standalone: true +}) +export class IgxHeaderCollapsedIndicatorDirective { + public static ngTemplateContextGuard(_directive: IgxHeaderCollapsedIndicatorDirective, + context: unknown): context is IgxGridTemplateContext { + return true + } +} + +/** + * @hidden + */ +@Directive({ + selector: '[igxExcelStyleHeaderIcon]', + standalone: true +}) +export class IgxExcelStyleHeaderIconDirective { + public static ngTemplateContextGuard(_directive: IgxExcelStyleHeaderIconDirective, + context: unknown): context is IgxGridHeaderTemplateContext { + return true + } +} + +/** + * @hidden + */ +@Directive({ + selector: '[igxSortHeaderIcon]', + standalone: true +}) +export class IgxSortHeaderIconDirective { + public static ngTemplateContextGuard(_directive: IgxSortHeaderIconDirective, + context: unknown): context is IgxGridHeaderTemplateContext { + return true + } +} + +/** + * @hidden + */ +@Directive({ + selector: '[igxSortAscendingHeaderIcon]', + standalone: true +}) +export class IgxSortAscendingHeaderIconDirective { + public static ngTemplateContextGuard(_directive: IgxSortAscendingHeaderIconDirective, + context: unknown): context is IgxGridHeaderTemplateContext { + return true + } +} + +/** + * @hidden + */ +@Directive({ + selector: '[igxSortDescendingHeaderIcon]', + standalone: true +}) +export class IgxSortDescendingHeaderIconDirective { + public static ngTemplateContextGuard(_directive: IgxSortDescendingHeaderIconDirective, + context: unknown): context is IgxGridHeaderTemplateContext { + return true + } +} + +/** @hidden */ +@Directive({ + selector: '[igxGridLoading]', + standalone: true +}) +export class IgxGridLoadingTemplateDirective { + public static ngTemplateContextGuard(_directive: IgxGridLoadingTemplateDirective, + context: unknown): context is IgxGridTemplateContext { + return true + } +} + +/** @hidden */ +@Directive({ + selector: '[igxGridEmpty]', + standalone: true +}) +export class IgxGridEmptyTemplateDirective { + public static ngTemplateContextGuard(_directive: IgxGridEmptyTemplateDirective, + context: unknown): context is IgxGridTemplateContext { + return true + } +} + +/** + * @hidden + */ +@Directive({ + selector: '[igxGroupAreaDrop]', + standalone: true +}) +export class IgxGroupAreaDropDirective extends IgxDropDirective { + private groupArea = inject(IgxGroupByAreaDirective); + + @HostBinding('class.igx-drop-area--hover') + public hovered = false; + + public override onDragEnter(event) { + const drag: IgxColumnMovingDragDirective = event.detail.owner; + const column: ColumnType = drag.column; + if (!this.columnBelongsToGrid(column)) { + return; + } + + const isGrouped = this.groupArea.expressions + ? this.groupArea.expressions.findIndex((item) => item.fieldName === column.field) !== -1 + : false; + if (column.groupable && !isGrouped && !column.columnGroup && !!column.field) { + drag.icon.innerText = 'group_work'; + this.hovered = true; + } else { + drag.icon.innerText = 'block'; + this.hovered = false; + } + } + + public override onDragLeave(event) { + const drag: IgxColumnMovingDragDirective = event.detail.owner; + const column: ColumnType = drag.column; + if (!this.columnBelongsToGrid(column)) { + return; + } + event.detail.owner.icon.innerText = 'block'; + this.hovered = false; + } + + private closestParentByAttr(elem, attr) { + return elem.hasAttribute(attr) ? + elem : + this.closestParentByAttr(elem.parentElement, attr); + } + + private columnBelongsToGrid(column: ColumnType) { + const elem = this.element.nativeElement; + const closestGridID = this.closestParentByAttr(elem, 'igxGroupAreaDrop').getAttribute('gridId'); + if (!column) { + return false; + } else { + const grid = column.grid; + if (!grid || grid.id !== closestGridID) { + return false; + } + return true; + } + } +} diff --git a/projects/igniteui-angular/grids/core/src/grid.rowEdit.directive.ts b/projects/igniteui-angular/grids/core/src/grid.rowEdit.directive.ts new file mode 100644 index 00000000000..a359a7c4c53 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/grid.rowEdit.directive.ts @@ -0,0 +1,103 @@ +import { Directive, ElementRef, HostListener, inject } from '@angular/core'; +import { IgxGridEmptyTemplateContext, IgxGridRowEditActionsTemplateContext, IgxGridRowEditTemplateContext, IgxGridRowEditTextTemplateContext, IGX_GRID_BASE } from './common/grid.interface'; + +/** @hidden @internal */ +@Directive({ + selector: '[igxRowEdit]', + standalone: true +}) +export class IgxRowEditTemplateDirective { + public static ngTemplateContextGuard(_directive: IgxRowEditTemplateDirective, + context: unknown): context is IgxGridRowEditTemplateContext { + return true; + } + } + +/** @hidden @internal */ +@Directive({ + selector: '[igxRowEditText]', + standalone: true +}) +export class IgxRowEditTextDirective { + public static ngTemplateContextGuard(_directive: IgxRowEditTextDirective, + context: unknown): context is IgxGridRowEditTextTemplateContext { + return true; + } + } + +/** @hidden @internal */ +@Directive({ + selector: '[igxRowAddText]', + standalone: true +}) +export class IgxRowAddTextDirective { + public static ngTemplateContextGuard(_directive: IgxRowAddTextDirective, + context: unknown): context is IgxGridEmptyTemplateContext { + return true; + } + } + +/** @hidden @internal */ +@Directive({ + selector: '[igxRowEditActions]', + standalone: true +}) +export class IgxRowEditActionsDirective { + public static ngTemplateContextGuard(_directive: IgxRowEditActionsDirective, + context: unknown): context is IgxGridRowEditActionsTemplateContext { + return true; + } + } + + +// TODO: Refactor circular ref, deps and logic +/** @hidden @internal */ +@Directive({ + selector: `[igxRowEditTabStop]`, + standalone: true +}) +export class IgxRowEditTabStopDirective { + public grid = inject(IGX_GRID_BASE); + public element = inject(ElementRef); + + private currentCellIndex: number; + + @HostListener('keydown.tab', [`$event`]) + @HostListener('keydown.shift.tab', [`$event`]) + public handleTab(event: KeyboardEvent): void { + event.stopPropagation(); + if ((this.grid.rowEditTabs.last === this && !event.shiftKey) || + (this.grid.rowEditTabs.first === this && event.shiftKey) + ) { + this.move(event); + } + } + + @HostListener('keydown.escape', [`$event`]) + public handleEscape(event: KeyboardEvent): void { + this.grid.crudService.endEdit(false, event); + this.grid.tbody.nativeElement.focus(); + } + + @HostListener('keydown.enter', ['$event']) + public handleEnter(event: KeyboardEvent): void { + event.stopPropagation(); + } + + /** + * Moves focus to first/last editable cell in the editable row and put the cell in edit mode. + * If cell is out of view first scrolls to the cell + * + * @param event keyboard event containing information about whether SHIFT key was pressed + */ + private move(event: KeyboardEvent) { + event.preventDefault(); + this.currentCellIndex = event.shiftKey ? this.grid.lastEditableColumnIndex : this.grid.firstEditableColumnIndex; + this.grid.navigation.activeNode.row = this.grid.crudService.rowInEditMode.index; + this.grid.navigation.activeNode.column = this.currentCellIndex; + this.grid.navigateTo(this.grid.crudService.rowInEditMode.index, this.currentCellIndex, (obj) => { + obj.target.activate(event); + this.grid.cdr.detectChanges(); + }); + } +} diff --git a/projects/igniteui-angular/grids/core/src/grouping/grid-group-by-area.component.ts b/projects/igniteui-angular/grids/core/src/grouping/grid-group-by-area.component.ts new file mode 100644 index 00000000000..e7a59842b03 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/grouping/grid-group-by-area.component.ts @@ -0,0 +1,59 @@ +import { + Component, + Input, +} from '@angular/core'; +import { IChipsAreaReorderEventArgs, IgxChipComponent, IgxChipsAreaComponent } from 'igniteui-angular/chips'; +import { FlatGridType } from '../common/grid.interface'; +import { IgxGroupByAreaDirective, IgxGroupByMetaPipe } from './group-by-area.directive'; +import { IgxGroupAreaDropDirective } from '../grid.directives'; +import { NgTemplateOutlet } from '@angular/common'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxSuffixDirective } from 'igniteui-angular/input-group'; +import { IgxDropDirective } from 'igniteui-angular/directives'; +import { IGroupingExpression, ISortingExpression } from 'igniteui-angular/core'; + +/** + * An internal component representing the group-by drop area for the igx-grid component. + * + * @hidden @internal + */ +@Component({ + selector: 'igx-grid-group-by-area', + templateUrl: 'group-by-area.component.html', + providers: [{ provide: IgxGroupByAreaDirective, useExisting: IgxGridGroupByAreaComponent }], + imports: [IgxChipsAreaComponent, IgxChipComponent, IgxIconComponent, IgxSuffixDirective, IgxGroupAreaDropDirective, IgxDropDirective, NgTemplateOutlet, IgxGroupByMetaPipe] +}) +export class IgxGridGroupByAreaComponent extends IgxGroupByAreaDirective { + @Input() + public sortingExpressions: ISortingExpression[] = []; + + /** The parent grid containing the component. */ + @Input() + public override grid: FlatGridType; + + public handleReorder(event: IChipsAreaReorderEventArgs) { + const { chipsArray, originalEvent } = event; + const newExpressions = this.getReorderedExpressions(chipsArray); + + this.grid.groupingExpansionState = []; + this.expressions = newExpressions; + + // When reordered using keyboard navigation, we don't have `onMoveEnd` event. + if (originalEvent instanceof KeyboardEvent) { + this.grid.groupingExpressions = newExpressions; + } + } + + public handleMoveEnd() { + this.grid.groupingExpressions = this.expressions; + } + + public groupBy(expression: IGroupingExpression) { + this.grid.groupBy(expression); + } + + public clearGrouping(name: string) { + this.grid.clearGrouping(name); + } +} + diff --git a/projects/igniteui-angular/grids/core/src/grouping/group-by-area.component.html b/projects/igniteui-angular/grids/core/src/grouping/group-by-area.component.html new file mode 100644 index 00000000000..b9f9dcc62b1 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/grouping/group-by-area.component.html @@ -0,0 +1,46 @@ + + @for (expression of chipExpressions; track expression.fieldName; let last = $last) { + + {{ (expression.fieldName | igxGroupByMeta:grid:grid.groupablePipeTrigger).title }} + + + + + + + + + } +
    + +
    +
    + + + + + {{ dropAreaMessage }} + diff --git a/projects/igniteui-angular/grids/core/src/grouping/group-by-area.directive.ts b/projects/igniteui-angular/grids/core/src/grouping/group-by-area.directive.ts new file mode 100644 index 00000000000..e2beae8f486 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/grouping/group-by-area.directive.ts @@ -0,0 +1,185 @@ +import { + Directive, + ElementRef, + EventEmitter, + HostBinding, + inject, + Input, + Output, + Pipe, + PipeTransform, + QueryList, + TemplateRef, + ViewChildren +} from '@angular/core'; +import { IChipsAreaReorderEventArgs, IgxChipComponent } from 'igniteui-angular/chips'; +import { FlatGridType, GridType } from '../common/grid.interface'; +import { IgxColumnMovingDragDirective } from '../moving/moving.drag.directive'; +import { IGroupingExpression, PlatformUtil, SortingDirection } from 'igniteui-angular/core'; + +/** + * An internal component representing a base group-by drop area. + * + * @hidden @internal + */ +@Directive() +export abstract class IgxGroupByAreaDirective { + private ref = inject(ElementRef); + protected platform = inject(PlatformUtil); + + /** + * The drop area template if provided by the parent grid. + * Otherwise, uses the default internal one. + */ + @Input() + public dropAreaTemplate: TemplateRef; + + @HostBinding('class.igx-grid-grouparea') + public defaultClass = true; + + /** The parent grid containing the component. */ + @Input() + public grid: FlatGridType | GridType; + + /** + * The group-by expressions provided by the parent grid. + */ + @Input() + public get expressions(): IGroupingExpression[] { + return this._expressions; + } + + public set expressions(value: IGroupingExpression[]) { + this._expressions = value; + this.chipExpressions = this._expressions; + this.expressionsChanged(); + this.expressionsChange.emit(this._expressions); + } + + /** + * The default message for the default drop area template. + * Obviously, if another template is provided, this is ignored. + */ + @Input() + public get dropAreaMessage(): string { + return this._dropAreaMessage ?? this.grid.resourceStrings.igx_grid_groupByArea_message; + } + + public set dropAreaMessage(value: string) { + this._dropAreaMessage = value; + } + + @Output() + public expressionsChange = new EventEmitter(); + + @ViewChildren(IgxChipComponent) + public chips: QueryList; + + public chipExpressions: IGroupingExpression[]; + + /** The native DOM element. Used in sizing calculations. */ + public get nativeElement() { + return this.ref.nativeElement; + } + + private _expressions: IGroupingExpression[] = []; + private _dropAreaMessage: string; + + public get dropAreaVisible(): boolean { + return (this.grid.columnInDrag && this.grid.columnInDrag.groupable) || + !this.expressions.length; + } + + public handleKeyDown(id: string, event: KeyboardEvent) { + if (this.platform.isActivationKey(event)) { + this.updateGroupSorting(id); + } + } + + public handleClick(id: string) { + if (!this.grid.getColumnByName(id).groupable) { + return; + } + this.updateGroupSorting(id); + } + + public onDragDrop(event) { + const drag: IgxColumnMovingDragDirective = event.detail.owner; + if (drag instanceof IgxColumnMovingDragDirective) { + const column = drag.column; + if (!this.grid.columns.find(c => c === column)) { + return; + } + + const isGrouped = this.expressions.findIndex((item) => item.fieldName === column.field) !== -1; + if (column.groupable && !isGrouped && !column.columnGroup && !!column.field) { + const groupingExpression = { + fieldName: column.field, + dir: this.grid.sortingExpressions.find(expr => expr.fieldName === column.field)?.dir || SortingDirection.Asc, + ignoreCase: column.sortingIgnoreCase, + strategy: column.sortStrategy, + groupingComparer: column.groupingComparer + }; + + this.groupBy(groupingExpression); + } + } + } + + protected getReorderedExpressions(chipsArray: IgxChipComponent[]) { + const newExpressions = []; + + chipsArray.forEach(chip => { + const expr = this.expressions.find(item => item.fieldName === chip.id); + + // disallow changing order if there are columns with groupable: false + if (!this.grid.getColumnByName(expr.fieldName)?.groupable) { + return; + } + + newExpressions.push(expr); + }); + + return newExpressions; + } + + protected updateGroupSorting(id: string) { + const expr = this.expressions.find(e => e.fieldName === id); + expr.dir = 3 - expr.dir; + const expressionsChangeEvent = this.grid.groupingExpressionsChange || this.expressionsChange; + expressionsChangeEvent.emit(this.expressions); + this.grid.pipeTrigger++; + this.grid.notifyChanges(); + } + + protected expressionsChanged() { + } + + public abstract handleReorder(event: IChipsAreaReorderEventArgs); + + public abstract handleMoveEnd(); + + public abstract groupBy(expression: IGroupingExpression); + + public abstract clearGrouping(name: string); + +} + +/** + * A pipe to circumvent the use of getters/methods just to get some additional + * information from the grouping expression and pass it to the chip representing + * that expression. + * + * @hidden @internal + */ +@Pipe({ + name: 'igxGroupByMeta', + standalone: true +}) +export class IgxGroupByMetaPipe implements PipeTransform { + + public transform(key: string, grid: GridType, _pipeTrigger?: number) { + const column = grid.getColumnByName(key); + return { groupable: !!column?.groupable, title: column?.header || key }; + } +} diff --git a/projects/igniteui-angular/grids/core/src/headers/grid-header-group.component.html b/projects/igniteui-angular/grids/core/src/headers/grid-header-group.component.html new file mode 100644 index 00000000000..88e66e7512f --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/headers/grid-header-group.component.html @@ -0,0 +1,132 @@ +@if (grid.hasColumnLayouts && column.columnGroup) { + @if (grid.moving) { + + } +
    + @for (child of column.children; track child) { + @if (!child.hidden) { + + + } + } +
    + @if (grid.moving) { + + } +} + + + + {{column.header}} + + + + + + + +@if (!grid.hasColumnLayouts && column.columnGroup) { + @if (grid.moving) { + + } +
    + @if (column.collapsible) { +
    + + +
    + } + + +
    + @if (grid.type !== 'pivot') { +
    + @for (child of column.children; track child) { + @if (!child.hidden) { + + + } + } +
    + } + @if (grid.moving) { + + } +} + +@if (!column.columnGroup) { + @if (grid.moving) { + + } + + + @if (grid.allowFiltering && grid.filterMode === 'quickFilter') { + + } + @if (!column.columnGroup && column.resizable) { + + + } + @if (grid.moving) { + + } +} diff --git a/projects/igniteui-angular/grids/core/src/headers/grid-header-group.component.ts b/projects/igniteui-angular/grids/core/src/headers/grid-header-group.component.ts new file mode 100644 index 00000000000..269708993f7 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/headers/grid-header-group.component.ts @@ -0,0 +1,356 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DoCheck, + ElementRef, + forwardRef, + HostBinding, + HostListener, + inject, + Input, + QueryList, + ViewChild, + ViewChildren +} from '@angular/core'; +import { IgxFilteringService } from '../filtering/grid-filtering.service'; +import { IgxColumnResizingService } from '../resizing/resizing.service'; +import { IgxGridHeaderComponent } from './grid-header.component'; +import { IgxGridFilteringCellComponent } from '../filtering/base/grid-filtering-cell.component'; +import { GridType, IGX_GRID_BASE } from '../common/grid.interface'; +import { GridSelectionMode } from '../common/enums'; +import { IgxHeaderGroupStylePipe } from './pipes'; +import { IgxResizeHandleDirective } from '../resizing/resize-handle.directive'; +import { IgxColumnMovingDropDirective } from '../moving/moving.drop.directive'; +import { IgxColumnMovingDragDirective } from '../moving/moving.drag.directive'; +import { NgClass, NgStyle, NgTemplateOutlet } from '@angular/common'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { ColumnType, PlatformUtil } from 'igniteui-angular/core'; + +const Z_INDEX = 9999; + +/** + * @hidden + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'igx-grid-header-group', + templateUrl: './grid-header-group.component.html', + imports: [NgClass, NgStyle, IgxColumnMovingDragDirective, IgxColumnMovingDropDirective, IgxIconComponent, NgTemplateOutlet, IgxGridHeaderComponent, IgxGridFilteringCellComponent, IgxResizeHandleDirective, IgxHeaderGroupStylePipe] +}) +export class IgxGridHeaderGroupComponent implements DoCheck { + + private cdr = inject(ChangeDetectorRef); + public grid = inject(IGX_GRID_BASE); + private ref = inject>(ElementRef); + public colResizingService = inject(IgxColumnResizingService); + public filteringService = inject(IgxFilteringService); + protected platform = inject(PlatformUtil); + + + @HostBinding('style.grid-row-end') + public get rowEnd(): number { + return this.column.rowEnd; + } + + @HostBinding('style.grid-column-end') + public get colEnd(): number { + return this.column.colEnd; + } + + @HostBinding('style.grid-row-start') + public get rowStart(): number { + return this.column.rowStart; + } + + @HostBinding('style.grid-column-start') + public get colStart(): number { + return this.column.colStart; + } + + @HostBinding('class.igx-grid-th--pinned') + public get pinnedCss() { + return this.column.pinned; + } + + public get headerID() { + return `${this.grid.id}_-1_${this.column.level}_${this.column.visibleIndex}`; + } + + /** + * Gets the column of the header group. + * + * @memberof IgxGridHeaderGroupComponent + */ + @Input() + public column: ColumnType; + + @HostBinding('class.igx-grid-th--active') + public get active() { + const node = this.grid.navigation.activeNode; + return node && !this.column.columnGroup ? + node.row === -1 && node.column === this.column.visibleIndex && node.level === this.column.level : false; + } + + public get activeGroup() { + const node = this.grid.navigation.activeNode; + return node ? node.row === -1 && node.column === this.column.visibleIndex && node.level === this.column.level : false; + } + + /** + * @hidden + */ + @ViewChild(IgxGridHeaderComponent) + public header: IgxGridHeaderComponent; + + /** + * @hidden + */ + @ViewChild(IgxGridFilteringCellComponent) + public filter: IgxGridFilteringCellComponent; + + /** + * @hidden + */ + @ViewChildren(forwardRef(() => IgxGridHeaderGroupComponent), { read: IgxGridHeaderGroupComponent }) + public children: QueryList; + + /** + * Gets the width of the header group. + * + * @memberof IgxGridHeaderGroupComponent + */ + public get width() { + return this.grid.getHeaderGroupWidth(this.column); + } + + @HostBinding('class.igx-grid-thead__item') + public defaultCss = true; + + @HostBinding('class.igx-grid__drag-col-header') + public get headerDragCss() { + return this.isHeaderDragged; + } + + @HostBinding('class.igx-grid-th--filtering') + public get filteringCss() { + return this.isFiltered; + } + + /** + * @hidden + */ + @HostBinding('style.z-index') + public get zIndex() { + if (!this.column.pinned) { + return null; + } + return Z_INDEX - this.grid.pinnedColumns.indexOf(this.column); + } + + /** + * @hidden + */ + public get ariaHidden(): boolean { + return this.grid.hasColumnGroups && (this.column.hidden || this.grid.navigation.activeNode?.row !== -1); + } + + /** + * Gets whether the header group belongs to a column that is filtered. + * + * @memberof IgxGridHeaderGroupComponent + */ + public get isFiltered(): boolean { + return this.filteringService.filteredColumn === this.column; + } + + /** + * Gets whether the header group is stored in the last column in the pinned area. + * + * @memberof IgxGridHeaderGroupComponent + */ + public get isLastPinned(): boolean { + return !this.grid.hasColumnLayouts ? this.column.isLastPinned : false; + } + + /** + * Gets whether the header group is stored in the first column of the right pinned area. + */ + public get isFirstPinned(): boolean { + return !this.grid.hasColumnLayouts ? this.column.isFirstPinned : false; + } + + @HostBinding('style.display') + public get groupDisplayStyle(): string { + return this.grid.hasColumnLayouts && this.column.children ? 'flex' : ''; + } + + /** + * Gets whether the header group is stored in a pinned column. + * + * @memberof IgxGridHeaderGroupComponent + */ + public get isPinned(): boolean { + return this.column.pinned; + } + + /** + * Gets whether the header group belongs to a column that is moved. + * + * @memberof IgxGridHeaderGroupComponent + */ + public get isHeaderDragged(): boolean { + return this.grid.columnInDrag === this.column; + } + + /** + * @hidden + */ + public get hasLastPinnedChildColumn(): boolean { + return this.column.allChildren.some(child => child.isLastPinned); + } + + /** + * @hidden + */ + public get hasFirstPinnedChildColumn(): boolean { + return this.column.allChildren.some(child => child.isFirstPinned); + } + + /** + * @hidden + */ + public get selectable() { + const selectableChildren = this.column.allChildren.filter(c => !c.hidden && c.selectable && !c.columnGroup); + return this.grid.columnSelection !== GridSelectionMode.none && + this.column.applySelectableClass + && !this.selected && selectableChildren.length > 0 + && !this.grid.filteringService.isFilterRowVisible; + } + + /** + * @hidden + */ + public get selected() { + return this.column.selected; + } + + /** + * @hidden + */ + public get height() { + return this.nativeElement.getBoundingClientRect().height; + } + + /** + * @hidden + */ + public get title() { + return this.column.title || this.column.header; + } + + public get nativeElement() { + return this.ref.nativeElement; + } + + /** + * @hidden + */ + @HostListener('mousedown', ['$event']) + public onMouseDown(event: MouseEvent): void { + if (!this.grid.allowFiltering || + (event.composedPath().findIndex(el => + (el as Element).tagName?.toLowerCase() === 'igx-grid-filtering-cell') < 1)) { + // Hack for preventing text selection in IE and Edge while dragging the resize element + event.preventDefault(); + } + } + + /** + * @hidden + */ + public groupClicked(event: MouseEvent): void { + const columnsToSelect = this.column.allChildren.filter(c => !c.hidden && c.selectable && !c.columnGroup).map(c => c.field); + if (this.grid.columnSelection !== GridSelectionMode.none + && columnsToSelect.length > 0 && !this.grid.filteringService.isFilterRowVisible) { + const clearSelection = this.grid.columnSelection === GridSelectionMode.single || !event.ctrlKey; + const rangeSelection = this.grid.columnSelection === GridSelectionMode.multiple && event.shiftKey; + if (!this.selected) { + this.grid.selectionService.selectColumns(columnsToSelect, clearSelection, rangeSelection, event); + } else { + const selectedFields = this.grid.selectionService.getSelectedColumns(); + if ((selectedFields.length === columnsToSelect.length) && selectedFields.every(el => columnsToSelect.includes(el)) + || !clearSelection) { + this.grid.selectionService.deselectColumns(columnsToSelect, event); + } else { + this.grid.selectionService.selectColumns(columnsToSelect, clearSelection, rangeSelection, event); + } + } + } + } + + /** + * @hidden @internal + */ + public onPointerDownIndicator(event) { + // Stop propagation of pointer events to now allow column dragging using the header indicators. + event.stopPropagation(); + } + + /** + * @hidden @internal + */ + public toggleExpandState(event: MouseEvent): void { + event.stopPropagation(); + this.column.expanded = !this.column.expanded; + } + + /** + * @hidden @internal + */ + public pointerdown(event: PointerEvent): void { + event.stopPropagation(); + this.activate(); + this.grid.theadRow.nativeElement.focus(); + } + + /* + * This method is necessary due to some specifics related with implementation of column moving + * @hidden + */ + public activate() { + this.grid.navigation.setActiveNode(this.activeNode); + this.grid.theadRow.nativeElement.focus(); + } + + public ngDoCheck() { + this.cdr.markForCheck(); + } + /** + * @hidden + */ + public onPinterEnter() { + this.column.applySelectableClass = true; + } + + /** + * @hidden + */ + public onPointerLeave() { + this.column.applySelectableClass = false; + } + + protected get activeNode() { + return { + row: -1, column: this.column.visibleIndex, level: this.column.level, + mchCache: { level: this.column.level, visibleIndex: this.column.visibleIndex }, + layout: this.column.columnLayoutChild ? { + rowStart: this.column.rowStart, + colStart: this.column.colStart, + rowEnd: this.column.rowEnd, + colEnd: this.column.colEnd, + columnVisibleIndex: this.column.visibleIndex + } : null + }; + } +} diff --git a/projects/igniteui-angular/grids/core/src/headers/grid-header-row.component.html b/projects/igniteui-angular/grids/core/src/headers/grid-header-row.component.html new file mode 100644 index 00000000000..261a1f203d9 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/headers/grid-header-row.component.html @@ -0,0 +1,142 @@ +
    + + +
    + + + @if (grid.moving && grid.columnInDrag && pinnedStartColumnCollection.length <= 0) { + + } + @if (grid.moving && grid.columnInDrag && pinnedStartColumnCollection.length > 0) { + + } + + + @if (grid.rowDraggable) { +
    +
    + +
    +
    + } + + + @if (grid.showRowSelectors) { +
    + + +
    + } + + + @if (isHierarchicalGrid) { +
    + +
    + } + + + + @if (grid?.groupingExpressions?.length) { +
    + +
    + } + + + @if (pinnedStartColumnCollection.length) { + @for (column of pinnedStartColumnCollection | igxTopLevel; track trackPinnedColumn(column)) { + + + } + } + + + + + + + + + @if (pinnedEndColumnCollection.length) { + @for (column of pinnedEndColumnCollection | igxTopLevel; track trackPinnedColumn(column)) { + + + } + } +
    + + + @if (grid.hasColumnGroups) { +
    + @for (column of visibleLeafColumns; track column.index) { +
    {{ column.header || column.field }}
    + } +
    + } + + + @if (grid.filteringService.isFilterRowVisible) { + + + } + + + @if (grid.moving && grid.columnInDrag) { + + } +
    + + +
    + + + +
    + + +
    +
    diff --git a/projects/igniteui-angular/grids/core/src/headers/grid-header-row.component.ts b/projects/igniteui-angular/grids/core/src/headers/grid-header-row.component.ts new file mode 100644 index 00000000000..a8fef047e5b --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/headers/grid-header-row.component.ts @@ -0,0 +1,236 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DoCheck, + ElementRef, + HostBinding, + Input, + QueryList, + TemplateRef, + ViewChild, + ViewChildren, + booleanAttribute, + inject +} from '@angular/core'; +import { GridType, IgxHeadSelectorTemplateContext } from '../common/grid.interface'; +import { IgxGridFilteringCellComponent } from '../filtering/base/grid-filtering-cell.component'; +import { IgxGridFilteringRowComponent } from '../filtering/base/grid-filtering-row.component'; +import { IgxGridHeaderGroupComponent } from './grid-header-group.component'; +import { IgxGridHeaderComponent } from './grid-header.component'; +import { IgxHeaderGroupStylePipe } from './pipes'; +import { IgxGridTopLevelColumns } from '../common/pipes'; +import { IgxColumnMovingDropDirective } from '../moving/moving.drop.directive'; +import { NgTemplateOutlet, NgClass, NgStyle } from '@angular/common'; +import { IgxGridForOfDirective } from 'igniteui-angular/directives'; +import { IgxCheckboxComponent } from 'igniteui-angular/checkbox'; +import { ColumnType, flatten, trackByIdentity } from 'igniteui-angular/core'; + +/** + * + * For all intents & purposes treat this component as what a usually is in the default element. + * + * This container holds the grid header elements and their behavior/interactions. + * + * @hidden @internal + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'igx-grid-header-row', + templateUrl: './grid-header-row.component.html', + imports: [IgxColumnMovingDropDirective, NgTemplateOutlet, NgClass, IgxGridHeaderGroupComponent, NgStyle, IgxGridForOfDirective, IgxGridFilteringRowComponent, IgxCheckboxComponent, IgxGridTopLevelColumns, IgxHeaderGroupStylePipe] +}) +export class IgxGridHeaderRowComponent implements DoCheck { + protected ref = inject>(ElementRef); + protected cdr = inject(ChangeDetectorRef); + + + /** The grid component containing this element. */ + @Input() + public grid: GridType; + + /** Pinned columns of the grid at start. */ + @Input() + public pinnedStartColumnCollection: ColumnType[] = []; + + /** Pinned columns of the grid at end. */ + @Input() + public pinnedEndColumnCollection: ColumnType[] = []; + + + /** Unpinned columns of the grid. */ + @Input() + public unpinnedColumnCollection: ColumnType[] = []; + + @HostBinding('attr.aria-activedescendant') + public get activeDescendant() { + const activeElem = this.navigation.activeNode; + + if (!activeElem || !Object.keys(activeElem).length || activeElem.row >= 0) { + return null; + } + return `${this.grid.id}_${activeElem.row}_${activeElem.level}_${activeElem.column}`; + } + + @Input({ transform: booleanAttribute }) + public hasMRL: boolean; + + @Input() + public width: number; + + /** + * Header groups inside the header row. + * + * @remarks + * Note: These are only the top level header groups in case there are multi-column headers + * or a specific column layout. If you want to get the flattened collection use the `groups` + * property below. + * + * @hidden @internal + * */ + @ViewChildren(IgxGridHeaderGroupComponent) + public _groups: QueryList; + + /** + * The flattened header groups collection. + * + * @hidden @internal + */ + public get groups(): IgxGridHeaderGroupComponent[] { + return flatten(this._groups?.toArray() ?? []); + } + + /** Header components in the header row. */ + public get headers(): IgxGridHeaderComponent[] { + return this.groups.map(group => group.header); + } + + /** Filtering cell components in the header row. */ + public get filters(): IgxGridFilteringCellComponent[] { + return this.groups.map(group => group.filter); + } + + /** + * Gets a list of all visible leaf columns in the grid. + * + * @hidden @internal + */ + public get visibleLeafColumns(): ColumnType[] { + const row = this.grid.gridAPI.get_row_by_index(this.grid.rowList.first?.index || 0); + if (row && row.cells) { + return row.cells.map(cell => cell.column); + } + } + + /** + * @hidden + * @internal + */ + public get isLeafHeaderAriaHidden(): boolean { + return this.grid.navigation.activeNode?.row === -1; + } + + /** The virtualized part of the header row containing the unpinned header groups. */ + @ViewChild('headerVirtualContainer', { read: IgxGridForOfDirective, static: true }) + public headerContainer: IgxGridForOfDirective; + + public get headerForOf() { + return this.headerContainer; + } + + @ViewChild('headerDragContainer') + public headerDragContainer: ElementRef; + + @ViewChild('headerSelectorContainer') + public headerSelectorContainer: ElementRef; + + @ViewChild('headerGroupContainer') + public headerGroupContainer: ElementRef; + + @ViewChild('headSelectorBaseTemplate') + public headSelectorBaseTemplate: TemplateRef; + + @ViewChild(IgxGridFilteringRowComponent) + public filterRow: IgxGridFilteringRowComponent; + + /** + * Expand/collapse all child grids area in a hierarchical grid. + * `undefined` in the base and tree grids. + * + * @internal @hidden + */ + @ViewChild('headerHierarchyExpander') + public headerHierarchyExpander: ElementRef; + + public get navigation() { + return this.grid.navigation; + } + + public get nativeElement() { + return this.ref.nativeElement; + } + + /** + * Returns whether the current grid instance is a hierarchical grid. + * as only hierarchical grids have the `isHierarchicalRecord` method. + * + * @hidden @internal + */ + public get isHierarchicalGrid() { + return !!this.grid.isHierarchicalRecord; + } + + public get indentationCSSClasses() { + return `igx-grid__header-indentation igx-grid__row-indentation--level-${this.grid.groupingExpressions.length}`; + } + + public get rowSelectorsContext(): IgxHeadSelectorTemplateContext { + const ctx = { + $implicit: { + selectedCount: this.grid.selectionService.filteredSelectedRowIds.length as number, + totalCount: this.grid.totalRowsCountAfterFilter as number + } + } as IgxHeadSelectorTemplateContext; + + if (this.isHierarchicalGrid) { + ctx.$implicit.selectAll = () => this.grid.selectAllRows(); + ctx.$implicit.deselectAll = () => this.grid.deselectAllRows(); + } + + return ctx; + } + + /** + * This hook exists as a workaround for the unfortunate fact + * that when we have pinned columns in the grid, the unpinned columns headers + * are affected by a delayed change detection cycle after a horizontal scroll :( + * Thus, we tell the parent grid change detector to check us at each cycle. + * + * @hidden @internal + */ + public ngDoCheck() { + this.cdr.markForCheck(); + } + + /** + * @hidden @internal + */ + public scroll(event: Event) { + this.grid.preventHeaderScroll(event); + } + + public headerRowSelection(event: MouseEvent) { + if (!this.grid.isMultiRowSelectionEnabled) { + return; + } + + if (this.grid.selectionService.areAllRowSelected()) { + this.grid.selectionService.clearRowSelection(event); + } else { + this.grid.selectionService.selectAllRows(event); + } + } + + /** state persistence switching all pinned columns resets collection */ + protected trackPinnedColumn = trackByIdentity; +} diff --git a/projects/igniteui-angular/grids/core/src/headers/grid-header.component.html b/projects/igniteui-angular/grids/core/src/headers/grid-header.component.html new file mode 100644 index 00000000000..d142eeae4b5 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/headers/grid-header.component.html @@ -0,0 +1,36 @@ + + {{ column.header || column.field }} + + + + + + + + + + + + + + + +@if (!column.columnGroup) { +
    + @if (column.sortable && !disabled) { +
    + +
    + } + @if (grid.allowFiltering && column.filterable && grid.filterMode === 'excelStyleFilter') { +
    + +
    + } +
    +} diff --git a/projects/igniteui-angular/grids/core/src/headers/grid-header.component.ts b/projects/igniteui-angular/grids/core/src/headers/grid-header.component.ts new file mode 100644 index 00000000000..9a86798f681 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/headers/grid-header.component.ts @@ -0,0 +1,354 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DoCheck, + ElementRef, + HostBinding, + HostListener, + inject, + Input, + OnDestroy, + TemplateRef, + ViewChild +} from '@angular/core'; +import { IgxColumnResizingService } from '../resizing/resizing.service'; +import { Subject } from 'rxjs'; +import { GridType, IGX_GRID_BASE } from '../common/grid.interface'; +import { GridSelectionMode } from '../common/enums'; +import { SortingIndexPipe } from './pipes'; +import { NgTemplateOutlet, NgClass } from '@angular/common'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { ColumnType, ExpressionsTreeUtil, GridColumnDataType, SortingDirection } from 'igniteui-angular/core'; + +/** + * @hidden + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'igx-grid-header', + templateUrl: 'grid-header.component.html', + imports: [IgxIconComponent, NgTemplateOutlet, NgClass, SortingIndexPipe] +}) +export class IgxGridHeaderComponent implements DoCheck, OnDestroy { + public grid = inject(IGX_GRID_BASE); + public colResizingService = inject(IgxColumnResizingService); + public cdr = inject(ChangeDetectorRef); + private ref = inject>(ElementRef); + + @Input() + public column: ColumnType; + + /** + * @hidden + */ + @ViewChild('defaultESFHeaderIconTemplate', { read: TemplateRef, static: true }) + protected defaultESFHeaderIconTemplate: TemplateRef; + + /** + * @hidden + */ + @ViewChild('defaultSortHeaderIconTemplate', { read: TemplateRef, static: true }) + protected defaultSortHeaderIconTemplate; + + /** + * @hidden + */ + @ViewChild('sortIconContainer', { read: ElementRef }) + protected sortIconContainer: ElementRef; + + @HostBinding('class.igx-grid-th--pinned') + public get pinnedCss() { + return this.isPinned; + } + + @HostBinding('class.igx-grid-th--pinned-last') + public get pinnedLastCss() { + return this.isLastPinned; + } + + @HostBinding('class.igx-grid-th--pinned-first') + public get pinnedFirstCSS() { + return this.isFirstPinned; + } + + /** + * Gets whether the header group is stored in the last column in the pinned area. + */ + public get isLastPinned(): boolean { + return !this.grid.hasColumnLayouts ? this.column.isLastPinned : false; + } + + /** + * Gets whether the header group is stored in the first column of the right pinned area. + */ + public get isFirstPinned(): boolean { + return !this.grid.hasColumnLayouts ? this.column.isFirstPinned : false; + } + + /** + * Gets whether the header group is stored in a pinned column. + * + * @memberof IgxGridHeaderGroupComponent + */ + public get isPinned(): boolean { + return this.column.pinned; + } + /** + * @hidden + */ + @Input() + @HostBinding('attr.id') + public id: string; + + /** + * Returns the `aria-selected` of the header. + */ + @HostBinding('attr.aria-selected') + public get ariaSelected(): boolean { + return this.column.selected; + } + + /** + * Returns the `aria-sort` of the header. + */ + @HostBinding('attr.aria-sort') + public get ariaSort() { + return this.sortDirection === SortingDirection.Asc ? 'ascending' + : this.sortDirection === SortingDirection.Desc ? 'descending' : null; + } + + /** + * @hidden + */ + @HostBinding('attr.aria-colindex') + public get ariaColIndx() { + return this.column.index + 1; + } + + /** + * @hidden + */ + @HostBinding('attr.aria-rowindex') + public get ariaRowIndx() { + return 1; + } + + @HostBinding('class.igx-grid-th') + public get columnGroupStyle() { + return !this.column.columnGroup; + } + + @HostBinding('class.asc') + public get sortAscendingStyle() { + return this.sortDirection === SortingDirection.Asc; + } + + @HostBinding('class.desc') + public get sortDescendingStyle() { + return this.sortDirection === SortingDirection.Desc; + } + + @HostBinding('class.igx-grid-th--number') + public get numberStyle() { + return this.column.dataType === GridColumnDataType.Number; + } + + @HostBinding('class.igx-grid-th--sortable') + public get sortableStyle() { + return this.column.sortable; + } + + @HostBinding('class.igx-grid-th--selectable') + public get selectableStyle() { + return this.selectable; + } + + @HostBinding('class.igx-grid-th--filtrable') + public get filterableStyle() { + return this.column.filterable && this.grid.filteringService.isFilterRowVisible; + } + + @HostBinding('class.igx-grid-th--sorted') + public get sortedStyle() { + return this.sorted; + } + + @HostBinding('class.igx-grid-th--selected') + public get selectedStyle() { + return this.selected; + } + + /** + * @hidden + */ + public get esfIconTemplate() { + return this.grid.excelStyleHeaderIconTemplate || this.defaultESFHeaderIconTemplate; + } + + /** + * @hidden + */ + public get sortIconTemplate() { + if (this.sortDirection === SortingDirection.None && this.grid.sortHeaderIconTemplate) { + return this.grid.sortHeaderIconTemplate; + } else if (this.sortDirection === SortingDirection.Asc && this.grid.sortAscendingHeaderIconTemplate) { + return this.grid.sortAscendingHeaderIconTemplate; + } else if (this.sortDirection === SortingDirection.Desc && this.grid.sortDescendingHeaderIconTemplate) { + return this.grid.sortDescendingHeaderIconTemplate; + } else { + return this.defaultSortHeaderIconTemplate; + } + } + /** + * @hidden + */ + public get disabled() { + const groupArea = this.grid.groupArea || this.grid.treeGroupArea; + if (groupArea?.expressions && groupArea.expressions.length && groupArea.expressions.map(g => g.fieldName).includes(this.column.field)) { + return true; + } + return false; + } + + public get sorted() { + return this.sortDirection !== SortingDirection.None; + } + + public get filterIconClassName() { + return this.column.filteringExpressionsTree || this.isAdvancedFilterApplied() ? 'igx-excel-filter__icon--filtered' : 'igx-excel-filter__icon'; + } + + public get selectable() { + return this.grid.columnSelection !== GridSelectionMode.none && + this.column.applySelectableClass && + !this.column.selected && + !this.grid.filteringService.isFilterRowVisible; + } + + public get selected() { + return this.column.selected + && (!this.grid.filteringService.isFilterRowVisible || this.grid.filteringService.filteredColumn !== this.column); + } + + public get title() { + return this.column.title || this.column.header || this.column.field; + } + + public get nativeElement() { + return this.ref.nativeElement; + } + + public sortDirection = SortingDirection.None; + protected _destroy$ = new Subject(); + + @HostListener('click', ['$event']) + public onClick(event: MouseEvent) { + if (!this.colResizingService.isColumnResizing) { + + if (this.grid.filteringService.isFilterRowVisible) { + if (this.column.filterCellTemplate) { + this.grid.filteringRow.close(); + return; + } + + if (this.column.filterable && !this.column.columnGroup && + !this.grid.filteringService.isFilterComplex(this.column.field)) { + this.grid.filteringService.filteredColumn = this.column; + } + } else if (this.grid.columnSelection !== GridSelectionMode.none && this.column.selectable) { + const clearSelection = this.grid.columnSelection === GridSelectionMode.single || !event.ctrlKey; + const rangeSelection = this.grid.columnSelection === GridSelectionMode.multiple && event.shiftKey; + + if (!this.column.selected || (this.grid.selectionService.getSelectedColumns().length > 1 && clearSelection)) { + this.grid.selectionService.selectColumn(this.column.field, clearSelection, rangeSelection, event); + } else { + this.grid.selectionService.deselectColumn(this.column.field, event); + } + } + } + this.grid.theadRow.nativeElement.focus(); + } + + /** + * @hidden + */ + @HostListener('pointerenter') + public onPinterEnter() { + this.column.applySelectableClass = true; + } + + /** + * @hidden + */ + @HostListener('pointerleave') + public onPointerLeave() { + this.column.applySelectableClass = false; + } + + /** + * @hidden @internal + */ + public ngDoCheck() { + this.getSortDirection(); + this.cdr.markForCheck(); + } + + /** + * @hidden @internal + */ + public ngOnDestroy(): void { + this._destroy$.next(true); + this._destroy$.complete(); + } + + /** + * @hidden @internal + */ + public onPointerDownIndicator(event) { + // Stop propagation of pointer events to now allow column dragging using the header indicators. + event.stopPropagation(); + } + + /** + * @hidden @internal + */ + public onFilteringIconClick(event) { + event.stopPropagation(); + this.grid.filteringService.toggleFilterDropdown(this.nativeElement, this.column); + } + + /** + * @hidden @internal + */ + public onSortingIconClick(event) { + event.stopPropagation(); + this.triggerSort(); + } + + protected getSortDirection() { + const expr = this.grid.sortingExpressions.find((x) => x.fieldName === this.column.field); + this.sortDirection = expr ? expr.dir : SortingDirection.None; + } + + protected isAdvancedFilterApplied() { + if (!this.grid.advancedFilteringExpressionsTree) { + return false; + } + return !!ExpressionsTreeUtil.find(this.grid.advancedFilteringExpressionsTree, this.column.field); + } + + private triggerSort() { + const groupingExpr = this.grid.groupingExpressions ? + this.grid.groupingExpressions.find((expr) => expr.fieldName === this.column.field) : + this.grid.groupArea?.expressions ? this.grid.groupArea?.expressions.find((expr) => expr.fieldName === this.column.field) : null; + const sortDir = groupingExpr ? + this.sortDirection + 1 > SortingDirection.Desc ? SortingDirection.Asc : SortingDirection.Desc + : this.sortDirection + 1 > SortingDirection.Desc ? SortingDirection.None : this.sortDirection + 1; + this.sortDirection = sortDir; + this.grid.sort({ + fieldName: this.column.field, dir: this.sortDirection, ignoreCase: this.column.sortingIgnoreCase, + strategy: this.column.sortStrategy + }); + } +} diff --git a/projects/igniteui-angular/grids/core/src/headers/pipes.ts b/projects/igniteui-angular/grids/core/src/headers/pipes.ts new file mode 100644 index 00000000000..8049dc5b26c --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/headers/pipes.ts @@ -0,0 +1,36 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { ColumnType, ISortingExpression } from 'igniteui-angular/core'; + + +@Pipe({ + name: 'sortingIndex', + standalone: true +}) +export class SortingIndexPipe implements PipeTransform { + public transform(columnField: string, sortingExpressions: ISortingExpression[]): number { + let sortIndex = sortingExpressions.findIndex(expression => expression.fieldName === columnField); + return sortIndex !== -1 ? ++sortIndex : null; + } +} + +@Pipe({ + name: 'igxHeaderGroupStyle', + standalone: true +}) +export class IgxHeaderGroupStylePipe implements PipeTransform { + + public transform(styles: { [prop: string]: any }, column: ColumnType, _: number): { [prop: string]: any } { + const css = {}; + + if (!styles) { + return css; + } + + for (const prop of Object.keys(styles)) { + const res = styles[prop]; + css[prop] = typeof res === 'function' ? res(column) : res; + } + + return css; + } +} diff --git a/projects/igniteui-angular/grids/core/src/headers/public_api.ts b/projects/igniteui-angular/grids/core/src/headers/public_api.ts new file mode 100644 index 00000000000..3f48fcafc90 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/headers/public_api.ts @@ -0,0 +1,15 @@ +// import { IgxGridHeaderGroupComponent } from './grid-header-group.component'; +// import { IgxGridHeaderRowComponent } from './grid-header-row.component'; +// import { IgxGridHeaderComponent } from './grid-header.component'; + +export { IgxGridHeaderComponent } from './grid-header.component'; +export { IgxGridHeaderGroupComponent } from './grid-header-group.component'; +export { IgxGridHeaderRowComponent } from './grid-header-row.component'; +export * from './pipes'; + +/* NOTE: Grid headers directives collection for ease-of-use import in standalone components scenario */ +// export const IGX_GRID_HEADERS_DIRECTIVES = [ +// IgxGridHeaderComponent, +// IgxGridHeaderGroupComponent, +// IgxGridHeaderRowComponent +// ] as const; diff --git a/projects/igniteui-angular/grids/core/src/moving/moving.drag.directive.ts b/projects/igniteui-angular/grids/core/src/moving/moving.drag.directive.ts new file mode 100644 index 00000000000..3be06517cb2 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/moving/moving.drag.directive.ts @@ -0,0 +1,148 @@ +import { Directive, OnDestroy, Input, inject } from '@angular/core'; +import { Subscription, fromEvent } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { IgxColumnMovingService } from './moving.service'; +import { IgxDragDirective } from 'igniteui-angular/directives'; +import { ColumnType } from 'igniteui-angular/core'; + +/** + * @hidden + * @internal + */ +@Directive({ + selector: '[igxColumnMovingDrag]', + standalone: true +}) +export class IgxColumnMovingDragDirective extends IgxDragDirective implements OnDestroy { + private cms = inject(IgxColumnMovingService); + + + @Input('igxColumnMovingDrag') + public column: ColumnType; + + public get draggable(): boolean { + return this.column && (this.column.grid.moving || (this.column.groupable && !this.column.columnGroup)); + } + + public get icon(): HTMLElement { + return this.cms.icon; + } + + private subscription$: Subscription; + private _ghostClass = 'igx-grid__drag-ghost-image'; + private ghostImgIconClass = 'igx-grid__drag-ghost-image-icon'; + private ghostImgIconGroupClass = 'igx-grid__drag-ghost-image-icon-group'; + private columnSelectedClass = 'igx-grid-th--selected'; + + constructor() { + super(); + this.ghostClass = this._ghostClass; + } + + public override ngOnDestroy() { + this._unsubscribe(); + super.ngOnDestroy(); + } + + public onEscape(event: Event) { + this.cms.cancelDrop = true; + this.onPointerUp(event); + } + + public override onPointerDown(event: Event) { + if (!this.draggable || (event.target as HTMLElement).getAttribute('draggable') === 'false') { + return; + } + + super.onPointerDown(event); + } + + public override onPointerMove(event: Event) { + if (this._clicked && !this._dragStarted) { + this._removeOnDestroy = false; + this.cms.column = this.column; + this.column.grid.cdr.detectChanges(); + + const movingStartArgs = { + source: this.column + }; + this.column.grid.columnMovingStart.emit(movingStartArgs); + this.subscription$ = fromEvent(this.column.grid.document.defaultView, 'keydown').pipe(takeUntil(this._destroy)).subscribe((ev: KeyboardEvent) => { + if (ev.key === this.platformUtil.KEYMAP.ESCAPE) { + this.onEscape(ev); + } + }); + } + + super.onPointerMove(event); + if (this._dragStarted && this.ghostElement && !this.cms.column) { + this.cms.column = this.column; + this.column.grid.cdr.detectChanges(); + } + + if (this.cms.column) { + const args = { + source: this.column, + cancel: false + }; + this.column.grid.columnMoving.emit(args); + + if (args.cancel) { + this.onEscape(event); + } + } + } + + public override onPointerUp(event: Event) { + // Run it explicitly inside the zone because sometimes onPointerUp executes after the code below. + this.zone.run(() => { + super.onPointerUp(event); + this.cms.column = null; + this.column.grid.cdr.detectChanges(); + }); + + this._unsubscribe(); + } + + protected override createGhost(pageX: number, pageY: number) { + super.createGhost(pageX, pageY); + + this.ghostElement.style.height = null; + this.ghostElement.style.minWidth = null; + this.ghostElement.style.flexBasis = null; + this.ghostElement.style.position = null; + + this.ghostElement.classList.remove(this.columnSelectedClass); + + const icon = this.column?.grid.document.createElement('i'); + const text = this.column?.grid.document.createTextNode('block'); + icon.appendChild(text); + + icon.classList.add('material-icons'); + this.cms.icon = icon; + + if (!this.column.columnGroup) { + icon.classList.add(this.ghostImgIconClass); + + this.ghostElement.insertBefore(icon, this.ghostElement.firstElementChild); + + this.ghostLeft = this._ghostStartX = pageX - ((this.ghostElement.getBoundingClientRect().width / 3) * 2); + this.ghostTop = this._ghostStartY = pageY - ((this.ghostElement.getBoundingClientRect().height / 3) * 2); + } else { + this.ghostElement.insertBefore(icon, this.ghostElement.childNodes[0]); + + icon.classList.add(this.ghostImgIconGroupClass); + this.ghostElement.children[0].style.paddingLeft = '0px'; + + this.ghostLeft = this._ghostStartX = pageX - ((this.ghostElement.getBoundingClientRect().width / 3) * 2); + this.ghostTop = this._ghostStartY = pageY - ((this.ghostElement.getBoundingClientRect().height / 3) * 2); + } + } + + private _unsubscribe() { + if (this.subscription$) { + this.subscription$.unsubscribe(); + this.subscription$ = null; + } + } +} diff --git a/projects/igniteui-angular/grids/core/src/moving/moving.drop.directive.ts b/projects/igniteui-angular/grids/core/src/moving/moving.drop.directive.ts new file mode 100644 index 00000000000..60354cd1e9b --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/moving/moving.drop.directive.ts @@ -0,0 +1,175 @@ +import { Directive, Input, OnDestroy, inject } from '@angular/core'; +import { DropPosition, IgxColumnMovingService } from './moving.service'; +import { Subject, interval, animationFrameScheduler } from 'rxjs'; +import { IgxColumnMovingDragDirective } from './moving.drag.directive'; +import { takeUntil } from 'rxjs/operators'; +import { IgxDropDirective, IgxForOfDirective, IgxGridForOfDirective } from 'igniteui-angular/directives'; +import { ColumnType } from 'igniteui-angular/core'; + +@Directive({ + selector: '[igxColumnMovingDrop]', + standalone: true +}) +export class IgxColumnMovingDropDirective extends IgxDropDirective implements OnDestroy { + private cms = inject(IgxColumnMovingService); + + + @Input('igxColumnMovingDrop') + public override set data(val: ColumnType | IgxForOfDirective) { + if (val instanceof IgxGridForOfDirective) { + this._displayContainer = val; + } else { + this._column = val as ColumnType; + } + + } + + public get column() { + return this._column; + } + + public get isDropTarget(): boolean { + return this.column && this.column.grid.moving && + ((!this.column.pinned && this.cms.column?.disablePinning) || !this.cms.column?.disablePinning); + } + + public get horizontalScroll() { + if (this._displayContainer) { + return this._displayContainer; + } + } + + public get nativeElement() { + return this.element.nativeElement; + } + + private _dropPos: DropPosition; + private _dropIndicator = null; + private _lastDropIndicator = null; + private _column: ColumnType; + private _displayContainer: IgxGridForOfDirective; + private _dragLeave = new Subject(); + private _dropIndicatorClass = 'igx-grid-th__drop-indicator--active'; + + constructor() { + super(); + } + + public override ngOnDestroy() { + this._dragLeave.next(true); + this._dragLeave.complete(); + super.ngOnDestroy(); + } + + public override onDragOver(event) { + const drag = event.detail.owner; + if (!(drag instanceof IgxColumnMovingDragDirective)) { + return; + } + + if (this.isDropTarget && + this.cms.column !== this.column && + this.cms.column.level === this.column.level && + this.cms.column.parent === this.column.parent) { + + if (this._lastDropIndicator) { + this._renderer.removeClass(this._dropIndicator, this._dropIndicatorClass); + } + + const clientRect = this.nativeElement.getBoundingClientRect(); + const pos = clientRect.left + clientRect.width / 2; + + const parent = this.nativeElement.parentElement; + if (event.detail.pageX < pos) { + this._dropPos = DropPosition.BeforeDropTarget; + this._lastDropIndicator = this._dropIndicator = parent.firstElementChild; + } else { + this._dropPos = DropPosition.AfterDropTarget; + this._lastDropIndicator = this._dropIndicator = parent.lastElementChild; + } + + if (this.cms.icon.innerText !== 'block') { + this._renderer.addClass(this._dropIndicator, this._dropIndicatorClass); + } + } + } + + public override onDragEnter(event) { + const drag = event.detail.owner; + if (!(drag instanceof IgxColumnMovingDragDirective)) { + return; + } + + if (this.column && this.cms.column.grid.id !== this.column.grid.id) { + this.cms.icon.innerText = 'block'; + return; + } + + if (this.isDropTarget && + this.cms.column !== this.column && + this.cms.column.level === this.column.level && + this.cms.column.parent === this.column.parent) { + + if (!this.column.pinned || (this.column.pinned && this.cms.column.pinned)) { + this.cms.icon.innerText = 'swap_horiz'; + } + + this.cms.icon.innerText = 'save_alt'; + } else { + this.cms.icon.innerText = 'block'; + } + + if (this.horizontalScroll) { + this.cms.icon.innerText = event.target.id === 'right' ? 'arrow_forward' : 'arrow_back'; + + interval(0, animationFrameScheduler).pipe(takeUntil(this._dragLeave)).subscribe(() => { + if (event.target.id === 'right') { + this.horizontalScroll.scrollPosition += 10; + } else { + this.horizontalScroll.scrollPosition -= 10; + } + }); + } + } + + public override onDragLeave(event) { + const drag = event.detail.owner; + if (!(drag instanceof IgxColumnMovingDragDirective)) { + return; + } + + this.cms.icon.innerText = 'block'; + + if (this._dropIndicator) { + this._renderer.removeClass(this._dropIndicator, this._dropIndicatorClass); + } + + if (this.horizontalScroll) { + this._dragLeave.next(true); + } + } + + public override onDragDrop(event) { + event.preventDefault(); + const drag = event.detail.owner; + if (this.cms.cancelDrop || !(drag instanceof IgxColumnMovingDragDirective)) { + this.cms.cancelDrop = false; + return; + } + + if (this.column && (this.cms.column.grid.id !== this.column.grid.id)) { + return; + } + + if (this.horizontalScroll) { + this._dragLeave.next(true); + } + + if (this.isDropTarget) { + this.column.grid.moveColumn(this.cms.column, this.column, this._dropPos); + + this.cms.column = null; + this.column.grid.cdr.detectChanges(); + } + } +} diff --git a/projects/igniteui-angular/grids/core/src/moving/moving.service.ts b/projects/igniteui-angular/grids/core/src/moving/moving.service.ts new file mode 100644 index 00000000000..a50f8516c6e --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/moving/moving.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@angular/core'; +import { ColumnType } from 'igniteui-angular/core'; + +/* mustCoerceToInt */ +/** + * This enumeration is used to configure whether the drop position is set before or after + * the target. + */ +export enum DropPosition { + BeforeDropTarget, + AfterDropTarget +} + + +/** + * @hidden + * @internal + */ +@Injectable({ providedIn: 'root' }) +export class IgxColumnMovingService { + public cancelDrop: boolean; + public icon: HTMLElement; + public column: ColumnType; +} diff --git a/projects/igniteui-angular/grids/core/src/pivot-grid-aggregate.ts b/projects/igniteui-angular/grids/core/src/pivot-grid-aggregate.ts new file mode 100644 index 00000000000..32ed36b21fa --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/pivot-grid-aggregate.ts @@ -0,0 +1,227 @@ +import { IPivotAggregator } from './pivot-grid.interface'; +import { IgxDateSummaryOperand, IgxNumberSummaryOperand, IgxTimeSummaryOperand } from './summaries/grid-summary'; + + +export class IgxPivotAggregate { + /** + * Gets array with default aggregator function for base aggregation. + * ```typescript + * IgxPivotAggregate.aggregators(); + * ``` + * + * @memberof IgxPivotAggregate + */ + public static aggregators(): Array { + return [{ + key: 'COUNT', + label: 'Count', + aggregator: IgxPivotAggregate.count + }]; + } + /** + * Counts all the records in the data source. + * If filtering is applied, counts only the filtered records. + * ```typescript + * IgxSummaryOperand.count(dataSource); + * ``` + * + * @memberof IgxPivotAggregate + */ + public static count(members: number[]): number { + return members.length; + } +} + +export class IgxPivotNumericAggregate extends IgxPivotAggregate { + + /** + * Gets array with default aggregator function for numeric aggregation. + * ```typescript + * IgxPivotAggregate.aggregators(); + * ``` + * + * @memberof IgxPivotAggregate + */ + public static override aggregators() { + let result: IPivotAggregator[] = []; + result = result.concat(super.aggregators()); + result.push({ + key: 'MIN', + label: 'Minimum', + aggregator: IgxPivotNumericAggregate.min + }); + result.push({ + key: 'MAX', + label: 'Maximum', + aggregator: IgxPivotNumericAggregate.max + }); + + result.push({ + key: 'SUM', + label: 'Sum', + aggregator: IgxPivotNumericAggregate.sum + }); + + result.push({ + key: 'AVG', + label: 'Average', + aggregator: IgxPivotNumericAggregate.average + }); + return result; + } + + /** + * Returns the minimum numeric value in the provided data records. + * If filtering is applied, returns the minimum value in the filtered data records. + * ```typescript + * IgxPivotNumericAggregate.min(members, data); + * ``` + * + * @memberof IgxPivotNumericAggregate + */ + public static min(members: number[]): number { + return IgxNumberSummaryOperand.min(members); + } + + /** + * Returns the maximum numeric value in the provided data records. + * If filtering is applied, returns the maximum value in the filtered data records. + * ```typescript + * IgxPivotNumericAggregate.max(data); + * ``` + * + * @memberof IgxPivotNumericAggregate + */ + public static max(members: number[]): number { + return IgxNumberSummaryOperand.max(members); + } + + /** + * Returns the sum of the numeric values in the provided data records. + * If filtering is applied, returns the sum of the numeric values in the data records. + * ```typescript + * IgxPivotNumericAggregate.sum(data); + * ``` + * + * @memberof IgxPivotNumericAggregate + */ + public static sum(members: number[]): number { + return IgxNumberSummaryOperand.sum(members); + } + + /** + * Returns the average numeric value in the data provided data records. + * If filtering is applied, returns the average numeric value in the filtered data records. + * ```typescript + * IgxPivotNumericAggregate.average(data); + * ``` + * + * @memberof IgxPivotNumericAggregate + */ + public static average(members: number[]): number { + return IgxNumberSummaryOperand.average(members); + } +} + +export class IgxPivotDateAggregate extends IgxPivotAggregate { + /** + * Gets array with default aggregator function for date aggregation. + * ```typescript + * IgxPivotDateAggregate.aggregators(); + * ``` + * + * @memberof IgxPivotAggregate + */ + public static override aggregators() { + let result: IPivotAggregator[] = []; + result = result.concat(super.aggregators()); + result.push({ + key: 'LATEST', + label: 'Latest Date', + aggregator: IgxPivotDateAggregate.latest + }); + result.push({ + key: 'EARLIEST', + label: 'Earliest Date', + aggregator: IgxPivotDateAggregate.earliest + }); + return result; + } + /** + * Returns the latest date value in the data records. + * If filtering is applied, returns the latest date value in the filtered data records. + * ```typescript + * IgxPivotDateAggregate.latest(data); + * ``` + * + * @memberof IgxPivotDateAggregate + */ + public static latest(members: any[]) { + return IgxDateSummaryOperand.latest(members); + } + + /** + * Returns the earliest date value in the data records. + * If filtering is applied, returns the latest date value in the filtered data records. + * ```typescript + * IgxPivotDateAggregate.earliest(data); + * ``` + * + * @memberof IgxPivotDateAggregate + */ + public static earliest(members: any[]) { + return IgxDateSummaryOperand.earliest(members); + } +} + +export class IgxPivotTimeAggregate extends IgxPivotAggregate { + /** + * Gets array with default aggregator function for time aggregation. + * ```typescript + * IgxPivotTimeAggregate.aggregators(); + * ``` + * + * @memberof IgxPivotAggregate + */ + public static override aggregators() { + let result: IPivotAggregator[] = []; + result = result.concat(super.aggregators()); + result.push({ + key: 'LATEST', + label: 'Latest Time', + aggregator: IgxPivotTimeAggregate.latestTime + }); + result.push({ + key: 'EARLIEST', + label: 'Earliest Time', + aggregator: IgxPivotTimeAggregate.earliestTime + }); + return result; + } + + /** + * Returns the latest time value in the data records. Compare only the time part of the date. + * If filtering is applied, returns the latest time value in the filtered data records. + * ```typescript + * IgxPivotTimeAggregate.latestTime(data); + * ``` + * + * @memberof IgxPivotTimeAggregate + */ + public static latestTime(members: any[]) { + return IgxTimeSummaryOperand.latestTime(members); + } + + /** + * Returns the earliest time value in the data records. Compare only the time part of the date. + * If filtering is applied, returns the earliest time value in the filtered data records. + * ```typescript + * IgxPivotTimeAggregate.earliestTime(data); + * ``` + * + * @memberof IgxPivotTimeAggregate + */ + public static earliestTime(members: any[]) { + return IgxTimeSummaryOperand.earliestTime(members); + } +} diff --git a/projects/igniteui-angular/grids/core/src/pivot-grid-dimensions.ts b/projects/igniteui-angular/grids/core/src/pivot-grid-dimensions.ts new file mode 100644 index 00000000000..e183fc9bed8 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/pivot-grid-dimensions.ts @@ -0,0 +1,188 @@ +import { getCurrentResourceStrings, GridColumnDataType, GridResourceStringsEN, IGridResourceStrings } from 'igniteui-angular/core'; +import { IPivotDimension } from './pivot-grid.interface'; +import { PivotUtil } from './pivot-util'; + +export interface IPivotDateDimensionOptions { + /** Enables/Disables total value of all periods. */ + total?: boolean; + /** Enables/Disables dimensions per year from provided periods. */ + years?: boolean; + /*/** Enables/Disables dimensions per quarter from provided periods. */ + quarters?: boolean; + /** Enables/Disables dimensions per month from provided periods. */ + months?: boolean; + /** Enabled/Disables dimensions for the full date provided */ + fullDate?: boolean; +} + +/* blazorAlternateBaseType: PivotDimension */ +/* alternateBaseType: PivotDimension */ +// Equals to pretty much this configuration: +// { +// member: () => 'All Periods', +// enabled: true, +// fieldName: 'AllPeriods', +// childLevel: { +// fieldName: 'Years', +// member: (rec) => { +// const recordValue = rec['Date']; +// return recordValue ? (new Date(recordValue)).getFullYear().toString() : rec['Years']; +// }, +// enabled: true, +// childLevel: { +// member: (rec) => { +// const recordValue = rec['Date']; +// return recordValue ? new Date(recordValue).toLocaleString('default', { month: 'long' }) : rec['Months']; +// }, +// enabled: true, +// fieldName: 'Months', +// childLevel: { +// member: 'Date', +// fieldName:'Date', +// enabled: true +// } +// } +// } +// }, +export class IgxPivotDateDimension implements IPivotDimension { + /** Enables/Disables a particular dimension from pivot structure. */ + public enabled = true; + + /** + * Gets/Sets data type + */ + public dataType?: GridColumnDataType; + + /* blazorSuppress */ + /** Default options. */ + public defaultOptions = { + total: true, + years: true, + months: true, + fullDate: true + }; + + /** + * Gets/Sets the resource strings. + * + * @remarks + * By default it uses EN resources. + */ + public set resourceStrings(value: IGridResourceStrings) { + this._resourceStrings = Object.assign({}, this._resourceStrings, value); + } + + public get resourceStrings(): IGridResourceStrings { + return this._resourceStrings; + } + + /** + * Gets/Sets the base dimension that is used by this class to determine the other dimensions and their values. + * Having base dimension set is required in order for the Date Dimensions to show. + */ + public get baseDimension(): IPivotDimension { + return this._baseDimension; + } + public set baseDimension(value: IPivotDimension) { + this._baseDimension = value; + this.initialize(this.baseDimension, this.options); + } + + /** + * Gets/Sets the options for the predefined date dimensions whether to show quarter, years and etc. + */ + public get options(): IPivotDateDimensionOptions { + return this._options; + } + public set options(value: IPivotDateDimensionOptions) { + this._options = value; + if (this.baseDimension) { + this.initialize(this.baseDimension, this.options); + } + } + + /** @hidden @internal */ + public childLevel?: IPivotDimension; + /** @hidden @internal */ + public memberName = 'AllPeriods'; + public displayName: string; + private _resourceStrings = getCurrentResourceStrings(GridResourceStringsEN); + private _baseDimension: IPivotDimension; + private _options: IPivotDateDimensionOptions = {}; + private _monthIntl = new Intl.DateTimeFormat('default', { month: 'long' }); + + + /** + * Creates additional pivot date dimensions based on a provided dimension describing date data: + * + * @param inDateDimension Base dimension that is used by this class to determine the other dimensions and their values. + * @param inOptions Options for the predefined date dimensions whether to show quarter, years and etc. + * @example + * ```typescript + * // Displays only years as parent dimension to the base dimension provided. + * new IgxPivotDateDimension({ memberName: 'Date', enabled: true }, { total: false, months: false }); + * ``` + */ + constructor(inBaseDimension: IPivotDimension = null, inOptions: IPivotDateDimensionOptions = {}) { + this._baseDimension = inBaseDimension; + this._options = inOptions; + if (this.baseDimension && this.options) { + this.initialize(this.baseDimension, this.options); + } + } + + protected initialize(inBaseDimension, inOptions) { + const options = { ...this.defaultOptions, ...inOptions }; + + this.dataType = GridColumnDataType.Date; + inBaseDimension.dataType = GridColumnDataType.Date; + + this.enabled = inBaseDimension.enabled; + this.displayName = inBaseDimension.displayName || this.resourceStrings.igx_grid_pivot_date_dimension_total; + + const baseDimension = options.fullDate ? inBaseDimension : null; + const monthDimensionDef: IPivotDimension = { + memberName: 'Months', + memberFunction: (rec) => { + const recordValue = PivotUtil.extractValueFromDimension(inBaseDimension, rec); + return recordValue ? this._monthIntl.format(new Date(recordValue)) : rec['Months']; + }, + enabled: true, + childLevel: baseDimension + }; + const monthDimension = options.months ? monthDimensionDef : baseDimension; + + const quarterDimensionDef: IPivotDimension = { + memberName: 'Quarters', + memberFunction: (rec) => { + const recordValue = PivotUtil.extractValueFromDimension(inBaseDimension, rec); + return recordValue ? `Q` + Math.ceil((new Date(recordValue).getMonth() + 1) / 3) : rec['Quarters']; + }, + enabled: true, + childLevel: monthDimension + }; + const quarterDimension = options.quarters ? quarterDimensionDef : monthDimension; + + const yearsDimensionDef: IPivotDimension = { + memberName: 'Years', + memberFunction: (rec) => { + const recordValue = PivotUtil.extractValueFromDimension(inBaseDimension, rec); + return recordValue ? (new Date(recordValue)).getFullYear().toString() : rec['Years']; + }, + enabled: true, + childLevel: quarterDimension + }; + const yearsDimension = options.years ? yearsDimensionDef : quarterDimension; + this.childLevel = yearsDimension; + + if (!options.total) { + this.memberName = yearsDimension.memberName; + this.memberFunction = yearsDimension.memberFunction; + this.childLevel = yearsDimension.childLevel; + this.displayName = yearsDimension.displayName; + } + } + + /** @hidden @internal */ + public memberFunction = (_data) => this.resourceStrings.igx_grid_pivot_date_dimension_total; +} diff --git a/projects/igniteui-angular/grids/core/src/pivot-grid.interface.ts b/projects/igniteui-angular/grids/core/src/pivot-grid.interface.ts new file mode 100644 index 00000000000..edbb95dc676 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/pivot-grid.interface.ts @@ -0,0 +1,302 @@ +import { ColumnType, GridColumnDataType, IDataCloneStrategy, IFilteringExpressionsTree, SortingDirection } from 'igniteui-angular/core'; + + +/** +* Default pivot keys used for data processing in the pivot pipes. +*/ +export const DEFAULT_PIVOT_KEYS = { + aggregations: 'aggregations', records: 'records', children: 'children', level: 'level', + rowDimensionSeparator: '_', columnDimensionSeparator: '-' +}; + +/** + * Event emitted when dimension collection for rows, columns of filters is changed. + */ +export interface IDimensionsChange { + /** The new list of dimensions. */ + dimensions: IPivotDimension[], + /** The dimension list type - Row, Column or Filter. */ + dimensionCollectionType: PivotDimensionType +} + +/** +* Event emitted when values list is changed. +*/ +export interface IValuesChange { + /** The new list of values. */ + values: IPivotValue[] +} + +/** + * Event emitted when pivot configuration is changed. + */ +export interface IPivotConfigurationChangedEventArgs { + /** The new configuration. */ + pivotConfiguration: IPivotConfiguration +} + +/** +* Interface describing Pivot data processing for dimensions. +* Should contain a process method and return records hierarchy based on the provided dimensions. +*/ +export interface IPivotDimensionStrategy { + /* blazorCSSuppress */ + process(collection: any, + dimensions: IPivotDimension[], + values: IPivotValue[], + cloneStrategy: IDataCloneStrategy, + pivotKeys?: IPivotKeys): any[]; +} + +/** +* Interface describing a PivotAggregation function. +* Accepts an array of extracted data members and a array of the original data records. +*/ +export type PivotAggregation = (members: any[], data: any[]) => any; + +/* marshalByValue */ +/** +* Interface describing a IPivotAggregator class. +* Used for specifying custom aggregator lists. +*/ +export interface IPivotAggregator { + /** Aggregation unique key. */ + key: string; + /** Aggregation label to show in the UI. */ + label: string; + /** + * Aggregation name that will be used from a list of predefined aggregations. + * If not set will use the specified aggregator function. + */ + aggregatorName?: PivotAggregationType; + + /* blazorAlternateType: AggregatorEventHandler */ + /* blazorOnlyScript */ + /** + * Aggregator function can be a custom implementation of `PivotAggregation`, or + * use predefined ones from `IgxPivotAggregate` and its variants. + */ + aggregator?: (members: any[], data?: any[]) => any; +} + +/* marshalByValue */ +/** +* Configuration of the pivot grid. +*/ +export interface IPivotConfiguration { + /** A strategy to transform the rows. */ + rowStrategy?: IPivotDimensionStrategy | null; + /** A strategy to transform the columns. */ + columnStrategy?: IPivotDimensionStrategy | null; + /** A list of the rows. */ + rows: IPivotDimension[] | null; + /** A list of the columns. */ + columns: IPivotDimension[] | null; + /** A list of the values. */ + values: IPivotValue[] | null; + /** Dimensions to be displayed in the filter area. */ + filters?: IPivotDimension[] | null; + /** Pivot data keys used for data generation. Can be used for custom remote scenarios where the data is pre-populated. */ + pivotKeys?: IPivotKeys; +} + +/* blazorElement */ +/* marshalByValue */ +/** +* Configuration of a pivot dimension. +*/ +export interface IPivotDimension { + /** Allows defining a hierarchy when multiple sub groups need to be extracted from single member. */ + childLevel?: IPivotDimension; + /** Unique member to extract related data field value or the result of the memberFunction. */ + memberName: string; + + /* csTreatAsEvent: MemberFunctionHandler */ + /* blazorOnlyScript */ + /** Function that extracts the value */ + memberFunction?: (data: any) => any; + /** Display name to show instead of the field name of this value. **/ + displayName?: string; + /** Enables/Disables a particular dimension from pivot structure. */ + enabled: boolean; + /** + * A predefined or defined via the `igxPivotSelector` filter expression tree for the current dimension to be applied in the filter pipe. + * */ + filter?: IFilteringExpressionsTree | null; + /** Enable/disable sorting for a particular dimension. True by default. */ + sortable?: boolean; + /** + * The sorting direction of the current dimension. Determines the order in which the values will appear in the related dimension. + */ + sortDirection?: SortingDirection; + /** + * The dataType of the related data field. + */ + dataType?: GridColumnDataType; + /** The width of the dimension cells to be rendered.Can be pixel, % or "auto". */ + width?: string; + /** Level of the dimension. */ + level?: number; + /** @hidden @internal */ + autoWidth?: number; + horizontalSummary? : boolean; +} + +/* marshalByValue */ +/** +* Configuration of a pivot value aggregation. +*/ +export interface IPivotValue { + /** Unique member to extract related data field value for aggregations. */ + member: string; + /** Display name to show instead of member for the column header of this value. **/ + displayName?: string; + /** + * Active aggregator definition with key, label and aggregator. + */ + aggregate: IPivotAggregator; + /** + * List of aggregates to show in aggregate drop-down. + */ + aggregateList?: IPivotAggregator[]; + /** Enables/Disables a particular value from pivot aggregation. */ + enabled: boolean; + /** Allow conditionally styling of the IgxPivotGrid cells. */ + styles?: any; + /** Enables a data type specific template of the cells */ + dataType?: GridColumnDataType; + + /* csTreatAsEvent: PivotValueFormatterEventHandler */ + /* blazorOnlyScript */ + /** Applies display format to cell values. */ + formatter?: (value: any, rowData?: IPivotGridRecord, columnData?: IPivotGridColumn) => any; +} + +/** Interface describing the Pivot column data. +* Contains information on the related column dimensions and their values. +*/ +export interface IPivotGridColumn { + field: string, + /* blazorSuppress */ + /** Gets/Sets the group value associated with the related column dimension by its memberName. **/ + dimensionValues: Map; + /** List of dimensions associated with the column.**/ + dimensions: IPivotDimension[]; + value: IPivotValue +} + +/* marshalByValue */ +/** Interface describing the Pivot data keys used for data generation. +* Can be used for custom remote scenarios where the data is pre-populated. +*/ +export interface IPivotKeys { + /** Field that stores children for hierarchy building. */ + children: string; + /** Field that stores reference to the original data records. */ + records: string; + /** Field that stores aggregation values. */ + aggregations: string; + /** Field that stores dimension level based on its hierarchy. */ + level: string; + /** Separator used when generating the unique column field values. */ + columnDimensionSeparator: string; + /** Separator used when generating the unique row field values. */ + rowDimensionSeparator: string; +} + +/* mustCoerceToInt */ +/** The dimension types - Row, Column or Filter. */ +export enum PivotDimensionType { + Row, + Column, + Filter +} + + +export enum PivotRowLayoutType { + Vertical = "vertical", + Horizontal = "horizontal" +} + +export enum PivotSummaryPosition { + Top = "top", + Bottom = "bottom" +} + +export interface IPivotUISettings { + showConfiguration?: boolean; + showRowHeaders?: boolean; + rowLayout?: PivotRowLayoutType; + horizontalSummariesPosition?: PivotSummaryPosition; +} + +export type PivotAggregationType = 'SUM' | 'AVG' | 'MIN' | 'MAX' | 'COUNT' | 'LATEST' | 'EARLIEST'; + +/** Interface describing the pivot dimension data. +* Contains additional information needed to render dimension headers. +*/ +export interface IPivotDimensionData { + /** Associated column definition. */ + column: ColumnType; + /** Associated dimension definition. */ + dimension: IPivotDimension; + /** List of previous dimension groups. */ + prevDimensions: IPivotDimension[]; + /** Whether this a child dimension. */ + isChild?: boolean; +} + +export interface PivotRowHeaderGroupType { + rowIndex: number; + parent: any; + header: any; + headerID: string; + grid: any; +} + +export interface DimensionValueType { + value: string; + children: Map; +} + +export interface IPivotGridRecord { + /* blazorSuppress */ + /** Gets/Sets the group value associated with the related row dimension by its memberName. **/ + dimensionValues: Map; + /* blazorSuppress */ + /** Gets/Sets the aggregation value associated with the value path. Value path depends on configured column dimension hierarchy and values.**/ + aggregationValues: Map; + /* blazorSuppress */ + /** List of children records in case any row dimension member contain a hierarchy. Each dimension member contains its own hierarchy, which you can get by its memberName. **/ + children?: Map; + /** List of original data records associated with the current pivoted data. **/ + records?: any[]; + /** Record level**/ + level?: number; + /** List of dimensions associated with the record.**/ + dimensions: IPivotDimension[]; + /** If set, it specifies the name of the dimension, that has total record enabled. */ + totalRecordDimensionName?: string; + /** The index of the record in the total view */ + dataIndex?: number; +} + +export interface IPivotGridGroupRecord extends IPivotGridRecord { + height?: number; + rowSpan?: number; +} + +export interface IPivotGridHorizontalGroup { + value?: string; + rootDimension?: IPivotDimension; + dimensions?: IPivotDimension[]; + records?: IPivotGridRecord[]; + rowStart?: number; + rowSpan?: number; + colStart?: number; + colSpan?: number; +} + +export interface IgxPivotGridValueTemplateContext { + $implicit: IPivotValue; +} diff --git a/projects/igniteui-angular/grids/core/src/pivot-util.ts b/projects/igniteui-angular/grids/core/src/pivot-util.ts new file mode 100644 index 00000000000..e8c005efb15 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/pivot-util.ts @@ -0,0 +1,530 @@ +import { DataUtil, FilteringExpressionsTree, FilteringLogic, GridColumnDataType, IDataCloneStrategy, IGridSortingStrategy, IgxSorting, ISortingExpression } from 'igniteui-angular/core'; +import { IgxPivotAggregate, IgxPivotDateAggregate, IgxPivotNumericAggregate, IgxPivotTimeAggregate } from './pivot-grid-aggregate'; +import { IPivotAggregator, IPivotConfiguration, IPivotDimension, IPivotGridRecord, IPivotKeys, IPivotValue, PivotDimensionType, PivotSummaryPosition } from './pivot-grid.interface'; +import { PivotGridType } from './common/grid.interface'; + +export class PivotUtil { + + // go through all children and apply new dimension groups as child + public static processGroups(recs: IPivotGridRecord[], dimension: IPivotDimension, pivotKeys: IPivotKeys, cloneStrategy: IDataCloneStrategy) { + for (const rec of recs) { + // process existing children + if (rec.children && rec.children.size > 0) { + // process hierarchy in dept + rec.children.forEach((values) => { + this.processGroups(values, dimension, pivotKeys, cloneStrategy); + }); + } + // add children for current dimension + const hierarchyFields = PivotUtil + .getFieldsHierarchy(rec.records, [dimension], PivotDimensionType.Row, pivotKeys, cloneStrategy); + const siblingData = PivotUtil + .processHierarchy(hierarchyFields, pivotKeys, 0); + rec.children.set(dimension.memberName, siblingData); + } + } + + public static flattenGroups(data: IPivotGridRecord[], dimension: IPivotDimension, expansionStates, defaultExpand: boolean, parent?: IPivotDimension, parentRec?: IPivotGridRecord) { + for (let i = 0; i < data.length; i++) { + const rec = data[i]; + const field = dimension.memberName; + if (!field) { + continue; + } + + let recordsData = rec.children.get(field); + if (!recordsData && parent) { + // check parent + recordsData = rec.children.get(parent.memberName); + if (recordsData) { + dimension = parent; + } + } + + if (parentRec) { + parentRec.dimensionValues.forEach((value, key) => { + if (parent.memberName !== key) { + rec.dimensionValues.set(key, value); + const dim = parentRec.dimensions.find(x => x.memberName === key); + rec.dimensions.unshift(dim); + } + + }); + } + + + const expansionRowKey = PivotUtil.getRecordKey(rec, dimension); + const isExpanded = expansionStates.get(expansionRowKey) === undefined ? + defaultExpand : + expansionStates.get(expansionRowKey); + const shouldExpand = isExpanded || !dimension.childLevel || !rec.dimensionValues.get(dimension.memberName); + if (shouldExpand && recordsData) { + if (dimension.childLevel) { + this.flattenGroups(recordsData, dimension.childLevel, expansionStates, defaultExpand, dimension, rec); + } else { + // copy parent values and dims in child + recordsData.forEach(x => { + rec.dimensionValues.forEach((value, key) => { + if (dimension.memberName !== key) { + x.dimensionValues.set(key, value); + const dim = rec.dimensions.find(y => y.memberName === key); + x.dimensions.unshift(dim); + } + + }); + }); + } + + data.splice(i + 1, 0, ...recordsData); + i += recordsData.length; + + } + } + } + + public static flattenGroupsHorizontally(data: IPivotGridRecord[], + dimension: IPivotDimension, + expansionStates, + defaultExpand: boolean, + visibleDimensions: IPivotDimension[], + summariesPosition: PivotSummaryPosition, + parent?: IPivotDimension, + parentRec?: IPivotGridRecord) { + for (let i = 0; i < data.length; i++) { + const rec = data[i]; + const field = dimension.memberName; + if (!field) { + continue; + } + + if (!visibleDimensions.find(recDim => recDim.memberName === rec.dimensions[0].memberName)) { + visibleDimensions.push(rec.dimensions[0]); + } + + let recordsData = rec.children.get(field); + if (!recordsData && parent) { + // check parent + recordsData = rec.children.get(parent.memberName); + if (recordsData) { + dimension = parent; + } + } + + if (parentRec) { + parentRec.dimensionValues.forEach((value, key) => { + rec.dimensionValues.set(key, value); + const dim = parentRec.dimensions.find(x => x.memberName === key); + rec.dimensions.unshift(dim); + }); + } + + const expansionRowKey = PivotUtil.getRecordKey(rec, dimension); + const isExpanded = expansionStates.get(expansionRowKey) === undefined ? + defaultExpand : + expansionStates.get(expansionRowKey); + const shouldExpand = isExpanded || !dimension.childLevel || !rec.dimensionValues.get(dimension.memberName); + if (shouldExpand && recordsData && !rec.totalRecordDimensionName) { + if (dimension.childLevel) { + this.flattenGroupsHorizontally(recordsData, dimension.childLevel, expansionStates, defaultExpand, visibleDimensions, summariesPosition, dimension, rec); + } else { + // copy parent values and dims in child + recordsData.forEach(x => { + rec.dimensionValues.forEach((value, key) => { + if (dimension.memberName !== key) { + x.dimensionValues.set(key, value); + const dim = rec.dimensions.find(y => y.memberName === key); + x.dimensions.unshift(dim); + } + + }); + }); + } + + recordsData.forEach((childRec) => { + if (childRec.dimensions.length === 1) { + rec.dimensionValues.forEach((value: string, key) => { + childRec.dimensionValues.set(key, value); + }); + } + + childRec.dimensions.forEach((dim) => { + if (!visibleDimensions.find(recDim => recDim.memberName === dim.memberName)) { + visibleDimensions.push(dim); + } + }); + }); + + const curDimValue = rec.dimensionValues.get(dimension.memberName); + if (dimension.horizontalSummary && curDimValue) { + rec.totalRecordDimensionName = dimension.memberName; + rec.dimensionValues.set(dimension.memberName, `${curDimValue} Total`); + if (summariesPosition === PivotSummaryPosition.Top) { + recordsData.unshift(rec); + } else { + recordsData.push(rec); + } + } + + data.splice(i, 1, ...recordsData); + i += recordsData.length - 1; + + } + } + } + + public static assignLevels(dims) { + for (const dim of dims) { + let currDim = dim; + let lvl = 0; + while (currDim.childLevel) { + currDim.level = lvl; + currDim = currDim.childLevel; + lvl++; + } + currDim.level = lvl; + } + } + public static getFieldsHierarchy(data: any[], dimensions: IPivotDimension[], + dimensionType: PivotDimensionType, pivotKeys: IPivotKeys, cloneStrategy: IDataCloneStrategy): Map { + const hierarchy = new Map(); + for (const rec of data) { + const vals = dimensionType === PivotDimensionType.Column ? + this.extractValuesForColumn(dimensions, rec, pivotKeys) : + this.extractValuesForRow(dimensions, rec, pivotKeys, cloneStrategy); + for (const [_key, val] of vals) { // this should go in depth also vals.children + if (hierarchy.get(val.value) != null) { + this.applyHierarchyChildren(hierarchy, val, rec, pivotKeys); + } else { + hierarchy.set(val.value, cloneStrategy.clone(val)); + this.applyHierarchyChildren(hierarchy, val, rec, pivotKeys); + } + } + } + return hierarchy; + } + + public static sort(data: IPivotGridRecord[], expressions: ISortingExpression[], sorting: IGridSortingStrategy = new IgxSorting()): any[] { + for (const rec of data) { + const children = rec.children; + for (const [key, child] of children) { + /** + * DataUtil.sort is returning new reference of the sorted array + * because of the Schwartizian transform + */ + const sorted = this.sort(child, expressions, sorting); + children.set(key, sorted); + } + + } + return DataUtil.sort(data, expressions, sorting); + } + + public static extractValueFromDimension(dim: IPivotDimension, recData: any) { + return dim.memberFunction ? dim.memberFunction.call(null, recData) : recData[dim.memberName]; + } + + public static getDimensionDepth(dim: IPivotDimension): number { + let lvl = 0; + while (dim.childLevel) { + lvl++; + dim = dim.childLevel; + } + return lvl; + } + + public static extractValuesForRow(dims: IPivotDimension[], recData: any, pivotKeys: IPivotKeys, cloneStrategy: IDataCloneStrategy) { + const values = new Map(); + for (const col of dims) { + if (recData[pivotKeys.level] && recData[pivotKeys.level] > 0) { + const childData = recData[pivotKeys.records]; + return this.getFieldsHierarchy(childData, [col], PivotDimensionType.Row, pivotKeys, cloneStrategy); + } + + const value = this.extractValueFromDimension(col, recData); + const objValue = {}; + objValue['value'] = value; + objValue['dimension'] = col; + if (col.childLevel) { + const childValues = this.extractValuesForRow([col.childLevel], recData, pivotKeys, cloneStrategy); + objValue[pivotKeys.children] = childValues; + } + values.set(value, objValue); + } + + return values; + } + + public static extractValuesForColumn(dims: IPivotDimension[], recData: any, pivotKeys: IPivotKeys, path = []) { + const vals = new Map(); + let lvlCollection = vals; + const flattenedDims = this.flatten(dims); + for (const col of flattenedDims) { + const value = this.extractValueFromDimension(col, recData); + path.push(value); + const newValue = path.join(pivotKeys.columnDimensionSeparator); + const newObj = { value: newValue, expandable: col.expandable, children: null, dimension: col }; + if (!newObj.children) { + newObj.children = new Map(); + } + lvlCollection.set(newValue, newObj); + lvlCollection = newObj.children; + } + return vals; + } + + public static flatten(arr, lvl = 0) { + const newArr = arr.reduce((acc, item) => { + if (item) { + item.level = lvl; + acc.push(item); + if (item.childLevel) { + item.expandable = true; + acc = acc.concat(this.flatten([item.childLevel], lvl + 1)); + } + } + return acc; + }, []); + return newArr; + } + + public static applyAggregations(rec: IPivotGridRecord, hierarchies, values, pivotKeys: IPivotKeys) { + if (hierarchies.size === 0) { + // no column groups + const aggregationResult = this.aggregate(rec.records, values); + this.applyAggregationRecordData(aggregationResult, undefined, rec, pivotKeys); + return; + } + hierarchies.forEach((hierarchy) => { + const children = hierarchy[pivotKeys.children]; + if (children && children.size > 0) { + this.applyAggregations(rec, children, values, pivotKeys); + const childRecords = this.collectRecords(children, pivotKeys); + hierarchy[pivotKeys.aggregations] = this.aggregate(childRecords, values); + this.applyAggregationRecordData(hierarchy[pivotKeys.aggregations], hierarchy.value, rec, pivotKeys); + } else if (hierarchy[pivotKeys.records]) { + hierarchy[pivotKeys.aggregations] = this.aggregate(hierarchy[pivotKeys.records], values); + this.applyAggregationRecordData(hierarchy[pivotKeys.aggregations], hierarchy.value, rec, pivotKeys); + } + }); + } + + protected static applyAggregationRecordData(aggregationData: any, groupName: string, rec: IPivotGridRecord, pivotKeys: IPivotKeys) { + const aggregationKeys = Object.keys(aggregationData); + if (aggregationKeys.length > 1) { + aggregationKeys.forEach((key) => { + const aggregationKey = groupName ? groupName + pivotKeys.columnDimensionSeparator + key : key; + rec.aggregationValues.set(aggregationKey, aggregationData[key]); + }); + } else if (aggregationKeys.length === 1) { + const aggregationKey = aggregationKeys[0]; + rec.aggregationValues.set(groupName || aggregationKey, aggregationData[aggregationKey]); + } + } + + public static aggregate(records, values: IPivotValue[]) { + const result = {}; + for (const pivotValue of values) { + const aggregator = PivotUtil.getAggregatorForType(pivotValue.aggregate, pivotValue.dataType); + if (!aggregator) { + throw `No valid aggregator found for ${pivotValue.member}. Please set either a valid aggregatorName or aggregator`; + } + result[pivotValue.member] = aggregator(records.map(r => r[pivotValue.member]), records); + } + + return result; + } + + public static getAggregatorForType(aggregate: IPivotAggregator, dataType: GridColumnDataType) { + let aggregator = aggregate.aggregator; + if (aggregate.aggregatorName) { + let aggregators = IgxPivotNumericAggregate.aggregators(); + if (!dataType || dataType === 'date' || dataType === 'dateTime') { + aggregators = aggregators.concat(IgxPivotDateAggregate.aggregators()) + } else if (dataType === 'time') { + aggregators = aggregators.concat(IgxPivotTimeAggregate.aggregators()); + } + aggregator = aggregators.find(x => x.key.toLocaleLowerCase() === aggregate.aggregatorName.toLocaleLowerCase())?.aggregator; + } + return aggregator; + } + + public static processHierarchy(hierarchies, pivotKeys, level = 0, rootData = false): IPivotGridRecord[] { + const flatData: IPivotGridRecord[] = []; + hierarchies.forEach((h, key) => { + const field = h.dimension.memberName; + const rec: IPivotGridRecord = { + dimensionValues: new Map(), + aggregationValues: new Map(), + children: new Map(), + dimensions: [h.dimension] + }; + rec.dimensionValues.set(field, key); + if (h[pivotKeys.records]) { + rec.records = this.getDirectLeafs(h[pivotKeys.records]); + } + rec.level = level; + flatData.push(rec); + if (h[pivotKeys.children] && h[pivotKeys.children].size > 0) { + const nestedData = this.processHierarchy(h[pivotKeys.children], + pivotKeys, level + 1, rootData); + rec.records = this.getDirectLeafs(nestedData); + rec.children.set(field, nestedData); + } + }); + + return flatData; + } + + public static getDirectLeafs(records: IPivotGridRecord[]) { + let leafs = []; + for (const rec of records) { + if (rec.records) { + const data = rec.records.filter(x => !x.records && leafs.indexOf(x) === -1); + leafs = leafs.concat(data); + } else { + leafs.push(rec); + } + } + return leafs; + } + + public static getRecordKey(rec: IPivotGridRecord, currentDim: IPivotDimension) { + const parentFields = []; + + const currentDimIndex = rec.dimensions.findIndex(x => x.memberName === currentDim.memberName) + 1; + const prevDims = rec.dimensions.slice(0, currentDimIndex); + for (const prev of prevDims) { + const prevValue = rec.dimensionValues.get(prev.memberName); + parentFields.push(prevValue); + } + + return parentFields.join('-'); + } + + public static buildExpressionTree(config: IPivotConfiguration) { + const allDimensions = (config?.rows || []).concat((config?.columns || [])).concat(config?.filters || []).filter(x => x !== null && x !== undefined); + const enabledDimensions = allDimensions.filter(x => x && x.enabled); + + const expressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + // add expression trees from all filters + PivotUtil.flatten(enabledDimensions).forEach((x: IPivotDimension) => { + if (x.filter && x.filter.filteringOperands) { + expressionsTree.filteringOperands.push(...x.filter.filteringOperands); + } + }); + + return expressionsTree; + } + + private static collectRecords(children, pivotKeys: IPivotKeys) { + let result = []; + children.forEach(value => result = result.concat(value[pivotKeys.records])); + return result; + } + + private static applyHierarchyChildren(hierarchy, val, rec, pivotKeys: IPivotKeys) { + const recordsKey = pivotKeys.records; + const childKey = pivotKeys.children; + const childCollection = val[childKey]; + const hierarchyValue = hierarchy.get(val.value); + if (Array.isArray(hierarchyValue[childKey])) { + hierarchyValue[childKey] = new Map(); + } + if (!childCollection || childCollection.size === 0) { + const dim = hierarchyValue.dimension; + const isValid = this.extractValueFromDimension(dim, rec) === val.value; + if (isValid) { + if (hierarchyValue[recordsKey]) { + hierarchyValue[recordsKey].push(rec); + } else { + hierarchyValue[recordsKey] = [rec]; + } + } + } else { + const hierarchyChild = hierarchyValue[childKey]; + for (const [_key, child] of childCollection) { + let hierarchyChildValue = hierarchyChild.get(child.value); + if (!hierarchyChildValue) { + hierarchyChild.set(child.value, child); + hierarchyChildValue = child; + } + + if (hierarchyChildValue[recordsKey]) { + const copy = Object.assign({}, rec); + if (rec[recordsKey]) { + // not all nested children are valid + const nestedValue = hierarchyChildValue.value; + const dimension = hierarchyChildValue.dimension; + const validRecs = rec[recordsKey].filter(x => this.extractValueFromDimension(dimension, x) === nestedValue); + copy[recordsKey] = validRecs; + } + hierarchyChildValue[recordsKey].push(copy); + } else { + hierarchyChildValue[recordsKey] = [rec]; + } + + if (child[childKey] && child[childKey].size > 0) { + this.applyHierarchyChildren(hierarchyChild, child, rec, pivotKeys); + } + } + } + } + + public static getAggregateList(val: IPivotValue, grid: PivotGridType): IPivotAggregator[] { + if (!val.aggregateList) { + let defaultAggr = this.getAggregatorsForValue(val, grid); + const isDefault = defaultAggr.find( + (x) => x.key === val.aggregate.key + ); + // resolve custom aggregations + if (!isDefault && grid.data[0][val.member] !== undefined) { + // if field exists, then we can apply default aggregations and add the custom one. + defaultAggr.unshift(val.aggregate); + } else if (!isDefault) { + // otherwise this is a custom aggregation that is not compatible + // with the defaults, since it operates on field that is not in the data + // leave only the custom one. + defaultAggr = [val.aggregate]; + } + val.aggregateList = defaultAggr; + } + return val.aggregateList; + } + + public static getAggregatorsForValue(value: IPivotValue, grid: PivotGridType): IPivotAggregator[] { + const dataType = value.dataType || grid.resolveDataTypes(grid.data[0][value.member]); + switch (dataType) { + case GridColumnDataType.Number: + case GridColumnDataType.Currency: + return IgxPivotNumericAggregate.aggregators(); + case GridColumnDataType.Date: + case GridColumnDataType.DateTime: + return IgxPivotDateAggregate.aggregators(); + case GridColumnDataType.Time: + return IgxPivotTimeAggregate.aggregators(); + default: + return IgxPivotAggregate.aggregators(); + } + } + + public static updateColumnTypeByAggregator(columns: any[], value: IPivotValue, isSingleValue: boolean): void { + const targetColumnType = PivotUtil.getColumnDataTypeForValue(value); + columns.forEach(column => { + if ((column.field?.includes(value.member) || isSingleValue) && targetColumnType !== undefined) { + column.dataType = targetColumnType; + } + }) + } + + private static getColumnDataTypeForValue(value: IPivotValue): GridColumnDataType { + const isCountAggregator = value.aggregate.aggregator?.name?.toLowerCase() === 'count' || value.aggregate.aggregatorName?.toLowerCase() === 'count'; + + if ((value.dataType === GridColumnDataType.Currency || value.dataType === GridColumnDataType.Percent) && isCountAggregator) { + return GridColumnDataType.Number; + } else if (value.dataType === GridColumnDataType.Currency && !isCountAggregator) { + return GridColumnDataType.Currency; + } else if (value.dataType === GridColumnDataType.Percent && !isCountAggregator) { + return GridColumnDataType.Percent; + } + } +} diff --git a/projects/igniteui-angular/grids/core/src/public_api.ts b/projects/igniteui-angular/grids/core/src/public_api.ts new file mode 100644 index 00000000000..1068577f3e6 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/public_api.ts @@ -0,0 +1,220 @@ +import { + IgxColumnActionsComponent, + IgxColumnHidingDirective, + IgxColumnPinningDirective +} from './column-actions/public_api'; +import { + IgxFilterCellTemplateDirective, + IgxSummaryTemplateDirective, + IgxCellTemplateDirective, + IgxCellValidationErrorDirective, + IgxCellHeaderTemplateDirective, + IgxCellFooterTemplateDirective, + IgxCellEditorTemplateDirective, + IgxCollapsibleIndicatorTemplateDirective, + IgxColumnComponent, + IgxColumnGroupComponent, + IgxColumnLayoutComponent +} from './columns/public_api'; +import { IgxAdvancedFilteringDialogComponent } from './filtering/advanced-filtering/advanced-filtering-dialog.component'; +import { + IgxGridExcelStyleFilteringComponent, + IgxExcelStyleHeaderComponent, + IgxExcelStyleSortingComponent, + IgxExcelStylePinningComponent, + IgxExcelStyleHidingComponent, + IgxExcelStyleSelectingComponent, + IgxExcelStyleClearFiltersComponent, + IgxExcelStyleConditionalFilterComponent, + IgxExcelStyleMovingComponent, + IgxExcelStyleSearchComponent, + IgxExcelStyleColumnOperationsTemplateDirective, + IgxExcelStyleFilterOperationsTemplateDirective, + IgxExcelStyleLoadingValuesTemplateDirective +} from './filtering/excel-style/public_api'; +import { IgxGridActionButtonComponent, IgxGridActionsBaseDirective, IgxGridEditingActionsComponent, IgxGridPinningActionsComponent } from './grid-actions/public_api'; +import { IgxGridFooterComponent } from './grid-footer/grid-footer.component'; +import { IgxExcelStyleHeaderIconDirective, IgxHeaderCollapsedIndicatorDirective, IgxHeaderExpandedIndicatorDirective, IgxRowCollapsedIndicatorDirective, IgxRowExpandedIndicatorDirective, IgxSortAscendingHeaderIconDirective, IgxSortDescendingHeaderIconDirective, IgxSortHeaderIconDirective, IgxGridEmptyTemplateDirective, IgxGridLoadingTemplateDirective } from './grid.directives'; +import { + IgxGridHeaderComponent, + IgxGridHeaderGroupComponent, + IgxGridHeaderRowComponent +} from './headers/public_api'; +import { IgxDragIndicatorIconDirective, IgxRowDragGhostDirective } from './row-drag.directive'; +import { IgxRowDirective } from './row.directive'; +import { + IgxRowSelectorDirective, + IgxGroupByRowSelectorDirective, + IgxHeadSelectorDirective +} from './selection/public_api'; +import { IgxGridStateDirective } from './state.directive'; +import { + IgxCSVTextDirective, + IgxExcelTextDirective, + IgxGridToolbarActionsComponent, + IgxGridToolbarAdvancedFilteringComponent, + IgxGridToolbarComponent, + IgxGridToolbarExporterComponent, + IgxGridToolbarHidingComponent, + IgxGridToolbarPinningComponent, + IgxGridToolbarTitleComponent, + IgxGridToolbarDirective +} from './toolbar/public_api'; + +export { IgxRowDirective } from './row.directive'; +export * from './grid.directives'; +export * from './grid-public-row'; +export * from './grid-public-cell'; +export * from './row-drag.directive'; +export { + IgxRowEditTextDirective, + IgxRowAddTextDirective, + IgxRowEditActionsDirective, + IgxRowEditTabStopDirective, + IgxRowEditTemplateDirective +} from './grid.rowEdit.directive'; +export * from './state-base.directive'; +export * from './state.directive'; +export * from './columns/public_api'; +export * from './headers/public_api'; +export * from './common/public_api'; +export * from './grid-actions/public_api'; +export * from './grid-footer/grid-footer.component'; +export { IgxAdvancedFilteringDialogComponent } from './filtering/advanced-filtering/advanced-filtering-dialog.component'; +export * from './filtering/excel-style/public_api'; +export * from './filtering/base/grid-filtering-cell.component'; +export * from './filtering/base/grid-filtering-row.component'; +export * from './filtering/grid-filtering.service'; +export * from './selection/public_api'; +export * from './summaries/grid-summary'; +export * from './summaries/grid-summary.service'; +export * from './summaries/summary-row.component'; +export * from './summaries/grid-root-summary.pipe'; +export * from './column-actions/public_api'; +export * from './toolbar/public_api'; +export * from './moving/moving.service'; +export * from './moving/moving.drag.directive'; +export * from './moving/moving.drop.directive'; +export * from './resizing/resizing.service'; +export * from './resizing/resizer.directive'; +export * from './resizing/resizer.component'; +export * from './resizing/pivot-grid/pivot-resize-handle.directive'; +export * from './resizing/pivot-grid/pivot-resizer.component'; +export * from './resizing/pivot-grid/pivot-resizing.service'; +export * from './grid-navigation.service'; +export * from './grid-validation.service'; +export * from './grid.common'; +export { IgxGridCellComponent } from './cell.component'; +export * from './grouping/grid-group-by-area.component'; +export * from './grouping/group-by-area.directive'; +export * from './grid-mrl-navigation.service'; +export * from './api.service'; +export * from './pivot-util'; +export * from './pivot-grid.interface'; +export * from './pivot-grid-dimensions'; +export * from './pivot-grid-aggregate'; +export * from './watch-changes'; +// Exporter services (moved from core) +export * from './services/exporter-common/base-export-service'; +export * from './services/exporter-common/exporter-options-base'; +export * from './services/exporter-common/export-utilities'; +export * from './services/csv/csv-exporter'; +export * from './services/csv/csv-exporter-options'; +export * from './services/csv/char-separated-value-data'; +export * from './services/excel/excel-exporter'; +export * from './services/excel/excel-exporter-options'; +export * from './services/pdf/pdf-exporter'; +export * from './services/pdf/pdf-exporter-options'; + +/* + +// export * from './common/shared.module'; +export * from './columns/interfaces'; +// export * from './headers/headers.module'; +// export * from './filtering/base/filtering.module'; +export * from './grid-base.directive'; +// export * from './grid-common.module'; +// +// export * from './toolbar/toolbar.module'; +export * from './grid/grid-validation.service'; + + +export * from './resizing/resize.module'; +// export * from './summaries/summary.module'; +*/ + +/* NOTE: Common grid directives collection for reuse + Import `IGX_GRID_DIRECTIVES` or `IGX_TREE_GRID_DIRECTIVES` or `IGX_HIERARCHICAL_GRID_DIRECTIVES` instead of `IGX_GRID_COMMON_DIRECTIVES` +*/ +export const IGX_GRID_COMMON_DIRECTIVES = [ + IgxRowDirective, + IgxGridFooterComponent, + IgxAdvancedFilteringDialogComponent, + IgxRowExpandedIndicatorDirective, + IgxRowCollapsedIndicatorDirective, + IgxHeaderExpandedIndicatorDirective, + IgxHeaderCollapsedIndicatorDirective, + IgxExcelStyleHeaderIconDirective, + IgxSortAscendingHeaderIconDirective, + IgxSortDescendingHeaderIconDirective, + IgxSortHeaderIconDirective, + IgxGridEmptyTemplateDirective, + IgxGridLoadingTemplateDirective, + IgxDragIndicatorIconDirective, + IgxRowDragGhostDirective, + IgxGridStateDirective, + // IGX_GRID_HEADERS_DIRECTIVES: + IgxGridHeaderComponent, + IgxGridHeaderGroupComponent, + IgxGridHeaderRowComponent, + // IGX_GRID_COLUMN_DIRECTIVES: + IgxFilterCellTemplateDirective, + IgxSummaryTemplateDirective, + IgxCellTemplateDirective, + IgxCellValidationErrorDirective, + IgxCellHeaderTemplateDirective, + IgxCellFooterTemplateDirective, + IgxCellEditorTemplateDirective, + IgxCollapsibleIndicatorTemplateDirective, + IgxColumnComponent, + IgxColumnGroupComponent, + IgxColumnLayoutComponent, + // IGX_GRID_COLUMN_ACTIONS_DIRECTIVES: + IgxColumnActionsComponent, + IgxColumnHidingDirective, + IgxColumnPinningDirective, + // IGX_GRID_SELECTION_DIRECTIVES: + IgxRowSelectorDirective, + IgxGroupByRowSelectorDirective, + IgxHeadSelectorDirective, + // IGX_GRID_TOOLBAR_DIRECTIVES: + IgxCSVTextDirective, + IgxExcelTextDirective, + IgxGridToolbarActionsComponent, + IgxGridToolbarAdvancedFilteringComponent, + IgxGridToolbarComponent, + IgxGridToolbarExporterComponent, + IgxGridToolbarHidingComponent, + IgxGridToolbarPinningComponent, + IgxGridToolbarTitleComponent, + IgxGridToolbarDirective, + // IGX_GRID_EXCEL_STYLE_FILTER_DIRECTIVES: + IgxGridExcelStyleFilteringComponent, + IgxExcelStyleHeaderComponent, + IgxExcelStyleSortingComponent, + IgxExcelStylePinningComponent, + IgxExcelStyleHidingComponent, + IgxExcelStyleSelectingComponent, + IgxExcelStyleClearFiltersComponent, + IgxExcelStyleConditionalFilterComponent, + IgxExcelStyleMovingComponent, + IgxExcelStyleSearchComponent, + IgxExcelStyleColumnOperationsTemplateDirective, + IgxExcelStyleFilterOperationsTemplateDirective, + IgxExcelStyleLoadingValuesTemplateDirective, + // IGX_GRID_ACTION_STRIP_DIRECTIVES: + IgxGridPinningActionsComponent, + IgxGridEditingActionsComponent, + IgxGridActionsBaseDirective, + IgxGridActionButtonComponent +] as const; diff --git a/projects/igniteui-angular/grids/core/src/resizing/pivot-grid/pivot-resize-handle.directive.ts b/projects/igniteui-angular/grids/core/src/resizing/pivot-grid/pivot-resize-handle.directive.ts new file mode 100644 index 00000000000..9d429901246 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/resizing/pivot-grid/pivot-resize-handle.directive.ts @@ -0,0 +1,56 @@ +import { + Directive, + inject, + Input} from '@angular/core'; +import { IgxPivotColumnResizingService } from './pivot-resizing.service' +import { IgxResizeHandleDirective } from '../resize-handle.directive'; +import { ColumnType } from 'igniteui-angular/core'; +import { PivotRowHeaderGroupType } from '../../pivot-grid.interface'; + +/** + * @hidden + * @internal + */ +@Directive({ + selector: '[igxPivotResizeHandle]', + standalone: true +}) +export class IgxPivotResizeHandleDirective extends IgxResizeHandleDirective { + public override colResizingService = inject(IgxPivotColumnResizingService); + + + /** + * @hidden + */ + @Input('igxPivotResizeHandle') + public set pivotColumn(value: ColumnType) { + this.column = value; + } + + public get pivotColumn() { + return this.column; + } + + /** + * @hidden + */ + @Input('igxPivotResizeHandleHeader') + public rowHeaderGroup: PivotRowHeaderGroupType; + + /** + * @hidden + */ + public override onDoubleClick() { + this._dblClick = true; + this.initResizeService(); + this.rowHeaderGroup.grid.autoSizeRowDimension(this.rowHeaderGroup.parent.rootDimension); + } + + /** + * @hidden + */ + protected override initResizeService(event = null) { + super.initResizeService(event); + this.colResizingService.rowHeaderGroup = this.rowHeaderGroup; + } +} diff --git a/projects/igniteui-angular/grids/core/src/resizing/pivot-grid/pivot-resizer.component.ts b/projects/igniteui-angular/grids/core/src/resizing/pivot-grid/pivot-resizer.component.ts new file mode 100644 index 00000000000..d4ca1f30f9e --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/resizing/pivot-grid/pivot-resizer.component.ts @@ -0,0 +1,14 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { IgxGridColumnResizerComponent } from '../resizer.component'; +import { IgxPivotColumnResizingService } from './pivot-resizing.service'; +import { IgxColumnResizerDirective } from '../resizer.directive'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'igx-pivot-grid-column-resizer', + templateUrl: '../resizer.component.html', + imports: [IgxColumnResizerDirective] +}) +export class IgxPivotGridColumnResizerComponent extends IgxGridColumnResizerComponent { + public override colResizingService = inject(IgxPivotColumnResizingService); +} diff --git a/projects/igniteui-angular/grids/core/src/resizing/pivot-grid/pivot-resizing.service.ts b/projects/igniteui-angular/grids/core/src/resizing/pivot-grid/pivot-resizing.service.ts new file mode 100644 index 00000000000..2fbaca7072d --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/resizing/pivot-grid/pivot-resizing.service.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@angular/core'; +import { IgxColumnResizingService } from '../resizing.service'; +import { ColumnType } from 'igniteui-angular/core'; +import { PivotRowHeaderGroupType } from '../../pivot-grid.interface'; + + +/** + * @hidden + * @internal + */ +@Injectable() +export class IgxPivotColumnResizingService extends IgxColumnResizingService { + /** + * @hidden + */ + public rowHeaderGroup: PivotRowHeaderGroupType; + + /** + * @hidden + */ + public override getColumnHeaderRenderedWidth() { + return this.rowHeaderGroup.header.nativeElement.getBoundingClientRect().width; + } + + protected override _handlePixelResize(diff: number, column: ColumnType) { + const rowDim = this.rowHeaderGroup.parent.rootDimension; + if (!rowDim) return; + + const currentColWidth = parseFloat(column.width); + const colMinWidth = column.minWidthPx; + const colMaxWidth = column.maxWidthPx; + let newWidth = currentColWidth; + if (currentColWidth + diff < colMinWidth) { + newWidth = colMinWidth; + } else if (colMaxWidth && (currentColWidth + diff > colMaxWidth)) { + newWidth = colMaxWidth; + } else { + newWidth = (currentColWidth + diff); + } + + this.rowHeaderGroup.grid.resizeRowDimensionPixels(rowDim, newWidth); + } + + protected override _handlePercentageResize() { } +} diff --git a/projects/igniteui-angular/grids/core/src/resizing/resize-handle.directive.ts b/projects/igniteui-angular/grids/core/src/resizing/resize-handle.directive.ts new file mode 100644 index 00000000000..4a2f4ecf3bf --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/resizing/resize-handle.directive.ts @@ -0,0 +1,117 @@ +import { AfterViewInit, Directive, ElementRef, Input, NgZone, HostListener, OnDestroy, inject } from '@angular/core'; +import { Subject, fromEvent } from 'rxjs'; +import { debounceTime, map, takeUntil } from 'rxjs/operators'; +import { IgxColumnResizingService } from './resizing.service'; +import { ColumnType } from 'igniteui-angular/core'; + + +/** + * @hidden + * @internal + */ +@Directive({ + selector: '[igxResizeHandle]', + standalone: true +}) +export class IgxResizeHandleDirective implements AfterViewInit, OnDestroy { + protected zone = inject(NgZone); + protected element = inject(ElementRef); + public colResizingService = inject(IgxColumnResizingService); + + + /** + * @hidden + */ + @Input('igxResizeHandle') + public column: ColumnType; + + /** + * @hidden + */ + protected _dblClick = false; + + /** + * @hidden + */ + private destroy$ = new Subject(); + + private readonly DEBOUNCE_TIME = 200; + + /** + * @hidden + */ + @HostListener('dblclick') + public onDoubleClick() { + this._dblClick = true; + this.initResizeService(); + this.colResizingService.autosizeColumnOnDblClick(); + } + + /** + * @hidden + */ + public ngOnDestroy() { + this.destroy$.next(true); + this.destroy$.complete(); + } + + /** + * @hidden + */ + public ngAfterViewInit() { + if (!this.column.columnGroup && this.column.resizable) { + this.zone.runOutsideAngular(() => { + fromEvent(this.element.nativeElement, 'mousedown').pipe( + map((event) => ({ + event, + // Preserves the original 'event.target' in a shadow DOM context. + target: event.target as HTMLElement + })), + debounceTime(this.DEBOUNCE_TIME), + takeUntil(this.destroy$) + ).subscribe(({ event, target }: { event: MouseEvent; target: HTMLElement }) => { + if (this._dblClick) { + this._dblClick = false; + return; + } + + if (event.button === 0) { + this._onResizeAreaMouseDown(event); + this.column.grid.resizeLine.resizer.onMousedown(event, target); + } + }); + }); + + fromEvent(this.element.nativeElement, 'mouseup').pipe( + debounceTime(this.DEBOUNCE_TIME), + takeUntil(this.destroy$) + ).subscribe(() => { + this.colResizingService.isColumnResizing = false; + this.colResizingService.showResizer = false; + this.column.grid.cdr.detectChanges(); + }); + } + } + + /** + * @hidden + */ + private _onResizeAreaMouseDown(event) { + this.initResizeService(event); + + this.colResizingService.showResizer = true; + this.column.grid.cdr.detectChanges(); + } + + /** + * @hidden + */ + protected initResizeService(event = null) { + this.colResizingService.column = this.column; + + if (event) { + this.colResizingService.isColumnResizing = true; + this.colResizingService.startResizePos = event.clientX; + } + } +} diff --git a/projects/igniteui-angular/grids/core/src/resizing/resize.module.ts b/projects/igniteui-angular/grids/core/src/resizing/resize.module.ts new file mode 100644 index 00000000000..6519430e9c6 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/resizing/resize.module.ts @@ -0,0 +1,38 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IgxColumnResizingService } from './resizing.service'; +import { IgxGridColumnResizerComponent } from './resizer.component'; +import { IgxResizeHandleDirective } from './resize-handle.directive'; +import { IgxColumnResizerDirective } from './resizer.directive'; +import { IgxPivotColumnResizingService } from './pivot-grid/pivot-resizing.service'; +import { IgxPivotResizeHandleDirective } from './pivot-grid/pivot-resize-handle.directive'; +import { IgxPivotGridColumnResizerComponent } from './pivot-grid/pivot-resizer.component'; + +export { IgxGridColumnResizerComponent } from './resizer.component'; +export { IgxPivotGridColumnResizerComponent } from './pivot-grid/pivot-resizer.component'; +export { IgxResizeHandleDirective } from './resize-handle.directive'; +export { IgxPivotResizeHandleDirective } from './pivot-grid/pivot-resize-handle.directive'; +export { IgxColumnResizerDirective } from './resizer.directive'; + +@NgModule({ + imports: [ + CommonModule, + IgxGridColumnResizerComponent, + IgxResizeHandleDirective, + IgxColumnResizerDirective, + IgxPivotGridColumnResizerComponent, + IgxPivotResizeHandleDirective + ], + exports: [ + IgxGridColumnResizerComponent, + IgxResizeHandleDirective, + IgxColumnResizerDirective, + IgxPivotGridColumnResizerComponent, + IgxPivotResizeHandleDirective + ], + providers: [ + IgxColumnResizingService, + IgxPivotColumnResizingService + ] +}) +export class IgxGridResizingModule {} diff --git a/projects/igniteui-angular/grids/core/src/resizing/resizer.component.html b/projects/igniteui-angular/grids/core/src/resizing/resizer.component.html new file mode 100644 index 00000000000..7da534bda24 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/resizing/resizer.component.html @@ -0,0 +1,8 @@ +
    +
    diff --git a/projects/igniteui-angular/grids/core/src/resizing/resizer.component.ts b/projects/igniteui-angular/grids/core/src/resizing/resizer.component.ts new file mode 100644 index 00000000000..b2e760491f0 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/resizing/resizer.component.ts @@ -0,0 +1,19 @@ +import { ChangeDetectionStrategy, Component, Input, ViewChild, inject } from '@angular/core'; +import { IgxColumnResizingService } from './resizing.service'; +import { IgxColumnResizerDirective } from './resizer.directive'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'igx-grid-column-resizer', + templateUrl: './resizer.component.html', + imports: [IgxColumnResizerDirective] +}) +export class IgxGridColumnResizerComponent { + public colResizingService = inject(IgxColumnResizingService); + + @Input() + public restrictResizerTop: number; + + @ViewChild(IgxColumnResizerDirective, { static: true }) + public resizer: IgxColumnResizerDirective; +} diff --git a/projects/igniteui-angular/grids/core/src/resizing/resizer.directive.ts b/projects/igniteui-angular/grids/core/src/resizing/resizer.directive.ts new file mode 100644 index 00000000000..ce89d99711a --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/resizing/resizer.directive.ts @@ -0,0 +1,126 @@ +import { Directive, ElementRef, Input, NgZone, Output, OnInit, OnDestroy, DOCUMENT, inject } from '@angular/core'; +import { Subject, fromEvent, animationFrameScheduler, interval } from 'rxjs'; +import { map, switchMap, takeUntil, throttle } from 'rxjs/operators'; + +/** + * @hidden + * @internal + */ +@Directive({ + selector: '[igxResizer]', + standalone: true +}) +export class IgxColumnResizerDirective implements OnInit, OnDestroy { + public element = inject>(ElementRef); + public document = inject(DOCUMENT); + public zone = inject(NgZone); + + + @Input() + public restrictHResizeMin: number = Number.MIN_SAFE_INTEGER; + + @Input() + public restrictHResizeMax: number = Number.MAX_SAFE_INTEGER; + + @Input() + public restrictResizerTop: number; + + @Output() + public resizeEnd = new Subject(); + + @Output() + public resizeStart = new Subject(); + + // eslint-disable-next-line @angular-eslint/no-output-native + @Output() public resize = new Subject(); + + private _left: number; + private _ratio: number = 1; + private _destroy = new Subject(); + + public get ratio(): number { + return this._ratio; + } + + constructor() { + + this.resizeStart.pipe( + takeUntil(this._destroy), + map((event) => event.clientX), + switchMap((offset) => this.resize + .pipe( + takeUntil(this._destroy), + takeUntil(this.resizeEnd), + map((event) => (event.clientX - offset) / (this._ratio)), + )) + ) + .subscribe((pos) => { + const left = this._left + pos; + const min = this._left - this.restrictHResizeMin; + const max = this._left + this.restrictHResizeMax; + + this.left = left < min ? min : left; + + if (left > max) { + this.left = max; + } + }); + + } + + public ngOnInit() { + this.zone.runOutsideAngular(() => { + fromEvent(this.document.defaultView, 'mousemove') + .pipe( + takeUntil(this._destroy), + throttle(() => interval(0, animationFrameScheduler)), + ) + .subscribe((res) => this.onMousemove(res)); + + fromEvent(this.document.defaultView, 'mouseup') + .pipe(takeUntil(this._destroy)) + .subscribe((res) => this.onMouseup(res)); + }); + } + + public ngOnDestroy() { + this._destroy.next(true); + this._destroy.complete(); + } + + public set left(val: number) { + requestAnimationFrame(() => this.element.nativeElement.style.left = val + 'px'); + } + + public set top(val: number) { + if (this.restrictResizerTop != undefined) { + requestAnimationFrame(() => this.element.nativeElement.style.top = this.restrictResizerTop + 'px'); + } else { + requestAnimationFrame(() => this.element.nativeElement.style.top = val + 'px'); + } + } + + public onMouseup(event: MouseEvent) { + this.resizeEnd.next(event); + this.resizeEnd.complete(); + } + + public onMousedown(event: MouseEvent, resizeHandleTarget: HTMLElement) { + event.preventDefault(); + const parent = this.element.nativeElement.parentElement.parentElement; + const parentRectWidth = parent.getBoundingClientRect().width; + const parentComputedWidth = parseFloat(window.getComputedStyle(parent).width); + if (Math.abs(parentRectWidth - parentComputedWidth) > 1) { + this._ratio = parentRectWidth / parentComputedWidth; + } + this.left = this._left = (event.clientX - parent.getBoundingClientRect().left) / this._ratio; + this.top = (resizeHandleTarget.getBoundingClientRect().top - parent.getBoundingClientRect().top) / this._ratio; + + this.resizeStart.next(event); + } + + public onMousemove(event: MouseEvent) { + event.preventDefault(); + this.resize.next(event); + } +} diff --git a/projects/igniteui-angular/grids/core/src/resizing/resizing.service.ts b/projects/igniteui-angular/grids/core/src/resizing/resizing.service.ts new file mode 100644 index 00000000000..4bb2cc39758 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/resizing/resizing.service.ts @@ -0,0 +1,231 @@ +import { inject, Injectable, NgZone } from '@angular/core'; +import { ColumnType } from 'igniteui-angular/core'; + +/** + * @hidden + * @internal + */ +@Injectable() +export class IgxColumnResizingService { + private zone = inject(NgZone); + + + /** + * @hidden + */ + public startResizePos: number; + /** + * Indicates that a column is currently being resized. + */ + public isColumnResizing: boolean; + /** + * @hidden + */ + public resizeCursor = 'col-resize'; + /** + * @hidden + */ + public showResizer = false; + /** + * The column being resized. + */ + public column: ColumnType; + + /** + * @hidden + */ + public getColumnHeaderRenderedWidth() { + return parseFloat(window.getComputedStyle(this.column.headerCell.nativeElement).width); + } + + /** + * @hidden + */ + public get resizerHeight(): number { + let height = this.column.grid.getVisibleContentHeight(); + + // Column height multiplier in case there are Column Layouts. The resizer height need to take into account rowStart. + let columnHeightMultiplier = 1; + if (this.column.columnLayoutChild) { + columnHeightMultiplier = this.column.grid.multiRowLayoutRowSize - this.column.rowStart + 1; + } + + if (this.column.level !== 0) { + height -= this.column.topLevelParent.headerGroup.height - this.column.headerGroup.height * columnHeightMultiplier; + } + + return height; + } + + /** + * Returns the minimal possible width to which the column can be resized. + */ + public get restrictResizeMin(): number { + const actualWidth = this.getColumnHeaderRenderedWidth(); + const minWidth = this.column.minWidthPx < actualWidth ? this.column.minWidthPx : actualWidth; + + return actualWidth - minWidth; + } + + /** + * Returns the maximal possible width to which the column can be resized. + */ + public get restrictResizeMax(): number { + const actualWidth = this.getColumnHeaderRenderedWidth(); + const maxWidth = this.column.maxWidthPx; + if (this.column.maxWidth) { + return maxWidth - actualWidth; + } else { + return Number.MAX_SAFE_INTEGER; + } + } + + /** + * Autosizes the column to the longest currently visible cell value, including the header cell. + * If the column has a predifined maxWidth and the autosized column width will become bigger than it, + * then the column is sized to its maxWidth. + */ + public autosizeColumnOnDblClick() { + const currentColWidth = this.getColumnHeaderRenderedWidth(); + this.column.width = this.column.getAutoSize(); + + this.zone.run(() => { }); + + this.column.grid.columnResized.emit({ + column: this.column, + prevWidth: currentColWidth.toString(), + newWidth: this.column.width + }); + } + + /** + * Resizes the column regaridng to the column minWidth and maxWidth. + */ + public resizeColumn(event: MouseEvent, ratio: number = 1) { + this.showResizer = false; + const diff = (event.clientX - this.startResizePos) / ratio; + + const colWidth = this.column.width; + const isPercentageWidth = colWidth && typeof colWidth === 'string' && colWidth.indexOf('%') !== -1; + let currentColWidth = parseFloat(colWidth); + const actualWidth = this.getColumnHeaderRenderedWidth(); + currentColWidth = Number.isNaN(currentColWidth) ? parseFloat(actualWidth as any) : currentColWidth; + + if (this.column.grid.hasColumnLayouts) { + this.resizeColumnLayoutFor(this.column, diff); + } else if (isPercentageWidth) { + this._handlePercentageResize(diff, this.column); + } else { + this._handlePixelResize(diff, this.column); + } + + + this.zone.run(() => { }); + + if (currentColWidth !== parseFloat(this.column.width)) { + this.column.grid.columnResized.emit({ + column: this.column, + prevWidth: isPercentageWidth ? currentColWidth + '%' : currentColWidth + 'px', + newWidth: this.column.width + }); + } + + this.isColumnResizing = false; + } + + protected _handlePixelResize(diff: number, column: ColumnType) { + const currentColWidth = parseFloat(column.width); + const colMinWidth = column.minWidthPx; + const colMaxWidth = column.maxWidthPx; + if (currentColWidth + diff < colMinWidth) { + column.width = colMinWidth + 'px'; + } else if (colMaxWidth && (currentColWidth + diff > colMaxWidth)) { + column.width = colMaxWidth + 'px'; + } else { + column.width = (currentColWidth + diff) + 'px'; + } + } + + protected _handlePercentageResize(diff: number, column: ColumnType) { + const currentPercentWidth = parseFloat(column.width); + const gridAvailableSize = column.grid.calcWidth; + + const diffPercentage = (diff / gridAvailableSize) * 100; + const colMinWidth = column.minWidthPercent; + const colMaxWidth = column.maxWidthPercent; + + if (currentPercentWidth + diffPercentage < colMinWidth) { + column.width = colMinWidth + '%'; + } else if (colMaxWidth && (currentPercentWidth + diffPercentage > colMaxWidth)) { + column.width = colMaxWidth + '%'; + } else { + column.width = (currentPercentWidth + diffPercentage) + '%'; + } + } + + protected getColMinWidth(column: ColumnType) { + let currentColWidth = parseFloat(column.width); + const actualWidth = column.headerCell.nativeElement.getBoundingClientRect().width; + currentColWidth = Number.isNaN(currentColWidth) || (currentColWidth < actualWidth) ? actualWidth : currentColWidth; + + const actualMinWidth = parseFloat(column.minWidth); + return actualMinWidth < currentColWidth ? actualMinWidth : currentColWidth; + } + + protected resizeColumnLayoutFor(column: ColumnType, diff: number) { + const relativeColumns = column.getResizableColUnderEnd(); + const combinedSpan = relativeColumns.reduce((acc, col) => acc + col.spanUsed, 0); + + // Resize first those who might reach min/max width + let columnsToResize = [...relativeColumns]; + let updatedDiff = diff; + let updatedCombinedSpan = combinedSpan; + let setMinMaxCols = false; + do { + // Cycle them until there are not ones that reach min/max size, because the diff accumulates after each cycle. + // This is because we can have at first 2 cols reaching min width and then after + // recalculating the diff there might be 1 more that reaches min width. + setMinMaxCols = false; + let newCombinedSpan = updatedCombinedSpan; + const newColsToResize = []; + columnsToResize.forEach((col) => { + const currentResizeWidth = parseFloat(col.target.calcWidth); + const resizeScaled = (diff / updatedCombinedSpan) * col.target.gridColumnSpan; + const colWidth = col.target.width; + const isPercentageWidth = colWidth && typeof colWidth === 'string' && colWidth.indexOf('%') !== -1; + + const minWidth = col.target.minWidthPx; + const maxWidth = col.target.maxWidthPx; + if (currentResizeWidth + resizeScaled < minWidth) { + col.target.width = isPercentageWidth ? col.target.minWidthPercent + '%' : minWidth + 'px'; + updatedDiff += (currentResizeWidth - minWidth); + newCombinedSpan -= col.spanUsed; + setMinMaxCols = true; + } else if (maxWidth && (currentResizeWidth + resizeScaled > maxWidth)) { + col.target.width = isPercentageWidth ? col.target.maxWidthPercent + '%' : col.target.maxWidthPx + 'px'; + updatedDiff -= (maxWidth - currentResizeWidth); + newCombinedSpan -= col.spanUsed; + setMinMaxCols = true; + } else { + // Save new ones that can be resized + newColsToResize.push(col); + } + }); + + updatedCombinedSpan = newCombinedSpan; + columnsToResize = newColsToResize; + } while (setMinMaxCols); + + // Those left that don't reach min/max size resize them normally. + columnsToResize.forEach((col) => { + const resizeScaled = (updatedDiff / updatedCombinedSpan) * col.target.gridColumnSpan; + const colWidth = col.target.width; + const isPercentageWidth = colWidth && typeof colWidth === 'string' && colWidth.indexOf('%') !== -1; + if (isPercentageWidth) { + this._handlePercentageResize(resizeScaled, col.target); + } else { + this._handlePixelResize(resizeScaled, col.target); + } + }); + } +} diff --git a/projects/igniteui-angular/grids/core/src/row-drag.directive.ts b/projects/igniteui-angular/grids/core/src/row-drag.directive.ts new file mode 100644 index 00000000000..7a31e8d12a3 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/row-drag.directive.ts @@ -0,0 +1,199 @@ +import { Directive, Input, OnDestroy, TemplateRef, inject } from '@angular/core'; +import { fromEvent, Subscription } from 'rxjs'; +import { IgxDragDirective } from 'igniteui-angular/directives'; +import { IRowDragStartEventArgs, IRowDragEndEventArgs } from './common/events'; +import { IgxGridEmptyTemplateContext, IgxGridRowDragGhostContext, RowType } from './common/grid.interface'; + + +const ghostBackgroundClass = 'igx-grid__tr--ghost'; +const gridCellClass = 'igx-grid__td'; +const rowSelectedClass = 'igx-grid__tr--selected'; +const cellSelectedClass = 'igx-grid__td--selected'; +const cellActiveClass = 'igx-grid__td--active'; + +/** + * @hidden + */ +@Directive({ + selector: '[igxRowDrag]', + standalone: true +}) +export class IgxRowDragDirective extends IgxDragDirective implements OnDestroy { + + @Input('igxRowDrag') + public override set data(value: any) { + this._data = value; + } + + public override get data(): any { + return this._data.grid.createRow(this._data.index, this._data.data); + } + + private subscription$: Subscription; + private _rowDragStarted = false; + + private get row(): RowType { + return this._data; + } + + public override onPointerDown(event) { + event.preventDefault(); + this._rowDragStarted = false; + this._removeOnDestroy = false; + super.onPointerDown(event); + } + + public override onPointerMove(event) { + super.onPointerMove(event); + if (this._dragStarted && !this._rowDragStarted) { + this._rowDragStarted = true; + const args: IRowDragStartEventArgs = { + dragDirective: this, + dragData: this.data, + dragElement: this.row.nativeElement, + cancel: false, + owner: this.row.grid + }; + + this.row.grid.rowDragStart.emit(args); + if (args.cancel) { + this.ghostElement.parentNode.removeChild(this.ghostElement); + this.ghostElement = null; + this._dragStarted = false; + this._clicked = false; + return; + } + this.row.grid.dragRowID = this.row.key; + this.row.grid.rowDragging = true; + this.row.grid.cdr.detectChanges(); + + this.subscription$ = fromEvent(this.row.grid.document.defaultView, 'keydown').subscribe((ev: KeyboardEvent) => { + if (ev.key === this.platformUtil.KEYMAP.ESCAPE) { + this._lastDropArea = false; + this.onPointerUp(event); + } + }); + } + } + + public override onPointerUp(event) { + + if (!this._clicked) { + return; + } + + const args: IRowDragEndEventArgs = { + dragDirective: this, + dragData: this.data, + dragElement: this.row.nativeElement, + animation: false, + owner: this.row.grid + }; + this.zone.run(() => { + this.row.grid.rowDragEnd.emit(args); + }); + + const dropArea = this._lastDropArea; + super.onPointerUp(event); + if (!dropArea && this.ghostElement) { + this.ghostElement.addEventListener('transitionend', this.transitionEndEvent, false); + } else { + this.endDragging(); + } + } + + protected override createGhost(pageX, pageY) { + this.row.grid.gridAPI.crudService.endEdit(false); + this.row.grid.cdr.detectChanges(); + this.ghostContext = { + $implicit: this.row.data, + data: this.row.data, + grid: this.row.grid + }; + super.createGhost(pageX, pageY, this.row.nativeElement); + + // check if there is an expander icon and create the ghost at the corresponding position + if (this.isHierarchicalGrid) { + const row = this.row as any; + if (row.expander) { + const expanderWidth = row.expander.nativeElement.getBoundingClientRect().width; + this._ghostHostX += expanderWidth; + } + } + + const ghost = this.ghostElement; + + const gridRect = this.row.grid.nativeElement.getBoundingClientRect(); + const rowRect = this.row.nativeElement.getBoundingClientRect(); + ghost.style.overflow = 'hidden'; + ghost.style.width = gridRect.width + 'px'; + ghost.style.height = rowRect.height + 'px'; + + ghost.classList.add(ghostBackgroundClass); + ghost.classList.remove(rowSelectedClass); + + const ghostCells = ghost.getElementsByClassName(gridCellClass); + for (const cell of ghostCells) { + cell.classList.remove(cellSelectedClass); + cell.classList.remove(cellActiveClass); + } + } + + private _unsubscribe() { + if (this.subscription$ && !this.subscription$.closed) { + this.subscription$.unsubscribe(); + } + } + + private endDragging() { + this.onTransitionEnd(null); + this.row.grid.dragRowID = null; + this.row.grid.rowDragging = false; + this.row.grid.cdr.detectChanges(); + this._unsubscribe(); + } + + private transitionEndEvent = () => { + if (this.ghostElement) { + this.ghostElement.removeEventListener('transitionend', this.transitionEndEvent, false); + } + this.endDragging(); + }; + + private get isHierarchicalGrid() { + return this.row.grid.type === 'hierarchical'; + } +} + +/** + * @hidden + */ +@Directive({ + selector: '[igxDragIndicatorIcon]', + standalone: true +}) + +export class IgxDragIndicatorIconDirective { + public static ngTemplateContextGuard(_directive: IgxDragIndicatorIconDirective, + context: unknown): context is IgxGridEmptyTemplateContext { + return true; + } +} + +/** + * @hidden + */ +@Directive({ + selector: '[igxRowDragGhost]', + standalone: true +}) +export class IgxRowDragGhostDirective { + public templateRef = inject>(TemplateRef); + + public static ngTemplateContextGuard(_directive: IgxRowDragGhostDirective, + context: unknown): context is IgxGridRowDragGhostContext { + return true; + } +} + + diff --git a/projects/igniteui-angular/grids/core/src/row.directive.ts b/projects/igniteui-angular/grids/core/src/row.directive.ts new file mode 100644 index 00000000000..9bab2b6624f --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/row.directive.ts @@ -0,0 +1,717 @@ +import { + AfterViewInit, + booleanAttribute, + ChangeDetectorRef, + Directive, + DoCheck, + ElementRef, + EventEmitter, + forwardRef, + HostBinding, + HostListener, + inject, + Input, + OnDestroy, + Output, + QueryList, + ViewChild, + ViewChildren +} from '@angular/core'; +import { IgxGridForOfDirective } from 'igniteui-angular/directives'; +import { ColumnType, TransactionType } from 'igniteui-angular/core'; +import { IgxGridSelectionService } from './selection/selection.service'; +import { IgxEditRow } from './common/crud.service'; +import { CellType, GridType, IGX_GRID_BASE } from './common/grid.interface'; +import { mergeWith } from 'lodash-es'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { trackByIdentity } from 'igniteui-angular/core'; +import { IgxCheckboxComponent } from 'igniteui-angular/checkbox'; + +@Directive({ + selector: '[igxRowBaseComponent]', + standalone: true +}) +export class IgxRowDirective implements DoCheck, AfterViewInit, OnDestroy { + public grid = inject(IGX_GRID_BASE); + public selectionService = inject(IgxGridSelectionService); + public element = inject>(ElementRef); + public cdr = inject(ChangeDetectorRef); + + /** + * @hidden + */ + @Output() + public addAnimationEnd = new EventEmitter(); + + /** + * @hidden + */ + @HostBinding('attr.role') + public role = 'row'; + + /** + * @hidden + */ + @Input() + public metaData: any; + + /** + * The data passed to the row component. + * + * ```typescript + * // get the row data for the first selected row + * let selectedRowData = this.grid.selectedRows[0].data; + * ``` + */ + @Input() + public get data(): any { + if (this.inEditMode) { + return mergeWith(this.grid.dataCloneStrategy.clone(this._data), this.grid.transactions.getAggregatedValue(this.key, false), + (objValue, srcValue) => { + if (Array.isArray(srcValue)) { + return objValue = srcValue; + } + }); + } + return this._data; + } + + public set data(v: any) { + this._data = v; + } + /** + * The index of the row. + * + * ```typescript + * // get the index of the second selected row + * let selectedRowIndex = this.grid.selectedRows[1].index; + * ``` + */ + @Input() + public index: number; + + /** + * Sets whether this specific row has disabled functionality for editing and row selection. + * Default value is `false`. + * ```typescript + * this.grid.selectedRows[0].pinned = true; + * ``` + */ + @Input({ transform: booleanAttribute }) + @HostBinding('attr.aria-disabled') + @HostBinding('class.igx-grid__tr--disabled') + public disabled = false; + + /** + * Sets whether the row is pinned. + * Default value is `false`. + * ```typescript + * this.grid.selectedRows[0].pinned = true; + * ``` + */ + public set pinned(value: boolean) { + if (value) { + this.grid.pinRow(this.key); + } else { + this.grid.unpinRow(this.key); + } + } + + /** + * Gets whether the row is pinned. + * ```typescript + * let isPinned = row.pinned; + * ``` + */ + public get pinned(): boolean { + return this.grid.isRecordPinned(this.data); + } + + public get hasMergedCells(): boolean { + return this.grid.columnsToMerge.length > 0; + } + + /** + * Gets the expanded state of the row. + * ```typescript + * let isExpanded = row.expanded; + * ``` + */ + public get expanded(): boolean { + return this.grid.gridAPI.get_row_expansion_state(this.data); + } + + /** + * Expands/collapses the current row. + * + * ```typescript + * this.grid.selectedRows[2].expanded = true; + * ``` + */ + public set expanded(val: boolean) { + this.grid.gridAPI.set_row_expansion_state(this.key, val); + } + + public get addRowUI(): any { + return !!this.grid.crudService.row && + this.grid.crudService.row.isAddRow && + this.grid.crudService.row.id === this.key; + } + + @HostBinding('style.min-height.px') + public get rowHeight() { + let height = this.grid.rowHeight || 32; + if (this.grid.hasColumnLayouts) { + const maxRowSpan = this.grid.multiRowLayoutRowSize; + height = height * maxRowSpan; + } + return this.addRowUI ? height : null; + } + + /** + * @hidden + */ + @Input() + public gridID: string; + + /** + * @hidden + */ + @ViewChildren('igxDirRef', { read: IgxGridForOfDirective }) + public _virtDirRow: QueryList>; + + /* blazorSuppress */ + public get virtDirRow(): IgxGridForOfDirective { + return this._virtDirRow ? this._virtDirRow.first : null; + } + + /** + * @hidden + */ + @ViewChild(forwardRef(() => IgxCheckboxComponent), { read: IgxCheckboxComponent }) + public checkboxElement: IgxCheckboxComponent; + + @ViewChildren('cell') + protected _cells: QueryList; + + /** + * Gets the rendered cells in the row component. + * + * ```typescript + * // get the cells of the third selected row + * let selectedRowCells = this.grid.selectedRows[2].cells; + * ``` + */ + public get cells() { + const res = new QueryList(); + if (!this._cells) { + return res; + } + const cList = this._cells.filter((item) => item.nativeElement.parentElement !== null) + .sort((item1, item2) => item1.column.visibleIndex - item2.column.visibleIndex); + res.reset(cList); + return res; + } + + @HostBinding('attr.data-rowIndex') + public get dataRowIndex() { + return this.index; + } + + /** + * @hidden + */ + @Input() + @HostBinding('attr.aria-selected') + public get selected(): boolean { + return this.selectionService.isRowSelected(this.key); + } + + public set selected(value: boolean) { + if (value) { + this.selectionService.selectRowsWithNoEvent([this.key]); + } else { + this.selectionService.deselectRowsWithNoEvent([this.key]); + } + this.grid.cdr.markForCheck(); + } + + /** + * @hidden + */ + public get columns(): ColumnType[] { + return this.grid.visibleColumns; + } + + /** + * @hidden + * @internal + */ + public get viewIndex(): number { + if ((this.grid as any).groupingExpressions.length) { + return this.grid.filteredSortedData.indexOf(this.data); + } + return this.index + this.grid.page * this.grid.perPage; + } + + /** + * @hidden + */ + public get pinnedColumns(): ColumnType[] { + return this.grid.pinnedColumns; + } + + /** + * @hidden + */ + public get pinnedStartColumns(): ColumnType[] { + return this.grid.pinnedStartColumns; + } + + /** + * @hidden + */ + public get pinnedEndColumns(): ColumnType[] { + return this.grid.pinnedEndColumns; + } + + /** + * @hidden + */ + public get isRoot(): boolean { + return true; + } + + /** + * @hidden + */ + public get hasChildren(): boolean { + return false; + } + + /** + * @hidden + */ + public get unpinnedColumns(): ColumnType[] { + return this.grid.unpinnedColumns; + } + + /** + * @hidden + */ + public get showRowSelectors(): boolean { + return this.grid.showRowSelectors; + } + + /** @hidden */ + public get dirty(): boolean { + const row = this.grid.transactions.getState(this.key); + if (row) { + return row.type === TransactionType.ADD || row.type === TransactionType.UPDATE; + } + + return false; + } + + /** + * @hidden + */ + public get rowDraggable(): boolean { + return this.grid.rowDraggable; + } + + /** @hidden */ + public get added(): boolean { + const row = this.grid.transactions.getState(this.key); + if (row) { + return row.type === TransactionType.ADD; + } + + return false; + } + + /** @hidden */ + public get deleted(): boolean { + return this.grid.gridAPI.row_deleted_transaction(this.key); + } + + /** + * @hidden + */ + public get dragging() { + return this.grid.dragRowID === this.key; + } + + // TODO: Refactor + public get inEditMode(): boolean { + if (this.grid.rowEditable) { + const editRowState = this.grid.crudService.row; + return (editRowState && editRowState.id === this.key) || false; + } else { + return false; + } + } + + /** + * Gets the ID of the row. + * A row in the grid is identified either by: + * - primaryKey data value, + * - the whole data, if the primaryKey is omitted. + * + * ```typescript + * let rowID = this.grid.selectedRows[2].key; + * ``` + */ + public get key() { + const primaryKey = this.grid.primaryKey; + if (this._data) { + return primaryKey ? this._data[primaryKey] : this._data; + } else { + return undefined; + } + } + + /** + * The native DOM element representing the row. Could be null in certain environments. + * + * ```typescript + * // get the nativeElement of the second selected row + * let selectedRowNativeElement = this.grid.selectedRows[1].nativeElement; + * ``` + */ + public get nativeElement() { + return this.element.nativeElement; + } + + /** + * @hidden + */ + public focused = false; + + /** + * @hidden + * @internal + */ + public defaultCssClass = 'igx-grid__tr'; + + /** + * @hidden + */ + public triggerAddAnimationClass = false; + + protected destroy$ = new Subject(); + protected _data: any; + protected _addRow: boolean; + + /** + * @hidden + * @internal + */ + @HostListener('click', ['$event']) + public onClick(event: MouseEvent) { + if (this.hasMergedCells && this.metaData?.cellMergeMeta) { + const targetRowIndex = this.grid.navigation.activeNode.row; + if (targetRowIndex != this.index) { + const row = this.grid.rowList.toArray().find(x => x.index === targetRowIndex); + row.onClick(event); + return; + } + } + this.grid.rowClick.emit({ + row: this, + event + }); + + if (this.grid.rowSelection === 'none' || this.deleted || !this.grid.selectRowOnClick) { + return; + } + if (event.shiftKey && this.grid.isMultiRowSelectionEnabled) { + this.selectionService.selectMultipleRows(this.key, this.data, event); + return; + } + + const clearSelection = !(+event.ctrlKey ^ +event.metaKey); + if (this.selected && !clearSelection) { + this.selectionService.deselectRow(this.key, event); + } else { + this.selectionService.selectRowById(this.key, clearSelection, event); + } + } + + /** + * @hidden + * @internal + */ + @HostListener('contextmenu', ['$event']) + public onContextMenu(event: MouseEvent) { + const cell = (event.target as HTMLElement).closest('.igx-grid__td'); + this.grid.contextMenu.emit({ + row: this, + cell: this.cells.find(c => c.nativeElement === cell), + event + }); + } + + /** + * @hidden + * @internal + */ + @HostListener('mouseenter') + public showActionStrip() { + if (this.grid.actionStrip) { + this.grid.actionStrip.show(this); + } + this.grid.hoverIndex = this.index; + } + + /** + * @hidden + * @internal + */ + @HostListener('mouseleave') + public hideActionStrip() { + if (this.grid.actionStrip && this.grid.actionStrip.hideOnRowLeave) { + this.grid.actionStrip.hide(); + } + this.grid.hoverIndex = null; + } + + /** + * @hidden + * @internal + */ + public ngAfterViewInit() { + // If the template of the row changes, the forOf in it is recreated and is not detected by the grid and rows can't be scrolled. + this._virtDirRow.changes.pipe(takeUntil(this.destroy$)).subscribe(() => this.grid.resetHorizontalVirtualization()); + } + + /** + * @hidden + * @internal + */ + public ngOnDestroy() { + // if action strip is shown here but row is about to be destroyed, hide it. + if (this.grid.actionStrip && this.grid.actionStrip.context === this) { + this.grid.actionStrip.hide(); + } + this.destroy$.next(true); + this.destroy$.complete(); + } + + /** + * @hidden + */ + public onRowSelectorClick(event) { + event.stopPropagation(); + if (event.shiftKey && this.grid.isMultiRowSelectionEnabled) { + this.selectionService.selectMultipleRows(this.key, this.data, event); + return; + } + if (this.selected) { + this.selectionService.deselectRow(this.key, event); + } else { + this.selectionService.selectRowById(this.key, false, event); + } + } + + /** + * Updates the specified row object and the data source record with the passed value. + * + * ```typescript + * // update the second selected row's value + * let newValue = "Apple"; + * this.grid.selectedRows[1].update(newValue); + * ``` + */ + public update(value: any) { + const crudService = this.grid.crudService; + if (crudService.cellInEditMode && crudService.cell.id.key === this.key) { + this.grid.transactions.endPending(false); + } + const row = new IgxEditRow(this.key, this.index, this.data, this.grid); + this.grid.gridAPI.update_row(row, value); + this.cdr.markForCheck(); + } + + /** + * Removes the specified row from the grid's data source. + * This method emits `rowDeleted` event. + * + * ```typescript + * // delete the third selected row from the grid + * this.grid.selectedRows[2].delete(); + * ``` + */ + public delete() { + this.grid.deleteRowById(this.key); + } + + 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; + } + + /** + * Pins the specified row. + * This method emits `rowPinning`\`rowPinned` event. + * + * ```typescript + * // pin the selected row from the grid + * this.grid.selectedRows[0].pin(); + * ``` + */ + public pin() { + return this.grid.pinRow(this.key); + } + + /** + * Unpins the specified row. + * This method emits `rowPinning`\`rowPinned` event. + * + * ```typescript + * // unpin the selected row from the grid + * this.grid.selectedRows[0].unpin(); + * ``` + */ + public unpin() { + return this.grid.unpinRow(this.key); + } + + /** + * @hidden + */ + public get rowCheckboxAriaLabel() { + return this.grid.primaryKey ? + this.selected ? 'Deselect row with key ' + this.key : 'Select row with key ' + this.key : + this.selected ? 'Deselect row' : 'Select row'; + } + + /** + * @hidden + */ + public ngDoCheck() { + this.cdr.markForCheck(); + } + + /** + * @hidden + */ + public shouldDisplayPinnedChip(col: ColumnType): boolean { + return this.pinned && this.disabled && col.visibleIndex === 0 && !this.metaData?.cellMergeMeta?.get(col.field)?.root; + } + + /** + * Spawns the add row UI for the specific row. + * + * @example + * ```typescript + * const row = this.grid1.getRowByIndex(1); + * row.beginAddRow(); + * ``` + */ + public beginAddRow() { + this.grid.crudService.enterAddRowMode(this); + } + + /** + * @hidden + */ + public triggerAddAnimation() { + this.triggerAddAnimationClass = true; + } + + /** + * @hidden + */ + public animationEndHandler() { + this.triggerAddAnimationClass = false; + this.addAnimationEnd.emit(this); + } + + protected getMergeCellSpan(col: ColumnType) { + if ((this.grid as any).shouldResize) { + return null; + } + const rowCount = this.metaData.cellMergeMeta.get(col.field).rowSpan; + let sizeSpans = ""; + 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 '; + } + return `${sizeSpans}`; + } + + protected isSelectionRoot(col: ColumnType) { + const mergeMeta = this.metaData?.cellMergeMeta; + const rowCount = mergeMeta?.get(col.field)?.rowSpan; + 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 = 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.extractRecordKey(x))).length > 0; + return inRange; + } + return false; + } + + protected isHoveredRoot(col: ColumnType) { + const mergeMeta = this.metaData?.cellMergeMeta; + const rowCount = mergeMeta?.get(col.field)?.rowSpan; + if (mergeMeta && rowCount > 1 && this.grid.hoverIndex !== null && this.grid.hoverIndex !== undefined) { + const indexInData = this.index; + const hoveredIndex = this.grid.hoverIndex; + return indexInData <= hoveredIndex && indexInData + rowCount > hoveredIndex; + } + 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() { + if ((this.grid as any).shouldResize) { + return null; + } + 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); + return size || this.grid.rowHeight; + } + + /** + * @hidden + */ + public get resolveDragIndicatorClasses(): string { + const defaultDragIndicatorCssClass = 'igx-grid__drag-indicator'; + const dragIndicatorOff = this.grid.rowDragging && !this.dragging ? 'igx-grid__drag-indicator--off' : ''; + return `${defaultDragIndicatorCssClass} ${dragIndicatorOff}`; + } + + /** + * - state persistence switching all pinned columns resets collection + * - MRL unpinnedColumns igxFor modes entire child loop on unpin + */ + protected trackPinnedColumn = trackByIdentity; +} diff --git a/projects/igniteui-angular/grids/core/src/selection/drag-select.directive.ts b/projects/igniteui-angular/grids/core/src/selection/drag-select.directive.ts new file mode 100644 index 00000000000..9aa941f4141 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/selection/drag-select.directive.ts @@ -0,0 +1,163 @@ +import { Directive, Input, Output, EventEmitter, ElementRef, OnDestroy, NgZone, OnInit, booleanAttribute, inject } from '@angular/core'; +import { interval, Observable, Subscription, Subject, animationFrameScheduler } from 'rxjs'; +import { filter, takeUntil } from 'rxjs/operators'; + +enum DragScrollDirection { + NONE, + LEFT, + TOP, + RIGHT, + BOTTOM, + TOPLEFT, + TOPRIGHT, + BOTTOMLEFT, + BOTTOMRIGHT +} + +/** + * An internal directive encapsulating the drag scroll behavior in the grid. + * + * @hidden @internal + */ +@Directive({ + selector: '[igxGridDragSelect]', + standalone: true +}) +export class IgxGridDragSelectDirective implements OnInit, OnDestroy { + private ref = inject>(ElementRef); + private zone = inject(NgZone); + + + @Output() + public dragStop = new EventEmitter(); + + @Output() + public dragScroll = new EventEmitter<{ left: number; top: number }>(); + + @Input({ alias: 'igxGridDragSelect', transform: booleanAttribute }) + public get activeDrag(): boolean { + return this._activeDrag; + } + + public set activeDrag(val: boolean) { + if (val !== this._activeDrag) { + this.unsubscribe(); + this._activeDrag = val; + } + } + + public get nativeElement() { + return this.ref.nativeElement; + } + + protected end$ = new Subject(); + protected lastDirection = DragScrollDirection.NONE; + protected _interval$: Observable; + protected _sub: Subscription; + + private _activeDrag: boolean; + + constructor() { + this._interval$ = interval(0, animationFrameScheduler).pipe( + takeUntil(this.end$), + filter(() => this.activeDrag) + ); + } + + public ngOnInit() { + this.zone.runOutsideAngular(() => { + this.nativeElement.addEventListener('pointerover', this.startDragSelection); + this.nativeElement.addEventListener('pointerleave', this.stopDragSelection); + }); + } + + public ngOnDestroy() { + this.zone.runOutsideAngular(() => { + this.nativeElement.removeEventListener('pointerover', this.startDragSelection); + this.nativeElement.removeEventListener('pointerleave', this.stopDragSelection); + }); + this.unsubscribe(); + this.end$.complete(); + } + + + protected startDragSelection = (ev: PointerEvent) => { + if (!this.activeDrag) { + return; + } + + const x = ev.clientX; + const y = ev.clientY; + const { direction, delta } = this._measureDimensions(x, y); + + if (direction === this.lastDirection) { + return; + } + + this.unsubscribe(); + this._sub = this._interval$.subscribe(() => this.dragScroll.emit(delta)); + this.lastDirection = direction; + }; + + protected stopDragSelection = () => { + if (!this.activeDrag) { + return; + } + + this.dragStop.emit(false); + this.unsubscribe(); + this.lastDirection = DragScrollDirection.NONE; + }; + + protected _measureDimensions(x: number, y: number): { direction: DragScrollDirection; delta: { left: number; top: number } } { + let direction: DragScrollDirection; + let delta = { left: 0, top: 0 }; + const { left, top, width, height } = this.nativeElement.getBoundingClientRect(); + const RATIO = 0.15; + + const offsetX = Math.trunc(x - left); + const offsetY = Math.trunc(y - top); + + const leftDirection = offsetX <= width * RATIO; + const rightDirection = offsetX >= width * (1 - RATIO); + const topDirection = offsetY <= height * RATIO; + const bottomDirection = offsetY >= height * (1 - RATIO); + + if (topDirection && leftDirection) { + direction = DragScrollDirection.TOPLEFT; + delta = { left: -1, top: -1 }; + } else if (topDirection && rightDirection) { + direction = DragScrollDirection.TOPRIGHT; + delta = { left: 1, top: -1 }; + } else if (bottomDirection && leftDirection) { + direction = DragScrollDirection.BOTTOMLEFT; + delta = { left: -1, top: 1 }; + } else if (bottomDirection && rightDirection) { + direction = DragScrollDirection.BOTTOMRIGHT; + delta = { top: 1, left: 1 }; + } else if (topDirection) { + direction = DragScrollDirection.TOP; + delta.top = -1; + } else if (bottomDirection) { + direction = DragScrollDirection.BOTTOM; + delta.top = 1; + } else if (leftDirection) { + direction = DragScrollDirection.LEFT; + delta.left = -1; + } else if (rightDirection) { + direction = DragScrollDirection.RIGHT; + delta.left = 1; + } else { + direction = DragScrollDirection.NONE; + } + + return { direction, delta }; + + } + + protected unsubscribe() { + if (this._sub) { + this._sub.unsubscribe(); + } + } +} diff --git a/projects/igniteui-angular/grids/core/src/selection/public_api.ts b/projects/igniteui-angular/grids/core/src/selection/public_api.ts new file mode 100644 index 00000000000..812ae047627 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/selection/public_api.ts @@ -0,0 +1,12 @@ +// import { IgxGroupByRowSelectorDirective, IgxHeadSelectorDirective, IgxRowSelectorDirective } from './row-selectors'; + +export * from './row-selectors'; +export * from './selection.service'; +export * from './drag-select.directive'; + +/* NOTE: Grid selection directives collection for ease-of-use import in standalone components scenario */ +// export const IGX_GRID_SELECTION_DIRECTIVES = [ +// IgxRowSelectorDirective, +// IgxGroupByRowSelectorDirective, +// IgxHeadSelectorDirective +// ] as const; diff --git a/projects/igniteui-angular/grids/core/src/selection/row-selectors.ts b/projects/igniteui-angular/grids/core/src/selection/row-selectors.ts new file mode 100644 index 00000000000..81dac7d8be0 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/selection/row-selectors.ts @@ -0,0 +1,56 @@ +import { Directive, TemplateRef, inject } from '@angular/core'; +import { IgxHeadSelectorTemplateContext, IgxGroupByRowSelectorTemplateContext, IgxRowSelectorTemplateContext } from '../common/grid.interface'; + +/** + * @hidden + * @internal + */ +@Directive({ + selector: '[igxRowSelector]', + standalone: true +}) +export class IgxRowSelectorDirective { + public templateRef = inject>(TemplateRef); + + + public static ngTemplateContextGuard(_directive: IgxRowSelectorDirective, + context: unknown): context is IgxRowSelectorTemplateContext { + return true + } +} + +/** + * @hidden + * @internal + */ +@Directive({ + selector: '[igxGroupByRowSelector]', + standalone: true +}) +export class IgxGroupByRowSelectorDirective { + public templateRef = inject>(TemplateRef); + + + public static ngTemplateContextGuard(_directive: IgxGroupByRowSelectorDirective, + context: unknown): context is IgxGroupByRowSelectorTemplateContext { + return true + } +} + +/** + * @hidden + * @internal + */ +@Directive({ + selector: '[igxHeadSelector]', + standalone: true +}) +export class IgxHeadSelectorDirective { + public templateRef = inject>(TemplateRef); + + + public static ngTemplateContextGuard(_directive: IgxHeadSelectorDirective, + context: unknown): context is IgxHeadSelectorTemplateContext { + return true + } +} diff --git a/projects/igniteui-angular/grids/core/src/selection/selection.service.ts b/projects/igniteui-angular/grids/core/src/selection/selection.service.ts new file mode 100644 index 00000000000..db318ff7c91 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/selection/selection.service.ts @@ -0,0 +1,868 @@ +import { EventEmitter, Injectable, NgZone, inject } from '@angular/core'; +import { Subject } from 'rxjs'; +import { IRowSelectionEventArgs } from '../common/events'; +import { GridType } from '../common/grid.interface'; +import { FilteringExpressionsTree, PlatformUtil } from 'igniteui-angular/core'; +import { GridSelectionRange, IColumnSelectionState, IMultiRowLayoutNode, ISelectionKeyboardState, ISelectionNode, ISelectionPointerState, SelectionState } from '../common/types'; +import { PivotUtil } from '../pivot-util'; + + +@Injectable() +export class IgxGridSelectionService { + private zone = inject(NgZone); + protected platform = inject(PlatformUtil); + + public grid: GridType; + public dragMode = false; + public activeElement: ISelectionNode | null; + public keyboardState = {} as ISelectionKeyboardState; + public pointerState = {} as ISelectionPointerState; + public columnsState = {} as IColumnSelectionState; + + public selection = new Map>(); + public temp = new Map>(); + public rowSelection: Set = new Set(); + public indeterminateRows: Set = new Set(); + public columnSelection: Set = new Set(); + /** + * @hidden @internal + */ + 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. + */ + private pointerEventInGridBody = false; + + private allRowsSelected: boolean; + private _lastSelectedNode: ISelectionNode; + private _ranges: Set = new Set(); + private _selectionRange: Range; + + /** + * Returns the current selected ranges in the grid from both + * keyboard and pointer interactions + */ + public get ranges(): GridSelectionRange[] { + + // The last action was keyboard + shift selection -> add it + this.addKeyboardRange(); + + const ranges = Array.from(this._ranges).map(range => JSON.parse(range)); + + // No ranges but we have a focused cell -> add it + if (!ranges.length && this.activeElement && this.grid.isCellSelectable) { + ranges.push(this.generateRange(this.activeElement)); + } + + return ranges; + } + + public get primaryButton(): boolean { + return this.pointerState.primaryButton; + } + + public set primaryButton(value: boolean) { + this.pointerState.primaryButton = value; + } + + constructor() { + this.initPointerState(); + this.initKeyboardState(); + this.initColumnsState(); + } + + /** + * Resets the keyboard state + */ + public initKeyboardState(): void { + this.keyboardState.node = null; + this.keyboardState.shift = false; + this.keyboardState.range = null; + this.keyboardState.active = false; + } + + /** + * Resets the pointer state + */ + public initPointerState(): void { + this.pointerState.node = null; + this.pointerState.ctrl = false; + this.pointerState.shift = false; + this.pointerState.range = null; + this.pointerState.primaryButton = true; + } + + /** + * Resets the columns state + */ + public initColumnsState(): void { + this.columnsState.field = null; + this.columnsState.range = []; + } + + /** + * Adds a single node. + * Single clicks | Ctrl + single clicks on cells is the usual case. + */ + public add(node: ISelectionNode, addToRange = true): void { + if (this.selection.has(node.row)) { + this.selection.get(node.row).add(node.column); + } else { + this.selection.set(node.row, new Set()).get(node.row).add(node.column); + } + + if (addToRange) { + this._ranges.add(JSON.stringify(this.generateRange(node))); + } + } + + /** + * Adds the active keyboard range selection (if any) to the `ranges` meta. + */ + public addKeyboardRange(): void { + if (this.keyboardState.range) { + this._ranges.add(JSON.stringify(this.keyboardState.range)); + } + } + + public remove(node: ISelectionNode): void { + if (this.selection.has(node.row)) { + this.selection.get(node.row).delete(node.column); + } + if (this.isActiveNode(node)) { + this.activeElement = null; + } + this._ranges.delete(JSON.stringify(this.generateRange(node))); + } + + public isInMap(node: ISelectionNode): boolean { + return (this.selection.has(node.row) && this.selection.get(node.row).has(node.column)) || + (this.temp.has(node.row) && this.temp.get(node.row).has(node.column)); + } + + public selected(node: ISelectionNode): boolean { + return (this.isActiveNode(node) && this.grid.isCellSelectable) || this.isInMap(node); + } + + public isActiveNode(node: ISelectionNode): boolean { + if (this.activeElement) { + const isActive = this.activeElement.column === node.column && this.activeElement.row === node.row; + if (this.grid.hasColumnLayouts) { + const layout = this.activeElement.layout; + return isActive && this.isActiveLayout(layout, node.layout); + } + return isActive; + } + return false; + } + + public isActiveLayout(current: IMultiRowLayoutNode, target: IMultiRowLayoutNode): boolean { + return current.columnVisibleIndex === target.columnVisibleIndex; + } + + public addRangeMeta(node: ISelectionNode, state?: SelectionState): void { + this._ranges.add(JSON.stringify(this.generateRange(node, state))); + } + + public removeRangeMeta(node: ISelectionNode, state?: SelectionState): void { + this._ranges.delete(JSON.stringify(this.generateRange(node, state))); + } + + /** + * Generates a new selection range from the given `node`. + * If `state` is passed instead it will generate the range based on the passed `node` + * and the start node of the `state`. + */ + public generateRange(node: ISelectionNode, state?: SelectionState): GridSelectionRange { + this._lastSelectedNode = node; + + if (!state) { + return { + rowStart: node.row, + rowEnd: node.row, + columnStart: node.column, + columnEnd: node.column + }; + } + + const { row, column } = state.node; + const rowStart = Math.min(node.row, row); + const rowEnd = Math.max(node.row, row); + const columnStart = Math.min(node.column, column); + const columnEnd = Math.max(node.column, column); + + return { rowStart, rowEnd, columnStart, columnEnd }; + } + + /** + * + */ + public keyboardStateOnKeydown(node: ISelectionNode, shift: boolean, shiftTab: boolean): void { + this.keyboardState.active = true; + this.initPointerState(); + this.keyboardState.shift = shift && !shiftTab; + if (!this.grid.navigation.isDataRow(node.row)) { + return; + } + // Kb navigation with shift and no previous node. + // Clear the current selection init the start node. + if (this.keyboardState.shift && !this.keyboardState.node) { + this.clear(); + this.keyboardState.node = Object.assign({}, node); + } + } + + public keyboardStateOnFocus(node: ISelectionNode, emitter: EventEmitter, dom): void { + const kbState = this.keyboardState; + + // Focus triggered by keyboard navigation + if (kbState.active) { + if (this.platform.isChromium) { + this._moveSelectionChrome(dom); + } + // Start generating a range if shift is hold + if (kbState.shift) { + this.dragSelect(node, kbState); + kbState.range = this.generateRange(node, kbState); + emitter.emit(this.generateRange(node, kbState)); + return; + } + + this.initKeyboardState(); + this.clear(); + this.add(node); + } + } + + public pointerDown(node: ISelectionNode, shift: boolean, ctrl: boolean): void { + this.addKeyboardRange(); + this.initKeyboardState(); + this.pointerState.ctrl = ctrl; + this.pointerState.shift = shift; + this.pointerEventInGridBody = true; + this.grid.document.body.addEventListener('pointerup', this.pointerOriginHandler); + + // No ctrl key pressed - no multiple selection + if (!ctrl) { + this.clear(); + } + + if (shift) { + // No previously 'clicked' node. Use the last active node. + if (!this.pointerState.node) { + this.pointerState.node = this.activeElement || node; + } + this.pointerDownShiftKey(node); + this.clearTextSelection(); + return; + } + + this.removeRangeMeta(node); + this.pointerState.node = node; + } + + public pointerDownShiftKey(node: ISelectionNode): void { + this.clear(); + this.selectRange(node, this.pointerState); + } + + public mergeMap(target: Map>, source: Map>): void { + const iterator = source.entries(); + let pair = iterator.next(); + let key: number; + let value: Set; + + while (!pair.done) { + [key, value] = pair.value; + if (target.has(key)) { + const newValue = target.get(key); + value.forEach(record => newValue.add(record)); + target.set(key, newValue); + } else { + target.set(key, value); + } + pair = iterator.next(); + } + } + + public pointerEnter(node: ISelectionNode, event: PointerEvent): boolean { + // https://www.w3.org/TR/pointerevents/#the-button-property + this.dragMode = (event.buttons === 1 && (event.button === -1 || event.button === 0)) && this.pointerEventInGridBody; + if (!this.dragMode) { + return false; + } + this.clearTextSelection(); + + // If the users triggers a drag-like event by first clicking outside the grid cells + // and then enters in the grid body we may not have a initial pointer starting node. + // Assume the first pointerenter node is where we start. + if (!this.pointerState.node) { + this.pointerState.node = node; + } + + if (this.pointerState.ctrl) { + this.selectRange(node, this.pointerState, this.temp); + } else { + this.dragSelect(node, this.pointerState); + } + return true; + } + + public pointerUp(node: ISelectionNode, emitter: EventEmitter, firedOutsideGrid?: boolean): boolean { + if (this.dragMode || firedOutsideGrid) { + this.restoreTextSelection(); + this.addRangeMeta(node, this.pointerState); + this.mergeMap(this.selection, this.temp); + this.zone.runTask(() => emitter.emit(this.generateRange(node, this.pointerState))); + this.temp.clear(); + this.dragMode = false; + return true; + } + + if (this.pointerState.shift) { + this.clearTextSelection(); + this.restoreTextSelection(); + this.addRangeMeta(node, this.pointerState); + emitter.emit(this.generateRange(node, this.pointerState)); + return true; + } + + if (this.pointerEventInGridBody && this.isActiveNode(node)) { + this.add(node); + } + return false; + } + + public selectRange(node: ISelectionNode, state: SelectionState, collection: Map> = this.selection): void { + if (collection === this.temp) { + collection.clear(); + } + const { rowStart, rowEnd, columnStart, columnEnd } = this.generateRange(node, state); + for (let i = rowStart; i <= rowEnd; i++) { + for (let j = columnStart as number; j <= (columnEnd as number); j++) { + if (collection.has(i)) { + collection.get(i).add(j); + } else { + collection.set(i, new Set()).get(i).add(j); + } + } + } + + this.selectedRangeChange.next(collection); + } + + public dragSelect(node: ISelectionNode, state: SelectionState): void { + if (!this.pointerState.ctrl) { + this.selection.clear(); + } + this.selectRange(node, state); + } + + public clear(clearAcriveEl = false): void { + if (clearAcriveEl) { + this.activeElement = null; + } + this.selection.clear(); + this.temp.clear(); + this._ranges.clear(); + } + + public clearTextSelection(): void { + const selection = window.getSelection(); + if (selection.rangeCount) { + this._selectionRange = selection.getRangeAt(0); + this._selectionRange.collapse(true); + selection.removeAllRanges(); + } + } + + public restoreTextSelection(): void { + const selection = window.getSelection(); + if (!selection.rangeCount) { + selection.addRange(this._selectionRange || this.grid.document.createRange()); + } + } + + public getSelectedRowsData() { + if (this.grid.type === 'pivot') { + return this.grid.dataView.filter(r => { + const keys = r.dimensions.map(d => PivotUtil.getRecordKey(r, d)); + return keys.some(k => this.isPivotRowSelected(k)); + }); + } + if (!this.grid.primaryKey) { + return Array.from(this.rowSelection); + } + const selection = []; + const gridDataMap = {}; + this.grid.gridAPI.get_all_data(true).forEach(row => gridDataMap[this.getRecordKey(row)] = row); + this.rowSelection.forEach(rID => { + const rData = gridDataMap[rID]; + const partialRowData = {}; + partialRowData[this.grid.primaryKey] = rID; + selection.push(rData ? rData : partialRowData); + }); + return selection; + } + + /** Returns array of the selected row id's. */ + public getSelectedRows(): Array { + return this.rowSelection.size ? Array.from(this.rowSelection.keys()) : []; + } + + /** Returns array of the rows in indeterminate state. */ + public getIndeterminateRows(): Array { + return this.indeterminateRows.size ? Array.from(this.indeterminateRows.keys()) : []; + } + + /** Clears row selection, if filtering is applied clears only selected rows from filtered data. */ + public clearRowSelection(event?): void { + const selectedRows = this.getSelectedRowsData(); + const removedRec = this.isFilteringApplied() ? + this.allData.filter(row => this.isRowSelected(this.getRecordKey(row))) : selectedRows; + let newSelection; + if (this.grid.primaryKey) { + newSelection = this.isFilteringApplied() ? selectedRows.filter(x => { + return !removedRec.some(item => item[this.grid.primaryKey] === x[this.grid.primaryKey]); + }) : []; + } else { + newSelection = this.isFilteringApplied() ? selectedRows.filter(x => !removedRec.includes(x)) : []; + } + this.emitRowSelectionEvent(newSelection, [], removedRec, event, selectedRows); + } + + /** Select all rows, if filtering is applied select only from filtered data. */ + public selectAllRows(event?) { + const addedRows = this.allData.filter((row) => !this.rowSelection.has(this.getRecordKey(row))); + const selectedRows = this.getSelectedRowsData(); + const newSelection = this.rowSelection.size ? selectedRows.concat(addedRows) : addedRows; + this.indeterminateRows.clear(); + this.emitRowSelectionEvent(newSelection, addedRows, [], event, selectedRows); + } + + /** Select the specified row and emit event. */ + public selectRowById(rowID, clearPrevSelection?, event?): void { + if (!(this.grid.isRowSelectable || this.grid.type === 'pivot') || this.isRowDeleted(rowID)) { + return; + } + clearPrevSelection = !this.grid.isMultiRowSelectionEnabled || clearPrevSelection; + if (this.grid.type === 'pivot') { + this.selectPivotRowById(rowID, clearPrevSelection, event); + return; + } + const selectedRows = this.getSelectedRowsData(); + const newSelection = clearPrevSelection ? [this.getRowDataById(rowID)] : this.rowSelection.has(rowID) ? + selectedRows : [...selectedRows, this.getRowDataById(rowID)]; + const removed = clearPrevSelection ? selectedRows : []; + this.emitRowSelectionEvent(newSelection, [this.getRowDataById(rowID)], removed, event, selectedRows); + } + + public selectPivotRowById(rowID, clearPrevSelection: boolean, event?): void { + const selectedRows = this.getSelectedRows(); + const newSelection = clearPrevSelection ? [rowID] : this.rowSelection.has(rowID) ? selectedRows : [...selectedRows, rowID]; + const added = this.getPivotRowsByIds([rowID]); + const removed = this.getPivotRowsByIds(clearPrevSelection ? selectedRows : []); + this.emitRowSelectionEventPivotGrid(selectedRows, newSelection, added, removed, event); + } + + /** Deselect the specified row and emit event. */ + public deselectRow(rowID, event?): void { + if (!this.isRowSelected(rowID)) { + return; + } + if(this.grid.type === 'pivot') { + this.deselectPivotRowByID(rowID, event); + return; + } + const selectedRows = this.getSelectedRowsData(); + const newSelection = selectedRows.filter(r => this.getRecordKey(r) !== rowID); + if (this.rowSelection.size && this.rowSelection.has(rowID)) { + this.emitRowSelectionEvent(newSelection, [], [this.getRowDataById(rowID)], event, selectedRows); + } + } + + public deselectPivotRowByID(rowID, event?) { + if (this.rowSelection.size && this.rowSelection.has(rowID)) { + const currSelection = this.getSelectedRows(); + const newSelection = currSelection.filter(r => r !== rowID); + const removed = this.getPivotRowsByIds([rowID]); + this.emitRowSelectionEventPivotGrid(currSelection, newSelection, [], removed, event); + } + } + + private emitRowSelectionEventPivotGrid(currSelection, newSelection, added, removed, event) { + if (this.areEqualCollections(currSelection, newSelection)) { + return; + } + const currSelectedRows = this.getSelectedRowsData(); + const args: IRowSelectionEventArgs = { + owner: this.grid, + oldSelection: currSelectedRows, + newSelection: this.getPivotRowsByIds(newSelection), + added, + removed, + event, + cancel: false, + allRowsSelected: this.areAllRowSelected(newSelection) + }; + this.grid.rowSelectionChanging.emit(args); + if (args.cancel) { + this.clearHeaderCBState(); + return; + } + this.selectRowsWithNoEvent(newSelection, true); + } + + /** Select the specified rows and emit event. */ + public selectRows(keys: any[], clearPrevSelection?: boolean, event?): void { + if (!this.grid.isMultiRowSelectionEnabled) { + return; + } + + let rowsToSelect = keys.filter(x => !this.isRowDeleted(x) && !this.rowSelection.has(x)); + if (!rowsToSelect.length && !clearPrevSelection) { + // no valid/additional rows to select and no clear + return; + } + + const selectedRows = this.getSelectedRowsData(); + rowsToSelect = this.grid.primaryKey ? rowsToSelect.map(r => this.getRowDataById(r)) : rowsToSelect; + const newSelection = clearPrevSelection ? rowsToSelect : [...selectedRows, ...rowsToSelect]; + const keysAsSet = new Set(rowsToSelect); + const removed = clearPrevSelection ? selectedRows.filter(x => !keysAsSet.has(x)) : []; + this.emitRowSelectionEvent(newSelection, rowsToSelect, removed, event, selectedRows); + } + + public deselectRows(keys: any[], event?): void { + if (!this.rowSelection.size) { + return; + } + let rowsToDeselect = keys.filter(x => this.rowSelection.has(x)); + if (!rowsToDeselect.length) { + return; + } + const selectedRows = this.getSelectedRowsData(); + rowsToDeselect = this.grid.primaryKey ? rowsToDeselect.map(r => this.getRowDataById(r)) : rowsToDeselect; + const keysAsSet = new Set(rowsToDeselect); + const newSelection = selectedRows.filter(r => !keysAsSet.has(r)); + this.emitRowSelectionEvent(newSelection, [], rowsToDeselect, event, selectedRows); + } + + /** Select specified rows. No event is emitted. */ + public selectRowsWithNoEvent(rowIDs: any[], clearPrevSelection?): void { + if (clearPrevSelection) { + this.rowSelection.clear(); + } + rowIDs.forEach(rowID => this.rowSelection.add(rowID)); + this.clearHeaderCBState(); + this.selectedRowsChange.next(rowIDs); + } + + /** Deselect specified rows. No event is emitted. */ + public deselectRowsWithNoEvent(rowIDs: any[]): void { + this.clearHeaderCBState(); + rowIDs.forEach(rowID => this.rowSelection.delete(rowID)); + this.selectedRowsChange.next(this.getSelectedRows()); + } + + public isRowSelected(rowID): boolean { + return this.rowSelection.size > 0 && this.rowSelection.has(rowID); + } + + public isPivotRowSelected(rowID): boolean { + let contains = false; + this.rowSelection.forEach(x => { + const correctRowId = rowID.replace(x,''); + if (rowID.includes(x) && (correctRowId === '' || correctRowId.startsWith('_')) ) { + contains = true; + return; + } + }); + return this.rowSelection.size > 0 && contains; + } + + public isRowInIndeterminateState(rowID): boolean { + return this.indeterminateRows.size > 0 && this.indeterminateRows.has(rowID); + } + + /** Select range from last selected row to the current specified row. */ + public selectMultipleRows(rowID, rowData, event?): void { + this.clearHeaderCBState(); + if (!this.rowSelection.size || this.isRowDeleted(rowID)) { + this.selectRowById(rowID); + return; + } + const gridData = this.allData; + const lastRowID = this.getSelectedRows()[this.rowSelection.size - 1]; + const currIndex = gridData.indexOf(this.getRowDataById(lastRowID)); + const newIndex = gridData.indexOf(rowData); + const rows = gridData.slice(Math.min(currIndex, newIndex), Math.max(currIndex, newIndex) + 1); + const currSelection = this.getSelectedRowsData(); + const added = rows.filter(r => !this.isRowSelected(this.getRecordKey(r))); + const newSelection = currSelection.concat(added); + this.emitRowSelectionEvent(newSelection, added, [], event, currSelection); + } + + public areAllRowSelected(newSelection?): boolean { + if (!this.grid.data && !newSelection) { + return false; + } + if (this.allRowsSelected !== undefined && !newSelection) { + return this.allRowsSelected; + } + const selectedData = new Set(this.getRowIDs(newSelection || this.rowSelection)); + return this.allRowsSelected = this.allData.length > 0 && this.allData.every(row => selectedData.has(this.getRecordKey(row))); + } + + public hasSomeRowSelected(): boolean { + const filteredData = this.isFilteringApplied() ? + this.getRowIDs(this.grid.filteredData).some(rID => this.isRowSelected(rID)) : true; + return this.rowSelection.size > 0 && filteredData && !this.areAllRowSelected(); + } + + public get filteredSelectedRowIds(): any[] { + return this.isFilteringApplied() ? + this.getRowIDs(this.allData).filter(rowID => this.isRowSelected(rowID)) : + this.getSelectedRows().filter(rowID => !this.isRowDeleted(rowID)); + } + + public emitRowSelectionEvent(newSelection, added, removed, event?, currSelection?): boolean { + currSelection = currSelection ?? this.getSelectedRowsData(); + if (this.areEqualCollections(currSelection, newSelection)) { + return; + } + + const args: IRowSelectionEventArgs = { + owner: this.grid, + oldSelection: currSelection, + newSelection, + added, + removed, + event, + cancel: false, + allRowsSelected: this.areAllRowSelected(newSelection) + }; + + this.grid.rowSelectionChanging.emit(args); + if (args.cancel) { + this.clearHeaderCBState(); + return; + } + this.selectRowsWithNoEvent(args.newSelection.map(r => this.getRecordKey(r)), true); + } + + public getPivotRowsByIds(ids: any[]) { + return this.grid.dataView.filter(r => { + const keys = r.dimensions.map(d => PivotUtil.getRecordKey(r, d)); + return new Set(ids.concat(keys)).size < ids.length + keys.length; + }); + } + + public getRowDataById(rowID): any { + if (!this.grid.primaryKey) { + return rowID; + } + const rowIndex = this.getRowIDs(this.grid.gridAPI.get_all_data(true)).indexOf(rowID); + return rowIndex < 0 ? rowID : this.grid.gridAPI.get_all_data(true)[rowIndex]; + } + + public clearHeaderCBState(): void { + this.allRowsSelected = undefined; + } + + public getRowIDs(data): Array { + return this.grid.primaryKey && data.length ? data.map(rec => rec[this.grid.primaryKey]) : data; + } + + public getRecordKey(record) { + return this.grid.primaryKey ? record[this.grid.primaryKey] : record; + } + + /** Clear rowSelection and update checkbox state */ + public clearAllSelectedRows(): void { + this.rowSelection.clear(); + this.indeterminateRows.clear(); + this.clearHeaderCBState(); + this.selectedRowsChange.next([]); + } + + /** Returns all data in the grid, with applied filtering and sorting and without deleted rows. */ + public get allData(): Array { + let allData; + // V.T. Jan 17th, 2024 #13757 Adding an additional conditional check to take account WITHIN range of groups + if (this.isFilteringApplied() || this.grid.sortingExpressions.length || this.grid.groupingExpressions?.length) { + allData = this.grid.pinnedRecordsCount ? this.grid._filteredSortedUnpinnedData : this.grid.filteredSortedData; + } else { + allData = this.grid.gridAPI.get_all_data(true); + } + return allData.filter(rData => !this.isRowDeleted(this.grid.gridAPI.get_row_id(rData))); + } + + /** Returns array of the selected columns fields. */ + public getSelectedColumns(): Array { + return this.columnSelection.size ? Array.from(this.columnSelection.keys()) : []; + } + + public isColumnSelected(field: string): boolean { + return this.columnSelection.size > 0 && this.columnSelection.has(field); + } + + /** Select the specified column and emit event. */ + public selectColumn(field: string, clearPrevSelection?, selectColumnsRange?, event?): void { + const stateColumn = this.columnsState.field ? this.grid.getColumnByName(this.columnsState.field) : null; + if (!event || !stateColumn || stateColumn.visibleIndex < 0 || !selectColumnsRange) { + this.columnsState.field = field; + this.columnsState.range = []; + + const newSelection = clearPrevSelection ? [field] : this.getSelectedColumns().indexOf(field) !== -1 ? + this.getSelectedColumns() : [...this.getSelectedColumns(), field]; + const removed = clearPrevSelection ? this.getSelectedColumns().filter(colField => colField !== field) : []; + const added = this.isColumnSelected(field) ? [] : [field]; + this.emitColumnSelectionEvent(newSelection, added, removed, event); + } else if (selectColumnsRange) { + this.selectColumnsRange(field, event); + } + } + + /** Select specified columns. And emit event. */ + public selectColumns(fields: string[], clearPrevSelection?, selectColumnsRange?, event?): void { + const columns = fields.map(f => this.grid.getColumnByName(f)).sort((a, b) => a.visibleIndex - b.visibleIndex); + const stateColumn = this.columnsState.field ? this.grid.getColumnByName(this.columnsState.field) : null; + if (!stateColumn || stateColumn.visibleIndex < 0 || !selectColumnsRange) { + this.columnsState.field = columns[0] ? columns[0].field : null; + this.columnsState.range = []; + + const added = fields.filter(colField => !this.isColumnSelected(colField)); + const removed = clearPrevSelection ? this.getSelectedColumns().filter(colField => fields.indexOf(colField) === -1) : []; + const newSelection = clearPrevSelection ? fields : this.getSelectedColumns().concat(added); + + this.emitColumnSelectionEvent(newSelection, added, removed, event); + } else { + const filedStart = stateColumn.visibleIndex > + columns[columns.length - 1].visibleIndex ? columns[0].field : columns[columns.length - 1].field; + this.selectColumnsRange(filedStart, event); + } + } + + /** Select range from last clicked column to the current specified column. */ + public selectColumnsRange(field: string, event): void { + const currIndex = this.grid.getColumnByName(this.columnsState.field).visibleIndex; + const newIndex = this.grid.columnToVisibleIndex(field); + const columnsFields = this.grid.visibleColumns + .filter(c => !c.columnGroup) + .sort((a, b) => a.visibleIndex - b.visibleIndex) + .slice(Math.min(currIndex, newIndex), Math.max(currIndex, newIndex) + 1) + .filter(col => col.selectable).map(col => col.field); + const removed = []; + const oldAdded = []; + const added = columnsFields.filter(colField => !this.isColumnSelected(colField)); + this.columnsState.range.forEach(f => { + if (columnsFields.indexOf(f) === -1) { + removed.push(f); + } else { + oldAdded.push(f); + } + }); + this.columnsState.range = columnsFields.filter(colField => !this.isColumnSelected(colField) || oldAdded.indexOf(colField) > -1); + const newSelection = this.getSelectedColumns().concat(added).filter(c => removed.indexOf(c) === -1); + this.emitColumnSelectionEvent(newSelection, added, removed, event); + } + + /** Select specified columns. No event is emitted. */ + public selectColumnsWithNoEvent(fields: string[], clearPrevSelection?): void { + if (clearPrevSelection) { + this.columnSelection.clear(); + } + fields.forEach(field => { + this.columnSelection.add(field); + }); + } + + /** Deselect the specified column and emit event. */ + public deselectColumn(field: string, event?): void { + this.initColumnsState(); + const newSelection = this.getSelectedColumns().filter(c => c !== field); + this.emitColumnSelectionEvent(newSelection, [], [field], event); + } + + /** Deselect specified columns. No event is emitted. */ + public deselectColumnsWithNoEvent(fields: string[]): void { + fields.forEach(field => this.columnSelection.delete(field)); + } + + /** Deselect specified columns. And emit event. */ + public deselectColumns(fields: string[], event?): void { + const removed = this.getSelectedColumns().filter(colField => fields.indexOf(colField) > -1); + const newSelection = this.getSelectedColumns().filter(colField => fields.indexOf(colField) === -1); + + this.emitColumnSelectionEvent(newSelection, [], removed, event); + } + + public emitColumnSelectionEvent(newSelection, added, removed, event?): boolean { + const currSelection = this.getSelectedColumns(); + if (this.areEqualCollections(currSelection, newSelection)) { + return; + } + + const args = { + oldSelection: currSelection, newSelection, + added, removed, event, cancel: false + }; + this.grid.columnSelectionChanging.emit(args); + if (args.cancel) { + return; + } + this.selectColumnsWithNoEvent(args.newSelection, true); + } + + /** Clear columnSelection */ + public clearAllSelectedColumns(): void { + this.columnSelection.clear(); + } + + protected areEqualCollections(first, second): boolean { + return first.length === second.length && new Set(first.concat(second)).size === first.length; + } + + /** + * (╯°□°)╯︵ ┻━┻ + * Chrome and Chromium don't care about the active + * range after keyboard navigation, thus this. + */ + private _moveSelectionChrome(node: Node) { + const selection = window.getSelection(); + selection.removeAllRanges(); + const range = new Range(); + range.selectNode(node); + range.collapse(true); + selection.addRange(range); + } + + private isFilteringApplied(): boolean { + return !FilteringExpressionsTree.empty(this.grid.filteringExpressionsTree) || + !FilteringExpressionsTree.empty(this.grid.advancedFilteringExpressionsTree); + } + + private isRowDeleted(rowID): boolean { + return this.grid.gridAPI.row_deleted_transaction(rowID); + } + + private pointerOriginHandler = (event) => { + this.pointerEventInGridBody = false; + this.grid.document.body.removeEventListener('pointerup', this.pointerOriginHandler); + + const gridCellSelectors = ['igx-grid-cell', 'igx-hierarchical-grid-cell', 'igx-tree-grid-cell']; + const isInsideGridCell = gridCellSelectors.some(selector => event.target.closest(selector)); + + if (!isInsideGridCell) { + this.pointerUp(this._lastSelectedNode, this.grid.rangeSelected, true); + } + }; +} diff --git a/projects/igniteui-angular/grids/core/src/services/csv/char-separated-value-data.ts b/projects/igniteui-angular/grids/core/src/services/csv/char-separated-value-data.ts new file mode 100644 index 00000000000..9e0b3624e86 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/services/csv/char-separated-value-data.ts @@ -0,0 +1,128 @@ +import { ExportUtilities } from '../exporter-common/export-utilities'; +import { IColumnInfo } from '../exporter-common/base-export-service'; +import { yieldingLoop } from 'igniteui-angular/core'; + +/** + * @hidden + */ +export class CharSeparatedValueData { + private _headerRecord = ''; + private _dataRecords = ''; + private _eor = '\r\n'; + private _delimiter; + private _escapeCharacters = ['\r', '\n', '\r\n']; + private _delimiterLength = 1; + private _isSpecialData = false; + + constructor(private _data: any[], valueDelimiter: string, private columns: IColumnInfo[] = []) { + this.setDelimiter(valueDelimiter); + } + + public prepareData(key?: any[]) { + if (!this._data || this._data.length === 0) { + return ''; + } + let keys = []; + if (key){ + keys = key; + }else { + keys = ExportUtilities.getKeysFromData(this._data); + } + + if (keys.length === 0) { + return ''; + } + + this._isSpecialData = ExportUtilities.isSpecialData(this._data[0]); + this._escapeCharacters.push(this._delimiter); + + this._headerRecord = this.processHeaderRecord(keys, this._data.length); + this._dataRecords = this.processDataRecords(this._data, keys); + + return this._headerRecord + this._dataRecords; + } + + public prepareDataAsync(done: (result: string) => void) { + const columns = this.columns?.filter(c => !c.skip) + .sort((a, b) => a.startIndex - b.startIndex) + .sort((a, b) => a.pinnedIndex - b.pinnedIndex); + const keys = columns && columns.length ? columns.map(c => c.field) : ExportUtilities.getKeysFromData(this._data); + + this._isSpecialData = ExportUtilities.isSpecialData(this._data[0]); + this._escapeCharacters.push(this._delimiter); + + const headers = columns && columns.length ? + /* When column groups are present, always use the field as it indicates the group the column belongs to. + * Otherwise, in PivotGrid scenarios we can end up with many duplicated column names without a hint what they represent. + */ + columns.map(c => c.columnGroupParent ? c.field : c.header ?? c.field) : + keys; + + this._headerRecord = this.processHeaderRecord(headers, this._data.length); + if (keys.length === 0 || ((!this._data || this._data.length === 0) && keys.length === 0)) { + done(''); + } else { + this.processDataRecordsAsync(this._data, keys, (dr) => { + done(this._headerRecord + dr); + }); + } + } + + private processField(value, escapeChars): string { + let safeValue = ExportUtilities.hasValue(value) ? String(value) : ''; + if (escapeChars.some((v) => safeValue.includes(v))) { + safeValue = `"${safeValue}"`; + } + return safeValue + this._delimiter; + } + + private processHeaderRecord(keys, dataLength): string { + let recordData = ''; + for (const keyName of keys) { + recordData += this.processField(keyName, this._escapeCharacters); + } + + const result = recordData.slice(0, -this._delimiterLength); + + return dataLength > 0 ? result + this._eor : result; + } + + private processRecord(record, keys): string { + const recordData = new Array(keys.length); + for (let index = 0; index < keys.length; index++) { + const value = (record[keys[index]] !== undefined) ? record[keys[index]] : this._isSpecialData ? record : ''; + recordData[index] = this.processField(value, this._escapeCharacters); + } + + return recordData.join('').slice(0, -this._delimiterLength) + this._eor; + } + + private processDataRecords(currentData, keys) { + const dataRecords = new Array(currentData.length); + + for (let i = 0; i < currentData.length; i++) { + const row = currentData[i]; + dataRecords[i] = this.processRecord(row, keys); + } + + return dataRecords.join(''); + } + + private processDataRecordsAsync(currentData, keys, done: (result: string) => void) { + const dataRecords = new Array(currentData.length); + + yieldingLoop(currentData.length, 1000, + (i) => { + const row = currentData[i]; + dataRecords[i] = this.processRecord(row, keys); + }, + () => { + done(dataRecords.join('')); + }); + } + + private setDelimiter(value) { + this._delimiter = value; + this._delimiterLength = value.length; + } +} diff --git a/projects/igniteui-angular/grids/core/src/services/csv/csv-exporter-grid.spec.ts b/projects/igniteui-angular/grids/core/src/services/csv/csv-exporter-grid.spec.ts new file mode 100644 index 00000000000..3c8425f531c --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/services/csv/csv-exporter-grid.spec.ts @@ -0,0 +1,577 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { IColumnExportingEventArgs, IRowExportingEventArgs } from '../exporter-common/base-export-service'; +import { ExportUtilities } from '../exporter-common/export-utilities'; +import { TestMethods } from '../exporter-common/test-methods.spec'; +import { IgxCsvExporterService } from './csv-exporter'; +import { CsvFileTypes, IgxCsvExporterOptions } from './csv-exporter-options'; +import { IgxTreeGridPrimaryForeignKeyComponent } from '../../../../../test-utils/tree-grid-components.spec'; +import { ReorderedColumnsComponent, + GridIDNameJobTitleComponent, + ProductsComponent, + ColumnsAddedOnInitComponent, + EmptyGridComponent } from '../../../../../test-utils/grid-samples.spec'; +import { SampleTestData } from '../../../../../test-utils/sample-test-data.spec'; +import { first } from 'rxjs/operators'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { wait } from '../../../../../test-utils/ui-interactions.spec'; +import { IgxPivotGridTestBaseComponent } from '../../../../../test-utils/pivot-grid-samples.spec'; +import { IgxGridComponent } from 'igniteui-angular/grids/grid'; +import { IgxTreeGridComponent } from 'igniteui-angular/grids/tree-grid'; +import { IgxPivotGridComponent } from 'igniteui-angular/grids/pivot-grid'; +import { IgxGridNavigationService, IgxPivotNumericAggregate } from 'igniteui-angular/grids/core'; +import { DefaultSortingStrategy, FilteringExpressionsTree, FilteringLogic, IgxNumberFilteringOperand, IgxStringFilteringOperand, SortingDirection } from 'igniteui-angular/core'; +import { CSVWrapper } from './csv-verification-wrapper.spec'; + +describe('CSV Grid Exporter', () => { + let exporter: IgxCsvExporterService; + let options: IgxCsvExporterOptions; + const data = SampleTestData.personJobData(); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + ReorderedColumnsComponent, + GridIDNameJobTitleComponent, + IgxTreeGridPrimaryForeignKeyComponent, + ProductsComponent, + ColumnsAddedOnInitComponent, + EmptyGridComponent + ] + }).compileComponents(); + })); + + beforeEach(() => { + exporter = new IgxCsvExporterService(); + options = new IgxCsvExporterOptions('CsvGridExport', CsvFileTypes.CSV); + + // Spy the saveBlobToFile method so the files are not really created + spyOn(ExportUtilities as any, 'saveBlobToFile'); + }); + + afterEach(() => { + exporter.columnExporting.unsubscribe(); + exporter.rowExporting.unsubscribe(); + }); + + it('should export grid as displayed.', async () => { + const currentGrid: IgxGridComponent = null; + + await TestMethods.testRawData(currentGrid, async (grid) => { + const wrapper = await getExportedData(grid, options); + wrapper.verifyData(wrapper.simpleGridData); + }); + }); + + it('should honor \'ignoreFiltering\' option.', async () => { + const result = await TestMethods.createGridAndFilter(); + const fix = result.fixture; + const grid = result.grid; + + options = new IgxCsvExporterOptions('TestCsv', CsvFileTypes.CSV); + options.ignoreFiltering = false; + fix.detectChanges(); + + let wrapper = await getExportedData(grid, options); + wrapper.verifyData(wrapper.gridOneSeniorDev, 'One row only should have been exported!'); + + options.ignoreFiltering = true; + fix.detectChanges(); + wrapper = await getExportedData(grid, options); + wrapper.verifyData(wrapper.simpleGridData, 'All 10 rows should have been exported!'); + }); + + it('should honor filter criteria changes.', async () => { + const result = await TestMethods.createGridAndFilter(); + const fix = result.fixture; + const grid = result.grid; + + expect(grid.rowList.length).toEqual(1); + + let wrapper = await getExportedData(grid, options); + wrapper.verifyData(wrapper.gridOneSeniorDev, 'One row should have been exported!'); + + grid.filter('JobTitle', 'Director', IgxStringFilteringOperand.instance().condition('equals'), true); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(2, 'Invalid number of rows after filtering!'); + wrapper = await getExportedData(grid, options); + wrapper.verifyData(wrapper.gridTwoDirectors, 'Two rows should have been exported!'); + }); + + it('should honor \'ignoreColumnsVisibility\' option.', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + grid.columnList.get(0).hidden = true; + options.ignoreColumnsVisibility = false; + + fix.detectChanges(); + expect(grid.visibleColumns.length).toEqual(2, 'Invalid number of visible columns!'); + let wrapper = await getExportedData(grid, options); + wrapper.verifyData(wrapper.gridNameJobTitle, 'Two columns data should have been exported!'); + + options.ignoreColumnsVisibility = true; + fix.detectChanges(); + wrapper = await getExportedData(grid, options); + wrapper.verifyData(wrapper.simpleGridData, 'All three columns data should have been exported!'); + }); + + it('should honor columns visibility changes.', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + options.ignoreColumnsOrder = true; + fix.detectChanges(); + + expect(grid.visibleColumns.length).toEqual(3, 'Invalid number of visible columns!'); + let wrapper = await getExportedData(grid, options); + wrapper.verifyData(wrapper.simpleGridData, 'All columns data should have been exported!'); + + grid.columnList.get(0).hidden = true; + fix.detectChanges(); + expect(grid.visibleColumns.length).toEqual(2, 'Invalid number of visible columns!'); + wrapper = await getExportedData(grid, options); + wrapper.verifyData(wrapper.gridNameJobTitle, 'Two columns data should have been exported!'); + + grid.columnList.get(0).hidden = false; + fix.detectChanges(); + expect(grid.visibleColumns.length).toEqual(3, 'Invalid number of visible columns!'); + wrapper = await getExportedData(grid, options); + wrapper.verifyData(wrapper.simpleGridData, 'All columns data should have been exported!'); + + grid.columnList.get(0).hidden = undefined; + fix.detectChanges(); + expect(grid.visibleColumns.length).toEqual(3, 'Invalid number of visible columns!'); + wrapper = await getExportedData(grid, options); + wrapper.verifyData(wrapper.simpleGridData, 'All columns data should have been exported!'); + }); + + it('should honor columns declaration order.', async () => { + const fix = TestBed.createComponent(ReorderedColumnsComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + + const wrapper = await getExportedData(grid, options); + wrapper.verifyData(wrapper.gridNameJobTitleID); + }); + + it('should honor applied sorting.', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + grid.sort({fieldName: 'Name', dir: SortingDirection.Asc, ignoreCase: true}); + fix.detectChanges(); + + const wrapper = await getExportedData(grid, options); + wrapper.verifyData(wrapper.sortedSimpleGridData); + }); + + it('should honor changes in applied sorting.', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + grid.sort({fieldName: 'Name', dir: SortingDirection.Asc, ignoreCase: true}); + fix.detectChanges(); + let wrapper = await getExportedData(grid, options); + wrapper.verifyData(wrapper.sortedSimpleGridData); + + grid.sort({fieldName: 'Name', dir: SortingDirection.Desc, ignoreCase: true}); + fix.detectChanges(); + wrapper = await getExportedData(grid, options); + wrapper.verifyData(wrapper.sortedDescSimpleGridData); + + grid.clearSort(); + grid.sort({fieldName: 'ID', dir: SortingDirection.Asc, ignoreCase: true}); + fix.detectChanges(); + wrapper = await getExportedData(grid, options); + wrapper.verifyData(wrapper.simpleGridData); + }); + + it('should display pinned columns data in the beginning.', async () => { + const result = await TestMethods.createGridAndPinColumn([1]); + const fix = result.fixture; + const grid = result.grid; + fix.detectChanges(); + + const wrapper = await getExportedData(grid, options); + wrapper.verifyData(wrapper.gridNameIDJobTitle, 'Name should have been the first field!'); + }); + + it('should not display pinned columns data first when ignoreColumnsOrder is true.', async () => { + const result = await TestMethods.createGridAndPinColumn([1]); + const fix = result.fixture; + const grid = result.grid; + options.ignoreColumnsOrder = true; + + fix.detectChanges(); + const wrapper = await getExportedData(grid, options); + wrapper.verifyData(wrapper.simpleGridData, 'Name should not have been the first field!'); + }); + + it('should fire \'columnExporting\' for each grid column.', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + const cols = []; + + exporter.columnExporting.subscribe((value) => { + cols.push({ header: value.header, index: value.columnIndex }); + }); + + const wrapper = await getExportedData(grid, options); + expect(cols.length).toBe(3); + expect(cols[0].header).toBe('ID'); + expect(cols[0].index).toBe(0); + expect(cols[1].header).toBe('Name'); + expect(cols[1].index).toBe(1); + expect(cols[2].header).toBe('JobTitle'); + expect(cols[2].index).toBe(2); + wrapper.verifyData(wrapper.simpleGridData); + }); + + it('should fire \'columnExporting\' for each visible grid column.', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + const cols = []; + + exporter.columnExporting.subscribe((value) => { + cols.push({ header: value.header, index: value.columnIndex }); + }); + + grid.columnList.get(0).hidden = true; + options.ignoreColumnsVisibility = false; + fix.detectChanges(); + + const wrapper = await getExportedData(grid, options); + expect(cols.length).toBe(2); + expect(cols[0].header).toBe('Name'); + expect(cols[0].index).toBe(0); + expect(cols[1].header).toBe('JobTitle'); + expect(cols[1].index).toBe(1); + wrapper.verifyData(wrapper.gridNameJobTitle); + }); + + it('should not export columns when \'columnExporting\' is canceled.', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + + exporter.columnExporting.subscribe((value: IColumnExportingEventArgs) => { + value.cancel = true; + }); + + const wrapper = await getExportedData(grid, options); + wrapper.verifyData(''); + }); + + it('should fire \'rowExporting\' for each grid row.', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + const rows = []; + + exporter.rowExporting.subscribe((value: IRowExportingEventArgs) => { + rows.push({ data: value.rowData, index: value.rowIndex }); + }); + + await getExportedData(grid, options); + + expect(rows.length).toBe(10); + for (let i = 0; i < rows.length; i++) { + expect(rows[i].index).toBe(i); + expect(JSON.stringify(rows[i].data)).toBe(JSON.stringify(data[i])); + } + }); + + it('should not export rows when \'rowExporting\' is canceled.', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + + exporter.rowExporting.subscribe((value: IRowExportingEventArgs) => { + value.cancel = true; + }); + + const wrapper = await getExportedData(grid, options); + wrapper.verifyData('ID,Name,JobTitle'); + }); + + it('should skip column formatter when \'onColunmExporting\' skipFormatter is true', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + grid.columnList.get(1).formatter = ((val: string) => val.toUpperCase()); + grid.columnList.get(2).formatter = ((val: string) => val.toLowerCase()); + grid.cdr.detectChanges(); + + let wrapper = await getExportedData(grid, options); + wrapper.verifyData(wrapper.simpleGridDataFormatted, 'Columns\' formatter should not be skipped.'); + + exporter.columnExporting.subscribe((val: IColumnExportingEventArgs) => { + val.skipFormatter = true; + }); + grid.cdr.detectChanges(); + wrapper = await getExportedData(grid, options); + wrapper.verifyData(wrapper.simpleGridData, 'Columns formatter should be skipped.'); + + exporter.columnExporting.subscribe((val: IColumnExportingEventArgs) => { + val.skipFormatter = false; + }); + grid.cdr.detectChanges(); + wrapper = await getExportedData(grid, options); + wrapper.verifyData(wrapper.simpleGridDataFormatted, 'Columns\' formatter should not be skipped.'); + }); + + it('Should honor the Advanced filters when exporting', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + const tree = new FilteringExpressionsTree(FilteringLogic.And); + tree.filteringOperands.push({ + fieldName: 'Name', + searchVal: 'a', + condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains', + ignoreCase: true + }); + tree.filteringOperands.push({ + fieldName: 'Name', + searchVal: 'r', + condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains', + ignoreCase: true + }); + tree.filteringOperands.push({ + fieldName: 'ID', + searchVal: 5, + condition: IgxNumberFilteringOperand.instance().condition('greaterThan'), + conditionName: 'greaterThan' + }); + + grid.advancedFilteringExpressionsTree = tree; + fix.detectChanges(); + + expect(grid.filteredData.length).toBe(4); + const wrapper = await getExportedData(grid, options); + wrapper.verifyData(wrapper.gridWithAdvancedFilters, 'Should export only filtered data.'); + }); + + it('should map dynamically added data & columns properly (#9872).', async () => { + const fix = TestBed.createComponent(ColumnsAddedOnInitComponent); + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + const wrapper = await getExportedData(grid, options); + wrapper.verifyData(wrapper.gridColumnsAddedOnInit, 'Columns should be exported in the same order as in the grid!'); + }); + + it('should not export more than one file', async () => { + const fix = TestBed.createComponent(EmptyGridComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + + exporter.export(grid, options); + + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + }); + + describe('Tree Grid CSV export', () => { + let fix; + let treeGrid: IgxTreeGridComponent; + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridPrimaryForeignKeyComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + }); + + it('should export tree grid as displayed.', async () => { + const wrapper = await getExportedData(treeGrid, options); + wrapper.verifyData(wrapper.treeGridData); + }); + + it('should export sorted tree grid properly.', async () => { + treeGrid.sort({fieldName: 'ID', dir: SortingDirection.Desc, ignoreCase: true, strategy: DefaultSortingStrategy.instance()}); + options.ignoreSorting = true; + fix.detectChanges(); + + let wrapper = await getExportedData(treeGrid, options); + wrapper.verifyData(wrapper.treeGridData); + + options.ignoreSorting = false; + + wrapper = await getExportedData(treeGrid, options); + wrapper.verifyData(wrapper.treeGridDataSorted); + + treeGrid.clearSort(); + fix.detectChanges(); + + wrapper = await getExportedData(treeGrid, options); + wrapper.verifyData(wrapper.treeGridData); + }); + + it('should export filtered tree grid properly.', async () => { + treeGrid.filter('ID', 3, IgxNumberFilteringOperand.instance().condition('greaterThan')); + options.ignoreFiltering = true; + fix.detectChanges(); + + let wrapper = await getExportedData(treeGrid, options); + wrapper.verifyData(wrapper.treeGridData); + + options.ignoreFiltering = false; + + wrapper = await getExportedData(treeGrid, options); + wrapper.verifyData(wrapper.treeGridDataFiltered); + + treeGrid.clearFilter(); + fix.detectChanges(); + + wrapper = await getExportedData(treeGrid, options); + wrapper.verifyData(wrapper.treeGridData); + }); + + it('should export filtered and sorted tree grid properly.', async () => { + treeGrid.filter('ID', 3, IgxNumberFilteringOperand.instance().condition('greaterThan')); + fix.detectChanges(); + treeGrid.sort({fieldName: 'Name', dir: SortingDirection.Desc, ignoreCase: true, strategy: DefaultSortingStrategy.instance()}); + fix.detectChanges(); + + const wrapper = await getExportedData(treeGrid, options); + wrapper.verifyData(wrapper.treeGridDataFilterSorted); + }); + + it('should fire \'rowExporting\' for each tree grid row.', async () => { + const rows = []; + + exporter.rowExporting.subscribe((value: IRowExportingEventArgs) => { + rows.push({ data: value.rowData, index: value.rowIndex }); + }); + + await getExportedData(treeGrid, options); + + expect(rows.length).toBe(8); + for (let i = 0; i < rows.length; i++) { + expect(rows[i].index).toBe(i); + expect(JSON.stringify(rows[i].data)).toBe(JSON.stringify(SampleTestData.employeeTreeDataDisplayOrder()[i])); + } + }); + + it('should skip the column formatter when columnExportinging skipFormatter is true.', async () => { + treeGrid.columnList.get(3).formatter = ((val: string) => val.toLowerCase()); + treeGrid.columnList.get(4).formatter = ((val: number) => + val * 12 // months + ); + treeGrid.cdr.detectChanges(); + let wrapper = await getExportedData(treeGrid, options); + wrapper.verifyData(wrapper.treeGridDataFormatted, 'Columns\' formatter should be applied.'); + + exporter.columnExporting.subscribe((val: IColumnExportingEventArgs) => { + val.skipFormatter = true; + }); + wrapper = await getExportedData(treeGrid, options); + wrapper.verifyData(wrapper.treeGridData, 'Columns\' formatter should be skipped.'); + + exporter.columnExporting.subscribe((val: IColumnExportingEventArgs) => { + val.skipFormatter = false; + }); + wrapper = await getExportedData(treeGrid, options); + wrapper.verifyData(wrapper.treeGridDataFormatted, 'Columns\' formatter should be applied.'); + }); + + it('Should honor the Advanced filters when exporting', async () => { + const tree = new FilteringExpressionsTree(FilteringLogic.And); + tree.filteringOperands.push({ + fieldName: 'Name', + searchVal: 'a', + condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains', + ignoreCase: true + }); + tree.filteringOperands.push({ + fieldName: 'Name', + searchVal: 'r', + condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains', + ignoreCase: true + }); + treeGrid.advancedFilteringExpressionsTree = tree; + fix.detectChanges(); + await wait(); + expect(treeGrid.filteredData.length).toBe(5); + + const wrapper = await getExportedData(treeGrid, options); + wrapper.verifyData(wrapper.treeGridWithAdvancedFilters, 'Should export only filtered data!'); + }); + }); + + describe('Pivot Grid CSV export', () => { + let fix; + let pivotGrid: IgxPivotGridComponent; + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + IgxGridNavigationService + ] + }); + + fix = TestBed.createComponent(IgxPivotGridTestBaseComponent); + fix.detectChanges(); + pivotGrid = fix.componentInstance.pivotGrid; + pivotGrid.pivotConfiguration = { + columns: [ + { + enabled: true, + memberName: 'Country' + } + ], + rows: [ + { + enabled: true, + memberName: 'ProductCategory' + } + ], + values: [ + { + enabled: true, + member: 'UnitsSold', + aggregate: { + aggregator: IgxPivotNumericAggregate.sum, + key: 'SUM', + label: 'Sum', + }, + } + ] + }; + fix.detectChanges(); + }); + + it('should export pivot grid successfully.', async () => { + await wait(); + const wrapper = await getExportedData(pivotGrid, options); + wrapper.verifyData(wrapper.pivotGridData); + }); + }); + + const getExportedData = (grid, csvOptions: IgxCsvExporterOptions) => { + const result = new Promise((resolve) => { + exporter.exportEnded.pipe(first()).subscribe((value) => { + const wrapper = new CSVWrapper(value.csvData, csvOptions.valueDelimiter); + resolve(wrapper); + }); + exporter.export(grid, csvOptions); + }); + return result; + }; +}); diff --git a/projects/igniteui-angular/grids/core/src/services/csv/csv-exporter-options.ts b/projects/igniteui-angular/grids/core/src/services/csv/csv-exporter-options.ts new file mode 100644 index 00000000000..8cfd793da4d --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/services/csv/csv-exporter-options.ts @@ -0,0 +1,130 @@ +import { IgxExporterOptionsBase } from '../exporter-common/exporter-options-base'; + +/** + * Objects of this class are used to configure the CSV exporting process. + */ +export class IgxCsvExporterOptions extends IgxExporterOptionsBase { + + private _valueDelimiter; + private _fileType; + + constructor(fileName: string, fileType: CsvFileTypes) { + super(fileName, IgxCsvExporterOptions.getExtensionFromFileType(fileType)); + this.setFileType(fileType); + this.setDelimiter(); + } + + private static getExtensionFromFileType(fType: CsvFileTypes) { + let extension = ''; + switch (fType) { + case CsvFileTypes.CSV: + extension = '.csv'; + break; + case CsvFileTypes.TSV: + extension = '.tsv'; + break; + case CsvFileTypes.TAB: + extension = '.tab'; + break; + default: + throw Error('Unsupported CSV file type!'); + } + return extension; + } + + /** + * Gets the value delimiter which will be used for the exporting operation. + * ```typescript + * let delimiter = this.exportOptions.valueDelimiter; + * ``` + * + * @memberof IgxCsvExporterOptions + */ + public get valueDelimiter() { + return this._valueDelimiter; + } + + /** + * Sets a value delimiter which will overwrite the default delimiter of the selected export format. + * ```typescript + * this.exportOptions.valueDelimiter = '|'; + * ``` + * + * @memberof IgxCsvExporterOptions + */ + public set valueDelimiter(value) { + this.setDelimiter(value); + } + + /** + * Gets the CSV export format. + * ```typescript + * let filetype = this.exportOptions.fileType; + * ``` + * + * @memberof IgxCsvExporterOptions + */ + public get fileType() { + return this._fileType; + } + + /** + * Sets the CSV export format. + * ```typescript + * this.exportOptions.fileType = CsvFileTypes.TAB; + * ``` + * + * @memberof IgxCsvExporterOptions + */ + public set fileType(value) { + this.setFileType(value); + } + + private setFileType(value) { + if (value !== undefined && value !== null && value !== this._fileType) { + this._fileType = value; + const extension = IgxCsvExporterOptions.getExtensionFromFileType(value); + if (!this.fileName.endsWith(extension)) { + const oldExt = '.' + this.fileName.split('.').pop(); + const newName = this.fileName.replace(oldExt, extension); + this._fileExtension = extension; + this.fileName = newName; + } + } + } + + private setDelimiter(value?) { + if (value !== undefined && value !== '' && value !== null) { + this._valueDelimiter = value; + } else { + switch (this.fileType) { + case CsvFileTypes.CSV: + this._valueDelimiter = ','; + break; + case CsvFileTypes.TSV: + case CsvFileTypes.TAB: + this._valueDelimiter = '\t'; + break; + } + } + } +} + +/** + * This enumeration is used to configure the default value separator + * as well as the default file extension used when performing CSV exporting. + */ +export enum CsvFileTypes { + /** + * Character Separated Values, default separator is "comma", default file extension is .csv + */ + CSV, + /** + * Tab Separated Values, default separator is tab, default file extension is .tsv + */ + TSV, + /** + * Tab Separated Values, default separator is tab, default file extension is .tab + */ + TAB +} diff --git a/projects/igniteui-angular/grids/core/src/services/csv/csv-exporter.spec.ts b/projects/igniteui-angular/grids/core/src/services/csv/csv-exporter.spec.ts new file mode 100644 index 00000000000..c4267eaee88 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/services/csv/csv-exporter.spec.ts @@ -0,0 +1,137 @@ +import { ExportUtilities } from '../exporter-common/export-utilities'; +import { IgxCsvExporterService } from './csv-exporter'; +import { CsvFileTypes, IgxCsvExporterOptions } from './csv-exporter-options'; +import { CSVWrapper } from './csv-verification-wrapper.spec'; +import { SampleTestData } from '../../../../../test-utils/sample-test-data.spec'; +import { first } from 'rxjs/operators'; + +describe('CSV exporter', () => { + let exporter: IgxCsvExporterService; + const fileTypes = [ CsvFileTypes.CSV, CsvFileTypes.TSV, CsvFileTypes.TAB ]; + + beforeEach(() => { + exporter = new IgxCsvExporterService(); + + // Spy the saveBlobToFile method so the files are not really created + spyOn(ExportUtilities as any, 'saveBlobToFile'); + }); + afterEach(() => { + exporter.columnExporting.unsubscribe(); + exporter.rowExporting.unsubscribe(); + }); + + /* ExportData() tests */ + for (const fileType of fileTypes) { + const typeName = CsvFileTypes[fileType]; + const options = new IgxCsvExporterOptions('Test' + typeName, fileType); + + it(typeName + ' should not fail when data is empty.', async () => { + const wrapper = await getExportedData([], options); + wrapper.verifyData(''); + }); + + it(typeName + ' should export empty objects successfully.', async () => { + const wrapper = await getExportedData(SampleTestData.emptyObjectData(), options); + wrapper.verifyData(''); + }); + + it(typeName + ' should export string data without headers successfully.', async () => { + const wrapper = await getExportedData(SampleTestData.stringArray(), options); + wrapper.verifyData(wrapper.noHeadersStringData); + }); + + it(typeName + ' should export number data without headers successfully.', async () => { + const wrapper = await getExportedData(SampleTestData.numbersArray(), options); + wrapper.verifyData(wrapper.noHeadersNumberData); + }); + + it(typeName + ' should export date time data without headers successfully.', async () => { + const wrapper = await getExportedData(SampleTestData.dateArray(), options); + wrapper.verifyData(wrapper.noHeadersDateTimeData); + }); + + it(typeName + ' should export object data without headers successfully.', async () => { + const wrapper = await getExportedData(SampleTestData.noHeadersObjectArray(), options); + wrapper.verifyData(wrapper.noHeadersObjectData); + }); + + it(typeName + ' should export regular data successfully.', async () => { + const wrapper = await getExportedData(SampleTestData.contactsData(), options); + wrapper.verifyData(wrapper.contactsData); + }); + + it(typeName + ' should export data with missing values successfully.', async () => { + const wrapper = await getExportedData(SampleTestData.contactsDataPartial(), options); + wrapper.verifyData(wrapper.contactsPartialData); + }); + + it(typeName + ' should export data with special characters successfully.', async () => { + const wrapper = await getExportedData(SampleTestData.getContactsFunkyData(options.valueDelimiter), options); + wrapper.verifyData(wrapper.contactsFunkyData); + }); + } + + it('CSV should export data with a custom delimiter successfully.', async () => { + const options = new IgxCsvExporterOptions('CustomDelimiter', CsvFileTypes.CSV); + options.valueDelimiter = '###'; + const wrapper = await getExportedData(SampleTestData.getContactsFunkyData(options.valueDelimiter), options); + wrapper.verifyData(wrapper.contactsFunkyData); + }); + + it('CSV should use a default delimiter when given an invalid one.', async () => { + const options = new IgxCsvExporterOptions('InvalidDelimiter', CsvFileTypes.CSV); + options.valueDelimiter = ''; + await getExportedData(SampleTestData.contactsData(), options); + expect(options.valueDelimiter).toBe(','); + }); + + it('CSV should overwrite file type successfully.', async () => { + const options = new IgxCsvExporterOptions('Export', CsvFileTypes.CSV); + options.fileType = CsvFileTypes.TAB; + await getExportedData(SampleTestData.getContactsFunkyData('\t'), options); + expect(options.fileName.endsWith('.tab')).toBe(true); + }); + + it('should fire \'columnExporting\' for each data field.', async () => { + const options = new IgxCsvExporterOptions('ExportEvents', CsvFileTypes.CSV); + const cols = []; + exporter.columnExporting.subscribe((value) => { + cols.push({ header: value.header, index: value.columnIndex }); + }); + + await getExportedData(SampleTestData.personJobData(), options); + expect(cols.length).toBe(3); + expect(cols[0].header).toBe('ID'); + expect(cols[0].index).toBe(0); + expect(cols[1].header).toBe('Name'); + expect(cols[1].index).toBe(1); + expect(cols[2].header).toBe('JobTitle'); + expect(cols[2].index).toBe(2); + }); + + it('should fire \'rowExporting\' for each data row.', async () => { + const options = new IgxCsvExporterOptions('ExportEvents', CsvFileTypes.CSV); + const rows = []; + exporter.rowExporting.subscribe((value) => { + rows.push({ data: value.rowData, index: value.rowIndex }); + }); + + await getExportedData(SampleTestData.personJobData(), options); + expect(rows.length).toBe(10); + for (let i = 0; i < rows.length; i++) { + expect(rows[i].index).toBe(i); + expect(JSON.stringify(rows[i].data)).toBe(JSON.stringify(SampleTestData.personJobData()[i])); + } + }); + + const getExportedData = (data: any[], csvOptions: IgxCsvExporterOptions) => { + const result = new Promise((resolve) => { + exporter.exportEnded.pipe(first()).subscribe((value) => { + const wrapper = new CSVWrapper(value.csvData, csvOptions.valueDelimiter); + resolve(wrapper); + }); + exporter.exportData(data, csvOptions); + }); + return result; + }; +}); diff --git a/projects/igniteui-angular/grids/core/src/services/csv/csv-exporter.ts b/projects/igniteui-angular/grids/core/src/services/csv/csv-exporter.ts new file mode 100644 index 00000000000..5013fdc57e8 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/services/csv/csv-exporter.ts @@ -0,0 +1,100 @@ +import { EventEmitter, Injectable } from '@angular/core'; +import { DEFAULT_OWNER, ExportHeaderType, IColumnInfo, IExportRecord, IgxBaseExporter } from '../exporter-common/base-export-service'; +import { ExportUtilities } from '../exporter-common/export-utilities'; +import { CharSeparatedValueData } from './char-separated-value-data'; +import { CsvFileTypes, IgxCsvExporterOptions } from './csv-exporter-options'; +import { IBaseEventArgs } from 'igniteui-angular/core'; + +export interface ICsvExportEndedEventArgs extends IBaseEventArgs { + csvData?: string; +} + +/** + * **Ignite UI for Angular CSV Exporter Service** - + * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/exporter-csv) + * + * The Ignite UI for Angular CSV Exporter service can export data in a Character Separated Values format from + * both raw data (array) or from an `IgxGrid`. + * + * Example: + * ```typescript + * public localData = [ + * { Name: "Eric Ridley", Age: "26" }, + * { Name: "Alanis Brook", Age: "22" }, + * { Name: "Jonathan Morris", Age: "23" } + * ]; + * + * constructor(private csvExportService: IgxCsvExporterService) { + * } + * + * const opt: IgxCsvExporterOptions = new IgxCsvExporterOptions("FileName", CsvFileTypes.CSV); + * this.csvExportService.exportData(this.localData, opt); + * ``` + */ +@Injectable({ + providedIn: 'root', +}) +export class IgxCsvExporterService extends IgxBaseExporter { + /** + * This event is emitted when the export process finishes. + * ```typescript + * this.exporterService.exportEnded.subscribe((args: ICsvExportEndedEventArgs) => { + * // put event handler code here + * }); + * ``` + * + * @memberof IgxCsvExporterService + */ + public override exportEnded = new EventEmitter(); + + private _stringData: string; + + protected exportDataImplementation(data: IExportRecord[], options: IgxCsvExporterOptions, done: () => void) { + const dimensionKeys = data[0]?.dimensionKeys; + data = dimensionKeys?.length ? + data.map((item) => item.rawData): + data.map((item) => item.data); + const columnList = this._ownersMap.get(DEFAULT_OWNER); + const columns = columnList?.columns.filter(c => c.headerType === ExportHeaderType.ColumnHeader); + if (dimensionKeys) { + const dimensionCols = dimensionKeys.map((key) => { + const columnInfo: IColumnInfo = { + header: key, + field: key, + dataType: 'string', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + columnSpan: 1, + startIndex: 0 + }; + return columnInfo; + }); + columns.unshift(...dimensionCols); + } + + const csvData = new CharSeparatedValueData(data, options.valueDelimiter, columns); + csvData.prepareDataAsync((r) => { + this._stringData = r; + this.saveFile(options); + this.exportEnded.emit({ csvData: this._stringData }); + done(); + }); + } + + private saveFile(options: IgxCsvExporterOptions) { + switch (options.fileType) { + case CsvFileTypes.CSV: + this.exportFile(this._stringData, options.fileName, 'text/csv;charset=utf-8;'); + break; + case CsvFileTypes.TSV: + case CsvFileTypes.TAB: + this.exportFile(this._stringData, options.fileName, 'text/tab-separated-values;charset=utf-8;'); + break; + } + } + + private exportFile(data: string, fileName: string, fileType: string): void { + const blob = new Blob([data ? '\ufeff' : '', data], { type: fileType }); + ExportUtilities.saveBlobToFile(blob, fileName); + } +} diff --git a/projects/igniteui-angular/grids/core/src/services/csv/csv-verification-wrapper.spec.ts b/projects/igniteui-angular/grids/core/src/services/csv/csv-verification-wrapper.spec.ts new file mode 100644 index 00000000000..45062bdbff1 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/services/csv/csv-verification-wrapper.spec.ts @@ -0,0 +1,298 @@ + +export class CSVWrapper { + private _data: string; + private _delimiter = ''; + private _eor = '\r\n'; + + constructor(data: string, valueDelimiter: string) { + this._data = data; + this._delimiter = valueDelimiter; + } + + public verifyData(expectedData: string, message?: string) { + expect(this._data).toBe(expectedData, message); + } + + public get noHeadersStringData() { + return `Column 1${this._eor}` + +`Terrance Orta${this._eor}` + +`Richard Mahoney LongerName${this._eor}` + +`Donna Price${this._eor}` + +`Lisa Landers${this._eor}` + +`Dorothy H. Spencer${this._eor}`; + } + + public get noHeadersObjectData() { + return `value${this._eor}` + +`1${this._eor}` + +`2${this._eor}` + +`3${this._eor}`; + } + + public get noHeadersNumberData() { + return `Column 1${this._eor}` + +`10${this._eor}` + +`20${this._eor}` + +`30${this._eor}`; + } + + public get noHeadersDateTimeData() { + return `Column 1${this._eor}` + +`${new Date('2018').toString()}${this._eor}` + +`${new Date(2018, 3, 23).toString()}${this._eor}` + +`${new Date(30).toString()}${this._eor}` + +`${new Date('2018/03/23').toString()}${this._eor}`; + } + + public get contactsData() { + return `name${this._delimiter}phone${this._eor}Terrance Orta${this._delimiter}770-504-2217${this._eor}` + + `Richard Mahoney LongerName${this._delimiter}${this._eor}Donna Price${this._delimiter}859-496-2817${this._eor}` + + `${this._delimiter}901-747-3428${this._eor}Dorothy H. Spencer${this._delimiter}573-394-9254${this._eor}`; + } + + public get contactsFunkyData() { + return `name${this._delimiter}phone${this._eor}Terrance Mc'Orta${this._delimiter}(+359)770-504-2217 | 2218${this._eor}` + + `Richard Mahoney /LongerName/${this._delimiter}${this._eor}"Donna${this._delimiter} /; Price"${this._delimiter}` + + `859 496 28**${this._eor}"\r\n"${this._delimiter}901-747-3428${this._eor}Dorothy "H." Spencer${this._delimiter}` + + `573-394-9254[fax]${this._eor}"Иван Иванов (1${this._delimiter}2)"${this._delimiter}№ 573-394-9254${this._eor}`; + + } + + public get contactsPartialData() { + return `name${this._delimiter}phone${this._eor}Terrance Orta${this._delimiter}770-504-2217${this._eor}` + + `Richard Mahoney LongerName${this._delimiter}${this._eor}${this._delimiter}780-555-1331${this._eor}`; + } + + public get simpleGridData() { + return `ID${this._delimiter}Name${this._delimiter}JobTitle${this._eor}` + + `1${this._delimiter}Casey Houston${this._delimiter}Vice President${this._eor}` + + `2${this._delimiter}Gilberto Todd${this._delimiter}Director${this._eor}` + + `3${this._delimiter}Tanya Bennett${this._delimiter}Director${this._eor}` + + `4${this._delimiter}Jack Simon${this._delimiter}Software Developer${this._eor}` + + `5${this._delimiter}Celia Martinez${this._delimiter}Senior Software Developer${this._eor}` + + `6${this._delimiter}Erma Walsh${this._delimiter}CEO${this._eor}` + + `7${this._delimiter}Debra Morton${this._delimiter}Associate Software Developer${this._eor}` + + `8${this._delimiter}Erika Wells${this._delimiter}Software Development Team Lead${this._eor}` + + `9${this._delimiter}Leslie Hansen${this._delimiter}Associate Software Developer${this._eor}` + + `10${this._delimiter}Eduardo Ramirez${this._delimiter}Manager${this._eor}`; + } + + public get simpleGridDataFormatted() { + return `ID${this._delimiter}Name${this._delimiter}JobTitle${this._eor}` + + `1${this._delimiter}CASEY HOUSTON${this._delimiter}vice president${this._eor}` + + `2${this._delimiter}GILBERTO TODD${this._delimiter}director${this._eor}` + + `3${this._delimiter}TANYA BENNETT${this._delimiter}director${this._eor}` + + `4${this._delimiter}JACK SIMON${this._delimiter}software developer${this._eor}` + + `5${this._delimiter}CELIA MARTINEZ${this._delimiter}senior software developer${this._eor}` + + `6${this._delimiter}ERMA WALSH${this._delimiter}ceo${this._eor}` + + `7${this._delimiter}DEBRA MORTON${this._delimiter}associate software developer${this._eor}` + + `8${this._delimiter}ERIKA WELLS${this._delimiter}software development team lead${this._eor}` + + `9${this._delimiter}LESLIE HANSEN${this._delimiter}associate software developer${this._eor}` + + `10${this._delimiter}EDUARDO RAMIREZ${this._delimiter}manager${this._eor}`; + } + + public get sortedSimpleGridData() { + return `ID${this._delimiter}Name${this._delimiter}JobTitle${this._eor}` + + `1${this._delimiter}Casey Houston${this._delimiter}Vice President${this._eor}` + + `5${this._delimiter}Celia Martinez${this._delimiter}Senior Software Developer${this._eor}` + + `7${this._delimiter}Debra Morton${this._delimiter}Associate Software Developer${this._eor}` + + `10${this._delimiter}Eduardo Ramirez${this._delimiter}Manager${this._eor}` + + `8${this._delimiter}Erika Wells${this._delimiter}Software Development Team Lead${this._eor}` + + `6${this._delimiter}Erma Walsh${this._delimiter}CEO${this._eor}` + + `2${this._delimiter}Gilberto Todd${this._delimiter}Director${this._eor}` + + `4${this._delimiter}Jack Simon${this._delimiter}Software Developer${this._eor}` + + `9${this._delimiter}Leslie Hansen${this._delimiter}Associate Software Developer${this._eor}` + + `3${this._delimiter}Tanya Bennett${this._delimiter}Director${this._eor}`; + } + + public get sortedDescSimpleGridData() { + return `ID${this._delimiter}Name${this._delimiter}JobTitle${this._eor}` + + `3${this._delimiter}Tanya Bennett${this._delimiter}Director${this._eor}` + + `9${this._delimiter}Leslie Hansen${this._delimiter}Associate Software Developer${this._eor}` + + `4${this._delimiter}Jack Simon${this._delimiter}Software Developer${this._eor}` + + `2${this._delimiter}Gilberto Todd${this._delimiter}Director${this._eor}` + + `6${this._delimiter}Erma Walsh${this._delimiter}CEO${this._eor}` + + `8${this._delimiter}Erika Wells${this._delimiter}Software Development Team Lead${this._eor}` + + `10${this._delimiter}Eduardo Ramirez${this._delimiter}Manager${this._eor}` + + `7${this._delimiter}Debra Morton${this._delimiter}Associate Software Developer${this._eor}` + + `5${this._delimiter}Celia Martinez${this._delimiter}Senior Software Developer${this._eor}` + + `1${this._delimiter}Casey Houston${this._delimiter}Vice President${this._eor}`; + } + + public get simpleGridRawData() { + return `ID${this._delimiter}Name${this._delimiter}JobTitle${this._delimiter}${this._eor}` + + `1${this._delimiter}Casey Houston${this._delimiter}Vice President${this._delimiter}${this._eor}` + + `2${this._delimiter}Gilberto Todd${this._delimiter}Director${this._delimiter}${this._eor}` + + `3${this._delimiter}Tanya Bennett${this._delimiter}Director${this._delimiter}${this._eor}` + + `4${this._delimiter}Jack Simon${this._delimiter}Software Developer${this._delimiter}${this._eor}` + + `5${this._delimiter}Celia Martinez${this._delimiter}Senior Software Developer${this._delimiter}${this._eor}` + + `6${this._delimiter}Erma Walsh${this._delimiter}CEO${this._delimiter}${this._eor}` + + `7${this._delimiter}Debra Morton${this._delimiter}Associate Software Developer${this._delimiter}${this._eor}` + + `8${this._delimiter}Erika Wells${this._delimiter}Software Development Team Lead${this._delimiter}${this._eor}` + + `9${this._delimiter}Leslie Hansen${this._delimiter}Associate Software Developer${this._delimiter}${this._eor}` + + `10${this._delimiter}Eduardo Ramirez${this._delimiter}Manager${this._delimiter}${this._eor}`; + } + + public get gridOneSeniorDev() { + return `ID${this._delimiter}Name${this._delimiter}JobTitle${this._eor}` + + `5${this._delimiter}Celia Martinez${this._delimiter}Senior Software Developer${this._eor}`; + } + + public get gridTwoDirectors() { + return `ID${this._delimiter}Name${this._delimiter}JobTitle${this._eor}` + + `2${this._delimiter}Gilberto Todd${this._delimiter}Director${this._eor}` + + `3${this._delimiter}Tanya Bennett${this._delimiter}Director${this._eor}`; + } + + public get gridNameJobTitle() { + return `Name${this._delimiter}JobTitle${this._eor}` + + `Casey Houston${this._delimiter}Vice President${this._eor}` + + `Gilberto Todd${this._delimiter}Director${this._eor}` + + `Tanya Bennett${this._delimiter}Director${this._eor}` + + `Jack Simon${this._delimiter}Software Developer${this._eor}` + + `Celia Martinez${this._delimiter}Senior Software Developer${this._eor}` + + `Erma Walsh${this._delimiter}CEO${this._eor}` + + `Debra Morton${this._delimiter}Associate Software Developer${this._eor}` + + `Erika Wells${this._delimiter}Software Development Team Lead${this._eor}` + + `Leslie Hansen${this._delimiter}Associate Software Developer${this._eor}` + + `Eduardo Ramirez${this._delimiter}Manager${this._eor}`; + } + + public get gridNameJobTitleID() { + return `Name${this._delimiter}JobTitle${this._delimiter}ID${this._eor}` + + `Casey Houston${this._delimiter}Vice President${this._delimiter}1${this._eor}` + + `Gilberto Todd${this._delimiter}Director${this._delimiter}2${this._eor}` + + `Tanya Bennett${this._delimiter}Director${this._delimiter}3${this._eor}` + + `Jack Simon${this._delimiter}Software Developer${this._delimiter}4${this._eor}` + + `Celia Martinez${this._delimiter}Senior Software Developer${this._delimiter}5${this._eor}` + + `Erma Walsh${this._delimiter}CEO${this._delimiter}6${this._eor}` + + `Debra Morton${this._delimiter}Associate Software Developer${this._delimiter}7${this._eor}` + + `Erika Wells${this._delimiter}Software Development Team Lead${this._delimiter}8${this._eor}` + + `Leslie Hansen${this._delimiter}Associate Software Developer${this._delimiter}9${this._eor}` + + `Eduardo Ramirez${this._delimiter}Manager${this._delimiter}10${this._eor}`; + } + + public get gridNameIDJobTitle() { + return `Name${this._delimiter}ID${this._delimiter}JobTitle${this._eor}` + + `Casey Houston${this._delimiter}1${this._delimiter}Vice President${this._eor}` + + `Gilberto Todd${this._delimiter}2${this._delimiter}Director${this._eor}` + + `Tanya Bennett${this._delimiter}3${this._delimiter}Director${this._eor}` + + `Jack Simon${this._delimiter}4${this._delimiter}Software Developer${this._eor}` + + `Celia Martinez${this._delimiter}5${this._delimiter}Senior Software Developer${this._eor}` + + `Erma Walsh${this._delimiter}6${this._delimiter}CEO${this._eor}` + + `Debra Morton${this._delimiter}7${this._delimiter}Associate Software Developer${this._eor}` + + `Erika Wells${this._delimiter}8${this._delimiter}Software Development Team Lead${this._eor}` + + `Leslie Hansen${this._delimiter}9${this._delimiter}Associate Software Developer${this._eor}` + + `Eduardo Ramirez${this._delimiter}10${this._delimiter}Manager${this._eor}`; + } + + public get gridWithAdvancedFilters() { + return `ID${this._delimiter}Name${this._delimiter}JobTitle${this._eor}` + + `6${this._delimiter}Erma Walsh${this._delimiter}CEO${this._eor}` + + `7${this._delimiter}Debra Morton${this._delimiter}Associate Software Developer${this._eor}` + + `8${this._delimiter}Erika Wells${this._delimiter}Software Development Team Lead${this._eor}` + + `10${this._delimiter}Eduardo Ramirez${this._delimiter}Manager${this._eor}`; + } + + public get treeGridData() { + return `ID${this._delimiter}ParentID${this._delimiter}Name${this._delimiter}JobTitle${this._delimiter}Age${this._eor}` + +`1${this._delimiter}-1${this._delimiter}Casey Houston${this._delimiter}Vice President${this._delimiter}32${this._eor}` + +`2${this._delimiter}1${this._delimiter}Gilberto Todd${this._delimiter}Director${this._delimiter}41${this._eor}` + +`3${this._delimiter}2${this._delimiter}Tanya Bennett${this._delimiter}Director${this._delimiter}29${this._eor}` + +`7${this._delimiter}2${this._delimiter}Debra Morton${this._delimiter}Associate Software Developer${this._delimiter}35${this._eor}` + +`4${this._delimiter}1${this._delimiter}Jack Simon${this._delimiter}Software Developer${this._delimiter}33${this._eor}` + +`6${this._delimiter}-1${this._delimiter}Erma Walsh${this._delimiter}CEO${this._delimiter}52${this._eor}` + +`10${this._delimiter}-1${this._delimiter}Eduardo Ramirez${this._delimiter}Manager${this._delimiter}53${this._eor}` + +`9${this._delimiter}10${this._delimiter}Leslie Hansen${this._delimiter}Associate Software Developer${this._delimiter}44${this._eor}`; + } + + public get treeGridDataFormatted() { + return `ID${this._delimiter}ParentID${this._delimiter}Name${this._delimiter}JobTitle${this._delimiter}Age${this._eor}` + +`1${this._delimiter}-1${this._delimiter}Casey Houston${this._delimiter}vice president${this._delimiter}384${this._eor}` + +`2${this._delimiter}1${this._delimiter}Gilberto Todd${this._delimiter}director${this._delimiter}492${this._eor}` + +`3${this._delimiter}2${this._delimiter}Tanya Bennett${this._delimiter}director${this._delimiter}348${this._eor}` + +`7${this._delimiter}2${this._delimiter}Debra Morton${this._delimiter}associate software developer${this._delimiter}420${this._eor}` + +`4${this._delimiter}1${this._delimiter}Jack Simon${this._delimiter}software developer${this._delimiter}396${this._eor}` + +`6${this._delimiter}-1${this._delimiter}Erma Walsh${this._delimiter}ceo${this._delimiter}624${this._eor}` + +`10${this._delimiter}-1${this._delimiter}Eduardo Ramirez${this._delimiter}manager${this._delimiter}636${this._eor}` + +`9${this._delimiter}10${this._delimiter}Leslie Hansen${this._delimiter}associate software developer${this._delimiter}528${this._eor}`; + } + + public get treeGridDataSorted() { + return `ID${this._delimiter}ParentID${this._delimiter}Name${this._delimiter}JobTitle${this._delimiter}Age${this._eor}` + +`10${this._delimiter}-1${this._delimiter}Eduardo Ramirez${this._delimiter}Manager${this._delimiter}53${this._eor}` + +`9${this._delimiter}10${this._delimiter}Leslie Hansen${this._delimiter}Associate Software Developer${this._delimiter}44${this._eor}` + +`6${this._delimiter}-1${this._delimiter}Erma Walsh${this._delimiter}CEO${this._delimiter}52${this._eor}` + +`1${this._delimiter}-1${this._delimiter}Casey Houston${this._delimiter}Vice President${this._delimiter}32${this._eor}` + +`4${this._delimiter}1${this._delimiter}Jack Simon${this._delimiter}Software Developer${this._delimiter}33${this._eor}` + +`2${this._delimiter}1${this._delimiter}Gilberto Todd${this._delimiter}Director${this._delimiter}41${this._eor}` + +`7${this._delimiter}2${this._delimiter}Debra Morton${this._delimiter}Associate Software Developer${this._delimiter}35${this._eor}` + +`3${this._delimiter}2${this._delimiter}Tanya Bennett${this._delimiter}Director${this._delimiter}29${this._eor}`; + } + + public get treeGridDataFiltered() { + return `ID${this._delimiter}ParentID${this._delimiter}Name${this._delimiter}JobTitle${this._delimiter}Age${this._eor}` + +`1${this._delimiter}-1${this._delimiter}Casey Houston${this._delimiter}Vice President${this._delimiter}32${this._eor}` + +`2${this._delimiter}1${this._delimiter}Gilberto Todd${this._delimiter}Director${this._delimiter}41${this._eor}` + +`7${this._delimiter}2${this._delimiter}Debra Morton${this._delimiter}Associate Software Developer${this._delimiter}35${this._eor}` + +`4${this._delimiter}1${this._delimiter}Jack Simon${this._delimiter}Software Developer${this._delimiter}33${this._eor}` + +`6${this._delimiter}-1${this._delimiter}Erma Walsh${this._delimiter}CEO${this._delimiter}52${this._eor}` + +`10${this._delimiter}-1${this._delimiter}Eduardo Ramirez${this._delimiter}Manager${this._delimiter}53${this._eor}` + +`9${this._delimiter}10${this._delimiter}Leslie Hansen${this._delimiter}Associate Software Developer${this._delimiter}44${this._eor}`; + } + + public get treeGridDataFilterSorted() { + return `ID${this._delimiter}ParentID${this._delimiter}Name${this._delimiter}JobTitle${this._delimiter}Age${this._eor}` + +`6${this._delimiter}-1${this._delimiter}Erma Walsh${this._delimiter}CEO${this._delimiter}52${this._eor}` + +`10${this._delimiter}-1${this._delimiter}Eduardo Ramirez${this._delimiter}Manager${this._delimiter}53${this._eor}` + +`9${this._delimiter}10${this._delimiter}Leslie Hansen${this._delimiter}Associate Software Developer${this._delimiter}44${this._eor}` + +`1${this._delimiter}-1${this._delimiter}Casey Houston${this._delimiter}Vice President${this._delimiter}32${this._eor}` + +`4${this._delimiter}1${this._delimiter}Jack Simon${this._delimiter}Software Developer${this._delimiter}33${this._eor}` + +`2${this._delimiter}1${this._delimiter}Gilberto Todd${this._delimiter}Director${this._delimiter}41${this._eor}` + +`7${this._delimiter}2${this._delimiter}Debra Morton${this._delimiter}Associate Software Developer${this._delimiter}35${this._eor}`; + } + + public get treeGridWithAdvancedFilters() { + return `ID${this._delimiter}ParentID${this._delimiter}Name${this._delimiter}JobTitle${this._delimiter}Age${this._eor}` + + `1${this._delimiter}-1${this._delimiter}Casey Houston${this._delimiter}Vice President${this._delimiter}32${this._eor}` + + `2${this._delimiter}1${this._delimiter}Gilberto Todd${this._delimiter}Director${this._delimiter}41${this._eor}` + + `7${this._delimiter}2${this._delimiter}Debra Morton${this._delimiter}Associate Software Developer${this._delimiter}35${this._eor}` + + `6${this._delimiter}-1${this._delimiter}Erma Walsh${this._delimiter}CEO${this._delimiter}52${this._eor}` + + `10${this._delimiter}-1${this._delimiter}Eduardo Ramirez${this._delimiter}Manager${this._delimiter}53${this._eor}`; + } + + public get gridColumnsAddedOnInit() { + return `CompanyName${this._delimiter}ContactName${this._delimiter}Address${this._delimiter}0${this._delimiter}` + + `1${this._delimiter}2${this._eor}` + + `Alfreds Futterkiste${this._delimiter}Maria Anders${this._delimiter}Obere Str. 57${this._delimiter}0${this._delimiter}` + + `2500${this._delimiter}5000${this._eor}` + + `Ana Trujillo Emparedados y helados${this._delimiter}Ana Trujillo${this._delimiter}Avda. de la Constitución 2222` + + `${this._delimiter}0${this._delimiter}2500${this._delimiter}5000${this._eor}` + + `Antonio Moreno Taquería${this._delimiter}Antonio Moreno${this._delimiter}Mataderos 2312${this._delimiter}0` + + `${this._delimiter}2500${this._delimiter}5000${this._eor}` + + `Around the Horn${this._delimiter}Thomas Hardy${this._delimiter}120 Hanover Sq.${this._delimiter}0${this._delimiter}` + + `2500${this._delimiter}5000${this._eor}` + + `Berglunds snabbköp${this._delimiter}Christina Berglund${this._delimiter}Berguvsvägen 8${this._delimiter}0` + + `${this._delimiter}2500${this._delimiter}5000${this._eor}` + + `Blauer See Delikatessen${this._delimiter}Hanna Moos${this._delimiter}Forsterstr. 57${this._delimiter}0${this._delimiter}` + + `2500${this._delimiter}5000${this._eor}` + + `Blondesddsl père et fils${this._delimiter}Frédérique Citeaux${this._delimiter}"24${this._delimiter} place Kléber"` + + `${this._delimiter}0${this._delimiter}2500${this._delimiter}5000${this._eor}` + + `Bólido Comidas preparadas${this._delimiter}Martín Sommer${this._delimiter}"C/ Araquil${this._delimiter} 67"` + + `${this._delimiter}0${this._delimiter}2500${this._delimiter}5000${this._eor}` + + `Bon app'${this._delimiter}Laurence Lebihan${this._delimiter}"12${this._delimiter} rue des Bouchers"${this._delimiter}` + + `0${this._delimiter}2500${this._delimiter}5000${this._eor}` + + `Bottom-Dollar Markets${this._delimiter}Elizabeth Lincoln${this._delimiter}23 Tsawassen Blvd.${this._delimiter}0` + + `${this._delimiter}2500${this._delimiter}5000${this._eor}` + + `B's Beverages${this._delimiter}Victoria Ashworth${this._delimiter}Fauntleroy Circus${this._delimiter}0${this._delimiter}` + + `2500${this._delimiter}5000${this._eor}`; + } + + public get pivotGridData() { + return `ProductCategory${this._delimiter}Bulgaria${this._delimiter}USA${this._delimiter}Uruguay${this._eor}` + + `Clothing${this._delimiter}774${this._delimiter}296${this._delimiter}456${this._eor}` + + `Bikes${this._delimiter}${this._delimiter}${this._delimiter}68${this._eor}` + + `Accessories${this._delimiter}${this._delimiter}293${this._delimiter}${this._eor}` + + `Components${this._delimiter}${this._delimiter}240${this._delimiter}${this._eor}`; + } +} diff --git a/projects/igniteui-angular/grids/core/src/services/excel/excel-elements-factory.ts b/projects/igniteui-angular/grids/core/src/services/excel/excel-elements-factory.ts new file mode 100644 index 00000000000..4333b1d787e --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/services/excel/excel-elements-factory.ts @@ -0,0 +1,96 @@ +import { + ExcelFileTypes, + ExcelFolderTypes +} from './excel-enums'; + +import { + AppFile, + ContentTypesFile, + CoreFile, + RootRelsFile, + SharedStringsFile, + StyleFile, + TablesFile, + ThemeFile, + WorkbookFile, + WorkbookRelsFile, + WorksheetFile, + WorksheetRelsFile +} from './excel-files'; + +import { + DocPropsExcelFolder, + RootExcelFolder, + RootRelsExcelFolder, + TablesExcelFolder, + ThemeExcelFolder, + WorksheetsExcelFolder, + WorksheetsRelsExcelFolder, + XLExcelFolder, + XLRelsExcelFolder +} from './excel-folders'; + +import { + IExcelFile, + IExcelFolder +} from './excel-interfaces'; + +/** @hidden */ +export class ExcelElementsFactory { + + public static getExcelFolder(type: ExcelFolderTypes): IExcelFolder { + switch (type) { + case ExcelFolderTypes.RootExcelFolder: + return new RootExcelFolder(); + case ExcelFolderTypes.RootRelsExcelFolder: + return new RootRelsExcelFolder(); + case ExcelFolderTypes.DocPropsExcelFolder: + return new DocPropsExcelFolder(); + case ExcelFolderTypes.XLExcelFolder: + return new XLExcelFolder(); + case ExcelFolderTypes.XLRelsExcelFolder: + return new XLRelsExcelFolder(); + case ExcelFolderTypes.ThemeExcelFolder: + return new ThemeExcelFolder(); + case ExcelFolderTypes.WorksheetsExcelFolder: + return new WorksheetsExcelFolder(); + case ExcelFolderTypes.WorksheetsRelsExcelFolder: + return new WorksheetsRelsExcelFolder(); + case ExcelFolderTypes.TablesExcelFolder: + return new TablesExcelFolder(); + default: + throw new Error('Unknown excel folder type!'); + } + } + + public static getExcelFile(type: ExcelFileTypes): IExcelFile { + switch (type) { + case ExcelFileTypes.RootRelsFile: + return new RootRelsFile(); + case ExcelFileTypes.AppFile: + return new AppFile(); + case ExcelFileTypes.CoreFile: + return new CoreFile(); + case ExcelFileTypes.WorkbookRelsFile: + return new WorkbookRelsFile(); + case ExcelFileTypes.ThemeFile: + return new ThemeFile(); + case ExcelFileTypes.WorksheetFile: + return new WorksheetFile(); + case ExcelFileTypes.StyleFile: + return new StyleFile(); + case ExcelFileTypes.WorkbookFile: + return new WorkbookFile(); + case ExcelFileTypes.ContentTypesFile: + return new ContentTypesFile(); + case ExcelFileTypes.SharedStringsFile: + return new SharedStringsFile(); + case ExcelFileTypes.WorksheetRelsFile: + return new WorksheetRelsFile(); + case ExcelFileTypes.TablesFile: + return new TablesFile(); + default: + throw Error('Unknown excel file type!'); + } + } +} diff --git a/projects/igniteui-angular/grids/core/src/services/excel/excel-enums.ts b/projects/igniteui-angular/grids/core/src/services/excel/excel-enums.ts new file mode 100644 index 00000000000..6153577f2ea --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/services/excel/excel-enums.ts @@ -0,0 +1,31 @@ +/** + * @hidden + */ +export enum ExcelFolderTypes { + RootExcelFolder, + RootRelsExcelFolder, + DocPropsExcelFolder, + XLExcelFolder, + XLRelsExcelFolder, + ThemeExcelFolder, + WorksheetsExcelFolder, + WorksheetsRelsExcelFolder, + TablesExcelFolder +} +/** + * @hidden + */ +export enum ExcelFileTypes { + RootRelsFile, + AppFile, + CoreFile, + WorkbookRelsFile, + ThemeFile, + WorksheetFile, + StyleFile, + WorkbookFile, + ContentTypesFile, + SharedStringsFile, + WorksheetRelsFile, + TablesFile +} diff --git a/projects/igniteui-angular/grids/core/src/services/excel/excel-exporter-grid.spec.ts b/projects/igniteui-angular/grids/core/src/services/excel/excel-exporter-grid.spec.ts new file mode 100644 index 00000000000..45d15167a3d --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/services/excel/excel-exporter-grid.spec.ts @@ -0,0 +1,1657 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { IColumnExportingEventArgs, IRowExportingEventArgs } from '../exporter-common/base-export-service'; +import { ExportUtilities } from '../exporter-common/export-utilities'; +import { TestMethods } from '../exporter-common/test-methods.spec'; +import { IgxExcelExporterService } from './excel-exporter'; +import { IgxExcelExporterOptions } from './excel-exporter-options'; +import { + ReorderedColumnsComponent, + GridIDNameJobTitleComponent, + ProductsComponent, + GridIDNameJobTitleHireDataPerformanceComponent, + GridHireDateComponent, + GridExportGroupedDataComponent, + MultiColumnHeadersExportComponent, + GridWithEmptyColumnsComponent, + ColumnsAddedOnInitComponent, + GridWithThreeLevelsOfMultiColumnHeadersAndTwoRowsExportComponent, + GroupedGridWithSummariesComponent, + GridCurrencySummariesComponent, + GridUserMeetingDataComponent, + GridCustomSummaryComponent, + GridCustomSummaryWithNullAndZeroComponent, + GridCustomSummaryWithUndefinedZeroAndValidNumberComponent, + GridCustomSummaryWithUndefinedAndNullComponent, + GridCustomSummaryWithDateComponent +} from '../../../../../test-utils/grid-samples.spec'; +import { SampleTestData } from '../../../../../test-utils/sample-test-data.spec'; +import { first } from 'rxjs/operators'; +import { IgxTreeGridPrimaryForeignKeyComponent, IgxTreeGridSummariesKeyComponent } from '../../../../../test-utils/tree-grid-components.spec'; + +import { UIInteractions, wait } from '../../../../../test-utils/ui-interactions.spec'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxHierarchicalGridExportComponent, + IgxHierarchicalGridMCHCollapsibleComponent, + IgxHierarchicalGridMultiColumnHeaderIslandsExportComponent, + IgxHierarchicalGridMultiColumnHeadersExportComponent, + IgxHierarchicalGridSummariesExportComponent +} from '../../../../../test-utils/hierarchical-grid-components.spec'; +import { GridFunctions } from '../../../../../test-utils/grid-functions.spec'; +import { IgxPivotGridMultipleRowComponent, IgxPivotGridTestComplexHierarchyComponent, SALES_DATA } from '../../../../../test-utils/pivot-grid-samples.spec'; +import { IgxHierarchicalRowComponent } from 'igniteui-angular/grids/hierarchical-grid/src/hierarchical-row.component'; +import { IgxTreeGridComponent } from 'igniteui-angular/grids/tree-grid'; +import { IgxPivotGridComponent } from 'igniteui-angular/grids/pivot-grid'; +import { IgxGridNavigationService, IgxPivotNumericAggregate, PivotRowLayoutType } from 'igniteui-angular/grids/core'; +import { IgxHierarchicalGridComponent } from 'igniteui-angular/grids/hierarchical-grid'; +import { IgxGridComponent } from 'igniteui-angular/grids/grid'; +import { FileContentData } from './test-data.service.spec'; +import { ZipWrapper } from './zip-verification-wrapper.spec'; +import { DefaultSortingStrategy, FilteringExpressionsTree, FilteringLogic, IgxNumberFilteringOperand, IgxStringFilteringOperand, SortingDirection } from 'igniteui-angular/core'; + +describe('Excel Exporter', () => { + let exporter: IgxExcelExporterService; + let actualData: FileContentData; + let options: IgxExcelExporterOptions; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + ReorderedColumnsComponent, + GridIDNameJobTitleComponent, + IgxTreeGridPrimaryForeignKeyComponent, + ProductsComponent, + GridWithEmptyColumnsComponent, + GridIDNameJobTitleHireDataPerformanceComponent, + GridHireDateComponent, + GridExportGroupedDataComponent, + IgxHierarchicalGridExportComponent, + MultiColumnHeadersExportComponent, + IgxHierarchicalGridMultiColumnHeadersExportComponent, + ColumnsAddedOnInitComponent, + IgxHierarchicalGridMultiColumnHeaderIslandsExportComponent, + GridWithThreeLevelsOfMultiColumnHeadersAndTwoRowsExportComponent, + IgxPivotGridMultipleRowComponent, + IgxPivotGridTestComplexHierarchyComponent, + IgxTreeGridSummariesKeyComponent, + IgxHierarchicalGridSummariesExportComponent, + GroupedGridWithSummariesComponent, + GridCurrencySummariesComponent, + GridUserMeetingDataComponent, + GridCustomSummaryComponent, + GridCustomSummaryWithNullAndZeroComponent, + GridCustomSummaryWithUndefinedZeroAndValidNumberComponent, + GridCustomSummaryWithUndefinedAndNullComponent, + GridCustomSummaryWithDateComponent + ], + providers: [ + IgxGridNavigationService + ] + }).compileComponents(); + })); + + beforeEach(waitForAsync(() => { + exporter = new IgxExcelExporterService(); + actualData = new FileContentData(); + + // Spy the saveBlobToFile method so the files are not really created + spyOn(ExportUtilities as any, 'saveBlobToFile'); + })); + + afterEach(waitForAsync(() => { + exporter.columnExporting.unsubscribe(); + exporter.rowExporting.unsubscribe(); + })); + + describe('', () => { + beforeEach(waitForAsync(() => { + options = createExportOptions('GridExcelExport', 50); + })); + + it('should export grid as displayed.', async () => { + const currentGrid: IgxGridComponent = null; + await TestMethods.testRawData(currentGrid, async (grid) => { + await exportAndVerify(grid, options, actualData.simpleGridData); + }); + }); + + it('should honor \'ignoreFiltering\' option.', async () => { + const result = await TestMethods.createGridAndFilter(); + const fix = result.fixture; + const grid = result.grid; + expect(grid.rowList.length).toEqual(1); + + options.ignoreFiltering = false; + fix.detectChanges(); + + let wrapper = await getExportedData(grid, options); + await wrapper.verifyDataFilesContent(actualData.simpleGridDataRecord5, 'One row only should have been exported!'); + + options.ignoreFiltering = true; + fix.detectChanges(); + wrapper = await getExportedData(grid, options); + await wrapper.verifyDataFilesContent(actualData.simpleGridData, 'All 10 rows should have been exported!'); + }); + + it('should honor filter criteria changes.', async () => { + const result = await TestMethods.createGridAndFilter(); + const fix = result.fixture; + const grid = result.grid; + expect(grid.rowList.length).toEqual(1); + options.ignoreFiltering = false; + + let wrapper = await getExportedData(grid, options); + await wrapper.verifyDataFilesContent(actualData.simpleGridDataRecord5, 'One row should have been exported!'); + + grid.filter('JobTitle', 'Director', IgxStringFilteringOperand.instance().condition('equals'), true); + fix.detectChanges(); + + expect(grid.rowList.length).toEqual(2, 'Invalid number of rows after filtering!'); + wrapper = await getExportedData(grid, options); + await wrapper.verifyDataFilesContent(actualData.simpleGridDataDirectors, 'Two rows should have been exported!'); + }); + + it('should honor \'ignoreColumnsVisibility\' option.', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + grid.columnList.get(0).hidden = true; + options.ignoreColumnsVisibility = false; + fix.detectChanges(); + + expect(grid.visibleColumns.length).toEqual(2, 'Invalid number of visible columns!'); + let wrapper = await getExportedData(grid, options); + await wrapper.verifyDataFilesContent(actualData.simpleGridNameJobTitle, 'Two columns should have been exported!'); + + options.ignoreColumnsVisibility = true; + fix.detectChanges(); + wrapper = await getExportedData(grid, options); + await wrapper.verifyDataFilesContent(actualData.simpleGridData, 'All three columns should have been exported!'); + }); + + it('should honor columns visibility changes.', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + options.ignoreColumnsVisibility = false; + + expect(grid.visibleColumns.length).toEqual(3, 'Invalid number of visible columns!'); + let wrapper = await getExportedData(grid, options); + await wrapper.verifyDataFilesContent(actualData.simpleGridData, 'All columns should have been exported!'); + + grid.columnList.get(0).hidden = true; + fix.detectChanges(); + + expect(grid.visibleColumns.length).toEqual(2, 'Invalid number of visible columns!'); + wrapper = await getExportedData(grid, options); + await wrapper.verifyDataFilesContent(actualData.simpleGridNameJobTitle, 'Two columns should have been exported!'); + + grid.columnList.get(0).hidden = false; + fix.detectChanges(); + + expect(grid.visibleColumns.length).toEqual(3, 'Invalid number of visible columns!'); + wrapper = await getExportedData(grid, options); + await wrapper.verifyDataFilesContent(actualData.simpleGridData, 'All columns should have been exported!'); + + grid.columnList.get(0).hidden = undefined; + fix.detectChanges(); + + expect(grid.visibleColumns.length).toEqual(3, 'Invalid number of visible columns!'); + wrapper = await getExportedData(grid, options); + await wrapper.verifyDataFilesContent(actualData.simpleGridData, 'All columns should have been exported!'); + }); + + it('should honor columns declaration order.', async () => { + const fix = TestBed.createComponent(ReorderedColumnsComponent); + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + + const wrapper = await getExportedData(grid, options); + await wrapper.verifyDataFilesContent(actualData.simpleGridNameJobTitleID); + }); + + it('should honor \'ignorePinning\' option.', async () => { + const result = await TestMethods.createGridAndPinColumn([1]); + const fix = result.fixture; + const grid = result.grid; + + options.ignorePinning = false; + fix.detectChanges(); + + let wrapper = await getExportedData(grid, options); + wrapper.verifyStructure(); + // await wrapper.verifyTemplateFilesContent(); + await wrapper.verifyDataFilesContent(actualData.gridNameFrozen, 'One frozen column should have been exported!'); + + options.ignorePinning = true; + fix.detectChanges(); + wrapper = await getExportedData(grid, options); + await wrapper.verifyDataFilesContent(actualData.gridNameIDJobTitle, 'No frozen columns should have been exported!'); + }); + + it('should honor pinned state changes.', async () => { + const result = await TestMethods.createGridAndPinColumn([1]); + const fix = result.fixture; + const grid = result.grid; + + let wrapper = await getExportedData(grid, options); + await wrapper.verifyDataFilesContent(actualData.gridNameFrozen, 'One frozen column should have been exported!'); + + grid.columnList.get(1).pinned = false; + fix.detectChanges(); + wrapper = await getExportedData(grid, options); + await wrapper.verifyDataFilesContent(actualData.simpleGridData, 'No frozen columns should have been exported!'); + }); + + it('should honor all pinned columns.', async () => { + const result = await TestMethods.createGridAndPinColumn(2, 0); + const grid = result.grid; + + const wrapper = await getExportedData(grid, options); + wrapper.verifyStructure(); + await wrapper.verifyDataFilesContent(actualData.gridJobTitleIdFrozen, 'Not all pinned columns are frozen in the export!'); + }); + + it('should honor \'freezeHeaders\' option.', async () => { + const result = await TestMethods.createGridAndPinColumn([1]); + const fix = result.fixture; + const grid = result.grid; + + options.ignorePinning = false; + options.freezeHeaders = true; + fix.detectChanges(); + + let wrapper = await getExportedData(grid, options); + wrapper.verifyStructure(); + await wrapper.verifyDataFilesContent(actualData.gridNameFrozenHeaders, + 'One frozen column and frozen headers should have been exported!'); + + options.ignorePinning = true; + fix.detectChanges(); + wrapper = await getExportedData(grid, options); + await wrapper.verifyDataFilesContent(actualData.gridFrozenHeaders, + 'No frozen columns and frozen headers should have been exported!'); + + options.freezeHeaders = false; + fix.detectChanges(); + wrapper = await getExportedData(grid, options); + await wrapper.verifyDataFilesContent(actualData.gridNameIDJobTitle, + 'No frozen columns and no frozen headers should have been exported!'); + }); + + it('should honor applied sorting.', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + grid.sort({ fieldName: 'Name', dir: SortingDirection.Asc, ignoreCase: true, strategy: DefaultSortingStrategy.instance() }); + fix.detectChanges(); + + const wrapper = await getExportedData(grid, options); + await wrapper.verifyDataFilesContent(actualData.simpleGridSortByName); + + // XXX : ???? What's the point of this? + // grid.clearSort(); + // fix.detectChanges(); + }); + + it('should honor changes in applied sorting.', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + grid.sort({ fieldName: 'Name', dir: SortingDirection.Asc, ignoreCase: true, strategy: DefaultSortingStrategy.instance() }); + fix.detectChanges(); + + let wrapper = await getExportedData(grid, options); + await wrapper.verifyDataFilesContent(actualData.simpleGridSortByName, 'Ascending sorted data should have been exported.'); + + grid.sort({ fieldName: 'Name', dir: SortingDirection.Desc, ignoreCase: true, strategy: DefaultSortingStrategy.instance() }); + fix.detectChanges(); + + wrapper = await getExportedData(grid, options); + await wrapper.verifyDataFilesContent( + actualData.simpleGridSortByNameDesc(), 'Descending sorted data should have been exported.'); + + grid.clearSort(); + grid.sort({ fieldName: 'ID', dir: SortingDirection.Asc, ignoreCase: true, strategy: DefaultSortingStrategy.instance() }); + fix.detectChanges(); + + // wrapper = await getExportedData(grid, options); + // await wrapper.verifyDataFilesContent(actualData.simpleGridSortByNameDesc(false), 'Unsorted data should have been exported.'); + }); + + it('should export all columns with the width specified in options.', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + grid.columnList.get(1).hidden = true; + grid.columnList.get(2).hidden = true; + const columnWidths = [100, 200, 0, null]; + fix.detectChanges(); + + await setColWidthAndExport(grid, options, fix, columnWidths[0]); + await setColWidthAndExport(grid, options, fix, columnWidths[1]); + await setColWidthAndExport(grid, options, fix, columnWidths[2]); + await setColWidthAndExport(grid, options, fix, columnWidths[3]); + }); + + it('should export all rows with the height specified in options.', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + + const rowHeights = [20, 40, 0, undefined, null]; + + await setRowHeightAndExport(grid, options, fix, rowHeights[0]); + await setRowHeightAndExport(grid, options, fix, rowHeights[1]); + await setRowHeightAndExport(grid, options, fix, rowHeights[2]); + await setRowHeightAndExport(grid, options, fix, rowHeights[3]); + await setRowHeightAndExport(grid, options, fix, rowHeights[4]); + }); + + it('should fire \'columnExporting\' for each grid column.', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + + const cols = []; + exporter.columnExporting.subscribe((value) => { + cols.push({ header: value.header, index: value.columnIndex }); + }); + + await getExportedData(grid, options); + expect(cols.length).toBe(3); + expect(cols[0].header).toBe('ID'); + expect(cols[0].index).toBe(0); + expect(cols[1].header).toBe('Name'); + expect(cols[1].index).toBe(1); + expect(cols[2].header).toBe('JobTitle'); + expect(cols[2].index).toBe(2); + }); + + it('should fire \'columnExporting\' for each visible grid column.', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + + const cols = []; + exporter.columnExporting.subscribe((value) => { + cols.push({ header: value.header, index: value.columnIndex }); + }); + + grid.columnList.get(0).hidden = true; + options.ignoreColumnsVisibility = false; + fix.detectChanges(); + + const wrapper = await getExportedData(grid, options); + expect(cols.length).toBe(2); + expect(cols[0].header).toBe('Name'); + expect(cols[0].index).toBe(0); + expect(cols[1].header).toBe('JobTitle'); + expect(cols[1].index).toBe(1); + await wrapper.verifyDataFilesContent(actualData.simpleGridNameJobTitle); + }); + + it('should not export columns when \'columnExporting\' is canceled.', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + options.alwaysExportHeaders = false; + + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + + exporter.columnExporting.subscribe((value: IColumnExportingEventArgs) => { + value.cancel = true; + }); + + const wrapper = await getExportedData(grid, options); + expect(wrapper.hasValues).toBe(false); + wrapper.verifyStructure(); + await wrapper.verifyTemplateFilesContent(); + }); + + it('should export the column at the specified index when \'columnIndex\' is set during \'columnExporting\' event.', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + + exporter.columnExporting.subscribe((value: IColumnExportingEventArgs) => { + if (value.columnIndex === 0) { + value.columnIndex = 2; + } + }); + + await exportAndVerify(grid, options, actualData.simpleGridNameJobTitleID); + }); + + it('should export the column at the specified index when \'columnIndex\' is set during \'columnExporting\' (2).', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + + exporter.columnExporting.subscribe((value: IColumnExportingEventArgs) => { + if (value.columnIndex === 2) { + value.columnIndex = 0; + } + }); + + await exportAndVerify(grid, options, actualData.simpleGridJobTitleIDName); + }); + + it('should handle gracefully setting \'columnIndex\' to an invalid value.', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + + exporter.columnExporting.subscribe((value: IColumnExportingEventArgs) => { + if (value.columnIndex === 0) { + value.columnIndex = 4; + } else if (value.columnIndex === 2) { + value.columnIndex = -1; + } + }); + + await exportAndVerify(grid, options, actualData.simpleGridNameJobTitleID); + }); + + it('should fire \'rowExporting\' for each grid row.', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + const data = SampleTestData.personJobData(); + + const rows = []; + exporter.rowExporting.subscribe((value: IRowExportingEventArgs) => { + rows.push({ data: value.rowData, index: value.rowIndex }); + }); + + await getExportedData(grid, options); + expect(rows.length).toBe(10); + for (let i = 0; i < rows.length; i++) { + expect(rows[i].index).toBe(i); + expect(JSON.stringify(rows[i].data)).toBe(JSON.stringify(data[i])); + } + }); + + it('should not export rows when \'rowExporting\' is canceled.', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + options.alwaysExportHeaders = false; + + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + + exporter.rowExporting.subscribe((value: IRowExportingEventArgs) => { + value.cancel = true; + }); + + const wrapper = await getExportedData(grid, options); + expect(wrapper.hasValues).toBe(false); + wrapper.verifyStructure(); + await wrapper.verifyTemplateFilesContent(); + }); + + it('shouldn\'t affect grid sort expressions', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + grid.columnList.get(1).header = 'My header'; + grid.columnList.get(1).sortable = true; + grid.sort({ fieldName: 'Name', dir: SortingDirection.Desc, ignoreCase: false }); + const sortField = grid.sortingExpressions[0].fieldName; + fix.detectChanges(); + + await getExportedData(grid, options); + fix.detectChanges(); + + await getExportedData(grid, options); + const sortFieldAfterExport = grid.sortingExpressions[0].fieldName; + expect(sortField).toBe(sortFieldAfterExport); + }); + + it('should skip the column formatter when \'columnExporting\' skipFormatter is true', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + + // Set column formatters + grid.columnList.get(0).formatter = ((val: number) => { + const numbers = ['one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten']; + return numbers[val - 1]; + }); + grid.cdr.detectChanges(); + fix.detectChanges(); + + // Verify the exported data is formatted by default + await exportAndVerify(grid, options, actualData.simpleGridNameJobTitleWithFormatting); + + exporter.columnExporting.subscribe((val: IColumnExportingEventArgs) => { + val.skipFormatter = true; + }); + fix.detectChanges(); + grid.cdr.detectChanges(); + + // Verify the data without formatting + await exportAndVerify(grid, options, actualData.simpleGridData); + + exporter.columnExporting.subscribe((val: IColumnExportingEventArgs) => { + val.skipFormatter = false; + }); + grid.cdr.detectChanges(); + fix.detectChanges(); + // Verify the exported data with formatting + await exportAndVerify(grid, options, actualData.simpleGridNameJobTitleWithFormatting); + }); + + it('should export columns without header', async () => { + const fix = TestBed.createComponent(GridWithEmptyColumnsComponent); + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + // Verify the data without formatting + await exportAndVerify(grid, options, actualData.gridWithEmptyColumns); + + exporter.columnExporting.subscribe((value: IColumnExportingEventArgs) => { + if (value.columnIndex === 0 || value.columnIndex === 2) { + value.cancel = true; + } + }); + await exportAndVerify(grid, options, actualData.simpleGridData); + }); + + it('Should honor Advanced filters when exporting', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + const tree = new FilteringExpressionsTree(FilteringLogic.And); + tree.filteringOperands.push({ + fieldName: 'Name', + searchVal: 'a', + condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains', + ignoreCase: true + }); + tree.filteringOperands.push({ + fieldName: 'Name', + searchVal: 'r', + condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains', + ignoreCase: true + }); + tree.filteringOperands.push({ + fieldName: 'ID', + searchVal: 5, + condition: IgxNumberFilteringOperand.instance().condition('greaterThan'), + conditionName: 'greaterThan' + }); + + grid.advancedFilteringExpressionsTree = tree; + fix.detectChanges(); + grid.cdr.detectChanges(); + await wait(); + expect(grid.filteredData.length).toBe(4); + + // Export and verify + await exportAndVerify(grid, options, actualData.gridWithAdvancedFilters); + }); + + it('Should set worksheet name', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + const worksheetName = 'NewWorksheetName'; + + await setWorksheetNameAndExport(grid, options, fix, worksheetName); + }); + + it('Should export arrays as strings.', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleHireDataPerformanceComponent); + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + + await exportAndVerify(grid, options, actualData.personJobHoursDataPerformance); + }); + + it('Should export dates correctly.', async () => { + const fix = TestBed.createComponent(GridHireDateComponent); + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + + await exportAndVerify(grid, options, actualData.hireDate); + }); + + it('Should export grouped grid', async () => { + const fix = TestBed.createComponent(GridExportGroupedDataComponent); + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + + grid.groupBy({ fieldName: 'Brand', dir: SortingDirection.Asc, ignoreCase: false }); + grid.groupBy({ fieldName: 'Price', dir: SortingDirection.Desc, ignoreCase: false }); + + fix.detectChanges(); + + await exportAndVerify(grid, options, actualData.exportGroupedData); + }); + + it('Should export grouped grid with collapsed rows', async () => { + const fix = TestBed.createComponent(GridExportGroupedDataComponent); + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + + grid.groupBy({ fieldName: 'Brand', dir: SortingDirection.Asc, ignoreCase: false }); + grid.groupBy({ fieldName: 'Price', dir: SortingDirection.Desc, ignoreCase: false }); + + fix.detectChanges(); + + const groupRows = grid.groupsRecords; + + grid.toggleGroup(groupRows[0].groups[1]); + grid.toggleGroup(groupRows[1]); + grid.toggleGroup(groupRows[1].groups[2]); + + fix.detectChanges(); + + + await exportAndVerify(grid, options, actualData.exportGroupedDataWithCollapsedRows); + }); + + it('Should export grouped grid with ignored sorting', async () => { + const fix = TestBed.createComponent(GridExportGroupedDataComponent); + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + + grid.groupBy({ fieldName: 'Brand', dir: SortingDirection.Asc, ignoreCase: false }); + grid.sort({ fieldName: 'Price', dir: SortingDirection.Desc }); + + options.ignoreSorting = true; + + fix.detectChanges(); + + await exportAndVerify(grid, options, actualData.exportGroupedDataWithIgnoreSorting); + }); + + it('Should export grouped grid with ignored filtering', async () => { + const fix = TestBed.createComponent(GridExportGroupedDataComponent); + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + + grid.groupBy({ fieldName: 'Brand', dir: SortingDirection.Asc, ignoreCase: false }); + + grid.filter('Model', 'Model', IgxStringFilteringOperand.instance().condition('contains'), true); + fix.detectChanges(); + + options.ignoreFiltering = true; + + fix.detectChanges(); + + await exportAndVerify(grid, options, actualData.exportGroupedDataWithIgnoreFiltering); + }); + + it('Should export grouped grid with ignored grouping', async () => { + const fix = TestBed.createComponent(GridExportGroupedDataComponent); + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + + grid.groupBy({ fieldName: 'Brand', dir: SortingDirection.Asc, ignoreCase: false }); + + options.ignoreGrouping = true; + + fix.detectChanges(); + + await exportAndVerify(grid, options, actualData.exportGroupedDataWithIgnoreGrouping); + }); + + it('should map dynamically added data & columns properly (#9872).', async () => { + const fix = TestBed.createComponent(ColumnsAddedOnInitComponent); + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + await exportAndVerify(grid, options, actualData.columnsAddedOnInit); + }); + + it('Should escape special chars in headers', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleHireDataPerformanceComponent); + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + grid.columnList.get(1).header = '&'; + grid.columnList.get(2).header = '<>'; + grid.columnList.get(3).header = '"'; + grid.columnList.get(4).header = '\''; + + + await exportAndVerify(grid, options, actualData.exportGridDataWithSpecialCharsInHeaders); + }); + + it('Should export date, dateTime, time and percent columns correctly', async () => { + const fix = TestBed.createComponent(GridUserMeetingDataComponent); + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + + await exportAndVerify(grid, options, actualData.exportGriWithDateData); + }); + + it('Should respect column formatter', async () => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + await wait(); + + const grid = fix.componentInstance.grid; + const nameCol = grid.getColumnByName('Name'); + nameCol.formatter = fix.componentInstance.formatter; + grid.getColumnByName('JobTitle').hidden = true; + + fix.detectChanges(); + + await exportAndVerify(grid, options, actualData.exportGriWithFormattedColumn); + }); + }); + + describe('', () => { + let fix; + let hGrid; + beforeEach(waitForAsync(() => { + options = createExportOptions('HierarchicalGridExcelExport'); + fix = TestBed.createComponent(IgxHierarchicalGridExportComponent); + fix.detectChanges(); + + hGrid = fix.componentInstance.hGrid; + })); + + it('should export hierarchical grid', async () => { + await exportAndVerify(hGrid, options, actualData.exportHierarchicalData); + }); + + it('should export hierarchical grid respecting options width.', async () => { + options = createExportOptions('HierarchicalGridExcelExport', 50); + + await exportAndVerify(hGrid, options, actualData.exportHierarchicalDataWithColumnWidth); + }); + + it('should export sorted hierarchical grid data', async () => { + hGrid.sort({ fieldName: 'GrammyNominations', dir: SortingDirection.Desc }); + fix.detectChanges(); + + await exportAndVerify(hGrid, options, actualData.exportSortedHierarchicalData); + }); + + it('should export hierarchical grid data with ignored sorting', async () => { + hGrid.sort({ fieldName: 'GrammyNominations', dir: SortingDirection.Desc }); + + options.ignoreSorting = true; + fix.detectChanges(); + + await exportAndVerify(hGrid, options, actualData.exportHierarchicalData); + }); + + it('should export filtered hierarchical grid data', async () => { + hGrid.filter('Debut', '2009', IgxStringFilteringOperand.instance().condition('contains'), true); + fix.detectChanges(); + + await exportAndVerify(hGrid, options, actualData.exportFilteredHierarchicalData); + }); + + it('should export hierarchical grid data with ignored filtering', async () => { + hGrid.filter('Debut', '2009', IgxStringFilteringOperand.instance().condition('contains'), true); + fix.detectChanges(); + + options.ignoreFiltering = true; + + await exportAndVerify(hGrid, options, actualData.exportHierarchicalData); + }); + + it('should export hierarchical grid with expanded rows.', async () => { + const firstRow = hGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + const secondRow = hGrid.gridAPI.get_row_by_index(1) as IgxHierarchicalRowComponent; + + UIInteractions.simulateClickAndSelectEvent(firstRow.expander); + fix.detectChanges(); + expect(firstRow.expanded).toBe(true); + + let childGrids = hGrid.gridAPI.getChildGrids(false); + + const firstChildGrid = childGrids[0]; + const firstChildRow = firstChildGrid.gridAPI.get_row_by_index(2) as IgxHierarchicalRowComponent; + + UIInteractions.simulateClickAndSelectEvent(firstChildRow.expander); + fix.detectChanges(); + expect(firstChildRow.expanded).toBe(true); + + const secondChildGrid = childGrids[1]; + const secondChildRow = secondChildGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + + UIInteractions.simulateClickAndSelectEvent(secondChildRow.expander); + fix.detectChanges(); + expect(secondChildRow.expanded).toBe(true); + + UIInteractions.simulateClickAndSelectEvent(secondRow.expander); + fix.detectChanges(); + expect(secondRow.expanded).toBe(true); + + childGrids = hGrid.gridAPI.getChildGrids(false); + + const thirdChildGrid = childGrids[3]; + const thirdChildRow = thirdChildGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + + UIInteractions.simulateClickAndSelectEvent(thirdChildRow.expander); + fix.detectChanges(); + expect(thirdChildRow.expanded).toBe(true); + + await exportAndVerify(hGrid, options, actualData.exportHierarchicalDataWithExpandedRows); + }); + + it('should export hierarchical grid data with frozen headers', async () => { + options.freezeHeaders = true; + fix.detectChanges(); + + await exportAndVerify(hGrid, options, actualData.exportHierarchicalDataWithFrozenHeaders); + }); + + it('should export hierarchical grid with skipped columns', async () => { + exporter.columnExporting.subscribe((args: IColumnExportingEventArgs) => { + if (args.header === 'Debut' || + args.header === 'Billboard Review' || + args.header === 'Album' || + args.header === 'Tickets Sold' || + args.header === 'Released') { + args.cancel = true; + } + }); + + fix.detectChanges(); + + await exportAndVerify(hGrid, options, actualData.exportHierarchicalDataWithSkippedColumns); + }); + + it('should export hierarchical grid with all child rows canceled.', async () => { + exporter.rowExporting.subscribe((args: IRowExportingEventArgs) => { + if (args.owner?.key === "Albums" || + args.owner?.key === "Songs" || + args.owner?.key === "Tours" || + args.owner?.key === "TourData") { + args.cancel = true; + } + }); + + fix.detectChanges(); + + await exportAndVerify(hGrid, options, actualData.exportHierarchicalDataWithSkippedRows); + }); + }); + + describe('', () => { + it('should export hierarchical grid with multi column headers', async () => { + const fix = TestBed.createComponent(IgxHierarchicalGridMultiColumnHeadersExportComponent); + fix.detectChanges(); + + const hGrid = fix.componentInstance.hGrid; + options = createExportOptions('HierarchicalGridMCHExcelExport'); + await exportAndVerify(hGrid, options, actualData.exportHierarchicalDataWithMultiColumnHeaders); + }); + + it('should export hierarchical grid with multi column headers only in the row island', async () => { + const fix = TestBed.createComponent(IgxHierarchicalGridMultiColumnHeaderIslandsExportComponent); + fix.detectChanges(); + + const hGrid = fix.componentInstance.hGrid; + options = createExportOptions('HierarchicalGridMCHExcelExport'); + await exportAndVerify(hGrid, options, actualData.exportHierarchicalDataWithMultiColumnHeadersOnlyInIsland); + }); + + it('should export hierarchical grid with multi column headers and skipped column', async () => { + const fix = TestBed.createComponent(IgxHierarchicalGridMultiColumnHeadersExportComponent); + fix.detectChanges(); + + const hGrid = fix.componentInstance.hGrid; + options = createExportOptions('HierarchicalGridMCHExcelExport'); + + exporter.columnExporting.subscribe((args: IColumnExportingEventArgs) => { + if (args.field === 'ContactTitle') { + args.cancel = true; + } + }); + fix.detectChanges(); + + await exportAndVerify(hGrid, options, actualData.exportMultiColumnHeadersDataWithSkippedColumn); + }); + + it('should export hierarchical grid with multi column headers and skipped parent multi column header', async () => { + const fix = TestBed.createComponent(IgxHierarchicalGridMultiColumnHeadersExportComponent); + fix.detectChanges(); + + const hGrid = fix.componentInstance.hGrid; + options = createExportOptions('HierarchicalGridMCHExcelExport'); + + exporter.columnExporting.subscribe((args: IColumnExportingEventArgs) => { + if (args.header === 'Address Information') { + args.cancel = true; + } + }); + fix.detectChanges(); + + await exportAndVerify(hGrid, options, actualData.exportMultiColumnHeadersDataWithSkippedParentMCH); + }); + + it('should export empty file when all parent multi column headers are skipped and alwaysExportHeaders is false', async () => { + const fix = TestBed.createComponent(IgxHierarchicalGridMultiColumnHeadersExportComponent); + fix.detectChanges(); + + const hGrid = fix.componentInstance.hGrid; + options = createExportOptions('HierarchicalGridMCHExcelExport'); + options.alwaysExportHeaders = false; + + exporter.columnExporting.subscribe((args: IColumnExportingEventArgs) => { + if (args.header === 'General Information' || args.header === 'Address Information' || args.field === 'CustomerID') { + args.cancel = true; + } + }); + fix.detectChanges(); + + await exportAndVerify(hGrid, options, actualData.exportMultiColumnHeadersDataWithAllParentsSkipped); + }); + + it('should export headers when exporting empty hierarchical grid with multi column headers', async () => { + const fix = TestBed.createComponent(IgxHierarchicalGridMultiColumnHeadersExportComponent); + fix.detectChanges(); + + const hGrid = fix.componentInstance.hGrid; + fix.componentInstance.data = []; + options = createExportOptions('HierarchicalGridMCHExcelExport'); + + fix.detectChanges(); + + await exportAndVerify(hGrid, options, actualData.exportEmptyMultiColumnHeadersDataWithExportedHeaders); + }); + + it('should export collapsible MCH with visibleWhenCollapsed set on 2 columns with the same field', async () => { + const fix = TestBed.createComponent(IgxHierarchicalGridMCHCollapsibleComponent); + fix.detectChanges(); + + const hGrid = fix.componentInstance.hGrid; + GridFunctions.clickGroupExpandIndicator(fix, hGrid.columnList.get(1)); + fix.detectChanges(); + + const firstRow = hGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(firstRow.expander); + fix.detectChanges(); + + const rowIsland = hGrid.childLayoutList.first; + GridFunctions.clickGroupExpandIndicator(fix, rowIsland.columnList.get(1)); + fix.detectChanges(); + + + options = createExportOptions('HierarchicalGridCollapsibleMCHExcelExport'); + await exportAndVerify(hGrid, options, actualData.exportHierarchicalDataWithCollapsibleMCH); + }) + }); + + describe('', () => { + let fix; + let treeGrid: IgxTreeGridComponent; + beforeEach(waitForAsync(() => { + options = createExportOptions('TreeGridExcelExport', 50); + fix = TestBed.createComponent(IgxTreeGridPrimaryForeignKeyComponent); + fix.detectChanges(); + + treeGrid = fix.componentInstance.treeGrid; + })); + + it('should export tree grid as displayed with all groups expanded.', async () => { + await exportAndVerify(treeGrid, options, actualData.treeGridData); + }); + + it('should export sorted tree grid properly.', async () => { + treeGrid.sort({ fieldName: 'ID', dir: SortingDirection.Desc }); + options.ignoreSorting = true; + fix.detectChanges(); + + await exportAndVerify(treeGrid, options, actualData.treeGridData); + + options.ignoreSorting = false; + await exportAndVerify(treeGrid, options, actualData.treeGridDataSorted); + + treeGrid.clearSort(); + fix.detectChanges(); + await exportAndVerify(treeGrid, options, actualData.treeGridData); + }); + + it('should export filtered tree grid properly.', async () => { + treeGrid.filter('ID', 3, IgxNumberFilteringOperand.instance().condition('greaterThan')); + options.ignoreFiltering = true; + fix.detectChanges(); + + await exportAndVerify(treeGrid, options, actualData.treeGridData); + + options.ignoreFiltering = false; + await exportAndVerify(treeGrid, options, actualData.treeGridDataFiltered); + + treeGrid.clearFilter(); + fix.detectChanges(); + await exportAndVerify(treeGrid, options, actualData.treeGridData); + }); + + it('should export filtered and sorted tree grid properly.', async () => { + treeGrid.filter('ID', 3, IgxNumberFilteringOperand.instance().condition('greaterThan')); + fix.detectChanges(); + treeGrid.sort({ fieldName: 'Name', dir: SortingDirection.Desc }); + fix.detectChanges(); + + await exportAndVerify(treeGrid, options, actualData.treeGridDataFilteredSorted); + }); + + it('should export tree grid with only first level expanded.', async () => { + treeGrid.expansionDepth = 1; + fix.detectChanges(); + await exportAndVerify(treeGrid, options, actualData.treeGridDataExpDepth(1)); + }); + + it('should export tree grid with collapsed first level.', async () => { + treeGrid.collapseAll(); + fix.detectChanges(); + await exportAndVerify(treeGrid, options, actualData.treeGridDataExpDepth(0)); + }); + + it('should export tree grid with ignore filtering properly.', async () => { + treeGrid.filter('Age', 52, IgxNumberFilteringOperand.instance().condition('greaterThan')); + options.ignoreFiltering = true; + fix.detectChanges(); + + await exportAndVerify(treeGrid, options, actualData.treeGridDataIgnoreFiltering); + }); + + it('should export tree grid with ignore sorting properly.', async () => { + treeGrid.sort({ fieldName: 'Age', dir: SortingDirection.Desc }); + options.ignoreSorting = true; + fix.detectChanges(); + + await exportAndVerify(treeGrid, options, actualData.treeGridData); + }); + + it('should throw an exception when nesting level is greater than 8.', async () => { + const nestedData = SampleTestData.employeePrimaryForeignKeyTreeData(); + for (let i = 1; i < 9; i++) { + nestedData[i - 1].ID = i; + nestedData[i - 1].ParentID = i - 1; + } + nestedData.push({ ID: 9, ParentID: 8, Name: 'Test', JobTitle: '', Age: 49 }); + treeGrid.data = nestedData; + fix.detectChanges(); + await wait(16); + + let error = ''; + try { + exporter.export(treeGrid, options); + await wait(); + } catch (ex) { + error = ex.message; + } + expect(error).toMatch('Can create an outline of up to eight levels!'); + + treeGrid.deleteRowById(9); + fix.detectChanges(); + await wait(16); + + error = ''; + try { + exporter.export(treeGrid, options); + await wait(); + } catch (ex) { + error = ex.message; + } + expect(error).toEqual(''); + + treeGrid.addRow({ ID: 9, ParentID: 8, Name: 'Test', JobTitle: '', Age: 49 }); + fix.detectChanges(); + await wait(16); + + error = ''; + try { + exporter.export(treeGrid, options); + await wait(); + } catch (ex) { + error = ex.message; + } + expect(error).toMatch('Can create an outline of up to eight levels!'); + }); + + it('should skip the formatter when columnExporting skipFormatter is true', async () => { + treeGrid.columnList.get(4).formatter = ((val: number) => { + const t = Math.floor(val / 10); + const o = val % 10; + return val + parseFloat(((t + o) / 12).toFixed(2)); + }); + treeGrid.cdr.detectChanges(); + await exportAndVerify(treeGrid, options, actualData.treeGridDataFormatted); + + exporter.columnExporting.subscribe((args: IColumnExportingEventArgs) => { + args.skipFormatter = true; + }); + treeGrid.cdr.detectChanges(); + await exportAndVerify(treeGrid, options, actualData.treeGridData); + + exporter.columnExporting.subscribe((args: IColumnExportingEventArgs) => { + args.skipFormatter = false; + }); + treeGrid.cdr.detectChanges(); + await exportAndVerify(treeGrid, options, actualData.treeGridDataFormatted); + }); + + it('Should honor Advanced filters when exporting', async () => { + const tree = new FilteringExpressionsTree(FilteringLogic.And); + tree.filteringOperands.push({ + fieldName: 'Age', + searchVal: 40, + condition: IgxNumberFilteringOperand.instance().condition('lessThan'), + conditionName: 'lessThan', + ignoreCase: true + }); + tree.filteringOperands.push({ + fieldName: 'Name', + searchVal: 'a', + condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains', + ignoreCase: true + }); + + treeGrid.advancedFilteringExpressionsTree = tree; + fix.detectChanges(); + treeGrid.cdr.detectChanges(); + await wait(); + expect(treeGrid.filteredData.length).toBe(5); + + await exportAndVerify(treeGrid, options, actualData.treeGridWithAdvancedFilters); + }); + + it('should export headers when exporting empty tree grid.', async () => { + fix.componentInstance.data = []; + fix.detectChanges(); + + await exportAndVerify(treeGrid, options, actualData.emptyTreeGridWithExportedHeaders); + }); + }); + + describe('', () => { + let fix; + let grid: IgxGridComponent; + + beforeEach(waitForAsync(() => { + options = createExportOptions('MultiColumnHeaderGridExcelExport'); + fix = TestBed.createComponent(MultiColumnHeadersExportComponent); + fix.detectChanges(); + + grid = fix.componentInstance.grid; + })); + + it('should export grid with multi column headers', async () => { + await exportAndVerify(grid, options, actualData.exportMultiColumnHeadersData, false); + }); + + it('should export grid with multi column headers and moved column', async () => { + grid.columnList.get(0).move(2); + await wait(); + fix.detectChanges(); + + await exportAndVerify(grid, options, actualData.exportMultiColumnHeadersDataWithMovedColumn, false); + }); + + it('should export grid with hidden column', async () => { + grid.columnList.get(0).hidden = true; + fix.detectChanges(); + + await exportAndVerify(grid, options, actualData.exportMultiColumnHeadersDataWithHiddenColumn, false); + }); + + it('should export grid with hidden column and ignoreColumnVisibility set to true', async () => { + grid.columnList.get(0).hidden = true; + options.ignoreColumnsVisibility = true; + fix.detectChanges(); + + await exportAndVerify(grid, options, actualData.exportMultiColumnHeadersDataWithIgnoreColumnVisibility, false); + }); + + it('should export grid with pinned column group', async () => { + grid.columnList.get(1).pinned = true; + fix.detectChanges(); + + await exportAndVerify(grid, options, actualData.exportMultiColumnHeadersDataWithPinnedColumn, false); + }); + + it('should export grid with collapsed and expanded multi column headers', async () => { + GridFunctions.clickGroupExpandIndicator(fix, grid.columnList.get(1)); + GridFunctions.clickGroupExpandIndicator(fix, grid.columnList.get(7)); + fix.detectChanges(); + await exportAndVerify(grid, options, actualData.exportCollapsedAndExpandedMultiColumnHeadersData, false); + }); + + it('should respect ignoreMultiColumnHeaders when set to true', async () => { + options.ignoreMultiColumnHeaders = true; + fix.detectChanges(); + + await exportAndVerify(grid, options, actualData.exportMultiColumnHeadersDataWithoutMultiColumnHeaders); + }); + + it('should export grid with frozen multi column headers', async () => { + options.freezeHeaders = true; + fix.detectChanges(); + await exportAndVerify(grid, options, actualData.exportFrozenMultiColumnHeadersData, false); + }); + + it('should export headers when exporting empty grid with multi column headers', async () => { + fix.componentInstance.data = []; + + fix.detectChanges(); + + await exportAndVerify(grid, options, actualData.exportEmptyGridWithMultiColumnHeadersData, false); + }); + + it('should export grid with three levels of multi column headers which have only two rows', async () => { + fix = TestBed.createComponent(GridWithThreeLevelsOfMultiColumnHeadersAndTwoRowsExportComponent); + fix.detectChanges(); + + grid = fix.componentInstance.grid; + + await exportAndVerify(grid, options, actualData.exportThreeLevelsOfMultiColumnHeadersWithTwoRowsData, false); + }); + + it('should export grouped grid with only multi column headers', async () => { + grid.groupBy({ fieldName: 'ContactTitle', dir: SortingDirection.Asc, ignoreCase: true }); + grid.columnList.get(0).hidden = true; + + fix.detectChanges(); + + await exportAndVerify(grid, options, actualData.exportMultiColumnHeadersWithGroupedData, false); + }); + }); + + + describe('', () => { + let fix; + let grid: any; + + beforeEach(waitForAsync(() => { + options = createExportOptions('GirdSummariesExcelExport', 50); + })); + + it('should export grid with summaries based on summaryCalculationMode', async () => { + fix = TestBed.createComponent(GroupedGridWithSummariesComponent); + fix.detectChanges(); + await wait(300); + + grid = fix.componentInstance.grid; + grid.summaryCalculationMode = 'rootLevelOnly'; + + await exportAndVerify(grid, options, actualData.exportGridWithSummaries); + + (grid as IgxGridComponent).groupBy({ fieldName: 'Shipped', dir: SortingDirection.Asc, ignoreCase: false }); + (grid as IgxGridComponent).groupBy({ fieldName: 'City', dir: SortingDirection.Asc, ignoreCase: false }); + (grid as IgxGridComponent).groupBy({ fieldName: 'ContactTitle', dir: SortingDirection.Asc, ignoreCase: false }); + + fix.detectChanges(); + + await exportAndVerify(grid, options, actualData.exportGroupedGridWithSummariesRootLevelOnly); + + grid.summaryCalculationMode = 'childLevelsOnly'; + fix.detectChanges(); + + await exportAndVerify(grid, options, actualData.exportGroupedGridWithSummariesChildLevelsOnly); + + grid.summaryCalculationMode = 'rootAndChildLevels'; + fix.detectChanges(); + + await exportAndVerify(grid, options, actualData.exportGroupedGridWithSummariesRootAndChildLevels); + }); + + it('should export tree grid with summaries', async () => { + fix = TestBed.createComponent(IgxTreeGridSummariesKeyComponent); + fix.detectChanges(); + await wait(300); + grid = fix.componentInstance.treeGrid; + + grid.toggleRow(grid.getRowByIndex(2).key); + grid.toggleRow(grid.getRowByIndex(0).key); + grid.toggleRow(grid.getRowByIndex(3).key); + fix.detectChanges(); + + await exportAndVerify(grid, options, actualData.exportTreeGridWithSummaries); + }); + + it('should export hierarchical grid with summaries', async () => { + fix = TestBed.createComponent(IgxHierarchicalGridSummariesExportComponent); + fix.detectChanges(); + await wait(300); + grid = fix.componentInstance.hGrid; + + const firstRow = grid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + const secondRow = grid.gridAPI.get_row_by_index(1) as IgxHierarchicalRowComponent; + + UIInteractions.simulateClickAndSelectEvent(firstRow.expander); + fix.detectChanges(); + expect(firstRow.expanded).toBe(true); + + let childGrids = grid.gridAPI.getChildGrids(false); + + const firstChildGrid = childGrids[0]; + const firstChildRow = firstChildGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + + UIInteractions.simulateClickAndSelectEvent(firstChildRow.expander); + fix.detectChanges(); + expect(firstChildRow.expanded).toBe(true); + + UIInteractions.simulateClickAndSelectEvent(secondRow.expander); + fix.detectChanges(); + expect(secondRow.expanded).toBe(true); + + childGrids = grid.gridAPI.getChildGrids(false); + + const thirdChildGrid = childGrids[1]; + const thirdChildRow = thirdChildGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + + UIInteractions.simulateClickAndSelectEvent(thirdChildRow.expander); + fix.detectChanges(); + expect(thirdChildRow.expanded).toBe(true); + + await exportAndVerify(grid, options, actualData.exportHierarchicalGridWithSummaries); + }); + + it('should export grid with custom summaries, only with summary label as string', async () => { + fix = TestBed.createComponent(GridCustomSummaryComponent); + fix.detectChanges(); + await wait(300); + + grid = fix.componentInstance.grid; + + await exportAndVerify(grid, options, actualData.exportGridWithCustomSummaryOnlyWithSummaryLabel); + }); + + it('should export grid with custom summaries, with null and zero (as number)', async () => { + fix = TestBed.createComponent(GridCustomSummaryWithNullAndZeroComponent); + fix.detectChanges(); + await wait(300); + grid = fix.componentInstance.grid; + + await exportAndVerify(grid, options, actualData.exportGridCustomSummaryWithNullAndZero); + }); + + it('should export grid with custom summaries, with undefined, zero and positive number (as number)', async () => { + fix = TestBed.createComponent(GridCustomSummaryWithUndefinedZeroAndValidNumberComponent); + fix.detectChanges(); + await wait(300); + + grid = fix.componentInstance.grid; + + await exportAndVerify(grid, options, actualData.exportGridCustomSummaryWithUndefinedZeroAndValidNumber); + }); + + it('should export grid with custom summaries, with undefined and null', async () => { + fix = TestBed.createComponent(GridCustomSummaryWithUndefinedAndNullComponent); + fix.detectChanges(); + await wait(300); + + grid = fix.componentInstance.grid; + + await exportAndVerify(grid, options, actualData.exportGridCustomSummaryWithUndefinedAndNull); + }); + + it('should export grid with custom summaries, with date', async () => { + fix = TestBed.createComponent(GridCustomSummaryWithDateComponent); + fix.detectChanges(); + await wait(300); + + grid = fix.componentInstance.grid; + + await exportAndVerify(grid, options, actualData.exportGridCustomSummaryWithDate); + }); + }); + + describe('', () => { + let fix; + let grid: IgxPivotGridComponent; + + beforeEach(waitForAsync(() => { + options = createExportOptions('PivotGridExcelExport'); + fix = TestBed.createComponent(IgxPivotGridMultipleRowComponent); + fix.detectChanges(); + })); + + it('should export pivot grid', async () => { + + await wait(300); + + grid = fix.componentInstance.pivotGrid; + + await exportAndVerify(grid, options, actualData.exportPivotGridData, false); + }); + + it('should export pivot grid that has row headers.', async () => { + fix = TestBed.createComponent(IgxPivotGridMultipleRowComponent); + fix.detectChanges(); + + grid = fix.componentInstance.pivotGrid; + grid.pivotUI.showRowHeaders = true; + fix.detectChanges(); + await wait(300); + + await exportAndVerify(grid, options, actualData.exportPivotGridDataWithHeaders, false); + }); + + it('should export pivot grid with hierarchical row dimensions.', async () => { + fix = TestBed.createComponent(IgxPivotGridMultipleRowComponent); + fix.detectChanges(); + + grid = fix.componentInstance.pivotGrid; + fix.componentInstance.data = SALES_DATA; + fix.componentInstance.pivotConfigHierarchy = { + rows: [ + { + memberName: 'All_Srep Code Alts', + enabled: true, + width: '150px', + childLevel: { + memberName: 'SREP_CODE_ALT', + displayName: 'Srep Code Alt', + sortDirection: 1, + enabled: true, + }, + }, + { + memberName: 'All_Srep Codes', + enabled: true, + width: '150px', + childLevel: { + memberName: 'SREP_CODE', + displayName: 'Srep Code', + sortDirection: 1, + enabled: true, + }, + }, + { + memberName: 'All_Customers', + enabled: true, + width: '150px', + childLevel: { + memberName: 'CUST_CODE', + displayName: 'Customer', + sortDirection: 1, + enabled: true, + }, + } + ], + columns: [], + values: [ + { + member: 'JOBS', + aggregate: { + key: 'Count of Jobs', + aggregator: IgxPivotNumericAggregate.count, + label: 'Count of Jobs', + }, + enabled: true, + dataType: 'number', + }, + { + member: 'INV_SALES', + aggregate: { + key: 'Sum of Sales', + aggregator: IgxPivotNumericAggregate.sum, + label: 'Sum of Sales', + }, + enabled: true, + dataType: 'number', + }, + ], + filters: [], + }; + grid.pivotUI.showRowHeaders = true; + fix.detectChanges(); + await wait(300); + + await exportAndVerify(grid, options, actualData.exportPivotGridHierarchicalRowDimensions, false); + }); + + it('should export hierarchical pivot grid', async () => { + fix = TestBed.createComponent(IgxPivotGridTestComplexHierarchyComponent); + fix.detectChanges(); + await wait(300); + + grid = fix.componentInstance.pivotGrid; + + await exportAndVerify(grid, options, actualData.exportPivotGridHierarchicalData, false); + }); + + it('should export pivot grid with horizontal row layout.', async () => { + fix = TestBed.createComponent(IgxPivotGridMultipleRowComponent); + fix.detectChanges(); + + grid = fix.componentInstance.pivotGrid; + grid.pivotUI.showRowHeaders = true; + grid.pivotUI.rowLayout = PivotRowLayoutType.Horizontal; + grid.pivotConfiguration.rows = [{ + memberName: 'ProductCategory', + memberFunction: (data) => data.ProductCategory, + enabled: true, + childLevel: { + memberName: 'Country', + enabled: true, + childLevel: { + memberName: 'Date', + enabled: true + } + } + }], + fix.detectChanges(); + await wait(300); + fix.detectChanges(); + + await exportAndVerify(grid, options, actualData.exportPivotGridDataHorizontal, false); + }); + }); + + const getExportedData = (grid, exportOptions: IgxExcelExporterOptions) => { + const exportData = new Promise((resolve) => { + exporter.exportEnded.pipe(first()).subscribe((value) => { + const wrapper = new ZipWrapper(value.xlsx); + resolve(wrapper); + }); + exporter.export(grid, exportOptions); + }); + return exportData; + }; + + const setColWidthAndExport = (grid, exportOptions: IgxExcelExporterOptions, fix, value) => new Promise((resolve) => { + options.columnWidth = value; + fix.detectChanges(); + getExportedData(grid, exportOptions).then((wrapper) => { + wrapper.verifyDataFilesContent(actualData.simpleGridColumnWidth(value), ' Width :' + value).then(() => resolve()); + }); + }); + + const setRowHeightAndExport = (grid, exportOptions: IgxExcelExporterOptions, fix, value) => new Promise((resolve) => { + options.rowHeight = value; + fix.detectChanges(); + getExportedData(grid, exportOptions).then((wrapper) => { + wrapper.verifyDataFilesContent(actualData.simpleGridRowHeight(value), ' Height :' + value).then(() => resolve()); + }); + }); + + const setWorksheetNameAndExport = (grid, exportOptions: IgxExcelExporterOptions, fix, worksheetName) => new Promise((resolve) => { + options.worksheetName = worksheetName; + fix.detectChanges(); + getExportedData(grid, exportOptions).then((wrapper) => { + wrapper.verifyDataFilesContent(actualData.simpleGridWorksheetName(worksheetName), ' Worksheet Name : ' + worksheetName) + .then(() => resolve()); + }); + }); + + const createExportOptions = (fileName, columnWidth?) => { + const opts = new IgxExcelExporterOptions(fileName); + + // Set column width to a specific value to workaround the issue where + // different platforms measure text differently + opts.columnWidth = columnWidth; + + return opts; + }; + + const exportAndVerify = async (component, exportOptions, expectedData, exportTable = true) => { + const isHGrid = component instanceof IgxHierarchicalGridComponent; + const shouldNotExportTable = isHGrid || !exportTable; + + const wrapper = await getExportedData(component, exportOptions); + await wrapper.verifyStructure(shouldNotExportTable); + await wrapper.verifyDataFilesContent(expectedData, '', shouldNotExportTable); + }; +}); diff --git a/projects/igniteui-angular/grids/core/src/services/excel/excel-exporter-options.ts b/projects/igniteui-angular/grids/core/src/services/excel/excel-exporter-options.ts new file mode 100644 index 00000000000..0dc941ad668 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/services/excel/excel-exporter-options.ts @@ -0,0 +1,123 @@ +import { IgxExporterOptionsBase } from '../exporter-common/exporter-options-base'; + +/** + * Objects of this class are used to configure the Excel exporting process. + */ +export class IgxExcelExporterOptions extends IgxExporterOptionsBase { + /** + * Specifies if column pinning should be ignored. If ignoreColumnsOrder is set to true, + * this option will always be considered as set to true. + * ```typescript + * let ignorePinning = this.exportOptions.ignorePinning; + * this.exportOptions.ignorePinning = true; + * ``` + * + * @memberof IgxExcelExporterOptions + */ + public ignorePinning = false; + + /** + * Specifies whether the exported data should be formatted as Excel table. (True by default) + * ```typescript + * let exportAsTable = this.exportOptions.exportAsTable; + * this.exportOptions.exportAsTable = false; + * ``` + * + * @memberof IgxExcelExporterOptions + */ + public exportAsTable = true; + + private _columnWidth: number; + private _rowHeight: number; + private _worksheetName: string; + + constructor(fileName: string) { + super(fileName, '.xlsx'); + } + + /** + * Gets the width of the columns in the exported excel file. + * ```typescript + * let width = this.exportOptions.columnWidth; + * ``` + * + * @memberof IgxExcelExporterOptions + */ + public get columnWidth(): number { + return this._columnWidth; + } + + /** + * Sets the width of the columns in the exported excel file. If left unspecified, + * the width of the column or the default width of the excel columns will be used. + * ```typescript + * this.exportOptions.columnWidth = 55; + * ``` + * + * @memberof IgxExcelExporterOptions + */ + public set columnWidth(value: number) { + if (value < 0) { + throw Error('Invalid value for column width!'); + } + + this._columnWidth = value; + } + + /** + * Gets the height of the rows in the exported excel file. + * ```typescript + * let height = this.exportOptions.rowHeight; + * ``` + * + * @memberof IgxExcelExporterOptions + */ + public get rowHeight(): number { + return this._rowHeight; + } + + /** + * Sets the height of the rows in the exported excel file. If left unspecified or 0, + * the default height of the excel rows will be used. + * ```typescript + * this.exportOptions.rowHeight = 25; + * ``` + * + * @memberof IgxExcelExporterOptions + */ + public set rowHeight(value: number) { + if (value < 0) { + throw Error('Invalid value for row height!'); + } + + this._rowHeight = value; + } + + /** + * Gets the name of the worksheet in the exported excel file. + * ```typescript + * let worksheetName = this.exportOptions.worksheetName; + * ``` + * + * @memberof IgxExcelExporterOptions + */ + public get worksheetName(): string { + if (this._worksheetName === undefined || this._worksheetName === null) { + return 'Sheet1'; + } + + return this._worksheetName; + } + + /** + * Sets the name of the worksheet in the exported excel file. + * ```typescript + * this.exportOptions.worksheetName = "Worksheet"; + * ``` + * + * @memberof IgxExcelExporterOptions + */ + public set worksheetName(value: string) { + this._worksheetName = value; + } +} diff --git a/projects/igniteui-angular/grids/core/src/services/excel/excel-exporter.spec.ts b/projects/igniteui-angular/grids/core/src/services/excel/excel-exporter.spec.ts new file mode 100644 index 00000000000..21cfad327dc --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/services/excel/excel-exporter.spec.ts @@ -0,0 +1,159 @@ +import { ExportUtilities } from '../exporter-common/export-utilities'; +import { IgxExcelExporterService } from './excel-exporter'; +import { IgxExcelExporterOptions } from './excel-exporter-options'; +import { IColumnExportingEventArgs } from '../exporter-common/base-export-service'; +import { ZipWrapper } from './zip-verification-wrapper.spec'; +import { FileContentData } from './test-data.service.spec'; +import { SampleTestData } from '../../../../../test-utils/sample-test-data.spec'; +import { first } from 'rxjs/operators'; + +describe('Excel Exporter', () => { + let exporter: IgxExcelExporterService; + let options: IgxExcelExporterOptions; + let actualData: FileContentData; + + beforeEach(() => { + exporter = new IgxExcelExporterService(); + actualData = new FileContentData(); + options = new IgxExcelExporterOptions('ExcelExport'); + + // Spy the saveBlobToFile method so the files are not really created + spyOn(ExportUtilities, 'saveBlobToFile'); + }); + + /* ExportData() tests */ + it('should not fail when data is empty.', async () => { + options.alwaysExportHeaders = false; + + const wrapper = await getExportedData([], options); + + wrapper.verifyStructure(); + await wrapper.verifyTemplateFilesContent(); + }); + + it('should export empty objects successfully.', async () => { + options.alwaysExportHeaders = false; + const wrapper = await getExportedData(SampleTestData.emptyObjectData(), options); + + wrapper.verifyStructure(); + await wrapper.verifyTemplateFilesContent(); + }); + + it('should export string data without headers successfully.', async () => { + options.columnWidth = 50; + const wrapper = await getExportedData(SampleTestData.stringArray(), options); + + wrapper.verifyStructure(); + await wrapper.verifyTemplateFilesContent(); + await wrapper.verifyDataFilesContent(actualData.noHeadersStringDataContent); + }); + + it('should export date time data without headers successfully.', async () => { + options.columnWidth = 50; + const wrapper = await getExportedData(SampleTestData.excelDateArray(), options); + + wrapper.verifyStructure(); + await wrapper.verifyTemplateFilesContent('', true); + await wrapper.verifyDataFilesContent(actualData.noHeadersDateTimeContent); + }); + + it('should export number data without headers successfully.', async () => { + options.columnWidth = 50; + const wrapper = await getExportedData(SampleTestData.numbersArray(), options); + + wrapper.verifyStructure(); + // await wrapper.verifyTemplateFilesContent(); + await wrapper.verifyDataFilesContent(actualData.noHeadersNumberDataContent); + }); + + it('should export object data without headers successfully.', async () => { + const wrapper = await getExportedData(SampleTestData.noHeadersObjectArray(), options); + + wrapper.verifyStructure(); + await wrapper.verifyTemplateFilesContent(); + await wrapper.verifyDataFilesContent(actualData.noHeadersObjectDataContent); + }); + + it('should export regular data successfully.', async () => { + options.columnWidth = 50; + const wrapper = await getExportedData(SampleTestData.contactsData(), options); + + wrapper.verifyStructure(); + await wrapper.verifyTemplateFilesContent(); + await wrapper.verifyDataFilesContent(actualData.contactsDataContent); + }); + + it('should export data with missing values successfully.', async () => { + options.columnWidth = 50; + const wrapper = await getExportedData(SampleTestData.contactsDataPartial(), options); + + wrapper.verifyStructure(); + await wrapper.verifyTemplateFilesContent(); + await wrapper.verifyDataFilesContent(actualData.contactsPartialDataContent); + }); + + it('should export data with special characters successully.', async () => { + options.columnWidth = 50; + const wrapper = await getExportedData(SampleTestData.contactsFunkyData(), options); + + wrapper.verifyStructure(); + await wrapper.verifyTemplateFilesContent(); + await wrapper.verifyDataFilesContent(actualData.contactsFunkyDataContent); + }); + + it('should throw an exception when setting negative width and height.', () => { + try { + options.columnWidth = -1; + } catch (ex) { + expect((ex as Error).message).toBe('Invalid value for column width!'); + } + + try { + options.rowHeight = -1; + } catch (ex) { + expect((ex as Error).message).toBe('Invalid value for row height!'); + } + }); + + it('should export data successfully when \'columnExporting\' is canceled.', async () => { + options.columnWidth = 50; + + exporter.columnExporting.subscribe((args: IColumnExportingEventArgs) => { + if (args.field === 'phone') { + args.cancel = true; + } + }); + + const wrapper = await getExportedData(SampleTestData.contactsData(), options); + + wrapper.verifyStructure(); + await wrapper.verifyTemplateFilesContent(); + await wrapper.verifyDataFilesContent(actualData.contactsDataSkippedColumnContent); + }); + + it('should not fail when data contains null characters (#14944).', async () => { + options.columnWidth = 50; + const wrapper = await getExportedData([ + 'Terrance\u0000Orta', + 'Richard Mahoney\x00LongerName', + 'Donna\0Price', + 'Lisa Landers', + 'Dorothy H. Spencer' + ], options); + + wrapper.verifyStructure(); + await wrapper.verifyTemplateFilesContent(); + await wrapper.verifyDataFilesContent(actualData.noHeadersStringDataWithNullChars); + }); + + const getExportedData = (data: any[], exportOptions: IgxExcelExporterOptions) => { + const result = new Promise((resolve) => { + exporter.exportEnded.pipe(first()).subscribe((value) => { + const wrapper = new ZipWrapper(value.xlsx); + resolve(wrapper); + }); + exporter.exportData(data, exportOptions); + }); + return result; + }; +}); diff --git a/projects/igniteui-angular/grids/core/src/services/excel/excel-exporter.ts b/projects/igniteui-angular/grids/core/src/services/excel/excel-exporter.ts new file mode 100644 index 00000000000..00824131b91 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/services/excel/excel-exporter.ts @@ -0,0 +1,155 @@ +import { zip } from 'fflate'; + +import { EventEmitter, Injectable } from '@angular/core'; +import { ExcelElementsFactory } from './excel-elements-factory'; +import { ExcelFolderTypes } from './excel-enums'; +import { IgxExcelExporterOptions } from './excel-exporter-options'; +import { IExcelFolder } from './excel-interfaces'; +import { ExportRecordType, IExportRecord, IgxBaseExporter, DEFAULT_OWNER, ExportHeaderType, GRID_LEVEL_COL } from '../exporter-common/base-export-service'; +import { ExportUtilities } from '../exporter-common/export-utilities'; +import { WorksheetData } from './worksheet-data'; +import { WorksheetFile } from './excel-files'; +import { IBaseEventArgs } from 'igniteui-angular/core'; + +export interface IExcelExportEndedEventArgs extends IBaseEventArgs { + xlsx?: Object +} + +const EXCEL_MAX_ROWS = 1048576; +const EXCEL_MAX_COLS = 16384; + +/** + * **Ignite UI for Angular Excel Exporter Service** - + * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/exporter_excel.html) + * + * The Ignite UI for Angular Excel Exporter service can export data in Microsoft® Excel® format from both raw data + * (array) or from an `IgxGrid`. + * + * Example: + * ```typescript + * public localData = [ + * { Name: "Eric Ridley", Age: "26" }, + * { Name: "Alanis Brook", Age: "22" }, + * { Name: "Jonathan Morris", Age: "23" } + * ]; + * + * constructor(private excelExportService: IgxExcelExporterService) { + * } + * + * this.excelExportService.exportData(this.localData, new IgxExcelExporterOptions("FileName")); + * ``` + */ +@Injectable({ + providedIn: 'root', +}) +export class IgxExcelExporterService extends IgxBaseExporter { + + /** + * This event is emitted when the export process finishes. + * ```typescript + * this.exporterService.exportEnded.subscribe((args: IExcelExportEndedEventArgs) => { + * // put event handler code here + * }); + * ``` + * + * @memberof IgxExcelExporterService + */ + public override exportEnded = new EventEmitter(); + + private static async populateZipFileConfig(fileStructure: Object, folder: IExcelFolder, worksheetData: WorksheetData) { + for (const childFolder of folder.childFolders(worksheetData)) { + const folderInstance = ExcelElementsFactory.getExcelFolder(childFolder); + const childStructure = fileStructure[folderInstance.folderName] = {}; + await IgxExcelExporterService.populateZipFileConfig(childStructure, folderInstance, worksheetData); + } + + for (const childFile of folder.childFiles(worksheetData)) { + const fileInstance = ExcelElementsFactory.getExcelFile(childFile); + if (fileInstance instanceof WorksheetFile) { + await (fileInstance as WorksheetFile).writeElementAsync(fileStructure, worksheetData); + } else { + fileInstance.writeElement(fileStructure, worksheetData); + } + } + } + + protected exportDataImplementation(data: IExportRecord[], options: IgxExcelExporterOptions, done: () => void): void { + const firstDataElement = data[0]; + const isHierarchicalGrid = firstDataElement?.type === ExportRecordType.HierarchicalGridRecord; + const isPivotGrid = firstDataElement?.type === ExportRecordType.PivotGridRecord; + + let rootKeys; + let columnCount; + let columnWidths; + let indexOfLastPinnedColumn; + let defaultOwner; + + const columnsExceedLimit = typeof firstDataElement !== 'undefined' ? + isHierarchicalGrid ? + data.some(d => Object.keys(d.data).length > EXCEL_MAX_COLS) : + Object.keys(firstDataElement.data).length > EXCEL_MAX_COLS : + false; + + if (data.length > EXCEL_MAX_ROWS || columnsExceedLimit) { + throw Error('The Excel file can contain up to 1,048,576 rows and 16,384 columns.'); + } + + if (typeof firstDataElement !== 'undefined') { + let maxLevel = 0; + + data.forEach((r) => { + maxLevel = Math.max(maxLevel, r.level); + }); + + if (maxLevel > 7) { + throw Error('Can create an outline of up to eight levels!'); + } + + if (isHierarchicalGrid) { + columnCount = data + .map(a => this._ownersMap.get(a.owner).columns.filter(c => !c.skip).length + a.level) + .sort((a, b) => b - a)[0]; + + rootKeys = this._ownersMap.get(firstDataElement.owner).columns.filter(c => !c.skip).map(c => c.field); + defaultOwner = this._ownersMap.get(firstDataElement.owner); + } else { + defaultOwner = this._ownersMap.get(DEFAULT_OWNER); + const columns = defaultOwner.columns.filter(col => col.field !== GRID_LEVEL_COL && !col.skip && col.headerType === ExportHeaderType.ColumnHeader); + + columnWidths = defaultOwner.columnWidths; + indexOfLastPinnedColumn = defaultOwner.indexOfLastPinnedColumn; + columnCount = isPivotGrid ? columns.length + this.pivotGridFilterFieldsCount : columns.length; + rootKeys = columns.map(c => c.field); + } + } else { + const ownersKeys = Array.from(this._ownersMap.keys()); + + defaultOwner = this._ownersMap.get(ownersKeys[0]); + columnWidths = defaultOwner.columnWidths; + columnCount = defaultOwner.columns.filter(col => col.field !== GRID_LEVEL_COL && !col.skip && col.headerType === ExportHeaderType.ColumnHeader).length; + } + + const worksheetData = + new WorksheetData(data, options, this._sort, columnCount, rootKeys, indexOfLastPinnedColumn, + columnWidths, defaultOwner, this._ownersMap); + + const rootFolder = ExcelElementsFactory.getExcelFolder(ExcelFolderTypes.RootExcelFolder); + const fileData = {}; + IgxExcelExporterService.populateZipFileConfig(fileData, rootFolder, worksheetData) + .then(() => { + zip(fileData, (_, result) => { + this.saveFile(result, options.fileName); + this.exportEnded.emit({ xlsx: fileData }); + done(); + }); + }); + } + + private saveFile(data: Uint8Array, fileName: string): void { + const blob = new Blob([data as BlobPart], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }); + + ExportUtilities.saveBlobToFile(blob, fileName); + } +} diff --git a/projects/igniteui-angular/grids/core/src/services/excel/excel-files.ts b/projects/igniteui-angular/grids/core/src/services/excel/excel-files.ts new file mode 100644 index 00000000000..0d5d81882c7 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/services/excel/excel-files.ts @@ -0,0 +1,813 @@ +import { IExcelFile } from './excel-interfaces'; +import { ExcelStrings } from './excel-strings'; +import { WorksheetData } from './worksheet-data'; + +import { strToU8 } from 'fflate'; +import { ExportHeaderType, ExportRecordType, IExportRecord, IColumnList, IColumnInfo, GRID_ROOT_SUMMARY, GRID_PARENT, GRID_LEVEL_COL } from '../exporter-common/base-export-service'; +import { yieldingLoop } from 'igniteui-angular/core'; + +/** + * @hidden + */ +export class RootRelsFile implements IExcelFile { + public writeElement(folder: Object) { + folder['.rels'] = strToU8(ExcelStrings.getRels()); + } +} + +/** + * @hidden + */ +export class AppFile implements IExcelFile { + public writeElement(folder: Object, worksheetData: WorksheetData) { + folder['app.xml'] = strToU8(ExcelStrings.getApp(worksheetData.options.worksheetName)); + } +} + +/** + * @hidden + */ +export class CoreFile implements IExcelFile { + public writeElement(folder: Object) { + folder['core.xml'] = strToU8(ExcelStrings.getCore()); + } +} + +/** + * @hidden + */ +export class WorkbookRelsFile implements IExcelFile { + public writeElement(folder: Object, worksheetData: WorksheetData) { + const hasSharedStrings = !worksheetData.isEmpty || worksheetData.options.alwaysExportHeaders; + folder['workbook.xml.rels'] = strToU8(ExcelStrings.getWorkbookRels(hasSharedStrings)); + } +} + +/** + * @hidden + */ +export class ThemeFile implements IExcelFile { + public writeElement(folder: Object) { + folder['theme1.xml'] = strToU8(ExcelStrings.getTheme()); + } +} + +interface Dimensions { + startCoordinate: string + endCoordinate: string +} + +interface CurrencyInfo { + styleXf: number + symbol: string +} + +/** + * @hidden + */ +export class WorksheetFile implements IExcelFile { + private static MIN_WIDTH = 8.43; + private maxOutlineLevel = 0; + private sheetData = ''; + private dimension = ''; + private freezePane = ''; + private rowHeight = ''; + + private mergeCellStr = ''; + private mergeCellsCounter = 0; + private rowIndex = 0; + private pivotGridRowHeadersMap = new Map(); + + private dimensionMap: Map = new Map(); + private hierarchicalDimensionMap: Map> = new Map>(); + private currentSummaryOwner = ''; + private currentHierarchicalOwner = ''; + private firstColumn = Number.MAX_VALUE; + private firstDataRow = Number.MAX_VALUE; + private isValidGrid: boolean; + private lastValidRow: string; + + private currencyStyleMap = new Map([ + ['USD', {styleXf: 5, symbol: '$'}], + ['GBP', {styleXf: 6, symbol: '£'}], + ['CNY', {styleXf: 7, symbol: '¥'}], + ['EUR', {styleXf: 8, symbol: '€'}], + ['JPY', {styleXf: 9, symbol: '¥'}], + ]); + + public writeElement() {} + + public async writeElementAsync(folder: Object, worksheetData: WorksheetData) { + return new Promise(resolve => { + this.prepareDataAsync(worksheetData, (cols, rows) => { + const hasTable = (!worksheetData.isEmpty || worksheetData.options.alwaysExportHeaders) + && worksheetData.options.exportAsTable; + + folder['sheet1.xml'] = strToU8(ExcelStrings.getSheetXML( + this.dimension, this.freezePane, cols, rows, hasTable, this.maxOutlineLevel, worksheetData.isHierarchical)); + resolve(); + }); + }); + } + + private prepareDataAsync(worksheetData: WorksheetData, done: (cols: string, sheetData: string) => void) { + this.sheetData = ''; + let cols = ''; + const dictionary = worksheetData.dataDictionary; + this.rowIndex = 0; + + if (worksheetData.isEmpty && (!worksheetData.options.alwaysExportHeaders || worksheetData.owner.columns.length === 0)) { + this.sheetData += ''; + this.dimension = 'A1'; + done('', this.sheetData); + } else { + const owner = worksheetData.owner; + const isHierarchicalGrid = worksheetData.isHierarchical; + const hasMultiColumnHeader = worksheetData.hasMultiColumnHeader; + const hasMultiRowHeader = worksheetData.hasMultiRowHeader; + + const hasUserSetIndex = owner.columns.some(col => col.exportIndex !== undefined); + + const height = worksheetData.options.rowHeight; + + this.isValidGrid = worksheetData.isHierarchical || worksheetData.isTreeGrid || worksheetData.isGroupedGrid; + this.rowHeight = height ? ` ht="${height}" customHeight="1"` : ''; + this.sheetData += ``; + + let headersForLevel: IColumnInfo[] = []; + + for(let i = 0; i <= owner.maxRowLevel; i++) { + headersForLevel = owner.columns.filter(c => c.level === i && c.rowSpan > 0 && !c.skip) + + this.printHeaders(worksheetData, headersForLevel, i, true); + + this.rowIndex++; + } + + this.rowIndex = 0; + + for (let i = 0; i <= owner.maxLevel; i++) { + this.rowIndex++; + const pivotGridColumns = this.pivotGridRowHeadersMap.get(this.rowIndex) ?? ""; + this.sheetData += `${pivotGridColumns}`; + + const allowedColumns = owner.columns.filter(c => c.headerType !== ExportHeaderType.RowHeader && + c.headerType !== ExportHeaderType.MultiRowHeader && + c.headerType !== ExportHeaderType.PivotRowHeader && + c.headerType !== ExportHeaderType.PivotMergedHeader); + + headersForLevel = hasMultiColumnHeader ? + allowedColumns + .filter(c => (c.level < i && + c.headerType !== ExportHeaderType.MultiColumnHeader || c.level === i) && c.columnSpan > 0 && !c.skip) + .sort((a, b) => a.startIndex - b.startIndex) + .sort((a, b) => a.pinnedIndex - b.pinnedIndex) : + hasUserSetIndex ? + allowedColumns.filter(c => !c.skip) : + allowedColumns.filter(c => !c.skip) + .sort((a, b) => a.startIndex - b.startIndex) + .sort((a, b) => a.pinnedIndex - b.pinnedIndex); + + this.printHeaders(worksheetData, headersForLevel, i, false); + + this.sheetData += ``; + } + + const multiColumnHeaderLevel = worksheetData.options.ignoreMultiColumnHeaders ? 0 : owner.maxLevel; + const freezeHeaders = worksheetData.options.freezeHeaders ? 2 + multiColumnHeaderLevel : 1; + + if (!isHierarchicalGrid) { + const col = worksheetData.hasSummaries ? worksheetData.columnCount + 1 : worksheetData.columnCount - 1 + this.dimension = 'A1:' + ExcelStrings.getExcelColumn(col) + (worksheetData.rowCount); + + cols += ''; + + if (!hasMultiColumnHeader) { + for (let j = 0; j < worksheetData.columnCount; j++) { + const width = dictionary.columnWidths[j]; + // Use the width provided in the options if it exists + let widthInTwips = worksheetData.options.columnWidth !== undefined ? + worksheetData.options.columnWidth : + Math.max(((width / 96) * 14.4), WorksheetFile.MIN_WIDTH); + if (!(widthInTwips > 0)) { + widthInTwips = WorksheetFile.MIN_WIDTH; + } + + cols += `
    `; + } + } else { + cols += ``; + } + + const indexOfLastPinnedColumn = worksheetData.indexOfLastPinnedColumn; + const frozenColumnCount = indexOfLastPinnedColumn + 1; + let firstCell = ExcelStrings.getExcelColumn(frozenColumnCount) + freezeHeaders; + if (indexOfLastPinnedColumn !== undefined && indexOfLastPinnedColumn !== -1 && + !worksheetData.options.ignorePinning && + !worksheetData.options.ignoreColumnsOrder) { + this.freezePane = + ``; + } else if (worksheetData.options.freezeHeaders) { + firstCell = ExcelStrings.getExcelColumn(0) + freezeHeaders; + this.freezePane = + ``; + } + } else { + const columnWidth = worksheetData.options.columnWidth ? worksheetData.options.columnWidth : 20; + cols += ``; + + if (worksheetData.options.freezeHeaders) { + const firstCell = ExcelStrings.getExcelColumn(0) + freezeHeaders; + this.freezePane = + ``; + } + } + + if (worksheetData.hasSummaries) { + cols += ``; + } + + cols += ''; + + this.processDataRecordsAsync(worksheetData, (rows) => { + this.sheetData += rows; + this.sheetData += ''; + + if ((hasMultiColumnHeader || hasMultiRowHeader) && this.mergeCellsCounter > 0) { + this.sheetData += `${this.mergeCellStr}`; + } + + done(cols, this.sheetData); + }); + } + } + + private processDataRecordsAsync(worksheetData: WorksheetData, done: (rows: string) => void) { + const rowDataArr = []; + const height = worksheetData.options.rowHeight; + this.rowHeight = height ? ' ht="' + height + '" customHeight="1"' : ''; + + const isHierarchicalGrid = worksheetData.isHierarchical; + const hasUserSetIndex = worksheetData.owner.columns.some(c => c.exportIndex !== undefined); + + let recordHeaders = []; + + yieldingLoop(worksheetData.rowCount - worksheetData.multiColumnHeaderRows - 1, 1000, + (i) => { + if (!worksheetData.isEmpty){ + if (!isHierarchicalGrid) { + if (hasUserSetIndex) { + recordHeaders = worksheetData.rootKeys; + } else { + recordHeaders = worksheetData.owner.columns + .filter(c => c.headerType === ExportHeaderType.ColumnHeader && !c.skip) + .sort((a, b) => a.startIndex-b.startIndex) + .sort((a, b) => a.pinnedIndex-b.pinnedIndex) + .map(c => c.field); + } + } else { + const record = worksheetData.data[i]; + + if (record.type === ExportRecordType.HeaderRecord) { + const recordOwner = worksheetData.owners.get(record.owner); + const hasMultiColumnHeaders = recordOwner.columns.some(c => !c.skip && c.headerType === ExportHeaderType.MultiColumnHeader); + + if (hasMultiColumnHeaders) { + this.hGridPrintMultiColHeaders(worksheetData, rowDataArr, record, recordOwner); + } + } + + recordHeaders = Object.keys(worksheetData.data[i].data); + } + + rowDataArr.push(this.processRow(worksheetData, i, recordHeaders, isHierarchicalGrid)); + } + }, + () => { + done(rowDataArr.join('')); + }); + } + + private hGridPrintMultiColHeaders(worksheetData: WorksheetData, rowDataArr: any[], record: IExportRecord, + owner: IColumnList) { + for (let j = 0; j < owner.maxLevel; j++) { + const recordLevel = record.level; + const outlineLevel = recordLevel > 0 ? ` outlineLevel="${recordLevel}"` : ''; + this.maxOutlineLevel = this.maxOutlineLevel < recordLevel ? recordLevel : this.maxOutlineLevel; + const sHidden = record.hidden ? ` hidden="1"` : ''; + + this.rowIndex++; + let row = ``; + + const headersForLevel = owner.columns + .filter(c => (c.level < j && + c.headerType !== ExportHeaderType.MultiColumnHeader || c.level === j) && c.columnSpan > 0 && !c.skip) + .sort((a, b) => a.startIndex - b.startIndex) + .sort((a, b) => a.pinnedIndex - b.pinnedIndex); + + let startValue = 0 + record.level; + + for (const currentCol of headersForLevel) { + if (currentCol.level === j) { + let columnCoordinate; + columnCoordinate = + ExcelStrings.getExcelColumn(startValue) + this.rowIndex; + + const columnValue = worksheetData.dataDictionary.saveValue(currentCol.header, true); + row += `${columnValue}`; + + if (j !== owner.maxLevel) { + this.mergeCellsCounter++; + this.mergeCellStr += ` `; + } + } + + this.mergeCellStr += `${columnCoordinate}" />`; + } + } + + startValue += currentCol.columnSpan; + } + row += ``; + rowDataArr.push(row); + } + } + + private processRow(worksheetData: WorksheetData, i: number, headersForLevel: any[], isHierarchicalGrid: boolean) { + const record = worksheetData.data[i]; + + const rowData = new Array(worksheetData.columnCount + 2); + + const rowLevel = record.level; + const outlineLevel = rowLevel > 0 ? ` outlineLevel="${rowLevel}"` : ''; + this.maxOutlineLevel = this.maxOutlineLevel < rowLevel ? rowLevel : this.maxOutlineLevel; + + const sHidden = record.hidden ? ` hidden="1"` : ''; + + this.rowIndex++; + const pivotGridColumns = this.pivotGridRowHeadersMap.get(this.rowIndex) ?? ""; + + rowData[0] = `${pivotGridColumns}`; + const keys = worksheetData.isSpecialData ? [record.data] : headersForLevel; + const isDataRecord = record.type === ExportRecordType.HierarchicalGridRecord + || record.type === ExportRecordType.DataRecord + || record.type === ExportRecordType.GroupedRecord + || record.type === ExportRecordType.TreeGridRecord; + + const isValidRecordType = isDataRecord || record.type === ExportRecordType.SummaryRecord; + + if (isValidRecordType && worksheetData.hasSummaries) { + this.resolveSummaryDimensions(record, isDataRecord, worksheetData.isGroupedGrid) + } + + for (let j = 0; j < keys.length; j++) { + const col = j + (isHierarchicalGrid ? rowLevel : worksheetData.isPivotGrid ? worksheetData.owner.maxRowLevel : 0); + + const cellData = this.getCellData(worksheetData, i, col, keys[j]); + + rowData[j + 1] = cellData; + } + + rowData[keys.length + 1] = ''; + + return rowData.join(''); + } + + private getCellData(worksheetData: WorksheetData, row: number, column: number, key: string): string { + const dictionary = worksheetData.dataDictionary; + let columnName = ExcelStrings.getExcelColumn(column) + (this.rowIndex); + const fullRow = worksheetData.data[row]; + const isHeaderRecord = fullRow.type === ExportRecordType.HeaderRecord; + const isSummaryRecord = fullRow.type === ExportRecordType.SummaryRecord; + const isValidRecordType = fullRow.type === ExportRecordType.GroupedRecord + || fullRow.type === ExportRecordType.DataRecord + || fullRow.type === ExportRecordType.HierarchicalGridRecord + || fullRow.type === ExportRecordType.TreeGridRecord; + + this.firstDataRow = this.firstDataRow > this.rowIndex ? this.rowIndex : this.firstDataRow; + + const cellValue = worksheetData.isSpecialData ? + fullRow.data : + fullRow.data[key]; + + if (cellValue === GRID_LEVEL_COL || key === GRID_LEVEL_COL) { + columnName = ExcelStrings.getExcelColumn(worksheetData.columnCount + 1) + (this.rowIndex); + } + + if (worksheetData.hasSummaries && (isValidRecordType || (worksheetData.isGroupedGrid && isSummaryRecord))) { + this.setSummaryCoordinates(columnName, key, fullRow.hierarchicalOwner, worksheetData.isGroupedGrid && isSummaryRecord) + } + + if (fullRow.summaryKey && fullRow.summaryKey === GRID_ROOT_SUMMARY && key !== GRID_LEVEL_COL && worksheetData.isGroupedGrid) { + this.setRootSummaryStartCoordinate(column, key); + + if (this.firstColumn > column) { + this.setRootSummaryStartCoordinate(worksheetData.columnCount + 1, GRID_LEVEL_COL); + this.firstColumn = column; + } + } + + const targetColArr = Array.from(worksheetData.owners.values()).map(arr => arr.columns).find(product => product.some(item => item.field === key)); + const targetCol = targetColArr ? targetColArr.find(col => col.field === key) : undefined; + + if ((cellValue === undefined || cellValue === null) && !worksheetData.hasSummaries) { + return ``; + } else if ((worksheetData.hasSummaries && (isValidRecordType || isHeaderRecord)) || !worksheetData.hasSummaries) { + const savedValue = dictionary.saveValue(cellValue, isHeaderRecord); + const isSavedAsString = savedValue !== -1; + + const isSavedAsDate = !isSavedAsString && cellValue instanceof Date; + + let value = isSavedAsString ? savedValue : cellValue; + + if (isSavedAsDate) { + const timeZoneOffset = value.getTimezoneOffset() * 60000; + const isoString = (new Date(value - timeZoneOffset)).toISOString(); + value = isoString.substring(0, isoString.indexOf('.')); + } + + const type = isSavedAsString ? ` t="s"` : isSavedAsDate ? ` t="d"` : ''; + + const isTime = targetCol?.dataType === 'time'; + const isDateTime = targetCol?.dataType === 'dateTime'; + const isPercentage = targetCol?.dataType === 'percent'; + const isColumnCurrencyType = targetCol?.dataType === 'currency'; + + const format = isPercentage ? ` s="12"` : isDateTime ? ` s="11"` : isTime ? ` s="10"` : isHeaderRecord ? ` s="3"` : isSavedAsString ? '' : isSavedAsDate ? ` s="2"` : isColumnCurrencyType ? ` s="${this.currencyStyleMap.get(targetCol.currencyCode)?.styleXf || 0}"` : ` s="1"`; + + return `${value}`; + } else { + let summaryFunc = `"${cellValue ?? ""}"`; + + if (isSummaryRecord && cellValue) { + const dimensionMapKey = this.isValidGrid ? fullRow.hierarchicalOwner ?? GRID_PARENT : null; + const level = worksheetData.isGroupedGrid ? worksheetData.maxLevel : fullRow.level; + + summaryFunc = this.getSummaryFunction(cellValue.label, key, dimensionMapKey, level, targetCol); + + if (!summaryFunc) { + let summaryValue; + const label = cellValue.label?.toString(); + const value = cellValue.value?.toString(); + + if (label && value) { + summaryValue = `${cellValue.label}: ${cellValue.value}`; + } else if (label) { + summaryValue = cellValue.label; + } else if (value) { + summaryValue = cellValue.value; + } + + const savedValue = dictionary.saveValue(summaryValue, false); + const isSavedAsString = savedValue !== -1; + const isSavedAsDate = !isSavedAsString && summaryValue instanceof Date; + + if (isSavedAsDate) { + const timeZoneOffset = summaryValue.getTimezoneOffset() * 60000; + const isoString = (new Date(summaryValue - timeZoneOffset)).toISOString(); + summaryValue = isoString.substring(0, isoString.indexOf('.')); + } + + const resolvedValue = isSavedAsString ? savedValue : summaryValue; + const type = isSavedAsString ? `t="s"` : isSavedAsDate ? `t="d"` : ''; + const style = isSavedAsDate ? `s="2"` : `s="1"`; + + return `${resolvedValue}`; + } + + return `${summaryFunc}`; + } + + return `${summaryFunc}`; + } + } + + private resolveSummaryDimensions(record: IExportRecord, isDataRecord: boolean, isGroupedGrid: boolean) { + if (this.isValidGrid && + this.currentHierarchicalOwner !== '' && + this.currentHierarchicalOwner !== record.owner && + !this.hierarchicalDimensionMap.get(this.currentHierarchicalOwner)) { + this.hierarchicalDimensionMap.set(this.currentHierarchicalOwner, new Map(this.dimensionMap)) + } + + if (isDataRecord) { + if (this.currentSummaryOwner !== record.summaryKey || this.currentHierarchicalOwner !== record.hierarchicalOwner) { + this.dimensionMap.clear(); + } + + this.currentSummaryOwner = record.summaryKey; + + // For grouped grid we need to reset the parent map + // so we can change the startCoordinate for each record + if (isGroupedGrid && this.currentHierarchicalOwner !== '' && record.hierarchicalOwner === GRID_PARENT) { + this.hierarchicalDimensionMap.delete(GRID_PARENT) + } + + this.currentHierarchicalOwner = record.hierarchicalOwner; + } + } + + private setSummaryCoordinates(columnName: string, key: string, hierarchicalOwner: string, useLastValidEndCoordinate: boolean) { + const targetDimensionMap = this.hierarchicalDimensionMap.get(hierarchicalOwner) ?? this.dimensionMap; + + if (!targetDimensionMap.get(key)) { + const initialDimensions: Dimensions = { + startCoordinate: columnName, + endCoordinate: columnName + }; + + targetDimensionMap.set(key, initialDimensions) + } else { + if (useLastValidEndCoordinate) { + this.setEndCoordinates(targetDimensionMap, true); + } else { + targetDimensionMap.get(key).endCoordinate = columnName; + this.lastValidRow = targetDimensionMap.get(key).endCoordinate.match(/[a-z]+|[^a-z]+/gi)[1] + } + } + + if (this.isValidGrid && !useLastValidEndCoordinate && hierarchicalOwner !== GRID_PARENT) { + const parentMap = this.hierarchicalDimensionMap.get(GRID_PARENT); + this.setEndCoordinates(parentMap); + } + } + + private setEndCoordinates(map: Map, useLastValidEndCoordinate = false) { + for (const a of map.values()) { + const colName = a.endCoordinate.match(/[a-z]+|[^a-z]+/gi)[0]; + a.endCoordinate = `${colName}${useLastValidEndCoordinate ? this.lastValidRow : this.rowIndex}`; + } + } + + private getSummaryFunction(type: string, key: string, dimensionMapKey: any, recordLevel: number, col: IColumnInfo): string { + const dimensionMap = dimensionMapKey ? this.hierarchicalDimensionMap.get(dimensionMapKey) : this.dimensionMap; + const dimensions = dimensionMap.get(key); + const levelDimensions = dimensionMap.get(GRID_LEVEL_COL); + + let func = ''; + let funcType = ''; + let result = ''; + const currencyInfo = this.currencyStyleMap.get(col.currencyCode); + + switch(type?.toString().toLowerCase()) { + case "count": + return `"Count: "&_xlfn.COUNTIF(${levelDimensions.startCoordinate}:${levelDimensions.endCoordinate}, ${recordLevel})` + case "min": + func = `_xlfn.MIN(_xlfn.IF(${levelDimensions.startCoordinate}:${levelDimensions.endCoordinate}=${recordLevel}, ${dimensions.startCoordinate}:${dimensions.endCoordinate}))` + funcType = `"Min: "&`; + + result = funcType + (col.dataType === 'currency' && currencyInfo + ? `_xlfn.TEXT(${func}, "${currencyInfo.symbol}#,##0.00")` + : `${func}`); + + return result + case "max": + func = `_xlfn.MAX(_xlfn.IF(${levelDimensions.startCoordinate}:${levelDimensions.endCoordinate}=${recordLevel}, ${dimensions.startCoordinate}:${dimensions.endCoordinate}))` + funcType = `"Max: "&`; + + result = funcType + (col.dataType === 'currency' && currencyInfo + ? `_xlfn.TEXT(${func}, "${currencyInfo.symbol}#,##0.00")` + : `${func}`); + + return result + case "sum": + func = `_xlfn.SUMIF(${levelDimensions.startCoordinate}:${levelDimensions.endCoordinate}, ${recordLevel}, ${dimensions.startCoordinate}:${dimensions.endCoordinate})` + funcType = `"Sum: "&`; + + result = funcType + (col.dataType === 'currency' && currencyInfo + ? `_xlfn.TEXT(${func}, "${currencyInfo.symbol}#,##0.00")` + : `${func}`); + + return result + case "avg": + func = `_xlfn.AVERAGEIF(${levelDimensions.startCoordinate}:${levelDimensions.endCoordinate}, ${recordLevel}, ${dimensions.startCoordinate}:${dimensions.endCoordinate})` + funcType = `"Avg: "&`; + + result = funcType + (col.dataType === 'currency' && currencyInfo + ? `_xlfn.TEXT(${func}, "${currencyInfo.symbol}#,##0.00")` + : `${func}`); + + return result + case "earliest": + // TODO: get date format from locale + return `"Earliest: "&_xlfn.TEXT(_xlfn.MIN(_xlfn.IF(${levelDimensions.startCoordinate}:${levelDimensions.endCoordinate}=${recordLevel}, ${dimensions.startCoordinate}:${dimensions.endCoordinate})), "m/d/yyyy")` + case "latest": + // TODO: get date format from locale + return `"Latest: "&_xlfn.TEXT(_xlfn.MAX(_xlfn.IF(${levelDimensions.startCoordinate}:${levelDimensions.endCoordinate}=${recordLevel}, ${dimensions.startCoordinate}:${dimensions.endCoordinate})), "m/d/yyyy")` + } + } + + private setRootSummaryStartCoordinate(column: number, key: string) { + const firstDataRecordColName = ExcelStrings.getExcelColumn(column) + (this.firstDataRow); + const targetMap = this.hierarchicalDimensionMap.get(GRID_PARENT); + + if (targetMap.get(key).startCoordinate !== firstDataRecordColName) { + targetMap.get(key).startCoordinate = firstDataRecordColName; + } + } + + private printHeaders(worksheetData: WorksheetData, headersForLevel: IColumnInfo[], i: number, isVertical: boolean) { + let startValue = 0; + let str = ''; + + const isHierarchicalGrid = worksheetData.isHierarchical; + let rowStyle = isHierarchicalGrid ? ' s="3"' : ''; + const dictionary = worksheetData.dataDictionary; + const owner = worksheetData.owner; + const maxLevel = isVertical + ? owner.maxRowLevel + : owner.maxLevel; + + for (const currentCol of headersForLevel) { + const spanLength = isVertical ? currentCol.rowSpan : currentCol.columnSpan; + + if (currentCol.level === i) { + let columnCoordinate; + const column = isVertical + ? this.rowIndex + : startValue + (owner.maxRowLevel ?? 0) + + let rowCoordinate = isVertical + ? startValue + owner.maxLevel + 2 + : this.rowIndex + if (currentCol.headerType === ExportHeaderType.PivotRowHeader) { + rowCoordinate = startValue + 1; + } + + const columnValue = currentCol.headerType === ExportHeaderType.PivotMergedHeader ? + dictionary.saveValue(currentCol.field, true, true) : + dictionary.saveValue(currentCol.header, true, false); + + columnCoordinate = (currentCol.field === GRID_LEVEL_COL + ? ExcelStrings.getExcelColumn(worksheetData.columnCount + 1) + : ExcelStrings.getExcelColumn(column)) + rowCoordinate; + + rowStyle = isVertical && currentCol.rowSpan > 1 ? ' s="4"' : rowStyle; + str = `${columnValue}`; + + if (isVertical) { + if (this.pivotGridRowHeadersMap.has(rowCoordinate)) { + this.pivotGridRowHeadersMap.set(rowCoordinate, this.pivotGridRowHeadersMap.get(rowCoordinate) + str) + } else { + this.pivotGridRowHeadersMap.set(rowCoordinate, str) + } + } else { + this.sheetData += str; + } + + if (i !== maxLevel) { + this.mergeCellsCounter++; + this.mergeCellStr += ` `; + + isVertical + ? this.pivotGridRowHeadersMap.set(row, str) + : this.sheetData += str + } + } + if ((currentCol.headerType === ExportHeaderType.RowHeader || currentCol.headerType === ExportHeaderType.MultiRowHeader) && + currentCol.columnSpan && currentCol.columnSpan > 1 ) { + columnCoordinate = ExcelStrings.getExcelColumn(column + currentCol.columnSpan - 1) + (rowCoordinate + spanLength - 1); + } + + this.mergeCellStr += `${columnCoordinate}" />`; + } + } + if (currentCol.headerType !== ExportHeaderType.PivotRowHeader) { + startValue += spanLength; + } + } + } +} + +/** + * @hidden + */ +export class StyleFile implements IExcelFile { + public writeElement(folder: Object) { + folder['styles.xml'] = strToU8(ExcelStrings.getStyles()); + } +} + +/** + * @hidden + */ +export class WorkbookFile implements IExcelFile { + public writeElement(folder: Object, worksheetData: WorksheetData) { + folder['workbook.xml'] = strToU8(ExcelStrings.getWorkbook(worksheetData.options.worksheetName)); + } +} + +/** + * @hidden + */ +export class ContentTypesFile implements IExcelFile { + public writeElement(folder: Object, worksheetData: WorksheetData) { + const hasSharedStrings = !worksheetData.isEmpty || worksheetData.options.alwaysExportHeaders; + folder['[Content_Types].xml'] = strToU8(ExcelStrings.getContentTypesXML(hasSharedStrings, worksheetData.options.exportAsTable)); + } +} + +/** + * @hidden + */ +export class SharedStringsFile implements IExcelFile { + public writeElement(folder: Object, worksheetData: WorksheetData) { + const dict = worksheetData.dataDictionary; + const sortedValues = dict.getKeys(); + const sharedStrings = new Array(sortedValues.length); + + for (const value of sortedValues) { + sharedStrings[dict.getSanitizedValue(value)] = '' + value + ''; + } + + folder['sharedStrings.xml'] = strToU8(ExcelStrings.getSharedStringXML( + dict.stringsCount, + sortedValues.length, + sharedStrings.join('')) + ); + } +} + +/** + * @hidden + */ +export class TablesFile implements IExcelFile { + public writeElement(folder: Object, worksheetData: WorksheetData) { + const columnCount = worksheetData.columnCount; + const lastColumn = ExcelStrings.getExcelColumn(columnCount - 1) + worksheetData.rowCount; + const autoFilterDimension = 'A1:' + lastColumn; + const tableDimension = worksheetData.isEmpty + ? 'A1:' + ExcelStrings.getExcelColumn(columnCount - 1) + (worksheetData.rowCount + 1) + : autoFilterDimension; + const hasUserSetIndex = worksheetData.owner.columns.some(c => c.exportIndex !== undefined); + const values = hasUserSetIndex + ? worksheetData.rootKeys + : worksheetData.owner.columns + .filter(c => !c.skip) + .sort((a, b) => a.startIndex - b.startIndex) + .sort((a, b) => a.pinnedIndex - b.pinnedIndex) + .map(c => c.header); + + let sortString = ''; + + let tableColumns = ''; + for (let i = 0; i < columnCount; i++) { + const value = values[i]; + tableColumns += ''; + } + + tableColumns += ''; + + if (worksheetData.sort) { + const sortingExpression = worksheetData.sort; + const sc = ExcelStrings.getExcelColumn(values.indexOf(sortingExpression.fieldName)); + const dir = sortingExpression.dir - 1; + sortString = ``; + } + + folder['table1.xml'] = strToU8(ExcelStrings.getTablesXML(autoFilterDimension, tableDimension, tableColumns, sortString)); + } +} + +/** + * @hidden + */ +export class WorksheetRelsFile implements IExcelFile { + public writeElement(folder: Object) { + folder['sheet1.xml.rels'] = strToU8(ExcelStrings.getWorksheetRels()); + } +} diff --git a/projects/igniteui-angular/grids/core/src/services/excel/excel-folders.ts b/projects/igniteui-angular/grids/core/src/services/excel/excel-folders.ts new file mode 100644 index 00000000000..99b89020323 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/services/excel/excel-folders.ts @@ -0,0 +1,168 @@ +import { + ExcelFileTypes, + ExcelFolderTypes +} from './excel-enums'; + +import { IExcelFolder } from './excel-interfaces'; +import { WorksheetData } from './worksheet-data'; + +/** @hidden */ +export class RootExcelFolder implements IExcelFolder { + public get folderName() { + return ''; + } + + public childFiles() { + return [ExcelFileTypes.ContentTypesFile]; + } + + public childFolders() { + return [ + ExcelFolderTypes.RootRelsExcelFolder, + ExcelFolderTypes.DocPropsExcelFolder, + ExcelFolderTypes.XLExcelFolder + ]; + } +} + +/** @hidden */ +export class RootRelsExcelFolder implements IExcelFolder { + public get folderName() { + return '_rels'; + } + + public childFiles() { + return [ExcelFileTypes.RootRelsFile]; + } + + public childFolders() { + return []; + } +} + +/** @hidden */ +export class DocPropsExcelFolder implements IExcelFolder { + public get folderName() { + return 'docProps'; + } + + public childFiles() { + return [ + ExcelFileTypes.AppFile, + ExcelFileTypes.CoreFile + ]; + } + + public childFolders() { + return []; + } +} + +/** @hidden */ +export class XLExcelFolder implements IExcelFolder { + public get folderName() { + return 'xl'; + } + + public childFiles(data: WorksheetData) { + const retVal = [ + ExcelFileTypes.StyleFile, + ExcelFileTypes.WorkbookFile + ]; + + if (!data.isEmpty || data.options.alwaysExportHeaders) { + retVal.push(ExcelFileTypes.SharedStringsFile); + } + + return retVal; + } + + public childFolders(data: WorksheetData) { + const retVal = [ + ExcelFolderTypes.XLRelsExcelFolder, + ExcelFolderTypes.ThemeExcelFolder, + ExcelFolderTypes.WorksheetsExcelFolder + ]; + + if ((!data.isEmpty || data.options.alwaysExportHeaders) && data.options.exportAsTable) { + retVal.push(ExcelFolderTypes.TablesExcelFolder); + } + + return retVal; + } +} + +/** @hidden */ +export class XLRelsExcelFolder implements IExcelFolder { + public get folderName() { + return '_rels'; + } + + public childFiles() { + return [ExcelFileTypes.WorkbookRelsFile]; + } + + public childFolders() { + return []; + } +} + +/** @hidden */ +export class ThemeExcelFolder implements IExcelFolder { + public get folderName() { + return 'theme'; + } + + public childFiles() { + return [ExcelFileTypes.ThemeFile]; + } + + public childFolders() { + return []; + } +} + +/** @hidden */ +export class WorksheetsExcelFolder implements IExcelFolder { + public get folderName() { + return 'worksheets'; + } + + public childFiles() { + return [ExcelFileTypes.WorksheetFile]; + } + + public childFolders(data: WorksheetData) { + return (data.isEmpty && !data.options.alwaysExportHeaders) || !data.options.exportAsTable ? [] : [ExcelFolderTypes.WorksheetsRelsExcelFolder]; + } +} + +/** @hidden */ +export class TablesExcelFolder implements IExcelFolder { + public get folderName() { + return 'tables'; + } + + public childFiles() { + return [ExcelFileTypes.TablesFile]; + } + + public childFolders() { + return []; + } +} + +/** @hidden */ +export class WorksheetsRelsExcelFolder implements IExcelFolder { + public get folderName() { + return '_rels'; + } + + public childFiles() { + return [ExcelFileTypes.WorksheetRelsFile]; + } + + public childFolders() { + return []; + } +} diff --git a/projects/igniteui-angular/grids/core/src/services/excel/excel-interfaces.ts b/projects/igniteui-angular/grids/core/src/services/excel/excel-interfaces.ts new file mode 100644 index 00000000000..80c5aca9896 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/services/excel/excel-interfaces.ts @@ -0,0 +1,19 @@ +import { + ExcelFileTypes, + ExcelFolderTypes +} from './excel-enums'; + +import { WorksheetData } from './worksheet-data'; + +/** @hidden */ +export interface IExcelFile { + writeElement(folder: Object, data: WorksheetData): void; +} + +/** @hidden */ +export interface IExcelFolder { + folderName: string; + + childFiles(data: WorksheetData): ExcelFileTypes[]; + childFolders(data: WorksheetData): ExcelFolderTypes[]; +} diff --git a/projects/igniteui-angular/grids/core/src/services/excel/excel-strings.ts b/projects/igniteui-angular/grids/core/src/services/excel/excel-strings.ts new file mode 100644 index 00000000000..fc9ba3b4d17 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/services/excel/excel-strings.ts @@ -0,0 +1,114 @@ +/** @hidden */ +export class ExcelStrings { + private static XML_STRING = '\r\n'; + private static SHARED_STRING_RELATIONSHIP = ''; + + public static getRels(): string { + return ExcelStrings.XML_STRING + ''; + } + + public static getApp(worksheetName: string): string { + return ExcelStrings.XML_STRING + `Microsoft Excel0falseWorksheets1${worksheetName}falsefalsefalse16.0300`; + } + + public static getCore(): string { + return ExcelStrings.XML_STRING + '2015-06-05T18:17:20Z2015-06-05T18:17:26Z'; + } + + public static getTheme(): string { + return ExcelStrings.XML_STRING + ''; + } + + public static getStyles(): string { + return ExcelStrings.XML_STRING + + ''; + } + + public static getWorkbook(worksheetName: string): string { + return ExcelStrings.XML_STRING + ``; + } + + public static getWorksheetRels(): string { + return ExcelStrings.XML_STRING + ``; + } + + public static getWorkbookRels(hasSharedStrings): string { + let retVal = ExcelStrings.XML_STRING + ``; + + if (hasSharedStrings) { + retVal += ExcelStrings.SHARED_STRING_RELATIONSHIP; + } + + retVal += ''; + + return retVal; + } + + public static getSheetXML(dimension: string, freezePane: string, cols: string, sheetData: string, hasTable: boolean, outlineLevel = 0, isHierarchical: boolean): string { + const hasOutline = outlineLevel > 0; + const tableParts = hasTable ? '' : ''; + const sheetOutlineProp = hasOutline ? '' : ''; + const sOutlineLevel = hasOutline ? `outlineLevelRow="${outlineLevel}"` : ''; + const dimensions = isHierarchical ? '' : ``; + + // return ExcelStrings.XML_STRING + + // '' + freezePane + '' + cols + sheetData + '' + tableParts + ''; + + return `${ExcelStrings.XML_STRING} + +${sheetOutlineProp} +${dimensions} +${freezePane} + +${cols} +${sheetData} + +${tableParts}`; + + } + + public static getSharedStringXML(count: number, uniqueCount: number, table: string): string { + return ExcelStrings.XML_STRING + '' + table + ''; + } + + public static getContentTypesXML(hasSharedStrings: boolean, hasTable: boolean): string { + let contentTypes = ExcelStrings.XML_STRING + + ` + + + + + + + + `; + + contentTypes += hasSharedStrings ? + ` ` : ''; + + contentTypes += hasTable ? + `` : ''; + contentTypes += ``; + + return contentTypes; + } + + public static getTablesXML(autoFilterDimension: string, tableDimension: string, tableColumns: string, sort: string): string { + return `${ExcelStrings.XML_STRING}
    + ${sort}${tableColumns} +
    `; + } + + + public static getExcelColumn(index: number): string { + // Returns the excel column name for given 0-based index + // For example 27 should return "AB" + let returnString = ''; + while (index >= 0) { + const char = index % 26; + returnString = String.fromCharCode(65 + char) + returnString; + index = Math.floor(index / 26) - 1; + } + return returnString; + } +} diff --git a/projects/igniteui-angular/grids/core/src/services/excel/test-data.service.spec.ts b/projects/igniteui-angular/grids/core/src/services/excel/test-data.service.spec.ts new file mode 100644 index 00000000000..97ed95aeb96 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/services/excel/test-data.service.spec.ts @@ -0,0 +1,1993 @@ +import { Injectable } from '@angular/core'; +import { ZipFiles } from './zip-helper.spec'; +import { IFileContent } from './zip-verification-wrapper.spec'; + +@Injectable() +export class ExportTestDataService { + + private _differentTypesData = [ + { Number: 1, String: '1', Boolean: true, Date: new Date(2018, 3, 3) }, + { Number: 2, String: '2', Boolean: false, Date: new Date(2018, 5, 6) }, + { Number: 3, String: '3', Boolean: true, Date: new Date(2018, 9, 22) } + ]; + + private _contactsData = [{ + name: 'Terrance Orta', + phone: '770-504-2217' + }, { + name: 'Richard Mahoney LongerName', + phone: '' + }, { + name: 'Donna Price', + phone: '859-496-2817' + }, { + name: '', + phone: '901-747-3428' + }, { + name: 'Dorothy H. Spencer', + phone: '573-394-9254' + }]; + + private _contactsFunkyData = [{ + name: 'Terrance Mc\'Orta', + phone: '(+359)770-504-2217 | 2218' + }, { + name: 'Richard Mahoney /LongerName/', + phone: '' + }, { + name: 'Donna, \/; Price', + phone: '859 496 28**' + }, { + name: '\r\n', + phone: '901-747-3428' + }, { + name: 'Dorothy "H." Spencer', + phone: '573-394-9254[fax]' + }, { + name: 'Иван Иванов (1,2)', + phone: '№ 573-394-9254' + }]; + + private _contactsPartial = [ + { + name: 'Terrance Orta', + phone: '770-504-2217' + }, { + name: 'Richard Mahoney LongerName' + }, { + phone: '780-555-1331' + } + ]; + + private _noHeadersStringData = [ + 'Terrance Orta', + 'Richard Mahoney LongerName', + 'Donna Price', + 'Lisa Landers', + 'Dorothy H. Spencer' + ]; + + private _noHeadersNumberData = [ + 10, + 20, + 30 + ]; + + private _noHeadersDateTime = [ + new Date('2018'), + new Date(2018, 3, 23), + new Date(30), + new Date('2018/03/23') + ]; + + private _noHeadersObjectData = [ + new ValueData('1'), + new ValueData('2'), + new ValueData('3') + ]; + + private _emptyObjectData = [ + {}, + {}, + {} + ]; + + private _simpleGridData = [ + { ID: 1, Name: 'Casey Houston', JobTitle: 'Vice President' }, + { ID: 2, Name: 'Gilberto Todd', JobTitle: 'Director' }, + { ID: 3, Name: 'Tanya Bennett', JobTitle: 'Director' }, + { ID: 4, Name: 'Jack Simon', JobTitle: 'Software Developer' }, + { ID: 5, Name: 'Celia Martinez', JobTitle: 'Senior Software Developer' }, + { ID: 6, Name: 'Erma Walsh', JobTitle: 'CEO' }, + { ID: 7, Name: 'Debra Morton', JobTitle: 'Associate Software Developer' }, + { ID: 8, Name: 'Erika Wells', JobTitle: 'Software Development Team Lead' }, + { ID: 9, Name: 'Leslie Hansen', JobTitle: 'Associate Software Developer' }, + { ID: 10, Name: 'Eduardo Ramirez', JobTitle: 'Manager' } + ]; + + private _simpleGridDataFull = [ + { ID: 1, Name: 'Casey Houston', JobTitle: 'Vice President', HireDate: '2017-06-19T11:43:07.714Z' }, + { ID: 2, Name: 'Gilberto Todd', JobTitle: 'Director', HireDate: '2015-12-18T11:23:17.714Z' }, + { ID: 3, Name: 'Tanya Bennett', JobTitle: 'Director', HireDate: '2005-11-18T11:23:17.714Z' }, + { ID: 4, Name: 'Jack Simon', JobTitle: 'Software Developer', HireDate: '2008-12-18T11:23:17.714Z' }, + { ID: 5, Name: 'Celia Martinez', JobTitle: 'Senior Software Developer', HireDate: '2007-12-19T11:23:17.714Z' }, + { ID: 6, Name: 'Erma Walsh', JobTitle: 'CEO', HireDate: '2016-12-18T11:23:17.714Z' }, + { ID: 7, Name: 'Debra Morton', JobTitle: 'Associate Software Developer', HireDate: '2005-11-19T11:23:17.714Z' }, + { ID: 8, Name: 'Erika Wells', JobTitle: 'Software Development Team Lead', HireDate: '2005-10-14T11:23:17.714Z' }, + { ID: 9, Name: 'Leslie Hansen', JobTitle: 'Associate Software Developer', HireDate: '2013-10-10T11:23:17.714Z' }, + { ID: 10, Name: 'Eduardo Ramirez', JobTitle: 'Manager', HireDate: '2011-11-28T11:23:17.714Z' } + ]; + + private _personJobHoursDataPerformance = [ + { ID: 1, Name: 'Casey Houston', JobTitle: 'Vice President', WorkingHours: 4, HireDate: '2017-06-19T11:43:07.714Z', Performance: + [ + {Points: 3, Week: 1}, + {Points: 6, Week: 2}, + {Points: 1, Week: 3}, + {Points: 12, Week: 4}, + ] + }, + { ID: 2, Name: 'Gilberto Todd', JobTitle: 'Director', WorkingHours: 6, HireDate: '2015-12-18T11:23:17.714Z', Performance: + [ + {Points: 8, Week: 1}, + {Points: 7, Week: 2}, + {Points: 4, Week: 3}, + {Points: 9, Week: 4}, + ] + }, + { ID: 3, Name: 'Tanya Bennett', JobTitle: 'Director', WorkingHours: 8, HireDate: '2005-11-18T11:23:17.714Z', Performance: + [ + {Points: 1, Week: 1}, + {Points: 3, Week: 2}, + {Points: 14, Week: 3}, + {Points: 29, Week: 4}, + ] + } + ]; + constructor() { } + + public get differentTypesData() { + return this._differentTypesData; + } + + public get contactsData() { + return this._contactsData; + } + public get contactsPartialData() { + return this._contactsPartial; + } + public get contactsFunkyData() { + return this._contactsFunkyData; + } + public get emptyObjectData() { + return this._emptyObjectData; + } + + public get noHeadersObjectData() { + return this._noHeadersObjectData; + } + + public get noHeadersStringData() { + return this._noHeadersStringData; + } + public get noHeadersNumberData() { + return this._noHeadersNumberData; + } + public get noHeadersDateTimeData() { + return this._noHeadersDateTime; + } + + public get simpleGridData() { + return this._simpleGridData; + } + + public get simpleGridDataFull() { + return this._simpleGridDataFull; + } + + public get personJobHoursDataPerformance() { + return this._personJobHoursDataPerformance; + } + + public getContactsFunkyData(delimiter) { + return [{ + name: 'Terrance Mc\'Orta', + phone: '(+359)770-504-2217 | 2218' + }, { + name: 'Richard Mahoney /LongerName/', + phone: '' + }, { + name: 'Donna' + delimiter + ' \/; Price', + phone: '859 496 28**' + }, { + name: '\r\n', + phone: '901-747-3428' + }, { + name: 'Dorothy "H." Spencer', + phone: '573-394-9254[fax]' + }, { + name: 'Иван Иванов (1' + delimiter + '2)', + phone: '№ 573-394-9254' + }]; + } +} + +export class ValueData { + public value: string; + + constructor(value: string) { + this.value = value; + } +} + +export class FileContentData { + + private _fileContentCollection: IFileContent[]; + private _sharedStringsData = ''; + private _tableData = ''; + private _worksheetData = ''; + private _workbookData = ` + `; + private _appData = ` + Microsoft Excel0falseWorksheets1Sheet1falsefalsefalse16.0300`; + + constructor() {} + + public create(worksheetData: string, tableData: string, sharedStringsData: string, workbookData: string, appData: string, isHGrid = false): IFileContent[] { + this._fileContentCollection = [ + { fileName: ZipFiles.dataFiles[1].name, fileContent : worksheetData }, + { fileName: ZipFiles.dataFiles[3].name, fileContent : sharedStringsData }, + { fileName: ZipFiles.templateFiles[6].name, fileContent : workbookData }, + { fileName: ZipFiles.templateFiles[1].name, fileContent : appData }, + ]; + + if (!isHGrid) { + this._fileContentCollection.push({ + fileName: ZipFiles.dataFiles[2].name, fileContent : tableData + }); + } + + return this._fileContentCollection; + } + + public simpleGridSortByNameDesc() { + this._sharedStringsData = `count="23" uniqueCount="21">IDNameJobTitle` + + `Tanya BennettDirectorLeslie HansenAssociate Software Developer` + + `Jack SimonSoftware DeveloperGilberto ToddErma WalshCEO` + + `Erika WellsSoftware Development Team LeadEduardo RamirezManager` + + `Debra MortonCelia MartinezSenior Software DeveloperCasey HoustonVice President`; + + this._tableData = `ref="A1:C11" totalsRowShown="0"> + `; + + this._worksheetData = `012334956478294610118121310141571665171811920`; + + return this.createData(); + } + + public simpleGridColumnWidth(width = 0) { + const wsDataColSettings = this.updateColumnWidth(width); + this._sharedStringsData = + `count="1" uniqueCount="1">ID`; + + this._tableData = `ref="A1:A11" totalsRowShown="0"> + `; + + this._worksheetData = + `` + + `${ wsDataColSettings }012345678910`; + + return this.createData(); + } + + public simpleGridRowHeight(height = 0) { + this._sharedStringsData = + `count="23" uniqueCount="21">IDNameJobTitleCasey HoustonVice PresidentGilberto ToddDirectorTanya BennettJack SimonSoftware DeveloperCelia MartinezSenior Software DeveloperErma WalshCEODebra MortonAssociate Software DeveloperErika WellsSoftware Development Team LeadLeslie HansenEduardo RamirezManager`; + + this._tableData = `ref="A1:C11" totalsRowShown="0"> + `; + + this._worksheetData = this.updateRowHeight(height); + + return this.createData(); + } + + public simpleGridWorksheetName(name) { + this._sharedStringsData = + `count="23" uniqueCount="21">IDNameJobTitleCasey HoustonVice PresidentGilberto ToddDirectorTanya BennettJack SimonSoftware DeveloperCelia MartinezSenior Software DeveloperErma WalshCEODebra MortonAssociate Software DeveloperErika WellsSoftware Development Team LeadLeslie HansenEduardo RamirezManager`; + + this._tableData = `ref="A1:C11" totalsRowShown="0"> + ` + + ``; + + this._worksheetData = + `0121342563764895101161213714158161791815101920`; + + this._workbookData = ` + `; + + this._appData = ` + Microsoft Excel0falseWorksheets1${name}falsefalsefalse16.0300`; + + return this.createData(); + } + + public treeGridDataExpDepth(depth: number) { + this._sharedStringsData = + `count="21" uniqueCount="19">IDParentIDNameJobTitleAgeCasey HoustonVice PresidentGilberto ToddDirectorTanya BennettDebra MortonAssociate Software DeveloperJack SimonSoftware DeveloperErma WalshCEOEduardo RamirezManagerLeslie Hansen`; + + this._tableData = `ref="A1:E9" totalsRowShown="0"> + `; + + switch (depth) { + case 0: + this._worksheetData = ` + + + + + +012341-156326-114155210-1161753`; + break; + case 1: + this._worksheetData = ` + + + + + +012341-15632217841411213336-114155210-1161753910181144`; + break; + } + + return this.createData(); + } + + private createData(isHGrid = false) { + return this.create(this._worksheetData, this._tableData, this._sharedStringsData, this._workbookData, this._appData, isHGrid); + } + + public get differentTypesDataContent() { + this._sharedStringsData = `count="6" uniqueCount="6">Column1Terrance OrtaRichard Mahoney ` + + `LongerNameDonna PriceLisa LandersDorothy H. Spencer`; + + this._tableData = `ref="A1:A6" totalsRowShown="0">` + + ``; + + this._worksheetData = + `` + + `` + + `01234` + + `5`; + + return this.createData(); + } + + public get contactsDataContent() { + this._sharedStringsData = `count="12" uniqueCount="11">namephoneTerrance Orta` + + `770-504-2217Richard Mahoney LongerNameDonna Price` + + `859-496-2817901-747-3428Dorothy H. Spencer573-394-9254`; + + this._tableData = `ref="A1:B6" totalsRowShown="0"> + ` + + ``; + + this._worksheetData = + `` + + `` + + `012` + + `345` + + `6758` + + `910`; + + return this.createData(); + } + + public get contactsPartialDataContent() { + this._sharedStringsData = + `count="6" uniqueCount="6">namephoneTerrance Orta770-504-2217Richard Mahoney LongerName780-555-1331`; + + this._tableData = `ref="A1:B4" totalsRowShown="0"> + ` + + ``; + + this._worksheetData = + `012345`; + + return this.createData(); + } + + public get contactsFunkyDataContent() { + this._sharedStringsData = `count="14" uniqueCount="14">namephoneTerrance ` + + `Mc'Orta(+359)770-504-2217 | 2218Richard Mahoney /LongerName/` + + `Donna, /; Price859 496 28** + 901-747-3428Dorothy "H." Spencer573-394-9254[fax]` + + `Иван Иванов (1,2)№ 573-394-9254`; + + this._tableData = `ref="A1:B7" totalsRowShown="0"> + ` + + ``; + + this._worksheetData = + `` + + `0` + + `123` + + `4567` + + `8910` + + `111213`; + + return this.createData(); + } + + public get contactsDataSkippedColumnContent() { + this._sharedStringsData = `count="6" uniqueCount="6">nameTerrance Orta` + + `Richard Mahoney LongerNameDonna Price` + + `Dorothy H. Spencer`; + + this._tableData = `ref="A1:A6" totalsRowShown="0"> + `; + + this._worksheetData = + `` + + `0` + + `1` + + `23` + + `45` + + ``; + + return this.createData(); + } + + public get noHeadersStringDataContent() { + this._sharedStringsData = `count="6" uniqueCount="6">Column1Terrance Orta` + + `Richard Mahoney LongerNameDonna PriceLisa Landers` + + `Dorothy H. Spencer`; + + this._tableData = `ref="A1:A6" totalsRowShown="0">` + + ``; + + this._worksheetData = `` + + `0` + + `123` + + `45`; + + return this.createData(); + } + + public get noHeadersNumberDataContent() { + this._sharedStringsData = `count="1" uniqueCount="1">Column 1`; + + this._tableData = `ref="A1:A4" totalsRowShown="0">` + + ``; + + this._worksheetData = `0102030`; + + return this.createData(); + } + + public get noHeadersDateTimeContent() { + this._sharedStringsData = `count="1" uniqueCount="1">Column 1`; + + this._tableData = `ref="A1:A3" totalsRowShown="0">` + + ``; + + this._worksheetData = `` + + `0` + + `2018-04-23T00:00:002018-03-23T00:00:00` + + ``; + + return this.createData(); + } + + public get noHeadersObjectDataContent() { + this._sharedStringsData = `count="4" uniqueCount="4">value12` + + `3`; + + this._tableData = `ref="A1:A4" totalsRowShown="0">` + + ``; + + this._worksheetData = `` + + `01` + + `23`; + + return this.createData(); + } + + public get simpleGridData() { + this._sharedStringsData = + `count="23" uniqueCount="21">IDNameJobTitleCasey HoustonVice PresidentGilberto ToddDirectorTanya BennettJack SimonSoftware DeveloperCelia MartinezSenior Software DeveloperErma WalshCEODebra MortonAssociate Software DeveloperErika WellsSoftware Development Team LeadLeslie HansenEduardo RamirezManager`; + + this._tableData = `ref="A1:C11" totalsRowShown="0"> + ` + + ``; + + this._worksheetData = + `0121342563764895101161213714158161791815101920`; + + return this.createData(); + } + + public get simpleGridDataFull() { + this._sharedStringsData = `count="44" uniqueCount="42">IDNameJobTitle` + + `HireDate1Casey HoustonVice President` + + `2017-06-19T11:43:07.714Z2Gilberto ToddDirector` + + `2015-12-18T11:23:17.714Z3Tanya Bennett2005-11-18T11:23:17.714Z` + + `4Jack SimonSoftware Developer2008-12-18T11:23:17.714Z` + + `5Celia MartinezSenior Software Developer` + + `2007-12-19T11:23:17.714Z6Erma WalshCEO` + + `2016-12-18T11:23:17.714Z7Debra MortonAssociate Software Developer` + + `2005-11-19T11:23:17.714Z8Erika WellsSoftware Development ` + + `Team Lead2005-10-14T11:23:17.714Z9Leslie Hansen` + + `2013-10-10T11:23:17.714Z10Eduardo RamirezManager` + + `2011-11-28T11:23:17.714Z`; + + this._tableData = `ref="A1:D11" totalsRowShown="0"> + ` + + ``; + + this._worksheetData = + `` + + `` + + `0123` + + `4567` + + `891011` + + `121310` + + `14151617181920` + + `21222324` + + `252627` + + `28293031` + + `323334` + + `35362937` + + `38394041` + + ``; + + return this.createData(); + } + + public get simpleGridDataPage1() { + this._sharedStringsData = `count="16" uniqueCount="15">IDNameJobTitle` + + `HireDate1Casey HoustonVice President` + + `2017-06-19T11:43:07.714Z2Gilberto ToddDirector` + + `2015-12-18T11:23:17.714Z3Tanya Bennett2005-11-18T11:23:17.714Z`; + + this._tableData = `ref="A1:D4" totalsRowShown="0"> + ` + + ``; + + this._worksheetData = + `` + + `` + + `` + + `0` + + `1234` + + `5678` + + `91011` + + `12131014`; + + return this.createData(); + } + + public get simpleGridDataPage2() { + this._sharedStringsData = `count="16" uniqueCount="16">IDNameJobTitle` + + `HireDate4Jack SimonSoftware Developer2008-12-18T11:23:17.714Z` + + `5Celia MartinezSenior Software Developer2007-12-19T11:23:17.714Z` + + `6Erma WalshCEO2016-12-18T11:23:17.714Z`; + + this._tableData = `ref="A1:D4" totalsRowShown="0"> + ` + + ``; + + this._worksheetData = + `` + + `` + + `01234` + + `567` + + `89101112131415`; + + return this.createData(); + } + + public get simpleGridDataPage1FiveRows() { + this._sharedStringsData = `count="24" uniqueCount="23">IDNameJobTitle` + + `HireDate1Casey HoustonVice President2017-06-19T11:43:07.714Z` + + `2Gilberto ToddDirector2015-12-18T11:23:17.714Z3` + + `Tanya Bennett2005-11-18T11:23:17.714Z4Jack Simon` + + `Software Developer2008-12-18T11:23:17.714Z5Celia Martinez` + + `Senior Software Developer2007-12-19T11:23:17.714Z`; + + this._tableData = `ref="A1:D6" totalsRowShown="0"> + ` + + ``; + + this._worksheetData = + `` + + `` + + `0123` + + `4567` + + `891011` + + `121310` + + `141516171819202122`; + + return this.createData(); + } + + public get simpleGridDataRecord5() { + this._sharedStringsData = `count="5" uniqueCount="5">IDNameJobTitleCelia MartinezSenior Software Developer`; + + this._tableData = `ref="A1:C2" totalsRowShown="0"> + ` + + ``; + + this._worksheetData = + `012534`; + + return this.createData(); + } + + public get simpleGridDataDirectors() { + + this._sharedStringsData = `count="7" uniqueCount="6">IDNameJobTitleGilberto ToddDirectorTanya Bennett`; + + this._tableData = `ref="A1:C3" totalsRowShown="0"> + `; + + this._worksheetData = + `012234354`; + + return this.createData(); + } + + public get simpleGridNameJobTitle() { + this._sharedStringsData = `count="22" uniqueCount="20">NameJobTitleCasey Houston` + + `Vice PresidentGilberto ToddDirectorTanya Bennett` + + `Jack SimonSoftware DeveloperCelia MartinezSenior Software Developer` + + `Erma WalshCEODebra MortonAssociate Software Developer` + + `Erika WellsSoftware Development Team LeadLeslie HansenEduardo Ramirez` + + `Manager`; + + this._tableData = `ref="A1:B11" totalsRowShown="0"> + ` + + ``; + + this._worksheetData = + `` + + `` + + `0` + + `123` + + `4565` + + `789` + + `101112` + + `131415` + + `161714` + + `1819`; + + return this.createData(); + } + + public get simpleGridNameJobTitleWithFormatting() { + this._sharedStringsData = `count="33" uniqueCount="31">IDNameJobTitleoneCasey HoustonVice PresidenttwoGilberto ToddDirectorthreeTanya BennettfourJack SimonSoftware DeveloperfiveCelia MartinezSenior Software DevelopersixErma WalshCEOsevenDebra MortonAssociate Software DevelopereightErika WellsSoftware Development Team LeadnineLeslie HansentenEduardo RamirezManager`; + + this._tableData = `ref="A1:C11" totalsRowShown="0"> + `; + + this._worksheetData = + ` + + + + 0123456789108111213141516171819202122232425262722282930`; + + return this.createData(); + } + + public get simpleGridNameJobTitleID() { + this._sharedStringsData = + `count="23" uniqueCount="21">NameJobTitleIDCasey HoustonVice PresidentGilberto ToddDirectorTanya BennettJack SimonSoftware DeveloperCelia MartinezSenior Software DeveloperErma WalshCEODebra MortonAssociate Software DeveloperErika WellsSoftware Development Team LeadLeslie HansenEduardo RamirezManager`; + + this._tableData = `ref="A1:C11" totalsRowShown="0"> + `; + + this._worksheetData = + `0123415627638941011512136141571617818159192010`; + + return this.createData(); + } + + public get simpleGridSortByName() { + this._sharedStringsData = `count="23" uniqueCount="21">IDNameJobTitle` + + `Casey HoustonVice PresidentCelia MartinezSenior Software Developer` + + `Debra MortonAssociate Software DeveloperEduardo RamirezManager` + + `Erika WellsSoftware Development Team LeadErma WalshCEO` + + `Gilberto ToddDirectorJack SimonSoftware Developer` + + `Leslie HansenTanya Bennett`; + + this._tableData = `ref="A1:C11" totalsRowShown="0"> + `; + + this._worksheetData = `0121345567781091081112613142151641718919832016`; + + return this.createData(); + } + + public get simpleGridJobTitleIDName() { + this._sharedStringsData = + `count="23" uniqueCount="21">JobTitleIDNameVice PresidentCasey HoustonDirectorGilberto ToddTanya BennettSoftware DeveloperJack SimonSenior Software DeveloperCelia MartinezCEOErma WalshAssociate Software DeveloperDebra MortonSoftware Development Team LeadErika WellsLeslie HansenManagerEduardo Ramirez`; + + this._tableData = `ref="A1:C11" totalsRowShown="0"> + `; + + this._worksheetData = + `0123145265378491051112613147151681714918191020`; + + return this.createData(); + } + + private updateColumnWidth(width: number) { + let wsDataColSettings = ''; + + switch (width) { + case 100: + wsDataColSettings = + ``; + break; + case 200: + wsDataColSettings = + ``; + break; + case null: + case 0: + wsDataColSettings = + ``; + break; + } + + return wsDataColSettings; + } + + private updateRowHeight(height: number) { + let wsSettings = + `0121342563764895101161213714158161791815101920`; + + switch (height) { + case 20: + wsSettings = + `0121342563764895101161213714158161791815101920`; + + break; + case 40: + wsSettings = + `0121342563764895101161213714158161791815101920`; + break; + case undefined: + case null: + case 0: + break; + } + + return wsSettings; + } + + public get gridNameIDJobTitle() { + this._sharedStringsData = + `count="23" uniqueCount="21">NameIDJobTitleCasey HoustonVice PresidentGilberto ToddDirectorTanya BennettJack SimonSoftware DeveloperCelia MartinezSenior Software DeveloperErma WalshCEODebra MortonAssociate Software DeveloperErika WellsSoftware Development Team LeadLeslie HansenEduardo RamirezManager`; + + this._tableData = `ref="A1:C11" totalsRowShown="0"> + ` + + ``; + + this._worksheetData = + `0123145267368491051112613147151681718915191020`; + + return this.createData(); + } + + public get gridNameFrozen() { + this._sharedStringsData = + `count="23" uniqueCount="21">NameIDJobTitleCasey HoustonVice PresidentGilberto ToddDirectorTanya BennettJack SimonSoftware DeveloperCelia MartinezSenior Software DeveloperErma WalshCEODebra MortonAssociate Software DeveloperErika WellsSoftware Development Team LeadLeslie HansenEduardo RamirezManager`; + + this._tableData = `ref="A1:C11" totalsRowShown="0"> + ` + + ``; + + this._worksheetData = `0123145267368491051112613147151681718915191020`; + + return this.createData(); + } + + public get gridJobTitleIdFrozen() { + this._sharedStringsData = `count="23" uniqueCount="21">JobTitleIDNameVice PresidentCasey HoustonDirectorGilberto ToddTanya BennettSoftware DeveloperJack SimonSenior Software DeveloperCelia MartinezCEOErma WalshAssociate Software DeveloperDebra MortonSoftware Development Team LeadErika WellsLeslie HansenManagerEduardo Ramirez`; + + this._tableData = `ref="A1:C11" totalsRowShown="0"> + `; + + this._worksheetData = ` + + + + 0123145265378491051112613147151681714918191020`; + + return this.createData(); + } + + public get gridNameFrozenHeaders() { + this._sharedStringsData = + `count="23" uniqueCount="21">NameIDJobTitleCasey HoustonVice PresidentGilberto ToddDirectorTanya BennettJack SimonSoftware DeveloperCelia MartinezSenior Software DeveloperErma WalshCEODebra MortonAssociate Software DeveloperErika WellsSoftware Development Team LeadLeslie HansenEduardo RamirezManager`; + + this._tableData = `ref="A1:C11" totalsRowShown="0"> + ` + + ``; + + this._worksheetData = `0123145267368491051112613147151681718915191020`; + + return this.createData(); + } + + public get gridFrozenHeaders() { + this._sharedStringsData = + `count="23" uniqueCount="21">NameIDJobTitleCasey HoustonVice PresidentGilberto ToddDirectorTanya BennettJack SimonSoftware DeveloperCelia MartinezSenior Software DeveloperErma WalshCEODebra MortonAssociate Software DeveloperErika WellsSoftware Development Team LeadLeslie HansenEduardo RamirezManager`; + + this._tableData = `ref="A1:C11" totalsRowShown="0"> + ` + + ``; + + this._worksheetData = `0123145267368491051112613147151681718915191020`; + + return this.createData(); + } + + public get treeGridData() { + this._sharedStringsData = + `count="21" uniqueCount="19">IDParentIDNameJobTitleAgeCasey HoustonVice PresidentGilberto ToddDirectorTanya BennettDebra MortonAssociate Software DeveloperJack SimonSoftware DeveloperErma WalshCEOEduardo RamirezManagerLeslie Hansen`; + + this._tableData = `ref="A1:E9" totalsRowShown="0"> + `; + + this._worksheetData = ` + + + + + +012341-1563221784132982972101135411213336-114155210-1161753910181144`; + + return this.createData(); + } + + public get treeGridDataIgnoreFiltering() { + this._sharedStringsData = + `count="21" uniqueCount="19">IDParentIDNameJobTitleAgeCasey HoustonVice PresidentGilberto ToddDirectorTanya BennettDebra MortonAssociate Software DeveloperJack SimonSoftware DeveloperErma WalshCEOEduardo RamirezManagerLeslie Hansen`; + + this._tableData = `ref="A1:E9" totalsRowShown="0"> + `; + + this._worksheetData = ` + + + + + 012341-1563221784132982972101135411213336-114155210-1161753`; + + return this.createData(); + } + + public get treeGridDataFormatted() { + this._sharedStringsData = + `count="21" uniqueCount="19">IDParentIDNameJobTitleAgeCasey HoustonVice PresidentGilberto ToddDirectorTanya BennettDebra MortonAssociate Software DeveloperJack SimonSoftware DeveloperErma WalshCEOEduardo RamirezManagerLeslie Hansen`; + + this._tableData = `ref="A1:E9" totalsRowShown="0"> + `; + + this._worksheetData = ` + + + + + + 012341-15632.42217841.42329829.9272101135.6741121333.56-1141552.5810-1161753.67910181144.67`; + + return this.createData(); + } + + public get treeGridDataSorted() { + this._sharedStringsData = + `count="21" uniqueCount="19">IDParentIDNameJobTitleAgeEduardo RamirezManagerLeslie HansenAssociate Software DeveloperErma WalshCEOCasey HoustonVice PresidentJack SimonSoftware DeveloperGilberto ToddDirectorDebra MortonTanya Bennett`; + + this._tableData = `ref="A1:E9" totalsRowShown="0"> + `; + + this._worksheetData = ` + + + + + +0123410-1565391078446-1910521-11112324113143321151641721783532181629`; + + return this.createData(); + } + + public get treeGridDataFiltered() { + this._sharedStringsData = + `count="19" uniqueCount="18">IDParentIDNameJobTitleAgeCasey HoustonVice PresidentGilberto ToddDirectorDebra MortonAssociate Software DeveloperJack SimonSoftware DeveloperErma WalshCEOEduardo RamirezManagerLeslie Hansen`; + + this._tableData = `ref="A1:E8" totalsRowShown="0"> + `; + + this._worksheetData = ` + + + + + +012341-156322178417291035411112336-113145210-1151653910171044`; + + return this.createData(); + } + + public get treeGridDataFilteredSorted() { + this._sharedStringsData = + `count="19" uniqueCount="18">IDParentIDNameJobTitleAgeErma WalshCEOEduardo RamirezManagerLeslie HansenAssociate Software DeveloperCasey HoustonVice PresidentJack SimonSoftware DeveloperGilberto ToddDirectorDebra Morton`; + + this._tableData = `ref="A1:E8" totalsRowShown="0"> + `; + + this._worksheetData = ` + + + + + +012346-1565210-17853910910441-1111232411314332115164172171035`; + + return this.createData(); + } + + public get treeGridWithAdvancedFilters() { + this._sharedStringsData = + `count="15" uniqueCount="14">IDParentIDNameJobTitleAgeCasey HoustonVice PresidentGilberto ToddDirectorTanya BennettDebra MortonAssociate Software DeveloperJack SimonSoftware Developer`; + + this._tableData = `ref="A1:E6" totalsRowShown="0"> + `; + + this._worksheetData = ` + + + + + 012341-156322178413298297210113541121333`; + + return this.createData(); + } + + public get emptyTreeGridWithExportedHeaders() { + this._sharedStringsData = + `count="5" uniqueCount="5">IDParentIDNameJobTitleAge`; + + this._tableData = `ref="A1:E2" totalsRowShown="0"> + `; + + this._worksheetData = ` + + + + + 01234`; + + return this.createData(); + } + + public get gridProductsWithFormatter() { + this._sharedStringsData = + `count="45" uniqueCount="35">Product IDProductNameInStockUnitsInStockOrderDateChaitrue2760.00Mon Mar 21 2005Aniseed Syrupfalse198.00Tue Jan 15 2008Chef Antons Cajun Seasoning52.00Sat Nov 20 2010Grandmas Boysenberry Spread0.00Thu Oct 11 2007Uncle Bobs Dried PearsFri Jul 27 2001Northwoods Cranberry Sauce1098.00Thu May 17 1990Queso CabralesThu Mar 03 2005Tofu7898.00Sat Sep 09 2017Teatime Chocolate Biscuits6998.00Thu Dec 25 2025Chocolate20000.00Thu Mar 01 2018`; + + this._tableData = `ref="A1:E11" totalsRowShown="0"> + `; + + this._worksheetData = ` + + + + + 01234156782910111231361415416101718519101720621622237241017258266272892963031103263334`; + + return this.createData(); + } + + public get gridProductsWithoutFormatter() { + this._sharedStringsData = + `count="35" uniqueCount="27">Product IDProductNameInStockUnitsInStockOrderDateChaitrueMon Mar 21 2005 02:00:00 GMT+0200 (Eastern European Standard Time)Aniseed SyrupfalseTue Jan 15 2008 02:00:00 GMT+0200 (Eastern European Standard Time)Chef Antons Cajun SeasoningSat Nov 20 2010 02:00:00 GMT+0200 (Eastern European Standard Time)Grandmas Boysenberry SpreadThu Oct 11 2007 03:00:00 GMT+0300 (Eastern European Summer Time)Uncle Bobs Dried PearsFri Jul 27 2001 03:00:00 GMT+0300 (Eastern European Summer Time)Northwoods Cranberry SauceThu May 17 1990 04:00:00 GMT+0400 (Eastern European Summer Time)Queso CabralesThu Mar 03 2005 02:00:00 GMT+0200 (Eastern European Standard Time)TofuSat Sep 09 2017 03:00:00 GMT+0300 (Eastern European Summer Time)Teatime Chocolate BiscuitsThu Dec 25 2025 02:00:00 GMT+0200 (Eastern European Standard Time)ChocolateThu Mar 01 2018 02:00:00 GMT+0200 (Eastern European Standard Time)`; + + this._tableData = `ref="A1:E11" totalsRowShown="0"> + `; + + this._worksheetData = ` + + + + + 01234156276072891981031165212413901451590166176109818719902082167898229236699824102562000026`; + + return this.createData(); + } + + public get gridWithEmptyColumns() { + this._sharedStringsData = + `count="25" uniqueCount="23">Column1IDColumn2NameJobTitleCasey HoustonVice PresidentGilberto ToddDirectorTanya BennettJack SimonSoftware DeveloperCelia MartinezSenior Software DeveloperErma WalshCEODebra MortonAssociate Software DeveloperErika WellsSoftware Development Team LeadLeslie HansenEduardo RamirezManager`; + + this._tableData = `ref="A1:E11" totalsRowShown="0"> + `; + + this._worksheetData = ` + + + + +01234156278398410115121361415716178181992017102122`; + + return this.createData(); + } + + public get gridWithAdvancedFilters() { + this._sharedStringsData = + `count="11" uniqueCount="11">IDNameJobTitleErma WalshCEODebra MortonAssociate Software DeveloperErika WellsSoftware Development Team LeadEduardo RamirezManager`; + + this._tableData = `ref="A1:C5" totalsRowShown="0"> + `; + + this._worksheetData = ` + + + + 01263475687810910`; + + return this.createData(); + } + + public get personJobHoursDataPerformance() { + this._sharedStringsData = + `count="18" uniqueCount="15">IDNameJobTitleWorkingHoursHireDatePerformanceCasey HoustonVice President2017-06-19T11:43:07.714Z[object Object],[object Object],[object Object],[object Object]Gilberto ToddDirector2015-12-18T11:23:17.714ZTanya Bennett2005-11-18T11:23:17.714Z`; + + this._tableData = `ref="A1:F4" totalsRowShown="0"> + `; + + this._worksheetData = ` + + + + 012345167489210116129313118149`; + + return this.createData(); + } + + public get hireDate() { + this._sharedStringsData = + `count="1" uniqueCount="1">HireDate`; + + this._tableData = `ref="A1:A6" totalsRowShown="0"> + `; + + this._worksheetData = ` + + + + 02008-04-20T00:00:002015-12-08T00:00:002012-07-30T00:00:002010-02-05T00:00:002020-05-17T00:00:00`; + + return this.createData(); + } + + public get exportGroupedData() { + this._sharedStringsData = + `count="29" uniqueCount="20">ModelEditionBrand: BMW (2)Price: 150000 (1)M5CompetitionPrice: 100000 (1)PerformanceBrand: Tesla (3)RoadsterPrice: 75000 (1)Model SSportPrice: 65000 (1)BaseBrand: VW (3)ArteonR LineBusinessPassat`; + + this._tableData = + `ref="A1:B20" totalsRowShown="0"> + `; + + this._worksheetData = + ` + + + + + 01234564786971011121311141561617101618131918`; + + return this.createData(); + } + + public get exportGroupedDataWithCollapsedRows() { + this._sharedStringsData = + `count="29" uniqueCount="20">ModelEditionBrand: BMW (2)Price: 150000 (1)M5CompetitionPrice: 100000 (1)PerformanceBrand: Tesla (3)RoadsterPrice: 75000 (1)Model SSportPrice: 65000 (1)BaseBrand: VW (3)ArteonR LineBusinessPassat`; + + this._tableData = + `ref="A1:B20" totalsRowShown="0"> + `; + + this._worksheetData = + ` + + + + + 012345681561617101618131918`; + + return this.createData(); + } + + public get exportGroupedDataWithIgnoreSorting() { + this._sharedStringsData = + `count="22" uniqueCount="17">PriceModelEditionBrand: Tesla (3)Model SSportRoadsterPerformanceBaseBrand: BMW (2)M5CompetitionBrand: VW (3)ArteonBusinessPassatR Line`; + + this._tableData = + `ref="A1:C12" totalsRowShown="0"> + `; + + this._worksheetData = + ` + + + + + 0123750004510000067650004891500001011100000107127500013146500015141000001316`; + + return this.createData(); + } + + public get exportGroupedDataWithIgnoreFiltering() { + this._sharedStringsData = + `count="22" uniqueCount="17">PriceModelEditionBrand: BMW (2)M5CompetitionPerformanceBrand: Tesla (3)Model SSportRoadsterBaseBrand: VW (3)ArteonBusinessPassatR Line`; + + this._tableData = + `ref="A1:C12" totalsRowShown="0"> + `; + + this._worksheetData = + ` + + + + + 012315000045100000467750008910000010665000811127500013146500015141000001316`; + + return this.createData(); + } + + public get exportGroupedDataWithIgnoreGrouping() { + this._sharedStringsData = + `count="19" uniqueCount="14">PriceModelEditionM5CompetitionPerformanceModel SSportRoadsterBaseArteonBusinessPassatR Line`; + + this._tableData = + `ref="A1:C9" totalsRowShown="0"> + `; + + this._worksheetData = + ` + + + + 012150000341000003575000671000008565000697500010116500012111000001013`; + + return this.createData(); + } + + public get exportHierarchicalData() { + this._sharedStringsData = + `count="106" uniqueCount="57">ArtistDebutGrammyNominationsGrammyAwardsNaomí YepesAlbumLaunch DateBillboard ReviewUS Billboard 200Pushing up daisiesNo.TitleReleasedGenreWood Shavifdsafdsafsangs Forever*fdasfsaWood Shavifdsafdsafsavngs Forever*vxzvczxWfdsafsaings Forever*fdsacewwwqwqWood Shavings Forever*rewqrqcxzPushing up daisies - DeluxeWood Shavings Forever - RemixPunkUtopiaSANTORINIHip-HopHEARTBEATOVERSEASWish You Were HereZoomDo You?No PhotosTourStarted onLocationHeadlinerFaithful TourSep 12WorldwideNOCountryTickets SoldAttendantsBelgiumUSABabila EbwéléFahrenheitShow OutMood SwingsScenarioAstroworldJul 21BulgariaRomaniaChloe`; + + this._worksheetData = + ` + + + + 0123420116047200901156201531`; + + return this.createData(); + } + + public get exportHierarchicalDataWithColumnWidth() { + this._sharedStringsData = + `count="106" uniqueCount="57">ArtistDebutGrammyNominationsGrammyAwardsNaomí YepesAlbumLaunch DateBillboard ReviewUS Billboard 200Pushing up daisiesNo.TitleReleasedGenreWood Shavifdsafdsafsangs Forever*fdasfsaWood Shavifdsafdsafsavngs Forever*vxzvczxWfdsafsaings Forever*fdsacewwwqwqWood Shavings Forever*rewqrqcxzPushing up daisies - DeluxeWood Shavings Forever - RemixPunkUtopiaSANTORINIHip-HopHEARTBEATOVERSEASWish You Were HereZoomDo You?No PhotosTourStarted onLocationHeadlinerFaithful TourSep 12WorldwideNOCountryTickets SoldAttendantsBelgiumUSABabila EbwéléFahrenheitShow OutMood SwingsScenarioAstroworldJul 21BulgariaRomaniaChloe`; + + this._worksheetData = + ` + + + + 0123420116047200901156201531`; + + return this.createData(); + } + + public get exportHierarchicalDataWithExpandedRows() { + this._sharedStringsData = + `count="106" uniqueCount="57">ArtistDebutGrammyNominationsGrammyAwardsNaomí YepesAlbumLaunch DateBillboard ReviewUS Billboard 200Pushing up daisiesNo.TitleReleasedGenreWood Shavifdsafdsafsangs Forever*fdasfsaWood Shavifdsafdsafsavngs Forever*vxzvczxWfdsafsaings Forever*fdsacewwwqwqWood Shavings Forever*rewqrqcxzPushing up daisies - DeluxeWood Shavings Forever - RemixPunkUtopiaSANTORINIHip-HopHEARTBEATOVERSEASWish You Were HereZoomDo You?No PhotosTourStarted onLocationHeadlinerFaithful TourSep 12WorldwideNOCountryTickets SoldAttendantsBelgiumUSABabila EbwéléFahrenheitShow OutMood SwingsScenarioAstroworldJul 21BulgariaRomaniaChloe`; + + this._worksheetData = + ` + + + + 01234201160567892000-05-31T00:00:008642222001-05-31T00:00:00122252021-12-19T00:00:0011101112131262021-12-19T00:00:00272282021-12-19T00:00:00273292021-12-19T00:00:0027302020-07-17T00:00:00533435363738394041424344451000010000461923001865233839404138394041383940414720090115678482000-05-31T00:00:008642343536375253404142434454250001982255650216332056201531`; + + return this.createData(); + } + + public get exportSortedHierarchicalData() { + this._sharedStringsData = + `count="106" uniqueCount="57">ArtistDebutGrammyNominationsGrammyAwardsNaomí YepesAlbumLaunch DateBillboard ReviewUS Billboard 200Pushing up daisiesNo.TitleReleasedGenreWood Shavifdsafdsafsangs Forever*fdasfsaWood Shavifdsafdsafsavngs Forever*vxzvczxWfdsafsaings Forever*fdsacewwwqwqWood Shavings Forever*rewqrqcxzPushing up daisies - DeluxeWood Shavings Forever - RemixPunkUtopiaSANTORINIHip-HopHEARTBEATOVERSEASWish You Were HereZoomDo You?No PhotosTourStarted onLocationHeadlinerFaithful TourSep 12WorldwideNOCountryTickets SoldAttendantsBelgiumUSAChloeBabila EbwéléFahrenheitShow OutMood SwingsScenarioAstroworldJul 21BulgariaRomania`; + + this._worksheetData = + ` + + + + 0123420116047201531482009011`; + + return this.createData(); + } + + public get exportFilteredHierarchicalData() { + this._sharedStringsData = + `count="33" uniqueCount="31">ArtistDebutGrammyNominationsGrammyAwardsBabila EbwéléAlbumLaunch DateBillboard ReviewUS Billboard 200FahrenheitNo.TitleReleasedGenreShow OutHip-HopMood SwingsScenarioTourStarted onLocationHeadlinerAstroworldJul 21WorldwideNOCountryTickets SoldAttendantsBulgariaRomania`; + + this._worksheetData = + ` + + + + 012342009011`; + + return this.createData(); + } + + public get exportHierarchicalDataWithMultiColumnHeaders() { + this._sharedStringsData = + `count="174" uniqueCount="115">CustomerIDGeneral InformationAddress InformationCompanyNamePersonal DetailsLocationContact InformationContactNameContactTitleAddressCityPostalCodeCountryPhoneFaxAlfreds FutterkisteMaria AndersSales RepresentativeObere Str. 57Berlin12209Germany030-0074321030-0076545Ana Trujillo Emparedados y heladosAna TrujilloOwnerAvda. de la Constitución 2222México D.F.05021Mexico(5) 555-4729(5) 555-3745Antonio Moreno TaqueríaAntonio MorenoMataderos 231205023(5) 555-3932Comércio MineiroPedro AfonsoSales AssociateAv. dos Lusíadas, 23Sao Paulo05432-043Brazil(11) 555-7647Consolidated HoldingsElizabeth BrownBerkeley Gardens 12 BreweryLondonWX1 6LTUK(171) 555-2282(171) 555-9199Drachenblut DelikatessenSven OttliebOrder AdministratorWalserweg 21Aachen520660241-0391230241-059428Du monde entierJanine Labrune67, rue des Cinquante OtagesNantes44000France40.67.88.8840.67.89.89FISSA Fabrica Inter. Salchichas S.A.Diego RoelAccounting ManagerC/ Moralzarzal, 86Madrid28034Spain(91) 555 94 44(91) 555 55 93Folies gourmandesMartine RancéAssistant Sales Agent184, chaussée de TournaiLille5900020.16.10.1620.16.10.17Folk och fä HBMaria LarssonÅkergatan 24BräckeS-844 67Sweden0695-34 67 21FrankenversandPeter FrankenMarketing ManagerBerliner Platz 43München80805089-0877310089-0877451France restaurationCarine Schmitt54, rue Royale40.32.21.2140.32.21.20Franchi S.p.A.Paolo AccortiVia Monte Bianco 34Torino10100Italy011-4988260011-4988261`; + + this._worksheetData = + ` + + + + 01234567891011121314151617181920212223383940414243444570717273747576777810710817109110111112113114 `; + + return this.createData(); + } + + public get exportHierarchicalDataWithCollapsibleMCH() { + this._sharedStringsData = + `count="53" uniqueCount="38">IDLocationCityALFKIBerlinCompanyNamePersonal DetailsContactNameContactTitleAna Trujillo Emparedados y heladosAna TrujilloOwnerAntonio Moreno TaqueríaAntonio MorenoCOMMISao PauloConsolidated HoldingsElizabeth BrownSales RepresentativeDrachenblut DelikatessenSven OttliebOrder AdministratorDu monde entierJanine LabruneFISSAMadridFolies gourmandesMartine RancéAssistant Sales AgentFolk och fä HBMaria LarssonFrankenversandPeter FrankenMarketing ManagerFrance restaurationCarine SchmittFRANSTorino`; + + this._worksheetData = + ` + + + + 012345657891011121311141524253637 `; + return this.createData(); + } + + public get exportMultiColumnHeadersData() { + this._sharedStringsData = + `count="195" uniqueCount="162">IDGeneral InformationAddress InformationPersonal DetailsLocationContact InformationContactNameContactTitleCountryPhoneFaxPostalCodeALFKIMaria AndersSales RepresentativeGermany030-0074321030-007654512209ANATRAna TrujilloOwnerMexico(5) 555-4729(5) 555-374505021ANTONAntonio Moreno(5) 555-393205023AROUTThomas HardyUK(171) 555-7788(171) 555-6750WA1 1DPBERGSChristina BerglundOrder AdministratorSweden0921-12 34 650921-12 34 67S-958 22BLAUSHanna Moos0621-084600621-0892468306BLONPFrédérique CiteauxMarketing ManagerFrance88.60.15.3188.60.15.3267000BOLIDMartín SommerSpain(91) 555 22 82(91) 555 91 9928023BONAPLaurence Lebihan91.24.45.4091.24.45.4113008BOTTMElizabeth LincolnAccounting ManagerCanada(604) 555-4729(604) 555-3745T2F 8M4BSBEVVictoria Ashworth(171) 555-1212EC2 5NTCACTUPatricio SimpsonSales AgentArgentina(1) 135-5555(1) 135-48921010CENTCFrancisco Chang(5) 555-3392(5) 555-729305022CHOPSYang WangSwitzerland0452-0765453012COMMIPedro AfonsoSales AssociateBrazil(11) 555-764705432-043CONSHElizabeth Brown(171) 555-2282(171) 555-9199WX1 6LTDRACDSven Ottlieb0241-0391230241-05942852066DUMONJanine Labrune40.67.88.8840.67.89.8944000EASTCAnn Devon(171) 555-0297(171) 555-3373WX3 6FWERNSHRoland MendelSales ManagerAustria7675-34257675-34268010FAMIAAria CruzMarketing Assistant(11) 555-985705442-030FISSADiego Roel(91) 555 94 44(91) 555 55 9328034FOLIGMartine RancéAssistant Sales Agent20.16.10.1620.16.10.1759000FOLKOMaria Larsson0695-34 67 21S-844 67FRANKPeter Franken089-0877310089-087745180805FRANRCarine Schmitt40.32.21.2140.32.21.20FRANSPaolo AccortiItaly011-4988260011-498826110100`; + + this._worksheetData = + ` + + + + 01234567891011121314151617181920212223242526272122282930311432333435363738394041424344141545464748495051525354555621575859606162215163646566676869707172737414327576777879808182838485502286878889902191929394959697989910010114321021031041051063815107108109110111215111211311411511679321171181191201211221231241251261271281299713013113213368571341351361371381395114014114214314421391451461471485015149150151152153505115415511415615714158159160161 + `; + + return this.createData(); + } + + public get exportThreeLevelsOfMultiColumnHeadersWithTwoRowsData() { + this._sharedStringsData = + `count="26" uniqueCount="26">IDGeneral InformationAddress InformationPersonal DetailsLocationContact InformationContactNameContactTitleCountryPhoneFaxPostalCodeALFKIMaria AndersSales RepresentativeGermany030-0074321030-007654512209ANATRAna TrujilloOwnerMexico(5) 555-4729(5) 555-374505021`; + + this._worksheetData = + ` + + + + 012345678910111213141516171819202122232425 + `; + + return this.createData(); + } + + public get exportMultiColumnHeadersWithGroupedData() { + this._sharedStringsData = + `count="177" uniqueCount="144">General InformationAddress InformationPersonal DetailsLocationContact InformationContactNameContactTitleCountryPhoneFaxPostalCodeContactTitle: Accounting Manager (2)Elizabeth LincolnAccounting ManagerCanada(604) 555-4729(604) 555-3745T2F 8M4Diego RoelSpain(91) 555 94 44(91) 555 55 9328034ContactTitle: Assistant Sales Agent (1)Martine RancéAssistant Sales AgentFrance20.16.10.1620.16.10.1759000ContactTitle: Marketing Assistant (1)Aria CruzMarketing AssistantBrazil(11) 555-985705442-030ContactTitle: Marketing Manager (4)Frédérique CiteauxMarketing Manager88.60.15.3188.60.15.3267000Francisco ChangMexico(5) 555-3392(5) 555-729305022Peter FrankenGermany089-0877310089-087745180805Carine Schmitt40.32.21.2140.32.21.2044000ContactTitle: Order Administrator (2)Christina BerglundOrder AdministratorSweden0921-12 34 650921-12 34 67S-958 22Sven Ottlieb0241-0391230241-05942852066ContactTitle: Owner (7)Ana TrujilloOwner(5) 555-4729(5) 555-374505021Antonio Moreno(5) 555-393205023Martín Sommer(91) 555 22 82(91) 555 91 9928023Laurence Lebihan91.24.45.4091.24.45.4113008Yang WangSwitzerland0452-0765453012Janine Labrune40.67.88.8840.67.89.89Maria Larsson0695-34 67 21S-844 67ContactTitle: Sales Agent (2)Patricio SimpsonSales AgentArgentina(1) 135-5555(1) 135-48921010Ann DevonUK(171) 555-0297(171) 555-3373WX3 6FWContactTitle: Sales Associate (1)Pedro AfonsoSales Associate(11) 555-764705432-043ContactTitle: Sales Manager (1)Roland MendelSales ManagerAustria7675-34257675-34268010ContactTitle: Sales Representative (6)Maria AndersSales Representative030-0074321030-007654512209Thomas Hardy(171) 555-7788(171) 555-6750WA1 1DPHanna Moos0621-084600621-0892468306Victoria Ashworth(171) 555-1212EC2 5NTElizabeth Brown(171) 555-2282(171) 555-9199WX1 6LTPaolo AccortiItaly011-4988260011-498826110100`; + + this._worksheetData = + ` + + + + + 012345678910111213141516171813192021222324252627282930313233343536373826394041423843444546473848495051523826535455565758596061626358486465666768694370717273694374757669197778798069268182838469858687886926899055916959929394959697989910010196102103104105106107108331091101111121131141151161171181191204812112212312412010212512612712812048129130131132120102133134135120102136137138139120140141142143 `; + + return this.createData(); + } + + public get exportFrozenMultiColumnHeadersData() { + this._sharedStringsData = + `count="195" uniqueCount="162">IDGeneral InformationAddress InformationPersonal DetailsLocationContact InformationContactNameContactTitleCountryPhoneFaxPostalCodeALFKIMaria AndersSales RepresentativeGermany030-0074321030-007654512209ANATRAna TrujilloOwnerMexico(5) 555-4729(5) 555-374505021ANTONAntonio Moreno(5) 555-393205023AROUTThomas HardyUK(171) 555-7788(171) 555-6750WA1 1DPBERGSChristina BerglundOrder AdministratorSweden0921-12 34 650921-12 34 67S-958 22BLAUSHanna Moos0621-084600621-0892468306BLONPFrédérique CiteauxMarketing ManagerFrance88.60.15.3188.60.15.3267000BOLIDMartín SommerSpain(91) 555 22 82(91) 555 91 9928023BONAPLaurence Lebihan91.24.45.4091.24.45.4113008BOTTMElizabeth LincolnAccounting ManagerCanada(604) 555-4729(604) 555-3745T2F 8M4BSBEVVictoria Ashworth(171) 555-1212EC2 5NTCACTUPatricio SimpsonSales AgentArgentina(1) 135-5555(1) 135-48921010CENTCFrancisco Chang(5) 555-3392(5) 555-729305022CHOPSYang WangSwitzerland0452-0765453012COMMIPedro AfonsoSales AssociateBrazil(11) 555-764705432-043CONSHElizabeth Brown(171) 555-2282(171) 555-9199WX1 6LTDRACDSven Ottlieb0241-0391230241-05942852066DUMONJanine Labrune40.67.88.8840.67.89.8944000EASTCAnn Devon(171) 555-0297(171) 555-3373WX3 6FWERNSHRoland MendelSales ManagerAustria7675-34257675-34268010FAMIAAria CruzMarketing Assistant(11) 555-985705442-030FISSADiego Roel(91) 555 94 44(91) 555 55 9328034FOLIGMartine RancéAssistant Sales Agent20.16.10.1620.16.10.1759000FOLKOMaria Larsson0695-34 67 21S-844 67FRANKPeter Franken089-0877310089-087745180805FRANRCarine Schmitt40.32.21.2140.32.21.20FRANSPaolo AccortiItaly011-4988260011-498826110100`; + + this._worksheetData = + ` + + + + + + 01234567891011121314151617181920212223242526272122282930311432333435363738394041424344141545464748495051525354555621575859606162215163646566676869707172737414327576777879808182838485502286878889902191929394959697989910010114321021031041051063815107108109110111215111211311411511679321171181191201211221231241251261271281299713013113213368571341351361371381395114014114214314421391451461471485015149150151152153505115415511415615714158159160161 + `; + + return this.createData(); + } + + public get exportMultiColumnHeadersDataWithMovedColumn() { + this._sharedStringsData = + `count="195" uniqueCount="162">General InformationIDAddress InformationPersonal DetailsLocationContact InformationContactNameContactTitleCountryPhoneFaxPostalCodeMaria AndersSales RepresentativeALFKIGermany030-0074321030-007654512209Ana TrujilloOwnerANATRMexico(5) 555-4729(5) 555-374505021Antonio MorenoANTON(5) 555-393205023Thomas HardyAROUTUK(171) 555-7788(171) 555-6750WA1 1DPChristina BerglundOrder AdministratorBERGSSweden0921-12 34 650921-12 34 67S-958 22Hanna MoosBLAUS0621-084600621-0892468306Frédérique CiteauxMarketing ManagerBLONPFrance88.60.15.3188.60.15.3267000Martín SommerBOLIDSpain(91) 555 22 82(91) 555 91 9928023Laurence LebihanBONAP91.24.45.4091.24.45.4113008Elizabeth LincolnAccounting ManagerBOTTMCanada(604) 555-4729(604) 555-3745T2F 8M4Victoria AshworthBSBEV(171) 555-1212EC2 5NTPatricio SimpsonSales AgentCACTUArgentina(1) 135-5555(1) 135-48921010Francisco ChangCENTC(5) 555-3392(5) 555-729305022Yang WangCHOPSSwitzerland0452-0765453012Pedro AfonsoSales AssociateCOMMIBrazil(11) 555-764705432-043Elizabeth BrownCONSH(171) 555-2282(171) 555-9199WX1 6LTSven OttliebDRACD0241-0391230241-05942852066Janine LabruneDUMON40.67.88.8840.67.89.8944000Ann DevonEASTC(171) 555-0297(171) 555-3373WX3 6FWRoland MendelSales ManagerERNSHAustria7675-34257675-34268010Aria CruzMarketing AssistantFAMIA(11) 555-985705442-030Diego RoelFISSA(91) 555 94 44(91) 555 55 9328034Martine RancéAssistant Sales AgentFOLIG20.16.10.1620.16.10.1759000Maria LarssonFOLKO0695-34 67 21S-844 67Peter FrankenFRANK089-0877310089-087745180805Carine SchmittFRANR40.32.21.2140.32.21.20Paolo AccortiFRANSItaly011-4988260011-498826110100`; + + this._worksheetData = + ` + + + + 01234567891011121314151617181920212223242526202722282930133132333435363738394041424313441545464748495051525354552056575859606120625163646566676869707172731374327576777879808182838449852286878889209091929394959697989910013101321021031041053710615107108109110201115111211311411578116321171181191201211221231241251261271281299713013113267133571341351361371381395114014114214320144391451461474914815149150151152491535115415511415613157158159160161 + `; + + return this.createData(); + } + + public get exportMultiColumnHeadersDataWithHiddenColumn() { + this._sharedStringsData = + `count="167" uniqueCount="134">General InformationAddress InformationPersonal DetailsLocationContact InformationContactNameContactTitleCountryPhoneFaxPostalCodeMaria AndersSales RepresentativeGermany030-0074321030-007654512209Ana TrujilloOwnerMexico(5) 555-4729(5) 555-374505021Antonio Moreno(5) 555-393205023Thomas HardyUK(171) 555-7788(171) 555-6750WA1 1DPChristina BerglundOrder AdministratorSweden0921-12 34 650921-12 34 67S-958 22Hanna Moos0621-084600621-0892468306Frédérique CiteauxMarketing ManagerFrance88.60.15.3188.60.15.3267000Martín SommerSpain(91) 555 22 82(91) 555 91 9928023Laurence Lebihan91.24.45.4091.24.45.4113008Elizabeth LincolnAccounting ManagerCanada(604) 555-4729(604) 555-3745T2F 8M4Victoria Ashworth(171) 555-1212EC2 5NTPatricio SimpsonSales AgentArgentina(1) 135-5555(1) 135-48921010Francisco Chang(5) 555-3392(5) 555-729305022Yang WangSwitzerland0452-0765453012Pedro AfonsoSales AssociateBrazil(11) 555-764705432-043Elizabeth Brown(171) 555-2282(171) 555-9199WX1 6LTSven Ottlieb0241-0391230241-05942852066Janine Labrune40.67.88.8840.67.89.8944000Ann Devon(171) 555-0297(171) 555-3373WX3 6FWRoland MendelSales ManagerAustria7675-34257675-34268010Aria CruzMarketing Assistant(11) 555-985705442-030Diego Roel(91) 555 94 44(91) 555 55 9328034Martine RancéAssistant Sales Agent20.16.10.1620.16.10.1759000Maria Larsson0695-34 67 21S-844 67Peter Franken089-0877310089-087745180805Carine Schmitt40.32.21.2140.32.21.20Paolo AccortiItaly011-4988260011-498826110100`; + + this._worksheetData = + ` + + + + 0123456789101112131415161718192021222318192425261227282930313233343536371213383940414243444546471848495051521843535455565758596061621227636465666768697071421972737475187677787980818283841227858687883213899091921843939495966627979899100101102103104105106107811081091105748111112113114115431161171181191833120121122421312312412512642431271289512912130131132133 + `; + + return this.createData(); + } + + public get exportMultiColumnHeadersDataWithPinnedColumn() { + this._sharedStringsData = + `count="195" uniqueCount="162">General InformationIDAddress InformationPersonal DetailsLocationContact InformationContactNameContactTitleCountryPhoneFaxPostalCodeMaria AndersSales RepresentativeALFKIGermany030-0074321030-007654512209Ana TrujilloOwnerANATRMexico(5) 555-4729(5) 555-374505021Antonio MorenoANTON(5) 555-393205023Thomas HardyAROUTUK(171) 555-7788(171) 555-6750WA1 1DPChristina BerglundOrder AdministratorBERGSSweden0921-12 34 650921-12 34 67S-958 22Hanna MoosBLAUS0621-084600621-0892468306Frédérique CiteauxMarketing ManagerBLONPFrance88.60.15.3188.60.15.3267000Martín SommerBOLIDSpain(91) 555 22 82(91) 555 91 9928023Laurence LebihanBONAP91.24.45.4091.24.45.4113008Elizabeth LincolnAccounting ManagerBOTTMCanada(604) 555-4729(604) 555-3745T2F 8M4Victoria AshworthBSBEV(171) 555-1212EC2 5NTPatricio SimpsonSales AgentCACTUArgentina(1) 135-5555(1) 135-48921010Francisco ChangCENTC(5) 555-3392(5) 555-729305022Yang WangCHOPSSwitzerland0452-0765453012Pedro AfonsoSales AssociateCOMMIBrazil(11) 555-764705432-043Elizabeth BrownCONSH(171) 555-2282(171) 555-9199WX1 6LTSven OttliebDRACD0241-0391230241-05942852066Janine LabruneDUMON40.67.88.8840.67.89.8944000Ann DevonEASTC(171) 555-0297(171) 555-3373WX3 6FWRoland MendelSales ManagerERNSHAustria7675-34257675-34268010Aria CruzMarketing AssistantFAMIA(11) 555-985705442-030Diego RoelFISSA(91) 555 94 44(91) 555 55 9328034Martine RancéAssistant Sales AgentFOLIG20.16.10.1620.16.10.1759000Maria LarssonFOLKO0695-34 67 21S-844 67Peter FrankenFRANK089-0877310089-087745180805Carine SchmittFRANR40.32.21.2140.32.21.20Paolo AccortiFRANSItaly011-4988260011-498826110100`; + + this._worksheetData = + ` + + + + 01234567891011121314151617181920212223242526202722282930133132333435363738394041424313441545464748495051525354552056575859606120625163646566676869707172731374327576777879808182838449852286878889209091929394959697989910013101321021031041053710615107108109110201115111211311411578116321171181191201211221231241251261271281299713013113267133571341351361371381395114014114214320144391451461474914815149150151152491535115415511415613157158159160161 + `; + + return this.createData(); + } + + public get exportCollapsedAndExpandedMultiColumnHeadersData() { + this._sharedStringsData = + `count="198" uniqueCount="188">IDGeneral InformationAddress InformationCompanyNameLocationContact InformationRegionCityAddressPhoneFaxPostalCodeALFKIAlfreds FutterkisteBerlinObere Str. 57030-0074321030-007654512209ANATRAna Trujillo Emparedados y heladosMéxico D.F.Avda. de la Constitución 2222(5) 555-4729(5) 555-374505021ANTONAntonio Moreno TaqueríaMataderos 2312(5) 555-393205023AROUTAround the HornLondon120 Hanover Sq.(171) 555-7788(171) 555-6750WA1 1DPBERGSBerglunds snabbköpLuleåBerguvsvägen 80921-12 34 650921-12 34 67S-958 22BLAUSBlauer See DelikatessenMannheimForsterstr. 570621-084600621-0892468306BLONPBlondesddsl père et filsStrasbourg24, place Kléber88.60.15.3188.60.15.3267000BOLIDBólido Comidas preparadasMadridC/ Araquil, 67(91) 555 22 82(91) 555 91 9928023BONAPBon app'Marseille12, rue des Bouchers91.24.45.4091.24.45.4113008BOTTMBottom-Dollar MarketsBCTsawassen23 Tsawassen Blvd.(604) 555-4729(604) 555-3745T2F 8M4BSBEVB's BeveragesFauntleroy Circus(171) 555-1212EC2 5NTCACTUCactus Comidas para llevarBuenos AiresCerrito 333(1) 135-5555(1) 135-48921010CENTCCentro comercial MoctezumaSierras de Granada 9993(5) 555-3392(5) 555-729305022CHOPSChop-suey ChineseBernHauptstr. 290452-0765453012COMMIComércio MineiroSPSao PauloAv. dos Lusíadas, 23(11) 555-764705432-043CONSHConsolidated HoldingsBerkeley Gardens 12 Brewery(171) 555-2282(171) 555-9199WX1 6LTDRACDDrachenblut DelikatessenAachenWalserweg 210241-0391230241-05942852066DUMONDu monde entierNantes67, rue des Cinquante Otages40.67.88.8840.67.89.8944000EASTCEastern Connection35 King George(171) 555-0297(171) 555-3373WX3 6FWERNSHErnst HandelGrazKirchgasse 67675-34257675-34268010FAMIAFamilia ArquibaldoRua Orós, 92(11) 555-985705442-030FISSAFISSA Fabrica Inter. Salchichas S.A.C/ Moralzarzal, 86(91) 555 94 44(91) 555 55 9328034FOLIGFolies gourmandesLille184, chaussée de Tournai20.16.10.1620.16.10.1759000FOLKOFolk och fä HBBräckeÅkergatan 240695-34 67 21S-844 67FRANKFrankenversandMünchenBerliner Platz 43089-0877310089-087745180805FRANRFrance restauration54, rue Royale40.32.21.2140.32.21.20FRANSFranchi S.p.A.TorinoVia Monte Bianco 34011-4988260011-498826110100`; + + this._worksheetData = + ` + + + + 0123456789101112131415161718192021222324252627212829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818233838485868788899091929394219596979899100101102103104105106107108109110111112113331141151161171181191201211221231241251261271281291301311321333313413513613713813914014114214314414514610710814714814915015161152153154155156157158159160161162163164165166167168169170171172173174175176177127178179180131181182183184185186187 + `; + + return this.createData(); + } + + public get exportMultiColumnHeadersDataWithIgnoreColumnVisibility() { + this._sharedStringsData = + `count="283" uniqueCount="241">IDGeneral InformationAddress InformationCompanyNamePersonal DetailsLocationContact InformationContactNameContactTitleCountryRegionCityAddressPhoneFaxPostalCodeALFKIAlfreds FutterkisteMaria AndersSales RepresentativeGermanyBerlinObere Str. 57030-0074321030-007654512209ANATRAna Trujillo Emparedados y heladosAna TrujilloOwnerMexicoMéxico D.F.Avda. de la Constitución 2222(5) 555-4729(5) 555-374505021ANTONAntonio Moreno TaqueríaAntonio MorenoMataderos 2312(5) 555-393205023AROUTAround the HornThomas HardyUKLondon120 Hanover Sq.(171) 555-7788(171) 555-6750WA1 1DPBERGSBerglunds snabbköpChristina BerglundOrder AdministratorSwedenLuleåBerguvsvägen 80921-12 34 650921-12 34 67S-958 22BLAUSBlauer See DelikatessenHanna MoosMannheimForsterstr. 570621-084600621-0892468306BLONPBlondesddsl père et filsFrédérique CiteauxMarketing ManagerFranceStrasbourg24, place Kléber88.60.15.3188.60.15.3267000BOLIDBólido Comidas preparadasMartín SommerSpainMadridC/ Araquil, 67(91) 555 22 82(91) 555 91 9928023BONAPBon app'Laurence LebihanMarseille12, rue des Bouchers91.24.45.4091.24.45.4113008BOTTMBottom-Dollar MarketsElizabeth LincolnAccounting ManagerCanadaBCTsawassen23 Tsawassen Blvd.(604) 555-4729(604) 555-3745T2F 8M4BSBEVB's BeveragesVictoria AshworthFauntleroy Circus(171) 555-1212EC2 5NTCACTUCactus Comidas para llevarPatricio SimpsonSales AgentArgentinaBuenos AiresCerrito 333(1) 135-5555(1) 135-48921010CENTCCentro comercial MoctezumaFrancisco ChangSierras de Granada 9993(5) 555-3392(5) 555-729305022CHOPSChop-suey ChineseYang WangSwitzerlandBernHauptstr. 290452-0765453012COMMIComércio MineiroPedro AfonsoSales AssociateBrazilSPSao PauloAv. dos Lusíadas, 23(11) 555-764705432-043CONSHConsolidated HoldingsElizabeth BrownBerkeley Gardens 12 Brewery(171) 555-2282(171) 555-9199WX1 6LTDRACDDrachenblut DelikatessenSven OttliebAachenWalserweg 210241-0391230241-05942852066DUMONDu monde entierJanine LabruneNantes67, rue des Cinquante Otages40.67.88.8840.67.89.8944000EASTCEastern ConnectionAnn Devon35 King George(171) 555-0297(171) 555-3373WX3 6FWERNSHErnst HandelRoland MendelSales ManagerAustriaGrazKirchgasse 67675-34257675-34268010FAMIAFamilia ArquibaldoAria CruzMarketing AssistantRua Orós, 92(11) 555-985705442-030FISSAFISSA Fabrica Inter. Salchichas S.A.Diego RoelC/ Moralzarzal, 86(91) 555 94 44(91) 555 55 9328034FOLIGFolies gourmandesMartine RancéAssistant Sales AgentLille184, chaussée de Tournai20.16.10.1620.16.10.1759000FOLKOFolk och fä HBMaria LarssonBräckeÅkergatan 240695-34 67 21S-844 67FRANKFrankenversandPeter FrankenMünchenBerliner Platz 43089-0877310089-087745180805FRANRFrance restaurationCarine Schmitt54, rue Royale40.32.21.2140.32.21.20FRANSFranchi S.p.A.Paolo AccortiItalyTorinoVia Monte Bianco 34011-4988260011-498826110100`; + + this._worksheetData = + ` + + + + 0123456789101112131415161718192021222324252627282930313233343536373829303139404142434419454647484950515253545556575859606162631920646566676869707172737475767778798081298283848586878889902973919293949596979899100101102103104105106107108109194546110111112113114115116117118119120121122123124125723031126127128129130131132291331341351361371381391401411421431441451461471481491501945461511521531541551561575420158159160161162163164165297316616716816917017117217311645461741751761771781791801811821831841851861871881891901911421431441921931941951961979982831981992002012022032042057320620720820921021121221329552142152162172182192207220221222223224225226227228727316622923023117023223323419235236237238239240 + `; + + return this.createData(); + } + + public get exportMultiColumnHeadersDataWithoutMultiColumnHeaders() { + this._sharedStringsData = + `count="190" uniqueCount="157">IDContactNameContactTitleCountryPhoneFaxPostalCodeALFKIMaria AndersSales RepresentativeGermany030-0074321030-007654512209ANATRAna TrujilloOwnerMexico(5) 555-4729(5) 555-374505021ANTONAntonio Moreno(5) 555-393205023AROUTThomas HardyUK(171) 555-7788(171) 555-6750WA1 1DPBERGSChristina BerglundOrder AdministratorSweden0921-12 34 650921-12 34 67S-958 22BLAUSHanna Moos0621-084600621-0892468306BLONPFrédérique CiteauxMarketing ManagerFrance88.60.15.3188.60.15.3267000BOLIDMartín SommerSpain(91) 555 22 82(91) 555 91 9928023BONAPLaurence Lebihan91.24.45.4091.24.45.4113008BOTTMElizabeth LincolnAccounting ManagerCanada(604) 555-4729(604) 555-3745T2F 8M4BSBEVVictoria Ashworth(171) 555-1212EC2 5NTCACTUPatricio SimpsonSales AgentArgentina(1) 135-5555(1) 135-48921010CENTCFrancisco Chang(5) 555-3392(5) 555-729305022CHOPSYang WangSwitzerland0452-0765453012COMMIPedro AfonsoSales AssociateBrazil(11) 555-764705432-043CONSHElizabeth Brown(171) 555-2282(171) 555-9199WX1 6LTDRACDSven Ottlieb0241-0391230241-05942852066DUMONJanine Labrune40.67.88.8840.67.89.8944000EASTCAnn Devon(171) 555-0297(171) 555-3373WX3 6FWERNSHRoland MendelSales ManagerAustria7675-34257675-34268010FAMIAAria CruzMarketing Assistant(11) 555-985705442-030FISSADiego Roel(91) 555 94 44(91) 555 55 9328034FOLIGMartine RancéAssistant Sales Agent20.16.10.1620.16.10.1759000FOLKOMaria Larsson0695-34 67 21S-844 67FRANKPeter Franken089-0877310089-087745180805FRANRCarine Schmitt40.32.21.2140.32.21.20FRANSPaolo AccortiItaly011-4988260011-498826110100`; + + this._worksheetData = + ` + + + + 012345678910111213141516171819202122161723242526927282930313233343536373839910404142434445464748495051165253545556571646585960616263646566676869927707172737475767778798045178182838485168687888990919293949596927979899100101331010210310410510616461071081091101117427112113114115116117118119120121122123124921251261271286352129130131132133134461351361371381391634140141142143451014414514614714845461491501091511529153154155156`; + + this._tableData = + `ref="A1:G28" totalsRowShown="0"> + `; + + return this.createData(); + } + + public get exportEmptyGridWithMultiColumnHeadersData() { + this._sharedStringsData = + `count="12" uniqueCount="12">IDGeneral InformationAddress InformationPersonal DetailsLocationContact InformationContactNameContactTitleCountryPhoneFaxPostalCode`; + + this._worksheetData = + ` + + + + 01234567891011 `; + + return this.createData(); + } + + public get exportHierarchicalDataWithFrozenHeaders() { + this._sharedStringsData = + `count="106" uniqueCount="57">ArtistDebutGrammyNominationsGrammyAwardsNaomí YepesAlbumLaunch DateBillboard ReviewUS Billboard 200Pushing up daisiesNo.TitleReleasedGenreWood Shavifdsafdsafsangs Forever*fdasfsaWood Shavifdsafdsafsavngs Forever*vxzvczxWfdsafsaings Forever*fdsacewwwqwqWood Shavings Forever*rewqrqcxzPushing up daisies - DeluxeWood Shavings Forever - RemixPunkUtopiaSANTORINIHip-HopHEARTBEATOVERSEASWish You Were HereZoomDo You?No PhotosTourStarted onLocationHeadlinerFaithful TourSep 12WorldwideNOCountryTickets SoldAttendantsBelgiumUSABabila EbwéléFahrenheitShow OutMood SwingsScenarioAstroworldJul 21BulgariaRomaniaChloe`; + + this._worksheetData = + ` + + + + 0123420116047200901156201531`; + + return this.createData(); + } + + public get columnsAddedOnInit() { + this._sharedStringsData = + `count="39" uniqueCount="39">CompanyNameContactNameAddress012Alfreds FutterkisteMaria AndersObere Str. 57Ana Trujillo Emparedados y heladosAna TrujilloAvda. de la Constitución 2222Antonio Moreno TaqueríaAntonio MorenoMataderos 2312Around the HornThomas Hardy120 Hanover Sq.Berglunds snabbköpChristina BerglundBerguvsvägen 8Blauer See DelikatessenHanna MoosForsterstr. 57Blondesddsl père et filsFrédérique Citeaux24, place KléberBólido Comidas preparadasMartín SommerC/ Araquil, 67Bon app'Laurence Lebihan12, rue des BouchersBottom-Dollar MarketsElizabeth Lincoln23 Tsawassen Blvd.B's BeveragesVictoria AshworthFauntleroy Circus`; + + this._tableData = + `ref="A1:F12" totalsRowShown="0"> + `; + + this._worksheetData = + ` + + + + 01234567802500500091011025005000121314025005000151617025005000181920025005000212223025005000242526025005000272829025005000303132025005000333435025005000363738025005000`; + + return this.createData(); + } + + public get exportGridDataWithSpecialCharsInHeaders() { + this._sharedStringsData = + `count="18" uniqueCount="15">ID&<>"'PerformanceCasey HoustonVice President2017-06-19T11:43:07.714Z[object Object],[object Object],[object Object],[object Object]Gilberto ToddDirector2015-12-18T11:23:17.714ZTanya Bennett2005-11-18T11:23:17.714Z`; + + this._tableData = + `ref="A1:F4" totalsRowShown="0"> + `; + + this._worksheetData = + ` + + + + 012345167489210116129313118149`; + + return this.createData(); + } + + public get exportGriWithDateData() { + this._sharedStringsData = + `count="10" uniqueCount="10">NameBirthDateLastLoginMeetingTimeAttendanceRateCasey HoustonGilberto ToddTanya BennettJack SimonCelia Martinez`; + + this._tableData = + `ref="A1:E6" totalsRowShown="0"> + `; + + this._worksheetData = + ` + + + + 0123451990-03-14T00:00:002023-04-28T13:12:362023-07-07T10:30:010.7861985-05-17T00:00:002023-04-14T14:25:232023-07-07T09:35:310.4671987-07-19T00:00:002023-03-23T19:07:132023-07-07T13:10:360.28981995-09-23T00:00:002023-02-27T17:17:412023-07-07T14:50:47191994-11-27T00:00:002023-03-14T01:31:492023-07-07T07:00:170.384`; + + return this.createData(); + } + + public get exportGriWithFormattedColumn() { + this._sharedStringsData = + `count="12" uniqueCount="12">IDNameCasey Houston - Vice PresidentGilberto Todd - DirectorTanya Bennett - DirectorJack Simon - Software DeveloperCelia Martinez - Senior Software DeveloperErma Walsh - CEODebra Morton - Associate Software DeveloperErika Wells - Software Development Team LeadLeslie Hansen - Associate Software DeveloperEduardo Ramirez - Manager`; + + this._tableData = + `ref="A1:B11" totalsRowShown="0"> + `; + + this._worksheetData = + ` + + + + 0112233445566778899101011`; + + return this.createData(); + } + + public get exportHierarchicalDataWithSkippedColumns() { + this._sharedStringsData = + `count="89" uniqueCount="47">ArtistGrammy NominationsGrammy AwardsNaomí YepesLaunch DateUS Billboard 200No.TitleGenreWood Shavifdsafdsafsangs Forever*fdasfsaWood Shavifdsafdsafsavngs Forever*vxzvczxWfdsafsaings Forever*fdsacewwwqwqWood Shavings Forever*rewqrqcxzWood Shavings Forever - RemixPunkSANTORINIHip-HopHEARTBEATOVERSEASZoomDo You?No PhotosTourStarted onLocationHeadlinerFaithful TourSep 12WorldwideNOCountryAttendantsBelgiumUSABabila EbwéléShow OutMood SwingsScenarioAstroworldJul 21BulgariaRomaniaChloe`; + + this._worksheetData = + ` + + + + 012360380114631`; + + return this.createData(); + } + + public get exportHierarchicalDataWithSkippedRows() { + this._sharedStringsData = + `count="7" uniqueCount="7">ArtistDebutGrammy NominationsGrammy AwardsNaomí YepesBabila EbwéléChloe`; + + this._worksheetData = + ` + + + 01234201160520090116201531`; + + return this.createData(); + } + + public get exportMultiColumnHeadersWithSkippedColumnData() { + this._sharedStringsData = + `count="224" uniqueCount="200">CustomerIDGeneral InformationAddress InformationCompanyNamePersonal DetailsLocationContact InformationContactNameAddressCityPostalCodeCountryPhoneFaxAlfreds FutterkisteMaria AndersObere Str. 57Berlin12209Germany030-0074321030-0076545Ana Trujillo Emparedados y heladosAna TrujilloAvda. de la Constitución 2222México D.F.05021Mexico(5) 555-4729(5) 555-3745Antonio Moreno TaqueríaAntonio MorenoMataderos 231205023(5) 555-3932Around the HornThomas Hardy120 Hanover Sq.LondonWA1 1DPUK(171) 555-7788(171) 555-6750Berglunds snabbköpChristina BerglundBerguvsvägen 8LuleåS-958 22Sweden0921-12 34 650921-12 34 67Blauer See DelikatessenHanna MoosForsterstr. 57Mannheim683060621-084600621-08924Blondesddsl père et filsFrédérique Citeaux24, place KléberStrasbourg67000France88.60.15.3188.60.15.32Bólido Comidas preparadasMartín SommerC/ Araquil, 67Madrid28023Spain(91) 555 22 82(91) 555 91 99Bon app'Laurence Lebihan12, rue des BouchersMarseille1300891.24.45.4091.24.45.41Bottom-Dollar MarketsElizabeth Lincoln23 Tsawassen Blvd.TsawassenT2F 8M4Canada(604) 555-4729(604) 555-3745B's BeveragesVictoria AshworthFauntleroy CircusEC2 5NT(171) 555-1212Cactus Comidas para llevarPatricio SimpsonCerrito 333Buenos Aires1010Argentina(1) 135-5555(1) 135-4892Centro comercial MoctezumaFrancisco ChangSierras de Granada 999305022(5) 555-3392(5) 555-7293Chop-suey ChineseYang WangHauptstr. 29Bern3012Switzerland0452-076545Comércio MineiroPedro AfonsoAv. dos Lusíadas, 23Sao Paulo05432-043Brazil(11) 555-7647Consolidated HoldingsElizabeth BrownBerkeley Gardens 12 BreweryWX1 6LT(171) 555-2282(171) 555-9199Drachenblut DelikatessenSven OttliebWalserweg 21Aachen520660241-0391230241-059428Du monde entierJanine Labrune67, rue des Cinquante OtagesNantes4400040.67.88.8840.67.89.89Eastern ConnectionAnn Devon35 King GeorgeWX3 6FW(171) 555-0297(171) 555-3373Ernst HandelRoland MendelKirchgasse 6Graz8010Austria7675-34257675-3426Familia ArquibaldoAria CruzRua Orós, 9205442-030(11) 555-9857FISSA Fabrica Inter. Salchichas S.A.Diego RoelC/ Moralzarzal, 8628034(91) 555 94 44(91) 555 55 93Folies gourmandesMartine Rancé184, chaussée de TournaiLille5900020.16.10.1620.16.10.17Folk och fä HBMaria LarssonÅkergatan 24BräckeS-844 670695-34 67 21FrankenversandPeter FrankenBerliner Platz 43München80805089-0877310089-0877451France restaurationCarine Schmitt54, rue Royale40.32.21.2140.32.21.20Franchi S.p.A.Paolo AccortiVia Monte Bianco 34Torino10100Italy011-4988260011-4988261`; + + this._worksheetData = + ` + + + + 012345678910111213141516171819202135363738394041656667686970717299100101102103104105106 `; + + return this.createData(); + } + + public get exportMultiColumnHeadersDataWithSkippedColumn() { + this._sharedStringsData = + `count="157" uniqueCount="107">CustomerIDGeneral InformationAddress InformationCompanyNamePersonal DetailsLocationContact InformationContactNameAddressCityPostalCodeCountryPhoneFaxAlfreds FutterkisteMaria AndersObere Str. 57Berlin12209Germany030-0074321030-0076545Ana Trujillo Emparedados y heladosAna TrujilloAvda. de la Constitución 2222México D.F.05021Mexico(5) 555-4729(5) 555-3745Antonio Moreno TaqueríaAntonio MorenoMataderos 231205023(5) 555-3932Comércio MineiroPedro AfonsoAv. dos Lusíadas, 23Sao Paulo05432-043Brazil(11) 555-7647Consolidated HoldingsElizabeth BrownBerkeley Gardens 12 BreweryLondonWX1 6LTUK(171) 555-2282(171) 555-9199Drachenblut DelikatessenSven OttliebWalserweg 21Aachen520660241-0391230241-059428Du monde entierJanine Labrune67, rue des Cinquante OtagesNantes44000France40.67.88.8840.67.89.89FISSA Fabrica Inter. Salchichas S.A.Diego RoelC/ Moralzarzal, 86Madrid28034Spain(91) 555 94 44(91) 555 55 93Folies gourmandesMartine Rancé184, chaussée de TournaiLille5900020.16.10.1620.16.10.17Folk och fä HBMaria LarssonÅkergatan 24BräckeS-844 67Sweden0695-34 67 21FrankenversandPeter FrankenBerliner Platz 43München80805089-0877310089-0877451France restaurationCarine Schmitt54, rue Royale40.32.21.2140.32.21.20Franchi S.p.A.Paolo AccortiVia Monte Bianco 34Torino10100Italy011-4988260011-4988261`; + + this._worksheetData = + ` + + + + 012345678910111213141516171819202135363738394041656667686970717299100101102103104105106 `; + + return this.createData(); + } + + public get exportMultiColumnHeadersDataWithSkippedParentMCH() { + this._sharedStringsData = + `count="63" uniqueCount="39">CustomerIDGeneral InformationCompanyNamePersonal DetailsContactNameContactTitleAlfreds FutterkisteMaria AndersSales RepresentativeAna Trujillo Emparedados y heladosAna TrujilloOwnerAntonio Moreno TaqueríaAntonio MorenoComércio MineiroPedro AfonsoSales AssociateConsolidated HoldingsElizabeth BrownDrachenblut DelikatessenSven OttliebOrder AdministratorDu monde entierJanine LabruneFISSA Fabrica Inter. Salchichas S.A.Diego RoelAccounting ManagerFolies gourmandesMartine RancéAssistant Sales AgentFolk och fä HBMaria LarssonFrankenversandPeter FrankenMarketing ManagerFrance restaurationCarine SchmittFranchi S.p.A.Paolo Accorti`; + + this._worksheetData = + ` + + + + 01234567814151624252637388 `; + + return this.createData(); + } + + public get exportMultiColumnHeadersDataWithAllParentsSkipped() { + this._worksheetData = + ` + + `; + + return this.createData(); + } + + public get exportHierarchicalDataWithMultiColumnHeadersOnlyInIsland() { + this._sharedStringsData = + `count="34" uniqueCount="29">CompanyNameAlfreds FutterkisteGeneral InformationAddress InformationPersonal DetailsLocationContact InformationContactNameContactTitleAddressCityPostalCodeCountryPhoneFaxAna Trujillo Emparedados y heladosAna TrujilloOwnerAvda. de la Constitución 2222México D.F.05021Mexico(5) 555-4729(5) 555-3745Antonio Moreno TaqueríaAntonio MorenoMataderos 231205023(5) 555-3932`; + + this._worksheetData = + ` + + + + 01 `; + + return this.createData(); + } + + public get exportEmptyMultiColumnHeadersDataWithExportedHeaders() { + this._sharedStringsData = + `count="15" uniqueCount="15">CustomerIDGeneral InformationAddress InformationCompanyNamePersonal DetailsLocationContact InformationContactNameContactTitleAddressCityPostalCodeCountryPhoneFax`; + + this._worksheetData = + ` + + + 01234567891011121314 `; + + return this.createData(); + } + + public get exportPivotGridData() { + this._sharedStringsData = + `count="38" uniqueCount="23">ClothingBikesAccessoriesComponentsBulgariaUSAUruguay01/01/202102/19/202001/05/201905/12/202001/06/202004/07/202112/08/2021StanleyElisaLydiaDavidJohnLarryWalterUnitsSoldUnitPrice`; + + this._worksheetData = + ` + + + + 14151617181920212221222122212221222122212204728212.81849216.055929649.5761045668.331611683.56251229385.58351324018.13 `; + + return this.createData(); + } + + public get exportPivotGridDataWithHeaders() { + this._sharedStringsData = + `count="41" uniqueCount="26">ProductCategoryClothingBikesAccessoriesComponentsCountryBulgariaUSAUruguayDate01/01/202102/19/202001/05/201905/12/202001/06/202004/07/202112/08/2021StanleyElisaLydiaDavidJohnLarryWalterUnitsSoldUnitPrice`; + + this._worksheetData = + ` + + + + 059171819202122232425242524252425242524252425161028212.811149216.0571229649.5781345668.332814683.56371529385.58471624018.13 `; + + return this.createData(); + } + + public get exportPivotGridDataHorizontal() { + this._sharedStringsData = + `count="41" uniqueCount="26">ProductCategoryClothingBikesAccessoriesComponentsCountryBulgariaUSAUruguayDate01/01/202102/19/202001/05/201905/12/202001/06/202004/07/202112/08/2021StanleyElisaLydiaDavidJohnLarryWalterUnitsSoldUnitPrice`; + + this._worksheetData = + ` + + + + + 059171819202122232425242524252425242524252425161028212.811149216.0571229649.5781345668.332814683.56371529385.58471624018.13 `; + return this.createData(); + } + + public get exportPivotGridHierarchicalData() { + this._sharedStringsData = + `count="40" uniqueCount="19">All CitiesPlovdivNew YorkCiudad de la CostaLondonYokohamaSofiaAllProductsClothingBikesAccessoriesComponentsBulgariaUSUruguayUKJapanUnitsSoldAmount of Sale`; + + this._worksheetData = + ` + + + + + 1213141516171817181718171817180777411509.0229614672.7252431400.5629325074.942404351.282823612.4229614672.7245631158.48968242.081029325074.94114927896.62404351.2172823612.4282823612.422729614672.72829614672.723752431400.56968242.08845631158.484729325074.941029325074.94572404351.2112404351.2674927896.6114927896.6 `; + + return this.createData(); + } + + public get exportPivotGridHierarchicalRowDimensions() { + this._sharedStringsData = + `count="198" uniqueCount="56">All_Srep Code Alts006020024029037041047053056060All_Srep Codes538254256162183103159603604622410419421263110111220231238All_Customers45230578001555780033864VS00862CW9211800185678046621804969950535965049856800332395044928148650596475062088VS05682053968203889110609831057802VS02524JOBSINV_SALES`; + + this._worksheetData = + ` + + + + + 01232545511121733281.02999999993313253.3134158081.9535131745.1136127565.6437198731.6338140424.7539118538.6240116352.7341130413.8742130797.9343137109.544124370.7845110792.434617229.73471176455.664814418.1549118192.1650124663.1451113113.945212497.1153158532.8913113253.313313253.31141158081.9534158081.95151259310.7535131745.1136127565.64161198731.6337198731.63171140424.7538140424.75181234891.3539118538.6240116352.73191130413.8741130413.87201130797.9342130797.93211137109.543137109.5221124370.78` + + `44124370.78231110792.4345110792.4324117229.734617229.732511176455.66471176455.6626114418.154814418.15271118192.1649118192.16281124663.1450124663.14291113113.9451113113.9430112497.115212497.11311158532.8953158532.8921113253.313313253.3113113253.313313253.313113117392.734158081.9535131745.1136127565.64141158081.9534158081.95151259310.7535131745.1136127565.644112139156.3837198731.6338140424.75161198731.6337198731.63171140424.7538140424.75511234891.3539118538.6240116352.73181234891.35` + + `39118538.6240116352.73611130413.8741130413.87191130413.8741130413.87711392278.2099999999942130797.9343137109.544124370.78201130797.9342130797.93211137109.543137109.5221124370.7844124370.788113194477.8245110792.434617229.73471176455.66231110792.4345110792.4324117229.734617229.732511176455.66471176455.6691114418.154814418.1526114418.154814418.151011242855.349118192.1650124663.14271118192.1649118192.16281124663.1450124663.141111374143.9451113113.945212497.1153158532.89291113113.94` + + `51113113.9430112497.115212497.11311158532.8953158532.89 `; + + return this.createData(); + } + + public get exportGridWithSummaries() { + this._sharedStringsData = + `count="86" uniqueCount="36">CityShippedContactTitlePTODaysGRID_LEVEL_COLBerlinSales RepresentativeMéxico D.F.OwnerLondonLuleåOrder AdministratorMannheimStrasbourgMarketing ManagerMadridMarseilleTsawassenAccounting ManagerBuenos AiresSales AgentBernSao PauloSales AssociateAachenNantesGrazSales ManagerMarketing AssistantLilletrueAssistant Sales AgentBräckeMünchenTorino`; + + this._tableData = + `ref="A1:D33" totalsRowShown="0"> + `; + + this._worksheetData = + ` + + + + 01234567200869120869320106723011612150136717014615330166927017691101861960106700206210086152502269270236241701067202561260266916010621902762829023629001661920303132103331912034311524026311526035317180"Count: "&_xlfn.COUNTIF(F2:F28, 0)"Count: "&_xlfn.COUNTIF(F2:F28, 0)"Count: "&_xlfn.COUNTIF(F2:F28, 0)"Count: "&_xlfn.COUNTIF(F2:F28, 0)"""""""""Min: "&_xlfn.MIN(_xlfn.IF(F2:F28=0, D2:D28))"""""""""Max: "&_xlfn.MAX(_xlfn.IF(F2:F28=0, D2:D28))"""""""""Sum: "&_xlfn.SUMIF(F2:F28, 0, D2:D28)"""""""""Avg: "&_xlfn.AVERAGEIF(F2:F28, 0, D2:D28)""` + + return this.createData(); + } + + public get exportTreeGridWithSummaries() { + this._sharedStringsData = + `count="42" uniqueCount="26">IDNameHireDateAgeOnPTOGRID_LEVEL_COLJohn WinchesterfalseMichael LangdonThomas HardytrueMonica ReyesRoland MendelSven OttliebAna SandersLaurence JohnsonElizabeth RichardsTrevor AshworthVictoria LincolnAntonio MorenoYang WangPedro AfonsoPatricio SimpsonFrancisco ChangPeter LewisCasey Harper`; + + this._tableData = + `ref="A1:E39" totalsRowShown="0"> + `; + + this._worksheetData = + ` + + + + + 01234514762008-04-20T00:00:00557047582011-07-03T00:00:00437195792009-07-19T00:00:0029101317112014-09-18T00:00:003171711122015-10-17T00:00:0035102998132009-11-11T00:00:004472"""Count: "&_xlfn.COUNTIF(G6:G7, 2)"Count: "&_xlfn.COUNTIF(G6:G7, 2)"Count: "&_xlfn.COUNTIF(G6:G7, 2)"Count: "&_xlfn.COUNTIF(G6:G7, 2)"""""""Earliest: "&_xlfn.TEXT(_xlfn.MIN(_xlfn.IF(G6:G7=2, C6:C7)), "m/d/yyyy")"Min: "&_xlfn.MIN(_xlfn.IF(G6:G7=2, D6:D7))"""""""""Latest: "&_xlfn.TEXT(_xlfn.MAX(_xlfn.IF(G6:G7=2, C6:C7)), "m/d/yyyy")"Max: "&_xlfn.MAX(_xlfn.IF(G6:G7=2, D6:D7))"""""""""""Sum: "&_xlfn.SUMIF(G6:G7, 2, D6:D7)"""""""""""Avg: "&_xlfn.AVERAGEIF(G6:G7, 2, D6:D7)"""""""Count: "&_xlfn.COUNTIF(G3:G5, 1)"Count: "&_xlfn.COUNTIF(G3:G5, 1)"Count: "&_xlfn.COUNTIF(G3:G5, 1)"Count: "&_xlfn.COUNTIF(G3:G5, 1)"""""""Earliest: "&_xlfn.TEXT(_xlfn.MIN(_xlfn.IF(G3:G5=1, C3:C5)), "m/d/yyyy")"Min: "&_xlfn.MIN(_xlfn.IF(G3:G5=1, D3:D5))"""""""""Latest: "&_xlfn.TEXT(_xlfn.MAX(_xlfn.IF(G3:G5=1, C3:C5)), "m/d/yyyy")"Max: "&_xlfn.MAX(_xlfn.IF(G3:G5=1, D3:D5))"""""""""""Sum: "&_xlfn.SUMIF(G3:G5, 1, D3:D5)"""""""""""Avg: "&_xlfn.AVERAGEIF(G3:G5, 1, D3:D5)""""847142014-02-22T00:00:00427019182014-02-22T00:00:00497015192014-05-04T00:00:0044101"""Count: "&_xlfn.COUNTIF(G23:G23, 1)"Count: "&_xlfn.COUNTIF(G23:G23, 1)"Count: "&_xlfn.COUNTIF(G23:G23, 1)"Count: "&_xlfn.COUNTIF(G23:G23, 1)"""""""Earliest: "&_xlfn.TEXT(_xlfn.MIN(_xlfn.IF(G23:G23=1, C23:C23)), "m/d/yyyy")"Min: "&_xlfn.MIN(_xlfn.IF(G23:G23=1, D23:D23))"""""""""Latest: "&_xlfn.TEXT(_xlfn.MAX(_xlfn.IF(G23:G23=1, C23:C23)), "m/d/yyyy")"Max: "&_xlfn.MAX(_xlfn.IF(G23:G23=1, D23:D23))"""""""""""Sum: "&_xlfn.SUMIF(G23:G23, 1, D23:D23)"""""""""""Avg: "&_xlfn.AVERAGEIF(G23:G23, 1, D23:D23)""""17202010-02-01T00:00:006170"""Count: "&_xlfn.COUNTIF(G2:G34, 0)"Count: "&_xlfn.COUNTIF(G2:G34, 0)"Count: "&_xlfn.COUNTIF(G2:G34, 0)"Count: "&_xlfn.COUNTIF(G2:G34, 0)"""""""Earliest: "&_xlfn.TEXT(_xlfn.MIN(_xlfn.IF(G2:G34=0, C2:C34)), "m/d/yyyy")"Min: "&_xlfn.MIN(_xlfn.IF(G2:G34=0, D2:D34))"""""""""Latest: "&_xlfn.TEXT(_xlfn.MAX(_xlfn.IF(G2:G34=0, C2:C34)), "m/d/yyyy")"Max: "&_xlfn.MAX(_xlfn.IF(G2:G34=0, D2:D34))"""""""""""Sum: "&_xlfn.SUMIF(G2:G34, 0, D2:D34)"""""""""""Avg: "&_xlfn.AVERAGEIF(G2:G34, 0, D2:D34)""""` + + return this.createData(); + } + + public get exportHierarchicalGridWithSummaries() { + this._sharedStringsData = + `count="76" uniqueCount="41">ArtistDebutGrammy NominationsGrammy AwardsGRID_LEVEL_COLNaomí YepesAlbumLaunch DateBillboard ReviewUS Billboard 200Pushing up daisiesNo.TitleReleasedGenreWood Shavifdsafdsafsangs Forever*fdasfsaWood Shavifdsafdsafsavngs Forever*vxzvczxWfdsafsaings Forever*fdsacewwwqwqWood Shavings Forever*rewqrqcxzPushing up daisies - DeluxeWood Shavings Forever - RemixPunkUtopiaSANTORINIHip-HopHEARTBEATOVERSEASWish You Were HereZoomDo You?No PhotosBabila EbwéléFahrenheitShow OutMood SwingsScenarioChloe`; + + this._worksheetData = + ` + + + + 012345201160067894102000-05-31T00:00:00864211112131441152019-06-09T00:00:001622172019-06-09T00:00:001823192019-06-09T00:00:002024212019-06-09T00:00:00222"""Count: "&_xlfn.COUNTIF(I6:I9, 2)""""""232001-05-31T00:00:001221262021-12-19T00:00:00111312020-07-17T00:00:00531"""""Count: "&_xlfn.COUNTIF(I4:I19, 1)"Count: "&_xlfn.COUNTIF(I4:I19, 1)"""""""Min: "&_xlfn.MIN(_xlfn.IF(I4:I19=1, D4:D19))"Min: "&_xlfn.MIN(_xlfn.IF(I4:I19=1, E4:E19))"""""""Max: "&_xlfn.MAX(_xlfn.IF(I4:I19=1, D4:D19))"Max: "&_xlfn.MAX(_xlfn.IF(I4:I19=1, E4:E19))"""""""Sum: "&_xlfn.SUMIF(I4:I19, 1, D4:D19)"Sum: "&_xlfn.SUMIF(I4:I19, 1, E4:E19)"""""""Avg: "&_xlfn.AVERAGEIF(I4:I19, 1, D4:D19)"Avg: "&_xlfn.AVERAGEIF(I4:I19, 1, E4:E19)""352009011067894362000-05-31T00:00:00864211112131441372020-07-17T00:00:002822382020-07-17T00:00:002823392020-07-17T00:00:00282"""Count: "&_xlfn.COUNTIF(I33:I35, 2)"""""""""""Count: "&_xlfn.COUNTIF(I31:I31, 1)"Count: "&_xlfn.COUNTIF(I31:I31, 1)"""""""Min: "&_xlfn.MIN(_xlfn.IF(I31:I31=1, D31:D31))"Min: "&_xlfn.MIN(_xlfn.IF(I31:I31=1, E31:E31))"""""""Max: "&_xlfn.MAX(_xlfn.IF(I31:I31=1, D31:D31))"Max: "&_xlfn.MAX(_xlfn.IF(I31:I31=1, E31:E31))"""""""Sum: "&_xlfn.SUMIF(I31:I31, 1, D31:D31)"Sum: "&_xlfn.SUMIF(I31:I31, 1, E31:E31)"""""""Avg: "&_xlfn.AVERAGEIF(I31:I31, 1, D31:D31)"Avg: "&_xlfn.AVERAGEIF(I31:I31, 1, E31:E31)""402015310"Count: "&_xlfn.COUNTIF(I2:I42, 0)"Count: "&_xlfn.COUNTIF(I2:I42, 0)"Min: "&_xlfn.MIN(_xlfn.IF(I2:I42=0, C2:C42))"Min: "&_xlfn.MIN(_xlfn.IF(I2:I42=0, D2:D42))"""""""Max: "&_xlfn.MAX(_xlfn.IF(I2:I42=0, C2:C42))"Max: "&_xlfn.MAX(_xlfn.IF(I2:I42=0, D2:D42))"""""""Avg: "&_xlfn.AVERAGEIF(I2:I42, 0, C2:C42)"Avg: "&_xlfn.AVERAGEIF(I2:I42, 0, D2:D42)""` + + return this.createData(); + } + + public get exportGroupedGridWithSummariesRootLevelOnly() { + this._sharedStringsData = + `count="94" uniqueCount="36">PTODaysGRID_LEVEL_COLShipped: (Blank) (22)City: Aachen (1)ContactTitle: Order Administrator (1)City: Berlin (1)ContactTitle: Sales Representative (1)City: Bern (1)ContactTitle: Owner (1)City: Buenos Aires (1)ContactTitle: Sales Agent (1)City: Graz (1)ContactTitle: Sales Manager (1)City: London (4)ContactTitle: Sales Representative (3)City: Luleå (1)City: Madrid (2)ContactTitle: Accounting Manager (1)City: Mannheim (1)City: Marseille (1)City: México D.F. (3)ContactTitle: Marketing Manager (1)ContactTitle: Owner (2)City: Nantes (1)City: Sao Paulo (2)ContactTitle: Marketing Assistant (1)ContactTitle: Sales Associate (1)City: Strasbourg (1)City: Tsawassen (1)Shipped: true (5)City: Bräcke (1)City: Lille (1)ContactTitle: Assistant Sales Agent (1)City: München (1)City: Torino (1)`; + + this._tableData = + `ref="A1:A79" totalsRowShown="0"> + `; + + this._worksheetData = + ` + + + + + 012343536363732038393273103113031231332931431139315323303231635315317318323932731937317320393113213223253233123323243931632532630327317328322333329318363303313931233233331334322324324322326335373183"Count: "&_xlfn.COUNTIF(C2:C74, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C2:C74=3, A2:A74))"""Max: "&_xlfn.MAX(_xlfn.IF(C2:C74=3, A2:A74))"""Sum: "&_xlfn.SUMIF(C2:C74, 3, A2:A74)"""Avg: "&_xlfn.AVERAGEIF(C2:C74, 3, A2:A74)""` + + return this.createData(); + } + + public get exportGroupedGridWithSummariesChildLevelsOnly() { + this._sharedStringsData = + `count="94" uniqueCount="36">PTODaysGRID_LEVEL_COLShipped: (Blank) (22)City: Aachen (1)ContactTitle: Order Administrator (1)City: Berlin (1)ContactTitle: Sales Representative (1)City: Bern (1)ContactTitle: Owner (1)City: Buenos Aires (1)ContactTitle: Sales Agent (1)City: Graz (1)ContactTitle: Sales Manager (1)City: London (4)ContactTitle: Sales Representative (3)City: Luleå (1)City: Madrid (2)ContactTitle: Accounting Manager (1)City: Mannheim (1)City: Marseille (1)City: México D.F. (3)ContactTitle: Marketing Manager (1)ContactTitle: Owner (2)City: Nantes (1)City: Sao Paulo (2)ContactTitle: Marketing Assistant (1)ContactTitle: Sales Associate (1)City: Strasbourg (1)City: Tsawassen (1)Shipped: true (5)City: Bräcke (1)City: Lille (1)ContactTitle: Assistant Sales Agent (1)City: München (1)City: Torino (1)`; + + this._tableData = + `ref="A1:A304" totalsRowShown="0"> + `; + + this._worksheetData = + ` + + + + + 0123435363"Count: "&_xlfn.COUNTIF(C4:C5, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C4:C5=3, A4:A5))"""Max: "&_xlfn.MAX(_xlfn.IF(C4:C5=3, A4:A5))"""Sum: "&_xlfn.SUMIF(C4:C5, 3, A4:A5)"""Avg: "&_xlfn.AVERAGEIF(C4:C5, 3, A4:A5)"""Count: "&_xlfn.COUNTIF(C3:C5, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C3:C5=3, A3:A5))"""Max: "&_xlfn.MAX(_xlfn.IF(C3:C5=3, A3:A5))"""Sum: "&_xlfn.SUMIF(C3:C5, 3, A3:A5)"""Avg: "&_xlfn.AVERAGEIF(C3:C5, 3, A3:A5)""6373203"Count: "&_xlfn.COUNTIF(C17:C18, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C17:C18=3, A17:A18))"""Max: "&_xlfn.MAX(_xlfn.IF(C17:C18=3, A17:A18))"""Sum: "&_xlfn.SUMIF(C17:C18, 3, A17:A18)"""Avg: "&_xlfn.AVERAGEIF(C17:C18, 3, A17:A18)"""Count: "&_xlfn.COUNTIF(C16:C18, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C16:C18=3, A16:A18))"""Max: "&_xlfn.MAX(_xlfn.IF(C16:C18=3, A16:A18))"""Sum: "&_xlfn.SUMIF(C16:C18, 3, A16:A18)"""Avg: "&_xlfn.AVERAGEIF(C16:C18, 3, A16:A18)""8393273"Count: "&_xlfn.COUNTIF(C30:C31, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C30:C31=3, A30:A31))"""Max: "&_xlfn.MAX(_xlfn.IF(C30:C31=3, A30:A31))"""Sum: "&_xlfn.SUMIF(C30:C31, 3, A30:A31)"""Avg: "&_xlfn.AVERAGEIF(C30:C31, 3, A30:A31)"""Count: "&_xlfn.COUNTIF(C29:C31, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C29:C31=3, A29:A31))"""Max: "&_xlfn.MAX(_xlfn.IF(C29:C31=3, A29:A31))"""Sum: "&_xlfn.SUMIF(C29:C31, 3, A29:A31)"""Avg: "&_xlfn.AVERAGEIF(C29:C31, 3, A29:A31)""10311303"Count: "&_xlfn.COUNTIF(C43:C44, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C43:C44=3, A43:A44))"""Max: "&_xlfn.MAX(_xlfn.IF(C43:C44=3, A43:A44))"""Sum: "&_xlfn.SUMIF(C43:C44, 3, A43:A44)"""Avg: "&_xlfn.AVERAGEIF(C43:C44, 3, A43:A44)"""Count: "&_xlfn.COUNTIF(C42:C44, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C42:C44=3, A42:A44))"""Max: "&_xlfn.MAX(_xlfn.IF(C42:C44=3, A42:A44))"""Sum: "&_xlfn.SUMIF(C42:C44, 3, A42:A44)"""Avg: "&_xlfn.AVERAGEIF(C42:C44, 3, A42:A44)""123133293"Count: "&_xlfn.COUNTIF(C56:C57, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C56:C57=3, A56:A57))"""Max: "&_xlfn.MAX(_xlfn.IF(C56:C57=3, A56:A57))"""Sum: "&_xlfn.SUMIF(C56:C57, 3, A56:A57)"""Avg: "&_xlfn.AVERAGEIF(C56:C57, 3, A56:A57)"""Count: "&_xlfn.COUNTIF(C55:C57, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C55:C57=3, A55:A57))"""Max: "&_xlfn.MAX(_xlfn.IF(C55:C57=3, A55:A57))"""Sum: "&_xlfn.SUMIF(C55:C57, 3, A55:A57)"""Avg: "&_xlfn.AVERAGEIF(C55:C57, 3, A55:A57)""14311393"Count: "&_xlfn.COUNTIF(C69:C70, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C69:C70=3, A69:A70))"""Max: "&_xlfn.MAX(_xlfn.IF(C69:C70=3, A69:A70))"""Sum: "&_xlfn.SUMIF(C69:C70, 3, A69:A70)"""Avg: "&_xlfn.AVERAGEIF(C69:C70, 3, A69:A70)""1532330323"Count: "&_xlfn.COUNTIF(C76:C79, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C76:C79=3, A76:A79))"""Max: "&_xlfn.MAX(_xlfn.IF(C76:C79=3, A76:A79))"""Sum: "&_xlfn.SUMIF(C76:C79, 3, A76:A79)"""Avg: "&_xlfn.AVERAGEIF(C76:C79, 3, A76:A79)"""Count: "&_xlfn.COUNTIF(C68:C79, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C68:C79=3, A68:A79))"""Max: "&_xlfn.MAX(_xlfn.IF(C68:C79=3, A68:A79))"""Sum: "&_xlfn.SUMIF(C68:C79, 3, A68:A79)"""Avg: "&_xlfn.AVERAGEIF(C68:C79, 3, A68:A79)""16353153"Count: "&_xlfn.COUNTIF(C91:C92, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C91:C92=3, A91:A92))"""Max: "&_xlfn.MAX(_xlfn.IF(C91:C92=3, A91:A92))"""Sum: "&_xlfn.SUMIF(C91:C92, 3, A91:A92)"""Avg: "&_xlfn.AVERAGEIF(C91:C92, 3, A91:A92)"""Count: "&_xlfn.COUNTIF(C90:C92, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C90:C92=3, A90:A92))"""Max: "&_xlfn.MAX(_xlfn.IF(C90:C92=3, A90:A92))"""Sum: "&_xlfn.SUMIF(C90:C92, 3, A90:A92)"""Avg: "&_xlfn.AVERAGEIF(C90:C92, 3, A90:A92)""17318323"Count: "&_xlfn.COUNTIF(C104:C105, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C104:C105=3, A104:A105))"""Max: "&_xlfn.MAX(_xlfn.IF(C104:C105=3, A104:A105))"""Sum: "&_xlfn.SUMIF(C104:C105, 3, A104:A105)"""Avg: "&_xlfn.AVERAGEIF(C104:C105, 3, A104:A105)""93273"Count: "&_xlfn.COUNTIF(C111:C112, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C111:C112=3, A111:A112))"""Max: "&_xlfn.MAX(_xlfn.IF(C111:C112=3, A111:A112))"""Sum: "&_xlfn.SUMIF(C111:C112, 3, A111:A112)"""Avg: "&_xlfn.AVERAGEIF(C111:C112, 3, A111:A112)"""Count: "&_xlfn.COUNTIF(C103:C112, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C103:C112=3, A103:A112))"""Max: "&_xlfn.MAX(_xlfn.IF(C103:C112=3, A103:A112))"""Sum: "&_xlfn.SUMIF(C103:C112, 3, A103:A112)"""Avg: "&_xlfn.AVERAGEIF(C103:C112, 3, A103:A112)""19373173"Count: "&_xlfn.COUNTIF(C124:C125, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C124:C125=3, A124:A125))"""Max: "&_xlfn.MAX(_xlfn.IF(C124:C125=3, A124:A125))"""Sum: "&_xlfn.SUMIF(C124:C125, 3, A124:A125)"""Avg: "&_xlfn.AVERAGEIF(C124:C125, 3, A124:A125)"""Count: "&_xlfn.COUNTIF(C123:C125, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C123:C125=3, A123:A125))"""Max: "&_xlfn.MAX(_xlfn.IF(C123:C125=3, A123:A125))"""Sum: "&_xlfn.SUMIF(C123:C125, 3, A123:A125)"""Avg: "&_xlfn.AVERAGEIF(C123:C125, 3, A123:A125)""20393113"Count: "&_xlfn.COUNTIF(C137:C138, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C137:C138=3, A137:A138))"""Max: "&_xlfn.MAX(_xlfn.IF(C137:C138=3, A137:A138))"" + "Sum: "&_xlfn.SUMIF(C137:C138, 3, A137:A138)"""Avg: "&_xlfn.AVERAGEIF(C137:C138, 3, A137:A138)"""Count: "&_xlfn.COUNTIF(C136:C138, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C136:C138=3, A136:A138))"""Max: "&_xlfn.MAX(_xlfn.IF(C136:C138=3, A136:A138))"""Sum: "&_xlfn.SUMIF(C136:C138, 3, A136:A138)"""Avg: "&_xlfn.AVERAGEIF(C136:C138, 3, A136:A138)""213223253"Count: "&_xlfn.COUNTIF(C150:C151, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C150:C151=3, A150:A151))"""Max: "&_xlfn.MAX(_xlfn.IF(C150:C151=3, A150:A151))"""Sum: "&_xlfn.SUMIF(C150:C151, 3, A150:A151)"""Avg: "&_xlfn.AVERAGEIF(C150:C151, 3, A150:A151)""233123323"Count: "&_xlfn.COUNTIF(C157:C159, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C157:C159=3, A157:A159))"""Max: "&_xlfn.MAX(_xlfn.IF(C157:C159=3, A157:A159))"""Sum: "&_xlfn.SUMIF(C157:C159, 3, A157:A159)"""Avg: "&_xlfn.AVERAGEIF(C157:C159, 3, A157:A159)"""Count: "&_xlfn.COUNTIF(C149:C159, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C149:C159=3, A149:A159))"""Max: "&_xlfn.MAX(_xlfn.IF(C149:C159=3, A149:A159))"""Sum: "&_xlfn.SUMIF(C149:C159, 3, A149:A159)"""Avg: "&_xlfn.AVERAGEIF(C149:C159, 3, A149:A159)""24393163"Count: "&_xlfn.COUNTIF(C171:C172, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C171:C172=3, A171:A172))"""Max: "&_xlfn.MAX(_xlfn.IF(C171:C172=3, A171:A172))"""Sum: "&_xlfn.SUMIF(C171:C172, 3, A171:A172)"""Avg: "&_xlfn.AVERAGEIF(C171:C172, 3, A171:A172)"""Count: "&_xlfn.COUNTIF(C170:C172, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C170:C172=3, A170:A172))"""Max: "&_xlfn.MAX(_xlfn.IF(C170:C172=3, A170:A172))"""Sum: "&_xlfn.SUMIF(C170:C172, 3, A170:A172)"""Avg: "&_xlfn.AVERAGEIF(C170:C172, 3, A170:A172)""25326303"Count: "&_xlfn.COUNTIF(C184:C185, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C184:C185=3, A184:A185))"""Max: "&_xlfn.MAX(_xlfn.IF(C184:C185=3, A184:A185))"""Sum: "&_xlfn.SUMIF(C184:C185, 3, A184:A185)"""Avg: "&_xlfn.AVERAGEIF(C184:C185, 3, A184:A185)""273173"Count: "&_xlfn.COUNTIF(C191:C192, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C191:C192=3, A191:A192))"""Max: "&_xlfn.MAX(_xlfn.IF(C191:C192=3, A191:A192))"""Sum: "&_xlfn.SUMIF(C191:C192, 3, A191:A192)"""Avg: "&_xlfn.AVERAGEIF(C191:C192, 3, A191:A192)"""Count: "&_xlfn.COUNTIF(C183:C192, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C183:C192=3, A183:A192))"""Max: "&_xlfn.MAX(_xlfn.IF(C183:C192=3, A183:A192))"""Sum: "&_xlfn.SUMIF(C183:C192, 3, A183:A192)"""Avg: "&_xlfn.AVERAGEIF(C183:C192, 3, A183:A192)""283223333"Count: "&_xlfn.COUNTIF(C204:C205, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C204:C205=3, A204:A205))"""Max: "&_xlfn.MAX(_xlfn.IF(C204:C205=3, A204:A205))"""Sum: "&_xlfn.SUMIF(C204:C205, 3, A204:A205)"""Avg: "&_xlfn.AVERAGEIF(C204:C205, 3, A204:A205)"""Count: "&_xlfn.COUNTIF(C203:C205, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C203:C205=3, A203:A205))"""Max: "&_xlfn.MAX(_xlfn.IF(C203:C205=3, A203:A205))"""Sum: "&_xlfn.SUMIF(C203:C205, 3, A203:A205)"""Avg: "&_xlfn.AVERAGEIF(C203:C205, 3, A203:A205)""29318363"Count: "&_xlfn.COUNTIF(C217:C218, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C217:C218=3, A217:A218))"""Max: "&_xlfn.MAX(_xlfn.IF(C217:C218=3, A217:A218))"""Sum: "&_xlfn.SUMIF(C217:C218, 3, A217:A218)"""Avg: "&_xlfn.AVERAGEIF(C217:C218, 3, A217:A218)"""Count: "&_xlfn.COUNTIF(C216:C218, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C216:C218=3, A216:A218))"""Max: "&_xlfn.MAX(_xlfn.IF(C216:C218=3, A216:A218))"""Sum: "&_xlfn.SUMIF(C216:C218, 3, A216:A218)"""Avg: "&_xlfn.AVERAGEIF(C216:C218, 3, A216:A218)"""Count: "&_xlfn.COUNTIF(C2:C218, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C2:C218=3, A2:A218))"""Max: "&_xlfn.MAX(_xlfn.IF(C2:C218=3, A2:A218))"""Sum: "&_xlfn.SUMIF(C2:C218, 3, A2:A218)"""Avg: "&_xlfn.AVERAGEIF(C2:C218, 3, A2:A218)""30331393123"Count: "&_xlfn.COUNTIF(C236:C237, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C236:C237=3, A236:A237))"""Max: "&_xlfn.MAX(_xlfn.IF(C236:C237=3, A236:A237))"""Sum: "&_xlfn.SUMIF(C236:C237, 3, A236:A237)"""Avg: "&_xlfn.AVERAGEIF(C236:C237, 3, A236:A237)"""Count: "&_xlfn.COUNTIF(C235:C237, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C235:C237=3, A235:A237))"""Max: "&_xlfn.MAX(_xlfn.IF(C235:C237=3, A235:A237))"""Sum: "&_xlfn.SUMIF(C235:C237, 3, A235:A237)"""Avg: "&_xlfn.AVERAGEIF(C235:C237, 3, A235:A237)""32333313"Count: "&_xlfn.COUNTIF(C249:C250, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C249:C250=3, A249:A250))"""Max: "&_xlfn.MAX(_xlfn.IF(C249:C250=3, A249:A250))"""Sum: "&_xlfn.SUMIF(C249:C250, 3, A249:A250)"""Avg: "&_xlfn.AVERAGEIF(C249:C250, 3, A249:A250)"""Count: "&_xlfn.COUNTIF(C248:C250, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C248:C250=3, A248:A250))"""Max: "&_xlfn.MAX(_xlfn.IF(C248:C250=3, A248:A250))"""Sum: "&_xlfn.SUMIF(C248:C250, 3, A248:A250)"""Avg: "&_xlfn.AVERAGEIF(C248:C250, 3, A248:A250)""343223243"Count: "&_xlfn.COUNTIF(C262:C263, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C262:C263=3, A262:A263))"""Max: "&_xlfn.MAX(_xlfn.IF(C262:C263=3, A262:A263))"""Sum: "&_xlfn.SUMIF(C262:C263, 3, A262:A263)"""Avg: "&_xlfn.AVERAGEIF(C262:C263, 3, A262:A263)"""Count: "&_xlfn.COUNTIF(C261:C263, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C261:C263=3, A261:A263))"""Max: "&_xlfn.MAX(_xlfn.IF(C261:C263=3, A261:A263))"""Sum: "&_xlfn.SUMIF(C261:C263, 3, A261:A263)"""Avg: "&_xlfn.AVERAGEIF(C261:C263, 3, A261:A263)""243223 + 263"Count: "&_xlfn.COUNTIF(C275:C276, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C275:C276=3, A275:A276))"""Max: "&_xlfn.MAX(_xlfn.IF(C275:C276=3, A275:A276))"""Sum: "&_xlfn.SUMIF(C275:C276, 3, A275:A276)"""Avg: "&_xlfn.AVERAGEIF(C275:C276, 3, A275:A276)"""Count: "&_xlfn.COUNTIF(C274:C276, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C274:C276=3, A274:A276))"""Max: "&_xlfn.MAX(_xlfn.IF(C274:C276=3, A274:A276))"""Sum: "&_xlfn.SUMIF(C274:C276, 3, A274:A276)"""Avg: "&_xlfn.AVERAGEIF(C274:C276, 3, A274:A276)""35373183"Count: "&_xlfn.COUNTIF(C288:C289, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C288:C289=3, A288:A289))"""Max: "&_xlfn.MAX(_xlfn.IF(C288:C289=3, A288:A289))"""Sum: "&_xlfn.SUMIF(C288:C289, 3, A288:A289)"""Avg: "&_xlfn.AVERAGEIF(C288:C289, 3, A288:A289)"""Count: "&_xlfn.COUNTIF(C287:C289, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C287:C289=3, A287:A289))"""Max: "&_xlfn.MAX(_xlfn.IF(C287:C289=3, A287:A289))"""Sum: "&_xlfn.SUMIF(C287:C289, 3, A287:A289)"""Avg: "&_xlfn.AVERAGEIF(C287:C289, 3, A287:A289)"""Count: "&_xlfn.COUNTIF(C234:C289, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C234:C289=3, A234:A289))"""Max: "&_xlfn.MAX(_xlfn.IF(C234:C289=3, A234:A289))"""Sum: "&_xlfn.SUMIF(C234:C289, 3, A234:A289)"""Avg: "&_xlfn.AVERAGEIF(C234:C289, 3, A234:A289)""` + + return this.createData(); + } + + public get exportGroupedGridWithSummariesRootAndChildLevels() { + this._sharedStringsData = + `count="94" uniqueCount="36">PTODaysGRID_LEVEL_COLShipped: (Blank) (22)City: Aachen (1)ContactTitle: Order Administrator (1)City: Berlin (1)ContactTitle: Sales Representative (1)City: Bern (1)ContactTitle: Owner (1)City: Buenos Aires (1)ContactTitle: Sales Agent (1)City: Graz (1)ContactTitle: Sales Manager (1)City: London (4)ContactTitle: Sales Representative (3)City: Luleå (1)City: Madrid (2)ContactTitle: Accounting Manager (1)City: Mannheim (1)City: Marseille (1)City: México D.F. (3)ContactTitle: Marketing Manager (1)ContactTitle: Owner (2)City: Nantes (1)City: Sao Paulo (2)ContactTitle: Marketing Assistant (1)ContactTitle: Sales Associate (1)City: Strasbourg (1)City: Tsawassen (1)Shipped: true (5)City: Bräcke (1)City: Lille (1)ContactTitle: Assistant Sales Agent (1)City: München (1)City: Torino (1)`; + + this._tableData = + `ref="A1:A309" totalsRowShown="0"> + `; + + this._worksheetData = + ` + + + + + 0123435363"Count: "&_xlfn.COUNTIF(C4:C5, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C4:C5=3, A4:A5))"""Max: "&_xlfn.MAX(_xlfn.IF(C4:C5=3, A4:A5))"""Sum: "&_xlfn.SUMIF(C4:C5, 3, A4:A5)"""Avg: "&_xlfn.AVERAGEIF(C4:C5, 3, A4:A5)"""Count: "&_xlfn.COUNTIF(C3:C5, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C3:C5=3, A3:A5))"""Max: "&_xlfn.MAX(_xlfn.IF(C3:C5=3, A3:A5))"""Sum: "&_xlfn.SUMIF(C3:C5, 3, A3:A5)"""Avg: "&_xlfn.AVERAGEIF(C3:C5, 3, A3:A5)""6373203"Count: "&_xlfn.COUNTIF(C17:C18, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C17:C18=3, A17:A18))"""Max: "&_xlfn.MAX(_xlfn.IF(C17:C18=3, A17:A18))"""Sum: "&_xlfn.SUMIF(C17:C18, 3, A17:A18)"""Avg: "&_xlfn.AVERAGEIF(C17:C18, 3, A17:A18)"""Count: "&_xlfn.COUNTIF(C16:C18, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C16:C18=3, A16:A18))"""Max: "&_xlfn.MAX(_xlfn.IF(C16:C18=3, A16:A18))"""Sum: "&_xlfn.SUMIF(C16:C18, 3, A16:A18)"""Avg: "&_xlfn.AVERAGEIF(C16:C18, 3, A16:A18)""8393273"Count: "&_xlfn.COUNTIF(C30:C31, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C30:C31=3, A30:A31))"""Max: "&_xlfn.MAX(_xlfn.IF(C30:C31=3, A30:A31))"""Sum: "&_xlfn.SUMIF(C30:C31, 3, A30:A31)"""Avg: "&_xlfn.AVERAGEIF(C30:C31, 3, A30:A31)"""Count: "&_xlfn.COUNTIF(C29:C31, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C29:C31=3, A29:A31))"""Max: "&_xlfn.MAX(_xlfn.IF(C29:C31=3, A29:A31))"""Sum: "&_xlfn.SUMIF(C29:C31, 3, A29:A31)"""Avg: "&_xlfn.AVERAGEIF(C29:C31, 3, A29:A31)""10311303"Count: "&_xlfn.COUNTIF(C43:C44, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C43:C44=3, A43:A44))"""Max: "&_xlfn.MAX(_xlfn.IF(C43:C44=3, A43:A44))"""Sum: "&_xlfn.SUMIF(C43:C44, 3, A43:A44)"""Avg: "&_xlfn.AVERAGEIF(C43:C44, 3, A43:A44)"""Count: "&_xlfn.COUNTIF(C42:C44, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C42:C44=3, A42:A44))"""Max: "&_xlfn.MAX(_xlfn.IF(C42:C44=3, A42:A44))"""Sum: "&_xlfn.SUMIF(C42:C44, 3, A42:A44)"""Avg: "&_xlfn.AVERAGEIF(C42:C44, 3, A42:A44)""123133293"Count: "&_xlfn.COUNTIF(C56:C57, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C56:C57=3, A56:A57))"""Max: "&_xlfn.MAX(_xlfn.IF(C56:C57=3, A56:A57))"""Sum: "&_xlfn.SUMIF(C56:C57, 3, A56:A57)"""Avg: "&_xlfn.AVERAGEIF(C56:C57, 3, A56:A57)"""Count: "&_xlfn.COUNTIF(C55:C57, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C55:C57=3, A55:A57))"""Max: "&_xlfn.MAX(_xlfn.IF(C55:C57=3, A55:A57))"""Sum: "&_xlfn.SUMIF(C55:C57, 3, A55:A57)"""Avg: "&_xlfn.AVERAGEIF(C55:C57, 3, A55:A57)""14311393"Count: "&_xlfn.COUNTIF(C69:C70, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C69:C70=3, A69:A70))"""Max: "&_xlfn.MAX(_xlfn.IF(C69:C70=3, A69:A70))"""Sum: "&_xlfn.SUMIF(C69:C70, 3, A69:A70)"""Avg: "&_xlfn.AVERAGEIF(C69:C70, 3, A69:A70)""1532330323"Count: "&_xlfn.COUNTIF(C76:C79, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C76:C79=3, A76:A79))"""Max: "&_xlfn.MAX(_xlfn.IF(C76:C79=3, A76:A79))"""Sum: "&_xlfn.SUMIF(C76:C79, 3, A76:A79)"""Avg: "&_xlfn.AVERAGEIF(C76:C79, 3, A76:A79)"""Count: "&_xlfn.COUNTIF(C68:C79, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C68:C79=3, A68:A79))"""Max: "&_xlfn.MAX(_xlfn.IF(C68:C79=3, A68:A79))"""Sum: "&_xlfn.SUMIF(C68:C79, 3, A68:A79)"""Avg: "&_xlfn.AVERAGEIF(C68:C79, 3, A68:A79)""16353153"Count: "&_xlfn.COUNTIF(C91:C92, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C91:C92=3, A91:A92))"""Max: "&_xlfn.MAX(_xlfn.IF(C91:C92=3, A91:A92))"""Sum: "&_xlfn.SUMIF(C91:C92, 3, A91:A92)"""Avg: "&_xlfn.AVERAGEIF(C91:C92, 3, A91:A92)"""Count: "&_xlfn.COUNTIF(C90:C92, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C90:C92=3, A90:A92))"""Max: "&_xlfn.MAX(_xlfn.IF(C90:C92=3, A90:A92))"""Sum: "&_xlfn.SUMIF(C90:C92, 3, A90:A92)"""Avg: "&_xlfn.AVERAGEIF(C90:C92, 3, A90:A92)""17318323"Count: "&_xlfn.COUNTIF(C104:C105, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C104:C105=3, A104:A105))"""Max: "&_xlfn.MAX(_xlfn.IF(C104:C105=3, A104:A105))"""Sum: "&_xlfn.SUMIF(C104:C105, 3, A104:A105)"""Avg: "&_xlfn.AVERAGEIF(C104:C105, 3, A104:A105)""93273"Count: "&_xlfn.COUNTIF(C111:C112, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C111:C112=3, A111:A112))"""Max: "&_xlfn.MAX(_xlfn.IF(C111:C112=3, A111:A112))"""Sum: "&_xlfn.SUMIF(C111:C112, 3, A111:A112)"""Avg: "&_xlfn.AVERAGEIF(C111:C112, 3, A111:A112)"""Count: "&_xlfn.COUNTIF(C103:C112, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C103:C112=3, A103:A112))"""Max: "&_xlfn.MAX(_xlfn.IF(C103:C112=3, A103:A112))"""Sum: "&_xlfn.SUMIF(C103:C112, 3, A103:A112)"""Avg: "&_xlfn.AVERAGEIF(C103:C112, 3, A103:A112)""19373173"Count: "&_xlfn.COUNTIF(C124:C125, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C124:C125=3, A124:A125))"""Max: "&_xlfn.MAX(_xlfn.IF(C124:C125=3, A124:A125))"""Sum: "&_xlfn.SUMIF(C124:C125, 3, A124:A125)"""Avg: "&_xlfn.AVERAGEIF(C124:C125, 3, A124:A125)"""Count: "&_xlfn.COUNTIF(C123:C125, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C123:C125=3, A123:A125))"""Max: "&_xlfn.MAX(_xlfn.IF(C123:C125=3, A123:A125))"""Sum: "&_xlfn.SUMIF(C123:C125, 3, A123:A125)"""Avg: "&_xlfn.AVERAGEIF(C123:C125, 3, A123:A125)""20393113"Count: "&_xlfn.COUNTIF(C137:C138, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C137:C138=3, A137:A138))"""Max: "&_xlfn.MAX(_xlfn.IF(C137:C138=3, A137:A138))"" + "Sum: "&_xlfn.SUMIF(C137:C138, 3, A137:A138)"""Avg: "&_xlfn.AVERAGEIF(C137:C138, 3, A137:A138)"""Count: "&_xlfn.COUNTIF(C136:C138, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C136:C138=3, A136:A138))"""Max: "&_xlfn.MAX(_xlfn.IF(C136:C138=3, A136:A138))"""Sum: "&_xlfn.SUMIF(C136:C138, 3, A136:A138)"""Avg: "&_xlfn.AVERAGEIF(C136:C138, 3, A136:A138)""213223253"Count: "&_xlfn.COUNTIF(C150:C151, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C150:C151=3, A150:A151))"""Max: "&_xlfn.MAX(_xlfn.IF(C150:C151=3, A150:A151))"""Sum: "&_xlfn.SUMIF(C150:C151, 3, A150:A151)"""Avg: "&_xlfn.AVERAGEIF(C150:C151, 3, A150:A151)""233123323"Count: "&_xlfn.COUNTIF(C157:C159, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C157:C159=3, A157:A159))"""Max: "&_xlfn.MAX(_xlfn.IF(C157:C159=3, A157:A159))"""Sum: "&_xlfn.SUMIF(C157:C159, 3, A157:A159)"""Avg: "&_xlfn.AVERAGEIF(C157:C159, 3, A157:A159)"""Count: "&_xlfn.COUNTIF(C149:C159, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C149:C159=3, A149:A159))"""Max: "&_xlfn.MAX(_xlfn.IF(C149:C159=3, A149:A159))"""Sum: "&_xlfn.SUMIF(C149:C159, 3, A149:A159)"""Avg: "&_xlfn.AVERAGEIF(C149:C159, 3, A149:A159)""24393163"Count: "&_xlfn.COUNTIF(C171:C172, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C171:C172=3, A171:A172))"""Max: "&_xlfn.MAX(_xlfn.IF(C171:C172=3, A171:A172))"""Sum: "&_xlfn.SUMIF(C171:C172, 3, A171:A172)"""Avg: "&_xlfn.AVERAGEIF(C171:C172, 3, A171:A172)"""Count: "&_xlfn.COUNTIF(C170:C172, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C170:C172=3, A170:A172))"""Max: "&_xlfn.MAX(_xlfn.IF(C170:C172=3, A170:A172))"""Sum: "&_xlfn.SUMIF(C170:C172, 3, A170:A172)"""Avg: "&_xlfn.AVERAGEIF(C170:C172, 3, A170:A172)""25326303"Count: "&_xlfn.COUNTIF(C184:C185, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C184:C185=3, A184:A185))"""Max: "&_xlfn.MAX(_xlfn.IF(C184:C185=3, A184:A185))"""Sum: "&_xlfn.SUMIF(C184:C185, 3, A184:A185)"""Avg: "&_xlfn.AVERAGEIF(C184:C185, 3, A184:A185)""273173"Count: "&_xlfn.COUNTIF(C191:C192, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C191:C192=3, A191:A192))"""Max: "&_xlfn.MAX(_xlfn.IF(C191:C192=3, A191:A192))"""Sum: "&_xlfn.SUMIF(C191:C192, 3, A191:A192)"""Avg: "&_xlfn.AVERAGEIF(C191:C192, 3, A191:A192)"""Count: "&_xlfn.COUNTIF(C183:C192, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C183:C192=3, A183:A192))"""Max: "&_xlfn.MAX(_xlfn.IF(C183:C192=3, A183:A192))"""Sum: "&_xlfn.SUMIF(C183:C192, 3, A183:A192)"""Avg: "&_xlfn.AVERAGEIF(C183:C192, 3, A183:A192)""283223333"Count: "&_xlfn.COUNTIF(C204:C205, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C204:C205=3, A204:A205))"""Max: "&_xlfn.MAX(_xlfn.IF(C204:C205=3, A204:A205))"""Sum: "&_xlfn.SUMIF(C204:C205, 3, A204:A205)"""Avg: "&_xlfn.AVERAGEIF(C204:C205, 3, A204:A205)"""Count: "&_xlfn.COUNTIF(C203:C205, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C203:C205=3, A203:A205))"""Max: "&_xlfn.MAX(_xlfn.IF(C203:C205=3, A203:A205))"""Sum: "&_xlfn.SUMIF(C203:C205, 3, A203:A205)"""Avg: "&_xlfn.AVERAGEIF(C203:C205, 3, A203:A205)""29318363"Count: "&_xlfn.COUNTIF(C217:C218, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C217:C218=3, A217:A218))"""Max: "&_xlfn.MAX(_xlfn.IF(C217:C218=3, A217:A218))"""Sum: "&_xlfn.SUMIF(C217:C218, 3, A217:A218)"""Avg: "&_xlfn.AVERAGEIF(C217:C218, 3, A217:A218)"""Count: "&_xlfn.COUNTIF(C216:C218, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C216:C218=3, A216:A218))"""Max: "&_xlfn.MAX(_xlfn.IF(C216:C218=3, A216:A218))"""Sum: "&_xlfn.SUMIF(C216:C218, 3, A216:A218)"""Avg: "&_xlfn.AVERAGEIF(C216:C218, 3, A216:A218)"""Count: "&_xlfn.COUNTIF(C2:C218, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C2:C218=3, A2:A218))"""Max: "&_xlfn.MAX(_xlfn.IF(C2:C218=3, A2:A218))"""Sum: "&_xlfn.SUMIF(C2:C218, 3, A2:A218)"""Avg: "&_xlfn.AVERAGEIF(C2:C218, 3, A2:A218)""30331393123"Count: "&_xlfn.COUNTIF(C236:C237, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C236:C237=3, A236:A237))"""Max: "&_xlfn.MAX(_xlfn.IF(C236:C237=3, A236:A237))"""Sum: "&_xlfn.SUMIF(C236:C237, 3, A236:A237)"""Avg: "&_xlfn.AVERAGEIF(C236:C237, 3, A236:A237)"""Count: "&_xlfn.COUNTIF(C235:C237, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C235:C237=3, A235:A237))"""Max: "&_xlfn.MAX(_xlfn.IF(C235:C237=3, A235:A237))"""Sum: "&_xlfn.SUMIF(C235:C237, 3, A235:A237)"""Avg: "&_xlfn.AVERAGEIF(C235:C237, 3, A235:A237)""32333313"Count: "&_xlfn.COUNTIF(C249:C250, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C249:C250=3, A249:A250))"""Max: "&_xlfn.MAX(_xlfn.IF(C249:C250=3, A249:A250))"""Sum: "&_xlfn.SUMIF(C249:C250, 3, A249:A250)"""Avg: "&_xlfn.AVERAGEIF(C249:C250, 3, A249:A250)"""Count: "&_xlfn.COUNTIF(C248:C250, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C248:C250=3, A248:A250))"""Max: "&_xlfn.MAX(_xlfn.IF(C248:C250=3, A248:A250))"""Sum: "&_xlfn.SUMIF(C248:C250, 3, A248:A250)"""Avg: "&_xlfn.AVERAGEIF(C248:C250, 3, A248:A250)""343223243"Count: "&_xlfn.COUNTIF(C262:C263, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C262:C263=3, A262:A263))"""Max: "&_xlfn.MAX(_xlfn.IF(C262:C263=3, A262:A263))"""Sum: "&_xlfn.SUMIF(C262:C263, 3, A262:A263)"""Avg: "&_xlfn.AVERAGEIF(C262:C263, 3, A262:A263)"""Count: "&_xlfn.COUNTIF(C261:C263, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C261:C263=3, A261:A263))"""Max: "&_xlfn.MAX(_xlfn.IF(C261:C263=3, A261:A263))"""Sum: "&_xlfn.SUMIF(C261:C263, 3, A261:A263)"""Avg: "&_xlfn.AVERAGEIF(C261:C263, 3, A261:A263)""243223 + 263"Count: "&_xlfn.COUNTIF(C275:C276, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C275:C276=3, A275:A276))"""Max: "&_xlfn.MAX(_xlfn.IF(C275:C276=3, A275:A276))"""Sum: "&_xlfn.SUMIF(C275:C276, 3, A275:A276)"""Avg: "&_xlfn.AVERAGEIF(C275:C276, 3, A275:A276)"""Count: "&_xlfn.COUNTIF(C274:C276, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C274:C276=3, A274:A276))"""Max: "&_xlfn.MAX(_xlfn.IF(C274:C276=3, A274:A276))"""Sum: "&_xlfn.SUMIF(C274:C276, 3, A274:A276)"""Avg: "&_xlfn.AVERAGEIF(C274:C276, 3, A274:A276)""35373183"Count: "&_xlfn.COUNTIF(C288:C289, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C288:C289=3, A288:A289))"""Max: "&_xlfn.MAX(_xlfn.IF(C288:C289=3, A288:A289))"""Sum: "&_xlfn.SUMIF(C288:C289, 3, A288:A289)"""Avg: "&_xlfn.AVERAGEIF(C288:C289, 3, A288:A289)"""Count: "&_xlfn.COUNTIF(C287:C289, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C287:C289=3, A287:A289))"""Max: "&_xlfn.MAX(_xlfn.IF(C287:C289=3, A287:A289))"""Sum: "&_xlfn.SUMIF(C287:C289, 3, A287:A289)"""Avg: "&_xlfn.AVERAGEIF(C287:C289, 3, A287:A289)"""Count: "&_xlfn.COUNTIF(C234:C289, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C234:C289=3, A234:A289))"""Max: "&_xlfn.MAX(_xlfn.IF(C234:C289=3, A234:A289))"""Sum: "&_xlfn.SUMIF(C234:C289, 3, A234:A289)"""Avg: "&_xlfn.AVERAGEIF(C234:C289, 3, A234:A289)"""Count: "&_xlfn.COUNTIF(C2:C289, 3)"""Min: "&_xlfn.MIN(_xlfn.IF(C2:C289=3, A2:A289))"""Max: "&_xlfn.MAX(_xlfn.IF(C2:C289=3, A2:A289))"""Sum: "&_xlfn.SUMIF(C2:C289, 3, A2:A289)"""Avg: "&_xlfn.AVERAGEIF(C2:C289, 3, A2:A289)""` + + return this.createData(); + } + + public get exportGridWithSummariesFrLocale() { + this._sharedStringsData = + `count="47" uniqueCount="29">Product IDProduct NamePriceUnits In StockDiscontinuedOrderDateGRID_LEVEL_COLChaifalseChangtrueAniseed SyrupChef Antons Cajun SeasoningChef Antons Gumbo MixGrandmas Boysenberry SpreadUncle Bobs Organic Dried PearsNorthwoods Cranberry SauceMishi Kobe NikuIkuraQueso CabralesQueso Manchego La PastoraKonbuTofuGenen ShouyuPavlovaAlice MuttonCarnarvon TigersTeatime Chocolate BiscuitsSir Rodneys Marmalade`; + + this._tableData = + `ref="A1:F26" totalsRowShown="0"> + `; + + this._worksheetData = + ` + + + + 012345617183982012-02-12T02:00:000291917102003-03-17T02:00:000311101382006-03-17T02:00:000412225382016-03-17T02:00:00051321.350102011-11-11T02:00:00061425082017-12-17T02:00:0007153015082016-07-17T03:00:00081640682018-01-17T02:00:0009179729102010-02-17T02:00:0001018313182008-05-17T03:00:0001119212282009-01-17T02:00:0001220388682015-11-17T02:00:000132162482015-03-17T02:00:000142223.253582017-06-17T03:00:000152315.53982014-03-17T02:00:000162417.452982018-03-28T03:00:0001725390102015-08-17T03:00:000182662.54282005-09-27T03:00:00019279.22582001-03-17T02:00:00020284.54082005-03-17T02:00:000"""Count: "&_xlfn.COUNTIF(H2:H21, 0)"Count: "&_xlfn.COUNTIF(H2:H21, 0)"""Count: "&_xlfn.COUNTIF(H2:H21, 0)"Count: "&_xlfn.COUNTIF(H2:H21, 0)"""""""Min: "&_xlfn.TEXT(_xlfn.MIN(_xlfn.IF(H2:H21=0, C2:C21)), "€#,##0.00")"""""Earliest: "&_xlfn.TEXT(_xlfn.MIN(_xlfn.IF(H2:H21=0, F2:F21)), "m/d/yyyy")"""""""Max: "&_xlfn.TEXT(_xlfn.MAX(_xlfn.IF(H2:H21=0, C2:C21)), "€#,##0.00")"""""Latest: "&_xlfn.TEXT(_xlfn.MAX(_xlfn.IF(H2:H21=0, F2:F21)), "m/d/yyyy")"""""""Sum: "&_xlfn.TEXT(_xlfn.SUMIF(H2:H21, 0, C2:C21), "€#,##0.00")"""""""""""""Avg: "&_xlfn.TEXT(_xlfn.AVERAGEIF(H2:H21, 0, C2:C21), "€#,##0.00")""""""""` + + return this.createData(); + } + + public get exportGridWithSummariesJaLocale() { + this._sharedStringsData = + `count="47" uniqueCount="29">Product IDProduct NamePriceUnits In StockDiscontinuedOrderDateGRID_LEVEL_COLChaifalseChangtrueAniseed SyrupChef Antons Cajun SeasoningChef Antons Gumbo MixGrandmas Boysenberry SpreadUncle Bobs Organic Dried PearsNorthwoods Cranberry SauceMishi Kobe NikuIkuraQueso CabralesQueso Manchego La PastoraKonbuTofuGenen ShouyuPavlovaAlice MuttonCarnarvon TigersTeatime Chocolate BiscuitsSir Rodneys Marmalade`; + + this._tableData = + `ref="A1:F26" totalsRowShown="0"> + `; + + this._worksheetData = + ` + + + + 012345617183982012-02-12T02:00:000291917102003-03-17T02:00:000311101382006-03-17T02:00:000412225382016-03-17T02:00:00051321.350102011-11-11T02:00:00061425082017-12-17T02:00:0007153015082016-07-17T03:00:00081640682018-01-17T02:00:0009179729102010-02-17T02:00:0001018313182008-05-17T03:00:0001119212282009-01-17T02:00:0001220388682015-11-17T02:00:000132162482015-03-17T02:00:000142223.253582017-06-17T03:00:000152315.53982014-03-17T02:00:000162417.452982018-03-28T03:00:0001725390102015-08-17T03:00:000182662.54282005-09-27T03:00:00019279.22582001-03-17T02:00:00020284.54082005-03-17T02:00:000"""Count: "&_xlfn.COUNTIF(H2:H21, 0)"Count: "&_xlfn.COUNTIF(H2:H21, 0)"""Count: "&_xlfn.COUNTIF(H2:H21, 0)"Count: "&_xlfn.COUNTIF(H2:H21, 0)"""""""Min: "&_xlfn.TEXT(_xlfn.MIN(_xlfn.IF(H2:H21=0, C2:C21)), "¥#,##0.00")"""""Earliest: "&_xlfn.TEXT(_xlfn.MIN(_xlfn.IF(H2:H21=0, F2:F21)), "m/d/yyyy")"""""""Max: "&_xlfn.TEXT(_xlfn.MAX(_xlfn.IF(H2:H21=0, C2:C21)), "¥#,##0.00")"""""Latest: "&_xlfn.TEXT(_xlfn.MAX(_xlfn.IF(H2:H21=0, F2:F21)), "m/d/yyyy")"""""""Sum: "&_xlfn.TEXT(_xlfn.SUMIF(H2:H21, 0, C2:C21), "¥#,##0.00")"""""""""""""Avg: "&_xlfn.TEXT(_xlfn.AVERAGEIF(H2:H21, 0, C2:C21), "¥#,##0.00")""""""""` + + return this.createData(); + } + + public get exportGridWithCustomSummaryOnlyWithSummaryLabel() { + this._sharedStringsData = + `count="10" uniqueCount="10">Product IDUnitPriceUnitsInStockGRID_LEVEL_COL210549.7568033176.3546`; + + this._tableData = + `ref="A1:C23" totalsRowShown="0"> + `; + + this._worksheetData = + ` + + + + 0123118390219170310130422530521.3500625007301500840609972901031310112122012388601362401423.253501515.53901617.452901739001862.5420199.2250204.5400456""789""` + + return this.createData(); + } + + public get exportGridCustomSummaryWithNullAndZero() { + this._sharedStringsData = + ` count="4" uniqueCount="4">Product IDUnitPriceUnitsInStockGRID_LEVEL_COL`; + + this._tableData = + `ref="A1:C23" totalsRowShown="0"> + `; + + this._worksheetData = + ` + + + + 0123118390219170310130422530521.3500625007301500840609972901031310112122012388601362401423.253501515.53901617.452901739001862.5420199.2250204.5400000""000""` + + return this.createData(); + } + + public get exportGridCustomSummaryWithUndefinedZeroAndValidNumber() { + this._sharedStringsData = + `count="4" uniqueCount="4">Product IDUnitPriceUnitsInStockGRID_LEVEL_COL`; + + this._tableData = + `ref="A1:C23" totalsRowShown="0"> + `; + + this._worksheetData = + ` + + + + 0123118390219170310130422530521.3500625007301500840609972901031310112122012388601362401423.253501515.53901617.452901739001862.5420199.2250204.5400000""232323""` + + return this.createData(); + } + + public get exportGridCustomSummaryWithUndefinedAndNull() { + this._sharedStringsData = + ` count="10" uniqueCount="5">Product IDUnitPriceUnitsInStockGRID_LEVEL_COL`; + + this._tableData = + `ref="A1:C23" totalsRowShown="0"> + `; + + this._worksheetData = + ` + + + + 0123118390219170310130422530521.3500625007301500840609972901031310112122012388601362401423.253501515.53901617.452901739001862.5420199.2250204.5400444""444""` + + return this.createData(); + } + + public get exportGridCustomSummaryWithDate() { + this._sharedStringsData = + `count="4" uniqueCount="4">Product IDUnitPriceUnitsInStockGRID_LEVEL_COL`; + + this._tableData = + `ref="A1:C23" totalsRowShown="0"> + `; + + this._worksheetData = + ` + + + + 0123118390219170310130422530521.3500625007301500840609972901031310112122012388601362401423.253501515.53901617.452901739001862.5420199.2250204.54002015-12-08T00:00:002015-12-08T00:00:002015-12-08T00:00:00""2020-05-12T00:00:002020-05-12T00:00:002020-05-12T00:00:00""` + + return this.createData(); + } + + public get noHeadersStringDataWithNullChars() { + this._sharedStringsData = `count="6" uniqueCount="6">Column1TerranceOrta` + + `Richard MahoneyLongerNameDonnaPriceLisa Landers` + + `Dorothy H. Spencer`; + + this._tableData = `ref="A1:A6" totalsRowShown="0">` + + ``; + + this._worksheetData = `` + + `0` + + `123` + + `45`; + + return this.createData(); + } + +} diff --git a/projects/igniteui-angular/grids/core/src/services/excel/worksheet-data-dictionary.ts b/projects/igniteui-angular/grids/core/src/services/excel/worksheet-data-dictionary.ts new file mode 100644 index 00000000000..9a6a86fed39 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/services/excel/worksheet-data-dictionary.ts @@ -0,0 +1,107 @@ +import { ExportUtilities } from '../exporter-common/export-utilities'; + +/** @hidden */ +export class WorksheetDataDictionary { + private static DEFAULT_FONT = '11pt Calibri'; + private static TEXT_PADDING = 5; + + public hasNumberValues = false; + public hasDateValues = false; + + public stringsCount: number; + + private _dictionary: any; + private _widthsDictionary: any; + + private _keys: string[]; + private _keysAreValid: boolean; + + private _counter: number; + private _columnWidths: number[]; + private _context: any; + + constructor(columnCount: number, columnWidth: number, columnWidthsList: number[]) { + this._dictionary = {}; + this._widthsDictionary = {}; + this._counter = 0; + this.dirtyKeyCollections(); + + this._columnWidths = new Array(columnCount); + + if (columnWidth) { + this._columnWidths.fill(columnWidth); + } else { + this._columnWidths = columnWidthsList; + } + + this.stringsCount = 0; + } + + public get columnWidths() { + return this._columnWidths; + } + + public saveValue(value: any, isHeader: boolean, shouldSanitizeValue = true): number { + let sanitizedValue = ''; + const isDate = value instanceof Date; + const isSavedAsString = isHeader || (typeof value !== 'number' && value !== Number(value) && !Number.isFinite(value) && !isDate); + + if (isSavedAsString) { + sanitizedValue = shouldSanitizeValue ? ExportUtilities.sanitizeValue(value) : value; + + if (this._dictionary[sanitizedValue] === undefined) { + this._dictionary[sanitizedValue] = this._counter++; + this.dirtyKeyCollections(); + } + + this.stringsCount ++; + } else if (isDate) { + this.hasDateValues = true; + } else { + this.hasNumberValues = true; + } + + return isSavedAsString ? this.getSanitizedValue(sanitizedValue) : -1; + } + + public getValue(value: string): number { + return this.getSanitizedValue(ExportUtilities.sanitizeValue(value)); + } + + public getSanitizedValue(sanitizedValue: string): number { + return this._dictionary[sanitizedValue]; + } + + public getKeys(): string[] { + if (!this._keysAreValid) { + this._keys = Object.keys(this._dictionary); + this._keysAreValid = true; + } + + return this._keys; + } + + private getTextWidth(value: any): number { + if (this._widthsDictionary[value] === undefined) { + const context = this.getContext(); + const metrics = context.measureText(value); + this._widthsDictionary[value] = metrics.width + WorksheetDataDictionary.TEXT_PADDING; + } + + return this._widthsDictionary[value]; + } + + private getContext(): any { + if (!this._context) { + const canvas = globalThis.document?.createElement('canvas'); + this._context = canvas.getContext('2d'); + this._context.font = WorksheetDataDictionary.DEFAULT_FONT; + } + + return this._context; + } + + private dirtyKeyCollections(): void { + this._keysAreValid = false; + } +} diff --git a/projects/igniteui-angular/grids/core/src/services/excel/worksheet-data.ts b/projects/igniteui-angular/grids/core/src/services/excel/worksheet-data.ts new file mode 100644 index 00000000000..ecd4aec9817 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/services/excel/worksheet-data.ts @@ -0,0 +1,124 @@ +import { ExportHeaderType, ExportRecordType, IColumnList, IExportRecord } from '../exporter-common/base-export-service'; +import { ExportUtilities } from '../exporter-common/export-utilities'; +import { IgxExcelExporterOptions } from './excel-exporter-options'; +import { WorksheetDataDictionary } from './worksheet-data-dictionary'; + +/** @hidden */ +export class WorksheetData { + private _rowCount: number; + private _dataDictionary: WorksheetDataDictionary; + private _isSpecialData: boolean; + private _hasMultiColumnHeader: boolean; + private _hasMultiRowHeader: boolean; + private _isHierarchical: boolean; + private _hasSummaries: boolean; + private _isPivotGrid: boolean; + private _isTreeGrid: boolean; + + constructor(private _data: IExportRecord[], + public options: IgxExcelExporterOptions, + public sort: any, + public columnCount: number, + public rootKeys: string[], + public indexOfLastPinnedColumn: number, + public columnWidths: number[], + public owner: IColumnList, + public owners: Map) { + this.initializeData(); + } + + public get data(): IExportRecord[] { + return this._data; + } + + public get rowCount(): number { + return this._rowCount; + } + + public get isEmpty(): boolean { + return !this.rowCount + || this.rowCount === this.owner.maxLevel + 1 + || !this.columnCount + || this.owner.columns.every(c => c.skip); + } + + public get isSpecialData(): boolean { + return this._isSpecialData; + } + + public get dataDictionary(): WorksheetDataDictionary { + return this._dataDictionary; + } + + public get hasMultiColumnHeader(): boolean { + return this._hasMultiColumnHeader; + } + + public get hasSummaries(): boolean { + return this._hasSummaries; + } + + public get hasMultiRowHeader(): boolean { + return this._hasMultiRowHeader; + } + + public get isHierarchical(): boolean { + return this._isHierarchical; + } + + public get isTreeGrid(): boolean { + return this._isTreeGrid; + } + + public get isPivotGrid(): boolean { + return this._isPivotGrid; + } + + public get isGroupedGrid(): boolean { + return this._data.some(d => d.type === ExportRecordType.GroupedRecord); + } + + public get maxLevel(): number { + return [...new Set(this._data.map(item => item.level))].sort((a,b) => (a > b ? -1 : 1))[0]; + } + + public get multiColumnHeaderRows(): number { + return !this.options.ignoreMultiColumnHeaders ? Array.from(this.owners.values()).map(c => c.maxLevel).reduce((a,b) => a + b) : 0; + } + + private initializeData() { + this._dataDictionary = new WorksheetDataDictionary(this.columnCount, this.options.columnWidth, this.columnWidths); + + this._hasMultiColumnHeader = Array.from(this.owners.values()) + .some(o => o.columns.some(col => !col.skip && col.headerType === ExportHeaderType.MultiColumnHeader)); + + this._hasMultiRowHeader = Array.from(this.owners.values()) + .some(o => o.columns.some(col => !col.skip && col.headerType === ExportHeaderType.MultiRowHeader)); + + this._isHierarchical = this.data[0]?.type === ExportRecordType.HierarchicalGridRecord + || !(typeof(Array.from(this.owners.keys())[0]) === 'string'); + + this._hasSummaries = this._data.filter(d => d.type === ExportRecordType.SummaryRecord).length > 0; + + this._isTreeGrid = this._data.filter(d => d.type === ExportRecordType.TreeGridRecord).length > 0; + + this._isPivotGrid = this.data[0]?.type === ExportRecordType.PivotGridRecord; + + const exportMultiColumnHeaders = this._hasMultiColumnHeader && !this.options.ignoreMultiColumnHeaders; + + if (this._isHierarchical || exportMultiColumnHeaders || this._isPivotGrid) { + this.options.exportAsTable = false; + } + + if (!this._data || this._data.length === 0) { + if (!this._isHierarchical) { + this._rowCount = this.owner.maxLevel + 1; + } + + return; + } + + this._isSpecialData = ExportUtilities.isSpecialData(this._data[0].data); + this._rowCount = this._data.length + this.multiColumnHeaderRows + 1; + } +} diff --git a/projects/igniteui-angular/grids/core/src/services/excel/zip-helper.spec.ts b/projects/igniteui-angular/grids/core/src/services/excel/zip-helper.spec.ts new file mode 100644 index 00000000000..70a305206ca --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/services/excel/zip-helper.spec.ts @@ -0,0 +1,298 @@ +import { ExcelFileTypes } from './excel-enums'; + +export class ZipFiles { + public static allFilesNames: string[] = [ + '_rels/', + '_rels/.rels', + 'docProps/', + 'docProps/app.xml', + 'docProps/core.xml', + 'xl/', + 'xl/_rels/', + 'xl/_rels/workbook.xml.rels', + 'xl/theme/', + 'xl/theme/theme1.xml', + 'xl/worksheets/', + 'xl/worksheets/sheet1.xml', + 'xl/styles.xml', + 'xl/workbook.xml', + '[Content_Types].xml', + 'xl/worksheets/_rels/', + 'xl/worksheets/_rels/sheet1.xml.rels', + 'xl/tables/', + 'xl/tables/table1.xml', + 'xl/sharedStrings.xml' + ]; + + public static dataFilesAndFoldersNames = [ + 'xl/worksheets/_rels/', + 'xl/worksheets/_rels/sheet1.xml.rels', + 'xl/worksheets/sheet1.xml', + 'xl/tables/', + 'xl/tables/table1.xml', + 'xl/sharedStrings.xml' + ]; + + public static hGridDataFilesAndFoldersNames = [ + 'xl/worksheets/sheet1.xml', + 'xl/sharedStrings.xml' + ]; + + public static templatesNames = [ + '_rels/', + '_rels/.rels', + 'docProps/', + 'docProps/app.xml', + 'docProps/core.xml', + 'xl/', + 'xl/_rels/', + 'xl/_rels/workbook.xml.rels', + 'xl/theme/', + 'xl/theme/theme1.xml', + 'xl/worksheets/', + 'xl/worksheets/sheet1.xml', + 'xl/styles.xml', + 'xl/workbook.xml', + '[Content_Types].xml' + ]; + + public static templateFiles = [ + { name: '_rels/.rels', type: ExcelFileTypes.RootRelsFile }, + { name: 'docProps/app.xml', type: ExcelFileTypes.AppFile }, + { name: 'docProps/core.xml', type: ExcelFileTypes.CoreFile }, + { name: 'xl/_rels/workbook.xml.rels', type: ExcelFileTypes.WorkbookRelsFile }, + { name: 'xl/theme/theme1.xml', type: ExcelFileTypes.ThemeFile }, + { name: 'xl/styles.xml', type: ExcelFileTypes.StyleFile }, + { name: 'xl/workbook.xml', type: ExcelFileTypes.WorkbookFile }, + { name: 'xl/worksheets/sheet1.xml', type: ExcelFileTypes.WorksheetFile }, + { name: '[Content_Types].xml', type: ExcelFileTypes.ContentTypesFile } + ]; + + public static dataFiles = [ + { name: 'xl/worksheets/_rels/sheet1.xml.rels', type: ExcelFileTypes.WorksheetRelsFile }, + { name: 'xl/worksheets/sheet1.xml', type: ExcelFileTypes.WorksheetFile }, + { name: 'xl/tables/table1.xml', type: ExcelFileTypes.TablesFile }, + { name: 'xl/sharedStrings.xml', type: ExcelFileTypes.SharedStringsFile } + ]; + + public static files = [ + { name: '_rels/.rels', type: ExcelFileTypes.RootRelsFile }, + { name: 'docProps/app.xml', type: ExcelFileTypes.AppFile }, + { name: 'docProps/core.xml', type: ExcelFileTypes.CoreFile }, + { name: 'xl/_rels/workbook.xml.rels', type: ExcelFileTypes.WorkbookRelsFile }, + { name: 'xl/theme/theme1.xml', type: ExcelFileTypes.ThemeFile }, + { name: 'xl/workbook.xml', type: ExcelFileTypes.WorkbookFile }, + { name: 'xl/worksheets/sheet1.xml', type: ExcelFileTypes.WorksheetFile }, + { name: 'xl/styles.xml', type: ExcelFileTypes.StyleFile }, + { name: '[Content_Types].xml', type: ExcelFileTypes.ContentTypesFile }, + { name: 'xl/worksheets/_rels/sheet1.xml.rels', type: ExcelFileTypes.WorksheetRelsFile }, + { name: 'xl/tables/table1.xml', type: ExcelFileTypes.TablesFile }, + { name: 'xl/sharedStrings.xml', type: ExcelFileTypes.SharedStringsFile } + ]; + + public static foldersNames: string[] = [ + '_rels/', + 'docProps/', + 'xl/', + 'xl/_rels/', + 'xl/tables/', + 'xl/theme/', + 'xl/worksheets/', + 'xl/worksheets/_rels/' + ]; + + public static filesNames: string[] = [ + '_rels/.rels', + 'docProps/app.xml', + 'docProps/core.xml', + 'xl/_rels/workbook.xml.rels', + 'xl/sharedStrings.xml', + 'xl/styles.xml', + 'xl/tables/table1.xml', + 'xl/theme/theme1.xml', + 'xl/workbook.xml', + 'xl/worksheets/_rels/sheet1.xml.rels', + 'xl/worksheets/sheet1.xml', + '[Content_Types].xml' + ]; + + public static hasDates: boolean; + + public static getTablesXML(tableData: string) { + return `\r\n +
    `; + } + + public static getStylesheetXML(): string { + return ``; + } + + public static getSharedStringsXML(stringsData: string) { + return ` +`; + } + + public static getContentTypesXML(hasData = true) { + const typesData = (hasData) ? `` : ``; + + return ` + + + + + + + + + ${typesData} + +`; + } + + public static getSheetDataFile(sheetData: string, hasValues: boolean, isHGrid: boolean) { + if (hasValues) { + const tablePart = isHGrid ? '' : ''; + + return ` + ${ sheetData }` + + `${tablePart}`; + } else { + const dimensionsPart = isHGrid ? '' : ''; + + return ` + ${dimensionsPart}` + + ``; + } + } + + public static createExpectedXML(xmlFile: ExcelFileTypes, currentData = '', hasValues = true, isHGrid = false): any { + let resultXml; + switch (xmlFile) { + case ExcelFileTypes.RootRelsFile: + resultXml = { + name: ZipFiles.templatesNames[1], + content : ` +` + +`` + }; + break; + case ExcelFileTypes.AppFile: + resultXml = { + name: ZipFiles.templatesNames[3], + content : ` +Microsoft Excel0` + +`falseWorksheets` + +`1Sheet1false` + +`falsefalse16.0300` + }; + break; + case ExcelFileTypes.CoreFile: + resultXml = { + name: ZipFiles.templatesNames[4], + content : ` +` + +`2015-06-05T18:17:20Z` + +`2015-06-05T18:17:26Z` + }; + break; + case ExcelFileTypes.WorkbookRelsFile: + const typesData = (hasValues) ? + `` : ''; + resultXml = { + name: ZipFiles.templatesNames[7], + content : ` +${typesData}` + +`` + }; + break; + case ExcelFileTypes.ThemeFile: + + const actualTheme = ``; + resultXml = { + name: ZipFiles.templatesNames[9], + content : ` +${actualTheme}` + }; + break; + case ExcelFileTypes.StyleFile: + resultXml = { + name: ZipFiles.templatesNames[12], + content : this.getStylesheetXML() + }; + break; + case ExcelFileTypes.WorkbookFile: + resultXml = { + name: ZipFiles.templatesNames[13], + content : ` +` + }; + break; + case ExcelFileTypes.WorksheetRelsFile: + resultXml = { + name: ZipFiles.dataFilesAndFoldersNames[1], + content : ` +` + }; + break; + case ExcelFileTypes.WorksheetFile: + resultXml = { + name: ZipFiles.templatesNames[11], + content : ZipFiles.getSheetDataFile(currentData, hasValues, isHGrid) + }; + break; + case ExcelFileTypes.ContentTypesFile: + resultXml = { + name: ZipFiles.templatesNames[14], + content : this.getContentTypesXML(hasValues) + }; + break; + case ExcelFileTypes.SharedStringsFile: + resultXml = { + name: ZipFiles.dataFilesAndFoldersNames[4], + content : ZipFiles.getSharedStringsXML(currentData) + }; + break; + case ExcelFileTypes.TablesFile: + resultXml = { + name: ZipFiles.dataFilesAndFoldersNames[3], + content : ZipFiles.getTablesXML(currentData) + }; + break; + default: + throw Error('Unexpected Excel file type!'); + } + + return resultXml; + } + + +} diff --git a/projects/igniteui-angular/grids/core/src/services/excel/zip-verification-wrapper.spec.ts b/projects/igniteui-angular/grids/core/src/services/excel/zip-verification-wrapper.spec.ts new file mode 100644 index 00000000000..f80e209444c --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/services/excel/zip-verification-wrapper.spec.ts @@ -0,0 +1,285 @@ +import { strFromU8 } from 'fflate'; +import { ExcelFileTypes } from './excel-enums'; +import { ZipFiles } from './zip-helper.spec'; + +export class ZipWrapper { + private _zip: Object; + private _filesAndFolders: string[]; + private _files: Map; + private _filesContent: IFileContent[] = []; + private _hasValues = true; + + constructor(currentZip: Object) { + this._zip = currentZip; + this._files = new Map(); + this._filesAndFolders = []; + this.createFilesAndFolders(this._zip, ''); + this._hasValues = this._filesAndFolders.length > ZipFiles.templatesNames.length; + this._filesContent = []; + } + + /* Asserts the zip contains the files it should contain. */ + public verifyStructure(isHGrid = false, message = '') { + let result = ObjectComparer.AreEqual(this.templateFilesAndFolders, ZipFiles.templatesNames); + const template = isHGrid ? ZipFiles.hGridDataFilesAndFoldersNames : ZipFiles.dataFilesAndFoldersNames; + + result = (this.hasValues) ? + result && ObjectComparer.AreEqual(this.dataFilesAndFolders, template) : + result && this._filesAndFolders.length === ZipFiles.templatesNames.length; + + expect(result).toBe(true, message + ' Unexpected zip structure!'); + } + + /* Verifies the contents of all template files and asserts the result. + Optionally, a message can be passed in, which, if specified, will be shown in the beginning of the comparison result. */ + public async verifyTemplateFilesContent(message = '', hasDates = false) { + ZipFiles.hasDates = hasDates; + + let result; + const msg = (message !== '') ? message + '\r\n' : ''; + + await this.readTemplateFiles().then(() => { + result = this.compareFiles(this.templateFilesContent, undefined); + expect(result.areEqual).toBe(true, msg + result.differences); + }); + } + + /* Verifies the contents of all data files and asserts the result. + Optionally, a message can be passed in, which, if specified, will be shown in the beginning of the comparison result. */ + public async verifyDataFilesContent(expectedData: IFileContent[], message = '', isHGrid = false) { + let result; + const msg = (message !== '') ? message + '\r\n' : ''; + + await this.readDataFiles().then(() => { + result = this.compareFiles(this.dataFilesContent, expectedData, isHGrid); + expect(result.areEqual).toBe(true, msg + result.differences); + }); + } + + private createFilesAndFolders(obj: Object, prefix: string) { + Object.keys(obj).forEach((key) => { + if (ArrayBuffer.isView(obj[key])) { + this._files.set(`${prefix}${key}`, obj[key] as Uint8Array); + this._filesAndFolders.push(`${prefix}${key}`); + } else { + const newPrefix = `${prefix}${key}/`; + this._filesAndFolders.push(newPrefix); + this.createFilesAndFolders(obj[key], newPrefix); + } + }); + } + + public get templateFilesAndFolders(): string[] { + return this._filesAndFolders.filter((name) => ZipFiles.templatesNames.indexOf(name) !== -1); + } + + public get dataFilesAndFolders(): string[] { + return this._filesAndFolders.filter((name) => ZipFiles.dataFilesAndFoldersNames.indexOf(name) !== -1); + } + + public get dataFilesOnly(): string[] { + return this.getFiles(this.dataFilesAndFolders); + } + + public get templateFilesOnly(): string[] { + return this.getFiles(this.templateFilesAndFolders); + } + + public get hasValues() { + return this._hasValues; + } + + private getFiles(collection: string[]) { + return collection.filter((f) => f.endsWith('/') === false); + } + + /* Reads all files and stores their contents in this._filesContent. */ + private readFiles(files: string[]) { + // const self = this; + this._filesContent = []; + for (const file of files) { + const content = strFromU8(this._files.get(file)); + this._filesContent.push({ + fileName: file, + fileContent: content + }); + } + } + + private async readDataFiles() { + await this.readFiles(this.dataFilesOnly); + } + + private async readTemplateFiles() { + const actualTemplates = (this.hasValues) ? this.templateFilesOnly.filter((f) => + f !== ZipFiles.templatesNames[11]) : this.templateFilesOnly; + await this.readFiles(actualTemplates); + } + + public get templateFilesContent(): IFileContent[] { + const actualTemplates = (this.hasValues) ? this.templateFilesOnly.filter((f) => + f !== ZipFiles.templatesNames[11]) : this.templateFilesOnly; + return this._filesContent.filter((c) => actualTemplates.indexOf(c.fileName) > -1); + } + + public get dataFilesContent(): IFileContent[] { + return this._filesContent.filter((c) => this.dataFilesOnly.indexOf(c.fileName) > -1); + } + + /* Formats the result of two files comparison by displaying both the actual and expected content. */ + private formatDifferences(differences, fileName, actualContent, expectedContent): string { + differences = `${differences} + ------------------ ${fileName} ------------------ + =================== Actual content ====================== + ${actualContent} + =================== Expected content ==================== + ${expectedContent}`; + return differences; + } + + /* Compares the content of two files based on the provided file type and expected value data. */ + private compareFilesContent(currentContent: string, fileType: ExcelFileTypes, fileData: string, isHGrid) { + let result = true; + let differences = ''; + const expectedFile = ZipFiles.createExpectedXML(fileType, fileData, this.hasValues, isHGrid); + const expectedContent = expectedFile.content; + result = ObjectComparer.AreEqualXmls(currentContent, expectedContent); + if (!result) { + differences = this.formatDifferences(differences, expectedFile.name, currentContent, expectedContent); + } + + return { areEqual: result, differences }; + } + + private compareContent(currentFile: IFileContent, expectedData: string, isHGrid) { + let result = true; + let differences = ''; + + const fileType = this.getFileTypeByName(currentFile.fileName); + + if (fileType !== undefined) { + const comparisonResult = this.compareFilesContent(currentFile.fileContent, fileType, expectedData, isHGrid); + result = comparisonResult.areEqual; + if (!result) { + differences = comparisonResult.differences; + } + } + + return { areEqual: result, differences }; + + } + + /* Compares the contents of the provided files to their expected values. */ + private compareFiles(actualFilesContent: IFileContent[], expectedFilesData: IFileContent[], isHGrid = false) { + let result = true; + let differences = ''; + for (const current of actualFilesContent) { + const index = (expectedFilesData !== undefined) ? expectedFilesData.findIndex((f) => f.fileName === current.fileName) : -1; + const excelData = (index > -1 && expectedFilesData[index] !== undefined) ? expectedFilesData[index].fileContent : ''; + const comparisonResult = this.compareContent(current, excelData, isHGrid); + result = result && comparisonResult.areEqual; + if (!comparisonResult.areEqual) { + differences = differences + comparisonResult.differences; + } + } + + return { areEqual: result, differences }; + } + + /* Returns file's name based on its type. */ + private getFileNameByType(type: ExcelFileTypes) { + const file = ZipFiles.files.find((f) => f.type === type); + return (file !== undefined) ? file.name : ''; + } + + /* Returns file's type based on its name. */ + private getFileTypeByName(name: string) { + const file = ZipFiles.files.find((f) => f.name === name); + return (file !== undefined) ? file.type : undefined; + } +} + +export interface IFileContent { + fileName: string; + fileContent: string; +} + +export class ObjectComparer { + public static AreEqual(actual, template): boolean { + + if (!ObjectComparer.compareTypesAndLength(actual, template)) { + return false; + } + + let result = true; + + // Compare properties + if (Object.prototype.toString.call(actual) === '[object Array]') { + for (let i = 0; i < actual.length; i++) { + result = (result && actual[i] === template[i]); + } + } else { + for (const key in actual) { + if (actual.hasOwnProperty(key)) { + // Compare the item + } + } + } + + return result; + } + + public static AreSimilar(actual, template): boolean { + + if (!ObjectComparer.compareTypesAndLength(actual, template)) { + return false; + } + + let result = true; + + // Compare properties + if (Object.prototype.toString.call(actual) === '[object Array]') { + for (let i = 0; i < actual.length; i++) { + result = (result && template.indexof(actual[i]) >= 0); + } + } else { + for (const key in actual) { + if (actual.hasOwnProperty(key)) { + // Compare the item + } + } + } + + return result; + } + + public static AreEqualXmls(actualXML: string, expectedXML: string) { + const regex = /(\r\n|\n|\r|\t|\s+)/g; + // /(\r\n|\n|\r|\t)/g; + const actual = actualXML.replace(regex, ''); + const expected = expectedXML.replace(regex, ''); + + return actual === expected; + } + + protected static compareTypesAndLength(actual, template): boolean { + + const actualType = Object.prototype.toString.call(actual); + const templateType = Object.prototype.toString.call(template); + + if (actualType !== templateType) { + return false; + } + // If items are not an object or array, return false + if (['[object Array]', '[object Object]'].indexOf(actualType) < 0) { + return false; + } + const actualLength = actualType === '[object Array]' ? actual.length : Object.keys(actual).length; + const templateLength = templateType === '[object Array]' ? template.length : Object.keys(template).length; + + if (actualLength !== templateLength) { + return false; + } + return true; + } +} diff --git a/projects/igniteui-angular/grids/core/src/services/exporter-common/base-export-service.ts b/projects/igniteui-angular/grids/core/src/services/exporter-common/base-export-service.ts new file mode 100644 index 00000000000..17d733b6b59 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/services/exporter-common/base-export-service.ts @@ -0,0 +1,1455 @@ +import { EventEmitter } from '@angular/core'; +import { ExportUtilities } from './export-utilities'; +import { IgxExporterOptionsBase } from './exporter-options-base'; +import { type ITreeGridRecord, type ColumnType, type GridTypeBase, type IPathSegment, type IgxSummaryResult, type GridColumnDataType, DataUtil, FilterUtil, GridSummaryCalculationMode, IBaseEventArgs, IFilteringState, IGroupByExpandState, IGroupByRecord, IGroupingState, TreeGridFilteringStrategy, cloneArray, cloneValue, columnFieldPath, resolveNestedPath, yieldingLoop, getHierarchy, isHierarchyMatch } from 'igniteui-angular/core'; + +import { DatePipe, FormatWidth, getLocaleCurrencyCode, getLocaleDateFormat, getLocaleDateTimeFormat } from '@angular/common'; + + +export enum ExportRecordType { + GroupedRecord = 'GroupedRecord', + TreeGridRecord = 'TreeGridRecord', + DataRecord = 'DataRecord', + HierarchicalGridRecord = 'HierarchicalGridRecord', + HeaderRecord = 'HeaderRecord', + SummaryRecord = 'SummaryRecord', + PivotGridRecord = 'PivotGridRecord' +} + +export enum ExportHeaderType { + RowHeader = 'RowHeader', + ColumnHeader = 'ColumnHeader', + MultiRowHeader = 'MultiRowHeader', + MultiColumnHeader = 'MultiColumnHeader', + PivotRowHeader = 'PivotRowHeader', + PivotMergedHeader = 'PivotMergedHeader', +} + +export interface IExportRecord { + data: any; + level: number; + type: ExportRecordType; + owner?: string | GridTypeBase; + hidden?: boolean; + summaryKey?: string; + hierarchicalOwner?: string; + references?: IColumnInfo[]; + /* Adding `rawData` and `dimesnionKeys` properties to support properly exporting pivot grid data to CSV. */ + rawData?: any; + dimensionKeys?: string[]; +} + +export interface IColumnList { + columns: IColumnInfo[]; + columnWidths: number[]; + indexOfLastPinnedColumn: number; + maxLevel?: number; + maxRowLevel?: number; +} + +export interface IColumnInfo { + header: string; + field: string; + skip: boolean; + dataType?: GridColumnDataType; + skipFormatter?: boolean; + formatter?: any; + headerType?: ExportHeaderType; + startIndex?: number; + columnSpan?: number; + rowSpan?: number; + level?: number; + exportIndex?: number; + pinnedIndex?: number; + columnGroupParent?: ColumnType | string; + columnGroup?: ColumnType | string; + currencyCode?: string; + displayFormat?: string; + dateFormat?: string; + digitsInfo?: string; +} +/** + * rowExporting event arguments + * this.exporterService.rowExporting.subscribe((args: IRowExportingEventArgs) => { + * // set args properties here + * }) + */ +export interface IRowExportingEventArgs extends IBaseEventArgs { + /** + * Contains the exporting row data + */ + rowData: any; + + /** + * Contains the exporting row index + */ + rowIndex: number; + + /** + * Skip the exporting row when set to true + */ + cancel: boolean; +} + +/** + * columnExporting event arguments + * ```typescript + * this.exporterService.columnExporting.subscribe((args: IColumnExportingEventArgs) => { + * // set args properties here + * }); + * ``` + */ +export interface IColumnExportingEventArgs extends IBaseEventArgs { + /** + * Contains the exporting column header + */ + header: string; + + /** + * Contains the exporting column field name + */ + field: string; + + /** + * Contains the exporting column index + */ + columnIndex: number; + + /** + * Skip the exporting column when set to true + */ + cancel: boolean; + + /** + * Export the column's data without applying its formatter, when set to true + */ + skipFormatter: boolean; + + /** + * A reference to the grid owner. + */ + grid?: GridTypeBase; +} + +/**hidden + * A helper class used to identify whether the user has set a specific columnIndex + * during columnExporting, so we can honor it at the exported file. +*/ +class IgxColumnExportingEventArgs implements IColumnExportingEventArgs { + public header: string; + public field: string; + public cancel: boolean; + public skipFormatter: boolean; + public grid?: GridTypeBase; + public owner?: any; + public userSetIndex? = false; + + private _columnIndex?: number; + + public get columnIndex(): number { + return this._columnIndex; + } + + public set columnIndex(value: number) { + this._columnIndex = value; + this.userSetIndex = true; + } + + constructor(original: IColumnExportingEventArgs) { + this.header = original.header; + this.field = original.field; + this.cancel = original.cancel; + this.skipFormatter = original.skipFormatter; + this.grid = original.grid; + this.owner = original.owner; + this._columnIndex = original.columnIndex; + } +} + +export const DEFAULT_OWNER = 'default'; +export const GRID_ROOT_SUMMARY = 'igxGridRootSummary'; +export const GRID_PARENT = 'grid-parent'; +export const GRID_LEVEL_COL = 'GRID_LEVEL_COL'; +const DEFAULT_COLUMN_WIDTH = 8.43; +const GRID_CHILD = 'grid-child-'; + +export abstract class IgxBaseExporter { + + public exportEnded = new EventEmitter(); + + /** + * This event is emitted when a row is exported. + * ```typescript + * this.exporterService.rowExporting.subscribe((args: IRowExportingEventArgs) => { + * // put event handler code here + * }); + * ``` + * + * @memberof IgxBaseExporter + */ + public rowExporting = new EventEmitter(); + + /** + * This event is emitted when a column is exported. + * ```typescript + * this.exporterService.columnExporting.subscribe((args: IColumnExportingEventArgs) => { + * // put event handler code here + * }); + * ``` + * + * @memberof IgxBaseExporter + */ + public columnExporting = new EventEmitter(); + + protected _sort = null; + protected pivotGridFilterFieldsCount: number; + protected _ownersMap: Map = new Map(); + + private locale: string + private _setChildSummaries = false + private isPivotGridExport: boolean; + private options: IgxExporterOptionsBase; + private summaries: Map> = new Map>(); + private rowIslandCounter = -1; + private flatRecords: IExportRecord[] = []; + private pivotGridColumns: IColumnInfo[] = [] + private pivotGridRowDimensionsMap: Map; + private ownerGrid: any; + + /* alternateName: exportGrid */ + /** + * Method for exporting IgxGrid component's data. + * ```typescript + * this.exporterService.export(this.igxGridForExport, this.exportOptions); + * ``` + * + * @memberof IgxBaseExporter + */ + public export(grid: any, options: IgxExporterOptionsBase): void { + if (options === undefined || options === null) { + throw Error('No options provided!'); + } + + this.options = options; + this.locale = grid.locale; + this.ownerGrid = grid; + let columns = grid.columns; + + if (this.options.ignoreMultiColumnHeaders) { + columns = columns.filter(col => col.children === undefined); + } + + const columnList = this.getColumns(columns); + + if (grid.type === 'hierarchical') { + this._ownersMap.set(grid, columnList); + + const childLayoutList = grid.childLayoutList; + + for (const island of childLayoutList) { + this.mapHierarchicalGridColumns(island, grid.data[0]); + } + } else if (grid.type === 'pivot') { + this.pivotGridColumns = []; + this.isPivotGridExport = true; + this.pivotGridRowDimensionsMap = new Map(); + + grid.visibleRowDimensions.filter(r => r.enabled).forEach(rowDimension => { + this.addToRowDimensionsMap(rowDimension, rowDimension.memberName); + }); + + this._ownersMap.set(DEFAULT_OWNER, columnList); + } else { + this._ownersMap.set(DEFAULT_OWNER, columnList); + } + + this.summaries = this.prepareSummaries(grid); + this._setChildSummaries = this.summaries.size > 1 && grid.summaryCalculationMode !== GridSummaryCalculationMode.rootLevelOnly; + + this.addLevelColumns(); + this.prepareData(grid); + this.addLevelData(); + this.addPivotGridColumns(grid); + this.addPivotRowHeaders(grid); + this.exportGridRecordsData(this.flatRecords, grid); + } + + /** + * Method for exporting any kind of array data. + * ```typescript + * this.exporterService.exportData(this.arrayForExport, this.exportOptions); + * ``` + * + * @memberof IgxBaseExporter + */ + public exportData(data: any[], options: IgxExporterOptionsBase): void { + if (options === undefined || options === null) { + throw Error('No options provided!'); + } + + this.options = options; + + const records = data.map(d => { + const record: IExportRecord = { + data: d, + type: ExportRecordType.DataRecord, + level: 0 + }; + + return record; + }); + + this.exportGridRecordsData(records); + } + + private addToRowDimensionsMap(rowDimension: any, rootParentName: string) { + this.pivotGridRowDimensionsMap[rowDimension.memberName] = rootParentName; + if (rowDimension.childLevel) { + this.addToRowDimensionsMap(rowDimension.childLevel, rootParentName) + } + } + + private exportGridRecordsData(records: IExportRecord[], grid?: GridTypeBase) { + if (this._ownersMap.size === 0) { + const recordsData = records.filter(r => r.type !== ExportRecordType.SummaryRecord).map(r => r.data); + const keys = ExportUtilities.getKeysFromData(recordsData); + const columns = keys.map((k) => + ({ header: k, field: k, skip: false, headerType: ExportHeaderType.ColumnHeader, level: 0, columnSpan: 1 })); + const columnWidths = new Array(keys.length).fill(DEFAULT_COLUMN_WIDTH); + + const mapRecord: IColumnList = { + columns, + columnWidths, + indexOfLastPinnedColumn: -1, + maxLevel: 0 + }; + + this._ownersMap.set(DEFAULT_OWNER, mapRecord); + } + + let shouldReorderColumns = false; + for (const [key, mapRecord] of this._ownersMap) { + let skippedPinnedColumnsCount = 0; + let columnsWithoutHeaderCount = 1; + let indexOfLastPinnedColumn = mapRecord.indexOfLastPinnedColumn; + + mapRecord.columns.forEach((column, index) => { + if (!column.skip) { + const columnExportArgs: IColumnExportingEventArgs = { + header: !ExportUtilities.isNullOrWhitespaces(column.header) ? + column.header : + 'Column' + columnsWithoutHeaderCount++, + field: column.field, + columnIndex: index, + cancel: false, + skipFormatter: false, + grid: key === DEFAULT_OWNER ? grid : key + }; + + const newColumnExportArgs = new IgxColumnExportingEventArgs(columnExportArgs); + this.columnExporting.emit(newColumnExportArgs); + + column.header = newColumnExportArgs.header; + column.skip = newColumnExportArgs.cancel; + column.skipFormatter = newColumnExportArgs.skipFormatter; + + if (newColumnExportArgs.userSetIndex) { + column.exportIndex = newColumnExportArgs.columnIndex; + shouldReorderColumns = true; + } + + if (column.skip) { + if (index <= indexOfLastPinnedColumn) { + skippedPinnedColumnsCount++; + } + + this.calculateColumnSpans(column, mapRecord, column.columnSpan); + + const nonSkippedColumns = mapRecord.columns.filter(c => !c.skip); + + if (nonSkippedColumns.length > 0) { + this._ownersMap.get(key).maxLevel = nonSkippedColumns.sort((a, b) => b.level - a.level)[0].level; + } + } + + if (this._sort && this._sort.fieldName === column.field) { + if (column.skip) { + this._sort = null; + } else { + this._sort.fieldName = column.header; + } + } + } + }); + + indexOfLastPinnedColumn -= skippedPinnedColumnsCount; + + // Reorder columns only if a column has been assigned a specific columnIndex during columnExporting event + if (shouldReorderColumns) { + mapRecord.columns = this.reorderColumns(mapRecord.columns); + } + } + + const dataToExport = new Array(); + const actualData = records[0]?.data; + const isSpecialData = ExportUtilities.isSpecialData(actualData); + + yieldingLoop(records.length, 100, (i) => { + const row = records[i]; + this.exportRow(dataToExport, row, i, isSpecialData); + }, () => { + this.exportDataImplementation(dataToExport, this.options, () => { + this.resetDefaults(); + }); + }); + } + + private calculateColumnSpans(column: IColumnInfo, mapRecord: IColumnList, span: number) { + if (column.headerType === ExportHeaderType.MultiColumnHeader && column.skip) { + const columnGroupChildren = mapRecord.columns.filter(c => c.columnGroupParent === column.columnGroup); + + columnGroupChildren.forEach(cgc => { + if (cgc.headerType === ExportHeaderType.MultiColumnHeader) { + cgc.columnSpan = 0; + cgc.columnGroupParent = null; + cgc.skip = true; + + this.calculateColumnSpans(cgc, mapRecord, cgc.columnSpan); + } else { + cgc.skip = true; + } + }); + } + + const targetCol = mapRecord.columns.filter(c => column.columnGroupParent !== null && column.columnGroupParent !== undefined && c.columnGroup === column.columnGroupParent)[0]; + if (targetCol !== undefined) { + targetCol.columnSpan -= span; + + if (targetCol.columnGroupParent !== null) { + this.calculateColumnSpans(targetCol, mapRecord, span); + } + + if (targetCol.columnSpan === 0) { + targetCol.skip = true; + } + } + } + + private exportRow(data: IExportRecord[], record: IExportRecord, index: number, isSpecialData: boolean) { + if (!isSpecialData) { + const owner = record.owner === undefined ? DEFAULT_OWNER : record.owner; + const ownerCols = this._ownersMap.get(owner).columns; + const hasRowHeaders = ownerCols.some(c => c.headerType === ExportHeaderType.RowHeader); + + if (record.type !== ExportRecordType.HeaderRecord) { + const columns = ownerCols + .filter(c => c.headerType === ExportHeaderType.ColumnHeader && !c.skip) + .sort((a, b) => a.startIndex - b.startIndex) + .sort((a, b) => a.pinnedIndex - b.pinnedIndex); + + if (hasRowHeaders) { + record.rawData = record.data; + } + + record.data = columns.reduce((a, e) => { + if (!e.skip) { + let rawValue = resolveNestedPath(record.data, columnFieldPath(e.field)) as any; + + const shouldApplyFormatter = e.formatter && !e.skipFormatter && record.type !== ExportRecordType.GroupedRecord; + const isOfDateType = e.dataType === 'date' || e.dataType === 'dateTime' || e.dataType === 'time'; + + if (isOfDateType && + record.type !== ExportRecordType.SummaryRecord && + record.type !== ExportRecordType.GroupedRecord && + !(rawValue instanceof Date) && + !shouldApplyFormatter && + rawValue !== undefined && + rawValue !== null) { + rawValue = new Date(rawValue); + } else if (e.dataType === 'string' && rawValue instanceof Date) { + rawValue = rawValue.toString(); + } + + let formattedValue = shouldApplyFormatter ? e.formatter(rawValue, record.data) : rawValue; + + if (this.isPivotGridExport && !isNaN(parseFloat(formattedValue))) { + formattedValue = parseFloat(formattedValue); + } + + a[e.field] = formattedValue; + } + return a; + }, {}); + } else { + record.data = record.data.filter((_, i) => !record.references[i].skip) + } + } + + const rowArgs = { + rowData: record.data, + rowIndex: index, + cancel: false, + owner: record.owner ?? this.ownerGrid + }; + + this.rowExporting.emit(rowArgs); + + if (!rowArgs.cancel) { + data.push(record); + } + } + + private reorderColumns(columns: IColumnInfo[]): IColumnInfo[] { + const filteredColumns = columns.filter(c => !c.skip); + const length = filteredColumns.length; + const specificIndicesColumns = filteredColumns.filter((col) => !isNaN(col.exportIndex)) + .sort((a, b) => a.exportIndex - b.exportIndex); + const indices = specificIndicesColumns.map(col => col.exportIndex); + + specificIndicesColumns.forEach(col => { + filteredColumns.splice(filteredColumns.indexOf(col), 1); + }); + + const reorderedColumns = new Array(length); + + if (specificIndicesColumns.length > Math.max(...indices)) { + return specificIndicesColumns.concat(filteredColumns); + } else { + indices.forEach((i, index) => { + if (i < 0 || i >= length) { + filteredColumns.push(specificIndicesColumns[index]); + } else { + let k = i; + while (k < length && reorderedColumns[k] !== undefined) { + ++k; + } + reorderedColumns[k] = specificIndicesColumns[index]; + } + }); + + for (let i = 0; i < length; i++) { + if (reorderedColumns[i] === undefined) { + reorderedColumns[i] = filteredColumns.splice(0, 1)[0]; + } + } + + } + return reorderedColumns; + } + + private prepareData(grid: GridTypeBase) { + this.flatRecords = []; + const hasFiltering = (grid.filteringExpressionsTree && grid.filteringExpressionsTree.filteringOperands.length > 0) || + (grid.advancedFilteringExpressionsTree && grid.advancedFilteringExpressionsTree.filteringOperands.length > 0); + const expressions = grid.groupingExpressions ? grid.groupingExpressions.concat(grid.sortingExpressions || []) : grid.sortingExpressions; + const hasSorting = expressions && expressions.length > 0; + let setSummaryOwner = false; + + switch (grid.type) { + case 'pivot': { + this.preparePivotGridData(grid); + break; + } + case 'hierarchical': { + this.prepareHierarchicalGridData(grid, hasFiltering, hasSorting); + setSummaryOwner = true; + break; + } + case 'tree': { + this.prepareTreeGridData(grid, hasFiltering, hasSorting); + break; + } + default: { + this.prepareGridData(grid, hasFiltering, hasSorting); + break; + } + } + + if (this.summaries.size > 0 && grid.summaryCalculationMode !== GridSummaryCalculationMode.childLevelsOnly) { + setSummaryOwner ? + this.setSummaries(GRID_ROOT_SUMMARY, 0, false, grid) : + this.setSummaries(GRID_ROOT_SUMMARY); + } + } + + private preparePivotGridData(grid: GridTypeBase) { + for (const record of grid.filteredSortedData) { + const recordData = Object.fromEntries(record.aggregationValues); + record.dimensionValues.forEach((value, key) => { + const actualKey = this.pivotGridRowDimensionsMap[key]; + recordData[actualKey] = value; + }); + + const pivotGridRecord: IExportRecord = { + data: recordData, + level: record.level, + type: ExportRecordType.PivotGridRecord + }; + + this.flatRecords.push(pivotGridRecord); + } + + if (this.flatRecords.length) { + this.flatRecords[0].dimensionKeys = Object.values(this.pivotGridRowDimensionsMap); + } + } + + private prepareHierarchicalGridData(grid: GridTypeBase, hasFiltering: boolean, hasSorting: boolean) { + + const skipOperations = + (!hasFiltering || !this.options.ignoreFiltering) && + (!hasSorting || !this.options.ignoreSorting); + + if (skipOperations) { + const data = grid.filteredSortedData; + this.addHierarchicalGridData(grid, data); + } else { + let data = grid.data; + + if (hasFiltering && !this.options.ignoreFiltering) { + const filteringState: IFilteringState = { + expressionsTree: grid.filteringExpressionsTree, + advancedExpressionsTree: grid.advancedFilteringExpressionsTree, + strategy: grid.filterStrategy + }; + + data = FilterUtil.filter(data, filteringState, grid); + } + + if (hasSorting && !this.options.ignoreSorting) { + this._sort = cloneValue(grid.sortingExpressions[0]); + + data = DataUtil.sort(data, grid.sortingExpressions, grid.sortStrategy, grid); + } + + this.addHierarchicalGridData(grid, data); + } + } + + private addHierarchicalGridData(grid: GridTypeBase, records: any[]) { + const childLayoutList = grid.childLayoutList; + const columnFields = this._ownersMap.get(grid).columns.map(col => col.field); + + for (const entry of records) { + const expansionStateVal = grid.expansionStates.has(entry) ? grid.expansionStates.get(entry) : grid.getDefaultExpandState(entry); + + const dataWithoutChildren = Object.keys(entry) + .filter(k => columnFields.includes(k)) + .reduce((obj, key) => { + obj[key] = entry[key]; + return obj; + }, {}); + + const hierarchicalGridRecord: IExportRecord = { + data: dataWithoutChildren, + level: 0, + type: ExportRecordType.HierarchicalGridRecord, + owner: grid, + hierarchicalOwner: GRID_PARENT + }; + + this.flatRecords.push(hierarchicalGridRecord); + + for (const island of childLayoutList) { + const path: IPathSegment = { + rowID: island.primaryKey ? entry[island.primaryKey] : entry, + rowKey: island.primaryKey ? entry[island.primaryKey] : entry, + rowIslandKey: island.key + }; + + const islandGrid = grid?.gridAPI.getChildGrid([path]); + const keyRecordData = this.prepareIslandData(island, islandGrid, entry[island.key]) || []; + + this.getAllChildColumnsAndData(island, keyRecordData, expansionStateVal, islandGrid); + } + } + } + + private prepareSummaries(grid: any): Map> { + let summaries = new Map>(); + + if (this.options.exportSummaries && grid.summaryService.summaryCacheMap.size > 0) { + const summaryCacheMap = grid.summaryService.summaryCacheMap; + + switch (grid.summaryCalculationMode) { + case GridSummaryCalculationMode.childLevelsOnly: + summaryCacheMap.delete(GRID_ROOT_SUMMARY); + break; + case GridSummaryCalculationMode.rootLevelOnly: + for (const k of summaryCacheMap.keys()) { + if (k !== GRID_ROOT_SUMMARY) { + summaryCacheMap.delete(k); + } + } + break; + } + + summaries = summaryCacheMap; + } + + return summaries; + } + + private prepareIslandData(island: any, islandGrid: GridTypeBase, data: any[]): any[] { + if (islandGrid !== undefined) { + const hasFiltering = (islandGrid.filteringExpressionsTree && + islandGrid.filteringExpressionsTree.filteringOperands.length > 0) || + (islandGrid.advancedFilteringExpressionsTree && + islandGrid.advancedFilteringExpressionsTree.filteringOperands.length > 0); + + const hasSorting = islandGrid.sortingExpressions && + islandGrid.sortingExpressions.length > 0; + + const skipOperations = + (!hasFiltering || !this.options.ignoreFiltering) && + (!hasSorting || !this.options.ignoreSorting); + + if (skipOperations) { + data = islandGrid.filteredSortedData; + } else { + if (hasFiltering && !this.options.ignoreFiltering) { + const filteringState: IFilteringState = { + expressionsTree: islandGrid.filteringExpressionsTree, + advancedExpressionsTree: islandGrid.advancedFilteringExpressionsTree, + strategy: islandGrid.filterStrategy + }; + + data = FilterUtil.filter(data, filteringState, islandGrid); + } + + if (hasSorting && !this.options.ignoreSorting) { + this._sort = cloneValue(islandGrid.sortingExpressions[0]); + + data = DataUtil.sort(data, islandGrid.sortingExpressions, islandGrid.sortStrategy, islandGrid); + } + } + } else { + const hasFiltering = (island.filteringExpressionsTree && + island.filteringExpressionsTree.filteringOperands.length > 0) || + (island.advancedFilteringExpressionsTree && + island.advancedFilteringExpressionsTree.filteringOperands.length > 0); + + const hasSorting = island.sortingExpressions && + island.sortingExpressions.length > 0; + + const skipOperations = + (!hasFiltering || this.options.ignoreFiltering) && + (!hasSorting || this.options.ignoreSorting); + + if (!skipOperations) { + if (hasFiltering && !this.options.ignoreFiltering) { + const filteringState: IFilteringState = { + expressionsTree: island.filteringExpressionsTree, + advancedExpressionsTree: island.advancedFilteringExpressionsTree, + strategy: island.filterStrategy + }; + + data = FilterUtil.filter(data, filteringState, island); + } + + if (hasSorting && !this.options.ignoreSorting) { + this._sort = cloneValue(island.sortingExpressions[0]); + + data = DataUtil.sort(data, island.sortingExpressions, island.sortStrategy, island); + } + } + } + + return data; + } + + private getAllChildColumnsAndData(island: any, + childData: any[], expansionStateVal: boolean, grid: GridTypeBase) { + const hierarchicalOwner = `${GRID_CHILD}${++this.rowIslandCounter}`; + const columnList = this._ownersMap.get(island).columns; + const columnHeaders = columnList.filter(col => col.headerType === ExportHeaderType.ColumnHeader); + const columnHeader = columnHeaders.map(col => col.header ? col.header : col.field); + + const headerRecord: IExportRecord = { + data: columnHeader, + level: island.level, + type: ExportRecordType.HeaderRecord, + owner: island, + hidden: !expansionStateVal, + references: columnHeaders, + hierarchicalOwner + }; + + if (childData && childData.length > 0) { + this.flatRecords.push(headerRecord); + + for (const rec of childData) { + const exportRecord: IExportRecord = { + data: rec, + level: island.level, + type: ExportRecordType.HierarchicalGridRecord, + owner: island, + hidden: !expansionStateVal, + hierarchicalOwner + }; + + exportRecord.summaryKey = island.key; + this.flatRecords.push(exportRecord); + + if (island.children.length > 0) { + const islandExpansionStateVal = grid === undefined ? + false : + grid.expansionStates.has(rec) ? + grid.expansionStates.get(rec) : + false; + + for (const childIsland of island.children) { + const path: IPathSegment = { + rowID: childIsland.primaryKey ? rec[childIsland.primaryKey] : rec, + rowKey: childIsland.primaryKey ? rec[childIsland.primaryKey] : rec, + rowIslandKey: childIsland.key + }; + + // only defined when row is expanded in UI + const childIslandGrid = grid?.gridAPI.getChildGrid([path]); + const keyRecordData = this.prepareIslandData(island, childIslandGrid, rec[childIsland.key]) || []; + + this.getAllChildColumnsAndData(childIsland, keyRecordData, islandExpansionStateVal, childIslandGrid); + } + } + } + + if (grid) { + const summaries = this.prepareSummaries(grid); + for (const k of summaries.keys()) { + const summary = summaries.get(k); + this.setSummaries(island.key, island.level, !expansionStateVal, island, summary, hierarchicalOwner) + } + } + } + } + + private prepareGridData(grid: GridTypeBase, hasFiltering: boolean, hasSorting: boolean) { + const groupedGridGroupingState: IGroupingState = { + expressions: grid.groupingExpressions, + expansion: grid.groupingExpansionState, + defaultExpanded: grid.groupsExpanded, + }; + + const hasGrouping = grid.groupingExpressions && + grid.groupingExpressions.length > 0; + + const skipOperations = + (!hasFiltering || !this.options.ignoreFiltering) && + (!hasSorting || !this.options.ignoreSorting) && + (!hasGrouping || !this.options.ignoreGrouping); + + if (skipOperations) { + if (hasGrouping) { + this.addGroupedData(grid, grid.groupsRecords, groupedGridGroupingState, true); + } else { + this.addFlatData(grid.filteredSortedData); + } + } else { + let gridData = grid.data; + + if (hasFiltering && !this.options.ignoreFiltering) { + const filteringState: IFilteringState = { + expressionsTree: grid.filteringExpressionsTree, + advancedExpressionsTree: grid.advancedFilteringExpressionsTree, + strategy: grid.filterStrategy + }; + + gridData = FilterUtil.filter(gridData, filteringState, grid); + } + + if (hasSorting && !this.options.ignoreSorting) { + // TODO: We should drop support for this since in a grouped grid it doesn't make sense + // this._sort = !isGroupedGrid ? + // cloneValue(grid.sortingExpressions[0]) : + // grid.sortingExpressions.length > 1 ? + // cloneValue(grid.sortingExpressions[1]) : + // cloneValue(grid.sortingExpressions[0]); + const expressions = grid.groupingExpressions ? grid.groupingExpressions.concat(grid.sortingExpressions || []) : grid.sortingExpressions; + gridData = DataUtil.sort(gridData, expressions, grid.sortStrategy, grid); + } + + if (hasGrouping && !this.options.ignoreGrouping) { + const groupsRecords = []; + DataUtil.group(cloneArray(gridData), groupedGridGroupingState, grid.groupStrategy, grid, groupsRecords); + gridData = groupsRecords; + } + + if (hasGrouping && !this.options.ignoreGrouping) { + this.addGroupedData(grid, gridData, groupedGridGroupingState, true); + } else { + this.addFlatData(gridData); + } + } + } + + private prepareTreeGridData(grid: GridTypeBase, hasFiltering: boolean, hasSorting: boolean) { + const skipOperations = + (!hasFiltering || !this.options.ignoreFiltering) && + (!hasSorting || !this.options.ignoreSorting); + + if (skipOperations) { + this.addTreeGridData(grid.processedRootRecords); + } else { + let gridData = grid.rootRecords; + + if (hasFiltering && !this.options.ignoreFiltering) { + const filteringState: IFilteringState = { + expressionsTree: grid.filteringExpressionsTree, + advancedExpressionsTree: grid.advancedFilteringExpressionsTree, + strategy: (grid.filterStrategy) ? grid.filterStrategy : new TreeGridFilteringStrategy() + }; + + gridData = filteringState.strategy + .filter(gridData, filteringState.expressionsTree, filteringState.advancedExpressionsTree); + } + + if (hasSorting && !this.options.ignoreSorting) { + this._sort = cloneValue(grid.sortingExpressions[0]); + + gridData = DataUtil.treeGridSort(gridData, grid.sortingExpressions, grid.sortStrategy); + } + + this.addTreeGridData(gridData); + } + } + + private addTreeGridData(records: ITreeGridRecord[], parentExpanded = true, hierarchicalOwner?: string) { + if (!records) { + return; + } + + for (const record of records) { + const treeGridRecord: IExportRecord = { + data: record.data, + level: record.level, + hidden: !parentExpanded, + type: ExportRecordType.TreeGridRecord, + summaryKey: record.key, + hierarchicalOwner: record.level === 0 ? GRID_PARENT : hierarchicalOwner + }; + + this.flatRecords.push(treeGridRecord); + + if (record.children) { + this.getTreeGridChildData(record.children, record.key, record.level, record.expanded && parentExpanded) + } + } + } + + private getTreeGridChildData(recordChildren: ITreeGridRecord[], key: string, level: number, parentExpanded = true) { + const hierarchicalOwner = `${GRID_CHILD}${++this.rowIslandCounter}` + let summaryLevel = level; + let summaryHidden = !parentExpanded; + + for (const rc of recordChildren) { + if (rc.children && rc.children.length > 0) { + this.addTreeGridData([rc], parentExpanded, hierarchicalOwner); + summaryLevel = rc.level; + } else { + + const currentRecord: IExportRecord = { + data: rc.data, + level: rc.level, + hidden: !parentExpanded, + type: ExportRecordType.DataRecord, + hierarchicalOwner + }; + + if (this._setChildSummaries) { + currentRecord.summaryKey = key; + } + + this.flatRecords.push(currentRecord); + summaryLevel = rc.level; + summaryHidden = !parentExpanded + } + } + + if (this._setChildSummaries) { + this.setSummaries(key, summaryLevel, summaryHidden, null, null, hierarchicalOwner); + } + } + + private addFlatData(records: any) { + if (!records) { + return; + } + for (const record of records) { + const data: IExportRecord = { + data: record, + type: ExportRecordType.DataRecord, + level: 0 + }; + + this.flatRecords.push(data); + } + } + + private setSummaries(summaryKey: string, level = 0, hidden = false, owner?: any, summary?: Map, hierarchicalOwner?: string) { + const rootSummary = summary ?? this.summaries.get(summaryKey); + + if (rootSummary) { + const values = [...rootSummary.values()]; + const biggest = values.sort((a, b) => b.length - a.length)[0]; + + for (let i = 0; i < biggest.length; i++) { + const obj = {} + + for (const [key, value] of rootSummary) { + const summaries = value.map(s => ({ label: s.label, value: s.summaryResult })) + obj[key] = summaries[i]; + } + + const summaryRecord: IExportRecord = { + data: obj, + type: ExportRecordType.SummaryRecord, + level, + hidden, + summaryKey, + hierarchicalOwner + }; + + if (owner) { + summaryRecord.owner = owner; + } + + this.flatRecords.push(summaryRecord); + } + } + } + + private addGroupedData(grid: GridTypeBase, records: IGroupByRecord[], groupingState: IGroupingState, setGridParent: boolean, parentExpanded = true, summaryKeysArr: string[] = []) { + if (!records) { + return; + } + + let previousKey = '' + const firstCol = this._ownersMap.get(DEFAULT_OWNER).columns + .filter(c => c.headerType === ExportHeaderType.ColumnHeader && !c.skip) + .sort((a, b) => a.startIndex - b.startIndex) + .sort((a, b) => a.pinnedIndex - b.pinnedIndex)[0].field; + + for (const record of records) { + let recordVal = record.value; + const hierarchicalOwner = setGridParent ? GRID_PARENT : `${GRID_CHILD}${++this.rowIslandCounter}`; + const hierarchy = getHierarchy(record); + const expandState: IGroupByExpandState = groupingState.expansion.find((s) => + isHierarchyMatch(s.hierarchy || [{ fieldName: record.expression.fieldName, value: recordVal }], + hierarchy, + grid.groupingExpressions)); + const expanded = expandState ? expandState.expanded : groupingState.defaultExpanded; + + const isDate = recordVal instanceof Date; + + if (isDate) { + const timeZoneOffset = recordVal.getTimezoneOffset() * 60000; + const isoString = (new Date(recordVal - timeZoneOffset)).toISOString(); + const pipe = new DatePipe(grid.locale); + recordVal = pipe.transform(isoString); + } + + const groupExpressionName = record.column && record.column.header ? + record.column.header : + record.expression.fieldName; + + recordVal = recordVal !== null ? recordVal : ''; + + const groupExpression: IExportRecord = { + data: { [firstCol]: `${groupExpressionName}: ${recordVal ?? '(Blank)'} (${record.records.length})` }, + level: record.level, + hidden: !parentExpanded, + type: ExportRecordType.GroupedRecord, + hierarchicalOwner + }; + + this.flatRecords.push(groupExpression); + + let currKey = ''; + let summaryKey = ''; + + if (this._setChildSummaries) { + currKey = `'${record.expression.fieldName}': '${recordVal}'`; + summaryKeysArr = summaryKeysArr.filter(a => a !== previousKey); + previousKey = currKey; + summaryKeysArr.push(currKey); + summaryKey = `{ ${summaryKeysArr.join(', ')} }`; + groupExpression.summaryKey = summaryKey; + } + + if (record.groups.length > 0) { + this.addGroupedData(grid, record.groups, groupingState, false, expanded && parentExpanded, summaryKeysArr); + } else { + const rowRecords = record.records; + + for (const rowRecord of rowRecords) { + const currentRecord: IExportRecord = { + data: rowRecord, + level: record.level + 1, + hidden: !(expanded && parentExpanded), + type: ExportRecordType.DataRecord, + hierarchicalOwner + }; + + if (summaryKey) { + currentRecord.summaryKey = summaryKey; + } + + this.flatRecords.push(currentRecord); + } + } + + if (this._setChildSummaries) { + this.setSummaries(summaryKey, record.level + 1, !(expanded && parentExpanded), null, null, hierarchicalOwner); + summaryKeysArr.pop(); + } + } + } + + private getColumns(columns: ColumnType[]): IColumnList { + const colList = []; + const colWidthList = []; + const hiddenColumns = []; + let indexOfLastPinnedColumn = -1; + let lastVisibleColumnIndex = -1; + let maxLevel = 0; + + columns.forEach((column) => { + const columnHeader = !ExportUtilities.isNullOrWhitespaces(column.header) ? column.header : column.field; + const exportColumn = !column.hidden || this.options.ignoreColumnsVisibility; + const index = this.options.ignoreColumnsOrder || this.options.ignoreColumnsVisibility ? column.index : column.visibleIndex; + const columnWidth = Number(column.width?.slice(0, -2)) || DEFAULT_COLUMN_WIDTH; + const columnLevel = !this.options.ignoreMultiColumnHeaders ? column.level : 0; + + const isMultiColHeader = column.columnGroup; + const colSpan = isMultiColHeader ? + column.allChildren + .filter(ch => !(ch.columnGroup) && (!this.options.ignoreColumnsVisibility ? !ch.hidden : true)) + .length : + 1; + + const columnInfo: IColumnInfo = { + header: ExportUtilities.sanitizeValue(columnHeader), + dataType: column.dataType, + field: column.field, + skip: !exportColumn, + formatter: column.formatter, + skipFormatter: false, + + headerType: isMultiColHeader ? ExportHeaderType.MultiColumnHeader : ExportHeaderType.ColumnHeader, + columnSpan: colSpan, + level: columnLevel, + startIndex: index, + pinnedIndex: !column.pinned ? + Number.MAX_VALUE : + !column.hidden ? + column.grid.pinnedColumns.indexOf(column) + : NaN, + columnGroupParent: column.parent ? column.parent : null, + columnGroup: isMultiColHeader ? column : null + }; + + if (column.dataType === 'currency') { + columnInfo.currencyCode = column.pipeArgs.currencyCode + ? column.pipeArgs.currencyCode + : getLocaleCurrencyCode(this.locale); + + columnInfo.displayFormat = column.pipeArgs.display + ? column.pipeArgs.display + : 'symbol'; + + columnInfo.digitsInfo = column.pipeArgs.digitsInfo + ? column.pipeArgs.digitsInfo + : '1.0-2'; + } + + if (column.dataType === 'date') { + columnInfo.dateFormat = getLocaleDateFormat(this.locale, FormatWidth.Medium); + } + + if (column.dataType === 'dateTime') { + columnInfo.dateFormat = getLocaleDateTimeFormat(this.locale, FormatWidth.Medium); + } + + if (this.options.ignoreColumnsOrder) { + if (columnInfo.startIndex !== columnInfo.pinnedIndex) { + columnInfo.pinnedIndex = Number.MAX_VALUE; + } + } + + if (column.level > maxLevel && !this.options.ignoreMultiColumnHeaders) { + maxLevel = column.level; + } + + if (index !== -1) { + colList.push(columnInfo); + colWidthList.push(columnWidth); + lastVisibleColumnIndex = Math.max(lastVisibleColumnIndex, colList.indexOf(columnInfo)); + } else { + hiddenColumns.push(columnInfo); + } + + if (column.pinned && exportColumn && columnInfo.headerType === ExportHeaderType.ColumnHeader) { + indexOfLastPinnedColumn++; + } + + }); + + //Append the hidden columns to the end of the list + hiddenColumns.forEach((hiddenColumn) => { + colList[++lastVisibleColumnIndex] = hiddenColumn; + }); + + const result: IColumnList = { + columns: colList, + columnWidths: colWidthList, + indexOfLastPinnedColumn, + maxLevel + }; + + return result; + } + + private mapHierarchicalGridColumns(island: any, gridData: any) { + let columnList: IColumnList; + let keyData; + + if (island.autoGenerate) { + keyData = gridData[island.key]; + const islandKeys = island.children.map(i => i.key); + + const islandData = keyData.map(i => { + const newItem = {}; + + Object.keys(i).map(k => { + if (!islandKeys.includes(k)) { + newItem[k] = i[k]; + } + }); + + return newItem; + }); + + columnList = this.getAutoGeneratedColumns(islandData); + } else { + const islandColumnList = island.columns; + columnList = this.getColumns(islandColumnList); + } + + this._ownersMap.set(island, columnList); + + if (island.children.length > 0) { + for (const childIsland of island.children) { + const islandKeyData = keyData !== undefined ? keyData[0] : {}; + this.mapHierarchicalGridColumns(childIsland, islandKeyData); + } + } + } + + private getAutoGeneratedColumns(data: any[]) { + const colList = []; + const colWidthList = []; + const keys = Object.keys(data[0]); + + keys.forEach((colKey, i) => { + const columnInfo: IColumnInfo = { + header: colKey, + field: colKey, + dataType: 'string', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + columnSpan: 1, + level: 0, + startIndex: i, + pinnedIndex: Number.MAX_VALUE + }; + + colList.push(columnInfo); + colWidthList.push(DEFAULT_COLUMN_WIDTH); + }); + + const result: IColumnList = { + columns: colList, + columnWidths: colWidthList, + indexOfLastPinnedColumn: -1, + maxLevel: 0, + }; + + return result; + } + + private addPivotRowHeaders(grid: any) { + if (grid?.pivotUI?.showRowHeaders) { + const headersList = this._ownersMap.get(DEFAULT_OWNER); + const enabledRows = grid.visibleRowDimensions.filter(r => r.enabled).map((r, index) => ({ name: r.displayName || r.memberName, level: index })); + let startIndex = 0; + enabledRows.forEach(x => { + headersList.columns.unshift({ + rowSpan: headersList.maxLevel + 1, + field: x.name, + header: x.name, + startIndex: startIndex, + skip: false, + pinnedIndex: 0, + level: x.level, + dataType: 'string', + headerType: ExportHeaderType.PivotRowHeader + }); + startIndex += 1; + }); + headersList.columnWidths.unshift(...Array(enabledRows.length).fill(200)); + } + } + + private addPivotGridColumns(grid: any) { + if (grid.type !== 'pivot') { + return; + } + + const enabledRows = grid.visibleRowDimensions.map((r, i) => ({ name: r.memberName, level: i })); + + this.preparePivotGridColumns(enabledRows); + this.pivotGridFilterFieldsCount = enabledRows.length; + + const columnList = this._ownersMap.get(DEFAULT_OWNER); + columnList.columns.unshift(...this.pivotGridColumns); + columnList.columnWidths.unshift(...Array(this.pivotGridColumns.length).fill(200)); + columnList.indexOfLastPinnedColumn = enabledRows.length - 1; + columnList.maxRowLevel = enabledRows.length; + this._ownersMap.set(DEFAULT_OWNER, columnList); + } + + private preparePivotGridColumns(keys: any, columnGroupParent?: string): any { + if (keys.length === 0) { + return; + } + + const records = this.flatRecords.map(r => r.data); + const groupedRecords = this.groupByKeys(records, keys); + + this.createRowDimension(groupedRecords, keys, columnGroupParent); + } + + private groupByKeys(items: any[], keys: any[]): any { + const group = (data: any[], groupKeys: any[]): any => { + if (groupKeys.length === 0) return data; + + const newKeys = [...groupKeys]; + const key = newKeys.shift().name; + const map = new Map(); + + for (const item of data) { + const keyValue = item[key]; + if (!map.has(keyValue)) { + map.set(keyValue, []); + } + map.get(keyValue).push(item); + } + + for (const [keyValue, value] of map) { + map.set(keyValue, group(value, newKeys)); + } + + return map; + }; + + return group(items, keys); + } + + private calculateRowSpan(value: any): number { + if (value instanceof Map) { + return Array.from(value.values()).reduce( + (total, current) => total + this.calculateRowSpan(current), + 0 + ) + } else if (Array.isArray(value)) { + return value.length; + } + + return 0; + } + + private createRowDimension(node: any, keys: any[], columnGroupParent?: string) { + if (!(node instanceof Map)) return; + + const key = keys[0]; + const newKeys = keys.filter(k => k.level > key.level); + let startIndex = 0; + for (const k of node.keys()) { + let groupKey = k; + const rowSpan = this.calculateRowSpan(node.get(k)); + + const rowDimensionColumn: IColumnInfo = { + columnSpan: 1, + rowSpan, + field: groupKey, + header: groupKey, + startIndex, + skip: false, + pinnedIndex: 0, + level: key.level, + dataType: 'string', + headerType: rowSpan > 1 ? ExportHeaderType.MultiRowHeader : ExportHeaderType.RowHeader, + }; + + if (!groupKey) { + // if (this.pivotGridColumns?.length) + // this.pivotGridColumns[this.pivotGridColumns.length - 1].columnSpan += 1; + rowDimensionColumn.headerType = ExportHeaderType.PivotMergedHeader; + groupKey = columnGroupParent; + } + if (key.level > 0) { + rowDimensionColumn.columnGroupParent = columnGroupParent; + } else { + rowDimensionColumn.columnGroup = groupKey; + } + + this.pivotGridColumns.push(rowDimensionColumn); + startIndex += rowSpan; + } + + for (const k of node.keys()) { + this.createRowDimension(node.get(k), newKeys, columnGroupParent); + } + } + + private addLevelColumns() { + if (this.options.exportSummaries && this.summaries.size > 0) { + this._ownersMap.forEach(om => { + const levelCol: IColumnInfo = { + header: GRID_LEVEL_COL, + dataType: 'number', + field: GRID_LEVEL_COL, + skip: false, + skipFormatter: false, + headerType: ExportHeaderType.ColumnHeader, + columnSpan: 1, + level: 0, + }; + + om.columns.push(levelCol); + om.columnWidths.push(20); + }) + } + } + + private addLevelData() { + if (this.options.exportSummaries && this.summaries.size > 0) { + for (const r of this.flatRecords) { + if (r.type === ExportRecordType.DataRecord || r.type === ExportRecordType.TreeGridRecord || r.type === ExportRecordType.HierarchicalGridRecord) { + r.data[GRID_LEVEL_COL] = r.level; + } + } + } + } + + private resetDefaults() { + this._sort = null; + this.flatRecords = []; + this.options = {} as IgxExporterOptionsBase; + this._ownersMap.clear(); + this.rowIslandCounter = 0; + } + + protected abstract exportDataImplementation(data: any[], options: IgxExporterOptionsBase, done: () => void): void; +} diff --git a/projects/igniteui-angular/grids/core/src/services/exporter-common/export-utilities.ts b/projects/igniteui-angular/grids/core/src/services/exporter-common/export-utilities.ts new file mode 100644 index 00000000000..7aebffbed55 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/services/exporter-common/export-utilities.ts @@ -0,0 +1,74 @@ +/** + * @hidden + */ +export class ExportUtilities { + public static getKeysFromData(data: any[]) { + const length = data.length; + if (length === 0) { + return []; + } + + const dataEntry = data[0]; + const dataEntryMiddle = data[Math.floor(length / 2)]; + const dataEntryLast = data[length - 1]; + + const keys1 = Object.keys(dataEntry); + const keys2 = Object.keys(dataEntryMiddle); + const keys3 = Object.keys(dataEntryLast); + + const keys = new Set(keys1.concat(keys2).concat(keys3)); + + return !ExportUtilities.isSpecialData(dataEntry) ? Array.from(keys) : ['Column 1']; + } + + public static saveBlobToFile(blob: Blob, fileName) { + const doc = globalThis.document; + const a = doc.createElement('a'); + const url = window.URL.createObjectURL(blob); + a.download = fileName; + + a.href = url; + doc.body.appendChild(a); + a.click(); + doc.body.removeChild(a); + window.URL.revokeObjectURL(url); + } + + public static stringToArrayBuffer(s: string): ArrayBuffer { + const buf = new ArrayBuffer(s.length); + const view = new Uint8Array(buf); + for (let i = 0; i !== s.length; ++i) { + view[i] = s.charCodeAt(i) & 0xFF; + } + return buf; + } + + public static isSpecialData(data: any): boolean { + return (typeof data === 'string' || + typeof data === 'number' || + data instanceof Date); + } + + public static hasValue(value: any): boolean { + return value !== undefined && value !== null; + } + + public static isNullOrWhitespaces(value: string): boolean { + return value === undefined || value === null || !value.trim(); + } + + public static sanitizeValue(value: any): string { + if (!this.hasValue(value)) { + return ''; + } else { + const stringValue = String(value); + return stringValue.replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + // Bug #14944 - Remove the not supported null character (\u0000, \x00) + .replace(/\x00/g, ''); + } + } +} diff --git a/projects/igniteui-angular/grids/core/src/services/exporter-common/exporter-options-base.ts b/projects/igniteui-angular/grids/core/src/services/exporter-common/exporter-options-base.ts new file mode 100644 index 00000000000..db18b6659f1 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/services/exporter-common/exporter-options-base.ts @@ -0,0 +1,137 @@ +export abstract class IgxExporterOptionsBase { + /** + * Specifies whether hidden columns should be exported. + * ```typescript + * let ignoreColumnsVisibility = this.exportOptions.ignoreColumnsVisibility; + * this.exportOptions.ignoreColumnsVisibility = true; + * ``` + * + * @memberof IgxExporterOptionsBase + */ + public ignoreColumnsVisibility = false; + + /** + * Specifies whether filtered out rows should be exported. + * ```typescript + * let ignoreFiltering = this.exportOptions.ignoreFiltering; + * this.exportOptions.ignoreFiltering = true; + * ``` + * + * @memberof IgxExporterOptionsBase + */ + public ignoreFiltering = false; + + /** + * Specifies if the exporter should ignore the current column order in the IgxGrid. + * ```typescript + * let ignoreColumnsOrder = this.exportOptions.ignoreColumnsOrder; + * this.exportOptions.ignoreColumnsOrder = true; + * ``` + * + * @memberof IgxExporterOptionsBase + */ + public ignoreColumnsOrder = false; + + /** + * Specifies whether the exported data should be sorted as in the provided IgxGrid. + * When you export grouped data, setting ignoreSorting to true will cause + * the grouping to fail because it relies on the sorting of the records. + * ```typescript + * let ignoreSorting = this.exportOptions.ignoreSorting; + * this.exportOptions.ignoreSorting = true; + * ``` + * + * @memberof IgxExporterOptionsBase + */ + public ignoreSorting = false; + + /** + * Specifies whether the exported data should be grouped as in the provided IgxGrid. + * ```typescript + * let ignoreGrouping = this.exportOptions.ignoreGrouping; + * this.exportOptions.ignoreGrouping = true; + * ``` + * + * @memberof IgxExporterOptionsBase + */ + public ignoreGrouping = false; + + /** + * Specifies whether the exported data should include multi column headers as in the provided IgxGrid. + * ```typescript + * let ignoreMultiColumnHeaders = this.exportOptions.ignoreMultiColumnHeaders; + * this.exportOptions.ignoreMultiColumnHeaders = true; + * ``` + * + * @memberof IgxExporterOptionsBase + */ + public ignoreMultiColumnHeaders = false; + + /** + * Specifies whether the exported data should include column summaries. + * ```typescript + * let exportSummaries = this.exportOptions.exportSummaries; + * this.exportOptions.exportSummaries = true; + * ``` + * + * @memberof IgxExporterOptionsBase + */ + public exportSummaries = true; + + /** + * Specifies whether the exported data should have frozen headers. + * ```typescript + * let freezeHeaders = this.exportOptions.freezeHeaders; + * this.exportOptions.freezeHeaders = true; + * ``` + * + * @memberof IgxExporterOptionsBase + */ + public freezeHeaders = false; + + /** + * Specifies whether the headers should be exported if there is no data. + * ```typescript + * let alwaysExportHeaders = this.exportOptions.alwaysExportHeaders; + * this.exportOptions.alwaysExportHeaders = false; + * ``` + * + * @memberof IgxExporterOptionsBase + */ + public alwaysExportHeaders = true; + + private _fileName: string; + + constructor(fileName: string, protected _fileExtension: string) { + this.setFileName(fileName); + } + + private setFileName(fileName: string): void { + this._fileName = fileName + (fileName.endsWith(this._fileExtension) === false ? this._fileExtension : ''); + } + + /** + * Gets the file name which will be used for the exporting operation. + * ```typescript + * let fileName = this.exportOptions.fileName; + * ``` + * + * @memberof IgxExporterOptionsBase + */ + public get fileName() { + return this._fileName; + } + + /** + * Sets the file name which will be used for the exporting operation. + * ```typescript + * this.exportOptions.fileName = 'exportedData01'; + * ``` + * + * @memberof IgxExporterOptionsBase + */ + public set fileName(value) { + this.setFileName(value); + } + +} diff --git a/projects/igniteui-angular/grids/core/src/services/exporter-common/test-methods.spec.ts b/projects/igniteui-angular/grids/core/src/services/exporter-common/test-methods.spec.ts new file mode 100644 index 00000000000..9ec3323543d --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/services/exporter-common/test-methods.spec.ts @@ -0,0 +1,54 @@ + +import { TestBed } from '@angular/core/testing'; +import { GridIDNameJobTitleComponent } from '../../../../../test-utils/grid-samples.spec'; +import { wait } from '../../../../../test-utils/ui-interactions.spec'; +import { IgxGridComponent } from 'igniteui-angular/grids/grid'; +import { IgxStringFilteringOperand } from 'igniteui-angular/core'; + +export class TestMethods { + + public static async testRawData(myGrid: IgxGridComponent, action: (grid) => Promise) { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + await wait(16); + myGrid = fix.componentInstance.grid; + + expect(myGrid.rowList.length).toEqual(10, 'Invalid number of rows initialized!'); + await action(myGrid); + } + + /* Creates an instance of GridDeclarationComponent; If filterParams is not specified, + applies the following filter: ["JobTitle", "Senior", IgxStringFilteringOperand.instance().condition('contains'), true]. */ + public static async createGridAndFilter(...filterParams: any[]) { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + await wait(16); + const myGrid = fix.componentInstance.grid; + + filterParams = (filterParams.length === 0) ? + ['JobTitle', 'Senior', IgxStringFilteringOperand.instance().condition('contains'), true] : filterParams; + + myGrid.filter(filterParams[0], filterParams[1], filterParams[2], filterParams[3]); + fix.detectChanges(); + + return { fixture: fix, grid: myGrid }; + } + + /* Creates an instance of GridDeclarationComponent and pins the columns with the specified indices. */ + public static async createGridAndPinColumn(...colIndices: any[]) { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + await wait(16); + + const myGrid = fix.componentInstance.grid; + // Pin columns + colIndices.forEach((i) => { + myGrid.columnList.get(i).pinned = true; + }); + + await wait(16); + + return { fixture: fix, grid: myGrid }; + } + +} diff --git a/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter-grid.spec.ts b/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter-grid.spec.ts new file mode 100644 index 00000000000..f41085540c2 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter-grid.spec.ts @@ -0,0 +1,514 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { ExportUtilities } from '../exporter-common/export-utilities'; +import { IgxPdfExporterService } from './pdf-exporter'; +import { IgxPdfExporterOptions } from './pdf-exporter-options'; +import { GridIDNameJobTitleComponent } from '../../../../../test-utils/grid-samples.spec'; +import { first } from 'rxjs/operators'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { NestedColumnGroupsGridComponent, ColumnGroupTestComponent, BlueWhaleGridComponent } from '../../../../../test-utils/grid-mch-sample.spec'; +import { IgxHierarchicalGridTestBaseComponent } from '../../../../../test-utils/hierarchical-grid-components.spec'; +import { IgxTreeGridSortingComponent, IgxTreeGridPrimaryForeignKeyComponent } from '../../../../../test-utils/tree-grid-components.spec'; +import { CustomSummariesComponent } from 'igniteui-angular/grids/grid/src/grid-summary.spec'; +import { IgxHierarchicalGridComponent } from 'igniteui-angular/grids/hierarchical-grid'; +import { IgxPivotGridMultipleRowComponent, IgxPivotGridTestComplexHierarchyComponent } from '../../../../../test-utils/pivot-grid-samples.spec'; +import { IgxPivotGridComponent } from 'igniteui-angular/grids/pivot-grid'; +import { PivotRowLayoutType } from 'igniteui-angular/grids/core'; +import { wait } from 'igniteui-angular/test-utils/ui-interactions.spec'; + +describe('PDF Grid Exporter', () => { + let exporter: IgxPdfExporterService; + let options: IgxPdfExporterOptions; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + GridIDNameJobTitleComponent, + IgxPivotGridMultipleRowComponent, + IgxPivotGridTestComplexHierarchyComponent + ] + }).compileComponents(); + })); + + beforeEach(() => { + exporter = new IgxPdfExporterService(); + options = new IgxPdfExporterOptions('PdfGridExport'); + + // Spy the saveBlobToFile method so the files are not really created + spyOn(ExportUtilities as any, 'saveBlobToFile'); + }); + + it('should export grid as displayed.', (done) => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.export(grid, options); + }); + + it('should export grid with custom page orientation', (done) => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + options.pageOrientation = 'landscape'; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.export(grid, options); + }); + + it('should honor ignoreColumnsVisibility option', (done) => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + grid.columnList.get(0).hidden = true; + options.ignoreColumnsVisibility = false; + + fix.detectChanges(); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.export(grid, options); + }); + + it('should handle empty grid', (done) => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + grid.data = []; + fix.detectChanges(); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.export(grid, options); + }); + + it('should export grid with landscape orientation', (done) => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + options.pageOrientation = 'landscape'; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.export(grid, options); + }); + + it('should export with table borders disabled', (done) => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + options.showTableBorders = false; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.export(grid, options); + }); + + it('should export with custom font size', (done) => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + options.fontSize = 14; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.export(grid, options); + }); + + it('should export with different page sizes', (done) => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + options.pageSize = 'letter'; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.export(grid, options); + }); + + it('should honor ignoreColumnsOrder option', (done) => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + options.ignoreColumnsOrder = true; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.export(grid, options); + }); + + it('should honor ignoreFiltering option', (done) => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + options.ignoreFiltering = false; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.export(grid, options); + }); + + it('should honor ignoreSorting option', (done) => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + options.ignoreSorting = false; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.export(grid, options); + }); + + + + it('should handle grid with multiple columns', (done) => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.export(grid, options); + }); + + it('should export with custom filename from options', (done) => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + const customOptions = new IgxPdfExporterOptions('MyCustomGrid'); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + const callArgs = (ExportUtilities.saveBlobToFile as jasmine.Spy).calls.mostRecent().args; + expect(callArgs[1]).toBe('MyCustomGrid.pdf'); + done(); + }); + + exporter.export(grid, customOptions); + }); + + it('should export grid with multi-column headers', (done) => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + ColumnGroupTestComponent + ] + }).compileComponents(); + + const fix = TestBed.createComponent(ColumnGroupTestComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.export(grid, options); + }); + + it('should export grid with nested multi-column headers', (done) => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + NestedColumnGroupsGridComponent + ] + }).compileComponents(); + + const fix = TestBed.createComponent(NestedColumnGroupsGridComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.export(grid, options); + }); + + it('should export grid with summaries', (done) => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + CustomSummariesComponent + ] + }).compileComponents(); + + const fix = TestBed.createComponent(CustomSummariesComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.export(grid, options); + }); + + it('should export hierarchical grid', (done) => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxHierarchicalGridTestBaseComponent + ] + }).compileComponents(); + + const fix = TestBed.createComponent(IgxHierarchicalGridTestBaseComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.hgrid; + grid.expandChildren = true; + grid.getChildGrids().forEach((childGrid: IgxHierarchicalGridComponent) => { + childGrid.expandChildren = true; + }); + fix.detectChanges(); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.export(grid, options); + }); + + it('should export tree grid with hierarchical data', (done) => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxTreeGridSortingComponent + ] + }).compileComponents(); + + const fix = TestBed.createComponent(IgxTreeGridSortingComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.treeGrid; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.export(grid, options); + }); + + it('should export tree grid with flat self-referencing data', (done) => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxTreeGridPrimaryForeignKeyComponent + ] + }).compileComponents(); + + const fix = TestBed.createComponent(IgxTreeGridPrimaryForeignKeyComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.treeGrid; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.export(grid, options); + }); + + it('should truncate long header text with ellipsis in multi-column headers', (done) => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + BlueWhaleGridComponent + ] + }).compileComponents(); + + const fix = TestBed.createComponent(BlueWhaleGridComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // The PDF should be created successfully even with long header text + expect(args.pdf).toBeDefined(); + done(); + }); + + // Use smaller page size to force truncation + options.pageSize = 'a5'; + exporter.export(grid, options); + }); + + describe('Pivot Grid PDF Export', () => { + let pivotGrid: IgxPivotGridComponent; + let fix; + beforeEach(async () => { + fix = TestBed.createComponent(IgxPivotGridMultipleRowComponent); + fix.detectChanges(); + await wait(); + + pivotGrid = fix.componentInstance.pivotGrid; + }); + + it('should export basic pivot grid', (done) => { + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.export(pivotGrid, options); + }); + + it('should export pivot grid with row headers', (done) => { + pivotGrid.pivotUI.showRowHeaders = true; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.export(pivotGrid, options); + }); + + it('should export pivot grid with horizontal row layout', (done) => { + pivotGrid.pivotUI.showRowHeaders = true; + pivotGrid.pivotUI.rowLayout = PivotRowLayoutType.Horizontal; + pivotGrid.pivotConfiguration.rows = [{ + memberName: 'ProductCategory', + memberFunction: (data) => data.ProductCategory, + enabled: true, + childLevel: { + memberName: 'Country', + enabled: true, + childLevel: { + memberName: 'Date', + enabled: true + } + } + }]; + fix.detectChanges(); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.export(pivotGrid, options); + }); + + it('should export pivot grid with custom page size', (done) => { + options.pageSize = 'letter'; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.export(pivotGrid, options); + }); + + it('should export pivot grid with landscape orientation', (done) => { + options.pageOrientation = 'landscape'; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.export(pivotGrid, options); + }); + + it('should export pivot grid without table borders', (done) => { + options.showTableBorders = false; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.export(pivotGrid, options); + }); + + it('should export pivot grid with custom font size', (done) => { + options.fontSize = 14; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.export(pivotGrid, options); + }); + + it('should export hierarchical pivot grid', (done) => { + fix = TestBed.createComponent(IgxPivotGridTestComplexHierarchyComponent); + fix.detectChanges(); + fix.whenStable().then(() => { + pivotGrid = fix.componentInstance.pivotGrid; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.export(pivotGrid, options); + }); + }); + }); +}); diff --git a/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter-options.ts b/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter-options.ts new file mode 100644 index 00000000000..5f8e6fa4a47 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter-options.ts @@ -0,0 +1,54 @@ +import { IgxExporterOptionsBase } from '../exporter-common/exporter-options-base'; + +/** + * Objects of this class are used to configure the PDF exporting process. + */ +export class IgxPdfExporterOptions extends IgxExporterOptionsBase { + /** + * Specifies the page orientation. (portrait or landscape, landscape by default) + * ```typescript + * let pageOrientation = this.exportOptions.pageOrientation; + * this.exportOptions.pageOrientation = 'portrait'; + * ``` + * + * @memberof IgxPdfExporterOptions + */ + public pageOrientation: 'portrait' | 'landscape' = 'landscape'; + + /** + * Specifies the page size. (a4, a3, letter, legal, etc., a4 by default) + * ```typescript + * let pageSize = this.exportOptions.pageSize; + * this.exportOptions.pageSize = 'letter'; + * ``` + * + * @memberof IgxPdfExporterOptions + */ + public pageSize: string = 'a4'; + + /** + * Specifies whether to show table borders. (True by default) + * ```typescript + * let showTableBorders = this.exportOptions.showTableBorders; + * this.exportOptions.showTableBorders = false; + * ``` + * + * @memberof IgxPdfExporterOptions + */ + public showTableBorders = true; + + /** + * Specifies the font size for the table content. (10 by default) + * ```typescript + * let fontSize = this.exportOptions.fontSize; + * this.exportOptions.fontSize = 12; + * ``` + * + * @memberof IgxPdfExporterOptions + */ + public fontSize = 10; + + constructor(fileName: string) { + super(fileName, '.pdf'); + } +} diff --git a/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter.spec.ts b/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter.spec.ts new file mode 100644 index 00000000000..2dbde14d859 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter.spec.ts @@ -0,0 +1,2891 @@ +import { ExportUtilities } from '../exporter-common/export-utilities'; +import { IgxPdfExporterService } from './pdf-exporter'; +import { IgxPdfExporterOptions } from './pdf-exporter-options'; +import { SampleTestData } from '../../../../../test-utils/sample-test-data.spec'; +import { first } from 'rxjs/operators'; +import { ExportRecordType, ExportHeaderType, DEFAULT_OWNER, IExportRecord, IColumnInfo, IColumnList, GRID_LEVEL_COL } from '../exporter-common/base-export-service'; + +describe('PDF Exporter', () => { + let exporter: IgxPdfExporterService; + let options: IgxPdfExporterOptions; + + beforeEach(() => { + exporter = new IgxPdfExporterService(); + options = new IgxPdfExporterOptions('PdfExport'); + + // Clear owners map between tests + (exporter as any)._ownersMap.clear(); + + // Spy the saveBlobToFile method so the files are not really created + spyOn(ExportUtilities, 'saveBlobToFile'); + }); + + it('should be created', () => { + expect(exporter).toBeTruthy(); + }); + + it('should export empty data without errors', (done) => { + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData([], options); + }); + + it('should export simple data successfully', (done) => { + const simpleData = [ + { Name: 'John', Age: 30 }, + { Name: 'Jane', Age: 25 } + ]; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(simpleData, options); + }); + + it('should export contacts data successfully', (done) => { + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(SampleTestData.contactsData(), options); + }); + + it('should export with custom page orientation', (done) => { + options.pageOrientation = 'landscape'; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(SampleTestData.contactsData(), options); + }); + + it('should export with custom page size', (done) => { + options.pageSize = 'letter'; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(SampleTestData.contactsData(), options); + }); + + it('should export without table borders', (done) => { + options.showTableBorders = false; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(SampleTestData.contactsData(), options); + }); + + it('should export with custom font size', (done) => { + options.fontSize = 12; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(SampleTestData.contactsData(), options); + }); + + it('should handle null and undefined values', (done) => { + const dataWithNulls = [ + { Name: 'John', Age: null }, + { Name: undefined, Age: 25 } + ]; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(dataWithNulls, options); + }); + + it('should handle date values', (done) => { + const dataWithDates = [ + { Name: 'John', BirthDate: new Date('1990-01-01') }, + { Name: 'Jane', BirthDate: new Date('1995-06-15') } + ]; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(dataWithDates, options); + }); + + it('should export with portrait orientation', (done) => { + options.pageOrientation = 'portrait'; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(SampleTestData.contactsData(), options); + }); + + it('should export with various page sizes', (done) => { + const pageSizes = ['a3', 'a5', 'legal']; + let completed = 0; + + const exportNext = (index: number) => { + if (index >= pageSizes.length) { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(pageSizes.length); + done(); + return; + } + + const opts = new IgxPdfExporterOptions('Test'); + opts.pageSize = pageSizes[index] as any; + + exporter.exportEnded.pipe(first()).subscribe(() => { + completed++; + exportNext(completed); + }); + + exporter.exportData(SampleTestData.contactsData(), opts); + }; + + exportNext(0); + }); + + it('should export with different font sizes', (done) => { + options.fontSize = 14; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(SampleTestData.contactsData(), options); + }); + + it('should export large dataset requiring pagination', (done) => { + const largeData = []; + for (let i = 0; i < 100; i++) { + largeData.push({ Name: `Person ${i}`, Age: 20 + (i % 50), City: `City ${i % 10}` }); + } + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(largeData, options); + }); + + it('should handle long text values with truncation', (done) => { + const dataWithLongText = [ + { Name: 'John', Description: 'This is a very long description that should be truncated with ellipsis in the PDF export to fit within the cell width' }, + { Name: 'Jane', Description: 'Another extremely long text that needs to be handled properly in the PDF export without breaking the layout' } + ]; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(dataWithLongText, options); + }); + + it('should export data with mixed data types', (done) => { + const mixedData = [ + { String: 'Text', Number: 42, Boolean: true, Date: new Date('2023-01-01'), Null: null, Undefined: undefined }, + { String: 'More text', Number: 3.14, Boolean: false, Date: new Date('2023-12-31'), Null: null, Undefined: undefined } + ]; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(mixedData, options); + }); + + it('should export with custom filename', (done) => { + const customOptions = new IgxPdfExporterOptions('CustomFileName'); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + const callArgs = (ExportUtilities.saveBlobToFile as jasmine.Spy).calls.mostRecent().args; + expect(callArgs[1]).toBe('CustomFileName.pdf'); + done(); + }); + + exporter.exportData(SampleTestData.contactsData(), customOptions); + }); + + it('should handle empty rows in data', (done) => { + const dataWithEmptyRows = [ + { Name: 'John', Age: 30 }, + {}, + { Name: 'Jane', Age: 25 } + ]; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(dataWithEmptyRows, options); + }); + + it('should emit exportEnded event with pdf object', (done) => { + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(args).toBeDefined(); + expect(args.pdf).toBeDefined(); + done(); + }); + + exporter.exportData(SampleTestData.contactsData(), options); + }); + + describe('Pivot Grid Export', () => { + it('should export pivot grid with single dimension', (done) => { + const pivotData: IExportRecord[] = [ + { + data: { Product: 'Product A', 'City-London-Sum': 100, 'City-Paris-Sum': 200 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product'] + }, + { + data: { Product: 'Product B', 'City-London-Sum': 150, 'City-Paris-Sum': 250 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product'] + } + ]; + + const columns: IColumnInfo[] = [ + { + header: 'Product', + field: 'Product', + skip: false, + headerType: ExportHeaderType.PivotRowHeader, + startIndex: 0, + level: 0 + }, + { + header: 'London', + field: 'City', + skip: false, + headerType: ExportHeaderType.MultiColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1, + columnGroup: 'London' + }, + { + header: 'Sum', + field: 'City-London-Sum', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 1, + columnSpan: 1, + columnGroupParent: 'London' + }, + { + header: 'Paris', + field: 'City', + skip: false, + headerType: ExportHeaderType.MultiColumnHeader, + startIndex: 1, + level: 0, + columnSpan: 1, + columnGroup: 'Paris' + }, + { + header: 'Sum', + field: 'City-Paris-Sum', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 1, + level: 1, + columnSpan: 1, + columnGroupParent: 'Paris' + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200, 200, 200], + indexOfLastPinnedColumn: 0, + maxLevel: 1, + maxRowLevel: 1 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(pivotData, options); + }); + + it('should export multi-dimensional pivot grid with multiple row dimensions', (done) => { + const pivotData: IExportRecord[] = [ + { + data: { Product: 'Product A', Category: 'Category 1', 'City-London-Sum': 100, 'City-Paris-Sum': 200 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product', 'Category'] + }, + { + data: { Product: 'Product A', Category: 'Category 2', 'City-London-Sum': 150, 'City-Paris-Sum': 250 }, + level: 1, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product', 'Category'] + }, + { + data: { Product: 'Product B', Category: 'Category 1', 'City-London-Sum': 120, 'City-Paris-Sum': 220 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product', 'Category'] + } + ]; + + const columns: IColumnInfo[] = [ + { + header: 'Product', + field: 'Product', + skip: false, + headerType: ExportHeaderType.PivotRowHeader, + startIndex: 0, + level: 0 + }, + { + header: 'Category', + field: 'Category', + skip: false, + headerType: ExportHeaderType.PivotRowHeader, + startIndex: 1, + level: 1 + }, + { + header: 'London', + field: 'City', + skip: false, + headerType: ExportHeaderType.MultiColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1, + columnGroup: 'London' + }, + { + header: 'Sum', + field: 'City-London-Sum', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 1, + columnSpan: 1, + columnGroupParent: 'London' + }, + { + header: 'Paris', + field: 'City', + skip: false, + headerType: ExportHeaderType.MultiColumnHeader, + startIndex: 1, + level: 0, + columnSpan: 1, + columnGroup: 'Paris' + }, + { + header: 'Sum', + field: 'City-Paris-Sum', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 1, + level: 1, + columnSpan: 1, + columnGroupParent: 'Paris' + }, + { + header: 'Product A', + field: 'Product', + skip: false, + headerType: ExportHeaderType.RowHeader, + startIndex: 0, + level: 0 + }, + { + header: 'Category 1', + field: 'Category', + skip: false, + headerType: ExportHeaderType.RowHeader, + startIndex: 0, + level: 1 + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200, 200, 200, 200], + indexOfLastPinnedColumn: 1, + maxLevel: 1, + maxRowLevel: 2 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(pivotData, options); + }); + + it('should export pivot grid with row dimension headers and multi-level column headers', (done) => { + const pivotData: IExportRecord[] = [ + { + data: { Product: 'Product A', 'City-London-Sum': 100, 'City-London-Avg': 50, 'City-Paris-Sum': 200 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product'] + } + ]; + + const columns: IColumnInfo[] = [ + { + header: 'Product', + field: 'Product', + skip: false, + headerType: ExportHeaderType.PivotRowHeader, + startIndex: 0, + level: 0 + }, + { + header: 'London', + field: 'City', + skip: false, + headerType: ExportHeaderType.MultiColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 2, + columnGroup: 'London' + }, + { + header: 'Sum', + field: 'City-London-Sum', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 1, + columnSpan: 1, + columnGroupParent: 'London' + }, + { + header: 'Avg', + field: 'City-London-Avg', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 1, + level: 1, + columnSpan: 1, + columnGroupParent: 'London' + }, + { + header: 'Paris', + field: 'City', + skip: false, + headerType: ExportHeaderType.MultiColumnHeader, + startIndex: 2, + level: 0, + columnSpan: 1, + columnGroup: 'Paris' + }, + { + header: 'Sum', + field: 'City-Paris-Sum', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 2, + level: 1, + columnSpan: 1, + columnGroupParent: 'Paris' + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200, 200, 200, 200], + indexOfLastPinnedColumn: 0, + maxLevel: 1, + maxRowLevel: 1 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(pivotData, options); + }); + + it('should export pivot grid with PivotMergedHeader columns', (done) => { + const pivotData: IExportRecord[] = [ + { + data: { Product: 'Product A', 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product'] + } + ]; + + const columns: IColumnInfo[] = [ + { + header: 'Product', + field: 'Product', + skip: false, + headerType: ExportHeaderType.PivotRowHeader, + startIndex: 0, + level: 0 + }, + { + header: '', + field: '', + skip: false, + headerType: ExportHeaderType.PivotMergedHeader, + startIndex: 0, + level: 0 + }, + { + header: 'Sum', + field: 'City-London-Sum', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200, 200], + indexOfLastPinnedColumn: 0, + maxLevel: 0, + maxRowLevel: 1 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(pivotData, options); + }); + + it('should export pivot grid when dimensionKeys are inferred from record data', (done) => { + const pivotData: IExportRecord[] = [ + { + data: { Product: 'Product A', Category: 'Category 1', 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord + // No dimensionKeys - should be inferred + } + ]; + + const columns: IColumnInfo[] = [ + { + header: 'Product', + field: 'Product', + skip: false, + headerType: ExportHeaderType.RowHeader, + startIndex: 0, + level: 0 + }, + { + header: 'Category', + field: 'Category', + skip: false, + headerType: ExportHeaderType.RowHeader, + startIndex: 1, + level: 1 + }, + { + header: 'Sum', + field: 'City-London-Sum', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200, 200, 200], + indexOfLastPinnedColumn: 1, + maxLevel: 0, + maxRowLevel: 2 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(pivotData, options); + }); + + it('should export pivot grid with MultiRowHeader columns', (done) => { + const pivotData: IExportRecord[] = [ + { + data: { Product: 'Product A', Category: 'Category 1', 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product', 'Category'] + }, + { + data: { Product: 'Product A', Category: 'Category 2', 'City-London-Sum': 150 }, + level: 1, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product', 'Category'] + } + ]; + + const columns: IColumnInfo[] = [ + { + header: 'Product', + field: 'Product', + skip: false, + headerType: ExportHeaderType.PivotRowHeader, + startIndex: 0, + level: 0 + }, + { + header: 'Category', + field: 'Category', + skip: false, + headerType: ExportHeaderType.PivotRowHeader, + startIndex: 1, + level: 1 + }, + { + header: 'Product A', + field: 'Product', + skip: false, + headerType: ExportHeaderType.MultiRowHeader, + startIndex: 0, + level: 0, + rowSpan: 2 + }, + { + header: 'Category 1', + field: 'Category', + skip: false, + headerType: ExportHeaderType.RowHeader, + startIndex: 0, + level: 1 + }, + { + header: 'Category 2', + field: 'Category', + skip: false, + headerType: ExportHeaderType.RowHeader, + startIndex: 1, + level: 1 + }, + { + header: 'Sum', + field: 'City-London-Sum', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200, 200, 200], + indexOfLastPinnedColumn: 1, + maxLevel: 0, + maxRowLevel: 2 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(pivotData, options); + }); + + it('should export pivot grid with row dimension columns by level', (done) => { + const pivotData: IExportRecord[] = [ + { + data: { Product: 'Product A', Category: 'Category 1', 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product', 'Category'] + }, + { + data: { Product: 'Product A', Category: 'Category 2', 'City-London-Sum': 150 }, + level: 1, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product', 'Category'] + } + ]; + + const columns: IColumnInfo[] = [ + { + header: 'Product', + field: 'Product', + skip: false, + headerType: ExportHeaderType.PivotRowHeader, + startIndex: 0, + level: 0 + }, + { + header: 'Category', + field: 'Category', + skip: false, + headerType: ExportHeaderType.PivotRowHeader, + startIndex: 1, + level: 1 + }, + { + header: 'Product A', + field: 'Product', + skip: false, + headerType: ExportHeaderType.RowHeader, + startIndex: 0, + level: 0 + }, + { + header: 'Category 1', + field: 'Category', + skip: false, + headerType: ExportHeaderType.RowHeader, + startIndex: 0, + level: 1 + }, + { + header: 'Category 2', + field: 'Category', + skip: false, + headerType: ExportHeaderType.RowHeader, + startIndex: 1, + level: 1 + }, + { + header: 'Sum', + field: 'City-London-Sum', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200, 200, 200], + indexOfLastPinnedColumn: 1, + maxLevel: 0, + maxRowLevel: 2 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(pivotData, options); + }); + }); + + describe('Hierarchical Grid Export', () => { + it('should export hierarchical grid with child records', (done) => { + const childOwner = 'child1'; + const childColumns: IColumnInfo[] = [ + { + header: 'Child Name', + field: 'name', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + }, + { + header: 'Child Age', + field: 'age', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 1, + level: 0, + columnSpan: 1 + } + ]; + + const childOwnerList: IColumnList = { + columns: childColumns, + columnWidths: [200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + }; + + (exporter as any)._ownersMap.set(childOwner, childOwnerList); + + const parentColumns: IColumnInfo[] = [ + { + header: 'Name', + field: 'name', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + }, + { + header: 'Age', + field: 'age', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 1, + level: 0, + columnSpan: 1 + } + ]; + + const parentOwner: IColumnList = { + columns: parentColumns, + columnWidths: [200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, parentOwner); + + const hierarchicalData: IExportRecord[] = [ + { + data: { name: 'Parent 1', age: 40 }, + level: 0, + type: ExportRecordType.HierarchicalGridRecord, + owner: DEFAULT_OWNER + }, + { + data: { name: 'Child 1', age: 10 }, + level: 1, + type: ExportRecordType.HierarchicalGridRecord, + owner: childOwner + }, + { + data: { name: 'Child 2', age: 12 }, + level: 1, + type: ExportRecordType.HierarchicalGridRecord, + owner: childOwner + }, + { + data: { name: 'Parent 2', age: 45 }, + level: 0, + type: ExportRecordType.HierarchicalGridRecord, + owner: DEFAULT_OWNER + } + ]; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(hierarchicalData, options); + }); + + it('should export hierarchical grid with multiple child levels', (done) => { + const grandChildOwner = 'grandchild1'; + const childOwner = 'child1'; + + const grandChildColumns: IColumnInfo[] = [ + { + header: 'Grandchild Name', + field: 'name', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + } + ]; + + const grandChildOwnerList: IColumnList = { + columns: grandChildColumns, + columnWidths: [200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + }; + + (exporter as any)._ownersMap.set(grandChildOwner, grandChildOwnerList); + + const childColumns: IColumnInfo[] = [ + { + header: 'Child Name', + field: 'name', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + } + ]; + + const childOwnerList: IColumnList = { + columns: childColumns, + columnWidths: [200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + }; + + (exporter as any)._ownersMap.set(childOwner, childOwnerList); + + const parentColumns: IColumnInfo[] = [ + { + header: 'Name', + field: 'name', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + } + ]; + + const parentOwner: IColumnList = { + columns: parentColumns, + columnWidths: [200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, parentOwner); + + const hierarchicalData: IExportRecord[] = [ + { + data: { name: 'Parent 1' }, + level: 0, + type: ExportRecordType.HierarchicalGridRecord, + owner: DEFAULT_OWNER + }, + { + data: { name: 'Child 1' }, + level: 1, + type: ExportRecordType.HierarchicalGridRecord, + owner: childOwner + }, + { + data: { name: 'Grandchild 1' }, + level: 2, + type: ExportRecordType.HierarchicalGridRecord, + owner: grandChildOwner + }, + { + data: { name: 'Grandchild 2' }, + level: 2, + type: ExportRecordType.HierarchicalGridRecord, + owner: grandChildOwner + } + ]; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(hierarchicalData, options); + }); + + it('should export hierarchical grid with multi-level headers in child grid', (done) => { + const childOwner = 'child1'; + + const childColumns: IColumnInfo[] = [ + { + header: 'Location', + field: 'location', + skip: false, + headerType: ExportHeaderType.MultiColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 2 + }, + { + header: 'City', + field: 'city', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 1, + columnSpan: 1 + }, + { + header: 'Country', + field: 'country', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 1, + level: 1, + columnSpan: 1 + } + ]; + + const childOwnerList: IColumnList = { + columns: childColumns, + columnWidths: [200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 1 + }; + + (exporter as any)._ownersMap.set(childOwner, childOwnerList); + + const parentColumns: IColumnInfo[] = [ + { + header: 'Name', + field: 'name', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + } + ]; + + const parentOwner: IColumnList = { + columns: parentColumns, + columnWidths: [200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, parentOwner); + + const hierarchicalData: IExportRecord[] = [ + { + data: { name: 'Parent 1' }, + level: 0, + type: ExportRecordType.HierarchicalGridRecord, + owner: DEFAULT_OWNER + }, + { + data: { city: 'London', country: 'UK' }, + level: 1, + type: ExportRecordType.HierarchicalGridRecord, + owner: childOwner + } + ]; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(hierarchicalData, options); + }); + }); + + describe('Tree Grid Export', () => { + it('should export tree grid with hierarchical levels', (done) => { + const treeData: IExportRecord[] = [ + { + data: { name: 'Root 1', value: 100 }, + level: 0, + type: ExportRecordType.TreeGridRecord + }, + { + data: { name: 'Child 1', value: 50 }, + level: 1, + type: ExportRecordType.TreeGridRecord + }, + { + data: { name: 'Grandchild 1', value: 25 }, + level: 2, + type: ExportRecordType.TreeGridRecord + }, + { + data: { name: 'Root 2', value: 200 }, + level: 0, + type: ExportRecordType.TreeGridRecord + } + ]; + + const columns: IColumnInfo[] = [ + { + header: 'Name', + field: 'name', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + }, + { + header: 'Value', + field: 'value', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 1, + level: 0, + columnSpan: 1 + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(treeData, options); + }); + }); + + describe('Summary Records Export', () => { + it('should export summary records with label and value', (done) => { + const summaryData: IExportRecord[] = [ + { + data: { name: 'Total', value: { label: 'Sum', value: 500 } }, + level: 0, + type: ExportRecordType.SummaryRecord + } + ]; + + const columns: IColumnInfo[] = [ + { + header: 'Name', + field: 'name', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + }, + { + header: 'Value', + field: 'value', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 1, + level: 0, + columnSpan: 1 + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(summaryData, options); + }); + + it('should export summary records with summaryResult property', (done) => { + const summaryData: IExportRecord[] = [ + { + data: { name: 'Total', value: { summaryResult: 1000 } }, + level: 0, + type: ExportRecordType.SummaryRecord + } + ]; + + const columns: IColumnInfo[] = [ + { + header: 'Name', + field: 'name', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + }, + { + header: 'Value', + field: 'value', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 1, + level: 0, + columnSpan: 1 + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(summaryData, options); + }); + }); + + describe('Edge Cases and Special Scenarios', () => { + it('should skip hidden records', (done) => { + const dataWithHidden: IExportRecord[] = [ + { + data: { Name: 'Visible', Age: 30 }, + level: 0, + type: ExportRecordType.DataRecord + }, + { + data: { Name: 'Hidden', Age: 25 }, + level: 0, + type: ExportRecordType.DataRecord, + hidden: true + }, + { + data: { Name: 'Visible 2', Age: 35 }, + level: 0, + type: ExportRecordType.DataRecord + } + ]; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(dataWithHidden, options); + }); + + it('should handle pagination when data exceeds page height', (done) => { + const largeData: IExportRecord[] = []; + for (let i = 0; i < 50; i++) { + largeData.push({ + data: { Name: `Person ${i}`, Age: 20 + i, City: `City ${i % 10}` }, + level: 0, + type: ExportRecordType.DataRecord + }); + } + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(largeData, options); + }); + + it('should handle pivot grid with empty row dimension fields', (done) => { + const pivotData: IExportRecord[] = [ + { + data: { 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: [] + } + ]; + + const columns: IColumnInfo[] = [ + { + header: 'Sum', + field: 'City-London-Sum', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200], + indexOfLastPinnedColumn: -1, + maxLevel: 0, + maxRowLevel: 0 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(pivotData, options); + }); + + it('should handle pivot grid when no columns are defined', (done) => { + const pivotData: IExportRecord[] = [ + { + data: { Product: 'Product A', Value: 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord + } + ]; + + const owner: IColumnList = { + columns: [], + columnWidths: [], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(pivotData, options); + }); + + it('should handle pivot grid with row dimension headers longer than fields', (done) => { + const pivotData: IExportRecord[] = [ + { + data: { Product: 'Product A', 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product'] + } + ]; + + const columns: IColumnInfo[] = [ + { + header: 'Product', + field: 'Product', + skip: false, + headerType: ExportHeaderType.PivotRowHeader, + startIndex: 0, + level: 0 + }, + { + header: 'Category', + field: 'Category', + skip: false, + headerType: ExportHeaderType.PivotRowHeader, + startIndex: 1, + level: 1 + }, + { + header: 'Sum', + field: 'City-London-Sum', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200, 200, 200], + indexOfLastPinnedColumn: 1, + maxLevel: 0, + maxRowLevel: 2 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(pivotData, options); + }); + + it('should handle pivot grid with date values in row dimensions', (done) => { + const pivotData: IExportRecord[] = [ + { + data: { Date: new Date('2023-01-01'), 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Date'] + } + ]; + + const columns: IColumnInfo[] = [ + { + header: 'Date', + field: 'Date', + skip: false, + headerType: ExportHeaderType.PivotRowHeader, + startIndex: 0, + level: 0 + }, + { + header: 'Sum', + field: 'City-London-Sum', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200, 200], + indexOfLastPinnedColumn: 0, + maxLevel: 0, + maxRowLevel: 1 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(pivotData, options); + }); + + it('should handle hierarchical grid with HeaderRecord type', (done) => { + const childOwner = 'child1'; + const childColumns: IColumnInfo[] = [ + { + header: 'Name', + field: 'name', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + } + ]; + + const childOwnerList: IColumnList = { + columns: childColumns, + columnWidths: [200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + }; + + (exporter as any)._ownersMap.set(childOwner, childOwnerList); + + const parentColumns: IColumnInfo[] = [ + { + header: 'Name', + field: 'name', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + } + ]; + + const parentOwner: IColumnList = { + columns: parentColumns, + columnWidths: [200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, parentOwner); + + const hierarchicalData: IExportRecord[] = [ + { + data: { name: 'Parent 1' }, + level: 0, + type: ExportRecordType.HierarchicalGridRecord, + owner: DEFAULT_OWNER + }, + { + data: {}, + level: 1, + type: ExportRecordType.HeaderRecord, + owner: childOwner + }, + { + data: { name: 'Child 1' }, + level: 1, + type: ExportRecordType.HierarchicalGridRecord, + owner: childOwner + } + ]; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(hierarchicalData, options); + }); + + it('should handle hierarchical grid with empty child columns', (done) => { + const childOwner = 'child1'; + const childOwnerList: IColumnList = { + columns: [], + columnWidths: [], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + }; + + (exporter as any)._ownersMap.set(childOwner, childOwnerList); + + const parentColumns: IColumnInfo[] = [ + { + header: 'Name', + field: 'name', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + } + ]; + + const parentOwner: IColumnList = { + columns: parentColumns, + columnWidths: [200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, parentOwner); + + const hierarchicalData: IExportRecord[] = [ + { + data: { name: 'Parent 1' }, + level: 0, + type: ExportRecordType.HierarchicalGridRecord, + owner: DEFAULT_OWNER + }, + { + data: { name: 'Child 1' }, + level: 1, + type: ExportRecordType.HierarchicalGridRecord, + owner: childOwner + } + ]; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(hierarchicalData, options); + }); + + it('should handle pagination with hierarchical grid', (done) => { + const childOwner = 'child1'; + const childColumns: IColumnInfo[] = [ + { + header: 'Child Name', + field: 'name', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + } + ]; + + const childOwnerList: IColumnList = { + columns: childColumns, + columnWidths: [200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + }; + + (exporter as any)._ownersMap.set(childOwner, childOwnerList); + + const parentColumns: IColumnInfo[] = [ + { + header: 'Name', + field: 'name', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + } + ]; + + const parentOwner: IColumnList = { + columns: parentColumns, + columnWidths: [200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, parentOwner); + + const hierarchicalData: IExportRecord[] = []; + // Create many parent-child pairs to trigger pagination + for (let i = 0; i < 30; i++) { + hierarchicalData.push({ + data: { name: `Parent ${i}` }, + level: 0, + type: ExportRecordType.HierarchicalGridRecord, + owner: DEFAULT_OWNER + }); + hierarchicalData.push({ + data: { name: `Child ${i}` }, + level: 1, + type: ExportRecordType.HierarchicalGridRecord, + owner: childOwner + }); + } + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(hierarchicalData, options); + }); + }); + + describe('Additional Edge Cases and Error Paths', () => { + it('should handle pivot grid with no defaultOwner', (done) => { + const pivotData: IExportRecord[] = [ + { + data: { Product: 'Product A', 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product'] + } + ]; + + // Don't set DEFAULT_OWNER in the map + (exporter as any)._ownersMap.clear(); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(pivotData, options); + }); + + it('should handle pivot grid dimension inference from columnGroup', (done) => { + const pivotData: IExportRecord[] = [ + { + data: { Product: 'Product A', Category: 'Category 1', 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord + // No dimensionKeys - should be inferred + } + ]; + + const columns: IColumnInfo[] = [ + { + header: 'Product', + field: 'Product', + skip: false, + headerType: ExportHeaderType.RowHeader, + startIndex: 0, + level: 0, + columnGroup: 'Product' + }, + { + header: 'Category', + field: 'Category', + skip: false, + headerType: ExportHeaderType.RowHeader, + startIndex: 1, + level: 1, + columnGroupParent: 'Product' + }, + { + header: 'Sum', + field: 'City-London-Sum', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200, 200, 200], + indexOfLastPinnedColumn: 1, + maxLevel: 0, + maxRowLevel: 2 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(pivotData, options); + }); + + it('should handle pivot grid with simple keys inference', (done) => { + const pivotData: IExportRecord[] = [ + { + data: { SimpleKey: 'Value1', 'Complex-Key-With-Separators': 100, 'Another_Complex_Key': 200 }, + level: 0, + type: ExportRecordType.PivotGridRecord + // No dimensionKeys and no matching row headers + } + ]; + + const columns: IColumnInfo[] = [ + { + header: 'Sum', + field: 'Complex-Key-With-Separators', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200], + indexOfLastPinnedColumn: -1, + maxLevel: 0, + maxRowLevel: 0 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(pivotData, options); + }); + + it('should handle pivot grid with row dimension headers longer than fields and trim them', (done) => { + const pivotData: IExportRecord[] = [ + { + data: { Product: 'Product A', 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product'] + } + ]; + + const columns: IColumnInfo[] = [ + { + header: 'Product', + field: 'Product', + skip: false, + headerType: ExportHeaderType.PivotRowHeader, + startIndex: 0, + level: 0 + }, + { + header: 'Category', + field: 'Category', + skip: false, + headerType: ExportHeaderType.PivotRowHeader, + startIndex: 1, + level: 1 + }, + { + header: 'SubCategory', + field: 'SubCategory', + skip: false, + headerType: ExportHeaderType.PivotRowHeader, + startIndex: 2, + level: 2 + }, + { + header: 'Sum', + field: 'City-London-Sum', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200, 200, 200, 200], + indexOfLastPinnedColumn: 2, + maxLevel: 0, + maxRowLevel: 3 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(pivotData, options); + }); + + it('should handle multi-level headers with empty headersForLevel', (done) => { + const columns: IColumnInfo[] = [ + { + header: 'Name', + field: 'name', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + }, + { + header: 'Parent', + field: 'parent', + skip: false, + headerType: ExportHeaderType.MultiColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1, + columnGroup: 'Parent' + }, + { + header: 'Child', + field: 'child', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 1, + columnSpan: 1, + columnGroupParent: 'Parent' + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200, 200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 2 // Level 2 exists but no columns at that level + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + const data: IExportRecord[] = [ + { + data: { name: 'Test', parent: 'Parent', child: 'Child' }, + level: 0, + type: ExportRecordType.DataRecord + } + ]; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(data, options); + }); + + it('should handle columns with skip: true', (done) => { + const columns: IColumnInfo[] = [ + { + header: 'Name', + field: 'name', + skip: true, // Should be skipped + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + }, + { + header: 'Age', + field: 'age', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 1, + level: 0, + columnSpan: 1 + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + const data: IExportRecord[] = [ + { + data: { name: 'John', age: 30 }, + level: 0, + type: ExportRecordType.DataRecord + } + ]; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(data, options); + }); + + it('should handle GRID_LEVEL_COL column', (done) => { + const columns: IColumnInfo[] = [ + { + header: 'Name', + field: 'name', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + }, + { + header: GRID_LEVEL_COL, + field: GRID_LEVEL_COL, + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 1, + level: 0, + columnSpan: 1 + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + const data: IExportRecord[] = [ + { + data: { name: 'John', [GRID_LEVEL_COL]: 0 }, + level: 0, + type: ExportRecordType.DataRecord + } + ]; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(data, options); + }); + + it('should handle records with missing data property', (done) => { + const data: IExportRecord[] = [ + { + data: { Name: 'John', Age: 30 }, + level: 0, + type: ExportRecordType.DataRecord + }, + { + data: undefined as any, + level: 0, + type: ExportRecordType.DataRecord + } + ]; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(data, options); + }); + + it('should handle pivot grid with fuzzy key matching', (done) => { + const pivotData: IExportRecord[] = [ + { + data: { 'ProductName': 'Product A', 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product'] // Field name doesn't match exactly + } + ]; + + const columns: IColumnInfo[] = [ + { + header: 'Product', + field: 'Product', + skip: false, + headerType: ExportHeaderType.PivotRowHeader, + startIndex: 0, + level: 0 + }, + { + header: 'Sum', + field: 'City-London-Sum', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200, 200], + indexOfLastPinnedColumn: 0, + maxLevel: 0, + maxRowLevel: 1 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(pivotData, options); + }); + + it('should handle pivot grid with possible dimension keys by index fallback', (done) => { + const pivotData: IExportRecord[] = [ + { + data: { SimpleKey1: 'Value1', SimpleKey2: 'Value2', 'Complex-Key': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['UnknownKey'] // Key doesn't exist in data + } + ]; + + const columns: IColumnInfo[] = [ + { + header: 'Unknown', + field: 'UnknownKey', + skip: false, + headerType: ExportHeaderType.PivotRowHeader, + startIndex: 0, + level: 0 + }, + { + header: 'Sum', + field: 'Complex-Key', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200, 200], + indexOfLastPinnedColumn: 0, + maxLevel: 0, + maxRowLevel: 1 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(pivotData, options); + }); + + it('should handle summary records with only label', (done) => { + const summaryData: IExportRecord[] = [ + { + data: { name: 'Total', value: { label: 'Sum' } }, + level: 0, + type: ExportRecordType.SummaryRecord + } + ]; + + const columns: IColumnInfo[] = [ + { + header: 'Name', + field: 'name', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + }, + { + header: 'Value', + field: 'value', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 1, + level: 0, + columnSpan: 1 + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(summaryData, options); + }); + + it('should handle summary records with only value', (done) => { + const summaryData: IExportRecord[] = [ + { + data: { name: 'Total', value: { value: 500 } }, + level: 0, + type: ExportRecordType.SummaryRecord + } + ]; + + const columns: IColumnInfo[] = [ + { + header: 'Name', + field: 'name', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + }, + { + header: 'Value', + field: 'value', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 1, + level: 0, + columnSpan: 1 + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(summaryData, options); + }); + + it('should handle pivot grid with empty PivotRowHeader columns', (done) => { + const pivotData: IExportRecord[] = [ + { + data: { Product: 'Product A', 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product'] + } + ]; + + const columns: IColumnInfo[] = [ + { + header: '', + field: '', + skip: false, + headerType: ExportHeaderType.PivotRowHeader, + startIndex: 0, + level: 0 + }, + { + header: 'Sum', + field: 'City-London-Sum', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200, 200], + indexOfLastPinnedColumn: 0, + maxLevel: 0, + maxRowLevel: 1 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(pivotData, options); + }); + + it('should handle hierarchical grid with owner not in map', (done) => { + const childOwner = 'nonexistent-owner'; + const parentColumns: IColumnInfo[] = [ + { + header: 'Name', + field: 'name', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + } + ]; + + const parentOwner: IColumnList = { + columns: parentColumns, + columnWidths: [200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, parentOwner); + // Don't set childOwner in map + + const hierarchicalData: IExportRecord[] = [ + { + data: { name: 'Parent 1' }, + level: 0, + type: ExportRecordType.HierarchicalGridRecord, + owner: DEFAULT_OWNER + }, + { + data: { name: 'Child 1' }, + level: 1, + type: ExportRecordType.HierarchicalGridRecord, + owner: childOwner + } + ]; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(hierarchicalData, options); + }); + + it('should handle tree grid with undefined level', (done) => { + const treeData: IExportRecord[] = [ + { + data: { name: 'Root 1', value: 100 }, + level: undefined as any, + type: ExportRecordType.TreeGridRecord + } + ]; + + const columns: IColumnInfo[] = [ + { + header: 'Name', + field: 'name', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + }, + { + header: 'Value', + field: 'value', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 1, + level: 0, + columnSpan: 1 + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(treeData, options); + }); + + it('should handle pivot grid with columnGroupParent as non-string', (done) => { + const pivotData: IExportRecord[] = [ + { + data: { Product: 'Product A', Category: 'Category 1', 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord + } + ]; + + const columns: IColumnInfo[] = [ + { + header: 'Product', + field: 'Product', + skip: false, + headerType: ExportHeaderType.RowHeader, + startIndex: 0, + level: 0, + columnGroup: { id: 'product' } as any // Non-string columnGroup + }, + { + header: 'Category', + field: 'Category', + skip: false, + headerType: ExportHeaderType.RowHeader, + startIndex: 1, + level: 1, + columnGroupParent: { id: 'product' } as any // Non-string columnGroupParent + }, + { + header: 'Sum', + field: 'City-London-Sum', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200, 200, 200], + indexOfLastPinnedColumn: 1, + maxLevel: 0, + maxRowLevel: 2 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(pivotData, options); + }); + + it('should handle pivot grid with column header matching record values', (done) => { + const pivotData: IExportRecord[] = [ + { + data: { Product: 'Product A', 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product'] + } + ]; + + const columns: IColumnInfo[] = [ + { + header: 'Product A', // Header matches value in data + field: 'Product', + skip: false, + headerType: ExportHeaderType.RowHeader, + startIndex: 0, + level: 0 + }, + { + header: 'Sum', + field: 'City-London-Sum', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200, 200], + indexOfLastPinnedColumn: 0, + maxLevel: 0, + maxRowLevel: 1 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(pivotData, options); + }); + + it('should handle pivot grid with record index-based column selection', (done) => { + const pivotData: IExportRecord[] = [ + { + data: { Product: 'Product A', 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product'] + }, + { + data: { Product: 'Product B', 'City-London-Sum': 200 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product'] + } + ]; + + const columns: IColumnInfo[] = [ + { + header: 'Product', + field: 'Product', + skip: false, + headerType: ExportHeaderType.PivotRowHeader, + startIndex: 0, + level: 0 + }, + { + header: 'Product A', + field: 'Product', + skip: false, + headerType: ExportHeaderType.RowHeader, + startIndex: 0, + level: 0 + }, + { + header: 'Product B', + field: 'Product', + skip: false, + headerType: ExportHeaderType.RowHeader, + startIndex: 1, + level: 0 + }, + { + header: 'Sum', + field: 'City-London-Sum', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200, 200], + indexOfLastPinnedColumn: 0, + maxLevel: 0, + maxRowLevel: 1 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(pivotData, options); + }); + + it('should handle pivot grid with empty allColumns in drawDataRow', (done) => { + const pivotData: IExportRecord[] = [ + { + data: { Product: 'Product A', 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product'] + } + ]; + + const columns: IColumnInfo[] = [ + { + header: 'Sum', + field: 'City-London-Sum', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200], + indexOfLastPinnedColumn: -1, + maxLevel: 0, + maxRowLevel: 0 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(pivotData, options); + }); + + it('should handle very long header text truncation', (done) => { + const longHeaderText = 'This is a very long header text that should be truncated because it exceeds the maximum width of the column header cell in the PDF export'; + const columns: IColumnInfo[] = [ + { + header: longHeaderText, + field: 'name', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + const data: IExportRecord[] = [ + { + data: { name: 'Test' }, + level: 0, + type: ExportRecordType.DataRecord + } + ]; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(data, options); + }); + + it('should handle pivot grid with row dimension columns but no matching data', (done) => { + const pivotData: IExportRecord[] = [ + { + data: { 'City-London-Sum': 100 }, // No dimension fields in data + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product'] // But dimensionKeys says Product exists + } + ]; + + const columns: IColumnInfo[] = [ + { + header: 'Product', + field: 'Product', + skip: false, + headerType: ExportHeaderType.PivotRowHeader, + startIndex: 0, + level: 0 + }, + { + header: 'Sum', + field: 'City-London-Sum', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200, 200], + indexOfLastPinnedColumn: 0, + maxLevel: 0, + maxRowLevel: 1 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(pivotData, options); + }); + + it('should handle column field as non-string gracefully', (done) => { + // This test verifies that non-string fields are handled without crashing + // The base exporter may filter these out, so we test with valid data structure + const columns: IColumnInfo[] = [ + { + header: 'Name', + field: 'name', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + }, + { + header: 'Value', + field: 'value', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 1, + level: 0, + columnSpan: 1 + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + const data: IExportRecord[] = [ + { + data: { name: 'Test', value: 123 }, + level: 0, + type: ExportRecordType.DataRecord + } + ]; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(data, options); + }); + + it('should handle empty rowDimensionHeaders fallback path', (done) => { + const pivotData: IExportRecord[] = [ + { + data: { Product: 'Product A', 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product'] + } + ]; + + const columns: IColumnInfo[] = [ + { + header: 'Sum', + field: 'City-London-Sum', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200], + indexOfLastPinnedColumn: -1, + maxLevel: 0, + maxRowLevel: 0 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(pivotData, options); + }); + + it('should handle PivotMergedHeader with empty header text', (done) => { + const pivotData: IExportRecord[] = [ + { + data: { Product: 'Product A', 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product'] + } + ]; + + const columns: IColumnInfo[] = [ + { + header: '', + field: '', + skip: false, + headerType: ExportHeaderType.PivotMergedHeader, + startIndex: 0, + level: 0 + }, + { + header: 'Sum', + field: 'City-London-Sum', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200, 200], + indexOfLastPinnedColumn: 0, + maxLevel: 0, + maxRowLevel: 1 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(pivotData, options); + }); + + it('should handle resolveLayoutStartIndex with no child columns', (done) => { + const columns: IColumnInfo[] = [ + { + header: 'Parent', + field: 'parent', + skip: false, + headerType: ExportHeaderType.MultiColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1, + columnGroup: 'Parent' + // No child columns with columnGroupParent === 'Parent' + } + ]; + + const owner: IColumnList = { + columns: columns, + columnWidths: [200], + indexOfLastPinnedColumn: -1, + maxLevel: 1 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + const data: IExportRecord[] = [ + { + data: { parent: 'Value' }, + level: 0, + type: ExportRecordType.DataRecord + } + ]; + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(data, options); + }); + + it('should handle data with zero total columns', (done) => { + const data: IExportRecord[] = [ + { + data: {}, + level: 0, + type: ExportRecordType.DataRecord + } + ]; + + const owner: IColumnList = { + columns: [], + columnWidths: [], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + }; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + done(); + }); + + exporter.exportData(data, options); + }); + }); +}); diff --git a/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter.ts b/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter.ts new file mode 100644 index 00000000000..2b0c4299011 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter.ts @@ -0,0 +1,1142 @@ +import { EventEmitter, Injectable } from '@angular/core'; +import { DEFAULT_OWNER, ExportHeaderType, ExportRecordType, GRID_LEVEL_COL, IExportRecord, IgxBaseExporter } from '../exporter-common/base-export-service'; +import { ExportUtilities } from '../exporter-common/export-utilities'; +import { IgxPdfExporterOptions } from './pdf-exporter-options'; +import { IBaseEventArgs } from 'igniteui-angular/core'; +import type { jsPDF } from 'jspdf'; + +export interface IPdfExportEndedEventArgs extends IBaseEventArgs { + pdf?: jsPDF; +} + +/** + * **Ignite UI for Angular PDF Exporter Service** - + * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/exporter_pdf.html) + * + * The Ignite UI for Angular PDF Exporter service can export data in PDF format from both raw data + * (array) or from an `IgxGrid`. + * + * Example: + * ```typescript + * public localData = [ + * { Name: "Eric Ridley", Age: "26" }, + * { Name: "Alanis Brook", Age: "22" }, + * { Name: "Jonathan Morris", Age: "23" } + * ]; + * + * constructor(private pdfExportService: IgxPdfExporterService) { + * } + * + * this.pdfExportService.exportData(this.localData, new IgxPdfExporterOptions("FileName")); + * ``` + */ +@Injectable({ + providedIn: 'root', +}) +export class IgxPdfExporterService extends IgxBaseExporter { + + /** + * This event is emitted when the export process finishes. + * ```typescript + * this.exporterService.exportEnded.subscribe((args: IPdfExportEndedEventArgs) => { + * // put event handler code here + * }); + * ``` + * + * @memberof IgxPdfExporterService + */ + public override exportEnded = new EventEmitter(); + + protected exportDataImplementation(data: IExportRecord[], options: IgxPdfExporterOptions, done: () => void): void { + const firstDataElement = data[0]; + const isHierarchicalGrid = firstDataElement?.type === ExportRecordType.HierarchicalGridRecord; + const isPivotGrid = firstDataElement?.type === ExportRecordType.PivotGridRecord; + + const defaultOwner = isHierarchicalGrid ? + this._ownersMap.get(firstDataElement.owner) : + this._ownersMap.get(DEFAULT_OWNER); + + // Get all columns (including multi-column headers) + const allColumns = defaultOwner?.columns.filter(col => !col.skip) || []; + + // Extract pivot grid row dimension fields (these are in the data, rendered as row headers) + // For pivot grids, the row dimension fields appear in each record's data + const rowDimensionFields: string[] = []; + const rowDimensionHeaders: string[] = []; + if (isPivotGrid && defaultOwner) { + const uniqueFields = new Set(); + + // Primary source: use dimensionKeys from the first record (set by base exporter) + // This is the authoritative source for dimension field names + if (firstDataElement?.dimensionKeys && Array.isArray(firstDataElement.dimensionKeys) && firstDataElement.dimensionKeys.length > 0) { + firstDataElement.dimensionKeys.forEach(key => { + if (!uniqueFields.has(key)) { + uniqueFields.add(key); + rowDimensionFields.push(key); + } + }); + } + + // If we still don't have fields, try to get them from the record data + if (rowDimensionFields.length === 0 && firstDataElement && firstDataElement.data) { + // Fallback: Try to infer dimension keys from the record data structure + // Get row dimension columns to understand the structure + const rowHeaderCols = allColumns.filter(col => + (col.headerType === ExportHeaderType.RowHeader || + col.headerType === ExportHeaderType.MultiRowHeader || + col.headerType === ExportHeaderType.PivotMergedHeader) && + !col.skip + ); + + const recordKeys = Object.keys(firstDataElement.data); + // Try to match row dimension columns to record keys + rowHeaderCols.forEach(col => { + const fieldName = typeof col.field === 'string' ? col.field : null; + const columnGroup = typeof col.columnGroup === 'string' ? col.columnGroup : + (typeof col.columnGroupParent === 'string' ? col.columnGroupParent : null); + // Check if the field or column group exists in record data + if (fieldName && recordKeys.includes(fieldName) && !uniqueFields.has(fieldName)) { + uniqueFields.add(fieldName); + rowDimensionFields.push(fieldName); + } else if (columnGroup && recordKeys.includes(columnGroup) && !uniqueFields.has(columnGroup)) { + uniqueFields.add(columnGroup); + rowDimensionFields.push(columnGroup); + } + }); + + // If still no fields found, use the first few simple keys from record data + // (dimension keys are usually simple, aggregation keys are often complex) + if (rowDimensionFields.length === 0) { + const simpleKeys = recordKeys.filter(key => { + // Dimension keys are typically simple (no separators, reasonable length) + return !key.includes('-') && !key.includes('_') && + key.length < 50 && + key === key.trim(); + }); + // Take up to the number of row dimensions (usually 1-3) + simpleKeys.slice(0, Math.min(3, simpleKeys.length)).forEach(key => { + if (!uniqueFields.has(key)) { + uniqueFields.add(key); + rowDimensionFields.push(key); + } + }); + } + } + + // Ensure we have at least some fields - if not, we can't display dimension values + // In this case, we'll still draw the columns but they'll be empty + + // Get PivotRowHeader columns - these are the dimension names (like "All My Products", "Product", "City") + // These should match the enabled row dimensions in order + const pivotRowHeaders = allColumns + .filter(col => col.headerType === ExportHeaderType.PivotRowHeader) + .sort((a, b) => (a.startIndex ?? 0) - (b.startIndex ?? 0)); + + // Use PivotRowHeader names as column headers + const sortedPivotRowHeaders = pivotRowHeaders.map(col => col.header || col.field).filter(h => h); + rowDimensionHeaders.push(...sortedPivotRowHeaders); + + // For hierarchical dimensions, we might need to add child level headers + // Check if we have row dimension columns at different levels that aren't covered by PivotRowHeaders + if (rowDimensionHeaders.length < rowDimensionFields.length) { + // Get row dimension columns to find missing headers + const rowHeaderCols = allColumns + .filter(col => + (col.headerType === ExportHeaderType.RowHeader || + col.headerType === ExportHeaderType.MultiRowHeader || + col.headerType === ExportHeaderType.PivotMergedHeader) && + col.field && + !col.skip + ) + .sort((a, b) => { + const levelDiff = (a.level ?? 0) - (b.level ?? 0); + if (levelDiff !== 0) return levelDiff; + return (a.startIndex ?? 0) - (b.startIndex ?? 0); + }); + + // Add missing headers using the header property from row dimension columns + const existingHeaders = new Set(rowDimensionHeaders); + rowHeaderCols.forEach(col => { + const fieldName = typeof col.field === 'string' ? col.field : null; + const headerName = (typeof col.header === 'string' ? col.header : fieldName) || ''; + // If this field is in rowDimensionFields but header is missing, add it + if (fieldName && rowDimensionFields.includes(fieldName) && !existingHeaders.has(headerName)) { + // Only add if we haven't reached the target count + if (rowDimensionHeaders.length < rowDimensionFields.length) { + rowDimensionHeaders.push(headerName); + existingHeaders.add(headerName); + } + } + }); + + // If still missing, use field names + for (let i = rowDimensionHeaders.length; i < rowDimensionFields.length; i++) { + rowDimensionHeaders.push(rowDimensionFields[i]); + } + } else if (rowDimensionHeaders.length > rowDimensionFields.length) { + // Trim excess headers to match fields count + rowDimensionHeaders.splice(rowDimensionFields.length); + } + } + + // Get leaf columns (actual data columns), excluding GRID_LEVEL_COL and row dimension fields + // For pivot grids, we need to exclude row dimension fields since they're rendered separately + let leafColumns = allColumns.filter(col => { + if (col.field === GRID_LEVEL_COL) return false; + if (col.headerType !== ExportHeaderType.ColumnHeader) return false; + // For pivot grids, exclude row dimension fields from regular columns + if (isPivotGrid && rowDimensionFields.includes(col.field)) return false; + return true; + }); + + // Sort leaf columns by startIndex to maintain proper order + leafColumns = leafColumns.sort((a, b) => (a.startIndex ?? 0) - (b.startIndex ?? 0)); + + // Check if we have multi-level headers + const maxLevel = defaultOwner?.maxLevel || 0; + const maxRowLevel = defaultOwner?.maxRowLevel || 0; + const hasMultiColumnHeaders = maxLevel > 0 && allColumns.some(col => col.headerType === ExportHeaderType.MultiColumnHeader); + const hasMultiRowHeaders = maxRowLevel > 0 && rowDimensionFields.length > 0; + + if (leafColumns.length === 0 && data.length > 0 && firstDataElement) { + // If no columns are defined, use the keys from the first data record + const keys = Object.keys(firstDataElement.data); + + keys.forEach((key) => { + leafColumns.push({ + header: key, + field: key, + skip: false, + headerType: ExportHeaderType.ColumnHeader, + columnSpan: 1, + startIndex: 0 + }); + }); + } + // Dynamically import jsPDF to reduce initial bundle size + import('jspdf').then(({ jsPDF }) => { + // Create PDF document + const pdf = new jsPDF({ + orientation: options.pageOrientation, + unit: 'pt', + format: options.pageSize + }); + + const pageWidth = pdf.internal.pageSize.getWidth(); + const pageHeight = pdf.internal.pageSize.getHeight(); + const margin = 40; + const usableWidth = pageWidth - (2 * margin); + + // Calculate column widths + // For pivot grids with row dimensions, we need space for both row dimension columns and data columns + // Use the maximum of headers and fields to ensure we have space for all columns + // Headers determine how many columns to display, fields determine what data to show + const rowDimensionColumnCount = isPivotGrid ? Math.max(rowDimensionHeaders.length, rowDimensionFields.length) : 0; + const totalColumns = rowDimensionColumnCount + leafColumns.length; + const columnWidth = usableWidth / (totalColumns > 0 ? totalColumns : 1); + const rowHeight = 20; + const headerHeight = 25; + const indentSize = 15; // Indentation per level for hierarchical data (visual indent in first column) + const childTableIndent = 30; // Indent for child tables + + let yPosition = margin; + + // Set font + pdf.setFontSize(options.fontSize); + + // Draw multi-level headers if present + // For pivot grids, always draw row dimension headers if they exist, even if there are no multi-column headers + if (hasMultiColumnHeaders || (isPivotGrid && rowDimensionHeaders.length > 0)) { + yPosition = this.drawMultiLevelHeaders( + pdf, + allColumns, + rowDimensionHeaders, + maxLevel, + maxRowLevel, + margin, + yPosition, + columnWidth, + headerHeight, + usableWidth, + options, + allColumns + ); + } else { + // Draw simple single-level headers + this.drawTableHeaders(pdf, leafColumns, rowDimensionHeaders, margin, yPosition, columnWidth, headerHeight, usableWidth, options); + yPosition += headerHeight; + } + + // Draw data rows + pdf.setFont('helvetica', 'normal'); + + // For pivot grids, get row dimension columns to help with value lookup + const rowDimensionColumnsByLevel: Map = new Map(); + if (isPivotGrid && defaultOwner) { + const allRowDimCols = allColumns.filter(col => + (col.headerType === ExportHeaderType.RowHeader || + col.headerType === ExportHeaderType.MultiRowHeader || + col.headerType === ExportHeaderType.PivotMergedHeader) && + !col.skip + ); + // Group by level + allRowDimCols.forEach(col => { + const level = col.level ?? 0; + if (!rowDimensionColumnsByLevel.has(level)) { + rowDimensionColumnsByLevel.set(level, []); + } + rowDimensionColumnsByLevel.get(level)!.push(col); + }); + // Sort each level by startIndex + rowDimensionColumnsByLevel.forEach((cols, level) => { + cols.sort((a, b) => (a.startIndex ?? 0) - (b.startIndex ?? 0)); + }); + } + + let i = 0; + while (i < data.length) { + const record = data[i]; + + // Skip hidden records (collapsed hierarchy) + if (record.hidden) { + i++; + continue; + } + + // Check if we need a new page + if (yPosition + rowHeight > pageHeight - margin) { + pdf.addPage(); + yPosition = margin; + + // Redraw headers on new page + if (hasMultiColumnHeaders || hasMultiRowHeaders) { + yPosition = this.drawMultiLevelHeaders( + pdf, + allColumns, + rowDimensionHeaders, + maxLevel, + maxRowLevel, + margin, + yPosition, + columnWidth, + headerHeight, + usableWidth, + options, + allColumns + ); + } else { + this.drawTableHeaders(pdf, leafColumns, rowDimensionHeaders, margin, yPosition, columnWidth, headerHeight, usableWidth, options); + yPosition += headerHeight; + } + } + + // Calculate indentation for hierarchical records + // TreeGrid supports both hierarchical data and flat self-referencing data (with foreignKey) + // In both cases, the base exporter sets the level property on TreeGridRecord + const isTreeGrid = record.type === 'TreeGridRecord'; + const recordIsHierarchicalGrid = record.type === 'HierarchicalGridRecord'; + + // For tree grids, indentation is visual (in the first column text) + // For hierarchical grids, we don't use indentation (level determines column offset instead) + const indentLevel = isTreeGrid ? (record.level || 0) : 0; + const indent = indentLevel * indentSize; + + // Draw parent row + this.drawDataRow(pdf, record, leafColumns, rowDimensionFields, margin, yPosition, columnWidth, rowHeight, indent, options, allColumns, isPivotGrid, rowDimensionColumnsByLevel, i, rowDimensionHeaders); + yPosition += rowHeight; + + // For hierarchical grids, check if this record has child records + if (recordIsHierarchicalGrid) { + const allDescendants = []; + + // Collect all descendant records (children, grandchildren, etc.) that belong to this parent + // Child records have a different owner (island object) than the parent + let j = i + 1; + while (j < data.length && data[j].level > record.level) { + // Include all descendants (any level deeper) + if (!data[j].hidden) { + allDescendants.push(data[j]); + } + j++; + } + + // If there are descendant records, draw child table(s) + if (allDescendants.length > 0) { + // Group descendants by owner to separate different child grids + // Owner is the actual island object, not a string + // Only collect DIRECT children (one level deeper) for initial grouping + const directDescendantsByOwner = new Map(); + + for (const desc of allDescendants) { + // Only include records that are exactly one level deeper (direct children) + if (desc.level === record.level + 1) { + const owner = desc.owner; + if (!directDescendantsByOwner.has(owner)) { + directDescendantsByOwner.set(owner, []); + } + directDescendantsByOwner.get(owner)!.push(desc); + } + } + + // Draw each child grid separately with its direct children only + for (const [owner, directChildren] of directDescendantsByOwner) { + yPosition = this.drawHierarchicalChildren( + pdf, + data, + allDescendants, // Pass all descendants so grandchildren can be found + directChildren, + owner, + yPosition, + margin, + childTableIndent, + usableWidth, + pageHeight, + headerHeight, + rowHeight, + options + ); + } + + // Skip the descendant records we just processed + i = j - 1; + } + } + + i++; + } + + // Save the PDF + this.saveFile(pdf, options.fileName); + this.exportEnded.emit({ pdf }); + done(); + }); + } + + private drawMultiLevelHeaders( + pdf: jsPDF, + columns: any[], + rowDimensionHeaders: string[], + maxLevel: number, + maxRowLevel: number, + xStart: number, + yStart: number, + baseColumnWidth: number, + headerHeight: number, + tableWidth: number, + options: IgxPdfExporterOptions, + allColumns?: any[] + ): number { + let yPosition = yStart; + pdf.setFont('helvetica', 'bold'); + + // First, draw row dimension header labels (for pivot grids) if present + // Draw headers if we have any row dimension headers, regardless of maxRowLevel + if (rowDimensionHeaders.length > 0 && allColumns) { + // Get PivotRowHeader columns - these are the dimension header names + const pivotRowHeaderCols = allColumns.filter(col => + col.headerType === ExportHeaderType.PivotRowHeader && + !col.skip + ).sort((a, b) => (a.startIndex ?? 0) - (b.startIndex ?? 0)); + + // Calculate how many header rows the data columns have (cities + number/value = 2 rows) + // The row dimension headers should span across all data column header rows + const dataColumnHeaderRows = maxLevel + 1; // maxLevel is 0-based, so +1 gives us the number of rows + const rowDimensionHeaderRowSpan = Math.max(dataColumnHeaderRows, 1); + + // Draw each PivotRowHeader with rowSpan to span across data column headers + pivotRowHeaderCols.forEach((pivotCol, index) => { + const xPosition = xStart + (index * baseColumnWidth); + const headerText = pivotCol.header || pivotCol.field || rowDimensionHeaders[index] || ''; + const width = baseColumnWidth; + const height = headerHeight * rowDimensionHeaderRowSpan; + + // Skip if this is a merged/empty header that shouldn't be drawn + // PivotMergedHeader columns are typically placeholders and shouldn't be drawn separately + // Also skip if header text is empty and it's not a valid header + if ((pivotCol.headerType === ExportHeaderType.PivotMergedHeader && !headerText) || + (!headerText && !pivotCol.header && !pivotCol.field)) { + return; + } + + // Set fill color to light gray for header background (explicitly set before each cell) + pdf.setFillColor(240, 240, 240); + // Set stroke color to black for borders + pdf.setDrawColor(0, 0, 0); + + if (options.showTableBorders) { + // Draw filled rectangle for background (light gray) + pdf.rect(xPosition, yPosition, width, height, 'F'); + // Draw border (black outline) - this should not fill, just stroke + pdf.rect(xPosition, yPosition, width, height); + } else { + // Even without borders, draw background + pdf.rect(xPosition, yPosition, width, height, 'F'); + } + + // Only draw text if we have content + if (headerText) { + // Center text in merged cell + let displayText = headerText; + const maxTextWidth = width - 10; + + if (pdf.getTextWidth(displayText) > maxTextWidth) { + while (pdf.getTextWidth(displayText + '...') > maxTextWidth && displayText.length > 0) { + displayText = displayText.substring(0, displayText.length - 1); + } + displayText += '...'; + } + + const textWidth = pdf.getTextWidth(displayText); + const textX = xPosition + (width - textWidth) / 2; + const textY = yPosition + (height / 2) + options.fontSize / 3; + + pdf.text(displayText, textX, textY); + } + }); + + // Don't move yPosition yet - data column headers will be drawn at the same yPosition + // We'll move yPosition after drawing all header rows + } else if (rowDimensionHeaders.length > 0) { + // Fallback: draw simple headers without merging + rowDimensionHeaders.forEach((headerText, index) => { + const width = baseColumnWidth; + const height = headerHeight; + const xPosition = xStart + (index * baseColumnWidth); + + if (options.showTableBorders) { + pdf.rect(xPosition, yPosition, width, height, 'F'); + pdf.rect(xPosition, yPosition, width, height); + } + + // Center text in cell + let displayText = headerText || ''; + const maxTextWidth = width - 10; + + if (pdf.getTextWidth(displayText) > maxTextWidth) { + while (pdf.getTextWidth(displayText + '...') > maxTextWidth && displayText.length > 0) { + displayText = displayText.substring(0, displayText.length - 1); + } + displayText += '...'; + } + + const textWidth = pdf.getTextWidth(displayText); + const textX = xPosition + (width - textWidth) / 2; + const textY = yPosition + height / 2 + options.fontSize / 3; + + pdf.text(displayText, textX, textY); + }); + yPosition += headerHeight; + } + + // Filter out row header types and GRID_LEVEL_COL from column rendering + const columnHeaders = columns.filter(col => + col.headerType !== ExportHeaderType.PivotRowHeader && + col.headerType !== ExportHeaderType.RowHeader && + col.headerType !== ExportHeaderType.MultiRowHeader && + col.headerType !== ExportHeaderType.PivotMergedHeader && + col.field !== GRID_LEVEL_COL + ); + + const rowDimensionOffset = rowDimensionHeaders.length * baseColumnWidth; + + const totalHeaderLevels = maxLevel + 1; + + // Map layout positions based on actual leaf order so headers align with child data columns + const headerLayoutMap = new Map(); + const leafHeaders = columnHeaders + .filter(col => col.headerType === ExportHeaderType.ColumnHeader && col.columnSpan > 0) + .sort((a, b) => (a.startIndex ?? 0) - (b.startIndex ?? 0)); + + leafHeaders.forEach((col, idx) => headerLayoutMap.set(col, idx)); + + const resolveLayoutStartIndex = (col: any): number => { + if (headerLayoutMap.has(col)) { + return headerLayoutMap.get(col)!; + } + + if (col.headerType === ExportHeaderType.MultiColumnHeader) { + const childColumns = columnHeaders.filter(child => + child.columnGroupParent === col.columnGroup && child.columnSpan > 0); + const childIndices = childColumns.map(child => resolveLayoutStartIndex(child)); + + if (childIndices.length > 0) { + const minIndex = Math.min(...childIndices); + headerLayoutMap.set(col, minIndex); + return minIndex; + } + } + + headerLayoutMap.set(col, 0); + return 0; + }; + + // Draw column headers level by level (from top/parent to bottom/children) + for (let level = 0; level <= maxLevel; level++) { + // Get headers for this level + const headersForLevel = columnHeaders + .filter(col => + col.level === level && + (col.headerType === ExportHeaderType.MultiColumnHeader || col.headerType === ExportHeaderType.ColumnHeader) + ) + .filter(col => col.columnSpan > 0); + + if (headersForLevel.length === 0) { + yPosition += headerHeight; + continue; + } + + // Sort by startIndex to maintain order + headersForLevel.sort((a, b) => a.startIndex - b.startIndex); + + // Draw each header in this level + headersForLevel.forEach((col, idx) => { + const colSpan = col.columnSpan || 1; + const width = baseColumnWidth * colSpan; + const normalizedStartIndex = resolveLayoutStartIndex(col); + const xPosition = xStart + rowDimensionOffset + (normalizedStartIndex * baseColumnWidth); + const rowSpan = col.headerType === ExportHeaderType.ColumnHeader ? + Math.max(1, (totalHeaderLevels - (col.level ?? 0))) : + 1; + const height = headerHeight * rowSpan; + + if (options.showTableBorders) { + pdf.setFillColor(240, 240, 240); + pdf.setDrawColor(0, 0, 0); + pdf.rect(xPosition, yPosition, width, height, 'F'); + pdf.rect(xPosition, yPosition, width, height); + } + + // Center text in cell with truncation if needed + let headerText = col.header || col.field || ''; + const maxTextWidth = width - 10; // Leave 5px padding on each side + + // Truncate text if it's too long + if (pdf.getTextWidth(headerText) > maxTextWidth) { + while (pdf.getTextWidth(headerText + '...') > maxTextWidth && headerText.length > 0) { + headerText = headerText.substring(0, headerText.length - 1); + } + headerText += '...'; + } + + const textWidth = pdf.getTextWidth(headerText); + const textX = xPosition + (width - textWidth) / 2; + const textY = yPosition + (height / 2) + options.fontSize / 3; + + pdf.text(headerText, textX, textY); + }); + + yPosition += headerHeight; + } + + // After drawing all headers, move yPosition down by the total header height + // For pivot grids with row dimension headers, this should be the max of row dimension header height and data column header height + if (rowDimensionHeaders.length > 0 && allColumns) { + const dataColumnHeaderRows = maxLevel + 1; + const rowDimensionHeaderRowSpan = Math.max(dataColumnHeaderRows, 1); + const totalHeaderHeight = headerHeight * rowDimensionHeaderRowSpan; + yPosition = yStart + totalHeaderHeight; + } + + pdf.setFont('helvetica', 'normal'); + return yPosition; + } + + private drawHierarchicalChildren( + pdf: jsPDF, + allData: IExportRecord[], + allDescendants: IExportRecord[], // All descendants to search for grandchildren + childRecords: IExportRecord[], // Direct children to render at this level + childOwner: any, // Owner is the island object, not a string + yPosition: number, + margin: number, + indentPerLevel: number, + usableWidth: number, + pageHeight: number, + headerHeight: number, + rowHeight: number, + options: IgxPdfExporterOptions + ): number { + // Get columns for this child owner + const childOwnerObj = this._ownersMap.get(childOwner); + + const allChildColumns = childOwnerObj?.columns.filter( + col => col.field !== GRID_LEVEL_COL && !col.skip + ) || []; + + const childColumns = allChildColumns.filter( + col => col.headerType === ExportHeaderType.ColumnHeader + ); + + if (childColumns.length === 0) { + return yPosition; + } + + // Filter out header records - they should not be rendered as data rows + const dataRecords = childRecords.filter(r => r.type !== 'HeaderRecord'); + + if (dataRecords.length === 0) { + return yPosition; + } + + // Add some spacing before child table + yPosition += 5; + + // Calculate available width after indentation + const availableWidth = usableWidth - indentPerLevel; + + // Calculate total column span for proper width distribution + const maxLevel = childOwnerObj?.maxLevel || 0; + + // Fix startIndex for all child columns + let currentIndex = 0; + for (const col of allChildColumns) { + if (col.level === 0 && (col.headerType === ExportHeaderType.MultiColumnHeader || col.headerType === ExportHeaderType.ColumnHeader)) { + col.startIndex = currentIndex; + currentIndex += col.columnSpan || 1; + } + } + + let totalColumnSpan = 0; + if (maxLevel > 0) { + const baseLevelColumns = allChildColumns.filter(col => + col.level === 0 && + (col.headerType === ExportHeaderType.MultiColumnHeader || col.headerType === ExportHeaderType.ColumnHeader) + ); + totalColumnSpan = baseLevelColumns.reduce((sum, col) => sum + (col.columnSpan || 1), 0); + } else { + totalColumnSpan = childColumns.length; + } + + // Recalculate column width based on child's column count and available width + const childColumnWidth = availableWidth / totalColumnSpan; + const actualChildTableWidth = childColumnWidth * totalColumnSpan; + const childTableX = margin + indentPerLevel; + + // Check if we need a new page for headers + if (yPosition + headerHeight > pageHeight - margin) { + pdf.addPage(); + yPosition = margin; + } + + // Draw child table headers + const hasMultiColumnHeaders = maxLevel > 0 && childOwnerObj.columns.some(col => col.headerType === ExportHeaderType.MultiColumnHeader); + + if (hasMultiColumnHeaders) { + yPosition = this.drawMultiLevelHeaders( + pdf, + allChildColumns, + [], // rowDimensionHeaders, if any + maxLevel, + 0, // maxRowLevel + childTableX, + yPosition, + childColumnWidth, + headerHeight, + actualChildTableWidth, + options + ); + } else { + this.drawTableHeaders(pdf, childColumns, [], childTableX, yPosition, childColumnWidth, headerHeight, actualChildTableWidth, options); + yPosition += headerHeight; + } + + // Find the minimum level in these records (direct children of parent) + const minLevel = Math.min(...dataRecords.map(r => r.level)); + + // Process each record at the minimum level (direct children) + const directChildren = dataRecords.filter(r => r.level === minLevel); + + for (const childRecord of directChildren) { + // Check if we need a new page + if (yPosition + rowHeight > pageHeight - margin) { + pdf.addPage(); + yPosition = margin; + // Redraw headers on new page + if (hasMultiColumnHeaders) { + yPosition = this.drawMultiLevelHeaders( + pdf, allChildColumns, [], maxLevel, 0, + childTableX, yPosition, childColumnWidth, headerHeight, + actualChildTableWidth, options + ); + } else { + this.drawTableHeaders(pdf, childColumns, [], childTableX, yPosition, childColumnWidth, headerHeight, actualChildTableWidth, options); + yPosition += headerHeight; + } + } + + // Draw the child record + this.drawDataRow(pdf, childRecord, childColumns, [], childTableX, yPosition, childColumnWidth, rowHeight, 0, options); + yPosition += rowHeight; + + // Check if this child has grandchildren (deeper levels in different child grids) + // Look for grandchildren in allDescendants that are direct descendants of this childRecord + const grandchildrenForThisRecord = allDescendants.filter(r => + r.level === childRecord.level + 1 && r.type !== 'HeaderRecord' + ); + + if (grandchildrenForThisRecord.length > 0) { + // Group grandchildren by their owner (different child islands under this record) + const grandchildrenByOwner = new Map(); + + for (const gc of grandchildrenForThisRecord) { + // Use the actual owner object + const gcOwner = gc.owner; + // Only include grandchildren that have a different owner (separate child grid) + if (gcOwner !== childOwner) { + if (!grandchildrenByOwner.has(gcOwner)) { + grandchildrenByOwner.set(gcOwner, []); + } + grandchildrenByOwner.get(gcOwner)!.push(gc); + } + } + + // Recursively draw each grandchild owner's records with increased indentation + for (const [gcOwner, directGrandchildren] of grandchildrenByOwner) { + yPosition = this.drawHierarchicalChildren( + pdf, + allData, + allDescendants, // Pass all descendants so great-grandchildren can be found + directGrandchildren, // Direct grandchildren to render + gcOwner, + yPosition, + margin, + indentPerLevel + 20, // Increase indentation for next level + usableWidth, + pageHeight, + headerHeight, + rowHeight, + options + ); + } + } + } + + // Add spacing after child table + yPosition += 5; + + return yPosition; + } + + private drawTableHeaders( + pdf: jsPDF, + columns: any[], + rowDimensionHeaders: string[], + xStart: number, + yPosition: number, + columnWidth: number, + headerHeight: number, + tableWidth: number, + options: IgxPdfExporterOptions + ): void { + pdf.setFont('helvetica', 'bold'); + pdf.setFillColor(240, 240, 240); + + if (options.showTableBorders) { + pdf.rect(xStart, yPosition, tableWidth, headerHeight, 'F'); + } + + // Draw row dimension headers first (for pivot grids) + rowDimensionHeaders.forEach((headerText, index) => { + const xPosition = xStart + (index * columnWidth); + let displayText = headerText; + + if (options.showTableBorders) { + pdf.rect(xPosition, yPosition, columnWidth, headerHeight); + } + + // Truncate text if it's too long + const maxTextWidth = columnWidth - 10; + if (pdf.getTextWidth(displayText) > maxTextWidth) { + while (pdf.getTextWidth(displayText + '...') > maxTextWidth && displayText.length > 0) { + displayText = displayText.substring(0, displayText.length - 1); + } + displayText += '...'; + } + + // Center text in cell + const textWidth = pdf.getTextWidth(displayText); + const textX = xPosition + (columnWidth - textWidth) / 2; + const textY = yPosition + headerHeight / 2 + options.fontSize / 3; + + pdf.text(displayText, textX, textY); + }); + + const rowDimensionOffset = rowDimensionHeaders.length * columnWidth; + + // Draw data column headers + columns.forEach((col, index) => { + // Skip GRID_LEVEL_COL - it shouldn't be rendered + if (col.field === GRID_LEVEL_COL) { + return; + } + + const xPosition = xStart + rowDimensionOffset + (index * columnWidth); + let headerText = col.header || col.field; + + if (options.showTableBorders) { + pdf.rect(xPosition, yPosition, columnWidth, headerHeight); + } + + // Truncate text if it's too long + const maxTextWidth = columnWidth - 10; // Leave 5px padding on each side + if (pdf.getTextWidth(headerText) > maxTextWidth) { + while (pdf.getTextWidth(headerText + '...') > maxTextWidth && headerText.length > 0) { + headerText = headerText.substring(0, headerText.length - 1); + } + headerText += '...'; + } + + // Center text in cell + const textWidth = pdf.getTextWidth(headerText); + const textX = xPosition + (columnWidth - textWidth) / 2; + const textY = yPosition + headerHeight / 2 + options.fontSize / 3; + + pdf.text(headerText, textX, textY); + }); + + pdf.setFont('helvetica', 'normal'); + } + + private drawDataRow( + pdf: jsPDF, + record: IExportRecord, + columns: any[], + rowDimensionFields: string[], + xStart: number, + yPosition: number, + columnWidth: number, + rowHeight: number, + indent: number, + options: IgxPdfExporterOptions, + allColumns?: any[], + isPivotGrid?: boolean, + rowDimensionColumnsByLevel?: Map, + recordIndex?: number, + rowDimensionHeaders?: string[] + ): void { + const isSummaryRecord = record.type === 'SummaryRecord'; + + // Draw row dimension cells first (for pivot grids) + // For pivot grids, the row dimension columns have 'header' property that contains the actual dimension values + // Use the maximum of fields and headers to ensure we draw all columns + const maxRowDimCols = Math.max(rowDimensionFields.length, rowDimensionHeaders?.length || 0); + for (let index = 0; index < maxRowDimCols; index++) { + const xPosition = xStart + (index * columnWidth); + let cellValue: any = null; + + // Primary approach: Get the value from row dimension columns' header property + // The row dimension columns are created with header = actual dimension value to display + if (isPivotGrid && allColumns) { + // Get all row dimension columns sorted by level and startIndex + const allRowDimCols = allColumns.filter(col => + (col.headerType === ExportHeaderType.RowHeader || + col.headerType === ExportHeaderType.MultiRowHeader || + col.headerType === ExportHeaderType.PivotMergedHeader) && + !col.skip + ).sort((a, b) => { + const levelDiff = (a.level ?? 0) - (b.level ?? 0); + if (levelDiff !== 0) return levelDiff; + return (a.startIndex ?? 0) - (b.startIndex ?? 0); + }); + + // For hierarchical dimensions, match columns by level + // The index corresponds to the dimension level (0 = first dimension, 1 = second, etc.) + const colsForLevel = allRowDimCols.filter(col => (col.level ?? 0) === index); + + // The row dimension columns are created in the same order as records appear + // We can use the record index to find the corresponding column + // However, for hierarchical dimensions, we need to account for row spans + if (colsForLevel.length > 0) { + // Try to find the column that matches this record + // First, try matching by checking if column field/header matches record data + let matchedCol = null; + if (record.data) { + for (const col of colsForLevel) { + const colField = typeof col.field === 'string' ? col.field : null; + const colHeader = typeof col.header === 'string' ? col.header : null; + + // Check if column field exists as a key in record data + if (colField && record.data[colField] !== undefined) { + matchedCol = col; + break; + } + // Check if column header matches a value in record data + if (colHeader) { + const recordValues = Object.values(record.data).map(v => String(v)); + if (recordValues.includes(colHeader)) { + matchedCol = col; + break; + } + } + } + } + + // If no match found, try to use record index to select column + // This works because columns are created in the same order as records + if (!matchedCol && recordIndex !== undefined) { + // For hierarchical dimensions with row spans, we need to account for that + // For now, use a simple index-based approach + const colIndex = Math.min(recordIndex, colsForLevel.length - 1); + matchedCol = colsForLevel[colIndex]; + } + + // If still no match, use the first column at this level + if (!matchedCol && colsForLevel.length > 0) { + matchedCol = colsForLevel[0]; + } + + // Use the header property - it contains the actual dimension value to display + if (matchedCol) { + if (matchedCol.header && typeof matchedCol.header === 'string') { + cellValue = matchedCol.header; + } else if (matchedCol.field && typeof matchedCol.field === 'string') { + cellValue = matchedCol.field; + } + } + } + } + + // Fallback: Try to get value using dimensionKeys (member names as keys in record.data) + if ((cellValue === null || cellValue === undefined) && record.data) { + const fieldName = rowDimensionFields[index]; + if (fieldName) { + cellValue = record.data[fieldName]; + } + } + + // Last resort: Try to find it by checking all keys in record data + if ((cellValue === null || cellValue === undefined) && record.data) { + const recordKeys = Object.keys(record.data); + const fieldName = rowDimensionFields[index]; + + // If we have a fieldName, try exact and fuzzy matching + if (fieldName) { + const matchingKey = recordKeys.find(key => + key.toLowerCase() === fieldName.toLowerCase() || + key === fieldName || + fieldName.toLowerCase().includes(key.toLowerCase()) || + key.toLowerCase().includes(fieldName.toLowerCase()) + ); + if (matchingKey) { + cellValue = record.data[matchingKey]; + } + } + + // For hierarchical dimensions, try using dimension keys by index + if ((cellValue === null || cellValue === undefined) && isPivotGrid && recordKeys.length > 0) { + const possibleDimKeys = recordKeys.filter(key => { + return !key.includes('-') && !key.includes('_') && + key === key.trim() && + key.length < 50; + }); + + if (possibleDimKeys.length > index) { + cellValue = record.data[possibleDimKeys[index]]; + } else if (possibleDimKeys.length > 0) { + cellValue = record.data[possibleDimKeys[0]]; + } + } + } + + // Convert value to string + if (cellValue === null || cellValue === undefined) { + cellValue = ''; + } else if (cellValue instanceof Date) { + cellValue = cellValue.toLocaleDateString(); + } else { + cellValue = String(cellValue); + } + + if (options.showTableBorders) { + pdf.setFillColor(255, 255, 255); + pdf.setDrawColor(0, 0, 0); + pdf.rect(xPosition, yPosition, columnWidth, rowHeight); + } + + // Truncate text if it's too long + const maxTextWidth = columnWidth - 10; + let displayText = cellValue; + + if (pdf.getTextWidth(displayText) > maxTextWidth) { + while (pdf.getTextWidth(displayText + '...') > maxTextWidth && displayText.length > 0) { + displayText = displayText.substring(0, displayText.length - 1); + } + displayText += '...'; + } + + const textY = yPosition + rowHeight / 2 + options.fontSize / 3; + pdf.text(displayText, xPosition + 5, textY); + } + + const rowDimensionOffset = maxRowDimCols * columnWidth; + + // Draw data columns + columns.forEach((col, index) => { + // Skip GRID_LEVEL_COL - it's an internal column + if (col.field === GRID_LEVEL_COL) { + return; + } + + const xPosition = xStart + rowDimensionOffset + (index * columnWidth); + let cellValue = record.data[col.field]; + + // Handle summary records - cellValue is an IgxSummaryResult object + if (isSummaryRecord && cellValue) { + // For summary records, the cellValue has label and value properties + // or it might be summaryResult property + if (cellValue.label !== undefined || cellValue.value !== undefined) { + const label = cellValue.label?.toString() || ''; + const value = cellValue.value?.toString() || cellValue.summaryResult?.toString() || ''; + if (label && value) { + cellValue = `${label}: ${value}`; + } else if (label) { + cellValue = label; + } else if (value) { + cellValue = value; + } else { + cellValue = ''; + } + } else if (cellValue.summaryResult !== undefined) { + cellValue = cellValue.summaryResult; + } + } + + // Convert value to string + if (cellValue === null || cellValue === undefined) { + cellValue = ''; + } else if (cellValue instanceof Date) { + cellValue = cellValue.toLocaleDateString(); + } else { + cellValue = String(cellValue); + } + + if (options.showTableBorders) { + pdf.setFillColor(255, 255, 255); + pdf.setDrawColor(0, 0, 0); + pdf.rect(xPosition, yPosition, columnWidth, rowHeight); + } + + // Apply indentation to the first column for hierarchical data + const textIndent = (index === 0) ? indent : 0; + + // Truncate text if it's too long, accounting for indentation + const maxTextWidth = columnWidth - 10 - textIndent; + let displayText = cellValue; + + if (pdf.getTextWidth(displayText) > maxTextWidth) { + while (pdf.getTextWidth(displayText + '...') > maxTextWidth && displayText.length > 0) { + displayText = displayText.substring(0, displayText.length - 1); + } + displayText += '...'; + } + + const textY = yPosition + rowHeight / 2 + options.fontSize / 3; + pdf.text(displayText, xPosition + 5 + textIndent, textY); + }); + } + + private saveFile(pdf: jsPDF, fileName: string): void { + const blob = pdf.output('blob'); + ExportUtilities.saveBlobToFile(blob, fileName); + } +} diff --git a/projects/igniteui-angular/grids/core/src/state-base.directive.ts b/projects/igniteui-angular/grids/core/src/state-base.directive.ts new file mode 100644 index 00000000000..dc76a23ad2c --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/state-base.directive.ts @@ -0,0 +1,691 @@ +import { Directive, Input, ViewContainerRef, createComponent, EnvironmentInjector, Injector, inject } from '@angular/core'; +import { IgxColumnComponent } from './columns/column.component'; +import { IgxColumnGroupComponent } from './columns/column-group.component'; +import { GridSelectionRange } from './common/types'; +import { GridType, IGX_GRID_BASE, IPinningConfig, PivotGridType } from './common/grid.interface'; +import { cloneArray, cloneValue, ColumnType, FieldType, GridColumnDataType, IExpressionTree, IFilteringExpressionsTree, IGroupByExpandState, IGroupingExpression, IGroupingState, IPagingState, ISortingExpression, recreateTreeFromFields } from 'igniteui-angular/core'; +import { IgxColumnLayoutComponent } from './columns/column-layout.component'; +import { IPivotConfiguration, IPivotDimension } from './pivot-grid.interface'; +import { PivotUtil } from './pivot-util'; +import { IgxPivotDateDimension } from './pivot-grid-dimensions'; + +export interface IGridState { + columns?: IColumnState[]; + filtering?: IFilteringExpressionsTree; + advancedFiltering?: IFilteringExpressionsTree; + paging?: IPagingState; + moving?: boolean; + sorting?: ISortingExpression[]; + groupBy?: IGroupingState; + cellSelection?: GridSelectionRange[]; + /* blazorPrimitiveValue */ + rowSelection?: any[]; + columnSelection?: string[]; + /* blazorPrimitiveValue */ + rowPinning?: any[]; + pinningConfig?: IPinningConfig; + /* blazorPrimitiveValue */ + expansion?: any[]; + rowIslands?: IGridStateCollection[]; + id?: string; + pivotConfiguration?: IPivotConfiguration; +} + +/* marshalByValue */ +export interface IGridStateCollection { + id: string; + parentRowID: any; + state: IGridState; +} + +export interface IGridStateOptions { + columns?: boolean; + filtering?: boolean; + advancedFiltering?: boolean; + sorting?: boolean; + groupBy?: boolean; + paging?: boolean; + cellSelection?: boolean; + rowSelection?: boolean; + columnSelection?: boolean; + rowPinning?: boolean; + pinningConfig?: boolean; + expansion?: boolean; + rowIslands?: boolean; + moving?: boolean; + pivotConfiguration?: boolean; +} + +/* marshalByValue */ +/* tsPlainInterface */ +export interface IColumnState { + pinned: boolean; + sortable: boolean; + filterable: boolean; + editable: boolean; + sortingIgnoreCase: boolean; + filteringIgnoreCase: boolean; + headerClasses: string; + headerGroupClasses: string; + maxWidth: string; + groupable: boolean; + hidden: boolean; + dataType: GridColumnDataType; + hasSummary: boolean; + field: string; + width: any; + header: string; + resizable: boolean; + searchable: boolean; + columnGroup: boolean; + // mrl props + columnLayout?: boolean; + rowStart?: number, + rowEnd?: number, + colStart?: number; + colEnd?: number, + /** + * @deprecated + */ + parent?: any; + key: string; + parentKey: string; + disableHiding: boolean; + disablePinning: boolean; + collapsible?: boolean; + expanded?: boolean; + visibleWhenCollapsed?: boolean; +} + +export type GridFeatures = keyof IGridStateOptions; + +interface Feature { + getFeatureState: (context: IgxGridStateBaseDirective) => IGridState; + restoreFeatureState: (context: IgxGridStateBaseDirective, state: IColumnState[] | IPagingState | boolean | ISortingExpression[] | + IGroupingState | IFilteringExpressionsTree | GridSelectionRange[] | IPinningConfig | IPivotConfiguration | any[]) => void; +} + +/* blazorElement */ +/* wcElementTag: igc-grid-state-base-directive */ +/* blazorIndirectRender */ +@Directive() +export class IgxGridStateBaseDirective { + public grid = inject(IGX_GRID_BASE, { host: true, optional: true }); + protected viewRef = inject(ViewContainerRef); + protected envInjector = inject(EnvironmentInjector); + protected injector = inject(Injector); + + + private featureKeys: GridFeatures[] = []; + private state: IGridState; + private currGrid: GridType; + protected _options: IGridStateOptions = { + columns: true, + filtering: true, + advancedFiltering: true, + sorting: true, + groupBy: true, + paging: true, + cellSelection: true, + rowSelection: true, + columnSelection: true, + rowPinning: true, + expansion: true, + moving: true, + rowIslands: true, + pivotConfiguration: true + }; + private FEATURES = { + sorting: { + getFeatureState: (context: IgxGridStateBaseDirective): IGridState => { + const sortingState = context.currGrid.sortingExpressions; + sortingState.forEach(s => { + delete s.strategy; + delete s.owner; + }); + return { sorting: sortingState }; + }, + restoreFeatureState: (context: IgxGridStateBaseDirective, state: ISortingExpression[]): void => { + context.currGrid.sortingExpressions = state; + } + }, + filtering: { + getFeatureState: (context: IgxGridStateBaseDirective): IGridState => { + const filteringState = context.currGrid.filteringExpressionsTree; + if (filteringState) { + delete filteringState.owner; + for (const item of filteringState.filteringOperands) { + delete (item as IFilteringExpressionsTree).owner; + } + } + return { filtering: filteringState }; + }, + restoreFeatureState: (context: IgxGridStateBaseDirective, state: IFilteringExpressionsTree): void => { + const filterTree = context.createExpressionsTreeFromObject(state); + context.currGrid.filteringExpressionsTree = filterTree as IFilteringExpressionsTree; + } + }, + advancedFiltering: { + getFeatureState: (context: IgxGridStateBaseDirective): IGridState => { + const filteringState = context.currGrid.advancedFilteringExpressionsTree; + let advancedFiltering: any; + if (filteringState) { + delete filteringState.owner; + for (const item of filteringState.filteringOperands) { + delete (item as IFilteringExpressionsTree).owner; + } + advancedFiltering = filteringState; + } else { + advancedFiltering = {}; + } + return { advancedFiltering }; + }, + restoreFeatureState: (context: IgxGridStateBaseDirective, state: IFilteringExpressionsTree): void => { + const filterTree = context.createExpressionsTreeFromObject(state); + context.currGrid.advancedFilteringExpressionsTree = filterTree as IFilteringExpressionsTree; + } + }, + columns: { + getFeatureState: (context: IgxGridStateBaseDirective): IGridState => { + const gridColumns: IColumnState[] = context.currGrid.columns.map((c) => ({ + pinned: c.pinned, + sortable: c.sortable, + filterable: c.filterable, + editable: c.editable, + sortingIgnoreCase: c.sortingIgnoreCase, + filteringIgnoreCase: c.filteringIgnoreCase, + headerClasses: c.headerClasses, + headerGroupClasses: c.headerGroupClasses, + maxWidth: c.maxWidth, + groupable: c.groupable, + hidden: c.hidden, + dataType: c.dataType, + hasSummary: c.hasSummary, + field: c.field, + width: c.width, + header: c.header, + resizable: c.resizable, + searchable: c.searchable, + selectable: c.selectable, + key: c.columnGroup ? this.getColumnGroupKey(c) : c.field, + parentKey: c.parent ? this.getColumnGroupKey(c.parent) : undefined, + columnGroup: c.columnGroup, + columnLayout: c.columnLayout || undefined, + rowStart: c.parent?.columnLayout ? c.rowStart : undefined, + rowEnd: c.parent?.columnLayout ? c.rowEnd : undefined, + colStart: c.parent?.columnLayout ? c.colStart : undefined, + colEnd: c.parent?.columnLayout ? c.colEnd : undefined, + disableHiding: c.disableHiding, + disablePinning: c.disablePinning, + collapsible: c.columnGroup ? c.collapsible : undefined, + expanded: c.columnGroup ? c.expanded : undefined, + visibleWhenCollapsed: c.parent?.columnGroup ? (c as IgxColumnComponent).visibleWhenCollapsed : undefined + })); + return { columns: gridColumns }; + }, + restoreFeatureState: (context: IgxGridStateBaseDirective, state: IColumnState[]): void => { + const newColumns = []; + state.forEach((colState) => { + const hasColumnGroup = colState.columnGroup; + const hasColumnLayouts = colState.columnLayout; + delete colState.columnGroup; + delete colState.columnLayout; + if (hasColumnGroup) { + let ref1: IgxColumnGroupComponent = context.currGrid.columns.find(x => x.columnGroup && (colState.key ? this.getColumnGroupKey(x) === colState.key : x.header === colState.header)) as IgxColumnGroupComponent; + if (!ref1) { + const component = hasColumnLayouts ? + createComponent(IgxColumnLayoutComponent, { environmentInjector: this.envInjector, elementInjector: this.injector }) : + createComponent(IgxColumnGroupComponent, { environmentInjector: this.envInjector, elementInjector: this.injector }); + ref1 = component.instance; + component.changeDetectorRef.detectChanges(); + } else { + ref1.children.reset([]); + } + Object.assign(ref1, colState); + ref1.grid = context.currGrid; + if (colState.parent || colState.parentKey) { + const columnGroup: IgxColumnGroupComponent = newColumns.find(e => e.columnGroup && (e.key ? e.key === colState.parentKey : e.header === ref1.parent)); + columnGroup.children.reset([...columnGroup.children.toArray(), ref1]); + ref1.parent = columnGroup; + } + ref1.cdr.detectChanges(); + newColumns.push(ref1); + } else { + let ref: IgxColumnComponent = context.currGrid.columns.find(x => !x.columnGroup && x.field === colState.field) as IgxColumnComponent; + if (!ref) { + const component = createComponent(IgxColumnComponent, { environmentInjector: this.envInjector, elementInjector: this.injector}); + ref = component.instance; + component.changeDetectorRef.detectChanges(); + } + + Object.assign(ref, colState); + ref.grid = context.currGrid; + if (colState.parent || colState.parentKey) { + const columnGroup: IgxColumnGroupComponent = newColumns.find(e => e.columnGroup && (e.key ? e.key === colState.parentKey : e.header === ref.parent)); + if (columnGroup) { + ref.parent = columnGroup; + columnGroup.children.reset([...columnGroup.children.toArray(), ref]); + } + } + ref.cdr.detectChanges(); + newColumns.push(ref); + } + }); + context.currGrid.updateColumns(newColumns); + newColumns.forEach(col => { + (context.currGrid as any).columnInit.emit(col); + }); + } + }, + groupBy: { + getFeatureState: (context: IgxGridStateBaseDirective): IGridState => { + const grid = context.currGrid; + const groupingExpressions = grid.groupingExpressions; + groupingExpressions.forEach(expr => { + delete expr.strategy; + }); + const expansionState = grid.groupingExpansionState; + const groupsExpanded = grid.groupsExpanded; + + return { groupBy: { expressions: groupingExpressions, expansion: expansionState, defaultExpanded: groupsExpanded} }; + }, + restoreFeatureState: (context: IgxGridStateBaseDirective, state: IGroupingState): void => { + const grid = context.currGrid; + grid.groupingExpressions = state.expressions as IGroupingExpression[]; + state.expansion.forEach(exp => { + exp.hierarchy.forEach(h => { + const dataType = grid.columns.find(c => c.field === h.fieldName).dataType; + if (dataType.includes(GridColumnDataType.Date) || dataType.includes(GridColumnDataType.Time)) { + h.value = h.value ? new Date(Date.parse(h.value)) : h.value; + } + }); + }); + if (grid.groupsExpanded !== state.defaultExpanded) { + grid.toggleAllGroupRows(); + } + grid.groupingExpansionState = state.expansion as IGroupByExpandState[]; + } + }, + paging: { + getFeatureState: (context: IgxGridStateBaseDirective): IGridState => { + const pagingState = context.currGrid.pagingState; + return { paging: pagingState }; + }, + restoreFeatureState: (context: IgxGridStateBaseDirective, state: IPagingState): void => { + if (!context.currGrid.paginator) { + return; + } + if (context.currGrid.perPage !== state.recordsPerPage) { + context.currGrid.perPage = state.recordsPerPage; + context.currGrid.cdr.detectChanges(); + } + context.currGrid.page = state.index; + } + }, + moving: { + getFeatureState: (context: IgxGridStateBaseDirective): IGridState => { + return { moving: context.currGrid.moving }; + }, + restoreFeatureState: (context: IgxGridStateBaseDirective, state: boolean): void => { + context.currGrid.moving = state; + } + }, + rowSelection: { + getFeatureState: (context: IgxGridStateBaseDirective): IGridState => { + const selection = context.currGrid.selectionService.getSelectedRows(); + return { rowSelection: selection }; + }, + restoreFeatureState: (context: IgxGridStateBaseDirective, state: any[]): void => { + context.currGrid.selectRows(state, true); + } + }, + cellSelection: { + getFeatureState: (context: IgxGridStateBaseDirective): IGridState => { + const selection = context.currGrid.getSelectedRanges().map(range => + ({ rowStart: range.rowStart, rowEnd: range.rowEnd, columnStart: range.columnStart, columnEnd: range.columnEnd })); + return { cellSelection: selection }; + }, + restoreFeatureState: (context: IgxGridStateBaseDirective, state: GridSelectionRange[]): void => { + state.forEach(r => { + const range = { rowStart: r.rowStart, rowEnd: r.rowEnd, columnStart: r.columnStart, columnEnd: r.columnEnd}; + context.currGrid.selectRange(range); + }); + } + }, + columnSelection: { + getFeatureState: (context: IgxGridStateBaseDirective): IGridState => { + const selection = context.currGrid.selectedColumns().map(c => c.field); + return { columnSelection: selection }; + }, + restoreFeatureState: (context: IgxGridStateBaseDirective, state: string[]): void => { + context.currGrid.deselectAllColumns(); + context.currGrid.selectColumns(state); + } + }, + rowPinning: { + getFeatureState: (context: IgxGridStateBaseDirective): IGridState => { + const pinned = context.currGrid.pinnedRows?.map(x => x.key); + return { rowPinning: pinned }; + }, + restoreFeatureState: (context: IgxGridStateBaseDirective, state: any[]): void => { + // clear current state. + context.currGrid.pinnedRows.forEach(row => row.unpin()); + state.forEach(rowID => context.currGrid.pinRow(rowID)); + } + }, + pinningConfig: { + getFeatureState: (context: IgxGridStateBaseDirective): IGridState => ({ pinningConfig: context.currGrid.pinning }), + restoreFeatureState: (context: IgxGridStateBaseDirective, state: IPinningConfig): void => { + context.currGrid.pinning = state; + } + }, + expansion: { + getFeatureState: (context: IgxGridStateBaseDirective): IGridState => { + const expansionStates = Array.from(context.currGrid.expansionStates); + return { expansion: expansionStates }; + }, + restoreFeatureState: (context: IgxGridStateBaseDirective, state: any[]): void => { + const expansionStates = new Map(state); + context.currGrid.expansionStates = expansionStates; + } + }, + rowIslands: { + getFeatureState(context: IgxGridStateBaseDirective): IGridState { + const childGridStates: IGridStateCollection[] = []; + const rowIslands = (context.currGrid as any).allLayoutList; + if (rowIslands) { + rowIslands.forEach(rowIsland => { + const childGrids = rowIsland.rowIslandAPI.getChildGrids(); + childGrids.forEach(chGrid => { + const parentRowID = this.getParentRowID(chGrid); + context.currGrid = chGrid; + if (context.currGrid) { + const childGridState = context.buildState(context.featureKeys) as IGridState; + childGridStates.push({ id: `${rowIsland.id}`, parentRowID, state: childGridState }); + } + }); + }); + } + context.currGrid = context.grid; + return { rowIslands: childGridStates }; + }, + restoreFeatureState(context: IgxGridStateBaseDirective, state: any): void { + const rowIslands = context.currGrid.allLayoutList; + if (rowIslands) { + rowIslands.forEach(rowIsland => { + const childGrids = rowIsland.rowIslandAPI.getChildGrids(); + childGrids.forEach(chGrid => { + const parentRowID = this.getParentRowID(chGrid); + context.currGrid = chGrid; + const childGridState = state.find(st => st.id === rowIsland.id && st.parentRowID === parentRowID); + if (childGridState && context.currGrid) { + context.restoreGridState(childGridState.state, context.featureKeys); + } + }); + }); + } + context.currGrid = context.grid; + }, + /** + * Traverses the hierarchy up to the root grid to return the ID of the expanded row. + */ + getParentRowID: (grid: GridType) => { + let childGrid; + while (grid.parent) { + childGrid = grid; + grid = grid.parent; + } + return grid.gridAPI.getParentRowId(childGrid); + } + }, + pivotConfiguration: { + getFeatureState(context: IgxGridStateBaseDirective): IGridState { + const config = context.currGrid.pivotConfiguration; + if (!config || context.currGrid.type !== 'pivot') { + return { pivotConfiguration: undefined }; + } + const configCopy = cloneValue(config); + configCopy.rows = cloneArray(config.rows, true); + configCopy.columns = cloneArray(config.columns, true); + configCopy.filters = cloneArray(config.filters, true); + const dims = [...(configCopy.rows || []), ...(configCopy.columns || []), ...(configCopy.filters || [])]; + const dateDimensions = dims.filter(x => context.isDateDimension(x)); + dateDimensions?.forEach(dim => { + // do not serialize the grid resource strings. This would pollute the object with unnecessary data. + dim.resourceStrings = {}; + }); + return { pivotConfiguration: configCopy }; + }, + restoreFeatureState(context: IgxGridStateBaseDirective, state: any): void { + const config: IPivotConfiguration = state; + if (!config || context.currGrid.type !== 'pivot') { + return; + } + context.restoreValues(config, context.currGrid as PivotGridType); + context.restoreDimensions(config); + context.currGrid.pivotConfiguration = config; + }, + + + } + }; + + /** + * An object with options determining if a certain feature state should be saved. + * ```html + * + * ``` + * ```typescript + * public options = {selection: false, advancedFiltering: false}; + * ``` + */ + @Input() + public get options(): IGridStateOptions { + return this._options; + } + + public set options(value: IGridStateOptions) { + Object.assign(this._options, value); + if (this.grid.type !== 'flat') { + delete this._options.groupBy; + } else { + delete this._options.rowIslands; + } + } + + /** + * Gets the state of a feature or states of all grid features, unless a certain feature is disabled through the `options` property. + * + * @param `serialize` determines whether the returned object will be serialized to JSON string. Default value is true. + * @param `feature` string or array of strings determining the features to be added in the state. If skipped, all features are added. + * @returns Returns the serialized to JSON string IGridState object, or the non-serialized IGridState object. + * ```html + * + * ``` + * ```typescript + * @ViewChild(IgxGridStateDirective, { static: true }) public state; + * let state = this.state.getState(); // returns string + * let state = this.state(false) // returns `IGridState` object + * ``` + */ + protected getStateInternal(serialize = true, features?: GridFeatures | GridFeatures[]): IGridState | string { + let state: IGridState | string; + this.currGrid = this.grid; + this.state = state = this.buildState(features) as IGridState; + if (serialize) { + state = JSON.stringify(state, this.stringifyCallback) as string; + } + return state; + } + + /* blazorSuppress */ + /** + * Restores grid features' state based on the IGridState object passed as an argument. + * + * @param IGridState object to restore state from. + * @returns + * ```html + * + * ``` + * ```typescript + * @ViewChild(IgxGridStateDirective, { static: true }) public state; + * this.state.setState(gridState); + * ``` + */ + protected setStateInternal(state: IGridState, features?: GridFeatures | GridFeatures[]) { + this.state = state; + this.currGrid = this.grid; + this.restoreGridState(state, features); + this.grid.cdr.detectChanges(); // TODO + } + + /** + * Builds an IGridState object. + */ + private buildState(keys?: GridFeatures | GridFeatures[]): IGridState { + this.applyFeatures(keys); + let gridState = {} as IGridState; + this.featureKeys.forEach(f => { + if (this.options[f]) { + if (this.grid.type !== 'flat' && f === 'groupBy') { + return; + } + const feature = this.getFeature(f); + const featureState: IGridState = feature?.getFeatureState(this); + gridState = Object.assign(gridState, featureState); + } + }); + return gridState; + } + + /** + * The method that calls corresponding methods to restore features from the passed IGridState object. + */ + private restoreGridState(state: IGridState, features?: GridFeatures | GridFeatures[]) { + this.applyFeatures(features); + this.restoreFeatures(state); + } + + private restoreFeatures(state: IGridState) { + this.featureKeys.forEach(f => { + if (this.options[f]) { + const featureState = state[f]; + if (f === 'moving' || featureState) { + const feature = this.getFeature(f); + feature.restoreFeatureState(this, featureState); + } + } + }); + } + + /** + * Returns a collection of all grid features. + */ + private applyFeatures(keys?: GridFeatures | GridFeatures[]) { + this.featureKeys = []; + if (!keys) { + for (const key of Object.keys(this.options)) { + this.featureKeys.push(key as GridFeatures); + } + } else if (Array.isArray(keys)) { + this.featureKeys = [...keys as GridFeatures[]]; + } else { + this.featureKeys.push(keys); + } + } + + /** + * This method restores complex objects in the pivot dimensions + * Like the IgxPivotDateDimension and filters. + */ + private restoreDimensions(config: IPivotConfiguration) { + const collections = [config.rows, config.columns, config.filters]; + for (const collection of collections) { + for (let index = 0; index < collection?.length; index++) { + const dim = collection[index]; + if (this.isDateDimension(dim)) { + this.restoreDateDimension(dim as IgxPivotDateDimension); + } + // restore complex filters + if (dim.filter) { + dim.filter = this.createExpressionsTreeFromObject(dim.filter) as IFilteringExpressionsTree; + } + } + } + } + + + /** + * This method restores the IgxPivotDateDimension with its default functions and resource strings. + */ + private restoreDateDimension(dim: IgxPivotDateDimension) { + const dateDim = new IgxPivotDateDimension((dim as any)._baseDimension, (dim as any)._options); + // restore functions and resource strings + dim.resourceStrings = dateDim.resourceStrings; + dim.memberFunction = dateDim.memberFunction; + let currDim: IPivotDimension = dim; + let originDim: IPivotDimension = dateDim; + while (currDim.childLevel) { + currDim = currDim.childLevel; + originDim = originDim.childLevel; + currDim.memberFunction = originDim.memberFunction; + } + } + + /** + * Returns if this is a IgxPivotDateDimension. + */ + private isDateDimension(dim: IPivotDimension) { + return (dim as any)._baseDimension; + } + + /** + * This method restores complex objects in the pivot values. + * Like the default aggregator methods. + */ + private restoreValues(config: IPivotConfiguration, grid: PivotGridType) { + // restore aggregator func if it matches the default aggregators key and label + const values = config.values; + for (const value of values) { + const aggregateList = value.aggregateList; + const aggregators = PivotUtil.getAggregatorsForValue(value, grid); + value.aggregate.aggregator = aggregators.find(x => x.key === value.aggregate.key && x.label === value.aggregate.label)?.aggregator; + if (aggregateList) { + for (const ag of aggregateList) { + ag.aggregator = aggregators.find(x => x.key === ag.key && x.label === ag.label)?.aggregator; + } + } + } + } + + /** + * This method builds a rehydrated IExpressionTree from a provided object. + */ + private createExpressionsTreeFromObject(exprTreeObject: IExpressionTree): IExpressionTree { + if (!exprTreeObject || !exprTreeObject.filteringOperands) { + return null; + } + + if (this.currGrid.type === 'pivot') { + return recreateTreeFromFields(exprTreeObject, this.currGrid.allDimensions.map(d => ({ dataType: d.dataType, field: d.memberName })) as FieldType[]) as IExpressionTree; + } + + return recreateTreeFromFields(exprTreeObject, this.currGrid.columns) as IExpressionTree; + } + + protected stringifyCallback(key: string, val: any) { + if (key === 'searchVal' && val instanceof Set) { + return Array.from(val); + } + return val; + } + + private getColumnGroupKey(columnGroup: ColumnType) : string { + return columnGroup.childColumns.map(x => x.columnGroup ? x.level + "_" + this.getColumnGroupKey(x) : x.field).sort().join("_"); + } + + private getFeature(key: string): Feature { + const feature: Feature = this.FEATURES[key]; + return feature; + } +} diff --git a/projects/igniteui-angular/grids/core/src/state.directive.spec.ts b/projects/igniteui-angular/grids/core/src/state.directive.spec.ts new file mode 100644 index 00000000000..a999f47eb9a --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/state.directive.spec.ts @@ -0,0 +1,1032 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { Component, TemplateRef, ViewChild } from '@angular/core'; +import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; +import { IgxGridStateDirective } from './state.directive'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IGroupingExpression } from '../../../core/src/data-operations/grouping-expression.interface'; +import { FilteringExpressionsTree, IFilteringExpressionsTree } from '../../../core/src/data-operations/filtering-expressions-tree'; +import { IPagingState } from '../../../core/src/data-operations/paging-state.interface'; +import { IgxBooleanFilteringOperand } from '../../../core/src/data-operations/filtering-condition'; +import { IGroupingState } from '../../../core/src/data-operations/groupby-state.interface'; +import { IGroupByExpandState } from '../../../core/src/data-operations/groupby-expand-state.interface'; +import { GridSelectionMode } from './common/enums'; +import { FilteringLogic } from '../../../core/src/data-operations/filtering-expression.interface'; +import { DefaultSortingStrategy, ISortingExpression, SortingDirection } from '../../../core/src/data-operations/sorting-strategy'; +import { GridSelectionRange } from './common/types'; +import { CustomFilter } from '../../../test-utils/grid-samples.spec'; +import { IgxPaginatorComponent } from 'igniteui-angular/paginator'; +import { IgxColumnComponent, IgxColumnGroupComponent, IgxColumnLayoutComponent, IgxGridDetailTemplateDirective, IgxGridMRLNavigationService } from './public_api'; +import { IColumnState, IGridState } from './state-base.directive'; +import { IgxGridComponent } from 'igniteui-angular/grids/grid'; + +describe('IgxGridState - input properties #grid', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxGridStateComponent, + IgxGridStateWithOptionsComponent, + IgxGridStateWithDetailsComponent + ], + providers: [ + IgxGridMRLNavigationService + ] + }).compileComponents(); + })); + + it('should initialize an IgxGridState with default options object', () => { + const defaultOptions = { + columns: true, + filtering: true, + advancedFiltering: true, + sorting: true, + groupBy: true, + paging: true, + cellSelection: true, + rowSelection: true, + columnSelection: true, + rowPinning: true, + expansion: true, + moving: true + }; + + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + + const state = fix.componentInstance.state; + + expect(state).toBeDefined('IgxGridState directive is initialized'); + expect(state.options).toEqual(jasmine.objectContaining(defaultOptions)); + }); + + it('should initialize an IgxGridState with correct options input', () => { + const optionsInput = { + columns: true, + filtering: false, + advancedFiltering: true, + sorting: false, + paging: true, + cellSelection: true, + rowSelection: true, + columnSelection: true, + rowPinning: true, + expansion: true, + groupBy: false, + moving: false + }; + + const fix = TestBed.createComponent(IgxGridStateWithOptionsComponent); + fix.detectChanges(); + + const state = fix.componentInstance.state; + expect(state.options).toEqual(jasmine.objectContaining(optionsInput)); + }); + + it('getState should return correct JSON string', () => { + const initialGridState = '{"columns":[{"pinned":true,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"testCss","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"number","hasSummary":false,"field":"ProductID","width":"150px","header":"Product ID","resizable":true,"searchable":false,"key":"ProductID","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"string","hasSummary":false,"field":"ProductName","width":"150px","header":"Product Name","resizable":true,"searchable":true,"selectable":false,"key":"ProductName","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":false,"filterable":true,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"boolean","hasSummary":true,"field":"InStock","width":"140px","header":"In Stock","resizable":true,"searchable":true,"key":"InStock","columnGroup":false,"disableHiding":false,"disablePinning":true},{"pinned":false,"sortable":true,"filterable":false,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"date","hasSummary":false,"field":"OrderDate","width":"110px","header":"Date ordered","resizable":false,"searchable":true,"key":"OrderDate","columnGroup":false,"disableHiding":false,"disablePinning":false}],"filtering":{"filteringOperands":[],"operator":0},"advancedFiltering":{},"sorting":[],"groupBy":{"expressions":[],"expansion":[],"defaultExpanded":true},"paging":{"index":0,"recordsPerPage":15,"metadata":{"countPages":1,"countRecords":10,"error":0}},"cellSelection":[],"rowSelection":[],"columnSelection":[],"rowPinning":[],"expansion":[],"moving":true}'; + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + + const state = fix.componentInstance.state; + + const gridState = state.getState(); + expect(gridState).toBe(initialGridState, 'JSON string representation of the initial grid state is not correct'); + }); + + it('getState should return correct IGridState object when using default options', () => { + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + const state = fix.componentInstance.state; + + const gridFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + const productFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And, 'ProductName'); + const productExpression = { + condition: IgxBooleanFilteringOperand.instance().condition('true'), + conditionName: 'true', + fieldName: 'InStock', + ignoreCase: true + }; + productFilteringExpressionsTree.filteringOperands.push(productExpression); + gridFilteringExpressionsTree.filteringOperands.push(productFilteringExpressionsTree); + + const groupingExpressions = [ + { dir: SortingDirection.Asc, fieldName: 'ProductID', ignoreCase: false, + strategy: DefaultSortingStrategy.instance() }, + { dir: SortingDirection.Asc, fieldName: 'OrderDate', ignoreCase: false, + strategy: DefaultSortingStrategy.instance() } + ]; + + grid.filteringExpressionsTree = gridFilteringExpressionsTree; + grid.groupingExpressions = groupingExpressions; + fix.detectChanges(); + + const columns = fix.componentInstance.columns; + const paging = grid.pagingState; + const moving = grid.moving; + const sorting = grid.sortingExpressions; + const groupBy = grid.groupingExpressions; + const groupByExpansion = grid.groupingExpansionState; + const filtering = grid.filteringExpressionsTree; + + const gridState = state.getState(false) as IGridState; + HelperFunctions.verifyColumns(columns, gridState); + HelperFunctions.verifyPaging(paging, gridState); + HelperFunctions.verifySortingExpressions(sorting, gridState); + HelperFunctions.verifyGroupingExpressions(groupBy, gridState); + HelperFunctions.verifyGroupingExpansion(groupByExpansion, gridState.groupBy); + HelperFunctions.verifyFilteringExpressions(filtering, gridState); + HelperFunctions.verifyMoving(moving, gridState); + }); + + it('getState should return correct IGridState object when options are not default', () => { + const fix = TestBed.createComponent(IgxGridStateWithOptionsComponent); + fix.detectChanges(); + const state = fix.componentInstance.state; + + let gridState = state.getState(false) as IGridState; + expect(gridState['sorting']).toBeFalsy(); + expect(gridState['groupBy']).toBeFalsy(); + expect(gridState['moving']).toBeFalsy(); + + gridState = state.getState(false, ['filtering', 'sorting', 'groupBy', 'moving']) as IGridState; + expect(gridState['sorting']).toBeFalsy(); + expect(gridState['groupBy']).toBeFalsy(); + expect(gridState['groupBy']).toBeFalsy(); + }); + + it('getState should return correct filtering state', () => { + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + const state = fix.componentInstance.state; + const filtering = grid.filteringExpressionsTree; + + let gridState = state.getState(true, 'filtering'); + expect(gridState).toBe('{"filtering":{"filteringOperands":[],"operator":0}}', 'JSON string'); + + gridState = state.getState(false, ['filtering']) as IGridState; + HelperFunctions.verifyFilteringExpressions(filtering, gridState); + }); + + it('setState should correctly restore grid filtering state from string', () => { + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + const state = fix.componentInstance.state; + const filteringState = '{"filtering":{"filteringOperands":[{"filteringOperands":[{"condition":{"name":"true","isUnary":true,"iconName":"filter_true"},"fieldName":"InStock","ignoreCase":true,"conditionName":"true"}],"operator":0,"fieldName":"InStock"}],"operator":0,"type":0}}'; + const initialState = '{"filtering":{"filteringOperands":[],"operator":0}}'; + + + let gridState = state.getState(true, 'filtering'); + expect(gridState).toBe(initialState); + + state.setState(filteringState); + gridState = state.getState(false, 'filtering') as IGridState; + HelperFunctions.verifyFilteringExpressions(grid.filteringExpressionsTree, gridState); + gridState = state.getState(true, 'filtering'); + expect(gridState).toBe(filteringState); + }); + + it('setState should correctly restore grid columns state from string containing a dateTime column', () => { + const fix = TestBed.createComponent(IgxGridStateComponent); + const lastDateCol = { field: 'LastDate', header: 'Last date', width: '110px', dataType: 'dateTime', pinned: false, sortable: true, filterable: false, groupable: true, hasSummary: false, + hidden: false, maxWidth: '300px', searchable: true, sortingIgnoreCase: true, filteringIgnoreCase: true, editable: true, headerClasses: '', headerGroupClasses: '', resizable: false }; + fix.componentInstance.columns.push(lastDateCol); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + grid.getCellByColumn(0, 'LastDate').value = new Date('2021-06-05T23:59'); + fix.detectChanges(); + const state = fix.componentInstance.state; + + const filteringState = '{"filtering":{"filteringOperands":[{"filteringOperands":[{"condition":{"name":"equals","isUnary":false,"iconName":"filter_equal"},"fieldName":"LastDate","ignoreCase":true,"conditionName":"equals","searchVal":"2021-06-05T20:59:00.000Z"}],"operator":1,"fieldName":"LastDate"}],"operator":0,"type":0}}'; + const initialState = '{"filtering":{"filteringOperands":[],"operator":0}}'; + + let gridState = state.getState(true, 'filtering'); + expect(gridState).toBe(initialState); + + state.setState(filteringState); + gridState = state.getState(false, 'filtering') as IGridState; + HelperFunctions.verifyFilteringExpressions(grid.filteringExpressionsTree, gridState); + gridState = state.getState(true, 'filtering'); + expect(gridState).toBe(filteringState); + }); + + it('setState should correctly restore grid filtering state from with null date values', () => { + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + grid.getCellByColumn(0, 'OrderDate').value = null; + fix.detectChanges(); + const state = fix.componentInstance.state; + + const filteringState = '{"filtering":{"filteringOperands":[{"filteringOperands":[{"condition":{"name":"empty","isUnary":true,"iconName":"filter_empty"},"fieldName":"OrderDate","ignoreCase":true,"searchVal":null,"conditionName":"empty"}],"operator":1,"fieldName":"OrderDate"}],"operator":0,"type":0}}'; + const initialState = '{"filtering":{"filteringOperands":[],"operator":0}}'; + + let gridState = state.getState(true, 'filtering'); + expect(gridState).toBe(initialState); + + state.setState(filteringState); + gridState = state.getState(false, 'filtering') as IGridState; + HelperFunctions.verifyFilteringExpressions(grid.filteringExpressionsTree, gridState); + gridState = state.getState(true, 'filtering'); + expect(gridState).toBe(filteringState); + }); + + it('setState should correctly restore grid filtering state from object', () => { + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + const state = fix.componentInstance.state; + const filteringState = '{"filtering":{"filteringOperands":[{"filteringOperands":[{"condition":{"name":"true","isUnary":true,"iconName":"filter_true"},"fieldName":"InStock","ignoreCase":true,"conditionName":"true"}],"operator":0,"fieldName":"InStock"}],"operator":0,"type":0}}'; + const filteringStateObject = JSON.parse(filteringState) as IGridState; + const initialState = '{"filtering":{"filteringOperands":[],"operator":0}}'; + + let gridState = state.getState(true, 'filtering'); + expect(gridState).toBe(initialState); + + state.setState(filteringStateObject); + gridState = state.getState(false, 'filtering'); + HelperFunctions.verifyFilteringExpressions(grid.filteringExpressionsTree, gridState as IGridState); + gridState = state.getState(true, 'filtering'); + expect(gridState).toBe(filteringState); + }); + + it("setState should correctly restore grid filtering state for a condition with custom filtering operand", () => { + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + grid.getColumnByName("ProductID").filters = CustomFilter.instance(); + fix.detectChanges(); + + const state = fix.componentInstance.state; + + const initialState = + '{"filtering":{"filteringOperands":[],"operator":0}}'; + const filteringState = + '{"filtering":{"filteringOperands":[{"filteringOperands":[{"fieldName":"ProductID","condition":{"name":"custom","isUnary":false,"iconName":"custom"},"searchVal":"custom","ignoreCase":true,"conditionName":"custom"}],"operator":1,"fieldName":"FirstName"}],"operator":0,"type":0}}'; + const filteringStateObject = JSON.parse(filteringState) as IGridState; + + let gridState = state.getState(true, "filtering"); + expect(gridState).toBe(initialState); + + state.setState(filteringStateObject); + fix.detectChanges(); + + gridState = state.getState(false, "filtering"); + HelperFunctions.verifyFilteringExpressions( + grid.filteringExpressionsTree, + gridState as IGridState + ); + gridState = state.getState(true, "filtering"); + expect(gridState).toBe(filteringState); + }); + + it('setState should correctly restore grid sorting state from string', () => { + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + const state = fix.componentInstance.state; + const sortingState = '{"sorting":[{"fieldName":"OrderDate","dir":1,"ignoreCase":true}]}'; + const initialState = '{"sorting":[]}'; + + let gridState = state.getState(true, 'sorting'); + expect(gridState).toBe(initialState); + + state.setState(sortingState); + gridState = state.getState(false, 'sorting') as IGridState; + HelperFunctions.verifySortingExpressions(grid.sortingExpressions, gridState); + gridState = state.getState(true, 'sorting'); + expect(gridState).toBe(sortingState); + }); + + it('setState should correctly restore grid sorting state from object', () => { + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + const state = fix.componentInstance.state; + const sortingState = '{"sorting":[{"fieldName":"OrderDate","dir":1,"ignoreCase":true}]}'; + const sortingStateObject = JSON.parse(sortingState) as IGridState; + const initialState = '{"sorting":[]}'; + + let gridState = state.getState(true, 'sorting'); + expect(gridState).toBe(initialState); + + state.setState(sortingStateObject); + gridState = state.getState(false, 'sorting'); + HelperFunctions.verifySortingExpressions(grid.sortingExpressions, gridState as IGridState); + gridState = state.getState(true, 'sorting'); + expect(gridState).toBe(sortingState); + }); + + it('setState should correctly restore grid groupBy state from string', () => { + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + const state = fix.componentInstance.state; + const groupingState = '{"groupBy":{"expressions":[{"dir":1,"fieldName":"ProductID","ignoreCase":false},{"dir":1,"fieldName":"OrderDate","ignoreCase":false}],"expansion":[],"defaultExpanded":true}}'; + const initialState = '{"groupBy":{"expressions":[],"expansion":[],"defaultExpanded":true}}'; + + let gridState = state.getState(true, 'groupBy'); + expect(gridState).toBe(initialState); + + state.setState(groupingState); + gridState = state.getState(false, 'groupBy') as IGridState; + HelperFunctions.verifyGroupingExpressions(grid.groupingExpressions, gridState); + gridState = state.getState(true, 'groupBy'); + expect(gridState).toBe(groupingState); + }); + + it('setState should correctly restore grid groupBy state from object', () => { + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + const state = fix.componentInstance.state; + const groupingState = '{"groupBy":{"expressions":[{"dir":1,"fieldName":"ProductID","ignoreCase":false},{"dir":1,"fieldName":"OrderDate","ignoreCase":false}],"expansion":[],"defaultExpanded":true}}'; + const initialState = '{"groupBy":{"expressions":[],"expansion":[],"defaultExpanded":true}}'; + const groupingStateObject = JSON.parse(groupingState) as IGridState; + + let gridState = state.getState(true, 'groupBy'); + expect(gridState).toBe(initialState); + + state.setState(groupingStateObject); + gridState = state.getState(false, 'groupBy'); + HelperFunctions.verifyGroupingExpressions(grid.groupingExpressions, gridState as IGridState); + gridState = state.getState(true, 'groupBy'); + expect(gridState).toBe(groupingState); + }); + + it('setState should correctly restore grid columns state from string', () => { + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + const state = fix.componentInstance.state; + const columnsState = '{"columns":[{"pinned":true,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"testCss","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"number","hasSummary":false,"field":"ProductID","width":"150px","header":"Product ID","resizable":true,"searchable":false,"selectable":true,"key":"ProductID","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"string","hasSummary":false,"field":"ProductName","width":"150px","header":"Product Name","resizable":true,"searchable":true,"selectable":false,"key":"ProductName","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":false,"filterable":true,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"boolean","hasSummary":true,"field":"InStock","width":"140px","header":"In Stock","resizable":true,"searchable":true,"selectable":true,"key":"InStock","columnGroup":false,"disableHiding":false,"disablePinning":true},{"pinned":false,"sortable":true,"filterable":false,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"date","hasSummary":false,"field":"OrderDate","width":"110px","header":"Date ordered","resizable":false,"searchable":true,"selectable":true,"key":"OrderDate","columnGroup":false,"disableHiding":false,"disablePinning":false}]}'; + const initialState = '{"columns":[{"pinned":true,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"testCss","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"number","hasSummary":false,"field":"ProductID","width":"150px","header":"Product ID","resizable":true,"searchable":false,"key":"ProductID","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"string","hasSummary":false,"field":"ProductName","width":"150px","header":"Product Name","resizable":true,"searchable":true,"selectable":false,"key":"ProductName","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":false,"filterable":true,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"boolean","hasSummary":true,"field":"InStock","width":"140px","header":"In Stock","resizable":true,"searchable":true,"key":"InStock","columnGroup":false,"disableHiding":false,"disablePinning":true},{"pinned":false,"sortable":true,"filterable":false,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"date","hasSummary":false,"field":"OrderDate","width":"110px","header":"Date ordered","resizable":false,"searchable":true,"key":"OrderDate","columnGroup":false,"disableHiding":false,"disablePinning":false}]}'; + const columns = JSON.parse(columnsState).columns; + + let gridState = state.getState(true, 'columns'); + expect(gridState).toBe(initialState); + + state.setState(columnsState); + gridState = state.getState(false, 'columns') as IGridState; + HelperFunctions.verifyColumns(columns, gridState); + gridState = state.getState(true, 'columns'); + expect(gridState).toBe(columnsState); + }); + + it('setState should correctly restore grid columns state from object', () => { + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + const state = fix.componentInstance.state; + const grid = fix.componentInstance.grid; + spyOn(grid.columnInit, 'emit').and.callThrough(); + const columnsState = '{"columns":[{"pinned":true,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"testCss","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"number","hasSummary":false,"field":"ProductID","width":"150px","header":"Product ID","resizable":true,"searchable":false,"selectable":false,"key":"ProductID","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":true,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"string","hasSummary":false,"field":"ProductName","width":"200px","header":"Product Name","resizable":true,"searchable":true,"selectable":true,"key":"ProductName","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":false,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"boolean","hasSummary":true,"field":"InStock","width":"140px","header":"In Stock","resizable":true,"searchable":true,"selectable":true,"key":"InStock","columnGroup":false,"disableHiding":false,"disablePinning":true},{"pinned":false,"sortable":true,"filterable":false,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"date","hasSummary":false,"field":"OrderDate","width":"110px","header":"Date ordered","resizable":false,"searchable":true,"selectable":true,"key":"OrderDate","columnGroup":false,"disableHiding":false,"disablePinning":false}]}'; + const initialState = '{"columns":[{"pinned":true,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"testCss","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"number","hasSummary":false,"field":"ProductID","width":"150px","header":"Product ID","resizable":true,"searchable":false,"key":"ProductID","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"string","hasSummary":false,"field":"ProductName","width":"150px","header":"Product Name","resizable":true,"searchable":true,"selectable":false,"key":"ProductName","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":false,"filterable":true,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"boolean","hasSummary":true,"field":"InStock","width":"140px","header":"In Stock","resizable":true,"searchable":true,"key":"InStock","columnGroup":false,"disableHiding":false,"disablePinning":true},{"pinned":false,"sortable":true,"filterable":false,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"date","hasSummary":false,"field":"OrderDate","width":"110px","header":"Date ordered","resizable":false,"searchable":true,"key":"OrderDate","columnGroup":false,"disableHiding":false,"disablePinning":false}]}'; + const columnsStateObject = JSON.parse(columnsState); + + let gridState = state.getState(true, 'columns'); + expect(gridState).toBe(initialState); + + state.setState(columnsStateObject); + gridState = state.getState(false, 'columns') as IGridState; + HelperFunctions.verifyColumns(columnsStateObject.columns, gridState); + gridState = state.getState(true, 'columns'); + expect(gridState).toBe(columnsState); + expect(grid.columnInit.emit).toHaveBeenCalledTimes(columnsStateObject.columns.length); + }); + + it('setState should correctly restore grid columns state properties: collapsible and expanded', () => { + const fix = TestBed.createComponent(CollapsibleColumnGroupTestComponent); + fix.detectChanges(); + const state = fix.componentInstance.state; + const grid = fix.componentInstance.grid; + const initialState = '{"columns":[{"pinned":false,"sortable":false,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","groupable":false,"hidden":false,"dataType":"string","hasSummary":false,"field":"ID","width":"100px","header":"","resizable":false,"searchable":true,"selectable":true,"key":"ID","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":false,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","groupable":false,"hidden":false,"dataType":"string","hasSummary":false,"width":"100px","header":"Address Information","resizable":false,"searchable":true,"selectable":true,"key":"Address_City","columnGroup":true,"disableHiding":false,"disablePinning":false,"collapsible":true,"expanded":true},{"pinned":false,"sortable":false,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","groupable":false,"hidden":true,"dataType":"string","hasSummary":false,"field":"City","width":"100px","header":"","resizable":false,"searchable":true,"selectable":true,"key":"City","parentKey":"Address_City","columnGroup":false,"disableHiding":false,"disablePinning":false,"visibleWhenCollapsed":true},{"pinned":false,"sortable":false,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","groupable":false,"hidden":false,"dataType":"string","hasSummary":false,"field":"Address","width":"100px","header":"","resizable":false,"searchable":true,"selectable":true,"key":"Address","parentKey":"Address_City","columnGroup":false,"disableHiding":false,"disablePinning":false,"visibleWhenCollapsed":false}]}'; + const newState = '{"columns":[{"pinned":false,"sortable":false,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","groupable":false,"hidden":false,"dataType":"string","hasSummary":false,"field":"ID","width":"100px","header":"","resizable":false,"searchable":true,"selectable":true,"key":"ID","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":false,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","groupable":false,"hidden":false,"dataType":"string","hasSummary":false,"width":"100px","header":"Address Information","resizable":false,"searchable":true,"selectable":true,"key":"Address_City","columnGroup":true,"disableHiding":false,"disablePinning":false,"collapsible":true,"expanded":false},{"pinned":false,"sortable":false,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","groupable":false,"hidden":true,"dataType":"string","hasSummary":false,"field":"City","width":"100px","header":"","resizable":false,"searchable":true,"selectable":true,"key":"City","parentKey":"Address_City","columnGroup":false,"disableHiding":false,"disablePinning":false,"visibleWhenCollapsed":true},{"pinned":false,"sortable":false,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","groupable":false,"hidden":false,"dataType":"string","hasSummary":false,"field":"Address","width":"100px","header":"","resizable":false,"searchable":true,"selectable":true,"key":"Address","parentKey":"Address_City","columnGroup":false,"disableHiding":false,"disablePinning":false,"visibleWhenCollapsed":false}]}'; + const columnsStateObject = JSON.parse(newState); + let gridState = state.getState(true, 'columns'); + expect(gridState).toBe(initialState); + // 1. initial state collapsible:true, expanded: true; + // 2. new state collapsible:true, expanded: false after restoration + + state.setState(columnsStateObject); // set new state - restored state + gridState = state.getState(false, 'columns') as IGridState; + HelperFunctions.verifyColumns(columnsStateObject.columns, gridState); + gridState = state.getState(true, 'columns'); + fix.detectChanges(); + expect(gridState).toBe(newState); + + const addressInfoGroup = grid.columns.find(c => c.header === "Address Information"); + expect(addressInfoGroup.collapsible).toBe(true); + expect(addressInfoGroup.expanded).toBe(false); + }); + + it('setState should correctly restore grid columns with Column Groups and same headers', () => { + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + const state = fix.componentInstance.state; + const initialState = '{"columns":[{"pinned":true,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"testCss","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"number","hasSummary":false,"field":"ProductID","width":"150px","header":"Product ID","resizable":true,"searchable":false,"key":"ProductID","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"string","hasSummary":false,"field":"ProductName","width":"150px","header":"Product Name","resizable":true,"searchable":true,"selectable":false,"key":"ProductName","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":false,"filterable":true,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"boolean","hasSummary":true,"field":"InStock","width":"140px","header":"In Stock","resizable":true,"searchable":true,"key":"InStock","columnGroup":false,"disableHiding":false,"disablePinning":true},{"pinned":false,"sortable":true,"filterable":false,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"date","hasSummary":false,"field":"OrderDate","width":"110px","header":"Date ordered","resizable":false,"searchable":true,"key":"OrderDate","columnGroup":false,"disableHiding":false,"disablePinning":false}]}'; + const columnsState = '{"columns":[{"pinned":false,"sortable":false,"filterable":false,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"testCss","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"string","hasSummary":false,"field":"ProductID","width":"150px","header":"General Information","resizable":true,"searchable":true,"key":"ProductID","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":false,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"string","hasSummary":false,"field":"","width":"398px","header":"General Information","resizable":false,"searchable":true,"selectable":true,"key":"ProductName_UnitsInStock","columnGroup":true,"disableHiding":false,"disablePinning":false,"collapsible":false,"expanded":true},{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"boolean","hasSummary":false,"field":"ProductName","width":"199px","header":"","resizable":true,"searchable":true,"selectable":true,"key":"ProductName","parentKey":"ProductName_UnitsInStock","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","groupable":false,"hidden":false,"dataType":"string","hasSummary":false,"field":"UnitsInStock","width":"199px","header":"","resizable":true,"searchable":true,"selectable":true,"key":"UnitsInStock","parentKey":"ProductName_UnitsInStock","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"string","hasSummary":false,"field":"InStock","width":"199px","header":"","resizable":true,"searchable":true,"selectable":true,"key":"InStock","columnGroup":false,"disableHiding":false,"disablePinning":true}]}'; + const columnsStateObject = JSON.parse(columnsState); + let gridState = state.getState(true, 'columns'); + + expect(gridState).toBe(initialState); + expect(() => { + state.setState(columnsStateObject); + }).not.toThrow(); + + gridState = state.getState(false, 'columns') as IGridState; + HelperFunctions.verifyColumns(columnsStateObject.columns, gridState); + }); + + it('setState should reuse columns with matching keys and create new ones for the rest.', () => { + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + const state = fix.componentInstance.state; + const grid = fix.componentInstance.grid; + const originalColumns = [...grid.columns]; + originalColumns.forEach(x => x.bodyTemplate = fix.componentInstance.template); + expect(originalColumns.length).toEqual(fix.componentInstance.columns.length); + + const gridState = state.getState(false, 'columns') as IGridState; + + gridState.columns.push({ + field: "AnotherColumn", + columnGroup: false, + dataType: 'boolean', + editable: true, + header: "CustomHeader", + key: "AnotherColumn", + disableHiding: false, + disablePinning: false, + filterable: true, + sortable: true, + groupable: true, + width: "100px", + hidden: false, + hasSummary: false, + filteringIgnoreCase: true, + expanded: false, + collapsible: false, + sortingIgnoreCase: true, + searchable: true, + resizable: true, + visibleWhenCollapsed: undefined, + pinned: false, + parentKey: undefined, + maxWidth: undefined, + headerClasses: undefined, + headerGroupClasses: undefined + }); + + state.setState(gridState); + fix.detectChanges(); + + expect(grid.columns.length).toEqual(gridState.columns.length); + + // all original columns are the same object refs and retain their template + originalColumns.forEach(x => { + expect(grid.columns.indexOf(x)).not.toBe(-1); + expect(x.bodyTemplate).toBe(fix.componentInstance.template); + }); + expect(grid.columns[grid.columns.length - 1 ].field).toBe("AnotherColumn"); + }); + + it('setState should correctly restore grid paging state from string', () => { + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + const state = fix.componentInstance.state; + const pagingState = '{"paging":{"index":0,"recordsPerPage":5,"metadata":{"countPages":2,"countRecords":10,"error":0}}}'; + const initialState = '{"paging":{"index":0,"recordsPerPage":15,"metadata":{"countPages":1,"countRecords":10,"error":0}}}'; + + let gridState = state.getState(true, 'paging'); + expect(gridState).toBe(initialState); + + state.setState(pagingState); + gridState = state.getState(false, 'paging'); + HelperFunctions.verifyPaging(grid.pagingState, gridState as IGridState); + gridState = state.getState(true, 'paging'); + expect(gridState).toBe(pagingState); + }); + + it('setState should correctly restore grid paging state from object', () => { + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + const state = fix.componentInstance.state; + const pagingState = '{"paging":{"index":0,"recordsPerPage":5,"metadata":{"countPages":2,"countRecords":10,"error":0}}}'; + const pagingStateObject = JSON.parse(pagingState) as IGridState; + const initialState = '{"paging":{"index":0,"recordsPerPage":15,"metadata":{"countPages":1,"countRecords":10,"error":0}}}'; + + let gridState = state.getState(true, 'paging'); + expect(gridState).toBe(initialState); + + state.setState(pagingStateObject); + gridState = state.getState(false, 'paging'); + HelperFunctions.verifyPaging(grid.pagingState, gridState as IGridState); + gridState = state.getState(true, 'paging'); + expect(gridState).toBe(pagingState); + }); + + it('setState should correctly restore grid row selection state from string', () => { + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + const state = fix.componentInstance.state; + const rowSelectionState = '{"rowSelection":[1,3,5,6]}'; + const initialState = '{"rowSelection":[]}'; + + let gridState = state.getState(true, 'rowSelection'); + expect(gridState).toBe(initialState); + state.setState('{"rowSelection":[2]}'); + state.setState(rowSelectionState); + gridState = state.getState(false, 'rowSelection'); + HelperFunctions.verifyRowSelection(grid.selectedRows, gridState as IGridState); + gridState = state.getState(true, 'rowSelection'); + expect(gridState).toBe(rowSelectionState); + }); + + it('setState should correctly restore grid row selection state from object', () => { + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + const state = fix.componentInstance.state; + const rowSelectionState = '{"rowSelection":[1,3,5,6]}'; + const initialState = '{"rowSelection":[]}'; + const rowSelectionStateObject = JSON.parse(rowSelectionState); + + let gridState = state.getState(true, 'rowSelection'); + expect(gridState).toBe(initialState); + + state.setState(rowSelectionStateObject); + gridState = state.getState(false, 'rowSelection'); + HelperFunctions.verifyRowSelection(grid.selectedRows, gridState as IGridState); + gridState = state.getState(true, 'rowSelection'); + expect(gridState).toBe(rowSelectionState); + }); + + it('setState should correctly restore grid row pinning state from object', () => { + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + grid.primaryKey = 'ProductID'; + fix.detectChanges(); + const state = fix.componentInstance.state; + const rowPinState = '{"rowPinning":[1,3]}'; + const initialState = '{"rowPinning":[]}'; + const rowPinStateObject = JSON.parse(rowPinState); + + let gridState = state.getState(true, 'rowPinning'); + expect(gridState).toBe(initialState); + + state.setState(rowPinStateObject); + fix.detectChanges(); + + expect(grid.pinnedRows.length).toBe(2); + expect(grid.pinnedRows[0].key).toBe(1); + expect(grid.pinnedRows[1].key).toBe(3); + gridState = state.getState(true, 'rowPinning'); + expect(gridState).toBe(rowPinState); + + grid.getRowByIndex(3).pin(); + fix.detectChanges(); + + state.setState(rowPinStateObject); + fix.detectChanges(); + + expect(grid.pinnedRows.length).toBe(2); + expect(grid.pinnedRows[0].key).toBe(1); + expect(grid.pinnedRows[1].key).toBe(3); + }); + + it('setState should correctly restore grid moving state from string', () => { + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + const state = fix.componentInstance.state; + + const movingState = '{"moving":false}'; + const initialState = '{"moving":true}'; + + let gridState = state.getState(true, 'moving'); + expect(gridState).toBe(initialState); + + state.setState(movingState); + expect(grid.moving).toBeFalsy(); + gridState = state.getState(true, 'moving'); + expect(gridState).toBe(movingState); + + }); + + it('setState should correctly restore grid moving state from object', () => { + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + const state = fix.componentInstance.state; + const movingState = '{"moving":false}'; + const initialState = '{"moving":true}'; + const movingStateObject = JSON.parse(movingState); + + let gridState = state.getState(true, 'moving'); + expect(gridState).toBe(initialState); + + state.setState(movingStateObject); + fix.detectChanges(); + + expect(grid.moving).toBeFalsy(); + gridState = state.getState(true, 'moving'); + expect(gridState).toBe(movingState); + + grid.moving = true; + fix.detectChanges(); + + state.setState(movingStateObject); + fix.detectChanges(); + + expect(grid.moving).toBeFalsy(); + gridState = state.getState(true, 'moving'); + expect(gridState).toBe(movingState); + }); + + it('setState should correctly restore grid cell selection state from string', () => { + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + grid.rowSelection = GridSelectionMode.none; + const state = fix.componentInstance.state; + const cellSelectionState = '{"cellSelection":[{"rowStart":0,"rowEnd":2,"columnStart":1,"columnEnd":3}]}'; + const initialState = '{"cellSelection":[]}'; + + let gridState = state.getState(true, 'cellSelection'); + expect(gridState).toBe(initialState); + + state.setState(cellSelectionState); + gridState = state.getState(false, 'cellSelection'); + HelperFunctions.verifyCellSelection(grid.getSelectedRanges(), gridState as IGridState); + gridState = state.getState(true, 'cellSelection'); + expect(gridState).toBe(cellSelectionState); + }); + + it('setState should correctly restore grid cell selection state from object', () => { + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + grid.rowSelection = GridSelectionMode.none; + const state = fix.componentInstance.state; + const cellSelectionState = '{"cellSelection":[{"rowStart":0,"rowEnd":2,"columnStart":1,"columnEnd":3}]}'; + const initialState = '{"cellSelection":[]}'; + const cellSelectionStateObject = JSON.parse(cellSelectionState); + + let gridState = state.getState(true, 'cellSelection'); + expect(gridState).toBe(initialState); + + state.setState(cellSelectionStateObject); + gridState = state.getState(false, 'cellSelection'); + HelperFunctions.verifyCellSelection(grid.getSelectedRanges(), gridState as IGridState); + gridState = state.getState(true, 'cellSelection'); + expect(gridState).toBe(cellSelectionState); + }); + + it('setState should correctly restore grid advanced filtering state from string', () => { + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + const state = fix.componentInstance.state; + const advFilteringState = '{"advancedFiltering":{"filteringOperands":[{"fieldName":"InStock","condition":{"name":"true","isUnary":true,"iconName":"filter_true"},"searchVal":null,"ignoreCase":true,"conditionName":"true"},{"fieldName":"ProductID","condition":{"name":"greaterThan","isUnary":false,"iconName":"filter_greater_than"},"searchVal":"3","ignoreCase":true,"conditionName":"greaterThan"}],"operator":0,"type":1}}'; + const initialState = '{"advancedFiltering":{}}'; + + let gridState = state.getState(true, 'advancedFiltering'); + expect(gridState).toBe(initialState); + + state.setState(advFilteringState); + gridState = state.getState(false, 'advancedFiltering') as IGridState; + HelperFunctions.verifyAdvancedFilteringExpressions(grid.advancedFilteringExpressionsTree, gridState); + gridState = state.getState(true, 'advancedFiltering'); + expect(gridState).toBe(advFilteringState); + }); + + it('setState should correctly restore grid advanced filtering state from object', () => { + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + const state = fix.componentInstance.state; + const advFilteringState = '{"advancedFiltering":{"filteringOperands":[{"fieldName":"InStock","condition":{"name":"true","isUnary":true,"iconName":"filter_true"},"searchVal":null,"ignoreCase":true,"conditionName":"true"},{"fieldName":"ProductID","condition":{"name":"greaterThan","isUnary":false,"iconName":"filter_greater_than"},"searchVal":"3","ignoreCase":true,"conditionName":"greaterThan"}],"operator":0,"type":1}}'; + const initialState = '{"advancedFiltering":{}}'; + const advFilteringStateObject = JSON.parse(advFilteringState); + + let gridState = state.getState(true, 'advancedFiltering'); + expect(gridState).toBe(initialState); + + state.setState(advFilteringStateObject); + gridState = state.getState(false, 'advancedFiltering') as IGridState; + HelperFunctions.verifyAdvancedFilteringExpressions(grid.advancedFilteringExpressionsTree, gridState); + gridState = state.getState(true, 'advancedFiltering'); + expect(gridState).toBe(advFilteringState); + }); + + it('setState should correctly restore grid advanced filtering state when there is a custom filtering operand', () => { + const fix = TestBed.createComponent(IgxGridStateComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + grid.getColumnByName("ProductID").filters = CustomFilter.instance(); + fix.detectChanges(); + + const state = fix.componentInstance.state; + const advFilteringState = '{"advancedFiltering":{"filteringOperands":[{"fieldName":"ProductID","condition":{"name":"custom","isUnary":false,"iconName":"custom"},"ignoreCase":true,"searchVal":"custom","conditionName":"custom"}],"operator":0,"type":1}}'; + const initialState = '{"advancedFiltering":{}}'; + + let gridState = state.getState(true, 'advancedFiltering'); + expect(gridState).toBe(initialState); + + state.setState(advFilteringState); + gridState = state.getState(false, 'advancedFiltering') as IGridState; + HelperFunctions.verifyAdvancedFilteringExpressions(grid.advancedFilteringExpressionsTree, gridState); + gridState = state.getState(true, 'advancedFiltering'); + expect(gridState).toBe(advFilteringState); + }); + + it('should correctly restore expansion state from string', () => { + const fix = TestBed.createComponent(IgxGridStateWithDetailsComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + const state = fix.componentInstance.state; + + const expansionState = '{"expansion":[[1,true],[2,true],[3,true]]}'; + const initialState = '{"expansion":[]}'; + + let gridState = state.getState(true, 'expansion'); + expect(gridState).toBe(initialState); + + state.setState(expansionState); + fix.detectChanges(); + gridState = state.getState(false, 'expansion'); + HelperFunctions.verifyExpansionStates(grid.expansionStates, gridState as IGridState); + gridState = state.getState(true, 'expansion'); + expect(gridState).toBe(expansionState); + }); + + it('should correctly restore mrl column states.', () => { + const fix = TestBed.createComponent(IgxGridMRLStateComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + const state = fix.componentInstance.state; + + const gridColumnState = state.getState(false, 'columns') as IGridState; + const group1 = gridColumnState.columns.find(x => x.field === 'group1'); + expect(group1.columnLayout).toBeTrue(); + + const prodId = gridColumnState.columns.find(x => x.field === 'ProductID'); + expect(prodId.columnLayout).toBeFalsy(); + expect(prodId.rowStart).toBe(1); + expect(prodId.rowEnd).toBe(4); + expect(prodId.colStart).toBe(1); + expect(prodId.colEnd).toBe(1); + + // apply change + group1.pinned = true; + prodId.pinned = true; + + state.setState(gridColumnState, 'columns'); + fix.detectChanges(); + + const group1Column = grid.getColumnByName("group1"); + const prodIdColumn = grid.getColumnByName("ProductID"); + expect(group1Column.columnLayout).toBeTrue(); + expect(group1Column.pinned).toBeTrue(); + expect(prodIdColumn.pinned).toBeTrue(); + expect(prodIdColumn.columnLayoutChild).toBeTrue(); + expect(prodIdColumn.parent).toBe(group1Column); + expect(prodIdColumn.rowStart).toBe(1); + expect(prodIdColumn.rowEnd).toBe(4); + expect(prodIdColumn.colStart).toBe(1); + expect(prodIdColumn.colEnd).toBe(1); + }); +}); + +class HelperFunctions { + public static verifyColumns(columns: IColumnState[], gridState: IGridState) { + columns.forEach((c, index) => { + expect(gridState.columns[index]).toEqual(jasmine.objectContaining(c)); + }); + } + + public static verifySortingExpressions(sortingExpressions: ISortingExpression[], gridState: IGridState) { + sortingExpressions.forEach((expr, i) => { + expect(expr).toEqual(jasmine.objectContaining(gridState.sorting[i])); + }); + } + + public static verifyGroupingExpressions(groupingExpressions: IGroupingExpression[], gridState: IGridState) { + groupingExpressions.forEach((expr, i) => { + expect(expr).toEqual(jasmine.objectContaining(gridState.groupBy.expressions[i])); + }); + } + + public static verifyGroupingExpansion(groupingExpansion: IGroupByExpandState[], groupBy: IGroupingState) { + groupingExpansion.forEach((exp, i) => { + expect(exp).toEqual(jasmine.objectContaining(groupBy.expansion[i])); + }); + } + + public static verifyFilteringExpressions(expressions: IFilteringExpressionsTree, gridState: IGridState) { + expect(expressions.fieldName).toBe(gridState.filtering.fieldName, 'Filtering expression field name is not correct'); + expect(expressions.operator).toBe(gridState.filtering.operator, 'Filtering expression operator value is not correct'); + expressions.filteringOperands.forEach((expr, i) => { + expect(expr).toEqual(jasmine.objectContaining(gridState.filtering.filteringOperands[i])); + }); + } + + public static verifyAdvancedFilteringExpressions(expressions: IFilteringExpressionsTree, gridState: IGridState) { + if (gridState.advancedFiltering) { + expect(expressions.fieldName).toBe(gridState.advancedFiltering.fieldName, 'Filtering expression field name is not correct'); + expect(expressions.operator).toBe(gridState.advancedFiltering.operator, 'Filtering expression operator value is not correct'); + expressions.filteringOperands.forEach((expr, i) => { + expect(expr).toEqual(jasmine.objectContaining(gridState.advancedFiltering.filteringOperands[i])); + }); + } else { + expect(expressions).toBeFalsy(); + } + } + + public static verifyPaging(paging: IPagingState, gridState: IGridState) { + expect(paging).toEqual(jasmine.objectContaining(gridState.paging)); + } + + public static verifyMoving(moving: boolean, gridState: IGridState){ + expect(moving).toEqual(gridState.moving); + } + + public static verifyRowSelection(selectedRows: any[], gridState: IGridState) { + gridState.rowSelection.forEach((s, index) => { + expect(s).toBe(selectedRows[index]); + }); + } + + public static verifyCellSelection(selectedCells: GridSelectionRange[], gridState: IGridState) { + selectedCells.forEach((expr, i) => { + expect(expr).toEqual(jasmine.objectContaining(gridState.cellSelection[i])); + }); + } + + public static verifyExpansionStates(expansion: Map, gridState: IGridState) { + const gridExpansion = new Map(gridState.expansion); + expansion.forEach((value, key) => { + expect(value).toBe(gridExpansion.get(key)); + }); + } +} + +@Component({ + template: ` + + @for (c of columns; track c.field) { + + + } + + + + Custom Content: {{cell.value}} + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxPaginatorComponent, IgxGridStateDirective] +}) +export class IgxGridStateComponent { + @ViewChild('grid', { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + + @ViewChild('bodyTemplate', { read: TemplateRef, static: true }) + public template: TemplateRef; + + @ViewChild(IgxGridStateDirective, { static: true }) + public state: IgxGridStateDirective; + + public data = SampleTestData.foodProductData(); + + public columns: any[] = [ + { field: 'ProductID', header: 'Product ID', width: '150px', dataType: 'number', pinned: true, sortable: true, filterable: true, groupable: false, hasSummary: false, hidden: false, maxWidth: '300px', searchable: false, sortingIgnoreCase: true, filteringIgnoreCase: true, editable: false, headerClasses: 'testCss', headerGroupClasses: '', resizable: true, disablePinning: false }, + { field: 'ProductName', header: 'Product Name', width: '150px', dataType: 'string', pinned: false, selectable: false, sortable: true, filterable: true, groupable: true, hasSummary: false, hidden: false, maxWidth: '300px', searchable: true, sortingIgnoreCase: true, filteringIgnoreCase: true, editable: false, headerClasses: '', headerGroupClasses: '', resizable: true, disablePinning: false }, + { field: 'InStock', header: 'In Stock', width: '140px', dataType: 'boolean', pinned: false, sortable: false, filterable: true, groupable: false, hasSummary: true, hidden: false, maxWidth: '300px', searchable: true, sortingIgnoreCase: true, filteringIgnoreCase: true, editable: true, headerClasses: '', headerGroupClasses: '', resizable: true, disablePinning: true }, + { field: 'OrderDate', header: 'Date ordered', width: '110px', dataType: 'date', pinned: false, sortable: true, filterable: false, groupable: true, hasSummary: false, hidden: false, maxWidth: '300px', searchable: true, sortingIgnoreCase: true, filteringIgnoreCase: true, editable: true, headerClasses: '', headerGroupClasses: '', resizable: false, disablePinning: false }, + ]; +} + +@Component({ + template: ` + + + + `, + imports: [IgxGridComponent, IgxPaginatorComponent, IgxGridStateDirective] +}) +export class IgxGridStateWithOptionsComponent { + @ViewChild('grid', { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + + @ViewChild(IgxGridStateDirective, { static: true }) + public state: IgxGridStateDirective; + + public data = SampleTestData.foodProductData(); + public options = { + filtering: false, + advancedFiltering: true, + sorting: false, + groupBy: false, + moving: false + }; +} + +@Component({ + template: ` + + + + Detail view + + + + `, + imports: [IgxGridComponent, IgxGridStateDirective, IgxGridDetailTemplateDirective, IgxPaginatorComponent] +}) +export class IgxGridStateWithDetailsComponent { + @ViewChild('grid', { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + + @ViewChild(IgxGridStateDirective, { static: true }) + public state: IgxGridStateDirective; + + public data = SampleTestData.foodProductData(); +} + +@Component({ + template: ` + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxColumnGroupComponent, IgxGridStateDirective] +}) +export class CollapsibleColumnGroupTestComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + @ViewChild(IgxGridStateDirective, { static: true }) + public state: IgxGridStateDirective; + public data = SampleTestData.contactInfoDataFull(); +} + +@Component({ + template: ` + + + + + + + + + + + + + + + + + `, + imports: [IgxGridComponent, IgxGridStateDirective, IgxColumnComponent, IgxColumnLayoutComponent] +}) +export class IgxGridMRLStateComponent { + @ViewChild('grid', { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + + @ViewChild(IgxGridStateDirective, { static: true }) + public state: IgxGridStateDirective; + + public data = SampleTestData.foodProductData(); +} diff --git a/projects/igniteui-angular/grids/core/src/state.directive.ts b/projects/igniteui-angular/grids/core/src/state.directive.ts new file mode 100644 index 00000000000..80bae4a14e7 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/state.directive.ts @@ -0,0 +1,79 @@ +import { Directive, EventEmitter, Input, Output } from '@angular/core'; +import { GridFeatures, IGridState, IGridStateOptions, IgxGridStateBaseDirective } from './state-base.directive'; + +@Directive({ + selector: '[igxGridState]', + standalone: true +}) +export class IgxGridStateDirective extends IgxGridStateBaseDirective { + private static ngAcceptInputType_options: IGridStateOptions | ''; + + /** + * An object with options determining if a certain feature state should be saved. + * ```html + * + * ``` + * ```typescript + * public options = {selection: false, advancedFiltering: false}; + * ``` + */ + @Input('igxGridState') + public override get options(): IGridStateOptions { + return super.options; + } + + public override set options(value: IGridStateOptions) { + super.options = value; + } + + /** + * Gets the state of a feature or states of all grid features, unless a certain feature is disabled through the `options` property. + * + * @param `serialize` determines whether the returned object will be serialized to JSON string. Default value is true. + * @param `feature` string or array of strings determining the features to be added in the state. If skipped, all features are added. + * @returns Returns the serialized to JSON string IGridState object, or the non-serialized IGridState object. + * ```html + * + * ``` + * ```typescript + * @ViewChild(IgxGridStateDirective, { static: true }) public state; + * let state = this.state.getState(); // returns string + * let state = this.state(false) // returns `IGridState` object + * ``` + */ + public getState(serialize = true, features?: GridFeatures | GridFeatures[]): IGridState | string { + return super.getStateInternal(serialize, features); + } + + /* blazorSuppress */ + /** + * Restores grid features' state based on the IGridState object passed as an argument. + * + * @param IGridState object to restore state from. + * @returns + * ```html + * + * ``` + * ```typescript + * @ViewChild(IgxGridStateDirective, { static: true }) public state; + * this.state.setState(gridState); + * ``` + */ + public setState(state: IGridState | string, features?: GridFeatures | GridFeatures[]) { + if (typeof state === 'string') { + state = JSON.parse(state) as IGridState; + this.stateParsed.emit(state) + } + return super.setStateInternal(state, features); + } + + /** + * Event emitted when set state is called with a string. + * Returns the parsed state object so that it can be further modified before applying to the grid. + * ```typescript + * this.state.stateParsed.subscribe(parsedState => parsedState.sorting.forEach(x => x.strategy = NoopSortingStrategy.instance()}); + * ``` + */ + @Output() + public stateParsed = new EventEmitter(); +} diff --git a/projects/igniteui-angular/grids/core/src/state.hierarchicalgrid.spec.ts b/projects/igniteui-angular/grids/core/src/state.hierarchicalgrid.spec.ts new file mode 100644 index 00000000000..d103ac20d2e --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/state.hierarchicalgrid.spec.ts @@ -0,0 +1,739 @@ +import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { Component, ViewChild } from '@angular/core'; +import { IgxGridStateDirective } from './state.directive'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { GridSelectionMode } from './common/enums'; +import { GridSelectionRange } from './common/types'; +import { IgxColumnComponent } from './public_api'; +import { IgxPaginatorComponent } from 'igniteui-angular/paginator'; +import { IColumnState, IGridState } from './state-base.directive'; +import { FilteringExpressionsTree, FilteringLogic, IFilteringExpressionsTree, IGroupingExpression, IgxStringFilteringOperand, IPagingState, ISortingExpression, SortingDirection } from 'igniteui-angular/core'; +import { IgxHierarchicalGridComponent, IgxRowIslandComponent } from 'igniteui-angular/grids/hierarchical-grid'; +import { IgxGridNavigationService } from './grid-navigation.service'; + +describe('IgxHierarchicalGridState - input properties #hGrid', () => { + let fix; + let grid; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, IgxHierarchicalGridTestExpandedBaseComponent], + providers: [ + IgxGridNavigationService + ] + }).compileComponents(); + })) + + beforeEach(() => { + fix = TestBed.createComponent(IgxHierarchicalGridTestExpandedBaseComponent); + fix.detectChanges(); + grid = fix.componentInstance.hgrid; + }); + + it('should initialize an igxGridState with default options object', () => { + fix.componentInstance.data = [ + {ID: 0, ProductName: 'Product: A0'}, + {ID: 1, ProductName: 'Product: A1', childData: generateDataUneven(1, 1)}, + {ID: 2, ProductName: 'Product: A2', childData: generateDataUneven(1, 1)} + ]; + fix.detectChanges(); + + const defaultOptions = { + columns: true, + filtering: true, + advancedFiltering: true, + sorting: true, + cellSelection: true, + rowSelection: true, + columnSelection: true, + expansion: true, + rowIslands: true, + moving: true + }; + + const state = fix.componentInstance.state; + expect(state).toBeDefined('IgxGridState directive is initialized'); + expect(state.options).toEqual(jasmine.objectContaining(defaultOptions)); + }); + + it('should initialize an igxGridState with correct options input', () => { + fix.componentInstance.data = [ + {ID: 0, ProductName: 'Product: A0'}, + {ID: 1, ProductName: 'Product: A1', childData: generateDataUneven(1, 1)}, + {ID: 2, ProductName: 'Product: A2', childData: generateDataUneven(1, 1)} + ]; + + fix.detectChanges(); + + const optionsInput = { + columns: true, + filtering: false, + advancedFiltering: true, + sorting: false, + paging: true, + cellSelection: true, + rowSelection: true, + columnSelection: true, + expansion: true, + rowIslands: false, + moving:true + }; + + const state = fix.componentInstance.state; + state.options = optionsInput; + expect(state.options).toEqual(jasmine.objectContaining(optionsInput)); + }); + + it('getState should return corect JSON string', () => { + pending(); + const initialGridState = '{"columns":[{"pinned":true,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"testCss","headerGroupClasses":"","maxWidth":"300px","groupable":false,,"hidden":false,"dataType":"number","hasSummary":false,"field":"ID","width":"150px","header":"ID","resizable":true,"searchable":false,"selectable":true,"parent":null,"columnGroup":false,"disableHiding":true,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,,"hidden":false,"dataType":"string","hasSummary":false,"field":"ProductName","width":"150px","header":"Product Name","resizable":true,"searchable":true,"selectable":true,"parent":null,"columnGroup":false,"disableHiding":true,"disablePinning":false}],"filtering":{"filteringOperands":[],"operator":0},"advancedFiltering":{},"sorting":[],"paging":{"index":0,"recordsPerPage":5,"metadata":{"countPages":4,"countRecords":20,"error":0}},"cellSelection":[],"rowSelection":[],"columnSelection":[],"rowPinning":[],"expansion":[],"rowIslands":[{"id":"igx-row-island-childData","parentRowID":"0","state":{"columns":[{"pinned":true,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"testCss","headerGroupClasses":"","maxWidth":"300px","groupable":false,,"hidden":false,"dataType":"number","hasSummary":false,"field":"ID","width":"150px","header":"Product ID","resizable":true,"searchable":false,"selectable":true,"disableHiding":true,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,,"hidden":false,"dataType":"string","hasSummary":false,"field":"ProductName","width":"150px","header":"Product Name","resizable":true,"searchable":true,"selectable":true,"disableHiding":true,"disablePinning":false},{"pinned":false,"sortable":false,"filterable":true,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":false,,"hidden":false,"dataType":"boolean","hasSummary":true,"field":"Col1","width":"140px","header":"Col 1","resizable":true,"searchable":true,"selectable":true,"disableHiding":true,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":false,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,,"hidden":false,"dataType":"date","hasSummary":false,"field":"Col2","width":"110px","header":"Col 2","resizable":false,"searchable":true,"selectable":true,"disableHiding":true,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":false,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,,"hidden":false,"dataType":"date","hasSummary":false,"field":"Col3","width":"110px","header":"Col 3","resizable":false,"searchable":true,"selectable":true,"disableHiding":true,"disablePinning":false}],"filtering":{"filteringOperands":[],"operator":0},"advancedFiltering":{},"sorting":[],"paging":{"index":0,"recordsPerPage":5,"metadata":{"countPages":2,"countRecords":7,"error":0}},"cellSelection":[],"rowSelection":[],"columnSelection":[],"rowPinning":[],"expansion":[],"rowIslands":[]}},{"id":"igx-row-island-childData","parentRowID":"1","state":{"columns":[{"pinned":true,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"testCss","headerGroupClasses":"","maxWidth":"300px","groupable":false,,"hidden":false,"dataType":"number","hasSummary":false,"field":"ID","width":"150px","header":"Product ID","resizable":true,"searchable":false,"selectable":true,"disableHiding":true,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,,"hidden":false,"dataType":"string","hasSummary":false,"field":"ProductName","width":"150px","header":"Product Name","resizable":true,"searchable":true,"selectable":true,"disableHiding":true,"disablePinning":false},{"pinned":false,"sortable":false,"filterable":true,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":false,,"hidden":false,"dataType":"boolean","hasSummary":true,"field":"Col1","width":"140px","header":"Col 1","resizable":true,"searchable":true,"selectable":true,"disableHiding":true,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":false,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,,"hidden":false,"dataType":"date","hasSummary":false,"field":"Col2","width":"110px","header":"Col 2","resizable":false,"searchable":true,"selectable":true,"disableHiding":true,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":false,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,,"hidden":false,"dataType":"date","hasSummary":false,"field":"Col3","width":"110px","header":"Col 3","resizable":false,"searchable":true,"selectable":true,"disableHiding":true,"disablePinning":false}],"filtering":{"filteringOperands":[],"operator":0},"advancedFiltering":{},"sorting":[],"paging":{"index":0,"recordsPerPage":5,"metadata":{"countPages":3,"countRecords":14,"error":0}},"cellSelection":[],"rowSelection":[],"columnSelection":[],"rowPinning":[],"expansion":[],"rowIslands":[]}},{"id":"igx-row-island-childData","parentRowID":"2","state":{"columns":[{"pinned":true,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"testCss","headerGroupClasses":"","maxWidth":"300px","groupable":false,,"hidden":false,"dataType":"number","hasSummary":false,"field":"ID","width":"150px","header":"Product ID","resizable":true,"searchable":false,"selectable":true,"disableHiding":true,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,,"hidden":false,"dataType":"string","hasSummary":false,"field":"ProductName","width":"150px","header":"Product Name","resizable":true,"searchable":true,"selectable":true,"disableHiding":true,"disablePinning":false},{"pinned":false,"sortable":false,"filterable":true,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":false,,"hidden":false,"dataType":"boolean","hasSummary":true,"field":"Col1","width":"140px","header":"Col 1","resizable":true,"searchable":true,"selectable":true,"disableHiding":true,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":false,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,,"hidden":false,"dataType":"date","hasSummary":false,"field":"Col2","width":"110px","header":"Col 2","resizable":false,"searchable":true,"selectable":true,"disableHiding":true,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":false,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,,"hidden":false,"dataType":"date","hasSummary":false,"field":"Col3","width":"110px","header":"Col 3","resizable":false,"searchable":true,"selectable":true,"disableHiding":true,"disablePinning":false}],"filtering":{"filteringOperands":[],"operator":0},"advancedFiltering":{},"sorting":[],"paging":{"index":0,"recordsPerPage":5,"metadata":{"countPages":2,"countRecords":7,"error":0}},"cellSelection":[],"rowSelection":[],"columnSelection":[],"rowPinning":[],"expansion":[],"rowIslands":[]}},{"id":"igx-row-island-childData","parentRowID":"3","state":{"columns":[{"pinned":true,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"testCss","headerGroupClasses":"","maxWidth":"300px","groupable":false,,"hidden":false,"dataType":"number","hasSummary":false,"field":"ID","width":"150px","header":"Product ID","resizable":true,"searchable":false,"selectable":true,"disableHiding":true,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,,"hidden":false,"dataType":"string","hasSummary":false,"field":"ProductName","width":"150px","header":"Product Name","resizable":true,"searchable":true,"selectable":true,"disableHiding":true,"disablePinning":false},{"pinned":false,"sortable":false,"filterable":true,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":false,,"hidden":false,"dataType":"boolean","hasSummary":true,"field":"Col1","width":"140px","header":"Col 1","resizable":true,"searchable":true,"selectable":true,"disableHiding":true,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":false,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,,"hidden":false,"dataType":"date","hasSummary":false,"field":"Col2","width":"110px","header":"Col 2","resizable":false,"searchable":true,"selectable":true,"disableHiding":true,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":false,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,,"hidden":false,"dataType":"date","hasSummary":false,"field":"Col3","width":"110px","header":"Col 3","resizable":false,"searchable":true,"selectable":true,"disableHiding":true,"disablePinning":false}],"filtering":{"filteringOperands":[],"operator":0},"advancedFiltering":{},"sorting":[],"paging":{"index":0,"recordsPerPage":5,"metadata":{"countPages":3,"countRecords":14,"error":0}},"cellSelection":[],"rowSelection":[],"columnSelection":[],"rowPinning":[],"expansion":[],"rowIslands":[]}},{"id":"igx-row-island-childData","parentRowID":"4","state":{"columns":[{"pinned":true,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"testCss","headerGroupClasses":"","maxWidth":"300px","groupable":false,,"hidden":false,"dataType":"number","hasSummary":false,"field":"ID","width":"150px","header":"Product ID","resizable":true,"searchable":false,"selectable":true,"disableHiding":true,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,,"hidden":false,"dataType":"string","hasSummary":false,"field":"ProductName","width":"150px","header":"Product Name","resizable":true,"searchable":true,"selectable":true,"disableHiding":true,"disablePinning":false},{"pinned":false,"sortable":false,"filterable":true,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":false,,"hidden":false,"dataType":"boolean","hasSummary":true,"field":"Col1","width":"140px","header":"Col 1","resizable":true,"searchable":true,"selectable":true,"disableHiding":true,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":false,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,,"hidden":false,"dataType":"date","hasSummary":false,"field":"Col2","width":"110px","header":"Col 2","resizable":false,"searchable":true,"selectable":true,"disableHiding":true,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":false,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,,"hidden":false,"dataType":"date","hasSummary":false,"field":"Col3","width":"110px","header":"Col 3","resizable":false,"searchable":true,"selectable":true,"disableHiding":true,"disablePinning":false}],"filtering":{"filteringOperands":[],"operator":0},"advancedFiltering":{},"sorting":[],"paging":{"index":0,"recordsPerPage":5,"metadata":{"countPages":2,"countRecords":7,"error":0}},"cellSelection":[],"rowSelection":[],"columnSelection":[],"rowPinning":[],"expansion":[],"rowIslands":[]}}]}'; + fix.detectChanges(); + + const state = fix.componentInstance.state; + const gridState = state.getState(); + expect(gridState).toBe(initialGridState, 'JSON string representation of the initial grid state is not correct'); + }); + + it('getState should return corect IGridState object when using default options', () => { + fix.detectChanges(); + grid = fix.componentInstance.hgrid; + const state = fix.componentInstance.state; + + const gridFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + const productFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And, 'ProductName'); + const productExpression = { + condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains', + fieldName: 'ProductName', + ignoreCase: true, + searchVal: 'A0' + }; + productFilteringExpressionsTree.filteringOperands.push(productExpression); + gridFilteringExpressionsTree.filteringOperands.push(productFilteringExpressionsTree); + + grid.filteringExpressionsTree = gridFilteringExpressionsTree; + fix.detectChanges(); + + const columns = fix.componentInstance.columns; + const sorting = grid.sortingExpressions; + const filtering = grid.filteringExpressionsTree; + + const gridState = state.getState(false) as IGridState; + HelperFunctions.verifyColumns(columns, gridState); + HelperFunctions.verifySortingExpressions(sorting, gridState); + HelperFunctions.verifyFilteringExpressions(filtering, gridState); + }); + + it('getState should return corect IGridState object when options are not default', () => { + fix.detectChanges(); + const state = fix.componentInstance.state; + grid = fix.componentInstance.hgrid; + + const optionsInput = { + paging: false, + sorting: false, + moving: false + }; + + state.options = optionsInput; + + let gridState = state.getState(false) as IGridState; + expect(gridState['sorting']).toBeFalsy(); + expect(gridState['paging']).toBeFalsy(); + expect(gridState['moving']).toBeFalsy(); + + const gridsCollection = HelperFunctions.getChildGridsCollection(grid, gridState); + gridsCollection.forEach(childGrid => { + expect(childGrid.state['sorting']).toBeFalsy(); + expect(childGrid.state['paging']).toBeFalsy(); + }); + + gridState = state.getState(false, ['filtering', 'sorting', 'paging', 'rowIslands', 'moving']) as IGridState; + expect(gridState['sorting']).toBeFalsy(); + expect(gridState['paging']).toBeFalsy(); + expect(gridState['moving']).toBeFalsy(); + }); + + it('getState should return correct moving state', () => { + const state = fix.componentInstance.state; + const initialState = HelperFunctions.buildStateString(grid, 'moving', 'true', 'true'); + + let gridState = state.getState(true, ['moving', 'rowIslands']); + expect(gridState).toBe(initialState); + + gridState = state.getState(false, ['moving', 'rowIslands']) as IGridState; + HelperFunctions.verifyMoving(grid.moving, gridState); + }); + + it('setState should correctly restore grid moving state from string', fakeAsync(() => { + const state = fix.componentInstance.state; + + const initialState = HelperFunctions.buildStateString(grid, 'moving', 'true', 'true'); + const movingState = HelperFunctions.buildStateString(grid, 'moving', 'false', 'false'); + + const movingStateObject = JSON.parse(movingState) as IGridState; + + let gridState = state.getState(true, ['moving', 'rowIslands']); + expect(gridState).toBe(initialState); + + state.setState(JSON.stringify(movingStateObject)); + tick(); + fix.detectChanges(); + gridState = state.getState(false, ['moving', 'rowIslands']) as IGridState; + HelperFunctions.verifyMoving(grid.moving, gridState); + const gridsCollection = HelperFunctions.getChildGridsCollection(grid, gridState); + gridsCollection.forEach(childGrid => { + HelperFunctions.verifyMoving(childGrid.grid.moving, childGrid.state); + }); + gridState = state.getState(true, ['moving', 'rowIslands']); + expect(gridState).toBe(movingState); + })); + + it('getState should return correct filtering state', () => { + const state = fix.componentInstance.state; + const filtering = grid.filteringExpressionsTree; + + const emptyFiltering = '{"filteringOperands":[],"operator":0}'; + const initialState = HelperFunctions.buildStateString(grid, 'filtering', emptyFiltering, emptyFiltering); + + let gridState = state.getState(true, ['filtering', 'rowIslands']); + expect(gridState).toBe(initialState); + + gridState = state.getState(false, ['filtering', 'rowIslands']) as IGridState; + HelperFunctions.verifyFilteringExpressions(filtering, gridState); + }); + + it('setState should correctly restore grid filtering state from string', fakeAsync(() => { + const state = fix.componentInstance.state; + + const emptyFiltering = '{"filteringOperands":[],"operator":0}'; + const initialState = HelperFunctions.buildStateString(grid, 'filtering', emptyFiltering, emptyFiltering); + + const filtering = '{"filteringOperands":[{"filteringOperands":[{"condition":{"name":"contains","isUnary":false,"iconName":"filter_contains"},"fieldName":"ProductName","ignoreCase":true,"searchVal":"A0","conditionName":"contains"}],"operator":0,"fieldName":"ProductName"}],"operator":0,"type":0}'; + const filteringState = HelperFunctions.buildStateString(grid, 'filtering', filtering, filtering); + + const filteringStateObject = JSON.parse(filteringState) as IGridState; + filteringStateObject.columns = fix.componentInstance.childColumns; + + let gridState = state.getState(true, ['filtering', 'rowIslands']); + expect(gridState).toBe(initialState); + + state.setState(JSON.stringify(filteringStateObject)); + tick(); + fix.detectChanges(); + gridState = state.getState(false, ['filtering', 'rowIslands']) as IGridState; + HelperFunctions.verifyFilteringExpressions(grid.filteringExpressionsTree, gridState); + const gridsCollection = HelperFunctions.getChildGridsCollection(grid, gridState); + gridsCollection.forEach(childGrid => { + HelperFunctions.verifyFilteringExpressions(childGrid.grid.filteringExpressionsTree, childGrid.state); + }); + gridState = state.getState(true, ['filtering', 'rowIslands']); + expect(gridState).toBe(filteringState); + })); + + it('setState should correctly restore grid filtering state from object', fakeAsync(() => { + const state = fix.componentInstance.state; + + const emptyFiltering = '{"filteringOperands":[],"operator":0}'; + const initialState = HelperFunctions.buildStateString(grid, 'filtering', emptyFiltering, emptyFiltering); + + const filtering = '{"filteringOperands":[{"filteringOperands":[{"condition":{"name":"contains","isUnary":false,"iconName":"filter_contains"},"fieldName":"ProductName","ignoreCase":true,"searchVal":"A0","conditionName":"contains"}],"operator":0,"fieldName":"ProductName"}],"operator":0,"type":0}'; + const filteringState = HelperFunctions.buildStateString(grid, 'filtering', filtering, filtering); + + const filteringStateObject = JSON.parse(filteringState) as IGridState; + filteringStateObject.columns = fix.componentInstance.childColumns; + + let gridState = state.getState(true, ['filtering', 'rowIslands']); + expect(gridState).toBe(initialState); + + state.setState(filteringStateObject); + tick(); + fix.detectChanges(); + gridState = state.getState(false, ['filtering', 'rowIslands']) as IGridState; + HelperFunctions.verifyFilteringExpressions(grid.filteringExpressionsTree, gridState); + const gridsCollection = HelperFunctions.getChildGridsCollection(grid, gridState); + gridsCollection.forEach(childGrid => { + HelperFunctions.verifyFilteringExpressions(childGrid.grid.filteringExpressionsTree, childGrid.state); + }); + gridState = state.getState(true, ['filtering', 'rowIslands']); + expect(gridState).toBe(filteringState); + })); + + it('setState should correctly restore grid sorting state from string', () => { + const state = fix.componentInstance.state; + + let sorting = grid.sortingExpressions; + const emptySorting = '[]'; + let initialState = HelperFunctions.buildStateString(grid, 'sorting', emptySorting, emptySorting); + + let gridState = state.getState(true, ['sorting', 'rowIslands']); + expect(gridState).toBe(initialState); + + grid.sortingExpressions = [ + { fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: true } + ]; + fix.detectChanges(); + + sorting = '[{"fieldName":"ProductName","dir":1,"ignoreCase":true}]'; + initialState = HelperFunctions.buildStateString(grid, 'sorting', sorting, emptySorting, emptySorting); + const sortingState = HelperFunctions.buildStateString(grid, 'sorting', sorting, sorting); + + gridState = state.getState(true, ['sorting', 'rowIslands']); + expect(gridState).toBe(initialState); + + state.setState(sortingState); + gridState = state.getState(false, ['sorting', 'rowIslands']) as IGridState; + HelperFunctions.verifySortingExpressions(grid.sortingExpressions, gridState); + const gridsCollection = HelperFunctions.getChildGridsCollection(grid, gridState); + gridsCollection.forEach(childGrid => { + HelperFunctions.verifySortingExpressions(childGrid.grid.sortingExpressions, childGrid.state); + }); + gridState = state.getState(true, ['sorting', 'rowIslands']); + expect(gridState).toBe(sortingState); + }); + + it('setState should correctly restore grid sorting state from object', () => { + const state = fix.componentInstance.state; + grid.sortingExpressions = [ + { fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: true } + ]; + fix.detectChanges(); + + const emptySorting = '[]'; + const sorting = '[{"fieldName":"ProductName","dir":1,"ignoreCase":true}]'; + const initialState = HelperFunctions.buildStateString(grid, 'sorting', sorting, emptySorting, emptySorting); + const sortingState = HelperFunctions.buildStateString(grid, 'sorting', sorting, sorting); + const sortingStateObject = JSON.parse(sortingState) as IGridState; + + let gridState = state.getState(true, ['sorting', 'rowIslands']); + expect(gridState).toBe(initialState); + + state.setState(sortingStateObject); + gridState = state.getState(false, ['sorting', 'rowIslands']); + HelperFunctions.verifySortingExpressions(grid.sortingExpressions, gridState as IGridState); + const gridsCollection = HelperFunctions.getChildGridsCollection(grid, gridState); + gridsCollection.forEach(childGrid => { + HelperFunctions.verifySortingExpressions(childGrid.grid.sortingExpressions, childGrid.state); + }); + gridState = state.getState(true, ['sorting', 'rowIslands']); + expect(gridState).toBe(sortingState); + }); + + it('setState should correctly restore grid paging state from string', () => { + pending(); + const state = fix.componentInstance.state; + + const initialState = '{"paging":{"index":0,"recordsPerPage":5,"metadata":{"countPages":4,"countRecords":20,"error":0}},"rowIslands":[{"id":"igx-row-island-childData","parentRowID":"0","state":{"paging":{"index":0,"recordsPerPage":5,"metadata":{"countPages":2,"countRecords":7,"error":0}},"rowIslands":[]}},{"id":"igx-row-island-childData","parentRowID":"1","state":{"paging":{"index":0,"recordsPerPage":5,"metadata":{"countPages":3,"countRecords":14,"error":0}},"rowIslands":[]}},{"id":"igx-row-island-childData","parentRowID":"2","state":{"paging":{"index":0,"recordsPerPage":5,"metadata":{"countPages":2,"countRecords":7,"error":0}},"rowIslands":[]}},{"id":"igx-row-island-childData","parentRowID":"3","state":{"paging":{"index":0,"recordsPerPage":5,"metadata":{"countPages":3,"countRecords":14,"error":0}},"rowIslands":[]}},{"id":"igx-row-island-childData","parentRowID":"4","state":{"paging":{"index":0,"recordsPerPage":5,"metadata":{"countPages":2,"countRecords":7,"error":0}},"rowIslands":[]}}]}'; + const pagingState = '{"paging":{"index":0,"recordsPerPage":20,"metadata":{"countPages":1,"countRecords":20,"error":0}},"rowIslands":[{"id":"igx-row-island-childData","parentRowID":"0","state":{"paging":{"index":0,"recordsPerPage":20,"metadata":{"countPages":1,"countRecords":7,"error":0}},"rowIslands":[]}},{"id":"igx-row-island-childData","parentRowID":"1","state":{"paging":{"index":0,"recordsPerPage":20,"metadata":{"countPages":1,"countRecords":14,"error":0}},"rowIslands":[]}},{"id":"igx-row-island-childData","parentRowID":"2","state":{"paging":{"index":0,"recordsPerPage":20,"metadata":{"countPages":1,"countRecords":7,"error":0}},"rowIslands":[]}},{"id":"igx-row-island-childData","parentRowID":"3","state":{"paging":{"index":0,"recordsPerPage":20,"metadata":{"countPages":1,"countRecords":14,"error":0}},"rowIslands":[]}},{"id":"igx-row-island-childData","parentRowID":"4","state":{"paging":{"index":0,"recordsPerPage":20,"metadata":{"countPages":1,"countRecords":7,"error":0}},"rowIslands":[]}},{"id":"igx-row-island-childData","parentRowID":"5","state":{"paging":{"index":0,"recordsPerPage":5,"metadata":{"countPages":3,"countRecords":14,"error":0}},"rowIslands":[]}},{"id":"igx-row-island-childData","parentRowID":"6","state":{"paging":{"index":0,"recordsPerPage":5,"metadata":{"countPages":2,"countRecords":7,"error":0}},"rowIslands":[]}}]}'; + + let gridState = state.getState(true, ['paging', 'rowIslands']); + expect(gridState).toBe(initialState); + + state.setState(pagingState); + gridState = state.getState(false, ['paging', 'rowIslands']); + HelperFunctions.verifyPaging(grid.pagingState, gridState as IGridState); + const gridsCollection = HelperFunctions.getChildGridsCollection(grid, gridState); + gridsCollection.forEach(childGrid => { + HelperFunctions.verifyPaging(childGrid.grid.pagingState, childGrid.state); + }); + gridState = state.getState(true, ['paging', 'rowIslands']); + expect(gridState).toBe(pagingState); + }); + + it('setState should correctly restore grid advanced filtering state from string', () => { + const state = fix.componentInstance.state; + + const emptyFiltering = '{}'; + const initialState = HelperFunctions.buildStateString(grid, 'advancedFiltering', emptyFiltering, emptyFiltering); + const filtering = '{"filteringOperands":[{"fieldName":"ProductName","condition":{"name":"contains","isUnary":false,"iconName":"filter_contains"},"searchVal":"A0","ignoreCase":true,"conditionName":"contains"},{"fieldName":"ID","condition":{"name":"lessThan","isUnary":false,"iconName":"filter_less_than"},"searchVal":3,"ignoreCase":true,"conditionName":"lessThan"}],"operator":0,"type":1}'; + const filteringState = HelperFunctions.buildStateString(grid, 'advancedFiltering', filtering, filtering); + + let gridState = state.getState(true, ['advancedFiltering', 'rowIslands']); + expect(gridState).toBe(initialState); + + state.setState(filteringState); + gridState = state.getState(false, ['advancedFiltering', 'rowIslands']) as IGridState; + HelperFunctions.verifyAdvancedFilteringExpressions(grid.advancedFilteringExpressionsTree, gridState); + const gridsCollection = HelperFunctions.getChildGridsCollection(grid, gridState); + gridsCollection.forEach(childGrid => { + HelperFunctions.verifyAdvancedFilteringExpressions(childGrid.grid.advancedFilteringExpressionsTree, childGrid.state); + }); + + gridState = state.getState(true, ['advancedFiltering', 'rowIslands']); + expect(gridState).toBe(filteringState); + }); + + it('setState should correctly restore grid advanced filtering state from object', () => { + const state = fix.componentInstance.state; + + const emptyFiltering = '{}'; + const initialState = HelperFunctions.buildStateString(grid, 'advancedFiltering', emptyFiltering, emptyFiltering); + const filtering = '{"filteringOperands":[{"fieldName":"ProductName","condition":{"name":"contains","isUnary":false,"iconName":"filter_contains"},"searchVal":"A0","ignoreCase":true,"conditionName":"contains"},{"fieldName":"ID","condition":{"name":"lessThan","isUnary":false,"iconName":"filter_less_than"},"searchVal":3,"ignoreCase":true,"conditionName":"lessThan"}],"operator":0,"type":1}'; + const filteringState = HelperFunctions.buildStateString(grid, 'advancedFiltering', filtering, filtering); + const filteringStateObject = JSON.parse(filteringState) as IGridState; + + let gridState = state.getState(true, ['advancedFiltering', 'rowIslands']); + expect(gridState).toBe(initialState); + + state.setState(filteringStateObject); + gridState = state.getState(false, ['advancedFiltering', 'rowIslands']) as IGridState; + HelperFunctions.verifyAdvancedFilteringExpressions(grid.advancedFilteringExpressionsTree, gridState); + const gridsCollection = HelperFunctions.getChildGridsCollection(grid, gridState); + gridsCollection.forEach(childGrid => { + HelperFunctions.verifyAdvancedFilteringExpressions(childGrid.grid.advancedFilteringExpressionsTree, childGrid.state); + }); + gridState = state.getState(true, ['advancedFiltering', 'rowIslands']); + expect(gridState).toBe(filteringState); + }); + + it('setState should correctly restore grid cell selection state from string', () => { + grid.rowSelection = GridSelectionMode.none; + const state = fix.componentInstance.state; + + const emptyCellSelection = '[]'; + const initialState = HelperFunctions.buildStateString(grid, 'cellSelection', emptyCellSelection, emptyCellSelection); + const cellSelection = '[{"rowStart":0,"rowEnd":2,"columnStart":1,"columnEnd":3}]'; + const cellSelectionState = HelperFunctions.buildStateString(grid, 'cellSelection', cellSelection, cellSelection); + + let gridState = state.getState(true, ['cellSelection', 'rowIslands']); + expect(gridState).toBe(initialState); + + state.setState(cellSelectionState); + gridState = state.getState(false, ['cellSelection', 'rowIslands']); + HelperFunctions.verifyCellSelection(grid.getSelectedRanges(), gridState as IGridState); + const gridsCollection = HelperFunctions.getChildGridsCollection(grid, gridState); + gridsCollection.forEach(childGrid => { + HelperFunctions.verifyCellSelection(childGrid.grid.getSelectedRanges(), childGrid.state); + }); + gridState = state.getState(true, ['cellSelection', 'rowIslands']); + expect(gridState).toBe(cellSelectionState); + }); + + it('setState should correctly restore grid row selection state from string', () => { + const state = fix.componentInstance.state; + + const emptyRowSelection = '[]'; + const initialState = HelperFunctions.buildStateString(grid, 'rowSelection', emptyRowSelection, emptyRowSelection); + const rowSelection = '["0","1"]'; + const childRowSelection = '["00","01"]'; + const rowSelectionState = HelperFunctions.buildStateString(grid, 'rowSelection', rowSelection, childRowSelection); + + let gridState = state.getState(true, ['rowSelection', 'rowIslands']); + expect(gridState).toBe(initialState); + + state.setState(rowSelectionState); + gridState = state.getState(false, ['rowSelection', 'rowIslands']); + HelperFunctions.verifyRowSelection(grid.selectedRows, gridState as IGridState); + const gridsCollection = HelperFunctions.getChildGridsCollection(grid, gridState); + gridsCollection.forEach(childGrid => { + HelperFunctions.verifyRowSelection(childGrid.grid.selectedRows, childGrid.state); + }); + gridState = state.getState(true, ['rowSelection', 'rowIslands']); + expect(gridState).toBe(rowSelectionState); + }); + + it('setState should correctly restore expansion state from string', () => { + grid.expandChildren = false; + fix.detectChanges(); + + const state = fix.componentInstance.state; + + const emptyExpansionState = '[]'; + const initialState = HelperFunctions.buildStateString(grid, 'expansion', emptyExpansionState, emptyExpansionState); + const expansion = '[["0",true]]'; + const childExpansion = '[["00",true]]'; + let expansionState = HelperFunctions.buildStateString(grid, 'expansion', expansion, childExpansion, emptyExpansionState); + + let gridState = state.getState(true, ['expansion', 'rowIslands']); + expect(gridState).toBe(initialState); + + state.setState(expansionState); + expansionState = HelperFunctions.buildStateString(grid, 'expansion', expansion, childExpansion, emptyExpansionState); + gridState = state.getState(false, ['expansion', 'rowIslands']); + HelperFunctions.verifyExpansionStates(grid.expansionStates, gridState as IGridState); + const gridsCollection = HelperFunctions.getChildGridsCollection(grid, gridState); + gridsCollection.forEach(childGrid => { + HelperFunctions.verifyExpansionStates(childGrid.grid.expansionStates, childGrid.state); + }); + gridState = state.getState(true, ['expansion', 'rowIslands']); + expect(gridState).toBe(expansionState); + }); + + it('setState should correctly restore grid columns state from string', fakeAsync(() => { + fix.detectChanges(); + const state = fix.componentInstance.state; + + // const rootGridColumns = '[{"pinned":true,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"testCss","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"number","hasSummary":false,"field":"ID","width":"150px","header":"ID","resizable":true,"searchable":false,"selectable":true,"parent":null,"columnGroup":false,"disableHiding":false,"disablePinning":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"string","hasSummary":false,"field":"ProductName","width":"150px","header":"Product Name","resizable":true,"searchable":true,"selectable":true,"parent":null,"columnGroup":false,"disableHiding":false,"disablePinning":false,"disablePinning":false}]'; + // const childGridColumns = '[{"pinned":true,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"testCss","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"number","hasSummary":false,"field":"ID","width":"150px","header":"Product ID","resizable":true,"searchable":false,"selectable":true,"parent":null,"columnGroup":false,"disableHiding":false,"disablePinning":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"string","hasSummary":false,"field":"ProductName","width":"150px","header":"Product Name","resizable":true,"searchable":true,"selectable":true,"parent":null,"columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":false,"filterable":true,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"boolean","hasSummary":true,"field":"Col1","width":"140px","header":"Col 1","resizable":true,"searchable":true,"selectable":true,"parent":null,"columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":false,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"date","hasSummary":false,"field":"Col2","width":"110px","header":"Col 2","resizable":false,"searchable":true,"selectable":true,"parent":null,"columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":false,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"date","hasSummary":false,"field":"Col3","width":"110px","header":"Col 3","resizable":false,"searchable":true,"selectable":true,"parent":null,"columnGroup":false,"disableHiding":false,"disablePinning":false}]'; + const initialState = '{"columns":[{"pinned":true,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"testCss","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"number","hasSummary":false,"field":"ID","width":"150px","header":"ID","resizable":true,"searchable":false,"selectable":true,"key":"ID","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"string","hasSummary":false,"field":"ProductName","width":"150px","header":"Product Name","resizable":true,"searchable":true,"selectable":true,"key":"ProductName","columnGroup":false,"disableHiding":false,"disablePinning":false}],"rowIslands":[{"id":"igx-row-island-childData","parentRowID":"0","state":{"columns":[{"pinned":true,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"testCss","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"number","hasSummary":false,"field":"ID","width":"150px","header":"Product ID","resizable":true,"searchable":false,"selectable":true,"key":"ID","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"string","hasSummary":false,"field":"ProductName","width":"150px","header":"Product Name","resizable":true,"searchable":true,"selectable":true,"key":"ProductName","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":false,"filterable":true,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"boolean","hasSummary":true,"field":"Col1","width":"140px","header":"Col 1","resizable":true,"searchable":true,"selectable":true,"key":"Col1","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":false,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"date","hasSummary":false,"field":"Col2","width":"110px","header":"Col 2","resizable":false,"searchable":true,"selectable":true,"key":"Col2","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":false,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"date","hasSummary":false,"field":"Col3","width":"110px","header":"Col 3","resizable":false,"searchable":true,"selectable":true,"key":"Col3","columnGroup":false,"disableHiding":false,"disablePinning":false}],"rowIslands":[]}},{"id":"igx-row-island-childData","parentRowID":"1","state":{"columns":[{"pinned":true,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"testCss","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"number","hasSummary":false,"field":"ID","width":"150px","header":"Product ID","resizable":true,"searchable":false,"selectable":true,"key":"ID","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"string","hasSummary":false,"field":"ProductName","width":"150px","header":"Product Name","resizable":true,"searchable":true,"selectable":true,"key":"ProductName","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":false,"filterable":true,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"boolean","hasSummary":true,"field":"Col1","width":"140px","header":"Col 1","resizable":true,"searchable":true,"selectable":true,"key":"Col1","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":false,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"date","hasSummary":false,"field":"Col2","width":"110px","header":"Col 2","resizable":false,"searchable":true,"selectable":true,"key":"Col2","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":false,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"date","hasSummary":false,"field":"Col3","width":"110px","header":"Col 3","resizable":false,"searchable":true,"selectable":true,"key":"Col3","columnGroup":false,"disableHiding":false,"disablePinning":false}],"rowIslands":[]}},{"id":"igx-row-island-childData","parentRowID":"2","state":{"columns":[{"pinned":true,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"testCss","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"number","hasSummary":false,"field":"ID","width":"150px","header":"Product ID","resizable":true,"searchable":false,"selectable":true,"key":"ID","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"string","hasSummary":false,"field":"ProductName","width":"150px","header":"Product Name","resizable":true,"searchable":true,"selectable":true,"key":"ProductName","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":false,"filterable":true,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"boolean","hasSummary":true,"field":"Col1","width":"140px","header":"Col 1","resizable":true,"searchable":true,"selectable":true,"key":"Col1","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":false,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"date","hasSummary":false,"field":"Col2","width":"110px","header":"Col 2","resizable":false,"searchable":true,"selectable":true,"key":"Col2","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":false,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"date","hasSummary":false,"field":"Col3","width":"110px","header":"Col 3","resizable":false,"searchable":true,"selectable":true,"key":"Col3","columnGroup":false,"disableHiding":false,"disablePinning":false}],"rowIslands":[]}},{"id":"igx-row-island-childData","parentRowID":"3","state":{"columns":[{"pinned":true,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"testCss","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"number","hasSummary":false,"field":"ID","width":"150px","header":"Product ID","resizable":true,"searchable":false,"selectable":true,"key":"ID","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"string","hasSummary":false,"field":"ProductName","width":"150px","header":"Product Name","resizable":true,"searchable":true,"selectable":true,"key":"ProductName","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":false,"filterable":true,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"boolean","hasSummary":true,"field":"Col1","width":"140px","header":"Col 1","resizable":true,"searchable":true,"selectable":true,"key":"Col1","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":false,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"date","hasSummary":false,"field":"Col2","width":"110px","header":"Col 2","resizable":false,"searchable":true,"selectable":true,"key":"Col2","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":false,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"date","hasSummary":false,"field":"Col3","width":"110px","header":"Col 3","resizable":false,"searchable":true,"selectable":true,"key":"Col3","columnGroup":false,"disableHiding":false,"disablePinning":false}],"rowIslands":[]}},{"id":"igx-row-island-childData","parentRowID":"4","state":{"columns":[{"pinned":true,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"testCss","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"number","hasSummary":false,"field":"ID","width":"150px","header":"Product ID","resizable":true,"searchable":false,"selectable":true,"key":"ID","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"string","hasSummary":false,"field":"ProductName","width":"150px","header":"Product Name","resizable":true,"searchable":true,"selectable":true,"key":"ProductName","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":false,"filterable":true,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"boolean","hasSummary":true,"field":"Col1","width":"140px","header":"Col 1","resizable":true,"searchable":true,"selectable":true,"key":"Col1","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":false,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"date","hasSummary":false,"field":"Col2","width":"110px","header":"Col 2","resizable":false,"searchable":true,"selectable":true,"key":"Col2","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":false,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"date","hasSummary":false,"field":"Col3","width":"110px","header":"Col 3","resizable":false,"searchable":true,"selectable":true,"key":"Col3","columnGroup":false,"disableHiding":false,"disablePinning":false}],"rowIslands":[]}}]}'; + const newRootGridColumns = '[{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"testCss","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"number","hasSummary":false,"field":"ID","width":"150px","header":"ID","resizable":true,"searchable":false,"selectable":true,"key":"ID","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":true,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"string","hasSummary":false,"field":"ProductName","width":"150px","header":"Product Name","resizable":true,"searchable":true,"selectable":true,"key":"ProductName","columnGroup":false,"disableHiding":false,"disablePinning":false}]'; + const newChildGridColumns = '[{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"testCss","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":true,"dataType":"number","hasSummary":false,"field":"ID","width":"150px","header":"Product ID","resizable":true,"searchable":false,"selectable":true,"key":"ID","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":true,"dataType":"string","hasSummary":false,"field":"ProductName","width":"150px","header":"Product Name","resizable":true,"searchable":true,"selectable":true,"key":"ProductName","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":true,"sortable":false,"filterable":true,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"boolean","hasSummary":true,"field":"Col1","width":"140px","header":"Col 1","resizable":true,"searchable":true,"selectable":true,"key":"Col1","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":false,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":true,"dataType":"date","hasSummary":false,"field":"Col2","width":"110px","header":"Col 2","resizable":false,"searchable":true,"selectable":true,"key":"Col2","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":false,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"date","hasSummary":false,"field":"Col3","width":"110px","header":"Col 3","resizable":false,"searchable":true,"selectable":true,"key":"Col3","columnGroup":false,"disableHiding":false,"disablePinning":false}]'; + const newColumnsState = HelperFunctions.buildStateString(grid, 'columns', newRootGridColumns, newChildGridColumns); + fix.detectChanges(); + let gridState = state.getState(true, ['columns', 'rowIslands']); + expect(gridState).toBe(initialState); + + state.setState(newColumnsState); + tick(); + fix.detectChanges(); + + gridState = state.getState(false, ['columns', 'rowIslands']) as IGridState; + HelperFunctions.verifyColumns(JSON.parse(newColumnsState).columns, gridState); + const gridsCollection = HelperFunctions.getChildGridsCollection(grid, gridState as IGridState); + gridsCollection.forEach(childGrid => { + const childGridNewState = HelperFunctions.getChildGridState(JSON.parse(newColumnsState), childGrid.rowIslandID, childGrid.parentRowID); + const childGridNewColumnsState = childGridNewState ? childGridNewState.columns : []; + HelperFunctions.verifyColumns(childGridNewColumnsState, childGrid.state); + }); + + gridState = state.getState(true, ['columns', 'rowIslands']); + expect(gridState).toBe(newColumnsState); + })); +}); + +class HelperFunctions { + public static verifyColumns(columns: IColumnState[], gridState: IGridState) { + columns.forEach((c, index) => { + expect(gridState.columns[index]).toEqual(jasmine.objectContaining(c)); + }); + } + + public static verifySortingExpressions(sortingExpressions: ISortingExpression[], gridState: IGridState) { + sortingExpressions.forEach((expr, i) => { + expect(expr).toEqual(jasmine.objectContaining(gridState.sorting[i])); + }); + } + + public static verifyGroupingExpressions(groupingExpressions: IGroupingExpression[], gridState: IGridState) { + groupingExpressions.forEach((expr, i) => { + expect(expr).toEqual(jasmine.objectContaining(gridState.groupBy.expressions[i])); + }); + } + + public static verifyFilteringExpressions(expressions: IFilteringExpressionsTree, gridState: IGridState) { + expect(expressions.fieldName).toBe(gridState.filtering.fieldName, 'Filtering expression field name is not correct'); + expect(expressions.operator).toBe(gridState.filtering.operator, 'Filtering expression operator value is not correct'); + expressions.filteringOperands.forEach((expr, i) => { + expect(expr).toEqual(jasmine.objectContaining(gridState.filtering.filteringOperands[i])); + }); + } + + public static verifyAdvancedFilteringExpressions(expressions: IFilteringExpressionsTree, gridState: IGridState) { + if (gridState.advancedFiltering) { + expect(expressions.fieldName).toBe(gridState.advancedFiltering.fieldName, 'Filtering expression field name is not correct'); + expect(expressions.operator).toBe(gridState.advancedFiltering.operator, 'Filtering expression operator value is not correct'); + expressions.filteringOperands.forEach((expr, i) => { + expect(expr).toEqual(jasmine.objectContaining(gridState.advancedFiltering.filteringOperands[i])); + }); + } else { + expect(expressions).toBeFalsy(); + } + } + + public static verifyExpansionStates(expansion: Map, gridState: IGridState) { + const gridExpansion = new Map(gridState.expansion); + expansion.forEach((value, key) => { + expect(value).toBe(gridExpansion.get(key)); + }); + } + + public static getChildGridsCollection(grid, state) { + const gridStatesCollection = []; + const rowIslands = (grid as any).allLayoutList; + if (rowIslands) { + rowIslands.forEach(rowIslandComponent => { + const childGrids = rowIslandComponent.rowIslandAPI.getChildGrids(); + + childGrids.forEach(chGrid => { + const parentRowId = this.getParentRowID(chGrid); + const rowIslandState = state.rowIslands.find(st => st.id === rowIslandComponent.id && st.parentRowID === parentRowId); + if (rowIslandState) { + const childGridState = { grid: chGrid, state: rowIslandState.state, rowIslandID: rowIslandState.id, parentRowID: parentRowId}; + gridStatesCollection.push(childGridState); + } + }); + }); + } + return gridStatesCollection; + } + + public static getChildGridState(state, rowIslandID, parentRowID): any { + const childGridState = state.rowIslands?.find( + st => st.id === rowIslandID && st.parentRowID === parentRowID + ); + return childGridState?.state; + } + + public static verifyPaging(paging: IPagingState, gridState: IGridState) { + expect(paging).toEqual(jasmine.objectContaining(gridState.paging)); + } + + public static verifyMoving(moving: boolean, gridState: IGridState){ + expect(moving).toEqual(gridState.moving); + } + + public static verifyRowSelection(selectedRows: any[], gridState: IGridState) { + gridState.rowSelection.forEach((s, index) => { + expect(s).toBe(selectedRows[index]); + }); + } + + public static verifyCellSelection(selectedCells: GridSelectionRange[], gridState: IGridState) { + selectedCells.forEach((expr, i) => { + expect(expr).toEqual(jasmine.objectContaining(gridState.cellSelection[i])); + }); + } + + + public static buildStateString(grid: IgxHierarchicalGridComponent, feature: string, level0State: string, level1State: string, level2State?: string): string { + const level0featureState = this.buildFeatureString(feature, level0State); + const level1featureState = this.buildFeatureString(feature, level1State); + const level2featureState = level2State ? this.buildFeatureString(feature, level2State) : this.buildFeatureString(feature, level1State); + const rowIslandsString = this.buildRowIslandsString(grid.allLayoutList, level1featureState, level2featureState); + const state = `{${level0featureState},${rowIslandsString}`; + return state; + } + + public static buildFeatureString(feature: string, stateString: string): string { + const state = `"${feature}":${stateString}`; + return state; + } + + public static buildRowIslandsString(rowIslands, level1State: string, level2State: string): string { + const rowIslandsStates = []; + rowIslands.forEach(rowIsland => { + const featureState = rowIsland.level === 1 ? level1State : level2State; + const childGrids = rowIsland.rowIslandAPI.getChildGrids(); + childGrids.forEach(chGrid => { + const parentRowId = this.getParentRowID(chGrid); + rowIslandsStates.push(this.buildRowIslandStateString(rowIsland.id, parentRowId, featureState)); + }); + }); + const rowIslandsState = rowIslandsStates.join(','); + const state = `"rowIslands":[${rowIslandsState}]}`; + return state; + } + + public static buildRowIslandStateString(rowIslandId: string, parentRowId: string, featureState): string { + const state = `{"id":"${rowIslandId}","parentRowID":"${parentRowId}","state":{${featureState},"rowIslands":[]}}`; + return state; + } + + private static getParentRowID(grid: IgxHierarchicalGridComponent) { + let childGrid; + while (grid.parent) { + childGrid = grid; + grid = grid.parent; + } + return grid.gridAPI.getParentRowId(childGrid); + } +} + +@Component({ + template: ` + + @for (c of columns; track c.field) { + + + } + + + @for (c of childColumns; track c.field) { + + + } + + + + + + `, + imports: [IgxHierarchicalGridComponent, IgxColumnComponent, IgxPaginatorComponent, IgxRowIslandComponent, IgxGridStateDirective] +}) +export class IgxHierarchicalGridTestExpandedBaseComponent { + @ViewChild('hGrid', { read: IgxHierarchicalGridComponent, static: true }) public hgrid: IgxHierarchicalGridComponent; + @ViewChild('rowIsland', { read: IgxRowIslandComponent, static: true }) public rowIsland: IgxRowIslandComponent; + @ViewChild('rowIsland2', { read: IgxRowIslandComponent, static: true }) public rowIsland2: IgxRowIslandComponent; + @ViewChild(IgxGridStateDirective, { static: true }) public state: IgxGridStateDirective; + + public data; + public options = { + filtering: false, + advancedFiltering: true, + sorting: false, + groupBy: true, + moving: true + }; + + public columns: any[] = [ + { field: 'ID', header: 'ID', width: '150px', dataType: 'number', pinned: true, sortable: true, filterable: true, groupable: false, hasSummary: false, hidden: false, maxWidth: '300px', searchable: false, sortingIgnoreCase: true, filteringIgnoreCase: true, editable: false, headerClasses: 'testCss', headerGroupClasses: '', resizable: true }, + { field: 'ProductName', header: 'Product Name', width: '150px', dataType: 'string', pinned: false, sortable: true, filterable: true, groupable: true, hasSummary: false, hidden: false, maxWidth: '300px', searchable: true, sortingIgnoreCase: true, filteringIgnoreCase: true, editable: false, headerClasses: '', headerGroupClasses: '', resizable: true } + ]; + + public childColumns: any[] = [ + { field: 'ID', header: 'Product ID', width: '150px', dataType: 'number', pinned: true, sortable: true, filterable: true, groupable: false, hasSummary: false, hidden: false, maxWidth: '300px', searchable: false, sortingIgnoreCase: true, filteringIgnoreCase: true, editable: false, headerClasses: 'testCss', headerGroupClasses: '', resizable: true }, + { field: 'ProductName', header: 'Product Name', width: '150px', dataType: 'string', pinned: false, sortable: true, filterable: true, groupable: true, hasSummary: false, hidden: false, maxWidth: '300px', searchable: true, sortingIgnoreCase: true, filteringIgnoreCase: true, editable: false, headerClasses: '', headerGroupClasses: '', resizable: true }, + { field: 'Col1', header: 'Col 1', width: '140px', dataType: 'boolean', pinned: false, sortable: false, filterable: true, groupable: false, hasSummary: true, hidden: false, maxWidth: '300px', searchable: true, sortingIgnoreCase: true, filteringIgnoreCase: true, editable: true, headerClasses: '', headerGroupClasses: '', resizable: true }, + { field: 'Col2', header: 'Col 2', width: '110px', dataType: 'date', pinned: false, sortable: true, filterable: false, groupable: true, hasSummary: false, hidden: false, maxWidth: '300px', searchable: true, sortingIgnoreCase: true, filteringIgnoreCase: true, editable: true, headerClasses: '', headerGroupClasses: '', resizable: false }, + { field: 'Col3', header: 'Col 3', width: '110px', dataType: 'date', pinned: false, sortable: true, filterable: false, groupable: true, hasSummary: false, hidden: false, maxWidth: '300px', searchable: true, sortingIgnoreCase: true, filteringIgnoreCase: true, editable: true, headerClasses: '', headerGroupClasses: '', resizable: false }, + ]; + + constructor() { + // 3 level hierarchy + this.data = generateDataUneven(20, 3); + } +} + +export const generateDataUneven = (count: number, level: number, parentID: string = null) => { + const prods = []; + const currLevel = level; + let children; + for (let i = 0; i < count; i++) { + const rowID = parentID ? parentID + i : i.toString(); + if (level > 0 ) { + // Have child grids for row with even id less rows by not multiplying by 2 + children = generateDataUneven((i % 2 + 1) * Math.round(count / 3) , currLevel - 1, rowID); + } + prods.push({ + ID: rowID, ChildLevels: currLevel, ProductName: 'Product: A' + i, Col1: i, + Col2: i, Col3: i, childData: children, childData2: children }); + } + return prods; +}; diff --git a/projects/igniteui-angular/grids/core/src/state.pivotgrid.spec.ts b/projects/igniteui-angular/grids/core/src/state.pivotgrid.spec.ts new file mode 100644 index 00000000000..0d399eb6624 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/state.pivotgrid.spec.ts @@ -0,0 +1,309 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { first, take } from 'rxjs/operators'; +import { IgxPivotGridPersistanceComponent } from '../../../test-utils/pivot-grid-samples.spec'; +import { NoopPivotDimensionsStrategy } from './common/pivot-strategy'; +import { IgxPivotNumericAggregate } from './pivot-grid-aggregate'; +import { IPivotDimension, IPivotGridRecord } from './pivot-grid.interface'; +import { IgxPivotRowDimensionHeaderComponent } from 'igniteui-angular/grids/pivot-grid/src/pivot-row-dimension-header.component'; +import { IgxPivotDateDimension } from './pivot-grid-dimensions'; +import { IgxGridNavigationService } from './grid-navigation.service'; + +describe('IgxPivotGridState #pivotGrid :', () => { + let fixture; + let pivotGrid; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, IgxPivotGridPersistanceComponent], + providers: [ + IgxGridNavigationService + ] + }).compileComponents(); + })); + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(IgxPivotGridPersistanceComponent); + fixture.detectChanges(); + pivotGrid = fixture.componentInstance.pivotGrid; + })); + + it('getState should return correct JSON string.', () => { + const state = fixture.componentInstance.state; + expect(state).toBeDefined('IgxGridState directive is initialized'); + const jsonString = state.getState(true); + const expectedObj = { + "columns": [ + { "pinned": false, "sortable": true, "filterable": true, "sortingIgnoreCase": true, "filteringIgnoreCase": true, "headerClasses": "", "headerGroupClasses": "", "groupable": false, "hidden": false, "dataType": "number", "hasSummary": false, "field": "Bulgaria", "width": "220px", "header": "Bulgaria", "resizable": false, "searchable": true, "selectable": true, "key": "Bulgaria", "columnGroup": false, "disableHiding": false, "disablePinning": false }, + { "pinned": false, "sortable": true, "filterable": true, "sortingIgnoreCase": true, "filteringIgnoreCase": true, "headerClasses": "", "headerGroupClasses": "", "groupable": false, "hidden": false, "dataType": "number", "hasSummary": false, "field": "US", "width": "220px", "header": "US", "resizable": false, "searchable": true, "selectable": true, "key": "US", "columnGroup": false, "disableHiding": false, "disablePinning": false }, + { "pinned": false, "sortable": true, "filterable": true, "sortingIgnoreCase": true, "filteringIgnoreCase": true, "headerClasses": "", "headerGroupClasses": "", "groupable": false, "hidden": false, "dataType": "number", "hasSummary": false, "field": "Uruguay", "width": "220px", "header": "Uruguay", "resizable": false, "searchable": true, "selectable": true, "key": "Uruguay", "columnGroup": false, "disableHiding": false, "disablePinning": false }, + { "pinned": false, "sortable": true, "filterable": true, "sortingIgnoreCase": true, "filteringIgnoreCase": true, "headerClasses": "", "headerGroupClasses": "", "groupable": false, "hidden": false, "dataType": "number", "hasSummary": false, "field": "UK", "width": "220px", "header": "UK", "resizable": false, "searchable": true, "selectable": true, "key": "UK", "columnGroup": false, "disableHiding": false, "disablePinning": false }, + { "pinned": false, "sortable": true, "filterable": true, "sortingIgnoreCase": true, "filteringIgnoreCase": true, "headerClasses": "", "headerGroupClasses": "", "groupable": false, "hidden": false, "dataType": "number", "hasSummary": false, "field": "Japan", "width": "220px", "header": "Japan", "resizable": false, "searchable": true, "selectable": true, "key": "Japan", "columnGroup": false, "disableHiding": false, "disablePinning": false } + ], + "filtering": { "filteringOperands": [], "operator": 0, "type": 0 }, + "advancedFiltering": {}, "sorting": [], "cellSelection": [], "rowSelection": [], "columnSelection": [], + "expansion": [], "moving": false, "rowIslands": [], + "pivotConfiguration": { + "columns": [ + { "memberName": "Country", "enabled": true, "level": 0 } + ], "rows": [ + { "memberName": "City", "enabled": true, "level": 0 }, + { "memberName": "ProductCategory", "enabled": true, "level": 0 }], + "values": [{ + "member": "UnitsSold", "aggregate": { "key": "SUM", "label": "Sum" }, + "enabled": true + }], + "filters" : [] + } + }; + + expect(jsonString).toBe(JSON.stringify(expectedObj)); + }); + + it('getState should return correct pivot configuration state.', () => { + const state = fixture.componentInstance.state; + const jsonString = state.getState(true, 'pivotConfiguration'); + const jsonObj = JSON.parse(jsonString); + expect(jsonObj.pivotConfiguration).toBeDefined(); + expect(jsonObj.pivotConfiguration.rows.length).toEqual(pivotGrid.pivotConfiguration.rows.length); + expect(jsonObj.pivotConfiguration.columns.length).toEqual(pivotGrid.pivotConfiguration.columns.length); + expect(jsonObj.pivotConfiguration.values.length).toEqual(pivotGrid.pivotConfiguration.values.length); + + // json string object cannot contain functions + expect(jsonObj.pivotConfiguration.values[0].aggregate.aggregator).toBeUndefined(); + }); + + it('setState should correctly restore simple configuration state from string.', () => { + const state = fixture.componentInstance.state; + const stateToRestore = `{ "pivotConfiguration" : { + "columns": [ + { "memberName": "ProductCategory", "enabled": true } + ], + "rows": [ + { "memberName": "City", "enabled": true }, + { "memberName": "Country", "enabled": true } + ], + "values": [ + { "member": "UnitsSold", "aggregate": { "key": "SUM", "label": "Sum" }, + "enabled": true + } + ] + } + }`; + + state.setState(stateToRestore, 'pivotConfiguration'); + fixture.detectChanges(); + expect(pivotGrid.pivotConfiguration.rows.length).toBe(2); + expect(pivotGrid.pivotConfiguration.rows[0].memberName).toBe('City'); + expect(pivotGrid.pivotConfiguration.rows[1].memberName).toBe('Country'); + + expect(pivotGrid.pivotConfiguration.columns.length).toBe(1); + expect(pivotGrid.pivotConfiguration.columns[0].memberName).toBe('ProductCategory'); + + expect(pivotGrid.pivotConfiguration.values.length).toBe(1); + expect(pivotGrid.pivotConfiguration.values[0].member).toBe('UnitsSold'); + expect(pivotGrid.pivotConfiguration.values[0].aggregate.aggregator).toBe(IgxPivotNumericAggregate.sum); + + expect(pivotGrid.columns.length).toBe(4); + expect(pivotGrid.rowList.length).toBe(6); + }); + + it('setState should correctly restore value sorting state from string.', () => { + const state = fixture.componentInstance.state; + const sortState = '{ "sorting" : [{"fieldName":"US","dir":2,"ignoreCase":true}] }'; + state.setState(sortState, 'sorting'); + fixture.detectChanges(); + + // check column is sorted + expect(pivotGrid.sortingExpressions.length).toBe(1); + const expectedOrder = [296, undefined, undefined, undefined, undefined, undefined, undefined]; + const columnValues = pivotGrid.dataView.map(x => (x as IPivotGridRecord).aggregationValues.get('US')); + expect(columnValues).toEqual(expectedOrder); + + }); + + it('setState should correctly restore dimension sorting state from string.', () => { + const state = fixture.componentInstance.state; + const stateToRestore = `{ "pivotConfiguration" : { + "columns": [ + { "memberName": "ProductCategory", "enabled": true, "sortDirection": 1 } + ], + "rows": [ + { "memberName": "City", "enabled": true, "sortDirection": 2 } + ], + "values": [ + { "member": "UnitsSold", "aggregate": { "key": "SUM", "label": "Sum" }, + "enabled": true + } + ] + } + }`; + + state.setState(stateToRestore, 'pivotConfiguration'); + fixture.detectChanges(); + + // check sorting + + // rows + const expectedOrder = ['Yokohama', 'Sofia', 'Plovdiv', 'New York', 'London', 'Ciudad de la Costa']; + const rowHeaders = fixture.debugElement.queryAll( + By.directive(IgxPivotRowDimensionHeaderComponent)); + const rowDimensionHeaders = rowHeaders.map(x => x.componentInstance.column.header); + expect(rowDimensionHeaders).toEqual(expectedOrder); + + // columns + const colHeaders = pivotGrid.columns.filter(x => x.level === 0).map(x => x.header); + const expected = ['Accessories', 'Bikes', 'Clothing', 'Components']; + expect(colHeaders).toEqual(expected); + }); + + it('setState should correctly restore excel style filtering.', () => { + const state = fixture.componentInstance.state; + const stateToRestore = `{ "pivotConfiguration" : { + "columns": [ + { "memberName": "ProductCategory", "enabled": true } + ], + "rows": [ + { "memberName": "City", "enabled": true, + "filter" : { + "filteringOperands":[ + { + "filteringOperands":[ + { + "condition": {"name":"in","isUnary":false,"iconName":"is-in","hidden":true}, + "fieldName":"City","ignoreCase":true,"searchVal":["Sofia"] + } + ], + "operator":1,"fieldName":"City" + }], + "operator":0 + } + } + ], + "values": [ + { "member": "UnitsSold", "aggregate": { "key": "SUM", "label": "Sum" }, + "enabled": true + } + ] + } + }`; + + // set filtering + state.setState(stateToRestore, 'pivotConfiguration'); + fixture.detectChanges(); + + const rowHeaders = fixture.debugElement.queryAll( + By.directive(IgxPivotRowDimensionHeaderComponent)); + const rowDimensionHeaders = rowHeaders.map(x => x.componentInstance.column.header); + expect(rowDimensionHeaders).toEqual(['Sofia']); + }); + + it('should successfully restore the IgxPivotDateDimension.', () => { + const state = fixture.componentInstance.state; + pivotGrid.pivotConfiguration.rows = [ + new IgxPivotDateDimension( + { + memberName: 'Date', + enabled: true + }, + { + months: true, + quarters: false, + years: true + } + ) + ]; + pivotGrid.pipeTrigger++; + fixture.detectChanges(); + const stateString = state.getState(true, 'pivotConfiguration'); + state.setState(stateString, 'pivotConfiguration'); + fixture.detectChanges(); + + const rows = pivotGrid.rowList.toArray(); + expect(rows.length).toBe(1); + const expectedHeaders = ['All Periods']; + const rowHeaders = fixture.debugElement.queryAll( + By.directive(IgxPivotRowDimensionHeaderComponent)); + const rowDimensionHeaders = rowHeaders.map(x => x.componentInstance.column.header); + expect(rowDimensionHeaders).toEqual(expectedHeaders); + }); + + it('should successfully restore the selected rows.', () => { + pivotGrid.rowSelection = 'single'; + const state = fixture.componentInstance.state; + expect(state).toBeDefined('IgxGridState directive is initialized'); + const headerRow = fixture.nativeElement.querySelectorAll('igx-pivot-row-dimension-content')[2]; + const header = headerRow.querySelector('igx-pivot-row-dimension-header'); + header.click(); + fixture.detectChanges(); + expect(pivotGrid.selectedRows.length).toBe(2); + const jsonString = state.getState(true); + // clear + pivotGrid. selectionService.rowSelection.clear(); + expect(pivotGrid.selectedRows.length).toBe(0); + // set old state + state.setState(jsonString); + fixture.detectChanges(); + expect(pivotGrid.selectedRows.length).toBe(2); + }); + + it('should allow setting back custom functions on init.', async() => { + const state = fixture.componentInstance.state; + const customFunc = () => 'All'; + pivotGrid.pivotConfiguration.rows = [ + { + memberName: 'All', + memberFunction: customFunc, + enabled: true, + childLevel: { memberName: "ProductCategory", enabled: true } + } + ]; + pivotGrid.pipeTrigger++; + fixture.detectChanges(); + pivotGrid.dimensionInit.pipe(first()).subscribe((dim: IPivotDimension) => { + if (dim.memberName === 'All') { + dim.memberFunction = customFunc; + } + }); + const stateString = state.getState(true, 'pivotConfiguration'); + state.setState(stateString, 'pivotConfiguration'); + fixture.detectChanges(); + const rows = pivotGrid.rowList.toArray(); + expect(rows.length).toBe(1); + const expectedHeaders = ['All']; + const rowHeaders = fixture.debugElement.queryAll( + By.directive(IgxPivotRowDimensionHeaderComponent)); + const rowDimensionHeaders = rowHeaders.map(x => x.componentInstance.column.header); + expect(rowDimensionHeaders).toEqual(expectedHeaders); + }); + + it('should allow restoring noop strategies', () => { + const noopInstance = NoopPivotDimensionsStrategy.instance(); + pivotGrid.pivotConfiguration.rowStrategy = noopInstance; + pivotGrid.pivotConfiguration.columnStrategy = noopInstance; + pivotGrid.data = []; + const state = fixture.componentInstance.state; + state.stateParsed.pipe(take(1)).subscribe(parsedState => { + parsedState.pivotConfiguration.rowStrategy = noopInstance; + parsedState.pivotConfiguration.columnStrategy = noopInstance; + }); + const stateToRestore = `{ "pivotConfiguration" : { + "columns": [ + { "memberName": "ProductCategory", "enabled": true } + ], + "rows": [ + { "memberName": "City", "enabled": true }, + { "memberName": "Country", "enabled": true } + ], + "values": [ + ] + } + }`; + + state.setState(stateToRestore, 'pivotConfiguration'); + fixture.detectChanges(); + fixture.detectChanges(); + expect(pivotGrid.pivotConfiguration.rowStrategy).toBe(noopInstance); + expect(pivotGrid.pivotConfiguration.columnStrategy).toBe(noopInstance); + }); +}); diff --git a/projects/igniteui-angular/grids/core/src/state.treegrid.spec.ts b/projects/igniteui-angular/grids/core/src/state.treegrid.spec.ts new file mode 100644 index 00000000000..d18e972ab3d --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/state.treegrid.spec.ts @@ -0,0 +1,384 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { Component, ViewChild } from '@angular/core'; +import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; +import { IgxGridStateDirective } from './state.directive'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IGroupingExpression } from '../../../core/src/data-operations/grouping-expression.interface'; +import { FilteringExpressionsTree, IFilteringExpressionsTree } from '../../../core/src/data-operations/filtering-expressions-tree'; +import { IPagingState } from '../../../core/src/data-operations/paging-state.interface'; +import { IgxNumberFilteringOperand } from '../../../core/src/data-operations/filtering-condition'; +import { IGroupingState } from '../../../core/src/data-operations/groupby-state.interface'; +import { IGroupByExpandState } from '../../../core/src/data-operations/groupby-expand-state.interface'; +import { GridSelectionMode } from './common/enums'; +import { FilteringLogic } from '../../../core/src/data-operations/filtering-expression.interface'; +import { ISortingExpression } from '../../../core/src/data-operations/sorting-strategy'; +import { GridSelectionRange } from './common/types'; +import { IgxPaginatorComponent } from 'igniteui-angular/paginator'; +import { IgxColumnComponent } from './public_api'; +import { IColumnState, IGridState } from './state-base.directive'; +import { IgxTreeGridComponent } from 'igniteui-angular/grids/tree-grid'; + +describe('IgxTreeGridState - input properties #tGrid', () => { + let fix; + let grid; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, IgxTreeGridTreeDataTestComponent] + }).compileComponents(); + })); + + beforeEach(waitForAsync(() => { + fix = TestBed.createComponent(IgxTreeGridTreeDataTestComponent); + fix.detectChanges(); + grid = fix.componentInstance.treeGrid; + })); + + it('should initialize an IgxGridState with default options object', () => { + const defaultOptions = { + columns: true, + filtering: true, + advancedFiltering: true, + sorting: true, + paging: true, + cellSelection: true, + rowSelection: true, + columnSelection: true, + rowIslands: true, + rowPinning: true, + expansion: true, + moving: true + }; + + fix.detectChanges(); + + const state = fix.componentInstance.state; + + expect(state).toBeDefined('IgxGridState directive is initialized'); + expect(state.options).toEqual(jasmine.objectContaining(defaultOptions)); + }); + + it('getState should return correct IGridState object when options are not default', () => { + const options = { + sorting: false, + paging: false, + moving: false + }; + fix.detectChanges(); + const state = fix.componentInstance.state; + state.options = options; + fix.detectChanges(); + + let gridState = state.getState(false) as IGridState; + expect(gridState['sorting']).toBeFalsy(); + expect(gridState['groupBy']).toBeFalsy(); + expect(gridState['moving']).toBeFalsy(); + + gridState = state.getState(false, ['filtering', 'sorting', 'groupBy']) as IGridState; + expect(gridState['sorting']).toBeFalsy(); + expect(gridState['groupBy']).toBeFalsy(); + expect(gridState['moving']).toBeFalsy(); + }); + + it('getState should return correct JSON string', () => { + const initialGridState = '{"columns":[{"pinned":true,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"testCss","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"number","hasSummary":false,"field":"ID","width":"150px","header":"ID","resizable":true,"searchable":false,"selectable":true,"key":"ID","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"string","hasSummary":false,"field":"Name","width":"150px","header":"Name","resizable":true,"searchable":true,"selectable":true,"key":"Name","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":false,"filterable":true,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"date","hasSummary":true,"field":"Hire Date","width":"140px","header":"Hire Date","resizable":true,"searchable":true,"selectable":true,"key":"Hire Date","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":true,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"number","hasSummary":false,"field":"Age","width":"110px","header":"Age","resizable":false,"searchable":true,"selectable":true,"key":"Age","columnGroup":false,"disableHiding":false,"disablePinning":false}],"filtering":{"filteringOperands":[],"operator":0},"advancedFiltering":{},"sorting":[],"paging":{"index":0,"recordsPerPage":5,"metadata":{"countPages":4,"countRecords":18,"error":0}},"cellSelection":[],"rowSelection":[],"columnSelection":[],"rowPinning":[],"expansion":[],"moving":true,"rowIslands":[]}'; + fix.detectChanges(); + + const state = fix.componentInstance.state; + + const gridState = state.getState(); + expect(gridState).toBe(initialGridState, 'JSON string representation of the initial grid state is not correct'); + }); + + it('getState should return correct IGridState object when using default options', () => { + fix.detectChanges(); + const state = fix.componentInstance.state; + + const gridFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + const productFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And, 'Age'); + const productExpression = { + condition: IgxNumberFilteringOperand.instance().condition('greaterThan'), + conditionName: 'greaterThan', + fieldName: 'Age', + ignoreCase: true, + searchVal: 35 + }; + productFilteringExpressionsTree.filteringOperands.push(productExpression); + gridFilteringExpressionsTree.filteringOperands.push(productFilteringExpressionsTree); + grid.filteringExpressionsTree = gridFilteringExpressionsTree; + fix.detectChanges(); + + const paging = grid.pagingState; + const sorting = grid.sortingExpressions; + const filtering = grid.filteringExpressionsTree; + + const gridState = state.getState(false) as IGridState; + HelperFunctions.verifyPaging(paging, gridState); + HelperFunctions.verifySortingExpressions(sorting, gridState); + HelperFunctions.verifyFilteringExpressions(filtering, gridState); + }); + + it('getState should return correct filtering state', () => { + fix.detectChanges(); + const state = fix.componentInstance.state; + const filtering = grid.filteringExpressionsTree; + + let gridState = state.getState(true, 'filtering'); + expect(gridState).toBe('{"filtering":{"filteringOperands":[],"operator":0}}', 'JSON string'); + + gridState = state.getState(false, ['filtering']) as IGridState; + HelperFunctions.verifyFilteringExpressions(filtering, gridState); + }); + + it('setState should correctly restore grid filtering state from string', () => { + fix.detectChanges(); + const state = fix.componentInstance.state; + const filteringState = '{"filtering":{"filteringOperands":[{"filteringOperands":[{"condition":{"name":"greaterThan","isUnary":false,"iconName":"filter_greater_than"},"searchVal":35,"fieldName":"Age","ignoreCase":true,"conditionName":"greaterThan"}],"operator":0,"fieldName":"Age"}],"operator":0,"type":0}}'; + const initialState = '{"filtering":{"filteringOperands":[],"operator":0}}'; + + let gridState = state.getState(true, 'filtering'); + expect(gridState).toBe(initialState); + + state.setState(filteringState); + gridState = state.getState(false, 'filtering') as IGridState; + HelperFunctions.verifyFilteringExpressions(grid.filteringExpressionsTree, gridState); + gridState = state.getState(true, 'filtering'); + expect(gridState).toBe(filteringState); + }); + + it('getState should return correct moving state', () => { + fix.detectChanges(); + const state = fix.componentInstance.state; + const moving = grid.moving; + + let gridState = state.getState(true, 'moving'); + expect(gridState).toBe('{"moving":true}', 'JSON string'); + + gridState = state.getState(false, ['moving']) as IGridState; + HelperFunctions.verifyMoving(moving, gridState); + }); + + it('setState should correctly restore grid moving state from string', () => { + fix.detectChanges(); + const state = fix.componentInstance.state; + const movingState = '{"moving":false}'; + const initialState = '{"moving":true}'; + + let gridState = state.getState(true, 'moving'); + expect(gridState).toBe(initialState); + + state.setState(movingState); + gridState = state.getState(false, 'moving') as IGridState; + HelperFunctions.verifyMoving(grid.moving, gridState); + gridState = state.getState(true, 'moving'); + expect(gridState).toBe(movingState); + }); + + it('setState should correctly restore grid sorting state from string', () => { + fix.detectChanges(); + const state = fix.componentInstance.state; + const sortingState = '{"sorting":[{"fieldName":"HireDate","dir":1,"ignoreCase":true}]}'; + const initialState = '{"sorting":[]}'; + + let gridState = state.getState(true, 'sorting'); + expect(gridState).toBe(initialState); + + state.setState(sortingState); + gridState = state.getState(false, 'sorting') as IGridState; + HelperFunctions.verifySortingExpressions(grid.sortingExpressions, gridState); + gridState = state.getState(true, 'sorting'); + expect(gridState).toBe(sortingState); + }); + + it('setState should correctly restore grid columns state from string', () => { + fix.detectChanges(); + const state = fix.componentInstance.state; + const initialColumnsState = '{"columns":[{"pinned":true,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"testCss","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"number","hasSummary":false,"field":"ID","width":"150px","header":"ID","resizable":true,"searchable":false,"selectable":true,"key":"ID","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"string","hasSummary":false,"field":"Name","width":"150px","header":"Name","resizable":true,"searchable":true,"selectable":true,"key":"Name","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":false,"filterable":true,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"date","hasSummary":true,"field":"Hire Date","width":"140px","header":"Hire Date","resizable":true,"searchable":true,"selectable":true,"key":"Hire Date","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":true,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"number","hasSummary":false,"field":"Age","width":"110px","header":"Age","resizable":false,"searchable":true,"selectable":true,"key":"Age","columnGroup":false,"disableHiding":false,"disablePinning":false}]}'; + const newColumnsState = '{"columns":[{"pinned":false,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"testCss","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"number","hasSummary":false,"field":"ID","width":"150px","header":"ID","resizable":true,"searchable":false,"selectable":true,"key":"ID","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":true,"sortable":true,"filterable":true,"editable":false,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"string","hasSummary":false,"field":"Name","width":"150px","header":"Name","resizable":true,"searchable":true,"selectable":true,"key":"Name","columnGroup":false,"disableHiding":true,"disablePinning":false},{"pinned":false,"sortable":true,"filterable":true,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":true,"hidden":false,"dataType":"number","hasSummary":false,"field":"Age","width":"110px","header":"Age","resizable":false,"searchable":true,"selectable":true,"key":"Age","columnGroup":false,"disableHiding":false,"disablePinning":false},{"pinned":false,"sortable":false,"filterable":true,"editable":true,"sortingIgnoreCase":true,"filteringIgnoreCase":true,"headerClasses":"","headerGroupClasses":"","maxWidth":"300px","groupable":false,"hidden":false,"dataType":"date","hasSummary":true,"field":"Hire Date","width":"140px","header":"Hire Date","resizable":true,"searchable":true,"selectable":true,"key":"Hire Date","columnGroup":false,"disableHiding":false,"disablePinning":false}]}'; + const columns = JSON.parse(newColumnsState).columns; + + let gridState = state.getState(true, 'columns'); + expect(gridState).toBe(initialColumnsState); + + state.setState(newColumnsState); + gridState = state.getState(false, 'columns') as IGridState; + HelperFunctions.verifyColumns(columns, gridState); + gridState = state.getState(true, 'columns'); + expect(gridState).toBe(newColumnsState); + }); + + it('setState should correctly restore grid paging state from string', () => { + fix.detectChanges(); + const state = fix.componentInstance.state; + const pagingState = '{"paging":{"index":0,"recordsPerPage":15,"metadata":{"countPages":2,"countRecords":18,"error":0}}}'; + const initialState = '{"paging":{"index":0,"recordsPerPage":5,"metadata":{"countPages":4,"countRecords":18,"error":0}}}'; + + let gridState = state.getState(true, 'paging'); + expect(gridState).toBe(initialState); + + state.setState(pagingState); + gridState = state.getState(false, 'paging'); + HelperFunctions.verifyPaging(grid.pagingState, gridState as IGridState); + gridState = state.getState(true, 'paging'); + expect(gridState).toBe(pagingState); + }); + + it('setState should correctly restore grid row selection state from string', () => { + fix.detectChanges(); + const state = fix.componentInstance.state; + const rowSelectionState = '{"rowSelection":[1,3,5,6]}'; + const initialState = '{"rowSelection":[]}'; + + let gridState = state.getState(true, 'rowSelection'); + expect(gridState).toBe(initialState); + + state.setState(rowSelectionState); + gridState = state.getState(false, 'rowSelection'); + HelperFunctions.verifyRowSelection(grid.selectedRows, gridState as IGridState); + gridState = state.getState(true, 'rowSelection'); + expect(gridState).toBe(rowSelectionState); + }); + + it('setState should correctly restore grid cell selection state from string', () => { + fix.detectChanges(); + grid.rowSelection = GridSelectionMode.none; + const state = fix.componentInstance.state; + const cellSelectionState = '{"cellSelection":[{"rowStart":0,"rowEnd":2,"columnStart":1,"columnEnd":3}]}'; + const initialState = '{"cellSelection":[]}'; + + let gridState = state.getState(true, 'cellSelection'); + expect(gridState).toBe(initialState); + + state.setState(cellSelectionState); + gridState = state.getState(false, 'cellSelection'); + HelperFunctions.verifyCellSelection(grid.getSelectedRanges(), gridState as IGridState); + gridState = state.getState(true, 'cellSelection'); + expect(gridState).toBe(cellSelectionState); + }); + + it('setState should correctly restore grid advanced filtering state from string', () => { + fix.detectChanges(); + const state = fix.componentInstance.state; + const advFilteringState = '{"advancedFiltering":{"filteringOperands":[{"fieldName":"Age","condition":{"name":"greaterThan","isUnary":false,"iconName":"filter_greater_than"},"searchVal":25,"ignoreCase":true,"conditionName":"greaterThan"},{"fieldName":"ID","condition":{"name":"greaterThan","isUnary":false,"iconName":"filter_greater_than"},"searchVal":"3","ignoreCase":true,"conditionName":"greaterThan"}],"operator":0,"type":1}}'; + const initialState = '{"advancedFiltering":{}}'; + + let gridState = state.getState(true, 'advancedFiltering'); + expect(gridState).toBe(initialState); + + state.setState(advFilteringState); + gridState = state.getState(false, 'advancedFiltering') as IGridState; + HelperFunctions.verifyAdvancedFilteringExpressions(grid.advancedFilteringExpressionsTree, gridState); + gridState = state.getState(true, 'advancedFiltering'); + expect(gridState).toBe(advFilteringState); + }); +}); + +class HelperFunctions { + public static verifyColumns(columns: IColumnState[], gridState: IGridState) { + columns.forEach((c, index) => { + expect(gridState.columns[index]).toEqual(jasmine.objectContaining(c)); + }); + } + + public static verifySortingExpressions(sortingExpressions: ISortingExpression[], gridState: IGridState) { + sortingExpressions.forEach((expr, i) => { + expect(expr).toEqual(jasmine.objectContaining(gridState.sorting[i])); + }); + } + + public static verifyGroupingExpressions(groupingExpressions: IGroupingExpression[], gridState: IGridState) { + groupingExpressions.forEach((expr, i) => { + expect(expr).toEqual(jasmine.objectContaining(gridState.groupBy.expressions[i])); + }); + } + + public static verifyGroupingExpansion(groupingExpansion: IGroupByExpandState[], groupBy: IGroupingState) { + groupingExpansion.forEach((exp, i) => { + expect(exp).toEqual(jasmine.objectContaining(groupBy.expansion[i])); + }); + } + + public static verifyFilteringExpressions(expressions: IFilteringExpressionsTree, gridState: IGridState) { + expect(expressions.fieldName).toBe(gridState.filtering.fieldName, 'Filtering expression field name is not correct'); + expect(expressions.operator).toBe(gridState.filtering.operator, 'Filtering expression operator value is not correct'); + expressions.filteringOperands.forEach((expr, i) => { + expect(expr).toEqual(jasmine.objectContaining(gridState.filtering.filteringOperands[i])); + }); + } + + public static verifyAdvancedFilteringExpressions(expressions: IFilteringExpressionsTree, gridState: IGridState) { + if (gridState.advancedFiltering) { + expect(expressions.fieldName).toBe(gridState.advancedFiltering.fieldName, 'Filtering expression field name is not correct'); + expect(expressions.operator).toBe(gridState.advancedFiltering.operator, 'Filtering expression operator value is not correct'); + expressions.filteringOperands.forEach((expr, i) => { + expect(expr).toEqual(jasmine.objectContaining(gridState.advancedFiltering.filteringOperands[i])); + }); + } else { + expect(expressions).toBeFalsy(); + } + } + + public static verifyPaging(paging: IPagingState, gridState: IGridState) { + expect(paging).toEqual(jasmine.objectContaining(gridState.paging)); + } + + public static verifyMoving(moving: boolean, gridState: IGridState){ + expect(moving).toEqual(gridState.moving); + } + + public static verifyRowSelection(selectedRows: any[], gridState: IGridState) { + gridState.rowSelection.forEach((s, index) => { + expect(s).toBe(selectedRows[index]); + }); + } + + public static verifyCellSelection(selectedCells: GridSelectionRange[], gridState: IGridState) { + selectedCells.forEach((expr, i) => { + expect(expr).toEqual(jasmine.objectContaining(gridState.cellSelection[i])); + }); + } +} + +@Component({ + template: ` + + + @for (c of columns; track c.field) { + + + } + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent, IgxPaginatorComponent, IgxGridStateDirective] +}) +export class IgxTreeGridTreeDataTestComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + @ViewChild(IgxGridStateDirective, { static: true }) public state: IgxGridStateDirective; + + public columns: any[] = [ + { field: 'ID', header: 'ID', width: '150px', dataType: 'number', pinned: true, sortable: true, filterable: true, groupable: false, hasSummary: false, hidden: false, maxWidth: '300px', searchable: false, sortingIgnoreCase: true, filteringIgnoreCase: true, editable: false, headerClasses: 'testCss', headerGroupClasses: '', resizable: true }, + { field: 'Name', header: 'Name', width: '150px', dataType: 'string', pinned: false, sortable: true, filterable: true, groupable: true, hasSummary: false, hidden: false, maxWidth: '300px', searchable: true, sortingIgnoreCase: true, filteringIgnoreCase: true, editable: false, headerClasses: '', headerGroupClasses: '', resizable: true }, + { field: 'Hire Date', header: 'Hire Date', width: '140px', dataType: 'date', pinned: false, sortable: false, filterable: true, groupable: false, hasSummary: true, hidden: false, maxWidth: '300px', searchable: true, sortingIgnoreCase: true, filteringIgnoreCase: true, editable: true, headerClasses: '', headerGroupClasses: '', resizable: true }, + { field: 'Age', header: 'Age', width: '110px', dataType: 'number', pinned: false, sortable: true, filterable: true, groupable: true, hasSummary: false, hidden: false, maxWidth: '300px', searchable: true, sortingIgnoreCase: true, filteringIgnoreCase: true, editable: true, headerClasses: '', headerGroupClasses: '', resizable: false } + ]; + public data = SampleTestData.employeeTreeData(); +} diff --git a/projects/igniteui-angular/grids/core/src/summaries/grid-root-summary.pipe.ts b/projects/igniteui-angular/grids/core/src/summaries/grid-root-summary.pipe.ts new file mode 100644 index 00000000000..796b960c130 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/summaries/grid-root-summary.pipe.ts @@ -0,0 +1,20 @@ +import { Pipe, PipeTransform, inject } from '@angular/core'; +import { GridType, IGX_GRID_BASE } from '../common/grid.interface'; + +@Pipe({ + name: 'igxGridSummaryDataPipe', + standalone: true +}) +export class IgxSummaryDataPipe implements PipeTransform { + private grid = inject(IGX_GRID_BASE); + + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public transform(id: string, trigger = 0) { + const summaryService = this.grid.summaryService; + return summaryService.calculateSummaries( + summaryService.rootSummaryID, + this.grid.gridAPI.get_summary_data() + ); + } +} diff --git a/projects/igniteui-angular/grids/core/src/summaries/grid-summary.service.ts b/projects/igniteui-angular/grids/core/src/summaries/grid-summary.service.ts new file mode 100644 index 00000000000..2da1fe5ff6c --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/summaries/grid-summary.service.ts @@ -0,0 +1,264 @@ +import { Injectable } from '@angular/core'; +import type { GridType, FlatGridType, TreeGridType } from '../common/grid.interface'; +import { cloneArray, columnFieldPath, DataUtil, type IgxSummaryResult, resolveNestedPath } from 'igniteui-angular/core'; + +/** @hidden */ +@Injectable() +export class IgxGridSummaryService { + public grid: GridType; + public rootSummaryID = 'igxGridRootSummary'; + public summaryHeight = 0; + public maxSummariesLength = 0; + public groupingExpressions = []; + public retriggerRootPipe = 0; + public deleteOperation = false; + + protected summaryCacheMap: Map> = new Map>(); + + public recalculateSummaries() { + this.resetSummaryHeight(); + this.grid.notifyChanges(true); + } + + public clearSummaryCache(args?) { + if (!this.summaryCacheMap.size) { + return; + } + if (!args) { + this.summaryCacheMap.clear(); + if (this.grid?.rootSummariesEnabled) { + this.retriggerRootPipe++; + } + return; + } + if (args.data) { + const rowID = this.grid.primaryKey ? args.data[this.grid.primaryKey] : args.data; + this.removeSummaries(rowID); + } + if (args.rowID !== undefined && args.rowID !== null) { + let columnName = args.cellID ? this.grid.columns.find(col => col.index === args.cellID.columnID).field : undefined; + if (columnName && this.grid.rowEditable) { + return; + } + + const isGroupedColumn = (this.grid as FlatGridType).groupingExpressions && + (this.grid as FlatGridType).groupingExpressions.map(expr => expr.fieldName).indexOf(columnName) !== -1; + if (columnName && isGroupedColumn) { + columnName = undefined; + } + this.removeSummaries(args.rowID, columnName); + } + } + + public removeSummaries(rowID, columnName?) { + this.deleteSummaryCache(this.rootSummaryID, columnName); + if (this.summaryCacheMap.size === 1 && this.summaryCacheMap.has(this.rootSummaryID)) { + return; + } + if (this.grid.type === 'tree') { + if (this.grid.transactions.enabled && this.deleteOperation) { + this.deleteOperation = false; + // TODO: this.removeChildRowSummaries(rowID, columnName); + this.summaryCacheMap.clear(); + return; + } + this.removeAllTreeGridSummaries(rowID, columnName); + } else if (this.grid.type === 'hierarchical') { + if (this.grid.transactions.enabled && this.deleteOperation) { + this.deleteOperation = false; + this.summaryCacheMap.clear(); + } + } else { + const summaryIds = this.getSummaryID(rowID, (this.grid as FlatGridType).groupingExpressions); + summaryIds.forEach(id => { + this.deleteSummaryCache(id, columnName); + }); + } + } + + public removeSummariesCachePerColumn(columnName) { + this.summaryCacheMap.forEach((cache) => { + if (cache.get(columnName)) { + cache.delete(columnName); + } + }); + if (this.grid.rootSummariesEnabled) { + this.retriggerRootPipe++; + } + } + + public calcMaxSummaryHeight() { + if (this.summaryHeight) { + return this.summaryHeight; + } + if (!this.grid.data) { + return this.summaryHeight = 0; + } + let maxSummaryLength = 0; + this.grid.columns.filter((col) => col.hasSummary && !col.hidden).forEach((column) => { + const getCurrentSummary = column.summaries.operate([], [], column.field); + const getCurrentSummaryColumn = column.disabledSummaries.length > 0 + ? getCurrentSummary.filter(s => !column.disabledSummaries.includes(s.key)).length + : getCurrentSummary.length; + + if (maxSummaryLength < getCurrentSummaryColumn) { + maxSummaryLength = getCurrentSummaryColumn; + } + }); + this.maxSummariesLength = maxSummaryLength; + this.summaryHeight = maxSummaryLength * this.grid.defaultSummaryHeight; + return this.summaryHeight; + } + + public calculateSummaries(rowID, data, groupRecord) { + let rowSummaries = this.summaryCacheMap.get(rowID); + if (!rowSummaries) { + rowSummaries = new Map(); + this.summaryCacheMap.set(rowID, rowSummaries); + } + + if (!this.hasSummarizedColumns || !data) { + return rowSummaries; + } + + const columns = this.grid.columns.filter(col => col.hasSummary); + const columnPathParts = columns.map(col => columnFieldPath(col.field)); + + for (const [idx, column] of columns.entries()) { + if (!rowSummaries.get(column.field)) { + let summaryResult = column.summaries.operate( + data.map(r => resolveNestedPath(r, columnPathParts[idx])), + data, + column.field, + groupRecord, + this.grid.locale, + column.pipeArgs + ); + + summaryResult = column.disabledSummaries.length > 0 + ? summaryResult.filter(s => !column.disabledSummaries.includes(s.key)) + : summaryResult; + + rowSummaries.set(column.field, summaryResult); + } + } + + return rowSummaries; + } + + public resetSummaryHeight() { + this.summaryHeight = 0; + if (this.grid) { + this.grid.summaryPipeTrigger++; + if (this.grid.rootSummariesEnabled) { + this.retriggerRootPipe++; + Promise.resolve().then(() => this.grid.notifyChanges(true)); + } + } + } + + public updateSummaryCache(groupingArgs) { + if (this.summaryCacheMap.size === 0 || !this.hasSummarizedColumns) { + return; + } + if (this.groupingExpressions.length === 0) { + this.groupingExpressions = groupingArgs.expressions.map(record => record.fieldName); + return; + } + if (groupingArgs.length === 0) { + this.groupingExpressions = []; + this.clearSummaryCache(); + return; + } + this.compareGroupingExpressions(this.groupingExpressions, groupingArgs); + this.groupingExpressions = groupingArgs.expressions.map(record => record.fieldName); + } + + public get hasSummarizedColumns(): boolean { + const summarizedColumns = this.grid.columns.filter(col => col.hasSummary && !col.hidden); + return summarizedColumns.length > 0; + } + + private deleteSummaryCache(id, columnName) { + if (this.summaryCacheMap.get(id)) { + const filteringApplied = columnName && this.grid.filteringExpressionsTree && + this.grid.filteringExpressionsTree.filteringOperands.map((expr) => expr.fieldName).indexOf(columnName) !== -1; + if (columnName && this.summaryCacheMap.get(id).get(columnName) && !filteringApplied) { + this.summaryCacheMap.get(id).delete(columnName); + } else { + this.summaryCacheMap.delete(id); + } + if (id === this.rootSummaryID && this.grid.rootSummariesEnabled) { + this.retriggerRootPipe++; + } + } + } + + private getSummaryID(rowID, groupingExpressions) { + if (groupingExpressions.length === 0) { + return []; + } + const summaryIDs = []; + let data = this.grid.data; + if (this.grid.transactions.enabled) { + data = DataUtil.mergeTransactions( + cloneArray(this.grid.data), + this.grid.transactions.getAggregatedChanges(true), + this.grid.primaryKey, + this.grid.dataCloneStrategy + ); + } + const rowData = this.grid.primaryKey ? data.find(rec => rec[this.grid.primaryKey] === rowID) : rowID; + if (!rowData) { + return summaryIDs; + } + let id = '{ '; + groupingExpressions.forEach(expr => { + id += `'${expr.fieldName}': '${rowData[expr.fieldName]}'`; + summaryIDs.push(id.concat(' }')); + id += ', '; + }); + return summaryIDs; + } + + private removeAllTreeGridSummaries(rowID, columnName?) { + let row = (this.grid as TreeGridType).records.get(rowID); + if (!row) { + return; + } + row = row.children ? row : row.parent; + while (row) { + rowID = row.key; + this.deleteSummaryCache(rowID, columnName); + row = row.parent; + } + } + + // TODO: remove only deleted rows + // private removeChildRowSummaries(rowID, columnName?) { + // } + + private compareGroupingExpressions(current, groupingArgs) { + const newExpressions = groupingArgs.expressions.map(record => record.fieldName); + const removedCols = groupingArgs.ungroupedColumns; + if (current.length <= newExpressions.length) { + const newExpr = newExpressions.slice(0, current.length).toString(); + if (current.toString() !== newExpr) { + this.clearSummaryCache(); + } + } else { + const currExpr = current.slice(0, newExpressions.length).toString(); + if (currExpr !== newExpressions.toString()) { + this.clearSummaryCache(); + return; + } + removedCols.map(col => col.field).forEach(colName => { + this.summaryCacheMap.forEach((cache, id) => { + if (id.indexOf(colName) !== -1) { + this.summaryCacheMap.delete(id); + } + }); + }); + } + } +} diff --git a/projects/igniteui-angular/grids/core/src/summaries/grid-summary.ts b/projects/igniteui-angular/grids/core/src/summaries/grid-summary.ts new file mode 100644 index 00000000000..6e62b803bfb --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/summaries/grid-summary.ts @@ -0,0 +1,306 @@ +import { IGroupByRecord, IgxSummaryResult } from 'igniteui-angular/core'; + +const clear = (el) => el === 0 || Boolean(el); +const first = (arr) => arr[0]; +const last = (arr) => arr[arr.length - 1]; + +/* blazorCSSuppress */ +export class IgxSummaryOperand { + /** + * Counts all the records in the data source. + * If filtering is applied, counts only the filtered records. + * ```typescript + * IgxSummaryOperand.count(dataSource); + * ``` + * + * @memberof IgxSummaryOperand + */ + public static count(data: any[]): number { + return data.length; + } + /** + * Executes the static `count` method and returns `IgxSummaryResult[]`. + * ```typescript + * interface IgxSummaryResult { + * key: string; + * label: string; + * summaryResult: any; + * } + * ``` + * Can be overridden in the inherited classes to provide customization for the `summary`. + * ```typescript + * class CustomSummary extends IgxSummaryOperand { + * constructor() { + * super(); + * } + * public operate(data: any[], allData: any[], fieldName: string, groupRecord: IGroupByRecord): IgxSummaryResult[] { + * const result = []; + * result.push({ + * key: "test", + * label: "Test", + * summaryResult: IgxSummaryOperand.count(data) + * }); + * return result; + * } + * } + * this.grid.getColumnByName('ColumnName').summaries = CustomSummary; + * ``` + * + * @memberof IgxSummaryOperand + */ + public operate(data: any[] = [], _allData: any[] = [], _fieldName?: string, _groupRecord?: IGroupByRecord): IgxSummaryResult[] { + return [{ + key: 'count', + label: 'Count', + defaultFormatting: false, + summaryResult: IgxSummaryOperand.count(data) + }]; + } +} + +/* blazorCSSuppress */ +// @dynamic +export class IgxNumberSummaryOperand extends IgxSummaryOperand { + /** + * Returns the minimum numeric value in the provided data records. + * If filtering is applied, returns the minimum value in the filtered data records. + * ```typescript + * IgxNumberSummaryOperand.min(data); + * ``` + * + * @memberof IgxNumberSummaryOperand + */ + public static min(data: any[]): number { + return data.length && data.filter(clear).length ? data.filter(clear).reduce((a, b) => Math.min(a, b)) : 0; + } + /** + * Returns the maximum numeric value in the provided data records. + * If filtering is applied, returns the maximum value in the filtered data records. + * ```typescript + * IgxNumberSummaryOperand.max(data); + * ``` + * + * @memberof IgxNumberSummaryOperand + */ + public static max(data: any[]): number { + return data.length && data.filter(clear).length ? data.filter(clear).reduce((a, b) => Math.max(a, b)) : 0; + } + /** + * Returns the sum of the numeric values in the provided data records. + * If filtering is applied, returns the sum of the numeric values in the data records. + * ```typescript + * IgxNumberSummaryOperand.sum(data); + * ``` + * + * @memberof IgxNumberSummaryOperand + */ + public static sum(data: any[]): number { + return data.length && data.filter(clear).length ? data.filter(clear).reduce((a, b) => +a + +b) : 0; + } + /** + * Returns the average numeric value in the data provided data records. + * If filtering is applied, returns the average numeric value in the filtered data records. + * ```typescript + * IgxSummaryOperand.average(data); + * ``` + * + * @memberof IgxNumberSummaryOperand + */ + public static average(data: any[]): number { + return data.length && data.filter(clear).length ? this.sum(data) / this.count(data) : 0; + } + /** + * Executes the static methods and returns `IgxSummaryResult[]`. + * ```typescript + * interface IgxSummaryResult { + * key: string; + * label: string; + * summaryResult: any; + * } + * ``` + * Can be overridden in the inherited classes to provide customization for the `summary`. + * ```typescript + * class CustomNumberSummary extends IgxNumberSummaryOperand { + * constructor() { + * super(); + * } + * public operate(data: any[], allData: any[], fieldName: string, groupRecord: IGroupByRecord): IgxSummaryResult[] { + * const result = super.operate(data, allData, fieldName, groupRecord); + * result.push({ + * key: "avg", + * label: "Avg", + * summaryResult: IgxNumberSummaryOperand.average(data) + * }); + * result.push({ + * key: 'mdn', + * label: 'Median', + * summaryResult: this.findMedian(data) + * }); + * return result; + * } + * } + * this.grid.getColumnByName('ColumnName').summaries = CustomNumberSummary; + * ``` + * + * @memberof IgxNumberSummaryOperand + */ + public override operate(data: any[] = [], allData: any[] = [], fieldName?: string, groupRecord?: IGroupByRecord): IgxSummaryResult[] { + const result = super.operate(data, allData, fieldName, groupRecord); + result.push({ + key: 'min', + label: 'Min', + defaultFormatting: true, + summaryResult: IgxNumberSummaryOperand.min(data) + }); + result.push({ + key: 'max', + label: 'Max', + defaultFormatting: true, + summaryResult: IgxNumberSummaryOperand.max(data) + }); + result.push({ + key: 'sum', + label: 'Sum', + defaultFormatting: true, + summaryResult: IgxNumberSummaryOperand.sum(data) + }); + result.push({ + key: 'average', + label: 'Avg', + defaultFormatting: true, + summaryResult: IgxNumberSummaryOperand.average(data) + }); + return result; + } +} + +/* blazorCSSuppress */ +// @dynamic +export class IgxDateSummaryOperand extends IgxSummaryOperand { + /** + * Returns the latest date value in the data records. + * If filtering is applied, returns the latest date value in the filtered data records. + * ```typescript + * IgxDateSummaryOperand.latest(data); + * ``` + * + * @memberof IgxDateSummaryOperand + */ + public static latest(data: any[]) { + return data.length && data.filter(clear).length ? + first(data.filter(clear).sort((a, b) => new Date(b).valueOf() - new Date(a).valueOf())) : undefined; + } + /** + * Returns the earliest date value in the data records. + * If filtering is applied, returns the latest date value in the filtered data records. + * ```typescript + * IgxDateSummaryOperand.earliest(data); + * ``` + * + * @memberof IgxDateSummaryOperand + */ + public static earliest(data: any[]) { + return data.length && data.filter(clear).length ? + last(data.filter(clear).sort((a, b) => new Date(b).valueOf() - new Date(a).valueOf())) : undefined; + } + /** + * Executes the static methods and returns `IgxSummaryResult[]`. + * ```typescript + * interface IgxSummaryResult { + * key: string; + * label: string; + * summaryResult: any; + * } + * ``` + * Can be overridden in the inherited classes to provide customization for the `summary`. + * ```typescript + * class CustomDateSummary extends IgxDateSummaryOperand { + * constructor() { + * super(); + * } + * public operate(data: any[], allData: any[], fieldName: string, groupRecord: IGroupByRecord): IgxSummaryResult[] { + * const result = super.operate(data, allData, fieldName, groupRecord); + * result.push({ + * key: "deadline", + * label: "Deadline Date", + * summaryResult: this.calculateDeadline(data); + * }); + * return result; + * } + * } + * this.grid.getColumnByName('ColumnName').summaries = CustomDateSummary; + * ``` + * + * @memberof IgxDateSummaryOperand + */ + public override operate(data: any[] = [], allData: any[] = [], fieldName?: string, groupRecord?: IGroupByRecord): IgxSummaryResult[] { + const result = super.operate(data, allData, fieldName, groupRecord); + result.push({ + key: 'earliest', + label: 'Earliest', + defaultFormatting: true, + summaryResult: IgxDateSummaryOperand.earliest(data) + }); + result.push({ + key: 'latest', + label: 'Latest', + defaultFormatting: true, + summaryResult: IgxDateSummaryOperand.latest(data) + }); + return result; + } +} + +/* blazorCSSuppress */ +// @dynamic +export class IgxTimeSummaryOperand extends IgxSummaryOperand { + /** + * Returns the latest time value in the data records. Compare only the time part of the date. + * If filtering is applied, returns the latest time value in the filtered data records. + * ```typescript + * IgxTimeSummaryOperand.latestTime(data); + * ``` + * + * @memberof IgxTimeSummaryOperand + */ + public static latestTime(data: any[]) { + return data.length && data.filter(clear).length ? + first(data.filter(clear).map(v => new Date(v)).sort((a, b) => + new Date().setHours(b.getHours(), b.getMinutes(), b.getSeconds()) - + new Date().setHours(a.getHours(), a.getMinutes(), a.getSeconds()))) : undefined; + } + + /** + * Returns the earliest time value in the data records. Compare only the time part of the date. + * If filtering is applied, returns the earliest time value in the filtered data records. + * ```typescript + * IgxTimeSummaryOperand.earliestTime(data); + * ``` + * + * @memberof IgxTimeSummaryOperand + */ + public static earliestTime(data: any[]) { + return data.length && data.filter(clear).length ? + last(data.filter(clear).map(v => new Date(v)).sort((a, b) => new Date().setHours(b.getHours(), b.getMinutes(), b.getSeconds()) - + new Date().setHours(a.getHours(), a.getMinutes(), a.getSeconds()))) : undefined; + } + /** + * @memberof IgxTimeSummaryOperand + */ + public override operate(data: any[] = [], allData: any[] = [], fieldName?: string, groupRecord?: IGroupByRecord): IgxSummaryResult[] { + const result = super.operate(data, allData, fieldName, groupRecord); + result.push({ + key: 'earliest', + label: 'Earliest', + defaultFormatting: true, + summaryResult: IgxTimeSummaryOperand.earliestTime(data) + }); + result.push({ + key: 'latest', + label: 'Latest', + defaultFormatting: true, + summaryResult: IgxTimeSummaryOperand.latestTime(data) + }); + return result; + } +} diff --git a/projects/igniteui-angular/grids/core/src/summaries/summary-cell.component.html b/projects/igniteui-angular/grids/core/src/summaries/summary-cell.component.html new file mode 100644 index 00000000000..f8f6c00aa31 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/summaries/summary-cell.component.html @@ -0,0 +1,14 @@ +@if (hasSummary) { + + +} + + @for (summary of summaryResults; track trackSummaryResult(summary)) { +
    + {{ translateSummary(summary) }} + + {{ formatSummaryResult(summary) }} + +
    + } +
    diff --git a/projects/igniteui-angular/grids/core/src/summaries/summary-cell.component.ts b/projects/igniteui-angular/grids/core/src/summaries/summary-cell.component.ts new file mode 100644 index 00000000000..beab0458b16 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/summaries/summary-cell.component.ts @@ -0,0 +1,156 @@ +import { Component, Input, HostBinding, HostListener, ChangeDetectionStrategy, ElementRef, TemplateRef, booleanAttribute, inject } from '@angular/core'; +import { + IgxSummaryOperand +} from './grid-summary'; +import { formatCurrency, formatDate, formatNumber, formatPercent, getLocaleCurrencyCode, getLocaleCurrencySymbol, NgTemplateOutlet } from '@angular/common'; +import { ISelectionNode } from '../common/types'; +import { ColumnType, GridColumnDataType, IgxSummaryResult, trackByIdentity } from 'igniteui-angular/core'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'igx-grid-summary-cell', + templateUrl: './summary-cell.component.html', + imports: [NgTemplateOutlet] +}) +export class IgxSummaryCellComponent { + private element = inject(ElementRef); + + + @Input() + public summaryResults: IgxSummaryResult[]; + + @Input() + public column: ColumnType; + + @Input() + public firstCellIndentation = 0; + + @Input({ transform: booleanAttribute }) + public hasSummary = false; + + @Input() + public summaryFormatter: (summaryResult: IgxSummaryResult, summaryOperand: IgxSummaryOperand) => any; + + @Input() + public summaryTemplate: TemplateRef; + + /** @hidden */ + @Input() + @HostBinding('class.igx-grid-summary--active') + public active: boolean; + + @Input() + @HostBinding('attr.data-rowIndex') + public rowIndex: number; + + @HostBinding('attr.data-visibleIndex') + public get visibleColumnIndex(): number { + return this.column.visibleIndex; + } + + @HostBinding('attr.id') + public get attrCellID() { + return `${this.grid.id}_${this.rowIndex}_${this.visibleColumnIndex}`; + } + + @HostListener('pointerdown') + public activate() { + const currNode = this.grid.navigation.activeNode; + if (currNode && this.rowIndex === currNode.row && this.visibleColumnIndex === currNode.column) { + return; + } + + this.grid.navigation.setActiveNode({ row: this.rowIndex, column: this.visibleColumnIndex }, 'summaryCell'); + this.grid.cdr.detectChanges(); + } + + protected get selectionNode(): ISelectionNode { + return { + row: this.rowIndex, + column: this.column.columnLayoutChild ? this.column.parent.visibleIndex : this.visibleColumnIndex, + isSummaryRow: true + }; + } + + public get width() { + return this.column.getCellWidth(); + } + + public get nativeElement(): any { + return this.element.nativeElement; + } + + public get columnDatatype(): GridColumnDataType { + return this.column.dataType; + } + + public get itemHeight() { + return this.column.grid.defaultSummaryHeight; + } + + /** + * @hidden + */ + public get grid() { + return (this.column.grid as any); + } + + /** + * @hidden @internal + */ + public get currencyCode(): string { + return this.column.pipeArgs.currencyCode ? + this.column.pipeArgs.currencyCode : getLocaleCurrencyCode(this.grid.locale); + } + + /** + * @hidden @internal + */ + public get currencySymbol(): string { + return this.column.pipeArgs.display ? + this.column.pipeArgs.display : getLocaleCurrencySymbol(this.grid.locale); + } + + /** cached single summary res after filter resets collection */ + protected trackSummaryResult = trackByIdentity; + + public translateSummary(summary: IgxSummaryResult): string { + return this.grid.resourceStrings[`igx_grid_summary_${summary.key}`] || summary.label; + } + + /** + * @hidden @internal + */ + public formatSummaryResult(summary: IgxSummaryResult): string { + if (summary.summaryResult === undefined || summary.summaryResult === null || summary.summaryResult === '') { + return ''; + } + + if (this.summaryFormatter) { + return this.summaryFormatter(summary, this.column.summaries); + } + + const args = this.column.pipeArgs; + const locale = this.grid.locale; + + if (summary.key === 'count') { + return formatNumber(summary.summaryResult, locale) + } + + if (summary.defaultFormatting) { + switch (this.column.dataType) { + case GridColumnDataType.Number: + return formatNumber(summary.summaryResult, locale, args.digitsInfo); + case GridColumnDataType.Date: + case GridColumnDataType.DateTime: + case GridColumnDataType.Time: + return formatDate(summary.summaryResult, args.format, locale, args.timezone); + case GridColumnDataType.Currency: + return formatCurrency(summary.summaryResult, locale, this.currencySymbol, this.currencyCode, args.digitsInfo); + case GridColumnDataType.Percent: + return formatPercent(summary.summaryResult, locale, args.digitsInfo); + } + } + return summary.summaryResult; + } +} diff --git a/projects/igniteui-angular/grids/core/src/summaries/summary-row.component.html b/projects/igniteui-angular/grids/core/src/summaries/summary-row.component.html new file mode 100644 index 00000000000..c2710596cae --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/summaries/summary-row.component.html @@ -0,0 +1,61 @@ +@if (summaries.size) { + @if (grid.summariesMargin) { +
    + } + @if (pinnedStartColumns.length > 0) { + + } + + + + + @if (pinnedEndColumns.length > 0) { + + } +} + + + @for (col of columns | igxNotGrouped; track trackPinnedColumn(col)) { + + + } + diff --git a/projects/igniteui-angular/grids/core/src/summaries/summary-row.component.ts b/projects/igniteui-angular/grids/core/src/summaries/summary-row.component.ts new file mode 100644 index 00000000000..963f721eb2b --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/summaries/summary-row.component.ts @@ -0,0 +1,140 @@ +import { + Component, + Input, + ViewChildren, + QueryList, + HostBinding, + ViewChild, + ElementRef, + ChangeDetectionStrategy, + ChangeDetectorRef, + DoCheck, + inject +} from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import { IgxSummaryCellComponent } from './summary-cell.component'; +import { GridType, IGX_GRID_BASE } from '../common/grid.interface'; +import { IgxGridNotGroupedPipe } from '../common/pipes'; +import { IgxForOfSyncService, IgxGridForOfDirective } from 'igniteui-angular/directives'; +import { ColumnType, IgxSummaryResult, trackByIdentity } from 'igniteui-angular/core'; + + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'igx-grid-summary-row', + templateUrl: './summary-row.component.html', + providers: [IgxForOfSyncService], + imports: [NgTemplateOutlet, IgxGridForOfDirective, IgxSummaryCellComponent, IgxGridNotGroupedPipe] +}) +export class IgxSummaryRowComponent implements DoCheck { + public grid = inject(IGX_GRID_BASE); + public element = inject>(ElementRef); + public cdr = inject(ChangeDetectorRef); + + + @Input() + public summaries: Map; + + @Input() + public gridID; + + @Input() + public index: number; + + @Input() + public firstCellIndentation = -1; + + @HostBinding('attr.data-rowIndex') + public get dataRowIndex() { + return this.index; + } + + public get minHeight() { + return this.grid.summaryRowHeight - 1; + } + + @ViewChildren(IgxSummaryCellComponent, { read: IgxSummaryCellComponent }) + public _summaryCells: QueryList; + + public get summaryCells(): QueryList { + const res = new QueryList(); + if (!this._summaryCells) { + return res; + } + const cList = this._summaryCells.filter(c => c.nativeElement.isConnected); + res.reset(cList); + return res; + } + public set summaryCells(cells) { } + + /** + * @hidden + */ + @ViewChild('igxDirRef', { read: IgxGridForOfDirective }) + public virtDirRow: IgxGridForOfDirective; + + public ngDoCheck() { + this.cdr.markForCheck(); + } + + public get nativeElement() { + return this.element.nativeElement; + } + + public getColumnSummaries(columnName: string) { + if (!this.summaries.get(columnName)) { + return []; + } + return this.summaries.get(columnName); + + } + + /** + * @hidden + * @internal + */ + public isCellActive(visibleColumnIndex) { + const node = this.grid.navigation.activeNode; + return node ? node.row === this.index && node.column === visibleColumnIndex : false; + } + + /** + * @hidden + */ + public get pinnedColumns(): ColumnType[] { + return this.grid.pinnedColumns; + } + + + /** + * @hidden + */ + public get pinnedStartColumns(): ColumnType[] { + return this.grid.pinnedStartColumns; + } + + + /** + * @hidden + */ + public get pinnedEndColumns(): ColumnType[] { + return this.grid.pinnedEndColumns; + } + + /** + * @hidden + */ + public get unpinnedColumns(): ColumnType[] { + return this.grid.unpinnedColumns; + } + + public getContext(row, cols) { + return { + $implicit: row, + columns: cols + }; + } + + /** state persistence switching all pinned columns resets collection */ + protected trackPinnedColumn = trackByIdentity; +} diff --git a/projects/igniteui-angular/grids/core/src/toolbar/common.ts b/projects/igniteui-angular/grids/core/src/toolbar/common.ts new file mode 100644 index 00000000000..392cd6f2461 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/toolbar/common.ts @@ -0,0 +1,102 @@ +import { Component, Directive, HostBinding, TemplateRef, inject } from '@angular/core'; +import { GridType } from '../common/grid.interface'; + +@Directive({ + selector: '[excelText],excel-text', + standalone: true +}) +export class IgxExcelTextDirective { } + +@Directive({ + selector: '[csvText],csv-text', + standalone: true +}) +export class IgxCSVTextDirective { } + +@Directive({ + selector: '[pdfText],pdf-text', + standalone: true +}) +export class IgxPdfTextDirective { } + +/* blazorElement */ +/* wcElementTag: igc-grid-toolbar-title */ +/* blazorAlternateBaseType: GridToolbarContent */ +/* blazorIndirectRender */ +/* singleInstanceIdentifier */ +/** + * Provides a way to template the title portion of the toolbar in the grid. + * + * @igxModule IgxGridToolbarModule + * @igxParent IgxGridToolbarComponent + * + * @example + * ```html + * My custom title + * ``` + */ +@Component({ + selector: 'igx-grid-toolbar-title', template: '', + standalone: true +}) +export class IgxGridToolbarTitleComponent { + /** + * Host `class.igx-grid-toolbar__title` binding. + * + * @hidden + * @internal + */ + @HostBinding('class.igx-grid-toolbar__title') + public cssClass = 'igx-grid-toolbar__title'; +} + +/* blazorElement */ +/* blazorIndirectRender */ +/* blazorAlternateBaseType: GridToolbarContent */ +/* wcElementTag: igc-grid-toolbar-actions */ +/* singleInstanceIdentifier */ +/** + * Provides a way to template the action portion of the toolbar in the grid. + * + * @igxModule IgxGridToolbarModule + * @igxParent IgxGridToolbarComponent + * + * @example + * ```html + * + * + * + * ``` + */ +@Component({ + selector: 'igx-grid-toolbar-actions', template: '', + standalone: true +}) +export class IgxGridToolbarActionsComponent { + /** + * Host `class.igx-grid-toolbar__actions` binding. + * + * @hidden + * @internal + */ + @HostBinding('class.igx-grid-toolbar__actions') + public cssClass = 'igx-grid-toolbar__actions'; + } + +export interface IgxGridToolbarTemplateContext { + $implicit: GridType; +} + +@Directive({ + selector: '[igxGridToolbar]', + standalone: true +}) +export class IgxGridToolbarDirective { + public template = inject>(TemplateRef); + + + public static ngTemplateContextGuard(_dir: IgxGridToolbarDirective, + ctx: unknown): ctx is IgxGridToolbarTemplateContext { + return true + } +} diff --git a/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-advanced-filtering.component.html b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-advanced-filtering.component.html new file mode 100644 index 00000000000..6a517c59ef4 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-advanced-filtering.component.html @@ -0,0 +1,17 @@ + diff --git a/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-advanced-filtering.component.ts b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-advanced-filtering.component.ts new file mode 100644 index 00000000000..7de270b2214 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-advanced-filtering.component.ts @@ -0,0 +1,71 @@ +import { Component, Input, OnInit, inject } from '@angular/core'; +import { IgxToolbarToken } from './token'; +import { IgxButtonDirective, IgxRippleDirective } from 'igniteui-angular/directives'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IFilteringExpressionsTree, isTree, OverlaySettings } from 'igniteui-angular/core'; + +/* blazorElement */ +/* wcElementTag: igc-grid-toolbar-advanced-filtering */ +/* blazorIndirectRender */ +/* blazorAlternateBaseType: GridToolbarBaseAction */ +/* jsonAPIManageItemInMarkup */ +/* singleInstanceIdentifier */ +/** + * Provides a pre-configured button to open the advanced filtering dialog of the grid. + * + * + * @igxModule IgxGridToolbarModule + * @igxParent IgxGridToolbarComponent, IgxGridToolbarActionsComponent + * + * @example + * ```html + * + * Custom text + * ``` + */ +@Component({ + selector: 'igx-grid-toolbar-advanced-filtering', + templateUrl: './grid-toolbar-advanced-filtering.component.html', + imports: [IgxButtonDirective, IgxRippleDirective, IgxIconComponent] +}) +export class IgxGridToolbarAdvancedFilteringComponent implements OnInit { + private toolbar = inject(IgxToolbarToken); + + protected numberOfColumns: number; + /** + * Returns the grid containing this component. + * @hidden @internal + */ + public get grid() { + return this.toolbar.grid; + } + + @Input() + public overlaySettings: OverlaySettings; + + /** + * @hidden + */ + public ngOnInit(): void { + // Initial value + this.numberOfColumns = this.grid?.advancedFilteringExpressionsTree ? this.extractUniqueFieldNamesFromFilterTree(this.grid?.advancedFilteringExpressionsTree).length : 0; + + // Subscribing for future updates + this.grid?.advancedFilteringExpressionsTreeChange.subscribe(filteringTree => { + this.numberOfColumns = this.extractUniqueFieldNamesFromFilterTree(filteringTree).length; + }); + } + + protected extractUniqueFieldNamesFromFilterTree(filteringTree?: IFilteringExpressionsTree) : string[] { + const columnNames = []; + if (!filteringTree) return columnNames; + filteringTree.filteringOperands.forEach((expr) => { + if (isTree(expr)) { + columnNames.push(...this.extractUniqueFieldNamesFromFilterTree(expr)); + } else { + columnNames.push(expr.fieldName); + } + }); + return [...new Set(columnNames)]; + } +} diff --git a/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-exporter.component.html b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-exporter.component.html new file mode 100644 index 00000000000..1c74723149c --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-exporter.component.html @@ -0,0 +1,60 @@ + + +
    +
      + @if (exportExcel) { +
    • + + + + @if (!excel.childNodes.length) { + + {{ grid?.resourceStrings.igx_grid_toolbar_exporter_excel_entry_text}} + + } +
    • + } + + @if (exportCSV) { +
    • + + + + @if (!csv.childNodes.length) { + + {{ grid?.resourceStrings.igx_grid_toolbar_exporter_csv_entry_text }} + + } +
    • + } + + @if (exportPDF) { +
    • + + + + @if (!pdf.childNodes.length) { + + {{ grid?.resourceStrings.igx_grid_toolbar_exporter_pdf_entry_text }} + + } +
    • + } +
    +
    diff --git a/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-exporter.component.ts b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-exporter.component.ts new file mode 100644 index 00000000000..910ac5054c9 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-exporter.component.ts @@ -0,0 +1,145 @@ +import { Component, Input, Output, EventEmitter, booleanAttribute, inject } from '@angular/core'; +import { first } from 'rxjs/operators'; +import { BaseToolbarDirective } from './grid-toolbar.base'; +import { IgxExcelTextDirective, IgxCSVTextDirective, IgxPdfTextDirective } from './common'; +import { GridType } from '../common/grid.interface'; +import { IgxButtonDirective, IgxRippleDirective, IgxToggleDirective } from 'igniteui-angular/directives'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { CsvFileTypes, IgxCsvExporterOptions } from '../services/csv/csv-exporter-options'; +import { IgxExcelExporterOptions } from '../services/excel/excel-exporter-options'; +import { IgxPdfExporterOptions } from '../services/pdf/pdf-exporter-options'; +import { IgxBaseExporter } from '../services/exporter-common/base-export-service'; +import { IgxExcelExporterService } from '../services/excel/excel-exporter'; +import { IgxCsvExporterService } from '../services/csv/csv-exporter'; +import { IgxPdfExporterService } from '../services/pdf/pdf-exporter'; + +export type IgxExporterOptions = IgxCsvExporterOptions | IgxExcelExporterOptions | IgxPdfExporterOptions; + +/* jsonAPIComplexObject */ +/* wcAlternateName: ExporterEventArgs */ +export interface IgxExporterEvent { + exporter: IgxBaseExporter; + /* alternateType: ExporterOptionsBase */ + options: IgxExporterOptions; + grid: GridType; + cancel: boolean; +} + + +/* blazorElement */ +/* wcElementTag: igc-grid-toolbar-exporter */ +/* blazorIndirectRender */ +/* jsonAPIManageItemInMarkup */ +/* singleInstanceIdentifier */ +/** + * Provides a pre-configured exporter component for the grid. + * + * @remarks + * This component still needs the actual exporter service(s) provided in the DI chain + * in order to export something. + * + * @igxModule IgxGridToolbarModule + * @igxParent IgxGridToolbarComponent, IgxGridToolbarActionsComponent + * + */ +@Component({ + selector: 'igx-grid-toolbar-exporter', + templateUrl: './grid-toolbar-exporter.component.html', + imports: [IgxButtonDirective, IgxRippleDirective, IgxIconComponent, IgxToggleDirective, IgxExcelTextDirective, IgxCSVTextDirective, IgxPdfTextDirective] +}) +export class IgxGridToolbarExporterComponent extends BaseToolbarDirective { + private excelExporter = inject(IgxExcelExporterService); + private csvExporter = inject(IgxCsvExporterService); + private pdfExporter = inject(IgxPdfExporterService); + + /** + * Show entry for CSV export. + */ + @Input({ transform: booleanAttribute }) + public exportCSV = true; + + /** + * Show entry for Excel export. + */ + @Input({ transform: booleanAttribute }) + public exportExcel = true; + + /** + * Show entry for PDF export. + */ + @Input({ transform: booleanAttribute }) + public exportPDF = true; + + /** + * The name for the exported file. + */ + @Input() + public filename = 'ExportedData'; + + /** + * Emitted when starting an export operation. Re-emitted additionally + * by the grid itself. + */ + @Output() + public exportStarted = new EventEmitter(); + + /** + * Emitted on successful ending of an export operation. + */ + @Output() + public exportEnded = new EventEmitter(); + + /** + * Indicates whether there is an export in progress. + */ + protected isExporting = false; + + protected exportClicked(type: 'excel' | 'csv' | 'pdf', toggleRef?: IgxToggleDirective) { + toggleRef?.close(); + this.export(type); + } + + /* alternateName: exportGrid */ + /** + * Export the grid's data + * @param type File type to export + */ + public export(type: 'excel' | 'csv' | 'pdf'): void { + let options: IgxExporterOptions; + let exporter: IgxBaseExporter; + + switch (type) { + case 'csv': + options = new IgxCsvExporterOptions(this.filename, CsvFileTypes.CSV); + exporter = this.csvExporter; + break; + case 'excel': + options = new IgxExcelExporterOptions(this.filename); + exporter = this.excelExporter; + break; + case 'pdf': + options = new IgxPdfExporterOptions(this.filename); + exporter = this.pdfExporter; + } + + const args = { exporter, options, grid: this.grid, cancel: false } as IgxExporterEvent; + + this.exportStarted.emit(args); + this.grid.toolbarExporting.emit(args); + + if (args.cancel) { + return; + } + + this.isExporting = true; + this.toolbar.showProgress = true; + + exporter.exportEnded.pipe(first()).subscribe(() => { + this.exportEnded.emit(); + this.isExporting = false; + this.toolbar.showProgress = false; + }); + + exporter.export(this.grid, options); + } +} diff --git a/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-hiding.component.html b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-hiding.component.html new file mode 100644 index 00000000000..9794e20a475 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-hiding.component.html @@ -0,0 +1,21 @@ +@if (grid.rendered$ | async) { + + + +} diff --git a/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-hiding.component.ts b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-hiding.component.ts new file mode 100644 index 00000000000..d68b0c2bad2 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-hiding.component.ts @@ -0,0 +1,38 @@ +import { Component, ViewChild } from '@angular/core'; +import { IgxColumnActionsComponent } from '../column-actions/column-actions.component'; +import { IgxColumnHidingDirective } from '../column-actions/column-hiding.directive'; +import { BaseToolbarColumnActionsDirective } from './grid-toolbar.base'; +import { AsyncPipe } from '@angular/common'; +import { IgxButtonDirective, IgxToggleDirective } from 'igniteui-angular/directives'; +import { IgxIconComponent } from 'igniteui-angular/icon'; + + +/* blazorElement */ +/* wcElementTag: igc-grid-toolbar-hiding */ +/* blazorIndirectRender */ +/* jsonAPIManageItemInMarkup */ +/* singleInstanceIdentifier */ +/** + * Provides a pre-configured column hiding component for the grid. + * + * + * @igxModule IgxGridToolbarModule + * @igxParent IgxGridToolbarComponent, IgxGridToolbarActionsComponent + * + * @example + * ```html + * + * ``` + */ +@Component({ + selector: 'igx-grid-toolbar-hiding', + templateUrl: './grid-toolbar-hiding.component.html', + imports: [IgxButtonDirective, IgxIconComponent, IgxColumnActionsComponent, IgxColumnHidingDirective, IgxToggleDirective, AsyncPipe] +}) +export class IgxGridToolbarHidingComponent extends BaseToolbarColumnActionsDirective { + + @ViewChild(IgxColumnHidingDirective, {read: IgxColumnActionsComponent}) + private set content(content: IgxColumnActionsComponent) { + this.columnActionsUI = content; + } +} diff --git a/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-pinning.component.html b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-pinning.component.html new file mode 100644 index 00000000000..445b4c9bea9 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-pinning.component.html @@ -0,0 +1,21 @@ +@if (grid.rendered$ | async) { + + + +} diff --git a/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-pinning.component.ts b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-pinning.component.ts new file mode 100644 index 00000000000..3c5ecd75a2b --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar-pinning.component.ts @@ -0,0 +1,37 @@ +import { Component, ViewChild } from '@angular/core'; +import { IgxColumnActionsComponent } from '../column-actions/column-actions.component'; +import { IgxColumnPinningDirective } from '../column-actions/column-pinning.directive'; +import { BaseToolbarColumnActionsDirective } from './grid-toolbar.base'; +import { AsyncPipe } from '@angular/common'; +import { IgxButtonDirective, IgxToggleDirective } from 'igniteui-angular/directives'; +import { IgxIconComponent } from 'igniteui-angular/icon'; + +/* blazorElement */ +/* wcElementTag: igc-grid-toolbar-pinning */ +/* singleInstanceIdentifier */ +/* blazorIndirectRender */ +/* jsonAPIManageItemInMarkup */ +/** + * Provides a pre-configured column pinning component for the grid. + * + * + * @igxModule IgxGridToolbarModule + * @igxParent IgxGridToolbarComponent, IgxGridToolbarActionsComponent + * + * @example + * ```html + * + * ``` + */ +@Component({ + selector: 'igx-grid-toolbar-pinning', + templateUrl: './grid-toolbar-pinning.component.html', + imports: [IgxButtonDirective, IgxIconComponent, IgxColumnActionsComponent, IgxColumnPinningDirective, IgxToggleDirective, AsyncPipe] +}) +export class IgxGridToolbarPinningComponent extends BaseToolbarColumnActionsDirective { + + @ViewChild(IgxColumnPinningDirective, {read: IgxColumnActionsComponent}) + private set content(content: IgxColumnActionsComponent) { + this.columnActionsUI = content; + } +} diff --git a/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar.base.ts b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar.base.ts new file mode 100644 index 00000000000..26963fa64e0 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar.base.ts @@ -0,0 +1,200 @@ +import { Directive, Input, EventEmitter, OnDestroy, Output, booleanAttribute, inject } from '@angular/core'; +import { Subject, Subscription } from 'rxjs'; +import { first, takeUntil } from 'rxjs/operators'; + +import { ColumnDisplayOrder } from '../common/enums'; +import { IColumnToggledEventArgs } from '../common/events'; +import { IgxColumnActionsComponent } from '../column-actions/column-actions.component'; +import { IgxToolbarToken } from './token'; +import { AbsoluteScrollStrategy, AutoPositionStrategy, HorizontalAlignment, OverlaySettings, VerticalAlignment } from 'igniteui-angular/core'; +import { IgxToggleDirective, ToggleViewCancelableEventArgs, ToggleViewEventArgs } from 'igniteui-angular/directives'; + +/* blazorInclude */ +/* blazorElement */ +/* blazorIndirectRender */ +/* blazorAlternateBaseType: GridToolbarBaseAction */ +/** + * Base class for the pinning/hiding column and exporter actions. + * + * @hidden @internal + */ +@Directive() +export abstract class BaseToolbarDirective implements OnDestroy { + protected toolbar = inject(IgxToolbarToken); + + /** + * Sets the height of the column list in the dropdown. + */ + @Input() + public columnListHeight: string; + + /** + * Title text for the column action component + */ + @Input() + public title: string; + + /** + * The placeholder text for the search input. + */ + @Input() + public prompt: string; + + /** + * Sets overlay settings + */ + @Input() + public set overlaySettings(overlaySettings: OverlaySettings) { + this._overlaySettings = overlaySettings; + } + + /** + * Returns overlay settings + */ + public get overlaySettings(): OverlaySettings { + return this._overlaySettings; + } + /** + * Emits an event before the toggle container is opened. + */ + @Output() + public opening = new EventEmitter(); + /** + * Emits an event after the toggle container is opened. + */ + + @Output() + public opened = new EventEmitter(); + /** + * Emits an event before the toggle container is closed. + */ + + @Output() + public closing = new EventEmitter(); + /** + * Emits an event after the toggle container is closed. + */ + + @Output() + public closed = new EventEmitter(); + + /** + * Emits when after a column's checked state is changed + */ + @Output() + public columnToggle = new EventEmitter(); + + private $destroy = new Subject(); + private $sub: Subscription; + + private _overlaySettings: OverlaySettings = { + positionStrategy: new AutoPositionStrategy({ + horizontalDirection: HorizontalAlignment.Left, + horizontalStartPoint: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Bottom, + verticalStartPoint: VerticalAlignment.Bottom + }), + scrollStrategy: new AbsoluteScrollStrategy(), + modal: false, + closeOnEscape: true, + closeOnOutsideClick: true + }; + + /** + * Returns the grid containing this component. + * @hidden @internal + */ + public get grid() { + return this.toolbar.grid; + } + + /** @hidden @internal **/ + public ngOnDestroy() { + this.$destroy.next(); + this.$destroy.complete(); + } + + /** @hidden @internal */ + public toggle(anchorElement: HTMLElement, toggleRef: IgxToggleDirective, actions?: IgxColumnActionsComponent): void { + if (actions) { + this._setupListeners(toggleRef, actions); + const setHeight = () => + actions.columnsAreaMaxHeight = actions.columnsAreaMaxHeight !== '100%' + ? actions.columnsAreaMaxHeight : + this.columnListHeight ?? + `${Math.max(this.grid.calcHeight * 0.5, 200)}px`; + toggleRef.opening.pipe(first()).subscribe(setHeight); + } + toggleRef.toggle({ + ...this.overlaySettings, ...{ + target: anchorElement, outlet: this.grid.outlet, + excludeFromOutsideClick: [anchorElement] + } + }); + + } + + /** @hidden @internal */ + public focusSearch(columnActions: HTMLElement) { + columnActions.querySelector('input')?.focus(); + } + + private _setupListeners(toggleRef: IgxToggleDirective, actions?: IgxColumnActionsComponent) { + if (actions) { + if (!this.$sub || this.$sub.closed) { + this.$sub = actions.columnToggled.pipe(takeUntil(this.$destroy)).subscribe((event) => this.columnToggle.emit(event)); + } + } + /** The if statement prevents emitting open and close events twice */ + if (toggleRef.collapsed) { + toggleRef.opening.pipe(first(), takeUntil(this.$destroy)).subscribe((event) => this.opening.emit(event)); + toggleRef.opened.pipe(first(), takeUntil(this.$destroy)).subscribe((event) => this.opened.emit(event)); + } else { + toggleRef.closing.pipe(first(), takeUntil(this.$destroy)).subscribe((event) => this.closing.emit(event)); + toggleRef.closed.pipe(first(), takeUntil(this.$destroy)).subscribe((event) => this.closed.emit(event)); + } + } +} + +/* blazorElement */ +/* blazorIndirectRender */ +/** + * @hidden @internal + * Base class for pinning/hiding column actions + */ +@Directive() +export abstract class BaseToolbarColumnActionsDirective extends BaseToolbarDirective { + @Input({ transform: booleanAttribute }) + public hideFilter = false; + + @Input() + public filterCriteria = ''; + + @Input() + public columnDisplayOrder: ColumnDisplayOrder = ColumnDisplayOrder.DisplayOrder; + + @Input() + public columnsAreaMaxHeight = '100%'; + + @Input() + public uncheckAllText: string; + + @Input() + public checkAllText: string; + + @Input() + public indentetion = 30; + + @Input() + public buttonText: string; + + protected columnActionsUI: IgxColumnActionsComponent; + + public checkAll() { + this.columnActionsUI.checkAllColumns(); + } + + public uncheckAll() { + this.columnActionsUI.uncheckAllColumns(); + } +} diff --git a/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar.component.html b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar.component.html new file mode 100644 index 00000000000..e37e3b901a8 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar.component.html @@ -0,0 +1,23 @@ + + +
    + +
    + + + + +@if (!hasActions) { + + @if (grid?.allowAdvancedFiltering) { + + } + +} + + +@if (showProgress) { +
    + +
    +} diff --git a/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar.component.ts b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar.component.ts new file mode 100644 index 00000000000..57ea58f5add --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/toolbar/grid-toolbar.component.ts @@ -0,0 +1,103 @@ +import { Component, ContentChild, ElementRef, HostBinding, Input, OnDestroy, booleanAttribute, inject } from '@angular/core'; +import { Subscription } from 'rxjs'; +import { pinLeft, unpinLeft } from '@igniteui/material-icons-extended'; +import { IgxGridToolbarActionsComponent } from './common'; +import { GridServiceType, GridType, IGX_GRID_SERVICE_BASE } from '../common/grid.interface'; +import { IgxToolbarToken } from './token'; +import { IgxGridToolbarAdvancedFilteringComponent } from './grid-toolbar-advanced-filtering.component'; +import { NgTemplateOutlet } from '@angular/common'; +import { IgxLinearProgressBarComponent } from 'igniteui-angular/progressbar'; +import { IgxIconService } from 'igniteui-angular/icon'; + +/* blazorElement */ +/* mustUseNGParentAnchor */ +/* wcElementTag: igc-grid-toolbar */ +/* blazorIndirectRender */ +/* singleInstanceIdentifier */ +/* contentParent: GridBaseDirective */ +/* contentParent: RowIsland */ +/* contentParent: HierarchicalGrid */ +/* jsonAPIManageItemInMarkup */ +/** + * Provides a context-aware container component for UI operations for the grid components. + * + * @igxModule IgxGridToolbarModule + * @igxParent IgxGridComponent, IgxTreeGridComponent, IgxHierarchicalGridComponent, IgxPivotGridComponent + * + */ +@Component({ + selector: 'igx-grid-toolbar', + templateUrl: './grid-toolbar.component.html', + providers: [{ provide: IgxToolbarToken, useExisting: IgxGridToolbarComponent }], + imports: [IgxGridToolbarActionsComponent, IgxGridToolbarAdvancedFilteringComponent, NgTemplateOutlet, IgxLinearProgressBarComponent] +}) +export class IgxGridToolbarComponent implements OnDestroy { + private api = inject(IGX_GRID_SERVICE_BASE); + private iconService = inject(IgxIconService); + private element = inject>(ElementRef); + + + /** + * When enabled, shows the indeterminate progress bar. + * + * @remarks + * By default this will be toggled, when the default exporter component is present + * and an exporting is in progress. + */ + @Input({ transform: booleanAttribute }) + public showProgress = false; + + /** + * Gets/sets the grid component for the toolbar component. + * + * @deprecated since version 17.1.0. No longer required to be set for the Hierarchical Grid child grid template + * + * @remarks + * Usually you should not set this property in the context of the default grid/tree grid. + * The only grids that demands this to be set are the hierarchical child grids. For additional + * information check the toolbar topic. + */ + @Input() + public get grid() { + if (this._grid) { + return this._grid; + } + return this.api.grid; + } + + public set grid(value: GridType) { + this._grid = value; + } + + /** Returns the native DOM element of the toolbar component */ + public get nativeElement() { + return this.element.nativeElement; + } + + /** + * @hidden + * @internal + */ + @ContentChild(IgxGridToolbarActionsComponent) + public hasActions: IgxGridToolbarActionsComponent; + + /** + * @hidden + * @internal + */ + @HostBinding('class.igx-grid-toolbar') + public defaultStyle = true; + + protected _grid: GridType; + protected sub: Subscription; + + constructor() { + this.iconService.addSvgIconFromText(pinLeft.name, pinLeft.value, 'imx-icons', true); + this.iconService.addSvgIconFromText(unpinLeft.name, unpinLeft.value, 'imx-icons', true); + } + + /** @hidden @internal */ + public ngOnDestroy() { + this.sub?.unsubscribe(); + } +} diff --git a/projects/igniteui-angular/grids/core/src/toolbar/public_api.ts b/projects/igniteui-angular/grids/core/src/toolbar/public_api.ts new file mode 100644 index 00000000000..4a00f01b138 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/toolbar/public_api.ts @@ -0,0 +1,29 @@ +// import { IgxCSVTextDirective, IgxExcelTextDirective, IgxGridToolbarActionsComponent, IgxGridToolbarDirective, IgxGridToolbarTitleComponent } from './common'; +// import { IgxGridToolbarAdvancedFilteringComponent } from './grid-toolbar-advanced-filtering.component'; +// import { IgxGridToolbarExporterComponent } from './grid-toolbar-exporter.component'; +// import { IgxGridToolbarHidingComponent } from './grid-toolbar-hiding.component'; +// import { IgxGridToolbarPinningComponent } from './grid-toolbar-pinning.component'; +// import { IgxGridToolbarComponent } from './grid-toolbar.component'; + +export * from './grid-toolbar.component'; +export * from './common'; +export * from './grid-toolbar-advanced-filtering.component'; +export * from './grid-toolbar-exporter.component'; +export * from './grid-toolbar-hiding.component'; +export * from './grid-toolbar-pinning.component'; +export * from './grid-toolbar-exporter.component'; +export * from './token'; + +/* NOTE: Grid toolbar directives collection for ease-of-use import in standalone components scenario */ +// export const IGX_GRID_TOOLBAR_DIRECTIVES = [ +// IgxCSVTextDirective, +// IgxExcelTextDirective, +// IgxGridToolbarActionsComponent, +// IgxGridToolbarAdvancedFilteringComponent, +// IgxGridToolbarComponent, +// IgxGridToolbarExporterComponent, +// IgxGridToolbarHidingComponent, +// IgxGridToolbarPinningComponent, +// IgxGridToolbarTitleComponent, +// IgxGridToolbarDirective +// ] as const; diff --git a/projects/igniteui-angular/grids/core/src/toolbar/token.ts b/projects/igniteui-angular/grids/core/src/toolbar/token.ts new file mode 100644 index 00000000000..b0ec0df1356 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/toolbar/token.ts @@ -0,0 +1,8 @@ +import { GridType } from '../common/grid.interface'; + +/** @hidden @internal */ +export abstract class IgxToolbarToken { + + public abstract grid: GridType; + public abstract showProgress: boolean; +} diff --git a/projects/igniteui-angular/grids/core/src/watch-changes.ts b/projects/igniteui-angular/grids/core/src/watch-changes.ts new file mode 100644 index 00000000000..d4db75c573a --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/watch-changes.ts @@ -0,0 +1,99 @@ +import { SimpleChanges, SimpleChange } from '@angular/core'; + +/** + * @hidden + */ +export function WatchChanges(): PropertyDecorator { + return (target: any, key: string, propDesc?: PropertyDescriptor) => { + const privateKey = '_' + key.toString(); + propDesc = propDesc || { + configurable: true, + enumerable: true, + }; + propDesc.get = propDesc.get || (function (this: any) { + return this[privateKey]; + }); + const originalSetter = propDesc.set || (function (this: any, val: any) { + this[privateKey] = val; + }); + + propDesc.set = function (this: any, val: any) { + const init = this._init; + const oldValue = this[key]; + if (val !== oldValue || (typeof val === 'object' && val === oldValue)) { + originalSetter.call(this, val); + if (this.ngOnChanges && !init) { + // in case wacthed prop changes trigger ngOnChanges manually + const changes: SimpleChanges = { + [key]: new SimpleChange(oldValue, val, false) + }; + this.ngOnChanges(changes); + } + } + }; + return propDesc; + }; +} + +export function WatchColumnChanges(): PropertyDecorator { + return (target: any, key: string, propDesc?: PropertyDescriptor) => { + const privateKey = '_' + key.toString(); + propDesc = propDesc || { + configurable: true, + enumerable: true, + }; + propDesc.get = propDesc.get || (function (this: any) { + return this[privateKey]; + }); + const originalSetter = propDesc.set || (function (this: any, val: any) { + this[privateKey] = val; + }); + + propDesc.set = function (this: any, val: any) { + const oldValue = this[key]; + originalSetter.call(this, val); + if (val !== oldValue || (typeof val === 'object' && val === oldValue)) { + if (this.columnChange) { + this.columnChange.emit(); + } + } + }; + return propDesc; + }; +} + +export function notifyChanges(repaint = false) { + return (_: any, key: string, propDesc?: PropertyDescriptor) => { + + const privateKey = `__${key}`; + + propDesc = propDesc || { + enumerable: true, + configurable: true + }; + + + const originalSetter = propDesc ? propDesc.set : null; + propDesc.get = propDesc.get || (function (this) { + return this[privateKey]; + }); + + propDesc.set = function (this, newValue) { + if (originalSetter) { + originalSetter.call(this, newValue); + if (this.grid) { + this.grid.notifyChanges(repaint && this.type !== 'pivot'); + } + } else { + if (newValue === this[key]) { + return; + } + this[privateKey] = newValue; + if (this.grid) { + this.grid.notifyChanges(repaint && this.type !== 'pivot'); + } + } + }; + return propDesc as any; + }; +} diff --git a/projects/igniteui-angular/grids/grid/README.md b/projects/igniteui-angular/grids/grid/README.md new file mode 100644 index 00000000000..ded963893d6 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/README.md @@ -0,0 +1,542 @@ +# igx-grid +**igx-grid** component provides the capability to manipulate and represent tabular data. +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/grid/grid) + +## Usage +```html + + +``` + +## Getting Started + +### Dependencies +The grid is exported as as an `NgModule`, thus all you need to do in your application is to import the _IgxGridModule_ inside your `AppModule` + +```typescript +// app.module.ts + +import { IgxGridModule } from 'igniteui-angular'; +// Or +import { IgxGridModule } from 'igniteui-angular/grids/grid'; + +@NgModule({ + imports: [ + ... + IgxGridModule, + ... + ] +}) +export class AppModule {} +``` + +Each of the components, directives and helper classes in the _IgxGridModule_ can be imported through the per-package entry points. Prefer subpath imports for optimal tree-shaking and smaller bundles. + +```typescript +import { IgxGridComponent } from 'igniteui-angular/grids/grid'; +// Per-feature entry points (examples): +// import { IgxPaginatorModule } from 'igniteui-angular/paginator'; +// import { IgxButtonModule } from 'igniteui-angular/button'; +// import { IgxIconModule } from 'igniteui-angular/icon'; +... + +@ViewChild('myGrid', { read: IgxGridComponent }) +public grid: IgxGridComponent; +``` + +### Basic configuration + +Define the grid +```html + + + + + + + Change + + + + Change(%) + + + +``` + +When all needed dependencies are included, next step would be to configure local or remote service that will return grids data. For example: + +```typescript +@Injectable() +export class FinancialSampleComponent { + @ViewChild("grid1") public grid1: IgxGridComponent; + public data: Observable; + constructor(private localService: LocalService) { + this.localService.getData(100000); + this.data = this.localService.records; + } + public ngOnInit(): void { + } + public formatNumber(value: number) { + return value.toFixed(2); + } + public formatCurrency(value: number) { + return "$" + value.toFixed(2); + } +} +``` + +Create the Grid component that will be used in the application. This will include: +- implement some sorting or paging for example. + +```typescript +public ngOnInit(): void { + this.grid1.state = { + paging: { + index: 2, + recordsPerPage: 10 + }, + sorting: { + expressions: [ + {fieldName: "ProductID", dir: SortingDirection.Desc} + ] + } + }; +} + +``` + +- enable some features for certain columns + +```typescript +public initColumns(event: IgxGridColumnInitEvent) { + const column: IgxColumnComponent = event.column; + if (column.field === "Change") { + column.filterable = true; + column.sortable = true; + column.editable = true; + } +} +``` + +- Аdd event handlers for CRUD operations + +```typescript +public addRow() { + if (!this.newRecord.trim()) { + this.newRecord = ""; + return; + } + const record = {ID: this.grid1.data[this.grid1.data.length - 1].ID + 1, Name: this.newRecord}; + this.grid1.addRow(record); + this.newRecord = ""; +} + +public updateRecord(event) { + this.grid1.updateCell(this.selectedCell.rowIndex, this.selectedCell.columnField, event); + this.grid1.getCell(this.selectedCell.rowIndex, this.selectedCell.columnField); +} + +public deleteRow(event) { + this.selectedRow = Object.assign({}, this.grid1.getRow(this.selectedCell.rowIndex)); + this.grid1.deleteRow(this.selectedCell.rowIndex); + this.selectedCell = {}; + this.snax.message = `Row with ID ${this.selectedRow.record.ID} was deleted`; + this.snax.open(); +} +``` + +- Аdd cell template to allow cells to grow according to their content. + +``` + + {{val}} + +``` + +## API + +### Inputs + +Below is the list of all inputs that the developers may set to configure the grid look/behavior: + + +|Name|Type|Description| +|--- |--- |--- | +|`id`|string|Unique identifier of the Grid. If not provided it will be automatically generated.| +|`data`|Array|The data source for the grid.| +|`resourceStrings`| IGridResourceStrings | Resource strings of the grid. | +|`autoGenerate`|boolean|Autogenerate grid's columns, default value is _false_| +|`autoGenerateExclude`|Array|A list of property keys to be excluded from the generated column collection, default is _[]_| +|`batchEditing`|boolean|Toggles batch editing in the grid, default is _false_| +|`moving`|boolean|Enables the columns moving feature. Defaults to _false_| +|`allowFiltering`| boolean | Enables quick filtering functionality in the grid. | +|`allowAdvancedFiltering`| boolean | Enables advanced filtering functionality in the grid. | +|`filterMode`| `FilterMode` | Determines the filter mode, default value is `quickFilter`.| +|`filteringLogic`| FilteringLogic | The filtering logic of the grid. Defaults to _AND_. | +|`filteringExpressionsTree`| IFilteringExpressionsTree | The filtering state of the grid. | +|`advancedFilteringExpressionsTree`| IFilteringExpressionsTree | The advanced filtering state of the grid. | +|`emptyFilteredGridMessage`| string | The message displayed when there are no records and the grid is filtered.| +|`uniqueColumnValuesStrategy`| void | Property that provides a callback for loading unique column values on demand. If this property is provided, the unique values it generates will be used by the Excel Style Filtering. | +|`sortingExpressions`|Array|The sorting state of the grid.| +|`rowSelectable`|boolean|Enables multiple row selection, default is _false_.| +|`height`|string|The height of the grid element. You can pass values such as `1000px`, `75%`, etc.| +|`width`|string|The width of the grid element. You can pass values such as `1000px`, `75%`, etc.| +|`paginationTemplate`|TemplateRef|You can provide a custom `ng-template` for the pagination part of the grid.| +|`groupStrategy`| IGridGroupingStrategy | Provides custom group strategy to be used when grouping | +|`groupingExpressions`| Array | The group by state of the grid. +|`groupingExpansionState`| Array | The list of expansion states of the group rows. Contains the expansion state(expanded: boolean) and an unique identifier for the group row (Array) that contains a list of the group row's parents described via their fieldName and value. +|`groupsExpanded`| boolean | Determines whether created groups are rendered expanded or collapsed. | +|`hideGroupedColumns`| boolean | Determines whether the grouped columns are hidden as well. | +|`rowEditable` | boolean | enables/disables row editing mode | +|`transactions`| `TransactionService` | Transaction provider allowing access to all transactions and states of the modified rows. | +|`summaryPosition`| GridSummaryPosition | The summary row position for the child levels. The default is top. | +|`summaryCalculationMode`| GridSummaryCalculationMode | The summary calculation mode. The default is rootAndChildLevels, which means summaries are calculated for root and child levels.| +| `rowHeight` | number | Sets the row height. | +| `columnWidth` | string | The default width of the `IgxGridComponent`'s columns. | +|`primaryKey`| any | Property that sets the primary key of the `IgxGridComponent`. | +|`exportExcel`| boolean | Returns whether the option for exporting to MS Excel is enabled or disabled. | +|`exportCsv`| boolean | Returns whether the option for exporting to CSV is enabled or disabled.| +|`exportText`| string | Returns the textual content for the main export button.| +|`exportExcelText`| string | Sets the textual content for the main export button. | +|`exportCsvText`| string | Returns the textual content for the CSV export button.| +|`locale`| string | Determines the locale of the grid. Default value is `en`. | +| `isLoading` | bool | Sets if the grid is waiting for data - default value false. | +| `rowDraggable` | bool | Sets if the grid rows can be dragged | +| `columnSelection` | GridSelectionMode | Sets if the grid columns can be selected | +| `showGroupArea` | boolean | Set/get whether the group are row is shown | + +### Outputs + +A list of the events emitted by the **igx-grid**: + +|Name|Description| +|--- |--- | +|_Event emitters_|_Notify for a change_| +|`cellEditEnter`|Emitted when cell enters edit mode.| +|`cellEdit`|Emitted just before a cell's value is committed (e.g. by pressing Enter).| +|`cellEditDone`|Emitted after a cell has been edited and editing has been committed.| +|`cellEditExit`|Emitted when a cell exits edit mode.| +|`rowEditEnter`|If `[rowEditing]` is enabled, emitted when a row enters edit mode (before cellEditEnter).| +|`rowEdit`|Emitted just before a row in edit mode's value is committed (e.g. by clicking the Done button on the Row Editing Overlay).| +|`rowEditDone`|Emitted after exiting edit mode for a row and editing has been committed.| +|`rowEditExit`|Emitted when a row exits edit mode without committing its values (e.g. by clicking the Cancel button on the Row Editing Overlay).| +|`dataChanging`|Emitted before the grid's data view is changed because of a data operation, rebinding, etc.| +|`dataChanged`|Emitted after the grid's data view is changed because of a data operation, rebinding, etc.| +|`cellClick`|Emitted when a cell is clicked. Returns the cell object.| +|`columnMoving`|Emitted when a column is moved. Returns the source and target columns objects. This event is cancelable.| +|`columnMovingEnd`|Emitted when a column moving ends. Returns the source and target columns objects. This event is cancelable.| +|`columnMovingStart`|Emitted when a column moving starts. Returns the moved column object.| +|`selected`|Emitted when a cell is selected. Returns the cell object.| +|`rowSelectionChanging`|Emitted when row selection is changing. Returns array with old and new selected rows' IDs and the target row, if available.| +|`columnSelectionChanging`|Emitted when a column selection is changing. Returns array with old and new selected column' fields| +|`columnInit`|Emitted when the grid columns are initialized. Returns the column object.| +|`sortingDone`|Emitted when sorting is performed through the UI. Returns the sorting expression.| +|`filteringDone`|Emitted when filtering is performed through the UI. Returns the filtering expressions tree of the column for which the filtering was performed.| +|`rowAdded`|Emitted when a row is being added to the grid through the API. Returns the data for the new row object.| +|`rowClick`|Emitted when a row is clicked. Returns the row object.| +|`rowDeleted`|Emitted when a row is deleted through the grid API. Returns the row object being removed.| +|`dataPreLoad`| Emitted when a new chunk of data is loaded from virtualization. | +|`columnPin`|Emitted when a column is pinned or unpinned through the grid API. The index that the column is inserted at may be changed through the `insertAtIndex` property. Use `isPinned` to check whether the column is pinned or unpinned.| +|`columnResized`|Emitted when a column is resized. Returns the column object, previous and new column width.| +|`contextMenu`|Emitted when a cell or row is right clicked. Returns the cell or row object.| +|`doubleClick`|Emitted when a cell is double clicked. Returns the cell object.| +|`columnVisibilityChanged`| Emitted when `IgxColumnComponent` visibility is changed. Args: { column: any, newValue: boolean } | +|`groupingDone`|Emitted when the grouping state changes as a result of grouping columns, ungrouping columns or a combination of both. Provides an array of `ISortingExpression`, an array of the **newly** grouped columns as `IgxColumnComponent` references and an array of the **newly** ungrouped columns as `IgxColumnComponent` references.| +|`toolbarExporting`| Emitted when an export process is initiated by the user.| +| `rowDragStart` | Emitted when the user starts dragging a row. | +| `rowDragEnd` | Emitted when the user drops a row or cancel the drag. | +| `gridScroll` | Emitted when grid is scrolled horizontally/vertically. | +| `gridKeydown` | Emitted when keydown is triggered over element inside grid's body. | +| `gridCopy` | Emitted when a copy operation is executed. | +| `rowToggle` | Emitted when the expanded state of a row gets changed. | +| `rowPinning` | Emitted when the pinned state of a row is changed. | +| `rangeSelected` | Emitted when making a range selection. | + + +Defining handlers for these event emitters is done using declarative event binding: + +```html + +``` + +### Methods + +Here is a list of all public methods exposed by **igx-grid**: + +|Signature|Description| +|--- |--- | +|`getColumnByName(name: string)`|Returns the column object with field property equal to `name` or `undefined` if no such column exists.| +|`getCellByColumn(rowIndex: number, columnField: string)`|Returns the cell object in column with `columnField` and row with `rowIndex` or `undefined`.| +|`addRow(data: any)`|Creates a new row object and adds the `data` record to the end of the data source.| +|`deleteRow(rowIndex: number)`|Removes the row object and the corresponding data record from the data source.| +|`updateRow(value: any, rowIndex: number)`|Updates the row object and the data source record with the passed value.| +|`updateCell(value: any, rowIndex: number, column: string)`|Updates the cell object and the record field in the data source.| +|`filter(name: string, value: any, conditionOrExpressionTree?: IFilteringOperation | IFilteringExpressionsTree, ignoreCase?: boolean)`|Filters a single column. A filtering condition or filtering expressions tree could be used. Check the available [filtering conditions](#filtering-conditions)| +|`clearFilter(name?: string)`|If `name` is provided, clears the filtering state of the corresponding column, otherwise clears the filtering state of all columns.| +|`sort(expression: ISortingExpression)`|Sorts a single column.| +|`sort(expressions: Array)`|Sorts the grid columns based on the provided array of sorting expressions.| +|`clearSort(name?: string)`|If `name` is provided, clears the sorting state of the corresponding column, otherwise clears the sorting state of all columns.| +|`enableSummaries(fieldName: string, customSummary?: any)`|Enable summaries for the specified column and apply your `customSummary`. If you do not provide the `customSummary`, then the default summary for the column data type will be applied.| +|`enableSummaries(expressions: Array)`|Enable summaries for the columns and apply your `customSummary` if it is provided.| +|`disableSummaries(fieldName: string)`|Disable summaries for the specified column.| +|`disableSummaries(columns: string[])`|Disable summaries for the listed columns.| +|`markForCheck()`|Manually triggers a change detection cycle for the grid and its children.| +|`pinColumn(name: string): boolean`|Pins a column by field name. Returns whether the operation is successful.| +|`unpinColumn(name: string): boolean`|Unpins a column by field name. Returns whether the operation is successful.| +|`selectedRows()`|Returns array of the currently selected rows' IDs| +|`selectRows(rowIDs: any[], clearCurrentSelection?: boolean)`|Marks the specified row(s) as selected in the grid `selectionAPI`. `clearCurrentSelection` first empties the grid's selection array.| +|`deselectRows(rowIDs: any[])`|Removes the specified row(s) from the grid's selection in the `selectionAPI`.| +|`selectAllRows()`|Marks all rows as selected in the grid `selectionAPI`.| +|`deselectAllRows()`|Sets the grid's row selection in the `selectionAPI` to `[]`.| +|`selectedColumns()`|Returns array of the currently selected columns| +|`selectColumns(columns: string[] | IgxColumnComponent[], clearCurrentSelection?: boolean)`|Marks the specified columns as selected in the grid `selectionAPI`. `clearCurrentSelection` first empties the grid's selection array.| +|`deselectColumns(columns: string[] | IgxColumnComponent[])`|Removes the specified columns from the grid's selection in the `selectionAPI`.| +|`deselectAllColumns()`|Sets the grid's column selection in the `selectionAPI` to `[]`.| +|`getSelectedColumnsData()`|Gets the the data form current selected columns.| +|`findNext(text: string, caseSensitive?: boolean, exactMatch?: boolean)`|Highlights all occurrences of the specified text and marks the next occurrence as active.| +|`findPrev(text: string, caseSensitive?: boolean, exactMatch?: boolean)`|Highlights all occurrences of the specified text and marks the previous occurrence as active.| +|`clearSearch(text: string, caseSensitive?: boolean)`|Removes all search highlights from the grid.| +|`refreshSearch()`|Refreshes the current search.| +|`groupBy(expression: IGroupingExpression)`| Groups by a new column based on the provided expression or modifies an existing one. +|`groupBy(expressions: Array)`| Groups columns based on the provided array of grouping expressions. +|`clearGrouping()`| Clears all grouping in the grid. +|`clearGrouping(fieldName: string)`| Clear grouping from a particular column. +|`isExpandedGroup(group: IGroupByRecord )`| Returns if a group is expanded or not. +|`toggleGroup(group: IGroupByRecord)`| Toggles the expansion state of a group. +|`toggleAllGroupRows()`| Toggles the expansion state of all group rows recursively. +|`selectAllRowsInGroup(group: IGroupByRecord, clearPrevSelection?: boolean)`| Select all rows within a group. +|`deselectAllRowsInGroup(group: IGroupByRecord)`| Deselect all rows within a group. +|`openAdvancedFilteringDialog()`| Opens the advanced filtering dialog. +|`closeAdvancedFilteringDialog(applyChanges: boolean)`| Closes the advanced filtering dialog. + + +## IgxColumnComponent + +### Inputs + +Inputs available on the **IgxGridColumnComponent** to define columns: + +|Name|Type|Description| +|--- |--- |--- | +|`field`|string|Column field name| +|`header`|string|Column header text| +|`sortable`|boolean|Set column to be sorted or not| +|`sortStrategy`| Provide custom sort strategy to be used when sorting| +|`editable`|boolean|Set column values to be editable| +|`filterable`|boolean|Set column values to be filterable| +|`hasSummary`| boolean |Sets whether or not the specific column has summaries enabled.| +|`summaries`| IgxSummaryOperand |Set custom summary for the specific column| +|`hidden`|boolean|Visibility of the column| +|`resizable`|boolean|Set column to be resizable| +|`selectable`|boolean|Set column to be selectable| +|`selected`|boolean|Set column to be selected| +|`width`|string|Columns width| +|`minWidth`|string|Columns minimal width| +|`maxWidth`|string|Columns miximum width| +|`headerClasses`|string|Additional CSS classes applied to the header element.| +|`cellClasses`|string|Additional CSS classes that can be applied conditionally to the cells in this column.| +|`formatter`|Function|A function used to "template" the values of the cells without the need to pass a cell template the column.| +|`index`|string|Column index| +|`filteringIgnoreCase`|boolean|Ignore capitalization of strings when filtering is applied. Defaults to _true_.| +|`sortingIgnoreCase`|boolean|Ignore capitalization of strings when sorting is applied. Defaults to _true_.| +|`dataType`|GridColumnDataType|One of string, number, boolean or Date. When filtering is enabled the filter UI conditions are based on the `dataType` of the column. Defaults to `string` if it is not provided. With `autoGenerate` enabled the grid will try to resolve the correct data type for each column based on the data source.| +|`editorOptions`|IColumnEditorOptions|Allows to pass optional parameters to control properties of the default column editors.| +|`pipeArgs`|IFieldPipeArgs|Pass optional parameters for DatePipe and/or DecimalPipe to format the display value for date and numeric columns.| +|`pinned`|boolean|Set column to be pinned or not| +|`searchable`|boolean|Determines whether the column is included in the search. If set to false, the cell values for this column will not be included in the results of the search API of the grid (defaults to true)| +|`groupable`|boolean| Determines whether the column may be grouped via the UI.| +|`disableHiding`|boolean| Enables/disables hiding for the column, default value is `false`.| +|`disablePinning`|boolean| Enables/disables pinning for the column, default value is `false`.| +|`rowStart`|number|Row index from which the field is starting. Only applies when the columns are within `IgxColumnLayoutComponent`.| +|`colStart`|number|Column index from which the field is starting. Only applies when the columns are within `IgxColumnLayoutComponent`.| +|`rowEnd`|string|Row index where the current field should end. The amount of rows between rowStart and rowEnd will determine the amount of spanning rows to that field. Only applies when the columns are within `IgxColumnLayoutComponent`.| +|`colEnd`|string|Column index where the current field should end. The amount of columns between colStart and colEnd will determine the amount of spanning columns to that field. Only applies when the columns are within `IgxColumnLayoutComponent`.| + + +### Methods +Here is a list of all public methods exposed by **IgxGridColumnComponent**: + +|Signature|Description| +|--- |--- | +|`pin(): boolean`|Pins the column. Returns if the operation is successful.| +|`unpin(): boolean`|Unpins the column. Returns if the operation is successful.| +|`move(index): boolean`|Moves the column to the specified visible index.| + + +### Getters/Setters + +|Name|Type|Getter|Setter|Description| +|--- |--- |--- |--- |--- | +|`bodyTemplate`|TemplateRef|Yes|Yes|Get/Set a reference to a template which will be applied to the cells in the column.| +|`headerTemplate`|TemplateRef|Yes|Yes|Get/Set a reference to a template which will be applied to the column header.| +|`footerTemplate`|TemplateRef|Yes|Yes|Get/Set a reference to a template which will be applied to the column footer.| +|`inlineEditorTemplate`|TemplateRef|Yes|Yes|Get/Set a reference to a template which will be applied as a cell enters edit mode.| +|`filterCellTemplate`|TemplateRef|Yes|Yes|Get/Set a reference to a template which will be applied to the filter cell of the column.| + + +## Filtering Conditions + +Use the filtering operand classes to apply conditions programmatically. Import the operand that matches your column data type and use its built-in condition names. + +```typescript +import { + IgxStringFilteringOperand, + IgxNumberFilteringOperand, + IgxDateFilteringOperand, + IgxBooleanFilteringOperand +} from 'igniteui-angular/core'; + +// Example: quick filter a column (string contains) +this.grid.filter('Name', 'John', IgxStringFilteringOperand.instance().condition('contains')); + +// Example: number greater than +this.grid.filter('Quantity', 10, IgxNumberFilteringOperand.instance().condition('greaterThan')); + +// Clear filter +this.grid.clearFilter('Name'); +``` + +### String types + +|Name|Signature|Description| +|--- |--- |--- | +|`contains`|`(target: string, searchVal: string, ignoreCase?: boolean)`|Returns true if the `target` contains the `searchVal`.| +|`startsWith`|`(target: string, searchVal: string, ignoreCase?: boolean)`|Returns true if the `target` starts with the `searchVal`.| +|`endsWith`|`(target: string, searchVal: string, ignoreCase?: boolean)`|Returns true if the `target` ends with the `searchVal`.| +|`doesNotContain`|`(target: string, searchVal: string, ignoreCase?: boolean)`|Returns true if `searchVal` is not in `target`.| +|`equals`|`(target: string, searchVal: string, ignoreCase?: boolean)`|Returns true if `searchVal` matches `target`.| +|`doesNotEqual`|`(target: string, searchVal: string, ignoreCase?: boolean)`|Returns true if `searchVal` does not match `target`.| +|`null`|`(target: any)`|Returns true if `target` is `null`.| +|`notNull`|`(target: any)`|Returns true if `target` is not `null`.| +|`empty`|`(target: any)`|Returns true if `target` is either `null`, `undefined` or a string of length 0.| +|`notEmpty`|`(target: any)`|Returns true if `target` is not `null`, `undefined` or a string of length 0.| + +Use them via the corresponding operand, for example: + +```typescript +const contains = IgxStringFilteringOperand.instance().condition('contains'); +this.grid.filter('Name', 'Ann', contains); +``` + + +### Number types + +|Name|Signature|Description| +|--- |--- |--- | +|`equals`|`(target: number, searchVal: number)`|Returns true if `target` equals `searchVal`.| +|`doesNotEqual`|`(target: number, searchVal: number)`|Returns true if `target` is not equal to `searchVal`.| +|`doesNotEqual`|`(target: number, searchVal: number)`|Returns true if `target` is greater than `searchVal`.| +|`lessThan`|`(target: number, searchVal: number)`|Returns true if `target` is less than `searchVal`.| +|`greaterThanOrEqualTo`|`(target: number, searchVal: number)`|Returns true if `target` is greater than or equal to `searchVal`.| +|`lessThanOrEqualTo`|`(target: number, searchVal: number)`|Returns true if `target` is less than or equal to `searchVal`.| +|`null`|`(target: any)`|Returns true if `target` is `null`.| +|`notNull`|`(target: any)`|Returns true if `target` is not `null`.| +|`empty`|`(target: any)`|Returns true if `target` is either `null`, `undefined` or `NaN`.| +|`notEmpty`|`(target: any)`|Returns true if `target` is not `null`, `undefined` or `NaN`.| + + +### Boolean types + +|Name|Signature|Description| +|--- |--- |--- | +|`all`|`(target: boolean)`|Returns all rows.| +|`true`|`(target: boolean)`|Returns if `target` is truthy.| +|`false`|`(target: boolean)`|Returns true if `target` is falsy.| +|`null`|`(target: any)`|Returns true if `target` is `null`.| +|`notNull`|`(target: any)`|Returns true if `target` is not `null`.| +|`empty`|`(target: any)`|Returns true if `target` is either `null` or `undefined`.| +|`notEmpty`|`(target: any)`|Returns true if target is not `null` or `undefined`.| + +### Date types + +|Name|Signature|Description| +|--- |--- |--- | +|`equals`|`(target: Date, searchVal: Date)`|Returns `true` if `target` equals `searchVal`.| +|`doesNotEqual`|`(target: Date, searchVal: Date)`|Returns `true` if `target` does not equal `searchVal`.| +|`before`|`(target: Date, searchVal: Date)`|Returns `true` if `target` is earlier than `searchVal`.| +|`after`|`(target: Date, searchVal: Date)`|Returns `true` if `target` is after `searchVal`.| +|`today`|`(target: Date)`|Returns `true` if `target` is the current date.| +|`yesterday`|`(target: Date)`|Returns `true` if `target` is the day before the current date.| +|`thisMonth`|`(target: Date)`|Returns `true` if `target` is contained in the current month.| +|`lastMonth`|`(target: Date)`|Returns `true` if `target` is contained in the month before the current month.| +|`nextMonth`|`(target: Date)`|Returns `true` if `target` is contained in the month following the current month.| +|`thisYear`|`(target: Date)`|Returns `true` if `target` is contained in the current year.| +|`lastYear`|`(target: Date)`|Returns `true` if `target` is contained in the year before the current year.| +|`nextYear`|`(target: Date)`|Returns `true` if `target` is contained in the year following the current year.| +|`null`|`(target: any)`|Returns true if `target` is `null`.| +|`notNull`|`(target: any)`|Returns true if `target` is not `null`.| +|`empty`|`(target: any)`|Returns true if `target` is either `null` or `undefined`.| +|`notEmpty`|`(target: any)`|Returns true if target is not `null` or `undefined`.| + +## IgxGridRowComponent + +### Getters/Setters + +|Name|Type|Getter|Setter|Description| +|--- |--- |--- |--- |--- | +|`rowData`|Array|Yes|No|The data passed to the row component.| +|`index`|number|Yes|No|The index of the row.| +|`cells`|QueryList|Yes|No|The rendered cells in the row component.| +|`grid`|IgxGridComponent|Yes|No|A reference to the grid containing the row.| +|`nativeElement`|HTMLElement|Yes|No|The native DOM element representing the row. Could be `null` in certain environments.| + +## IgxGridGroupByRowComponent + +### Getters/Setters + +|Name|Type|Getter|Setter|Description| +|--- |--- |--- |--- |--- | +|`index` | number | Yes | No | The index of the row in the rows list. | +|`grid`|IgxGridComponent|Yes|No|A reference to the grid containing the group row. | +|`groupRow` | IGroupByRecord | Yes | No | The group row data. Contains the related group expression, level, sub-records and group value. | +|`expanded` | boolean | Yes | No | Whether the row is expanded or not. | +|`groupContent` | ElementRef | Yes | No | The container for the group row template. Holds the group row content. | +|`focused` | boolean | Yes | No | Returns whether the group row is currently focused. | + +### Methods + +|Name|Return Type|Description| +|--- |--- |--- | +|`toggle()`|void| Toggles the expand state of the group row. | + +## IgxGridCell + +### Getters/Setters + +|Name|Type|Getter|Setter|Description| +|--- |--- |--- |--- |--- | +|`grid`|IgxGridComponent|Yes|No|The grid component itself.| +|`column`|IgxColumnComponent|Yes|No|The column to which the cell belongs.| +|`row`|RowType|Yes|No|The row to which the cell belongs.| +|`value`|any|Yes|Yes|The value in the cell.| +|`editValue`|any|Yes|No|The value in the cell editor.| +|`selected`|boolean|Yes|Yes|Returns if the cell is selected.| +|`active`|boolean|Yes|No|Returns if the cell is active (focused).| +|`editable`|boolean|Yes|No|Returns if the cell can enter edit mode).| +|`editMode`|boolean|Yes|Yes|Gets/Sets the cell in edit mode.| +|`id`|object|Yes|No|An object describing the cell with `rowID`, `columnID` and `rowIndex`.| +|`editMode`|boolean|Yes|Yes|Gets/Sets the cell in edit mode.| + +### Methods + +|Name|Return Type|Description| +|--- |--- |--- | +|`update(val: any)`|void|Emits the `onEditDone` event and updates the appropriate record in the data source.| + +## IgxGridState Directive + +### Getters/Setters + +|Name|Type|Getter|Setter|Description| +|--- |--- |--- |--- |--- | +|`options`|IGridStateOptions|Yes|Yes|Features to be exluded from tracking in the IgxGridState directive.| + +### Methods + +|Name|Return Type|Description| +|--- |--- |--- | +|`getState(serialize: boolean, feature?: string | string[])`|IGridState, string|Gets the state of a feature or states of all grid features, unless a certain feature is disabled through the `options` property..| +|`setState(val: IGridState | string)`|void|Restores grid features' state based on the IGridState object passed as an argument.| diff --git a/projects/igniteui-angular/grids/grid/index.ts b/projects/igniteui-angular/grids/grid/index.ts new file mode 100644 index 00000000000..a945b107663 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/index.ts @@ -0,0 +1,9 @@ +/** + * IgxGrid - Standard data grid component + * + * Import grid-specific components and re-export core grid functionality + */ + +// Export grid-specific components +export * from './src/public_api'; +export * from './src/grid.module'; diff --git a/projects/igniteui-angular/grids/grid/ng-package.json b/projects/igniteui-angular/grids/grid/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/grids/grid/src/cell-merge.spec.ts b/projects/igniteui-angular/grids/grid/src/cell-merge.spec.ts new file mode 100644 index 00000000000..46a12dbee2c --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/cell-merge.spec.ts @@ -0,0 +1,1283 @@ +import { Component, TemplateRef, ViewChild } from '@angular/core'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { ByLevelTreeGridMergeStrategy, DefaultMergeStrategy, DefaultSortingStrategy, GridColumnDataType, GridTypeBase, IgxStringFilteringOperand, ɵSize, SortingDirection } from 'igniteui-angular/core'; +import { IgxPaginatorComponent } from 'igniteui-angular/paginator';; +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, setElementSize } from '../../../test-utils/helper-utils.spec'; +import { ColumnLayoutTestComponent } from './grid.multi-row-layout.spec'; +import { IgxHierarchicalGridTestBaseComponent } from '../../hierarchical-grid/src/hierarchical-grid.spec'; +import { IgxTreeGridSelectionComponent } from '../../../test-utils/tree-grid-components.spec'; +import { IgxGridComponent } from './grid.component'; +import { IgxHierarchicalRowComponent } from '../../hierarchical-grid/src/hierarchical-row.component'; +import { IgxHierarchicalGridComponent } from 'igniteui-angular/grids/hierarchical-grid'; +import { GridCellMergeMode, IgxColumnComponent, IgxGridMRLNavigationService, IgxGridNavigationService } from 'igniteui-angular/grids/core'; + +describe('IgxGrid - Cell merging #grid', () => { + let fix; + let grid: IgxGridComponent; + const MERGE_CELL_CSS_CLASS = '.igx-grid__td--merged'; + const CSS_CLASS_GRID_ROW = '.igx-grid__tr'; + const HIGHLIGHT_ACTIVE_CSS_CLASS = '.igx-highlight__active'; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, DefaultCellMergeGridComponent, ColumnLayoutTestComponent, + IgxHierarchicalGridTestBaseComponent, IgxTreeGridSelectionComponent + ], + providers: [ + IgxGridMRLNavigationService, + IgxGridNavigationService + ] + }).compileComponents(); + })); + + + + describe('Basic', () => { + + beforeEach(() => { + fix = TestBed.createComponent(DefaultCellMergeGridComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + }); + + 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 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', () => { + 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, 2px border + expect(mergedCell.getBoundingClientRect().height).toBe(100 + 200 + 2); + }); + }); + }); + + describe('Integration', () => { + beforeEach(() => { + fix = TestBed.createComponent(IntegrationCellMergeGridComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + }); + + 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("50px 50px"); + + // 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("50px 50px"); + }); + }); + + 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('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 } + ]); + }); + + + }); + + 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("50px 50px"); + }); + }); + + 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("50px 50px"); + }); + + 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("50px 50px"); + }); + + 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.', async () => { + 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); + await wait(1); + (grid as any)._activeRowIndexes = null; + fix.detectChanges(); + + expect((grid as any).activeRowIndexes).toEqual([0, 0]); + 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 interrupt merge sequence correctly when there are multiple overlapping merge groups affected.', async () => { + const col1 = grid.getColumnByName('ProductName'); + const col2 = grid.getColumnByName('Downloads'); + const col3 = grid.getColumnByName('Released'); + const col4 = grid.getColumnByName('ReleaseDate'); + + col1.merge = true; + col2.merge = true; + col3.merge = true; + col4.merge = true; + + fix.detectChanges(); + + const data = [ + { + Downloads: 1000, + ID: 1, + ProductName: 'Ignite UI for JavaScript', + ReleaseDate: fix.componentInstance.today, + Released: true + }, + { + Downloads: 1000, + ID: 2, + ProductName: 'Ignite UI for JavaScript', + ReleaseDate: fix.componentInstance.today, + Released: true + }, + { + Downloads: 1000, + ID: 3, + ProductName: 'Ignite UI for Angular', + ReleaseDate: fix.componentInstance.today, + Released: true + }, + { + Downloads: 1000, + ID: 4, + ProductName: 'Ignite UI for JavaScript', + ReleaseDate: fix.componentInstance.prevDay, + Released: true + }, + { + Downloads: 100, + ID: 5, + ProductName: 'Ignite UI for Angular', + ReleaseDate: fix.componentInstance.prevDay, + Released: true + }, + { + Downloads: 1000, + ID: 6, + ProductName: 'Ignite UI for Angular', + ReleaseDate: null, + Released: true + }, + { + Downloads: 0, + ID: 7, + ProductName: null, + ReleaseDate: fix.componentInstance.prevDay, + Released: true + }, + { + Downloads: 1000, + ID: 8, + ProductName: 'NetAdvantage', + ReleaseDate: fix.componentInstance.prevDay, + Released: true + }, + { + Downloads: 1000, + ID: 9, + ProductName: 'NetAdvantage', + ReleaseDate: null, + Released: true + } + ]; + fix.componentInstance.data = data; + fix.detectChanges(); + + const row1 = grid.rowList.toArray()[0]; + UIInteractions.simulateClickAndSelectEvent(row1.cells.toArray()[1].nativeElement); + await wait(1); + (grid as any)._activeRowIndexes = null; + fix.detectChanges(); + + expect((grid as any).activeRowIndexes).toEqual([0, 0]); + GridFunctions.verifyColumnMergedState(grid, col1, [ + { 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 } + ]); + + GridFunctions.verifyColumnMergedState(grid, col2, [ + { value: 1000, span: 1 }, + { value: 1000, span: 3 }, + { value: 100, span: 1 }, + { value: 1000, span: 1 }, + { value: 0, span: 1 }, + { value: 1000, span: 2 } + ]); + + GridFunctions.verifyColumnMergedState(grid, col3, [ + { value: true, span: 1 }, + { value: true, span: 8 } + ]); + + GridFunctions.verifyColumnMergedState(grid, col4, [ + { value: fix.componentInstance.today, span: 1 }, + { value: fix.componentInstance.today, span: 2 }, + { value: fix.componentInstance.prevDay, span: 2 }, + { value: null, span: 1 }, + { value: fix.componentInstance.prevDay, span: 2 }, + { value: null, span: 1 } + ]); + + const row2 = grid.rowList.toArray()[1]; + UIInteractions.simulateClickAndSelectEvent(row2.cells.toArray()[1].nativeElement); + await wait(1); + (grid as any)._activeRowIndexes = null; + fix.detectChanges(); + + expect((grid as any).activeRowIndexes).toEqual([1, 1]); + GridFunctions.verifyColumnMergedState(grid, col1, [ + { 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 } + ]); + + GridFunctions.verifyColumnMergedState(grid, col2, [ + { value: 1000, span: 1 }, + { value: 1000, span: 1 }, + { value: 1000, span: 2 }, + { value: 100, span: 1 }, + { value: 1000, span: 1 }, + { value: 0, span: 1 }, + { value: 1000, span: 2 } + ]); + + GridFunctions.verifyColumnMergedState(grid, col3, [ + { value: true, span: 1 }, + { value: true, span: 1 }, + { value: true, span: 7 } + ]); + + GridFunctions.verifyColumnMergedState(grid, col4, [ + { value: fix.componentInstance.today, span: 1 }, + { value: fix.componentInstance.today, span: 1 }, + { value: fix.componentInstance.today, span: 1 }, + { value: fix.componentInstance.prevDay, span: 2 }, + { value: null, span: 1 }, + { value: fix.componentInstance.prevDay, span: 2 }, + { value: null, span: 1 } + ]); + + const row3 = grid.rowList.toArray()[2]; + UIInteractions.simulateClickAndSelectEvent(row3.cells.toArray()[1].nativeElement); + await wait(1); + (grid as any)._activeRowIndexes = null; + fix.detectChanges(); + + expect((grid as any).activeRowIndexes).toEqual([2, 2]); + GridFunctions.verifyColumnMergedState(grid, col1, [ + { 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.verifyColumnMergedState(grid, col2, [ + { value: 1000, span: 2 }, + { value: 1000, span: 1 }, + { value: 1000, span: 1 }, + { value: 100, span: 1 }, + { value: 1000, span: 1 }, + { value: 0, span: 1 }, + { value: 1000, span: 2 } + ]); + + GridFunctions.verifyColumnMergedState(grid, col3, [ + { value: true, span: 2 }, + { value: true, span: 1 }, + { value: true, span: 6 } + ]); + + GridFunctions.verifyColumnMergedState(grid, col4, [ + { value: fix.componentInstance.today, span: 2 }, + { value: fix.componentInstance.today, span: 1 }, + { value: fix.componentInstance.prevDay, span: 2 }, + { value: null, span: 1 }, + { value: fix.componentInstance.prevDay, span: 2 }, + { value: null, span: 1 } + ]); + }); + + }); + + 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.', async () => { + 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); + await wait(1); + 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); + + const mergedIntersectedCell = grid.gridAPI.get_cell_by_index(0, 'ProductName'); + // check cell has selected style + hasClass(mergedIntersectedCell.nativeElement, 'igx-grid__td--merged-selected', true); + }); + + }); + + 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.', 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'); + 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); + }); + + }); + + describe('Sizing', () => { + it('should size correct when size is set to anything other than large', async () => { + fix.componentInstance.cols = [{ field: 'ProductName', dataType: GridColumnDataType.String, merge: true }] + fix.detectChanges(); + setElementSize(grid.nativeElement, ɵSize.Small) + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + const sizes = grid.rowList.map(x => parseFloat(getComputedStyle(x.nativeElement).getPropertyValue("height"))); + const expectedSizes = new Array(9).fill(32); + + expect(sizes).toEqual(expectedSizes); + }); + + }); + + 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 } + ]); + }); + }); + }); +}); + +@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; + + @ViewChild('customTemplate', { read: TemplateRef, static: true }) + 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 } + ]; + + 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 + } + ]; + +} + +@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[], + isDate?: boolean, + isTime?: boolean, + grid?: GridTypeBase + ) { + return data; + } +} diff --git a/projects/igniteui-angular/grids/grid/src/cell.spec.ts b/projects/igniteui-angular/grids/grid/src/cell.spec.ts new file mode 100644 index 00000000000..95d97bc6142 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/cell.spec.ts @@ -0,0 +1,462 @@ +import { Component, ViewChild, OnInit, NgZone, DebugElement } from '@angular/core'; +import { TestBed, fakeAsync, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxGridComponent } from './public_api'; +import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; +import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; +import { VirtualGridComponent, NoScrollsComponent, + NoColumnWidthGridComponent, IgxGridDateTimeColumnComponent } from '../../../test-utils/grid-samples.spec'; +import { GridFunctions } from '../../../test-utils/grid-functions.spec'; +import { TestNgZone } from '../../../test-utils/helper-utils.spec'; +import { CellType, IGridCellEventArgs, IgxColumnComponent } from 'igniteui-angular/grids/core'; +import { HammerGesturesManager, PlatformUtil } from 'igniteui-angular/core'; + +describe('IgxGrid - Cell component #grid', () => { + + describe('Test events', () => { + let fix; + let grid: IgxGridComponent; + let cellElem: DebugElement; + let firstCell: CellType; + let firstCellElem: CellType; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, NoScrollsComponent + ] + }).compileComponents(); + })); + + beforeEach(() => { + fix = TestBed.createComponent(NoScrollsComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + cellElem = GridFunctions.getRowCells(fix, 0)[0]; + firstCell = grid.getCellByColumn(0, 'ID'); + firstCellElem = grid.gridAPI.get_cell_by_index(0, 'ID'); + }); + + it('@Input properties and getters', () => { + expect(firstCell.column.index).toEqual(grid.columnList.first.index); + expect(firstCell.row.index).toEqual(grid.rowList.first.index); + expect(firstCell.grid).toBe(grid); + expect(firstCell.active).toBeFalse(); + expect(firstCell.selected).toBeFalse(); + expect(firstCell.editMode).toBeFalse(); + expect(firstCell.editValue).toBeUndefined(); + expect(firstCellElem.nativeElement).toBeDefined(); + expect(firstCellElem.nativeElement.textContent).toMatch('1'); + expect(firstCellElem.readonly).toBe(true); + }); + + it('selection and selection events', () => { + expect(cellElem.nativeElement.getAttribute('aria-selected')).toMatch('false'); + + spyOn(grid.selected, 'emit').and.callThrough(); + UIInteractions.simulateClickAndSelectEvent(cellElem); + const args: IGridCellEventArgs = { + cell: grid.getCellByColumn(0, 'ID'), + event: jasmine.anything() as any + }; + fix.detectChanges(); + + expect(grid.selected.emit).toHaveBeenCalledWith(args); + expect(firstCell.selected).toBe(true); + expect(cellElem.nativeElement.getAttribute('aria-selected')).toMatch('true'); + + firstCell.selected = !firstCell.selected; + expect(firstCell.selected).toBe(false); + + firstCell.selected = !firstCell.selected; + expect(firstCell.selected).toBe(true); + }); + + it('Should not emit selection event for already selected cell', () => { + grid.getColumnByName('ID').editable = true; + fix.detectChanges(); + + spyOn(grid.selected, 'emit').and.callThrough(); + + UIInteractions.simulateClickAndSelectEvent(cellElem); + fix.detectChanges(); + + expect(grid.selected.emit).toHaveBeenCalledTimes(1); + + const gridContent = GridFunctions.getGridContent(fix); + UIInteractions.triggerEventHandlerKeyDown('Enter', gridContent); + fix.detectChanges(); + UIInteractions.triggerEventHandlerKeyDown('Escape', gridContent); + fix.detectChanges(); + + expect(grid.selected.emit).toHaveBeenCalledTimes(1); + }); + + it('Should trigger onCellClick event when click into cell', () => { + spyOn(grid.cellClick, 'emit').and.callThrough(); + const event = new Event('click'); + firstCellElem.nativeElement.dispatchEvent(event); + const args: IGridCellEventArgs = { + cell: grid.getCellByColumn(0, 'ID'), + event + }; + + fix.detectChanges(); + expect(grid.cellClick.emit).toHaveBeenCalledTimes(1); + expect(grid.cellClick.emit).toHaveBeenCalledWith(args); + }); + + it('Should trigger doubleClick event', () => { + grid.columnList.get(0).editable = true; + fix.detectChanges(); + spyOn(grid.doubleClick, 'emit').and.callThrough(); + + cellElem.triggerEventHandler('dblclick', new Event('dblclick')); + fix.detectChanges(); + expect(grid.doubleClick.emit).toHaveBeenCalledTimes(1); + + cellElem.triggerEventHandler('dblclick', new Event('dblclick')); + fix.detectChanges(); + expect(grid.doubleClick.emit).toHaveBeenCalledTimes(2); + }); + + it('Should trigger contextMenu event when right click into cell', () => { + spyOn(grid.contextMenu, 'emit').and.callThrough(); + const event = new Event('contextmenu', { bubbles: true }); + cellElem.nativeElement.dispatchEvent(event); + + fix.detectChanges(); + expect(grid.contextMenu.emit).toHaveBeenCalledTimes(1); + expect(grid.contextMenu.emit).toHaveBeenCalledWith(jasmine.objectContaining({ + cell: jasmine.anything(), + row: jasmine.anything() + })); + }); + + it('Should trigger doubleClick event when double click into cell', () => { + spyOn(grid.doubleClick, 'emit').and.callThrough(); + const event = new Event('dblclick'); + spyOn(event, 'preventDefault'); + cellElem.nativeElement.dispatchEvent(event); + const args: IGridCellEventArgs = { + cell: grid.getCellByColumn(0, 'ID'), + event + }; + + fix.detectChanges(); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(grid.doubleClick.emit).toHaveBeenCalledWith(args); + }); + }); + + describe('Cells in virtualized grid ', () => { + let fix; + let grid: IgxGridComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, VirtualGridComponent], + providers: [{ provide: NgZone, useFactory: () => new TestNgZone() }] + }).compileComponents(); + })); + + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(VirtualGridComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + })); + + it('should fit last cell in the available display container when there is vertical scroll.', () => { + const rows = grid.rowList; + rows.forEach((item) => { + expect(item.cells.last.width).toEqual('200px'); + }); + }); + + it('should use default column width for cells with width in %.', () => { + fix.componentInstance.defaultWidth = '25%'; + fix.detectChanges(); + const rows = grid.rowList; + rows.forEach((item) => { + expect(item.cells.last.width).toEqual('25%'); + }); + }); + + it('should fit last cell in the available display container when there is vertical and horizontal scroll.', (async () => { + fix.componentInstance.columns = fix.componentInstance.generateCols(100); + fix.componentInstance.data = fix.componentInstance.generateData(1000); + await wait(); + fix.detectChanges(); + + const firsCell = GridFunctions.getRowCells(fix, 1)[0]; + expect(GridFunctions.getValueFromCellElement(firsCell)).toEqual('0'); + + fix.componentInstance.scrollLeft(999999); + await wait(); + // This won't work always in debugging mode due to the angular native events behavior, so errors are expected + fix.detectChanges(); + const cells = GridFunctions.getRowCells(fix, 1); + const lastCell = cells[cells.length - 1]; + expect(GridFunctions.getValueFromCellElement(lastCell)).toEqual('990'); + + // Calculate where the end of the cell is. Relative left position should equal the grid calculated width + expect(lastCell.nativeElement.getBoundingClientRect().left + + lastCell.nativeElement.offsetWidth + + grid.scrollSize).toEqual(parseInt(grid.width, 10)); + })); + + it('should not reduce the width of last pinned cell when there is vertical scroll.', () => { + const columns = grid.columnList; + const lastCol: IgxColumnComponent = columns.last; + lastCol.pinned = true; + fix.detectChanges(); + lastCol.cells.forEach((cell) => { + expect(cell.width).toEqual('200px'); + }); + const rows = fix.componentInstance.grid.rowList; + rows.forEach((item) => { + expect(item.cells.last.width).toEqual('200px'); + }); + }); + + it('should not make last column smaller when vertical scrollbar is on the right of last cell', () => { + fix.componentInstance.columns = fix.componentInstance.generateCols(4, '30px'); + fix.componentInstance.data = fix.componentInstance.generateData(10); + fix.detectChanges(); + + const lastColumnCells = grid.columnList.get(grid.columnList.length - 1).cells; + lastColumnCells.forEach((item) => { + expect(item.width).toEqual('30px'); + }); + }); + + it('should not make last column smaller when vertical scrollbar is on the left of last cell', async () => { + fix.componentInstance.columns = fix.componentInstance.generateCols(4, '500px'); + fix.componentInstance.data = fix.componentInstance.generateData(10); + fix.detectChanges(); + + const scrollbar = grid.headerContainer.getScroll(); + scrollbar.scrollLeft = 10000; + fix.detectChanges(); + await wait(); + const lastColumnCells = grid.columnList.get(grid.columnList.length - 1).cells; + fix.detectChanges(); + lastColumnCells.forEach((item) => { + expect(item.width).toEqual('500px'); + }); + }); + + it('Should not clear selected cell when scrolling with mouse wheel', (async () => { + const cell = grid.gridAPI.get_cell_by_index(3, 'value'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + const displayContainer = grid.verticalScrollContainer.dc.instance._viewContainer.element.nativeElement; + await UIInteractions.simulateWheelEvent(displayContainer, 0, 200); + fix.detectChanges(); + await wait(16); + + const gridContent = GridFunctions.getGridContent(fix); + UIInteractions.triggerEventHandlerKeyDown('arrowup', gridContent); + await wait(16); + fix.detectChanges(); + + expect(grid.getCellByColumn(2, 'value').selected).toBeTruthy(); + })); + }); + + describe('iOS tests', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, NoScrollsComponent + ] + }).compileComponents(); + })); + + it('Should not attach doubletap handler for non-iOS', () => { + const addListenerSpy = spyOn(HammerGesturesManager.prototype, 'addEventListener'); + const platformUtil: PlatformUtil = TestBed.inject(PlatformUtil); + const oldIsIOS = platformUtil.isIOS; + platformUtil.isIOS = false; + const fix = TestBed.createComponent(NoScrollsComponent); + fix.detectChanges(); + // spyOnProperty(PlatformUtil.prototype, 'isIOS').and.returnValue(false); + expect(addListenerSpy).not.toHaveBeenCalled(); + + platformUtil.isIOS = oldIsIOS; + }); + + it('Should handle doubletap on iOS, trigger doubleClick event', () => { + const addListenerSpy = spyOn(HammerGesturesManager.prototype, 'addEventListener'); + const platformUtil: PlatformUtil = TestBed.inject(PlatformUtil); + const oldIsIOS = platformUtil.isIOS; + platformUtil.isIOS = true; + const fix = TestBed.createComponent(NoScrollsComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + const firstCellElem = grid.gridAPI.get_cell_by_index(0, 'ID'); + + // should attach 'doubletap' + expect(addListenerSpy.calls.count()).toBeGreaterThan(1); + expect(addListenerSpy).toHaveBeenCalledWith(firstCellElem.nativeElement, 'doubletap', firstCellElem.onDoubleClick, + { cssProps: {} as any }); + + spyOn(grid.doubleClick, 'emit').and.callThrough(); + + const event = { + type: 'doubletap', + preventDefault: jasmine.createSpy('preventDefault') + }; + firstCellElem.onDoubleClick(event as any); + const args: IGridCellEventArgs = { + cell: grid.getCellByColumn(0, 'ID'), + event + } as any; + + fix.detectChanges(); + expect(event.preventDefault).toHaveBeenCalled(); + expect(grid.doubleClick.emit).toHaveBeenCalledWith(args); + + platformUtil.isIOS = oldIsIOS; + }); + }); + + describe('No column widths', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, NoColumnWidthGridComponent + ] + }).compileComponents(); + })); + + it('should not make last column width 0 when no column width is set', () => { + const fix = TestBed.createComponent(NoColumnWidthGridComponent); + fix.detectChanges(); + const columns = fix.componentInstance.grid.columns; + const lastCol: IgxColumnComponent = columns[columns.length - 1]; + lastCol._cells.forEach((cell) => { + expect(cell.nativeElement.clientWidth).toBeGreaterThan(100); + }); + }); + }); + + describe('Cells styles', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, ConditionalCellStyleTestComponent + ] + }).compileComponents(); + })); + + it('should be able to conditionally style cells', fakeAsync(() => { + const fixture = TestBed.createComponent(ConditionalCellStyleTestComponent); + fixture.detectChanges(); + + const grid = fixture.componentInstance.grid; + + grid.getColumnByName('UnitsInStock')._cells.forEach((cell) => { + expect(cell.nativeElement.classList).toContain('test1'); + }); + + const indexColCells = grid.getColumnByName('ProductID')._cells; + + expect(indexColCells[3].nativeElement.classList).not.toContain('test'); + expect(indexColCells[4].nativeElement.classList).toContain('test2'); + expect(indexColCells[5].nativeElement.classList).toContain('test'); + expect(indexColCells[6].nativeElement.classList).toContain('test'); + + expect(grid.getColumnByName('ProductName')._cells[4].nativeElement.classList).toContain('test2'); + expect(grid.getColumnByName('InStock')._cells[4].nativeElement.classList).toContain('test2'); + expect(grid.getColumnByName('OrderDate')._cells[4].nativeElement.classList).toContain('test2'); + })); + }); + + describe('Cell properties', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, IgxGridDateTimeColumnComponent + ] + }).compileComponents(); + })); + + it('verify that value of the cell title is correctly', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxGridDateTimeColumnComponent); + fixture.detectChanges(); + + const grid = fixture.componentInstance.grid; + const receiveTime = grid.getColumnByName('ReceiveTime'); + + expect(receiveTime._cells[0].title.normalize("NFKD")).toEqual('8:37:11 AM'); + expect(receiveTime._cells[5].title.normalize("NFKD")).toEqual('12:47:42 PM'); + + const product = grid.getColumnByName('ProductName'); + + expect(product._cells[2].title).toEqual('Antons Cajun Seasoning'); + expect(product._cells[6].title).toEqual('Queso Cabrales'); + + product.formatter = fixture.componentInstance.testFormatter; + fixture.detectChanges(); + + expect(product._cells[2].title).toEqual('testAntons Cajun Seasoning'); + expect(product._cells[6].title).toEqual('testQueso Cabrales'); + + })); + }); +}); +@Component({ + template: ` + + @for (c of columns; track c.field) { + + + } + `, + styleUrls: ['../../../test-utils/grid-cell-style-testing.scss'], + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class ConditionalCellStyleTestComponent implements OnInit { + @ViewChild('grid', { static: true }) public grid: IgxGridComponent; + + public data: Array; + public columns: Array; + + public cellClasses; + public cellClasses1; + + public callback = (rowData: any, columnKey: any) => rowData[columnKey] >= 5; + + public callback1 = (rowData: any) => rowData[this.grid.primaryKey] === 5; + + public ngOnInit(): void { + this.cellClasses = { + test: this.callback, + test2: this.callback1 + }; + + this.cellClasses1 = { + test2: this.callback1 + }; + + this.columns = [ + { field: 'ProductID', width: 100, cellClasses: this.cellClasses }, + { field: 'ProductName', width: 200, cellClasses: this.cellClasses1 }, + { field: 'InStock', width: 150, cellClasses: this.cellClasses1 }, + { field: 'UnitsInStock', width: 150, cellClasses: { test1: true } }, + { field: 'OrderDate', width: 150, cellClasses: this.cellClasses1 } + ]; + this.data = SampleTestData.foodProductDataExtended(); + } +} diff --git a/projects/igniteui-angular/grids/grid/src/column-group.spec.ts b/projects/igniteui-angular/grids/grid/src/column-group.spec.ts new file mode 100644 index 00000000000..232dc7f0d15 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/column-group.spec.ts @@ -0,0 +1,1909 @@ +import { TestBed, ComponentFixture, waitForAsync, fakeAsync, tick } from '@angular/core/testing'; +import { IgxGridComponent } from './grid.component'; +import { DebugElement, QueryList } from '@angular/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxColumnComponent } from 'igniteui-angular/grids/core'; +import { IgxColumnGroupComponent } from 'igniteui-angular/grids/core'; +import { By } from '@angular/platform-browser'; +import { IgxGridHeaderComponent } from 'igniteui-angular/grids/core'; +import { GridSummaryFunctions, GridFunctions } from '../../../test-utils/grid-functions.spec'; +import { wait } from '../../../test-utils/ui-interactions.spec'; +import { DropPosition } from 'igniteui-angular/grids/core'; +import { OneGroupOneColGridComponent, OneGroupThreeColsGridComponent, + BlueWhaleGridComponent, ColumnGroupTestComponent, ColumnGroupFourLevelTestComponent, + ThreeGroupsThreeColumnsGridComponent, + NestedColGroupsGridComponent, StegosaurusGridComponent, + OneColPerGroupGridComponent, NestedColumnGroupsGridComponent, + DynamicGridComponent, NestedColGroupsWithTemplatesGridComponent, + DynamicColGroupsGridComponent, + ColumnGroupHiddenInTemplateComponent} from '../../../test-utils/grid-mch-sample.spec'; +import { CellType } from 'igniteui-angular/grids/core'; +import { DefaultSortingStrategy, IgxStringFilteringOperand, SortingDirection } from 'igniteui-angular/core'; + +const GRID_COL_THEAD_TITLE_CLASS = 'igx-grid-th__title'; +const GRID_COL_GROUP_THEAD_TITLE_CLASS = 'igx-grid-thead__title'; +const GRID_COL_GROUP_THEAD_GROUP_CLASS = 'igx-grid-thead__group'; + + +describe('IgxGrid - multi-column headers #grid', () => { + let fixture: ComponentFixture; + let grid: IgxGridComponent; + let componentInstance; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + OneGroupOneColGridComponent, + OneGroupThreeColsGridComponent, + BlueWhaleGridComponent, + ColumnGroupTestComponent, + ColumnGroupFourLevelTestComponent, + ThreeGroupsThreeColumnsGridComponent, + NestedColGroupsGridComponent, + StegosaurusGridComponent, + OneColPerGroupGridComponent, + NestedColumnGroupsGridComponent, + DynamicGridComponent, + NestedColGroupsWithTemplatesGridComponent, + DynamicColGroupsGridComponent, + ColumnGroupHiddenInTemplateComponent + ] + }) + .compileComponents(); + })); + + describe('Initialization and rendering tests: ', () => { + it('should initialize a grid with column groups', () => { + fixture = TestBed.createComponent(ColumnGroupTestComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + const expectedColumnGroups = 5; + const expectedLevel = 2; + const groupHeaders = GridFunctions.getColumnGroupHeaders(fixture); + expect(groupHeaders.length).toEqual(expectedColumnGroups); + expect(grid.getColumnByName('ContactName').level).toEqual(expectedLevel); + }); + + it('should initialize a grid with correct header height', () => { + fixture = TestBed.createComponent(ColumnGroupTestComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + + const expectedGridHeaderHeight = 151; + const headerHeight = grid.nativeElement + .querySelector("igx-grid-header-row") + .getBoundingClientRect().height; + + expect(Math.round(headerHeight)).toEqual(expectedGridHeaderHeight); + }); + + it('Should render column group headers correctly.', fakeAsync(() => { + fixture = TestBed.createComponent(BlueWhaleGridComponent); + fixture.detectChanges(); + componentInstance = fixture.componentInstance; + grid = componentInstance.grid; + const columnWidthPx = parseInt(componentInstance.columnWidth, 10); + // 2 levels of column group and 1 level of columns + const gridHeadersDepth = 3; + + const firstGroupChildrenCount = 100; + const secondGroupChildrenCount = 2; + const secondSubGroupChildrenCount = 50; + const secondSubGroupHeadersDepth = 2; + + const firstGroup = GridFunctions.getColumnGroupHeaders(fixture)[0]; + testColumnGroupHeaderRendering(firstGroup, firstGroupChildrenCount * columnWidthPx, + gridHeadersDepth * grid.defaultRowHeight, componentInstance.firstGroupTitle, + 'firstGroupColumn', firstGroupChildrenCount); + + let horizontalScroll = grid.headerContainer.getScroll(); + let scrollToNextGroup = firstGroupChildrenCount * columnWidthPx + columnWidthPx; + horizontalScroll.scrollLeft = scrollToNextGroup; + + tick(); + fixture.detectChanges(); + const secondGroup = GridFunctions.getColumnGroupHeaders(fixture)[1]; + testColumnGroupHeaderRendering(secondGroup, + secondGroupChildrenCount * secondSubGroupChildrenCount * columnWidthPx, + gridHeadersDepth * grid.defaultRowHeight, componentInstance.secondGroupTitle, + 'secondSubGroup', 0); + + const secondSubGroups = secondGroup.queryAll(By.css('.secondSubGroup')); + testColumnGroupHeaderRendering(secondSubGroups[0], + secondSubGroupChildrenCount * columnWidthPx, + secondSubGroupHeadersDepth * grid.defaultRowHeight, componentInstance.secondSubGroupTitle, + 'secondSubGroupColumn', secondSubGroupChildrenCount); + + testColumnGroupHeaderRendering(secondSubGroups[1], + secondSubGroupChildrenCount * columnWidthPx, + secondSubGroupHeadersDepth * grid.defaultRowHeight, componentInstance.secondSubGroupTitle, + 'secondSubGroupColumn', secondSubGroupChildrenCount); + + horizontalScroll = grid.headerContainer.getScroll(); + scrollToNextGroup = horizontalScroll.scrollLeft + + secondSubGroupHeadersDepth * secondSubGroupChildrenCount * columnWidthPx; + + horizontalScroll.scrollLeft = scrollToNextGroup; + + tick(); + fixture.detectChanges(); + + const idColumn = fixture.debugElement.query(By.css('.lonelyId')); + testColumnHeaderRendering(idColumn, columnWidthPx, + gridHeadersDepth * grid.defaultRowHeight, componentInstance.idHeaderTitle); + + const companyNameColumn = GridFunctions.getColumnHeader('CompanyName', fixture); + testColumnHeaderRendering(companyNameColumn, columnWidthPx, + 2 * grid.defaultRowHeight, componentInstance.companyNameTitle); + + const personDetailsColumn = GridFunctions.getColumnGroupHeader('Person Details', fixture); + testColumnGroupHeaderRendering(personDetailsColumn, 2 * columnWidthPx, + 2 * grid.defaultRowHeight, componentInstance.personDetailsTitle, + 'personDetailsColumn', 2); + + })); + + it('Should render a hidden row of the leaf column headers for accessibility purposes.', fakeAsync(() => { + fixture = TestBed.createComponent(BlueWhaleGridComponent) as ComponentFixture; + (fixture as ComponentFixture).componentInstance.firstGroupRepeats = 0; + (fixture as ComponentFixture).componentInstance.secondGroupRepeats = 0; + tick(); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + grid = fixture.componentInstance.grid; + const gridHeader = GridFunctions.getGridHeader(grid); + + const groupHeaderEls = Array.from(gridHeader.nativeElement.querySelectorAll('.' + GRID_COL_GROUP_THEAD_TITLE_CLASS)); + for (const header of groupHeaderEls) { + expect(header.getAttribute('aria-hidden')).toBe('true'); + } + + const columnHeaders = GridFunctions.getColumnHeaders(fixture); + for (const header of columnHeaders) { + expect(header.nativeNode.getAttribute('aria-hidden')).toBe('true'); + } + + const hiddenRow = gridHeader.nativeElement.querySelectorAll('[role="row"]')[1]; + const horizontalVirtualization = grid.rowList.first.virtDirRow; + const chunkSize = horizontalVirtualization.state.chunkSize; + + expect(hiddenRow.children.length).toBeLessThanOrEqual(chunkSize); + expect(grid.columns.length).toBeGreaterThan(chunkSize); + + expect(hiddenRow.children[0].textContent).toBe('ID'); + expect(hiddenRow.children[1].textContent).toBe('Company Name'); + expect(hiddenRow.children[2].textContent).toBe('ContactName'); + expect(hiddenRow.children[3].textContent).toBe('ContactTitle'); + expect(hiddenRow.children[4].textContent).toBe('Country'); + expect(hiddenRow.children[5].textContent).toBe('Region'); + expect(hiddenRow.children[6].textContent).toBe('City'); + })); + + it('The hidden row of the leaf columns contains only headers of the rendered cells', fakeAsync(() => { + fixture = TestBed.createComponent(BlueWhaleGridComponent) as ComponentFixture; + tick(); + fixture.detectChanges(); + + grid = fixture.componentInstance.grid; + const horizontalVirtualization = grid.rowList.first.virtDirRow; + const chunkSize = horizontalVirtualization.state.chunkSize; + + tick(); + fixture.detectChanges(); + + const gridHeader = GridFunctions.getGridHeader(grid); + const hiddenRow = gridHeader.nativeElement.querySelectorAll('[role="row"]')[1]; + + expect(hiddenRow.children.length).toBeLessThanOrEqual(chunkSize); + for (const ariaHeader of Array.from(hiddenRow.children)) { + expect(ariaHeader.textContent).toBe('ID'); + } + })); + + it('The ariaHidden getter should not throw when the grid has no active node (#16517)', fakeAsync(() => { + fixture = TestBed.createComponent(BlueWhaleGridComponent) as ComponentFixture; + tick(); + fixture.detectChanges(); + + // The grid active node will be null if there is no data and the body is focused + grid = fixture.componentInstance.grid; + grid.data = []; + + tick(); + fixture.detectChanges(); + + const gridContent = GridFunctions.getGridContent(fixture); + + expect(() => { + gridContent.triggerEventHandler('focus', null); + tick(400); + fixture.detectChanges(); + }).not.toThrow(); + })); + + it('Should render dynamic column group header correctly (#12165).', () => { + fixture = TestBed.createComponent(BlueWhaleGridComponent) as ComponentFixture; + (fixture as ComponentFixture).componentInstance.firstGroupRepeats = 1; + (fixture as ComponentFixture).componentInstance.secondGroupRepeats = 1; + fixture.detectChanges(); + + componentInstance = fixture.componentInstance; + grid = componentInstance.grid; + const columnWidthPx = parseInt(componentInstance.columnWidth, 10); + // 2 levels of column group and 1 level of columns + const gridHeadersDepth = 3; + + let firstGroupChildrenCount = 1; + + let firstGroup = GridFunctions.getColumnGroupHeaders(fixture)[0]; + testColumnGroupHeaderRendering(firstGroup, firstGroupChildrenCount * columnWidthPx, + gridHeadersDepth * grid.defaultRowHeight, componentInstance.firstGroupTitle, + 'firstGroupColumn', firstGroupChildrenCount); + + let allHeaders = GridFunctions.getColumnHeaders(fixture).map(x => x.componentInstance); + let firstSixHeaders = allHeaders.slice(0, 6).map(x => x.column.field); + expect(allHeaders.length).toEqual(14); + expect(firstSixHeaders).toEqual(['ID', 'ID', 'ID', 'ID', 'CompanyName', 'ContactName']); + + (componentInstance as BlueWhaleGridComponent).extraMissingColumn = true; + fixture.detectChanges(); + + firstGroupChildrenCount = 2; + firstGroup = GridFunctions.getColumnGroupHeaders(fixture)[0]; + testColumnGroupHeaderRendering(firstGroup, firstGroupChildrenCount * columnWidthPx, + gridHeadersDepth * grid.defaultRowHeight, componentInstance.firstGroupTitle, + 'firstGroupColumn', firstGroupChildrenCount); + + allHeaders = GridFunctions.getColumnHeaders(fixture).map(x => x.componentInstance); + firstSixHeaders = allHeaders.slice(0, 6).map(x => x.column.field); + expect(allHeaders.length).toEqual(15); + expect(firstSixHeaders).toEqual(['ID', 'Missing', 'ID', 'ID', 'ID', 'CompanyName']); + }); + + it('Should not render empty column group.', () => { + fixture = TestBed.createComponent(ColumnGroupTestComponent); + fixture.detectChanges(); + const ci = fixture.componentInstance; + + // Empty column group should not be displayed + const emptyColGroup = GridFunctions.getColumnGroupHeader('Empty Header', fixture); + expect(parseInt(ci.emptyColGroup.width, 10)).toBe(0); + expect(emptyColGroup).toBeUndefined(); + }); + + it('Should render headers correctly when having a column per group.', () => { + fixture = TestBed.createComponent(OneColPerGroupGridComponent); + fixture.detectChanges(); + const ci = fixture.componentInstance; + grid = ci.grid; + + const addressColGroup = GridFunctions.getColumnGroupHeader('Address Group', fixture); + const addressColGroupDepth = 2; // one-level children + const addressColGroupChildrenCount = 1; + + testColumnGroupHeaderRendering(addressColGroup, parseInt(ci.columnWidth, 10), + addressColGroupDepth * grid.defaultRowHeight, ci.addressColGroupTitle, + 'addressCol', addressColGroupChildrenCount); + + const addressCol = GridFunctions.getColumnHeader('Address', fixture); + + testColumnHeaderRendering(addressCol, parseInt(ci.columnWidth, 10), + grid.defaultRowHeight, ci.addressColTitle); + + const phoneColGroup = GridFunctions.getColumnGroupHeader('Phone Group', fixture); + const phoneColGroupDepth = 2; // one-level children + const phoneColGroupChildrenCount = 1; + + testColumnGroupHeaderRendering(phoneColGroup, parseInt(ci.phoneColWidth, 10), + phoneColGroupDepth * grid.defaultRowHeight, ci.phoneColGroupTitle, + 'phoneCol', phoneColGroupChildrenCount); + + const phoneCol = GridFunctions.getColumnHeader('Phone', fixture); + + testColumnHeaderRendering(phoneCol, parseInt(ci.phoneColWidth, 10), + grid.defaultRowHeight, ci.phoneColTitle); + + const faxColGroup = GridFunctions.getColumnGroupHeader('Fax Group', fixture); + const faxColGroupDepth = 2; // one-level children + const faxColGroupChildrenCount = 1; + + testColumnGroupHeaderRendering(faxColGroup, parseInt(ci.faxColWidth, 10), + faxColGroupDepth * grid.defaultRowHeight, ci.faxColGroupTitle, 'faxCol', + faxColGroupChildrenCount); + + const faxCol = GridFunctions.getColumnHeader('Fax', fixture); + + testColumnHeaderRendering(faxCol, parseInt(ci.faxColWidth, 10), + grid.defaultRowHeight, ci.faxColTitle); + }); + + it('Should render headers correctly when having nested column groups.', () => { + fixture = TestBed.createComponent(NestedColumnGroupsGridComponent); + fixture.detectChanges(); + NestedColGroupsTests.testHeadersRendering(fixture); + }); + + it('Should render headers correctly when having nested column groups with huge header text.', () => { + fixture = TestBed.createComponent(NestedColumnGroupsGridComponent); + fixture.detectChanges(); + const ci = fixture.componentInstance; + grid = ci.grid; + + const title = 'Lorem Ipsum is simply dummy text of the printing and typesetting' + + ' industry.Lorem Ipsum has been the industry\'s standard dummy text ever since' + + ' the 1500s, when an unknown printer took a galley of type and scrambled it to' + + ' make a type specimen book. It has survived not only five centuries, but also the' + + ' leap into electronic typesetting, remaining essentially unchanged.It was popularised' + + ' in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and' + + ' more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.'; + ci.masterColGroupTitle = ci.firstSlaveColGroupTitle = + ci.secondSlaveColGroupTitle = ci.addressColTitle = ci.phoneColTitle = + ci.faxColTitle = ci.cityColTitle = title; + fixture.detectChanges(); + NestedColGroupsTests.testHeadersRendering(fixture); + }); + + it('Should correctly initialize column group templates.', () => { + fixture = TestBed.createComponent(NestedColGroupsWithTemplatesGridComponent); + fixture.detectChanges(); + const ci = fixture.componentInstance; + const locationColGroup = ci.locationColGroup; + const contactInfoColGroup = ci.contactInfoColGroup; + + expect(locationColGroup.headerTemplate).toBeDefined(); + expect(contactInfoColGroup.headerTemplate).toBeUndefined(); + + const headerSpans: DebugElement[] = fixture.debugElement.queryAll(By.css('.col-group-template')); + expect(headerSpans.length).toBe(1); + expect(headerSpans[0].nativeElement.textContent).toMatch('Column group template'); + }); + + it('Should correctly change column group templates dynamically.', () => { + fixture = TestBed.createComponent(NestedColGroupsWithTemplatesGridComponent); + fixture.detectChanges(); + componentInstance = fixture.componentInstance; + const locationColGroup = componentInstance.locationColGroup; + const genInfoColGroup = componentInstance.genInfoColGroup; + const headerTemplate = componentInstance.dynamicColGroupTemplate; + + locationColGroup.headerTemplate = headerTemplate; + genInfoColGroup.headerTemplate = headerTemplate; + fixture.detectChanges(); + + let headerSpans: DebugElement[] = fixture.debugElement.queryAll(By.css('.dynamic-col-group-template')); + expect(headerSpans.length).toBe(2); + headerSpans.forEach(headerSpan => { + expect(headerSpan.nativeElement.textContent).toMatch('Dynamic column group template'); + }); + + locationColGroup.headerTemplate = null; + fixture.detectChanges(); + + headerSpans = fixture.debugElement.queryAll(By.css('.dynamic-col-group-template')); + expect(headerSpans.length).toBe(1); + headerSpans.forEach(headerSpan => { + expect(headerSpan.nativeElement.textContent).toMatch('Dynamic column group template'); + }); + headerSpans = fixture.debugElement.queryAll(By.css('.col-group-template')); + expect(headerSpans.length).toBe(0); + headerSpans = fixture.debugElement.queryAll(By.css('.' + GRID_COL_GROUP_THEAD_TITLE_CLASS)); + expect(headerSpans[1].nativeElement.textContent).toBe('Location'); + }); + + it('There shouldn\'t be any errors when dynamically removing a column group with filtering enabled', () => { + fixture = TestBed.createComponent(DynamicColGroupsGridComponent); + fixture.detectChanges(); + + grid = fixture.componentInstance.grid; + + let columnLength = grid.columnList.length; + let firstColumnGroup = grid.columnList.first; + let expectedColumnName = 'First'; + let expectedColumnListLength = 10; + + expect(firstColumnGroup.header).toEqual(expectedColumnName); + expect(expectedColumnListLength).toEqual(columnLength); + + fixture.componentInstance.columnGroups = fixture.componentInstance.columnGroups.splice(1, fixture.componentInstance.columnGroups.length - 1); + fixture.detectChanges(); + fixture.componentInstance.columnGroups = fixture.componentInstance.columnGroups.splice(1, fixture.componentInstance.columnGroups.length - 1); + fixture.detectChanges(); + + firstColumnGroup = grid.columnList.first; + expectedColumnName = 'Third'; + columnLength = grid.columnList.length; + expectedColumnListLength = 3; + + expect(firstColumnGroup.header).toEqual(expectedColumnName); + expect(expectedColumnListLength).toEqual(columnLength); + }); + + it('There shouldn\'t be any errors when dynamically removing or adding a column in column group', () => { + fixture = TestBed.createComponent(DynamicColGroupsGridComponent); + fixture.detectChanges(); + + grid = fixture.componentInstance.grid; + + expect(grid.columnList.length).toEqual(10); + + expect(() => { + // Delete column + fixture.componentInstance.columnGroups[0].columns.splice(0, 1); + fixture.detectChanges(); + }).not.toThrow(); + + expect(grid.columnList.length).toEqual(9); + + expect(() => { + // Add column + fixture.componentInstance.columnGroups[0].columns.push({ field: 'Fax', type: 'string' }); + fixture.detectChanges(); + }).not.toThrow(); + + expect(grid.columnList.length).toEqual(10); + + expect(() => { + // Update column + fixture.componentInstance.columnGroups[0].columns[1] = { field: 'City', type: 'string' }; + fixture.detectChanges(); + }).not.toThrow(); + + expect(grid.columnList.length).toEqual(10); + }); + + it('There shouldn\'t be any errors when the grid is grouped by a column that isn\'t defined', () => { + fixture = TestBed.createComponent(ColumnGroupTestComponent); + fixture.componentInstance.hideGroupedColumns = true; + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + + expect(() => { + grid.groupBy({ + fieldName: 'NonExistentFieldName', dir: SortingDirection.Desc, ignoreCase: false, + strategy: DefaultSortingStrategy.instance() + }); + fixture.detectChanges(); + }).not.toThrow(); + }); + + it('should set title attribute on column group header spans', () => { + fixture = TestBed.createComponent(ColumnGroupTestComponent); + fixture.detectChanges(); + + grid = fixture.componentInstance.grid; + const generalGroup = grid.columnList.find(c => c.header === 'General Information'); + generalGroup.title = 'General Information Title'; + fixture.detectChanges(); + + const headers = fixture.debugElement.queryAll(By.css('.' + GRID_COL_GROUP_THEAD_TITLE_CLASS)); + const generalHeader = headers.find(h => h.nativeElement.textContent === 'General Information'); + const addressHeader = headers.find(h => h.nativeElement.textContent === 'Address Information'); + + expect(generalHeader.nativeElement.firstElementChild.title).toBe('General Information Title'); + expect(addressHeader.nativeElement.firstElementChild.title).toBe('Address Information'); + }); + + it('should hide column group when hidden property is set to true in the template - parent and child level', () => { + fixture = TestBed.createComponent(ColumnGroupHiddenInTemplateComponent); + fixture.detectChanges(); + + grid = fixture.componentInstance.grid; + const generalGroup = grid.columnList.find(c => c.header === 'General Information'); + const locationGroup = grid.columnList.find(c => c.header === 'Location'); + expect(generalGroup.hidden).toBe(true); + expect(locationGroup.hidden).toBe(true); + + expect(GridFunctions.getColumnHeaders(fixture).length).toEqual(6); + expect(GridFunctions.getColumnGroupHeaders(fixture).length).toEqual(2); + }); + }); + + describe('Columns widths tests (1 group 1 column) ', () => { + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(OneGroupOneColGridComponent); + fixture.detectChanges(); + componentInstance = fixture.componentInstance; + grid = fixture.componentInstance.grid; + })); + + it('Width should be correct. Column group with column. No width.', () => { + grid.ngAfterViewInit(); + fixture.detectChanges(); + const locationColGroup = getColGroup(grid, 'Location'); + expect(parseInt(locationColGroup.width, 10) + grid.scrollSize).toBe(parseInt(componentInstance.gridWrapperWidthPx, 10)); + const cityColumn = grid.getColumnByName('City'); + expect(parseInt(cityColumn.width, 10) + grid.scrollSize).toBe(parseInt(componentInstance.gridWrapperWidthPx, 10)); + }); + + it('Width should be correct. Column group with column. Width in px.', () => { + const gridWidth = '600px'; + const gridWidthPx = parseInt(gridWidth, 10); + grid.width = gridWidth; + fixture.detectChanges(); + + const locationColGroup = getColGroup(grid, 'Location'); + expect(parseInt(locationColGroup.width, 10) + grid.scrollSize).toBe(gridWidthPx); + const cityColumn = grid.getColumnByName('City'); + expect(parseInt(cityColumn.width, 10) + grid.scrollSize).toBe(gridWidthPx); + }); + + it('Width should be correct. Column group with column. Width in percent.', () => { + const gridWidth = '50%'; + grid.width = gridWidth; + fixture.detectChanges(); + + const locationColGroup = getColGroup(grid, 'Location'); + const gridWidthInPx = ((parseInt(gridWidth, 10) / 100) * + parseInt(componentInstance.gridWrapperWidthPx, 10) - grid.scrollSize) + 'px'; + expect(locationColGroup.width).toBe(gridWidthInPx); + const cityColumn = grid.getColumnByName('City'); + expect(cityColumn.width).toBe(gridWidthInPx); + }); + + it('Width should be correct. Column group with column. Column width in px.', () => { + const gridColWidth = '200px'; + grid.columnWidth = gridColWidth; + fixture.detectChanges(); + + const locationColGroup = getColGroup(grid, 'Location'); + expect(locationColGroup.width).toBe(gridColWidth); + const cityColumn = grid.getColumnByName('City'); + expect(cityColumn.width).toBe(gridColWidth); + }); + + it('Width should be correct. Column group with column. Column width in percent.', () => { + const gridColWidth = '50%'; + grid.columnWidth = gridColWidth; + fixture.detectChanges(); + + const locationColGroup = getColGroup(grid, 'Location'); + const expectedWidth = (grid.calcWidth / 2) + 'px'; + expect(locationColGroup.width).toBe(expectedWidth); + const cityColumn = grid.getColumnByName('City'); + expect(cityColumn.width).toBe(gridColWidth); + }); + + it('Width should be correct. Column group with column. Column with width in px.', () => { + const columnWidth = '200px'; + componentInstance.columnWidth = columnWidth; + fixture.detectChanges(); + + const locationColGroup = getColGroup(grid, 'Location'); + expect(locationColGroup.width).toBe(columnWidth); + const cityColumn = grid.getColumnByName('City'); + expect(cityColumn.width).toBe(columnWidth); + }); + + it('Width should be correct. Column group with column. Column with width in percent.', () => { + const columnWidth = '50%'; + componentInstance.columnWidth = columnWidth; + fixture.detectChanges(); + + const locationColGroup = getColGroup(grid, 'Location'); + const expectedWidth = (grid.calcWidth / 2) + 'px'; + expect(locationColGroup.width).toBe(expectedWidth); + const cityColumn = grid.getColumnByName('City'); + expect(cityColumn.width).toBe(columnWidth); + }); + + it('Should not throw exception if multi-column header columns width is set as number', () => { + + expect(() => { + const cityColumn = grid.getColumnByName('City'); + (cityColumn.width as any) = 55; + fixture.detectChanges(); + }).not.toThrow(); + }); + + }); + + describe('Columns widths tests (1 group 3 columns) ', () => { + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(OneGroupThreeColsGridComponent); + fixture.detectChanges(); + componentInstance = fixture.componentInstance; + grid = fixture.componentInstance.grid; + })); + + it('Width should be correct. Column group with three columns. No width.', () => { + const scrWitdh = grid.nativeElement.querySelector('.igx-grid__tbody-scrollbar').getBoundingClientRect().width; + const availableWidth = (parseInt(componentInstance.gridWrapperWidthPx, 10) - scrWitdh).toString(); + const locationColGroup = getColGroup(grid, 'Location'); + const colWidth = Math.floor(parseInt(availableWidth, 10) / 3); + const colWidthPx = colWidth + 'px'; + expect(locationColGroup.width).toBe((Math.round(colWidth) * 3) + 'px'); + const countryColumn = grid.getColumnByName('Country'); + expect(countryColumn.width).toBe(colWidthPx); + const regionColumn = grid.getColumnByName('Region'); + expect(regionColumn.width).toBe(colWidthPx); + const cityColumn = grid.getColumnByName('City'); + expect(cityColumn.width).toBe(colWidthPx); + }); + + it('Width should be correct. Column group with three columns. Width in px.', () => { + const gridWidth = '600px'; + grid.width = gridWidth; + fixture.detectChanges(); + const scrWitdh = grid.nativeElement.querySelector('.igx-grid__tbody-scrollbar').getBoundingClientRect().width; + const gridWidthInPx = parseInt(gridWidth, 10) - scrWitdh; + const colWidth = gridWidthInPx / 3; + const colWidthPx = colWidth + 'px'; + const locationColGroup = getColGroup(grid, 'Location'); + expect(locationColGroup.width).toBe(colWidth * 3 + 'px'); + const countryColumn = grid.getColumnByName('Country'); + expect(countryColumn.width).toBe(colWidthPx); + const regionColumn = grid.getColumnByName('Region'); + expect(regionColumn.width).toBe(colWidthPx); + const cityColumn = grid.getColumnByName('City'); + expect(cityColumn.width).toBe(colWidthPx); + }); + + it('Width should be correct. Column group with three columns. Columns with mixed width - px and percent.', async () => { + const col1 = grid.getColumnByName('Country'); + const col2 = grid.getColumnByName('Region'); + const col3 = grid.getColumnByName('City'); + + col1.width = '200px'; + col2.width = '20%'; + col3.width = '50%'; + + fixture.detectChanges(); + + // check group has correct size. + let locationColGroup = getColGroup(grid, 'Location'); + let expectedWidth = (200 + grid.calcWidth * 0.7) + 'px'; + expect(locationColGroup.width).toBe(expectedWidth); + + // check header and content have same size. + const col1Header = grid.getColumnByName('Country').headerCell.nativeElement; + const cell1 = (grid.gridAPI.get_row_by_index(0).cells as QueryList).first.nativeElement; + expect(col1Header.offsetWidth).toEqual(cell1.offsetWidth); + + let col2Header = grid.getColumnByName('Region').headerCell.nativeElement; + let cell2 = (grid.gridAPI.get_row_by_index(0).cells as QueryList).toArray()[1].nativeElement; + expect(col2Header.offsetWidth - cell2.offsetWidth).toBeLessThanOrEqual(1); + + let col3Header = grid.getColumnByName('City').headerCell.nativeElement; + let cell3 = (grid.gridAPI.get_row_by_index(0).cells as QueryList).toArray()[2].nativeElement; + expect(col3Header.offsetWidth).toEqual(cell3.offsetWidth); + + // check that if grid is resized, group size is updated. + componentInstance.gridWrapperWidthPx = '500'; + fixture.detectChanges(); + + await wait(100); + fixture.detectChanges(); + + locationColGroup = getColGroup(grid, 'Location'); + expectedWidth = (200 + grid.calcWidth * 0.7) + 'px'; + expect(locationColGroup.width).toBe(expectedWidth); + + col2Header = grid.getColumnByName('Region').headerCell.nativeElement; + cell2 = (grid.gridAPI.get_row_by_index(0).cells as QueryList).toArray()[1].nativeElement; + expect(col2Header.offsetWidth - cell2.offsetWidth).toBeLessThanOrEqual(1); + + col3Header = grid.getColumnByName('City').headerCell.nativeElement; + cell3 = (grid.gridAPI.get_row_by_index(0).cells as QueryList).toArray()[2].nativeElement; + expect(col3Header.offsetWidth).toEqual(cell3.offsetWidth); + }); + + it('Width should be correct. Column group with three columns. Columns with mixed width - px, percent and null.', () => { + const col1 = grid.getColumnByName('Country'); + const col2 = grid.getColumnByName('Region'); + const col3 = grid.getColumnByName('City'); + + col1.width = '200px'; + col2.width = '20%'; + col3.width = null; + + fixture.detectChanges(); + + // check group has correct size. Should fill available space in grid since one column has no width. + const locationColGroup = getColGroup(grid, 'Location'); + const expectedWidth = grid.calcWidth + 'px'; + expect(locationColGroup.width).toBe(expectedWidth); + + // check header and content have same size. + const col1Header = grid.getColumnByName('Country').headerCell.nativeElement; + const cell1 = (grid.gridAPI.get_row_by_index(0).cells as QueryList).toArray()[0].nativeElement; + expect(col1Header.offsetWidth).toEqual(cell1.offsetWidth); + + const col2Header = grid.getColumnByName('Region').headerCell.nativeElement; + const cell2 = (grid.gridAPI.get_row_by_index(0).cells as QueryList).toArray()[1].nativeElement; + expect(col2Header.offsetWidth - cell2.offsetWidth).toBeLessThanOrEqual(1); + + const col3Header = grid.getColumnByName('City').headerCell.nativeElement; + const cell3 = (grid.gridAPI.get_row_by_index(0).cells as QueryList).toArray()[2].nativeElement; + expect(col3Header.offsetWidth).toEqual(cell3.offsetWidth); + }); + + it('Width should be correct. Column group with three columns. Width in percent.', () => { + const gridWidth = '50%'; + grid.width = gridWidth; + fixture.detectChanges(); + + const scrWitdh = grid.nativeElement.querySelector('.igx-grid__tbody-scrollbar').getBoundingClientRect().width; + + const gridWidthInPx = (parseInt(gridWidth, 10) / 100) * + parseInt(componentInstance.gridWrapperWidthPx, 10) - scrWitdh; + const colWidth = gridWidthInPx / 3; + const colWidthPx = colWidth + 'px'; + const locationColGroup = getColGroup(grid, 'Location'); + expect(locationColGroup.width).toBe((colWidth * 3) + 'px'); + const countryColumn = grid.getColumnByName('Country'); + expect(countryColumn.width).toBe(colWidthPx); + const regionColumn = grid.getColumnByName('Region'); + expect(regionColumn.width).toBe(colWidthPx); + const cityColumn = grid.getColumnByName('City'); + expect(cityColumn.width).toBe(colWidthPx); + }); + + it('Width should be correct. Column group with three columns. Column width in px.', () => { + const gridColWidth = '200px'; + grid.columnWidth = gridColWidth; + fixture.detectChanges(); + + const locationColGroup = getColGroup(grid, 'Location'); + const gridWidth = parseInt(gridColWidth, 10) * 3; + expect(locationColGroup.width).toBe(gridWidth + 'px'); + const countryColumn = grid.getColumnByName('Country'); + expect(countryColumn.width).toBe(gridColWidth); + const regionColumn = grid.getColumnByName('Region'); + expect(regionColumn.width).toBe(gridColWidth); + const cityColumn = grid.getColumnByName('City'); + expect(cityColumn.width).toBe(gridColWidth); + }); + + it('Width should be correct. Colum group with three columns. Column width in percent.', () => { + const gridColWidth = '20%'; + grid.columnWidth = gridColWidth; + fixture.detectChanges(); + + const locationColGroup = getColGroup(grid, 'Location'); + const expectedWidth = (grid.calcWidth * 0.2 * 3) + 'px'; + expect(locationColGroup.width).toBe(expectedWidth); + const countryColumn = grid.getColumnByName('Country'); + expect(countryColumn.width).toBe(gridColWidth); + const regionColumn = grid.getColumnByName('Region'); + expect(regionColumn.width).toBe(gridColWidth); + const cityColumn = grid.getColumnByName('City'); + expect(cityColumn.width).toBe(gridColWidth); + }); + + it('Width should be correct. Column group with three columns. Columns with width in px.', () => { + const columnWidth = '200px'; + componentInstance.columnWidth = columnWidth; + fixture.detectChanges(); + + const locationColGroup = getColGroup(grid, 'Location'); + const groupWidth = parseInt(columnWidth, 10) * 3; + expect(locationColGroup.width).toBe(groupWidth + 'px'); + const countryColumn = grid.getColumnByName('Country'); + expect(countryColumn.width).toBe(columnWidth); + const regionColumn = grid.getColumnByName('Region'); + expect(regionColumn.width).toBe(columnWidth); + const cityColumn = grid.getColumnByName('City'); + expect(cityColumn.width).toBe(columnWidth); + }); + + it('Width should be correct. Column group with three columns. Columns with width in percent.', () => { + const columnWidth = '20%'; + componentInstance.columnWidth = columnWidth; + fixture.detectChanges(); + + const locationColGroup = getColGroup(grid, 'Location'); + const expectedWidth = (grid.calcWidth * 0.2 * 3) + 'px'; + expect(locationColGroup.width).toBe(expectedWidth); + const countryColumn = grid.getColumnByName('Country'); + expect(countryColumn.width).toBe(columnWidth); + const regionColumn = grid.getColumnByName('Region'); + expect(regionColumn.width).toBe(columnWidth); + const cityColumn = grid.getColumnByName('City'); + expect(cityColumn.width).toBe(columnWidth); + }); + + it("Columns with percent width headers should sum to exactly the parent column group's header width", () => { + const gridWidth = "700px"; + grid.width = gridWidth; + fixture.detectChanges(); + const columnWidth = "30%"; + componentInstance.columnWidth = columnWidth; + fixture.detectChanges(); + + const headersWidth = grid.nativeElement + .querySelector("igx-grid-header") + .getBoundingClientRect().width; + const expectedWidth = headersWidth * 3; + expect(parseFloat(headersWidth.toFixed(1))).toBe((parseFloat(columnWidth) / 100) * grid.calcWidth); + const locationColGroupHeaderWidth = grid.nativeElement + .querySelector("igx-grid-header-group") + .getBoundingClientRect().width; + expect(parseFloat(locationColGroupHeaderWidth.toFixed(1))).toBe(parseFloat(expectedWidth.toFixed(1))); + }); + }); + + describe('Column hiding: ', () => { + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(ColumnGroupFourLevelTestComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + })); + + it('column hiding - verify grid after hiding the last column group', async () => { + grid.navigateTo(0, 10); + await wait(100); + fixture.detectChanges(); + await wait(250); + fixture.detectChanges(); + + let headerDisplayContainer = fixture.debugElement.query(By.css('.igx-grid-thead__wrapper >* .igx-display-container')); + let leftOffset = parseInt(headerDisplayContainer.styles.left, 10); + expect(leftOffset).toBeLessThan(-600); + + const initialBodyHeight = parseInt(fixture.debugElement.query(By.css('.igx-grid__tbody-content')).styles.height, 10); + const contactInfoGroup = grid.columns.find(c => c.header === 'Contact Information'); + const groupWidth = contactInfoGroup.width; + contactInfoGroup.hidden = true; + fixture.detectChanges(); + await wait(200); + fixture.detectChanges(); + + headerDisplayContainer = fixture.debugElement.query(By.css('.igx-grid-thead__wrapper >* .igx-display-container')); + const expectedOffset = leftOffset - parseInt(groupWidth, 10); + leftOffset = parseInt(headerDisplayContainer.styles.left, 10); + + expect(parseInt(fixture.debugElement.query(By.css('.igx-grid__tbody-content')).styles.height, 10)).toEqual(initialBodyHeight); + expect(leftOffset).toBeGreaterThanOrEqual(expectedOffset); + }); + + it('column hiding - parent level', () => { + const addressGroup = grid.columnList.filter(c => c.header === 'Address Information')[0]; + + addressGroup.hidden = true; + fixture.detectChanges(); + + expect(GridFunctions.getColumnHeaders(fixture).length).toEqual(4); + expect(GridFunctions.getColumnGroupHeaders(fixture).length).toEqual(2); + }); + + it('column hiding - child level', () => { + const addressGroup = fixture.componentInstance.addrInfoColGroup; + + addressGroup.childColumns[0].hidden = true; + fixture.detectChanges(); + + expect(GridFunctions.getColumnGroupHeaders(fixture).length).toEqual(5); + expect(addressGroup.childColumns[0].hidden).toBe(true); + expect(addressGroup.childColumns[0].childColumns.every(c => c.hidden === true)).toEqual(true); + }); + + it('column hiding - Verify column hiding of Individual column and Child column', () => { + testGroupsAndColumns(7, 11, fixture); + + // Hide individual column + grid.getColumnByName('ID').hidden = true; + fixture.detectChanges(); + testGroupsAndColumns(7, 10, fixture); + + // Hide column in goup + grid.getColumnByName('CompanyName').hidden = true; + fixture.detectChanges(); + expect(GridFunctions.getColumnGroupHeaders(fixture).length).toEqual(7); + expect(GridFunctions.getColumnHeaders(fixture).length).toEqual(9); + + grid.getColumnByName('Address').hidden = true; + fixture.detectChanges(); + + testGroupsAndColumns(7, 8, fixture); + }); + + it('column hiding - Verify when 2 of 2 child columns are hidden, the Grouped column would be hidden as well.', () => { + testGroupsAndColumns(7, 11, fixture); + + // Hide 2 columns in the group + grid.getColumnByName('ContactName').hidden = true; + fixture.detectChanges(); + grid.getColumnByName('ContactTitle').hidden = true; + fixture.detectChanges(); + + testGroupsAndColumns(6, 9, fixture); + expect(getColGroup(grid, 'Person Details').hidden).toEqual(true); + + // Show one of the columns + grid.getColumnByName('ContactName').hidden = false; + fixture.detectChanges(); + + testGroupsAndColumns(7, 10, fixture); + expect(getColGroup(grid, 'Person Details').hidden).toEqual(false); + }); + + it('column hiding - Verify when 1 child column and 1 group are hidden, the Grouped column would be hidden as well.', () => { + testGroupsAndColumns(7, 11, fixture); + + // Hide 2 columns in the group + grid.getColumnByName('CompanyName').hidden = true; + fixture.detectChanges(); + getColGroup(grid, 'Person Details').hidden = true; + fixture.detectChanges(); + + testGroupsAndColumns(5, 8, fixture); + expect(getColGroup(grid, 'General Information').hidden).toEqual(true); + + // Show the group + getColGroup(grid, 'Person Details').hidden = false; + fixture.detectChanges(); + testGroupsAndColumns(7, 10, fixture); + expect(getColGroup(grid, 'General Information').hidden).toEqual(false); + }); + }); + + describe('API methods tests ', () => { + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(ColumnGroupFourLevelTestComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + })); + + it('API method level should return correct values', () => { + grid.getColumnByName('Fax').hidden = true; + fixture.detectChanges(); + getColGroup(grid, 'Person Details').hidden = true; + fixture.detectChanges(); + + expect(grid.columnList.filter(col => col.columnGroup).length).toEqual(7); + + // Get level of column + expect(grid.getColumnByName('ID').level).toEqual(0); + expect(grid.getColumnByName('CompanyName').level).toEqual(1); + expect(grid.getColumnByName('Country').level).toEqual(2); + expect(grid.getColumnByName('City').level).toEqual(3); + expect(grid.getColumnByName('PostalCode').level).toEqual(2); + // Get level of hidden column + expect(grid.getColumnByName('Fax').level).toEqual(2); + // Get level of column in hidden group + expect(grid.getColumnByName('ContactTitle').level).toEqual(2); + + // Get level of grouped column + expect(getColGroup(grid, 'General Information').level).toEqual(0); + expect(getColGroup(grid, 'Location').level).toEqual(1); + expect(getColGroup(grid, 'Location City').level).toEqual(2); + expect(getColGroup(grid, 'Contact Information').level).toEqual(1); + expect(getColGroup(grid, 'Postal Code').level).toEqual(1); + // Get level of hidden group + expect(getColGroup(grid, 'Person Details').level).toEqual(1); + }); + + it('API method columnGroup should return correct values', () => { + grid.getColumnByName('Fax').hidden = true; + fixture.detectChanges(); + getColGroup(grid, 'Person Details').hidden = true; + fixture.detectChanges(); + + expect(grid.columnList.filter(col => col.columnGroup).length).toEqual(7); + // Get columnGroup of column + expect(grid.getColumnByName('ID').columnGroup).toEqual(false); + expect(grid.getColumnByName('Fax').columnGroup).toEqual(false); + expect(grid.getColumnByName('ContactTitle').columnGroup).toEqual(false); + + // Get columnGroup of grouped column + expect(getColGroup(grid, 'General Information').columnGroup).toEqual(true); + expect(getColGroup(grid, 'Location City').columnGroup).toEqual(true); + expect(getColGroup(grid, 'Contact Information').columnGroup).toEqual(true); + expect(getColGroup(grid, 'Postal Code').columnGroup).toEqual(true); + expect(getColGroup(grid, 'Person Details').columnGroup).toEqual(true); + }); + + it('API method allChildren should return correct values', () => { + grid.getColumnByName('Fax').hidden = true; + fixture.detectChanges(); + getColGroup(grid, 'Person Details').hidden = true; + fixture.detectChanges(); + + expect(grid.columnList.filter(col => col.columnGroup).length).toEqual(7); + // Get allChildren of column + expect(grid.getColumnByName('ID').allChildren.length).toEqual(0); + expect(grid.getColumnByName('PostalCode').allChildren.length).toEqual(0); + // Get allChildren of hidden column + expect(grid.getColumnByName('Fax').allChildren.length).toEqual(0); + + // Get allChildren of group + const genInfGroupedColumnAllChildren = getColGroup(grid, 'General Information').allChildren; + expect(genInfGroupedColumnAllChildren.length).toEqual(4); + expect(genInfGroupedColumnAllChildren.indexOf(getColGroup(grid, 'Person Details'))).toBeGreaterThanOrEqual(0); + + // Get allChildren of hidden group + expect(getColGroup(grid, 'Person Details').allChildren.length).toEqual(2); + + // Get allChildren of group with one column + const postCodeGroupedColumnAllChildren = getColGroup(grid, 'Postal Code').allChildren; + expect(postCodeGroupedColumnAllChildren.length).toEqual(1); + expect(postCodeGroupedColumnAllChildren.indexOf(grid.getColumnByName('PostalCode'))).toEqual(0); + + // Get allChildren of group with hidden columns and more levels + const addressGroupedColumnAllChildren = getColGroup(grid, 'Address Information').allChildren; + expect(addressGroupedColumnAllChildren.length).toEqual(11); + expect(addressGroupedColumnAllChildren.indexOf(getColGroup(grid, 'Postal Code'))).toBeGreaterThanOrEqual(0); + expect(addressGroupedColumnAllChildren.indexOf(grid.getColumnByName('PostalCode'))).toBeGreaterThanOrEqual(0); + expect(addressGroupedColumnAllChildren.indexOf(grid.getColumnByName('Address'))).toBeGreaterThanOrEqual(0); + expect(addressGroupedColumnAllChildren.indexOf(grid.getColumnByName('Country'))).toBeGreaterThanOrEqual(0); + expect(addressGroupedColumnAllChildren.indexOf(grid.getColumnByName('Fax'))).toBeGreaterThanOrEqual(0); + expect(addressGroupedColumnAllChildren.indexOf(getColGroup(grid, 'General Information'))).toEqual(-1); + }); + + it('API method children should return correct values', () => { + grid.getColumnByName('Fax').hidden = true; + fixture.detectChanges(); + getColGroup(grid, 'Person Details').hidden = true; + fixture.detectChanges(); + + expect(grid.columnList.filter(col => col.columnGroup).length).toEqual(7); + + // Get children of grouped column + expect(getColGroup(grid, 'General Information').childColumns.length).toEqual(2); + + // Get children of hidden group + expect(getColGroup(grid, 'Person Details').childColumns.length).toEqual(2); + + // Get children of group with one column + const postCodeGroupedColumnAllChildren = getColGroup(grid, 'Postal Code').childColumns; + expect(postCodeGroupedColumnAllChildren.length).toEqual(1); + + // Get children of group with more levels + const addressGroupedColumnAllChildren = getColGroup(grid, 'Address Information').childColumns; + expect(addressGroupedColumnAllChildren.length).toEqual(3); + }); + + it('API method topLevelParent should return correct values', () => { + grid.getColumnByName('Fax').hidden = true; + fixture.detectChanges(); + getColGroup(grid, 'Person Details').hidden = true; + fixture.detectChanges(); + + expect(grid.columnList.filter(col => col.columnGroup).length).toEqual(7); + + // Get topLevelParent of column with no group + expect(grid.getColumnByName('ID').topLevelParent).toBeUndefined(); + + // Get topLevelParent of column + const addressGroupedColumn = getColGroup(grid, 'Address Information'); + expect(grid.getColumnByName('PostalCode').topLevelParent).toEqual(addressGroupedColumn); + expect(grid.getColumnByName('Fax').topLevelParent).toEqual(addressGroupedColumn); + expect(grid.getColumnByName('Country').topLevelParent).toEqual(addressGroupedColumn); + + const genInfGroupedColumn = getColGroup(grid, 'General Information'); + expect(grid.getColumnByName('ContactName').topLevelParent).toEqual(genInfGroupedColumn); + expect(grid.getColumnByName('CompanyName').topLevelParent).toEqual(genInfGroupedColumn); + + // Get topLevelParent of top group + expect(genInfGroupedColumn.topLevelParent).toBeUndefined(); + expect(addressGroupedColumn.topLevelParent).toBeUndefined(); + + // Get topLevelParent of group + expect(getColGroup(grid, 'Person Details').topLevelParent).toEqual(genInfGroupedColumn); + expect(getColGroup(grid, 'Postal Code').topLevelParent).toEqual(addressGroupedColumn); + expect(getColGroup(grid, 'Location City').topLevelParent).toEqual(addressGroupedColumn); + }); + + it('Should emit "columnInit" event when having multi-column headers.', () => { + fixture = TestBed.createComponent(NestedColumnGroupsGridComponent); + const ci = fixture.componentInstance; + grid = ci.grid; + + spyOn(grid.columnInit, 'emit').and.callThrough(); + fixture.detectChanges(); + const colsCount = 4; + const colGroupsCount = 3; + + expect(grid.columnInit.emit).toHaveBeenCalledTimes(colsCount + colGroupsCount); + }); + + it('Should fire "columnInit" event when adding a multi-column header.', () => { + fixture = TestBed.createComponent(DynamicGridComponent); + componentInstance = fixture.componentInstance; + grid = componentInstance.grid; + fixture.detectChanges(); + + spyOn(grid.columnInit, 'emit').and.callThrough(); + componentInstance.mchCount.push({}); + fixture.detectChanges(); + const colsCount = grid.unpinnedColumns.length; // all + expect(grid.columnInit.emit).toHaveBeenCalledTimes(colsCount); + }); + }); + + describe('Column Pinning ', () => { + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(ColumnGroupTestComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + })); + + it('column pinning - Pin a column in a group using property.', () => { + PinningTests.testColumnGroupPinning((component) => { + component.contactTitleCol.pinned = true; + fixture.detectChanges(); + }, (component) => { + component.contactTitleCol.pinned = false; + fixture.detectChanges(); + }); + }); + + it('column pinning - Pin a column in a group using grid API.', () => { + PinningTests.testColumnGroupPinning((component) => { + component.grid.pinColumn(component.contactTitleCol); + fixture.detectChanges(); + }, (component) => { + component.grid.unpinColumn(component.contactTitleCol); + fixture.detectChanges(); + }); + }); + + it('column pinning - Pin an inner column group using property.', () => { + PinningTests.testColumnGroupPinning((component) => { + component.pDetailsColGroup.pinned = true; + fixture.detectChanges(); + }, (component) => { + component.pDetailsColGroup.pinned = false; + fixture.detectChanges(); + }); + }); + + it('column pinning - Pin an inner column group using grid API.', () => { + PinningTests.testColumnGroupPinning((component) => { + component.grid.pinColumn(component.pDetailsColGroup); + fixture.detectChanges(); + }, (component) => { + component.grid.unpinColumn(component.pDetailsColGroup); + fixture.detectChanges(); + }); + }); + + it('column pinning - Pin a group using property.', () => { + PinningTests.testColumnGroupPinning((component) => { + component.genInfoColGroup.pinned = true; + fixture.detectChanges(); + }, (component) => { + component.genInfoColGroup.pinned = false; + fixture.detectChanges(); + }); + }); + + it('column pinning - Pin a group using API.', () => { + PinningTests.testColumnGroupPinning((component) => { + component.grid.pinColumn(component.genInfoColGroup); + fixture.detectChanges(); + }, (component) => { + component.grid.unpinColumn(component.genInfoColGroup); + fixture.detectChanges(); + }); + }); + + it('column pinning - Verify pin a not fully visble group', () => { + expect(grid.pinnedColumns.length).toEqual(0); + expect(grid.unpinnedColumns.length).toEqual(16); + + // Pin a Group which is not fully visble + const grAdressInf = getColGroup(grid, 'Address Information'); + grAdressInf.pinned = true; + fixture.detectChanges(); + + // Verify group and all its children are pinned + testColumnPinning(grAdressInf, true); + expect(grid.pinnedColumnsCount).toEqual(10); + + expect(grid.getCellByColumn(0, 'ID')).toBeDefined(); + expect(grid.getCellByColumn(0, 'Country')).toBeDefined(); + expect(grid.getCellByColumn(0, 'City')).toBeDefined(); + + expect(grid.getCellByColumn(0, 'ID').value).toEqual('ALFKI'); + expect(grid.getCellByColumn(0, 'Country').value).toEqual('Germany'); + expect(grid.getCellByColumn(0, 'City').value).toEqual('Berlin'); + }); + + it('Should pin column groups using indexes correctly.', fakeAsync(() => { + fixture = TestBed.createComponent(StegosaurusGridComponent); + fixture.detectChanges(); + const ci = fixture.componentInstance; + grid = ci.grid; + + ci.genInfoColGroup.pinned = true; + fixture.detectChanges(); + ci.idCol.pinned = true; + fixture.detectChanges(); + ci.postalCodeColGroup.pinned = true; + fixture.detectChanges(); + ci.cityColGroup.pinned = true; + fixture.detectChanges(); + + testColumnsVisibleIndexes(ci.genInfoColList.concat(ci.idCol) + .concat(ci.postalCodeColList).concat(ci.cityColList).concat(ci.countryColList) + .concat(ci.regionColList).concat(ci.addressColList).concat(ci.phoneColList) + .concat(ci.faxColList)); + + // unpinning with index + expect(grid.unpinColumn(ci.genInfoColGroup, 2)).toBe(true); + tick(); + fixture.detectChanges(); + const postUnpinningColList = [ci.idCol].concat(ci.postalCodeColList).concat(ci.cityColList) + .concat(ci.countryColList).concat(ci.regionColList).concat(ci.genInfoColList) + .concat(ci.addressColList).concat(ci.phoneColList).concat(ci.faxColList); + testColumnsVisibleIndexes(postUnpinningColList); + testColumnPinning(ci.genInfoColGroup, false); + + // pinning to non-existent index + expect(grid.pinColumn(ci.genInfoColGroup, 15)).toBe(false); + tick(); + fixture.detectChanges(); + testColumnsVisibleIndexes(postUnpinningColList); + testColumnPinning(ci.genInfoColGroup, false); + + // pinning to negative index + expect(grid.pinColumn(ci.genInfoColGroup, -15)).toBe(false); + tick(); + fixture.detectChanges(); + testColumnsVisibleIndexes(postUnpinningColList); + testColumnPinning(ci.genInfoColGroup, false); + + // pinning with index + expect(grid.pinColumn(ci.genInfoColGroup, 2)).toBe(true); + tick(); + fixture.detectChanges(); + const postPinningColList = [ci.idCol].concat(ci.postalCodeColList).concat(ci.genInfoColList) + .concat(ci.cityColList).concat(ci.countryColList).concat(ci.regionColList) + .concat(ci.addressColList).concat(ci.phoneColList).concat(ci.faxColList); + testColumnsVisibleIndexes(postPinningColList); + testColumnPinning(ci.genInfoColGroup, true); + + // unpinning to non-existent index + expect(grid.unpinColumn(ci.genInfoColGroup, 15)).toBe(false); + tick(); + fixture.detectChanges(); + testColumnsVisibleIndexes(postPinningColList); + testColumnPinning(ci.genInfoColGroup, true); + + // unpinning to negative index + expect(grid.unpinColumn(ci.genInfoColGroup, -15)).toBe(false); + tick(); + fixture.detectChanges(); + testColumnsVisibleIndexes(postPinningColList); + testColumnPinning(ci.genInfoColGroup, true); + })); + + it('Should initially pin the whole group when one column of the group is pinned', () => { + fixture = TestBed.createComponent(ThreeGroupsThreeColumnsGridComponent); + fixture.componentInstance.cnPinned = true; + fixture.detectChanges(); + const contactTitle = fixture.componentInstance.grid.getColumnByName('ContactTitle'); + expect(contactTitle.pinned).toBeTruthy(); + }); + }); + + describe('Column moving ', () => { + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(ColumnGroupTestComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + })); + + it('Should not allow moving group to another level via API.', fakeAsync(() => { + expect(grid.pinnedColumns.length).toEqual(0); + expect(grid.unpinnedColumns.length).toEqual(16); + expect(grid.rowList.first.cells.first.value).toMatch('ALFKI'); + expect(grid.rowList.first.cells.toArray()[1].value).toMatch('Alfreds Futterkiste'); + expect(grid.rowList.first.cells.toArray()[2].value).toMatch('Maria Anders'); + expect(grid.rowList.first.cells.toArray()[3].value).toMatch('Sales Representative'); + + // Pin a column + const colID = grid.getColumnByName('ID'); + colID.pinned = true; + fixture.detectChanges(); + + expect(grid.pinnedColumns.length).toEqual(1); + expect(grid.unpinnedColumns.length).toEqual(15); + expect(colID.visibleIndex).toEqual(0); + expect(grid.rowList.first.cells.first.value).toMatch('ALFKI'); + + // Try to move a group column to pinned area, where there is non group column + const contName = grid.getColumnByName('ContactName'); + grid.moveColumn(contName, colID); + tick(); + fixture.detectChanges(); + + // pinning should be unsuccesfull ! + expect(grid.pinnedColumns.length).toEqual(1); + expect(grid.unpinnedColumns.length).toEqual(15); + expect(grid.rowList.first.cells.first.value).toMatch('ALFKI'); + + // pin grouped column to the pinned area + const genGroup = getColGroup(grid, 'General Information'); + genGroup.pinned = true; + fixture.detectChanges(); + + expect(grid.pinnedColumns.length).toEqual(6); + expect(grid.unpinnedColumns.length).toEqual(10); + expect(genGroup.visibleIndex).toEqual(1); + expect(colID.visibleIndex).toEqual(0); + + expect(grid.rowList.first.cells.first.value).toMatch('ALFKI'); + expect(grid.rowList.first.cells.toArray()[1].value).toMatch('Alfreds Futterkiste'); + expect(grid.rowList.first.cells.toArray()[2].value).toMatch('Maria Anders'); + expect(grid.rowList.first.cells.toArray()[3].value).toMatch('Sales Representative'); + + // pin grouped column to the pinned area + const compName = grid.getColumnByName('CompanyName'); + const persDetails = getColGroup(grid, 'Person Details'); + const contTitle = grid.getColumnByName('ContactTitle'); + + grid.moveColumn(colID, genGroup); + tick(); + fixture.detectChanges(); + grid.moveColumn(compName, persDetails); + tick(); + fixture.detectChanges(); + grid.moveColumn(contName, contTitle); + tick(); + fixture.detectChanges(); + + expect(grid.rowList.first.cells.first.value).toMatch('Sales Representative'); + expect(grid.rowList.first.cells.toArray()[1].value).toMatch('Maria Anders'); + expect(grid.rowList.first.cells.toArray()[2].value).toMatch('Alfreds Futterkiste'); + expect(grid.rowList.first.cells.toArray()[3].value).toMatch('ALFKI'); + })); + + it('Should move column group correctly. One level column groups.', fakeAsync(() => { + fixture = TestBed.createComponent(ThreeGroupsThreeColumnsGridComponent); + fixture.detectChanges(); + const ci = fixture.componentInstance; + grid = ci.grid; + const genInfoCols = [ci.genInfoColGroup, ci.companyNameCol, + ci.contactNameCol, ci.contactTitleCol]; + const locCols = [ci.locationColGroup, ci.countryCol, ci.regionCol, ci.cityCol]; + const contactInfoCols = [ci.contactInfoColGroup, ci.phoneCol, ci.faxCol, ci.postalCodeCol]; + + testColumnsOrder(genInfoCols.concat(locCols).concat(contactInfoCols)); + + // moving last to be first + grid.moveColumn(ci.contactInfoColGroup, ci.genInfoColGroup, DropPosition.BeforeDropTarget); + tick(); + fixture.detectChanges(); + testColumnsOrder(contactInfoCols.concat(genInfoCols).concat(locCols)); + + // moving first to be last + grid.moveColumn(ci.contactInfoColGroup, ci.locationColGroup); + tick(); + fixture.detectChanges(); + testColumnsOrder(genInfoCols.concat(locCols).concat(contactInfoCols)); + + // moving inner to be last + grid.moveColumn(ci.locationColGroup, ci.contactInfoColGroup); + tick(); + fixture.detectChanges(); + testColumnsOrder(genInfoCols.concat(contactInfoCols).concat(locCols)); + + // moving inner to be first + grid.moveColumn(ci.contactInfoColGroup, ci.genInfoColGroup, DropPosition.BeforeDropTarget); + tick(); + fixture.detectChanges(); + testColumnsOrder(contactInfoCols.concat(genInfoCols).concat(locCols)); + + // moving to the same spot, no change expected + grid.moveColumn(ci.genInfoColGroup, ci.genInfoColGroup); + tick(); + fixture.detectChanges(); + testColumnsOrder(contactInfoCols.concat(genInfoCols).concat(locCols)); + + // moving column group to the place of a column, no change expected + grid.moveColumn(ci.genInfoColGroup, ci.countryCol); + tick(); + fixture.detectChanges(); + testColumnsOrder(contactInfoCols.concat(genInfoCols).concat(locCols)); + })); + + it('Should move columns within column groups. One level column groups.', fakeAsync(() => { + fixture = TestBed.createComponent(ThreeGroupsThreeColumnsGridComponent); + fixture.detectChanges(); + const ci = fixture.componentInstance; + grid = ci.grid; + const genInfoAndLocCols = [ci.genInfoColGroup, ci.companyNameCol, + ci.contactNameCol, ci.contactTitleCol, ci.locationColGroup, ci.countryCol, + ci.regionCol, ci.cityCol]; + + // moving last to be first + grid.moveColumn(ci.postalCodeCol, ci.phoneCol, DropPosition.BeforeDropTarget); + tick(); + fixture.detectChanges(); + testColumnsOrder(genInfoAndLocCols.concat([ci.contactInfoColGroup, + ci.postalCodeCol, ci.phoneCol, ci.faxCol])); + + // moving first to be last + grid.moveColumn(ci.postalCodeCol, ci.faxCol); + tick(); + fixture.detectChanges(); + testColumnsOrder(genInfoAndLocCols.concat([ci.contactInfoColGroup, + ci.phoneCol, ci.faxCol, ci.postalCodeCol])); + + // moving inner to be last + grid.moveColumn(ci.faxCol, ci.postalCodeCol); + tick(); + fixture.detectChanges(); + testColumnsOrder(genInfoAndLocCols.concat([ci.contactInfoColGroup, + ci.phoneCol, ci.postalCodeCol, ci.faxCol])); + + // moving inner to be first + grid.moveColumn(ci.postalCodeCol, ci.phoneCol, DropPosition.BeforeDropTarget); + tick(); + fixture.detectChanges(); + testColumnsOrder(genInfoAndLocCols.concat([ci.contactInfoColGroup, + ci.postalCodeCol, ci.phoneCol, ci.faxCol])); + + // moving to the sample spot, no change expected + grid.moveColumn(ci.postalCodeCol, ci.postalCodeCol); + tick(); + fixture.detectChanges(); + testColumnsOrder(genInfoAndLocCols.concat([ci.contactInfoColGroup, + ci.postalCodeCol, ci.phoneCol, ci.faxCol])); + + // moving column to the place of its column group, no change expected + grid.moveColumn(ci.postalCodeCol, ci.contactInfoColGroup); + tick(); + fixture.detectChanges(); + testColumnsOrder(genInfoAndLocCols.concat([ci.contactInfoColGroup, + ci.postalCodeCol, ci.phoneCol, ci.faxCol])); + + //// moving column to the place of a column group, no change expected + grid.moveColumn(ci.postalCodeCol, ci.genInfoColGroup); + tick(); + fixture.detectChanges(); + testColumnsOrder(genInfoAndLocCols.concat([ci.contactInfoColGroup, + ci.postalCodeCol, ci.phoneCol, ci.faxCol])); + })); + + it('Should move columns and groups. Two level column groups.', fakeAsync(() => { + fixture = TestBed.createComponent(NestedColGroupsGridComponent); + fixture.detectChanges(); + const ci = fixture.componentInstance; + grid = ci.grid; + + // moving a two-level col + grid.moveColumn(ci.phoneCol, ci.locationColGroup, DropPosition.BeforeDropTarget); + tick(); + fixture.detectChanges(); + testColumnsOrder([ci.contactInfoColGroup, ci.phoneCol, ci.locationColGroup, ci.countryCol, + ci.genInfoColGroup, ci.companyNameCol, ci.cityCol]); + + // moving a three-level col + grid.moveColumn(ci.cityCol, ci.contactInfoColGroup, DropPosition.BeforeDropTarget); + tick(); + fixture.detectChanges(); + const colsOrder = [ci.cityCol, ci.contactInfoColGroup, ci.phoneCol, + ci.locationColGroup, ci.countryCol, ci.genInfoColGroup, ci.companyNameCol]; + testColumnsOrder(colsOrder); + + // moving between different groups, hould stay the same + grid.moveColumn(ci.locationColGroup, ci.companyNameCol); + tick(); + fixture.detectChanges(); + testColumnsOrder(colsOrder); + + // moving between different levels, should stay the same + grid.moveColumn(ci.countryCol, ci.phoneCol); + tick(); + fixture.detectChanges(); + testColumnsOrder(colsOrder); + + // moving between different levels, should stay the same + grid.moveColumn(ci.cityCol, ci.phoneCol); + tick(); + fixture.detectChanges(); + testColumnsOrder(colsOrder); + + grid.moveColumn(ci.genInfoColGroup, ci.companyNameCol); + tick(); + fixture.detectChanges(); + testColumnsOrder(colsOrder); + + grid.moveColumn(ci.locationColGroup, ci.contactInfoColGroup); + tick(); + fixture.detectChanges(); + testColumnsOrder(colsOrder); + })); + + it('Should move columns and groups. Pinning enabled.', fakeAsync(() => { + fixture = TestBed.createComponent(StegosaurusGridComponent); + fixture.detectChanges(); + const ci = fixture.componentInstance; + + ci.idCol.pinned = true; + fixture.detectChanges(); + ci.genInfoColGroup.pinned = true; + fixture.detectChanges(); + ci.postalCodeColGroup.pinned = true; + fixture.detectChanges(); + ci.cityColGroup.pinned = true; + fixture.detectChanges(); + + // moving group from unpinned to pinned + ci.grid.moveColumn(ci.phoneColGroup, ci.idCol, DropPosition.BeforeDropTarget); + tick(); + fixture.detectChanges(); + let postMovingOrder = ci.phoneColList.concat([ci.idCol]).concat(ci.genInfoColList) + .concat(ci.postalCodeColList).concat(ci.cityColList).concat(ci.countryColList) + .concat(ci.regionColList).concat(ci.addressColList).concat(ci.faxColList); + testColumnsVisibleIndexes(postMovingOrder); + testColumnPinning(ci.phoneColGroup, true); + testColumnPinning(ci.idCol, true); + + // moving sub group to different parent, should not be allowed + ci.grid.moveColumn(ci.pDetailsColGroup, ci.regionCol); + tick(); + fixture.detectChanges(); + testColumnsVisibleIndexes(postMovingOrder); + testColumnPinning(ci.pDetailsColGroup, true); + testColumnPinning(ci.regionCol, false); + + // moving pinned group as firstly unpinned + ci.grid.moveColumn(ci.idCol, ci.cityColGroup); + tick(); + fixture.detectChanges(); + ci.idCol.pinned = false; + fixture.detectChanges(); + postMovingOrder = ci.phoneColList.concat(ci.genInfoColList) + .concat(ci.postalCodeColList).concat(ci.cityColList).concat([ci.idCol]) + .concat(ci.countryColList).concat(ci.regionColList) + .concat(ci.addressColList).concat(ci.faxColList); + testColumnsVisibleIndexes(postMovingOrder); + testColumnPinning(ci.idCol, false); + testColumnPinning(ci.countryColGroup, false); + + // moving column to different parent, shound not be allowed + ci.grid.moveColumn(ci.postalCodeCol, ci.cityCol); + tick(); + fixture.detectChanges(); + testColumnsVisibleIndexes(postMovingOrder); + testColumnPinning(ci.postalCodeCol, true); + testColumnPinning(ci.cityCol, true); + })); + }); + + describe('Features integration tests: ', () => { + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(ColumnGroupFourLevelTestComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + })); + + it('sorting - sort a grouped column by API', () => { + // Verify columns and groups + testGroupsAndColumns(7, 11, fixture); + + grid.getColumnByName('CompanyName').sortable = true; + grid.getColumnByName('ContactName').sortable = true; + fixture.detectChanges(); + // Sort column + grid.sort({ fieldName: 'CompanyName', dir: SortingDirection.Asc, ignoreCase: true }); + fixture.detectChanges(); + + // Verify columns and groups + testGroupsAndColumns(7, 11, fixture); + + // Verify cells + expect(grid.getCellByColumn(0, 'ID').value).toEqual('ALFKI'); + expect(grid.getCellByColumn(0, 'ContactTitle').value).toEqual('Sales Representative'); + expect(grid.getCellByColumn(0, 'CompanyName').value).toEqual('Alfreds Futterkiste'); + expect(grid.getCellByColumn(4, 'ID').value).toEqual('BSBEV'); + expect(grid.getCellByColumn(4, 'ContactTitle').value).toEqual('Sales Representative'); + expect(grid.getCellByColumn(4, 'Country').value).toEqual('UK'); + + grid.clearSort(); + fixture.detectChanges(); + // Verify columns and groups + testGroupsAndColumns(7, 11, fixture); + + // Verify cells + expect(grid.getCellByColumn(0, 'ID').value).toEqual('ALFKI'); + expect(grid.getCellByColumn(0, 'ContactTitle').value).toEqual('Sales Representative'); + expect(grid.getCellByColumn(0, 'CompanyName').value).toEqual('Alfreds Futterkiste'); + expect(grid.getCellByColumn(4, 'ID').value).toEqual('BERGS'); + expect(grid.getCellByColumn(4, 'Country').value).toEqual('Sweden'); + + // sort column which is not in the view + grid.sort({ fieldName: 'ContactName', dir: SortingDirection.Asc, ignoreCase: true }); + fixture.detectChanges(); + + // Verify columns and groups + testGroupsAndColumns(7, 11, fixture); + + // Verify cells + expect(grid.getCellByColumn(0, 'ID').value).toEqual('ANATR'); + expect(grid.getCellByColumn(0, 'ContactTitle').value).toEqual('Owner'); + expect(grid.getCellByColumn(0, 'CompanyName').value).toEqual('Ana Trujillo Emparedados y helados'); + expect(grid.getCellByColumn(3, 'ID').value).toEqual('FAMIA'); + expect(grid.getCellByColumn(3, 'ContactTitle').value).toEqual('Marketing Assistant'); + expect(grid.getCellByColumn(3, 'Country').value).toEqual('Brazil'); + }); + + it('sorting - sort a grouped column by clicking on header cell UI', () => { + // Verify columns and groups + testGroupsAndColumns(7, 11, fixture); + + grid.getColumnByName('CompanyName').sortable = true; + fixture.detectChanges(); + + // Sort column by clicking on it + const contactTitleHeaderCell = GridFunctions.getColumnHeaderByIndex(fixture, 3); + contactTitleHeaderCell.triggerEventHandler('click', new Event('click')); + fixture.detectChanges(); + + // Verify columns and groups + testGroupsAndColumns(7, 11, fixture); + // Verify cells + expect(grid.getCellByColumn(0, 'ID').value).toEqual('ALFKI'); + expect(grid.getCellByColumn(0, 'ContactTitle').value).toEqual('Sales Representative'); + expect(grid.getCellByColumn(0, 'CompanyName').value).toEqual('Alfreds Futterkiste'); + expect(grid.getCellByColumn(4, 'ID').value).toEqual('BERGS'); + expect(grid.getCellByColumn(4, 'ContactTitle').value).toEqual('Order Administrator'); + expect(grid.getCellByColumn(4, 'Country').value).toEqual('Sweden'); + }); + + it('summaries - verify summaries when there are grouped columns', () => { + const allColumns = grid.columnList; + allColumns.forEach((col) => { + if (!col.columnGroup) { + col.hasSummary = true; + } + }); + fixture.detectChanges(); + + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fixture); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, ['Count'], ['27']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['27']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['27']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count'], ['27']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count'], ['27']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 5, ['Count'], ['27']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 6, ['Count'], ['27']); + }); + + it('filtering - filter a grouped column', fakeAsync(() => { + const initialRowListLenght = grid.rowList.length; + // Verify columns and groups + testGroupsAndColumns(7, 11, fixture); + + grid.getColumnByName('ContactTitle').filterable = true; + tick(); + grid.getColumnByName('PostalCode').filterable = true; + tick(); + fixture.detectChanges(); + + // Filter column + grid.filter('ContactTitle', 'Accounting Manager', + IgxStringFilteringOperand.instance().condition('equals'), true); + tick(); + fixture.detectChanges(); + expect(grid.rowList.length).toEqual(2); + + // Verify columns and groups + testGroupsAndColumns(7, 11, fixture); + + // Filter column + grid.filter('PostalCode', '28', IgxStringFilteringOperand.instance().condition('contains'), true); + tick(); + fixture.detectChanges(); + expect(grid.rowList.length).toEqual(1); + + // Reset filters + grid.clearFilter('ContactTitle'); + tick(); + grid.clearFilter('PostalCode'); + tick(); + fixture.detectChanges(); + + expect(grid.rowList.length).toEqual(initialRowListLenght); + // Verify columns and groups + testGroupsAndColumns(7, 11, fixture); + + // Filter column with no match + grid.filter('ContactTitle', 'no items', IgxStringFilteringOperand.instance().condition('equals'), true); + tick(); + fixture.detectChanges(); + expect(grid.rowList.length).toEqual(0); + // Verify columns and groups + testGroupsAndColumns(7, 11, fixture); + + // Clear filter + grid.clearFilter('ContactTitle'); + tick(); + fixture.detectChanges(); + + expect(grid.rowList.length).toEqual(initialRowListLenght); + // Verify columns and groups + testGroupsAndColumns(7, 11, fixture); + })); + + it('grouping - verify grouping when there are grouped columns', () => { + fixture = TestBed.createComponent(ColumnGroupTestComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + + // Verify columns and groups + testGroupsAndColumns(5, 11, fixture); + + grid.getColumnByName('ContactTitle').groupable = true; + grid.getColumnByName('Country').groupable = true; + grid.getColumnByName('Phone').groupable = true; + fixture.detectChanges(); + + grid.groupBy({ + fieldName: 'ContactTitle', dir: SortingDirection.Desc, ignoreCase: false, + strategy: DefaultSortingStrategy.instance() + }); + + fixture.detectChanges(); + + // verify grouping expressions + const grExprs = grid.groupingExpressions; + expect(grExprs.length).toEqual(1); + expect(grExprs[0].fieldName).toEqual('ContactTitle'); + + // verify rows + const groupRows = grid.groupsRowList.toArray(); + const dataRows = grid.dataRowList.toArray(); + + expect(groupRows.length).toEqual(1); + expect(dataRows.length).toEqual(6); + + // Verify first grouped row + const firstGroupedRow = groupRows[0].groupRow; + expect(firstGroupedRow.value).toEqual('Sales Representative'); + expect(firstGroupedRow.records.length).toEqual(6); + }); + }); +}); + +const getColGroup = (grid: IgxGridComponent, headerName: string): IgxColumnGroupComponent => { + const colGroups = grid.columnList.filter(c => c.columnGroup && c.header === headerName); + if (colGroups.length === 0) { + return null; + } else if (colGroups.length === 1) { + return colGroups[0]; + } else { + throw new Error('More than one column group found.'); + } +}; + +// tests column and column group header rendering +const testColumnGroupHeaderRendering = (column: DebugElement, width: number, height: number, + title: string, descendentColumnCssClass?: string, descendentColumnCount?: number) => { + + expect(column.nativeElement.offsetHeight).toBe(height); + expect(column.nativeElement.offsetWidth).toBe(width); + + const colHeaderTitle = column.children + .filter(c => c.nativeElement.classList.contains(GRID_COL_GROUP_THEAD_TITLE_CLASS))[0]; + expect(colHeaderTitle.nativeElement.textContent).toBe(title); + + const colGroupDirectChildren = column.children + .filter(c => c.nativeElement.classList.contains(GRID_COL_GROUP_THEAD_GROUP_CLASS))[0] + .children.filter(c => { + const header = c.query(By.directive(IgxGridHeaderComponent)); + return header.nativeElement.classList.contains(descendentColumnCssClass); + }); + + expect(colGroupDirectChildren.length).toBe(descendentColumnCount); +}; + +const testColumnHeaderRendering = (column: DebugElement, width: number, height: number, + title: string) => { + expect(column.nativeElement.offsetHeight).toBe(height); + expect(column.nativeElement.offsetWidth).toBe(width); + + const colHeaderTitle = column.children + .filter(c => c.nativeElement.classList.contains(GRID_COL_THEAD_TITLE_CLASS))[0]; + expect(colHeaderTitle.nativeElement.textContent.trim()).toBe(title); +}; + +const testColumnsOrder = (columns: IgxColumnComponent[]) => { + testColumnsIndexes(columns); + testColumnsVisibleIndexes(columns); +}; + +const testColumnsIndexes = (columns: IgxColumnComponent[]) => { + for (let index = 0; index < columns.length; index++) { + expect(columns[index].index).toBe(index); + } +}; + +const testColumnsVisibleIndexes = (columns: IgxColumnComponent[]) => { + let visibleIndex = 0; + for (const column of columns) { + expect(column.visibleIndex).toBe(visibleIndex); + if (!(column instanceof IgxColumnGroupComponent)) { + visibleIndex++; + } + } +}; + +const testGroupsAndColumns = (groups: number, columns: number, ci) => { + expect(GridFunctions.getColumnGroupHeaders(ci).length).toEqual(groups); + expect(GridFunctions.getColumnHeaders(ci).length).toEqual(columns); +}; + +const testColumnPinning = (column: IgxColumnComponent, isPinned: boolean) => { + expect(column.pinned).toBe(isPinned); + expect(column.allChildren.every(c => c.pinned === isPinned)).toEqual(true); +}; + +type PinUnpinFunc = (component: ColumnGroupFourLevelTestComponent) => void; + +class PinningTests { + public static testColumnGroupPinning(pinGenInfoColFunc: PinUnpinFunc, unpinGenInfoColFunc: PinUnpinFunc) { + const fixture = TestBed.createComponent(ColumnGroupFourLevelTestComponent); + fixture.detectChanges(); + const ci = fixture.componentInstance; + const grid = ci.grid; + expect(grid.pinnedColumns.length).toEqual(0); + expect(grid.unpinnedColumns.length).toEqual(18); + + // Pin a column in a group + pinGenInfoColFunc(ci); + + // Verify the topParent group is pinned + testColumnPinning(ci.genInfoColGroup, true); + testColumnPinning(ci.idCol, false); + testColumnPinning(ci.addrInfoColGroup, false); + testColumnsIndexes(ci.colsAndGroupsNaturalOrder); + testColumnsVisibleIndexes(ci.genInfoColsAndGroups.concat(ci.idCol).concat(ci.addrInfoColGroup)); + + expect(grid.pinnedColumns.length).toEqual(5); + expect(grid.unpinnedColumns.length).toEqual(13); + + // Unpin a column + unpinGenInfoColFunc(ci); + + // Verify the topParent group is not pinned + testColumnPinning(ci.genInfoColGroup, false); + testColumnPinning(ci.idCol, false); + testColumnPinning(ci.addrInfoColGroup, false); + testColumnsOrder(ci.colsAndGroupsNaturalOrder); + + expect(grid.pinnedColumns.length).toEqual(0); + expect(grid.unpinnedColumns.length).toEqual(18); + } +} + +class NestedColGroupsTests { + public static testHeadersRendering(fixture: ComponentFixture) { + const ci = fixture.componentInstance; + const grid = ci.grid; + const firstSlaveColGroup = fixture.debugElement.query(By.css('.firstSlaveColGroup')); + const firstSlaveColGroupDepth = 2; // one-level children + const firstSlaveColGroupChildrenCount = 2; + const firstSlaveColGroupWidth = parseInt(ci.columnWidth, 10) + parseInt(ci.phoneColWidth, 10); + + testColumnGroupHeaderRendering(firstSlaveColGroup, firstSlaveColGroupWidth, + firstSlaveColGroupDepth * grid.defaultRowHeight, + ci.firstSlaveColGroupTitle, 'firstSlaveChild', firstSlaveColGroupChildrenCount); + + const secondSlaveColGroup = fixture.debugElement.query(By.css('.secondSlaveColGroup')); + const secondSlaveColGroupDepth = 2; // one-level children + const secondSlaveColGroupChildrenCount = 2; + const secondSlaveColGroupWidth = parseInt(ci.faxColWidth, 10) + parseInt(ci.cityColWidth, 10); + + testColumnGroupHeaderRendering(secondSlaveColGroup, secondSlaveColGroupWidth, + secondSlaveColGroupDepth * grid.defaultRowHeight, + ci.secondSlaveColGroupTitle, 'secondSlaveChild', secondSlaveColGroupChildrenCount); + + const masterColGroup = fixture.debugElement.query(By.css('.masterColGroup')); + const masterColGroupWidth = firstSlaveColGroupWidth + secondSlaveColGroupWidth; + const masterSlaveColGroupDepth = 3; + const masterColGroupChildrenCount = 0; + + testColumnGroupHeaderRendering(masterColGroup, masterColGroupWidth, + masterSlaveColGroupDepth * grid.defaultRowHeight, ci.masterColGroupTitle, + 'slaveColGroup', masterColGroupChildrenCount); + } +} + diff --git a/projects/igniteui-angular/grids/grid/src/column-hiding.spec.ts b/projects/igniteui-angular/grids/grid/src/column-hiding.spec.ts new file mode 100644 index 00000000000..03def055f9b --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/column-hiding.spec.ts @@ -0,0 +1,860 @@ + +import { DebugElement } from '@angular/core'; +import { TestBed, fakeAsync, tick, ComponentFixture, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxGridComponent } from './grid.component'; +import { ColumnHidingTestComponent, ColumnGroupsHidingTestComponent } from '../../../test-utils/grid-base-components.spec'; +import { UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { GridFunctions } from '../../../test-utils/grid-functions.spec'; +import { GridSelectionMode, ColumnDisplayOrder, IgxColumnActionsComponent } from 'igniteui-angular/grids/core'; +import { ControlsFunction } from '../../../test-utils/controls-functions.spec'; +import { SortingDirection } from 'igniteui-angular/core'; + +describe('Column Hiding UI #grid', () => { + + let fix: ComponentFixture; + let grid: IgxGridComponent; + let columnChooser: IgxColumnActionsComponent; + let columnChooserElement: DebugElement; + + const verifyCheckbox = ControlsFunction.verifyCheckbox; + const verifyColumnIsHidden = GridFunctions.verifyColumnIsHidden; + const getColumnHidingButton = GridFunctions.getColumnHidingButton; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + ColumnHidingTestComponent, + ColumnGroupsHidingTestComponent + ] + }).compileComponents(); + })); + + describe('Basic', () => { + beforeEach(() => { + fix = TestBed.createComponent(ColumnHidingTestComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + columnChooser = fix.componentInstance.chooser; + columnChooserElement = GridFunctions.getColumnHidingElement(fix); + }); + + it('title is initially empty.', () => { + const title = GridFunctions.getColumnChooserTitle(columnChooserElement); + expect(title).toBe(null); + }); + + it('title can be successfully changed.', () => { + columnChooser.title = 'Show/Hide Columns'; + fix.detectChanges(); + + const titleElement = GridFunctions.getColumnChooserTitle(columnChooserElement).nativeElement as HTMLHeadingElement; + expect(columnChooser.title).toBe('Show/Hide Columns'); + expect(titleElement.textContent).toBe('Show/Hide Columns'); + + columnChooser.title = undefined; + fix.detectChanges(); + + expect(GridFunctions.getColumnChooserTitle(columnChooserElement)).toBeNull(); + + columnChooser.title = null; + fix.detectChanges(); + + expect(GridFunctions.getColumnChooserTitle(columnChooserElement)).toBeNull(); + }); + + it(`filter input visibility is controlled via 'hideFilter' property.`, () => { + let filterInputElement = GridFunctions.getColumnHidingHeaderInput(columnChooserElement); + expect(filterInputElement).not.toBeNull(); + + fix.componentInstance.hideFilter = true; + fix.detectChanges(); + + filterInputElement = GridFunctions.getColumnHidingHeaderInput(columnChooserElement); + expect(filterInputElement).toBeNull(); + + fix.componentInstance.hideFilter = false; + fix.detectChanges(); + + filterInputElement = GridFunctions.getColumnHidingHeaderInput(columnChooserElement); + expect(filterInputElement).not.toBeNull(); + }); + + it('lists all 4 hidable grid columns.', () => { + const columnItems = columnChooser.columnItems; + expect(columnItems.length).toBe(4); + + expect(GridFunctions.getColumnChooserItems(columnChooserElement).length).toBe(4); + }); + + it('orders columns according to "columnDisplayOrder".', () => { + expect(columnChooser.columnDisplayOrder).toBe(ColumnDisplayOrder.DisplayOrder); + + let columnNames = GridFunctions.getColumnActionsColumnList(columnChooserElement); + expect(columnNames).toEqual(['ID', 'Downloads', 'Released', 'ReleaseDate']); + + columnChooser.columnDisplayOrder = ColumnDisplayOrder.Alphabetical; + fix.detectChanges(); + + expect(columnChooser.columnDisplayOrder).toBe(ColumnDisplayOrder.Alphabetical); + columnNames = GridFunctions.getColumnActionsColumnList(columnChooserElement); + expect(columnNames).toEqual(['Downloads', 'ID', 'Released', 'ReleaseDate']); + + columnChooser.columnDisplayOrder = ColumnDisplayOrder.DisplayOrder; + fix.detectChanges(); + columnNames = GridFunctions.getColumnActionsColumnList(columnChooserElement); + expect(columnNames).toEqual(['ID', 'Downloads', 'Released', 'ReleaseDate']); + }); + + it('does not show "ProductName" column.', () => { + const colProductName = GridFunctions.getColumnChooserItemElement(columnChooserElement, 'ProductName'); + expect(colProductName).toBeUndefined(); + }); + + it('"hiddenColumnsCount" reflects properly the number of hidden columns.', fakeAsync(() => { + spyOn(grid.columnVisibilityChanged, 'emit'); + spyOn(grid.columnVisibilityChanging, 'emit'); + + expect(fix.componentInstance.hiddenColumnsCount).toBe(3); + + grid.columnList.get(2).hidden = false; + fix.detectChanges(); + expect(fix.componentInstance.hiddenColumnsCount).toBe(4); + + grid.columnList.get(0).hidden = true; + fix.detectChanges(); + expect(fix.componentInstance.hiddenColumnsCount).toBe(3); + expect(grid.columnVisibilityChanging.emit).toHaveBeenCalledTimes(0); + expect(grid.columnVisibilityChanged.emit).toHaveBeenCalledTimes(0); + + GridFunctions.clickColumnChooserItem(columnChooserElement, 'Released'); + fix.detectChanges(); + expect(fix.componentInstance.hiddenColumnsCount).toBe(2); + + expect(grid.columnVisibilityChanging.emit).toHaveBeenCalledTimes(1); + expect(grid.columnVisibilityChanged.emit).toHaveBeenCalledTimes(1); + })); + + it('allows hiding a column whose disabled=undefined.', () => { + grid.columnList.get(3).disableHiding = undefined; + fix.detectChanges(); + + verifyCheckbox('Released', true, false, columnChooserElement); + }); + + it('columnToggled, columnVisibilityChanged, onColumnVisibilityChanging event is fired on toggling checkboxes.', () => { + spyOn(columnChooser.columnToggled, 'emit'); + spyOn(grid.columnVisibilityChanged, 'emit'); + spyOn(grid.columnVisibilityChanging, 'emit'); + + GridFunctions.clickColumnChooserItem(columnChooserElement, 'ReleaseDate'); + + expect(columnChooser.columnToggled.emit).toHaveBeenCalledTimes(1); + expect(columnChooser.columnToggled.emit).toHaveBeenCalledWith( + { column: grid.getColumnByName('ReleaseDate'), checked: false }); + expect(grid.columnVisibilityChanging.emit).toHaveBeenCalledTimes(1); + expect(grid.columnVisibilityChanged.emit).toHaveBeenCalledTimes(1); + + GridFunctions.clickColumnChooserItem(columnChooserElement, 'ReleaseDate'); + + expect(columnChooser.columnToggled.emit).toHaveBeenCalledTimes(2); + expect(columnChooser.columnToggled.emit).toHaveBeenCalledWith( + { column: grid.getColumnByName('ReleaseDate'), checked: true }); + expect(grid.columnVisibilityChanging.emit).toHaveBeenCalledTimes(2); + expect(grid.columnVisibilityChanged.emit).toHaveBeenCalledTimes(2); + + GridFunctions.clickColumnChooserItem(columnChooserElement, 'Downloads'); + + expect(columnChooser.columnToggled.emit).toHaveBeenCalledTimes(3); + expect(columnChooser.columnToggled.emit).toHaveBeenCalledWith( + { column: grid.getColumnByName('Downloads'), checked: true }); + expect(grid.columnVisibilityChanging.emit).toHaveBeenCalledTimes(3); + expect(grid.columnVisibilityChanged.emit).toHaveBeenCalledTimes(3); + + GridFunctions.clickColumnChooserItem(columnChooserElement, 'Downloads'); + + expect(columnChooser.columnToggled.emit).toHaveBeenCalledTimes(4); + expect(columnChooser.columnToggled.emit).toHaveBeenCalledWith( + { column: grid.getColumnByName('Downloads'), checked: false }); + expect(grid.columnVisibilityChanging.emit).toHaveBeenCalledTimes(4); + expect(grid.columnVisibilityChanged.emit).toHaveBeenCalledTimes(4); + }); + + it('does not show any items when all columns disabled is true.', () => { + grid.columnList.forEach((col) => col.disableHiding = true); + fix.detectChanges(); + + const checkboxes = GridFunctions.getColumnChooserItems(columnChooserElement); + expect(checkboxes.length).toBe(0); + ControlsFunction.verifyButtonIsDisabled(GridFunctions.getColumnChooserButton(columnChooserElement, 'Show All').nativeElement); + ControlsFunction.verifyButtonIsDisabled(GridFunctions.getColumnChooserButton(columnChooserElement, 'Hide All').nativeElement); + }); + + it('- toggling column checkbox checked state successfully changes the grid column visibility.', () => { + const checkbox = GridFunctions.getColumnChooserItemElement(columnChooserElement, 'ReleaseDate'); + verifyCheckbox('ReleaseDate', true, false, columnChooserElement); + + const column = grid.getColumnByName('ReleaseDate'); + verifyColumnIsHidden(column, false, 4); + + GridFunctions.clickColumnChooserItem(columnChooserElement, 'ReleaseDate'); + fix.detectChanges(); + expect(GridFunctions.getColumnChooserItemInput(checkbox).checked).toBe(false); + verifyColumnIsHidden(column, true, 3); + + GridFunctions.clickColumnChooserItem(columnChooserElement, 'ReleaseDate'); + fix.detectChanges(); + expect(GridFunctions.getColumnChooserItemInput(checkbox).checked).toBe(true); + verifyColumnIsHidden(column, false, 4); + }); + + it('reflects properly grid column hidden value changes.', () => { + spyOn(grid.columnVisibilityChanged, 'emit'); + spyOn(grid.columnVisibilityChanging, 'emit'); + + const name = 'ReleaseDate'; + verifyCheckbox(name, true, false, columnChooserElement); + const column = grid.getColumnByName(name); + + column.hidden = true; + fix.detectChanges(); + + verifyCheckbox(name, false, false, columnChooserElement); + verifyColumnIsHidden(column, true, 3); + + column.hidden = false; + fix.detectChanges(); + + verifyCheckbox(name, true, false, columnChooserElement); + verifyColumnIsHidden(column, false, 4); + + column.hidden = undefined; + fix.detectChanges(); + + verifyCheckbox(name, true, false, columnChooserElement); + verifyColumnIsHidden(column, undefined, 4); + + column.hidden = true; + fix.detectChanges(); + verifyColumnIsHidden(column, true, 3); + + column.hidden = null; + fix.detectChanges(); + + verifyCheckbox(name, true, false, columnChooserElement); + verifyColumnIsHidden(column, null, 4); + expect(grid.columnVisibilityChanging.emit).toHaveBeenCalledTimes(0); + expect(grid.columnVisibilityChanged.emit).toHaveBeenCalledTimes(0); + }); + + it('enables the column checkbox and "Show All" button after changing disabled of a hidden column.', () => { + grid.columnList.forEach((col) => col.disableHiding = true); + const name = 'Downloads'; + grid.getColumnByName(name).disableHiding = false; + fix.detectChanges(); + const showAll = GridFunctions.getColumnChooserButton(columnChooserElement, 'Show All').nativeElement; + const hideAll = GridFunctions.getColumnChooserButton(columnChooserElement, 'Hide All').nativeElement; + + const checkbox = GridFunctions.getColumnChooserItemElement(columnChooserElement, name); + verifyCheckbox(name, false, false, columnChooserElement); + ControlsFunction.verifyButtonIsDisabled(showAll, false); + ControlsFunction.verifyButtonIsDisabled(hideAll); + + GridFunctions.clickColumnChooserItem(columnChooserElement, name); + fix.detectChanges(); + + expect(GridFunctions.getColumnChooserItemInput(checkbox).checked).toBe(true, 'Checkbox is not checked!'); + ControlsFunction.verifyButtonIsDisabled(showAll); + ControlsFunction.verifyButtonIsDisabled(hideAll, false); + + GridFunctions.clickColumnChooserItem(columnChooserElement, name); + fix.detectChanges(); + + expect(GridFunctions.getColumnChooserItemInput(checkbox).checked).toBe(false, 'Checkbox is not unchecked!'); + + ControlsFunction.verifyButtonIsDisabled(showAll, false); + ControlsFunction.verifyButtonIsDisabled(hideAll); + }); + + it('enables the column checkbox and "Hide All" button after changing disabled of a visible column.', () => { + grid.columns.forEach((col) => col.disableHiding = true); + const name = 'Released'; + grid.getColumnByName(name).disableHiding = false; + fix.detectChanges(); + const showAll = GridFunctions.getColumnChooserButton(columnChooserElement, 'Show All').nativeElement; + const hideAll = GridFunctions.getColumnChooserButton(columnChooserElement, 'Hide All').nativeElement; + + const checkbox = GridFunctions.getColumnChooserItemElement(columnChooserElement, name); + verifyCheckbox(name, true, false, columnChooserElement); + ControlsFunction.verifyButtonIsDisabled(showAll); + ControlsFunction.verifyButtonIsDisabled(hideAll, false); + + GridFunctions.clickColumnChooserItem(columnChooserElement, name); + fix.detectChanges(); + + expect(GridFunctions.getColumnChooserItemInput(checkbox).checked).toBe(false); + + ControlsFunction.verifyButtonIsDisabled(showAll, false); + ControlsFunction.verifyButtonIsDisabled(hideAll); + + GridFunctions.clickColumnChooserItem(columnChooserElement, name); + fix.detectChanges(); + + expect(GridFunctions.getColumnChooserItemInput(checkbox).checked).toBe(true); + ControlsFunction.verifyButtonIsDisabled(showAll); + ControlsFunction.verifyButtonIsDisabled(hideAll, false); + }); + + it('- "Hide All" button gets enabled after checking a column when all used to be hidden.', () => { + const hideAll = GridFunctions.getColumnChooserButton(columnChooserElement, 'Hide All'); + hideAll.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + ControlsFunction.verifyButtonIsDisabled(hideAll.nativeElement); + + GridFunctions.clickColumnChooserItem(columnChooserElement, 'ID'); + fix.detectChanges(); + + ControlsFunction.verifyButtonIsDisabled(hideAll.nativeElement, false); + }); + + it('- "Show All" button gets enabled after unchecking a column when all used to be visible.', () => { + const showAll = GridFunctions.getColumnChooserButton(columnChooserElement, 'Show All'); + showAll.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + ControlsFunction.verifyButtonIsDisabled(showAll.nativeElement); + + GridFunctions.clickColumnChooserItem(columnChooserElement, 'Released'); + fix.detectChanges(); + + ControlsFunction.verifyButtonIsDisabled(showAll.nativeElement, false); + }); + + it('- "Hide All" button gets disabled after checking the last unchecked column.', () => { + const hideAll = GridFunctions.getColumnChooserButton(columnChooserElement, 'Hide All').nativeElement; + ControlsFunction.verifyButtonIsDisabled(hideAll, false); + + GridFunctions.clickColumnChooserItem(columnChooserElement, 'ReleaseDate'); + GridFunctions.clickColumnChooserItem(columnChooserElement, 'Released'); + GridFunctions.clickColumnChooserItem(columnChooserElement, 'ID'); + fix.detectChanges(); + + ControlsFunction.verifyButtonIsDisabled(hideAll); + }); + + it('- "Show All" button gets disabled after unchecking the last checked column.', () => { + const showAll = GridFunctions.getColumnChooserButton(columnChooserElement, 'Show All').nativeElement; + ControlsFunction.verifyButtonIsDisabled(showAll, false); + + GridFunctions.clickColumnChooserItem(columnChooserElement, 'Downloads'); + fix.detectChanges(); + ControlsFunction.verifyButtonIsDisabled(showAll); + }); + + it('reflects changes in columns headers.', () => { + const column = grid.getColumnByName('ReleaseDate'); + column.header = 'Release Date'; + fix.detectChanges(); + + expect(GridFunctions.getColumnChooserItemElement(columnChooserElement, 'ReleaseDate')).toBeUndefined(); + expect(GridFunctions.getColumnChooserItemElement(columnChooserElement, 'Release Date')).toBeDefined(); + }); + + it('shows a filter textbox with no prompt', () => { + const filterInput = GridFunctions.getColumnChooserFilterInput(columnChooserElement).nativeElement; + + expect(filterInput).toBeDefined(); + expect(filterInput.placeholder).toBe(''); + expect(filterInput.textContent).toBe(''); + }); + + it('filter prompt can be changed.', () => { + columnChooser.filterColumnsPrompt = 'Type to filter columns'; + fix.detectChanges(); + + const filterInput = GridFunctions.getColumnChooserFilterInput(columnChooserElement).nativeElement; + expect(filterInput.placeholder).toBe('Type to filter columns'); + expect(filterInput.textContent).toBe(''); + + columnChooser.filterColumnsPrompt = null; + fix.detectChanges(); + + expect(filterInput.placeholder).toBe(''); + expect(filterInput.textContent).toBe(''); + + columnChooser.filterColumnsPrompt = undefined; + fix.detectChanges(); + + expect(filterInput.placeholder).toBe(''); + + columnChooser.filterColumnsPrompt = '@\#&*'; + fix.detectChanges(); + + expect(filterInput.placeholder).toBe('@\#&*'); + }); + + it('filters columns on every keystroke in filter input.', () => { + const filterInput = GridFunctions.getColumnChooserFilterInput(columnChooserElement); + + UIInteractions.triggerInputEvent(filterInput, 'r'); + fix.detectChanges(); + expect(columnChooser.columnItems.length).toBe(2); + + UIInteractions.triggerInputEvent(filterInput, 'releasedate'); + fix.detectChanges(); + expect(columnChooser.columnItems.length).toBe(1); + + UIInteractions.triggerInputEvent(filterInput, 'r'); + fix.detectChanges(); + expect(columnChooser.columnItems.length).toBe(2); + + UIInteractions.triggerInputEvent(filterInput, ''); + fix.detectChanges(); + expect(columnChooser.columnItems.length).toBe(4); + }); + + it('filters columns according to the specified filter criteria.', fakeAsync(() => { + columnChooser.filterCriteria = 'd'; + fix.detectChanges(); + tick(); + + const filterInput = GridFunctions.getColumnChooserFilterInput(columnChooserElement).nativeElement; + expect(filterInput.value).toBe('d'); + expect(columnChooser.columnItems.length).toBe(4); + + columnChooser.filterCriteria += 'a'; + fix.detectChanges(); + tick(); + + expect(filterInput.value).toBe('da'); + expect(columnChooser.columnItems.length).toBe(1); + + columnChooser.filterCriteria = ''; + columnChooser.filterCriteria = 'el'; + fix.detectChanges(); + tick(); + + expect(filterInput.value).toBe('el'); + expect(columnChooser.columnItems.length).toBe(2); + + columnChooser.filterCriteria = ''; + fix.detectChanges(); + tick(); + + expect(filterInput.value).toBe(''); + expect(columnChooser.columnItems.length).toBe(4); + })); + + it('- Hide All button operates over the filtered in columns only', fakeAsync(() => { + spyOn(grid.columnVisibilityChanged, 'emit'); + spyOn(grid.columnVisibilityChanging, 'emit'); + + grid.columnList.get(1).disableHiding = false; + columnChooser.filterCriteria = 're'; + fix.detectChanges(); + + const showAll = GridFunctions.getColumnChooserButton(columnChooserElement, 'Show All').nativeElement; + const hideAll = GridFunctions.getColumnChooserButton(columnChooserElement, 'Hide All'); + ControlsFunction.verifyButtonIsDisabled(showAll); + ControlsFunction.verifyButtonIsDisabled(hideAll.nativeElement, false); + + expect(columnChooser.columnItems.length).toBe(2); + hideAll.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + let checkbox = GridFunctions.getColumnChooserItemElement(columnChooserElement, 'Released'); + expect(GridFunctions.getColumnChooserItemInput(checkbox).checked).toBe(false); + checkbox = GridFunctions.getColumnChooserItemElement(columnChooserElement, 'ReleaseDate'); + expect(GridFunctions.getColumnChooserItemInput(checkbox).checked).toBe(false); + + ControlsFunction.verifyButtonIsDisabled(showAll, false); + ControlsFunction.verifyButtonIsDisabled(hideAll.nativeElement); + + expect(grid.columnVisibilityChanging.emit).toHaveBeenCalledTimes(columnChooser.columnItems.length); + expect(grid.columnVisibilityChanged.emit).toHaveBeenCalledTimes(columnChooser.columnItems.length); + + columnChooser.filterCriteria = 'r'; + tick(); + fix.detectChanges(); + + ControlsFunction.verifyButtonIsDisabled(showAll, false); + ControlsFunction.verifyButtonIsDisabled(hideAll.nativeElement, false); + + checkbox = GridFunctions.getColumnChooserItemElement(columnChooserElement, 'ProductName'); + expect(GridFunctions.getColumnChooserItemInput(checkbox).checked).toBe(true); + + hideAll.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + columnChooser.filterCriteria = ''; + tick(); + fix.detectChanges(); + + expect(columnChooser.filterCriteria).toBe('', 'Filter criteria is not empty string!'); + expect(GridFunctions.getColumnChooserItemInput(checkbox).checked).toBe(false); + checkbox = GridFunctions.getColumnChooserItemElement(columnChooserElement, 'ID'); + expect(GridFunctions.getColumnChooserItemInput(checkbox).checked).toBe(true); + + ControlsFunction.verifyButtonIsDisabled(showAll, false); + ControlsFunction.verifyButtonIsDisabled(hideAll.nativeElement, false); + + expect(grid.columnVisibilityChanging.emit).toHaveBeenCalledTimes(columnChooser.columnItems.length); + expect(grid.columnVisibilityChanged.emit).toHaveBeenCalledTimes(columnChooser.columnItems.length); + })); + + it('- When Hide All columns no rows should be rendered', fakeAsync(() => { + fix.componentInstance.paging = true; + tick(50); + fix.detectChanges(); + grid.rowSelection = GridSelectionMode.multiple; + grid.rowDraggable = true; + tick(50); + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'Released', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + + let fixEl = fix.nativeElement; let gridEl = grid.nativeElement; + let tHeadItems = fixEl.querySelector('igx-grid-header-group'); + let gridRows = fixEl.querySelector('igx-grid-row'); + const paging = fixEl.querySelector('.igx-paginator'); + let rowSelectors = gridEl.querySelector('.igx-grid__cbx-padding > igx-checkbox'); + let dragIndicators = gridEl.querySelector('.igx-grid__drag-indicator'); + let verticalScrollBar = gridEl.querySelector('.igx-grid__tbody-scrollbar[hidden]'); + + expect(tHeadItems).not.toBeNull(); + expect(gridRows).not.toBeNull(); + expect(paging).not.toBeNull(); + expect(rowSelectors).not.toBeNull(); + expect(dragIndicators).not.toBeNull(); + expect(verticalScrollBar).toBeNull(); + + grid.columnList.forEach((col) => col.hidden = true); + tick(30); + fix.detectChanges(); + grid.columnList.forEach((col) => { + expect(col.width).toBe('0px'); + }); + fixEl = fix.nativeElement; + gridEl = grid.nativeElement; + + tHeadItems = fixEl.querySelector('igx-grid-header-group'); + gridRows = fixEl.querySelector('igx-grid-row'); + rowSelectors = gridEl.querySelector('.igx-grid__cbx-padding > igx-checkbox'); + dragIndicators = gridEl.querySelector('.igx-grid__drag-indicator'); + verticalScrollBar = gridEl.querySelector('.igx-grid__tbody-scrollbar[hidden]'); + + expect(tHeadItems).toBeNull(); + expect(gridRows).toBeNull(); + expect(rowSelectors).toBeNull(); + expect(dragIndicators).toBeNull(); + expect(verticalScrollBar).not.toBeNull(); + })); + + it('- Show All button operates over the filtered in columns only', fakeAsync(() => { + spyOn(grid.columnVisibilityChanged, 'emit'); + spyOn(grid.columnVisibilityChanging, 'emit'); + + grid.columnList.get(1).disableHiding = false; + fix.detectChanges(); + const colLength = columnChooser.columnItems.length; + columnChooser.checkAllColumns(); + columnChooser.filterCriteria = 're'; + fix.detectChanges(); + tick(); + + expect(grid.columnVisibilityChanging.emit).toHaveBeenCalledTimes(colLength); + expect(grid.columnVisibilityChanged.emit).toHaveBeenCalledTimes(colLength); + + const showAll = GridFunctions.getColumnChooserButton(columnChooserElement, 'Show All'); + const hideAll = GridFunctions.getColumnChooserButton(columnChooserElement, 'Hide All').nativeElement; + ControlsFunction.verifyButtonIsDisabled(showAll.nativeElement); + ControlsFunction.verifyButtonIsDisabled(hideAll, false); + + showAll.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + expect(grid.columnVisibilityChanging.emit).toHaveBeenCalledTimes(colLength + columnChooser.columnItems.length); + expect(grid.columnVisibilityChanged.emit).toHaveBeenCalledTimes(colLength + columnChooser.columnItems.length); + + let checkbox = GridFunctions.getColumnChooserItemElement(columnChooserElement, 'Released'); + expect(GridFunctions.getColumnChooserItemInput(checkbox).checked).toBe(true); + checkbox = GridFunctions.getColumnChooserItemElement(columnChooserElement, 'ReleaseDate'); + expect(GridFunctions.getColumnChooserItemInput(checkbox).checked).toBe(true); + + ControlsFunction.verifyButtonIsDisabled(showAll.nativeElement); + ControlsFunction.verifyButtonIsDisabled(hideAll, false); + + columnChooser.filterCriteria = 'r'; + fix.detectChanges(); + tick(); + + ControlsFunction.verifyButtonIsDisabled(showAll.nativeElement); + ControlsFunction.verifyButtonIsDisabled(hideAll, false); + + checkbox = GridFunctions.getColumnChooserItemElement(columnChooserElement, 'ProductName'); + expect(GridFunctions.getColumnChooserItemInput(checkbox).checked).toBe(true); + + showAll.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + columnChooser.filterCriteria = ''; + fix.detectChanges(); + tick(); + + expect(columnChooser.filterCriteria).toBe('', 'Filter criteria is not empty string!'); + + checkbox = GridFunctions.getColumnChooserItemElement(columnChooserElement, 'ID'); + expect(GridFunctions.getColumnChooserItemInput(checkbox).checked).toBe(true); + checkbox = GridFunctions.getColumnChooserItemElement(columnChooserElement, 'ProductName'); + expect(GridFunctions.getColumnChooserItemInput(checkbox).checked).toBe(true); + + ControlsFunction.verifyButtonIsDisabled(showAll.nativeElement); + ControlsFunction.verifyButtonIsDisabled(hideAll, false); + })); + + it('hides the proper columns after filtering and clearing the filter', () => { + const showAll = GridFunctions.getColumnChooserButton(columnChooserElement, 'Show All'); + + const filterInput = GridFunctions.getColumnChooserFilterInput(columnChooserElement); + + UIInteractions.triggerInputEvent(filterInput, 'a'); + fix.detectChanges(); + + ControlsFunction.verifyButtonIsDisabled(showAll.nativeElement, false); + showAll.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + ControlsFunction.verifyButtonIsDisabled(showAll.nativeElement); + expect(grid.columnList.get(2).hidden).toBe(false, 'Downloads column is not hidden!'); + + UIInteractions.triggerInputEvent(filterInput, ''); + fix.detectChanges(); + + ControlsFunction.verifyButtonIsDisabled(showAll.nativeElement); + expect(grid.columnList.get(0).hidden).toBe(false, 'ID column is not shown!'); + GridFunctions.clickColumnChooserItem(columnChooserElement, 'ID'); + fix.detectChanges(); + + ControlsFunction.verifyButtonIsDisabled(showAll.nativeElement, false); + expect(grid.columnList.get(0).hidden).toBe(true, 'ID column is not hidden!'); + }); + + it('height can be controlled via columnsAreaMaxHeight input.', () => { + expect(columnChooser.columnsAreaMaxHeight).toBe('100%'); + expect(columnChooserElement.nativeElement.offsetHeight >= 230).toBe(true); + + columnChooser.columnsAreaMaxHeight = '150px'; + fix.detectChanges(); + const columnsAreaDiv = GridFunctions.getColumnHidingColumnsContainer(columnChooserElement); + expect(getComputedStyle(columnsAreaDiv.nativeElement).maxHeight).toBe('150px'); + expect(columnChooserElement.nativeElement.offsetHeight <= 255).toBe(true); + }); + + it('should recalculate heights when enough columns are hidden so that there is no need for horizontal scrollbar.', () => { + grid.height = '200px'; + fix.detectChanges(); + expect(grid.scr.nativeElement.hidden).toBe(false); + const toolbar = GridFunctions.getToolbar(fix); + const gridHeader = GridFunctions.getGridHeader(grid); + const gridScroll = GridFunctions.getGridScroll(fix); + const gridFooter = GridFunctions.getGridFooterWrapper(fix); + let expectedHeight = parseInt(window.getComputedStyle(grid.nativeElement).height, 10) + - parseInt(window.getComputedStyle(toolbar.nativeElement).height, 10) + - parseInt(window.getComputedStyle(gridHeader.nativeElement).height, 10) + - parseInt(window.getComputedStyle(gridFooter.nativeElement).height, 10) + - parseInt(window.getComputedStyle(gridScroll.nativeElement).height, 10); + + expect(grid.calcHeight).toEqual(expectedHeight); + + grid.columnList.get(3).hidden = true; + fix.detectChanges(); + expect(grid.scr.nativeElement.hidden).toBe(true); + + expectedHeight = parseInt(window.getComputedStyle(grid.nativeElement).height, 10) + - parseInt(window.getComputedStyle(toolbar.nativeElement).height, 10) + - parseInt(window.getComputedStyle(gridHeader.nativeElement).height, 10) + - parseInt(window.getComputedStyle(gridFooter.nativeElement).height, 10); + + expect(grid.calcHeight).toEqual(expectedHeight); + }); + }); + + describe('Column Groups', () => { + beforeEach(() => { + fix = TestBed.createComponent(ColumnGroupsHidingTestComponent); + (fix.componentInstance as ColumnGroupsHidingTestComponent).hasGroupColumns = true; + fix.detectChanges(); + grid = fix.componentInstance.grid; + columnChooser = fix.componentInstance.chooser; + columnChooserElement = GridFunctions.getColumnHidingElement(fix); + }); + + it('indents columns according to their level.', () => { + const items = GridFunctions.getColumnChooserItems(columnChooserElement); + const margin0 = '0px'; + const margin30 = '30px'; + const margin60 = '60px'; + expect(getComputedStyle(items[0].nativeElement).marginLeft).toBe(margin0); + expect(getComputedStyle(items[1].nativeElement).marginLeft).toBe(margin0); + expect(getComputedStyle(items[2].nativeElement).marginLeft).toBe(margin30); + expect(getComputedStyle(items[3].nativeElement).marginLeft).toBe(margin30); + expect(getComputedStyle(items[4].nativeElement).marginLeft).toBe(margin60); + expect(getComputedStyle(items[5].nativeElement).marginLeft).toBe(margin60); + expect(getComputedStyle(items[6].nativeElement).marginLeft).toBe(margin0); + }); + + it('checks & hides all children when hiding their parent.', () => { + GridFunctions.clickColumnChooserItem(columnChooserElement, 'Person Details'); + fix.detectChanges(); + + verifyCheckbox('Person Details', false, false, columnChooserElement); + verifyCheckbox('ContactName', false, false, columnChooserElement); + verifyCheckbox('ContactTitle', false, false, columnChooserElement); + + verifyColumnIsHidden(grid.columnList.get(3), true, 4); + verifyColumnIsHidden(grid.columnList.get(4), true, 4); + verifyColumnIsHidden(grid.columnList.get(5), true, 4); + + verifyCheckbox('CompanyName', true, false, columnChooserElement); + verifyCheckbox('General Information', true, false, columnChooserElement); + + GridFunctions.clickColumnChooserItem(columnChooserElement, 'Person Details'); + fix.detectChanges(); + + verifyColumnIsHidden(grid.columnList.get(3), false, 7); + verifyColumnIsHidden(grid.columnList.get(4), false, 7); + verifyColumnIsHidden(grid.columnList.get(5), false, 7); + + verifyCheckbox('Person Details', true, false, columnChooserElement); + verifyCheckbox('ContactName', true, false, columnChooserElement); + verifyCheckbox('ContactTitle', true, false, columnChooserElement); + + verifyCheckbox('CompanyName', true, false, columnChooserElement); + verifyCheckbox('General Information', true, false, columnChooserElement); + }); + + it('checks & hides all descendants when hiding top level parent.', () => { + GridFunctions.clickColumnChooserItem(columnChooserElement, 'General Information'); + fix.detectChanges(); + + verifyCheckbox('General Information', false, false, columnChooserElement); + verifyCheckbox('CompanyName', false, false, columnChooserElement); + + verifyCheckbox('Person Details', false, false, columnChooserElement); + verifyCheckbox('ContactName', false, false, columnChooserElement); + verifyCheckbox('ContactTitle', false, false, columnChooserElement); + + verifyCheckbox('Missing', true, false, columnChooserElement); + verifyCheckbox('ID', true, false, columnChooserElement); + + GridFunctions.clickColumnChooserItem(columnChooserElement, 'General Information'); + fix.detectChanges(); + + verifyCheckbox('General Information', true, false, columnChooserElement); + verifyCheckbox('CompanyName', true, false, columnChooserElement); + + verifyCheckbox('Person Details', true, false, columnChooserElement); + verifyCheckbox('ContactName', true, false, columnChooserElement); + verifyCheckbox('ContactTitle', true, false, columnChooserElement); + }); + + it('checks/unchecks parent when all children are checked/unchecked.', () => { + verifyCheckbox('Person Details', true, false, columnChooserElement); + GridFunctions.clickColumnChooserItem(columnChooserElement, 'ContactName'); + fix.detectChanges(); + + verifyCheckbox('Person Details', true, false, columnChooserElement); + GridFunctions.clickColumnChooserItem(columnChooserElement, 'ContactTitle'); + fix.detectChanges(); + + verifyCheckbox('Person Details', false, false, columnChooserElement); + GridFunctions.clickColumnChooserItem(columnChooserElement, 'ContactName'); + fix.detectChanges(); + + verifyCheckbox('Person Details', true, false, columnChooserElement); + + GridFunctions.clickColumnChooserItem(columnChooserElement, 'ContactTitle'); + fix.detectChanges(); + + verifyCheckbox('Person Details', true, false, columnChooserElement); + }); + + it('filters group columns properly.', () => { + columnChooser.filterCriteria = 'cont'; + fix.detectChanges(); + + expect(columnChooser.columnItems.length).toBe(4); + expect(GridFunctions.getColumnChooserItems(columnChooserElement).length).toBe(4); + + expect(GridFunctions.getColumnChooserItemElement(columnChooserElement, 'General Information')).toBeDefined(); + expect(GridFunctions.getColumnChooserItemElement(columnChooserElement, 'Person Details')).toBeDefined(); + expect(GridFunctions.getColumnChooserItemElement(columnChooserElement, 'ContactName')).toBeDefined(); + expect(GridFunctions.getColumnChooserItemElement(columnChooserElement, 'ContactTitle')).toBeDefined(); + + columnChooser.filterCriteria = 'pers'; + fix.detectChanges(); + + expect(columnChooser.columnItems.length).toBe(2); + expect(GridFunctions.getColumnChooserItems(columnChooserElement).length).toBe(2); + expect(GridFunctions.getColumnChooserItemElement(columnChooserElement, 'General Information')).toBeDefined(); + expect(GridFunctions.getColumnChooserItemElement(columnChooserElement, 'Person Details')).toBeDefined(); + + columnChooser.filterCriteria = 'mi'; + fix.detectChanges(); + + expect(columnChooser.columnItems.length).toBe(1); + expect(GridFunctions.getColumnChooserItems(columnChooserElement).length).toBe(1); + expect(GridFunctions.getColumnChooserItemElement(columnChooserElement, 'General Information')).toBeUndefined(); + expect(GridFunctions.getColumnChooserItemElement(columnChooserElement, 'Missing')).toBeDefined(); + }); + + it('hides the proper columns when filtering and pressing hide all.', () => { + columnChooser.filterCriteria = 'cont'; + fix.detectChanges(); + + const hideAll = GridFunctions.getColumnChooserButton(columnChooserElement, 'Hide All'); + hideAll.triggerEventHandler('click', new Event('click')); + columnChooser.filterCriteria = ''; + fix.detectChanges(); + for (let i = 1; i < 6; i++) { + verifyColumnIsHidden(grid.columnList.get(i), true, 2); + } + }); + }); + + describe('toolbar button', () => { + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(ColumnHidingTestComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + grid.columnList.get(2).hidden = true; + fix.componentInstance.showInline = false; + fix.detectChanges(); + + columnChooserElement = GridFunctions.getColumnHidingElement(fix); + })); + + it('shows the number of hidden columns.', () => { + fix.detectChanges(); + const btnText = getColumnHidingButton(fix).innerText.toLowerCase(); + expect(btnText.includes('1') && btnText.includes('hidden')).toBe(true); + expect(getColumnChooserButtonIcon(fix).innerText.toLowerCase()).toBe('visibility_off'); + }); + + it('shows the proper icon when no columns are hidden.', () => { + grid.columnList.get(2).hidden = false; + fix.detectChanges(); + + const btnText = getColumnHidingButton(fix).innerText.toLowerCase(); + expect(btnText.includes('0') && btnText.includes('hidden')).toBe(true); + expect(getColumnChooserButtonIcon(fix).innerText.toLowerCase()).toBe('visibility'); + }); + }); + + const getColumnChooserButtonIcon = (fixture: ComponentFixture) => getColumnHidingButton(fixture).querySelector('igx-icon'); +}); diff --git a/projects/igniteui-angular/grids/grid/src/column-moving.spec.ts b/projects/igniteui-angular/grids/grid/src/column-moving.spec.ts new file mode 100644 index 00000000000..8cc48444bc0 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/column-moving.spec.ts @@ -0,0 +1,1958 @@ +import { DebugElement } from '@angular/core'; +import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxColumnComponent, IgxColumnGroupComponent } from 'igniteui-angular/grids/core'; +import { IgxInputDirective } from 'igniteui-angular/input-group'; +import { + MovableColumnsComponent, + MovableTemplatedColumnsComponent, + MovableColumnsLargeComponent, + MultiColumnHeadersComponent + } from '../../../test-utils/grid-samples.spec'; +import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; +import { IgxGridComponent } from './grid.component'; +import { GridSelectionFunctions, GridFunctions } from '../../../test-utils/grid-functions.spec'; +import { ColumnType, SortingDirection } from 'igniteui-angular/core'; + +describe('IgxGrid - Column Moving #grid', () => { + const CELL_CSS_CLASS = '.igx-grid__td'; + const COLUMN_HEADER_CLASS = '.igx-grid-th'; + const COLUMN_GROUP_HEADER_CLASS = '.igx-grid-thead__title'; + const COLUMN_RESIZE_CLASS = '.igx-grid-th__resize-line'; + + let fixture; let grid: IgxGridComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + FormsModule, + NoopAnimationsModule, + MovableColumnsComponent, + MovableTemplatedColumnsComponent, + MovableColumnsLargeComponent, + MultiColumnHeadersComponent + ] + }).compileComponents(); + })); + + describe('', () => { + beforeEach(() => { + fixture = TestBed.createComponent(MovableColumnsComponent); + grid = fixture.componentInstance.grid; + fixture.detectChanges(); + }); + + it('Should be able to reorder columns.', fakeAsync(() => { + let columnsList = grid.columns; + grid.moveColumn(columnsList[0], columnsList[2]); + tick(); + fixture.detectChanges(); + columnsList = grid.columns; + expect(columnsList[0].field).toEqual('Name'); + expect(columnsList[1].field).toEqual('LastName'); + expect(columnsList[2].field).toEqual('ID'); + })); + + it('Should be able to reorder columns programmatically.', fakeAsync(() => { + let columnsList = grid.columns; + const column = columnsList[0] as IgxColumnComponent; + column.move(2); + tick(); + fixture.detectChanges(); + + columnsList = grid.columns; + expect(columnsList[0].field).toEqual('Name'); + expect(columnsList[1].field).toEqual('LastName'); + expect(columnsList[2].field).toEqual('ID'); + })); + + it('Should not reorder columns, if passed incorrect index.', fakeAsync(() => { + let columnsList = grid.columns; + + expect(columnsList[0].field).toEqual('ID'); + expect(columnsList[1].field).toEqual('Name'); + expect(columnsList[2].field).toEqual('LastName'); + + const column = columnsList[0] as IgxColumnComponent; + column.move(-1); + tick(); + fixture.detectChanges(); + + columnsList = grid.columns; + expect(columnsList[0].field).toEqual('ID'); + expect(columnsList[1].field).toEqual('Name'); + expect(columnsList[2].field).toEqual('LastName'); + + column.move(columnsList.length); + tick(); + fixture.detectChanges(); + + columnsList = grid.columns; + expect(columnsList[0].field).toEqual('ID'); + expect(columnsList[1].field).toEqual('Name'); + expect(columnsList[2].field).toEqual('LastName'); + })); + + it('Should show hidden column on correct index', fakeAsync(() => { + let columnsList = grid.columns; + const column = columnsList[0] as IgxColumnComponent; + + column.hidden = true; + fixture.detectChanges(); + + column.move(2); + tick(); + column.hidden = false; + fixture.detectChanges(); + + columnsList = grid.columns; + expect(columnsList[0].field).toEqual('Name'); + expect(columnsList[1].field).toEqual('LastName'); + expect(columnsList[2].field).toEqual('ID'); + })); + + it('Should fire columnMovingEnd with correct values of event arguments.', fakeAsync(() => { + let columnsList = grid.columns; + const column = columnsList[0] as IgxColumnComponent; + + spyOn(grid.columnMovingEnd, 'emit').and.callThrough(); + + column.move(2); + tick(); + fixture.detectChanges(); + + columnsList = grid.columns; + const args = { source: grid.columns[2], target: grid.columns[1], cancel: false }; + expect(grid.columnMovingEnd.emit).toHaveBeenCalledTimes(1); + expect(grid.columnMovingEnd.emit).toHaveBeenCalledWith(args); + })); + + it('Should exit edit mode and commit the new value when column moving programmatically', fakeAsync(() => { + fixture.componentInstance.isEditable = true; + fixture.detectChanges(); + const cacheValue = grid.getCellByColumn(0, 'ID').value; + + // step 1 - enter edit mode on a cell + const cell = fixture.debugElement.queryAll(By.css(CELL_CSS_CLASS))[0]; + cell.nativeElement.dispatchEvent(new Event('focus')); + fixture.detectChanges(); + + cell.triggerEventHandler('dblclick', {}); + fixture.detectChanges(); + expect(grid.getCellByColumn(0, 'ID').editMode).toBe(true); + + // step 2 - enter some new value + const editTemplate = cell.query(By.css('input')); + editTemplate.nativeElement.value = '4'; + editTemplate.nativeElement.dispatchEvent(new Event('input')); + fixture.detectChanges(); + + // step 3 - move a column + const columnsList = grid.columns; + const column = columnsList[0] as IgxColumnComponent; + column.move(2); + tick(); + fixture.detectChanges(); + + // step 4 - verify cell has exited edit mode correctly + expect(grid.columns[2].field).toEqual('ID'); + expect(grid.getCellByColumn(0, 'ID').editMode).toBe(false); + expect(grid.getCellByColumn(0, 'ID').value).toBe(cacheValue); + })); + + it('Should preserve hidden columns order after columns are reordered programmatically', fakeAsync(() => { + + // step 1 - hide a column + fixture.componentInstance.isHidden = true; + fixture.detectChanges(); + fixture.detectChanges(); + + // step 2 - move a column + const columnsList = grid.columns; + const column = columnsList[2] as IgxColumnComponent; + column.move(1); + tick(); + fixture.detectChanges(); + + + expect(grid.visibleColumns[1].field).toEqual('LastName'); + + // step 3 - show hidden columns and verify correct order + fixture.componentInstance.isHidden = false; + fixture.detectChanges(); + fixture.detectChanges(); + + expect(grid.visibleColumns[0].field).toEqual('ID'); + expect(grid.visibleColumns[1].field).toEqual('Name'); + expect(grid.visibleColumns[2].field).toEqual('LastName'); + })); + + it('Should not break vertical or horizontal scrolling after columns are reordered programmatically', (async () => { + let columnsList = grid.columns; + + // step 1 - move a column + const column = columnsList[1] as IgxColumnComponent; + column.move(2); + await wait(); + fixture.detectChanges(); + + columnsList = grid.columns; + + expect(columnsList[0].field).toEqual('ID'); + expect(columnsList[1].field).toEqual('LastName'); + expect(columnsList[2].field).toEqual('Name'); + + // step 2 - verify vertical scrolling is not broken + grid.verticalScrollContainer.getScroll().scrollTop = 200; + await wait(100); + fixture.detectChanges(); + + expect(grid.columns[0].cells[3].value).toBeTruthy(7); + + // step 3 - verify horizontal scrolling is not broken + grid.headerContainer.getScroll().scrollLeft = 200; + await wait(100); + fixture.detectChanges(); + + expect(grid.columns[2].cells[3].value).toBeTruthy('BRown'); + })); + + it('Should be able to reorder columns programmatically when a column is grouped.', (async () => { + fixture.componentInstance.isGroupable = true; + fixture.detectChanges(); + let columnsList = grid.columns; + + // step 1 - group a column + grid.groupBy({ fieldName: 'ID', dir: SortingDirection.Desc, ignoreCase: false }); + fixture.detectChanges(); + + // step 2 - move a column + const column = columnsList[0] as IgxColumnComponent; + column.move(2); + await wait(); + fixture.detectChanges(); + fixture.detectChanges(); + + columnsList = grid.columns; + expect(columnsList[0].field).toEqual('Name'); + expect(columnsList[1].field).toEqual('LastName'); + expect(columnsList[2].field).toEqual('ID'); + })); + + it('Should not break KB after columns are reordered programmatically - selection belongs to the moved column.', (async () => { + let columnsList = grid.columns; + + // step 1 - select a cell from 'ID' column + const cell = grid.gridAPI.get_cell_by_index(0, 'ID'); + UIInteractions.simulateClickAndSelectEvent(cell); + fixture.detectChanges(); + + // step 2 - move that column + + const column = columnsList[0] as IgxColumnComponent; + column.move(1); + await wait(); + fixture.detectChanges(); + + columnsList = grid.columns; + expect(columnsList[0].field).toEqual('Name'); + expect(columnsList[1].field).toEqual('ID'); + expect(columnsList[2].field).toEqual('LastName'); + + // step 3 - navigate right and verify cell selection is updated + const gridContent = GridFunctions.getGridContent(fixture); + GridFunctions.focusFirstCell(fixture, grid); + UIInteractions.triggerKeyDownEvtUponElem('arrowright', gridContent.nativeElement, true); + await wait(50); + fixture.detectChanges(); + + expect(grid.getCellByColumn(0, 'ID').selected).toBeTruthy(); + })); + + it('Should not reorder columns when dropping the ghost image on a non-interactive area.', (async () => { + const headers: DebugElement[] = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + + // step 1 - start moving a column, release the drag ghost over cells area + // and verify columns are not reordered + const header = headers[1].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 250, 65); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 256, 71); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 380, 350); + await wait(); + UIInteractions.simulatePointerEvent('pointerup', header, 380, 350); + await wait(); + fixture.detectChanges(); + + const columnsList = grid.columns; + expect(columnsList[0].field).toEqual('ID'); + expect(columnsList[1].field).toEqual('Name'); + expect(columnsList[2].field).toEqual('LastName'); + })); + + it('Should not reorder columns on hitting ESCAPE key.', (async () => { + const headers: DebugElement[] = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + + // step 2 - start moving a column + const header = headers[0].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 130, 65); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 136, 71); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 270, 71); + await wait(); + + // step 2 - hit ESCAPE over the headers area and verify column moving is canceled + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + document.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' })); + fixture.detectChanges(); + + UIInteractions.simulatePointerEvent('pointerup', header, 270, 71); + await wait(); + fixture.detectChanges(); + + const columnsList = grid.columns; + expect(columnsList[0].field).toEqual('ID'); + expect(columnsList[1].field).toEqual('Name'); + expect(columnsList[2].field).toEqual('LastName'); + })); + + it('Should not break sorting and resizing when column moving is enabled.', (async () => { + fixture.componentInstance.isFilterable = true; + fixture.componentInstance.isResizable = true; + fixture.componentInstance.isSortable = true; + fixture.detectChanges(); + + const headers: DebugElement[] = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + + const header = headers[1].nativeElement; + let columnsList = grid.columns; + + // step 1 - move a column + UIInteractions.simulatePointerEvent('pointerdown', header, 250, 65); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 244, 71); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 100, 71); + await wait(50); + UIInteractions.simulatePointerEvent('pointerup', header, 100, 71); + await wait(); + fixture.detectChanges(); + + columnsList = grid.columns; + expect(columnsList[0].field).toEqual('Name'); + expect(columnsList[1].field).toEqual('ID'); + expect(columnsList[2].field).toEqual('LastName'); + + // step 2 - verify resizing is not broken + const resizeHandle = headers[0].parent.nativeElement.children[2]; + UIInteractions.simulateMouseEvent('mousedown', resizeHandle, 200, 80); + await wait(250); + fixture.detectChanges(); + + const resizer = fixture.debugElement.queryAll(By.css(COLUMN_RESIZE_CLASS))[0].nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 300, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 300, 5); + fixture.detectChanges(); + + expect(grid.columns[0].width).toEqual('250px'); + + // step 3 - verify sorting is not broken + GridFunctions.clickHeaderSortIcon(headers[0]); + GridFunctions.clickHeaderSortIcon(headers[0]); + fixture.detectChanges(); + + expect(grid.getCellByColumn(0, 'ID').value).toEqual(6); + })); + + it('Should not break vertical or horizontal scrolling after columns are reordered.', (async () => { + const headers: DebugElement[] = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + + const header = headers[1].nativeElement; + let columnsList = grid.columns; + + // step 1 - move a column + UIInteractions.simulatePointerEvent('pointerdown', header, 250, 65); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 244, 71); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 100, 71); + await wait(); + UIInteractions.simulatePointerEvent('pointerup', header, 100, 71); + await wait(); + fixture.detectChanges(); + + columnsList = grid.columns; + expect(columnsList[0].field).toEqual('Name'); + expect(columnsList[1].field).toEqual('ID'); + expect(columnsList[2].field).toEqual('LastName'); + + // step 2 - verify vertical scrolling is not broken + grid.verticalScrollContainer.getScroll().scrollTop = 200; + await wait(100); + fixture.detectChanges(); + + expect(grid.columns[0].cells[3].value).toBeTruthy('Rick'); + + // step 3 - verify horizontal scrolling is not broken + grid.headerContainer.getScroll().scrollLeft = 200; + await wait(100); + fixture.detectChanges(); + + expect(grid.columns[2].cells[3].value).toBeTruthy('BRown'); + })); + + it('Should fire columnMovingStart, columnMoving and columnMovingEnd with correct values of event arguments.', (async () => { + const headers: DebugElement[] = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + + // step 1 - start moving a column + const header = headers[0].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 150, 65); + await wait(); + + // step 2 - verify columnMovingStart is fired correctly + UIInteractions.simulatePointerEvent('pointermove', header, 156, 71); + await wait(50); + + expect(fixture.componentInstance.countStart).toEqual(1); + expect(fixture.componentInstance.source).toEqual(grid.columns[0]); + + UIInteractions.simulatePointerEvent('pointermove', header, 330, 75); + await wait(50); + + // step 3 - verify columnMoving is fired correctly + expect(fixture.componentInstance.count).toBeGreaterThan(1); + expect(fixture.componentInstance.source).toEqual(grid.columns[0]); + + UIInteractions.simulatePointerEvent('pointerup', header, 330, 75); + await wait(); + fixture.detectChanges(); + + // step 4 - verify columnMovingEnd is fired correctly + expect(fixture.componentInstance.countEnd).toEqual(1); + expect(fixture.componentInstance.source).toEqual(grid.columns[1]); + expect(fixture.componentInstance.target).toEqual(grid.columns[0]); + expect(fixture.componentInstance.cancel).toBe(false); + })); + + it('Should be able to cancel columnMoving event.', (async () => { + const headers: DebugElement[] = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + + // step 1 - try moving a column + const header = headers[0].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 150, 65); + await wait(); + + UIInteractions.simulatePointerEvent('pointermove', header, 156, 71); + await wait(); + + if (fixture.componentInstance.source.field === 'ID') { + fixture.componentInstance.cancel = true; + } + + UIInteractions.simulatePointerEvent('pointermove', header, 330, 75); + await wait(50); + UIInteractions.simulatePointerEvent('pointerup', header, 330, 75); + await wait(); + fixture.detectChanges(); + + // step 2 - verify the event was canceled + const columnsList = grid.columns; + expect(columnsList[0].field).toEqual('ID'); + expect(columnsList[1].field).toEqual('Name'); + expect(columnsList[2].field).toEqual('LastName'); + })); + + it('Should be able to cancel columnMovingEnd event.', (async () => { + const headers: DebugElement[] = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + + // step 1 - subscribe to the columnMovingEnd event in order to cancel it + grid.columnMovingEnd.subscribe((e) => { + if (fixture.componentInstance.target.field === 'Name') { + e.cancel = true; + } + }); + + // step 2 - try moving a column + const header = headers[0].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 150, 65); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 156, 71); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 330, 75); + await wait(50); + UIInteractions.simulatePointerEvent('pointerup', header, 330, 75); + await wait(50); + fixture.detectChanges(); + + // step 3 - verify the event was canceled(in componentInstance) + const columnsList = grid.columns; + expect(columnsList[0].field).toEqual('ID'); + expect(columnsList[1].field).toEqual('Name'); + expect(columnsList[2].field).toEqual('LastName'); + })); + + it('Should preserve filtering after columns are reordered.', async () => { + pending('This scenario need to be reworked with new Filtering row'); + fixture.componentInstance.isFilterable = true; + fixture.detectChanges(); + + const headers: DebugElement[] = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + + headers[0].triggerEventHandler('click', new Event('click')); + fixture.detectChanges(); + + // step 1 - filter a column + const filterUIContainer = fixture.debugElement.query(By.css('igx-grid-filter')); + const filterIcon = filterUIContainer.query(By.css('igx-icon')); + + filterIcon.nativeElement.click(); + await wait(); + fixture.detectChanges(); + + const select = filterUIContainer.query(By.css('select')); + const options = select.nativeElement.options; + options[4].selected = true; + select.nativeElement.dispatchEvent(new Event('change')); + fixture.detectChanges(); + + const input = filterUIContainer.query(By.directive(IgxInputDirective)); + input.nativeElement.value = '2'; + input.nativeElement.dispatchEvent(new Event('input')); + fixture.detectChanges(); + + filterUIContainer.queryAll(By.css('button'))[1].nativeElement.click(); + fixture.detectChanges(); + + // step 2 - move a column and verify column remains filtered + const header = headers[0].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 130, 65); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 130, 71); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 300, 71); + await wait(50); + UIInteractions.simulatePointerEvent('pointerup', header, 300, 71); + await wait(); + fixture.detectChanges(); + + expect(grid.columns[1].field).toEqual('ID'); + expect(grid.rowList.length).toEqual(1); + }); + + it('Should exit edit mode and discard the new value when column moving starts.', (async () => { + fixture.componentInstance.isEditable = true; + fixture.detectChanges(); + + const headers: DebugElement[] = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + + // step 1 - enter edit mode on a cell + const cell = fixture.debugElement.queryAll(By.css(CELL_CSS_CLASS))[0]; + const cacheValue = grid.getCellByColumn(0, 'ID').value; + cell.nativeElement.dispatchEvent(new Event('focus')); + fixture.detectChanges(); + + cell.triggerEventHandler('dblclick', {}); + fixture.detectChanges(); + expect(grid.getCellByColumn(0, 'ID').editMode).toBe(true); + + // step 2 - enter some new value + const editTemplate = cell.query(By.css('input')); + editTemplate.nativeElement.value = '4'; + editTemplate.nativeElement.dispatchEvent(new Event('input')); + fixture.detectChanges(); + + // step 3 - move a column + const header = headers[1].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 250, 65); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 244, 71); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 100, 71); + await wait(); + UIInteractions.simulatePointerEvent('pointerup', header, 100, 71); + await wait(); + fixture.detectChanges(); + + // step 4 - verify cell has exited edit mode correctly + expect(grid.columns[1].field).toEqual('ID'); + expect(grid.getCellByColumn(0, 'ID').editMode).toBe(false); + expect(grid.getCellByColumn(0, 'ID').value).toBe(cacheValue); + })); + + it('Should preserve hidden columns order after columns are reordered.', (async () => { + const headers: DebugElement[] = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + + // step 1 - hide a column + fixture.componentInstance.isHidden = true; + fixture.detectChanges(); + fixture.detectChanges(); + + // step 2 - move a column + const header = headers[2].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 400, 65); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 400, 71); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 80, 71); + await wait(); + UIInteractions.simulatePointerEvent('pointerup', header, 80, 71); + await wait(); + fixture.detectChanges(); + + expect(grid.visibleColumns[0].field).toEqual('Region'); + + // step 3 - show hidden columns and verify correct order + fixture.componentInstance.isHidden = false; + fixture.detectChanges(); + fixture.detectChanges(); + + expect(grid.visibleColumns[0].field).toEqual('ID'); + expect(grid.visibleColumns[1].field).toEqual('Region'); + })); + + it('Should be able to reorder columns when a column is grouped.', (async () => { + fixture.componentInstance.isGroupable = true; + fixture.detectChanges(); + + // step 1 - group a column + grid.groupBy({ fieldName: 'ID', dir: SortingDirection.Desc, ignoreCase: false }); + fixture.detectChanges(); + + // step 2 - move a column + const header = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS))[0].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 180, 120); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 180, 126); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 350, 135); + await wait(); + UIInteractions.simulatePointerEvent('pointerup', header, 350, 135); + await wait(); + fixture.detectChanges(); + + const columnsList = grid.columns; + expect(columnsList[0].field).toEqual('Name'); + expect(columnsList[1].field).toEqual('ID'); + })); + + it('Should not break KB after columns are reordered - selection belongs to the moved column.', (async () => { + const headers: DebugElement[] = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + + // step 1 - select a cell from 'ID' column + const cell = grid.gridAPI.get_cell_by_index(0, 'ID'); + UIInteractions.simulateClickAndSelectEvent(cell); + fixture.detectChanges(); + + // step 2 - move that column + const header = headers[0].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 150, 65); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 156, 71); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 330, 75); + await wait(); + UIInteractions.simulatePointerEvent('pointerup', header, 330, 75); + await wait(); + fixture.detectChanges(); + + const columnsList = grid.columns; + expect(columnsList[0].field).toEqual('Name'); + expect(columnsList[1].field).toEqual('ID'); + expect(columnsList[2].field).toEqual('LastName'); + + // step 3 - navigate right and verify cell selection is updated + const gridContent = GridFunctions.getGridContent(fixture); + GridFunctions.focusFirstCell(fixture, grid); + UIInteractions.triggerKeyDownEvtUponElem('arrowright', gridContent.nativeElement, true); + await wait(50); + fixture.detectChanges(); + + expect(grid.getCellByColumn(0, 'ID').selected).toBeTruthy(); + })); + + it('Should not break KB after columns are reordered - selection does not belong to the moved column.', (async () => { + const headers: DebugElement[] = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + + // step 1 - select a cell from 'ID' column + const cell = fixture.debugElement.queryAll(By.css(CELL_CSS_CLASS))[0]; + UIInteractions.simulateClickAndSelectEvent(cell); + fixture.detectChanges(); + + // step 2 - move that column + const header = headers[0].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 150, 65); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 156, 71); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 480, 75); + await wait(); + UIInteractions.simulatePointerEvent('pointerup', header, 480, 75); + await wait(); + fixture.detectChanges(); + + const columnsList = grid.columns; + expect(columnsList[0].field).toEqual('Name'); + expect(columnsList[1].field).toEqual('LastName'); + expect(columnsList[2].field).toEqual('ID'); + + // step 3 - navigate and verify cell selection is updated + const gridContent = GridFunctions.getGridContent(fixture); + GridFunctions.focusFirstCell(fixture, grid); + UIInteractions.triggerKeyDownEvtUponElem('arrowright', gridContent.nativeElement, true); + await wait(50); + fixture.detectChanges(); + + expect(grid.getCellByColumn(0, 'LastName').selected).toBeTruthy(); + })); + + }); + + describe('', () => { + beforeEach(() => { + fixture = TestBed.createComponent(MovableTemplatedColumnsComponent); + grid = fixture.componentInstance.grid; + fixture.detectChanges(); + }); + + it('Should reorder movable columns with templated headers.', (async () => { + fixture.componentInstance.isResizable = true; + fixture.componentInstance.isSortable = true; + fixture.componentInstance.isFilterable = true; + fixture.detectChanges(); + + const headers: DebugElement[] = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + + // step 1 - move a column + const header = headers[0].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 50, 50); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 56, 56); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 230, 30); + await wait(); + UIInteractions.simulatePointerEvent('pointerup', header, 230, 30); + await wait(); + fixture.detectChanges(); + + // step 2 - verify column are reordered correctly + const columnsList = grid.columns; + expect(columnsList[0].field).toEqual('Name'); + expect(columnsList[1].field).toEqual('ID'); + expect(columnsList[2].field).toEqual('LastName'); + })); + }); + + describe('', () => { + beforeEach(() => { + fixture = TestBed.createComponent(MovableColumnsLargeComponent); + grid = fixture.componentInstance.grid; + fixture.detectChanges(); + }); + + it('Should be able to scroll forwards to reorder columns that are out of view.', (async () => { + + // step 1 - start moving a column and verify columns are scrolled into view, + // when holding the drag ghost over the right edge of the grid + const header = grid.headerCellList[0].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 50, 50); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 56, 56); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 485, 30); + await wait(1000); + fixture.detectChanges(); + + // step 2 - verify the column being moved can be reordered among new columns + UIInteractions.simulatePointerEvent('pointermove', header, 450, 30); + await wait(); + UIInteractions.simulatePointerEvent('pointerup', header, 450, 30); + await wait(); + fixture.detectChanges(); + + const list = grid.columns; + expect(list[0].field).toEqual('CompanyName'); + expect(list[4].field).toEqual('ID'); + })); + + it('Should be able to scroll backwards to reorder columns that are out of view.', (async () => { + + // step 1 - scroll left to the end + grid.headerContainer.getScroll().scrollLeft = 1000; + await wait(30); + fixture.detectChanges(); + + // step 2 - start moving a column and verify columns are scrolled into view, + // when holding the drag ghost over the left edge of the grid + const header = grid.headerCellList[4].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 350, 50); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 356, 56); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 10, 30); + await wait(500); + fixture.detectChanges(); + + // step 3 - verify the column being moved can be reordered among new columns + UIInteractions.simulatePointerEvent('pointermove', header, 130, 30); + await wait(); + UIInteractions.simulatePointerEvent('pointerup', header, 130, 30); + await wait(); + fixture.detectChanges(); + + const list = grid.columns; + expect(list[0].field).toEqual('ID'); + expect(list[7].field).toEqual('Region'); + })); + + it('Should be able to scroll/reorder columns that are out of view - with pinned columns.', (async () => { + + grid.getColumnByName('ID').pinned = true; + fixture.detectChanges(); + + // step 1 - scroll left to the end + grid.headerContainer.getScroll().scrollLeft = 1000; + await wait(30); + fixture.detectChanges(); + + // step 2 - start moving a column and verify columns are scrolled into view, + // when holding the drag ghost before pinned area edge + const header = grid.headerCellList[5].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 450, 50); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 456, 56); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 110, 30); + await wait(1000); + fixture.detectChanges(); + + // step 4 - verify the column being moved can be reordered among new columns + UIInteractions.simulatePointerEvent('pointermove', header, 200, 30); + await wait(); + UIInteractions.simulatePointerEvent('pointerup', header, 200, 30); + await wait(); + fixture.detectChanges(); + + const list = grid.columns; + expect(list[0].field).toEqual('ID'); + expect(list[2].field).toEqual('Fax'); + })); + + it('Should preserve cell selection after columns are reordered.', (async () => { + let headers: DebugElement[] = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + + // step 1 - select a cell from the 'ID' column + const cell = grid.gridAPI.get_cell_by_index(0, 'ID'); + cell.activate(null); + fixture.detectChanges(); + expect(cell.selected).toBeTruthy(); + + // step 2 - move that column and verify selection is preserved + let header = headers[0].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 70, 50); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 64, 56); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 280, 25); + await wait(50); + UIInteractions.simulatePointerEvent('pointerup', header, 280, 25); + await wait(); + fixture.detectChanges(); + + let columnsList = grid.columns; + expect(columnsList[0].field).toEqual('CompanyName'); + expect(columnsList[1].field).toEqual('ContactName'); + expect(columnsList[2].field).toEqual('ID'); + expect(grid.getCellByColumn(0, 'CompanyName').selected).toBeTruthy(); + + // step 3 - move another column and verify selection is preserved + headers = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + header = headers[1].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 150, 50); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 150, 56); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 40, 25); + await wait(); + UIInteractions.simulatePointerEvent('pointerup', header, 40, 25); + await wait(); + fixture.detectChanges(); + + columnsList = grid.columns; + expect(columnsList[0].field).toEqual('ContactName'); + expect(columnsList[1].field).toEqual('CompanyName'); + expect(columnsList[2].field).toEqual('ID'); + expect(grid.getCellByColumn(0, 'ContactName').selected).toBeTruthy(); + })); + + it('Should preserve cell selection after columns are reordered programatically.', (async () => { + let columnsList = grid.columns; + + // step 1 - select a cell from the 'ID' column + const cell = grid.gridAPI.get_cell_by_index(0, 'ID'); + cell.activate(null); + fixture.detectChanges(); + expect(cell.selected).toBeTruthy(); + + // step 2 - move that column and verify selection is preserved + const column = columnsList[0] as IgxColumnComponent; + column.move(2); + await wait(); + fixture.detectChanges(); + + columnsList = grid.columns; + expect(columnsList[0].field).toEqual('CompanyName'); + expect(columnsList[1].field).toEqual('ContactName'); + expect(columnsList[2].field).toEqual('ID'); + expect(grid.getCellByColumn(0, 'CompanyName').selected).toBeTruthy(); + })); + + it('Should preserve cell selection after columns are reordered - horizontal scrolling.', (async () => { + const headers: DebugElement[] = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + + // step 1 - select a visible cell from the 'ID' column + const cell = grid.gridAPI.get_cell_by_index(0, 'ID'); + UIInteractions.simulateClickAndSelectEvent(cell); + fixture.detectChanges(); + expect(cell.selected).toBeTruthy(); + + GridSelectionFunctions.verifySelectedRange(grid, 0, 0, 0, 0); + + // step 2 - reorder that column among columns that are currently out of view + // and verify selection is preserved + const header = headers[0].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 50, 50); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 56, 56); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 490, 30); + await wait(1000); + fixture.detectChanges(); + + UIInteractions.simulatePointerEvent('pointermove', header, 350, 30); + await wait(); + UIInteractions.simulatePointerEvent('pointerup', header, 350, 30); + await wait(); + fixture.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(grid, 0, 0, 0, 0); + })); + + it('Should preserve cell selection after columns are reordered - vertical scrolling.', (async () => { + // step 1 - scroll left to the end + grid.headerContainer.getScroll().scrollLeft = 1000; + await wait(50); + fixture.detectChanges(); + + // step 2 - scroll down vertically and select a cell that was initially out of view + grid.verticalScrollContainer.getScroll().scrollTop = 1200; + await wait(100); + fixture.detectChanges(); + + const cell = grid.gridAPI.get_cell_by_index(25, 'Phone'); + const selectedData = [{ Phone: '40.32.21.21'}]; + UIInteractions.simulateClickAndSelectEvent(cell); + fixture.detectChanges(); + + expect(cell.selected).toBeTruthy(); + GridSelectionFunctions.verifySelectedRange(grid, 25, 25, 9, 9); + expect(grid.getSelectedData()).toEqual(selectedData); + + // step 3 - scroll up vertically so that the selected cell becomes out of view + grid.verticalScrollContainer.getScroll().scrollTop = 0; + await wait(50); + fixture.detectChanges(); + + // step 4 - reorder that "Phone" column + const header = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS))[4].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 350, 50); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 356, 56); + await wait(100); + UIInteractions.simulatePointerEvent('pointermove', header, 10, 30); + await wait(100); + fixture.detectChanges(); + + UIInteractions.simulatePointerEvent('pointermove', header, 40, 30); + await wait(50); + UIInteractions.simulatePointerEvent('pointerup', header, 40, 30); + await wait(50); + fixture.detectChanges(); + + // step 5 - verify selection is preserved + grid.verticalScrollContainer.getScroll().scrollTop = 1200; + await wait(100); + fixture.detectChanges(); + + const newSelectedData = [{Country: 'France'}]; + GridSelectionFunctions.verifySelectedRange(grid, 25, 25, 9, 9); + expect(grid.getSelectedData()).toEqual(newSelectedData); + })); + + it('Should affect all pages when columns are reordered programatically and paging is enabled.', (async () => { + fixture.componentInstance.paging = true; + fixture.detectChanges(); + + let columnsList = grid.columns; + + // step 1 - move a column + const column = columnsList[0] as IgxColumnComponent; + column.move(2); + await wait(); + fixture.detectChanges(); + + // step 2 - navigate to page 2 and verify correct column order + grid.paginator.paginate(1); + fixture.detectChanges(); + + columnsList = grid.columns; + expect(columnsList[2].field).toEqual('ID'); + expect(columnsList[3].field).toEqual('ContactTitle'); + expect(columnsList[4].field).toEqual('Address'); + })); + + it('Should affect all pages when columns are reordered and paging is enabled.', (async () => { + fixture.componentInstance.paging = true; + fixture.detectChanges(); + + const headers: DebugElement[] = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + + // step 1 - move a column + const header = headers[0].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 50, 25); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 56, 31); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 420, 31); + await wait(50); + UIInteractions.simulatePointerEvent('pointerup', header, 420, 31); + await wait(); + fixture.detectChanges(); + + // step 2 - navigate to page 2 and verify correct column order + grid.paginator.paginate(1); + fixture.detectChanges(); + + const columnsList = grid.columns; + expect(columnsList[2].field).toEqual('ContactTitle'); + expect(columnsList[3].field).toEqual('ID'); + expect(columnsList[4].field).toEqual('Address'); + })); + + it('Should preserve sorting after columns are reordered.', (async () => { + const headers: DebugElement[] = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + + // step 1 - sort the 'ID' column + headers[0].triggerEventHandler('click', new Event('click')); + fixture.detectChanges(); + + // step 2 - move that column + const header = headers[0].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 50, 25); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 50, 31); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 420, 31); + await wait(50); + UIInteractions.simulatePointerEvent('pointerup', header, 420, 31); + await wait(); + fixture.detectChanges(); + + // step 3 - verify column remains sorted + expect(grid.columns[3].field).toEqual('ID'); + expect(grid.getCellByColumn(0, 'ID').value).toEqual('ALFKI'); + })); + + it('Should preserve sorting after columns are reordered programatically.', (async () => { + const columnsList = grid.columns; + const headers: DebugElement[] = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + + // step 1 - sort the 'ID' column + headers[0].triggerEventHandler('click', new Event('click')); + fixture.detectChanges(); + + // step 2 - move that column + const column = columnsList[0] as IgxColumnComponent; + column.move(3); + await wait(); + fixture.detectChanges(); + fixture.detectChanges(); + + // step 3 - verify column remains sorted + expect(grid.columns[3].field).toEqual('ID'); + expect(grid.getCellByColumn(0, 'ID').value).toEqual('ALFKI'); + })); + + it('Pinning - should be able to reorder pinned columns among themselves.', (async () => { + + // step 1 - pin some columns + grid.getColumnByName('Address').pinned = true; + grid.getColumnByName('ID').pinned = true; + grid.getColumnByName('ContactTitle').pinned = true; + fixture.detectChanges(); + + // step 2 - move a pinned column + const header = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS))[0].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 50, 25); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 50, 31); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 280, 31); + await wait(50); + UIInteractions.simulatePointerEvent('pointerup', header, 280, 31); + await wait(50); + fixture.detectChanges(); + + // step 3 - verify pinned columns are reordered correctly + expect(grid.pinnedColumns[0].field).toEqual('ID'); + expect(grid.pinnedColumns[1].field).toEqual('ContactTitle'); + expect(grid.pinnedColumns[2].field).toEqual('Address'); + })); + + it('Pinning - should be able to programatically reorder pinned columns among themselves.', (async () => { + // step 1 - pin some columns + grid.getColumnByName('Address').pinned = true; + grid.getColumnByName('ID').pinned = true; + grid.getColumnByName('ContactTitle').pinned = true; + fixture.detectChanges(); + + // step 2 - move a pinned column + const column = grid.getColumnByName('ID'); + column.move(2); + await wait(); + fixture.detectChanges(); + + // step 3 - verify pinned columns are reordered correctly + expect(grid.pinnedColumns[0].field).toEqual('Address'); + expect(grid.pinnedColumns[1].field).toEqual('ContactTitle'); + expect(grid.pinnedColumns[2].field).toEqual('ID'); + })); + + it('Pinning - should pin an unpinned column when drag/drop it among pinned columns.', (async () => { + + // step 1 - pin some columns + grid.getColumnByName('Address').pinned = true; + grid.getColumnByName('ID').pinned = true; + fixture.detectChanges(); + + // step 2 - drag/drop an unpinned column among pinned columns + const header = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS))[3].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 350, 25); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 350, 31); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 130, 31); + await wait(50); + UIInteractions.simulatePointerEvent('pointerup', header, 130, 31); + await wait(); + fixture.detectChanges(); + + // step 3 - verify column is pinned at the correct place + expect(grid.pinnedColumns[0].field).toEqual('Address'); + expect(grid.pinnedColumns[1].field).toEqual('ContactName'); + expect(grid.pinnedColumns[2].field).toEqual('ID'); + expect(grid.getColumnByName('ContactName').pinned).toBeTruthy(); + })); + + it('Pinning - should unpin a pinned column when drag/drop it among unpinned columns.', (async () => { + + // step 1 - pin some columns + grid.getColumnByName('Address').pinned = true; + grid.getColumnByName('ID').pinned = true; + grid.getColumnByName('ContactTitle').pinned = true; + fixture.detectChanges(); + + // step 2 - drag/drop a pinned column among unpinned columns + const header = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS))[1].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 150, 25); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 150, 31); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 330, 31); + await wait(50); + UIInteractions.simulatePointerEvent('pointerup', header, 330, 31); + await wait(); + fixture.detectChanges(); + + // step 3 - verify column is unpinned at the correct place + expect(grid.pinnedColumns[0].field).toEqual('Address'); + expect(grid.pinnedColumns[1].field).toEqual('ContactTitle'); + expect(grid.unpinnedColumns[0].field).toEqual('ID'); + expect(grid.getColumnByName('ID').pinned).toBeFalsy(); + })); + + it('Pinning - should not be able to pin a column if pinned area exceeds maximum allowed width.', (async () => { + + // step 1 - pin some columns + grid.getColumnByName('Address').pinned = true; + grid.getColumnByName('ID').pinned = true; + grid.getColumnByName('ContactTitle').pinned = true; + fixture.detectChanges(); + + // step 2 - try drag/drop an unpinned column among pinned columns + const header = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS))[1].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 450, 25); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 450, 31); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 180, 31); + await wait(50); + UIInteractions.simulatePointerEvent('pointerup', header, 180, 31); + await wait(); + fixture.detectChanges(); + + // step 3 - verify column cannot be pinned + expect(grid.pinnedColumns.length).toEqual(3); + expect(grid.unpinnedColumns[0].field).toEqual('CompanyName'); + expect(grid.getColumnByName('CompanyName').pinned).toBeFalsy(); + })); + + it('Pinning - Should be able to pin/unpin columns programmatically', (async () => { + const columnsList = grid.columns; + + // step 1 - pin some columns + grid.getColumnByName('ID').pinned = true; + grid.getColumnByName('CompanyName').pinned = true; + fixture.detectChanges(); + + // step 2 - pin a column interactively via drag/drop + // step 2 - move that column and verify selection is preserved + const column = columnsList[4] as IgxColumnComponent; + column.move(2); + await wait(); + fixture.detectChanges(); + + // step 3 - unpin that column programmatically and verify correct order + grid.getColumnByName('ID').unpin(); + fixture.detectChanges(); + + expect(grid.getColumnByName('ID').pinned).toBeFalsy(); + expect(grid.unpinnedColumns[0].field).toEqual('ID'); + })); + + it('Pinning - Should be able to pin/unpin columns both: programmatically and interactively via drag/drop.', (async () => { + + // step 1 - pin some columns + grid.getColumnByName('ID').pinned = true; + grid.getColumnByName('CompanyName').pinned = true; + fixture.detectChanges(); + + // step 2 - pin a column interactively via drag/drop + const header = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS))[4].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 450, 25); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 450, 31); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 80, 31); + await wait(50); + UIInteractions.simulatePointerEvent('pointerup', header, 80, 31); + await wait(); + fixture.detectChanges(); + + // step 3 - unpin that column programmatically and verify correct order + grid.getColumnByName('ID').unpin(); + fixture.detectChanges(); + + expect(grid.getColumnByName('ID').pinned).toBeFalsy(); + expect(grid.unpinnedColumns[0].field).toEqual('ID'); + })); + + it('Pinning - Should not be able to pin a column if disablePinning is enabled for that column', (async () => { + // step 1 - pin some columns + grid.getColumnByName('Address').pinned = true; + grid.getColumnByName('ID').pinned = true; + grid.getColumnByName('ContactName').disablePinning = true; + fixture.detectChanges(); + + // step 2 - drag/drop an unpinned column among pinned columns + const header = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS))[3].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 350, 25); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 350, 31); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 130, 31); + await wait(50); + UIInteractions.simulatePointerEvent('pointerup', header, 130, 31); + await wait(); + fixture.detectChanges(); + + // step 3 - verify column is still unpinned + expect(grid.pinnedColumns.length).toEqual(2); + expect(grid.pinnedColumns[0].field).toEqual('Address'); + expect(grid.pinnedColumns[1].field).toEqual('ID'); + expect(grid.unpinnedColumns[0].field).toEqual('CompanyName'); + expect(grid.unpinnedColumns[1].field).toEqual('ContactName'); + expect(grid.getColumnByName('ContactName').pinned).toBeFalsy(); + })); + + it('Pinning - Should not be able to pin a column programmaticaly if disablePinning is enabled for that column', (async () => { + + // step 1 - pin some columns + grid.getColumnByName('Address').pinned = true; + grid.getColumnByName('ID').pinned = true; + grid.getColumnByName('ContactName').disablePinning = true; + fixture.detectChanges(); + + // step 2 - drag/drop an unpinned column among pinned columns + // step 2 - move that column and verify selection is preserved + const column = grid.getColumnByName('ContactName') as IgxColumnComponent; + column.move(1); + await wait(); + fixture.detectChanges(); + + // step 3 - verify column is still unpinned + expect(grid.pinnedColumns.length).toEqual(2); + expect(grid.pinnedColumns[0].field).toEqual('Address'); + expect(grid.pinnedColumns[1].field).toEqual('ID'); + expect(grid.unpinnedColumns[0].field).toEqual('CompanyName'); + expect(grid.unpinnedColumns[1].field).toEqual('ContactName'); + expect(grid.getColumnByName('ContactName').pinned).toBeFalsy(); + })); + + it('Pinning - Should not be able to move unpinned column if disablePinning is enabled for all unpinned columns', (async () => { + // step 1 - pin some columns + grid.getColumnByName('Address').pinned = true; + grid.getColumnByName('ContactTitle').pinned = true; + + grid.columns.forEach((column) => { + if (column.field !== 'Address' && column.field !== 'ContactTitle') { + column.disablePinning = true; + } + }); + fixture.detectChanges(); + + // step 2 - drag/drop a pinned column among unpinned columns + const header = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS))[2].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 350, 25); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 350, 31); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 400, 31); + await wait(50); + UIInteractions.simulatePointerEvent('pointerup', header, 400, 31); + await wait(); + fixture.detectChanges(); + + // step 3 - verify column is unpinned at the correct place + expect(grid.pinnedColumns[0].field).toEqual('Address'); + expect(grid.pinnedColumns[1].field).toEqual('ContactTitle'); + expect(grid.unpinnedColumns[0].field).toEqual('CompanyName'); + expect(grid.unpinnedColumns[1].field).toEqual('ID'); + expect(grid.getColumnByName('ID').pinned).toBeFalsy(); + })); + + + it('Pinning - Should not be able to programmatically move unpinned column if disablePinning is enabled for all unpinned columns', (async () => { + // step 1 - pin some columns + grid.getColumnByName('Address').pinned = true; + grid.getColumnByName('ContactTitle').pinned = true; + fixture.detectChanges(); + + expect(grid.pinnedColumns[0].field).toEqual('Address'); + expect(grid.pinnedColumns[1].field).toEqual('ContactTitle'); + expect(grid.unpinnedColumns[0].field).toEqual('ID'); + expect(grid.unpinnedColumns[1].field).toEqual('CompanyName'); + + grid.columns.forEach((col) => { + if (col.field !== 'Address' && col.field !== 'ContactTitle') { + col.disablePinning = true; + } + }); + fixture.detectChanges(); + + // step 2 - drag/drop a pinned column among unpinned columns + const column = grid.getColumnByName('ID') as IgxColumnComponent; + column.move(1); + await wait(); + fixture.detectChanges(); + + // step 3 - verify column is unpinned at the correct place + expect(grid.pinnedColumns[0].field).toEqual('Address'); + expect(grid.pinnedColumns[1].field).toEqual('ContactTitle'); + expect(grid.unpinnedColumns[0].field).toEqual('ID'); + expect(grid.unpinnedColumns[1].field).toEqual('CompanyName'); + expect(grid.getColumnByName('ID').pinned).toBeFalsy(); + })); + }); + + describe('', () => { + beforeEach(() => { + fixture = TestBed.createComponent(MultiColumnHeadersComponent); + grid = fixture.componentInstance.grid; + grid.moving = true; + fixture.detectChanges(); + }); + + it('MCH - should reorder only columns on the same level (top level simple column).', (async () => { + + // step 1 - try reordering simple column level 0 and simple column level 1 + const header = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS))[0].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 50, 75); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 50, 81); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 200, 81); + await wait(); + UIInteractions.simulatePointerEvent('pointerup', header, 200, 81); + await wait(); + fixture.detectChanges(); + + let columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[0].field).toEqual('Missing'); + expect(columnsList[1].field).toEqual('CompanyName'); + + // step 2 - try reordering simple column level 0 and group column level 1 + UIInteractions.simulatePointerEvent('pointerdown', header, 50, 75); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 50, 81); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 380, 81); + await wait(); + UIInteractions.simulatePointerEvent('pointerup', header, 380, 81); + await wait(); + fixture.detectChanges(); + + columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[0].field).toEqual('Missing'); + expect(columnsList[1].field).toEqual('CompanyName'); + + // step 3 - try reordering simple column level 0 and group column level 0 + UIInteractions.simulatePointerEvent('pointerdown', header, 50, 75); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 50, 81); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 380, 25); + await wait(); + UIInteractions.simulatePointerEvent('pointerup', header, 380, 25); + await wait(); + fixture.detectChanges(); + + columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[0].field).toEqual('CompanyName'); + expect(columnsList[3].field).toEqual('Missing'); + })); + + it('MCH - should programmatically reorder columns', (async () => { + // step 1 - move level 0 column to first position + let column = grid.getColumnByName('ID'); + column.move(0); + await wait(); + fixture.detectChanges(); + + let columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[0].field).toEqual('ID'); + expect(columnsList[1].field).toEqual('Missing'); + expect(columnsList[2].field).toEqual('CompanyName'); + + // step 2 - try moving level 0 column into column group // not possible + column.move(3); + await wait(); + fixture.detectChanges(); + + columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[0].field).toEqual('ID'); + expect(columnsList[1].field).toEqual('Missing'); + expect(columnsList[2].field).toEqual('CompanyName'); + expect(columnsList[3].field).toEqual('ContactName'); + + // step 3 - try moving level 0 column into column group // not possible + column.move(5); + await wait(); + fixture.detectChanges(); + + columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[0].field).toEqual('ID'); + expect(columnsList[1].field).toEqual('Missing'); + expect(columnsList[2].field).toEqual('CompanyName'); + expect(columnsList[3].field).toEqual('ContactName'); + + // step 4 - try moving level 0 column between two column groups + column.move(4); + await wait(); + fixture.detectChanges(); + + columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[0].field).toEqual('Missing'); + expect(columnsList[1].field).toEqual('CompanyName'); + expect(columnsList[2].field).toEqual('ContactName'); + expect(columnsList[3].field).toEqual('ContactTitle'); + expect(columnsList[4].field).toEqual('ID'); + + // step 5 - move level 0 column to last position + column.move(8); + await wait(); + fixture.detectChanges(); + + columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[0].field).toEqual('Missing'); + expect(columnsList[1].field).toEqual('CompanyName'); + expect(columnsList[2].field).toEqual('ContactName'); + expect(columnsList[8].field).toEqual('ID'); + + // step 6 - move last column between two column groups + column.move(4); + await wait(); + fixture.detectChanges(); + + columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[0].field).toEqual('Missing'); + expect(columnsList[1].field).toEqual('CompanyName'); + expect(columnsList[2].field).toEqual('ContactName'); + expect(columnsList[3].field).toEqual('ContactTitle'); + expect(columnsList[4].field).toEqual('ID'); + + + // step 7 - move level 1 column in the group + column = grid.getColumnByName('Address'); + column.move(5); + await wait(); + fixture.detectChanges(); + + columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[4].field).toEqual('ID'); + expect(columnsList[5].field).toEqual('Address'); + expect(columnsList[6].field).toEqual('Country'); + + // step 8 - move level 1 column outside the group // not possible + column.move(4); + await wait(); + fixture.detectChanges(); + + columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[4].field).toEqual('ID'); + expect(columnsList[5].field).toEqual('Address'); + expect(columnsList[6].field).toEqual('Country'); + + // step 9 - move level 2 column outsuide the group + column = grid.getColumnByName('ContactName'); + column.move(0); + await wait(); + fixture.detectChanges(); + + columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[0].field).toEqual('Missing'); + expect(columnsList[1].field).toEqual('CompanyName'); + expect(columnsList[2].field).toEqual('ContactName'); + expect(columnsList[3].field).toEqual('ContactTitle'); + + // step 10 - move level 2 column inside the group + column = grid.getColumnByName('ContactTitle'); + column.move(2); + await wait(); + fixture.detectChanges(); + + columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[0].field).toEqual('Missing'); + expect(columnsList[1].field).toEqual('CompanyName'); + expect(columnsList[2].field).toEqual('ContactTitle'); + expect(columnsList[3].field).toEqual('ContactName'); + + // step 11 - move level 2 column inside the group + column = grid.getColumnByName('Missing'); + column.move(8); + await wait(); + fixture.detectChanges(); + + columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[0].field).toEqual('CompanyName'); + expect(columnsList[1].field).toEqual('ContactTitle'); + expect(columnsList[2].field).toEqual('ContactName'); + expect(columnsList[7].field).toEqual('City'); + expect(columnsList[8].field).toEqual('Missing'); + + column = grid.getColumnByName('CompanyName'); + column.move(1); + await wait(); + fixture.detectChanges(); + + expect(columnsList[0].field).toEqual('CompanyName'); + expect(columnsList[1].field).toEqual('ContactTitle'); + expect(columnsList[2].field).toEqual('ContactName'); + + column.move(2); + await wait(); + fixture.detectChanges(); + + columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[0].field).toEqual('ContactTitle'); + expect(columnsList[1].field).toEqual('ContactName'); + expect(columnsList[2].field).toEqual('CompanyName'); + })); + + it('MCH - should not move group column to last position', (async () => { + let column: ColumnType = grid.getColumnByName('Missing'); + column.move(3); + await wait(); + fixture.detectChanges(); + + let columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[0].field).toEqual('CompanyName'); + expect(columnsList[1].field).toEqual('ContactName'); + expect(columnsList[3].field).toEqual('Missing'); + + column = grid.getColumnByName('CompanyName').topLevelParent; + column.move(8); + await wait(); + fixture.detectChanges(); + + columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[0].field).toEqual('CompanyName'); + expect(columnsList[1].field).toEqual('ContactName'); + expect(columnsList[3].field).toEqual('Missing'); + })); + + it('MCH - should be able to move group column to position lastIndex - group.children.length', (async () => { + let column: ColumnType = grid.getColumnByName('Missing'); + column.move(3); + await wait(); + fixture.detectChanges(); + + let columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[0].field).toEqual('CompanyName'); + expect(columnsList[1].field).toEqual('ContactName'); + expect(columnsList[3].field).toEqual('Missing'); + + column = grid.getColumnByName('CompanyName').topLevelParent; + column.move(6); + await wait(); + fixture.detectChanges(); + + columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[6].field).toEqual('CompanyName'); + expect(columnsList[7].field).toEqual('ContactName'); + expect(columnsList[8].field).toEqual('ContactTitle'); + })); + + it('MCH - trying to move level 1 column to last position should be impossible', (async () => { + + let columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[0].field).toEqual('Missing'); + expect(columnsList[1].field).toEqual('CompanyName'); + expect(columnsList[2].field).toEqual('ContactName'); + expect(columnsList[8].field).toEqual('Address'); + + // step 1 - move level 0 column to first position + const column = grid.getColumnByName('CompanyName'); + column.move(8); + await wait(); + fixture.detectChanges(); + + columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[0].field).toEqual('Missing'); + expect(columnsList[1].field).toEqual('CompanyName'); + expect(columnsList[2].field).toEqual('ContactName'); + expect(columnsList[8].field).toEqual('Address'); + })); + + it('MCH - should reorder only columns on the same level (top level group column).', (async () => { + + // step 1 - try reordering group column level 0 and simple column level 1 + let header = fixture.debugElement.queryAll(By.css(COLUMN_GROUP_HEADER_CLASS))[0].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 250, 25); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 250, 31); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 650, 75); + await wait(); + UIInteractions.simulatePointerEvent('pointerup', header, 650, 75); + await wait(); + fixture.detectChanges(); + + let columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[0].field).toEqual('Missing'); + expect(columnsList[1].field).toEqual('CompanyName'); + expect(columnsList[4].field).toEqual('ID'); + expect(columnsList[5].field).toEqual('Country'); + + // step 2 - try reordering group column level 0 and simple column level 0 + UIInteractions.simulatePointerEvent('pointerdown', header, 250, 25); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 250, 31); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 570, 81); + await wait(50); + UIInteractions.simulatePointerEvent('pointerup', header, 570, 81); + await wait(); + fixture.detectChanges(); + + columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[0].field).toEqual('Missing'); + expect(columnsList[1].field).toEqual('ID'); + expect(columnsList[2].field).toEqual('CompanyName'); + + // step 3 - try reordering group column level 0 and group column level 0 + header = fixture.debugElement.queryAll(By.css(COLUMN_GROUP_HEADER_CLASS))[2].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 700, 25); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 700, 31); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 200, 31); + await wait(); + UIInteractions.simulatePointerEvent('pointerup', header, 200, 31); + await wait(); + fixture.detectChanges(); + + columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[0].field).toEqual('Missing'); + expect(columnsList[1].field).toEqual('ID'); + expect(columnsList[2].field).toEqual('Country'); + expect(columnsList[3].field).toEqual('Region'); + })); + + it('MCH - should reorder only columns on the same level (sub level simple column).', (async () => { + + // step 1 - try reordering simple column level 1 and simple column level 0 + const header = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS))[1].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 150, 100); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 150, 106); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 40, 106); + await wait(); + UIInteractions.simulatePointerEvent('pointerup', header, 40, 106); + await wait(); + fixture.detectChanges(); + + let columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[0].field).toEqual('Missing'); + expect(columnsList[1].field).toEqual('CompanyName'); + expect(columnsList[4].field).toEqual('ID'); + + // step 2 - try reordering simple column level 1 and simple column level 2 + UIInteractions.simulatePointerEvent('pointerdown', header, 150, 100); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 150, 106); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 300, 125); + await wait(); + UIInteractions.simulatePointerEvent('pointerup', header, 300, 125); + await wait(); + fixture.detectChanges(); + + columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[0].field).toEqual('Missing'); + expect(columnsList[1].field).toEqual('CompanyName'); + expect(columnsList[4].field).toEqual('ID'); + + // step 3 - try reordering simple column level 1 and group column level 0 + UIInteractions.simulatePointerEvent('pointerdown', header, 150, 100); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 150, 106); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 700, 30); + await wait(); + UIInteractions.simulatePointerEvent('pointerup', header, 700, 30); + + fixture.detectChanges(); + + columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[0].field).toEqual('Missing'); + expect(columnsList[1].field).toEqual('CompanyName'); + expect(columnsList[4].field).toEqual('ID'); + + // step 4 - try reordering simple column level 1 and group column level 1 + UIInteractions.simulatePointerEvent('pointerdown', header, 150, 100); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 150, 106); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 430, 75); + await wait(); + UIInteractions.simulatePointerEvent('pointerup', header, 430, 75); + await wait(); + fixture.detectChanges(); + + columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[0].field).toEqual('Missing'); + expect(columnsList[1].field).toEqual('ContactName'); + expect(columnsList[3].field).toEqual('CompanyName'); + })); + + it('MCH - should reorder only columns on the same level (sub level group column).', (async () => { + + // step 1 - try reordering group column level 1 and simple column level 0 + const header = fixture.debugElement.queryAll(By.css(COLUMN_GROUP_HEADER_CLASS))[1].nativeElement; + + UIInteractions.simulatePointerEvent('pointerdown', header, 300, 75); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 300, 81); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 40, 81); + await wait(); + UIInteractions.simulatePointerEvent('pointerup', header, 40, 81); + await wait(); + fixture.detectChanges(); + + let columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[0].field).toEqual('Missing'); + expect(columnsList[1].field).toEqual('CompanyName'); + expect(columnsList[2].field).toEqual('ContactName'); + + // step 2 - try reordering group column level 1 and group column level 0 + UIInteractions.simulatePointerEvent('pointerdown', header, 300, 75); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 300, 81); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 800, 25); + await wait(); + UIInteractions.simulatePointerEvent('pointerup', header, 800, 25); + await wait(); + fixture.detectChanges(); + + columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[0].field).toEqual('Missing'); + expect(columnsList[1].field).toEqual('CompanyName'); + expect(columnsList[2].field).toEqual('ContactName'); + + // step 3 - try reordering group column level 1 and simple column level 1 + UIInteractions.simulatePointerEvent('pointerdown', header, 300, 75); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 300, 81); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 130, 81); + await wait(); + UIInteractions.simulatePointerEvent('pointerup', header, 130, 81); + await wait(); + fixture.detectChanges(); + + columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[0].field).toEqual('Missing'); + expect(columnsList[1].field).toEqual('ContactName'); + expect(columnsList[3].field).toEqual('CompanyName'); + })); + + it('MCH - should reorder only columns on the same level, with same parent.', (async () => { + + // step 1 - try reordering simple column level 1 and simple column level 1 (different parent) + let header = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS))[1].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 150, 75); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 150, 81); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 660, 100); + await wait(); + UIInteractions.simulatePointerEvent('pointerup', header, 600, 100); + await wait(); + fixture.detectChanges(); + + let columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[1].field).toEqual('CompanyName'); + expect(columnsList[5].field).toEqual('Country'); + + // step 2 - try reordering simple column level 2 and simple column level 2 (same parent) + header = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS))[3].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 400, 125); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 400, 131); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 260, 131); + await wait(); + UIInteractions.simulatePointerEvent('pointerup', header, 260, 131); + await wait(); + fixture.detectChanges(); + + columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[2].field).toEqual('ContactTitle'); + expect(columnsList[3].field).toEqual('ContactName'); + + // step 3 - try reordering simple column level 0 and simple column level 0 (no parent) + header = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS))[0].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 50, 75); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 50, 81); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 580, 81); + await wait(); + UIInteractions.simulatePointerEvent('pointerup', header, 580, 81); + await wait(); + fixture.detectChanges(); + + columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[0].field).toEqual('CompanyName'); + expect(columnsList[3].field).toEqual('ID'); + expect(columnsList[4].field).toEqual('Missing'); + })); + + it('MCH - should not break selection and keyboard navigation when reordering columns.', async () => { + + // step 1 - select a cell from 'ContactName' column + const cell = grid.gridAPI.get_cell_by_index(0, 'ContactName'); + UIInteractions.simulateClickAndSelectEvent(cell); + fixture.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(grid, 0, 0, 2, 2); + + // step 2 - reorder the parent column and verify selection is preserved + const header = fixture.debugElement.queryAll(By.css(COLUMN_GROUP_HEADER_CLASS))[0].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 300, 25); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 300, 31); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 580, 50); + await wait(); + UIInteractions.simulatePointerEvent('pointerup', header, 580, 50); + await wait(); + fixture.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(grid, 0, 0, 2, 2); + expect(grid.getSelectedData()).toEqual([{CompanyName: 'Alfreds Futterkiste' }]); + + // step 3 - navigate right and verify cell selection is updated + const cellEl = fixture.debugElement.queryAll(By.css(CELL_CSS_CLASS))[2]; + UIInteractions.simulateClickAndSelectEvent(cellEl); + UIInteractions.triggerKeyDownEvtUponElem('arrowright', cellEl.nativeElement, true); + await wait(50); + fixture.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(grid, 0, 0, 3, 3); + expect(grid.getSelectedData()).toEqual([{ContactName: 'Maria Anders' }]); + }); + + it('MCH - should pin only top level columns.', (async () => { + fixture.componentInstance.isPinned = true; + await wait(); + fixture.detectChanges(); + + // step 2 - try pinning a sub level simple column + let header = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS))[1].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 150, 75); + await wait(); + fixture.detectChanges(); + UIInteractions.simulatePointerEvent('pointermove', header, 150, 81); + await wait(); + fixture.detectChanges(); + UIInteractions.simulatePointerEvent('pointermove', header, 30, 50); + await wait(); + fixture.detectChanges(); + UIInteractions.simulatePointerEvent('pointerup', header, 30, 50); + await wait(); + fixture.detectChanges(); + + let columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[0].field).toEqual('Missing'); + expect(columnsList[1].field).toEqual('CompanyName'); + + // step 3 - try pinning a top level group column + header = fixture.debugElement.queryAll(By.css(COLUMN_GROUP_HEADER_CLASS))[0].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 150, 25); + await wait(); + fixture.detectChanges(); + UIInteractions.simulatePointerEvent('pointermove', header, 150, 31); + await wait(30); + fixture.detectChanges(); + UIInteractions.simulatePointerEvent('pointermove', header, 40, 50); + await wait(30); + fixture.detectChanges(); + UIInteractions.simulatePointerEvent('pointerup', header, 40, 50); + await wait(30); + fixture.detectChanges(); + + columnsList = grid.columns.filter((col) => !(col instanceof IgxColumnGroupComponent)); + expect(columnsList[0].field).toEqual('CompanyName'); + expect(columnsList[3].field).toEqual('Missing'); + })); + }); +}); diff --git a/projects/igniteui-angular/grids/grid/src/column-pinning.spec.ts b/projects/igniteui-angular/grids/grid/src/column-pinning.spec.ts new file mode 100644 index 00000000000..f2b3ede7e1f --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/column-pinning.spec.ts @@ -0,0 +1,372 @@ + +import { DebugElement } from '@angular/core'; +import { TestBed, waitForAsync, ComponentFixture } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxGridComponent } from './grid.component'; +import { + ColumnPinningTestComponent, + ColumnGroupsPinningTestComponent, + ColumnPinningWithTemplateTestComponent +} from '../../../test-utils/grid-base-components.spec'; +import { GridFunctions } from '../../../test-utils/grid-functions.spec'; +import { ControlsFunction } from '../../../test-utils/controls-functions.spec'; +import { wait } from '../../../test-utils/ui-interactions.spec'; +import { IgxColumnActionsComponent } from 'igniteui-angular/grids/core'; + +describe('Column Pinning UI #grid', () => { + let fix: ComponentFixture; + let grid: IgxGridComponent; + let columnChooser: IgxColumnActionsComponent; + let columnChooserElement: DebugElement; + + const verifyCheckbox = ControlsFunction.verifyCheckbox; + const verifyColumnIsPinned = GridFunctions.verifyColumnIsPinned; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + ColumnPinningTestComponent, + ColumnGroupsPinningTestComponent, + ColumnPinningWithTemplateTestComponent + ] + }).compileComponents(); + })); + + describe('Base', () => { + beforeEach(() => { + fix = TestBed.createComponent(ColumnPinningTestComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + columnChooser = fix.componentInstance.chooser; + columnChooserElement = GridFunctions.getColumnPinningElement(fix); + }); + + it('title is initially empty.', () => { + const title = GridFunctions.getColumnChooserTitle(columnChooserElement); + expect(title).toBe(null); + }); + + it('title can be successfully changed.', () => { + columnChooser.title = 'Pin/Unpin Columns'; + fix.detectChanges(); + + const titleElement = GridFunctions.getColumnChooserTitle(columnChooserElement).nativeElement as HTMLHeadingElement; + expect(columnChooser.title).toBe('Pin/Unpin Columns'); + expect(titleElement.textContent).toBe('Pin/Unpin Columns'); + + columnChooser.title = undefined; + fix.detectChanges(); + + expect(GridFunctions.getColumnChooserTitle(columnChooserElement)).toBeNull(); + + columnChooser.title = null; + fix.detectChanges(); + + expect(GridFunctions.getColumnChooserTitle(columnChooserElement)).toBeNull(); + }); + + it(`filter input visibility is controlled via 'hideFilter' property.`, () => { + let filterInputElement = GridFunctions.getColumnHidingHeaderInput(columnChooserElement); + expect(filterInputElement).not.toBeNull(); + + fix.componentInstance.hideFilter = true; + fix.detectChanges(); + + filterInputElement = GridFunctions.getColumnHidingHeaderInput(columnChooserElement); + expect(filterInputElement).toBeNull(); + + fix.componentInstance.hideFilter = false; + fix.detectChanges(); + + filterInputElement = GridFunctions.getColumnHidingHeaderInput(columnChooserElement); + expect(filterInputElement).not.toBeNull(); + }); + + it('shows all checkboxes unchecked.', () => { + const checkboxes = GridFunctions.getColumnChooserItems(columnChooserElement); + expect(checkboxes.filter((chk) => !chk.nativeElement.checked).length).toBe(5); + }); + + it(`- toggling column checkbox checked state successfully changes the column's pinned state.`, () => { + const checkbox = GridFunctions.getColumnChooserItemElement(columnChooserElement, 'ReleaseDate'); + verifyCheckbox('ReleaseDate', false, false, columnChooserElement); + + const column = grid.getColumnByName('ReleaseDate'); + verifyColumnIsPinned(column, false, 0); + + GridFunctions.clickColumnChooserItem(columnChooserElement, 'ReleaseDate'); + fix.detectChanges(); + + expect(GridFunctions.getColumnChooserItemInput(checkbox).checked).toBe(true); + verifyColumnIsPinned(column, true, 1); + + GridFunctions.clickColumnChooserItem(columnChooserElement, 'ReleaseDate'); + fix.detectChanges(); + + expect(GridFunctions.getColumnChooserItemInput(checkbox).checked).toBe(false); + verifyColumnIsPinned(column, false, 0); + }); + + it('reflects properly grid column pinned value changes.', () => { + const name = 'ReleaseDate'; + verifyCheckbox(name, false, false, columnChooserElement); + const column = grid.getColumnByName(name); + + column.pinned = true; + fix.detectChanges(); + + verifyCheckbox(name, true, false, columnChooserElement); + verifyColumnIsPinned(column, true, 1); + + column.pinned = false; + fix.detectChanges(); + + verifyCheckbox(name, false, false, columnChooserElement); + verifyColumnIsPinned(column, false, 0); + + column.pinned = undefined; + fix.detectChanges(); + + verifyCheckbox(name, false, false, columnChooserElement); + verifyColumnIsPinned(column, false, 0); + + column.pinned = true; + fix.detectChanges(); + verifyColumnIsPinned(column, true, 1); + + column.pinned = null; + fix.detectChanges(); + + verifyCheckbox(name, false, false, columnChooserElement); + verifyColumnIsPinned(column, false, 0); + }); + + it('columnPin event is fired on toggling checkboxes.', waitForAsync(() => { + spyOn(grid.columnPin, 'emit').and.callThrough(); + spyOn(grid.columnPinned, 'emit').and.callThrough(); + + GridFunctions.clickColumnChooserItem(columnChooserElement, 'ReleaseDate'); + fix.detectChanges(); + + expect(grid.columnPin.emit).toHaveBeenCalledTimes(1); + expect(grid.columnPin.emit).toHaveBeenCalledWith( + { column: grid.getColumnByName('ReleaseDate'), insertAtIndex: 0, isPinned: false, cancel: false }); + + expect(grid.columnPinned.emit).toHaveBeenCalledTimes(1); + expect(grid.columnPinned.emit).toHaveBeenCalledWith( + { column: grid.getColumnByName('ReleaseDate'), insertAtIndex: 0, isPinned: true }); + + GridFunctions.clickColumnChooserItem(columnChooserElement, 'Downloads'); + fix.detectChanges(); + + expect(grid.columnPin.emit).toHaveBeenCalledTimes(2); + expect(grid.columnPin.emit).toHaveBeenCalledWith( + { column: grid.getColumnByName('Downloads'), insertAtIndex: 1, isPinned: false, cancel: false }); + + expect(grid.columnPinned.emit).toHaveBeenCalledTimes(2); + expect(grid.columnPinned.emit).toHaveBeenCalledWith( + { column: grid.getColumnByName('Downloads'), insertAtIndex: 1, isPinned: true }); + + GridFunctions.clickColumnChooserItem(columnChooserElement, 'ReleaseDate'); + fix.detectChanges(); + + // When unpinning columns columnPin event should be fired + expect(grid.columnPin.emit).toHaveBeenCalledTimes(3); + expect(grid.columnPin.emit).toHaveBeenCalledWith( + { column: grid.getColumnByName('ReleaseDate'), insertAtIndex: 3, isPinned: true, cancel: false }); + + expect(grid.columnPinned.emit).toHaveBeenCalledTimes(3); + expect(grid.columnPinned.emit).toHaveBeenCalledWith( + { column: grid.getColumnByName('ReleaseDate'), insertAtIndex: 3, isPinned: false }); + + + GridFunctions.clickColumnChooserItem(columnChooserElement, 'Downloads'); + fix.detectChanges(); + + expect(grid.columnPin.emit).toHaveBeenCalledTimes(4); + expect(grid.columnPin.emit).toHaveBeenCalledWith( + { column: grid.getColumnByName('Downloads'), insertAtIndex: 2, isPinned: true, cancel: false }); + + expect(grid.columnPinned.emit).toHaveBeenCalledTimes(4); + expect(grid.columnPinned.emit).toHaveBeenCalledWith( + { column: grid.getColumnByName('Downloads'), insertAtIndex: 2, isPinned: false }); + + GridFunctions.clickColumnChooserItem(columnChooserElement, 'ProductName'); + fix.detectChanges(); + + expect(grid.columnPin.emit).toHaveBeenCalledTimes(5); + expect(grid.columnPin.emit).toHaveBeenCalledWith( + { column: grid.getColumnByName('ProductName'), insertAtIndex: 0, isPinned: false, cancel: false }); + + expect(grid.columnPinned.emit).toHaveBeenCalledTimes(5); + expect(grid.columnPinned.emit).toHaveBeenCalledWith( + { column: grid.getColumnByName('ProductName'), insertAtIndex: 0, isPinned: true }); + })); + + it('columnPin event should fire when pinning and unpining using api', waitForAsync(() => { + spyOn(grid.columnPin, 'emit').and.callThrough(); + spyOn(grid.columnPinned, 'emit').and.callThrough(); + + grid.columnList.get(0).pin(); + + expect(grid.columnPin.emit).toHaveBeenCalledTimes(1); + expect(grid.columnPin.emit).toHaveBeenCalledWith( + { column: grid.getColumnByName('ID'), insertAtIndex: 0, isPinned: false, cancel: false }); + + expect(grid.columnPinned.emit).toHaveBeenCalledTimes(1); + expect(grid.columnPinned.emit).toHaveBeenCalledWith( + { column: grid.getColumnByName('ID'), insertAtIndex: 0, isPinned: true }); + + // columnPin should not be fired if column is already pinned + grid.columnList.get(0).pin(); + + expect(grid.columnPin.emit).toHaveBeenCalledTimes(1); + expect(grid.columnPinned.emit).toHaveBeenCalledTimes(1); + + grid.columnList.get(0).unpin(); + + expect(grid.columnPin.emit).toHaveBeenCalledTimes(2); + expect(grid.columnPin.emit).toHaveBeenCalledWith( + { column: grid.getColumnByName('ID'), insertAtIndex: 0, isPinned: true, cancel: false }); + + expect(grid.columnPinned.emit).toHaveBeenCalledTimes(2); + expect(grid.columnPinned.emit).toHaveBeenCalledWith( + { column: grid.getColumnByName('ID'), insertAtIndex: 0, isPinned: false }); + })); + + it('does pin columns if unpinned area width will become less than the defined minimum.', () => { + GridFunctions.clickColumnChooserItem(columnChooserElement, 'ID'); + GridFunctions.clickColumnChooserItem(columnChooserElement, 'ProductName'); + GridFunctions.clickColumnChooserItem(columnChooserElement, 'Downloads'); + fix.detectChanges(); + + verifyColumnIsPinned(grid.columnList.get(0), true, 3); + verifyColumnIsPinned(grid.columnList.get(1), true, 3); + verifyColumnIsPinned(grid.columnList.get(2), true, 3); + }); + + it('toolbar should contain only pinnable columns', async () => { + // Toolbar rendering tick + await wait(); + fix.detectChanges(); + + const pinningUIButton = GridFunctions.getColumnPinningButton(fix); + pinningUIButton.click(); + fix.detectChanges(); + + expect(GridFunctions.getOverlay(fix).querySelectorAll('igx-checkbox').length).toEqual(5); + + grid.columnList.get(0).disablePinning = true; + fix.detectChanges(); + + pinningUIButton.click(); + fix.detectChanges(); + + expect(GridFunctions.getOverlay(fix).querySelectorAll('igx-checkbox').length).toEqual(4); + }); + + it('Checks order of columns after unpinning', () => { + for (const column of grid.columnList) { + column.pin(); + } + fix.detectChanges(); + grid.getColumnByName('ID').unpin(); + grid.getColumnByName('ReleaseDate').unpin(); + grid.getColumnByName('Downloads').unpin(); + grid.getColumnByName('ProductName').unpin(); + grid.getColumnByName('Released').unpin(); + fix.detectChanges(); + grid.unpinnedColumns.forEach((column, index) => { + if (index === grid.unpinnedColumns.length - 1) { + return; + } + expect( + column.index < grid.unpinnedColumns[index + 1].index + ).toBe(true); + }); + }); + + it('Checks order of columns after unpinning if there are hidden columns', () => { + // Columns are ordered like this: ID, ProductName, Downloads, Released, ReleaseDate + expect(grid.getColumnByName('Downloads').index).toBe(2); + expect(grid.getColumnByName('Released').index).toBe(3); + + grid.getColumnByName('ID').hidden = true; + grid.getColumnByName('Downloads').pin(); + grid.getColumnByName('Released').pin(); + fix.detectChanges(); + + // unpinnedColumns contains only visible cols + expect(grid.unpinnedColumns.length).toBe(2); + // _unpinnedColumns contains all unpinned cols (including hidden) + expect((grid as any)._unpinnedColumns.length).toBe(3); + + grid.getColumnByName('Released').unpin(); + fix.detectChanges(); + + expect(grid.unpinnedColumns.length).toBe(3); + expect((grid as any)._unpinnedColumns.length).toBe(4); + // Downloads is still pinned; ID is not part of unpinnedColumns + expect(grid.getColumnByName('Released').field).toEqual((grid as any).unpinnedColumns[1].field); + expect(grid.getColumnByName('Released').field).toEqual((grid as any)._unpinnedColumns[2].field); + + grid.getColumnByName('Downloads').unpin(); + fix.detectChanges(); + expect(grid.getColumnByName('Downloads').field).toEqual((grid as any).unpinnedColumns[1].field); + expect(grid.getColumnByName('Downloads').field).toEqual((grid as any)._unpinnedColumns[2].field); + }); + }); + + describe('Pinning with Column Groups', () => { + beforeEach(() => { + fix = TestBed.createComponent(ColumnGroupsPinningTestComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + columnChooser = fix.componentInstance.chooser; + columnChooserElement = GridFunctions.getColumnPinningElement(fix); + }); + + it('shows only top level columns.', () => { + const columnNames = GridFunctions.getColumnActionsColumnList(columnChooserElement); + expect(columnNames).toEqual(['Missing', 'General Information', 'ID']); + }); + + it('- pinning group column pins all children.', () => { + fix.detectChanges(); + const columnName = 'General Information'; + GridFunctions.clickColumnChooserItem(columnChooserElement, 'Missing'); + GridFunctions.clickColumnChooserItem(columnChooserElement, columnName); + fix.detectChanges(); + + verifyCheckbox(columnName, true, false, columnChooserElement); + expect(grid.columnList.get(1).allChildren.every((col) => col.pinned)).toBe(true); + }); + + it('- unpinning group column unpins all children.', () => { + const columnName = 'General Information'; + grid.columnList.get(0).unpin(); + grid.columnList.get(1).pin(); + fix.detectChanges(); + + verifyCheckbox(columnName, true, false, columnChooserElement); + expect(grid.columnList.get(1).allChildren.every((col) => col.pinned)).toBe(true); + + GridFunctions.clickColumnChooserItem(columnChooserElement, columnName); + fix.detectChanges(); + verifyCheckbox(columnName, false, false, columnChooserElement); + expect(grid.columnList.get(1).allChildren.every((col) => !col.pinned)).toBe(true); + }); + }); + + it('- should size cells correctly when there is a large pinned templated column', () => { + fix = TestBed.createComponent(ColumnPinningWithTemplateTestComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + // verify all cells have 100px height + const cells = GridFunctions.getRowCells(fix, 0); + cells.forEach((cell) => { + expect(cell.nativeElement.offsetHeight).toBe(100); + }); + }); +}); diff --git a/projects/igniteui-angular/grids/grid/src/column-resizing.spec.ts b/projects/igniteui-angular/grids/grid/src/column-resizing.spec.ts new file mode 100644 index 00000000000..3b65edeacba --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/column-resizing.spec.ts @@ -0,0 +1,1151 @@ +import { Component, DebugElement, OnInit, ViewChild } from '@angular/core'; +import { TestBed, fakeAsync, tick, ComponentFixture, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxGridComponent } from './grid.component'; +import { UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { GridTemplateStrings, ColumnDefinitions } from '../../../test-utils/template-strings.spec'; +import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; +import { MultiColumnHeadersComponent } from '../../../test-utils/grid-samples.spec'; +import { GridFunctions } from '../../../test-utils/grid-functions.spec'; +import { IColumnResizeEventArgs, IgxCellHeaderTemplateDirective, IgxCellTemplateDirective, IgxColumnComponent, IgxGridToolbarComponent, IgxGridToolbarTitleComponent } from 'igniteui-angular/grids/core'; +import { setElementSize } from '../../../test-utils/helper-utils.spec'; +import { IgxColumnResizerDirective } from 'igniteui-angular/grids/core'; +import { ɵSize } from 'igniteui-angular/core'; +import { IgxAvatarComponent } from 'igniteui-angular/avatar'; +import { Calendar } from 'igniteui-angular/calendar'; + +describe('IgxGrid - Deferred Column Resizing #grid', () => { + + const COLUMN_HEADER_GROUP_CLASS = '.igx-grid-thead__item'; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + MultiColumnHeadersComponent, + NoopAnimationsModule, + ResizableColumnsComponent, + GridFeaturesComponent, + LargePinnedColGridComponent, + NullColumnsComponent, + MinWidthColumnsComponent, + ColGridComponent, + ColPercentageGridComponent + ] + }).compileComponents(); + })); + + describe('Base tests: ', () => { + let fixture: ComponentFixture; + let grid: IgxGridComponent; + let headers: DebugElement[]; + let headerResArea: HTMLElement; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(ResizableColumnsComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + headers = GridFunctions.getColumnHeaders(fixture); + headerResArea = GridFunctions.getHeaderResizeArea(headers[0]).nativeElement; + })); + + it('should define grid with resizable columns.', fakeAsync(() => { + + expect(grid.columnList.get(0).width).toEqual('100px'); + expect(grid.columnList.get(0).resizable).toBeTruthy(); + expect(grid.columnList.get(2).resizable).toBeFalsy(); + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 100, 15); + tick(200); + fixture.detectChanges(); + + let resizer = GridFunctions.getResizer(fixture).nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 250, 15); + UIInteractions.simulateMouseEvent('mouseup', resizer, 250, 15); + fixture.detectChanges(); + + expect(grid.columnList.get(0).width).toEqual('250px'); + + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 250, 0); + tick(200); + fixture.detectChanges(); + resizer = GridFunctions.getResizer(fixture).nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 40, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 40, 5); + fixture.detectChanges(); + + expect(grid.columnList.get(0).width).toEqual('80px'); + + expect(grid.columnList.get(2).cells[0].value).toEqual('Brown'); + + GridFunctions.clickHeaderSortIcon(headers[2]); + GridFunctions.clickHeaderSortIcon(headers[2]); + fixture.detectChanges(); + + expect(grid.columnList.get(2).cells[0].value).toEqual('Wilson'); + })); + + it('should resize column outside grid view.', fakeAsync(() => { + expect(grid.columnList.get(0).width).toEqual('100px'); + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 100, 0); + tick(200); + fixture.detectChanges(); + + const resizer = GridFunctions.getResizer(fixture).nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 700, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 700, 5); + fixture.detectChanges(); + + expect(grid.columnList.get(0).width).toEqual('700px'); + })); + + it('should resize column with preset min and max widths.', fakeAsync(() => { + expect(grid.columnList.get(1).width).toEqual('100px'); + expect(grid.columnList.get(1).minWidth).toEqual('70px'); + expect(grid.columnList.get(1).maxWidth).toEqual('250px'); + expect(grid.columnList.get(1).resizable).toBeTruthy(); + headerResArea = GridFunctions.getHeaderResizeArea(headers[1]).nativeElement; + + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 200, 0); + tick(200); + fixture.detectChanges(); + + let resizer = GridFunctions.getResizer(fixture).nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 370, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 370, 5); + fixture.detectChanges(); + + expect(grid.columnList.get(1).width).toEqual('250px'); + + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 350, 0); + tick(200); + fixture.detectChanges(); + + resizer = GridFunctions.getResizer(fixture).nativeElement; + UIInteractions.simulateMouseEvent('mousemove', resizer, 100, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 100, 5); + fixture.detectChanges(); + + expect(grid.columnList.get(1).width).toEqual('70px'); + })); + + it('should calculate correctly resizer position and column width when grid is scaled and zoomed', fakeAsync(() => { + grid.nativeElement.style.transform = 'scale(1.2)'; + grid.nativeElement.style.setProperty('zoom', '1.05'); + fixture.detectChanges(); + headerResArea = GridFunctions.getHeaderResizeArea(headers[1]).nativeElement; + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 153, 0); + tick(200); + fixture.detectChanges(); + + const resizer = GridFunctions.getResizer(fixture); + const resizerDirective = resizer.componentInstance.resizer as IgxColumnResizerDirective; + const leftSetterSpy = spyOnProperty(resizerDirective, 'left', 'set').and.callThrough(); + UIInteractions.simulateMouseEvent('mousemove', resizer.nativeElement, 200, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer.nativeElement, 200, 5); + fixture.detectChanges(); + + expect(leftSetterSpy).toHaveBeenCalled(); + expect(parseInt(leftSetterSpy.calls.mostRecent().args[0].toFixed(0))).toEqual(200); + expect(parseInt(grid.columnList.get(1).headerCell.nativeElement.getBoundingClientRect().width.toFixed(0))).toEqual(173); + })); + + it('should be able to resize column to the minWidth < defaultMinWidth', fakeAsync(() => { + const column = grid.getColumnByName('ID'); + column.minWidth = 'a'; + fixture.detectChanges(); + + expect(column.resizable).toBe(true); + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 100, 0); + tick(200); + fixture.detectChanges(); + + let resizer = GridFunctions.getResizer(fixture).nativeElement; + UIInteractions.simulateMouseEvent('mousemove', resizer, 10, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 10, 5); + fixture.detectChanges(); + + expect(column.width).toEqual('80px'); + column.minWidth = '50'; + fixture.detectChanges(); + + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 80, 0); + tick(200); + fixture.detectChanges(); + + resizer = GridFunctions.getResizer(fixture).nativeElement; + UIInteractions.simulateMouseEvent('mousemove', resizer, 10, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 10, 5); + fixture.detectChanges(); + + expect(column.width).toEqual('50px'); + })); + + it('should change the defaultMinWidth on grid size change', fakeAsync(() => { + const column = grid.getColumnByName('ID'); + + expect(column.defaultMinWidth).toBe('80'); + expect(column.resizable).toBe(true); + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 100, 0); + tick(200); + fixture.detectChanges(); + + let resizer = GridFunctions.getResizer(fixture).nativeElement; + UIInteractions.simulateMouseEvent('mousemove', resizer, 10, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 10, 5); + fixture.detectChanges(); + + expect(column.width).toEqual('80px'); + setElementSize(grid.nativeElement, ɵSize.Medium) + tick(16); // needed because of the throttleTime of the resize obserer + fixture.detectChanges(); + + expect(column.defaultMinWidth).toBe('64'); + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 80, 0); + tick(200); + fixture.detectChanges(); + + resizer = GridFunctions.getResizer(fixture).nativeElement; + UIInteractions.simulateMouseEvent('mousemove', resizer, 10, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 10, 5); + fixture.detectChanges(); + + expect(column.width).toEqual('64px'); + setElementSize(grid.nativeElement, ɵSize.Small) + tick(16); // needed because of the throttleTime of the resize obserer + fixture.detectChanges(); + + expect(column.defaultMinWidth).toBe('56'); + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 64, 0); + tick(200); + fixture.detectChanges(); + + resizer = GridFunctions.getResizer(fixture).nativeElement; + UIInteractions.simulateMouseEvent('mousemove', resizer, 10, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 10, 5); + fixture.detectChanges(); + + expect(column.width).toEqual('56px'); + })); + + it('should update grid after resizing a column to be bigger.', fakeAsync(() => { + const displayContainer: HTMLElement = GridFunctions.getGridDisplayContainer(fixture).nativeElement; + let rowsRendered = displayContainer.querySelectorAll('igx-display-container'); + let colsRendered = rowsRendered[0].children; + + expect(grid.columnList.get(0).width).toEqual('100px'); + expect(colsRendered.length).toEqual(4); + + // Resize first column + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 100, 0); + tick(200); + fixture.detectChanges(); + + const resizer = GridFunctions.getResizer(fixture).nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 700, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 700, 5); + fixture.detectChanges(); + + expect(grid.columnList.get(0).width).toEqual('700px'); + + // Check grid has updated cells and scrollbar + const hScroll = fixture.componentInstance.grid.headerContainer.getScroll(); + const hScrollVisible = hScroll.offsetWidth < hScroll.children[0].offsetWidth; + rowsRendered = displayContainer.querySelectorAll('igx-display-container'); + colsRendered = rowsRendered[0].children; + + expect(hScrollVisible).toBe(true); + expect(colsRendered.length).toEqual(4); + })); + + it('should recalculate grid heights after resizing so the horizontal scrollbar appears.', fakeAsync(() => { + let expectedHeight = grid.nativeElement.offsetHeight + - grid.theadRow.nativeElement.offsetHeight + - grid.tfoot.nativeElement.offsetHeight + - (grid.isHorizontalScrollHidden ? 0 : grid.scrollSize); + + expect(grid.calcHeight).toEqual(expectedHeight); + expect(grid.columnList.get(0).width).toEqual('100px'); + + // Resize first column + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 100, 0); + tick(200); + fixture.detectChanges(); + + const resizer = GridFunctions.getResizer(fixture).nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 250, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 250, 5); + fixture.detectChanges(); + + expect(grid.columnList.get(0).width).toEqual('250px'); + + // Check grid has updated cells and scrollbar + const hScroll = fixture.componentInstance.grid.headerContainer.getScroll(); + const hScrollVisible = hScroll.offsetWidth < hScroll.children[0].offsetWidth; + + expectedHeight = grid.nativeElement.offsetHeight + - grid.theadRow.nativeElement.offsetHeight + - grid.tfoot.nativeElement.offsetHeight + - (grid.isHorizontalScrollHidden ? 0 : grid.scrollSize); + + expect(grid.calcHeight).toEqual(expectedHeight); + expect(hScrollVisible).toBe(true); + })); + + it('should resize pinned column with preset max width.', fakeAsync(() => { + grid.pinColumn('ID'); + grid.pinColumn('Name'); + grid.getColumnByName('LastName').resizable = true; + grid.pinColumn('LastName'); + fixture.detectChanges(); + + expect(grid.columnList.get(1).width).toEqual('100px'); + headers = GridFunctions.getColumnHeaders(fixture); + headerResArea = GridFunctions.getHeaderResizeArea(headers[1]).nativeElement; + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 200, 0); + tick(200); + fixture.detectChanges(); + + const resizer = GridFunctions.getResizer(fixture).nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 350, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 350, 5); + fixture.detectChanges(); + + expect(grid.columnList.get(1).width).toEqual('250px'); + })); + + it('should resize pinned columns.', fakeAsync(() => { + grid.pinColumn('ID'); + grid.pinColumn('Name'); + grid.getColumnByName('LastName').resizable = true; + fixture.detectChanges(); + + headers = GridFunctions.getColumnHeaders(fixture); + headerResArea = GridFunctions.getHeaderResizeArea(headers[0]).nativeElement; + expect(grid.columnList.get(0).width).toEqual('100px'); + expect(grid.columnList.get(1).width).toEqual('100px'); + + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 100, 0); + tick(200); + fixture.detectChanges(); + + let resizer = GridFunctions.getResizer(fixture).nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 450, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 450, 5); + fixture.detectChanges(); + + expect(grid.columnList.get(0).width).toEqual('450px'); + expect(grid.columnList.get(1).width).toEqual('100px'); + + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 300, 0); + tick(200); + fixture.detectChanges(); + + resizer = GridFunctions.getResizer(fixture).nativeElement; + UIInteractions.simulateMouseEvent('mousemove', resizer, 100, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 100, 5); + fixture.detectChanges(); + + expect(grid.columnList.get(0).width).toEqual('250px'); + })); + }); + + describe('Autoresize tests: ', () => { + let fixture: ComponentFixture; + let grid: IgxGridComponent; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(LargePinnedColGridComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + })); + + it('should autoresize column with preset max width.', fakeAsync(() => { + const headers = GridFunctions.getColumnHeaders(fixture); + const resizeArea = GridFunctions.getHeaderResizeArea(headers[4]).nativeElement; + + expect(grid.columnList.get(4)._cells[0].nativeElement.getBoundingClientRect().width).toEqual(50); + expect(grid.columnList.get(4).maxWidth).toEqual('100px'); + + UIInteractions.simulateMouseEvent('dblclick', resizeArea, 0, 0); + tick(200); + fixture.detectChanges(); + + expect(grid.columnList.get(4).width).toEqual('100px'); + })); + + it('should autoresize pinned column on double click.', fakeAsync(() => { + const headers = GridFunctions.getColumnHeaders(fixture); + const resizeArea = GridFunctions.getHeaderResizeArea(headers[2]).nativeElement; + + expect(grid.columnList.get(2).width).toEqual('100px'); + + UIInteractions.simulateMouseEvent('dblclick', resizeArea, 0, 0); + tick(200); + fixture.detectChanges(); + + expect(grid.columnList.get(2).width).toEqual('92px'); + })); + + it('should autosize column programmatically.', () => { + const column = grid.getColumnByName('ID'); + column.minWidth = '30px'; + expect(column.width).toEqual('100px'); + + column.autosize(); + fixture.detectChanges(); + + expect(column.width).toEqual('67px'); + }); + + it('should autosize column correctly if there is scaling via css.', () => { + grid.nativeElement.style.transform = 'scale(0.6)'; + const column = grid.getColumnByName('Items'); + + column.autosize(); + fixture.detectChanges(); + + expect(column.width).toEqual('92px'); + grid.nativeElement.style.transform = ''; + }); + + it('should autosize column programmatically based only on header.', () => { + const column = fixture.componentInstance.grid.columnList.filter(c => c.field === 'ReleaseDate')[0]; + expect(column.width).toEqual('100px'); + + column.autosize(true); + fixture.detectChanges(); + + expect(column.width).toEqual('112px'); + }); + + it('should autosize pinned column programmatically.', () => { + const column = grid.getColumnByName('Released'); + expect(column.width).toEqual('100px'); + + column.autosize(); + fixture.detectChanges(); + + expect(column.width).toEqual('95px'); + }); + + it('should autosize last pinned column programmatically.', () => { + const column = grid.getColumnByName('Items'); + expect(column.width).toEqual('100px'); + + column.autosize(); + fixture.detectChanges(); + + expect(column.width).toEqual('92px'); + }); + + it('should autosize column when minWidth is set.', () => { + const column = grid.getColumnByName('ID'); + column.minWidth = '70px'; + expect(column.minWidth).toEqual('70px'); + expect(column.width).toEqual('100px'); + + column.autosize(); + fixture.detectChanges(); + + expect(column.width).toEqual('70px'); + }); + }); + + describe('Percentage tests: ', () => { + let fixture: ComponentFixture; + let grid: IgxGridComponent; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(ColPercentageGridComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + })); + + it('should resize columns with % width.', fakeAsync(() => { + grid.height = null; + fixture.detectChanges(); + const headers = GridFunctions.getColumnHeaders(fixture); + expect(grid.columnList.get(0).width).toBe('25%'); + + const headerResArea = headers[0].parent.children[2].nativeElement; + const startPos = headerResArea.getBoundingClientRect().x; + UIInteractions.simulateMouseEvent('mousedown', headerResArea, startPos, 5); + tick(200); + fixture.detectChanges(); + + const resizer = GridFunctions.getResizer(fixture).nativeElement; + expect(resizer).toBeDefined(); + // resize with 100px, which is 25% + UIInteractions.simulateMouseEvent('mousemove', resizer, startPos + 100, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, startPos + 100, 5); + fixture.detectChanges(); + expect(grid.columnList.get(0).width).toBe('50%'); + })); + + it('should resize columns with % width and % maxWidth.', fakeAsync(() => { + grid.height = null; + fixture.detectChanges(); + const headers = GridFunctions.getColumnHeaders(fixture); + grid.columnList.get(0).maxWidth = '30%'; + expect(grid.columnList.get(0).width).toBe('25%'); + + const headerResArea = headers[0].parent.children[2].nativeElement; + const startPos = headerResArea.getBoundingClientRect().x; + UIInteractions.simulateMouseEvent('mousedown', headerResArea, startPos, 5); + tick(200); + fixture.detectChanges(); + + const resizer = GridFunctions.getResizer(fixture).nativeElement; + expect(resizer).toBeDefined(); + // resize with +100px, which is 25% + UIInteractions.simulateMouseEvent('mousemove', resizer, startPos + 100, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, startPos + 100, 5); + fixture.detectChanges(); + + expect(grid.columnList.get(0).width).toBe(grid.columnList.get(0).maxWidth); + })); + + it('should resize columns with % width and % minWidth.', fakeAsync(() => { + grid.height = null; + fixture.detectChanges(); + const headers = GridFunctions.getColumnHeaders(fixture); + grid.columnList.get(0).minWidth = '10%'; + expect(grid.columnList.get(0).width).toBe('25%'); + + const headerResArea = headers[0].parent.children[2].nativeElement; + const startPos = headerResArea.getBoundingClientRect().x; + UIInteractions.simulateMouseEvent('mousedown', headerResArea, startPos, 5); + tick(200); + fixture.detectChanges(); + + const resizer = GridFunctions.getResizer(fixture).nativeElement; + // resize with -100px + UIInteractions.simulateMouseEvent('mousemove', resizer, startPos - 100, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, startPos - 100, 5); + fixture.detectChanges(); + + expect(grid.columnList.get(0).width).toBe(grid.columnList.get(0).minWidth); + })); + + it('should resize columns with % width and pixel maxWidth.', fakeAsync(() => { + grid.height = null; + fixture.detectChanges(); + const headers = GridFunctions.getColumnHeaders(fixture); + grid.columnList.get(0).maxWidth = '200px'; + expect(grid.columnList.get(0).width).toBe('25%'); + + const headerResArea = headers[0].parent.children[2].nativeElement; + const startPos = headerResArea.getBoundingClientRect().x; + UIInteractions.simulateMouseEvent('mousedown', headerResArea, startPos, 5); + tick(200); + fixture.detectChanges(); + + const resizer = GridFunctions.getResizer(fixture).nativeElement; + expect(resizer).toBeDefined(); + // resize with +200px, which is 50% + UIInteractions.simulateMouseEvent('mousemove', resizer, startPos + 200, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, startPos + 200, 5); + fixture.detectChanges(); + expect(grid.columnList.get(0).width).toBe('50%'); + })); + + it('should resize columns with % width and pixel minWidth.', fakeAsync(() => { + grid.height = null; + fixture.detectChanges(); + const headers = GridFunctions.getColumnHeaders(fixture); + // minWidth is 12.5% of the grid width - 400px + grid.columnList.get(0).minWidth = '50px'; + expect(grid.columnList.get(0).width).toBe('25%'); + + const headerResArea = headers[0].parent.children[2].nativeElement; + const startPos = headerResArea.getBoundingClientRect().x; + UIInteractions.simulateMouseEvent('mousedown', headerResArea, startPos, 5); + tick(200); + fixture.detectChanges(); + + const resizer = GridFunctions.getResizer(fixture).nativeElement; + // resize with -100px + UIInteractions.simulateMouseEvent('mousemove', resizer, startPos - 100, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, startPos - 100, 5); + fixture.detectChanges(); + + expect(grid.columnList.get(0).width).toBe('12.5%'); + })); + + it('should autosize column with % width programmatically.', fakeAsync(() => { + grid.height = null; + fixture.detectChanges(); + const col = grid.columnList.get(0); + expect(col.width).toBe('25%'); + col.autosize(); + fixture.detectChanges(); + const calcPercent = (col.getHeaderCellWidths().width + col.getHeaderCellWidths().padding) / grid.calcWidth * 100; + expect(grid.columnList.get(0).width).toBe(calcPercent + '%'); + })); + + it('should autosize column with % width on double click.', fakeAsync(() => { + grid.height = null; + fixture.detectChanges(); + expect(grid.columnList.get(0).width).toBe('25%'); + const headers = GridFunctions.getColumnHeaders(fixture); + const headerResArea = headers[0].parent.children[2].nativeElement; + UIInteractions.simulateMouseEvent('dblclick', headerResArea, 0, 0); + tick(200); + fixture.detectChanges(); + const col = grid.columnList.get(0); + const calcPercent = (col.getHeaderCellWidths().width + col.getHeaderCellWidths().padding) / grid.calcWidth * 100; + expect(col.width).toBe(calcPercent + '%'); + })); + }); + + describe('Integration tests: ', () => { + let fixture: ComponentFixture; + let grid: IgxGridComponent; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(GridFeaturesComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + })); + + it('should resize sortable columns.', fakeAsync(() => { + const headers = GridFunctions.getColumnHeaders(fixture); + const headerResArea = GridFunctions.getHeaderResizeArea(headers[2]).nativeElement; + + expect(grid.columnList.get(2).width).toEqual('150px'); + expect(grid.columnList.get(2).sortable).toBeTruthy(); + expect(grid.columnList.get(2).cells[0].value).toEqual(254); + + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 450, 0); + tick(200); + fixture.detectChanges(); + + const resizer = GridFunctions.getResizer(fixture).nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 550, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 550, 5); + fixture.detectChanges(); + + // column has maxWidth='150px' + expect(grid.columnList.get(1).width).toEqual('150px'); + + GridFunctions.clickHeaderSortIcon(headers[2]); + GridFunctions.clickHeaderSortIcon(headers[2]); + fixture.detectChanges(); + + expect(grid.columnList.get(2).cells[0].value).toEqual(1000); + })); + + it('should autoresize column on double click.', fakeAsync(() => { + const headers = GridFunctions.getColumnHeaders(fixture); + const resizeArea = GridFunctions.getHeaderResizeArea(headers[1]).nativeElement; + + expect(grid.columnList.get(0).width).toEqual('150px'); + expect(grid.columnList.get(1).width).toEqual('150px'); + expect(grid.columnList.get(2).width).toEqual('150px'); + + UIInteractions.simulateMouseEvent('dblclick', resizeArea, 0, 0); + tick(200); + fixture.detectChanges(); + + expect(grid.columnList.get(1).width).toEqual('195px'); + })); + + it('should autoresize templated column on double click.', fakeAsync(() => { + const headers = GridFunctions.getColumnHeaders(fixture); + const resizeArea = GridFunctions.getHeaderResizeArea(headers[5]).nativeElement; + + expect(grid.columnList.get(5).width).toEqual('150px'); + + UIInteractions.simulateMouseEvent('dblclick', resizeArea, 0, 0); + tick(200); + fixture.detectChanges(); + + expect(grid.columnList.get(5).width).toEqual('89px'); + })); + + it('should fire columnResized with correct event args.', fakeAsync(() => { + const resizingSpy = spyOn(grid.columnResized, 'emit').and.callThrough(); + const headers: DebugElement[] = GridFunctions.getColumnHeaders(fixture); + + expect(grid.columnList.get(0).width).toEqual('150px'); + + const headerResArea = GridFunctions.getHeaderResizeArea(headers[0]).nativeElement; + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 150, 5); + tick(200); + fixture.detectChanges(); + + const resizer = GridFunctions.getResizer(fixture).nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 300, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 300, 5); + fixture.detectChanges(); + + let resizingArgs: IColumnResizeEventArgs = { column: grid.columnList.get(0), prevWidth: '150px', newWidth: '300px' }; + expect(grid.columnList.get(0).width).toEqual('300px'); + expect(resizingSpy).toHaveBeenCalledTimes(1); + expect(resizingSpy).toHaveBeenCalledWith(resizingArgs); + + expect(grid.columnList.get(1).width).toEqual('150px'); + + const resizeArea = GridFunctions.getHeaderResizeArea(headers[1]).nativeElement; + UIInteractions.simulateMouseEvent('dblclick', resizeArea, 0, 0); + tick(200); + fixture.detectChanges(); + + expect(grid.columnList.get(1).width).toEqual('195px'); + resizingArgs = { column: grid.columnList.get(1), prevWidth: '150', newWidth: '195px' }; + expect(resizingSpy).toHaveBeenCalledTimes(2); + expect(resizingSpy).toHaveBeenCalledWith(resizingArgs); + })); + + it('should autosize templated column programmatically.', () => { + const column = grid.getColumnByName('Category'); + expect(column.width).toEqual('150px'); + + column.autosize(); + fixture.detectChanges(); + + expect(column.width).toEqual('89px'); + }); + + it('should ignore header template during autosize if autosizeHeader is false.', () => { + const column = grid.getColumnByName('ID'); + column.minWidth = '10px'; + column.autosizeHeader = false; + fixture.detectChanges(); + + expect(column.width).toEqual('150px'); + + column.autosize(); + fixture.detectChanges(); + + expect(column.width).toEqual('55px'); + }); + }); + + describe('Multi Column Headers tests: ', () => { + let fixture: ComponentFixture; + let grid: IgxGridComponent; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(MultiColumnHeadersComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + })); + + it('should autosize filterable/sortable/resizable/movable column programmatically.', () => { + const column = grid.getColumnByName('Missing'); + expect(column.width).toEqual('100px'); + + column.autosize(); + fixture.detectChanges(); + // the exact width is different between chrome and chrome headless so an exact match is erroneous + expect(Math.abs(parseInt(column.width, 10) - 120)).toBeLessThan(2); + }); + + it('should autosize MCHs programmatically.', () => { + let column = grid.getColumnByName('CompanyName'); + expect(column.width).toEqual('130px'); + + column.autosize(); + fixture.detectChanges(); + expect(column.width).toEqual('239px'); + + column = grid.getColumnByName('ContactName'); + expect(column.width).toEqual('100px'); + + column.autosize(); + fixture.detectChanges(); + expect(column.width).toEqual('148px'); + + column = grid.getColumnByName('Region'); + expect(column.width).toEqual('150px'); + + column.autosize(); + fixture.detectChanges(); + expect(column.width).toEqual('85px'); + + column = grid.getColumnByName('Country'); + expect(column.width).toEqual('90px'); + + column.autosize(); + fixture.detectChanges(); + expect(column.width).toEqual('111px'); + }); + }); + + describe('Different columns widths tests: ', () => { + it('should resize columns with initial width of null.', fakeAsync(() => { + const fixture = TestBed.createComponent(NullColumnsComponent); + fixture.detectChanges(); + + const grid = fixture.componentInstance.grid; + const headers: DebugElement[] = GridFunctions.getColumnHeaders(fixture); + + expect(parseInt(grid.columnList.get(0).width, 10)).not.toBeNaN(); + + let headerResArea = GridFunctions.getHeaderResizeArea(headers[0]).nativeElement; + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 126, 5); + tick(200); + fixture.detectChanges(); + + let resizer = GridFunctions.getResizer(fixture).nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 250, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 250, 5); + fixture.detectChanges(); + + expect(grid.columnList.get(0).width).toEqual('200px'); + + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 200, 0); + tick(200); + fixture.detectChanges(); + + resizer = GridFunctions.getResizer(fixture).nativeElement; + UIInteractions.simulateMouseEvent('mousemove', resizer, 50, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 50, 5); + fixture.detectChanges(); + + expect(grid.columnList.get(0).width).toEqual(grid.columnList.get(0).minWidth + 'px'); + + headerResArea = GridFunctions.getHeaderResizeArea(headers[1]).nativeElement; + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 197, 5); + tick(200); + fixture.detectChanges(); + + expect(parseInt(grid.columnList.get(1).width, 10)).not.toBeNaN(); + + resizer = GridFunctions.getResizer(fixture).nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 300, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 300, 5); + fixture.detectChanges(); + + expect(parseInt(grid.columnList.get(1).width, 10)).toBeGreaterThanOrEqual(100); + + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 300, 5); + tick(200); + fixture.detectChanges(); + + resizer = GridFunctions.getResizer(fixture).nativeElement; + UIInteractions.simulateMouseEvent('mousemove', resizer, 50, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 50, 5); + fixture.detectChanges(); + + expect(grid.columnList.get(1).width).toEqual('80px'); + })); + + it('should size headers correctly when column width is below the allowed minimum.', () => { + const fixture = TestBed.createComponent(ColGridComponent); + fixture.detectChanges(); + + const headers = GridFunctions.getColumnHeaders(fixture); + const headerGroups = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_GROUP_CLASS)); + const filteringCells = GridFunctions.getFilteringCells(fixture); + + expect(headers[0].nativeElement.getBoundingClientRect().width).toBe(49); + expect(headers[1].nativeElement.getBoundingClientRect().width).toBe(50); + expect(headers[2].nativeElement.getBoundingClientRect().width).toBe(49); + + expect(filteringCells[0].nativeElement.getBoundingClientRect().width).toBe(49); + expect(filteringCells[1].nativeElement.getBoundingClientRect().width).toBe(50); + expect(filteringCells[2].nativeElement.getBoundingClientRect().width).toBe(49); + + expect(headerGroups[0].nativeElement.getBoundingClientRect().width).toBe(49); + expect(headerGroups[1].nativeElement.getBoundingClientRect().width).toBe(50); + expect(headerGroups[2].nativeElement.getBoundingClientRect().width).toBe(49); + }); + + it('should size headers correctly when column width is in %.', () => { + const fixture = TestBed.createComponent(ColPercentageGridComponent); + fixture.detectChanges(); + const grid = fixture.componentInstance.grid; + grid.ngAfterViewInit(); + + const headers = GridFunctions.getColumnHeaders(fixture); + const headerGroups = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_GROUP_CLASS)); + const filteringCells = GridFunctions.getFilteringCells(fixture); + const expectedWidth = (parseInt(grid.width, 10) - grid.scrollSize) / 4; + expect(headers[0].nativeElement.getBoundingClientRect().width).toBeCloseTo(expectedWidth, 0); + expect(headers[1].nativeElement.getBoundingClientRect().width).toBeCloseTo(expectedWidth, 0); + expect(headers[2].nativeElement.getBoundingClientRect().width).toBeCloseTo(expectedWidth, 0); + expect(headers[3].nativeElement.getBoundingClientRect().width).toBeCloseTo(expectedWidth, 0); + + expect(filteringCells[0].nativeElement.getBoundingClientRect().width).toBeCloseTo(expectedWidth, 0); + expect(filteringCells[1].nativeElement.getBoundingClientRect().width).toBeCloseTo(expectedWidth, 0); + expect(filteringCells[2].nativeElement.getBoundingClientRect().width).toBeCloseTo(expectedWidth, 0); + expect(filteringCells[3].nativeElement.getBoundingClientRect().width).toBeCloseTo(expectedWidth, 0); + + expect(headerGroups[0].nativeElement.getBoundingClientRect().width).toBeCloseTo(expectedWidth, 0); + expect(headerGroups[1].nativeElement.getBoundingClientRect().width).toBeCloseTo(expectedWidth, 0); + expect(headerGroups[2].nativeElement.getBoundingClientRect().width).toBeCloseTo(expectedWidth, 0); + expect(headerGroups[3].nativeElement.getBoundingClientRect().width).toBeCloseTo(expectedWidth, 0); + }); + + it('should render all columns when all have autosize set initially.', fakeAsync(() => { + const fixture = TestBed.createComponent(ColAutosizeGridComponent); + fixture.detectChanges(); + tick(200); + + const headers = GridFunctions.getColumnHeaders(fixture); + const firstRowCells = GridFunctions.getRowCells(fixture, 0); + expect(headers.length).toEqual(11); + 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); + })); + }); +}); + +@Component({ + template: GridTemplateStrings.declareGrid(`width="500px" height="300px"`, ``, ColumnDefinitions.resizableThreeOfFour), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class ResizableColumnsComponent { + @ViewChild(IgxGridComponent, { static: true }) public grid: IgxGridComponent; + + 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, IgxCellTemplateDirective] +}) +export class LargePinnedColGridComponent implements OnInit { + @ViewChild(IgxGridComponent, { static: true }) public grid: IgxGridComponent; + + public timeGenerator: Calendar = new Calendar(); + public today: Date = new Date(new Date().getFullYear(), new Date().getMonth(), new Date().getDate(), 0, 0, 0); + public data = []; + public value: any; + + public ngOnInit() { + this.data = SampleTestData.generateProductData(75); + } + + public returnVal(value) { + return value; + } +} + +@Component({ + template: GridTemplateStrings.declareGrid(``, ``, ColumnDefinitions.gridFeatures), + imports: [IgxGridComponent, IgxColumnComponent, IgxCellTemplateDirective, IgxCellHeaderTemplateDirective, IgxAvatarComponent] +}) +export class GridFeaturesComponent { + @ViewChild(IgxGridComponent, { static: true }) public grid: IgxGridComponent; + + public timeGenerator: Calendar = new Calendar(); + public today: Date = new Date(new Date().getFullYear(), new Date().getMonth(), new Date().getDate(), 0, 0, 0); + + public data = SampleTestData.productInfoDataFull(); +} + +@Component({ + template: GridTemplateStrings.declareGrid(`height="800px"`, ``, ColumnDefinitions.resizableColsComponent), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class NullColumnsComponent implements OnInit { + @ViewChild(IgxGridComponent, { static: true }) public grid: IgxGridComponent; + + public data = []; + public columns = []; + + public ngOnInit(): void { + this.columns = [ + { field: 'ID', resizable: true, maxWidth: 200, minWidth: 70 }, + { field: 'CompanyName', resizable: true }, + { field: 'ContactName', resizable: true }, + { field: 'ContactTitle', resizable: true }, + { field: 'Address', resizable: true }, + { field: 'City', resizable: true }, + { field: 'Region', resizable: true }, + { field: 'PostalCode', resizable: true }, + { field: 'Phone', resizable: true }, + { field: 'Fax', resizable: true } + ]; + + this.data = SampleTestData.contactInfoData(); + } +} + +@Component({ + template: GridTemplateStrings.declareGrid(`width="400px" height="200px"`, ``, ` + + + `), + 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"`, ``, ` + + + + + `), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class ColGridComponent implements OnInit { + @ViewChild(IgxGridComponent, { static: true }) public grid: IgxGridComponent; + + public data = []; + + public ngOnInit() { + this.data = SampleTestData.generateProductData(10); + } +} + +@Component({ + template: GridTemplateStrings.declareGrid(`width="400px" height="600px" [allowFiltering]="true"`, ``, ` + + + `), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class ColPercentageGridComponent implements OnInit { + @ViewChild(IgxGridComponent, { static: true }) public grid: IgxGridComponent; + + public data = []; + + public ngOnInit() { + this.data = SampleTestData.generateProductData(10); + } +} + +@Component({ + template: GridTemplateStrings.declareGrid(`width="1500px" height="600px"`, ``, ` + + + + + + + + + + + `), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class ColAutosizeGridComponent implements OnInit { + @ViewChild(IgxGridComponent, { static: true }) public grid: IgxGridComponent; + + public data = []; + + public ngOnInit() { + this.data = SampleTestData.generateProductData(10); + } +} diff --git a/projects/igniteui-angular/grids/grid/src/column-selection.spec.ts b/projects/igniteui-angular/grids/grid/src/column-selection.spec.ts new file mode 100644 index 00000000000..ca124d9bbda --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/column-selection.spec.ts @@ -0,0 +1,1124 @@ +import { TestBed, ComponentFixture, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { IgxGridComponent } from './grid.component'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { ProductsComponent, ColumnSelectionGroupTestComponent } from '../../../test-utils/grid-samples.spec'; +import { GridSelectionFunctions, GridFunctions } from '../../../test-utils/grid-functions.spec'; +import { IgxColumnComponent } from 'igniteui-angular/grids/core'; +import { IColumnSelectionEventArgs } from 'igniteui-angular/grids/core'; +import { GridSelectionMode } from 'igniteui-angular/grids/core'; +import { IgxStringFilteringOperand } from 'igniteui-angular/core'; + +const SELECTED_COLUMN_CLASS = 'igx-grid-th--selected'; +const SELECTED_COLUMN_CELL_CLASS = 'igx-grid__td--column-selected'; +const SELECTED_FILTER_CELL_CLASS = 'igx-grid__filtering-cell--selected'; + +const selectedData = () => ([ + { ProductID: 1, ProductName: 'Chai' }, + { ProductID: 2, ProductName: 'Aniseed Syrup' }, + { ProductID: 3, ProductName: 'Chef Antons Cajun Seasoning' }, + { ProductID: 4, ProductName: 'Grandmas Boysenberry Spread' }, + { ProductID: 5, ProductName: 'Uncle Bobs Dried Pears' }, + { ProductID: 6, ProductName: 'Northwoods Cranberry Sauce' }, + { ProductID: 7, ProductName: 'Queso Cabrales' }, + { ProductID: 8, ProductName: 'Tofu' }, + { ProductID: 9, ProductName: 'Teatime Chocolate Biscuits' }, + { ProductID: 10, ProductName: 'Chocolate' } +]); + +describe('IgxGrid - Column Selection #grid', () => { + + let fix: ComponentFixture; + let grid: IgxGridComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + ProductsComponent, ColumnSelectionGroupTestComponent, NoopAnimationsModule + ] + }).compileComponents(); + })); + + describe('Base tests: ', () => { + let colProductName: IgxColumnComponent; + let colProductID: IgxColumnComponent; + let colInStock: IgxColumnComponent; + beforeEach(() => { + fix = TestBed.createComponent(ProductsComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + colProductName = grid.getColumnByName('ProductName'); + colProductID = grid.getColumnByName('ProductID'); + colInStock = grid.getColumnByName('InStock'); + grid.columnSelection = GridSelectionMode.multiple; + fix.detectChanges(); + }); + + it('setting selected and selectable properties ', () => { + spyOn(grid.columnSelectionChanging, 'emit').and.callThrough(); + grid.columnList.forEach(column => { + expect(column.selectable).toBeTruthy(); + expect(column.selected).toBeFalsy(); + }); + + let col = grid.getColumnByName('ProductID'); + col.selected = true; + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnAndCellsSelected(col); + + col = grid.getColumnByName('ProductName'); + col.selectable = false; + fix.detectChanges(); + + expect(col.selectable).toBeFalsy(); + + // Verify that when column is not selectable cannot select it. + col.selected = true; + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnAndCellsSelected(col, false); + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledTimes(0); + }); + + it('selecting a column with mouse click', () => { + spyOn(grid.columnSelectionChanging, 'emit').and.callThrough(); + colProductName.selectable = false; + fix.detectChanges(); + + GridFunctions.clickColumnHeaderUI('ProductID', fix); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID); + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledTimes(1); + let args: IColumnSelectionEventArgs = { + oldSelection: [], + newSelection: ['ProductID'], + added: ['ProductID'], + removed: [], + event: jasmine.anything() as any, + cancel: false + }; + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledWith(args); + + GridFunctions.clickColumnHeaderUI('ProductName', fix); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductName, false); + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledTimes(1); + + GridFunctions.clickColumnHeaderUI('InStock', fix); + GridSelectionFunctions.verifyColumnAndCellsSelected(colInStock); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID, false); + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledTimes(2); + args = { + oldSelection: ['ProductID'], + newSelection: ['InStock'], + added: ['InStock'], + removed: ['ProductID'], + event: jasmine.anything() as any, + cancel: false + }; + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledWith(args); + + GridFunctions.clickColumnHeaderUI('InStock', fix); + GridSelectionFunctions.verifyColumnAndCellsSelected(colInStock, false); + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledTimes(3); + args = { + oldSelection: ['InStock'], + newSelection: [], + added: [], + removed: ['InStock'], + event: jasmine.anything() as any, + cancel: false + }; + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledWith(args); + }); + + it('verify selectable class is applied when hover a column', () => { + colProductName.selectable = false; + fix.detectChanges(); + + const productIDHeader = GridFunctions.getColumnHeader('ProductID', fix); + productIDHeader.triggerEventHandler('pointerenter', null); + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnHeaderHasSelectableClass(productIDHeader); + + productIDHeader.triggerEventHandler('pointerleave', null); + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnHeaderHasSelectableClass(productIDHeader, false); + + const productNameHeader = GridFunctions.getColumnHeader('ProductName', fix); + productNameHeader.triggerEventHandler('pointerenter', null); + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnHeaderHasSelectableClass(productNameHeader, false); + + productNameHeader.triggerEventHandler('pointerleave', null); + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnHeaderHasSelectableClass(productIDHeader, false); + }); + + it('verify ARIA support', () => { + colProductName.selected = true; + fix.detectChanges(); + + const productIDHeader = GridFunctions.getColumnHeader('ProductID', fix); + const productNameHeader = GridFunctions.getColumnHeader('ProductName', fix); + + expect(productIDHeader.nativeElement.getAttribute('aria-selected')).toMatch('false'); + expect(productNameHeader.nativeElement.getAttribute('aria-selected')).toMatch('true'); + + colProductName._cells.forEach(cell => { + expect(cell.nativeElement.getAttribute('aria-selected')).toMatch('true'); + }); + colProductID._cells.forEach(cell => { + expect(cell.nativeElement.getAttribute('aria-selected')).toMatch('false'); + }); + }); + + it('verify canceling event columnSelectionChanging', () => { + GridFunctions.clickColumnHeaderUI('ProductID', fix); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID); + + grid.columnSelectionChanging.subscribe((e: IColumnSelectionEventArgs) => { + e.cancel = true; + }); + + // Click on same column to deselect it. + GridFunctions.clickColumnHeaderUI('ProductID', fix); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID); + + // Click on different column + GridFunctions.clickColumnHeaderUI('ProductName', fix); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductName, false); + expect(grid.selectedColumns()).toEqual([colProductID]); + + // Click on different column holding ctrl key + GridFunctions.clickColumnHeaderUI('ProductName', fix, true); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductName, false); + expect(grid.selectedColumns()).toEqual([colProductID]); + + // Click on different column holding shift key + GridFunctions.clickColumnHeaderUI('ProductName', fix, false, true); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductName, false); + expect(grid.selectedColumns()).toEqual([colProductID]); + }); + + it('verify method selectColumns', () => { + spyOn(grid.columnSelectionChanging, 'emit').and.callThrough(); + const colUnits = grid.getColumnByName('UnitsInStock'); + const colOrderDate = grid.getColumnByName('OrderDate'); + // select columns with array of fields + grid.selectColumns(['ProductID', 'InStock']); + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnsSelected([colProductID, colInStock]); + expect(grid.selectedColumns()).toEqual([colProductID, colInStock]); + + // select columns with with clearCurrentSelection false + grid.selectColumns(['UnitsInStock', 'InStock']); + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnsSelected([colProductID, colInStock, colUnits]); + expect(grid.selectedColumns()).toEqual([colProductID, colInStock, colUnits]); + + // select columns with with clearCurrentSelection true + grid.selectColumns(['OrderDate', 'ProductID'], true); + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnsSelected([colProductID, colOrderDate]); + expect(grid.selectedColumns()).toEqual([colOrderDate, colProductID]); + + // select columns with array of columns + grid.selectColumns([colInStock, colProductName]); + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnsSelected([colProductID, colOrderDate, colInStock, colProductName]); + expect(grid.selectedColumns()).toEqual([colOrderDate, colProductID, colInStock, colProductName]); + + grid.selectColumns([colProductID], true); + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnSelected(colProductID); + GridSelectionFunctions.verifyColumnsSelected([colOrderDate, colInStock, colProductName], false); + expect(grid.selectedColumns()).toEqual([colProductID]); + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledTimes(0); + }); + + it('verify method deselectColumns', () => { + spyOn(grid.columnSelectionChanging, 'emit').and.callThrough(); + grid.columns.forEach(col => col.selected = true); + + const colUnits = grid.getColumnByName('UnitsInStock'); + const colOrderDate = grid.getColumnByName('OrderDate'); + + // deselect columns with array of fields + grid.deselectColumns(['ProductID']); + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnSelected(colProductID, false); + GridSelectionFunctions.verifyColumnsSelected([colProductName, colInStock, colUnits, colOrderDate]); + expect(grid.selectedColumns()).toEqual([colProductName, colInStock, colUnits, colOrderDate]); + + // deselect columns with not existing field + grid.deselectColumns(['testField', 'ProductID', 'UnitsInStock']); + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnsSelected([colProductID, colUnits], false); + GridSelectionFunctions.verifyColumnsSelected([colProductName, colInStock, colOrderDate]); + expect(grid.selectedColumns()).toEqual([colProductName, colInStock, colOrderDate]); + + // select columns with array of columns + grid.deselectColumns([colOrderDate, colUnits]); + fix.detectChanges(); + GridSelectionFunctions.verifyColumnsSelected([colProductID, colUnits, colOrderDate], false); + GridSelectionFunctions.verifyColumnsSelected([colProductName, colInStock]); + expect(grid.selectedColumns()).toEqual([colProductName, colInStock]); + }); + + it('verify methods selectAllColumns and deselectAllColumns', () => { + spyOn(grid.columnSelectionChanging, 'emit').and.callThrough(); + // select all columns + grid.selectAllColumns(); + fix.detectChanges(); + + grid.columnList.forEach(c => { + expect(c.selected).toEqual(true); + }); + + // deselect all columns + grid.deselectAllColumns(); + fix.detectChanges(); + GridSelectionFunctions.verifyColumnsSelected(grid.columnList.toArray(), false); + expect(grid.selectedColumns()).toEqual([]); + + // Set selectable false to a column + colProductName.selectable = false; + fix.detectChanges(); + + // select all columns + grid.selectAllColumns(); + fix.detectChanges(); + + grid.columnList.forEach(c => { + expect(c.selected).toEqual(true); + }); + + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledTimes(0); + }); + + it('verify method getSelectedColumnsData', () => { + colProductID.selected = true; + colProductName.selected = true; + fix.detectChanges(); + + expect(grid.getSelectedColumnsData()).toEqual(selectedData()); + }); + + it('verify when columnSelection is none columns cannot be selected', () => { + grid.columnSelection = GridSelectionMode.none; + fix.detectChanges(); + + // Click on a column + GridFunctions.clickColumnHeaderUI('ProductID', fix); + + // verify column is not selected + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID, false); + + // Hover a column + const productNameHeader = GridFunctions.getColumnHeader('ProductName', fix); + productNameHeader.triggerEventHandler('pointerenter', null); + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnHeaderHasSelectableClass(productNameHeader, false); + + productNameHeader.triggerEventHandler('pointerleave', null); + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnHeaderHasSelectableClass(productNameHeader, false); + }); + }); + + describe('Multi selection tests: ', () => { + let colProductName: IgxColumnComponent; + let colProductID: IgxColumnComponent; + let colInStock: IgxColumnComponent; + beforeEach(() => { + fix = TestBed.createComponent(ProductsComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + colProductName = grid.getColumnByName('ProductName'); + colProductID = grid.getColumnByName('ProductID'); + colInStock = grid.getColumnByName('InStock'); + grid.columnSelection = GridSelectionMode.multiple; + fix.detectChanges(); + }); + + it('selecting a column with ctrl + mouse click', () => { + spyOn(grid.columnSelectionChanging, 'emit').and.callThrough(); + colProductName.selectable = false; + fix.detectChanges(); + + GridSelectionFunctions.clickOnColumnToSelect(colProductID, true); + grid.cdr.detectChanges(); + + GridSelectionFunctions.verifyColumnSelected(colProductID); + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledTimes(1); + let args: IColumnSelectionEventArgs = { + oldSelection: [], + newSelection: ['ProductID'], + added: ['ProductID'], + removed: [], + event: jasmine.anything() as any, + cancel: false + }; + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledWith(args); + + GridSelectionFunctions.clickOnColumnToSelect(colInStock, true); + grid.cdr.detectChanges(); + + GridSelectionFunctions.verifyColumnSelected(colProductID); + GridSelectionFunctions.verifyColumnSelected(colInStock); + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledTimes(2); + args = { + oldSelection: ['ProductID'], + newSelection: ['ProductID', 'InStock'], + added: ['InStock'], + removed: [], + event: jasmine.anything() as any, + cancel: false + }; + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledWith(args); + + GridSelectionFunctions.clickOnColumnToSelect(colProductName, true); + grid.cdr.detectChanges(); + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledTimes(2); + + const colOrderDate = grid.getColumnByName('OrderDate'); + GridSelectionFunctions.clickOnColumnToSelect(colOrderDate, true); + grid.cdr.detectChanges(); + + GridSelectionFunctions.verifyColumnSelected(colProductID); + GridSelectionFunctions.verifyColumnSelected(colInStock); + GridSelectionFunctions.verifyColumnSelected(colOrderDate); + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledTimes(3); + args = { + oldSelection: ['ProductID', 'InStock'], + newSelection: ['ProductID', 'InStock', 'OrderDate'], + added: ['OrderDate'], + removed: [], + event: jasmine.anything() as any, + cancel: false + }; + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledWith(args); + + GridSelectionFunctions.clickOnColumnToSelect(colInStock, true); + grid.cdr.detectChanges(); + + GridSelectionFunctions.verifyColumnSelected(colProductID); + GridSelectionFunctions.verifyColumnSelected(colOrderDate); + GridSelectionFunctions.verifyColumnSelected(colInStock, false); + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledTimes(4); + args = { + oldSelection: ['ProductID', 'InStock', 'OrderDate'], + newSelection: ['ProductID', 'OrderDate'], + added: [], + removed: ['InStock'], + event: jasmine.anything() as any, + cancel: false + }; + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledWith(args); + GridSelectionFunctions.clickOnColumnToSelect(colOrderDate); + grid.cdr.detectChanges(); + + GridSelectionFunctions.verifyColumnSelected(colProductID, false); + GridSelectionFunctions.verifyColumnSelected(colOrderDate); + GridSelectionFunctions.verifyColumnSelected(colInStock, false); + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledTimes(5); + args = { + oldSelection: ['ProductID', 'OrderDate'], + newSelection: ['OrderDate'], + added: [], + removed: ['ProductID'], + event: jasmine.anything() as any, + cancel: false + }; + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledWith(args); + }); + + it('selecting a column with shift + mouse click', () => { + spyOn(grid.columnSelectionChanging, 'emit').and.callThrough(); + const colUnits = grid.getColumnByName('UnitsInStock'); + const colOrderDate = grid.getColumnByName('OrderDate'); + colUnits.selected = true; + colProductName.selectable = false; + fix.detectChanges(); + + GridSelectionFunctions.clickOnColumnToSelect(colInStock, true, true); + grid.cdr.detectChanges(); + + GridSelectionFunctions.verifyColumnSelected(colUnits); + GridSelectionFunctions.verifyColumnSelected(colInStock); + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledTimes(1); + let args: IColumnSelectionEventArgs = { + oldSelection: ['UnitsInStock'], + newSelection: ['UnitsInStock', 'InStock'], + added: ['InStock'], + removed: [], + event: jasmine.anything() as any, + cancel: false + }; + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledWith(args); + + GridSelectionFunctions.clickOnColumnToSelect(colOrderDate, false, true); + grid.cdr.detectChanges(); + + GridSelectionFunctions.verifyColumnSelected(colOrderDate); + GridSelectionFunctions.verifyColumnSelected(colInStock); + GridSelectionFunctions.verifyColumnSelected(colUnits); + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledTimes(2); + args = { + oldSelection: ['UnitsInStock', 'InStock'], + newSelection: ['UnitsInStock', 'InStock', 'OrderDate'], + added: ['OrderDate'], + removed: [], + event: jasmine.anything() as any, + cancel: false + }; + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledWith(args); + + GridSelectionFunctions.clickOnColumnToSelect(colProductID, false, true); + grid.cdr.detectChanges(); + + GridSelectionFunctions.verifyColumnSelected(colOrderDate, false); + GridSelectionFunctions.verifyColumnSelected(colProductName, false); + GridSelectionFunctions.verifyColumnSelected(colProductID); + GridSelectionFunctions.verifyColumnSelected(colInStock); + GridSelectionFunctions.verifyColumnSelected(colUnits); + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledTimes(3); + args = { + oldSelection: ['UnitsInStock', 'InStock', 'OrderDate'], + newSelection: ['UnitsInStock', 'InStock', 'ProductID'], + added: ['ProductID'], + removed: ['OrderDate'], + event: jasmine.anything() as any, + cancel: false + }; + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledWith(args); + }); + + it('Verify changing selection to none', () => { + expect(grid.columnSelection).toEqual('multiple'); + grid.selectColumns(['ProductID', 'ProductName']); + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductName); + + grid.columnSelection = GridSelectionMode.none; + fix.detectChanges(); + + expect(grid.columnSelection).toEqual('none'); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID, false); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductName, false); + + // Click on column header to select + GridFunctions.clickColumnHeaderUI('ProductID', fix); + // verify column is not selected + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID, false); + }); + }); + + describe('Single selection tests: ', () => { + let colProductName: IgxColumnComponent; + let colProductID: IgxColumnComponent; + let colInStock: IgxColumnComponent; + beforeEach(() => { + fix = TestBed.createComponent(ProductsComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + colProductName = grid.getColumnByName('ProductName'); + colProductID = grid.getColumnByName('ProductID'); + colInStock = grid.getColumnByName('InStock'); + grid.columnSelection = GridSelectionMode.single; + fix.detectChanges(); + }); + + it('selecting a column', () => { + // Click on column to select it. + GridFunctions.clickColumnHeaderUI('ProductID', fix); + + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID); + + // Click on another column + GridFunctions.clickColumnHeaderUI('ProductName', fix); + + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID, false); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductName); + + // Click on another column holding Ctrl + GridFunctions.clickColumnHeaderUI('InStock', fix, true); + + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID, false); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductName, false); + GridSelectionFunctions.verifyColumnAndCellsSelected(colInStock); + + // Click on another column holding Shift + GridFunctions.clickColumnHeaderUI('ProductID', fix, false, true); + + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductName, false); + GridSelectionFunctions.verifyColumnAndCellsSelected(colInStock, false); + + // Click on same column + GridFunctions.clickColumnHeaderUI('ProductID', fix); + + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID, false); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductName, false); + GridSelectionFunctions.verifyColumnAndCellsSelected(colInStock, false); + }); + + it('selecting a columns with API', () => { + // Verify setting selected property + colProductID.selected = true; + colProductName.selected = true; + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductName); + + // Click on column holding Ctrl + GridFunctions.clickColumnHeaderUI('InStock', fix, true); + + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID, false); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductName, false); + GridSelectionFunctions.verifyColumnAndCellsSelected(colInStock); + + // select multiple columns with method + grid.selectAllColumns(); + fix.detectChanges(); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductName); + GridSelectionFunctions.verifyColumnAndCellsSelected(colInStock); + }); + + it('Verify changing selection to multiple', () => { + expect(grid.columnSelection).toEqual('single'); + + GridFunctions.clickColumnHeaderUI('InStock', fix); + + GridSelectionFunctions.verifyColumnAndCellsSelected(colInStock); + + grid.columnSelection = GridSelectionMode.multiple; + fix.detectChanges(); + expect(grid.columnSelection).toEqual('multiple'); + GridSelectionFunctions.verifyColumnAndCellsSelected(colInStock, false); + expect(grid.selectedColumns()).toEqual([]); + }); + }); + + describe('Multi column headers tests: ', () => { + beforeEach(() => { + fix = TestBed.createComponent(ColumnSelectionGroupTestComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + }); + + it('setting selected on a column group', () => { + const personDetails = GridFunctions.getColGroup(grid, 'Person Details'); + const genInf = GridFunctions.getColGroup(grid, 'General Information'); + const companyName = grid.getColumnByName('CompanyName'); + const contactName = grid.getColumnByName('ContactName'); + const contactTitle = grid.getColumnByName('ContactTitle'); + spyOn(grid.columnSelectionChanging, 'emit').and.callThrough(); + + // verify setting selected true on a column group + genInf.selected = true; + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnsSelected([companyName, contactName, contactTitle]); + GridSelectionFunctions.verifyColumnGroupSelected(fix, personDetails); + GridSelectionFunctions.verifyColumnGroupSelected(fix, genInf); + + // verify setting selected false on a column group + personDetails.selected = false; + fix.detectChanges(); + GridSelectionFunctions.verifyColumnsSelected([contactName, contactTitle], false); + GridSelectionFunctions.verifyColumnSelected(companyName); + GridSelectionFunctions.verifyColumnGroupSelected(fix, personDetails, false); + GridSelectionFunctions.verifyColumnGroupSelected(fix, genInf, false); + + contactName.selected = true; + contactTitle.selected = true; + fix.detectChanges(); + GridSelectionFunctions.verifyColumnsSelected([companyName, contactName, contactTitle]); + GridSelectionFunctions.verifyColumnGroupSelected(fix, personDetails); + GridSelectionFunctions.verifyColumnGroupSelected(fix, genInf); + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledTimes(0); + }); + + it('setting selected on a column group with no selectable children', () => { + const countryInf = GridFunctions.getColGroup(grid, 'Country Information'); + const regInf = GridFunctions.getColGroup(grid, 'Region Information'); + const cityInf = GridFunctions.getColGroup(grid, 'City Information'); + const country = grid.getColumnByName('Country'); + const region = grid.getColumnByName('Region'); + const postalCode = grid.getColumnByName('PostalCode'); + const city = grid.getColumnByName('City'); + const address = grid.getColumnByName('Address'); + spyOn(grid.columnSelectionChanging, 'emit').and.callThrough(); + + // verify setting selected true on a column group + countryInf.selected = true; + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnsSelected([region, postalCode]); + GridSelectionFunctions.verifyColumnsSelected([country, city, address], false); + GridSelectionFunctions.verifyColumnGroupSelected(fix, countryInf); + GridSelectionFunctions.verifyColumnGroupSelected(fix, regInf); + GridSelectionFunctions.verifyColumnGroupSelected(fix, cityInf, false); + + // Set select to false to a column group + countryInf.selected = false; + fix.detectChanges(); + GridSelectionFunctions.verifyColumnsSelected([country, region, postalCode, city, address], false); + GridSelectionFunctions.verifyColumnGroupSelected(fix, countryInf, false); + GridSelectionFunctions.verifyColumnGroupSelected(fix, regInf, false); + GridSelectionFunctions.verifyColumnGroupSelected(fix, cityInf, false); + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledTimes(0); + }); + + it('setting selectable false to group children', () => { + const genInf = GridFunctions.getColGroup(grid, 'General Information'); + const personDetails = GridFunctions.getColGroup(grid, 'Person Details'); + const companyName = grid.getColumnByName('CompanyName'); + const contactName = grid.getColumnByName('ContactName'); + const contactTitle = grid.getColumnByName('ContactTitle'); + spyOn(grid.columnSelectionChanging, 'emit').and.callThrough(); + + // verify setting selected true on a column group + contactName.selectable = false; + contactTitle.selectable = false; + companyName.selectable = false; + fix.detectChanges(); + + expect(personDetails.selectable).toBeFalsy(); + expect(genInf.selectable).toBeFalsy(); + + // Set selected + genInf.selected = true; + fix.detectChanges(); + GridSelectionFunctions.verifyColumnGroupSelected(fix, personDetails, false); + GridSelectionFunctions.verifyColumnGroupSelected(fix, genInf, false); + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledTimes(0); + }); + + it('verify that when hover group all its selectable children have correct classes', () => { + const contactName = grid.getColumnByName('ContactName'); + const contactTitle = grid.getColumnByName('ContactTitle'); + const genInfHeader = GridFunctions.getColumnGroupHeaderCell('General Information', fix); + const personDetailsHeader = GridFunctions.getColumnGroupHeaderCell('Person Details', fix); + const companyNameHeader = GridFunctions.getColumnHeader('CompanyName', fix); + const contactNameHeader = GridFunctions.getColumnHeader('ContactName', fix); + const contactTitleHeader = GridFunctions.getColumnHeader('ContactTitle', fix); + + genInfHeader.triggerEventHandler('pointerenter', null); + fix.detectChanges(); + GridSelectionFunctions.verifyColumnsHeadersHasSelectableClass([genInfHeader, + personDetailsHeader, companyNameHeader, contactNameHeader, contactTitleHeader]); + + genInfHeader.triggerEventHandler('pointerleave', null); + fix.detectChanges(); + GridSelectionFunctions.verifyColumnsHeadersHasSelectableClass([genInfHeader, + personDetailsHeader, companyNameHeader, contactNameHeader, contactTitleHeader], false); + + contactName.selectable = false; + contactTitle.selectable = false; + fix.detectChanges(); + + genInfHeader.triggerEventHandler('pointerenter', null); + fix.detectChanges(); + GridSelectionFunctions.verifyColumnsHeadersHasSelectableClass([genInfHeader, companyNameHeader]); + GridSelectionFunctions.verifyColumnsHeadersHasSelectableClass([personDetailsHeader, + contactNameHeader, contactTitleHeader], false); + + genInfHeader.triggerEventHandler('pointerleave', null); + fix.detectChanges(); + GridSelectionFunctions.verifyColumnsHeadersHasSelectableClass([genInfHeader, companyNameHeader], false); + + // hover not selectable group + personDetailsHeader.triggerEventHandler('pointerenter', null); + fix.detectChanges(); + GridSelectionFunctions.verifyColumnsHeadersHasSelectableClass([personDetailsHeader, + contactNameHeader, contactTitleHeader, genInfHeader, companyNameHeader], false); + personDetailsHeader.triggerEventHandler('pointerleave', null); + fix.detectChanges(); + GridSelectionFunctions.verifyColumnsHeadersHasSelectableClass([personDetailsHeader, + contactNameHeader, contactTitleHeader, genInfHeader, companyNameHeader], false); + }); + + it('When click on a col group all it\'s visible and selectable child should be selected', () => { + const personDetails = GridFunctions.getColGroup(grid, 'Person Details'); + const genInfHeader = GridFunctions.getColGroup(grid, 'General Information'); + const companyName = grid.getColumnByName('CompanyName'); + const contactName = grid.getColumnByName('ContactName'); + const contactTitle = grid.getColumnByName('ContactTitle'); + + contactTitle.hidden = true; + contactName.selectable = false; + fix.detectChanges(); + + GridFunctions.clickColumnGroupHeaderUI('General Information', fix); + + GridSelectionFunctions.verifyColumnSelected(companyName); + GridSelectionFunctions.verifyColumnGroupSelected(fix, genInfHeader); + GridSelectionFunctions.verifyColumnGroupSelected(fix, personDetails, false); + GridSelectionFunctions.verifyColumnsSelected([contactTitle, contactName], false); + + GridFunctions.clickColumnGroupHeaderUI('General Information', fix); + + GridSelectionFunctions.verifyColumnSelected(companyName, false); + GridSelectionFunctions.verifyColumnGroupSelected(fix, genInfHeader, false); + GridSelectionFunctions.verifyColumnGroupSelected(fix, personDetails, false); + GridSelectionFunctions.verifyColumnsSelected([contactTitle, contactName], false); + }); + + it('Should select multiple columns when click and hold ctrl', () => { + const personDetails = GridFunctions.getColGroup(grid, 'Person Details'); + const countryInfo = GridFunctions.getColGroup(grid, 'Country Information'); + const regionInfo = GridFunctions.getColGroup(grid, 'Region Information'); + const contactName = grid.getColumnByName('ContactName'); + const contactTitle = grid.getColumnByName('ContactTitle'); + const region = grid.getColumnByName('Region'); + const postalCode = grid.getColumnByName('PostalCode'); + + GridFunctions.clickColumnGroupHeaderUI('Person Details', fix, true); + GridFunctions.clickColumnGroupHeaderUI('Region Information', fix, true); + + GridSelectionFunctions.verifyColumnGroupSelected(fix, personDetails); + GridSelectionFunctions.verifyColumnGroupSelected(fix, countryInfo); + GridSelectionFunctions.verifyColumnGroupSelected(fix, regionInfo); + GridSelectionFunctions.verifyColumnsSelected([contactTitle, contactName, region, postalCode]); + + GridFunctions.clickColumnHeaderUI('Region', fix); + GridFunctions.clickColumnHeaderUI('PostalCode', fix, true); + + GridSelectionFunctions.verifyColumnGroupSelected(fix, regionInfo); + GridSelectionFunctions.verifyColumnGroupSelected(fix, countryInfo); + GridSelectionFunctions.verifyColumnsSelected([region, postalCode]); + GridSelectionFunctions.verifyColumnGroupSelected(fix, personDetails, false); + GridSelectionFunctions.verifyColumnsSelected([contactTitle, contactName], false); + }); + + it('Should select whole range of columns when click and hold shift', () => { + const countryInfo = GridFunctions.getColGroup(grid, 'Country Information'); + const personDetails = GridFunctions.getColGroup(grid, 'Person Details'); + const regionInfo = GridFunctions.getColGroup(grid, 'Region Information'); + const id = grid.getColumnByName('ID'); + const contactName = grid.getColumnByName('ContactName'); + const contactTitle = grid.getColumnByName('ContactTitle'); + const region = grid.getColumnByName('Region'); + const postalCode = grid.getColumnByName('PostalCode'); + + GridFunctions.clickColumnHeaderUI('ID', fix, false, true); + GridFunctions.clickColumnHeaderUI('PostalCode', fix, false, true); + + GridSelectionFunctions.verifyColumnGroupSelected(fix, countryInfo); + GridSelectionFunctions.verifyColumnGroupSelected(fix, regionInfo); + GridSelectionFunctions.verifyColumnsSelected([region, postalCode, id]); + + GridFunctions.clickColumnHeaderUI('ID', fix); + GridFunctions.clickColumnHeaderUI('ContactName', fix, false, true); + + GridSelectionFunctions.verifyColumnGroupSelected(fix, personDetails); + GridSelectionFunctions.verifyColumnsSelected([contactTitle, contactName, id]); + GridSelectionFunctions.verifyColumnGroupSelected(fix, countryInfo, false); + GridSelectionFunctions.verifyColumnGroupSelected(fix, regionInfo, false); + GridSelectionFunctions.verifyColumnsSelected([region, postalCode], false); + }); + + it('Should select the group when all visible child are selected', () => { + const countryInfo = GridFunctions.getColGroup(grid, 'Country Information'); + const regionInfo = GridFunctions.getColGroup(grid, 'Region Information'); + const region = grid.getColumnByName('Region'); + const country = grid.getColumnByName('Country'); + const postalCode = grid.getColumnByName('PostalCode'); + + GridSelectionFunctions.verifyColumnGroupSelected(fix, countryInfo, false); + GridSelectionFunctions.verifyColumnGroupSelected(fix, regionInfo, false); + GridSelectionFunctions.verifyColumnSelected(region, false); + + country.hidden = true; + country.selectable = true; + postalCode.hidden = true; + fix.detectChanges(); + + GridFunctions.clickColumnHeaderUI('Region', fix); + GridSelectionFunctions.verifyColumnGroupSelected(fix, countryInfo); + GridSelectionFunctions.verifyColumnGroupSelected(fix, regionInfo); + GridSelectionFunctions.verifyColumnSelected(region); + }); + + it('group is selected if all it\'s children are selected', () => { + const regionInfo = GridFunctions.getColGroup(grid, 'Region Information'); + const region = grid.getColumnByName('Region'); + const country = grid.getColumnByName('Country'); + const postalCode = grid.getColumnByName('PostalCode'); + + country.selectable = true; + country.selected = true; + region.selected = true; + postalCode.selected = true; + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnGroupSelected(fix, regionInfo); + + postalCode.selected = false; + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnGroupSelected(fix, regionInfo, false); + }); + + it('when column(s) is/are hidden, selection should not reflect on them', () => { + const postalCode = grid.getColumnByName('PostalCode'); + const region = grid.getColumnByName('Region'); + const contactName = grid.getColumnByName('ContactName'); + const contactTitle = grid.getColumnByName('ContactTitle'); + const personDetails = GridFunctions.getColGroup(grid, 'Person Details'); + const regionInfo = GridFunctions.getColGroup(grid, 'Region Information'); + + postalCode.hidden = true; + contactTitle.hidden = true; + fix.detectChanges(); + + GridFunctions.clickColumnGroupHeaderUI('Person Details', fix, true); + GridFunctions.clickColumnGroupHeaderUI('Region Information', fix, true); + + GridSelectionFunctions.verifyColumnsSelected([postalCode, contactTitle], false); + GridSelectionFunctions.verifyColumnsSelected([contactName, region]); + + grid.deselectAllColumns(); + fix.detectChanges(); + GridSelectionFunctions.verifyColumnsSelected([contactName, region], false); + + personDetails.selected = true; + regionInfo.selected = true; + fix.detectChanges(); + GridSelectionFunctions.verifyColumnsSelected([postalCode, contactTitle, contactName, region]); + }); + + it('When column selection is single and click on a group its children should be selected', () => { + const personDetails = GridFunctions.getColGroup(grid, 'Person Details'); + const genInfHeader = GridFunctions.getColGroup(grid, 'General Information'); + const companyName = grid.getColumnByName('CompanyName'); + const contactName = grid.getColumnByName('ContactName'); + const contactTitle = grid.getColumnByName('ContactTitle'); + + grid.columnSelection = GridSelectionMode.single; + fix.detectChanges(); + + GridFunctions.clickColumnGroupHeaderUI('General Information', fix); + + GridSelectionFunctions.verifyColumnGroupSelected(fix, genInfHeader); + GridSelectionFunctions.verifyColumnGroupSelected(fix, personDetails); + GridSelectionFunctions.verifyColumnsSelected([companyName, contactTitle, contactName]); + + // Click on a column group in the group + GridFunctions.clickColumnGroupHeaderUI('Person Details', fix); + GridSelectionFunctions.verifyColumnGroupSelected(fix, genInfHeader, false); + GridSelectionFunctions.verifyColumnGroupSelected(fix, personDetails, true); + GridSelectionFunctions.verifyColumnsSelected([contactTitle, contactName]); + GridSelectionFunctions.verifyColumnSelected(companyName, false); + + // Click on a column in the group + GridFunctions.clickColumnHeaderUI('ContactName', fix); + GridSelectionFunctions.verifyColumnGroupSelected(fix, personDetails, false); + GridSelectionFunctions.verifyColumnSelected(contactTitle, false); + GridSelectionFunctions.verifyColumnSelected(contactName); + + }); + }); + + describe('Integration tests: ', () => { + let colProductID: IgxColumnComponent; + let colProductName: IgxColumnComponent; + beforeEach(() => { + fix = TestBed.createComponent(ProductsComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + grid.columnSelection = GridSelectionMode.multiple; + fix.detectChanges(); + colProductID = grid.getColumnByName('ProductID'); + colProductName = grid.getColumnByName('ProductName'); + }); + + it('Filtering: Verify column selection when filter row is opened ', fakeAsync(() => { + grid.allowFiltering = true; + fix.detectChanges(); + const filterCell = GridFunctions.getFilterCell(fix, 'ProductID'); + expect(filterCell.nativeElement.classList.contains(SELECTED_FILTER_CELL_CLASS)).toBeFalsy(); + const colInStock = grid.getColumnByName('InStock'); + colProductID.selected = true; + fix.detectChanges(); + + expect(filterCell.nativeElement.classList.contains(SELECTED_FILTER_CELL_CLASS)).toBeTruthy(); + GridFunctions.clickFilterCellChipUI(fix, 'InStock'); // Name column contains nested object as a value + tick(150); + fix.detectChanges(); + + const filterRow = GridFunctions.getFilterRow(fix); + expect(filterRow).toBeDefined(); + + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID); + + GridFunctions.clickColumnHeaderUI('InStock', fix); + tick(); + + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID); + GridSelectionFunctions.verifyColumnAndCellsSelected(colInStock, false); + expect(grid.filteringRow.column.field).toEqual('InStock'); + + GridFunctions.clickColumnHeaderUI('ProductID', fix); + tick(); + + const productIDHeader = GridFunctions.getColumnHeader('ProductID', fix); + expect(productIDHeader.nativeElement.classList.contains(SELECTED_COLUMN_CLASS)).toBeFalsy(); + colProductID._cells.forEach(cell => { + expect(cell.nativeElement.classList.contains(SELECTED_COLUMN_CELL_CLASS)).toEqual(true); + }); + expect(grid.filteringRow.column.field).toEqual('ProductID'); + + // Hover column headers + const productNameHeader = GridFunctions.getColumnHeader('ProductName', fix); + productNameHeader.triggerEventHandler('pointerenter', null); + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnHeaderHasSelectableClass(productNameHeader, false); + })); + + it('Filtering: Verify column selection when filter', () => { + colProductName.selected = true; + colProductID.selected = true; + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductName); + + grid.filter('ProductName', 'Chocolate', IgxStringFilteringOperand.instance().condition('equals'), true); + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductName); + expect(grid.getSelectedColumnsData()).toEqual([{ ProductID: 10, ProductName: 'Chocolate' }]); + + grid.clearFilter(); + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductName); + expect(grid.getSelectedColumnsData()).toEqual(selectedData()); + }); + + it('Sorting: Verify column selection is not change when click on sort indicator', () => { + const productIDHeader = GridFunctions.getColumnHeader('ProductID', fix); + colProductName.selected = true; + colProductID.sortable = true; + colProductID.selected = true; + fix.detectChanges(); + + GridFunctions.clickHeaderSortIcon(productIDHeader); + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID, true); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductName, true); + expect(grid.getSelectedColumnsData()).toEqual(selectedData()); + + GridFunctions.clickHeaderSortIcon(productIDHeader); + fix.detectChanges(); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID, true); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductName, true); + expect(grid.getSelectedColumnsData()).toEqual(selectedData().sort((a, b) => b.ProductID - a.ProductID)); + }); + + it('Pinning: Verify that when pin/unpin the column stays selected', () => { + colProductName.selected = true; + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductName); + + // pin the column + colProductName.pinned = true; + fix.detectChanges(); + + GridFunctions.verifyColumnIsPinned(colProductName, true, 1); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductName); + + colProductName.pinned = false; + fix.detectChanges(); + + GridFunctions.verifyColumnIsPinned(colProductName, false, 0); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductName); + }); + + it('Hiding: Verify that when hide/unhide a column the column stays selected', () => { + colProductID.selected = true; + colProductName.selected = true; + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductName); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID); + + // hide the column + colProductName.hidden = true; + fix.detectChanges(); + + GridFunctions.verifyColumnIsHidden(colProductName, true, 4); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID); + expect(grid.getSelectedColumnsData()).toEqual(selectedData()); + expect(grid.selectedColumns().includes(colProductName)).toBeTruthy(); + + colProductName.hidden = false; + fix.detectChanges(); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductName); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID); + }); + + it('Moving: Verify that when move a column, it stays selected', fakeAsync(() => { + colProductID.selected = true; + fix.detectChanges(); + + grid.moveColumn(colProductID, colProductName); + tick(); + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductName, false); + expect(colProductID.visibleIndex).toEqual(1); + })); + + it('Paging: Verify column stays selected when change page', fakeAsync(() => { + colProductName.selected = true; + colProductID.selected = true; + fix.componentInstance.paging = true; + fix.detectChanges(); + fix.componentInstance.paginator.perPage = 3; + fix.detectChanges(); + tick(30); + + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductName); + expect(grid.getSelectedColumnsData()).toEqual(selectedData()); + + fix.componentInstance.paginator.paginate(1); + fix.detectChanges(); + tick(16); + + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductID); + GridSelectionFunctions.verifyColumnAndCellsSelected(colProductName); + expect(grid.getSelectedColumnsData()).toEqual(selectedData()); + })); + }); +}); diff --git a/projects/igniteui-angular/grids/grid/src/column.spec.ts b/projects/igniteui-angular/grids/grid/src/column.spec.ts new file mode 100644 index 00000000000..06c055a128d --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/column.spec.ts @@ -0,0 +1,1866 @@ +import { Component, DebugElement, TemplateRef, ViewChild } from '@angular/core'; +import { TestBed, fakeAsync, tick, waitForAsync, ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { getLocaleCurrencySymbol, registerLocaleData } from '@angular/common'; +import localeFr from '@angular/common/locales/fr'; +import localeJa from '@angular/common/locales/ja'; + +import { IgxGridComponent } from './grid.component'; +import { GridTemplateStrings, ColumnDefinitions } from '../../../test-utils/template-strings.spec'; +import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; +import { + ColumnHiddenFromMarkupComponent, + ColumnCellFormatterComponent, + DynamicColumnsComponent, + GridAddColumnComponent, + IgxGridCurrencyColumnComponent, + IgxGridPercentColumnComponent, + IgxGridDateTimeColumnComponent +} from '../../../test-utils/grid-samples.spec'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; +import { GridFunctions, GridSummaryFunctions } from '../../../test-utils/grid-functions.spec'; +import { IgxCellFooterTemplateDirective, IgxCellHeaderTemplateDirective, IgxCellTemplateDirective, IgxColumnComponent, IgxSummaryTemplateDirective } from 'igniteui-angular/grids/core'; +import { IgxGridRowComponent } from './grid-row.component'; +import { GridColumnDataType, IgxStringFilteringOperand, SortingDirection } from 'igniteui-angular/core'; +import { IgxButtonDirective, IgxDateTimeEditorDirective } from 'igniteui-angular/directives'; +import { IgxInputDirective } from 'igniteui-angular/input-group'; + +describe('IgxGrid - Column properties #grid', () => { + + registerLocaleData(localeFr); + registerLocaleData(localeJa); + + const COLUMN_HEADER_CLASS = '.igx-grid-th'; + const COLUMN_HEADER_GROUP_CLASS = '.igx-grid-thead__item'; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + ColumnCellFormatterComponent, + ColumnHiddenFromMarkupComponent, + DynamicColumnsComponent, + GridAddColumnComponent, + IgxGridCurrencyColumnComponent, + IgxGridPercentColumnComponent, + IgxGridDateTimeColumnComponent, + NoopAnimationsModule, + ColumnsFromIterableComponent, + TemplatedColumnsComponent, + TemplatedInputColumnsComponent, + TemplatedContextInputColumnsComponent, + ColumnHaederClassesComponent, + ResizableColumnsComponent, + DOMAttributesAsSettersComponent + ] + }).compileComponents(); + })); + + it('should correctly initialize column templates', () => { + const fix = TestBed.createComponent(TemplatedColumnsComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.instance; + + const headerSpans: DebugElement[] = fix.debugElement.queryAll(By.css('.header')); + const cellSpans: DebugElement[] = fix.debugElement.queryAll(By.css('.cell')); + const summarySpans: DebugElement[] = fix.debugElement.queryAll(By.css('.summary')); + + grid.columnList.forEach((column) => expect(column.bodyTemplate).toBeDefined()); + grid.columnList.forEach((column) => expect(column.headerTemplate).toBeDefined()); + grid.columnList.forEach((column) => expect(column.summaryTemplate).toBeDefined()); + + headerSpans.forEach((span) => expect(span.nativeElement.textContent).toMatch('Header text')); + cellSpans.forEach((span) => expect(span.nativeElement.textContent).toMatch('Cell text')); + summarySpans.forEach((span) => expect(span.nativeElement.textContent).toMatch('Summary text')); + }); + + it('should provide a way to change templates dynamically', () => { + const fix = TestBed.createComponent(TemplatedColumnsComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.instance; + + grid.columnList.forEach((column) => column.headerTemplate = fix.componentInstance.newHeaderTemplate); + grid.columnList.forEach((column) => column.bodyTemplate = fix.componentInstance.newCellTemplate); + grid.columnList.forEach((column) => column.summaryTemplate = fix.componentInstance.newSummaryTemplate); + + fix.detectChanges(); + + const headerSpans: DebugElement[] = fix.debugElement.queryAll(By.css('.new-header')); + const cellSpans: DebugElement[] = fix.debugElement.queryAll(By.css('.new-cell')); + const summarySpans: DebugElement[] = fix.debugElement.queryAll(By.css('.new-summary')); + + headerSpans.forEach((span) => expect(span.nativeElement.textContent).toMatch('New header text')); + cellSpans.forEach((span) => expect(span.nativeElement.textContent).toMatch('New cell text')); + summarySpans.forEach((span) => expect(span.nativeElement.textContent).toMatch('New summary text')); + }); + + it('should reflect column hiding correctly in the DOM dynamically', () => { + const fix = TestBed.createComponent(TemplatedColumnsComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.instance; + + grid.columnList.first.hidden = true; + fix.detectChanges(); + + expect(grid.visibleColumns.length).toEqual(1); + expect(grid.visibleColumns[0].field).toEqual('Name'); + expect(fix.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)).length).toEqual(1); + + grid.columnList.first.hidden = false; + fix.detectChanges(); + + expect(grid.visibleColumns.length).toEqual(2); + expect(grid.visibleColumns[0].field).toEqual('ID'); + expect(fix.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)).length).toEqual(2); + }); + + it('should reflect column hiding correctly in the DOM from markup declaration', () => { + const fix = TestBed.createComponent(ColumnHiddenFromMarkupComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + + expect(grid.visibleColumns.length).toEqual(0); + expect(fix.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)).length).toEqual(0); + + grid.columnList.first.hidden = false; + fix.detectChanges(); + + expect(grid.visibleColumns.length).toEqual(1); + expect(fix.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)).length).toEqual(1); + }); + + it('should support providing a custom formatter for cell values', () => { + const fix = TestBed.createComponent(ColumnCellFormatterComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + const formatter = fix.componentInstance.multiplier; + + const boolFormatter = fix.componentInstance.boolFormatter; + + expect(grid.columnList.first.formatter).toBeDefined(); + + for (let i = 0; i < 3; i++) { + const cell = grid.gridAPI.get_cell_by_index(i, 'ID'); + expect(cell.nativeElement.textContent).toMatch(formatter(cell.value)); + + const cellBool = grid.gridAPI.get_cell_by_index(i, 'IsEmployed'); + expect(cellBool.nativeElement.textContent).toMatch(boolFormatter(cellBool.value)); + } + }); + + it('should correctly pass row data context for the format callback', () => { + const fix = TestBed.createComponent(ColumnCellFormatterComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + const formatter = fix.componentInstance.containsY; + grid.getColumnByName('ID').formatter = formatter; + fix.detectChanges(); + + for (let i = 0; i < 2; i++) { + const cell = grid.gridAPI.get_cell_by_index(i, 'ID'); + expect(cell.nativeElement.textContent).toMatch('true'); + } + }); + + it('should reflect the column in the DOM based on its index', fakeAsync(() => { + const fix = TestBed.createComponent(ColumnCellFormatterComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + let headers: DebugElement[]; + + expect(grid.columns[0].field).toMatch('ID'); + expect(grid.columns[2].field).toMatch('Name'); + + headers = fix.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + expect(headers[0].nativeElement.textContent).toMatch('ID'); + expect(headers[2].nativeElement.textContent).toMatch('Name'); + + // Swap columns + grid.moveColumn(grid.columns[0], grid.columns[2]); + tick(); + fix.detectChanges(); + + expect(grid.columns[0].field).toMatch('IsEmployed'); + expect(grid.columns[2].field).toMatch('ID'); + + headers = fix.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + expect(headers[0].nativeElement.textContent).toMatch('IsEmployed'); + expect(headers[1].nativeElement.textContent).toMatch('Name'); + })); + + it('should support adding and removing columns through a declared iterable', fakeAsync(/** columnList.changes rAF */() => { + const fix = TestBed.createComponent(ColumnsFromIterableComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.instance; + + expect(grid.columnList.length).toEqual(2); + + fix.componentInstance.columns.push('MyNewColumn'); + fix.detectChanges(); + + expect(grid.columnList.length).toEqual(3); + expect(grid.columnList.last.field).toMatch('MyNewColumn'); + + fix.componentInstance.columns.pop(); + fix.detectChanges(); + + expect(grid.columnList.length).toEqual(2); + expect(grid.columnList.last.field).toMatch('Name'); + })); + + it('should add new column at the correct visible index', fakeAsync(() => { + const fix = TestBed.createComponent(GridAddColumnComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + const maxVindex = fix.componentInstance.columns.length - 1; + + // add to unpinned area + grid.moving = true; + fix.componentInstance.columns.push({ field: 'City', width: 150, type: 'string' }); + fix.detectChanges(); + + let cityCol = grid.getColumnByName('City'); + expect(cityCol.visibleIndex).toEqual(maxVindex + 1); + + // remove the newly added column + fix.componentInstance.columns.pop(); + fix.detectChanges(); + + cityCol = grid.getColumnByName('City'); + expect(cityCol).not.toBeDefined(); + + // add to pinned area + fix.componentInstance.columns.push({ field: 'City', width: 150, type: 'string', pinned: true }); + fix.detectChanges(); + + cityCol = grid.getColumnByName('City'); + expect(cityCol.visibleIndex).toEqual(1); + })); + + it('should apply columnWidth on columns that don\'t have explicit width', () => { + const fix = TestBed.createComponent(ColumnCellFormatterComponent); + fix.componentInstance.grid.columnWidth = '200px'; + fix.detectChanges(); + const cols = fix.componentInstance.grid.columnList; + + cols.forEach((item) => { + expect(item.width).toEqual('200px'); + }); + const headers = fix.debugElement.queryAll(By.css(COLUMN_HEADER_GROUP_CLASS)); + expect(headers[0].nativeElement.clientWidth).toEqual(200); + }); + + it('headers and cells classes should be correct after scroll horizontal', async () => { + // Use setTimeout because when scroll the grid whenStable does not work + const fix = TestBed.createComponent(ColumnHaederClassesComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + const CELL_CSS_CLASS = '.igx-grid__td'; + const COLUMN_NUMBER_CLASS = 'igx-grid-th--number'; + const CELL_NUMBER_CLASS = 'igx-grid__td--number'; + + // Verify haeder clases + let headers: DebugElement[]; + let allCells: DebugElement[]; + + allCells = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS)); + allCells.forEach((cell) => expect(cell.nativeElement.className.indexOf(CELL_NUMBER_CLASS)).toBeGreaterThan(-1)); + expect(allCells[3].nativeElement.className.indexOf('headerAlignSyle')).toBeGreaterThan(-1); + + headers = fix.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + headers.forEach((header) => expect(header.nativeElement.className.indexOf(COLUMN_NUMBER_CLASS)).toBeGreaterThan(-1)); + expect(headers[2].nativeElement.className.indexOf('headerAlignSyle')).toBeGreaterThan(-1); + grid.headerContainer.getScroll().scrollLeft = 200; + await wait(100); + fix.detectChanges(); + headers = fix.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + headers.forEach((header) => expect(header.nativeElement.className.indexOf(COLUMN_NUMBER_CLASS)).toBeGreaterThan(-1)); + expect(headers[0].nativeElement.className.indexOf('headerAlignSyle')).toBeGreaterThan(-1); + + allCells = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS)); + allCells.forEach((cell) => expect(cell.nativeElement.className.indexOf(CELL_NUMBER_CLASS)).toBeGreaterThan(-1)); + expect(allCells[1].nativeElement.className.indexOf('headerAlignSyle')).toBeGreaterThan(-1); + + grid.headerContainer.getScroll().scrollLeft = 0; + await wait(100); + fix.detectChanges(); + headers = fix.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + headers.forEach((header) => expect(header.nativeElement.className.indexOf(COLUMN_NUMBER_CLASS)).toBeGreaterThan(-1)); + expect(headers[2].nativeElement.className.indexOf('headerAlignSyle')).toBeGreaterThan(-1); + + allCells = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS)); + allCells.forEach((cell) => expect(cell.nativeElement.className.indexOf(CELL_NUMBER_CLASS)).toBeGreaterThan(-1)); + expect(allCells[3].nativeElement.className.indexOf('headerAlignSyle')).toBeGreaterThan(-1); + }); + + it('column width should be adjusted after a column has been hidden', () => { + const fix = TestBed.createComponent(ColumnsFromIterableComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.instance; + grid.width = '600px'; + fix.detectChanges(); + + expect(grid.calcWidth).toBe(600); + expect(grid.columnList.get(0).width).toBe('300px'); + expect(!grid.columnList.get(0).widthSetByUser); + expect(grid.columnList.get(1).width).toBe('300px'); + expect(!grid.columnList.get(1).widthSetByUser); + grid.columnList.get(0).hidden = true; + fix.detectChanges(); + + expect(grid.columnList.get(1).width).toBe('600px'); + grid.columnList.get(0).hidden = false; + fix.detectChanges(); + + expect(grid.columnList.get(0).width).toBe('300px'); + expect(grid.columnList.get(1).width).toBe('300px'); + }); + + it('should support passing templates through the markup as an input property', () => { + const fixture = TestBed.createComponent(TemplatedInputColumnsComponent); + fixture.detectChanges(); + + const grid = fixture.componentInstance.instance; + + grid.getColumnByName('Name')._cells.forEach(c => + expect(c.nativeElement.querySelector('.customCellTemplate')).toBeDefined()); + + grid.headerCellList.forEach(header => + expect(header.nativeElement.querySelector('.customHeaderTemplate')).toBeDefined()); + + grid.summariesRowList.forEach(summary => + expect(summary.nativeElement.querySelector('.customSummaryTemplate')).not.toBeNull()); + + const cell = grid.getCellByColumn(0, 'ID'); + cell.editMode = true; + fixture.detectChanges(); + + expect(grid.gridAPI.get_cell_by_index(0, 'ID').nativeElement.querySelector('.customEditorTemplate')).toBeDefined(); + + }); + + it('should support passing properties through the additionalTemplateContext input property', () => { + const fixture = TestBed.createComponent(TemplatedContextInputColumnsComponent); + fixture.detectChanges(); + + const grid = fixture.componentInstance.instance; + const contextObject = { property1: 'cellContent', property2: 'cellContent1' }; + const firstColumn = grid.columnList.get(0); + const secondColumn = grid.columnList.get(1); + + expect(firstColumn.additionalTemplateContext).toEqual(contextObject); + expect(firstColumn._cells[0].nativeElement.innerText).toEqual(contextObject.property1); + expect(secondColumn._cells[0].nativeElement.innerText).toEqual(contextObject.property2); + }); + + it('should apply column\'s formatter programmatically', () => { + const expectedVal = ['Johny', 'Sally', 'Tim']; + const expectedValToLower = ['johny', 'sally', 'tim']; + const fix = TestBed.createComponent(ColumnsFromIterableComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.instance; + const col = grid.columnList.get(1); + expect(col.formatter).toBeUndefined(); + const rowCount = grid.rowList.length; + for (let i = 0; i < rowCount; i++) { + // Check the display value + expect(grid.gridAPI.get_cell_by_index(i, 'Name').nativeElement.textContent).toBe(expectedVal[i]); + // Check the cell's value is not changed + expect(grid.getCellByColumn(i, 'Name').value).toBe(expectedVal[i]); + } + + // Apply formatter to the last column + col.formatter = (val: string) => val.toLowerCase(); + fix.detectChanges(); + + expect(col.formatter).toBeTruthy(); + expect(col.formatter).toBeDefined(); + for (let i = 0; i < rowCount; i++) { + // Check the cell's formatter value(display value) + expect(grid.gridAPI.get_cell_by_index(i, 'Name').nativeElement.textContent).toBe(expectedValToLower[i]); + // Check the cell's value is not changed + expect(grid.getCellByColumn(i, 'Name').value).toBe(expectedVal[i]); + } + }); + + it('should clear filter when a columns is removed dynamically', () => { + const fix = TestBed.createComponent(DynamicColumnsComponent); + fix.detectChanges(); + + const columns = fix.componentInstance.columns; + const grid = fix.componentInstance.grid; + grid.allowFiltering = true; + fix.detectChanges(); + + expect(grid.columnList.length).toBe(7); + + grid.filter('CompanyName', 'NoItemsFound', IgxStringFilteringOperand.instance().condition('contains'), true); + fix.detectChanges(); + + expect(grid.rowList.length).toEqual(0); + + expect(() => { + fix.componentInstance.columns = columns.slice(2, columns.length - 1); + fix.detectChanges(); + }).not.toThrow(); + + expect(grid.rowList.length).toBeGreaterThan(10); + expect(grid.columnList.length).toBe(4); + }); + + it('should clear grouping when a columns is removed dynamically', () => { + const fix = TestBed.createComponent(DynamicColumnsComponent); + fix.detectChanges(); + + const columns = fix.componentInstance.columns; + const grid = fix.componentInstance.grid; + grid.getColumnByName('CompanyName').groupable = true; + grid.getColumnByName('Address').groupable = true; + grid.getColumnByName('City').groupable = true; + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'CompanyName', dir: SortingDirection.Asc, ignoreCase: false + }); + + fix.detectChanges(); + + let groupRows = grid.nativeElement.querySelectorAll('igx-grid-groupby-row'); + + expect(groupRows.length).toBeGreaterThan(0); + + expect(() => { + fix.componentInstance.columns = columns.slice(2, columns.length - 1); + fix.detectChanges(); + }).not.toThrow(); + + groupRows = grid.nativeElement.querySelectorAll('igx-grid-groupby-row'); + + expect(groupRows.length).toBe(0); + expect(grid.columnList.length).toBe(4); + }); + + it('should apply custom CSS bindings to the grid cells', () => { + const fix = TestBed.createComponent(ColumnHaederClassesComponent); + fix.detectChanges(); + + const styles = { + background: 'black', + color: 'white' + }; + + const grid = fix.componentInstance.grid; + grid.columnList.forEach(c => c.cellStyles = styles); + fix.detectChanges(); + + const row = grid.gridAPI.get_row_by_index(0); + row.cells.forEach(cell => expect(cell.nativeElement.getAttribute('style')).toMatch('background: black')); + }); + + it('should apply custom CSS bindings to grid headers', () => { + const fix = TestBed.createComponent(ColumnHaederClassesComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + + + const styles = { + background: 'rebeccapurple', + color: 'white' + }; + + grid.columnList.forEach(col => col.headerStyles = styles); + fix.detectChanges(); + + grid.headerCellList.forEach(header => expect(header.nativeElement.getAttribute('style')).toMatch('background: rebeccapurple')); + + }); + + it('should apply custom CSS bindings to grid header groups', () => { + const fix = TestBed.createComponent(ColumnHaederClassesComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + + + const styles = { + background: 'rebeccapurple', + color: 'white' + }; + + grid.columnList.forEach(col => col.headerGroupStyles = styles); + fix.detectChanges(); + + grid.headerGroupsList.forEach(hGroup => expect(hGroup.nativeElement.getAttribute('style')).toMatch('background: rebeccapurple')); + }); + + it('should set title attribute on column header spans', () => { + const fix = TestBed.createComponent(ColumnsFromIterableComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.instance; + const idColumn = grid.getColumnByName('ID'); + const nameColumn = grid.getColumnByName('Name'); + + idColumn.header = 'ID Header'; + idColumn.title = 'ID Title'; + nameColumn.header = 'Name Header'; + fix.detectChanges(); + + const headers = fix.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + const idHeader = headers[0].nativeElement; + const nameHeader = headers[1].nativeElement; + expect(idHeader.textContent).toBe('ID Header'); + expect(idHeader.firstElementChild.firstElementChild.title).toBe('ID Title'); + expect(nameHeader.textContent).toBe('Name Header'); + expect(nameHeader.firstElementChild.firstElementChild.title).toBe('Name Header'); + }); + + describe('Data type currency column tests', () => { + // NOTE: The following three tests fail only in an Ivy scenario. We should leave them running anyways + it('should display correctly the data when column dataType is currency #ivy', () => { + const fix = TestBed.createComponent(IgxGridCurrencyColumnComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + const unitsColumn = grid.getColumnByName('UnitsInStock'); + + expect(unitsColumn._cells[0].nativeElement.innerText).toEqual('$2,760'); + expect(unitsColumn._cells[5].nativeElement.innerText).toEqual('$1,098'); + expect(unitsColumn._cells[6].nativeElement.innerText).toEqual('$0'); + expect(unitsColumn._cells[8].nativeElement.innerText).toEqual('$6,998'); + + unitsColumn.pipeArgs = { + digitsInfo: '3.4-4', + currencyCode: 'USD', + display: 'symbol-narrow' + }; + fix.detectChanges(); + + expect(unitsColumn._cells[0].nativeElement.innerText).toEqual('$2,760.0000'); + expect(unitsColumn._cells[5].nativeElement.innerText).toEqual('$1,098.0000'); + expect(unitsColumn._cells[6].nativeElement.innerText).toEqual('$000.0000'); + expect(unitsColumn._cells[8].nativeElement.innerText).toEqual('$6,998.0000'); + + }); + + it('should be able to change the locale runtime #ivy', () => { + const fix = TestBed.createComponent(IgxGridCurrencyColumnComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + const unitsColumn = grid.getColumnByName('UnitsInStock'); + + expect(unitsColumn._cells[8].nativeElement.innerText).toEqual('$6,998'); + grid.locale = 'fr-FR'; + fix.detectChanges(); + + expect(unitsColumn._cells[8].nativeElement.innerText).toEqual('6 998 €'); + expect(unitsColumn._cells[5].nativeElement.innerText).toEqual('1 098 €'); + expect(unitsColumn._cells[3].nativeElement.innerText).toEqual('0 €'); + + grid.locale = 'ja'; + fix.detectChanges(); + + expect(unitsColumn._cells[8].nativeElement.innerText).toEqual('¥6,998'); + expect(unitsColumn._cells[5].nativeElement.innerText).toEqual('¥1,098'); + expect(unitsColumn._cells[3].nativeElement.innerText).toEqual('¥0'); + }); + + it('should display the currency symbol in edit mode correctly according the grid locale #ivy', fakeAsync(() => { + const fix = TestBed.createComponent(IgxGridCurrencyColumnComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + const unitsColumn = grid.getColumnByName('UnitsInStock'); + unitsColumn.editable = true; + fix.detectChanges(); + + let firstCell = unitsColumn._cells[0]; + + expect(firstCell.nativeElement.innerText).toEqual('$2,760'); + + firstCell.setEditMode(true); + fix.detectChanges(); + tick(); + + let input = firstCell.nativeElement.querySelector('.igx-input-group__input'); + let prefix = firstCell.nativeElement.querySelector('igx-prefix'); + let suffix = firstCell.nativeElement.querySelector('igx-suffix'); + expect((input as any).value).toEqual('2760'); + expect((prefix as HTMLElement).innerText).toEqual(getLocaleCurrencySymbol(grid.locale)); + expect(suffix).toBeNull(); + + firstCell.setEditMode(false); + fix.detectChanges(); + + grid.locale = 'fr-FR'; + fix.detectChanges(); + tick(); + + firstCell = grid.gridAPI.get_cell_by_index(0, 'UnitsInStock'); + expect(grid.locale).toEqual('fr-FR'); + expect(firstCell.nativeElement.innerText).toEqual('2 760 €'); + + firstCell.setEditMode(true); + fix.detectChanges(); + tick(); + + input = firstCell.nativeElement.querySelector('.igx-input-group__input'); + prefix = firstCell.nativeElement.querySelector('igx-prefix'); + suffix = firstCell.nativeElement.querySelector('igx-suffix'); + expect((input as any).value).toEqual('2760'); + expect(prefix).toBeNull(); + expect((suffix as HTMLElement).innerText).toEqual(getLocaleCurrencySymbol(grid.locale)); + })); + + it('should display summaries correctly for currency column', () => { + const fix = TestBed.createComponent(IgxGridCurrencyColumnComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + const unitsColumn = grid.getColumnByName('UnitsInStock'); + unitsColumn.hasSummary = true; + fix.detectChanges(); + + let summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['10', '$0', '$20,000', '$39,004', '$3,900.4']); + + grid.locale = 'fr-FR'; + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['10', '0 €', '20 000 €', '39 004 €', '3 900,4 €']); + }); + + it('filtering UI list should be populated with correct values based on the currency code, locale and/or pipeArgs', fakeAsync(() => { + const fix = TestBed.createComponent(IgxGridCurrencyColumnComponent); + tick(); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + const unitsColumn = grid.getColumnByName('UnitsInStock'); + grid.allowFiltering = true; + grid.filterMode = 'excelStyleFilter'; + fix.detectChanges(); + + GridFunctions.clickExcelFilterIcon(fix, unitsColumn.field); + tick(100); + fix.detectChanges(); + + let excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + let esfSearch = GridFunctions.getExcelFilteringSearchComponent(fix, excelMenu); + let checkBoxes = esfSearch.querySelectorAll('igx-checkbox'); + + expect((checkBoxes[1].querySelector('.igx-checkbox__label') as HTMLElement).innerText).toEqual('$0'); + expect((checkBoxes[3].querySelector('.igx-checkbox__label') as HTMLElement).innerText).toEqual('$198'); + + GridFunctions.clickCancelExcelStyleFiltering(fix); + fix.detectChanges(); + + unitsColumn.pipeArgs = { + digitsInfo: '3.3-3', + currencyCode: 'EUR', + display: 'symbol-narrow' + }; + fix.detectChanges(); + + GridFunctions.clickExcelFilterIcon(fix, unitsColumn.field); + tick(100); + fix.detectChanges(); + + excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + esfSearch = GridFunctions.getExcelFilteringSearchComponent(fix, excelMenu); + checkBoxes = esfSearch.querySelectorAll('igx-checkbox'); + + expect((checkBoxes[1].querySelector('.igx-checkbox__label') as HTMLElement).innerText).toEqual('€000.000'); + expect((checkBoxes[3].querySelector('.igx-checkbox__label') as HTMLElement).innerText).toEqual('€198.000'); + })); + + }); + + describe('Data type percent column tests', () => { + it('should display correctly the data when column dataType is percent', () => { + const fix = TestBed.createComponent(IgxGridPercentColumnComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + let discountColumn = grid.getColumnByName('Discount'); + + expect(discountColumn._cells[0].nativeElement.innerText).toEqual('27%'); + expect(discountColumn._cells[5].nativeElement.innerText).toEqual('2.7%'); + expect(discountColumn._cells[8].nativeElement.innerText).toEqual('12.3%'); + + discountColumn.pipeArgs = { + digitsInfo: '3.2-2', + }; + fix.detectChanges(); + + grid.sort({ fieldName: 'Discount', dir: SortingDirection.Desc, ignoreCase: false }); + fix.detectChanges(); + + grid.clearSort(); + fix.detectChanges(); + + discountColumn = grid.getColumnByName('Discount'); + expect(discountColumn._cells[0].nativeElement.innerText).toEqual('027.00%'); + expect(discountColumn._cells[5].nativeElement.innerText).toEqual('002.70%'); + expect(discountColumn._cells[8].nativeElement.innerText).toEqual('012.30%'); + }); + + it('should be able to change the locale runtime ', () => { + const fix = TestBed.createComponent(IgxGridPercentColumnComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + let discountColumn = grid.getColumnByName('Discount'); + + expect(discountColumn._cells[8].nativeElement.innerText).toEqual('12.3%'); + grid.locale = 'fr-FR'; + fix.detectChanges(); + + grid.sort({ fieldName: 'Discount', dir: SortingDirection.Desc, ignoreCase: false }); + fix.detectChanges(); + + grid.clearSort(); + fix.detectChanges(); + + discountColumn = grid.getColumnByName('Discount'); + expect(discountColumn._cells[8].nativeElement.innerText).toEqual('12,3 %'); + expect(discountColumn._cells[5].nativeElement.innerText).toEqual('2,7 %'); + }); + + it('should preview the percent value correctly when cell is in edit mode correctly', fakeAsync(() => { + const fix = TestBed.createComponent(IgxGridPercentColumnComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + const discountColumn = grid.getColumnByName('Discount'); + discountColumn.editable = true; + fix.detectChanges(); + + let firstCell = discountColumn._cells[0]; + + expect(firstCell.nativeElement.innerText).toEqual('27%'); + + firstCell.setEditMode(true); + fix.detectChanges(); + tick(); + + let input = firstCell.nativeElement.querySelector('.igx-input-group__input'); + const prefix = firstCell.nativeElement.querySelector('igx-prefix'); + let suffix = firstCell.nativeElement.querySelector('igx-suffix'); + expect((input as any).value).toEqual('0.27'); + expect(prefix).toBeNull(); + expect((suffix as HTMLElement).innerText).toEqual('27%'); + + UIInteractions.clickAndSendInputElementValue(input, 0.33); + fix.detectChanges(); + tick(); + + input = firstCell.nativeElement.querySelector('.igx-input-group__input'); + suffix = firstCell.nativeElement.querySelector('igx-suffix'); + expect((input as any).value).toEqual('0.33'); + expect((suffix as HTMLElement).innerText).toEqual('33%'); + + grid.gridAPI.crudService.endEdit(true); + fix.detectChanges(); + + firstCell = discountColumn._cells[0]; + expect(firstCell.nativeElement.innerText).toEqual('33%'); + })); + + it('should display summaries correctly for currency column', () => { + const fix = TestBed.createComponent(IgxGridPercentColumnComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + const discountColumn = grid.getColumnByName('Discount'); + discountColumn.hasSummary = true; + fix.detectChanges(); + + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['10', '-70%', '1,100%', '2,153.9%', '215.39%']); + }); + + it('filtering UI list should be populated with correct values based on the currency code, locale and/or pipeArgs', fakeAsync(() => { + const fix = TestBed.createComponent(IgxGridPercentColumnComponent); + tick(); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + const unitsColumn = grid.getColumnByName('Discount'); + grid.allowFiltering = true; + grid.filterMode = 'excelStyleFilter'; + fix.detectChanges(); + + GridFunctions.clickExcelFilterIcon(fix, unitsColumn.field); + tick(100); + fix.detectChanges(); + + let excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + let esfSearch = GridFunctions.getExcelFilteringSearchComponent(fix, excelMenu); + let checkBoxes = esfSearch.querySelectorAll('igx-checkbox'); + + expect((checkBoxes[1].querySelector('.igx-checkbox__label') as HTMLElement).innerText).toEqual('-70%'); + expect((checkBoxes[3].querySelector('.igx-checkbox__label') as HTMLElement).innerText).toEqual('2.7%'); + + GridFunctions.clickCancelExcelStyleFiltering(fix); + fix.detectChanges(); + + unitsColumn.pipeArgs = { + digitsInfo: '3.3-3', + currencyCode: 'EUR', + display: 'symbol-narrow' + }; + fix.detectChanges(); + + GridFunctions.clickExcelFilterIcon(fix, unitsColumn.field); + tick(100); + fix.detectChanges(); + + excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + esfSearch = GridFunctions.getExcelFilteringSearchComponent(fix, excelMenu); + checkBoxes = esfSearch.querySelectorAll('igx-checkbox'); + + expect((checkBoxes[1].querySelector('.igx-checkbox__label') as HTMLElement).innerText).toEqual('-070.000%'); + expect((checkBoxes[3].querySelector('.igx-checkbox__label') as HTMLElement).innerText).toEqual('002.700%'); + })); + + }); + + describe('Date, DateTime and Time column tests', () => { + let grid: IgxGridComponent; + let fix: ComponentFixture; + + beforeEach(() => { + fix = TestBed.createComponent(IgxGridDateTimeColumnComponent); + fix.detectChanges(); + + grid = fix.componentInstance.grid; + }); + + it('should display correctly the data when column dataType is dateTime #ivy', () => { + let orderDateColumn = grid.getColumnByName('OrderDate'); + + expect(orderDateColumn._cells[0].nativeElement.innerText.normalize("NFKD")).toEqual('Oct 1, 2015, 11:37:22 AM'); + expect(orderDateColumn._cells[5].nativeElement.innerText.normalize("NFKD")).toEqual('Oct 30, 2019, 4:17:27 PM'); + expect(orderDateColumn._cells[8].nativeElement.innerText.normalize("NFKD")).toEqual('Aug 3, 2021, 3:15:00 PM'); + + orderDateColumn.pipeArgs = { format: 'short' }; + fix.detectChanges(); + + grid.sort({ fieldName: 'Discount', dir: SortingDirection.Desc, ignoreCase: false }); + fix.detectChanges(); + + grid.clearSort(); + fix.detectChanges(); + + orderDateColumn = grid.getColumnByName('OrderDate'); + expect(orderDateColumn._cells[0].nativeElement.innerText.normalize("NFKD")).toEqual('10/1/15, 11:37 AM'); + expect(orderDateColumn._cells[5].nativeElement.innerText.normalize("NFKD")).toEqual('10/30/19, 4:17 PM'); + expect(orderDateColumn._cells[8].nativeElement.innerText.normalize("NFKD")).toEqual('8/3/21, 3:15 PM'); + }); + + it('should display correctly the data when column dataType is time #ivy', () => { + let receiveTime = grid.getColumnByName('ReceiveTime'); + + expect(receiveTime._cells[0].nativeElement.innerText.normalize("NFKD")).toEqual('8:37:11 AM'); + expect(receiveTime._cells[5].nativeElement.innerText.normalize("NFKD")).toEqual('12:47:42 PM'); + + receiveTime.pipeArgs = { format: 'shortTime' }; + fix.detectChanges(); + + grid.sort({ fieldName: 'Discount', dir: SortingDirection.Desc, ignoreCase: false }); + fix.detectChanges(); + + grid.clearSort(); + fix.detectChanges(); + + receiveTime = grid.getColumnByName('ReceiveTime'); + expect(receiveTime._cells[0].nativeElement.innerText.normalize("NFKD")).toEqual('8:37 AM'); + expect(receiveTime._cells[5].nativeElement.innerText.normalize("NFKD")).toEqual('12:47 PM'); + }); + + it('DateTime: should preview the dateTime value correctly when cell is in edit mode correctly', fakeAsync(() => { + const orderColumn = grid.getColumnByName('OrderDate'); + orderColumn.editable = true; + fix.detectChanges(); + + const firstCell = orderColumn._cells[0]; + expect(firstCell.nativeElement.innerText.normalize("NFKD")).toEqual('Oct 1, 2015, 11:37:22 AM'); + + firstCell.setEditMode(true); + fix.detectChanges(); + tick(); + + const firstRow = fix.debugElement.query(By.directive(IgxGridRowComponent)); + const dateTimeEditor = firstRow.query(By.directive(IgxDateTimeEditorDirective)) + .injector.get(IgxDateTimeEditorDirective); + const prefix = firstCell.nativeElement.querySelector('igx-prefix'); + const suffix = firstCell.nativeElement.querySelector('igx-suffix'); + const input = dateTimeEditor.nativeElement; + + // input is not focused yet, so the value is as the display format sets it + expect(input.value).toEqual('Oct 1, 2015, 11:37:22 AM'); + + expect(dateTimeEditor.inputFormat.normalize('NFKC')).toBe('MM/dd/yyyy, hh:mm:ss tt'); + dateTimeEditor.onFocus(); + fix.detectChanges(); + + expect(dateTimeEditor.nativeElement.value.normalize('NFKC')).toEqual('10/01/2015, 11:37:22 AM'); + expect(prefix).toBeNull(); + expect(suffix).toBeNull(); + + dateTimeEditor.value = new Date(2021, 11, 3, 15, 15, 22); + fix.detectChanges(); + + grid.endEdit(true); + fix.detectChanges(); + + expect(firstCell.nativeElement.innerText.normalize("NFKD")).toEqual('Dec 3, 2021, 3:15:22 PM'); + })); + + it('Time: should preview the time value correctly when cell is in edit mode correctly', fakeAsync(() => { + const timeColumn = grid.getColumnByName('ReceiveTime'); + timeColumn.editable = true; + fix.detectChanges(); + + const cell = timeColumn._cells[1]; + + expect(cell.nativeElement.innerText.normalize("NFKD")).toEqual('12:12:02 PM'); + + cell.setEditMode(true); + tick(); + fix.detectChanges(); + + const input = cell.nativeElement.querySelector('.igx-input-group__input'); + const prefix = cell.nativeElement.querySelector('igx-prefix'); + const suffix = cell.nativeElement.querySelector('igx-suffix'); + expect((input as any).value.normalize('NFKD')).toEqual('12:12:02 PM'); + expect(prefix).not.toBeNull(); + expect(suffix).not.toBeNull(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', input, true); + fix.detectChanges(); + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', input, true); + fix.detectChanges(); + + grid.endEdit(true); + fix.detectChanges(); + + expect(cell.nativeElement.innerText.normalize("NFKD")).toEqual('10:12:02 AM'); + })); + + it('should display summaries correctly for dateTime and time column', () => { + const column = grid.getColumnByName('OrderDate'); + const receiveTimeColumn = grid.getColumnByName('ReceiveTime'); + column.hasSummary = true; + receiveTimeColumn.hasSummary = true; + fix.detectChanges(); + + let summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, + ['Count', 'Earliest', 'Latest'], ['10', 'Mar 12, 2015, 9:31:22 PM', 'Aug 3, 2021, 3:15:00 PM']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['10', '6:40:18 AM', '8:20:24 PM']); + + column.pipeArgs = { format: 'short' }; + receiveTimeColumn.pipeArgs = { format: 'shortTime' }; + grid.sort({ fieldName: 'Discount', dir: SortingDirection.Desc, ignoreCase: false }); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, + ['Count', 'Earliest', 'Latest'], ['10', '3/12/15, 9:31 PM', '8/3/21, 3:15 PM']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['10', '6:40 AM', '8:20 PM']); + }); + + it('DateTime: filtering UI list should be populated with correct values based on the pipeArgs', fakeAsync(() => { + const orderDateColumn = grid.getColumnByName('OrderDate'); + grid.allowFiltering = true; + grid.filterMode = 'excelStyleFilter'; + fix.detectChanges(); + + GridFunctions.clickExcelFilterIcon(fix, orderDateColumn.field); + tick(100); + fix.detectChanges(); + + let excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + let esfSearch = GridFunctions.getExcelFilteringSearchComponent(fix, excelMenu); + let checkBoxes = esfSearch.querySelectorAll('igx-checkbox'); + + expect((checkBoxes[1].querySelector('.igx-checkbox__label') as HTMLElement).innerText.normalize("NFKD")).toEqual('Mar 12, 2015, 9:31:22 PM'); + expect((checkBoxes[3].querySelector('.igx-checkbox__label') as HTMLElement).innerText.normalize("NFKD")).toEqual('Aug 18, 2016, 11:17:22 AM'); + + GridFunctions.clickCancelExcelStyleFiltering(fix); + fix.detectChanges(); + + orderDateColumn.pipeArgs = { + format: 'short' + }; + fix.detectChanges(); + + GridFunctions.clickExcelFilterIcon(fix, orderDateColumn.field); + tick(100); + fix.detectChanges(); + + excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + esfSearch = GridFunctions.getExcelFilteringSearchComponent(fix, excelMenu); + checkBoxes = esfSearch.querySelectorAll('igx-checkbox'); + + expect((checkBoxes[1].querySelector('.igx-checkbox__label') as HTMLElement).innerText.normalize("NFKD")).toEqual('3/12/15, 9:31 PM'); + expect((checkBoxes[3].querySelector('.igx-checkbox__label') as HTMLElement).innerText.normalize("NFKD")).toEqual('8/18/16, 11:17 AM'); + })); + + it('Time: filtering UI list should be populated with correct values based on the pipeArgs', fakeAsync(() => { + const timeColumn = grid.getColumnByName('ReceiveTime'); + grid.allowFiltering = true; + fix.detectChanges(); + grid.filterMode = 'excelStyleFilter'; + fix.detectChanges(); + + GridFunctions.clickExcelFilterIcon(fix, timeColumn.field); + tick(200); + fix.detectChanges(); + + let excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + let esfSearch = GridFunctions.getExcelFilteringSearchComponent(fix, excelMenu); + let checkBoxes = esfSearch.querySelectorAll('igx-checkbox'); + + expect((checkBoxes[1].querySelector('.igx-checkbox__label') as HTMLElement).innerText.normalize("NFKD")).toEqual('6:40:18 AM'); + expect((checkBoxes[3].querySelector('.igx-checkbox__label') as HTMLElement).innerText.normalize("NFKD")).toEqual('12:12:02 PM'); + GridFunctions.clickCancelExcelStyleFiltering(fix); + tick(200); + fix.detectChanges(); + + timeColumn.pipeArgs = { format: 'shortTime' }; + fix.detectChanges(); + + GridFunctions.clickExcelFilterIcon(fix, timeColumn.field); + tick(200); + fix.detectChanges(); + + excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + esfSearch = GridFunctions.getExcelFilteringSearchComponent(fix, excelMenu); + checkBoxes = esfSearch.querySelectorAll('igx-checkbox'); + + expect((checkBoxes[1].querySelector('.igx-checkbox__label') as HTMLElement).innerText.normalize("NFKD")).toEqual('6:40 AM'); + expect((checkBoxes[3].querySelector('.igx-checkbox__label') as HTMLElement).innerText.normalize("NFKD")).toEqual('12:12 PM'); + })); + + it('DateTime: dateTime input should be disabled when try to filter based on unary conditions - today or etc. #ivy', fakeAsync(() => { + const orderDateColumn = grid.getColumnByName('OrderDate'); + grid.allowFiltering = true; + grid.filterMode = 'excelStyleFilter'; + fix.detectChanges(); + + GridFunctions.clickExcelFilterIcon(fix, orderDateColumn.field); + tick(100); + fix.detectChanges(); + GridFunctions.clickExcelFilterCascadeButton(fix); + tick(100); + fix.detectChanges(); + GridFunctions.clickOperatorFromCascadeMenu(fix, 4); + tick(200); + fix.detectChanges(); + + const inputElement = fix.debugElement.query(By.css('igx-input-group.igx-input-group--disabled')); + expect(inputElement).not.toBeNull(); + })); + + it('Date/Time/DateTime: Set editorOptions.dateTimeFormat as inputFormat for default cell editor', fakeAsync(() => { + const producedDateColumn = grid.getColumnByName('ProducedDate'); + const orderDateColumn = grid.getColumnByName('OrderDate'); + const receiveTimeColumn = grid.getColumnByName('ReceiveTime'); + + producedDateColumn.editorOptions = { dateTimeFormat: 'yyyy-MM-dd' }; + orderDateColumn.editorOptions = { dateTimeFormat: 'yyyy--MM--dd' }; + receiveTimeColumn.editorOptions = { dateTimeFormat: 'h-mm-ss aaaaa' }; + fix.detectChanges(); + + producedDateColumn._cells[0].setEditMode(true) + fix.detectChanges(); + tick(); + + let inputDebugElement = fix.debugElement.query(By.directive(IgxInputDirective)); + let dateTimeEditor = inputDebugElement.injector.get(IgxDateTimeEditorDirective); + dateTimeEditor.nativeElement.focus(); + tick(16); + fix.detectChanges(); + + expect((dateTimeEditor.nativeElement as any).value).toEqual('2014-10-01'); + + orderDateColumn._cells[0].setEditMode(true) + fix.detectChanges(); + tick(); + + inputDebugElement = fix.debugElement.query(By.directive(IgxInputDirective)); + dateTimeEditor = inputDebugElement.injector.get(IgxDateTimeEditorDirective); + dateTimeEditor.nativeElement.focus(); + tick(16); + fix.detectChanges(); + + expect((dateTimeEditor.nativeElement as any).value).toEqual('2015--10--01'); + + receiveTimeColumn._cells[0].setEditMode(true) + fix.detectChanges(); + tick(); + + inputDebugElement = fix.debugElement.query(By.directive(IgxInputDirective)); + dateTimeEditor = inputDebugElement.injector.get(IgxDateTimeEditorDirective); + dateTimeEditor.nativeElement.focus(); + tick(16); + fix.detectChanges(); + + expect(dateTimeEditor.nativeElement.value).toEqual('08-37-11 a'); + })); + + it('DateTime: Use pipeArgs.format as inputFormat for cell editor if numeric and editorOptions.dateTimeFormat is unset', fakeAsync(() => { + const orderDateColumn = grid.getColumnByName('OrderDate'); + const firstCell = orderDateColumn._cells[0]; + expect(firstCell.nativeElement.innerText.normalize('NFKD')).toEqual('Oct 1, 2015, 11:37:22 AM'); + + orderDateColumn.pipeArgs = { format: 'dd-MM-yyyy hh:mm aa' }; + fix.detectChanges(); + + expect(firstCell.nativeElement.innerText.normalize('NFKD')).toEqual('01-10-2015 11:37 AM'); + + firstCell.setEditMode(true); + fix.detectChanges(); + + let inputDebugElement = fix.debugElement.query(By.directive(IgxInputDirective)); + let dateTimeEditor = inputDebugElement.injector.get(IgxDateTimeEditorDirective); + firstCell.setEditMode(true); + fix.detectChanges(); + + dateTimeEditor.nativeElement.focus(); + tick(16); + fix.detectChanges(); + + expect((dateTimeEditor.nativeElement as any).value).toEqual('01-10-2015 11:37 AM'); + + orderDateColumn.pipeArgs = { format: 'MMM d, y, h:mm:ss a' }; + firstCell.setEditMode(false); + fix.detectChanges(); + + expect(firstCell.nativeElement.innerText).toEqual('Oct 1, 2015, 11:37:22 AM'); + + firstCell.setEditMode(true); + fix.detectChanges(); + + inputDebugElement = fix.debugElement.query(By.directive(IgxInputDirective)); + dateTimeEditor = inputDebugElement.injector.get(IgxDateTimeEditorDirective); + dateTimeEditor.nativeElement.focus(); + tick(16); + fix.detectChanges(); + + // resolve back to the default format for the locale since the pipeArgs.format is not numeric + expect(dateTimeEditor.nativeElement.value.normalize('NFKC')).toEqual('10/01/2015, 11:37:22 AM'); + })); + + it('Date: Use pipeArgs.format as inputFormat for cell editor if numeric and editorOptions.dateTimeFormat is unset', fakeAsync(() => { + const producedDateColumn = grid.getColumnByName('ProducedDate'); + const firstCell = producedDateColumn._cells[0]; + expect(firstCell.nativeElement.innerText).toEqual('Oct 1, 2014'); + + producedDateColumn.pipeArgs = { format: 'dd-MM-yyyy' }; + fix.detectChanges(); + + expect(firstCell.nativeElement.innerText).toEqual('01-10-2014'); + + firstCell.setEditMode(true); + fix.detectChanges(); + tick(); + + let inputDebugElement = fix.debugElement.query(By.directive(IgxInputDirective)); + let dateTimeEditor = inputDebugElement.injector.get(IgxDateTimeEditorDirective); + dateTimeEditor.nativeElement.focus(); + tick(16); + fix.detectChanges(); + + expect((dateTimeEditor.nativeElement as any).value).toEqual('01-10-2014'); + + producedDateColumn.pipeArgs = { format: 'MMM d, y' }; + firstCell.setEditMode(false); + fix.detectChanges(); + + expect(firstCell.nativeElement.innerText).toEqual('Oct 1, 2014'); + + firstCell.setEditMode(true); + fix.detectChanges(); + tick(); + + inputDebugElement = fix.debugElement.query(By.directive(IgxInputDirective)); + dateTimeEditor = inputDebugElement.injector.get(IgxDateTimeEditorDirective); + dateTimeEditor.nativeElement.focus(); + tick(16); + fix.detectChanges(); + + // resolve back to the default format for the locale since the pipeArgs.format is not numeric + expect(dateTimeEditor.nativeElement.value).toEqual('10/01/2014'); + })); + + it('Time: Use pipeArgs.format as inputFormat for cell editor if numeric and editorOptions.dateTimeFormat is unset', fakeAsync(() => { + const receivedTimeColumn = grid.getColumnByName('ReceiveTime'); + const firstCell = receivedTimeColumn._cells[0]; + expect(firstCell.nativeElement.innerText.normalize('NFKD')).toEqual('8:37:11 AM'); + + receivedTimeColumn.pipeArgs = { format: 'h-mm-ss aaaaa' }; + fix.detectChanges(); + + expect(firstCell.nativeElement.innerText.normalize('NFKD')).toEqual('8-37-11 a'); + + firstCell.setEditMode(true); + fix.detectChanges(); + tick(); + + let inputDebugElement = fix.debugElement.query(By.directive(IgxInputDirective)); + let dateTimeEditor = inputDebugElement.injector.get(IgxDateTimeEditorDirective); + dateTimeEditor.nativeElement.focus(); + tick(16); + fix.detectChanges(); + + expect((dateTimeEditor.nativeElement as any).value.normalize('NFKC')).toEqual('08-37-11 a'); + + receivedTimeColumn.pipeArgs = { format: 'longTime' }; + firstCell.setEditMode(false); + fix.detectChanges(); + + expect(firstCell.nativeElement.innerText.normalize('NFKD')).toContain('8:37:11 AM GMT'); + + firstCell.setEditMode(true); + fix.detectChanges(); + + inputDebugElement = fix.debugElement.query(By.directive(IgxInputDirective)); + dateTimeEditor = inputDebugElement.injector.get(IgxDateTimeEditorDirective); + dateTimeEditor.nativeElement.focus(); + tick(16); + fix.detectChanges(); + + // resolve back to the default time format since the pipeArgs.format is not numeric + expect((dateTimeEditor.nativeElement as any).value.normalize('NFKC')).toEqual('08:37 AM'); + })); + + it('Date/Time/DateTime: Use default locale format as inputFormat when editorOptions/pipeArgs formats are null/empty ', fakeAsync(() => { + const producedDateColumn = grid.getColumnByName('ProducedDate'); + const orderDateColumn = grid.getColumnByName('OrderDate'); + const receiveTimeColumn = grid.getColumnByName('ReceiveTime'); + + + producedDateColumn.editorOptions = null; + orderDateColumn.editorOptions.dateTimeFormat = ''; + receiveTimeColumn.pipeArgs = { + format: undefined + }; + fix.detectChanges(); + + producedDateColumn._cells[0].setEditMode(true) + fix.detectChanges(); + + let inputDebugElement = fix.debugElement.query(By.directive(IgxInputDirective)); + let dateTimeEditor = inputDebugElement.injector.get(IgxDateTimeEditorDirective); + dateTimeEditor.nativeElement.focus(); + tick(16); + fix.detectChanges(); + + expect(dateTimeEditor.nativeElement.value).toEqual('10/01/2014'); + + orderDateColumn._cells[0].setEditMode(true) + fix.detectChanges(); + + inputDebugElement = fix.debugElement.query(By.directive(IgxInputDirective)); + dateTimeEditor = inputDebugElement.injector.get(IgxDateTimeEditorDirective); + dateTimeEditor.nativeElement.focus(); + tick(16); + fix.detectChanges(); + + expect(dateTimeEditor.nativeElement.value.normalize('NFKC')).toEqual('10/01/2015, 11:37:22 AM'); + + receiveTimeColumn._cells[0].setEditMode(true) + fix.detectChanges(); + + inputDebugElement = fix.debugElement.query(By.directive(IgxInputDirective)); + dateTimeEditor = inputDebugElement.injector.get(IgxDateTimeEditorDirective); + dateTimeEditor.nativeElement.focus(); + tick(16); + fix.detectChanges(); + + expect(dateTimeEditor.nativeElement.value.normalize('NFKC')).toEqual('08:37 AM'); + })); + + it('Sorting dateTime column', () => { + const currColumn = 'OrderDate'; + grid.sort({ fieldName: currColumn, dir: SortingDirection.Asc, ignoreCase: false }); + fix.detectChanges(); + + const sortedValues = [new Date(2015, 2, 12, 21, 31, 22), new Date(2015, 9, 1, 11, 37, 22), new Date(2016, 7, 18, 11, 17, 22), + new Date(2018, 6, 14, 17, 27, 23), new Date(2019, 3, 17, 5, 5, 15), new Date(2019, 9, 30, 16, 17, 27), + new Date(2021, 4, 11, 7, 47, 1), new Date(2021, 4, 11, 18, 37, 2), + new Date(2021, 7, 3, 15, 15, 0), new Date(2021, 7, 3, 15, 15, 0)]; + + expect(grid.rowList.length).toEqual(sortedValues.length); + sortedValues.forEach((value, index) => { + expect(grid.getCellByColumn(index, currColumn).value.toISOString()).toEqual(value.toISOString()); + }); + + grid.sort({ fieldName: currColumn, dir: SortingDirection.Desc, ignoreCase: false }); + fix.detectChanges(); + + expect(grid.rowList.length).toEqual(sortedValues.length); + sortedValues.forEach((value, index) => { + expect(grid.getCellByColumn(sortedValues.length - 1 - index, currColumn).value.toISOString()).toEqual(value.toISOString()); + }); + }); + + it('Sorting time column', () => { + const currentColumn = 'ReceiveTime'; + grid.sort({ fieldName: currentColumn, dir: SortingDirection.Asc, ignoreCase: false }); + fix.detectChanges(); + + const sortedValues = ['6:40:18 AM', '8:37:11 AM', '12:12:02 PM', '12:47:42 PM', '12:47:42 PM', '2:07:12 PM', + '2:30:00 PM', '3:30:22 PM', '3:30:30 PM', '8:20:24 PM']; + + expect(grid.rowList.length).toEqual(sortedValues.length); + sortedValues.forEach((value, index) => { + expect(grid.getCellByColumn(index, currentColumn).value.toLocaleTimeString()).toEqual(value); + }); + + grid.sort({ fieldName: currentColumn, dir: SortingDirection.Desc, ignoreCase: false }); + fix.detectChanges(); + + expect(grid.rowList.length).toEqual(sortedValues.length); + sortedValues.forEach((value, index) => { + expect(grid.getCellByColumn(sortedValues.length - 1 - index, currentColumn).value.toLocaleTimeString()).toEqual(value); + }); + }); + + }); + + describe('Data type image column tests', () => { + let fix: ComponentFixture; + let grid: IgxGridComponent; + const dataWithImages = [{ + avatar: './test-utils/assets/images/avatar/1.jpg', + phone: '770-504-2217', + text: 'Terrance Orta', + available: false + }, { + avatar: './test-utils/assets/images/avatar/2.jpg', + phone: '423-676-2869', + text: 'Richard Mahoney', + available: true + }, { + avatar: './test-utils/assets/images/avatar/3.jpg', + phone: '859-496-2817', + text: 'Donna Price', + available: true + }, { + avatar: './test-utils/assets/images/avatar/4.jpg', + phone: '901-747-3428', + text: 'Lisa Landers', + available: true + }, { + avatar: './test-utils/assets/images/avatar/12.jpg', + phone: '573-394-9254', + text: 'Dorothy H. Spencer', + available: true + }, { + avatar: './test-utils/assets/images/avatar/13.jpg', + phone: '323-668-1482', + text: 'Stephanie May', + available: false + }, { + avatar: './test-utils/assets/images/avatar/14.jpg', + phone: '401-661-3742', + text: 'Marianne Taylor', + available: true + }]; + + beforeEach(waitForAsync(() => { + fix = TestBed.createComponent(IgxGridComponent); + grid = fix.componentInstance; + // For test fixture destroy + grid.id = "root1"; + grid.data = dataWithImages; + grid.autoGenerate = true; + fix.detectChanges(); + })); + + it('should initialize correctly with autoGenerate and image data', () => { + const column = grid.getColumnByName('avatar'); + expect(column.dataType).toBe(GridColumnDataType.Image); + expect(column.sortable).toBeFalse(); + expect(column.groupable).toBeFalse(); + expect(column.filterable).toBeFalse(); + expect(column.editable).toBeFalse(); + expect(column.hasSummary).toBeFalse(); + + const cell = column._cells[0]; + expect(cell.nativeElement.firstElementChild.tagName).toBe('IMG'); + expect(cell.nativeElement.firstElementChild.getAttribute('src')).toBe('./test-utils/assets/images/avatar/1.jpg'); + expect(cell.nativeElement.firstElementChild.getAttribute('alt')).toBe('1'); + }); + + }); + + describe('Auto-sizing with width auto: ', () => { + it('should auto-size column in view on init.', fakeAsync(() => { + const fix = TestBed.createComponent(ResizableColumnsComponent); + fix.detectChanges(); + tick(); + const grid = fix.componentInstance.instance; + expect(grid.columns[0].width).toBe('95px'); + expect(grid.columns[1].width).toBe('207px'); + })); + + it('should auto-size within minWidth/maxWidth bounds', fakeAsync(() => { + const fix = TestBed.createComponent(ResizableColumnsComponent); + fix.componentInstance.columns = [ + { field: 'ID', width: 'auto', minWidth: '100px', maxWidth: '200px' }, + { field: 'Address', minWidth: '100px', maxWidth: '200px', width: 'auto' } + ]; + fix.detectChanges(); + tick(); + const grid = fix.componentInstance.instance; + expect(grid.columns[0].width).toBe('100px'); + expect(grid.columns[1].width).toBe('200px'); + })); + + it('should auto-size column when scrolled into view.', (async () => { + const fix = TestBed.createComponent(ResizableColumnsComponent); + fix.componentInstance.columns = [ + { field: 'ID', width: 'auto' }, + { field: 'CompanyName', width: 'auto' }, + { field: 'ContactName', width: 'auto' }, + { field: 'ContactTitle', width: 'auto' }, + { field: 'Address', width: 'auto' }, + { field: 'City', width: 'auto' }, + { field: 'Region', width: 'auto' }, + { field: 'PostalCode', width: 'auto' }, + { field: 'Phone', width: 'auto' }, + { field: 'Fax', width: 'auto' } + ]; + fix.detectChanges(); + await wait(); + const grid = fix.componentInstance.instance; + // initially no autoSize + expect(grid.columns.find(x => x.field === 'Fax').width).toBe('fit-content'); + // scroll last column in view + grid.navigateTo(0, 9); + await wait(100); + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + // check size after it comes in view + expect(grid.columns.find(x => x.field === 'Fax').width).toBe('130px'); + })); + + it('should auto-size correctly when cell has custom template', fakeAsync(() => { + const fix = TestBed.createComponent(ResizableColumnsComponent); + const grid = fix.componentInstance.instance; + fix.detectChanges(); + const col = grid.columns[0]; + col.bodyTemplate = fix.componentInstance.customTemplate; + fix.detectChanges(); + tick(); + expect(col.width).toBe('137px'); + })); + + it('should auto-size after an initially hidden column is shown.', fakeAsync(() => { + const fix = TestBed.createComponent(ResizableColumnsComponent); + fix.componentInstance.columns = [ + { field: 'ID', width: 'auto', hidden: true }, + { field: 'Address', minWidth: '100px', maxWidth: '200px', width: 'auto' } + ]; + fix.detectChanges(); + tick(); + const grid = fix.componentInstance.instance; + const col = grid.columns[0]; + expect(col.width).toBe('fit-content'); + col.hidden = false; + fix.detectChanges(); + tick(); + expect(col.width).toBe('95px'); + })); + + it('should auto-size initially pinned column.', fakeAsync(() => { + const fix = TestBed.createComponent(ResizableColumnsComponent); + fix.componentInstance.columns = [ + { field: 'ID', width: 'auto', pinned: true }, + { field: 'Address', minWidth: '100px', maxWidth: '200px', width: 'auto' } + ]; + fix.detectChanges(); + tick(); + const grid = fix.componentInstance.instance; + const pinnedCol = grid.pinnedColumns[0]; + expect(pinnedCol.width).toBe('97px'); + })); + + it('should auto-size columns added in view after grid is resized', fakeAsync(() => { + const fix = TestBed.createComponent(ResizableColumnsComponent); + fix.componentInstance.columns = [ + { field: 'ID', width: 'auto' }, + { field: 'CompanyName', width: 'auto' }, + { field: 'ContactName', width: 'auto' }, + { field: 'ContactTitle', width: 'auto' }, + { field: 'Address', width: 'auto' }, + { field: 'City', width: 'auto' }, + { field: 'Region', width: 'auto' }, + { field: 'PostalCode', width: 'auto' }, + { field: 'Phone', width: 'auto' }, + { field: 'Fax', width: 'auto' } + ]; + fix.detectChanges(); + tick(); + const grid = fix.componentInstance.instance; + const lastCol = grid.columns[grid.columns.length - 1]; + expect(lastCol.width).toBe('fit-content'); + // resize grid so that all columns are in view + grid.width = '1500px'; + fix.detectChanges(); + tick(); + fix.detectChanges(); + const widths = grid.columns.map(x => x.width); + expect(widths).toEqual(['95px', '240px', '149px', '159px', '207px', '114px', '86px', '108px', '130px', '130px']); + })); + + it('should auto-size on initial data loaded.', fakeAsync(() => { + const fix = TestBed.createComponent(ResizableColumnsComponent); + fix.componentInstance.data = []; + fix.componentInstance.columns = [ + { field: 'ID', width: 'auto' }, + { field: 'CompanyName', width: 'auto' }, + { field: 'ContactName', width: 'auto' }, + { field: 'ContactTitle', width: 'auto' }, + { field: 'Address', width: 'auto' }, + { field: 'City', width: 'auto' }, + { field: 'Region', width: 'auto' }, + { field: 'PostalCode', width: 'auto' }, + { field: 'Phone', width: 'auto' }, + { field: 'Fax', width: 'auto' } + ]; + const grid = fix.componentInstance.instance; + // resize grid so that all columns are in view + grid.width = '1500px'; + fix.detectChanges(); + tick(); + + let widths = grid.columns.map(x => x.width); + // default min of 80px is disregarded for user-set widths, including auto. + expect(widths).toEqual(['68px', '130px', '121px', '114px', '92px', '72px', '86px', '108px', '82px', '69px']); + fix.componentInstance.data = SampleTestData.contactInfoData(); + fix.detectChanges(); + tick(); + + widths = grid.columns.map(x => x.width); + expect(widths).toEqual(['95px', '240px', '149px', '159px', '207px', '114px', '86px', '108px', '130px', '130px']); + })); + + it('should recalculate sizes via the recalculateAutoSizes API ', fakeAsync(() => { + const fix = TestBed.createComponent(ResizableColumnsComponent); + fix.detectChanges(); + tick(); + const grid = fix.componentInstance.instance; + expect(grid.columns[0].width).toBe('95px'); + expect(grid.columns[1].width).toBe('207px'); + + grid.data = [ + { + ID: 'VeryVeryVeryLongID', + Address: 'Avda. de la Constitución 2222 Obere Str. 57' + } + ]; + fix.detectChanges(); + // no width change on new data. + expect(grid.columns[0].width).toBe('95px'); + expect(grid.columns[1].width).toBe('207px'); + + + // use api to force recalculation + grid.recalculateAutoSizes(); + fix.detectChanges(); + tick(); + expect(grid.columns[0].width).toBe('164px'); + expect(grid.columns[1].width).toBe('279px'); + })); + }); + + + describe('DOM attributes as setters', () => { + it('successfully renders a grid with DOM attributes as setters', fakeAsync(() => { + const fixture = TestBed.createComponent(DOMAttributesAsSettersComponent); + fixture.detectChanges(); + tick(); + + const grid = fixture.componentInstance.instance; + const column = grid.getColumnByName('id'); + + const gridAttributes = ` + moving + hideRowSelectors + rowDraggable + rowEditable + allowFiltering + allowAdvancedFiltering + showSummaryOnCollapse + batchEditing + selectRowOnClick + groupsExpanded + hideGroupedColumns + showGroupArea + `.split('\n') + .map(attr => attr.trim()) + .filter(attr => Boolean(attr)); + + const columnAttributes = ` + sortable + groupable + editable + filterable + resizable + autosizeHeader + hasSummary + hidden + disableHiding + disablePinning + filteringIgnoreCase + sortingIgnoreCase + searchable + pinned + visibleWhenCollapsed + `.split('\n') + .map(attr => attr.trim()) + .filter(attr => Boolean(attr)); + + for (const attr of gridAttributes) { + expect(grid[attr]).toBe(true, `Grid attribute: '${attr}' failed`); + } + + for (const attr of columnAttributes) { + expect(column[attr]).toBe(true, `Column attribute: '${attr}' failed`); + } + })) + }); +}); + +@Component({ + template: GridTemplateStrings.declareGrid('', '', ColumnDefinitions.iterableComponent), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class ColumnsFromIterableComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public instance: IgxGridComponent; + + public data = SampleTestData.personIDNameData(); + public columns = ['ID', 'Name']; +} + +interface IColumnConfig { + field: string, + width: string, + minWidth?: string; + maxWidth?: string; + hidden?: boolean; + pinned?: boolean; +} + +@Component({ + template: GridTemplateStrings.declareGrid(`height="800px" width="400px"`, ``, ColumnDefinitions.resizableColsComponent) + + ` + + + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxButtonDirective] +}) +export class ResizableColumnsComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public instance: IgxGridComponent; + + @ViewChild('customTemplate', { read: TemplateRef, static: true }) + public customTemplate: TemplateRef; + + public data = SampleTestData.contactInfoData(); + public columns: IColumnConfig[] = [ + { field: 'ID', width: 'auto' }, + { field: 'Address', minWidth: '100px', maxWidth: '400px', width: 'auto' } + ]; +} + +@Component({ + template: GridTemplateStrings.declareGrid('', '', ColumnDefinitions.columnTemplates) + + ` + New header text + + + + New cell text + + + + New summary text + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxCellTemplateDirective, IgxCellHeaderTemplateDirective, IgxCellFooterTemplateDirective, IgxSummaryTemplateDirective] +}) +export class TemplatedColumnsComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public instance: IgxGridComponent; + + @ViewChild('newHeader', { read: TemplateRef, static: true }) + public newHeaderTemplate: TemplateRef; + + @ViewChild('newCell', { read: TemplateRef, static: true }) + public newCellTemplate: TemplateRef; + + @ViewChild('newSummary', { read: TemplateRef, static: true }) + public newSummaryTemplate: TemplateRef; + + public data = SampleTestData.personIDNameData(); +} + +@Component({ + template: ` + + @for (field of columns; track field) { + + + } + + + + {{ value }} + + + + {{ column.field }} + + + + {{ value }} + + + + {{ summaryResults[0].label }}: {{ summaryResults[0].summaryResult }} + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxSummaryTemplateDirective] +}) +export class TemplatedInputColumnsComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public instance: IgxGridComponent; + + public data = SampleTestData.personIDNameRegionData(); + public columns = Object.keys(this.data[0]); +} + + +@Component({ + template: ` + + + + {{ cell.column.additionalTemplateContext.property1 }} + + + + + {{ props.property2 }} + + + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxCellTemplateDirective] +}) +export class TemplatedContextInputColumnsComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public instance: IgxGridComponent; + public contextObject = { property1: 'cellContent', property2: 'cellContent1' }; + + public data = SampleTestData.personNameAgeData(); +} + +@Component({ + template: ` + + + + + + + + + + + `, + styles: [`.headerAlignSyle {text-align: right !important;}`], + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class ColumnHaederClassesComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + + public data = [ + { ProductId: 1, Number1: 11, Number2: 10, Number3: 5, Number4: 3, Number5: 4, Number6: 6, Number7: 7 } + ]; +} + +@Component({ + template: ` + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class DOMAttributesAsSettersComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public instance: IgxGridComponent; + + public data = [{ id: 1, value: 1 }]; +} diff --git a/projects/igniteui-angular/grids/grid/src/expandable-cell.component.html b/projects/igniteui-angular/grids/grid/src/expandable-cell.component.html new file mode 100644 index 00000000000..c6fca1627c8 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/expandable-cell.component.html @@ -0,0 +1,262 @@ + + @if (displayPinnedChip) { + {{ grid.resourceStrings.igx_grid_pinned_row_indicator }} + } + + + @if (column.dataType !== 'boolean' && column.dataType !== 'image' || (column.dataType === 'boolean' && this.formatter)) { +
    + {{ formatter ? (value | columnFormatter:formatter:rowData) : column.dataType === "number" + ? (value | number:column.pipeArgs.digitsInfo:grid.locale) : (column.dataType === 'date' || column.dataType === 'time' || column.dataType === 'dateTime') + ? (value | date:column.pipeArgs.format:column.pipeArgs.timezone:grid.locale) : column.dataType === 'currency' + ? (value | currency:currencyCode:column.pipeArgs.display:column.pipeArgs.digitsInfo:grid.locale) : column.dataType === 'percent' + ? (value | percent:column.pipeArgs.digitsInfo:grid.locale) : value}}
    + } + + @if (column.dataType === 'boolean' && !this.formatter) { +
    + + +
    + } + @if (column.dataType === 'image') { + + } +
    + + @if (column.dataType !== 'boolean' || (column.dataType === 'boolean' && this.formatter)) { +
    + {{ value ? value : (column.header || column.field) }} +
    + } +
    + + @if (column.dataType === 'string' || column.dataType === 'image') { + + + + + + } + @if (column.dataType === 'number') { + + + + } + @if (column.dataType === 'boolean') { + + + + } + @if (column.dataType === 'date') { + + + + + } + @if (column.dataType === 'time') { + + + + } + @if (column.dataType === 'dateTime') { + + + + } + @if (column.dataType === 'currency') { + + @if (grid.currencyPositionLeft) { + {{ currencyCodeSymbol }} + } + + @if (!grid.currencyPositionLeft) { + {{ currencyCodeSymbol }} + } + + } + @if (column.dataType === 'percent') { + + + {{ editValue | percent:column.pipeArgs.digitsInfo:grid.locale }} + + } + +@if (showExpanderIndicator) { +
    + + +
    +} + + + + +@if (isInvalid) { + + +
    +
    + +
    +
    +} + + + + + + + + + + @if (formGroup?.get(column?.field).errors?.['required']) { +
    + {{grid.resourceStrings.igx_grid_required_validation_error}} +
    + } + @if (formGroup?.get(column?.field).errors?.['minlength']) { +
    + {{grid.resourceStrings.igx_grid_min_length_validation_error | igxStringReplace:'{0}':formGroup.get(column.field).errors.minlength.requiredLength }} +
    + } + @if (formGroup?.get(column?.field).errors?.['maxlength']) { +
    + {{grid.resourceStrings.igx_grid_max_length_validation_error | igxStringReplace:'{0}':formGroup.get(column.field).errors.maxlength.requiredLength }} +
    + } + @if (formGroup?.get(column?.field).errors?.['min']) { +
    + {{grid.resourceStrings.igx_grid_min_validation_error | igxStringReplace:'{0}':formGroup.get(column.field).errors.min.min }} +
    + } + @if (formGroup?.get(column?.field).errors?.['max']) { +
    + {{grid.resourceStrings.igx_grid_max_validation_error | igxStringReplace:'{0}':formGroup.get(column.field).errors.max.max }} +
    + } + @if (formGroup?.get(column?.field).errors?.['email']) { +
    + {{grid.resourceStrings.igx_grid_email_validation_error }} +
    + } + @if (formGroup?.get(column?.field).errors?.['pattern']) { +
    + {{grid.resourceStrings.igx_grid_pattern_validation_error}} +
    + } +
    diff --git a/projects/igniteui-angular/grids/grid/src/expandable-cell.component.ts b/projects/igniteui-angular/grids/grid/src/expandable-cell.component.ts new file mode 100644 index 00000000000..d14df770817 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/expandable-cell.component.ts @@ -0,0 +1,110 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + Input, + OnInit, + TemplateRef, + ViewChild, + DOCUMENT, + inject +} from '@angular/core'; +import { NgClass, NgTemplateOutlet, DecimalPipe, PercentPipe, CurrencyPipe, DatePipe } from '@angular/common'; +import { + IgxColumnFormatterPipe, + IgxGridCellComponent, + IgxGridCellImageAltPipe, + IgxStringReplacePipe +} from 'igniteui-angular/grids/core'; +import { HammerGesturesManager } from 'igniteui-angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { IgxChipComponent } from 'igniteui-angular/chips'; +import { IgxDateTimeEditorDirective, IgxFocusDirective, IgxTextHighlightDirective, IgxTooltipDirective, IgxTooltipTargetDirective } from 'igniteui-angular/directives'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxInputDirective, IgxInputGroupComponent, IgxPrefixDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; +import { IgxCheckboxComponent } from 'igniteui-angular/checkbox'; +import { IgxDatePickerComponent } from 'igniteui-angular/date-picker'; +import { IgxTimePickerComponent } from 'igniteui-angular/time-picker'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'igx-expandable-grid-cell', + templateUrl: 'expandable-cell.component.html', + providers: [HammerGesturesManager], + imports: [IgxChipComponent, IgxTextHighlightDirective, IgxIconComponent, NgClass, FormsModule, ReactiveFormsModule, IgxInputGroupComponent, IgxInputDirective, IgxFocusDirective, IgxCheckboxComponent, IgxDatePickerComponent, IgxTimePickerComponent, IgxDateTimeEditorDirective, IgxPrefixDirective, IgxSuffixDirective, NgTemplateOutlet, IgxTooltipTargetDirective, IgxTooltipDirective, IgxGridCellImageAltPipe, IgxStringReplacePipe, IgxColumnFormatterPipe, DecimalPipe, PercentPipe, CurrencyPipe, DatePipe] +}) +export class IgxGridExpandableCellComponent extends IgxGridCellComponent implements OnInit { + public document = inject(DOCUMENT); + + /** + * @hidden + */ + @Input() + public expanded = false; + + @ViewChild('indicator', { read: ElementRef }) + public indicator: ElementRef; + + @ViewChild('indentationDiv', { read: ElementRef }) + public indentationDiv: ElementRef; + + /** + * @hidden + */ + @ViewChild('defaultExpandedTemplate', { read: TemplateRef, static: true }) + protected defaultExpandedTemplate: TemplateRef; + + /** + * @hidden + */ + @ViewChild('defaultCollapsedTemplate', { read: TemplateRef, static: true }) + protected defaultCollapsedTemplate: TemplateRef; + + /** + * @hidden + */ + public toggle(event: Event) { + event.stopPropagation(); + const expansionState = this.grid.gridAPI.get_row_expansion_state(this.intRow.data); + this.grid.gridAPI.set_row_expansion_state(this.intRow.key, !expansionState, event); + } + + /** + * @hidden + */ + public onIndicatorFocus() { + this.grid.gridAPI.update_cell(this.grid.crudService.cell); + } + + /** + * @hidden + */ + public override calculateSizeToFit(range: any): number { + let leftPadding = 0; + if (this.indentationDiv) { + const indentationStyle = this.document.defaultView.getComputedStyle(this.indentationDiv.nativeElement); + leftPadding = parseFloat(indentationStyle.paddingLeft); + } + const contentWidth = this.platformUtil.getNodeSizeViaRange(range, this.nativeElement); + return contentWidth + leftPadding; + } + + /** + * @hidden + */ + public get iconTemplate() { + if (this.expanded) { + return this.grid.rowExpandedIndicatorTemplate || this.defaultExpandedTemplate; + } else { + return this.grid.rowCollapsedIndicatorTemplate || this.defaultCollapsedTemplate; + } + } + + /** + * @hidden + */ + public get showExpanderIndicator() { + const isGhost = this.intRow.pinned && this.intRow.disabled; + return !this.editMode && (!this.intRow.pinned || isGhost); + } +} diff --git a/projects/igniteui-angular/grids/grid/src/grid-add-row.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-add-row.spec.ts new file mode 100644 index 00000000000..bc3beb7bfeb --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid-add-row.spec.ts @@ -0,0 +1,1124 @@ +import { IgxGridComponent } from './public_api'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { DebugElement } from '@angular/core'; +import { GridFunctions, GridSummaryFunctions } from '../../../test-utils/grid-functions.spec'; +import { + IgxAddRowComponent, IgxGridRowEditingDefinedColumnsComponent, IgxGridRowEditingTransactionComponent +} from '../../../test-utils/grid-samples.spec'; + +import { By } from '@angular/platform-browser'; +import { IgxActionStripComponent } from 'igniteui-angular/action-strip'; +import { DefaultGridMasterDetailComponent } from './grid.master-detail.spec'; +import { ColumnLayoutTestComponent } from './grid.multi-row-layout.spec'; +import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; +import { IgxGridRowComponent } from './grid-row.component'; +import { takeUntil, first } from 'rxjs/operators'; +import { Subject } from 'rxjs'; +import { DefaultSortingStrategy, IgxStringFilteringOperand, SortingDirection, TransactionType } from 'igniteui-angular/core'; +import { IgxGridMRLNavigationService } from 'igniteui-angular/grids/core'; + +const DEBOUNCETIME = 30; + +describe('IgxGrid - Row Adding #grid', () => { + const GRID_ROW = 'igx-grid-row'; + const DISPLAY_CONTAINER = 'igx-display-container'; + const SUMMARY_ROW = 'igx-grid-summary-row'; + const GRID_THEAD_ITEM = '.igx-grid-thead__item'; + + let fixture; + let grid: IgxGridComponent; + let gridContent: DebugElement; + let actionStrip: IgxActionStripComponent; + const endTransition = () => { + // transition end needs to be simulated + const animationElem = fixture.nativeElement.querySelector('.igx-grid__tr--inner'); + const endEvent = new AnimationEvent('animationend'); + animationElem.dispatchEvent(endEvent); + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxAddRowComponent, + IgxGridRowEditingTransactionComponent, + IgxGridRowEditingDefinedColumnsComponent, + ColumnLayoutTestComponent, + DefaultGridMasterDetailComponent + ], + providers: [ + IgxGridMRLNavigationService + ] + }).compileComponents(); + })); + + describe('General tests', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxAddRowComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + gridContent = GridFunctions.getGridContent(fixture); + actionStrip = fixture.componentInstance.actionStrip; + }); + + it('Should be able to enter add row mode on action strip click', () => { + const row = grid.rowList.first; + actionStrip.show(row); + fixture.detectChanges(); + const addRowIcon = fixture.debugElement.queryAll(By.css(`igx-grid-editing-actions igx-icon`))[1]; + addRowIcon.parent.triggerEventHandler('click', new Event('click')); + fixture.detectChanges(); + const addRow = grid.gridAPI.get_row_by_index(1); + expect(addRow.addRowUI).toBeTrue(); + }); + + it('Should be able to enter add row mode through the exposed API method.', () => { + const rows = grid.rowList.toArray(); + rows[0].beginAddRow(); + fixture.detectChanges(); + + endTransition(); + + let addRow = grid.gridAPI.get_row_by_index(1); + expect(addRow.addRowUI).toBeTrue(); + + UIInteractions.triggerEventHandlerKeyDown('escape', gridContent); + fixture.detectChanges(); + addRow = grid.gridAPI.get_row_by_index(1); + expect(addRow.addRowUI).toBeFalse(); + + rows[1].beginAddRow(); + fixture.detectChanges(); + addRow = grid.gridAPI.get_row_by_index(2); + expect(addRow.addRowUI).toBeTrue(); + }); + + it('Should display the banner above the row if there is no room underneath it', () => { + fixture.componentInstance.paging = true; + fixture.detectChanges(); + grid.notifyChanges(true); + fixture.detectChanges(); + + grid.paginator.perPage = 7; + fixture.detectChanges(); + + const lastRow = grid.rowList.last; + const lastRowIndex = lastRow.index; + actionStrip.show(lastRow); + fixture.detectChanges(); + + const addRowIcon = fixture.debugElement.queryAll(By.css(`igx-grid-editing-actions igx-icon`))[1]; + addRowIcon.parent.triggerEventHandler('click', new Event('click')); + fixture.detectChanges(); + + endTransition(); + + const addRow = grid.gridAPI.get_row_by_index(lastRowIndex + 1); + // expect(addRow.addRow).toBeTrue(); + + const banner = GridFunctions.getRowEditingOverlay(fixture); + fixture.detectChanges(); + const bannerBottom = banner.getBoundingClientRect().bottom; + const addRowTop = addRow.nativeElement.getBoundingClientRect().top; + + // The banner appears above the row + expect(bannerBottom).toBeLessThanOrEqual(addRowTop); + + // No much space between the row and the banner + expect(addRowTop - bannerBottom).toBeLessThan(2); + }); + + it('Should be able to enter add row mode on Alt + plus key.', () => { + GridFunctions.focusFirstCell(fixture, grid); + fixture.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('+', gridContent, true, false, false); + fixture.detectChanges(); + + const addRow = grid.gridAPI.get_row_by_index(1); + expect(addRow.addRowUI).toBeTrue(); + + }); + + it('Should not be able to enter add row mode on Alt + Shift + plus key.', () => { + GridFunctions.focusFirstCell(fixture, grid); + fixture.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('+', gridContent, true, true, false); + fixture.detectChanges(); + + const banner = GridFunctions.getRowEditingOverlay(fixture); + expect(banner).toBeNull(); + expect(grid.gridAPI.get_row_by_index(1).addRowUI).toBeFalse(); + }); + + it('Should not be able to enter add row mode when rowEditing is disabled', () => { + grid.rowEditable = false; + fixture.detectChanges(); + + grid.rowList.first.beginAddRow(); + fixture.detectChanges(); + + const banner = GridFunctions.getRowEditingOverlay(fixture); + expect(banner).toBeNull(); + expect(grid.gridAPI.get_row_by_index(1).addRowUI).toBeFalse(); + }); + + it('Should allow adding row from pinned row.', () => { + let row = grid.gridAPI.get_row_by_index(0); + row.pin(); + fixture.detectChanges(); + expect(grid.pinnedRecords.length).toBe(1); + + row = grid.gridAPI.get_row_by_index(0); + row.beginAddRow(); + fixture.detectChanges(); + + endTransition(); + + // add row should be pinned + const addRow = grid.gridAPI.get_row_by_index(1) as IgxGridRowComponent; + expect(addRow.addRowUI).toBe(true); + expect(grid.pinnedRows[1]).toBe(addRow); + + grid.gridAPI.crudService.endEdit(true); + fixture.detectChanges(); + + // added record should be pinned. + expect(grid.pinnedRecords.length).toBe(2); + expect(grid.pinnedRecords[1]).toBe(grid.data[grid.data.length - 1]); + + }); + it('Should allow adding row from ghost row.', () => { + const row = grid.getRowByIndex(0); + row.pin(); + fixture.detectChanges(); + expect(grid.pinnedRecords.length).toBe(1); + + const ghostRow = grid.gridAPI.get_row_by_index(1); + ghostRow.beginAddRow(); + fixture.detectChanges(); + + endTransition(); + + // add row should be unpinned + const addRow = grid.gridAPI.get_row_by_index(2); + expect(addRow.addRowUI).toBe(true); + expect(grid.pinnedRows.length).toBe(1); + + grid.gridAPI.crudService.endEdit(true); + fixture.detectChanges(); + + // added record should be unpinned. + expect(grid.pinnedRecords.length).toBe(1); + expect(grid.unpinnedRecords[grid.unpinnedRecords.length - 1]).toBe(grid.data[grid.data.length - 1]); + }); + it('should navigate to added row on snackbar button click.', async () => { + const rows = grid.rowList.toArray(); + const dataCount = grid.data.length; + rows[0].beginAddRow(); + fixture.detectChanges(); + endTransition(); + + grid.gridAPI.crudService.endEdit(true); + fixture.detectChanges(); + + // check row is in data + expect(grid.data.length).toBe(dataCount + 1); + + const addedRec = grid.data[grid.data.length - 1]; + + grid.addRowSnackbar.triggerAction(); + fixture.detectChanges(); + + await wait(100); + fixture.detectChanges(); + + // check added row is rendered and is in view + const row = grid.gridAPI.get_row_by_key(addedRec[grid.primaryKey]); + expect(row).not.toBeNull(); + const gridOffsets = grid.tbody.nativeElement.getBoundingClientRect(); + const rowOffsets = row.nativeElement.getBoundingClientRect(); + expect(rowOffsets.top >= gridOffsets.top && rowOffsets.bottom <= gridOffsets.bottom).toBeTruthy(); + }); + + it('should navigate to added row on snackbar button click when row is not in current view.', async () => { + fixture.componentInstance.paging = true; + fixture.detectChanges(); + + grid.paginator.perPage = 5; + grid.markForCheck(); + fixture.detectChanges(); + + const rows = grid.rowList.toArray(); + const dataCount = grid.data.length; + + rows[0].beginAddRow(); + fixture.detectChanges(); + + endTransition(); + + grid.gridAPI.crudService.endEdit(true); + fixture.detectChanges(); + + // check row is in data + expect(grid.data.length).toBe(dataCount + 1); + + const addedRec = grid.data[grid.data.length - 1]; + + grid.addRowSnackbar.triggerAction(); + fixture.detectChanges(); + + await wait(100); + fixture.detectChanges(); + + // check page is correct + expect(grid.paginator.page).toBe(5); + + // check added row is rendered and is in view + const row = grid.gridAPI.get_row_by_key(addedRec[grid.primaryKey]); + expect(row).not.toBeNull(); + const gridOffsets = grid.tbody.nativeElement.getBoundingClientRect(); + const rowOffsets = row.nativeElement.getBoundingClientRect(); + expect(rowOffsets.top >= gridOffsets.top && rowOffsets.bottom <= gridOffsets.bottom).toBeTruthy(); + }); + + it('Should generate correct row ID based on the primary column type', () => { + const column = grid.columnList.find(col => col.field === grid.primaryKey); + const type = column.dataType; + + const row = grid.gridAPI.get_row_by_index(0); + row.beginAddRow(); + fixture.detectChanges(); + + const newRow = grid.gridAPI.get_row_by_index(1); + expect(newRow.addRowUI).toBeTrue(); + const cell = newRow.cells.find(c => c.column === column); + expect(typeof(cell.value)).toBe(type); + }); + + it('should allow setting a different display time for snackbar', async () => { + grid.snackbarDisplayTime = 50; + fixture.detectChanges(); + + const row = grid.gridAPI.get_row_by_index(0); + row.beginAddRow(); + fixture.detectChanges(); + + endTransition(); + + grid.gridAPI.crudService.endEdit(true); + fixture.detectChanges(); + + expect(grid.addRowSnackbar.isVisible).toBe(true); + // should hide after 50ms + await wait(51); + fixture.detectChanges(); + + expect(grid.addRowSnackbar.isVisible).toBe(false); + }); + + it('Should set templated banner text when adding row', () => { + const rows = grid.rowList.toArray(); + rows[0].beginAddRow(); + fixture.detectChanges(); + + endTransition(); + + const addRow = grid.gridAPI.get_row_by_index(1); + expect(addRow.addRowUI).toBeTrue(); + + expect(GridFunctions.getRowEditingBannerText(fixture)).toEqual('Adding Row'); + }); + }); + + describe('Add row events tests:', () => { + const $destroyer = new Subject(); + + beforeEach(() => { + fixture = TestBed.createComponent(IgxAddRowComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + gridContent = GridFunctions.getGridContent(fixture); + }); + + afterEach(() => { + $destroyer.next(true); + }); + + it('Should emit all events in the correct order', () => { + spyOn(grid.rowEditEnter, 'emit').and.callThrough(); + spyOn(grid.cellEditEnter, 'emit').and.callThrough(); + spyOn(grid.cellEdit, 'emit').and.callThrough(); + spyOn(grid.cellEditDone, 'emit').and.callThrough(); + spyOn(grid.cellEditExit, 'emit').and.callThrough(); + spyOn(grid.rowEdit, 'emit').and.callThrough(); + spyOn(grid.rowEditDone, 'emit').and.callThrough(); + spyOn(grid.rowEditExit, 'emit').and.callThrough(); + + grid.rowEditEnter.pipe(takeUntil($destroyer)).subscribe(() => { + expect(grid.rowEditEnter.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEditEnter.emit).not.toHaveBeenCalled(); + }); + + grid.cellEditEnter.pipe(takeUntil($destroyer)).subscribe(() => { + expect(grid.rowEditEnter.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEditEnter.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEdit.emit).not.toHaveBeenCalled(); + }); + + grid.cellEdit.pipe(takeUntil($destroyer)).subscribe(() => { + expect(grid.cellEditEnter.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEdit.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEditDone.emit).not.toHaveBeenCalled(); + }); + + grid.cellEditDone.pipe(takeUntil($destroyer)).subscribe(() => { + expect(grid.cellEdit.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEditDone.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEditExit.emit).not.toHaveBeenCalled(); + }); + + grid.cellEditExit.pipe(takeUntil($destroyer)).subscribe(() => { + expect(grid.cellEditDone.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEditExit.emit).toHaveBeenCalledTimes(1); + expect(grid.rowEdit.emit).not.toHaveBeenCalled(); + }); + + grid.rowEdit.pipe(takeUntil($destroyer)).subscribe(() => { + expect(grid.cellEditExit.emit).toHaveBeenCalledTimes(1); + expect(grid.rowEdit.emit).toHaveBeenCalledTimes(1); + expect(grid.rowEditDone.emit).not.toHaveBeenCalled(); + }); + + grid.rowEditDone.pipe(takeUntil($destroyer)).subscribe(() => { + expect(grid.rowEdit.emit).toHaveBeenCalledTimes(1); + expect(grid.rowEditDone.emit).toHaveBeenCalledTimes(1); + expect(grid.rowEditExit.emit).not.toHaveBeenCalled(); + }); + + grid.rowEditExit.pipe(takeUntil($destroyer)).subscribe(() => { + expect(grid.rowEditDone.emit).toHaveBeenCalledTimes(1); + expect(grid.rowEditExit.emit).toHaveBeenCalledTimes(1); + }); + + grid.rowList.first.beginAddRow(); + fixture.detectChanges(); + + endTransition(); + + const cell = grid.gridAPI.get_cell_by_index(1, 'CompanyName'); + const cellInput = cell.nativeElement.querySelector('[igxinput]'); + UIInteractions.setInputElementValue(cellInput, 'aaa'); + fixture.detectChanges(); + grid.gridAPI.crudService.endEdit(true); + fixture.detectChanges(); + }); + + it('Should emit all grid editing events as per row editing specification', () => { + spyOn(grid.cellEditEnter, 'emit').and.callThrough(); + spyOn(grid.cellEditDone, 'emit').and.callThrough(); + spyOn(grid.rowEditEnter, 'emit').and.callThrough(); + spyOn(grid.rowEditDone, 'emit').and.callThrough(); + + const row = grid.gridAPI.get_row_by_index(0); + row.beginAddRow(); + fixture.detectChanges(); + + endTransition(); + + const newRow = grid.gridAPI.get_row_by_index(1); + expect(newRow.addRowUI).toBeTrue(); + expect(grid.cellEditEnter.emit).toHaveBeenCalled(); + expect(grid.rowEditEnter.emit).toHaveBeenCalled(); + + const cell = grid.gridAPI.get_cell_by_index(1, 'CompanyName'); + const cellInput = cell.nativeElement.querySelector('[igxinput]'); + UIInteractions.setInputElementValue(cellInput, 'aaa'); + fixture.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fixture.detectChanges(); + + expect(grid.cellEditDone.emit).toHaveBeenCalled(); + + expect(grid.rowEditDone.emit).toHaveBeenCalled(); + }); + + it('Should not enter add mode when rowEditEnter is canceled', () => { + grid.rowEditEnter.pipe(takeUntil($destroyer)).subscribe((evt) => { + evt.cancel = true; + }); + + grid.rowList.first.beginAddRow(); + fixture.detectChanges(); + endTransition(); + + expect(grid.gridAPI.get_row_by_index(1).addRowUI).toBeFalse(); + }); + + it('Should enter add mode but close it when cellEditEnter is canceled', () => { + let canceled = true; + grid.cellEditEnter.pipe(first()).subscribe((evt) => { + evt.cancel = canceled; + }); + + grid.rowList.first.beginAddRow(); + fixture.detectChanges(); + + expect(grid.gridAPI.get_row_by_index(1).addRowUI).toBeTrue(); + expect(grid.crudService.cellInEditMode).toEqual(false); + + grid.gridAPI.crudService.endEdit(false); + fixture.detectChanges(); + + canceled = false; + grid.rowList.first.beginAddRow(); + fixture.detectChanges(); + + expect(grid.gridAPI.get_row_by_index(1).addRowUI).toBeTrue(); + }); + + it(`Should emit 'rowEditEnter' only once while adding a new row`, () => { + spyOn(grid.rowEditEnter, 'emit').and.callThrough(); + const row = grid.gridAPI.get_row_by_index(0); + row.beginAddRow(); + fixture.detectChanges(); + + endTransition(); + + const newRow = grid.gridAPI.get_row_by_index(1); + expect(newRow.addRowUI).toBeTrue(); + + let targetCell = grid.gridAPI.get_cell_by_index(1, 'ContactName') as any; + UIInteractions.simulateClickAndSelectEvent(targetCell); + fixture.detectChanges(); + + targetCell = grid.gridAPI.get_cell_by_index(1, 'CompanyName') as any; + UIInteractions.simulateClickAndSelectEvent(targetCell); + fixture.detectChanges(); + + expect(grid.rowEditEnter.emit).toHaveBeenCalledTimes(1); + }); + + it('Should scroll and start adding a row as the first one when using the public API method', async () => { + await wait(DEBOUNCETIME); + fixture.detectChanges(); + + grid.navigateTo(20, 0); + + await wait(DEBOUNCETIME); + fixture.detectChanges(); + + grid.beginAddRowById(null); + + await wait(DEBOUNCETIME); + fixture.detectChanges(); + + expect(grid.gridAPI.get_row_by_index(0).addRowUI).toBeTrue(); + }); + + xit('Should scroll and start adding a row as for a row that is not in view', async () => { + await wait(DEBOUNCETIME); + fixture.detectChanges(); + + grid.beginAddRowById('FAMIA'); + + await wait(DEBOUNCETIME); + fixture.detectChanges(); + + expect(grid.gridAPI.get_row_by_index(8).addRowUI).toBeTrue(); + }); + }); + + describe('Exit add row mode tests', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxAddRowComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + gridContent = GridFunctions.getGridContent(fixture); + actionStrip = fixture.componentInstance.actionStrip; + }); + + it('Should exit add row mode and commit on clicking DONE button in the overlay', () => { + const dataLength = grid.data.length; + const row = grid.rowList.first; + row.beginAddRow(); + fixture.detectChanges(); + + endTransition(); + + let newRow = grid.gridAPI.get_row_by_index(1); + expect(newRow.addRowUI).toBeTrue(); + + const doneButtonElement = GridFunctions.getRowEditingDoneButton(fixture); + doneButtonElement.click(); + fixture.detectChanges(); + + newRow = grid.gridAPI.get_row_by_index(1); + expect(newRow.addRowUI).toBeFalse(); + expect(grid.data.length).toBe(dataLength + 1); + }); + + it('Should exit add row mode and discard on clicking CANCEL button in the overlay', async () => { + const dataLength = grid.data.length; + const row = grid.rowList.first; + row.beginAddRow(); + fixture.detectChanges(); + + endTransition(); + + let newRow = grid.gridAPI.get_row_by_index(1); + expect(newRow.addRowUI).toBeTrue(); + + const cancelButtonElement = GridFunctions.getRowEditingCancelButton(fixture); + cancelButtonElement.click(); + fixture.detectChanges(); + await wait(100); + fixture.detectChanges(); + + newRow = grid.gridAPI.get_row_by_index(1); + expect(newRow.addRowUI).toBeFalse(); + expect(grid.data.length).toBe(dataLength); + }); + + it('Should exit add row mode and discard on ESC KEYDOWN', () => { + const dataLength = grid.data.length; + const row = grid.rowList.first; + row.beginAddRow(); + fixture.detectChanges(); + + endTransition(); + + let newRow = grid.gridAPI.get_row_by_index(1); + expect(newRow.addRowUI).toBeTrue(); + + UIInteractions.triggerEventHandlerKeyDown('escape', gridContent); + fixture.detectChanges(); + + newRow = grid.gridAPI.get_row_by_index(1); + expect(newRow.addRowUI).toBeFalse(); + expect(grid.data.length).toBe(dataLength); + }); + + it('Should exit add row mode and commit on ENTER KEYDOWN.', () => { + const dataLength = grid.data.length; + const row = grid.rowList.first; + row.beginAddRow(); + fixture.detectChanges(); + + endTransition(); + + let newRow = grid.gridAPI.get_row_by_index(1); + expect(newRow.addRowUI).toBeTrue(); + + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fixture.detectChanges(); + + newRow = grid.gridAPI.get_row_by_index(1); + expect(newRow.addRowUI).toBeFalse(); + expect(grid.data.length).toBe(dataLength + 1); + }); + + it('Should correctly scroll all rows after closing the add row', async () => { + grid.width = '400px'; + fixture.detectChanges(); + + const dataLength = grid.data.length; + const row = grid.rowList.first; + row.beginAddRow(); + fixture.detectChanges(); + + endTransition(); + + let newRow = grid.gridAPI.get_row_by_index(1); + expect(newRow.addRowUI).toBeTrue(); + + const cancelButtonElement = GridFunctions.getRowEditingCancelButton(fixture); + cancelButtonElement.click(); + fixture.detectChanges(); + await wait(100); + fixture.detectChanges(); + + newRow = grid.gridAPI.get_row_by_index(1); + expect(newRow.addRowUI).toBeFalse(); + expect(grid.data.length).toBe(dataLength); + + (grid as any).scrollTo(0, grid.columnList.length - 1); + await wait(100); + fixture.detectChanges(); + + // All rows should be scrolled, from their forOf directive. If not then the `_horizontalForOfs` in the grid is outdated. + const gridRows = fixture.debugElement.queryAll(By.css(GRID_ROW)); + gridRows.forEach(item => { + const displayContainer = item.query(By.css(DISPLAY_CONTAINER)); + expect(displayContainer.nativeElement.style.left).not.toBe('0px'); + }); + }); + }); + + describe('Row Adding - Paging tests', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxAddRowComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + gridContent = GridFunctions.getGridContent(fixture); + }); + + it('Should preserve the changes after page navigation', () => { + const dataLength = grid.data.length; + fixture.componentInstance.paging = true; + fixture.detectChanges(); + grid.paginator.perPage = 5; + fixture.detectChanges(); + + const row = grid.rowList.first; + row.beginAddRow(); + fixture.detectChanges(); + endTransition(); + + GridFunctions.navigateToLastPage(grid.nativeElement); + fixture.detectChanges(); + expect(grid.data.length).toBe(dataLength); + }); + + it('Should save changes when changing page count', () => { + const dataLength = grid.data.length; + fixture.componentInstance.paging = true; + fixture.detectChanges(); + grid.paginator.perPage = 5; + fixture.detectChanges(); + + const row = grid.rowList.first; + row.beginAddRow(); + fixture.detectChanges(); + + endTransition(); + + const select = GridFunctions.getGridPageSelectElement(fixture); + select.click(); + fixture.detectChanges(); + const selectList = fixture.debugElement.query(By.css('.igx-drop-down__list-scroll')); + selectList.children[2].nativeElement.click(); + fixture.detectChanges(); + expect(grid.data.length).toBe(dataLength); + }); + }); + + describe('Row Adding - Filtering tests', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxAddRowComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + gridContent = GridFunctions.getGridContent(fixture); + actionStrip = fixture.componentInstance.actionStrip; + }); + + it('Should exit add row mode on filter applied and discard', () => { + spyOn(grid.gridAPI.crudService, 'endEdit').and.callThrough(); + + const dataLength = grid.data.length; + const row = grid.rowList.first; + row.beginAddRow(); + fixture.detectChanges(); + + endTransition(); + + grid.filter('CompanyName', 'al', IgxStringFilteringOperand.instance().condition('contains'), true); + fixture.detectChanges(); + + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalled(); + expect(grid.data.length).toBe(dataLength); + }); + + it('Filtering should consider newly added rows', () => { + grid.filter('CompanyName', 'al', IgxStringFilteringOperand.instance().condition('contains'), true); + fixture.detectChanges(); + expect(grid.dataView.length).toBe(4); + + const row = grid.gridAPI.get_row_by_index(0); + row.beginAddRow(); + fixture.detectChanges(); + + endTransition(); + + const newRow = grid.gridAPI.get_row_by_index(1); + expect(newRow.addRowUI).toBeTrue(); + + const cell = grid.gridAPI.get_cell_by_index(1, 'CompanyName'); + const cellInput = cell.nativeElement.querySelector('[igxinput]'); + UIInteractions.setInputElementValue(cellInput, 'Alan'); + grid.gridAPI.crudService.endEdit(true); + fixture.detectChanges(); + + expect(grid.dataView.length).toBe(5); + }); + + it('Should not show the action strip "Show" button if added row is filtered out', () => { + grid.filter('CompanyName', 'al', IgxStringFilteringOperand.instance().condition('contains'), true); + fixture.detectChanges(); + + const row = grid.gridAPI.get_row_by_index(0); + row.beginAddRow(); + fixture.detectChanges(); + + endTransition(); + const newRow = grid.gridAPI.get_row_by_index(1); + expect(newRow.addRowUI).toBeTrue(); + + const cell = grid.gridAPI.get_cell_by_index(1, 'CompanyName'); + const cellInput = cell.nativeElement.querySelector('[igxinput]'); + UIInteractions.setInputElementValue(cellInput, 'Xuary'); + grid.gridAPI.crudService.endEdit(true); + fixture.detectChanges(); + + expect(grid.dataView.length).toBe(4); + expect(grid.addRowSnackbar.actionText).toBe(''); + }); + }); + + describe('Row Adding - Sorting tests', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxAddRowComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + gridContent = GridFunctions.getGridContent(fixture); + actionStrip = fixture.componentInstance.actionStrip; + }); + + it('Should exit add row mode and discard on sorting', () => { + spyOn(grid.gridAPI.crudService, 'endEdit').and.callThrough(); + + const dataLength = grid.data.length; + const row = grid.rowList.first; + row.beginAddRow(); + fixture.detectChanges(); + + grid.sort({ + fieldName: 'CompanyName', dir: SortingDirection.Asc, ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }); + fixture.detectChanges(); + + expect(grid.data.length).toBe(dataLength); + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalled(); + }); + + it('Sorting should consider newly added rows', () => { + grid.sort({ + fieldName: 'CompanyName', dir: SortingDirection.Asc, ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }); + fixture.detectChanges(); + + const row = grid.gridAPI.get_row_by_index(0); + row.beginAddRow(); + fixture.detectChanges(); + + endTransition(); + + const newRow = grid.gridAPI.get_row_by_index(1); + expect(newRow.addRowUI).toBeTrue(); + + const cell = grid.gridAPI.get_cell_by_index(1, 'CompanyName'); + const cellInput = cell.nativeElement.querySelector('[igxinput]'); + UIInteractions.setInputElementValue(cellInput, 'Azua'); + grid.gridAPI.crudService.endEdit(true); + fixture.detectChanges(); + + expect(grid.getCellByColumn(4, 'CompanyName').value).toBe('Azua'); + }); + }); + + describe('Row Adding - Master detail view', () => { + beforeEach(() => { + fixture = TestBed.createComponent(DefaultGridMasterDetailComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + gridContent = GridFunctions.getGridContent(fixture); + }); + + it('Should collapse expanded detail view before spawning add row UI', () => { + grid.rowEditable = true; + fixture.detectChanges(); + const row = grid.rowList.first; + grid.expandRow(row.key); + fixture.detectChanges(); + expect(row.expanded).toBeTrue(); + + row.beginAddRow(); + fixture.detectChanges(); + expect(row.expanded).toBeFalse(); + expect(grid.gridAPI.get_row_by_index(1).addRowUI).toBeTrue(); + }); + }); + + describe('Row Adding - MRL tests', () => { + beforeEach(() => { + fixture = TestBed.createComponent(ColumnLayoutTestComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + }); + + it('Should render adding row with correct multi row layout', () => { + grid.rowEditable = true; + fixture.detectChanges(); + const gridFirstRow = grid.rowList.first; + // headers are aligned to cells + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + + gridFirstRow.beginAddRow(); + fixture.detectChanges(); + const newRow = grid.gridAPI.get_row_by_index(1); + expect(newRow.addRowUI).toBeTrue(); + GridFunctions.verifyLayoutHeadersAreAligned(grid, newRow); + }); + }); + + describe('Row Adding - Group by', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxAddRowComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + gridContent = GridFunctions.getGridContent(fixture); + }); + + it(`Should show the action strip "Show" button if added row is in collapsed group + 4and on click should expand the group and scroll to the correct added row`, () => { + grid.groupBy({ + fieldName: 'CompanyName', dir: SortingDirection.Asc, ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }); + fixture.detectChanges(); + + let groupRows = grid.groupsRowList.toArray(); + grid.toggleGroup(groupRows[2].groupRow); + fixture.detectChanges(); + expect(groupRows[2].expanded).toBeFalse(); + + const row = grid.gridAPI.get_row_by_index(1); + row.beginAddRow(); + fixture.detectChanges(); + endTransition(); + + const cell = grid.gridAPI.get_cell_by_index(2, 'CompanyName'); + const cellInput = cell.nativeElement.querySelector('[igxinput]'); + UIInteractions.setInputElementValue(cellInput, 'Antonio Moreno Taquería'); + grid.gridAPI.crudService.endEdit(true); + fixture.detectChanges(); + const addedRec = grid.data[grid.data.length - 1]; + + expect(grid.addRowSnackbar.actionText).toBe('SHOW'); + grid.addRowSnackbar.triggerAction(); + fixture.detectChanges(); + const row2 = grid.getRowByKey(addedRec[grid.primaryKey]); + + groupRows = grid.groupsRowList.toArray(); + expect(row2).not.toBeNull(); + expect(groupRows[2].expanded).toBeTrue(); + expect(groupRows[2].groupRow.records.length).toEqual(2); + expect(groupRows[2].groupRow.records[1]).toBe(row2.data); + }); + }); + + describe('Row Adding - Summaries', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxAddRowComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + gridContent = GridFunctions.getGridContent(fixture); + }); + + it('Should update summaries after adding new row', () => { + grid.getColumnByName('ID').hasSummary = true; + fixture.detectChanges(); + let summaryRow = fixture.debugElement.query(By.css(SUMMARY_ROW)); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, ['Count'], ['27']); + + grid.rowList.first.beginAddRow(); + fixture.detectChanges(); + + endTransition(); + + grid.gridAPI.crudService.endEdit(true); + fixture.detectChanges(); + + summaryRow = fixture.debugElement.query(By.css(SUMMARY_ROW)); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, ['Count'], ['28']); + }); + }); + + describe('Row Adding - Column manipulations', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxAddRowComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + gridContent = GridFunctions.getGridContent(fixture); + }); + + it('Should exit add row mode when moving a column', fakeAsync(() => { + spyOn(grid.gridAPI.crudService, 'endEdit').and.callThrough(); + const dataLength = grid.data.length; + const row = grid.rowList.first; + row.beginAddRow(); + fixture.detectChanges(); + + endTransition(); + + expect(grid.gridAPI.get_row_by_index(1).addRowUI).toBeTrue(); + expect(grid.rowEditingOverlay.collapsed).toEqual(false); + + grid.moveColumn(grid.columnList.get(1), grid.columnList.get(2)); + tick(); + fixture.detectChanges(); + + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalled(); + expect(grid.data.length).toBe(dataLength); + expect(grid.rowEditingOverlay.collapsed).toEqual(true); + })); + + it('Should exit add row mode when pinning/unpinning a column', () => { + spyOn(grid.gridAPI.crudService, 'endEdit').and.callThrough(); + const dataLength = grid.data.length; + const row = grid.rowList.first; + row.beginAddRow(); + fixture.detectChanges(); + endTransition(); + + expect(grid.gridAPI.get_row_by_index(1).addRowUI).toBeTrue(); + expect(grid.rowEditingOverlay.collapsed).toEqual(false); + + grid.pinColumn('CompanyName'); + fixture.detectChanges(); + + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalled(); + expect(grid.data.length).toBe(dataLength); + expect(grid.rowEditingOverlay.collapsed).toEqual(true); + + row.beginAddRow(); + fixture.detectChanges(); + endTransition(); + grid.unpinColumn('CompanyName'); + fixture.detectChanges(); + + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalled(); + expect(grid.data.length).toBe(dataLength); + expect(grid.rowEditingOverlay.collapsed).toEqual(true); + }); + + it('Should exit add row mode when resizing a column', async () => { + spyOn(grid.gridAPI.crudService, 'endEdit').and.callThrough(); + + fixture.detectChanges(); + + const dataLength = grid.data.length; + const row = grid.rowList.first; + row.beginAddRow(); + fixture.detectChanges(); + + endTransition(); + + expect(grid.gridAPI.get_row_by_index(1).addRowUI).toBeTrue(); + expect(grid.rowEditingOverlay.collapsed).toEqual(false); + + const headers: DebugElement[] = fixture.debugElement.queryAll(By.css(GRID_THEAD_ITEM)); + const headerResArea = headers[2].children[3].nativeElement; + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 400, 0); + await wait(200); + fixture.detectChanges(); + const resizer = GridFunctions.getResizer(fixture).nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 450, 0); + UIInteractions.simulateMouseEvent('mouseup', resizer, 450, 0); + fixture.detectChanges(); + + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalled(); + expect(grid.data.length).toBe(dataLength); + expect(grid.rowEditingOverlay.collapsed).toEqual(false); + }); + + it('Should exit add row mode when hiding a column', () => { + spyOn(grid.gridAPI.crudService, 'endEdit').and.callThrough(); + const dataLength = grid.data.length; + const row = grid.rowList.first; + row.beginAddRow(); + fixture.detectChanges(); + + endTransition(); + + expect(grid.gridAPI.get_row_by_index(1).addRowUI).toBeTrue(); + expect(grid.rowEditingOverlay.collapsed).toEqual(false); + + const column = grid.columnList.filter(c => c.field === 'ContactName')[0]; + column.hidden = true; + fixture.detectChanges(); + + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalled(); + expect(grid.data.length).toBe(dataLength); + expect(grid.rowEditingOverlay.collapsed).toEqual(true); + }); + }); + + describe('Row Adding - Transactions', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxGridRowEditingTransactionComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + gridContent = GridFunctions.getGridContent(fixture); + }); + + it('Should create ADD transaction when adding a new row', () => { + const row = grid.rowList.first; + row.beginAddRow(); + fixture.detectChanges(); + endTransition(); + + grid.gridAPI.crudService.endEdit(true); + fixture.detectChanges(); + const states = grid.transactions.getAggregatedChanges(true); + + expect(states.length).toEqual(1); + expect(states[0].type).toEqual(TransactionType.ADD); + }); + + it('All updates on uncommitted add row should be merged into one ADD transaction', () => { + const row = grid.rowList.first; + row.beginAddRow(); + fixture.detectChanges(); + + endTransition(); + + grid.gridAPI.crudService.endEdit(true); + fixture.detectChanges(); + let states = grid.transactions.getAggregatedChanges(true); + expect(states.length).toEqual(1); + expect(states[0].type).toEqual(TransactionType.ADD); + + const cell = grid.getCellByColumn(grid.dataView.length - 1, 'ProductName'); + cell.update('aaa'); + fixture.detectChanges(); + states = grid.transactions.getAggregatedChanges(true); + expect(states.length).toEqual(1); + expect(states[0].type).toEqual(TransactionType.ADD); + expect(states[0].newValue['ProductName']).toEqual('aaa'); + }); + + it('Should display number of defined columns for rowChangesCount', () => { + fixture = TestBed.createComponent(IgxGridRowEditingDefinedColumnsComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + + const row = grid.rowList.first; + row.beginAddRow(); + fixture.detectChanges(); + endTransition(); + + const cellElem = grid.gridAPI.get_cell_by_index(10, 'ProductName'); + UIInteractions.simulateDoubleClickAndSelectEvent(cellElem); + fixture.detectChanges(); + + expect(grid.rowChangesCount).toEqual(3); + }); + }); +}); diff --git a/projects/igniteui-angular/grids/grid/src/grid-api.service.ts b/projects/igniteui-angular/grids/grid/src/grid-api.service.ts new file mode 100644 index 00000000000..53687cb9fc6 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid-api.service.ts @@ -0,0 +1,158 @@ +import { GridBaseAPIService } from 'igniteui-angular/grids/core'; +import { Injectable } from '@angular/core'; +import { GridServiceType, GridType } from 'igniteui-angular/grids/core'; +import { cloneArray, DataUtil, IGroupByExpandState, IGroupByRecord, IGroupingExpression } from 'igniteui-angular/core'; + +@Injectable() +export class IgxGridAPIService extends GridBaseAPIService implements GridServiceType { + + public groupBy(expression: IGroupingExpression): void { + const groupingState = cloneArray(this.grid.groupingExpressions); + this.prepare_grouping_expression([groupingState], expression); + this.grid.groupingExpressions = groupingState; + this.arrange_sorting_expressions(); + } + + public groupBy_multiple(expressions: IGroupingExpression[]): void { + const groupingState = cloneArray(this.grid.groupingExpressions); + + for (const each of expressions) { + this.prepare_grouping_expression([groupingState], each); + } + + this.grid.groupingExpressions = groupingState; + this.arrange_sorting_expressions(); + } + + public override clear_groupby(name?: string | Array) { + const groupingState = cloneArray(this.grid.groupingExpressions); + + if (name) { + const names = typeof name === 'string' ? [name] : name; + const groupedCols = groupingState.filter((state) => names.indexOf(state.fieldName) < 0); + this.grid.groupingExpressions = groupedCols; + names.forEach((colName) => { + const grExprIndex = groupingState.findIndex((exp) => exp.fieldName === colName); + const grpExpandState = this.grid.groupingExpansionState; + /* remove expansion states related to the cleared group + and all with deeper hierarchy than the cleared group */ + const newExpandState = grpExpandState.filter((val) => val.hierarchy && val.hierarchy.length <= grExprIndex); + /* Do not set the new instance produced by filter + when there are no differences between expansion states */ + if (newExpandState.length !== grpExpandState.length) { + this.grid.groupingExpansionState = newExpandState; + } + }); + } else { + // clear all + this.grid.groupingExpressions = []; + this.grid.groupingExpansionState = []; + } + } + + public groupBy_get_expanded_for_group(groupRow: IGroupByRecord): IGroupByExpandState { + const grState = this.grid.groupingExpansionState; + const hierarchy = DataUtil.getHierarchy(groupRow); + return grState.find((state) => + DataUtil.isHierarchyMatch( + state.hierarchy || [{ fieldName: groupRow.expression.fieldName, value: groupRow.value }], + hierarchy, + this.grid.groupingExpressions)); + } + + public groupBy_is_row_in_group(groupRow: IGroupByRecord, rowID): boolean { + const grid = this.grid; + let rowInGroup = false; + groupRow.records.forEach(row => { + if (grid.primaryKey ? row[grid.primaryKey] === rowID : row === rowID) { + rowInGroup = true; + } + }); + return rowInGroup; + } + + public groupBy_toggle_group(groupRow: IGroupByRecord) { + const grid = this.grid; + if (grid.gridAPI.crudService.cellInEditMode) { + this.crudService.endEdit(false); + } + + const expansionState = grid.groupingExpansionState; + const state: IGroupByExpandState = this.groupBy_get_expanded_for_group(groupRow); + if (state) { + state.expanded = !state.expanded; + } else { + expansionState.push({ + expanded: !grid.groupsExpanded, + hierarchy: DataUtil.getHierarchy(groupRow) + }); + } + this.grid.groupingExpansionState = [...expansionState]; + if (grid.rowEditable) { + grid.repositionRowEditingOverlay(grid.gridAPI.crudService.rowInEditMode); + } + } + public set_grouprow_expansion_state(groupRow: IGroupByRecord, value: boolean) { + if (this.grid.isExpandedGroup(groupRow) !== value) { + this.groupBy_toggle_group(groupRow); + } + } + + public groupBy_fully_expand_group(groupRow: IGroupByRecord) { + const state: IGroupByExpandState = this.groupBy_get_expanded_for_group(groupRow); + const expanded = state ? state.expanded : this.grid.groupsExpanded; + if (!expanded) { + this.groupBy_toggle_group(groupRow); + } + if (groupRow.groupParent) { + this.groupBy_fully_expand_group(groupRow.groupParent); + } + } + + public groupBy_select_all_rows_in_group(groupRow: IGroupByRecord, clearPrevSelection: boolean) { + this.grid.selectionService.selectRowsWithNoEvent(this.grid.primaryKey ? + groupRow.records.map(x => x[this.grid.primaryKey]) : groupRow.records, clearPrevSelection); + } + + public groupBy_deselect_all_rows_in_group(groupRow: IGroupByRecord) { + this.grid.selectionService.deselectRowsWithNoEvent(this.grid.primaryKey ? + groupRow.records.map(x => x[this.grid.primaryKey]) : groupRow.records); + } + + public arrange_sorting_expressions() { + const groupingState = this.grid.groupingExpressions; + const sortingState = cloneArray(this.grid.sortingExpressions); + for (const grExpr of groupingState) { + const sortExprIndex = sortingState.findIndex((exp) => exp.fieldName === grExpr.fieldName); + if (sortExprIndex > -1) { + sortingState.splice(sortExprIndex, 1); + } + } + this.grid.sortingExpressions = sortingState; + } + + public get_groupBy_record_id(gRow: IGroupByRecord): string { + let recordId = '{ '; + const hierrarchy = DataUtil.getHierarchy(gRow); + + for (let i = 0; i < hierrarchy.length; i++) { + const groupByKey = hierrarchy[i]; + recordId += `'${groupByKey.fieldName}': '${groupByKey.value}'`; + + if (i < hierrarchy.length - 1) { + recordId += ', '; + } + } + recordId += ' }'; + + return recordId; + } + + public override remove_grouping_expression(fieldName: string) { + const groupingExpressions = this.grid.groupingExpressions; + const index = groupingExpressions.findIndex((expr) => expr.fieldName === fieldName); + if (index !== -1) { + groupingExpressions.splice(index, 1); + } + } +} diff --git a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts new file mode 100644 index 00000000000..b47e3fb43fa --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts @@ -0,0 +1,8174 @@ +import { formatNumber, getLocaleNumberFormat, NumberFormatStyle } from '@angular/common'; +import { + AfterContentInit, + AfterViewInit, + booleanAttribute, + ChangeDetectorRef, + ComponentRef, + ContentChild, + ContentChildren, + createComponent, + Directive, + DoCheck, + ElementRef, + EnvironmentInjector, + EventEmitter, + HostBinding, + HostListener, + Injector, + Input, + IterableChangeRecord, + IterableDiffers, + LOCALE_ID, + NgZone, + OnDestroy, + OnInit, + Output, + TemplateRef, + QueryList, + ViewChild, + ViewChildren, + ViewContainerRef, + DOCUMENT, + inject +} from '@angular/core'; +import { + areEqualArrays, + columnFieldPath, + formatDate, + resizeObservable, + Transaction, + TransactionType, + TransactionService, + State, + cloneArray, + mergeObjects, + compareMaps, + resolveNestedPath, + isObject, + PlatformUtil, + VerticalAlignment, + HorizontalAlignment, + PositionSettings, + OverlaySettings, + IgxFlatTransactionFactory, + TRANSACTION_TYPE, + IgxOverlayService, + ConnectedPositioningStrategy, + ContainerPositionStrategy, + AbsoluteScrollStrategy, + Action, + StateUpdateEvent, + TransactionEventOrigin, + getCurrentResourceStrings, + DataUtil, + DefaultDataCloneStrategy, + DefaultMergeStrategy, + FilteringExpressionsTree, + FilteringExpressionsTreeType, + FilteringLogic, + FilteringStrategy, + GridColumnDataType, + IDataCloneStrategy, + IFilteringExpressionsTree, + IFilteringOperation, + IFilteringStrategy, + IGridMergeStrategy, + IGridSortingStrategy, + IGroupByRecord, + ISortingExpression, + isTree, + recreateTree, + recreateTreeFromFields, + ɵSize, + ColumnPinningPosition, + ColumnType, + EntityType, + ISortingOptions, + ISummaryExpression, + GridSummaryCalculationMode, + IgxActionStripToken, + GridResourceStringsEN, + IGridResourceStrings, + IgxOverlayOutletDirective +} from 'igniteui-angular/core'; +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 { + IgxToggleDirective, + IForOfDataChangeEventArgs, + IgxGridForOfDirective, + IgxTextHighlightService, + ICachedViewLoadedEventArgs, + IgxTemplateOutletDirective +} from 'igniteui-angular/directives'; +import { IgxGridRowComponent } from './grid-row.component'; +import { IgxPaginatorToken, type IgxPaginatorComponent } from 'igniteui-angular/paginator'; +import { IgxSnackbarComponent } from 'igniteui-angular/snackbar'; +import { CharSeparatedValueData, DropPosition, FilterMode, getUUID, GridCellMergeMode, GridKeydownTargetType, GridPagingMode, GridSelectionMode, GridSelectionRange, GridServiceType, GridSummaryPosition, GridType, GridValidationTrigger, IActiveNode, IActiveNodeChangeEventArgs, ICellPosition, IClipboardOptions, IColumnMovingEndEventArgs, IColumnMovingEventArgs, IColumnMovingStartEventArgs, IColumnResizeEventArgs, IColumnsAutoGeneratedEventArgs, IColumnSelectionEventArgs, IColumnVisibilityChangedEventArgs, IColumnVisibilityChangingEventArgs, IFilteringEventArgs, IGridCellEventArgs, IGridClipboardEvent, IGridContextMenuEventArgs, IGridEditDoneEventArgs, IGridEditEventArgs, IGridFormGroupCreatedEventArgs, IGridKeydownEventArgs, IGridRowEventArgs, IGridScrollEventArgs, IGridToolbarExportEventArgs, IGridValidationStatusEventArgs, IGX_GRID_SERVICE_BASE, IgxAdvancedFilteringDialogComponent, IgxCell, IgxColumnComponent, IgxColumnGroupComponent, IgxColumnResizingService, IgxDragIndicatorIconDirective, IgxEditRow, IgxExcelStyleHeaderIconDirective, IgxExcelStyleLoadingValuesTemplateDirective, IgxFilteringService, IgxGridBodyDirective, IgxGridCellComponent, IgxGridColumnResizerComponent, IgxGridEmptyTemplateContext, IgxGridEmptyTemplateDirective, IgxGridExcelStyleFilteringComponent, IgxGridFilteringCellComponent, IgxGridFilteringRowComponent, IgxGridGroupByAreaComponent, IgxGridHeaderComponent, IgxGridHeaderGroupComponent, IgxGridHeaderRowComponent, IgxGridHeaderTemplateContext, IgxGridLoadingTemplateDirective, IgxGridNavigationService, IgxGridRowDragGhostContext, IgxGridRowEditActionsTemplateContext, IgxGridRowEditTemplateContext, IgxGridRowEditTextTemplateContext, IgxGridRowTemplateContext, IgxGridSelectionService, IgxGridSummaryService, IgxGridTemplateContext, IgxGridToolbarComponent, IgxGridTransaction, IgxGridValidationService, IgxHeaderCollapsedIndicatorDirective, IgxHeaderExpandedIndicatorDirective, IgxHeadSelectorDirective, IgxHeadSelectorTemplateContext, IgxRowAddTextDirective, IgxRowCollapsedIndicatorDirective, IgxRowDirective, IgxRowDragGhostDirective, IgxRowEditActionsDirective, IgxRowEditTabStopDirective, IgxRowEditTemplateDirective, IgxRowEditTextDirective, IgxRowExpandedIndicatorDirective, IgxRowSelectorDirective, IgxRowSelectorTemplateContext, IgxSortAscendingHeaderIconDirective, IgxSortDescendingHeaderIconDirective, IgxSortHeaderIconDirective, IgxSummaryRowComponent, IgxToolbarToken, IPinColumnCancellableEventArgs, IPinColumnEventArgs, IPinningConfig, IPinRowEventArgs, IRowDataCancelableEventArgs, IRowDataEventArgs, IRowDragEndEventArgs, IRowDragStartEventArgs, IRowSelectionEventArgs, IRowToggleEventArgs, ISearchInfo, ISizeInfo, ISortingEventArgs, RowEditPositionStrategy, RowPinningPosition, RowType, WatchChanges } from 'igniteui-angular/grids/core'; + +interface IMatchInfoCache { + row: any; + index: number; + column: string; + metadata: Map; +} + +let FAKE_ROW_ID = -1; +const DEFAULT_ITEMS_PER_PAGE = 15; +const MINIMUM_COLUMN_WIDTH = 136; +// By default row editing overlay outlet is inside grid body so that overlay is hidden below grid header when scrolling. +// In cases when grid has 1-2 rows there isn't enough space in grid body and row editing overlay should be shown above header. +// Default row editing overlay height is higher then row height that is why the case is valid also for row with 2 rows. +// More accurate calculation is not possible, cause row editing overlay is still not shown and we don't know its height, +// but in the same time we need to set row editing overlay outlet before opening the overlay itself. +const MIN_ROW_EDITING_COUNT_THRESHOLD = 2; + +/* blazorIndirectRender + blazorComponent + omitModule + wcSkipComponentSuffix */ +@Directive() +export abstract class IgxGridBaseDirective implements GridType, + OnInit, DoCheck, OnDestroy, AfterContentInit, AfterViewInit { + + public readonly validation = inject(IgxGridValidationService); + /** @hidden @internal */ + public readonly selectionService = inject(IgxGridSelectionService); + protected colResizingService = inject(IgxColumnResizingService); + public readonly gridAPI = inject(IGX_GRID_SERVICE_BASE); + protected transactionFactory = inject(IgxFlatTransactionFactory); + private elementRef = inject>(ElementRef); + protected zone = inject(NgZone); + /** @hidden @internal */ + public document = inject(DOCUMENT); + public readonly cdr = inject(ChangeDetectorRef); + protected differs = inject(IterableDiffers); + protected viewRef = inject(ViewContainerRef); + protected injector = inject(Injector); + protected envInjector = inject(EnvironmentInjector); + public navigation = inject(IgxGridNavigationService); + /** @hidden @internal */ + public filteringService = inject(IgxFilteringService); + protected textHighlightService = inject(IgxTextHighlightService); + protected overlayService = inject(IgxOverlayService); + /** @hidden @internal */ + public summaryService = inject(IgxGridSummaryService); + private localeId = inject(LOCALE_ID); + protected platform = inject(PlatformUtil); + protected _diTransactions = inject(IgxGridTransaction, { optional: true }); + + /** + * Gets/Sets the display time for the row adding snackbar notification. + * + * @remarks + * By default it is 6000ms. + */ + @Input() + public snackbarDisplayTime = 6000; + + /** + * Gets/Sets whether to auto-generate the columns. + * + * @remarks + * The default value is false. When set to true, it will override all columns declared through code or in markup. + * @example + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public autoGenerate = false; + + /** + * Gets/Sets a list of property keys to be excluded from the generated column collection + * @remarks + * The collection is only used during initialization and changing it will not cause any changes in the generated columns at runtime + * unless the grid is destroyed and recreated. To modify the columns visible in the UI at runtime, please use their + * [hidden](https://www.infragistics.com/products/ignite-ui-angular/docs/typescript/latest/classes/IgxColumnComponent.html#hidden) property. + * @example + * ```html + * + * ``` + * ```typescript + * const Data = [{ 'Id': '1', 'ProductName': 'name1', 'Description': 'description1', 'Count': 5 }] + * ``` + */ + @Input() + public autoGenerateExclude: string[] = []; + + /** + * Controls whether columns moving is enabled in the grid. + * + */ + @Input({ transform: booleanAttribute }) + public moving = false; + + /** + * Gets/Sets a custom template when empty. + * + * @example + * ```html + * + * + * + * ``` + * Or + * ```html + * + * ``` + */ + @Input() + public get emptyGridTemplate(): TemplateRef { + return this._emptyGridTemplate || this.emptyDirectiveTemplate; + } + public set emptyGridTemplate(template: TemplateRef) { + this._emptyGridTemplate = template; + } + + /** + * Gets/Sets a custom template for adding row UI when grid is empty. + * + * @example + * ```html + * + * ``` + */ + @Input() + public addRowEmptyTemplate: TemplateRef; + + /** + * Gets/Sets a custom template when loading. + * + * @example + * ```html + * + * + * + * ``` + * Or + * ```html + * + * ``` + */ + @Input() + public get loadingGridTemplate(): TemplateRef { + return this._loadingGridTemplate || this.loadingDirectiveTemplate; + } + public set loadingGridTemplate(template: TemplateRef) { + this._loadingGridTemplate = template; + } + + /** + * Get/Set IgxSummaryRow height + */ + @Input() + public set summaryRowHeight(value: number) { + this._summaryRowHeight = value | 0; + this.summaryService.summaryHeight = value; + if (!this._init) { + this.reflow(); + } + } + + public get summaryRowHeight(): number { + if (this.hasSummarizedColumns && this.rootSummariesEnabled) { + return this._summaryRowHeight || this.summaryService.calcMaxSummaryHeight(); + } + return 0; + } + + /** @hidden @internal */ + public get hasColumnsToAutosize() { + return this._columns.some(x => x.width === 'fit-content'); + } + + /** + * Gets/Sets the data clone strategy of the grid when in edit mode. + * + * @example + * ```html + * + * ``` + */ + @Input() + public get dataCloneStrategy(): IDataCloneStrategy { + return this._dataCloneStrategy; + } + + public set dataCloneStrategy(strategy: IDataCloneStrategy) { + if (strategy) { + this._dataCloneStrategy = strategy; + this._transactions.cloneStrategy = strategy; + } + } + + /** + * Controls the copy behavior of the grid. + */ + @Input() + public clipboardOptions: IClipboardOptions = { + /** + * Enables/disables the copy behavior + */ + enabled: true, + /** + * Include the columns headers in the clipboard output. + */ + copyHeaders: true, + /** + * Apply the columns formatters (if any) on the data in the clipboard output. + */ + copyFormatters: true, + /** + * The separator used for formatting the copy output. Defaults to `\t`. + */ + separator: '\t' + }; + + /** + * Emitted after filtering is performed. + * + * @remarks + * Returns the filtering expressions tree of the column for which filtering was performed. + * @example + * ```html + * + * ``` + */ + @Output() + public filteringExpressionsTreeChange = new EventEmitter(); + + /** + * Emitted after advanced filtering is performed. + * + * @remarks + * Returns the advanced filtering expressions tree. + * @example + * ```html + * + * ``` + */ + @Output() + public advancedFilteringExpressionsTreeChange = new EventEmitter(); + + /** + * Emitted when grid is scrolled horizontally/vertically. + * + * @example + * ```html + * + * ``` + */ + @Output() + public gridScroll = new EventEmitter(); + + /* treatAsRef */ + /** + * Sets a conditional class selector to the grid's row element. + * Accepts an object literal, containing key-value pairs, + * where the key is the name of the CSS class and the value is + * either a callback function that returns a boolean, or boolean, like so: + * ```typescript + * callback = (row: RowType) => { return row.selected > 6; } + * rowClasses = { 'className' : this.callback }; + * ``` + * ```html + * + * ``` + * + * @memberof IgxColumnComponent + */ + @Input() + public rowClasses: any; + + /* treatAsRef */ + /** + * Sets conditional style properties on the grid row element. + * It accepts an object literal where the keys are + * the style properties and the value is an expression to be evaluated. + * ```typescript + * styles = { + * background: 'yellow', + * color: (row: RowType) => row.selected : 'red': 'white' + * } + * ``` + * ```html + * + * ``` + * + * @memberof IgxColumnComponent + */ + @Input() + public rowStyles = null; + + /** + * Gets/Sets the primary key. + * + * @example + * ```html + * + * ``` + */ + @WatchChanges() + @Input() + public get primaryKey(): string { + return this._primaryKey; + } + + public set primaryKey(value: string) { + this._primaryKey = value; + this.checkPrimaryKeyField(); + } + + /* blazorSuppress */ + /** + * Gets/Sets a unique values strategy used by the Excel Style Filtering + * + * @remarks + * Provides a callback for loading unique column values on demand. + * If this property is provided, the unique values it generates will be used by the Excel Style Filtering. + * @example + * ```html + * + * ``` + */ + @Input() + public uniqueColumnValuesStrategy: (column: ColumnType, + filteringExpressionsTree: IFilteringExpressionsTree, + done: (values: any[]) => void) => void; + + /** @hidden @internal */ + @ContentChildren(IgxGridExcelStyleFilteringComponent, { read: IgxGridExcelStyleFilteringComponent, descendants: false }) + public excelStyleFilteringComponents: QueryList; + + /** @hidden @internal */ + public get excelStyleFilteringComponent() { + return this.excelStyleFilteringComponents?.first; + } + + /** @hidden @internal */ + public get headerGroups() { + return this.theadRow.groups; + } + + /** + * Emitted when a cell is clicked. + * + * @remarks + * Returns the `IgxGridCell`. + * @example + * ```html + * + * ``` + */ + @Output() + public cellClick = new EventEmitter(); + + /** + * Emitted when a row is clicked. + * + * @remarks + * Returns the `IgxGridRow`. + * @example + * ```html + * + * ``` + */ + @Output() + public rowClick = new EventEmitter(); + + + /** + * Emitted when formGroup is created on edit of row/cell. + * + * @example + * ```html + * + * ``` + */ + @Output() + public formGroupCreated = new EventEmitter(); + + /** + * Emitted when grid's validation status changes. + * + * @example + * ```html + * + * ``` + */ + @Output() + public validationStatusChange = new EventEmitter(); + + /** + * Emitted when a cell is selected. + * + * @remarks + * Returns the `IgxGridCell`. + * @example + * ```html + * + * ``` + */ + @Output() + public selected = new EventEmitter(); + + /** + * Emitted when `IgxGridRowComponent` is selected. + * + * @example + * ```html + * + * ``` + */ + @Output() + public rowSelectionChanging = new EventEmitter(); + + /** + * Emitted when `IgxColumnComponent` is selected. + * + * @example + * ```html + * + * ``` + */ + @Output() + public columnSelectionChanging = new EventEmitter(); + + /** + * Emitted before `IgxColumnComponent` is pinned. + * + * @remarks + * The index at which to insert the column may be changed through the `insertAtIndex` property. + * @example + * ```typescript + * public columnPinning(event) { + * if (event.column.field === "Name") { + * event.insertAtIndex = 0; + * } + * } + * ``` + */ + @Output() + public columnPin = new EventEmitter(); + + /** + * Emitted after `IgxColumnComponent` is pinned. + * + * @remarks + * The index that the column is inserted at may be changed through the `insertAtIndex` property. + * @example + * ```typescript + * public columnPinning(event) { + * if (event.column.field === "Name") { + * event.insertAtIndex = 0; + * } + * } + * ``` + */ + @Output() + public columnPinned = new EventEmitter(); + + /** + * Emitted when cell enters edit mode. + * + * @remarks + * This event is cancelable. + * @example + * ```html + * + * + * ``` + */ + @Output() + public cellEditEnter = new EventEmitter(); + + /** + * Emitted when cell exits edit mode. + * + * @example + * ```html + * + * + * ``` + */ + @Output() + public cellEditExit = new EventEmitter(); + + /** + * Emitted when cell has been edited. + * + * @remarks + * Event is fired after editing is completed, when the cell is exiting edit mode. + * This event is cancelable. + * @example + * ```html + * + * + * ``` + */ + @Output() + public cellEdit = new EventEmitter(); + + /* blazorCSSuppress */ + /** + * Emitted after cell has been edited and editing has been committed. + * + * @example + * ```html + * + * + * ``` + */ + @Output() + public cellEditDone = new EventEmitter(); + + /** + * Emitted when a row enters edit mode. + * + * @remarks + * Emitted when [rowEditable]="true". + * This event is cancelable. + * @example + * ```html + * + * + * ``` + */ + @Output() + public rowEditEnter = new EventEmitter(); + + /** + * Emitted when exiting edit mode for a row. + * + * @remarks + * Emitted when [rowEditable]="true" & `endEdit(true)` is called. + * Emitted when changing rows during edit mode, selecting an un-editable cell in the edited row, + * performing paging operation, column resizing, pinning, moving or hitting `Done` + * button inside of the rowEditingOverlay, or hitting the `Enter` key while editing a cell. + * This event is cancelable. + * @example + * ```html + * + * + * ``` + */ + @Output() + public rowEdit = new EventEmitter(); + + /** + * Emitted after exiting edit mode for a row and editing has been committed. + * + * @remarks + * Emitted when [rowEditable]="true" & `endEdit(true)` is called. + * Emitted when changing rows during edit mode, selecting an un-editable cell in the edited row, + * performing paging operation, column resizing, pinning, moving or hitting `Done` + * button inside of the rowEditingOverlay, or hitting the `Enter` key while editing a cell. + * @example + * ```html + * + * + * ``` + */ + @Output() + public rowEditDone = new EventEmitter(); + + /** + * Emitted when row editing is canceled. + * + * @remarks + * Emits when [rowEditable]="true" & `endEdit(false)` is called. + * Emitted when changing hitting `Esc` key during cell editing and when click on the `Cancel` button + * in the row editing overlay. + * @example + * ```html + * + * + * ``` + */ + @Output() + public rowEditExit = new EventEmitter(); + + /** + * Emitted when a column is initialized. + * + * @remarks + * Returns the column object. + * @example + * ```html + * + * ``` + */ + @Output() + public columnInit = new EventEmitter(); + + /* blazorInclude */ + /** + * @hidden @internal + */ + @Output() + public columnsAutogenerated = new EventEmitter(); + + /** + * Emitted before sorting expressions are applied. + * + * @remarks + * Returns an `ISortingEventArgs` object. `sortingExpressions` key holds the sorting expressions. + * @example + * ```html + * + * ``` + */ + @Output() + public sorting = new EventEmitter(); + + /** + * Emitted after sorting is completed. + * + * @remarks + * Returns the sorting expression. + * @example + * ```html + * + * ``` + */ + @Output() + public sortingDone = new EventEmitter(); + + /** + * Emitted before filtering expressions are applied. + * + * @remarks + * Returns an `IFilteringEventArgs` object. `filteringExpressions` key holds the filtering expressions for the column. + * @example + * ```html + * + * ``` + */ + @Output() + public filtering = new EventEmitter(); + + /** + * Emitted after filtering is performed through the UI. + * + * @remarks + * Returns the filtering expressions tree of the column for which filtering was performed. + * @example + * ```html + * + * ``` + */ + @Output() + public filteringDone = new EventEmitter(); + + /* blazorCSSuppress */ + /** + * Emitted when a row is added. + * + * @remarks + * Returns the data for the new `IgxGridRowComponent` object. + * @example + * ```html + * + * ``` + */ + @Output() + public rowAdded = new EventEmitter(); + + /* blazorCSSuppress */ + /** + * Emitted when a row is deleted. + * + * @remarks + * Returns an `IRowDataEventArgs` object. + * @example + * ```html + * + * ``` + */ + @Output() + public rowDeleted = new EventEmitter(); + + /** + * Emmited when deleting a row. + * + * @remarks + * This event is cancelable. + * Returns an IRowDataCancellableEventArgs` object. + * @example + * ```html + * + * ``` + */ + @Output() + public rowDelete = new EventEmitter(); + + /** + * Emmited just before the newly added row is commited. + * + * @remarks + * This event is cancelable. + * Returns an IRowDataCancellableEventArgs` object. + * @example + * ```html + * + * ``` + */ + @Output() + public rowAdd = new EventEmitter(); + + /** + * Emitted after column is resized. + * + * @remarks + * Returns the `IgxColumnComponent` object's old and new width. + * @example + * ```html + * + * ``` + */ + @Output() + public columnResized = new EventEmitter(); + + /** + * Emitted when a cell or row is right clicked. + * + * @remarks + * Returns the `IgxGridCell` object if the immediate context menu target is a cell or an `IgxGridRow` otherwise. + * ```html + * + * ``` + */ + @Output() + public contextMenu = new EventEmitter(); + + /** + * Emitted when a cell is double clicked. + * + * @remarks + * Returns the `IgxGridCell` object. + * @example + * ```html + * + * ``` + */ + @Output() + public doubleClick = new EventEmitter(); + + /** + * Emitted before column visibility is changed. + * + * @remarks + * Args: { column: any, newValue: boolean } + * @example + * ```html + * + * ``` + */ + @Output() + public columnVisibilityChanging = new EventEmitter(); + + /** + * Emitted after column visibility is changed. + * + * @remarks + * Args: { column: IgxColumnComponent, newValue: boolean } + * @example + * ```html + * + * ``` + */ + @Output() + public columnVisibilityChanged = new EventEmitter(); + + /** + * Emitted when column moving starts. + * + * @remarks + * Returns the moved `IgxColumnComponent` object. + * @example + * ```html + * + * ``` + */ + @Output() + public columnMovingStart = new EventEmitter(); + + /** + * Emitted during the column moving operation. + * + * @remarks + * Returns the source and target `IgxColumnComponent` objects. This event is cancelable. + * @example + * ```html + * + * ``` + */ + @Output() + public columnMoving = new EventEmitter(); + + /** + * Emitted when column moving ends. + * + * @remarks + * Returns the source and target `IgxColumnComponent` objects. + * @example + * ```html + * + * ``` + */ + @Output() + public columnMovingEnd = new EventEmitter(); + + /** + * Emitted when keydown is triggered over element inside grid's body. + * + * @remarks + * This event is fired only if the key combination is supported in the grid. + * Return the target type, target object and the original event. This event is cancelable. + * @example + * ```html + * + * ``` + */ + @Output() + public gridKeydown = new EventEmitter(); + + /** + * Emitted when start dragging a row. + * + * @remarks + * Return the dragged row. + */ + @Output() + public rowDragStart = new EventEmitter(); + + /** + * Emitted when dropping a row. + * + * @remarks + * Return the dropped row. + */ + @Output() + public rowDragEnd = new EventEmitter(); + + /** + * Emitted when a copy operation is executed. + * + * @remarks + * Fired only if copy behavior is enabled through the [`clipboardOptions`]{@link IgxGridBaseDirective#clipboardOptions}. + */ + @Output() + public gridCopy = new EventEmitter(); + + /* blazorCSSuppress */ + /** + * Emitted when the rows are expanded or collapsed. + * + * @example + * ```html + * + * ``` + */ + @Output() + public expansionStatesChange = new EventEmitter>(); + + /* blazorInclude */ + /** + * Emitted when the rows are selected or deselected. + * + * @example + * ```html + * + * ``` + */ + @Output() + public selectedRowsChange = new EventEmitter(); + + /** + * Emitted when the expanded state of a row gets changed. + * + * @example + * ```html + * + * ``` + */ + @Output() + public rowToggle = new EventEmitter(); + + /** + * Emitted when the pinned state of a row is changed. + * + * @example + * ```html + * + * ``` + */ + @Output() + public rowPinning = new EventEmitter(); + + /** + * Emitted when the pinned state of a row is changed. + * + * @example + * ```html + * + * ``` + */ + @Output() + public rowPinned = new EventEmitter(); + + /** + * Emitted when the active node is changed. + * + * @example + * ``` + * + * ``` + */ + @Output() + public activeNodeChange = new EventEmitter(); + + /** + * Emitted before sorting is performed. + * + * @remarks + * Returns the sorting expressions. + * @example + * ```html + * + * ``` + */ + @Output() + public sortingExpressionsChange = new EventEmitter(); + + + /** + * Emitted when an export process is initiated by the user. + * + * @example + * ```typescript + * toolbarExporting(event: IGridToolbarExportEventArgs){ + * const toolbarExporting = event; + * } + * ``` + */ + @Output() + public toolbarExporting = new EventEmitter(); + + /* End of toolbar related definitions */ + + /** + * Emitted when making a range selection. + * + * @remarks + * Range selection can be made either through drag selection or through keyboard selection. + */ + @Output() + public rangeSelected = new EventEmitter(); + + /** Emitted after the ngAfterViewInit hook. At this point the grid exists in the DOM */ + @Output() + public rendered = new EventEmitter(); + + /** + * @hidden @internal + */ + @Output() + public localeChange = new EventEmitter(); + + /** + * Emitted before the grid's data view is changed because of a data operation, rebinding, etc. + * + * @example + * ```typescript + * + * ``` + */ + @Output() + public dataChanging = new EventEmitter(); + + /** + * Emitted after the grid's data view is changed because of a data operation, rebinding, etc. + * + * @example + * ```typescript + * + * ``` + */ + @Output() + public dataChanged = new EventEmitter(); + + + /** + * @hidden @internal + */ + @ViewChild(IgxSnackbarComponent) + public addRowSnackbar: IgxSnackbarComponent; + + /** + * @hidden @internal + */ + @ViewChild(IgxGridColumnResizerComponent) + public resizeLine: IgxGridColumnResizerComponent; + + /** + * @hidden @internal + */ + @ViewChild('loadingOverlay', { read: IgxToggleDirective, static: true }) + public loadingOverlay: IgxToggleDirective; + + /** + * @hidden @internal + */ + @ViewChild('igxLoadingOverlayOutlet', { read: IgxOverlayOutletDirective, static: true }) + public loadingOutlet: IgxOverlayOutletDirective; + + /* reactContentChildren */ + /* blazorInclude */ + /* blazorTreatAsCollection */ + /* blazorCollectionName: ColumnCollection */ + /* ngQueryListName: columnList */ + /** + * @hidden @internal + */ + @ContentChildren(IgxColumnComponent, { read: IgxColumnComponent, descendants: true }) + public columnList: QueryList = new QueryList(); + + /* contentChildren */ + /* blazorInclude */ + /* blazorTreatAsCollection */ + /* blazorCollectionName: ActionStripCollection */ + /* blazorCollectionItemName: ActionStrip */ + /* ngQueryListName: actionStripComponents */ + /** @hidden @internal */ + @ContentChildren(IgxActionStripToken) + protected actionStripComponents: QueryList; + + /** @hidden @internal */ + public get actionStrip() { + return this.actionStripComponents?.first; + } + + /** + * @hidden @internal + */ + @ContentChild(IgxExcelStyleLoadingValuesTemplateDirective, { read: IgxExcelStyleLoadingValuesTemplateDirective, static: true }) + public excelStyleLoadingValuesTemplateDirective: IgxExcelStyleLoadingValuesTemplateDirective; + + /** @hidden @internal */ + @ViewChild('emptyFilteredGrid', { read: TemplateRef, static: true }) + public emptyFilteredGridTemplate: TemplateRef; + + /** @hidden @internal */ + @ViewChild('defaultEmptyGrid', { read: TemplateRef, static: true }) + public emptyGridDefaultTemplate: TemplateRef; + + /** + * @hidden @internal + */ + @ViewChild('defaultLoadingGrid', { read: TemplateRef, static: true }) + public loadingGridDefaultTemplate: TemplateRef; + + /** + * @hidden @internal + */ + @ViewChild('scrollContainer', { read: IgxGridForOfDirective, static: true }) + public parentVirtDir: IgxGridForOfDirective; + + /** + * @hidden + * @internal + */ + @ContentChildren(IgxHeadSelectorDirective, { read: TemplateRef, descendants: false }) + public headSelectorsTemplates: QueryList>; + + /** + * @hidden + * @internal + */ + @ContentChildren(IgxRowSelectorDirective, { read: TemplateRef, descendants: false }) + public rowSelectorsTemplates: QueryList>; + + /** + * @hidden + * @internal + */ + @ContentChildren(IgxRowDragGhostDirective, { read: TemplateRef, descendants: false }) + public dragGhostCustomTemplates: QueryList>; + + + /** + * Gets the custom template, if any, used for row drag ghost. + */ + @Input() + public get dragGhostCustomTemplate() { + return this._dragGhostCustomTemplate || this.dragGhostCustomTemplates?.first; + } + + /** + * Sets a custom template for the row drag ghost. + *```html + * + * menu + * + * ``` + * ```typescript + * @ViewChild("'template'", {read: TemplateRef }) + * public template: TemplateRef; + * this.grid.dragGhostCustomTemplate = this.template; + * ``` + */ + public set dragGhostCustomTemplate(template: TemplateRef) { + this._dragGhostCustomTemplate = template; + } + + + /** + * @hidden @internal + */ + @ViewChild('verticalScrollContainer', { read: IgxGridForOfDirective, static: true }) + public verticalScrollContainer: IgxGridForOfDirective; + + /** + * @hidden @internal + */ + @ViewChild('verticalScrollHolder', { read: IgxGridForOfDirective, static: true }) + public verticalScroll: IgxGridForOfDirective; + + /** + * @hidden @internal + */ + @ViewChild('scr', { read: ElementRef, static: true }) + public scr: ElementRef; + + /** @hidden @internal */ + @ViewChild('headSelectorBaseTemplate', { read: TemplateRef, static: true }) + public headerSelectorBaseTemplate: TemplateRef; + + /** + * @hidden @internal + */ + @ViewChild('footer', { read: ElementRef }) + public footer: ElementRef; + + /** @hidden @internal */ + public get headerContainer() { + return this.theadRow?.headerForOf; + } + + /** @hidden @internal */ + public get headerSelectorContainer() { + return this.theadRow?.headerSelectorContainer; + } + + /** @hidden @internal */ + public get headerDragContainer() { + return this.theadRow?.headerDragContainer; + } + + /** @hidden @internal */ + public get headerGroupContainer() { + return this.theadRow?.headerGroupContainer; + } + + /** @hidden @internal */ + public get filteringRow(): IgxGridFilteringRowComponent { + return this.theadRow?.filterRow; + } + + /** @hidden @internal */ + @ViewChild(IgxGridHeaderRowComponent, { static: true }) + public theadRow: IgxGridHeaderRowComponent; + + /** @hidden @internal */ + @ViewChild(IgxGridGroupByAreaComponent) + public groupArea: IgxGridGroupByAreaComponent; + + /** + * @hidden @internal + */ + @ViewChild('tbody', { static: true }) + public tbody: ElementRef; + + @ViewChild(IgxGridBodyDirective, { static: true, read: ElementRef }) + protected tbodyContainer: ElementRef; + + /** + * @hidden @internal + */ + @ViewChild('pinContainer', { read: ElementRef }) + public pinContainer: ElementRef; + + /** + * @hidden @internal + */ + @ViewChild('tfoot', { static: true }) + public tfoot: ElementRef; + + /** + * @hidden @internal + */ + @ViewChild('igxRowEditingOverlayOutlet', { read: IgxOverlayOutletDirective, static: true }) + public rowEditingOutletDirective: IgxOverlayOutletDirective; + + /** + * @hidden @internal + */ + @ViewChildren(IgxTemplateOutletDirective, { read: IgxTemplateOutletDirective }) + public tmpOutlets: QueryList = new QueryList(); + + /** + * @hidden + * @internal + */ + @ViewChild('dragIndicatorIconBase', { read: TemplateRef, static: true }) + public dragIndicatorIconBase: TemplateRef; + + /** + * @hidden @internal + */ + @ContentChildren(IgxRowEditTemplateDirective, { descendants: false, read: TemplateRef }) + public rowEditCustomDirectives: QueryList>; + + /** + * @hidden @internal + */ + @ContentChildren(IgxRowEditTextDirective, { descendants: false, read: TemplateRef }) + public rowEditTextDirectives: QueryList>; + + /** + * Gets the row edit text template. + */ + @Input() + public get rowEditTextTemplate(): TemplateRef { + return this._rowEditTextTemplate || this.rowEditTextDirectives?.first; + } + /** + * Sets the row edit text template. + *```html + * + * Changes: {{rowChangesCount}} + * + * ``` + *```typescript + * @ViewChild('template', {read: TemplateRef }) + * public template: TemplateRef; + * this.grid.rowEditTextTemplate = this.template; + * ``` + */ + public set rowEditTextTemplate(template: TemplateRef) { + this._rowEditTextTemplate = template; + } + + /** + * @hidden @internal + */ + @ContentChild(IgxRowAddTextDirective, { read: TemplateRef }) + public rowAddText: TemplateRef; + + /** + * Gets the row add text template. + */ + @Input() + public get rowAddTextTemplate(): TemplateRef { + return this._rowAddTextTemplate || this.rowAddText; + } + /** + * Sets the row add text template. + *```html + * + * Adding Row + * + * ``` + *```typescript + * @ViewChild('template', {read: TemplateRef }) + * public template: TemplateRef; + * this.grid.rowAddTextTemplate = this.template; + * ``` + */ + public set rowAddTextTemplate(template: TemplateRef) { + this._rowAddTextTemplate = template; + } + + /** + * @hidden @internal + */ + @ContentChildren(IgxRowEditActionsDirective, { descendants: false, read: TemplateRef }) + public rowEditActionsDirectives: QueryList>; + + /** + * Gets the row edit actions template. + */ + @Input() + public get rowEditActionsTemplate(): TemplateRef { + return this._rowEditActionsTemplate || this.rowEditActionsDirectives?.first; + } + /** + * Sets the row edit actions template. + *```html + * + * + * + * + * ``` + *```typescript + * @ViewChild('template', {read: TemplateRef }) + * public template: TemplateRef; + * this.grid.rowEditActionsTemplate = this.template; + * ``` + */ + public set rowEditActionsTemplate(template: TemplateRef) { + this._rowEditActionsTemplate = template; + } + + /** + * The custom template, if any, that should be used when rendering a row expand indicator. + */ + @ContentChild(IgxRowExpandedIndicatorDirective, { read: TemplateRef }) + protected rowExpandedIndicatorDirectiveTemplate: TemplateRef = null; + + /** + * Gets the row expand indicator template. + */ + @Input() + public get rowExpandedIndicatorTemplate(): TemplateRef { + return this._rowExpandedIndicatorTemplate || this.rowExpandedIndicatorDirectiveTemplate; + } + + /** + * Sets the row expand indicator template. + *```html + * + * remove + * + * ``` + *```typescript + * @ViewChild('template', {read: TemplateRef }) + * public template: TemplateRef; + * this.grid.rowExpandedIndicatorTemplate = this.template; + * ``` + */ + public set rowExpandedIndicatorTemplate(template: TemplateRef) { + this._rowExpandedIndicatorTemplate = template; + } + + /** + * The custom template, if any, that should be used when rendering a row collapse indicator. + */ + @ContentChild(IgxRowCollapsedIndicatorDirective, { read: TemplateRef }) + protected rowCollapsedIndicatorDirectiveTemplate: TemplateRef = null; + + /** + * Gets the row collapse indicator template. + */ + @Input() + public get rowCollapsedIndicatorTemplate(): TemplateRef { + return this._rowCollapsedIndicatorTemplate || this.rowCollapsedIndicatorDirectiveTemplate; + } + + /** + * Sets the row collapse indicator template. + *```html + * + * add + * + * ``` + *```typescript + * @ViewChild('template', {read: TemplateRef }) + * public template: TemplateRef; + * this.grid.rowCollapsedIndicatorTemplate = this.template; + * ``` + */ + public set rowCollapsedIndicatorTemplate(template: TemplateRef) { + this._rowCollapsedIndicatorTemplate = template; + } + + /** + * The custom template, if any, that should be used when rendering a header expand indicator. + */ + @ContentChild(IgxHeaderExpandedIndicatorDirective, { read: TemplateRef }) + protected headerExpandedIndicatorDirectiveTemplate: TemplateRef = null; + + /** + * Gets the header expand indicator template. + */ + @Input() + public get headerExpandedIndicatorTemplate(): TemplateRef { + return this._headerExpandIndicatorTemplate || this.headerExpandedIndicatorDirectiveTemplate; + } + + /** + * Sets the header expand indicator template. + *```html + * + * remove + * + * ``` + *```typescript + * @ViewChild('template', {read: TemplateRef }) + * public template: TemplateRef; + * this.grid.headerExpandedIndicatorTemplate = this.template; + * ``` + */ + public set headerExpandedIndicatorTemplate(template: TemplateRef) { + this._headerExpandIndicatorTemplate = template; + } + + /** + * The custom template, if any, that should be used when rendering a header collapse indicator. + */ + @ContentChild(IgxHeaderCollapsedIndicatorDirective, { read: TemplateRef }) + protected headerCollapsedIndicatorDirectiveTemplate: TemplateRef = null; + + /** + * Gets the row collapse indicator template. + */ + @Input() + public get headerCollapsedIndicatorTemplate(): TemplateRef { + return this._headerCollapseIndicatorTemplate || this.headerCollapsedIndicatorDirectiveTemplate; + } + + /** + * Sets the row collapse indicator template. + *```html + * + * add + * + * ``` + *```typescript + * @ViewChild('template', {read: TemplateRef }) + * public template: TemplateRef; + * this.grid.headerCollapsedIndicatorTemplate = this.template; + * ``` + */ + public set headerCollapsedIndicatorTemplate(template: TemplateRef) { + this._headerCollapseIndicatorTemplate = template; + } + + /** @hidden @internal */ + @ContentChild(IgxExcelStyleHeaderIconDirective, { read: TemplateRef }) + public excelStyleHeaderIconDirectiveTemplate: TemplateRef = null; + + /** + * Gets the excel style header icon. + */ + @Input() + public get excelStyleHeaderIconTemplate(): TemplateRef { + return this._excelStyleHeaderIconTemplate || this.excelStyleHeaderIconDirectiveTemplate; + } + + /** + * Sets the excel style header icon. + *```html + * + * filter_alt + * + * ``` + *```typescript + * @ViewChild('template', {read: TemplateRef }) + * public template: TemplateRef; + * this.grid.excelStyleHeaderIconTemplate = this.template; + * ``` + */ + public set excelStyleHeaderIconTemplate(template: TemplateRef) { + this._excelStyleHeaderIconTemplate = template; + } + + + /** + * @hidden + * @internal + */ + @ContentChild(IgxSortAscendingHeaderIconDirective, { read: TemplateRef }) + public sortAscendingHeaderIconDirectiveTemplate: TemplateRef = null; + + /** + * The custom template, if any, that should be used when rendering a header sorting indicator when columns are sorted in asc order. + */ + @Input() + public get sortAscendingHeaderIconTemplate(): TemplateRef { + return this._sortAscendingHeaderIconTemplate; + } + + /** + * Sets a custom template that should be used when rendering a header sorting indicator when columns are sorted in asc order. + *```html + * + * expand_less + * + * ``` + * ```typescript + * @ViewChild("'template'", {read: TemplateRef }) + * public template: TemplateRef; + * this.grid.sortAscendingHeaderIconTemplate = this.template; + * ``` + */ + public set sortAscendingHeaderIconTemplate(template: TemplateRef) { + this._sortAscendingHeaderIconTemplate = template; + } + + /** @hidden @internal */ + @ContentChild(IgxSortDescendingHeaderIconDirective, { read: TemplateRef }) + public sortDescendingHeaderIconDirectiveTemplate: TemplateRef = null; + + /** + * The custom template, if any, that should be used when rendering a header sorting indicator when columns are sorted in desc order. + */ + @Input() + public get sortDescendingHeaderIconTemplate() { + return this._sortDescendingHeaderIconTemplate; + } + + /** + * Sets a custom template that should be used when rendering a header sorting indicator when columns are sorted in desc order. + *```html + * + * expand_more + * + * ``` + * ```typescript + * @ViewChild("'template'", {read: TemplateRef }) + * public template: TemplateRef; + * this.grid.sortDescendingHeaderIconTemplate = this.template; + * ``` + */ + public set sortDescendingHeaderIconTemplate(template: TemplateRef) { + this._sortDescendingHeaderIconTemplate = template; + } + + /** + * @hidden + * @internal + */ + @ContentChild(IgxSortHeaderIconDirective, { read: TemplateRef }) + public sortHeaderIconDirectiveTemplate: TemplateRef = null; + + /** + * Gets custom template, if any, that should be used when rendering a header sorting indicator when columns are not sorted. + */ + @Input() + public get sortHeaderIconTemplate(): TemplateRef { + return this._sortHeaderIconTemplate; + } + + /** + * Sets a custom template that should be used when rendering a header sorting indicator when columns are not sorted. + *```html + * + * unfold_more + * + * ``` + * ```typescript + * @ViewChild("'template'", {read: TemplateRef }) + * public template: TemplateRef; + * this.grid.sortHeaderIconTemplate = this.template; + * ``` + */ + public set sortHeaderIconTemplate(template: TemplateRef) { + this._sortHeaderIconTemplate = template; + } + + /** + * @hidden + * @internal + */ + @ContentChildren(IgxDragIndicatorIconDirective, { read: TemplateRef, descendants: false }) + public dragIndicatorIconTemplates: QueryList>; + + + @ContentChild(IgxGridLoadingTemplateDirective, { read: TemplateRef }) + protected loadingDirectiveTemplate: TemplateRef; + + @ContentChild(IgxGridEmptyTemplateDirective, { read: TemplateRef }) + protected emptyDirectiveTemplate: TemplateRef; + + /** + * @hidden @internal + */ + @ViewChildren(IgxRowEditTabStopDirective) + public rowEditTabsDEFAULT: QueryList; + + /** + * @hidden @internal + */ + @ContentChildren(IgxRowEditTabStopDirective, { descendants: true }) + public rowEditTabsCUSTOM: QueryList; + + /** + * @hidden @internal + */ + @ViewChild('rowEditingOverlay', { read: IgxToggleDirective }) + public rowEditingOverlay: IgxToggleDirective; + + /** + * @hidden @internal + */ + @HostBinding('attr.tabindex') + public tabindex = 0; + + /** + * @hidden @internal + */ + @HostBinding('attr.role') + public hostRole = 'grid'; + + /* contentChildren */ + /* blazorInclude */ + /* blazorTreatAsCollection */ + /* blazorCollectionName: GridToolbarCollection */ + /* ngQueryListName: toolbar */ + /** @hidden @internal */ + @ContentChildren(IgxToolbarToken) + public toolbar: QueryList; + + /* contentChildren */ + /* blazorInclude */ + /* blazorTreatAsCollection */ + /* blazorCollectionName: PaginatorCollection */ + /* ngQueryListName: paginationComponents */ + /** @hidden @internal */ + @ContentChildren(IgxPaginatorToken) + protected paginationComponents: QueryList; + + /** + * @hidden @internal + */ + @ViewChild('igxFilteringOverlayOutlet', { read: IgxOverlayOutletDirective, static: true }) + protected _outletDirective: IgxOverlayOutletDirective; + + /** + * @hidden @internal + * @igxElementsAnchor + */ + @ViewChild('sink', { read: ViewContainerRef, static: true }) + public anchor: ViewContainerRef; + + /** + * @hidden @internal + */ + @ViewChild('defaultExpandedTemplate', { read: TemplateRef, static: true }) + protected defaultExpandedTemplate: TemplateRef; + + /** + * @hidden @internal + */ + @ViewChild('defaultCollapsedTemplate', { read: TemplateRef, static: true }) + protected defaultCollapsedTemplate: TemplateRef; + + /** + * @hidden @internal + */ + @ViewChild('defaultESFHeaderIcon', { read: TemplateRef, static: true }) + protected defaultESFHeaderIconTemplate: TemplateRef; + + @ViewChildren('summaryRow', { read: IgxSummaryRowComponent }) + protected _summaryRowList: QueryList; + + @ViewChildren('row') + private _rowList: QueryList; + + @ViewChildren('pinnedRow') + private _pinnedRowList: QueryList; + + /** + * @hidden @internal + */ + @ViewChild('defaultRowEditTemplate', { read: TemplateRef, static: true }) + private defaultRowEditTemplate: TemplateRef; + + @ViewChildren(IgxRowDirective, { read: IgxRowDirective }) + private _dataRowList: QueryList; + + @HostBinding('class.igx-grid') + protected baseClass = 'igx-grid'; + + @HostBinding('attr.aria-colcount') + protected get ariaColCount(): number { + return this.visibleColumns.length; + } + + @HostBinding('attr.aria-rowcount') + protected get ariaRowCount(): number { + return this._rendered ? this._rowCount : null; + } + + /** + * Gets/Sets the resource strings. + * + * @remarks + * By default it uses EN resources. + */ + @Input() + public set resourceStrings(value: IGridResourceStrings) { + this._resourceStrings = Object.assign({}, this._resourceStrings, value); + } + + public get resourceStrings(): IGridResourceStrings { + return this._resourceStrings; + } + + /** + * Gets/Sets the filtering logic of the `IgxGridComponent`. + * + * @remarks + * The default is AND. + * @example + * ```html + * + * ``` + */ + @WatchChanges() + @Input() + public get filteringLogic() { + return this._filteringExpressionsTree.operator; + } + + public set filteringLogic(value: FilteringLogic) { + this._filteringExpressionsTree.operator = value; + } + + /* mustSetInCodePlatforms: WebComponents;Blazor */ + /** + * Gets/Sets the filtering state. + * + * @example + * ```html + * + * ``` + * @remarks + * Supports two-way binding. + */ + @WatchChanges() + @Input() + public get filteringExpressionsTree() { + return this._filteringExpressionsTree; + } + + public set filteringExpressionsTree(value) { + if (value && isTree(value)) { + for (let index = 0; index < value.filteringOperands.length; index++) { + if (!(isTree(value.filteringOperands[index]))) { + const newExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And, value.filteringOperands[index].fieldName); + newExpressionsTree.filteringOperands.push(value.filteringOperands[index]); + value.filteringOperands[index] = newExpressionsTree; + } + } + + value.type = FilteringExpressionsTreeType.Regular; + if (value && this._columns?.length > 0) { + this._filteringExpressionsTree = this.getRecreatedTree(value); + } else { + this._filteringExpressionsTree = value; + } + this.filteringPipeTrigger++; + this.filteringExpressionsTreeChange.emit(this._filteringExpressionsTree); + + if (this.filteringService.isFilteringExpressionsTreeEmpty(this._filteringExpressionsTree) && + this.filteringService.isFilteringExpressionsTreeEmpty(this._advancedFilteringExpressionsTree)) { + this._filteredData = null; + } + + this.filteringService.refreshExpressions(); + this.selectionService.clearHeaderCBState(); + this.summaryService.clearSummaryCache(); + this.notifyChanges(); + } + } + + /** + * Gets/Sets the advanced filtering state. + * + * @example + * ```typescript + * let advancedFilteringExpressionsTree = this.grid.advancedFilteringExpressionsTree; + * this.grid.advancedFilteringExpressionsTree = logic; + * ``` + */ + @WatchChanges() + @Input() + public get advancedFilteringExpressionsTree() { + return this._advancedFilteringExpressionsTree; + } + + public set advancedFilteringExpressionsTree(value) { + const filteringEventArgs: IFilteringEventArgs = { + owner: this, + filteringExpressions: value, + cancel: false + }; + + this.filtering.emit(filteringEventArgs); + + if (filteringEventArgs.cancel) { + return; + } + + if (value && isTree(value)) { + value.type = FilteringExpressionsTreeType.Advanced; + if (this._columns && this._columns.length > 0) { + this._advancedFilteringExpressionsTree = this.getRecreatedTree(value); + } else { + this._advancedFilteringExpressionsTree = value; + } + this.filteringPipeTrigger++; + } else { + this._advancedFilteringExpressionsTree = null; + } + this.advancedFilteringExpressionsTreeChange.emit(this._advancedFilteringExpressionsTree); + + if (this.filteringService.isFilteringExpressionsTreeEmpty(this._filteringExpressionsTree) && + this.filteringService.isFilteringExpressionsTreeEmpty(this._advancedFilteringExpressionsTree)) { + this._filteredData = null; + } + + this.selectionService.clearHeaderCBState(); + this.summaryService.clearSummaryCache(); + this.notifyChanges(); + + // Wait for the change detection to update filtered data through the pipes and then emit the event. + requestAnimationFrame(() => this.filteringDone.emit(this._advancedFilteringExpressionsTree)); + } + + /** + * Gets/Sets the locale. + * + * @remarks + * If not set, returns browser's language. + */ + @Input() + public get locale(): string { + return this._locale; + } + + public set locale(value: string) { + if (value !== this._locale) { + this._locale = value; + this._currencyPositionLeft = undefined; + this.summaryService.clearSummaryCache(); + this.pipeTrigger++; + this.notifyChanges(); + this.localeChange.emit(); + } + } + + @Input() + public get pagingMode() { + return this._pagingMode; + } + + public set pagingMode(val: GridPagingMode) { + this._pagingMode = val; + this.pipeTrigger++; + this.notifyChanges(true); + } + + /** @hidden @internal */ + public get page(): number { + return this.paginator?.page || 0; + } + + public set page(val: number) { + if (this.paginator) { + this.paginator.page = val; + } + } + + /** @hidden @internal */ + public get perPage(): number { + return this.paginator?.perPage || DEFAULT_ITEMS_PER_PAGE; + } + + public set perPage(val: number) { + if (this.paginator) { + this.paginator.perPage = val; + } + } + + /** + * Gets/Sets if the row selectors are hidden. + * + * @remarks + * By default row selectors are shown + */ + @WatchChanges() + @Input({ transform: booleanAttribute }) + public get hideRowSelectors() { + return this._hideRowSelectors; + } + + public set hideRowSelectors(value: boolean) { + this._hideRowSelectors = value; + this.notifyChanges(true); + } + + /** + * Gets/Sets whether rows can be moved. + * + * @example + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public get rowDraggable(): boolean { + return this._rowDrag && this.hasVisibleColumns; + } + + public set rowDraggable(val: boolean) { + this._rowDrag = val; + this.notifyChanges(true); + } + + /** + * Gets/Sets the trigger for validators used when editing the grid. + * + * @example + * ```html + * + * ``` + */ + @Input() + public validationTrigger: GridValidationTrigger = 'change'; + + /** + * @hidden + * @internal + */ + public rowDragging = false; + + /** @hidden @internal */ + public dragRowID = null; + + /** + * Gets/Sets whether the rows are editable. + * + * @remarks + * By default it is set to false. + * @example + * ```html + * + * ``` + */ + @WatchChanges() + @Input({ transform: booleanAttribute }) + public get rowEditable(): boolean { + return this._rowEditable; + } + + public set rowEditable(val: boolean) { + if (!this._init) { + this.refreshGridState(); + } + this._rowEditable = val; + this.notifyChanges(); + } + + /** + * Gets/Sets the height. + * + * @example + * ```html + * + * ``` + */ + @WatchChanges() + @HostBinding('style.height') + @Input() + public get height(): string | null { + return this._height; + } + + public set height(value: string | null) { + if (this._height !== value) { + this._height = value; + this.nativeElement.style.height = value; + this.notifyChanges(true); + } + } + + /** + * @hidden @internal + */ + @HostBinding('style.width') + public get hostWidth() { + return this._width || this._hostWidth; + } + + /** + * Gets/Sets the width of the grid. + * + * @example + * ```typescript + * let gridWidth = this.grid.width; + * ``` + */ + @WatchChanges() + @Input() + public get width(): string | null { + return this._width; + } + + public set width(value: string | null) { + if (this._width !== value) { + this._width = value; + this.nativeElement.style.width = value; + this.notifyChanges(true); + } + } + + /** @hidden @internal */ + public get headerWidth() { + return parseInt(this.width, 10) - 17; + } + + /** + * Gets/Sets the row height. + * + * @example + * ```html + * + * ``` + */ + @WatchChanges() + @Input() + public get rowHeight(): number { + return this._rowHeight ? this._rowHeight : this.defaultRowHeight; + } + + public set rowHeight(value: number | string) { + if (typeof value !== 'number') { + value = parseInt(value, 10); + } + this._rowHeight = value; + } + + /** + * Gets/Sets the default width of the columns. + * + * @example + * ```html + * + * ``` + */ + @WatchChanges() + @Input() + public get columnWidth(): string { + return this._columnWidth; + } + public set columnWidth(value: string) { + this._columnWidth = value; + this.columnWidthSetByUser = true; + this.notifyChanges(true); + } + + /** + * Get/Sets the message displayed when there are no records. + * + * @example + * ```html + * + * ``` + */ + @Input() + public set emptyGridMessage(value: string) { + this._emptyGridMessage = value; + } + public get emptyGridMessage(): string { + return this._emptyGridMessage || this.resourceStrings.igx_grid_emptyGrid_message; + } + + /** + * Gets/Sets whether the grid is going to show a loading indicator. + * + * @example + * ```html + * + * ``` + */ + @WatchChanges() + @Input({ transform: booleanAttribute }) + public set isLoading(value: boolean) { + if (this._isLoading !== value) { + this._isLoading = value; + if (this.data) { + this.evaluateLoadingState(); + } + } + Promise.resolve().then(() => { + // wait for the current detection cycle to end before triggering a new one. + this.notifyChanges(); + }); + } + + public get isLoading(): boolean { + return this._isLoading; + } + + /** + * Gets/Sets whether the columns should be auto-generated once again after the initialization of the grid + * + * @remarks + * This will allow to bind the grid to remote data and having auto-generated columns at the same time. + * Note that after generating the columns, this property would be disabled to avoid re-creating + * columns each time a new data is assigned. + * @example + * ```typescript + * this.grid.shouldGenerate = true; + * ``` + * @deprecated in version 18.2.0. Column re-creation now relies on `autoGenerate` instead. + */ + public get shouldGenerate(): boolean { + return this.autoGenerate; + } + + public set shouldGenerate(value: boolean) { + this.autoGenerate = value; + } + + /** + * Gets/Sets the message displayed when there are no records and the grid is filtered. + * + * @example + * ```html + * + * ``` + */ + @Input() + public set emptyFilteredGridMessage(value: string) { + this._emptyFilteredGridMessage = value; + } + + public get emptyFilteredGridMessage(): string { + return this._emptyFilteredGridMessage || this.resourceStrings.igx_grid_emptyFilteredGrid_message; + } + + /* mustSetInCodePlatforms: WebComponents;Blazor;React */ + /** + * Gets/Sets the initial pinning configuration. + * + * @remarks + * Allows to apply pinning the columns to the start or the end. + * Note that pinning to both sides at a time is not allowed. + * @example + * ```html + * + * ``` + */ + @Input() + public get pinning() { + return this._pinning; + } + public set pinning(value) { + if (value !== this._pinning) { + this.resetCaches(); + } + this._pinning = value; + } + + /** + * Gets/Sets if the filtering is enabled. + * + * @example + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public get allowFiltering() { + return this._allowFiltering; + } + + public set allowFiltering(value) { + if (this._allowFiltering !== value) { + this._allowFiltering = value; + this.filteringService.registerSVGIcons(); + + + this.filteringService.isFilterRowVisible = false; + this.filteringService.filteredColumn = null; + + this.notifyChanges(true); + } + } + + /** + * Gets/Sets a value indicating whether the advanced filtering is enabled. + * + * @example + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public get allowAdvancedFiltering() { + return this._allowAdvancedFiltering; + } + + public set allowAdvancedFiltering(value) { + if (this._allowAdvancedFiltering !== value) { + this._allowAdvancedFiltering = value; + this.filteringService.registerSVGIcons(); + + if (!this._init) { + this.notifyChanges(true); + } + } + } + + /** + * Gets/Sets the filter mode. + * + * @example + * ```html + * + * ``` + * @remarks + * By default it's set to FilterMode.quickFilter. + */ + @Input() + public get filterMode() { + return this._filterMode; + } + + public set filterMode(value: FilterMode) { + switch (value) { + case FilterMode.excelStyleFilter: + case FilterMode.quickFilter: + this._filterMode = value; + break; + default: + break; + } + + if (this.filteringService.isFilterRowVisible) { + this.filteringRow.close(); + } + this.notifyChanges(true); + } + + /** + * Gets/Sets the summary position. + * + * @example + * ```html + * + * ``` + * @remarks + * By default it is bottom. + */ + @Input() + public get summaryPosition() { + return this._summaryPosition; + } + + public set summaryPosition(value: GridSummaryPosition) { + this._summaryPosition = value; + this.notifyChanges(); + } + + /** + * Gets/Sets the summary calculation mode. + * + * @example + * ```html + * + * ``` + * @remarks + * By default it is rootAndChildLevels which means the summaries are calculated for the root level and each child level. + */ + @Input() + public get summaryCalculationMode() { + return this._summaryCalculationMode; + } + + public set summaryCalculationMode(value: GridSummaryCalculationMode) { + this._summaryCalculationMode = value; + if (!this._init) { + this.crudService.endEdit(false); + this.summaryService.resetSummaryHeight(); + this.notifyChanges(true); + } + } + + /** + * Controls whether the summary row is visible when groupBy/parent row is collapsed. + * + * @example + * ```html + * + * ``` + * @remarks + * By default showSummaryOnCollapse is set to 'false' which means that the summary row is not visible + * when the groupBy/parent row is collapsed. + */ + @Input({ transform: booleanAttribute }) + public get showSummaryOnCollapse() { + return this._showSummaryOnCollapse; + } + + public set showSummaryOnCollapse(value: boolean) { + this._showSummaryOnCollapse = value; + this.notifyChanges(); + } + + /** + * Gets/Sets the filtering strategy of the grid. + * + * @example + * ```html + * + * ``` + */ + @Input() + public get filterStrategy(): IFilteringStrategy { + return this._filterStrategy; + } + + public set filterStrategy(classRef: IFilteringStrategy) { + this._filterStrategy = classRef; + } + + /** + * Gets/Sets the sorting strategy of the grid. + * + * @example + * ```html + * + * ``` + */ + @Input() + public get sortStrategy(): IGridSortingStrategy { + return this._sortingStrategy; + } + + public set sortStrategy(value: IGridSortingStrategy) { + this._sortingStrategy = value; + } + + + /** + * Gets/Sets the merge strategy of the grid. + * + * @example + * ```html + * + * ``` + */ + @Input() + public get mergeStrategy() { + return this._mergeStrategy; + } + public set mergeStrategy(value) { + this._mergeStrategy = value; + } + + /** + * Gets/Sets the sorting options - single or multiple sorting. + * Accepts an `ISortingOptions` object with any of the `mode` properties. + * + * @example + * ```typescript + * const _sortingOptions: ISortingOptions = { + * mode: 'single' + * } + * ```html + * + * ``` + */ + @Input() + public set sortingOptions(value: ISortingOptions) { + if (!this._init) { + // clear sort only if option is changed runtime. No need to clear on initial load. + this.clearSort(); + } + this._sortingOptions = Object.assign(this._sortingOptions, value); + } + + public get sortingOptions() { + return this._sortingOptions; + } + + /* blazorByValueArray */ + /* blazorAlwaysWriteback */ + /* @tsTwoWayProperty (true, "SelectedRowsChange", "Detail", false) */ + /* blazorPrimitiveValue */ + /** + * Gets/Sets the current selection state. + * + * @remarks + * Represents the selected rows' IDs (primary key or rowData) + * @example + * ```html + * + * ``` + */ + @Input() + public set selectedRows(rowIDs: any[]) { + this.selectRows(rowIDs || [], true); + } + + public get selectedRows(): any[] { + return this.selectionService.getSelectedRows(); + } + + + /** @hidden @internal */ + public get headerGroupsList(): IgxGridHeaderGroupComponent[] { + return this.theadRow.groups; + } + + /** @hidden @internal */ + public get headerCellList(): IgxGridHeaderComponent[] { + return this.headerGroupsList.map(headerGroup => headerGroup.header).filter(header => header); + } + + /** @hidden @internal */ + public get filterCellList(): IgxGridFilteringCellComponent[] { + return this.headerGroupsList.map(group => group.filter).filter(cell => cell); + } + + /** + * @hidden @internal + */ + public get summariesRowList() { + const res = new QueryList(); + if (!this._summaryRowList) { + return res; + } + const sumList = this._summaryRowList.filter((item) => item.element.nativeElement.parentElement !== null); + res.reset(sumList); + return res; + } + + /* csSuppress */ + /** + * A list of `IgxGridRowComponent`. + * + * @example + * ```typescript + * const rowList = this.grid.rowList; + * ``` + */ + public get rowList() { + const res = new QueryList(); + if (!this._rowList) { + return res; + } + const rList = this._rowList + .filter((item) => item.element.nativeElement.parentElement !== null) + .sort((a, b) => a.index - b.index); + res.reset(rList); + return res; + } + + /* csSuppress */ + /** + * A list of currently rendered `IgxGridRowComponent`'s. + * + * @example + * ```typescript + * const dataList = this.grid.dataRowList; + * ``` + */ + public get dataRowList(): QueryList { + const res = new QueryList(); + if (!this._dataRowList) { + return res; + } + const rList = this._dataRowList.filter(item => item.element.nativeElement.parentElement !== null).sort((a, b) => a.index - b.index); + res.reset(rList); + return res; + } + + /** + * Gets the header row selector template. + */ + @Input() + public get headSelectorTemplate(): TemplateRef { + return this._headSelectorTemplate || this.headSelectorsTemplates?.first; + } + + /** + * Sets the header row selector template. + * ```html + * + * {{ headContext.selectedCount }} / {{ headContext.totalCount }} + * + * ``` + * ```typescript + * @ViewChild("'template'", {read: TemplateRef }) + * public template: TemplateRef; + * this.grid.headSelectorTemplate = this.template; + * ``` + */ + public set headSelectorTemplate(template: TemplateRef) { + this._headSelectorTemplate = template; + } + + /** + * @hidden + * @internal + */ + public get isRowPinningToTop() { + return this.pinning.rows !== RowPinningPosition.Bottom; + } + + /** + * Gets the row selector template. + */ + @Input() + public get rowSelectorTemplate(): TemplateRef { + return this._rowSelectorTemplate || this.rowSelectorsTemplates?.first; + } + + /** + * Sets a custom template for the row selectors. + * ```html + * + * + * + * ``` + * ```typescript + * @ViewChild("'template'", {read: TemplateRef }) + * public template: TemplateRef; + * this.grid.rowSelectorTemplate = this.template; + * ``` + */ + public set rowSelectorTemplate(template: TemplateRef) { + this._rowSelectorTemplate = template; + } + + /** + * @hidden @internal + */ + public get rowOutletDirective() { + return this.rowEditingOutletDirective; + } + + /** + * @hidden @internal + */ + public get parentRowOutletDirective() { + return this.outlet; + } + + /** + * @hidden @internal + */ + public get rowEditCustom(): TemplateRef { + if (this.rowEditCustomDirectives && this.rowEditCustomDirectives.first) { + return this.rowEditCustomDirectives.first; + } + return null; + } + + /** + + /** + * @hidden @internal + */ + public get rowEditContainer(): TemplateRef { + return this.rowEditCustom ? this.rowEditCustom : this.defaultRowEditTemplate; + } + + /** + * The custom template, if any, that should be used when rendering the row drag indicator icon + */ + @Input() + public get dragIndicatorIconTemplate(): TemplateRef { + return this._customDragIndicatorIconTemplate || this.dragIndicatorIconTemplates?.first; + } + + /** + * Sets a custom template that should be used when rendering the row drag indicator icon. + *```html + * + * expand_less + * + * ``` + * ```typescript + * @ViewChild("'template'", {read: TemplateRef }) + * public template: TemplateRef; + * this.grid.dragIndicatorIconTemplate = this.template; + * ``` + */ + public set dragIndicatorIconTemplate(val: TemplateRef) { + this._customDragIndicatorIconTemplate = val; + } + + /** + * @hidden @internal + */ + public get firstEditableColumnIndex(): number { + const index = this.visibleColumns.filter(col => col.editable) + .map(c => c.visibleIndex).sort((a, b) => a - b); + return index.length ? index[0] : null; + } + + /** + * @hidden @internal + */ + public get lastEditableColumnIndex(): number { + const index = this.visibleColumns.filter(col => col.editable) + .map(c => c.visibleIndex).sort((a, b) => a > b ? -1 : 1); + return index.length ? index[0] : null; + } + + /** + * @hidden @internal + * TODO: Nav service logic doesn't handle 0 results from this querylist + */ + public get rowEditTabs(): QueryList { + return this.rowEditTabsCUSTOM.length ? this.rowEditTabsCUSTOM : this.rowEditTabsDEFAULT; + } + + /** @hidden @internal */ + public get activeDescendant(): string | undefined { + const activeElem = this.navigation.activeNode; + + if (!activeElem || !Object.keys(activeElem).length || activeElem.row < 0) { + return; + } + return `${this.id}_${activeElem.row}_${activeElem.column}`; + } + + /** @hidden @internal */ + public get bannerClass(): string { + const position = this.rowEditPositioningStrategy.isTop ? 'igx-banner__border-top' : 'igx-banner__border-bottom'; + return `igx-banner ${position}`; + } + + /* mustSetInCodePlatforms: WebComponents;Blazor;React */ + /** + * Gets/Sets the sorting state. + * + * @remarks + * Supports two-way data binding. + * @example + * ```html + * + * ``` + */ + @WatchChanges() + @Input() + public get sortingExpressions(): ISortingExpression[] { + return this._sortingExpressions; + } + + public set sortingExpressions(value: ISortingExpression[]) { + this._sortingExpressions = cloneArray(value); + this.sortingExpressionsChange.emit(this._sortingExpressions); + if (this.cellMergeMode === GridCellMergeMode.onSort) { + this.resetColumnCollections(); + } + this.notifyChanges(); + } + + /** + * Gets the number of hidden columns. + * + * @example + * ```typescript + * const hiddenCol = this.grid.hiddenColumnsCount; + * `` + */ + public get hiddenColumnsCount() { + return this._columns.filter((col) => col.columnGroup === false && col.hidden === true).length; + } + + /** + * Gets the number of pinned columns. + */ + public get pinnedColumnsCount() { + return this.pinnedColumns.filter(col => !col.columnLayout).length; + } + + /** + * Gets/Sets whether the grid has batch editing enabled. + * When batch editing is enabled, changes are not made directly to the underlying data. + * Instead, they are stored as transactions, which can later be committed w/ the `commit` method. + * + * @example + * ```html + * + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public get batchEditing(): boolean { + return this._batchEditing; + } + + public set batchEditing(val: boolean) { + if (val !== this._batchEditing) { + delete this._transactions; + this._batchEditing = val; + this.switchTransactionService(val); + this.subscribeToTransactions(); + } + } + + /* blazorSuppress */ + /** + * Get transactions service for the grid. + */ + public get transactions(): TransactionService { + if (this._diTransactions && !this.batchEditing) { + return this._diTransactions; + } + return this._transactions; + } + + /** + * @hidden @internal + */ + public get currentRowState(): any { + return this._currentRowState; + } + + /** + * @hidden @internal + */ + public get currencyPositionLeft(): boolean { + if (this._currencyPositionLeft !== undefined) { + return this._currencyPositionLeft; + } + const format = getLocaleNumberFormat(this.locale, NumberFormatStyle.Currency); + const formatParts = format.split(','); + const i = formatParts.indexOf(formatParts.find(c => c.includes('¤'))); + return this._currencyPositionLeft = i < 1; + } + + /** + * Gets/Sets cell selection mode. + * + * @remarks + * By default the cell selection mode is multiple + * @param selectionMode: GridSelectionMode + */ + @WatchChanges() + @Input() + public get cellSelection() { + return this._cellSelectionMode; + } + + public set cellSelection(selectionMode: GridSelectionMode) { + this._cellSelectionMode = selectionMode; + // if (this.gridAPI.grid) { + this.selectionService.clear(true); + this._activeRowIndexes = null; + this.notifyChanges(); + // } + } + + /** + * Gets/Sets cell merge mode. + * + */ + @WatchChanges() + @Input() + public get cellMergeMode() { + return this._cellMergeMode; + } + + public set cellMergeMode(value: GridCellMergeMode) { + if (value !== this._cellMergeMode) { + this._cellMergeMode = value; + this.resetColumnCollections(); + this.notifyChanges(); + } + } + + /** + * Gets/Sets row selection mode + * + * @remarks + * By default the row selection mode is 'none' + * Note that in IgxGrid and IgxHierarchicalGrid 'multipleCascade' behaves like 'multiple' + */ + @WatchChanges() + @Input() + public get rowSelection() { + return this._rowSelectionMode; + } + + public set rowSelection(selectionMode: GridSelectionMode) { + this._rowSelectionMode = selectionMode; + if (!this._init) { + this.selectionService.clearAllSelectedRows(); + this.notifyChanges(true); + } + } + + /** + * Gets/Sets column selection mode + * + * @remarks + * By default the row selection mode is none + * @param selectionMode: GridSelectionMode + */ + @WatchChanges() + @Input() + public get columnSelection() { + return this._columnSelectionMode; + } + + public set columnSelection(selectionMode: GridSelectionMode) { + this._columnSelectionMode = selectionMode; + // if (this.gridAPI.grid) { + this.selectionService.clearAllSelectedColumns(); + this.notifyChanges(true); + // } + } + + /** + * @hidden @internal + */ + public set pagingState(value) { + this._pagingState = value; + if (this.paginator && !this._init) { + this.paginator.totalRecords = value.metadata.countRecords; + } + } + + public get pagingState() { + return this._pagingState; + } + + /** + * @hidden @internal + */ + public rowEditMessage; + + /** + * @hidden @internal + */ + public calcWidth: number; + /** + * @hidden @internal + */ + public calcHeight = 0; + /** + * @hidden @internal + */ + public tfootHeight: number; + + /** + * @hidden @internal + */ + public disableTransitions = false; + + /** + * Represents the last search information. + */ + public get lastSearchInfo(): ISearchInfo { + return this._lastSearchInfo; + } + + /** + * @hidden @internal + */ + public columnWidthSetByUser = false; + + /** + * @hidden @internal + */ + public pinnedRecords: any[]; + + /** + * @hidden @internal + */ + public unpinnedRecords: any[]; + + /** + * @hidden @internal + */ + public rendered$ = this.rendered.asObservable().pipe(shareReplay({ bufferSize: 1, refCount: true })); + + /** @hidden @internal */ + public resizeNotify = new Subject(); + + /** @hidden @internal */ + public rowAddedNotifier = new Subject(); + + /** @hidden @internal */ + public rowDeletedNotifier = new Subject(); + + /** @hidden @internal */ + public pipeTriggerNotifier = new Subject(); + + /** @hidden @internal */ + public _filteredSortedPinnedData: any[]; + + /** @hidden @internal */ + public _filteredSortedUnpinnedData: any[]; + + /** @hidden @internal */ + public _filteredPinnedData: any[]; + + /** + * @hidden + */ + public _filteredUnpinnedData; + /** + * @hidden @internal + */ + public _destroyed = false; + /** + * @hidden @internal + */ + public _totalRecords = -1; + /** + * @hidden @internal + */ + public columnsWithNoSetWidths = null; + /** + * @hidden @internal + */ + public pipeTrigger = 0; + /** + * @hidden @internal + */ + public filteringPipeTrigger = 0; + + /** + * @hidden @internal + */ + public isColumnWidthSum = false; + + /** + * @hidden @internal + */ + public summaryPipeTrigger = 0; + /** + * @hidden @internal + */ + public groupablePipeTrigger = 0; + + /** + * @hidden @internal + */ + public hoverIndex: number; + + /** + * @hidden @internal + */ + public EMPTY_DATA = []; + + /** @hidden @internal */ + public get type(): GridType["type"] { + return 'flat'; + } + + /** @hidden @internal */ + public _baseFontSize: number; + + /** + * @hidden + */ + public destroy$ = new Subject(); + /** + * @hidden + */ + protected _pagingMode: GridPagingMode = 'local'; + /** + * @hidden + */ + protected _pagingState; + /** + * @hidden + */ + protected _hideRowSelectors = false; + /** + * @hidden + */ + protected _rowDrag = false; + /** + * @hidden + */ + protected _columns: IgxColumnComponent[] = []; + /** + * @hidden + */ + protected _pinnedColumns: IgxColumnComponent[] = []; + + /** + * @hidden + */ + protected _pinnedStartColumns: IgxColumnComponent[] = []; + + /** + * @hidden + */ + protected _pinnedEndColumns: IgxColumnComponent[] = []; + + /** + * @hidden + */ + protected _unpinnedColumns: IgxColumnComponent[] = []; + /** + * @hidden + */ + protected _filteringExpressionsTree: IFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + /** + * @hidden + */ + protected _advancedFilteringExpressionsTree: IFilteringExpressionsTree; + /** + * @hidden + */ + protected _sortingExpressions: Array = []; + /** + * @hidden + */ + protected _columnHiding = false; + /** + * @hidden + */ + protected _columnPinning = false; + + protected _pinnedRecordIDs = []; + /** + * @hidden + */ + protected _mergeStrategy: IGridMergeStrategy = new DefaultMergeStrategy(); + + /** + * @hidden + */ + protected _hasVisibleColumns; + protected _allowFiltering = false; + protected _allowAdvancedFiltering = false; + protected _filterMode: FilterMode = FilterMode.quickFilter; + + + protected _defaultTargetRecordNumber = 10; + protected _expansionStates: Map = new Map(); + protected _defaultExpandState = false; + protected _headerFeaturesWidth = NaN; + protected _init = true; + protected _firstAutoResize = true; + protected _autoSizeColumnsNotify = new Subject(); + protected _cdrRequestRepaint = false; + protected _userOutletDirective: IgxOverlayOutletDirective; + protected _transactions: TransactionService; + protected _batchEditing = false; + protected _sortingOptions: ISortingOptions = { mode: 'multiple' }; + protected _filterStrategy: IFilteringStrategy = new FilteringStrategy(); + protected _autoGeneratedCols: ColumnType[] = []; + protected _autoGeneratedColsRefs: ComponentRef[] = []; + protected _dataView = []; + protected _lastSearchInfo: ISearchInfo = { + searchText: '', + caseSensitive: false, + exactMatch: false, + activeMatchIndex: 0, + matchInfoCache: [], + matchCount: 0, + content: '' + }; + protected _hGridSchema: EntityType[]; + protected gridComputedStyles; + + /** @hidden @internal */ + public get paginator() { + return this.paginationComponents?.first; + } + + /** + * @hidden @internal + */ + public get scrollSize() { + return this.verticalScrollContainer.getScrollNativeSize(); + } + + private _primaryKey: string; + private _rowEditable = false; + private _currentRowState: any; + private _filteredSortedData = null; + private _filteredData = null; + private _mergedDataInView = null; + private _activeRowIndexes = null; + + private _customDragIndicatorIconTemplate: TemplateRef; + private _excelStyleHeaderIconTemplate: TemplateRef; + private _rowSelectorTemplate: TemplateRef; + private _headSelectorTemplate: TemplateRef; + private _rowEditTextTemplate: TemplateRef; + private _rowAddTextTemplate: TemplateRef; + private _rowEditActionsTemplate: TemplateRef; + private _dragGhostCustomTemplate: TemplateRef; + private _rowExpandedIndicatorTemplate: TemplateRef; + private _rowCollapsedIndicatorTemplate: TemplateRef; + private _headerExpandIndicatorTemplate: TemplateRef; + private _headerCollapseIndicatorTemplate: TemplateRef; + private _emptyGridTemplate: TemplateRef; + private _loadingGridTemplate: TemplateRef; + + private _cdrRequests = false; + private _resourceStrings = getCurrentResourceStrings(GridResourceStringsEN); + private _emptyGridMessage = null; + private _emptyFilteredGridMessage = null; + private _isLoading = false; + private _locale: string; + private overlayIDs = []; + private _sortingStrategy: IGridSortingStrategy; + private _pinning: IPinningConfig = { columns: ColumnPinningPosition.Start }; + private _shouldRecalcRowHeight = false; + + private _hostWidth; + private _advancedFilteringOverlayId: string; + private _advancedFilteringPositionSettings: PositionSettings = { + verticalDirection: VerticalAlignment.Middle, + horizontalDirection: HorizontalAlignment.Center, + horizontalStartPoint: HorizontalAlignment.Center, + verticalStartPoint: VerticalAlignment.Middle + }; + + private _advancedFilteringOverlaySettings: OverlaySettings = { + closeOnOutsideClick: false, + modal: false, + positionStrategy: new ConnectedPositioningStrategy(this._advancedFilteringPositionSettings), + }; + + private columnListDiffer; + private rowListDiffer; + private _height: string | null = '100%'; + private _width: string | null = '100%'; + private _rowHeight: number | undefined; + private _horizontalForOfs: Array> = []; + private _multiRowLayoutRowSize = 1; + // Caches + private _totalWidth = NaN; + private _pinnedVisible = []; + private _unpinnedVisible = []; + private _pinnedStartWidth = NaN; + private _pinnedEndWidth = NaN; + private _unpinnedWidth = NaN; + private _visibleColumns = []; + private _columnGroups = false; + + private _columnWidth: string; + + private _summaryPosition: GridSummaryPosition = GridSummaryPosition.bottom; + private _summaryCalculationMode: GridSummaryCalculationMode = GridSummaryCalculationMode.rootAndChildLevels; + private _showSummaryOnCollapse = false; + private _summaryRowHeight = 0; + private _cellSelectionMode: GridSelectionMode = GridSelectionMode.multiple; + private _rowSelectionMode: GridSelectionMode = GridSelectionMode.none; + private _selectRowOnClick = true; + private _columnSelectionMode: GridSelectionMode = GridSelectionMode.none; + + private lastAddedRowIndex; + + private _currencyPositionLeft: boolean; + + private rowEditPositioningStrategy = new RowEditPositionStrategy({ + horizontalDirection: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Bottom, + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Bottom, + closeAnimation: null + }); + + private rowEditSettings: OverlaySettings = { + scrollStrategy: new AbsoluteScrollStrategy(), + modal: false, + closeOnOutsideClick: false, + outlet: this.rowOutletDirective, + positionStrategy: this.rowEditPositioningStrategy + }; + + private transactionChange$ = new Subject(); + private _rendered = false; + private readonly DRAG_SCROLL_DELTA = 10; + private _dataCloneStrategy: IDataCloneStrategy = new DefaultDataCloneStrategy(); + private _autoSize = false; + private _sortHeaderIconTemplate: TemplateRef = null; + private _sortAscendingHeaderIconTemplate: TemplateRef = null; + private _sortDescendingHeaderIconTemplate: TemplateRef = null; + private _gridSize: ɵSize = ɵSize.Large; + private _defaultRowHeight = 50; + private _rowCount: number; + private _cellMergeMode: GridCellMergeMode = GridCellMergeMode.onSort; + private _columnsToMerge: IgxColumnComponent[] = []; + + /** + * @hidden @internal + */ + public get minColumnWidth() { + return MINIMUM_COLUMN_WIDTH; + } + + protected get isCustomSetRowHeight(): boolean { + return !isNaN(this._rowHeight); + } + + /** + * @hidden @internal + */ + public abstract id: string; + /* blazorSuppress */ + public abstract data: any[] | null; + + /** + * Returns an array of objects containing the filtered data. + * + * @example + * ```typescript + * let filteredData = this.grid.filteredData; + * ``` + */ + public get filteredData() { + return this._filteredData; + } + + /** + * Returns an array containing the filtered sorted data. + * + * @example + * ```typescript + * const filteredSortedData = this.grid1.filteredSortedData; + * ``` + */ + public get filteredSortedData(): any[] | null { + return this._filteredSortedData; + } + + /** + * @hidden @internal + */ + public get rowChangesCount() { + if (!this.crudService.row) { + return 0; + } + const f = (obj: any) => { + let changes = 0; + Object.keys(obj).forEach(key => isObject(obj[key]) ? changes += f(obj[key]) : changes++); + return changes; + }; + if (this.transactions.getState(this.crudService.row.id)?.type === TransactionType.ADD) { + return this._columns.filter(c => c.field).length; + } + const rowChanges = this.transactions.getAggregatedValue(this.crudService.row.id, false); + return rowChanges ? f(rowChanges) : 0; + } + + /** + * @hidden @internal + */ + public get dataWithAddedInTransactionRows() { + const result = cloneArray(this.gridAPI.get_all_data()); + if (this.transactions.enabled) { + result.push(...this.transactions.getAggregatedChanges(true) + .filter(t => t.type === TransactionType.ADD) + .map(t => t.newValue)); + } + + if (this.crudService.row && this.crudService.row.isAddRow) { + result.splice(this.crudService.row.index, 0, this.crudService.row.data); + } + + return result; + } + + /** + * @hidden @internal + */ + public get dataLength() { + return this.transactions.enabled ? this.dataWithAddedInTransactionRows.length : this.gridAPI.get_all_data().length; + } + + /** + * @hidden @internal + */ + public get template(): TemplateRef { + if (this.isLoading && (this.hasZeroResultFilter || this.hasNoData)) { + return this.loadingGridTemplate ? this.loadingGridTemplate : this.loadingGridDefaultTemplate; + } + + if (this.hasZeroResultFilter) { + return this.emptyGridTemplate ? this.emptyGridTemplate : this.emptyFilteredGridTemplate; + } + + if (this.hasNoData) { + return this.emptyGridTemplate ? this.emptyGridTemplate : this.emptyGridDefaultTemplate; + } + } + + /** + * @hidden @internal + */ + private get hasZeroResultFilter(): boolean { + return this.filteredData && this.filteredData.length === 0; + } + protected get totalCalcWidth() { + return this.platform.isBrowser ? this.calcWidth : undefined; + } + + protected get renderData() { + // omit data if not in the browser and size is % + return !this.platform.isBrowser && this.isPercentHeight ? undefined : this.data; + } + + @HostBinding('style.display') + protected displayStyle = 'grid'; + + @HostBinding('style.grid-template-rows') + protected templateRows = 'auto auto auto 1fr auto auto'; + + /** + * @hidden @internal + */ + private get hasNoData(): boolean { + return !this.data || this.dataLength === 0 || !this.platform.isBrowser; + } + + /** + * @hidden @internal + */ + public get shouldOverlayLoading(): boolean { + return this.isLoading && !this.hasNoData && !this.hasZeroResultFilter; + } + + /** + * @hidden @internal + */ + public get isMultiRowSelectionEnabled(): boolean { + return this.rowSelection === GridSelectionMode.multiple + || this.rowSelection === GridSelectionMode.multipleCascade; + } + + /** + * @hidden @internal + */ + public get isRowSelectable(): boolean { + return this.rowSelection !== GridSelectionMode.none; + } + + /** + * @hidden @internal + */ + public get isCellSelectable() { + return this.cellSelection !== GridSelectionMode.none; + } + + /** + * @hidden @internal + */ + public get columnInDrag() { + return this.gridAPI.cms.column; + } + + constructor() { + this.locale = this.locale || this.localeId; + this._transactions = this.transactionFactory.create(TRANSACTION_TYPE.None); + this._transactions.cloneStrategy = this.dataCloneStrategy; + this.cdr.detach(); + this.selectionService.selectedRowsChange.pipe(takeUntil(this.destroy$)).subscribe((args: any[]) => { + this.selectedRowsChange.emit(args); + }); + IgcTrialWatermark.register(); + } + + /** + * @hidden + * @internal + */ + @HostListener('mouseleave') + public hideActionStrip() { + this.actionStrip?.hide(); + } + + /** + * @hidden + * @internal + */ + public get headerFeaturesWidth() { + return this._headerFeaturesWidth; + } + + /** + * @hidden + * @internal + */ + public isDetailRecord(_rec) { + return false; + } + + /** + * @hidden + * @internal + */ + public isGroupByRecord(_rec) { + return false; + } + + /** + * @hidden + * @internal + */ + public isChildGridRecord(_rec) { + return false; + } + + /** + * @hidden @internal + */ + public isGhostRecord(record: any): boolean { + return record.ghostRecord !== undefined; + } + /** + * @hidden @internal + */ + public isAddRowRecord(record: any): boolean { + return record.addRow !== undefined; + } + + /** + * @hidden + * Returns the row index of a row that takes into account the full view data like pinning. + */ + public getDataViewIndex(rowIndex, pinned) { + if (pinned && !this.isRowPinningToTop) { + rowIndex = rowIndex + this.unpinnedDataView.length; + } else if (!pinned && this.isRowPinningToTop) { + rowIndex = rowIndex + this.pinnedDataView.length; + } + return rowIndex; + } + + /** + * @hidden + * @internal + */ + public get hasDetails() { + return false; + } + + /** + * Returns the state of the grid virtualization. + * + * @remarks + * Includes the start index and how many records are rendered. + * @example + * ```typescript + * const gridVirtState = this.grid1.virtualizationState; + * ``` + */ + public get virtualizationState() { + return this.verticalScrollContainer.state; + } + + /** + * @hidden + * @internal + */ + public hideOverlays() { + this.overlayIDs.forEach(overlayID => { + const overlay = this.overlayService.getOverlayById(overlayID); + + if (overlay?.visible && !overlay.closeAnimationPlayer?.hasStarted()) { + this.overlayService.hide(overlayID); + + this.nativeElement.focus(); + } + }); + } + + /** + * Returns whether the record is pinned or not. + * + * @param rowIndex Index of the record in the `dataView` collection. + * + * @hidden + * @internal + */ + public isRecordPinnedByViewIndex(rowIndex: number) { + return this.hasPinnedRecords && (this.isRowPinningToTop && rowIndex < this.pinnedDataView.length) || + (!this.isRowPinningToTop && rowIndex >= this.unpinnedDataView.length); + } + + /** + * Returns whether the record is pinned or not. + * + * @param rowIndex Index of the record in the `filteredSortedData` collection. + */ + public isRecordPinnedByIndex(rowIndex: number) { + return this.hasPinnedRecords && (this.isRowPinningToTop && rowIndex < this._filteredSortedPinnedData.length) || + (!this.isRowPinningToTop && rowIndex >= this._filteredSortedUnpinnedData.length); + } + + /** + * @hidden + * @internal + */ + public isRecordPinned(rec) { + return this.getInitialPinnedIndex(rec) !== -1; + } + + /** + * @hidden + * @internal + */ + public isRecordMerged(rec) { + return rec?.cellMergeMeta; + } + + protected getMergeCellOffset(rowData) { + const index = rowData.dataIndex; + let offset = this.verticalScrollContainer.scrollPosition - this.verticalScrollContainer.getScrollForIndex(index); + if (this.hasPinnedRecords && this.isRowPinningToTop) { + offset -= this.pinnedRowHeight; + } + return -offset; + } + + /** + * @hidden + * @internal + * Returns the record index in order of pinning by the user. Does not consider sorting/filtering. + */ + public getInitialPinnedIndex(rec) { + const id = this.gridAPI.get_row_id(rec); + return this._pinnedRecordIDs.indexOf(id); + } + + /** + * @hidden + * @internal + */ + public get hasPinnedRecords() { + return this._pinnedRecordIDs.length > 0; + } + + /** + * @hidden + * @internal + */ + public get pinnedRecordsCount() { + return this._pinnedRecordIDs.length; + } + + /** + * @hidden + * @internal + */ + public get crudService() { + return this.gridAPI.crudService; + } + + /** + * @hidden + * @internal + */ + public _setupServices() { + this.gridAPI.grid = this as any; + this.crudService.grid = this as any; + this.selectionService.grid = this as any; + this.validation.grid = this as any; + this.navigation.grid = this as any; + this.filteringService.grid = this as any; + this.summaryService.grid = this as any; + } + + /** + * @hidden + * @internal + */ + public _setupListeners() { + const destructor = takeUntil(this.destroy$); + fromEvent(this.nativeElement, 'focusout').pipe(filter(() => !!this.navigation.activeNode), destructor).subscribe((event) => { + const activeNode = this.navigation.activeNode; + if (!this.crudService.cell && !!activeNode && + ((event.target === this.tbody.nativeElement && activeNode.row >= 0 && + activeNode.row < this.dataView.length) + || (event.target === this.theadRow.nativeElement && activeNode.row === -1) + || (event.target === this.tfoot.nativeElement && activeNode.row === this.dataView.length)) && + !(this.rowEditable && this.crudService.rowEditingBlocked && this.crudService.rowInEditMode)) { + this.clearActiveNode(); + } + }); + this.rowAddedNotifier.pipe(destructor).subscribe(args => this.refreshGridState(args)); + this.rowDeletedNotifier.pipe(destructor).subscribe(args => { + this.summaryService.deleteOperation = true; + this.summaryService.clearSummaryCache(args); + }); + + this.subscribeToTransactions(); + + this.resizeNotify.pipe( + filter(() => !this._init), + throttleTime(40, animationFrameScheduler, { leading: true, trailing: true }), + destructor + ) + .subscribe(() => { + this.zone.run(() => { + // do not trigger reflow if element is detached. + if (this.nativeElement.isConnected) { + if (this.shouldResize) { + // resizing occurs due to the change of --ig-size css var + this._gridSize = this.gridSize; + this.updateDefaultRowHeight(); + this._autoSize = this.isPercentHeight && this.calcHeight !== this.getDataBasedBodyHeight(); + this.crudService.endEdit(false); + if (this._summaryRowHeight === 0) { + this.summaryService.summaryHeight = 0; + } + } + this.notifyChanges(true); + } + }); + }); + + this.pipeTriggerNotifier.pipe(takeUntil(this.destroy$)).subscribe(() => this.pipeTrigger++); + this.columnMovingEnd.pipe(destructor).subscribe(() => this.crudService.endEdit(false)); + + this.overlayService.opening.pipe(destructor).subscribe((event) => { + if (this._advancedFilteringOverlayId === event.id) { + const instance = event.componentRef.instance as IgxAdvancedFilteringDialogComponent; + if (instance) { + instance.initialize(this as any, this.overlayService, event.id); + } + } + }); + + this.overlayService.opened.pipe(destructor).subscribe((event) => { + const overlaySettings = this.overlayService.getOverlayById(event.id)?.settings; + + // do not hide the advanced filtering overlay on scroll + if (this._advancedFilteringOverlayId === event.id) { + const instance = event.componentRef.instance as IgxAdvancedFilteringDialogComponent; + if (instance) { + instance.lastActiveNode = this.navigation.activeNode; + instance.queryBuilder.setAddButtonFocus(); + } + return; + } + + // do not hide the overlay if it's attached to a row + if (this.rowEditingOverlay?.overlayId === event.id) { + return; + } + + if (overlaySettings?.outlet === this.outlet && this.overlayIDs.indexOf(event.id) === -1) { + this.overlayIDs.push(event.id); + } + }); + + this.overlayService.closed.pipe(filter(() => !this._init), destructor).subscribe((event) => { + if (this._advancedFilteringOverlayId === event.id) { + this.overlayService.detach(this._advancedFilteringOverlayId); + this._advancedFilteringOverlayId = null; + return; + } + + const ind = this.overlayIDs.indexOf(event.id); + if (ind !== -1) { + this.overlayIDs.splice(ind, 1); + } + }); + + this.verticalScrollContainer.dataChanging.pipe(filter(() => !this._init), destructor).subscribe(($event) => { + const shouldRecalcSize = this.isPercentHeight && + (!this.calcHeight || this.calcHeight === this.getDataBasedBodyHeight() || + this.calcHeight === this.renderedRowHeight * this._defaultTargetRecordNumber); + if (shouldRecalcSize) { + this.calculateGridHeight(); + $event.containerSize = this.calcHeight; + } + this.evaluateLoadingState(); + this.updateMergedData(); + }); + + this.verticalScrollContainer.scrollbarVisibilityChanged.pipe(filter(() => !this._init), destructor).subscribe(() => { + // called to recalc all widths that may have changes as a result of + // the vert. scrollbar showing/hiding + this.notifyChanges(true); + this.cdr.detectChanges(); + Promise.resolve().then(() => this.headerContainer.updateScroll()); + }); + + + this.headerContainer?.scrollbarVisibilityChanged.pipe(filter(() => !this._init), destructor).subscribe(() => { + // the horizontal scrollbar showing/hiding + // update scrollbar visibility and recalc heights + this.notifyChanges(true); + this.cdr.detectChanges(); + }); + + this.verticalScrollContainer.contentSizeChange.pipe(filter(() => !this._init), throttleTime(30), destructor).subscribe(() => { + this.notifyChanges(true); + }); + + this.verticalScrollContainer.chunkPreload.pipe(filter(() => !this._init), destructor).subscribe(() => { + this.updateMergedData(); + }); + + // notifier for column autosize requests + this._autoSizeColumnsNotify.pipe( + throttleTime(0, this.platform.isBrowser ? animationFrameScheduler : undefined, { leading: false, trailing: true }), + destructor + ) + .subscribe(() => { + this.autoSizeColumnsInView(); + this._firstAutoResize = false; + }); + + this.activeNodeChange.pipe( + throttleTime(0, this.platform.isBrowser ? animationFrameScheduler : undefined, { leading: false, trailing: true }), + destructor + ).subscribe(() => { + this._activeRowIndexes = null; + if (this.hasCellsToMerge) { + this.refreshSearch(); + this.notifyChanges(); + } + }); + + this.selectionService.selectedRangeChange.pipe(filter(() => !this._init), destructor).subscribe(() => { + this._activeRowIndexes = null; + if (this.hasCellsToMerge) { + this.refreshSearch(); + } + }); + } + + /** + * @hidden + */ + public ngOnInit() { + this._setupServices(); + this._setupListeners(); + this.rowListDiffer = this.differs.find([]).create(null); + // compare based on field, not on object ref. + this.columnListDiffer = this.differs.find([]).create((_index, col: ColumnType) => col.field); + this.calcWidth = this.width && this.width.indexOf('%') === -1 ? parseInt(this.width, 10) : 0; + this.gridComputedStyles = this.document.defaultView.getComputedStyle(this.nativeElement); + } + + /** + * @hidden + * @internal + */ + public resetColumnsCaches() { + this._columns.forEach(column => column.resetCaches()); + } + + /** + * @hidden @internal + */ + public generateRowID(): string | number { + const primaryColumn = this._columns.find(col => col.field === this.primaryKey); + const idType = this.data.length ? + this.resolveDataTypes(this.data[0][this.primaryKey]) : primaryColumn ? primaryColumn.dataType : 'string'; + return idType === 'string' ? getUUID() : FAKE_ROW_ID--; + } + + /** + * @hidden + * @internal + */ + public resetForOfCache() { + const firstVirtRow = this.dataRowList.first; + if (firstVirtRow) { + if (this._cdrRequests) { + firstVirtRow.virtDirRow.cdr.detectChanges(); + } + firstVirtRow.virtDirRow.assumeMaster(); + } + } + + /** + * @hidden + * @internal + */ + public setFilteredData(data, pinned: boolean) { + if (this.hasPinnedRecords && pinned) { + this._filteredPinnedData = data || []; + const filteredUnpinned = this._filteredUnpinnedData || []; + const filteredData = [... this._filteredPinnedData, ...filteredUnpinned]; + this._filteredData = filteredData.length > 0 ? filteredData : this._filteredUnpinnedData; + } else if (this.hasPinnedRecords && !pinned) { + this._filteredUnpinnedData = data; + } else { + this._filteredData = data; + } + } + + /** + * @hidden + * @internal + */ + public get columnsToMerge(): ColumnType[] { + 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))) + ); + this._columnsToMerge = 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; + } + + /** + * @hidden + * @internal + */ + public resetColumnCollections() { + if (this.hasColumnLayouts) { + this._columns.filter(x => x.columnLayout).forEach(x => x.populateVisibleIndexes()); + } + this._visibleColumns.length = 0; + this._pinnedVisible.length = 0; + this._unpinnedVisible.length = 0; + if (this.allowResetOfColumnsToMerge()) { + this._columnsToMerge.length = 0; + } + } + + /** + * @hidden + * @internal + */ + public resetCachedWidths() { + this._unpinnedWidth = NaN; + this._pinnedStartWidth = NaN; + this._pinnedEndWidth = NaN; + this._totalWidth = NaN; + } + + /** + * @hidden + * @internal + */ + public resetCaches(recalcFeatureWidth = true) { + if (recalcFeatureWidth) { + this._headerFeaturesWidth = NaN; + this.summaryService.summaryHeight = 0; + } + this.resetColumnsCaches(); + this.resetColumnCollections(); + this.resetForOfCache(); + this.resetCachedWidths(); + this.hasVisibleColumns = undefined; + this._columnGroups = this._columns.some(col => col.columnGroup); + } + + /** + * @hidden + */ + public ngAfterContentInit() { + if (this.sortHeaderIconDirectiveTemplate) { + this.sortHeaderIconTemplate = this.sortHeaderIconDirectiveTemplate; + } + + if (this.sortAscendingHeaderIconDirectiveTemplate) { + this.sortAscendingHeaderIconTemplate = this.sortAscendingHeaderIconDirectiveTemplate; + } + + if (this.sortDescendingHeaderIconDirectiveTemplate) { + this.sortDescendingHeaderIconTemplate = this.sortDescendingHeaderIconDirectiveTemplate; + } + + this.setupColumns(); + this.toolbar.changes.pipe(filter(() => !this._init), takeUntil(this.destroy$)).subscribe(() => this.notifyChanges(true)); + this.setUpPaginator(); + this.paginationComponents.changes.pipe(takeUntil(this.destroy$)).subscribe(() => { + this.setUpPaginator(); + }); + if (this.actionStrip) { + this.actionStrip.menuOverlaySettings.outlet = this.outlet; + } + } + + + protected get activeRowIndexes(): number[] { + if (this._activeRowIndexes) { + return this._activeRowIndexes; + } else { + const activeRow = this.navigation.activeNode?.row; + + const selectedCellIndexes = this.selectionService.selection + ? Array.from(this.selectionService.selection.keys()) + : []; + this._activeRowIndexes = [activeRow, ...selectedCellIndexes]; + return this._activeRowIndexes; + } + } + + protected get hasCellsToMerge() { + return this.columnsToMerge.length > 0; + } + + /** + * @hidden @internal + */ + public dataRebinding(event: IForOfDataChangeEventArgs) { + if (event.state.chunkSize == 0) { + this._shouldRecalcRowHeight = true; + } + this.dataChanging.emit(event); + } + + /** + * @hidden @internal + */ + public dataRebound(event: IForOfDataChangeEventArgs) { + this.selectionService.clearHeaderCBState(); + if (this._shouldRecalcRowHeight) { + this._shouldRecalcRowHeight = false; + this.updateDefaultRowHeight(); + } + this.dataChanged.emit(event); + } + + /** @hidden @internal */ + public createFilterDropdown(column: ColumnType, options: OverlaySettings) { + options.outlet = this.outlet; + if (this.excelStyleFilteringComponent) { + this.excelStyleFilteringComponent.initialize(column, this.overlayService); + this.excelStyleFilteringComponent.populateData(); + const id = this.overlayService.attach(this.excelStyleFilteringComponent.element, options); + this.excelStyleFilteringComponent.overlayComponentId = id; + return id; + } + const id = this.overlayService.attach(IgxGridExcelStyleFilteringComponent, this.viewRef, options); + return id; + } + + /** @hidden @internal */ + public setUpPaginator() { + if (this.paginator) { + this.paginator.pageChange + .pipe(takeWhile(() => !!this.paginator), filter(() => !this._init)) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.selectionService.clear(true); + this._activeRowIndexes = null; + this.crudService.endEdit(false); + this.pipeTrigger++; + this.navigateTo(0); + this.notifyChanges(); + }); + this.paginator.perPageChange + .pipe(takeWhile(() => !!this.paginator), filter(() => !this._init)) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.selectionService.clear(true); + this._activeRowIndexes = null; + this.page = 0; + this.crudService.endEdit(false); + this.notifyChanges(); + }); + } else { + this.markForCheck(); + } + } + + /** + * @hidden + * @internal + */ + public setFilteredSortedData(data, pinned: boolean) { + data = data || []; + if (this.pinnedRecordsCount > 0) { + if (pinned) { + this._filteredSortedPinnedData = data; + this.pinnedRecords = data; + this._filteredSortedData = this.isRowPinningToTop ? [... this._filteredSortedPinnedData, ... this._filteredSortedUnpinnedData] : + [... this._filteredSortedUnpinnedData, ... this._filteredSortedPinnedData]; + this.refreshSearch(true, false); + } else { + this._filteredSortedUnpinnedData = data; + } + } else { + this._filteredSortedData = data; + this.refreshSearch(true, false); + } + this.buildDataView(data); + } + + /** + * @hidden @internal + */ + public resetHorizontalVirtualization() { + const elementFilter = (item: IgxRowDirective | IgxSummaryRowComponent) => this.isDefined(item.nativeElement.parentElement); + this._horizontalForOfs = [ + ...this._dataRowList.filter(elementFilter).map(item => item.virtDirRow), + ...this._summaryRowList.filter(elementFilter).map(item => item.virtDirRow) + ]; + } + + /** + * @hidden @internal + */ + public _setupRowObservers() { + const elementFilter = (item: IgxRowDirective | IgxSummaryRowComponent) => this.isDefined(item.nativeElement.parentElement); + const extractForOfs = pipe(map((collection: any[]) => collection.filter(elementFilter).map(item => item.virtDirRow))); + const rowListObserver = extractForOfs(this._dataRowList.changes); + const summaryRowObserver = extractForOfs(this._summaryRowList.changes); + rowListObserver.pipe(takeUntil(this.destroy$)).subscribe(() => { + this.resetHorizontalVirtualization(); + }); + summaryRowObserver.pipe(takeUntil(this.destroy$)).subscribe(() => { + this.resetHorizontalVirtualization(); + }); + this.resetHorizontalVirtualization(); + } + + /** + * @hidden @internal + */ + public _zoneBegoneListeners() { + this.zone.runOutsideAngular(() => { + this.verticalScrollHandler = this.verticalScrollHandler.bind(this); + this.horizontalScrollHandler = this.horizontalScrollHandler.bind(this); + this.verticalScrollContainer.getScroll().addEventListener('scroll', this.verticalScrollHandler); + this.headerContainer?.getScroll().addEventListener('scroll', this.horizontalScrollHandler); + if (this.hasColumnsToAutosize) { + this.headerContainer?.dataChanged.pipe(takeUntil(this.destroy$)).subscribe(() => { + this.cdr.detectChanges(); + this.zone.onStable.pipe(first()).subscribe(() => { + this.autoSizeColumnsInView(); + }); + }); + } + // Window resize observer not needed because when you resize the window element the tbody container always resize so + // it would always notify resizing, thus a change detection and recalculation of sizes will occur + resizeObservable(this.nativeElement).pipe(first(), takeUntil(this.destroy$)).subscribe(() => this.resizeNotify.next()); + resizeObservable(this.tbodyContainer.nativeElement).pipe(takeUntil(this.destroy$)).subscribe(() => this.resizeNotify.next()); + }); + } + + /** + * @hidden + */ + public ngAfterViewInit() { + this.initPinning(); + this.calculateGridSizes(); + this._init = false; + this.cdr.reattach(); + this._setupRowObservers(); + this._zoneBegoneListeners(); + + const vertScrDC = this.verticalScrollContainer.displayContainer; + vertScrDC.addEventListener('scroll', this.preventContainerScroll); + + this._pinnedRowList.changes + .pipe(takeUntil(this.destroy$)) + .subscribe((change: QueryList) => { + this.onPinnedRowsChanged(change); + }); + + this.addRowSnackbar?.clicked.pipe(takeUntil(this.destroy$)).subscribe(() => { + const rec = this.filteredSortedData[this.lastAddedRowIndex]; + this.scrollTo(rec, 0); + this.addRowSnackbar.close(); + }); + + // Keep the stream open for future subscribers + this.rendered$.pipe(takeUntil(this.destroy$)).subscribe(() => { + if (this.paginator) { + this.paginator.totalRecords = this.totalRecords ? this.totalRecords : this.paginator.totalRecords; + this.paginator.overlaySettings = { outlet: this.outlet }; + } + if (this.hasColumnsToAutosize) { + this.autoSizeColumnsInView(); + } + this._calculateRowCount(); + this._rendered = true; + }); + Promise.resolve().then(() => this.rendered.next(true)); + + } + + /** + * @hidden @internal + */ + public notifyChanges(repaint = false) { + this._cdrRequests = true; + this._cdrRequestRepaint = repaint; + this.cdr.markForCheck(); + } + + /** + * @hidden @internal + */ + public ngDoCheck() { + if (this._init) { + return; + } + + if (this._cdrRequestRepaint) { + this.resetNotifyChanges(); + this.calculateGridSizes(); + this.refreshSearch(true); + return; + } + + if (this._cdrRequests) { + this.resetNotifyChanges(); + this.cdr.detectChanges(); + } + } + + /** + * @hidden + * @internal + */ + public getDragGhostCustomTemplate() { + + return this.dragGhostCustomTemplate; + } + + /** + * @hidden @internal + */ + public ngOnDestroy() { + this.tmpOutlets.forEach((tmplOutlet) => { + tmplOutlet.cleanCache(); + }); + this._autoGeneratedColsRefs.forEach(ref => ref.destroy()); + this._autoGeneratedColsRefs = []; + + this.destroy$.next(true); + this.destroy$.complete(); + this.transactionChange$.next(); + this.transactionChange$.complete(); + this._destroyed = true; + + this.textHighlightService.destroyGroup(this.id); + + if (this._advancedFilteringOverlayId) { + this.overlayService.detach(this._advancedFilteringOverlayId); + delete this._advancedFilteringOverlayId; + } + + this.overlayIDs.forEach(overlayID => { + const overlay = this.overlayService.getOverlayById(overlayID); + + if (overlay && !overlay.detached) { + this.overlayService.detach(overlayID); + } + }); + + + this.zone.runOutsideAngular(() => { + this.verticalScrollContainer?.getScroll()?.removeEventListener('scroll', this.verticalScrollHandler); + this.headerContainer?.getScroll()?.removeEventListener('scroll', this.horizontalScrollHandler); + const vertScrDC = this.verticalScrollContainer?.displayContainer; + vertScrDC?.removeEventListener('scroll', this.preventContainerScroll); + }); + } + + /** + * Toggles the specified column's visibility. + * + * @example + * ```typescript + * this.grid1.toggleColumnVisibility({ + * column: this.grid1.columns[0], + * newValue: true + * }); + * ``` + */ + public toggleColumnVisibility(args: IColumnVisibilityChangedEventArgs) { + const col = args.column ? this._columns.find((c) => c === args.column) : undefined; + + if (!col) { + return; + } + col.toggleVisibility(args.newValue); + } + + /* blazorCSSuppress */ + /** + * Gets/Sets a list of key-value pairs [row ID, expansion state]. + * + * @remarks + * Includes only states that differ from the default one. + * Supports two-way binding. + * @example + * ```html + * + * + * ``` + */ + @Input() + public get expansionStates() { + return this._expansionStates; + } + + /* blazorCSSuppress */ + public set expansionStates(value) { + this._expansionStates = new Map(value); + this.expansionStatesChange.emit(this._expansionStates); + this.notifyChanges(true); + if (this.gridAPI.grid) { + this.cdr.detectChanges(); + } + } + + /** + * Expands all rows. + * + * @example + * ```typescript + * this.grid.expandAll(); + * ``` + */ + public expandAll() { + this._defaultExpandState = true; + this.expansionStates = new Map(); + } + + /** + * Collapses all rows. + * + * @example + * ```typescript + * this.grid.collapseAll(); + * ``` + */ + public collapseAll() { + this._defaultExpandState = false; + this.expansionStates = new Map(); + } + + /** + * Expands the row by its id. + * + * @remarks + * ID is either the primaryKey value or the data record instance. + * @example + * ```typescript + * this.grid.expandRow(rowID); + * ``` + * @param rowID The row id - primaryKey value or the data record instance. + */ + public expandRow(rowID: any) { + this.gridAPI.set_row_expansion_state(rowID, true); + } + + /** + * Collapses the row by its id. + * + * @remarks + * ID is either the primaryKey value or the data record instance. + * @example + * ```typescript + * this.grid.collapseRow(rowID); + * ``` + * @param rowID The row id - primaryKey value or the data record instance. + */ + public collapseRow(rowID: any) { + this.gridAPI.set_row_expansion_state(rowID, false); + } + + + /** + * Toggles the row by its id. + * + * @remarks + * ID is either the primaryKey value or the data record instance. + * @example + * ```typescript + * this.grid.toggleRow(rowID); + * ``` + * @param rowID The row id - primaryKey value or the data record instance. + */ + public toggleRow(rowID: any) { + const rec = this.gridAPI.get_rec_by_id(rowID); + const state = this.gridAPI.get_row_expansion_state(rec); + this.gridAPI.set_row_expansion_state(rowID, !state); + } + + /** + * @hidden + * @internal + */ + public getDefaultExpandState(_rec: any) { + return this._defaultExpandState; + } + + /** + * Gets the native element. + * + * @example + * ```typescript + * const nativeEl = this.grid.nativeElement. + * ``` + */ + public get nativeElement() { + return this.elementRef.nativeElement; + } + + /** + * Gets/Sets the outlet used to attach the grid's overlays to. + * + * @remarks + * If set, returns the outlet defined outside the grid. Otherwise returns the grid's internal outlet directive. + */ + @Input() + public get outlet() { + return this.resolveOutlet(); + } + + public set outlet(val: IgxOverlayOutletDirective) { + this._userOutletDirective = val; + } + + + /** + * Gets the default row height. + * + * @example + * ```typescript + * const rowHeigh = this.grid.defaultRowHeight; + * ``` + */ + public get defaultRowHeight(): number { + return this._defaultRowHeight; + } + + /** + * @hidden @internal + */ + public get defaultSummaryHeight(): number { + switch (this.gridSize) { + case ɵSize.Medium: + return 30; + case ɵSize.Small: + return 24; + default: + return 36; + } + } + + /** @hidden @internal */ + public get pinnedStartWidth() { + if (!isNaN(this._pinnedStartWidth)) { + return this._pinnedStartWidth; + } + 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 */ + public get unpinnedWidth() { + if (!isNaN(this._unpinnedWidth)) { + return this._unpinnedWidth; + } + this._unpinnedWidth = this.getUnpinnedWidth(); + return this._unpinnedWidth; + } + + /** + * @hidden @internal + */ + public isHorizontalScrollHidden = false; + + /** + * @hidden @internal + * Gets the header cell inner width for auto-sizing. + */ + public getHeaderCellWidth(element: HTMLElement): ISizeInfo { + const range = this.document.createRange(); + const headerWidth = this.platform.getNodeSizeViaRange(range, + element, element); + + const headerStyle = this.document.defaultView.getComputedStyle(element); + const headerPadding = parseFloat(headerStyle.paddingLeft) + parseFloat(headerStyle.paddingRight) + + parseFloat(headerStyle.borderRightWidth); + + return { width: Math.ceil(headerWidth), padding: Math.ceil(headerPadding) }; + } + + /** + * @hidden @internal + * Gets the combined width of the columns that are specific to the enabled grid features. They are fixed. + */ + public featureColumnsWidth(expander?: ElementRef) { + if (Number.isNaN(this._headerFeaturesWidth)) { + // TODO: platformUtil.isBrowser check + const rowSelectArea = this.headerSelectorContainer?.nativeElement?.getBoundingClientRect ? + this.headerSelectorContainer.nativeElement.getBoundingClientRect().width : 0; + const rowDragArea = this.rowDraggable && this.headerDragContainer?.nativeElement?.getBoundingClientRect ? + this.headerDragContainer.nativeElement.getBoundingClientRect().width : 0; + const groupableArea = this.headerGroupContainer?.nativeElement?.getBoundingClientRect ? + this.headerGroupContainer.nativeElement.getBoundingClientRect().width : 0; + const expanderWidth = expander?.nativeElement?.getBoundingClientRect ? expander.nativeElement.getBoundingClientRect().width : 0; + this._headerFeaturesWidth = rowSelectArea + rowDragArea + groupableArea + expanderWidth; + } + return this._headerFeaturesWidth; + } + + /** + * @hidden @internal + */ + public get summariesMargin() { + return this.featureColumnsWidth(); + } + + /** + * Gets an array of `IgxColumnComponent`s. + * + * @example + * ```typescript + * const colums = this.grid.columns. + * ``` + */ + public get columns(): IgxColumnComponent[] { + return this._columns || []; + } + + /** + * Gets an array of the pinned `IgxColumnComponent`s. + * + * @example + * ```typescript + * const pinnedColumns = this.grid.pinnedColumns. + * ``` + */ + public get pinnedColumns(): IgxColumnComponent[] { + if (this._pinnedVisible.length) { + return this._pinnedVisible; + } + this._pinnedVisible = this._pinnedColumns.filter(col => !col.hidden); + 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. + * + * @example + * ```typescript + * const pinnedRow = this.grid.pinnedRows; + * ``` + */ + public get pinnedRows(): IgxGridRowComponent[] { + return this._pinnedRowList.toArray().sort((a, b) => a.index - b.index); + } + + /** + * Gets an array of unpinned `IgxColumnComponent`s. + * + * @example + * ```typescript + * const unpinnedColumns = this.grid.unpinnedColumns. + * ``` + */ + public get unpinnedColumns(): IgxColumnComponent[] { + if (this._unpinnedVisible.length) { + return this._unpinnedVisible; + } + this._unpinnedVisible = this._unpinnedColumns.filter((col) => !col.hidden); + return this._unpinnedVisible; + } + + /** + * Gets the `width` to be set on `IgxGridHeaderGroupComponent`. + */ + public getHeaderGroupWidth(column: IgxColumnComponent): string { + return this.hasColumnLayouts + ? '' + : `${parseFloat(column.calcWidth)}px`; + } + + /** + * Returns the `IgxColumnComponent` by field name. + * + * @example + * ```typescript + * const myCol = this.grid1.getColumnByName("ID"); + * ``` + * @param name + */ + public getColumnByName(name: string): IgxColumnComponent { + return this._columns.find((col) => col.field === name); + } + + public getColumnByVisibleIndex(index: number): IgxColumnComponent { + return this.visibleColumns.find((col) => + !col.columnGroup && !col.columnLayout && + col.visibleIndex === index + ); + } + + /** + * Recalculates all widths of columns that have size set to `auto`. + * + * @example + * ```typescript + * this.grid1.recalculateAutoSizes(); + * ``` + */ + public recalculateAutoSizes() { + // reset auto-size and calculate it again. + this._columns.forEach(x => x.autoSize = undefined); + this.resetCaches(); + this.zone.onStable.pipe(first()).subscribe(() => { + this.cdr.detectChanges(); + this.autoSizeColumnsInView(); + }); + } + + /** + * Returns an array of visible `IgxColumnComponent`s. + * + * @example + * ```typescript + * const visibleColumns = this.grid.visibleColumns. + * ``` + */ + public get visibleColumns(): IgxColumnComponent[] { + if (this._visibleColumns.length) { + return this._visibleColumns; + } + this._visibleColumns = this._columns.filter(c => !c.hidden); + return this._visibleColumns; + } + + /** + * Returns the total number of records. + * + * @remarks + * Only functions when paging is enabled. + * @example + * ```typescript + * const totalRecords = this.grid.totalRecords; + * ``` + */ + @Input() + public get totalRecords(): number { + return this._totalRecords >= 0 ? this._totalRecords : this.pagingState?.metadata.countRecords; + } + + public set totalRecords(total: number) { + if (total >= 0) { + if (this.paginator) { + this.paginator.totalRecords = total; + } + this._totalRecords = total; + this.pipeTrigger++; + this.notifyChanges(); + } + } + + /** @hidden @internal */ + public get totalWidth(): number { + if (!isNaN(this._totalWidth)) { + return this._totalWidth; + } + // Take only top level columns + const cols = this.visibleColumns.filter(col => col.level === 0 && !col.pinned); + let totalWidth = 0; + let i = 0; + for (i; i < cols.length; i++) { + totalWidth += parseFloat(cols[i].calcWidth) || 0; + } + this._totalWidth = totalWidth; + return totalWidth; + } + + /** + * @hidden + * @internal + */ + public get showRowSelectors(): boolean { + return this.isRowSelectable && this.hasVisibleColumns && !this.hideRowSelectors; + } + + /** + * @hidden + * @internal + */ + public get showAddButton() { + return this.rowEditable && this.dataView.length === 0 && this._columns.length > 0; + } + + /** + * @hidden + * @internal + */ + public get showDragIcons(): boolean { + return this.rowDraggable && this._columns.length > this.hiddenColumnsCount; + } + + /** + * @hidden + * @internal + */ + protected _getDataViewIndex(index: number): number { + let newIndex = index; + if ((index < 0 || index >= this.dataView.length) && this.pagingMode === 'remote' && this.page !== 0) { + newIndex = index - this.perPage * this.page; + } else if (this.gridAPI.grid.verticalScrollContainer.isRemote) { + newIndex = index - this.gridAPI.grid.virtualizationState.startIndex; + } + return newIndex; + } + + /** + * @hidden + * @internal + */ + protected getDataIndex(dataViewIndex: number): number { + let newIndex = dataViewIndex; + if (this.gridAPI.grid.verticalScrollContainer.isRemote) { + newIndex = dataViewIndex + this.gridAPI.grid.virtualizationState.startIndex; + } + return newIndex; + } + + /** + * Places a column before or after the specified target column. + * + * @example + * ```typescript + * grid.moveColumn(column, target); + * ``` + */ + public moveColumn(column: IgxColumnComponent, target: IgxColumnComponent, pos: DropPosition = DropPosition.AfterDropTarget) { + // M.A. May 11th, 2021 #9508 Make the event cancelable + const eventArgs: IColumnMovingEndEventArgs = { source: column, target, cancel: false }; + + this.columnMovingEnd.emit(eventArgs); + + if (eventArgs.cancel) { + return; + } + + if (column === target || (column.level !== target.level) || + (column.topLevelParent !== target.topLevelParent)) { + return; + } + + if (column.level) { + this._moveChildColumns(column.parent as IgxColumnComponent, column, target, pos); + } + + // let columnPinStateChanged; + // 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 = target.pinningPosition === ColumnPinningPosition.Start ? this.pinnedStartColumns.indexOf(target) : this.pinnedEndColumns.indexOf(target); + const index = pos === DropPosition.AfterDropTarget ? pinnedIndex + 1 : pinnedIndex; + column.pin(index, target.pinningPosition); + } + + if (!target.pinned && column.pinned) { + const unpinnedIndex = this._unpinnedColumns.indexOf(target); + const index = pos === DropPosition.AfterDropTarget ? unpinnedIndex + 1 : unpinnedIndex; + 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); + // } + + // if (!target.pinned && !column.pinned && !columnPinStateChanged) { + // this._reorderColumns(column, target, pos, this._unpinnedColumns); + // } + + this._moveColumns(column, target, pos); + this._columnsReordered(column); + } + + /** + * Triggers change detection for the `IgxGridComponent`. + * Calling markForCheck also triggers the grid pipes explicitly, resulting in all updates being processed. + * May degrade performance if used when not needed, or if misused: + * ```typescript + * // DON'Ts: + * // don't call markForCheck from inside a loop + * // don't call markForCheck when a primitive has changed + * grid.data.forEach(rec => { + * rec = newValue; + * grid.markForCheck(); + * }); + * + * // DOs + * // call markForCheck after updating a nested property + * grid.data.forEach(rec => { + * rec.nestedProp1.nestedProp2 = newValue; + * }); + * grid.markForCheck(); + * ``` + * + * @example + * ```typescript + * grid.markForCheck(); + * ``` + */ + public markForCheck() { + this.pipeTrigger++; + this.cdr.detectChanges(); + } + + /* csSuppress */ + /** + * Creates a new `IgxGridRowComponent` and adds the data record to the end of the data source. + * + * @example + * ```typescript + * this.grid1.addRow(record); + * ``` + * @param data + */ + public addRow(data: any): void { + // commit pending states prior to adding a row + this.crudService.endEdit(true); + this.gridAPI.addRowToData(data); + + this.pipeTrigger++; + this.rowAddedNotifier.next({ data: data, rowData: data, owner: this, primaryKey: data[this.primaryKey], rowKey: data[this.primaryKey] }); + this.notifyChanges(); + } + + /* blazorCSSuppress */ + /** + * Removes the `IgxGridRowComponent` and the corresponding data record by primary key. + * + * @remarks + * Requires that the `primaryKey` property is set. + * The method accept rowSelector as a parameter, which is the rowID. + * @example + * ```typescript + * this.grid1.deleteRow(0); + * ``` + * @param rowSelector + */ + public deleteRow(rowSelector: any): any { + if (this.primaryKey !== undefined && this.primaryKey !== null) { + return this.deleteRowById(rowSelector); + } + } + + /** @hidden */ + public deleteRowById(rowId: any): any { + const args: IRowDataCancelableEventArgs = { + rowID: rowId, + primaryKey: rowId, + rowKey: rowId, + rowData: this.getRowData(rowId), + data: this.getRowData(rowId), + oldValue: this.getRowData(rowId), + owner: this, + isAddRow: false, + cancel: false + }; + this.rowDelete.emit(args); + if (args.cancel) { + return; + } + + const record = this.gridAPI.deleteRowById(rowId); + if (record !== null && record !== undefined) { + const rowDeletedEventArgs: IRowDataEventArgs = { + data: record, + rowData: record, + owner: this, + primaryKey: record[this.primaryKey], + rowKey: record[this.primaryKey] + }; + this.rowDeleted.emit(rowDeletedEventArgs); + } + return record; + } + + /* blazorCSSuppress */ + /** + * Updates the `IgxGridRowComponent` and the corresponding data record by primary key. + * + * @remarks + * Requires that the `primaryKey` property is set. + * @example + * ```typescript + * this.gridWithPK.updateCell('Updated', 1, 'ProductName'); + * ``` + * @param value the new value which is to be set. + * @param rowSelector corresponds to rowID. + * @param column corresponds to column field. + */ + public updateCell(value: any, rowSelector: any, column: string): void { + if (this.isDefined(this.primaryKey)) { + const col = this._columns.find(c => c.field === column); + if (col) { + // Simplify + const rowData = this.gridAPI.getRowData(rowSelector); + const index = this.gridAPI.get_row_index_in_data(rowSelector); + // If row passed is invalid + if (index < 0) { + return; + } + + const id = { + rowID: rowSelector, + columnID: col.index, + rowIndex: index + }; + + const cell = new IgxCell(id, index, col, rowData[col.field], value, rowData, this as any); + const formControl = this.validation.getFormControl(cell.id.rowID, cell.column.field); + formControl.setValue(value); + this.gridAPI.update_cell(cell); + this.cdr.detectChanges(); + } + } + } + + /* blazorCSSuppress */ + /** + * Updates the `IgxGridRowComponent` + * + * @remarks + * The row is specified by + * rowSelector parameter and the data source record with the passed value. + * This method will apply requested update only if primary key is specified in the grid. + * @example + * ```typescript + * grid.updateRow({ + * ProductID: 1, ProductName: 'Spearmint', InStock: true, UnitsInStock: 1, OrderDate: new Date('2005-03-21') + * }, 1); + * ``` + * @param value– + * @param rowSelector correspond to rowID + */ + // TODO: prevent event invocation + public updateRow(value: any, rowSelector: any): void { + if (this.isDefined(this.primaryKey)) { + const editableCell = this.crudService.cell; + if (editableCell && editableCell.id.rowID === rowSelector) { + this.crudService.endCellEdit(); + } + const row = new IgxEditRow(rowSelector, -1, this.gridAPI.getRowData(rowSelector), this as any); + this.gridAPI.update_row(row, value); + + // TODO: fix for #5934 and probably break for #5763 + // consider adding of third optional boolean parameter in updateRow. + // If developer set this parameter to true we should call notifyChanges(true), and + // vise-versa if developer set it to false we should call notifyChanges(false). + // The parameter should default to false + this.notifyChanges(); + } + } + + /** + * Returns the data that is contained in the row component. + * + * @remarks + * If the primary key is not specified the row selector match the row data. + * @example + * ```typescript + * const data = grid.getRowData(94741); + * ``` + * @param rowSelector correspond to rowID + */ + public getRowData(rowSelector: any): any { + if (!this.primaryKey) { + return rowSelector; + } + const data = this.gridAPI.get_all_data(this.transactions.enabled); + const index = this.gridAPI.get_row_index_in_data(rowSelector); + return index < 0 ? {} : data[index]; + } + + /** + * Sort a single `IgxColumnComponent`. + * + * @remarks + * Sort the `IgxGridComponent`'s `IgxColumnComponent` based on the provided array of sorting expressions. + * @example + * ```typescript + * this.grid.sort({ fieldName: name, dir: SortingDirection.Asc, ignoreCase: false }); + * ``` + */ + public sort(expression: ISortingExpression | Array): void { + const sortingState = cloneArray(this.sortingExpressions); + + if (expression instanceof Array) { + for (const each of expression) { + this.gridAPI.prepare_sorting_expression([sortingState], each); + } + } else { + if (this._sortingOptions.mode === 'single') { + this._columns.forEach((col) => { + if (!(col.field === expression.fieldName)) { + this.clearSort(col.field); + } + }); + } + this.gridAPI.prepare_sorting_expression([sortingState], expression); + } + + const eventArgs: ISortingEventArgs = { owner: this, sortingExpressions: sortingState, cancel: false }; + this.sorting.emit(eventArgs); + + if (eventArgs.cancel) { + return; + } + + this.crudService.endEdit(false); + if (expression instanceof Array) { + this.gridAPI.sort_multiple(expression); + } else { + this.gridAPI.sort(expression); + } + requestAnimationFrame(() => this.sortingDone.emit(expression)); + } + + /** + * Filters a single `IgxColumnComponent`. + * + * @example + * ```typescript + * public filter(term) { + * this.grid.filter("ProductName", term, IgxStringFilteringOperand.instance().condition("contains")); + * } + * ``` + * @param name + * @param value + * @param conditionOrExpressionTree + * @param ignoreCase + */ + public filter(name: string, value: any, conditionOrExpressionTree?: IFilteringOperation | IFilteringExpressionsTree, + ignoreCase?: boolean) { + this.filteringService.filter(name, value, conditionOrExpressionTree, ignoreCase); + } + + /** + * Filters all the `IgxColumnComponent` in the `IgxGridComponent` with the same condition. + * + * @example + * ```typescript + * grid.filterGlobal('some', IgxStringFilteringOperand.instance().condition('contains')); + * ``` + * @param value + * @param condition + * @param ignoreCase + * @deprecated in version 19.0.0. + */ + public filterGlobal(value: any, condition, ignoreCase?) { + this.filteringService.filterGlobal(value, condition, ignoreCase); + } + + /** + * Enables summaries for the specified column and applies your customSummary. + * + * @remarks + * If you do not provide the customSummary, then the default summary for the column data type will be applied. + * @example + * ```typescript + * grid.enableSummaries([{ fieldName: 'ProductName' }, { fieldName: 'ID' }]); + * ``` + * Enable summaries for the listed columns. + * @example + * ```typescript + * grid.enableSummaries('ProductName'); + * ``` + * @param rest + */ + public enableSummaries(...rest) { + if (rest.length === 1 && Array.isArray(rest[0])) { + this._multipleSummaries(rest[0], true); + } else { + this._summaries(rest[0], true, rest[1]); + } + } + + /** + * Disable summaries for the specified column. + * + * @example + * ```typescript + * grid.disableSummaries('ProductName'); + * ``` + * @remarks + * Disable summaries for the listed columns. + * @example + * ```typescript + * grid.disableSummaries([{ fieldName: 'ProductName' }]); + * ``` + */ + public disableSummaries(...rest) { + if (rest.length === 1 && Array.isArray(rest[0])) { + this._disableMultipleSummaries(rest[0]); + } else { + this._summaries(rest[0], false); + } + } + + /** + * If name is provided, clears the filtering state of the corresponding `IgxColumnComponent`. + * + * @remarks + * Otherwise clears the filtering state of all `IgxColumnComponent`s. + * @example + * ```typescript + * this.grid.clearFilter(); + * ``` + * @param name + */ + public clearFilter(name?: string) { + this.filteringService.clearFilter(name); + } + + /** + * If name is provided, clears the sorting state of the corresponding `IgxColumnComponent`. + * + * @remarks + * otherwise clears the sorting state of all `IgxColumnComponent`. + * @example + * ```typescript + * this.grid.clearSort(); + * ``` + * @param name + */ + public clearSort(name?: string) { + if (!name) { + this.sortingExpressions = []; + return; + } + if (!this.gridAPI.get_column_by_name(name)) { + return; + } + this.gridAPI.clear_sort(name); + } + + /** + * @hidden @internal + */ + public refreshGridState(_args?) { + this.crudService.endEdit(true); + this.selectionService.clearHeaderCBState(); + this.summaryService.clearSummaryCache(); + this.summaryPipeTrigger++; + this.cdr.detectChanges(); + } + + // TODO: We have return values here. Move them to event args ?? + + /** + * Pins a column by field name. + * + * @remarks + * Returns whether the operation is successful. + * @example + * ```typescript + * this.grid.pinColumn("ID"); + * ``` + * @param columnName + * @param index + * @param pinningPosition + */ + public pinColumn(columnName: string | IgxColumnComponent, index?: number, pinningPosition?: ColumnPinningPosition): boolean { + const col = columnName instanceof IgxColumnComponent ? columnName : this.getColumnByName(columnName); + return col.pin(index, pinningPosition); + } + + /** + * Unpins a column by field name. Returns whether the operation is successful. + * + * @example + * ```typescript + * this.grid.pinColumn("ID"); + * ``` + * @param columnName + * @param index + */ + public unpinColumn(columnName: string | IgxColumnComponent, index?: number): boolean { + const col = columnName instanceof IgxColumnComponent ? columnName : this.getColumnByName(columnName); + return col.unpin(index); + } + + /* csSuppress */ + /** + * Pin the row by its id. + * + * @remarks + * ID is either the primaryKey value or the data record instance. + * @example + * ```typescript + * this.grid.pinRow(rowID); + * ``` + * @param rowID The row id - primaryKey value or the data record instance. + * @param index The index at which to insert the row in the pinned collection. + */ + public pinRow(rowID: any, index?: number, row?: RowType): boolean { + if (this._pinnedRecordIDs.indexOf(rowID) !== -1) { + return false; + } + const eventArgs = this.gridAPI.get_pin_row_event_args(rowID, index, row, true); + this.rowPinning.emit(eventArgs); + + if (eventArgs.cancel) { + return; + } + this.crudService.endEdit(false); + + const insertIndex = typeof eventArgs.insertAtIndex === 'number' ? eventArgs.insertAtIndex : this._pinnedRecordIDs.length; + this._pinnedRecordIDs.splice(insertIndex, 0, rowID); + this.pipeTrigger++; + if (this.gridAPI.grid) { + this.cdr.detectChanges(); + this.rowPinned.emit(eventArgs); + } + + return true; + } + + /* csSuppress */ + /** + * Unpin the row by its id. + * + * @remarks + * ID is either the primaryKey value or the data record instance. + * @example + * ```typescript + * this.grid.unpinRow(rowID); + * ``` + * @param rowID The row id - primaryKey value or the data record instance. + */ + public unpinRow(rowID: any, row?: RowType): boolean { + const index = this._pinnedRecordIDs.indexOf(rowID); + if (index === -1) { + return false; + } + + const eventArgs = this.gridAPI.get_pin_row_event_args(rowID, null, row, false); + this.rowPinning.emit(eventArgs); + + if (eventArgs.cancel) { + return; + } + + this.crudService.endEdit(false); + this._pinnedRecordIDs.splice(index, 1); + this.pipeTrigger++; + if (this.gridAPI.grid) { + this.cdr.detectChanges(); + this.rowPinned.emit(eventArgs); + } + + return true; + } + + /** @hidden @internal */ + public get pinnedRowHeight() { + const containerHeight = this.pinContainer ? this.pinContainer.nativeElement.offsetHeight : 0; + return this.hasPinnedRecords ? containerHeight : 0; + } + + /** @hidden @internal */ + public get totalHeight() { + const height = this.calcHeight ? this.calcHeight + this.pinnedRowHeight : this.calcHeight; + return this.platform.isBrowser ? height : undefined; + } + + /** + * Recalculates grid width/height dimensions. + * + * @remarks + * Should be run when changing DOM elements dimentions manually that affect the grid's size. + * @example + * ```typescript + * this.grid.reflow(); + * ``` + */ + public reflow() { + this.calculateGridSizes(); + } + + /** + * Finds the next occurrence of a given string in the grid and scrolls to the cell if it isn't visible. + * + * @remarks + * Returns how many times the grid contains the string. + * @example + * ```typescript + * this.grid.findNext("financial"); + * ``` + * @param text the string to search. + * @param caseSensitive optionally, if the search should be case sensitive (defaults to false). + * @param exactMatch optionally, if the text should match the entire value (defaults to false). + */ + public findNext(text: string, caseSensitive?: boolean, exactMatch?: boolean): number { + return this.find(text, 1, caseSensitive, exactMatch); + } + + /** + * Finds the previous occurrence of a given string in the grid and scrolls to the cell if it isn't visible. + * + * @remarks + * Returns how many times the grid contains the string. + * @example + * ```typescript + * this.grid.findPrev("financial"); + * ``` + * @param text the string to search. + * @param caseSensitive optionally, if the search should be case sensitive (defaults to false). + * @param exactMatch optionally, if the text should match the entire value (defaults to false). + */ + public findPrev(text: string, caseSensitive?: boolean, exactMatch?: boolean): number { + return this.find(text, -1, caseSensitive, exactMatch); + } + + /** + * Reapplies the existing search. + * + * @remarks + * Returns how many times the grid contains the last search. + * @example + * ```typescript + * this.grid.refreshSearch(); + * ``` + * @param updateActiveInfo + */ + public refreshSearch(updateActiveInfo?: boolean, endEdit = true): number { + if (this._lastSearchInfo.searchText) { + this.rebuildMatchCache(); + + if (updateActiveInfo) { + const activeInfo = this.textHighlightService.highlightGroupsMap.get(this.id); + this._lastSearchInfo.matchInfoCache.forEach((match, i) => { + if (match.column === activeInfo.column && + match.row === activeInfo.row && + match.index === activeInfo.index && + compareMaps(match.metadata, activeInfo.metadata)) { + this._lastSearchInfo.activeMatchIndex = i; + } + }); + } + + return this.find(this._lastSearchInfo.searchText, + 0, + this._lastSearchInfo.caseSensitive, + this._lastSearchInfo.exactMatch, + false, + endEdit); + } else { + return 0; + } + } + + /** + * Removes all the highlights in the cell. + * + * @example + * ```typescript + * this.grid.clearSearch(); + * ``` + */ + public clearSearch() { + this._lastSearchInfo = { + searchText: '', + caseSensitive: false, + exactMatch: false, + activeMatchIndex: 0, + matchInfoCache: [], + matchCount: 0, + content: '' + }; + + this.rowList.forEach((row) => { + if (row.cells) { + row.cells.forEach((c: IgxGridCellComponent) => { + c.clearHighlight(); + }); + } + }); + } + + /** @hidden @internal */ + public get hasEditableColumns(): boolean { + return this._columns.some((col) => col.editable); + } + + /** @hidden @internal */ + public get hasSummarizedColumns(): boolean { + const summarizedColumns = this._columns.filter(col => col.hasSummary && !col.hidden); + return summarizedColumns.length > 0; + } + + /** + * @hidden @internal + */ + public get rootSummariesEnabled(): boolean { + return this.summaryCalculationMode !== GridSummaryCalculationMode.childLevelsOnly; + } + + /** + * @hidden @internal + */ + public get hasVisibleColumns(): boolean { + if (this._hasVisibleColumns === undefined) { + return this._columns ? this._columns.some(c => !c.hidden) : false; + } + return this._hasVisibleColumns; + } + + public set hasVisibleColumns(value) { + this._hasVisibleColumns = value; + } + + /** @hidden @internal */ + public get hasMovableColumns(): boolean { + return this.moving; + } + + /** @hidden @internal */ + public get hasColumnGroups(): boolean { + return this._columnGroups; + } + + /** @hidden @internal */ + public get hasColumnLayouts() { + return !!this._columns.some(col => col.columnLayout); + } + + + /** + * @hidden @internal + */ + public get multiRowLayoutRowSize() { + return this._multiRowLayoutRowSize; + } + + /** + * @hidden + */ + protected get rowBasedHeight() { + return this.dataLength * this.rowHeight; + } + + /** + * @hidden + */ + protected get isPercentWidth() { + return this.width && this.width.indexOf('%') !== -1; + } + + protected get shouldResize(): boolean { + return this._gridSize !== this.gridSize; + } + + /** + * @hidden @internal + */ + public get isPercentHeight() { + return this._height && this._height.indexOf('%') !== -1; + } + + /** + * @hidden + */ + protected get defaultTargetBodyHeight(): number { + const allItems = this.dataLength; + return this.renderedActualRowHeight * Math.min(this._defaultTargetRecordNumber, + this.paginator ? Math.min(allItems, this.paginator.perPage) : allItems); + } + + /** + * @hidden @internal + * The rowHeight input is bound to min-height css prop of rows that adds a 1px border in all cases + */ + public get renderedRowHeight(): number { + if (this.hasCellsToMerge) { + return this.rowHeight; + } + return this.rowHeight + 1; + } + + /** + * @hidden @internal + */ + public get outerWidth() { + return this.hasVerticalScroll() ? this.calcWidth + this.scrollSize : this.calcWidth; + } + + /** + * @hidden @internal + * Gets the size of the grid + */ + public get gridSize(): ɵSize { + return this.gridComputedStyles?.getPropertyValue('--component-size') || ɵSize.Large; + } + + /** + * @hidden @internal + * Gets the visible content height that includes header + tbody + footer. + */ + public getVisibleContentHeight() { + let height = this.theadRow.nativeElement.clientHeight + this.tbody.nativeElement.clientHeight; + if (this.hasSummarizedColumns) { + height += this.tfoot.nativeElement.clientHeight; + } + return height; + } + + /** + * @hidden @internal + */ + public getPossibleColumnWidth(baseWidth: number = null, minColumnWidth: number = null) { + let computedWidth; + if (baseWidth !== null) { + computedWidth = baseWidth; + } else { + computedWidth = this.calcWidth || + parseFloat(this.document.defaultView.getComputedStyle(this.nativeElement).getPropertyValue('width')); + } + + const visibleChildColumns = this.visibleColumns.filter(c => !c.columnGroup); + + + // Column layouts related + let visibleCols = []; + const columnBlocks = this.visibleColumns.filter(c => c.columnGroup); + const colsPerBlock = columnBlocks.map(block => block.getInitialChildColumnSizes(block.children)); + const combinedBlocksSize = colsPerBlock.reduce((acc, item) => acc + item.length, 0); + colsPerBlock.forEach(blockCols => visibleCols = visibleCols.concat(blockCols)); + // + + const columnsWithSetWidths = this.hasColumnLayouts ? + visibleCols.filter(c => c.widthSetByUser) : + visibleChildColumns.filter(c => (c.widthSetByUser || c.widthConstrained) && c.width !== 'fit-content'); + + const columnsToSize = this.hasColumnLayouts ? + combinedBlocksSize - columnsWithSetWidths.length : + visibleChildColumns.length - columnsWithSetWidths.length; + const sumExistingWidths = columnsWithSetWidths + .reduce((prev, curr) => { + const colInstance = this.hasColumnLayouts ? curr.ref : curr; + const colWidth = !colInstance.widthConstrained ? curr.width : colInstance.calcPixelWidth; + let widthValue = parseFloat(colWidth); + if (isNaN(widthValue)) { + widthValue = MINIMUM_COLUMN_WIDTH; + } + const currWidth = colWidth && typeof colWidth === 'string' && colWidth.indexOf('%') !== -1 ? + widthValue / 100 * computedWidth : + widthValue; + // apply constraints, since constraint may change width + const constrainedWidth = this.hasColumnLayouts ? currWidth : colInstance.getConstrainedSizePx(currWidth); + return prev + constrainedWidth; + }, 0); + + // When all columns are hidden, return 0px width + if (!sumExistingWidths && !columnsToSize) { + return '0px'; + } + computedWidth -= this.featureColumnsWidth(); + + const minColWidth = minColumnWidth || this.minColumnWidth; + + const columnWidth = !Number.isFinite(sumExistingWidths) ? + Math.max(computedWidth / columnsToSize, minColWidth) : + Math.max((computedWidth - sumExistingWidths) / columnsToSize, minColWidth); + + return columnWidth + 'px'; + } + + /** + * @hidden @internal + */ + public hasVerticalScroll() { + if (this._init) { + return false; + } + const isScrollable = this.verticalScrollContainer ? this.verticalScrollContainer.isScrollable() : false; + return !!(this.calcWidth && this.dataView && this.dataView.length > 0 && isScrollable); + } + + /** + * Gets calculated width of the pinned areas. + * + * @example + * ```typescript + * const pinnedWidth = this.grid.getPinnedStartWidth(); + * ``` + * @param takeHidden If we should take into account the hidden columns in the pinned area. + */ + 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); + } + } + // 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 + */ + public isColumnGrouped(_fieldName: string): boolean { + return false; + } + + /** + * @hidden @internal + * TODO: REMOVE + */ + public onHeaderSelectorClick(event) { + if (!this.isMultiRowSelectionEnabled) { + return; + } + if (this.selectionService.areAllRowSelected()) { + this.selectionService.clearRowSelection(event); + } else { + this.selectionService.selectAllRows(event); + } + } + + /** + * @hidden @internal + */ + public get headSelectorBaseAriaLabel() { + if (this._filteringExpressionsTree.filteringOperands.length > 0) { + return this.selectionService.areAllRowSelected() ? 'Deselect all filtered' : 'Select all filtered'; + } + + return this.selectionService.areAllRowSelected() ? 'Deselect all' : 'Select all'; + } + + /** + * @hidden + * @internal + */ + public get totalRowsCountAfterFilter() { + if (this.data) { + return this.selectionService.allData.length; + } + + return 0; + } + + /** @hidden @internal */ + public get pinnedDataView(): any[] { + return this.pinnedRecords ? this.pinnedRecords : []; + } + + /** @hidden @internal */ + public get unpinnedDataView(): any[] { + return this.unpinnedRecords ? this.unpinnedRecords : this.verticalScrollContainer?.igxForOf || []; + } + + /** + * Returns the currently transformed paged/filtered/sorted/grouped/pinned/unpinned row data, displayed in the grid. + * + * @example + * ```typescript + * const dataView = this.grid.dataView; + * ``` + */ + public get dataView() { + return this._dataView; + } + + /** + * Gets/Sets whether clicking over a row should select/deselect it + * + * @remarks + * By default it is set to true + * @param enabled: boolean + */ + @WatchChanges() + @Input({ transform: booleanAttribute }) + public get selectRowOnClick() { + return this._selectRowOnClick; + } + + public set selectRowOnClick(enabled: boolean) { + this._selectRowOnClick = enabled; + } + + /** + * Select specified rows by ID. + * + * @example + * ```typescript + * this.grid.selectRows([1,2,5], true); + * ``` + * @param rowIDs + * @param clearCurrentSelection if true clears the current selection + */ + public selectRows(rowIDs: any[], clearCurrentSelection?: boolean) { + this.selectionService.selectRowsWithNoEvent(rowIDs, clearCurrentSelection); + this.notifyChanges(); + } + + /** + * Deselect specified rows by ID. + * + * @example + * ```typescript + * this.grid.deselectRows([1,2,5]); + * ``` + * @param rowIDs + */ + public deselectRows(rowIDs: any[]) { + this.selectionService.deselectRowsWithNoEvent(rowIDs); + this.notifyChanges(); + } + + /** + * Selects all rows + * + * @remarks + * By default if filtering is in place, selectAllRows() and deselectAllRows() select/deselect all filtered rows. + * If you set the parameter onlyFilterData to false that will select all rows in the grid exept deleted rows. + * @example + * ```typescript + * this.grid.selectAllRows(); + * this.grid.selectAllRows(false); + * ``` + * @param onlyFilterData + */ + public selectAllRows(onlyFilterData = true) { + const data = onlyFilterData && this.filteredData ? this.filteredData : this.gridAPI.get_all_data(true); + const rowIDs = this.selectionService.getRowIDs(data).filter(rID => !this.gridAPI.row_deleted_transaction(rID)); + this.selectRows(rowIDs); + } + + /** + * Deselects all rows + * + * @remarks + * By default if filtering is in place, selectAllRows() and deselectAllRows() select/deselect all filtered rows. + * If you set the parameter onlyFilterData to false that will deselect all rows in the grid exept deleted rows. + * @example + * ```typescript + * this.grid.deselectAllRows(); + * ``` + * @param onlyFilterData + */ + public deselectAllRows(onlyFilterData = true) { + if (onlyFilterData && this.filteredData && this.filteredData.length > 0) { + this.deselectRows(this.selectionService.getRowIDs(this.filteredData)); + } else { + this.selectionService.clearAllSelectedRows(); + this.notifyChanges(); + } + } + + /** + * Deselect selected cells. + * @example + * ```typescript + * this.grid.clearCellSelection(); + * ``` + */ + public clearCellSelection(): void { + this.selectionService.clear(true); + this._activeRowIndexes = null; + this.notifyChanges(); + } + + /** + * @hidden @internal + */ + public dragScroll(delta: { left: number; top: number }): void { + const horizontal = this.headerContainer.getScroll(); + const vertical = this.verticalScrollContainer.getScroll(); + const { left, top } = delta; + + horizontal.scrollLeft += left * this.DRAG_SCROLL_DELTA; + vertical.scrollTop += top * this.DRAG_SCROLL_DELTA; + } + + /** + * @hidden @internal + */ + public isDefined(arg: any): boolean { + return arg !== undefined && arg !== null; + } + + /** + * Select range(s) of cells between certain rows and columns of the grid. + */ + public selectRange(arg: GridSelectionRange | GridSelectionRange[] | null | undefined): void { + if (!this.isDefined(arg)) { + this.clearCellSelection(); + return; + } + if (arg instanceof Array) { + arg.forEach(range => this.setSelection(range)); + } else { + this.setSelection(arg); + } + this.notifyChanges(); + } + + /** + * @hidden @internal + */ + public columnToVisibleIndex(field: string | number): number { + const visibleColumns = this.visibleColumns; + if (typeof field === 'number') { + return field; + } + return visibleColumns.find(column => column.field === field).visibleIndex; + } + + /** + * @hidden @internal + */ + public setSelection(range: GridSelectionRange): void { + const startNode = { row: range.rowStart, column: this.columnToVisibleIndex(range.columnStart) }; + const endNode = { row: range.rowEnd, column: this.columnToVisibleIndex(range.columnEnd) }; + + this.selectionService.pointerState.node = startNode; + this.selectionService.selectRange(endNode, this.selectionService.pointerState); + this.selectionService.addRangeMeta(endNode, this.selectionService.pointerState); + this.selectionService.initPointerState(); + } + + /** + * Get the currently selected ranges in the grid. + */ + public getSelectedRanges(): GridSelectionRange[] { + return this.selectionService.ranges; + } + + /** + * + * Returns an array of the current cell selection in the form of `[{ column.field: cell.value }, ...]`. + * + * @remarks + * If `formatters` is enabled, the cell value will be formatted by its respective column formatter (if any). + * If `headers` is enabled, it will use the column header (if any) instead of the column field. + */ + public getSelectedData(formatters = false, headers = false) { + const source = this.filteredSortedData; + return this.extractDataFromSelection(source, formatters, headers); + } + + /** + * Get current selected columns. + * + * @example + * Returns an array with selected columns + * ```typescript + * const selectedColumns = this.grid.selectedColumns(); + * ``` + */ + public selectedColumns(): ColumnType[] { + const fields = this.selectionService.getSelectedColumns(); + return fields.map(field => this.getColumnByName(field)).filter(field => field); + } + + /** + * Select specified columns. + * + * @example + * ```typescript + * this.grid.selectColumns(['ID','Name'], true); + * ``` + * @param columns + * @param clearCurrentSelection if true clears the current selection + */ + public selectColumns(columns: string[] | ColumnType[], clearCurrentSelection?: boolean) { + let fieldToSelect: string[] = []; + if (columns.length === 0 || typeof columns[0] === 'string') { + fieldToSelect = columns as string[]; + } else { + (columns as ColumnType[]).forEach(col => { + if (col.columnGroup) { + const children = col.allChildren.filter(c => !c.columnGroup).map(c => c.field); + fieldToSelect = [...fieldToSelect, ...children]; + } else { + fieldToSelect.push(col.field); + } + }); + } + + this.selectionService.selectColumnsWithNoEvent(fieldToSelect, clearCurrentSelection); + this.notifyChanges(); + } + + /** + * Deselect specified columns by field. + * + * @example + * ```typescript + * this.grid.deselectColumns(['ID','Name']); + * ``` + * @param columns + */ + public deselectColumns(columns: string[] | ColumnType[]) { + let fieldToDeselect: string[] = []; + if (columns.length === 0 || typeof columns[0] === 'string') { + fieldToDeselect = columns as string[]; + } else { + (columns as ColumnType[]).forEach(col => { + if (col.columnGroup) { + const children = col.allChildren.filter(c => !c.columnGroup).map(c => c.field); + fieldToDeselect = [...fieldToDeselect, ...children]; + } else { + fieldToDeselect.push(col.field); + } + }); + } + this.selectionService.deselectColumnsWithNoEvent(fieldToDeselect); + this.notifyChanges(); + } + + /** + * Deselects all columns + * + * @example + * ```typescript + * this.grid.deselectAllColumns(); + * ``` + */ + public deselectAllColumns() { + this.selectionService.clearAllSelectedColumns(); + this.notifyChanges(); + } + + /** + * Selects all columns + * + * @example + * ```typescript + * this.grid.deselectAllColumns(); + * ``` + */ + public selectAllColumns() { + this.selectColumns(this._columns.filter(c => !c.columnGroup)); + } + + /** + * + * Returns an array of the current columns selection in the form of `[{ column.field: cell.value }, ...]`. + * + * @remarks + * If `formatters` is enabled, the cell value will be formatted by its respective column formatter (if any). + * If `headers` is enabled, it will use the column header (if any) instead of the column field. + */ + public getSelectedColumnsData(formatters = false, headers = false) { + const source = this.filteredSortedData ? this.filteredSortedData : this.data; + return this.extractDataFromColumnsSelection(source, formatters, headers); + } + + + /** @hidden @internal **/ + public combineSelectedCellAndColumnData(columnData: any[], formatters = false, headers = false) { + const source = this.filteredSortedData; + return this.extractDataFromSelection(source, formatters, headers, columnData); + } + + /** + * @hidden @internal + */ + public preventContainerScroll = (evt) => { + if (evt.target.scrollTop !== 0) { + this.verticalScrollContainer.addScroll(evt.target.scrollTop); + evt.target.scrollTop = 0; + } + if (evt.target.scrollLeft !== 0) { + this.headerContainer.scrollPosition += evt.target.scrollLeft; + evt.target.scrollLeft = 0; + } + }; + + /** + * @hidden + * @internal + */ + public copyHandler(event) { + const eventPathElements = event.composedPath().map(el => el.tagName?.toLowerCase()); + if (eventPathElements.includes('igx-grid-filtering-row') || + eventPathElements.includes('igx-grid-filtering-cell')) { + return; + } + + const selectedColumns = this.gridAPI.grid.selectedColumns(); + const columnData = this.getSelectedColumnsData(this.clipboardOptions.copyFormatters, this.clipboardOptions.copyHeaders); + let selectedData; + if (event.type === 'copy') { + selectedData = this.getSelectedData(this.clipboardOptions.copyFormatters, this.clipboardOptions.copyHeaders); + } + + let data = []; + let result; + + if (event.code === 'KeyC' && (event.ctrlKey || event.metaKey) && event.currentTarget.className === 'igx-grid-thead__wrapper') { + if (selectedData.length) { + if (columnData.length === 0) { + result = this.prepareCopyData(event, selectedData); + } else { + data = this.combineSelectedCellAndColumnData(columnData, this.clipboardOptions.copyFormatters, + this.clipboardOptions.copyHeaders); + result = this.prepareCopyData(event, data[0], data[1]); + } + } else { + data = columnData; + result = this.prepareCopyData(event, data); + } + + navigator.clipboard.writeText(result).then().catch(e => console.error(e)); + } else if (!this.clipboardOptions.enabled || this.crudService.cellInEditMode || event.type === 'keydown') { + return; + } else { + if (selectedColumns.length) { + data = this.combineSelectedCellAndColumnData(columnData, this.clipboardOptions.copyFormatters, + this.clipboardOptions.copyHeaders); + result = this.prepareCopyData(event, data[0], data[1]); + } else { + data = selectedData; + result = this.prepareCopyData(event, data); + } + event.clipboardData.setData('text/plain', result); + } + } + + /** + * @hidden @internal + */ + public prepareCopyData(event, data, keys?) { + const ev = { data, cancel: false } as IGridClipboardEvent; + this.gridCopy.emit(ev); + + if (ev.cancel) { + return; + } + + const transformer = new CharSeparatedValueData(ev.data, this.clipboardOptions.separator); + let result = keys ? transformer.prepareData(keys) : transformer.prepareData(); + + if (!this.clipboardOptions.copyHeaders) { + result = result.substring(result.indexOf('\n') + 1); + } + + if (data && data.length > 0 && Object.values(data[0]).length === 1) { + result = result.slice(0, -2); + } + + event.preventDefault(); + + /* Necessary for the hiearachical case but will probably have to + change how getSelectedData is propagated in the hiearachical grid + */ + event.stopPropagation(); + + return result; + } + + /** + * @hidden @internal + */ + public showSnackbarFor(index: number) { + this.addRowSnackbar.actionText = index === -1 ? '' : this.resourceStrings.igx_grid_snackbar_addrow_actiontext; + this.lastAddedRowIndex = index; + this.addRowSnackbar.open(); + } + + /* blazorCsSuppress */ + /** + * Navigates to a position in the grid based on provided `rowindex` and `visibleColumnIndex`. + * + * @remarks + * Also can execute a custom logic over the target element, + * through a callback function that accepts { targetType: GridKeydownTargetType, target: Object } + * @example + * ```typescript + * this.grid.navigateTo(10, 3, (args) => { args.target.nativeElement.focus(); }); + * ``` + */ + public navigateTo(rowIndex: number, visibleColIndex = -1, cb: (args: any) => void = null) { + const totalItems = (this as any).totalItemCount ?? this.dataView.length - 1; + if (rowIndex < 0 || rowIndex > totalItems || (visibleColIndex !== -1 + && this._columns.map(col => col.visibleIndex).indexOf(visibleColIndex) === -1)) { + return; + } + if (this.dataView.slice(rowIndex, rowIndex + 1).find(rec => rec.expression || rec.childGridsData)) { + visibleColIndex = -1; + } + // If the target row is pinned no need to scroll as well. + const shouldScrollVertically = this.navigation.shouldPerformVerticalScroll(rowIndex, visibleColIndex); + const shouldScrollHorizontally = this.navigation.shouldPerformHorizontalScroll(visibleColIndex, rowIndex); + if (shouldScrollVertically) { + this.navigation.performVerticalScrollToCell(rowIndex, visibleColIndex, () => { + if (shouldScrollHorizontally) { + this.navigation.performHorizontalScrollToCell(visibleColIndex, () => + this.executeCallback(rowIndex, visibleColIndex, cb)); + } else { + this.executeCallback(rowIndex, visibleColIndex, cb); + } + }); + } else if (shouldScrollHorizontally) { + this.navigation.performHorizontalScrollToCell(visibleColIndex, () => { + if (shouldScrollVertically) { + this.navigation.performVerticalScrollToCell(rowIndex, visibleColIndex, () => + this.executeCallback(rowIndex, visibleColIndex, cb)); + } else { + this.executeCallback(rowIndex, visibleColIndex, cb); + } + }); + } else { + this.executeCallback(rowIndex, visibleColIndex, cb); + } + } + + /* blazorCsSuppress */ + /** + * Returns `ICellPosition` which defines the next cell, + * according to the current position, that match specific criteria. + * + * @remarks + * You can pass callback function as a third parameter of `getPreviousCell` method. + * The callback function accepts IgxColumnComponent as a param + * @example + * ```typescript + * const nextEditableCellPosition = this.grid.getNextCell(0, 3, (column) => column.editable); + * ``` + */ + public getNextCell(currRowIndex: number, curVisibleColIndex: number, + callback: (IgxColumnComponent) => boolean = null): ICellPosition { + const columns = this._columns.filter(col => !col.columnGroup && col.visibleIndex >= 0); + const dataViewIndex = this._getDataViewIndex(currRowIndex); + if (!this.isValidPosition(dataViewIndex, curVisibleColIndex)) { + return { rowIndex: currRowIndex, visibleColumnIndex: curVisibleColIndex }; + } + const colIndexes = callback ? columns.filter((col) => callback(col)).map(editCol => editCol.visibleIndex).sort((a, b) => a - b) : + columns.map(editCol => editCol.visibleIndex).sort((a, b) => a - b); + const nextCellIndex = colIndexes.find(index => index > curVisibleColIndex); + if (this.dataView.slice(dataViewIndex, dataViewIndex + 1) + .find(rec => !rec.expression && !rec.summaries && !rec.childGridsData && !rec.detailsData) && nextCellIndex !== undefined) { + return { rowIndex: currRowIndex, visibleColumnIndex: nextCellIndex }; + } else { + const nextIndex = this.getNextDataRowIndex(currRowIndex) + if (colIndexes.length === 0 || nextIndex === currRowIndex) { + return { rowIndex: currRowIndex, visibleColumnIndex: curVisibleColIndex }; + } else { + return { rowIndex: nextIndex, visibleColumnIndex: colIndexes[0] }; + } + } + } + + /* blazorCsSuppress */ + /** + * Returns `ICellPosition` which defines the previous cell, + * according to the current position, that match specific criteria. + * + * @remarks + * You can pass callback function as a third parameter of `getPreviousCell` method. + * The callback function accepts IgxColumnComponent as a param + * @example + * ```typescript + * const previousEditableCellPosition = this.grid.getPreviousCell(0, 3, (column) => column.editable); + * ``` + */ + public getPreviousCell(currRowIndex: number, curVisibleColIndex: number, + callback: (IgxColumnComponent) => boolean = null): ICellPosition { + const columns = this._columns.filter(col => !col.columnGroup && col.visibleIndex >= 0); + const dataViewIndex = this._getDataViewIndex(currRowIndex); + if (!this.isValidPosition(dataViewIndex, curVisibleColIndex)) { + return { rowIndex: currRowIndex, visibleColumnIndex: curVisibleColIndex }; + } + const colIndexes = callback ? columns.filter((col) => callback(col)).map(editCol => editCol.visibleIndex).sort((a, b) => b - a) : + columns.map(editCol => editCol.visibleIndex).sort((a, b) => b - a); + const prevCellIndex = colIndexes.find(index => index < curVisibleColIndex); + if (this.dataView.slice(dataViewIndex, dataViewIndex + 1) + .find(rec => !rec.expression && !rec.summaries && !rec.childGridsData && !rec.detailsData) && prevCellIndex !== undefined) { + return { rowIndex: currRowIndex, visibleColumnIndex: prevCellIndex }; + } else { + const prevIndex = this.getNextDataRowIndex(currRowIndex, true); + if (colIndexes.length === 0 || prevIndex === currRowIndex) { + return { rowIndex: currRowIndex, visibleColumnIndex: curVisibleColIndex }; + } else { + return { rowIndex: prevIndex, visibleColumnIndex: colIndexes[0] }; + } + } + } + + /** + * @hidden + * @internal + */ + public endRowEditTabStop(commit = true, event?: Event) { + const canceled = this.crudService.endEdit(commit, event); + + if (canceled) { + return true; + } + + this.navigation.restoreActiveNodeFocus(); + } + + /** + * @hidden @internal + */ + public trackColumnChanges(_index, col) { + return col.field + col._calcWidth.toString(); + } + + /** + * @hidden + */ + public isExpandedGroup(_group: IGroupByRecord): boolean { + return undefined; + } + + /** + * @hidden @internal + * TODO: MOVE to CRUD + */ + public openRowOverlay(id) { + this.configureRowEditingOverlay(id, this.rowList.length <= MIN_ROW_EDITING_COUNT_THRESHOLD); + + this.rowEditingOverlay.open(this.rowEditSettings); + this.rowEditingOverlay.element.addEventListener('wheel', this.rowEditingWheelHandler); + } + + /** + * @hidden @internal + */ + public closeRowEditingOverlay() { + this.rowEditingOverlay.element.removeEventListener('wheel', this.rowEditingWheelHandler); + this.rowEditPositioningStrategy.isTopInitialPosition = null; + this.rowEditingOverlay.close(); + this.rowEditingOverlay.element.parentElement.style.display = ''; + } + + /** + * @hidden @internal + */ + public toggleRowEditingOverlay(show) { + const rowStyle = this.rowEditingOverlay.element.style; + if (show) { + rowStyle.display = 'block'; + } else { + rowStyle.display = 'none'; + } + } + + /** + * @hidden @internal + */ + public repositionRowEditingOverlay(row: RowType) { + if (row && !this.rowEditingOverlay.collapsed) { + const rowStyle = this.rowEditingOverlay.element.parentElement.style; + if (row) { + rowStyle.display = ''; + this.configureRowEditingOverlay(row.key); + this.rowEditingOverlay.reposition(); + } else { + rowStyle.display = 'none'; + } + } + } + + protected viewDetachHandler(args) { + if (this.actionStrip && args.view.rootNodes.find(x => x === this.actionStrip.context?.element.nativeElement)) { + this.actionStrip.hide(); + } + } + + /** + * @hidden @internal + */ + public cachedViewLoaded(args: ICachedViewLoadedEventArgs) { + if (this.hasHorizontalScroll()) { + const tmplId = args.context.templateID.type; + const index = args.context.index; + args.view.detectChanges(); + this.zone.onStable.pipe(first()).subscribe(() => { + const row = tmplId === 'dataRow' ? this.gridAPI.get_row_by_index(index) : null; + const summaryRow = tmplId === 'summaryRow' ? this.summariesRowList.find((sr) => sr.dataRowIndex === index) : null; + if (row && row instanceof IgxRowDirective) { + this._restoreVirtState(row); + } else if (summaryRow) { + this._restoreVirtState(summaryRow); + } + }); + } + } + + /** + * Opens the advanced filtering dialog. + */ + public openAdvancedFilteringDialog(overlaySettings?: OverlaySettings) { + const settings = overlaySettings ? overlaySettings : this._advancedFilteringOverlaySettings; + if (!this._advancedFilteringOverlayId) { + this._advancedFilteringOverlaySettings.target = + (this as any).rootGrid ? (this as any).rootGrid.nativeElement : this.nativeElement; + this._advancedFilteringOverlaySettings.outlet = this.outlet; + + this._advancedFilteringOverlayId = this.overlayService.attach( + IgxAdvancedFilteringDialogComponent, + this.viewRef, + settings); + this.overlayService.show(this._advancedFilteringOverlayId); + } + } + + /** + * Closes the advanced filtering dialog. + * + * @param applyChanges indicates whether the changes should be applied + */ + public closeAdvancedFilteringDialog(applyChanges: boolean) { + if (this._advancedFilteringOverlayId) { + const advancedFilteringOverlay = this.overlayService.getOverlayById(this._advancedFilteringOverlayId); + const advancedFilteringDialog = advancedFilteringOverlay.componentRef.instance as IgxAdvancedFilteringDialogComponent; + + if (applyChanges) { + advancedFilteringDialog.applyChanges(); + } + advancedFilteringDialog.closeDialog(); + } + } + + /** + * @hidden @internal + */ + public getEmptyRecordObjectFor(inRow: RowType) { + const row = { ...inRow?.data }; + Object.keys(row).forEach(key => row[key] = undefined); + const id = this.generateRowID(); + row[this.primaryKey] = id; + return { rowID: id, data: row, recordRef: row }; + } + + /** + * @hidden @internal + */ + public hasHorizontalScroll() { + return Math.round(this.totalWidth - this.unpinnedWidth) > 0 && this.width !== null; + } + + /** + * @hidden @internal + */ + public isSummaryRow(rowData): boolean { + return rowData && rowData.summaries && (rowData.summaries instanceof Map); + } + + /** + * @hidden @internal + */ + public triggerPipes() { + this.pipeTrigger++; + this.cdr.detectChanges(); + } + + /** + * @hidden + */ + public rowEditingWheelHandler = (event: WheelEvent) => { + if (event.deltaY > 0) { + this.verticalScrollContainer.scrollNext(); + } else { + this.verticalScrollContainer.scrollPrev(); + } + } + + /** + * @hidden + */ + public getUnpinnedIndexById(id) { + return this.unpinnedRecords.findIndex(x => x[this.primaryKey] === id); + } + + /** + * Finishes the row transactions on the current row and returns whether the grid editing was canceled. + * + * @remarks + * If `commit === true`, passes them from the pending state to the data (or transaction service) + * @example + * ```html + * + * ``` + * @param commit + */ + // TODO: Facade for crud service refactoring. To be removed + // TODO: do not remove this, as it is used in rowEditTemplate, but mark is as internal and hidden + /* blazorCSSuppress */ + public endEdit(commit = true, event?: Event): boolean { + if (!this.crudService.cellInEditMode && !this.crudService.rowInEditMode) { + return; + } + const document = this.nativeElement?.getRootNode() as Document | ShadowRoot; + const focusWithin = this.nativeElement?.contains(document.activeElement); + + const success = this.crudService.endEdit(commit, event); + + if (focusWithin) { + // restore focus for navigation + this.navigation.restoreActiveNodeFocus(); + } else if (this.navigation.activeNode) { + // grid already lost focus, clear active node + this.clearActiveNode(); + } + + return success; + } + + /** + * Enters add mode by spawning the UI under the specified row by rowID. + * + * @remarks + * If null is passed as rowID, the row adding UI is spawned as the first record in the data view + * @remarks + * Spawning the UI to add a child for a record only works if you provide a rowID + * @example + * ```typescript + * this.grid.beginAddRowById('ALFKI'); + * this.grid.beginAddRowById('ALFKI', true); + * this.grid.beginAddRowById(null); + * ``` + * @param rowID - The rowID to spawn the add row UI for, or null to spawn it as the first record in the data view + * @param asChild - Whether the record should be added as a child. Only applicable to igxTreeGrid. + */ + public beginAddRowById(rowID: any, asChild?: boolean): void { + let index = rowID; + if (rowID == null) { + if (asChild) { + console.warn('The record cannot be added as a child to an unspecified record.'); + return; + } + index = null; + } else { + // find the index of the record with that PK + index = this.gridAPI.get_rec_index_by_id(rowID, this.dataView); + if (index === -1) { + console.warn('No row with the specified ID was found.'); + return; + } + } + + this._addRowForIndex(index, asChild); + } + + protected _addRowForIndex(index: number, asChild?: boolean) { + if (!this.dataView.length) { + this.beginAddRowForIndex(index, asChild); + return; + } + // check if the index is valid - won't support anything outside the data view + if (index >= 0 && index < this.dataView.length) { + // check if the index is in the view port + if ((index < this.virtualizationState.startIndex || + index >= this.virtualizationState.startIndex + this.virtualizationState.chunkSize) && + !this.isRecordPinnedByViewIndex(index)) { + this.verticalScrollContainer.chunkLoad + .pipe(first(), takeUntil(this.destroy$)) + .subscribe(() => { + this.beginAddRowForIndex(index, asChild); + }); + this.navigateTo(index); + this.notifyChanges(true); + return; + } + this.beginAddRowForIndex(index, asChild); + } else { + console.warn('The row with the specified PK or index is outside of the current data view.'); + } + } + + /* csSuppress */ + /** + * Enters add mode by spawning the UI at the specified index. + * + * @remarks + * Accepted values for index are integers from 0 to this.grid.dataView.length + * @example + * ```typescript + * this.grid.beginAddRowByIndex(0); + * ``` + * @param index - The index to spawn the UI at. Accepts integers from 0 to this.grid.dataView.length + */ + public beginAddRowByIndex(index: number): void { + if (index === 0) { + return this.beginAddRowById(null); + } + return this._addRowForIndex(index - 1); + } + + /** + * @hidden + */ + public preventHeaderScroll(args) { + if (args.target.scrollLeft !== 0) { + (this.navigation as any).forOfDir().getScroll().scrollLeft = args.target.scrollLeft; + args.target.scrollLeft = 0; + } + } + + protected beginAddRowForIndex(index: number, asChild = false) { + // TODO is row from rowList suitable for enterAddRowMode + const row = index == null ? + null : this.rowList.find(r => r.index === index); + if (row !== undefined) { + this.crudService.enterAddRowMode(row, asChild); + } else { + console.warn('No row with the specified PK or index was found.'); + } + } + + protected switchTransactionService(val: boolean) { + if (val) { + this._transactions = this.transactionFactory.create(TRANSACTION_TYPE.Base); + } else { + this._transactions = this.transactionFactory.create(TRANSACTION_TYPE.None); + } + + if (this.dataCloneStrategy) { + this._transactions.cloneStrategy = this.dataCloneStrategy; + } + } + + protected subscribeToTransactions(): void { + this.transactionChange$.next(); + this.transactions.onStateUpdate.pipe(takeUntil(merge(this.destroy$, this.transactionChange$))) + .subscribe(this.transactionStatusUpdate.bind(this)); + } + + protected transactionStatusUpdate(event: StateUpdateEvent) { + let actions: Action[] = []; + if (event.origin === TransactionEventOrigin.REDO) { + actions = event.actions ? event.actions.filter(x => x.transaction.type === TransactionType.DELETE) : []; + } else if (event.origin === TransactionEventOrigin.UNDO) { + actions = event.actions ? event.actions.filter(x => x.transaction.type === TransactionType.ADD) : []; + } + if (actions.length > 0) { + for (const action of actions) { + if (this.selectionService.isRowSelected(action.transaction.id)) { + this.selectionService.deselectRow(action.transaction.id); + } + } + } + if (event.origin === TransactionEventOrigin.REDO || event.origin === TransactionEventOrigin.UNDO) { + event.actions.forEach(x => { + if (x.transaction.type === TransactionType.UPDATE) { + const value = this.transactions.getAggregatedValue(x.transaction.id, true); + this.validation.update(x.transaction.id, value ?? x.recordRef); + } else if (x.transaction.type === TransactionType.DELETE || x.transaction.type === TransactionType.ADD) { + const value = this.transactions.getAggregatedValue(x.transaction.id, true); + if (value) { + this.validation.create(x.transaction.id, value ?? x.recordRef); + this.validation.update(x.transaction.id, value ?? x.recordRef); + this.validation.markAsTouched(x.transaction.id); + } else { + this.validation.clear(x.transaction.id); + } + } + + }); + } + + this.selectionService.clearHeaderCBState(); + this.summaryService.clearSummaryCache(); + this.pipeTrigger++; + this.notifyChanges(); + } + + protected writeToData(rowIndex: number, value: any) { + mergeObjects(this.gridAPI.get_all_data()[rowIndex], value); + } + + protected _restoreVirtState(row) { + // check virtualization state of data record added from cache + // in case state is no longer valid - update it. + const rowForOf = row.virtDirRow; + const gridScrLeft = rowForOf.getScroll().scrollLeft; + rowForOf.onHScroll(gridScrLeft); + rowForOf.cdr.detectChanges(); + } + + protected changeRowEditingOverlayStateOnScroll(row: RowType) { + if (!this.rowEditable || !this.rowEditingOverlay || this.rowEditingOverlay.collapsed) { + return; + } + if (!row) { + this.toggleRowEditingOverlay(false); + } else { + this.repositionRowEditingOverlay(row); + } + } + + /** + * Should be called when data and/or isLoading input changes so that the overlay can be + * hidden/shown based on the current value of shouldOverlayLoading + */ + protected evaluateLoadingState() { + if (this.shouldOverlayLoading) { + // a new overlay should be shown + const overlaySettings: OverlaySettings = { + outlet: this.loadingOutlet, + closeOnOutsideClick: false, + positionStrategy: new ContainerPositionStrategy() + }; + this.loadingOverlay.open(overlaySettings); + } else { + this.loadingOverlay.close(); + } + } + + /** + * @hidden + * Sets grid width i.e. this.calcWidth + */ + protected calculateGridWidth() { + let width; + + if (this.isPercentWidth) { + /* width in %*/ + const computed = this.document.defaultView.getComputedStyle(this.nativeElement).getPropertyValue('width'); + width = computed.indexOf('%') === -1 ? parseFloat(computed) : null; + } else { + width = parseInt(this.width, 10); + } + + if (!width && this.nativeElement) { + width = this.nativeElement.offsetWidth; + } + + + if (this.width === null || !width) { + this.isColumnWidthSum = true; + width = this.getColumnWidthSum(); + } else { + this.isColumnWidthSum = false; + } + + if (this.hasVerticalScroll() && this.width !== null) { + width -= this.scrollSize; + } + if ((Number.isFinite(width) || width === null) && width !== this.calcWidth) { + this.calcWidth = width; + } + this._derivePossibleWidth(); + } + + /** + * @hidden + * Sets columns defaultWidth property + */ + protected _derivePossibleWidth() { + if (!this.columnWidthSetByUser) { + this._columnWidth = this.width !== null ? this.getPossibleColumnWidth() : this.minColumnWidth + 'px'; + } + this._columns.forEach((column: IgxColumnComponent) => { + if (this.hasColumnLayouts && parseFloat(this._columnWidth)) { + const columnWidthCombined = parseFloat(this._columnWidth) * (column.colEnd ? column.colEnd - column.colStart : 1); + column.defaultWidth = columnWidthCombined + 'px'; + } else { + // D.K. March 29th, 2021 #9145 Consider min/max width when setting defaultWidth property + column.defaultWidth = this.getExtremumBasedColWidth(column); + column.resetCaches(); + } + }); + this.resetCachedWidths(); + } + + /** + * @hidden + * @internal + */ + protected getExtremumBasedColWidth(column: IgxColumnComponent): string { + let width = this._columnWidth; + if (width && typeof width !== 'string') { + width = String(width); + } + const minWidth = width.indexOf('%') === -1 ? column.userSetMinWidthPx : column.minWidthPercent; + const maxWidth = width.indexOf('%') === -1 ? column.maxWidthPx : column.maxWidthPercent; + if (column.hidden) { + return width; + } + + if (minWidth > parseFloat(width)) { + width = String(column.minWidth); + } else if (maxWidth < parseFloat(width)) { + width = String(column.maxWidth); + } + + // if no px or % are defined in maxWidth/minWidth consider it px + if (width.indexOf('%') === -1 && width.indexOf('px') === -1) { + width += 'px'; + } + return width; + } + + protected resetNotifyChanges() { + this._cdrRequestRepaint = false; + this._cdrRequests = false; + } + + /** @hidden @internal */ + public resolveOutlet() { + return this._userOutletDirective ? this._userOutletDirective : this._outletDirective; + } + + /** + * Reorder columns in the main columnList and _columns collections. + * + * @hidden + */ + protected _moveColumns(from: IgxColumnComponent, to: IgxColumnComponent, pos: DropPosition) { + const orderedList = this._pinnedStartColumns.concat(this._unpinnedColumns, this._pinnedEndColumns); + const list = orderedList; + this._reorderColumns(from, to, pos, list); + const newList = this._resetColumnList(list); + this.updateColumns(newList); + } + + + /** + * Update internal column's collection. + * @hidden + */ + public updateColumns(newColumns: IgxColumnComponent[]) { + // 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) { + this._filteringExpressionsTree = this.getRecreatedTree(this._filteringExpressionsTree); + } + if (this._columns && this._columns.length && this._advancedFilteringExpressionsTree) { + this._advancedFilteringExpressionsTree = this.getRecreatedTree(this._advancedFilteringExpressionsTree); + } + this.resetCaches(); + } + + /** + * @hidden + */ + protected _resetColumnList(list?) { + if (!list) { + list = this._columns; + } + let newList = []; + list.filter(c => c.level === 0).forEach(p => { + newList.push(p); + if (p.columnGroup) { + newList = newList.concat(p.allChildren); + } + }); + return newList; + } + + /** + * Reorders columns inside the passed column collection. + * When reordering column group collection, the collection is not flattened. + * In all other cases, the columns collection is flattened, this is why adittional calculations on the dropIndex are done. + * + * @hidden + */ + protected _reorderColumns(from: IgxColumnComponent, to: IgxColumnComponent, position: DropPosition, columnCollection: any[], + inGroup = false) { + const fromIndex = columnCollection.indexOf(from); + const childColumnsCount = inGroup ? 1 : from.allChildren.length + 1; + columnCollection.splice(fromIndex, childColumnsCount); + let dropIndex = columnCollection.indexOf(to); + if (position === DropPosition.AfterDropTarget) { + dropIndex++; + if (!inGroup && to.columnGroup) { + dropIndex += to.allChildren.length; + } + } + columnCollection.splice(dropIndex, 0, from); + } + + /** + * Reorder column group collection. + * + * @hidden + */ + protected _moveChildColumns(parent: IgxColumnComponent, from: IgxColumnComponent, to: IgxColumnComponent, pos: DropPosition) { + const buffer = parent.children.toArray(); + this._reorderColumns(from, to, pos, buffer, true); + parent.children.reset(buffer); + } + + /** + * @hidden @internal + */ + protected setupColumns() { + if (this.autoGenerate) { + this.autogenerateColumns(); + } else { + this._columns = this.getColumnList(); + } + if (this._columns && this._columns.length && this._filteringExpressionsTree) { + this._filteringExpressionsTree = this.getRecreatedTree(this._filteringExpressionsTree); + } + if (this._columns && this._columns.length && this._advancedFilteringExpressionsTree) { + this._advancedFilteringExpressionsTree = this.getRecreatedTree(this._advancedFilteringExpressionsTree); + } + + this.initColumns(this._columns, (col: IgxColumnComponent) => this.columnInit.emit(col)); + this.columnListDiffer.diff(this.columnList); + this._calculateRowCount(); + + this.columnList.changes + .pipe(takeUntil(this.destroy$)) + .subscribe((change: QueryList) => { + this.onColumnsChanged(change); + }); + } + + protected getColumnList() { + return this.columnList.toArray().filter((col) => col.grid === this); + } + + /** + * @hidden + */ + protected deleteRowFromData(rowID: any, index: number) { + // if there is a row (index !== 0) delete it + // if there is a row in ADD or UPDATE state change it's state to DELETE + if (index !== -1) { + if (this.transactions.enabled) { + const transaction: Transaction = { id: rowID, type: TransactionType.DELETE, newValue: null }; + this.transactions.add(transaction, this.data[index]); + } else { + this.data.splice(index, 1); + } + } else { + const state: State = this.transactions.getState(rowID); + this.transactions.add({ id: rowID, type: TransactionType.DELETE, newValue: null }, state && state.recordRef); + } + } + + + /** + * @hidden @internal + */ + protected getDataBasedBodyHeight(): number { + return !this.data || (this.data.length < this._defaultTargetRecordNumber) ? + 0 : this.defaultTargetBodyHeight; + } + + /** + * @hidden @internal + */ + protected onPinnedRowsChanged(change: QueryList) { + const diff = this.rowListDiffer.diff(change); + if (diff) { + this.notifyChanges(true); + } + } + + /** + * @hidden + */ + protected onColumnsChanged(change: QueryList) { + const diff = this.columnListDiffer.diff(change); + + if (this.autoGenerate && this._columns.length === 0 && this._autoGeneratedCols.length > 0) { + // In Ivy if there are nested conditional templates the content children are re-evaluated + // hence autogenerated columns are cleared and need to be reset. + this.updateColumns(this._autoGeneratedCols as IgxColumnComponent[]); + return; + } + if (diff) { + let added = false; + let removed = false; + let pinning = false; + diff.forEachAddedItem((record: IterableChangeRecord) => { + if (record.item.grid !== this) { + return; + } + 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); + } + }); + + this.initColumns(this.getColumnList(), (col: IgxColumnComponent) => this.columnInit.emit(col)); + if (pinning) { + this.initPinning(); + } + + diff.forEachRemovedItem((record: IterableChangeRecord) => { + if (record.item.grid !== this) { + return; + } + const isColumnGroup = record.item instanceof IgxColumnGroupComponent; + if (!isColumnGroup) { + // Clear Grouping + this.gridAPI.clear_groupby(record.item.field); + + // Clear Filtering + this.filteringService.clear_filter(record.item.field); + + // Close filter row + if (this.filteringService.isFilterRowVisible + && this.filteringService.filteredColumn + && this.filteringService.filteredColumn.field === record.item.field) { + this.filteringRow.close(); + } + + // Clear Sorting + this.gridAPI.clear_sort(record.item.field); + + // Remove column selection + this.selectionService.deselectColumnsWithNoEvent([record.item.field]); + } + removed = true; + }); + + this.resetCaches(); + + if (added || removed) { + this.onColumnsAddedOrRemoved(); + } + } + } + + protected checkPrimaryKeyField() { + if (this.primaryKey && this.data?.length && !(this.primaryKey in this.data[0])) { + console.warn(`Field "${this.primaryKey}" is not defined in the data. Set \`primaryKey\` to a valid field.`); + } + } + + /** + * @hidden @internal + */ + protected onColumnsAddedOrRemoved() { + this.summaryService.clearSummaryCache(); + Promise.resolve().then(() => { + // `onColumnsChanged` can be executed midway a current detectChange cycle and markForCheck will be ignored then. + // This ensures that we will wait for the current cycle to end so we can trigger a new one and ngDoCheck to fire. + this.notifyChanges(true); + }); + } + + /** + * @hidden + */ + protected calculateGridSizes(recalcFeatureWidth = true) { + /* + TODO: (R.K.) This layered lasagne should be refactored + ASAP. The reason I have to reset the caches so many times is because + after teach `detectChanges` call they are filled with invalid + state. Of course all of this happens midway through the grid + sizing process which of course, uses values from the caches, thus resulting + in a broken layout. + */ + this.cdr.detectChanges(); + this.resetCaches(recalcFeatureWidth); + const hasScroll = this.hasVerticalScroll(); + const hasHScroll = !this.isHorizontalScrollHidden; + this.calculateGridWidth(); + this.resetCaches(recalcFeatureWidth); + this.cdr.detectChanges(); + this.calculateGridHeight(); + + if (this.rowEditable) { + this.repositionRowEditingOverlay(this.crudService.rowInEditMode); + } + + if (this.filteringService.isFilterRowVisible) { + this.filteringRow.resetChipsArea(); + } + + this.cdr.detectChanges(); + // in case scrollbar has appeared recalc to size correctly. + if (hasScroll !== this.hasVerticalScroll()) { + this.calculateGridWidth(); + this.cdr.detectChanges(); + } + + // in case horizontal scrollbar has appeared recalc to size correctly. + if (hasHScroll !== this.hasHorizontalScroll()) { + this.isHorizontalScrollHidden = !this.hasHorizontalScroll(); + this.cdr.detectChanges(); + this.calculateGridHeight(); + this.cdr.detectChanges(); + } + if (this.zone.isStable) { + this.zone.run(() => { + this._applyWidthHostBinding(); + this.cdr.detectChanges(); + }); + } else { + this.zone.onStable.pipe(first()).subscribe(() => { + this.zone.run(() => { + this._applyWidthHostBinding(); + }); + }); + } + this.resetCaches(recalcFeatureWidth); + if (this.hasColumnsToAutosize) { + this.cdr.detectChanges(); + this.zone.onStable.pipe(first()).subscribe(() => { + this._autoSizeColumnsNotify.next(); + }); + } + } + + /** + * @hidden + * Sets TBODY height i.e. this.calcHeight + */ + protected calculateGridHeight() { + + this.calcHeight = this._calculateGridBodyHeight(); + if (this.pinnedRowHeight && this.calcHeight) { + this.calcHeight -= this.pinnedRowHeight; + } + } + + /** + * @hidden + */ + protected getGroupAreaHeight(): number { + return 0; + } + + /** + * @hidden + */ + protected getComputedHeight(elem) { + return elem.offsetHeight ? parseFloat(this.document.defaultView.getComputedStyle(elem).getPropertyValue('height')) : 0; + } + /** + * @hidden + */ + protected getFooterHeight(): number { + return this.summaryRowHeight || this.getComputedHeight(this.tfoot.nativeElement); + } + /** + * @hidden + */ + protected getTheadRowHeight(): number { + // D.P.: Before CSS loads,theadRow computed height will be 'auto'->NaN, so use 0 fallback + const height = this.getComputedHeight(this.theadRow.nativeElement) || 0; + return (!this.allowFiltering || (this.allowFiltering && this.filterMode !== FilterMode.quickFilter)) ? + height - this.getFilterCellHeight() : + height; + } + + /** + * @hidden + */ + protected getToolbarHeight(): number { + let toolbarHeight = 0; + if (this.toolbar.first) { + toolbarHeight = this.getComputedHeight(this.toolbar.first.nativeElement); + } + return toolbarHeight; + } + + /** + * @hidden + */ + protected getPagingFooterHeight(): number { + let pagingHeight = 0; + if (this.footer) { + const height = this.getComputedHeight(this.footer.nativeElement); + pagingHeight = this.footer.nativeElement.firstElementChild ? + height : 0; + } + return pagingHeight; + } + + /** + * @hidden + */ + protected getFilterCellHeight(): number { + const headerGroupNativeEl = (this.headerGroupsList.length !== 0) ? + this.headerGroupsList[0].nativeElement : null; + const filterCellNativeEl = (headerGroupNativeEl) ? + headerGroupNativeEl.querySelector('igx-grid-filtering-cell') as HTMLElement : null; + return (filterCellNativeEl) ? filterCellNativeEl.offsetHeight : 0; + } + + /** + * @hidden + */ + protected _calculateGridBodyHeight(): number { + if (!this._height) { + return null; + } + const actualTheadRow = this.getTheadRowHeight(); + const footerHeight = this.getFooterHeight(); + const toolbarHeight = this.getToolbarHeight(); + const pagingHeight = this.getPagingFooterHeight(); + const groupAreaHeight = this.getGroupAreaHeight(); + const scrHeight = this.getComputedHeight(this.scr.nativeElement); + const renderedHeight = toolbarHeight + actualTheadRow + + footerHeight + pagingHeight + groupAreaHeight + + scrHeight; + + let gridHeight = 0; + + if (this.isPercentHeight) { + const computed = this.document.defaultView.getComputedStyle(this.nativeElement).getPropertyValue('height'); + const autoSize = this._shouldAutoSize(renderedHeight); + if (autoSize || computed.indexOf('%') !== -1) { + const bodyHeight = this.getDataBasedBodyHeight(); + return bodyHeight > 0 ? bodyHeight : null; + } + gridHeight = parseFloat(computed); + } else { + gridHeight = parseInt(this._height, 10); + } + const height = Math.abs(gridHeight - renderedHeight); + + if (Math.round(height) === 0 || isNaN(gridHeight)) { + const bodyHeight = this.defaultTargetBodyHeight; + return bodyHeight > 0 ? bodyHeight : null; + } + return height; + } + + protected checkContainerSizeChange() { + const parentElement = this.nativeElement.parentElement || (this.nativeElement.getRootNode() as any).host; + const origHeight = parentElement.offsetHeight; + this.nativeElement.style.display = 'none'; + const height = parentElement.offsetHeight; + this.nativeElement.style.display = ''; + return origHeight !== height; + } + + protected _shouldAutoSize(renderedHeight) { + this.tbody.nativeElement.style.display = 'none'; + const parentElement = this.nativeElement.parentElement || (this.nativeElement.getRootNode() as any).host; + let res = !parentElement || + parentElement.clientHeight === 0 || + parentElement.clientHeight === renderedHeight; + if (parentElement && (res || this._autoSize)) { + // If grid causes the parent container to extend (for example when container is flex) + // we should always auto-size since the actual size of the container will continuously change as the grid renders elements. + this._autoSize = false; + res = this.checkContainerSizeChange(); + } + this.tbody.nativeElement.style.display = ''; + return res; + } + + /** + * @hidden + * Gets calculated width of the unpinned area + * @param takeHidden If we should take into account the hidden columns in the pinned area. + */ + protected getUnpinnedWidth(takeHidden = false) { + let width = this.isPercentWidth ? + this.calcWidth : + parseInt(this.width, 10) || parseInt(this.hostWidth, 10) || this.calcWidth; + if (this.hasVerticalScroll() && !this.isPercentWidth) { + width -= this.scrollSize; + } + + return width - (this.getPinnedStartWidth(takeHidden) + this.getPinnedEndWidth(takeHidden)); + } + + /** + * @hidden + */ + protected _summaries(fieldName: string, hasSummary: boolean, summaryOperand?: any) { + const column = this.gridAPI.get_column_by_name(fieldName); + if (column) { + column.hasSummary = hasSummary; + if (summaryOperand) { + if (this.rootSummariesEnabled) { + this.summaryService.retriggerRootPipe++; + } + column.summaries = summaryOperand; + } + } + } + + /** + * @hidden + */ + protected _multipleSummaries(expressions: ISummaryExpression[], hasSummary: boolean) { + expressions.forEach((element) => { + this._summaries(element.fieldName, hasSummary, element.customSummary); + }); + } + /** + * @hidden + */ + protected _disableMultipleSummaries(expressions) { + expressions.forEach((column) => { + const columnName = column && column.fieldName ? column.fieldName : column; + this._summaries(columnName, false); + }); + } + + /** + * @hidden + */ + public resolveDataTypes(rec) { + if (typeof rec === 'number') { + return GridColumnDataType.Number; + } else if (typeof rec === 'boolean') { + return GridColumnDataType.Boolean; + } else if (typeof rec === 'object' && rec instanceof Date) { + return GridColumnDataType.Date; + } else if (typeof rec === 'string' && (/\.(gif|jpe?g|tiff?|png|webp|bmp)$/i).test(rec)) { + return GridColumnDataType.Image; + } + return GridColumnDataType.String; + } + + /** + * @hidden + */ + protected autogenerateColumns() { + const data = this.gridAPI.get_data(); + const fields = this.generateDataFields(data); + const columns = []; + + this._autoGeneratedColsRefs.forEach(ref => ref.destroy()); + this._autoGeneratedColsRefs = []; + fields.forEach((field) => { + const ref = createComponent(IgxColumnComponent, { environmentInjector: this.envInjector, elementInjector: this.injector }); + ref.instance.field = field; + ref.instance.dataType = this.resolveDataTypes(data[0][field]); + ref.changeDetectorRef.detectChanges(); + this._autoGeneratedColsRefs.push(ref); + columns.push(ref.instance); + }); + this._autoGeneratedCols = columns; + + this.updateColumns(columns); + this.columnsAutogenerated.emit({ columns: this._autoGeneratedCols }); + } + + protected generateDataFields(data: any[]): string[] { + return Object.keys(data && data.length !== 0 ? data[0] : []) + .filter(key => !this.autoGenerateExclude.includes(key)); + } + + /** + * @hidden + */ + protected initColumns(collection: IgxColumnComponent[], cb: (args: any) => void = null) { + this._columnGroups = collection.some(col => col.columnGroup); + if (this.hasColumnLayouts) { + // Set overall row layout size + collection.forEach((col) => { + if (col.columnLayout) { + const layoutSize = col.children ? + col.children.reduce((acc, val) => Math.max(val.rowStart + val.gridRowSpan - 1, acc), 1) : + 1; + this._multiRowLayoutRowSize = Math.max(layoutSize, this._multiRowLayoutRowSize); + } + }); + } + if (this.hasColumnLayouts && this.hasColumnGroups) { + // invalid configuration - multi-row and column groups + // remove column groups + const columnLayoutColumns = collection.filter((col) => col.columnLayout || col.columnLayoutChild); + collection = columnLayoutColumns; + } + + collection.forEach((column: IgxColumnComponent) => { + column.defaultWidth = this.columnWidthSetByUser ? this._columnWidth : column.defaultWidth ? column.defaultWidth : ''; + + if (cb) { + cb(column); + } + }); + + this.updateColumns(collection); + + if (this.hasColumnLayouts) { + collection.forEach((column: IgxColumnComponent) => { + column.populateVisibleIndexes(); + }); + } + } + + /** + * @hidden + */ + 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)); + } + + protected extractDataFromSelection(source: any[], formatters = false, headers = false, columnData?: any[]): any[] { + let columnsArray: IgxColumnComponent[]; + let record = {}; + let selectedData = []; + let keys = []; + const selectionCollection = new Map(); + const keysAndData = []; + const activeEl = this.selectionService.activeElement; + + if (this.type === 'hierarchical') { + const expansionRowIndexes = []; + for (const [key, value] of this.expansionStates.entries()) { + if (value) { + const rowIndex = this.gridAPI.get_rec_index_by_id(key, this.dataView); + expansionRowIndexes.push(rowIndex); + } + } + if (this.selectionService.selection.size > 0) { + if (expansionRowIndexes.length > 0) { + for (const [key, value] of this.selectionService.selection.entries()) { + const updatedKey = key; + let subtract = 0; + expansionRowIndexes.forEach((row) => { + if (updatedKey > Number(row)) { + subtract++; + } + }); + selectionCollection.set(updatedKey - subtract, value); + } + } + } else if (activeEl) { + let subtract = 0; + if (expansionRowIndexes.length > 0) { + expansionRowIndexes.forEach(row => { + if (activeEl.row > Number(row)) { + subtract++; + } + }); + activeEl.row -= subtract; + } + } + } + + const totalItems = (this as any).totalItemCount ?? 0; + const isRemote = totalItems && totalItems > this.dataView.length; + let selectionMap; + if (this.type === 'hierarchical' && selectionCollection.size > 0) { + selectionMap = isRemote ? Array.from(selectionCollection) : + Array.from(selectionCollection).filter((tuple) => tuple[0] < source.length); + } else { + selectionMap = isRemote ? Array.from(this.selectionService.selection) : + Array.from(this.selectionService.selection).filter((tuple) => tuple[0] < source.length); + } + + if (this.cellSelection === GridSelectionMode.single && activeEl) { + selectionMap.push([activeEl.row, new Set().add(activeEl.column)]); + } + + if (this.cellSelection === GridSelectionMode.none && activeEl) { + selectionMap.push([activeEl.row, new Set().add(activeEl.column)]); + } + + if (columnData) { + selectedData = columnData; + } + + // eslint-disable-next-line prefer-const + for (let [row, set] of selectionMap) { + row = this.paginator && (this.pagingMode === 'local' && source === this.filteredSortedData) ? row + (this.perPage * this.page) : row; + row = isRemote ? row - this.virtualizationState.startIndex : row; + if (!source[row] || source[row].detailsData !== undefined) { + continue; + } + const temp = Array.from(set); + for (const each of temp) { + columnsArray = this.getSelectableColumnsAt(each); + columnsArray.forEach((col) => { + if (col) { + const key = this.type !== 'pivot' && headers ? col.header || col.field : col.field; + const rowData = source[row].ghostRecord ? source[row].recordRef : source[row]; + const value = this.type === 'pivot' ? rowData.aggregationValues.get(col.field) + : resolveNestedPath(rowData, columnFieldPath(col.field)); + record[key] = formatters && col.formatter ? col.formatter(value, rowData) : value; + if (columnData) { + if (!record[key]) { + record[key] = ''; + } + record[key] = record[key].toString().concat('recordRow-' + row); + } + } + }); + } + if (Object.keys(record).length) { + if (columnData) { + if (!keys.length) { + keys = Object.keys(columnData[0]); + } + for (const [key, value] of Object.entries(record)) { + if (!keys.includes(key)) { + keys.push(key); + } + let c: any = value; + const rowNumber = +c.split('recordRow-')[1]; + c = c.split('recordRow-')[0]; + record[key] = c; + const mergedObj = Object.assign(selectedData[rowNumber], record); + selectedData[rowNumber] = mergedObj; + } + } else { + selectedData.push(record); + } + } + record = {}; + } + + if (keys.length) { + keysAndData.push(selectedData); + keysAndData.push(keys); + return keysAndData; + } else { + return selectedData; + } + } + + protected getSelectableColumnsAt(index) { + if (this.hasColumnLayouts) { + const visibleLayoutColumns = this.visibleColumns + .filter(col => col.columnLayout) + .sort((a, b) => a.visibleIndex - b.visibleIndex); + const colLayout = visibleLayoutColumns[index]; + return colLayout ? colLayout.children.toArray() : []; + } else { + const visibleColumns = this.visibleColumns + .filter(col => !col.columnGroup) + .sort((a, b) => a.visibleIndex - b.visibleIndex); + return [visibleColumns[index]]; + } + } + + protected autoSizeColumnsInView() { + if (!this.hasColumnsToAutosize) return; + const vState = this.headerContainer.state; + let colResized = false; + const unpinnedInView = this.headerContainer.igxGridForOf.slice(vState.startIndex, vState.startIndex + vState.chunkSize).flatMap(x => x.columnGroup ? x.allChildren : x); + const columnsInView = this.pinnedColumns.concat(unpinnedInView as IgxColumnComponent[]); + for (const col of columnsInView) { + if (!col.autoSize && col.headerCell) { + const cellsContentWidths = []; + if (col._cells.length !== this.rowList.length) { + this.rowList.forEach(x => x.cdr.detectChanges()); + } + const cells = this._dataRowList.map(x => x.cells.find(c => c.column === col)); + cells.forEach((cell) => cellsContentWidths.push(cell?.nativeElement?.offsetWidth || 0)); + let maxForCells = Math.max(...cellsContentWidths); + const header = this.headerCellList.find(x => x.column === col); + cellsContentWidths.push(header.nativeElement.offsetWidth); + const max = Math.max(...cellsContentWidths); + // in cases with template contains something, like a webcomponent, + // that renders fully only after it is already injected in the DOM, + // and initially renders as empty, skip measuring it. + let emptyCellWithPaddingOnly = 0; + if (cells.length > 0 && !!col.bodyTemplate) { + const cellStyle = this.document.defaultView.getComputedStyle(cells[0].nativeElement); + emptyCellWithPaddingOnly = parseFloat(cellStyle.paddingLeft) + parseFloat(cellStyle.paddingRight); + } else { + maxForCells = max; + } + + if (max === 0 || (maxForCells <= emptyCellWithPaddingOnly && this._firstAutoResize)) { + // cells not in DOM yet or content not fully initialized. + continue; + } + let maxSize = Math.ceil(Math.max(...cellsContentWidths)) + 1; + if (col.maxWidth && maxSize > col.maxWidthPx) { + maxSize = col.maxWidthPx; + } else if (maxSize < col.userSetMinWidthPx) { + maxSize = col.userSetMinWidthPx; + } + col.autoSize = maxSize; + col.resetCaches(); + colResized = true; + } + } + if (colResized) { + this.resetCachedWidths(); + this.cdr.detectChanges(); + } + + if (this.isColumnWidthSum) { + this.calcWidth = this.getColumnWidthSum(); + } + } + + protected extractDataFromColumnsSelection(source: any[], formatters = false, headers = false): any[] { + let record = {}; + const selectedData = []; + const selectedColumns = this.selectedColumns(); + if (selectedColumns.length === 0) { + return []; + } + + for (const data of source) { + selectedColumns.forEach((col) => { + const key = headers ? col.header || col.field : col.field; + record[key] = formatters && col.formatter ? col.formatter(data[col.field], data) + : data[col.field]; + }); + + if (Object.keys(record).length) { + selectedData.push(record); + } + record = {}; + } + return selectedData; + } + + /** + * @hidden + */ + protected initPinning() { + this.calculateGridWidth(); + this.resetCaches(); + this.handleColumnPinningForGroups(); + this.notifyChanges(); + } + + /** + * @hidden + */ + protected scrollTo(row: any | number, column: any | number, inCollection = this._filteredSortedUnpinnedData): void { + let delayScrolling = false; + + if (this.paginator && typeof (row) !== 'number') { + const rowIndex = inCollection.indexOf(row); + const page = Math.floor(rowIndex / this.perPage); + + if (this.page !== page) { + delayScrolling = true; + 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, + targetRowIndex); + }); + } else { + this.scrollDirective(this.verticalScrollContainer, + targetRowIndex); + } + + this.scrollToHorizontally(column); + } + + /** + * @hidden + */ + protected scrollToHorizontally(column: any | number) { + 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.pinnedStartColumns.length) { + if (columnIndex >= this.pinnedStartColumns.length) { + columnIndex -= this.pinnedStartColumns.length; + this.scrollDirective(virtDir, columnIndex); + } + } else { + this.scrollDirective(virtDir, columnIndex); + } + } + + /** + * @hidden + */ + protected scrollDirective(directive: IgxGridForOfDirective, goal: number): void { + if (!directive) { + return; + } + directive.scrollTo(goal); + } + + + /** + * @hidden + */ + protected getColumnWidthSum(): number { + let colSum = 0; + const cols = this.hasColumnLayouts ? + this.visibleColumns.filter(x => x.columnLayout) : this.visibleColumns.filter(x => !x.columnGroup); + cols.forEach((item) => { + colSum += parseInt((item.calcWidth || item.defaultWidth), 10) || this.minColumnWidth; + }); + if (!colSum) { + return null; + } + this.cdr.detectChanges(); + colSum += this.featureColumnsWidth(); + return colSum; + } + + /** + * Notify changes, reset cache and populateVisibleIndexes. + * + * @hidden + */ + private _columnsReordered(column: IgxColumnComponent) { + this.notifyChanges(); + // after reordering is done reset cached column collections. + this.resetColumnCollections(); + column.resetCaches(); + } + + protected buildDataView(_data: any[]) { + this._dataView = this.isRowPinningToTop ? + [...this.pinnedDataView, ...this.unpinnedDataView] : + [...this.unpinnedDataView, ...this.pinnedDataView]; + } + + private _applyWidthHostBinding() { + let width = this._width; + if (width === null) { + let currentWidth = this.calcWidth; + if (this.hasVerticalScroll()) { + currentWidth += this.scrollSize; + } + width = currentWidth + 'px'; + this.resetCaches(); + } + this._hostWidth = width; + this.cdr.markForCheck(); + } + + protected verticalScrollHandler(event) { + this.verticalScrollContainer.onScroll(event); + this.disableTransitions = true; + + this.zone.run(() => { + this.zone.onStable.pipe(first()).subscribe(() => { + this.verticalScrollContainer.chunkLoad.emit(this.verticalScrollContainer.state); + if (this.rowEditable) { + this.changeRowEditingOverlayStateOnScroll(this.crudService.rowInEditMode); + } + }); + }); + this.disableTransitions = false; + + this.hideOverlays(); + this.actionStrip?.hide(); + if (this.actionStrip) { + this.actionStrip.context = null; + } + const args: IGridScrollEventArgs = { + direction: 'vertical', + event, + scrollPosition: this.verticalScrollContainer.scrollPosition + }; + this.gridScroll.emit(args); + } + + protected horizontalScrollHandler(event) { + const scrollLeft = event.target.scrollLeft; + this.headerContainer.onHScroll(scrollLeft); + this._horizontalForOfs.forEach(vfor => vfor.onHScroll(scrollLeft)); + this.cdr.markForCheck(); + + this.zone.run(() => { + this.zone.onStable.pipe(first()).subscribe(() => { + this.parentVirtDir.chunkLoad.emit(this.headerContainer.state); + requestAnimationFrame(() => { + this.autoSizeColumnsInView(); + }); + }); + }); + if (!this.navigation.isColumnFullyVisible(this.navigation.lastColumnIndex)) { + this.hideOverlays(); + } + const args: IGridScrollEventArgs = { direction: 'horizontal', event, scrollPosition: this.headerContainer.scrollPosition }; + this.gridScroll.emit(args); + } + + protected get renderedActualRowHeight() { + let border = 1; + if (this.rowList.toArray().length > 0) { + const rowStyles = this.document.defaultView.getComputedStyle(this.rowList.first.nativeElement); + border = rowStyles.borderBottomWidth ? Math.ceil(parseFloat(rowStyles.borderBottomWidth)) : border; + } + return this.rowHeight + border; + } + + private executeCallback(rowIndex, visibleColIndex = -1, cb: (args: any) => void = null) { + if (!cb) { + return; + } + let row = this.summariesRowList.filter(s => s.index !== 0).concat(this.rowList.toArray()).find(r => r.index === rowIndex); + if (!row) { + if ((this as any).totalItemCount) { + this.verticalScrollContainer.dataChanged.pipe(first(), takeUntil(this.destroy$)).subscribe(() => { + this.cdr.detectChanges(); + row = this.summariesRowList.filter(s => s.index !== 0).concat(this.rowList.toArray()).find(r => r.index === rowIndex); + const cbArgs = this.getNavigationArguments(row, visibleColIndex); + cb(cbArgs); + }); + } + const dataViewIndex = this._getDataViewIndex(rowIndex); + if (this.dataView[dataViewIndex].detailsData) { + this.navigation.setActiveNode({ row: rowIndex }); + this.cdr.detectChanges(); + } + + return; + } + const args = this.getNavigationArguments(row, visibleColIndex); + cb(args); + } + + private getNavigationArguments(row, visibleColIndex) { + let targetType: GridKeydownTargetType; let target; + switch (row.nativeElement.tagName.toLowerCase()) { + case 'igx-grid-groupby-row': + targetType = 'groupRow'; + target = row; + break; + case 'igx-grid-summary-row': + targetType = 'summaryCell'; + target = visibleColIndex !== -1 ? + row.summaryCells.find(c => c.visibleColumnIndex === visibleColIndex) : row.summaryCells.first; + break; + case 'igx-child-grid-row': + targetType = 'hierarchicalRow'; + target = row; + break; + default: + targetType = 'dataCell'; + target = visibleColIndex !== -1 ? row.cells.find(c => c.visibleColumnIndex === visibleColIndex) : row.cells.first; + break; + } + return { targetType, target }; + } + + private getNextDataRowIndex(currentRowIndex, previous = false): number { + const resolvedIndex = this._getDataViewIndex(currentRowIndex); + if (currentRowIndex < 0 || (currentRowIndex === 0 && previous) || (resolvedIndex >= this.dataView.length - 1 && !previous)) { + return currentRowIndex; + } + // find next/prev record that is editable. + const nextRowIndex = previous ? this.findPrevEditableDataRowIndex(currentRowIndex) : + this.dataView.findIndex((_rec, index) => + index > resolvedIndex && this.isEditableDataRecordAtIndex(index)); + const nextDataIndex = this.getDataIndex(nextRowIndex); + return nextDataIndex !== -1 ? nextDataIndex : currentRowIndex; + } + + /** + * Returns the previous editable row index or -1 if no such row is found. + * + * @param currentIndex The index of the current editable record. + */ + private findPrevEditableDataRowIndex(currentIndex): number { + let i = this.dataView.length; + const resolvedIndex = this._getDataViewIndex(currentIndex); + while (i--) { + if (i < resolvedIndex && this.isEditableDataRecordAtIndex(i)) { + return i; + } + } + return -1; + } + + + /** + * Returns if the record at the specified data view index is a an editable data record. + * If record is group rec, summary rec, child rec, ghost rec. etc. it is not editable. + * + * @param dataViewIndex The index of that record in the data view. + * + */ + // TODO: Consider moving it into CRUD + private isEditableDataRecordAtIndex(dataViewIndex) { + const rec = this.dataView[dataViewIndex]; + return !rec.expression && !rec.summaries && !rec.childGridsData && !rec.detailsData && + !this.isGhostRecordAtIndex(dataViewIndex); + } + + /** + * Returns if the record at the specified data view index is a ghost. + * If record is pinned but is not in pinned area then it is a ghost record. + * + * @param dataViewIndex The index of that record in the data view. + */ + private isGhostRecordAtIndex(dataViewIndex) { + const isPinned = this.isRecordPinned(this.dataView[dataViewIndex]); + const isInPinnedArea = this.isRecordPinnedByViewIndex(dataViewIndex); + return isPinned && !isInPinnedArea; + } + + private isValidPosition(rowIndex, colIndex): boolean { + const rows = this.summariesRowList.filter(s => s.index !== 0).concat(this.rowList.toArray()).length; + const cols = this._columns.filter(col => !col.columnGroup && col.visibleIndex >= 0 && !col.hidden).length; + if (rows < 1 || cols < 1) { + return false; + } + if (rowIndex > -1 && rowIndex < this.dataView.length && + colIndex > - 1 && colIndex <= Math.max(...this.visibleColumns.map(c => c.visibleIndex))) { + return true; + } + return false; + } + + private find(text: string, increment: number, caseSensitive?: boolean, exactMatch?: boolean, scroll?: boolean, endEdit = true) { + if (!this.rowList) { + return 0; + } + + if (endEdit) { + this.crudService.endEdit(false); + } + + if (!text) { + this.clearSearch(); + return 0; + } + + const caseSensitiveResolved = caseSensitive ? true : false; + const exactMatchResolved = exactMatch ? true : false; + let rebuildCache = false; + + if (this._lastSearchInfo.searchText !== text || + this._lastSearchInfo.caseSensitive !== caseSensitiveResolved || + this._lastSearchInfo.exactMatch !== exactMatchResolved) { + this._lastSearchInfo = { + searchText: text, + activeMatchIndex: 0, + caseSensitive: caseSensitiveResolved, + exactMatch: exactMatchResolved, + matchInfoCache: [], + matchCount: 0, + content: '' + }; + + rebuildCache = true; + } else { + this._lastSearchInfo.activeMatchIndex += increment; + } + + if (rebuildCache) { + this.rowList.forEach((row) => { + if (row.cells) { + row.cells.forEach((c: IgxGridCellComponent) => { + c.highlightText(text, caseSensitiveResolved, exactMatchResolved); + }); + } + }); + + this.rebuildMatchCache(); + } + + if (this._lastSearchInfo.activeMatchIndex >= this._lastSearchInfo.matchCount) { + this._lastSearchInfo.activeMatchIndex = 0; + } else if (this._lastSearchInfo.activeMatchIndex < 0) { + this._lastSearchInfo.activeMatchIndex = this._lastSearchInfo.matchCount - 1; + } + + if (this._lastSearchInfo.matchCount > 0) { + const matchInfo = this._lastSearchInfo.matchInfoCache[this._lastSearchInfo.activeMatchIndex]; + this._lastSearchInfo = { ...this._lastSearchInfo }; + + if (scroll !== false) { + this.scrollTo(matchInfo.row, matchInfo.column); + } + + this.textHighlightService.setActiveHighlight(this.id, { + column: matchInfo.column, + row: matchInfo.row, + index: matchInfo.index, + metadata: matchInfo.metadata, + }); + + } else { + this.textHighlightService.clearActiveHighlight(this.id); + } + + return this._lastSearchInfo.matchCount; + } + + private rebuildMatchCache() { + this._lastSearchInfo.matchInfoCache = []; + + const caseSensitive = this._lastSearchInfo.caseSensitive; + const exactMatch = this._lastSearchInfo.exactMatch; + const searchText = caseSensitive ? this._lastSearchInfo.searchText : this._lastSearchInfo.searchText.toLowerCase(); + let data = this.filteredSortedData; + if (this.hasCellsToMerge) { + 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)); + + 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(currentRowData, columnsPathParts[cid]), currentRowData) : + c.dataType === 'number' ? formatNumber(resolveNestedPath(currentRowData, columnsPathParts[cid]) as number, this.locale, pipeArgs.digitsInfo) : + c.dataType === 'date' + ? 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 && !isMergePlaceHolder) { + const mic: IMatchInfoCache = { + row: currentRowData, + column: c.field, + index: 0, + metadata: new Map([['pinned', this.isRecordPinnedByIndex(rowIndex)]]) + }; + + this._lastSearchInfo.matchInfoCache.push(mic); + } + } else { + let occurrenceIndex = 0; + let searchIndex = searchValue.indexOf(searchText); + + while (searchIndex !== -1 && !isMergePlaceHolder) { + const mic: IMatchInfoCache = { + row: currentRowData, + column: c.field, + index: occurrenceIndex++, + metadata: new Map([['pinned', this.isRecordPinnedByIndex(rowIndex)]]) + }; + + this._lastSearchInfo.matchInfoCache.push(mic); + + searchValue = searchValue.substring(searchIndex + searchText.length); + searchIndex = searchValue.indexOf(searchText); + } + } + } + }); + }); + + this._lastSearchInfo.matchCount = this._lastSearchInfo.matchInfoCache.length; + } + + protected updateDefaultRowHeight() { + if (this.dataRowList.length > 0 && this.dataRowList.first.cells && this.dataRowList.first.cells.length > 0) { + const height = parseFloat(this.document.defaultView.getComputedStyle(this.dataRowList.first.cells.first.nativeElement)?.getPropertyValue('height')); + if (height) { + this._defaultRowHeight = height; + } else { + this._shouldRecalcRowHeight = true; + } + } + } + + // TODO: About to Move to CRUD + private configureRowEditingOverlay(rowID: any, useOuter = false) { + let settings = this.rowEditSettings; + const overlay = this.overlayService.getOverlayById(this.rowEditingOverlay.overlayId); + if (overlay) { + settings = overlay.settings; + } + settings.outlet = useOuter ? this.parentRowOutletDirective : this.rowOutletDirective; + this.rowEditPositioningStrategy.settings.container = this.tbody.nativeElement; + const pinned = this._pinnedRecordIDs.indexOf(rowID) !== -1; + const targetRow = !pinned ? + this.gridAPI.get_row_by_key(rowID) as IgxRowDirective + : this.pinnedRows.find(x => x.key === rowID) as IgxRowDirective; + if (!targetRow) { + return; + } + settings.target = targetRow.element.nativeElement; + this.toggleRowEditingOverlay(true); + } + + private handleColumnPinningForGroups(): void { + // When a column is a group or is inside a group, pin all related. + const pinnedColumns = []; + const unpinnedColumns = []; + + this._pinnedColumns.forEach(col => { + if (col.parent) { + col.parent.pinned = true; + } + if (col.columnGroup) { + col.children.forEach(child => child.pinned = true); + } + }); + + // Make sure we don't exceed unpinned area min width and get pinned and unpinned col collections. + // We take into account top level columns (top level groups and non groups). + // If top level is unpinned the pinning handles all children to be unpinned as well. + for (const column of this._columns) { + if (column.pinned && !column.parent) { + pinnedColumns.push(column); + } else if (column.pinned && column.parent) { + if (column.topLevelParent.pinned) { + pinnedColumns.push(column); + } else { + column.pinned = false; + unpinnedColumns.push(column); + } + } else { + unpinnedColumns.push(column); + } + } + // 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; + } + + protected shouldRecreateColumns(oldData: any[] | null | undefined, newData: any[] | null | undefined): boolean { + if (!oldData || !oldData.length) return true; + if (!newData || !newData.length) return false; + return Object.keys(oldData[0]).join() !== Object.keys(newData[0]).join(); + } + + /** + * Clears the current navigation service active node + */ + private clearActiveNode() { + this.navigation.lastActiveNode = this.navigation.activeNode; + this.navigation.activeNode = {} as IActiveNode; + this._activeRowIndexes = null; + this.notifyChanges(); + } + + private getRecreatedTree(value: IFilteringExpressionsTree): IFilteringExpressionsTree { + if (this._hGridSchema) { + return recreateTree(value, this._hGridSchema, true) as IFilteringExpressionsTree; + } else { + return recreateTreeFromFields(value, this._columns) as IFilteringExpressionsTree; + } + } + + private _calculateRowCount(): void { + if (this.verticalScrollContainer?.isRemote) { + this._rowCount = this.verticalScrollContainer.totalItemCount ?? 0; + } else if (this.paginator) { + this._rowCount = this.totalRecords ?? 0; + } else { + this._rowCount = this.verticalScrollContainer?.igxForOf?.length ?? 0; + } + this._rowCount += 1; // include header row + } + + 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; + this.notifyChanges(); + } + } +} diff --git a/projects/igniteui-angular/grids/grid/src/grid-cell-editing.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-cell-editing.spec.ts new file mode 100644 index 00000000000..c84769e3161 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid-cell-editing.spec.ts @@ -0,0 +1,1438 @@ +import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxGridComponent } from './public_api'; +import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; +import { GridFunctions } from '../../../test-utils/grid-functions.spec'; +import { + CellEditingTestComponent, CellEditingScrollTestComponent, + SelectionWithTransactionsComponent, + ColumnEditablePropertyTestComponent, + CellEditingCustomEditorTestComponent +} from '../../../test-utils/grid-samples.spec'; +import { DebugElement } from '@angular/core'; +import { first, takeUntil } from 'rxjs/operators'; +import { Subject, fromEvent } from 'rxjs'; +import { IGridEditDoneEventArgs, IGridEditEventArgs, IgxColumnComponent } from 'igniteui-angular/grids/core'; +import { IgxStringFilteringOperand, SortingDirection } from 'igniteui-angular/core'; + +const DEBOUNCE_TIME = 30; +const CELL_CSS_CLASS = '.igx-grid__td'; +const CELL_CSS_CLASS_NUMBER_FORMAT = '.igx-grid__td--number'; +const CELL_CLASS_IN_EDIT_MODE = 'igx-grid__td--editing'; +const EDITED_CELL_CSS_CLASS = 'igx-grid__td--edited'; + +describe('IgxGrid - Cell Editing #grid', () => { + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + CellEditingTestComponent, + CellEditingScrollTestComponent, + ColumnEditablePropertyTestComponent, + SelectionWithTransactionsComponent + ] + }).compileComponents(); + })); + + describe('Base Tests', () => { + let fixture; + let grid: IgxGridComponent; + let gridContent: DebugElement; + beforeEach(() => { + fixture = TestBed.createComponent(CellEditingTestComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + gridContent = GridFunctions.getGridContent(fixture); + }); + + it('should be able to enter edit mode on dblclick, enter and f2', () => { + const cell = grid.gridAPI.get_cell_by_index(0, 'fullName'); + + UIInteractions.simulateDoubleClickAndSelectEvent(cell); + fixture.detectChanges(); + expect(cell.editMode).toBe(true); + + UIInteractions.triggerEventHandlerKeyDown('escape', gridContent); + fixture.detectChanges(); + expect(cell.editMode).toBe(false); + + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fixture.detectChanges(); + expect(cell.editMode).toBe(true); + + UIInteractions.triggerEventHandlerKeyDown('escape', gridContent); + fixture.detectChanges(); + expect(cell.editMode).toBe(false); + + UIInteractions.triggerEventHandlerKeyDown('f2', gridContent); + fixture.detectChanges(); + expect(cell.editMode).toBe(true); + + UIInteractions.triggerEventHandlerKeyDown('escape', gridContent); + fixture.detectChanges(); + expect(cell.editMode).toBe(false); + }); + + it('should be able to edit cell which is a Primary Key', () => { + grid.primaryKey = 'personNumber'; + fixture.detectChanges(); + + const cell = grid.gridAPI.get_cell_by_index(0, 'personNumber'); + const cellDomPK = fixture.debugElement.queryAll(By.css(CELL_CSS_CLASS))[4]; + + UIInteractions.simulateDoubleClickAndSelectEvent(cell); + fixture.detectChanges(); + + expect(cell.editMode).toBe(true); + + const editTemplate = cellDomPK.query(By.css('input[type=\'number\']')); + UIInteractions.clickAndSendInputElementValue(editTemplate, 87); + + fixture.detectChanges(); + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + + fixture.detectChanges(); + expect(cell.editMode).toBe(false); + expect(cell.value).toBe(87); + }); + + it('edit template should be according column data type -- number', () => { + const cell = grid.gridAPI.get_cell_by_index(0, 'age'); + const cellDomNumber = fixture.debugElement.queryAll(By.css(CELL_CSS_CLASS))[1]; + + UIInteractions.simulateDoubleClickAndSelectEvent(cell); + fixture.detectChanges(); + + expect(cell.editMode).toBe(true); + const editTemplate = cellDomNumber.query(By.css('input[type=\'number\']')); + expect(editTemplate).toBeDefined(); + + UIInteractions.clickAndSendInputElementValue(editTemplate, 0.3698); + fixture.detectChanges(); + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fixture.detectChanges(); + + expect(cell.editMode).toBe(false); + expect(parseFloat(cell.value)).toBe(0.3698); + expect(editTemplate.nativeElement.type).toBe('number'); + }); + + it('should validate input data when edit numeric column', () => { + const cell = grid.gridAPI.get_cell_by_index(0, 'age'); + const cellDomNumber = fixture.debugElement.queryAll(By.css(CELL_CSS_CLASS))[1]; + const expectedValue = 0; + let editValue = 'some696'; + + UIInteractions.simulateDoubleClickAndSelectEvent(cell); + fixture.detectChanges(); + + const editTemplate = cellDomNumber.query(By.css('input[type=\'number\']')); + + UIInteractions.clickAndSendInputElementValue(editTemplate, editValue); + fixture.detectChanges(); + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fixture.detectChanges(); + + expect(cell.editMode).toBe(false); + expect(parseFloat(cell.value)).toBe(expectedValue); + + UIInteractions.simulateDoubleClickAndSelectEvent(cell); + fixture.detectChanges(); + + editValue = ''; + UIInteractions.clickAndSendInputElementValue(editTemplate, editValue); + fixture.detectChanges(); + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fixture.detectChanges(); + + expect(cell.editMode).toBe(false); + expect(parseFloat(cell.value)).toBe(expectedValue); + }); + + it('edit template should be according column data type -- boolean', fakeAsync(() => { + const cell = grid.gridAPI.get_cell_by_index(0, 'isActive'); + const cellDomBoolean = fixture.debugElement.queryAll(By.css(CELL_CSS_CLASS))[2]; + + UIInteractions.simulateDoubleClickAndSelectEvent(cell); + fixture.detectChanges(); + tick(); + + expect(cell.editMode).toBe(true); + + const editTemplate = cellDomBoolean.query(By.css('.igx-checkbox')); + expect(editTemplate).toBeDefined(); + expect(cell.value).toBe(true); + expect(grid.gridAPI.get_cell_by_index(0, 'isActive') + .nativeElement.querySelector('.igx-checkbox--checked')).toBeInstanceOf(HTMLElement); + + editTemplate.nativeElement.click(); + fixture.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fixture.detectChanges(); + + expect(cell.editMode).toBe(false); + expect(cell.value).toBe(false); + expect(grid.gridAPI.get_cell_by_index(0, 'isActive').nativeElement.querySelector('.igx-checkbox--checked')).toBeNull(); + })); + + it('edit template should be according column data type -- date', () => { + const cell = grid.gridAPI.get_cell_by_index(0, 'birthday'); + const cellDomDate = fixture.debugElement.queryAll(By.css(CELL_CSS_CLASS))[3]; + const selectedDate = new Date('04/12/2017'); + + UIInteractions.simulateDoubleClickAndSelectEvent(cell); + fixture.detectChanges(); + + expect(cell.editMode).toBe(true); + const datePicker = cellDomDate.query(By.css('igx-date-picker')).componentInstance; + expect(datePicker).toBeDefined(); + + datePicker.select(selectedDate); + fixture.detectChanges(); + + expect(datePicker.value).toBe(selectedDate); + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fixture.detectChanges(); + + expect(cell.editMode).toBe(false); + expect(cell.value.getTime()).toBe(selectedDate.getTime()); + }); + + it('should be able to change value form date picker input-- date', () => { + const cell = grid.gridAPI.get_cell_by_index(0, 'birthday'); + const cellDomDate = fixture.debugElement.queryAll(By.css(CELL_CSS_CLASS))[3]; + const selectedDate = new Date('04/12/2017'); + const editValue = '04/12/2017'; + + UIInteractions.simulateDoubleClickAndSelectEvent(cell); + fixture.detectChanges(); + + expect(cell.editMode).toBe(true); + const datePicker = cellDomDate.query(By.css('igx-date-picker')).componentInstance; + expect(datePicker).toBeDefined(); + + const editTemplate = cellDomDate.query(By.css('.igx-date-picker__input-date')); + editTemplate.triggerEventHandler('focus', { target: editTemplate.nativeElement }); + fixture.detectChanges(); + UIInteractions.clickAndSendInputElementValue(editTemplate, editValue); + fixture.detectChanges(); + + expect(datePicker.value).toEqual(selectedDate); + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fixture.detectChanges(); + + expect(cell.editMode).toBe(false); + expect(cell.value.getTime()).toEqual(selectedDate.getTime()); + }); + + it('should be able to clear value -- date', () => { + const cell = grid.gridAPI.get_cell_by_index(0, 'birthday'); + const cellDomDate = fixture.debugElement.queryAll(By.css(CELL_CSS_CLASS))[3]; + + UIInteractions.simulateDoubleClickAndSelectEvent(cell); + fixture.detectChanges(); + + expect(cell.editMode).toBe(true); + const datePicker = cellDomDate.query(By.css('igx-date-picker')).componentInstance; + expect(datePicker).toBeDefined(); + + const clear = cellDomDate.queryAll(By.css('.igx-icon'))[1]; + UIInteractions.simulateClickAndSelectEvent(clear); + + expect(datePicker.value).toBeNull(); + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fixture.detectChanges(); + + expect(cell.editMode).toBe(false); + expect(cell.value).toBeNull(); + }); + + it('Should not revert cell\' value when doubleClick while in editMode', fakeAsync(() => { + const cellElem = fixture.debugElement.query(By.css(CELL_CSS_CLASS)); + const firstCell = grid.gridAPI.get_cell_by_index(0, 'fullName'); + + expect(grid.gridAPI.get_cell_by_index(0, 'fullName').nativeElement.textContent).toBe('John Brown'); + expect(firstCell.editMode).toBeFalsy(); + + UIInteractions.simulateDoubleClickAndSelectEvent(firstCell); + fixture.detectChanges(); + tick(100); + + const editCell = cellElem.query(By.css('input')); + expect(editCell.nativeElement.value).toBe('John Brown'); + expect(firstCell.editMode).toBeTruthy(); + + UIInteractions.clickAndSendInputElementValue(editCell, 'test'); + fixture.detectChanges(); + cellElem.triggerEventHandler('dblclick', new Event('dblclick')); + fixture.detectChanges(); + expect(editCell.nativeElement.value).toBe('test'); + expect(firstCell.editMode).toBeTruthy(); + })); + + it('should end cell editing when clearing or applying advanced filter', fakeAsync(() => { + const cell = grid.gridAPI.get_cell_by_index(0, 'fullName'); + + // Enter cell edit mode + UIInteractions.simulateDoubleClickAndSelectEvent(cell); + fixture.detectChanges(); + expect(cell.editMode).toBe(true); + + // Open Advanced Filtering dialog. + grid.openAdvancedFilteringDialog(); + fixture.detectChanges(); + + // Clear the filters. + GridFunctions.clickAdvancedFilteringClearFilterButton(fixture); + fixture.detectChanges(); + + expect(cell.editMode).toBe(false); + + // Close the dialog. + GridFunctions.clickAdvancedFilteringCancelButton(fixture); + fixture.detectChanges(); + + // Enter cell edit mode + UIInteractions.simulateDoubleClickAndSelectEvent(cell); + fixture.detectChanges(); + expect(cell.editMode).toBe(true); + + // Open Advanced Filtering dialog. + grid.openAdvancedFilteringDialog(); + fixture.detectChanges(); + + // Apply the filters. + GridFunctions.clickAdvancedFilteringApplyButton(fixture); + fixture.detectChanges(); + + expect(cell.editMode).toBe(false); + })); + + it('should focus the first cell when editing mode is cell', fakeAsync(() => { + const cell = grid.gridAPI.get_cell_by_index(0, 'fullName'); + expect(cell.editMode).toBe(false); + expect(document.activeElement.nodeName).toEqual('BODY') + + // Enter cell edit mode + UIInteractions.simulateDoubleClickAndSelectEvent(cell); + fixture.detectChanges(); + tick(100); + + // Check focused element and selection + expect(cell.editMode).toBe(true); + expect(document.activeElement.nodeName).toEqual('INPUT') + expect((document.activeElement as HTMLInputElement).value).toBe('John Brown'); + expect((document.activeElement as HTMLInputElement).selectionStart).toEqual(0) + expect((document.activeElement as HTMLInputElement).selectionEnd).toEqual(10) + })); + + it('should work correct when not using ngModel but value and change event', fakeAsync(() => { + fixture = TestBed.createComponent(CellEditingCustomEditorTestComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + gridContent = GridFunctions.getGridContent(fixture); + grid.getColumnByName("fullName").inlineEditorTemplate = fixture.componentInstance.templateCell; + fixture.detectChanges(); + + const cell = grid.gridAPI.get_cell_by_index(0, 'fullName'); + + UIInteractions.simulateDoubleClickAndSelectEvent(cell); + fixture.detectChanges(); + tick(16); // trigger igxFocus + + expect(cell.editMode).toBe(true); + const newValue = 'new value'; + + const editTemplate = fixture.debugElement.query(By.css('input')); + fromEvent(editTemplate.nativeElement, "blur").pipe(first()).subscribe(() => { + // needed because we cannot simulate entirely user input (change event needs it) + editTemplate.nativeElement.dispatchEvent(new Event('change')); + fixture.detectChanges(); + }); + UIInteractions.clickAndSendInputElementValue(editTemplate, newValue); + fixture.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fixture.detectChanges(); + expect(cell.editMode).toBe(false); + expect(cell.value).toBe(newValue); + })); + + it('should preserve the navigation when cancel cellEdit and async set cell.editMode=false', fakeAsync(() => { + grid.cellEdit.subscribe((evt: IGridEditEventArgs) => { + evt.cancel = true; + const rowIndex = evt.cellID.rowIndex; + const field = evt.column.field; + const target = grid.getCellByColumn(rowIndex, field); + setTimeout(() => { + target.editMode = false; + }, 100); + }); + + const cell = grid.gridAPI.get_cell_by_index(0, 'fullName'); + + UIInteractions.simulateDoubleClickAndSelectEvent(cell); + fixture.detectChanges(); + tick(16); + expect(cell.editMode).toBeTrue(); + + const editInput = fixture.debugElement.query(By.css('igx-grid-cell input')); + if (editInput) { + UIInteractions.clickAndSendInputElementValue(editInput, 'Edited'); + } + fixture.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fixture.detectChanges(); + + tick(100); + fixture.detectChanges(); + expect(cell.editMode).toBeFalse(); + + expect(document.activeElement).toBe(grid.tbody.nativeElement); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', document.activeElement as HTMLElement, true); + fixture.detectChanges(); + + const nextCell = grid.getCellByColumn(0, 'age'); + const active = (grid as any).navigation.activeNode; + expect(active.row).toBe(0); + expect(active.column).toBe(nextCell.column.visibleIndex); + })); + }); + + describe('Scroll, pin and blur', () => { + let fixture; + let grid: IgxGridComponent; + let gridContent: DebugElement; + + beforeEach(() => { + fixture = TestBed.createComponent(CellEditingScrollTestComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + gridContent = GridFunctions.getGridContent(fixture); + }); + + it('edit mode - leaves edit mode on blur', () => { + const cell = grid.gridAPI.get_cell_by_index(0, 'firstName'); + const button = fixture.debugElement.query(By.css('.btnTest')); + + cell.column.editable = true; + UIInteractions.simulateClickAndSelectEvent(cell); + fixture.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fixture.detectChanges(); + + expect(cell.editMode).toBe(true); + + button.nativeElement.dispatchEvent(new Event('click')); + fixture.detectChanges(); + + expect(cell.editMode).toBe(true); + }); + + it('edit mode - exit edit mode and submit when pin/unpin unpin column', () => { + let cell = grid.gridAPI.get_cell_by_index(0, 'firstName'); + const cacheValue = cell.value; + const cellDom = fixture.debugElement.queryAll(By.css(CELL_CSS_CLASS))[0]; + + UIInteractions.simulateDoubleClickAndSelectEvent(cell); + fixture.detectChanges(); + + expect(grid.gridAPI.crudService.cell).toBeDefined(); + const editTemplate = cellDom.query(By.css('input')); + UIInteractions.clickAndSendInputElementValue(editTemplate, 'Gary Martin'); + fixture.detectChanges(); + + grid.pinColumn('firstName'); + fixture.detectChanges(); + expect(grid.gridAPI.crudService.cell).toBeNull(); + expect(grid.pinnedColumns.length).toBe(1); + cell = grid.gridAPI.get_cell_by_index(0, 'firstName'); + expect(cell.value).toBe(cacheValue); + cell = grid.gridAPI.get_cell_by_index(1, 'firstName'); + const cellValue = cell.value; + cell.editMode = true; + fixture.detectChanges(); + + expect(grid.gridAPI.crudService.cell).toBeDefined(); + grid.unpinColumn('firstName'); + fixture.detectChanges(); + cell = grid.gridAPI.get_cell_by_index(1, 'firstName'); + expect(grid.pinnedColumns.length).toBe(0); + expect(grid.gridAPI.crudService.cell).toBeNull(); + expect(cell.editMode).toBe(false); + expect(cell.value).toBe(cellValue); + }); + + it('edit mode - leaves cell in edit mode on scroll', async () => { + const cell = grid.gridAPI.get_cell_by_index(0, 'firstName'); + const editableCellId = cell.cellID; + UIInteractions.simulateDoubleClickAndSelectEvent(cell); + await wait(); + fixture.detectChanges(); + + let editCellID = grid.gridAPI.crudService.cell.id; + expect(editableCellId.columnID).toBe(editCellID.columnID); + expect(editableCellId.rowIndex).toBe(editCellID.rowIndex); + expect(JSON.stringify(editableCellId.rowID)).toBe(JSON.stringify(editCellID.rowID)); + + GridFunctions.scrollTop(grid, 1000); + await wait(100); + fixture.detectChanges(); + GridFunctions.scrollLeft(grid, 800); + await wait(100); + fixture.detectChanges(); + + editCellID = grid.gridAPI.crudService.cell.id; + expect(editableCellId.columnID).toBe(editCellID.columnID); + expect(editableCellId.rowIndex).toBe(editCellID.rowIndex); + expect(JSON.stringify(editableCellId.rowID)).toBe(JSON.stringify(editCellID.rowID)); + }); + + it('When cell in editMode and try to navigate with `ArrowDown` - focus should remain over the input.', async () => { + const verticalScroll = grid.verticalScrollContainer.getScroll(); + const cellElem = fixture.debugElement.query(By.css(CELL_CSS_CLASS)).nativeElement; + const cell = grid.gridAPI.get_cell_by_index(0, 'firstName'); + expect(cellElem.classList.contains(CELL_CLASS_IN_EDIT_MODE)).toBe(false); + + UIInteractions.simulateDoubleClickAndSelectEvent(cell); + fixture.detectChanges(); + await wait(50); + + let inputElem: HTMLInputElement = document.activeElement as HTMLInputElement; + expect(cell.editMode).toBeTruthy(); + expect(cellElem.classList.contains(CELL_CLASS_IN_EDIT_MODE)).toBe(true); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', inputElem, true); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + inputElem = document.activeElement as HTMLInputElement; + expect(cell.editMode).toBeTruthy(); + expect(cellElem.classList.contains(CELL_CLASS_IN_EDIT_MODE)).toBe(true); + expect(verticalScroll.scrollTop).toBe(0); + }); + + it('When cell in editMode and try to navigate with `ArrowUp` - focus should remain over the input.', (async () => { + const verticalScroll = grid.verticalScrollContainer.getScroll(); + GridFunctions.scrollTop(grid, 1000); + await wait(500); + fixture.detectChanges(); + + const testCells = grid.getColumnByName('firstName')._cells; + const cell = testCells[testCells.length - 1]; + const cellElem = cell.nativeElement; + + cellElem.dispatchEvent(new Event('focus')); + cellElem.dispatchEvent(new MouseEvent('dblclick')); + fixture.detectChanges(); + await wait(50); + + let inputElem: HTMLInputElement = document.activeElement as HTMLInputElement; + expect(cell.editMode).toBeTruthy(); + expect(cellElem.classList.contains(CELL_CLASS_IN_EDIT_MODE)).toBe(true); + const expectedScroll = verticalScroll.scrollTop; + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', inputElem, true); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + inputElem = document.activeElement as HTMLInputElement; + expect(cell.editMode).toBeTruthy(); + expect(cellElem.classList.contains(CELL_CLASS_IN_EDIT_MODE)).toBe(true); + expect(verticalScroll.scrollTop).toBe(expectedScroll); + })); + + it('When cell in editMode and try to navigate with `ArrowRight` - focus should remain over the input.', (async () => { + const cellElem = fixture.debugElement.query(By.css(CELL_CSS_CLASS)).nativeElement; + const virtRow = grid.gridAPI.get_row_by_index(0).virtDirRow; + expect(cellElem.classList.contains(CELL_CLASS_IN_EDIT_MODE)).toBe(false); + + cellElem.dispatchEvent(new Event('focus')); + cellElem.dispatchEvent(new MouseEvent('dblclick')); + fixture.detectChanges(); + await wait(50); + + const inputElem: HTMLInputElement = document.activeElement as HTMLInputElement; + const cell = grid.getCellByColumn(0, 'firstName'); + expect(cell.editMode).toBeTruthy(); + expect(cellElem.classList.contains(CELL_CLASS_IN_EDIT_MODE)).toBe(true); + + UIInteractions.triggerKeyDownEvtUponElem('arrowright', inputElem, true); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + const displayContainer = parseInt(virtRow.dc.instance._viewContainer.element.nativeElement.style.left, 10); + expect(cell.editMode).toBeTruthy(); + expect(cellElem.classList.contains(CELL_CLASS_IN_EDIT_MODE)).toBe(true); + expect(displayContainer).toBe(0); + })); + + it('When cell in editMode and try to navigate with `ArrowLeft` - focus should remain over the input.', (async () => { + const virtRow = grid.gridAPI.get_row_by_index(0).virtDirRow; + + GridFunctions.scrollLeft(grid, 800); + await wait(100); + fixture.detectChanges(); + + const testCells = fixture.debugElement.query(By.css('igx-grid-row')).queryAll(By.css('igx-grid-cell')); + const cellElem = testCells[testCells.length - 1].nativeElement; + + cellElem.dispatchEvent(new Event('focus')); + cellElem.dispatchEvent(new MouseEvent('dblclick')); + fixture.detectChanges(); + await wait(50); + + let inputElem: HTMLInputElement = document.activeElement as HTMLInputElement; + expect(cellElem.classList.contains(CELL_CLASS_IN_EDIT_MODE)).toBe(true); + const virtRowStyle = parseInt(virtRow.dc.instance._viewContainer.element.nativeElement.style.left, 10); + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', inputElem, fixture); + await wait(DEBOUNCE_TIME); + fixture.detectChanges(); + + inputElem = document.activeElement as HTMLInputElement; + expect(cellElem.classList.contains(CELL_CLASS_IN_EDIT_MODE)).toBe(true); + expect(parseInt(virtRow.dc.instance._viewContainer.element.nativeElement.style.left, 10)) + .toBe(virtRowStyle); + })); + + it('edit mode - should close calendar when scroll', (async () => { + GridFunctions.scrollLeft(grid, 800); + await wait(100); + fixture.detectChanges(); + + let cell = grid.gridAPI.get_cell_by_index(1, 'birthday'); + cell.nativeElement.dispatchEvent(new MouseEvent('dblclick')); + fixture.detectChanges(); + + const domDatePicker = fixture.debugElement.query(By.css('igx-date-picker')); + const iconDate = domDatePicker.query(By.css('.igx-icon')); + expect(iconDate).toBeDefined(); + + UIInteractions.simulateClickAndSelectEvent(iconDate); + fixture.detectChanges(); + + // Verify calendar is opened + let picker = document.getElementsByClassName('igx-calendar-picker'); + expect(picker.length).toBe(1); + + GridFunctions.scrollTop(grid, 10); + await wait(100); + fixture.detectChanges(); + + // Verify calendar is closed + picker = document.getElementsByClassName('igx-calendar-picker'); + expect(picker.length).toBe(0); + + // Verify cell is still in edit mode + cell = grid.gridAPI.get_cell_by_index(1, 'birthday'); + expect(cell.nativeElement.classList.contains(CELL_CLASS_IN_EDIT_MODE)).toBe(true); + const editCellID = grid.gridAPI.crudService.cell.id; + expect(4).toBe(editCellID.columnID); + expect(1).toBe(editCellID.rowIndex); + })); + }); + + describe('Events', () => { + let fixture; + let grid: IgxGridComponent; + let gridContent: DebugElement; + const $destroyer = new Subject(); + + beforeEach(() => { + fixture = TestBed.createComponent(CellEditingTestComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + gridContent = GridFunctions.getGridContent(fixture); + grid.ngAfterViewInit(); + }); + + afterEach(fakeAsync(() => { + $destroyer.next(true); + })); + + it(`Should properly emit 'cellEditEnter' event`, () => { + spyOn(grid.cellEditEnter, 'emit').and.callThrough(); + const cell = grid.gridAPI.get_cell_by_index(0, 'fullName'); + let initialRowData = { ...cell.row.data }; + expect(cell.editMode).toBeFalsy(); + + UIInteractions.simulateDoubleClickAndSelectEvent(cell); + fixture.detectChanges(); + + let cellArgs: IGridEditEventArgs = { + primaryKey: cell.row.key, + rowID: cell.row.key, + rowKey: cell.row.key, + cellID: cell.cellID, + rowData: initialRowData, + oldValue: 'John Brown', + cancel: false, + valid: true, + column: cell.column, + owner: grid, + event: jasmine.anything() as any + }; + expect(grid.cellEditEnter.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEditEnter.emit).toHaveBeenCalledWith(cellArgs); + expect(cell.editMode).toBeTruthy(); + + // press tab on edited cell + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + fixture.detectChanges(); + + expect(cell.editMode).toBeFalsy(); + const cell2 = grid.getCellByColumn(0, 'age'); + initialRowData = { ...cell2.row.data }; + cellArgs = { + cellID: cell2.id, + rowID: cell2.row.key, + primaryKey: cell2.row.key, + rowKey: cell2.row.key, + rowData: initialRowData, + oldValue: 20, + valid: true, + cancel: false, + column: cell2.column, + owner: grid, + event: jasmine.anything() as any + }; + expect(grid.cellEditEnter.emit).toHaveBeenCalledTimes(2); + expect(grid.cellEditEnter.emit).toHaveBeenCalledWith(cellArgs); + expect(cell2.editMode).toBeTruthy(); + }); + + it(`Should be able to cancel 'cellEditEnter' event`, () => { + spyOn(grid.cellEditEnter, 'emit').and.callThrough(); + grid.cellEditEnter.subscribe((e: IGridEditEventArgs) => { + e.cancel = true; + }); + let cell = grid.gridAPI.get_cell_by_index(0, 'fullName'); + let initialRowData = { ...cell.row.data }; + expect(cell.editMode).toBeFalsy(); + + UIInteractions.simulateDoubleClickAndSelectEvent(cell); + fixture.detectChanges(); + + let cellArgs: IGridEditEventArgs = { + cellID: cell.cellID, + rowKey: cell.row.key, + rowID: cell.row.key, + primaryKey: cell.row.key, + rowData: initialRowData, + oldValue: 'John Brown', + cancel: true, + valid: true, + column: cell.column, + owner: grid, + event: jasmine.anything() as any + }; + expect(grid.cellEditEnter.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEditEnter.emit).toHaveBeenCalledWith(cellArgs); + expect(cell.editMode).toBeFalsy(); + + // press enter on a cell + cell = grid.gridAPI.get_cell_by_index(0, 'age'); + initialRowData = { ...cell.row.data }; + UIInteractions.simulateClickAndSelectEvent(cell); + fixture.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fixture.detectChanges(); + + cellArgs = { + cellID: cell.cellID, + rowKey: cell.row.key, + rowID: cell.row.key, + primaryKey: cell.row.key, + rowData: initialRowData, + oldValue: 20, + cancel: true, + column: cell.column, + owner: grid, + event: jasmine.anything() as any, + valid: true + }; + expect(grid.cellEditEnter.emit).toHaveBeenCalledTimes(2); + expect(grid.cellEditEnter.emit).toHaveBeenCalledWith(cellArgs); + expect(cell.editMode).toBeFalsy(); + }); + + it(`Should properly emit 'cellEditExit' event`, () => { + spyOn(grid.cellEditExit, 'emit').and.callThrough(); + let cell = grid.gridAPI.get_cell_by_index(0, 'fullName'); + let initialRowData = { ...cell.row.data }; + expect(cell.editMode).toBeFalsy(); + + UIInteractions.simulateDoubleClickAndSelectEvent(cell); + fixture.detectChanges(); + + // press tab on edited cell + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + fixture.detectChanges(); + + let cellArgs: IGridEditDoneEventArgs = { + rowID: cell.row.key, + rowKey: cell.row.key, + primaryKey: cell.row.key, + cellID: cell.cellID, + rowData: initialRowData, + newValue: 'John Brown', + valid: true, + oldValue: 'John Brown', + column: cell.column, + owner: grid, + event: jasmine.anything() as any + }; + + expect(grid.cellEditExit.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEditExit.emit).toHaveBeenCalledWith(cellArgs); + + // press tab on edited cell + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + fixture.detectChanges(); + + expect(cell.editMode).toBeFalsy(); + cell = grid.gridAPI.get_cell_by_index(0, 'age'); + initialRowData = { ...cell.row.data }; + cellArgs = { + cellID: cell.cellID, + rowKey: cell.row.key, + rowID: cell.row.key, + primaryKey: cell.row.key, + rowData: initialRowData, + newValue: 20, + oldValue: 20, + valid: true, + column: cell.column, + owner: grid, + event: jasmine.anything() as any + }; + expect(grid.cellEditExit.emit).toHaveBeenCalledTimes(2); + expect(grid.cellEditExit.emit).toHaveBeenCalledWith(cellArgs); + }); + + it(`Should properly emit 'cellEdit' event`, () => { + spyOn(grid.cellEdit, 'emit').and.callThrough(); + let cellArgs: IGridEditEventArgs; + let cell = grid.gridAPI.get_cell_by_index(0, 'fullName'); + + UIInteractions.simulateDoubleClickAndSelectEvent(cell); + fixture.detectChanges(); + + expect(cell.editMode).toBe(true); + let editTemplate = fixture.debugElement.query(By.css('input')); + UIInteractions.clickAndSendInputElementValue(editTemplate, 'New Name'); + fixture.detectChanges(); + + // press tab on edited cell + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + fixture.detectChanges(); + + // TODO: cellEdit should emit updated rowData - issue #7304 + cellArgs = { + cellID: cell.cellID, + rowKey: cell.row.key, + rowID: cell.row.key, + primaryKey: cell.row.key, + rowData: cell.row.data, + oldValue: 'John Brown', + newValue: 'New Name', + cancel: false, + column: cell.column, + owner: grid, + event: jasmine.anything() as any, + valid: true + }; + expect(grid.cellEdit.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEdit.emit).toHaveBeenCalledWith(cellArgs); + + cell = grid.gridAPI.get_cell_by_index(0, 'age'); + expect(cell.editMode).toBe(true); + editTemplate = fixture.debugElement.query(By.css('input')); + UIInteractions.clickAndSendInputElementValue(editTemplate, 1); + fixture.detectChanges(); + + // press enter on edited cell + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fixture.detectChanges(); + + // TODO: cellEdit should emit updated rowData - issue #7304 + cellArgs = { + cellID: cell.cellID, + rowKey: cell.row.key, + rowID: cell.row.key, + primaryKey: cell.row.key, + rowData: cell.row.data, + oldValue: 20, + newValue: 1, + cancel: false, + column: cell.column, + owner: grid, + event: jasmine.anything() as any, + valid: true + }; + expect(grid.cellEdit.emit).toHaveBeenCalledTimes(2); + expect(grid.cellEdit.emit).toHaveBeenCalledWith(cellArgs); + }); + + it(`Should be able to cancel 'cellEdit' event`, fakeAsync(() => { + const emitSpy = spyOn(grid.cellEdit, 'emit').and.callThrough(); + grid.cellEdit.subscribe((e: IGridEditEventArgs) => { + e.cancel = true; + }); + const cell = grid.gridAPI.get_cell_by_index(0, 'fullName'); + const initialRowData = { ...cell.row.data }; + + UIInteractions.simulateDoubleClickAndSelectEvent(cell); + fixture.detectChanges(); + tick(16); // trigger igxFocus rAF + const editInput = fixture.debugElement.query(By.css('igx-grid-cell input')).nativeElement; + + expect(cell.editMode).toBe(true); + expect(document.activeElement).toBe(editInput); + const cellValue = cell.value; + const newValue = 'new value'; + cell.editValue = newValue; + + grid.gridAPI.crudService.endEdit(true); + fixture.detectChanges(); + + + const cellArgs: IGridEditEventArgs = { + rowID: cell.row.key, + primaryKey: cell.row.key, + rowKey: cell.row.key, + cellID: cell.cellID, + rowData: initialRowData, + oldValue: cellValue, + newValue, + cancel: true, + column: cell.column, + owner: grid, + valid: true, + event: undefined + }; + expect(grid.cellEdit.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEdit.emit).toHaveBeenCalledWith(cellArgs); + + emitSpy.calls.reset(); + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + fixture.detectChanges(); + + cellArgs.event = jasmine.anything() as any; + expect(cell.editMode).toBe(true); + expect(cell.value).toBe(cellValue); + expect(document.activeElement).toBe(editInput); + expect(grid.cellEdit.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEdit.emit).toHaveBeenCalledWith(cellArgs); + + const nextCell = grid.getCellByColumn(0, 'age'); + expect(nextCell.editMode).toBe(false); + + // activate the new cell + emitSpy.calls.reset(); + grid.gridAPI.get_cell_by_index(0, 'age').activate(new FocusEvent('focus')); + fixture.detectChanges(); + expect(grid.cellEdit.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEdit.emit).toHaveBeenCalledWith(cellArgs); + + expect(nextCell.editMode).toBe(false); + expect(cell.editMode).toBe(true); + expect(document.activeElement).toBe(editInput); + + emitSpy.calls.reset(); + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fixture.detectChanges(); + + expect(cell.editMode).toBe(true); + expect(document.activeElement).toBe(editInput); + expect(grid.cellEdit.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEdit.emit).toHaveBeenCalledWith(cellArgs); + })); + + it(`Should be able to update other cell in 'cellEdit' event`, fakeAsync(() => { + grid.primaryKey = 'personNumber'; + fixture.detectChanges(); + + spyOn(grid.cellEdit, 'emit').and.callThrough(); + grid.cellEdit.subscribe((e: IGridEditEventArgs) => { + if (e.cellID.columnID === 0) { + grid.updateCell(1, e.rowID, 'age'); + } + }); + + let cell = grid.getCellByColumn(0, 'fullName'); + + UIInteractions.simulateDoubleClickAndSelectEvent(grid.gridAPI.get_cell_by_index(0, 'fullName')); + fixture.detectChanges(); + + expect(cell.editMode).toBe(true); + let editTemplate = fixture.debugElement.query(By.css('input')); + UIInteractions.clickAndSendInputElementValue(editTemplate, 'New Name'); + fixture.detectChanges(); + + // press tab on edited cell + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + fixture.detectChanges(); + tick(); + + expect(grid.cellEdit.emit).toHaveBeenCalledTimes(1); + cell = grid.getCellByColumn(0, 'age'); + expect(cell.editMode).toBe(true); + expect(cell.value).toEqual(1); + expect(cell.editValue).toEqual(1); + editTemplate = fixture.debugElement.query(By.css('input')); + expect(editTemplate.nativeElement.value).toEqual('1'); + + cell = grid.getCellByColumn(0, 'fullName'); + expect(cell.value).toEqual('New Name'); + })); + + it(`Should not update data in grid with transactions, when row is updated in cellEdit and cellEdit is canceled`, () => { + fixture = TestBed.createComponent(SelectionWithTransactionsComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + + grid.primaryKey = 'ID'; + fixture.detectChanges(); + + // update the cell value via updateRow and cancel the event + grid.cellEdit.subscribe((e: IGridEditEventArgs) => { + const rowIndex: number = e.cellID.rowIndex; + const row = grid.gridAPI.get_row_by_index(rowIndex); + grid.updateRow({ [(row as any).columns[e.cellID.columnID].field]: e.newValue }, row.key); + e.cancel = true; + }); + + const cell = grid.getCellByColumn(0, 'Name'); + const initialValue = cell.value; + const firstNewValue = 'New Value'; + const secondNewValue = 'Very New Value'; + + cell.update(firstNewValue); + fixture.detectChanges(); + expect(cell.value).toBe(firstNewValue); + + cell.update(secondNewValue); + fixture.detectChanges(); + expect(cell.value).toBe(secondNewValue); + + grid.transactions.undo(); + fixture.detectChanges(); + expect(cell.value).toBe(firstNewValue); + + grid.transactions.undo(); + fixture.detectChanges(); + expect(cell.value).toBe(initialValue); + }); + + xit(`Should emit the committed/new rowData cellEditDone`, () => { + fixture = TestBed.createComponent(SelectionWithTransactionsComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + + const cell = grid.getCellByColumn(0, 'Name'); + const initialValue = cell.value; + const newValue = 'New Name'; + const updatedRowData = Object.assign({}, cell.row.data, { Name: newValue }); + + spyOn(grid.cellEditDone, 'emit').and.callThrough(); + + cell.update(newValue); + fixture.detectChanges(); + expect(cell.value).toBe(newValue); + + const cellArgs: IGridEditDoneEventArgs = { + cellID: cell.id, + rowID: cell.row.key, + primaryKey: cell.row.key, + rowKey: cell.row.key, + rowData: updatedRowData, // fixture is with transactions & without rowEditing + oldValue: initialValue, + newValue, + column: cell.column, + owner: grid, + event: undefined + }; + expect(grid.cellEditDone.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEditDone.emit).toHaveBeenCalledWith(cellArgs); + }); + + it(`Should properly emit 'cellEditExit' event`, () => { + spyOn(grid.cellEditExit, 'emit').and.callThrough(); + const cell = grid.gridAPI.get_cell_by_index(0, 'fullName'); + const initialRowData = { ...cell.row.data }; + + UIInteractions.simulateDoubleClickAndSelectEvent(cell); + fixture.detectChanges(); + + expect(cell.editMode).toBe(true); + const editTemplate = fixture.debugElement.query(By.css('input')); + UIInteractions.clickAndSendInputElementValue(editTemplate, 'New Name'); + fixture.detectChanges(); + + // press escape on edited cell + UIInteractions.triggerEventHandlerKeyDown('escape', gridContent); + fixture.detectChanges(); + + const cellArgs: IGridEditDoneEventArgs = { + cellID: cell.cellID, + rowID: cell.row.key, + primaryKey: cell.row.key, + rowKey: cell.row.key, + rowData: initialRowData, + oldValue: 'John Brown', + newValue: 'New Name', + column: cell.column, + owner: grid, + event: jasmine.anything() as any, + valid: true + }; + expect(grid.cellEditExit.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEditExit.emit).toHaveBeenCalledWith(cellArgs); + + expect(cell.editMode).toBe(false); + }); + + it(`Should properly emit 'cellEditDone' event`, () => { + const doneSpy = spyOn(grid.cellEditDone, 'emit').and.callThrough(); + + let cellArgs: IGridEditDoneEventArgs; + let cell = grid.getCellByColumn(0, 'fullName'); + const firstNewValue = 'New Name'; + const secondNewValue = 1; + let updatedRowData = Object.assign({}, cell.row.data, { fullName: firstNewValue }); + + UIInteractions.simulateDoubleClickAndSelectEvent(grid.gridAPI.get_cell_by_index(0, 'fullName')); + fixture.detectChanges(); + + expect(cell.editMode).toBe(true); + let editTemplate = fixture.debugElement.query(By.css('input')); + UIInteractions.clickAndSendInputElementValue(editTemplate, firstNewValue); + fixture.detectChanges(); + + // press tab on edited cell + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + fixture.detectChanges(); + + cellArgs = { + rowKey: cell.row.key, + cellID: cell.id, + rowID: cell.row.key, + primaryKey: cell.row.key, + rowData: updatedRowData, // fixture is without rowEditing and without transactions + oldValue: 'John Brown', + newValue: firstNewValue, + column: cell.column, + owner: grid, + event: jasmine.anything() as any, + valid: true + }; + expect(grid.cellEditDone.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEditDone.emit).toHaveBeenCalledWith(cellArgs); + + cell = grid.getCellByColumn(0, 'age'); + expect(cell.editMode).toBe(true); + editTemplate = fixture.debugElement.query(By.css('input')); + UIInteractions.clickAndSendInputElementValue(editTemplate, 1); + fixture.detectChanges(); + + // press enter on edited cell + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fixture.detectChanges(); + + updatedRowData = Object.assign({}, cell.row.data, { age: secondNewValue }); + cellArgs = { + cellID: cell.id, + rowKey: cell.row.key, + rowID: cell.row.key, + primaryKey: cell.row.key, + rowData: cell.row.data, // fixture is without rowEditing and without transactions + oldValue: 20, + newValue: secondNewValue, + column: cell.column, + owner: grid, + event: jasmine.anything() as any, + valid: true + }; + expect(grid.cellEditDone.emit).toHaveBeenCalledTimes(2); + expect(grid.cellEditDone.emit).toHaveBeenCalledWith(cellArgs); + + const spyDoneArgs = doneSpy.calls.mostRecent().args[0] as IGridEditDoneEventArgs; + expect(spyDoneArgs.rowData).toBe(grid.data[0]); + }); + + it('Should not enter cell edit when cellEditEnter is canceled', () => { + let canceled = true; + const cell = grid.gridAPI.get_cell_by_index(0, 'fullName'); + grid.cellEditEnter.pipe(takeUntil($destroyer)).subscribe((evt) => { + evt.cancel = canceled; + }); + + grid.gridAPI.crudService.enterEditMode(cell); + fixture.detectChanges(); + + expect(grid.gridAPI.crudService.cellInEditMode).toEqual(false); + grid.gridAPI.crudService.endEditMode(); + fixture.detectChanges(); + + canceled = false; + grid.gridAPI.crudService.enterEditMode(cell); + fixture.detectChanges(); + expect(grid.gridAPI.crudService.cellInEditMode).toEqual(true); + }); + + it('When cellEdit is canceled the new value of the cell should be committed and the editing should be closed (API call)', () => { + const cell = grid.gridAPI.get_cell_by_index(0, 'fullName'); + grid.cellEdit.pipe(takeUntil($destroyer)).subscribe((evt) => { + evt.cancel = true; + }); + + grid.gridAPI.crudService.enterEditMode(cell); + fixture.detectChanges(); + + const cellValue = cell.value; + const newValue = 'new value'; + cell.update(newValue); + fixture.detectChanges(); + + expect(grid.gridAPI.crudService.cellInEditMode).toEqual(false); + expect(cell.value).not.toEqual(cellValue); + expect(cell.value).toEqual(newValue); + }); + + it('should update editValue when externally changing grid data.', () => { + const cell = grid.getCellByColumn(0, 'fullName'); + cell.editMode = true; + fixture.detectChanges(); + + expect(cell.editMode).toBeTruthy(); + expect(cell.editValue).toBe('John Brown'); + + fixture.detectChanges(); + cell.editMode = false; + fixture.detectChanges(); + + grid.data[0].fullName = "Test"; + grid.cdr.detectChanges(); + + cell.editMode = true; + fixture.detectChanges(); + expect(cell.editMode).toBeTruthy(); + expect(cell.editValue).toBe('Test'); + }); + }); + + describe('Integration tests', () => { + let fixture; + let grid: IgxGridComponent; + let gridContent; + beforeEach(() => { + fixture = TestBed.createComponent(CellEditingTestComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + gridContent = GridFunctions.getGridContent(fixture); + }); + + it(`Should exit edit mode when rowEditable changes`, () => { + const cell = grid.getCellByColumn(0, 'personNumber'); + expect(cell.editMode).toBeFalsy(); + + cell.editMode = true; + fixture.detectChanges(); + + expect(cell.editMode).toBeTruthy(); + + grid.rowEditable = true; + fixture.detectChanges(); + + expect(cell.editMode).toBeFalsy(); + }); + + it('should exit edit mode on filtering', () => { + const cell = grid.gridAPI.get_cell_by_index(0, 'fullName'); + const cellDom = fixture.debugElement.queryAll(By.css(CELL_CSS_CLASS))[0]; + const cellValue = cell.value; + + UIInteractions.simulateDoubleClickAndSelectEvent(cell); + fixture.detectChanges(); + + const editTemplate = cellDom.query(By.css('input')); + expect(cell.editMode).toBe(true); + + UIInteractions.clickAndSendInputElementValue(editTemplate, 'Rick Gilmore'); + fixture.detectChanges(); + + grid.filter('fullName', 'Al', IgxStringFilteringOperand.instance().condition('equals')); + fixture.detectChanges(); + grid.clearFilter('fullName'); + fixture.detectChanges(); + + const cell2 = grid.getCellByColumn(0, 'fullName'); + expect(cell2.editMode).toBe(false); + expect(cell2.value).toBe(cellValue); + }); + + it('should not throw errors when update cell to value, which does not match filter criteria', () => { + grid.filter('personNumber', 1, IgxStringFilteringOperand.instance().condition('equals')); + fixture.detectChanges(); + + const cell = grid.gridAPI.get_cell_by_index(0, 'personNumber'); + const cellDomPK = fixture.debugElement.queryAll(By.css(CELL_CSS_CLASS))[4]; + const previousCell = grid.gridAPI.get_cell_by_index(0, 'birthday'); + + UIInteractions.simulateDoubleClickAndSelectEvent(cell); + fixture.detectChanges(); + expect(cell.editMode).toBe(true); + + const editTemplate = cellDomPK.query(By.css('input[type=\'number\']')); + UIInteractions.clickAndSendInputElementValue(editTemplate, 9); + fixture.detectChanges(); + + expect(() => previousCell.onClick(new MouseEvent('click'))).not.toThrow(); + }); + + it('should exit edit mode on sorting', () => { + const cell = grid.gridAPI.get_cell_by_index(0, 'fullName'); + const cellDom = fixture.debugElement.queryAll(By.css(CELL_CSS_CLASS))[0]; + + UIInteractions.simulateDoubleClickAndSelectEvent(cell); + fixture.detectChanges(); + + const editTemplate = cellDom.query(By.css('input')); + expect(cell.editMode).toBe(true); + UIInteractions.clickAndSendInputElementValue(editTemplate, 'Rick Gilmore'); + fixture.detectChanges(); + + grid.sort({ fieldName: 'age', dir: SortingDirection.Desc, ignoreCase: false }); + fixture.detectChanges(); + + expect(grid.gridAPI.crudService.cell).toBeNull(); + }); + + it('should update correct cell when sorting is applied', () => { + grid.sort({ fieldName: 'age', dir: SortingDirection.Desc, ignoreCase: false }); + fixture.detectChanges(); + + const cell = grid.gridAPI.get_cell_by_index(0, 'fullName'); + const cellDom = fixture.debugElement.queryAll(By.css(CELL_CSS_CLASS))[0]; + UIInteractions.simulateDoubleClickAndSelectEvent(cell); + fixture.detectChanges(); + + const editTemplate = cellDom.query(By.css('input')); + expect(cell.editMode).toBe(true); + expect(cell.editValue).toBe('Tom Riddle'); + UIInteractions.clickAndSendInputElementValue(editTemplate, 'Rick Gilmore'); + fixture.detectChanges(); + + expect(grid.gridAPI.crudService.cell.editValue).toBe('Rick Gilmore'); + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + + fixture.detectChanges(); + expect(cell.value).toBe('Rick Gilmore'); + expect(grid.gridAPI.crudService.cell).toBeNull(); + }); + + it('should clean active state when endEdit on focusout of the grid', async () => { + const handleFocusOut = ($event: FocusEvent) => { + if (!$event.relatedTarget || !grid.nativeElement.contains($event.relatedTarget as Node)) { + grid.endEdit(true); + grid.clearCellSelection(); + } + }; + grid.nativeElement.addEventListener('focusout', handleFocusOut); + const cell = grid.gridAPI.get_cell_by_index(0, 'fullName'); + const cellDom = fixture.debugElement.queryAll(By.css(CELL_CSS_CLASS))[0]; + + UIInteractions.simulateDoubleClickAndSelectEvent(cell); + fixture.detectChanges(); + await wait(16 /* igxFocus raf */); + expect(cell.editMode).toBe(true); + + const editTemplate = cellDom.query(By.css('input')); + expect(document.activeElement).toBe(editTemplate.nativeElement); + + UIInteractions.clickAndSendInputElementValue(editTemplate, 'Edit Cell'); + fixture.detectChanges(); + + editTemplate.nativeElement.blur(); + fixture.detectChanges(); + + expect(cell.editMode).toBe(false); + expect(cell.value).toBe('Edit Cell'); + expect(Object.keys(grid.navigation.activeNode).length).toBe(0); + + grid.nativeElement.removeEventListener('focusout', handleFocusOut); + }); + }); + + it('Cell editing (when rowEditable=false) - default column editable value is false', fakeAsync(() => { + const fixture = TestBed.createComponent(ColumnEditablePropertyTestComponent); + fixture.detectChanges(); + const grid = fixture.componentInstance.grid; + const columns: IgxColumnComponent[] = grid.columnList.toArray(); + expect(columns[0].editable).toBeFalsy(); + expect(columns[1].editable).toBeFalsy(); + expect(columns[2].editable).toBeTruthy(); + expect(columns[3].editable).toBeTruthy(); + expect(columns[4].editable).toBeFalsy(); + expect(columns[5].editable).toBeFalsy(); + })); + + // Bug #5855 + it('should apply proper style on cell editing when new value equals zero or false', fakeAsync(() => { + const fixture = TestBed.createComponent(SelectionWithTransactionsComponent); + fixture.detectChanges(); + + const grid = fixture.componentInstance.grid; + const gridContent = GridFunctions.getGridContent(fixture); + grid.getColumnByName('ParentID').hidden = true; + grid.getColumnByName('Name').hidden = true; + grid.getColumnByName('HireDate').hidden = true; + grid.getColumnByName('Age').editable = true; + grid.getColumnByName('OnPTO').editable = true; + fixture.detectChanges(); + + let cell = grid.getCellByColumn(0, 'Age'); + let cellDomPK = fixture.debugElement.queryAll(By.css(CELL_CSS_CLASS_NUMBER_FORMAT))[1]; + + UIInteractions.simulateDoubleClickAndSelectEvent(grid.gridAPI.get_cell_by_index(0, 'Age')); + fixture.detectChanges(); + expect(cell.editMode).toBe(true); + + let editTemplate = cellDomPK.query(By.css('input[type=\'number\']')); + UIInteractions.clickAndSendInputElementValue(editTemplate, 0); + fixture.detectChanges(); + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fixture.detectChanges(); + + expect(cell.editMode).toBe(false); + expect(cell.value).toBe(0); + expect(grid.gridAPI.get_cell_by_index(0, 'Age').nativeElement.classList).toContain(EDITED_CELL_CSS_CLASS); + + cell = grid.getCellByColumn(1, 'OnPTO'); + cellDomPK = fixture.debugElement.queryAll(By.css(CELL_CSS_CLASS))[5]; + + UIInteractions.simulateDoubleClickAndSelectEvent(grid.gridAPI.get_cell_by_index(1, 'OnPTO')); + fixture.detectChanges(); + tick(); + + expect(cell.editMode).toBe(true); + + editTemplate = cellDomPK.query(By.css('.igx-checkbox')).query(By.css('.igx-checkbox__label')); + editTemplate.nativeElement.click(); + fixture.detectChanges(); + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fixture.detectChanges(); + + expect(cell.editMode).toBe(false); + expect(cell.value).toBe(false); + expect(grid.gridAPI.get_cell_by_index(1, 'OnPTO').nativeElement.classList).toContain(EDITED_CELL_CSS_CLASS); + })); +}); diff --git a/projects/igniteui-angular/grids/grid/src/grid-cell-selection.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-cell-selection.spec.ts new file mode 100644 index 00000000000..93ba9188b21 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid-cell-selection.spec.ts @@ -0,0 +1,3394 @@ +import { TestBed, fakeAsync, tick, ComponentFixture, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxGridComponent } from './public_api'; +import { + SelectionWithScrollsComponent, + SelectionWithTransactionsComponent, + CellSelectionNoneComponent, + CellSelectionSingleComponent, + IgxGridRowEditingWithoutEditableColumnsComponent +} from '../../../test-utils/grid-samples.spec'; +import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; +import { clearGridSubs, setupGridScrollDetection } from '../../../test-utils/helper-utils.spec'; +import { GridSelectionMode } from 'igniteui-angular/grids/core'; + +import { GridSelectionFunctions, GridFunctions } from '../../../test-utils/grid-functions.spec'; +import { DebugElement } from '@angular/core'; +import { DropPosition } from 'igniteui-angular/grids/core'; +import { IgxGridGroupByRowComponent } from './groupby-row.component'; +import { DefaultSortingStrategy, IgxStringFilteringOperand, SortingDirection } from 'igniteui-angular/core'; + +describe('IgxGrid - Cell selection #grid', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + SelectionWithScrollsComponent, + SelectionWithTransactionsComponent, + CellSelectionNoneComponent, + CellSelectionSingleComponent, + IgxGridRowEditingWithoutEditableColumnsComponent + ] + }).compileComponents(); + })); + + describe('Base', () => { + let fix; + let grid: IgxGridComponent; + let detect; + + beforeEach(() => { + fix = TestBed.createComponent(SelectionWithScrollsComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + detect = () => grid.cdr.detectChanges(); + }); + + it('Should be able to select a range with mouse dragging', () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const startCell = grid.gridAPI.get_cell_by_index(2, 'ParentID'); + const endCell = grid.gridAPI.get_cell_by_index(3, 'ID'); + const range = { rowStart: 2, rowEnd: 3, columnStart: 0, columnEnd: 1 }; + + UIInteractions.simulatePointerOverElementEvent('pointerdown', startCell.nativeElement); + detect(); + + expect(startCell.active).toBe(true); + + for (let i = 3; i < 5; i++) { + const cell = grid.gridAPI.get_cell_by_index(i, grid.columnList.get(i - 1).field); + UIInteractions.simulatePointerOverElementEvent('pointerenter', cell.nativeElement); + detect(); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, i, 1, i - 1); + } + + for (let i = 3; i >= 0; i--) { + const cell = grid.gridAPI.get_cell_by_index(i, 'HireDate'); + UIInteractions.simulatePointerOverElementEvent('pointerenter', cell.nativeElement); + detect(); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, i, 1, 3); + } + + for (let i = 2; i >= 0; i--) { + const cell = grid.gridAPI.get_cell_by_index(0, grid.columnList.get(i).field); + UIInteractions.simulatePointerOverElementEvent('pointerenter', cell.nativeElement); + detect(); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 0, 1, i); + } + + for (let i = 1; i < 4; i++) { + const cell = grid.gridAPI.get_cell_by_index(i, 'ID'); + UIInteractions.simulatePointerOverElementEvent('pointerenter', cell.nativeElement); + detect(); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, i, 1, 0); + } + + UIInteractions.simulatePointerOverElementEvent('pointerup', endCell.nativeElement); + detect(); + // Invoke endEdit() to make sure if no editing is going on, + // the cell activation shouldn't be lost (https://infragistics.visualstudio.com/Indigo_Platform/_workitems/edit/37933) + grid.endEdit(true, null); + fix.detectChanges(); + + expect(startCell.active).toBe(true); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 3, 1, 0); + GridSelectionFunctions.verifySelectedRange(grid, 2, 3, 0, 1); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + expect(selectionChangeSpy).toHaveBeenCalledWith(range); + }); + + it('Should not lose selection on right clicking', () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const range = { rowStart: 2, rowEnd: 3, columnStart: 0, columnEnd: 1 }; + grid.setSelection(range); + detect(); + + GridSelectionFunctions.verifySelectedRange(grid, 2, 3, 0, 1, 0, 1); + + // Simulate right-click + const endCell = grid.gridAPI.get_cell_by_index(4, 'ID'); + UIInteractions.simulateNonPrimaryClick(endCell); + detect(); + + GridSelectionFunctions.verifySelectedRange(grid, 2, 3, 0, 1, 0, 1); + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + + const c = grid.gridAPI.get_cell_by_index(0, 'ID'); + UIInteractions.simulateClickAndSelectEvent(c); + detect(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifySelectedRange(grid, 0, 0, 0, 0, 0, 1); + }); + + it('Should be able to select multiple ranges with Ctrl key and mouse drag', () => { + let firstCell = grid.gridAPI.get_cell_by_index(1, 'ParentID'); + let secondCell = grid.gridAPI.get_cell_by_index(2, 'Name'); + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + + GridSelectionFunctions.selectCellsRangeNoWait(fix, firstCell, secondCell); + detect(); + + let range = { rowStart: 1, rowEnd: 2, columnStart: 1, columnEnd: 2 }; + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + expect(selectionChangeSpy).toHaveBeenCalledWith(range); + expect(grid.selectedCells.length).toBe(4); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 2, 1, 2); + + firstCell = grid.gridAPI.get_cell_by_index(2, 'ParentID'); + secondCell = grid.gridAPI.get_cell_by_index(3, 'ID'); + GridSelectionFunctions.selectCellsRangeNoWait(fix, firstCell, secondCell, true); + detect(); + + expect(grid.selectedCells.length).toBe(7); + range = { rowStart: 2, rowEnd: 3, columnStart: 0, columnEnd: 1 }; + expect(selectionChangeSpy).toHaveBeenCalledTimes(2); + expect(selectionChangeSpy).toHaveBeenCalledWith(range); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 2, 1, 2); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 3, 0, 1); + }); + + it('Should select correct cells with Ctrl key and mouse drag', () => { + const range = { rowStart: 3, rowEnd: 2, columnStart: 'Name', columnEnd: 'ParentID' }; + const firstCell = grid.gridAPI.get_cell_by_index(1, 'ParentID'); + const secondCell = grid.gridAPI.get_cell_by_index(1, 'ID'); + const thirdCell = grid.gridAPI.get_cell_by_index(2, 'ParentID'); + const expectedData = [ + { ParentID: 147, Name: 'Monica Reyes' }, + { ParentID: 847, Name: 'Laurence Johnson' }, + { ParentID: 147 } + ]; + grid.selectRange(range); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 3, 1, 2); + GridSelectionFunctions.verifySelectedRange(grid, 2, 3, 1, 2); + + UIInteractions.simulatePointerOverElementEvent('pointerdown', firstCell.nativeElement, false, true); + detect(); + + expect(firstCell.active).toBe(true); + GridSelectionFunctions.verifySelectedRange(grid, 2, 3, 1, 2); + + UIInteractions.simulatePointerOverElementEvent('pointerenter', secondCell.nativeElement, false, true); + detect(); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 3, 1, 2); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 1, 0, 1); + GridSelectionFunctions.verifySelectedRange(grid, 2, 3, 1, 2); + + UIInteractions.simulatePointerOverElementEvent('pointerenter', thirdCell.nativeElement, false, true); + detect(); + + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 3, 1, 2); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 2, 1, 1); + GridSelectionFunctions.verifySelectedRange(grid, 2, 3, 1, 2); + + UIInteractions.simulatePointerOverElementEvent('pointerup', thirdCell.nativeElement); + detect(); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 3, 1, 2); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 2, 1, 1); + GridSelectionFunctions.verifyCellSelected(secondCell, false); + GridSelectionFunctions.verifySelectedRange(grid, 2, 3, 1, 2, 0, 2); + GridSelectionFunctions.verifySelectedRange(grid, 1, 2, 1, 1, 1, 2); + expect(grid.getSelectedData()).toEqual(expectedData); + }); + + it('Should be able to select multiple cells with Ctrl key and mouse click', () => { + const firstCell = grid.gridAPI.get_cell_by_index(1, 'ParentID'); + const secondCell = grid.gridAPI.get_cell_by_index(2, 'Name'); + const thirdCell = grid.gridAPI.get_cell_by_index(0, 'ID'); + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifyCellSelected(firstCell); + expect(grid.selectedCells.length).toBe(1); + + UIInteractions.simulateClickAndSelectEvent(secondCell, false, true); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifyCellSelected(firstCell); + GridSelectionFunctions.verifyCellSelected(secondCell); + expect(grid.selectedCells.length).toBe(2); + + UIInteractions.simulateClickAndSelectEvent(thirdCell, false, true); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifyCellSelected(firstCell); + GridSelectionFunctions.verifyCellSelected(secondCell); + GridSelectionFunctions.verifyCellSelected(thirdCell); + expect(grid.selectedCells.length).toBe(3); + expect(grid.getSelectedData()).toEqual([{ ParentID: 147 }, { Name: 'Monica Reyes' }, { ID: 475 }]); + GridSelectionFunctions.verifySelectedRange(grid, 1, 1, 1, 1, 0, 3); + GridSelectionFunctions.verifySelectedRange(grid, 2, 2, 2, 2, 1, 3); + GridSelectionFunctions.verifySelectedRange(grid, 0, 0, 0, 0, 2, 3); + }); + + it('Should be able to select cells correctly when focus is returned to the grid', async() => { + const firstCell = grid.gridAPI.get_cell_by_index(1, 'ParentID'); + const secondCell = grid.gridAPI.get_cell_by_index(2, 'Name'); + + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellSelected(firstCell); + expect(grid.selectedCells.length).toBe(1); + + UIInteractions.simulateClickAndSelectEvent(firstCell, false, true); + fix.detectChanges(); + + expect(grid.selectedCells.length).toBe(0); + + grid.navigation.lastActiveNode = grid.navigation.activeNode; + grid.navigation.activeNode = null; + fix.detectChanges(); + grid.tbody.nativeElement.focus(); + fix.detectChanges(); + + UIInteractions.simulateClickAndSelectEvent(secondCell, false, true); + fix.detectChanges(); + GridSelectionFunctions.verifyCellSelected(firstCell, false); + GridSelectionFunctions.verifyCellSelected(secondCell, true); + expect(grid.selectedCells.length).toBe(1); + }); + + it('Should not trigger range selection when CellTemplate is used and the user clicks on element inside it', () => { + fix = TestBed.createComponent(IgxGridRowEditingWithoutEditableColumnsComponent); + fix.detectChanges(); + + const component = fix.componentInstance; + grid = fix.componentInstance.grid; + + expect(component.customCell).toBeDefined(); + + const column = grid.getColumnByName('ProductID'); + column.bodyTemplate = component.customCell; + fix.detectChanges(); + + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const cell = grid.gridAPI.get_cell_by_index(1, 'ProductID'); + const cellElement = cell.nativeElement; + const span = cellElement.querySelector('span'); + + expect(span).not.toBeNull(); + + UIInteractions.simulateClickAndSelectEvent(span); + fix.detectChanges(); + expect(selectionChangeSpy).not.toHaveBeenCalled(); + }); + + it('Should be able to select range when click on a cell and hold Shift key and click on another Cell', () => { + const firstCell = grid.gridAPI.get_cell_by_index(3, 'HireDate'); + const secondCell = grid.gridAPI.get_cell_by_index(1, 'ID'); + const thirdCell = grid.gridAPI.get_cell_by_index(0, 'Name'); + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + + GridSelectionFunctions.selectCellsRangeWithShiftKeyNoWait(fix, firstCell, secondCell); + expect(grid.selectedCells.length).toBe(12); + let range = { rowStart: 1, rowEnd: 3, columnStart: 0, columnEnd: 3 }; + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + expect(selectionChangeSpy).toHaveBeenCalledWith(range); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 3, 0, 3); + GridSelectionFunctions.verifySelectedRange(grid, 1, 3, 0, 3); + + UIInteractions.simulateClickAndSelectEvent(thirdCell, true); + fix.detectChanges(); + + expect(grid.selectedCells.length).toBe(8); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 0, 3, 2, 3); + range = { rowStart: 0, rowEnd: 3, columnStart: 2, columnEnd: 3 }; + expect(selectionChangeSpy).toHaveBeenCalledTimes(2); + expect(selectionChangeSpy).toHaveBeenCalledWith(range); + GridSelectionFunctions.verifySelectedRange(grid, 0, 3, 2, 3); + }); + + it('Should return correct ranges from `getSelectedRanges` on shfit + click in the event handler', () => { + const firstCell = grid.gridAPI.get_cell_by_index(3, 'HireDate'); + const secondCell = grid.gridAPI.get_cell_by_index(1, 'ID'); + + const sub = grid.rangeSelected.subscribe(_ => { + expect(grid.selectedCells.length).toEqual(12); + const range = grid.getSelectedRanges()[0]; + GridSelectionFunctions.verifySelectedRange(grid, range.rowStart, range.rowEnd, range.columnStart, range.columnEnd); + GridSelectionFunctions.verifySelectedRange(grid, 1, 3, 0, 3); + }); + GridSelectionFunctions.selectCellsRangeWithShiftKeyNoWait(fix, firstCell, secondCell); + sub.unsubscribe(); + }); + + it('Should be able to select range with Shift key when first cell is not visible', (async () => { + const firstCell = grid.gridAPI.get_cell_by_index(1, 'ID'); + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const expectedData1 = [ + { ID: 957, ParentID: 147 }, + { ID: 317, ParentID: 147 }, + { ID: 225, ParentID: 847 }, + { ID: 663, ParentID: 847 }, + { ID: 15, ParentID: 19 }, + { ID: 12, ParentID: 17 }, + { ID: 101, ParentID: 17 } + ]; + + const expectedData2 = [ + { ID: 957, ParentID: 147, Name: 'Thomas Hardy' }, + { ID: 317, ParentID: 147, Name: 'Monica Reyes' }, + { ID: 225, ParentID: 847, Name: 'Laurence Johnson' }, + { ID: 663, ParentID: 847, Name: 'Elizabeth Richards' }, + { ID: 15, ParentID: 19, Name: 'Antonio Moreno' }, + { ID: 12, ParentID: 17, Name: 'Pedro Afonso' } + ]; + + UIInteractions.simulateClickAndSelectEvent(firstCell); + await wait(); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellSelected(firstCell); + + grid.verticalScrollContainer.scrollTo(grid.dataView.length - 1); + await wait(100); + fix.detectChanges(); + + const secondCell = grid.gridAPI.get_cell_by_index(7, 'ParentID'); + UIInteractions.simulateClickAndSelectEvent(secondCell, true); + await wait(); + fix.detectChanges(); + + let range = { rowStart: 1, rowEnd: 7, columnStart: 0, columnEnd: 1 }; + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + expect(grid.getSelectedData()).toEqual(expectedData1); + expect(selectionChangeSpy).toHaveBeenCalledWith(range); + expect(grid.getSelectedRanges()).toEqual([range]); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 3, 7, 0, 1); + + const thirdCell = grid.gridAPI.get_cell_by_index(6, 'Name'); + UIInteractions.simulateClickAndSelectEvent(thirdCell, true); + await wait(); + fix.detectChanges(); + + range = { rowStart: 1, rowEnd: 6, columnStart: 0, columnEnd: 2 }; + expect(selectionChangeSpy).toHaveBeenCalledTimes(2); + expect(selectionChangeSpy).toHaveBeenCalledWith(range); + expect(grid.getSelectedRanges()).toEqual([range]); + expect(grid.getSelectedData()).toEqual(expectedData2); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 3, 6, 0, 2); + + grid.verticalScrollContainer.scrollTo(0); + await wait(100); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 4, 0, 2); + expect(selectionChangeSpy).toHaveBeenCalledTimes(2); + expect(grid.getSelectedData()).toEqual(expectedData2); + })); + + it('Should update range selection when hold a Ctrl key and click on another cell', () => { + const firstCell = grid.gridAPI.get_cell_by_index(2, 'ID'); + const secondCell = grid.gridAPI.get_cell_by_index(0, 'ParentID'); + const thirdCell = grid.gridAPI.get_cell_by_index(0, 'Name'); + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const expectedData1 = [ + { ID: 475, ParentID: 147 }, + { ID: 957, ParentID: 147 }, + { ID: 317, ParentID: 147 } + ]; + const expectedData2 = [ + { ID: 475, ParentID: 147, Name: 'Michael Langdon' }, + { ID: 957 }, + { ID: 317, ParentID: 147 } + ]; + + GridSelectionFunctions.selectCellsRangeWithShiftKeyNoWait(fix, firstCell, secondCell); + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + expect(selectionChangeSpy).toHaveBeenCalledWith({ rowStart: 0, rowEnd: 2, columnStart: 0, columnEnd: 1 }); + expect(grid.getSelectedData()).toEqual(expectedData1); + GridSelectionFunctions.verifySelectedRange(grid, 0, 2, 0, 1); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 0, 2, 0, 1); + + // Click on another cell holding control + UIInteractions.simulateClickAndSelectEvent(thirdCell, false, true); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifySelectedRange(grid, 0, 2, 0, 1, 0, 2); + GridSelectionFunctions.verifySelectedRange(grid, 0, 0, 2, 2, 1, 2); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 0, 2, 0, 1); + GridSelectionFunctions.verifyCellSelected(thirdCell); + + // Click on a cell in the region and verify it is deselected + let cell = grid.gridAPI.get_cell_by_index(1, 'ParentID'); + UIInteractions.simulateClickAndSelectEvent(cell, false, true); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellsRegionSelected(grid, 0, 0, 0, 2); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 2, 0, 1); + GridSelectionFunctions.verifyCellSelected(cell, false); + GridSelectionFunctions.verifyCellSelected(grid.gridAPI.get_cell_by_index(1, 'ID'), true); + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + expect(grid.getSelectedData()).toEqual(expectedData2); + + // Click on a cell without holding Ctrl + cell = grid.gridAPI.get_cell_by_index(0, 'ID'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + expect(grid.getSelectedData()).toEqual([{ ID: 475 }]); + GridSelectionFunctions.verifySelectedRange(grid, 0, 0, 0, 0); + GridSelectionFunctions.verifyCellSelected(cell); + GridSelectionFunctions.verifyCellSelected(firstCell, false); + GridSelectionFunctions.verifyCellSelected(secondCell, false); + GridSelectionFunctions.verifyCellSelected(thirdCell, false); + }); + + it('Should not be possible to select a range when change cellSelection to none', () => { + const rangeChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const startCell = grid.gridAPI.get_cell_by_index(0, 'Name'); + const endCell = grid.gridAPI.get_cell_by_index(2, 'ParentID'); + + expect(grid.cellSelection).toEqual(GridSelectionMode.multiple); + GridSelectionFunctions.selectCellsRangeNoWait(fix, startCell, endCell); + detect(); + + expect(rangeChangeSpy).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 0, 2, 1, 2); + GridSelectionFunctions.verifySelectedRange(grid, 0, 2, 1, 2); + + grid.cellSelection = GridSelectionMode.none; + fix.detectChanges(); + + GridSelectionFunctions.verifyCellsRegionSelected(grid, 0, 2, 1, 2, false); + expect(grid.getSelectedData()).toEqual([]); + expect(grid.getSelectedRanges()).toEqual([]); + + // Try to select a range + GridSelectionFunctions.selectCellsRangeNoWait(fix, startCell, endCell); + detect(); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 0, 2, 1, 2, false); + expect(rangeChangeSpy).toHaveBeenCalledTimes(1); + expect(grid.selectedCells.length).toBe(0); + expect(grid.getSelectedData().length).toBe(1); + expect(grid.getSelectedRanges()).toEqual([]); + }); + + it('Should not be possible to select a range when change cellSelection to single', () => { + const rangeChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const startCell = grid.gridAPI.get_cell_by_index(0, 'ID'); + const endCell = grid.gridAPI.get_cell_by_index(1, 'ParentID'); + + expect(grid.cellSelection).toEqual(GridSelectionMode.multiple); + GridSelectionFunctions.selectCellsRangeNoWait(fix, startCell, endCell); + detect(); + + expect(rangeChangeSpy).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 0, 1, 0, 1); + GridSelectionFunctions.verifySelectedRange(grid, 0, 1, 0, 1); + + grid.cellSelection = GridSelectionMode.single; + fix.detectChanges(); + + expect(grid.cellSelection).toEqual(GridSelectionMode.single); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 0, 1, 0, 1, false); + expect(grid.getSelectedData()).toEqual([]); + expect(grid.getSelectedRanges()).toEqual([]); + + // Try to select a range + UIInteractions.simulatePointerOverElementEvent('pointerdown', endCell.nativeElement); + endCell.nativeElement.dispatchEvent(new MouseEvent('click')); + fix.detectChanges(); + + UIInteractions.simulatePointerOverElementEvent('pointerenter', startCell.nativeElement); + UIInteractions.simulatePointerOverElementEvent('pointerup', startCell.nativeElement); + fix.detectChanges(); + detect(); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 0, 0, 0, 1, false); + expect(rangeChangeSpy).toHaveBeenCalledTimes(1); + expect(grid.selectedCells.length).toBe(1); + expect(grid.getSelectedData()).toEqual([{ ParentID: 147 }]); + GridSelectionFunctions.verifySelectedRange(grid, 1, 1, 1, 1); + }); + }); + + describe('API', () => { + let fix; + let grid; + let detect; + + beforeEach(() => { + fix = TestBed.createComponent(SelectionWithScrollsComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + detect = () => grid.cdr.detectChanges(); + }); + + it('Should select a single cell', () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const range = { rowStart: 2, rowEnd: 2, columnStart: 1, columnEnd: 1 }; + const cell = grid.gridAPI.get_cell_by_index(2, 'ParentID'); + const expectedData = [ + { ParentID: 147 } + ]; + grid.selectRange(range); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellSelected(cell); + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + expect(grid.getSelectedData()).toEqual(expectedData); + expect(grid.getSelectedRanges()).toEqual([range]); + }); + + it('Should select a region', () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const range = { rowStart: 0, rowEnd: 2, columnStart: 'Name', columnEnd: 'ParentID' }; + const expectedData = [ + { ParentID: 147, Name: 'Michael Langdon' }, + { ParentID: 147, Name: 'Thomas Hardy' }, + { ParentID: 147, Name: 'Monica Reyes' } + ]; + grid.selectRange(range); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellsRegionSelected(grid, 0, 2, 1, 2); + GridSelectionFunctions.verifySelectedRange(grid, 0, 2, 1, 2); + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + expect(grid.getSelectedData()).toEqual(expectedData); + }); + + it('Should select a region when one of cells is not visible', (async () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const range = { rowStart: 3, rowEnd: 7, columnStart: 'ID', columnEnd: 'ParentID' }; + const expectedData = [ + { ID: 225, ParentID: 847 }, + { ID: 663, ParentID: 847 }, + { ID: 15, ParentID: 19 }, + { ID: 12, ParentID: 17 }, + { ID: 101, ParentID: 17 } + ]; + + grid.selectRange(range); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellsRegionSelected(grid, 3, 4, 0, 1); + GridSelectionFunctions.verifySelectedRange(grid, 3, 7, 0, 1); + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + expect(grid.getSelectedData()).toEqual(expectedData); + + grid.verticalScrollContainer.scrollTo(grid.dataView.length - 1); + await wait(100); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellsRegionSelected(grid, 4, 7, 0, 1); + GridSelectionFunctions.verifySelectedRange(grid, 3, 7, 0, 1); + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + expect(grid.getSelectedData()).toEqual(expectedData); + })); + + it('Should select a region when two of cells are not visible', (async () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const range = { rowStart: 6, rowEnd: 6, columnStart: 'OnPTO', columnEnd: 'Age' }; + const expectedData = [ + { Age: 50, OnPTO: false } + ]; + + grid.selectRange(range); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(grid, 6, 6, 4, 5); + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + expect(grid.getSelectedData()).toEqual(expectedData); + + grid.verticalScrollContainer.scrollTo(grid.dataView.length - 1); + await wait(100); + fix.detectChanges(); + + grid.dataRowList.first.virtDirRow.scrollTo(5); + await wait(100); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(grid, 6, 6, 4, 5); + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + expect(grid.getSelectedData()).toEqual(expectedData); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 6, 6, 4, 5); + })); + + it('Should add new range when there is already added range', () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const range1 = { rowStart: 0, rowEnd: 1, columnStart: 'ID', columnEnd: 'ParentID' }; + const range2 = { rowStart: 1, rowEnd: 2, columnStart: 'ParentID', columnEnd: 'Name' }; + const expectedData1 = [ + { ID: 475, ParentID: 147 }, + { ID: 957, ParentID: 147 } + ]; + const expectedData2 = [ + { ID: 475, ParentID: 147 }, + { ID: 957, ParentID: 147, Name: 'Thomas Hardy' }, + { ParentID: 147, Name: 'Monica Reyes' } + ]; + + grid.selectRange(range1); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(grid, 0, 1, 0, 1); + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + expect(grid.getSelectedData()).toEqual(expectedData1); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 0, 1, 0, 1); + + grid.selectRange(range2); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(grid, 0, 1, 0, 1, 0, 2); + GridSelectionFunctions.verifySelectedRange(grid, 1, 2, 1, 2, 1, 2); + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + expect(grid.getSelectedData()).toEqual(expectedData2); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 0, 1, 0, 1); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 2, 1, 2); + }); + + it('Should add multiple ranges', () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const range1 = { rowStart: 0, rowEnd: 0, columnStart: 'ID', columnEnd: 'ParentID' }; + const range2 = { rowStart: 2, rowEnd: 3, columnStart: 'ParentID', columnEnd: 'Name' }; + const expectedData = [ + { ID: 475, ParentID: 147 }, + { ParentID: 147, Name: 'Monica Reyes' }, + { ParentID: 847, Name: 'Laurence Johnson' } + ]; + + grid.selectRange([range1, range2]); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(grid, 0, 0, 0, 1, 0, 2); + GridSelectionFunctions.verifySelectedRange(grid, 2, 3, 1, 2, 1, 2); + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + expect(grid.getSelectedData()).toEqual(expectedData); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 0, 0, 0, 1); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 3, 1, 2); + }); + + it('Should add multiple ranges when they have same cells', () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const range1 = { rowStart: 1, rowEnd: 3, columnStart: 'ID', columnEnd: 'ParentID' }; + const range2 = { rowStart: 3, rowEnd: 1, columnStart: 'ParentID', columnEnd: 'ID' }; + const expectedData = [ + { ID: 957, ParentID: 147 }, + { ID: 317, ParentID: 147 }, + { ID: 225, ParentID: 847 } + ]; + + grid.selectRange([range1, range2]); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(grid, 1, 3, 0, 1); + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + expect(grid.getSelectedData()).toEqual(expectedData); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 3, 0, 1); + }); + + it('Should add multiple ranges when some of their cells are same', () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const range1 = { rowStart: 1, rowEnd: 3, columnStart: 'ID', columnEnd: 'ParentID' }; + const range2 = { rowStart: 4, rowEnd: 2, columnStart: 'ParentID', columnEnd: 'ID' }; + const expectedData = [ + { ID: 957, ParentID: 147 }, + { ID: 317, ParentID: 147 }, + { ID: 225, ParentID: 847 }, + { ID: 663, ParentID: 847 } + ]; + + grid.selectRange([range1, range2]); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(grid, 1, 3, 0, 1, 0, 2); + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 0, 1, 1, 2); + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + expect(grid.getSelectedData()).toEqual(expectedData); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 4, 0, 1); + }); + + it('Should not add range when column is hidden', () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const range = { rowStart: 1, rowEnd: 3, columnStart: 'ID', columnEnd: 'Name' }; + grid.getColumnByName('Name').hidden = true; + fix.detectChanges(); + + let errorMessage = ''; + try { + grid.selectRange(range); + } catch (error) { + errorMessage = error.message; + } finally { + fix.detectChanges(); + } + expect(errorMessage).toContain('visibleIndex'); + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + expect(grid.getSelectedData()).toEqual([]); + expect(grid.getSelectedRanges()).toEqual([]); + }); + + it('Should not add range when column is hidden and there is already selected range', () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const range1 = { rowStart: 1, rowEnd: 2, columnStart: 'ID', columnEnd: 'Name' }; + const range2 = { rowStart: 0, rowEnd: 4, columnStart: 'ParentID', columnEnd: 'OnPTO' }; + const expectedData = [ + { ID: 957, Name: 'Thomas Hardy' }, + { ID: 317, Name: 'Monica Reyes' } + ]; + grid.getColumnByName('ParentID').hidden = true; + fix.detectChanges(); + + grid.selectRange(range1); + fix.detectChanges(); + GridSelectionFunctions.verifySelectedRange(grid, 1, 2, 0, 1); + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + expect(grid.getSelectedData()).toEqual(expectedData); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 2, 0, 1); + + let errorMessage = ''; + try { + grid.selectRange(range2); + } catch (error) { + errorMessage = error.message; + } finally { + fix.detectChanges(); + } + expect(errorMessage).toContain('visibleIndex'); + GridSelectionFunctions.verifySelectedRange(grid, 1, 2, 0, 1); + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + expect(grid.getSelectedData()).toEqual(expectedData); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 2, 0, 1); + }); + + it('Should not add range when column does not exist', () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const range = { rowStart: 1, rowEnd: 3, columnStart: 'NotExisting', columnEnd: 'Name' }; + + let errorMessage = ''; + try { + grid.selectRange(range); + } catch (error) { + errorMessage = error.message; + } finally { + fix.detectChanges(); + } + expect(errorMessage).toContain('visibleIndex'); + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + expect(grid.getSelectedData()).toEqual([]); + expect(grid.getSelectedRanges()).toEqual([]); + }); + + it('Should add range when row does not exist', () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const range = { rowStart: -7, rowEnd: 100, columnStart: 'ID', columnEnd: 'ID' }; + const expectedData = [ + { ID: 475 }, + { ID: 957 }, + { ID: 317 }, + { ID: 225 }, + { ID: 663 }, + { ID: 15 }, + { ID: 12 }, + { ID: 101 } + ]; + + grid.selectRange(range); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifySelectedRange(grid, -7, 100, 0, 0); + expect(grid.getSelectedData()).toEqual(expectedData); + }); + + it('Should add range when columnStart index does not exist', () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const range = { rowStart: 0, rowEnd: 1, columnStart: -4, columnEnd: 0 }; + const expectedData = [ + { ID: 475 }, + { ID: 957 } + ]; + + grid.selectRange(range); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifySelectedRange(grid, 0, 1, -4, 0); + expect(grid.getSelectedData()).toEqual(expectedData); + }); + + it('Should add range when columnStart and columnEnd indexes do not exist', () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const range = { rowStart: 1, rowEnd: 2, columnStart: 5, columnEnd: 10 }; + const expectedData = [ + { OnPTO: true }, + { OnPTO: false } + ]; + + grid.selectRange(range); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifySelectedRange(grid, 1, 2, 5, 10); + expect(grid.getSelectedData()).toEqual(expectedData); + }); + + it('Should not add range when columnStart and columnEnd indexes do not exist', () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const range = { rowStart: 1, rowEnd: 2, columnStart: 10, columnEnd: 100 }; + + grid.selectRange(range); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifySelectedRange(grid, 1, 2, 10, 100); + expect(grid.getSelectedData()).toEqual([]); + }); + + it('Should be able to clear the selected ranges', () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const range = { rowStart: 1, rowEnd: 2, columnStart: 1, columnEnd: 2 }; + const expectedData = [ + { ParentID: 147, Name: 'Thomas Hardy' }, + { ParentID: 147, Name: 'Monica Reyes' } + ]; + grid.selectRange(range); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifySelectedRange(grid, 1, 2, 1, 2); + expect(grid.getSelectedData()).toEqual(expectedData); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 2, 1, 2); + + grid.selectRange(); + fix.detectChanges(); + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + expect(grid.getSelectedRanges().length).toEqual(0); + expect(grid.getSelectedData()).toEqual([]); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 2, 1, 2, false); + }); + + it('Should be able to clear the selection when a single cell is selected', () => { + const cell = grid.gridAPI.get_cell_by_index(1, 'ParentID'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellSelected(cell); + GridSelectionFunctions.verifySelectedRange(grid, 1, 1, 1, 1); + + grid.selectRange(null); + fix.detectChanges(); + expect(grid.getSelectedRanges().length).toEqual(0); + expect(grid.getSelectedData()).toEqual([]); + GridSelectionFunctions.verifyCellSelected(cell, false); + }); + + + it('Should be able to clear the selection when there are no selected cells', () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + grid.selectRange(); + fix.detectChanges(); + expect(grid.getSelectedRanges().length).toEqual(0); + expect(grid.getSelectedData()).toEqual([]); + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + }); + + it('Should return correct selected data when selected event is emitted', () => { + let selectedData = []; + grid.selected.subscribe(() => { + selectedData = grid.getSelectedData(); + }); + + const cell = grid.gridAPI.get_cell_by_index(2, 'Name'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + expect(selectedData.length).toBe(1); + expect(selectedData[0]).toEqual({ Name: 'Monica Reyes' }); + + const idCell = grid.gridAPI.get_cell_by_index(1, 'ID'); + UIInteractions.simulateClickAndSelectEvent(idCell, false, true); + fix.detectChanges(); + + expect(selectedData.length).toBe(2); + expect(selectedData[0]).toEqual({ Name: 'Monica Reyes' }); + expect(selectedData[1]).toEqual({ ID: 957 }); + }); + + it('rangeSelected event should be emitted when pointer leaves active state outside grid\'s cells', () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const startCell = grid.gridAPI.get_cell_by_index(2, 'ParentID'); + const range = { rowStart: 2, rowEnd: 3, columnStart: 0, columnEnd: 1 }; + + UIInteractions.simulatePointerOverElementEvent('pointerdown', startCell.nativeElement); + detect(); + + expect(startCell.active).toBe(true); + + for (let i = 3; i < 5; i++) { + const cell = grid.gridAPI.get_cell_by_index(i, grid.columnList.get(i - 1).field); + UIInteractions.simulatePointerOverElementEvent('pointerenter', cell.nativeElement); + } + + for (let i = 3; i >= 0; i--) { + const cell = grid.gridAPI.get_cell_by_index(i, 'HireDate'); + UIInteractions.simulatePointerOverElementEvent('pointerenter', cell.nativeElement); + } + + for (let i = 2; i >= 0; i--) { + const cell = grid.gridAPI.get_cell_by_index(0, grid.columnList.get(i).field); + UIInteractions.simulatePointerOverElementEvent('pointerenter', cell.nativeElement); + } + + for (let i = 1; i < 4; i++) { + const cell = grid.gridAPI.get_cell_by_index(i, 'ID'); + UIInteractions.simulatePointerOverElementEvent('pointerenter', cell.nativeElement); + } + + UIInteractions.simulatePointerOverElementEvent('pointerup', document.body); + detect(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + expect(selectionChangeSpy).toHaveBeenCalledWith(range); + }); + + it('Should not throw an error when trying to do a drag selection that is started outside the grid', fakeAsync(() => { + const cell = grid.gridAPI.get_cell_by_index(1, 'ParentID'); + + UIInteractions.simulatePointerOverElementEvent('pointerdown', document.body); + tick(); + fix.detectChanges(); + + UIInteractions.simulatePointerOverElementEvent('pointerenter', cell.nativeElement); + UIInteractions.simulatePointerOverElementEvent('pointerup', cell.nativeElement); + tick(); + fix.detectChanges(); + + expect(() => { + fix.detectChanges(); + }).not.toThrow(); + })); + }); + + describe('Keyboard navigation', () => { + let fix: ComponentFixture; + let grid; + let detect; + let gridContent: DebugElement; + + beforeEach(() => { + fix = TestBed.createComponent(SelectionWithScrollsComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + gridContent = GridFunctions.getGridContent(fix); + setupGridScrollDetection(fix, grid); + detect = () => grid.cdr.detectChanges(); + }); + + afterEach(() => { + clearGridSubs(); + }); + + it('Should be able to select a range with arrow keys and holding Shift', () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + let cell = grid.gridAPI.get_cell_by_index(1, 'ParentID'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + GridSelectionFunctions.verifyCellSelected(cell); + GridSelectionFunctions.verifySelectedRange(grid, 1, 1, 1, 1); + + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', cell.nativeElement, true, false, true); + + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 2, 1, 1); + + cell = grid.gridAPI.get_cell_by_index(2, 'ParentID'); + UIInteractions.triggerKeyDownEvtUponElem('arrowright', cell.nativeElement, true, false, true); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(2); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 2, 1, 2); + cell = grid.gridAPI.get_cell_by_index(2, 'Name'); + UIInteractions.triggerKeyDownEvtUponElem('arrowup', cell.nativeElement, true, false, true); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 1, 1, 2); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 2, 1, 3, false); + cell = grid.gridAPI.get_cell_by_index(1, 'Name'); + UIInteractions.triggerKeyDownEvtUponElem('arrowleft', cell.nativeElement, true, false, true); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(4); + GridSelectionFunctions.verifyCellSelected(cell, false); + cell = grid.gridAPI.get_cell_by_index(1, 'ParentID'); + GridSelectionFunctions.verifyCellSelected(cell); + + UIInteractions.triggerKeyDownEvtUponElem('arrowleft', cell.nativeElement, true, false, true); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(5); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 1, 0, 1); + GridSelectionFunctions.verifySelectedRange(grid, 1, 1, 0, 1); + expect(grid.getSelectedData()).toEqual([{ ID: 957, ParentID: 147 }]); + }); + + it(`Should not clear selection from keyboard shift-state on non-primary click`, () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + let cell = grid.gridAPI.get_cell_by_index(1, 'ParentID'); + + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + GridSelectionFunctions.verifyCellSelected(cell); + GridSelectionFunctions.verifySelectedRange(grid, 1, 1, 1, 1); + + UIInteractions.triggerEventHandlerKeyDown('arrowdown', gridContent, false, true); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 2, 1, 1); + + cell = grid.gridAPI.get_cell_by_index(2, 'ParentID'); + UIInteractions.triggerEventHandlerKeyDown('arrowright', gridContent, false, true); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(2); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 2, 1, 2); + + UIInteractions.simulateNonPrimaryClick(cell); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(2); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 2, 1, 2); + }); + + it(`Should not clear range when try to navigate out the grid with shift + + arrrow keys and then click on other cell with pressed Ctrl'`, () => { + pending('# Issue should be fixedy'); + let cell = grid.gridAPI.get_cell_by_index(0, 'ID'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + GridSelectionFunctions.verifyCellSelected(cell); + GridSelectionFunctions.verifySelectedRange(grid, 0, 0, 0, 0); + + UIInteractions.triggerKeyDownEvtUponElem('arrowup', cell.nativeElement, true, false, true); + fix.detectChanges(); + UIInteractions.triggerKeyDownEvtUponElem('arrowleft', cell.nativeElement, true, false, true); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellSelected(cell); + GridSelectionFunctions.verifySelectedRange(grid, 0, 0, 0, 0); + + cell = grid.gridAPI.get_cell_by_index(3, 'ParentID'); + UIInteractions.simulateClickAndSelectEvent(cell, false, true); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellSelected(cell); + GridSelectionFunctions.verifySelectedRange(grid, 0, 0, 0, 0, 0, 2); + GridSelectionFunctions.verifySelectedRange(grid, 3, 3, 1, 1, 1, 2); + }); + + it('Should be able to select and move scroll with arrow keys and holding Shift', (async () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + let cell = grid.gridAPI.get_cell_by_index(1, 'Name'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellSelected(cell); + for (let i = 3; i < 6; i++) { + cell = grid.gridAPI.get_cell_by_index(1, grid.columnList.get(i - 1).field); + UIInteractions.triggerEventHandlerKeyDown('arrowright', gridContent, false, true); + await wait(100); + fix.detectChanges(); + } + + expect(selectionChangeSpy).toHaveBeenCalledTimes(3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 1, 2, 5); + for (let i = 1; i < 6; i++) { + cell = grid.gridAPI.get_cell_by_index(i, 'OnPTO'); + UIInteractions.triggerEventHandlerKeyDown('arrowdown', gridContent, false, true); + await wait(100); + fix.detectChanges(); + } + + expect(selectionChangeSpy).toHaveBeenCalledTimes(8); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 3, 6, 2, 5); + for (let i = 7; i > 0; i--) { + cell = grid.gridAPI.get_cell_by_index(i, 'OnPTO'); + UIInteractions.triggerEventHandlerKeyDown('arrowup', gridContent, false, true); + await wait(100); + fix.detectChanges(); + } + + expect(selectionChangeSpy).toHaveBeenCalledTimes(14); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 0, 1, 2, 5); + for (let i = 5; i > 0; i--) { + cell = grid.gridAPI.get_cell_by_index(0, grid.columnList.get(i - 1).field); + UIInteractions.triggerEventHandlerKeyDown('arrowleft', gridContent, false, true); + await wait(100); + fix.detectChanges(); + } + + expect(selectionChangeSpy).toHaveBeenCalledTimes(19); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 0, 1, 1, 2); + GridSelectionFunctions.verifySelectedRange(grid, 0, 1, 0, 2); + })); + + it('Should not fire event when no new cells are selected', (async () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + let cell = grid.gridAPI.get_cell_by_index(0, 'ID'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + GridSelectionFunctions.verifyCellSelected(cell); + + UIInteractions.triggerKeyDownEvtUponElem('arrowup', cell.nativeElement, true, false, true); + await wait(50); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 0, 0, 0, 0); + GridSelectionFunctions.verifySelectedRange(grid, 0, 0, 0, 0); + + UIInteractions.triggerKeyDownEvtUponElem('arrowleft', cell.nativeElement, true, false, true); + await wait(50); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 0, 0, 0, 0); + GridSelectionFunctions.verifySelectedRange(grid, 0, 0, 0, 0); + + grid.verticalScrollContainer.scrollTo(grid.dataView.length - 1); + await wait(100); + fix.detectChanges(); + + grid.dataRowList.first.virtDirRow.scrollTo(5); + await wait(100); + fix.detectChanges(); + + cell = grid.gridAPI.get_cell_by_index(7, 'OnPTO'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellSelected(cell); + GridSelectionFunctions.verifySelectedRange(grid, 7, 7, 5, 5); + + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', cell.nativeElement, true, false, true); + await wait(50); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 7, 7, 5, 5); + + UIInteractions.triggerKeyDownEvtUponElem('arrowright', cell.nativeElement, true, false, true); + await wait(50); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifySelectedRange(grid, 7, 7, 5, 5); + })); + + it('Should select cells when select region with keyboard and then click on a cell holding Ctrl', (async () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const firstCell = grid.gridAPI.get_cell_by_index(2, 'Name'); + const secondCell = grid.gridAPI.get_cell_by_index(3, 'ParentID'); + const expectedData = [ + { Name: 'Thomas Hardy' }, + { Name: 'Monica Reyes' }, + { ParentID: 847 } + ]; + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + GridSelectionFunctions.verifyCellSelected(firstCell); + + UIInteractions.triggerKeyDownEvtUponElem('arrowup', firstCell.nativeElement, true, false, true); + await wait(50); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 2, 2, 2); + + UIInteractions.simulateClickAndSelectEvent(secondCell, false, true); + fix.detectChanges(); + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + expect(grid.getSelectedData()).toEqual(expectedData); + GridSelectionFunctions.verifyCellSelected(secondCell); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 2, 2, 2); + GridSelectionFunctions.verifySelectedRange(grid, 1, 2, 2, 2, 0, 2); + GridSelectionFunctions.verifySelectedRange(grid, 3, 3, 1, 1, 1, 2); + })); + + it('Should correct range when navigate with the keyboard and click on another cell with Shift key', (async () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const firstCell = grid.gridAPI.get_cell_by_index(1, 'ID'); + const secondCell = grid.gridAPI.get_cell_by_index(2, 'Name'); + const expectedData = [ + { ParentID: 147, Name: 'Thomas Hardy' }, + { ParentID: 147, Name: 'Monica Reyes' } + ]; + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + GridSelectionFunctions.verifyCellSelected(firstCell); + + UIInteractions.triggerKeyDownEvtUponElem('arrowright', firstCell.nativeElement, true, false, true); + await wait(50); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 1, 0, 1); + + UIInteractions.simulateClickAndSelectEvent(secondCell, true); + fix.detectChanges(); + expect(selectionChangeSpy).toHaveBeenCalledTimes(2); + GridSelectionFunctions.verifyCellSelected(firstCell, false); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 2, 1, 2); + GridSelectionFunctions.verifySelectedRange(grid, 1, 2, 1, 2); + expect(grid.getSelectedData()).toEqual(expectedData); + })); + + it('Should be able to navigate with the keyboard when a range is selected by dragging', (async () => { + const firstCell = grid.gridAPI.get_cell_by_index(1, 'ParentID'); + const secondCell = grid.gridAPI.get_cell_by_index(4, 'Name'); + const thirdCell = grid.gridAPI.get_cell_by_index(2, 'ParentID'); + + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + + await GridSelectionFunctions.selectCellsRange(fix, firstCell, secondCell); + detect(); + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifySelectedRange(grid, 1, 4, 1, 2); + expect(firstCell.focused); + + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', firstCell.nativeElement, true); + await wait(100); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifyCellSelected(thirdCell); + GridSelectionFunctions.verifySelectedRange(grid, 2, 2, 1, 1); + expect(grid.getSelectedData()).toEqual([{ ParentID: 147 }]); + })); + + it('Should be able to navigate with the keyboard when a range is selected by click ad holding ShiftKey', (async () => { + const firstCell = grid.gridAPI.get_cell_by_index(0, 'Name'); + const secondCell = grid.gridAPI.get_cell_by_index(2, 'ID'); + const thirdCell = grid.gridAPI.get_cell_by_index(2, 'ParentID'); + + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + + await GridSelectionFunctions.selectCellsRangeWithShiftKey(fix, firstCell, secondCell); + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifySelectedRange(grid, 0, 2, 0, 2); + expect(secondCell.focused); + + UIInteractions.triggerKeyDownEvtUponElem('arrowright', secondCell.nativeElement, true, false, true); + await wait(50); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(2); + GridSelectionFunctions.verifyCellSelected(thirdCell); + GridSelectionFunctions.verifyCellSelected(secondCell); + GridSelectionFunctions.verifyCellSelected(firstCell, false); + GridSelectionFunctions.verifySelectedRange(grid, 2, 2, 0, 1); + })); + + it('Should be able to navigate with the keyboard when a range is selected by click ad holding Ctrl', (async () => { + const firstCell = grid.gridAPI.get_cell_by_index(1, 'Name'); + const secondCell = grid.gridAPI.get_cell_by_index(2, 'HireDate'); + const thirdCell = grid.gridAPI.get_cell_by_index(1, 'HireDate'); + + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifyCellSelected(firstCell); + expect(grid.selectedCells.length).toBe(1); + + UIInteractions.simulateClickAndSelectEvent(secondCell, false, true); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifyCellSelected(firstCell); + GridSelectionFunctions.verifyCellSelected(secondCell); + expect(grid.selectedCells.length).toBe(2); + + UIInteractions.triggerKeyDownEvtUponElem('arrowup', secondCell.nativeElement, true, false, true); + await wait(50); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifyCellSelected(thirdCell); + GridSelectionFunctions.verifyCellSelected(firstCell, false); + GridSelectionFunctions.verifySelectedRange(grid, 1, 2, 3, 3); + })); + + it('Should handle Shift + Ctrl + Arrow Down keys combination', (async () => { + const firstCell = grid.gridAPI.get_cell_by_index(2, 'Name'); + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifyCellSelected(firstCell); + expect(grid.selectedCells.length).toBe(1); + + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', firstCell.nativeElement, true, false, true, true); + await wait(100); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifySelectedRange(grid, 2, 7, 2, 2); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 3, 7, 2, 2); + + const lastCell = grid.gridAPI.get_cell_by_index(7, 'Name'); + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', lastCell.nativeElement, true); + await wait(100); + fix.detectChanges(); + GridSelectionFunctions.verifyCellSelected(lastCell); + })); + + it('Should handle Shift + Ctrl + Arrow Up keys combination', (async () => { + const cell = grid.gridAPI.get_cell_by_index(4, 'ParentID'); + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifyCellSelected(cell); + expect(grid.selectedCells.length).toBe(1); + + UIInteractions.triggerKeyDownEvtUponElem('arrowup', cell.nativeElement, true, false, true, true); + await wait(100); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifySelectedRange(grid, 0, 4, 1, 1); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 0, 4, 1, 1); + })); + + it('Should handle Shift + Ctrl + Arrow Left keys combination', () => { + const firstCell = grid.gridAPI.get_cell_by_index(3, 'HireDate'); + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifyCellSelected(firstCell); + expect(grid.selectedCells.length).toBe(1); + + UIInteractions.triggerKeyDownEvtUponElem('arrowleft', firstCell.nativeElement, true, false, true, true); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifySelectedRange(grid, 3, 3, 0, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 3, 3, 0, 3); + }); + + it('Should handle Shift + Ctrl + Arrow Right keys combination', (async () => { + const firstCell = grid.gridAPI.get_cell_by_index(4, 'Name'); + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifyCellSelected(firstCell); + expect(grid.selectedCells.length).toBe(1); + + UIInteractions.triggerKeyDownEvtUponElem('arrowright', firstCell.nativeElement, true, false, true, true); + await wait(100); + fix.detectChanges(); + await wait(50); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifySelectedRange(grid, 4, 4, 2, 5); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 4, 4, 2, 5); + })); + + it('Should handle Shift + Ctrl + Home keys combination', (async () => { + const firstCell = grid.gridAPI.get_cell_by_index(3, 'HireDate'); + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifyCellSelected(firstCell); + expect(grid.selectedCells.length).toBe(1); + + UIInteractions.triggerKeyDownEvtUponElem('home', firstCell.nativeElement, true, false, true, true); + await wait(100); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifySelectedRange(grid, 0, 3, 0, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 0, 3, 0, 3); + + const lastCell = grid.gridAPI.get_cell_by_index(0, 'ID'); + UIInteractions.triggerKeyDownEvtUponElem('arrowup', lastCell.nativeElement, true); + await wait(100); + fix.detectChanges(); + GridSelectionFunctions.verifyCellSelected(lastCell); + })); + + it('Should handle Shift + Ctrl + End keys combination', (async () => { + const firstCell = grid.gridAPI.get_cell_by_index(2, 'ID'); + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + + UIInteractions.simulateClickAndSelectEvent(firstCell); + await wait(); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifyCellSelected(firstCell); + expect(grid.selectedCells.length).toBe(1); + + UIInteractions.triggerKeyDownEvtUponElem('end', firstCell.nativeElement, true, false, true, true); + await wait(200); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifySelectedRange(grid, 2, 7, 0, 5); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 3, 7, 2, 5); + })); + + it('Grouping: should select cells with arrow up and down keys', (async () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + grid.getColumnByName('ParentID').groupable = true; + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ParentID', dir: SortingDirection.Desc, + ignoreCase: false, strategy: DefaultSortingStrategy.instance() + }); + fix.detectChanges(); + + let cell = grid.gridAPI.get_cell_by_index(2, 'Name'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellSelected(cell); + expect(grid.selectedCells.length).toBe(1); + + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', cell.nativeElement, true, false, true); + await wait(100); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + cell = grid.gridAPI.get_cell_by_index(2, 'Name'); + GridSelectionFunctions.verifyCellSelected(cell); + expect(grid.selectedCells.length).toBe(1); + + const row = grid.gridAPI.get_row_by_index(3); + expect(row instanceof IgxGridGroupByRowComponent).toBe(true); + expect(row.focused).toBe(true); + + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', row.nativeElement, true, false, true); + await wait(100); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + cell = grid.gridAPI.get_cell_by_index(4, 'Name'); + GridSelectionFunctions.verifyCellSelected(cell); + expect(grid.selectedCells.length).toBe(2); + + UIInteractions.triggerKeyDownEvtUponElem('arrowleft', cell.nativeElement, true, false, true); + await wait(100); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(2); + cell = grid.gridAPI.get_cell_by_index(4, 'ParentID'); + GridSelectionFunctions.verifyCellSelected(cell); + expect(grid.selectedCells.length).toBe(4); + + UIInteractions.triggerKeyDownEvtUponElem('arrowup', cell.nativeElement, true, false, true); + await wait(100); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(2); + expect(grid.selectedCells.length).toBe(4); + expect(row instanceof IgxGridGroupByRowComponent).toBe(true); + expect(row.focused).toBe(true); + + UIInteractions.triggerKeyDownEvtUponElem('arrowup', row.nativeElement, true, false, true); + await wait(100); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(3); + expect(grid.selectedCells.length).toBe(2); + GridSelectionFunctions.verifySelectedRange(grid, 2, 2, 1, 2); + })); + + it('Grouping: should not select range when from grouped row navigate without Shift', (async () => { + grid.getColumnByName('ParentID').groupable = true; + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ParentID', dir: SortingDirection.Desc, + ignoreCase: false, strategy: DefaultSortingStrategy.instance() + }); + fix.detectChanges(); + + let cell = grid.gridAPI.get_cell_by_index(2, 'Name'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellSelected(cell); + expect(grid.selectedCells.length).toBe(1); + + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', cell.nativeElement, true, false, true); + await wait(100); + fix.detectChanges(); + + expect(grid.selectedCells.length).toBe(1); + const row = grid.gridAPI.get_row_by_index(3); + expect(row.focused).toBe(true); + + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', row.nativeElement, true); + await wait(100); + fix.detectChanges(); + + cell = grid.gridAPI.get_cell_by_index(4, 'Name'); + GridSelectionFunctions.verifyCellSelected(cell); + expect(grid.selectedCells.length).toBe(1); + GridSelectionFunctions.verifySelectedRange(grid, 4, 4, 2, 2); + })); + + it('Grouping: should clear selection when you press arrowkey without shift on groupRow', (async () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + grid.getColumnByName('ParentID').groupable = true; + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ParentID', dir: SortingDirection.Desc, + ignoreCase: false, strategy: DefaultSortingStrategy.instance() + }); + fix.detectChanges(); + + let cell = grid.gridAPI.get_cell_by_index(2, 'Name'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifyCellSelected(cell); + expect(grid.selectedCells.length).toBe(1); + for (let i = 2; i < 10; i++) { + let obj = grid.gridAPI.get_cell_by_index(i, 'Name'); + if (!obj) { + obj = grid.gridAPI.get_row_by_index(i); + } + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', obj.nativeElement, true, false, true); + await wait(50); + fix.detectChanges(); + } + + expect(selectionChangeSpy).toHaveBeenCalledTimes(5); + cell = grid.gridAPI.get_cell_by_index(10, 'Name'); + UIInteractions.triggerKeyDownEvtUponElem('arrowleft', cell.nativeElement, true, false, true); + await wait(50); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(6); + GridSelectionFunctions.verifySelectedRange(grid, 2, 10, 1, 2); + })); + + it('Grouping and Summaries: should select cells with arrow up and down keys', (async () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + grid.getColumnByName('ParentID').groupable = true; + grid.getColumnByName('Name').hasSummary = true; + grid.summaryCalculationMode = 'childLevelsOnly'; + grid.height = '700px'; + await wait(30); + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ParentID', dir: SortingDirection.Desc, + ignoreCase: false, strategy: DefaultSortingStrategy.instance() + }); + fix.detectChanges(); + + let cell = grid.gridAPI.get_cell_by_index(2, 'ParentID'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifyCellSelected(cell); + for (let i = 2; i < 10; i++) { + let obj = grid.gridAPI.get_cell_by_index(i, 'ParentID'); + if (!obj) { + obj = grid.gridAPI.get_row_by_index(i); + if (!(obj instanceof IgxGridGroupByRowComponent)) { + obj = grid.summariesRowList.find(row => row.index === i) + .summaryCells.find(sCell => sCell.visibleColumnIndex === 1); + } + } + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', obj.nativeElement, true, false, true); + await wait(50); + fix.detectChanges(); + } + + expect(selectionChangeSpy).toHaveBeenCalledTimes(4); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 10, 1, 1); + expect(grid.selectedCells.length).toBe(5); + + cell = grid.gridAPI.get_cell_by_index(10, 'ParentID'); + UIInteractions.triggerKeyDownEvtUponElem('arrowright', cell.nativeElement, true, false, true); + await wait(50); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(5); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 10, 1, 2); + expect(grid.selectedCells.length).toBe(10); + + for (let i = 10; i > 3; i--) { + let obj = grid.gridAPI.get_cell_by_index(i, 'Name'); + if (!obj) { + obj = grid.gridAPI.get_row_by_index(i); + if (!(obj instanceof IgxGridGroupByRowComponent)) { + obj = grid.summariesRowList.find(row => row.index === i) + .summaryCells.find(sCell => sCell.visibleColumnIndex === 2); + } + } + UIInteractions.triggerKeyDownEvtUponElem('arrowup', obj.nativeElement, true, false, true); + await wait(50); + fix.detectChanges(); + } + + expect(selectionChangeSpy).toHaveBeenCalledTimes(8); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 5, 1, 2); + expect(grid.selectedCells.length).toBe(4); + + const summaryCell = grid.summariesRowList.find(row => row.index === 3) + .summaryCells.find(sCell => sCell.visibleColumnIndex === 2); + UIInteractions.triggerKeyDownEvtUponElem('arrowleft', summaryCell.nativeElement, true); + await wait(50); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(8); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 5, 1, 2); + expect(grid.selectedCells.length).toBe(4); + GridSelectionFunctions.verifySelectedRange(grid, 2, 5, 1, 2); + })); + + it('Grouping and Summaries: should select cells with arrow up and down keys when there are scrolls', (async () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + grid.getColumnByName('ParentID').groupable = true; + grid.getColumnByName('Name').hasSummary = true; + grid.summaryCalculationMode = 'childLevelsOnly'; + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ParentID', dir: SortingDirection.Desc, + ignoreCase: false, strategy: DefaultSortingStrategy.instance() + }); + fix.detectChanges(); + + const cell = grid.gridAPI.get_cell_by_index(2, 'ID'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifyCellSelected(cell); + for (let i = 2; i < 8; i++) { + let obj = grid.gridAPI.get_cell_by_index(i, 'ID'); + if (!obj) { + obj = grid.gridAPI.get_row_by_index(i); + if (!(obj instanceof IgxGridGroupByRowComponent)) { + obj = grid.summariesRowList.find(row => row.index === i) + .summaryCells.find(sCell => sCell.visibleColumnIndex === 0); + } + } + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', obj.nativeElement, true, false, true); + await wait(50); + fix.detectChanges(); + } + + expect(selectionChangeSpy).toHaveBeenCalledTimes(3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 6, 7, 0, 0); + for (let i = 0; i < 5; i++) { + const summaryCell = grid.summariesRowList.find(row => row.index === 8) + .summaryCells.find(sCell => sCell.visibleColumnIndex === i); + UIInteractions.triggerKeyDownEvtUponElem('arrowright', summaryCell.nativeElement, true); + await wait(50); + fix.detectChanges(); + } + + expect(selectionChangeSpy).toHaveBeenCalledTimes(3); + GridSelectionFunctions.verifySelectedRange(grid, 2, 7, 0, 0); + const sumCell = grid.summariesRowList.find(row => row.index === 8) + .summaryCells.find(sCell => sCell.visibleColumnIndex === 5); + UIInteractions.triggerKeyDownEvtUponElem('arrowup', sumCell.nativeElement, true); + await wait(50); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(grid, 7, 7, 5, 5); + })); + }); + + describe('Features integration', () => { + let fix; + let grid; + let detect; + + beforeEach(() => { + fix = TestBed.createComponent(SelectionWithScrollsComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + setupGridScrollDetection(fix, grid); + detect = () => grid.cdr.detectChanges(); + }); + + afterEach(() => { + clearGridSubs(); + }); + + + it('Sorting: selection should not change when sorting is performed', () => { + const column = grid.getColumnByName('ID'); + column.sortable = true; + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + GridSelectionFunctions.selectCellsRangeNoWait( + fix, grid.gridAPI.get_cell_by_index(1, 'ParentID'), grid.gridAPI.get_cell_by_index(4, 'HireDate')); + detect(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 4, 1, 3); + GridSelectionFunctions.verifySelectedRange(grid, 1, 4, 1, 3); + const selectedData = [ + { ParentID: 147, Name: 'Thomas Hardy', HireDate: new Date('Jul 19, 2009') }, + { ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014') }, + { ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') } + ]; + expect(grid.getSelectedData()).toEqual(selectedData); + grid.sort({ fieldName: column.field, dir: SortingDirection.Asc, ignoreCase: false }); + fix.detectChanges(); + + const filteredSelectedData = [ + { ParentID: 19, Name: 'Antonio Moreno', HireDate: new Date('May 4, 2014') }, + { ParentID: 17, Name: 'Casey Harper', HireDate: new Date('Mar 19, 2016') }, + { ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014') }, + { ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') } + ]; + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifySelectedRange(grid, 1, 4, 1, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 4, 1, 3); + expect(grid.getSelectedData()).not.toEqual(selectedData); + expect(grid.getSelectedData()).toEqual(filteredSelectedData); + grid.clearSort(); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifySelectedRange(grid, 1, 4, 1, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 4, 1, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + }); + + it('Sorting: selection containing selected cell out of the view should not change when sorting is performed', () => { + const column = grid.getColumnByName('ID'); + column.sortable = true; + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const range = { rowStart: 2, rowEnd: 7, columnStart: 'ID', columnEnd: 'OnPTO' }; + grid.selectRange(range); + fix.detectChanges(); + + const selectedData = [ + { ID: 317, ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014'), Age: 31, OnPTO: false }, + { ID: 225, ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014'), Age: 44, OnPTO: true }, + { ID: 663, ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017'), Age: 25, OnPTO: false }, + { ID: 15, ParentID: 19, Name: 'Antonio Moreno', HireDate: new Date('May 4, 2014'), Age: 44, OnPTO: true }, + { ID: 12, ParentID: 17, Name: 'Pedro Afonso', HireDate: new Date('Dec 18, 2007'), Age: 50, OnPTO: false }, + { ID: 101, ParentID: 17, Name: 'Casey Harper', HireDate: new Date('Mar 19, 2016'), Age: 27, OnPTO: false } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 7, 0, 5); + expect(grid.getSelectedData()).toEqual(selectedData); + grid.sort({ fieldName: column.field, dir: SortingDirection.Asc, ignoreCase: false }); + fix.detectChanges(); + + const sortedData = [ + { ID: 101, ParentID: 17, Name: 'Casey Harper', HireDate: new Date('Mar 19, 2016'), Age: 27, OnPTO: false }, + { ID: 225, ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014'), Age: 44, OnPTO: true }, + { ID: 317, ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014'), Age: 31, OnPTO: false }, + { ID: 475, ParentID: 147, Name: 'Michael Langdon', HireDate: new Date('Jul 03 2011'), Age: 43, OnPTO: false }, + { ID: 663, ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017'), Age: 25, OnPTO: false }, + { ID: 957, ParentID: 147, Name: 'Thomas Hardy', HireDate: new Date('Jul 19, 2009'), Age: 29, OnPTO: true } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 7, 0, 5); + expect(grid.getSelectedData()).not.toEqual(selectedData); + expect(grid.getSelectedData()).toEqual(sortedData); + grid.clearSort(); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifySelectedRange(grid, 2, 7, 0, 5); + expect(grid.getSelectedData().length).toBe(selectedData.length); + expect(grid.getSelectedData()).toEqual(selectedData); + }); + + it('Filtering: selected range should not change when filtering is performed', () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const firstCell = grid.gridAPI.get_cell_by_index(0, 'ParentID'); + const secondCell = grid.gridAPI.get_cell_by_index(3, 'HireDate'); + GridSelectionFunctions.selectCellsRangeNoWait(fix, firstCell, secondCell); + detect(); + + const selectedData = [{ ParentID: 147, Name: 'Michael Langdon', HireDate: new Date('Jul 3, 2011') }, + { ParentID: 147, Name: 'Thomas Hardy', HireDate: new Date('Jul 19, 2009') }, + { ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 0, 3, 1, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 0, 3, 1, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + grid.filter('Name', 'm', IgxStringFilteringOperand.instance().condition('contains'), false); + fix.detectChanges(); + + const filteredSelectedData = [{ ParentID: 147, Name: 'Michael Langdon', HireDate: new Date('Jul 3, 2011') }, + { ParentID: 147, Name: 'Thomas Hardy', HireDate: new Date('Jul 19, 2009') }, + { ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { ParentID: 19, Name: 'Antonio Moreno', HireDate: new Date('May 4, 2014') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 0, 3, 1, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 0, 3, 1, 3); + expect(grid.getSelectedData()).not.toEqual(selectedData); + expect(grid.getSelectedData()).toEqual(filteredSelectedData); + grid.clearFilter(); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifySelectedRange(grid, 0, 3, 1, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 0, 3, 1, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + + }); + + it('Filtering: selected range should not change when filtering result is smaller that selected range', () => { + const range = { rowStart: 0, rowEnd: 4, columnStart: 'ID', columnEnd: 'HireDate' }; + grid.selectRange(range); + fix.detectChanges(); + const selectedData = [ + { ID: 475, ParentID: 147, Name: 'Michael Langdon', HireDate: new Date('Jul 03 2011') }, + { ID: 957, ParentID: 147, Name: 'Thomas Hardy', HireDate: new Date('Jul 19, 2009') }, + { ID: 317, ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { ID: 225, ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014') }, + { ID: 663, ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') } + ]; + + GridSelectionFunctions.verifySelectedRange(grid, 0, 4, 0, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 0, 4, 0, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + + grid.filter('Name', 'm', IgxStringFilteringOperand.instance().condition('contains'), false); + fix.detectChanges(); + const filteredSelectedData = [{ ID: 475, ParentID: 147, Name: 'Michael Langdon', HireDate: new Date('Jul 3, 2011') }, + { ID: 957, ParentID: 147, Name: 'Thomas Hardy', HireDate: new Date('Jul 19, 2009') }, + { ID: 317, ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { ID: 15, ParentID: 19, Name: 'Antonio Moreno', HireDate: new Date('May 4, 2014') } + ]; + + GridSelectionFunctions.verifySelectedRange(grid, 0, 4, 0, 3); + expect(grid.getSelectedData()).not.toEqual(selectedData); + expect(grid.getSelectedData()).toEqual(filteredSelectedData); + grid.clearFilter(); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(grid, 0, 4, 0, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 0, 4, 0, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + }); + + it('Filtering: selected range should not change when filtering result is empty that selected range', () => { + const range = { rowStart: 0, rowEnd: 4, columnStart: 'ID', columnEnd: 'OnPTO' }; + grid.selectRange(range); + const selectedData = [ + { ID: 475, ParentID: 147, Name: 'Michael Langdon', HireDate: new Date('Jul 03 2011'), Age: 43, OnPTO: false }, + { ID: 957, ParentID: 147, Name: 'Thomas Hardy', HireDate: new Date('Jul 19, 2009'), Age: 29, OnPTO: true }, + { ID: 317, ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014'), Age: 31, OnPTO: false }, + { ID: 225, ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014'), Age: 44, OnPTO: true }, + { ID: 663, ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017'), Age: 25, OnPTO: false } + ]; + + GridSelectionFunctions.verifySelectedRange(grid, 0, 4, 0, 5); + expect(grid.getSelectedData()).toEqual(selectedData); + + grid.filter('Name', 'leon', IgxStringFilteringOperand.instance().condition('contains'), false); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(grid, 0, 4, 0, 5); + expect(grid.dataRowList.length).toBe(0); + expect(grid.getSelectedData()).toEqual([]); + expect(grid.selectedCells.length).toBe(0); + grid.clearFilter(); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(grid, 0, 4, 0, 5); + expect(grid.getSelectedData()).toEqual(selectedData); + }); + + it('Filtering, Paging: selected range should not change when perform filtering', fakeAsync(() => { + fix.componentInstance.paging = true; + fix.detectChanges(); + grid.perPage = 5; + fix.detectChanges(); + tick(16); + + const selectRange = { rowStart: 1, rowEnd: 2, columnStart: 'ID', columnEnd: 'HireDate' }; + grid.selectRange(selectRange); + fix.detectChanges(); + + const selData = [ + { ID: 957, ParentID: 147, Name: 'Thomas Hardy', HireDate: new Date('Jul 19, 2009') }, + { ID: 317, ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 1, 2, 0, 3); + expect(grid.getSelectedData()).toEqual(selData); + grid.filter('Name', 'm', IgxStringFilteringOperand.instance().condition('contains'), false); + fix.detectChanges(); + tick(16); + + const fData = [ + { ID: 957, ParentID: 147, Name: 'Thomas Hardy', HireDate: new Date('Jul 19, 2009') }, + { ID: 317, ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + ]; + GridSelectionFunctions.verifySelectedRange(grid, 1, 2, 0, 3); + expect(grid.getSelectedData()).toEqual(fData); + })); + + it('Paging: selected range should be cleared on paging', fakeAsync(() => { + fix.componentInstance.paging = true; + fix.detectChanges(); + grid.paginator.perPage = 5; + fix.detectChanges(); + tick(16); + + const range = { rowStart: 1, rowEnd: 4, columnStart: 'ID', columnEnd: 'HireDate' }; + grid.selectRange(range); + fix.detectChanges(); + + const selectedData = [{ ID: 957, ParentID: 147, Name: 'Thomas Hardy', HireDate: new Date('Jul 19, 2009') }, + { ID: 317, ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { ID: 225, ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014') }, + { ID: 663, ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 1, 4, 0, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 4, 0, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + grid.paginator.paginate(1); + fix.detectChanges(); + tick(16); + + expect(grid.getSelectedRanges().length).toBe(0); + expect(grid.getSelectedRanges()).toEqual([]); + expect(grid.getSelectedData().length).toBe(0); + expect(grid.getSelectedData()).toEqual([]); + })); + + it('Paging: selected range should be cleared when perPage items are changed', fakeAsync(() => { + fix.componentInstance.paging = true; + fix.detectChanges(); + grid.perPage = 5; + fix.detectChanges(); + tick(16); + + const range = { rowStart: 2, rowEnd: 4, columnStart: 'ID', columnEnd: 'OnPTO' }; + grid.selectRange(range); + fix.detectChanges(); + + const selectedData = [ + { ID: 317, ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014'), Age: 31, OnPTO: false }, + { ID: 225, ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014'), Age: 44, OnPTO: true }, + { ID: 663, ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017'), Age: 25, OnPTO: false } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 0, 5); + expect(grid.getSelectedData().length).toBe(selectedData.length); + expect(grid.getSelectedData()).toEqual(selectedData); + grid.perPage = 7; + fix.detectChanges(); + tick(16); + + expect(grid.getSelectedRanges().length).toBe(0); + expect(grid.getSelectedRanges()).toEqual([]); + expect(grid.getSelectedData().length).toBe(0); + expect(grid.getSelectedData()).toEqual([]); + })); + + xit('Resizing: selected range should not change on resizing', fakeAsync(() => { + const range = { rowStart: 2, rowEnd: 4, columnStart: 'ID', columnEnd: 'HireDate' }; + grid.selectRange(range); + fix.detectChanges(); + + const selectedData = [ + { ID: 317, ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { ID: 225, ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014') }, + { ID: 663, ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 0, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 4, 0, 3); + expect(grid.getSelectedData().length).toBe(selectedData.length); + expect(grid.getSelectedData()).toEqual(selectedData); + const columnName = grid.getColumnByName('Name'); + const initialWidth = columnName.width; + columnName.resizable = true; + fix.detectChanges(); + + const header = GridFunctions.getColumnHeaderByIndex(fix, 2); + const headerResArea = GridFunctions.getHeaderResizeArea(header).nativeElement; + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 100, 15); + tick(); + fix.detectChanges(); + + const resizer = GridFunctions.getResizer(fix).nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 200, 15); + tick(); + UIInteractions.simulateMouseEvent('mouseup', resizer, 200, 15); + tick(); + fix.detectChanges(); + + expect(columnName.width).not.toEqual(initialWidth); + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 0, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 4, 0, 3); + expect(grid.getSelectedData().length).toBe(selectedData.length); + expect(grid.getSelectedData()).toEqual(selectedData); + })); + + it('Hiding: selection should be perserved on column hiding', () => { + const range = { rowStart: 2, rowEnd: 3, columnStart: 'ID', columnEnd: 'HireDate' }; + grid.selectRange(range); + fix.detectChanges(); + + const selectedData = [ + { ID: 317, ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { ID: 225, ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 3, 0, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + const columnName = grid.getColumnByName('Name'); + columnName.hidden = true; + fix.detectChanges(); + + const newSelectedData = [ + { ID: 317, ParentID: 147, HireDate: new Date('Sep 18, 2014'), Age: 31 }, + { ID: 225, ParentID: 847, HireDate: new Date('May 4, 2014'), Age: 44 } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 3, 0, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 3, 0, 3); + expect(grid.getSelectedData()).toEqual(newSelectedData); + columnName.hidden = false; + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(grid, 2, 3, 0, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 3, 0, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + }); + + it('Hiding: when hide last column which is in selected range, selection range is changed', (async () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + grid.dataRowList.first.virtDirRow.scrollTo(5); + await wait(100); + fix.detectChanges(); + + const range = { rowStart: 2, rowEnd: 3, columnStart: 'HireDate', columnEnd: 'OnPTO' }; + grid.selectRange(range); + fix.detectChanges(); + + const selectedData = [{ HireDate: new Date('Sep 18, 2014'), Age: 31, OnPTO: false }, + { HireDate: new Date('May 4, 2014'), Age: 44, OnPTO: true } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 3, 3, 5); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 3, 3, 5); + expect(grid.getSelectedData()).toEqual(selectedData); + const columnName = grid.getColumnByName('OnPTO'); + columnName.hidden = true; + await wait(); + fix.detectChanges(); + + const newSelectedData = [ + { HireDate: new Date('Sep 18, 2014'), Age: 31 }, + { HireDate: new Date('May 4, 2014'), Age: 44 } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 3, 3, 5); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 3, 3, 4); + expect(grid.getSelectedData()).toEqual(newSelectedData); + columnName.hidden = false; + fix.detectChanges(); + + grid.dataRowList.first.virtDirRow.scrollTo(5); + await wait(100); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifySelectedRange(grid, 2, 3, 3, 5); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 3, 3, 5); + expect(grid.getSelectedData()).toEqual(selectedData); + })); + + it('Hiding: selected data shoudld be [] when all columns are hidden', () => { + const range = { rowStart: 2, rowEnd: 3, columnStart: 'ID', columnEnd: 'HireDate' }; + grid.selectRange(range); + fix.detectChanges(); + + const selectedData = [ + { ID: 317, ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { ID: 225, ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 3, 0, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + + grid.columnList.forEach(col => col.hidden = true); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(grid, 2, 3, 0, 3); + expect(grid.getSelectedData()).toEqual([]); + + grid.columnList.forEach(col => col.hidden = false); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(grid, 2, 3, 0, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + }); + + it('Pinning: should be able to select cells from unpinned cols to pinned', (async () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + grid.dataRowList.first.virtDirRow.scrollTo(5); + await wait(100); + fix.detectChanges(); + + const columnName = grid.getColumnByName('OnPTO'); + columnName.pinned = true; + fix.detectChanges(); + + const firstCell = grid.gridAPI.get_cell_by_index(2, 'OnPTO'); + const secondCell = grid.gridAPI.get_cell_by_index(4, 'HireDate'); + await GridSelectionFunctions.selectCellsRange(fix, firstCell, secondCell); + detect(); + + grid.dataRowList.first.virtDirRow.scrollTo(0); + await wait(100); + fix.detectChanges(); + + const selectedData = [ + { OnPTO: false, ID: 317, ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { OnPTO: true, ID: 225, ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014') }, + { OnPTO: false, ID: 663, ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') } + ]; + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 0, 4); + expect(grid.getSelectedData()).toEqual(selectedData); + columnName.pinned = false; + fix.detectChanges(); + + const newSelectedData = [ + { ID: 317, ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014'), Age: 31 }, + { ID: 225, ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014'), Age: 44 }, + { ID: 663, ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017'), Age: 25 } + ]; + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 0, 4); + expect(grid.getSelectedData()).toEqual(newSelectedData); + })); + + it('Pinning: should be able to select cells from unpinned cols to pinned', (async () => { + const columnName = grid.getColumnByName('Age'); + const secondCol = grid.getColumnByName('OnPTO'); + secondCol.pinned = true; + columnName.pinned = true; + fix.detectChanges(); + + grid.dataRowList.first.virtDirRow.scrollTo(2); + await wait(100); + fix.detectChanges(); + await GridSelectionFunctions.selectCellsRange(fix, grid.gridAPI.get_cell_by_index(2, 'Age'), grid.gridAPI.get_cell_by_index(4, 'Name')); + detect(); + + const selectedData = [ + { ID: 317, ParentID: 147, Name: 'Monica Reyes', Age: 31 }, + { ID: 225, ParentID: 847, Name: 'Laurence Johnson', Age: 44 }, + { ID: 663, ParentID: 847, Name: 'Elizabeth Richards', Age: 25 } + ]; + + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 1, 4); + expect(grid.getSelectedData()).toEqual(selectedData); + })); + + it('Pinning: should be able to select cells from unpinned cols to pinned', () => { + const firstCell = grid.gridAPI.get_cell_by_index(2, 'ParentID'); + const secondCell = grid.gridAPI.get_cell_by_index(4, 'HireDate'); + GridSelectionFunctions.selectCellsRangeNoWait(fix, firstCell, secondCell); + detect(); + + const selectedData = [ + { ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014') }, + { ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 1, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + const column = grid.getColumnByName('Name'); + column.pinned = true; + fix.detectChanges(); + + const newSelectedData = [ + { ID: 317, ParentID: 147, HireDate: new Date('Sep 18, 2014') }, + { ID: 225, ParentID: 847, HireDate: new Date('May 4, 2014') }, + { ID: 663, ParentID: 847, HireDate: new Date('Dec 9, 2017') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 1, 3); + expect(grid.getSelectedData()).toEqual(newSelectedData); + }); + + it('Pinning: selection should remains the same when unpin column from selected area', () => { + const firstCol = grid.getColumnByName('ParentID'); + const secondCol = grid.getColumnByName('HireDate'); + firstCol.pinned = true; + secondCol.pinned = true; + fix.detectChanges(); + + const firstCell = grid.gridAPI.get_cell_by_index(2, 'ParentID'); + const secondCell = grid.gridAPI.get_cell_by_index(4, 'HireDate'); + GridSelectionFunctions.selectCellsRangeNoWait(fix, firstCell, secondCell); + fix.detectChanges(); + + const selectedData = [ + { ParentID: 147, HireDate: new Date('Sep 18, 2014') }, + { ParentID: 847, HireDate: new Date('May 4, 2014') }, + { ParentID: 847, HireDate: new Date('Dec 9, 2017') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 0, 1); + expect(grid.getSelectedData()).toEqual(selectedData); + firstCol.pinned = false; + fix.detectChanges(); + + const newSelData = [ + { HireDate: new Date('Sep 18, 2014'), ID: 317 }, + { HireDate: new Date('May 4, 2014'), ID: 225 }, + { HireDate: new Date('Dec 9, 2017'), ID: 663 } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 0, 1); + expect(grid.getSelectedData()).toEqual(newSelData); + }); + + it('GroupBy: should be able to select range when there is grouping applied ', () => { + grid.groupBy({ + fieldName: 'ParentID', dir: SortingDirection.Desc, ignoreCase: true + }); + const firstCell = grid.gridAPI.get_cell_by_index(2, 'ParentID'); + const secondCell = grid.gridAPI.get_cell_by_index(4, 'HireDate'); + GridSelectionFunctions.selectCellsRangeNoWait(fix, firstCell, secondCell); + detect(); + + const selectedData = [ + { ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') }, + { ParentID: 147, Name: 'Michael Langdon', HireDate: new Date('Jul 3, 2011') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 1, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + grid.clearGrouping(); + fix.detectChanges(); + + const newSelectedData = [ + { ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014') }, + { ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 1, 3); + expect(grid.getSelectedData()).toEqual(newSelectedData); + }); + + it('GroupBy: selected range should remain the same when perform grouping ', () => { + const firstCell = grid.gridAPI.get_cell_by_index(2, 'ParentID'); + const secondCell = grid.gridAPI.get_cell_by_index(4, 'HireDate'); + GridSelectionFunctions.selectCellsRangeNoWait(fix, firstCell, secondCell); + detect(); + + const selectedData = [ + { ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014') }, + { ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 1, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + grid.groupBy({ + fieldName: 'ParentID', dir: SortingDirection.Desc, ignoreCase: true + }); + fix.detectChanges(); + + const newSelectedData = [ + { ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') }, + { ParentID: 147, Name: 'Michael Langdon', HireDate: new Date('Jul 3, 2011') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 1, 3); + expect(grid.getSelectedData()).toEqual(newSelectedData); + }); + + it('GroupBy: selected range should change when collapse a group row', () => { + grid.groupBy({ + fieldName: 'ParentID', dir: SortingDirection.Desc, ignoreCase: true + }); + const firstCell = grid.gridAPI.get_cell_by_index(2, 'ParentID'); + const secondCell = grid.gridAPI.get_cell_by_index(4, 'HireDate'); + GridSelectionFunctions.selectCellsRangeNoWait(fix, firstCell, secondCell); + detect(); + + const selectedData = [ + { ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') }, + { ParentID: 147, Name: 'Michael Langdon', HireDate: new Date('Jul 3, 2011') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 1, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + grid.rowList.first.toggle(); + fix.detectChanges(); + + const newSelectedData = [ + { ParentID: 147, Name: 'Michael Langdon', HireDate: new Date('Jul 3, 2011') }, + { ParentID: 147, Name: 'Thomas Hardy', HireDate: new Date('Jul 19, 2009') }, + { ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 1, 3); + expect(grid.getSelectedData()).toEqual(newSelectedData); + }); + + it('Grouping: selected data should be empty when all group rows are collapsed', () => { + grid.groupBy({ + fieldName: 'ParentID', dir: SortingDirection.Desc, ignoreCase: true + }); + const firstCell = grid.gridAPI.get_cell_by_index(2, 'ParentID'); + const secondCell = grid.gridAPI.get_cell_by_index(4, 'HireDate'); + GridSelectionFunctions.selectCellsRangeNoWait(fix, firstCell, secondCell); + detect(); + + const selectedData = [ + { ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') }, + { ParentID: 147, Name: 'Michael Langdon', HireDate: new Date('Jul 3, 2011') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 1, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + grid.toggleAllGroupRows(); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 1, 3); + expect(grid.dataRowList.length).toBe(0); + expect(grid.getSelectedData()).toEqual([]); + grid.toggleAllGroupRows(); + fix.detectChanges(); + + expect(grid.dataRowList.lenght).not.toBe(0); + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 1, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + }); + + it('Moving: selection should not change when move columns inside selected range', fakeAsync(() => { + const firstCell = grid.gridAPI.get_cell_by_index(2, 'ParentID'); + const secondCell = grid.gridAPI.get_cell_by_index(4, 'HireDate'); + GridSelectionFunctions.selectCellsRangeNoWait(fix, firstCell, secondCell); + detect(); + + const selectedData = [ + { ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014') }, + { ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') } + ]; + + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 1, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 4, 1, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + grid.moveColumn(grid.getColumnByName('ParentID'), grid.getColumnByName('HireDate')); + tick(); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 1, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 4, 1, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + grid.primaryKey = 'ID'; + fix.detectChanges(); + + grid.moveColumn(grid.getColumnByName('ParentID'), grid.getColumnByName('ID'), DropPosition.BeforeDropTarget); + tick(); + fix.detectChanges(); + const newSelectedData = [ + { ID: 317, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { ID: 225, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014') }, + { ID: 663, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 1, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 4, 1, 3); + expect(grid.getSelectedData()).toEqual(newSelectedData); + })); + + it('Summaries: selection range should not change when enable/disable summaries', (async () => { + grid.height = '600px'; + await wait(100); + fix.detectChanges(); + grid.groupBy({ + fieldName: 'ParentID', dir: SortingDirection.Desc, ignoreCase: true + }); + grid.summaryCalculationMode = 'childLevelsOnly'; + grid.getColumnByName('Name').hasSummary = true; + fix.detectChanges(); + + const firstCell = grid.gridAPI.get_cell_by_index(1, 'ParentID'); + const secondCell = grid.gridAPI.get_cell_by_index(5, 'HireDate'); + await GridSelectionFunctions.selectCellsRange(fix, firstCell, secondCell); + detect(); + + const selectedData = [ + { ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014') }, + { ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') }, + { ParentID: 147, Name: 'Michael Langdon', HireDate: new Date('Jul 3, 2011') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 1, 5, 1, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + grid.getColumnByName('Name').hasSummary = false; + fix.detectChanges(); + + const newSelectedData = [ + { ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014') }, + { ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') }, + { ParentID: 147, Name: 'Michael Langdon', HireDate: new Date('Jul 3, 2011') }, + { ParentID: 147, Name: 'Thomas Hardy', HireDate: new Date('Jul 19, 2009') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 1, 5, 1, 3); + expect(grid.getSelectedData()).toEqual(newSelectedData); + })); + + it('Summaries: selection range should not change when change summaryPosition', (async () => { + grid.height = '600px'; + await wait(100); + fix.detectChanges(); + grid.groupBy({ + fieldName: 'ParentID', dir: SortingDirection.Desc, ignoreCase: true + }); + grid.summaryCalculationMode = 'childLevelsOnly'; + grid.getColumnByName('Name').hasSummary = true; + fix.detectChanges(); + + const firstCell = grid.gridAPI.get_cell_by_index(1, 'ParentID'); + const secondCell = grid.gridAPI.get_cell_by_index(5, 'HireDate'); + await GridSelectionFunctions.selectCellsRange(fix, firstCell, secondCell); + detect(); + + const selectedData = [ + { ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014') }, + { ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') }, + { ParentID: 147, Name: 'Michael Langdon', HireDate: new Date('Jul 3, 2011') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 1, 5, 1, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + + grid.summaryPosition = 'top'; + fix.detectChanges(); + const newSelData = [ + { ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014') }, + { ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 1, 5, 1, 3); + expect(grid.getSelectedData()).toEqual(newSelData); + + grid.getColumnByName('Name').hasSummary = false; + fix.detectChanges(); + const newSelectedData = [ + { ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014') }, + { ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') }, + { ParentID: 147, Name: 'Michael Langdon', HireDate: new Date('Jul 3, 2011') }, + { ParentID: 147, Name: 'Thomas Hardy', HireDate: new Date('Jul 19, 2009') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 1, 5, 1, 3); + expect(grid.getSelectedData()).toEqual(newSelectedData); + })); + + it('CRUD: selection range should be preserved when delete a row', () => { + const firstCell = grid.gridAPI.get_cell_by_index(2, 'ParentID'); + const secondCell = grid.gridAPI.get_cell_by_index(4, 'HireDate'); + GridSelectionFunctions.selectCellsRangeNoWait(fix, firstCell, secondCell); + detect(); + + const selectedData = [ + { ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014') }, + { ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 1, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 4, 1, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + const row = grid.getRowByIndex(3); + row.delete(); + fix.detectChanges(); + + const newSelectedData = [ + { ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') }, + { ParentID: 19, Name: 'Antonio Moreno', HireDate: new Date('May 4, 2014') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 1, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 4, 1, 3); + expect(grid.getSelectedData()).toEqual(newSelectedData); + + grid.primaryKey = 'ID'; + fix.detectChanges(); + + expect(grid.primaryKey).toBeDefined(); + grid.deleteRow(15); + grid.deleteRow(101); + fix.detectChanges(); + + const newSelection = [ + { ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') }, + { ParentID: 17, Name: 'Pedro Afonso', HireDate: new Date('Dec 18, 2007') } + ]; + + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 1, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 4, 1, 3); + expect(grid.getSelectedData()).toEqual(newSelection); + grid.selectRange(); + fix.detectChanges(); + const range = { rowStart: 0, rowEnd: 4, columnStart: 'ID', columnEnd: 'OnPTO' }; + grid.selectRange(range); + fix.detectChanges(); + + let data = [ + { ID: 475, ParentID: 147, Name: 'Michael Langdon', HireDate: new Date('Jul 3, 2011'), Age: 43, OnPTO: false }, + { ID: 957, ParentID: 147, Name: 'Thomas Hardy', HireDate: new Date('Jul 19, 2009'), Age: 29, OnPTO: true }, + { ID: 317, ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014'), Age: 31, OnPTO: false }, + { ID: 663, ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017'), Age: 25, OnPTO: false }, + { ID: 12, ParentID: 17, Name: 'Pedro Afonso', HireDate: new Date('Dec 18, 2007'), Age: 50, OnPTO: false } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 0, 4, 0, 5); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 0, 4, 1, 3); + expect(grid.getSelectedData()).toEqual(data); + grid.deleteRow(957); + fix.detectChanges(); + + data = [ + { ID: 475, ParentID: 147, Name: 'Michael Langdon', HireDate: new Date('Jul 3, 2011'), Age: 43, OnPTO: false }, + { ID: 317, ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014'), Age: 31, OnPTO: false }, + { ID: 663, ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017'), Age: 25, OnPTO: false }, + { ID: 12, ParentID: 17, Name: 'Pedro Afonso', HireDate: new Date('Dec 18, 2007'), Age: 50, OnPTO: false } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 0, 4, 0, 5); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 0, 3, 1, 3); + expect(grid.getSelectedData()).toEqual(data); + }); + + it('CRUD: selected range should not change when add row', () => { + const range = { rowStart: 1, rowEnd: 4, columnStart: 'ID', columnEnd: 'HireDate' }; + grid.selectRange(range); + fix.detectChanges(); + + let selectedData = [ + { ID: 957, ParentID: 147, Name: 'Thomas Hardy', HireDate: new Date('Jul 19, 2009') }, + { ID: 317, ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { ID: 225, ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014') }, + { ID: 663, ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 1, 4, 0, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 4, 0, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + + grid.addRow({ ID: 112, ParentID: 177, Name: 'Ricardo Matias', HireDate: new Date('Dec 27, 2017'), Age: 55, OnPTO: false }); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(grid, 1, 4, 0, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 4, 0, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + grid.sort({ fieldName: 'ParentID', dir: SortingDirection.Asc, ignoreCase: false }); + fix.detectChanges(); + + selectedData = [ + { ID: 101, ParentID: 17, Name: 'Casey Harper', HireDate: new Date('Mar 19, 2016') }, + { ID: 15, ParentID: 19, Name: 'Antonio Moreno', HireDate: new Date('May 4, 2014') }, + { ID: 475, ParentID: 147, Name: 'Michael Langdon', HireDate: new Date('Jul 3, 2011') }, + { ID: 957, ParentID: 147, Name: 'Thomas Hardy', HireDate: new Date('Jul 19, 2009') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 1, 4, 0, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 4, 0, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + grid.addRow({ ID: 258, ParentID: 21, Name: 'Mario Lopez', HireDate: new Date('May 27, 2017'), Age: 33, OnPTO: false }); + fix.detectChanges(); + + selectedData = [ + { ID: 101, ParentID: 17, Name: 'Casey Harper', HireDate: new Date('Mar 19, 2016') }, + { ID: 15, ParentID: 19, Name: 'Antonio Moreno', HireDate: new Date('May 4, 2014') }, + { ID: 258, ParentID: 21, Name: 'Mario Lopez', HireDate: new Date('May 27, 2017') }, + { ID: 475, ParentID: 147, Name: 'Michael Langdon', HireDate: new Date('Jul 3, 2011') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 1, 4, 0, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 4, 0, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + }); + + it('CRUD: selected range should not change when update row', () => { + const range = { rowStart: 1, rowEnd: 4, columnStart: 'ID', columnEnd: 'HireDate' }; + grid.selectRange(range); + fix.detectChanges(); + + let selectedData = [ + { ID: 957, ParentID: 147, Name: 'Thomas Hardy', HireDate: new Date('Jul 19, 2009') }, + { ID: 317, ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { ID: 225, ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014') }, + { ID: 663, ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 1, 4, 0, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 4, 0, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + + const row = grid.getRowByIndex(2); + row.update({ ID: 112, ParentID: 177, Name: 'Ricardo Matias', HireDate: new Date('Dec 27, 2017'), Age: 55, OnPTO: false }); + fix.detectChanges(); + + selectedData = [ + { ID: 957, ParentID: 147, Name: 'Thomas Hardy', HireDate: new Date('Jul 19, 2009') }, + { ID: 112, ParentID: 177, Name: 'Ricardo Matias', HireDate: new Date('Dec 27, 2017') }, + { ID: 225, ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014') }, + { ID: 663, ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 1, 4, 0, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 4, 0, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + }); + + it('CRUD: selected range should not change when update row', () => { + const range = { rowStart: 1, rowEnd: 4, columnStart: 'ID', columnEnd: 'HireDate' }; + grid.selectRange(range); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(grid, 1, 4, 0, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 4, 0, 3); + grid.gridAPI.get_cell_by_index(0, 'ParentID').update(123); + grid.gridAPI.get_cell_by_index(2, 'ParentID').update(847); + grid.gridAPI.get_cell_by_index(3, 'Name').update('Paola Alicante'); + fix.detectChanges(); + + let selectedData = [ + { ID: 957, ParentID: 147, Name: 'Thomas Hardy', HireDate: new Date('Jul 19, 2009') }, + { ID: 317, ParentID: 847, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { ID: 225, ParentID: 847, Name: 'Paola Alicante', HireDate: new Date('May 4, 2014') }, + { ID: 663, ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 1, 4, 0, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 4, 0, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + grid.primaryKey = 'ID'; + fix.detectChanges(); + + grid.getCellByKey(475, 'ParentID').update(741); + grid.getCellByKey(317, 'ID').update(987); + grid.getCellByKey(663, 'Name').update('Peter Lincoln'); + fix.detectChanges(); + + selectedData = [ + { ID: 957, ParentID: 147, Name: 'Thomas Hardy', HireDate: new Date('Jul 19, 2009') }, + { ID: 987, ParentID: 847, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { ID: 225, ParentID: 847, Name: 'Paola Alicante', HireDate: new Date('May 4, 2014') }, + { ID: 663, ParentID: 847, Name: 'Peter Lincoln', HireDate: new Date('Dec 9, 2017') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 1, 4, 0, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 4, 0, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + }); + + // TODO cell + it('CRUD: Non-primary click with a cell in edit mode', () => { + grid.getColumnByName('Name').editable = true; + fix.detectChanges(); + + const cell = grid.getCellByColumn(0, 'Name'); + const cellElem = grid.gridAPI.get_cell_by_index(0, 'Name'); + UIInteractions.simulateClickAndSelectEvent(cellElem); + fix.detectChanges(); + + cell.editMode = true; + cell.editValue = 'No name'; + fix.detectChanges(); + + UIInteractions.simulateNonPrimaryClick(cellElem); + fix.detectChanges(); + expect(cell.editMode).toEqual(true); + expect(cell.editValue).toEqual('No name'); + expect(cell.value).not.toEqual('No name'); + + const target = grid.gridAPI.get_cell_by_index(0, 'Age'); + UIInteractions.simulateNonPrimaryClick(target); + fix.detectChanges(); + + expect(cell.editMode).toEqual(false); + expect(cell.value).toMatch('No name'); + expect(target.selected).toEqual(false); + expect(cell.selected).toEqual(true); + }); + + it('Search: selection range should be preserved when perform search', () => { + const range = { rowStart: 2, rowEnd: 4, columnStart: 'ID', columnEnd: 'HireDate' }; + grid.selectRange(range); + fix.detectChanges(); + + const selectedData = [ + { ID: 317, ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { ID: 225, ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014') }, + { ID: 663, ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 0, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 4, 0, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + + grid.findNext('re'); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 4, 0, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + expect(grid.nativeElement.querySelector('.igx-highlight__active')).toBeDefined(); + const cell = grid.gridAPI.get_cell_by_index(3, 'Name'); + expect(cell.nativeElement.querySelector('.igx-highlight')).toBeDefined(); + expect(cell.nativeElement.classList.contains('igx-grid__td--selected')).toBeTruthy(); + grid.findNext('re'); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 4, 0, 3); + expect(cell.nativeElement.querySelector('.igx-highlight__active')).toBeDefined(); + expect(grid.getSelectedData()).toEqual(selectedData); + }); + + it('Row Selection: the selection range should not change when select row', () => { + grid.rowSelection = GridSelectionMode.multiple; + const range = { rowStart: 2, rowEnd: 4, columnStart: 'ID', columnEnd: 'HireDate' }; + grid.selectRange(range); + fix.detectChanges(); + + const selectedData = [ + { ID: 317, ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { ID: 225, ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014') }, + { ID: 663, ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 0, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 4, 0, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + const row = grid.getRowByIndex(3); + grid.selectRows([row.key]); + fix.detectChanges(); + + expect(row.selected).toBeTruthy(); + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 0, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 4, 0, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + }); + + it('Row Selection: selected range should be preserved when select row with space', () => { + grid.rowSelection = GridSelectionMode.multiple; + fix.detectChanges(); + const cell = grid.gridAPI.get_cell_by_index(2, 'ID'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + const range = { rowStart: 2, rowEnd: 4, columnStart: 'ID', columnEnd: 'HireDate' }; + grid.selectRange(range); + fix.detectChanges(); + + const selectedData = [ + { ID: 317, ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { ID: 225, ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014') }, + { ID: 663, ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 0, 3, 1, 2); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 4, 0, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + + expect(grid.getRowByIndex(2).selected).toBeTruthy(); + GridFunctions.simulateGridContentKeydown(fix, 'space'); + fix.detectChanges(); + + expect(grid.getRowByIndex(2).selected).toBeFalsy(); + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 0, 3, 1, 2); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 4, 0, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + }); + + it('Row Selection: selected range with mouse interaction should be preserved when select row with space', () => { + grid.rowSelection = GridSelectionMode.multiple; + const firstCell = grid.gridAPI.get_cell_by_index(2, 'ID'); + const secondCell = grid.gridAPI.get_cell_by_index(4, 'HireDate'); + GridSelectionFunctions.selectCellsRangeNoWait(fix, firstCell, secondCell); + detect(); + + const selectedData = [ + { ID: 317, ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { ID: 225, ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014') }, + { ID: 663, ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 0, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 4, 0, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + const cell = grid.gridAPI.get_cell_by_index(2, 'ID'); + UIInteractions.triggerKeyDownEvtUponElem('space', cell.nativeElement, true, false, false); + fix.detectChanges(); + + expect(grid.getRowByIndex(2).selected).toBeTruthy(); + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 0, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 4, 0, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + }); + + }); + + describe('CRUD - transaction enabled', () => { + let fix; + let grid; + + beforeEach(() => { + fix = TestBed.createComponent(SelectionWithTransactionsComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + }); + + it('CRUD: selected range should not change when delete row', () => { + const range = { rowStart: 2, rowEnd: 4, columnStart: 'ParentID', columnEnd: 'HireDate' }; + grid.selectRange(range); + fix.detectChanges(); + + let selectedData = [ + { ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014') }, + { ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 1, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 4, 1, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + const row = grid.getRowByIndex(3); + row.delete(); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 1, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 4, 1, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + grid.transactions.undo(); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 1, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 4, 1, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + grid.transactions.redo(); + fix.detectChanges(); + grid.transactions.commit(fix.componentInstance.data); + fix.detectChanges(); + + selectedData = [ + { ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') }, + { ParentID: 19, Name: 'Antonio Moreno', HireDate: new Date('May 4, 2014') }, + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 1, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 4, 1, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + }); + + it('CRUD: selected range should not change when update row', () => { + const range = { rowStart: 2, rowEnd: 4, columnStart: 'ParentID', columnEnd: 'HireDate' }; + grid.selectRange(range); + fix.detectChanges(); + + const selectedData = [ + { ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014') }, + { ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 1, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 4, 1, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + const row = grid.getRowByIndex(3); + row.update({ ID: 112, ParentID: 177, Name: 'Ricardo Matias', HireDate: new Date('Dec 27, 2017'), Age: 55, OnPTO: false }); + fix.detectChanges(); + + const newSelectedData = [ + { ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { ParentID: 177, Name: 'Ricardo Matias', HireDate: new Date('Dec 27, 2017') }, + { ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 1, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 4, 1, 3); + expect(grid.getSelectedData()).toEqual(newSelectedData); + grid.transactions.clear(); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 1, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 4, 1, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + grid.primaryKey = 'ID'; + fix.detectChanges(); + + grid.updateRow({ ID: 112, ParentID: 147, Name: 'Ricardo Lalonso', HireDate: new Date('Dec 27, 2017') }, 225); + fix.detectChanges(); + const data = [ + { ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { ParentID: 147, Name: 'Ricardo Lalonso', HireDate: new Date('Dec 27, 2017') }, + { ParentID: 847, Name: 'Elizabeth Richards', HireDate: new Date('Dec 9, 2017') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 1, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 4, 1, 3); + expect(grid.getSelectedData()).toEqual(data); + + grid.transactions.undo(); + fix.detectChanges(); + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 1, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 4, 1, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + + grid.transactions.redo(); + fix.detectChanges(); + grid.transactions.commit(fix.componentInstance.data); + fix.detectChanges(); + GridSelectionFunctions.verifySelectedRange(grid, 2, 4, 1, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 4, 1, 3); + expect(grid.getSelectedData()).toEqual(data); + }); + + it('CRUD: selected range should not change when add row', () => { + const range = { rowStart: 1, rowEnd: 3, columnStart: 'ID', columnEnd: 'HireDate' }; + grid.selectRange(range); + fix.detectChanges(); + grid.addRow({ ID: 112, ParentID: 177, Name: 'Ricardo Matias', HireDate: new Date('Dec 27, 2017'), Age: 55, OnPTO: false }); + fix.detectChanges(); + + let selectedData = [ + { ID: 957, ParentID: 147, Name: 'Thomas Hardy', HireDate: new Date('Jul 19, 2009') }, + { ID: 317, ParentID: 147, Name: 'Monica Reyes', HireDate: new Date('Sep 18, 2014') }, + { ID: 225, ParentID: 847, Name: 'Laurence Johnson', HireDate: new Date('May 4, 2014') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 1, 3, 0, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 3, 0, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + grid.sort({ fieldName: 'ParentID', dir: SortingDirection.Asc, ignoreCase: false }); + fix.detectChanges(); + + const newSelectedData = [ + { ID: 101, ParentID: 17, Name: 'Casey Harper', HireDate: new Date('Mar 19, 2016') }, + { ID: 15, ParentID: 19, Name: 'Antonio Moreno', HireDate: new Date('May 4, 2014') }, + { ID: 475, ParentID: 147, Name: 'Michael Langdon', HireDate: new Date('Jul 3, 2011') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 1, 3, 0, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 3, 0, 3); + expect(grid.getSelectedData()).toEqual(newSelectedData); + grid.addRow({ ID: 258, ParentID: 21, Name: 'Mario Lopez', HireDate: new Date('May 27, 2017'), Age: 33, OnPTO: false }); + fix.detectChanges(); + + selectedData = [ + { ID: 101, ParentID: 17, Name: 'Casey Harper', HireDate: new Date('Mar 19, 2016') }, + { ID: 15, ParentID: 19, Name: 'Antonio Moreno', HireDate: new Date('May 4, 2014') }, + { ID: 258, ParentID: 21, Name: 'Mario Lopez', HireDate: new Date('May 27, 2017') } + ]; + GridSelectionFunctions.verifySelectedRange(grid, 1, 3, 0, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 3, 0, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + grid.transactions.undo(); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(grid, 1, 3, 0, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 3, 0, 3); + expect(grid.getSelectedData()).toEqual(newSelectedData); + + grid.transactions.redo(); + fix.detectChanges(); + grid.transactions.commit(fix.componentInstance.data); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(grid, 1, 3, 0, 3); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 3, 0, 3); + expect(grid.getSelectedData()).toEqual(selectedData); + }); + }); + + describe('None selection', () => { + let fix; + let grid: IgxGridComponent; + let detect; + + beforeEach(() => { + fix = TestBed.createComponent(CellSelectionNoneComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + detect = () => grid.cdr.detectChanges(); + }); + + it('When click on cell it should not be selected', () => { + const rangeChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const selectionChangeSpy = spyOn(grid.selected, 'emit').and.callThrough(); + const firstCell = grid.gridAPI.get_cell_by_index(1, 'ParentID'); + const secondCell = grid.gridAPI.get_cell_by_index(2, 'Name'); + const thirdCell = grid.gridAPI.get_cell_by_index(0, 'ID'); + + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + GridSelectionFunctions.verifyCellSelected(firstCell, false); + expect(firstCell.active).toBeTruthy(); + expect(grid.selectedCells.length).toBe(0); + + UIInteractions.simulateClickAndSelectEvent(secondCell, false, true); + fix.detectChanges(); + GridSelectionFunctions.verifyCellSelected(firstCell, false); + GridSelectionFunctions.verifyCellSelected(secondCell, false); + expect(grid.selectedCells.length).toBe(0); + + UIInteractions.simulateClickAndSelectEvent(thirdCell, true); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + expect(rangeChangeSpy).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifyCellSelected(firstCell, false); + GridSelectionFunctions.verifyCellSelected(secondCell, false); + GridSelectionFunctions.verifyCellSelected(thirdCell, false); + expect(grid.selectedCells.length).toBe(0); + expect(grid.getSelectedData().length).toBe(1); + expect(grid.getSelectedRanges()).toEqual([]); + }); + + it('When when navigate with keyboard cells should not be selected', () => { + const rangeChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const selectionChangeSpy = spyOn(grid.selected, 'emit').and.callThrough(); + let cell = grid.gridAPI.get_cell_by_index(1, 'ParentID'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', cell.nativeElement, true); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + expect(rangeChangeSpy).toHaveBeenCalledTimes(0); + + cell = grid.gridAPI.get_cell_by_index(2, 'ParentID'); + expect(cell.active).toBeTruthy(); + GridSelectionFunctions.verifyCellSelected(cell, false); + expect(grid.selectedCells.length).toBe(0); + + UIInteractions.triggerKeyDownEvtUponElem('arrowright', cell.nativeElement, true, false, true); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 2, 1, 2, false); + cell = grid.gridAPI.get_cell_by_index(2, 'Name'); + expect(cell.active).toBeTruthy(); + + UIInteractions.triggerKeyDownEvtUponElem('arrowup', cell.nativeElement, true); + fix.detectChanges(); + + cell = grid.gridAPI.get_cell_by_index(1, 'Name'); + expect(cell.active).toBeTruthy(); + GridSelectionFunctions.verifyCellSelected(cell, false); + expect(grid.selectedCells.length).toBe(0); + + UIInteractions.triggerKeyDownEvtUponElem('arrowleft', cell.nativeElement, true, false, true); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + expect(rangeChangeSpy).toHaveBeenCalledTimes(0); + cell = grid.gridAPI.get_cell_by_index(1, 'ParentID'); + GridSelectionFunctions.verifyCellSelected(cell, false); + expect(grid.selectedCells.length).toBe(0); + expect(grid.getSelectedData().length).toBe(1); + expect(grid.getSelectedRanges()).toEqual([]); + }); + + it('Should not select select a range with mouse dragging', () => { + const rangeChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const startCell = grid.gridAPI.get_cell_by_index(0, 'ID'); + const endCell = grid.gridAPI.get_cell_by_index(3, 'ID'); + + GridSelectionFunctions.selectCellsRangeNoWait(fix, startCell, endCell); + detect(); + + GridSelectionFunctions.verifyCellsRegionSelected(grid, 0, 3, 0, 0, false); + GridSelectionFunctions.verifyCellSelected(startCell, false); + GridSelectionFunctions.verifyCellSelected(endCell, false); + expect(rangeChangeSpy).toHaveBeenCalledTimes(0); + expect(grid.selectedCells.length).toBe(0); + expect(grid.getSelectedData().length).toBe(1); + expect(grid.getSelectedRanges()).toEqual([]); + }); + + it('Should select a region from API', () => { + const range = { rowStart: 0, rowEnd: 2, columnStart: 'Name', columnEnd: 'ParentID' }; + const expectedData = [ + { ParentID: 147, Name: 'Michael Langdon' }, + { ParentID: 147, Name: 'Thomas Hardy' }, + { ParentID: 147, Name: 'Monica Reyes' } + ]; + grid.selectRange(range); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellsRegionSelected(grid, 0, 2, 1, 2); + GridSelectionFunctions.verifySelectedRange(grid, 0, 2, 1, 2); + expect(grid.getSelectedData()).toEqual(expectedData); + }); + + it('Should select a cell from API', () => { + const selectionChangeSpy = spyOn(grid.selected, 'emit').and.callThrough(); + const cell = grid.gridAPI.get_cell_by_index(1, 'Name'); + cell.selected = true; + fix.detectChanges(); + + GridSelectionFunctions.verifyCellSelected(cell); + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + expect(grid.getSelectedData()).toEqual([{ Name: 'Thomas Hardy' }]); + expect(grid.selectedCells.length).toBe(1); + }); + + it('When change cell selection to multi it should be possible to select cells with mouse dragging', () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const startCell = grid.gridAPI.get_cell_by_index(0, 'ParentID'); + const endCell = grid.gridAPI.get_cell_by_index(1, 'ParentID'); + const expectedData = [ + { ParentID: 147 }, + { ParentID: 147 } + ]; + + expect(grid.cellSelection).toEqual(GridSelectionMode.none); + grid.cellSelection = GridSelectionMode.multiple; + fix.detectChanges(); + + GridSelectionFunctions.selectCellsRangeNoWait(fix, startCell, endCell); + detect(); + + expect(startCell.active).toBe(true); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 0, 1, 1, 1); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + expect(grid.selectedCells.length).toBe(2); + expect(grid.getSelectedData()).toEqual(expectedData); + GridSelectionFunctions.verifySelectedRange(grid, 0, 1, 1, 1); + }); + }); + + describe('Single selection', () => { + let fix; + let grid: IgxGridComponent; + let detect; + + beforeEach(() => { + fix = TestBed.createComponent(CellSelectionSingleComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + detect = () => grid.cdr.detectChanges(); + }); + + it('When click on cell it should selected', () => { + const rangeChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const selectionChangeSpy = spyOn(grid.selected, 'emit').and.callThrough(); + const firstCell = grid.gridAPI.get_cell_by_index(1, 'ParentID'); + const secondCell = grid.gridAPI.get_cell_by_index(2, 'Name'); + const thirdCell = grid.gridAPI.get_cell_by_index(0, 'ID'); + + // Click on a cell + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + GridSelectionFunctions.verifyCellSelected(firstCell); + expect(grid.selectedCells.length).toBe(1); + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + + // Click on a cell holding Ctrl + UIInteractions.simulateClickAndSelectEvent(secondCell, false, true); + fix.detectChanges(); + GridSelectionFunctions.verifyCellSelected(firstCell, false); + GridSelectionFunctions.verifyCellSelected(secondCell); + expect(grid.selectedCells.length).toBe(1); + expect(selectionChangeSpy).toHaveBeenCalledTimes(2); + + // Click on a cell holding Shift + UIInteractions.simulateClickAndSelectEvent(thirdCell, true); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellSelected(firstCell, false); + GridSelectionFunctions.verifyCellSelected(secondCell, false); + GridSelectionFunctions.verifyCellSelected(thirdCell); + expect(grid.selectedCells.length).toBe(1); + expect(selectionChangeSpy).toHaveBeenCalledTimes(3); + expect(rangeChangeSpy).toHaveBeenCalledTimes(0); + expect(grid.getSelectedData()).toEqual([{ ID: 475 }]); + GridSelectionFunctions.verifySelectedRange(grid, 0, 0, 0, 0); + }); + + it('Should deselect a selected cell with Ctrl + click', () => { + const selectionChangeSpy = spyOn(grid.selected, 'emit').and.callThrough(); + const firstCell = grid.gridAPI.get_cell_by_index(1, 'ParentID'); + + // Click on a cell + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + GridSelectionFunctions.verifyCellSelected(firstCell); + expect(grid.selectedCells.length).toBe(1); + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + + // Click on same cell holding Ctrl + UIInteractions.simulateClickAndSelectEvent(firstCell, false, true); + fix.detectChanges(); + GridSelectionFunctions.verifyCellSelected(firstCell, false); + expect(grid.selectedCells.length).toBe(0); + expect(selectionChangeSpy).toHaveBeenCalledTimes(2); + }); + + it('When when navigate with arrow keys cell selection should be changed', () => { + const selectionChangeSpy = spyOn(grid.selected, 'emit').and.callThrough(); + let cell = grid.gridAPI.get_cell_by_index(1, 'Name'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', cell.nativeElement, true); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(2); + cell = grid.gridAPI.get_cell_by_index(2, 'Name'); + GridSelectionFunctions.verifyCellSelected(cell); + expect(grid.selectedCells.length).toBe(1); + expect(grid.getSelectedData()).toEqual([{ Name: 'Monica Reyes' }]); + GridSelectionFunctions.verifySelectedRange(grid, 2, 2, 2, 2); + + UIInteractions.triggerKeyDownEvtUponElem('arrowleft', cell.nativeElement, true); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(3); + cell = grid.gridAPI.get_cell_by_index(2, 'ParentID'); + GridSelectionFunctions.verifyCellSelected(cell); + expect(grid.selectedCells.length).toBe(1); + expect(grid.getSelectedData()).toEqual([{ ParentID: 147 }]); + GridSelectionFunctions.verifySelectedRange(grid, 2, 2, 1, 1); + }); + + it('When when navigate with arrow keys and holding Shift only one cell should be selected', () => { + const selectionChangeSpy = spyOn(grid.selected, 'emit').and.callThrough(); + const rangeChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + let cell = grid.gridAPI.get_cell_by_index(3, 'ParentID'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('arrowup', cell.nativeElement, true, false, true); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(2); + cell = grid.gridAPI.get_cell_by_index(2, 'ParentID'); + GridSelectionFunctions.verifyCellSelected(cell); + expect(grid.selectedCells.length).toBe(1); + expect(grid.getSelectedData()).toEqual([{ ParentID: 147 }]); + GridSelectionFunctions.verifySelectedRange(grid, 2, 2, 1, 1); + + UIInteractions.triggerKeyDownEvtUponElem('arrowright', cell.nativeElement, true, false, true); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(3); + expect(rangeChangeSpy).toHaveBeenCalledTimes(0); + cell = grid.gridAPI.get_cell_by_index(2, 'Name'); + GridSelectionFunctions.verifyCellSelected(cell); + expect(grid.selectedCells.length).toBe(1); + expect(grid.getSelectedData()).toEqual([{ Name: 'Monica Reyes' }]); + GridSelectionFunctions.verifySelectedRange(grid, 2, 2, 2, 2); + }); + + it('Should not select select a range with mouse dragging', () => { + const rangeChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const startCell = grid.gridAPI.get_cell_by_index(0, 'ID'); + const endCell = grid.gridAPI.get_cell_by_index(1, 'ParentID'); + + UIInteractions.simulateClickAndSelectEvent(startCell); + fix.detectChanges(); + + GridSelectionFunctions.selectCellsRangeNoWait(fix, startCell, endCell); + detect(); + + GridSelectionFunctions.verifyCellSelected(startCell); + GridSelectionFunctions.verifyCellSelected(endCell, false); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 1, 0, 1, false); + + expect(rangeChangeSpy).toHaveBeenCalledTimes(0); + expect(grid.selectedCells.length).toBe(1); + expect(grid.getSelectedData()).toEqual([{ ID: 475 }]); + GridSelectionFunctions.verifySelectedRange(grid, 0, 0, 0, 0); + }); + + it('Should select a region from API', () => { + const range = { rowStart: 0, rowEnd: 2, columnStart: 'Name', columnEnd: 'ParentID' }; + const expectedData = [ + { ParentID: 147, Name: 'Michael Langdon' }, + { ParentID: 147, Name: 'Thomas Hardy' }, + { ParentID: 147, Name: 'Monica Reyes' } + ]; + grid.selectRange(range); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellsRegionSelected(grid, 0, 2, 1, 2); + GridSelectionFunctions.verifySelectedRange(grid, 0, 2, 1, 2); + expect(grid.getSelectedData()).toEqual(expectedData); + }); + + it('When change cell selection to multi it should be possible to select cells with mouse dragging', () => { + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const startCell = grid.gridAPI.get_cell_by_index(3, 'ParentID'); + const endCell = grid.gridAPI.get_cell_by_index(2, 'Name'); + const expectedData = [ + { ParentID: 147, Name: 'Monica Reyes' }, + { ParentID: 847, Name: 'Laurence Johnson' } + ]; + + expect(grid.cellSelection).toEqual(GridSelectionMode.single); + UIInteractions.simulateClickAndSelectEvent(startCell); + fix.detectChanges(); + GridSelectionFunctions.verifyCellSelected(startCell); + + grid.cellSelection = GridSelectionMode.multiple; + fix.detectChanges(); + + expect(grid.cellSelection).toEqual(GridSelectionMode.multiple); + GridSelectionFunctions.verifyCellSelected(startCell, false); + expect(grid.selectedCells.length).toBe(0); + + GridSelectionFunctions.selectCellsRangeNoWait(fix, startCell, endCell); + detect(); + + GridSelectionFunctions.verifyCellsRegionSelected(grid, 2, 3, 1, 2); + expect(grid.selectedCells.length).toBe(4); + expect(grid.getSelectedData()).toEqual(expectedData); + GridSelectionFunctions.verifySelectedRange(grid, 2, 3, 1, 2); + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + }); + + it('When change cell selection to none selected cells should be cleared', () => { + const cell = grid.gridAPI.get_cell_by_index(2, 'Name'); + + expect(grid.cellSelection).toEqual(GridSelectionMode.single); + + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + GridSelectionFunctions.verifyCellSelected(cell); + + grid.cellSelection = GridSelectionMode.none; + fix.detectChanges(); + + expect(grid.cellSelection).toEqual(GridSelectionMode.none); + GridSelectionFunctions.verifyCellSelected(cell, false); + expect(grid.selectedCells.length).toBe(0); + expect(grid.getSelectedData()).toEqual([]); + expect(grid.getSelectedRanges()).toEqual([]); + }); + + it('Should return correct selected data when selected event is emitted using mouse click and kb navigation', () => { + let selectedData = []; + grid.selected.subscribe(() => { + selectedData = grid.getSelectedData(); + }); + + const cell = grid.gridAPI.get_cell_by_index(2, 'Name'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + expect(selectedData.length).toBe(1); + expect(selectedData[0]).toEqual({ Name: 'Monica Reyes' }); + + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', cell.nativeElement, true, false, true); + fix.detectChanges(); + + expect(selectedData.length).toBe(1); + expect(selectedData[0]).toEqual({ Name: 'Laurence Johnson' }); + }); + }); + +}); diff --git a/projects/igniteui-angular/grids/grid/src/grid-clipboard.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-clipboard.spec.ts new file mode 100644 index 00000000000..2ebd4142ac7 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid-clipboard.spec.ts @@ -0,0 +1,199 @@ +import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxGridComponent } from './public_api'; +import { IgxGridClipboardComponent } from '../../../test-utils/grid-samples.spec'; +import { take } from 'rxjs/operators'; +import { GridFunctions } from '../../../test-utils/grid-functions.spec'; +import { IgxGridFilteringRowComponent } from 'igniteui-angular/grids/core'; +import { CancelableEventArgs } from 'igniteui-angular/core'; +import { IgxInputDirective } from 'igniteui-angular/input-group'; + +describe('IgxGrid - Clipboard #grid', () => { + + let fix: ComponentFixture; + let grid: IgxGridComponent; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + IgxGridClipboardComponent, NoopAnimationsModule + ] + }).compileComponents(); + })); + + beforeEach(() => { + fix = TestBed.createComponent(IgxGridClipboardComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + }); + + it('Copy data with default settings', () => { + const copySpy = spyOn(grid.gridCopy, 'emit').and.callThrough(); + const range = { rowStart: 0, rowEnd: 1, columnStart: 1, columnEnd: 3 }; + grid.selectRange(range); + fix.detectChanges(); + + const eventData = dispatchCopyEventOnGridBody(fix); + expect(copySpy).toHaveBeenCalledTimes(1); + expect(eventData). + + toEqual('ProductNameHeader\tDownloads\tReleased\r\n** Ignite UI for JavaScript **\t254\tfalse\r\n** NetAdvantage **\t127\ttrue\r\n'); + }); + + it('Copy data when there are no selected cells', () => { + const copySpy = spyOn(grid.gridCopy, 'emit').and.callThrough(); + const eventData = dispatchCopyEventOnGridBody(fix); + expect(copySpy).toHaveBeenCalledTimes(1); + expect(copySpy).toHaveBeenCalledWith({ + data: [], + cancel: false + }); + expect(eventData).toEqual(''); + }); + + it('Copy data with different separator', () => { + const copySpy = spyOn(grid.gridCopy, 'emit').and.callThrough(); + grid.clipboardOptions.separator = ';'; + grid.selectRange({ rowStart: 0, rowEnd: 0, columnStart: 0, columnEnd: 0 }); + grid.selectRange({ rowStart: 1, rowEnd: 1, columnStart: 1, columnEnd: 1 }); + fix.detectChanges(); + + let eventData = dispatchCopyEventOnGridBody(fix); + expect(copySpy).toHaveBeenCalledTimes(1); + expect(eventData).toEqual('ID;ProductNameHeader\r\n1;\r\n;** NetAdvantage **'); + + grid.clipboardOptions.separator = ','; + fix.detectChanges(); + + eventData = dispatchCopyEventOnGridBody(fix); + expect(copySpy).toHaveBeenCalledTimes(2); + expect(eventData).toEqual('ID,ProductNameHeader\r\n1,\r\n,** NetAdvantage **'); + }); + + it('Copy data without headers', () => { + const copySpy = spyOn(grid.gridCopy, 'emit').and.callThrough(); + grid.clipboardOptions.copyHeaders = false; + grid.selectRange({ rowStart: 1, rowEnd: 2, columnStart: 2, columnEnd: 3 }); + fix.detectChanges(); + + let eventData = dispatchCopyEventOnGridBody(fix); + expect(copySpy).toHaveBeenCalledTimes(1); + expect(eventData).toEqual('127\ttrue\r\n20\t\r\n'); + + grid.clipboardOptions.copyHeaders = true; + fix.detectChanges(); + + eventData = dispatchCopyEventOnGridBody(fix); + expect(copySpy).toHaveBeenCalledTimes(2); + expect(eventData).toEqual('Downloads\tReleased\r\n127\ttrue\r\n20\t\r\n'); + }); + + it('Copy data when paging is enabled', () => { + fix.componentInstance.paging = true; + fix.detectChanges(); + grid.paginator.perPage = 5; + fix.detectChanges(); + + grid.paginator.page = 1; + fix.detectChanges(); + const copySpy = spyOn(grid.gridCopy, 'emit').and.callThrough(); + grid.clipboardOptions.copyHeaders = false; + grid.selectRange({ rowStart: 1, rowEnd: 2, columnStart: 2, columnEnd: 3 }); + fix.detectChanges(); + + const eventData = dispatchCopyEventOnGridBody(fix); + expect(copySpy).toHaveBeenCalledTimes(1); + expect(eventData).toEqual('0\ttrue\r\n1000\t\r\n'); + }); + + it('Disable clipboardOptions', () => { + const copySpy = spyOn(grid.gridCopy, 'emit').and.callThrough(); + grid.clipboardOptions.enabled = false; + grid.selectRange({ rowStart: 0, rowEnd: 2, columnStart: 0, columnEnd: 3 }); + fix.detectChanges(); + + const eventData = dispatchCopyEventOnGridBody(fix); + expect(copySpy).toHaveBeenCalledTimes(0); + expect(eventData).toEqual(''); + }); + + it('Disable copyFormatters', () => { + const copySpy = spyOn(grid.gridCopy, 'emit').and.callThrough(); + grid.clipboardOptions.copyFormatters = false; + grid.selectRange({ rowStart: 1, rowEnd: 3, columnStart: 1, columnEnd: 1 }); + fix.detectChanges(); + + let eventData = dispatchCopyEventOnGridBody(fix); + expect(copySpy).toHaveBeenCalledTimes(1); + expect(eventData).toEqual('ProductNameHeader\r\nNetAdvantage\r\nIgnite UI for Angular\r\n'); + grid.clipboardOptions.copyFormatters = true; + fix.detectChanges(); + + eventData = dispatchCopyEventOnGridBody(fix); + expect(copySpy).toHaveBeenCalledTimes(2); + expect(eventData).toEqual('ProductNameHeader\r\n** NetAdvantage **\r\n** Ignite UI for Angular **\r\n** null **'); + }); + + it('Cancel gridCopy event ', () => { + const copySpy = spyOn(grid.gridCopy, 'emit').and.callThrough(); + grid.gridCopy.pipe(take(1)).subscribe((e: CancelableEventArgs) => e.cancel = true); + grid.selectRange({ rowStart: 1, rowEnd: 3, columnStart: 0, columnEnd: 3 }); + fix.detectChanges(); + + const eventData = dispatchCopyEventOnGridBody(fix); + expect(copySpy).toHaveBeenCalledTimes(1); + expect(copySpy).toHaveBeenCalledWith({ + data: grid.getSelectedData(true, true), + cancel: true + }); + expect(eventData).toEqual('undefined'); + }); + + it('Copy when there is a cell in edit mode', fakeAsync(() => { + const copySpy = spyOn(grid.gridCopy, 'emit').and.callThrough(); + const cell = grid.getCellByColumn(0, 'ProductName'); + grid.gridAPI.get_cell_by_index(0, 'ProductName').nativeElement.dispatchEvent( new Event('dblclick')); + tick(16); + fix.detectChanges(); + expect(cell.editMode).toBe(true); + + grid.selectRange({ rowStart: 1, rowEnd: 3, columnStart: 0, columnEnd: 3 }); + tick(16); + fix.detectChanges(); + + expect(cell.editMode).toBe(true); + + const eventData = dispatchCopyEventOnGridBody(fix); + expect(copySpy).toHaveBeenCalledTimes(0); + expect(eventData).toEqual(''); + })); + + it('Should be able to copy from quick filtering input', fakeAsync(() => { + fix.componentInstance.allowFiltering = true; + fix.detectChanges(); + const productNameFilterCellChip = GridFunctions.getFilterChipsForColumn('ProductName', fix)[0]; + productNameFilterCellChip.nativeElement.click(); + tick(100); + fix.detectChanges(); + + const filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + const inputDebugElement = filteringRow.query(By.directive(IgxInputDirective)); + const input = inputDebugElement.nativeElement; + const searchVal = 'aaa'; + + const ev = new ClipboardEvent('copy', {bubbles: true, clipboardData: new DataTransfer()}); + ev.clipboardData.setData('text/plain', searchVal); + input.dispatchEvent(ev); + fix.detectChanges(); + const eventData = ev.clipboardData.getData('text/plain'); + expect(eventData).toEqual(searchVal); + })); +}); + +const dispatchCopyEventOnGridBody = (fixture) => { + const gridBody = fixture.debugElement.query(By.css('.igx-grid__tbody')).nativeElement; + const ev = new ClipboardEvent('copy', {clipboardData: new DataTransfer()}); + gridBody.dispatchEvent(ev); + fixture.detectChanges(); + return ev.clipboardData.getData('text/plain'); +}; diff --git a/projects/igniteui-angular/grids/grid/src/grid-collapsible-columns.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-collapsible-columns.spec.ts new file mode 100644 index 00000000000..f4fc2273286 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid-collapsible-columns.spec.ts @@ -0,0 +1,639 @@ +import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { IgxGridComponent } from './grid.component'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { + CollapsibleColumnGroupTestComponent, + CollapsibleGroupsTemplatesTestComponent, + CollapsibleGroupsDynamicColComponent +} from '../../../test-utils/grid-samples.spec'; +import { GridFunctions } from '../../../test-utils/grid-functions.spec'; +import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; +import { DropPosition } from 'igniteui-angular/grids/core'; +import { IgxColumnGroupComponent } from 'igniteui-angular/grids/core'; +import { SortingDirection } from 'igniteui-angular/core'; + +describe('IgxGrid - multi-column headers #grid', () => { + let contactInf; + let countryInf; + let addressInf: IgxColumnGroupComponent; + let regionInf; + let cityInf; + let phoneCol; + let countryCol; + let emptyCol; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + CollapsibleColumnGroupTestComponent, + CollapsibleGroupsTemplatesTestComponent, + CollapsibleGroupsDynamicColComponent + ] + }).compileComponents(); + })); + + describe('Base Tests', () => { + let fixture; + let grid: IgxGridComponent; + + beforeEach(() => { + fixture = TestBed.createComponent(CollapsibleColumnGroupTestComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + contactInf = GridFunctions.getColGroup(grid, 'Contact Information'); + countryInf = GridFunctions.getColGroup(grid, 'Country Information'); + addressInf = GridFunctions.getColGroup(grid, 'Address Information'); + regionInf = GridFunctions.getColGroup(grid, 'Region Information'); + cityInf = GridFunctions.getColGroup(grid, 'City Information'); + phoneCol = grid.getColumnByName('Phone'); + countryCol = grid.getColumnByName('Country'); + emptyCol = grid.getColumnByName('Empty'); + }); + + it('verify setting collapsible to a column group ', () => { + GridFunctions.verifyColumnIsHidden(contactInf, false, 10); + GridFunctions.verifyColumnIsHidden(countryInf, true, 10); + + GridFunctions.verifyGroupIsExpanded(fixture, addressInf); + GridFunctions.verifyGroupIsExpanded(fixture, contactInf, false); + + spyOn(addressInf.collapsibleChange, 'emit').and.callThrough(); + addressInf.collapsible = false; + fixture.detectChanges(); + + GridFunctions.verifyColumnIsHidden(contactInf, false, 19); + GridFunctions.verifyColumnIsHidden(countryInf, false, 19); + GridFunctions.verifyGroupIsExpanded(fixture, addressInf, false); + expect(addressInf.collapsibleChange.emit).toHaveBeenCalledTimes(1); + expect(addressInf.collapsibleChange.emit).toHaveBeenCalledWith(false); + + addressInf.collapsible = true; + fixture.detectChanges(); + + GridFunctions.verifyColumnIsHidden(contactInf, false, 10); + GridFunctions.verifyColumnIsHidden(countryInf, true, 10); + GridFunctions.verifyGroupIsExpanded(fixture, addressInf); + expect(addressInf.collapsibleChange.emit).toHaveBeenCalledTimes(2); + expect(addressInf.collapsibleChange.emit).toHaveBeenCalledWith(true); + }); + + it('verify setting collapsible when all the column has same visibleWhenCollapsed', () => { + addressInf.collapsible = false; + fixture.detectChanges(); + + expect(contactInf.collapsible).toBeFalsy(); + expect(countryInf.collapsible).toBeFalsy(); + + GridFunctions.verifyGroupIsExpanded(fixture, contactInf, false); + GridFunctions.verifyGroupIsExpanded(fixture, countryInf, false); + + countryCol.visibleWhenCollapsed = false; + phoneCol.visibleWhenCollapsed = true; + + fixture.detectChanges(); + + expect(contactInf.collapsible).toBeTruthy(); + expect(countryInf.collapsible).toBeTruthy(); + + GridFunctions.verifyGroupIsExpanded(fixture, contactInf); + GridFunctions.verifyGroupIsExpanded(fixture, countryInf); + + GridFunctions.verifyColumnIsHidden(countryCol, false, 12); + GridFunctions.verifyColumnIsHidden(emptyCol, false, 12); + GridFunctions.verifyColumnIsHidden(regionInf, true, 12); + + GridFunctions.verifyColumnIsHidden(grid.getColumnByName('Fax'), false, 12); + GridFunctions.verifyColumnIsHidden(phoneCol, true, 12); + }); + + it('verify setting expanded to a column group', () => { + spyOn(addressInf.expandedChange, 'emit').and.callThrough(); + addressInf.expanded = false; + fixture.detectChanges(); + + expect(addressInf.expanded).toBeFalsy(); + GridFunctions.verifyColumnIsHidden(contactInf, true, 16); + GridFunctions.verifyColumnIsHidden(countryInf, false, 16); + + GridFunctions.verifyGroupIsExpanded(fixture, addressInf, true, false); + expect(addressInf.expandedChange.emit).toHaveBeenCalledTimes(1); + expect(addressInf.expandedChange.emit).toHaveBeenCalledWith(false); + + addressInf.expanded = true; + fixture.detectChanges(); + + expect(addressInf.expanded).toBeTruthy(); + GridFunctions.verifyColumnIsHidden(contactInf, false, 10); + GridFunctions.verifyColumnIsHidden(countryInf, true, 10); + + GridFunctions.verifyGroupIsExpanded(fixture, addressInf); + expect(addressInf.expandedChange.emit).toHaveBeenCalledTimes(2); + expect(addressInf.expandedChange.emit).toHaveBeenCalledWith(true); + }); + + it('verify setting expanded to a column group form UI', () => { + spyOn(addressInf.expandedChange, 'emit').and.callThrough(); + GridFunctions.clickGroupExpandIndicator(fixture, addressInf); + fixture.detectChanges(); + + expect(addressInf.expanded).toBeFalsy(); + GridFunctions.verifyColumnIsHidden(contactInf, true, 16); + GridFunctions.verifyColumnIsHidden(countryInf, false, 16); + + GridFunctions.verifyGroupIsExpanded(fixture, addressInf, true, false); + expect(addressInf.expandedChange.emit).toHaveBeenCalledTimes(1); + expect(addressInf.expandedChange.emit).toHaveBeenCalledWith(false); + + GridFunctions.clickGroupExpandIndicator(fixture, addressInf); + fixture.detectChanges(); + + expect(addressInf.expanded).toBeTruthy(); + GridFunctions.verifyColumnIsHidden(contactInf, false, 10); + GridFunctions.verifyColumnIsHidden(countryInf, true, 10); + + GridFunctions.verifyGroupIsExpanded(fixture, addressInf); + expect(addressInf.expandedChange.emit).toHaveBeenCalledTimes(2); + expect(addressInf.expandedChange.emit).toHaveBeenCalledWith(true); + }); + + it('verify setting visibleWhenCollapseChange when group is expanded', () => { + addressInf.expanded = false; + countryCol.visibleWhenCollapsed = false; + regionInf.visibleWhenCollapsed = false; + fixture.detectChanges(); + spyOn(countryCol.visibleWhenCollapsedChange, 'emit').and.callThrough(); + spyOn(cityInf.visibleWhenCollapsedChange, 'emit').and.callThrough(); + spyOn(emptyCol.visibleWhenCollapsedChange, 'emit').and.callThrough(); + + GridFunctions.verifyGroupIsExpanded(fixture, countryInf); + GridFunctions.verifyColumnsAreHidden([countryCol, emptyCol, regionInf], false, 13); + GridFunctions.verifyColumnIsHidden(cityInf, true, 13); + + // Change visibleWhenCollapsed to column country + countryCol.visibleWhenCollapsed = true; + fixture.detectChanges(); + + GridFunctions.verifyGroupIsExpanded(fixture, countryInf); + GridFunctions.verifyColumnsAreHidden([countryCol, cityInf], true, 12); + GridFunctions.verifyColumnsAreHidden([emptyCol, regionInf], false, 12); + expect(countryCol.visibleWhenCollapsedChange.emit).toHaveBeenCalledTimes(1); + expect(countryCol.visibleWhenCollapsedChange.emit).toHaveBeenCalledWith(true); + + // Change visibleWhenCollapsed to group + cityInf.visibleWhenCollapsed = false; + fixture.detectChanges(); + + GridFunctions.verifyGroupIsExpanded(fixture, countryInf); + GridFunctions.verifyColumnIsHidden(countryCol, true, 15); + GridFunctions.verifyColumnsAreHidden([emptyCol, regionInf, cityInf], false, 15); + expect(cityInf.visibleWhenCollapsedChange.emit).toHaveBeenCalledTimes(1); + expect(cityInf.visibleWhenCollapsedChange.emit).toHaveBeenCalledWith(false); + + // Change visibleWhenCollapsed form null false + emptyCol.visibleWhenCollapsed = true; + fixture.detectChanges(); + + GridFunctions.verifyGroupIsExpanded(fixture, countryInf); + GridFunctions.verifyColumnsAreHidden([countryCol, emptyCol], true, 14); + GridFunctions.verifyColumnsAreHidden([regionInf, cityInf], false, 14); + expect(emptyCol.visibleWhenCollapsedChange.emit).toHaveBeenCalledTimes(1); + expect(emptyCol.visibleWhenCollapsedChange.emit).toHaveBeenCalledWith(true); + }); + + it('verify setting visibleWhenCollapseChange when group is collapsed', () => { + addressInf.expanded = false; + countryCol.visibleWhenCollapsed = false; + regionInf.visibleWhenCollapsed = false; + countryInf.expanded = false; + fixture.detectChanges(); + + spyOn(regionInf.visibleWhenCollapsedChange, 'emit').and.callThrough(); + spyOn(cityInf.visibleWhenCollapsedChange, 'emit').and.callThrough(); + spyOn(countryCol.visibleWhenCollapsedChange, 'emit').and.callThrough(); + + // set visibleWhenCollapsed to true + regionInf.visibleWhenCollapsed = true; + fixture.detectChanges(); + + GridFunctions.verifyGroupIsExpanded(fixture, countryInf, true, false); + GridFunctions.verifyColumnIsHidden(countryCol, true, 15); + GridFunctions.verifyColumnsAreHidden([regionInf, cityInf, emptyCol], false, 15); + + // set visibleWhenCollapsed to false + cityInf.visibleWhenCollapsed = false; + fixture.detectChanges(); + + GridFunctions.verifyGroupIsExpanded(fixture, countryInf, true, false); + GridFunctions.verifyColumnsAreHidden([countryCol, cityInf], true, 12); + GridFunctions.verifyColumnsAreHidden([regionInf, emptyCol], false, 12); + + // set visibleWhenCollapsed to null + countryCol.visibleWhenCollapsed = undefined; + fixture.detectChanges(); + + GridFunctions.verifyGroupIsExpanded(fixture, countryInf, true, false); + GridFunctions.verifyColumnsAreHidden([countryCol, cityInf], true, 12); + GridFunctions.verifyColumnsAreHidden([regionInf, emptyCol], false, 12); + + // verify events + expect(regionInf.visibleWhenCollapsedChange.emit).toHaveBeenCalledTimes(1); + expect(regionInf.visibleWhenCollapsedChange.emit).toHaveBeenCalledWith(true); + expect(cityInf.visibleWhenCollapsedChange.emit).toHaveBeenCalledTimes(1); + expect(cityInf.visibleWhenCollapsedChange.emit).toHaveBeenCalledWith(false); + expect(countryCol.visibleWhenCollapsedChange.emit).toHaveBeenCalledTimes(1); + expect(countryCol.visibleWhenCollapsedChange.emit).toHaveBeenCalledWith(undefined); + }); + + it('verify ARIA Support', () => { + const contactInfHeader = GridFunctions.getColumnGroupHeaderCell(contactInf.header, fixture); + const addressInfHeader = GridFunctions.getColumnGroupHeaderCell(addressInf.header, fixture); + + expect(contactInfHeader.attributes['role']).toEqual('columnheader'); + expect(addressInfHeader.attributes['role']).toEqual('columnheader'); + + expect(contactInfHeader.attributes['aria-label']).toEqual(contactInf.header); + expect(addressInfHeader.attributes['aria-label']).toEqual(addressInf.header); + expect(addressInfHeader.attributes['aria-expanded']).toEqual('true'); + + addressInf.expanded = false; + fixture.detectChanges(); + expect(addressInfHeader.attributes['aria-expanded']).toEqual('false'); + }); + }); + + describe('Templates Tests', () => { + let fixture; + let grid: IgxGridComponent; + + beforeEach(() => { + fixture = TestBed.createComponent(CollapsibleGroupsTemplatesTestComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + addressInf = GridFunctions.getColGroup(grid, 'Address Information'); + }); + + it('verify that templates can be defined in the markup', () => { + const generalInf = GridFunctions.getColGroup(grid, 'General Information'); + GridFunctions.verifyGroupIsExpanded(fixture, generalInf, true, true, ['remove', 'add']); + + generalInf.expanded = false; + fixture.detectChanges(); + + GridFunctions.verifyGroupIsExpanded(fixture, generalInf, true, false, ['remove', 'add']); + }); + + it('verify setting templates by property', fakeAsync(() => { + GridFunctions.verifyGroupIsExpanded(fixture, addressInf); + + // Set template + addressInf.collapsibleIndicatorTemplate = fixture.componentInstance.indicatorTemplate; + fixture.detectChanges(); + + GridFunctions.verifyGroupIsExpanded(fixture, addressInf, true, true, ['lock', 'lock_open']); + + addressInf.expanded = false; + fixture.detectChanges(); + + GridFunctions.verifyGroupIsExpanded(fixture, addressInf, true, false, ['lock', 'lock_open']); + + // remove template + addressInf.collapsibleIndicatorTemplate = null; + // Changing the template back takes an async cycle, so tick is needed + tick(); + fixture.detectChanges(); + + GridFunctions.verifyGroupIsExpanded(fixture, addressInf, true, false); + })); + }); + + describe('Dynamic Columns Tests', () => { + let fixture; + let grid: IgxGridComponent; + + beforeEach(() => { + pending('The test will work when use Angular 9'); + fixture = TestBed.createComponent(CollapsibleGroupsDynamicColComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + }); + + it('verify adding columns', () => { + pending('The test will work when use Angular 9'); + const firstGroup = GridFunctions.getColGroup(grid, 'First'); + GridFunctions.verifyGroupIsExpanded(fixture, firstGroup, false); + fixture.detectChanges(); + + // add a column to first group + fixture.componentInstance.columnGroups[0].columns.push({ field: 'Fax', type: 'string', visibleWhenCollapsed: false }); + fixture.detectChanges(); + + GridFunctions.verifyGroupIsExpanded(fixture, firstGroup); + + firstGroup.expanded = false; + fixture.detectChanges(); + + GridFunctions.verifyGroupIsExpanded(fixture, firstGroup, true, false); + GridFunctions.verifyColumnsAreHidden( + [grid.getColumnByName('ID'), grid.getColumnByName('CompanyName'), grid.getColumnByName('ContactName')], false, 7); + GridFunctions.verifyColumnIsHidden(grid.getColumnByName('Fax'), true, 7); + }); + + it('verify deleting columns', () => { + pending('The test will work when use Angular 9'); + const secondGroup = GridFunctions.getColGroup(grid, 'Second'); + GridFunctions.verifyGroupIsExpanded(fixture, secondGroup); + fixture.detectChanges(); + + // delete a column + fixture.componentInstance.columnGroups[1].columns.pop(); + fixture.detectChanges(); + + GridFunctions.verifyGroupIsExpanded(fixture, secondGroup); + + GridFunctions.verifyColumnIsHidden(grid.getColumnByName('PostalCode'), false, 6); + + // delete another column + fixture.componentInstance.columnGroups[1].columns = fixture.componentInstance.columnGroups[1].columns.splice(2); + fixture.detectChanges(); + + GridFunctions.verifyGroupIsExpanded(fixture, secondGroup, false); + }); + + it('verify updating columns', () => { + pending('The test will work when use Angular 9'); + const secondGroup = GridFunctions.getColGroup(grid, 'Second'); + const firstGroup = GridFunctions.getColGroup(grid, 'First'); + + GridFunctions.verifyGroupIsExpanded(fixture, firstGroup, false); + GridFunctions.verifyGroupIsExpanded(fixture, secondGroup); + + // update a column a column + fixture.componentInstance.columnGroups[0].columns[0].visibleWhenCollapsed = false; + fixture.detectChanges(); + fixture.detectChanges(); + + GridFunctions.verifyGroupIsExpanded(fixture, firstGroup); + GridFunctions.verifyColumnsAreHidden([grid.getColumnByName('CompanyName'), grid.getColumnByName('ContactName')], true, 5); + GridFunctions.verifyColumnIsHidden(grid.getColumnByName('ID'), false, 5); + + // update a column in second group + GridFunctions.verifyColumnIsHidden(grid.getColumnByName('ContactTitle'), true, 5); + fixture.componentInstance.columnGroups[1].columns[0].visibleWhenCollapsed = false; + fixture.detectChanges(); + fixture.detectChanges(); + + GridFunctions.verifyColumnIsHidden(grid.getColumnByName('ContactTitle'), false, 6); + + fixture.componentInstance.columnGroups[1].columns[1].visibleWhenCollapsed = false; + fixture.detectChanges(); + fixture.detectChanges(); + + GridFunctions.verifyGroupIsExpanded(fixture, secondGroup, false); + }); + }); + + describe('Integration Tests', () => { + let fixture; + let grid: IgxGridComponent; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(CollapsibleColumnGroupTestComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + regionInf = GridFunctions.getColGroup(grid, 'Region Information'); + countryInf = GridFunctions.getColGroup(grid, 'Country Information'); + contactInf = GridFunctions.getColGroup(grid, 'Contact Information'); + addressInf = GridFunctions.getColGroup(grid, 'Address Information'); + phoneCol = grid.getColumnByName('Phone'); + countryCol = grid.getColumnByName('Country'); + })); + + it('Hiding: Verify that expanded state is preserved when hide column group', () => { + addressInf.expanded = false; + fixture.detectChanges(); + + GridFunctions.verifyGroupIsExpanded(fixture, addressInf, true, false); + addressInf.hidden = true; + fixture.detectChanges(); + + GridFunctions.verifyColumnIsHidden(addressInf, true, 6); + addressInf.hidden = false; + fixture.detectChanges(); + + GridFunctions.verifyColumnIsHidden(addressInf, false, 16); + GridFunctions.verifyGroupIsExpanded(fixture, addressInf, true, false); + + addressInf.expanded = true; + fixture.detectChanges(); + + GridFunctions.verifyColumnIsHidden(addressInf, false, 10); + GridFunctions.verifyGroupIsExpanded(fixture, addressInf, true, true); + addressInf.hidden = true; + fixture.detectChanges(); + + GridFunctions.verifyColumnIsHidden(addressInf, true, 6); + addressInf.hidden = false; + fixture.detectChanges(); + + GridFunctions.verifyColumnIsHidden(addressInf, false, 10); + GridFunctions.verifyGroupIsExpanded(fixture, addressInf, true, true); + }); + + it('Hiding: Verify that column can be hidden when the group is expanded', () => { + expect(addressInf.expanded).toBe(true); + GridFunctions.verifyColumnIsHidden(phoneCol, false, 10); + phoneCol.hidden = true; + fixture.detectChanges(); + + expect(addressInf.expanded).toBe(true); + GridFunctions.verifyGroupIsExpanded(fixture, addressInf, true, true); + GridFunctions.verifyColumnIsHidden(phoneCol, true, 9); + phoneCol.hidden = false; + fixture.detectChanges(); + + expect(addressInf.expanded).toBe(true); + GridFunctions.verifyGroupIsExpanded(fixture, addressInf, true, true); + GridFunctions.verifyColumnIsHidden(phoneCol, false, 10); + }); + + it('Hiding: Verify collapse a group when for a column disableHiding is set', () => { + phoneCol.disableHiding = true; + fixture.detectChanges(); + + addressInf.expanded = false; + fixture.detectChanges(); + + expect(phoneCol.disableHiding).toBe(true); + GridFunctions.verifyColumnIsHidden(phoneCol, true, 16); + GridFunctions.verifyGroupIsExpanded(fixture, addressInf, true, false); + }); + + it(`Hiding: Verify that when a column has set to hidden to true and + visibleWhenCollapseChange to false, it is previewed in expanded group`, () => { + expect(addressInf.collapsible).toBe(true); + expect(fixture.componentInstance.hideContactInformation).toBe(true); + + GridFunctions.verifyColumnIsHidden(contactInf, false, 10); + GridFunctions.verifyGroupIsExpanded(fixture, addressInf, true, true); + + contactInf.hidden = true; + fixture.detectChanges(); + + GridFunctions.verifyColumnIsHidden(contactInf, true, 6); + contactInf.hidden = false; + fixture.detectChanges(); + + GridFunctions.verifyColumnIsHidden(contactInf, false, 10); + GridFunctions.verifyGroupIsExpanded(fixture, addressInf, true, true); + }); + + it('Pinning: Verify that expanded state is preserved when pin column group', () => { + expect(addressInf.pinned).toBe(false); + expect(addressInf.expanded).toBe(true); + GridFunctions.verifyGroupIsExpanded(fixture, addressInf, true, true); + addressInf.pinned = true; + fixture.detectChanges(); + + expect(addressInf.pinned).toBe(true); + expect(addressInf.expanded).toBe(true); + GridFunctions.verifyGroupIsExpanded(fixture, addressInf, true, true); + addressInf.expanded = false; + fixture.detectChanges(); + + expect(addressInf.pinned).toBe(true); + expect(addressInf.expanded).toBe(false); + GridFunctions.verifyGroupIsExpanded(fixture, addressInf, true, false); + addressInf.pinned = false; + fixture.detectChanges(); + + expect(addressInf.pinned).toBe(false); + expect(addressInf.expanded).toBe(false); + GridFunctions.verifyGroupIsExpanded(fixture, addressInf, true, false); + }); + + it('Editing: Verify edit mode is closed when expand/collapse a group', () => { + const contactNameCol = grid.getColumnByName('ContactName'); + const cell = grid.getCellByColumn(0, 'ContactName'); + contactNameCol.editable = true; + fixture.detectChanges(); + + UIInteractions.simulateDoubleClickAndSelectEvent(grid.gridAPI.get_cell_by_index(0, 'ContactName')); + fixture.detectChanges(); + + expect(cell.editMode).toBe(true); + addressInf.expanded = false; + fixture.detectChanges(); + + GridFunctions.verifyGroupIsExpanded(fixture, addressInf, true, false); + expect(cell.editMode).toBe(false); + }); + + it('Row Editing: Verify edit mode is closed when expand/collapse a group', () => { + grid.primaryKey = 'ID'; + fixture.detectChanges(); + + grid.rowEditable = true; + fixture.detectChanges(); + addressInf.expanded = false; + fixture.detectChanges(); + + const cell = grid.getCellByColumn(0, 'Country'); + UIInteractions.simulateDoubleClickAndSelectEvent(grid.gridAPI.get_cell_by_index(0, 'Country')); + fixture.detectChanges(); + + expect(cell.editMode).toBe(true); + expect(grid.gridAPI.crudService.row).not.toBeNull(); + addressInf.expanded = true; + fixture.detectChanges(); + + expect(grid.gridAPI.crudService.row).toBeNull(); + GridFunctions.verifyGroupIsExpanded(fixture, addressInf, true, true); + }); + + it('Moving: Verify that expanded state is preserved when move column group', fakeAsync(() => { + const generalInf = GridFunctions.getColGroup(grid, 'General Information'); + + expect(addressInf.expanded).toBeTruthy(); + expect(generalInf.collapsible).toBeFalsy(); + expect(generalInf.visibleIndex).toBe(1); + + grid.moveColumn(generalInf, addressInf, DropPosition.AfterDropTarget); + tick(); + fixture.detectChanges(); + + expect(addressInf.expanded).toBeTruthy(); + expect(generalInf.collapsible).toBeFalsy(); + expect(generalInf.visibleIndex).toBe(3); + addressInf.expanded = false; + tick(); + fixture.detectChanges(); + + expect(addressInf.expanded).toBeFalsy(); + grid.moveColumn(generalInf, addressInf, DropPosition.BeforeDropTarget); + tick(); + fixture.detectChanges(); + + expect(addressInf.expanded).toBeFalsy(); + expect(generalInf.collapsible).toBeFalsy(); + expect(generalInf.visibleIndex).toBe(1); + })); + + it('Moving: Verify moving column inside the group', () => { + const postalCode = grid.getColumnByName('PostalCode'); + addressInf.expanded = false; + countryCol.visibleWhenCollapsed = false; + regionInf.visibleWhenCollapsed = false; + postalCode.visibleWhenCollapsed = false; + fixture.detectChanges(); + + expect(regionInf.expanded).toBe(true); + expect(countryInf.expanded).toBe(true); + expect(countryCol.visibleIndex).toBe(4); + + grid.moveColumn(countryCol, regionInf); + fixture.detectChanges(); + + expect(regionInf.expanded).toBe(true); + GridFunctions.verifyGroupIsExpanded(fixture, regionInf, true, true); + expect(countryInf.expanded).toBe(true); + GridFunctions.verifyGroupIsExpanded(fixture, countryInf, true, true); + expect(countryCol.visibleIndex).toBe(6); + }); + + it('Search: search when a group is expanded', async () => { + const highlightClass = '.igx-highlight'; + grid.findNext('Mexico'); + await wait(30); + fixture.detectChanges(); + + let spans = fixture.nativeElement.querySelectorAll(highlightClass); + expect(spans.length).toBe(0); + + addressInf.expanded = false; + fixture.detectChanges(); + + spans = fixture.nativeElement.querySelectorAll(highlightClass); + expect(spans.length).toBe(2); + }); + + it('Group By: test when group by a column', () => { + addressInf.expanded = false; + countryCol.visibleWhenCollapsed = false; + regionInf.visibleWhenCollapsed = false; + fixture.detectChanges(); + + GridFunctions.verifyGroupIsExpanded(fixture, countryInf, true, true); + grid.groupBy({ fieldName: 'Country', dir: SortingDirection.Asc, ignoreCase: false }); + fixture.detectChanges(); + + GridFunctions.verifyGroupIsExpanded(fixture, countryInf, true, true); + GridFunctions.verifyColumnIsHidden(countryCol, false, 13); + grid.hideGroupedColumns = true; + fixture.detectChanges(); + + GridFunctions.verifyGroupIsExpanded(fixture, countryInf, true, true); + GridFunctions.verifyColumnIsHidden(countryCol, true, 12); + }); + }); +}); diff --git a/projects/igniteui-angular/grids/grid/src/grid-filtering-advanced.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-filtering-advanced.spec.ts new file mode 100644 index 00000000000..0c8c636f9b1 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid-filtering-advanced.spec.ts @@ -0,0 +1,1922 @@ +import { fakeAsync, TestBed, tick, flush, ComponentFixture, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxGridComponent } from './grid.component'; +import { UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { GridFunctions } from '../../../test-utils/grid-functions.spec'; +import { + IgxGridAdvancedFilteringColumnGroupComponent, + IgxGridAdvancedFilteringComponent, + IgxGridExternalAdvancedFilteringComponent, + IgxGridAdvancedFilteringBindingComponent, + IgxGridAdvancedFilteringDynamicColumnsComponent, + IgxGridAdvancedFilteringSerializedTreeComponent, + IgxGridAdvancedFilteringWithToolbarComponent +} from '../../../test-utils/grid-samples.spec'; +import { IgxHierarchicalGridExportComponent, IgxHierarchicalGridTestBaseComponent, IgxHierGridExternalAdvancedFilteringComponent } from '../../../test-utils/hierarchical-grid-components.spec'; +import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; +import { By } from '@angular/platform-browser'; +import { IgxHGridRemoteOnDemandComponent, IgxHierarchicalGridMissingChildDataComponent } from '../../hierarchical-grid/src/hierarchical-grid.spec'; +import { QueryBuilderFunctions } from '../../../query-builder/src/query-builder/query-builder-functions.spec'; +import { IFilteringEventArgs, IgxGridNavigationService, IgxGridToolbarAdvancedFilteringComponent } from 'igniteui-angular/grids/core'; +import { FilteringExpressionsTree, FilteringLogic, FormattedValuesFilteringStrategy, IGridResourceStrings, IgxNumberFilteringOperand, IgxStringFilteringOperand } from 'igniteui-angular/core'; +import { QueryBuilderSelectors } from 'igniteui-angular/query-builder/src/query-builder/query-builder.common'; +import { IgxDateTimeEditorDirective } from 'igniteui-angular/directives'; +import { IgxHierarchicalGridComponent } from 'igniteui-angular/grids/hierarchical-grid'; + +describe('IgxGrid - Advanced Filtering #grid - ', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxGridAdvancedFilteringColumnGroupComponent, + IgxGridAdvancedFilteringComponent, + IgxGridExternalAdvancedFilteringComponent, + IgxGridAdvancedFilteringBindingComponent, + IgxHierGridExternalAdvancedFilteringComponent, + IgxGridAdvancedFilteringDynamicColumnsComponent, + IgxGridAdvancedFilteringWithToolbarComponent, + IgxHierarchicalGridTestBaseComponent, + IgxHierarchicalGridExportComponent, + IgxHGridRemoteOnDemandComponent + ], + providers: [ + IgxGridNavigationService + ] + }).compileComponents(); + })); + + describe('General tests - ', () => { + let fix: ComponentFixture; + let grid: IgxGridComponent; + + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(IgxGridAdvancedFilteringComponent); + grid = fix.componentInstance.grid; + fix.detectChanges(); + })); + + it('Should show/hide Advanced Filtering button in toolbar based on respective input.', fakeAsync(() => { + // Verify Advanced Filtering button in toolbar is visible. + let advFilterButton = GridFunctions.getAdvancedFilteringButton(fix); + expect(advFilterButton !== null && advFilterButton !== undefined).toBe(true, 'Adv.Filter button is not visible.'); + + grid.allowAdvancedFiltering = false; + fix.detectChanges(); + + // Verify Advanced Filtering button in toolbar is not visible. + advFilterButton = GridFunctions.getAdvancedFilteringButton(fix); + expect(advFilterButton !== null && advFilterButton !== undefined).toBe(false, 'Adv.Filter button is visible.'); + + grid.allowAdvancedFiltering = true; + fix.detectChanges(); + + // Verify Advanced Filtering button in toolbar is visible. + advFilterButton = GridFunctions.getAdvancedFilteringButton(fix); + expect(advFilterButton !== null && advFilterButton !== undefined).toBe(true, 'Adv.Filter button is not visible.'); + })); + + it('Should correctly initialize the Advanced Filtering dialog.', fakeAsync(() => { + // Open Advanced Filtering dialog. + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + const advFilteringDialog = GridFunctions.getAdvancedFilteringComponent(fix); + + // Verify AF dialog is opened. + expect(advFilteringDialog).not.toBeNull(); + expect(advFilteringDialog.querySelector('igx-query-builder')).not.toBeNull(); + + // Verify there are not filters present and that the default text is shown. + expect(grid.advancedFilteringExpressionsTree).toBeUndefined(); + QueryBuilderFunctions.verifyRootAndSubGroupExpressionsCount(fix, 0, 0); + + // Close Advanced Filtering dialog. + GridFunctions.clickAdvancedFilteringCancelButton(fix); + tick(200); + fix.detectChanges(); + + // Verify AF dialog is closed. + expect(GridFunctions.getAdvancedFilteringComponent(fix)).toBeNull(); + })); + + it('Should open/close Advanced Filtering dialog through API.', fakeAsync(() => { + // Open dialog through API. + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + // Verify AF dialog is opened. + expect(GridFunctions.getAdvancedFilteringComponent(fix)).not.toBeNull(); + + // Close dialog through API. + grid.closeAdvancedFilteringDialog(false); + tick(100); + fix.detectChanges(); + + // Verify AF dialog is closed. + expect(GridFunctions.getAdvancedFilteringComponent(fix)).toBeNull(); + })); + + it('Should close Advanced Filtering dialog through API by respecting \'applyChanges\' argument.', fakeAsync(() => { + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + // Click the initial 'Add Condition' button of the query builder. + QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fix, 0); + tick(100); + fix.detectChanges(); + + // Populate edit inputs. + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 1); // Select 'ProductName' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 2); // Select 'Starts With' operator. + let input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(input, 'ign', fix); // Type filter value. + + // Commit the populated expression. + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix); + fix.detectChanges(); + + // Close dialog through API. + grid.closeAdvancedFilteringDialog(true); + tick(100); + fix.detectChanges(); + + // Verify AF dialog is closed. + expect(GridFunctions.getAdvancedFilteringComponent(fix)).toBeNull(); + // Verify the filter changes are applied. + expect(grid.filteredData.length).toEqual(2); + expect(GridFunctions.getCurrentCellFromGrid(grid, 0, 1).value).toBe('Ignite UI for JavaScript'); + expect(GridFunctions.getCurrentCellFromGrid(grid, 1, 1).value).toBe('Ignite UI for Angular'); + + // Open the dialog again. + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + // Click the existing chip to enter edit mode. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [0]); + tick(50); + fix.detectChanges(); + // Edit the filter value. + input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(input, 'some non-existing value', fix); // Type filter value. + // Commit the populated expression. + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix); + fix.detectChanges(); + + // Close dialog through API. + grid.closeAdvancedFilteringDialog(false); + tick(100); + fix.detectChanges(); + + // Verify AF dialog is closed. + expect(GridFunctions.getAdvancedFilteringComponent(fix)).toBeNull(); + // Verify the filter changes are NOT applied. + expect(grid.filteredData.length).toEqual(2); + expect(GridFunctions.getCurrentCellFromGrid(grid, 0, 1).value).toBe('Ignite UI for JavaScript'); + expect(GridFunctions.getCurrentCellFromGrid(grid, 1, 1).value).toBe('Ignite UI for Angular'); + })); + + it('Should update the Advanced Filtering button in toolbar when (filtering)/(clear filtering).', fakeAsync(() => { + // Verify that the advanced filtering button indicates there are no filters. + let advFilterBtn = GridFunctions.getAdvancedFilteringButton(fix); + expect(Array.from(advFilterBtn.children).some(c => (c as any).classList.contains('igx-adv-filter--column-number'))) + .toBe(false, 'Button indicates there is active filtering.'); + + // Open Advanced Filtering dialog. + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + // Click the initial 'Add Condition' button of the query builder. + QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fix, 0); + tick(100); + fix.detectChanges(); + + // Populate edit inputs. + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 1); // Select 'ProductName' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 0); // Select 'Contains' operator. + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(input, 'angular', fix); // Type filter value. + + // Commit the populated expression. + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix); + fix.detectChanges(); + + // Apply the filters. + GridFunctions.clickAdvancedFilteringApplyButton(fix); + fix.detectChanges(); + + // Verify that the advanced filtering button indicates there are filters. + advFilterBtn = GridFunctions.getAdvancedFilteringButton(fix); + expect(Array.from(advFilterBtn.children).some(c => (c as any).classList.contains('igx-adv-filter--column-number'))) + .toBe(true, 'Button indicates there is no active filtering.'); + + // Open Advanced Filtering dialog. + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + // Clear the filters. + GridFunctions.clickAdvancedFilteringClearFilterButton(fix); + fix.detectChanges(); + + // Close the dialog. + GridFunctions.clickAdvancedFilteringApplyButton(fix); + fix.detectChanges(); + + // Verify that the advanced filtering button indicates there are no filters. + advFilterBtn = GridFunctions.getAdvancedFilteringButton(fix); + expect(Array.from(advFilterBtn.children).some(c => (c as any).classList.contains('igx-adv-filter--column-number'))) + .toBe(false, 'Button indicates there is active filtering.'); + })); + + it('The Clear/Cancel/Apply buttons type should be set to "button"', fakeAsync(() => { + // Open Advanced Filtering dialog. + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + // Get Clear/Cancel/Apply buttons types. + const clearButtonType = GridFunctions.getAdvancedFilteringClearFilterButton(fix).getAttributeNode('type').value; + const cancelButtonType = GridFunctions.getAdvancedFilteringCancelButton(fix).getAttributeNode('type').value; + const applyButtonType = GridFunctions.getAdvancedFilteringApplyButton(fix).getAttributeNode('type').value; + + const expectedButtonType = 'button'; + + // Verify buttons type is set to "button". + expect(clearButtonType).toBe(expectedButtonType, 'Clear button type is not "button"'); + expect(cancelButtonType).toBe(expectedButtonType, 'Cancel button type is not "button"'); + expect(applyButtonType).toBe(expectedButtonType, 'Apply button type is not "button"'); + })); + + it('Should emit the filtering event when applying filters.', fakeAsync(() => { + spyOn(grid.filtering, 'emit'); + + // Open Advanced Filtering dialog. + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + // Click the initial 'Add Condition' button of the query builder. + QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fix, 0); + tick(100); + fix.detectChanges(); + + // Populate edit inputs. + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 1); // Select 'ProductName' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 0); // Select 'Contains' operator. + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(input, 'angular', fix); // Type filter value. + + // Commit the populated expression. + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix); + fix.detectChanges(); + + // Apply the filters. + GridFunctions.clickAdvancedFilteringApplyButton(fix); + tick(100); + fix.detectChanges(); + + // Ensure that filtering event was emitted with expected arguments + expect(grid.filtering.emit).toHaveBeenCalledWith(jasmine.objectContaining({ + owner: grid, + filteringExpressions: grid.advancedFilteringExpressionsTree, + cancel: false + })); + })); + + it('Should cancel filtering if cancel is set to true.', fakeAsync(() => { + spyOn(grid.filtering, 'emit').and.callFake((args: IFilteringEventArgs) => { + args.cancel = true; + }); + + // Open Advanced Filtering dialog. + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + // Click the initial 'Add Condition' button of the query builder. + QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fix, 0); + tick(100); + fix.detectChanges(); + + // Populate edit inputs. + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 1); // Select 'ProductName' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 0); // Select 'Contains' operator. + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(input, 'angular', fix); // Type filter value. + + // Commit the populated expression. + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix); + fix.detectChanges(); + + // Apply the filters. + GridFunctions.clickAdvancedFilteringApplyButton(fix); + tick(100); + fix.detectChanges(); + + // Ensure that cancel flag is true + expect(grid.filtering.emit).toHaveBeenCalled(); + const emittedArgs: IFilteringEventArgs = (grid.filtering.emit as jasmine.Spy).calls.mostRecent().args[0]; + expect(emittedArgs.cancel).toBeTrue(); + + // Ensure that grid.filteredData is null + expect(grid.filteredData).toEqual(null); + })); + + it('Should emit the filteringDone event when applying filters.', fakeAsync(() => { + spyOn(grid.filteringDone, 'emit'); + + // Open Advanced Filtering dialog. + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + // Click the initial 'Add Condition' button of the query builder. + QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fix, 0); + tick(100); + fix.detectChanges(); + + // Populate edit inputs. + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 1); // Select 'ProductName' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 0); // Select 'Contains' operator. + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(input, 'angular', fix); // Type filter value. + + // Commit the populated expression. + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix); + fix.detectChanges(); + + // Apply the filters. + GridFunctions.clickAdvancedFilteringApplyButton(fix); + tick(100); + fix.detectChanges(); + + expect(grid.filteringDone.emit).toHaveBeenCalledWith(grid.advancedFilteringExpressionsTree); + expect(grid.nativeElement.querySelector('.igx-adv-filter--column-number').textContent).toContain('1'); + })); + + it('Applying/Clearing filter through the API should correctly update the UI and correctly show number of filtered columns', fakeAsync(() => { + grid.height = '800px'; + fix.detectChanges(); + tick(50); + + // Verify the initial state of the grid and that no filters are present. + expect(grid.filteredData).toBeNull(); + expect(grid.nativeElement.querySelector('.igx-adv-filter--column-number')).toBeNull(); + + // Apply advanced filter through API. + const tree = new FilteringExpressionsTree(FilteringLogic.And); + tree.filteringOperands.push({ + fieldName: 'Downloads', searchVal: 100, condition: IgxNumberFilteringOperand.instance().condition('greaterThan'), + conditionName: 'greaterThan' + }); + const orTree = new FilteringExpressionsTree(FilteringLogic.Or); + orTree.filteringOperands.push({ + fieldName: 'ProductName', searchVal: 'angular', condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains', + ignoreCase: true + }); + orTree.filteringOperands.push({ + fieldName: 'ProductName', searchVal: 'script', condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains', + ignoreCase: true + }); + tree.filteringOperands.push(orTree); + grid.advancedFilteringExpressionsTree = tree; + fix.detectChanges(); + + // Verify the state of the grid after filtering. + expect(grid.filteredData.length).toBe(2); + expect(grid.nativeElement.querySelector('.igx-adv-filter--column-number').textContent).toContain('(2)'); + expect(GridFunctions.getExcelFilterIconFiltered(fix, 'ProductName')).toBeDefined(); + expect(GridFunctions.getExcelFilterIconFiltered(fix, 'Downloads')).toBeDefined(); + + // Clear filters through API. + grid.advancedFilteringExpressionsTree = null; + fix.detectChanges(); + + // Verify there are not filters present and that the default text is shown. + expect(grid.advancedFilteringExpressionsTree).toBeNull(); + expect(grid.nativeElement.querySelector('.igx-adv-filter--column-number')).toBeNull(); + })); + + it('Applying/Clearing filter through the API should correctly update the UI.', fakeAsync(() => { + // Test prerequisites + grid.height = '800px'; + fix.detectChanges(); + tick(50); + + // Verify the initial state of the grid and that no filters are present. + expect(grid.filteredData).toBeNull(); + expect(grid.rowList.length).toBe(8); + expect(GridFunctions.getCurrentCellFromGrid(grid, 0, 1).value).toBe('Ignite UI for JavaScript'); + expect(GridFunctions.getCurrentCellFromGrid(grid, 1, 1).value).toBe('NetAdvantage'); + + // Apply advanced filter through API. + const tree = new FilteringExpressionsTree(FilteringLogic.And); + tree.filteringOperands.push({ + fieldName: 'Downloads', searchVal: 100, condition: IgxNumberFilteringOperand.instance().condition('greaterThan'), + conditionName: 'greaterThan' + }); + const orTree = new FilteringExpressionsTree(FilteringLogic.Or); + orTree.filteringOperands.push({ + fieldName: 'ProductName', searchVal: 'angular', condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains', + ignoreCase: true + }); + orTree.filteringOperands.push({ + fieldName: 'ProductName', searchVal: 'script', condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains', + ignoreCase: true + }); + tree.filteringOperands.push(orTree); + grid.advancedFilteringExpressionsTree = tree; + fix.detectChanges(); + + // Verify the state of the grid after filtering. + expect(grid.filteredData.length).toBe(2); + expect(grid.rowList.length).toBe(2); + expect(GridFunctions.getCurrentCellFromGrid(grid, 0, 1).value).toBe('Ignite UI for JavaScript'); + expect(GridFunctions.getCurrentCellFromGrid(grid, 1, 1).value).toBe('Some other item with Script'); + + // Open Advanced Filtering dialog. + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + // Verify there is a root group with 'And' operator line and 2 children. + const rootGroup = QueryBuilderFunctions.getQueryBuilderTreeRootGroup(fix); + expect(rootGroup).not.toBeNull(); + QueryBuilderFunctions.verifyOperatorLine(QueryBuilderFunctions.getQueryBuilderTreeRootGroupOperatorLine(fix) as HTMLElement, 'and'); + expect(QueryBuilderFunctions.getQueryBuilderTreeChildItems(rootGroup as HTMLElement).length).toBe(2); + + // Verify the content of the first child (expression) of the root group. + QueryBuilderFunctions.verifyExpressionChipContent(fix, [1], 'Downloads', 'Greater Than', '100'); + + // Verify the content of the second child (group) of the root group. + const group = QueryBuilderFunctions.getQueryBuilderTreeItem(fix, [0]); + expect(QueryBuilderFunctions.getQueryBuilderTreeChildItems(group as HTMLElement, false).length).toBe(2); + QueryBuilderFunctions.verifyExpressionChipContent(fix, [0, 0], 'ProductName', 'Contains', 'angular'); + QueryBuilderFunctions.verifyExpressionChipContent(fix, [0, 1], 'ProductName', 'Contains', 'script'); + // Verify the operator line of the child group. + QueryBuilderFunctions.verifyOperatorLine(QueryBuilderFunctions.getQueryBuilderTreeGroupOperatorLine(fix, [0]) as HTMLElement, 'or'); + + // Close Advanced Filtering dialog. + GridFunctions.clickAdvancedFilteringCancelButton(fix); + tick(100); + fix.detectChanges(); + + // Clear filters through API. + grid.advancedFilteringExpressionsTree = null; + fix.detectChanges(); + + // Open Advanced Filtering dialog. + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + // Verify there are not filters present and that the default text is shown. + expect(grid.advancedFilteringExpressionsTree).toBeNull(); + QueryBuilderFunctions.verifyRootAndSubGroupExpressionsCount(fix, 0, 0); + })); + + it('Applying/Clearing filter through the UI should correctly update the API.', fakeAsync(() => { + // Test prerequisites + grid.height = '800px'; + fix.detectChanges(); + tick(50); + + // Verify the initial state of the grid and that no filters are present. + expect(grid.advancedFilteringExpressionsTree).toBeUndefined(); + expect(grid.filteredData).toBeNull(); + expect(grid.rowList.length).toBe(8); + expect(GridFunctions.getCurrentCellFromGrid(grid, 0, 1).value).toBe('Ignite UI for JavaScript'); + expect(GridFunctions.getCurrentCellFromGrid(grid, 1, 1).value).toBe('NetAdvantage'); + + // Open Advanced Filtering dialog. + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + // Click the initial 'Add Condition' button of the query builder. + QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fix, 0); + tick(100); + fix.detectChanges(); + + // Populate edit inputs. + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 1); // Select 'ProductName' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 0); // Select 'Contains' operator. + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(input, 'script', fix); // Type filter value. + + // Commit the populated expression. + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix); + fix.detectChanges(); + + // Apply the filters. + GridFunctions.clickAdvancedFilteringApplyButton(fix); + tick(100); + fix.detectChanges(); + + // Verify the state of the grid after the filtering. + expect(grid.advancedFilteringExpressionsTree !== null && grid.advancedFilteringExpressionsTree !== undefined).toBe(true); + expect(grid.filteredData.length).toBe(2); + expect(grid.rowList.length).toBe(2); + expect(GridFunctions.getCurrentCellFromGrid(grid, 0, 1).value).toBe('Ignite UI for JavaScript'); + expect(GridFunctions.getCurrentCellFromGrid(grid, 1, 1).value).toBe('Some other item with Script'); + + // Open Advanced Filtering dialog. + grid.openAdvancedFilteringDialog(); + tick(100); + fix.detectChanges(); + + // Clear the filters. + GridFunctions.clickAdvancedFilteringClearFilterButton(fix); + tick(100); + fix.detectChanges(); + + // Verify that no filters are present. + expect(grid.advancedFilteringExpressionsTree).toBeNull(); + expect(grid.filteredData).toBeNull(); + expect(grid.rowList.length).toBe(8); + expect(GridFunctions.getCurrentCellFromGrid(grid, 0, 1).value).toBe('Ignite UI for JavaScript'); + expect(GridFunctions.getCurrentCellFromGrid(grid, 1, 1).value).toBe('NetAdvantage'); + })); + + it('Should apply filters on Apply button click without prior Commit button click', fakeAsync(() => { + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + // Click the initial 'Add Condition' button of the query builder. + QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fix, 0); + tick(100); + fix.detectChanges(); + + // Populate edit inputs. + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 1); // Select 'ProductName' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 2); // Select 'Starts With' operator. + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(input, 'ign', fix); // Type filter value. + + GridFunctions.clickAdvancedFilteringApplyButton(fix); + tick(100); + fix.detectChanges(); + + // Verify the filter results. + expect(grid.filteredData.length).toEqual(2); + expect(grid.rowList.length).toBe(2); + expect(GridFunctions.getCurrentCellFromGrid(grid, 0, 1).value).toBe('Ignite UI for JavaScript'); + expect(GridFunctions.getCurrentCellFromGrid(grid, 1, 1).value).toBe('Ignite UI for Angular'); + })); + + it('Should close the dialog on Apply button click if not all expression inputs are set', fakeAsync(() => { + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + // Click the initial 'Add Condition' button of the query builder. + QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fix, 0); + tick(100); + fix.detectChanges(); + + GridFunctions.clickAdvancedFilteringApplyButton(fix); + tick(100); + fix.detectChanges(); + + // Verify the dialog is closed an no records are filtered + expect(GridFunctions.getAdvancedFilteringComponent(fix)).toBeNull(); + expect(grid.filteredData).toBe(null); + + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + // Click the initial 'Add Condition' button of the query builder. + QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fix, 0); + tick(100); + fix.detectChanges(); + + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 1); // Select 'ProductName' column. + + GridFunctions.clickAdvancedFilteringApplyButton(fix); + tick(100); + fix.detectChanges(); + + expect(GridFunctions.getAdvancedFilteringComponent(fix)).toBeNull(); + expect(grid.filteredData).toBe(null); + + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + // Click the initial 'Add Condition' button of the query builder. + QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fix, 0); + tick(100); + fix.detectChanges(); + + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 1); // Select 'ProductName' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 2); // Select 'Starts With' operator. + + GridFunctions.clickAdvancedFilteringApplyButton(fix); + tick(100); + fix.detectChanges(); + + expect(GridFunctions.getAdvancedFilteringComponent(fix)).toBeNull(); + expect(grid.filteredData).toBe(null); + })); + + it('Column dropdown should contain only filterable columns.', fakeAsync(() => { + // Make the 'Downloads', 'Released' and 'ReleaseDate' columns non-filterable. + grid.getColumnByName('Downloads').filterable = false; + grid.getColumnByName('Released').filterable = false; + grid.getColumnByName('ReleaseDate').filterable = false; + grid.cdr.detectChanges(); + tick(100); + + // Open Advanced Filtering dialog. + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + const queryBuilderElement: HTMLElement = fix.debugElement.queryAll(By.css(`.${QueryBuilderSelectors.QUERY_BUILDER_TREE}`))[0].nativeElement; + + // Click the initial 'Add Condition' button of the query builder. + QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fix, 0); + tick(100); + fix.detectChanges(); + + // Open column dropdown and verify that only filterable columns are present. + QueryBuilderFunctions.clickQueryBuilderColumnSelect(fix); + fix.detectChanges(); + const dropdownItems = QueryBuilderFunctions.getQueryBuilderSelectDropdownItems(queryBuilderElement); + expect(dropdownItems.length).toBe(4); + expect((dropdownItems[0] as HTMLElement).innerText).toBe('HeaderID'); + expect((dropdownItems[1] as HTMLElement).innerText).toBe('ProductName'); + expect((dropdownItems[2] as HTMLElement).innerText).toBe('Another Field'); + expect((dropdownItems[3] as HTMLElement).innerText).toBe('ReleaseTime'); + })); + + it('Should scroll the adding buttons into view when the add icon of a chip is clicked.', fakeAsync(() => { + // Apply advanced filter through API. + const tree = new FilteringExpressionsTree(FilteringLogic.Or); + for (let index = 0; index < 30; index++) { + tree.filteringOperands.push({ + fieldName: 'Downloads', searchVal: index, condition: IgxNumberFilteringOperand.instance().condition('equals'), conditionName: 'equals' + }); + } + grid.advancedFilteringExpressionsTree = tree; + fix.detectChanges(); + + // Open Advanced Filtering dialog. + GridFunctions.clickAdvancedFilteringButton(fix); + fix.detectChanges(); + + // Scroll to the top. + const exprContainer = QueryBuilderFunctions.getQueryBuilderExpressionsContainer(fix); + tick(50); + exprContainer.scrollTop = 0; + + // Hover the last visible expression chip + const expressionItem = fix.nativeElement.querySelectorAll(`.${QueryBuilderSelectors.FILTER_TREE_EXPRESSION_ITEM}`)[9]; + expressionItem.dispatchEvent(new MouseEvent('mouseenter')); + tick(); + fix.detectChanges(); + + // Click the add icon to display the adding buttons. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChipIcon(fix, [9], 'add'); + fix.detectChanges(); + tick(50); + + // Verify the adding buttons are in view. + const addingButtons = QueryBuilderFunctions.getQueryBuilderTreeRootGroupButtons(fix, 0); + for (const addingButton of addingButtons) { + verifyElementIsInExpressionsContainerView(fix, addingButton as HTMLElement); + } + })); + + it('Should scroll the newly added expression into view when the respective add button is clicked.', fakeAsync(() => { + // Apply advanced filter through API. + const tree = new FilteringExpressionsTree(FilteringLogic.Or); + for (let index = 0; index < 30; index++) { + tree.filteringOperands.push({ + fieldName: 'Downloads', searchVal: index, condition: IgxNumberFilteringOperand.instance().condition('equals'), conditionName: 'equals' + }); + } + grid.advancedFilteringExpressionsTree = tree; + fix.detectChanges(); + + // Open Advanced Filtering dialog. + GridFunctions.clickAdvancedFilteringButton(fix); + fix.detectChanges(); + + // Scroll to the top. + const exprContainer = QueryBuilderFunctions.getQueryBuilderExpressionsContainer(fix); + tick(50); + exprContainer.scrollTop = 0; + + // Hover the previous to last visible expression chip. + const expressionItem = fix.nativeElement.querySelectorAll(`.${QueryBuilderSelectors.FILTER_TREE_EXPRESSION_ITEM}`)[9]; + expressionItem.dispatchEvent(new MouseEvent('mouseenter')); + tick(); + fix.detectChanges(); + + // Click the add icon to display the adding buttons. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChipIcon(fix, [9], 'add'); + fix.detectChanges(); + tick(50); + + // Click the 'add condition' button. + const addCondButton = QueryBuilderFunctions.getQueryBuilderTreeRootGroupButtons(fix, 0)[0] as HTMLElement; + addCondButton.click(); + fix.detectChanges(); + + // Verify the edit mode container (the one with the editing inputs) is in view. + verifyElementIsInExpressionsContainerView(fix, QueryBuilderFunctions.getQueryBuilderEditModeContainer(fix, false) as HTMLElement); + })); + + it('Should scroll to the expression when entering its edit mode.', fakeAsync(() => { + // Apply advanced filter through API. + const tree = new FilteringExpressionsTree(FilteringLogic.Or); + for (let index = 0; index < 30; index++) { + tree.filteringOperands.push({ + fieldName: 'Downloads', searchVal: index, condition: IgxNumberFilteringOperand.instance().condition('equals'), conditionName: 'equals' + }); + } + grid.advancedFilteringExpressionsTree = tree; + fix.detectChanges(); + + // Open Advanced Filtering dialog. + GridFunctions.clickAdvancedFilteringButton(fix); + fix.detectChanges(); + + // Scroll to the top. + const exprContainer = QueryBuilderFunctions.getQueryBuilderExpressionsContainer(fix); + tick(50); + exprContainer.scrollTop = 0; + + // Hover the last visible expression chip + const expressionItem = fix.nativeElement.querySelectorAll(`.${QueryBuilderSelectors.FILTER_TREE_EXPRESSION_ITEM}`)[9]; + expressionItem.dispatchEvent(new MouseEvent('mouseenter')); + tick(); + fix.detectChanges(); + + // Click the chip to enter edit mode of the expression. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [9]); + fix.detectChanges(); + tick(50); + + // Verify the edit mode container (the one with the editing inputs) is in view. + verifyElementIsInExpressionsContainerView(fix, QueryBuilderFunctions.getQueryBuilderEditModeContainer(fix, false) as HTMLElement); + })); + + it('Should clear all conditions and groups when the \'clear filter\' button is clicked.', fakeAsync(() => { + // Apply advanced filter through API. + const tree = new FilteringExpressionsTree(FilteringLogic.And); + tree.filteringOperands.push({ + fieldName: 'Downloads', searchVal: 100, condition: IgxNumberFilteringOperand.instance().condition('greaterThan'), + conditionName: 'greaterThan' + }); + const orTree = new FilteringExpressionsTree(FilteringLogic.Or); + orTree.filteringOperands.push({ + fieldName: 'ProductName', searchVal: 'angular', condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains', + ignoreCase: true + }); + orTree.filteringOperands.push({ + fieldName: 'ProductName', searchVal: 'script', condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains', + ignoreCase: true + }); + tree.filteringOperands.push(orTree); + grid.advancedFilteringExpressionsTree = tree; + fix.detectChanges(); + + // Open Advanced Filtering dialog. + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + // Verify there are filters in the dialog. + expect(QueryBuilderFunctions.getQueryBuilderTreeRootGroup(fix)).not.toBeNull(); + + // Clear the filters. + GridFunctions.clickAdvancedFilteringClearFilterButton(fix); + tick(100); + fix.detectChanges(); + + // Verify there are no filters in the dialog. + QueryBuilderFunctions.verifyRootAndSubGroupExpressionsCount(fix, 0, 0); + })); + + it('Should keep edited conditions and groups inside AF dialog when applying and opening it again.', fakeAsync(() => { + // Apply advanced filter through API. + const tree = new FilteringExpressionsTree(FilteringLogic.And); + tree.filteringOperands.push({ + fieldName: 'Downloads', searchVal: 100, condition: IgxNumberFilteringOperand.instance().condition('greaterThan'), + conditionName: 'greaterThan' + }); + const orTree = new FilteringExpressionsTree(FilteringLogic.Or); + orTree.filteringOperands.push({ + fieldName: 'ProductName', searchVal: 'angular', condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains', + ignoreCase: true + }); + orTree.filteringOperands.push({ + fieldName: 'ProductName', searchVal: 'script', condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains', + ignoreCase: true + }); + tree.filteringOperands.push(orTree); + grid.advancedFilteringExpressionsTree = tree; + fix.detectChanges(); + + // Verify the current filter state. + expect(grid.filteredData.length).toBe(2); + expect(grid.rowList.length).toBe(2); + expect(GridFunctions.getCurrentCellFromGrid(grid, 0, 1).value).toBe('Ignite UI for JavaScript'); + expect(GridFunctions.getCurrentCellFromGrid(grid, 1, 1).value).toBe('Some other item with Script'); + + // Open Advanced Filtering dialog. + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + // Verify the content of the first expression in the inner 'or' group. + QueryBuilderFunctions.verifyExpressionChipContent(fix, [0, 0], 'ProductName', 'Contains', 'angular'); + + // Edit the first expression in the inner 'or' group. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [0, 0]); // Click the chip + tick(200); + fix.detectChanges(); + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(input, 'a', fix); // Type filter value. + + // Commit the populated expression. + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix); + fix.detectChanges(); + + // Apply the filters. + GridFunctions.clickAdvancedFilteringApplyButton(fix); + tick(100); + fix.detectChanges(); + + // Verify the new current filter state. + expect(grid.filteredData.length).toBe(3); + expect(grid.rowList.length).toBe(3); + expect(GridFunctions.getCurrentCellFromGrid(grid, 0, 1).value).toBe('Ignite UI for JavaScript'); + expect(GridFunctions.getCurrentCellFromGrid(grid, 1, 1).value).toBe('NetAdvantage'); + + // Open Advanced Filtering dialog. + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + // Verify the content of the first expression in the inner 'or' group. + QueryBuilderFunctions.verifyExpressionChipContent(fix, [0, 0], 'ProductName', 'Contains', 'a'); + })); + + it('Should not keep changes over edited conditions and groups inside AF dialog when canceling and opening it again.', fakeAsync(() => { + // Apply advanced filter through API. + const tree = new FilteringExpressionsTree(FilteringLogic.And); + tree.filteringOperands.push({ + fieldName: 'Downloads', searchVal: 100, condition: IgxNumberFilteringOperand.instance().condition('greaterThan'), + conditionName: 'greaterThan' + }); + const orTree = new FilteringExpressionsTree(FilteringLogic.Or); + orTree.filteringOperands.push({ + fieldName: 'ProductName', searchVal: 'angular', condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains', + ignoreCase: true + }); + orTree.filteringOperands.push({ + fieldName: 'ProductName', searchVal: 'script', condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains', + ignoreCase: true + }); + tree.filteringOperands.push(orTree); + grid.advancedFilteringExpressionsTree = tree; + fix.detectChanges(); + + // Verify the current filter state. + expect(grid.filteredData.length).toBe(2); + expect(grid.rowList.length).toBe(2); + expect(GridFunctions.getCurrentCellFromGrid(grid, 0, 1).value).toBe('Ignite UI for JavaScript'); + expect(GridFunctions.getCurrentCellFromGrid(grid, 1, 1).value).toBe('Some other item with Script'); + + // Open Advanced Filtering dialog. + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + // Verify the content of the first expression in the inner 'or' group. + QueryBuilderFunctions.verifyExpressionChipContent(fix, [0, 0], 'ProductName', 'Contains', 'angular'); + + // Edit the first expression in the inner 'or' group. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [0, 0]); // Click the chip + tick(200); + fix.detectChanges(); + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(input, 'a', fix); // Type filter value. + + // Commit the populated expression. + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix); + fix.detectChanges(); + + // Cancel the filters. + GridFunctions.clickAdvancedFilteringCancelButton(fix); + tick(100); + fix.detectChanges(); + + // Verify the new filter state remains unchanged. + expect(grid.filteredData.length).toBe(2); + expect(grid.rowList.length).toBe(2); + expect(GridFunctions.getCurrentCellFromGrid(grid, 0, 1).value).toBe('Ignite UI for JavaScript'); + expect(GridFunctions.getCurrentCellFromGrid(grid, 1, 1).value).toBe('Some other item with Script'); + + // Open Advanced Filtering dialog. + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + // Verify the content of the first expression in the inner 'or' group. + QueryBuilderFunctions.verifyExpressionChipContent(fix, [0, 0], 'ProductName', 'Contains', 'angular'); + })); + + it('Should not close the AF dialog when clicking outside of it.', fakeAsync(() => { + // Open Advanced Filtering dialog. + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + // Verify that the Advanced Filtering dialog is opened. + expect(GridFunctions.getAdvancedFilteringComponent(fix)).not.toBeNull('Advanced Filtering dialog is not opened.'); + + grid.nativeElement.click(); + tick(200); + fix.detectChanges(); + + // Verify that the Advanced Filtering dialog remains opened. + expect(GridFunctions.getAdvancedFilteringComponent(fix)).not.toBeNull('Advanced Filtering dialog is not opened.'); + })); + + it('Should filter by cells formatted data when using FormattedValuesFilteringStrategy', fakeAsync(() => { + const formattedStrategy = new FormattedValuesFilteringStrategy(['Downloads']); + grid.filterStrategy = formattedStrategy; + const downloadsFormatter = (val: number): number => { + if (!val || val > 0 && val < 100) { + return 1; + } else if (val >= 100 && val < 500) { + return 2; + } else { + return 3; + } + }; + grid.columnList.get(2).formatter = downloadsFormatter; + fix.detectChanges(); + + grid.openAdvancedFilteringDialog(); + tick(200); + fix.detectChanges(); + + // Add root group. + QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fix, 0); + tick(100); + fix.detectChanges(); + + // Add a new expression + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 2); // Select 'ProductName' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 0); // Select 'Contains' operator. + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(input, '1', fix); // Type filter value. + + // Commit the populated expression. + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix); + tick(100); + fix.detectChanges(); + GridFunctions.clickAdvancedFilteringApplyButton(fix); + tick(100); + fix.detectChanges(); + + const rows = GridFunctions.getRows(fix); + expect(rows.length).toEqual(3, 'Wrong filtered rows count'); + })); + + it('Should filter by cells formatted data when using FormattedValuesFilteringStrategy with rowData', fakeAsync(() => { + const formattedStrategy = new FormattedValuesFilteringStrategy(['ProductName']); + grid.filterStrategy = formattedStrategy; + const anotherFieldFormatter = (value: any, rowData: any) => rowData.ID + ':' + value; + grid.columnList.get(1).formatter = anotherFieldFormatter; + fix.detectChanges(); + + grid.openAdvancedFilteringDialog(); + tick(200); + fix.detectChanges(); + + // Add root group. + QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fix, 0); + tick(100); + fix.detectChanges(); + + // Add a new expression + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 1); // Select 'ProductName' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 0); // Select 'Contains' operator. + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(input, '1:', fix); // Type filter value. + + // Commit the populated expression. + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix); + tick(100); + fix.detectChanges(); + GridFunctions.clickAdvancedFilteringApplyButton(fix); + tick(100); + fix.detectChanges(); + + const rows = GridFunctions.getRows(fix); + expect(rows.length).toEqual(1, 'Wrong filtered rows count'); + })); + + it('DateTime: Should set editorOptions.dateTimeFormat prop as inputFormat for the filter value editor', fakeAsync(() => { + const releaseDateColumn = grid.getColumnByName('ReleaseDate'); + releaseDateColumn.dataType = 'dateTime'; + releaseDateColumn.editorOptions = { + dateTimeFormat: 'dd-MM-yyyy HH:mm aaaaa' + } + fix.detectChanges(); + + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + const queryBuilderElement: HTMLElement = fix.debugElement.queryAll(By.css(`.${QueryBuilderSelectors.QUERY_BUILDER_TREE}`))[0].nativeElement; + QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fix, 0); + tick(100); + fix.detectChanges(); + + QueryBuilderFunctions.clickQueryBuilderColumnSelect(fix); + fix.detectChanges(); + const dropdownItems = QueryBuilderFunctions.getQueryBuilderSelectDropdownItems(queryBuilderElement); + expect((dropdownItems[4] as HTMLElement).innerText).toBe('ReleaseDate'); + + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 4); // Select 'ReleaseDate' column + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 0); + + const dateTimeEditor = fix.debugElement.query(By.directive(IgxDateTimeEditorDirective)) + .injector.get(IgxDateTimeEditorDirective); + expect(dateTimeEditor.inputFormat.normalize('NFKC')).toMatch(releaseDateColumn.editorOptions.dateTimeFormat); + expect(dateTimeEditor.displayFormat.normalize('NFKC')).toMatch(releaseDateColumn.pipeArgs.format); + expect(dateTimeEditor.locale).toMatch(grid.locale); + })); + + it('DateTime: Should set pipeArgs.format as inputFormat for the filter editor if numeric and editorOptions.dateTimeFormat not set', fakeAsync(() => { + const releaseDateColumn = grid.getColumnByName('ReleaseDate'); + releaseDateColumn.dataType = 'dateTime'; + releaseDateColumn.pipeArgs = { + format: 'dd-MM-yyyy HH:mm aaaaa' + } + fix.detectChanges(); + + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fix, 0); + tick(100); + fix.detectChanges(); + + QueryBuilderFunctions.clickQueryBuilderColumnSelect(fix); + fix.detectChanges(); + + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 4); + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 0); + + const dateTimeEditor = fix.debugElement.query(By.directive(IgxDateTimeEditorDirective)) + .injector.get(IgxDateTimeEditorDirective); + expect(dateTimeEditor.inputFormat.normalize('NFKC')).toMatch(releaseDateColumn.pipeArgs.format); + expect(dateTimeEditor.displayFormat.normalize('NFKC')).toMatch(releaseDateColumn.pipeArgs.format); + })); + + it('Time: Should set editorOptions.dateTimeFormat prop as inputFormat for the filter value editor', fakeAsync(() => { + const releaseTimeColumn = grid.getColumnByName('ReleaseTime'); + releaseTimeColumn.editorOptions = { + dateTimeFormat: 'hh:mm' + } + fix.detectChanges(); + + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fix, 0); + tick(100); + fix.detectChanges(); + + QueryBuilderFunctions.clickQueryBuilderColumnSelect(fix); + fix.detectChanges(); + + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 6); + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 0); + + const dateTimeEditor = fix.debugElement.query(By.directive(IgxDateTimeEditorDirective)) + .injector.get(IgxDateTimeEditorDirective); + expect(dateTimeEditor.inputFormat.normalize('NFKC')).toMatch(releaseTimeColumn.editorOptions.dateTimeFormat); + })); + + it('Time: Should set pipeArgs.format as inputFormat for the filter editor if numeric and editorOptions.dateTimeFormat not set', fakeAsync(() => { + const releaseTimeColumn = grid.getColumnByName('ReleaseTime'); + releaseTimeColumn.pipeArgs = { + format: 'hh:mm' + } + fix.detectChanges(); + + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fix, 0); + tick(100); + fix.detectChanges(); + + QueryBuilderFunctions.clickQueryBuilderColumnSelect(fix); + fix.detectChanges(); + + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 6); + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 0); + + const dateTimeEditor = fix.debugElement.query(By.directive(IgxDateTimeEditorDirective)) + .injector.get(IgxDateTimeEditorDirective); + expect(dateTimeEditor.inputFormat.normalize('NFKC')).toMatch(releaseTimeColumn.pipeArgs.format); + })); + + it('should handle advanced filtering correctly when grid columns and data are dynamically changed', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxGridAdvancedFilteringDynamicColumnsComponent); + grid = fixture.componentInstance.grid; + fixture.detectChanges(); + + expect(grid.filteredData).toBeNull(); + expect(grid.rowList.length).toBe(8); + expect(GridFunctions.getCurrentCellFromGrid(grid, 0, 1).value).toBe('Ignite UI for JavaScript'); + expect(GridFunctions.getCurrentCellFromGrid(grid, 1, 1).value).toBe('NetAdvantage'); + + // Open Advanced Filtering dialog + GridFunctions.clickAdvancedFilteringButton(fixture); + fixture.detectChanges(); + + // Click the initial 'Add Condition' button + QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fixture, 0); + tick(100); + fixture.detectChanges(); + + // Populate edit inputs + QueryBuilderFunctions.selectColumnInEditModeExpression(fixture, 1); + QueryBuilderFunctions.selectOperatorInEditModeExpression(fixture, 2); + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fixture).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(input, 'ign', fixture); + + // Commit the populated expression + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fixture); + fixture.detectChanges(); + + // Apply the filters + GridFunctions.clickAdvancedFilteringApplyButton(fixture); + fixture.detectChanges(); + + // Verify the filter results + expect(grid.filteredData.length).toEqual(2); + expect(grid.rowList.length).toBe(2); + expect(GridFunctions.getCurrentCellFromGrid(grid, 0, 1).value).toBe('Ignite UI for JavaScript'); + expect(GridFunctions.getCurrentCellFromGrid(grid, 1, 1).value).toBe('Ignite UI for Angular'); + + // Change the grid's columns collection + fixture.componentInstance.columns = [ + { field: 'ID', header: 'ID', width: '200px', type: 'string' }, + { field: 'CompanyName', header: 'Company Name', width: '200px', type: 'string' }, + { field: 'ContactName', header: 'Contact Name', width: '200px', type: 'string' }, + { field: 'ContactTitle', header: 'Contact Title', width: '200px', type: 'string' }, + { field: 'City', header: 'City', width: '200px', type: 'string' }, + { field: 'Country', header: 'Country', width: '200px', type: 'string' }, + ]; + fixture.detectChanges(); + flush(); + + // Change the grid's data collection + grid.data = SampleTestData.contactInfoDataFull(); + fixture.detectChanges(); + flush(); + + // Spy for error messages in the console + const consoleSpy = spyOn(console, 'error'); + + // Open Advanced Filtering dialog + GridFunctions.clickAdvancedFilteringButton(fixture); + fixture.detectChanges(); + flush(); + + // Verify the filters are cleared + expect(grid.filteredData).toEqual([]); + expect(grid.rowList.length).toBe(0); + + // Check for error messages in the console + expect(consoleSpy).not.toHaveBeenCalled(); + })); + }); + + describe('Advanced filtering with toolbar', () => { + let fix: ComponentFixture; + let grid: IgxGridComponent; + + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(IgxGridAdvancedFilteringWithToolbarComponent); + grid = fix.componentInstance.grid; + fix.detectChanges(); + })); + + it('Should update toolbar when advancedFilteringExpressionsTreeChange emits a new value', fakeAsync(() => { + // Set initial filtering expressions tree + const tree = new FilteringExpressionsTree(FilteringLogic.And); + tree.filteringOperands.push({ + fieldName: 'ProductName', + condition: IgxStringFilteringOperand.instance().condition('contains'), + searchVal: 'angular', + ignoreCase: true + }); + + // Apply the initial filtering tree + grid.advancedFilteringExpressionsTree = tree; + fix.detectChanges(); + + // Create a new filtering tree with more filters + const updatedTree = new FilteringExpressionsTree(FilteringLogic.And); + updatedTree.filteringOperands.push({ + fieldName: 'Downloads', + condition: IgxStringFilteringOperand.instance().condition('equals'), + searchVal: 10, + ignoreCase: true + }); + updatedTree.filteringOperands.push({ + fieldName: 'ProductName', + condition: IgxStringFilteringOperand.instance().condition('contains'), + searchVal: 'angular', + ignoreCase: true + }); + updatedTree.filteringOperands.push({ + fieldName: 'Category', + condition: IgxStringFilteringOperand.instance().condition('equals'), + searchVal: 'electronics', + ignoreCase: false + }); + + // Update the filtering expressions tree + grid.advancedFilteringExpressionsTree = updatedTree; + fix.detectChanges(); + + // Verify the correct number of filters + const toolbarDebugElement = fix.debugElement.query(By.directive(IgxGridToolbarAdvancedFilteringComponent)); + const toolbarComponent = toolbarDebugElement.componentInstance as IgxGridToolbarAdvancedFilteringComponent; + const numberOfFilters = (toolbarComponent as any).numberOfColumns; + + expect(grid.advancedFilteringExpressionsTree.filteringOperands.length).toEqual(3); + expect(numberOfFilters).toEqual(3); + })); + }) + + describe('Localization - ', () => { + it('Should correctly change resource strings for Advanced Filtering dialog.', fakeAsync(() => { + const fix = TestBed.createComponent(IgxGridAdvancedFilteringComponent); + const grid: IgxGridComponent = fix.componentInstance.grid; + const myResourceStrings: IGridResourceStrings = { + igx_grid_filter_operator_and: 'My and', + igx_grid_filter_operator_or: 'My or', + igx_grid_advanced_filter_title: 'My advanced filter', + igx_grid_advanced_filter_end_group: 'My end group', + igx_grid_advanced_filter_create_and_group: 'My create and group', + igx_grid_advanced_filter_and_label: 'My and', + igx_grid_advanced_filter_or_label: 'My or', + igx_grid_advanced_filter_add_condition: 'Add my condition', + igx_grid_advanced_filter_add_condition_root: 'My condition', + igx_grid_advanced_filter_add_group_root: 'My group', + igx_grid_advanced_filter_add_group: 'Add my group', + igx_grid_advanced_filter_ungroup: 'My ungroup', + igx_grid_advanced_filter_switch_group: 'My switch to {0}', + igx_grid_advanced_filter_delete_filters: 'My delete filters' + }; + + grid.resourceStrings = { + ...grid.resourceStrings, + ...myResourceStrings + }; + + const tree = new FilteringExpressionsTree(FilteringLogic.And); + tree.filteringOperands.push({ + fieldName: 'ProductName', + condition: IgxStringFilteringOperand.instance().condition('contains'), + searchVal: 'angular', + ignoreCase: true + }); + + grid.advancedFilteringExpressionsTree = tree; + fix.detectChanges(); + + // Open Advanced Filtering dialog. + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + expect(QueryBuilderFunctions.getQueryBuilderHeaderText(fix).trim()).toBe('My advanced filter'); + expect((QueryBuilderFunctions.getQueryBuilderTreeRootGroupButtons(fix, 0)[0] as HTMLElement).querySelector('span').innerText) + .toBe('My condition'); + expect((QueryBuilderFunctions.getQueryBuilderTreeRootGroupButtons(fix, 0)[1] as HTMLElement).querySelector('span').innerText) + .toBe('My group'); + + QueryBuilderFunctions.clickQueryBuilderGroupContextMenu(fix, 0); + tick(100); + fix.detectChanges(); + + const groupDDLItems = QueryBuilderFunctions.getQueryBuilderGroupContextMenuDropDownItems(fix); + expect(groupDDLItems[0].innerText).toBe('My switch to MY OR'); + expect(groupDDLItems[1].innerText).toBe('My ungroup'); + + QueryBuilderFunctions.clickQueryBuilderGroupContextMenu(fix, 0); + tick(100); + fix.detectChanges(); + + // Hover the condition chip to show the add button + const expressionItem = fix.nativeElement.querySelectorAll(`.${QueryBuilderSelectors.FILTER_TREE_EXPRESSION_ITEM}`)[0]; + expressionItem.dispatchEvent(new MouseEvent('mouseenter')); + tick(); + fix.detectChanges(); + + // Click the add icon to display the adding buttons and verify their content. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChipIcon(fix, [0], 'add'); + fix.detectChanges(); + tick(50); + + const addingButtons: any = QueryBuilderFunctions.getQueryBuilderTreeRootGroupButtons(fix, 0); + expect(addingButtons[0].innerText).toBe('add\nMy condition'); + expect(addingButtons[1].innerText).toBe('add\nMy group'); + })); + }); + + describe('Column groups - ', () => { + let fix; let grid: IgxGridComponent; + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(IgxGridAdvancedFilteringColumnGroupComponent); + grid = fix.componentInstance.grid; + fix.detectChanges(); + })); + + it('Should not display column groups in advanced filtering dialog.', fakeAsync(() => { + // Open dialog through API. + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + const queryBuilderElement: HTMLElement = fix.debugElement.queryAll(By.css(`.${QueryBuilderSelectors.QUERY_BUILDER_TREE}`))[0].nativeElement; + + // Click the initial 'Add Condition' button. + QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fix, 0); + tick(100); + fix.detectChanges(); + + // Open column dropdown and verify that there are no column groups present. + QueryBuilderFunctions.clickQueryBuilderColumnSelect(fix); + fix.detectChanges(); + const dropdownValues = QueryBuilderFunctions.getQueryBuilderSelectDropdownItems(queryBuilderElement).map((x: any) => x.innerText); + const expectedValues = ['ID', 'ProductName', 'Downloads', 'Released', 'ReleaseDate', 'Another Field', 'DateTimeCreated']; + expect(expectedValues).toEqual(dropdownValues); + })); + }); + + describe('External - ', () => { + let fix; let grid: IgxGridComponent; + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(IgxGridExternalAdvancedFilteringComponent); + grid = fix.componentInstance.grid; + fix.detectChanges(); + })); + + it('Should allow hosting Advanced Filtering dialog outside of the grid.', fakeAsync(() => { + // Add a root 'and' group. + QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fix, 0); + tick(100); + fix.detectChanges(); + + // Populate edit inputs. + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 2); // Select 'Downloads' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 2); // Select 'Greater Than' operator. + let input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(input, '100', fix); // Type filter value. + // Commit the populated expression. + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix); + fix.detectChanges(); + + // Add new expression to the root group. + const addExpressionBtn = QueryBuilderFunctions.getQueryBuilderTreeRootGroupButtons(fix, 0)[0] as HTMLElement; + addExpressionBtn.click(); + tick(100); + fix.detectChanges(); + + // Populate edit inputs. + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 1); // Select 'ProductName' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 0); // Select 'Contains' operator. + input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(input, 'ignite', fix); // Type filter value. + // Commit the populated expression. + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix); + fix.detectChanges(); + + // Apply the filters. + GridFunctions.clickAdvancedFilteringApplyButton(fix); + tick(100); + fix.detectChanges(); + + // Verify the state of the grid after the filtering. + expect(grid.advancedFilteringExpressionsTree).toBeTruthy(); + expect(grid.filteredData.length).toBe(1); + expect(grid.rowList.length).toBe(1); + expect(GridFunctions.getCurrentCellFromGrid(grid, 0, 1).value).toBe('Ignite UI for JavaScript'); + })); + + it('Should allow hosting Advanced Filtering dialog outside of the hierarchical grid without any console errors.', fakeAsync(() => { + fix = TestBed.createComponent(IgxHierGridExternalAdvancedFilteringComponent); + const hgrid: IgxHierarchicalGridComponent = fix.componentInstance.hgrid; + fix.detectChanges(); + spyOn(console, 'error'); + + const advFilterDialog = fix.nativeElement.querySelector('.igx-advanced-filter'); + const applyFilterButton: any = Array.from(advFilterDialog.querySelectorAll('button')) + .find((b: any) => b.innerText.toLowerCase() === 'apply'); + + applyFilterButton.click(); + tick(100); + fix.detectChanges(); + + UIInteractions.simulatePointerEvent('pointerenter', + hgrid.nativeElement.querySelectorAll('igx-hierarchical-grid-cell')[0], 5, 5); + fix.detectChanges(); + + expect(console.error).not.toHaveBeenCalled(); + })); + + }); + + describe('Expression tree bindings - ', () => { + let fix; let grid: IgxGridComponent; + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(IgxGridAdvancedFilteringBindingComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + })); + + it('should correctly filter with \'advancedFilteringExpressionsTree\' binding', fakeAsync(() => { + // Verify initially filtered in Advanced Filtering - 'Downloads > 200' + expect(grid.filteredData.length).toEqual(3); + expect(grid.rowList.length).toBe(3); + + // Verify filtering expressions tree binding state + expect(grid.advancedFilteringExpressionsTree).toBe(fix.componentInstance.filterTree); + + // Clear filter + grid.advancedFilteringExpressionsTree = null; + fix.detectChanges(); + + // Verify filtering expressions tree binding state + expect(grid.advancedFilteringExpressionsTree).toBe(fix.componentInstance.filterTree); + + // Verify no filtered data + expect(grid.filteredData).toBe(null); + expect(grid.rowList.length).toBe(8); + })); + + it('should correctly set filteredData if advancedFilteringExpressionsTree is empty', fakeAsync(() => { + // Verify filtering expressions tree binding state + expect(grid.advancedFilteringExpressionsTree).toBe(fix.componentInstance.filterTree); + + const tree = new FilteringExpressionsTree(FilteringLogic.And); + tree.filteringOperands = []; + + // Clear filter + grid.advancedFilteringExpressionsTree = tree; + fix.detectChanges(); + + // Verify filtering expressions tree binding state + expect(grid.advancedFilteringExpressionsTree).toBe(tree); + + // Verify no filtered data + expect(grid.filteredData).toBe(null); + })); + + }); + + describe('Expression tree rehydration - ', () => { + let fix: ComponentFixture; + let grid: IgxGridComponent; + + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(IgxGridAdvancedFilteringSerializedTreeComponent); + grid = fix.componentInstance.grid; + fix.detectChanges(); + })); + + it('should correctly filter with a deserialized expression tree.', fakeAsync(() => { + const errorSpy = spyOn(console, 'error'); + + expect(errorSpy).not.toHaveBeenCalled(); + + // Verify filtered data + expect(grid.filteredData.length).toEqual(3); + expect(grid.rowList.length).toBe(3); + })); + + it('should correctly filter with a declared IFilteringExpressionsTree object.', fakeAsync(() => { + const errorSpy = spyOn(console, 'error'); + fix.componentInstance.grid.advancedFilteringExpressionsTree = fix.componentInstance.filterTreeObject; + fix.detectChanges(); + expect(errorSpy).not.toHaveBeenCalled(); + + // Verify filtered data + expect(grid.filteredData.length).toEqual(2); + expect(grid.rowList.length).toBe(2); + })); + + it('should correctly filter when binding to a declared IFilteringExpressionsTree object.', fakeAsync(() => { + const errorSpy = spyOn(console, 'error'); + fix.componentInstance.filterTree = fix.componentInstance.filterTreeObject; + fix.detectChanges(); + + expect(errorSpy).not.toHaveBeenCalled(); + + // Verify filtered data + expect(grid.filteredData.length).toEqual(2); + expect(grid.rowList.length).toBe(2); + })); + }); + + describe('Hierarchical grid advanced filtering - ', () => { + let fix: ComponentFixture; + let hgrid: IgxHierarchicalGridComponent; + + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(IgxHierarchicalGridTestBaseComponent); + hgrid = fix.componentInstance.hgrid; + hgrid.allowAdvancedFiltering = true; + fix.detectChanges(); + + // Open Advanced Filtering dialog. + hgrid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + // Click the initial 'Add Condition' button of the query builder. + QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fix, 0); + tick(100); + fix.detectChanges(); + })); + + it(`Should have 'In'/'Not-In' operators for fields with chilld entities.`, fakeAsync(() => { + // Populate edit inputs. + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 0); // Select 'ID' column. + + // Open the operator dropdown and verify they are 'string' specific + 'In'/'Not In'. + QueryBuilderFunctions.clickQueryBuilderOperatorSelect(fix); + fix.detectChanges(); + const queryBuilderElement: HTMLElement = fix.debugElement.queryAll(By.css(`.${QueryBuilderSelectors.QUERY_BUILDER_TREE}`))[0].nativeElement; + const dropdownValues: string[] = QueryBuilderFunctions.getQueryBuilderSelectDropdownItems(queryBuilderElement).map((x: any) => x.innerText); + const expectedValues = ['Contains', 'Does Not Contain', 'Starts With', 'Ends With', 'Equals', + 'Does Not Equal', 'Empty', 'Not Empty', 'Null', 'Not Null', 'In', 'Not In'];; + expect(dropdownValues).toEqual(expectedValues); + + // Close Advanced Filtering dialog. + hgrid.closeAdvancedFilteringDialog(false); + tick(200); + fix.detectChanges(); + })); + + it(`Should NOT have 'In'/'Not-In' operators for fields without chilld entities.`, fakeAsync(() => { + // Populate edit inputs. + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 0); // Select 'ID' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 10); // Select 'In' operator. + + // Select entity in nested level + QueryBuilderFunctions.selectEntityAndClickInitialAddCondition(fix, 0, 1); + // Populate edit inputs on level 1. + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 0, 1); // Select 'ID' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 11, 1); // Select 'Not In' operator. + + // Select entity in nested level + QueryBuilderFunctions.selectEntityAndClickInitialAddCondition(fix, 0, 2); + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 0, 2); // Select 'ID' column. + // Open the operator dropdown and verify they are 'string' specific + 'In'/'Not In'. + QueryBuilderFunctions.clickQueryBuilderOperatorSelect(fix, 2); + fix.detectChanges(); + const queryBuilderElement: HTMLElement = fix.debugElement.queryAll(By.css(`.${QueryBuilderSelectors.QUERY_BUILDER_TREE}`))[2].nativeElement; + const dropdownValues: string[] = QueryBuilderFunctions.getQueryBuilderSelectDropdownItems(queryBuilderElement).map((x: any) => x.innerText); + const expectedValues = ['Contains', 'Does Not Contain', 'Starts With', 'Ends With', 'Equals', + 'Does Not Equal', 'Empty', 'Not Empty', 'Null', 'Not Null'];; + expect(dropdownValues).toEqual(expectedValues); + + // Close Advanced Filtering dialog. + hgrid.closeAdvancedFilteringDialog(false); + tick(200); + fix.detectChanges(); + })); + + it('Should have correct entities depending on the hierarchy level.', fakeAsync(() => { + // Populate edit inputs. + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 0); // Select 'ID' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 10); // Select 'In' operator. + + QueryBuilderFunctions.clickQueryBuilderEntitySelect(fix, 1); + fix.detectChanges(); + const queryBuilderElement: HTMLElement = fix.debugElement.queryAll(By.css(`.${QueryBuilderSelectors.QUERY_BUILDER_TREE}`))[1].nativeElement; + const dropdownValues: string[] = QueryBuilderFunctions.getQueryBuilderSelectDropdownItems(queryBuilderElement).map((x: any) => x.innerText); + const expectedValues = ['childData']; + expect(dropdownValues).toEqual(expectedValues); + + // Close Advanced Filtering dialog. + hgrid.closeAdvancedFilteringDialog(false); + tick(200); + fix.detectChanges(); + })); + + it(`Should apply 'In'/'Not-In' operators for each level properly.`, fakeAsync(() => { + // Populate edit inputs. + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 0); // Select 'ID' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 10); // Select 'In' operator. + tick(100); + fix.detectChanges(); + + // When there is one entity, it should be selected by default + const entityInputGroup = QueryBuilderFunctions.getQueryBuilderEntitySelect(fix, 1).querySelector('input'); + expect(entityInputGroup.value).toBe('childData'); + + const fieldInputGroup = QueryBuilderFunctions.getQueryBuilderFieldsCombo(fix, 1).querySelector('input'); + expect(fieldInputGroup.value).toBe('ID'); + + // Click the initial 'Add Condition' button. + QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fix, 0); + tick(100); + fix.detectChanges(); + // Populate edit inputs on level 1. + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 0, 1); // Select 'ID' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 0, 1); // Select 'Contains' operator. + + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix, false, 1).querySelector('input'); + // Type Value + UIInteractions.clickAndSendInputElementValue(input, '39'); + tick(100); + fix.detectChanges(); + + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix, 1); + fix.detectChanges(); + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix, 0); + fix.detectChanges(); + // Close Advanced Filtering dialog. + hgrid.closeAdvancedFilteringDialog(true); + tick(200); + fix.detectChanges(); + + // Veify grid data + expect(hgrid.filteredData.length).toEqual(5); + expect(hgrid.rowList.length).toBe(5); + })); + + it(`Should have correct return fields in the child query when there are multiple child entities.`, fakeAsync(() => { + const fixture = TestBed.createComponent(IgxHierarchicalGridExportComponent); + const hierarchicalGrid = fixture.componentInstance.hGrid; + fixture.componentInstance.shouldDisplayArtist = true; + hierarchicalGrid.allowAdvancedFiltering = true; + fixture.detectChanges(); + + hierarchicalGrid.openAdvancedFilteringDialog(); + fixture.detectChanges(); + + // Click the initial 'Add Condition' button. + QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fixture, 0); + tick(100); + fixture.detectChanges(); + // Populate edit inputs. + QueryBuilderFunctions.selectColumnInEditModeExpression(fixture, 0); // Select 'Artist' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fixture, 10); // Select 'In' operator. + tick(100); + fixture.detectChanges(); + + QueryBuilderFunctions.selectEntityInEditModeExpression(fixture, 0, 1); + tick(100); + fixture.detectChanges(); + + const fieldInputGroup = QueryBuilderFunctions.getQueryBuilderFieldsCombo(fixture, 1).querySelector('input'); + expect(fieldInputGroup.value).toBe('Artist'); + })); + + it('Should correctly apply filtering expressions tree to the hgrid component through API.', fakeAsync(() => { + // Close Advanced Filtering dialog. + hgrid.closeAdvancedFilteringDialog(false); + tick(200); + fix.detectChanges(); + // Spy for error messages in the console + const consoleSpy = spyOn(console, 'error'); + // Apply advanced filter through API. + const innerTree = new FilteringExpressionsTree(0, undefined, 'childData', ['ID']); + innerTree.filteringOperands.push({ + fieldName: 'ID', + ignoreCase: false, + conditionName: IgxStringFilteringOperand.instance().condition('contains').name, + searchVal: '39' + }); + + const tree = new FilteringExpressionsTree(0, undefined, 'rootData', ['ID']); + tree.filteringOperands.push({ + fieldName: 'ID', + conditionName: IgxStringFilteringOperand.instance().condition('inQuery').name, + ignoreCase: false, + searchTree: innerTree + }); + + hgrid.advancedFilteringExpressionsTree = tree; + fix.detectChanges(); + + // Check for error messages in the console + expect(consoleSpy).not.toHaveBeenCalled(); + expect(hgrid.filteredData.length).toBe(5); + })); + + it('Should correctly apply JSON filtering expressions tree to the hgrid correctly.', fakeAsync(() => { + // Close Advanced Filtering dialog. + hgrid.closeAdvancedFilteringDialog(false); + tick(200); + fix.detectChanges(); + // Spy for error messages in the console + const consoleSpy = spyOn(console, 'error'); + + const innerTree = new FilteringExpressionsTree(0, undefined, 'childData', ['ID']); + innerTree.filteringOperands.push({ + fieldName: 'ID', + ignoreCase: false, + conditionName: IgxStringFilteringOperand.instance().condition('contains').name, + searchVal: '39' + }); + + const tree = new FilteringExpressionsTree(0, undefined, 'rootData', ['ID']); + tree.filteringOperands.push({ + fieldName: 'ID', + conditionName: IgxStringFilteringOperand.instance().condition('inQuery').name, + ignoreCase: false, + searchTree: innerTree + }); + + hgrid.advancedFilteringExpressionsTree = JSON.parse(JSON.stringify(tree)); + fix.detectChanges(); + + // Check for error messages in the console + expect(consoleSpy).not.toHaveBeenCalled(); + expect(hgrid.filteredData.length).toBe(5); + })); + + it('Should have proper fields in UI when schema is defined with load on demand.', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxHGridRemoteOnDemandComponent); + const hierarchicalGrid = fixture.componentInstance.instance; + hierarchicalGrid.allowAdvancedFiltering = true; + hierarchicalGrid.schema = [ + { + name: 'rootLevel', + fields: [ + { field: 'ID', dataType: 'string' }, + { field: 'ChildLevels', dataType: 'number' }, + { field: 'ProductName', dataType: 'string' }, + { field: 'Col1', dataType: 'number' }, + { field: 'Col2', dataType: 'number' }, + { field: 'Col3', dataType: 'number' } + ], + childEntities: [ + { + name: 'childData', + fields: [ + { field: 'ID', dataType: 'string' }, + { field: 'ProductName', dataType: 'string' } + ], + childEntities: [ + { + name: 'childData2', + fields: [ + { field: 'ID', dataType: 'string' }, + { field: 'ProductName', dataType: 'string' } + ] + } + ] + } + ] + } + ] + fixture.detectChanges(); + + hierarchicalGrid.openAdvancedFilteringDialog(); + fixture.detectChanges(); + + // Click the initial 'Add Condition' button. + QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fixture, 0); + tick(100); + fixture.detectChanges(); + // Populate edit inputs. + QueryBuilderFunctions.selectColumnInEditModeExpression(fixture, 0); // Select 'ID' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fixture, 10); // Select 'In' operator. + tick(100); + fixture.detectChanges(); + + const entityInputGroup = QueryBuilderFunctions.getQueryBuilderEntitySelect(fixture, 1).querySelector('input'); + expect(entityInputGroup.value).toBe('childData'); + + const fieldInputGroup = QueryBuilderFunctions.getQueryBuilderFieldsCombo(fixture, 1).querySelector('input'); + expect(fieldInputGroup.value).toBe('ID'); + + // Verify entities + QueryBuilderFunctions.clickQueryBuilderEntitySelect(fixture, 1); + fixture.detectChanges(); + const queryBuilderElement: HTMLElement = fixture.debugElement.queryAll(By.css(`.${QueryBuilderSelectors.QUERY_BUILDER_TREE}`))[1].nativeElement; + let dropdownValues: string[] = QueryBuilderFunctions.getQueryBuilderSelectDropdownItems(queryBuilderElement).map((x: any) => x.innerText); + let expectedValues = ['childData']; + expect(dropdownValues).toEqual(expectedValues); + + // Verify return fileds + QueryBuilderFunctions.clickQueryBuilderFieldsCombo(fixture, 1); + fixture.detectChanges(); + dropdownValues = QueryBuilderFunctions.getQueryBuilderSelectDropdownItems(queryBuilderElement, 1).map((x: any) => x.innerText); + expectedValues = ['ID', 'ProductName']; + expect(dropdownValues).toEqual(expectedValues); + })); + + it('Should correctly change resource strings for hierarchical Advanced Filtering dialog.', fakeAsync(() => { + hgrid.closeAdvancedFilteringDialog(false); + tick(200); + fix.detectChanges(); + + const innerTree = new FilteringExpressionsTree(0, undefined, 'childData', ['ID']); + innerTree.filteringOperands.push({ + fieldName: 'ID', + ignoreCase: false, + conditionName: IgxStringFilteringOperand.instance().condition('contains').name, + searchVal: '39' + }); + + const tree = new FilteringExpressionsTree(0, undefined, 'rootData', ['ID']); + tree.filteringOperands.push({ + fieldName: 'ID', + conditionName: IgxStringFilteringOperand.instance().condition('inQuery').name, + ignoreCase: false, + searchTree: innerTree + }); + + hgrid.advancedFilteringExpressionsTree = tree; + + const myResourceStrings: IGridResourceStrings = { + igx_grid_filter_operator_and: 'My and', + igx_grid_filter_operator_or: 'My or', + igx_grid_advanced_filter_title: 'My advanced filter', + igx_grid_advanced_filter_end_group: 'My end group', + igx_grid_advanced_filter_create_and_group: 'My create and group', + igx_grid_advanced_filter_and_label: 'My and', + igx_grid_advanced_filter_or_label: 'My or', + igx_grid_advanced_filter_add_condition: 'Add my condition', + igx_grid_advanced_filter_add_condition_root: 'My condition', + igx_grid_advanced_filter_add_group_root: 'My group', + igx_grid_advanced_filter_add_group: 'Add my group', + igx_grid_advanced_filter_ungroup: 'My ungroup', + igx_grid_advanced_filter_switch_group: 'My switch to {0}', + igx_grid_advanced_filter_delete_filters: 'My delete filters', + igx_grid_advanced_filter_from_label: 'My from', + igx_grid_advanced_filter_query_value_placeholder: 'My sub-query results', + igx_grid_advanced_filter_select_entity: 'Select my entity', + igx_grid_advanced_filter_select_return_field_single: 'Select my return fields', + igx_grid_filter_in: 'My In', + igx_grid_filter_notIn: 'My Not In' + }; + + hgrid.resourceStrings = { + ...hgrid.resourceStrings, + ...myResourceStrings + }; + + fix.detectChanges(); + + // Open Advanced Filtering dialog. + hgrid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + // Open up the sub-query + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [0]); + tick(100); + fix.detectChanges(); + + const valueInput: any = QueryBuilderFunctions.getQueryBuilderValueInput(fix); + expect(valueInput.querySelector('input').placeholder).toBe('My sub-query results'); + + const entitySelect = QueryBuilderFunctions.getQueryBuilderEntitySelect(fix, 1); + const selectLabel = entitySelect.previousElementSibling as HTMLSpanElement; + expect(selectLabel.innerText).toBe('My from'); + expect(entitySelect.querySelector('input').placeholder).toBe('Select my entity'); + const fieldsCombo = QueryBuilderFunctions.getQueryBuilderFieldsCombo(fix, 1); + expect(fieldsCombo.querySelector('input').placeholder).toBe('Select my return fields'); + + var operatorSelect = QueryBuilderFunctions.getQueryBuilderOperatorSelect(fix, 0); + expect(operatorSelect.querySelector('input').value).toBe('My In'); + expect(Array.from(operatorSelect.querySelectorAll('igx-select-item')).pop().textContent).toBe('My Not In'); + })); + + it('Should not throw an error when some child data is missing.', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxHierarchicalGridMissingChildDataComponent); + const hierarchicalGrid = fixture.componentInstance.hGrid; + hierarchicalGrid.allowAdvancedFiltering = true; + fixture.detectChanges(); + + // Open Advanced Filtering dialog. + expect(() => { + hierarchicalGrid.openAdvancedFilteringDialog(); + fixture.detectChanges(); + }).not.toThrow(); + })); + }); +}); + + +const verifyElementIsInExpressionsContainerView = (fix, element: HTMLElement) => { + const elementRect = element.getBoundingClientRect(); + const exprContainer: HTMLElement = QueryBuilderFunctions.getQueryBuilderExpressionsContainer(fix) as HTMLElement; + const exprContainerRect = exprContainer.getBoundingClientRect(); + expect(elementRect.top >= exprContainerRect.top).toBe(true, 'top is not in view'); + expect(elementRect.bottom <= exprContainerRect.bottom).toBe(true, 'bottom is not in view'); + expect(elementRect.left >= exprContainerRect.left).toBe(true, 'left is not in view'); + expect(elementRect.right <= exprContainerRect.right).toBe(true, 'right is not in view'); +}; diff --git a/projects/igniteui-angular/grids/grid/src/grid-filtering-ui.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-filtering-ui.spec.ts new file mode 100644 index 00000000000..d7fb0c08c9e --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid-filtering-ui.spec.ts @@ -0,0 +1,7589 @@ +import { DebugElement } from '@angular/core'; +import { fakeAsync, TestBed, tick, flush, ComponentFixture, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxInputDirective, IgxInputGroupComponent } from 'igniteui-angular/input-group'; +import { INPUT_DEBOUNCE_TIME } from 'igniteui-angular/grids/core'; +import { IgxGridComponent } from './grid.component'; +import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; +import { IgxGridFilteringCellComponent } from 'igniteui-angular/grids/core'; +import { IgxGridHeaderComponent } from 'igniteui-angular/grids/core'; +import { IgxGridFilteringRowComponent } from 'igniteui-angular/grids/core'; +import { GridFunctions, GridSelectionFunctions } from '../../../test-utils/grid-functions.spec'; +import { IgxGridHeaderGroupComponent } from 'igniteui-angular/grids/core'; +import { DatePipe, registerLocaleData } from '@angular/common'; +import localeDe from '@angular/common/locales/de'; +import localeFr from '@angular/common/locales/fr'; +import localeBg from '@angular/common/locales/bg'; +import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; +import { + IgxGridFilteringComponent, + IgxGridFilteringScrollComponent, + IgxGridFilteringMCHComponent, + IgxGridFilteringTemplateComponent, + IgxGridFilteringESFEmptyTemplatesComponent, + IgxGridFilteringESFTemplatesComponent, + IgxGridFilteringESFLoadOnDemandComponent, + CustomFilteringStrategyComponent, + IgxGridExternalESFComponent, + IgxGridExternalESFTemplateComponent, + IgxGridDatesFilteringComponent, + LoadOnDemandFilterStrategy, + IgxGridFilteringNumericComponent, + IgxGridConditionalFilteringComponent +} from '../../../test-utils/grid-samples.spec'; +import { GridSelectionMode, FilterMode } from 'igniteui-angular/grids/core'; +import { ControlsFunction } from '../../../test-utils/controls-functions.spec'; +import { setElementSize } from '../../../test-utils/helper-utils.spec'; +import { DefaultSortingStrategy, FilteringExpressionsTree, FilteringLogic, FilteringStrategy, FormattedValuesFilteringStrategy, getComponentSize, GridResourceStringsEN, IFilteringExpression, IFilteringExpressionsTree, IgxBooleanFilteringOperand, IgxDateFilteringOperand, IgxDateTimeFilteringOperand, igxI18N, IgxNumberFilteringOperand, IgxStringFilteringOperand, IgxTimeFilteringOperand, ɵSize, SortingDirection } from 'igniteui-angular/core'; +import { IgxDateTimeEditorDirective } from 'igniteui-angular/directives'; +import { IgxTimePickerComponent } from 'igniteui-angular/time-picker'; +import { IgxChipComponent, IgxBadgeComponent, IgxDatePickerComponent, IgxCalendarComponent, IgxIconComponent } from 'igniteui-angular'; + +const DEBOUNCE_TIME = 30; +const FILTER_UI_ROW = 'igx-grid-filtering-row'; +const FILTER_UI_CELL = 'igx-grid-filtering-cell'; +const GRID_RESIZE_CLASS = '.igx-grid-th__resize-line'; + +describe('IgxGrid - Filtering Row UI actions #grid', () => { + + registerLocaleData(localeDe); + registerLocaleData(localeFr); + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxGridFilteringComponent, + IgxGridFilteringScrollComponent, + IgxGridFilteringMCHComponent, + IgxGridFilteringTemplateComponent, + IgxGridDatesFilteringComponent, + IgxGridFilteringNumericComponent + ] + }).compileComponents(); + })); + + describe(null, () => { + let fix: ComponentFixture; + let grid: IgxGridComponent; + const today = SampleTestData.today; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + providers: [{ provide: INPUT_DEBOUNCE_TIME, useValue: 0 }] + }); + fix = TestBed.createComponent(IgxGridFilteringComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + })); + + // UI tests string column, empty input + it('UI tests on string column changing conditions', fakeAsync(() => { + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const reset = filterUIRow.queryAll(By.css('button'))[0]; + const close = filterUIRow.queryAll(By.css('button'))[1]; + const input = filterUIRow.query(By.directive(IgxInputDirective)); + + expect(grid.rowList.length).toEqual(8); + + // iterate over not unary conditions when input is empty + // starts with + GridFunctions.openFilterDDAndSelectCondition(fix, 2); + + expect(grid.rowList.length).toEqual(8); + verifyFilterRowUI(input, close, reset); + + // does not contain + GridFunctions.openFilterDDAndSelectCondition(fix, 1); + + expect(grid.rowList.length).toEqual(8); + verifyFilterRowUI(input, close, reset); + + // iterate over unary conditions + GridFunctions.openFilterDDAndSelectCondition(fix, 6); + + expect(grid.rowList.length).toEqual(4); + verifyFilterRowUI(input, close, reset, false); + + GridFunctions.openFilterDDAndSelectCondition(fix, 0); + + expect(grid.rowList.length).toEqual(4); + verifyFilterRowUI(input, close, reset, false); + })); + + // UI tests string column with value in input + it('UI tests on string column', fakeAsync(() => { + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const input = filterUIRow.query(By.directive(IgxInputDirective)); + const reset = filterUIRow.queryAll(By.css('button'))[0]; + const close = filterUIRow.queryAll(By.css('button'))[1]; + + expect(grid.rowList.length).toEqual(8); + + // iterate over not unary conditions and fill the input + // starts with + GridFunctions.openFilterDDAndSelectCondition(fix, 2); + GridFunctions.typeValueInFilterRowInput('Net', fix, input); + tick(); + fix.detectChanges(); + + verifyFilterUIPosition(filterUIRow, grid); + verifyFilterRowUI(input, close, reset, false); + expect(grid.rowList.length).toEqual(1); + expect(grid.getCellByColumn(0, 'ID').value).toEqual(2); + + // ends with + GridFunctions.openFilterDDAndSelectCondition(fix, 3); + GridFunctions.typeValueInFilterRowInput('script', fix, input); + tick(); + fix.detectChanges(); + + expect(grid.rowList.length).toEqual(2); + verifyFilterRowUI(input, close, reset, false); + + // does not contain + GridFunctions.openFilterDDAndSelectCondition(fix, 1); + GridFunctions.typeValueInFilterRowInput('script', fix, input); + tick(); + fix.detectChanges(); + + verifyFilterRowUI(input, close, reset, false); + expect(grid.rowList.length).toEqual(6); + })); + + // UI tests number column + it('UI tests on number column changing conditions', fakeAsync(() => { + GridFunctions.clickFilterCellChip(fix, 'Downloads'); + + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + let input = filterUIRow.query(By.directive(IgxInputDirective)); + const reset = filterUIRow.queryAll(By.css('button'))[0]; + const close = filterUIRow.queryAll(By.css('button'))[1]; + + expect(grid.rowList.length).toEqual(8); + verifyFilterRowUI(input, close, reset); + + // does not equal + GridFunctions.openFilterDDAndSelectCondition(fix, 1); + + expect(grid.rowList.length).toEqual(8); + verifyFilterRowUI(input, close, reset); + + // greater than + GridFunctions.openFilterDDAndSelectCondition(fix, 2); + + expect(grid.rowList.length).toEqual(8); + verifyFilterRowUI(input, close, reset); + + // iterate over unary conditions + // empty + GridFunctions.openFilterDDAndSelectCondition(fix, 6); + + expect(grid.rowList.length).toEqual(1); + verifyFilterRowUI(input, close, reset, false); + + // not empty + GridFunctions.openFilterDDAndSelectCondition(fix, 7); + + expect(grid.rowList.length).toEqual(7); + verifyFilterRowUI(input, close, reset, false); + + // changing from unary to not unary condition when input is empty - filtering should keep its state + // open dropdown + GridFunctions.openFilterDDAndSelectCondition(fix, 0); + + input = filterUIRow.query(By.directive(IgxInputDirective)); + expect(grid.rowList.length).toEqual(7); + verifyFilterRowUI(input, close, reset, false); + })); + + it('UI tests on number column', fakeAsync(() => { + GridFunctions.clickFilterCellChip(fix, 'Downloads'); + + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const input = filterUIRow.query(By.directive(IgxInputDirective)); + const reset = filterUIRow.queryAll(By.css('button'))[0]; + const close = filterUIRow.queryAll(By.css('button'))[1]; + + // iterate over not unary conditions and fill the input + // does not equal + GridFunctions.openFilterDDAndSelectCondition(fix, 1); + GridFunctions.typeValueInFilterRowInput(100, fix, input); + tick(); + fix.detectChanges(); + + expect(grid.rowList.length).toEqual(7); + verifyFilterRowUI(input, close, reset, false); + + // less than + GridFunctions.openFilterDDAndSelectCondition(fix, 3); + expect(grid.rowList.length).toEqual(3); + verifyFilterRowUI(input, close, reset, false); + + // greater than or equal to + GridFunctions.openFilterDDAndSelectCondition(fix, 4); + GridFunctions.typeValueInFilterRowInput(254, fix, input); + tick(); + fix.detectChanges(); + + + expect(grid.rowList.length).toEqual(3); + verifyFilterRowUI(input, close, reset, false); + })); + + // UI tests boolean column + it('UI tests on boolean column', fakeAsync(() => { + GridFunctions.clickFilterCellChip(fix, 'Released'); + + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const reset = filterUIRow.queryAll(By.css('button'))[0]; + const close = filterUIRow.queryAll(By.css('button'))[1]; + const input = filterUIRow.query(By.directive(IgxInputDirective)); + tick(); + + expect(grid.rowList.length).toEqual(8); + + verifyFilterUIPosition(filterUIRow, grid); + + // false condition + GridFunctions.openFilterDDAndSelectCondition(fix, 2); + + expect(grid.rowList.length).toEqual(2); + verifyFilterRowUI(input, close, reset, false); + + // true condition + GridFunctions.openFilterDDAndSelectCondition(fix, 1); + + expect(grid.rowList.length).toEqual(3); + verifyFilterRowUI(input, close, reset, false); + + // (all) condition + GridFunctions.openFilterDDAndSelectCondition(fix, 0); + + expect(grid.rowList.length).toEqual(8); + verifyFilterRowUI(input, close, reset, false); + + // not null condition + GridFunctions.openFilterDDAndSelectCondition(fix, 6); + + expect(grid.rowList.length).toEqual(6); + verifyFilterRowUI(input, close, reset, false); + })); + + it('UI tests on boolean column open dropdown', fakeAsync(() => { + GridFunctions.clickFilterCellChip(fix, 'Released'); + + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const input = filterUIRow.query(By.directive(IgxInputDirective)); + const prefix = GridFunctions.getFilterRowPrefix(fix); + + input.triggerEventHandler('click', null); + tick(DEBOUNCE_TIME); + fix.detectChanges(); + GridFunctions.verifyFilteringDropDownIsOpened(fix); + + UIInteractions.triggerEventHandlerKeyDown(' ', prefix); + tick(DEBOUNCE_TIME); + fix.detectChanges(); + GridFunctions.verifyFilteringDropDownIsOpened(fix, false); + + UIInteractions.triggerEventHandlerKeyDown('Enter', input); + tick(DEBOUNCE_TIME); + fix.detectChanges(); + GridFunctions.verifyFilteringDropDownIsOpened(fix); + + UIInteractions.triggerEventHandlerKeyDown('Tab', prefix); + tick(DEBOUNCE_TIME); + fix.detectChanges(); + GridFunctions.verifyFilteringDropDownIsOpened(fix, false); + + UIInteractions.triggerEventHandlerKeyDown(' ', input); + tick(DEBOUNCE_TIME); + fix.detectChanges(); + GridFunctions.verifyFilteringDropDownIsOpened(fix); + })); + + // UI tests date column + it('UI - should correctly filter date column', fakeAsync(() => { + GridFunctions.clickFilterCellChip(fix, 'ReleaseDate'); + + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const reset = filterUIRow.queryAll(By.css('button'))[0]; + const close = filterUIRow.queryAll(By.css('button'))[1]; + const input = filterUIRow.query(By.directive(IgxInputDirective)); + const expectedResults = GridFunctions.createDateFilterConditions(grid, today); + + // Today condition + GridFunctions.openFilterDDAndSelectCondition(fix, 4); + + expect(grid.rowList.length).toEqual(1); + verifyFilterRowUI(input, close, reset, false); + verifyFilterUIPosition(filterUIRow, grid); + + expect(grid.rowList.length).toEqual(1); + + // This Month condition + GridFunctions.openFilterDDAndSelectCondition(fix, 6); + verifyFilterRowUI(input, close, reset, false); + expect(grid.rowList.length).toEqual(expectedResults[5]); + + // Last Month condition + GridFunctions.openFilterDDAndSelectCondition(fix, 7); + verifyFilterRowUI(input, close, reset, false); + expect(grid.rowList.length).toEqual(expectedResults[0]); + + // Empty condition + GridFunctions.openFilterDDAndSelectCondition(fix, 12); + verifyFilterRowUI(input, close, reset, false); + expect(grid.rowList.length).toEqual(2); + })); + + it('UI - should correctly filter date column by \'equals\' filtering conditions', fakeAsync(() => { + pending('This should be tested in the e2e test'); + GridFunctions.clickFilterCellChip(fix, 'ReleaseDate'); + + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const input = filterUIRow.query(By.directive(IgxInputDirective)); + + GridFunctions.openFilterDDAndSelectCondition(fix, 0); + + input.triggerEventHandler('click', null); + tick(); + fix.detectChanges(); + + const outlet = document.getElementsByClassName('igx-grid__outlet')[0]; + const calendar = outlet.getElementsByClassName('igx-calendar')[0]; + + const currentDay = calendar.querySelector('.igx-days-view__date--current'); + + currentDay.dispatchEvent(new Event('click')); + + flush(); + fix.detectChanges(); + input.triggerEventHandler('change', null); + tick(); + fix.detectChanges(); + + expect(grid.rowList.length).toEqual(1); + })); + + it('Should correctly select month from month view datepicker/calendar component', fakeAsync(() => { + pending('This should be tested in the e2e test'); + const filteringCells = fix.debugElement.queryAll(By.css(FILTER_UI_CELL)); + filteringCells[4].query(By.css('igx-chip')).nativeElement.click(); + tick(); + fix.detectChanges(); + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const filterIcon = filterUIRow.query(By.css('igx-icon')); + const input = filterUIRow.query(By.directive(IgxInputDirective)); + + filterIcon.nativeElement.click(); + tick(); + fix.detectChanges(); + + input.nativeElement.click(); + tick(); + fix.detectChanges(); + + const outlet = document.getElementsByClassName('igx-grid__outlet')[0]; + let calendar = outlet.getElementsByClassName('igx-calendar')[0]; + + calendar.querySelector('.igx-days-view__date--current'); + const monthView = calendar.querySelector('.igx-calendar-picker__date'); + + monthView.dispatchEvent(new Event('click')); + tick(); + fix.detectChanges(); + + const firstMonth = calendar.querySelector('.igx-calendar__month'); + const firstMonthText = (firstMonth as HTMLElement).innerText; + firstMonth.dispatchEvent(new Event('click')); + tick(); + fix.detectChanges(); + + calendar = outlet.getElementsByClassName('igx-calendar')[0]; + const month = calendar.querySelector('.igx-calendar-picker__date'); + + expect(month.innerHTML.trim()).toEqual(firstMonthText); + })); + + it('Should correctly select year from year view datepicker/calendar component', fakeAsync(() => { + pending('This should be tested in the e2e test'); + const filteringCells = fix.debugElement.queryAll(By.css(FILTER_UI_CELL)); + filteringCells[4].query(By.css('igx-chip')).nativeElement.click(); + tick(); + fix.detectChanges(); + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const filterIcon = filterUIRow.query(By.css('igx-icon')); + const input = filterUIRow.query(By.directive(IgxInputDirective)); + + filterIcon.nativeElement.click(); + tick(); + fix.detectChanges(); + + input.nativeElement.click(); + tick(); + fix.detectChanges(); + + const outlet = document.getElementsByClassName('igx-grid__outlet')[0]; + let calendar = outlet.getElementsByClassName('igx-calendar')[0]; + + const monthView = calendar.querySelectorAll('.igx-calendar-picker__date')[1]; + monthView.dispatchEvent(new Event('click')); + tick(); + fix.detectChanges(); + + const firstMonth = calendar.querySelectorAll('.igx-calendar__year')[0]; + firstMonth.dispatchEvent(new Event('click')); + tick(); + fix.detectChanges(); + + calendar = outlet.getElementsByClassName('igx-calendar')[0]; + const month = calendar.querySelectorAll('.igx-calendar-picker__date')[1]; + + const expectedResult = today.getFullYear() - 3; + expect(month.innerHTML.trim()).toEqual(expectedResult.toString()); + })); + + it('Time/DateTime: Should set editorOptions.dateTimeFormat as inputFormat to the quick filter editor', fakeAsync(() => { + const releaseDateTimeCol = grid.getColumnByName('ReleaseDateTime'); + const releaseTimeCol = grid.getColumnByName('ReleaseTime'); + releaseDateTimeCol.editorOptions = { + dateTimeFormat: 'dd-MM-yyyy' + }; + releaseDateTimeCol.pipeArgs = { + format: 'yyyy-dd-MM' + }; + + releaseTimeCol.editorOptions = { + dateTimeFormat: 'hh:mm' + }; + releaseTimeCol.pipeArgs = { + format: 'longTime' + }; + GridFunctions.clickFilterCellChipUI(fix, 'ReleaseDateTime'); + + let filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + let inputDirectiveInstance = filterUIRow.query(By.directive(IgxDateTimeEditorDirective)) + .injector.get(IgxDateTimeEditorDirective); + expect(inputDirectiveInstance.inputFormat).toMatch('dd-MM-yyyy'); + expect(inputDirectiveInstance.displayFormat).toMatch('yyyy-dd-MM'); + + GridFunctions.clickFilterCellChipUI(fix, 'ReleaseTime'); + tick(DEBOUNCE_TIME); + fix.detectChanges(); + + filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + inputDirectiveInstance = filterUIRow.query(By.directive(IgxTimePickerComponent)).componentInstance; + expect(inputDirectiveInstance.inputFormat).toMatch('hh:mm'); + expect(inputDirectiveInstance.displayFormat).toMatch('longTime'); + })); + + it('Time/DateTime: Should set pipeArgs.format as inputFormat to the quick filter editor if numeric and editorOptions.dateTimeFormat not set', fakeAsync(() => { + const releaseDateTimeCol = grid.getColumnByName('ReleaseDateTime'); + const releaseTimeCol = grid.getColumnByName('ReleaseTime'); + + releaseDateTimeCol.pipeArgs = { + format: 'yyyy--dd--MM' + }; + + releaseTimeCol.pipeArgs = { + format: 'shortTime' + }; + tick(DEBOUNCE_TIME); + fix.detectChanges(); + + GridFunctions.clickFilterCellChipUI(fix, 'ReleaseDateTime'); + tick(DEBOUNCE_TIME); + fix.detectChanges(); + + let filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + let dateTimeEditor = filterUIRow.query(By.directive(IgxDateTimeEditorDirective)) + .injector.get(IgxDateTimeEditorDirective); + expect(dateTimeEditor.inputFormat).toMatch('yyyy--dd--MM'); + expect(dateTimeEditor.displayFormat).toMatch('yyyy--dd--MM'); + + GridFunctions.clickFilterCellChipUI(fix, 'ReleaseTime'); + tick(DEBOUNCE_TIME); + fix.detectChanges(); + + filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + dateTimeEditor = filterUIRow.query(By.directive(IgxDateTimeEditorDirective)) + .injector.get(IgxDateTimeEditorDirective); + // since 'shortTime' is numeric, input format will include its numeric parts + expect(dateTimeEditor.inputFormat.normalize('NFKC')).toMatch('hh:mm tt'); + expect(dateTimeEditor.displayFormat).toMatch('shortTime'); + })); + + + // UI tests custom column + it('UI tests on custom column', fakeAsync(() => { + GridFunctions.clickFilterCellChip(fix, 'AnotherField'); + + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const input = filterUIRow.query(By.directive(IgxInputDirective)); + const reset = filterUIRow.queryAll(By.css('button'))[0]; + const close = filterUIRow.queryAll(By.css('button'))[1]; + + GridFunctions.typeValueInFilterRowInput('a', fix, input); + tick(); + fix.detectChanges(); + + expect(grid.rowList.length).toEqual(1); + expect(grid.getCellByColumn(0, 'AnotherField').value).toMatch('custom'); + verifyFilterRowUI(input, close, reset, false); + })); + + it('Removing second condition removes the And/Or button', fakeAsync(() => { + const filteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And, 'ProductName'); + const expression = { + fieldName: 'ProductName', + searchVal: 'g', + condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains' + }; + const expression1 = { + fieldName: 'ProductName', + searchVal: 'I', + condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains' + }; + filteringExpressionsTree.filteringOperands.push(expression); + filteringExpressionsTree.filteringOperands.push(expression1); + grid.filter('ProductName', null, filteringExpressionsTree); + fix.detectChanges(); + + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + verifyFilterUIPosition(filterUIRow, grid); + + expect(grid.rowList.length).toEqual(2); + let andButton = fix.debugElement.queryAll(By.css('#operand')); + expect(andButton.length).toEqual(1); + + // remove the second chip + const secondChip = filterUIRow.queryAll(By.css('igx-chip'))[1]; + ControlsFunction.clickChipRemoveButton(secondChip.nativeElement); + tick(); + fix.detectChanges(); + + expect(grid.rowList.length).toEqual(3); + andButton = fix.debugElement.queryAll(By.css('#operand')); + expect(andButton.length).toEqual(0); + })); + + it('When filter column with value 0 and dataType number, filtering chip should be applied', fakeAsync(() => { + GridFunctions.clickFilterCellChip(fix, 'Downloads'); + + GridFunctions.typeValueInFilterRowInput(0, fix); + + GridFunctions.closeFilterRow(fix); + + const gridheaders = fix.debugElement.queryAll(By.css('igx-grid-header')); + const headerOfTypeNumber = gridheaders.find(gh => gh.nativeElement.classList.contains('igx-grid-th--number')); + const filterCellsForTypeNumber = headerOfTypeNumber.parent.query(By.css(FILTER_UI_CELL)); + expect(filterCellsForTypeNumber.queryAll(By.css('.igx-filtering-chips')).length).toBe(1); + })); + + it('Should correctly create FilteringExpressionsTree and populate filterUI.', fakeAsync(() => { + const filteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And, 'ProductName'); + const expression = { + fieldName: 'ProductName', + searchVal: 'Ignite', + condition: IgxStringFilteringOperand.instance().condition('startsWith'), + conditionName: 'startsWith' + }; + + filteringExpressionsTree.filteringOperands.push(expression); + grid.filteringExpressionsTree = filteringExpressionsTree; + + fix.detectChanges(); + + expect(grid.rowList.length).toEqual(2); + + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + + GridFunctions.openFilterDD(fix.debugElement); + fix.detectChanges(); + + const filterUIContainer = fix.debugElement.queryAll(By.css(FILTER_UI_ROW))[0]; + const input = filterUIContainer.query(By.directive(IgxInputDirective)); + + expect(ControlsFunction.getDropDownSelectedItem(filterUIContainer).nativeElement.textContent).toMatch('Starts With'); + expect(input.nativeElement.value).toMatch('Ignite'); + })); + + it('Should complete the filter when clicking the commit icon', fakeAsync(() => { + const filterValue = 'an'; + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + + GridFunctions.typeValueInFilterRowInput(filterValue, fix); + tick(); + fix.detectChanges(); + + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const filterChip = filterUIRow.query(By.directive(IgxChipComponent)); + expect(filterChip).toBeTruthy(); + expect(filterChip.componentInstance.selected).toBeTruthy(); + + grid.filteringRow.onCommitClick(); + tick(100); + fix.detectChanges(); + + expect(filterChip.componentInstance.selected).toBeFalsy(); + })); + + it('Should complete the filter when focusing out of the input', fakeAsync(() => { + const filterValue = 'an'; + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + tick(16); // onConditionsChanged rAF + + GridFunctions.typeValueInFilterRowInput(filterValue, fix); + tick(); + fix.detectChanges(); + + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const filterChip = filterUIRow.query(By.directive(IgxChipComponent)); + expect(filterChip).toBeTruthy(); + expect(filterChip.componentInstance.selected).toBeTruthy(); + + grid.nativeElement.focus(); + tick(100); + fix.detectChanges(); + + expect(filterChip.componentInstance.selected).toBeFalsy(); + })); + + it('UI - should use dropdown mode for the date picker', fakeAsync(() => { + GridFunctions.clickFilterCellChip(fix, 'ReleaseDate'); + + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const datePicker = filterUIRow.query(By.css('igx-date-picker')); + expect(datePicker.componentInstance.mode).toBe('dropdown'); + })); + + it('Should not select all filter chips when switching columns', fakeAsync(() => { + const filteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And, 'ProductName'); + const expression = { + fieldName: 'ProductName', + searchVal: 'Ignite', + condition: IgxStringFilteringOperand.instance().condition('startsWith'), + conditionName: 'startsWith' + }; + const expression1 = { + fieldName: 'ProductName', + searchVal: 'Angular', + condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains' + }; + filteringExpressionsTree.filteringOperands.push(expression); + filteringExpressionsTree.filteringOperands.push(expression1); + grid.filter('ProductName', null, filteringExpressionsTree); + fix.detectChanges(); + + GridFunctions.clickFilterCellChip(fix, 'Downloads'); + + const columnProductName = GridFunctions.getColumnHeader('ProductName', fix); + columnProductName.triggerEventHandler('click', { stopPropagation: () => { } }); + fix.detectChanges(); + + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const filterChips = filterUIRow.queryAll(By.directive(IgxChipComponent)); + const input = filterUIRow.query(By.directive(IgxInputDirective)); + expect(filterChips.length).toEqual(2); + expect(filterChips[0].componentInstance.selected).toBeFalsy(); + expect(filterChips[1].componentInstance.selected).toBeFalsy(); + expect(input.nativeElement.value).toMatch(''); + })); + + it('should render Filter chip for filterable columns and render empty cell for a column when filterable is set to false', + fakeAsync(() => { + grid.width = '1500px'; + fix.detectChanges(); + + const filteringCells = GridFunctions.getFilteringCells(fix); + const filteringChips = GridFunctions.getFilteringChips(fix); + expect(filteringCells.length).toBe(8); + expect(filteringChips.length).toBe(7); + + let idCellChips = GridFunctions.getFilteringChipPerIndex(fix, 0); + expect(idCellChips.length).toBe(0); + + grid.getColumnByName('ID').filterable = true; + fix.detectChanges(); + // tick(100); + + idCellChips = GridFunctions.getFilteringChipPerIndex(fix, 0); + expect(idCellChips.length).toBe(1); + })); + + it('should render correct input and dropdown in filter row for different column types', + fakeAsync(/** showHideArrowButtons rAF */() => { + pending('This should be tested in the e2e test'); + const filteringCells = fix.debugElement.queryAll(By.css(FILTER_UI_CELL)); + const stringCellChip = filteringCells[1].query(By.css('igx-chip')); + const numberCellChip = filteringCells[2].query(By.css('igx-chip')); + const boolCellChip = filteringCells[3].query(By.css('igx-chip')); + const dateCellChip = filteringCells[4].query(By.css('igx-chip')); + // open for string + stringCellChip.triggerEventHandler('click', null); + fix.detectChanges(); + + checkUIForType('string', fix.debugElement); + + // close + let filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + let close = filterUIRow.queryAll(By.css('button'))[1]; + close.nativeElement.click(); + fix.detectChanges(); + + // open for number + numberCellChip.nativeElement.click(); + fix.detectChanges(); + checkUIForType('number', fix.debugElement); + + // close + filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + close = filterUIRow.queryAll(By.css('button'))[1]; + close.nativeElement.click(); + fix.detectChanges(); + + // open for date + dateCellChip.nativeElement.click(); + fix.detectChanges(); + checkUIForType('date', fix.debugElement); + + // close + filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + close = filterUIRow.queryAll(By.css('button'))[1]; + close.nativeElement.click(); + fix.detectChanges(); + + // open for bool + boolCellChip.nativeElement.click(); + fix.detectChanges(); + checkUIForType('bool', fix.debugElement); + + // close + filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + close = filterUIRow.queryAll(By.css('button'))[1]; + close.nativeElement.click(); + fix.detectChanges(); + })); + + it('should apply multiple conditions to grid immediately while the filter row is still open', fakeAsync(() => { + pending('This should be tested in the e2e test'); + const filteringCells = fix.debugElement.queryAll(By.css(FILTER_UI_CELL)); + const stringCellChip = filteringCells[1].query(By.css('igx-chip')); + const numberCellChip = filteringCells[2].query(By.css('igx-chip')); + const boolCellChip = filteringCells[3].query(By.css('igx-chip')); + const dateCellChip = filteringCells[4].query(By.css('igx-chip')); + // open for string + stringCellChip.nativeElement.click(); + fix.detectChanges(); + + GridFunctions.filterBy('Starts With', 'I', fix); + expect(grid.rowList.length).toEqual(2); + GridFunctions.filterBy('Ends With', 'r', fix); + expect(grid.rowList.length).toEqual(1); + + // Reset and Close + GridFunctions.resetFilterRow(fix); + GridFunctions.closeFilterRow(fix); + + // open for number + numberCellChip.nativeElement.click(); + fix.detectChanges(); + + GridFunctions.filterBy('Less Than', '100', fix); + expect(grid.rowList.length).toEqual(3); + GridFunctions.filterBy('Greater Than', '10', fix); + expect(grid.rowList.length).toEqual(1); + + // Reset and Close + GridFunctions.resetFilterRow(fix); + GridFunctions.closeFilterRow(fix); + + // open for bool + boolCellChip.nativeElement.click(); + fix.detectChanges(); + + GridFunctions.filterBy('False', '', fix); + expect(grid.rowList.length).toEqual(2); + GridFunctions.filterBy('Empty', '', fix); + expect(grid.rowList.length).toEqual(3); + + // Reset and Close + GridFunctions.resetFilterRow(fix); + GridFunctions.closeFilterRow(fix); + + // open for date + dateCellChip.nativeElement.click(); + fix.detectChanges(); + + GridFunctions.filterBy('Today', '', fix); + expect(grid.rowList.length).toEqual(1); + GridFunctions.filterBy('Null', '', fix); + expect(grid.rowList.length).toEqual(0); + })); + + it('should render navigation arrows in the filtering row when chips don\'t fit.', fakeAsync(() => { + const filteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And, 'ProductName'); + for (let i = 0; i < 10; i++) { + const expression = { + fieldName: 'ProductName', + searchVal: 'I', + condition: IgxStringFilteringOperand.instance().condition('startsWith'), + conditionName: 'startsWith' + }; + filteringExpressionsTree.filteringOperands.push(expression); + } + grid.filter('ProductName', null, filteringExpressionsTree); + fix.detectChanges(); + + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + tick(500); + fix.detectChanges(); + + expect(GridFunctions.getFilterRowLeftArrowButton(fix)).not.toBe(null); + expect(GridFunctions.getFilterRowRightArrowButton(fix)).not.toBe(null); + })); + + it('should update UI when chip is removed from filter row.', fakeAsync(() => { + grid.filter('ProductName', 'I', IgxStringFilteringOperand.instance().condition('startsWith')); + fix.detectChanges(); + + expect(grid.rowList.length).toEqual(2); + + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + + // remove from row + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + GridFunctions.removeFilterChipByIndex(0, filterUIRow); + tick(100); + fix.detectChanges(); + + expect(grid.rowList.length).toEqual(8); + })); + + it('should not render chip in header if condition that requires value is applied and then value is cleared in filter row.', + fakeAsync(() => { + grid.filter('ProductName', 'I', IgxStringFilteringOperand.instance().condition('startsWith')); + fix.detectChanges(); + + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + tick(100); + fix.detectChanges(); + + const clearButton = GridFunctions.getFilterRowInputClearIcon(fix); + clearButton.triggerEventHandler('click', null); + tick(100); + fix.detectChanges(); + + GridFunctions.closeFilterRow(fix); + tick(100); + fix.detectChanges(); + + // check no condition is applied + expect(grid.rowList.length).toEqual(8); + + const filteringChips = GridFunctions.getFilteringChips(fix); + expect(GridFunctions.getChipText(filteringChips[1])).toEqual('Filter'); + })); + + it('should reset the filter chips area when changing grid width', fakeAsync(() => { + grid.width = '300px'; + fix.detectChanges(); + tick(100); + + const filteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And, 'ProductName'); + const expression1 = { + fieldName: 'ProductName', + searchVal: 'Ignite', + condition: IgxStringFilteringOperand.instance().condition('startsWith'), + conditionName: 'startsWith' + }; + + const expression2 = { + fieldName: 'ProductName', + searchVal: 'test', + condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains' + }; + + filteringExpressionsTree.filteringOperands.push(expression1); + filteringExpressionsTree.filteringOperands.push(expression2); + grid.filter('ProductName', null, filteringExpressionsTree); + fix.detectChanges(); + + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + tick(100); + fix.detectChanges(); + + expect(GridFunctions.getFilterRowLeftArrowButton(fix)).not.toBeNull(); + expect(GridFunctions.getFilterRowRightArrowButton(fix)).not.toBeNull(); + + grid.width = '900px'; + fix.detectChanges(); + tick(200); + + expect(GridFunctions.getFilterRowLeftArrowButton(fix)).toBeNull(); + expect(GridFunctions.getFilterRowRightArrowButton(fix)).toBeNull(); + })); + + it('Should correctly update filtering row rendered when changing current column by clicking on a header.', fakeAsync(() => { + pending('This should be tested in the e2e test'); + const headers = fix.debugElement.queryAll(By.directive(IgxGridHeaderComponent)); + const numberHeader = headers[2]; + const boolHeader = headers[3]; + const dateHeader = headers[4]; + const initialChips = fix.debugElement.queryAll(By.directive(IgxChipComponent)); + const stringCellChip = initialChips[0].nativeElement; + + stringCellChip.click(); + fix.detectChanges(); + + checkUIForType('string', fix.debugElement); + + // Click on number column. + numberHeader.nativeElement.click(); + fix.detectChanges(); + + checkUIForType('number', fix.debugElement); + + // Click on boolean column + boolHeader.nativeElement.click(); + fix.detectChanges(); + + checkUIForType('bool', fix.debugElement); + + // Click on date column + dateHeader.nativeElement.click(); + fix.detectChanges(); + + checkUIForType('date', fix.debugElement); + })); + + it('Should correctly render read-only input when selecting read-only condition and should create a chip.', fakeAsync(() => { + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + + GridFunctions.openFilterDD(fix.debugElement); + fix.detectChanges(); + + const filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + const dropdownList = fix.debugElement.query(By.css('div.igx-drop-down__list-scroll')); + const input = filteringRow.query(By.directive(IgxInputDirective)); + + GridFunctions.selectFilteringCondition('Empty', dropdownList); + fix.detectChanges(); + + const chips = filteringRow.queryAll(By.directive(IgxChipComponent)); + expect(chips.length).toEqual(1); + expect(chips[0].componentInstance.selected).toBeTruthy(); + expect(GridFunctions.getChipText(chips[0])).toEqual('Empty'); + expect(input.properties.readOnly).toBeTruthy(); + })); + + it('should correctly filter negative values', fakeAsync(() => { + fix = TestBed.createComponent(IgxGridFilteringNumericComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + + GridFunctions.clickFilterCellChip(fix, 'Number'); + + const filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + const input = filteringRow.query(By.directive(IgxInputDirective)); + + // Set input and confirm + GridFunctions.typeValueInFilterRowInput('-1', fix); + tick(); + fix.detectChanges(); + + expect(input.componentInstance.value).toEqual('-1'); + expect(grid.rowList.length).toEqual(1); + + GridFunctions.typeValueInFilterRowInput('0', fix); + tick(); + fix.detectChanges(); + + expect(input.componentInstance.value).toEqual('0'); + expect(grid.rowList.length).toEqual(0); + + GridFunctions.typeValueInFilterRowInput('-0.5', fix); + tick(); + fix.detectChanges(); + + expect(input.componentInstance.value).toEqual('-0.5'); + expect(grid.rowList.length).toEqual(1); + + GridFunctions.typeValueInFilterRowInput('', fix); + tick(); + fix.detectChanges(); + + expect(input.componentInstance.value).toEqual(null); + expect(grid.rowList.length).toEqual(3); + })); + + it('Should focus input .', fakeAsync(() => { + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + + // Open dropdown + GridFunctions.openFilterDD(fix.debugElement); + fix.detectChanges(); + + const filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + const dropdownList = fix.debugElement.query(By.css('div.igx-drop-down__list-scroll')); + const input = filteringRow.query(By.directive(IgxInputDirective)); + + // Select condition with input + GridFunctions.selectFilteringCondition('Contains', dropdownList); + + // Check focus is kept + expect(document.activeElement).toEqual(input.nativeElement); + + // Set input and confirm + GridFunctions.typeValueInFilterRowInput('a', fix, input); + tick(); + fix.detectChanges(); + + // Check a chip is created after input and is marked as selected. + const filterChip = filteringRow.query(By.directive(IgxChipComponent)); + expect(filterChip).toBeTruthy(); + expect(filterChip.componentInstance.selected).toBeTruthy(); + expect(input.componentInstance.value).toEqual('a'); + + UIInteractions.triggerEventHandlerKeyDown('Enter', input); + fix.detectChanges(); + + // Check focus is kept and chips is no longer selected. + expect(filterChip.componentInstance.selected).toBeFalsy(); + expect(grid.rowList.length).toEqual(3); + expect(document.activeElement).toEqual(input.nativeElement); + expect(input.componentInstance.value).toEqual(null); + + GridFunctions.clickChip(filterChip); + fix.detectChanges(); + + expect(document.activeElement).toEqual(input.nativeElement); + expect(input.componentInstance.value).toEqual('a'); + expect(filterChip.componentInstance.selected).toBeTruthy(); + })); + + it('should update UI when filtering via the API.', fakeAsync(() => { + grid.width = '1600px'; + grid.columnList.get(1).width = '400px'; + fix.detectChanges(); + + const filteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And, 'ProductName'); + const expression = { + fieldName: 'ProductName', + searchVal: 'Ignite', + condition: IgxStringFilteringOperand.instance().condition('startsWith'), + conditionName: 'startsWith' + }; + const expression1 = { + fieldName: 'ProductName', + searchVal: 'Angular', + condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains' + }; + filteringExpressionsTree.filteringOperands.push(expression); + filteringExpressionsTree.filteringOperands.push(expression1); + grid.filter('ProductName', null, filteringExpressionsTree); + grid.filter('Released', true, IgxBooleanFilteringOperand.instance().condition('false')); + fix.detectChanges(); + + expect(grid.rowList.length).toEqual(0); + const filteringCells = GridFunctions.getFilteringCells(fix); + const stringCellChips = filteringCells[1].queryAll(By.css('igx-chip')); + const boolCellChips = filteringCells[3].queryAll(By.css('igx-chip')); + const strConnector = filteringCells[1].query(By.css('.igx-filtering-chips__connector')); + + expect(strConnector.nativeElement.textContent.trim()).toBe('And'); + expect(stringCellChips.length).toBe(2); + expect(boolCellChips.length).toBe(1); + + expect(GridFunctions.getChipText(stringCellChips[0])).toBe('Ignite'); + expect(GridFunctions.getChipText(stringCellChips[1])).toBe('Angular'); + expect(GridFunctions.getChipText(boolCellChips[0])).toBe('False'); + })); + + it('should display view more icon in filter cell if chips don\'t fit in the cell.', fakeAsync(() => { + grid.columnList.get(1).width = '200px'; + fix.detectChanges(); + + const filteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And, 'ProductName'); + const expression = { + fieldName: 'ProductName', + searchVal: 'Ignite', + condition: IgxStringFilteringOperand.instance().condition('startsWith'), + conditionName: 'startsWith' + }; + const expression1 = { + fieldName: 'ProductName', + searchVal: 'for', + condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains' + }; + filteringExpressionsTree.filteringOperands.push(expression); + filteringExpressionsTree.filteringOperands.push(expression1); + grid.filter('ProductName', null, filteringExpressionsTree); + fix.detectChanges(); + + // check 1 chip and view more icon is displayed. + const chips = GridFunctions.getFilterChipsForColumn('ProductName', fix); + expect(chips.length).toEqual(1); + const fcIndicator = GridFunctions.getFilterIndicatorForColumn('ProductName', fix); + + const indicatorBadge = fcIndicator[0].query(By.directive(IgxBadgeComponent)); + expect(indicatorBadge).toBeTruthy(); + expect(indicatorBadge.nativeElement.innerText.trim()).toEqual('1'); + })); + + it('should select chip when open it from filter cell', fakeAsync(() => { + grid.filter('ProductName', 'Ignite', IgxStringFilteringOperand.instance().condition('startsWith')); + fix.detectChanges(); + + GridFunctions.clickFilterCellChipUI(fix, 'ProductName'); + tick(100); + fix.detectChanges(); + + const filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + const filterChip = filteringRow.query(By.directive(IgxChipComponent)); + const input = filteringRow.query(By.directive(IgxInputDirective)); + expect(filterChip).toBeTruthy(); + expect(filterChip.componentInstance.selected).toBeTruthy(); + expect(input.componentInstance.value).toEqual('Ignite'); + + })); + + it('Should allow setting filtering conditions through filteringExpressionsTree.', fakeAsync(() => { + grid.columnList.get(1).width = '200px'; + fix.detectChanges(); + + // Add initial filtering conditions + const gridFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + const columnsFilteringTree = new FilteringExpressionsTree(FilteringLogic.And, 'ProductName'); + columnsFilteringTree.filteringOperands = [ + { fieldName: 'ProductName', searchVal: 'a', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' }, + { fieldName: 'ProductName', searchVal: 'o', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' } + ]; + gridFilteringExpressionsTree.filteringOperands.push(columnsFilteringTree); + grid.filteringExpressionsTree = gridFilteringExpressionsTree; + fix.detectChanges(); + + const colChips = GridFunctions.getFilterChipsForColumn('ProductName', fix); + const colOperands = GridFunctions.getFilterOperandsForColumn('ProductName', fix); + const colIndicator = GridFunctions.getFilterIndicatorForColumn('ProductName', fix); + + expect(grid.rowList.length).toEqual(2); + expect(colChips.length).toEqual(1); + expect(GridFunctions.getChipText(colChips[0])).toEqual('a'); + expect(colOperands.length).toEqual(0); + + const indicatorBadge = colIndicator[0].query(By.directive(IgxBadgeComponent)); + expect(indicatorBadge).toBeTruthy(); + expect(indicatorBadge.nativeElement.innerText.trim()).toEqual('1'); + })); + + it('Should close FilterRow when Escape is pressed.', fakeAsync(() => { + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + + let filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + + UIInteractions.triggerKeyDownEvtUponElem('Escape', filteringRow.nativeElement, true); + tick(100); + fix.detectChanges(); + + filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + expect(filteringRow).toBeNull(); + })); + + it('Should correctly load default resource strings for filter row', fakeAsync(() => { + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + + const filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + expect(filteringRow).toBeDefined(); + + const editingBtns = filteringRow.query(By.css('.igx-grid__filtering-row-editing-buttons')); + const reset = editingBtns.queryAll(By.css('button'))[0]; + + expect(reset.nativeElement.childNodes[1].textContent.trim()).toBe('Reset'); + })); + + it('Should correctly change resource strings for filter row using Changei18n.', fakeAsync(() => { + fix = TestBed.createComponent(IgxGridFilteringComponent); + const strings = GridResourceStringsEN; + strings.igx_grid_filter = 'My filter'; + strings.igx_grid_filter_row_close = 'My close'; + igxI18N.instance().changei18n(strings); + fix.detectChanges(); + + const initialChips = GridFunctions.getFilteringChips(fix); + expect(GridFunctions.getChipText(initialChips[0])).toBe('My filter'); + + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + + const filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + expect(filteringRow).toBeDefined(); + + const editingBtns = filteringRow.query(By.css('.igx-grid__filtering-row-editing-buttons')); + const reset = editingBtns.queryAll(By.css('button'))[0]; + const close = editingBtns.queryAll(By.css('button'))[1]; + + expect(close.nativeElement.childNodes[1].textContent.trim()).toBe('My close'); + expect(reset.nativeElement.childNodes[1].textContent.trim()).toBe('Reset'); + + igxI18N.instance().changei18n({ + igx_grid_filter: 'Filter', + igx_grid_filter_row_close: 'Close' + }); + })); + + it('Should correctly change resource strings for filter row.', fakeAsync(() => { + fix = TestBed.createComponent(IgxGridFilteringComponent); + grid = fix.componentInstance.grid; + grid.resourceStrings = Object.assign({}, grid.resourceStrings, { + igx_grid_filter: 'My filter', + igx_grid_filter_row_close: 'My close' + }); + fix.detectChanges(); + + const initialChips = GridFunctions.getFilteringChips(fix); + expect(GridFunctions.getChipText(initialChips[0])).toBe('My filter'); + + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + + const filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + expect(filteringRow).toBeDefined(); + + const editingBtns = filteringRow.query(By.css('.igx-grid__filtering-row-editing-buttons')); + const reset = editingBtns.queryAll(By.css('button'))[0]; + const close = editingBtns.queryAll(By.css('button'))[1]; + + expect(close.nativeElement.childNodes[1].textContent.trim()).toBe('My close'); + expect(reset.nativeElement.childNodes[1].textContent.trim()).toBe('Reset'); + })); + + it('should correctly apply locale to datePicker.', fakeAsync(() => { + fix.detectChanges(); + + grid.locale = 'de-DE'; + tick(300); + fix.detectChanges(); + + const initialChips = fix.debugElement.queryAll(By.directive(IgxChipComponent)); + const dateCellChip = initialChips[3].nativeElement; + + dateCellChip.click(); + fix.detectChanges(); + + const filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + const datePicker = filteringRow.query(By.directive(IgxDatePickerComponent)); + datePicker.componentInstance.getEditElement().click(); + tick(); + fix.detectChanges(); + + const outlet = document.getElementsByClassName('igx-grid__outlet')[0]; + const calendar = outlet.getElementsByClassName('igx-calendar')[0]; + + const sundayLabel = calendar.querySelectorAll('.igx-days-view__label')[0].textContent; + + expect(sundayLabel.trim()).toEqual('Mo'); + })); + + it('Should size grid correctly if enable/disable filtering in run time.', fakeAsync(() => { + const head = grid.theadRow.nativeElement; + const body = grid.nativeElement.querySelector('.igx-grid__tbody'); + + expect(head.getBoundingClientRect().bottom).toEqual(body.getBoundingClientRect().top); + + fix.componentInstance.activateFiltering(false); + fix.detectChanges(); + + expect(head.getBoundingClientRect().bottom).toEqual(body.getBoundingClientRect().top); + + fix.componentInstance.activateFiltering(true); + fix.detectChanges(); + + expect(head.getBoundingClientRect().bottom).toEqual(body.getBoundingClientRect().top); + })); + + it('Should remove FilterRow, when allowFiltering is set to false.', fakeAsync(() => { + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + + let filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + expect(filteringRow).toBeDefined(); + + grid.allowFiltering = false; + fix.detectChanges(); + + filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + expect(filteringRow).toBeNull(); + })); + + it('should open \'conditions dropdown\' on prefix click and should close it on second click.', fakeAsync(() => { + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + + const prefix = GridFunctions.getFilterRowPrefix(fix); + const event = { + currentTarget: prefix.nativeElement, + preventDefault: () => { }, + stopPropagation: () => { } + }; + + // Click prefix to open conditions dropdown + prefix.triggerEventHandler('click', event); + tick(100); + fix.detectChanges(); + + // Verify dropdown is opened + GridFunctions.verifyFilteringDropDownIsOpened(fix); + + // Click prefix again to close conditions dropdown + prefix.triggerEventHandler('click', event); + tick(100); + fix.detectChanges(); + + // Verify dropdown is closed + GridFunctions.verifyFilteringDropDownIsOpened(fix, false); + })); + + it('should close \'conditions dropdown\' when navigate with Tab key', fakeAsync(() => { + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + + const prefix = GridFunctions.getFilterRowPrefix(fix); + const event = { + currentTarget: prefix.nativeElement, + preventDefault: () => { }, + stopPropagation: () => { } + }; + + // Click prefix to open conditions dropdown + prefix.triggerEventHandler('click', event); + tick(100); + fix.detectChanges(); + + // Verify dropdown is opened + GridFunctions.verifyFilteringDropDownIsOpened(fix); + + // Press Tab key + UIInteractions.triggerKeyDownEvtUponElem('Tab', prefix.nativeElement, true); + tick(100); + fix.detectChanges(); + + // Verify dropdown is closed + GridFunctions.verifyFilteringDropDownIsOpened(fix, false); + })); + + it('should open \'conditions dropdown\' when press Alt+KyeDown on the input', fakeAsync(() => { + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const input = filterUIRow.query(By.directive(IgxInputDirective)); + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', input, true); + tick(DEBOUNCE_TIME); + fix.detectChanges(); + GridFunctions.verifyFilteringDropDownIsOpened(fix); + })); + + it('should close filter row on Escape key pressed on the input', fakeAsync(() => { + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + + let filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const input = filterUIRow.query(By.directive(IgxInputDirective)); + + UIInteractions.triggerEventHandlerKeyDown('Escape', input); + tick(100); + fix.detectChanges(); + + filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + expect(filterUIRow).toBeNull(); + expect(grid.filteringService.isFilterRowVisible).toBeFalsy(); + })); + + it('Should not commit the input when null value is added', fakeAsync(() => { + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const reset = filterUIRow.queryAll(By.css('button'))[0]; + const close = filterUIRow.queryAll(By.css('button'))[1]; + const input = filterUIRow.query(By.directive(IgxInputDirective)); + + expect(grid.rowList.length).toEqual(8); + + // select unary conditions + GridFunctions.openFilterDDAndSelectCondition(fix, 6); + + let filterChip = filterUIRow.query(By.directive(IgxChipComponent)); + expect(filterChip).toBeTruthy(); + expect(filterChip.componentInstance.selected).toBeTruthy(); + expect(grid.rowList.length).toEqual(4); + verifyFilterRowUI(input, close, reset, false); + + // select not unary conditions + GridFunctions.openFilterDDAndSelectCondition(fix, 1); + + expect(filterChip).toBeTruthy(); + expect(filterChip.componentInstance.selected).toBeTruthy(); + expect(grid.rowList.length).toEqual(4); + verifyFilterRowUI(input, close, reset, false); + + // submit the input with empty value + UIInteractions.triggerEventHandlerKeyDown('Enter', input); + tick(DEBOUNCE_TIME); + fix.detectChanges(); + + filterChip = filterUIRow.query(By.directive(IgxChipComponent)); + expect(filterChip).toBeNull(); + expect(grid.rowList.length).toEqual(8); + verifyFilterRowUI(input, close, reset); + })); + + it('Should navigate keyboard focus correctly between the filter row and the grid cells.', fakeAsync(() => { + pending(`The test needs refactoring. The dispatchEvent doesn't focus elements with tabindex over them. + Also, the focus is now persistent over the tbody element`); + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + + const cell = grid.gridAPI.get_cell_by_index(0, 'ID'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + cell.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true })); + fix.detectChanges(); + + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const closeButton = filterUIRow.queryAll(By.css('button'))[1]; + expect(document.activeElement).toBe(closeButton.nativeElement); + + filterUIRow.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' })); + fix.detectChanges(); + tick(); + expect(document.activeElement).toBe(cell.nativeElement); + })); + + it('should hide chip arrows when the grid is narrow and column is not filtered', fakeAsync(() => { + grid.width = '400px'; + tick(200); + fix.detectChanges(); + + // Click string filter chip to show filter row. + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + tick(200); + + // Verify arrows and chip area are not visible because there is no active filtering for the column. + const filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + const chipArea = filteringRow.query(By.css('igx-chip-area')); + expect(GridFunctions.getFilterRowLeftArrowButton(fix)).toBeNull(); + expect(GridFunctions.getFilterRowRightArrowButton(fix)).toBeNull(); + expect(chipArea).toBeNull('chipArea is present'); + })); + + it('Should remove first chip and filter by the remaining ones.', fakeAsync(() => { + // Add initial filtering conditions + const gridFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + const columnsFilteringTree = new FilteringExpressionsTree(FilteringLogic.And, 'ProductName'); + columnsFilteringTree.filteringOperands = [ + { fieldName: 'ProductName', searchVal: 'z', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' }, + { fieldName: 'ProductName', searchVal: 'n', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' }, + { fieldName: 'ProductName', searchVal: 'g', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' } + ]; + gridFilteringExpressionsTree.filteringOperands.push(columnsFilteringTree); + grid.filteringExpressionsTree = gridFilteringExpressionsTree; + fix.detectChanges(); + + expect(grid.rowList.length).toEqual(0); + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + tick(200); + + // remove first chip + const filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + GridFunctions.removeFilterChipByIndex(0, filteringRow); + fix.detectChanges(); + + expect(grid.rowList.length).toEqual(3); + expect(filteringRow.queryAll(By.css('igx-chip')).length).toEqual(2); + })); + + it('Should remove middle chip and filter by the remaining ones.', fakeAsync(() => { + // Add initial filtering conditions + const gridFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + const columnsFilteringTree = new FilteringExpressionsTree(FilteringLogic.And, 'ProductName'); + columnsFilteringTree.filteringOperands = [ + { fieldName: 'ProductName', searchVal: 'n', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' }, + { fieldName: 'ProductName', searchVal: 'z', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' }, + { fieldName: 'ProductName', searchVal: 'g', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' } + ]; + gridFilteringExpressionsTree.filteringOperands.push(columnsFilteringTree); + grid.filteringExpressionsTree = gridFilteringExpressionsTree; + fix.detectChanges(); + + expect(grid.rowList.length).toEqual(0); + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + tick(200); + + // remove middle chip + const filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + GridFunctions.removeFilterChipByIndex(1, filteringRow); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(3); + expect(filteringRow.queryAll(By.css('igx-chip')).length).toEqual(2); + })); + + it('Verify filter cell chip is scrolled into view on click.', async () => { + grid.width = '470px'; + await wait(100); + fix.detectChanges(); + GridFunctions.getGridHeader(grid).nativeElement.focus(); // + fix.detectChanges(); + + // Verify 'ReleaseDate' filter chip is not fully visible. + let chip = GridFunctions.getFilterChipsForColumn('ReleaseDate', fix)[0].nativeElement; + let chipRect = chip.getBoundingClientRect(); + let gridRect = grid.nativeElement.getBoundingClientRect(); + expect(chipRect.right > gridRect.right).toBe(true, + 'chip should not be fully visible and thus not within grid'); + + GridFunctions.clickFilterCellChipUI(fix, 'ReleaseDate'); + await wait(100); + fix.detectChanges(); + + grid.filteringRow.close(); + await wait(); + fix.detectChanges(); + + // Verify 'ReleaseDate' filter chip is fully visible. + chip = GridFunctions.getFilterChipsForColumn('ReleaseDate', fix)[0].nativeElement; + chipRect = chip.getBoundingClientRect(); + gridRect = grid.nativeElement.getBoundingClientRect(); + expect(chipRect.left > gridRect.left && chipRect.right < gridRect.right).toBe(true, + 'chip should be fully visible and within grid'); + }); + + it('Verify condition chips are scrolled into/(out of) view by using arrow buttons.', (async () => { + grid.width = '700px'; + fix.detectChanges(); + + // Add initial filtering conditions + const gridFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + const columnsFilteringTree = new FilteringExpressionsTree(FilteringLogic.And, 'ProductName'); + columnsFilteringTree.filteringOperands = [ + { fieldName: 'ProductName', searchVal: 'a', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' }, + { fieldName: 'ProductName', searchVal: 'e', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' }, + { fieldName: 'ProductName', searchVal: 'i', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' } + ]; + gridFilteringExpressionsTree.filteringOperands.push(columnsFilteringTree); + grid.filteringExpressionsTree = gridFilteringExpressionsTree; + fix.detectChanges(); + + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + await wait(150); + fix.detectChanges(); + + verifyMultipleChipsVisibility(fix, [true, false, false]); + + grid.filteringRow.scrollChipsOnArrowPress('right'); + await wait(150); + fix.detectChanges(); + grid.filteringRow.scrollChipsOnArrowPress('right'); + await wait(150); + + fix.detectChanges(); + verifyMultipleChipsVisibility(fix, [false, true, false]); + + grid.filteringRow.scrollChipsOnArrowPress('left'); + await wait(150); + fix.detectChanges(); + grid.filteringRow.scrollChipsOnArrowPress('left'); + await wait(150); + fix.detectChanges(); + verifyMultipleChipsVisibility(fix, [true, false, false]); + })); + + it('Should navigate from left arrow button to first condition chip Tab.', (async () => { + grid.width = '700px'; + fix.detectChanges(); + + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + + // Add first chip. + GridFunctions.typeValueInFilterRowInput('a', fix); + await wait(16); + fix.detectChanges(); + GridFunctions.submitFilterRowInput(fix); + await wait(100); + fix.detectChanges(); + // Add second chip. + GridFunctions.typeValueInFilterRowInput('e', fix); + await wait(16); + fix.detectChanges(); + GridFunctions.submitFilterRowInput(fix); + await wait(100); + fix.detectChanges(); + // Add third chip. + GridFunctions.typeValueInFilterRowInput('i', fix); + await wait(16); + fix.detectChanges(); + GridFunctions.submitFilterRowInput(fix); + await wait(100); + fix.detectChanges(); + + // Verify first chip is not in view. + verifyChipVisibility(fix, 0, false); + + const leftArrowButton = GridFunctions.getFilterRowLeftArrowButton(fix).nativeElement; + leftArrowButton.focus(); + await wait(16); + fix.detectChanges(); + leftArrowButton.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' })); + fix.detectChanges(); + await wait(100); + + // Verify first chip is in view. + verifyChipVisibility(fix, 0, true); + })); + + it('Should toggle the selection of a condition chip when using \'Enter\' key.', fakeAsync(() => { + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + + // Add chip. + GridFunctions.typeValueInFilterRowInput('a', fix); + tick(100); + GridFunctions.submitFilterRowInput(fix); + tick(100); + + // Verify chip is not selected. + const filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + const filterChip = filteringRow.query(By.directive(IgxChipComponent)); + expect(filterChip).toBeTruthy(); + expect(filterChip.componentInstance.selected).toBeFalsy(); + + filterChip.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + tick(100); + fix.detectChanges(); + + // Verify chip is selected. + expect(filterChip.componentInstance.selected).toBeTruthy(); + + filterChip.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + fix.detectChanges(); + tick(100); + + // Verify chip is not selected. + expect(filterChip.componentInstance.selected).toBeFalsy(); + })); + + it('Should commit the value in the input when pressing \'Enter\' on commit icon in input.', fakeAsync(() => { + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + + // Type 'ang' in the filter row input. + GridFunctions.typeValueInFilterRowInput('ang', fix); + tick(); + fix.detectChanges(); + + // Verify chip is selected (in edit mode). + const filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + const filterChip = filteringRow.query(By.directive(IgxChipComponent)); + expect(filterChip).toBeTruthy(); + expect(filterChip.componentInstance.selected).toBeTruthy(); + + // Press 'Enter' on the commit icon. + const inputCommitIcon = GridFunctions.getFilterRowInputCommitIcon(fix); + UIInteractions.triggerEventHandlerKeyDown('Enter', inputCommitIcon); + tick(200); + fix.detectChanges(); + + // Verify chip is not selected (it is committed). + expect(filterChip.componentInstance.selected).toBeFalsy(); + })); + + it('Should clear the value in the input when pressing \'Enter\' on clear icon in input.', fakeAsync(() => { + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + + // Type 'ang' in the filter row input. + GridFunctions.typeValueInFilterRowInput('ang', fix); + tick(); + fix.detectChanges(); + + // Verify chip is selected (in edit mode). + const filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + const filterChip = filteringRow.query(By.directive(IgxChipComponent)); + expect(filterChip).toBeTruthy(); + expect(filterChip.componentInstance.selected).toBeTruthy(); + + // Press 'Enter' on the clear icon. + const inputClearIcon = GridFunctions.getFilterRowInputClearIcon(fix); + UIInteractions.triggerEventHandlerKeyDown('Enter', inputClearIcon); + tick(200); + fix.detectChanges(); + + // Verify there are no chips since we cleared the input. + const conditionChips = filteringRow.queryAll(By.directive(IgxChipComponent)); + expect(conditionChips.length).toBe(0); + })); + + it(`Should open/close filterRow for respective column when pressing 'ctrl + shift + l' on its filterCell chip.`, + fakeAsync(() => { + // Verify filterRow is not opened. + let filterUIRow = grid.theadRow.filterRow; + expect(filterUIRow).toBeUndefined(); + + const releaseDateColumn = GridFunctions.getColumnHeader('ReleaseDate', fix); + UIInteractions.simulateClickAndSelectEvent(releaseDateColumn); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('l', releaseDateColumn.nativeElement, true, false, true, true); + tick(200); + fix.detectChanges(); + + // Verify filterRow is opened for the 'ReleaseDate' column. + filterUIRow = grid.theadRow.filterRow; + expect(filterUIRow).toBeDefined(); + const headerGroupsFiltering = grid.headerGroupsList.filter(group => group.isFiltered); + expect(headerGroupsFiltering.length).toBe(1); + expect(headerGroupsFiltering[0].column.field).toMatch('ReleaseDate'); + + UIInteractions.triggerKeyDownEvtUponElem('l', filterUIRow.nativeElement, true, false, true, true); + tick(200); + fix.detectChanges(); + + filterUIRow = grid.theadRow.filterRow; + expect(filterUIRow).toBeUndefined(); + })); + + it('Should navigate to first cell of grid when pressing \'Tab\' on the last filterCell chip.', fakeAsync(() => { + pending('Should be fixed with headers navigation'); + const filterCellChip = GridFunctions.getFilterChipsForColumn('AnotherField', fix)[0]; + UIInteractions.triggerKeyDownEvtUponElem('Tab', filterCellChip.nativeElement, true); + tick(200); + fix.detectChanges(); + + const firstCell: any = GridFunctions.getRowCells(fix, 0)[0].nativeElement; + expect(document.activeElement).toBe(firstCell); + })); + + it(`Should remove first condition chip when click 'clear' button and focus 'more' icon.`, fakeAsync(() => { + grid.getColumnByName('ProductName').width = '200px'; + tick(DEBOUNCE_TIME); + fix.detectChanges(); + + // Add initial filtering conditions + const gridFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + const columnsFilteringTree = new FilteringExpressionsTree(FilteringLogic.And, 'ProductName'); + columnsFilteringTree.filteringOperands = [ + { fieldName: 'ProductName', searchVal: 'a', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' }, + { fieldName: 'ProductName', searchVal: 'e', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' }, + { fieldName: 'ProductName', searchVal: 'i', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' } + ]; + gridFilteringExpressionsTree.filteringOperands.push(columnsFilteringTree); + grid.filteringExpressionsTree = gridFilteringExpressionsTree; + fix.detectChanges(); + + // Verify active chip and its text. + let filterCellChip = GridFunctions.getFilterChipsForColumn('ProductName', fix)[0]; + expect(GridFunctions.getChipText(filterCellChip)).toBe('a'); + + // Remove active chip. + ControlsFunction.clickChipRemoveButton(filterCellChip.nativeElement); + tick(50); + fix.detectChanges(); + + const header = GridFunctions.getGridHeader(grid); + expect(document.activeElement).toBe(header.nativeElement); + + // Verify new chip text. + filterCellChip = GridFunctions.getFilterChipsForColumn('ProductName', fix)[0]; + expect(GridFunctions.getChipText(filterCellChip)).toBe('e'); + })); + + it(`Should focus 'grid header' when close filter row.`, fakeAsync(() => { + grid.getColumnByName('ProductName').width = '80px'; + tick(DEBOUNCE_TIME); + fix.detectChanges(); + + // Add initial filtering conditions + const gridFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + const columnsFilteringTree = new FilteringExpressionsTree(FilteringLogic.And, 'ProductName'); + columnsFilteringTree.filteringOperands = [ + { fieldName: 'ProductName', searchVal: 'a', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' }, + { fieldName: 'ProductName', searchVal: 'e', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' }, + { fieldName: 'ProductName', searchVal: 'i', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' } + ]; + gridFilteringExpressionsTree.filteringOperands.push(columnsFilteringTree); + grid.filteringExpressionsTree = gridFilteringExpressionsTree; + fix.detectChanges(); + + // Click more icon + const moreIcon = GridFunctions.getFilterIndicatorForColumn('ProductName', fix)[0]; + moreIcon.triggerEventHandler('click', null); + tick(100); + fix.detectChanges(); + + // verify first chip is selected + const filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + const filterChip = filteringRow.query(By.directive(IgxChipComponent)); + expect(filterChip).toBeTruthy(); + expect(filterChip.componentInstance.selected).toBeTruthy(); + + // close filter row + GridFunctions.closeFilterRow(fix); + tick(DEBOUNCE_TIME); + + const header = GridFunctions.getGridHeader(grid); + expect(document.activeElement).toEqual(header.nativeElement); + })); + + it('Should update active element when click \'clear\' button of last chip and there is no \'more\' icon.', fakeAsync(() => { + pending('This this is not valid anymore, so we should probably dellete it.'); + grid.getColumnByName('ProductName').width = '350px'; + tick(DEBOUNCE_TIME); + fix.detectChanges(); + + // Add initial filtering conditions + const gridFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + const columnsFilteringTree = new FilteringExpressionsTree(FilteringLogic.And, 'ProductName'); + columnsFilteringTree.filteringOperands = [ + { fieldName: 'ProductName', searchVal: 'a', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' }, + { fieldName: 'ProductName', searchVal: 'e', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' }, + { fieldName: 'ProductName', searchVal: 'i', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' } + ]; + gridFilteringExpressionsTree.filteringOperands.push(columnsFilteringTree); + grid.filteringExpressionsTree = gridFilteringExpressionsTree; + fix.detectChanges(); + + // Verify chips count. + expect(GridFunctions.getFilterChipsForColumn('ProductName', fix).length).toBe(3, 'incorrect chips count'); + + // Verify last chip text. + let lastFilterCellChip = GridFunctions.getFilterChipsForColumn('ProductName', fix)[2]; + expect(GridFunctions.getChipText(lastFilterCellChip)).toBe('i'); + // Remove last chip. + // Remove active chip. + ControlsFunction.clickChipRemoveButton(lastFilterCellChip.nativeElement); + tick(DEBOUNCE_TIME); + fix.detectChanges(); + + // Verify chips count. + expect(GridFunctions.getFilterChipsForColumn('ProductName', fix).length).toBe(2, 'incorrect chips count'); + // Verify new last chip text. + lastFilterCellChip = GridFunctions.getFilterChipsForColumn('ProductName', fix)[1]; + expect(GridFunctions.getChipText(lastFilterCellChip)).toBe('e'); + + // Verify that 'clear' icon div of the new last chip is now active. + + const clearIconDiv = ControlsFunction.getChipRemoveButton(lastFilterCellChip.nativeElement); + expect(document.activeElement).toBe(clearIconDiv); + })); + + it('Should open filterRow when clicking \'more\' icon', fakeAsync(() => { + // Add initial filtering conditions + const gridFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + const columnsFilteringTree = new FilteringExpressionsTree(FilteringLogic.And, 'ProductName'); + columnsFilteringTree.filteringOperands = [ + { fieldName: 'ProductName', searchVal: 'a', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' }, + { fieldName: 'ProductName', searchVal: 'e', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' } + ]; + gridFilteringExpressionsTree.filteringOperands.push(columnsFilteringTree); + grid.filteringExpressionsTree = gridFilteringExpressionsTree; + fix.detectChanges(); + + // Click 'more' icon + const filterCell = GridFunctions.getFilterCell(fix, 'ProductName'); + const moreIcon: any = Array.from(filterCell.queryAll(By.css('igx-icon'))) + .find((ic: any) => ic.nativeElement.innerText === 'filter_list'); + moreIcon.nativeElement.click(); + tick(100); + fix.detectChanges(); + + // Verify filterRow is opened. + expect(fix.debugElement.query(By.css(FILTER_UI_ROW))).not.toBeNull(); + + // Verify first chip is selected (in edit mode). + const chipDiv = GridFunctions.getFilterConditionChip(fix, 0).querySelector('.igx-chip__item'); + expect(chipDiv.classList.contains('igx-chip__item--selected')).toBe(true, 'chip is not selected'); + })); + + it('Should not throw error when deleting the last chip', (async () => { + pending('This should be tested in the e2e test'); + grid.width = '700px'; + fix.detectChanges(); + await wait(100); + + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + + // Add first chip. + GridFunctions.typeValueInFilterRowInput('a', fix); + await wait(16); + GridFunctions.submitFilterRowInput(fix); + await wait(100); + // Add second chip. + GridFunctions.typeValueInFilterRowInput('e', fix); + await wait(16); + GridFunctions.submitFilterRowInput(fix); + await wait(100); + // Add third chip. + GridFunctions.typeValueInFilterRowInput('i', fix); + await wait(16); + GridFunctions.submitFilterRowInput(fix); + await wait(100); + // Add fourth chip. + GridFunctions.typeValueInFilterRowInput('o', fix); + await wait(16); + GridFunctions.submitFilterRowInput(fix); + await wait(200); + + verifyMultipleChipsVisibility(fix, [false, false, false, true]); + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const chips = filterUIRow.queryAll(By.directive(IgxChipComponent)); + expect(chips.length).toBe(4); + + const leftArrowButton = GridFunctions.getFilterRowLeftArrowButton(fix).nativeElement; + expect(leftArrowButton).toBeTruthy('Left scroll arrow should be visible'); + + const rightArrowButton = GridFunctions.getFilterRowRightArrowButton(fix).nativeElement; + expect(rightArrowButton).toBeTruthy('Right scroll arrow should be visible'); + expect(grid.rowList.length).toBe(2); + + let chipToRemove = filterUIRow.componentInstance.expressionsList[3]; + expect(() => { + filterUIRow.componentInstance.onChipRemoved(null, chipToRemove); + }) + .not.toThrowError(/'id' of undefined/); + fix.detectChanges(); + await wait(500); + fix.detectChanges(); + + chipToRemove = filterUIRow.componentInstance.expressionsList[2]; + expect(() => { + filterUIRow.componentInstance.onChipRemoved(null, chipToRemove); + }) + .not.toThrowError(/'id' of undefined/); + fix.detectChanges(); + await wait(100); + })); + + it('should scroll correct chip in view when one is deleted', async () => { + grid.width = '800px'; + fix.detectChanges(); + await wait(DEBOUNCE_TIME); + + // Add initial filtering conditions + const gridFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + const columnsFilteringTree = new FilteringExpressionsTree(FilteringLogic.And, 'ProductName'); + columnsFilteringTree.filteringOperands = [ + { fieldName: 'ProductName', searchVal: 'a', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' }, + { fieldName: 'ProductName', searchVal: 'e', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' }, + { fieldName: 'ProductName', searchVal: 'i', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' }, + { fieldName: 'ProductName', searchVal: 'n', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' } + ]; + gridFilteringExpressionsTree.filteringOperands.push(columnsFilteringTree); + grid.filteringExpressionsTree = gridFilteringExpressionsTree; + fix.detectChanges(); + + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + await wait(300); + + verifyMultipleChipsVisibility(fix, [true, true, false, false]); + + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + GridFunctions.removeFilterChipByIndex(1, filterUIRow); + // wait for chip to be scrolled in view + fix.detectChanges(); + await wait(300); + + verifyMultipleChipsVisibility(fix, [true, true, false]); + let chips = filterUIRow.queryAll(By.directive(IgxChipComponent)); + expect(chips.length).toBe(3); + + GridFunctions.removeFilterChipByIndex(0, filterUIRow); + // wait for chip to be scrolled in view + fix.detectChanges(); + await wait(300); + fix.detectChanges(); + + verifyMultipleChipsVisibility(fix, [true, true]); + chips = filterUIRow.queryAll(By.directive(IgxChipComponent)); + expect(chips.length).toBe(2); + }); + + it('Unary conditions should be committable', fakeAsync(() => { + grid.height = '700px'; + fix.detectChanges(); + + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + + // Check that the filterRow is opened + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + // Select Empty condition + GridFunctions.openFilterDDAndSelectCondition(fix, 6); + + const chip = filterUIRow.query(By.directive(IgxChipComponent)); + expect(chip.componentInstance.selected).toBeTruthy(); + grid.nativeElement.focus(); + grid.filteringRow.onInputGroupFocusout(); + fix.detectChanges(); + tick(100); + expect(chip.componentInstance.selected).toBeFalsy(); + + GridFunctions.clickChip(chip); + fix.detectChanges(); + tick(100); + expect(chip.componentInstance.selected).toBeTruthy(); + })); + + it('Should close filterRow when changing filterMode from \'quickFilter\' to \'excelStyleFilter\'', fakeAsync(() => { + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + + // Add a condition chip without submitting it. + GridFunctions.typeValueInFilterRowInput('a', fix); + tick(DEBOUNCE_TIME); + + // Change filterMode to 'excelStyleFilter` + grid.filterMode = FilterMode.excelStyleFilter; + fix.detectChanges(); + + // Verify the the filterRow is closed. + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + expect(filterUIRow).toBeNull('filterRow is visible'); + + // Verify the ESF icons are visible. + const thead = grid.theadRow.nativeElement; + const filterIcons = thead.querySelectorAll('.igx-excel-filter__icon'); + expect(filterIcons.length).toEqual(6, 'incorrect esf filter icons count'); + + // Verify the condition was submitted. + const header = GridFunctions.getColumnHeader('ProductName', fix); + const activeFilterIcon = header.nativeElement.querySelector('.igx-excel-filter__icon--filtered'); + expect(activeFilterIcon).toBeDefined('no active filter icon was found'); + })); + + it('Should clear non-unary conditions with null searchVal when close', fakeAsync(() => { + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + fix.detectChanges(); + + GridFunctions.openFilterDD(fix.debugElement); + const dropdownList = fix.debugElement.query(By.css('div.igx-drop-down__list.igx-toggle')); + GridFunctions.selectFilteringCondition('Empty', dropdownList); + fix.detectChanges(); + GridFunctions.openFilterDD(fix.debugElement); + GridFunctions.selectFilteringCondition('Contains', dropdownList); + fix.detectChanges(); + GridFunctions.closeFilterRow(fix); + + const headerChip = GridFunctions.getFilterChipsForColumn('ProductName', fix); + expect(headerChip.length).toBe(1); + })); + + it('Should commit the input and new chip after focus out and should edit chip without creating new one.', fakeAsync(() => { + // Click date filter chip to show filter row. + const dateFilterCellChip = GridFunctions.getFilterChipsForColumn('ReleaseDate', fix)[0]; + dateFilterCellChip.nativeElement.click(); + tick(100); + fix.detectChanges(); + + // Click input to open calendar. + const filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + const inputDebugElement = filteringRow.query(By.directive(IgxInputDirective)); + const input = inputDebugElement.nativeElement; + input.click(); + tick(100); + fix.detectChanges(); + + // Click the today date. + const outlet = document.getElementsByClassName('igx-grid__outlet')[0]; + let calendar = outlet.getElementsByClassName('igx-calendar')[0]; + const todayDayItem: HTMLElement = calendar.querySelector('.igx-days-view__date--current'); + UIInteractions.simulateClickAndSelectEvent(todayDayItem.firstChild); + grid.filteringRow.onInputGroupFocusout(); + tick(100); + fix.detectChanges(); + + // Verify the newly added chip is selected. + const chip = GridFunctions.getFilterConditionChip(fix, 0); + const chipDiv = chip.querySelector('.igx-chip__item'); + + expect(chipDiv.classList.contains('igx-chip__item--selected')).toBe(true, 'initial chip is committed'); + + // Focus out + grid.nativeElement.focus(); + grid.filteringRow.onInputGroupFocusout(); + tick(200); + fix.detectChanges(); + + expect(chipDiv.classList.contains('igx-chip__item--selected')).toBe(false, 'initial chip is not committed'); + expect(input.value).toBe('', 'initial input value is present and not committed'); + + chip.click(); + tick(200); + fix.detectChanges(); + + // Open calendar + input.click(); + tick(100); + fix.detectChanges(); + + calendar = outlet.getElementsByClassName('igx-calendar')[0]; + + // View years + const yearView: HTMLElement = calendar.querySelectorAll('.igx-calendar-picker__date')[1] as HTMLElement; + yearView.dispatchEvent(new Event('mousedown')); + tick(100); + fix.detectChanges(); + + // Select the first year + const firstYear: HTMLElement = calendar.querySelectorAll('.igx-calendar-view__item')[0] as HTMLElement; + firstYear.dispatchEvent(new Event('mousedown')); + tick(100); + fix.detectChanges(); + + // Select the first month + const firstMonth: HTMLElement = calendar.querySelectorAll('.igx-calendar-view__item')[0] as HTMLElement; + firstMonth.dispatchEvent(new Event('mousedown')); + tick(100); + fix.detectChanges(); + + // Select the first day + const firstDayItem: HTMLElement = calendar.querySelector('.igx-days-view__date:not(.igx-days-view__date--inactive)'); + + UIInteractions.simulateClickAndSelectEvent(firstDayItem.firstChild); + grid.filteringRow.onInputGroupFocusout(); + tick(200); + fix.detectChanges(); + + expect(chipDiv.classList.contains('igx-chip__item--selected')).toBe(true, 'chip is committed'); + + // Focus out + grid.nativeElement.focus(); + grid.filteringRow.onInputGroupFocusout(); + tick(200); + fix.detectChanges(); + expect(chipDiv.classList.contains('igx-chip__item--selected')).toBe(false, 'chip is selected'); + + // Check if we still have only one committed chip + const chipsLength = GridFunctions.getAllFilterConditionChips(fix).length; + + expect(chipsLength).toBe(1, 'there is more than one chip'); + expect(chipDiv.classList.contains('igx-chip__item--selected')).toBe(false, 'chip is not committed'); + expect(input.value).toBe('', 'input value is present and not committed'); + })); + + it('should not retain expression values in cell filter after calling grid clearFilter() method.', fakeAsync(() => { + // Click on 'ProductName' filter chip + GridFunctions.clickFilterCellChipUI(fix, 'ProductName'); + fix.detectChanges(); + + // Enter expression + GridFunctions.typeValueInFilterRowInput('NetAdvantage', fix); + tick(DEBOUNCE_TIME); + GridFunctions.closeFilterRow(fix); + fix.detectChanges(); + + // Verify filtered data + expect(grid.filteredData.length).toEqual(1); + + // Clear filters of all columns + grid.clearFilter(); + fix.detectChanges(); + + // Verify filtered data + expect(grid.filteredData).toBeNull(); + + // Click on 'ProductName' filter chip + GridFunctions.clickFilterCellChipUI(fix, 'ProductName'); + fix.detectChanges(); + + // Verify there are no chips since we cleared all filters + const filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + const conditionChips = filteringRow.queryAll(By.directive(IgxChipComponent)); + expect(conditionChips.length).toBe(0); + + // Verify filtered data + expect(grid.filteredData).toBeNull(); + })); + + it('should not reset expression when input is cleared', fakeAsync(() => { + grid.filter('ProductName', 'I', IgxStringFilteringOperand.instance().condition('startsWith')); + fix.detectChanges(); + + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + + grid.filteringRow.onClearClick(); + tick(100); + fix.detectChanges(); + + expect(grid.filteringRow.expression.condition.name).toEqual('startsWith'); + })); + + it('should reset expression when the condition is unary', fakeAsync(() => { + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + + // iterate over unary conditions + // empty + GridFunctions.openFilterDDAndSelectCondition(fix, 6); + expect(grid.filteringRow.expression.condition.name).toEqual('empty'); + + const filterUIRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + const reset = filterUIRow.queryAll(By.css('button'))[0]; + + reset.triggerEventHandler('click', null); + tick(100); + fix.detectChanges(); + + expect(grid.filteringRow.expression.condition.name).toEqual('contains'); + })); + + it('should reset expression to selected unary condition', fakeAsync(() => { + GridFunctions.clickFilterCellChip(fix, 'ReleaseDate'); + + const filterUIRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + const datePicker = filterUIRow.query(By.directive(IgxDatePickerComponent)); + + // Equals condition + datePicker.triggerEventHandler('click', null); + tick(); + fix.detectChanges(); + + const currentDay = document.querySelector('.igx-days-view__date--current'); + + UIInteractions.simulateClickAndSelectEvent(currentDay.firstChild); + tick(); + fix.detectChanges(); + + expect(grid.filteringRow.expression.condition.name).toEqual('equals'); + expect(grid.rowList.length).toEqual(1); + + // This Month condition + const expectedResults = GridFunctions.createDateFilterConditions(grid, today); + GridFunctions.openFilterDDAndSelectCondition(fix, 6); + tick(); + fix.detectChanges(); + + expect(grid.filteringRow.expression.condition.name).toEqual('thisMonth'); + expect(grid.rowList.length).toEqual(expectedResults[5]); + + const conditionChips = filterUIRow.queryAll(By.directive(IgxChipComponent)); + expect(conditionChips.length).toBe(1); + })); + + it('Should filter by cells formatted data when using FormattedValuesFilteringStrategy', fakeAsync(() => { + const formattedStrategy = new FormattedValuesFilteringStrategy(['Downloads']); + grid.filterStrategy = formattedStrategy; + const downloadsFormatter = (val: number): number => { + if (!val || val > 0 && val < 100) { + return 1; + } else if (val >= 100 && val < 500) { + return 2; + } else { + return 3; + } + }; + grid.columnList.get(2).formatter = downloadsFormatter; + fix.detectChanges(); + + GridFunctions.clickFilterCellChipUI(fix, 'Downloads'); + fix.detectChanges(); + + GridFunctions.typeValueInFilterRowInput('3', fix); + tick(DEBOUNCE_TIME); + GridFunctions.closeFilterRow(fix); + fix.detectChanges(); + + // const cells = GridFunctions.getColumnCells(fix, 'Downloads'); + const rows = GridFunctions.getRows(fix); + expect(rows.length).toEqual(2); + })); + + it('Should filter by cells formatted data when using FormattedValuesFilteringStrategy with rowData', fakeAsync(() => { + const formattedStrategy = new FormattedValuesFilteringStrategy(['ProductName']); + grid.filterStrategy = formattedStrategy; + const anotherFieldFormatter = (value: any, rowData: any) => rowData.ID + ':' + value; + grid.columnList.get(1).formatter = anotherFieldFormatter; + fix.detectChanges(); + + GridFunctions.clickFilterCellChipUI(fix, 'ProductName'); + fix.detectChanges(); + + GridFunctions.typeValueInFilterRowInput('1:', fix); + tick(DEBOUNCE_TIME); + GridFunctions.closeFilterRow(fix); + fix.detectChanges(); + + const rows = GridFunctions.getRows(fix); + expect(rows.length).toEqual(1); + })); + + it('Should remove pending chip via its close button #9333', fakeAsync(() => { + GridFunctions.clickFilterCellChipUI(fix, 'Downloads'); + fix.detectChanges(); + + GridFunctions.typeValueInFilterRowInput('3', fix); + tick(DEBOUNCE_TIME); + const inputGroup = fix.debugElement.query(By.directive(IgxInputGroupComponent)); + const filterRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + const pendingChip = filterRow.queryAll(By.directive(IgxChipComponent))[0]; + const chipCloseButton = pendingChip.query(By.css('div.igx-chip__remove')).nativeElement; + + chipCloseButton.dispatchEvent(new Event('mousedown')); + inputGroup.nativeElement.dispatchEvent(new Event('focusout')); + chipCloseButton.dispatchEvent(new Event('mouseup')); + chipCloseButton.dispatchEvent(new Event('click')); + tick(DEBOUNCE_TIME); + fix.detectChanges(); + + const chips = filterRow.queryAll(By.directive(IgxChipComponent)); + expect(chips.length).toEqual(0, 'No chips should be present'); + })); + + it('Should not throw error when pressing Backspace in empty dateTime filter.', fakeAsync(() => { + spyOn(console, 'error'); + + GridFunctions.clickFilterCellChipUI(fix, 'ReleaseDateTime'); + tick(DEBOUNCE_TIME); + fix.detectChanges(); + + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const input = filterUIRow.query(By.directive(IgxInputDirective)); + GridFunctions.typeValueInFilterRowInput('', fix, input); + tick(DEBOUNCE_TIME); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('Backspace', input); + fix.detectChanges(); + + expect(console.error).not.toHaveBeenCalled(); + })); + + it('Should not throw error when pressing Arrow keys in filter when focus is outside of input.', fakeAsync(() => { + spyOn(console, 'error'); + + GridFunctions.clickFilterCellChipUI(fix, 'ProductName'); + fix.detectChanges(); + + const prefix = GridFunctions.getFilterRowPrefix(fix).nativeElement; + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', prefix); + fix.detectChanges(); + + expect(console.error).not.toHaveBeenCalled(); + })); + }); + + describe('Integration scenarios', () => { + let fix: ComponentFixture; + let grid: IgxGridComponent; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + providers: [{ provide: INPUT_DEBOUNCE_TIME, useValue: 0 }] + }); + fix = TestBed.createComponent(IgxGridFilteringComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + })); + + // Filtering + Row Selectors + it('should display the Row Selector header checkbox above the filter row.', fakeAsync(() => { + grid.rowSelection = GridSelectionMode.multiple; + fix.detectChanges(); + + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + + const filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + const frElem = filteringRow.nativeElement; + const chkBoxElem = GridSelectionFunctions.getRowCheckboxInput(GridSelectionFunctions.getHeaderRow(fix)); + expect(frElem.offsetTop).toBeGreaterThanOrEqual(chkBoxElem.offsetTop + chkBoxElem.clientHeight); + })); + + // Filtering + Moving + it('should move chip under the correct column when column is moved and filter row should open for correct column.', + fakeAsync(() => { + grid.filter('ProductName', 'Angular', IgxStringFilteringOperand.instance().condition('contains')); + fix.detectChanges(); + + // swap columns + const stringCol = grid.getColumnByName('ProductName'); + const numberCol = grid.getColumnByName('Downloads'); + grid.moveColumn(stringCol, numberCol); + tick(); + fix.detectChanges(); + + // check UI in filter cell is correct after moving + const filteringCells = GridFunctions.getFilteringCells(fix); + + expect(GridFunctions.getChipText(filteringCells[2])).toEqual('Angular'); + expect(GridFunctions.getChipText(filteringCells[1])).toEqual('Filter'); + })); + + // Filtering + Hiding + it('should not display filter cell for hidden columns and chips should show under correct column.', fakeAsync(() => { + grid.filter('ProductName', 'Angular', IgxStringFilteringOperand.instance().condition('contains')); + fix.detectChanges(); + + let filteringCells = GridFunctions.getFilteringCells(fix); + expect(filteringCells.length).toEqual(8); + + // hide column + grid.getColumnByName('ID').hidden = true; + fix.detectChanges(); + + filteringCells = GridFunctions.getFilteringCells(fix); + expect(filteringCells.length).toEqual(7); + expect(GridFunctions.getChipText(filteringCells[0])).toEqual('Angular'); + + grid.getColumnByName('ProductName').hidden = true; + fix.detectChanges(); + + filteringCells = GridFunctions.getFilteringCells(fix); + expect(filteringCells.length).toEqual(6); + + for (const filterCell of filteringCells) { + expect(GridFunctions.getChipText(filterCell)).toEqual('Filter'); + } + })); + + it('Should close filter row when hide the current column', fakeAsync(() => { + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + + // Check that the filterRow is opened + let filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + expect(filterUIRow).not.toBeNull(); + + // Add first chip. + GridFunctions.typeValueInFilterRowInput('a', fix); + tick(); + fix.detectChanges(); + + grid.getColumnByName('ProductName').hidden = true; + tick(); + fix.detectChanges(); + + // Check that the filterRow is closed + filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + expect(filterUIRow).toBeNull(); + expect(grid.rowList.length).toBe(3, 'filter is not applied'); + })); + + it('Should keep existing column filter after hiding another column.', fakeAsync(() => { + // Add initial filtering conditions + const gridFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + const columnsFilteringTree = new FilteringExpressionsTree(FilteringLogic.And, 'ProductName'); + columnsFilteringTree.filteringOperands = [ + { fieldName: 'ProductName', searchVal: 'x', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' }, + { fieldName: 'ProductName', searchVal: 'y', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' }, + { fieldName: 'ProductName', searchVal: 'i', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' }, + { fieldName: 'ProductName', searchVal: 'g', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' }, + { fieldName: 'ProductName', searchVal: 'n', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' } + ]; + gridFilteringExpressionsTree.filteringOperands.push(columnsFilteringTree); + grid.filteringExpressionsTree = gridFilteringExpressionsTree; + fix.detectChanges(); + + // Open filter row for 'ProductName' column and add 4 condition chips. + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + tick(200); + fix.detectChanges(); + + // Change second operator to 'Or' and verify the results. + GridFunctions.clickChipOperator(fix, 1); + fix.detectChanges(); + GridFunctions.clickChipOperatorValue(fix, 'Or'); + tick(100); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(2); + expect(GridFunctions.getCurrentCellFromGrid(grid, 0, 1).value).toBe('Ignite UI for JavaScript'); + expect(GridFunctions.getCurrentCellFromGrid(grid, 1, 1).value).toBe('Ignite UI for Angular'); + + // Hide another column and verify the filtering results remain the same. + const column = grid.columnList.find((c) => c.field === 'Released'); + column.hidden = true; + fix.detectChanges(); + expect(grid.rowList.length).toEqual(2); + expect(GridFunctions.getCurrentCellFromGrid(grid, 0, 1).value).toBe('Ignite UI for JavaScript'); + expect(GridFunctions.getCurrentCellFromGrid(grid, 1, 1).value).toBe('Ignite UI for Angular'); + })); + + // Filtering + Grouping + it('should display the header expand/collapse icon for groupby above the filter row.', fakeAsync(() => { + grid.getColumnByName('ProductName').groupable = true; + grid.groupBy({ + fieldName: 'ProductName', + dir: SortingDirection.Asc, + ignoreCase: false, + strategy: DefaultSortingStrategy.instance() + }); + fix.detectChanges(); + + GridFunctions.clickFilterCellChip(fix, 'ProductName'); + + const filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + const frElem = filteringRow.nativeElement; + const expandBtn = fix.debugElement.query(By.css('.igx-grid__group-expand-btn')); + const expandBtnElem = expandBtn.nativeElement; + expect(frElem.offsetTop).toBeGreaterThanOrEqual(expandBtnElem.offsetTop + expandBtnElem.clientHeight); + })); + + // Filtering + Pinning + it('should position chips correctly after pinning column.', () => { + grid.filter('ProductName', 'Angular', IgxStringFilteringOperand.instance().condition('contains')); + fix.detectChanges(); + + grid.getColumnByName('ProductName').pinned = true; + fix.detectChanges(); + + // check chips is under correct column + const filteringCells = fix.debugElement.queryAll(By.css(FILTER_UI_CELL)); + const stringCellChip = filteringCells[0].query(By.css('igx-chip')); + expect(GridFunctions.getChipText(stringCellChip)).toEqual('Angular'); + }); + + it('Should display view more indicator when column is resized so not all filters are visible.', fakeAsync(() => { + grid.columnList.get(1).width = '250px'; + fix.detectChanges(); + + // Add initial filtering conditions + const gridFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + const columnsFilteringTree = new FilteringExpressionsTree(FilteringLogic.And, 'ProductName'); + columnsFilteringTree.filteringOperands = [ + { fieldName: 'ProductName', searchVal: 'a', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' }, + { fieldName: 'ProductName', searchVal: 'o', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' } + ]; + gridFilteringExpressionsTree.filteringOperands.push(columnsFilteringTree); + grid.filteringExpressionsTree = gridFilteringExpressionsTree; + fix.detectChanges(); + + let colChips = GridFunctions.getFilterChipsForColumn('ProductName', fix); + let colOperands = GridFunctions.getFilterOperandsForColumn('ProductName', fix); + let colIndicator = GridFunctions.getFilterIndicatorForColumn('ProductName', fix); + + expect(colChips.length).toEqual(2); + expect(colOperands.length).toEqual(1); + expect(colIndicator.length).toEqual(0); + + // Enable resizing + fix.componentInstance.resizable = true; + fix.detectChanges(); + grid.cdr.detectChanges(); + + // Make 'ProductName' column smaller + + const headers: DebugElement[] = fix.debugElement.queryAll(By.directive(IgxGridHeaderGroupComponent)); + const headerResArea = headers[1].children[2].nativeElement; + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 200, 0); + tick(200); + const resizer = fix.debugElement.queryAll(By.css(GRID_RESIZE_CLASS))[0].nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 100, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 100, 5); + fix.detectChanges(); + + colChips = GridFunctions.getFilterChipsForColumn('ProductName', fix); + colOperands = GridFunctions.getFilterOperandsForColumn('ProductName', fix); + colIndicator = GridFunctions.getFilterIndicatorForColumn('ProductName', fix); + + expect(colChips.length).toEqual(1); + expect(GridFunctions.getChipText(colChips[0])).toEqual('a'); + expect(colOperands.length).toEqual(0); + expect(colIndicator.length).toEqual(1); + + const indicatorBadge = colIndicator[0].query(By.directive(IgxBadgeComponent)); + expect(indicatorBadge).toBeTruthy(); + expect(indicatorBadge.nativeElement.innerText.trim()).toEqual('1'); + })); + + it('Should correctly resize the current column that filtering the row is rendered for.', fakeAsync(() => { + grid.columnList.get(1).width = '250px'; + fix.detectChanges(); + + // Enable resizing + grid.columnList.forEach(col => col.resizable = true); + fix.detectChanges(); + + const initialChips = fix.debugElement.queryAll(By.directive(IgxChipComponent)); + const stringCellChip = initialChips[0].nativeElement; + stringCellChip.click(); + fix.detectChanges(); + + const headers: DebugElement[] = fix.debugElement.queryAll(By.directive(IgxGridHeaderGroupComponent)); + const headerResArea = headers[1].children[2].nativeElement; + let filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + + expect(filteringRow).toBeTruthy(); + expect(headers[1].nativeElement.offsetWidth).toEqual(250); + + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 200, 0); + tick(200); + const resizer = fix.debugElement.queryAll(By.css(GRID_RESIZE_CLASS))[0].nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 100, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 100, 5); + fix.detectChanges(); + + filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + expect(filteringRow).toBeTruthy(); + expect(headers[1].nativeElement.offsetWidth).toEqual(150); + })); + + // Filtering + Resizing + it('Should correctly render all filtering chips when column is resized so all filter are visible.', fakeAsync(() => { + grid.columnList.get(2).width = '100px'; + fix.detectChanges(); + + // Add initial filtering conditions + const gridFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + const columnsFilteringTree = new FilteringExpressionsTree(FilteringLogic.And, 'Downloads'); + columnsFilteringTree.filteringOperands = [ + { fieldName: 'Downloads', searchVal: 25, condition: IgxNumberFilteringOperand.instance().condition('greaterThan'), conditionName: 'greaterThan' }, + { fieldName: 'Downloads', searchVal: 200, condition: IgxNumberFilteringOperand.instance().condition('lessThan'), conditionName: 'lessThan' } + ]; + gridFilteringExpressionsTree.filteringOperands.push(columnsFilteringTree); + grid.filteringExpressionsTree = gridFilteringExpressionsTree; + fix.detectChanges(); + + // Enable resizing + grid.columnList.forEach(col => col.resizable = true); + fix.detectChanges(); + grid.cdr.detectChanges(); + + let colChips = GridFunctions.getFilterChipsForColumn('Downloads', fix); + let colOperands = GridFunctions.getFilterOperandsForColumn('Downloads', fix); + let colIndicator = GridFunctions.getFilterIndicatorForColumn('Downloads', fix); + + expect(colChips.length).toEqual(0); + expect(colOperands.length).toEqual(0); + expect(colIndicator.length).toEqual(1); + + const indicatorBadge = colIndicator[0].query(By.directive(IgxBadgeComponent)); + expect(indicatorBadge).toBeTruthy(); + expect(indicatorBadge.nativeElement.innerText.trim()).toEqual('2'); + + // Make 'Downloads' column bigger + const headers: DebugElement[] = fix.debugElement.queryAll(By.directive(IgxGridHeaderGroupComponent)); + const headerResArea = headers[2].children[2].nativeElement; + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 100, 0); + tick(200); + const resizer = fix.debugElement.queryAll(By.css(GRID_RESIZE_CLASS))[0].nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 300, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 300, 5); + fix.detectChanges(); + + colChips = GridFunctions.getFilterChipsForColumn('Downloads', fix); + colOperands = GridFunctions.getFilterOperandsForColumn('Downloads', fix); + colIndicator = GridFunctions.getFilterIndicatorForColumn('Downloads', fix); + + expect(colChips.length).toEqual(2); + expect(colOperands.length).toEqual(1); + expect(colOperands[0].nativeElement.innerText).toEqual('AND'); + expect(colIndicator.length).toEqual(0); + })); + + it('UI focusing grid\'s body content does not throw a console error after filtering. (issue 8930)', fakeAsync(() => { + spyOn(console, 'error'); + GridFunctions.clickFilterCellChipUI(fix, 'ProductName'); + fix.detectChanges(); + + GridFunctions.applyFilter('Jav', fix); + tick(DEBOUNCE_TIME); + fix.detectChanges(); + + GridFunctions.applyFilter('xy', fix); + tick(DEBOUNCE_TIME); + fix.detectChanges(); + + const tBodyContent = GridFunctions.getGridContent(fix); + tBodyContent.triggerEventHandler('focus', null); + + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + GridFunctions.removeFilterChipByIndex(1, filterUIRow); + tick(); + fix.detectChanges(); + + tBodyContent.triggerEventHandler('focus', null); + tick(); + expect(console.error).not.toHaveBeenCalled(); + })); + }); + + describe(null, () => { + let fix: ComponentFixture; + let grid: IgxGridComponent; + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(IgxGridFilteringMCHComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + })); + + // Filtering + Column Groups + it('should size correctly the header based on grid size.', async () => { + setElementSize(grid.nativeElement, ɵSize.Large); + fix.detectChanges(); + + const thead = GridFunctions.getGridHeader(grid).nativeElement; + expect(thead.getBoundingClientRect().height).toEqual(grid.defaultRowHeight * 4 + 1); + + setElementSize(grid.nativeElement, ɵSize.Medium); + fix.detectChanges(); + await wait(100); // needed because the resize observer handler for --ig-size is called inside an angular zone + fix.detectChanges(); + expect(thead.getBoundingClientRect().height).toEqual(grid.defaultRowHeight * 4 + 1); + + setElementSize(grid.nativeElement, ɵSize.Small); + fix.detectChanges(); + await wait(100); // needed because the resize observer handler for --ig-size is called inside an angular zone + fix.detectChanges(); + expect(thead.getBoundingClientRect().height).toEqual(grid.defaultRowHeight * 4 + 1); + + }); + + it('should position filter row correctly when grid has column groups.', fakeAsync(() => { + const thead = GridFunctions.getGridHeader(grid).nativeElement; + + const filteringCells = GridFunctions.getFilteringCells(fix); + const cellElem = filteringCells[0].nativeElement; + expect(cellElem.offsetParent.offsetHeight + cellElem.offsetHeight + 1).toBeCloseTo(thead.clientHeight, 10); + + GridFunctions.clickFilterCellChip(fix, 'ID'); + + // check if it is positioned at the bottom of the thead. + const filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + const frElem = filteringRow.nativeElement; + expect(frElem.offsetTop + frElem.clientHeight + 1).toEqual(thead.clientHeight); + })); + + it('should position filter row and chips correctly when grid has column groups and one is hidden.', + fakeAsync(() => { + const filteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And, 'ProductName'); + const expression = { + fieldName: 'ProductName', + searchVal: 'Ignite', + condition: IgxStringFilteringOperand.instance().condition('startsWith'), + conditionName: 'startsWith' + }; + filteringExpressionsTree.filteringOperands.push(expression); + grid.filteringExpressionsTree = filteringExpressionsTree; + fix.detectChanges(); + let filteringCells = GridFunctions.getFilteringCells(fix); + expect(filteringCells.length).toEqual(6); + + const groupCol = grid.getColumnByName('General'); + groupCol.hidden = true; + fix.detectChanges(); + + filteringCells = GridFunctions.getFilteringCells(fix); + expect(filteringCells.length).toEqual(1); + + GridFunctions.clickFilterCellChip(fix, 'AnotherField'); + fix.detectChanges(); + grid.cdr.detectChanges(); + + // check if it is positioned at the bottom of the thead. + const theadWrapper = grid.theadRow.nativeElement.firstElementChild; + const filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + const frElem = filteringRow.nativeElement; + expect(frElem.offsetTop + frElem.clientHeight).toEqual(theadWrapper.clientHeight); + + GridFunctions.closeFilterRow(fix); + + groupCol.hidden = false; + fix.detectChanges(); + + filteringCells = GridFunctions.getFilteringCells(fix); + expect(filteringCells.length).toEqual(6); + + expect(GridFunctions.getChipText(filteringCells[1])).toEqual('Ignite'); + })); + + it('Should size grid correctly if enable/disable filtering in run time - MCH.', fakeAsync(() => { + const head = grid.theadRow.nativeElement; + const body = grid.nativeElement.querySelector('.igx-grid__tbody'); + + expect(head.getBoundingClientRect().bottom).toEqual(body.getBoundingClientRect().top); + + grid.allowFiltering = false; + fix.detectChanges(); + + expect(head.getBoundingClientRect().bottom).toEqual(body.getBoundingClientRect().top); + + grid.allowFiltering = false; + fix.detectChanges(); + + expect(head.getBoundingClientRect().bottom).toEqual(body.getBoundingClientRect().top); + })); + }); + + describe(null, () => { + let fix; let grid; + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(IgxGridFilteringScrollComponent); + grid = fix.componentInstance.grid; + fix.detectChanges(); + })); + + it('Should correctly update empty filter cells when scrolling horizontally.', async () => { + let emptyFilterCells = fix.debugElement.queryAll(By.directive(IgxGridFilteringCellComponent)) + .filter((cell) => cell.nativeElement.children.length === 0); + expect(emptyFilterCells.length).toEqual(1); + + let emptyFilterHeader = emptyFilterCells[0].parent.query(By.directive(IgxGridHeaderComponent)); + expect(emptyFilterHeader.componentInstance.column.field).toEqual('Downloads'); + + // Scroll to the right + grid.headerContainer.getScroll().scrollLeft = 300; + await wait(); + fix.detectChanges(); + + emptyFilterCells = fix.debugElement.queryAll(By.directive(IgxGridFilteringCellComponent)) + .filter((cell) => cell.nativeElement.children.length === 0); + expect(emptyFilterCells.length).toEqual(1); + + emptyFilterHeader = emptyFilterCells[0].parent.query(By.directive(IgxGridHeaderComponent)); + expect(emptyFilterHeader.componentInstance.column.field).toEqual('Downloads'); + }); + }); + + describe(null, () => { + let fix; + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(IgxGridFilteringTemplateComponent); + fix.detectChanges(); + })); + + it('Should render custom filter template instead of default one.', fakeAsync(() => { + // Verify default filter template is not present. + expect(GridFunctions.getFilterCell(fix, 'ProductName').query(By.css('.igx-filtering-chips'))).toBeNull( + '\'ProductName\' default filter chips area template was found.'); + expect(GridFunctions.getFilterCell(fix, 'Downloads').query(By.css('.igx-filtering-chips'))).toBeNull( + '\'Downloads\' default filter chips area template was found.'); + expect(GridFunctions.getFilterCell(fix, 'Released').query(By.css('.igx-filtering-chips'))).toBeNull( + '\'Released\' default filter chips area template was found.'); + expect(GridFunctions.getFilterCell(fix, 'ReleaseDate').query(By.css('.igx-filtering-chips'))).toBeNull( + '\'ReleaseDate\' default filter chips area template was found.'); + + // Verify the custom filter template is present. + expect(GridFunctions.getFilterCell(fix, 'ProductName').query(By.css('.custom-filter'))).not.toBeNull( + '\'ProductName\' customer filter template was not found.'); + expect(GridFunctions.getFilterCell(fix, 'Downloads').query(By.css('.custom-filter'))).not.toBeNull( + '\'Downloads\' customer filter template was not found.'); + expect(GridFunctions.getFilterCell(fix, 'Released').query(By.css('.custom-filter'))).not.toBeNull( + '\'Released\' customer filter template was not found.'); + expect(GridFunctions.getFilterCell(fix, 'ReleaseDate').query(By.css('.custom-filter'))).not.toBeNull( + '\'ReleaseDate\' customer filter template was not found.'); + })); + + it('Should close default filter template when clicking on a column with custom one.', fakeAsync(() => { + // Click on a column with default filter + GridFunctions.clickFilterCellChip(fix, 'Licensed'); + fix.detectChanges(); + + // Verify filter row is visible + let filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + expect(filterUIRow).not.toBeNull(); + + // Click on a column with custom filter + const header = GridFunctions.getColumnHeaderTitleByIndex(fix, 1); + header.click(); + fix.detectChanges(); + + // Expect the filter row is closed + filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + expect(filterUIRow).toBeNull('Default filter template was found on a column with custom filtering.'); + })); + + it('Should not prevent mousedown event when target is within the filter cell template', fakeAsync(() => { + const filterCell = GridFunctions.getFilterCell(fix, 'ProductName'); + const input = filterCell.query(By.css('input')).nativeElement; + + const mousedownEvent = new MouseEvent('mousedown', { bubbles: true }); + const preventDefaultSpy = spyOn(mousedownEvent, 'preventDefault'); + input.dispatchEvent(mousedownEvent, { bubbles: true }); + fix.detectChanges(); + + expect(preventDefaultSpy).not.toHaveBeenCalled(); + })); + + it('Should prevent mousedown event when target is filter cell or its parent elements', fakeAsync(() => { + const filteringCells = fix.debugElement.queryAll(By.css(FILTER_UI_CELL)); + const firstCell = filteringCells[0].nativeElement; + + const mousedownEvent = new MouseEvent('mousedown', { bubbles: true }); + const preventDefaultSpy = spyOn(mousedownEvent, 'preventDefault'); + firstCell.dispatchEvent(mousedownEvent); + fix.detectChanges(); + + expect(preventDefaultSpy).toHaveBeenCalled(); + })); + }); + + describe(null, () => { + let fix: ComponentFixture; + let grid: IgxGridComponent; + const today = SampleTestData.today; + + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(IgxGridDatesFilteringComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + })); + + // UI tests date column // date values are ISO 8601 strings + it('UI - should correctly filter ISO 8601 date column', fakeAsync(() => { + GridFunctions.clickFilterCellChip(fix, 'ReleaseDate'); + + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const reset = filterUIRow.queryAll(By.css('button'))[0]; + const close = filterUIRow.queryAll(By.css('button'))[1]; + const input = filterUIRow.query(By.directive(IgxInputDirective)); + const expectedResults = GridFunctions.createDateFilterConditions(grid, today); + + // Today condition + GridFunctions.openFilterDDAndSelectCondition(fix, 4); + + expect(grid.rowList.length).toEqual(1); + verifyFilterRowUI(input, close, reset, false); + verifyFilterUIPosition(filterUIRow, grid); + + expect(grid.rowList.length).toEqual(1); + + // This Month condition + GridFunctions.openFilterDDAndSelectCondition(fix, 6); + verifyFilterRowUI(input, close, reset, false); + expect(grid.rowList.length).toEqual(expectedResults[5]); + + // Last Month condition + GridFunctions.openFilterDDAndSelectCondition(fix, 7); + verifyFilterRowUI(input, close, reset, false); + expect(grid.rowList.length).toEqual(expectedResults[0]); + + // Empty condition + GridFunctions.openFilterDDAndSelectCondition(fix, 12); + verifyFilterRowUI(input, close, reset, false); + expect(grid.rowList.length).toEqual(2); + })); + + it('UI - should correctly filter ISO 8601 date column by \'equals\' filtering conditions', fakeAsync(() => { + GridFunctions.clickFilterCellChip(fix, 'ReleaseDate'); + + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const input = filterUIRow.query(By.css('.igx-date-picker__input-date')); + const datePicker = filterUIRow.query(By.directive(IgxDatePickerComponent)); + + GridFunctions.openFilterDDAndSelectCondition(fix, 0); + + datePicker.triggerEventHandler('click', null); + tick(); + fix.detectChanges(); + + const outlet = document.getElementsByClassName('igx-grid__outlet')[0]; + const calendar = outlet.getElementsByClassName('igx-calendar')[0]; + + const currentDay = calendar.querySelector('.igx-days-view__date--current'); + + UIInteractions.simulateClickAndSelectEvent(currentDay.firstChild); + + flush(); + fix.detectChanges(); + input.triggerEventHandler('change', null); + tick(); + fix.detectChanges(); + + expect(grid.rowList.length).toEqual(1); + })); + + }); + + describe('Filtering events', () => { + let fix: ComponentFixture; + let grid: IgxGridComponent; + + const cal = SampleTestData.timeGenerator; + const today = SampleTestData.today; + + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(IgxGridDatesFilteringComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + + spyOn(grid.filtering, 'emit'); + spyOn(grid.filteringDone, 'emit'); + })); + + it('Should emit filteringDone when we clicked reset - Date column type', fakeAsync(() => { + emitFilteringDoneOnResetClick( + fix, + grid, + cal.timedelta(today, 'day', 15), + 'ReleaseDate', + IgxDateFilteringOperand.instance().condition('equals') + ); + })); + + it('Should emit filteringDone when we clicked reset - String column type', fakeAsync(() => { + emitFilteringDoneOnResetClick( + fix, + grid, + 'search', + 'ProductName', + IgxStringFilteringOperand.instance().condition('contains') + ); + })); + + it('Should emit filteringDone when we clicked reset - Number column type', fakeAsync(() => { + emitFilteringDoneOnResetClick( + fix, + grid, + 100, + 'Downloads', + IgxNumberFilteringOperand.instance().condition('equals') + ); + })); + + it('Should emit filteringDone when we clicked reset - Currency column type', fakeAsync(() => { + emitFilteringDoneOnResetClick( + fix, + grid, + 100000, + 'Revenue', + IgxNumberFilteringOperand.instance().condition('equals') + ); + })); + + it('Should emit filteringDone when we clicked reset - DateTime column type', fakeAsync(() => { + emitFilteringDoneOnResetClick( + fix, + grid, + cal.timedelta(today, 'hour', 1), + 'ReleaseTime', + IgxDateTimeFilteringOperand.instance().condition('equals') + ); + })); + + it('Should emit filteringDone when clear the input of filteringUI - Date column type', fakeAsync(() => { + emitFilteringDoneOnInputClear( + fix, + grid, + cal.timedelta(today, 'day', 15), + 'ReleaseDate', + IgxDateFilteringOperand.instance().condition('equals') + ); + })); + + it('Should emit filteringDone when clear the input of filteringUI - String column type', fakeAsync(() => { + emitFilteringDoneOnInputClear( + fix, + grid, + 'search', + 'ProductName', + IgxStringFilteringOperand.instance().condition('contains') + ); + })); + + it('Should emit filteringDone when clear the input of filteringUI - Number column type', fakeAsync(() => { + emitFilteringDoneOnInputClear( + fix, + grid, + 3, + 'Downloads', + IgxNumberFilteringOperand.instance().condition('equals') + ); + })); + + it('Should emit filteringDone when clear the input of filteringUI - Currency column type', fakeAsync(() => { + emitFilteringDoneOnInputClear( + fix, + grid, + 100000, + 'Revenue', + IgxNumberFilteringOperand.instance().condition('equals') + ); + })); + + it('Should emit filteringDone when clear the input of filteringUI - DateTime column type', fakeAsync(() => { + emitFilteringDoneOnInputClear( + fix, + grid, + cal.timedelta(today, 'hour', 1), + 'ReleaseTime', + IgxDateTimeFilteringOperand.instance().condition('equals') + ); + })); + + it('should update UI when chip is removed from header cell - Date column type.', fakeAsync(() => { + verifyRemoveChipFromHeader( + fix, + grid, + cal.timedelta(today, 'day', 15), + 'ReleaseDate', + IgxDateFilteringOperand.instance().condition('equals'), + 1, + 4 + ); + })); + + it('should update UI when chip is removed from header cell - String column type.', fakeAsync(() => { + verifyRemoveChipFromHeader( + fix, + grid, + 'I', + 'ProductName', + IgxStringFilteringOperand.instance().condition('startsWith'), + 2, + 1 + ); + })); + + it('should update UI when chip is removed from header cell - Number column type.', fakeAsync(() => { + verifyRemoveChipFromHeader( + fix, + grid, + 100, + 'Downloads', + IgxNumberFilteringOperand.instance().condition('equals'), + 1, + 2 + ); + })); + + it('should update UI when chip is removed from header cell - Currency column type.', fakeAsync(() => { + verifyRemoveChipFromHeader( + fix, + grid, + 100000, + 'Revenue', + IgxNumberFilteringOperand.instance().condition('equals'), + 1, + 7 + ); + })); + + it('should update UI when chip is removed from header cell - Boolean column type.', fakeAsync(() => { + verifyRemoveChipFromHeader( + fix, + grid, + true, + 'Released', + IgxBooleanFilteringOperand.instance().condition('true'), + 3, + 3 + ); + })); + + it('should update UI when chip is removed from header cell - DateTime column type.', fakeAsync(() => { + verifyRemoveChipFromHeader( + fix, + grid, + cal.timedelta(today, 'hour', 1), + 'ReleaseTime', + IgxDateTimeFilteringOperand.instance().condition('equals'), + 3, + 6 + ); + })); + + it('should emit filteringDone after chip is close from filtering row - Date column type', fakeAsync(() => { + closeChipFromFilteringUIRow(fix, grid, 'ReleaseDate', 4); + })); + + it('should emit filteringDone after chip is close from filtering row - String column type', fakeAsync(() => { + closeChipFromFilteringUIRow(fix, grid, 'ProductName', 9); + })); + + it('should emit filteringDone after chip is close from filtering row - Number column type', fakeAsync(() => { + closeChipFromFilteringUIRow(fix, grid, 'Downloads', 7); + })); + + it('should emit filteringDone after chip is close from filtering row - Currency column type', fakeAsync(() => { + closeChipFromFilteringUIRow(fix, grid, 'Revenue', 7); + })); + + it('should emit filteringDone after chip is close from filtering row - Boolean column type', fakeAsync(() => { + closeChipFromFilteringUIRow(fix, grid, 'Released', 1); + })); + + it('should emit filteringDone after chip is close from filtering row - DateTime column type', fakeAsync(() => { + closeChipFromFilteringUIRow(fix, grid, 'ReleaseTime', 4); + })); + }); +}); + +describe('IgxGrid - Filtering actions - Excel style filtering #grid', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxGridFilteringComponent, + IgxGridFilteringESFEmptyTemplatesComponent, + IgxGridFilteringESFTemplatesComponent, + IgxGridFilteringESFLoadOnDemandComponent, + IgxGridFilteringMCHComponent, + IgxGridExternalESFComponent, + IgxGridExternalESFTemplateComponent + ] + }).compileComponents(); + })); + + describe(null, () => { + let fix: ComponentFixture; + let grid: IgxGridComponent; + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(IgxGridFilteringComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + grid.filterMode = FilterMode.excelStyleFilter; + fix.detectChanges(); + })); + + it('Should sort the grid properly, when clicking Ascending button.', async () => { + grid.columnList.get(2).sortable = true; + fix.detectChanges(); + + GridFunctions.clickExcelFilterIconFromCodeAsync(fix, grid, 'Downloads'); + fix.detectChanges(); + await wait(100); + + const sortAsc = GridFunctions.getExcelStyleFilteringSortButtons(fix)[0]; + + sortAsc.click(); + await wait(); + fix.detectChanges(); + + expect(grid.sortingExpressions[0].fieldName).toEqual('Downloads'); + expect(grid.sortingExpressions[0].dir).toEqual(SortingDirection.Asc); + ControlsFunction.verifyButtonIsSelected(sortAsc); + + sortAsc.click(); + await wait(); + fix.detectChanges(); + + expect(grid.sortingExpressions.length).toEqual(0); + ControlsFunction.verifyButtonIsSelected(sortAsc, false); + }); + + it('Should sort the grid properly, when clicking Descending button.', async () => { + grid.columnList.get(2).sortable = true; + fix.detectChanges(); + + GridFunctions.clickExcelFilterIconFromCodeAsync(fix, grid, 'Downloads'); + await wait(100); + fix.detectChanges(); + + const sortDesc = GridFunctions.getExcelStyleFilteringSortButtons(fix)[1]; + + sortDesc.click(); + await wait(); + fix.detectChanges(); + + expect(grid.sortingExpressions[0].fieldName).toEqual('Downloads'); + expect(grid.sortingExpressions[0].dir).toEqual(SortingDirection.Desc); + ControlsFunction.verifyButtonIsSelected(sortDesc); + + sortDesc.click(); + await wait(); + fix.detectChanges(); + + expect(grid.sortingExpressions.length).toEqual(0); + ControlsFunction.verifyButtonIsSelected(sortDesc, false); + }); + + it('Should (sort ASC)/(sort DESC) when clicking the respective sort button.', async () => { + grid.columnList.get(2).sortable = true; + fix.detectChanges(); + + GridFunctions.clickExcelFilterIconFromCodeAsync(fix, grid, 'Downloads'); + await wait(100); + fix.detectChanges(); + + const sortAsc = GridFunctions.getExcelStyleFilteringSortButtons(fix)[0]; + const sortDesc = GridFunctions.getExcelStyleFilteringSortButtons(fix)[1]; + + sortDesc.click(); + await wait(); + fix.detectChanges(); + + expect(grid.sortingExpressions[0].fieldName).toEqual('Downloads'); + expect(grid.sortingExpressions[0].dir).toEqual(SortingDirection.Desc); + ControlsFunction.verifyButtonIsSelected(sortDesc); + + sortAsc.click(); + await wait(); + fix.detectChanges(); + + expect(grid.sortingExpressions[0].fieldName).toEqual('Downloads'); + expect(grid.sortingExpressions[0].dir).toEqual(SortingDirection.Asc); + ControlsFunction.verifyButtonIsSelected(sortAsc); + ControlsFunction.verifyButtonIsSelected(sortDesc, false); + }); + + it('Should toggle correct Ascending/Descending button on opening when sorting is applied.', async () => { + grid.columnList.get(2).sortable = true; + grid.sortingExpressions.push({ dir: SortingDirection.Asc, fieldName: 'Downloads' }); + fix.detectChanges(); + + GridFunctions.clickExcelFilterIconFromCodeAsync(fix, grid, 'Downloads'); + await wait(100); + fix.detectChanges(); + + const sortAsc = GridFunctions.getExcelStyleFilteringSortButtons(fix)[0]; + const sortDesc = GridFunctions.getExcelStyleFilteringSortButtons(fix)[1]; + + ControlsFunction.verifyButtonIsSelected(sortAsc); + ControlsFunction.verifyButtonIsSelected(sortDesc, false); + }); + + it('Should move column left/right when clicking buttons.', fakeAsync(() => { + grid.moving = true; + fix.detectChanges(); + + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'Downloads'); + + const moveLeft = GridFunctions.getExcelStyleFilteringMoveButtons(fix)[0]; + const moveRight = GridFunctions.getExcelStyleFilteringMoveButtons(fix)[1]; + + moveLeft.click(); + tick(); + fix.detectChanges(); + + expect(grid.columns[2].field).toBe('ProductName'); + expect(grid.columns[1].field).toBe('Downloads'); + + moveLeft.click(); + tick(); + fix.detectChanges(); + + expect(grid.columns[1].field).toBe('ID'); + expect(grid.columns[0].field).toBe('Downloads'); + ControlsFunction.verifyButtonIsDisabled(moveLeft); + + moveRight.click(); + tick(); + fix.detectChanges(); + + expect(grid.columns[0].field).toBe('ID'); + expect(grid.columns[1].field).toBe('Downloads'); + ControlsFunction.verifyButtonIsDisabled(moveLeft, false); + })); + + it('Should right pin and unpin column after moving it left/right when clicking buttons.', fakeAsync(() => { + grid.pinning.columns = 1; + + const columnToPin = grid.columnList.get(grid.columnList.length - 2); + columnToPin.pinned = true; + fix.detectChanges(); + + expect(grid.pinnedColumns.length).toBe(1); + + const columnToMove = grid.unpinnedColumns[grid.unpinnedColumns.length - 1]; + grid.moving = true; + + GridFunctions.clickExcelFilterIconFromCode(fix, grid, columnToMove.field); + + let moveLeft = GridFunctions.getExcelStyleFilteringMoveButtons(fix)[0]; + let moveRight = GridFunctions.getExcelStyleFilteringMoveButtons(fix)[1]; + + moveRight.click(); + fix.detectChanges(); + + expect(grid.pinnedColumns.length).toBe(2); + + expect(grid.pinnedColumns[0].field).toBe(columnToMove.field); + expect(grid.pinnedColumns[1].field).toBe(columnToPin.field); + + GridFunctions.clickExcelFilterIconFromCode(fix, grid, columnToMove.field); + moveRight = GridFunctions.getExcelStyleFilteringMoveButtons(fix)[1]; + moveRight.click(); + fix.detectChanges(); + + expect(grid.pinnedColumns[0].field).toBe(columnToPin.field); + expect(grid.pinnedColumns[1].field).toBe(columnToMove.field); + + moveLeft = GridFunctions.getExcelStyleFilteringMoveButtons(fix)[0]; + moveLeft.click(); + fix.detectChanges(); + + moveLeft.click(); + fix.detectChanges(); + + expect(grid.pinnedColumns.length).toBe(1); + expect(grid.pinnedColumns[0].field).toBe(columnToPin.field); + + tick(16); + })); + + it('Should pin column when clicking buttons.', fakeAsync(() => { + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'Downloads'); + + GridFunctions.getExcelFilteringPinContainer(fix).click(); + fix.detectChanges(); + + expect(grid.pinnedColumns[0].field).toEqual('Downloads'); + })); + + it('Should unpin column when clicking buttons.', fakeAsync(() => { + grid.columnList.get(2).pinned = true; + fix.detectChanges(); + + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'Downloads'); + + GridFunctions.getExcelFilteringUnpinContainer(fix).click(); + fix.detectChanges(); + + expect(grid.pinnedColumns.length).toEqual(0); + })); + + it('Should hide column when click on button.', fakeAsync(() => { + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'Downloads'); + + spyOn(grid.columnVisibilityChanged, 'emit'); + spyOn(grid.columnVisibilityChanging, 'emit'); + GridFunctions.getExcelFilteringHideContainer(fix).click(); + fix.detectChanges(); + + const args = { column: grid.getColumnByName('Downloads'), newValue: true }; + expect(grid.columnVisibilityChanging.emit).toHaveBeenCalledTimes(1); + expect(grid.columnVisibilityChanging.emit).toHaveBeenCalledWith({ ...args, cancel: false }); + expect(grid.columnVisibilityChanged.emit).toHaveBeenCalledTimes(1); + expect(grid.columnVisibilityChanged.emit).toHaveBeenCalledWith(args); + + GridFunctions.verifyColumnIsHidden(grid.columnList.get(2), true, 7); + })); + + it('Should not select values in list if two values with And operator are entered.', fakeAsync(() => { + const gridFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + const columnsFilteringTree = new FilteringExpressionsTree(FilteringLogic.And, 'Downloads'); + columnsFilteringTree.filteringOperands = [ + { fieldName: 'Downloads', searchVal: 20, condition: IgxNumberFilteringOperand.instance().condition('equals'), conditionName: 'equals' }, + { fieldName: 'Downloads', searchVal: 20, condition: IgxNumberFilteringOperand.instance().condition('equals'), conditionName: 'equals' } + ]; + gridFilteringExpressionsTree.filteringOperands.push(columnsFilteringTree); + grid.filteringExpressionsTree = gridFilteringExpressionsTree; + fix.detectChanges(); + + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'Downloads'); + + expect(grid.filteredData.length).toEqual(1); + + const excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + const checkboxes: any[] = Array.from(GridFunctions.getExcelStyleFilteringCheckboxes(fix, excelMenu)); + checkboxes.forEach(c => expect(c.checked).toBeFalsy()); + })); + + it('Should show the previously entered filter value when reopen esf dialog from the applied filter operand', fakeAsync(() => { + const gridFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + const columnsFilteringTree = new FilteringExpressionsTree(FilteringLogic.Or, 'ProductName'); + columnsFilteringTree.filteringOperands = [ + { fieldName: 'ProductName', searchVal: 'Angular', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' } + ]; + gridFilteringExpressionsTree.filteringOperands.push(columnsFilteringTree); + grid.filteringExpressionsTree = gridFilteringExpressionsTree; + fix.detectChanges(); + + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + + expect(grid.nativeElement.querySelector('.igx-excel-filter__filter-number').textContent).toContain('(1)'); + expect(grid.filteredData.length).toEqual(1); + + const excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + const checkboxes: any[] = Array.from(GridFunctions.getExcelStyleFilteringCheckboxes(fix, excelMenu)); + checkboxes.forEach(c => expect(c.checked).toBeFalsy()); + + GridFunctions.clickExcelFilterCascadeButton(fix); + tick(100); + fix.detectChanges(); + + const ddItem = fix.nativeElement.querySelector('.igx-drop-down__item--selected'); + expect(ddItem).toBeDefined(); + expect(ddItem.outerText.toLowerCase()).toMatch('contains'); + + GridFunctions.clickOperatorFromCascadeMenu(fix, 0); + tick(100); + fix.detectChanges(); + + expect(GridFunctions.getExcelFilteringInput(fix, 0).value).toEqual('Angular'); + })); + + it('Should Not show the previously entered filter value when reopen esf dialog from other filterOperand', fakeAsync(() => { + const gridFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + const columnsFilteringTree = new FilteringExpressionsTree(FilteringLogic.Or, 'ProductName'); + columnsFilteringTree.filteringOperands = [ + { fieldName: 'ProductName', searchVal: 'Angular', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' } + ]; + gridFilteringExpressionsTree.filteringOperands.push(columnsFilteringTree); + grid.filteringExpressionsTree = gridFilteringExpressionsTree; + fix.detectChanges(); + + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + + expect(grid.nativeElement.querySelector('.igx-excel-filter__filter-number').textContent).toContain('(1)'); + expect(grid.filteredData.length).toEqual(1); + + const excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + const checkboxes: any[] = Array.from(GridFunctions.getExcelStyleFilteringCheckboxes(fix, excelMenu)); + checkboxes.forEach(c => expect(c.checked).toBeFalsy()); + + GridFunctions.clickExcelFilterCascadeButton(fix); + tick(100); + fix.detectChanges(); + + const ddItem = fix.nativeElement.querySelector('.igx-drop-down__item--selected'); + expect(ddItem).toBeDefined(); + expect(ddItem.outerText.toLowerCase()).toMatch('contains'); + + GridFunctions.clickOperatorFromCascadeMenu(fix, 1); + tick(100); + fix.detectChanges(); + + expect(GridFunctions.getExcelFilteringInput(fix, 0).value).toEqual(''); + })); + + it('Should not select values in list if two values with Or operator are entered and contains operand.', fakeAsync(() => { + const gridFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + const columnsFilteringTree = new FilteringExpressionsTree(FilteringLogic.Or, 'ProductName'); + columnsFilteringTree.filteringOperands = [ + { fieldName: 'ProductName', searchVal: 'Angular', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' }, + { fieldName: 'ProductName', searchVal: 'Ignite', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' } + ]; + gridFilteringExpressionsTree.filteringOperands.push(columnsFilteringTree); + grid.filteringExpressionsTree = gridFilteringExpressionsTree; + fix.detectChanges(); + + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + + expect(grid.nativeElement.querySelector('.igx-excel-filter__filter-number').textContent).toContain('(2)'); + expect(grid.filteredData.length).toEqual(2); + + const excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + const checkboxes: any[] = Array.from(GridFunctions.getExcelStyleFilteringCheckboxes(fix, excelMenu)); + checkboxes.forEach(c => expect(c.checked).toBeFalsy()); + + GridFunctions.clickExcelFilterCascadeButton(fix); + tick(30); + fix.detectChanges(); + + const ddItem = fix.nativeElement.querySelector('.igx-drop-down__item--selected'); + expect(ddItem).toBeDefined(); + expect(ddItem.querySelector('.igx-grid__filtering-dropdown-text').textContent).toMatch('Custom filter...'); + })); + + it('Should select values in list if two values with Or operator are entered and they are in the list below.', fakeAsync(() => { + const gridFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + const columnsFilteringTree = new FilteringExpressionsTree(FilteringLogic.Or, 'Downloads'); + columnsFilteringTree.filteringOperands = [ + { fieldName: 'Downloads', searchVal: 254, condition: IgxNumberFilteringOperand.instance().condition('equals'), conditionName: 'equals' }, + { fieldName: 'Downloads', searchVal: 20, condition: IgxNumberFilteringOperand.instance().condition('equals'), conditionName: 'equals' } + ]; + gridFilteringExpressionsTree.filteringOperands.push(columnsFilteringTree); + grid.filteringExpressionsTree = gridFilteringExpressionsTree; + fix.detectChanges(); + + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'Downloads'); + + expect(grid.filteredData.length).toEqual(2); + + const excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + const checkboxes: any[] = Array.from(GridFunctions.getExcelStyleFilteringCheckboxes(fix, excelMenu)); + + fix.detectChanges(); + + expect(checkboxes[0].checked && checkboxes[0].indeterminate).toBeTruthy(); + expect(!checkboxes[1].checked && !checkboxes[1].indeterminate).toBeTruthy(); + expect(!checkboxes[2].checked && !checkboxes[2].indeterminate).toBeTruthy(); + expect(checkboxes[3].checked && !checkboxes[3].indeterminate).toBeTruthy(); + })); + + it('Should change filter when changing And/Or operator.', fakeAsync(() => { + const gridFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + const columnsFilteringTree = new FilteringExpressionsTree(FilteringLogic.Or, 'Downloads'); + columnsFilteringTree.filteringOperands = [ + { fieldName: 'Downloads', searchVal: 254, condition: IgxNumberFilteringOperand.instance().condition('equals'), conditionName: 'equals' }, + { fieldName: 'Downloads', searchVal: 20, condition: IgxNumberFilteringOperand.instance().condition('equals'), conditionName: 'equals' } + ]; + gridFilteringExpressionsTree.filteringOperands.push(columnsFilteringTree); + grid.filteringExpressionsTree = gridFilteringExpressionsTree; + fix.detectChanges(); + + GridFunctions.clickExcelFilterIcon(fix, 'Downloads'); + tick(100); + fix.detectChanges(); + + expect(grid.filteredData.length).toEqual(2); + + GridFunctions.clickExcelFilterCascadeButton(fix); + tick(); + fix.detectChanges(); + + GridFunctions.clickOperatorFromCascadeMenu(fix, 10); + tick(100); + + const andButton = GridFunctions.getExcelCustomFilteringExpressionAndButton(fix); + andButton.click(); + fix.detectChanges(); + + GridFunctions.clickApplyExcelStyleCustomFiltering(fix); + + expect(grid.filteredData.length).toEqual(0); + })); + + it('Should change filter when changing operator.', fakeAsync(() => { + const gridFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + const columnsFilteringTree = new FilteringExpressionsTree(FilteringLogic.Or, 'Downloads'); + columnsFilteringTree.filteringOperands = [ + { fieldName: 'Downloads', searchVal: 254, condition: IgxNumberFilteringOperand.instance().condition('equals'), conditionName: 'equals' }, + { fieldName: 'Downloads', searchVal: 20, condition: IgxNumberFilteringOperand.instance().condition('equals'), conditionName: 'equals' } + ]; + gridFilteringExpressionsTree.filteringOperands.push(columnsFilteringTree); + grid.filteringExpressionsTree = gridFilteringExpressionsTree; + fix.detectChanges(); + + GridFunctions.clickExcelFilterIcon(fix, 'Downloads'); + tick(100); + fix.detectChanges(); + + expect(grid.filteredData.length).toEqual(2); + + GridFunctions.clickExcelFilterCascadeButton(fix); + tick(); + fix.detectChanges(); + + GridFunctions.clickOperatorFromCascadeMenu(fix, 10); + tick(); + + // select second expression's operator + GridFunctions.setOperatorESF(fix, 1, 2); + + GridFunctions.clickApplyExcelStyleCustomFiltering(fix); + + expect(grid.filteredData.length).toEqual(5); + })); + + it('Should not be able to exit custom dialog when press tab on apply button', fakeAsync(() => { + GridFunctions.clickExcelFilterIcon(fix, 'Downloads'); + tick(100); + fix.detectChanges(); + + GridFunctions.clickExcelFilterCascadeButton(fix); + tick(); + fix.detectChanges(); + + GridFunctions.clickOperatorFromCascadeMenu(fix, 0); + tick(); + + const applyButton = GridFunctions.getApplyExcelStyleCustomFiltering(fix); + applyButton.focus(); + fix.detectChanges(); + + expect(document.activeElement).toBe(applyButton); + + UIInteractions.triggerKeyDownEvtUponElem('Tab', applyButton, true); + fix.detectChanges(); + + expect(document.activeElement).toBe(applyButton); + })); + + it('Should populate custom filter dialog.', fakeAsync(() => { + const gridFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + const columnsFilteringTree = new FilteringExpressionsTree(FilteringLogic.Or, 'Downloads'); + columnsFilteringTree.filteringOperands = [ + { fieldName: 'Downloads', searchVal: 254, condition: IgxNumberFilteringOperand.instance().condition('equals'), conditionName: 'equals' }, + { fieldName: 'Downloads', searchVal: 20, condition: IgxNumberFilteringOperand.instance().condition('lessThan'), conditionName: 'lessThan' } + ]; + gridFilteringExpressionsTree.filteringOperands.push(columnsFilteringTree); + grid.filteringExpressionsTree = gridFilteringExpressionsTree; + fix.detectChanges(); + + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'Downloads'); + + GridFunctions.clickExcelFilterCascadeButton(fix); + tick(); + fix.detectChanges(); + + GridFunctions.clickOperatorFromCascadeMenu(fix, 10); + tick(); + + // Verify inputs values + expect(GridFunctions.getExcelFilteringInput(fix, 0).value).toEqual('254'); + expect(GridFunctions.getExcelFilteringInput(fix, 1).value).toEqual('20'); + + // Verify Drop Down values + expect(GridFunctions.getExcelFilteringDDInput(fix, 0).value).toEqual('Equals'); + expect(GridFunctions.getExcelFilteringDDInput(fix, 1).value).toEqual('Less Than'); + })); + + it('Should display friendly conditions\' names in custom filter dialog.', fakeAsync(() => { + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + + GridFunctions.clickExcelFilterCascadeButton(fix); + tick(); + fix.detectChanges(); + + GridFunctions.clickOperatorFromCascadeMenu(fix, 1); + tick(100); + + const firstValue = GridFunctions.getExcelFilteringDDInput(fix, 0).value; + + expect(firstValue).toMatch('Does Not Contain'); + })); + + it('Should clear the filter when click Clear filter item.', fakeAsync(() => { + const gridFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + const columnsFilteringTree = new FilteringExpressionsTree(FilteringLogic.Or, 'Downloads'); + columnsFilteringTree.filteringOperands = [ + { fieldName: 'Downloads', searchVal: 254, condition: IgxNumberFilteringOperand.instance().condition('equals'), conditionName: 'equals' }, + { fieldName: 'Downloads', searchVal: 20, condition: IgxNumberFilteringOperand.instance().condition('equals'), conditionName: 'equals' } + ]; + gridFilteringExpressionsTree.filteringOperands.push(columnsFilteringTree); + grid.filteringExpressionsTree = gridFilteringExpressionsTree; + fix.detectChanges(); + + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'Downloads'); + + GridFunctions.clickClearFilterInExcelStyleFiltering(fix); + fix.detectChanges(); + + expect(grid.filteredData).toBeNull(); + })); + + it('Should clear filter when pressing \'Enter\' on the clear filter button in ESF.', fakeAsync(() => { + const gridFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + const columnsFilteringTree = new FilteringExpressionsTree(FilteringLogic.Or, 'ProductName'); + columnsFilteringTree.filteringOperands = [ + { fieldName: 'ProductName', searchVal: 'Angular', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' }, + { fieldName: 'ProductName', searchVal: 'Ignite', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' } + ]; + gridFilteringExpressionsTree.filteringOperands.push(columnsFilteringTree); + grid.filteringExpressionsTree = gridFilteringExpressionsTree; + fix.detectChanges(); + + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + + expect(grid.filteredData.length).toEqual(2); + + const clearFilterButton = GridFunctions.getClearFilterInExcelStyleFiltering(fix); + clearFilterButton.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + tick(100); + fix.detectChanges(); + + expect(grid.filteredData).toBeNull(); + })); + + it('Should set the \'aria-disabled\' attribute for the ESF dialog clear filter button element with role=\'menuitem\'.', fakeAsync(() => { + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + expect(grid.filteredData).toBeNull(); + let clearFilterButtonMenuItemRole = GridFunctions.getClearFilterInExcelStyleFiltering(fix); + expect(clearFilterButtonMenuItemRole.getAttribute('aria-disabled')).toBe('true'); + + const gridFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + const columnsFilteringTree = new FilteringExpressionsTree(FilteringLogic.Or, 'ProductName'); + columnsFilteringTree.filteringOperands = [ + { fieldName: 'ProductName', searchVal: 'Angular', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' }, + { fieldName: 'ProductName', searchVal: 'Ignite', condition: IgxStringFilteringOperand.instance().condition('contains'), conditionName: 'contains' } + ]; + gridFilteringExpressionsTree.filteringOperands.push(columnsFilteringTree); + grid.filteringExpressionsTree = gridFilteringExpressionsTree; + fix.detectChanges(); + + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + expect(grid.filteredData.length).toEqual(2); + clearFilterButtonMenuItemRole = GridFunctions.getClearFilterInExcelStyleFiltering(fix); + expect(clearFilterButtonMenuItemRole.getAttribute('aria-disabled')).toBe('false'); + + const clearFilterButton = GridFunctions.getClearFilterInExcelStyleFiltering(fix); + clearFilterButton.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + tick(100); + fix.detectChanges(); + + expect(grid.filteredData).toBeNull(); + clearFilterButtonMenuItemRole = GridFunctions.getClearFilterInExcelStyleFiltering(fix); + expect(clearFilterButtonMenuItemRole.getAttribute('aria-disabled')).toBe('true'); + })); + + it('Should update filter icon when dialog is closed and the filter has been changed.', fakeAsync(() => { + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'Downloads'); + + const checkbox = GridFunctions.getExcelStyleFilteringCheckboxes(fix); + + checkbox[0].click(); + tick(); + fix.detectChanges(); + + checkbox[2].click(); + tick(); + fix.detectChanges(); + + GridFunctions.clickApplyExcelStyleFiltering(fix); + fix.detectChanges(); + + expect(grid.filteredData.length).toEqual(1); + + let filterIcon = GridFunctions.getExcelFilterIcon(fix, 'Downloads'); + expect(filterIcon).toBeNull(); + + filterIcon = GridFunctions.getExcelFilterIconFiltered(fix, 'Downloads'); + expect(filterIcon).toBeDefined(); + })); + + it('Should filter grid via custom dialog.', fakeAsync(() => { + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'Downloads'); + + GridFunctions.clickExcelFilterCascadeButton(fix); + tick(); + fix.detectChanges(); + + GridFunctions.clickOperatorFromCascadeMenu(fix, 0); + tick(100); + + // set first expression's value + GridFunctions.setInputValueESF(fix, 0, 0); + tick(100); + + // select second expression's operator + GridFunctions.setOperatorESF(fix, 1, 1); + tick(100); + + // set second expression's value + GridFunctions.setInputValueESF(fix, 1, 20); + tick(100); + + GridFunctions.clickApplyExcelStyleCustomFiltering(fix); + + expect(grid.filteredData.length).toEqual(1); + })); + + it('Should filter grid via custom dialog - 3 expressions.', fakeAsync(() => { + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'Released'); + + GridFunctions.clickExcelFilterCascadeButton(fix); + tick(); + fix.detectChanges(); + + GridFunctions.clickOperatorFromCascadeMenu(fix, 0); + tick(100); + + // select second expression's operator + GridFunctions.setOperatorESF(fix, 1, 1); + + GridFunctions.clickAddFilterExcelStyleCustomFiltering(fix); + fix.detectChanges(); + + // select third expression's operator + GridFunctions.setOperatorESF(fix, 2, 4); + + GridFunctions.clickApplyExcelStyleCustomFiltering(fix); + + expect(grid.filteredData.length).toEqual(3); + })); + + it('Should clear filter from custom dialog.', fakeAsync(() => { + const gridFilteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + const columnsFilteringTree = new FilteringExpressionsTree(FilteringLogic.Or, 'Downloads'); + columnsFilteringTree.filteringOperands = [ + { fieldName: 'Downloads', searchVal: 254, condition: IgxNumberFilteringOperand.instance().condition('equals'), conditionName: 'equals' }, + { fieldName: 'Downloads', searchVal: 20, condition: IgxNumberFilteringOperand.instance().condition('equals'), conditionName: 'equals' } + ]; + gridFilteringExpressionsTree.filteringOperands.push(columnsFilteringTree); + grid.filteringExpressionsTree = gridFilteringExpressionsTree; + fix.detectChanges(); + + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'Downloads'); + + GridFunctions.clickExcelFilterCascadeButton(fix); + tick(); + fix.detectChanges(); + + GridFunctions.clickOperatorFromCascadeMenu(fix, 10); + tick(100); + + GridFunctions.clickClearFilterExcelStyleCustomFiltering(fix); + GridFunctions.clickApplyExcelStyleCustomFiltering(fix); + + expect(grid.filteredData).toBeNull(); + })); + + it('Should pin/unpin column when clicking pin/unpin icon in header', fakeAsync(() => { + setElementSize(grid.nativeElement, ɵSize.Medium); + tick(200); + fix.detectChanges(); + + // Open excel style filtering component and pin 'ProductName' column through header icon + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + + GridFunctions.clickPinIconInExcelStyleFiltering(fix); + tick(200); + fix.detectChanges(); + // Verify Excel menu is closed + expect(GridFunctions.getExcelStyleFilteringComponent(fix)).toBeNull(); + const column = grid.getColumnByName('ProductName'); + GridFunctions.verifyColumnIsPinned(column, true, 1); + + // Open excel style filtering component and UNpin 'ProductName' column through header icon + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + + GridFunctions.clickPinIconInExcelStyleFiltering(fix); + tick(200); + fix.detectChanges(); + + GridFunctions.verifyColumnIsPinned(column, false, 0); + })); + + it('Should hide column when clicking hide icon in header', fakeAsync(() => { + setElementSize(grid.nativeElement, ɵSize.Small); + tick(200); + fix.detectChanges(); + spyOn(grid.columnVisibilityChanging, 'emit'); + spyOn(grid.columnVisibilityChanged, 'emit'); + + const column = grid.columnList.find((col) => col.field === 'ProductName'); + GridFunctions.verifyColumnIsHidden(column, false, 8); + + // Open excel style filtering component and hide 'ProductName' column through header icon + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + GridFunctions.clickHideIconInExcelStyleFiltering(fix); + tick(200); + fix.detectChanges(); + + // Verify Excel menu is closed + expect(GridFunctions.getExcelStyleFilteringComponent(fix)).toBeNull(); + const args = { column, newValue: true }; + expect(grid.columnVisibilityChanging.emit).toHaveBeenCalledTimes(1); + expect(grid.columnVisibilityChanging.emit).toHaveBeenCalledWith({ ...args, cancel: false }); + expect(grid.columnVisibilityChanged.emit).toHaveBeenCalledTimes(1); + expect(grid.columnVisibilityChanged.emit).toHaveBeenCalledWith(args); + })); + + it('Should move pinned column correctly by using move buttons', fakeAsync(() => { + grid.moving = true; + const productNameCol = grid.getColumnByName('ProductName'); + const idCol = grid.getColumnByName('ID'); + productNameCol.pinned = true; + idCol.pinned = true; + fix.detectChanges(); + + expect(productNameCol.pinned).toBe(true); + + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + + // Move 'ProductName' one step to the right. (should move) + let moveRight = GridFunctions.getExcelStyleFilteringMoveButtons(fix)[1]; + UIInteractions.simulateClickEvent(moveRight); + tick(DEBOUNCE_TIME); + fix.detectChanges(); + expect(GridFunctions.getColumnHeaderTitleByIndex(fix, 0).innerText).toBe('ID'); + expect(GridFunctions.getColumnHeaderTitleByIndex(fix, 1).innerText).toBe('ProductName'); + expect(productNameCol.pinned).toBe(true); + + // Move 'ProductName' one step to the left. (should move) + const moveLeft = GridFunctions.getExcelStyleFilteringMoveButtons(fix)[0]; + UIInteractions.simulateClickEvent(moveLeft); + tick(DEBOUNCE_TIME); + fix.detectChanges(); + expect(GridFunctions.getColumnHeaderTitleByIndex(fix, 0).innerText).toBe('ProductName'); + expect(GridFunctions.getColumnHeaderTitleByIndex(fix, 1).innerText).toBe('ID'); + expect(productNameCol.pinned).toBe(true); + + // Try move 'ProductName' one step to the left. (Button should be disabled since it's already first) + const moveComponent = GridFunctions.getExcelFilteringMoveComponent(fix); + ControlsFunction.verifyButtonIsDisabled(moveComponent.querySelectorAll('button')[0]); + + // Move 'ProductName' two steps to the right. (should move) + moveRight = GridFunctions.getExcelStyleFilteringMoveButtons(fix)[1]; + UIInteractions.simulateClickEvent(moveRight); + tick(DEBOUNCE_TIME); + fix.detectChanges(); + UIInteractions.simulateClickEvent(moveRight); + tick(DEBOUNCE_TIME); + fix.detectChanges(); + expect(GridFunctions.getColumnHeaderTitleByIndex(fix, 0).innerText).toBe('ID'); + expect(GridFunctions.getColumnHeaderTitleByIndex(fix, 1).innerText).toBe('ProductName'); + expect(productNameCol.pinned).toBe(false); + })); + + it('Should move unpinned column correctly by using move buttons', fakeAsync(() => { + grid.moving = true; + const productNameCol = grid.getColumnByName('ProductName'); + const downloadsCol = grid.getColumnByName('Downloads'); + productNameCol.pinned = true; + fix.detectChanges(); + expect(GridFunctions.getColumnHeaderTitleByIndex(fix, 0).innerText).toBe('ProductName'); + expect(GridFunctions.getColumnHeaderTitleByIndex(fix, 2).innerText).toBe('Downloads'); + expect(downloadsCol.pinned).toBe(false); + + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'Downloads'); + + const moveLeft = GridFunctions.getExcelStyleFilteringMoveButtons(fix)[0]; + UIInteractions.simulateClickEvent(moveLeft); + tick(DEBOUNCE_TIME); + fix.detectChanges(); + UIInteractions.simulateClickEvent(moveLeft); + tick(DEBOUNCE_TIME); + fix.detectChanges(); + + expect(GridFunctions.getColumnHeaderTitleByIndex(fix, 0).innerText).toBe('ProductName'); + expect(GridFunctions.getColumnHeaderTitleByIndex(fix, 1).innerText).toBe('Downloads'); + expect(downloadsCol.pinned).toBe(true); + })); + + it('Should filter and clear the excel search component correctly', async () => { + GridFunctions.clickExcelFilterIconFromCodeAsync(fix, grid, 'ProductName'); + fix.detectChanges(); + await wait(100); + const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix); + let listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent); + expect(listItems.length).toBe(6, 'incorrect rendered list items count'); + + // Type string in search box. + const inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix, searchComponent); + UIInteractions.clickAndSendInputElementValue(inputNativeElement, 'ignite', fix); + fix.detectChanges(); + await wait(100); + + listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent); + expect(listItems.length).toBe(4, 'incorrect rendered list items count'); + + // Clear filtering of ESF search. + const clearIcon: any = Array.from(searchComponent.querySelectorAll('igx-icon')) + .find((icon: any) => icon.innerText === 'clear'); + clearIcon.click(); + fix.detectChanges(); + + listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent); + expect(listItems.length).toBe(6, 'incorrect rendered list items count'); + }); + + it('Should allow to input commas in excel search component input field when column dataType is number.', async () => { + GridFunctions.clickExcelFilterIconFromCodeAsync(fix, grid, 'Downloads'); + fix.detectChanges(); + await wait(100); + const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix); + let listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent); + const inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix, searchComponent); + + // Type 1,000 in search box. + UIInteractions.clickAndSendInputElementValue(inputNativeElement, '1,000', fix); + fix.detectChanges(); + await wait(100); + + listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent); + expect(listItems.length).toBe(3, 'incorrect rendered list items count'); + + // Type non-numerical symbol in search box. + UIInteractions.clickAndSendInputElementValue(inputNativeElement, 'a', fix); + fix.detectChanges(); + await wait(100); + + listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent); + expect(inputNativeElement.value).toBe('', 'incorrect rendered list items count'); + expect(listItems.length).toBe(8, 'incorrect rendered list items count'); + }); + + it('Should enable/disable the apply button correctly.', async () => { + GridFunctions.clickExcelFilterIconFromCodeAsync(fix, grid, 'ProductName'); + fix.detectChanges(); + await wait(100); + // Verify there are filtered-in results and that apply button is enabled. + const listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix); + let applyButton = GridFunctions.getApplyButtonExcelStyleFiltering(fix) as HTMLElement; + expect(listItems.length).toBe(6, 'ESF search result should NOT be empty'); + ControlsFunction.verifyButtonIsDisabled(applyButton, false); + + // Verify the apply button is disabled when all items are unchecked (when unchecking 'Select All'). + const checkbox = GridFunctions.getExcelStyleFilteringCheckboxes(fix); + checkbox[0].click(); // Select All + fix.detectChanges(); + await wait(100); + applyButton = GridFunctions.getApplyButtonExcelStyleFiltering(fix); + ControlsFunction.verifyButtonIsDisabled(applyButton); + }); + + it('size is properly applied on the excel style filtering component', fakeAsync(() => { + const column = grid.columnList.find((c) => c.field === 'ProductName'); + column.sortable = true; + grid.moving = true; + fix.detectChanges(); + + // Open excel style filtering component and verify its size + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + + verifyExcelStyleFilteringSize(fix, ɵSize.Large); + GridFunctions.clickApplyExcelStyleFiltering(fix); + fix.detectChanges(); + + setElementSize(grid.nativeElement, ɵSize.Small); + tick(200); + fix.detectChanges(); + + // Open excel style filtering component and verify its size + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + tick(100); + fix.detectChanges(); + verifyExcelStyleFilteringSize(fix, ɵSize.Small); + GridFunctions.clickApplyExcelStyleFiltering(fix); + fix.detectChanges(); + + setElementSize(grid.nativeElement, ɵSize.Medium); + tick(200); + fix.detectChanges(); + + // Open excel style filtering component and verify its size + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + tick(100); + fix.detectChanges(); + verifyExcelStyleFilteringSize(fix, ɵSize.Medium); + GridFunctions.clickApplyExcelStyleFiltering(fix); + fix.detectChanges(); + })); + + it('size is properly applied on the column selection container', fakeAsync(() => { + grid.columnSelection = GridSelectionMode.multiple; + fix.detectChanges(); + + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + tick(100); + fix.detectChanges(); + + let columnSelectionContainer = GridFunctions.getExcelFilteringColumnSelectionContainer(fix); + let headerIcons = GridFunctions.getExcelFilteringHeaderIcons(fix); + + expect(columnSelectionContainer).not.toBeNull(); + expect(headerIcons.length).toEqual(0); + + setElementSize(grid.nativeElement, ɵSize.Small); + fix.detectChanges(); + + columnSelectionContainer = GridFunctions.getExcelFilteringColumnSelectionContainer(fix); + headerIcons = GridFunctions.getExcelFilteringHeaderIcons(fix); + const columnSelectionIcon = headerIcons.find((bi: any) => bi.innerText === 'done'); + + expect(columnSelectionContainer.tagName).toEqual('DIV'); + expect(columnSelectionIcon).not.toBeNull(); + + })); + + it('size is properly applied on the excel style custom filtering dialog', fakeAsync(() => { + const column = grid.columnList.find((c) => c.field === 'ProductName'); + column.sortable = true; + grid.moving = true; + fix.detectChanges(); + + // Open excel style custom filtering dialog and verify its size + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + + GridFunctions.clickExcelFilterCascadeButton(fix); + fix.detectChanges(); + GridFunctions.clickOperatorFromCascadeMenu(fix, 0); + + verifyExcelCustomFilterSize(fix, ɵSize.Large); + GridFunctions.clickApplyExcelStyleCustomFiltering(fix); + + setElementSize(grid.nativeElement, ɵSize.Medium); + tick(200); + fix.detectChanges(); + + // Open excel style custom filtering dialog and verify its size + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + + GridFunctions.clickExcelFilterCascadeButton(fix); + fix.detectChanges(); + GridFunctions.clickOperatorFromCascadeMenu(fix, 0); + + verifyExcelCustomFilterSize(fix, ɵSize.Medium); + GridFunctions.clickApplyExcelStyleCustomFiltering(fix); + + setElementSize(grid.nativeElement, ɵSize.Small); + tick(200); + fix.detectChanges(); + + // Open excel style custom filtering dialog and verify its size + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + + GridFunctions.clickExcelFilterCascadeButton(fix); + fix.detectChanges(); + GridFunctions.clickOperatorFromCascadeMenu(fix, 0); + + verifyExcelCustomFilterSize(fix, ɵSize.Small); + GridFunctions.clickApplyExcelStyleCustomFiltering(fix); + })); + + it('size is properly applied on the excel style cascade dropdown', fakeAsync(() => { + const gridNativeElement = fix.debugElement.query(By.css('igx-grid')).nativeElement; + + // Open excel style cascade operators dropdown and verify its size + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + + GridFunctions.clickExcelFilterCascadeButton(fix); + fix.detectChanges(); + + verifyGridSubmenuSize(gridNativeElement, ɵSize.Large); + + GridFunctions.clickCancelExcelStyleFiltering(fix); + tick(); + fix.detectChanges(); + + setElementSize(grid.nativeElement, ɵSize.Medium); + tick(200); + fix.detectChanges(); + + // Open excel style cascade operators dropdown and verify its size + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + + GridFunctions.clickExcelFilterCascadeButton(fix); + fix.detectChanges(); + + verifyGridSubmenuSize(gridNativeElement, ɵSize.Medium); + + GridFunctions.clickCancelExcelStyleFiltering(fix); + fix.detectChanges(); + + setElementSize(grid.nativeElement, ɵSize.Small); + tick(200); + fix.detectChanges(); + + // Open excel style cascade operators dropdown and verify its size + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + + GridFunctions.clickExcelFilterCascadeButton(fix); + fix.detectChanges(); + verifyGridSubmenuSize(gridNativeElement, ɵSize.Small); + })); + + it('size is properly applied on the excel custom dialog\'s default expression dropdown', + fakeAsync(() => { + const gridNativeElement = fix.debugElement.query(By.css('igx-grid')).nativeElement; + + // Open excel style custom filtering dialog. + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + + GridFunctions.clickExcelFilterCascadeButton(fix); + fix.detectChanges(); + GridFunctions.clickOperatorFromCascadeMenu(fix, 0); + tick(200); + + // Click the left input to open the operators dropdown and verify its size. + let conditionsInput = GridFunctions.getExcelFilteringDDInput(fix, 0); + conditionsInput.click(); + tick(100); + fix.detectChanges(); + + verifyGridSubmenuSize(gridNativeElement, ɵSize.Large); + GridFunctions.clickCancelExcelStyleCustomFiltering(fix); + tick(100); + fix.detectChanges(); + + // Change size + setElementSize(grid.nativeElement, ɵSize.Medium); + tick(200); + fix.detectChanges(); + + // Open excel style custom filtering dialog. + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + + GridFunctions.clickExcelFilterCascadeButton(fix); + fix.detectChanges(); + GridFunctions.clickOperatorFromCascadeMenu(fix, 0); + tick(200); + + // Click the left input to open the operators dropdown and verify its size. + conditionsInput = GridFunctions.getExcelFilteringDDInput(fix, 0); + conditionsInput.click(); + tick(100); + fix.detectChanges(); + + verifyGridSubmenuSize(gridNativeElement, ɵSize.Medium); + })); + + it('size is properly applied on the excel custom dialog\'s date expression dropdown', + fakeAsync(() => { + const gridNativeElement = fix.debugElement.query(By.css('igx-grid')).nativeElement; + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ReleaseDate'); + + GridFunctions.clickExcelFilterCascadeButton(fix); + fix.detectChanges(); + GridFunctions.clickOperatorFromCascadeMenu(fix, 0); + tick(200); + + // Click the left input to open the operators dropdown and verify its size. + let conditionsInput = GridFunctions.getExcelFilteringDDInput(fix, 0, true); + conditionsInput.click(); + tick(100); + fix.detectChanges(); + + verifyGridSubmenuSize(gridNativeElement, ɵSize.Large); + + GridFunctions.clickCancelExcelStyleCustomFiltering(fix); + tick(100); + fix.detectChanges(); + + // Change size + setElementSize(grid.nativeElement, ɵSize.Medium); + tick(200); + fix.detectChanges(); + + // Open excel style custom filtering dialog. + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ReleaseDate'); + + GridFunctions.clickExcelFilterCascadeButton(fix); + fix.detectChanges(); + GridFunctions.clickOperatorFromCascadeMenu(fix, 0); + tick(200); + + // Click the left input to open the operators dropdown and verify its size. + conditionsInput = GridFunctions.getExcelFilteringDDInput(fix, 0, true); + conditionsInput.click(); + tick(100); + fix.detectChanges(); + verifyGridSubmenuSize(gridNativeElement, ɵSize.Medium); + })); + + it('Should include \'false\' value in results when searching.', fakeAsync(() => { + // Open excel style custom filtering dialog. + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'Released'); + + // Type string in search box. + const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix); + const inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix, searchComponent); + UIInteractions.clickAndSendInputElementValue(inputNativeElement, 'false', fix); + tick(100); + fix.detectChanges(); + + // Verify that the first item is 'Select All' and the third item is 'false'. + const listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent); + expect(listItems.length).toBe(3, 'incorrect rendered list items count'); + expect(listItems[0].innerText).toBe('Select all search results'); + expect(listItems[2].innerText).toBe('False'); + })); + + it('should scroll items in search list correctly', (async () => { + // Add additional rows as prerequisite for the test + for (let index = 0; index < 30; index++) { + const newRow = { + Downloads: index, + ID: index + 100, + ProductName: 'New Product ' + index, + ReleaseDate: new Date(), + Released: false, + AnotherField: 'z' + }; + grid.addRow(newRow); + } + fix.detectChanges(); + + setElementSize(grid.nativeElement, ɵSize.Small); + await wait(100); + fix.detectChanges(); + + // Open excel style filtering component + GridFunctions.clickExcelFilterIcon(fix, 'ProductName'); + await wait(100); + fix.detectChanges(); + + // Scroll the search list to the bottom. + const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix); + const scrollbar = GridFunctions.getExcelStyleSearchComponentScrollbar(fix); + scrollbar.scrollTop = 3000; + await wait(100); + fix.detectChanges(); + + // Verify scrollbar's scrollTop. + expect(scrollbar.scrollTop >= 660 && scrollbar.scrollTop <= 700).toBe(true, + 'search scrollbar has incorrect scrollTop: ' + scrollbar.scrollTop); + // Verify display container height. + const displayContainer = searchComponent.querySelector('igx-display-container'); + const displayContainerRect = displayContainer.getBoundingClientRect(); + const listHeight = searchComponent.querySelector('igx-list').getBoundingClientRect().height; + const itemHeight = displayContainer.querySelector('igx-list-item').getBoundingClientRect().height; + expect(displayContainerRect.height > listHeight + itemHeight && displayContainerRect.height < listHeight + (itemHeight * 2)).toBe(true, 'incorrect search display container height'); + // Verify rendered list items count. + const listItems = displayContainer.querySelectorAll('igx-list-item'); + expect(listItems.length).toBe(Math.ceil(listHeight / itemHeight) + 1, 'incorrect rendered list items count'); + })); + + it('should correctly display all items in search list after filtering it', (async () => { + // Add additional rows as prerequisite for the test + for (let index = 0; index < 4; index++) { + const newRow = { + Downloads: index, + ID: index + 100, + ProductName: 'New Sales Product ' + index, + ReleaseDate: new Date(), + Released: false, + AnotherField: 'z' + }; + grid.addRow(newRow); + } + fix.detectChanges(); + + // Open excel style filtering component + GridFunctions.clickExcelFilterIcon(fix, 'ProductName'); + await wait(200); + fix.detectChanges(); + + // Scroll the search list to the middle. + const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix); + const displayContainer = searchComponent.querySelector('igx-display-container') as HTMLElement; + const scrollbar = GridFunctions.getExcelStyleSearchComponentScrollbar(fix); + scrollbar.scrollTop = displayContainer.getBoundingClientRect().height / 2; + await wait(200); + fix.detectChanges(); + await wait(100); + + // Type string in search box + const inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix, searchComponent); + UIInteractions.clickAndSendInputElementValue(inputNativeElement, 'sale', fix); + await wait(200); + fix.detectChanges(); + + // Verify the display container is within the bounds of the list + const displayContainerRect = displayContainer.getBoundingClientRect(); + const listNativeElement = searchComponent.querySelector('.igx-list'); + const listRect = listNativeElement.getBoundingClientRect(); + expect(displayContainerRect.top >= listRect.top).toBe(true, 'displayContainer starts above list'); + expect(displayContainerRect.bottom <= listRect.bottom).toBe(true, 'displayContainer ends below list'); + })); + + it('Should not treat \'Select All\' as a search result.', async () => { + GridFunctions.clickExcelFilterIconFromCodeAsync(fix, grid, 'ProductName'); + fix.detectChanges(); + await wait(100); + + const excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix, excelMenu); + const input = GridFunctions.getExcelStyleSearchComponentInput(fix, searchComponent); + let listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent); + expect(listItems.length).toBe(6); + + UIInteractions.clickAndSendInputElementValue(input, 'a', fix); + await wait(100); + listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent); + expect(listItems.length).toBe(5); + + UIInteractions.clickAndSendInputElementValue(input, 'al', fix); + await wait(100); + listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent); + expect(listItems.length).toBe(0); + }); + + it('Column formatter should skip the \'SelectAll\' list item', fakeAsync(() => { + grid.columnList.get(4).formatter = (val: Date) => new Intl.DateTimeFormat('bg-BG').format(val); + grid.cdr.detectChanges(); + + // Open excel style filtering component + try { + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ReleaseDate'); + } catch (ex) { + expect(ex).toBeNull(); + } + + const listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix); + expect(listItems[0].innerText).toBe('Select All'); + })); + + it('should keep newly added filter expression in view', fakeAsync(() => { + GridFunctions.clickExcelFilterIcon(fix, 'ProductName'); + tick(100); + fix.detectChanges(); + + GridFunctions.clickExcelFilterCascadeButton(fix); + fix.detectChanges(); + GridFunctions.clickOperatorFromCascadeMenu(fix, 0); + tick(200); + + // Click 'Add Filter' button. + GridFunctions.clickAddFilterExcelStyleCustomFiltering(fix); + tick(200); + + // Verify last expression is currently in view inside the expressions container. + const customFilterMenu = GridFunctions.getExcelStyleCustomFilteringDialog(fix); + const expressionsContainer = customFilterMenu.querySelector('.igx-excel-filter__secondary-main'); + const expressions = GridFunctions.getExcelCustomFilteringDefaultExpressions(fix); + const lastExpression = expressions[expressions.length - 1]; + const lastExpressionRect = lastExpression.getBoundingClientRect(); + const expressionsContainerRect = expressionsContainer.getBoundingClientRect(); + expect(lastExpressionRect.top >= expressionsContainerRect.top).toBe(true, + 'lastExpression starts above expressionsContainer'); + expect(lastExpressionRect.bottom <= expressionsContainerRect.bottom).toBe(true, + 'lastExpression ends below expressionsContainer'); + + // Verify addFilter button is currently in view beneath the last expression. + const addFilterButton = GridFunctions.getAddFilterExcelStyleCustomFiltering(fix); + const addFilterButtonRect = addFilterButton.getBoundingClientRect(); + expect(addFilterButtonRect.top >= lastExpressionRect.bottom).toBe(true, + 'addFilterButton overlaps lastExpression'); + expect(addFilterButtonRect.bottom <= expressionsContainerRect.bottom).toBe(true, + 'addFilterButton ends below expressionsContainer'); + })); + + it('Should generate "equals" conditions when selecting two values.', fakeAsync(() => { + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + + const excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + const checkbox = GridFunctions.getExcelStyleFilteringCheckboxes(fix, excelMenu); + + checkbox[0].click(); // Select All + tick(); + fix.detectChanges(); + + checkbox[2].click(); // Ignite UI for Angular + checkbox[3].click(); // Ignite UI for JavaScript + tick(); + fix.detectChanges(); + + GridFunctions.clickApplyExcelStyleFiltering(fix, excelMenu); + tick(); + fix.detectChanges(); + + expect(grid.rowList.length).toBe(2); + const operands = + (grid.filteringExpressionsTree.filteringOperands[0] as IFilteringExpressionsTree) + .filteringOperands as IFilteringExpression[]; + expect(operands.length).toBe(2); + verifyFilteringExpression(operands[0], 'ProductName', 'equals', 'Ignite UI for Angular'); + verifyFilteringExpression(operands[1], 'ProductName', 'equals', 'Ignite UI for JavaScript'); + })); + + it('Should generate "in" condition when selecting more than two values.', fakeAsync(() => { + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + + const excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + const checkbox = GridFunctions.getExcelStyleFilteringCheckboxes(fix, excelMenu); + + checkbox[0].click(); // Select All + tick(); + fix.detectChanges(); + + checkbox[2].click(); // Ignite UI for Angular + checkbox[3].click(); // Ignite UI for JavaScript + checkbox[4].click(); // NetAdvantage + tick(); + fix.detectChanges(); + + GridFunctions.clickApplyExcelStyleFiltering(fix, excelMenu); + tick(); + fix.detectChanges(); + + expect(grid.rowList.length).toBe(3); + const operands = + (grid.filteringExpressionsTree.filteringOperands[0] as IFilteringExpressionsTree) + .filteringOperands as IFilteringExpression[]; + expect(operands.length).toBe(1); + verifyFilteringExpression(operands[0], 'ProductName', 'in', + new Set(['Ignite UI for Angular', 'Ignite UI for JavaScript', 'NetAdvantage'])); + })); + + it('Should not throw error when selecting more than two values and column dataType is date.', fakeAsync(() => { + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ReleaseDate'); + + const excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + const checkbox = GridFunctions.getExcelStyleFilteringCheckboxes(fix, excelMenu); + + checkbox[0].click(); // Select All + tick(); + fix.detectChanges(); + + checkbox[2].click(); + checkbox[3].click(); + checkbox[4].click(); + checkbox[6].click(); + tick(); + fix.detectChanges(); + + expect(() => { + GridFunctions.clickApplyExcelStyleFiltering(fix, excelMenu); + }).not.toThrowError(); + })); + + it('Should generate "in" and "empty" conditions when selecting more than two values including (Blanks).', fakeAsync(() => { + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + + const excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + const checkbox = GridFunctions.getExcelStyleFilteringCheckboxes(fix, excelMenu); + + checkbox[0].click(); // Select All + tick(); + fix.detectChanges(); + + checkbox[1].click(); // (Blanks) + checkbox[2].click(); // Ignite UI for Angular + checkbox[3].click(); // Ignite UI for JavaScript + tick(); + fix.detectChanges(); + + GridFunctions.clickApplyExcelStyleFiltering(fix, excelMenu); + tick(); + fix.detectChanges(); + + expect(grid.rowList.length).toBe(6); + const operands = + (grid.filteringExpressionsTree.filteringOperands[0] as IFilteringExpressionsTree) + .filteringOperands as IFilteringExpression[]; + expect(operands.length).toBe(2); + verifyFilteringExpression(operands[0], 'ProductName', 'in', + new Set(['Ignite UI for Angular', 'Ignite UI for JavaScript'])); + verifyFilteringExpression(operands[1], 'ProductName', 'empty', null); + })); + + it('should not display search scrollbar when not needed for the current size', (async () => { + grid.columnSelection = GridSelectionMode.multiple; + fix.detectChanges(); + + grid.getCellByColumn(3, 'ProductName').update('Test'); + fix.detectChanges(); + + // Verify scrollbar is visible for 'comfortable'. + GridFunctions.clickExcelFilterIcon(fix, 'ProductName'); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + expect(isExcelSearchScrollBarVisible(fix)).toBe(true, 'excel search scrollbar should be visible'); + GridFunctions.clickApplyExcelStyleFiltering(fix); + fix.detectChanges(); + + setElementSize(grid.nativeElement, ɵSize.Medium); + await wait(100); + fix.detectChanges(); + + // Verify scrollbar is NOT visible for 'cosy'. + GridFunctions.clickExcelFilterIcon(fix, 'ProductName'); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + expect(isExcelSearchScrollBarVisible(fix)).toBe(false, 'excel search scrollbar should NOT be visible'); + GridFunctions.clickApplyExcelStyleFiltering(fix); + fix.detectChanges(); + + setElementSize(grid.nativeElement, ɵSize.Small); + await wait(100); + fix.detectChanges(); + + // Verify scrollbar is NOT visible for 'compact'. + GridFunctions.clickExcelFilterIcon(fix, 'ProductName'); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + expect(isExcelSearchScrollBarVisible(fix)).toBe(false, 'excel search scrollbar should NOT be visible'); + })); + + it('Should cascade filter the available filter options.', fakeAsync(() => { + GridFunctions.clickExcelFilterIcon(fix, 'Downloads'); + tick(100); + fix.detectChanges(); + + verifyExcelStyleFilterAvailableOptions(fix, + ['Select All', '(Blanks)', '0', '20', '100', '127', '254'], + [true, true, true, true, true, true, true]); + + GridFunctions.clickExcelFilterIcon(fix, 'ProductName'); + tick(100); + fix.detectChanges(); + verifyExcelStyleFilterAvailableOptions(fix, + ['Select All', '(Blanks)', 'Ignite UI for Angular', 'Ignite UI for JavaScript', + 'NetAdvantage', 'Some other item with Script'], + [true, true, true, true, true, true]); + + GridFunctions.clickExcelFilterIcon(fix, 'Released'); + tick(100); + fix.detectChanges(); + verifyExcelStyleFilterAvailableOptions(fix, + ['Select All', '(Blanks)', 'False', 'True'], + [true, true, true, true]); + + toggleExcelStyleFilteringItems(fix, true, 3); + + expect(grid.rowList.length).toBe(5); + + GridFunctions.clickExcelFilterIcon(fix, 'Released'); + tick(100); + fix.detectChanges(); + verifyExcelStyleFilterAvailableOptions(fix, + ['Select All', '(Blanks)', 'False', 'True'], + [null, true, true, false]); + + GridFunctions.clickExcelFilterIcon(fix, 'Downloads'); + tick(100); + fix.detectChanges(); + verifyExcelStyleFilterAvailableOptions(fix, + ['Select All', '20', '100', '254', '702', '1,000'], + [true, true, true, true, true, true]); + + GridFunctions.clickExcelFilterIcon(fix, 'ProductName'); + tick(100); + fix.detectChanges(); + verifyExcelStyleFilterAvailableOptions(fix, + ['Select All', '(Blanks)', 'Ignite UI for Angular', 'Ignite UI for JavaScript', 'Some other item with Script'], + [true, true, true, true, true]); + + toggleExcelStyleFilteringItems(fix, false, 0); + toggleExcelStyleFilteringItems(fix, true, 2, 3); + + expect(grid.rowList.length).toBe(2); + + GridFunctions.clickExcelFilterIcon(fix, 'Released'); + tick(100); + fix.detectChanges(); + verifyExcelStyleFilterAvailableOptions(fix, + ['Select All', '(Blanks)', 'False'], + [true, true, true]); + + GridFunctions.clickExcelFilterIcon(fix, 'ProductName'); + tick(100); + fix.detectChanges(); + verifyExcelStyleFilterAvailableOptions(fix, + ['Select All', '(Blanks)', 'Ignite UI for Angular', 'Ignite UI for JavaScript', 'Some other item with Script'], + [null, false, true, true, false]); + + GridFunctions.clickExcelFilterIcon(fix, 'Downloads'); + tick(100); + fix.detectChanges(); + verifyExcelStyleFilterAvailableOptions(fix, + ['Select All', '20', '254'], + [true, true, true]); + })); + + it('Should correctly modify existing filters.', fakeAsync(() => { + GridFunctions.clickExcelFilterIcon(fix, 'ProductName'); + tick(100); + fix.detectChanges(); + verifyExcelStyleFilterAvailableOptions(fix, + ['Select All', '(Blanks)', 'Ignite UI for Angular', 'Ignite UI for JavaScript', + 'NetAdvantage', 'Some other item with Script'], + [true, true, true, true, true, true]); + toggleExcelStyleFilteringItems(fix, true, 1, 5); + expect(grid.rowList.length).toBe(3); + + GridFunctions.clickExcelFilterIcon(fix, 'Downloads'); + tick(100); + fix.detectChanges(); + verifyExcelStyleFilterAvailableOptions(fix, + ['Select All', '20', '127', '254'], + [true, true, true, true]); + toggleExcelStyleFilteringItems(fix, true, 2); + expect(grid.rowList.length).toBe(2); + + GridFunctions.clickExcelFilterIcon(fix, 'ProductName'); + tick(100); + fix.detectChanges(); + verifyExcelStyleFilterAvailableOptions(fix, + ['Select All', 'Ignite UI for Angular', 'Ignite UI for JavaScript'], + [true, true, true]); + + GridFunctions.clickExcelFilterIcon(fix, 'Downloads'); + tick(100); + fix.detectChanges(); + verifyExcelStyleFilterAvailableOptions(fix, + ['Select All', '20', '127', '254'], + [null, true, false, true]); + toggleExcelStyleFilteringItems(fix, true, 1, 2, 3); + expect(grid.rowList.length).toBe(1); + + GridFunctions.clickExcelFilterIcon(fix, 'Downloads'); + tick(100); + fix.detectChanges(); + verifyExcelStyleFilterAvailableOptions(fix, + ['Select All', '20', '127', '254'], + [null, false, true, false]); + })); + + it('Should correctly modify the first one of the existing filters.', fakeAsync(() => { + GridFunctions.clickExcelFilterIcon(fix, 'ProductName'); + tick(100); + fix.detectChanges(); + verifyExcelStyleFilterAvailableOptions(fix, + ['Select All', '(Blanks)', 'Ignite UI for Angular', 'Ignite UI for JavaScript', + 'NetAdvantage', 'Some other item with Script'], + [true, true, true, true, true, true]); + toggleExcelStyleFilteringItems(fix, true, 1, 5); + expect(grid.rowList.length).toBe(3); + + GridFunctions.clickExcelFilterIcon(fix, 'Downloads'); + tick(100); + fix.detectChanges(); + verifyExcelStyleFilterAvailableOptions(fix, + ['Select All', '20', '127', '254'], + [true, true, true, true]); + toggleExcelStyleFilteringItems(fix, true, 2); + expect(grid.rowList.length).toBe(2); + + GridFunctions.clickExcelFilterIcon(fix, 'ProductName'); + tick(100); + fix.detectChanges(); + verifyExcelStyleFilterAvailableOptions(fix, + ['Select All', 'Ignite UI for Angular', 'Ignite UI for JavaScript'], + [true, true, true]); + toggleExcelStyleFilteringItems(fix, true, 1); + expect(grid.rowList.length).toBe(1); + + GridFunctions.clickExcelFilterIcon(fix, 'Downloads'); + tick(100); + fix.detectChanges(); + verifyExcelStyleFilterAvailableOptions(fix, + ['Select All', '254'], + [true, true]); + + GridFunctions.clickExcelFilterIcon(fix, 'ProductName'); + tick(100); + fix.detectChanges(); + verifyExcelStyleFilterAvailableOptions(fix, + ['Select All', 'Ignite UI for Angular', 'Ignite UI for JavaScript'], + [null, false, true]); + })); + + it('Should display the ESF based on the filterIcon within the grid', async () => { + // Test prerequisites + grid.width = '800px'; + for (const column of grid.columnList) { + column.width = '300px'; + } + await wait(16); + fix.detectChanges(); + + // Scroll a bit to the right, so the ProductName column is not fully visible. + GridFunctions.scrollLeft(grid, 500); + await wait(100); + fix.detectChanges(); + + GridFunctions.clickExcelFilterIcon(fix, 'ProductName'); + fix.detectChanges(); + + // Verify that the left, top and right borders of the ESF are within the grid. + const gridNativeElement = fix.debugElement.query(By.css('igx-grid')).nativeElement; + const gridRect = gridNativeElement.getBoundingClientRect(); + const excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + const excelMenuRect = excelMenu.getBoundingClientRect(); + expect(excelMenuRect.left >= gridRect.left).toBe(true, 'ESF spans outside the grid on the left'); + expect(excelMenuRect.top >= gridRect.top).toBe(true, 'ESF spans outside the grid on the top'); + expect(excelMenuRect.right <= gridRect.right).toBe(true, 'ESF spans outside the grid on the right'); + }); + + it('Should add/remove expressions in custom filter dialog through UI correctly.', fakeAsync(() => { + // Open excel style custom filtering dialog. + GridFunctions.clickExcelFilterIcon(fix, 'ProductName'); + tick(100); + fix.detectChanges(); + GridFunctions.clickExcelFilterCascadeButton(fix); + fix.detectChanges(); + GridFunctions.clickOperatorFromCascadeMenu(fix, 0); + tick(200); + + // Verify expressions count. + let expressions = GridFunctions.getExcelCustomFilteringDefaultExpressions(fix); + expect(expressions.length).toBe(2); + + // Add two new expressions. + GridFunctions.clickAddFilterExcelStyleCustomFiltering(fix); + tick(100); + fix.detectChanges(); + GridFunctions.clickAddFilterExcelStyleCustomFiltering(fix); + tick(100); + fix.detectChanges(); + + // Verify expressions count. + expressions = GridFunctions.getExcelCustomFilteringDefaultExpressions(fix); + expect(expressions.length).toBe(4); + + // Remove last expression by clicking its remove icon. + let expr: any = expressions[3]; + let removeIcon: any = Array.from(expr.querySelectorAll('igx-icon')) + .find((icon: any) => icon.innerText === 'cancel'); + removeIcon.click(); + fix.detectChanges(); + + // Verify expressions count. + expressions = Array.from(GridFunctions.getExcelCustomFilteringDefaultExpressions(fix)); + expect(expressions.length).toBe(3); + + // Remove second expression by clicking its remove icon. + expr = expressions[1]; + removeIcon = Array.from(expr.querySelectorAll('igx-icon')) + .find((icon: any) => icon.innerText === 'cancel'); + removeIcon.click(); + fix.detectChanges(); + + // Verify expressions count. + expressions = GridFunctions.getExcelCustomFilteringDefaultExpressions(fix); + expect(expressions.length).toBe(2); + })); + + it('Should keep selected operator of custom expression the same when clicking it.', fakeAsync(() => { + // Open excel style custom filtering dialog. + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + + GridFunctions.clickExcelFilterCascadeButton(fix); + fix.detectChanges(); + GridFunctions.clickOperatorFromCascadeMenu(fix, 0); + tick(200); + + // Verify 'And' button is selected on first expression. + let andButton = GridFunctions.getExcelCustomFilteringExpressionAndButton(fix); + ControlsFunction.verifyButtonIsSelected(andButton); + + // Click the 'And' button. + andButton.click(); + tick(100); + fix.detectChanges(); + + // Verify that selected button remains the same. + andButton = GridFunctions.getExcelCustomFilteringExpressionAndButton(fix); + ControlsFunction.verifyButtonIsSelected(andButton); + + const orButton = GridFunctions.getExcelCustomFilteringExpressionOrButton(fix); + ControlsFunction.verifyButtonIsSelected(orButton, false); + })); + + it('Should select the button operator in custom expression when pressing \'Enter\' on it.', async () => { + // Open excel style custom filtering dialog. + GridFunctions.clickExcelFilterIconFromCodeAsync(fix, grid, 'ProductName'); + + GridFunctions.clickExcelFilterCascadeButton(fix); + fix.detectChanges(); + GridFunctions.clickOperatorFromCascadeMenu(fix, 0); + await wait(200); + + const andButton = GridFunctions.getExcelCustomFilteringExpressionAndButton(fix); + const orButton = GridFunctions.getExcelCustomFilteringExpressionOrButton(fix); + + // Verify 'and' is selected. + ControlsFunction.verifyButtonIsSelected(andButton); + ControlsFunction.verifyButtonIsSelected(orButton, false); + + // Press 'Enter' on 'or' button and verify it gets selected. + UIInteractions.triggerKeyDownEvtUponElem('Enter', orButton, true); + await wait(); + fix.detectChanges(); + + ControlsFunction.verifyButtonIsSelected(andButton, false); + ControlsFunction.verifyButtonIsSelected(orButton); + + // Press 'Enter' on 'and' button and verify it gets selected. + UIInteractions.triggerKeyDownEvtUponElem('Enter', andButton, true); + await wait(); + fix.detectChanges(); + + ControlsFunction.verifyButtonIsSelected(andButton); + ControlsFunction.verifyButtonIsSelected(orButton, false); + }); + + it('Should open conditions dropdown of custom expression with \'Alt + Arrow Down\'.', fakeAsync(() => { + // Open excel style custom filtering dialog. + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + + GridFunctions.clickExcelFilterCascadeButton(fix); + fix.detectChanges(); + GridFunctions.clickOperatorFromCascadeMenu(fix, 0); + tick(200); + + const expr = GridFunctions.getExcelCustomFilteringDefaultExpressions(fix)[0]; + const conditionsInput = GridFunctions.getExcelFilteringDDInput(fix); + + // Dropdown should be hidden. + let operatorsDropdownToggle = expr.querySelector('.igx-toggle--hidden'); + expect(operatorsDropdownToggle).not.toBeNull(); + + // Press 'Alt + Arrow Down' to open operators dropdown. + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', conditionsInput, true, true); + tick(100); + fix.detectChanges(); + + // Dropdown should be visible. + operatorsDropdownToggle = expr.querySelector('.igx-toggle--hidden'); + expect(operatorsDropdownToggle).toBeNull(); + + // Click-off to close dropdown. + expr.click(); + tick(100); + fix.detectChanges(); + + // Dropdown should be hidden. + operatorsDropdownToggle = expr.querySelector('.igx-toggle--hidden'); + expect(operatorsDropdownToggle).not.toBeNull(); + })); + + it('Should open calendar when clicking date-picker of custom expression.', fakeAsync(() => { + // Open excel style custom filtering dialog. + GridFunctions.clickExcelFilterIcon(fix, 'ReleaseDate'); + tick(100); + fix.detectChanges(); + GridFunctions.clickExcelFilterCascadeButton(fix); + fix.detectChanges(); + GridFunctions.clickOperatorFromCascadeMenu(fix, 0); + tick(200); + + const expr = GridFunctions.getExcelCustomFilteringDateExpressions(fix)[0]; + const datePicker = expr.querySelector('igx-date-picker'); + + // Verify calendar is not opened. + let calendar = expr.querySelector('igx-calendar'); + expect(calendar).toBeNull(); + + const input = datePicker.querySelector('input'); + UIInteractions.simulateClickEvent(input); + fix.detectChanges(); + expect(datePicker).not.toBeNull(); + + // Verify calendar is opened. + calendar = grid.nativeElement.querySelector('igx-calendar'); + expect(calendar).not.toBeNull(); + + // Click-off to close calendar. + expr.click(); + tick(100); + fix.detectChanges(); + + // Verify calendar is not opened. + calendar = fix.debugElement.query(By.directive(IgxCalendarComponent)); + expect(calendar).toBeNull(); + })); + + it('Should filter grid through custom date filter dialog.', fakeAsync(() => { + const column = grid.getColumnByName('ReleaseDate'); + // Open excel style custom filtering dialog. + GridFunctions.clickExcelFilterIcon(fix, 'ReleaseDate'); + tick(100); + fix.detectChanges(); + GridFunctions.clickExcelFilterCascadeButton(fix); + fix.detectChanges(); + + expect(grid.nativeElement.querySelector('.igx-excel-filter__filter-number').textContent).not.toContain('('); + GridFunctions.clickOperatorFromCascadeMenu(fix, 0); + tick(200); + + const expr = GridFunctions.getExcelCustomFilteringDateExpressions(fix)[0]; + const datePicker = expr.querySelector('igx-date-picker'); + const input = datePicker.querySelector('input'); + UIInteractions.simulateClickEvent(input); + fix.detectChanges(); + + // Click today item. + const calendar = document.querySelector('igx-calendar'); + const todayItem = calendar.querySelector('.igx-days-view__date--current'); + UIInteractions.simulateClickAndSelectEvent(todayItem.firstChild); + tick(100); + fix.detectChanges(); + flush(); + + // Verify the results are with 'today' date. + const filteredDate = SampleTestData.today; + const datePickerDebugEl = fix.debugElement.query(By.directive(IgxDatePickerComponent)); + expect(datePickerDebugEl.componentInstance.value).toEqual(filteredDate); + + // Click 'apply' button to apply filter. + GridFunctions.clickApplyExcelStyleCustomFiltering(fix); + + const pipe = new DatePipe(grid.locale); + const cellText = pipe.transform(filteredDate, column.pipeArgs.format, column.pipeArgs.timezone); + const cell = GridFunctions.getColumnCells(fix, 'ReleaseDate')[0].nativeElement; + expect(cell.innerText).toMatch(cellText); + expect(grid.filteredData.length).toEqual(1); + })); + + it('Should take pipeArgs weekStart property as calendar\'s default.', fakeAsync(() => { + const column = grid.getColumnByName('ReleaseDate'); + + column.pipeArgs = { + digitsInfo: '3.4-4', + currencyCode: 'USD', + display: 'symbol-narrow', + weekStart: 5, + }; + fix.detectChanges(); + + // Open excel style custom filtering dialog. + GridFunctions.clickExcelFilterIcon(fix, 'ReleaseDate'); + tick(100); + fix.detectChanges(); + GridFunctions.clickExcelFilterCascadeButton(fix); + fix.detectChanges(); + GridFunctions.clickOperatorFromCascadeMenu(fix, 0); + tick(200); + + const expr = GridFunctions.getExcelCustomFilteringDateExpressions(fix)[0]; + const datePicker = expr.querySelector('igx-date-picker'); + const input = datePicker.querySelector('input'); + UIInteractions.simulateClickEvent(input); + fix.detectChanges(); + + // Get Calendar component. + const calendar = document.querySelector('igx-calendar'); + + const daysOfWeek = calendar.querySelector('.igx-days-view__row'); + const weekStart = daysOfWeek.firstElementChild as HTMLSpanElement; + + expect(weekStart.innerText).toMatch('Fri'); + })); + + it('Should filter grid with ISO 8601 dates through custom date filter dialog', fakeAsync(() => { + fix.componentInstance.data = SampleTestData.excelFilteringData().map(rec => { + const newRec = Object.assign({}, rec) as any; + newRec.ReleaseDate = rec.ReleaseDate ? rec.ReleaseDate.toISOString() : null; + return newRec; + }); + fix.detectChanges(); + const column = grid.getColumnByName('ReleaseDate'); + + // Open excel style custom filtering dialog. + GridFunctions.clickExcelFilterIcon(fix, 'ReleaseDate'); + tick(100); + fix.detectChanges(); + GridFunctions.clickExcelFilterCascadeButton(fix); + fix.detectChanges(); + GridFunctions.clickOperatorFromCascadeMenu(fix, 0); + tick(200); + + const expr = GridFunctions.getExcelCustomFilteringDateExpressions(fix)[0]; + const datePicker = expr.querySelector('igx-date-picker'); + const input = datePicker.querySelector('input'); + UIInteractions.simulateClickEvent(input); + fix.detectChanges(); + + // Click today item. + const calendar = document.querySelector('igx-calendar'); + const todayItem = calendar.querySelector('.igx-days-view__date--current'); + UIInteractions.simulateClickAndSelectEvent(todayItem.firstChild); + tick(100); + fix.detectChanges(); + flush(); + + // Verify the results are with 'today' date. + const filteredDate = SampleTestData.today; + const datePickerDebugEl = fix.debugElement.query(By.directive(IgxDatePickerComponent)); + expect(datePickerDebugEl.componentInstance.value).toEqual(filteredDate); + + // Click 'apply' button to apply filter. + GridFunctions.clickApplyExcelStyleCustomFiltering(fix); + + const pipe = new DatePipe(grid.locale); + const cellText = pipe.transform(filteredDate, column.pipeArgs.format, column.pipeArgs.timezone); + const cell = GridFunctions.getColumnCells(fix, 'ReleaseDate')[0].nativeElement; + expect(cell.innerText).toMatch(cellText); + expect(grid.filteredData.length).toEqual(1); + })); + + it('Should filter grid with milliseconds dates through custom date filter dialog', fakeAsync(() => { + fix.componentInstance.data = SampleTestData.excelFilteringData().map(rec => { + const newRec = Object.assign({}, rec) as any; + newRec.ReleaseDate = rec.ReleaseDate ? rec.ReleaseDate.getTime() : null; + return newRec; + }); + fix.detectChanges(); + const column = grid.getColumnByName('ReleaseDate'); + + // Open excel style custom filtering dialog. + GridFunctions.clickExcelFilterIcon(fix, 'ReleaseDate'); + tick(100); + fix.detectChanges(); + GridFunctions.clickExcelFilterCascadeButton(fix); + fix.detectChanges(); + GridFunctions.clickOperatorFromCascadeMenu(fix, 0); + tick(200); + + const expr = GridFunctions.getExcelCustomFilteringDateExpressions(fix)[0]; + const datePicker = expr.querySelector('igx-date-picker'); + const input = datePicker.querySelector('input'); + UIInteractions.simulateClickEvent(input); + fix.detectChanges(); + + // Click today item. + const calendar = document.querySelector('igx-calendar'); + const todayItem = calendar.querySelector('.igx-days-view__date--current'); + UIInteractions.simulateClickAndSelectEvent(todayItem.firstChild); + tick(100); + fix.detectChanges(); + flush(); + + // Verify the results are with 'today' date. + const filteredDate = SampleTestData.today; + const datePickerDebugEl = fix.debugElement.query(By.directive(IgxDatePickerComponent)); + expect(datePickerDebugEl.componentInstance.value).toEqual(filteredDate); + + // Click 'apply' button to apply filter. + GridFunctions.clickApplyExcelStyleCustomFiltering(fix); + + const pipe = new DatePipe(grid.locale); + const cellText = pipe.transform(filteredDate, column.pipeArgs.format, column.pipeArgs.timezone); + const cell = GridFunctions.getColumnCells(fix, 'ReleaseDate')[0].nativeElement; + expect(cell.innerText).toMatch(cellText); + expect(grid.filteredData.length).toEqual(1); + })); + + it('DateTime: Should use editorOptions.dateTimeFormat as inputFormat to the filter editor in the custom filtering dialog', fakeAsync(() => { + fix.componentInstance.data = SampleTestData.excelFilteringData().map(rec => { + const newRec = Object.assign({}, rec) as any; + newRec.ReleaseDateTime = rec.ReleaseDateTime ? rec.ReleaseDateTime.toISOString() : null; + return newRec; + }); + const column = grid.getColumnByName('ReleaseDateTime'); + column.editorOptions = { + dateTimeFormat: 'dd-MM-yyyy HH:mm aaaaa' + } + fix.detectChanges(); + + GridFunctions.clickExcelFilterIcon(fix, 'ReleaseDateTime'); + tick(100); + fix.detectChanges(); + GridFunctions.clickExcelFilterCascadeButton(fix); + fix.detectChanges(); + GridFunctions.clickOperatorFromCascadeMenu(fix, 0); + tick(200); + + const dateTimeEditor = fix.debugElement.query(By.directive(IgxDateTimeEditorDirective)) + .injector.get(IgxDateTimeEditorDirective); + expect(dateTimeEditor.inputFormat).toMatch(column.editorOptions.dateTimeFormat); + expect(dateTimeEditor.displayFormat).toMatch(column.pipeArgs.format); + })); + + it('DateTime: Should use pipeArgs.format as inputFormat to the filter editor in the custom filtering dialog if editorOptions.dateTimeFormat not set', fakeAsync(() => { + fix.componentInstance.data = SampleTestData.excelFilteringData().map(rec => { + const newRec = Object.assign({}, rec) as any; + newRec.ReleaseDateTime = rec.ReleaseDateTime ? rec.ReleaseDateTime.toISOString() : null; + return newRec; + }); + const column = grid.getColumnByName('ReleaseDateTime'); + column.pipeArgs = { + format: 'dd-MM-yyyy' + } + fix.detectChanges(); + + GridFunctions.clickExcelFilterIcon(fix, 'ReleaseDateTime'); + tick(100); + fix.detectChanges(); + GridFunctions.clickExcelFilterCascadeButton(fix); + fix.detectChanges(); + GridFunctions.clickOperatorFromCascadeMenu(fix, 0); + tick(200); + + const dateTimeEditorDirective = fix.debugElement.query(By.directive(IgxDateTimeEditorDirective)) + .injector.get(IgxDateTimeEditorDirective); + expect(dateTimeEditorDirective.inputFormat.normalize('NFKC')).toMatch('dd-MM-yyyy'); + expect(dateTimeEditorDirective.displayFormat.normalize('NFKC')).toMatch('dd-MM-yyyy'); + })); + + it('DateTime: custom filtering dialog input locale should be set as the grid locale', fakeAsync(() => { + registerLocaleData(localeBg, 'bg'); + grid.locale = 'bg'; + fix.componentInstance.data = SampleTestData.excelFilteringData().map(rec => { + const newRec = Object.assign({}, rec) as any; + newRec.ReleaseDateTime = rec.ReleaseDateTime ? rec.ReleaseDateTime.toISOString() : null; + return newRec; + }); + + GridFunctions.clickExcelFilterIcon(fix, 'ReleaseDateTime'); + tick(100); + fix.detectChanges(); + GridFunctions.clickExcelFilterCascadeButton(fix); + fix.detectChanges(); + GridFunctions.clickOperatorFromCascadeMenu(fix, 0); + tick(200); + + const dateTimeEditorDirective = fix.debugElement.query(By.directive(IgxDateTimeEditorDirective)) + .injector.get(IgxDateTimeEditorDirective); + expect(dateTimeEditorDirective.locale).toMatch(grid.locale); + })); + + it('Time: Should use editorOptions.dateTimeFormat as inputFormat to the filter editor in the custom filtering dialog', fakeAsync(() => { + const column = grid.getColumnByName('ReleaseTime'); + column.editorOptions = { + dateTimeFormat: 'HH:mm' + } + fix.detectChanges(); + + GridFunctions.clickExcelFilterIcon(fix, 'ReleaseTime'); + tick(100); + fix.detectChanges(); + GridFunctions.clickExcelFilterCascadeButton(fix); + fix.detectChanges(); + GridFunctions.clickOperatorFromCascadeMenu(fix, 0); + tick(200); + + const dateTimeEditorDirective = fix.debugElement.query(By.directive(IgxDateTimeEditorDirective)) + .injector.get(IgxDateTimeEditorDirective); + expect(dateTimeEditorDirective.inputFormat).toMatch(column.editorOptions.dateTimeFormat); + expect(dateTimeEditorDirective.displayFormat).toMatch(column.pipeArgs.format); + })); + + it('Time: Should use pipeArgs.format as inputFormat to the filter editor in the custom filtering dialog if editorOptions.dateTimeFormat not set', fakeAsync(() => { + const column = grid.getColumnByName('ReleaseTime'); + column.pipeArgs = { + format: 'HH:mm' + } + fix.detectChanges(); + + GridFunctions.clickExcelFilterIcon(fix, 'ReleaseTime'); + tick(100); + fix.detectChanges(); + GridFunctions.clickExcelFilterCascadeButton(fix); + fix.detectChanges(); + GridFunctions.clickOperatorFromCascadeMenu(fix, 0); + tick(200); + + const dateTimeEditorDirective = fix.debugElement.query(By.directive(IgxDateTimeEditorDirective)) + .injector.get(IgxDateTimeEditorDirective); + expect(dateTimeEditorDirective.inputFormat).toMatch(column.pipeArgs.format); + expect(dateTimeEditorDirective.displayFormat).toMatch(column.pipeArgs.format); + })); + it('Should filter grid through custom date filter dialog when using pipeArgs for the column', fakeAsync(() => { + fix.componentInstance.data = SampleTestData.excelFilteringData().map(rec => { + const newRec = Object.assign({}, rec) as any; + newRec.ReleaseDate = rec.ReleaseDate ? rec.ReleaseDate.toISOString() : null; + return newRec; + }); + + const formatOptions = { + timezone: 'utc', + }; + const column = grid.getColumnByName('ReleaseDate'); + column.pipeArgs = formatOptions; + fix.detectChanges(); + + // Open excel style custom filtering dialog. + GridFunctions.clickExcelFilterIcon(fix, 'ReleaseDate'); + tick(100); + fix.detectChanges(); + GridFunctions.clickExcelFilterCascadeButton(fix); + fix.detectChanges(); + GridFunctions.clickOperatorFromCascadeMenu(fix, 0); + tick(200); + + const expr = GridFunctions.getExcelCustomFilteringDateExpressions(fix)[0]; + const datePicker = expr.querySelector('igx-date-picker'); + const input = datePicker.querySelector('input'); + UIInteractions.simulateClickEvent(input); + fix.detectChanges(); + + // Click today item. + const calendar = document.querySelector('igx-calendar'); + const todayItem = calendar.querySelector('.igx-days-view__date--current'); + UIInteractions.simulateClickAndSelectEvent(todayItem.firstChild); + tick(100); + fix.detectChanges(); + flush(); + + // Verify the results are with 'today' date. + const filteredDate = SampleTestData.today; + const datePickerDebugEl = fix.debugElement.query(By.directive(IgxDatePickerComponent)); + expect(datePickerDebugEl.componentInstance.value).toEqual(filteredDate); + + // Click 'apply' button to apply filter. + GridFunctions.clickApplyExcelStyleCustomFiltering(fix); + + const pipe = new DatePipe(grid.locale); + const cellText = pipe.transform(filteredDate, column.pipeArgs.format, column.pipeArgs.timezone); + const cell = GridFunctions.getColumnCells(fix, 'ReleaseDate')[0].nativeElement; + expect(cell.innerText).toMatch(cellText); + expect(grid.filteredData.length).toEqual(1); + })); + + it('Should filter grid through custom date filter dialog when using pipeArgs and formatter for the column', fakeAsync(() => { + const pipe = new DatePipe('fr-FR'); + const formatOptions = { + timezone: 'utc', + format: 'longDate' + }; + const column = grid.getColumnByName('ReleaseDate'); + column.pipeArgs = formatOptions; + grid.getColumnByName('ReleaseDate').formatter = ((value: any) => { + const val = value !== null && value !== undefined && value !== '' ? pipe.transform(value, 'longDate') : 'No value!'; + return val; + }); + fix.detectChanges(); + + // Open excel style custom filtering dialog. + GridFunctions.clickExcelFilterIcon(fix, 'ReleaseDate'); + tick(100); + fix.detectChanges(); + GridFunctions.clickExcelFilterCascadeButton(fix); + fix.detectChanges(); + GridFunctions.clickOperatorFromCascadeMenu(fix, 0); + tick(200); + + const expr = GridFunctions.getExcelCustomFilteringDateExpressions(fix)[0]; + const datePicker = expr.querySelector('igx-date-picker'); + const input = datePicker.querySelector('input'); + UIInteractions.simulateClickEvent(input); + fix.detectChanges(); + tick(350); // calendar animationDone timeout + + // Click today item. + const calendar = document.querySelector('igx-calendar'); + const todayItem = calendar.querySelector('.igx-days-view__date--current'); + UIInteractions.simulateClickAndSelectEvent(todayItem.firstChild); + tick(); + fix.detectChanges(); + + // Verify the results are with 'today' date. + const filteredDate = SampleTestData.today; + const datePickerDebugEl = fix.debugElement.query(By.directive(IgxDatePickerComponent)); + expect(datePickerDebugEl.componentInstance.value).toEqual(filteredDate); + + // Click 'apply' button to apply filter. + GridFunctions.clickApplyExcelStyleCustomFiltering(fix); + + const cellText = column.formatter(filteredDate, null); + const cell = GridFunctions.getColumnCells(fix, 'ReleaseDate')[0].nativeElement; + expect(cell.innerText).toMatch(cellText); + expect(grid.filteredData.length).toEqual(1); + })); + + it('Should correctly update \'SelectAll\' based on checkboxes.', fakeAsync(() => { + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + + const visibleListItems = GridFunctions.getExcelStyleFilteringCheckboxes(fix); + const thirdItemCbInput = visibleListItems[2]; + + // Verify 'Select All' checkbox is not indeterminate. + const selectAllCheckbox = visibleListItems[0]; + ControlsFunction.verifyCheckboxState(selectAllCheckbox.parentElement); + + // Uncheck third list item. + UIInteractions.simulateClickEvent(thirdItemCbInput); + tick(100); + fix.detectChanges(); + + // Verify 'Select All' checkbox is indeterminate. + ControlsFunction.verifyCheckboxState(selectAllCheckbox.parentElement, true, true); + + // Check third list item again. + UIInteractions.simulateClickEvent(thirdItemCbInput); + tick(100); + fix.detectChanges(); + + // Verify 'Select All' checkbox is not indeterminate. + ControlsFunction.verifyCheckboxState(selectAllCheckbox.parentElement); + })); + + it('Should correctly update all items based on \'SelectAll\' checkbox.', fakeAsync(() => { + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + + const visibleListItems = GridFunctions.getExcelStyleFilteringCheckboxes(fix); + const dataListItems = visibleListItems.slice(1, visibleListItems.length); + + // Verify all visible data list items are checked. + for (const dataListItem of dataListItems) { + ControlsFunction.verifyCheckboxState(dataListItem.parentElement); + } + + // Click 'Select All' checkbox. + let selectAllCbInput = visibleListItems[0]; + UIInteractions.simulateClickEvent(selectAllCbInput); + tick(100); + fix.detectChanges(); + + // Verify all visible data list items are unchecked. + for (const dataListItem of dataListItems) { + ControlsFunction.verifyCheckboxState(dataListItem.parentElement, false); + } + + // Click 'Select All' checkbox. + selectAllCbInput = visibleListItems[0]; + UIInteractions.simulateClickEvent(selectAllCbInput); + tick(100); + fix.detectChanges(); + + // Verify all visible data list items are checked. + for (const dataListItem of dataListItems) { + ControlsFunction.verifyCheckboxState(dataListItem.parentElement); + } + })); + + it('Should correctly update all \'SelectAll\' checkbox when not a single item is checked.', async () => { + GridFunctions.clickExcelFilterIconFromCodeAsync(fix, grid, 'Released'); + fix.detectChanges(); + await wait(100); + + const visibleListItems = GridFunctions.getExcelStyleFilteringCheckboxes(fix); + expect(visibleListItems.length).toBe(4); + + // Verify 'Select All' checkbox is checked. + ControlsFunction.verifyCheckboxState(visibleListItems[0].parentElement); + + + // Uncheck second, third and fourth list items. + const secondListItemCbInput = visibleListItems[1]; + const thirdListItemCbInput = visibleListItems[2]; + const fourthListItemCbInput = visibleListItems[3]; + secondListItemCbInput.click(); + fix.detectChanges(); + thirdListItemCbInput.click(); + fix.detectChanges(); + fourthListItemCbInput.click(); + fix.detectChanges(); + + // Verify 'Select All' checkbox is unchecked. + ControlsFunction.verifyCheckboxState(visibleListItems[0].parentElement, false); + }); + + it('Should open custom filter dropdown when pressing \'Enter\' on custom filter cascade button.', fakeAsync(() => { + grid.width = '700px'; + fix.detectChanges(); + + GridFunctions.clickExcelFilterIcon(fix, 'AnotherField'); + tick(100); + fix.detectChanges(); + + const cascadeButton = GridFunctions.getExcelFilterCascadeButton(fix); + + // Verify that custom filter dropdown (the submenu) is not visible. + let subMenu = fix.nativeElement.querySelector('.igx-drop-down__list.igx-toggle--hidden'); + expect(subMenu).not.toBeNull(); + + cascadeButton.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + tick(100); + fix.detectChanges(); + + + // Verify that custom filter dropdown (the submenu) is visible. + subMenu = fix.nativeElement.querySelector('.igx-drop-down__list.igx-toggle--hidden'); + expect(subMenu).toBeNull(); + })); + + it('Should close ESF when pressing \'Escape\'.', fakeAsync(() => { + let excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + // Verify ESF is not visible. + expect(excelMenu).toBeNull(); + + GridFunctions.clickExcelFilterIcon(fix, 'Released'); + tick(100); + fix.detectChanges(); + + // Verify ESF is visible. + excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + expect(excelMenu).not.toBeNull(); + + excelMenu.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + tick(100); + fix.detectChanges(); + + // Verify ESF is not visible. + excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + expect(excelMenu).toBeNull(); + })); + + it('Should open/close ESF menu for respective column when pressing \'ctrl + shift + l\' on its filterCell chip.', + fakeAsync(() => { + let excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + // Verify ESF is not visible. + expect(excelMenu).toBeNull(); + + const downloadsColumn = GridFunctions.getColumnHeader('Downloads', fix); + UIInteractions.simulateClickAndSelectEvent(downloadsColumn); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('l', downloadsColumn.nativeElement, true, false, true, true); + tick(200); + fix.detectChanges(); + + // Verify ESF menu is opened for the 'Downloads' column. + excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + expect(excelMenu).not.toBeNull(); + + // Press the combination again. + UIInteractions.triggerKeyDownEvtUponElem('l', excelMenu, true, false, true, true); + tick(200); + fix.detectChanges(); + + excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + expect(excelMenu).toBeNull(); + })); + + it('Should filter ISO 8601 dates for date column ignoring the time portion - issue #14643', fakeAsync(() => { + // Add hours part to the ReleaseDate so some records differ only by the time portion + fix.componentInstance.data = SampleTestData.excelFilteringData().map(rec => { + const newRec = Object.assign({}, rec) as any; + + if (rec.ReleaseDate) { + const date = new Date(rec.ReleaseDate); + date.setHours(date.getHours() + Math.floor(Math.random() * 24)); + newRec.ReleaseDate = date.toISOString(); + } else { + newRec.ReleaseDate = null; + } + + return newRec; + }); + fix.detectChanges(); + + GridFunctions.clickExcelFilterIcon(fix, 'ReleaseDate'); + tick(100); + fix.detectChanges(); + + const excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + const checkbox = GridFunctions.getExcelStyleFilteringCheckboxes(fix, excelMenu)[1]; + + checkbox.click(); + tick(); + fix.detectChanges(); + + const applyButton = GridFunctions.getApplyButtonExcelStyleFiltering(fix); + applyButton.focus(); + fix.detectChanges(); + + expect(document.activeElement).toBe(applyButton); + + UIInteractions.simulateClickEvent(applyButton); + fix.detectChanges(); + + const rows = GridFunctions.getRows(fix); + const cell1 = GridFunctions.getRowCells(fix, 0)[4].nativeElement; + const cell2 = GridFunctions.getRowCells(fix, 3)[4].nativeElement; + expect(cell1.textContent.toString()).toEqual(cell2.textContent.toString()); + expect(rows.length).toBe(6, 'incorrect number of rows'); + + //Check if checkboxes have correct state on ESF menu reopening + GridFunctions.clickExcelFilterIcon(fix, 'ReleaseDate'); + tick(100); + fix.detectChanges(); + + const checkboxes: any[] = Array.from(GridFunctions.getExcelStyleFilteringCheckboxes(fix)); + expect(checkboxes[0].indeterminate).toBeTrue(); + expect(checkboxes[1].checked).toBeFalse(); + const listItemsCheckboxes = checkboxes.slice(2, checkboxes.length - 1); + for (const checkboxItem of listItemsCheckboxes) { + ControlsFunction.verifyCheckboxState(checkboxItem.parentElement); + } + })); + + it('Should filter date by input string', fakeAsync(() => { + GridFunctions.clickExcelFilterIcon(fix, 'ReleaseDate'); + tick(100); + fix.detectChanges(); + + const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix); + let listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent); + const inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix, searchComponent); + + const todayDateFull = SampleTestData.today; + const todayDate = todayDateFull.getDate().toString(); + const dayOfWeek = todayDateFull.toString().substring(0, 3); + + UIInteractions.clickAndSendInputElementValue(inputNativeElement, todayDate, fix); + tick(100); + fix.detectChanges(); + + listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent); + expect(listItems.length).toBeGreaterThan(2); + for (let i = 2; i < listItems.length; i++) { + expect(listItems[i].textContent.toString().indexOf(todayDate) > -1).toBeTruthy(); + } + + UIInteractions.clickAndSendInputElementValue(inputNativeElement, dayOfWeek, fix); + tick(100); + fix.detectChanges(); + + listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent); + expect(listItems.length).toBe(0, 'incorrect rendered list items count'); + })); + + it('Should filter ISO 8601 date by input string', fakeAsync(() => { + fix.componentInstance.data = SampleTestData.excelFilteringData().map(rec => { + const newRec = Object.assign({}, rec) as any; + newRec.ReleaseDate = rec.ReleaseDate ? rec.ReleaseDate.toISOString() : null; + return newRec; + }); + fix.detectChanges(); + + GridFunctions.clickExcelFilterIcon(fix, 'ReleaseDate'); + tick(100); + fix.detectChanges(); + + const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix); + let listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent); + const inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix, searchComponent); + + const todayDateFull = SampleTestData.today; + const todayDate = todayDateFull.getDate().toString(); + const dayOfWeek = todayDateFull.toString().substring(0, 3); + + UIInteractions.clickAndSendInputElementValue(inputNativeElement, todayDate, fix); + tick(100); + fix.detectChanges(); + + listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent); + expect(listItems.length).toBeGreaterThan(2); + for (let i = 2; i < listItems.length; i++) { + expect(listItems[i].textContent.toString().indexOf(todayDate) > -1).toBeTruthy(); + } + + UIInteractions.clickAndSendInputElementValue(inputNativeElement, dayOfWeek, fix); + tick(100); + fix.detectChanges(); + + listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent); + expect(listItems.length).toBe(0, 'incorrect rendered list items count'); + })); + + it('Should filter milliseconds date by input string', fakeAsync(() => { + fix.componentInstance.data = SampleTestData.excelFilteringData().map(rec => { + const newRec = Object.assign({}, rec) as any; + newRec.ReleaseDate = rec.ReleaseDate ? rec.ReleaseDate.getTime() : null; + return newRec; + }); + fix.detectChanges(); + + GridFunctions.clickExcelFilterIcon(fix, 'ReleaseDate'); + tick(100); + fix.detectChanges(); + + const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix); + let listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent); + const inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix, searchComponent); + + const todayDateFull = SampleTestData.today; + const todayDate = todayDateFull.getDate().toString(); + const dayOfWeek = todayDateFull.toString().substring(0, 3); + + UIInteractions.clickAndSendInputElementValue(inputNativeElement, todayDate, fix); + tick(100); + fix.detectChanges(); + + listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent); + expect(listItems.length).toBeGreaterThan(2); + for (let i = 2; i < listItems.length; i++) { + expect(listItems[i].textContent.toString().indexOf(todayDate) > -1).toBeTruthy(); + } + + UIInteractions.clickAndSendInputElementValue(inputNativeElement, dayOfWeek, fix); + tick(100); + fix.detectChanges(); + + listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent); + expect(listItems.length).toBe(0, 'incorrect rendered list items count'); + })); + + it('Should ignore duplicate records when column\'s filteringIgnoreCase is true', fakeAsync(() => { + const column = grid.getColumnByName('AnotherField'); + expect(column.filteringIgnoreCase).toBeTrue(); + + GridFunctions.clickExcelFilterIcon(fix, 'AnotherField'); + tick(100); + fix.detectChanges(); + + verifyExcelStyleFilterAvailableOptions(fix, + ['Select All', 'a', 'Custom'], + [true, true, true]); + })); + + it('Should not ignore duplicate records when column\'s filteringIgnoreCase is false', fakeAsync(() => { + const column = grid.getColumnByName('AnotherField'); + column.filteringIgnoreCase = false; + expect(column.filteringIgnoreCase).toBeFalse(); + + GridFunctions.clickExcelFilterIcon(fix, 'AnotherField'); + tick(100); + fix.detectChanges(); + + verifyExcelStyleFilterAvailableOptions(fix, + ['Select All', 'a', 'Custom', 'custoM', 'custom'], + [true, true, true, true, true]); + })); + + it('Should display "Add to current filter selection" button on typing in input', fakeAsync(() => { + // Open excel style filtering dialog. + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'Downloads'); + // Type string in search box. + const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix); + const inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix, searchComponent); + UIInteractions.clickAndSendInputElementValue(inputNativeElement, '2', fix); + tick(100); + fix.detectChanges(); + + // Verify that the second item is 'Add to current filter selection'. + const listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent); + expect(listItems.length).toBe(6, 'incorrect rendered list items count'); + expect(listItems[1].innerText).toBe('Add current selection to filter'); + })); + + it('Should filter grid the same way as in Excel', fakeAsync(() => { + // Open excel style custom filtering dialog. + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'Downloads'); + + // Type string in search box. + let searchComponent = GridFunctions.getExcelStyleSearchComponent(fix); + let inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix, searchComponent); + UIInteractions.clickAndSendInputElementValue(inputNativeElement, '2', fix); + tick(100); + fix.detectChanges(); + + let listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent) + .splice(2) + .map(c => c.innerText) + .sort(); + + // Click 'apply' button to apply filter. + GridFunctions.clickApplyExcelStyleFiltering(fix); + tick(100); + fix.detectChanges(); + + // Get the results and verify that they match the list items. + let gridCellValues = GridFunctions.getColumnCells(fix, 'Downloads') + .map(c => c.nativeElement.innerText) + .sort(); + + expect(gridCellValues.length).toEqual(4); + expect(gridCellValues).toEqual(listItems); + + // Open excel style custom filtering dialog again. + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'Downloads'); + + // Type string in search box. + searchComponent = GridFunctions.getExcelStyleSearchComponent(fix); + inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix, searchComponent); + UIInteractions.clickAndSendInputElementValue(inputNativeElement, '5', fix); + tick(100); + fix.detectChanges(); + + listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent) + .splice(2) + .map(c => c.innerText) + .sort(); + + // Click 'apply' button to apply filter. + GridFunctions.clickApplyExcelStyleFiltering(fix); + tick(100); + fix.detectChanges(); + + // Get the results and verify that they match the list items. + gridCellValues = GridFunctions.getColumnCells(fix, 'Downloads') + .map(c => c.nativeElement.innerText) + .sort(); + + expect(gridCellValues.length).toEqual(1); + expect(gridCellValues).toEqual(listItems); + })); + + it('Should filter grid correctly with case insensitive duplicates', fakeAsync(() => { + grid.data = SampleTestData.excelFilteringDataDuplicateValues(); + fix.detectChanges(); + // Open excel style custom filtering dialog. + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'AnotherField'); + + // Type string in search box. + const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix); + const inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix, searchComponent); + UIInteractions.clickAndSendInputElementValue(inputNativeElement, 'cust', fix); + tick(100); + fix.detectChanges(); + + // Click 'apply' button to apply filter. + GridFunctions.clickApplyExcelStyleFiltering(fix); + tick(100); + fix.detectChanges(); + + // Get the results and verify their count. + const gridCellValues = GridFunctions.getColumnCells(fix, 'AnotherField') + .map(c => c.nativeElement.innerText) + .sort(); + + expect(gridCellValues.length).toEqual(5); + })); + + it('Should disable the apply button when there are no results.', fakeAsync(() => { + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'Downloads'); + + const applyButton = GridFunctions.getApplyButtonExcelStyleFiltering(fix) as HTMLElement; + const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix); + const inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix, searchComponent); + + UIInteractions.clickAndSendInputElementValue(inputNativeElement, '3', fix); + tick(100); + fix.detectChanges(); + + ControlsFunction.verifyButtonIsDisabled(applyButton); + })); + + it('Should be able to navigate inside the search list items', fakeAsync(() => { + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'Downloads'); + const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix); + const list = searchComponent.querySelector('igx-list'); + list.dispatchEvent(new Event('focus')); + tick(DEBOUNCE_TIME); + fix.detectChanges(); + const listItems = list.querySelectorAll('igx-list-item'); + + // we expect only the first list item to be active when the list is focused + expect(listItems[0].classList.contains("igx-list__item-base--active")).toBeTrue(); + expect(listItems[1].classList.contains("igx-list__item-base--active")).toBeFalse(); + + // on arrow down the second item should be active + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', list, true); + fix.detectChanges(); + expect(listItems[0].classList.contains("igx-list__item-base--active")).toBeFalse(); + expect(listItems[1].classList.contains("igx-list__item-base--active")).toBeTrue(); + + // on arrow up the first item should be active again + UIInteractions.triggerKeyDownEvtUponElem('arrowup', list, true); + fix.detectChanges(); + expect(listItems[0].classList.contains("igx-list__item-base--active")).toBeTrue(); + expect(listItems[1].classList.contains("igx-list__item-base--active")).toBeFalse(); + + // on home the first item should be active + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', list, true); + fix.detectChanges(); + expect(listItems[1].classList.contains("igx-list__item-base--active")).toBeTrue(); + UIInteractions.triggerKeyDownEvtUponElem('home', list, true); + fix.detectChanges(); + expect(listItems[0].classList.contains("igx-list__item-base--active")).toBeTrue(); + + // on space key on the first item (select all) all the checkbox should deselect + let checkboxes = list.querySelectorAll('igx-checkbox'); + let checkboxesStatus = Array.from(checkboxes).map((checkbox: Element) => checkbox.querySelector('input').checked); + checkboxesStatus.forEach(status => { + expect(status).toBeTrue(); + }); + UIInteractions.triggerKeyDownEvtUponElem('space', list, true); + fix.detectChanges(); + checkboxes = list.querySelectorAll('igx-checkbox'); + checkboxesStatus = Array.from(checkboxes).map((checkbox: Element) => checkbox.querySelector('input').checked); + checkboxesStatus.forEach(status => { + expect(status).toBeFalse(); + }); + })); + + it('Should not lose focus with arrowUp/arrowDown when navigating inside search list', fakeAsync(() => { + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'Downloads'); + const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix); + const list = searchComponent.querySelector('igx-list'); + list.dispatchEvent(new Event('focus')); + tick(DEBOUNCE_TIME); + fix.detectChanges(); + const listItems = list.querySelectorAll('igx-list-item'); + + // we expect only the first list item to be active when the list is focused + expect(listItems[0].classList.contains("igx-list__item-base--active")).toBeTrue(); + expect(listItems[1].classList.contains("igx-list__item-base--active")).toBeFalse(); + + // on arrow up the focus should stay on the first element and not on the search input + UIInteractions.triggerKeyDownEvtUponElem('arrowup', list, true); + fix.detectChanges(); + expect(listItems[0].classList.contains("igx-list__item-base--active")).toBeTrue(); + })); + + it('Should add list items to current filtered items when "Add to current filter selection" is selected.', fakeAsync(() => { + const totalListItems = []; + + // Open excel style custom filtering dialog. + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'Downloads'); + + // Type string in search box. + let searchComponent = GridFunctions.getExcelStyleSearchComponent(fix); + let inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix, searchComponent); + UIInteractions.clickAndSendInputElementValue(inputNativeElement, '5', fix); + fix.detectChanges(); + tick(); + + let listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent) + .splice(2) + .map(c => c.innerText); + + listItems.forEach(c => totalListItems.push(c)); + + // Click 'apply' button to apply filter. + GridFunctions.clickApplyExcelStyleFiltering(fix); + fix.detectChanges(); + tick(); + + // Get the results and verify that they match the list items. + let gridCellValues = GridFunctions.getColumnCells(fix, 'Downloads') + .map(c => c.nativeElement.innerText); + + expect(gridCellValues.length).toEqual(1); + expect(gridCellValues).toEqual(totalListItems); + + // Open excel style custom filtering dialog again. + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'Downloads'); + + // Type string in search box. + searchComponent = GridFunctions.getExcelStyleSearchComponent(fix); + inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix, searchComponent); + UIInteractions.clickAndSendInputElementValue(inputNativeElement, '7', fix); + fix.detectChanges(); + tick(); + + listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent) + .splice(2) + .map(c => c.innerText); + + listItems.forEach(c => totalListItems.push(c)); + totalListItems.sort(); + + // Select 'Add to current filter selection'. + const checkbox = GridFunctions.getExcelStyleFilteringCheckboxes(fix); + checkbox[1].click(); + fix.detectChanges(); + tick(); + + // Click 'apply' button to apply filter. + GridFunctions.clickApplyExcelStyleFiltering(fix); + fix.detectChanges(); + tick(); + + // Get the results and verify that they match the list items. + gridCellValues = GridFunctions.getColumnCells(fix, 'Downloads') + .map(c => c.nativeElement.innerText) + .sort(); + + expect(gridCellValues.length).toEqual(3); + expect(gridCellValues).toEqual(totalListItems); + + // Open excel style custom filtering dialog again. + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'Downloads'); + + fix.detectChanges(); + tick(); + + // Get checkboxes and verify 'Select All' is indeterminate. + const excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + const checkboxes: any[] = Array.from(GridFunctions.getExcelStyleFilteringCheckboxes(fix, excelMenu)); + expect(checkboxes[0].indeterminate).toBeTrue(); + })); + + it('Should commit and close ESF on pressing \'Enter\'', fakeAsync(() => { + // Open excel style filtering dialog. + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'Downloads'); + + let excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + + // Verify ESF is visible. + expect(excelMenu).not.toBeNull(); + + // Type string in search box. + const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix); + const inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix, searchComponent); + UIInteractions.clickAndSendInputElementValue(inputNativeElement, '2', fix); + tick(100); + fix.detectChanges(); + + const listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent) + .splice(2) + .map(c => c.innerText) + .sort(); + + // Press 'Enter' + inputNativeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + tick(100); + fix.detectChanges(); + + const gridCellValues = GridFunctions.getColumnCells(fix, 'Downloads') + .map(c => c.nativeElement.innerText) + .sort(); + + // Verify that excel style filtering dialog is closed and data is filtered. + excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + expect(excelMenu).toBeNull(); + expect(gridCellValues.length).toEqual(4); + expect(gridCellValues).toEqual(listItems); + })); + + it('Should clear input if there is text and \'Escape\' is pressed.', fakeAsync(() => { + // Open excel style filtering dialog. + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'Downloads'); + + let inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix); + let excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + + // Verify ESF is visible. + expect(excelMenu).not.toBeNull(); + + // Verify that the dialog is closed on pressing Escape. + inputNativeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + fix.detectChanges(); + flush(); + + excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + expect(excelMenu).toBeNull(); + + // Open excel style filtering dialog again and type in the input. + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'Downloads'); + inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix); + + UIInteractions.clickAndSendInputElementValue(inputNativeElement, '2', fix); + + // Press Escape again and verify that ESF menu is still visible and the input is empty + inputNativeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix); + fix.detectChanges(); + flush(); + + excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + expect(excelMenu).not.toBeNull(); + expect(inputNativeElement.value).toBe('', 'input isn\'t cleared correctly'); + })); + + it('Should clear search criteria when selecting clear column filters option.', fakeAsync(() => { + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + + let checkboxes = GridFunctions.getExcelStyleFilteringCheckboxes(fix); + fix.detectChanges(); + + checkboxes[0].click(); + tick(); + fix.detectChanges(); + + checkboxes[2].click(); + tick(); + fix.detectChanges(); + + GridFunctions.clickApplyExcelStyleFiltering(fix); + tick(); + fix.detectChanges(); + expect(grid.filteredData.length).toEqual(1); + + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + flush(); + + let inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix); + UIInteractions.clickAndSendInputElementValue(inputNativeElement, 'Net', fix); + + const listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix); + expect(listItems.length).toBe(3, 'incorrect rendered list items count'); + + GridFunctions.clickClearFilterInExcelStyleFiltering(fix); + flush(); + expect(grid.filteredData).toBeNull(); + + inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix); + expect(inputNativeElement.value).toBe('', 'search criteria is not cleared'); + + checkboxes = GridFunctions.getExcelStyleFilteringCheckboxes(fix); + const listItemsCheckboxes = checkboxes.slice(1, checkboxes.length); + for (const checkbox of listItemsCheckboxes) { + ControlsFunction.verifyCheckboxState(checkbox.parentElement); + } + })); + + it('Should filter cell by its formatted data when using FormattedValueFilteringStrategy', async () => { + const formattedFilterStrategy = new FormattedValuesFilteringStrategy(); + grid.filterStrategy = formattedFilterStrategy; + const productNameFormatter = (value: string): string => { + const val = value ? value.toLowerCase() : ''; + if (val.includes('script')) { + return 'Web'; + } else if (val.includes('netadvantage')) { + return 'Desktop'; + } else { + return 'Other'; + } + }; + grid.columnList.get(1).formatter = productNameFormatter; + + GridFunctions.clickExcelFilterIcon(fix, grid.columnList.get(1).field); + await wait(200); + fix.detectChanges(); + + const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix); + const inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix, searchComponent); + UIInteractions.clickAndSendInputElementValue(inputNativeElement, 'script', fix); + await wait(100); + fix.detectChanges(); + + let items = GridFunctions.getExcelStyleSearchComponentListItems(fix); + expect(items.length).toBe(0); + + UIInteractions.clickAndSendInputElementValue(inputNativeElement, 'web', fix); + await wait(100); + fix.detectChanges(); + items = GridFunctions.getExcelStyleSearchComponentListItems(fix); + expect(items.length).toBe(3); + verifyExcelStyleFilterAvailableOptions(fix, + ['Select all search results', 'Add current selection to filter', 'Web'], + [true, false, true]); + + inputNativeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await wait(100); + fix.detectChanges(); + const cellValues = GridFunctions.getColumnCells(fix, 'ProductName').map(c => c.nativeElement.innerText).sort(); + expect(cellValues).toEqual(['Web', 'Web']); + }); + + it('Should display the default True and False resource strings in the search list for boolean column.', async () => { + GridFunctions.clickExcelFilterIconFromCodeAsync(fix, grid, 'Released'); + fix.detectChanges(); + await wait(100); + + const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix); + const listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent); + + expect(listItems.length).toBe(4, 'incorrect rendered list items count'); + expect(listItems[2].innerText).toBe('False', 'incorrect list item label'); + expect(listItems[3].innerText).toBe('True', 'incorrect list item label'); + + const checkboxes = GridFunctions.getExcelStyleFilteringCheckboxes(fix); + checkboxes[3].click(); + fix.detectChanges(); + await wait(100); + + GridFunctions.clickApplyExcelStyleFiltering(fix); + fix.detectChanges(); + await wait(100); + + expect(grid.filteredData.length).toEqual(5); + }); + + it('Should display the custom resource strings when specified in the search list for boolean column.', async () => { + grid.resourceStrings.igx_grid_filter_false = 'No'; + grid.resourceStrings.igx_grid_filter_true = 'Yes'; + fix.detectChanges(); + + GridFunctions.clickExcelFilterIconFromCodeAsync(fix, grid, 'Released'); + fix.detectChanges(); + await wait(100); + + const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix); + const listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent); + + expect(listItems.length).toBe(4, 'incorrect rendered list items count'); + expect(listItems[2].innerText).toBe('No', 'incorrect list item label'); + expect(listItems[3].innerText).toBe('Yes', 'incorrect list item label'); + }); + + it('Should sort items in excel style search correctly', fakeAsync(() => { + const data = [ + { + Downloads: 254, + ID: 1, + ProductName: 'Ignite UI for JavaScript', + ReleaseDate: null, + ReleaseDateTime: null, + ReleaseTime: new Date(2010, 4, 27, 23, 0, 0), + Released: false, + AnotherField: 'BWord', + Revenue: 60000 + }, + { + Downloads: 127, + ID: 2, + ProductName: 'NetAdvantage', + ReleaseDate: null, + ReleaseDateTime: null, + ReleaseTime: new Date(2021, 4, 27, 1, 0, 0), + Released: true, + AnotherField: 'bWord', + Revenue: 50000 + }, + { + Downloads: 20, + ID: 3, + ProductName: 'Ignite UI for Angular', + ReleaseDate: null, + ReleaseDateTime: null, + ReleaseTime: new Date(2015, 4, 27, 12, 0, 0), + Released: null, + AnotherField: 'aWord', + Revenue: 100000 + } + ]; + fix.componentInstance.data = data; + fix.detectChanges(); + + // Open excel style custom filtering dialog for string column + GridFunctions.clickExcelFilterIcon(fix, 'AnotherField'); + tick(100); + fix.detectChanges(); + + // Verify items order is case INsensitive + verifyExcelStyleFilterAvailableOptions(fix, + ['Select All', 'aWord', 'BWord'], + [true, true, true]); + + // Open excel style custom filtering dialog for time column + GridFunctions.clickExcelFilterIcon(fix, 'ReleaseTime'); + tick(100); + fix.detectChanges(); + + // Verify items order is based only on time and not date + verifyExcelStyleFilterAvailableOptions(fix, + ['Select All', '1:00:00 AM', '12:00:00 PM', '11:00:00 PM'], + [true, true, true, true]); + })); + + it('should clear all filters in the custom dialog when clicking "Clear Filter" button', fakeAsync(() => { + GridFunctions.clickExcelFilterIcon(fix, 'ReleaseDate'); + tick(100); + fix.detectChanges(); + + GridFunctions.clickExcelFilterCascadeButton(fix); + tick(); + fix.detectChanges(); + + GridFunctions.clickOperatorFromCascadeMenu(fix, 5); + tick(); + fix.detectChanges(); + + const expressions = GridFunctions.getExcelCustomFilteringDateExpressions(fix); + const lastExpression = expressions[expressions.length - 1]; + (lastExpression.querySelector('igx-select').querySelector('igx-input-group') as HTMLElement).click(); + tick(); + fix.detectChanges(); + const dropdownList = fix.debugElement.query(By.css('div.igx-drop-down__list.igx-toggle')); + + const todayItem = dropdownList.children[0].children.find(item => item.nativeElement?.innerText === 'Today'); + todayItem.nativeElement.click(); + tick(); + fix.detectChanges(); + + GridFunctions.clickClearFilterExcelStyleCustomFiltering(fix); + tick(); + fix.detectChanges(); + + GridFunctions.getExcelCustomFilteringDateExpressions(fix).forEach(expr => { + const input = expr.children[0].querySelector('input'); + expect(input.value).toBe(''); + }); + })); + + it('should correctly filter negative decimal values in Excel Style filtering', fakeAsync(() => { + GridFunctions.clickExcelFilterIcon(fix, 'Downloads'); + tick(); + fix.detectChanges(); + + GridFunctions.clickExcelFilterCascadeButton(fix); + tick(); + fix.detectChanges(); + + GridFunctions.clickOperatorFromCascadeMenu(fix, 2); + tick(); + fix.detectChanges(); + + GridFunctions.setInputValueESF(fix, 0, '-1'); + tick(100); + fix.detectChanges(); + expect(GridFunctions.getExcelFilteringInput(fix, 0).value).toBe('-1'); + + const applyButton = GridFunctions.getApplyExcelStyleCustomFiltering(fix); + applyButton.click(); + tick(100); + fix.detectChanges(); + + expect(grid.filteredData.length).toBe(8); + + GridFunctions.clickExcelFilterIcon(fix, 'Downloads'); + tick(); + fix.detectChanges(); + + GridFunctions.clickExcelFilterCascadeButton(fix); + tick(); + fix.detectChanges(); + + GridFunctions.clickOperatorFromCascadeMenu(fix, 2); + tick(); + fix.detectChanges(); + + GridFunctions.setInputValueESF(fix, 0, '-0.1'); + tick(100); + fix.detectChanges(); + expect(GridFunctions.getExcelFilteringInput(fix, 0).value).toBe('-0.1'); + + applyButton.click(); + tick(100); + fix.detectChanges(); + + expect(grid.filteredData.length).toBe(8); + })); + }); + + describe('Templates: ', () => { + let fix; let grid; + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(IgxGridFilteringESFTemplatesComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + })); + + it('Should use custom templates for ESF components instead of default ones.', fakeAsync(() => { + fix = TestBed.createComponent(IgxGridFilteringESFEmptyTemplatesComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + + grid.columnSelection = GridSelectionMode.multiple; + fix.detectChanges(); + + const filterableColumns = grid.columnList.filter((c) => c.filterable === true); + for (const column of filterableColumns) { + // Open ESF. + GridFunctions.clickExcelFilterIcon(fix, column.field); + tick(100); + fix.detectChanges(); + const excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + + // Verify custom column operations template is used. + expect(excelMenu.querySelector('igx-excel-style-column-operations')).not.toBeNull(); + + // Verify custom filter operations template is used. + expect(excelMenu.querySelector('igx-excel-style-filter-operations')).not.toBeNull(); + + // Verify components in default ESF column operations template are not present. + expect(GridFunctions.getExcelFilteringHeaderComponent(fix, excelMenu)).toBeNull(); + expect(GridFunctions.getExcelFilteringSortComponent(fix, excelMenu)).toBeNull(); + expect(GridFunctions.getExcelFilteringMoveComponent(fix, excelMenu)).toBeNull(); + expect(GridFunctions.getExcelFilteringPinComponent(fix, excelMenu)).toBeNull(); + expect(GridFunctions.getExcelFilteringHideComponent(fix, excelMenu)).toBeNull(); + expect(GridFunctions.getExcelFilteringColumnSelectionComponent(fix, excelMenu)).toBeNull(); + + // Verify components in default ESF filter operations template are not present. + expect(GridFunctions.getExcelFilteringClearFiltersComponent(fix, excelMenu)).toBeNull(); + expect(GridFunctions.getExcelFilteringConditionalFilterComponent(fix, excelMenu)).toBeNull(); + expect(GridFunctions.getExcelFilteringSearchComponent(fix, excelMenu)).toBeNull(); + } + })); + + it('Should filter and clear the excel search component correctly from template', fakeAsync(() => { + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + + const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix); + let listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent); + expect(listItems.length).toBe(6, 'incorrect rendered list items count'); + + // Type string in search box. + const inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix, searchComponent); + UIInteractions.clickAndSendInputElementValue(inputNativeElement, 'ignite', fix); + tick(100); + fix.detectChanges(); + + listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent); + expect(listItems.length).toBe(4, 'incorrect rendered list items count'); + + // Clear filtering of ESF search. + const clearIcon: any = Array.from(searchComponent.querySelectorAll('igx-icon')) + .find((icon: any) => icon.innerText === 'clear'); + clearIcon.click(); + fix.detectChanges(); + + listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent); + expect(listItems.length).toBe(6, 'incorrect rendered list items count'); + tick(100); + })); + + it('Should move column left/right when clicking buttons from template', fakeAsync(() => { + grid.moving = true; + fix.detectChanges(); + + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'Downloads'); + + const moveLeft = GridFunctions.getExcelStyleFilteringMoveButtons(fix)[0]; + const moveRight = GridFunctions.getExcelStyleFilteringMoveButtons(fix)[1]; + + moveLeft.click(); + tick(); + fix.detectChanges(); + + expect(grid.columns[2].field).toBe('ProductName'); + expect(grid.columns[1].field).toBe('Downloads'); + + moveLeft.click(); + tick(); + fix.detectChanges(); + + expect(grid.columns[1].field).toBe('ID'); + expect(grid.columns[0].field).toBe('Downloads'); + ControlsFunction.verifyButtonIsDisabled(moveLeft); + + moveRight.click(); + tick(); + fix.detectChanges(); + + expect(grid.columns[0].field).toBe('ID'); + expect(grid.columns[1].field).toBe('Downloads'); + ControlsFunction.verifyButtonIsDisabled(moveLeft, false); + })); + + it('should select/deselect column in external ESF template when interact with the column selection item through esf menu', () => { + fix = TestBed.createComponent(IgxGridExternalESFTemplateComponent); + grid = fix.componentInstance.grid; + fix.detectChanges(); + + // Test in single multiple mode + grid.columnSelection = GridSelectionMode.multiple; + fix.detectChanges(); + + spyOn(grid.columnSelectionChanging, 'emit'); + const column = grid.getColumnByName('Downloads'); + fix.componentInstance.esf.column = column; + fix.detectChanges(); + + GridFunctions.clickColumnSelectionInExcelStyleFiltering(fix); + fix.detectChanges(); + + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifyColumnAndCellsSelected(column, true); + + GridFunctions.clickColumnSelectionInExcelStyleFiltering(fix); + fix.detectChanges(); + + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledTimes(2); + GridSelectionFunctions.verifyColumnAndCellsSelected(column, false); + + // Test in single selection mode + grid.columnSelection = GridSelectionMode.single; + fix.detectChanges(); + + grid.selectColumns(['ID']); + fix.detectChanges(); + + const columnId = grid.getColumnByName('ID'); + GridSelectionFunctions.verifyColumnAndCellsSelected(columnId); + + GridFunctions.clickColumnSelectionInExcelStyleFiltering(fix); + fix.detectChanges(); + + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledTimes(3); + GridSelectionFunctions.verifyColumnAndCellsSelected(column, true); + GridSelectionFunctions.verifyColumnAndCellsSelected(columnId, false); + + }); + + it('Should reset esf menu with templates on column change', fakeAsync(() => { + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'Downloads'); + flush(); + + let inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix); + UIInteractions.clickAndSendInputElementValue(inputNativeElement, 20, fix); + + const listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix); + expect(listItems.length).toBe(3, 'incorrect rendered list items count'); + + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + flush(); + + inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix); + expect(inputNativeElement.value).toBe('', 'input value didn\'t reset'); + })); + + it('Should reset blank items on column change.', fakeAsync(() => { + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + flush(); + + let listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix); + expect(listItems[1].innerText).toBe('(Blanks)'); + + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'AnotherField'); + flush(); + + listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix); + expect(listItems[1].innerText).not.toBe('(Blanks)'); + })); + + it('Should use custom excel style filter icon instead of default one.', fakeAsync(() => { + const header = GridFunctions.getColumnHeader('AnotherField', fix); + fix.detectChanges(); + const icon = GridFunctions.getHeaderFilterIcon(header); + fix.detectChanges(); + expect(icon).not.toBeNull(); + expect(icon.nativeElement.textContent.toLowerCase().trim()).toBe('filter_alt'); + })); + + it('should allow setting excel style filter icon via Input.', () => { + grid.excelStyleHeaderIconTemplate = fix.componentInstance.customExcelHeaderIcon; + fix.detectChanges(); + const header = GridFunctions.getColumnHeader('AnotherField', fix); + fix.detectChanges(); + const icon = GridFunctions.getHeaderFilterIcon(header); + fix.detectChanges(); + expect(icon.nativeElement.textContent.toLowerCase().trim()).toBe('search'); + }); + + it('Should reset list scroll position on filtered column change.', async () => { + // Add additional rows as prerequisite for the test + for (let index = 0; index < 10; index++) { + const newRow = { + Downloads: index, + ID: index + 100, + ProductName: 'New Product ' + index, + ReleaseDate: new Date(), + Released: false, + AnotherField: 'z' + }; + grid.addRow(newRow); + } + + fix.detectChanges(); + + // Select column + GridFunctions.clickExcelFilterIcon(fix, 'ProductName'); + await wait(); + fix.detectChanges(); + + // Scroll the search list to the bottom. + let scrollbar = GridFunctions.getExcelStyleSearchComponentScrollbar(fix); + expect(scrollbar.scrollTop).toBe(0); + const listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix); + expect(listItems[0].innerText).toBe('Select All'); + + scrollbar.scrollTop = 3000; + await wait(); + fix.detectChanges(); + expect(listItems[0].innerText).not.toBe('Select All'); + expect(scrollbar.scrollTop).toBeGreaterThan(300); + + // Select another column + GridFunctions.clickExcelFilterIcon(fix, 'Downloads'); + await wait(); + fix.detectChanges(); + + // Update scrollbar + scrollbar = GridFunctions.getExcelStyleSearchComponentScrollbar(fix); + expect(scrollbar.scrollTop).toBe(0, 'search scrollbar did not reset'); + }); + }); + + describe('Load values on demand', () => { + let fix; + let grid: IgxGridComponent; + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(IgxGridFilteringESFLoadOnDemandComponent); + grid = fix.componentInstance.grid; + fix.detectChanges(); + })); + + it('Verify unique values are loaded correctly in ESF search component.', fakeAsync(() => { + // Open excel style custom filtering dialog and wait a bit. + GridFunctions.clickExcelFilterIcon(fix, 'ProductName'); + tick(400); + fix.detectChanges(); + + // Verify items in search have not loaded yet and that the loading indicator is visible. + let listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix); + expect(listItems.length).toBe(0, 'incorrect rendered list items count'); + let loadingIndicator = GridFunctions.getExcelFilteringLoadingIndicator(fix); + expect(loadingIndicator).not.toBeNull('esf loading indicator is not visible'); + + // Wait for items to load. + tick(650); + + // Verify items in search have loaded and that the loading indicator is not visible. + listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix); + expect(listItems.length).toBe(6, 'incorrect rendered list items count'); + loadingIndicator = GridFunctions.getExcelFilteringLoadingIndicator(fix); + expect(loadingIndicator).toBeNull('esf loading indicator is visible'); + })); + + it('Verify unique values are loaded correctly in ESF search component when using filtering strategy.', fakeAsync(() => { + grid.uniqueColumnValuesStrategy = undefined; + grid.filterStrategy = new LoadOnDemandFilterStrategy(); + fix.detectChanges(); + + // Open excel style custom filtering dialog and wait a bit. + GridFunctions.clickExcelFilterIcon(fix, 'ProductName'); + tick(400); + fix.detectChanges(); + + // Verify items in search have not loaded yet and that the loading indicator is visible. + let listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix); + expect(listItems.length).toBe(0, 'incorrect rendered list items count'); + let loadingIndicator = GridFunctions.getExcelFilteringLoadingIndicator(fix); + expect(loadingIndicator).not.toBeNull('esf loading indicator is not visible'); + + // Wait for items to load. + tick(650); + + // Verify items in search have loaded and that the loading indicator is not visible. + listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix); + expect(listItems.length).toBe(6, 'incorrect rendered list items count'); + loadingIndicator = GridFunctions.getExcelFilteringLoadingIndicator(fix); + expect(loadingIndicator).toBeNull('esf loading indicator is visible'); + })); + + it('Verify unique date values are loaded correctly in ESF search component.', fakeAsync(() => { + // Open excel style custom filtering dialog and wait a bit. + GridFunctions.clickExcelFilterIcon(fix, 'ReleaseDate'); + tick(400); + fix.detectChanges(); + + // Verify items in search have not loaded yet and that the loading indicator is visible. + let listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix); + expect(listItems.length).toBe(0, 'incorrect rendered list items count'); + let loadingIndicator = GridFunctions.getExcelFilteringLoadingIndicator(fix); + expect(loadingIndicator).not.toBeNull('esf loading indicator is not visible'); + + // Wait for items to load. + tick(650); + + // Verify items in search have loaded and that the loading indicator is not visible. + listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix); + expect(listItems.length).toBe(7, 'incorrect rendered list items count'); + loadingIndicator = GridFunctions.getExcelFilteringLoadingIndicator(fix); + expect(loadingIndicator).toBeNull('esf loading indicator is visible'); + })); + + it('Verify unique ISO 8601 date values are loaded correctly in ESF search component.', fakeAsync(() => { + fix.componentInstance.data = SampleTestData.excelFilteringData().map(rec => { + const newRec = Object.assign({}, rec) as any; + newRec.ReleaseDate = rec.ReleaseDate ? rec.ReleaseDate.toISOString() : null; + return newRec; + }); + fix.detectChanges(); + + // Open excel style custom filtering dialog and wait a bit. + GridFunctions.clickExcelFilterIcon(fix, 'ReleaseDate'); + tick(400); + fix.detectChanges(); + + // Verify items in search have not loaded yet and that the loading indicator is visible. + let listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix); + expect(listItems.length).toBe(0, 'incorrect rendered list items count'); + let loadingIndicator = GridFunctions.getExcelFilteringLoadingIndicator(fix); + expect(loadingIndicator).not.toBeNull('esf loading indicator is not visible'); + + // Wait for items to load. + tick(650); + + // Verify items in search have loaded and that the loading indicator is not visible. + listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix); + expect(listItems.length).toBe(7, 'incorrect rendered list items count'); + loadingIndicator = GridFunctions.getExcelFilteringLoadingIndicator(fix); + expect(loadingIndicator).toBeNull('esf loading indicator is visible'); + })); + + it('Verify unique milliseconds date values are loaded correctly in ESF search component.', fakeAsync(() => { + fix.componentInstance.data = SampleTestData.excelFilteringData().map(rec => { + const newRec = Object.assign({}, rec) as any; + newRec.ReleaseDate = rec.ReleaseDate ? rec.ReleaseDate.getTime() : null; + return newRec; + }); + fix.detectChanges(); + + // Open excel style custom filtering dialog and wait a bit. + GridFunctions.clickExcelFilterIcon(fix, 'ReleaseDate'); + tick(400); + fix.detectChanges(); + + // Verify items in search have not loaded yet and that the loading indicator is visible. + let listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix); + expect(listItems.length).toBe(0, 'incorrect rendered list items count'); + let loadingIndicator = GridFunctions.getExcelFilteringLoadingIndicator(fix); + expect(loadingIndicator).not.toBeNull('esf loading indicator is not visible'); + + // Wait for items to load. + tick(650); + + // Verify items in search have loaded and that the loading indicator is not visible. + listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix); + expect(listItems.length).toBe(7, 'incorrect rendered list items count'); + loadingIndicator = GridFunctions.getExcelFilteringLoadingIndicator(fix); + expect(loadingIndicator).toBeNull('esf loading indicator is visible'); + })); + + it('Verify date values are displayed in correct format according to column pipeArgs', fakeAsync(() => { + const downloads = ['Select All', '(Blanks)', '0,00', '20,00', '100,00', '127,00', '254,00', '702,00', '1 000,00']; + fix.componentInstance.data = SampleTestData.excelFilteringData().map(rec => { + const newRec = Object.assign({}, rec) as any; + newRec.ReleaseDate = rec.ReleaseDate ? rec.ReleaseDate.getTime() : null; + return newRec; + }); + const dates = fix.componentInstance.data.filter(el => el.ReleaseDate).map(el => new Date(el.ReleaseDate)).sort((a, b) => a - b); + grid.locale = 'fr-FR'; + const datePipe = new DatePipe(grid.locale); + const formatOptions = { + format: 'longDate', + digitsInfo: '1.2-2' + }; + grid.getColumnByName('ReleaseDate').pipeArgs = formatOptions; + grid.getColumnByName('Downloads').pipeArgs = formatOptions; + fix.detectChanges(); + + // Open excel style custom filtering dialog and wait a bit. + GridFunctions.clickExcelFilterIcon(fix, 'ReleaseDate'); + tick(1050); + fix.detectChanges(); + + // Verify items in search have loaded and that the loading indicator is not visible. + let listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix); + expect(listItems.length).toBe(7, 'incorrect rendered list items count'); + + for (let i = 2; i < listItems.length; i++) { + const label = datePipe.transform(dates[i - 2], formatOptions.format); + expect(listItems[i].innerText).toBe(label); + } + + const loadingIndicator = GridFunctions.getExcelFilteringLoadingIndicator(fix); + expect(loadingIndicator).toBeNull('esf loading indicator is visible'); + + // Open excel style custom filtering dialog and wait a bit. + GridFunctions.clickExcelFilterIcon(fix, 'Downloads'); + tick(1050); + fix.detectChanges(); + + // Verify items in search have loaded and that the loading indicator is not visible. + listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix); + expect(listItems.length).toBe(7, 'incorrect rendered list items count'); + + listItems.forEach((item, ind) => { + expect(item.innerText).toBe(downloads[ind]); + }); + })); + + it('Verify date values are displayed in correct format according to column formatter', fakeAsync(() => { + fix.componentInstance.data = SampleTestData.excelFilteringData().map(rec => { + const newRec = Object.assign({}, rec) as any; + newRec.ReleaseDate = rec.ReleaseDate ? rec.ReleaseDate.getTime() : null; + return newRec; + }); + grid.locale = 'fr-FR'; + const datePipe = new DatePipe(grid.locale); + grid.getColumnByName('ReleaseDate').formatter = ((value: any) => { + const pipe = new DatePipe('fr-FR'); + const val = value !== null && value !== undefined && value !== '' ? pipe.transform(value, 'longDate') : 'No value!'; + return val; + }); + + const dates = fix.componentInstance.data.map(el => new Date(el.ReleaseDate)).sort((a, b) => a - b); + fix.detectChanges(); + + // Open excel style custom filtering dialog and wait a bit. + GridFunctions.clickExcelFilterIcon(fix, 'ReleaseDate'); + tick(1050); + fix.detectChanges(); + + // Verify items in search have loaded and that the loading indicator is not visible. + const listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix); + expect(listItems.length).toBe(7, 'incorrect rendered list items count'); + + expect(listItems[1].innerText).toBe('(Blanks)'); + for (let i = 2; i < listItems.length; i++) { + const date = dates[i]; + const label = date !== null && date !== undefined && date !== '' ? datePipe.transform(date, 'longDate') : 'No value!'; + expect(listItems[i].innerText).toBe(label); + } + + const loadingIndicator = GridFunctions.getExcelFilteringLoadingIndicator(fix); + expect(loadingIndicator).toBeNull('esf loading indicator is visible'); + })); + + it('Verify date values are displayed in correct format according to column formatter after filtering', fakeAsync(() => { + grid.locale = 'fr-FR'; + const datePipe = new DatePipe(grid.locale); + grid.getColumnByName('ReleaseDate').formatter = ((value: any) => { + const pipe = new DatePipe('fr-FR'); + const val = value !== null && value !== undefined && value !== '' ? pipe.transform(value, 'longDate') : 'No value!'; + return val; + }); + + const dates = fix.componentInstance.data.filter(d => d.ReleaseDate !== null && d.ReleaseDate !== undefined) + .map(el => new Date(el.ReleaseDate)).sort((a, b) => a - b); + fix.detectChanges(); + + // Open excel style custom filtering dialog and wait a bit. + GridFunctions.clickExcelFilterIcon(fix, 'ReleaseDate'); + tick(1050); + fix.detectChanges(); + + // Verify items in search have loaded and that the loading indicator is not visible. + let listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix); + expect(listItems.length).toBe(7, 'incorrect rendered list items count'); + + const checkboxElements = GridFunctions.getExcelStyleFilteringCheckboxes(fix); + checkboxElements[2].click(); + tick(); + fix.detectChanges(); + + GridFunctions.clickApplyExcelStyleFiltering(fix); + fix.detectChanges(); + + // Open excel style custom filtering dialog and wait a bit. + GridFunctions.clickExcelFilterIcon(fix, 'ReleaseDate'); + tick(1050); + fix.detectChanges(); + + listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix); + expect(listItems.length).toBe(7, 'incorrect rendered list items count'); + + expect(listItems[1].innerText).toBe('(Blanks)'); + for (let i = 2; i < listItems.length; i++) { + const date = dates[i - 2]; + const label = date !== null && date !== undefined && date !== '' ? datePipe.transform(date, 'longDate') : 'No value!'; + expect(listItems[i].innerText).toBe(label); + } + + const loadingIndicator = GridFunctions.getExcelFilteringLoadingIndicator(fix); + expect(loadingIndicator).toBeNull('esf loading indicator is visible'); + })); + + it('Done callback should be executed only once per column', fakeAsync(() => { + const compInstance = fix.componentInstance as IgxGridFilteringESFLoadOnDemandComponent; + // Open excel style custom filtering dialog and wait a bit. + GridFunctions.clickExcelFilterIcon(fix, 'ProductName'); + tick(1000); + fix.detectChanges(); + + // Verify items in search have loaded and that the loading indicator is not visible. + expect(compInstance.doneCallbackCounter).toBe(1, 'Incorrect done callback execution count'); + let listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix); + expect(listItems.length).toBe(6, 'incorrect rendered list items count'); + let loadingIndicator = GridFunctions.getExcelFilteringLoadingIndicator(fix); + expect(loadingIndicator).toBeNull('esf loading indicator is visible'); + + GridFunctions.clickExcelFilterIcon(fix, 'Downloads'); + tick(1000); + fix.detectChanges(); + expect(compInstance.doneCallbackCounter).toBe(2, 'Incorrect done callback execution count'); + listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix); + expect(listItems.length).toBe(7, 'incorrect rendered list items count'); + loadingIndicator = GridFunctions.getExcelFilteringLoadingIndicator(fix); + expect(loadingIndicator).toBeNull('esf loading indicator is visible'); + })); + + it('Should not execute done callback for null column', fakeAsync(() => { + GridFunctions.clickExcelFilterIcon(fix, 'ProductName'); + fix.detectChanges(); + + expect(() => { + GridFunctions.clickExcelFilterIcon(fix, 'Downloads'); + tick(2000); + }).not.toThrowError(/'dataType' of null/); + })); + }); + + describe(null, () => { + let fix: ComponentFixture; + let grid: IgxGridComponent; + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(IgxGridFilteringMCHComponent); + grid = fix.componentInstance.grid; + grid.filterMode = FilterMode.excelStyleFilter; + fix.detectChanges(); + })); + + it('Should pin column next to already pinned group by moving it to the left.', fakeAsync(() => { + // Test prerequisites + grid.width = '1000px'; + fix.detectChanges(); + tick(100); + // Adjust column widths, so their group can be pinned. + const columnFields = ['ID', 'ProductName', 'Downloads', 'Released', 'ReleaseDate', 'AnotherField']; + columnFields.forEach((columnField) => { + const col = grid.columnList.find((c) => c.field === columnField); + col.width = '100px'; + }); + fix.detectChanges(); + // Make 'AnotherField' column movable. + const column = grid.columns.find((c) => c.field === 'AnotherField'); + grid.moving = true; + fix.detectChanges(); + + // Pin the 'General Information' group by pinning its child 'ProductName' column. + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + tick(100); + fix.detectChanges(); + GridFunctions.clickPinIconInExcelStyleFiltering(fix, false); + tick(200); + fix.detectChanges(); + + // Verify 'AnotherField' column is not pinned. + GridFunctions.verifyColumnIsPinned(column, false, 7); + + // Try to pin the 'AnotherField' column by moving it to the left. + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'AnotherField'); + const moveLeft = GridFunctions.getExcelStyleFilteringMoveButtons(fix)[0]; + UIInteractions.simulateClickEvent(moveLeft); + tick(DEBOUNCE_TIME); + fix.detectChanges(); + + // Verify 'AnotherField' column is successfully pinned next to the column group. + GridFunctions.verifyColumnIsPinned(column, true, 8); + })); + }); + + describe('External Excel Style Filtering', () => { + let fix; let grid; + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(IgxGridExternalESFComponent); + grid = fix.componentInstance.grid; + fix.detectChanges(); + })); + + it('Should allow hosting Excel Style filtering component outside of the grid.', fakeAsync(() => { + // sort + GridFunctions.clickSortAscInExcelStyleFiltering(fix); + fix.detectChanges(); + expect(grid.sortingExpressions[0].fieldName).toEqual('ProductName'); + expect(grid.sortingExpressions[0].dir).toEqual(SortingDirection.Asc); + + // pin + GridFunctions.clickPinIconInExcelStyleFiltering(fix, false); + fix.detectChanges(); + expect(grid.pinnedColumns[0].field).toEqual('ProductName'); + + // filter + verifyExcelStyleFilterAvailableOptions(fix, ['Select All', '(Blanks)', 'Ignite UI for Angular', + 'Ignite UI for JavaScript', 'NetAdvantage', 'Some other item with Script'], + [true, true, true, true, true, true]); + toggleExcelStyleFilteringItems(fix, true, 1, 4); + expect(grid.rowList.length).toBe(3); + + // hide + GridFunctions.clickHideIconInExcelStyleFiltering(fix, false); + fix.detectChanges(); + expect(grid.columnList.get(1).hidden).toBeTruthy(); + })); + + it('Column selection button should be visible/hidden when column is selectable/not selectable', fakeAsync(() => { + grid.columnSelection = GridSelectionMode.multiple; + fix.detectChanges(); + + let columnSelectionContainer = GridFunctions.getExcelFilteringColumnSelectionContainer(fix); + expect(columnSelectionContainer).toBeNull(); + + const esf = fix.componentInstance.esf; + esf.column = grid.getColumnByName('Downloads'); + tick(); + fix.detectChanges(); + + columnSelectionContainer = GridFunctions.getExcelFilteringColumnSelectionContainer(fix); + expect(columnSelectionContainer).not.toBeNull(); + + grid.columnSelection = GridSelectionMode.none; + fix.detectChanges(); + fix.componentInstance.esf.cdr.detectChanges(); + + columnSelectionContainer = GridFunctions.getExcelFilteringColumnSelectionContainer(fix); + expect(columnSelectionContainer).toBeNull(); + })); + + it('should select/deselect column when interact with the column selection item through esf menu', fakeAsync(() => { + // Test in single multiple mode + grid.columnSelection = GridSelectionMode.multiple; + fix.detectChanges(); + + spyOn(grid.columnSelectionChanging, 'emit'); + const column = grid.getColumnByName('Downloads'); + fix.componentInstance.esf.column = column; + tick(); + fix.detectChanges(); + + GridFunctions.clickColumnSelectionInExcelStyleFiltering(fix); + fix.detectChanges(); + + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifyColumnAndCellsSelected(column, true); + + GridFunctions.clickColumnSelectionInExcelStyleFiltering(fix); + fix.detectChanges(); + + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledTimes(2); + GridSelectionFunctions.verifyColumnAndCellsSelected(column, false); + + // Test in single selection mode + grid.columnSelection = GridSelectionMode.single; + fix.detectChanges(); + + grid.selectColumns(['ID']); + fix.detectChanges(); + + const columnId = grid.getColumnByName('ID'); + GridSelectionFunctions.verifyColumnAndCellsSelected(columnId); + + GridFunctions.clickColumnSelectionInExcelStyleFiltering(fix); + fix.detectChanges(); + + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledTimes(3); + GridSelectionFunctions.verifyColumnAndCellsSelected(column, true); + GridSelectionFunctions.verifyColumnAndCellsSelected(columnId, false); + + })); + + it('should discard filters through esf menu properly on cancel button click', fakeAsync(() => { + grid.filter('ProductName', 'Ignite', IgxStringFilteringOperand.instance().condition('contains')); + fix.detectChanges(); + + expect(fix.debugElement.nativeElement.querySelector('.igx-excel-filter__filter-number').textContent).toContain('(1)'); + expect(grid.filteredData.length).toEqual(2); + tick(200); + fix.detectChanges(); + + GridFunctions.clickExcelFilterCascadeButton(fix); + tick(); + fix.detectChanges(); + + GridFunctions.clickOperatorFromCascadeMenu(fix, 0); + tick(100); + fix.detectChanges(); + GridFunctions.setOperatorESF(fix, 1, 0); + GridFunctions.setInputValueESF(fix, 1, 'Angular'); + GridFunctions.clickCancelExcelStyleCustomFiltering(fix); + + expect(fix.debugElement.nativeElement.querySelector('.igx-excel-filter__filter-number').textContent).toContain('(1)'); + expect(grid.filteredData.length).toEqual(2); + })); + + }); + + describe('IgxGrid - Conditional Filter', () => { + let fix: ComponentFixture; + let grid: IgxGridComponent; + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(IgxGridConditionalFilteringComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + grid.filterMode = FilterMode.excelStyleFilter; + fix.detectChanges(); + })); + + it('Should not throw console error on opening the drop-down.', async () => { + spyOn(console, 'error'); + GridFunctions.clickExcelFilterIconFromCodeAsync(fix, grid, 'Downloads'); + fix.detectChanges(); + await wait(100); + + expect(console.error).not.toHaveBeenCalled(); + }); + }); +}); + +describe('IgxGrid - Custom Filtering Strategy #grid', () => { + let fix: ComponentFixture; + let grid: IgxGridComponent; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + CustomFilteringStrategyComponent + ], + providers: [{ provide: INPUT_DEBOUNCE_TIME, useValue: 0 }] + }).compileComponents(); + })); + + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(CustomFilteringStrategyComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + })); + + it('Should be able to set custom filtering strategy', () => { + expect(grid.filterStrategy).toBeDefined(); + expect(grid.filterStrategy).toBeInstanceOf(FilteringStrategy); + grid.filterStrategy = fix.componentInstance.strategy; + fix.detectChanges(); + + expect(grid.filterStrategy).toEqual(fix.componentInstance.strategy); + }); + + it('Should be able to override getFieldValue method', fakeAsync(() => { + GridFunctions.clickFilterCellChipUI(fix, 'Name'); // Name column contains nested object as a value + fix.detectChanges(); + + GridFunctions.typeValueInFilterRowInput('ca', fix); + tick(); + fix.detectChanges(); + GridFunctions.submitFilterRowInput(fix); + fix.detectChanges(); + + expect(grid.filteredData).toEqual([]); + GridFunctions.resetFilterRow(fix); + GridFunctions.closeFilterRow(fix); + fix.detectChanges(); + + // Apply the custom strategy and perform the same filter + grid.filterStrategy = fix.componentInstance.strategy; + fix.detectChanges(); + GridFunctions.clickFilterCellChipUI(fix, 'Name'); // Name column contains nested object as a value + fix.detectChanges(); + + GridFunctions.typeValueInFilterRowInput('ca', fix); + tick(); + fix.detectChanges(); + GridFunctions.submitFilterRowInput(fix); + fix.detectChanges(); + + expect(grid.filteredData).toEqual( + [{ ID: 1, Name: { FirstName: 'Casey', LastName: 'Houston' }, JobTitle: 'Vice President', Company: 'Company A' }]); + })); + + it('Should be able to override findMatchByExpression method', fakeAsync(() => { + GridFunctions.clickFilterCellChipUI(fix, 'JobTitle'); // Default strategy is case not sensitive + tick(150); + fix.detectChanges(); + + GridFunctions.typeValueInFilterRowInput('direct', fix); + tick(DEBOUNCE_TIME); + GridFunctions.submitFilterRowInput(fix); + tick(DEBOUNCE_TIME); + fix.detectChanges(); + + expect(grid.filteredData).toEqual([ + { ID: 2, Name: { FirstName: 'Gilberto', LastName: 'Todd' }, JobTitle: 'Director', Company: 'Company C' }, + { ID: 3, Name: { FirstName: 'Tanya', LastName: 'Bennett' }, JobTitle: 'Director', Company: 'Company A' }]); + GridFunctions.resetFilterRow(fix); + GridFunctions.closeFilterRow(fix); + fix.detectChanges(); + + // Apply the custom strategy and perform the same filter + grid.filterStrategy = fix.componentInstance.strategy; + fix.detectChanges(); + GridFunctions.clickFilterCellChipUI(fix, 'JobTitle'); + tick(150); + fix.detectChanges(); + + GridFunctions.typeValueInFilterRowInput('direct', fix); + tick(DEBOUNCE_TIME); + GridFunctions.submitFilterRowInput(fix); + tick(DEBOUNCE_TIME); + fix.detectChanges(); + + expect(grid.filteredData).toEqual([]); + })); + + it('should use the custom filtering strategy when filter the grid through API method', fakeAsync(() => { + grid.filterStrategy = fix.componentInstance.strategy; + fix.detectChanges(); + grid.filter('Name', 'D', IgxStringFilteringOperand.instance().condition('contains')); + tick(DEBOUNCE_TIME); + fix.detectChanges(); + + expect(grid.filteredData).toEqual([ + { + ID: 7, Name: { FirstName: 'Debra', LastName: 'Morton' }, + JobTitle: 'Associate Software Developer', Company: 'Company B' + }, + { ID: 10, Name: { FirstName: 'Eduardo', LastName: 'Ramirez' }, JobTitle: 'Manager', Company: 'Company E' }]); + })); +}); + +const verifyFilterRowUI = (input, closeButton, resetButton, buttonResetDisabled = true) => { + ControlsFunction.verifyButtonIsDisabled(closeButton.nativeElement, false); + ControlsFunction.verifyButtonIsDisabled(resetButton.nativeElement, buttonResetDisabled); + expect(input.nativeElement.offsetHeight).toBeGreaterThan(0); +}; + +const verifyFilterUIPosition = (filterUIContainer, grid) => { + const filterUiRightBorder = filterUIContainer.nativeElement.offsetParent.offsetLeft + + filterUIContainer.nativeElement.offsetLeft + filterUIContainer.nativeElement.offsetWidth; + expect(filterUiRightBorder).toBeLessThanOrEqual(grid.nativeElement.offsetWidth); +}; + +const isExcelSearchScrollBarVisible = (fix) => { + const searchScrollbar = GridFunctions.getExcelStyleSearchComponentScrollbar(fix); + return searchScrollbar.offsetHeight < searchScrollbar.children[0].offsetHeight; +}; + +const checkUIForType = (type: string, elem: DebugElement) => { + let expectedConditions; + let expectedInputType; + const isReadOnly = type === 'bool' ? true : false; + switch (type) { + case 'string': + expectedConditions = IgxStringFilteringOperand.instance().operations.filter(f => !f.hidden); + expectedInputType = 'text'; + break; + case 'number': + expectedConditions = IgxNumberFilteringOperand.instance().operations.filter(f => !f.hidden); + expectedInputType = 'number'; + break; + case 'date': + expectedConditions = IgxDateFilteringOperand.instance().operations.filter(f => !f.hidden); + expectedInputType = 'datePicker'; + break; + case 'bool': + expectedConditions = IgxBooleanFilteringOperand.instance().operations.filter(f => !f.hidden); + expectedInputType = 'text'; + break; + case 'dateTime': + expectedConditions = IgxDateTimeFilteringOperand.instance().operations.filter(f => !f.hidden); + expectedInputType = 'text'; + break; + case 'time': + expectedConditions = IgxTimeFilteringOperand.instance().operations.filter(f => !f.hidden); + expectedInputType = 'timePicker'; + break; + } + GridFunctions.openFilterDD(elem); + const ddList = elem.query(By.css('div.igx-drop-down__list-scroll')); + const ddItems = ddList.nativeElement.children; + // check drop-down conditions + for (let i = 0; i < expectedConditions.length; i++) { + const txt = expectedConditions[i].name.split(/(?=[A-Z])/).join(' ').toLowerCase(); + expect(txt).toEqual(ddItems[i].textContent.toLowerCase()); + } + // check input is correct type + const filterUIRow = elem.query(By.css(FILTER_UI_ROW)); + if (expectedInputType !== 'datePicker') { + const input = filterUIRow.query(By.css('.igx-input-group__input')); + expect(input.nativeElement.type).toBe(expectedInputType); + expect(input.nativeElement.attributes.hasOwnProperty('readonly')).toBe(isReadOnly); + } else { + const datePicker = filterUIRow.query(By.directive(IgxDatePickerComponent)); + expect(datePicker).not.toBe(null); + } +}; + +const verifyExcelStyleFilteringSize = (fix: ComponentFixture, expectedSize: ɵSize) => { + // Get excel style dialog + const excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + + // Verify size of search input and list. + const excelSearch = excelMenu.querySelector('igx-excel-style-search'); + const inputGroup = excelSearch.querySelector('igx-input-group'); + const list = excelSearch.querySelector('igx-list'); + expect(getComponentSize(inputGroup)).toBe(expectedSize); + expect(getComponentSize(list)).toBe(expectedSize); + + // Verify size of all flat and contained buttons in excel stlye dialog. + const flatButtons: HTMLElement[] = excelMenu.querySelectorAll('.igx-button--flat:not(.igx-excel-filter__secondary *):not(.igx-excel-filter__menu-footer)'); + const containedButtons: HTMLElement[] = excelMenu.querySelectorAll('.igx-button--contained:not(.igx-excel-filter__secondary *):not(.igx-excel-filter__menu-footer)'); + const buttons: HTMLElement[] = Array.from(flatButtons).concat(Array.from(containedButtons)); + buttons.forEach((button) => { + expect(getComponentSize(button)).toBe(expectedSize); + }); + + // Verify column pinning and column hiding elements in header area and actions area + // are shown based on the expected size. + verifyPinningHidingSize(fix, expectedSize); + // Verify column sorting and column moving buttons are positioned either on right of their + // respective header or under it, based on the expected size. + verifySortMoveSize(fix, expectedSize); +}; + +const verifyPinningHidingSize = (fix: ComponentFixture, expectedSize: ɵSize) => { + // Get excel style dialog + const excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + + // Get column pinning and column hiding icons from header (if present at all) + const headerTitle = excelMenu.querySelector('h4'); + const headerIcons: DebugElement[] = GridFunctions.getExcelFilteringHeaderIconsDebugElements(fix, excelMenu); + const headerAreaPinIcon: HTMLElement = + headerIcons.find((buttonIcon: DebugElement) => buttonIcon.query(By.directive(IgxIconComponent)).componentInstance.name === "pin")?.nativeElement; + const headerAreaUnpinIcon: HTMLElement + = headerIcons.find((buttonIcon: DebugElement) => buttonIcon.query(By.directive(IgxIconComponent)).componentInstance.name === "unpin")?.nativeElement; + const headerAreaColumnHidingIcon: HTMLElement = + headerIcons.find((buttonIcon: DebugElement) => buttonIcon.query(By.directive(IgxIconComponent)).componentInstance.name === 'hide')?.nativeElement; + + // Get column pinning and column hiding icons from actionsArea (if present at all) + const actionsPinArea = GridFunctions.getExcelFilteringPinContainer(fix, excelMenu); + const actionsAreaColumnHidingIcon = GridFunctions.getExcelFilteringHideContainer(fix, excelMenu); + + if (expectedSize === ɵSize.Large) { + // Verify icons in header are not present. + expect(headerAreaPinIcon === null || headerAreaPinIcon === undefined).toBe(true, + 'headerArea pin icon is present'); + expect(headerAreaUnpinIcon === null || headerAreaUnpinIcon === undefined).toBe(true, + 'headerArea unpin icon is present'); + expect(headerAreaColumnHidingIcon === null || headerAreaColumnHidingIcon === undefined).toBe(true, + 'headerArea column hiding icon is present'); + // Verify icons in actions area are present. + expect(actionsPinArea !== null).toBe(true, 'actionsArea pin/unpin icon is NOT present'); + expect(actionsAreaColumnHidingIcon).not.toBeNull('actionsArea column hiding icon is NOT present'); + } else { + // Verify icons in header are present. + expect((headerAreaPinIcon !== null) || (headerAreaUnpinIcon !== null)).toBe(true, + 'headerArea pin/unpin icon is NOT present'); + expect(headerAreaColumnHidingIcon).not.toBeNull('headerArea column hiding icon is NOT present'); + // Verify icons in actions area are not present. + expect(actionsPinArea).toBeNull('actionsArea pin icon is present'); + expect(actionsAreaColumnHidingIcon).toBeNull('headerArea column hiding icon is present'); + // Verify icons are on right of the title + const headerTitleRect = headerTitle.getBoundingClientRect(); + const pinUnpinIconRect = ((headerAreaPinIcon !== null) ? headerAreaPinIcon : headerAreaUnpinIcon).getBoundingClientRect(); + const columnHidingRect = headerAreaColumnHidingIcon.getBoundingClientRect(); + + expect(pinUnpinIconRect.left >= headerTitleRect.right).toBe(true, + 'pinUnpin icon is NOT on the right of top header'); + expect(columnHidingRect.left > headerTitleRect.right).toBe(true, + 'columnHiding icon is NOT on the right of top header'); + } +}; + +const verifySortMoveSize = (fix: ComponentFixture, expectedSize: ɵSize) => { + // Get excel style dialog. + const excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + + // Get container of sort component and its header and buttons. + const sortContainer = GridFunctions.getExcelStyleFilteringSortContainer(fix, excelMenu); + const sortHeaderRect = sortContainer.querySelector('header').getBoundingClientRect(); + const sortButtons = GridFunctions.getExcelStyleFilteringSortButtons(fix, excelMenu); + + // Get container of move component and its header and buttons. + const moveContainer = GridFunctions.getExcelStyleFilteringMoveContainer(fix, excelMenu); + const moveHeaderRect = moveContainer.querySelector('header').getBoundingClientRect(); + const moveButtons = GridFunctions.getExcelStyleFilteringMoveButtons(fix, excelMenu); + + const isSmall = expectedSize === ɵSize.Small; + // Verify sort buttons are on right of the sort title if size is 'small' + // or that they are under the sort title if size is not 'small'. + expect(sortHeaderRect.right <= sortButtons[0].getBoundingClientRect().left).toBe(isSmall, + 'incorrect sort button horizontal position based on the sort title'); + expect(sortHeaderRect.right <= sortButtons[1].getBoundingClientRect().left).toBe(isSmall, + 'incorrect sort button horizontal position based on the sort title'); + expect(sortHeaderRect.bottom <= sortButtons[0].getBoundingClientRect().top).toBe(!isSmall, + 'incorrect sort button vertical position based on the sort title'); + expect(sortHeaderRect.bottom <= sortButtons[1].getBoundingClientRect().top).toBe(!isSmall, + 'incorrect sort button vertical position based on the sort title'); + // Verify move buttons are on right of the move title if size is 'small' + // or that they are under the sort title if size is not 'small'. + expect(moveHeaderRect.right < moveButtons[0].getBoundingClientRect().left).toBe(isSmall, + 'incorrect move button horizontal position based on the sort title'); + expect(moveHeaderRect.right < moveButtons[1].getBoundingClientRect().left).toBe(isSmall, + 'incorrect move button horizontal position based on the sort title'); + expect(moveHeaderRect.bottom <= moveButtons[0].getBoundingClientRect().top).toBe(!isSmall, + 'incorrect move button vertical position based on the sort title'); + expect(moveHeaderRect.bottom <= moveButtons[1].getBoundingClientRect().top).toBe(!isSmall, + 'incorrect move button vertical position based on the sort title'); +}; + +const verifyExcelCustomFilterSize = (fix: ComponentFixture, expectedSize: ɵSize) => { + // Excel style filtering custom filter dialog + const customFilterMenu = GridFunctions.getExcelStyleCustomFilteringDialog(fix); + // Main container of custom filter dialog + const container = customFilterMenu.querySelector('.igx-excel-filter__secondary-main'); + + // Verify size of all flat and contained buttons in custom filter dialog. + const flatButtons = container.querySelectorAll('.igx-button--flat'); + const containedButtons = container.querySelectorAll('.igx-button--contained'); + const buttons = Array.from(flatButtons).concat(Array.from(containedButtons)); + buttons.forEach((button) => { + expect(getComponentSize(button)).toBe(expectedSize); + }); + + // Verify size of all input groups in custom filter dialog. + const inputGroups = customFilterMenu.querySelectorAll('igx-input-group'); + inputGroups.forEach((inputGroup) => { + expect(getComponentSize(inputGroup)).toBe(expectedSize, 'incorrect inputGroup size in custom filter dialog'); + }); +}; + +const verifyGridSubmenuSize = (gridNativeElement: HTMLElement, expectedSize: ɵSize) => { + const outlet = gridNativeElement.querySelector('.igx-grid__outlet'); + const dropdowns = Array.from(outlet.querySelectorAll('.igx-drop-down__list')); + const visibleDropdown: any = dropdowns.find((d) => !d.classList.contains('igx-toggle--hidden')); + const dropdownItems = visibleDropdown.querySelectorAll('igx-drop-down-item'); + + dropdownItems.forEach((dropdownItem) => { + expect(getComponentSize(dropdownItem)).toBe(expectedSize, 'incorrect dropdown item size'); + }); +}; + +const verifyFilteringExpression = (operand: IFilteringExpression, fieldName: string, conditionName: string, searchVal: any) => { + expect(operand.fieldName).toBe(fieldName); + expect(operand.condition.name).toBe(conditionName); + expect(operand.searchVal).toEqual(searchVal); +}; + +const verifyExcelStyleFilterAvailableOptions = (fix, labels: string[], checked: boolean[]) => { + const excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + const labelElements: any[] = Array.from(GridFunctions.getExcelStyleSearchComponentListItems(fix, excelMenu)); + const checkboxElements: any[] = Array.from(GridFunctions.getExcelStyleFilteringCheckboxes(fix, excelMenu)); + + expect(labelElements.length).toBe(labels.length, 'incorrect rendered list items count'); + labels.forEach((l, index) => { + expect(l).toEqual(labelElements[index].innerText.normalize("NFKD")); + }); + checked.forEach((c, index) => { + expect(checkboxElements[index].indeterminate ? null : checkboxElements[index].checked).toEqual(c); + }); +}; + +const toggleExcelStyleFilteringItems = (fix, shouldApply: boolean, ...itemIndices: number[]) => { + const excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix); + const checkbox = GridFunctions.getExcelStyleFilteringCheckboxes(fix, excelMenu); + + for (const index of itemIndices) { + checkbox[index].click(); + } + tick(); + fix.detectChanges(); + + if (shouldApply) { + GridFunctions.clickApplyExcelStyleFiltering(fix, excelMenu); + tick(); + fix.detectChanges(); + } +}; + +/** + * Verfiy multiple condition chips on their respective indices (asc order left to right) + * are whether fully visible or not. + */ +const verifyMultipleChipsVisibility = (fix, expectedVisibilities: boolean[]) => { + for (let index = 0; index < expectedVisibilities.length; index++) { + verifyChipVisibility(fix, index, expectedVisibilities[index]); + } +}; + +/** + * Verfiy that the condition chip on the respective index (asc order left to right) + * is whether fully visible or not. + */ +const verifyChipVisibility = (fix, index: number, shouldBeFullyVisible: boolean) => { + const filteringRow = fix.debugElement.query(By.directive(IgxGridFilteringRowComponent)); + const visibleChipArea = filteringRow.query(By.css('.igx-grid__filtering-row-main')); + const visibleChipAreaRect = visibleChipArea.nativeElement.getBoundingClientRect(); + + const chip = GridFunctions.getFilterConditionChip(fix, index); + const chipRect = chip.getBoundingClientRect(); + + expect(chipRect.left >= visibleChipAreaRect.left && chipRect.right <= visibleChipAreaRect.right) + .toBe(shouldBeFullyVisible, 'chip[' + index + '] visibility is incorrect'); +}; + +const emitFilteringDoneOnResetClick = (fix, grid, filterVal: any, columnName: string, condition) => { + filterGrid(fix, grid, columnName, filterVal, condition); + + const filteringExpressions = grid.filteringExpressionsTree.find(columnName) as FilteringExpressionsTree; + verifyEmitFilteringDone(grid, filteringExpressions, 1); + + GridFunctions.clickFilterCellChip(fix, columnName); + GridFunctions.resetFilterRow(fix); + + const emptyFilter = new FilteringExpressionsTree(null, columnName); + verifyEmitFilteringDone(grid, emptyFilter, 2); + + const filterUiRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const reset = filterUiRow.queryAll(By.css('button'))[0]; + expect(reset.nativeElement.classList.contains('igx-button--disabled')).toEqual(true); +}; + +const emitFilteringDoneOnInputClear = (fix, grid, filterVal, columnName, condition) => { + filterGrid(fix, grid, columnName, filterVal, condition); + + GridFunctions.clickFilterCellChip(fix, columnName); + + grid.filteringRow.onClearClick(); + tick(100); + fix.detectChanges(); + + const emptyFilter = new FilteringExpressionsTree(null, columnName); + verifyEmitFilteringDone(grid, emptyFilter, 2); +}; + +const verifyRemoveChipFromHeader = (fix, grid, filterVal, columnName, condition, rowListLength, cellIndex) => { + filterGrid(fix, grid, columnName, filterVal, condition); + + const filteringExpressions = grid.filteringExpressionsTree.find(columnName) as FilteringExpressionsTree; + verifyEmitFilteringDone(grid, filteringExpressions, 1); + + const filteringCells = GridFunctions.getFilteringCells(fix); + const stringCellChip = filteringCells[cellIndex].query(By.css('igx-chip')); + + // remove chip + ControlsFunction.clickChipRemoveButton(stringCellChip.nativeElement); + tick(30); + fix.detectChanges(); + + expect(grid.rowList.length).toEqual(8); + + const emptyFilter = new FilteringExpressionsTree(null, columnName); + verifyEmitFilteringDone(grid, emptyFilter, 2); +}; + +const closeChipFromFilteringUIRow = (fix, grid, columnName, index) => { + GridFunctions.clickFilterCellChip(fix, columnName); + + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const close = filterUIRow.queryAll(By.css('button'))[1]; + + GridFunctions.openFilterDDAndSelectCondition(fix, index); + + close.triggerEventHandler('click', null); + tick(); + fix.detectChanges(); + + const filteringExpressions = grid.filteringExpressionsTree.find(columnName) as FilteringExpressionsTree; + verifyEmitFilteringDone(grid, filteringExpressions, 1); +}; + +const verifyEmitFilteringDone = (grid, filteringExpressions, calledTimes) => { + const args = { owner: grid, cancel: false, filteringExpressions }; + expect(grid.filtering.emit).toHaveBeenCalledWith(args); + expect(grid.filtering.emit).toHaveBeenCalledTimes(calledTimes); + expect(grid.filteringDone.emit).toHaveBeenCalledWith(filteringExpressions); + expect(grid.filteringDone.emit).toHaveBeenCalledTimes(calledTimes); +}; + +const filterGrid = (fix, grid, columnName, filterVal, condition) => { + grid.filter(columnName, filterVal, condition); + tick(100); + fix.detectChanges(); +}; diff --git a/projects/igniteui-angular/grids/grid/src/grid-filtering.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-filtering.spec.ts new file mode 100644 index 00000000000..3b583c90567 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid-filtering.spec.ts @@ -0,0 +1,1197 @@ +import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxGridComponent } from './grid.component'; +import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; +import { GridFunctions, GridSummaryFunctions } from '../../../test-utils/grid-functions.spec'; +import { IgxGridFilteringComponent, CustomFilter, IgxGridFilteringBindingComponent } from '../../../test-utils/grid-samples.spec'; +import { FilteringExpressionsTree, FilteringLogic, IFilteringExpression, IgxBooleanFilteringOperand, IgxDateFilteringOperand, IgxDateTimeFilteringOperand, IgxNumberFilteringOperand, IgxStringFilteringOperand, IgxTimeFilteringOperand, NoopFilteringStrategy } from 'igniteui-angular/core'; +import { IgxChipComponent } from 'igniteui-angular/chips'; +import { ExpressionUI } from 'igniteui-angular/grids/core'; + +describe('IgxGrid - Filtering actions #grid', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + IgxGridFilteringComponent, NoopAnimationsModule + ] + }).compileComponents(); + })); + + let fix; let grid; + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(IgxGridFilteringComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + })); + + it('should correctly filter by \'string\' filtering conditions', fakeAsync(() => { + // Contains filter + grid.filter('ProductName', 'Ignite', IgxStringFilteringOperand.instance().condition('contains'), true); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(2); + expect(grid.getCellByColumn(0, 'ID').value).toEqual(1); + expect(grid.getCellByColumn(1, 'ID').value).toEqual(3); + + // Clear filtering + grid.clearFilter('ProductName'); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(8); + + // StartsWith filter + grid.filter('ProductName', 'Net', IgxStringFilteringOperand.instance().condition('startsWith'), true); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(1); + expect(grid.getCellByColumn(0, 'ID').value).toEqual(2); + + // EndsWith filter + grid.clearFilter('ProductName'); + fix.detectChanges(); + grid.filter('ProductName', 'Script', IgxStringFilteringOperand.instance().condition('endsWith'), true); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(2); + + // DoesNotContain filter + grid.clearFilter('ProductName'); + fix.detectChanges(); + grid.filter('ProductName', 'Ignite', IgxStringFilteringOperand.instance().condition('doesNotContain'), true); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(6); + + // Equals filter + grid.clearFilter('ProductName'); + fix.detectChanges(); + grid.filter('ProductName', 'NetAdvantage', IgxStringFilteringOperand.instance().condition('equals'), true); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(1); + + // DoesNotEqual filter + grid.clearFilter('ProductName'); + fix.detectChanges(); + grid.filter('ProductName', 'NetAdvantage', IgxStringFilteringOperand.instance().condition('doesNotEqual'), true); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(7); + + // Null filter + grid.clearFilter('ProductName'); + fix.detectChanges(); + grid.filter('ProductName', null, IgxStringFilteringOperand.instance().condition('null'), true); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(3); + + // NotNull filter + grid.clearFilter('ProductName'); + fix.detectChanges(); + grid.filter('ProductName', null, IgxStringFilteringOperand.instance().condition('notNull'), true); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(5); + + // Empty filter + grid.clearFilter('ProductName'); + fix.detectChanges(); + grid.filter('ProductName', null, IgxStringFilteringOperand.instance().condition('empty'), true); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(4); + + // NotEmpty filter + grid.clearFilter('ProductName'); + fix.detectChanges(); + grid.filter('ProductName', null, IgxStringFilteringOperand.instance().condition('notEmpty'), true); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(4); + + // Ignorecase filter 'false' + grid.clearFilter('ProductName'); + fix.detectChanges(); + grid.filter('ProductName', 'Ignite UI for Angular', IgxStringFilteringOperand.instance().condition('equals'), false); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(1); + + // Custom Filter + grid.clearFilter('ProductName'); + fix.detectChanges(); + grid.filter('AnotherField', '', CustomFilter.instance().condition('custom'), false); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(1); + })); + + it('should correctly filter by \'number\' filtering conditions', fakeAsync(() => { + // DoesNotEqual filter + grid.filter('Downloads', 254, IgxNumberFilteringOperand.instance().condition('doesNotEqual')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(7); + + // Equal filter + grid.clearFilter('Downloads'); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(8); + grid.filter('Downloads', 127, IgxNumberFilteringOperand.instance().condition('equals'), true); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(1); + + // GreaterThan filter + grid.clearFilter('Downloads'); + fix.detectChanges(); + grid.filter('Downloads', 100, IgxNumberFilteringOperand.instance().condition('greaterThan'), true); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(4); + + // LessThan filter + grid.clearFilter('Downloads'); + fix.detectChanges(); + grid.filter('Downloads', 100, IgxNumberFilteringOperand.instance().condition('lessThan'), true); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(3); + + // GreaterThanOrEqualTo filter + grid.clearFilter('Downloads'); + fix.detectChanges(); + grid.filter('Downloads', 100, IgxNumberFilteringOperand.instance().condition('greaterThanOrEqualTo'), true); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(5); + + // LessThanOrEqualTo filter + grid.clearFilter('Downloads'); + fix.detectChanges(); + grid.filter('Downloads', 20, IgxNumberFilteringOperand.instance().condition('lessThanOrEqualTo'), true); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(3); + + // Null filter + grid.clearFilter('Downloads'); + fix.detectChanges(); + grid.filter('Downloads', null, IgxNumberFilteringOperand.instance().condition('null'), true); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(1); + + // NotNull filter + grid.clearFilter('Downloads'); + fix.detectChanges(); + grid.filter('Downloads', null, IgxNumberFilteringOperand.instance().condition('notNull'), true); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(7); + + // Empty filter + grid.clearFilter('Downloads'); + fix.detectChanges(); + grid.filter('Downloads', null, IgxNumberFilteringOperand.instance().condition('empty'), true); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(1); + + // NotEmpty filter + grid.clearFilter('Downloads'); + fix.detectChanges(); + grid.filter('Downloads', null, IgxNumberFilteringOperand.instance().condition('notEmpty'), true); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(7); + })); + + it('should correctly filter by \'boolean\' filtering conditions', fakeAsync(() => { + // Empty filter + grid.filter('Released', null, IgxBooleanFilteringOperand.instance().condition('empty')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(3); + + // False filter + grid.clearFilter('Released'); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(8); + grid.filter('Released', null, IgxBooleanFilteringOperand.instance().condition('false')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(2); + + // True filter + grid.clearFilter('Released'); + fix.detectChanges(); + grid.filter('Released', null, IgxBooleanFilteringOperand.instance().condition('true')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(3); + + // NotEmpty filter + grid.clearFilter('Released'); + fix.detectChanges(); + grid.filter('Released', null, IgxBooleanFilteringOperand.instance().condition('notEmpty')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(5); + + // NotNull filter + grid.clearFilter('Released'); + fix.detectChanges(); + grid.filter('Released', null, IgxBooleanFilteringOperand.instance().condition('notNull')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(6); + + // Null filter + grid.clearFilter('Released'); + fix.detectChanges(); + grid.filter('Released', null, IgxBooleanFilteringOperand.instance().condition('null')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(2); + })); + + it('should correctly filter by \'date\' filtering conditions', fakeAsync(() => { + const cal = SampleTestData.timeGenerator; + const today = SampleTestData.today; + + // Fill expected results based on the current date + const expectedResults = GridFunctions.createDateFilterConditions(grid, today); + + // After filter + grid.filter('ReleaseDate', cal.timedelta(today, 'day', 4), + IgxDateFilteringOperand.instance().condition('after')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(1); + + // Before filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(8); + grid.filter('ReleaseDate', cal.timedelta(today, 'day', 4), + IgxDateFilteringOperand.instance().condition('before')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(5); + + // DoesNotEqual filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', today, + IgxDateFilteringOperand.instance().condition('doesNotEqual')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(7); + + // Equals filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', today, + IgxDateFilteringOperand.instance().condition('equals')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(1); + + // LastMonth filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('lastMonth')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(expectedResults[0]); + + // NextMonth filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('nextMonth')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(expectedResults[1]); + + // ThisYear filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('thisYear')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(expectedResults[2]); + + // LastYear filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('lastYear')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(expectedResults[4]); + + // NextYear filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('nextYear')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(expectedResults[3]); + + // Null filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('null')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(1); + + // NotNull filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('notNull')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(7); + + // Empty filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('empty')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(2); + + // NotEmpty filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('notEmpty')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(6); + + // Today filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('today')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(1); + + // Yesterday filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('yesterday')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(1); + })); + + it('should correctly filter by \'time\' filtering conditions', fakeAsync(() => { + const cal = SampleTestData.timeGenerator; + const today = SampleTestData.todayFullDate; + + // At, Not At, Before, After, At or Before + // At or After, Empty, Not Empty, Null, Not Null + + // At 11:15:35 + grid.filter('ReleaseTime', cal.timedelta(today, 'hour', 1), + IgxTimeFilteringOperand.instance().condition('at')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(1); + + // Not At 09:15:35 + grid.clearFilter('ReleaseTime'); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(8); + grid.filter('ReleaseTime', cal.timedelta(today, 'hour', -1), + IgxTimeFilteringOperand.instance().condition('not_at')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(7); + + // Before 10:25:35 + grid.clearFilter('ReleaseTime'); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(8); + grid.filter('ReleaseTime', cal.timedelta(today, 'minute', +10), + IgxTimeFilteringOperand.instance().condition('before')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(4); + + // After 10:15:55 + grid.clearFilter('ReleaseTime'); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(8); + grid.filter('ReleaseTime', cal.timedelta(today, 'second', +20), + IgxTimeFilteringOperand.instance().condition('after')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(2); + + // At or Before 10:25:35 + grid.clearFilter('ReleaseTime'); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(8); + grid.filter('ReleaseTime', cal.timedelta(today, 'minute', +10), + IgxTimeFilteringOperand.instance().condition('at_before')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(5); + + // At or After 10:15:55 + grid.clearFilter('ReleaseTime'); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(8); + grid.filter('ReleaseTime', cal.timedelta(today, 'second', +20), + IgxTimeFilteringOperand.instance().condition('at_after')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(3); + + // Empty filter + grid.clearFilter('ReleaseTime'); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(8); + grid.filter('ReleaseTime', null, IgxTimeFilteringOperand.instance().condition('empty')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(2); + + // NotEmpty filter + grid.clearFilter('ReleaseTime'); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(8); + grid.filter('ReleaseTime', null, IgxTimeFilteringOperand.instance().condition('notEmpty')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(6); + + // Null filter + grid.clearFilter('ReleaseTime'); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(8); + grid.filter('ReleaseTime', null, IgxTimeFilteringOperand.instance().condition('null')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(1); + + // NotNull filter + grid.clearFilter('ReleaseTime'); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(8); + grid.filter('ReleaseTime', null, IgxTimeFilteringOperand.instance().condition('notNull')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(7); + })); + + it('should correctly filter by \'dateTime\' filtering conditions', fakeAsync(() => { + const cal = SampleTestData.timeGenerator; + const today = SampleTestData.todayFullDate; + + // Equals 11:15:35 + grid.filter('ReleaseDateTime', cal.timedelta(today, 'hour', 1), + IgxDateTimeFilteringOperand.instance().condition('equals')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(1); + + // Does not equal 11:15:35 + grid.filter('ReleaseDateTime', cal.timedelta(today, 'hour', 1), + IgxDateTimeFilteringOperand.instance().condition('doesNotEqual')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(7); + })); + + it('should correctly filter with earliest/latest \'date\' values', fakeAsync(() => { + const earliest = new Date(SampleTestData.timeGenerator.timedelta(SampleTestData.today, 'month', -1).getTime() + 7200 * 1000); + const latest = new Date(SampleTestData.timeGenerator.timedelta(SampleTestData.today, 'month', 1).getTime() - 7200 * 1000); + + // Before filter + expect(grid.rowList.length).toEqual(8); + grid.filter('ReleaseDate', earliest, IgxDateFilteringOperand.instance().condition('before')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(1); + + // After filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', earliest, IgxDateFilteringOperand.instance().condition('after')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(5); + + // DoesNotEqual filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', earliest, IgxDateFilteringOperand.instance().condition('doesNotEqual')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(7); + + // Equals filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', earliest, IgxDateFilteringOperand.instance().condition('equals')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(1); + + // Before filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', latest, IgxDateFilteringOperand.instance().condition('before')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(5); + + // After filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', latest, IgxDateFilteringOperand.instance().condition('after')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(1); + })); + + it('should correctly filter by \'date\' filtering conditions when dates are ISO 8601 strings', fakeAsync(() => { + const cal = SampleTestData.timeGenerator; + const today = SampleTestData.today; + + grid.data = grid.data.map(rec => { + const newRec = Object.assign({}, rec) as any; + newRec.ReleaseDate = rec.ReleaseDate ? rec.ReleaseDate.toISOString() : rec.ReleaseDate; + return newRec; + }); + + // Fill expected results based on the current date + const expectedResults = GridFunctions.createDateFilterConditions(grid, today); + + // After filter + grid.filter('ReleaseDate', cal.timedelta(today, 'day', 4), + IgxDateFilteringOperand.instance().condition('after')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(1); + + // Before filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(8); + grid.filter('ReleaseDate', cal.timedelta(today, 'day', 4), + IgxDateFilteringOperand.instance().condition('before')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(5); + + // DoesNotEqual filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', today, + IgxDateFilteringOperand.instance().condition('doesNotEqual')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(7); + + // Equals filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', today, + IgxDateFilteringOperand.instance().condition('equals')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(1); + + // LastMonth filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('lastMonth')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(expectedResults[0]); + + // NextMonth filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('nextMonth')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(expectedResults[1]); + + // ThisYear filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('thisYear')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(expectedResults[2]); + + // LastYear filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('lastYear')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(expectedResults[4]); + + // NextYear filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('nextYear')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(expectedResults[3]); + + // Null filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('null')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(1); + + // NotNull filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('notNull')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(7); + + // Empty filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('empty')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(2); + + // NotEmpty filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('notEmpty')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(6); + + // Today filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('today')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(1); + + // Yesterday filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('yesterday')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(1); + })); + + it('should correctly filter by \'date\' filtering conditions when dates are miliseconds numbers', fakeAsync(() => { + const cal = SampleTestData.timeGenerator; + const today = SampleTestData.today; + + grid.data = grid.data.map(rec => { + const newRec = Object.assign({}, rec) as any; + newRec.ReleaseDate = rec.ReleaseDate ? rec.ReleaseDate.getTime() : rec.ReleaseDate; + return newRec; + }); + + // Fill expected results based on the current date + const expectedResults = GridFunctions.createDateFilterConditions(grid, today); + + // After filter + grid.filter('ReleaseDate', cal.timedelta(today, 'day', 4), + IgxDateFilteringOperand.instance().condition('after')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(1); + + // Before filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(8); + grid.filter('ReleaseDate', cal.timedelta(today, 'day', 4), + IgxDateFilteringOperand.instance().condition('before')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(5); + + // DoesNotEqual filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', today, + IgxDateFilteringOperand.instance().condition('doesNotEqual')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(7); + + // Equals filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', today, + IgxDateFilteringOperand.instance().condition('equals')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(1); + + // LastMonth filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('lastMonth')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(expectedResults[0]); + + // NextMonth filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('nextMonth')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(expectedResults[1]); + + // ThisYear filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('thisYear')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(expectedResults[2]); + + // LastYear filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('lastYear')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(expectedResults[4]); + + // NextYear filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('nextYear')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(expectedResults[3]); + + // Null filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('null')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(1); + + // NotNull filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('notNull')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(7); + + // Empty filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('empty')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(2); + + // NotEmpty filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('notEmpty')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(6); + + // Today filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('today')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(1); + + // Yesterday filter + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('yesterday')); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(1); + })); + + it('should exclude null and undefined values when filter by \'false\'', fakeAsync(() => { + expect(grid.rowList.length).toEqual(8); + + grid.filter('Released', false, IgxStringFilteringOperand.instance().condition('equals'), true); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(2); + expect(grid.getCellByColumn(0, 'Released').value).toBe(false); + expect(grid.getCellByColumn(1, 'Released').value).toBe(false); + })); + + it('should correctly apply multiple filtering through API', fakeAsync(() => { + spyOn(grid.filtering, 'emit'); + spyOn(grid.filteringDone, 'emit'); + + const gridExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + gridExpressionsTree.filteringOperands = [ + { fieldName: 'Downloads', searchVal: 20, condition: IgxNumberFilteringOperand.instance().condition('greaterThanOrEqualTo'), conditionName: 'greaterThanOrEqualTo' }, + { fieldName: 'ID', searchVal: 4, condition: IgxNumberFilteringOperand.instance().condition('greaterThan'), conditionName: 'greaterThan' } + ]; + + grid.filteringExpressionsTree = gridExpressionsTree; + tick(30); + fix.detectChanges(); + + expect(grid.filtering.emit).toHaveBeenCalledTimes(0); + expect(grid.filteringDone.emit).toHaveBeenCalledTimes(0); + + expect(grid.rowList.length).toEqual(3); + expect(grid.filteringExpressionsTree.filteringOperands.length).toEqual(2); + let expression = grid.filteringExpressionsTree.filteringOperands[0] as IFilteringExpression; + expect(expression).toBeDefined(); + expression = grid.filteringExpressionsTree.filteringOperands[1] as IFilteringExpression; + expect(expression).toBeDefined(); + + grid.clearFilter(); + tick(30); + fix.detectChanges(); + + expect(grid.rowList.length).toEqual(8); + expect(grid.filteringExpressionsTree.filteringOperands.length).toEqual(0); + })); + + it('should correctly apply global filtering', fakeAsync(() => { + spyOn(grid.filtering, 'emit'); + spyOn(grid.filteringDone, 'emit'); + + grid.filteringLogic = FilteringLogic.Or; + grid.filterGlobal('some', IgxStringFilteringOperand.instance().condition('contains')); + tick(30); + fix.detectChanges(); + + expect(grid.filteringExpressionsTree.filteringOperands.length).toEqual(grid.columnList.length); + expect(grid.rowList.length).toEqual(1); + + const filteringExpressions = grid.filteringExpressionsTree; + const args = { owner: grid, cancel: false, filteringExpressions }; + expect(grid.filtering.emit).toHaveBeenCalledWith(args); + expect(grid.filteringDone.emit).toHaveBeenCalledWith(filteringExpressions); + })); + + it('Should render chip when filtering using the API.', fakeAsync(() => { + const firstHeaderCell = fix.debugElement.query(By.css('.header-release-date')); + let filteringChips = firstHeaderCell.parent.queryAll(By.directive(IgxChipComponent)); + expect(filteringChips.length).toEqual(1); + let chipContent = filteringChips[0].query(By.css('.igx-chip__content')).nativeElement.innerText; + expect(chipContent).toEqual('Filter'); + + grid.filter('ReleaseDate', null, IgxDateFilteringOperand.instance().condition('today')); + fix.detectChanges(); + filteringChips = firstHeaderCell.parent.queryAll(By.directive(IgxChipComponent)); + expect(filteringChips.length).toEqual(1); + chipContent = filteringChips[0].query(By.css('.igx-chip__content')).nativeElement.innerText; + expect(chipContent).not.toEqual('Filter'); + + grid.clearFilter('ReleaseDate'); + fix.detectChanges(); + filteringChips = firstHeaderCell.parent.queryAll(By.directive(IgxChipComponent)); + expect(filteringChips.length).toEqual(1); + chipContent = filteringChips[0].query(By.css('.igx-chip__content')).nativeElement.innerText; + expect(chipContent).toEqual('Filter'); + })); + + it('Should correctly apply two conditions to two columns at once.', fakeAsync(() => { + const colDownloadsExprTree = new FilteringExpressionsTree(FilteringLogic.And, 'Downloads'); + colDownloadsExprTree.filteringOperands = [ + { fieldName: 'Downloads', searchVal: 20, condition: IgxNumberFilteringOperand.instance().condition('greaterThanOrEqualTo'), conditionName: 'greaterThanOrEqualTo' }, + { fieldName: 'Downloads', searchVal: 100, condition: IgxNumberFilteringOperand.instance().condition('lessThanOrEqualTo'), conditionName: 'lessThanOrEqualTo' } + ]; + + const colIdExprTree = new FilteringExpressionsTree(FilteringLogic.And, 'ID'); + colIdExprTree.filteringOperands = [ + { fieldName: 'ID', searchVal: 1, condition: IgxNumberFilteringOperand.instance().condition('greaterThan'), conditionName: 'greaterThan' }, + { fieldName: 'ID', searchVal: 5, condition: IgxNumberFilteringOperand.instance().condition('lessThan'), conditionName: 'lessThan' } + ]; + + const gridExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + gridExpressionsTree.filteringOperands = [colDownloadsExprTree, colIdExprTree]; + + grid.filteringExpressionsTree = gridExpressionsTree; + fix.detectChanges(); + + expect(grid.rowList.length).toEqual(1); + expect(grid.filteringExpressionsTree.filteringOperands.length).toEqual(2); + + grid.clearFilter(); + fix.detectChanges(); + + expect(grid.rowList.length).toEqual(8); + expect(grid.filteringExpressionsTree.filteringOperands.length).toEqual(0); + })); + + it('Should correctly apply two conditions to number column.', fakeAsync(() => { + const filteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And, 'Downloads'); + const expression = { + fieldName: 'Downloads', + searchVal: 50, + condition: IgxNumberFilteringOperand.instance().condition('greaterThan'), + conditionName: 'greaterThan' + }; + const expression1 = { + fieldName: 'Downloads', + searchVal: 500, + condition: IgxNumberFilteringOperand.instance().condition('lessThan'), + conditionName: 'lessThan' + }; + filteringExpressionsTree.filteringOperands.push(expression); + filteringExpressionsTree.filteringOperands.push(expression1); + grid.filter('Downloads', null, filteringExpressionsTree); + + fix.detectChanges(); + + expect(grid.rowList.length).toEqual(3); + expect((grid.filteringExpressionsTree.filteringOperands[0] as FilteringExpressionsTree).filteringOperands.length).toEqual(2); + })); + + it('Should correctly apply two conditions to string column.', fakeAsync(() => { + const filteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And, 'ProductName'); + const expression = { + fieldName: 'ProductName', + searchVal: 'Ignite', + condition: IgxStringFilteringOperand.instance().condition('startsWith'), + conditionName: 'startsWith' + }; + const expression1 = { + fieldName: 'ProductName', + searchVal: 'Angular', + condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains' + }; + filteringExpressionsTree.filteringOperands.push(expression); + filteringExpressionsTree.filteringOperands.push(expression1); + grid.filter('ProductName', null, filteringExpressionsTree); + + fix.detectChanges(); + + expect(grid.rowList.length).toEqual(1); + expect((grid.filteringExpressionsTree.filteringOperands[0] as FilteringExpressionsTree).filteringOperands.length).toEqual(2); + })); + + it('Should correctly apply two conditions to date column.', fakeAsync(() => { + const today: Date = new Date(new Date().getFullYear(), new Date().getMonth(), new Date().getDate(), 0, 0, 0); + + const filteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.Or, 'ReleaseDate'); + const expression = { + fieldName: 'ReleaseDate', + searchVal: null, + condition: IgxDateFilteringOperand.instance().condition('yesterday'), + conditionName: 'yesterday' + }; + const expression1 = { + fieldName: 'ReleaseDate', + searchVal: today, + condition: IgxDateFilteringOperand.instance().condition('after'), + conditionName: 'after' + }; + filteringExpressionsTree.filteringOperands.push(expression); + filteringExpressionsTree.filteringOperands.push(expression1); + grid.filter('ReleaseDate', null, filteringExpressionsTree); + + fix.detectChanges(); + + expect(grid.rowList.length).toEqual(4); + expect((grid.filteringExpressionsTree.filteringOperands[0] as FilteringExpressionsTree).filteringOperands.length).toEqual(2); + })); + + it('Should correctly update summary.', fakeAsync(() => { + const gridExpressionsTree = new FilteringExpressionsTree(FilteringLogic.Or); + const filteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.Or, 'ReleaseDate'); + const expression = { + fieldName: 'ReleaseDate', + searchVal: null, + condition: IgxDateFilteringOperand.instance().condition('yesterday'), + conditionName: 'yesterday' + }; + filteringExpressionsTree.filteringOperands.push(expression); + gridExpressionsTree.filteringOperands.push(filteringExpressionsTree); + grid.filteringExpressionsTree = gridExpressionsTree; + + fix.detectChanges(); + tick(100); + + expect(grid.rowList.length).toEqual(1); + + const summaryRow = fix.debugElement.query(By.css('igx-grid-summary-row')); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, ['Count'], ['1']); + })); + + it('should correctly show and hide the "No records found." message.', fakeAsync(() => { + grid.filter('ProductName', 'asdf', IgxStringFilteringOperand.instance().condition('contains'), true); + fix.detectChanges(); + let noRecordsSpan = fix.debugElement.query(By.css('.igx-grid__tbody-message')); + expect(grid.rowList.length).toEqual(0); + expect(noRecordsSpan).toBeTruthy(); + expect(noRecordsSpan.nativeElement.innerText).toBe('No records found.'); + + grid.filteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + fix.detectChanges(); + noRecordsSpan = fix.debugElement.query(By.css('.igx-grid__tbody-message')); + expect(grid.rowList.length).toEqual(8); + expect(noRecordsSpan).toBeFalsy(); + })); + + it('Should generate the expressions UI list correctly.', fakeAsync(() => { + const filteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.Or, 'ProductName'); + const expression = { + fieldName: 'ProductName', + searchVal: 'Ignite', + condition: IgxStringFilteringOperand.instance().condition('startsWith'), + conditionName: 'startsWith' + }; + const expression1 = new FilteringExpressionsTree(FilteringLogic.And, 'ProductName'); + const expression11 = { + fieldName: 'ProductName', + searchVal: 'Angular', + condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains' + }; + const expression12 = { + fieldName: 'ProductName', + searchVal: 'jQuery', + condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains' + }; + const expression2 = new FilteringExpressionsTree(FilteringLogic.And, 'ProductName'); + const expression21 = { + fieldName: 'ProductName', + searchVal: 'Angular', + condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains' + }; + const expression22 = { + fieldName: 'ProductName', + searchVal: 'jQuery', + condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains' + }; + expression1.filteringOperands.push(expression11); + expression1.filteringOperands.push(expression12); + expression2.filteringOperands.push(expression21); + expression2.filteringOperands.push(expression22); + filteringExpressionsTree.filteringOperands.push(expression); + filteringExpressionsTree.filteringOperands.push(expression1); + filteringExpressionsTree.filteringOperands.push(expression2); + grid.filter('ProductName', null, filteringExpressionsTree); + + const expressionUIs: ExpressionUI[] = []; + grid.filteringService.generateExpressionsList(grid.filteringExpressionsTree, grid.filteringLogic, expressionUIs); + + verifyExpressionUI(expressionUIs[0], expression, FilteringLogic.Or, undefined); + verifyExpressionUI(expressionUIs[1], expression11, FilteringLogic.And, FilteringLogic.Or); + verifyExpressionUI(expressionUIs[2], expression12, FilteringLogic.Or, FilteringLogic.And); + verifyExpressionUI(expressionUIs[3], expression21, FilteringLogic.And, FilteringLogic.Or); + verifyExpressionUI(expressionUIs[4], expression22, null, FilteringLogic.And); + })); + + it('Should do nothing when clearing filter of non-existing column.', fakeAsync(() => { + grid.filter('ProductName', 'ignite', IgxStringFilteringOperand.instance().condition('contains'), true); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(2); + + grid.clearFilter('NonExistingColumnName'); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(2); + })); + + it('Should always emit filteringDone with proper eventArgs, even when column does not exist', fakeAsync(() => { + spyOn(grid.filtering, 'emit'); + spyOn(grid.filteringDone, 'emit'); + + grid.filteringLogic = FilteringLogic.Or; + grid.filter('Nonexisting', 'ignite', IgxStringFilteringOperand.instance().condition('contains'), true); + tick(100); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(0); + const args = grid.filteringExpressionsTree.find('Nonexisting') as FilteringExpressionsTree; + expect(grid.filtering.emit).toHaveBeenCalledWith({ owner: grid, cancel: false, filteringExpressions: args }); + expect(grid.filteringDone.emit).toHaveBeenCalledWith(args); + })); + + it('Should emit filteringDone when filtering globally', fakeAsync(() => { + spyOn(grid.filtering, 'emit'); + spyOn(grid.filteringDone, 'emit'); + + grid.filteringLogic = FilteringLogic.Or; + grid.filterGlobal('some', IgxStringFilteringOperand.instance().condition('contains')); + tick(100); + fix.detectChanges(); + + const args = { owner: grid, cancel: false, filteringExpressions: grid.filteringExpressionsTree }; + expect(grid.filtering.emit).toHaveBeenCalledWith(args); + expect(grid.filteringDone.emit).toHaveBeenCalledWith(grid.filteringExpressionsTree); + })); + + it('Should keep existing expressionTree when filtering with a null expressionTree.', fakeAsync(() => { + spyOn(grid.filtering, 'emit'); + spyOn(grid.filteringDone, 'emit'); + + const expression1 = new FilteringExpressionsTree(FilteringLogic.Or, 'ProductName'); + const expression11 = { + fieldName: 'ProductName', + searchVal: 'Angular', + condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains' + }; + + // Verify results after filtering. + expression1.filteringOperands.push(expression11); + grid.filter('ProductName', null, expression1); + tick(30); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(1); + expect(GridFunctions.getCurrentCellFromGrid(grid, 0, 1).value).toBe('Ignite UI for Angular'); + + const args = { owner: grid, cancel: false, filteringExpressions: expression1 }; + expect(grid.filtering.emit).toHaveBeenCalledWith(args); + expect(grid.filteringDone.emit).toHaveBeenCalledWith(expression1); + + // Verify that passing null for expressionTree with a new searchVal will keep the existing expressionTree. + grid.filter('ProductName', 'ignite', null); + tick(30); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(1); + expect(GridFunctions.getCurrentCellFromGrid(grid, 0, 1).value).toBe('Ignite UI for Angular'); + + expect(grid.filtering.emit).toHaveBeenCalledWith(args); + expect(grid.filteringDone.emit).toHaveBeenCalledWith(expression1); + })); + + it('Should throw descriptive error when filter() is called without condition', fakeAsync(() => { + expect(() => { + grid.filter('Downloads', 100); + fix.detectChanges(); + }).toThrowError('Invalid condition or Expression Tree!'); + })); + + it('Should not clear previous filtering when filterGlobal() is called with invalid condition', fakeAsync(() => { + spyOn(grid.filtering, 'emit'); + spyOn(grid.filteringDone, 'emit'); + + grid.filter('Downloads', 100, IgxNumberFilteringOperand.instance().condition('greaterThan'), true); + tick(30); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(4); + expect(grid.getCellByColumn(0, 'Downloads').value).toEqual(254); + + const args = { owner: grid, cancel: false, filteringExpressions: grid.filteringExpressionsTree.find('Downloads') }; + expect(grid.filtering.emit).toHaveBeenCalledWith(args); + expect(grid.filteringDone.emit).toHaveBeenCalledWith(grid.filteringExpressionsTree.find('Downloads')); + + // Execute global filtering with invalid condition. + grid.filterGlobal(1000, null); + fix.detectChanges(); + + expect(grid.rowList.length).toEqual(4); + expect(grid.getCellByColumn(0, 'Downloads').value).toEqual(254); + + expect(grid.filtering.emit).toHaveBeenCalledTimes(1); + expect(grid.filteringDone.emit).toHaveBeenCalledTimes(1); + })); + + it('Should disable filtering feature when using NoopFilteringStrategy.', fakeAsync(() => { + spyOn(grid.filtering, 'emit'); + spyOn(grid.filteringDone, 'emit'); + + // Use the NoopFilteringStrategy. + grid.filterStrategy = NoopFilteringStrategy.instance(); + fix.detectChanges(); + + grid.filter('ProductName', 'some value', IgxStringFilteringOperand.instance().condition('contains')); + tick(30); + fix.detectChanges(); + + // Verify the grid is not filtered, because of the noop filter strategy. + expect(grid.rowList.length).toBe(8); + expect(GridFunctions.getCurrentCellFromGrid(grid, 0, 1).value).toBe('Ignite UI for JavaScript'); + expect(GridFunctions.getCurrentCellFromGrid(grid, 1, 1).value).toBe('NetAdvantage'); + + const filteringExpressions = grid.filteringExpressionsTree.find('ProductName'); + const args = { owner: grid, cancel: false, filteringExpressions }; + expect(grid.filtering.emit).toHaveBeenCalledWith(args); + expect(grid.filteringDone.emit).toHaveBeenCalledWith(filteringExpressions); + })); +}); + +describe('IgxGrid - Filtering expression tree bindings #grid', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxGridFilteringBindingComponent + ] + }).compileComponents(); + })); + + let fix; let grid: IgxGridComponent; + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(IgxGridFilteringBindingComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + })); + + it('should correctly filter with \'filteringExpressionsTree\' binding', fakeAsync(() => { + spyOn(grid.filtering, 'emit'); + spyOn(grid.filteringDone, 'emit'); + + // Verify initially filtered 'Downloads > 200' + expect(grid.rowList.length).toEqual(3); + expect(grid.filteringExpressionsTree.filteringOperands.length).toEqual(1); + + // Verify filtering expressions tree binding state + expect(grid.filteringExpressionsTree).toBe(fix.componentInstance.filterTree); + + // Clear filter + grid.clearFilter('Downloads'); + tick(30); + fix.detectChanges(); + + // Verify filtering expressions tree binding state + expect(grid.filteringExpressionsTree).toBe(fix.componentInstance.filterTree); + + // Verify no filtered data + expect(grid.rowList.length).toEqual(8); + expect(grid.filteringExpressionsTree.filteringOperands.length).toEqual(0); + })); +}); + +const verifyExpressionUI = (expressionUI: ExpressionUI, expression: IFilteringExpression, + afterOperator: FilteringLogic, beforeOperator: FilteringLogic) => { + expect(expressionUI.expression).toBe(expression); + expect(expressionUI.afterOperator).toBe(afterOperator); + expect(expressionUI.beforeOperator).toBe(beforeOperator); +}; diff --git a/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav-headers.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav-headers.spec.ts new file mode 100644 index 00000000000..6514e85712d --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav-headers.spec.ts @@ -0,0 +1,1434 @@ +import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { IgxGridComponent } from './grid.component'; +import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; +import { clearGridSubs, setupGridScrollDetection } from '../../../test-utils/helper-utils.spec'; +import { + SelectionWithScrollsComponent, + MRLTestComponent, + ColumnGroupsNavigationTestComponent +} from '../../../test-utils/grid-samples.spec'; +import { GridFunctions, GridSelectionFunctions } from '../../../test-utils/grid-functions.spec'; +import { GridSelectionMode, FilterMode, IgxGridMRLNavigationService } from 'igniteui-angular/grids/core'; +import { IActiveNodeChangeEventArgs } from 'igniteui-angular/grids/core'; +import { IgxGridHeaderRowComponent } from 'igniteui-angular/grids/core'; +import { IgxStringFilteringOperand, ISortingStrategy, SortingDirection } from 'igniteui-angular/core'; + +const DEBOUNCETIME = 30; + +describe('IgxGrid - Headers Keyboard navigation #grid', () => { + describe('Headers Navigation', () => { + let fix; + let grid: IgxGridComponent; + let gridHeader: IgxGridHeaderRowComponent; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + SelectionWithScrollsComponent, NoopAnimationsModule + ], + providers: [ + IgxGridMRLNavigationService + ] + }).compileComponents(); + })); + + beforeEach(() => { + fix = TestBed.createComponent(SelectionWithScrollsComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + setupGridScrollDetection(fix, grid); + fix.detectChanges(); + gridHeader = GridFunctions.getGridHeader(grid); + }); + + afterEach(() => { + clearGridSubs(); + }); + + it('when click on a header it should stay in the view', async () => { + grid.headerContainer.getScroll().scrollLeft = 1000; + await wait(100); + fix.detectChanges(); + + let header = GridFunctions.getColumnHeader('OnPTO', fix); + UIInteractions.simulateClickAndSelectEvent(header); + await wait(200); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('OnPTO', fix); + expect(header).toBeDefined(); + GridFunctions.verifyHeaderIsFocused(header.parent); + }); + + it('should focus first header when the grid is scrolled', async () => { + grid.navigateTo(7, 5); + await wait(250); + fix.detectChanges(); + + gridHeader.nativeElement.focus(); //('focus', {}); + await wait(250); + fix.detectChanges(); + + const header = GridFunctions.getColumnHeader('ID', fix); + expect(header).not.toBeDefined(); + expect(grid.navigation.activeNode.column).toEqual(3); + expect(grid.navigation.activeNode.row).toEqual(-1); + expect(grid.headerContainer.getScroll().scrollLeft).toBeGreaterThanOrEqual(200); + expect(grid.verticalScrollContainer.getScroll().scrollTop).toBeGreaterThanOrEqual(100); + }); + + it('should emit when activeNode ref is changed', () => { + spyOn(grid.activeNodeChange, 'emit').and.callThrough(); + + const args: IActiveNodeChangeEventArgs = { + row: -1, + column: 0, + level: 0, + tag: 'headerCell' + }; + + gridHeader.nativeElement.focus(); //('focus', null); + fix.detectChanges(); + + expect(grid.activeNodeChange.emit).toHaveBeenCalledWith(args); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader); + fix.detectChanges(); + + args.column += 1; + expect(grid.activeNodeChange.emit).toHaveBeenCalledWith(args); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridHeader); + fix.detectChanges(); + + args.column -= 1; + expect(grid.activeNodeChange.emit).toHaveBeenCalledWith(args); + expect(grid.activeNodeChange.emit).toHaveBeenCalledTimes(3); + }); + + it('should allow horizontal navigation', async () => { + // Focus grid header + gridHeader.nativeElement.focus(); //('focus', null); + fix.detectChanges(); + + // Verify first header is focused + let header = GridFunctions.getColumnHeader('ID', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + + for (let index = 0; index < 5; index++) { + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader); + await wait(DEBOUNCETIME); + fix.detectChanges(); + } + + header = GridFunctions.getColumnHeader('OnPTO', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + // Press arrow right again + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader); + fix.detectChanges(); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + for (let index = 5; index > 1; index--) { + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridHeader); + await wait(DEBOUNCETIME); + fix.detectChanges(); + } + header = GridFunctions.getColumnHeader('ParentID', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + }); + + it('should navigate to first/last header', async () => { + // Focus grid header + let header = GridFunctions.getColumnHeader('ParentID', fix); + UIInteractions.simulateClickAndSelectEvent(header); + fix.detectChanges(); + + // Verify header is focused + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + // Press end key + UIInteractions.triggerEventHandlerKeyDown('End', gridHeader); + await wait(100); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('OnPTO', fix); + expect(header).toBeTruthy(); + expect(grid.navigation.activeNode.column).toEqual(5); + expect(grid.navigation.activeNode.row).toEqual(-1); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + // Press Home ket + UIInteractions.triggerEventHandlerKeyDown('home', gridHeader); + await wait(100); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('ID', fix); + expect(header).toBeTruthy(); + expect(grid.navigation.activeNode.column).toEqual(0); + expect(grid.navigation.activeNode.row).toEqual(-1); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + // Press Ctrl+ Arrow right + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader, false, false, true); + await wait(100); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('OnPTO', fix); + expect(header).toBeTruthy(); + expect(grid.navigation.activeNode.column).toEqual(5); + expect(grid.navigation.activeNode.row).toEqual(-1); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + // Press Ctrl+ Arrow left + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridHeader, false, false, true); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('ID', fix); + expect(header).toBeTruthy(); + expect(grid.navigation.activeNode.column).toEqual(0); + expect(grid.navigation.activeNode.row).toEqual(-1); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + }); + + it('should not change active header on arrow up or down pressed', () => { + // Focus grid header + const header = GridFunctions.getColumnHeader('Name', fix); + UIInteractions.simulateClickAndSelectEvent(header); + fix.detectChanges(); + + // Verify header is focused + GridFunctions.verifyHeaderIsFocused(header.parent); + + // Press arrow down key + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridHeader); + fix.detectChanges(); + + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + // Press arrow up key + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridHeader, false, false, true); + fix.detectChanges(); + + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + // Press pageUp key + UIInteractions.triggerEventHandlerKeyDown('PageUp', gridHeader); + fix.detectChanges(); + + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + // Press pageDown key + UIInteractions.triggerEventHandlerKeyDown('PageUp', gridHeader); + fix.detectChanges(); + + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + }); + + it('Verify navigation when there are pinned columns', async () => { + grid.getColumnByName('ParentID').pinned = true; + fix.detectChanges(); + + // Focus grid header + gridHeader.nativeElement.focus(); //('focus', null); + fix.detectChanges(); + + // Verify first header is focused + let header = GridFunctions.getColumnHeader('ParentID', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + // Navigate to last cell + UIInteractions.triggerEventHandlerKeyDown('End', gridHeader); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('OnPTO', fix); + expect(header).toBeTruthy(); + expect(grid.navigation.activeNode.column).toEqual(5); + expect(grid.navigation.activeNode.row).toEqual(-1); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + // Click on the pinned column + header = GridFunctions.getColumnHeader('ParentID', fix); + UIInteractions.simulateClickAndSelectEvent(header); + fix.detectChanges(); + + // Start navigating right + + for (let index = 0; index < 5; index++) { + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader); + await wait(DEBOUNCETIME); + fix.detectChanges(); + } + + header = GridFunctions.getColumnHeader('OnPTO', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + const hScroll = grid.headerContainer.getScroll().scrollLeft; + + // Navigate with home key + UIInteractions.triggerEventHandlerKeyDown('Home', gridHeader); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('ParentID', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + expect(grid.headerContainer.getScroll().scrollLeft).toEqual(hScroll); + }); + + it('Sorting: Should be able to sort a column with the keyboard', fakeAsync (() => { + spyOn(grid.sorting, 'emit').and.callThrough(); + spyOn(grid.sortingDone, 'emit').and.callThrough(); + grid.getColumnByName('ID').sortable = true; + fix.detectChanges(); + + // Focus grid header + let header = GridFunctions.getColumnHeader('ID', fix); + UIInteractions.simulateClickAndSelectEvent(header); + fix.detectChanges(); + + // Verify first header is focused + GridFunctions.verifyHeaderIsFocused(header.parent); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridHeader, false, false, true); + tick(DEBOUNCETIME); + fix.detectChanges(); + + GridFunctions.verifyHeaderSortIndicator(header, true); + expect(grid.sortingExpressions.length).toEqual(1); + expect(grid.sortingExpressions[0].fieldName).toEqual('ID'); + expect(grid.sortingExpressions[0].dir).toEqual(SortingDirection.Asc); + + expect(grid.sorting.emit).toHaveBeenCalledWith({ + cancel: false, + sortingExpressions: grid.sortingExpressions, + owner: grid + }); + + GridFunctions.verifyHeaderIsFocused(header.parent); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridHeader, false, false, true); + tick(DEBOUNCETIME); + fix.detectChanges(); + + GridFunctions.verifyHeaderSortIndicator(header, false, false); + expect(grid.sortingExpressions.length).toEqual(0); + + GridFunctions.verifyHeaderIsFocused(header.parent); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridHeader, false, false, true); + tick(DEBOUNCETIME); + fix.detectChanges(); + + GridFunctions.verifyHeaderSortIndicator(header, true); + expect(grid.sortingExpressions.length).toEqual(1); + expect(grid.sortingExpressions[0].fieldName).toEqual('ID'); + expect(grid.sortingExpressions[0].dir).toEqual(SortingDirection.Asc); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridHeader, false, false, true); + tick(DEBOUNCETIME); + fix.detectChanges(); + + GridFunctions.verifyHeaderSortIndicator(header, false, true); + expect(grid.sortingExpressions.length).toEqual(1); + expect(grid.sortingExpressions[0].fieldName).toEqual('ID'); + expect(grid.sortingExpressions[0].dir).toEqual(SortingDirection.Desc); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridHeader, false, false, true); + tick(DEBOUNCETIME); + fix.detectChanges(); + + GridFunctions.verifyHeaderSortIndicator(header, false, false); + expect(grid.sortingExpressions.length).toEqual(0); + + // select not sortable column + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('ParentID', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridHeader, false, false, true); + tick(DEBOUNCETIME); + fix.detectChanges(); + + GridFunctions.verifyHeaderSortIndicator(header, false, false, false); + expect(grid.sortingExpressions.length).toEqual(0); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridHeader, false, false, true); + tick(DEBOUNCETIME); + fix.detectChanges(); + + GridFunctions.verifyHeaderSortIndicator(header, false, false, false); + expect(grid.sortingExpressions.length).toEqual(0); + + expect(grid.sorting.emit).toHaveBeenCalledTimes(5); + expect(grid.sortingDone.emit).toHaveBeenCalledTimes(5); + })); + + it('Filtering: Should be able to open filter row with the keyboard', () => { + // Focus grid header + let header = GridFunctions.getColumnHeader('ID', fix); + UIInteractions.simulateClickAndSelectEvent(header); + fix.detectChanges(); + + // Verify first header is focused + GridFunctions.verifyHeaderIsFocused(header.parent); + + // Test when grid does not have filtering + UIInteractions.triggerEventHandlerKeyDown('L', gridHeader, false, true, true); + fix.detectChanges(); + + let filterRow = GridFunctions.getFilterRow(fix); + expect(filterRow).toBeNull(); + + // Allow filtering + grid.allowFiltering = true; + grid.getColumnByName('ID').filterable = false; + fix.detectChanges(); + + // Try to open filter row for not filterable column + UIInteractions.triggerEventHandlerKeyDown('L', gridHeader, false, true, true); + fix.detectChanges(); + + filterRow = GridFunctions.getFilterRow(fix); + expect(filterRow).toBeNull(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader); + fix.detectChanges(); + header = GridFunctions.getColumnHeader('ParentID', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + + // Try to open filter row for not filterable column + UIInteractions.triggerEventHandlerKeyDown('L', gridHeader, false, true, true); + fix.detectChanges(); + + filterRow = GridFunctions.getFilterRow(fix); + expect(filterRow).not.toBeNull(); + expect(grid.filteringRow.column.field).toEqual('ParentID'); + }); + + it('Excel Style Filtering: Should be able to open ESF with the keyboard', () => { + // Allow ESF + grid.allowFiltering = true; + grid.filterMode = FilterMode.excelStyleFilter; + grid.getColumnByName('ID').filterable = false; + fix.detectChanges(); + + let header = GridFunctions.getColumnHeader('ID', fix); + UIInteractions.simulateClickAndSelectEvent(header); + fix.detectChanges(); + + // Try to open filter for not filterable column + UIInteractions.triggerEventHandlerKeyDown('L', gridHeader, false, true, true); + fix.detectChanges(); + + let filterDialog = GridFunctions.getExcelStyleFilteringComponent(fix); + expect(filterDialog).toBeNull(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader); + fix.detectChanges(); + header = GridFunctions.getColumnHeader('ParentID', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + + // Open filter + UIInteractions.triggerEventHandlerKeyDown('L', gridHeader, false, true, true); + fix.detectChanges(); + + filterDialog = GridFunctions.getExcelStyleFilteringComponent(fix); + expect(filterDialog).toBeDefined(); + }); + + it('Advanced Filtering: Should be able to open Advanced filter', () => { + const header = GridFunctions.getColumnHeader('ID', fix); + UIInteractions.simulateClickAndSelectEvent(header); + fix.detectChanges(); + + // Verify first header is focused + GridFunctions.verifyHeaderIsFocused(header.parent); + + // Test when advanced filtering is disabled + UIInteractions.triggerEventHandlerKeyDown('L', gridHeader, true); + fix.detectChanges(); + + // Verify AF dialog is not opened. + expect(GridFunctions.getAdvancedFilteringComponent(fix)).toBeNull(); + + // Enable Advanced Filtering + grid.allowAdvancedFiltering = true; + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('L', gridHeader, true); + fix.detectChanges(); + + // Verify AF dialog is opened. + expect(GridFunctions.getAdvancedFilteringComponent(fix)).not.toBeNull(); + }); + + it('Advanced Filtering: Should be able to close Advanced filtering with "escape"', fakeAsync(() => { + // Enable Advanced Filtering + grid.allowAdvancedFiltering = true; + fix.detectChanges(); + let header = GridFunctions.getColumnHeader('Name', fix); + UIInteractions.simulateClickAndSelectEvent(header); + fix.detectChanges(); + + // Verify first header is focused + GridFunctions.verifyHeaderIsFocused(header.parent); + + UIInteractions.triggerEventHandlerKeyDown('L', gridHeader, true); + fix.detectChanges(); + + // Verify AF dialog is opened. + expect(GridFunctions.getAdvancedFilteringComponent(fix)).not.toBeNull(); + + const afDialog = fix.nativeElement.querySelector('.igx-advanced-filter'); + UIInteractions.triggerKeyDownEvtUponElem('Escape', afDialog); + tick(100); + fix.detectChanges(); + + // Verify AF dialog is closed. + header = GridFunctions.getColumnHeader('Name', fix); + expect(GridFunctions.getAdvancedFilteringComponent(fix)).toBeNull(); + GridFunctions.verifyHeaderIsFocused(header.parent); + })); + + + it('Column selection: Should be able to select columns when columnSelection is multi', () => { + const columnID = grid.getColumnByName('ID'); + const columnParentID = grid.getColumnByName('ParentID'); + const columnName = grid.getColumnByName('Name'); + columnName.selectable = false; + expect(grid.columnSelection).toEqual(GridSelectionMode.none); + let header = GridFunctions.getColumnHeader('ID', fix); + UIInteractions.simulateClickAndSelectEvent(header); + fix.detectChanges(); + + // Verify first header is focused + GridFunctions.verifyHeaderIsFocused(header.parent); + + // Press space when the columnSelection is none + UIInteractions.triggerEventHandlerKeyDown('Space', gridHeader); + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnAndCellsSelected(columnID, false); + + grid.columnSelection = GridSelectionMode.multiple; + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('Space', gridHeader); + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnAndCellsSelected(columnID); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('ParentID', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + + UIInteractions.triggerEventHandlerKeyDown('Space', gridHeader); + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnAndCellsSelected(columnID); + GridSelectionFunctions.verifyColumnAndCellsSelected(columnParentID); + + UIInteractions.triggerEventHandlerKeyDown('Space', gridHeader); + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnAndCellsSelected(columnID); + GridSelectionFunctions.verifyColumnAndCellsSelected(columnParentID, false); + + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('Name', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + + // Press Space on not selectable column + UIInteractions.triggerEventHandlerKeyDown('Space', gridHeader); + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnAndCellsSelected(columnID); + GridSelectionFunctions.verifyColumnAndCellsSelected(columnName, false); + }); + + it('Column selection: Should be able to select columns when columnSelection is single', () => { + spyOn(grid.columnSelectionChanging, 'emit').and.callThrough(); + const columnID = grid.getColumnByName('ID'); + const columnParentID = grid.getColumnByName('ParentID'); + const columnName = grid.getColumnByName('Name'); + columnName.selectable = false; + grid.columnSelection = GridSelectionMode.single; + + let header = GridFunctions.getColumnHeader('ID', fix); + UIInteractions.simulateClickAndSelectEvent(header); + fix.detectChanges(); + + // Verify first header is focused + GridFunctions.verifyHeaderIsFocused(header.parent); + GridSelectionFunctions.verifyColumnAndCellsSelected(columnID); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('ParentID', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + + UIInteractions.triggerEventHandlerKeyDown('Space', gridHeader); + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnAndCellsSelected(columnID, false); + GridSelectionFunctions.verifyColumnAndCellsSelected(columnParentID); + + UIInteractions.triggerEventHandlerKeyDown('Space', gridHeader); + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnAndCellsSelected(columnParentID, false); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('Name', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + + // Press Space on not selectable column + UIInteractions.triggerEventHandlerKeyDown('Space', gridHeader); + fix.detectChanges(); + + GridSelectionFunctions.verifyColumnAndCellsSelected(columnName, false); + expect(grid.columnSelectionChanging.emit).toHaveBeenCalledTimes(3); + }); + + it('Group by: Should be able group columns with keyboard', () => { + spyOn(grid.groupingDone, 'emit').and.callThrough(); + grid.getColumnByName('ID').groupable = true; + grid.getColumnByName('Name').groupable = true; + + let header = GridFunctions.getColumnHeader('ID', fix); + UIInteractions.simulateClickAndSelectEvent(header); + fix.detectChanges(); + + // Verify first header is focused + GridFunctions.verifyHeaderIsFocused(header.parent); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader, true, true); + fix.detectChanges(); + + expect(grid.groupingExpressions.length).toEqual(1); + expect(grid.groupingExpressions[0].fieldName).toEqual('ID'); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader); + fix.detectChanges(); + + // Try to group not groupable column + header = GridFunctions.getColumnHeader('ParentID', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader, true, true); + fix.detectChanges(); + + expect(grid.groupingExpressions.length).toEqual(1); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('Name', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + + // Press Space on not selectable column + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader, true, true); + fix.detectChanges(); + + expect(grid.groupingExpressions.length).toEqual(2); + expect(grid.groupingExpressions[0].fieldName).toEqual('ID'); + expect(grid.groupingExpressions[1].fieldName).toEqual('Name'); + + // Ungroup column + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridHeader, true, true); + fix.detectChanges(); + + expect(grid.groupingExpressions.length).toEqual(1); + expect(grid.groupingExpressions[0].fieldName).toEqual('ID'); + + // Ungroup not grouped column + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridHeader, true, true); + fix.detectChanges(); + + expect(grid.groupingExpressions.length).toEqual(1); + expect(grid.groupingExpressions[0].fieldName).toEqual('ID'); + expect(grid.groupingDone.emit).toHaveBeenCalled(); + }); + + it('Group by: Should be able group columns with keyboard when hideGroupedColumns is true', fakeAsync(() => { + grid.width = '1000px'; + grid.hideGroupedColumns = true; + grid.columns.forEach(c => c.groupable = true); + fix.detectChanges(); + tick(100); + let header = GridFunctions.getColumnHeader('ID', fix); + UIInteractions.simulateClickAndSelectEvent(header); + fix.detectChanges(); + + // Group by first column + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader, true, true); + tick(100); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('ParentID', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + expect(grid.groupingExpressions.length).toEqual(1); + expect(grid.groupingExpressions[0].fieldName).toEqual('ID'); + GridFunctions.verifyColumnIsHidden(grid.getColumnByName('ID'), true, 5); + + // Go to last column + UIInteractions.triggerEventHandlerKeyDown('End', gridHeader); + fix.detectChanges(); + + // Try to group not groupable column + header = GridFunctions.getColumnHeader('OnPTO', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader, true, true); + tick(100); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('Age', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + expect(grid.groupingExpressions.length).toEqual(2); + expect(grid.groupingExpressions[0].fieldName).toEqual('ID'); + expect(grid.groupingExpressions[1].fieldName).toEqual('OnPTO'); + GridFunctions.verifyColumnIsHidden(grid.getColumnByName('OnPTO'), true, 4); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('HireDate', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + })); + + it('Group by: Should respect column properties when grouping with keyboard', () => { + grid.sort({ fieldName: 'ID', dir: SortingDirection.Desc }); + + let sortStrategy: ISortingStrategy; + const comparer = (a: string, b: string) => (a.toLowerCase() === b.toLowerCase() ? 0 : -1); + + const column = grid.getColumnByName('ID'); + column.groupable = true; + column.sortingIgnoreCase = false; + column.sortStrategy = sortStrategy; + column.groupingComparer = comparer; + + (grid.navigation as any).performHeaderKeyCombination(column, 'arrowright', true, false, true); + + expect(grid.groupingExpressions[0].fieldName).toEqual(column.field); + expect(grid.groupingExpressions[0].dir).toEqual(2); + expect(grid.groupingExpressions[0].ignoreCase).toEqual(false); + expect(grid.groupingExpressions[0].strategy).toBeUndefined(); + expect(grid.groupingExpressions[0].groupingComparer).toEqual(comparer); + }); + it('should set aria-activedescendant to the currently focused header', async () => { + let header = GridFunctions.getColumnHeader('ID', fix); + UIInteractions.simulateClickAndSelectEvent(header); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('ParentID', fix); + + GridFunctions.verifyHeaderIsFocused(header.parent) + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + }); + }); + + describe('MRL Headers Navigation', () => { + let fix; + let grid: IgxGridComponent; + let gridHeader: IgxGridHeaderRowComponent; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + MRLTestComponent, NoopAnimationsModule + ], + providers: [ + IgxGridMRLNavigationService + ] + }).compileComponents(); + })); + + beforeEach(() => { + fix = TestBed.createComponent(MRLTestComponent); + fix.detectChanges(); + + grid = fix.componentInstance.grid; + setupGridScrollDetection(fix, grid); + fix.detectChanges(); + gridHeader = GridFunctions.getGridHeader(grid); + }); + + afterEach(() => { + clearGridSubs(); + }); + + it('should navigate through a layout with right and left arrow keys in first level', async () => { + let header = GridFunctions.getColumnHeader('CompanyName', fix); + UIInteractions.simulateClickAndSelectEvent(header); + fix.detectChanges(); + + // Verify first header is focused + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('City', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('Country', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('Phone', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridHeader); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('Country', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridHeader); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('City', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridHeader); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('CompanyName', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + }); + + it('should navigate through a layout with right and left arrow keys in second level', async () => { + let header = GridFunctions.getColumnHeader('ContactTitle', fix); + UIInteractions.simulateClickAndSelectEvent(header); + fix.detectChanges(); + + // Verify first header is focused + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('City', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('Fax', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridHeader); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('City', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridHeader); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('ContactTitle', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridHeader); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('ContactName', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + }); + + it('should navigate through a layout with home and end keys', async () => { + let header = GridFunctions.getColumnHeader('ContactTitle', fix); + UIInteractions.simulateClickAndSelectEvent(header); + fix.detectChanges(); + + // Verify first header is focused + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader, false, false, true); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('Fax', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridHeader, false, false, true); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('ContactName', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + header = GridFunctions.getColumnHeader('Address', fix); + UIInteractions.simulateClickAndSelectEvent(header); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('End', gridHeader); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('Fax', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('home', gridHeader); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('Address', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + }); + + it('should navigate through a layout with up and down arrow keys', () => { + let header = GridFunctions.getColumnHeader('ContactTitle', fix); + UIInteractions.simulateClickAndSelectEvent(header); + fix.detectChanges(); + + // Verify first header is focused + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('CompanyName', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('ContactTitle', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('Address', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('ContactTitle', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + }); + + it('should focus the first element when focus the header', async () => { + gridHeader.nativeElement.focus(); //('focus', null); + fix.detectChanges(); + + const header = GridFunctions.getColumnHeader('CompanyName', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + }); + }); + + describe('MCH Headers Navigation', () => { + let fix; + let grid: IgxGridComponent; + let gridHeader: IgxGridHeaderRowComponent; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + ColumnGroupsNavigationTestComponent, NoopAnimationsModule + ] + }).compileComponents(); + })); + + beforeEach(() => { + fix = TestBed.createComponent(ColumnGroupsNavigationTestComponent); + fix.detectChanges(); + + grid = fix.componentInstance.grid; + setupGridScrollDetection(fix, grid); + fix.detectChanges(); + gridHeader = GridFunctions.getGridHeader(grid); + }); + + afterEach(() => { + clearGridSubs(); + }); + + it('should navigate through groups with right and left arrow keys in first level', () => { + let header = GridFunctions.getColumnGroupHeaderCell('General Information', fix); + UIInteractions.simulateClickAndSelectEvent(header); + fix.detectChanges(); + + // Verify first header is focused + GridFunctions.verifyHeaderIsFocused(header); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('ID', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnGroupHeaderCell('Address Information', fix); + GridFunctions.verifyHeaderIsFocused(header); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader); + fix.detectChanges(); + GridFunctions.verifyHeaderIsFocused(header); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('ID', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnGroupHeaderCell('General Information', fix); + GridFunctions.verifyHeaderIsFocused(header); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnGroupHeaderCell('General Information', fix); + GridFunctions.verifyHeaderIsFocused(header); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + }); + + it('should navigate through groups with right and left arrow keys in child level', () => { + let header = GridFunctions.getColumnHeader('ContactTitle', fix); + UIInteractions.simulateClickAndSelectEvent(header); + fix.detectChanges(); + + // Verify first header is focused + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('ID', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('Region', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('Country', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnGroupHeaderCell('City Information', fix); + GridFunctions.verifyHeaderIsFocused(header); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('Country', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('Region', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('ID', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('ContactTitle', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + }); + + it('should navigate through groups with Home and End keys', () => { + let header = GridFunctions.getColumnHeader('ID', fix); + UIInteractions.simulateClickAndSelectEvent(header); + fix.detectChanges(); + + // Verify first header is focused + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader, false, false, true); + fix.detectChanges(); + + header = GridFunctions.getColumnGroupHeaderCell('Address Information', fix); + GridFunctions.verifyHeaderIsFocused(header); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridHeader, false, false, true); + fix.detectChanges(); + + header = GridFunctions.getColumnGroupHeaderCell('General Information', fix); + GridFunctions.verifyHeaderIsFocused(header); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + header = GridFunctions.getColumnHeader('City', fix); + UIInteractions.simulateClickAndSelectEvent(header); + fix.detectChanges(); + + // Verify first header is focused + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('Home', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('CompanyName', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('End', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('Address', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + }); + + it('should navigate through groups with arrowUp and down keys', () => { + let header = GridFunctions.getColumnHeader('City', fix); + UIInteractions.simulateClickAndSelectEvent(header); + fix.detectChanges(); + + // Verify first header is focused + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnGroupHeaderCell('City Information', fix); + GridFunctions.verifyHeaderIsFocused(header); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnGroupHeaderCell('Country Information', fix); + GridFunctions.verifyHeaderIsFocused(header); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnGroupHeaderCell('Address Information', fix); + GridFunctions.verifyHeaderIsFocused(header); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnGroupHeaderCell('Country Information', fix); + GridFunctions.verifyHeaderIsFocused(header); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnGroupHeaderCell('City Information', fix); + GridFunctions.verifyHeaderIsFocused(header); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('City', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridHeader); + fix.detectChanges(); + + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + // click on parent + header = GridFunctions.getColumnGroupHeaderCell('Address Information', fix); + + UIInteractions.simulateClickAndSelectEvent(header); + fix.detectChanges(); + + GridFunctions.verifyHeaderIsFocused(header); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridHeader); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('Region', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + }); + + it('should focus the first element when focus the header', () => { + gridHeader.nativeElement.focus(); //('focus', null); + fix.detectChanges(); + + let header = GridFunctions.getColumnGroupHeaderCell('General Information', fix); + GridFunctions.verifyHeaderIsFocused(header); + GridFunctions.verifyHeaderActiveDescendant(gridHeader, header.nativeElement.id); + + // Verify children are not focused + header = GridFunctions.getColumnGroupHeaderCell('Person Details', fix); + GridFunctions.verifyHeaderIsFocused(header, false); + + header = GridFunctions.getColumnGroupHeaderCell('Person Details', fix); + GridFunctions.verifyHeaderIsFocused(header, false); + + header = GridFunctions.getColumnHeader('CompanyName', fix); + GridFunctions.verifyHeaderIsFocused(header.parent, false); + }); + + it('should be able to expand collapse column group with the keyboard', () => { + const getInfGroup = GridFunctions.getColGroup(grid, 'General Information'); + const personDetailsGroup = GridFunctions.getColGroup(grid, 'Person Details'); + const companyName = grid.getColumnByName('CompanyName'); + getInfGroup.collapsible = true; + personDetailsGroup.visibleWhenCollapsed = true; + companyName.visibleWhenCollapsed = false; + fix.detectChanges(); + + GridFunctions.verifyColumnIsHidden(companyName, false, 10); + GridFunctions.verifyGroupIsExpanded(fix, getInfGroup); + + gridHeader.nativeElement.focus(); //('focus', null); + fix.detectChanges(); + + const header = GridFunctions.getColumnGroupHeaderCell('General Information', fix); + GridFunctions.verifyHeaderIsFocused(header); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridHeader, true); + fix.detectChanges(); + + GridFunctions.verifyColumnIsHidden(companyName, true, 12); + GridFunctions.verifyGroupIsExpanded(fix, getInfGroup, true, false); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader, true); + fix.detectChanges(); + + GridFunctions.verifyColumnIsHidden(companyName, false, 10); + GridFunctions.verifyGroupIsExpanded(fix, getInfGroup); + + // set group not to be collapsible + getInfGroup.collapsible = false; + fix.detectChanges(); + + GridFunctions.verifyColumnIsHidden(companyName, false, 13); + GridFunctions.verifyGroupIsExpanded(fix, getInfGroup, false); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridHeader, true); + fix.detectChanges(); + + GridFunctions.verifyColumnIsHidden(companyName, false, 13); + GridFunctions.verifyGroupIsExpanded(fix, getInfGroup, false); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridHeader, true); + fix.detectChanges(); + + GridFunctions.verifyColumnIsHidden(companyName, false, 13); + GridFunctions.verifyGroupIsExpanded(fix, getInfGroup, false); + }); + + it('Column selection: should be possible to select column group with the keyboard', () => { + grid.columnSelection = GridSelectionMode.multiple; + fix.detectChanges(); + + gridHeader.nativeElement.focus(); //('focus', null); + fix.detectChanges(); + + const header = GridFunctions.getColumnGroupHeaderCell('General Information', fix); + GridFunctions.verifyHeaderIsFocused(header); + + UIInteractions.triggerEventHandlerKeyDown('Space', gridHeader); + fix.detectChanges(); + fix.detectChanges(); + + expect(grid.getColumnByName('CompanyName').selected).toBeTruthy(); + expect(grid.getColumnByName('ContactName').selected).toBeTruthy(); + expect(grid.getColumnByName('ContactTitle').selected).toBeTruthy(); + + UIInteractions.triggerEventHandlerKeyDown('Space', gridHeader); + fix.detectChanges(); + + expect(grid.selectedColumns().length).toEqual(0); + }); + + it('Features Integration: should nor be possible to sort, filter or groupBy column group', () => { + grid.allowAdvancedFiltering = true; + grid.columns.forEach(c => { +c.sortable = true; c.groupable = true; +}); + fix.detectChanges(); + + const header = GridFunctions.getColumnGroupHeaderCell('General Information', fix); + UIInteractions.simulateClickAndSelectEvent(header); + fix.detectChanges(); + + GridFunctions.verifyHeaderIsFocused(header); + + // Press Ctrl+ Arrow Up and down on group to sort it + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridHeader, false, false, true); + fix.detectChanges(); + + expect(grid.sortingExpressions.length).toEqual(0); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridHeader, false, false, true); + fix.detectChanges(); + + expect(grid.sortingExpressions.length).toEqual(0); + + // Press Shift + Alt + Arrow left on group to groupBy it + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader, true, true); + fix.detectChanges(); + + expect(grid.groupingExpressions.length).toEqual(0); + + + // Press Ctrl + Shift + L on group to open filter row + UIInteractions.triggerEventHandlerKeyDown('L', gridHeader, false, true, true); + fix.detectChanges(); + + expect(GridFunctions.getFilterRow(fix)).toBeNull(); + + // Change filter mode to be excel style filter + grid.filterMode = FilterMode.excelStyleFilter; + fix.detectChanges(); + + // Press Ctrl + Shift + L on group to open excel style filter + UIInteractions.triggerEventHandlerKeyDown('L', gridHeader, false, true, true); + fix.detectChanges(); + + expect(GridFunctions.getExcelStyleFilteringComponent(fix)).toBeNull(); + }); + + it('MCH Grid with no data: should be able to navigate with arrow keys in the headers', () => { + grid.filter('Country', 'Bulgaria', IgxStringFilteringOperand.instance().condition('contains'), true); + fix.detectChanges(); + + expect(grid.rowList.length).toBe(0); + + let header = GridFunctions.getColumnGroupHeaderCell('General Information', fix); + UIInteractions.simulateClickAndSelectEvent(header); + fix.detectChanges(); + + GridFunctions.verifyHeaderIsFocused(header); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader, false, false, true); + fix.detectChanges(); + + header = GridFunctions.getColumnGroupHeaderCell('Address Information', fix); + GridFunctions.verifyHeaderIsFocused(header); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridHeader, false, false, false); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('Region', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridHeader, false, false, false); + fix.detectChanges(); + + header = GridFunctions.getColumnGroupHeaderCell('Country Information', fix); + GridFunctions.verifyHeaderIsFocused(header); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridHeader, false, false, true); + fix.detectChanges(); + + header = GridFunctions.getColumnHeader('CompanyName', fix); + GridFunctions.verifyHeaderIsFocused(header.parent); + }); + }); +}); diff --git a/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav.spec.ts new file mode 100644 index 00000000000..8b48210269f --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid-keyBoardNav.spec.ts @@ -0,0 +1,1044 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxGridComponent } from './grid.component'; +import { IGridCellEventArgs, IActiveNodeChangeEventArgs } from 'igniteui-angular/grids/core'; +import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; +import { clearGridSubs, setupGridScrollDetection } from '../../../test-utils/helper-utils.spec'; +import { + VirtualGridComponent, + NoScrollsComponent, + IgxGridGroupByComponent +} from '../../../test-utils/grid-samples.spec'; + +import { GridFunctions, GridSelectionFunctions } from '../../../test-utils/grid-functions.spec'; +import { DebugElement, QueryList } from '@angular/core'; +import { IgxGridGroupByRowComponent } from './groupby-row.component'; +import { CellType } from 'igniteui-angular/grids/core'; +import { DefaultSortingStrategy, SortingDirection } from 'igniteui-angular/core'; + +const DEBOUNCETIME = 30; + +describe('IgxGrid - Keyboard navigation #grid', () => { + + describe('in not virtualized grid', () => { + let fix; + let grid: IgxGridComponent; + let gridContent: DebugElement; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoScrollsComponent, NoopAnimationsModule + ] + }).compileComponents(); + })); + + beforeEach(() => { + fix = TestBed.createComponent(NoScrollsComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + gridContent = GridFunctions.getGridContent(fix); + }); + + it('should move selected cell with arrow keys', () => { + let selectedCell: CellType; + + grid.selected.subscribe((event: IGridCellEventArgs) => { + selectedCell = grid.gridAPI.get_cell_by_index(event.cell.row.index, event.cell.column.field); + }); + + // Focus and select first cell + GridFunctions.focusFirstCell(fix, grid); + + UIInteractions.triggerEventHandlerKeyDown('arrowdown', gridContent); + fix.detectChanges(); + + expect(selectedCell.value).toEqual(2); + expect(selectedCell.column.field).toMatch('ID'); + GridFunctions.verifyGridContentActiveDescendant(gridContent, selectedCell.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('arrowright', gridContent); + fix.detectChanges(); + + expect(selectedCell.value).toEqual('Gilberto Todd'); + expect(selectedCell.column.field).toMatch('Name'); + GridFunctions.verifyGridContentActiveDescendant(gridContent, selectedCell.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('arrowup', gridContent); + fix.detectChanges(); + + expect(selectedCell.value).toEqual('Casey Houston'); + expect(selectedCell.column.field).toMatch('Name'); + GridFunctions.verifyGridContentActiveDescendant(gridContent, selectedCell.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('arrowleft', gridContent); + fix.detectChanges(); + + expect(selectedCell.value).toEqual(1); + expect(selectedCell.column.field).toMatch('ID'); + GridFunctions.verifyGridContentActiveDescendant(gridContent, selectedCell.nativeElement.id); + }); + + it('should jump to first/last cell with Ctrl', () => { + let selectedCell: CellType; + grid.selected.subscribe((event: IGridCellEventArgs) => { + selectedCell = grid.gridAPI.get_cell_by_index(event.cell.row.index, event.cell.column.field); + }); + + GridFunctions.focusFirstCell(fix, grid); + + UIInteractions.triggerEventHandlerKeyDown('arrowright', gridContent, false, false, true); + fix.detectChanges(); + + expect(selectedCell.value).toEqual('Company A'); + expect(selectedCell.column.field).toMatch('Company'); + GridFunctions.verifyGridContentActiveDescendant(gridContent, selectedCell.nativeElement.id); + + UIInteractions.triggerEventHandlerKeyDown('arrowleft', gridContent, false, false, true); + fix.detectChanges(); + + expect(selectedCell.value).toEqual(1); + expect(selectedCell.column.field).toMatch('ID'); + GridFunctions.verifyGridContentActiveDescendant(gridContent, selectedCell.nativeElement.id); + }); + + it('should allow vertical keyboard navigation in pinned area.', () => { + grid.getColumnByName('Name').pinned = true; + fix.detectChanges(); + + let selectedCell; + grid.selected.subscribe((event: IGridCellEventArgs) => { + selectedCell = event.cell; + }); + GridFunctions.focusFirstCell(fix, grid); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('arrowdown', gridContent); + fix.detectChanges(); + + expect(selectedCell.value).toEqual('Gilberto Todd'); + expect(selectedCell.column.field).toMatch('Name'); + + UIInteractions.triggerEventHandlerKeyDown('arrowup', gridContent); + fix.detectChanges(); + + expect(selectedCell.value).toEqual('Casey Houston'); + expect(selectedCell.column.field).toMatch('Name'); + }); + + it('Should emit when activeNode ref is changed', () => { + spyOn(grid.activeNodeChange, 'emit').and.callThrough(); + + const args: IActiveNodeChangeEventArgs = { + row: 0, + column: 0, + level: 0, + tag: 'dataCell' + }; + + gridContent.triggerEventHandler('focus', null); + fix.detectChanges(); + expect(grid.activeNodeChange.emit).toHaveBeenCalledWith(args); + + UIInteractions.triggerEventHandlerKeyDown('arrowright', gridContent); + fix.detectChanges(); + args.column += 1; + expect(grid.activeNodeChange.emit).toHaveBeenCalledWith(args); + + UIInteractions.triggerEventHandlerKeyDown('arrowright', gridContent); + fix.detectChanges(); + args.column += 1; + expect(grid.activeNodeChange.emit).toHaveBeenCalledWith(args); + + UIInteractions.triggerEventHandlerKeyDown('arrowdown', gridContent); + fix.detectChanges(); + args.row += 1; + expect(grid.activeNodeChange.emit).toHaveBeenCalledWith(args); + + UIInteractions.triggerEventHandlerKeyDown('arrowleft', gridContent); + fix.detectChanges(); + args.column -= 1; + expect(grid.activeNodeChange.emit).toHaveBeenCalledWith(args); + + expect(grid.activeNodeChange.emit).toHaveBeenCalledTimes(5); + }); + + + it('should emit activeNodeChange once when you click over the same element', () => { + spyOn(grid.activeNodeChange, 'emit').and.callThrough(); + + gridContent.triggerEventHandler('focus', null); + fix.detectChanges(); + + const activeNode = grid.navigation.activeNode; + const cell = grid.gridAPI.get_cell_by_index(activeNode.row, activeNode.column); + UIInteractions.simulateMouseEvent('mousedown', cell.nativeElement, 0, 0); + fix.detectChanges(); + + expect(grid.activeNodeChange.emit).toHaveBeenCalledTimes(1); + }); + + it('should allow horizontal keyboard navigation between start pinned area and unpinned area.', () => { + grid.getColumnByName('Name').pinned = true; + grid.getColumnByName('Company').pinned = true; + fix.detectChanges(); + + let selectedCell; + grid.selected.subscribe((event: IGridCellEventArgs) => { + selectedCell = event.cell; + }); + GridFunctions.focusFirstCell(fix, grid); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('arrowright', gridContent); + fix.detectChanges(); + + expect(selectedCell.value).toEqual('Company A'); + expect(selectedCell.column.field).toMatch('Company'); + + UIInteractions.triggerEventHandlerKeyDown('arrowright', gridContent); + fix.detectChanges(); + + expect(selectedCell.value).toEqual(1); + expect(selectedCell.column.field).toMatch('ID'); + + UIInteractions.triggerEventHandlerKeyDown('arrowleft', gridContent); + fix.detectChanges(); + + expect(selectedCell.value).toEqual('Company A'); + expect(selectedCell.column.field).toMatch('Company'); + }); + }); + + describe('in virtualized grid', () => { + let fix; + let grid: IgxGridComponent; + let gridContent: DebugElement; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + VirtualGridComponent, NoopAnimationsModule + ] + }).compileComponents(); + })); + + beforeEach(() => { + fix = TestBed.createComponent(VirtualGridComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + setupGridScrollDetection(fix, grid); + fix.detectChanges(); + gridContent = GridFunctions.getGridContent(fix); + }); + + afterEach(() => { + clearGridSubs(); + }); + + it('should focus the first cell when focus the grid body', async () => { + GridFunctions.getGridHeader(grid).nativeElement.focus(); + fix.detectChanges(); + const cols = []; + for (let i = 0; i < 10; i++) { + cols.push({ field: 'col' + i }); + } + fix.componentInstance.columns = cols; + fix.componentInstance.data = fix.componentInstance.generateData(100); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + grid.headerContainer.getScroll().scrollLeft = 1000; + await wait(100); + fix.detectChanges(); + + grid.verticalScrollContainer.getScroll().scrollTop = 200; + await wait(200); + fix.detectChanges(); + + gridContent.triggerEventHandler('focus', null); + await wait(400); + fix.detectChanges(); + + const cell = grid.gridAPI.get_cell_by_index(4, 'col5'); + expect(cell).toBeDefined(); + GridSelectionFunctions.verifyCellActive(cell); + GridSelectionFunctions.verifyCellSelected(cell); + GridFunctions.verifyGridContentActiveDescendant(GridFunctions.getGridContent(fix), cell.nativeElement.id); + }); + + it('should allow navigating down', async () => { + GridFunctions.focusFirstCell(fix, grid); + await wait(); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('arrowdown', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('arrowdown', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('arrowdown', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.row.index).toEqual(3); + }); + + it('should allow navigating up', async () => { + grid.verticalScrollContainer.scrollTo(104); + await wait(); + fix.detectChanges(); + + const cell = grid.gridAPI.get_cell_by_index(100, 'value'); + UIInteractions.simulateClickAndSelectEvent(cell); + await wait(); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.row.index).toEqual(100); + // Navigate to the 94th row + UIInteractions.triggerEventHandlerKeyDown('arrowup', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('arrowup', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('arrowup', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.row.index).toEqual(97); + }); + + it('should allow horizontal navigation', async () => { + const cols = []; + for (let i = 0; i < 10; i++) { + cols.push({ field: 'col' + i }); + } + fix.componentInstance.columns = cols; + fix.componentInstance.data = fix.componentInstance.generateData(1000); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + GridFunctions.focusFirstCell(fix, grid); + await wait(); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.column.index).toEqual(3); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.column.index).toEqual(2); + }); + + it('should allow horizontal navigation in virtualized grid with pinned cols.', async () => { + const cols = []; + for (let i = 0; i < 10; i++) { + cols.push({ field: 'col' + i }); + } + fix.componentInstance.columns = cols; + fix.detectChanges(); + fix.componentInstance.data = fix.componentInstance.generateData(1000); + fix.detectChanges(); + + grid.pinColumn('col1'); + grid.pinColumn('col3'); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + GridFunctions.focusFirstCell(fix, grid); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.column.visibleIndex).toEqual(2); + // Verify columns + let cells = (grid.gridAPI.get_row_by_index(0).cells as QueryList).toArray(); + expect(cells.length).toEqual(5); + expect(cells[0].column.field).toEqual('col1'); + expect(cells[1].column.field).toEqual('col3'); + expect(cells[3].column.field).toEqual('col2'); + expect(cells[4].column.field).toEqual('col4'); + + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.column.visibleIndex).toEqual(0); + + cells = (grid.gridAPI.get_row_by_index(0).cells as QueryList).toArray(); + expect(cells.length).toEqual(5); + expect(cells[0].column.field).toEqual('col1'); + expect(cells[1].column.field).toEqual('col3'); + expect(cells[2].column.field).toEqual('col0'); + expect(cells[3].column.field).toEqual('col2'); + }); + + it('should scroll into view the not fully visible cells when navigating down', async () => { + fix.componentInstance.columns = fix.componentInstance.generateCols(100); + fix.componentInstance.data = fix.componentInstance.generateData(1000); + fix.detectChanges(); + + const rows = GridFunctions.getRows(fix); + let cell = grid.gridAPI.get_cell_by_index(3, '1'); + const bottomRowHeight = rows[4].nativeElement.offsetHeight; + const displayContainer = GridFunctions.getGridDisplayContainer(fix).nativeElement; + const bottomCellVisibleHeight = displayContainer.parentElement.offsetHeight % bottomRowHeight; + UIInteractions.simulateClickAndSelectEvent(cell); + await wait(); + fix.detectChanges(); + + let selectedCell = fix.componentInstance.selectedCell; + expect(selectedCell.value).toEqual(30); + expect(selectedCell.column.field).toMatch('1'); + cell = grid.gridAPI.get_cell_by_index(selectedCell.row.index, selectedCell.column.field); + GridFunctions.verifyGridContentActiveDescendant(GridFunctions.getGridContent(fix), cell.nativeElement.id); + UIInteractions.triggerEventHandlerKeyDown('arrowdown', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + selectedCell = fix.componentInstance.selectedCell; + expect(parseInt(displayContainer.style.top, 10)).toBeLessThanOrEqual(-1 * (grid.rowHeight - bottomCellVisibleHeight)); + expect(displayContainer.parentElement.scrollTop).toEqual(0); + expect(selectedCell.value).toEqual(40); + expect(selectedCell.column.field).toMatch('1'); + cell = grid.gridAPI.get_cell_by_index(selectedCell.row.index, selectedCell.column.field); + GridFunctions.verifyGridContentActiveDescendant(GridFunctions.getGridContent(fix), cell.nativeElement.id); + }); + + it('should scroll into view the not fully visible cells when navigating up', async () => { + fix.componentInstance.columns = fix.componentInstance.generateCols(100); + fix.componentInstance.data = fix.componentInstance.generateData(1000); + fix.detectChanges(); + + const displayContainer = GridFunctions.getGridDisplayContainer(fix).nativeElement; + fix.componentInstance.scrollTop(25); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + expect(displayContainer.style.top).toEqual('-25px'); + const cell = grid.gridAPI.get_cell_by_index(1, '1'); + UIInteractions.simulateClickAndSelectEvent(cell); + await wait(); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(10); + expect(fix.componentInstance.selectedCell.column.field).toMatch('1'); + + UIInteractions.triggerEventHandlerKeyDown('arrowup', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + fix.detectChanges(); + expect(displayContainer.style.top).toEqual('0px'); + expect(fix.componentInstance.selectedCell.value).toEqual(0); + expect(fix.componentInstance.selectedCell.column.field).toMatch('1'); + }); + + it('should allow navigating first/last cell in column with down/up and Ctrl key.', async () => { + let cell = grid.gridAPI.get_cell_by_index(1, 'value'); + UIInteractions.simulateClickAndSelectEvent(cell); + await wait(); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', cell.nativeElement, true, false, false, true); + await wait(100); + fix.detectChanges(); + + + let cell2 = grid.getCellByColumn(999, 'value'); + GridSelectionFunctions.verifyGridCellSelected(fix, cell2); + + cell = grid.gridAPI.get_cell_by_index(998, 'other'); + UIInteractions.simulateClickAndSelectEvent(cell); + await wait(); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('arrowup', cell.nativeElement, true, false, false, true); + await wait(100); + fix.detectChanges(); + + cell2 = grid.getCellByColumn(0, 'other'); + GridSelectionFunctions.verifyGridCellSelected(fix, cell2); + }); + + it('should allow navigating first/last cell in column with home/end and Cntr key.', async () => { + fix.componentInstance.columns = fix.componentInstance.generateCols(50); + fix.componentInstance.data = fix.componentInstance.generateData(500); + fix.detectChanges(); + + grid.verticalScrollContainer.addScrollTop(5000); + await wait(100); + fix.detectChanges(); + + let cell = grid.gridAPI.get_cell_by_index(101, '2'); + UIInteractions.simulateClickAndSelectEvent(cell); + await wait(); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('home', cell.nativeElement, true, false, false, true); + await wait(150); + fix.detectChanges(); + + let cell2 = grid.getCellByColumn(0, '0'); + GridSelectionFunctions.verifyGridCellSelected(fix, cell2); + expect(grid.verticalScrollContainer.getScroll().scrollTop).toEqual(0); + + cell = grid.gridAPI.get_cell_by_index(4, '2'); + UIInteractions.simulateClickAndSelectEvent(cell); + await wait(); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('end', cell.nativeElement, true, false, false, true); + await wait(200); + fix.detectChanges(); + + cell2 = grid.getCellByColumn(499, '49'); + GridSelectionFunctions.verifyGridCellSelected(fix, cell2); + }); + + it('should scroll into view the not fully visible cells when navigating left', async () => { + grid.tbody.nativeElement.focus(); + fix.detectChanges(); + + fix.componentInstance.columns = fix.componentInstance.generateCols(100); + await wait(DEBOUNCETIME); + fix.detectChanges(); + fix.componentInstance.data = fix.componentInstance.generateData(1000); + fix.detectChanges(); + + const rowDisplayContainer = GridFunctions.getRowDisplayContainer(fix, 1).nativeElement; + fix.componentInstance.scrollLeft(50); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + expect(rowDisplayContainer.style.left).toEqual('-50px'); + const curCell = grid.gridAPI.get_cell_by_index(1, '1'); + UIInteractions.simulateClickAndSelectEvent(curCell); + await wait(); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(10); + expect(fix.componentInstance.selectedCell.column.field).toMatch('1'); + UIInteractions.triggerKeyDownEvtUponElem('arrowleft', grid.tbody.nativeElement, true); + await wait(DEBOUNCETIME); + + fix.detectChanges(); + expect(rowDisplayContainer.style.left).toEqual('0px'); + expect(fix.componentInstance.selectedCell.value).toEqual(0); + expect(fix.componentInstance.selectedCell.column.field).toMatch('0'); + }); + + it('should scroll into view the not fully visible cells when navigating right', async () => { + grid.tbody.nativeElement.focus(); + fix.detectChanges(); + + fix.componentInstance.columns = fix.componentInstance.generateCols(100); + fix.componentInstance.data = fix.componentInstance.generateData(1000); + fix.detectChanges(); + + const rowDisplayContainer = GridFunctions.getRowDisplayContainer(fix, 1).nativeElement; + expect(rowDisplayContainer.style.left).toEqual('0px'); + const curCell = grid.gridAPI.get_cell_by_index(1, '2'); + UIInteractions.simulateClickAndSelectEvent(curCell); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(20); + expect(fix.componentInstance.selectedCell.column.field).toMatch('2'); + + UIInteractions.triggerKeyDownEvtUponElem('arrowright', grid.tbody.nativeElement, true); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(30); + expect(fix.componentInstance.selectedCell.column.field).toMatch('3'); + expect(parseInt(rowDisplayContainer.style.left, 10)).toBeLessThanOrEqual(-40); + }); + + it('should scroll first row into view when pressing arrow up', (async () => { + grid.reflow(); + fix.componentInstance.scrollTop(25); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + let scrollContainer = grid.verticalScrollContainer.dc.instance._viewContainer; + let scrollContainerOffset = scrollContainer.element.nativeElement.offsetTop; + expect(scrollContainerOffset).toEqual(-25); + + const cell = grid.gridAPI.get_cell_by_index(1, 'value'); + UIInteractions.simulateClickAndSelectEvent(cell); + await wait(); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(10); + expect(fix.componentInstance.selectedCell.column.field).toMatch('value'); + UIInteractions.triggerEventHandlerKeyDown('arrowup', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + scrollContainer = grid.verticalScrollContainer.dc.instance._viewContainer; + scrollContainerOffset = scrollContainer.element.nativeElement.offsetTop; + + expect(scrollContainerOffset).toEqual(0); + expect(fix.componentInstance.selectedCell.value).toEqual(0); + expect(fix.componentInstance.selectedCell.column.field).toMatch('value'); + })); + + it('should allow pageup/pagedown navigation when the grid is focused', async () => { + fix.componentInstance.columns = fix.componentInstance.generateCols(25); + fix.componentInstance.data = fix.componentInstance.generateData(25); + fix.detectChanges(); + + GridFunctions.focusFirstCell(fix, grid); + await wait(); + fix.detectChanges(); + + // testing the pagedown key + UIInteractions.triggerEventHandlerKeyDown('PageDown', gridContent); + grid.cdr.detectChanges(); + await wait(); + + let currScrollTop = grid.verticalScrollContainer.getScroll().scrollTop; + expect(currScrollTop).toEqual(grid.verticalScrollContainer.igxForContainerSize); + + // testing the pageup key + UIInteractions.triggerEventHandlerKeyDown('PageUp', gridContent); + grid.cdr.detectChanges(); + await wait(); + currScrollTop = grid.headerContainer.getScroll().scrollTop; + expect(currScrollTop).toEqual(0); + }); + + it('Custom KB navigation: should be able to scroll to a random cell in the grid', async () => { + fix.componentInstance.columns = fix.componentInstance.generateCols(25); + fix.componentInstance.data = fix.componentInstance.generateData(25); + fix.detectChanges(); + + GridFunctions.focusFirstCell(fix, grid); + + grid.navigateTo(15, 1, (args) => { + args.target.activate(null); + }); + fix.detectChanges(); + await wait(200); + fix.detectChanges(); + + const target = grid.gridAPI.get_cell_by_index(15, '1'); + expect(target).toBeDefined(); + GridSelectionFunctions.verifyCellSelected(target); + GridSelectionFunctions.verifyCellActive(target); + }); + + it('Custom KB navigation: should be able to scroll horizontally and vertically to a cell in the grid', async () => { + fix.componentInstance.columns = fix.componentInstance.generateCols(100); + fix.componentInstance.data = fix.componentInstance.generateData(100); + fix.detectChanges(); + await wait(DEBOUNCETIME); + + GridFunctions.focusFirstCell(fix, grid); + + grid.navigateTo(50, 50, (args) => { + args.target.activate(null); +}); + await wait(DEBOUNCETIME); + fix.detectChanges(); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + const target = grid.gridAPI.get_cell_by_index(50, '50'); + expect(target).toBeDefined(); + GridSelectionFunctions.verifyCellSelected(target); + GridSelectionFunctions.verifyCellActive(target); + }); + + it('Custom KB navigation: gridKeydown should be emitted', async () => { + fix.componentInstance.columns = fix.componentInstance.generateCols(25); + fix.componentInstance.data = fix.componentInstance.generateData(25); + fix.detectChanges(); + const gridKeydown = spyOn(grid.gridKeydown, 'emit').and.callThrough(); + + const cell = grid.gridAPI.get_cell_by_index(1, '2'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('arrowup', cell.nativeElement, true); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + expect(gridKeydown).toHaveBeenCalledTimes(1); + expect(gridKeydown).toHaveBeenCalledWith({ + targetType: 'dataCell', target: cell, cancel: false, event: new KeyboardEvent('keydown') + }); + }); + }); + + describe('Group By navigation ', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + IgxGridGroupByComponent, NoopAnimationsModule + ] + }).compileComponents(); + })); + + let fix; + let grid: IgxGridComponent; + let gridContent; + + beforeEach(() => { + fix = TestBed.createComponent(IgxGridGroupByComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + gridContent = GridFunctions.getGridContent(fix); + setupGridScrollDetection(fix, grid); + fix.detectChanges(); + }); + + afterEach(() => { + clearGridSubs(); + }); + + it('should focus the first cell when focus the grid body and there is a grouped column', async () => { + GridFunctions.getGridHeader(grid).nativeElement.focus(); + fix.detectChanges(); + grid.columnWidth = '200px'; + await wait(); + fix.detectChanges(); + + grid.headerContainer.getScroll().scrollLeft = 1000; + await wait(100); + fix.detectChanges(); + + grid.verticalScrollContainer.getScroll().scrollTop = 200; + await wait(100); + fix.detectChanges(); + + gridContent.triggerEventHandler('focus', null); + await wait(200); + fix.detectChanges(); + + expect(grid.verticalScrollContainer.getScroll().scrollTop).toBeGreaterThanOrEqual(100); + }); + + it('should toggle expand/collapse state of group row with ArrowRight/ArrowLeft key.', () => { + const gRow = grid.groupsRowList.toArray()[0]; + const gRowElement = GridFunctions.getGroupedRows(fix)[0]; + gRowElement.triggerEventHandler('pointerdown', {}); + fix.detectChanges(); + expect(gRow.expanded).toBe(true); + + UIInteractions.triggerEventHandlerKeyDown('arrowleft', gridContent, true); + fix.detectChanges(); + + expect(gRow.expanded).toBe(false); + + UIInteractions.triggerEventHandlerKeyDown('arrowright', gridContent, true); + fix.detectChanges(); + expect(gRow.expanded).toBe(true); + }); + + it('should toggle expand/collapse state of group row with ArrowUp/ArrowDown key.', () => { + const gRow = grid.groupsRowList.toArray()[0]; + const gRowElement = GridFunctions.getGroupedRows(fix)[0]; + gRowElement.triggerEventHandler('pointerdown', {}); + fix.detectChanges(); + + expect(gRow.expanded).toBe(true); + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent, true); + fix.detectChanges(); + + expect(gRow.expanded).toBe(false); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent, true); + fix.detectChanges(); + expect(gRow.expanded).toBe(true); + }); + + it(`focus should stay over the group row when expanding/collapsing + with keyboard and the grid is scrolled to the bottom`, (async () => { + + grid.verticalScrollContainer.scrollTo(grid.dataView.length - 1); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + let groupedRowsCount = grid.groupsRowList.length; + let groupRow = grid.groupsRowList.toArray()[groupedRowsCount - 1]; + const groupRowElement = GridFunctions.getGroupedRows(fix)[groupedRowsCount - 1]; + groupRowElement.triggerEventHandler('pointerdown', null); + fix.detectChanges(); + + GridFunctions.verifyGroupRowIsFocused(groupRow); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent, true); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + groupedRowsCount = grid.groupsRowList.length; + groupRow = grid.groupsRowList.toArray()[groupedRowsCount - 1]; + expect(groupRow.index).toEqual(11); + expect(groupRow.expanded).toBeFalsy(); + GridFunctions.verifyGroupRowIsFocused(groupRow); + })); + + it(`should be able to navigate down to the next row when expand the last group row + and grid is scrolled to bottom`, (async () => { + grid.verticalScrollContainer.scrollTo(grid.dataView.length - 1); + await wait(100); + fix.detectChanges(); + + grid.groupsRowList.last.toggle(); + await wait(DEBOUNCETIME); + fix.detectChanges(); + expect(grid.groupsRowList.last.expanded).toBeFalsy(); + + grid.groupsRowList.last.toggle(); + await wait(DEBOUNCETIME); + fix.detectChanges(); + expect(grid.groupsRowList.last.expanded).toBeTruthy(); + + const groupRowIndex = grid.groupsRowList.last.index; + grid.groupsRowList.last.nativeElement.dispatchEvent(new Event('pointerdown')); + await wait(); + fix.detectChanges(); + UIInteractions.triggerEventHandlerKeyDown('arrowDown', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + const cell = grid.gridAPI.get_cell_by_index(groupRowIndex + 1, 'Downloads'); + GridSelectionFunctions.verifyCellSelected(cell); + })); + + it('should allow keyboard navigation through group rows.', (async () => { + fix.componentInstance.width = '400px'; + fix.componentInstance.height = '300px'; + await wait(); + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'Released', dir: SortingDirection.Desc, + ignoreCase: false, strategy: DefaultSortingStrategy.instance() + }); + await wait(); + fix.detectChanges(); + + let row = grid.gridAPI.get_row_by_index(1); + row.nativeElement.dispatchEvent(new Event('pointerdown')); + await wait(); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('arrowDown', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + row = grid.gridAPI.get_row_by_index(2); + expect((row.cells as QueryList).toArray()[0].selected).toBe(true); + + UIInteractions.triggerEventHandlerKeyDown('arrowUp', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + row = grid.gridAPI.get_row_by_index(1); + expect(row.focused).toBeTrue(); + })); + + it('should persist last selected cell column index when navigate through group rows.', async () => { + fix.componentInstance.width = '400px'; + fix.componentInstance.height = '300px'; + grid.columnWidth = '200px'; + await wait(DEBOUNCETIME); + fix.detectChanges(); + grid.groupBy({ + fieldName: 'Released', dir: SortingDirection.Desc, + ignoreCase: false, strategy: DefaultSortingStrategy.instance() + }); + fix.detectChanges(); + + grid.headerContainer.getScroll().scrollLeft = 1000; + await wait(DEBOUNCETIME); + + const cell = grid.gridAPI.get_cell_by_index(2, 'Released'); + UIInteractions.simulateClickAndSelectEvent(cell); + await wait(); + fix.detectChanges(); + let row; + for (let index = 2; index < 9; index++) { + row = grid.gridAPI.get_row_by_index(index); + + if (!(row instanceof IgxGridGroupByRowComponent)) { + const selectedCell = grid.selectedCells[0]; + expect(selectedCell.row.index).toEqual(index); + expect(selectedCell.column.field).toEqual('Released'); + } + UIInteractions.triggerEventHandlerKeyDown('arrowDown', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + } + let cell2 = grid.getCellByColumn(9, 'Released'); + expect(cell2.selected).toBe(true); + + for (let index = 9; index > 1; index--) { + row = grid.gridAPI.get_row_by_index(index); + if (!(row instanceof IgxGridGroupByRowComponent)) { + const selectedCell = grid.selectedCells[0]; + expect(selectedCell.row.index).toEqual(index); + expect(selectedCell.column.field).toEqual('Released'); + } + UIInteractions.triggerEventHandlerKeyDown('arrowUp', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + } + + row = grid.gridAPI.get_row_by_index(1); + expect(row instanceof IgxGridGroupByRowComponent).toBe(true); + expect(row.focused).toBe(true); + + cell2 = grid.getCellByColumn(2, 'Released'); + expect(cell2.selected).toBe(true); + }); + + it('should focus grouped row when press arrow keys up or down', (async () => { + grid.tbody.nativeElement.focus(); + fix.detectChanges(); + + let cell = grid.gridAPI.get_cell_by_index(1, 'ID'); + UIInteractions.simulateClickAndSelectEvent(cell); + await wait(); + fix.detectChanges(); + + expect(cell.selected).toBe(true); + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', grid.tbody.nativeElement, true); + await wait(); + fix.detectChanges(); + + let groupRow = grid.groupsRowList.toArray()[0]; + cell = grid.gridAPI.get_cell_by_index(1, 'ID'); + GridFunctions.verifyGroupRowIsFocused(groupRow); + + cell = grid.gridAPI.get_cell_by_index(2, 'ProductName'); + UIInteractions.simulateClickAndSelectEvent(cell); + await wait(); + fix.detectChanges(); + + expect(cell.active).toBe(true); + expect(cell.selected).toBe(true); + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', grid.tbody.nativeElement, true); + await wait(); + fix.detectChanges(); + + groupRow = grid.groupsRowList.toArray()[1]; + GridFunctions.verifyGroupRowIsFocused(groupRow); + expect(cell.selected).toBe(true); + })); + + it('should keep selected cell when expand/collapse grouped row ', (async () => { + grid.tbody.nativeElement.focus(); + fix.detectChanges(); + + const cell = grid.gridAPI.get_cell_by_index(2, 'Released'); + UIInteractions.simulateClickAndSelectEvent(cell); + await wait(); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', grid.tbody.nativeElement, true); + await wait(); + fix.detectChanges(); + + const groupRow = grid.groupsRowList.toArray()[1]; + GridFunctions.verifyGroupRowIsFocused(groupRow); + expect(cell.selected).toBe(true); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', groupRow.nativeElement, true, true); + await wait(); + fix.detectChanges(); + + expect(cell.selected).toBe(true); + expect(groupRow.expanded).toBe(false); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', groupRow.nativeElement, true, true); + await wait(); + fix.detectChanges(); + + expect(cell.selected).toBe(true); + expect(groupRow.expanded).toBe(true); + })); + + it('Custom KB navigation: should be able to scroll to a random row and pass a cb', async () => { + fix.componentInstance.width = '600px'; + fix.componentInstance.height = '500px'; + grid.columnWidth = '200px'; + await wait(DEBOUNCETIME); + fix.detectChanges(); + + grid.navigateTo(9, -1, (args) => { + args.target.nativeElement.dispatchEvent(new Event('pointerdown')); +}); + await wait(100); + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + + const target = grid.rowList.find(r => r.index === 9); + expect(target).toBeDefined(); + GridFunctions.verifyGroupRowIsFocused(target); + }); + + it('Custom KB navigation: gridKeydown should be emitted for ', async () => { + fix.componentInstance.width = '600px'; + fix.componentInstance.height = '500px'; + grid.columnWidth = '200px'; + await wait(DEBOUNCETIME); + fix.detectChanges(); + const rowEl = grid.rowList.find(r => r.index === 0); + UIInteractions.simulateClickAndSelectEvent(rowEl); + fix.detectChanges(); + + const gridKeydown = spyOn(grid.gridKeydown, 'emit').and.callThrough(); + UIInteractions.triggerKeyDownEvtUponElem('Enter', rowEl.nativeElement, true); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + expect(gridKeydown).toHaveBeenCalledTimes(1); + expect(gridKeydown).toHaveBeenCalledWith({ + targetType: 'groupRow', target: rowEl, cancel: false, event: new KeyboardEvent('keydown') + }); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', rowEl.nativeElement, true); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + expect(gridKeydown).toHaveBeenCalledTimes(2); + expect(gridKeydown).toHaveBeenCalledWith({ + targetType: 'groupRow', target: rowEl, cancel: false, event: new KeyboardEvent('keydown') + }); + }); + }); +}); diff --git a/projects/igniteui-angular/grids/grid/src/grid-mrl-keyboard-nav.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-mrl-keyboard-nav.spec.ts new file mode 100644 index 00000000000..902c33ca6ff --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid-mrl-keyboard-nav.spec.ts @@ -0,0 +1,2667 @@ +import { Component, ViewChild } from '@angular/core'; +import { TestBed, ComponentFixture, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxGridComponent } from './grid.component'; +import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; +import { wait, UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { clearGridSubs, setupGridScrollDetection } from '../../../test-utils/helper-utils.spec'; +import { IgxGridGroupByRowComponent } from './groupby-row.component'; +import { GridFunctions, GRID_MRL_BLOCK } from '../../../test-utils/grid-functions.spec'; +import { CellType, IGridCellEventArgs, IgxColumnComponent, IgxGridMRLNavigationService } from 'igniteui-angular/grids/core'; +import { IgxColumnLayoutComponent } from 'igniteui-angular/grids/core'; +import { DefaultSortingStrategy, SortingDirection } from 'igniteui-angular/core'; + +const DEBOUNCE_TIME = 30; +const CELL_CSS_CLASS = '.igx-grid__td'; +const ROW_CSS_CLASS = '.igx-grid__tr'; +const CELL_BLOCK = `.${GRID_MRL_BLOCK}`; + +describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { + let fix: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, ColumnLayoutTestComponent], + providers: [IgxGridMRLNavigationService] + }).compileComponents(); + })); + + beforeEach(() => { + fix = TestBed.createComponent(ColumnLayoutTestComponent); + }); + + describe('Navigation without scrolling', () => { + describe('General', () => { + it('should navigate through a single layout with right and left arrow keys', () => { + fix.detectChanges(); + + const [firstCell, _secondCell, _thirdCell, _fourthCell] = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS)); + UIInteractions.simulateClickAndSelectEvent(firstCell); + + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].ID); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ID'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].CompanyName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('CompanyName'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].ContactName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ContactName'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight'); + fix.detectChanges(); + + // reached the end shouldn't stay on the same cell + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].ContactName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ContactName'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowLeft'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].CompanyName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('CompanyName'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowLeft'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].ID); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ID'); + }); + + it('should navigate between column layouts with right arrow key', () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ContactName', rowStart: 1, colStart: 1 }, + { field: 'City', rowStart: 2, colStart: 1 } + ] + }, { + group: 'group2', + columns: [ + { field: 'Phone', rowStart: 1, colStart: 1, rowEnd: 3 } + ] + }]; + fix.detectChanges(); + + const [_firstCell, secondCell, _thirdCell] = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS)); + UIInteractions.simulateClickAndSelectEvent(secondCell); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].City); + expect(fix.componentInstance.selectedCell.column.field).toMatch('City'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].Phone); + expect(fix.componentInstance.selectedCell.column.field).toMatch('Phone'); + }); + + it('should navigate between column layouts with left key', () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ContactName', rowStart: 1, colStart: 1, rowEnd: 3 } + ] + }, { + group: 'group2', + columns: [ + { field: 'Phone', rowStart: 1, colStart: 1 }, + { field: 'City', rowStart: 2, colStart: 1 } + ] + }]; + fix.detectChanges(); + + const [_firstCell, _secondCell, thirdCell] = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS)); + UIInteractions.simulateClickAndSelectEvent(thirdCell); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].City); + expect(fix.componentInstance.selectedCell.column.field).toMatch('City'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowLeft'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].ContactName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ContactName'); + }); + + it('should navigate down and up to a cell from the same column layout from a cell with bigger col span', () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'Phone', rowStart: 2, colStart: 1 }, + { field: 'City', rowStart: 2, colStart: 2 } + ] + }]; + fix.detectChanges(); + + const [firstCell, _secondCell, _thirdCell] = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS)); + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].Phone); + expect(fix.componentInstance.selectedCell.column.field).toMatch('Phone'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowUp'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].ContactName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ContactName'); + }); + + it('should navigate down and up to a cell from the same column layout to a cell with bigger col span', () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'Phone', rowStart: 1, colStart: 1 }, + { field: 'City', rowStart: 1, colStart: 2 }, + { field: 'ContactName', rowStart: 2, colStart: 1, colEnd: 3 } + ] + }]; + fix.detectChanges(); + + const [_firstCell, secondCell, _thirdCell] = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS)); + UIInteractions.simulateClickAndSelectEvent(secondCell); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].ContactName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ContactName'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowUp'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].City); + expect(fix.componentInstance.selectedCell.column.field).toMatch('City'); + }); + + it('should navigate down and up to a cell from the same column layout according to its starting location', () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'Phone', rowStart: 1, colStart: 1 }, + { field: 'City', rowStart: 1, colStart: 2, colEnd: 4 }, + { field: 'ContactName', rowStart: 2, colStart: 1, colEnd: 3 }, + { field: 'ContactTitle', rowStart: 2, colStart: 3 } + ] + }]; + fix.detectChanges(); + + const [_firstCell, secondCell, _thirdCell] = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS)); + UIInteractions.simulateClickAndSelectEvent(secondCell); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].ContactName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ContactName'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowUp'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].City); + expect(fix.componentInstance.selectedCell.column.field).toMatch('City'); + }); + + it('should allow navigating down to a cell from the next row', () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'Phone', rowStart: 1, colStart: 1 }, + { field: 'City', rowStart: 1, colStart: 2 }, + { field: 'ContactName', rowStart: 2, colStart: 1, colEnd: 3 } + ] + }]; + fix.detectChanges(); + + const [_firstCell, _secondCell, thirdCell] = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS)); + + UIInteractions.simulateClickAndSelectEvent(thirdCell); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[1].Phone); + expect(fix.componentInstance.selectedCell.column.field).toMatch('Phone'); + }); + + it('should allow navigating down to a cell from the next row with hidden column layout', () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + hidden: true, + columns: [ + { field: 'ID', rowStart: 1, colStart: 1, rowEnd: 3 } + ] + }, { + group: 'group2', + columns: [ + { field: 'Phone', rowStart: 1, colStart: 1 }, + { field: 'City', rowStart: 1, colStart: 2 }, + { field: 'ContactName', rowStart: 2, colStart: 1, colEnd: 3 } + ] + }]; + fix.detectChanges(); + + const [_firstCell, _secondCell, thirdCell] = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS)); + + UIInteractions.simulateClickAndSelectEvent(thirdCell); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[1].Phone); + expect(fix.componentInstance.selectedCell.column.field).toMatch('Phone'); + }); + + it('should retain the focus when the first cell is reached', () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'Phone', rowStart: 1, colStart: 1 }, + { field: 'City', rowStart: 1, colStart: 2 }, + { field: 'ContactName', rowStart: 2, colStart: 1, colEnd: 3 } + ] + }]; + fix.detectChanges(); + + const [_firstCell, secondCell, _thirdCell] = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS)); + + UIInteractions.simulateClickAndSelectEvent(secondCell); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowUp'); + fix.detectChanges(); + + const selectedCell = fix.componentInstance.selectedCell; + expect(selectedCell.value).toEqual(fix.componentInstance.data[0].City); + expect(selectedCell.column.field).toMatch('City'); + const cell = fix.componentInstance.grid.gridAPI.get_cell_by_index(selectedCell.row.index, selectedCell.column.field); + GridFunctions.verifyGridContentActiveDescendant(GridFunctions.getGridContent(fix), cell.nativeElement.id); + }); + + it('should navigate up correctly', () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'Phone', rowStart: 2, colStart: 1 }, + { field: 'City', rowStart: 2, colStart: 2 } + ] + }]; + + fix.detectChanges(); + + const [_firstCell, _secondCell, thirdCell] = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS)); + + UIInteractions.simulateClickAndSelectEvent(thirdCell); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowUp'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].ContactName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ContactName'); + }); + + it('navigate to right and left with hidden columns', () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + hidden: true, + columns: [ + { field: 'ID', rowStart: 1, colStart: 1, rowEnd: 3 } + ] + }, { + group: 'group2', + columns: [ + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'Phone', rowStart: 2, colStart: 1 }, + { field: 'City', rowStart: 2, colStart: 2 } + ] + }]; + fix.detectChanges(); + + const [_firstCell, secondCell, _thirdCell] = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS)); + + UIInteractions.simulateClickAndSelectEvent(secondCell); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].City); + expect(fix.componentInstance.selectedCell.column.field).toMatch('City'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowLeft'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].Phone); + expect(fix.componentInstance.selectedCell.column.field).toMatch('Phone'); + }); + + it(`should navigate to the first cell from the layout by pressing Ctrl + Arrow Left and Right key + and then Arrow Up + Down to same cell`, async () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + hidden: true, + columns: [ + { field: 'ID', rowStart: 1, colStart: 1, rowEnd: 3 } + ] + }, { + group: 'group2', + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'ContactName', rowStart: 2, colStart: 1 }, + { field: 'ContactTitle', rowStart: 2, colStart: 2 }, + { field: 'Address', rowStart: 3, colStart: 1, colEnd: 3 } + ] + }, { + group: 'group3', + columns: [ + { field: 'City', rowStart: 1, colStart: 1, colEnd: 3, rowEnd: 3 }, + { field: 'Region', rowStart: 3, colStart: 1 }, + { field: 'PostalCode', rowStart: 3, colStart: 2 } + ] + }, { + group: 'group4', + columns: [ + { field: 'Country', rowStart: 1, colStart: 1 }, + { field: 'Phone', rowStart: 1, colStart: 2 }, + { field: 'Fax', rowStart: 2, colStart: 1, colEnd: 3, rowEnd: 4 } + ] + }]; + fix.detectChanges(); + const rows = fix.debugElement.queryAll(By.css(ROW_CSS_CLASS)); + const firstRowCell = rows[1].queryAll(By.css(CELL_CSS_CLASS)); + const lastCell = firstRowCell[firstRowCell.length - 1]; + + UIInteractions.simulateClickAndSelectEvent(lastCell); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowLeft', false, false, true); + await wait(); + fix.detectChanges(); + + await wait(); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].ContactName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ContactName'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].ContactTitle); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ContactTitle'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowUp'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].CompanyName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('CompanyName'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].ContactTitle); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ContactTitle'); + }); + + it(`should navigate to the first cell from the layout by pressing Ctrl + Arrow Right and Left key + and then Arrow Up + Down to same cell`, async () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + hidden: true, + columns: [ + { field: 'ID', rowStart: 1, colStart: 1, rowEnd: 3 } + ] + }, { + group: 'group2', + columns: [ + { field: 'Country', rowStart: 1, colStart: 1 }, + { field: 'Phone', rowStart: 1, colStart: 2 }, + { field: 'Fax', rowStart: 2, colStart: 1, colEnd: 3, rowEnd: 4 } + ] + }, { + group: 'group3', + columns: [ + { field: 'City', rowStart: 1, colStart: 1, colEnd: 3, rowEnd: 3 }, + { field: 'Region', rowStart: 3, colStart: 1 }, + { field: 'PostalCode', rowStart: 3, colStart: 2 } + ] + }, { + group: 'group4', + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'ContactName', rowStart: 2, colStart: 1 }, + { field: 'ContactTitle', rowStart: 2, colStart: 2 }, + { field: 'Address', rowStart: 3, colStart: 1, colEnd: 3 } + ] + }]; + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + const rows = fix.debugElement.queryAll(By.css(ROW_CSS_CLASS)); + const firstRowCell = rows[1].queryAll(By.css(CELL_CSS_CLASS)); + const firstCell = firstRowCell[2]; + + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight', false, false, true); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].ContactTitle); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ContactTitle'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowLeft'); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].ContactName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ContactName'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowUp'); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].CompanyName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('CompanyName'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].ContactName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ContactName'); + }); + + it('should navigate using Arrow Left through bigger cell with same rowStart but bigger row span', () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ID', rowStart: 1, colStart: 1 }, + { field: 'CompanyName', rowStart: 1, colStart: 2, rowEnd: 3 }, + { field: 'ContactName', rowStart: 1, colStart: 3 }, + { field: 'ContactTitle', rowStart: 2, colStart: 1 }, + { field: 'City', rowStart: 2, colStart: 3 }, + { field: 'Country', rowStart: 3, colStart: 1 }, + { field: 'Address', rowStart: 3, colStart: 2, colEnd: 4 }, + ] + }]; + fix.detectChanges(); + + const [_thirdCell, _secondCell, firstCell] = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS)); + + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].ContactName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ContactName'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowLeft'); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowLeft'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].ID); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ID'); + }); + + it('should navigate using Arrow Left through bigger cell with smaller rowStart and bigger rowEnd', () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ID', rowStart: 1, colStart: 1 }, + { field: 'CompanyName', rowStart: 1, colStart: 2, rowEnd: 3 }, + { field: 'ContactName', rowStart: 1, colStart: 3 }, + { field: 'ContactTitle', rowStart: 2, colStart: 1 }, + { field: 'City', rowStart: 2, colStart: 3 }, + { field: 'Country', rowStart: 3, colStart: 1 }, + { field: 'Address', rowStart: 3, colStart: 2, colEnd: 4 }, + ] + }]; + fix.detectChanges(); + + const firstCell = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[4]; + + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].City); + expect(fix.componentInstance.selectedCell.column.field).toMatch('City'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowLeft'); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowLeft'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].ContactTitle); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ContactTitle'); + }); + + it('should navigate using Arrow Right through bigger cell with same rowStart but bigger row', () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ID', rowStart: 1, colStart: 1 }, + { field: 'CompanyName', rowStart: 1, colStart: 2, rowEnd: 3 }, + { field: 'ContactName', rowStart: 1, colStart: 3 }, + { field: 'ContactTitle', rowStart: 2, colStart: 1 }, + { field: 'City', rowStart: 2, colStart: 3 }, + { field: 'Country', rowStart: 3, colStart: 1 }, + { field: 'Address', rowStart: 3, colStart: 2, colEnd: 4 }, + ] + }]; + fix.detectChanges(); + + const [firstCell, _secondCell, _thirdCell] = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS)); + + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].ID); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ID'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight'); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].ContactName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ContactName'); + }); + + it('should navigate using Arrow Right through bigger cell with smaller rowStart and bigger rowEnd', () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ID', rowStart: 1, colStart: 1 }, + { field: 'CompanyName', rowStart: 1, colStart: 2, rowEnd: 3 }, + { field: 'ContactName', rowStart: 1, colStart: 3 }, + { field: 'ContactTitle', rowStart: 2, colStart: 1 }, + { field: 'City', rowStart: 2, colStart: 3 }, + { field: 'Country', rowStart: 3, colStart: 1 }, + { field: 'Address', rowStart: 3, colStart: 2, colEnd: 4 }, + ] + }]; + fix.detectChanges(); + + const firstCell = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[3]; + + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].ContactTitle); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ContactTitle'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight'); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].City); + expect(fix.componentInstance.selectedCell.column.field).toMatch('City'); + }); + + it('should navigate using Arrow Down through cell with same colStart but bigger colEnd', () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ID', rowStart: 1, colStart: 1 }, + { field: 'CompanyName', rowStart: 1, colStart: 2, rowEnd: 3 }, + { field: 'ContactName', rowStart: 1, colStart: 3 }, + { field: 'ContactTitle', rowStart: 2, colStart: 1 }, + { field: 'City', rowStart: 2, colStart: 3 }, + { field: 'Country', rowStart: 3, colStart: 1 }, + { field: 'Address', rowStart: 3, colStart: 2, colEnd: 4 }, + ] + }]; + fix.detectChanges(); + + const firstCell = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[1]; + + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].CompanyName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('CompanyName'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[1].CompanyName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('CompanyName'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowUp'); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowUp'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].CompanyName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('CompanyName'); + }); + + it('should navigate using Arrow Down through cell with smaller colStart and bigger colEnd', () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ID', rowStart: 1, colStart: 1 }, + { field: 'CompanyName', rowStart: 1, colStart: 2, rowEnd: 3 }, + { field: 'ContactName', rowStart: 1, colStart: 3 }, + { field: 'ContactTitle', rowStart: 2, colStart: 1 }, + { field: 'City', rowStart: 2, colStart: 3 }, + { field: 'Country', rowStart: 3, colStart: 1 }, + { field: 'Address', rowStart: 3, colStart: 2, colEnd: 4 }, + ] + }]; + fix.detectChanges(); + + const firstCell = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[4]; + + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].City); + expect(fix.componentInstance.selectedCell.column.field).toMatch('City'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[1].ContactName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ContactName'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowUp'); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowUp'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].City); + expect(fix.componentInstance.selectedCell.column.field).toMatch('City'); + }); + + it('should navigate using Arrow Up through cell with smaller colStart and bigger colEnd', () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ID', rowStart: 1, colStart: 1 }, + { field: 'CompanyName', rowStart: 1, colStart: 2, rowEnd: 3 }, + { field: 'ContactName', rowStart: 1, colStart: 3 }, + { field: 'ContactTitle', rowStart: 2, colStart: 1 }, + { field: 'City', rowStart: 2, colStart: 3 }, + { field: 'Country', rowStart: 3, colStart: 1 }, + { field: 'Address', rowStart: 3, colStart: 2, colEnd: 4 }, + ] + }]; + fix.detectChanges(); + + const firstCell = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[9]; + + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[1].ContactName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ContactName'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowUp'); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowUp'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].City); + expect(fix.componentInstance.selectedCell.column.field).toMatch('City'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[1].ContactName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ContactName'); + }); + + it('should navigate correctly with column group is hidden.', () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + // row span 3 + columns: [ + { field: 'ID', rowStart: 1, colStart: 1, rowEnd: 4 } + ] + }, { + group: 'group2', + columns: [ + // col span 2 + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'Phone', rowStart: 2, colStart: 1 }, + { field: 'City', rowStart: 2, colStart: 2 }, + // col span 2 + { field: 'ContactTitle', rowStart: 3, colStart: 1, colEnd: 3 } + ] + }, { + group: 'group3', + columns: [ + // row span 2 + { field: 'Address', rowStart: 1, colStart: 1, rowEnd: 3 }, + { field: 'PostalCode', rowStart: 3, colStart: 1 } + ] + }]; + fix.detectChanges(); + const grid = fix.componentInstance.grid; + // hide second group + const secondGroup = grid.getColumnByName('group2'); + secondGroup.hidden = true; + fix.detectChanges(); + + // check visible indexes are correct + expect(grid.getCellByColumn(0, 'ID').column.visibleIndex).toBe(0); + expect(grid.getCellByColumn(0, 'Address').column.visibleIndex).toBe(1); + expect(grid.getCellByColumn(0, 'PostalCode').column.visibleIndex).toBe(2); + // focus last + const cell = grid.gridAPI.get_cell_by_index(0, 'Address'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + // arrow left + GridFunctions.simulateGridContentKeydown(fix, 'ArrowLeft'); + fix.detectChanges(); + + // check correct cell has focus + let cell2 = grid.getCellByColumn(0, 'ID'); + expect(cell2.active).toBe(true); + let cellElement = fix.componentInstance.grid.gridAPI.get_cell_by_index(cell2.row.index, cell2.column.field).nativeElement; + GridFunctions.verifyGridContentActiveDescendant(GridFunctions.getGridContent(fix), cellElement.id); + + // arrow right + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight'); + fix.detectChanges(); + + // check correct cell has focus + cell2 = grid.getCellByColumn(0, 'Address'); + expect(cell2.active).toBe(true); + cellElement = fix.componentInstance.grid.gridAPI.get_cell_by_index(cell2.row.index, cell2.column.field).nativeElement; + GridFunctions.verifyGridContentActiveDescendant(GridFunctions.getGridContent(fix), cellElement.id); + }); + }); + + describe('GroupBy Integration', () => { + it('should allow navigation through group rows with arrow keys starting from group row.', () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + // row span 3 + columns: [ + { field: 'ID', rowStart: 1, colStart: 1, rowEnd: 4 } + ] + }, { + group: 'group2', + columns: [ + // col span 2 + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'Phone', rowStart: 2, colStart: 1 }, + { field: 'City', rowStart: 2, colStart: 2 }, + // col span 2 + { field: 'ContactTitle', rowStart: 3, colStart: 1, colEnd: 3 } + ] + }, { + group: 'group3', + columns: [ + // row span 2 + { field: 'Address', rowStart: 1, colStart: 1, rowEnd: 3 }, + { field: 'PostalCode', rowStart: 3, colStart: 1 } + ] + }]; + fix.detectChanges(); + // group by city + const grid = fix.componentInstance.grid; + grid.groupBy({ + fieldName: 'City', + dir: SortingDirection.Asc, + ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }); + fix.detectChanges(); + + let groupRow = (grid.gridAPI.get_row_by_index(0) as any) as IgxGridGroupByRowComponent; + UIInteractions.simulateClickAndSelectEvent(groupRow); + fix.detectChanges(); + + // arrow down + GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); + fix.detectChanges(); + + // check first data cell is active + let cell = grid.getCellByColumn(1, 'ID'); + expect(cell.active).toBe(true); + + // arrow down + GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); + fix.detectChanges(); + + // check next group row is active + groupRow = (grid.gridAPI.get_row_by_index(2) as any) as IgxGridGroupByRowComponent; + GridFunctions.verifyGroupRowIsFocused(groupRow); + + // arrow up + GridFunctions.simulateGridContentKeydown(fix, 'ArrowUp'); + fix.detectChanges(); + // check prev cell is active + cell = grid.getCellByColumn(1, 'ID'); + expect(cell.active).toBe(true); + + // arrow up + GridFunctions.simulateGridContentKeydown(fix, 'ArrowUp'); + fix.detectChanges(); + + // check first group row is active + groupRow = (grid.gridAPI.get_row_by_index(0) as any) as IgxGridGroupByRowComponent; + GridFunctions.verifyGroupRowIsFocused(groupRow); + }); + + it('should allow navigation through group rows with arrow keys starting from middle of grid row', () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + // row span 3 + columns: [ + { field: 'ID', rowStart: 1, colStart: 1, rowEnd: 4 } + ] + }, { + group: 'group2', + columns: [ + // col span 2 + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'Phone', rowStart: 2, colStart: 1 }, + { field: 'City', rowStart: 2, colStart: 2 }, + // col span 2 + { field: 'ContactTitle', rowStart: 3, colStart: 1, colEnd: 3 } + ] + }, { + group: 'group3', + columns: [ + // row span 2 + { field: 'Address', rowStart: 1, colStart: 1, rowEnd: 3 }, + { field: 'PostalCode', rowStart: 3, colStart: 1 } + ] + }]; + + fix.detectChanges(); + // group by city + const grid = fix.componentInstance.grid; + grid.height = '700px'; + grid.groupBy({ + fieldName: 'City', + dir: SortingDirection.Asc, + ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }); + fix.detectChanges(); + + const firstBlock = fix.debugElement.queryAll(By.css('igx-grid-row'))[0].queryAll(By.css(CELL_BLOCK))[1]; + const secondBlock = fix.debugElement.queryAll(By.css('igx-grid-row'))[1].queryAll(By.css(CELL_BLOCK))[1]; + const firstCell = firstBlock.queryAll(By.css(CELL_CSS_CLASS))[3]; + const secondCell = secondBlock.queryAll(By.css(CELL_CSS_CLASS))[0]; + + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + // arrow down + GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); + fix.detectChanges(); + + // check next group row is active + let groupRow = (grid.gridAPI.get_row_by_index(2) as any) as IgxGridGroupByRowComponent; + GridFunctions.verifyGroupRowIsFocused(groupRow); + + // arrow down + GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); + fix.detectChanges(); + + // check first data cell is active + expect(fix.componentInstance.selectedCell.value).toEqual('Maria Anders'); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ContactName'); + expect(secondCell.componentInstance.active).toBeTruthy(); + + // arrow up + GridFunctions.simulateGridContentKeydown(fix, 'ArrowUp'); + fix.detectChanges(); + + // check group row is active + groupRow = (grid.gridAPI.get_row_by_index(2) as any) as IgxGridGroupByRowComponent; + GridFunctions.verifyGroupRowIsFocused(groupRow); + + // arrow up + GridFunctions.simulateGridContentKeydown(fix, 'ArrowUp'); + fix.detectChanges(); + + // check last cell in group 1 layout is active + expect(fix.componentInstance.selectedCell.value).toEqual('Order Administrator'); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ContactTitle'); + expect(firstCell.componentInstance.active).toBeTruthy(); + }); + }); + + describe('Column Moving Integration', () => { + it('tab navigation should follow correct sequence if a column is moved.', fakeAsync(() => { + fix.componentInstance.colGroups = [{ + group: 'group1', + // row span 3 + columns: [ + { field: 'ID', rowStart: 1, colStart: 1, rowEnd: 4 } + ] + }, { + group: 'group2', + columns: [ + // col span 2 + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'Phone', rowStart: 2, colStart: 1 }, + { field: 'City', rowStart: 2, colStart: 2 }, + // col span 2 + { field: 'ContactTitle', rowStart: 3, colStart: 1, colEnd: 3 } + ] + }, { + group: 'group3', + columns: [ + // row span 2 + { field: 'Address', rowStart: 1, colStart: 1, rowEnd: 3 }, + { field: 'PostalCode', rowStart: 3, colStart: 1 } + ] + }]; + fix.detectChanges(); + const grid = fix.componentInstance.grid; + // move second group + const col1 = grid.getColumnByName('group3'); + const col2 = grid.getColumnByName('group1'); + grid.moveColumn(col2, col1); + tick(); + fix.detectChanges(); + + // check visible indexes are correct + expect(grid.getCellByColumn(0, 'ContactName').column.visibleIndex).toBe(0); + expect(grid.getCellByColumn(0, 'Address').column.visibleIndex).toBe(1); + expect(grid.getCellByColumn(0, 'ID').column.visibleIndex).toBe(2); + expect(grid.getCellByColumn(0, 'Phone').column.visibleIndex).toBe(3); + expect(grid.getCellByColumn(0, 'City').column.visibleIndex).toBe(4); + expect(grid.getCellByColumn(0, 'ContactTitle').column.visibleIndex).toBe(5); + expect(grid.getCellByColumn(0, 'PostalCode').column.visibleIndex).toBe(6); + })); + }); + + describe('Pinning integration', () => { + it('tab navigation should follow correct sequence if a column is pinned runtime.', () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + // row span 3 + columns: [ + { field: 'ID', rowStart: 1, colStart: 1, rowEnd: 4 } + ] + }, { + group: 'group2', + columns: [ + // col span 2 + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'Phone', rowStart: 2, colStart: 1 }, + { field: 'City', rowStart: 2, colStart: 2 }, + // col span 2 + { field: 'ContactTitle', rowStart: 3, colStart: 1, colEnd: 3 } + ] + }, { + group: 'group3', + columns: [ + // row span 2 + { field: 'Address', rowStart: 1, colStart: 1, rowEnd: 3 }, + { field: 'PostalCode', rowStart: 3, colStart: 1 } + ] + }]; + fix.detectChanges(); + const grid = fix.componentInstance.grid; + // hide second group + const secondGroup = grid.getColumnByName('group2'); + secondGroup.pinned = true; + fix.detectChanges(); + + // check visible indexes are correct + expect(grid.getCellByColumn(0, 'ContactName').column.visibleIndex).toBe(0); + expect(grid.getCellByColumn(0, 'ID').column.visibleIndex).toBe(1); + expect(grid.getCellByColumn(0, 'Address').column.visibleIndex).toBe(2); + expect(grid.getCellByColumn(0, 'Phone').column.visibleIndex).toBe(3); + expect(grid.getCellByColumn(0, 'City').column.visibleIndex).toBe(4); + expect(grid.getCellByColumn(0, 'ContactTitle').column.visibleIndex).toBe(5); + expect(grid.getCellByColumn(0, 'PostalCode').column.visibleIndex).toBe(6); + }); + + it('should navigate left from unpinned to pinned area when pinning second block in template', () => { + fix.componentInstance.colGroups = [ + { + group: 'group1', + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3, width: '300px' }, + { field: 'ContactName', rowStart: 2, colStart: 1 }, + { field: 'ContactTitle', rowStart: 2, colStart: 2 }, + { field: 'Address', rowStart: 3, colStart: 1, colEnd: 3 } + ] + }, + { + group: 'group2', + pinned: true, + columns: [ + { field: 'City', rowStart: 1, colStart: 1, colEnd: 3, rowEnd: 3, width: '400px' }, + { field: 'Region', rowStart: 3, colStart: 1 }, + { field: 'PostalCode', rowStart: 3, colStart: 2 } + ] + }, + { + group: 'group3', + columns: [ + { field: 'Phone', rowStart: 1, colStart: 1, width: '200px' }, + { field: 'Fax', rowStart: 2, colStart: 1 }, + { field: 'PostalCode', rowStart: 3, colStart: 1 } + ] + } + ]; + fix.componentInstance.grid.width = '600px'; + fix.detectChanges(); + const firstBlock = fix.debugElement.query(By.css('igx-grid-row')).queryAll(By.css(CELL_BLOCK))[0]; + const secondBlock = fix.debugElement.query(By.css('igx-grid-row')).queryAll(By.css(CELL_BLOCK))[1]; + + const firstCell = secondBlock.queryAll(By.css(CELL_CSS_CLASS))[1]; + const secondCell = firstBlock.queryAll(By.css(CELL_CSS_CLASS))[0]; + + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowLeft'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].City); + expect(fix.componentInstance.selectedCell.column.field).toMatch('City'); + expect(secondCell.componentInstance.active).toBeTruthy(); + }); + + it('should navigate down to next row inside pinned area when pinning second block in template', () => { + fix.componentInstance.colGroups = [ + { + group: 'group1', + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3, width: '300px' }, + { field: 'ContactName', rowStart: 2, colStart: 1 }, + { field: 'ContactTitle', rowStart: 2, colStart: 2 }, + { field: 'Address', rowStart: 3, colStart: 1, colEnd: 3 } + ] + }, + { + group: 'group2', + pinned: true, + columns: [ + { field: 'City', rowStart: 1, colStart: 1, colEnd: 3, rowEnd: 3, width: '400px' }, + { field: 'Region', rowStart: 3, colStart: 1 }, + { field: 'PostalCode', rowStart: 3, colStart: 2 } + ] + }, + { + group: 'group3', + columns: [ + { field: 'Phone', rowStart: 1, colStart: 1, width: '200px' }, + { field: 'Fax', rowStart: 2, colStart: 1 }, + { field: 'PostalCode', rowStart: 3, colStart: 1 } + ] + } + ]; + fix.componentInstance.grid.width = '600px'; + fix.detectChanges(); + const firstBlock = fix.debugElement.query(By.css('igx-grid-row')).queryAll(By.css(CELL_BLOCK))[0]; + const secondBlock = fix.debugElement.queryAll(By.css('igx-grid-row'))[1].queryAll(By.css(CELL_BLOCK))[0]; + + const firstCell = firstBlock.queryAll(By.css(CELL_CSS_CLASS))[1]; + const secondCell = secondBlock.queryAll(By.css(CELL_CSS_CLASS))[0]; + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[1].City); + expect(fix.componentInstance.selectedCell.column.field).toMatch('City'); + expect(secondCell.componentInstance.active).toBeTruthy(); + }); + + it('should navigate down to next row inside unpinned area when pinning second block in template', () => { + fix.componentInstance.colGroups = [ + { + group: 'group1', + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3, width: '300px' }, + { field: 'ContactName', rowStart: 2, colStart: 1 }, + { field: 'ContactTitle', rowStart: 2, colStart: 2 }, + { field: 'Address', rowStart: 3, colStart: 1, colEnd: 3 } + ] + }, + { + group: 'group2', + pinned: true, + columns: [ + { field: 'City', rowStart: 1, colStart: 1, colEnd: 3, rowEnd: 3, width: '400px' }, + { field: 'Region', rowStart: 3, colStart: 1 }, + { field: 'PostalCode', rowStart: 3, colStart: 2 } + ] + }, + { + group: 'group3', + columns: [ + { field: 'Phone', rowStart: 1, colStart: 1, width: '200px' }, + { field: 'Fax', rowStart: 2, colStart: 1 }, + { field: 'PostalCode', rowStart: 3, colStart: 1 } + ] + } + ]; + fix.componentInstance.grid.width = '600px'; + fix.detectChanges(); + const firstBlock = fix.debugElement.query(By.css('igx-grid-row')).queryAll(By.css(CELL_BLOCK))[1]; + const secondBlock = fix.debugElement.queryAll(By.css('igx-grid-row'))[1].queryAll(By.css(CELL_BLOCK))[1]; + let _dummyCell; + let firstCell; + let secondCell; + [firstCell, + secondCell, _dummyCell, + firstCell] = firstBlock.queryAll(By.css(CELL_CSS_CLASS)); + [secondCell, + _dummyCell, _dummyCell, + _dummyCell] = secondBlock.queryAll(By.css(CELL_CSS_CLASS)); + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[1].CompanyName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('CompanyName'); + expect(secondCell.componentInstance.active).toBeTruthy(); + }); + + it('should navigate up to next row inside pinned area when pinning second block in template', () => { + fix.componentInstance.colGroups = [ + { + group: 'group1', + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3, width: '300px' }, + { field: 'ContactName', rowStart: 2, colStart: 1 }, + { field: 'ContactTitle', rowStart: 2, colStart: 2 }, + { field: 'Address', rowStart: 3, colStart: 1, colEnd: 3 } + ] + }, + { + group: 'group2', + pinned: true, + columns: [ + { field: 'City', rowStart: 1, colStart: 1, colEnd: 3, rowEnd: 3, width: '400px' }, + { field: 'Region', rowStart: 3, colStart: 1 }, + { field: 'PostalCode', rowStart: 3, colStart: 2 } + ] + }, + { + group: 'group3', + columns: [ + { field: 'Phone', rowStart: 1, colStart: 1, width: '200px' }, + { field: 'Fax', rowStart: 2, colStart: 1 }, + { field: 'PostalCode', rowStart: 3, colStart: 1 } + ] + } + ]; + fix.componentInstance.grid.width = '600px'; + fix.detectChanges(); + const firstBlock = fix.debugElement.query(By.css('igx-grid-row')).queryAll(By.css(CELL_BLOCK))[0]; + const secondBlock = fix.debugElement.queryAll(By.css('igx-grid-row'))[1].queryAll(By.css(CELL_BLOCK))[0]; + + const firstCell = secondBlock.queryAll(By.css(CELL_CSS_CLASS))[0]; + const secondCell = firstBlock.queryAll(By.css(CELL_CSS_CLASS))[1]; + + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowUp'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].Region); + expect(fix.componentInstance.selectedCell.column.field).toMatch('Region'); + expect(secondCell.componentInstance.active).toBeTruthy(); + }); + + it('should navigate up to next row inside unpinned area when pinning second block in template', () => { + fix.componentInstance.colGroups = [ + { + group: 'group1', + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3, width: '300px' }, + { field: 'ContactName', rowStart: 2, colStart: 1 }, + { field: 'ContactTitle', rowStart: 2, colStart: 2 }, + { field: 'Address', rowStart: 3, colStart: 1, colEnd: 3 } + ] + }, + { + group: 'group2', + pinned: true, + columns: [ + { field: 'City', rowStart: 1, colStart: 1, colEnd: 3, rowEnd: 3, width: '400px' }, + { field: 'Region', rowStart: 3, colStart: 1 }, + { field: 'PostalCode', rowStart: 3, colStart: 2 } + ] + }, + { + group: 'group3', + columns: [ + { field: 'Phone', rowStart: 1, colStart: 1, width: '200px' }, + { field: 'Fax', rowStart: 2, colStart: 1 }, + { field: 'PostalCode', rowStart: 3, colStart: 1 } + ] + } + ]; + fix.componentInstance.grid.width = '600px'; + fix.detectChanges(); + const firstBlock = fix.debugElement.query(By.css('igx-grid-row')).queryAll(By.css(CELL_BLOCK))[1]; + const secondBlock = fix.debugElement.queryAll(By.css('igx-grid-row'))[1].queryAll(By.css(CELL_BLOCK))[1]; + + const firstCell = secondBlock.queryAll(By.css(CELL_CSS_CLASS))[0]; + const secondCell = firstBlock.queryAll(By.css(CELL_CSS_CLASS))[3]; + + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowUp'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].Address); + expect(fix.componentInstance.selectedCell.column.field).toMatch('Address'); + expect(secondCell.componentInstance.active).toBeTruthy(); + }); + + it('should navigate up to next row inside unpinned area when pinning second block in template', () => { + fix.componentInstance.colGroups = [ + { + group: 'group1', + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3, width: '300px' }, + { field: 'ContactName', rowStart: 2, colStart: 1 }, + { field: 'ContactTitle', rowStart: 2, colStart: 2 }, + { field: 'Address', rowStart: 3, colStart: 1, colEnd: 3 }, + ] + }, + { + group: 'group2', + pinned: true, + columns: [ + { field: 'Col1', rowStart: 1, colStart: 1 }, + { field: 'Col2', rowStart: 1, colStart: 2 }, + { field: 'Col3', rowStart: 1, colStart: 3 }, + { field: 'City', rowStart: 2, colStart: 1, colEnd: 4, width: '400px' }, + { field: 'Region', rowStart: 3, colStart: 1 }, + { field: 'PostalCode', rowStart: 3, colStart: 2, colEnd: 4 } + ] + }, + { + group: 'group3', + columns: [ + { field: 'Phone', rowStart: 1, colStart: 1, width: '200px' }, + { field: 'Fax', rowStart: 2, colStart: 1 }, + { field: 'PostalCode', rowStart: 3, colStart: 1 } + ] + } + ]; + fix.componentInstance.grid.width = '1000px'; + fix.detectChanges(); + const firstBlock = fix.debugElement.query(By.css('igx-grid-row')).queryAll(By.css(CELL_BLOCK))[1]; + const secondBlock = fix.debugElement.query(By.css('igx-grid-row')).queryAll(By.css(CELL_BLOCK))[2]; + + const firstCell = firstBlock.queryAll(By.css(CELL_CSS_CLASS))[2]; + const secondCell = secondBlock.queryAll(By.css(CELL_CSS_CLASS))[1]; + + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].Fax); + expect(fix.componentInstance.selectedCell.column.field).toMatch('Fax'); + expect(secondCell.componentInstance.active).toBeTruthy(); + }); + }); + + describe('Row Edit integration', () => { + it('shift+tab navigation should go through edit row buttons when navigating in row edit mode. ', () => { + fix.componentInstance.colGroups = [ + { + group: 'group1', + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3, width: '300px', editable: true }, + { field: 'ContactName', rowStart: 2, colStart: 1, editable: true }, + { field: 'ContactTitle', rowStart: 2, colStart: 2, editable: true }, + { field: 'Address', rowStart: 3, colStart: 1, colEnd: 3, editable: true } + ] + }, + { + group: 'group2', + columns: [ + { field: 'City', rowStart: 1, colStart: 1, colEnd: 3, rowEnd: 3, width: '400px', editable: true }, + { field: 'Region', rowStart: 3, colStart: 1, editable: true }, + { field: 'PostalCode', rowStart: 3, colStart: 2, editable: true } + ] + }, + { + group: 'group3', + columns: [ + { field: 'Phone', rowStart: 1, colStart: 1, width: '200px', editable: true }, + { field: 'Fax', rowStart: 2, colStart: 1, editable: true }, + { field: 'ID', rowStart: 3, colStart: 1, editable: true } + ] + } + ]; + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + grid.primaryKey = 'ID'; + grid.rowEditable = true; + fix.detectChanges(); + + let targetCell = grid.getCellByColumn(0, 'CompanyName'); + UIInteractions.simulateDoubleClickAndSelectEvent(grid.gridAPI.get_cell_by_index(0, 'CompanyName')); + fix.detectChanges(); + + const rowEditingBannerElement = fix.debugElement.query(By.css('.igx-banner__row')).nativeElement; + const doneButtonElement = rowEditingBannerElement.lastElementChild; + const cancelButtonElement = rowEditingBannerElement.firstElementChild; + + // shift+tab into Done button + GridFunctions.simulateGridContentKeydown(fix, 'Tab', false, true); + fix.detectChanges(); + expect(document.activeElement).toEqual(doneButtonElement); + + // shift+ tab into Cancel + UIInteractions.triggerKeyDownEvtUponElem('tab', doneButtonElement, true, false, true); + fix.detectChanges(); + + // shift+ tab into last cell + UIInteractions.triggerKeyDownEvtUponElem('tab', cancelButtonElement, true, false, true); + fix.detectChanges(); + + targetCell = grid.getCellByColumn(0, 'PostalCode'); + expect(targetCell.active).toBe(true); + }); + }); + }); + + // Note: Some tests execute await wait() and etc two times, because the grid scrolls two times. + // This means that we need to wait chunkLoad event from the igxForOf two times. + describe('Navigation with scrolling', () => { + describe('General', () => { + it('should allow navigating down with scrolling', async () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'Phone', rowStart: 1, colStart: 1 }, + { field: 'City', rowStart: 1, colStart: 2 }, + { field: 'ContactName', rowStart: 2, colStart: 1, colEnd: 3 } + ] + }]; + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + const rows = fix.debugElement.queryAll(By.css(ROW_CSS_CLASS)); + const penultRowCells = rows[rows.length - 2].queryAll(By.css(CELL_CSS_CLASS)); + const secondCell = penultRowCells[1]; + const rowIndex = parseInt(secondCell.nativeElement.getAttribute('data-rowindex'), 10); + + UIInteractions.simulateClickAndSelectEvent(secondCell); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[rowIndex + 1].City); + expect(fix.componentInstance.selectedCell.column.field).toMatch('City'); + }); + + it('should navigate correctly by pressing Ctrl + ArrowUp/ArrowDown key', async () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + pinned: true, + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'ContactName', rowStart: 2, colStart: 1 }, + { field: 'ContactTitle', rowStart: 2, colStart: 2 } + ] + }]; + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + setupGridScrollDetection(fix, fix.componentInstance.grid); + + const [_firstCell, _secondCell, thirdCell] = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS)); + + UIInteractions.simulateClickAndSelectEvent(thirdCell); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].ContactTitle); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ContactTitle'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown', false, false, true); + await wait(DEBOUNCE_TIME * 2); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value) + .toEqual(fix.componentInstance.data[fix.componentInstance.data.length - 1].ContactTitle); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ContactTitle'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowUp', false, false, true); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].ContactTitle); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ContactTitle'); + clearGridSubs(); + }); + + it('should navigate to the last cell from the layout by pressing Home/End or Ctrl + ArrowLeft/ArrowRight key', async () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + hidden: true, + columns: [ + { field: 'ID', rowStart: 1, colStart: 1, rowEnd: 3 } + ] + }, { + group: 'group2', + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'ContactName', rowStart: 2, colStart: 1 }, + { field: 'ContactTitle', rowStart: 2, colStart: 2 }, + { field: 'Address', rowStart: 3, colStart: 1, colEnd: 3 } + ] + }, { + group: 'group3', + columns: [ + { field: 'City', rowStart: 1, colStart: 1, colEnd: 3, rowEnd: 3 }, + { field: 'Region', rowStart: 3, colStart: 1 }, + { field: 'PostalCode', rowStart: 3, colStart: 2 } + ] + }, { + group: 'group4', + columns: [ + { field: 'Country', rowStart: 1, colStart: 1 }, + { field: 'Phone', rowStart: 1, colStart: 2 }, + { field: 'Fax', rowStart: 2, colStart: 1, colEnd: 3, rowEnd: 4 } + ] + }]; + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + const firstCell = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[0]; + + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'end'); + await wait(DEBOUNCE_TIME * 2); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].Phone); + expect(fix.componentInstance.selectedCell.column.field).toMatch('Phone'); + + GridFunctions.simulateGridContentKeydown(fix, 'home'); + await wait(DEBOUNCE_TIME * 2); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].CompanyName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('CompanyName'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight', false, false, true); + await wait(DEBOUNCE_TIME * 2); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].Phone); + expect(fix.componentInstance.selectedCell.column.field).toMatch('Phone'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowLeft', false, false, true); + await wait(DEBOUNCE_TIME * 2); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].CompanyName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('CompanyName'); + }); + + it('should navigate to the last cell from the first/last layout by pressing Ctrl + Home/End key', async () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + hidden: true, + columns: [ + { field: 'ID', rowStart: 1, colStart: 1, rowEnd: 3 } + ] + }, { + group: 'group2', + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'ContactName', rowStart: 2, colStart: 1 }, + { field: 'ContactTitle', rowStart: 2, colStart: 2 }, + { field: 'Address', rowStart: 3, colStart: 1, colEnd: 3 } + ] + }, { + group: 'group3', + columns: [ + { field: 'City', rowStart: 1, colStart: 1, colEnd: 3, rowEnd: 3 }, + { field: 'Region', rowStart: 3, colStart: 1 }, + { field: 'PostalCode', rowStart: 3, colStart: 2 } + ] + }, { + group: 'group4', + columns: [ + { field: 'Country', rowStart: 1, colStart: 1 }, + { field: 'Phone', rowStart: 1, colStart: 2 }, + { field: 'Fax', rowStart: 2, colStart: 1, colEnd: 3, rowEnd: 4 } + ] + }]; + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + setupGridScrollDetection(fix, fix.componentInstance.grid); + const firstCell = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[0]; + + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'end', false, false, true); + await wait(200); + fix.detectChanges(); + await wait(200); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value) + .toEqual(fix.componentInstance.data[fix.componentInstance.data.length - 1].Fax); + expect(fix.componentInstance.selectedCell.column.field).toMatch('Fax'); + + GridFunctions.simulateGridContentKeydown(fix, 'home', false, false, true); + await wait(200); + fix.detectChanges(); + await wait(200); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].CompanyName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('CompanyName'); + + clearGridSubs(); + }); + + it(`should navigate to the last cell from the layout by pressing Home/End or Ctrl + ArrowLeft/ArrowRight key + and keep same rowStart from the first selection when last cell spans more rows`, async () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + hidden: true, + columns: [ + { field: 'ID', rowStart: 1, colStart: 1, rowEnd: 3 } + ] + }, { + group: 'group2', + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'ContactName', rowStart: 2, colStart: 1 }, + { field: 'ContactTitle', rowStart: 2, colStart: 2 }, + { field: 'Address', rowStart: 3, colStart: 1, colEnd: 3 } + ] + }, { + group: 'group3', + columns: [ + { field: 'City', rowStart: 1, colStart: 1, colEnd: 3, rowEnd: 3 }, + { field: 'Region', rowStart: 3, colStart: 1 }, + { field: 'PostalCode', rowStart: 3, colStart: 2 } + ] + }, { + group: 'group4', + columns: [ + { field: 'Country', rowStart: 1, colStart: 1 }, + { field: 'Phone', rowStart: 1, colStart: 2 }, + { field: 'Fax', rowStart: 2, colStart: 1, colEnd: 3, rowEnd: 4 } + ] + }]; + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + // last cell from first layout + const lastCell = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[3]; + + UIInteractions.simulateClickAndSelectEvent(lastCell); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'end'); + await wait(DEBOUNCE_TIME * 2); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].Fax); + expect(fix.componentInstance.selectedCell.column.field).toMatch('Fax'); + + GridFunctions.simulateGridContentKeydown(fix, 'home'); + await wait(DEBOUNCE_TIME * 2); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].Address); + expect(fix.componentInstance.selectedCell.column.field).toMatch('Address'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight', false, false, true); + await wait(DEBOUNCE_TIME * 2); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].Fax); + expect(fix.componentInstance.selectedCell.column.field).toMatch('Fax'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowLeft', false, false, true); + await wait(DEBOUNCE_TIME * 2); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].Address); + expect(fix.componentInstance.selectedCell.column.field).toMatch('Address'); + }); + + it(`should navigate to the last cell from the layout by pressing Home/End and Ctrl key + and keep same rowStart from the first selection when last cell spans more rows`, async () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + hidden: true, + columns: [ + { field: 'ID', rowStart: 1, colStart: 1, rowEnd: 3 } + ] + }, { + group: 'group2', + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'ContactName', rowStart: 2, colStart: 1 }, + { field: 'ContactTitle', rowStart: 2, colStart: 2 }, + { field: 'Address', rowStart: 3, colStart: 1, colEnd: 3 } + ] + }, { + group: 'group3', + columns: [ + { field: 'City', rowStart: 1, colStart: 1, colEnd: 3, rowEnd: 3 }, + { field: 'Region', rowStart: 3, colStart: 1 }, + { field: 'PostalCode', rowStart: 3, colStart: 2 } + ] + }, { + group: 'group4', + columns: [ + { field: 'Country', rowStart: 1, colStart: 1 }, + { field: 'Phone', rowStart: 1, colStart: 2 }, + { field: 'Fax', rowStart: 2, colStart: 1, colEnd: 3, rowEnd: 4 } + ] + }]; + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + setupGridScrollDetection(fix, fix.componentInstance.grid); + // last cell from first layout + const lastCell = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[3]; + + UIInteractions.simulateClickAndSelectEvent(lastCell); + await wait(); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'End', false, false, true); + await wait(200); + fix.detectChanges(); + await wait(200); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value) + .toEqual(fix.componentInstance.data[fix.componentInstance.data.length - 1].Fax); + expect(fix.componentInstance.selectedCell.column.field).toMatch('Fax'); + + GridFunctions.simulateGridContentKeydown(fix, 'Home', false, false, true); + await wait(200); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].CompanyName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('CompanyName'); + clearGridSubs(); + }); + + it(`should navigate to the last cell from the layout by pressing Ctrl + Arrow Right key + and then Arrow Down + Up to same cell`, async () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + hidden: true, + columns: [ + { field: 'ID', rowStart: 1, colStart: 1, rowEnd: 3 } + ] + }, { + group: 'group2', + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'ContactName', rowStart: 2, colStart: 1 }, + { field: 'ContactTitle', rowStart: 2, colStart: 2 }, + { field: 'Address', rowStart: 3, colStart: 1, colEnd: 3 } + ] + }, { + group: 'group3', + columns: [ + { field: 'City', rowStart: 1, colStart: 1, colEnd: 3, rowEnd: 3 }, + { field: 'Region', rowStart: 3, colStart: 1 }, + { field: 'PostalCode', rowStart: 3, colStart: 2 } + ] + }, { + group: 'group4', + columns: [ + { field: 'Country', rowStart: 1, colStart: 1 }, + { field: 'Phone', rowStart: 1, colStart: 2 }, + { field: 'Fax', rowStart: 2, colStart: 1, colEnd: 3, rowEnd: 4 } + ] + }]; + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + setupGridScrollDetection(fix, fix.componentInstance.grid); + const firstCell = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[0]; + + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight', false, false, true); + await wait(); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].Phone); + expect(fix.componentInstance.selectedCell.column.field).toMatch('Phone'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); + await wait(); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].Fax); + expect(fix.componentInstance.selectedCell.column.field).toMatch('Fax'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowUp'); + await wait(); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].Phone); + expect(fix.componentInstance.selectedCell.column.field).toMatch('Phone'); + + clearGridSubs(); + }); + + it(`should navigate to the last cell from the layout by pressing Ctrl + Arrow Right key + and then Arrow Up + Down to same cell`, async () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + hidden: true, + columns: [ + { field: 'ID', rowStart: 1, colStart: 1, rowEnd: 3 } + ] + }, { + group: 'group2', + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'ContactName', rowStart: 2, colStart: 1 }, + { field: 'ContactTitle', rowStart: 2, colStart: 2 }, + { field: 'Address', rowStart: 3, colStart: 1, colEnd: 3 } + ] + }, { + group: 'group3', + columns: [ + { field: 'City', rowStart: 1, colStart: 1, colEnd: 3, rowEnd: 3 }, + { field: 'Region', rowStart: 3, colStart: 1 }, + { field: 'PostalCode', rowStart: 3, colStart: 2 } + ] + }, { + group: 'group4', + columns: [ + { field: 'Country', rowStart: 1, colStart: 1 }, + { field: 'Phone', rowStart: 1, colStart: 2 }, + { field: 'Fax', rowStart: 2, colStart: 1, colEnd: 3, rowEnd: 4 } + ] + }]; + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + setupGridScrollDetection(fix, fix.componentInstance.grid); + const rows = fix.debugElement.queryAll(By.css(ROW_CSS_CLASS)); + const firstCell = rows[2].queryAll(By.css(CELL_CSS_CLASS))[0]; + + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight', false, false, true); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[1].Phone); + expect(fix.componentInstance.selectedCell.column.field).toMatch('Phone'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowUp'); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].Fax); + expect(fix.componentInstance.selectedCell.column.field).toMatch('Fax'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[1].Phone); + expect(fix.componentInstance.selectedCell.column.field).toMatch('Phone'); + + clearGridSubs(); + }); + + it('should scroll active cell fully in view when navigating with arrow keys and row is partially visible.', async () => { + fix.componentInstance.colGroups = [ + { + group: 'group1', + columns: [ + // col span 2 + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'Phone', rowStart: 2, colStart: 1 }, + { field: 'City', rowStart: 2, colStart: 2 }, + // col span 2 + { field: 'ContactTitle', rowStart: 3, colStart: 1, colEnd: 3 } + ] + }, + { + group: 'group2', + columns: [ + // row span 2 + { field: 'Address', rowStart: 1, colStart: 1, rowEnd: 3 }, + { field: 'PostalCode', rowStart: 3, colStart: 1 } + ] + }, + { + group: 'group3', + // row span 3 + columns: [ + { field: 'ID', rowStart: 1, colStart: 1, rowEnd: 4 } + ] + }]; + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + fix.detectChanges(); + + // focus 3rd row, first cell + let cell = grid.gridAPI.get_cell_by_index(2, 'ContactName'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + // arrow down + GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + // check next cell is active and is fully in view + cell = grid.gridAPI.get_cell_by_index(2, 'Phone'); + expect(cell.active).toBe(true); + GridFunctions.verifyGridContentActiveDescendant(GridFunctions.getGridContent(fix), cell.nativeElement.id); + expect(grid.verticalScrollContainer.getScroll().scrollTop).toBeGreaterThan(50); + let diff = grid.gridAPI.get_cell_by_index(2, 'Phone') + .nativeElement.getBoundingClientRect().bottom - grid.tbody.nativeElement.getBoundingClientRect().bottom; + expect(diff).toBe(0); + + // focus 1st row, 2nd cell + cell = grid.gridAPI.get_cell_by_index(0, 'Phone'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + // arrow up + GridFunctions.simulateGridContentKeydown(fix, 'ArrowUp'); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + // check next cell is active and is fully in view + cell = grid.gridAPI.get_cell_by_index(0, 'ContactName'); + expect(cell.active).toBe(true); + GridFunctions.verifyGridContentActiveDescendant(GridFunctions.getGridContent(fix), cell.nativeElement.id); + expect(grid.verticalScrollContainer.getScroll().scrollTop).toBe(0); + diff = grid.gridAPI.get_cell_by_index(0, 'ContactName') + .nativeElement.getBoundingClientRect().top - grid.tbody.nativeElement.getBoundingClientRect().top; + expect(diff).toBe(0); + + // focus 3rd row, first cell + cell = grid.gridAPI.get_cell_by_index(2, 'ContactName'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + // arrow right + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight'); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + // check next cell is active and is fully in view + cell = grid.gridAPI.get_cell_by_index(2, 'Address'); + expect(cell.active).toBe(true); + GridFunctions.verifyGridContentActiveDescendant(GridFunctions.getGridContent(fix), cell.nativeElement.id); + expect(grid.verticalScrollContainer.getScroll().scrollTop).toBeGreaterThan(50); + diff = grid.gridAPI.get_cell_by_index(2, 'Address') + .nativeElement.getBoundingClientRect().bottom - grid.tbody.nativeElement.getBoundingClientRect().bottom; + expect(diff).toBe(0); + + // focus 1st row, Address + cell = grid.gridAPI.get_cell_by_index(0, 'Address'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + // arrow left + GridFunctions.simulateGridContentKeydown(fix, 'ArrowLeft'); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + // check next cell is active and is fully in view + cell = grid.gridAPI.get_cell_by_index(0, 'ContactName'); + expect(cell.active).toBe(true); + expect(grid.verticalScrollContainer.getScroll().scrollTop).toBe(0); + diff = grid.gridAPI.get_cell_by_index(0, 'ContactName') + .nativeElement.getBoundingClientRect().top - grid.tbody.nativeElement.getBoundingClientRect().top; + expect(diff).toBe(0); + }); + + it('should scroll active cell fully in view when navigating with arrow keys and column is partially visible.', async () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + // col span 2 + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 5 }, + { field: 'Phone', rowStart: 2, colStart: 1 }, + { field: 'City', rowStart: 2, colStart: 2 }, + { field: 'Address', rowStart: 2, colStart: 3 }, + { field: 'Country', rowStart: 2, colStart: 4 }, + // col span 2 + { field: 'ContactTitle', rowStart: 3, colStart: 1, colEnd: 5 } + ] + }]; + const grid = fix.componentInstance.grid; + grid.columnWidth = '300px'; + grid.width = '300px'; + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + // focus 1st row, 2nd row cell + let cell = grid.getCellByColumn(0, 'Phone'); + UIInteractions.simulateClickAndSelectEvent(grid.gridAPI.get_cell_by_index(0, 'Phone')); + fix.detectChanges(); + + // arrow right + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight'); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + // check next cell is active + cell = grid.getCellByColumn(0, 'City'); + expect(cell.active).toBe(true); + expect(grid.headerContainer.getScroll().scrollLeft).toBeGreaterThanOrEqual(300); + + // arrow left + GridFunctions.simulateGridContentKeydown(fix, 'ArrowLeft'); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + // check next cell is active + cell = grid.getCellByColumn(0, 'Phone'); + expect(cell.active).toBe(true); + }); + + it(`should navigate to the last cell from the layout by pressing Ctrl + ArrowLeft/ArrowRight key + in grid with horizontal virtualization`, async () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + // row span 3 + columns: [ + { field: 'ID', rowStart: 1, colStart: 1, rowEnd: 4 } + ] + }, { + group: 'group2', + columns: [ + // col span 2 + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'Phone', rowStart: 2, colStart: 1 }, + { field: 'City', rowStart: 2, colStart: 2 }, + // col span 2 + { field: 'ContactTitle', rowStart: 3, colStart: 1, colEnd: 3 } + ] + }, { + group: 'group3', + columns: [ + // row span 2 + { field: 'Address', rowStart: 1, colStart: 1, rowEnd: 3 }, + { field: 'PostalCode', rowStart: 3, colStart: 1 } + ] + }]; + const grid = fix.componentInstance.grid; + grid.columnWidth = '300px'; + grid.width = '400px'; + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + let firstCell = grid.gridAPI.get_cell_by_index(0, 'ID'); + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + // ctrl+arrow right + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight', false, false, true); + await wait(DEBOUNCE_TIME * 2); + fix.detectChanges(); + + // check correct cell is active and is fully in view + const lastCell = grid.gridAPI.get_cell_by_index(0, 'Address'); + expect(lastCell.active).toBe(true); + expect(grid.headerContainer.getScroll().scrollLeft).toBeGreaterThan(800); + let diff = lastCell.nativeElement.getBoundingClientRect().right - grid.tbody.nativeElement.getBoundingClientRect().right; + expect(diff).toBe(0); + + // ctrl+arrow left + GridFunctions.simulateGridContentKeydown(fix, 'ArrowLeft', false, false, true); + await wait(DEBOUNCE_TIME * 2); + fix.detectChanges(); + + // first cell should be active and is fully in view + firstCell = grid.gridAPI.get_cell_by_index(0, 'ID'); + expect(firstCell.active).toBe(true); + expect(grid.headerContainer.getScroll().scrollLeft).toBe(0); + diff = firstCell.nativeElement.getBoundingClientRect().left - grid.tbody.nativeElement.getBoundingClientRect().left; + expect(diff).toBe(0); + }); + }); + + describe('Pinning', () => { + it('should navigate from pinned to unpinned area and backwards', async () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + pinned: true, + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'ContactName', rowStart: 2, colStart: 1 }, + { field: 'ContactTitle', rowStart: 2, colStart: 2 }, + { field: 'Address', rowStart: 3, colStart: 1, colEnd: 3 } + ] + }, { + group: 'group2', + columns: [ + { field: 'City', rowStart: 1, colStart: 1, colEnd: 3, rowEnd: 3 }, + { field: 'Region', rowStart: 3, colStart: 1 }, + { field: 'PostalCode', rowStart: 3, colStart: 2 } + ] + }]; + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + const firstCell = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[0]; + + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight'); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].City); + expect(fix.componentInstance.selectedCell.column.field).toMatch('City'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowLeft'); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].CompanyName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('CompanyName'); + }); + + it('when navigating from pinned to unpinned area cell should be fully scrolled in view.', async () => { + //pending('This should be tested in the e2e test'); + fix.componentInstance.colGroups = [{ + group: 'group1', + // row span 3 + columns: [ + { field: 'ID', rowStart: 1, colStart: 1, rowEnd: 4 } + ] + }, { + group: 'group2', + columns: [ + // col span 2 + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'Phone', rowStart: 2, colStart: 1 }, + { field: 'City', rowStart: 2, colStart: 2 }, + // col span 2 + { field: 'ContactTitle', rowStart: 3, colStart: 1, colEnd: 3 } + ] + }, { + group: 'group3', + columns: [ + // row span 2 + { field: 'Address', rowStart: 1, colStart: 1, rowEnd: 3 }, + { field: 'PostalCode', rowStart: 3, colStart: 1 } + ] + }]; + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + grid.columnWidth = '300px'; + grid.width = '500px'; + setupGridScrollDetection(fix, grid); + fix.detectChanges(); + + // pin col + grid.getColumnByName('ID').pinned = true; + fix.detectChanges(); + + // scroll right + grid.headerContainer.getScroll().scrollLeft = 800; + await wait(DEBOUNCE_TIME * 2); + fix.detectChanges(); + + // focus first pinned cell + const firstCell = grid.gridAPI.get_cell_by_index(0, 'ID'); + UIInteractions.simulateClickAndSelectEvent(firstCell); + await wait(); + fix.detectChanges(); + + // arrow right + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight'); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + // check if first unpinned cell is active and is in view + const firstUnpinnedCell = grid.gridAPI.get_cell_by_index(0, 'ContactName'); + expect(firstUnpinnedCell.active).toBe(true); + const diff = firstUnpinnedCell.nativeElement.getBoundingClientRect().left - + grid.pinnedStartWidth - grid.tbody.nativeElement.getBoundingClientRect().left; + expect(diff).toBe(0); + + // TODO: Rest of the test needs to be finished + // arrow left + // GridFunctions.simulateGridContentKeydown(fix, 'ArrowLeft'); + // fix.detectChanges(); + + // expect(firstCell.active).toBe(true); + + // // scroll right + // grid.headerContainer.getScroll().scrollLeft = 800; + // fix.detectChanges(); + // await wait(DEBOUNCE_TIME); + + // GridFunctions.simulateGridContentKeydown(fix, 'Tab'); + // fix.detectChanges(); + // await wait(); + + // firstUnpinnedCell = grid.gridAPI.get_cell_by_index(0, 'ContactName'); + // expect(firstUnpinnedCell.active).toBe(true); + // diff = firstUnpinnedCell.nativeElement.getBoundingClientRect().left - + // grid.pinnedWidth - grid.tbody.nativeElement.getBoundingClientRect().left; + // expect(diff).toBe(0); + + clearGridSubs(); + }); + + it('should navigate to unpinned area when the column layout is bigger than the display container', async () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + pinned: true, + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3, width: '300px' }, + { field: 'ContactName', rowStart: 2, colStart: 1 }, + { field: 'ContactTitle', rowStart: 2, colStart: 2 }, + { field: 'Address', rowStart: 3, colStart: 1, colEnd: 3 } + ] + }, { + group: 'group2', + columns: [ + { field: 'City', rowStart: 1, colStart: 1, colEnd: 3, rowEnd: 3, width: '400px' }, + { field: 'Region', rowStart: 3, colStart: 1 }, + { field: 'PostalCode', rowStart: 3, colStart: 2 } + ] + }]; + fix.componentInstance.grid.width = '600px'; + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + const firstCell = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[0]; + + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight'); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].City); + expect(fix.componentInstance.selectedCell.column.field).toMatch('City'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowLeft'); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].CompanyName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('CompanyName'); + }); + + it('should navigate from pinned to unpinned area and backwards using Home/End', async () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + pinned: true, + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'ContactName', rowStart: 2, colStart: 1 }, + { field: 'ContactTitle', rowStart: 2, colStart: 2 }, + { field: 'Address', rowStart: 3, colStart: 1, colEnd: 3 } + ] + }, { + group: 'group2', + columns: [ + { field: 'City', rowStart: 1, colStart: 1, colEnd: 3, rowEnd: 3 }, + { field: 'Region', rowStart: 3, colStart: 1 }, + { field: 'PostalCode', rowStart: 3, colStart: 2 } + ] + }]; + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + const secondCell = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[1]; + + UIInteractions.simulateClickAndSelectEvent(secondCell); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'End'); + await wait(); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].City); + expect(fix.componentInstance.selectedCell.column.field).toMatch('City'); + + GridFunctions.simulateGridContentKeydown(fix, 'Home'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].ContactName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ContactName'); + }); + + it('should navigate from pinned to unpinned area and backwards using Ctrl+Left/Right', async () => { + fix.componentInstance.colGroups = [{ + group: 'group1', + pinned: true, + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'ContactName', rowStart: 2, colStart: 1 }, + { field: 'ContactTitle', rowStart: 2, colStart: 2 }, + { field: 'Address', rowStart: 3, colStart: 1, colEnd: 3 } + ] + }, { + group: 'group2', + columns: [ + { field: 'City', rowStart: 1, colStart: 1, colEnd: 3, rowEnd: 3 }, + { field: 'Region', rowStart: 3, colStart: 1 }, + { field: 'PostalCode', rowStart: 3, colStart: 2 } + ] + }]; + fix.detectChanges(); + const secondCell = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[1]; + + UIInteractions.simulateClickAndSelectEvent(secondCell); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight', false, false, true); + await wait(); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].City); + expect(fix.componentInstance.selectedCell.column.field).toMatch('City'); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowLeft', false, false, true); + await wait(); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].ContactName); + expect(fix.componentInstance.selectedCell.column.field).toMatch('ContactName'); + }); + + it('should navigate to the last block with one pinned group and unpinned area has scrollbar', async () => { + fix.componentInstance.colGroups = [ + { + group: 'group1', + pinned: true, + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3, width: '300px' }, + { field: 'ContactName', rowStart: 2, colStart: 1 }, + { field: 'ContactTitle', rowStart: 2, colStart: 2 }, + { field: 'Address', rowStart: 3, colStart: 1, colEnd: 3 } + ] + }, + { + group: 'group2', + columns: [ + { field: 'City', rowStart: 1, colStart: 1, colEnd: 3, rowEnd: 3, width: '400px' }, + { field: 'Region', rowStart: 3, colStart: 1 }, + { field: 'PostalCode', rowStart: 3, colStart: 2 } + ] + }, + { + group: 'group3', + columns: [ + { field: 'Phone', rowStart: 1, colStart: 1, width: '200px' }, + { field: 'Fax', rowStart: 2, colStart: 1 }, + { field: 'PostalCode', rowStart: 3, colStart: 1 } + ] + } + ]; + fix.componentInstance.grid.width = '600px'; + fix.detectChanges(); + const secondBlock = fix.debugElement.query(By.css('igx-grid-row')).queryAll(By.css(CELL_BLOCK))[1]; + const thirdBlock = fix.debugElement.query(By.css('igx-grid-row')).queryAll(By.css(CELL_BLOCK))[2]; + + const [secondCell, thirdCell, _fourthCell] = thirdBlock.queryAll(By.css(CELL_CSS_CLASS)); + const firstCell = secondBlock.queryAll(By.css(CELL_CSS_CLASS))[0]; + + fix.componentInstance.grid.headerContainer.getScroll().scrollLeft = 500; + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight'); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].Phone); + expect(fix.componentInstance.selectedCell.column.field).toMatch('Phone'); + expect(secondCell.componentInstance.active).toBeTruthy(); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); + fix.detectChanges(); + + expect(fix.componentInstance.selectedCell.value).toEqual(fix.componentInstance.data[0].Fax); + expect(fix.componentInstance.selectedCell.column.field).toMatch('Fax'); + expect(thirdCell.componentInstance.active).toBeTruthy(); + }); + }); + + describe('Row Edit', () => { + it('tab navigation should should skip non-editable cells when navigating in row edit mode. ', async () => { + fix.componentInstance.colGroups = [ + { + group: 'group1', + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3, width: '300px', editable: true }, + { field: 'ContactName', rowStart: 2, colStart: 1, editable: false }, + { field: 'ContactTitle', rowStart: 2, colStart: 2, editable: true }, + { field: 'Address', rowStart: 3, colStart: 1, colEnd: 3, editable: true } + ] + }, + { + group: 'group2', + columns: [ + { field: 'City', rowStart: 1, colStart: 1, colEnd: 3, rowEnd: 3, width: '400px', editable: true }, + { field: 'Region', rowStart: 3, colStart: 1, editable: true }, + { field: 'PostalCode', rowStart: 3, colStart: 2, editable: true } + ] + }, + { + group: 'group3', + columns: [ + { field: 'Phone', rowStart: 1, colStart: 1, width: '200px', editable: true }, + { field: 'Fax', rowStart: 2, colStart: 1, editable: true }, + { field: 'ID', rowStart: 3, colStart: 1, editable: true } + ] + } + ]; + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + grid.primaryKey = 'ID'; + grid.rowEditable = true; + fix.detectChanges(); + + let cell = grid.getCellByColumn(0, 'CompanyName'); + UIInteractions.simulateDoubleClickAndSelectEvent(grid.gridAPI.get_cell_by_index(0, 'CompanyName')); + fix.detectChanges(); + + const order = ['CompanyName', 'City', 'Phone', 'ContactTitle']; + // tab through cols and check order is correct - ContactName should be skipped. + for (let i = 1; i < order.length; i++) { + GridFunctions.simulateGridContentKeydown(fix, 'Tab'); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + cell = grid.getCellByColumn(0, order[i]); + expect(cell.editMode).toBe(true); + } + + // shift+tab through cols and check order is correct - ContactName should be skipped. + for (let j = order.length - 2; j >= 0; j--) { + GridFunctions.simulateGridContentKeydown(fix, 'Tab', false, true); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + cell = grid.getCellByColumn(0, order[j]); + expect(cell.editMode).toBe(true); + } + }); + }); + }); + + describe('IgxGrid Multi Row Layout - navigateTo #grid', () => { + + it('navigateTo method should work in multi-row layout grid.', async () => { + fix.componentInstance.colGroups = [ + { + group: 'group1', + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3, editable: true }, + { field: 'ContactName', rowStart: 2, colStart: 1, editable: false, width: '100px' }, + { field: 'ContactTitle', rowStart: 2, colStart: 2, editable: true, width: '100px' }, + { field: 'Address', rowStart: 3, colStart: 1, colEnd: 3, editable: true, width: '100px' } + ] + }, + { + group: 'group2', + columns: [ + { field: 'City', rowStart: 1, colStart: 1, colEnd: 3, rowEnd: 3, width: '400px', editable: true }, + { field: 'Region', rowStart: 3, colStart: 1, editable: true }, + { field: 'PostalCode', rowStart: 3, colStart: 2, editable: true } + ] + }, + { + group: 'group3', + columns: [ + { field: 'Phone', rowStart: 1, colStart: 1, width: '200px', editable: true }, + { field: 'Fax', rowStart: 2, colStart: 1, editable: true, width: '200px' }, + { field: 'ID', rowStart: 3, colStart: 1, editable: true, width: '200px' } + ] + } + ]; + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + grid.width = '500px'; + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + // navigate down to cell in a row that is in the DOM but is not in view (half-visible row) + let col = grid.getColumnByName('ContactTitle'); + grid.navigateTo(2, col.visibleIndex); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + // cell should be at bottom of grid + let cell = grid.gridAPI.get_cell_by_index(2, 'ContactTitle'); + expect(grid.verticalScrollContainer.getScroll().scrollTop).toBeGreaterThan(50); + let diff = cell.nativeElement.getBoundingClientRect().bottom - grid.tbody.nativeElement.getBoundingClientRect().bottom; + // there is 2px border at the bottom now + expect(diff).toBe(0); + + // navigate up to cell in a row that is in the DOM but is not in view (half-visible row) + col = grid.getColumnByName('CompanyName'); + grid.navigateTo(0, col.visibleIndex); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + // cell should be at top of grid + cell = grid.gridAPI.get_cell_by_index(0, 'CompanyName'); + expect(grid.verticalScrollContainer.getScroll().scrollTop).toBe(0); + diff = cell.nativeElement.getBoundingClientRect().top - grid.tbody.nativeElement.getBoundingClientRect().top; + expect(diff).toBe(0); + }); + + it('navigateTo method should work in multi-row layout grid when scrolling to bottom.', async () => { + fix.componentInstance.colGroups = [ + { + group: 'group1', + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3, editable: true }, + { field: 'ContactName', rowStart: 2, colStart: 1, editable: false, width: '100px' }, + { field: 'ContactTitle', rowStart: 2, colStart: 2, editable: true, width: '100px' }, + { field: 'Address', rowStart: 3, colStart: 1, colEnd: 3, editable: true, width: '100px' } + ] + }, + { + group: 'group2', + columns: [ + { field: 'City', rowStart: 1, colStart: 1, colEnd: 3, rowEnd: 3, width: '400px', editable: true }, + { field: 'Region', rowStart: 3, colStart: 1, editable: true }, + { field: 'PostalCode', rowStart: 3, colStart: 2, editable: true } + ] + }, + { + group: 'group3', + columns: [ + { field: 'Phone', rowStart: 1, colStart: 1, width: '200px', editable: true }, + { field: 'Fax', rowStart: 2, colStart: 1, editable: true, width: '200px' }, + { field: 'ID', rowStart: 3, colStart: 1, editable: true, width: '200px' } + ] + } + ]; + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + grid.width = '500px'; + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + // navigate to cell in a row is not in the DOM + let col = grid.getColumnByName('CompanyName'); + grid.navigateTo(10, col.visibleIndex); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + // cell should be at bottom of grid + let cell = grid.gridAPI.get_cell_by_index(10, 'CompanyName'); + expect(grid.verticalScrollContainer.getScroll().scrollTop).toBeGreaterThan(50 * 10); + let diff = cell.nativeElement.getBoundingClientRect().bottom - grid.tbody.nativeElement.getBoundingClientRect().bottom; + // there is 2px border at the bottom now + expect(diff).toBe(0); + + // navigate right to cell in column that is in DOM but is not in view + col = grid.getColumnByName('City'); + grid.navigateTo(10, col.visibleIndex); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + // cell should be at right edge of grid + cell = grid.gridAPI.get_cell_by_index(10, 'City'); + expect(grid.headerContainer.getScroll().scrollLeft).toBeGreaterThan(100); + // check if cell right edge is visible + diff = cell.nativeElement.getBoundingClientRect().right - grid.tbody.nativeElement.getBoundingClientRect().right; + await wait(); + expect(diff).toBe(0); + + // navigate left to cell in column that is in DOM but is not in view + col = grid.getColumnByName('CompanyName'); + grid.navigateTo(10, col.visibleIndex); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + // cell should be at left edge of grid + cell = grid.gridAPI.get_cell_by_index(10, 'CompanyName'); + expect(grid.headerContainer.getScroll().scrollLeft).toBe(0); + // check if cell right left is visible + diff = cell.nativeElement.getBoundingClientRect().left - grid.tbody.nativeElement.getBoundingClientRect().left; + expect(diff).toBe(0); + + // navigate to cell in column that is not in DOM + + col = grid.getColumnByName('ID'); + grid.navigateTo(9, col.visibleIndex); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + // cell should be at right edge of grid + cell = grid.gridAPI.get_cell_by_index(9, 'ID'); + expect(grid.headerContainer.getScroll().scrollLeft).toBeGreaterThan(250); + // check if cell right right is visible + diff = cell.nativeElement.getBoundingClientRect().right - grid.tbody.nativeElement.getBoundingClientRect().right; + expect(diff).toBe(0); + }); + }); +}); + +@Component({ + template: ` + + @for (group of colGroups; track group.group) { + + @for (col of group.columns; track col.field) { + + } + + } + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxColumnLayoutComponent] +}) +export class ColumnLayoutTestComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + public selectedCell: CellType; + public cols: Array = [ + { field: 'ID', rowStart: 1, colStart: 1 }, + { field: 'CompanyName', rowStart: 1, colStart: 2 }, + { field: 'ContactName', rowStart: 1, colStart: 3 }, + { field: 'ContactTitle', rowStart: 2, colStart: 1, rowEnd: 4, colEnd: 4 }, + ]; + public colGroups: Array = [ + { + group: 'group1', + columns: this.cols + } + ]; + public data = SampleTestData.contactInfoDataFull(); + + public cellSelected(event: IGridCellEventArgs) { + this.selectedCell = event.cell; + } +} diff --git a/projects/igniteui-angular/grids/grid/src/grid-row-editing.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-row-editing.spec.ts new file mode 100644 index 00000000000..ba77b0bbd7d --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid-row-editing.spec.ts @@ -0,0 +1,3094 @@ +import { DebugElement } from '@angular/core'; +import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxGridComponent } from './grid.component'; +import { CellType, IGridEditDoneEventArgs, IGridEditEventArgs, IRowDataCancelableEventArgs, IRowDataEventArgs, RowType } from 'igniteui-angular/grids/core'; +import { IgxColumnComponent } from 'igniteui-angular/grids/core'; +import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; +import { clearGridSubs, setElementSize, setupGridScrollDetection } from '../../../test-utils/helper-utils.spec'; +import { GridFunctions, GridSummaryFunctions } from '../../../test-utils/grid-functions.spec'; +import { + IgxGridRowEditingComponent, + IgxGridRowEditingTransactionComponent, + IgxGridWithEditingAndFeaturesComponent, + IgxGridRowEditingWithoutEditableColumnsComponent, + IgxGridCustomOverlayComponent, + IgxGridEmptyRowEditTemplateComponent, + VirtualGridComponent, + ObjectCloneStrategy, + IgxGridCustomRowEditTemplateComponent +} from '../../../test-utils/grid-samples.spec'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { DefaultDataCloneStrategy, DefaultSortingStrategy, IgxNumberFilteringOperand, IgxStringFilteringOperand, ɵSize, SortingDirection, Transaction, TransactionType } from 'igniteui-angular/core'; + +const CELL_CLASS = '.igx-grid__td'; +const ROW_EDITED_CLASS = 'igx-grid__tr--edited'; +const ROW_DELETED_CLASS = 'igx-grid__tr--deleted'; +const SUMMARY_ROW = 'igx-grid-summary-row'; +const COLUMN_HEADER_GROUP_CLASS = '.igx-grid-thead__item'; +const DEBOUNCETIME = 30; + +describe('IgxGrid - Row Editing #grid', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxGridRowEditingComponent, + IgxGridRowEditingTransactionComponent, + IgxGridWithEditingAndFeaturesComponent, + IgxGridRowEditingWithoutEditableColumnsComponent, + IgxGridCustomOverlayComponent, + IgxGridEmptyRowEditTemplateComponent, + IgxGridCustomRowEditTemplateComponent, + VirtualGridComponent + ] + }).compileComponents(); + })); + + describe('General tests', () => { + let fix; + let grid: IgxGridComponent; + let cell: CellType; + let cellElem: CellType; + let cellDebug: DebugElement; + let gridContent: DebugElement; + + beforeEach(() => { + fix = TestBed.createComponent(IgxGridRowEditingComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + gridContent = GridFunctions.getGridContent(fix); + cell = grid.getCellByColumn(2, 'ProductName'); + cellElem = grid.gridAPI.get_cell_by_index(2, 'ProductName'); + cellDebug = GridFunctions.getRowCells(fix, 2)[2]; + // row = grid.gridAPI.get_row_by_index(2); + }); + + it('Should throw a warning when [rowEditable] is set on a grid w/o [primaryKey]', () => { + jasmine.getEnv().allowRespy(true); + grid.primaryKey = null; + grid.rowEditable = false; + fix.detectChanges(); + + spyOn(console, 'warn'); + grid.rowEditable = true; + fix.detectChanges(); + + // Throws warning but still sets the property correctly + expect(grid.rowEditable).toBeTruthy(); + + UIInteractions.simulateDoubleClickAndSelectEvent(cellElem); + + fix.detectChanges(); + expect(console.warn).toHaveBeenCalledWith('The grid must have a `primaryKey` specified when using `rowEditable`!'); + expect(console.warn).toHaveBeenCalledTimes(1); + jasmine.getEnv().allowRespy(false); + }); + + it('Should be able to enter edit mode on dblclick, enter and f2', () => { + fix.detectChanges(); + const row = grid.gridAPI.get_row_by_index(2); + + UIInteractions.simulateDoubleClickAndSelectEvent(cellElem); + fix.detectChanges(); + expect(row.inEditMode).toBe(true); + + UIInteractions.triggerEventHandlerKeyDown('escape', gridContent); + fix.detectChanges(); + expect(row.inEditMode).toBe(false); + + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fix.detectChanges(); + expect(row.inEditMode).toBe(true); + + UIInteractions.triggerEventHandlerKeyDown('escape', gridContent); + fix.detectChanges(); + expect(row.inEditMode).toBe(false); + + UIInteractions.triggerEventHandlerKeyDown('f2', gridContent); + fix.detectChanges(); + expect(row.inEditMode).toBe(true); + + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fix.detectChanges(); + expect(row.inEditMode).toBe(false); + }); + + it('Should not be able to enter edit mode on dblclick, enter and f2 when [rowEditable] is set on a grid w/o [primaryKey]', () => { + grid.primaryKey = null; + grid.rowEditable = true; + fix.detectChanges(); + + const row = grid.gridAPI.get_row_by_index(2); + + UIInteractions.simulateDoubleClickAndSelectEvent(cellElem); + fix.detectChanges(); + expect(row.inEditMode).toBe(false); + + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fix.detectChanges(); + expect(row.inEditMode).toBe(false); + + UIInteractions.triggerEventHandlerKeyDown('f2', gridContent); + fix.detectChanges(); + expect(row.inEditMode).toBe(false); + }); + + it('Emit all events with proper arguments', () => { + const row = grid.gridAPI.get_row_by_index(2); + const initialRowData = { ...cell.row.data }; + const newCellValue = 'Aaaaa'; + const updatedRowData = Object.assign({}, row.data, { ProductName: newCellValue }); + + spyOn(grid.cellEditEnter, 'emit').and.callThrough(); + spyOn(grid.cellEdit, 'emit').and.callThrough(); + spyOn(grid.cellEditDone, 'emit').and.callThrough(); + spyOn(grid.cellEditExit, 'emit').and.callThrough(); + spyOn(grid.rowEditEnter, 'emit').and.callThrough(); + spyOn(grid.rowEdit, 'emit').and.callThrough(); + spyOn(grid.rowEditExit, 'emit').and.callThrough(); + spyOn(grid.rowEditDone, 'emit').and.callThrough(); + + let cellInput = null; + + UIInteractions.simulateDoubleClickAndSelectEvent(cellElem); + fix.detectChanges(); + expect(row.inEditMode).toBe(true); + const cellEditArgs: IGridEditEventArgs = { + rowKey: cell.row.key, + cellID: cell.id, + rowID: cell.row.key, + primaryKey: cell.row.key, + rowData: cell.row.data, + oldValue: cell.value, + cancel: false, + column: cell.column, + owner: grid, + valid: true, + event: jasmine.anything() as any + }; + let rowEditArgs: IGridEditEventArgs = { + rowID: row.key, + primaryKey: row.key, + rowKey: cell.row.key, + rowData: initialRowData, + oldValue: row.data, + cancel: false, + valid: true, + owner: grid, + isAddRow: row.addRowUI, + event: jasmine.anything() as any + }; + expect(grid.cellEditEnter.emit).toHaveBeenCalledWith(cellEditArgs); + expect(grid.rowEditEnter.emit).toHaveBeenCalledWith(rowEditArgs); + + + UIInteractions.triggerEventHandlerKeyDown('escape', gridContent); + fix.detectChanges(); + + expect(row.inEditMode).toBe(false); + let cellEditExitArgs: IGridEditDoneEventArgs = { + cellID: cell.id, + rowID: cell.row.key, + primaryKey: cell.row.key, + rowKey: cell.row.key, + rowData: cell.row.data, + oldValue: cell.value, + valid: true, + newValue: cell.value, + column: cell.column, + owner: grid, + event: jasmine.anything() as any + }; + + const rowEditExitArgs: IGridEditDoneEventArgs = { + primaryKey: row.key, + rowID: row.key, + rowKey: row.key, + rowData: initialRowData, + newValue: initialRowData, + oldValue: row.data, + owner: grid, + isAddRow: row.addRowUI, + event: jasmine.anything() as any, + valid: true + }; + + expect(grid.cellEditExit.emit).toHaveBeenCalledWith(cellEditExitArgs); + expect(grid.rowEditExit.emit).toHaveBeenCalledWith(rowEditExitArgs); + + UIInteractions.simulateDoubleClickAndSelectEvent(cellDebug); + fix.detectChanges(); + expect(row.inEditMode).toBe(true); + + cellInput = (cellElem as any).nativeElement.querySelector('[igxinput]'); + UIInteractions.setInputElementValue(cellInput, newCellValue); + fix.detectChanges(); + + cellEditExitArgs = { + cellID: cell.id, + rowKey: cell.row.key, + rowID: cell.row.key, + primaryKey: cell.row.key, + rowData: Object.assign({}, row.data, { ProductName: newCellValue }), + oldValue: cell.value, + newValue: newCellValue, + valid: true, + column: cell.column, + owner: grid, + event: jasmine.anything() as any + }; + + cellEditArgs.newValue = newCellValue; + cellEditArgs.rowData = Object.assign({}, row.data, { ProductName: newCellValue }); + rowEditArgs = { + primaryKey: row.key, + rowID: row.key, + rowKey: cell.row.key, + rowData: initialRowData, + newValue: Object.assign({}, row.data, { ProductName: newCellValue }), + oldValue: row.data, + cancel: false, + owner: grid, + isAddRow: row.addRowUI, + valid: true, + event: jasmine.anything() as any + }; + + const cellDoneArgs: IGridEditDoneEventArgs = { + rowID: cell.row.key, + primaryKey: row.key, + rowKey: row.key, + cellID: cell.id, + rowData: updatedRowData, // with rowEditable - IgxGridRowEditingComponent + oldValue: cell.value, + newValue: newCellValue, + valid: true, + column: cell.column, + owner: grid, + event: jasmine.anything() as any + }; + + const rowDoneArgs: IGridEditDoneEventArgs = { + primaryKey: row.key, + rowID: row.key, + rowKey: row.key, + rowData: updatedRowData, // with rowEditable - IgxGridRowEditingComponent + oldValue: row.data, + newValue: Object.assign({}, row.data, { ProductName: newCellValue }), + owner: grid, + isAddRow: row.addRowUI, + event: jasmine.anything() as any, + valid: true + }; + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + + fix.detectChanges(); + + expect(grid.cellEdit.emit).toHaveBeenCalledWith(cellEditArgs); + expect(grid.cellEditDone.emit).toHaveBeenCalledWith(cellDoneArgs); + expect(grid.cellEditExit.emit).toHaveBeenCalledWith(cellEditExitArgs); + expect(grid.rowEdit.emit).toHaveBeenCalledWith(rowEditArgs); + expect(grid.rowEditDone.emit).toHaveBeenCalledWith(rowDoneArgs); + }); + + it('Emit rowAdd and rowAdded event with proper arguments', () => { + spyOn(grid.rowAdd, 'emit').and.callThrough(); + spyOn(grid.rowAdded, 'emit').and.callThrough(); + // start add row + grid.beginAddRowById(null); + fix.detectChanges(); + + const generatedId = grid.getRowByIndex(0).cells[0].value; + + // enter edit mode of cell + const prodCell = GridFunctions.getRowCells(fix, 0)[2]; + UIInteractions.simulateDoubleClickAndSelectEvent(prodCell); + fix.detectChanges(); + + // input value + const cellInput = (prodCell as any).nativeElement.querySelector('[igxinput]'); + UIInteractions.setInputElementValue(cellInput, "NewValue"); + fix.detectChanges(); + + // Done button click + const doneButtonElement = GridFunctions.getRowEditingDoneButton(fix); + doneButtonElement.click(); + fix.detectChanges(); + + // check event args + const rowAddArgs: IRowDataCancelableEventArgs = { + cancel: false, + oldValue: { ProductID: generatedId}, + rowData: { ProductID: generatedId, ProductName: "NewValue"}, + data: { ProductID: generatedId, ProductName: "NewValue"}, + rowID: generatedId, + primaryKey: generatedId, + rowKey: generatedId, + valid: true, + event: jasmine.anything() as any, + owner: grid, + isAddRow: true + } + + const rowAddedArgs: IRowDataEventArgs = { + rowData: { ProductID: generatedId, ProductName: "NewValue"}, + data: { ProductID: generatedId, ProductName: "NewValue"}, + primaryKey: generatedId, + rowKey: generatedId, + owner: grid + }; + expect(grid.rowAdd.emit).toHaveBeenCalledWith(rowAddArgs); + expect(grid.rowAdded.emit).toHaveBeenCalledWith(rowAddedArgs); + }); + + it('Should display the banner below the edited row if it is not the last one', () => { + cell.editMode = true; + + const editRow = grid.gridAPI.get_row_by_index(2).nativeElement; //cellElem.row.nativeElement; + const banner = GridFunctions.getRowEditingOverlay(fix); + + fix.detectChanges(); + + const bannerTop = banner.getBoundingClientRect().top; + const editRowBottom = editRow.getBoundingClientRect().bottom; + + // The banner appears below the row + expect(bannerTop).toBeGreaterThanOrEqual(editRowBottom); + + // No much space between the row and the banner + expect(bannerTop - editRowBottom).toBeLessThan(2); + }); + + it('Should display the banner after the edited row if it is the last one, but has room underneath it', () => { + const lastItemIndex = 6; + cell = grid.getCellByColumn(lastItemIndex, 'ProductName'); + cellElem = grid.gridAPI.get_cell_by_index(lastItemIndex, 'ProductName'); + cell.editMode = true; + + const editRow = grid.gridAPI.get_row_by_index(lastItemIndex).nativeElement; + const banner = GridFunctions.getRowEditingOverlay(fix); + fix.detectChanges(); + + const bannerTop = banner.getBoundingClientRect().top; + const editRowBottom = editRow.getBoundingClientRect().bottom; + + // The banner appears below the row + expect(bannerTop).toBeGreaterThanOrEqual(editRowBottom); + + // No much space between the row and the banner + expect(bannerTop - editRowBottom).toBeLessThan(2); + }); + + it('Should display the banner above the edited row if it is the last one', () => { + cell = grid.getCellByColumn(grid.data.length - 1, 'ProductName'); + cellElem = grid.gridAPI.get_cell_by_index(grid.data.length - 1, 'ProductName'); + cell.editMode = true; + + const editRow = grid.gridAPI.get_cell_by_index(grid.data.length - 1, 'ProductName').nativeElement; + const banner = GridFunctions.getRowEditingOverlay(fix); + fix.detectChanges(); + + const bannerBottom = banner.getBoundingClientRect().bottom; + const editRowTop = editRow.getBoundingClientRect().top; + + // The banner appears above the row + expect(bannerBottom).toBeLessThanOrEqual(editRowTop); + + // No much space between the row and the banner + expect(editRowTop - bannerBottom).toBeLessThan(2); + }); + + it(`Should preserve updated value inside the cell when it enters edit mode again`, () => { + cell.editMode = true; + cell.update('IG'); + + fix.detectChanges(); + // TODO cell + cell.editMode = false; + cell.editMode = true; + + expect(cell.value).toEqual('IG'); + }); + + it(`Should correctly get column.editable for grid with no transactions`, () => { + grid.columnList.forEach(c => { + c.editable = true; + }); + + const primaryKeyColumn = grid.columnList.find(c => c.field === grid.primaryKey); + const nonPrimaryKeyColumn = grid.columnList.find(c => c.field !== grid.primaryKey); + expect(primaryKeyColumn).toBeDefined(); + expect(nonPrimaryKeyColumn).toBeDefined(); + + grid.rowEditable = false; + expect(primaryKeyColumn.editable).toBeTruthy(); + expect(nonPrimaryKeyColumn.editable).toBeTruthy(); + + grid.rowEditable = true; + expect(primaryKeyColumn.editable).toBeFalsy(); + expect(nonPrimaryKeyColumn.editable).toBeTruthy(); + }); + + it('Should properly exit pending state when committing row edit w/o changes', () => { + const initialDataLength = grid.data.length; + UIInteractions.simulateClickAndSelectEvent(grid.gridAPI.get_cell_by_index(cell.row.index, cell.column.index)); + fix.detectChanges(); + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fix.detectChanges(); + expect(cell.editMode).toBeTruthy(); + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fix.detectChanges(); + expect(cell.editMode).toBeFalsy(); + grid.deleteRow(2); + + fix.detectChanges(); + expect(grid.data.length).toEqual(initialDataLength - 1); + }); + + it('Overlay position: Open overlay for top row', () => { + grid.height = '300px'; + fix.detectChanges(); + + + let row: HTMLElement = grid.gridAPI.get_row_by_index(0).nativeElement; + cell = grid.getCellByColumn(0, 'ProductName'); + cell.editMode = true; + + + let overlayContent = GridFunctions.getRowEditingOverlay(fix); + expect(row.getBoundingClientRect().bottom === overlayContent.getBoundingClientRect().top).toBeTruthy(); + cell.editMode = false; + + + row = grid.gridAPI.get_row_by_index(2).nativeElement; + cell = grid.getCellByColumn(2, 'ProductName'); + cell.editMode = true; + + overlayContent = GridFunctions.getRowEditingOverlay(fix); + expect(row.getBoundingClientRect().bottom === overlayContent.getBoundingClientRect().top).toBeTruthy(); + cell.editMode = false; + + + row = grid.gridAPI.get_row_by_index(3).nativeElement; + cell = grid.getCellByColumn(3, 'ProductName'); + cell.editMode = true; + + overlayContent = GridFunctions.getRowEditingOverlay(fix); + expect(row.getBoundingClientRect().top === overlayContent.getBoundingClientRect().bottom).toBeTruthy(); + cell.editMode = false; + + + row = grid.gridAPI.get_row_by_index(0).nativeElement; + cell = grid.getCellByColumn(0, 'ProductName'); + cellElem = grid.gridAPI.get_cell_by_index(0, 'ProductName'); + cell.editMode = true; + + overlayContent = GridFunctions.getRowEditingOverlay(fix); + expect(row.getBoundingClientRect().bottom === overlayContent.getBoundingClientRect().top).toBeTruthy(); + cell.editMode = false; + }); + + it('should end row editing when clearing or applying advanced filter', () => { + fix.detectChanges(); + const row = grid.gridAPI.get_row_by_index(2); + + // Enter row edit mode + UIInteractions.simulateDoubleClickAndSelectEvent(cellElem); + fix.detectChanges(); + expect(row.inEditMode).toBe(true); + + // Open Advanced Filtering dialog. + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + // Clear the filters. + GridFunctions.clickAdvancedFilteringClearFilterButton(fix); + fix.detectChanges(); + + expect(row.inEditMode).toBe(false); + + // Close the dialog. + GridFunctions.clickAdvancedFilteringCancelButton(fix); + fix.detectChanges(); + + // Enter row edit mode + UIInteractions.simulateDoubleClickAndSelectEvent(cellElem); + fix.detectChanges(); + expect(row.inEditMode).toBe(true); + + // Open Advanced Filtering dialog. + grid.openAdvancedFilteringDialog(); + fix.detectChanges(); + + // Apply the filters. + GridFunctions.clickAdvancedFilteringApplyButton(fix); + fix.detectChanges(); + + expect(row.inEditMode).toBe(false); + }); + }); + + describe('Navigation - Keyboard', () => { + let fix; + let grid: IgxGridComponent; + let gridContent: DebugElement; + let targetCell: any; + let editedCell: CellType; + + beforeEach(() => { + fix = TestBed.createComponent(IgxGridWithEditingAndFeaturesComponent); + fix.detectChanges(); + + grid = fix.componentInstance.grid; + setupGridScrollDetection(fix, grid); + gridContent = GridFunctions.getGridContent(fix); + }); + + afterEach(() => { + clearGridSubs(); + }); + + it(`Should jump from first editable columns to overlay buttons`, () => { + targetCell = grid.gridAPI.get_cell_by_index(0, 'Downloads'); + UIInteractions.simulateDoubleClickAndSelectEvent(targetCell); + fix.detectChanges(); + + // TO button + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent, false, true); + fix.detectChanges(); + + expect(targetCell.editMode).toBeFalsy(); + + const doneButtonElement = GridFunctions.getRowEditingDoneButton(fix); + expect(document.activeElement).toEqual(doneButtonElement); + + // FROM button to last cell + grid.rowEditTabs.last.handleTab(UIInteractions.getKeyboardEvent('keydown', 'tab')); + + fix.detectChanges(); + + expect(targetCell.editMode).toBeTruthy(); + }); + + it(`Should jump from last editable columns to overlay buttons`, (async () => { + grid.tbody.nativeElement.focus(); + fix.detectChanges(); + + GridFunctions.scrollLeft(grid, 800); + fix.detectChanges(); + await wait(DEBOUNCETIME); + + targetCell = grid.gridAPI.get_cell_by_index(0, 'Test'); + UIInteractions.simulateClickAndSelectEvent(targetCell); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('f2', gridContent); + fix.detectChanges(); + + // TO button + expect(targetCell.editMode).toBeTruthy(); + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + fix.detectChanges(); + + expect(targetCell.editMode).toBeFalsy(); + const cancelButtonElementDebug = GridFunctions.getRowEditingCancelDebugElement(fix); + expect(document.activeElement).toEqual(cancelButtonElementDebug.nativeElement); + + // FROM button to last cell + grid.rowEditTabs.first.handleTab(UIInteractions.getKeyboardEvent('keydown', 'tab', false, true)); + + fix.detectChanges(); + await wait(DEBOUNCETIME * 2); + fix.detectChanges(); + expect(targetCell.editMode).toBeTruthy(); + })); + + it(`Should scroll editable column into view when navigating from buttons`, (async () => { + let cell = grid.getCellByColumn(0, 'Downloads'); + let cellElem = grid.gridAPI.get_cell_by_index(0, 'Downloads'); + // let cellDebug; + UIInteractions.simulateDoubleClickAndSelectEvent(cellElem); + fix.detectChanges(); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent, false, true); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + + // go to 'Cancel' + const doneButtonElement = GridFunctions.getRowEditingDoneButton(fix); + expect(document.activeElement).toEqual(doneButtonElement); + grid.rowEditTabs.last.handleTab(UIInteractions.getKeyboardEvent('keydown', 'tab', false, true)); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + // go to LAST editable cell + + grid.rowEditTabs.first.handleTab(UIInteractions.getKeyboardEvent('keydown', 'tab', false, true)); + fix.detectChanges(); + await wait(DEBOUNCETIME * 2); + fix.detectChanges(); + + cell = grid.getCellByColumn(0, 'Test'); + cellElem = grid.gridAPI.get_cell_by_index(0, 'Test'); + expect(cell.editMode).toBeTruthy(); + expect(grid.headerContainer.getScroll().scrollLeft).toBeGreaterThan(0); + + // move to Cancel + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + // Focus cancel + const cancelButtonElement = GridFunctions.getRowEditingCancelButton(fix); + cancelButtonElement.focus(); + await wait(DEBOUNCETIME); + grid.rowEditTabs.first.handleTab(UIInteractions.getKeyboardEvent('keydown', 'tab')); + await wait(); + fix.detectChanges(); + + // move to FIRST editable cell + grid.rowEditTabs.last.handleTab(UIInteractions.getKeyboardEvent('keydown', 'tab')); + fix.detectChanges(); + await wait(DEBOUNCETIME * 2); + fix.detectChanges(); + + cell = grid.getCellByColumn(0, 'Downloads'); + cellElem = grid.gridAPI.get_cell_by_index(0, 'Downloads'); + expect(cell.editMode).toBeTruthy(); + expect(grid.headerContainer.getScroll().scrollLeft).toEqual(0); + })); + + it(`Should skip non-editable columns`, () => { + const cell = grid.gridAPI.get_cell_by_index(0, 'ID'); + const cellReleaseDate = grid.gridAPI.get_cell_by_index(0, 'ReleaseDate'); + targetCell = grid.gridAPI.get_cell_by_index(0, 'Downloads'); + + UIInteractions.simulateDoubleClickAndSelectEvent(targetCell); + fix.detectChanges(); + + expect(targetCell.editMode).toBeTruthy(); + // Move forwards + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + fix.detectChanges(); + fix.detectChanges(); + + expect(targetCell.editMode).toBeFalsy(); + expect(cell.editMode).toBeFalsy(); + expect(cellReleaseDate.editMode).toBeTruthy(); + + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent, false, true); + + fix.detectChanges(); + expect(targetCell.editMode).toBeTruthy(); + expect(cell.editMode).toBeFalsy(); + expect(cellReleaseDate.editMode).toBeFalsy(); + }); + + it(`Should skip non-editable columns when column pinning is enabled`, () => { + fix.componentInstance.pinnedFlag = true; + fix.detectChanges(); + + targetCell = grid.gridAPI.get_cell_by_index(0, 'Downloads'); + UIInteractions.simulateDoubleClickAndSelectEvent(targetCell); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + fix.detectChanges(); + + // EXPECT focused cell to be 'Released' + editedCell = grid.gridAPI.get_cell_by_index(0, 'Released'); + expect(editedCell.editMode).toBeTruthy(); + // from pinned to unpinned + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + fix.detectChanges(); + // EXPECT focused cell to be 'ReleaseDate' + editedCell = grid.gridAPI.get_cell_by_index(0, 'ReleaseDate'); + expect(editedCell.editMode).toBeTruthy(); + // from unpinned to pinned + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent, false, true); + fix.detectChanges(); + // EXPECT edited cell to be 'Released' + editedCell = grid.gridAPI.get_cell_by_index(0, 'Released'); + expect(editedCell.editMode).toBeTruthy(); + }); + + it(`Should skip non-editable columns when column hiding is enabled`, () => { + fix.componentInstance.hiddenFlag = true; + fix.detectChanges(); + + targetCell = grid.gridAPI.get_cell_by_index(0, 'Downloads'); + UIInteractions.simulateDoubleClickAndSelectEvent(targetCell); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + fix.detectChanges(); + + // EXPECT focused cell to be 'Released' + editedCell = grid.gridAPI.get_cell_by_index(0, 'Released'); + expect(editedCell.editMode).toBeTruthy(); + + // jump over 1 hidden, editable + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + fix.detectChanges(); + // EXPECT focused cell to be 'Items' + editedCell = grid.gridAPI.get_cell_by_index(0, 'Items'); + expect(editedCell.editMode).toBeTruthy(); + // jump over 1 hidden, editable + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent, false, true, false); + fix.detectChanges(); + // EXPECT edited cell to be 'Released' + editedCell = grid.gridAPI.get_cell_by_index(0, 'Released'); + expect(editedCell.editMode).toBeTruthy(); + // jump over 3 hidden, both editable and not + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent, false, true, false); + fix.detectChanges(); + // EXPECT edited cell to be 'Downloads' + editedCell = grid.gridAPI.get_cell_by_index(0, 'Downloads'); + expect(editedCell.editMode).toBeTruthy(); + }); + + it(`Should skip non-editable columns when column pinning & hiding is enabled`, () => { + fix.componentInstance.hiddenFlag = true; + fix.detectChanges(); + fix.componentInstance.pinnedFlag = true; + fix.detectChanges(); + // jump over 1 hidden, pinned + + targetCell = grid.gridAPI.get_cell_by_index(0, 'Downloads'); + UIInteractions.simulateDoubleClickAndSelectEvent(targetCell); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + fix.detectChanges(); + + // EXPECT focused cell to be 'Released' + editedCell = grid.gridAPI.get_cell_by_index(0, 'Released'); + expect(editedCell.editMode).toBeTruthy(); + // jump over 3 hidden, both editable and not + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + fix.detectChanges(); + // EXPECT focused cell to be 'Items' + editedCell = grid.gridAPI.get_cell_by_index(0, 'Items'); + expect(editedCell.editMode).toBeTruthy(); + // jump back to pinned + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent, false, true); + fix.detectChanges(); + + // EXPECT edited cell to be 'Released' + editedCell = grid.gridAPI.get_cell_by_index(0, 'Released'); + expect(editedCell.editMode).toBeTruthy(); + // jump over 1 hidden, pinned + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent, false, true); + fix.detectChanges(); + // EXPECT edited cell to be 'Downloads' + editedCell = grid.gridAPI.get_cell_by_index(0, 'Downloads'); + expect(editedCell.editMode).toBeTruthy(); + }); + + it(`Should skip non-editable columns when column grouping is enabled`, (async () => { + fix.componentInstance.columnGroupingFlag = true; + fix.detectChanges(); + + targetCell = grid.gridAPI.get_cell_by_index(0, 'ReleaseDate'); + UIInteractions.simulateDoubleClickAndSelectEvent(targetCell); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + fix.detectChanges(); + + // Should disregards the Igx-Column-Group component + // EXPECT focused cell to be 'Released' + editedCell = grid.gridAPI.get_cell_by_index(0, 'Released'); + expect(editedCell.editMode).toBeTruthy(); + // Go forwards, jump over Category and group end + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + fix.detectChanges(); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + // EXPECT focused cell to be 'Items' + editedCell = grid.gridAPI.get_cell_by_index(0, 'Items'); + expect(editedCell.editMode).toBeTruthy(); + // Go backwards, jump over group end and return to 'Released' + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent, false, true); + fix.detectChanges(); + await wait(DEBOUNCETIME); + fix.detectChanges(); + // EXPECT focused cell to be 'Released' + editedCell = grid.gridAPI.get_cell_by_index(0, 'Released'); + expect(editedCell.editMode).toBeTruthy(); + + // Go to release date + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent, false, true); + fix.detectChanges(); + + editedCell = grid.gridAPI.get_cell_by_index(0, 'ReleaseDate'); + expect(editedCell.editMode).toBeTruthy(); + })); + + it(`Should skip non-editable columns when all column features are enabled`, () => { + fix.componentInstance.hiddenFlag = true; + fix.componentInstance.pinnedFlag = true; + fix.componentInstance.columnGroupingFlag = true; + fix.detectChanges(); + + targetCell = grid.gridAPI.get_cell_by_index(0, 'Downloads'); + UIInteractions.simulateDoubleClickAndSelectEvent(targetCell); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + fix.detectChanges(); + // Move from Downloads over hidden to Released in Column Group + editedCell = grid.gridAPI.get_cell_by_index(0, 'Released'); + expect(editedCell.editMode).toBeTruthy(); + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + fix.detectChanges(); + + // Move from pinned 'Released' (in Column Group) to unpinned 'Items' + editedCell = grid.gridAPI.get_cell_by_index(0, 'Items'); + expect(editedCell.editMode).toBeTruthy(); + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent, false, true); + fix.detectChanges(); + + // Move back to pinned 'Released' (in Column Group) + editedCell = grid.gridAPI.get_cell_by_index(0, 'Released'); + expect(editedCell.editMode).toBeTruthy(); + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent, false, true); + fix.detectChanges(); + // Move back to pinned 'Downloads' + editedCell = grid.gridAPI.get_cell_by_index(0, 'Downloads'); + expect(editedCell.editMode).toBeTruthy(); + }); + + it(`Should update row changes when focus overlay buttons on tabbing`, (async () => { + grid.getColumnByName("ID").hidden = true; + grid.tbody.nativeElement.focus(); + fix.detectChanges(); + + targetCell = grid.gridAPI.get_cell_by_index(0, 'Downloads'); + fix.detectChanges(); + + UIInteractions.simulateClickAndSelectEvent(targetCell); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('Enter', gridContent); + fix.detectChanges(); + + // change first editable cell value + targetCell.editValue = '500'; + fix.detectChanges(); + + // go to Done + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent, false, true); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + expect(GridFunctions.getRowEditingBannerText(fix)).toBe('You have 1 changes in this row and 1 hidden columns'); + + // go to last editable cell + grid.rowEditTabs.first.handleTab(UIInteractions.getKeyboardEvent('keydown', 'tab', false, true)); + fix.detectChanges(); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + const currentEditCell = grid.gridAPI.get_cell_by_index(0, 'Test'); + expect(currentEditCell.editMode).toBeTruthy(); + expect(grid.headerContainer.getScroll().scrollLeft).toBeGreaterThan(0); + + // change last editable cell value + currentEditCell.editValue = 'No test'; + fix.detectChanges(); + + // move to Cancel + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + fix.detectChanges(); + + expect(GridFunctions.getRowEditingBannerText(fix)).toBe('You have 2 changes in this row and 1 hidden columns'); + })); + + it(`Should show no row changes when changing the cell value to the original one`, () => { + targetCell = grid.gridAPI.get_cell_by_index(0, 'Downloads'); + fix.detectChanges(); + + const originalValue = targetCell.value; + + UIInteractions.simulateDoubleClickAndSelectEvent(targetCell); + fix.detectChanges(); + + // change first editable cell value + targetCell.editValue = '500'; + fix.detectChanges(); + + // go to next cell + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + fix.detectChanges(); + + expect(GridFunctions.getRowEditingBannerText(fix)).toBe('You have 1 changes in this row and 0 hidden columns'); + + // return to first editable cell + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent, false, true); + fix.detectChanges(); + + // change cell value to the original one + targetCell.editValue = originalValue; + fix.detectChanges(); + + // go to next cell + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + fix.detectChanges(); + + expect(GridFunctions.getRowEditingBannerText(fix)).toBe('You have 0 changes in this row and 0 hidden columns'); + }); + + it(`Should focus last edited cell after click on editable buttons`, (async () => { + targetCell = grid.gridAPI.get_cell_by_index(0, 'Downloads'); + UIInteractions.simulateDoubleClickAndSelectEvent(targetCell); + fix.detectChanges(); + + // Scroll the grid + GridFunctions.scrollLeft(grid, 750); + + // Focus done button + const doneButtonElement = GridFunctions.getRowEditingDoneButton(fix); + const doneButtonElementDebug = GridFunctions.getRowEditingDoneDebugElement(fix); + doneButtonElement.focus(); + fix.detectChanges(); + + expect(document.activeElement).toEqual(doneButtonElement); + doneButtonElementDebug.triggerEventHandler('click', new Event('click')); + + fix.detectChanges(); + + expect(targetCell.active).toBeTruthy(); + })); + + it(`Should not detectChanges & emit Grid.keyDown (navigation service) while editing`, () => { + targetCell = grid.gridAPI.get_cell_by_index(0, 'Downloads'); + + fix.detectChanges(); + + const keyDonwSpy = spyOn(grid.gridKeydown, 'emit'); + + UIInteractions.simulateDoubleClickAndSelectEvent(targetCell); + fix.detectChanges(); + + const detectChangesSpy = spyOn(grid.cdr, 'detectChanges').and.callThrough(); + const cellElem = fix.debugElement.query(By.css(CELL_CLASS)); + const input = cellElem.query(By.css('input')); + + // change first editable cell value + UIInteractions.triggerKeyDownEvtUponElem('1', input.nativeElement, true); + UIInteractions.setInputElementValue(input, '1'); + + UIInteractions.triggerKeyDownEvtUponElem('2', input.nativeElement, true); + UIInteractions.setInputElementValue(input, '12'); + fix.detectChanges(); + + expect(targetCell.editValue).toBe(12); + + expect(keyDonwSpy).not.toHaveBeenCalled(); + expect(detectChangesSpy).toHaveBeenCalledTimes(0); + }); + }); + + describe('Exit row editing', () => { + let fix; + let grid: IgxGridComponent; + let cell: CellType; + let cellElem: CellType; + beforeEach(() => { + fix = TestBed.createComponent(IgxGridRowEditingComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + cell = grid.getCellByColumn(0, 'ProductName'); + cellElem = grid.gridAPI.get_cell_by_index(0, 'ProductName'); + }); + + it(`Should call correct methods on clicking DONE and CANCEL buttons in row edit overlay`, () => { + const mockEvent = new MouseEvent('click'); + spyOn(grid.gridAPI.crudService, 'endEdit'); + + // put cell in edit mode + cell.editMode = true; + fix.detectChanges(); + + // ged CANCEL button and click it + const cancelButtonElement = GridFunctions.getRowEditingCancelButton(fix); + cancelButtonElement.dispatchEvent(mockEvent); + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalled(); + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalledWith(false, mockEvent); + + cell.editMode = true; + fix.detectChanges(); + + // ged DONE button and click it + const doneButtonElement = GridFunctions.getRowEditingDoneButton(fix); + doneButtonElement.dispatchEvent(mockEvent); + fix.detectChanges(); + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalled(); + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalledWith(true, mockEvent); + }); + + it(`Should exit row editing AND do not commit when press Escape key on Done and Cancel buttons`, () => { + const mockEvent = new KeyboardEvent('keydown', { key: 'escape' }); + spyOn(grid.gridAPI.crudService, 'endEdit').and.callThrough(); + + // put cell in edit mode + cell.editMode = true; + fix.detectChanges(); + + // press Escape on Done button + const doneButtonElement = GridFunctions.getRowEditingDoneButton(fix); + // const doneButtonElementDebug = GridFunctions.getRowEditingDoneDebugElement(fix); + doneButtonElement.dispatchEvent(mockEvent); + fix.detectChanges(); + + const overlayContent = GridFunctions.getRowEditingOverlay(fix); + expect(cell.editMode).toEqual(false); + expect(overlayContent).toBeFalsy(); + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalled(); + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalledWith(false, mockEvent); + + UIInteractions.simulateDoubleClickAndSelectEvent(cellElem); + fix.detectChanges(); + // press Escape on Cancel button + const cancelButtonElement = GridFunctions.getRowEditingDoneButton(fix); + cancelButtonElement.dispatchEvent(mockEvent); + fix.detectChanges(); + + expect(cell.editMode).toEqual(false); + expect(overlayContent).toBeFalsy(); + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalled(); + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalledWith(false, mockEvent); + }); + + it(`Should exit row editing AND COMMIT on add row`, () => { + spyOn(grid.gridAPI.crudService, 'endEdit').and.callThrough(); + + // put cell in edit mode + cell.editMode = true; + + grid.addRow({ ProductID: 99, ProductName: 'ADDED', InStock: true, UnitsInStock: 20000, OrderDate: new Date('2018-03-01') }); + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalled(); + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalledWith(true); + expect(cell.editMode).toBeFalsy(); + }); + + it(`Should exit row editing AND COMMIT on delete row`, () => { + spyOn(grid.gridAPI.crudService, 'endEdit').and.callThrough(); + + // put cell in edit mode + cell.editMode = true; + fix.detectChanges(); + grid.deleteRow(grid.gridAPI.get_row_by_index(2).key); + fix.detectChanges(); + + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalled(); + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalledWith(true); + expect(cell.editMode).toBeFalsy(); + }); + + it(`Should exit row editing AND DISCARD on filter`, () => { + spyOn(grid.gridAPI.crudService, 'exitCellEdit').and.callThrough(); + spyOn(grid.gridAPI.crudService, 'endEdit').and.callThrough(); + + // put cell in edit mode + // const cell = grid.getCellByColumn(0, 'ProductName'); + cell.editMode = true; + + grid.filter('ProductName', 'a', IgxStringFilteringOperand.instance().condition('contains'), true); + fix.detectChanges(); + + expect(grid.gridAPI.crudService.exitCellEdit).toHaveBeenCalled(); + expect(grid.gridAPI.crudService.exitCellEdit).toHaveBeenCalledWith(undefined); + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalled(); + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalledWith(false); + expect(cell.editMode).toBeFalsy(); + }); + + it(`Should exit row editing AND DISCARD on sort`, () => { + spyOn(grid.gridAPI.crudService, 'endEdit').and.callThrough(); + spyOn(grid.crudService, 'exitCellEdit').and.callThrough(); + + // put cell in edit mode + cell.editMode = true; + + + cell.update('123'); + grid.sort({ + fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }); + + fix.detectChanges(); + + expect(cell.editMode).toBe(false); + expect(cell.value).toBe('Aniseed Syrup'); // SORT does not submit + + expect(grid.crudService.exitCellEdit).toHaveBeenCalled(); + expect(grid.crudService.endEdit).toHaveBeenCalled(); + expect(grid.crudService.endEdit).toHaveBeenCalledWith(false); + }); + + it(`Should exit row editing AND COMMIT on grid size change`, async () => { + setElementSize(grid.nativeElement, ɵSize.Large); + fix.detectChanges(); + + cell.editMode = true; + fix.detectChanges(); + + let overlayContent = GridFunctions.getRowEditingOverlay(fix); + expect(overlayContent).toBeTruthy(); + expect(cell.editMode).toBeTruthy(); + + setElementSize(grid.nativeElement, ɵSize.Medium); + fix.detectChanges(); + await wait(16); // needed because of the throttleTime on the resize observer + fix.detectChanges(); + + overlayContent = GridFunctions.getRowEditingOverlay(fix); + expect(overlayContent).toBeFalsy(); + expect(cell.editMode).toBeFalsy(); + }); + + it(`Should NOT exit row editing on click on non-editable cell in same row`, () => { + spyOn(grid.gridAPI.crudService, 'endEdit').and.callThrough(); + + // put cell in edit mode + cell.editMode = true; + + fix.detectChanges(); + + let overlayContent = GridFunctions.getRowEditingOverlay(fix); + expect(overlayContent).toBeTruthy(); + expect(cell.editMode).toBeTruthy(); + + const nonEditableCell = grid.gridAPI.get_cell_by_index(0, 'ProductID'); + UIInteractions.simulateClickAndSelectEvent(nonEditableCell); + fix.detectChanges(); + + expect(grid.gridAPI.crudService.endEdit).not.toHaveBeenCalled(); + overlayContent = GridFunctions.getRowEditingOverlay(fix); + expect(overlayContent).toBeTruthy(); + expect(cell.editMode).toBeFalsy(); + expect(nonEditableCell.editMode).toBeFalsy(); + }); + + it(`Should exit row editing AND COMMIT on click on non-editable cell in other row`, () => { + spyOn(grid.gridAPI.crudService, 'endEdit').and.callThrough(); + + // put cell in edit mode + cell.editMode = true; + + fix.detectChanges(); + + let overlayContent = GridFunctions.getRowEditingOverlay(fix); + expect(overlayContent).toBeTruthy(); + const nonEditableCell = grid.gridAPI.get_cell_by_index(2, 'ProductID'); + UIInteractions.simulateClickAndSelectEvent(nonEditableCell); + fix.detectChanges(); + + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalled(); + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalledWith(true, (jasmine.anything() as any)); + overlayContent = GridFunctions.getRowEditingOverlay(fix); + expect(overlayContent).toBeFalsy(); + expect(cell.editMode).toBeFalsy(); + }); + + it(`Should exit row editing AND COMMIT on click on editable cell in other row`, () => { + spyOn(grid.gridAPI.crudService, 'endEdit').and.callThrough(); + + // put cell in edit mode + cell.editMode = true; + fix.detectChanges(); + + + let overlayContent = GridFunctions.getRowEditingOverlay(fix); + expect(overlayContent).toBeTruthy(); + + const otherEditableCell = grid.gridAPI.get_cell_by_index(2, 'ProductName'); + UIInteractions.simulateClickAndSelectEvent(otherEditableCell); + fix.detectChanges(); + + overlayContent = GridFunctions.getRowEditingOverlay(fix); + expect(overlayContent).toBeTruthy(); + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalled(); + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalledWith(true, jasmine.anything() as any); + expect(cell.editMode).toBeFalsy(); + expect(otherEditableCell.editMode).toBeTruthy(); + }); + + it(`Should exit row editing AND DISCARD on ESC KEYDOWN`, () => { + const targetCell = grid.gridAPI.get_cell_by_index(0, 'ProductName') as any; + UIInteractions.simulateDoubleClickAndSelectEvent(targetCell); + fix.detectChanges(); + + spyOn(grid.gridAPI.crudService, 'exitCellEdit').and.callThrough(); + + UIInteractions.triggerKeyDownEvtUponElem('escape', grid.tbody.nativeElement, true); + fix.detectChanges(); + + expect(grid.gridAPI.crudService.exitCellEdit).toHaveBeenCalled(); + expect(cell.editMode).toBeFalsy(); + }); + + it(`Should exit edit mode when edited row is being deleted`, () => { + const row = grid.gridAPI.get_row_by_index(0); + spyOn(grid.gridAPI.crudService, 'endEdit').and.callThrough(); + cell.editMode = true; + fix.detectChanges(); + expect(grid.rowEditingOverlay.collapsed).toBeFalsy(); + row.delete(); + fix.detectChanges(); + expect(grid.rowEditingOverlay.collapsed).toBeTruthy(); + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalledTimes(1); + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalledWith(true); + }); + }); + + describe('Integration', () => { + let fix; + let grid: IgxGridComponent; + let cell: CellType; + let cellElem: CellType; + + beforeEach(() => { + fix = TestBed.createComponent(IgxGridRowEditingComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + cell = grid.getCellByColumn(0, 'ProductName'); + cellElem = grid.gridAPI.get_cell_by_index(0, 'ProductName'); + + }); + + it(`Paging: Should preserve the changes after page navigation`, () => { + fix.componentInstance.paging = true; + fix.detectChanges(); + grid.paginator.perPage = 7; + fix.detectChanges(); + + const cacheValue = cell.value; + let rowElement = grid.gridAPI.get_row_by_index(0).nativeElement; + expect(rowElement.classList).not.toContain(ROW_EDITED_CLASS); + + cell.editMode = true; + + cell.update('IG'); + cell.editMode = false; + fix.detectChanges(); + + + expect(rowElement.classList).toContain(ROW_EDITED_CLASS); + + // Next page button click + GridFunctions.navigateToNextPage(grid.nativeElement); + fix.detectChanges(); + expect(grid.paginator.page).toEqual(1); + expect(cell.value).toBe('Tofu'); + rowElement = grid.gridAPI.get_row_by_index(0).nativeElement; + expect(rowElement.classList).not.toContain(ROW_EDITED_CLASS); + + // Previous page button click + GridFunctions.navigateToPrevPage(grid.nativeElement); + fix.detectChanges(); + expect(cell.value).toBe(cacheValue); + rowElement = grid.gridAPI.get_row_by_index(0).nativeElement; + expect(rowElement.classList).not.toContain(ROW_EDITED_CLASS); + }); + + it(`Paging: Should discard changes when changing page while editing`, () => { + fix.componentInstance.paging = true; + fix.detectChanges(); + grid.paginator.perPage = 7; + fix.detectChanges(); + + const cacheValeue = cell.value; + cell.editMode = true; + cell.update('IG'); + + // Do not exit edit mode + + // Next page button click + GridFunctions.navigateToNextPage(grid.nativeElement); + + fix.detectChanges(); + expect(grid.paginator.page).toEqual(1); + expect(cell.value).toBe('Tofu'); + + // Previous page button click + GridFunctions.navigateToPrevPage(grid.nativeElement); + + fix.detectChanges(); + + expect(cell.editMode).toBeFalsy(); + expect(cell.value).toBe(cacheValeue); + }); + + it(`Paging: Should exit edit mode when changing the page size while editing`, () => { + fix.componentInstance.paging = true; + fix.detectChanges(); + grid.paginator.page + grid.paginator.perPage = 7; + fix.detectChanges(); + + const select = GridFunctions.getGridPageSelectElement(fix); + + cell.editMode = true; + // cell.update('IG'); + // cell.update exits edit mode of the CELL + // Do not exit edit mode + + fix.detectChanges(); + + expect(GridFunctions.getRowEditingOverlay(fix)).toBeTruthy(); + expect(GridFunctions.getRowEditingBanner(fix)).toBeTruthy(); + // Change page size + select.click(); + fix.detectChanges(); + const selectList = fix.debugElement.query(By.css('.igx-drop-down__list-scroll')); + selectList.children[2].nativeElement.click(); + + fix.detectChanges(); + + expect(cell.editMode).toEqual(false); + expect(GridFunctions.getRowEditingOverlay(fix)).toBeFalsy(); + // Element is still there in the grid template, but is hidden + expect(GridFunctions.getRowEditingBanner(fix).parentElement.attributes['aria-hidden']).toBeTruthy(); + }); + + it(`Paging: Should exit edit mode when changing the page size resulting in the edited cell going to the next page`, + () => { + fix.componentInstance.paging = true; + fix.detectChanges(); + grid.paginator.perPage = 7; + fix.detectChanges(); + + const select = GridFunctions.getGridPageSelectElement(fix); + + cell.editMode = true; + + grid.gridAPI.crudService.cell.editValue = 'IG'; + // cell.update('IG'); + // Do not exit edit mode + fix.detectChanges(); + + expect(GridFunctions.getRowEditingOverlay(fix)).toBeTruthy(); + expect(GridFunctions.getRowEditingBanner(fix)).toBeTruthy(); + + // Change page size + select.click(); + fix.detectChanges(); + const selectList = fix.debugElement.query(By.css('.igx-drop-down__list-scroll')); + selectList.children[0].nativeElement.click(); + + fix.detectChanges(); + + // Next page button click + GridFunctions.navigateToNextPage(grid.nativeElement); + + fix.detectChanges(); + + expect(grid.paginator.page).toEqual(1); + cell = grid.getCellByColumn(1, 'ProductName'); + cellElem = grid.gridAPI.get_cell_by_index(1, 'ProductName'); + + fix.detectChanges(); + + expect(cell.editMode).toEqual(false); + + expect(GridFunctions.getRowEditingOverlay(fix)).toBeFalsy(); + // banner is still present in grid template, just not visible + expect(GridFunctions.getRowEditingBanner(fix)).toBeTruthy(); + }); + + it(`Filtering: Should exit edit mode on filter applied`, () => { + spyOn(grid.gridAPI.crudService, 'endEdit').and.callThrough(); + + cell.editMode = true; + // flush(); + + // search if the targeted column contains the keyword, ignoring case + grid.filter('ProductName', 'bob', IgxStringFilteringOperand.instance().condition('contains'), false); + // flush(); + fix.detectChanges(); + + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalled(); + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalledWith(false); + }); + + it(`Filtering: Should NOT include the new value in the results when filtering`, () => { + const newValue = 'My Awesome Product'; + cell.editMode = true; + + cell.update(newValue); + fix.detectChanges(); + + // loop over the grid's data to see if any cell contains the new value + const editedCell = grid.data.filter(el => el.ProductName === newValue); + + // a cell with the updated value is NOT found (filter does NOT submit) + expect(editedCell.length).toEqual(0); + }); + + it(`Filtering: Should preserve the cell's data if it has been modified while being filtered out`, () => { + // Steps: + // 1) Filter by any value + // 2) Edit any of the filtered rows so that the row is removed from the filtered columns + // 3) Remove filtering + // 4) Verify the update is preserved + + const targetColumnName = 'ProductName'; + const keyword = 'ch'; + const newValue = 'My Awesome Product'; + + // search if the targeted column contains the keyword, ignoring case + grid.filter(targetColumnName, keyword, IgxStringFilteringOperand.instance().condition('contains'), true); + + + fix.detectChanges(); + cell.update(newValue); + + + // remove filtering + grid.clearFilter(); + + fix.detectChanges(); + expect(cell.value).toEqual(newValue); + }); + + it(`GroupBy: Should exit edit mode when Grouping`, () => { + spyOn(grid.gridAPI.crudService, 'exitCellEdit').and.callThrough(); + + cell.editMode = true; + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }); + + expect(grid.gridAPI.crudService.exitCellEdit).toHaveBeenCalled(); + }); + + it(`Sorting: Should NOT include the new value in the results when sorting`, () => { + const newValue = 'Don Juan De Marco'; + cell.editMode = true; + + cell.update(newValue); + + grid.sort({ + fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }); + + fix.detectChanges(); + + // loop over the grid's data to see if any cell contains the new value + const editedCell = grid.data.filter(el => el.ProductName === newValue); + + // a cell with the updated value is found + // sorting DOES NOT submit + expect(editedCell.length).toEqual(0); + }); + + it(`Sorting: Editing a sorted row`, () => { + // Sort any column + grid.sort({ + fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }); + + fix.detectChanges(); + + // Edit any of the sorted rows so that the row position is changed + cell.editMode = true; + + // Cell will always be first + cell.update('AAAAAAAAAAA Don Juan De Marco'); + cell.editMode = false; + + fix.detectChanges(); + + cell = grid.getCellByColumn(0, 'ProductName'); + cellElem = grid.gridAPI.get_cell_by_index(0, 'ProductName'); + expect(cell.value).toBe('AAAAAAAAAAA Don Juan De Marco'); + }); + + it(`Summaries: Should update summaries after row editing completes`, fakeAsync(() => { + grid.enableSummaries('OrderDate'); + tick(16); + fix.detectChanges(); + + let summaryRow = fix.debugElement.query(By.css(SUMMARY_ROW)); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['10', 'May 17, 1990', 'Dec 25, 2025']); + + cell = grid.getCellByColumn(0, 'OrderDate'); + cellElem = grid.gridAPI.get_cell_by_index(0, 'OrderDate'); + UIInteractions.simulateDoubleClickAndSelectEvent(cellElem); + tick(16); + // Cell will always be first + const editTemplate = fix.debugElement.query(By.css('input')); + UIInteractions.clickAndSendInputElementValue(editTemplate, '01/01/1901', fix); + tick(16); + fix.detectChanges(); + GridFunctions.simulateGridContentKeydown(fix, 'tab', false, true); + tick(16); + fix.detectChanges(); + + cell = grid.getCellByColumn(0, 'ProductName'); + cellElem = grid.gridAPI.get_cell_by_index(0, 'ProductName'); + expect(cell.editMode).toBeTruthy(); + summaryRow = fix.debugElement.query(By.css(SUMMARY_ROW)); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['10', 'May 17, 1990', 'Dec 25, 2025']); + GridFunctions.simulateGridContentKeydown(fix, 'enter'); + tick(16); + fix.detectChanges(); + + summaryRow = fix.debugElement.query(By.css(SUMMARY_ROW)); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['10', 'Jan 1, 1901', 'Dec 25, 2025']); + })); + + it(`Moving: Should exit edit mode when moving a column`, fakeAsync(() => { + grid.moving = true; + const column = grid.columnList.filter(c => c.field === 'ProductName')[0]; + const targetColumn = grid.columnList.filter(c => c.field === 'ProductID')[0]; + + fix.detectChanges(); + + spyOn(grid.gridAPI.crudService, 'endEdit').and.callThrough(); + + // put cell in edit mode + cell.editMode = true; + + + expect(cell.editMode).toEqual(true); + expect(grid.rowEditingOverlay.collapsed).toEqual(false); + grid.moveColumn(column, targetColumn); + tick(); + fix.detectChanges(); + + expect(cell.editMode).toBeFalsy(); + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalled(); + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalledWith(false); + expect(grid.rowEditingOverlay.collapsed).toEqual(true); + })); + + it(`Pinning: Should exit edit mode when pinning/unpinning a column`, () => { + spyOn(grid.gridAPI.crudService, 'endEdit').and.callThrough(); + + // put cell in edit mode + cell.editMode = true; + + grid.pinColumn('ProductName'); + + fix.detectChanges(); + + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalled(); + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalledWith(false); + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalledTimes(1); + expect(cell.editMode).toBeFalsy(); + + // put cell in edit mode + cell = grid.getCellByColumn(2, 'ProductName'); + cellElem = grid.gridAPI.get_cell_by_index(2, 'ProductName'); + cell.editMode = true; + + + grid.unpinColumn('ProductName'); + + fix.detectChanges(); + + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalled(); + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalledWith(false); + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalledTimes(2); + expect(cell.editMode).toBeFalsy(); + }); + + it(`Resizing: Should keep edit mode when resizing a column`, fakeAsync(() => { + spyOn(grid.gridAPI.crudService, 'endEdit').and.callThrough(); + + // put cell in edit mode + cell.editMode = true; + + const column = grid.columnList.filter(c => c.field === 'ProductName')[0]; + column.resizable = true; + fix.detectChanges(); + + const headers: DebugElement[] = fix.debugElement.queryAll(By.css(COLUMN_HEADER_GROUP_CLASS)); + const headerResArea = headers[2].children[1].nativeElement; + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 500, 0); + tick(200); + const resizer = fix.debugElement.queryAll(By.css('.igx-grid-th__resize-line'))[0].nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 550, 0); + UIInteractions.simulateMouseEvent('mouseup', resizer, 550, 0); + fix.detectChanges(); + + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalledTimes(0); + expect(cell.editMode).toBeTruthy(); + })); + + it(`Hiding: Should exit edit mode when hiding a column`, () => { + cell.editMode = true; + + fix.detectChanges(); + expect(grid.gridAPI.crudService.cell).toBeTruthy(); // check if there is cell in edit mode + spyOn(grid.gridAPI.crudService, 'exitCellEdit').and.callThrough(); + + cell.column.hidden = true; + + fix.detectChanges(); + + expect(grid.gridAPI.crudService.exitCellEdit).toHaveBeenCalled(); + expect(grid.rowEditingOverlay.collapsed).toBeTruthy(); + }); + + it(`Hiding: Should show correct value when showing the column again`, waitForAsync(async () => { + fix.componentInstance.showToolbar = true; + fix.detectChanges(); + await fix.whenStable(); + fix.detectChanges(); + + const targetCbText = 'Product Name'; + cell.editMode = true; + + cell.update('Tea'); + + // hide column + GridFunctions.getColumnHidingButton(fix).click(); + fix.detectChanges(); + const columnChooser = GridFunctions.getColumnHidingElement(fix); + + GridFunctions.clickColumnChooserItem(columnChooser, targetCbText); + fix.detectChanges(); + + // show column + GridFunctions.clickColumnChooserItem(columnChooser, targetCbText); + fix.detectChanges(); + + GridFunctions.getColumnHidingButton(fix).click(); + + + expect(cell.value).toEqual('Chai'); + })); + }); + + describe('Events', () => { + let fix; + let grid: IgxGridComponent; + let cell: CellType; + let cellElem: CellType; + let initialRow: RowType; + let initialData: any; + const $destroyer = new Subject(); + + beforeEach(() => { + fix = TestBed.createComponent(IgxGridRowEditingComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + cell = grid.getCellByColumn(0, 'ProductName'); + cellElem = grid.gridAPI.get_cell_by_index(0, 'ProductName'); + initialRow = grid.getRowByIndex(0); + initialData = { ...initialRow.data }; + fix.componentInstance.pinnedFlag = true; + fix.detectChanges(); + }); + + afterEach(fakeAsync(() => { + $destroyer.next(true); + })); + + it(`Should strictly follow the right execution sequence of editing events`, () => { + spyOn(grid.rowEditEnter, 'emit').and.callThrough(); + spyOn(grid.cellEditEnter, 'emit').and.callThrough(); + spyOn(grid.cellEdit, 'emit').and.callThrough(); + spyOn(grid.cellEditDone, 'emit').and.callThrough(); + spyOn(grid.cellEditExit, 'emit').and.callThrough(); + spyOn(grid.rowEdit, 'emit').and.callThrough(); + spyOn(grid.rowEditDone, 'emit').and.callThrough(); + spyOn(grid.rowEditExit, 'emit').and.callThrough(); + + grid.rowEditEnter.pipe(takeUntil($destroyer)).subscribe(() => { + expect(grid.rowEditEnter.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEditEnter.emit).not.toHaveBeenCalled(); + }); + + grid.cellEditEnter.pipe(takeUntil($destroyer)).subscribe(() => { + expect(grid.rowEditEnter.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEditEnter.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEdit.emit).not.toHaveBeenCalled(); + }); + + grid.cellEdit.pipe(takeUntil($destroyer)).subscribe(() => { + expect(grid.cellEditEnter.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEdit.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEditDone.emit).not.toHaveBeenCalled(); + }); + + grid.cellEditDone.pipe(takeUntil($destroyer)).subscribe(() => { + expect(grid.cellEdit.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEditDone.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEditExit.emit).not.toHaveBeenCalled(); + }); + + grid.cellEditExit.pipe(takeUntil($destroyer)).subscribe(() => { + expect(grid.cellEditDone.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEditExit.emit).toHaveBeenCalledTimes(1); + expect(grid.rowEdit.emit).not.toHaveBeenCalled(); + }); + + grid.rowEdit.pipe(takeUntil($destroyer)).subscribe(() => { + expect(grid.cellEditExit.emit).toHaveBeenCalledTimes(1); + expect(grid.rowEdit.emit).toHaveBeenCalledTimes(1); + expect(grid.rowEditDone.emit).not.toHaveBeenCalled(); + }); + + grid.rowEditDone.pipe(takeUntil($destroyer)).subscribe(() => { + expect(grid.rowEdit.emit).toHaveBeenCalledTimes(1); + expect(grid.rowEditDone.emit).toHaveBeenCalledTimes(1); + expect(grid.rowEditExit.emit).not.toHaveBeenCalled(); + }); + + grid.rowEditExit.pipe(takeUntil($destroyer)).subscribe(() => { + expect(grid.rowEditDone.emit).toHaveBeenCalledTimes(1); + expect(grid.rowEditExit.emit).toHaveBeenCalledTimes(1); + }); + + grid.gridAPI.crudService.enterEditMode(cell); + fix.detectChanges(); + + cell.editValue = 'new Value'; + grid.endRowEditTabStop(true, null); + fix.detectChanges(); + }); + + it('Should not enter edit mode when rowEditEnter is canceled', () => { + grid.rowEditEnter.pipe(takeUntil($destroyer)).subscribe((evt) => { + evt.cancel = true; + }); + + grid.gridAPI.crudService.enterEditMode(cell); + fix.detectChanges(); + + expect(!!grid.gridAPI.crudService.rowInEditMode).toEqual(false); + expect(grid.gridAPI.crudService.cellInEditMode).toEqual(false); + }); + + it('Should not enter cell edit when cellEditEnter is canceled but row edit should be entered', () => { + let canceled = true; + grid.cellEditEnter.pipe(takeUntil($destroyer)).subscribe((evt) => { + evt.cancel = canceled; + }); + + grid.gridAPI.crudService.enterEditMode(cell); + fix.detectChanges(); + + expect(!!grid.gridAPI.crudService.rowInEditMode).toEqual(true); + expect(grid.gridAPI.crudService.cellInEditMode).toEqual(false); + + grid.gridAPI.crudService.endEditMode(); + fix.detectChanges(); + + canceled = false; + grid.gridAPI.crudService.enterEditMode(cell); + fix.detectChanges(); + + expect(!!grid.gridAPI.crudService.rowInEditMode).toEqual(true); + expect(grid.gridAPI.crudService.cellInEditMode).toEqual(true); + }); + + it('When cellEdit is canceled the new value of the cell should never be commited and editing should be closed', () => { + grid.cellEdit.pipe(takeUntil($destroyer)).subscribe((evt) => { + evt.cancel = true; + }); + + grid.gridAPI.crudService.enterEditMode(cell); + fix.detectChanges(); + + const cellValue = cell.value; + cell.editValue = 'new value'; + + grid.endRowEditTabStop(true); + fix.detectChanges(); + + expect(!!grid.gridAPI.crudService.rowInEditMode).toEqual(true); + expect(grid.gridAPI.crudService.cellInEditMode).toEqual(true); + expect(cell.value).toEqual(cellValue); + }); + + it('When rowEdit is cancelled the new row data should never be commited', () => { + grid.rowEdit.pipe(takeUntil($destroyer)).subscribe((evt) => { + evt.cancel = true; + }); + + cell.editMode = true; + + fix.detectChanges(); + + cell.editValue = 'New Name'; + fix.detectChanges(); + // On button click + const doneButtonElement = GridFunctions.getRowEditingDoneButton(fix); + doneButtonElement.click(); + fix.detectChanges(); + + const rowData = Object.assign({}, cell.row.data, { ProductName: 'New Name' }); + expect(!!grid.gridAPI.crudService.rowInEditMode).toEqual(true); + expect(grid.gridAPI.crudService.cellInEditMode).toEqual(false); + expect(cell.row.data.ProductName).toEqual('New Name'); + expect(grid.dataView[0]).not.toEqual(rowData); + }); + + it(`Should properly emit 'rowEdit' event - Button Click`, () => { + spyOn(grid.rowEditExit, 'emit').and.callThrough(); + spyOn(grid.rowEdit, 'emit').and.callThrough(); + + cell.editMode = true; + + fix.detectChanges(); + + cell.editValue = 'New Name'; + fix.detectChanges(); + // On button click + const doneButtonElement = GridFunctions.getRowEditingDoneButton(fix); + doneButtonElement.click(); + + fix.detectChanges(); + + expect(grid.rowEditExit.emit).toHaveBeenCalled(); + expect(grid.rowEdit.emit).toHaveBeenCalled(); + // TODO: rowEdit should emit updated rowData - issue #7304 + expect(grid.rowEdit.emit).toHaveBeenCalledWith({ + primaryKey: 1, + rowID: 1, + rowKey: 1, + rowData: initialData, + newValue: Object.assign({}, initialData, { ProductName: 'New Name' }), + oldValue: initialData, + cancel: false, + owner: grid, + isAddRow: false, + valid: true, + event: jasmine.anything() as any + }); + }); + + it(`Should be able to cancel 'rowEdit' event `, () => { + spyOn(grid.rowEdit, 'emit').and.callThrough(); + + grid.rowEdit.subscribe((e: IGridEditEventArgs) => { + e.cancel = true; + }); + const gridContent = GridFunctions.getGridContent(fix); + const targetCell = grid.gridAPI.get_cell_by_index(0, 'ProductName') as any; + UIInteractions.simulateDoubleClickAndSelectEvent(targetCell); + fix.detectChanges(); + + let overlayContent = GridFunctions.getRowEditingOverlay(fix); + expect(cell.editMode).toEqual(true); + expect(overlayContent).toBeTruthy(); + cell.editValue = 'New Name'; + fix.detectChanges(); + + // On button click + const doneButtonElement = GridFunctions.getRowEditingDoneButton(fix); + doneButtonElement.click(); + + fix.detectChanges(); + + overlayContent = GridFunctions.getRowEditingOverlay(fix); + expect(overlayContent).toBeTruthy(); + expect(cell.editMode).toEqual(false); + expect(grid.rowEdit.emit).toHaveBeenCalledTimes(1); + expect(grid.rowEdit.emit).toHaveBeenCalledWith({ + primaryKey: 1, + rowID: 1, + rowKey: 1, + rowData: initialData, + newValue: Object.assign({}, initialData, { ProductName: 'New Name' }), + oldValue: initialData, + cancel: true, + owner: grid, + isAddRow: false, + event: jasmine.anything() as any, + valid: true + }); + + // Enter cell edit mode again + UIInteractions.simulatePointerOverElementEvent('pointerdown', targetCell.nativeElement); + + fix.detectChanges(); + + // Press enter on cell + UIInteractions.triggerEventHandlerKeyDown('Enter', gridContent); + fix.detectChanges(); + + overlayContent = GridFunctions.getRowEditingOverlay(fix); + expect(overlayContent).toBeTruthy(); + expect(cell.editMode).toEqual(false); + expect(grid.rowEdit.emit).toHaveBeenCalledTimes(2); + expect(grid.rowEdit.emit).toHaveBeenCalledWith({ + primaryKey: 1, + rowID: 1, + rowKey: 1, + rowData: initialData, + newValue: Object.assign({}, initialData, { ProductName: 'New Name' }), + oldValue: initialData, + cancel: true, + owner: grid, + isAddRow: false, + event: jasmine.anything() as any, + valid: true + }); + }); + + it(`Should properly emit 'rowEditExit' event - Button Click`, () => { + spyOn(grid.rowEditExit, 'emit').and.callThrough(); + spyOn(grid.rowEdit, 'emit').and.callThrough(); + + cell.editMode = true; + + fix.detectChanges(); + + cell.editValue = 'New Name'; + fix.detectChanges(); + // On button click + const cancelButtonElement = GridFunctions.getRowEditingCancelButton(fix); + cancelButtonElement.click(); + + fix.detectChanges(); + + expect(grid.rowEdit.emit).not.toHaveBeenCalled(); + expect(grid.rowEditExit.emit).toHaveBeenCalled(); + expect(grid.rowEditExit.emit).toHaveBeenCalledWith({ + primaryKey: 1, + rowID: 1, + rowKey: 1, + rowData: initialData, + newValue: initialData, + oldValue: initialData, + owner: grid, + isAddRow: false, + event: jasmine.anything() as any, + valid: true + }); + }); + + it(`Should properly emit 'rowEditEnter' event`, () => { + + spyOn(grid.rowEditEnter, 'emit').and.callThrough(); + + grid.tbody.nativeElement.focus(); + fix.detectChanges(); + + const targetCell = grid.gridAPI.get_cell_by_index(0, 'ProductName') as any; + UIInteractions.simulateClickAndSelectEvent(targetCell); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('enter', grid.tbody.nativeElement, true); + fix.detectChanges(); + + expect(grid.rowEditEnter.emit).toHaveBeenCalled(); + expect(grid.rowEditEnter.emit).toHaveBeenCalledWith({ + primaryKey: 1, + rowID: 1, + rowKey: 1, + rowData: initialData, + oldValue: initialData, + cancel: false, + owner: grid, + isAddRow: false, + event: jasmine.anything() as any, + valid: true + }); + }); + + it(`Should be able to cancel 'rowEditEnter' event `, () => { + spyOn(grid.rowEditEnter, 'emit').and.callThrough(); + + grid.rowEditEnter.subscribe((e: IGridEditEventArgs) => { + e.cancel = true; + }); + + const targetCell = grid.gridAPI.get_cell_by_index(0, 'ProductName') as any; + UIInteractions.simulateClickAndSelectEvent(targetCell); + fix.detectChanges(); + + targetCell.nativeElement.dispatchEvent(new Event('dblclick')); + fix.detectChanges(); + + expect(cell.editMode).toEqual(false); + expect(!!grid.gridAPI.crudService.rowInEditMode).toEqual(false); + expect(GridFunctions.getRowEditingOverlay(fix)).toBeFalsy(); + + expect(grid.rowEditEnter.emit).toHaveBeenCalledTimes(1); + expect(grid.rowEditEnter.emit).toHaveBeenCalledWith({ + primaryKey: 1, + rowID: 1, + rowKey: 1, + rowData: initialData, + oldValue: initialData, + cancel: true, + owner: grid, + isAddRow: false, + event: jasmine.anything() as any, + valid: true + }); + }); + + it(`Should properly emit 'rowEditExit' event - Filtering`, () => { + spyOn(grid.rowEditExit, 'emit').and.callThrough(); + + const gridContent = GridFunctions.getGridContent(fix); + const targetCell = grid.gridAPI.get_cell_by_index(0, 'ProductName') as any; + UIInteractions.simulateDoubleClickAndSelectEvent(targetCell); + fix.detectChanges(); + + const expectedRes = 'New Name'; + const cellInput = (cellElem as any).nativeElement.querySelector('[igxinput]'); + UIInteractions.setInputElementValue(cellInput, expectedRes); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + + fix.detectChanges(); + // On filter + grid.filter('ProductID', 0, IgxNumberFilteringOperand.instance().condition('greaterThan'), true); + fix.detectChanges(); + + expect(grid.rowEditExit.emit).toHaveBeenCalledTimes(1); + expect(grid.rowEditExit.emit).toHaveBeenCalledWith({ + primaryKey: 1, + rowID: 1, + rowKey: 1, + rowData: initialData, + newValue: initialData, + oldValue: initialData, + owner: grid, + isAddRow: false, + event: undefined, + valid: true + }); + }); + + it(`Should properly emit 'rowEditExit' event - Sorting`, () => { + + spyOn(grid.rowEditExit, 'emit').and.callThrough(); + spyOn(grid.rowEdit, 'emit').and.callThrough(); + + cell.editMode = true; + + fix.detectChanges(); + + cell.editValue = 'New Name'; + fix.detectChanges(); + // On sort + grid.sort({ + fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }); + fix.detectChanges(); + + expect(grid.rowEditExit.emit).toHaveBeenCalledTimes(1); + expect(grid.rowEditExit.emit).toHaveBeenCalledWith({ + primaryKey: 1, + rowID: 1, + rowKey: 1, + rowData: initialData, + newValue: initialData, + oldValue: initialData, + owner: grid, + isAddRow: false, + event: undefined, + valid: true + }); + }); + + it(`Should properly emit 'cellEdit' event `, () => { + + spyOn(grid.rowEdit, 'emit').and.callThrough(); + spyOn(grid.cellEdit, 'emit').and.callThrough(); + // TODO: cellEdit should emit updated rowData - issue #7304 + const cellArgs: IGridEditEventArgs = { + cellID: cell.id, + primaryKey: cell.row.key, + rowID: cell.row.key, + rowKey: cell.row.key, + rowData: cell.row.data, + oldValue: 'Chai', + newValue: 'New Value', + cancel: false, + column: cell.column, + owner: grid, + event: jasmine.anything() as any, + valid: true + }; + + UIInteractions.simulateDoubleClickAndSelectEvent(cellElem); + fix.detectChanges(); + + expect(cell.editMode).toBe(true); + const editTemplate = fix.debugElement.query(By.css('input')); + UIInteractions.clickAndSendInputElementValue(editTemplate, 'New Value'); + fix.detectChanges(); + + // Click on cell in different row + cellElem = grid.gridAPI.get_cell_by_index(2, 'ProductName'); + UIInteractions.simulateClickAndSelectEvent(cellElem); + fix.detectChanges(); + + expect(grid.rowEdit.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEdit.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEdit.emit).toHaveBeenCalledWith(cellArgs); + }); + }); + + describe('Column editable property', () => { + let fix; + let grid: IgxGridComponent; + let gridContent: DebugElement; + + it('Default column editable value is correct, when row editing is enabled', () => { + fix = TestBed.createComponent(IgxGridRowEditingWithoutEditableColumnsComponent); + fix.detectChanges(); + + grid = fix.componentInstance.grid; + + let columns: IgxColumnComponent[] = grid.columnList.toArray(); + expect(columns[0].editable).toBeTruthy(); // column.editable not set + expect(columns[1].editable).toBeFalsy(); // column.editable not set. Primary column + expect(columns[2].editable).toBeTruthy(); // column.editable set to true + expect(columns[3].editable).toBeTruthy(); // column.editable not set + expect(columns[4].editable).toBeFalsy(); // column.editable set to false + + grid.rowEditable = false; + columns = grid.columnList.toArray(); + expect(columns[0].editable).toBeFalsy(); // column.editable not set + expect(columns[1].editable).toBeFalsy(); // column.editable not set. Primary column + expect(columns[2].editable).toBeTruthy(); // column.editable set to true + expect(columns[3].editable).toBeFalsy(); // column.editable not set + expect(columns[4].editable).toBeFalsy(); // column.editable set to false + + grid.rowEditable = true; + columns = grid.columnList.toArray(); + expect(columns[0].editable).toBeTruthy(); // column.editable not set + expect(columns[1].editable).toBeFalsy(); // column.editable not set. Primary column + expect(columns[2].editable).toBeTruthy(); // column.editable set to true + expect(columns[3].editable).toBeTruthy(); // column.editable not set + expect(columns[4].editable).toBeFalsy(); // column.editable set to false + }); + + it('should scroll into view not visible cell when in row edit and move from pinned to unpinned column', (async () => { + fix = TestBed.createComponent(VirtualGridComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + gridContent = GridFunctions.getGridContent(fix); + setupGridScrollDetection(fix, grid); + fix.detectChanges(); + + fix.componentInstance.columns = fix.componentInstance.generateCols(100); + fix.componentInstance.data = fix.componentInstance.generateData(100); + + fix.detectChanges(); + await wait(DEBOUNCETIME); + + grid.primaryKey = '0'; + grid.rowEditable = true; + grid.columns.every(c => c.editable = true); + + grid.getColumnByName('2').pinned = true; + grid.getColumnByName('3').pinned = true; + grid.getColumnByName('3').editable = false; + grid.getColumnByName('0').editable = false; + + await wait(DEBOUNCETIME); + fix.detectChanges(); + + grid.navigateTo(0, 99); + + await wait(DEBOUNCETIME); + fix.detectChanges(); + + const cellElem = grid.gridAPI.get_cell_by_index(0, '2'); + UIInteractions.simulateDoubleClickAndSelectEvent(cellElem); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + expect(grid.gridAPI.crudService.cell.column.header).toBe('2'); + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + + await wait(DEBOUNCETIME); + fix.detectChanges(); + + expect(grid.gridAPI.crudService.cell.column.header).toBe('1'); + clearGridSubs(); + })); + }); + + describe('Custom overlay', () => { + + it('Custom overlay', () => { + const fix = TestBed.createComponent(IgxGridCustomOverlayComponent); + fix.detectChanges(); + const gridContent = GridFunctions.getGridContent(fix); + + const grid = fix.componentInstance.grid; + const cellElem = grid.gridAPI.get_cell_by_index(0, 'ProductName'); + spyOn(grid.gridAPI.crudService, 'endEdit').and.callThrough(); + UIInteractions.simulateDoubleClickAndSelectEvent(cellElem); + fix.detectChanges(); + + expect(parseInt(GridFunctions.getRowEditingBannerText(fix), 10)).toEqual(0); + fix.componentInstance.cellInEditMode.editValue = 'Spiro'; + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + fix.detectChanges(); + + expect(parseInt(GridFunctions.getRowEditingBannerText(fix), 10)).toEqual(1); + + fix.componentInstance.buttons.last.element.nativeElement.click(); + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalled(); + expect(grid.gridAPI.crudService.endEdit).toHaveBeenCalledTimes(1); + + fix.detectChanges(); + expect(cellElem.active).toBeTruthy(); + expect(grid.nativeElement.contains(document.activeElement)).toBeTrue(); + }); + + it('Empty template', () => { + const fix = TestBed.createComponent(IgxGridEmptyRowEditTemplateComponent); + fix.detectChanges(); + const gridContent = GridFunctions.getGridContent(fix); + + + const grid = fix.componentInstance.grid; + let cell = grid.getCellByColumn(0, 'ProductName'); + const cellElem = grid.gridAPI.get_cell_by_index(0, 'ProductName'); + UIInteractions.simulateDoubleClickAndSelectEvent(cellElem); + + fix.detectChanges(); + + + cell.editValue = 'Spiro'; + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + + fix.detectChanges(); + + fix.detectChanges(); + + + expect(cell.editMode).toBe(false); + cell = grid.getCellByColumn(0, 'ReorderLevel'); + expect(cell.editMode).toBe(true); + + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent, false, true); + fix.detectChanges(); + + fix.detectChanges(); + + + expect(cell.editMode).toBe(false); + cell = grid.getCellByColumn(0, 'ProductName'); + expect(cell.editMode).toBe(true); + }); + + it('should allow setting custom templates via Input.', () => { + const fix = TestBed.createComponent(IgxGridCustomRowEditTemplateComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + + grid.rowAddTextTemplate = fix.componentInstance.addText; + grid.rowEditTextTemplate = fix.componentInstance.editText; + grid.rowEditActionsTemplate = fix.componentInstance.editActions; + fix.detectChanges(); + + // enter edit mode + const cellElem = grid.gridAPI.get_cell_by_index(0, 'ProductName'); + UIInteractions.simulateDoubleClickAndSelectEvent(cellElem); + fix.detectChanges(); + + expect(GridFunctions.getRowEditingBannerText(fix)).toBe('CUSTOM EDIT TEXT'); + const bannerRow = GridFunctions.getRowEditingBannerRow(fix); + expect(bannerRow.textContent.trim()).toBe('CUSTOM EDIT ACTIONS'); + + grid.endEdit(); + + grid.beginAddRowByIndex(0); + fix.detectChanges(); + expect(GridFunctions.getRowEditingBannerText(fix)).toBe('CUSTOM ADD TEXT'); + }); + }); + + describe('Transaction', () => { + let fix; + let grid; + let cell: CellType; + let cellElem: any; + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(IgxGridRowEditingTransactionComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + cell = grid.getCellByColumn(0, 'ProductName'); + cellElem = grid.gridAPI.get_cell_by_index(0, 'ProductName'); + })); + + it('cellEditDone, rowEditDone should emit the committed/new rowData', () => { + const gridContent = GridFunctions.getGridContent(fix); + const row = grid.gridAPI.get_row_by_index(0); + const newCellValue = 'Aaaaa'; + const updatedRowData = Object.assign({}, row.data, { ProductName: newCellValue }); + + spyOn(grid.cellEditDone, 'emit').and.callThrough(); + const rowDoneSpy = spyOn(grid.rowEditDone, 'emit').and.callThrough(); + UIInteractions.simulateDoubleClickAndSelectEvent(cellElem); + fix.detectChanges(); + + const cellInput = (cellElem as any).nativeElement.querySelector('[igxinput]'); + UIInteractions.setInputElementValue(cellInput, newCellValue); + fix.detectChanges(); + + const cellDoneArgs: IGridEditDoneEventArgs = { + primaryKey: cell.row.key, + rowID: cell.row.key, + rowKey: cell.row.key, + cellID: cell.id, + rowData: updatedRowData, // with rowEditable&Transactions - IgxGridRowEditingTransactionComponent + oldValue: cell.value, + newValue: newCellValue, + column: cell.column, + owner: grid, + event: jasmine.anything() as any, + valid: true + }; + + const rowDoneArgs: IGridEditDoneEventArgs = { + primaryKey: row.key, + rowID: row.key, + rowKey: row.key, + rowData: updatedRowData, // with rowEditable&Transactions - IgxGridRowEditingTransactionComponent + oldValue: row.data, + newValue: Object.assign({}, row.data, { ProductName: newCellValue }), + owner: grid, + isAddRow: row.addRowUI, + event: jasmine.anything() as any, + valid: true + }; + + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fix.detectChanges(); + + expect(grid.cellEditDone.emit).toHaveBeenCalledTimes(1); + expect(grid.rowEditDone.emit).toHaveBeenCalledTimes(1); + + expect(grid.cellEditDone.emit).toHaveBeenCalledWith(cellDoneArgs); + expect(grid.rowEditDone.emit).toHaveBeenCalledWith(rowDoneArgs); + const rowDoneSpyArgs = rowDoneSpy.calls.mostRecent().args[0] as IGridEditDoneEventArgs; + expect(rowDoneSpyArgs.rowData).toBe(rowDoneSpyArgs.newValue); + }); + + it('Should add correct class to the edited row', () => { + const row: HTMLElement = grid.gridAPI.get_row_by_index(0).nativeElement; + expect(row.classList).not.toContain(ROW_EDITED_CLASS); + + cell.editMode = true; + fix.detectChanges(); + + cell.editValue = 'IG'; + grid.gridAPI.crudService.endEdit(true); + fix.detectChanges(); + + expect(row.classList).toContain(ROW_EDITED_CLASS); + }); + + it(`Should correctly get column.editable for grid with transactions`, () => { + grid.columnList.forEach(c => { + c.editable = true; + }); + + const primaryKeyColumn = grid.columnList.find(c => c.field === grid.primaryKey); + const nonPrimaryKeyColumn = grid.columnList.find(c => c.field !== grid.primaryKey); + expect(primaryKeyColumn).toBeDefined(); + expect(nonPrimaryKeyColumn).toBeDefined(); + + grid.rowEditable = false; + expect(primaryKeyColumn.editable).toBeFalsy(); + expect(nonPrimaryKeyColumn.editable).toBeTruthy(); + + grid.rowEditable = true; + expect(primaryKeyColumn.editable).toBeFalsy(); + expect(nonPrimaryKeyColumn.editable).toBeTruthy(); + }); + + it(`Should not allow editing a deleted row`, () => { + grid.deleteRow(grid.gridAPI.get_row_by_index(0).key); + fix.detectChanges(); + + cell.editMode = true; + + fix.detectChanges(); + expect(cell.editMode).toBeFalsy(); + }); + + it(`Should exit row editing when clicking on a cell from a deleted row`, () => { + grid.deleteRow(1); + + fix.detectChanges(); + spyOn(grid.gridAPI.crudService, 'endRowTransaction').and.callThrough(); + + const firstCell = grid.gridAPI.get_cell_by_index(2, 'ProductName'); + UIInteractions.simulateDoubleClickAndSelectEvent(firstCell); + fix.detectChanges(); + expect(grid.gridAPI.crudService.endRowTransaction).toHaveBeenCalledTimes(0); + + const targetCell = grid.gridAPI.get_cell_by_index(0, 'ProductName') as any; + UIInteractions.simulateClickAndSelectEvent(targetCell); + fix.detectChanges(); + expect(grid.gridAPI.crudService.endRowTransaction).toHaveBeenCalledTimes(1); + expect(cell.selected).toBeTruthy(); + expect(firstCell.selected).toBeFalsy(); + }); + + it(`Should verify getRowByIndex API editing members`, () => { + const row = grid.getRowByIndex(0); + row.delete(); + fix.detectChanges(); + + // Check if row is deleted + expect(row.deleted).toBe(true); + spyOn(grid.gridAPI.crudService, 'endRowTransaction').and.callThrough(); + + const firstCell = grid.gridAPI.get_cell_by_index(2, 'ProductName'); + UIInteractions.simulateDoubleClickAndSelectEvent(firstCell); + fix.detectChanges(); + + const rowToUpdate = grid.getRowByIndex(2); + // Check if row is in edit mode + expect(rowToUpdate.inEditMode).toBe(true); + expect(grid.gridAPI.crudService.endRowTransaction).toHaveBeenCalledTimes(0); + + const targetCell = grid.gridAPI.get_cell_by_index(0, 'ProductName') as any; + UIInteractions.simulateClickAndSelectEvent(targetCell); + fix.detectChanges(); + expect(grid.gridAPI.crudService.endRowTransaction).toHaveBeenCalledTimes(1); + + expect(rowToUpdate.inEditMode).toBe(false); + + const newRow = { + ProductID: 123, + ProductName: 'DummyItem', + }; + + // Update with the visible row instance through get_row_by_index + grid.gridAPI.get_row_by_index(grid.getRowByIndex(2).index).update(newRow); + fix.detectChanges(); + expect(grid.getRowByIndex(2).data.ProductID).toEqual(123); + expect(grid.getRowByIndex(2).data.ProductName).toEqual('DummyItem'); + + const newRowUpdate = { + InStock: true, + UnitsInStock: 1, + ProductName: 'DummyItemNew', + }; + + // Update with the getRowByIndex API method + grid.getRowByIndex(3).update(newRowUpdate); + fix.detectChanges(); + expect(grid.getRowByIndex(3).data.InStock).toBe(true); + expect(grid.getRowByIndex(3).data.UnitsInStock).toEqual(1); + expect(grid.getRowByIndex(3).data.ProductName).toEqual('DummyItemNew'); + }); + + it(`Paging: Should not apply edited classes to the same row on a different page`, () => { + // This is not a valid scenario if the grid does not have transactions enabled + fix.componentInstance.paging = true; + fix.detectChanges(); + + const rowEl: HTMLElement = grid.gridAPI.get_row_by_index(0).nativeElement; + + expect(rowEl.classList).not.toContain(ROW_EDITED_CLASS); + + cell.editMode = true; + + cell.editValue = 'IG'; + + fix.detectChanges(); + grid.gridAPI.crudService.endEdit(true); + + fix.detectChanges(); + expect(rowEl.classList).toContain(ROW_EDITED_CLASS); + + // Next page button click + GridFunctions.navigateToNextPage(grid.nativeElement); + fix.detectChanges(); + expect(grid.paginator.page).toEqual(1); + expect(rowEl.classList).not.toContain(ROW_EDITED_CLASS); + }); + + it('Transaction Update, Delete, Add, Undo, Redo, Commit check transaction and grid state', () => { + const trans = grid.transactions; + spyOn(trans.onStateUpdate, 'emit').and.callThrough(); + let row = null; + let updateValue = 'Chaiiii'; + cell.editMode = true; + fix.detectChanges(); + cell.editValue = updateValue; + fix.detectChanges(); + expect(trans.onStateUpdate.emit).not.toHaveBeenCalled(); + let state = trans.getAggregatedChanges(false); + expect(state.length).toEqual(0); + + cell = grid.getCellByColumn(1, 'ProductName'); + cellElem = grid.gridAPI.get_cell_by_index(1, 'ProductName'); + updateValue = 'Sirop'; + cell.editMode = true; + fix.detectChanges(); + cell.editValue = updateValue; + fix.detectChanges(); + + // Called once because row edit ended on row 1; + expect(trans.onStateUpdate.emit).toHaveBeenCalledTimes(1); + state = trans.getAggregatedChanges(false); + expect(state.length).toEqual(1); + expect(state[0].type).toEqual(TransactionType.UPDATE); + expect(state[0].newValue['ProductName']).toEqual('Chaiiii'); + + grid.gridAPI.crudService.endEdit(true); + fix.detectChanges(); + state = trans.getAggregatedChanges(false); + expect(trans.onStateUpdate.emit).toHaveBeenCalled(); + expect(state.length).toEqual(2); + expect(state[0].type).toEqual(TransactionType.UPDATE); + expect(state[0].newValue['ProductName']).toEqual('Chaiiii'); + expect(state[1].type).toEqual(TransactionType.UPDATE); + expect(state[1].newValue['ProductName']).toEqual(updateValue); + grid.deleteRow(grid.gridAPI.get_row_by_index(2).key); + fix.detectChanges(); + + expect(trans.onStateUpdate.emit).toHaveBeenCalled(); + state = trans.getAggregatedChanges(false); + expect(state.length).toEqual(3); + expect(state[2].type).toEqual(TransactionType.DELETE); + expect(state[2].newValue).toBeNull(); + + trans.undo(); + fix.detectChanges(); + + expect(trans.onStateUpdate.emit).toHaveBeenCalled(); + state = trans.getAggregatedChanges(false); + expect(state.length).toEqual(2); + expect(state[1].type).toEqual(TransactionType.UPDATE); + expect(state[1].newValue['ProductName']).toEqual(updateValue); + row = grid.gridAPI.get_row_by_index(2).nativeElement; + expect(row.classList).not.toContain('igx -grid__tr--deleted'); + + trans.redo(); + fix.detectChanges(); + + expect(trans.onStateUpdate.emit).toHaveBeenCalled(); + state = trans.getAggregatedChanges(false); + expect(state.length).toEqual(3); + expect(state[2].type).toEqual(TransactionType.DELETE); + expect(state[2].newValue).toBeNull(); + expect(row.classList).toContain(ROW_DELETED_CLASS); + + trans.commit(grid.data); + fix.detectChanges(); + state = trans.getAggregatedChanges(false); + expect(state.length).toEqual(0); + expect(row.classList).not.toContain(ROW_DELETED_CLASS); + + cell = grid.getCellByColumn(0, 'ProductName'); + cellElem = grid.gridAPI.get_cell_by_index(0, 'ProductName'); + updateValue = 'Chaiwe'; + cell.editMode = true; + fix.detectChanges(); + cell.update(updateValue); + cell.editMode = false; + fix.detectChanges(); + trans.clear(); + fix.detectChanges(); + state = trans.getAggregatedChanges(false); + expect(state.length).toEqual(0); + expect((cellElem as any).nativeElement.classList).not.toContain(ROW_EDITED_CLASS); + }); + + it('Should allow to change value of a cell with initial value of 0', () => { + expect(cell.value).toBe('Chai'); + + cell.update('Awesome Tea'); + + fix.detectChanges(); + expect(cell.value).toBe('Awesome Tea'); + }); + + it('Should allow to change value of a cell with initial value of false', () => { + cell = grid.getCellByColumn(3, 'InStock'); + cellElem = grid.gridAPI.get_cell_by_index(3, 'InStock'); + expect(cell.value).toBeFalsy(); + + cell.update(true); + + fix.detectChanges(); + expect(cell.value).toBeTruthy(); + }); + + it('Should allow to change value of a cell with initial value of empty string', () => { + expect(cell.value).toBe('Chai'); + + cell.update(''); + + fix.detectChanges(); + expect(cell.value).toBe(''); + + cell.update('Updated value'); + + fix.detectChanges(); + expect(cell.value).toBe('Updated value'); + }); + + it(`Should not log a transaction when a cell's value does not change`, () => { + const initialState = grid.transactions.getAggregatedChanges(false); + expect(cell.value).toBe('Chai'); + + // Set to same value + cell.update('Chai'); + + fix.detectChanges(); + expect(cell.value).toBe('Chai'); + expect(grid.transactions.getAggregatedChanges(false)).toEqual(initialState); + + // Change value and check if it's logged + cell.update('Updated value'); + + fix.detectChanges(); + expect(cell.value).toBe('Updated value'); + const expectedTransaction: Transaction = { + id: 1, + newValue: { ProductName: 'Updated value' }, + type: TransactionType.UPDATE + }; + expect(grid.transactions.getAggregatedChanges(false)).toEqual([expectedTransaction]); + }); + + it(`Should not log a transaction when a cell's value does not change - Date`, () => { + let cellDate = grid.gridAPI.get_cell_by_index(0, 'OrderDate'); + const initialState = grid.transactions.getAggregatedChanges(false); + const gridContent = GridFunctions.getGridContent(fix); + + // Enter edit mode + UIInteractions.simulateDoubleClickAndSelectEvent(cellDate); + fix.detectChanges(); + // Exit edit mode without change + UIInteractions.triggerEventHandlerKeyDown('escape', gridContent); + + fix.detectChanges(); + cellDate = grid.gridAPI.get_cell_by_index(0, 'UnitsInStock'); + UIInteractions.simulateDoubleClickAndSelectEvent(cellDate); + fix.detectChanges(); + expect(grid.transactions.getAggregatedChanges(true)).toEqual(initialState); + GridFunctions.simulateGridContentKeydown(fix, 'Esc'); + + cellDate = grid.gridAPI.get_cell_by_index(0, 'OrderDate'); + const newValue = new Date('01/01/2000'); + cellDate.update(newValue); + + fix.detectChanges(); + + const expectedTransaction: Transaction = { + id: 1, + newValue: { OrderDate: newValue }, + type: TransactionType.UPDATE + }; + expect(grid.transactions.getAggregatedChanges(false)).toEqual([expectedTransaction]); + }); + + it('Should allow to change of a cell in added row in grid with transactions', () => { + const addRowData = { + ProductID: 99, + ProductName: 'Added product', + InStock: false, + UnitsInStock: 0, + OrderDate: new Date() + }; + grid.addRow(addRowData); + + fix.detectChanges(); + + cell = grid.getCellByColumn(10, 'ProductName'); + cellElem = grid.gridAPI.get_cell_by_index(10, 'ProductName'); + expect(cell.value).toBe(addRowData.ProductName); + + cell.update('Changed product'); + + fix.detectChanges(); + expect(cell.value).toBe('Changed product'); + }); + + it('Should properly mark cell/row as dirty if new value evaluates to `false`', () => { + const targetRow = grid.gridAPI.get_row_by_index(0); + let targetRowElement = targetRow.element.nativeElement; + let targetCellElement = targetRow.cells.toArray()[1].nativeElement; + expect(targetRowElement.classList).not.toContain(ROW_EDITED_CLASS, 'row contains edited class w/o edits'); + expect(targetCellElement.classList).not.toContain('igx-grid__td--edited', 'cell contains edited class w/o edits'); + + targetRow.cells.toArray()[1].update(''); + + fix.detectChanges(); + + targetRowElement = targetRow.element.nativeElement; + targetCellElement = targetRow.cells.toArray()[1].nativeElement; + expect(targetRowElement.classList).toContain(ROW_EDITED_CLASS, 'row does not contain edited class w/ edits'); + expect(targetCellElement.classList).toContain('igx-grid__td--edited', 'cell does not contain edited class w/ edits'); + }); + + it('Should change pages when the only item on the last page is a pending added row that gets deleted', () => { + expect(grid.data.length).toEqual(10); + fix.componentInstance.paging = true; + fix.detectChanges(); + grid.paginator.perPage = 5; + fix.detectChanges(); + + expect(grid.paginator.totalPages).toEqual(2); + grid.addRow({ + ProductID: 123, + ProductName: 'DummyItem', + InStock: true, + UnitsInStock: 1, + OrderDate: new Date() + }); + fix.detectChanges(); + + expect(grid.paginator.totalPages).toEqual(3); + grid.paginator.page = 2; + + fix.detectChanges(); + expect(grid.paginator.page).toEqual(2); + grid.deleteRowById(123); + + fix.detectChanges(); + // This is behaving incorrectly - if there is only 1 transaction and it is an ADD transaction on the last page + // Deleting the ADD transaction on the last page will trigger grid.paginator.page-- TWICE + expect(grid.paginator.page).toEqual(1); // Should be 1 + expect(grid.paginator.totalPages).toEqual(2); + }); + + it('Should change pages when committing deletes on the last page', () => { + expect(grid.data.length).toEqual(10); + fix.componentInstance.paging = true; + fix.detectChanges(); + grid.paginator.perPage = 5; + fix.detectChanges(); + + expect(grid.paginator.totalPages).toEqual(2); + grid.paginator.page = 1; + + fix.detectChanges(); + expect(grid.paginator.page).toEqual(1); + for (let i = 0; i < grid.data.length / 2; i++) { + grid.deleteRowById(grid.data.reverse()[i].ProductID); + } + fix.detectChanges(); + + expect(grid.paginator.page).toEqual(1); + grid.transactions.commit(grid.data); + fix.detectChanges(); + + expect(grid.paginator.page).toEqual(0); + expect(grid.paginator.totalPages).toEqual(1); + }); + + it('Should NOT change pages when deleting a row on the last page', () => { + fix.componentInstance.paging = true; + fix.detectChanges(); + grid.paginator.perPage = 5; + fix.detectChanges(); + + expect(grid.paginator.totalPages).toEqual(2); + expect(grid.data.length).toEqual(10); + grid.paginator.page = 1; + + fix.detectChanges(); + expect(grid.paginator.page).toEqual(1); + grid.deleteRowById(grid.data[grid.data.length - 1].ProductID); + fix.detectChanges(); + + expect(grid.paginator.page).toEqual(1); + expect(grid.paginator.totalPages).toEqual(2); + }); + + it('Should not log transaction when exit edit mode on row with state and with no changes', () => { + const trans = grid.transactions; + const updateValue = 'Chaiiii'; + cell.editMode = true; + + + cell.editValue = updateValue; + + fix.detectChanges(); + + grid.gridAPI.crudService.endEdit(true); + + fix.detectChanges(); + + expect(trans.getTransactionLog().length).toBe(1); + + cell.editMode = true; + + + cell.editValue = updateValue; + + fix.detectChanges(); + + grid.gridAPI.crudService.endEdit(true); + + fix.detectChanges(); + + // should not log new transaction as there is no change in the row's cells + expect(trans.getTransactionLog().length).toBe(1); + }); + }); + + describe('Row Editing - Grouping', () => { + let fix; + let grid: IgxGridComponent; + let cell: CellType; + let groupRows; + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(IgxGridWithEditingAndFeaturesComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + grid.getColumnByName('ProductName').editable = true; + fix.detectChanges(); + grid.groupBy({ + fieldName: 'Released', dir: SortingDirection.Desc, ignoreCase: false, + strategy: DefaultSortingStrategy.instance() + }); + fix.detectChanges(); + })); + + it('Hide row editing dialog with group collapsing/expanding', () => { + // fix.detectChanges(); + // grid = fix.componentInstance.grid; + // fix.detectChanges(); + + // fix.detectChanges(); + //add gridCell type for cell + cell = grid.getCellByColumn(1, 'ProductName'); + + expect(grid.gridAPI.crudService.cellInEditMode).toBeFalsy(); + + // set cell in second group in edit mode + cell.editMode = true; + fix.detectChanges(); + + expect(grid.gridAPI.crudService.cellInEditMode).toBeTruthy(); + groupRows = grid.groupsRowList.toArray(); + expect(groupRows[0].expanded).toBeTruthy(); + + // collapse first group + grid.toggleGroup(groupRows[0].groupRow); + fix.detectChanges(); + + expect(groupRows[0].expanded).toBeFalsy(); + expect(grid.gridAPI.crudService.cellInEditMode).toBeFalsy(); + + // expand first group + grid.toggleGroup(groupRows[0].groupRow); + fix.detectChanges(); + + expect(groupRows[0].expanded).toBeTruthy(); + expect(grid.gridAPI.crudService.cellInEditMode).toBeFalsy(); + + // collapse first group + grid.toggleGroup(groupRows[0].groupRow); + fix.detectChanges(); + + expect(groupRows[0].expanded).toBeFalsy(); + expect(grid.gridAPI.crudService.cellInEditMode).toBeFalsy(); + + // set cell in second group in edit mode + cell.editMode = true; + fix.detectChanges(); + + expect(grid.gridAPI.crudService.cellInEditMode).toBeFalsy(); + }); + + it('Hide row editing dialog when hierarchical group is collapsed/expanded', () => { + // fix.detectChanges(); + // grid = fix.componentInstance.grid; + // fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false, + strategy: DefaultSortingStrategy.instance() + }); + fix.detectChanges(); + expect(grid.gridAPI.crudService.cellInEditMode).toBeFalsy(); + cell = grid.getCellByColumn(2, 'ProductName'); + cell.editMode = true; + fix.detectChanges(); + expect(grid.gridAPI.crudService.cellInEditMode).toBeTruthy(); + groupRows = grid.groupsRowList.toArray(); + + grid.toggleGroup(groupRows[0].groupRow); + fix.detectChanges(); + expect(grid.gridAPI.crudService.cellInEditMode).toBeFalsy(); + grid.toggleGroup(groupRows[0].groupRow); + fix.detectChanges(); + expect(grid.gridAPI.crudService.cellInEditMode).toBeFalsy(); + }); + }); + + describe('Transactions service', () => { + let trans; + let fix; + let grid; + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(IgxGridRowEditingTransactionComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + trans = grid.transactions; + })); + + + it(`Should not commit added row to grid's data in grid with transactions`, () => { + spyOn(trans, 'add').and.callThrough(); + + const addRowData = { + ProductID: 100, + ProductName: 'Added', + InStock: true, + UnitsInStock: 20000, + OrderDate: new Date(1) + }; + + grid.addRow(addRowData); + + expect(trans.add).toHaveBeenCalled(); + expect(trans.add).toHaveBeenCalledTimes(1); + expect(trans.add).toHaveBeenCalledWith({ id: 100, type: 'add', newValue: addRowData }); + expect(grid.data.length).toBe(10); + }); + + it(`Should not delete deleted row from grid's data in grid with transactions`, () => { + spyOn(trans, 'add').and.callThrough(); + + grid.deleteRow(5); + + expect(trans.add).toHaveBeenCalled(); + expect(trans.add).toHaveBeenCalledTimes(1); + expect(trans.add).toHaveBeenCalledWith({ id: 5, type: 'delete', newValue: null }, grid.data[4]); + expect(grid.data.length).toBe(10); + }); + + it(`Should not update updated cell in grid's data in grid with transactions`, () => { + spyOn(trans, 'add').and.callThrough(); + + grid.updateCell('Updated Cell', 3, 'ProductName'); + + expect(trans.add).toHaveBeenCalled(); + expect(trans.add).toHaveBeenCalledTimes(1); + expect(trans.add).toHaveBeenCalledWith({ + id: 3, + type: 'update', + newValue: { ProductName: 'Updated Cell' } + }, grid.data[2]); + expect(grid.data.length).toBe(10); + }); + + it(`Should not update updated row in grid's data in grid with transactions`, () => { + spyOn(trans, 'add').and.callThrough(); + + const updateRowData = { + ProductID: 100, + ProductName: 'Added', + InStock: true, + UnitsInStock: 20000, + OrderDate: new Date(1) + }; + const oldRowData = grid.data[2]; + + grid.updateRow(updateRowData, 3); + + expect(trans.add).toHaveBeenCalled(); + expect(trans.add).toHaveBeenCalledTimes(1); + expect(trans.add).toHaveBeenCalledWith({ + id: 3, + type: 'update', + newValue: updateRowData + }, oldRowData); + expect(grid.data[2]).toBe(oldRowData); + }); + + it(`Should be able to add a row if another row is in edit mode`, () => { + const rowCount = grid.rowList.length; + grid.rowEditable = true; + fix.detectChanges(); + + const targetRow = fix.debugElement.query(By.css(`${CELL_CLASS}:last-child`)); + UIInteractions.simulateClickAndSelectEvent(targetRow); + fix.detectChanges(); + + grid.addRow({ + ProductID: 1000, + ProductName: 'New Product', + InStock: true, + UnitsInStock: 1, + OrderDate: new Date() + }); + fix.detectChanges(); + + expect(grid.rowList.length).toBeGreaterThan(rowCount); + }); + + it(`Should be able to add a row if a cell is in edit mode`, () => { + const rowCount = grid.rowList.length; + const cell = grid.getCellByColumn(0, 'ProductName'); + cell.editMode = true; + + fix.detectChanges(); + + grid.addRow({ + ProductID: 1000, + ProductName: 'New Product', + InStock: true, + UnitsInStock: 1, + OrderDate: new Date() + }); + fix.detectChanges(); + + + expect(grid.rowList.length).toBeGreaterThan(rowCount); + }); + + it(`Should be able to clone data with custom clone strategy`, () => { + trans = grid.transactions; + expect(trans.cloneStrategy).toBeInstanceOf(DefaultDataCloneStrategy); + + grid.dataCloneStrategy = new ObjectCloneStrategy(); + fix.detectChanges(); + + const cell = grid.getCellByColumn(0, 'ProductName'); + cell.editMode = true; + fix.detectChanges(); + + cell.editValue = 'New Name'; + fix.detectChanges(); + const doneButtonElement = GridFunctions.getRowEditingDoneButton(fix); + doneButtonElement.click(); + fix.detectChanges(); + + trans = grid.transactions; + const states = trans.getAggregatedChanges(false); + expect(states[0]['newValue']['cloned']).toEqual(true); + expect(trans.cloneStrategy).toBeInstanceOf(ObjectCloneStrategy); + }); + }); +}); diff --git a/projects/igniteui-angular/grids/grid/src/grid-row-pinning.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-row-pinning.spec.ts new file mode 100644 index 00000000000..cdbe0bf2861 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid-row-pinning.spec.ts @@ -0,0 +1,1526 @@ +import { ViewChild, Component, DebugElement, OnInit, QueryList } from '@angular/core'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { IgxGridComponent } from './grid.component'; +import { CellType, IgxColumnComponent, IgxGridDetailTemplateDirective, IgxGridMRLNavigationService, IPinningConfig, IPinRowEventArgs, RowPinningPosition } from 'igniteui-angular/grids/core'; +import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; +import { GridFunctions } from '../../../test-utils/grid-functions.spec'; +import { GridSummaryFunctions } from '../../../test-utils/grid-functions.spec'; +import { wait, UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { clearGridSubs, setupGridScrollDetection } from '../../../test-utils/helper-utils.spec'; +import { GridRowConditionalStylingComponent } from '../../../test-utils/grid-base-components.spec'; +import { IgxColumnLayoutComponent } from 'igniteui-angular/grids/core'; +import { ColumnPinningPosition, IgxStringFilteringOperand, SortingDirection } from 'igniteui-angular/core'; +import { IgxPaginatorComponent } from 'igniteui-angular/paginator'; + +describe('Row Pinning #grid', () => { + const FIXED_ROW_CONTAINER = '.igx-grid__tr--pinned '; + const CELL_CSS_CLASS = '.igx-grid__td'; + const DEBOUNCE_TIME = 60; + + let fix; + let grid: IgxGridComponent; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + GridRowConditionalStylingComponent, + GridRowPinningComponent, + GridRowPinningWithMRLComponent, + GridRowPinningWithMDVComponent, + GridRowPinningWithTransactionsComponent, + GridRowPinningWithInitialPinningComponent + ], + providers: [ + IgxGridMRLNavigationService + ] + }).compileComponents(); + })); + + describe('', () => { + beforeEach(() => { + fix = TestBed.createComponent(GridRowPinningComponent); + grid = fix.componentInstance.instance; + fix.detectChanges(); + }); + + it('should pin rows to top.', () => { + // pin 2nd data row + grid.pinRow(fix.componentInstance.data[1]); + fix.detectChanges(); + + expect(grid.pinnedRows.length).toBe(1); + let pinRowContainer = fix.debugElement.queryAll(By.css(FIXED_ROW_CONTAINER)); + expect(pinRowContainer.length).toBe(1); + expect(pinRowContainer[0].children.length).toBe(1); + expect(pinRowContainer[0].children[0].context.key).toBe(fix.componentInstance.data[1]); + expect(pinRowContainer[0].children[0].nativeElement).toBe(grid.gridAPI.get_row_by_index(0).nativeElement); + + expect(grid.gridAPI.get_row_by_index(1).key).toBe(fix.componentInstance.data[0]); + expect(grid.gridAPI.get_row_by_index(3).key).toBe(fix.componentInstance.data[2]); + + // pin 3rd data row + grid.pinRow(fix.componentInstance.data[2]); + fix.detectChanges(); + + pinRowContainer = fix.debugElement.queryAll(By.css(FIXED_ROW_CONTAINER)); + expect(pinRowContainer[0].children.length).toBe(2); + expect(pinRowContainer[0].children[0].context.key).toBe(fix.componentInstance.data[1]); + expect(pinRowContainer[0].children[1].context.key).toBe(fix.componentInstance.data[2]); + + expect(grid.gridAPI.get_row_by_index(2).key).toBe(fix.componentInstance.data[0]); + expect(grid.gridAPI.get_row_by_index(5).key).toBe(fix.componentInstance.data[3]); + + fix.detectChanges(); + // 2 records pinned + 2px border + expect(grid.pinnedRowHeight).toBe(2 * grid.renderedRowHeight + 2); + const expectedHeight = parseInt(grid.height, 10) - grid.pinnedRowHeight - 18 - grid.theadRow.nativeElement.offsetHeight; + expect(grid.calcHeight - expectedHeight).toBeLessThanOrEqual(1); + }); + + it('should pin rows to bottom.', () => { + fix.componentInstance.pinningConfig = { columns: ColumnPinningPosition.Start, rows: RowPinningPosition.Bottom }; + fix.detectChanges(); + + // pin 2nd + grid.pinRow(fix.componentInstance.data[1]); + fix.detectChanges(); + + expect(grid.pinnedRows.length).toBe(1); + let pinRowContainer = fix.debugElement.queryAll(By.css(FIXED_ROW_CONTAINER)); + expect(pinRowContainer.length).toBe(1); + expect(pinRowContainer[0].children.length).toBe(1); + expect(pinRowContainer[0].children[0].context.key).toBe(fix.componentInstance.data[1]); + expect(pinRowContainer[0].children[0].context.index - grid.pinnedRows.length).toBe(fix.componentInstance.data.length - 1); + expect(pinRowContainer[0].children[0].nativeElement) + .toBe(grid.gridAPI.get_row_by_index(fix.componentInstance.data.length).nativeElement); + + expect(grid.gridAPI.get_row_by_index(0).key).toBe(fix.componentInstance.data[0]); + expect(grid.gridAPI.get_row_by_index(2).key).toBe(fix.componentInstance.data[2]); + + // pin 1st + grid.pinRow(fix.componentInstance.data[0]); + fix.detectChanges(); + + pinRowContainer = fix.debugElement.queryAll(By.css(FIXED_ROW_CONTAINER)); + expect(pinRowContainer[0].children.length).toBe(2); + expect(pinRowContainer[0].children[0].context.key).toBe(fix.componentInstance.data[1]); + expect(pinRowContainer[0].children[1].context.key).toBe(fix.componentInstance.data[0]); + fix.detectChanges(); + // check last pinned is fully in view + const last = pinRowContainer[0].children[1].context.nativeElement; + expect(last.getBoundingClientRect().bottom - grid.tbody.nativeElement.getBoundingClientRect().bottom).toBe(0); + + // 2 records pinned + 2px border + expect(grid.pinnedRowHeight).toBe(2 * grid.renderedRowHeight + 2); + const expectedHeight = parseInt(grid.height, 10) - grid.pinnedRowHeight - 18 - grid.theadRow.nativeElement.offsetHeight; + expect(grid.calcHeight - expectedHeight).toBeLessThanOrEqual(1); + }); + + it('should allow pinning row at specified index via API.', () => { + grid.pinRow(fix.componentInstance.data[1]); + fix.detectChanges(); + + expect(grid.pinnedRows.length).toBe(1); + expect(grid.pinnedRows[0].data).toBe(fix.componentInstance.data[1]); + + // pin at index 0 + grid.pinRow(fix.componentInstance.data[2], 0); + fix.detectChanges(); + + expect(grid.pinnedRows.length).toBe(2); + expect(grid.pinnedRows[0].data).toBe(fix.componentInstance.data[2]); + expect(grid.pinnedRows[1].data).toBe(fix.componentInstance.data[1]); + + // pin at index 1 + grid.pinRow(fix.componentInstance.data[3], 1); + fix.detectChanges(); + + expect(grid.pinnedRows.length).toBe(3); + expect(grid.pinnedRows[0].data).toBe(fix.componentInstance.data[2]); + expect(grid.pinnedRows[1].data).toBe(fix.componentInstance.data[3]); + expect(grid.pinnedRows[2].data).toBe(fix.componentInstance.data[1]); + }); + + it('should emit rowPinning on pin/unpin.', () => { + spyOn(grid.rowPinning, 'emit').and.callThrough(); + + let row = grid.getRowByIndex(0); + const rowID = row.key; + row.pin(); + + // Check pinned state with getRowByIndex after pin action + expect(row.pinned).toBe(true); + + expect(grid.rowPinning.emit).toHaveBeenCalledTimes(1); + expect(grid.rowPinning.emit).toHaveBeenCalledWith({ + rowID, + rowKey: rowID, + insertAtIndex: 0, + isPinned: true, + row, + cancel: false + }); + + row = grid.getRowByIndex(0); + row.unpin(); + // Check pinned state with getRowByIndex after unpin action + expect(row.pinned).toBe(false); + + expect(grid.rowPinning.emit).toHaveBeenCalledTimes(2); + }); + + it('should emit correct rowPinning arguments on pin/unpin.', () => { + spyOn(grid.rowPinning, 'emit').and.callThrough(); + + const row = grid.getRowByIndex(5); + const rowID = row.key; + row.pin(); + + expect(grid.rowPinning.emit).toHaveBeenCalledTimes(1); + expect(grid.rowPinning.emit).toHaveBeenCalledWith({ + rowID, + rowKey: rowID, + insertAtIndex: 0, + isPinned: true, + row, + cancel: false + }); + + const row2 = grid.getRowByIndex(3); + const rowID2 = row2.key; + row2.pin(); + + expect(grid.rowPinning.emit).toHaveBeenCalledTimes(2); + expect(grid.rowPinning.emit).toHaveBeenCalledWith({ + rowID: rowID2, + rowKey: rowID2, + insertAtIndex: 1, + isPinned: true, + row: row2, + cancel: false + }); + }); + + it('should be able to set pin position of row on pin/unpin events.', () => { + const row1 = grid.getRowByIndex(0); + row1.pin(); + expect(row1.pinned).toBe(true); + expect(grid.pinnedRecords.length).toBe(1); + expect(grid.pinnedRecords[0]).toEqual(row1.data); + + const row2 = grid.getRowByIndex(2); + row2.pin(); + grid.pinRow(row2.key); + expect(row2.pinned).toBe(true); + expect(grid.pinnedRecords.length).toBe(2); + expect(grid.pinnedRecords[1]).toEqual(row2.data); + + grid.rowPinning.subscribe((e: IPinRowEventArgs) => { + e.insertAtIndex = 0; + }); + const row5 = grid.getRowByIndex(5); + row5.pin(); + expect(row2.pinned).toBe(true); + expect(grid.pinnedRecords.length).toBe(3); + expect(grid.pinnedRecords[0]).toEqual(row5.data); + }); + + it('should emit rowPinned on pin/unpin.', () => { + spyOn(grid.rowPinned, 'emit').and.callThrough(); + + const row = grid.getRowByIndex(0); + const rowID = row.key; + row.pin(); + + // Check pinned state with getRowByIndex after pin action + expect(row.pinned).toBe(true); + + expect(grid.rowPinned.emit).toHaveBeenCalledTimes(1); + expect(grid.rowPinned.emit).toHaveBeenCalledWith({ + rowID, + rowKey: rowID, + insertAtIndex: 0, + isPinned: true, + row, + cancel: false + }); + + row.unpin(); + // Check pinned state with getRowByIndex after unpin action + expect(row.pinned).toBe(false); + + expect(grid.rowPinned.emit).toHaveBeenCalledTimes(2); + }); + + it(`Should be able to cancel rowPinning on pin/unpin event.`, () => { + spyOn(grid.rowPinning, 'emit').and.callThrough(); + let sub = grid.rowPinning.subscribe((e: IPinRowEventArgs) => { + e.cancel = true; + }); + + const row = grid.getRowByIndex(0); + const rowID = row.key; + expect(row.pinned).toBeFalsy(); + + row.pin(); + fix.detectChanges(); + + expect(grid.rowPinning.emit).toHaveBeenCalledTimes(1); + expect(grid.rowPinning.emit).toHaveBeenCalledWith({ + insertAtIndex: 0, + isPinned: true, + rowID, + rowKey: rowID, + row, + cancel: true + }); + expect(row.pinned).toBeFalsy(); + + sub.unsubscribe(); + + row.pin(); + fix.detectChanges(); + + expect(grid.rowPinning.emit).toHaveBeenCalledTimes(2); + expect(grid.rowPinning.emit).toHaveBeenCalledWith({ + insertAtIndex: 0, + isPinned: true, + rowID, + rowKey: rowID, + row, + cancel: false + }); + expect(row.pinned).toBe(true); + + sub = grid.rowPinning.subscribe((e: IPinRowEventArgs) => { + e.cancel = true; + }); + + row.unpin(); + fix.detectChanges(); + + expect(grid.rowPinning.emit).toHaveBeenCalledTimes(3); + expect(grid.rowPinning.emit).toHaveBeenCalledWith({ + isPinned: false, + rowID, + rowKey: rowID, + row, + cancel: true + }); + expect(row.pinned).toBe(true); + sub.unsubscribe(); + }); + + it('should pin/unpin via grid API methods.', () => { + // pin 2nd row + grid.pinRow(fix.componentInstance.data[1]); + fix.detectChanges(); + + expect(grid.pinnedRows.length).toBe(1); + let pinRowContainer = fix.debugElement.queryAll(By.css(FIXED_ROW_CONTAINER)); + expect(pinRowContainer.length).toBe(1); + expect(pinRowContainer[0].children.length).toBe(1); + expect(pinRowContainer[0].children[0].context.key).toBe(fix.componentInstance.data[1]); + expect(pinRowContainer[0].children[0].context.index).toBe(0); + expect(pinRowContainer[0].children[0].nativeElement).toBe(grid.gridAPI.get_row_by_index(0).nativeElement); + + expect(grid.gridAPI.get_row_by_index(0).key).toBe(fix.componentInstance.data[1]); + expect(grid.gridAPI.get_row_by_index(1).key).toBe(fix.componentInstance.data[0]); + + // unpin 2nd row + grid.unpinRow(fix.componentInstance.data[1]); + fix.detectChanges(); + + expect(grid.pinnedRows.length).toBe(0); + pinRowContainer = fix.debugElement.queryAll(By.css(FIXED_ROW_CONTAINER)); + expect(pinRowContainer.length).toBe(0); + + expect(grid.gridAPI.get_row_by_index(0).key).toBe(fix.componentInstance.data[0]); + expect(grid.gridAPI.get_row_by_index(1).key).toBe(fix.componentInstance.data[1]); + }); + + it('should pin/unpin via row API methods.', () => { + // pin 2nd row + let row = grid.gridAPI.get_row_by_index(1); + row.pin(); + fix.detectChanges(); + + expect(grid.pinnedRows.length).toBe(1); + let pinRowContainer = fix.debugElement.queryAll(By.css(FIXED_ROW_CONTAINER)); + expect(pinRowContainer.length).toBe(1); + expect(pinRowContainer[0].children.length).toBe(1); + expect(pinRowContainer[0].children[0].context.key).toBe(fix.componentInstance.data[1]); + + expect(grid.gridAPI.get_row_by_index(0).key).toBe(fix.componentInstance.data[1]); + expect(grid.gridAPI.get_row_by_index(1).key).toBe(fix.componentInstance.data[0]); + + // unpin + row = grid.gridAPI.get_row_by_index(0); + row.unpin(); + + expect(grid.pinnedRows.length).toBe(0); + pinRowContainer = fix.debugElement.queryAll(By.css(FIXED_ROW_CONTAINER)); + expect(pinRowContainer.length).toBe(0); + + expect(grid.gridAPI.get_row_by_index(0).key).toBe(fix.componentInstance.data[0]); + expect(grid.gridAPI.get_row_by_index(1).key).toBe(fix.componentInstance.data[1]); + }); + + it('should pin/unpin via row pinned setter.', () => { + // pin 2nd row + let row = grid.gridAPI.get_row_by_index(1); + row.pinned = true; + fix.detectChanges(); + + expect(grid.pinnedRows.length).toBe(1); + let pinRowContainer = fix.debugElement.queryAll(By.css(FIXED_ROW_CONTAINER)); + expect(pinRowContainer.length).toBe(1); + expect(pinRowContainer[0].children.length).toBe(1); + expect(pinRowContainer[0].children[0].context.key).toBe(fix.componentInstance.data[1]); + + expect(grid.gridAPI.get_row_by_index(0).key).toBe(fix.componentInstance.data[1]); + expect(grid.gridAPI.get_row_by_index(1).key).toBe(fix.componentInstance.data[0]); + + // unpin + row = grid.gridAPI.get_row_by_index(0); + row.pinned = false; + fix.detectChanges(); + + expect(grid.pinnedRows.length).toBe(0); + pinRowContainer = fix.debugElement.queryAll(By.css(FIXED_ROW_CONTAINER)); + expect(pinRowContainer.length).toBe(0); + + expect(grid.gridAPI.get_row_by_index(0).key).toBe(fix.componentInstance.data[0]); + expect(grid.gridAPI.get_row_by_index(1).key).toBe(fix.componentInstance.data[1]); + }); + + it('should search in both pinned and unpinned rows.', () => { + // pin 1st row + let row = grid.gridAPI.get_row_by_index(0); + row.pinned = true; + fix.detectChanges(); + expect(grid.pinnedRows.length).toBe(1); + + let finds = grid.findNext('mari'); + fix.detectChanges(); + + const fixNativeElement = fix.debugElement.nativeElement; + let spans = fixNativeElement.querySelectorAll('.igx-highlight'); + expect(spans.length).toBe(2); + expect(finds).toEqual(3); + + finds = grid.findNext('antonio'); + fix.detectChanges(); + + spans = fixNativeElement.querySelectorAll('.igx-highlight'); + expect(spans.length).toBe(2); + expect(finds).toEqual(2); + + // pin 3rd row + row = grid.gridAPI.get_row_by_index(2); + row.pinned = true; + fix.detectChanges(); + expect(grid.pinnedRows.length).toBe(2); + + finds = grid.findNext('antonio'); + fix.detectChanges(); + + spans = fixNativeElement.querySelectorAll('.igx-highlight'); + expect(spans.length).toBe(2); + expect(finds).toEqual(2); + }); + + it('should allow pinning onInit', () => { + expect(() => { + fix = TestBed.createComponent(GridRowPinningComponent); + grid = fix.componentInstance.instance; + fix.detectChanges(); + grid.pinRow(fix.componentInstance.data[1]); + fix.detectChanges(); + }).not.toThrow(); + expect(grid.pinnedRows.length).toBe(1); + expect(grid.gridAPI.get_row_by_index(0).key).toBe(fix.componentInstance.data[1]); + }); + + it('should pin rows when columns are grouped.', () => { + grid.height = '650px'; + fix.detectChanges(); + // pin 1st and 2nd data row + grid.pinRow(fix.componentInstance.data[0]); + grid.pinRow(fix.componentInstance.data[1]); + fix.detectChanges(); + + // group by string column + grid.groupBy({ + fieldName: 'ContactTitle', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + + expect(grid.pinnedRows.length).toBe(2); + + // verify rows + const groupRows = grid.groupsRowList.toArray(); + const dataRows = grid.dataRowList.toArray(); + + expect(groupRows.length).toEqual(2); + expect(dataRows.length).toEqual(9); + expect(groupRows[0].groupRow.records[0].ID).toEqual('ALFKI'); + expect(groupRows[0].groupRow.records[1].ID).toEqual('AROUT'); + + // pin 4th data row with ID:AROUT + grid.pinRow(fix.componentInstance.data[3]); + fix.detectChanges(); + + expect(grid.pinnedRows.length).toBe(3); + + // make sure the pinned rows is in the unpinned area as disabled row + expect(groupRows[0].groupRow.records[0].ID).toEqual('ALFKI'); + expect(groupRows[0].groupRow.records[1].ID).toEqual('AROUT'); + expect(groupRows[0].groupRow.records[2].ID).toEqual('BLAUS'); + }); + + it('should apply filtering to both pinned and unpinned rows.', () => { + grid.gridAPI.get_row_by_index(1).pin(); + fix.detectChanges(); + grid.gridAPI.get_row_by_index(5).pin(); + fix.detectChanges(); + + let pinRowContainer = fix.debugElement.queryAll(By.css(FIXED_ROW_CONTAINER)); + expect(pinRowContainer[0].children.length).toBe(2); + expect(pinRowContainer[0].children[0].context.key).toBe(fix.componentInstance.data[1]); + expect(pinRowContainer[0].children[1].context.key).toBe(fix.componentInstance.data[4]); + + grid.filter('ID', 'B', IgxStringFilteringOperand.instance().condition('contains'), false); + fix.detectChanges(); + + pinRowContainer = fix.debugElement.queryAll(By.css(FIXED_ROW_CONTAINER)); + expect(pinRowContainer[0].children.length).toBe(1); + expect(pinRowContainer[0].children[0].context.key).toBe(fix.componentInstance.data[4]); + }); + + it('should calculate global summaries correctly when filtering is applied.', () => { + grid.getColumnByName('ID').hasSummary = true; + fix.detectChanges(); + grid.filter('ID', 'BERGS', IgxStringFilteringOperand.instance().condition('contains'), false); + fix.detectChanges(); + + let summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, ['Count'], ['1']); + + // pin row + grid.gridAPI.get_row_by_index(0).pin(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, ['Count'], ['1']); + }); + + it('should remove pinned container and recalculate sizes when all pinned records are filtered out.', () => { + grid.gridAPI.get_row_by_index(1).pin(); + fix.detectChanges(); + let pinRowContainer = fix.debugElement.queryAll(By.css(FIXED_ROW_CONTAINER)); + expect(pinRowContainer.length).toBe(1); + + fix.detectChanges(); + let expectedHeight = parseInt(grid.height, 10) - grid.pinnedRowHeight - 18 - grid.theadRow.nativeElement.offsetHeight; + expect(grid.calcHeight - expectedHeight).toBeLessThanOrEqual(1); + + grid.filter('ID', 'B', IgxStringFilteringOperand.instance().condition('startsWith'), false); + fix.detectChanges(); + + pinRowContainer = fix.debugElement.queryAll(By.css(FIXED_ROW_CONTAINER)); + expect(pinRowContainer.length).toBe(0); + + fix.detectChanges(); + expect(grid.pinnedRowHeight).toBe(0); + expectedHeight = parseInt(grid.height, 10) - grid.pinnedRowHeight - 18 - grid.theadRow.nativeElement.offsetHeight; + expect(grid.calcHeight - expectedHeight).toBeLessThanOrEqual(1); + }); + + it('should return correct filterData collection.', () => { + grid.gridAPI.get_row_by_index(1).pin(); + fix.detectChanges(); + grid.gridAPI.get_row_by_index(6).pin(); + fix.detectChanges(); + + grid.filter('ID', 'B', IgxStringFilteringOperand.instance().condition('contains'), false); + fix.detectChanges(); + + let gridFilterData = grid.filteredData; + expect(gridFilterData.length).toBe(8); + expect(gridFilterData[0].ID).toBe('BLAUS'); + expect(gridFilterData[1].ID).toBe('BERGS'); + + fix.componentInstance.pinningConfig = { columns: ColumnPinningPosition.Start, rows: RowPinningPosition.Bottom }; + fix.detectChanges(); + + gridFilterData = grid.filteredData; + expect(gridFilterData.length).toBe(8); + expect(gridFilterData[0].ID).toBe('BLAUS'); + expect(gridFilterData[1].ID).toBe('BERGS'); + }); + + it('should apply sorting to both pinned and unpinned rows.', () => { + grid.gridAPI.get_row_by_index(1).pin(); + grid.gridAPI.get_row_by_index(6).pin(); + + expect(grid.gridAPI.get_row_by_index(0).key).toBe(fix.componentInstance.data[1]); + expect(grid.gridAPI.get_row_by_index(1).key).toBe(fix.componentInstance.data[5]); + + grid.sort({ fieldName: 'ID', dir: SortingDirection.Desc, ignoreCase: false }); + fix.detectChanges(); + + // check pinned rows data is sorted + expect(grid.gridAPI.get_row_by_index(0).key).toBe(fix.componentInstance.data[5]); + expect(grid.gridAPI.get_row_by_index(1).key).toBe(fix.componentInstance.data[1]); + + // check unpinned rows data is sorted + const lastIndex = fix.componentInstance.data.length - 1; + expect(grid.gridAPI.get_row_by_index(2).key).toBe(fix.componentInstance.data[lastIndex]); + }); + }); + + describe('Row pinning with Master Detail View', () => { + beforeEach(() => { + fix = TestBed.createComponent(GridRowPinningWithMDVComponent); + grid = fix.componentInstance.instance; + fix.detectChanges(); + }); + + it('should be in view when expanded and pinning row to bottom of the grid.', async () => { + fix.componentInstance.pinningConfig = { columns: ColumnPinningPosition.Start, rows: RowPinningPosition.Bottom }; + fix.detectChanges(); + // pin 1st row + const row = grid.gridAPI.get_row_by_index(0); + row.pinned = true; + fix.detectChanges(); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + GridFunctions.toggleMasterRow(fix, grid.pinnedRows[0]); + fix.detectChanges(); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + + expect(grid.pinnedRows.length).toBe(1); + + const firstRowIconName = GridFunctions.getRowExpandIconName(grid.rowList.first); + const pinnedRow = grid.pinnedRows[0]; + expect(grid.expansionStates.size).toEqual(1); + expect(grid.expansionStates.has(pinnedRow.key)).toBeTruthy(); + expect(grid.expansionStates.get(pinnedRow.key)).toBeTruthy(); + // disabled row should have expand icon + expect(firstRowIconName).toEqual('expand_more'); + // disabled row should have chip + const cell = (grid.gridAPI.get_row_by_index(0).cells as QueryList).toArray()[0]; + expect(cell.nativeElement.getElementsByClassName('igx-grid__td--pinned-chip').length).toBe(1); + // pinned row shouldn't have expand icon + const hasIconForPinnedRow = pinnedRow.cells.first.nativeElement.querySelector('igx-icon'); + expect(hasIconForPinnedRow).toBeNull(); + + // check last pinned row is fully in view + expect(pinnedRow.nativeElement.getBoundingClientRect().bottom - grid.tbody.nativeElement.getBoundingClientRect().bottom) + .toBe(0); + }); + + it('should calculate global summaries with both pinned and unpinned collections', () => { + // enable summaries for each column + grid.columns.forEach(c => { + c.hasSummary = true; + }); + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ContactTitle', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + + let row = grid.gridAPI.get_row_by_index(1); + row.pinned = true; + fix.detectChanges(); + let summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, ['Count'], ['27']); + + row = grid.pinnedRows[0]; + row.pinned = false; + fix.detectChanges(); + expect(grid.pinnedRows.length).toBe(0); + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, ['Count'], ['27']); + }); + + it('should calculate groupby row summaries only within unpinned collection', () => { + // enable summaries for each column + grid.columns.forEach(c => { + c.hasSummary = true; + }); + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ContactTitle', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + + let row = grid.gridAPI.get_row_by_index(1); + row.pinned = true; + fix.detectChanges(); + + expect(grid.pinnedRows.length).toBe(1); + + // get first summary row and make sure that the pinned record is not contained within the calculations + let summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 4); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, ['Count'], ['2']); + + // unpin the row and check if the summary is recalculated + row = grid.pinnedRows[0]; + row.pinned = false; + fix.detectChanges(); + expect(grid.pinnedRows.length).toBe(0); + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 3); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, ['Count'], ['2']); + }); + }); + + describe('Paging', () => { + let paginator: IgxPaginatorComponent; + + beforeEach(() => { + fix = TestBed.createComponent(GridRowPinningComponent); + fix.componentInstance.createSimpleData(12); + grid = fix.componentInstance.instance; + fix.componentInstance.paging = true; + fix.detectChanges(); + + paginator = fix.debugElement.query(By.directive(IgxPaginatorComponent)).componentInstance; + paginator.perPage = 5; + fix.detectChanges(); + }); + + it('should correctly apply paging state for grid and paginator when there are pinned rows.', () => { + // pin the first row + grid.gridAPI.get_row_by_index(0).pin(); + + expect(grid.rowList.length).toEqual(6); + expect(grid.perPage).toEqual(5); + expect(paginator.perPage).toEqual(5); + expect(paginator.totalRecords).toEqual(12); + expect(paginator.totalPages).toEqual(3); + + // pin the second row + grid.gridAPI.get_row_by_index(2).pin(); + + expect(grid.rowList.length).toEqual(7); + expect(grid.perPage).toEqual(5); + expect(paginator.perPage).toEqual(5); + expect(paginator.totalRecords).toEqual(12); + expect(paginator.totalPages).toEqual(3); + }); + + it('should have the correct records shown for pages with pinned rows', () => { + grid.gridAPI.get_row_by_index(0).pin(); + + let rows = grid.rowList.toArray(); + + [1, 1, 2, 3, 4, 5].forEach((x, index) => expect(rows[index].cells.first.value).toEqual(x)); + + grid.paginator.paginate(2); + fix.detectChanges(); + + rows = grid.rowList.toArray(); + + [1, 11, 12].forEach((x, index) => expect(rows[index].cells.first.value).toEqual(x)); + }); + }); + + describe(' Editing ', () => { + beforeEach(() => { + fix = TestBed.createComponent(GridRowPinningWithTransactionsComponent); + grid = fix.componentInstance.instance; + fix.detectChanges(); + }); + + it('should allow pinning edited row.', () => { + grid.updateCell('New value', 'ANTON', 'CompanyName'); + fix.detectChanges(); + grid.pinRow('ANTON'); + fix.detectChanges(); + + expect(grid.pinnedRows.length).toBe(1); + const pinRowContainer = fix.debugElement.queryAll(By.css(FIXED_ROW_CONTAINER)); + expect(pinRowContainer.length).toBe(1); + expect(pinRowContainer[0].children.length).toBe(1); + expect(pinRowContainer[0].children[0].context.key).toBe('ANTON'); + expect(pinRowContainer[0].children[0].context.data.CompanyName).toBe('New value'); + }); + + it('should allow pinning deleted row.', () => { + grid.deleteRow('ALFKI'); + fix.detectChanges(); + grid.pinRow('ALFKI'); + fix.detectChanges(); + + expect(grid.pinnedRows.length).toBe(1); + const pinRowContainer = fix.debugElement.queryAll(By.css(FIXED_ROW_CONTAINER)); + expect(pinRowContainer.length).toBe(1); + expect(pinRowContainer[0].children.length).toBe(1); + expect(pinRowContainer[0].children[0].context.key).toBe('ALFKI'); + }); + + it('should allow pinning added row.', () => { + + grid.addRow({ ID: 'Test', CompanyName: 'Test' }); + fix.detectChanges(); + + grid.pinRow('Test'); + fix.detectChanges(); + expect(grid.pinnedRows.length).toBe(1); + const pinRowContainer = fix.debugElement.queryAll(By.css(FIXED_ROW_CONTAINER)); + expect(pinRowContainer.length).toBe(1); + expect(pinRowContainer[0].children.length).toBe(1); + expect(pinRowContainer[0].children[0].context.key).toBe('Test'); + }); + + it('should stop editing when edited row is pinned/unpinned.', () => { + grid.getColumnByName('CompanyName').editable = true; + fix.detectChanges(); + let cell = grid.getCellByColumn(0, 'CompanyName'); + let cellDomNumber = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[1]; + cellDomNumber.triggerEventHandler('dblclick', {}); + fix.detectChanges(); + + expect(cell.editMode).toBeTruthy(); + + grid.pinRow(cell.row.key); + fix.detectChanges(); + + cell = grid.getCellByColumn(0, 'CompanyName'); + expect(cell.editMode).toBeFalsy(); + + cellDomNumber = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[1]; + cellDomNumber.triggerEventHandler('dblclick', {}); + fix.detectChanges(); + + expect(cell.editMode).toBeTruthy(); + grid.unpinRow(cell.row.key); + fix.detectChanges(); + cell = grid.getCellByColumn(0, 'CompanyName'); + expect(cell.editMode).toBeFalsy(); + }); + + }); + + describe('Row pinning with MRL', () => { + beforeEach(() => { + fix = TestBed.createComponent(GridRowPinningWithMRLComponent); + grid = fix.componentInstance.instance; + fix.detectChanges(); + }); + + it('should pin/unpin correctly to top', () => { + // pin + grid.pinRow(fix.componentInstance.data[1]); + fix.detectChanges(); + + expect(grid.pinnedRows.length).toBe(1); + const pinRowContainer = fix.debugElement.queryAll(By.css(FIXED_ROW_CONTAINER)); + expect(pinRowContainer.length).toBe(1); + expect(pinRowContainer[0].children.length).toBe(1); + expect(pinRowContainer[0].children[0].context.key).toBe(fix.componentInstance.data[1]); + expect(pinRowContainer[0].children[0].nativeElement).toBe(grid.gridAPI.get_row_by_index(0).nativeElement); + + expect(grid.gridAPI.get_row_by_index(0).pinned).toBeTruthy(); + const gridPinnedRow = grid.pinnedRows[0]; + + // headers are aligned to cells + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridPinnedRow, true); + GridFunctions.verifyDOMMatchesLayoutSettings(grid, gridPinnedRow, fix.componentInstance.colGroups); + + // unpin + const row = grid.pinnedRows[0]; + row.pinned = false; + fix.detectChanges(); + + expect(grid.pinnedRows.length).toBe(0); + expect(row.pinned).toBeFalsy(); + + const gridUnpinnedRow = grid.gridAPI.get_row_by_index(1); + + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridUnpinnedRow); + GridFunctions.verifyDOMMatchesLayoutSettings(grid, gridUnpinnedRow, fix.componentInstance.colGroups); + }); + + it('should pin/unpin correctly to bottom', () => { + + fix.componentInstance.pinningConfig = { columns: ColumnPinningPosition.Start, rows: RowPinningPosition.Bottom }; + fix.detectChanges(); + + // pin + grid.pinRow(fix.componentInstance.data[1]); + fix.detectChanges(); + + expect(grid.pinnedRows.length).toBe(1); + const pinRowContainer = fix.debugElement.queryAll(By.css(FIXED_ROW_CONTAINER)); + expect(pinRowContainer.length).toBe(1); + expect(pinRowContainer[0].children.length).toBe(1); + expect(pinRowContainer[0].children[0].context.key).toBe(fix.componentInstance.data[1]); + expect(pinRowContainer[0].children[0].nativeElement) + .toBe(grid.gridAPI.get_row_by_index(fix.componentInstance.data.length).nativeElement); + + expect(grid.gridAPI.get_row_by_index(fix.componentInstance.data.length).pinned).toBeTruthy(); + const gridPinnedRow = grid.pinnedRows[0]; + + // headers are aligned to cells + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridPinnedRow, true); + GridFunctions.verifyDOMMatchesLayoutSettings(grid, gridPinnedRow, fix.componentInstance.colGroups); + + // unpin + const row = grid.pinnedRows[0]; + row.pinned = false; + fix.detectChanges(); + + expect(grid.pinnedRows.length).toBe(0); + expect(row.pinned).toBeFalsy(); + + const gridUnpinnedRow = grid.gridAPI.get_row_by_index(1); + + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridUnpinnedRow); + GridFunctions.verifyDOMMatchesLayoutSettings(grid, gridUnpinnedRow, fix.componentInstance.colGroups); + }); + + it('should test getRowByIndex API members.', () => { + fix.componentInstance.pinningConfig = { columns: ColumnPinningPosition.Start, rows: RowPinningPosition.Bottom }; + fix.detectChanges(); + + // pin 1st + grid.pinRow(fix.componentInstance.data[0]); + fix.detectChanges(); + const firstRow = grid.getRowByIndex(0); + // Check if the row is pinned to the bottom through the Row pinned API + expect(firstRow.pinned).toBe(true); + + // Toggle pin state with row API + firstRow.pinned = false; + expect(firstRow.pinned).toBe(false); + fix.detectChanges(); + firstRow.pinned = true; + expect(firstRow.pinned).toBe(true); + fix.detectChanges(); + + // Check dom existence + expect(grid.pinnedRows.length).toBe(1); + let pinRowContainer = fix.debugElement.queryAll(By.css(FIXED_ROW_CONTAINER)); + expect(pinRowContainer[0].children[0].nativeElement) + .toBe(grid.gridAPI.get_row_by_index(fix.componentInstance.data.length).nativeElement); + + // Pin/Unpin with the methods + firstRow.unpin(); + expect(firstRow.pinned).toBe(false); + firstRow.pin(); + expect(firstRow.pinned).toBe(true); + + // Check again pinned row presence + pinRowContainer = fix.debugElement.queryAll(By.css(FIXED_ROW_CONTAINER)); + expect(pinRowContainer[0].children[0].nativeElement) + .toBe(grid.gridAPI.get_row_by_index(fix.componentInstance.data.length).nativeElement); + + // Check select + firstRow.selected = true; + fix.detectChanges(); + expect(firstRow.selected).toBe(true); + + // Check pinned row existence after the selection + expect(pinRowContainer[0].children[0].nativeElement.offsetParent).toBeDefined(); + expect(pinRowContainer[0].children[0].nativeElement.offsetWidth).toBeGreaterThan(0); + + firstRow.selected = false; + fix.detectChanges(); + expect(firstRow.selected).toBe(false); + + // Delete row + firstRow.delete(); + fix.detectChanges(); + expect(grid.gridAPI.get_row_by_index(0).data.ID).toEqual('ANATR'); + // TO DO Check pinned row existence after the row deletion + expect(pinRowContainer[0].children[0].nativeElement.offsetParent).toBeNull(); + expect(pinRowContainer[0].children[0].nativeElement.offsetWidth).toEqual(0); + + // Check API methods + expect(firstRow.key).toBeTruthy(); + expect(firstRow.data).toBeTruthy(); + expect(firstRow.pinned).toBe(true); + }); + }); + + describe(' Hiding', () => { + beforeEach(() => { + fix = TestBed.createComponent(GridRowPinningComponent); + grid = fix.componentInstance.instance; + fix.detectChanges(); + }); + + it('should hide columns in pinned and unpinned area', () => { + // pin 2nd data row + grid.pinRow(fix.componentInstance.data[1]); + const hiddenCol = grid.columns[1]; + hiddenCol.hidden = true; + fix.detectChanges(); + + const pinnedCells = grid.pinnedRows[0].cells; + expect(pinnedCells.filter(cell => cell.column.field === hiddenCol.field).length).toBe(0); + + const unpinnedCells = grid.rowList.first.cells; + expect(unpinnedCells.filter(cell => cell.column.field === hiddenCol.field).length).toBe(0); + + expect(pinnedCells.length).toBe(unpinnedCells.length); + + const headerCells = grid.headerCellList; + expect(headerCells.filter(cell => cell.column.field === hiddenCol.field).length).toBe(0); + + expect(grid.pinnedRows.length).toBe(1); + const pinRowContainer = fix.debugElement.queryAll(By.css(FIXED_ROW_CONTAINER)); + expect(pinRowContainer.length).toBe(1); + expect(pinRowContainer[0].children.length).toBe(1); + expect(pinRowContainer[0].children[0].context.key).toBe(fix.componentInstance.data[1]); + expect(pinRowContainer[0].children[0].nativeElement).toBe(grid.gridAPI.get_row_by_index(0).nativeElement); + + expect(grid.gridAPI.get_row_by_index(0).key).toBe(fix.componentInstance.data[1]); + expect(grid.gridAPI.get_row_by_index(1).key).toBe(fix.componentInstance.data[0]); + expect(grid.gridAPI.get_row_by_index(2).key).toBe(fix.componentInstance.data[1]); + expect(grid.gridAPI.get_row_by_index(3).key).toBe(fix.componentInstance.data[2]); + + fix.detectChanges(); + // 1 records pinned + 2px border + expect(grid.pinnedRowHeight).toBe(grid.renderedRowHeight + 2); + const expectedHeight = parseInt(grid.height, 10) - grid.pinnedRowHeight - 18 - grid.theadRow.nativeElement.offsetHeight; + expect(grid.calcHeight - expectedHeight).toBeLessThanOrEqual(1); + }); + + it('should keep the scrollbar sizes correct when partially filtering out pinned records', () => { + grid.gridAPI.get_row_by_index(1).pin(); + grid.gridAPI.get_row_by_index(3).pin(); + grid.gridAPI.get_row_by_index(5).pin(); + grid.gridAPI.get_row_by_index(7).pin(); + fix.detectChanges(); + // 4 records pinned + 2px border + expect(grid.pinnedRowHeight).toBe(4 * grid.renderedRowHeight + 2); + let expectedHeight = parseInt(grid.height, 10) - grid.pinnedRowHeight - 18 - grid.theadRow.nativeElement.offsetHeight; + expect(grid.calcHeight - expectedHeight).toBeLessThanOrEqual(1); + + grid.filter('ContactTitle', 'Owner', IgxStringFilteringOperand.instance().condition('contains'), false); + fix.detectChanges(); + + // 2 records pinned + 2px border + expect(grid.pinnedRowHeight).toBe(2 * grid.renderedRowHeight + 2); + expectedHeight = parseInt(grid.height, 10) - grid.pinnedRowHeight - 18 - grid.theadRow.nativeElement.offsetHeight; + expect(grid.calcHeight - expectedHeight).toBeLessThanOrEqual(1); + }); + }); + + describe(' Cell Editing', () => { + + beforeEach(() => { + fix = TestBed.createComponent(GridRowPinningComponent); + fix.detectChanges(); + // enable cell editing for column + grid = fix.componentInstance.instance; + grid.getColumnByName('CompanyName').editable = true; + }); + + it('should enter edit mode for the next editable cell when tabbing.', () => { + const gridContent = GridFunctions.getGridContent(fix); + grid.gridAPI.get_row_by_index(0).pin(); + grid.gridAPI.get_row_by_index(3).pin(); + + const firstEditable = grid.gridAPI.get_cell_by_index(0, 'CompanyName'); + const secondEditable = grid.gridAPI.get_cell_by_index(1, 'CompanyName'); + const thirdEditable = grid.gridAPI.get_cell_by_index(3, 'CompanyName'); + const fourthEditable = grid.gridAPI.get_cell_by_index(5, 'CompanyName'); + + // enter edit mode for pinned row + UIInteractions.simulateDoubleClickAndSelectEvent(firstEditable); + fix.detectChanges(); + + expect(firstEditable.editMode).toBeTruthy(); + + // press tab on edited cell + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + fix.detectChanges(); + + expect(firstEditable.editMode).toBeFalsy(); + expect(secondEditable.editMode).toBeTruthy(); + + // press tab on edited cell + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + fix.detectChanges(); + + expect(secondEditable.editMode).toBeFalsy(); + expect(thirdEditable.editMode).toBeTruthy(); + + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + fix.detectChanges(); + + expect(thirdEditable.editMode).toBeFalsy(); + expect(fourthEditable.editMode).toBeTruthy(); + }); + it('should enter edit mode for the previous editable cell when shift+tabbing.', () => { + const gridContent = GridFunctions.getGridContent(fix); + grid.gridAPI.get_row_by_index(0).pin(); + grid.gridAPI.get_row_by_index(3).pin(); + + const firstEditable = grid.gridAPI.get_cell_by_index(0, 'CompanyName'); + const secondEditable = grid.gridAPI.get_cell_by_index(1, 'CompanyName'); + const thirdEditable = grid.gridAPI.get_cell_by_index(3, 'CompanyName'); + const fourthEditable = grid.gridAPI.get_cell_by_index(5, 'CompanyName'); + + // enter edit mode for unpinned row + UIInteractions.simulateDoubleClickAndSelectEvent(fourthEditable); + fix.detectChanges(); + + expect(fourthEditable.editMode).toBeTruthy(); + // press shift+tab on edited cell + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent, false, true); + fix.detectChanges(); + + expect(fourthEditable.editMode).toBeFalsy(); + expect(thirdEditable.editMode).toBeTruthy(); + + // press shift+tab on edited cell + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent, false, true); + fix.detectChanges(); + + expect(thirdEditable.editMode).toBeFalsy(); + expect(secondEditable.editMode).toBeTruthy(); + + // press shift+tab on edited cell + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent, false, true); + fix.detectChanges(); + + expect(secondEditable.editMode).toBeFalsy(); + expect(firstEditable.editMode).toBeTruthy(); + }); + }); + + describe(' Navigation', () => { + let gridContent: DebugElement; + + beforeEach(() => { + fix = TestBed.createComponent(GridRowPinningComponent); + fix.detectChanges(); + grid = fix.componentInstance.instance; + setupGridScrollDetection(fix, grid); + gridContent = GridFunctions.getGridContent(fix); + }); + + afterEach(() => { + clearGridSubs(); + }); + + it('should navigate to bottom from top pinned row using Ctrl+ArrowDown', async () => { + grid.gridAPI.get_row_by_index(5).pin(); + + const firstRowCell = (grid.gridAPI.get_row_by_index(0).cells as QueryList).toArray()[1]; + UIInteractions.simulateClickAndSelectEvent(firstRowCell); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent, false, false, true); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + const lastRowCell = grid.getRowByIndex(27).cells[1]; + const selectedCell = fix.componentInstance.instance.selectedCells[0]; + // expect(selectedCell).toBe(lastRowCell); + expect(selectedCell.row.index).toBe(lastRowCell.row.index); + expect(selectedCell.column.visibleIndex).toBe(lastRowCell.column.visibleIndex); + }); + + it('should navigate and scroll to first unpinned row from top pinned row using ArrowDown', async () => { + grid.gridAPI.get_row_by_index(5).pin(); + + grid.navigateTo(10); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + const firstRowCell = (grid.gridAPI.get_row_by_index(0).cells as QueryList).toArray()[1]; + UIInteractions.simulateClickAndSelectEvent(firstRowCell); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + const secondRowCell = grid.getRowByIndex(1).cells[1]; + const selectedCell = fix.componentInstance.instance.selectedCells[0]; + expect(selectedCell.row.index).toBe(secondRowCell.row.index); + expect(selectedCell.column.visibleIndex).toBe(secondRowCell.column.visibleIndex); + }); + + it('should navigate to top pinned row from bottom unpinned row without scrolling using Ctrl+ArrowUp', async () => { + grid.gridAPI.get_row_by_index(5).pin(); + + grid.navigateTo(27); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + expect(grid.verticalScrollContainer.getScroll().scrollTop).not.toEqual(0); + + const lastRowCell = (grid.gridAPI.get_row_by_index(27).cells as QueryList).toArray()[1]; + UIInteractions.simulateClickAndSelectEvent(lastRowCell); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent, false, false, true); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + const firstRowCell = grid.getRowByIndex(0).cells[1]; + const selectedCell = fix.componentInstance.instance.selectedCells[0]; + expect(selectedCell.row.index).toBe(firstRowCell.row.index); + expect(selectedCell.column.visibleIndex).toBe(firstRowCell.column.visibleIndex); + expect(grid.verticalScrollContainer.getScroll().scrollTop).not.toEqual(0); + }); + + it('should navigate to top pinned row from first unpinned row using ArrowUp', async () => { + grid.gridAPI.get_row_by_index(5).pin(); + grid.gridAPI.get_row_by_index(1).pin(); + + const thirdRowCell = (grid.gridAPI.get_row_by_index(2).cells as QueryList).toArray()[1]; + UIInteractions.simulateClickAndSelectEvent(thirdRowCell); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + expect(grid.navigation.activeNode.row).toBe(2); + expect(grid.navigation.activeNode.column).toBe(1); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + const secondRowCell = grid.getRowByIndex(1).cells[1]; + const selectedCell = fix.componentInstance.instance.selectedCells[0]; + expect(selectedCell.row.index).toBe(secondRowCell.row.index); + expect(selectedCell.column.visibleIndex).toBe(secondRowCell.column.visibleIndex); + }); + + it('should navigate and scroll to top from bottom pinned row using Ctrl+ArrowUp', async () => { + fix.componentInstance.pinningConfig = { columns: ColumnPinningPosition.Start, rows: RowPinningPosition.Bottom }; + grid.gridAPI.get_row_by_index(5).pin(); + + grid.navigateTo(26); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + const lastRowCell = (grid.gridAPI.get_row_by_index(27).cells as QueryList).toArray()[1]; + UIInteractions.simulateClickAndSelectEvent(lastRowCell); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + expect(grid.navigation.activeNode.row).toBe(27); + expect(grid.navigation.activeNode.column).toBe(1); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent, false, false, true); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + const firstRowCell = grid.getRowByIndex(0).cells[1]; + const selectedCell = fix.componentInstance.instance.selectedCells[0]; + expect(selectedCell.row.index).toBe(firstRowCell.row.index); + expect(selectedCell.column.visibleIndex).toBe(firstRowCell.column.visibleIndex); + }); + + it('should navigate to last unpinned row from bottom pinned row using ArrowUp', async () => { + fix.componentInstance.pinningConfig = { columns: ColumnPinningPosition.Start, rows: RowPinningPosition.Bottom }; + grid.gridAPI.get_row_by_index(5).pin(); + fix.detectChanges(); + + const firstRowCell = (grid.gridAPI.get_row_by_index(27).cells as QueryList).toArray()[1]; + UIInteractions.simulateClickAndSelectEvent(firstRowCell); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + const lastUnpinnedRowCell = grid.getRowByIndex(26).cells[1]; + const selectedCell = fix.componentInstance.instance.selectedCells[0]; + expect(selectedCell.row.index).toBe(lastUnpinnedRowCell.row.index); + expect(selectedCell.column.visibleIndex).toBe(lastUnpinnedRowCell.column.visibleIndex); + }); + + it('should navigate to bottom pinned row from top unpinned row without scrolling using Ctrl+ArrowDown', async () => { + fix.componentInstance.pinningConfig = { columns: ColumnPinningPosition.Start, rows: RowPinningPosition.Bottom }; + grid.gridAPI.get_row_by_index(5).pin(); + + expect(grid.verticalScrollContainer.getScroll().scrollTop).toEqual(0); + + const firstRowCell = (grid.gridAPI.get_row_by_index(0).cells as QueryList).toArray()[1]; + UIInteractions.simulateClickAndSelectEvent(firstRowCell); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent, false, false, true); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + const lastRowCell = grid.getRowByIndex(27).cells[1]; + const selectedCell = fix.componentInstance.instance.selectedCells[0]; + + expect(selectedCell.row.index).toBe(lastRowCell.row.index); + expect(selectedCell.column.visibleIndex).toBe(lastRowCell.column.visibleIndex); + expect(grid.verticalScrollContainer.getScroll().scrollTop).toEqual(0); + }); + + it('should navigate to bottom pinned row from last unpinned row using ArrowDown', async () => { + fix.componentInstance.pinningConfig = { columns: ColumnPinningPosition.Start, rows: RowPinningPosition.Bottom }; + grid.gridAPI.get_row_by_index(5).pin(); + grid.gridAPI.get_row_by_index(1).pin(); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + grid.navigateTo(26); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + const firstRowCell = (grid.gridAPI.get_row_by_index(26).cells as QueryList).toArray()[1]; + UIInteractions.simulateClickAndSelectEvent(firstRowCell); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + expect(grid.navigation.activeNode.row).toBe(26); + expect(grid.navigation.activeNode.column).toBe(1); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + const lastRowCell = grid.getRowByIndex(27).cells[1]; + const selectedCell = fix.componentInstance.instance.selectedCells[0]; + expect(selectedCell.row.index).toBe(lastRowCell.row.index); + expect(selectedCell.column.visibleIndex).toBe(lastRowCell.column.visibleIndex); + }); + + it('should navigate down from pinned to unpinned row when there are filtered out pinned rows', async () => { + grid.gridAPI.get_row_by_index(5).pin(); + grid.gridAPI.get_row_by_index(1).pin(); + grid.filter('ID', 'B', IgxStringFilteringOperand.instance().condition('contains'), false); + fix.detectChanges(); + + const firstRowCell = (grid.gridAPI.get_row_by_index(0).cells as QueryList).toArray()[1]; + UIInteractions.simulateClickAndSelectEvent(firstRowCell); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + const lastRowCell = grid.getRowByIndex(1).cells[1]; + const selectedCell = fix.componentInstance.instance.selectedCells[0]; + expect(selectedCell.row.index).toBe(lastRowCell.row.index); + expect(selectedCell.column.visibleIndex).toBe(lastRowCell.column.visibleIndex); + }); + }); + + describe(' Initial pinning', () => { + beforeEach(() => { + fix = TestBed.createComponent(GridRowPinningWithInitialPinningComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid1; + }); + + it('should pin rows on OnInit.', () => { + fix.detectChanges(); + expect(grid.hasPinnedRecords).toBeTrue(); + }); + }); + + describe('Conditional row styling', () => { + + beforeEach(() => { + fix = TestBed.createComponent(GridRowConditionalStylingComponent); + grid = fix.componentInstance.grid; + fix.detectChanges(); + }); + + it('Should be able to conditionally style rows. Check is the class present in the row native element class list', () => { + fix.detectChanges(); + const firstRow = grid.gridAPI.get_row_by_index(0); + const fourthRow = grid.gridAPI.get_row_by_index(3); + + expect(firstRow).toBeDefined(); + expect(firstRow.nativeElement.classList.contains('eventRow')).toBeTrue(); + expect(firstRow.nativeElement.classList.contains('oddRow')).toBeFalse(); + expect(fourthRow.nativeElement.classList.contains('eventRow')).toBeFalse(); + expect(fourthRow.nativeElement.classList.contains('oddRow')).toBeTrue(); + }); + + it('Should apply custom CSS bindings to the grid cells/rows. Check the style attribute to match each binding', () => { + const evenColStyles = { + background: (row) => row.index % 2 === 0 ? 'gray' : 'white', + animation: '0.75s popin' + }; + + fix.detectChanges(); + grid.rowStyles = evenColStyles; + grid.notifyChanges(true); + fix.detectChanges(); + const firstRow = grid.gridAPI.get_row_by_index(0); + const fourthRow = grid.gridAPI.get_row_by_index(3); + + const expectedEvenStyles = 'background: gray; animation: 0.75s ease 0s 1 normal none running popin;'; + const expectedOddStyles = 'background: white; animation: 0.75s ease 0s 1 normal none running popin;'; + + expect(firstRow.nativeElement.style.cssText).toEqual(expectedEvenStyles); + expect(fourthRow.nativeElement.style.cssText).toEqual(expectedOddStyles); + }); + + }); +}); + +@Component({ + template: ` + + @if (paging) { + + } + + `, + imports: [IgxGridComponent, IgxPaginatorComponent] +}) +export class GridRowPinningComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public instance: IgxGridComponent; + public paging = false; + + public data: any[] = SampleTestData.contactInfoDataFull(); + public pinningConfig: IPinningConfig = { columns: ColumnPinningPosition.Start, rows: RowPinningPosition.Top }; + + public createSimpleData(count: number) { + this.data = Array(count).fill({}).map((x, idx) => x = { idx: idx + 1 }); + } +} + +@Component({ + template: ` + + @for (group of colGroups; track group.group) { + + @for (col of group.columns; track col.field) { + + } + + } + + `, + imports: [IgxGridComponent, IgxColumnLayoutComponent, IgxColumnComponent] +}) +export class GridRowPinningWithMRLComponent extends GridRowPinningComponent { + public cols: Array = [ + { field: 'ID', rowStart: 1, colStart: 1 }, + { field: 'CompanyName', rowStart: 1, colStart: 2 }, + { field: 'ContactName', rowStart: 1, colStart: 3 }, + { field: 'ContactTitle', rowStart: 2, colStart: 1, rowEnd: 4, colEnd: 4 }, + ]; + public colGroups = [ + { + group: 'group1', + columns: this.cols + } + ]; +} + +@Component({ + template: ` + + +
    +
    Country: {{dataItem.Country}}
    +
    City: {{dataItem.City}}
    +
    Address: {{dataItem.Address}}
    +
    +
    +
    + `, + imports: [IgxGridComponent, IgxGridDetailTemplateDirective] +}) +export class GridRowPinningWithMDVComponent extends GridRowPinningComponent { } + + +@Component({ + template: ` + + + `, + imports: [IgxGridComponent] +}) +export class GridRowPinningWithTransactionsComponent extends GridRowPinningComponent { } + +@Component({ + template: ` + + + `, + imports: [IgxGridComponent] +}) +export class GridRowPinningWithInitialPinningComponent implements OnInit { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public grid1: IgxGridComponent; + + public data: any[] = SampleTestData.contactInfoDataFull(); + public pinningConfig: IPinningConfig = { columns: ColumnPinningPosition.Start, rows: RowPinningPosition.Top }; + public ngOnInit(): void { + this.grid1.pinRow(this.data[0].ID); + } +} diff --git a/projects/igniteui-angular/grids/grid/src/grid-row-selection.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-row-selection.spec.ts new file mode 100644 index 00000000000..1b71f444157 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid-row-selection.spec.ts @@ -0,0 +1,2456 @@ +import { TestBed, fakeAsync, tick, waitForAsync, ComponentFixture } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxGridComponent } from './grid.component'; +import { wait, UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { + RowSelectionComponent, + SelectionWithScrollsComponent, + SingleRowSelectionComponent, + RowSelectionWithoutPrimaryKeyComponent, + SelectionWithTransactionsComponent, + GridCustomSelectorsComponent +} from '../../../test-utils/grid-samples.spec'; +import { GridFunctions, GridSelectionFunctions } from '../../../test-utils/grid-functions.spec'; +import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; +import { GridSelectionMode, IRowSelectionEventArgs } from 'igniteui-angular/grids/core'; +import { FilteringExpressionsTree, FilteringLogic, IgxBooleanFilteringOperand, IgxNumberFilteringOperand, IgxStringFilteringOperand, SortingDirection } from 'igniteui-angular/core'; + +const DEBOUNCETIME = 30; +const SCROLL_DEBOUNCETIME = 100; + + +describe('IgxGrid - Row Selection #grid', () => { + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + RowSelectionComponent, + SelectionWithScrollsComponent, + RowSelectionWithoutPrimaryKeyComponent, + SingleRowSelectionComponent, + SelectionWithTransactionsComponent, + GridCustomSelectorsComponent + ] + }).compileComponents(); + })); + + describe('Base tests', () => { + let fix: ComponentFixture; + let grid: IgxGridComponent; + const gridData = SampleTestData.foodProductDataExtended(); + + beforeEach(() => { + fix = TestBed.createComponent(RowSelectionComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + }); + + it('Should have checkbox on each row', async () => { + // There can be no virtual scrolling on this grid with its preset height + grid.height = '300px'; + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowHasCheckbox(fix); + GridSelectionFunctions.verifySelectionCheckBoxesAlignment(grid); + + for (const row of grid.rowList.toArray()) { + GridSelectionFunctions.verifyRowHasCheckbox(row.nativeElement); + } + + GridFunctions.scrollTop(grid, 500); + await wait(SCROLL_DEBOUNCETIME); + fix.detectChanges(); + + // Verify the grid has scrolled + expect(grid.rowList.first.cells.first.value).not.toBe(1); + GridSelectionFunctions.verifySelectionCheckBoxesAlignment(grid); + + for (const row of grid.rowList.toArray()) { + GridSelectionFunctions.verifyRowHasCheckbox(row.nativeElement); + } + }); + + it('Should persist through scrolling vertical', async () => { + // There can be no virtual scrolling on this grid with its preset height + grid.height = '300px'; + fix.detectChanges(); + + const selectedRow = grid.gridAPI.get_row_by_index(0); + expect(selectedRow).toBeDefined(); + + GridSelectionFunctions.verifyRowSelected(selectedRow, false); + + selectedRow.onRowSelectorClick(UIInteractions.getMouseEvent('click')); + await wait(); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + GridSelectionFunctions.verifyRowSelected(selectedRow); + expect(grid.selectedRows).toEqual([1]); + + GridFunctions.scrollTop(grid, 500); + await wait(SCROLL_DEBOUNCETIME); + fix.detectChanges(); + + expect(grid.selectedRows).toEqual([1]); + GridSelectionFunctions.verifyRowSelected(grid.rowList.first, false); + + GridFunctions.scrollTop(grid, 0); + await wait(SCROLL_DEBOUNCETIME); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + GridSelectionFunctions.verifyRowSelected(selectedRow); + expect(grid.selectedRows).toEqual([1]); + }); + + it('Should have correct checkboxes position when scroll left', (async () => { + grid.width = '300px'; + fix.detectChanges(); + GridSelectionFunctions.verifySelectionCheckBoxesAlignment(grid); + + GridFunctions.scrollLeft(grid, 1000); + await wait(SCROLL_DEBOUNCETIME); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectionCheckBoxesAlignment(grid); + + GridFunctions.scrollLeft(grid, 0); + await wait(SCROLL_DEBOUNCETIME); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectionCheckBoxesAlignment(grid); + })); + + it('Header checkbox should select/deselect all rows', () => { + const allRows = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]; + const allRowsArray = [gridData[0], gridData[1], gridData[2], gridData[3], gridData[4], gridData[5], gridData[6], gridData[7], gridData[8], gridData[9], + gridData[10], gridData[11], gridData[12], gridData[13], gridData[14], gridData[15], gridData[16], gridData[17], gridData[18]]; + spyOn(grid.rowSelectionChanging, 'emit').and.callThrough(); + GridSelectionFunctions.clickHeaderRowCheckbox(fix); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + GridSelectionFunctions.verifyRowsArraySelected(grid.rowList.toArray()); + expect(grid.selectedRows).toEqual(allRows); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(1); + let args: IRowSelectionEventArgs = { + added: allRowsArray, + cancel: false, + event: jasmine.anything() as any, + newSelection: allRowsArray, + oldSelection: [], + removed: [], + allRowsSelected: true, + owner: grid + }; + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledWith(args); + + GridSelectionFunctions.clickHeaderRowCheckbox(fix); + fix.detectChanges(); + + expect(grid.selectedRows).toEqual([]); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, false); + GridSelectionFunctions.verifyRowsArraySelected(grid.rowList.toArray(), false); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(2); + args = { + oldSelection: allRowsArray, + newSelection: [], + added: [], + removed: allRowsArray, + event: jasmine.anything() as any, + cancel: false, + allRowsSelected: false, + owner: grid + }; + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledWith(args); + }); + + it('Header checkbox should deselect all rows - scenario when clicking first row, while header checkbox is clicked', () => { + const firstRow = grid.gridAPI.get_row_by_index(0); + spyOn(grid.rowSelectionChanging, 'emit').and.callThrough(); + + GridSelectionFunctions.clickHeaderRowCheckbox(fix); + fix.detectChanges(); + + expect(firstRow.selected).toBeTruthy(); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + + GridSelectionFunctions.clickRowCheckbox(firstRow); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + + GridSelectionFunctions.clickRowCheckbox(firstRow); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + + GridSelectionFunctions.clickHeaderRowCheckbox(fix); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(4); + }); + + it('Checkbox should select/deselect row', () => { + const firstRow = grid.gridAPI.get_row_by_index(0); + const secondRow = grid.gridAPI.get_row_by_index(1); + spyOn(grid.rowSelectionChanging, 'emit').and.callThrough(); + + GridSelectionFunctions.clickRowCheckbox(firstRow); + fix.detectChanges(); + + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(1); + let args: IRowSelectionEventArgs = { + added: [gridData[0]], + cancel: false, + event: jasmine.anything() as any, + newSelection: [gridData[0]], + oldSelection: [], + removed: [], + allRowsSelected: false, + owner: grid + }; + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledWith(args); + + expect(grid.selectedRows).toEqual([1]); + GridSelectionFunctions.verifyRowSelected(firstRow); + GridSelectionFunctions.verifyRowSelected(secondRow, false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + + GridSelectionFunctions.clickRowCheckbox(secondRow); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + GridSelectionFunctions.verifyRowSelected(secondRow); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + expect(grid.selectedRows).toEqual([1, 2]); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(2); + args = { + added: [gridData[1]], + cancel: false, + event: jasmine.anything() as any, + newSelection: [gridData[0], gridData[1]], + oldSelection: [gridData[0]], + removed: [], + allRowsSelected: false, + owner: grid + }; + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledWith(args); + + GridSelectionFunctions.clickRowCheckbox(firstRow); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyRowSelected(secondRow); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + expect(grid.selectedRows).toEqual([2]); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(3); + args = { + added: [], + cancel: false, + event: jasmine.anything() as any, + newSelection: [gridData[1]], + oldSelection: [gridData[0], gridData[1]], + removed: [gridData[0]], + allRowsSelected: false, + owner: grid + }; + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledWith(args); + + GridSelectionFunctions.clickRowCheckbox(secondRow); + fix.detectChanges(); + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyRowSelected(secondRow, false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix); + expect(grid.selectedRows).toEqual([]); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(4); + args = { + added: [], + cancel: false, + event: jasmine.anything() as any, + newSelection: [], + oldSelection: [gridData[1]], + removed: [gridData[1]], + allRowsSelected: false, + owner: grid + }; + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledWith(args); + }); + + it('Should display the newly selected rows in correct order', () => { + const firstRow = grid.gridAPI.get_row_by_index(0); + const secondRow = grid.gridAPI.get_row_by_index(1); + const thirdRow = grid.gridAPI.get_row_by_index(2); + spyOn(grid.rowSelectionChanging, 'emit').and.callThrough(); + + GridSelectionFunctions.clickRowCheckbox(thirdRow); + fix.detectChanges(); + + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(1); + let args: IRowSelectionEventArgs = { + added: [gridData[2]], + cancel: false, + event: jasmine.anything() as any, + newSelection: [gridData[2]], + oldSelection: [], + removed: [], + allRowsSelected: false, + owner: grid + }; + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledWith(args); + + GridSelectionFunctions.clickRowCheckbox(firstRow); + fix.detectChanges(); + + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(2); + args = { + added: [gridData[0]], + cancel: false, + event: jasmine.anything() as any, + newSelection: [gridData[2], gridData[0]], + oldSelection: [gridData[2]], + removed: [], + allRowsSelected: false, + owner: grid + }; + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledWith(args); + + GridSelectionFunctions.clickRowCheckbox(secondRow); + fix.detectChanges(); + + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(3); + args = { + added: [gridData[1]], + cancel: false, + event: jasmine.anything() as any, + newSelection: [gridData[2], gridData[0], gridData[1]], + oldSelection: [gridData[2], gridData[0]], + removed: [], + allRowsSelected: false, + owner: grid + }; + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledWith(args); + + expect(grid.selectedRows.length).toEqual(3); + GridSelectionFunctions.verifyRowSelected(firstRow); + GridSelectionFunctions.verifyRowSelected(secondRow); + GridSelectionFunctions.verifyRowSelected(thirdRow); + }); + + it('Should maintain selected rows through data change and new selection', async () => { + const firstRow = grid.gridAPI.get_row_by_index(0); + const secondRow = grid.gridAPI.get_row_by_index(1); + spyOn(grid.rowSelectionChanging, 'emit'); + + // second detection needed to enable scroll after first runs ngAfterViewInit... + fix.detectChanges(); + + GridFunctions.scrollTop(grid, 500); + await wait(SCROLL_DEBOUNCETIME); + fix.detectChanges(); + + let row15 = grid.gridAPI.get_row_by_index(14); + const row15Data = row15.data; + GridSelectionFunctions.clickRowCheckbox(row15); + fix.detectChanges(); + + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(1); + let args: IRowSelectionEventArgs = { + added: [gridData[14]], + cancel: false, + event: jasmine.anything() as any, + newSelection: [gridData[14]], + oldSelection: [], + removed: [], + allRowsSelected: false, + owner: grid + }; + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledWith(args); + + // halve data: + grid.data = grid.data.slice(0, 10); + fix.detectChanges(); + + GridSelectionFunctions.clickRowCheckbox(firstRow); + fix.detectChanges(); + + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(2); + // row 15 no longer in data, expect partial: + args = { + ...args, + added: [gridData[0]], + newSelection: [{ [grid.primaryKey]: row15Data[grid.primaryKey]}, gridData[0]], + oldSelection: [{ [grid.primaryKey]: row15Data[grid.primaryKey]}], + }; + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledWith(args); + + // restore data: + grid.data = SampleTestData.foodProductDataExtended(); + fix.detectChanges(); + + GridSelectionFunctions.clickRowCheckbox(secondRow); + fix.detectChanges(); + + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(3); + args = { + ...args, + added: [gridData[1]], + newSelection: [gridData[14], gridData[0], gridData[1]], + oldSelection: [gridData[14], gridData[0]], + }; + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledWith(args); + + expect(grid.selectedRows.length).toEqual(3); + GridSelectionFunctions.verifyRowSelected(firstRow); + GridSelectionFunctions.verifyRowSelected(secondRow); + + GridFunctions.scrollTop(grid, 500); + await wait(SCROLL_DEBOUNCETIME); + fix.detectChanges(); + + row15 = grid.gridAPI.get_row_by_index(14); + GridSelectionFunctions.verifyRowSelected(row15); + }); + + it('Should select the row with mouse click ', () => { + expect(grid.selectRowOnClick).toBe(true); + spyOn(grid.rowSelectionChanging, 'emit').and.callThrough(); + const firstRow = grid.gridAPI.get_row_by_index(1); + const secondRow = grid.gridAPI.get_row_by_index(2); + const mockEvent = new MouseEvent('click'); + + firstRow.nativeElement.dispatchEvent(mockEvent); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + expect(grid.selectedRows).toEqual([2]); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(1); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledWith({ + added: [gridData[1]], + cancel: false, + event: mockEvent, + newSelection: [gridData[1]], + oldSelection: [], + removed: [], + allRowsSelected: false, + owner: grid + }); + + // Click again on same row + UIInteractions.simulateClickEvent(firstRow.nativeElement); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(1); + + // Click on a different row + secondRow.nativeElement.dispatchEvent(mockEvent); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyRowSelected(secondRow); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + expect(grid.selectedRows).toEqual([3]); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(2); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledWith({ + added: [gridData[2]], + cancel: false, + event: mockEvent, + newSelection: [gridData[2]], + oldSelection: [gridData[1]], + removed: [gridData[1]], + allRowsSelected: false, + owner: grid + }); + }); + it('Should select the row only on checkbox click when selectRowOnClick has value false', () => { + grid.selectRowOnClick = false; + fix.detectChanges(); + + expect(grid.selectRowOnClick).toBe(false); + grid.hideRowSelectors = false; + + spyOn(grid.rowSelectionChanging, 'emit').and.callThrough(); + const firstRow = grid.gridAPI.get_row_by_index(1); + const secondRow = grid.gridAPI.get_row_by_index(2); + + // Click on the first row checkbox + GridSelectionFunctions.clickRowCheckbox(firstRow); + fix.detectChanges(); + GridSelectionFunctions.verifyRowSelected(firstRow); + expect(grid.selectedRows).toEqual([2]); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(1); + + // Click on the second row + UIInteractions.simulateClickEvent(secondRow.nativeElement); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + GridSelectionFunctions.verifyRowSelected(secondRow, false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(1); + }); + it('Should select multiple rows with clicking and holding Ctrl', () => { + expect(grid.selectRowOnClick).toBe(true); + spyOn(grid.rowSelectionChanging, 'emit').and.callThrough(); + const firstRow = grid.gridAPI.get_row_by_index(2); + const secondRow = grid.gridAPI.get_row_by_index(0); + + UIInteractions.simulateClickEvent(firstRow.nativeElement); + fix.detectChanges(); + + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifyRowSelected(firstRow); + + // Click on a different row + UIInteractions.simulateClickEvent(secondRow.nativeElement, false, true); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + GridSelectionFunctions.verifyRowSelected(secondRow); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(2); + }); + it('Should deselect selected row with clicking and holding Ctrl', () => { + expect(grid.selectRowOnClick).toBe(true); + spyOn(grid.rowSelectionChanging, 'emit').and.callThrough(); + const firstRow = grid.gridAPI.get_row_by_index(2); + const secondRow = grid.gridAPI.get_row_by_index(0); + + UIInteractions.simulateClickEvent(firstRow.nativeElement); + fix.detectChanges(); + + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifyRowSelected(firstRow); + + // Click again on this row holding Ctrl + UIInteractions.simulateClickEvent(firstRow.nativeElement, false, true); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(2); + + // Click on the first and second row + UIInteractions.simulateClickEvent(firstRow.nativeElement); + fix.detectChanges(); + + UIInteractions.simulateClickEvent(secondRow.nativeElement, false, true); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + GridSelectionFunctions.verifyRowSelected(secondRow); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(4); + + // Click again on the second row + UIInteractions.simulateClickEvent(secondRow.nativeElement, false, true); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + GridSelectionFunctions.verifyRowSelected(secondRow, false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(5); + }); + it('Should NOT select rows with clicking and holding Ctrl when selectRowOnClick has false value', () => { + grid.selectRowOnClick = false; + grid.hideRowSelectors = false; + spyOn(grid.rowSelectionChanging, 'emit').and.callThrough(); + const firstRow = grid.gridAPI.get_row_by_index(2); + const secondRow = grid.gridAPI.get_row_by_index(0); + const thirdRow = grid.gridAPI.get_row_by_index(4); + + // Click on the first row checkbox + GridSelectionFunctions.clickRowCheckbox(firstRow); + fix.detectChanges(); + GridSelectionFunctions.verifyRowSelected(firstRow); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(1); + + // Click on the second row checkbox + GridSelectionFunctions.clickRowCheckbox(secondRow); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + GridSelectionFunctions.verifyRowSelected(secondRow); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(2); + + // Click + Ctrl on the third row + UIInteractions.simulateClickEvent(thirdRow.nativeElement); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + GridSelectionFunctions.verifyRowSelected(secondRow); + GridSelectionFunctions.verifyRowSelected(thirdRow, false); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(2); + }); + it('Should select multiple rows with clicking Space on a cell', (async () => { + grid.tbody.nativeElement.focus(); + fix.detectChanges(); + + spyOn(grid.rowSelectionChanging, 'emit').and.callThrough(); + const firstRow = grid.gridAPI.get_row_by_index(0); + const secondRow = grid.gridAPI.get_row_by_index(1); + let cell = grid.gridAPI.get_cell_by_index(0, 'ProductName'); + + UIInteractions.simulateClickAndSelectEvent(cell); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellSelected(cell); + GridSelectionFunctions.verifyRowSelected(firstRow); + + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifyRowSelected(firstRow); + GridSelectionFunctions.verifyRowSelected(secondRow, false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', cell.nativeElement, true); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + + cell = grid.gridAPI.get_cell_by_index(1, 'ProductName'); + GridSelectionFunctions.verifyCellSelected(cell); + GridSelectionFunctions.verifyRowSelected(firstRow); + + // Click Space on the cell + GridFunctions.simulateGridContentKeydown(fix, 'space'); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(2); + GridSelectionFunctions.verifyRowSelected(firstRow); + GridSelectionFunctions.verifyRowSelected(secondRow); + + // Click again Space on the cell + GridFunctions.simulateGridContentKeydown(fix, 'space'); + fix.detectChanges(); + await wait(DEBOUNCETIME); + + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(3); + GridSelectionFunctions.verifyRowSelected(firstRow); + GridSelectionFunctions.verifyRowSelected(secondRow, false); + })); + + it('Should select multiple rows with Shift + Click', () => { + expect(grid.selectRowOnClick).toBe(true); + spyOn(grid.rowSelectionChanging, 'emit').and.callThrough(); + const firstRow = grid.gridAPI.get_row_by_index(1); + const secondRow = grid.gridAPI.get_row_by_index(4); + const mockEvent = new MouseEvent('click', { shiftKey: true }); + + UIInteractions.simulateClickEvent(firstRow.nativeElement); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + + // Click on other row holding Shift key + secondRow.nativeElement.dispatchEvent(mockEvent); + fix.detectChanges(); + + expect(grid.selectedRows).toEqual([2, 3, 4, 5]); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(2); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledWith({ + added: [gridData[2], gridData[3], gridData[4]], + cancel: false, + event: mockEvent, + newSelection: [gridData[1], gridData[2], gridData[3], gridData[4]], + oldSelection: [gridData[1]], + removed: [], + allRowsSelected: false, + owner: grid + }); + + for (let index = 1; index < 5; index++) { + const row = grid.gridAPI.get_row_by_index(index); + GridSelectionFunctions.verifyRowSelected(row); + } + }); + + it('Should select the correct rows with Shift + Click when grouping is activated', () => { + expect(grid.selectRowOnClick).toBe(true); + spyOn(grid.rowSelectionChanging, 'emit').and.callThrough(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + + fix.detectChanges(); + + const firstGroupRow = grid.gridAPI.get_row_by_index(1); + const lastGroupRow = grid.gridAPI.get_row_by_index(4); + + // Clicking on the first row within a group + UIInteractions.simulateClickEvent(firstGroupRow.nativeElement); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstGroupRow); + + // Simulate Shift+Click on a row within another group + const mockEvent = new MouseEvent('click', { shiftKey: true }); + lastGroupRow.nativeElement.dispatchEvent(mockEvent); + fix.detectChanges(); + + expect(grid.selectedRows).toEqual([5, 14, 8]); // ids + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(2); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledWith({ + added: [grid.dataView[2], grid.dataView[4]], + cancel: false, + event: jasmine.anything(), + newSelection: [grid.dataView[1], grid.dataView[2], grid.dataView[4]], + oldSelection: [grid.dataView[1]], + removed: [], + allRowsSelected: false, + owner: grid + }); + + const expectedSelectedRowIds = [5, 14, 8]; + grid.dataView.forEach((rowData, index) => { + if (expectedSelectedRowIds.includes(rowData.ProductID)) { + const row = grid.gridAPI.get_row_by_index(index); + GridSelectionFunctions.verifyRowSelected(row); + } + }); + + }); + + it('Should NOT select multiple rows with Shift + Click when selectRowOnClick has false value', () => { + grid.selectRowOnClick = false; + spyOn(grid.rowSelectionChanging, 'emit').and.callThrough(); + // Shift + Click + const firstRow = grid.gridAPI.get_row_by_index(1); + const secondRow = grid.gridAPI.get_row_by_index(4); + + UIInteractions.simulateClickEvent(firstRow.nativeElement, false, false); + fix.detectChanges(); + + expect(grid.selectedRows).toEqual([]); + GridSelectionFunctions.verifyRowSelected(firstRow, false); + + // Click on other row holding Shift key + UIInteractions.simulateClickEvent(secondRow.nativeElement, true, false); + fix.detectChanges(); + + expect(grid.selectedRows).toEqual([]); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifyRowSelected(secondRow, false); + for (let index = 1; index < 4; index++) { + const row = grid.gridAPI.get_row_by_index(index); + GridSelectionFunctions.verifyRowSelected(row, false); + } + }); + + it('Should hide/show checkboxes when change hideRowSelectors', () => { + const firstRow = grid.gridAPI.get_row_by_index(1); + + expect(grid.hideRowSelectors).toBe(false); + + firstRow.onRowSelectorClick(UIInteractions.getMouseEvent('click')); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + + grid.hideRowSelectors = true; + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, true, false); + GridSelectionFunctions.verifyHeaderRowHasCheckbox(fix, false, false); + GridSelectionFunctions.verifyRowHasCheckbox(firstRow.nativeElement, false, false); + + grid.hideRowSelectors = false; + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + GridSelectionFunctions.verifyHeaderRowHasCheckbox(fix); + GridSelectionFunctions.verifyRowHasCheckbox(firstRow.nativeElement); + }); + + it('Should be able to change RowSelection to none', () => { + const firstRow = grid.gridAPI.get_row_by_index(0); + expect(grid.rowSelection).toEqual(GridSelectionMode.multiple); + + grid.selectRows([1]); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + + grid.rowSelection = GridSelectionMode.none; + fix.detectChanges(); + + expect(grid.rowSelection).toEqual(GridSelectionMode.none); + GridSelectionFunctions.verifyRowSelected(firstRow, false, false); + GridSelectionFunctions.verifyHeaderRowHasCheckbox(fix, false, false); + GridSelectionFunctions.verifyRowHasCheckbox(firstRow.nativeElement, false, false); + + // Click on a row + firstRow.onRowSelectorClick(UIInteractions.getMouseEvent('click')); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false, false); + }); + + it('Should be able to change RowSelection to single', () => { + const firstRow = grid.gridAPI.get_row_by_index(0); + const secondRow = grid.gridAPI.get_row_by_index(1); + expect(grid.rowSelection).toEqual(GridSelectionMode.multiple); + + grid.selectRows([1]); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + + grid.rowSelection = GridSelectionMode.single; + fix.detectChanges(); + + expect(grid.rowSelection).toEqual(GridSelectionMode.single); + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyHeaderRowHasCheckbox(fix, false); + GridSelectionFunctions.verifyRowHasCheckbox(firstRow.nativeElement); + GridSelectionFunctions.verifySelectionCheckBoxesAlignment(grid); + + // Click on a row + UIInteractions.simulateClickEvent(firstRow.nativeElement); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + + // Click on another row holding Ctrl + UIInteractions.simulateClickEvent(secondRow.nativeElement, false, true); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(secondRow); + GridSelectionFunctions.verifyRowSelected(firstRow, false); + }); + + it('Should be able to cancel rowSelectionChanging event', () => { + const firstRow = grid.gridAPI.get_row_by_index(0); + grid.rowSelectionChanging.subscribe((e: IRowSelectionEventArgs) => { + e.cancel = true; + }); + + // Click on a row + UIInteractions.simulateClickEvent(firstRow.nativeElement); + fix.detectChanges(); + GridSelectionFunctions.verifyRowSelected(firstRow, false); + + // Click on a row checkbox + GridSelectionFunctions.clickRowCheckbox(firstRow); + fix.detectChanges(); + GridSelectionFunctions.verifyRowSelected(firstRow, false); + + // Click on header checkbox + GridSelectionFunctions.clickHeaderRowCheckbox(fix); + fix.detectChanges(); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix); + GridSelectionFunctions.verifyRowSelected(firstRow, false); + + // Select rows from API + grid.selectRows([2, 3]); + fix.detectChanges(); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + GridSelectionFunctions.verifyRowSelected(grid.gridAPI.get_row_by_index(1)); + GridSelectionFunctions.verifyRowSelected(grid.gridAPI.get_row_by_index(2)); + + // Click on header checkbox + GridSelectionFunctions.clickHeaderRowCheckbox(fix); + fix.detectChanges(); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyRowSelected(grid.gridAPI.get_row_by_index(1)); + GridSelectionFunctions.verifyRowSelected(grid.gridAPI.get_row_by_index(2)); + + // Select all rows from API + grid.selectAllRows(); + fix.detectChanges(); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + GridSelectionFunctions.verifyRowSelected(firstRow); + GridSelectionFunctions.verifyRowSelected(grid.gridAPI.get_row_by_index(1)); + GridSelectionFunctions.verifyRowSelected(grid.gridAPI.get_row_by_index(2)); + + // Click on header checkbox + GridSelectionFunctions.clickHeaderRowCheckbox(fix); + fix.detectChanges(); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + GridSelectionFunctions.verifyRowSelected(firstRow); + GridSelectionFunctions.verifyRowSelected(grid.gridAPI.get_row_by_index(1)); + GridSelectionFunctions.verifyRowSelected(grid.gridAPI.get_row_by_index(2)); + }); + + it('Should be able to programmatically overwrite the selection using rowSelectionChanging event', () => { + const firstRow = grid.gridAPI.get_row_by_index(0); + const secondRow = grid.gridAPI.get_row_by_index(1); + const thirdRow = grid.gridAPI.get_row_by_index(2); + grid.rowSelectionChanging.subscribe((e: IRowSelectionEventArgs) => { + if (e.added.length > 0 && (e.added[0].ProductID) % 2 === 0) { + e.newSelection = e.oldSelection || []; + } + }); + + GridSelectionFunctions.verifyRowsArraySelected([firstRow, secondRow, thirdRow], false); + + GridSelectionFunctions.clickRowCheckbox(firstRow); + fix.detectChanges(); + + expect(firstRow.selected).toBeTruthy(); + expect(secondRow.selected).toBeFalsy(); + expect(thirdRow.selected).toBeFalsy(); + + GridSelectionFunctions.clickRowCheckbox(firstRow); + GridSelectionFunctions.clickRowCheckbox(secondRow); + fix.detectChanges(); + + expect(firstRow.selected).toBeFalsy(); + expect(secondRow.selected).toBeFalsy(); + GridSelectionFunctions.verifyRowsArraySelected([firstRow, secondRow, thirdRow], false); + }); + + it('ARIA support', () => { + const firstRow = grid.gridAPI.get_row_by_index(0).nativeElement; + const headerCheckbox = GridSelectionFunctions.getRowCheckboxInput(GridSelectionFunctions.getHeaderRow(fix)); + + expect(firstRow.getAttribute('aria-selected')).toMatch('false'); + expect(headerCheckbox.getAttribute('aria-checked')).toMatch('false'); + expect(headerCheckbox.getAttribute('aria-label')).toMatch('Select all'); + + GridSelectionFunctions.clickHeaderRowCheckbox(fix); + fix.detectChanges(); + + expect(firstRow.getAttribute('aria-selected')).toMatch('true'); + expect(headerCheckbox.getAttribute('aria-checked')).toMatch('true'); + expect(headerCheckbox.getAttribute('aria-label')).toMatch('Deselect all'); + + GridSelectionFunctions.clickHeaderRowCheckbox(fix); + fix.detectChanges(); + + expect(firstRow.getAttribute('aria-selected')).toMatch('false'); + expect(headerCheckbox.getAttribute('aria-checked')).toMatch('false'); + expect(headerCheckbox.getAttribute('aria-label')).toMatch('Select all'); + }); + + it('ARIA support when there is filtered data', async () => { + grid.filter('ProductName', 'Ca', IgxStringFilteringOperand.instance().condition('contains'), true); + fix.detectChanges(); + + const firstRow = grid.gridAPI.get_row_by_index(0).nativeElement; + const headerCheckbox = GridSelectionFunctions.getRowCheckboxInput(GridSelectionFunctions.getHeaderRow(fix)); + expect(firstRow.getAttribute('aria-selected')).toMatch('false'); + expect(headerCheckbox.getAttribute('aria-checked')).toMatch('false'); + expect(headerCheckbox.getAttribute('aria-label')).toMatch('Select all filtered'); + + grid.onHeaderSelectorClick(UIInteractions.getMouseEvent('click')); + fix.detectChanges(); + + expect(firstRow.getAttribute('aria-selected')).toMatch('true'); + expect(headerCheckbox.getAttribute('aria-checked')).toMatch('true'); + expect(headerCheckbox.getAttribute('aria-label')).toMatch('Deselect all filtered'); + + grid.onHeaderSelectorClick(UIInteractions.getMouseEvent('click')); + await wait(); + fix.detectChanges(); + + expect(firstRow.getAttribute('aria-selected')).toMatch('false'); + expect(headerCheckbox.getAttribute('aria-checked')).toMatch('false'); + expect(headerCheckbox.getAttribute('aria-label')).toMatch('Select all filtered'); + + grid.clearFilter(); + fix.detectChanges(); + + expect(firstRow.getAttribute('aria-selected')).toMatch('false'); + expect(headerCheckbox.getAttribute('aria-checked')).toMatch('false'); + expect(headerCheckbox.getAttribute('aria-label')).toMatch('Select all'); + }); + }); + + describe('RowSelection none', () => { + let fix: ComponentFixture; + let grid: IgxGridComponent; + + beforeEach(() => { + fix = TestBed.createComponent(SelectionWithScrollsComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + }); + + it('Change RowSelection to multiple ', fakeAsync(() => { + GridSelectionFunctions.verifyHeaderRowHasCheckbox(fix, false, false); + GridSelectionFunctions.verifyRowHasCheckbox(grid.gridAPI.get_row_by_index(0).nativeElement, false, false); + + grid.selectRows([475]); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(grid.gridAPI.get_row_by_index(0), true, false); + + grid.rowSelection = GridSelectionMode.multiple; + fix.detectChanges(); + tick(100); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectionCheckBoxesAlignment(grid); + GridSelectionFunctions.verifyRowSelected(grid.gridAPI.get_row_by_index(0), false, false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix); + GridSelectionFunctions.verifyHeaderRowHasCheckbox(fix); + GridSelectionFunctions.verifyRowHasCheckbox(grid.gridAPI.get_row_by_index(0).nativeElement); + })); + }); + + describe('RowSelection single', () => { + let fix: ComponentFixture; + let grid: IgxGridComponent; + const gridData = SampleTestData.foodProductDataExtended(); + + beforeEach(() => { + fix = TestBed.createComponent(SingleRowSelectionComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + }); + + it('Header checkbox should NOT select/deselect all rows when selectionMode is single', () => { + spyOn(grid.rowSelectionChanging, 'emit').and.callThrough(); + GridSelectionFunctions.clickHeaderRowCheckbox(fix); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, false); + GridSelectionFunctions.verifyRowsArraySelected([]); + expect(grid.selectedRows).toEqual([]); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(0); + + GridSelectionFunctions.clickHeaderRowCheckbox(fix); + fix.detectChanges(); + + expect(grid.selectedRows).toEqual([]); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, false); + GridSelectionFunctions.verifyRowsArraySelected([]); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(0); + }); + + it('Should have checkbox on each row and do not have header checkbox', () => { + GridSelectionFunctions.verifyHeaderRowHasCheckbox(fix, false); + GridSelectionFunctions.verifySelectionCheckBoxesAlignment(grid); + + for (const row of grid.rowList.toArray()) { + GridSelectionFunctions.verifyRowHasCheckbox(row.nativeElement); + } + + }); + + it('Should be able to select only one row when click on a checkbox', () => { + const firstRow = grid.gridAPI.get_row_by_index(0); + const secondRow = grid.gridAPI.get_row_by_index(1); + spyOn(grid.rowSelectionChanging, 'emit').and.callThrough(); + + GridSelectionFunctions.clickRowCheckbox(firstRow); + fix.detectChanges(); + + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(1); + let args: IRowSelectionEventArgs = { + added: [gridData[0]], + cancel: false, + event: jasmine.anything() as any, + newSelection: [gridData[0]], + oldSelection: [], + removed: [], + allRowsSelected: false, + owner: grid + }; + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledWith(args); + + expect(grid.selectedRows).toEqual([1]); + GridSelectionFunctions.verifyRowSelected(firstRow); + GridSelectionFunctions.verifyRowSelected(secondRow, false); + + // Click other row checkbox + GridSelectionFunctions.clickRowCheckbox(secondRow); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyRowSelected(secondRow); + expect(grid.selectedRows).toEqual([2]); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(2); + args = { + added: [gridData[1]], + cancel: false, + event: jasmine.anything() as any, + newSelection: [gridData[1]], + oldSelection: [gridData[0]], + removed: [gridData[0]], + allRowsSelected: false, + owner: grid + }; + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledWith(args); + }); + it('Should NOT select a row on click when selectRowOnClick has false value', () => { + grid.selectRowOnClick = false; + grid.tbody.nativeElement.focus(); + fix.detectChanges(); + + spyOn(grid.rowSelectionChanging, 'emit').and.callThrough(); + const firstRow = grid.gridAPI.get_row_by_index(0); + const cell = grid.gridAPI.get_cell_by_index(0, 0); + UIInteractions.simulateClickEvent(cell.nativeElement); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false); + expect(grid.selectedRows).toEqual([]); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(0); + }); + it('Should not select multiple rows with clicking and holding Ctrl', () => { + spyOn(grid.rowSelectionChanging, 'emit').and.callThrough(); + const firstRow = grid.gridAPI.get_row_by_index(2); + const secondRow = grid.gridAPI.get_row_by_index(0); + + UIInteractions.simulateClickEvent(firstRow.nativeElement); + fix.detectChanges(); + + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(1); + expect(grid.selectedRows).toEqual([3]); + GridSelectionFunctions.verifyRowSelected(firstRow); + + // Click on a different row holding Ctrl + UIInteractions.simulateClickEvent(secondRow.nativeElement, false, true); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyRowSelected(secondRow); + expect(grid.selectedRows).toEqual([1]); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(2); + }); + it('Should deselect a selected row with clicking and holding Ctrl', () => { + spyOn(grid.rowSelectionChanging, 'emit').and.callThrough(); + const firstRow = grid.gridAPI.get_row_by_index(2); + + UIInteractions.simulateClickEvent(firstRow.nativeElement); + fix.detectChanges(); + + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(1); + expect(grid.selectedRows).toEqual([3]); + GridSelectionFunctions.verifyRowSelected(firstRow); + + // Click on the same row holding Ctrl + UIInteractions.simulateClickEvent(firstRow.nativeElement, false, true); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false); + expect(grid.selectedRows.length).toEqual(0); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(2); + }); + it('Should not select a row with clicking and holding Ctrl when selectRowOnClick has false value', () => { + grid.selectRowOnClick = false; + spyOn(grid.rowSelectionChanging, 'emit').and.callThrough(); + const firstRow = grid.gridAPI.get_row_by_index(2); + const secondRow = grid.gridAPI.get_row_by_index(0); + + UIInteractions.simulateClickEvent(firstRow.nativeElement); + fix.detectChanges(); + + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(0); + expect(grid.selectedRows).toEqual([]); + GridSelectionFunctions.verifyRowSelected(firstRow, false); + + // Click on a different row holding Ctrl + UIInteractions.simulateClickEvent(secondRow.nativeElement, false, true); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyRowSelected(secondRow, false); + expect(grid.selectedRows).toEqual([]); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(0); + }); + it('Should not select multiple rows with clicking Space on a cell', (async () => { + grid.tbody.nativeElement.focus(); + fix.detectChanges(); + + spyOn(grid.rowSelectionChanging, 'emit').and.callThrough(); + const firstRow = grid.gridAPI.get_row_by_index(0); + const secondRow = grid.gridAPI.get_row_by_index(1); + let cell = grid.gridAPI.get_cell_by_index(0, 'ProductName'); + + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + await wait(DEBOUNCETIME); + + GridSelectionFunctions.verifyCellSelected(cell); + GridSelectionFunctions.verifyRowSelected(firstRow); + + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(1); + expect(grid.selectedRows).toEqual([1]); + GridSelectionFunctions.verifyRowSelected(firstRow); + GridSelectionFunctions.verifyRowSelected(secondRow, false); + + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', grid.tbody.nativeElement, true); + fix.detectChanges(); + await wait(DEBOUNCETIME); + + // Click Space on the cell + cell = grid.gridAPI.get_cell_by_index(1, 'ProductName'); + UIInteractions.triggerKeyDownEvtUponElem('space', grid.tbody.nativeElement, true); + fix.detectChanges(); + await wait(DEBOUNCETIME); + + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(2); + expect(grid.selectedRows).toEqual([2]); + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyRowSelected(secondRow); + + // Click again Space on the cell + UIInteractions.triggerKeyDownEvtUponElem('space', grid.tbody.nativeElement, true); + fix.detectChanges(); + await wait(DEBOUNCETIME); + + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(3); + expect(grid.selectedRows).toEqual([]); + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyRowSelected(secondRow, false); + })); + + it('Should not select multiple rows with Shift + Click', () => { + spyOn(grid.rowSelectionChanging, 'emit').and.callThrough(); + const firstRow = grid.gridAPI.get_row_by_index(1); + const secondRow = grid.gridAPI.get_row_by_index(4); + const mockEvent = new MouseEvent('click', { shiftKey: true }); + + UIInteractions.simulateClickEvent(firstRow.nativeElement); + fix.detectChanges(); + + expect(grid.selectedRows).toEqual([2]); + GridSelectionFunctions.verifyRowSelected(firstRow); + + // Click on other row holding Shift key + secondRow.nativeElement.dispatchEvent(mockEvent); + fix.detectChanges(); + + expect(grid.selectedRows).toEqual([5]); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(2); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledWith({ + added: [gridData[4]], + cancel: false, + event: mockEvent, + newSelection: [gridData[4]], + oldSelection: [gridData[1]], + removed: [gridData[1]], + allRowsSelected: false, + owner: grid + }); + + GridSelectionFunctions.verifyRowSelected(secondRow); + for (let index = 1; index < 4; index++) { + const row = grid.gridAPI.get_row_by_index(index); + GridSelectionFunctions.verifyRowSelected(row, false); + } + }); + it('Should not select row with Shift + Click when selectRowOnClick has false value ', () => { + grid.selectRowOnClick = false; + spyOn(grid.rowSelectionChanging, 'emit').and.callThrough(); + + // Shift + Click + const firstRow = grid.gridAPI.get_row_by_index(1); + const secondRow = grid.gridAPI.get_row_by_index(4); + + UIInteractions.simulateClickEvent(firstRow.nativeElement, false, false); + fix.detectChanges(); + + expect(grid.selectedRows).toEqual([]); + GridSelectionFunctions.verifyRowSelected(firstRow, false); + + // Click on other row holding Shift key + UIInteractions.simulateClickEvent(secondRow.nativeElement, true, false); + fix.detectChanges(); + + expect(grid.selectedRows).toEqual([]); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifyRowSelected(secondRow, false); + for (let index = 1; index < 4; index++) { + const row = grid.gridAPI.get_row_by_index(index); + GridSelectionFunctions.verifyRowSelected(row, false); + } + }); + it('Should hide/show checkboxes when change hideRowSelectors', () => { + const firstRow = grid.gridAPI.get_row_by_index(1); + + expect(grid.hideRowSelectors).toBe(false); + + firstRow.onRowSelectorClick(UIInteractions.getMouseEvent('click')); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + + grid.hideRowSelectors = true; + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, true, false); + GridSelectionFunctions.verifyHeaderRowHasCheckbox(fix, false, false); + GridSelectionFunctions.verifyRowHasCheckbox(firstRow.nativeElement, false, false); + + grid.hideRowSelectors = false; + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + GridSelectionFunctions.verifyHeaderRowHasCheckbox(fix, false); + GridSelectionFunctions.verifyRowHasCheckbox(firstRow.nativeElement); + }); + + it('Should be able to select multiple rows from API', () => { + grid.selectRows([1, 3, 5], true); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowsArraySelected( + [grid.gridAPI.get_row_by_index(0), grid.gridAPI.get_row_by_index(2), grid.gridAPI.get_row_by_index(4)]); + expect(grid.selectedRows).toEqual([1, 3, 5]); + + grid.selectRows([1, 2, 4], false); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowsArraySelected( + [grid.gridAPI.get_row_by_index(0), + grid.gridAPI.get_row_by_index(1), + grid.gridAPI.get_row_by_index(2), + grid.gridAPI.get_row_by_index(3), + grid.gridAPI.get_row_by_index(4)]); + expect(grid.selectedRows).toEqual([1, 3, 5, 2, 4]); + }); + + it('Should be able to cancel rowSelectionChanging event', () => { + const firstRow = grid.gridAPI.get_row_by_index(0); + const secondRow = grid.gridAPI.get_row_by_index(1); + + // Click on a row + UIInteractions.simulateClickEvent(firstRow.nativeElement); + fix.detectChanges(); + GridSelectionFunctions.verifyRowSelected(firstRow); + expect(grid.selectedRows).toEqual([1]); + + // Cancel the event + grid.rowSelectionChanging.subscribe((e: IRowSelectionEventArgs) => { + e.cancel = true; + }); + + // Click on a row checkbox + GridSelectionFunctions.clickRowCheckbox(firstRow); + fix.detectChanges(); + GridSelectionFunctions.verifyRowSelected(firstRow); + expect(grid.selectedRows).toEqual([1]); + + // Click on other row checkbox + GridSelectionFunctions.clickRowCheckbox(secondRow); + fix.detectChanges(); + GridSelectionFunctions.verifyRowSelected(firstRow); + GridSelectionFunctions.verifyRowSelected(secondRow, false); + expect(grid.selectedRows).toEqual([1]); + + // Click on other row + UIInteractions.simulateClickEvent(secondRow.nativeElement); + fix.detectChanges(); + GridSelectionFunctions.verifyRowSelected(firstRow); + GridSelectionFunctions.verifyRowSelected(secondRow, false); + expect(grid.selectedRows).toEqual([1]); + }); + + it('Should be able to change RowSelection to none', () => { + const firstRow = grid.gridAPI.get_row_by_index(0); + expect(grid.rowSelection).toEqual(GridSelectionMode.single); + + grid.selectRows([1]); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + + grid.rowSelection = GridSelectionMode.none; + fix.detectChanges(); + + expect(grid.rowSelection).toEqual(GridSelectionMode.none); + GridSelectionFunctions.verifyRowSelected(firstRow, false, false); + GridSelectionFunctions.verifyHeaderRowHasCheckbox(fix, false, false); + GridSelectionFunctions.verifyRowHasCheckbox(firstRow.nativeElement, false, false); + + // Click on a row + UIInteractions.simulateClickEvent(firstRow.nativeElement); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false, false); + }); + + it('Should be able to change RowSelection to multiple', () => { + const firstRow = grid.gridAPI.get_row_by_index(0); + const secondRow = grid.gridAPI.get_row_by_index(2); + expect(grid.rowSelection).toEqual(GridSelectionMode.single); + + grid.selectRows([1]); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + + grid.rowSelection = GridSelectionMode.multiple; + fix.detectChanges(); + + expect(grid.rowSelection).toEqual(GridSelectionMode.multiple); + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyHeaderRowHasCheckbox(fix); + GridSelectionFunctions.verifyRowHasCheckbox(firstRow.nativeElement); + GridSelectionFunctions.verifySelectionCheckBoxesAlignment(grid); + + // Click on a row + UIInteractions.simulateClickEvent(firstRow.nativeElement); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + + // Click on another row holding Ctrl + UIInteractions.simulateClickEvent(secondRow.nativeElement, false, true); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(secondRow); + GridSelectionFunctions.verifyRowSelected(firstRow); + }); + }); + + describe('API test', () => { + let fix: ComponentFixture; + let grid: IgxGridComponent; + + beforeEach(() => { + fix = TestBed.createComponent(RowSelectionComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + }); + + it('Should be able to programmatically select all rows and keep the header checkbox intact, #1298', () => { + spyOn(grid.rowSelectionChanging, 'emit').and.callThrough(); + grid.selectAllRows(); + grid.cdr.detectChanges(); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowsArraySelected(grid.rowList.toArray()); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + + grid.selectAllRows(); + grid.cdr.detectChanges(); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowsArraySelected(grid.rowList.toArray()); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + + grid.deselectAllRows(); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowsArraySelected(grid.rowList.toArray(), false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(0); + }); + + it('Should be able to select/deselect rows programmatically', () => { + spyOn(grid.rowSelectionChanging, 'emit').and.callThrough(); + const firstRow = grid.gridAPI.get_row_by_index(0); + const secondRow = grid.gridAPI.get_row_by_index(1); + const thirdRow = grid.gridAPI.get_row_by_index(2); + const forthRow = grid.gridAPI.get_row_by_index(3); + + expect(grid.selectedRows).toEqual([]); + GridSelectionFunctions.verifyRowsArraySelected(grid.rowList.toArray(), false); + + grid.deselectRows([1, 2, 3]); + fix.detectChanges(); + + expect(grid.selectedRows).toEqual([]); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix); + + grid.selectRows([1, 2, 3], false); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + GridSelectionFunctions.verifyRowsArraySelected([firstRow, secondRow, thirdRow]); + expect(grid.selectedRows).toEqual([1, 2, 3]); + + grid.deselectRows([1, 3]); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + GridSelectionFunctions.verifyRowsArraySelected([firstRow, thirdRow], false); + GridSelectionFunctions.verifyRowSelected(secondRow); + + grid.selectRows([1, 2, 3, 4], true); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + GridSelectionFunctions.verifyRowsArraySelected([firstRow, secondRow, thirdRow, forthRow]); + expect(grid.selectedRows).toEqual([1, 2, 3, 4]); + + grid.selectRows([1], true); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + GridSelectionFunctions.verifyRowsArraySelected([secondRow, thirdRow, forthRow], false); + GridSelectionFunctions.verifyRowSelected(firstRow); + expect(grid.selectedRows).toEqual([1]); + + grid.deselectRows([2, 3, 100]); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + GridSelectionFunctions.verifyRowsArraySelected([secondRow, thirdRow, forthRow], false); + GridSelectionFunctions.verifyRowSelected(firstRow); + expect(grid.selectedRows).toEqual([1]); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(0); + + grid.deselectRows([1]); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix); + GridSelectionFunctions.verifyRowsArraySelected([firstRow, secondRow, thirdRow, forthRow], false); + expect(grid.selectedRows).toEqual([]); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(0); + }); + + it('Should be able to correctly select all rows programmatically', () => { + const firstRow = grid.gridAPI.get_row_by_index(0); + const rowsToCheck = [firstRow, grid.gridAPI.get_row_by_index(1)]; + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, false); + + grid.selectAllRows(); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowsArraySelected(rowsToCheck); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + + GridSelectionFunctions.clickRowCheckbox(firstRow); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false, true); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + }); + + it('Should be able to select a row', () => { + const firstRow = grid.gridAPI.get_row_by_index(0); + firstRow.selected = true; + fix.detectChanges(); + + expect(grid.selectedRows).toEqual([1]); + GridSelectionFunctions.verifyRowSelected(firstRow); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + + firstRow.selected = false; + fix.detectChanges(); + + expect(grid.selectedRows).toEqual([]); + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix); + }); + }); + + describe('Selection without primaryKey', () => { + let fix: ComponentFixture; + let grid: IgxGridComponent; + const gridData = SampleTestData.personIDNameRegionData(); + + beforeEach(() => { + fix = TestBed.createComponent(RowSelectionWithoutPrimaryKeyComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + }); + + it('Verify event parameters', () => { + spyOn(grid.rowSelectionChanging, 'emit').and.callThrough(); + const firstRow = grid.gridAPI.get_row_by_index(1); + const secondRow = grid.gridAPI.get_row_by_index(4); + + UIInteractions.simulateClickEvent(firstRow.nativeElement); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + expect(grid.selectedRows).toEqual([gridData[1]]); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(1); + let args: IRowSelectionEventArgs = { + added: [gridData[1]], + cancel: false, + event: jasmine.anything() as any, + newSelection: [gridData[1]], + oldSelection: [], + removed: [], + allRowsSelected: false, + owner: grid + }; + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledWith(args); + + UIInteractions.simulateClickEvent(secondRow.nativeElement, true); + fix.detectChanges(); + + expect(grid.selectedRows).toEqual([gridData[1], gridData[2], gridData[3], gridData[4]]); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(2); + args = { + added: [gridData[2], gridData[3], gridData[4]], + cancel: false, + event: jasmine.anything() as any, + newSelection: [gridData[1], gridData[2], gridData[3], gridData[4]], + oldSelection: [gridData[1]], + removed: [], + allRowsSelected: false, + owner: grid + }; + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledWith(args); + }); + + it('Should persist through scrolling vertical', async () => { + const selectedRow = grid.gridAPI.get_row_by_index(0); + + grid.height = '200px'; + fix.detectChanges(); + + selectedRow.onRowSelectorClick(UIInteractions.getMouseEvent('click')); + await wait(); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + GridSelectionFunctions.verifyRowSelected(selectedRow); + expect(grid.selectedRows).toEqual([gridData[0]]); + + GridFunctions.scrollTop(grid, 500); + await wait(SCROLL_DEBOUNCETIME); + fix.detectChanges(); + + expect(grid.selectedRows).toEqual([gridData[0]]); + GridSelectionFunctions.verifyRowsArraySelected(grid.rowList, false); + + GridFunctions.scrollTop(grid, 0); + await wait(SCROLL_DEBOUNCETIME); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + GridSelectionFunctions.verifyRowSelected(selectedRow); + }); + + it('Should be able to select and deselect rows from API', () => { + const firstRow = grid.gridAPI.get_row_by_index(0); + const secondRow = grid.gridAPI.get_row_by_index(2); + const thirdRow = grid.gridAPI.get_row_by_index(5); + + grid.selectAllRows(); + fix.detectChanges(); + + expect(grid.selectedRows).toEqual(gridData); + GridSelectionFunctions.verifyRowsArraySelected(grid.rowList.toArray()); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + + grid.deselectRows([firstRow.key, secondRow.key, thirdRow.key]); + fix.detectChanges(); + + expect(grid.selectedRows).toEqual([gridData[1], gridData[3], gridData[4], gridData[6]]); + GridSelectionFunctions.verifyRowsArraySelected([firstRow, secondRow, thirdRow], false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + + grid.selectRows([firstRow.key, secondRow.key, thirdRow.key], false); + fix.detectChanges(); + + expect(grid.selectedRows) + .toEqual([gridData[1], gridData[3], gridData[4], gridData[6], gridData[0], gridData[2], gridData[5]]); + GridSelectionFunctions.verifyRowsArraySelected(grid.rowList.toArray()); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + }); + }); + + describe('Selection with primaryKey', () => { + let fix: ComponentFixture; + let grid: IgxGridComponent; + + beforeEach(() => { + fix = TestBed.createComponent(RowSelectionComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + grid.data = grid.data.slice(0, 10); + fix.detectChanges(); + }); + + it('Should be able to select row through primaryKey and index', () => { + expect(grid.primaryKey).toBeTruthy(); + expect(grid.rowList.length).toEqual(10, 'All 10 rows should initialized'); + expect(grid.getRowByKey(2).data['ProductName']).toMatch('Aniseed Syrup'); + expect(grid.gridAPI.get_row_by_index(1).data['ProductName']).toMatch('Aniseed Syrup'); + }); + + it('Should be able to update a cell in a row through primaryKey', () => { + expect(grid.primaryKey).toBeTruthy(); + expect(grid.rowList.length).toEqual(10, 'All 10 rows should initialized'); + expect(grid.getRowByKey(2).data['UnitsInStock']).toEqual(198); + grid.updateCell(300, 2, 'UnitsInStock'); + fix.detectChanges(); + expect(grid.getRowByKey(2).data['UnitsInStock']).toEqual(300); + }); + + it('Should be able to update row through primaryKey', () => { + expect(grid.primaryKey).toBeTruthy(); + expect(grid.rowList.length).toEqual(10, 'All 10 rows should initialized'); + expect(grid.getRowByKey(2).data['UnitsInStock']).toEqual(198); + grid.updateRow({ ProductID: 2, ProductName: 'Aniseed Syrup', UnitsInStock: 300 }, 2); + fix.detectChanges(); + expect(grid.gridAPI.get_row_by_index(1).data['UnitsInStock']).toEqual(300); + expect(grid.getRowByKey(2).data['UnitsInStock']).toEqual(300); + }); + + it('Should be able to delete a row through primaryKey', () => { + expect(grid.primaryKey).toBeTruthy(); + expect(grid.rowList.length).toEqual(10, 'All 10 rows should initialized'); + expect(grid.getRowByKey(2)).toBeDefined(); + grid.deleteRow(2); + fix.detectChanges(); + expect(grid.getRowByKey(2)).toBeUndefined(); + expect(grid.gridAPI.get_row_by_index(2)).toBeDefined(); + }); + + it('Should handle update by not overwriting the value in the data column specified as primaryKey', () => { + expect(grid.primaryKey).toBeTruthy(); + expect(grid.rowList.length).toEqual(10, 'All 10 rows should initialized'); + expect(grid.getRowByKey(2)).toBeDefined(); + grid.updateRow({ ProductID: 7, ProductName: 'Aniseed Syrup', UnitsInStock: 300 }, 2); + fix.detectChanges(); + expect(grid.getRowByKey(7)).toBeDefined(); + expect(grid.gridAPI.get_row_by_index(1)).toBeDefined(); + expect(grid.gridAPI.get_row_by_index(1).data[grid.primaryKey]).toEqual(7); + }); + + it('Should be able to programatically select all rows with a correct reference, #1297', () => { + grid.selectAllRows(); + fix.detectChanges(); + + expect(grid.selectedRows).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + }); + }); + + describe('Integration tests', () => { + let fix: ComponentFixture; + let grid: IgxGridComponent; + + beforeEach(() => { + fix = TestBed.createComponent(RowSelectionComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + }); + + it('Paging: Should persist through paging', () => { + fix.componentInstance.paging = true; + fix.detectChanges(); + + const firstRow = grid.gridAPI.get_row_by_index(0); + const secondRow = grid.gridAPI.get_row_by_index(1); + const middleRow = grid.gridAPI.get_row_by_index(3); + + secondRow.onRowSelectorClick(UIInteractions.getMouseEvent('click')); + middleRow.onRowSelectorClick(UIInteractions.getMouseEvent('click')); + grid.notifyChanges(true); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(secondRow); + GridSelectionFunctions.verifyRowSelected(middleRow); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + + grid.paginator.nextPage(); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(secondRow, false); + GridSelectionFunctions.verifyRowSelected(middleRow, false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + + firstRow.onRowSelectorClick(UIInteractions.getMouseEvent('click')); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + GridSelectionFunctions.verifyRowSelected(secondRow, false); + GridSelectionFunctions.verifyRowSelected(middleRow, false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + + grid.paginator.previousPage(); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyRowSelected(secondRow); + GridSelectionFunctions.verifyRowSelected(middleRow); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + }); + + it('Paging: Should persist all rows selection through paging', () => { + fix.componentInstance.paging = true; + fix.detectChanges(); + + const secondRow = grid.gridAPI.get_row_by_index(1); + grid.onHeaderSelectorClick(UIInteractions.getMouseEvent('click')); + fix.detectChanges(); + grid.notifyChanges(); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + GridSelectionFunctions.verifyRowsArraySelected(grid.rowList.toArray()); + + grid.paginator.nextPage(); + fix.detectChanges(); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + GridSelectionFunctions.verifyRowsArraySelected(grid.rowList.toArray()); + + // Click on a single row + secondRow.onClick(UIInteractions.getMouseEvent('click')); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + GridSelectionFunctions.verifyRowSelected(secondRow); + + grid.paginator.previousPage(); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + GridSelectionFunctions.verifyRowsArraySelected(grid.rowList.toArray(), false); + }); + + it('Paging: Should be able to select rows with Shift and Click', () => { + fix.componentInstance.paging = true; + fix.detectChanges(); + + const firstRow = grid.gridAPI.get_row_by_index(0); + const thirdRow = grid.gridAPI.get_row_by_index(3); + grid.onHeaderSelectorClick(UIInteractions.getMouseEvent('click')); + + // Select first row on first page + firstRow.onClick(UIInteractions.getMouseEvent('click')); + fix.detectChanges(); + grid.notifyChanges(); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + GridSelectionFunctions.verifyRowSelected(firstRow); + + grid.paginator.nextPage(); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + GridSelectionFunctions.verifyRowsArraySelected(grid.rowList.toArray(), false); + + // Click on the last row in page holding Shift + thirdRow.onClick(UIInteractions.getMouseEvent('click', false, true, false)); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + GridSelectionFunctions.verifyRowsArraySelected(grid.rowList.toArray()); + + grid.paginator.previousPage(); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + GridSelectionFunctions.verifyRowsArraySelected(grid.rowList.toArray()); + }); + + it('CRUD: Should handle the deselection on a selected row properly', () => { + let firstRow = grid.gridAPI.get_row_by_key(1); + grid.selectRows([1]); + + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + + grid.deleteRow(1); + fix.detectChanges(); + + expect(grid.getRowByKey(1)).toBeUndefined(); + expect(grid.selectedRows.includes(1)).toBe(false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix); + + grid.selectAllRows(); + fix.detectChanges(); + + firstRow = grid.gridAPI.get_row_by_key(2); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + GridSelectionFunctions.verifyRowSelected(firstRow); + + grid.deleteRow(2); + fix.detectChanges(); + + expect(grid.gridAPI.get_row_by_key(2)).toBeUndefined(); + expect(grid.selectedRows.includes(2)).toBe(false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + + grid.deselectRows([3]); + fix.detectChanges(); + + expect(grid.selectedRows.includes(3)).toBe(false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + + grid.deleteRow(3); + fix.detectChanges(); + + expect(grid.gridAPI.get_row_by_key(3)).toBeUndefined(); + expect(grid.selectedRows.includes(3)).toBe(false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + }); + + it('CRUD: Should handle the adding new row properly', () => { + grid.selectAllRows(); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + grid.addRow({ ProductID: 20, ProductName: 'test', InStock: true, UnitsInStock: 1, OrderDate: new Date('2019-03-01') }); + fix.detectChanges(); + + expect(grid.selectedRows.includes(20)).toBe(false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + }); + + it('CRUD: Should update selected row when update cell', () => { + let firstRow = grid.gridAPI.get_row_by_index(1); + firstRow.selected = true; + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + expect(grid.selectedRows).toEqual([2]); + grid.updateCell(102, 2, 'ProductID'); + fix.detectChanges(); + + firstRow = grid.gridAPI.get_row_by_index(1); + expect(firstRow.key).toEqual(102); + GridSelectionFunctions.verifyRowSelected(firstRow); + expect(grid.selectedRows).toEqual([102]); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + }); + + it('CRUD: Should update selected row when update row', () => { + grid.selectAllRows(); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + + grid.updateRow({ ProductID: 103, ProductName: 'test', InStock: true, UnitsInStock: 1, OrderDate: new Date('2019-03-01') }, 3); + fix.detectChanges(); + + const row = grid.gridAPI.get_row_by_index(2); + GridSelectionFunctions.verifyRowSelected(row); + expect(row.key).toEqual(103); + expect(grid.selectedRows.includes(3)).toBe(false); + expect(grid.selectedRows.includes(103)).toBe(true); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + }); + + it('Sorting: Should have persistent selection through data operations', () => { + const rowsToCheck = [grid.gridAPI.get_row_by_index(0), grid.gridAPI.get_row_by_index(1)]; + GridSelectionFunctions.verifyRowsArraySelected(rowsToCheck, false); + + grid.selectRows([1, 2], false); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowsArraySelected(rowsToCheck, true); + + grid.sort({ fieldName: 'UnitsInStock', dir: SortingDirection.Desc, ignoreCase: true }); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowsArraySelected(rowsToCheck, false); + + grid.clearSort('UnitsInStock'); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowsArraySelected(rowsToCheck, true); + }); + + it('Summaries integration', () => { + grid.getColumnByName('ProductID').hasSummary = true; + fix.detectChanges(); + + expect(grid.summariesMargin).toBe(grid.featureColumnsWidth()); + }); + + it('Filtering: Should properly check the header checkbox state when filtering, #2469', () => { + spyOn(grid.rowSelectionChanging, 'emit').and.callThrough(); + + grid.filter('ProductID', 10, IgxNumberFilteringOperand.instance().condition('greaterThanOrEqualTo'), true); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(0); + expect(grid.selectedRows).toEqual([]); + + grid.clearFilter('ProductID'); + fix.detectChanges(); + + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(0); + + GridSelectionFunctions.clickHeaderRowCheckbox(fix); + fix.detectChanges(); + + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + + grid.filter('ProductID', 0, IgxNumberFilteringOperand.instance().condition('greaterThanOrEqualTo'), true); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + expect(grid.selectedRows.length).toBe(19); + + grid.filter('ProductID', 100, IgxNumberFilteringOperand.instance().condition('greaterThanOrEqualTo'), true); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix); + expect(grid.rowList.length).toBe(0); + expect(grid.selectedRows.length).toBe(19); + + grid.clearFilter(); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + expect(grid.selectedRows.length).toBe(19); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(1); + }); + + it('Filtering: Should select correct rows when filter is applied', () => { + spyOn(grid.rowSelectionChanging, 'emit').and.callThrough(); + const secondRow = grid.gridAPI.get_row_by_index(1); + + GridSelectionFunctions.clickRowCheckbox(secondRow); + fix.detectChanges(); + + expect(secondRow.selected).toBeTruthy(); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(1); + + grid.filter('ProductName', 'Ca', IgxStringFilteringOperand.instance().condition('contains'), true); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(1); + + GridSelectionFunctions.clickHeaderRowCheckbox(fix); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(2); + + grid.clearFilter('ProductName'); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + expect(grid.gridAPI.get_row_by_index(1).selected).toBeTruthy(); + expect(grid.gridAPI.get_row_by_index(2).selected).toBeTruthy(); + expect(grid.gridAPI.get_row_by_index(6).selected).toBeTruthy(); + + grid.filter('ProductName', 'Ca', IgxStringFilteringOperand.instance().condition('contains'), true); + fix.detectChanges(); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(2); + GridSelectionFunctions.clickHeaderRowCheckbox(fix); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(3); + + grid.clearFilter('ProductName'); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + + expect(grid.gridAPI.get_row_by_index(1).selected).toBeTruthy(); + expect(grid.gridAPI.get_row_by_index(2).selected).toBeFalsy(); + expect(grid.gridAPI.get_row_by_index(6).selected).toBeFalsy(); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(3); + + GridSelectionFunctions.clickRowCheckbox(grid.gridAPI.get_row_by_index(2)); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(4); + + grid.filter('ProductName', 'Ca', IgxStringFilteringOperand.instance().condition('contains'), true); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(4); + + GridSelectionFunctions.clickHeaderRowCheckbox(fix); + fix.detectChanges(); + + GridSelectionFunctions.clickHeaderRowCheckbox(fix); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(6); + + grid.clearFilter('ProductName'); + fix.detectChanges(); + + expect(grid.gridAPI.get_row_by_index(2).selected).toBeFalsy(); + expect(grid.gridAPI.get_row_by_index(1).selected).toBeTruthy(); + expect(grid.rowSelectionChanging.emit).toHaveBeenCalledTimes(6); + }); + + it('Should select only filtered records', () => { + grid.height = '1100px'; + const tree = new FilteringExpressionsTree(FilteringLogic.And); + tree.filteringOperands.push({ + fieldName: 'UnitsInStock', + searchVal: 0, + condition: IgxNumberFilteringOperand.instance().condition('greaterThan'), + conditionName: 'greaterThan' + }); + tree.filteringOperands.push({ + fieldName: 'ProductName', + searchVal: 'a', + condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains', + ignoreCase: true + }); + grid.advancedFilteringExpressionsTree = tree; + fix.detectChanges(); + GridSelectionFunctions.headerCheckboxClick(grid); + fix.detectChanges(); + + expect(grid.rowList.length).toBe(9); + expect(grid.selectedRows.length).toBe(9); + GridSelectionFunctions.verifyHeaderRowCheckboxState(grid, true, false); + + grid.advancedFilteringExpressionsTree = null; + fix.detectChanges(); + + expect(grid.rowList.length).toBe(19); + expect(grid.selectedRows.length).toBe(9); + GridSelectionFunctions.verifyHeaderRowCheckboxState(grid, false, true); + }); + + it('Should bind selectedRows properly', () => { + fix.componentInstance.selectedRows = [1, 2, 3]; + fix.detectChanges(); + expect(grid.gridAPI.get_row_by_index(0).selected).toBeTrue(); + expect(grid.gridAPI.get_row_by_index(4).selected).toBeFalse(); + + fix.componentInstance.selectedRows = [4, 5, 6]; + fix.detectChanges(); + + expect(grid.gridAPI.get_row_by_index(3).selected).toBeTrue(); + expect(grid.gridAPI.get_row_by_index(0).selected).toBeFalse(); + }); + + it('Row Pinning: should update checkbox status correctly when there is pinned row and groupBy', () => { + grid.pinRow(2); + fix.detectChanges(); + + grid.groupBy({ fieldName: 'InStock', dir: SortingDirection.Desc, ignoreCase: false }); + + GridSelectionFunctions.headerCheckboxClick(grid); + fix.detectChanges(); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true, false); + expect(grid.selectedRows.length).toBe(grid.data.length); + }); + + it('Should deselect updated row with header checkbox when batchEditing is enbaled and filtering is applied', () => { + grid.batchEditing = true; + grid.selectRows([1]); + grid.filter('InStock', null, IgxBooleanFilteringOperand.instance().condition('true')); + fix.detectChanges(); + + const row = grid.gridAPI.get_row_by_index(0); + GridSelectionFunctions.verifyRowSelected(row); + + grid.updateRow({ ProductID: 1, ProductName: 'test', InStock: true, UnitsInStock: 1, OrderDate: new Date('2019-03-01') }, 1); + fix.detectChanges(); + + GridSelectionFunctions.clickHeaderRowCheckbox(fix); + fix.detectChanges(); + GridSelectionFunctions.verifyRowSelected(row); + + GridSelectionFunctions.clickHeaderRowCheckbox(fix); + fix.detectChanges(); + GridSelectionFunctions.verifyRowSelected(row, false); + }); + }); + + describe('Integration with CRUD and transactions', () => { + let fix: ComponentFixture; + let grid: IgxGridComponent; + + beforeEach(() => { + fix = TestBed.createComponent(SelectionWithTransactionsComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + grid.rowSelection = GridSelectionMode.multiple; + fix.detectChanges(); + }); + + it('Should unselect row when delete it', () => { + const firstRow = grid.gridAPI.get_row_by_index(0); + + GridSelectionFunctions.clickRowCheckbox(firstRow); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + + grid.deleteRowById(firstRow.key); + fix.detectChanges(); + + expect(grid.selectedRows).toEqual([]); + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix); + }); + + it('Should not allow selecting rows that are deleted', () => { + const firstRow = grid.gridAPI.get_row_by_index(0); + const secondRow = grid.gridAPI.get_row_by_index(1); + const thirdRow = grid.gridAPI.get_row_by_index(2); + + grid.deleteRowById(firstRow.key); + grid.deleteRowById(secondRow.key); + fix.detectChanges(); + + grid.selectAllRows(); + fix.detectChanges(); + + expect(grid.selectedRows.includes(firstRow.key)).toBe(false); + expect(grid.selectedRows.includes(secondRow.key)).toBe(false); + expect(grid.selectedRows.includes(thirdRow.key)).toBe(true); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + + grid.selectRows([firstRow.key, secondRow.key, thirdRow.key]); + fix.detectChanges(); + + expect(grid.selectedRows.includes(firstRow.key)).toBe(true); + expect(grid.selectedRows.includes(secondRow.key)).toBe(true); + GridSelectionFunctions.verifyRowSelected(firstRow); + GridSelectionFunctions.verifyRowSelected(secondRow); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + }); + + it('Should have correct header checkbox when delete a row', async () => { + const firstRow = grid.gridAPI.get_row_by_index(0); + const secondRow = grid.gridAPI.get_row_by_index(1); + + grid.onHeaderSelectorClick(UIInteractions.getMouseEvent('click')); + await wait(); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowsArraySelected(grid.rowList); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + + grid.deleteRowById(firstRow.key); + fix.detectChanges(); + + expect(grid.selectedRows.length).toEqual(7); + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + + grid.deselectRows([secondRow.key]); + fix.detectChanges(); + + expect(grid.selectedRows.length).toEqual(6); + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyRowSelected(secondRow, false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + + grid.deleteRowById(secondRow.key); + fix.detectChanges(); + + expect(grid.selectedRows.length).toEqual(6); + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyRowSelected(secondRow, false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + }); + + it('Should not be possible to select deleted row', () => { + const firstRow = grid.gridAPI.get_row_by_index(0); + const secondRow = grid.gridAPI.get_row_by_index(3); + + grid.deleteRowById(firstRow.key); + fix.detectChanges(); + + GridSelectionFunctions.clickRowCheckbox(firstRow); + fix.detectChanges(); + + expect(grid.selectedRows).toEqual([]); + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix); + + UIInteractions.simulateClickEvent(firstRow.nativeElement); + fix.detectChanges(); + + expect(grid.selectedRows).toEqual([]); + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix); + + UIInteractions.simulateClickEvent(secondRow.nativeElement); + fix.detectChanges(); + + expect(grid.selectedRows).toEqual([secondRow.key]); + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyRowSelected(secondRow); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + + UIInteractions.simulateClickEvent(firstRow.nativeElement, true); + fix.detectChanges(); + + expect(grid.selectedRows).toEqual([secondRow.key]); + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyRowSelected(secondRow); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + + GridSelectionFunctions.clickHeaderRowCheckbox(fix); + fix.detectChanges(); + + expect(grid.selectedRows.includes(firstRow.key)).toBe(false); + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyRowSelected(secondRow); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + }); + + it('Should have correct header checkbox when undo row deleting', async () => { + const firstRow = grid.gridAPI.get_row_by_index(0); + + grid.onHeaderSelectorClick(UIInteractions.getMouseEvent('click')); + await wait(); + fix.detectChanges(); + + expect(grid.selectedRows.includes(firstRow.key)).toBe(true); + GridSelectionFunctions.verifyRowSelected(firstRow); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + + grid.deleteRowById(firstRow.key); + fix.detectChanges(); + + expect(grid.selectedRows.includes(firstRow.key)).toBe(false); + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + + grid.transactions.undo(); + fix.detectChanges(); + + expect(grid.selectedRows.length).toBe(7); + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + + grid.transactions.redo(); + fix.detectChanges(); + + expect(grid.selectedRows.includes(firstRow.key)).toBe(false); + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + }); + + it('Should have correct header checkbox when add row', () => { + grid.height = '800px'; + fix.detectChanges(); + + grid.selectAllRows(); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + + grid.addRow({ ID: 112, ParentID: 177, Name: 'Ricardo Matias', HireDate: new Date('Dec 27, 2017'), Age: 55, OnPTO: false }); + fix.detectChanges(); + + const addedRow = grid.gridAPI.get_row_by_key(112); + GridSelectionFunctions.verifyRowSelected(addedRow, false); + expect(grid.selectedRows.includes(112)).toBe(false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + + GridSelectionFunctions.clickRowCheckbox(addedRow); + fix.detectChanges(); + + expect(grid.selectedRows.includes(112)).toBe(true); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + GridSelectionFunctions.verifyRowSelected(addedRow); + }); + + it('Should be able to select added row', () => { + grid.height = '800px'; + fix.detectChanges(); + + grid.addRow({ ID: 112, ParentID: 177, Name: 'Ricardo Matias', HireDate: new Date('Dec 27, 2017'), Age: 55, OnPTO: false }); + fix.detectChanges(); + + const addedRow = grid.gridAPI.get_row_by_key(112); + GridSelectionFunctions.verifyRowSelected(addedRow, false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix); + + GridSelectionFunctions.clickHeaderRowCheckbox(fix); + fix.detectChanges(); + + expect(grid.selectedRows.includes(112)).toBe(true); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + GridSelectionFunctions.verifyRowSelected(addedRow); + }); + }); + + describe('Custom row selectors', () => { + let fix: ComponentFixture; + let grid; + + beforeEach(waitForAsync(() => { + fix = TestBed.createComponent(GridCustomSelectorsComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + grid.rowSelection = GridSelectionMode.multiple; + })); + + it('Should have the correct properties in the custom row selector template', () => { + const firstRow = grid.gridAPI.get_row_by_index(0); + const firstCheckbox = firstRow.nativeElement.querySelector('.igx-checkbox__composite'); + const context = { index: 0, rowID: 'ALFKI', key: 'ALFKI', selected: false }; + const contextUnselect = { index: 0, rowID: 'ALFKI', key: 'ALFKI', selected: true }; + spyOn(fix.componentInstance, 'onRowCheckboxClick').and.callThrough(); + firstCheckbox.click(); + fix.detectChanges(); + + expect(fix.componentInstance.onRowCheckboxClick).toHaveBeenCalledTimes(1); + expect(fix.componentInstance.onRowCheckboxClick).toHaveBeenCalledWith(fix.componentInstance.rowCheckboxClick, context); + + // Verify correct properties when unselecting a row + firstCheckbox.click(); + fix.detectChanges(); + + expect(fix.componentInstance.onRowCheckboxClick).toHaveBeenCalledTimes(2); + expect(fix.componentInstance.onRowCheckboxClick).toHaveBeenCalledWith(fix.componentInstance.rowCheckboxClick, contextUnselect); + }); + + it('Should have the correct properties in the custom row selector header template', () => { + const context = { selectedCount: 0, totalCount: 27 }; + const contextUnselect = { selectedCount: 27, totalCount: 27 }; + const headerCheckbox = grid.theadRow.nativeElement.querySelector('.igx-checkbox__composite'); + spyOn(fix.componentInstance, 'onHeaderCheckboxClick').and.callThrough(); + headerCheckbox.click(); + fix.detectChanges(); + + expect(fix.componentInstance.onHeaderCheckboxClick).toHaveBeenCalledTimes(1); + expect(fix.componentInstance.onHeaderCheckboxClick).toHaveBeenCalledWith(fix.componentInstance.headerCheckboxClick, context); + + headerCheckbox.click(); + fix.detectChanges(); + + expect(fix.componentInstance.onHeaderCheckboxClick).toHaveBeenCalledTimes(2); + expect(fix.componentInstance.onHeaderCheckboxClick). + toHaveBeenCalledWith(fix.componentInstance.headerCheckboxClick, contextUnselect); + }); + + it('Should have correct indices on all pages', () => { + grid.paginator.nextPage(); + fix.detectChanges(); + + const firstRootRow = grid.gridAPI.get_row_by_index(0); + expect(firstRootRow.nativeElement.querySelector('.rowNumber').textContent).toEqual('15'); + }); + + it('Should allow setting row, group row and header custom templates via Input.', () => { + grid.rowSelectorTemplate = fix.componentInstance.customRowTemplate; + grid.headSelectorTemplate = fix.componentInstance.customHeaderTemplate; + grid.groupByRowSelectorTemplate = fix.componentInstance.customGroupRowTemplate; + + fix.detectChanges(); + grid.groupBy({ + fieldName: 'CompanyName', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + const rowDOM = grid.dataRowList.toArray()[0].nativeElement; + const groupRowDOM = grid.groupsRowList.toArray()[0].nativeElement + const rowSelector = rowDOM.querySelector(`.igx-grid__cbx-selection`); + const groupRowSelector = groupRowDOM.querySelector(`.igx-grid__cbx-selection`); + const headerSelector = GridSelectionFunctions.getHeaderRow(fix).querySelector(`.igx-grid__cbx-selection`); + + expect(rowSelector.textContent).toBe('CUSTOM SELECTOR: 0'); + expect(groupRowSelector.textContent).toBe('CUSTOM GROUP SELECTOR'); + expect(headerSelector.textContent).toBe('CUSTOM HEADER SELECTOR'); + }); + }); +}); diff --git a/projects/igniteui-angular/grids/grid/src/grid-row.component.html b/projects/igniteui-angular/grids/grid/src/grid-row.component.html new file mode 100644 index 00000000000..5ef9a3af0ea --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid-row.component.html @@ -0,0 +1,268 @@ + + + +
    + +
    +
    + + @if (rowDraggable) { +
    + +
    + } + @if (this.showRowSelectors) { +
    + + +
    + } + @if (grid.groupingExpressions.length > 0) { +
    + } + + + @if (!grid.hasColumnLayouts) { + @if (pinnedStartColumns.length > 0) { + @for (col of pinnedStartColumns | igxNotGrouped; track trackPinnedColumn(col)) { + @if (this.hasMergedCells) { +
    + +
    + } + @else { + + } + } + } + + @if (this.hasMergedCells) { +
    + +
    + } + @else { + + } + +
    + @if (pinnedEndColumns.length > 0) { + @for (col of pinnedEndColumns | igxNotGrouped; track trackPinnedColumn(col)) { + @if (this.hasMergedCells) { +
    + +
    + } + @else { + + } + } + } + } + + @if (grid.hasColumnLayouts) { + @if (pinnedStartColumns.length > 0) { + + } + +
    + @for (col of col.children; track trackPinnedColumn(col)) { + + } +
    +
    + @if (pinnedEndColumns.length > 0) { + + } + } +
    + + + @for (col of pinnedColumns | igxTopLevel; track trackPinnedColumn(col)) { +
    + @for (col of col.children; track col) { + + } +
    + } +
    + + +
    + + +
    +
    + + + + + + + + + + + + + + + + + + + + diff --git a/projects/igniteui-angular/grids/grid/src/grid-row.component.ts b/projects/igniteui-angular/grids/grid/src/grid-row.component.ts new file mode 100644 index 00000000000..3e97345b41f --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid-row.component.ts @@ -0,0 +1,42 @@ +import { Component, ChangeDetectionStrategy, forwardRef } from '@angular/core'; +import { NgTemplateOutlet, NgStyle, NgClass } from '@angular/common'; +import { + IgxGridCellComponent, + IgxGridCellStyleClassesPipe, + IgxGridCellStylesPipe, + IgxGridDataMapperPipe, + IgxGridNotGroupedPipe, + IgxGridTopLevelColumns, + IgxGridTransactionStatePipe, + IgxRowDirective, + IgxRowDragDirective +} from 'igniteui-angular/grids/core'; +import { IgxGridExpandableCellComponent } from './expandable-cell.component'; +import { IgxGridForOfDirective } from 'igniteui-angular/directives'; +import { IgxCheckboxComponent } from 'igniteui-angular/checkbox'; + +/* blazorIndirectRender */ +/* blazorElement */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'igx-grid-row', + templateUrl: './grid-row.component.html', + providers: [{ provide: IgxRowDirective, useExisting: forwardRef(() => IgxGridRowComponent) }], + imports: [NgTemplateOutlet, IgxRowDragDirective, IgxGridForOfDirective, NgStyle, IgxCheckboxComponent, IgxGridCellComponent, NgClass, IgxGridExpandableCellComponent, IgxGridNotGroupedPipe, IgxGridTopLevelColumns, IgxGridCellStylesPipe, IgxGridCellStyleClassesPipe, IgxGridDataMapperPipe, IgxGridTransactionStatePipe] +}) +export class IgxGridRowComponent extends IgxRowDirective { + + public getContext(col, row) { + return { + $implicit: col, + row + }; + } + + public getContextMRL(pinnedCols, row) { + return { + $implicit: pinnedCols, + row + }; + } +} diff --git a/projects/igniteui-angular/grids/grid/src/grid-summary.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-summary.spec.ts new file mode 100644 index 00000000000..923ab8e26fd --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid-summary.spec.ts @@ -0,0 +1,2775 @@ +import { Component, DebugElement, ViewChild } from '@angular/core'; +import { fakeAsync, TestBed, tick, ComponentFixture, flush, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxGridComponent } from './grid.component'; +import { wait, UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { GridFunctions, GridSummaryFunctions } from '../../../test-utils/grid-functions.spec'; +import { + ProductsComponent, + SummaryColumnComponent, + FilteringComponent, + SummariesGroupByComponent, + SummariesGroupByTransactionsComponent +} from '../../../test-utils/grid-samples.spec'; +import { clearGridSubs, setupGridScrollDetection, ymd } from '../../../test-utils/helper-utils.spec'; +import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; +import { DropPosition, IgxColumnComponent, IgxDateSummaryOperand, IgxGridRow, IgxGroupByRow, IgxNumberSummaryOperand, IgxSummaryOperand, IgxSummaryRow } from 'igniteui-angular/grids/core'; +import { DatePipe } from '@angular/common'; +import { IgxGridGroupByRowComponent } from './groupby-row.component'; +import { GridSummaryCalculationMode, IColumnPipeArgs, IgxNumberFilteringOperand, IgxStringFilteringOperand, IgxSummaryResult, SortingDirection } from 'igniteui-angular/core'; + +describe('IgxGrid - Summaries #grid', () => { + + const SUMMARY_CLASS = '.igx-grid-summary'; + const ITEM_CLASS = 'igx-grid-summary__item'; + const SUMMARY_ROW = 'igx-grid-summary-row'; + const SUMMARY_CELL = 'igx-grid-summary-cell'; + const EMPTY_SUMMARY_CLASS = 'igx-grid-summary--empty'; + const DEBOUNCETIME = 30; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + CustomSummariesComponent, + ProductsComponent, + SummaryColumnComponent, + FilteringComponent, + SummariesGroupByComponent, + SummariesGroupByTransactionsComponent + ] + }).compileComponents(); + })); + + describe('Base tests: ', () => { + describe('in grid with no summaries defined: ', () => { + let fixture: ComponentFixture; + let grid: IgxGridComponent; + beforeEach(() => { + fixture = TestBed.createComponent(ProductsComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + }); + + it('should not have summary if no summary is active ', () => { + expect(fixture.debugElement.query(By.css(SUMMARY_CLASS))).toBeNull(); + }); + + it('should enableSummaries through grid API ', () => { + expect(grid.hasSummarizedColumns).toBe(false); + let tFoot = GridFunctions.getGridFooterWrapper(fixture).nativeElement.getBoundingClientRect().height; + expect(tFoot < grid.defaultSummaryHeight).toBe(true); + + grid.enableSummaries([{ fieldName: 'ProductName' }, { fieldName: 'ProductID' }]); + fixture.detectChanges(); + + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fixture); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, ['Count'], ['10']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['10']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, [], []); + + expect(grid.getColumnByName('ProductID').hasSummary).toBe(true); + expect(grid.getColumnByName('ProductName').hasSummary).toBe(true); + expect(grid.getColumnByName('OrderDate').hasSummary).toBe(false); + + grid.summaryRowHeight = 0; + fixture.detectChanges(); + + tFoot = GridFunctions.getGridFooterWrapper(fixture).nativeElement.getBoundingClientRect().height; + expect(tFoot).toEqual(grid.defaultSummaryHeight); + }); + + it(`should recalculate grid sizes correctly when the column is outside of the viewport`, () => { + grid.width = '300px'; + fixture.detectChanges(); + + grid.getColumnByName('UnitsInStock').hasSummary = true; + grid.summaryRowHeight = null; + fixture.detectChanges(); + + const tFoot = GridFunctions.getGridFooterWrapper(fixture).nativeElement.getBoundingClientRect().height; + expect(tFoot).toEqual(5 * grid.defaultSummaryHeight); + expect(GridSummaryFunctions.getRootSummaryRow(fixture)).toBeDefined(); + }); + + + it(`Should update summary section when the column is outside of the + viewport and have identical width with others`, (async () => { + grid.getColumnByName('UnitsInStock').hasSummary = true; + grid.width = '300px'; + await wait(100); + fixture.detectChanges(); + + grid.addRow({ + ProductID: 11, ProductName: 'Belgian Chocolate', InStock: true, UnitsInStock: 99000, OrderDate: ymd('2018-03-01') + }); + await wait(30); + fixture.detectChanges(); + grid.dataRowList.first.virtDirRow.scrollTo(3); + await wait(30); + fixture.detectChanges(); + + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fixture); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Min', 'Max', 'Sum', 'Avg'], + ['11', '0', '99,000', '138,004', '12,545.818']); + })); + + it('When we have data which is undefined and enable summary per defined column, error should not be thrown', () => { + const idColumn = grid.getColumnByName('ProductID'); + expect(grid.data.length > 0).toEqual(true); + + fixture.componentInstance.data = undefined; + fixture.detectChanges(); + + expect(grid.data).toEqual([]); + expect(() => { + grid.enableSummaries(idColumn.field); + fixture.detectChanges(); + }).not.toThrow(); + }); + + it('should not display initially disabled summaries in the summary output', fakeAsync(() => { + grid.enableSummaries([{ fieldName: 'UnitsInStock' }]); + fixture.detectChanges(); + tick(); + + const column = grid.getColumnByName('UnitsInStock'); + + column.disabledSummaries = ['count', 'min', 'max']; + fixture.detectChanges(); + tick(); + + GridSummaryFunctions.verifyColumnSummaries( + GridSummaryFunctions.getRootSummaryRow(fixture), 3, + ['Sum', 'Avg'], + ['39,004', '3,900.4'] + ); + })); + + it('should apply disabled summaries dynamically at runtime', fakeAsync(() => { + grid.enableSummaries([{ fieldName: 'UnitsInStock' }]); + fixture.detectChanges(); + tick(); + + const column = grid.getColumnByName('UnitsInStock'); + + GridSummaryFunctions.verifyColumnSummaries( + GridSummaryFunctions.getRootSummaryRow(fixture), 3, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], + ['10', '0', '20,000', '39,004', '3,900.4'] + ); + + column.disabledSummaries = ['count']; + fixture.detectChanges(); + tick(); + GridSummaryFunctions.verifyColumnSummaries( + GridSummaryFunctions.getRootSummaryRow(fixture), 3, + ['Min', 'Max', 'Sum', 'Avg'], + ['0', '20,000', '39,004', '3,900.4'] + ); + + column.disabledSummaries = ['count', 'sum']; + fixture.detectChanges(); + tick(); + GridSummaryFunctions.verifyColumnSummaries( + GridSummaryFunctions.getRootSummaryRow(fixture), 3, + ['Min', 'Max', 'Avg'], + ['0', '20,000', '3,900.4'] + ); + + column.disabledSummaries = ['count', 'sum', 'average']; + fixture.detectChanges(); + tick(); + GridSummaryFunctions.verifyColumnSummaries( + GridSummaryFunctions.getRootSummaryRow(fixture), 3, + ['Min', 'Max'], + ['0', '20,000'] + ); + + column.disabledSummaries = ['min', 'max']; + fixture.detectChanges(); + tick(); + GridSummaryFunctions.verifyColumnSummaries( + GridSummaryFunctions.getRootSummaryRow(fixture), 3, + ['Count', 'Sum', 'Avg'], + ['10', '39,004', '3,900.4'] + ); + })); + }); + + describe('custom summaries: ', () => { + let fixture: ComponentFixture; + let grid: IgxGridComponent; + + beforeEach(() => { + fixture = TestBed.createComponent(CustomSummariesComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + }); + + it('should properly render custom summaries', () => { + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fixture); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Sum', 'Avg'], ['10', '39,004', '3,900.4']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Earliest', 'Items InStock'], ['May 17, 1990', '1337']); + + grid.filter('UnitsInStock', '0', IgxNumberFilteringOperand.instance().condition('lessThan'), true); + fixture.detectChanges(); + + const filterResult = grid.rowList.length; + expect(filterResult).toEqual(0); + + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Sum', 'Avg'], ['0', '0', '0']); + }); + + it('should properly calculate all data custom summaries height', () => { + grid.getColumnByName('UnitsInStock').summaries = fixture.componentInstance.allDataAvgSummary; + grid.getColumnByName('OrderDate').summaries = fixture.componentInstance.allDataAvgSummary; + fixture.detectChanges(); + + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fixture); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Test 1', 'Test 2'], ['10', '50', '150']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count', 'Test 3'], ['10', '850']); + + grid.summaryRowHeight = undefined; + fixture.detectChanges(); + + const tFootHeight = GridFunctions.getGridFooterWrapper(fixture).nativeElement.getBoundingClientRect().height; + expect(tFootHeight).toBeGreaterThanOrEqual(3 * grid.defaultSummaryHeight); + }); + + it('should change custom summaries at runtime', () => { + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fixture); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Sum', 'Avg'], ['10', '39,004', '3,900.4']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Earliest', 'Items InStock'], ['May 17, 1990', '1337']); + GridSummaryFunctions.verifyVisibleSummariesHeight(fixture, 3, grid.defaultSummaryHeight); + grid.getColumnByName('UnitsInStock').summaries = fixture.componentInstance.dealsSummaryMinMax; + grid.summaryRowHeight = 0; + fixture.detectChanges(); + const tFootHeight = GridFunctions.getGridFooterWrapper(fixture).nativeElement.getBoundingClientRect().height; + expect(tFootHeight).toBe(2 * grid.defaultSummaryHeight); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['0', '20,000']); + GridSummaryFunctions.verifyVisibleSummariesHeight(fixture, 2, grid.defaultSummaryHeight); + }); + + it('should be able to access alldata from each summary', () => { + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fixture); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Sum', 'Avg'], ['10', '39,004', '3,900.4']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Earliest', 'Items InStock'], ['May 17, 1990', '1337']); + GridSummaryFunctions.verifyVisibleSummariesHeight(fixture, 3, grid.defaultSummaryHeight); + grid.getColumnByName('UnitsInStock').summaries = fixture.componentInstance.inStockSummary; + fixture.detectChanges(); + + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Min', 'Max', 'Sum', 'Avg', 'Items InStock'], + ['10', '0', '20,000', '39,004', '3,900.4', '6']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Earliest', 'Items InStock'], ['May 17, 1990', '1337']); + + grid.getCellByColumn(4, 'InStock').update(true); + fixture.detectChanges(); + + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Min', 'Max', 'Sum', 'Avg', 'Items InStock'], + ['10', '0', '20,000', '39,004', '3,900.4', '7']); + + grid.filter('UnitsInStock', 0, IgxNumberFilteringOperand.instance().condition('equals')); + fixture.detectChanges(); + + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Min', 'Max', 'Sum', 'Avg', 'Items InStock'], + ['3', '0', '0', '0', '0', '1']); + }); + + it('Last column summary cell should be aligned according to its data cells', () => { + grid.columnList.forEach(c => { + c.width = '150px'; + }); + grid.getColumnByName('UnitsInStock').hasSummary = true; + grid.width = '900px'; + grid.height = '500px'; + fixture.detectChanges(); + + // Get last cell of first data row + const lastColumnNormalCell = GridFunctions.getRowCells(fixture, 0)[4]; + const lastColumnNormalCellRect = lastColumnNormalCell.nativeElement.getBoundingClientRect(); + + // Get last summary cell of the summary row + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fixture); + const lastColumnSummaryCell = GridSummaryFunctions.getSummaryCellByVisibleIndex(summaryRow, 4); + const lastColumnSummaryCellRect = lastColumnSummaryCell.nativeElement.getBoundingClientRect(); + + expect(lastColumnSummaryCellRect.left).toBe(lastColumnNormalCellRect.left, + 'summary cell and data cell are not left aligned'); + expect(lastColumnSummaryCellRect.right).toBe(lastColumnNormalCellRect.right, + 'summary cell and data cell are not right aligned'); + }); + + it('should apply disabledSummaries with custom summary', fakeAsync(() => { + grid.enableSummaries([{ fieldName: 'UnitsInStock' }]); + fixture.detectChanges(); + tick(); + + const column = grid.getColumnByName('UnitsInStock'); + column.summaries = fixture.componentInstance.inStockSummary; + fixture.detectChanges(); + tick(); + + GridSummaryFunctions.verifyColumnSummaries( + GridSummaryFunctions.getRootSummaryRow(fixture), 3, + ['Count', 'Min', 'Max', 'Sum', 'Avg', 'Items InStock'], + ['10', '0', '20,000', '39,004', '3,900.4', '6'] + ); + + column.disabledSummaries = ['test']; + fixture.detectChanges(); + tick(); + + GridSummaryFunctions.verifyColumnSummaries( + GridSummaryFunctions.getRootSummaryRow(fixture), 3, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], + ['10', '0', '20,000', '39,004', '3,900.4'] + ); + })); + }); + + describe('specific data: ', () => { + let fixture: ComponentFixture; + let grid: IgxGridComponent; + beforeEach(() => { + fixture = TestBed.createComponent(FilteringComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + }); + + it('should have correct summaries when there are null and undefined values', () => { + grid.getColumnByName('ProductName').hasSummary = true; + grid.getColumnByName('Downloads').hasSummary = true; + grid.getColumnByName('Released').hasSummary = true; + grid.getColumnByName('ReleaseDate').hasSummary = true; + fixture.detectChanges(); + + const summaryRow = fixture.debugElement.query(By.css(SUMMARY_ROW)); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['8']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['8', '1', '1,000', '2,204', '275.5']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count'], ['8']); + const options: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric' }; + const earliest = SampleTestData.timeGenerator.timedelta(SampleTestData.today, 'month', -1).toLocaleString('us', options); + const latest = SampleTestData.timeGenerator.timedelta(SampleTestData.today, 'month', 1).toLocaleString('us', options); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count', 'Earliest', 'Latest'], ['8', earliest, latest]); + }); + }); + + describe('', () => { + let fix; + let grid: IgxGridComponent; + beforeEach(() => { + fix = TestBed.createComponent(SummaryColumnComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + }); + + it('should disableSummaries through grid API ', () => { + const summariedColumns = []; + grid.columnList.forEach((col) => { + if (col.hasSummary) { + summariedColumns.push(col.field); + } + }); + grid.disableSummaries(summariedColumns); + fix.detectChanges(); + + expect(GridSummaryFunctions.getRootSummaryRow(fix)).toBeNull(); + expect(grid.hasSummarizedColumns).toBe(false); + }); + + it('should change summary operand through grid API', () => { + grid.enableSummaries([{ fieldName: 'UnitsInStock', customSummary: fix.componentInstance.dealsSummaryMinMax }]); + grid.summaryRowHeight = 0; + fix.detectChanges(); + + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + const tFootHeight = GridFunctions.getGridFooterWrapper(fix).nativeElement.getBoundingClientRect().height; + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['0', '20,000']); + GridSummaryFunctions.verifyVisibleSummariesHeight(fix, 3, grid.defaultSummaryHeight); + expect(tFootHeight).toBe(3 * grid.defaultSummaryHeight); + }); + + it('should have summary per each column that \'hasSummary\'= true', () => { + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Min', 'Max', 'Sum', 'Avg'], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count', 'Earliest', 'Latest'], []); + }); + + it('should have count summary for string and boolean data types', () => { + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + grid.columnList.forEach((col) => { + if (col.hasSummary && (col.dataType === 'string' || col.dataType === 'boolean')) { + GridSummaryFunctions.verifyColumnSummaries(summaryRow, col.visibleIndex, ['Count'], []); + } + }); + }); + + it('should have count, min, max, avg and sum summary for numeric data types', () => { + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + grid.columnList.forEach((col) => { + if (col.hasSummary && (col.dataType === 'number')) { + GridSummaryFunctions.verifyColumnSummaries(summaryRow, col.visibleIndex, ['Count', 'Min', 'Max', 'Sum', 'Avg'], []); + } + }); + }); + + it('should have count, earliest and latest summary for \'date\' data types', () => { + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + grid.columnList.forEach((col) => { + if (col.hasSummary && (col.dataType === 'date')) { + GridSummaryFunctions.verifyColumnSummaries(summaryRow, col.visibleIndex, ['Count', 'Earliest', 'Latest'], []); + } + }); + }); + + it('should summary function stay active when is clicked on it\'s label', () => { + const summary = fix.debugElement.queryAll(By.css(SUMMARY_CELL))[3]; + const min: DebugElement = summary.query(By.css('[title=\'Min\']')); + + expect(min.parent.nativeElement.classList.contains(ITEM_CLASS)).toBeTruthy(); + min.triggerEventHandler('click', null); + fix.detectChanges(); + + expect(min.parent.nativeElement.classList.contains(ITEM_CLASS)).toBeTruthy(); + expect(summary.query(By.css('[title=\'Count\']')).parent.nativeElement.classList.contains(ITEM_CLASS)).toBeTruthy(); + expect(summary.query(By.css('[title=\'Max\']')).parent.nativeElement.classList.contains(ITEM_CLASS)).toBeTruthy(); + expect(summary.query(By.css('[title=\'Sum\']')).parent.nativeElement.classList.contains(ITEM_CLASS)).toBeTruthy(); + expect(summary.query(By.css('[title=\'Avg\']')).parent.nativeElement.classList.contains(ITEM_CLASS)).toBeTruthy(); + }); + + it('should calculate summaries for \'number\' dataType or return if no data is provided', () => { + const summaryClass = fix.componentInstance.numberSummary; + + const summaries = summaryClass.operate(fix.componentInstance.data.map((x) => x['UnitsInStock'])); + expect(summaries[0].summaryResult).toBe(10); + expect(summaries[1].summaryResult).toBe(0); + expect(summaries[2].summaryResult).toBe(20000); + expect(summaries[3].summaryResult).toBe(39004); + expect(summaries[4].summaryResult).toBe(3900.4); + + const emptySummaries = summaryClass.operate(); + expect(emptySummaries[0].summaryResult).toBe(0); + expect(typeof emptySummaries[1].summaryResult).not.toEqual(undefined); + expect(typeof emptySummaries[2].summaryResult).not.toEqual(undefined); + expect(typeof emptySummaries[3].summaryResult).not.toEqual(undefined); + expect(typeof emptySummaries[4].summaryResult).not.toEqual(undefined); + + expect(typeof emptySummaries[1].summaryResult).not.toEqual(null); + expect(typeof emptySummaries[2].summaryResult).not.toEqual(null); + expect(typeof emptySummaries[3].summaryResult).not.toEqual(null); + expect(typeof emptySummaries[4].summaryResult).not.toEqual(null); + + expect(emptySummaries[1].summaryResult === 0).toBeTruthy(); + expect(emptySummaries[2].summaryResult === 0).toBeTruthy(); + expect(emptySummaries[3].summaryResult === 0).toBeTruthy(); + expect(emptySummaries[4].summaryResult === 0).toBeTruthy(); + }); + + + it('should calculate summaries for \'date\' dataType or return if no data is provided', () => { + const summaryClass = fix.componentInstance.dateSummary; + const pipe = new DatePipe('en-US'); + + const summaries = summaryClass.operate(fix.componentInstance.data.map((x) => x['OrderDate'])); + expect(summaries[0].summaryResult).toBe(10); + expect(pipe.transform(summaries[1].summaryResult, 'mediumDate')).toBe('May 17, 1990'); + expect(pipe.transform(summaries[2].summaryResult, 'mediumDate')).toBe('Dec 25, 2025'); + + const emptySummaries = summaryClass.operate([]); + expect(emptySummaries[0].summaryResult).toBe(0); + expect(emptySummaries[1].summaryResult).toBe(undefined); + expect(emptySummaries[2].summaryResult).toBe(undefined); + }); + + it('should display summaries for \'date\' dataType based on column formatter', () => { + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + const pipe = new DatePipe('fr-FR'); + + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, + ['Count', 'Earliest', 'Latest'], ['10', 'May 17, 1990', 'Dec 25, 2025']); + + grid.getColumnByName('OrderDate').summaryFormatter + = ((summaryResult: IgxSummaryResult, summaryOperand: IgxSummaryOperand) => { + const result = summaryResult.summaryResult; + if (summaryOperand instanceof IgxDateSummaryOperand + && summaryResult.key !== 'count' && result !== null && result !== undefined) { + return pipe.transform(result, 'mediumDate'); + } + return result; + }); + fix.detectChanges(); + + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, + ['Count', 'Earliest', 'Latest'], ['10', '17 mai 1990', '25 déc. 2025']); + }); + + it('should calc tfoot height according number of summary functions', () => { + const summaries = fix.debugElement.queryAll(By.css(SUMMARY_CELL)); + const footerRow = GridSummaryFunctions.getRootSummaryRow(fix).nativeElement.getBoundingClientRect().height; + const tfootSize = +footerRow; + grid.summaryRowHeight = null; + fix.detectChanges(); + + const expectedHeight = GridSummaryFunctions.calcMaxSummaryHeight(grid.columnList, summaries, grid.defaultSummaryHeight); + + expect(tfootSize).toBe(expectedHeight); + }); + + it('should be able to change \'hasSummary\' property runtime and to recalculate grid sizes correctly', fakeAsync(() => { + grid.columnList.forEach((col) => { + if (col.field !== 'ProductID') { + expect(grid.getColumnByName(col.field).hasSummary).toBe(true); + } + }); + grid.getColumnByName('UnitsInStock').hasSummary = false; + grid.summaryRowHeight = 0; + tick(100); + fix.detectChanges(); + + expect(grid.getColumnByName('UnitsInStock').hasSummary).toBe(false); + + const summaries = fix.debugElement.queryAll(By.css(SUMMARY_CELL)).filter((el) => + el.nativeElement.classList.contains(EMPTY_SUMMARY_CLASS) === false); + const tfootSize = GridSummaryFunctions.getRootSummaryRow(fix).nativeElement.getBoundingClientRect().height; + const expectedHeight = GridSummaryFunctions.calcMaxSummaryHeight(grid.columnList, summaries, grid.defaultSummaryHeight); + expect(tfootSize).toBe(expectedHeight); + + grid.getColumnByName('ProductName').hasSummary = false; + grid.getColumnByName('InStock').hasSummary = false; + grid.getColumnByName('OrderDate').hasSummary = false; + fix.detectChanges(); + tick(100); + expect(GridSummaryFunctions.getRootSummaryRow(fix)).toBeNull(); + expect(grid.hasSummarizedColumns).toBe(false); + })); + + it('should render correct data after hiding one bigger and then one smaller summary when scrolled to the bottom', (async () => { + grid.data = SampleTestData.foodProductData(); + grid.width = '800px'; + grid.height = '600px'; + grid.allowFiltering = false; + fix.detectChanges(); + await wait(100); + + let rowsRendered; + let tbody; + let expectedRowLenght; + let firstCellsText; + + GridFunctions.scrollTop(grid, 10000); + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + + rowsRendered = GridFunctions.getRows(fix); + tbody = GridFunctions.getGridDisplayContainer(fix).nativeElement.getBoundingClientRect().height; + expectedRowLenght = Math.round(parseFloat(tbody) / grid.defaultRowHeight); + expect(rowsRendered.length).toEqual(expectedRowLenght); + + grid.disableSummaries(['ProductName', 'InStock', 'UnitsInStock']); + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + + rowsRendered = GridFunctions.getRows(fix); + tbody = GridFunctions.getGridDisplayContainer(fix).nativeElement.getBoundingClientRect().height; + expectedRowLenght = Math.ceil(parseFloat(tbody) / grid.defaultRowHeight) - 1; + + firstCellsText = rowsRendered.map((item) => { + const cellElem = GridFunctions.getRowCells(fix, 0, item)[0]; + if (cellElem) { + return GridFunctions.getValueFromCellElement(cellElem); + } + }); + expect(rowsRendered.length).toEqual(expectedRowLenght); + let expectedFirstCellNum = grid.data.length - expectedRowLenght + 1; + + for (let i = 0; i < rowsRendered.length - 1; i++) { + expect(firstCellsText[i]).toEqual((expectedFirstCellNum + i).toString()); + } + + grid.disableSummaries(['OrderDate']); + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + + rowsRendered = GridFunctions.getRows(fix); + tbody = GridFunctions.getGridDisplayContainer(fix).nativeElement.getBoundingClientRect().height; + expectedRowLenght = Math.ceil(parseFloat(tbody) / grid.defaultRowHeight) - 1; + + firstCellsText = rowsRendered.map((item) => { + const cellElem = GridFunctions.getRowCells(fix, 0, item)[0]; + if (cellElem) { + return GridFunctions.getValueFromCellElement(cellElem); + } + }); + expect(rowsRendered.length).toEqual(expectedRowLenght); + expectedFirstCellNum = grid.data.length - expectedRowLenght + 1; + for (let i = 0; i < rowsRendered.length - 1; i++) { + expect(firstCellsText[i]).toEqual((expectedFirstCellNum + i).toString()); + } + })); + + it('should be able to set the height of the summary row through summaryRowHeight input unless the value is falsy', () => { + const summaryRow = fix.debugElement.query(By.css(SUMMARY_ROW)); + const summaryRowHeight = 300; + grid.summaryRowHeight = summaryRowHeight; + fix.detectChanges(); + + expect(summaryRow.nativeElement.offsetHeight).toBe(summaryRowHeight); + + grid.summaryRowHeight = 0; + fix.detectChanges(); + + expect(summaryRow.nativeElement.offsetHeight).not.toBeFalsy(); + + grid.summaryRowHeight = null; + fix.detectChanges(); + + expect(summaryRow.nativeElement.offsetHeight).not.toBeFalsy(); + }); + + it('should display the formatted summary value in the summary cells\' tooltip', () => { + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + const pipe = new DatePipe('fr-FR'); + + const summary = GridSummaryFunctions.getSummaryCellByVisibleIndex(summaryRow, 4); + const summaryItems = summary.queryAll(By.css('.igx-grid-summary__item')); + + let summaryResultsValues = ['10', 'May 17, 1990', 'Dec 25, 2025']; + + for (let i = 0; i < summaryItems.length; i++) { + const summaryItem = summaryItems[i]; + const summaryResult = summaryItem.query(By.css('.igx-grid-summary__result')); + const summaryResultTooltip = summaryResult.nativeElement.getAttribute('title'); + expect(summaryResultsValues[i]).toEqual(summaryResultTooltip); + } + grid.getColumnByName('OrderDate').summaryFormatter + = ((summaryResult: IgxSummaryResult, summaryOperand: IgxSummaryOperand) => { + const result = summaryResult.summaryResult; + if (summaryOperand instanceof IgxDateSummaryOperand + && summaryResult.key !== 'count' && result !== null && result !== undefined) { + return pipe.transform(result, 'mediumDate'); + } + return result; + }); + fix.detectChanges(); + + summaryResultsValues = ['10', '17 mai 1990', '25 déc. 2025']; + + for (let i = 0; i < summaryItems.length; i++) { + const summaryItem = summaryItems[i]; + const summaryResult = summaryItem.query(By.css('.igx-grid-summary__result')); + const summaryResultTooltip = summaryResult.nativeElement.getAttribute('title'); + expect(summaryResultsValues[i]).toEqual(summaryResultTooltip); + } + }); + }); + }); + + describe('Integration Scenarios: ', () => { + describe('', () => { + let fix; + let grid: IgxGridComponent; + beforeEach(() => { + fix = TestBed.createComponent(SummaryColumnComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + }); + + it('Filtering: should calculate summaries only over filteredData', fakeAsync(() => { + grid.filter('UnitsInStock', 0, IgxNumberFilteringOperand.instance().condition('equals'), true); + fix.detectChanges(); + + let filterResult = grid.rowList.length; + expect(filterResult).toEqual(3); + + let summaryRow = fix.debugElement.query(By.css(SUMMARY_ROW)); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['3']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['3', '0', '0', '0', '0']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, + ['Count', 'Earliest', 'Latest'], ['3', 'Jul 27, 2001', 'Oct 11, 2007']); + + grid.filter('ProductID', 0, IgxNumberFilteringOperand.instance().condition('equals'), true); + fix.detectChanges(); + + filterResult = grid.rowList.length; + expect(filterResult).toEqual(0); + + summaryRow = fix.debugElement.query(By.css(SUMMARY_ROW)); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['0']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['0']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['0', '0', '0', '0', '0']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count', 'Earliest', 'Latest'], ['0', '', '']); + + grid.clearFilter(); + fix.detectChanges(); + + filterResult = grid.rowList.length; + expect(filterResult).toEqual(10); + + summaryRow = fix.debugElement.query(By.css(SUMMARY_ROW)); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['10']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['10']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['10', '0', '20,000', '39,004', '3,900.4']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, + ['Count', 'Earliest', 'Latest'], ['10', 'May 17, 1990', 'Dec 25, 2025']); + })); + + it('Moving: should move summaries when move column', fakeAsync(() => { + grid.moving = true; + const colUnitsInStock = grid.getColumnByName('UnitsInStock'); + const colProductID = grid.getColumnByName('ProductID'); + fix.detectChanges(); + + grid.moveColumn(colUnitsInStock, colProductID, DropPosition.BeforeDropTarget); + tick(); + fix.detectChanges(); + + const summaryRow = fix.debugElement.query(By.css(SUMMARY_ROW)); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['10', '0', '20,000', '39,004', '3,900.4']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['10']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count'], ['10']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, + ['Count', 'Earliest', 'Latest'], ['10', 'May 17, 1990', 'Dec 25, 2025']); + })); + + it('Hiding: should hide summary row when a column which has summary is hidded', fakeAsync(() => { + grid.getColumnByName('ProductName').hasSummary = false; + grid.getColumnByName('InStock').hasSummary = false; + grid.getColumnByName('OrderDate').hasSummary = false; + // grid.recalculateSummaries(); + fix.detectChanges(); + tick(100); + + let summaryRow = fix.debugElement.query(By.css(SUMMARY_ROW)); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['10', '0', '20,000', '39,004', '3,900.4']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, [], []); + + grid.getColumnByName('UnitsInStock').hidden = true; + tick(); + fix.detectChanges(); + + + summaryRow = fix.debugElement.query(By.css(SUMMARY_ROW)); + expect(summaryRow).toBeNull(); + expect(grid.hasSummarizedColumns).toBe(false); + + grid.getColumnByName('UnitsInStock').hidden = false; + tick(); + fix.detectChanges(); + summaryRow = fix.debugElement.query(By.css(SUMMARY_ROW)); + expect(summaryRow).toBeDefined(); + expect(grid.hasSummarizedColumns).toBe(true); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['10', '0', '20,000', '39,004', '3,900.4']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, [], []); + })); + + it('Hiding: should recalculate summary area after column with enabled summary is hidden', fakeAsync(() => { + grid.summaryRowHeight = undefined; + let tFoot = GridFunctions.getGridFooterWrapper(fix).nativeElement.getBoundingClientRect().height; + expect(tFoot).toEqual(5 * grid.defaultSummaryHeight); + + grid.getColumnByName('UnitsInStock').hidden = true; + tick(); + fix.detectChanges(); + + tFoot = GridFunctions.getGridFooterWrapper(fix).nativeElement.getBoundingClientRect().height; + expect(tFoot).toEqual(3 * grid.defaultSummaryHeight); + expect(grid.hasSummarizedColumns).toBe(true); + + let summaryRow = fix.debugElement.query(By.css(SUMMARY_ROW)); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['10']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['10']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['10', 'May 17, 1990', 'Dec 25, 2025']); + + grid.getColumnByName('UnitsInStock').hidden = false; + grid.summaryRowHeight = 0; + tick(); + fix.detectChanges(); + + expect(grid.hasSummarizedColumns).toBe(true); + tFoot = GridFunctions.getGridFooterWrapper(fix).nativeElement.getBoundingClientRect().height; + expect(tFoot).toEqual(5 * grid.defaultSummaryHeight); + summaryRow = fix.debugElement.query(By.css(SUMMARY_ROW)); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['10']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['10']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['10', '0', '20,000', '39,004', '3,900.4']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, + ['Count', 'Earliest', 'Latest'], ['10', 'May 17, 1990', 'Dec 25, 2025']); + })); + + it('CRUD: should recalculate summary functions rowAdded', () => { + grid.addRow({ + ProductID: 11, ProductName: 'Belgian Chocolate', InStock: true, UnitsInStock: 99000, OrderDate: ymd('2018-03-01') + }); + fix.detectChanges(); + + const summaryRow = fix.debugElement.query(By.css(SUMMARY_ROW)); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['11']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['11']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['11', '0', '99,000', '138,004', '12,545.818']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, + ['Count', 'Earliest', 'Latest'], ['11', 'May 17, 1990', 'Dec 25, 2025']); + }); + + it('CRUD: should recalculate summary functions rowDeleted', () => { + grid.deleteRow(9); + fix.detectChanges(); + + const summaryRow = fix.debugElement.query(By.css(SUMMARY_ROW)); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['9']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['9']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['9', '0', '20,000', '32,006', '3,556.222']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, + ['Count', 'Earliest', 'Latest'], ['9', 'May 17, 1990', 'Mar 1, 2018']); + }); + + it('CRUD: should recalculate summary functions on updateRow', () => { + const productNameCell = grid.getCellByColumn(0, 'ProductName'); + const unitsInStockCell = grid.getCellByColumn(0, 'UnitsInStock'); + + expect(productNameCell.value).toBe('Chai'); + expect(unitsInStockCell.value).toBe(2760); + + grid.updateRow({ + ProductID: 1, ProductName: 'Spearmint', InStock: true, UnitsInStock: 510000, OrderDate: ymd('1984-03-21') + }, 1); + fix.detectChanges(); + + expect(productNameCell.value).toBe('Spearmint'); + expect(unitsInStockCell.value).toBe(510000); + + const summaryRow = fix.debugElement.query(By.css(SUMMARY_ROW)); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['10']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['10']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['10', '0', '510,000', '546,244', '54,624.4']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, + ['Count', 'Earliest', 'Latest'], ['10', 'Mar 21, 1984', 'Dec 25, 2025']); + }); + + it('CRUD: should recalculate summary functions on cell update', () => { + const unitsInStockCell = grid.getCellByColumn(0, 'UnitsInStock'); + unitsInStockCell.update(99000); + fix.detectChanges(); + + let summaryRow = fix.debugElement.query(By.css(SUMMARY_ROW)); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['10', '0', '99,000', '135,244', '13,524.4']); + + unitsInStockCell.update(-12); + fix.detectChanges(); + summaryRow = fix.debugElement.query(By.css(SUMMARY_ROW)); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['10', '-12', '20,000', '36,232', '3,623.2']); + }); + + it('Pinning: should display all active summaries after column pinning', () => { + grid.pinColumn('UnitsInStock'); + grid.pinColumn('ProductID'); + fix.detectChanges(); + + const summaryRow = fix.debugElement.query(By.css(SUMMARY_ROW)); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['10', '0', '20,000', '39,004', '3,900.4']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['10']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count'], ['10']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, + ['Count', 'Earliest', 'Latest'], ['10', 'May 17, 1990', 'Dec 25, 2025']); + }); + + it('CRUD: Apply filter and update cell', () => { + grid.filter('ProductName', 'ch', IgxStringFilteringOperand.instance().condition('contains')); + fix.detectChanges(); + + let summaryRow = fix.debugElement.query(By.css(SUMMARY_ROW)); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['4']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['4', '52', '20,000', '29,810', '7,452.5']); + + const cell = grid.getCellByColumn(2, 'ProductName'); + cell.update('Teatime Cocoa Biscuits'); + fix.detectChanges(); + + summaryRow = fix.debugElement.query(By.css(SUMMARY_ROW)); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['3', '52', '20,000', '22,812', '7,604']); + }); + }); + }); + + describe('Keyboard Navigation', () => { + let fix; + let grid: IgxGridComponent; + beforeEach(() => { + fix = TestBed.createComponent(SummariesGroupByComponent); + grid = fix.componentInstance.grid; + setupGridScrollDetection(fix, grid); + grid.width = '800px'; + grid.height = '800px'; + fix.detectChanges(); + }); + + afterEach(() => { + clearGridSubs(); + }); + + it('should be able to select summaries with arrow keys', async () => { + const gridFooter = GridFunctions.getGridFooter(fix); + let summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.focusSummaryCell(fix, summaryRow, 0); + + for (let i = 0; i < 5; i++) { + GridSummaryFunctions.verifySummaryCellActive(fix, summaryRow, i); + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridFooter); + await wait(DEBOUNCETIME); + fix.detectChanges(); + } + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['8', '25', '50', '293', '36.625']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 5, ['Count'], ['8']); + + for (let i = 5; i > 0; i--) { + GridSummaryFunctions.verifySummaryCellActive(fix, summaryRow, i); + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridFooter); + await wait(DEBOUNCETIME); + fix.detectChanges(); + } + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['8', '17', '847', '2,188', '273.5']); + }); + + it('should be able to navigate with Arrow keys and Ctrl', async () => { + const gridFooter = GridFunctions.getGridFooter(fix); + let summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.focusSummaryCell(fix, summaryRow, 1); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridFooter, false, false, true); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + GridSummaryFunctions.verifySummaryCellActive(fix, summaryRow, 5); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['8', '25', '50', '293', '36.625']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 5, ['Count'], ['8']); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridFooter, false, false, true); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + GridSummaryFunctions.verifySummaryCellActive(fix, summaryRow, 0); + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['8', '17', '847', '2,188', '273.5']); + }); + + it('should not change active summary cell when press Arrow Down and Up', () => { + const gridFooter = GridFunctions.getGridFooter(fix); + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.focusSummaryCell(fix, summaryRow, 1); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridFooter); + fix.detectChanges(); + GridSummaryFunctions.verifySummaryCellActive(fix, summaryRow, 1); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridFooter); + fix.detectChanges(); + GridSummaryFunctions.verifySummaryCellActive(fix, summaryRow, 1); + }); + + it('Grouping: should be able to select summaries with arrow keys', async () => { + const gridContent = GridFunctions.getGridContent(fix); + grid.groupBy({ + fieldName: 'ParentID', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + + GridSummaryFunctions.focusSummaryCell(fix, 3, 0); + for (let i = 0; i < 5; i++) { + GridSummaryFunctions.verifySummaryCellActive(fix, 3, i); + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + } + + let summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 3); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['2', '27', '50', '77', '38.5']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 5, ['Count'], ['2']); + + for (let i = 5; i > 0; i--) { + GridSummaryFunctions.verifySummaryCellActive(fix, 3, i); + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + } + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 3); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['2', '17', '17', '34', '17']); + }); + + it('Grouping: should not change active summary cell when press Ctrl+ArrowUp/Down', () => { + grid.groupBy({ + fieldName: 'ParentID', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + + GridSummaryFunctions.focusSummaryCell(fix, 3, 1); + GridSummaryFunctions.verifySummaryCellActive(fix, 3, 1); + + const summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 3); + const summaryCell = GridSummaryFunctions.getSummaryCellByVisibleIndex(summaryRow, 1); + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', summaryCell, false, false, true); + fix.detectChanges(); + GridSummaryFunctions.verifySummaryCellActive(fix, 3, 1); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', summaryCell, false, false, true); + fix.detectChanges(); + GridSummaryFunctions.verifySummaryCellActive(fix, 3, 1); + }); + + it('Grouping: should be able to navigate with Arrow keys Right/Left and Ctrl', async () => { + grid.groupBy({ + fieldName: 'ParentID', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + const gridContent = GridFunctions.getGridContent(fix); + GridSummaryFunctions.focusSummaryCell(fix, 3, 1); + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent, false, false, true); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + GridSummaryFunctions.verifySummaryCellActive(fix, 3, 5); + let summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 3); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['2', '27', '50', '77', '38.5']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 5, ['Count'], ['2']); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent, false, false, true); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + GridSummaryFunctions.verifySummaryCellActive(fix, 3, 0); + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 3); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['2', '17', '17', '34', '17']); + }); + + it('Grouping: should not change active summary cell when press CTRL+Home/End ', () => { + grid.groupBy({ + fieldName: 'ParentID', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + + GridSummaryFunctions.focusSummaryCell(fix, 3, 1); + GridSummaryFunctions.verifySummaryCellActive(fix, 3, 1); + + const summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 3); + const summaryCell = GridSummaryFunctions.getSummaryCellByVisibleIndex(summaryRow, 1); + UIInteractions.triggerEventHandlerKeyDown('End', summaryCell, false, false, true); + fix.detectChanges(); + GridSummaryFunctions.verifySummaryCellActive(fix, 3, 1); + + UIInteractions.triggerEventHandlerKeyDown('Home', summaryCell, false, false, true); + fix.detectChanges(); + GridSummaryFunctions.verifySummaryCellActive(fix, 3, 1); + }); + + it('Grouping: should navigate with arrow keys from cell to summary row ', () => { + grid.tbody.nativeElement.focus(); + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ParentID', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + + let cell = grid.getCellByColumn(2, 'ID'); + const cellElem = grid.gridAPI.get_cell_by_index(2, 'ID'); + UIInteractions.simulateClickAndSelectEvent(cellElem); + fix.detectChanges(); + + cell = grid.getCellByColumn(2, 'ID'); + expect(cell.selected).toBe(true); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', grid.tbody.nativeElement, true); + fix.detectChanges(); + + cell = grid.getCellByColumn(2, 'ID'); + expect(cell.selected).toBe(true); + GridSummaryFunctions.verifySummaryCellActive(fix, 3, 0); + + GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown'); + fix.detectChanges(); + + GridSummaryFunctions.verifySummaryCellActive(fix, 3, 0, false); + + cell = grid.getCellByColumn(2, 'ID'); + expect(cell.selected).toBe(true); + + const row = grid.gridAPI.get_row_by_index(4); + expect(row instanceof IgxGridGroupByRowComponent).toBe(true); + expect(row.focused).toBe(true); + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', grid.tbody.nativeElement, true); + fix.detectChanges(); + + GridSummaryFunctions.verifySummaryCellActive(fix, 3, 0); + + cell = grid.getCellByColumn(2, 'ID'); + expect(cell.selected).toBe(true); + GridFunctions.simulateGridContentKeydown(fix, 'ArrowRight'); + fix.detectChanges(); + + GridSummaryFunctions.verifySummaryCellActive(fix, 3, 1); + + // summaryCell = GridSummaryFunctions.getSummaryCellByVisibleIndex(summaryRow, 1); + GridFunctions.simulateGridContentKeydown(fix, 'ArrowUp'); + fix.detectChanges(); + cell = grid.getCellByColumn(2, 'ParentID'); + expect(cell.selected).toBe(true); + cell = grid.getCellByColumn(2, 'ID'); + expect(cell.selected).toBe(false); + }); + + it('should navigate with tab to filter row if the grid is empty', () => { + pending('this test need to be written again when the header are ready'); + grid.allowFiltering = true; + grid.filter('ID', 0, IgxNumberFilteringOperand.instance().condition('lessThanOrEqualTo')); + fix.detectChanges(); + + GridFunctions.clickFilterCellChip(fix, 'ParentID'); + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + + GridSummaryFunctions.focusSummaryCell(fix, summaryRow, 0); + const summaryCell = GridSummaryFunctions.getSummaryCellByVisibleIndex(summaryRow, 0); + UIInteractions.triggerEventHandlerKeyDown('Tab', summaryCell, false, true); + + fix.detectChanges(); + const closeButton = GridFunctions.getFilterRowCloseButton(fix); + expect(document.activeElement).toEqual(closeButton.nativeElement); + + // TO DO update with correct method + // grid.filteringRow.onTabKeydown(UIInteractions.getKeyboardEvent('keydown', 'tab')); + fix.detectChanges(); + + GridSummaryFunctions.verifySummaryCellActive(fix, 0, 0); + }); + }); + + describe('CRUD with transactions: ', () => { + let fix; + let grid; + beforeEach(() => { + fix = TestBed.createComponent(SummariesGroupByTransactionsComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + }); + + it('Add row', () => { + let newRow = { + ID: 777, + ParentID: 17, + Name: 'New Employee', + HireDate: new Date(2019, 3, 3), + Age: 19, + OnPTO: true + }; + grid.addRow(newRow); + fix.detectChanges(); + + let summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Min', 'Max'], ['19', '50']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['9']); + + // Undo transactions + grid.transactions.undo(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Min', 'Max'], ['25', '50']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['8']); + + // redo transactions + grid.transactions.redo(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Min', 'Max'], ['19', '50']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['9']); + + // Commit + grid.transactions.commit(fix.componentInstance.data); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Min', 'Max'], ['19', '50']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['9']); + + newRow = { + ID: 999, + ParentID: 17, + Name: 'New Employee', + HireDate: new Date(2019, 3, 3), + Age: 19, + OnPTO: true + }; + grid.addRow(newRow); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['10']); + + // Discard + grid.transactions.clear(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Min', 'Max'], ['19', '50']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['9']); + }); + + it('Delete row', () => { + grid.deleteRow(475); + fix.detectChanges(); + + let summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['7']); + + // Undo transactions + grid.transactions.undo(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['8']); + + // redo transactions + grid.transactions.redo(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['7']); + + // Commit + grid.transactions.commit(fix.componentInstance.data); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['7']); + + grid.deleteRow(317); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['6']); + + // Discard + grid.transactions.clear(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['7']); + }); + + it('Update row', () => { + let newRow = { + ID: 12, + ParentID: 17, + Name: 'New Employee', + HireDate: new Date(2019, 3, 3), + Age: 19 + }; + grid.updateRow(newRow, 12); + fix.detectChanges(); + + let summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['8', 'Jul 19, 2009', 'Apr 3, 2019']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Min', 'Max'], ['19', '44']); + + // Undo transactions + grid.transactions.undo(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['8', 'Dec 18, 2007', 'Dec 9, 2017']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Min', 'Max'], ['25', '50']); + + // redo transactions + grid.transactions.redo(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['8', 'Jul 19, 2009', 'Apr 3, 2019']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Min', 'Max'], ['19', '44']); + + // Commit + grid.transactions.commit(fix.componentInstance.data); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['8', 'Jul 19, 2009', 'Apr 3, 2019']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Min', 'Max'], ['19', '44']); + + newRow = { + ID: 957, + ParentID: 17, + Name: 'New Employee', + HireDate: new Date(2000, 4, 4), + Age: 65 + }; + grid.updateRow(newRow, 957); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Earliest', 'Latest'], ['8', 'May 4, 2000', 'Apr 3, 2019']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Min', 'Max'], ['19', '65']); + + // Discard + grid.transactions.clear(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['8', 'Jul 19, 2009', 'Apr 3, 2019']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Min', 'Max'], ['19', '44']); + }); + + it('Update cell', () => { + grid.updateCell(19, grid.getRowByKey(12).key, 'Age'); + fix.detectChanges(); + + let summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Min', 'Max'], ['19', '44']); + + // Undo transactions + grid.transactions.undo(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Min', 'Max'], ['25', '50']); + + // redo transactions + grid.transactions.redo(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Min', 'Max'], ['19', '44']); + + // Commit + grid.transactions.commit(fix.componentInstance.data); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Min', 'Max'], ['19', '44']); + + grid.updateCell(65, grid.getRowByKey(957).key, 'Age'); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Min', 'Max'], ['19', '65']); + + // Discard + grid.transactions.clear(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Min', 'Max'], ['19', '44']); + }); + + it('Grouping: Add grouped row', (async () => { + grid.groupBy({ + fieldName: 'ParentID', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + + grid.verticalScrollContainer.scrollTo(grid.dataView.length - 1); + await wait(100); + fix.detectChanges(); + + const newRow = { + ID: 777, + ParentID: 17, + Name: 'New Employee', + HireDate: new Date(2019, 3, 3), + Age: 19, + OnPTO: true + }; + grid.addRow(newRow); + fix.detectChanges(); + + let summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Min', 'Max'], ['19', '50']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 5, ['Count'], ['9']); + + grid.verticalScrollContainer.scrollTo(0); + await wait(100); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 4); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 5, ['Count'], ['3']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Min', 'Max'], ['19', '50']); + + // Undo transactions + grid.transactions.undo(); + fix.detectChanges(); + + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 0, 2, ['Count'], ['8']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 3, 5, ['Count'], ['2']); + + // redo transactions + grid.transactions.redo(); + fix.detectChanges(); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 0, 4, ['Min', 'Max'], ['19', '50']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 0, 5, ['Count'], ['9']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 4, 4, ['Min', 'Max'], ['19', '50']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 4, 5, ['Count'], ['3']); + + fix.detectChanges(); + // Get row by index and check it's type + GridSummaryFunctions.verifyRowWithIndexIsOfType(grid, 0, IgxGroupByRow); + GridSummaryFunctions.verifyRowWithIndexIsOfType(grid, 1, IgxGridRow); + GridSummaryFunctions.verifyRowWithIndexIsOfType(grid, 4, IgxSummaryRow); + + // Check the API members - isSummaryRow + const summaryRow4 = grid.getRowByIndex(4); + expect(summaryRow4.isSummaryRow).toBe(true); + // Check rowID, rowData, data, disabled + expect(summaryRow4.key).toBeUndefined(); + expect(summaryRow4.data).toBeUndefined(); + expect(summaryRow4.data).toBeUndefined(); + expect(summaryRow4.pinned).toBeUndefined(); + expect(summaryRow4.selected).toBeUndefined(); + + // Get row by index and check summaries member + expect(summaryRow4 instanceof IgxSummaryRow).toBe(true); + expect(summaryRow4.summaries instanceof Map).toBe(true); + })); + + it('Grouping: Add not grouped row', (async () => { + grid.groupBy({ + fieldName: 'ParentID', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + + const newRow = { + ID: 777, + ParentID: 999, + Name: 'New Employee', + HireDate: null, + Age: 19, + OnPTO: true + }; + grid.addRow(newRow); + fix.detectChanges(); + + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['9']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['9', 'Dec 18, 2007', 'Dec 9, 2017']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Min', 'Max'], ['19', '50']); + + grid.verticalScrollContainer.scrollTo(grid.dataView.length - 1); + await wait(50); + fix.detectChanges(); + + let row = grid.gridAPI.get_row_by_index(16); + expect(row instanceof IgxGridGroupByRowComponent).toBe(true); + + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 18, 4, ['Min', 'Max'], ['19', '19']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 18, 5, ['Count'], ['1']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 18, 3, ['Count', 'Earliest', 'Latest'], ['1', '', '']); + + // Undo transactions + grid.transactions.undo(); + await wait(50); + fix.detectChanges(); + row = grid.gridAPI.get_row_by_index(16); + expect(row).toBeUndefined(); + + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 0, 2, ['Count'], ['8']); + + // redo transactions + grid.transactions.redo(); + await wait(50); + fix.detectChanges(); + + row = grid.gridAPI.get_row_by_index(16); + expect(row instanceof IgxGridGroupByRowComponent).toBe(true); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 0, 4, ['Min', 'Max'], ['19', '50']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 0, 5, ['Count'], ['9']); + + grid.verticalScrollContainer.scrollTo(grid.dataView.length - 1); + await wait(50); + fix.detectChanges(); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 18, 5, ['Count'], ['1']); + + // Discard + grid.transactions.clear(); + await wait(50); + fix.detectChanges(); + + row = grid.gridAPI.get_row_by_index(16); + expect(row).toBeUndefined(); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 0, 2, ['Count'], ['8']); + })); + + it('Grouping: delete row', () => { + grid.groupBy({ + fieldName: 'ParentID', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + + grid.deleteRow(grid.getRowByIndex(1).key); + fix.detectChanges(); + + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 0, 2, ['Count'], ['7']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 3, 2, ['Count'], ['1']); + + grid.deleteRow(grid.getRowByIndex(2).key); + fix.detectChanges(); + + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 0, 2, ['Count'], ['6']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 3, 2, ['Count'], ['0']); + + const row = grid.gridAPI.get_row_by_index(0); + expect(row instanceof IgxGridGroupByRowComponent).toBe(true); + + // Commit transactions + grid.transactions.commit(fix.componentInstance.data); + fix.detectChanges(); + + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 0, 2, ['Count'], ['6']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 2, 2, ['Count'], ['1']); + }); + + it('Grouping: Update row and keep grouping', () => { + grid.groupBy({ + fieldName: 'ParentID', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + + const newRow = { + ID: 101, + ParentID: 17, + Name: 'New Employee', + HireDate: new Date(2019, 3, 3), + Age: 19 + }; + grid.updateRow(newRow, 101); + fix.detectChanges(); + + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 0, 2, ['Count'], ['8']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 0, 4, ['Min', 'Max'], ['19', '50']); + + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 3, 2, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 3, 4, ['Min', 'Max'], ['19', '50']); + }); + + it('CRUD: summaries should be updated when row is submitted when rowEditable=true', fakeAsync(() => { + grid.getColumnByName('Age').editable = true; + grid.getColumnByName('HireDate').editable = true; + fix.detectChanges(); + grid.rowEditable = true; + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ParentID', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + + let summaryRow = fix.debugElement.query(By.css(SUMMARY_ROW)); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Min', 'Max'], ['27', '50']); + + UIInteractions.simulateDoubleClickAndSelectEvent(grid.gridAPI.get_cell_by_index(1, 'Age')); + flush(); + fix.detectChanges(); + + const editTemplate = fix.debugElement.query(By.css('input[type=\'number\']')); + UIInteractions.clickAndSendInputElementValue(editTemplate, 87); + flush(); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('tab', grid.gridAPI.get_cell_by_index(1, 'Age').nativeElement, true, false, true); + flush(); + fix.detectChanges(); + + summaryRow = fix.debugElement.query(By.css(SUMMARY_ROW)); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Min', 'Max'], ['27', '50']); + + UIInteractions.triggerKeyDownEvtUponElem('enter', grid.gridAPI.get_cell_by_index(1, 'HireDate').nativeElement, true); + flush(); + fix.detectChanges(); + + summaryRow = fix.debugElement.query(By.css(SUMMARY_ROW)); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Min', 'Max'], ['27', '87']); + })); + + it('Grouping: Update row and change grouping', (async () => { + grid.getColumnByName('HireDate').hasSummary = false; + grid.groupBy({ + fieldName: 'ParentID', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + + const newRow = { + ID: 101, + ParentID: 19, + Name: 'New Employee', + HireDate: new Date(2019, 3, 3), + Age: 19 + }; + grid.updateRow(newRow, 101); + fix.detectChanges(); + + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 0, 2, ['Count'], ['8']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 0, 4, ['Min', 'Max'], ['19', '50']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 6, 2, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 6, 4, ['Min', 'Max'], ['19', '44']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 2, 2, ['Count'], ['1']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 2, 4, ['Min', 'Max'], ['50', '50']); + + // Undo transactions + grid.transactions.undo(); + fix.detectChanges(); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 6, 2, ['Count'], ['1']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 6, 4, ['Min', 'Max'], ['44', '44']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 3, 2, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 3, 4, ['Min', 'Max'], ['27', '50']); + + // Redo transactions + grid.transactions.redo(); + fix.detectChanges(); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 6, 2, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 6, 4, ['Min', 'Max'], ['19', '44']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 2, 2, ['Count'], ['1']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 2, 4, ['Min', 'Max'], ['50', '50']); + + })); + + it('Grouping: Update row and add new group', () => { + grid.groupBy({ + fieldName: 'ParentID', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + + const newRow = { + ID: 12, + ParentID: -2, + Name: 'New Employee', + HireDate: new Date(2019, 3, 3), + Age: 19 + }; + grid.updateRow(newRow, 12); + fix.detectChanges(); + + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 0, 2, ['Count'], ['8']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 0, 4, ['Min', 'Max'], ['19', '44']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 2, 2, ['Count'], ['1']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 2, 4, ['Min', 'Max'], ['19', '19']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 5, 2, ['Count'], ['1']); + + // Undo transactions + grid.transactions.undo(); + fix.detectChanges(); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 0, 4, ['Min', 'Max'], ['25', '50']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 3, 2, ['Count'], ['2']); + + // Redo transactions + grid.transactions.redo(); + fix.detectChanges(); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 0, 2, ['Count'], ['8']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 0, 4, ['Min', 'Max'], ['19', '44']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 2, 2, ['Count'], ['1']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 2, 4, ['Min', 'Max'], ['19', '19']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 5, 2, ['Count'], ['1']); + }); + + it('Grouping: Update row and change group name', () => { + grid.groupBy({ + fieldName: 'ParentID', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + + const newRow1 = { + ID: 12, + ParentID: -2, + Name: 'New Employee', + HireDate: new Date(2019, 3, 3), + Age: 19 + }; + grid.updateRow(newRow1, 12); + + const newRow2 = { + ID: 101, + ParentID: -2, + Name: 'New Employee2', + HireDate: new Date(2015, 5, 5), + Age: 60 + }; + grid.updateRow(newRow2, 101); + fix.detectChanges(); + + + let groupRows = grid.groupsRowList.toArray(); + expect(groupRows[0].groupRow.value).toEqual(-2); + expect(groupRows[1].groupRow.value).toEqual(19); + + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 0, 2, ['Count'], ['8']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 0, 4, ['Min', 'Max'], ['19', '60']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 3, 2, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 3, 4, ['Min', 'Max'], ['19', '60']); + + // Undo transactions + grid.transactions.undo(); + grid.transactions.undo(); + fix.detectChanges(); + groupRows = grid.groupsRowList.toArray(); + expect(groupRows[0].groupRow.value).toEqual(17); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 0, 4, ['Min', 'Max'], ['25', '50']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 3, 2, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 3, 4, ['Min', 'Max'], ['27', '50']); + + // Redo transactions + grid.transactions.redo(); + grid.transactions.redo(); + fix.detectChanges(); + groupRows = grid.groupsRowList.toArray(); + expect(groupRows[0].groupRow.value).toEqual(-2); + expect(groupRows[1].groupRow.value).toEqual(19); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 0, 2, ['Count'], ['8']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 0, 4, ['Min', 'Max'], ['19', '60']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 3, 2, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 3, 4, ['Min', 'Max'], ['19', '60']); + }); + + it('Grouping: Update cell and change grouping', () => { + grid.getColumnByName('HireDate').hasSummary = false; + grid.groupBy({ + fieldName: 'ParentID', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + + grid.updateCell(-1, grid.getRowByKey(101).key, 'ParentID'); + fix.detectChanges(); + + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 0, 2, ['Count'], ['8']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 2, 2, ['Count'], ['1']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 2, 4, ['Min', 'Max'], ['27', '27']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 5, 2, ['Count'], ['1']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 5, 4, ['Min', 'Max'], ['50', '50']); + let groupRows = grid.groupsRowList.toArray(); + expect(groupRows[0].groupRow.value).toEqual(-1); + expect(groupRows[1].groupRow.value).toEqual(17); + + grid.updateCell(19, grid.getRowByKey(12).key, 'ParentID'); + fix.detectChanges(); + groupRows = grid.groupsRowList.toArray(); + expect(groupRows[0].groupRow.value).toEqual(-1); + expect(groupRows[1].groupRow.value).toEqual(19); + + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 6, 2, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 6, 4, ['Min', 'Max'], ['44', '50']); + + // Clear transactions + grid.transactions.clear(); + fix.detectChanges(); + + expect(groupRows[0].groupRow.value).toEqual(17); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 6, 2, ['Count'], ['1']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 3, 2, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummariesBySummaryRowIndex(fix, 3, 4, ['Min', 'Max'], ['27', '50']); + }); + }); + + describe('Grouping tests: ', () => { + let fix; + let grid; + beforeEach(() => { + fix = TestBed.createComponent(SummariesGroupByComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + grid.groupBy({ + fieldName: 'ParentID', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + }); + + it('should render correct summaries when there is grouped column', () => { + verifyBaseSummaries(fix); + verifySummariesForParentID17(fix, 3); + const groupRows = grid.groupsRowList.toArray(); + groupRows[0].toggle(); + fix.detectChanges(); + verifyBaseSummaries(fix); + verifySummariesForParentID19(fix, 3); + verifySummaryRowIndentationByDataRowIndex(fix, 0); + verifySummaryRowIndentationByDataRowIndex(fix, 3); + }); + + it('should render correct summaries when change grouping', () => { + // Group by another column + grid.groupBy({ + fieldName: 'OnPTO', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + + verifyBaseSummaries(fix); + verifySummariesForParentID17(fix, 4); + verifySummariesForParentID17(fix, 5); + verifySummaryRowIndentationByDataRowIndex(fix, 0); + verifySummaryRowIndentationByDataRowIndex(fix, 4); + verifySummaryRowIndentationByDataRowIndex(fix, 5); + + // change order + grid.groupingExpressions = [ + { fieldName: 'OnPTO', dir: SortingDirection.Asc, ignoreCase: true }, + { fieldName: 'ParentID', dir: SortingDirection.Asc, ignoreCase: true } + ]; + fix.detectChanges(); + + verifyBaseSummaries(fix); + verifySummariesForParentID17(fix, 4); + verifySummaryRowIndentationByDataRowIndex(fix, 0); + verifySummaryRowIndentationByDataRowIndex(fix, 4); + + const summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 8); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['2', 'Jul 3, 2011', 'Sep 18, 2014']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['2', '31', '43', '74', '37']); + + grid.clearGrouping('OnPTO'); + fix.detectChanges(); + verifyBaseSummaries(fix); + verifySummariesForParentID17(fix, 3); + verifySummariesForParentID19(fix, 6); + verifySummaryRowIndentationByDataRowIndex(fix, 0); + verifySummaryRowIndentationByDataRowIndex(fix, 3); + verifySummaryRowIndentationByDataRowIndex(fix, 6); + }); + + it('should be able to enable/disable summaries at runtime', () => { + grid.getColumnByName('Age').hasSummary = false; + grid.getColumnByName('ParentID').hasSummary = false; + fix.detectChanges(); + + GridSummaryFunctions.verifyVisibleSummariesHeight(fix, 3, grid.defaultSummaryHeight); + + let summaries = GridSummaryFunctions.getAllVisibleSummaries(fix); + summaries.forEach(summary => { + GridSummaryFunctions.verifyColumnSummaries(summary, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 1, [], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 2, ['Count'], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 3, ['Count', 'Earliest', 'Latest'], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 4, [], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 5, ['Count'], []); + }); + + // Disable all summaries + grid.getColumnByName('Name').hasSummary = false; + grid.getColumnByName('HireDate').hasSummary = false; + grid.getColumnByName('OnPTO').hasSummary = false; + fix.detectChanges(); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(0); + + grid.getColumnByName('Name').hasSummary = true; + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(5); + GridSummaryFunctions.verifyVisibleSummariesHeight(fix, 1, grid.defaultSummaryHeight); + summaries = GridSummaryFunctions.getAllVisibleSummaries(fix); + summaries.forEach(summary => { + GridSummaryFunctions.verifyColumnSummaries(summary, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 1, [], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 2, ['Count'], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 3, [], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 4, [], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 5, [], []); + }); + }); + + it('should show/hide summaries when expand/collapse group row', () => { + grid.disableSummaries([{ fieldName: 'Age' }, { fieldName: 'ParentID' }, { fieldName: 'HireDate' }]); + fix.detectChanges(); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(5); + + const groupRows = grid.groupsRowList.toArray(); + groupRows[0].toggle(); + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(4); + + grid.toggleAllGroupRows(); + fix.detectChanges(); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(1); + + groupRows[0].toggle(); + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(2); + + grid.toggleAllGroupRows(); + fix.detectChanges(); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(5); + }); + + it('should show summaries when group row is collapsed', () => { + expect(grid.showSummaryOnCollapse).toBe(false); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(3); + const groupRows = grid.groupsRowList.toArray(); + grid.showSummaryOnCollapse = true; + fix.detectChanges(); + + groupRows[0].toggle(); + fix.detectChanges(); + + expect(groupRows[0].expanded).toBe(false); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(4); + let summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 1); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['2', '17', '17', '34', '17']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['2', 'Dec 18, 2007', 'Mar 19, 2016']); + + groupRows[0].toggle(); + fix.detectChanges(); + + expect(groupRows[0].expanded).toBe(true); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(3); + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 3); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['2', '17', '17', '34', '17']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['2', 'Dec 18, 2007', 'Mar 19, 2016']); + }); + + it('should be able to change showSummaryOnCollapse run time', () => { + expect(grid.showSummaryOnCollapse).toBe(false); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(3); + const groupRows = grid.groupsRowList.toArray(); + fix.detectChanges(); + + groupRows[0].toggle(); + fix.detectChanges(); + + expect(groupRows[0].expanded).toBe(false); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(3); + + grid.showSummaryOnCollapse = true; + fix.detectChanges(); + + expect(grid.showSummaryOnCollapse).toBe(true); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(4); + const summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 1); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['2', '17', '17', '34', '17']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['2', 'Dec 18, 2007', 'Mar 19, 2016']); + }); + + + it('should correctly position summary row when group row is collapsed', () => { + grid.showSummaryOnCollapse = true; + fix.detectChanges(); + grid.groupBy({ fieldName: 'OnPTO', dir: SortingDirection.Asc, ignoreCase: false }); + fix.detectChanges(); + + let summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 4); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['2', '17', '17', '34', '17']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['2', 'Dec 18, 2007', 'Mar 19, 2016']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 5); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['2', '17', '17', '34', '17']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['2', 'Dec 18, 2007', 'Mar 19, 2016']); + + const groupRows = grid.groupsRowList.toArray(); + groupRows[1].toggle(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 2); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['2', '17', '17', '34', '17']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['2', 'Dec 18, 2007', 'Mar 19, 2016']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 3); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['2', '17', '17', '34', '17']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['2', 'Dec 18, 2007', 'Mar 19, 2016']); + + grid.summaryPosition = 'top'; + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 1); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['2', '17', '17', '34', '17']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['2', 'Dec 18, 2007', 'Mar 19, 2016']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 3); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['2', '17', '17', '34', '17']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['2', 'Dec 18, 2007', 'Mar 19, 2016']); + + }); + + it('should navigate correctly with Ctrl + ArrowUp/Home when summary position is top', () => { + grid.showSummaryOnCollapse = true; + fix.detectChanges(); + + grid.summaryPosition = 'top'; + fix.detectChanges(); + + fix.detectChanges(); + let cell = grid.getCellByColumn(6, 'Age'); + let cellElem = grid.gridAPI.get_cell_by_index(6, 'Age'); + UIInteractions.simulateClickAndSelectEvent(cellElem); + fix.detectChanges(); + + expect(cell.selected).toBe(true); + UIInteractions.triggerKeyDownEvtUponElem('Home', cellElem.nativeElement, true, false, false, true); + fix.detectChanges(); + + cell = grid.getCellByColumn(2, 'ID'); + expect(cell.selected).toBe(true); + expect(cell.active).toBe(true); + + cell = grid.getCellByColumn(6, 'Name'); + cellElem = grid.gridAPI.get_cell_by_index(6, 'Name'); + UIInteractions.simulateClickAndSelectEvent(cellElem); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', cellElem.nativeElement, true, false, false, true); + fix.detectChanges(); + + cell = grid.getCellByColumn(2, 'Name'); + expect(cell.selected).toBe(true); + expect(cell.active).toBe(true); + }); + + it('should be able to enable/disable summaries at runtime', () => { + grid.getColumnByName('Age').hasSummary = false; + grid.getColumnByName('ParentID').hasSummary = false; + grid.summaryRowHeight = 0; + fix.detectChanges(); + + GridSummaryFunctions.verifyVisibleSummariesHeight(fix, 3, grid.defaultSummaryHeight); + + let summaries = GridSummaryFunctions.getAllVisibleSummaries(fix); + + summaries.forEach(summary => { + GridSummaryFunctions.verifyColumnSummaries(summary, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 1, [], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 2, ['Count'], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 3, ['Count', 'Earliest', 'Latest'], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 4, [], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 5, ['Count'], []); + }); + + // Disable all summaries + grid.getColumnByName('Name').hasSummary = false; + grid.getColumnByName('HireDate').hasSummary = false; + grid.getColumnByName('OnPTO').hasSummary = false; + fix.detectChanges(); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(0); + + grid.getColumnByName('Name').hasSummary = true; + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(5); + GridSummaryFunctions.verifyVisibleSummariesHeight(fix, 1, grid.defaultSummaryHeight); + summaries = GridSummaryFunctions.getAllVisibleSummaries(fix); + summaries.forEach(summary => { + GridSummaryFunctions.verifyColumnSummaries(summary, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 1, [], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 2, ['Count'], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 3, [], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 4, [], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 5, [], []); + }); + }); + + it('should be able to change summaryCalculationMode at runtime', () => { + grid.getColumnByName('Age').hasSummary = false; + grid.getColumnByName('ParentID').hasSummary = false; + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesRowIndexes(fix)).toEqual([3, 6, 11, grid.dataView.length]); + + grid.summaryCalculationMode = 'rootLevelOnly'; + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(1); + expect(GridSummaryFunctions.getAllVisibleSummariesRowIndexes(fix)).toEqual([grid.dataView.length]); + + grid.summaryCalculationMode = 'childLevelsOnly'; + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(3); + expect(GridSummaryFunctions.getAllVisibleSummariesRowIndexes(fix)).toEqual([3, 6, 11]); + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + expect(summaryRow).toBeNull(); + + grid.summaryCalculationMode = 'rootAndChildLevels'; + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(4); + expect(GridSummaryFunctions.getAllVisibleSummariesRowIndexes(fix)).toEqual([3, 6, 11, grid.dataView.length]); + }); + + it('should remove child summaries when remove grouped column', () => { + grid.clearGrouping('ParentID'); + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(1); + expect(GridSummaryFunctions.getAllVisibleSummariesRowIndexes(fix)).toEqual([grid.dataView.length]); + verifyBaseSummaries(fix); + }); + + it('Hiding: should render correct summaries when show/hide a column', () => { + grid.getColumnByName('Age').hidden = true; + grid.getColumnByName('ParentID').hidden = true; + grid.summaryRowHeight = 0; + fix.detectChanges(); + + let summaries = GridSummaryFunctions.getAllVisibleSummaries(fix); + summaries.forEach(summary => { + GridSummaryFunctions.verifyColumnSummaries(summary, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 1, ['Count'], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 2, ['Count', 'Earliest', 'Latest'], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 3, ['Count'], []); + }); + + GridSummaryFunctions.verifyVisibleSummariesHeight(fix, 3); + + grid.getColumnByName('Name').hidden = true; + grid.getColumnByName('HireDate').hidden = true; + grid.getColumnByName('OnPTO').hidden = true; + fix.detectChanges(); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(0); + + grid.getColumnByName('HireDate').hidden = false; + grid.getColumnByName('OnPTO').hidden = false; + grid.summaryRowHeight = 0; + fix.detectChanges(); + + summaries = GridSummaryFunctions.getAllVisibleSummaries(fix); + summaries.forEach(summary => { + GridSummaryFunctions.verifyColumnSummaries(summary, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 1, ['Count', 'Earliest', 'Latest'], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 2, ['Count'], []); + }); + + GridSummaryFunctions.verifyVisibleSummariesHeight(fix, 3); + + let summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 3); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['2']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 6); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['1']); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['8']); + }); + + it('Filtering: should render correct summaries when filter', () => { + grid.filter('ID', 12, IgxNumberFilteringOperand.instance().condition('lessThanOrEqualTo')); + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(2); + let summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 2); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['1']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['1', 'Dec 18, 2007', 'Dec 18, 2007']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['1', '50', '50', '50', '50']); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['1']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['1', 'Dec 18, 2007', 'Dec 18, 2007']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['1', '50', '50', '50', '50']); + + grid.clearFilter(); + fix.detectChanges(); + + verifyBaseSummaries(fix); + verifySummariesForParentID17(fix, 3); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(3); + }); + + it('Filtering: should render correct summaries when filter with no results found', () => { + grid.filter('ID', 1, IgxNumberFilteringOperand.instance().condition('lessThanOrEqualTo')); + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(1); + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['0']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Earliest', 'Latest'], ['0', '', '']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['0', '0', '0', '0', '0']); + + grid.clearFilter(); + fix.detectChanges(); + + verifyBaseSummaries(fix); + verifySummariesForParentID17(fix, 3); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(3); + }); + + it('Paging: should render correct summaries when paging is enable and position is buttom', fakeAsync(() => { + fix.componentInstance.paging = true; + fix.detectChanges(); + grid.perPage = 3; + fix.detectChanges(); + tick(16); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(2); + verifyBaseSummaries(fix); + verifySummariesForParentID17(fix, 3); + + grid.page = 1; + fix.detectChanges(); + tick(16); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(2); + verifyBaseSummaries(fix); + verifySummariesForParentID19(fix, 2); + + grid.page = 2; + fix.detectChanges(); + tick(16); + verifySummariesForParentID147(fix, 3); + verifyBaseSummaries(fix); + + grid.page = 0; + fix.detectChanges(); + tick(16); + + const groupRows = grid.groupsRowList.toArray(); + groupRows[0].toggle(); + fix.detectChanges(); + tick(16); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(2); + verifyBaseSummaries(fix); + })); + + it('Paging: should render correct summaries when paging is enable and position is top', fakeAsync(() => { + fix.componentInstance.paging = true; + fix.detectChanges(); + grid.perPage = 3; + grid.summaryPosition = 'top'; + fix.detectChanges(); + tick(16); + + grid.page = 1; + fix.detectChanges(); + tick(16); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(3); + verifyBaseSummaries(fix); + verifySummariesForParentID19(fix, 1); + verifySummariesForParentID147(fix, 4); + + grid.page = 2; + fix.detectChanges(); + tick(16); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(1); + verifyBaseSummaries(fix); + })); + + it('CRUD: Add grouped item', () => { + const newRow = { + ID: 777, + ParentID: 17, + Name: 'New Employee', + HireDate: new Date(2019, 3, 3), + Age: 19, + OnPTO: true + }; + grid.addRow(newRow); + fix.detectChanges(); + + let summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['9', '17', '847', '2,205', '245']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['9']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['9', 'Dec 18, 2007', 'Apr 3, 2019']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['9', '19', '50', '312', '34.667']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 5, ['Count'], ['9']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 4); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['3', '17', '17', '51', '17']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['3']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['3', 'Dec 18, 2007', 'Apr 3, 2019']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 5, ['Count'], ['3']); + }); + + it('CRUD: Adding grouped item via UI should update group summary accordingly', () => { + grid.rowEditable = true; + fix.detectChanges(); + const newRow = { + ID: 777, + ParentID: 17, + Name: 'New Employee', + HireDate: new Date(2019, 3, 3), + Age: 19, + OnPTO: true + }; + const rows = grid.rowList.toArray(); + rows[1].beginAddRow(); + + const animationElem = fix.nativeElement.querySelector('.igx-grid__tr--inner'); + const endEvent = new AnimationEvent('animationend'); + animationElem.dispatchEvent(endEvent); + + fix.detectChanges(); + + let addRow = grid.gridAPI.get_row_by_index(2); + expect(addRow.addRowUI).toBeTrue(); + + let cell = grid.getCellByColumn(2, 'ParentID'); + cell.update(newRow.ParentID); + cell = grid.getCellByColumn(2, 'Name'); + cell.update(newRow.Name); + cell = grid.getCellByColumn(2, 'HireDate'); + cell.update(newRow.HireDate); + cell = grid.getCellByColumn(2, 'Age'); + cell.update(newRow.Age); + cell = grid.getCellByColumn(2, 'OnPTO'); + cell.update(newRow.OnPTO); + + fix.detectChanges(); + grid.endEdit(true); + + fix.detectChanges(); + + addRow = grid.gridAPI.get_row_by_index(2); + expect(addRow.addRowUI).toBeFalse(); + + const summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 4); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['3', '17', '17', '51', '17']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['3']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['3', 'Dec 18, 2007', 'Apr 3, 2019']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 5, ['Count'], ['3']); + }); + + it('CRUD: Add not grouped item', () => { + const newRow = { + ID: 777, + ParentID: 1, + Name: 'New Employee', + HireDate: new Date(2019, 3, 3), + Age: 19, + OnPTO: true + }; + grid.addRow(newRow); + fix.detectChanges(); + + let summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['9', '1', '847', '2,189', '243.222']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['9']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['9', 'Dec 18, 2007', 'Apr 3, 2019']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['9', '19', '50', '312', '34.667']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 5, ['Count'], ['9']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 2); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['1', '1', '1', '1', '1']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['1']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 5, ['Count'], ['1']); + + verifySummariesForParentID17(fix, 6); + }); + + it('CRUD: delete node', () => { + grid.getColumnByName('Age').hasSummary = false; + grid.getColumnByName('ParentID').hasSummary = false; + grid.getColumnByName('HireDate').hasSummary = false; + fix.detectChanges(); + + grid.deleteRow(grid.getRowByIndex(1).key); + fix.detectChanges(); + + let summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['7']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 2); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['1']); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(5); + + grid.deleteRow(grid.getRowByIndex(1).key); + fix.detectChanges(); + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['6']); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(4); + }); + + it('CRUD: delete all nodes', () => { + grid.deleteRow(grid.getRowByIndex(1).key); + grid.deleteRow(grid.getRowByIndex(2).key); + grid.deleteRow(grid.getRowByIndex(5).key); + grid.deleteRow(grid.getRowByIndex(8).key); + fix.detectChanges(); + + let summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['4']); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(3); + + grid.deleteRow(grid.getRowByIndex(1).key); + grid.deleteRow(grid.getRowByIndex(2).key); + grid.deleteRow(grid.getRowByIndex(5).key); + grid.deleteRow(grid.getRowByIndex(6).key); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['0']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Earliest', 'Latest'], ['0', '', '']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['0', '0', '0', '0', '0']); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(1); + }); + + it('CRUD: Update node and keep grouping', () => { + const newRow = { + ID: 12, + ParentID: 17, + Name: 'New Employee', + HireDate: new Date(2019, 3, 3), + Age: 19 + }; + grid.getRowByKey(12).update(newRow); + fix.detectChanges(); + + let summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['8']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['8', 'Jul 19, 2009', 'Apr 3, 2019']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['8', '19', '44', '262', '32.75']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 5, ['Count'], ['8']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 3); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['2', 'Mar 19, 2016', 'Apr 3, 2019']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['2', '19', '27', '46', '23']); + }); + + it('CRUD: Update node and change grouping', () => { + grid.getColumnByName('Age').hasSummary = false; + grid.getColumnByName('ParentID').hasSummary = false; + + const newRow = { + ID: 12, + ParentID: 19, + Name: 'New Employee', + HireDate: new Date(2019, 3, 3), + Age: 19 + }; + grid.getRowByKey(12).update(newRow); + fix.detectChanges(); + + let summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['8']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['8', 'Jul 19, 2009', 'Apr 3, 2019']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 5, ['Count'], ['8']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 2); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['1']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Earliest', 'Latest'], ['1', 'Mar 19, 2016', 'Mar 19, 2016']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 6); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Earliest', 'Latest'], ['2', 'May 4, 2014', 'Apr 3, 2019']); + }); + + it('CRUD and GroupBy: recalculate summaries when update cell which is grouped', () => { + grid.width = '400px'; + grid.height = '800px'; + fix.detectChanges(); + + grid.summaryCalculationMode = GridSummaryCalculationMode.childLevelsOnly; + fix.detectChanges(); + + grid.getColumnByName('ID').hasSummary = true; + fix.detectChanges(); + + let summaryRow = GridSummaryFunctions.getAllVisibleSummariesSorted(fix)[0]; + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['2', '12', '101', '113', '56.5']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['2', '17', '17', '34', '17']); + + grid.updateCell(19, 101, 'ParentID'); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getAllVisibleSummariesSorted(fix)[0]; + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['1', '12', '12', '12', '12']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['1', '17', '17', '17', '17']); + + const secondSummaryRow = GridSummaryFunctions.getAllVisibleSummariesSorted(fix)[1]; + GridSummaryFunctions.verifyColumnSummaries(secondSummaryRow, 0, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['2', '15', '101', '116', '58']); + GridSummaryFunctions.verifyColumnSummaries(secondSummaryRow, 1, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['2', '19', '19', '38', '19']); + }); + }); + + const verifySummaryRowIndentationByDataRowIndex = (fixture, visibleIndex) => { + const grid = fixture.componentInstance.grid; + const summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fixture, visibleIndex ? visibleIndex : grid.dataView.length); + const summaryRowIndentation = summaryRow.query(By.css('.igx-grid__summaries-patch')); + expect(summaryRowIndentation.nativeElement.offsetWidth).toEqual(grid.featureColumnsWidth()); + }; + + const verifyBaseSummaries = (fixture) => { + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fixture); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['8', '17', '847', '2,188', '273.5']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['8']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Earliest', 'Latest'], ['8', 'Dec 18, 2007', 'Dec 9, 2017']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['8', '25', '50', '293', '36.625']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 5, ['Count'], ['8']); + }; + + const verifySummariesForParentID19 = (fixture, vissibleIndex) => { + const summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fixture, vissibleIndex); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['1', '19', '19', '19', '19']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['1']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Earliest', 'Latest'], ['1', 'May 4, 2014', 'May 4, 2014']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['1', '44', '44', '44', '44']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 5, ['Count'], ['1']); + }; + + const verifySummariesForParentID147 = (fixture, vissibleIndex) => { + const summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fixture, vissibleIndex); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['3', '147', '147', '441', '147']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['3']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Earliest', 'Latest'], ['3', 'Jul 19, 2009', 'Sep 18, 2014']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['3', '29', '43', '103', '34.333']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 5, ['Count'], ['3']); + }; + + const verifySummariesForParentID17 = (fixture, vissibleIndex) => { + const summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fixture, vissibleIndex); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['2', '17', '17', '34', '17']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Earliest', 'Latest'], ['2', 'Dec 18, 2007', 'Mar 19, 2016']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['2', '27', '50', '77', '38.5']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 5, ['Count'], ['2']); + }; +}); + +class DealsSummary extends IgxNumberSummaryOperand { + constructor() { + super(); + } + + public override operate(summaries?: any[]): IgxSummaryResult[] { + const result = super.operate(summaries).filter((obj) => { + if (obj.key === 'average' || obj.key === 'sum' || obj.key === 'count') { + return obj; + } + }); + return result; + } +} + +class DealsSummaryMinMax extends IgxNumberSummaryOperand { + constructor() { + super(); + } + + public override operate(summaries?: any[]): IgxSummaryResult[] { + const result = super.operate(summaries).filter((obj) => { + if (obj.key === 'min' || obj.key === 'max') { + return obj; + } + }); + return result; + } +} + +class EarliestSummary extends IgxDateSummaryOperand { + constructor() { + super(); + } + + public override operate(summaries?: any[]): IgxSummaryResult[] { + const result = super.operate(summaries).filter((obj) => { + if (obj.key === 'earliest') { + return obj; + } + }); + result.push({ + key: 'test', + label: 'Items InStock', + summaryResult: 1337 + }); + return result; + } +} + +class InStockSummary extends IgxNumberSummaryOperand { + constructor() { + super(); + } + + public override operate(summaries: any[], allData = [], field?): IgxSummaryResult[] { + const result = super.operate(summaries); + if (field && field === 'UnitsInStock') { + result.push({ + key: 'test', + label: 'Items InStock', + summaryResult: allData.filter((rec) => rec.InStock).length + }); + } + return result; + } +} + +class AllDataAvgSummary extends IgxSummaryOperand { + constructor() { + super(); + } + + public override operate(data: any[], _allData = [], fieldName = ''): IgxSummaryResult[] { + const result = super.operate(data); + if (fieldName === 'UnitsInStock') { + result.push({ + key: 'long', + label: 'Test 1', + summaryResult: 50 + }); + result.push({ + key: 'long', + label: 'Test 2', + summaryResult: 150 + }); + } + if (fieldName === 'OrderDate') { + result.push({ + key: 'long', + label: 'Test 3', + summaryResult: 850 + }); + } + return result; + } +} + +@Component({ + template: ` + + + + + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) + +export class CustomSummariesComponent { + @ViewChild('grid1', { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + public data = SampleTestData.foodProductData(); + public dealsSummary = DealsSummary; + public dealsSummaryMinMax = DealsSummaryMinMax; + public earliest = EarliestSummary; + public inStockSummary = InStockSummary; + public allDataAvgSummary = AllDataAvgSummary; + public formatOptions: IColumnPipeArgs = { + digitsInfo: '1.0-2' + }; + public locale = 'en-US'; +} diff --git a/projects/igniteui-angular/grids/grid/src/grid-toolbar.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-toolbar.spec.ts new file mode 100644 index 00000000000..5c974efbd89 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid-toolbar.spec.ts @@ -0,0 +1,452 @@ +import { Component, ViewChild } from '@angular/core'; +import { TestBed, fakeAsync, ComponentFixture, tick, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxGridComponent } from './public_api'; +import { GridFunctions } from "../../../test-utils/grid-functions.spec"; +import { By } from "@angular/platform-browser"; +import { AbsoluteScrollStrategy, GlobalPositionStrategy } from 'igniteui-angular/core'; +import { IgxCsvExporterService, IgxExcelExporterService, IgxGridToolbarActionsComponent, IgxGridToolbarAdvancedFilteringComponent, IgxGridToolbarComponent, IgxGridToolbarExporterComponent, IgxGridToolbarHidingComponent, IgxGridToolbarPinningComponent, IgxGridToolbarTitleComponent } from 'igniteui-angular/grids/core'; +import { ExportUtilities } from 'igniteui-angular/grids/core'; + +const TOOLBAR_TAG = 'igx-grid-toolbar'; +const TOOLBAR_TITLE_TAG = 'igx-grid-toolbar-title'; +const TOOLBAR_ACTIONS_TAG = 'igx-grid-toolbar-actions'; +const TOOLBAR_PINNING_TAG = 'igx-grid-toolbar-pinning'; +const TOOLBAR_HIDING_TAG = 'igx-grid-toolbar-hiding'; +const TOOLBAR_ADVANCED_FILTERING_TAG = 'igx-grid-toolbar-advanced-filtering'; +const TOOLBAR_EXPORTER_TAG = 'igx-grid-toolbar-exporter'; + +const DATA = [ + { ProductID: 1, ProductName: 'Chai', InStock: true, UnitsInStock: 2760, OrderDate: new Date('2005-03-21') }, + { ProductID: 2, ProductName: 'Aniseed Syrup', InStock: false, UnitsInStock: 198, OrderDate: new Date('2008-01-15') }, + { ProductID: 3, ProductName: 'Chef Antons Cajun Seasoning', InStock: true, UnitsInStock: 52, OrderDate: new Date('2010-11-20') }, + { ProductID: 4, ProductName: 'Grandmas Boysenberry Spread', InStock: false, UnitsInStock: 0, OrderDate: new Date('2007-10-11') }, + { ProductID: 5, ProductName: 'Uncle Bobs Dried Pears', InStock: false, UnitsInStock: 0, OrderDate: new Date('2001-07-27') }, + { ProductID: 6, ProductName: 'Northwoods Cranberry Sauce', InStock: true, UnitsInStock: 1098, OrderDate: new Date('1990-05-17') }, + { ProductID: 7, ProductName: 'Queso Cabrales', InStock: false, UnitsInStock: 0, OrderDate: new Date('2005-03-03') }, + { ProductID: 8, ProductName: 'Tofu', InStock: true, UnitsInStock: 7898, OrderDate: new Date('2017-09-09') }, + { ProductID: 9, ProductName: 'Teatime Chocolate Biscuits', InStock: true, UnitsInStock: 6998, OrderDate: new Date('2025-12-25') }, + { ProductID: 10, ProductName: 'Chocolate', InStock: true, UnitsInStock: 20000, OrderDate: new Date('2018-03-01') } +]; + +describe('IgxGrid - Grid Toolbar #grid - ', () => { + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + DefaultToolbarComponent, + ToolbarActionsComponent + ], + providers: [ + IgxExcelExporterService, + IgxCsvExporterService + ] + }).compileComponents(); + })); + + describe('Basic Tests - ', () => { + let fixture: ComponentFixture; + + const $ = (selector: string) => fixture.debugElement.nativeElement.querySelector(selector) as HTMLElement; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(DefaultToolbarComponent); + fixture.detectChanges(); + })); + + it ('toolbar is rendered when declared between grid tags', () => { + expect($(TOOLBAR_TAG)).toBeInstanceOf(HTMLElement); + }); + + it('toolbar can be conditionally rendered', () => { + fixture.componentInstance.toolbarEnabled = false; + fixture.detectChanges(); + expect($(TOOLBAR_TAG)).toBeNull(); + }); + + it('toolbar title can be set through content projection', () => { + const newTitle = '1234567890'; + fixture.componentInstance.toolbarTitle = newTitle; + fixture.componentInstance.toolbarTitleEnabled = true; + fixture.detectChanges(); + + const titleEl = $(TOOLBAR_TITLE_TAG); + expect(titleEl).toBeInstanceOf(HTMLElement); + expect(titleEl.textContent).toMatch(newTitle); + }); + + it('default toolbar actions are rendered', () => { + const actionsEl = $(TOOLBAR_ACTIONS_TAG); + expect(actionsEl).toBeInstanceOf(HTMLElement); + }); + + it('toolbar actions can be set through content projection', () => { + fixture.componentInstance.toolbarActionsEnabled = true; + fixture.detectChanges(); + const actionsEl = $(TOOLBAR_ACTIONS_TAG); + expect(actionsEl).toBeInstanceOf(HTMLElement); + expect(actionsEl.textContent).toMatch(''); + }); + + it('toolbar default content projection', () => { + fixture.componentInstance.customContentEnabled = true; + fixture.detectChanges(); + const contentEl = $('p'); + expect(contentEl.textContent).toMatch(fixture.componentInstance.customContent); + }); + + it('toolbar progress indicator prop', () => { + fixture.componentInstance.showProgress = true; + fixture.detectChanges(); + const barEl = $('igx-linear-bar'); + expect(barEl).toBeInstanceOf(HTMLElement); + }); + }); + + describe('Toolbar actions - ', () => { + let fixture: ComponentFixture; + let instance: ToolbarActionsComponent; + + const $ = (selector: string) => fixture.debugElement.nativeElement.querySelector(selector) as HTMLElement; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(ToolbarActionsComponent); + fixture.detectChanges(); + instance = fixture.componentInstance; + })); + + it('the buttons type should be set to "button"', fakeAsync(() => { + tick(); + fixture.detectChanges(); + + const pinningButtonType = $(TOOLBAR_PINNING_TAG).querySelector('button').getAttributeNode('type').value; + const hidingButtonType = $(TOOLBAR_HIDING_TAG).querySelector('button').getAttributeNode('type').value; + const advancedFilteringButtonType = $(TOOLBAR_ADVANCED_FILTERING_TAG).querySelector('button').getAttributeNode('type').value; + const exporterButtonType = $(TOOLBAR_EXPORTER_TAG).querySelector('button').getAttributeNode('type').value; + + const expectedButtonType = 'button'; + + expect(pinningButtonType).toBe(expectedButtonType); + expect(hidingButtonType).toBe(expectedButtonType); + expect(advancedFilteringButtonType).toBe(expectedButtonType); + expect(exporterButtonType).toBe(expectedButtonType); + })); + + it('toolbar exporter props', () => { + const exporterButton = $(TOOLBAR_EXPORTER_TAG).querySelector('button'); + + instance.exportCSV = false; + instance.exportExcel = false; + exporterButton.click(); + fixture.detectChanges(); + + expect($('#excelEntry')).toBeNull(); + expect($('#csvEntry')).toBeNull(); + + exporterButton.click(); + fixture.detectChanges(); + + instance.exportCSV = true; + instance.exportExcel = true; + exporterButton.click(); + fixture.detectChanges(); + + expect($('#excelEntry')).not.toBeNull(); + expect($('#csvEntry')).not.toBeNull(); + }); + + it('toolbar exporter content projection', () => { + expect($(TOOLBAR_EXPORTER_TAG).textContent).toMatch(instance.exporterText); + }); + + it('toolbar exporter dropdown entries', () => { + $(TOOLBAR_EXPORTER_TAG).querySelector('button').click(); + fixture.detectChanges(); + + expect($('igx-column-actions')).toBeInstanceOf(HTMLElement); + expect($('#excelEntry').textContent).toMatch(instance.customExcelText); + expect($('#csvEntry').textContent).toMatch(instance.customCSVText); + }); + + it('progress indicator should stop on canceling the export', () => { + fixture.componentInstance.exportStartCancelled = true; + fixture.detectChanges(); + $(TOOLBAR_EXPORTER_TAG).querySelector('button').click(); + fixture.detectChanges(); + $('#excelEntry').click(); + fixture.detectChanges(); + + expect(instance.exporterAction.isExporting).toBeFalse(); + expect(instance.exporterAction.toolbar.showProgress).toBeFalse(); + }); + + it('toolbar exporter should include PDF option by default', () => { + const exporterButton = $(TOOLBAR_EXPORTER_TAG).querySelector('button'); + exporterButton.click(); + fixture.detectChanges(); + + expect($('#pdfEntry')).not.toBeNull(); + }); + + it('toolbar exporter should hide PDF option when exportPDF is false', () => { + instance.exportPDF = false; + fixture.detectChanges(); + + const exporterButton = $(TOOLBAR_EXPORTER_TAG).querySelector('button'); + exporterButton.click(); + fixture.detectChanges(); + + expect($('#pdfEntry')).toBeNull(); + }); + + it('toolbar exporter should show PDF option when exportPDF is true', () => { + instance.exportPDF = true; + fixture.detectChanges(); + + const exporterButton = $(TOOLBAR_EXPORTER_TAG).querySelector('button'); + exporterButton.click(); + fixture.detectChanges(); + + expect($('#pdfEntry')).not.toBeNull(); + }); + + it('toolbar exporter should display custom PDF text', () => { + const exporterButton = $(TOOLBAR_EXPORTER_TAG).querySelector('button'); + exporterButton.click(); + fixture.detectChanges(); + + expect($('#pdfEntry').textContent).toMatch(instance.customPDFText); + }); + + it('toolbar exporter should export to PDF when clicked', () => { + const exporterButton = $(TOOLBAR_EXPORTER_TAG).querySelector('button'); + exporterButton.click(); + fixture.detectChanges(); + + spyOn(instance.exporterAction, 'export'); + $('#pdfEntry').click(); + fixture.detectChanges(); + + expect(instance.exporterAction.export).toHaveBeenCalledWith('pdf'); + }); + + it('toolbar exporter should emit exportStarted event for PDF export', () => { + let exportStartedFired = false; + instance.exporterAction.exportStarted.subscribe(() => { + exportStartedFired = true; + }); + + const exporterButton = $(TOOLBAR_EXPORTER_TAG).querySelector('button'); + exporterButton.click(); + fixture.detectChanges(); + + spyOn(ExportUtilities, 'saveBlobToFile'); + $('#pdfEntry').click(); + fixture.detectChanges(); + + expect(exportStartedFired).toBe(true); + }); + + it('toolbar exporter PDF export can be cancelled', () => { + fixture.componentInstance.exportStartCancelled = true; + fixture.detectChanges(); + + const exporterButton = $(TOOLBAR_EXPORTER_TAG).querySelector('button'); + exporterButton.click(); + fixture.detectChanges(); + $('#pdfEntry').click(); + fixture.detectChanges(); + + expect(instance.exporterAction.isExporting).toBeFalse(); + expect(instance.exporterAction.toolbar.showProgress).toBeFalse(); + }); + + it('Setting overlaySettings for each toolbar columns action', () => { + const defaultSettings = instance.pinningAction.overlaySettings; + const defaultFiltSettings = instance.advancedFiltAction.overlaySettings; + const defaultExportSettings = instance.exporterAction.overlaySettings; + + instance.pinningAction.overlaySettings = instance.overlaySettings; + instance.hidingAction.overlaySettings = instance.overlaySettings; + fixture.detectChanges(); + + expect(defaultSettings).not.toEqual(instance.pinningAction.overlaySettings); + expect(defaultSettings).not.toEqual(instance.hidingAction.overlaySettings); + expect(defaultFiltSettings).toEqual(instance.advancedFiltAction.overlaySettings); + expect(defaultExportSettings).toEqual(instance.exporterAction.overlaySettings); + + instance.advancedFiltAction.overlaySettings = instance.overlaySettings; + instance.exporterAction.overlaySettings = instance.overlaySettings; + fixture.detectChanges(); + + expect(defaultFiltSettings).not.toEqual(instance.advancedFiltAction.overlaySettings); + expect(defaultExportSettings).not.toEqual(instance.exporterAction.overlaySettings); + }); + + it('should initialize input property columnsAreaMaxHeight properly', fakeAsync(() => { + expect(instance.pinningAction.columnsAreaMaxHeight).toEqual('100%'); + + instance.pinningAction.columnsAreaMaxHeight = '10px'; + fixture.detectChanges(); + + expect(instance.pinningAction.columnsAreaMaxHeight).toEqual('10px'); + + const pinningButton = GridFunctions.getColumnPinningButton(fixture); + pinningButton.click(); + tick(); + fixture.detectChanges() + const element = fixture.debugElement.query(By.css('.igx-column-actions__columns')); + expect(element.attributes.style).toBe('max-height: 10px;'); + + expect(instance.pinningAction.columnsAreaMaxHeight).toEqual('10px'); + })); + + it('should emit columnToggle event when a column is shown/hidden via the column hiding action', fakeAsync(() => { + const spy = spyOn(instance.hidingAction.columnToggle, 'emit'); + const hidingUI = $(TOOLBAR_HIDING_TAG); + const grid = fixture.componentInstance.grid; + fixture.detectChanges(); + const hidingActionButton = hidingUI.querySelector('button'); + const columnChooserElement = GridFunctions.getColumnHidingElement(fixture); + + hidingActionButton.click(); + tick(); + fixture.detectChanges(); + + GridFunctions.clickColumnChooserItem(columnChooserElement, 'ProductID'); + fixture.detectChanges(); + + expect(instance.hidingAction.columnToggle.emit).toHaveBeenCalledTimes(1); + expect(instance.hidingAction.columnToggle.emit).toHaveBeenCalledWith( + { column: grid.getColumnByName('ProductID'), checked: false }); + + // test after closing and reopening the hiding UI + spy.calls.reset(); + hidingActionButton.click(); + tick(); + fixture.detectChanges(); + + hidingActionButton.click(); + tick(); + fixture.detectChanges(); + + GridFunctions.clickColumnChooserItem(columnChooserElement, 'ProductID'); + fixture.detectChanges(); + + expect(instance.hidingAction.columnToggle.emit).toHaveBeenCalledTimes(1); + expect(instance.hidingAction.columnToggle.emit).toHaveBeenCalledWith( + { column: grid.getColumnByName('ProductID'), checked: true }); + })); + }); +}); + +@Component({ + template: ` + + @if (toolbarEnabled) { + + @if (customContentEnabled) { +

    {{ customContent }}

    + } + @if (toolbarTitleEnabled) { + {{ toolbarTitle }} + } + @if (toolbarActionsEnabled) { + + } +
    + } +
    + `, + imports: [IgxGridComponent, IgxGridToolbarComponent, IgxGridToolbarActionsComponent, IgxGridToolbarTitleComponent] +}) +export class DefaultToolbarComponent { + public toolbarEnabled = true; + public toolbarTitle = 'Custom title'; + public toolbarTitleEnabled = false; + public customContentEnabled = false; + public customContent = 'Custom Content'; + public toolbarActionsEnabled = false; + public showProgress = false; + public data = []; + + constructor() { + this.data = [...DATA]; + } +} + +@Component({ + template: ` + + + + + + + {{ advancedFilteringTitle }} + + + {{ exporterText }} + {{ customExcelText }} + {{ customCSVText }} + {{ customPDFText }} + + + + + `, + imports: [ + IgxGridComponent, + IgxGridToolbarComponent, + IgxGridToolbarActionsComponent, + IgxGridToolbarPinningComponent, + IgxGridToolbarHidingComponent, + IgxGridToolbarAdvancedFilteringComponent, + IgxGridToolbarExporterComponent + ] +}) +export class ToolbarActionsComponent { + @ViewChild(IgxGridComponent, { static: true }) + public grid: IgxGridComponent; + + @ViewChild('pinningAction', {static: true}) + public pinningAction; + + @ViewChild('hidingAction', {static: true}) + public hidingAction; + + @ViewChild('advancedFiltAction', {static: true}) + public advancedFiltAction; + + @ViewChild('exporterAction', {static: true}) + public exporterAction; + + public data = []; + public advancedFilteringTitle = 'Custom button text'; + public exportCSV = true; + public exportExcel = true; + public exportPDF = true; + public exportFilename = ''; + public exporterText = 'Exporter Options'; + public customExcelText = '<< Excel export >>'; + public customCSVText = '<< CSV export >>'; + public customPDFText = '<< PDF export >>'; + public overlaySettings = { + positionStrategy: new GlobalPositionStrategy(), + scrollStrategy: new AbsoluteScrollStrategy(), + modal: true, + closeOnEscape: false + }; + public exportStartCancelled = false; + + constructor() { + this.data = [...DATA]; + } + + public exportStarted(args) { + if (this.exportStartCancelled) { + args.cancel = true; + } + } +} diff --git a/projects/igniteui-angular/grids/grid/src/grid-validation.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-validation.spec.ts new file mode 100644 index 00000000000..851b6f13e8d --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid-validation.spec.ts @@ -0,0 +1,720 @@ +import { fakeAsync, flush, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { Validators } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators' +import { IgxInputDirective } from 'igniteui-angular/input-group'; +import { IgxTooltipTargetDirective } from 'igniteui-angular/directives'; +import { GridFunctions, GridSelectionFunctions } from '../../../test-utils/grid-functions.spec'; +import { + IgxGridCustomEditorsComponent, + IgxGridValidationTestBaseComponent, + IgxGridValidationTestCustomErrorComponent, + IgxTreeGridValidationTestComponent +} from '../../../test-utils/grid-validation-samples.spec'; +import { UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { IGridFormGroupCreatedEventArgs } from 'igniteui-angular/grids/core'; +import { IgxGridComponent } from './grid.component'; +import { AutoPositionStrategy, HorizontalAlignment, IgxOverlayService, VerticalAlignment } from 'igniteui-angular/core'; +import { IgxTreeGridComponent } from 'igniteui-angular/grids/tree-grid'; + +describe('IgxGrid - Validation #grid', () => { + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxGridValidationTestBaseComponent, + IgxGridValidationTestCustomErrorComponent, + IgxGridCustomEditorsComponent, + IgxTreeGridValidationTestComponent + ] + }).compileComponents(); + })); + + describe('Basic Validation - ', () => { + let fixture; + const $destroyer = new Subject(); + + beforeEach(() => { + fixture = TestBed.createComponent(IgxGridValidationTestBaseComponent); + fixture.detectChanges(); + }); + + afterEach(() => { + UIInteractions.clearOverlay(); + $destroyer.next(true); + }); + + it('should allow setting built-in validators via template-driven configuration on the column', () => { + const grid = fixture.componentInstance.grid as IgxGridComponent; + const firstColumn = grid.columnList.first; + const validators = firstColumn.validators; + expect(validators.length).toBeGreaterThanOrEqual(3); + + const minValidator = validators.find(validator => validator['inputName'] === 'minlength'); + const maxValidator = validators.find(validator => validator['inputName'] === 'maxlength'); + const requiredValidator = validators.find(validator => validator['inputName'] === 'required'); + + expect(parseInt(minValidator['minlength'], 10)).toBe(4); + expect(parseInt(maxValidator['maxlength'], 10)).toBe(8); + expect(requiredValidator).toBeDefined(); + + let cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + UIInteractions.simulateDoubleClickAndSelectEvent(cell.element); + cell.update('asd'); + fixture.detectChanges(); + + cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + //min length should be 4 + GridFunctions.verifyCellValid(cell, false); + + cell.editMode = true; + cell.update('test'); + fixture.detectChanges(); + cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + GridFunctions.verifyCellValid(cell, true); + }); + + it('should allow setting custom validators via template-driven configuration on the column', () => { + const grid = fixture.componentInstance.grid as IgxGridComponent; + const firstColumn = grid.columnList.first; + const validators = firstColumn.validators; + + + const customValidator = validators.find(validator => validator['forbiddenName']); + expect(customValidator).toBeDefined(); + expect(customValidator['forbiddenName']).toEqual('bob'); + let cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + UIInteractions.simulateDoubleClickAndSelectEvent(cell.element); + cell.update('bob'); + fixture.detectChanges(); + + cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + //the name should not contain bob + GridFunctions.verifyCellValid(cell, false); + + cell.editMode = true; + cell.update('valid'); + fixture.detectChanges(); + cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + GridFunctions.verifyCellValid(cell, true); + }); + + it('should allow setting validators on the exposed FormGroup object', () => { + const grid = fixture.componentInstance.grid as IgxGridComponent; + grid.formGroupCreated.pipe(takeUntil($destroyer)).subscribe((args: IGridFormGroupCreatedEventArgs) => { + const prodName = args.formGroup.get('ProductName'); + prodName.addValidators(Validators.email); + }); + + + let cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + UIInteractions.simulateDoubleClickAndSelectEvent(cell.element); + cell.update('test'); + fixture.detectChanges(); + + cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + //the name should be correct email + GridFunctions.verifyCellValid(cell, false); + expect(cell.formControl.errors.email).toBeTrue(); + + cell.editMode = true; + cell.update('m@in.com'); + fixture.detectChanges(); + cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + GridFunctions.verifyCellValid(cell, true); + expect(cell.formControl.errors).toBeFalsy(); + }); + + it('should allow setting validation triggers - "change" , "blur".', async () => { + const grid = fixture.componentInstance.grid as IgxGridComponent; + //changing validation triger to blur + grid.validationTrigger = 'blur'; + fixture.detectChanges(); + + let cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + UIInteractions.simulateDoubleClickAndSelectEvent(cell.element); + fixture.detectChanges(); + + const input = fixture.debugElement.query(By.directive(IgxInputDirective)).nativeElement; + input.value = 'asd'; + input.dispatchEvent(new Event('input')); + fixture.detectChanges(); + + //the cell should be invalid after blur event is fired + cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + GridFunctions.verifyCellValid(cell, true); + + input.dispatchEvent(new Event('blur')); + fixture.detectChanges(); + // fix.detectChanges(); + + cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + + GridFunctions.verifyCellValid(cell, false); + }); + + it('should mark invalid cell with igx-grid__td--invalid class and show the related error cell template', () => { + const grid = fixture.componentInstance.grid as IgxGridComponent; + + let cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + UIInteractions.simulateDoubleClickAndSelectEvent(cell.element); + cell.update('asd'); + fixture.detectChanges(); + + cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + //min length should be 4 + GridFunctions.verifyCellValid(cell, false); + const errorMessage = cell.errorTooltip.first.elementRef.nativeElement.children[0].textContent; + expect(errorMessage).toEqual(' Entry should be at least 4 character(s) long '); + }); + + it('should mark invalid cell with igx-grid__td--invalid class and show the related error cell template when the field contains "."', () => { + const grid = fixture.componentInstance.grid as IgxGridComponent; + // add new column + fixture.componentInstance.columns.push({ field: 'New.Column', dataType: 'string' }); + fixture.detectChanges(); + expect(grid.columns.length).toBe(5); + + let cell = grid.gridAPI.get_cell_by_visible_index(1, 4); + UIInteractions.simulateDoubleClickAndSelectEvent(cell.element); + cell.update('asd'); + fixture.detectChanges(); + + cell = grid.gridAPI.get_cell_by_visible_index(1, 4); + //min length should be 4 + GridFunctions.verifyCellValid(cell, false); + const errorMessage = cell.errorTooltip.first.elementRef.nativeElement.children[0].textContent; + expect(errorMessage).toEqual(' Entry should be at least 4 character(s) long '); + }); + + it('should show the error message on error icon hover and when the invalid cell becomes active.', fakeAsync(() => { + const grid = fixture.componentInstance.grid as IgxGridComponent; + + let cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + UIInteractions.simulateDoubleClickAndSelectEvent(cell.element); + const input = fixture.debugElement.query(By.directive(IgxInputDirective)).nativeElement; + input.value = 'asd'; + input.dispatchEvent(new Event('input')); + fixture.detectChanges(); + + cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + //min length should be 4 + GridFunctions.verifyCellValid(cell, false); + GridSelectionFunctions.verifyCellActive(cell, true); + const errorMessage = cell.errorTooltip.first.elementRef.nativeElement.children[0].textContent; + expect(errorMessage).toEqual(' Entry should be at least 4 character(s) long '); + + const overlayService = TestBed.inject(IgxOverlayService); + const info = overlayService.getOverlayById(cell.errorTooltip.first.overlayId); + const positionSettings = info.settings.positionStrategy.settings; + + expect(info.settings.positionStrategy instanceof AutoPositionStrategy).toBe(true); + expect(positionSettings.horizontalStartPoint).toEqual(HorizontalAlignment.Center); + expect(positionSettings.horizontalDirection).toEqual(HorizontalAlignment.Center); + expect(positionSettings.verticalStartPoint).toEqual(VerticalAlignment.Bottom); + expect(positionSettings.verticalDirection).toEqual(VerticalAlignment.Bottom); + expect(positionSettings.openAnimation.options.params).toEqual({ duration: '150ms' }); + expect(positionSettings.closeAnimation.options.params).toEqual({ duration: '75ms' }); + + cell.errorTooltip.first.close(); + tick(); + fixture.detectChanges(); + expect(cell.errorTooltip.first.collapsed).toBeTrue(); + + const element = fixture.debugElement.query(By.directive(IgxTooltipTargetDirective)).nativeElement; + element.dispatchEvent(new MouseEvent('mouseenter')); + flush(); + fixture.detectChanges(); + expect(cell.errorTooltip.first.collapsed).toBeFalse(); + })); + + it('should allow preventing edit mode for cell/row to end by canceling the related event if isValid event argument is false', () => { + const grid = fixture.componentInstance.grid as IgxGridComponent; + grid.cellEdit.pipe(takeUntil($destroyer)).subscribe((args) => { + if (!args.valid) { + args.cancel = true; + } + }); + + let cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + UIInteractions.simulateDoubleClickAndSelectEvent(cell.element); + + const lastValue = cell.value; + cell.formControl.setValue('asd'); + fixture.detectChanges(); + grid.gridAPI.crudService.endEdit(true); + fixture.detectChanges(); + + cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + // should have been canceled and left in editmode because of non valid value + // should not have updated the value + GridFunctions.verifyCellValid(cell, false); + expect(!!grid.gridAPI.crudService.rowInEditMode).toEqual(true); + expect(grid.gridAPI.crudService.cellInEditMode).toEqual(true); + expect(cell.value).toEqual(lastValue); + + cell.update('test'); + fixture.detectChanges(); + cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + GridFunctions.verifyCellValid(cell, true); + }); + + it('should trigger the validationStatusChange event on grid when validation status changes', () => { + const grid = fixture.componentInstance.grid as IgxGridComponent; + spyOn(grid.validationStatusChange, "emit").and.callThrough(); + + let cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + UIInteractions.simulateDoubleClickAndSelectEvent(cell.element); + cell.editMode = true; + cell.update('asd'); + fixture.detectChanges(); + cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + + grid.crudService.endEdit(true); + fixture.detectChanges(); + + GridFunctions.verifyCellValid(cell, false); + expect(grid.validationStatusChange.emit).toHaveBeenCalledWith({ status: 'INVALID', owner: grid }); + + UIInteractions.simulateDoubleClickAndSelectEvent(cell.element); + cell.editMode = true; + cell.update('test'); + fixture.detectChanges(); + cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + + grid.crudService.endEdit(true); + fixture.detectChanges(); + + GridFunctions.verifyCellValid(cell, true); + expect(grid.validationStatusChange.emit).toHaveBeenCalledWith({ status: 'INVALID', owner: grid }); + }); + + it('should return invalid transaction using the transaction service API', () => { + const grid = fixture.componentInstance.grid as IgxGridComponent; + + + let cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + UIInteractions.simulateDoubleClickAndSelectEvent(cell.element); + cell.update('asd'); + fixture.detectChanges(); + cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + let invalidRecords = grid.validation.getInvalid(); + + GridFunctions.verifyCellValid(cell, false); + expect(invalidRecords[0].fields[1].status).toEqual('INVALID'); + + + cell.editMode = true; + cell.update('test'); + fixture.detectChanges(); + cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + + GridFunctions.verifyCellValid(cell, true); + invalidRecords = grid.validation.getInvalid(); + expect(invalidRecords.length).toEqual(0); + }); + + it('should update formControl state when grid data is updated.', () => { + const grid = fixture.componentInstance.grid as IgxGridComponent; + const originalDataCopy = JSON.parse(JSON.stringify(grid.data)); + + grid.data = JSON.parse(JSON.stringify(grid.data)); + let cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + UIInteractions.simulateDoubleClickAndSelectEvent(cell.element); + cell.update('asd'); + fixture.detectChanges(); + grid.crudService.endEdit(true); + fixture.detectChanges(); + + cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + //min length should be 4 + GridFunctions.verifyCellValid(cell, false); + + grid.data = originalDataCopy; + fixture.detectChanges(); + + cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + GridFunctions.verifyCellValid(cell, true); + UIInteractions.simulateDoubleClickAndSelectEvent(cell.element); + expect(cell.editValue).toBe(originalDataCopy[1].ProductName); + }); + + it('should create formControl for dynamically added columns\' cells', () => { + const grid = fixture.componentInstance.grid as IgxGridComponent; + expect(grid.columns.length).toBe(4); + + // edit the row prior to adding new column, so that the formGroup of the row is created + let cell = grid.gridAPI.get_cell_by_visible_index(1, 3); + UIInteractions.simulateDoubleClickAndSelectEvent(cell.element); + + fixture.detectChanges(); + cell.update(100); + grid.crudService.endEdit(true); + fixture.detectChanges(); + + // add new column + fixture.componentInstance.columns.push({ field: 'NewColumn', dataType: 'string' }); + fixture.detectChanges(); + expect(grid.columns.length).toBe(5); + + // edit the new field's cell of the previously edited row + cell = grid.gridAPI.get_cell_by_visible_index(1, 4); + // will throw if form control was not created + UIInteractions.simulateDoubleClickAndSelectEvent(cell.element); + cell.update('asd'); + fixture.detectChanges(); + grid.crudService.endEdit(true); + fixture.detectChanges(); + }); + }); + + describe('Custom Validation - ', () => { + let fixture; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(IgxGridValidationTestCustomErrorComponent); + fixture.detectChanges(); + })); + + it('should allow setting custom validators via template-driven configuration on the column', () => { + const grid = fixture.componentInstance.grid as IgxGridComponent; + let cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + + UIInteractions.simulateDoubleClickAndSelectEvent(cell.element); + cell.update('bob'); + fixture.detectChanges(); + + cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + //bob cannot be the name + GridFunctions.verifyCellValid(cell, false); + const errorMessage = cell.errorTooltip.first.elementRef.nativeElement.children[0].textContent; + expect(errorMessage).toEqual(' This name is forbidden. '); + + cell.editMode = true; + cell.update('test'); + fixture.detectChanges(); + cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + GridFunctions.verifyCellValid(cell, true); + }); + }); + + describe('Custom Editor Templates - ', () => { + let fixture; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(IgxGridCustomEditorsComponent); + fixture.componentInstance.grid.batchEditing = true; + fixture.detectChanges(); + })); + + it('should trigger validation on change when using custom editor bound via formControl.', () => { + // template bound via formControl + const template = fixture.componentInstance.formControlTemplate; + const grid = fixture.componentInstance.grid as IgxGridComponent; + const col = grid.columns[1]; + col.inlineEditorTemplate = template; + fixture.detectChanges(); + + const cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + UIInteractions.simulateDoubleClickAndSelectEvent(cell.element); + const input = fixture.debugElement.query(By.css('input')); + UIInteractions.clickAndSendInputElementValue(input, 'bob'); + fixture.detectChanges(); + + GridFunctions.verifyCellValid(cell, false); + const errorMessage = cell.errorTooltip.first.elementRef.nativeElement.children[0].textContent; + expect(errorMessage).toEqual(' Entry should be at least 4 character(s) long '); + }); + + it('should trigger validation on change when using custom editor bound via editValue.', () => { + // template bound via ngModel to editValue + const template = fixture.componentInstance.modelTemplate; + const grid = fixture.componentInstance.grid as IgxGridComponent; + const col = grid.columns[1]; + col.inlineEditorTemplate = template; + fixture.detectChanges(); + + const cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + UIInteractions.simulateDoubleClickAndSelectEvent(cell.element); + const input = fixture.debugElement.query(By.css('input')); + UIInteractions.clickAndSendInputElementValue(input, 'bob'); + fixture.detectChanges(); + + GridFunctions.verifyCellValid(cell, false); + const errorMessage = cell.errorTooltip.first.elementRef.nativeElement.children[0].textContent; + expect(errorMessage).toEqual(' Entry should be at least 4 character(s) long '); + }); + + it('should trigger validation on blur when using custom editor bound via editValue.', () => { + // template bound via ngModel to editValue + const template = fixture.componentInstance.modelTemplate; + const grid = fixture.componentInstance.grid as IgxGridComponent; + const col = grid.columns[1]; + col.inlineEditorTemplate = template; + grid.validationTrigger = 'blur'; + fixture.detectChanges(); + + const cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + UIInteractions.simulateDoubleClickAndSelectEvent(cell.element); + const input = fixture.debugElement.query(By.css('input')); + UIInteractions.clickAndSendInputElementValue(input, 'bob'); + fixture.detectChanges(); + + // invalid value is entered, but no blur has happened yet. + // Hence validation state is still valid. + GridFunctions.verifyCellValid(cell, true); + expect(cell.errorTooltip.length).toBe(0); + + // exit edit mode + grid.crudService.endEdit(true); + fixture.detectChanges(); + GridFunctions.verifyCellValid(cell, false); + const errorMessage = cell.errorTooltip.first.elementRef.nativeElement.children[0].textContent; + expect(errorMessage).toEqual(' Entry should be at least 4 character(s) long '); + }); + }); + + describe('Transactions integration - ', () => { + let fixture; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(IgxGridValidationTestBaseComponent); + fixture.componentInstance.batchEditing = true; + fixture.detectChanges(); + })); + + it('should update validity when setting new value through grid API', () => { + const grid = fixture.componentInstance.grid as IgxGridComponent; + const cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + + grid.updateCell('IG', 2, 'ProductName'); + grid.validation.markAsTouched(2); + fixture.detectChanges(); + + GridFunctions.verifyCellValid(cell, false); + + grid.transactions.undo(); + fixture.detectChanges(); + + GridFunctions.verifyCellValid(cell, true); + + grid.updateRow({ + ProductID: 2, + ProductName: '', + SupplierID: 1, + CategoryID: 1, + QuantityPerUnit: '24 - 12 oz bottles', + UnitPrice: '19.0000', + UnitsInStock: 66, + UnitsOnOrder: 40, + ReorderLevel: 25, + Discontinued: false, + OrderDate: new Date('2003-03-17').toISOString(), + OrderDate2: new Date('2003-03-17').toISOString() + }, 2); + grid.validation.markAsTouched(2); + fixture.detectChanges(); + + GridFunctions.verifyCellValid(cell, false); + + grid.transactions.undo(); + fixture.detectChanges(); + + GridFunctions.verifyCellValid(cell, true); + + grid.transactions.redo(); + fixture.detectChanges(); + + GridFunctions.verifyCellValid(cell, false); + }); + + it('should update validation status when using undo/redo api', () => { + const grid = fixture.componentInstance.grid as IgxGridComponent; + const cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + + UIInteractions.simulateDoubleClickAndSelectEvent(cell.element); + cell.editMode = true; + cell.update('IG'); + fixture.detectChanges(); + + grid.gridAPI.crudService.endEdit(true); + fixture.detectChanges(); + + GridFunctions.verifyCellValid(cell, false); + + grid.transactions.undo(); + fixture.detectChanges(); + + GridFunctions.verifyCellValid(cell, true); + + grid.transactions.redo(); + fixture.detectChanges(); + + GridFunctions.verifyCellValid(cell, false); + + grid.transactions.commit(grid.data); + fixture.detectChanges(); + + GridFunctions.verifyCellValid(cell, false); + expect((grid.validation as any).getValidity().length).toEqual(1); + + grid.validation.clear(); + fixture.detectChanges(); + }); + + it('should not invalidate cleared number cell', () => { + const grid = fixture.componentInstance.grid as IgxGridComponent; + const cell = grid.gridAPI.get_cell_by_visible_index(1, 3); + + // Set cell to null, which should invalidate + UIInteractions.simulateDoubleClickAndSelectEvent(cell.element); + cell.editMode = true; + cell.update(null); + fixture.detectChanges(); + + expect(grid.validation.getInvalid().length).toEqual(1); + GridFunctions.verifyCellValid(cell, false); + + // Exit edit. CRUD service sets number value to 0 + grid.gridAPI.crudService.endEdit(true); + fixture.detectChanges(); + + expect(grid.validation.getInvalid().length).toEqual(0); + GridFunctions.verifyCellValid(cell, true); + + // Undo. Expect previous value + grid.transactions.undo(); + fixture.detectChanges(); + + expect(grid.validation.getInvalid().length).toEqual(0); + expect((grid.validation as any).getValidity().length).toEqual(1); + GridFunctions.verifyCellValid(cell, true); + + // Redo. Expect value 0 + grid.transactions.redo(); + fixture.detectChanges(); + + expect(grid.validation.getInvalid().length).toEqual(0); + expect((grid.validation as any).getValidity().length).toEqual(1); + GridFunctions.verifyCellValid(cell, true); + + grid.transactions.commit(grid.data); + grid.validation.clear(); + fixture.detectChanges(); + + expect((grid.validation as any).getValidity().length).toEqual(0); + }); + + it('should not show errors when the row is deleted', () => { + const grid = fixture.componentInstance.grid as IgxGridComponent; + const cell = grid.gridAPI.get_cell_by_visible_index(1, 1); + + UIInteractions.simulateDoubleClickAndSelectEvent(cell.element); + cell.editMode = true; + cell.update('IG'); + fixture.detectChanges(); + + grid.gridAPI.crudService.endEdit(true); + fixture.detectChanges(); + + expect(grid.validation.getInvalid().length).toEqual(1); + GridFunctions.verifyCellValid(cell, false); + + grid.deleteRow(2); + fixture.detectChanges(); + + expect(grid.validation.getInvalid().length).toEqual(0); + GridFunctions.verifyCellValid(cell, true); + + grid.transactions.undo(); + fixture.detectChanges(); + + expect(grid.validation.getInvalid().length).toEqual(1); + GridFunctions.verifyCellValid(cell, false); + + grid.transactions.redo(); + fixture.detectChanges(); + + expect(grid.validation.getInvalid().length).toEqual(0); + GridFunctions.verifyCellValid(cell, true); + }); + }); + + describe('TreeGrid integration - ', () => { + let fixture; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(IgxTreeGridValidationTestComponent); + fixture.componentInstance.batchEditing = true; + fixture.detectChanges(); + })); + + it('should allow setting built-in validators via template-driven and mark cell invalid', () => { + const treeGrid = fixture.componentInstance.treeGrid as IgxTreeGridComponent; + const cell = treeGrid.gridAPI.get_cell_by_visible_index(4, 1); + + UIInteractions.simulateDoubleClickAndSelectEvent(cell.element); + cell.editMode = true; + cell.update('IG'); + fixture.detectChanges(); + + GridFunctions.verifyCellValid(cell, false); + + treeGrid.gridAPI.crudService.endEdit(true); + fixture.detectChanges(); + + GridFunctions.verifyCellValid(cell, false); + }); + + it('should allow setting custom validators via template-driven and mark cell invalid', () => { + const treeGrid = fixture.componentInstance.treeGrid as IgxTreeGridComponent; + const cell = treeGrid.gridAPI.get_cell_by_visible_index(4, 1); + + UIInteractions.simulateDoubleClickAndSelectEvent(cell.element); + cell.editMode = true; + cell.update('bob'); + fixture.detectChanges(); + + GridFunctions.verifyCellValid(cell, false); + + treeGrid.gridAPI.crudService.endEdit(true); + fixture.detectChanges(); + + GridFunctions.verifyCellValid(cell, false); + }); + + it('should update validation status when using undo/redo/delete api', () => { + const treeGrid = fixture.componentInstance.treeGrid as IgxTreeGridComponent; + const cell = treeGrid.gridAPI.get_cell_by_visible_index(4, 1); + + UIInteractions.simulateDoubleClickAndSelectEvent(cell.element); + cell.editMode = true; + cell.update('IG'); + fixture.detectChanges(); + + treeGrid.gridAPI.crudService.endEdit(true); + fixture.detectChanges(); + + treeGrid.transactions.undo(); + fixture.detectChanges(); + + expect(treeGrid.validation.getInvalid().length).toEqual(0); + GridFunctions.verifyCellValid(cell, true); + + treeGrid.transactions.redo(); + fixture.detectChanges(); + + expect(treeGrid.validation.getInvalid().length).toEqual(1); + GridFunctions.verifyCellValid(cell, false); + + treeGrid.deleteRow(711); + fixture.detectChanges(); + + expect(treeGrid.validation.getInvalid().length).toEqual(0); + GridFunctions.verifyCellValid(cell, true); + }); + }); +}); diff --git a/projects/igniteui-angular/grids/grid/src/grid.component.html b/projects/igniteui-angular/grids/grid/src/grid.component.html new file mode 100644 index 00000000000..2c588d04f72 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid.component.html @@ -0,0 +1,325 @@ + + + + +@if (showGroupArea && (groupingExpressions.length > 0 || hasGroupableColumns)) { + + +} + + + + + +
    +
    + @if (moving && columnInDrag && pinnedColumns.length <= 0) { + + } + @if (moving && columnInDrag && pinnedColumns.length > 0) { + + } + + + + @if (mergedDataInView && mergedDataInView.length > 0) { +
    + @for (rowData of mergedDataInView; track rowData.record;) { + + + } +
    + } + + + @if (data + | gridTransaction:id:pipeTrigger + | visibleColumns:hasVisibleColumns + | gridAddRow:true:pipeTrigger + | gridRowPinning:id:true:pipeTrigger + | gridFiltering:filteringExpressionsTree:filterStrategy:advancedFilteringExpressionsTree:id:pipeTrigger:filteringPipeTrigger:true + | gridSort:sortingExpressions:groupingExpressions:sortStrategy:id:pipeTrigger:true + | gridCellMerge:columnsToMerge:cellMergeMode:mergeStrategy:pipeTrigger + | gridUnmergeActive:columnsToMerge:activeRowIndexes:true:pipeTrigger; as pinnedData) { + @if (pinnedData.length > 0) { +
    + @for (rowData of pinnedData; track (rowData.recordRef || rowData); let rowIndex = $index) { + + + } +
    + } + } +
    + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + @if (this.groupingExpressions.length > 0) { +
    + } + + +
    +
    +
    + + +
    + +
    +
    + @if (shouldOverlayLoading) { + + + } +
    + @if (moving && columnInDrag) { + + } +
    +
    +
    + +
    +
    +
    + +
    + {{resourceStrings.igx_grid_snackbar_addrow_label}} +
    + +
    +
    +
    + + +
    +
    + @if (hasSummarizedColumns && rootSummariesEnabled) { + + + } +
    +
    +
    + +
    +
    +
    + + +
    +
    +
    + + + + + + {{emptyFilteredGridMessage}} + @if (showAddButton) { + + + + } + + + + + + {{emptyGridMessage}} + @if (showAddButton) { + + + + } + + + + + + + + +
    + + +
    +
    + + + + + + + + + + +@if (rowEditable) { +
    +
    + + +
    +
    +} + + + {{ this.resourceStrings.igx_grid_row_edit_text | igxStringReplace:'{0}':rowChangesCount.toString() | igxStringReplace:'{1}':hiddenColumnsCount.toString() }} + + + + + + + + +
    + + + + +
    +
    +
    + + +
    +
    +
    + + + + + +@if (colResizingService.showResizer) { + +} +
    +@if (platform.isElements) { +
    + + +} diff --git a/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts new file mode 100644 index 00000000000..a5782e6034e --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts @@ -0,0 +1,3935 @@ +import { AfterViewInit, ChangeDetectorRef, Component, Injectable, OnInit, ViewChild, TemplateRef, inject } from '@angular/core'; +import { TestBed, fakeAsync, tick, flush, waitForAsync } from '@angular/core/testing'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxGridComponent } from './grid.component'; +import { IGridRowEventArgs, IgxColumnComponent, IgxColumnGroupComponent, IgxGridEmptyTemplateDirective, IgxGridFooterComponent, IgxGridLoadingTemplateDirective, IgxGridRow, IgxGroupByRow, IgxSummaryRow } from 'igniteui-angular/grids/core'; +import { IForOfState } from 'igniteui-angular/directives'; +import { GridTemplateStrings } from '../../../test-utils/template-strings.spec'; +import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; +import { BasicGridComponent } from '../../../test-utils/grid-base-components.spec'; +import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; +import { GridSelectionMode } from 'igniteui-angular/grids/core'; +import { IgxTabContentComponent, IgxTabHeaderComponent, IgxTabItemComponent, IgxTabsComponent } from 'igniteui-angular/tabs'; +import { IgxGridRowComponent } from './grid-row.component'; +import { GRID_SCROLL_CLASS, GridFunctions } from '../../../test-utils/grid-functions.spec'; +import { AsyncPipe } from '@angular/common'; +import { setElementSize, ymd } from '../../../test-utils/helper-utils.spec'; +import { FilteringExpressionsTree, FilteringLogic, getComponentSize, GridColumnDataType, IgxNumberFilteringOperand, IgxStringFilteringOperand, ISortingExpression, ɵSize, SortingDirection } from 'igniteui-angular/core'; +import { IgxPaginatorComponent, IgxPaginatorContentDirective } from 'igniteui-angular/paginator'; + +describe('IgxGrid Component Tests #grid', () => { + const MIN_COL_WIDTH = '136px'; + const COLUMN_HEADER_CLASS = '.igx-grid-th'; + + const TBODY_CLASS = '.igx-grid__tbody-content'; + const THEAD_CLASS = '.igx-grid-thead'; + + describe('IgxGrid - input properties', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxGridTestComponent, + IgxGridMarkupDeclarationComponent, + IgxGridRemoteVirtualizationComponent, + IgxGridRemoteOnDemandComponent, + IgxGridEmptyMessage100PercentComponent + ] + }) + .compileComponents(); + })); + + it('should initialize a grid with columns from markup', () => { + const fix = TestBed.createComponent(IgxGridMarkupDeclarationComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.instance; + const domGrid = fix.debugElement.query(By.css('igx-grid')).nativeElement; + + expect(grid).toBeDefined('Grid initializing through markup failed'); + expect(grid.columnList.length).toEqual(2, 'Invalid number of columns initialized'); + expect(grid.rowList.length).toEqual(3, 'Invalid number of rows initialized'); + + expect(grid.id).toContain('igx-grid-'); + expect(domGrid.id).toContain('igx-grid-'); + + grid.id = 'customGridId'; + fix.detectChanges(); + + expect(grid.id).toBe('customGridId'); + expect(domGrid.id).toBe('customGridId'); + expect(fix.componentInstance.columnEventCount).toEqual(2); + }); + + it('should initialize a grid with autogenerated columns', () => { + const fix = TestBed.createComponent(IgxGridTestComponent); + fix.componentInstance.data = [ + { Number: 1, String: '1', Boolean: true, Date: new Date(Date.now()) } + ]; + fix.componentInstance.columns = []; + fix.componentInstance.autoGenerate = true; + fix.detectChanges(); + // fakeAsync is not needed. Need a second change detection cycle for height changes to be applied. + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + + expect(grid).toBeDefined('Grid initializing through autoGenerate failed'); + expect(grid.columns.length).toEqual(4, 'Invalid number of columns initialized'); + expect(grid.rowList.length).toEqual(1, 'Invalid number of rows initialized'); + expect(grid.columns[0].dataType).toEqual(GridColumnDataType.Number, 'Invalid dataType set on column'); + expect(grid.columns.find((col) => col.index === 1).dataType) + .toEqual(GridColumnDataType.String, 'Invalid dataType set on column'); + expect(grid.columns.find((col) => col.index === 2).dataType) + .toEqual(GridColumnDataType.Boolean, 'Invalid dataType set on column'); + expect(grid.columns[grid.columns.length - 1].dataType).toEqual(GridColumnDataType.Date, 'Invalid dataType set on column'); + expect(fix.componentInstance.columnEventCount).toEqual(4); + }); + + it('should initialize a grid and change column properties during initialization', () => { + const fix = TestBed.createComponent(IgxGridTestComponent); + fix.componentInstance.columns = []; + fix.componentInstance.autoGenerate = true; + fix.detectChanges(); + // fakeAsync is not needed. Need a second change detection cycle for height changes to be applied. + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + + grid.columnList.forEach((column) => { + expect(column.filterable).toEqual(true); + expect(column.sortable).toEqual(true); + }); + }); + + it('should skip properties from autoGenerateExclude when auto-generating columns', () => { + const fix = TestBed.createComponent(IgxGridTestComponent); + fix.componentInstance.data = [ + { Number: 1, String: '1', Boolean: true, Date: new Date(Date.now()) } + ]; + fix.componentInstance.autoGenerateExclude = ['Date', 'String']; + fix.componentInstance.columns = []; + fix.componentInstance.autoGenerate = true; + fix.detectChanges(); + const grid = fix.componentInstance.grid; + + expect(grid.columns.map(col => col.field)).toEqual(['Number', 'Boolean'], 'Invalid columns after exclusion initialized'); + }); + + it('should initialize a grid and allow changing columns runtime with @for', () => { + const fix = TestBed.createComponent(IgxGridTestComponent); + fix.detectChanges(); + // reverse order of @for bound collection + fix.componentInstance.columns.reverse(); + fix.detectChanges(); + // check order + const grid = fix.componentInstance.grid; + expect(grid.columns[0].field).toBe('value'); + expect(grid.columns[1].field).toBe('index'); + }); + + it('should initialize grid with remote virtualization', async () => { + const fix = TestBed.createComponent(IgxGridRemoteVirtualizationComponent); + fix.detectChanges(); + await wait(16); + let rows = fix.componentInstance.instance.rowList.toArray(); + expect(rows.length).toEqual(10); + + const verticalScroll = fix.componentInstance.instance.verticalScrollContainer; + const elem = verticalScroll['scrollComponent'].elementRef.nativeElement; + + // scroll down + expect(() => { + elem.scrollTop = 1000; + fix.detectChanges(); + fix.componentRef.hostView.detectChanges(); + }).not.toThrow(); + + fix.detectChanges(); + fix.componentInstance.cdr.detectChanges(); + await wait(16); + rows = fix.componentInstance.instance.rowList.toArray(); + const data = fix.componentInstance.data.source.getValue(); + for (let i = fix.componentInstance.instance.virtualizationState.startIndex; i < rows.length; i++) { + expect(rows[i].data['Col1']) + .toBe(data[i]['Col1']); + } + }); + + it('should remove all rows if data becomes null/undefined.', () => { + const fix = TestBed.createComponent(IgxGridRemoteVirtualizationComponent); + fix.detectChanges(); + const grid = fix.componentInstance.instance; + expect(grid.rowList.length).toEqual(10); + + fix.componentInstance.nullData(); + fix.detectChanges(); + + const noRecordsSpan = fix.debugElement.query(By.css('.igx-grid__tbody-message')); + expect(grid.rowList.length).toEqual(0); + expect(noRecordsSpan).toBeTruthy(); + expect(noRecordsSpan.nativeElement.innerText).toBe('Grid has no data.'); + }); + + it('height/width should be calculated depending on number of records', () => { + const fix = TestBed.createComponent(IgxGridTestComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + fix.componentInstance.grid.height = null; + fix.detectChanges(); + + const gridBody = fix.debugElement.query(By.css(TBODY_CLASS)); + const gridHeader = fix.debugElement.query(By.css(THEAD_CLASS)); + const gridFooter = fix.debugElement.query(By.css('.igx-grid__tfoot')); + const gridScroll = fix.debugElement.query(By.css(GRID_SCROLL_CLASS)); + let gridBodyHeight; + + expect(grid.rowList.length).toEqual(1); + expect(window.getComputedStyle(gridBody.nativeElement).height).toMatch('51px'); + + for (let i = 2; i <= 30; i++) { + grid.addRow({ index: i, value: i }); + } + + fix.detectChanges(); + + expect(grid.rowList.length).toEqual(30); + expect(window.getComputedStyle(gridBody.nativeElement).height).toMatch('1530px'); + expect(fix.componentInstance.isVerticalScrollbarVisible()).toBe(false); + expect(fix.componentInstance.isHorizontalScrollbarVisible()).toBe(false); + + grid.height = '200px'; + fix.detectChanges(); + + expect(fix.componentInstance.isVerticalScrollbarVisible()).toBe(true); + // no horizontal scr, since columns have no width hence they should + // distribute the available width between them + expect(fix.componentInstance.isHorizontalScrollbarVisible()).toBe(false); + const verticalScrollHeight = fix.componentInstance.getVerticalScrollHeight(); + + grid.width = '200px'; + fix.detectChanges(); + + expect(fix.componentInstance.isVerticalScrollbarVisible()).toBe(true); + expect(fix.componentInstance.isHorizontalScrollbarVisible()).toBe(true); + expect(fix.componentInstance.getVerticalScrollHeight()).toBeLessThan(verticalScrollHeight); + gridBodyHeight = parseInt(window.getComputedStyle(grid.nativeElement).height, 10) + - parseInt(window.getComputedStyle(gridHeader.nativeElement).height, 10) + - parseInt(window.getComputedStyle(gridFooter.nativeElement).height, 10) + - parseInt(window.getComputedStyle(gridScroll.nativeElement).height, 10); + + expect(window.getComputedStyle(grid.nativeElement).width).toMatch('200px'); + expect(window.getComputedStyle(grid.nativeElement).height).toMatch('200px'); + expect(parseInt(window.getComputedStyle(gridBody.nativeElement).height, 10)).toEqual(gridBodyHeight); + + grid.height = '50%'; + fix.detectChanges(); + grid.width = '50%'; + fix.detectChanges(); + + expect(window.getComputedStyle(grid.nativeElement).height).toMatch('300px'); + expect(window.getComputedStyle(grid.nativeElement).width).toMatch('400px'); + + gridBodyHeight = parseInt(window.getComputedStyle(grid.nativeElement).height, 10) + - parseInt(window.getComputedStyle(gridHeader.nativeElement).height, 10) + - parseInt(window.getComputedStyle(gridFooter.nativeElement).height, 10); + + // The scrollbar is no longer visible + expect(parseInt(window.getComputedStyle(gridBody.nativeElement).height, 10)).toEqual(gridBodyHeight); + }); + + it('should not have column misalignment when no vertical scrollbar is shown', () => { + const fix = TestBed.createComponent(IgxGridTestComponent); + fix.detectChanges(); + // fakeAsync is not needed. Need a second change detection cycle for height changes to be applied. + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + const gridBody = fix.debugElement.query(By.css(TBODY_CLASS)); + const gridHeader = fix.debugElement.query(By.css(THEAD_CLASS)); + + expect(window.getComputedStyle(gridBody.children[0].nativeElement).width).toEqual( + window.getComputedStyle(gridHeader.children[0].nativeElement).width + ); + expect(grid.rowList.length).toBeGreaterThan(0); + }); + + it('should change grid size runtime correctly', async () => { + const fixture = TestBed.createComponent(IgxGridTestComponent); + const grid = fixture.componentInstance.grid; + fixture.componentInstance.columns[1].hasSummary = true; + grid.summaryRowHeight = 0; + // density with custom class #6931: + grid.nativeElement.classList.add('custom'); + fixture.detectChanges(); + + const headerHight = fixture.debugElement.query(By.css(THEAD_CLASS)).query(By.css('.igx-grid__tr')).nativeElement; + const rowHeight = fixture.debugElement.query(By.css(TBODY_CLASS)).query(By.css('.igx-grid__tr')).nativeElement; + const summaryItemHeight = fixture.debugElement.query(By.css('.igx-grid__tfoot')) + .query(By.css('.igx-grid-summary__item')).nativeElement; + const summaryRowHeight = fixture.debugElement.query(By.css('.igx-grid__tfoot')).nativeElement; + + + expect(grid.nativeElement.classList).toEqual(jasmine.arrayWithExactContents(['igx-grid', 'custom'])); + expect(getComponentSize(grid.nativeElement)).toEqual('3'); + expect(grid.defaultRowHeight).toBe(50); + expect(headerHight.offsetHeight).toBe(grid.defaultRowHeight); + expect(rowHeight.offsetHeight).toBe(51); + expect(summaryItemHeight.offsetHeight).toBe(grid.defaultSummaryHeight - 1); + expect(summaryRowHeight.offsetHeight).toBe(grid.defaultSummaryHeight); + setElementSize(grid.nativeElement, ɵSize.Medium) + grid.summaryRowHeight = null; + fixture.detectChanges(); + await wait(32); // needed because of the throttleTime on the resize observer + fixture.detectChanges(); + + expect(getComponentSize(grid.nativeElement)).toEqual('2'); + expect(grid.defaultRowHeight).toBe(40); + expect(headerHight.offsetHeight).toBe(grid.defaultRowHeight); + expect(rowHeight.offsetHeight).toBe(41); + expect(summaryItemHeight.offsetHeight).toBe(grid.defaultSummaryHeight - 1); + expect(summaryRowHeight.offsetHeight).toBe(grid.defaultSummaryHeight); + setElementSize(grid.nativeElement, ɵSize.Small) + grid.summaryRowHeight = undefined; + fixture.detectChanges(); + await wait(32); // needed because of the throttleTime on the resize observer + fixture.detectChanges(); + + expect(getComponentSize(grid.nativeElement)).toEqual('1'); + expect(grid.defaultRowHeight).toBe(32); + expect(headerHight.offsetHeight).toBe(grid.defaultRowHeight); + expect(rowHeight.offsetHeight).toBe(33); + expect(summaryItemHeight.offsetHeight).toBe(grid.defaultSummaryHeight - 1); + expect(summaryRowHeight.offsetHeight).toBe(grid.defaultSummaryHeight); + }); + + it ('checks if attributes are correctly assigned when grid has or does not have data', fakeAsync( () => { + const fixture = TestBed.createComponent(IgxGridTestComponent); + const grid = fixture.componentInstance.grid; + + fixture.componentInstance.generateData(30); + fixture.detectChanges(); + tick(100); + // Checks if igx-grid__tbody-content attribute is null when there is data in the grid + const container = fixture.nativeElement.querySelectorAll('.igx-grid__tbody-content')[0]; + expect(container.getAttribute('role')).toBe(null); + + //Filter grid so no results are available and grid is empty + grid.filter('index','111',IgxStringFilteringOperand.instance().condition('contains'),true); + grid.markForCheck(); + fixture.detectChanges(); + expect(container.getAttribute('role')).toMatch('row'); + + // clear grid data and check if attribute is now 'row' + grid.clearFilter(); + fixture.componentInstance.clearData(); + fixture.detectChanges(); + tick(100); + + expect(container.getAttribute('role')).toMatch('row'); + + })); + + it('should render empty message', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxGridTestComponent); + fixture.componentInstance.data = []; + fixture.detectChanges(); + tick(16); + + const grid = fixture.componentInstance.grid; + const gridBody = fixture.debugElement.query(By.css(TBODY_CLASS)); + const domGrid = fixture.debugElement.query(By.css('igx-grid')).nativeElement; + + // make sure default width/height are applied when there is no data + expect(domGrid.style.height).toBe('100%'); + expect(domGrid.style.width).toBe('100%'); + + // Check for loaded rows in grid's container + fixture.componentInstance.generateData(30); + fixture.detectChanges(); + tick(1000); + expect(parseInt(window.getComputedStyle(gridBody.nativeElement).height, 10)).toBe(548); + + // Check for empty filter grid message and body less than 100px + const columns = fixture.componentInstance.grid.columnList; + grid.filter(columns.get(0).field, 546000, IgxNumberFilteringOperand.instance().condition('equals')); + fixture.detectChanges(); + tick(100); + expect(gridBody.nativeElement.textContent).toEqual(grid.emptyFilteredGridMessage); + expect(parseInt(window.getComputedStyle(gridBody.nativeElement).height, 10)).toBe(548); + + // Clear filter and check if grid's body height is restored based on all loaded rows + grid.clearFilter(columns.get(0).field); + fixture.detectChanges(); + tick(100); + expect(parseInt(window.getComputedStyle(gridBody.nativeElement).height, 10)).toBe(548); + + // Clearing grid's data and check for empty grid message + fixture.componentInstance.clearData(); + fixture.detectChanges(); + tick(100); + + expect(gridBody.nativeElement.innerText).toMatch(grid.emptyGridMessage); + })); + + it('should render loading indicator when loading is enabled', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxGridTestComponent); + fixture.componentInstance.data = []; + fixture.componentInstance.grid.isLoading = true; + fixture.detectChanges(); + tick(16); + + const grid = fixture.componentInstance.grid; + const gridBody = fixture.debugElement.query(By.css(TBODY_CLASS)); + let loadingIndicator = gridBody.query(By.css('.igx-grid__loading')); + const domGrid = fixture.debugElement.query(By.css('igx-grid')).nativeElement; + + // make sure default width/height are applied when there is no data + expect(domGrid.style.height).toBe('100%'); + expect(domGrid.style.width).toBe('100%'); + + expect(loadingIndicator).not.toBeNull(); + expect(gridBody.nativeElement.textContent).not.toEqual(grid.emptyFilteredGridMessage); + + // Check for loaded rows in grid's container + fixture.componentInstance.generateData(30); + fixture.detectChanges(); + tick(1000); + expect(parseInt(window.getComputedStyle(gridBody.nativeElement).height, 10)).toBe(548); + + loadingIndicator = gridBody.query(By.css('.igx-grid__loading')); + expect(loadingIndicator).toBeNull(); + + // Check for empty filter grid message and body less than 100px + const columns = fixture.componentInstance.grid.columnList; + grid.filter(columns.get(0).field, 546000, IgxNumberFilteringOperand.instance().condition('equals')); + fixture.detectChanges(); + tick(100); + expect(gridBody.nativeElement.textContent).not.toEqual(grid.emptyFilteredGridMessage); + expect(parseInt(window.getComputedStyle(gridBody.nativeElement).height, 10)).toBe(548); + + // Clear filter and check if grid's body height is restored based on all loaded rows + grid.clearFilter(columns.get(0).field); + fixture.detectChanges(); + tick(100); + expect(parseInt(window.getComputedStyle(gridBody.nativeElement).height, 10)).toBe(548); + + // Clearing grid's data and check for empty grid message + fixture.componentInstance.clearData(); + fixture.detectChanges(); + tick(100); + + loadingIndicator = gridBody.query(By.css('.igx-grid__loading')); + expect(loadingIndicator).not.toBeNull(); + })); + + it('should render loading indicator when loading is enabled when there is height', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxGridTestComponent); + fixture.componentInstance.data = []; + fixture.componentInstance.grid.isLoading = true; + fixture.componentInstance.grid.height = '400px'; + fixture.detectChanges(); + tick(16); + + const grid = fixture.componentInstance.grid; + const gridElement = fixture.debugElement.query(By.css('.igx-grid')); + const gridBody = fixture.debugElement.query(By.css(TBODY_CLASS)); + let loadingIndicator = gridBody.query(By.css('.igx-grid__loading')); + + expect(loadingIndicator).not.toBeNull(); + expect(gridBody.nativeElement.textContent).not.toEqual(grid.emptyFilteredGridMessage); + + // Check for loaded rows in grid's container + fixture.componentInstance.generateData(30); + fixture.detectChanges(); + tick(1000); + expect(parseInt(window.getComputedStyle(gridBody.nativeElement).height, 10)).toBeGreaterThan(300); + + loadingIndicator = gridBody.query(By.css('.igx-grid__loading')); + expect(loadingIndicator).toBeNull(); + + // the overlay should be shown + loadingIndicator = gridElement.query(By.css('.igx-grid__loading-outlet')); + expect(loadingIndicator.nativeElement.children.length).not.toBe(0); + + // Check for empty filter grid message and body less than 100px + const columns = fixture.componentInstance.grid.columnList; + grid.filter(columns.get(0).field, 546000, IgxNumberFilteringOperand.instance().condition('equals')); + fixture.detectChanges(); + tick(100); + expect(gridBody.nativeElement.textContent).not.toEqual(grid.emptyFilteredGridMessage); + + // Clear filter and check if grid's body height is restored based on all loaded rows + grid.clearFilter(columns.get(0).field); + fixture.detectChanges(); + tick(100); + expect(parseInt(window.getComputedStyle(gridBody.nativeElement).height, 10)).toBeGreaterThan(300); + + // Clearing grid's data and check for empty grid message + fixture.componentInstance.clearData(); + fixture.detectChanges(); + tick(100); + + loadingIndicator = gridBody.query(By.css('.igx-grid__loading')); + expect(loadingIndicator).not.toBeNull(); + + // the overlay should be hidden + loadingIndicator = gridElement.query(By.css('.igx-grid__loading-outlet')); + expect(loadingIndicator.nativeElement.children.length).toBe(0); + })); + + it('should render loading indicator when loading is enabled and autoGenerate is enabled', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxGridTestComponent); + fixture.componentInstance.data = []; + fixture.componentInstance.grid.isLoading = true; + fixture.componentInstance.columns = []; + fixture.componentInstance.autoGenerate = true; + fixture.detectChanges(); + tick(16); + + const grid = fixture.componentInstance.grid; + const gridBody = fixture.debugElement.query(By.css(TBODY_CLASS)); + const gridHead = fixture.debugElement.query(By.css(THEAD_CLASS)); + let loadingIndicator = gridBody.query(By.css('.igx-grid__loading')); + let colHeaders = gridHead.queryAll(By.css('igx-grid-header')); + + expect(loadingIndicator).not.toBeNull(); + expect(colHeaders.length).toBe(0); + expect(gridBody.nativeElement.textContent).not.toEqual(grid.emptyFilteredGridMessage); + + // Check for loaded rows in grid's container + fixture.componentInstance.data = [ + { Number: 1, String: '1', Boolean: true, Date: new Date(Date.now()) } + ]; + fixture.detectChanges(); + tick(1000); + + loadingIndicator = gridBody.query(By.css('.igx-grid__loading')); + colHeaders = gridHead.queryAll(By.css('igx-grid-header')); + expect(colHeaders.length).toBeGreaterThan(0); + expect(loadingIndicator).toBeNull(); + + // Clearing grid's data and check for empty grid message + fixture.componentInstance.clearData(); + fixture.detectChanges(); + tick(100); + + loadingIndicator = gridBody.query(By.css('.igx-grid__loading')); + expect(loadingIndicator).not.toBeNull(); + })); + + it('should render loading indicator when loading is enabled and autoGenerate is enabled and async data', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxGridRemoteOnDemandComponent); + fixture.detectChanges(); + tick(16); + + const grid = fixture.componentInstance.instance; + const gridBody = fixture.debugElement.query(By.css(TBODY_CLASS)); + const gridHead = fixture.debugElement.query(By.css(THEAD_CLASS)); + let loadingIndicator = gridBody.query(By.css('.igx-grid__loading')); + + expect(loadingIndicator).not.toBeNull(); + expect(gridBody.nativeElement.textContent).not.toEqual(grid.emptyFilteredGridMessage); + + fixture.componentInstance.bind(); + + const colHeaders = gridHead.queryAll(By.css('igx-grid-header')); + loadingIndicator = gridBody.query(By.css('.igx-grid__loading')); + expect(colHeaders.length).toBeGreaterThan(0); + expect(loadingIndicator).toBeNull(); + expect(parseInt(window.getComputedStyle(gridBody.nativeElement).height, 10)).toBeGreaterThan(500); + })); + + it('should render loading indicator when loading is enabled and the grid has empty filtering pre-applied', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxGridTestComponent); + const grid = fixture.componentInstance.grid; + grid.filteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And); + grid.filteringExpressionsTree.filteringOperands = [ + { + condition: IgxNumberFilteringOperand.instance().condition('equals'), + conditionName: 'equals', + fieldName: 'index', + searchVal: 0 + } + ]; + grid.isLoading = true; + fixture.detectChanges(); + tick(16); + + const gridBody = fixture.debugElement.query(By.css(TBODY_CLASS)); + const loadingIndicator = gridBody.query(By.css('.igx-grid__loading')); + const domGrid = fixture.debugElement.query(By.css('igx-grid')).nativeElement; + + // make sure default width/height are applied when there is no data + expect(domGrid.style.height).toBe('100%'); + expect(domGrid.style.width).toBe('100%'); + + expect(loadingIndicator).not.toBeNull(); + expect(gridBody.nativeElement.textContent).not.toEqual(grid.emptyFilteredGridMessage); + })); + + it('should allow applying custom empty and loading indicator', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxGridRemoteOnDemandComponent); + fixture.componentInstance.customLoading = true; + fixture.detectChanges(); + tick(16); + + const grid = fixture.componentInstance.instance; + const gridBody = fixture.debugElement.query(By.css(TBODY_CLASS)); + const gridHead = fixture.debugElement.query(By.css(THEAD_CLASS)); + + grid.isLoading = false; + tick(); + fixture.detectChanges(); + expect(gridBody.nativeElement.textContent).toEqual('No Data 😢'); + grid.isLoading = true; + tick(); + fixture.detectChanges(); + expect(gridBody.nativeElement.textContent).toEqual('Loading 🔃'); + + grid.loadingGridTemplate = fixture.componentInstance.customTemplate; + grid.markForCheck(); + fixture.detectChanges(); + expect(gridBody.nativeElement.textContent).toEqual('Loading...'); + expect(gridBody.nativeElement.textContent).not.toEqual(grid.emptyFilteredGridMessage); + + fixture.componentInstance.bind(); + + const colHeaders = gridHead.queryAll(By.css('igx-grid-header')); + expect(colHeaders.length).toBeGreaterThan(0); + expect(parseInt(window.getComputedStyle(gridBody.nativeElement).height, 10)).toBeGreaterThan(500); + })); + + it('should remove loading overlay when isLoading is set to false', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxGridTestComponent); + fixture.componentInstance.data = []; + fixture.componentInstance.grid.isLoading = true; + fixture.detectChanges(); + tick(16); + + const grid = fixture.componentInstance.grid; + const gridElement = fixture.debugElement.query(By.css('.igx-grid')); + const gridBody = fixture.debugElement.query(By.css(TBODY_CLASS)); + let loadingIndicator = gridBody.query(By.css('.igx-grid__loading')); + + expect(loadingIndicator).not.toBeNull(); + expect(gridBody.nativeElement.textContent).not.toEqual(grid.emptyFilteredGridMessage); + + // Check for loaded rows in grid's container + fixture.componentInstance.generateData(30); + fixture.detectChanges(); + tick(1000); + expect(parseInt(window.getComputedStyle(gridBody.nativeElement).height, 10)).toBe(548); + + loadingIndicator = gridBody.query(By.css('.igx-grid__loading')); + expect(loadingIndicator).toBeNull(); + + // the overlay should be shown + loadingIndicator = gridElement.query(By.css('.igx-grid__loading-outlet')); + expect(loadingIndicator.nativeElement.children.length).not.toBe(0); + + grid.isLoading = false; + tick(16); + expect(loadingIndicator.nativeElement.children.length).toBe(0); + + // Clearing grid's data and check for empty grid message + fixture.componentInstance.clearData(); + fixture.detectChanges(); + tick(100); + + // isLoading is still false so the empty data message should show, not the loading indicator + loadingIndicator = gridBody.query(By.css('.igx-grid__loading')); + expect(loadingIndicator).toBeNull(); + + expect(gridBody.nativeElement.textContent).toEqual(grid.emptyGridMessage); + })); + + it('should render empty message when grid height is 100%', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxGridEmptyMessage100PercentComponent); + fixture.detectChanges(); + tick(16); + + const grid = fixture.componentInstance.grid; + const gridBody = fixture.debugElement.query(By.css(TBODY_CLASS)); + const domGrid = fixture.debugElement.query(By.css('igx-grid')).nativeElement; + + // make sure default width/height are applied when there is no data + expect(domGrid.style.height).toBe('100%'); + expect(domGrid.style.width).toBe('100%'); + + expect(parseInt(window.getComputedStyle(gridBody.nativeElement).height, 10)).toBeGreaterThan(0); + expect(gridBody.nativeElement.innerText).toMatch(grid.emptyGridMessage); + })); + + it('should apply correct rowHeight when set as input', () => { + const fixture = TestBed.createComponent(IgxGridTestComponent); + const grid = fixture.componentInstance.grid; + grid.rowHeight = 75; + fixture.detectChanges(); + + const cell = fixture.debugElement.query(By.css(TBODY_CLASS)).query(By.css('.igx-grid__td')).nativeElement; + const expectedCellHeight = 76; // rowHeight + 1px border + expect(cell.offsetHeight).toEqual(expectedCellHeight); + }); + + it('should throw a warning when primaryKey is set to a non-existing data field', () => { + jasmine.getEnv().allowRespy(true); + const warnSpy = spyOn(console, 'warn'); + const fixture = TestBed.createComponent(IgxGridTestComponent); + const grid = fixture.componentInstance.grid; + grid.primaryKey = 'testField'; + fixture.detectChanges(); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + `Field "${grid.primaryKey}" is not defined in the data. Set \`primaryKey\` to a valid field.` + ); + warnSpy.calls.reset(); + + // update data to include the 'testField' + fixture.componentInstance.data = [{ index: 1, value: 1, testField: 1 }]; + fixture.detectChanges(); + + expect(console.warn).toHaveBeenCalledTimes(0); + + // remove the 'testField' runtime + fixture.componentInstance.data = [{ index: 1, value: 1 }]; + fixture.componentInstance.columns = [...fixture.componentInstance.columns]; + fixture.detectChanges(); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + `Field "${grid.primaryKey}" is not defined in the data. Set \`primaryKey\` to a valid field.` + ); + jasmine.getEnv().allowRespy(false); + }); + }); + + describe('IgxGrid - virtualization tests', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxGridTestComponent + ] + }) + .compileComponents(); + })); + + it('should change chunk size for every record after enlarging the grid and the horizontal dirs are scrambled', async () => { + const fix = TestBed.createComponent(IgxGridTestComponent); + for (let i = 2; i < 100; i++) { + fix.componentInstance.data.push({ index: i, value: i, desc: i, detail: i }); + } + fix.componentInstance.columns[0].width = '400px'; + fix.componentInstance.columns[1].width = '400px'; + fix.componentInstance.columns.push( + { field: 'desc', header: 'desc', dataType: 'number', width: '400px', hasSummary: false }, + { field: 'detail', header: 'detail', dataType: 'number', width: '400px', hasSummary: false } + ); + fix.detectChanges(); + fix.componentInstance.grid.verticalScrollContainer.getScroll().scrollTop = 100; + await wait(100); + fix.detectChanges(); + fix.componentInstance.grid.verticalScrollContainer.getScroll().scrollTop = 250; + await wait(100); + fix.detectChanges(); + fix.componentInstance.grid.width = '1300px'; + await wait(100); + fix.detectChanges(); + const rows = fix.componentInstance.grid.rowList.toArray(); + for (const row of rows) { + expect(row.cells.length).toEqual(4); + } + }); + + it('should not keep a cached-out template as master after column resizing', async () => { + const fix = TestBed.createComponent(IgxGridTestComponent); + for (let i = 2; i < 100; i++) { + fix.componentInstance.data.push({ index: i, value: i, desc: i, detail: i }); + } + fix.componentInstance.columns[0].width = '400px'; + fix.componentInstance.columns[1].width = '400px'; + fix.componentInstance.columns.push( + { field: 'desc', header: 'desc', dataType: 'number', width: '400px', hasSummary: false }, + { field: 'detail', header: 'detail', dataType: 'number', width: '400px', hasSummary: false } + ); + fix.detectChanges(); + fix.componentInstance.grid.groupBy({ fieldName: 'value', dir: SortingDirection.Asc }); + fix.detectChanges(); + fix.componentInstance.grid.getColumnByName('index').width = '100px'; + fix.detectChanges(); + await wait(16); + const rows = fix.componentInstance.grid.dataRowList.toArray(); + for (const row of rows) { + expect(row.cells.length).toEqual(4); + } + }); + + it('Should scroll horizontally when press shift + mouse wheel over grid headers', (async () => { + const fix = TestBed.createComponent(IgxGridTestComponent); + for (let i = 2; i < 100; i++) { + fix.componentInstance.data.push({ index: i, value: i, desc: i, detail: i }); + } + fix.componentInstance.columns[0].width = '400px'; + fix.componentInstance.columns[1].width = '400px'; + fix.componentInstance.columns.push( + { field: 'desc', header: 'desc', dataType: 'number', width: '400px', hasSummary: false }, + { field: 'detail', header: 'detail', dataType: 'number', width: '400px', hasSummary: false } + ); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + grid.headerContainer.dc.instance._scrollInertia.smoothingDuration = 0; + const initialScroll = grid.verticalScrollContainer.getScroll().scrollTop; + const initialHorScroll = grid.headerContainer.getScroll().scrollLeft; + + const displayContainer = grid.headerContainer.dc.instance._viewContainer.element.nativeElement; + await UIInteractions.simulateWheelEvent(displayContainer, 0, -240, true); + fix.detectChanges(); + await wait(16); + + expect(grid.verticalScrollContainer.getScroll().scrollTop).toBe(initialScroll); + expect(grid.headerContainer.getScroll().scrollLeft).toBeGreaterThan(initialHorScroll + 50); + + await UIInteractions.simulateWheelEvent(displayContainer, 0, 240, true); + fix.detectChanges(); + await wait(16); + + expect(grid.verticalScrollContainer.getScroll().scrollTop).toBe(initialScroll); + expect(grid.headerContainer.getScroll().scrollLeft).toEqual(initialHorScroll); + })); + + + it('Should scroll horizontally when press shift + mouse wheel over grid data row', (async () => { + const fix = TestBed.createComponent(IgxGridTestComponent); + for (let i = 2; i < 100; i++) { + fix.componentInstance.data.push({ index: i, value: i, desc: i, detail: i }); + } + fix.componentInstance.columns[0].width = '400px'; + fix.componentInstance.columns[1].width = '400px'; + fix.componentInstance.columns.push( + { field: 'desc', header: 'desc', dataType: 'number', width: '400px', hasSummary: false }, + { field: 'detail', header: 'detail', dataType: 'number', width: '400px', hasSummary: false } + ); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + grid.rowList.first.virtDirRow.dc.instance._scrollInertia.smoothingDuration = 0; + const initialScroll = grid.verticalScrollContainer.getScroll().scrollTop; + const initialHorScroll = grid.rowList.first.virtDirRow.getScroll().scrollLeft; + + const cell = grid.gridAPI.get_cell_by_index(3, 'value'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + const displayContainer = grid.rowList.first.virtDirRow.dc.instance._viewContainer.element.nativeElement; + await UIInteractions.simulateWheelEvent(displayContainer, 0, -240, true); + fix.detectChanges(); + await wait(16); + + expect(grid.verticalScrollContainer.getScroll().scrollTop).toBe(initialScroll); + expect(grid.headerContainer.getScroll().scrollLeft).toBeGreaterThan(initialHorScroll + 50); + + await UIInteractions.simulateWheelEvent(displayContainer, 0, -240, true); + fix.detectChanges(); + await wait(16); + + expect(grid.verticalScrollContainer.getScroll().scrollTop).toBe(initialScroll); + expect(grid.headerContainer.getScroll().scrollLeft).toBeGreaterThanOrEqual(2 * (initialHorScroll + 50)); + })); + }); + + describe('IgxGrid - default rendering for rows and columns', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxGridDefaultRenderingComponent, + IgxGridColumnPercentageWidthComponent, + IgxGridWrappedInContComponent, + IgxGridFormattingComponent, + IgxGridFixedContainerHeightComponent + ] + }) + .compileComponents(); + })); + + it('should init columns with width >= 136px when 5 rows and 5 columns are rendered', () => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + fix.componentInstance.initColumnsRows(5, 5); + fix.detectChanges(); + // fakeAsync is not needed. Need a second change detection cycle for height changes to be applied. + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + + expect(grid.columnList.get(0).width).not.toBeLessThan(136); + expect(grid.columnList.get(2).width).not.toBeLessThan(136); + expect(grid.width).toMatch('100%'); + expect(grid.rowList.length).toBeGreaterThan(0); + }); + + it('should init columns with width >= 136px when 30 rows and 10 columns are rendered', () => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + fix.componentInstance.initColumnsRows(30, 10); + fix.detectChanges(); + // fakeAsync is not needed. Need a second change detection cycle for height changes to be applied. + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + + expect(grid.columnList.get(0).width).not.toBeLessThan(136); + expect(grid.columnList.get(4).width).not.toBeLessThan(136); + expect(grid.columnList.get(6).width).not.toBeLessThan(136); + expect(grid.width).toMatch('100%'); + expect(grid.rowList.length).toBeGreaterThan(0); + }); + + it(`should init columns with width >= 136px and a horizontal scrollbar + when 1000 rows and 30 columns are rendered`, () => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + fix.componentInstance.initColumnsRows(1000, 30); + fix.detectChanges(); + // fakeAsync is not needed. Need a second change detection cycle for height changes to be applied. + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + + expect(grid.columnList.get(0).width).not.toBeLessThan(136); + expect(grid.columnList.get(4).width).not.toBeLessThan(136); + expect(grid.columnList.get(14).width).not.toBeLessThan(136); + expect(fix.componentInstance.isHorizontalScrollbarVisible()).toBe(true); + }); + + it(`should init columns with width >= 136px and a horizontal scrollbar + when 200 rows and 150 columns are rendered`, () => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + fix.componentInstance.initColumnsRows(200, 150); + fix.detectChanges(); + // fakeAsync is not needed. Need a second change detection cycle for height changes to be applied. + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + + expect(grid.columnList.get(0).width).not.toBeLessThan(136); + expect(grid.columnList.get(4).width).not.toBeLessThan(136); + expect(grid.columnList.get(100).width).not.toBeLessThan(136); + expect(fix.componentInstance.isHorizontalScrollbarVisible()).toBe(true); + expect(grid.rowList.length).toBeGreaterThan(0); + }); + + it('should account for columns with set width when determining default column width when grid has 100% width', () => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + const grid = fix.componentInstance.grid; + fix.componentInstance.initColumnsRows(5, 5); + fix.componentInstance.changeInitColumns = true; + fix.detectChanges(); + // fakeAsync is not needed. Need a second change detection cycle for height changes to be applied. + fix.detectChanges(); + + expect(grid.width).toEqual('100%'); + expect(grid.columnList.get(0).width).toEqual('100px'); + expect(grid.columnList.get(4).width).toEqual('100px'); + + const actualGridWidth = grid.nativeElement.clientWidth; + const expectedDefWidth = Math.max(Math.floor((actualGridWidth - + parseInt(grid.columnList.get(0).width, 10) - + parseInt(grid.columnList.get(4).width, 10)) / 3), + parseInt(MIN_COL_WIDTH, 10)); + expect(parseInt(grid.columnWidth, 10)).toEqual(expectedDefWidth); + + expect(parseInt(grid.columnList.get(1).width, 10)).toEqual(expectedDefWidth); + expect(parseInt(grid.columnList.get(2).width, 10)).toEqual(expectedDefWidth); + expect(parseInt(grid.columnList.get(3).width, 10)).toEqual(expectedDefWidth); + + grid.columnList.forEach((column) => { + const width = parseInt(column.width, 10); + const minWidth = parseInt(grid.columnWidth, 10); + if (column.index !== 0 && column.index !== 4) { + expect(width).toBeGreaterThanOrEqual(minWidth); + } + }); + + expect(fix.componentInstance.isHorizontalScrollbarVisible()).toBe(false); + expect(grid.rowList.length).toBeGreaterThan(0); + }); + + it('should account for columns with set width when determining default column width when grid has px width', () => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + const grid = fix.componentInstance.grid; + grid.width = '600px'; + fix.componentInstance.initColumnsRows(5, 5); + fix.componentInstance.changeInitColumns = true; + fix.detectChanges(); + // fakeAsync is not needed. Need a second change detection cycle for height changes to be applied. + fix.detectChanges(); + + expect(grid.width).toEqual('600px'); + expect(grid.columnList.get(0).width).toEqual('100px'); + expect(grid.columnList.get(4).width).toEqual('100px'); + + const actualGridWidth = grid.nativeElement.clientWidth; + const expectedDefWidth = Math.max(Math.floor((actualGridWidth - + parseInt(grid.columnList.get(0).width, 10) - + parseInt(grid.columnList.get(4).width, 10)) / 3), + parseInt(MIN_COL_WIDTH, 10)); + expect(parseInt(grid.columnWidth, 10)).toEqual(expectedDefWidth); + + expect(parseInt(grid.columnList.get(1).width, 10)).toEqual(expectedDefWidth); + expect(parseInt(grid.columnList.get(2).width, 10)).toEqual(expectedDefWidth); + expect(parseInt(grid.columnList.get(3).width, 10)).toEqual(expectedDefWidth); + + grid.columnList.forEach((column) => { + const width = parseInt(column.width, 10); + const minWidth = parseInt(grid.columnWidth, 10); + if (column.index !== 0 && column.index !== 4) { + expect(width).toBeGreaterThanOrEqual(minWidth); + } + }); + + expect(fix.componentInstance.isHorizontalScrollbarVisible()).toBe(true); + expect(grid.rowList.length).toBeGreaterThan(0); + }); + + it(`should account for columns with set width when determining default column width when grid has 100% width + and there are enough rows to cover the grid's height`, () => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + const grid = fix.componentInstance.grid; + fix.componentInstance.initColumnsRows(30, 5); + fix.componentInstance.changeInitColumns = true; + fix.detectChanges(); + // fakeAsync is not needed. Need a second change detection cycle for height changes to be applied. + fix.detectChanges(); + + expect(grid.width).toEqual('100%'); + expect(grid.columnList.get(0).width).toEqual('100px'); + expect(grid.columnList.get(4).width).toEqual('100px'); + + const actualGridWidth = grid.unpinnedWidth; + + const expectedDefWidth = Math.max(Math.floor((actualGridWidth - + parseInt(grid.columnList.get(0).width, 10) - + parseInt(grid.columnList.get(4).width, 10)) / 3), + parseInt(MIN_COL_WIDTH, 10)); + expect(parseInt(grid.columnWidth, 10)).toEqual(expectedDefWidth); + + expect(parseInt(grid.columnList.get(1).width, 10)).toEqual(expectedDefWidth); + expect(parseInt(grid.columnList.get(2).width, 10)).toEqual(expectedDefWidth); + expect(parseInt(grid.columnList.get(3).width, 10)).toEqual(expectedDefWidth); + + grid.columnList.forEach((column) => { + const width = parseInt(column.width, 10); + const minWidth = parseInt(grid.columnWidth, 10); + if (column.index !== 0 && column.index !== 4) { + expect(width).toBeGreaterThanOrEqual(minWidth); + } + }); + + expect(fix.componentInstance.isHorizontalScrollbarVisible()).toBe(false); + expect(grid.rowList.length).toBeGreaterThan(0); + }); + + it(`should account for columns with set width when determining default column width when grid has 100% width + and there are enough rows to cover the grid's height and enough columns to cover the grid's width`, () => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + const grid = fix.componentInstance.grid; + fix.componentInstance.initColumnsRows(1000, 30); + fix.componentInstance.changeInitColumns = true; + fix.detectChanges(); + // fakeAsync is not needed. Need a second change detection cycle for height changes to be applied. + fix.detectChanges(); + + expect(grid.width).toEqual('100%'); + expect(grid.columnList.get(0).width).toEqual('200px'); + expect(grid.columnList.get(3).width).toEqual('200px'); + expect(grid.columnList.get(5).width).toEqual('200px'); + expect(grid.columnList.get(10).width).toEqual('200px'); + expect(grid.columnList.get(25).width).toEqual('200px'); + + const actualGridWidth = grid.nativeElement.clientWidth; + + const expectedDefWidth = Math.max(Math.floor((actualGridWidth - 5 * 200) / 25), parseInt(MIN_COL_WIDTH, 10)); + expect(parseInt(grid.columnWidth, 10)).toEqual(expectedDefWidth); + expect(parseInt(grid.columnList.get(1).width, 10)).toEqual(expectedDefWidth); + expect(parseInt(grid.columnList.get(2).width, 10)).toEqual(expectedDefWidth); + expect(parseInt(grid.columnList.get(4).width, 10)).toEqual(expectedDefWidth); + + grid.columnList.forEach((column) => { + const width = parseInt(column.width, 10); + const minWidth = parseInt(grid.columnWidth, 10); + if (column.index !== 0 && column.index !== 3 && column.index !== 5 && + column.index !== 10 && column.index !== 25) { + expect(width).toEqual(minWidth); + } + }); + + expect(fix.componentInstance.isHorizontalScrollbarVisible()).toBe(true); + expect(grid.rowList.length).toBeGreaterThan(0); + }); + + it(`should account for columns with set width when determining default column width when grid has px width + and there are enough rows to cover the grid's height and enough columns to cover the grid's width`, () => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + const grid = fix.componentInstance.grid; + grid.width = '800px'; + fix.componentInstance.initColumnsRows(1000, 30); + fix.componentInstance.changeInitColumns = true; + fix.detectChanges(); + // fakeAsync is not needed. Need a second change detection cycle for height changes to be applied. + fix.detectChanges(); + + expect(grid.width).toEqual('800px'); + expect(grid.columnList.get(0).width).toEqual('200px'); + expect(grid.columnList.get(3).width).toEqual('200px'); + expect(grid.columnList.get(5).width).toEqual('200px'); + expect(grid.columnList.get(10).width).toEqual('200px'); + expect(grid.columnList.get(25).width).toEqual('200px'); + + const actualGridWidth = grid.nativeElement.clientWidth; + const expectedDefWidth = Math.max(Math.floor((actualGridWidth - 5 * 200) / 25), parseInt(MIN_COL_WIDTH, 10)); + expect(parseInt(grid.columnWidth, 10)).toEqual(expectedDefWidth); + + grid.columnList.forEach((column) => { + const width = parseInt(column.width, 10); + const minWidth = parseInt(grid.columnWidth, 10); + if (column.index !== 0 && column.index !== 3 && column.index !== 5 && + column.index !== 10 && column.index !== 25) { + expect(width).toEqual(minWidth); + } + }); + expect(fix.componentInstance.isHorizontalScrollbarVisible()).toBe(true); + expect(grid.rowList.length).toBeGreaterThan(0); + }); + + it(`should account for columns with set width when determining default column width when grid has 100% width + and there are 10000 rows and 150 columns`, () => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + const grid = fix.componentInstance.grid; + fix.componentInstance.initColumnsRows(10000, 150); + fix.componentInstance.changeInitColumns = true; + fix.detectChanges(); + // fakeAsync is not needed. Need a second change detection cycle for height changes to be applied. + fix.detectChanges(); + + expect(grid.width).toEqual('100%'); + expect(grid.columnList.get(0).width).toEqual('500px'); + expect(grid.columnList.get(3).width).toEqual('500px'); + expect(grid.columnList.get(5).width).toEqual('500px'); + expect(grid.columnList.get(10).width).toEqual('500px'); + expect(grid.columnList.get(50).width).toEqual('500px'); + + grid.columnList.forEach((column) => { + const width = parseInt(column.width, 10); + const minWidth = parseInt(grid.columnWidth, 10); + if (column.index !== 0 && column.index !== 3 && column.index !== 5 && + column.index !== 10 && column.index !== 50) { + expect(width).toEqual(minWidth); + } + }); + + expect(fix.componentInstance.isHorizontalScrollbarVisible()).toBe(true); + expect(grid.rowList.length).toBeGreaterThan(0); + }); + + it(`should account for columns with set width when determining default column width when grid has px width + and there are 10000 rows and 150 columns`, () => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + const grid = fix.componentInstance.grid; + grid.width = '800px'; + fix.componentInstance.initColumnsRows(10000, 150); + fix.componentInstance.changeInitColumns = true; + fix.detectChanges(); + // fakeAsync is not needed. Need a second change detection cycle for height changes to be applied. + fix.detectChanges(); + + expect(grid.width).toEqual('800px'); + expect(grid.columnList.get(0).width).toEqual('500px'); + expect(grid.columnList.get(3).width).toEqual('500px'); + expect(grid.columnList.get(5).width).toEqual('500px'); + expect(grid.columnList.get(10).width).toEqual('500px'); + expect(grid.columnList.get(50).width).toEqual('500px'); + + grid.columnList.forEach((column) => { + const width = parseInt(column.width, 10); + const minWidth = parseInt(grid.columnWidth, 10); + if (column.index !== 0 && column.index !== 3 && column.index !== 5 && + column.index !== 10 && column.index !== 50) { + expect(width).toEqual(minWidth); + } + }); + + expect(fix.componentInstance.isHorizontalScrollbarVisible()).toBe(true); + expect(grid.rowList.length).toBeGreaterThan(0); + }); + + it('should render all records if height is explicitly set to null.', () => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + const grid = fix.componentInstance.grid; + fix.componentInstance.initColumnsRows(20, 5); + grid.height = null; + fix.detectChanges(); + // fakeAsync is not needed. Need a second change detection cycle for height changes to be applied. + fix.detectChanges(); + + const recsCount = grid.data.length; + + // tbody should have height equal to all items * item height + expect(grid.tbody.nativeElement.clientHeight).toEqual(recsCount * 51); + expect(grid.rowList.length).toBeGreaterThan(0); + }); + + it('should match width and height of parent container when width/height are set in %', () => { + const fix = TestBed.createComponent(IgxGridWrappedInContComponent); + const grid = fix.componentInstance.grid; + fix.componentInstance.data = fix.componentInstance.fullData; + fix.componentInstance.outerWidth = 800; + fix.componentInstance.outerHeight = 600; + fix.componentInstance.grid.width = '50%'; + fix.componentInstance.grid.height = '50%'; + fix.detectChanges(); + // fakeAsync is not needed. Need a second change detection cycle for height changes to be applied. + fix.detectChanges(); + + expect(window.getComputedStyle(grid.nativeElement).height).toMatch('300px'); + expect(window.getComputedStyle(grid.nativeElement).width).toMatch('400px'); + expect(grid.rowList.length).toBeGreaterThan(0); + }); + + it(`should render 10 records if height is unset and parent container's height is unset`, () => { + const fix = TestBed.createComponent(IgxGridWrappedInContComponent); + fix.componentInstance.data = fix.componentInstance.fullData; + fix.detectChanges(); + // fakeAsync is not needed. Need a second change detection cycle for height changes to be applied. + fix.detectChanges(); + + const defaultHeight = fix.debugElement.query(By.css(TBODY_CLASS)).styles.height; + expect(defaultHeight).not.toBeFalsy(); + expect(parseInt(defaultHeight, 10)).toBeGreaterThan(400); + expect(fix.componentInstance.isVerticalScrollbarVisible()).toBeTruthy(); + expect(fix.componentInstance.grid.rowList.length).toBeGreaterThanOrEqual(10); + }); + + it(`should render pixel height when one is set and parent container's height is unset`, () => { + const fix = TestBed.createComponent(IgxGridWrappedInContComponent); + fix.componentInstance.data = fix.componentInstance.fullData; + fix.componentInstance.grid.height = '700px'; + fix.detectChanges(); + // fakeAsync is not needed. Need a second change detection cycle for height changes to be applied. + fix.detectChanges(); + + const defaultHeight = fix.debugElement.query(By.css(TBODY_CLASS)).styles.height; + expect(defaultHeight).not.toBeFalsy(); + expect(parseInt(defaultHeight, 10)).toBeGreaterThan(400); + expect(fix.componentInstance.isVerticalScrollbarVisible()).toBeTruthy(); + expect(fix.componentInstance.grid.rowList.length).toBeGreaterThanOrEqual(10); + }); + + it(`should render all records exactly if height is 100% and parent container's height is unset and + there are fewer than 10 records in the data view`, () => { + const fix = TestBed.createComponent(IgxGridWrappedInContComponent); + fix.componentInstance.grid.height = '100%'; + fix.componentInstance.data = fix.componentInstance.semiData; + fix.detectChanges(); + // fakeAsync is not needed. Need a second change detection cycle for height changes to be applied. + fix.detectChanges(); + const defaultHeight = fix.debugElement.query(By.css(TBODY_CLASS)).styles.height; + expect(defaultHeight).toBeFalsy(); + expect(fix.debugElement.query(By.css(TBODY_CLASS)) + .nativeElement.getBoundingClientRect().height).toBeGreaterThan(200); + expect(fix.componentInstance.isVerticalScrollbarVisible()).toBeFalsy(); + expect(fix.componentInstance.grid.rowList.length).toEqual(5); + }); + + it(`should render 10 records if height is 100% and parent container's height is unset and + grid size is changed`, async () => { + const fix = TestBed.createComponent(IgxGridWrappedInContComponent); + fix.detectChanges(); + + fix.componentInstance.grid.height = '100%'; + fix.componentInstance.data = fix.componentInstance.fullData.slice(0, 10); + fix.detectChanges(); + await wait(32); // needed because of the throttleTime on the resize observer + fix.detectChanges(); + expect(fix.componentInstance.grid.rowList.length).toEqual(10); + + setElementSize(fix.componentInstance.grid.nativeElement, ɵSize.Small) + fix.detectChanges(); + await wait(32); // needed because of the throttleTime on the resize observer + fix.detectChanges(); + + const defaultHeight = fix.debugElement.query(By.css(TBODY_CLASS)).styles.height; + const defaultHeightNum = parseInt(defaultHeight, 10); + expect(defaultHeight).not.toBeFalsy(); + expect(defaultHeightNum).toBe(330); + expect(fix.componentInstance.isVerticalScrollbarVisible()).toBeFalsy(); + expect(fix.componentInstance.grid.rowList.length).toEqual(10); + }); + + it(`should render grid with correct height when parent container's height is set + and the total row height is smaller than parent height #1861`, fakeAsync(() => { + const fix = TestBed.createComponent(IgxGridFixedContainerHeightComponent); + fix.componentInstance.grid.height = '100%'; + fix.componentInstance.paging = true; + fix.componentInstance.data = fix.componentInstance.data.slice(0, 5); + + tick(); + fix.detectChanges(); + const domGrid = fix.debugElement.query(By.css('igx-grid')).nativeElement; + expect(parseInt(window.getComputedStyle(domGrid).height, 10)).toBe(300); + })); + + it(`should render grid with correct height when height is in percent and the + sum height of all rows is lower than parent height #1858`, fakeAsync(() => { + const fix = TestBed.createComponent(IgxGridFixedContainerHeightComponent); + fix.componentInstance.grid.height = '100%'; + fix.componentInstance.data = fix.componentInstance.data.slice(0, 3); + + tick(); + fix.detectChanges(); + const domGrid = fix.debugElement.query(By.css('igx-grid')).nativeElement; + expect(parseInt(window.getComputedStyle(domGrid).height, 10)).toBe(300); + })); + + it('should keep auto-sizing if initial data is empty then set to a new array', fakeAsync(() => { + const fix = TestBed.createComponent(IgxGridWrappedInContComponent); + tick(); + fix.detectChanges(); + let defaultHeight = fix.debugElement.query(By.css(TBODY_CLASS)).styles.height; + expect(defaultHeight).toBeFalsy(); // initially body height is null in auto-sizing scenarios with empty data + expect(fix.componentInstance.grid.calcHeight).toBeNull(); + fix.componentInstance.data = fix.componentInstance.fullData; + tick(); + fix.detectChanges(); + defaultHeight = fix.debugElement.query(By.css(TBODY_CLASS)).styles.height; + const defaultHeightNum = parseInt(defaultHeight, 10); + expect(defaultHeight).not.toBeFalsy(); + expect(defaultHeightNum).toBe(510); + expect(fix.componentInstance.grid.calcHeight).toBe(510); + })); + + it('should keep auto-sizing if initial data is set to empty array that is then filled', fakeAsync(() => { + const fix = TestBed.createComponent(IgxGridWrappedInContComponent); + fix.detectChanges(); + + let defaultHeight = fix.debugElement.query(By.css(TBODY_CLASS)).styles.height; + expect(defaultHeight).toBeFalsy(); // initially body height is null in auto-sizing scenarios with empty data + expect(fix.componentInstance.grid.calcHeight).toBeNull(); + + fix.componentInstance.data = fix.componentInstance.fullData; + fix.detectChanges(); + + defaultHeight = fix.debugElement.query(By.css(TBODY_CLASS)).styles.height; + const defaultHeightNum = parseInt(defaultHeight, 10); + expect(defaultHeight).not.toBeFalsy(); + expect(defaultHeightNum).toBe(510); + expect(fix.componentInstance.grid.calcHeight).toBe(510); + })); + + it(`should not render with calcHeight null at any point when loading data and + auto-sizing is required and initial data is empty`, () => { + const fix = TestBed.createComponent(IgxGridWrappedInContComponent); + fix.detectChanges(); + + let defaultHeight = fix.debugElement.query(By.css(TBODY_CLASS)).styles.height; + expect(defaultHeight).toBeFalsy(); // initially body height is null in auto-sizing scenarios with empty data + expect(fix.componentInstance.grid.calcHeight).toBeNull(); + + fix.componentInstance.data = Array.from({ length: 100000 }, (_, i) => ({ ID: i, CompanyName: 'CN' + i })); + fix.detectChanges(); + + defaultHeight = fix.debugElement.query(By.css(TBODY_CLASS)).styles.height; + const defaultHeightNum = parseInt(defaultHeight, 10); + expect(defaultHeight).not.toBeFalsy(); + expect(defaultHeightNum).toBe(510); + expect(fix.componentInstance.grid.calcHeight).toBe(510); + }); + + it('should keep auto-sizing if initial data is set to small array that is then filled', () => { + const fix = TestBed.createComponent(IgxGridWrappedInContComponent); + fix.componentInstance.data = fix.componentInstance.semiData; + fix.detectChanges(); + + let defaultHeight = fix.debugElement.query(By.css(TBODY_CLASS)).styles.height; + expect(defaultHeight).toBeFalsy(); + expect(fix.componentInstance.grid.calcHeight).toBeNull(); + fix.componentInstance.data = fix.componentInstance.fullData; + fix.componentInstance.cdr.detectChanges(); + fix.detectChanges(); + + defaultHeight = fix.debugElement.query(By.css(TBODY_CLASS)).styles.height; + const defaultHeightNum = parseInt(defaultHeight, 10); + expect(defaultHeight).not.toBeFalsy(); + expect(defaultHeightNum).toBe(510); + expect(fix.componentInstance.grid.calcHeight).toBe(510); + }); + + it(`should render with calcHeight null if initial data is small but then + auto-size when it is filled`, async () => { + const fix = TestBed.createComponent(IgxGridWrappedInContComponent); + fix.componentInstance.data = fix.componentInstance.semiData; + fix.detectChanges(); + let defaultHeight = fix.debugElement.query(By.css(TBODY_CLASS)).styles.height; + expect(defaultHeight).toBeFalsy(); + expect(fix.componentInstance.grid.calcHeight).toBeNull(); + fix.componentInstance.data = Array.from({ length: 100000 }, (_, i) => ({ ID: i, CompanyName: 'CN' + i })); + fix.detectChanges(); + await wait(500); + defaultHeight = fix.debugElement.query(By.css(TBODY_CLASS)).styles.height; + const defaultHeightNum = parseInt(defaultHeight, 10); + expect(defaultHeight).not.toBeFalsy(); + expect(defaultHeightNum).toBe(510); + expect(fix.componentInstance.grid.calcHeight).toBe(510); + }); + + it('should keep default height when filtering', fakeAsync(() => { + const fix = TestBed.createComponent(IgxGridWrappedInContComponent); + tick(); + fix.detectChanges(); + let defaultHeight = fix.debugElement.query(By.css(TBODY_CLASS)).styles.height; + expect(defaultHeight).toBeFalsy(); // initially body height is null in auto-sizing scenarios with empty data + expect(fix.componentInstance.grid.calcHeight).toBeNull(); + fix.componentInstance.data = fix.componentInstance.fullData; + tick(); + fix.detectChanges(); + defaultHeight = fix.debugElement.query(By.css(TBODY_CLASS)).styles.height; + let defaultHeightNum = parseInt(defaultHeight, 10); + expect(defaultHeight).not.toBeFalsy(); + expect(defaultHeightNum).toBe(510); + expect(fix.componentInstance.grid.calcHeight).toBe(510); + fix.componentInstance.grid.filter('ID', 'ALFKI', IgxStringFilteringOperand.instance().condition('equals')); + tick(); + fix.detectChanges(); + defaultHeight = fix.debugElement.query(By.css(TBODY_CLASS)).styles.height; + defaultHeightNum = parseInt(defaultHeight, 10); + expect(defaultHeight).not.toBeFalsy(); + expect(defaultHeightNum).toBe(510); + expect(fix.componentInstance.grid.calcHeight).toBe(510); + })); + + it('should not keep default height when lower the amount of bound data', async () => { + const fix = TestBed.createComponent(IgxGridWrappedInContComponent); + fix.detectChanges(); + + let defaultHeight = fix.debugElement.query(By.css(TBODY_CLASS)).styles.height; + expect(defaultHeight).toBeFalsy(); // initially body height is null in auto-sizing scenarios with empty data + expect(fix.componentInstance.grid.calcHeight).toBeNull(); + + fix.componentInstance.data = fix.componentInstance.fullData; + fix.detectChanges(); + + defaultHeight = fix.debugElement.query(By.css(TBODY_CLASS)).styles.height; + const defaultHeightNum = parseInt(defaultHeight, 10); + expect(defaultHeight).not.toBeFalsy(); + expect(defaultHeightNum).toBe(510); + expect(fix.componentInstance.grid.calcHeight).toBe(510); + + fix.componentInstance.grid.data = fix.componentInstance.semiData; + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + + defaultHeight = fix.debugElement.query(By.css(TBODY_CLASS)).styles.height; + expect(defaultHeight).toBeFalsy(); + expect(fix.componentInstance.grid.calcHeight).toBeNull(); + }); + + it('should not keep auto-sizing when changing height', fakeAsync(() => { + const fix = TestBed.createComponent(IgxGridWrappedInContComponent); + fix.detectChanges(); + + let defaultHeight = fix.debugElement.query(By.css(TBODY_CLASS)).styles.height; + expect(defaultHeight).toBeFalsy(); // initially body height is null in auto-sizing scenarios with empty data + expect(fix.componentInstance.grid.calcHeight).toBeNull(); + + fix.componentInstance.data = fix.componentInstance.fullData; + fix.detectChanges(); + + defaultHeight = fix.debugElement.query(By.css(TBODY_CLASS)).styles.height; + let defaultHeightNum = parseInt(defaultHeight, 10); + expect(defaultHeight).not.toBeFalsy(); + expect(defaultHeightNum).toBe(510); + expect(fix.componentInstance.grid.calcHeight).toBe(510); + + fix.componentInstance.grid.height = '400px'; + fix.detectChanges(); + + defaultHeight = fix.debugElement.query(By.css(TBODY_CLASS)).styles.height; + defaultHeightNum = parseInt(defaultHeight, 10); + expect(defaultHeight).not.toBeFalsy(); + expect(defaultHeightNum).toBeLessThan(400); + expect(defaultHeightNum).toBeGreaterThan(300); + expect(fix.componentInstance.grid.calcHeight).toBeLessThan(400); + expect(fix.componentInstance.grid.calcHeight).toBeGreaterThan(300); + })); + + it('should not auto-size when changing height is determinable', fakeAsync(() => { + const fix = TestBed.createComponent(IgxGridWrappedInContComponent); + fix.componentInstance.outerHeight = 800; + tick(); + fix.detectChanges(); + let defaultHeight = fix.debugElement.query(By.css(TBODY_CLASS)).styles.height; + let defaultHeightNum = parseInt(defaultHeight, 10); + expect(defaultHeight).not.toBeFalsy(); + expect(defaultHeightNum).toBeLessThan(800); + expect(defaultHeightNum).toBeGreaterThan(700); + expect(fix.componentInstance.grid.calcHeight).toBeLessThan(800); + expect(fix.componentInstance.grid.calcHeight).toBeGreaterThan(700); + fix.componentInstance.data = fix.componentInstance.fullData; + tick(); + fix.detectChanges(); + defaultHeight = fix.debugElement.query(By.css(TBODY_CLASS)).styles.height; + defaultHeightNum = parseInt(defaultHeight, 10); + expect(defaultHeight).not.toBeFalsy(); + expect(defaultHeightNum).toBeLessThan(800); + expect(defaultHeightNum).toBeGreaterThan(700); + expect(fix.componentInstance.grid.calcHeight).toBeLessThan(800); + expect(fix.componentInstance.grid.calcHeight).toBeGreaterThan(700); + fix.componentInstance.data = fix.componentInstance.fullData; + })); + + it('should not auto-size when container has display:contents and size is determinable ', fakeAsync(() => { + const fix = TestBed.createComponent(IgxGridWrappedInContComponent); + fix.componentInstance.display = "contents"; + fix.componentInstance.data = fix.componentInstance.fullData; + tick(); + fix.detectChanges(); + const defaultHeight = fix.debugElement.query(By.css(TBODY_CLASS)).styles.height; + const defaultHeightNum = parseInt(defaultHeight, 10); + expect(defaultHeight).not.toBeFalsy(); + + expect(defaultHeightNum).toBeGreaterThan(fix.componentInstance.grid.renderedRowHeight * 10); + expect(fix.componentInstance.grid.calcHeight).toBeGreaterThan(fix.componentInstance.grid.renderedRowHeight * 10); + })); + + it('should render correct columns if after scrolling right container size changes so that all columns become visible.', async () => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + grid.width = '500px'; + fix.componentInstance.initColumnsRows(5, 5); + fix.detectChanges(); + await wait(16); + fix.detectChanges(); + expect(fix.componentInstance.isHorizontalScrollbarVisible()).toBe(true); + const scrollbar = grid.headerContainer.getScroll(); + scrollbar.scrollLeft = 10000; + grid.width = '1500px'; + + fix.detectChanges(); + await wait(100); + expect(fix.componentInstance.isHorizontalScrollbarVisible()).toBe(false); + const headers = fix.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + expect(headers.length).toEqual(5); + for (let i = 0; i < headers.length; i++) { + expect(headers[i].context.column.field).toEqual(grid.columnList.get(i).field); + } + }); + + it('Should render date and number values based on default formatting', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxGridFormattingComponent); + fixture.detectChanges(); + tick(16); + const grid = fixture.componentInstance.grid; + const rows = grid.rowList.toArray(); + // verify default number formatting + let expectedValue = '2,760'; + expect((rows[0].cells.toArray()[3] as any).element.nativeElement.textContent).toBe(expectedValue); + expectedValue = '1,098'; + expect((rows[5].cells.toArray()[3] as any).element.nativeElement.textContent).toBe(expectedValue); + expectedValue = '7,898'; + expect((rows[7].cells.toArray()[3] as any).element.nativeElement.textContent).toBe(expectedValue); + // verify formatter function formatting + expectedValue = '2.76e+3'; + expect((rows[0].cells.toArray()[5] as any).element.nativeElement.textContent).toBe(expectedValue); + expectedValue = '1.098e+3'; + expect((rows[5].cells.toArray()[5] as any).element.nativeElement.textContent).toBe(expectedValue); + expectedValue = '7.898e+3'; + expect((rows[7].cells.toArray()[5] as any).element.nativeElement.textContent).toBe(expectedValue); + // verify date formatting + expectedValue = 'Mar 21, 2005'; + expect((rows[0].cells.toArray()[4] as any).element.nativeElement.textContent).toBe(expectedValue); + expectedValue = 'Jan 15, 2008'; + expect((rows[1].cells.toArray()[4] as any).element.nativeElement.textContent).toBe(expectedValue); + expectedValue = 'Nov 20, 2010'; + expect((rows[2].cells.toArray()[4] as any).element.nativeElement.textContent).toBe(expectedValue); + // verify summaries formatting + let avgValue; + let earliestValue; + const summaries = fixture.debugElement.queryAll(By.css('.igx-grid-summary')); + summaries.forEach((summary) => { + const avgLabel = summary.query(By.css('[title=\'Avg\']')); + const earliest = summary.query(By.css('[title=\'Earliest\']')); + if (avgLabel) { + avgValue = avgLabel.nativeElement.nextSibling.innerText; + expect(avgValue).toBe('3,900.4'); + } + if (earliest) { + earliestValue = earliest.nativeElement.nextSibling.innerText; + expect(earliestValue).toBe('May 17, 1990'); + } + }); + })); + + it('Should properly handle dates in ISO 8601 format', () => { + const fixture = TestBed.createComponent(IgxGridFormattingComponent); + const grid = fixture.componentInstance.grid; + grid.data = fixture.componentInstance.data.map(rec => { + const newRec = rec as any as { + ProductID: number; + ProductName: string; + InStock: boolean; + UnitsInStock: number; + OrderDate: string; + }; + newRec.OrderDate = rec.OrderDate.toISOString(); + return newRec; + }); + fixture.detectChanges(); + + // verify cells formatting + const rows = grid.rowList.toArray(); + let expectedValue = 'Mar 21, 2005'; + expect((rows[0].cells.toArray()[4] as any).element.nativeElement.textContent).toBe(expectedValue); + expectedValue = 'Jan 15, 2008'; + expect((rows[1].cells.toArray()[4] as any).element.nativeElement.textContent).toBe(expectedValue); + expectedValue = 'Nov 20, 2010'; + expect((rows[2].cells.toArray()[4] as any).element.nativeElement.textContent).toBe(expectedValue); + + // verify summaries formatting + let avgValue; + let earliestValue; + const summaries = fixture.debugElement.queryAll(By.css('.igx-grid-summary')); + summaries.forEach((summary) => { + const avgLabel = summary.query(By.css('[title=\'Avg\']')); + const earliest = summary.query(By.css('[title=\'Earliest\']')); + if (avgLabel) { + avgValue = avgLabel.nativeElement.nextSibling.innerText; + expect(avgValue).toBe('3,900.4'); + } + if (earliest) { + earliestValue = earliest.nativeElement.nextSibling.innerText; + expect(earliestValue).toBe('May 17, 1990'); + } + }); + }); + + it('Should properly handle dates represented as number of milliseconds', () => { + const fixture = TestBed.createComponent(IgxGridFormattingComponent); + const grid = fixture.componentInstance.grid; + grid.data = fixture.componentInstance.data.map(rec => { + const newRec = rec as any as { + ProductID: number; + ProductName: string; + InStock: boolean; + UnitsInStock: number; + OrderDate: number; + }; + newRec.OrderDate = rec.OrderDate.getTime(); + return newRec; + }); + fixture.detectChanges(); + + // verify cells formatting + const rows = grid.rowList.toArray(); + let expectedValue = 'Mar 21, 2005'; + expect((rows[0].cells.toArray()[4] as any).element.nativeElement.textContent).toBe(expectedValue); + expectedValue = 'Jan 15, 2008'; + expect((rows[1].cells.toArray()[4] as any).element.nativeElement.textContent).toBe(expectedValue); + expectedValue = 'Nov 20, 2010'; + expect((rows[2].cells.toArray()[4] as any).element.nativeElement.textContent).toBe(expectedValue); + + // verify summaries formatting + let avgValue; + let earliestValue; + const summaries = fixture.debugElement.queryAll(By.css('.igx-grid-summary')); + summaries.forEach((summary) => { + const avgLabel = summary.query(By.css('[title=\'Avg\']')); + const earliest = summary.query(By.css('[title=\'Earliest\']')); + if (avgLabel) { + avgValue = avgLabel.nativeElement.nextSibling.innerText; + expect(avgValue).toBe('3,900.4'); + } + if (earliest) { + earliestValue = earliest.nativeElement.nextSibling.innerText; + expect(earliestValue).toBe('May 17, 1990'); + } + }); + }); + + it('Should change dates/number display based on locale #ivy', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxGridFormattingComponent); + const grid = fixture.componentInstance.grid; + grid.data = fixture.componentInstance.data.map(rec => { + const newRec = rec as any as { + ProductID: number; + ProductName: string; + InStock: boolean; + UnitsInStock: number; + OrderDate: number; + }; + newRec.OrderDate = rec.OrderDate.getTime(); + return newRec; + }); + fixture.detectChanges(); + + let rows = grid.rowList.toArray(); + let expectedValue = 'Mar 21, 2005'; + expect((rows[0].cells.toArray()[4] as any).element.nativeElement.textContent).toBe(expectedValue); + expectedValue = 'Jan 15, 2008'; + expect((rows[1].cells.toArray()[4] as any).element.nativeElement.textContent).toBe(expectedValue); + expectedValue = 'Nov 20, 2010'; + expect((rows[2].cells.toArray()[4] as any).element.nativeElement.textContent).toBe(expectedValue); + // verify summaries formatting + let avgValue; + let earliestValue; + let summaries = fixture.debugElement.queryAll(By.css('.igx-grid-summary')); + summaries.forEach((summary) => { + const avgLabel = summary.query(By.css('[title=\'Avg\']')); + const earliest = summary.query(By.css('[title=\'Earliest\']')); + if (avgLabel) { + avgValue = avgLabel.nativeElement.nextSibling.innerText; + expect(avgValue).toBe('3,900.4'); + } + if (earliest) { + earliestValue = earliest.nativeElement.nextSibling.innerText; + expect(earliestValue).toBe('May 17, 1990'); + } + }); + + grid.locale = 'de-DE'; + grid.columnList.get(5).pipeArgs = { + timezone: 'UTC', + format: 'longDate', + digitsInfo: '1.2-2' + }; + grid.columnList.get(4).pipeArgs = { + timezone: 'UTC', + format: 'longDate', + digitsInfo: '1.2-2' + }; + grid.columnList.get(3).pipeArgs = { + timezone: 'UTC', + format: 'longDate', + digitsInfo: '1.2-2' + }; + tick(300); + fixture.detectChanges(); + + rows = grid.rowList.toArray(); + expectedValue = `${ymd('2005-03-21').getUTCDate()}. März 2005`; + expect((rows[0].cells.toArray()[4] as any).element.nativeElement.textContent).toBe(expectedValue); + expectedValue = `${ymd('2005-01-15').getUTCDate()}. Januar 2008`; + expect((rows[1].cells.toArray()[4] as any).element.nativeElement.textContent).toBe(expectedValue); + expectedValue =`${ymd('2005-11-20').getUTCDate()}. November 2010`; + expect((rows[2].cells.toArray()[4] as any).element.nativeElement.textContent).toBe(expectedValue); + + // verify summaries formatting + summaries = fixture.debugElement.queryAll(By.css('.igx-grid-summary')); + summaries.forEach((summary) => { + const avgLabel = summary.query(By.css('[title=\'Avg\']')); + const earliest = summary.query(By.css('[title=\'Earliest\']')); + if (avgLabel) { + avgValue = avgLabel.nativeElement.nextSibling.innerText; + expect(avgValue).toBe('3.900,40'); + } + if (earliest) { + earliestValue = earliest.nativeElement.nextSibling.innerText; + expect(earliestValue).toBe(`${ymd('1990-05-17').getUTCDate()}. Mai 1990`); + } + }); + })); + + it('Should calculate default column width when a column has width in %', async () => { + const fix = TestBed.createComponent(IgxGridColumnPercentageWidthComponent); + fix.componentInstance.initColumnsRows(5, 3); + await wait(16); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + expect(grid.columnList.get(1).width).toEqual('150px'); + expect(grid.columnList.get(2).width).toEqual('150px'); + + const hScroll = fix.debugElement.query(By.css(GRID_SCROLL_CLASS)); + expect(hScroll.nativeElement.hidden).toBe(true); + + grid.columnList.get(0).width = '70%'; + fix.detectChanges(); + await wait(16); + // check UI + const header0 = fix.debugElement.queryAll(By.css('igx-grid-header-group'))[0]; + const header1 = fix.debugElement.queryAll(By.css('igx-grid-header-group'))[1]; + const header2 = fix.debugElement.queryAll(By.css('igx-grid-header-group'))[2]; + + expect(header0.nativeElement.offsetWidth).toEqual(350); + expect(header1.nativeElement.offsetWidth).toEqual(136); + expect(header2.nativeElement.offsetWidth).toEqual(136); + expect(hScroll.nativeElement.hidden).toBe(false); + + // check virtualization cache is valid + const virtDir = grid.gridAPI.get_row_by_index(0).virtDirRow; + expect(virtDir.getSizeAt(0)).toEqual(350); + expect(virtDir.getSizeAt(1)).toEqual(136); + expect(virtDir.getSizeAt(2)).toEqual(136); + }); + it('Should re-calculate column width when a column has width in % and grid width changes.', async () => { + const fix = TestBed.createComponent(IgxGridColumnPercentageWidthComponent); + fix.componentInstance.initColumnsRows(5, 3); + fix.detectChanges(); + await wait(16); + + const grid = fix.componentInstance.grid; + const hScroll = fix.debugElement.query(By.css(GRID_SCROLL_CLASS)); + expect(hScroll.nativeElement.hidden).toBe(true); + grid.columnList.get(0).width = '70%'; + fix.detectChanges(); + await wait(16); + grid.width = '1000px'; + fix.detectChanges(); + await wait(16); + + // check UI + const header0 = fix.debugElement.queryAll(By.css('igx-grid-header-group'))[0]; + const header1 = fix.debugElement.queryAll(By.css('igx-grid-header-group'))[1]; + const header2 = fix.debugElement.queryAll(By.css('igx-grid-header-group'))[2]; + expect(header0.nativeElement.offsetWidth).toEqual(700); + expect(header1.nativeElement.offsetWidth).toEqual(150); + expect(header2.nativeElement.offsetWidth).toEqual(150); + + expect(hScroll.nativeElement.hidden).toBe(true); + // check virtualization cache is valid + const virtDir = grid.gridAPI.get_row_by_index(0).virtDirRow; + expect(virtDir.getSizeAt(0)).toEqual(700); + expect(virtDir.getSizeAt(1)).toEqual(150); + expect(virtDir.getSizeAt(2)).toEqual(150); + }); + it('Should calculate column width when a column has width in % and row selectors are enabled.', async () => { + const fix = TestBed.createComponent(IgxGridColumnPercentageWidthComponent); + fix.componentInstance.initColumnsRows(5, 3); + await wait(16); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + const hScroll = fix.debugElement.query(By.css(GRID_SCROLL_CLASS)); + grid.rowSelection = GridSelectionMode.multiple; + fix.detectChanges(); + grid.columnList.get(0).width = '70%'; + + fix.detectChanges(); + await wait(16); + // check UI + const rowSelectorHeader = grid.theadRow.nativeElement.querySelector('.igx-grid__cbx-selection') as HTMLElement; + const header0 = fix.debugElement.queryAll(By.css('igx-grid-header-group'))[0]; + const header1 = fix.debugElement.queryAll(By.css('igx-grid-header-group'))[1]; + const header2 = fix.debugElement.queryAll(By.css('igx-grid-header-group'))[2]; + const expectedWidth = Math.round(0.7 * (grid.unpinnedWidth + rowSelectorHeader.offsetWidth)); + expect(header0.nativeElement.offsetWidth).toBeGreaterThanOrEqual(expectedWidth - 1); + expect(header0.nativeElement.offsetWidth).toBeLessThanOrEqual(expectedWidth + 1); + expect(header1.nativeElement.offsetWidth).toEqual(136); + expect(header2.nativeElement.offsetWidth).toEqual(136); + expect(hScroll.nativeElement.hidden).toBe(false); + + // check virtualization cache is valid + const virtDir = grid.gridAPI.get_row_by_index(0).virtDirRow; + expect(virtDir.getSizeAt(0)).toEqual(expectedWidth); + expect(virtDir.getSizeAt(1)).toEqual(136); + expect(virtDir.getSizeAt(2)).toEqual(136); + + }); + it('Should render correct column widths when having mixed width setting - px, %, null', async () => { + const fix = TestBed.createComponent(IgxGridColumnPercentageWidthComponent); + fix.componentInstance.initColumnsRows(5, 3); + const grid = fix.componentInstance.grid; + const hScroll = fix.debugElement.query(By.css(GRID_SCROLL_CLASS)); + fix.detectChanges(); + grid.columnList.get(0).width = '50%'; + grid.columnList.get(1).width = '100px'; + fix.detectChanges(); + await wait(16); + const header0 = fix.debugElement.queryAll(By.css('igx-grid-header-group'))[0]; + const header1 = fix.debugElement.queryAll(By.css('igx-grid-header-group'))[1]; + const header2 = fix.debugElement.queryAll(By.css('igx-grid-header-group'))[2]; + + expect(header0.nativeElement.offsetWidth).toEqual(250); + expect(header1.nativeElement.offsetWidth).toEqual(100); + expect(header2.nativeElement.offsetWidth).toEqual(150); + expect(hScroll.nativeElement.hidden).toBe(true); + + // check virtualization cache is valid + const virtDir = grid.gridAPI.get_row_by_index(0).virtDirRow; + expect(virtDir.getSizeAt(0)).toEqual(250); + expect(virtDir.getSizeAt(1)).toEqual(100); + expect(virtDir.getSizeAt(2)).toEqual(150); + + }); + + it('cells and columns widths should be equal. column widths in percentages', () => { + const fix = TestBed.createComponent(IgxGridColumnPercentageWidthComponent); + fix.componentInstance.initColumnsRows(5, 6); + const grid = fix.componentInstance.grid; + fix.componentInstance.grid.height = "250px"; + fix.detectChanges(); + const hScroll = fix.debugElement.query(By.css(GRID_SCROLL_CLASS)); + fix.detectChanges(); + const percentageWidths = ['5%', '12%', '10%', '12%', '5%', '11%']; + for (let i = 0; i < grid.columnList.length; i++) { + grid.columnList.get(i).width = percentageWidths[i]; + } + fix.detectChanges(); + expect(hScroll.nativeElement.hidden).toBe(true); + + for (let i = 0; i < grid.columnList.length; i++) { + const header = fix.debugElement.queryAll(By.css('igx-grid-header'))[i]; + const cell = fix.debugElement.queryAll(By.css('igx-grid-cell'))[i]; + const headerStyle = document.defaultView.getComputedStyle(header.nativeElement); + const paddingsAndBorders = parseFloat(headerStyle.paddingLeft) + parseFloat(headerStyle.paddingRight) + + parseFloat(headerStyle.borderRightWidth); + expect(header.nativeElement.offsetWidth).toEqual(Math.max(cell.nativeElement.offsetWidth, paddingsAndBorders)); + expect(Number.isInteger(header.nativeElement.offsetWidth)).toBe(true); + } + }); + + it('should render all columns if grid width is set to null.', async () => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + fix.componentInstance.initColumnsRows(5, 30); + const grid = fix.componentInstance.grid; + fix.detectChanges(); + + grid.width = null; + fix.detectChanges(); + await wait(16); + + // grid should render all columns and all should be visible. + const cells = grid.gridAPI.get_row_by_index(0).cells; + expect(cells.length).toBe(30); + expect(parseInt(grid.hostWidth, 10)).toBe(30 * 136); + }); + + it('should render grid and columns with correct width when all are in % and inside a hidden container.', () => { + // in this case since the grid width is 0, the grid will use the sum of the columns + // those should resolve to 136px, as per the docs + const fix = TestBed.createComponent(IgxGridColumnHiddenPercentageWidthComponent); + const grid = fix.componentInstance.grid; + grid.width = '100%'; + // 4 cols - 10% width + fix.componentInstance.initColumnsRows(5, 4); + fix.detectChanges(); + + expect(grid.calcWidth).toBe(136*4); + expect(grid.columns[0].calcWidth).toBe(136); + expect(grid.columns[1].calcWidth).toBe(136); + }); + + it('should retain column with in % after hiding/showing grid with 100% width', () => { + const fix = TestBed.createComponent(IgxGridColumnPercentageWidthComponent); + fix.componentInstance.initColumnsRows(5, 3); + const grid = fix.componentInstance.grid; + fix.detectChanges(); + grid.width = '100%'; + fix.detectChanges(); + grid.columnList.get(0).width = '50%'; + fix.detectChanges(); + + // hide + grid.nativeElement.style.display = 'none'; + // simulate resize observer reflow + grid.reflow(); + + expect(grid.columnList.get(0).width).toBe('50%'); + + grid.nativeElement.style.display = ''; + // simulate resize observer reflow + grid.reflow(); + + expect(grid.columnList.get(0).width).toBe('50%'); + }); + + it('should correctly autosize column headers when the grid container has no data and is initially hidden and then shown', async () => { + const fix = TestBed.createComponent(IgxGridColumnHeaderAutoSizeComponent); + const grid = fix.componentInstance.grid; + + //waiting for requestAnimationFrame to finish + await wait(17); + fix.detectChanges(); + + fix.componentInstance.gridContainerHidden = false; + await wait(17) + fix.detectChanges() + + const calcWidth = parseInt(grid.columnList.first.calcWidth, 10) + + expect(calcWidth).not.toBe(80); + }); + + it('should correctly autosize column headers inside column groups.', async () => { + const fix = TestBed.createComponent(IgxGridColumnHeaderInGroupAutoSizeComponent); + const grid = fix.componentInstance.grid; + grid.data = [{field1: "Test"}]; + + //waiting for requestAnimationFrame to finish + fix.detectChanges(); + await wait(17); + fix.detectChanges(); + + const calcWidth = parseInt(grid.getColumnByName("field1").calcWidth); + expect(calcWidth).toBe(126); + }); + + it('should recreate columns when data changes and autoGenerate is true', fakeAsync(() => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + + grid.width = '500px'; + grid.height = '500px'; + grid.autoGenerate = true; + fix.detectChanges(); + + const initialData = [ + { id: 1, name: 'John' }, + { id: 2, name: 'Jane' } + ]; + grid.data = initialData; + tick(); + fix.detectChanges(); + + expect(grid.columns.length).toBe(2); + expect(grid.columns[0].field).toBe('id'); + expect(grid.columns[1].field).toBe('name'); + + const newData = [ + { id: 1, firstName: 'John', lastName: 'Doe' }, + { id: 2, firstName: 'Jane', lastName: 'Smith' } + ]; + grid.data = newData; + tick(); + fix.detectChanges(); + + expect(grid.columns.length).toBe(3); + expect(grid.columns[0].field).toBe('id'); + expect(grid.columns[1].field).toBe('firstName'); + expect(grid.columns[2].field).toBe('lastName'); + })); + + it('should set correct aria attributes related to total rows/cols count and indexes', async () => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + fix.componentInstance.initColumnsRows(80, 20); + fix.detectChanges(); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + const gridHeader = GridFunctions.getGridHeader(grid); + const headerRowElement = gridHeader.nativeElement.querySelector('[role="row"]'); + + grid.navigateTo(50, 16); + fix.detectChanges(); + await wait(); + fix.detectChanges(); + + expect(headerRowElement.getAttribute('aria-rowindex')).toBe('1'); + expect(grid.nativeElement.getAttribute('aria-rowcount')).toBe('81'); + expect(grid.nativeElement.getAttribute('aria-colcount')).toBe('20'); + + const cell = grid.gridAPI.get_cell_by_index(50, 'col16'); + // The following attributes indicate to assistive technologies which portions + // of the content are displayed in case not all are rendered, + // such as with the built-in virtualization of the grid. 1-based index. + expect(cell.nativeElement.getAttribute('aria-rowindex')).toBe('52'); + expect(cell.nativeElement.getAttribute('aria-colindex')).toBe('17'); + }); + }); + + describe('IgxGrid - min/max width constraints rules', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxGridDefaultRenderingComponent + ] + }).compileComponents(); + })); + + describe('min/max in px', () => { + + it('in column with no width should not go outside bounds.', async() => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + // 4 cols + fix.componentInstance.initColumnsRows(5, 4); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + grid.width = "1500px"; + fix.detectChanges(); + const col1 = grid.columns[0]; + const col2 = grid.columns[1]; + const col3 = grid.columns[2]; + const col4 = grid.columns[3]; + + // without constraint, they split width equally + expect(col1.calcPixelWidth).toBe(grid.calcWidth / 4); + expect(col2.calcPixelWidth).toBe(grid.calcWidth / 4); + expect(col3.calcPixelWidth).toBe(grid.calcWidth / 4); + expect(col4.calcPixelWidth).toBe(grid.calcWidth / 4); + + // set smaller max in px + col1.maxWidth = '100px'; + fix.detectChanges(); + + // first column takes new max + expect(col1.calcPixelWidth).toBe(100); + // the rest split the remaining width + expect(col2.calcPixelWidth).toBe((grid.calcWidth - col1.calcPixelWidth) / 3); + expect(col3.calcPixelWidth).toBe((grid.calcWidth - col1.calcPixelWidth) / 3); + expect(col4.calcPixelWidth).toBe((grid.calcWidth - col1.calcPixelWidth) / 3); + + + // set larger min in px + col1.maxWidth = null; + fix.detectChanges(); + + col1.minWidth = '600px'; + fix.detectChanges(); + await wait(16); + fix.detectChanges(); + + // first column takes new min + expect(col1.calcPixelWidth).toBe(600); + // the rest split the remaining width + expect(col2.calcPixelWidth).toBe((grid.calcWidth - col1.calcPixelWidth) / 3); + expect(col3.calcPixelWidth).toBe((grid.calcWidth - col1.calcPixelWidth) / 3); + expect(col4.calcPixelWidth).toBe((grid.calcWidth - col1.calcPixelWidth) / 3); + }); + + it('in column with pixel width should not go outside bounds.', async() => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + // 4 cols + fix.componentInstance.initColumnsRows(5, 4); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + grid.width = "1500px"; + fix.detectChanges(); + + const col1 = grid.columns[0]; + col1.width = "150px"; + fix.detectChanges(); + + expect(col1.calcPixelWidth).toBe(150); + + // set smaller max in px + col1.maxWidth = '100px'; + fix.detectChanges(); + + // first column takes new max + expect(col1.calcPixelWidth).toBe(100); + + // set larger min in px + col1.maxWidth = null; + fix.detectChanges(); + col1.minWidth = '500px'; + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + + // first column takes new min + expect(col1.calcPixelWidth).toBe(500); + }); + + it('in column with auto width should not go outside bounds.', async() => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + // 4 cols + fix.componentInstance.initColumnsRows(5, 4); + fix.componentInstance.columns[0].header = "Some longer text to auto-size"; + fix.componentInstance.columns[0].width = 'auto'; + fix.detectChanges(); + // wait for auto-sizing + await wait(100); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + grid.width = "1500px"; + fix.detectChanges(); + const col1 = grid.columns[0]; + + // some autosize should be calculated + expect(col1.autoSize).not.toBeUndefined(); + + // set smaller max in px + col1.maxWidth = '100px'; + fix.detectChanges(); + + // first column takes new max + expect(col1.calcPixelWidth).toBe(100); + + // set larger min in px + col1.maxWidth = null; + fix.detectChanges(); + col1.minWidth = '500px'; + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + + // first column takes new min + expect(col1.calcPixelWidth).toBe(500); + }); + }); + + + describe('min/max in %', () => { + it('in column with no width should not go outside bounds.', async () => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + // 4 cols + fix.componentInstance.initColumnsRows(5, 4); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + grid.width = "1500px"; + fix.detectChanges(); + const col1 = grid.columns[0]; + const col2 = grid.columns[1]; + const col3 = grid.columns[2]; + const col4 = grid.columns[3]; + + // set smaller max in % + col1.maxWidth = '10%'; + fix.detectChanges(); + + // first column takes new max + expect(col1.calcPixelWidth).toBe(grid.calcWidth * 0.1); + // the rest split the remaining width + expect(col2.calcPixelWidth).toBe((grid.calcWidth - col1.calcPixelWidth) / 3); + expect(col3.calcPixelWidth).toBe((grid.calcWidth - col1.calcPixelWidth) / 3); + expect(col4.calcPixelWidth).toBe((grid.calcWidth - col1.calcPixelWidth) / 3); + + // set larger min in px + col1.maxWidth = null; + fix.detectChanges(); + col1.minWidth = '50%'; + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + + // first column takes new min + expect(col1.calcPixelWidth).toBe(grid.calcWidth * 0.5); + // the rest split the remaining width + expect(col2.calcPixelWidth).toBe((grid.calcWidth - col1.calcPixelWidth) / 3); + expect(col3.calcPixelWidth).toBe((grid.calcWidth - col1.calcPixelWidth) / 3); + expect(col4.calcPixelWidth).toBe((grid.calcWidth - col1.calcPixelWidth) / 3); + }); + + it('in column with pixel width should not go outside bounds.', async() => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + // 4 cols + fix.componentInstance.initColumnsRows(5, 4); + fix.componentInstance.columns[0].width = '400px'; + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + grid.width = "1500px"; + fix.detectChanges(); + const col1 = grid.columns[0]; + const col2 = grid.columns[1]; + const col3 = grid.columns[2]; + const col4 = grid.columns[3]; + + // set smaller max in % + col1.maxWidth = '10%'; + fix.detectChanges(); + + // first column takes new max + expect(col1.calcPixelWidth).toBe(grid.calcWidth * 0.1); + // the rest split the remaining width + expect(col2.calcPixelWidth).toBe((grid.calcWidth - col1.calcPixelWidth) / 3); + expect(col3.calcPixelWidth).toBe((grid.calcWidth - col1.calcPixelWidth) / 3); + expect(col4.calcPixelWidth).toBe((grid.calcWidth - col1.calcPixelWidth) / 3); + + // set larger min in px + col1.maxWidth = null; + fix.detectChanges(); + col1.minWidth = '50%'; + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + + // first column takes new min + expect(col1.calcPixelWidth).toBe(grid.calcWidth * 0.5); + // the rest split the remaining width + expect(col2.calcPixelWidth).toBe((grid.calcWidth - col1.calcPixelWidth) / 3); + expect(col3.calcPixelWidth).toBe((grid.calcWidth - col1.calcPixelWidth) / 3); + expect(col4.calcPixelWidth).toBe((grid.calcWidth - col1.calcPixelWidth) / 3); + }); + + it('in column with auto width should not go outside bounds.', async() => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + // 4 cols + fix.componentInstance.initColumnsRows(5, 4); + fix.componentInstance.columns[0].header = "Some longer text to auto-size"; + fix.componentInstance.columns[0].width = 'auto'; + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + + + const grid = fix.componentInstance.grid; + grid.width = "1500px"; + fix.detectChanges(); + const col1 = grid.columns[0]; + + // some autosize should be calculated + expect(col1.autoSize).not.toBeUndefined(); + + // set smaller max in px + col1.maxWidth = '10%'; + fix.detectChanges(); + + // first column takes new max + expect(col1.calcPixelWidth).toBe(grid.calcWidth * 0.1); + + // set larger min in px + col1.maxWidth = null; + fix.detectChanges(); + col1.minWidth = '50%'; + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + + // first column takes new min + expect(col1.calcPixelWidth).toBe(grid.calcWidth * 0.5); + }); + }) + + }); + + describe('IgxGrid - API methods', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxGridDefaultRenderingComponent, + IgxGridWrappedInContComponent + ] + }).compileComponents(); + })); + + it(`When edit a cell onto filtered data through grid method, the row should + disappear and the new value should not persist onto the next row`, fakeAsync(() => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + fix.componentInstance.initColumnsRows(5, 5); + fix.detectChanges(); + tick(16); + + const grid = fix.componentInstance.grid; + const cols = fix.componentInstance.columns; + const editValue = 0; + + grid.filter(cols[1].key, 2, IgxNumberFilteringOperand.instance().condition('greaterThan')); + fix.detectChanges(); + grid.getCellByColumn(0, cols[1].key).update(editValue); + fix.detectChanges(); + const gridRows = fix.debugElement.queryAll(By.css('igx-grid-row')); + expect(gridRows.length).toEqual(1); + const firstRowCells = gridRows[0].queryAll(By.css('igx-grid-cell')); + const firstCellInputValue = firstRowCells[1].nativeElement.textContent.trim(); + expect(firstCellInputValue).toEqual('4'); + })); + + it(`GetNextCell: should return correctly next cell coordinates`, async () => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + fix.componentInstance.initColumnsRows(15, 5); + fix.detectChanges(); + await wait(16); + + const grid = fix.componentInstance.grid; + grid.height = '500px'; + await wait(30); + fix.detectChanges(); + + grid.getColumnByName('col2').editable = true; + fix.detectChanges(); + grid.getColumnByName('col4').editable = true; + fix.detectChanges(); + // when the next cell is on the same row + let nextCellCoords = grid.getNextCell(0, 0, (col) => col.editable); + expect(nextCellCoords).toEqual({ rowIndex: 0, visibleColumnIndex: 2 }); + // when the next cell is on the next row + nextCellCoords = grid.getNextCell(0, 4, (col) => col.editable); + expect(nextCellCoords).toEqual({ rowIndex: 1, visibleColumnIndex: 2 }); + // when the next cell is not in the view + nextCellCoords = grid.getNextCell(9, 4, (col) => col.editable); + expect(nextCellCoords).toEqual({ rowIndex: 10, visibleColumnIndex: 2 }); + // when the current row and column index are not valid + nextCellCoords = grid.getNextCell(-10, 14, (col) => col.editable); + expect(nextCellCoords).toEqual({ rowIndex: -10, visibleColumnIndex: 14 }); + // when grid has no data + grid.filter('col0', 2, IgxNumberFilteringOperand.instance().condition('greaterThan')); + fix.detectChanges(); + + nextCellCoords = grid.getNextCell(0, 0, (col) => col.editable); + expect(nextCellCoords).toEqual({ rowIndex: 0, visibleColumnIndex: 0 }); + }); + + it(`GetPreviousCell: should return correctly next cell coordinates`, async () => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + fix.componentInstance.initColumnsRows(15, 5); + fix.detectChanges(); + await wait(16); + + const grid = fix.componentInstance.grid; + grid.height = '500px'; + await wait(30); + fix.detectChanges(); + + grid.getColumnByName('col2').editable = true; + fix.detectChanges(); + grid.getColumnByName('col4').editable = true; + fix.detectChanges(); + // when the previous cell is on the same row + let prevCellCoords = grid.getPreviousCell(0, 4, (col) => col.editable); + expect(prevCellCoords).toEqual({ rowIndex: 0, visibleColumnIndex: 2 }); + // when the previous cell is on the previous row + prevCellCoords = grid.getPreviousCell(1, 2, (col) => col.editable); + expect(prevCellCoords).toEqual({ rowIndex: 0, visibleColumnIndex: 4 }); + // when the current row and column index are not valid + prevCellCoords = grid.getPreviousCell(-110, 2, (col) => col.editable); + expect(prevCellCoords).toEqual({ rowIndex: -110, visibleColumnIndex: 2 }); + // when there is no previous cell + prevCellCoords = grid.getPreviousCell(0, 2, (col) => col.editable); + expect(prevCellCoords).toEqual({ rowIndex: 0, visibleColumnIndex: 2 }); + // when the filter function has no matching columns + prevCellCoords = grid.getPreviousCell(0, 3, (col) => col.pinned); + expect(prevCellCoords).toEqual({ rowIndex: 0, visibleColumnIndex: 3 }); + // when grid has no data + grid.filter('col0', 2, IgxNumberFilteringOperand.instance().condition('greaterThan')); + fix.detectChanges(); + + prevCellCoords = grid.getPreviousCell(99, 0, (col) => col.editable); + expect(prevCellCoords).toEqual({ rowIndex: 99, visibleColumnIndex: 0 }); + }); + + it('should not reset vertical scroll position when calling navigateTo with only rowIndex specified', async () => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + fix.componentInstance.initColumnsRows(15, 5); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + grid.height = '300px'; + grid.width = '500px'; + fix.detectChanges(); + + grid.navigateTo(0); + await wait(); + fix.detectChanges(); + + grid.verticalScrollContainer.getScroll().scrollTop = 200; + await wait(); + fix.detectChanges(); + expect(grid.verticalScrollContainer.getScroll().scrollTop).toEqual(200); + + grid.headerContainer.getScroll().scrollLeft = 100; + await wait(); + fix.detectChanges(); + + expect(grid.verticalScrollContainer.getScroll().scrollTop).toEqual(200); + }); + + it('should emit onScroll event when scrolling horizontally/vertically', async () => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + fix.componentInstance.initColumnsRows(30, 10); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + grid.height = '300px'; + grid.width = '300px'; + fix.detectChanges(); + + spyOn(grid.gridScroll, 'emit').and.callThrough(); + let verticalScrollEvent; + let horizontalScrollEvent; + grid.verticalScrollContainer.getScroll().addEventListener('scroll', (evt) => verticalScrollEvent = evt); + grid.headerContainer.getScroll().addEventListener('scroll', (evt) => horizontalScrollEvent = evt); + + grid.navigateTo(20, 0); + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + + expect(grid.gridScroll.emit).toHaveBeenCalledTimes(1); + expect(grid.gridScroll.emit).toHaveBeenCalledWith({ + direction: 'vertical', + scrollPosition: grid.verticalScrollContainer.getScrollForIndex(20, true), + event: verticalScrollEvent + }); + + grid.navigateTo(20, 6); + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + + expect(grid.gridScroll.emit).toHaveBeenCalledTimes(2); + expect(grid.gridScroll.emit).toHaveBeenCalledWith({ + direction: 'horizontal', + scrollPosition: grid.headerContainer.getScrollForIndex(6, true), + event: horizontalScrollEvent + }); + }); + + it('Should emit rowClick when clicking anywhere on a row', () => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + fix.componentInstance.initColumnsRows(5, 5); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + grid.groupBy({ + fieldName: 'col2', + dir: SortingDirection.Desc, + ignoreCase: false + }); + fix.detectChanges(); + spyOn(fix.componentInstance.grid.rowClick, 'emit').and.callThrough(); + const event = new Event('click'); + const grow = grid.rowList.get(0); + const row = grid.rowList.get(1); + grow.nativeElement.dispatchEvent(event); + row.nativeElement.dispatchEvent(event); + const args: IGridRowEventArgs = { + row: row, + event + }; + + fix.detectChanges(); + expect(grid.rowClick.emit).toHaveBeenCalledTimes(2); + expect(grid.rowClick.emit).toHaveBeenCalledWith(args); + }); + + it('Should emit contextMenu when clicking outside of the columns area', () => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + fix.componentInstance.initColumnsRows(5, 5); + //fix.componentInstance.columns.forEach(c => c.width = '100px'); + fix.componentInstance.grid.width = '900px'; + fix.detectChanges(); + const grid = fix.componentInstance.grid; + grid.columnList.forEach(c => c.width = '100px'); + fix.detectChanges(); + const spy = spyOn(grid.contextMenu, 'emit').and.callThrough(); + const event = new Event('contextmenu', { bubbles: true }); + const row = grid.rowList.get(0); + const cell = row.cells.get(0); + cell.nativeElement.dispatchEvent(event); + fix.detectChanges(); + expect(grid.contextMenu.emit).toHaveBeenCalledTimes(1); + expect(grid.contextMenu.emit).toHaveBeenCalledWith(jasmine.objectContaining({ + cell: jasmine.anything() + })); + spy.calls.reset(); + row.nativeElement.dispatchEvent(event); + fix.detectChanges(); + expect(grid.contextMenu.emit).toHaveBeenCalledTimes(1); + expect(grid.contextMenu.emit).toHaveBeenCalledWith(jasmine.objectContaining({ + row: jasmine.anything() + })); + }); + + it(`Verify that getRowData returns correct data`, () => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + fix.componentInstance.initColumnsRows(5, 5); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + const cols = fix.componentInstance.columns; + + const row = { col0: 0, col1: 4, col2: 8, col3: 12, col4: 16 }; + const secondRow = { col0: 0, col1: 1, col2: 2, col3: 3, col4: 4 }; + + expect(grid.getRowData(row)).toEqual(row); + + grid.primaryKey = 'col1'; + fix.detectChanges(); + + expect(grid.getRowData(4)).toEqual(row); + + grid.filter(cols[1].key, 2, IgxNumberFilteringOperand.instance().condition('greaterThan')); + fix.detectChanges(); + + expect(grid.getRowData(4)).toEqual(row); + expect(grid.getRowData(1)).toEqual(secondRow); + expect(grid.getRowData(7)).toEqual({}); + + grid.sort({ fieldName: 'col2', dir: SortingDirection.Desc, ignoreCase: true }); + fix.detectChanges(); + + expect(grid.getRowData(4)).toEqual(row); + expect(grid.getRowData(1)).toEqual(secondRow); + expect(grid.getRowData(7)).toEqual({}); + }); + + // note: it leaks when grid.groupBy() is executed because template-outlet doesn't destroy the viewrefs + // to be addressed in a separate PR + it(`Verify that getRowByIndex and RowType API returns correct data`, () => { + const fix = TestBed.createComponent(IgxGridDefaultRenderingComponent); + fix.componentInstance.initColumnsRows(35, 5); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + const virtRowsLength = grid.dataRowList.length; + const indexToCompare = 32; + + let firstRow = grid.getRowByIndex(0); + let secondRow = grid.getRowByIndex(1); + let thirdRow = grid.getRowByIndex(2); + + expect(indexToCompare > virtRowsLength).toBe(true); + // Check if the comparable row is within the virt container + expect(grid.gridAPI.get_row_by_index(virtRowsLength - 1) instanceof IgxGridRowComponent).toBe(true); + + // Check if the comparable row is outside the virt container + expect(grid.gridAPI.get_row_by_index(virtRowsLength + 1)).toEqual(undefined); + + // Get row with the new getRowByIndex method + expect(grid.getRowByIndex(32) instanceof IgxGridRow).toBe(true); + + // GroupBy column and get the collapsed grouped row + expect(firstRow instanceof IgxGridRow).toBe(true); + + // index + expect(firstRow.index).toBe(0); + expect(firstRow.viewIndex).toBe(0); + expect(firstRow.parent).toBeUndefined(); + + fix.detectChanges(); + grid.groupBy({ fieldName: 'col1', dir: SortingDirection.Asc }); + fix.detectChanges(); + + firstRow = grid.getRowByIndex(0); + secondRow = grid.getRowByIndex(1); + thirdRow = grid.getRowByIndex(2); + + // First row is IgxGroupByRow second row is igxGridRow + expect(firstRow instanceof IgxGroupByRow).toBe(true); + expect(secondRow instanceof IgxGridRow).toBe(true); + expect(secondRow.index).toBe(1); + expect(secondRow.viewIndex).toBe(1); + + // expand/collapse first group row + firstRow.expanded = true; + fix.detectChanges(); + + firstRow = grid.getRowByIndex(0); + secondRow = grid.getRowByIndex(1); + thirdRow = grid.getRowByIndex(2); + + expect(firstRow.expanded).toBe(true); + firstRow = grid.getRowByIndex(0); + secondRow = grid.getRowByIndex(1); + thirdRow = grid.getRowByIndex(2); + + // index + expect(secondRow.index).toBe(1); + expect(secondRow.viewIndex).toBe(1); + + // select group row + expect(firstRow.selected).toBeFalse(); + expect(secondRow.selected).toBeFalse(); + firstRow.children.forEach(row => { + expect(row.selected).toBeFalse(); + }); + firstRow.selected = !firstRow.selected; + + expect(firstRow.selected).toBeTrue(); + expect(secondRow.selected).toBeTrue(); + firstRow.children.forEach(row => { + expect(row.selected).toBeTrue(); + }); + + firstRow.selected = !firstRow.selected; + + expect(firstRow.selected).toBeFalse(); + expect(secondRow.selected).toBeFalse(); + firstRow.children.forEach(row => { + expect(row.selected).toBeFalse(); + }); + + (firstRow as IgxGroupByRow).toggle(); + fix.detectChanges(); + expect(firstRow.expanded).toBe(false); + + firstRow = grid.getRowByIndex(0); + secondRow = grid.getRowByIndex(1); + thirdRow = grid.getRowByIndex(2); + + // First row is still IgxGroupByRow and now the second row is as well IgxGroupByRow + expect(firstRow instanceof IgxGroupByRow).toBe(true); + expect(firstRow.key).toBeUndefined(); + expect(secondRow instanceof IgxGroupByRow).toBe(true); + + // Check hasChildren and other API members for igxGrid + expect(thirdRow.hasChildren).toBe(false); + expect(thirdRow.children).toBeUndefined(); + expect(thirdRow.parent instanceof IgxGroupByRow).toBe(true); + expect(thirdRow.parent.parent).toBeUndefined(); + expect(thirdRow.index).toEqual(2); + expect(secondRow.isSummaryRow).toBeUndefined(); + + // GroupByRow check + expect(thirdRow.isGroupByRow).toBeUndefined(); + expect(secondRow.isGroupByRow).toBe(true); + expect(thirdRow.groupRow).toBeUndefined(); + expect(secondRow.groupRow).toBeTruthy(); + + + // key and rowData check - first with group row (index 1) and then with IgxGridRow (index 2) + expect(secondRow.key).toBeUndefined(); + expect(secondRow.data).toBeUndefined(); + expect(secondRow.pinned).toBeUndefined(); + expect(secondRow.selected).toBeFalse(); + expect(thirdRow.key).toBeTruthy(); + expect(thirdRow.data).toBeTruthy(); + expect(thirdRow.pinned).toBe(false); + expect(thirdRow.selected).toBe(false); + + // Toggle selection + thirdRow.selected = true; + expect(thirdRow.selected).toBe(true); + + fix.componentInstance.paging = true; + fix.detectChanges(); + grid.perPage = 4; + grid.columnList.forEach(c => c.hasSummary = true); + fix.detectChanges(); + + firstRow = grid.getRowByIndex(0); + const fourthRow = grid.getRowByIndex(3); + + expect(firstRow instanceof IgxGroupByRow).toBe(true); + expect(firstRow.index).toEqual(0); + expect(firstRow.viewIndex).toEqual(0); + expect(fourthRow instanceof IgxSummaryRow).toBe(true); + expect(fourthRow.index).toBe(3); + expect(fourthRow.viewIndex).toBe(3); + + grid.page = 1; + grid.cdr.detectChanges(); + fix.detectChanges(); + + firstRow = grid.getRowByIndex(0); + secondRow = grid.getRowByIndex(1); + thirdRow = grid.getRowByIndex(2); + + expect(firstRow instanceof IgxGridRow).toBe(true); + expect(firstRow.index).toEqual(0); + expect(firstRow.viewIndex).toEqual(5); + expect(secondRow instanceof IgxSummaryRow).toBe(true); + expect(secondRow.index).toBe(1); + expect(secondRow.viewIndex).toBe(6); + expect(thirdRow instanceof IgxGroupByRow).toBe(true); + expect(thirdRow.index).toBe(2); + expect(thirdRow.viewIndex).toBe(7); + }); + + it('Verify that getRowByIndex returns correct data when paging is enabled', fakeAsync(() => { + const fix = TestBed.createComponent(IgxGridWrappedInContComponent); + const grid = fix.componentInstance.grid; + fix.componentInstance.data = fix.componentInstance.fullData; + fix.detectChanges(); + tick(16); + fix.componentInstance.paging = true; + fix.detectChanges(); + tick(16); + grid.notifyChanges(true); + fix.detectChanges(); + + // Compare the result returned by both get_row_by_index and getRowByIndex + expect(grid.gridAPI.get_row_by_index(fix.componentInstance.pageSize + 1)).toBeUndefined(); + expect(grid.getRowByIndex(fix.componentInstance.pageSize - 1) instanceof IgxGridRow).toBe(true); + expect(grid.getRowByIndex(fix.componentInstance.pageSize) instanceof IgxGridRow).toBe(false); + + // Change page and check getRowByIndex + grid.page = 1; + fix.detectChanges(); + tick(); + + let firstRow = grid.getRowByIndex(0); + // Return the first row after page change + expect(firstRow instanceof IgxGridRow).toBe(true); + expect(firstRow.index).toBe(0); + expect(firstRow.viewIndex).toBe(5); + + // Change page and check getRowByIndex + grid.page = 2; + fix.detectChanges(); + tick(); + + firstRow = grid.getRowByIndex(0); + const secondRow = grid.getRowByIndex(1); + // Return the first row after page change + expect(firstRow instanceof IgxGridRow).toBe(true); + expect(firstRow.index).toBe(0); + expect(firstRow.viewIndex).toBe(10); + expect(secondRow.index).toBe(1); + expect(secondRow.viewIndex).toBe(11); + })); + }); + + describe('IgxGrid - Integration with other Igx Controls', () => { + let fix; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxGridInsideIgxTabsComponent + ] + }).compileComponents(); + })); + + beforeEach(waitForAsync(() => { + fix = TestBed.createComponent(IgxGridInsideIgxTabsComponent); + fix.detectChanges(); + })); + + it('IgxTabs: should initialize a grid with correct width/height', async () => { + const grid = fix.componentInstance.grid3; + const tab = fix.componentInstance.tabs; + expect(grid.calcHeight).toBe(510); + tab.items.toArray()[2].selected = true; + await wait(100); + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + const gridHeader = fix.debugElement.query(By.css(THEAD_CLASS)); + const headers = fix.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + const gridBody = fix.debugElement.query(By.css(TBODY_CLASS)); + expect(parseInt(window.getComputedStyle(gridHeader.nativeElement).width, 10)).toBe(600); + expect(headers.length).toBe(4); + expect(parseInt(window.getComputedStyle(gridBody.nativeElement).width, 10)).toBe(600); + expect(parseInt(window.getComputedStyle(gridBody.nativeElement).height, 10)).toBe(510); + }); + + it('IgxTabs: should initialize a grid with correct width/height when there is no column width set', async () => { + + const grid = fix.componentInstance.grid2; + const tab = fix.componentInstance.tabs; + + expect(grid.calcHeight).toBe(300); + tab.items.toArray()[1].selected = true; + await wait(100); + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + const headers = fix.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + expect(headers.length).toBe(4); + const gridBody = fix.debugElement.query(By.css(TBODY_CLASS)); + const expectedHeight = grid.nativeElement.offsetHeight + - grid.theadRow.nativeElement.offsetHeight + - grid.tfoot.nativeElement.offsetHeight + - (grid.isHorizontalScrollHidden ? 0 : grid.scrollSize); + expect(parseInt(window.getComputedStyle(gridBody.nativeElement).width, 10) + grid.scrollSize).toBe(500); + expect(parseInt(window.getComputedStyle(gridBody.nativeElement).height, 10)).toBe(expectedHeight); + }); + + it('IgxTabs: should initialize a grid with correct height when paging and summaries are enabled', async () => { + const grid = fix.componentInstance.grid4; + const tab = fix.componentInstance.tabs; + tab.items.toArray()[3].selected = true; + await wait(100); + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + const headers = fix.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + const gridBody = fix.debugElement.query(By.css(TBODY_CLASS)); + const paging = fix.debugElement.query(By.css('igx-page-nav')); + const summaries = fix.debugElement.queryAll(By.css('igx-grid-summary-cell')); + expect(headers.length).toBe(4); + expect(summaries.length).toBe(4); + const expectedHeight = grid.nativeElement.offsetHeight + - grid.theadRow.nativeElement.offsetHeight + - grid.tfoot.nativeElement.offsetHeight + - grid.footer.nativeElement.offsetHeight + - (grid.isHorizontalScrollHidden ? 0 : grid.scrollSize); + expect(parseInt(window.getComputedStyle(gridBody.nativeElement).height, 10)).toBe(expectedHeight); + expect(parseInt(window.getComputedStyle(paging.nativeElement).height, 10)).toBe(36); + }); + + it('IgxTabs: should initialize a grid with correct height when height = 100%', async () => { + + const grid = fix.componentInstance.grid5; + const tab = fix.componentInstance.tabs; + expect(grid.calcHeight).toBe(204); + tab.items.toArray()[4].selected = true; + await wait(100); + fix.detectChanges(); + await wait(100); + grid.cdr.detectChanges(); + const headers = fix.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + const gridBody = fix.debugElement.query(By.css(TBODY_CLASS)); + const paging = fix.debugElement.query(By.css('igx-page-nav')); + expect(headers.length).toBe(4); + expect(parseInt(window.getComputedStyle(gridBody.nativeElement).height, 10)).toBe(204); + expect(parseInt(window.getComputedStyle(paging.nativeElement).height, 10)).toBe(36); + }); + + it('IgxTabs: should initialize a grid with correct height height = 100% when parent has height', async () => { + + const grid = fix.componentInstance.grid6; + const tab = fix.componentInstance.tabs; + expect(grid.calcHeight).toBe(510); + tab.items.toArray()[5].selected = true; + await wait(100); + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + const gridBody = fix.debugElement.query(By.css(TBODY_CLASS)); + const expectedHeight = grid.nativeElement.offsetHeight + - grid.theadRow.nativeElement.offsetHeight + - grid.tfoot.nativeElement.offsetHeight + - (grid.isHorizontalScrollHidden ? 0 : grid.scrollSize); + expect(grid.calcHeight).toBe(expectedHeight); + expect(parseInt(window.getComputedStyle(gridBody.nativeElement).height, 10)).toBe(expectedHeight); + expect(parseInt(window.getComputedStyle(grid.nativeElement).height, 10)).toBe(300); + }); + + it('IgxTabs: should persist scroll position after changing tabs.', async () => { + const grid = fix.componentInstance.grid2; + fix.detectChanges(); + const tab = fix.componentInstance.tabs; + + tab.items.toArray()[1].selected = true; + await wait(100); + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + + grid.navigateTo(0, grid.columnList.length - 1); + await wait(100); + fix.detectChanges(); + grid.navigateTo(grid.data.length - 1); + await wait(100); + fix.detectChanges(); + + const scrTop = grid.verticalScrollContainer.getScroll().scrollTop; + const scrLeft = grid.dataRowList.first.virtDirRow.getScroll().scrollLeft; + + expect(scrTop).not.toBe(0); + expect(scrLeft).not.toBe(0); + + tab.items.toArray()[0].selected = true; + await wait(100); + fix.detectChanges(); + + tab.items.toArray()[1].selected = true; + await wait(100); + fix.detectChanges(); + await wait(100); + + // check scrollTop/scrollLeft was persisted. + expect(grid.verticalScrollContainer.getScroll().scrollTop).toBe(scrTop); + expect(grid.dataRowList.first.virtDirRow.getScroll().scrollLeft).toBe(scrLeft); + }); + }); + + describe('IgxGrid - footer section', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxGridWithCustomFooterComponent + ] + }).compileComponents(); + })); + + it('should be able to display custom content', () => { + const fix = TestBed.createComponent(IgxGridWithCustomFooterComponent); + fix.detectChanges(); + + const footer = fix.debugElement.query(By.css('igx-grid-footer')).nativeElement; + const footerContent = footer.textContent.trim(); + + expect(footerContent).toEqual('Custom content'); + const grid = fix.componentInstance.grid; + + const expectedHeight = parseInt(grid.height, 10) - grid.theadRow.nativeElement.offsetHeight - grid.scrollSize - 100; + expect(expectedHeight - grid.calcHeight).toBeLessThanOrEqual(1); + }); + }); + + describe('IgxGrid - with custom pagination template', () => { + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxGridWithCustomPaginationTemplateComponent + ] + }).compileComponents(); + })); + + it('should have access to grid context', fakeAsync(() => { + const fix = TestBed.createComponent(IgxGridWithCustomPaginationTemplateComponent); + tick(); + fix.detectChanges(); + flush(); + fix.detectChanges(); + + const totalRecords = fix.componentInstance.grid.totalRecords.toString(); + const paginationContent = fix.debugElement.query(By.css('.records')).nativeElement; + const paginationText = paginationContent.textContent.trim(); + + expect(paginationText).toEqual(totalRecords); + })); + }); + + // TODO: Enable performance tests again + describe('IgxGrid - Performance tests #perf', () => { + const MAX_RAW_RENDER = 1967; // two average diffs from 7.3 rendering performance + const MAX_GROUPED_RENDER = 1500; + const MAX_VER_SCROLL_O = 220; + const MAX_HOR_SCROLL_O = 220; + const MAX_VER_SCROLL_U = 380; + const MAX_HOR_SCROLL_U = 380; + const MAX_FOCUS = 120; + let observer: MutationObserver; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxGridPerformanceComponent + ] + }).compileComponents(); + })); + + afterEach(() => { + observer?.disconnect(); + observer = null; + }); + + it('should render the grid in a certain amount of time', async () => { + const fix = TestBed.createComponent(IgxGridPerformanceComponent); + fix.detectChanges(); + expect(fix.componentInstance.delta) + .withContext('Rendering took: ' + fix.componentInstance.delta + + 'ms but should have taken at most: ' + MAX_RAW_RENDER + 'ms') + .toBeLessThan(MAX_RAW_RENDER); + }); + + it('should render grouped grid in a certain amount of time', async () => { + const fix = TestBed.createComponent(IgxGridPerformanceComponent); + fix.componentInstance.groupingExpressions.push({ + fieldName: 'field0', + dir: SortingDirection.Asc + }); + fix.detectChanges(); + expect(fix.componentInstance.delta) + .withContext('Rendering took: ' + fix.componentInstance.delta + + 'ms but should have taken at most: ' + MAX_GROUPED_RENDER + 'ms') + .toBeLessThan(MAX_GROUPED_RENDER); + }); + + xit('should scroll (optimized delta) the grid vertically in a certain amount of time', async (done) => { + const fix = TestBed.createComponent(IgxGridPerformanceComponent); + fix.detectChanges(); + await wait(16); + const startTime = new Date().getTime(); + const config: MutationObserverInit = { + attributes: true, + attributeOldValue: true, + attributeFilter: ['ng-reflect-value'] + }; + const callback = () => { + let ready = true; + const rows = fix.componentInstance.grid.rowList.toArray(); + for (let i = 0; i < 4; i++) { + if (rows[i].cells.first.nativeElement.attributes['ng-reflect-value'].nodeValue !== String(i + 3)) { + ready = false; + break; + } + } + if (ready) { + const delta = new Date().getTime() - startTime; + expect(delta) + .withContext('Scrolling took: ' + delta + 'ms but should have taken at most: ' + MAX_VER_SCROLL_O + 'ms') + .toBeLessThan(MAX_VER_SCROLL_O); + observer.disconnect(); + done(); + } + + }; + observer = new MutationObserver(callback); + observer.observe(fix.componentInstance.grid.rowList.first.cells.first.nativeElement, config); + fix.componentInstance.verticalScroll.scrollTop = 120; + await wait(100); + fix.detectChanges(); + }); + + xit('should scroll (unoptimized delta) the grid vertically in a certain amount of time', async (done) => { + const fix = TestBed.createComponent(IgxGridPerformanceComponent); + fix.detectChanges(); + await wait(16); + const startTime = new Date().getTime(); + const config: MutationObserverInit = { + attributes: true, + attributeOldValue: true, + attributeFilter: ['ng-reflect-value'] + }; + const callback = (mutationsList) => { + const cellMutated = mutationsList.filter(mutation => + mutation.oldValue === '60' && mutation.target.attributes['ng-reflect-value'].nodeValue === '84').length === 1; + if (cellMutated) { + const delta = new Date().getTime() - startTime; + expect(delta) + .withContext('Scrolling took: ' + delta + 'ms but should have taken at most: ' + MAX_VER_SCROLL_U + 'ms') + .toBeLessThan(MAX_VER_SCROLL_U); + observer.disconnect(); + done(); + } + }; + observer = new MutationObserver(callback); + observer.observe(fix.componentInstance.grid.rowList.last.cells.first.nativeElement, config); + fix.componentInstance.verticalScroll.scrollTop = 800; + await wait(100); + fix.detectChanges(); + }); + + xit('should scroll (optimized delta) the grid horizontally in a certain amount of time', async (done) => { + const fix = TestBed.createComponent(IgxGridPerformanceComponent); + fix.detectChanges(); + await wait(16); + const startTime = new Date().getTime(); + const config: MutationObserverInit = { + attributes: true, + attributeOldValue: true, + attributeFilter: ['ng-reflect-value'] + }; + const callback = mutationsList => { + const cellMutated = mutationsList.filter(mutation => + mutation.oldValue === '1' && mutation.target.attributes['ng-reflect-value'].nodeValue === '22').length === 1; + if (cellMutated) { + const delta = new Date().getTime() - startTime; + expect(delta) + .withContext('Scrolling took: ' + delta + 'ms but should have taken at most: ' + MAX_HOR_SCROLL_O + 'ms') + .toBeLessThan(MAX_HOR_SCROLL_O); + observer.disconnect(); + done(); + } + }; + observer = new MutationObserver(callback); + observer.observe(fix.componentInstance.grid.rowList.last.cells.toArray()[1].nativeElement, config); + fix.componentInstance.horizontalScroll.scrollLeft = 250; + await wait(100); + fix.detectChanges(); + }); + + xit('should scroll (unoptimized delta) the grid horizontally in a certain amount of time', async (done) => { + const fix = TestBed.createComponent(IgxGridPerformanceComponent); + fix.detectChanges(); + await wait(16); + const startTime = new Date().getTime(); + const config: MutationObserverInit = { + attributes: true, + attributeOldValue: true, + attributeFilter: ['ng-reflect-value'] + }; + const callback = mutationsList => { + const cellMutated = mutationsList.filter(mutation => + mutation.oldValue === '60' && mutation.target.attributes['ng-reflect-value'].nodeValue === '8').length === 1; + if (cellMutated) { + const delta = new Date().getTime() - startTime; + expect(delta) + .withContext('Scrolling took: ' + delta + 'ms but should have taken at most: ' + MAX_HOR_SCROLL_U + 'ms') + .toBeLessThan(MAX_HOR_SCROLL_U); + observer.disconnect(); + done(); + } + }; + observer = new MutationObserver(callback); + observer.observe(fix.componentInstance.grid.rowList.last.cells.first.nativeElement, config); + fix.componentInstance.horizontalScroll.scrollLeft = 800; + await wait(100); + fix.detectChanges(); + }); + + xit('should focus a cell in a certain amount of time', async (done) => { + const fix = TestBed.createComponent(IgxGridPerformanceComponent); + fix.detectChanges(); + await wait(16); + const startTime = new Date().getTime(); + const config: MutationObserverInit = { + attributes: true, + attributeOldValue: true, + attributeFilter: ['aria-selected'] + }; + const callback = mutationsList => { + const cellMutated = mutationsList.filter(mutation => + mutation.oldValue === 'false' && mutation.target.attributes['aria-selected'].nodeValue === 'true').length === 1; + if (cellMutated) { + const delta = new Date().getTime() - startTime; + expect(delta) + .withContext('Focusing took: ' + delta + 'ms but should have taken at most: ' + MAX_FOCUS + 'ms') + .toBeLessThan(MAX_FOCUS); + observer.disconnect(); + done(); + } + }; + observer = new MutationObserver(callback); + observer.observe(fix.componentInstance.grid.rowList.first.cells.first.nativeElement, config); + // UIInteractions.simulateClickAndSelectEvent(fix.componentInstance.grid.rowList.first.cells.first.nativeElement); + await wait(16); + fix.detectChanges(); + }); + }); + + describe('Setting null data', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxGridNoDataComponent, + IgxGridTestComponent + ] + }).compileComponents(); + })); + + it('should not throw error when data is null', () => { + const fix = TestBed.createComponent(IgxGridNoDataComponent); + fix.componentInstance.grid.batchEditing = true; + expect(() => fix.detectChanges()).not.toThrow(); + }); + + it('should not throw error when data is set to null', () => { + const fix = TestBed.createComponent(IgxGridTestComponent); + fix.componentInstance.data = null; + expect(() => fix.detectChanges()).not.toThrow(); + }); + + it('should not throw error when data is set to null and transactions are enabled', () => { + const fix = TestBed.createComponent(IgxGridTestComponent); + fix.componentInstance.grid.batchEditing = true; + fix.componentInstance.data = null; + expect(() => fix.detectChanges()).not.toThrow(); + }); + }); +}); + +@Component({ + template: `
    + + @for (column of columns; track column.field) { + + + } + +
    `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class IgxGridTestComponent { + public cdr = inject(ChangeDetectorRef); + + @ViewChild('grid', { static: true }) public grid: IgxGridComponent; + public data: any[] = [{ index: 1, value: 1 }]; + public columns = [ + { field: 'index', header: 'index', dataType: 'number', width: null, hasSummary: false }, + { field: 'value', header: 'value', dataType: 'number', width: null, hasSummary: false } + ]; + + public autoGenerate = false; + + public autoGenerateExclude = []; + + public columnEventCount = 0; + + public columnCreated(column: IgxColumnComponent) { + this.columnEventCount++; + column.filterable = true; + column.sortable = true; + } + + public isHorizontalScrollbarVisible() { + const scrollbar = this.grid.headerContainer.getScroll(); + if (scrollbar) { + return scrollbar.offsetWidth < (scrollbar.children.item(0) as HTMLElement).offsetWidth; + } + + return false; + } + + public getVerticalScrollHeight() { + const scrollbar = this.grid.verticalScrollContainer.getScroll(); + if (scrollbar) { + return parseInt(scrollbar.style.height, 10); + } + + return 0; + } + + public isVerticalScrollbarVisible() { + const scrollbar = this.grid.verticalScrollContainer.getScroll(); + if (scrollbar && scrollbar.offsetHeight > 0) { + return scrollbar.offsetHeight < (scrollbar.children.item(0) as HTMLElement).offsetHeight; + } + return false; + } + + public generateData(rows) { + const d = []; + for (let r = 0; r < rows; r++) { + const record = {}; + for (let c = 0; c < this.columns.length; c++) { + record[this.columns[c].field] = c * r; + } + d.push(record); + } + this.data = d; + } + + public clearData() { + this.data = []; + } +} + +@Component({ + template: ` + @for (col of columns; track col.key) { + + + } + @if (paging) { + + } + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxPaginatorComponent] +}) +export class IgxGridDefaultRenderingComponent { + @ViewChild('grid', { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + + public columns = []; + public data = []; + public paging = false; + + public changeInitColumns = false; + + public initColumnsRows(rowsNumber: number, columnsNumber: number): void { + this.columns = []; + this.data = []; + let i; let j: number; + for (i = 0; i < columnsNumber; i++) { + this.columns.push({ + key: 'col' + i, + dataType: 'number' + }); + } + for (i = 0; i < rowsNumber; i++) { + const record = {}; + for (j = 0; j < columnsNumber; j++) { + record[this.columns[j].key] = j * i; + } + this.data.push(record); + } + } + + public isHorizontalScrollbarVisible() { + const scrollbar = this.grid.headerContainer.getScroll(); + return scrollbar.offsetWidth < (scrollbar.children.item(0) as HTMLElement).offsetWidth; + } + + public initColumns(column) { + if (this.changeInitColumns) { + switch (this.grid.columnList.length) { + case 5: + if (column.index === 0 || column.index === 4) { + column.width = '100px'; + } + break; + case 30: + if (column.index === 0 || column.index === 5 || column.index === 3 || column.index === 10 || column.index === 25) { + column.width = '200px'; + } + break; + case 150: + if (column.index === 0 || column.index === 5 || column.index === 3 || column.index === 10 || column.index === 50) { + column.width = '500px'; + } + break; + } + } + } +} + +@Component({ + template: ` +
    + + + + + +
    `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class IgxGridColumnHeaderAutoSizeComponent { + @ViewChild('grid', { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + + public gridContainerHidden = true; + +} + +@Component({ + template: ` + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxColumnGroupComponent] +}) +export class IgxGridColumnHeaderInGroupAutoSizeComponent { + @ViewChild('grid', { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; +} + +@Component({ + template: ` + @for (col of columns; track col.key) { + + + } + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class IgxGridColumnPercentageWidthComponent extends IgxGridDefaultRenderingComponent { + public override initColumns(column) { + if (column.index === 0) { + column.width = '40%'; + } + } +} + +@Component({ + template: ` + @for (col of columns; track col.key) { + + + } + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class IgxGridColumnHiddenPercentageWidthComponent extends IgxGridDefaultRenderingComponent { + public hidden = true; +} + +@Component({ + template: `
    + + +
    + Custom content +
    +
    +
    +
    `, + imports: [IgxGridComponent, IgxGridFooterComponent] +}) +export class IgxGridWithCustomFooterComponent extends IgxGridTestComponent { + @ViewChild(IgxGridComponent, { static: true }) public override grid: IgxGridComponent; + public override data = SampleTestData.foodProductData(); +} +@Component({ + template: `
    + + @if (paging) { + + } + +
    `, + imports: [IgxGridComponent, IgxPaginatorComponent] +}) +export class IgxGridWrappedInContComponent extends IgxGridTestComponent { + public override data = []; + + public fullData = [ + { ID: 'ALFKI', CompanyName: 'Alfreds Futterkiste' }, + { ID: 'ANATR', CompanyName: 'Ana Trujillo Emparedados y helados' }, + { ID: 'ANTON', CompanyName: 'Antonio Moreno Taquería' }, + { ID: 'AROUT', CompanyName: 'Around the Horn' }, + { ID: 'BERGS', CompanyName: 'Berglunds snabbköp' }, + { ID: 'BLAUS', CompanyName: 'Blauer See Delikatessen' }, + { ID: 'BLONP', CompanyName: 'Blondesddsl père et fils' }, + { ID: 'BOLID', CompanyName: 'Bólido Comidas preparadas' }, + { ID: 'BONAP', CompanyName: 'Bon app\'' }, + { ID: 'BOTTM', CompanyName: 'Bottom-Dollar Markets' }, + { ID: 'BSBEV', CompanyName: 'B\'s Beverages' }, + { ID: 'CACTU', CompanyName: 'Cactus Comidas para llevar' }, + { ID: 'CENTC', CompanyName: 'Centro comercial Moctezuma' }, + { ID: 'CHOPS', CompanyName: 'Chop-suey Chinese' }, + { ID: 'COMMI', CompanyName: 'Comércio Mineiro' }, + { ID: 'CONSH', CompanyName: 'Consolidated Holdings' }, + { ID: 'DRACD', CompanyName: 'Drachenblut Delikatessen' }, + { ID: 'DUMON', CompanyName: 'Du monde entier' }, + { ID: 'EASTC', CompanyName: 'Eastern Connection' }, + { ID: 'ERNSH', CompanyName: 'Ernst Handel' }, + { ID: 'FAMIA', CompanyName: 'Familia Arquibaldo' }, + { ID: 'FISSA', CompanyName: 'FISSA Fabrica Inter' }, + { ID: 'FOLIG', CompanyName: 'Folies gourmandes' }, + { ID: 'FOLKO', CompanyName: 'Folk och fä HB' }, + { ID: 'FRANK', CompanyName: 'Frankenversand' }, + { ID: 'FRANR', CompanyName: 'France restauration' }, + { ID: 'FRANS', CompanyName: 'Franchi S.p.A.' } + ]; + + public get semiData(): any[] { + return this.fullData.slice(0, 5); + } + + public height = null; + public paging = false; + public pageSize = 5; + public outerWidth = 800; + public outerHeight: number; + public display: string = ""; +} + +@Component({ + template: `
    + + @if (paging) { + + } + +
    `, + imports: [IgxGridComponent, IgxPaginatorComponent] +}) +export class IgxGridFixedContainerHeightComponent extends IgxGridWrappedInContComponent { + public override paging = false; + public override pageSize = 5; +} + +@Component({ + template: ` + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class IgxGridMarkupDeclarationComponent extends IgxGridTestComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public instance: IgxGridComponent; + public override data = [ + { ID: 1, Name: 'Johnny' }, + { ID: 2, Name: 'Sally' }, + { ID: 3, Name: 'Tim' } + ]; +} + +@Component({ + template: `
    + + + + +
    + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class IgxGridEmptyMessage100PercentComponent extends IgxGridTestComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public override grid: IgxGridComponent; + public override data = []; +} + +@Injectable() +export class LocalService { + public records: Observable; + private _records: BehaviorSubject; + private dataStore: any[]; + + constructor() { + this.dataStore = []; + this._records = new BehaviorSubject([]); + this.records = this._records.asObservable(); + } + + public nullData() { + this._records.next(null); + } + + public getData(data?: IForOfState, cb?: (any) => void): any { + const size = data.chunkSize === 0 ? 10 : data.chunkSize; + this.dataStore = this.generateData(data.startIndex, data.startIndex + size); + this._records.next(this.dataStore); + const count = 1000; + if (cb) { + cb(count); + } + } + + public generateData(start, end) { + const dummyData = []; + for (let i = start; i < end; i++) { + dummyData.push({ Col1: 10 * i }); + } + return dummyData; + } +} + +@Component({ + template: ` + + + + + `, + providers: [LocalService], + imports: [IgxGridComponent, IgxColumnComponent, AsyncPipe] +}) +export class IgxGridRemoteVirtualizationComponent implements OnInit, AfterViewInit { + private localService = inject(LocalService); + public cdr = inject(ChangeDetectorRef); + + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public instance: IgxGridComponent; + public data; + public ngOnInit(): void { + this.data = this.localService.records; + } + + public nullData() { + this.localService.nullData(); + } + + public ngAfterViewInit() { + this.localService.getData(this.instance.virtualizationState, (count) => { + this.instance.totalItemCount = count; + this.cdr.detectChanges(); + }); + } + + public dataLoading(evt) { + this.localService.getData(evt, () => { + this.cdr.detectChanges(); + }); + } +} + +@Component({ + template: ` + + No Data 😢 + @if (customLoading) { + Loading 🔃 + } + + + + Loading... + + `, + providers: [LocalService], + imports: [IgxGridComponent, IgxGridEmptyTemplateDirective, IgxGridLoadingTemplateDirective, AsyncPipe] +}) +export class IgxGridRemoteOnDemandComponent { + private localService = inject(LocalService); + public cdr = inject(ChangeDetectorRef); + + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public instance: IgxGridComponent; + @ViewChild('customTemplate', { read: TemplateRef, static: true }) + public customTemplate: TemplateRef; + public data; + public customLoading = false; + + public bind() { + this.data = this.localService.records; + this.localService.getData(this.instance.virtualizationState, (count) => { + this.instance.totalItemCount = count; + this.cdr.detectChanges(); + }); + } + + public dataLoading(evt) { + this.localService.getData(evt, () => { + this.cdr.detectChanges(); + }); + } +} + +@Component({ + template: GridTemplateStrings.declareGrid('', '', ` + + + + + + + + + + `), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class IgxGridFormattingComponent extends BasicGridComponent { + @ViewChild(IgxGridComponent, { static: true }) public override grid: IgxGridComponent; + public override data = SampleTestData.foodProductData(); + public width = '600px'; + public height = '400px'; + public value: any; + public formatNum(value) { + return value.toExponential().toString(); + } +} + +@Component({ + template: ` +
    + + + + Tab 1 + + This is Tab 1 content. + + + + Tab 2 + + + + @for (column of columns; track column.field) { + + + } + + + + + + Tab 3 + + + + @for (column of columns; track column.field) { + + + } + + + + + + Tab 4 + + + + @for (column of columns; track column.field) { + + + } + + + + + + + Tab 5 + + + + @for (column of columns; track column.field) { + + + } + + + + + + + Tab 6 + + +
    + + @for (column of columns; track column.field) { + + + } + +
    +
    +
    +
    +
    + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxTabsComponent, IgxTabHeaderComponent, IgxTabContentComponent, IgxTabItemComponent, IgxPaginatorComponent] +}) +export class IgxGridInsideIgxTabsComponent { + @ViewChild('grid2', { read: IgxGridComponent, static: true }) + public grid2: IgxGridComponent; + @ViewChild('grid3', { read: IgxGridComponent, static: true }) + public grid3: IgxGridComponent; + @ViewChild('grid4', { read: IgxGridComponent, static: true }) + public grid4: IgxGridComponent; + @ViewChild('grid5', { read: IgxGridComponent, static: true }) + public grid5: IgxGridComponent; + @ViewChild('grid6', { read: IgxGridComponent, static: true }) + public grid6: IgxGridComponent; + @ViewChild(IgxTabsComponent, { read: IgxTabsComponent, static: true }) + public tabs: IgxTabsComponent; + + public columns = [ + { field: 'id', width: 100 }, + { field: '1', width: 100 }, + { field: '2', width: 100 }, + { field: '3', width: 100 } + ]; + + public data = []; + + constructor() { + const data = []; + for (let j = 1; j <= 10; j++) { + const item = {}; + item['id'] = j; + for (let k = 2, len = this.columns.length; k <= len; k++) { + const field = this.columns[k - 1].field; + item[field] = `item${j}-${k}`; + } + data.push(item); + } + this.data = data; + } +} + +@Component({ + template: ` + + + + @if (grid.rendered$ | async) { +

    {{grid.totalRecords}}

    + } +
    +
    +
    + `, + imports: [IgxGridComponent, IgxPaginatorComponent, IgxPaginatorContentDirective, AsyncPipe] +}) +export class IgxGridWithCustomPaginationTemplateComponent { + @ViewChild('grid', { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + public data = SampleTestData.foodProductData(); +} + +@Component({ + template: ` + @for (column of columns; track column.field) { + + } + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class IgxGridPerformanceComponent implements AfterViewInit, OnInit { + + @ViewChild('grid', { read: IgxGridComponent, static: true }) public grid: IgxGridComponent; + + public columns = []; + public data = []; + public startTime; + public delta; + public groupingExpressions: Array = []; + public autoGenerate = false; + + public get verticalScroll() { + return this.grid.verticalScrollContainer.getScroll(); + } + + public get horizontalScroll() { + return this.grid.headerContainer.getScroll(); + } + + public ngOnInit() { + const cols = []; const d = []; + for (let i = 0; i < 30; i++) { + cols.push({ field: 'field' + i, width: '100px', hasSummary: false }); + } + for (let i = 0; i < 10000; i++) { + const r = {}; + r['field0'] = i; + for (let j = 1; j < 30; j++) { + r['field' + j] = j; + } + d.push(r); + } + this.columns = cols; + this.data = d; + this.startTime = new Date().getTime(); + } + + public ngAfterViewInit() { + this.delta = new Date().getTime() - this.startTime; + } +} + +@Component({ + template: ` + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxPaginatorComponent] +}) +export class IgxGridNoDataComponent { + @ViewChild(IgxGridComponent, { static: true }) public grid: IgxGridComponent; +} diff --git a/projects/igniteui-angular/grids/grid/src/grid.component.ts b/projects/igniteui-angular/grids/grid/src/grid.component.ts new file mode 100644 index 00000000000..64ceec9d441 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid.component.ts @@ -0,0 +1,1403 @@ +import { + Component, ChangeDetectionStrategy, Input, Output, EventEmitter, ContentChild, ViewChildren, + QueryList, ViewChild, TemplateRef, DoCheck, AfterContentInit, HostBinding, + OnInit, AfterViewInit, ContentChildren, CUSTOM_ELEMENTS_SCHEMA, booleanAttribute +} from '@angular/core'; +import { NgTemplateOutlet, NgClass, NgStyle } from '@angular/common'; +import { + CellType, + FilterMode, + GridType, + IGX_GRID_BASE, + IGX_GRID_SERVICE_BASE, + IgxColumnComponent, + IgxColumnMovingDropDirective, + IgxColumnResizingService, + IgxFilteringService, + IgxGridAddRowPipe, + IgxGridBodyDirective, + IgxGridCell, + IgxGridColumnResizerComponent, + IgxGridCRUDService, + IgxGridDetailTemplateDirective, + IgxGridDragSelectDirective, + IgxGridGroupByAreaComponent, + IgxGridHeaderRowComponent, + IgxGridMasterDetailContext, + IgxGridMRLNavigationService, + IgxGridNavigationService, + IgxGridRow, + IgxGridRowClassesPipe, + IgxGridRowPinningPipe, + IgxGridRowStylesPipe, + IgxGridSelectionService, + IgxGridSummaryService, + IgxGridTransactionPipe, + IgxGridValidationService, + IgxGroupByRow, + IgxGroupByRowSelectorDirective, + IgxGroupByRowSelectorTemplateContext, + IgxGroupByRowTemplateContext, + IgxGroupByRowTemplateDirective, + IgxHasVisibleColumnsPipe, + IgxRowEditTabStopDirective, + IgxStringReplacePipe, + IgxSummaryDataPipe, + IgxSummaryRow, + IgxSummaryRowComponent, + RowType +} from 'igniteui-angular/grids/core'; +import { IgxGridAPIService } from './grid-api.service'; +import { IgxGridGroupByRowComponent } from './groupby-row.component'; +import { take, takeUntil } from 'rxjs/operators'; +import { cloneArray, IBaseEventArgs, IGridGroupingStrategy, IGroupByExpandState, IGroupByRecord, IGroupingExpression, IgxOverlayOutletDirective, ISortingExpression } from 'igniteui-angular/core'; +import { IgxGridDetailsPipe } from './grid.details.pipe'; +import { IgxGridSummaryPipe } from './grid.summary.pipe'; +import { IgxGridGroupingPipe, IgxGridPagingPipe, IgxGridSortingPipe, IgxGridFilteringPipe, IgxGridCellMergePipe, IgxGridUnmergeActivePipe } from './grid.pipes'; +import { IgxGridRowComponent } from './grid-row.component'; +import { Observable, Subject } from 'rxjs'; +import { IForOfState, IgxButtonDirective, IgxForOfScrollSyncService, IgxForOfSyncService, IgxGridForOfDirective, IgxRippleDirective, IgxScrollInertiaDirective, IgxTemplateOutletDirective, IgxToggleDirective } from 'igniteui-angular/directives'; +import { IgxCircularProgressBarComponent } from 'igniteui-angular/progressbar'; +import { IgxSnackbarComponent } from 'igniteui-angular/snackbar'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxGridBaseDirective } from './grid-base.directive'; + +let NEXT_ID = 0; + +export interface IGroupingDoneEventArgs extends IBaseEventArgs { + expressions: Array | ISortingExpression; + groupedColumns: Array | IgxColumnComponent; + ungroupedColumns: Array | IgxColumnComponent; +} + +/* blazorAdditionalDependency: Column */ +/* blazorAdditionalDependency: ColumnGroup */ +/* blazorAdditionalDependency: ColumnLayout */ +/* blazorAdditionalDependency: GridToolbar */ +/* blazorAdditionalDependency: GridToolbarActions */ +/* blazorAdditionalDependency: GridToolbarTitle */ +/* blazorAdditionalDependency: GridToolbarAdvancedFiltering */ +/* blazorAdditionalDependency: GridToolbarExporter */ +/* blazorAdditionalDependency: GridToolbarHiding */ +/* blazorAdditionalDependency: GridToolbarPinning */ +/* blazorAdditionalDependency: ActionStrip */ +/* blazorAdditionalDependency: GridActionsBaseDirective */ +/* blazorAdditionalDependency: GridEditingActions */ +/* blazorAdditionalDependency: GridPinningActions */ +/* blazorIndirectRender */ +/** + * Grid provides a way to present and manipulate tabular data. + * + * @igxModule IgxGridModule + * @igxGroup Grids & Lists + * @igxKeywords grid, table + * @igxTheme igx-grid-theme + * @remarks + * The Ignite UI Grid is used for presenting and manipulating tabular data in the simplest way possible. Once data + * has been bound, it can be manipulated through filtering, sorting & editing operations. + * @example + * ```html + * + * + * + * + * + * ``` + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + preserveWhitespaces: false, + providers: [ + IgxGridCRUDService, + IgxGridNavigationService, + IgxGridSummaryService, + IgxGridSelectionService, + IgxGridValidationService, + { provide: IGX_GRID_SERVICE_BASE, useClass: IgxGridAPIService }, + { provide: IGX_GRID_BASE, useExisting: IgxGridComponent }, + IgxFilteringService, + IgxColumnResizingService, + IgxForOfSyncService, + IgxForOfScrollSyncService, + ], + selector: 'igx-grid', + templateUrl: './grid.component.html', + imports: [ + NgClass, + NgStyle, + NgTemplateOutlet, + IgxGridGroupByAreaComponent, + IgxGridHeaderRowComponent, + IgxGridBodyDirective, + IgxGridDragSelectDirective, + IgxColumnMovingDropDirective, + IgxGridForOfDirective, + IgxTemplateOutletDirective, + IgxGridRowComponent, + IgxGridGroupByRowComponent, + IgxSummaryRowComponent, + IgxOverlayOutletDirective, + IgxToggleDirective, + IgxCircularProgressBarComponent, + IgxSnackbarComponent, + IgxButtonDirective, + IgxRippleDirective, + IgxIconComponent, + IgxRowEditTabStopDirective, + IgxGridColumnResizerComponent, + IgxGridTransactionPipe, + IgxHasVisibleColumnsPipe, + IgxGridRowPinningPipe, + IgxGridAddRowPipe, + IgxGridRowClassesPipe, + IgxGridRowStylesPipe, + IgxSummaryDataPipe, + IgxGridGroupingPipe, + IgxGridPagingPipe, + IgxGridSortingPipe, + IgxGridFilteringPipe, + IgxGridSummaryPipe, + IgxGridDetailsPipe, + IgxStringReplacePipe, + IgxGridCellMergePipe, + IgxGridUnmergeActivePipe, + IgxScrollInertiaDirective + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] +}) +export class IgxGridComponent extends IgxGridBaseDirective implements GridType, OnInit, DoCheck, AfterContentInit, AfterViewInit { + /** + * Emitted when a new chunk of data is loaded from virtualization. + * + * @example + * ```typescript + * + * ``` + */ + @Output() + public dataPreLoad = new EventEmitter(); + + /** + * Emitted when grouping is performed. + * + * @example + * ```html + * + * ``` + */ + @Output() + public groupingExpressionsChange = new EventEmitter(); + + /** + * Emitted when groups are expanded/collapsed. + * + * @example + * ```html + * + * ``` + */ + @Output() + public groupingExpansionStateChange = new EventEmitter(); + + /** + * Emitted when columns are grouped/ungrouped. + * + * @remarks + * The `groupingDone` event would be raised only once if several columns get grouped at once by calling + * the `groupBy()` or `clearGrouping()` API methods and passing an array as an argument. + * The event arguments provide the `expressions`, `groupedColumns` and `ungroupedColumns` properties, which contain + * the `ISortingExpression` and the `IgxColumnComponent` related to the grouping/ungrouping operation. + * Please note that `groupedColumns` and `ungroupedColumns` show only the **newly** changed columns (affected by the **last** + * grouping/ungrouping operation), not all columns which are currently grouped/ungrouped. + * columns. + * @example + * ```html + * + * ``` + */ + @Output() + public groupingDone = new EventEmitter(); + + /** + * Gets/Sets whether created groups are rendered expanded or collapsed. + * + * @remarks + * The default rendered state is expanded. + * @example + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public groupsExpanded = true; + + /** + * Gets/Sets the template that will be rendered as a GroupBy drop area. + * + * @remarks + * The grid needs to have at least one groupable column in order the GroupBy area to be displayed. + * @example + * ```html + * + * + * + * Custom drop area! + * + * ``` + */ + @Input() + public dropAreaTemplate: TemplateRef; + + /** + * @hidden @internal + */ + @ContentChild(IgxGridDetailTemplateDirective, { read: TemplateRef }) + public detailTemplateDirective: TemplateRef; + + + /** + * Returns a reference to the master-detail template. + * ```typescript + * let detailTemplate = this.grid.detailTemplate; + * ``` + * + * @memberof IgxColumnComponent + */ + @Input('detailTemplate') + public get detailTemplate(): TemplateRef { + return this._detailTemplate; + } + /** + * Sets the master-detail template. + * ```html + * + *
    + *
    City: {{dataItem.City}}
    + *
    Address: {{dataItem.Address}}
    + *
    + *
    + * ``` + * ```typescript + * @ViewChild("'detailTemplate'", {read: TemplateRef }) + * public detailTemplate: TemplateRef; + * this.grid.detailTemplate = this.detailTemplate; + * ``` + * + * @memberof IgxColumnComponent + */ + public set detailTemplate(template: TemplateRef) { + this._detailTemplate = template; + } + + /** + * @hidden @internal + */ + @HostBinding('attr.role') + public role = 'grid'; + + /** + * Gets/Sets the value of the `id` attribute. + * + * @remarks + * If not provided it will be automatically generated. + * @example + * ```html + * + * ``` + */ + @HostBinding('attr.id') + @Input() + public id = `igx-grid-${NEXT_ID++}`; + + /** + * @hidden @internal + */ + @ViewChild('record_template', { read: TemplateRef, static: true }) + protected recordTemplate: TemplateRef; + + @ViewChild('detail_template_container', { read: TemplateRef, static: true }) + protected detailTemplateContainer: TemplateRef; + + @ViewChild('group_template', { read: TemplateRef, static: true }) + protected defaultGroupTemplate: TemplateRef; + + @ViewChild('summary_template', { read: TemplateRef, static: true }) + protected summaryTemplate: TemplateRef; + + /** + * @hidden @internal + */ + @ContentChild(IgxGroupByRowTemplateDirective, { read: IgxGroupByRowTemplateDirective }) + protected groupTemplate: IgxGroupByRowTemplateDirective; + + /** + * @hidden + * @internal + */ + @ContentChildren(IgxGroupByRowSelectorDirective, { read: TemplateRef, descendants: false }) + protected groupByRowSelectorsTemplates: QueryList>; + + @ViewChildren(IgxGridGroupByRowComponent, { read: IgxGridGroupByRowComponent }) + private _groupsRowList: QueryList; + + private _groupsRecords: IGroupByRecord[] = []; + /** + * Gets the hierarchical representation of the group by records. + * + * @example + * ```typescript + * let groupRecords = this.grid.groupsRecords; + * ``` + */ + public get groupsRecords(): IGroupByRecord[] { + return this._groupsRecords; + } + + /** + * @hidden @internal + * Includes children of collapsed group rows. + */ + public groupingResult: any[]; + + /** + * @hidden @internal + */ + public groupingMetadata: any[]; + + /** + * @hidden @internal + * Does not include children of collapsed group rows. + */ + public groupingFlatResult: any[]; + /** + * @hidden + */ + protected _groupingExpressions: IGroupingExpression[] = []; + /** + * @hidden + */ + protected _groupingExpandState: IGroupByExpandState[] = []; + /** + * @hidden + */ + protected _groupRowTemplate: TemplateRef; + + /** + * @hidden + */ + protected _groupStrategy: IGridGroupingStrategy; + /** + * @hidden + */ + protected groupingDiffer; + private _data?: any[] | null; + private _hideGroupedColumns = false; + private _dropAreaMessage = null; + private _showGroupArea = true; + + private _groupByRowSelectorTemplate: TemplateRef; + private _detailTemplate; + + /** + * Gets/Sets the array of data that populates the component. + * + * @example + * ```html + * + * ``` + */ + /* treatAsRef */ + @Input() + public get data(): any[] | null { + return this._data; + } + + public set data(value: any[] | null) { + const dataLoaded = (!this._data || this._data.length === 0) && value && value.length > 0; + const oldData = this._data; + this._data = value || []; + this.summaryService.clearSummaryCache(); + if (!this._init) { + this.validation.updateAll(this._data); + } + + if (this.autoGenerate && this._data.length > 0 && this.shouldRecreateColumns(oldData, this._data) && this.gridAPI.grid) { + this.setupColumns(); + } + + this.cdr.markForCheck(); + if (this.isPercentHeight) { + this.notifyChanges(true); + } + // check if any columns have width auto and if so recalculate their auto-size on data loaded. + if (dataLoaded && this._columns.some(x => (x as any)._width === 'auto')) { + this.recalculateAutoSizes(); + } + this.checkPrimaryKeyField(); + } + + /** + * Gets/Sets the total number of records in the data source. + * + * @remarks + * This property is required for remote grid virtualization to function when it is bound to remote data. + * @example + * ```typescript + * const itemCount = this.grid1.totalItemCount; + * this.grid1.totalItemCount = 55; + * ``` + */ + @Input() + public set totalItemCount(count) { + this.verticalScrollContainer.totalItemCount = count; + } + + public get totalItemCount() { + return this.verticalScrollContainer.totalItemCount; + } + + private get _gridAPI(): IgxGridAPIService { + return this.gridAPI as IgxGridAPIService; + } + + private childDetailTemplates: Map = new Map(); + + /** + * @hidden @internal + */ + public groupingPerformedSubject = new Subject(); + + /** + * @hidden @internal + */ + public groupingPerformed$: Observable = this.groupingPerformedSubject.asObservable(); + + /* mustSetInCodePlatforms: WebComponents;Blazor;React */ + /** + * Gets/Sets the group by state. + * + * @example + * ```typescript + * let groupByState = this.grid.groupingExpressions; + * this.grid.groupingExpressions = [...]; + * ``` + * @remarks + * Supports two-way data binding. + * @example + * ```html + * + * ``` + */ + @Input() + public get groupingExpressions(): IGroupingExpression[] { + return this._groupingExpressions; + } + + public set groupingExpressions(value: IGroupingExpression[]) { + if (this.groupingExpressions === value) { + return; + } + if (value && value.length > 10) { + throw Error('Maximum amount of grouped columns is 10.'); + } + const oldExpressions: IGroupingExpression[] = this.groupingExpressions; + const newExpressions: IGroupingExpression[] = value; + this._groupingExpressions = cloneArray(value); + this.groupingExpressionsChange.emit(this._groupingExpressions); + if (this._gridAPI.grid) { + /* grouping and sorting are working separate from each other */ + this._applyGrouping(); + this.notifyChanges(); + } + if (!this._init && JSON.stringify(oldExpressions, this.stringifyCallback) !== JSON.stringify(newExpressions, this.stringifyCallback) && this._columns) { + const groupedCols: IgxColumnComponent[] = []; + const ungroupedCols: IgxColumnComponent[] = []; + const groupedColsArr = newExpressions.filter((obj) => !oldExpressions.some((obj2) => obj.fieldName === obj2.fieldName)); + groupedColsArr.forEach((elem) => { + groupedCols.push(this.getColumnByName(elem.fieldName)); + }, this); + const ungroupedColsArr = oldExpressions.filter((obj) => !newExpressions.some((obj2) => obj.fieldName === obj2.fieldName)); + ungroupedColsArr.forEach((elem) => { + ungroupedCols.push(this.getColumnByName(elem.fieldName)); + }, this); + this.notifyChanges(); + const groupingDoneArgs: IGroupingDoneEventArgs = { + expressions: newExpressions, + groupedColumns: groupedCols, + ungroupedColumns: ungroupedCols + }; + this.groupingPerformed$.pipe(take(1)).subscribe(() => { + this.groupingDone.emit(groupingDoneArgs); + }); + } + } + + /** + * Gets/Sets a list of expansion states for group rows. + * + * @remarks + * Includes only states that differ from the default one (controlled through groupsExpanded and states that the user has changed. + * Contains the expansion state (expanded: boolean) and the unique identifier for the group row (Array). + * Supports two-way data binding. + * @example + * ```html + * + * ``` + */ + @Input() + public get groupingExpansionState() { + return this._groupingExpandState; + } + + public set groupingExpansionState(value) { + if (value !== this._groupingExpandState) { + this.groupingExpansionStateChange.emit(value); + } + this._groupingExpandState = value; + if (this.gridAPI.grid) { + this.cdr.detectChanges(); + } + } + + /** + * Gets/Sets whether the grouped columns should be hidden. + * + * @remarks + * The default value is "false" + * @example + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public get hideGroupedColumns() { + return this._hideGroupedColumns; + } + + public set hideGroupedColumns(value: boolean) { + if (value) { + this.groupingDiffer = this.differs.find(this.groupingExpressions).create(); + } else { + this.groupingDiffer = null; + } + if (this._columns && this.groupingExpressions) { + this._setGroupColsVisibility(value); + } + + this._hideGroupedColumns = value; + } + + /** + * Gets/Sets the grouping strategy of the grid. + * + * @remarks The default IgxGrouping extends from IgxSorting and a custom one can be used as a `sortStrategy` as well. + * + * @example + * ```html + * + * ``` + */ + @Input() + public get groupStrategy(): IGridGroupingStrategy { + return this._groupStrategy; + } + + public set groupStrategy(value: IGridGroupingStrategy) { + this._groupStrategy = value; + } + + /** + * Gets/Sets the message displayed inside the GroupBy drop area where columns can be dragged on. + * + * @remarks + * The grid needs to have at least one groupable column in order the GroupBy area to be displayed. + * @example + * ```html + * + * + * + * ``` + */ + @Input() + public set dropAreaMessage(value: string) { + this._dropAreaMessage = value; + this.notifyChanges(); + } + + public get dropAreaMessage(): string { + return this._dropAreaMessage || this.resourceStrings.igx_grid_groupByArea_message; + } + + /** + * @hidden @internal + */ + public get groupsRowList() { + const res = new QueryList(); + if (!this._groupsRowList) { + return res; + } + const rList = this._groupsRowList.filter(item => item.element.nativeElement.parentElement !== null) + .sort((item1, item2) => item1.index - item2.index); + res.reset(rList); + return res; + } + + /** + * Gets the group by row selector template. + */ + @Input() + public get groupByRowSelectorTemplate(): TemplateRef { + return this._groupByRowSelectorTemplate || this.groupByRowSelectorsTemplates?.first; + } + + /** + * Sets the group by row selector template. + * ```html + * + * {{ groupByRowContext.selectedCount }} / {{ groupByRowContext.totalCount }} + * + * ``` + * ```typescript + * @ViewChild("'template'", {read: TemplateRef }) + * public template: TemplateRef; + * this.grid.groupByRowSelectorTemplate = this.template; + * ``` + */ + public set groupByRowSelectorTemplate(template: TemplateRef) { + this._groupByRowSelectorTemplate = template; + } + + /** + * @hidden @internal + */ + public getDetailsContext(rowData, index): IgxGridDetailTemplateDirective { + return { + $implicit: rowData, + index + }; + } + + /** + * @hidden @internal + */ + public detailsViewFocused(container, rowIndex) { + this.navigation.setActiveNode({ row: rowIndex }); + } + + /** + * @hidden @internal + */ + public override get hasDetails() { + return !!this.detailTemplate; + } + + /** + * @hidden @internal + */ + public getRowTemplate(rowData) { + if (this.isGroupByRecord(rowData)) { + return this.defaultGroupTemplate; + } else if (this.isSummaryRow(rowData)) { + return this.summaryTemplate; + } else if (this.hasDetails && this.isDetailRecord(rowData)) { + return this.detailTemplateContainer; + } else { + return this.recordTemplate; + } + } + + /** + * @hidden @internal + */ + public override isDetailRecord(record) { + return record && record.detailsData !== undefined; + } + + /** + * @hidden @internal + */ + public isDetailActive(rowIndex) { + return this.navigation.activeNode ? this.navigation.activeNode.row === rowIndex : false; + } + + /** + * Gets/Sets the template reference for the group row. + * + * @example + * ``` + * const groupRowTemplate = this.grid.groupRowTemplate; + * this.grid.groupRowTemplate = myRowTemplate; + * ``` + */ + @Input() + public get groupRowTemplate(): TemplateRef { + return this._groupRowTemplate; + } + + public set groupRowTemplate(template: TemplateRef) { + this._groupRowTemplate = template; + this.notifyChanges(); + } + + /** @hidden @internal */ + public trackChanges: (index, rec) => any; + + /** + * Groups by a new `IgxColumnComponent` based on the provided expression, or modifies an existing one. + * + * @remarks + * Also allows for multiple columns to be grouped at once if an array of `ISortingExpression` is passed. + * The `groupingDone` event would get raised only **once** if this method gets called multiple times with the same arguments. + * @example + * ```typescript + * this.grid.groupBy({ fieldName: name, dir: SortingDirection.Asc, ignoreCase: false }); + * this.grid.groupBy([ + * { fieldName: name1, dir: SortingDirection.Asc, ignoreCase: false }, + * { fieldName: name2, dir: SortingDirection.Desc, ignoreCase: true }, + * { fieldName: name3, dir: SortingDirection.Desc, ignoreCase: false } + * ]); + * ``` + */ + public groupBy(expression: IGroupingExpression | Array): void { + if (this.checkIfNoColumnField(expression)) { + return; + } + this.crudService.endEdit(false); + if (expression instanceof Array) { + this._gridAPI.groupBy_multiple(expression); + } else { + this._gridAPI.groupBy(expression); + } + this.notifyChanges(true); + } + + /** + * Clears grouping for particular column, array of columns or all columns. + * + * @remarks + * Clears all grouping in the grid, if no parameter is passed. + * If a parameter is provided, clears grouping for a particular column or an array of columns. + * @example + * ```typescript + * this.grid.clearGrouping(); //clears all grouping + * this.grid.clearGrouping("ID"); //ungroups a single column + * this.grid.clearGrouping(["ID", "Column1", "Column2"]); //ungroups multiple columns + * ``` + * @param name Name of column or array of column names to be ungrouped. + */ + public clearGrouping(name?: string | Array): void { + this._gridAPI.clear_groupby(name); + this.calculateGridSizes(); + this.notifyChanges(true); + this.groupingPerformedSubject.next(); + } + + /** + * Returns if a group is expanded or not. + * + * @param group The group record. + * @example + * ```typescript + * public groupRow: IGroupByRecord; + * const expandedGroup = this.grid.isExpandedGroup(this.groupRow); + * ``` + */ + public override isExpandedGroup(group: IGroupByRecord): boolean { + const state: IGroupByExpandState = this._getStateForGroupRow(group); + return state ? state.expanded : this.groupsExpanded; + } + + /** + * Toggles the expansion state of a group. + * + * @param groupRow The group record to toggle. + * @example + * ```typescript + * public groupRow: IGroupByRecord; + * const toggleExpGroup = this.grid.toggleGroup(this.groupRow); + * ``` + */ + public toggleGroup(groupRow: IGroupByRecord) { + this._toggleGroup(groupRow); + this.notifyChanges(); + } + + /** + * Select all rows within a group. + * + * @param groupRow: The group record which rows would be selected. + * @param clearCurrentSelection if true clears the current selection + * @example + * ```typescript + * this.grid.selectRowsInGroup(this.groupRow, true); + * ``` + */ + public selectRowsInGroup(groupRow: IGroupByRecord, clearPrevSelection?: boolean) { + this._gridAPI.groupBy_select_all_rows_in_group(groupRow, clearPrevSelection); + this.notifyChanges(); + } + + /** + * Deselect all rows within a group. + * + * @param groupRow The group record which rows would be deselected. + * @example + * ```typescript + * public groupRow: IGroupByRecord; + * this.grid.deselectRowsInGroup(this.groupRow); + * ``` + */ + public deselectRowsInGroup(groupRow: IGroupByRecord) { + this._gridAPI.groupBy_deselect_all_rows_in_group(groupRow); + this.notifyChanges(); + } + + /** + * Expands the specified group and all of its parent groups. + * + * @param groupRow The group record to fully expand. + * @example + * ```typescript + * public groupRow: IGroupByRecord; + * this.grid.fullyExpandGroup(this.groupRow); + * ``` + */ + public fullyExpandGroup(groupRow: IGroupByRecord) { + this._fullyExpandGroup(groupRow); + this.notifyChanges(); + } + + /** + * @hidden @internal + */ + public override isGroupByRecord(record: any): boolean { + // return record.records instance of GroupedRecords fails under Webpack + return record && record?.records && record.records?.length && + record.expression && record.expression?.fieldName; + } + + /** + * Toggles the expansion state of all group rows recursively. + * + * @example + * ```typescript + * this.grid.toggleAllGroupRows; + * ``` + */ + public toggleAllGroupRows() { + this.groupingExpansionState = []; + this.groupsExpanded = !this.groupsExpanded; + this.notifyChanges(); + } + + /** @hidden @internal */ + public get hasGroupableColumns(): boolean { + return this._columns.some((col) => col.groupable && !col.columnGroup); + } + + /** + * Returns whether the `IgxGridComponent` has group area. + * + * @example + * ```typescript + * let isGroupAreaVisible = this.grid.showGroupArea; + * ``` + * + * @example + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public get showGroupArea(): boolean { + return this._showGroupArea; + } + public set showGroupArea(value: boolean) { + this._showGroupArea = value; + this.notifyChanges(true); + } + + /** + * @hidden @internal + */ + public override isColumnGrouped(fieldName: string): boolean { + return this.groupingExpressions.find(exp => exp.fieldName === fieldName) ? true : false; + } + + /** + * @hidden @internal + */ + public getContext(rowData: any, rowIndex: number, pinned?: boolean): any { + if (this.isDetailRecord(rowData)) { + const cachedData = this.childDetailTemplates.get(rowData.detailsData); + const rowID = this.primaryKey ? rowData.detailsData[this.primaryKey] : rowData.detailsData; + if (cachedData) { + const view = cachedData.view; + const tmlpOutlet = cachedData.owner; + return { + $implicit: rowData.detailsData, + moveView: view, + owner: tmlpOutlet, + index: this.dataView.indexOf(rowData), + templateID: { + type: 'detailRow', + id: rowID + } + }; + } else { + // child rows contain unique grids, hence should have unique templates + return { + $implicit: rowData.detailsData, + templateID: { + type: 'detailRow', + id: rowID + }, + index: this.dataView.indexOf(rowData) + }; + } + } + return { + $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), + metaData: this.isRecordMerged(rowData) ? rowData : null + }; + } + + /** + * @hidden @internal + */ + public viewCreatedHandler(args) { + if (args.context.templateID.type === 'detailRow') { + this.childDetailTemplates.set(args.context.$implicit, args); + } + } + + /** + * @hidden @internal + */ + public viewMovedHandler(args) { + if (args.context.templateID.type === 'detailRow') { + // view was moved, update owner in cache + const key = args.context.$implicit; + const cachedData = this.childDetailTemplates.get(key); + cachedData.owner = args.owner; + } + } + + /** + * @hidden @internal + */ + public get iconTemplate() { + if (this.groupsExpanded) { + return this.headerExpandedIndicatorTemplate || this.defaultExpandedTemplate; + } else { + return this.headerCollapsedIndicatorTemplate || this.defaultCollapsedTemplate; + } + } + + /** + * @hidden @internal + */ + public override ngAfterContentInit() { + super.ngAfterContentInit(); + if (this.allowFiltering && this.hasColumnLayouts) { + this.filterMode = FilterMode.excelStyleFilter; + } + if (this.groupTemplate) { + this._groupRowTemplate = this.groupTemplate.template; + } + + if (this.detailTemplateDirective) { + this._detailTemplate = this.detailTemplateDirective; + } + + + if (this.hideGroupedColumns && this._columns && this.groupingExpressions) { + this._setGroupColsVisibility(this.hideGroupedColumns); + } + this._setupNavigationService(); + } + + /** + * @hidden @internal + */ + public override ngAfterViewInit() { + super.ngAfterViewInit(); + this.verticalScrollContainer.beforeViewDestroyed.pipe(takeUntil(this.destroy$)).subscribe((view) => { + const rowData = view.context.$implicit; + if (this.isDetailRecord(rowData)) { + const cachedData = this.childDetailTemplates.get(rowData.detailsData); + if (cachedData) { + const tmlpOutlet = cachedData.owner; + tmlpOutlet._viewContainerRef.detach(0); + } + } + }); + + this.sortingExpressionsChange.pipe(takeUntil(this.destroy$)).subscribe((sortingExpressions: ISortingExpression[]) => { + if (!this.groupingExpressions || !this.groupingExpressions.length) { + return; + } + + sortingExpressions.forEach((sortExpr: ISortingExpression) => { + const fieldName = sortExpr.fieldName; + const groupingExpr = this.groupingExpressions.find(ex => ex.fieldName === fieldName); + if (groupingExpr) { + groupingExpr.dir = sortExpr.dir; + } + }); + }); + } + + /** + * @hidden @internal + */ + public override ngOnInit() { + super.ngOnInit(); + this.trackChanges = (_, rec) => (rec?.detailsData !== undefined ? rec.detailsData : rec); + this.groupingDone.pipe(takeUntil(this.destroy$)).subscribe((args) => { + this.crudService.endEdit(false); + this.summaryService.updateSummaryCache(args); + this._headerFeaturesWidth = NaN; + }); + } + + /** + * @hidden @internal + */ + public override ngDoCheck(): void { + if (this.groupingDiffer && this._columns && !this.hasColumnLayouts) { + const changes = this.groupingDiffer.diff(this.groupingExpressions); + if (changes && this._columns.length > 0) { + changes.forEachAddedItem((rec) => { + const col = this.getColumnByName(rec.item.fieldName); + if (col) { + col.hidden = true; + } + }); + changes.forEachRemovedItem((rec) => { + const col = this.getColumnByName(rec.item.fieldName); + col.hidden = false; + }); + } + } + super.ngDoCheck(); + } + + /** + * @hidden @internal + */ + public dataLoading(event) { + this.dataPreLoad.emit(event); + } + + /** + * + * Returns an array of the current cell selection in the form of `[{ column.field: cell.value }, ...]`. + * + * @remarks + * If `formatters` is enabled, the cell value will be formatted by its respective column formatter (if any). + * If `headers` is enabled, it will use the column header (if any) instead of the column field. + */ + public override getSelectedData(formatters = false, headers = false): any[] { + if (this.groupingExpressions.length || this.hasDetails) { + const source = []; + + const process = (record) => { + if (record.expression || record.summaries || this.isDetailRecord(record)) { + source.push(null); + return; + } + source.push(record); + + }; + + this.dataView.forEach(process); + return this.extractDataFromSelection(source, formatters, headers); + } else { + return super.getSelectedData(formatters, headers); + } + } + + /** + * Returns the `IgxGridRow` by index. + * + * @example + * ```typescript + * const myRow = grid.getRowByIndex(1); + * ``` + * @param index + */ + public getRowByIndex(index: number): RowType { + let row: RowType; + if (index < 0) { + return undefined; + } + if (this.dataView.length >= this.virtualizationState.startIndex + this.virtualizationState.chunkSize) { + row = this.createRow(index); + } else { + if (!(index < this.virtualizationState.startIndex) && !(index > this.virtualizationState.startIndex + this.virtualizationState.chunkSize)) { + row = this.createRow(index); + } + } + + if (this.pagingMode === 'remote' && this.page !== 0) { + row.index = index + this.perPage * this.page; + } + return row; + } + + /** + * Returns `IgxGridRow` object by the specified primary key. + * + * @remarks + * Requires that the `primaryKey` property is set. + * @example + * ```typescript + * const myRow = this.grid1.getRowByKey("cell5"); + * ``` + * @param keyValue + */ + public getRowByKey(key: any): RowType { + const rec = this.filteredSortedData ? this.primaryKey ? + this.filteredSortedData.find(record => record[this.primaryKey] === key) : + this.filteredSortedData.find(record => record === key) : undefined; + const index = this.dataView.indexOf(rec); + if (index < 0 || index > this.dataView.length) { + return undefined; + } + + return new IgxGridRow(this, index, rec); + } + + /** + * @hidden @internal + */ + public allRows(): RowType[] { + return this.dataView.map((rec, index) => { + this.pagingMode === 'remote' && this.page !== 0 ? + index = index + this.perPage * this.page : index = this.dataRowList.first.index + index; + return this.createRow(index); + }); + } + + /** + * Returns the collection of `IgxGridRow`s for current page. + * + * @hidden @internal + */ + public dataRows(): RowType[] { + return this.allRows().filter(row => row instanceof IgxGridRow); + } + + /** + * Returns an array of the selected `IgxGridCell`s. + * + * @example + * ```typescript + * const selectedCells = this.grid.selectedCells; + * ``` + */ + public get selectedCells(): CellType[] { + return this.dataRows().map((row) => row.cells.filter((cell) => cell.selected)) + .reduce((a, b) => a.concat(b), []); + } + + /** + * Returns a `CellType` object that matches the conditions. + * + * @example + * ```typescript + * const myCell = this.grid1.getCellByColumn(2, "UnitPrice"); + * ``` + * @param rowIndex + * @param columnField + */ + public getCellByColumn(rowIndex: number, columnField: string): CellType { + const row = this.getRowByIndex(rowIndex); + const column = this._columns.find((col) => col.field === columnField); + if (row && row instanceof IgxGridRow && !row.data?.detailsData && column) { + if (this.pagingMode === 'remote' && this.page !== 0) { + row.index = rowIndex + this.perPage * this.page; + } + return new IgxGridCell(this, row.index, column); + } + } + + /** + * Returns a `CellType` object that matches the conditions. + * + * @remarks + * Requires that the primaryKey property is set. + * @example + * ```typescript + * grid.getCellByKey(1, 'index'); + * ``` + * @param rowSelector match any rowID + * @param columnField + */ + public getCellByKey(rowSelector: any, columnField: string): CellType { + const row = this.getRowByKey(rowSelector); + const column = this._columns.find((col) => col.field === columnField); + if (row && column) { + return new IgxGridCell(this, row.index, column); + } + } + + public override pinRow(rowID: any, index?: number): boolean { + const row = this.getRowByKey(rowID); + return super.pinRow(rowID, index, row); + } + + public override unpinRow(rowID: any): boolean { + const row = this.getRowByKey(rowID); + return super.unpinRow(rowID, row); + } + + /** + * @hidden @internal + */ + public createRow(index: number, data?: any): RowType { + let row: RowType; + + const dataIndex = this._getDataViewIndex(index); + const rec = data ?? this.dataView[dataIndex]; + + if (rec && this.isGroupByRecord(rec)) { + row = new IgxGroupByRow(this, index, rec); + } + if (rec && this.isSummaryRow(rec)) { + row = new IgxSummaryRow(this, index, rec.summaries); + } + // if found record is a no a groupby or summary row, return IgxGridRow instance + if (!row && rec) { + row = new IgxGridRow(this, index, rec); + } + + return row; + } + + /** + * @hidden @internal + */ + protected override get defaultTargetBodyHeight(): number { + const allItems = this.totalItemCount || this.dataLength; + return this.renderedActualRowHeight * Math.min(this._defaultTargetRecordNumber, + this.paginator ? Math.min(allItems, this.perPage) : allItems); + } + + /** + * @hidden @internal + */ + protected override getGroupAreaHeight(): number { + return this.groupArea ? this.getComputedHeight(this.groupArea.nativeElement) : 0; + } + + /** + * @hidden @internal + */ + protected override onColumnsAddedOrRemoved() { + // update grouping states + this.groupablePipeTrigger++; + if (this.groupingExpressions && this.hideGroupedColumns) { + this._setGroupColsVisibility(this.hideGroupedColumns); + } + super.onColumnsAddedOrRemoved(); + } + + /** + * @hidden + */ + protected override onColumnsChanged(change: QueryList) { + super.onColumnsChanged(change); + + if (this.hasColumnLayouts && !(this.navigation instanceof IgxGridMRLNavigationService)) { + this._setupNavigationService(); + } + } + + /** + * @hidden @internal + */ + protected override scrollTo(row: any | number, column: any | number): void { + if (this.groupingExpressions && this.groupingExpressions.length + && typeof (row) !== 'number') { + const rowIndex = this.groupingResult.indexOf(row); + const groupByRecord = this.groupingMetadata[rowIndex]; + if (groupByRecord) { + this._fullyExpandGroup(groupByRecord); + } + } + + super.scrollTo(row, column, this.groupingFlatResult); + } + + /** + * @hidden @internal + */ + protected _getStateForGroupRow(groupRow: IGroupByRecord): IGroupByExpandState { + return this._gridAPI.groupBy_get_expanded_for_group(groupRow); + } + + /** + * @hidden + */ + protected _toggleGroup(groupRow: IGroupByRecord) { + this._gridAPI.groupBy_toggle_group(groupRow); + } + + /** + * @hidden @internal + */ + protected _fullyExpandGroup(groupRow: IGroupByRecord) { + this._gridAPI.groupBy_fully_expand_group(groupRow); + } + + /** + * @hidden @internal + */ + protected _applyGrouping() { + this._gridAPI.sort_groupBy_multiple(this._groupingExpressions); + } + + protected _setupNavigationService() { + if (this.hasColumnLayouts) { + this.navigation = this.injector.get(IgxGridMRLNavigationService); + this.navigation.grid = this; + } + } + + private checkIfNoColumnField(expression: IGroupingExpression | Array | any): boolean { + if (expression instanceof Array) { + for (const singleExpression of expression) { + if (!singleExpression.fieldName) { + return true; + } + } + return false; + } + return !expression.fieldName; + } + + private _setGroupColsVisibility(value) { + if (this._columns.length > 0 && !this.hasColumnLayouts) { + this.groupingExpressions.forEach((expr) => { + const col = this.getColumnByName(expr.fieldName); + col.hidden = value; + }); + } + } + + private stringifyCallback(key: string, val: any) { + // Workaround for Blazor, since its wrappers inject this externalObject that cannot serialize. + if (key === 'externalObject') { + return undefined; + } + return val; + } +} diff --git a/projects/igniteui-angular/grids/grid/src/grid.crud.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.crud.spec.ts new file mode 100644 index 00000000000..a1e0ee794bf --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid.crud.spec.ts @@ -0,0 +1,367 @@ +import { Component, ViewChild } from '@angular/core'; +import { TestBed, fakeAsync, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { IgxGridComponent } from './grid.component'; +import { wait } from '../../../test-utils/ui-interactions.spec'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IGridEditEventArgs } from 'igniteui-angular/grids/core'; + +const CELL_CSS_CLASS = '.igx-grid__td'; + +describe('IgxGrid - CRUD operations #grid', () => { + let fix; + let grid; + let data; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, DefaultCRUDGridComponent + ] + }).compileComponents(); + })); + + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(DefaultCRUDGridComponent); + fix.detectChanges(); + grid = fix.componentInstance.instance; + data = fix.componentInstance.data; + })); + + it('should support adding rows through the grid API', () => { + let expectedLength = 1; + expect(grid.data.length).toEqual(expectedLength); + expect(grid.rowList.length).toEqual(expectedLength); + + for (let i = 0; i < 10; i++) { + grid.addRow({ index: i, value: i}); + } + fix.detectChanges(); + + expectedLength = 11; + expect(grid.data.length).toEqual(expectedLength); + expect(grid.rowList.length).toEqual(expectedLength); + }); + + it('should support adding rows by manipulating the `data` @Input of the grid', () => { + // Add to the data array without changing the reference + // with manual detection + for (let i = 0; i < 10; i++) { + fix.componentInstance.data.push({ index: i, value: i}); + } + + grid.cdr.markForCheck(); + fix.detectChanges(); + + expect(grid.data.length).toEqual(fix.componentInstance.data.length); + expect(grid.rowList.length).toEqual(fix.componentInstance.data.length); + + // Add to the data array with changing the reference + // without manual detection + + for (let i = 0; i < 10; i++) { + fix.componentInstance.data.push({ index: i, value: i}); + } + fix.componentInstance.data = fix.componentInstance.data.slice(); + fix.detectChanges(); + + expect(grid.data.length).toEqual(fix.componentInstance.data.length); + expect(grid.rowList.length).toEqual(fix.componentInstance.data.length); + }); + + it('should support deleting rows through the grid API', () => { + grid.deleteRow(1); + fix.detectChanges(); + + let expectedLength = 0; + expect(grid.data.length).toEqual(expectedLength); + expect(data.length).toEqual(expectedLength); + expect(grid.rowList.length).toEqual(expectedLength); + + for (let i = 0; i < 10; i++) { + grid.addRow({ index: i, value: i}); + } + fix.detectChanges(); + + // Delete first and last rows + grid.deleteRow(grid.rowList.first.index); + grid.deleteRow(grid.rowList.last.index); + + fix.detectChanges(); + + expectedLength = 8; + expect(grid.data.length).toEqual(expectedLength); + expect(grid.rowList.length).toEqual(expectedLength); + + expect(grid.rowList.first.cells.first.value).toEqual(1); + expect(grid.rowList.last.cells.first.value).toEqual(8); + + // Try to delete a non-existant row + grid.deleteRow(-1e7); + fix.detectChanges(); + + expect(grid.data.length).toEqual(8); + }); + + it('should support removing rows by manipulating the `data` @Input of the grid', () => { + // Remove from the data array without changing the reference + // with manual detection + fix.componentInstance.data.pop(); + grid.cdr.markForCheck(); + fix.detectChanges(); + + expect(grid.data.length).toEqual(0); + expect(grid.rowList.length).toEqual(0); + + for (let i = 0; i < 10; i++) { + fix.componentInstance.data.push({ index: i, value: i}); + } + fix.componentInstance.data = fix.componentInstance.data.slice(); + fix.detectChanges(); + + expect(grid.data.length).toEqual(fix.componentInstance.data.length); + expect(grid.rowList.length).toEqual(fix.componentInstance.data.length); + + // Remove from the data array with changing the reference + // without manual detection + fix.componentInstance.data.splice(0, 5); + fix.componentInstance.data = fix.componentInstance.data.slice(); + fix.detectChanges(); + + expect(grid.data.length).toEqual(5); + expect(grid.rowList.length).toEqual(5); + expect(grid.rowList.first.cells.first.value).toEqual(5); + }); + + it('should support updating a row through the grid API', () => { + // Update non-existing row + grid.updateRow({ index: -100, value: -100 }, 100); + fix.detectChanges(); + + expect(grid.rowList.first.cells.first.value).not.toEqual(-100); + expect(grid.data[0].index).not.toEqual(-100); + + const newValue = { index: 200, value: 200 }; + // Update an existing row + grid.updateRow(newValue, 1); + fix.detectChanges(); + + expect(grid.rowList.first.cells.first.value).toEqual(200); + expect(grid.data[0].index).toEqual(200); + }); + + it('should support updating a row through the grid API', () => { + grid.updateRow({ index: 777, value: 777 }, 1, 'index'); + fix.detectChanges(); + + expect(grid.rowList.first.cells.first.value).toEqual(777); + expect(grid.data[0].index).toEqual(777); + }); + + it('should support updating a cell value through the grid API', () => { + // Update a non-existing cell + grid.updateCell(-100, 100, 'index'); + fix.detectChanges(); + + expect(grid.rowList.first.cells.first.value).not.toEqual(-100); + expect(grid.rowList.first.cells.first.nativeElement.textContent).not.toMatch('-100'); + + // Update an existing cell + grid.updateCell(200, 1, 'index'); + fix.detectChanges(); + + expect(grid.rowList.first.cells.first.value).toEqual(200); + expect(grid.rowList.first.cells.first.nativeElement.textContent).toMatch('200'); + }); + + it('should support updating a cell value through the cell object', () => { + const firstCell = grid.getCellByColumn(0, 'index'); + firstCell.update(100); + + fix.detectChanges(); + + expect(grid.rowList.first.cells.first.value).toEqual(100); + expect(grid.rowList.first.cells.first.nativeElement.textContent).toMatch('100'); + }); + + it('should update row through row object when PK is defined', () => { + let firstRow = grid.getRowByKey(1); + firstRow.update({ index: 31, value: 51}); + + fix.detectChanges(); + firstRow = grid.getRowByKey(31); + expect(firstRow).toBeDefined(); + const firstCell = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[0]; + const secondCell = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[1]; + expect(parseInt(firstCell.nativeElement.innerText, 10)).toBe(31); + expect(parseInt(secondCell.nativeElement.innerText, 10)).toBe(51); + }); + + it('should update row through row object when PK is NOT defined', () => { + grid.primaryKey = null; + fix.detectChanges(); + expect(grid.primaryKey).toBeNull(); + let firstRow = grid.getRowByIndex(0); + firstRow.update({ index: 100, value: 99}); + + fix.detectChanges(); + firstRow = grid.getRowByIndex(0); + expect(firstRow).toBeDefined(); + const firstCell = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[0]; + const secondCell = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[1]; + expect(parseInt(firstCell.nativeElement.innerText, 10)).toBe(100); + expect(parseInt(secondCell.nativeElement.innerText, 10)).toBe(99); + }); + + it('should delete row through row object when PK is defined', () => { + let firstRow = grid.getRowByKey(1); + firstRow.delete(); + fix.detectChanges(); + firstRow = grid.getRowByKey(1); + expect(firstRow).toBeUndefined(); + expect(grid.rowList.length).toBe(0); + }); + + it('should delete row through row object when PK is defined and there is cell in edit mode', () => { + const indexColumn = grid.getColumnByName('index'); + indexColumn.editable = true; + fix.detectChanges(); + const cell = grid.getCellByKey(1, 'index'); + const cellDom = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[0]; + fix.detectChanges(); + cellDom.triggerEventHandler('dblclick', {}); + fix.detectChanges(); + expect(cell.editMode).toBe(true); + grid.deleteRow(1); + fix.detectChanges(); + const firstRow = grid.getRowByKey(1); + expect(firstRow).toBeUndefined(); + expect(grid.rowList.length).toBe(0); + }); + + it('should delete row through row object when PK is NOT defined', () => { + grid.primaryKey = null; + fix.detectChanges(); + expect(grid.primaryKey).toBeNull(); + let firstRow = grid.getRowByIndex(0); + firstRow.delete(); + fix.detectChanges(); + firstRow = grid.getRowByIndex(0); + expect(firstRow).toBeUndefined(); + expect(grid.rowList.length).toBe(0); + }); + + it('should delete row through row object when PK is NOT defined and there is cell in edit mode', () => { + const indexColumn = grid.getColumnByName('index'); + indexColumn.editable = true; + grid.primaryKey = null; + fix.detectChanges(); + expect(grid.primaryKey).toBeNull(); + const cell = grid.getCellByColumn(0, 'index'); + const cellDom = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[0]; + let firstRow; + fix.detectChanges(); + cellDom.triggerEventHandler('dblclick', {}); + fix.detectChanges(); + expect(cell.editMode).toBe(true); + firstRow = grid.getRowByIndex(0); + firstRow.delete(); + fix.detectChanges(); + firstRow = grid.getRowByIndex(0); + expect(firstRow).toBeUndefined(); + expect(grid.rowList.length).toBe(0); + }); + + it('should be able to updateRow when PK is defined outside displayContainer', async () => { + grid.height = '250px'; + await wait(50); + fix.detectChanges(); + const rowID = 9; + const sampleData = [{ index: 2, value: 2}, + { index: 3, value: 3}, { index: 4, value: 4}, { index: 5, value: 5}, + { index: 6, value: 6}, { index: 7, value: 7}, { index: 8, value: 8}, + { index: 9, value: 9}, { index: 10, value: 10}, { index: 11, value: 11}]; + sampleData.forEach((record) => grid.addRow(record)); + fix.detectChanges(); + expect(grid.data.length).toBe(11); + + grid.updateRow({ index: 97, value: 87}, rowID); + fix.detectChanges(); + expect(grid.data.map((record) => record[grid.primaryKey]).indexOf(rowID)).toBe(-1); + expect(grid.data[grid.data.map((record) => record[grid.primaryKey]).indexOf(97)]).toBeDefined(); + }); + + it('should be able to deleteRow when PK is defined outside displayContainer', async () => { + grid.height = '250px'; + await wait(50); + fix.detectChanges(); + const rowID = 9; + const sampleData = [{ index: 2, value: 2}, + { index: 3, value: 3}, { index: 4, value: 4}, { index: 5, value: 5}, + { index: 6, value: 6}, { index: 7, value: 7}, { index: 8, value: 8}, + { index: 9, value: 9}, { index: 10, value: 10}, { index: 11, value: 11}]; + sampleData.forEach((record) => grid.addRow(record)); + fix.detectChanges(); + expect(grid.data.length).toBe(11); + + grid.deleteRow(rowID); + fix.detectChanges(); + expect(grid.data.map((record) => record[grid.primaryKey]).indexOf(rowID)).toBe(-1); + expect(grid.data.length).toBe(10); + }); + + it('should be able to updateCell when PK is defined outside displayContainer', async () => { + grid.height = '250px'; + await wait(50); + fix.detectChanges(); + const rowID = 9; + const columnName = 'value'; + const sampleData = [{ index: 2, value: 2}, + { index: 3, value: 3}, { index: 4, value: 4}, { index: 5, value: 5}, + { index: 6, value: 6}, { index: 7, value: 7}, { index: 8, value: 8}, + { index: 9, value: 9}, { index: 10, value: 10}, { index: 11, value: 11}]; + sampleData.forEach((record) => grid.addRow(record)); + fix.detectChanges(); + + const cell = grid.getCellByKey(rowID, columnName); + expect(grid.data.length).toBe(11); + expect(cell.row.index).toBe(8); + expect(cell.row.key).toBe(rowID); + expect(cell.column.field).toBe(columnName); + + grid.updateCell( 97, rowID, columnName); + fix.detectChanges(); + const row = grid.data[grid.data.map((record) => record[grid.primaryKey]).indexOf(rowID)]; + expect(row).toBeDefined(); + expect(row['index']).toBe(rowID); + expect(row['value']).toBe(97); + }); +}); + +@Component({ + template: ` + + + `, + imports: [IgxGridComponent] +}) +export class DefaultCRUDGridComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public instance: IgxGridComponent; + + public data = [ + { index: 1, value: 1} + ]; + + public editDone(event: IGridEditEventArgs) { + if (event.newValue === 666) { + event.newValue = event.cellID ? 777 : { index: 777, value: 777 }; + } + } +} diff --git a/projects/igniteui-angular/grids/grid/src/grid.details.pipe.ts b/projects/igniteui-angular/grids/grid/src/grid.details.pipe.ts new file mode 100644 index 00000000000..9271eb1b6ec --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid.details.pipe.ts @@ -0,0 +1,33 @@ +import { PipeTransform, Pipe, inject } from '@angular/core'; +import { GridType, IGX_GRID_BASE } from 'igniteui-angular/grids/core'; + +/** @hidden */ +@Pipe({ + name: 'gridDetails', + standalone: true +}) +export class IgxGridDetailsPipe implements PipeTransform { + private grid = inject(IGX_GRID_BASE); + + + public transform(collection: any[], hasDetails: boolean, expansionStates: Map, _pipeTrigger: number) { + if (!hasDetails) { + return collection; + } + const res = this.addDetailRows(collection, expansionStates); + return res; + } + + protected addDetailRows(collection: any[], _expansionStates: Map) { + const result = []; + collection.forEach((v) => { + result.push(v); + if (!this.grid.isGroupByRecord(v) && !this.grid.isSummaryRow(v) && + this.grid.gridAPI.get_row_expansion_state(v)) { + const detailsObj = { detailsData: v }; + result.push(detailsObj); + } + }); + return result; + } +} diff --git a/projects/igniteui-angular/grids/grid/src/grid.groupby.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.groupby.spec.ts new file mode 100644 index 00000000000..0087c32f4db --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid.groupby.spec.ts @@ -0,0 +1,4328 @@ +import { Component, ViewChild, TemplateRef, QueryList } from '@angular/core'; +import { formatNumber } from '@angular/common' +import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxColumnComponent, IgxGridStateDirective } from 'igniteui-angular/grids/core'; +import { IgxGridComponent } from './grid.component'; +import { IgxGroupAreaDropDirective, IgxGroupByRowTemplateDirective, IgxHeaderCollapsedIndicatorDirective, IgxHeaderExpandedIndicatorDirective, IgxRowCollapsedIndicatorDirective, IgxRowExpandedIndicatorDirective } from 'igniteui-angular/grids/core'; +import { IgxColumnMovingDragDirective } from 'igniteui-angular/grids/core'; +import { IgxGridRowComponent } from './grid-row.component'; +import { wait, UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { DataParent, SampleTestData } from '../../../test-utils/sample-test-data.spec'; +import { MultiColumnHeadersWithGroupingComponent } from '../../../test-utils/grid-samples.spec'; +import { GridSelectionFunctions, GridFunctions, GRID_SCROLL_CLASS } from '../../../test-utils/grid-functions.spec'; +import { GridSelectionMode } from 'igniteui-angular/grids/core'; +import { ControlsFunction } from '../../../test-utils/controls-functions.spec'; +import { ymd } from '../../../test-utils/helper-utils.spec'; +import { IgxGroupByRowSelectorDirective } from 'igniteui-angular/grids/core'; +import { DefaultSortingStrategy, IGroupingExpression, IgxGrouping, IgxStringFilteringOperand, ISortingExpression, SortingDirection } from 'igniteui-angular/core'; +import { IgxChipComponent } from 'igniteui-angular/chips'; +import { IgxPaginatorComponent } from 'igniteui-angular/paginator'; +import { IgxCheckboxComponent } from 'igniteui-angular/checkbox'; + +describe('IgxGrid - GroupBy #grid', () => { + + const COLUMN_HEADER_CLASS = '.igx-grid-th'; + const COLUMN_HEADER_GROUP_CLASS = '.igx-grid-thead__item'; + const GRID_RESIZE_CLASS = '.igx-grid-th__resize-line'; + const SORTING_ICON_ASC_CONTENT = 'arrow_upward'; + const DISABLED_CHIP = 'igx-chip--disabled'; + const CHIP = 'igx-chip'; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + DefaultGridComponent, + GroupableGridComponent, + CustomTemplateGridComponent, + GroupByDataMoreColumnsComponent, + GroupByEmptyColumnFieldComponent, + GridGroupByRowCustomSelectorsComponent, + GridGroupByCaseSensitiveComponent, + GridGroupByTestDateTimeDataComponent, + GridGroupByStateComponent, + MultiColumnHeadersWithGroupingComponent + ] + }).compileComponents(); + })); + + const checkGroups = (groupRows, expectedGroupOrder, grExpr?) => { + // verify group rows are sorted correctly, their indexes in the grid are correct and their group records match the group value. + let count = 0; + const maxLevel = grExpr ? grExpr.length - 1 : 0; + for (const groupRow of groupRows) { + const recs = groupRow.groupRow.records; + const val = groupRow.groupRow.value; + const index = groupRow.index; + const field = groupRow.groupRow.expression.fieldName; + const level = groupRow.groupRow.level; + expect(level).toEqual(grExpr ? grExpr.indexOf(groupRow.groupRow.expression) : 0); + expect(index).toEqual(count); + count++; + expect(val).toEqual(expectedGroupOrder[groupRows.indexOf(groupRow)]); + for (const rec of recs) { + if (level === maxLevel) { + count++; + } + if (groupRow.groupRow.expression.ignoreCase) { + expect(rec[field]?.toString().toLowerCase()).toEqual(val?.toString().toLowerCase()); + } else { + expect(rec[field]).toEqual(val); + } + } + } + }; + + const checkChips = (chips: QueryList, grouping: IGroupingExpression[]) => { + chips.forEach((chip, index) => { + const content = chip.nativeElement.querySelector('.igx-chip__content').textContent.trim(); + const icon = chip.nativeElement.querySelector('[igxsuffix]').textContent.trim(); + + expect(content).toBe(grouping[index].fieldName); + + if (icon === SORTING_ICON_ASC_CONTENT) { + expect(grouping[index].dir).toBe(SortingDirection.Asc); + } else { + expect(grouping[index].dir).toBe(SortingDirection.Desc); + } + }); + }; + + it('should allow grouping by different data types.', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + fix.detectChanges(); + + // group by string column + const grid = fix.componentInstance.instance; + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + + // verify grouping expressions + const grExprs = grid.groupingExpressions; + expect(grExprs.length).toEqual(1); + expect(grExprs[0].fieldName).toEqual('ProductName'); + + // verify rows + let groupRows = grid.groupsRowList.toArray(); + let dataRows = grid.dataRowList.toArray(); + + expect(groupRows.length).toEqual(5); + expect(dataRows.length).toEqual(8); + + checkGroups(groupRows, ['NetAdvantage', 'Ignite UI for JavaScript', 'Ignite UI for Angular', '', null]); + // ungroup + grid.clearGrouping('ProductName'); + tick(); + + fix.detectChanges(); + + // verify no groups are present + expect(grid.groupsRowList.toArray().length).toEqual(0); + + // group by number + grid.groupBy({ + fieldName: 'Downloads', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + + groupRows = grid.groupsRowList.toArray(); + dataRows = grid.dataRowList.toArray(); + + expect(groupRows.length).toEqual(6); + expect(dataRows.length).toEqual(8); + + checkGroups(groupRows, [1000, 254, 100, 20, 0, null]); + + // ungroup and group by boolean column + grid.clearGrouping('Downloads'); + tick(); + fix.detectChanges(); + grid.groupBy({ fieldName: 'Released', dir: SortingDirection.Desc, ignoreCase: false }); + fix.detectChanges(); + + groupRows = grid.groupsRowList.toArray(); + dataRows = grid.dataRowList.toArray(); + + expect(groupRows.length).toEqual(3); + expect(dataRows.length).toEqual(8); + + checkGroups(groupRows, [true, false, null]); + + // ungroup and group by date column + grid.clearGrouping('Released'); + tick(); + fix.detectChanges(); + grid.groupBy({ + fieldName: 'ReleaseDate', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + + groupRows = grid.groupsRowList.toArray(); + dataRows = grid.dataRowList.toArray(); + + expect(groupRows.length).toEqual(4); + expect(dataRows.length).toEqual(8); + + const expectedValue1 = groupRows[1].nativeElement.nextElementSibling.querySelectorAll('igx-grid-cell')[3].textContent; + const actualValue1 = groupRows[1].element.nativeElement.querySelector('.igx-group-label__text').textContent; + const expectedValue2 = groupRows[2].nativeElement.nextElementSibling.querySelectorAll('igx-grid-cell')[3].textContent; + const actualValue2 = groupRows[2].element.nativeElement.querySelector('.igx-group-label__text').textContent; + const expectedValue3 = groupRows[3].nativeElement.nextElementSibling.querySelectorAll('igx-grid-cell')[3].textContent; + const actualValue3 = groupRows[3].element.nativeElement.querySelector('.igx-group-label__text').textContent; + + expect(actualValue1).toEqual(expectedValue1); + expect(actualValue2).toEqual(expectedValue2); + expect(actualValue3).toEqual(expectedValue3); + + checkGroups( + groupRows, + [null, fix.componentInstance.prevDay, fix.componentInstance.today, fix.componentInstance.nextDay]); + })); + + it('should only account for year, month and day when grouping by \'date\' dataType column', fakeAsync(() => { + const fix = TestBed.createComponent(GridGroupByTestDateTimeDataComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'DateField', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + + const groupRows = grid.groupsRowList.toArray(); + expect(groupRows.length).toEqual(3); + + const targetTestVal = new Date(2012, 1, 12); + const index = groupRows.findIndex(gr => new Date(gr.groupRow.value).getTime() === targetTestVal.getTime()); + expect(groupRows[index].groupRow.records.length).toEqual(2); + + // compare the date values in the target group which are two identical dates with different time values + const field = groupRows[index].groupRow.expression.fieldName; + const record1 = groupRows[index].groupRow.records[0]; + const record2 = groupRows[index].groupRow.records[1]; + const rec1Date = new Date(record1[field]); + const rec2Date = new Date(record2[field]); + + // the time portions of the two records differ, so the Dates are not equal even though they are in the same group + expect(rec1Date.getTime()).not.toEqual(rec2Date.getTime()); + // the date portions are the same, so they are in the same group, as the column type is `date` + expect(rec1Date.getDate()).toEqual(rec2Date.getDate()); + expect(rec1Date.getMonth()).toEqual(rec2Date.getMonth()); + expect(rec1Date.getFullYear()).toEqual(rec2Date.getFullYear()); + })); + + it('should only account for hours, minutes, seconds and ms when grouping by \'time\' dataType column', fakeAsync(() => { + const fix = TestBed.createComponent(GridGroupByTestDateTimeDataComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + + grid.groupBy({ + fieldName: 'TimeField', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + + const groupRows = grid.groupsRowList.toArray(); + expect(groupRows.length).toEqual(3); + + const targetTestVal = new Date(new Date().setHours(3, 20, 0, 1)); + const index = groupRows.findIndex(gr => new Date(gr.groupRow.value).getHours() === targetTestVal.getHours() + && new Date(gr.groupRow.value).getMinutes() === targetTestVal.getMinutes() + && new Date(gr.groupRow.value).getSeconds() === targetTestVal.getSeconds() + && new Date(gr.groupRow.value).getMilliseconds() === targetTestVal.getMilliseconds()); + + expect(groupRows[index].groupRow.records.length).toEqual(3); + + // compare the date values in the target group which are three different dates with same time values + const field = groupRows[index].groupRow.expression.fieldName; + const record1 = groupRows[index].groupRow.records[0]; + const record2 = groupRows[index].groupRow.records[1]; + const record3 = groupRows[index].groupRow.records[2]; + const rec1Date = new Date(record1[field]); + const rec2Date = new Date(record2[field]); + const rec3Date = new Date(record3[field]); + + // the date portions of the following records differ, so the Dates are not equal even though they are in the same group + expect(rec1Date.getTime()).not.toEqual(rec2Date.getTime()); + // the below are equal by date as well + expect(rec2Date.getTime()).toEqual(rec3Date.getTime()); + // the time portions of the not equal dates are the same, so they are in the same group, as the column type is `time` + expect(rec1Date.getHours()).toEqual(rec2Date.getHours()); + expect(rec1Date.getMinutes()).toEqual(rec2Date.getMinutes()); + expect(rec1Date.getSeconds()).toEqual(rec2Date.getSeconds()); + expect(rec1Date.getMilliseconds()).toEqual(rec2Date.getMilliseconds()); + })); + + it('should account for all date values when grouping by \'dateTime\' dataType column', fakeAsync(() => { + const fix = TestBed.createComponent(GridGroupByTestDateTimeDataComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + + grid.groupBy({ + fieldName: 'DateTimeField', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + + // there are two identical DateTime values, so 4 groups out of 5 data records + const groupRows = grid.groupsRowList.toArray(); + expect(groupRows.length).toEqual(4); + + const targetTestVal = new Date(ymd('2003-03-17').setHours(3, 20, 0, 1)); + const index = groupRows.findIndex(gr => new Date(gr.groupRow.value).getTime() === targetTestVal.getTime()); + expect(groupRows[index].groupRow.records.length).toEqual(2); + + // compare the date values in the target group which are two identical dates - date and time portions + const field = groupRows[index].groupRow.expression.fieldName; + const record1 = groupRows[index].groupRow.records[0]; + const record2 = groupRows[index].groupRow.records[1]; + const rec1Date = new Date(record1[field]); + const rec2Date = new Date(record2[field]); + + expect(rec1Date.getTime()).toEqual(rec2Date.getTime()); + })); + + it('should display time value in the group by row when grouped by a \'time\' column', fakeAsync(() => { + const fix = TestBed.createComponent(GridGroupByTestDateTimeDataComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + + grid.groupBy({ + fieldName: 'TimeField', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + + const groupRows = grid.groupsRowList.toArray(); + const expectedValue1 = groupRows[0].nativeElement.nextElementSibling.querySelectorAll('igx-grid-cell')[3].textContent; + const actualValue1 = groupRows[0].element.nativeElement.querySelector('.igx-group-label__text').textContent; + expect(expectedValue1).toEqual(actualValue1); + })); + + it('should display time value in the group by row when grouped by a \'dateTime\' column', fakeAsync(() => { + const fix = TestBed.createComponent(GridGroupByTestDateTimeDataComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + + grid.groupBy({ + fieldName: 'DateTimeField', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + + const groupRows = grid.groupsRowList.toArray(); + const expectedValue1 = groupRows[0].nativeElement.nextElementSibling.querySelectorAll('igx-grid-cell')[4].textContent; + const actualValue1 = groupRows[0].element.nativeElement.querySelector('.igx-group-label__text').textContent; + expect(expectedValue1).toEqual(actualValue1); + })); + + it('should allow grouping by multiple columns.', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + fix.detectChanges(); + fix.componentInstance.height = null; + tick(); + fix.detectChanges(); + + // group by 2 columns + const grid = fix.componentInstance.instance; + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + grid.groupBy({ fieldName: 'Released', dir: SortingDirection.Desc, ignoreCase: false }); + fix.detectChanges(); + + let groupRows = grid.groupsRowList.toArray(); + let dataRows = grid.dataRowList.toArray(); + + // verify groups and data rows count + expect(groupRows.length).toEqual(13); + expect(dataRows.length).toEqual(8); + // verify groups + checkGroups(groupRows, + ['NetAdvantage', true, false, 'Ignite UI for JavaScript', true, + false, 'Ignite UI for Angular', false, null, '', true, null, true], + grid.groupingExpressions); + + // group by 3rd column + + grid.groupBy({ + fieldName: 'Downloads', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + + groupRows = grid.groupsRowList.toArray(); + dataRows = grid.dataRowList.toArray(); + + // verify groups and data rows count + expect(groupRows.length).toEqual(21); + expect(dataRows.length).toEqual(8); + // verify groups + checkGroups(groupRows, + ['NetAdvantage', true, 1000, false, 1000, 'Ignite UI for JavaScript', true, null, false, 254, 'Ignite UI for Angular', + false, 20, null, 1000, '', true, 100, null, true, 0], + grid.groupingExpressions); + })); + + it('should allow grouping with a custom comparer', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + fix.detectChanges(); + fix.componentInstance.data[0].ReleaseDate = new Date(2017, 1, 1, 15, 30, 0, 0); + fix.componentInstance.data[1].ReleaseDate = new Date(2017, 1, 1, 20, 30, 0, 0); + fix.componentInstance.height = null; + const grid = fix.componentInstance.instance; + fix.detectChanges(); + grid.groupBy({ + fieldName: 'ReleaseDate', + dir: SortingDirection.Desc, + groupingComparer: (a: Date, b: Date) => { + if (a instanceof Date && b instanceof Date && + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate()) { + return 0; + } + return DefaultSortingStrategy.instance().compareValues(a, b); + } + }); + tick(); + fix.detectChanges(); + let groupRows = grid.groupsRowList.toArray(); + // verify groups count + expect(groupRows.length).toEqual(5); + // now click the chip to change sorting, the grouping expression should hold + // the comparer and reapply the same grouping again + const chips = grid.groupArea.chips; + // click grouping direction arrow + grid.groupArea.handleClick(chips.get(0).id); + tick(); + fix.detectChanges(); + // chips = fix.nativeElement.querySelectorAll('igx-chip'); + expect(chips.length).toBe(1); + checkChips(chips, grid.groupingExpressions); + expect(chips.get(0).nativeElement.querySelectorAll('igx-icon')[0].textContent.trim()).toBe('arrow_upward'); + groupRows = grid.groupsRowList.toArray(); + expect(groupRows.length).toEqual(5); + })); + + it('should allows expanding/collapsing groups.', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + grid.primaryKey = 'ID'; + fix.detectChanges(); + grid.groupBy({ fieldName: 'Released', dir: SortingDirection.Desc, ignoreCase: false }); + fix.detectChanges(); + + let groupRows = grid.groupsRowList.toArray(); + let dataRows = grid.dataRowList.toArray(); + // verify groups and data rows count + expect(groupRows.length).toEqual(3); + expect(dataRows.length).toEqual(8); + + // toggle group row - collapse + expect(groupRows[0].expanded).toEqual(true); + grid.toggleGroup(groupRows[0].groupRow); + tick(); + fix.detectChanges(); + expect(groupRows[0].expanded).toEqual(false); + groupRows = grid.groupsRowList.toArray(); + dataRows = grid.dataRowList.toArray(); + expect(groupRows.length).toEqual(3); + expect(dataRows.length).toEqual(4); + // verify collapsed group sub records are not rendered + + // behavioral change! row should not be returned, as its parent is collapsed + for (const rec of groupRows[0].groupRow.records) { + expect(grid.getRowByKey(rec.ID)).toBeUndefined(); + } + + // toggle group row - expand + grid.toggleGroup(groupRows[0].groupRow); + tick(); + fix.detectChanges(); + expect(groupRows[0].expanded).toEqual(true); + + for (const rec of groupRows[0].groupRow.records) { + expect(grid.getRowByKey(rec.ID)).not.toBeUndefined(); + } + + groupRows = grid.groupsRowList.toArray(); + dataRows = grid.dataRowList.toArray(); + expect(groupRows.length).toEqual(3); + expect(dataRows.length).toEqual(8); + + // verify expanded group sub records are rendered + for (const rec of groupRows[0].groupRow.records) { + expect(grid.getRowByKey(rec.ID)).not.toBeUndefined(); + } + + const groupRow = grid.getRowByIndex(0); + expect(groupRow.isGroupByRow).toBe(true); + expect(groupRow.expanded).toBe(true); + groupRow.expanded = false; + tick(); + fix.detectChanges(); + expect(groupRow.expanded).toBe(false); + + grid.clearGrouping(); + tick(); + grid.groupBy({ fieldName: 'ReleaseDate', dir: SortingDirection.Desc }); + fix.detectChanges(); + grid.toggleGroup(grid.groupsRowList.first.groupRow); + tick(); + fix.detectChanges(); + expect(groupRows[0].expanded).toEqual(false); + })); + + it('should allow changing the order of the groupBy columns.', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + fix.detectChanges(); + + // set groupingExpressions + const grid = fix.componentInstance.instance; + const exprs: ISortingExpression[] = [ + { fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: true }, + { fieldName: 'Released', dir: SortingDirection.Desc, ignoreCase: true } + ]; + grid.groupingExpressions = exprs; + tick(); + fix.detectChanges(); + + let groupRows = grid.groupsRowList.toArray(); + let dataRows = grid.dataRowList.toArray(); + + expect(groupRows.length).toEqual(13); + expect(dataRows.length).toEqual(8); + // verify groups + checkGroups(groupRows, + ['NetAdvantage', true, false, 'Ignite UI for JavaScript', true, + false, 'Ignite UI for Angular', false, null, '', true, null, true], + grid.groupingExpressions); + + // change order + grid.groupingExpressions = [ + { fieldName: 'Released', dir: SortingDirection.Asc, ignoreCase: true }, + { fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: true } + ]; + tick(); + grid.sortingExpressions = [ + { fieldName: 'Released', dir: SortingDirection.Asc, ignoreCase: true }, + { fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: true } + ]; + tick(); + fix.detectChanges(); + + groupRows = grid.groupsRowList.toArray(); + dataRows = grid.dataRowList.toArray(); + expect(groupRows.length).toEqual(11); + expect(dataRows.length).toEqual(8); + // verify groups + checkGroups(groupRows, + [null, 'Ignite UI for Angular', false, 'Ignite UI for Angular', 'Ignite UI for JavaScript', + 'NetAdvantage', true, null, '', 'Ignite UI for JavaScript', 'NetAdvantage'], + grid.groupingExpressions); + })); + + it('should group records correctly when ignoreCase is set to true.', fakeAsync(() => { + const fix = TestBed.createComponent(GridGroupByCaseSensitiveComponent); + fix.detectChanges(); + + // set groupingExpressions + const grid = fix.componentInstance.instance; + const exprs: ISortingExpression[] = [ + { fieldName: 'ContactTitle', dir: SortingDirection.Asc, ignoreCase: true } + ]; + grid.groupingExpressions = exprs; + tick(); + fix.detectChanges(); + + const groupRows = grid.groupsRowList.toArray(); + const dataRows = grid.dataRowList.toArray(); + + expect(groupRows.length).toEqual(2); + expect(dataRows.length).toEqual(5); + // verify groups + checkGroups(groupRows, + ['Order Administrator', 'Owner'], + grid.groupingExpressions); + })); + + it('should allow setting expand/collapse state', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + grid.primaryKey = 'ID'; + fix.detectChanges(); + + grid.groupsExpanded = false; + grid.groupBy({ fieldName: 'Released', dir: SortingDirection.Desc, ignoreCase: false }); + fix.detectChanges(); + + let groupRows = grid.groupsRowList.toArray(); + let dataRows = grid.dataRowList.toArray(); + + expect(groupRows.length).toEqual(3); + expect(dataRows.length).toEqual(0); + + for (const grRow of groupRows) { + expect(grRow.expanded).toBe(false); + } + + grid.groupsExpanded = true; + tick(); + grid.cdr.detectChanges(); + + groupRows = grid.groupsRowList.toArray(); + dataRows = grid.dataRowList.toArray(); + + expect(groupRows.length).toEqual(3); + expect(dataRows.length).toEqual(8); + + for (const grRow of groupRows) { + expect(grRow.expanded).toBe(true); + } + })); + + it('should trigger an groupingDone event when a column is grouped with the correct params.', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + grid.primaryKey = 'ID'; + fix.detectChanges(); + + grid.groupBy({ fieldName: 'Released', dir: SortingDirection.Desc, ignoreCase: false }); + fix.detectChanges(); + + const currExpr = fix.componentInstance.currentSortExpressions; + expect(currExpr.expressions.length).toEqual(1); + expect(currExpr.expressions[0].fieldName).toEqual('Released'); + expect(currExpr.groupedColumns.length).toEqual(1); + expect(currExpr.groupedColumns[0].field).toEqual('Released'); + expect(currExpr.ungroupedColumns.length).toEqual(0); + })); + + it('should trigger an groupingDone event when a column is ungrouped with the correct params.', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + grid.primaryKey = 'ID'; + fix.detectChanges(); + + grid.groupBy([ + { fieldName: 'Released', dir: SortingDirection.Desc, ignoreCase: false }, + { fieldName: 'ReleaseDate', dir: SortingDirection.Desc, ignoreCase: false } + ]); + tick(); + fix.detectChanges(); + grid.clearGrouping('Released'); + tick(); + fix.detectChanges(); + const currExpr = fix.componentInstance.currentSortExpressions; + expect(currExpr.expressions.length).toEqual(1); + expect(currExpr.expressions[0].fieldName).toEqual('ReleaseDate'); + expect(currExpr.groupedColumns.length).toEqual(0); + expect(currExpr.ungroupedColumns.length).toEqual(1); + expect(currExpr.ungroupedColumns[0].field).toEqual('Released'); + })); + + it('should trigger an groupingDone event when multiple columns are grouped with the correct params.', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + grid.primaryKey = 'ID'; + fix.detectChanges(); + grid.groupBy([ + { fieldName: 'Released', dir: SortingDirection.Desc, ignoreCase: false }, + { fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: false }, + { fieldName: 'ReleaseDate', dir: SortingDirection.Desc, ignoreCase: false } + ]); + tick(); + fix.detectChanges(); + const currExpr = fix.componentInstance.currentSortExpressions; + expect(currExpr.expressions.length).toEqual(3); + expect(currExpr.expressions[0].fieldName).toEqual('Released'); + expect(currExpr.expressions[1].fieldName).toEqual('ProductName'); + expect(currExpr.expressions[2].fieldName).toEqual('ReleaseDate'); + expect(currExpr.groupedColumns.length).toEqual(3); + expect(currExpr.groupedColumns[0].field).toEqual('Released'); + expect(currExpr.groupedColumns[1].field).toEqual('ProductName'); + expect(currExpr.groupedColumns[2].field).toEqual('ReleaseDate'); + expect(currExpr.ungroupedColumns.length).toEqual(0); + })); + + it('should trigger an groupingDone event when multiple columns are ungrouped with the correct params.', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + grid.primaryKey = 'ID'; + fix.detectChanges(); + grid.groupBy([ + { fieldName: 'Released', dir: SortingDirection.Desc, ignoreCase: false }, + { fieldName: 'ReleaseDate', dir: SortingDirection.Desc, ignoreCase: false }, + { fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: false }, + { fieldName: 'Downloads', dir: SortingDirection.Asc, ignoreCase: false } + ]); + tick(); + fix.detectChanges(); + grid.clearGrouping(['Released', 'ProductName', 'Downloads']); + tick(); + fix.detectChanges(); + const currExpr = fix.componentInstance.currentSortExpressions; + expect(currExpr.expressions.length).toEqual(1); + expect(currExpr.expressions[0].fieldName).toEqual('ReleaseDate'); + expect(currExpr.groupedColumns.length).toEqual(0); + expect(currExpr.ungroupedColumns.length).toEqual(3); + expect(currExpr.ungroupedColumns[0].field).toEqual('Released'); + expect(currExpr.ungroupedColumns[1].field).toEqual('ProductName'); + expect(currExpr.ungroupedColumns[2].field).toEqual('Downloads'); + })); + + it(`should trigger an groupingDone event when the user pushes a new array of grouping expressions, which results in + both grouping and un-grouping at the same time.`, fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + grid.primaryKey = 'ID'; + fix.detectChanges(); + grid.groupBy([ + { fieldName: 'Released', dir: SortingDirection.Desc, ignoreCase: false }, + { fieldName: 'ReleaseDate', dir: SortingDirection.Desc, ignoreCase: false }, + { fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: false } + ]); + tick(); + fix.detectChanges(); + const newExpressions = [ + { fieldName: 'ReleaseDate', dir: SortingDirection.Desc, ignoreCase: false }, + { fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: false }, + { fieldName: 'Downloads', dir: SortingDirection.Asc, ignoreCase: false } + ]; + grid.groupingExpressions = newExpressions; + tick(); + fix.detectChanges(); + const currExpr = fix.componentInstance.currentSortExpressions; + expect(currExpr.expressions.length).toEqual(3); + expect(currExpr.expressions[0].fieldName).toEqual('ReleaseDate'); + expect(currExpr.expressions[1].fieldName).toEqual('ProductName'); + expect(currExpr.expressions[2].fieldName).toEqual('Downloads'); + expect(currExpr.ungroupedColumns.length).toEqual(1); + expect(currExpr.ungroupedColumns[0].field).toEqual('Released'); + expect(currExpr.groupedColumns.length).toEqual(1); + expect(currExpr.groupedColumns[0].field).toEqual('Downloads'); + })); + + it('should update groupsRecords when groupingDone is emitted', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.detectChanges(); + + let groupsRecordsLength; + + spyOn(grid.groupingDone, 'emit').and.callThrough(); + grid.groupingDone.subscribe(() => { + groupsRecordsLength = grid.groupsRecords.length; + }); + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + tick(); + fix.detectChanges(); + expect(groupsRecordsLength).toEqual(5); + + grid.clearGrouping(); + tick(); + fix.detectChanges(); + expect(groupsRecordsLength).toEqual(0); + })); + + it('should allow setting custom template for group row content and expand/collapse icons.', fakeAsync(() => { + const fix = TestBed.createComponent(CustomTemplateGridComponent); + const grid = fix.componentInstance.instance; + fix.detectChanges(); + + grid.groupBy({ fieldName: 'Released', dir: SortingDirection.Desc, ignoreCase: false }); + fix.detectChanges(); + + const groupRows = grid.groupsRowList.toArray(); + + for (const grRow of groupRows) { + const elem = grRow.groupContent.nativeElement; + const grVal = grRow.groupRow.value === null ? '' : grRow.groupRow.value.toString(); + const expectedText = 'Grouping by "Is it Released". ' + + 'Total items with value:' + grVal + + ' are ' + grRow.groupRow.records.length; + expect(elem.innerText.trim(['\n', '\r', ' '])).toEqual(expectedText); + const expander = grRow.nativeElement.querySelector('.igx-grid__grouping-indicator'); + expect(expander.innerText).toBe('EXPANDED'); + } + + groupRows[0].toggle(); + const expndr = groupRows[0].nativeElement.querySelector('.igx-grid__grouping-indicator'); + expect(expndr.innerText).toBe('COLLAPSED'); + + expect(grid.headerGroupContainer.nativeElement.innerText).toBe('EXPANDED'); + grid.toggleAllGroupRows(); + fix.detectChanges(); + expect(grid.headerGroupContainer.nativeElement.innerText).toBe('COLLAPSED'); + + })); + + it('should allow setting custom template for group row via Input.', fakeAsync(() => { + const fix = TestBed.createComponent(CustomTemplateGridComponent); + const grid = fix.componentInstance.instance; + fix.detectChanges(); + grid.groupRowTemplate = fix.componentInstance.customGroupBy; + fix.detectChanges(); + grid.groupBy({ fieldName: 'Released', dir: SortingDirection.Desc, ignoreCase: false }); + fix.detectChanges(); + const grRow = grid.groupsRowList.toArray()[0]; + const elem = grRow.groupContent.nativeElement; + expect(elem.innerText.trim()).toEqual('CUSTOM GROUP BY'); + })); + + it('should have the correct ARIA attributes on the group rows.', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + grid.primaryKey = 'ID'; + fix.detectChanges(); + + grid.groupBy({ fieldName: 'Released', dir: SortingDirection.Desc, ignoreCase: false }); + fix.detectChanges(); + + const groupRows = grid.groupsRowList.toArray(); + for (const grRow of groupRows) { + const elem = grRow.element.nativeElement; + expect(elem.attributes['aria-describedby'].value).toEqual(grid.id + '_Released'); + expect(elem.attributes['aria-expanded'].value).toEqual('true'); + } + })); + + it('should not apply grouping if the grouping expressions value is the same reference', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + fix.detectChanges(); + + // group by string column + const grid = fix.componentInstance.instance; + fix.detectChanges(); + grid.groupBy({ + fieldName: 'ReleaseDate', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + spyOn(grid.groupingExpressionsChange, 'emit'); + fix.detectChanges(); + const firstCellElem = grid.gridAPI.get_cell_by_index(2, 'Downloads'); + UIInteractions.simulateClickAndSelectEvent(firstCellElem); + fix.detectChanges(); + expect(grid.groupingExpressionsChange.emit).toHaveBeenCalledTimes(0); + })); + + it('should emit groupingExpressionsChange when a group is sorted through the chip', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + fix.detectChanges(); + + // group by string column + const grid = fix.componentInstance.instance; + fix.detectChanges(); + grid.groupBy({ + fieldName: 'ReleaseDate', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + spyOn(grid.groupingExpressionsChange, 'emit'); + fix.detectChanges(); + const chips = grid.groupArea.chips; + grid.groupArea.handleClick(chips.first.id); + fix.detectChanges(); + expect(grid.groupingExpressionsChange.emit).toHaveBeenCalledTimes(1); + })); + + it('should group unbound column with custom grouping strategy', fakeAsync(() => { + const fix = TestBed.createComponent(GroupableGridComponent); + fix.componentInstance.data.forEach((r, i) => { + r['fieldValue1'] = Math.floor(i / 3); + r['fieldValue2'] = Math.floor(i / 4); + }); + fix.detectChanges(); + fix.componentInstance.instance.groupBy({ + fieldName: 'UnboundField', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + const groupRows = fix.componentInstance.instance.groupsRowList.toArray(); + expect(groupRows.length).toEqual(4); + })); + + it('should update chip state after columns change.', () => { + const fix = TestBed.createComponent(GroupByDataMoreColumnsComponent); + fix.detectChanges(); + const grid = fix.componentInstance.instance; + grid.groupingExpressions = [ + { + dir: SortingDirection.Asc, + fieldName: "NewColumn" + } + ]; + fix.detectChanges(); + + // no such column initially, so chip is disabled. + const chips = grid.groupArea.chips; + expect(chips.first.disabled).toBeTrue(); + const newCols = [...fix.componentInstance.columns]; + newCols.push({ + field: "NewColumn", + width: 100 + }); + fix.componentInstance.columns = newCols; + fix.detectChanges(); + + // column now exists and has groupable=true, so chip should be enabled. + expect(chips.first.disabled).toBeFalse(); + }); + + it('should update chip state on column groupable prop change', () => { + const fix = TestBed.createComponent(DefaultGridComponent); + fix.detectChanges(); + const grid = fix.componentInstance.instance; + const column = grid.getColumnByName('ProductName'); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + + // initially should not be disabled. + const chips = grid.groupArea.chips; + expect(chips.first.disabled).toBeFalse(); + + // should get disabled on groupable=false + column.groupable = false; + fix.detectChanges(); + expect(chips.first.disabled).toBeTrue(); + }); + + // GroupBy + Sorting integration + it('should apply sorting on each group\'s records when non-grouped column is sorted.', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.enableSorting = true; + tick(); + fix.detectChanges(); + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + const groupRows = grid.groupsRowList.toArray(); + const dataRows = grid.dataRowList.toArray(); + // verify groups and data rows count + expect(groupRows.length).toEqual(5); + expect(dataRows.length).toEqual(8); + + grid.sort({ fieldName: 'Released', dir: SortingDirection.Asc, ignoreCase: false }); + fix.detectChanges(); + + // verify groups + checkGroups(groupRows, ['NetAdvantage', 'Ignite UI for JavaScript', 'Ignite UI for Angular', '', null]); + + // verify data records order + const expectedDataRecsOrder = [false, true, false, true, null, false, true, true]; + dataRows.forEach((row, index) => { + expect(row.data.Released).toEqual(expectedDataRecsOrder[index]); + }); + + })); + + it('should apply the specified sort order on the group rows when already grouped column is sorted in asc/desc order.', + fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.enableSorting = true; + fix.detectChanges(); + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + + let groupRows = grid.groupsRowList.toArray(); + let dataRows = grid.dataRowList.toArray(); + + // verify groups and data rows count + expect(groupRows.length).toEqual(5); + expect(dataRows.length).toEqual(8); + + // verify group order + checkGroups(groupRows, ['NetAdvantage', 'Ignite UI for JavaScript', 'Ignite UI for Angular', '', null]); + grid.sort({ + fieldName: 'ProductName', + dir: SortingDirection.Asc, + ignoreCase: false + }); + fix.detectChanges(); + + groupRows = grid.groupsRowList.toArray(); + dataRows = grid.dataRowList.toArray(); + + // verify group order + checkGroups(groupRows, [null, '', 'Ignite UI for Angular', 'Ignite UI for JavaScript', 'NetAdvantage']); + + })); + + it('should remove grouping when already grouped column is sorted with order "None" via the API.', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.enableSorting = true; + fix.detectChanges(); + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + + let groupRows = grid.groupsRowList.toArray(); + let dataRows = grid.dataRowList.toArray(); + + // verify groups and data rows count + expect(groupRows.length).toEqual(5); + expect(dataRows.length).toEqual(8); + + // verify group order + checkGroups(groupRows, ['NetAdvantage', 'Ignite UI for JavaScript', 'Ignite UI for Angular', '', null]); + grid.sort({ fieldName: 'ProductName', dir: SortingDirection.None, ignoreCase: false }); + fix.detectChanges(); + groupRows = grid.groupsRowList.toArray(); + dataRows = grid.dataRowList.toArray(); + + // verify groups and data rows count + expect(groupRows.length).toEqual(0); + expect(dataRows.length).toEqual(8); + + })); + + it('should not be able to sort the column when is already grouped by', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.enableSorting = true; + fix.detectChanges(); + grid.groupBy({ + fieldName: 'Downloads', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + + const headers = fix.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + //header sort icon should not be displayed + const sortIcon = headers[0].query(By.css('.sort-icon')); + expect(sortIcon).toBeNull() + })); + + it('should group by the specified field when grouping by an already sorted field.', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.enableSorting = true; + fix.detectChanges(); + grid.sort({ fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false }); + fix.detectChanges(); + + expect(grid.sortingExpressions.length).toBe(1); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + const groupRows = grid.groupsRowList.toArray(); + // verify group order + checkGroups(groupRows, [null, '', 'Ignite UI for Angular', 'Ignite UI for JavaScript', 'NetAdvantage']); + expect(grid.sortingExpressions.length).toBe(0); + expect(grid.groupingExpressions.length).toBe(1); + })); + + it('should allow grouping of already sorted column', waitForAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.enableSorting = true; + fix.detectChanges(); + grid.sort({ fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false }); + fix.detectChanges(); + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + const groupRows = grid.groupsRowList.toArray(); + const dataRows = grid.dataRowList.toArray(); + // verify groups and data rows count + expect(groupRows.length).toEqual(5); + expect(dataRows.length).toEqual(8); + expect(grid.groupingExpressions.length).toEqual(1); + })); + + // GroupBy + Virtualization integration + it('should virtualize data and group records.', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + + fix.componentInstance.width = '600px'; + fix.componentInstance.height = '300px'; + grid.columnWidth = '200px'; + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + grid.groupBy({ fieldName: 'Released', dir: SortingDirection.Desc, ignoreCase: false }); + fix.detectChanges(); + + expect(grid.groupsRowList.toArray().length).toEqual(3); + expect(grid.dataRowList.toArray().length).toEqual(2); + expect(grid.rowList.toArray().length).toEqual(5); + })); + + it('should recalculate visible chunk data and scrollbar size when expanding/collapsing group rows.', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + + fix.componentInstance.width = '600px'; + tick(); + fix.componentInstance.height = '300px'; + tick(); + grid.columnWidth = '200px'; + tick(); + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + grid.groupBy({ fieldName: 'Released', dir: SortingDirection.Desc, ignoreCase: false }); + fix.detectChanges(); + + const origScrollHeight = parseInt((grid.verticalScrollContainer.getScroll().children[0] as HTMLElement).style.height, 10); + + // collapse all group rows currently in the view + const grRows = grid.groupsRowList.toArray(); + grRows[0].toggle(); + tick(); + fix.detectChanges(); + + // verify rows are updated + expect(grid.groupsRowList.toArray().length).toEqual(4); + expect(grid.dataRowList.toArray().length).toEqual(1); + expect(grid.rowList.toArray().length).toEqual(5); + + // verify scrollbar is updated - 4 rows x 51px are hidden. + expect(parseInt((grid.verticalScrollContainer.getScroll().children[0] as HTMLElement).style.height, 10)) + .toEqual(origScrollHeight - 204); + + grRows[0].toggle(); + tick(); + fix.detectChanges(); + + expect(grid.groupsRowList.toArray().length).toEqual(3); + expect(grid.dataRowList.toArray().length).toEqual(2); + expect(grid.rowList.toArray().length).toEqual(5); + + expect(parseInt((grid.verticalScrollContainer.getScroll().children[0] as HTMLElement).style.height, 10)) + .toEqual(origScrollHeight); + })); + + it('should persist group row expand/collapse state when scrolling.', async () => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + + fix.componentInstance.width = '500px'; + fix.componentInstance.height = '300px'; + grid.columnWidth = '200px'; + await wait(); + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + grid.groupBy({ fieldName: 'Released', dir: SortingDirection.Desc, ignoreCase: false }); + fix.detectChanges(); + + let groupRow = grid.groupsRowList.toArray()[0]; + groupRow.toggle(); + await wait(); + + expect(groupRow.expanded).toBe(false); + fix.detectChanges(); + + // scroll to bottom + grid.verticalScrollContainer.getScroll().scrollTop = 10000; + await wait(100); + fix.detectChanges(); + + // scroll back to the top + grid.verticalScrollContainer.getScroll().scrollTop = 0; + await wait(100); + fix.detectChanges(); + + groupRow = grid.groupsRowList.toArray()[0]; + + expect(groupRow.expanded).toBe(false); + }); + + it('should retain focused group after expanding/collapsing row via KB - Alt + ArrowUp/ArrowDown', async () => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + + fix.componentInstance.width = '500px'; + fix.componentInstance.height = '300px'; + grid.columnWidth = '200px'; + await wait(); + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + + // scroll to bottom + grid.verticalScrollContainer.getScroll().scrollTop = 10000; + await wait(100); + fix.detectChanges(); + + // collapse last group row + let groupRow = grid.gridAPI.get_row_by_index(11); + UIInteractions.simulateClickAndSelectEvent(groupRow); + fix.detectChanges(); + GridFunctions.verifyGroupRowIsFocused(groupRow); + GridFunctions.simulateGridContentKeydown(fix, 'ArrowUp', true); + fix.detectChanges(); + groupRow = grid.gridAPI.get_row_by_index(11); + GridFunctions.verifyGroupRowIsFocused(groupRow); + // expand last group row + GridFunctions.simulateGridContentKeydown(fix, 'ArrowDown', true); + fix.detectChanges(); + groupRow = grid.gridAPI.get_row_by_index(11); + GridFunctions.verifyGroupRowIsFocused(groupRow); + }); + + it('should allow scrolling to bottom after collapsing a few groups.', async () => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + + fix.componentInstance.width = '500px'; + fix.componentInstance.height = '300px'; + grid.columnWidth = '200px'; + await wait(); + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + + // expand 2 group rows + + const groupRows = grid.groupsRowList.toArray(); + groupRows[0].toggle(); + groupRows[1].toggle(); + fix.detectChanges(); + + // scroll to bottom + grid.verticalScrollContainer.getScroll().scrollTop = 10000; + await wait(100); + fix.detectChanges(); + + // verify virtualization states - should be in last chunk + const virtState = grid.verticalScrollContainer.state; + expect(virtState.startIndex).toBe(grid.dataView.length - virtState.chunkSize); + + // verify last row is visible at bottom + const lastRow = grid.gridAPI.get_row_by_index(grid.dataView.length - 1); + expect(lastRow.nativeElement.getBoundingClientRect().bottom).toBe(grid.tbody.nativeElement.getBoundingClientRect().bottom); + + }); + + it('should leave group rows static when scrolling horizontally.', async () => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + + fix.componentInstance.width = '400px'; + fix.componentInstance.height = '300px'; + grid.columnWidth = '200px'; + await wait(); + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + grid.groupBy({ fieldName: 'Released', dir: SortingDirection.Desc, ignoreCase: false }); + fix.detectChanges(); + const groupRow = grid.groupsRowList.toArray()[0]; + const origRect = groupRow.element.nativeElement.getBoundingClientRect(); + grid.headerContainer.getScroll().scrollLeft = 1000; + await wait(100); + fix.detectChanges(); + + const rect = groupRow.element.nativeElement.getBoundingClientRect(); + + // verify row location is the same + expect(rect.left).toEqual(origRect.left); + expect(rect.top).toEqual(origRect.top); + }); + + it('should obtain correct virtualization state after all groups are collapsed and column is resized.', () => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + grid.groupsExpanded = false; + grid.columnWidth = '200px'; + fix.detectChanges(); + + let fDataRow = grid.dataRowList.toArray()[0]; + expect(fDataRow.virtDirRow.sizesCache[1]).toBe(200); + + grid.groupBy({ fieldName: 'Released', dir: SortingDirection.Desc, ignoreCase: false }); + fix.detectChanges(); + + grid.columns[0].width = '500px'; + fix.detectChanges(); + const groupRows = grid.groupsRowList.toArray(); + groupRows[0].toggle(); + fix.detectChanges(); + + fDataRow = grid.dataRowList.toArray()[0]; + expect(fDataRow.virtDirRow.sizesCache[1]).toBe(500); + }); + + // GroupBy + Filtering + it('should filters by the data records and renders their related groups.', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + fix.detectChanges(); + const grid = fix.componentInstance.instance; + fix.componentInstance.width = '1200px'; + tick(); + fix.detectChanges(); + grid.columnWidth = '200px'; + tick(); + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + let groupRows = grid.groupsRowList.toArray(); + let dataRows = grid.dataRowList.toArray(); + + expect(groupRows.length).toEqual(5); + expect(dataRows.length).toEqual(8); + expect(grid.rowList.toArray().length).toEqual(13); + + grid.filter('ProductName', 'Ignite', IgxStringFilteringOperand.instance().condition('contains'), true); + tick(); + fix.detectChanges(); + + groupRows = grid.groupsRowList.toArray(); + dataRows = grid.dataRowList.toArray(); + + expect(groupRows.length).toEqual(2); + expect(dataRows.length).toEqual(4); + expect(grid.rowList.toArray().length).toEqual(6); + })); + + // GroupBy + RowSelectors + it('should render row selectors in group row and remove them when the selection mode is set to none.', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.width = '1200px'; + tick(); + grid.columnWidth = '200px'; + tick(); + grid.rowSelection = GridSelectionMode.multiple; + tick(); + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + + fix.detectChanges(); + + const grRows = grid.groupsRowList.toArray(); + const dataRows = grid.dataRowList.toArray(); + for (const grRow of grRows) { + expect(GridSelectionFunctions.getRowCheckboxDiv(grRow.element.nativeElement)).toBeDefined(); + } + for (const dRow of dataRows) { + expect(GridSelectionFunctions.getRowCheckboxDiv(dRow.element.nativeElement)).toBeDefined(); + } + GridSelectionFunctions.verifySelectionCheckBoxesAlignment(grid); + + grid.rowSelection = GridSelectionMode.none; + fix.detectChanges(); + for (const grRow of grRows) { + expect(GridSelectionFunctions.getRowCheckboxDiv(grRow.element.nativeElement)).toBeNull(); + } + for (const dRow of dataRows) { + expect(GridSelectionFunctions.getRowCheckboxDiv(dRow.element.nativeElement)).toBeNull(); + } + + grid.rowSelection = GridSelectionMode.single; + fix.detectChanges(); + for (const grRow of grRows) { + expect(GridSelectionFunctions.getRowCheckboxDiv(grRow.element.nativeElement)).toBeDefined(); + } + for (const dRow of dataRows) { + expect(GridSelectionFunctions.getRowCheckboxDiv(dRow.element.nativeElement)).toBeDefined(); + } + })); + + it('group row checkboxes should be checked when selectAll API is called or when header checkbox is clicked.', + fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.width = '1200px'; + tick(); + grid.columnWidth = '200px'; + tick(); + grid.rowSelection = GridSelectionMode.multiple; + tick(); + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + tick(); + fix.detectChanges(); + + grid.selectAllRows(); + fix.detectChanges(); + + expect(grid.selectedRows.length).toEqual(8); + const grRows = grid.groupsRowList.toArray(); + for (const grRow of grRows) { + expect(GridSelectionFunctions.verifyGroupByRowCheckboxState(grRow, true, false)); + } + let rows = fix.debugElement.queryAll(By.css('.igx-grid__tr--selected')); + for (const r of rows) { + expect(r.componentInstance instanceof IgxGridRowComponent).toBe(true); + } + + grid.deselectAllRows(); + fix.detectChanges(); + expect(grid.selectedRows.length).toEqual(0); + for (const grRow of grRows) { + expect(GridSelectionFunctions.verifyGroupByRowCheckboxState(grRow, false, false)); + } + + GridSelectionFunctions.clickHeaderRowCheckbox(fix); + fix.detectChanges(); + + expect(grid.selectedRows.length).toEqual(8); + + rows = fix.debugElement.queryAll(By.css('.igx-grid__tr--selected')); + for (const grRow of grRows) { + expect(GridSelectionFunctions.verifyGroupByRowCheckboxState(grRow, true, false)); + } + for (const r of rows) { + expect(r.componentInstance instanceof IgxGridRowComponent).toBe(true); + } + })); + + it(`should select all records for group by pressing space when selectionMode is multiple + and not all records within a group are selected and the groupRow is focused`, + fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.width = '1200px'; + tick(); + grid.columnWidth = '200px'; + tick(); + grid.rowSelection = GridSelectionMode.multiple; + tick(); + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + tick(); + fix.detectChanges(); + + const grRow = grid.groupsRowList.toArray()[0]; + + grRow.activate(); + tick(); + fix.detectChanges(); + + const selectionSpy = spyOn(grid.rowSelectionChanging, 'emit'); + GridFunctions.simulateGridContentKeydown(fix, 'Space'); + fix.detectChanges(); + + expect(selectionSpy).toHaveBeenCalledTimes(1); + const args = selectionSpy.calls.mostRecent().args[0]; + expect(args.added.length).toBe(2); + expect(grid.selectedRows.length).toEqual(2); + + for (const key of grRow.groupRow.records) { + expect(GridSelectionFunctions.verifyRowSelected(grid.gridAPI.get_row_by_key(key))); + } + + grid.deselectAllRows(); + fix.detectChanges(); + + grid.selectRows([grRow.groupRow.records[0]]); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'Space'); + fix.detectChanges(); + + for (const key of grRow.groupRow.records) { + expect(GridSelectionFunctions.verifyRowSelected(grid.gridAPI.get_row_by_key(key))); + } + })); + + it('should not affect current row selection by pressing space when selectionMode is single and the groupRow is focused', + fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.width = '1200px'; + tick(); + grid.columnWidth = '200px'; + tick(); + grid.rowSelection = GridSelectionMode.single; + tick(); + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + tick(); + fix.detectChanges(); + + const selectionCount = grid.selectedRows.length; + + const grRow = grid.groupsRowList.toArray()[0]; + + grRow.activate(); + tick(); + fix.detectChanges(); + + GridFunctions.simulateGridContentKeydown(fix, 'Space'); + fix.detectChanges(); + + const newSelectionCount = grid.selectedRows.length; + + expect(selectionCount).toEqual(newSelectionCount); + + })); + + it(`should deselect all records for group by pressing space when selectionMode is multiple + and all records within a group are selected and the groupRow is focused`, + fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.width = '1200px'; + tick(); + grid.columnWidth = '200px'; + tick(); + grid.rowSelection = GridSelectionMode.multiple; + tick(); + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + tick(); + fix.detectChanges(); + + const grRow = grid.groupsRowList.toArray()[0]; + grid.selectRows(grRow.groupRow.records); + tick(); + fix.detectChanges(); + + grRow.activate(); + tick(); + fix.detectChanges(); + + const selectionSpy = spyOn(grid.rowSelectionChanging, 'emit'); + GridFunctions.simulateGridContentKeydown(fix, 'Space'); + fix.detectChanges(); + + expect(selectionSpy).toHaveBeenCalledTimes(1); + const args = selectionSpy.calls.mostRecent().args[0]; + expect(args.removed.length).toBe(2); + expect(grid.selectedRows.length).toEqual(0); + + for (const key of grRow.groupRow.records) { + expect(GridSelectionFunctions.verifyRowSelected(grid.gridAPI.get_row_by_key(key), false)); + } + })); + + it('row selectors for all rows in certain group should be checked/unchecked if the checkbox for this group row is checked/unchecked', + fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.width = '1200px'; + tick(); + grid.columnWidth = '200px'; + tick(); + grid.rowSelection = GridSelectionMode.multiple; + tick(); + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + tick(); + fix.detectChanges(); + + const grRow = grid.groupsRowList.toArray()[0]; + + for (const key of grRow.groupRow.records) { + expect(GridSelectionFunctions.verifyRowSelected(grid.gridAPI.get_row_by_key(key), false, false)); + } + + GridSelectionFunctions.clickRowCheckbox(grRow); + fix.detectChanges(); + + for (const key of grRow.groupRow.records) { + expect(GridSelectionFunctions.verifyRowSelected(grid.gridAPI.get_row_by_key(key), true, true)); + } + })); + + it('the group row selector state should be checked if all records in the group are selected', + fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.width = '1200px'; + tick(); + grid.columnWidth = '200px'; + tick(); + grid.rowSelection = GridSelectionMode.multiple; + tick(); + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + tick(); + fix.detectChanges(); + + const grRow = grid.groupsRowList.toArray()[0]; + + grid.selectRows(grRow.groupRow.records); + fix.detectChanges(); + + expect(GridSelectionFunctions.verifyGroupByRowCheckboxState(grRow, true, false)); + })); + + it('the group row selector state should be undetermined if some of the records in the group but not all are selected', + fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.width = '1200px'; + tick(); + grid.columnWidth = '200px'; + tick(); + grid.rowSelection = GridSelectionMode.multiple; + tick(); + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + tick(); + fix.detectChanges(); + + const grRow = grid.groupsRowList.toArray()[0]; + + grid.selectRows([grRow.groupRow.records[0]]); + fix.detectChanges(); + + expect(GridSelectionFunctions.verifyGroupByRowCheckboxState(grRow, false, true)); + })); + + it('the group row selectors should be disabled if the grid selection mode is single', + fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.width = '1200px'; + tick(); + grid.columnWidth = '200px'; + tick(); + grid.rowSelection = GridSelectionMode.single; + tick(); + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + tick(); + fix.detectChanges(); + + const grRow = grid.groupsRowList.toArray()[0]; + + expect(GridSelectionFunctions.verifyGroupByRowCheckboxState(grRow, false, false, true)); + })); + + it('group row checkbox should remain the right state after filter is applied, all rows are selected and filter is removed', + fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.width = '1200px'; + tick(); + grid.columnWidth = '200px'; + tick(); + grid.rowSelection = GridSelectionMode.multiple; + tick(); + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + tick(); + fix.detectChanges(); + + grid.filter('ID', '2', IgxStringFilteringOperand.instance().condition('doesNotEqual'), true); + tick(); + fix.detectChanges(); + + const grRow = grid.groupsRowList.toArray()[0]; + + GridSelectionFunctions.clickRowCheckbox(grRow); + fix.detectChanges(); + + expect(GridSelectionFunctions.verifyGroupByRowCheckboxState(grRow, true, false)); + + grid.clearFilter(); + tick(); + fix.detectChanges(); + + expect(GridSelectionFunctions.verifyRowSelected(grid.gridAPI.get_row_by_key(grRow.groupRow.records[0]), false)); + expect(GridSelectionFunctions.verifyGroupByRowCheckboxState(grRow, false, true)); + })); + + it('group row checkbox should remain the right state after selecting all rows in group and adding a new row to that group', + fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.width = '1200px'; + tick(); + grid.columnWidth = '200px'; + tick(); + grid.rowSelection = GridSelectionMode.multiple; + tick(); + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + tick(); + fix.detectChanges(); + + const grRow = grid.groupsRowList.toArray()[0]; + + GridSelectionFunctions.clickRowCheckbox(grRow); + fix.detectChanges(); + + expect(GridSelectionFunctions.verifyGroupByRowCheckboxState(grRow, true, false)); + + const newRow = { ID: '9', ProductName: 'NetAdvantage', Downloads: '350' }; + grid.addRow(newRow); + fix.detectChanges(); + + expect(GridSelectionFunctions.verifyGroupByRowCheckboxState(grRow, false, true)); + })); + + it('should select/deselect all rows in group from API', + fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.width = '1200px'; + tick(); + grid.columnWidth = '200px'; + tick(); + grid.rowSelection = GridSelectionMode.multiple; + tick(); + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + tick(); + fix.detectChanges(); + + const grRow = grid.groupsRowList.toArray()[0]; + const grRecord = grid.groupsRecords[0]; + + grid.selectRowsInGroup(grRecord); + tick(); + fix.detectChanges(); + + for (const key of grRow.groupRow.records) { + expect(GridSelectionFunctions.verifyRowSelected(grid.gridAPI.get_row_by_key(key))); + } + + expect(GridSelectionFunctions.verifyGroupByRowCheckboxState(grRow, true, false)); + + grid.deselectRowsInGroup(grRecord); + tick(); + fix.detectChanges(); + + for (const key of grRow.groupRow.records) { + expect(GridSelectionFunctions.verifyRowSelected(grid.gridAPI.get_row_by_key(key), false)); + } + + expect(GridSelectionFunctions.verifyGroupByRowCheckboxState(grRow, false, false)); + })); + + it('should select/deselect all rows in group from API with PrimaryKey', + fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + grid.primaryKey = 'ID'; + fix.detectChanges(); + fix.componentInstance.width = '1200px'; + tick(); + grid.columnWidth = '200px'; + tick(); + grid.rowSelection = GridSelectionMode.multiple; + tick(); + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + tick(); + fix.detectChanges(); + + const grRow = grid.groupsRowList.toArray()[0]; + const grRecord = grid.groupsRecords[0]; + + grid.selectRowsInGroup(grRecord); + tick(); + fix.detectChanges(); + + for (const key of grRow.groupRow.records) { + expect(GridSelectionFunctions.verifyRowSelected(grid.gridAPI.get_row_by_key(key.ID))); + } + + expect(GridSelectionFunctions.verifyGroupByRowCheckboxState(grRow, true, false)); + + grid.deselectRowsInGroup(grRecord); + tick(); + fix.detectChanges(); + + for (const key of grRow.groupRow.records) { + expect(GridSelectionFunctions.verifyRowSelected(grid.gridAPI.get_row_by_key(key.ID), false)); + } + + expect(GridSelectionFunctions.verifyGroupByRowCheckboxState(grRow, false, false)); + })); + + it('ARIA support for groupby row selectors', + fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.width = '1200px'; + tick(); + grid.columnWidth = '200px'; + tick(); + grid.rowSelection = GridSelectionMode.multiple; + tick(); + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + tick(); + fix.detectChanges(); + + const grRow = grid.groupsRowList.toArray()[0]; + const groupByRowCheckboxElement = GridSelectionFunctions.getRowCheckboxInput(grRow.element.nativeElement); + + expect(groupByRowCheckboxElement.getAttribute('aria-checked')).toMatch('false'); + expect(groupByRowCheckboxElement.getAttribute('aria-label')) + .toMatch('Select all rows in the group with field name ProductName and value NetAdvantage'); + + grid.selectRows([grRow.groupRow.records[0]]); + fix.detectChanges(); + + expect(groupByRowCheckboxElement.getAttribute('aria-checked')).toMatch('mixed'); + expect(groupByRowCheckboxElement.getAttribute('aria-label')) + .toMatch('Select all rows in the group with field name ProductName and value NetAdvantage'); + + grid.selectRows([grRow.groupRow.records[1]]); + fix.detectChanges(); + + expect(groupByRowCheckboxElement.getAttribute('aria-checked')).toMatch('true'); + expect(groupByRowCheckboxElement.getAttribute('aria-label')) + .toMatch('Deselect all rows in the group with field name ProductName and value NetAdvantage'); + + })); + + it(`edit selected row so it goes to another group where all rows are selected as well. + The group row checkbox of the new group that the record becomes part of should be checked.`, + fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.enableEditing = true; + fix.componentInstance.width = '1200px'; + grid.primaryKey = 'ID'; + grid.columnWidth = '200px'; + grid.rowSelection = GridSelectionMode.multiple; + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + + const grRow = grid.groupsRowList.toArray()[0]; + + grid.selectRowsInGroup(grRow.groupRow); + grid.selectRows([5]); + fix.detectChanges(); + + const cell = grid.getCellByKey(5, 'ProductName'); + cell.column.editable = true; + fix.detectChanges(); + + UIInteractions.simulateDoubleClickAndSelectEvent(grid.gridAPI.get_cell_by_key(5, 'ProductName')); + fix.detectChanges(); + + expect(cell.editMode).toBe(true); + expect(grid.selectedRows.length).toEqual(3); + + const editCellDom = fix.debugElement.query(By.css('.igx-grid__td--editing')); + const input = editCellDom.query(By.css('input')); + + clickAndSendInputElementValue(input, 'NetAdvantage', fix); + GridFunctions.simulateGridContentKeydown(fix, 'Enter'); + fix.detectChanges(); + + expect(grRow.groupRow.records.length).toEqual(3); + expect(GridSelectionFunctions.verifyGroupByRowCheckboxState(grRow, true, false)); + })); + + it(`edit selected row so it goes to another group where all rows are not selected. + The group row checkbox of the new group that the record becomes part of should be in indeterminate state.`, + fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.enableEditing = true; + fix.componentInstance.width = '1200px'; + grid.primaryKey = 'ID'; + grid.columnWidth = '200px'; + grid.rowSelection = GridSelectionMode.multiple; + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + + const grRow = grid.groupsRowList.toArray()[0]; + + grid.selectRows([5]); + fix.detectChanges(); + + const cell = grid.getCellByKey(5, 'ProductName'); + cell.column.editable = true; + fix.detectChanges(); + + UIInteractions.simulateDoubleClickAndSelectEvent(grid.gridAPI.get_cell_by_key(5, 'ProductName')); + fix.detectChanges(); + + expect(cell.editMode).toBe(true); + expect(grid.selectedRows.length).toEqual(1); + + const editCellDom = fix.debugElement.query(By.css('.igx-grid__td--editing')); + const input = editCellDom.query(By.css('input')); + + clickAndSendInputElementValue(input, 'NetAdvantage', fix); + GridFunctions.simulateGridContentKeydown(fix, 'Enter'); + fix.detectChanges(); + + expect(grRow.groupRow.records.length).toEqual(3); + expect(GridSelectionFunctions.verifyGroupByRowCheckboxState(grRow, false, true)); + })); + + it(`edit non-selected row so it goes to another group where all rows are selected. + The group row checkbox of the new group that the record becomes part of should become in indeterminate state.`, + fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.enableEditing = true; + fix.componentInstance.width = '1200px'; + grid.primaryKey = 'ID'; + grid.columnWidth = '200px'; + grid.rowSelection = GridSelectionMode.multiple; + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + + const grRow = grid.groupsRowList.toArray()[0]; + + grid.selectRowsInGroup(grRow.groupRow); + fix.detectChanges(); + + const cell = grid.getCellByKey(5, 'ProductName'); + cell.column.editable = true; + fix.detectChanges(); + + expect(GridSelectionFunctions.verifyGroupByRowCheckboxState(grRow, true, false)); + + UIInteractions.simulateDoubleClickAndSelectEvent(grid.gridAPI.get_cell_by_key(5, 'ProductName')); + fix.detectChanges(); + + expect(cell.editMode).toBe(true); + + const editCellDom = fix.debugElement.query(By.css('.igx-grid__td--editing')); + const input = editCellDom.query(By.css('input')); + + clickAndSendInputElementValue(input, 'NetAdvantage', fix); + GridFunctions.simulateGridContentKeydown(fix, 'Enter'); + fix.detectChanges(); + + expect(GridSelectionFunctions.verifyGroupByRowCheckboxState(grRow, false, true)); + })); + + it(`edit the only non-selected row in a group so that it moves to another group + and check whether the current group row checkbox becomes checked.`, + fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.enableEditing = true; + fix.componentInstance.width = '1200px'; + grid.primaryKey = 'ID'; + grid.columnWidth = '200px'; + grid.rowSelection = GridSelectionMode.multiple; + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + + const grRow = grid.groupsRowList.toArray()[0]; + + grid.selectRows([2]); + fix.detectChanges(); + + const cell = grid.getCellByKey(8, 'ProductName'); + cell.column.editable = true; + fix.detectChanges(); + + expect(GridSelectionFunctions.verifyGroupByRowCheckboxState(grRow, false, true)); + + UIInteractions.simulateDoubleClickAndSelectEvent(grid.gridAPI.get_cell_by_key(8, 'ProductName')); + fix.detectChanges(); + + expect(cell.editMode).toBe(true); + + const editCellDom = fix.debugElement.query(By.css('.igx-grid__td--editing')); + const input = editCellDom.query(By.css('input')); + + clickAndSendInputElementValue(input, 'Ignite UI for Angular', fix); + GridFunctions.simulateGridContentKeydown(fix, 'Enter'); + fix.detectChanges(); + + expect(GridSelectionFunctions.verifyGroupByRowCheckboxState(grRow, true, false)); + })); + + it(`edit the only selected row in a group so that it moves to another group + and check whether the current group row checkbox becomes unchecked.`, + fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.enableEditing = true; + fix.componentInstance.width = '1200px'; + grid.primaryKey = 'ID'; + grid.columnWidth = '200px'; + grid.rowSelection = GridSelectionMode.multiple; + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + + const grRow = grid.groupsRowList.toArray()[0]; + + grid.selectRows([2]); + fix.detectChanges(); + + const cell = grid.getCellByKey(2, 'ProductName'); + cell.column.editable = true; + fix.detectChanges(); + + expect(GridSelectionFunctions.verifyGroupByRowCheckboxState(grRow, false, true)); + + UIInteractions.simulateDoubleClickAndSelectEvent(grid.gridAPI.get_cell_by_key(2, 'ProductName')); + fix.detectChanges(); + + expect(cell.editMode).toBe(true); + + const editCellDom = fix.debugElement.query(By.css('.igx-grid__td--editing')); + const input = editCellDom.query(By.css('input')); + + clickAndSendInputElementValue(input, 'Ignite UI for Angular', fix); + GridFunctions.simulateGridContentKeydown(fix, 'Enter'); + fix.detectChanges(); + + expect(GridSelectionFunctions.verifyGroupByRowCheckboxState(grRow, false, false)); + })); + + it('groupRowCheckbox should be in the right state by deleting rows from that group', + fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.width = '1200px'; + tick(); + grid.columnWidth = '200px'; + tick(); + grid.rowSelection = GridSelectionMode.multiple; + tick(); + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + tick(); + fix.detectChanges(); + + const grRow1 = grid.groupsRowList.toArray()[0]; + + grid.selectRows([grRow1.groupRow.records[0]]); + fix.detectChanges(); + grid.deleteRowById(grRow1.groupRow.records[0]); + fix.detectChanges(); + expect(GridSelectionFunctions.verifyGroupByRowCheckboxState(grRow1, false, false)); + + const grRow2 = grid.groupsRowList.toArray()[1]; + + grid.selectRows(grRow2.groupRow.records); + fix.detectChanges(); + grid.deleteRowById(grRow2.groupRow.records[0]); + fix.detectChanges(); + expect(GridSelectionFunctions.verifyGroupByRowCheckboxState(grRow2, true, false)); + + const grRow3 = grid.groupsRowList.toArray()[2]; + + grid.selectRows([grRow3.groupRow.records[1]]); + fix.detectChanges(); + grid.deleteRowById(grRow3.groupRow.records[0]); + fix.detectChanges(); + expect(GridSelectionFunctions.verifyGroupByRowCheckboxState(grRow3, true, false)); + })); + + it('should hide/show checkboxes when change hideRowSelectors', + fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.width = '1200px'; + tick(); + grid.columnWidth = '200px'; + tick(); + grid.rowSelection = GridSelectionMode.multiple; + tick(); + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + tick(); + fix.detectChanges(); + + grid.hideRowSelectors = true; + fix.detectChanges(); + + const grRows = grid.groupsRowList.toArray(); + + for (const grRow of grRows) { + expect(GridSelectionFunctions.verifyRowHasCheckbox(grRow.element.nativeElement, false, false)); + } + + grid.hideRowSelectors = false; + fix.detectChanges(); + + for (const grRow of grRows) { + expect(GridSelectionFunctions.verifyRowHasCheckbox(grRow.element.nativeElement)); + } + + })); + + it('Should have the correct properties in the custom row selector template', fakeAsync(() => { + const fix = TestBed.createComponent(GridGroupByRowCustomSelectorsComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.width = '1200px'; + tick(); + grid.columnWidth = '200px'; + tick(); + grid.rowSelection = GridSelectionMode.multiple; + tick(); + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + tick(); + fix.detectChanges(); + + const grRow = grid.groupsRowList.toArray()[0]; + const contextSelect = { selectedCount: 0, totalCount: 2, groupRow: grid.groupsRowList.toArray()[0].groupRow }; + const contextUnselect = { selectedCount: 2, totalCount: 2, groupRow: grid.groupsRowList.toArray()[0].groupRow }; + + spyOn(fix.componentInstance, 'onGroupByRowClick').and.callThrough(); + + grRow.nativeElement.querySelector('.igx-checkbox__composite').click(); + fix.detectChanges(); + expect(fix.componentInstance.onGroupByRowClick).toHaveBeenCalledWith(fix.componentInstance.groupByRowClick, contextSelect); + + grRow.nativeElement.querySelector('.igx-checkbox__composite').click(); + fix.detectChanges(); + expect(fix.componentInstance.onGroupByRowClick).toHaveBeenCalledWith(fix.componentInstance.groupByRowClick, contextUnselect); + })); + + it('should update chips state when columns are added/removed', fakeAsync(() => { + const fix = TestBed.createComponent(GroupByDataMoreColumnsComponent); + const cols = fix.componentInstance.columns; + fix.componentInstance.columns = []; + fix.componentInstance.instance.groupingExpressions = [ + { + dir: SortingDirection.Asc, + fieldName: 'A', + ignoreCase: false, + strategy: DefaultSortingStrategy.instance() + } + ]; + fix.detectChanges(); + const chips = fix.componentInstance.instance.groupArea.chips; + let chipContent = chips.first.nativeElement.querySelector('.igx-chip__content').textContent.trim(); + expect(chipContent).toBe('A'); + fix.componentInstance.columns = cols; + fix.detectChanges(); + chipContent = chips.first.nativeElement.querySelector('.igx-chip__content').textContent.trim(); + expect(chipContent).toBe('AA'); + })); + + // GroupBy Row Formatting + it('should properly apply formatters, both custom and default ones for the default row value template', fakeAsync(() => { + const fix = TestBed.createComponent(GroupableGridComponent); + const grid = fix.componentInstance.instance; + tick(); + fix.detectChanges(); + grid.groupBy({ + fieldName: 'Downloads', dir: SortingDirection.Desc, ignoreCase: false + }); + tick(); + fix.detectChanges(); + let cellText = grid.groupsRowList.first.nativeElement.querySelector(".igx-group-label__text").innerText; + expect(cellText).toEqual(formatNumber(1000, grid.locale)); + // apply custom formatter + grid.getColumnByName('Downloads').formatter = (value, _row) => `\$${value}`; + grid.groupingExpressions = []; + tick(); + fix.detectChanges(); + grid.groupBy({ + fieldName: 'Downloads', dir: SortingDirection.Desc, ignoreCase: false + }); + tick(); + fix.detectChanges(); + cellText = grid.groupsRowList.first.nativeElement.querySelector(".igx-group-label__text").innerText; + expect(cellText).toEqual('$1000'); + })); + + // GroupBy + Resizing + it('should retain same size for group row after a column is resized.', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.width = '1200px'; + fix.componentInstance.enableResizing = true; + grid.columnWidth = '200px'; + fix.detectChanges(); + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + + let grRows = grid.groupsRowList.toArray(); + for (const grRow of grRows) { + expect(grRow.element.nativeElement.clientWidth).toEqual(1200); + } + + const headers = fix.debugElement.queryAll(By.css(COLUMN_HEADER_GROUP_CLASS)); + const headerResArea = headers[0].children[1].nativeElement; + UIInteractions.simulateMouseEvent('mouseover', headerResArea, 200, 5); + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 200, 5); + tick(200); + UIInteractions.simulateMouseEvent('mouseup', headerResArea, 200, 5); + fix.detectChanges(); + + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 200, 5); + tick(200); + const resizer = fix.debugElement.queryAll(By.css(GRID_RESIZE_CLASS))[0].nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 550, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 550, 5); + fix.detectChanges(); + + expect(grid.columns[0].width).toEqual('550px'); + + grRows = grid.groupsRowList.toArray(); + for (const grRow of grRows) { + expect(grRow.element.nativeElement.clientWidth).toEqual(1200); + } + })); + + // GroupBy + Hiding + it('should retain same size for group row after a column is hidden.', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.width = '1200px'; + tick(); + grid.columnWidth = '200px'; + tick(); + fix.detectChanges(); + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + + grid.getColumnByName('ProductName').hidden = true; + tick(); + grid.getColumnByName('Released').hidden = true; + tick(); + + fix.detectChanges(); + + const grRows = grid.groupsRowList.toArray(); + for (const grRow of grRows) { + expect(grRow.element.nativeElement.clientWidth).toEqual(1200); + } + })); + + // GroupBy + Pinning + it('should retain same size for group row after a column is pinned.', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.width = '500px'; + tick(); + grid.columnWidth = '200px'; + tick(); + fix.detectChanges(); + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + + grid.pinColumn('ProductName'); + tick(); + + fix.detectChanges(); + const grRows = grid.groupsRowList.toArray(); + for (const grRow of grRows) { + expect(grRow.element.nativeElement.clientWidth).toEqual(500); + } + })); + + // GroupBy + Updating + it('should update the UI when adding/deleting/updating records via the API so that they more to the correct group.', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.width = '500px'; + grid.columnWidth = '200px'; + grid.primaryKey = 'ID'; + fix.detectChanges(); + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + + // verify rows + let groupRows = grid.groupsRowList.toArray(); + let dataRows = grid.dataRowList.toArray(); + + expect(groupRows.length).toEqual(5); + expect(dataRows.length).toEqual(8); + + // add records + grid.addRow({ + Downloads: 0, + ID: 1010, + ProductName: 'Ignite UI for Everyone', + ReleaseDate: new Date(), + Released: false + }); + tick(); + fix.detectChanges(); + groupRows = grid.groupsRowList.toArray(); + dataRows = grid.dataRowList.toArray(); + expect(groupRows.length).toEqual(6); + expect(dataRows.length).toEqual(9); + + // update records + grid.updateRow({ ID: 1010, ProductName: 'Ignite UI for Angular' }, 1010); + tick(); + fix.detectChanges(); + + groupRows = grid.groupsRowList.toArray(); + dataRows = grid.dataRowList.toArray(); + expect(groupRows.length).toEqual(5); + expect(dataRows.length).toEqual(9); + + grid.deleteRow(1010); + tick(); + fix.detectChanges(); + grid.deleteRow(3); + tick(); + fix.detectChanges(); + grid.deleteRow(6); + tick(); + fix.detectChanges(); + groupRows = grid.groupsRowList.toArray(); + dataRows = grid.dataRowList.toArray(); + expect(groupRows.length).toEqual(4); + expect(dataRows.length).toEqual(6); + })); + + + it('should update the UI when updating records via the UI after grouping is re-applied so that they more to the correct group', async () => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.enableEditing = true; + fix.componentInstance.width = '800px'; + grid.columnWidth = '200px'; + grid.primaryKey = 'ID'; + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + + const cell = grid.getCellByKey(5, 'ProductName'); + cell.column.editable = true; + fix.detectChanges(); + + UIInteractions.simulateDoubleClickAndSelectEvent(grid.gridAPI.get_cell_by_key(5, 'ProductName')); + await wait(); + fix.detectChanges(); + + expect(cell.editMode).toBe(true); + + const editCellDom = fix.debugElement.query(By.css('.igx-grid__td--editing')); + const input = editCellDom.query(By.css('input')); + + clickAndSendInputElementValue(input, 'NetAdvantage', fix); + await wait(); + GridFunctions.simulateGridContentKeydown(fix, 'Enter'); + await wait(30); + fix.detectChanges(); + + const groupRows = grid.groupsRowList.toArray(); + const dataRows = grid.dataRowList.toArray(); + + expect(groupRows.length).toEqual(4); + expect(dataRows.length).toEqual(8); + }); + + // GroupBy + Paging integration + it('should apply paging on both data records and group records.', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + fix.detectChanges(); + const grid = fix.componentInstance.instance; + fix.componentInstance.paging = true; + tick(); + fix.detectChanges(); + fix.componentInstance.instance.perPage = 4; + tick(); + fix.detectChanges(); + fix.componentInstance.instance.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + const groupRows = grid.groupsRowList.toArray(); + const dataRows = grid.dataRowList.toArray(); + + expect(groupRows.length).toEqual(2); + expect(dataRows.length).toEqual(2); + + expect(groupRows[0].groupRow.value).toEqual('NetAdvantage'); + expect(groupRows[1].groupRow.value).toEqual('Ignite UI for JavaScript'); + })); + + it('should have groups with correct summaries with paging.', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + fix.detectChanges(); + const grid = fix.componentInstance.instance; + fix.componentInstance.paging = true; + tick(); + fix.detectChanges(); + fix.componentInstance.instance.perPage = 4; + tick(); + fix.detectChanges(); + fix.componentInstance.instance.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + let groupRows = grid.groupsRowList.toArray(); + let dataRows = grid.dataRowList.toArray(); + + expect(groupRows.length).toEqual(2); + expect(dataRows.length).toEqual(2); + expect(groupRows[0].groupRow.records.length).toEqual(2); + expect(groupRows[1].groupRow.records.length).toEqual(2); + expect(groupRows[0].groupRow.value).toEqual('NetAdvantage'); + expect(groupRows[1].groupRow.value).toEqual('Ignite UI for JavaScript'); + + fix.componentInstance.instance.paginator.paginate(1); + tick(); + fix.detectChanges(); + + groupRows = grid.groupsRowList.toArray(); + dataRows = grid.dataRowList.toArray(); + expect(groupRows.length).toEqual(1); + expect(dataRows.length).toEqual(3); + expect(groupRows[0].groupRow.records.length).toEqual(2); + expect(groupRows[0].groupRow.value).toEqual('Ignite UI for Angular'); + })); + + it('should persist groupby state between pages.', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + fix.detectChanges(); + const grid = fix.componentInstance.instance; + fix.componentInstance.paging = true; + tick(); + fix.detectChanges(); + fix.componentInstance.instance.perPage = 4; + tick(); + fix.detectChanges(); + fix.componentInstance.instance.groupingExpansionState.push({ + expanded: false, + hierarchy: [{ fieldName: 'ProductName', value: 'Ignite UI for JavaScript' }] + }); + tick(); + fix.detectChanges(); + fix.componentInstance.instance.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + let groupRows = grid.groupsRowList.toArray(); + let dataRows = grid.dataRowList.toArray(); + + expect(groupRows.length).toEqual(2); + expect(dataRows.length).toEqual(2); + expect(groupRows[0].groupRow.records.length).toEqual(2); + expect(groupRows[1].groupRow.records.length).toEqual(2); + expect(dataRows[1].data.ProductName).toEqual('NetAdvantage'); + + fix.componentInstance.instance.paginator.paginate(1); + tick(); + fix.detectChanges(); + + groupRows = grid.groupsRowList.toArray(); + dataRows = grid.dataRowList.toArray(); + expect(groupRows.length).toEqual(2); + expect(dataRows.length).toEqual(2); + expect(groupRows[0].groupRow.records.length).toEqual(2); + expect(groupRows[1].groupRow.records.length).toEqual(1); + expect(dataRows[0].data.ProductName).toEqual('Ignite UI for Angular'); + + fix.componentInstance.instance.paginator.paginate(0); + tick(); + fix.detectChanges(); + + groupRows = grid.groupsRowList.toArray(); + dataRows = grid.dataRowList.toArray(); + expect(groupRows.length).toEqual(2); + expect(dataRows.length).toEqual(2); + expect(groupRows[0].groupRow.records.length).toEqual(2); + expect(groupRows[1].groupRow.records.length).toEqual(2); + expect(dataRows[1].data.ProductName).toEqual('NetAdvantage'); + })); + + // GroupBy Area + it('should apply group area if a column is grouped.', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.enableSorting = true; + tick(); + fix.detectChanges(); + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + // verify group area is rendered + expect(grid.groupArea).toBeDefined(); + })); + + it('should apply group area if a column is groupable.', fakeAsync(() => { + const fix = TestBed.createComponent(GroupableGridComponent); + const grid = fix.componentInstance.instance; + tick(); + fix.detectChanges(); + const gridElement: HTMLElement = fix.nativeElement.querySelector('.igx-grid'); + // verify group area is rendered + expect(grid.groupArea).toBeDefined(); + expect(gridElement.clientHeight).toEqual(700); + })); + + it('should allow collapsing and expanding all group rows', fakeAsync(() => { + const fix = TestBed.createComponent(GroupableGridComponent); + const grid = fix.componentInstance.instance; + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: false + }); + grid.toggleAllGroupRows(); + tick(); + fix.detectChanges(); + const groupRows = grid.groupsRowList.toArray(); + expect(groupRows[0].expanded).not.toBe(true); + expect(groupRows[groupRows.length - 1].expanded).not.toBe(true); + + grid.toggleAllGroupRows(); + tick(); + fix.detectChanges(); + expect(groupRows[0].expanded).toBe(true); + expect(groupRows[groupRows.length - 1].expanded).toBe(true); + })); + + it('should update horizontal virtualization state correctly when data row views are re-used from cache.', async () => { + const fix = TestBed.createComponent(GroupableGridComponent); + const grid = fix.componentInstance.instance; + fix.detectChanges(); + // group and collapse all groups + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + grid.toggleAllGroupRows(); + await wait(100); + fix.detectChanges(); + + // scroll left + grid.headerContainer.getScroll().scrollLeft = 1000; + fix.detectChanges(); + + const gridScrLeft = grid.headerContainer.getScroll().scrollLeft; + await wait(100); + fix.detectChanges(); + + grid.toggleAllGroupRows(); + fix.detectChanges(); + await wait(); + // verify rows are scrolled to the right + let dataRows = grid.dataRowList.toArray(); + dataRows.forEach(dr => { + const virtualization = dr.virtDirRow; + // should be at last chunk + const expectedStartIndex = virtualization.igxForOf.length - virtualization.state.chunkSize; + expect(virtualization.state.startIndex).toBe(expectedStartIndex); + // should have correct left offset + const left = parseInt(virtualization.dc.instance._viewContainer.element.nativeElement.style.left, 10); + expect(-left).toBe(gridScrLeft - virtualization.getColumnScrollLeft(expectedStartIndex)); + }); + + // scroll down + grid.verticalScrollContainer.getScroll().scrollTop = 10000; + await wait(100); + fix.detectChanges(); + + // verify rows are scrolled to the right + dataRows = grid.dataRowList.toArray(); + dataRows.forEach(dr => { + const virtualization = dr.virtDirRow; + // should be at last chunk + const expectedStartIndex = virtualization.igxForOf.length - virtualization.state.chunkSize; + expect(virtualization.state.startIndex).toBe(expectedStartIndex); + // should have correct left offset + const left = parseInt(virtualization.dc.instance._viewContainer.element.nativeElement.style.left, 10); + expect(-left).toBe(gridScrLeft - virtualization.getColumnScrollLeft(expectedStartIndex)); + }); + }); + + // GroupBy chip + it('should apply the chip correctly when there are grouping expressions applied and reordered', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + fix.detectChanges(); + + // set groupingExpressions + const grid = fix.componentInstance.instance; + const exprs: ISortingExpression[] = [ + { fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: true }, + { fieldName: 'Released', dir: SortingDirection.Desc, ignoreCase: true } + ]; + grid.groupingExpressions = exprs; + tick(); + fix.detectChanges(); + let groupRows = grid.groupsRowList.toArray(); + checkGroups(groupRows, + ['NetAdvantage', true, false, 'Ignite UI for JavaScript', true, + false, 'Ignite UI for Angular', false, null, '', true, null, true], + grid.groupingExpressions); + const chips = grid.groupArea.chips; + checkChips(chips, grid.groupingExpressions); + + // change order + grid.groupingExpressions = [ + { fieldName: 'Released', dir: SortingDirection.Asc, ignoreCase: true }, + { fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: true } + ]; + tick(); + grid.sortingExpressions = [ + { fieldName: 'Released', dir: SortingDirection.Asc, ignoreCase: true }, + { fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: true } + ]; + tick(); + fix.detectChanges(); + + groupRows = grid.groupsRowList.toArray(); + // verify groups + checkGroups(groupRows, + [null, 'Ignite UI for Angular', false, 'Ignite UI for Angular', 'Ignite UI for JavaScript', + 'NetAdvantage', true, null, '', 'Ignite UI for JavaScript', 'NetAdvantage'], + grid.groupingExpressions); + checkChips(chips, grid.groupingExpressions); + })); + + it('should apply the chip correctly when there is grouping at runtime', fakeAsync(() => { + const fix = TestBed.createComponent(GroupableGridComponent); + const grid = fix.componentInstance.instance; + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + const groupRows = grid.groupsRowList.toArray(); + const chips = fix.nativeElement.querySelectorAll('igx-chip'); + checkChips(chips, grid.groupingExpressions); + checkGroups(groupRows, ['NetAdvantage', 'Ignite UI for JavaScript', 'Ignite UI for Angular', '', null]); + })); + + it('should remove sorting when grouping is removed', fakeAsync(() => { + const fix = TestBed.createComponent(GroupableGridComponent); + const grid = fix.componentInstance.instance; + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + let chips = fix.nativeElement.querySelectorAll('igx-chip'); + // click close button + UIInteractions.simulateMouseEvent('click', ControlsFunction.getChipRemoveButton(chips[0]), 0, 0); + tick(); + fix.detectChanges(); + chips = fix.nativeElement.querySelectorAll('igx-chip'); + expect(chips.length).toBe(0); + expect(grid.groupingExpressions.length).toBe(0); + expect(grid.sortingExpressions.length).toBe(0); + })); + + it('should change sorting direction when grouping changes direction', fakeAsync(() => { + const fix = TestBed.createComponent(GroupableGridComponent); + const grid = fix.componentInstance.instance; + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + const chips = grid.groupArea.chips; + // click grouping direction arrow + grid.groupArea.handleClick(chips.get(0).id); + tick(); + fix.detectChanges(); + tick(); + expect(chips.length).toBe(1); + checkChips(chips, grid.groupingExpressions); + expect(chips.get(0).nativeElement.querySelectorAll('igx-icon')[0].textContent.trim()).toBe('arrow_upward'); + })); + + it('should change grouping direction when sorting changes direction', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.enableSorting = true; + tick(); + fix.detectChanges(); + + grid.groupBy({ fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: false }); + fix.detectChanges(); + const productNameCol = grid.headerGroupsList.find(header => header.column.field === 'ProductName'); + UIInteractions.simulateClickEvent(productNameCol.nativeElement); + tick(); + fix.detectChanges(); + const chips = grid.groupArea.chips; + tick(); + checkChips(chips, grid.groupingExpressions); + })); + + it('should allow row selection after grouping, scrolling down to a new virtual frame and attempting to select a row.', async () => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + grid.rowSelection = GridSelectionMode.multiple; + fix.componentInstance.height = '200px'; + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + grid.groupBy({ + fieldName: 'Released', dir: SortingDirection.Desc, ignoreCase: false + }); + + fix.detectChanges(); + + // scroll to bottom + grid.verticalScrollContainer.getScroll().scrollTop = 10000; + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + const rows = grid.dataRowList.toArray(); + expect(rows.length).toEqual(1); + GridSelectionFunctions.clickRowCheckbox(rows[0].element); + await wait(100); + fix.detectChanges(); + expect(grid.selectedRows.length).toEqual(1); + GridSelectionFunctions.verifyRowSelected(rows[0]); + + }); + + it('should persist state for the correct group record when there are group records with the same fieldName and value.', + fakeAsync(() => { + const fix = TestBed.createComponent(GroupableGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.data = [ + { + Downloads: 0, + ID: 1, + ProductName: 'JavaScript', + ReleaseDate: new Date(), + Released: false + }, + { + Downloads: 0, + ID: 2, + ProductName: 'JavaScript', + ReleaseDate: new Date(), + Released: true + } + ]; + tick(); + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'Released', + dir: SortingDirection.Asc, + }); + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: false + }); + + fix.detectChanges(); + + const groupRows = grid.groupsRowList.toArray(); + + // group rows that have the same fieldName and value but belong to different parent groups + const similarGroupRows = groupRows.filter((gRows) => + gRows.groupRow.value === 'JavaScript' && gRows.groupRow.expression.fieldName); + expect(similarGroupRows.length).toEqual(2); + + // verify that if one is collapse the other remains expanded + similarGroupRows[0].toggle(); + tick(); + + expect(similarGroupRows[0].expanded).toEqual(false); + expect(similarGroupRows[1].expanded).toEqual(true); + })); + + it('should render disabled non-interactable chip for column that does not allow grouping.', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.detectChanges(); + grid.getColumnByName('ProductName').groupable = false; + tick(); + grid.getColumnByName('Released').groupable = true; + tick(); + fix.detectChanges(); + grid.groupBy([ + { fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: true }, + { fieldName: 'Released', dir: SortingDirection.Asc, ignoreCase: true } + ]); + fix.detectChanges(); + + const chips = fix.nativeElement.querySelectorAll(CHIP); + tick(); + expect(chips.length).toBe(2); + + // check correct chip is disabled + expect(chips[0].className).toContain(DISABLED_CHIP); + expect(chips[1].className).not.toContain(DISABLED_CHIP); + + // check no remove button on disabled chip + expect(ControlsFunction.getChipRemoveButton(chips[0])).toBeNull(); + expect(ControlsFunction.getChipRemoveButton(chips[1])).toBeDefined(); + + // check click does not allow changing sort dir + chips[0].children[0].dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1 })); + tick(); + chips[0].children[0].dispatchEvent(new PointerEvent('pointerup')); + tick(); + fix.detectChanges(); + + chips[1].children[0].dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1 })); + tick(); + chips[1].children[0].dispatchEvent(new PointerEvent('pointerup')); + tick(); + + fix.detectChanges(); + grid.cdr.detectChanges(); + + const fChipDirection = chips[0].querySelector('[igxsuffix]').innerText; + const sChipDirection = chips[1].querySelector('[igxsuffix]').innerText; + + expect(fChipDirection).toEqual('arrow_upward'); + expect(sChipDirection).toEqual('arrow_downward'); + })); + + it('should remove expansion state when removing groups', fakeAsync(() => { + const fix = TestBed.createComponent(GroupableGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.data = [ + { + Downloads: 0, + ID: 1, + ProductName: 'JavaScript', + ReleaseDate: new Date(), + Released: false + }, + { + Downloads: 0, + ID: 2, + ProductName: 'JavaScript', + ReleaseDate: new Date(), + Released: true + } + ]; + tick(); + fix.detectChanges(); + + grid.groupBy([ + { fieldName: 'Released', dir: SortingDirection.Asc, ignoreCase: false }, + { fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: false } + ]); + fix.detectChanges(); + + const groupRows = grid.groupsRowList.toArray(); + groupRows[1].toggle(); + tick(); + fix.detectChanges(); + expect(groupRows[0].expanded).toEqual(true); + expect(groupRows[1].expanded).toEqual(false); + + grid.clearGrouping('ProductName'); + tick(); + fix.detectChanges(); + + grid.groupBy([{ + fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: false + }]); + fix.detectChanges(); + + expect(groupRows[0].expanded).toEqual(true); + expect(groupRows[1].expanded).toEqual(true); + expect(groupRows[2].expanded).toEqual(true); + expect(groupRows[3].expanded).toEqual(true); + + groupRows[1].toggle(); + tick(); + fix.detectChanges(); + + grid.clearGrouping(); + tick(); + fix.detectChanges(); + + grid.groupBy([ + { fieldName: 'Released', dir: SortingDirection.Asc, ignoreCase: false }, + { fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: false } + ]); + fix.detectChanges(); + + expect(groupRows[0].expanded).toEqual(true); + expect(groupRows[1].expanded).toEqual(true); + expect(groupRows[2].expanded).toEqual(true); + expect(groupRows[3].expanded).toEqual(true); + })); + + it('should remove expansion state of groups with higher group hierarchy', fakeAsync(() => { + const fix = TestBed.createComponent(GroupableGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.data = [ + { + Downloads: 0, + ID: 1, + ProductName: 'JavaScript', + ReleaseDate: new Date(), + Released: false + }, + { + Downloads: 0, + ID: 2, + ProductName: 'JavaScript', + ReleaseDate: new Date(), + Released: true + } + ]; + tick(); + fix.detectChanges(); + + grid.groupBy([ + { fieldName: 'Released', dir: SortingDirection.Asc, ignoreCase: false }, + { fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: false } + ]); + fix.detectChanges(); + + let groupRows = grid.groupsRowList.toArray(); + groupRows[1].toggle(); + tick(); + fix.detectChanges(); + expect(groupRows[0].expanded).toEqual(true); + expect(groupRows[1].expanded).toEqual(false); + + grid.clearGrouping('Released'); + tick(); + fix.detectChanges(); + + grid.groupBy([ + { fieldName: 'Released', dir: SortingDirection.Asc, ignoreCase: false } + ]); + fix.detectChanges(); + + // reorder chips by simulating events + const chips = fix.nativeElement.querySelectorAll('igx-chip'); + UIInteractions.simulatePointerEvent('pointerdown', chips[0], 0, 0); + tick(); + UIInteractions.simulatePointerEvent('pointermove', chips[0], 200, 0); + tick(); + UIInteractions.simulatePointerEvent('pointerup', chips[0], 0, 0); + tick(); + fix.detectChanges(); + + groupRows = grid.groupsRowList.toArray(); + expect(groupRows[0].expanded).toEqual(true); + expect(groupRows[1].expanded).toEqual(true); + })); + + it('should reorder groups when reordering chip', async () => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.detectChanges(); + grid.groupBy({ fieldName: 'Released', dir: SortingDirection.Desc, ignoreCase: false }); + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + + const chipComponents = fix.debugElement.queryAll(By.directive(IgxChipComponent)); + // Disable chip animations + chipComponents.forEach((chip) => { + chip.componentInstance.animateOnRelease = false; + }); + + // Trigger initial pointer events on the element with igxDrag. When the drag begins the ghostElement should receive events. + UIInteractions.simulatePointerEvent('pointerdown', chipComponents[0].componentInstance.dragDirective.element.nativeElement, 75, 30); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', + chipComponents[0].componentInstance.dragDirective.element.nativeElement, 110, 30); + await wait(); + fix.detectChanges(); + + UIInteractions.simulatePointerEvent('pointermove', chipComponents[0].componentInstance.dragDirective.ghostElement, 250, 30); + await wait(); + fix.detectChanges(); + + UIInteractions.simulatePointerEvent('pointerup', chipComponents[0].componentInstance.dragDirective.ghostElement, 250, 30); + await wait(); + fix.detectChanges(); + const chips = grid.groupArea.chips; + checkChips(chips, grid.groupingExpressions); + + // verify groups + const groupRows = grid.groupsRowList.toArray(); + checkGroups(groupRows, + ['NetAdvantage', true, false, 'Ignite UI for JavaScript', true, + false, 'Ignite UI for Angular', false, null, '', true, null, true], + grid.groupingExpressions); + + }); + + it('should remove expansion state when reordering chips', async () => { + const fix = TestBed.createComponent(GroupableGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.data = [ + { + Downloads: 0, + ID: 1, + ProductName: 'JavaScript', + ReleaseDate: new Date(), + Released: false + }, + { + Downloads: 0, + ID: 2, + ProductName: 'JavaScript', + ReleaseDate: new Date(), + Released: true + } + ]; + fix.detectChanges(); + + grid.groupBy([ + { fieldName: 'Released', dir: SortingDirection.Asc, ignoreCase: false }, + { fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: false } + ]); + fix.detectChanges(); + + let groupRows = grid.groupsRowList.toArray(); + groupRows[1].toggle(); + fix.detectChanges(); + expect(groupRows[0].expanded).toEqual(true); + expect(groupRows[1].expanded).toEqual(false); + + groupRows = grid.groupsRowList.toArray(); + // reorder chips by simulating events + let chipComponents = fix.debugElement.queryAll(By.directive(IgxChipComponent)); + // Disable chip animations + chipComponents.forEach((chip) => { + chip.componentInstance.animateOnRelease = false; + }); + fix.detectChanges(); + + // Trigger initial pointer events on the element with igxDrag. When the drag begins the ghostElement should receive events. + UIInteractions.simulatePointerEvent('pointerdown', + chipComponents[0].componentInstance.dragDirective.element.nativeElement, 100, 30); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', + chipComponents[0].componentInstance.dragDirective.element.nativeElement, 110, 30); + await wait(); + fix.detectChanges(); + + UIInteractions.simulatePointerEvent('pointermove', chipComponents[0].componentInstance.dragDirective.ghostElement, 250, 30); + await wait(); + fix.detectChanges(); + + UIInteractions.simulatePointerEvent('pointerup', chipComponents[0].componentInstance.dragDirective.ghostElement, 250, 30); + await wait(); + fix.detectChanges(); + + expect(groupRows[0].expanded).toEqual(true); + expect(groupRows[1].expanded).toEqual(true); + + let chipsElems = fix.nativeElement.querySelectorAll('igx-chip'); + expect(chipsElems[0].querySelector('div.igx-chip__content').textContent.trim()).toEqual('ProductName'); + expect(chipsElems[1].querySelector('div.igx-chip__content').textContent.trim()).toEqual('Released'); + + // reorder chips again to revert them in original state + chipComponents = fix.debugElement.queryAll(By.directive(IgxChipComponent)); + + // Trigger initial pointer events on the element with igxDrag. When the drag begins the ghostElement should receive events. + UIInteractions.simulatePointerEvent('pointerdown', + chipComponents[0].componentInstance.dragDirective.element.nativeElement, 100, 30); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', + chipComponents[0].componentInstance.dragDirective.element.nativeElement, 110, 30); + await wait(); + fix.detectChanges(); + + UIInteractions.simulatePointerEvent('pointermove', chipComponents[0].componentInstance.dragDirective.ghostElement, 250, 30); + await wait(); + fix.detectChanges(); + + UIInteractions.simulatePointerEvent('pointerup', chipComponents[0].componentInstance.dragDirective.ghostElement, 250, 30); + await wait(); + fix.detectChanges(); + + chipsElems = fix.nativeElement.querySelectorAll('igx-chip'); + expect(chipsElems[0].querySelector('div.igx-chip__content').textContent.trim()).toEqual('Released'); + expect(chipsElems[1].querySelector('div.igx-chip__content').textContent.trim()).toEqual('ProductName'); + + groupRows = grid.groupsRowList.toArray(); + expect(groupRows[0].expanded).toEqual(true); + expect(groupRows[1].expanded).toEqual(true); + }); + + it('should not throw an error when moving a column over a chip when there is grouped columns', async () => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.detectChanges(); + + grid.groupBy({ fieldName: 'Released', dir: SortingDirection.Desc, ignoreCase: false }); + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false + }); + fix.detectChanges(); + + const firstColumn = fix.debugElement.query(By.directive(IgxColumnMovingDragDirective)); + const directiveInstance = firstColumn.injector.get(IgxColumnMovingDragDirective); + + // Trigger initial pointer events on the element with igxDrag. When the drag begins the ghostElement should receive events. + UIInteractions.simulatePointerEvent('pointerdown', firstColumn.nativeElement, 75, 30); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', firstColumn.nativeElement, 110, 30); + await wait(); + + expect(async () => { + fix.detectChanges(); + UIInteractions.simulatePointerEvent('pointermove', directiveInstance.ghostElement, 250, 30); + await wait(); + }).not.toThrow(); + + fix.detectChanges(); + UIInteractions.simulatePointerEvent('pointerup', directiveInstance.ghostElement, 250, 30); + await wait(); + }); + + it('should throw an error when grouping more than 10 colunms', fakeAsync(() => { + const fix = TestBed.createComponent(GroupByDataMoreColumnsComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.testData = [ + { A: '1', B: 'ALFKI', C: '2', D: '3', E: '4', F: '5', H: '6', G: '7', K: '8', L: '9', M: '10', N: '1' } + ]; + tick(); + fix.detectChanges(); + let m = ''; + const expr = fix.componentInstance.columns.map(val => ({ fieldName: val.field, dir: SortingDirection.Asc, ignoreCase: true })); + // not allowed to group by more than 10 columns + try { + grid.groupBy(expr); + tick(); + } catch (e) { + m = e.message; + } + tick(); + expect(m).toBe('Maximum amount of grouped columns is 10.'); + })); + + it('should not allow grouping by column with no name', fakeAsync(() => { + const fix = TestBed.createComponent(GroupByEmptyColumnFieldComponent); + const grid = fix.componentInstance.instance; + fix.detectChanges(); + tick(); + const expr = grid.columnList.map(val => ({ fieldName: val.field, dir: SortingDirection.Asc, ignoreCase: true })); + grid.groupBy(expr); + tick(); + expect(grid.groupsRowList.toArray().length).toBe(0); + })); + + it('should display column header text in the grouping chip.', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.detectChanges(); + grid.columns[0].header = 'Custom Header Text'; + tick(); + fix.detectChanges(); + + grid.groupBy({ fieldName: 'Downloads', dir: SortingDirection.Asc, ignoreCase: false }); + fix.detectChanges(); + + const chips = fix.nativeElement.querySelectorAll(CHIP); + expect(chips.length).toBe(1); + const chipText = chips[0].querySelector('div.igx-chip__content').innerText; + expect(chipText).toEqual('Custom Header Text'); + expect(chips[0].getAttribute('title')).toEqual('Custom Header Text'); + })); + + it('should update grid sizes when columns are grouped/ungrouped.', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + fix.componentInstance.width = '400px'; + tick(); + fix.componentInstance.height = '500px'; + tick(); + const grid = fix.componentInstance.instance; + fix.detectChanges(); + const groupArea = grid.groupArea; + const gridHeader = grid.theadRow; + const gridFooter = grid.tfoot; + const gridScroll = fix.debugElement.query(By.css(GRID_SCROLL_CLASS)); + + let expectedHeight = parseInt(window.getComputedStyle(grid.nativeElement).height, 10) + - parseInt(window.getComputedStyle(groupArea.nativeElement).height, 10) + - parseInt(window.getComputedStyle(gridHeader.nativeElement).height, 10) + - parseInt(window.getComputedStyle(gridFooter.nativeElement).height, 10) + - parseInt(window.getComputedStyle(gridScroll.nativeElement).height, 10); + + expect(grid.calcHeight).toEqual(expectedHeight); + + // verify height is recalculated. + grid.groupBy({ fieldName: 'Released', dir: SortingDirection.Asc, ignoreCase: false }); + grid.groupBy({ fieldName: 'Downloads', dir: SortingDirection.Asc, ignoreCase: false }); + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: false + }); + grid.groupBy({ + fieldName: 'ReleaseDate', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + + expectedHeight = parseInt(window.getComputedStyle(grid.nativeElement).height, 10) + - parseInt(window.getComputedStyle(groupArea.nativeElement).height, 10) + - parseInt(window.getComputedStyle(gridHeader.nativeElement).height, 10) + - parseInt(window.getComputedStyle(gridFooter.nativeElement).height, 10) + - parseInt(window.getComputedStyle(gridScroll.nativeElement).height, 10); + + expect(grid.calcHeight).toEqual(expectedHeight); + // verify width is recalculated + const indentation = fix.debugElement.query(By.css('.igx-grid__header-indentation')); + + expect(grid.pinnedStartWidth).toEqual(parseInt(window.getComputedStyle(indentation.nativeElement).width, 10)); + expect(grid.unpinnedWidth).toEqual(400 - parseInt(window.getComputedStyle(indentation.nativeElement).width, 10) - grid.scrollSize); + + grid.clearGrouping(); + tick(); + fix.detectChanges(); + + expectedHeight = parseInt(window.getComputedStyle(grid.nativeElement).height, 10) + - parseInt(window.getComputedStyle(groupArea.nativeElement).height, 10) + - parseInt(window.getComputedStyle(gridHeader.nativeElement).height, 10) + - parseInt(window.getComputedStyle(gridFooter.nativeElement).height, 10) + - parseInt(window.getComputedStyle(gridScroll.nativeElement).height, 10); + + expect(grid.calcHeight).toEqual(expectedHeight); + expect(grid.pinnedStartWidth).toEqual(0); + const expectedWidth = parseInt(grid.width, 10) - grid.scrollSize; + expect(grid.unpinnedWidth).toEqual(expectedWidth); + })); + + it('should expose tree structure to access groups', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.detectChanges(); + grid.groupBy([ + { fieldName: 'Released', dir: SortingDirection.Asc, ignoreCase: false }, + { fieldName: 'Downloads', dir: SortingDirection.Asc, ignoreCase: false }, + { fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: false } + ]); + fix.detectChanges(); + + // there should be 3 groups at top level + const groupsRecords = grid.groupsRecords; + expect(groupsRecords.length).toBe(3); + expect(groupsRecords[0].value).toBeNull(); + expect(groupsRecords[0].expression.fieldName).toBe('Released'); + // the first group should have 1 sub group which has 1 subgroup too + const fsubGroups = groupsRecords[0].groups; + expect(fsubGroups.length).toBe(1); + expect(fsubGroups[0].value).toBe(1000); + expect(fsubGroups[0].expression.fieldName).toBe('Downloads'); + const fsubsubGroups = groupsRecords[0].groups[0].groups; + expect(fsubsubGroups.length).toBe(1); + expect(fsubsubGroups[0].value).toBe('Ignite UI for Angular'); + expect(fsubsubGroups[0].expression.fieldName).toBe('ProductName'); + + expect(groupsRecords[2].value).toBe(true); + expect(groupsRecords[2].expression.fieldName).toBe('Released'); + // the last group should have 4 sub group which has 1 subgroup + const lsubGroups = groupsRecords[2].groups; + expect(lsubGroups.length).toBe(4); + expect(lsubGroups[0].value).toBeNull(); + expect(lsubGroups[0].expression.fieldName).toBe('Downloads'); + const lsubsubGroups = groupsRecords[2].groups[0].groups; + expect(lsubsubGroups.length).toBe(1); + expect(lsubsubGroups[0].value).toBe('Ignite UI for JavaScript'); + expect(lsubsubGroups[0].expression.fieldName).toBe('ProductName'); + })); + + it('should allows expanding/collapsing groups extracted from the groupRows tree', fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + grid.primaryKey = 'ID'; + fix.detectChanges(); + grid.groupBy({ fieldName: 'Released', dir: SortingDirection.Desc, ignoreCase: false }); + fix.detectChanges(); + + let groupRows = grid.groupsRowList.toArray(); + let dataRows = grid.dataRowList.toArray(); + // verify groups and data rows count + expect(groupRows.length).toEqual(3); + expect(dataRows.length).toEqual(8); + + // toggle group row - collapse + expect(groupRows[0].expanded).toEqual(true); + grid.toggleGroup(grid.groupsRecords[0]); + fix.detectChanges(); + expect(groupRows[0].expanded).toEqual(false); + groupRows = grid.groupsRowList.toArray(); + dataRows = grid.dataRowList.toArray(); + expect(groupRows.length).toEqual(3); + expect(dataRows.length).toEqual(4); + // verify collapsed group sub records are not rendered + + for (const rec of groupRows[0].groupRow.records) { + expect(grid.gridAPI.get_row_by_key(rec.ID)).toBeUndefined(); + } + + // toggle group row - expand + grid.toggleGroup(grid.groupsRecords[0]); + fix.detectChanges(); + expect(groupRows[0].expanded).toEqual(true); + groupRows = grid.groupsRowList.toArray(); + dataRows = grid.dataRowList.toArray(); + expect(groupRows.length).toEqual(3); + expect(dataRows.length).toEqual(8); + + // verify expanded group sub records are rendered + for (const rec of groupRows[0].groupRow.records) { + expect(grid.getRowByKey(rec.ID)).not.toBeUndefined(); + } + })); + + it('should allow setting groupingExpressions and sortingExpressions initially.', + fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + fix.componentInstance.enableSorting = true; + const grid = fix.componentInstance.instance; + grid.sortingExpressions = + [{ fieldName: 'Downloads', dir: SortingDirection.Asc, ignoreCase: false }]; + grid.groupingExpressions = + [{ fieldName: 'Released', dir: SortingDirection.Asc, ignoreCase: false }]; + fix.detectChanges(); + + //grouping expressions should not affect grouping expressions + expect(grid.sortingExpressions.length).toEqual(1); + expect(grid.groupingExpressions.length).toEqual(1); + + const groupRows = grid.groupsRowList.toArray(); + + expect(groupRows.length).toEqual(3); + + const chips = grid.groupArea.chips; + checkChips(chips, grid.groupingExpressions); + + const sortingIcon = fix.debugElement.query(By.css('.sort-icon')); + expect(sortingIcon.nativeElement.textContent.trim()).toEqual(SORTING_ICON_ASC_CONTENT); + })); + + it('should show horizontal scrollbar if column widths are equal to the grid width and a column is grouped.', + fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + + const grid = fix.componentInstance.instance; + + grid.columnWidth = '200px'; + tick(); + fix.componentInstance.width = '1000px'; + tick(); + + fix.detectChanges(); + + const hScrBar = grid.scr.nativeElement; + expect(hScrBar.hidden).toBe(true); + + grid.groupBy({ + fieldName: 'Downloads', + dir: SortingDirection.Asc, + ignoreCase: false + }); + fix.detectChanges(); + expect(hScrBar.hidden).toBe(false); + })); + + it('should allow changing the text of the drop area', async () => { + const fix = TestBed.createComponent(DefaultGridComponent); + fix.detectChanges(); + + fix.componentInstance.instance.dropAreaMessage = 'Drop area here!'; + await wait(); + fix.detectChanges(); + + const groupDropArea = fix.debugElement.query(By.directive(IgxGroupAreaDropDirective)); + expect(groupDropArea.nativeElement.children[1].textContent).toEqual('Drop area here!'); + }); + + it('should allow templating the drop area by passing template reference', async () => { + const fix = TestBed.createComponent(DefaultGridComponent); + fix.detectChanges(); + + fix.componentInstance.currentDropArea = fix.componentInstance.dropAreaTemplate; + await wait(); + fix.detectChanges(); + + const groupDropArea = fix.debugElement.query(By.directive(IgxGroupAreaDropDirective)); + expect(groupDropArea.nativeElement.textContent.trim()).toEqual('Custom template'); + }); + + it('should hide all the grouped columns when hideGroupedColumns option is initially set to "true"', + fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + grid.hideGroupedColumns = true; + tick(); + fix.detectChanges(); + grid.groupBy([ + { fieldName: 'Downloads', dir: SortingDirection.Asc, ignoreCase: false }, + { fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: false } + ]); + tick(); + fix.detectChanges(); + // the two grouped columns should be hidden + expect(grid.getColumnByName('Downloads').hidden).toBe(true); + expect(grid.getColumnByName('ProductName').hidden).toBe(true); + // these should be visible + expect(grid.getColumnByName('ID').hidden).toBe(false); + expect(grid.getColumnByName('ReleaseDate').hidden).toBe(false); + expect(grid.getColumnByName('Released').hidden).toBe(false); + })); + + it('should show all the grid columns when hideGroupedColumns option is set to "false" at runtime, after being "true" initially', + fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + grid.hideGroupedColumns = true; + fix.detectChanges(); + grid.groupBy([ + { fieldName: 'Downloads', dir: SortingDirection.Asc, ignoreCase: false }, + { fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: false } + ]); + tick(); + fix.detectChanges(); + // the two grouped columns should be hidden initially + expect(grid.getColumnByName('Downloads').hidden).toBe(true); + expect(grid.getColumnByName('ProductName').hidden).toBe(true); + grid.hideGroupedColumns = false; + tick(); + fix.detectChanges(); + // all columns, whether grouped or ungrouped, should be visible + expect(grid.getColumnByName('Downloads').hidden).toBe(false); + expect(grid.getColumnByName('ProductName').hidden).toBe(false); + expect(grid.getColumnByName('ID').hidden).toBe(false); + expect(grid.getColumnByName('ReleaseDate').hidden).toBe(false); + expect(grid.getColumnByName('Released').hidden).toBe(false); + })); + + it('should hide the grouped columns when hideGroupedColumns option is set to "true" at runtime, after being "false" initially', + fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.detectChanges(); + grid.groupBy([ + { fieldName: 'Downloads', dir: SortingDirection.Asc, ignoreCase: false }, + { fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: false } + ]); + tick(); + fix.detectChanges(); + // all columns, whether grouped or ungrouped, should be visible + expect(grid.getColumnByName('Downloads').hidden).toBe(false); + expect(grid.getColumnByName('ProductName').hidden).toBe(false); + expect(grid.getColumnByName('ID').hidden).toBe(false); + expect(grid.getColumnByName('ReleaseDate').hidden).toBe(false); + expect(grid.getColumnByName('Released').hidden).toBe(false); + grid.hideGroupedColumns = true; + tick(); + fix.detectChanges(); + // the two grouped columns should now be hidden + expect(grid.getColumnByName('Downloads').hidden).toBe(true); + expect(grid.getColumnByName('ProductName').hidden).toBe(true); + })); + + it(`should hide the grouped columns when hideGroupedColumns option is enabled, + there are initially set groupingExpressions and columns are autogenerated`, + fakeAsync(() => { + const fix = TestBed.createComponent(DefaultGridComponent); + fix.detectChanges(); + const grid = fix.componentInstance.instance; + grid.hideGroupedColumns = true; + grid.groupingExpressions = [ + { fieldName: 'Released', dir: SortingDirection.Asc } + ]; + fix.detectChanges(); + expect(grid.getColumnByName('Released').hidden).toBe(true); + const groupRows = grid.groupsRowList.toArray(); + + expect(groupRows.length).toEqual(3); + })); + + it('should hide all the grouped columns when hideGroupedColumns option is "true" and columns are set runtime', + fakeAsync(() => { + const fix = TestBed.createComponent(GroupByDataMoreColumnsComponent); + fix.detectChanges(); + const grid = fix.componentInstance.instance; + fix.componentInstance.columns = []; + grid.hideGroupedColumns = true; + fix.detectChanges(); + tick(); + fix.detectChanges(); + fix.componentInstance.columns = [ + { field: 'A', width: 100 }, + { field: 'B', width: 100 }, + { field: 'C', width: 100 } + ]; + grid.groupingExpressions = [ + { fieldName: 'A', dir: SortingDirection.Asc } + ]; + fix.detectChanges(); + tick(); + + expect(grid.getColumnByName('A').hidden).toBe(true); + })); + + it('should respect current sorting direction when grouping', (async () => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.enableSorting = true; + fix.detectChanges(); + + grid.sort({ fieldName: 'Downloads', dir: SortingDirection.Desc }); + + const firstColumn = fix.debugElement.query(By.directive(IgxColumnMovingDragDirective)); + + UIInteractions.simulatePointerEvent('pointerdown', firstColumn.nativeElement, 75, 30); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', firstColumn.nativeElement, 110, 30); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', firstColumn.nativeElement, 100, 30); + await wait(50); + UIInteractions.simulatePointerEvent('pointerup', firstColumn.nativeElement, 100, 30); + await wait(50); + fix.detectChanges(); + + expect(grid.groupingExpressions[0].dir).toEqual(2); + })); + + it('should update grouping expression when sorting a column first then grouping by it and changing sorting for it again', () => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + const strategy = CustomSortingStrategy.instance(); + fix.componentInstance.enableSorting = true; + fix.detectChanges(); + + grid.sort({ fieldName: 'ID', dir: SortingDirection.Asc, ignoreCase: false, strategy }); + + expect(grid.sortingExpressions) + .toEqual([{ fieldName: 'ID', dir: SortingDirection.Asc, ignoreCase: false, strategy }]); + expect(grid.groupingExpressions).toEqual([]); + + grid.groupBy({ fieldName: 'ID', dir: SortingDirection.Asc, ignoreCase: false, strategy }); + grid.sort({ fieldName: 'ID', dir: SortingDirection.Desc, ignoreCase: false, strategy }); + + expect(grid.sortingExpressions) + .toEqual([{ fieldName: 'ID', dir: SortingDirection.Desc, ignoreCase: false, strategy }]); + expect(grid.groupingExpressions) + .toEqual([{ fieldName: 'ID', dir: SortingDirection.Desc, ignoreCase: false, strategy }]); + }); + + it('should update grouping expression when sorting a column first then grouping by another and changing sorting for it', () => { + const fix = TestBed.createComponent(DefaultGridComponent); + const grid = fix.componentInstance.instance; + fix.componentInstance.enableSorting = true; + fix.detectChanges(); + + grid.sort({ fieldName: 'Downloads', dir: SortingDirection.Asc, ignoreCase: false }); + grid.sort({ fieldName: 'ID', dir: SortingDirection.Desc, ignoreCase: false }); + fix.detectChanges(); + expect(grid.sortingExpressions).toEqual([ + { fieldName: 'Downloads', dir: SortingDirection.Asc, ignoreCase: false, strategy: DefaultSortingStrategy.instance() }, + { fieldName: 'ID', dir: SortingDirection.Desc, ignoreCase: false, strategy: DefaultSortingStrategy.instance() } + ]); + expect(grid.groupingExpressions).toEqual([]); + + grid.groupBy({ + fieldName: 'Released', dir: SortingDirection.Asc, ignoreCase: false, strategy: DefaultSortingStrategy.instance() + }); + grid.sort({ + fieldName: 'Released', dir: SortingDirection.Desc, ignoreCase: false, strategy: DefaultSortingStrategy.instance() + }); + fix.detectChanges(); + + expect(grid.sortingExpressions).toEqual([ + { fieldName: 'Downloads', dir: SortingDirection.Asc, ignoreCase: false, strategy: DefaultSortingStrategy.instance() }, + { fieldName: 'ID', dir: SortingDirection.Desc, ignoreCase: false, strategy: DefaultSortingStrategy.instance() }, + { fieldName: 'Released', dir: SortingDirection.Desc, ignoreCase: false, strategy: DefaultSortingStrategy.instance() } + ]); + expect(grid.groupingExpressions).toEqual([{ + fieldName: 'Released', dir: SortingDirection.Desc, ignoreCase: false, strategy: DefaultSortingStrategy.instance() + }]); + }); + + it('should not be able to group by ColumnGroup', async () => { + const fix = TestBed.createComponent(MultiColumnHeadersWithGroupingComponent); + const grid = fix.componentInstance.grid; + fix.detectChanges(); + + // Try to group by a column group + const header = fix.debugElement.queryAll(By.css('.igx-grid-thead__title'))[0].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 10, 10); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 150, 22); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 100, 30); + await wait(50); + UIInteractions.simulatePointerEvent('pointerup', header, 100, 30); + await wait(50); + fix.detectChanges(); + + // verify there is no grouping + const groupRows = grid.groupsRowList.toArray(); + expect(groupRows.length).toBe(0); + expect(grid.groupingExpressions).toEqual([]); + }); + + it('should not show the group area if only columnGroups has property groupable set to true', async () => { + const fix = TestBed.createComponent(MultiColumnHeadersWithGroupingComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + grid.getColumnByName('ID').groupable = false; + await wait(30); + fix.detectChanges(); + + // verify group area is not rendered + expect(grid.groupArea).not.toBeDefined(); + }); + + it('should add title attribute to chips when column is grouped', () => { + const fix = TestBed.createComponent(DefaultGridComponent); + fix.detectChanges(); + const exprs: ISortingExpression[] = [ + { fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: true }, + { fieldName: 'Released', dir: SortingDirection.Desc, ignoreCase: true } + ]; + const grid = fix.componentInstance.instance; + grid.groupBy(exprs); + fix.detectChanges(); + const chips = fix.nativeElement.querySelectorAll('igx-chip'); + expect(chips[0].getAttribute('title')).toEqual('ProductName'); + expect(chips[1].getAttribute('title')).toEqual('Released'); + }); + + it('should not be able to group by ColumnGroup', async () => { + const fix = TestBed.createComponent(MultiColumnHeadersWithGroupingComponent); + const grid = fix.componentInstance.grid; + fix.detectChanges(); + + // Try to group by a column group + const header = fix.debugElement.queryAll(By.css('.igx-grid-thead__title'))[0].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 10, 10); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 150, 22); + await wait(50); + UIInteractions.simulatePointerEvent('pointermove', header, 100, 30); + await wait(50); + UIInteractions.simulatePointerEvent('pointerup', header, 100, 30); + await wait(50); + fix.detectChanges(); + + // verify there is no grouping + const groupRows = grid.groupsRowList.toArray(); + expect(groupRows.length).toBe(0); + expect(grid.groupingExpressions).toEqual([]); + }); + + it('should not show the group area if only columnGroups has property groupable set to true', async () => { + const fix = TestBed.createComponent(MultiColumnHeadersWithGroupingComponent); + fix.detectChanges(); + const grid = fix.componentInstance.grid; + grid.getColumnByName('ID').groupable = false; + await wait(30); + fix.detectChanges(); + + // verify group area is not rendered + expect(grid.groupArea).not.toBeDefined(); + }); + + it('should add title attribute to chips when column is grouped', () => { + const fix = TestBed.createComponent(DefaultGridComponent); + fix.detectChanges(); + + const exprs: ISortingExpression[] = [ + { fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: true }, + { fieldName: 'Released', dir: SortingDirection.Desc, ignoreCase: true } + ]; + const grid = fix.componentInstance.instance; + grid.groupBy(exprs); + fix.detectChanges(); + + const chips = fix.nativeElement.querySelectorAll('igx-chip'); + expect(chips[0].getAttribute('title')).toEqual('ProductName'); + expect(chips[1].getAttribute('title')).toEqual('Released'); + }); + + it('should order sorting expressions correctly when setting groupingExpressions runtime.', () => { + const fix = TestBed.createComponent(DefaultGridComponent); + fix.detectChanges(); + + const sExprs: ISortingExpression[] = [ + { fieldName: 'Released', dir: SortingDirection.Desc, ignoreCase: true } + ]; + const grid = fix.componentInstance.instance; + grid.sortingExpressions = sExprs; + + fix.detectChanges(); + let dataRows = grid.dataRowList.toArray(); + expect(dataRows.length).toEqual(8); + // verify data records order + const expectedDataRecsOrder = [true, true, true, true, false, false, false, null]; + dataRows.forEach((row, index) => { + expect(row.data.Released).toEqual(expectedDataRecsOrder[index]); + }); + + const grExprs: ISortingExpression[] = [ + { fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: true } + ]; + grid.groupingExpressions = grExprs; + fix.detectChanges(); + + // check grouping expressions override sorting expressions - grouping should be applied first + expect(grid.sortingExpressions.length).toBe(1); + expect(grid.sortingExpressions[0]).toBe(sExprs[0]); + + dataRows = grid.dataRowList.toArray(); + const expectedReleaseRecsOrder = [true, false, true, false, false, null, true, true]; + const expectedProductNameOrder = ['NetAdvantage', 'NetAdvantage', 'Ignite UI for JavaScript', 'Ignite UI for JavaScript', + 'Ignite UI for Angular', 'Ignite UI for Angular', '', null]; + dataRows.forEach((row, index) => { + expect(row.data.Released).toEqual(expectedReleaseRecsOrder[index]); + expect(row.data.ProductName).toEqual(expectedProductNameOrder[index]); + }); + }); + + it('should apply custom comparer function when grouping by dragging a column into the group area', async () => { + const fix = TestBed.createComponent(GroupableGridComponent); + const grid = fix.componentInstance.instance; + const year = new Date().getFullYear().toString(); + fix.detectChanges(); + await wait(); + + grid.columnList.get(1).groupingComparer = (a, b) => { + if (a instanceof Date && b instanceof Date && + a.getFullYear() === b.getFullYear()) { + return 0; + } + return DefaultSortingStrategy.instance().compareValues(a, b); + }; + fix.detectChanges(); + + const firstColumn = fix.debugElement.queryAll(By.directive(IgxColumnMovingDragDirective))[1]; + const directiveInstance = firstColumn.injector.get(IgxColumnMovingDragDirective); + + // Trigger initial pointer events on the element with igxDrag. When the drag begins the ghostElement should receive events. + UIInteractions.simulatePointerEvent('pointerdown', firstColumn.nativeElement, 75, 30); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', firstColumn.nativeElement, 110, 30); + await wait(); + + expect(async () => { + fix.detectChanges(); + UIInteractions.simulatePointerEvent('pointermove', directiveInstance.ghostElement, 250, 30); + await wait(); + }).not.toThrow(); + + fix.detectChanges(); + UIInteractions.simulatePointerEvent('pointerup', directiveInstance.ghostElement, 250, 30); + await wait(); + + const groupRows = fix.debugElement.queryAll(By.css('igx-grid-groupby-row')); + + expect(groupRows.length).toEqual(2); + expect(grid.groupsRecords.length).toEqual(2); + expect(grid.groupsRecords[1].records.length).toEqual(6); + for (const record of grid.groupsRecords[1].records) { + expect(record.ReleaseDate.getFullYear().toString()).toEqual(year); + } + }); + + it('should be able to build groups with 100 000+ records', () => { + const fix = TestBed.createComponent(GroupableGridComponent); + const grid = fix.componentInstance.instance; + + const data = []; + for (let i = 0; i < 1000000; i++) { + data.push({ + Downloads: i, + ID: 1, + ProductName: 'Test' + }); + } + + fix.componentInstance.data = data; + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'ProductName', + dir: SortingDirection.Asc + }); + fix.detectChanges(); + + const groupRows = grid.groupsRowList.toArray(); + checkGroups(groupRows, ['Test']); + }); + + describe('GroupBy with state directive', () => { + let fix: ComponentFixture; + let state: IgxGridStateDirective; + let grid: IgxGridComponent; + + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(GridGroupByStateComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + state = fix.componentInstance.state; + })); + + it('should restore date/time columns groupBy expansion state and expand/collapse hierarchies correctly - issue #14619', fakeAsync(() => { + grid.groupBy({ + fieldName: 'DateField', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + + const groupRows = grid.groupsRowList.toArray(); + expect(groupRows.length).toEqual(3); + expect(groupRows[0].expanded).toEqual(true); + + grid.toggleGroup(groupRows[0].groupRow); + fix.detectChanges(); + + expect(groupRows[0].expanded).toEqual(false); + expect(groupRows[1].expanded).toEqual(true); + expect(groupRows[2].expanded).toEqual(true); + + const gridGroupByState = state.getState(true, 'groupBy'); + + expect(grid.groupsExpanded).toBe(true); + grid.toggleAllGroupRows(); + fix.detectChanges(); + + groupRows.forEach(gr => expect(gr.expanded).toEqual(false)); + + state.setState(gridGroupByState, "groupBy"); + fix.detectChanges(); + + expect(groupRows[0].expanded).toEqual(false); + expect(groupRows[1].expanded).toEqual(true); + expect(groupRows[2].expanded).toEqual(true); + + // check that toggling a single group row does not affect the others + grid.toggleGroup(groupRows[1].groupRow); + fix.detectChanges(); + + expect(groupRows[0].expanded).toEqual(false); + expect(groupRows[1].expanded).toEqual(false); + expect(groupRows[2].expanded).toEqual(true); + })); + + it('should restore date/time columns groupBy expansion state in nested hierarchies correctly - issue #14619', fakeAsync(() => { + grid.groupBy([{ + fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: false + }, { + fieldName: 'DateField', dir: SortingDirection.Asc, ignoreCase: false + }]); + fix.detectChanges(); + + let groupRows = grid.groupsRowList.toArray(); + expect(groupRows.length).toEqual(9); + expect(groupRows[4].groupRow.records.length).toEqual(2); + + grid.toggleGroup(groupRows[5].groupRow); + fix.detectChanges(); + + const gridGroupByState = state.getState(true, 'groupBy'); + + grid.toggleGroup(groupRows[6].groupRow); + fix.detectChanges(); + + groupRows = grid.groupsRowList.toArray(); + expect(groupRows[5].expanded).toEqual(false); + expect(groupRows[6].expanded).toEqual(false); + + state.setState(gridGroupByState, "groupBy"); + fix.detectChanges(); + + expect(groupRows[5].expanded).toEqual(false); + expect(groupRows[6].expanded).toEqual(true); + + // check that toggling a single group row in the hierarchy does not affect the others + grid.toggleGroup(groupRows[6].groupRow); + fix.detectChanges(); + + expect(groupRows[5].expanded).toEqual(false); + expect(groupRows[6].expanded).toEqual(false); + + grid.toggleGroup(groupRows[5].groupRow); + fix.detectChanges(); + + expect(groupRows[5].expanded).toEqual(true); + expect(groupRows[6].expanded).toEqual(false); + })); + + it('should properly restore groupBy expansion state that was saved before the grid groupsExpanded property has changed', fakeAsync(() => { + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Asc, ignoreCase: false + }); + fix.detectChanges(); + + grid.toggleAllGroupRows(); + fix.detectChanges(); + + let groupRows = grid.groupsRowList.toArray(); + groupRows.forEach(gr => expect(gr.expanded).toEqual(false)); + + grid.toggleGroup(groupRows[1].groupRow); + expect(groupRows[1].expanded).toEqual(true); + + const gridGroupByState = state.getState(true, 'groupBy'); + + expect(grid.groupsExpanded).toBe(false); + grid.toggleAllGroupRows(); + fix.detectChanges(); + + expect(grid.groupsExpanded).toBe(true); + + state.setState(gridGroupByState, "groupBy"); + fix.detectChanges(); + + groupRows = grid.groupsRowList.toArray(); + expect(groupRows[0].expanded).toEqual(false); + expect(groupRows[1].expanded).toEqual(true); + expect(groupRows[2].expanded).toEqual(false); + expect(groupRows[3].expanded).toEqual(false); + })); + }); + + const clickAndSendInputElementValue = (element, text, fix) => { + element.nativeElement.value = text; + element.nativeElement.dispatchEvent(new Event('input')); + fix.detectChanges(); + return fix.whenStable(); + }; +}); +@Component({ + template: ` + + @if (paging) { + + } + + + Custom template + + `, + imports: [IgxGridComponent, IgxPaginatorComponent] +}) +export class DefaultGridComponent extends DataParent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public instance: IgxGridComponent; + + @ViewChild('dropArea', { read: TemplateRef, static: true }) + public dropAreaTemplate: TemplateRef; + + public width = '800px'; + public height = null; + public currentDropArea; + + public enableSorting = false; + public enableFiltering = false; + public enableResizing = false; + public enableEditing = false; + public enableGrouping = true; + public currentSortExpressions; + public currentGroupingExpressions = []; + public paging = false; + + public columnsCreated(column: IgxColumnComponent) { + column.sortable = this.enableSorting; + column.filterable = this.enableFiltering; + column.resizable = this.enableResizing; + column.editable = this.enableEditing; + column.groupable = this.enableGrouping; + } + public groupingDoneHandler(sortExpr) { + this.currentSortExpressions = sortExpr; + } +} + +const formatUnboundValueFunction = (rowData: any | undefined): string | undefined => rowData.fieldValue1 + ' ' + rowData.fieldValue2; + + +class MySortingStrategy extends IgxGrouping { + protected override getFieldValue( + obj: any, + key: string, + isDate = false, + isTime = false + ): unknown { + if (key !== 'UnboundField') { + return super.getFieldValue(obj, key, isDate, isTime); + } + + return formatUnboundValueFunction(obj); + } +} + +@Component({ + template: ` + + + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxPaginatorComponent] +}) +export class GroupableGridComponent extends DataParent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public instance: IgxGridComponent; + + public width = '800px'; + public height = '700px'; + + public sortStrategy = new MySortingStrategy(); + public groupStrategy = this.sortStrategy; + + public formatUnboundValue(value: string, rowData: any | undefined): string | undefined { + return formatUnboundValueFunction(rowData); + } +} + +@Component({ + template: ` + + + + + + + + + Grouping by "{{groupRow.column.header}}". + Total items with value:{{ groupRow.value }} are {{ groupRow.records.length }} + + + + EXPANDED + + + COLLAPSED + + + + EXPANDED + + + COLLAPSED + + + + + CUSTOM GROUP BY + + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxGroupByRowTemplateDirective, IgxRowExpandedIndicatorDirective, IgxRowCollapsedIndicatorDirective, IgxHeaderExpandedIndicatorDirective, IgxHeaderCollapsedIndicatorDirective] +}) +export class CustomTemplateGridComponent extends DataParent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public instance: IgxGridComponent; + + public width = '800px'; + public height = null; + + @ViewChild('template', { read: TemplateRef }) + public customGroupBy: TemplateRef; +} + +@Component({ + template: ` + + @for (c of columns; track c.field) { + + + } + + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class GroupByDataMoreColumnsComponent extends DataParent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public instance: IgxGridComponent; + + public width = '800px'; + public height = null; + public testData = []; + + public columns = [ + { field: 'A', header: 'AA', width: 100 }, + { field: 'B', width: 100 }, + { field: 'C', width: 100 }, + { field: 'D', width: 100 }, + { field: 'E', width: 100 }, + { field: 'F', width: 100 }, + { field: 'H', width: 100 }, + { field: 'G', width: 100 }, + { field: 'K', width: 100 }, + { field: 'L', width: 100 }, + { field: 'M', width: 100 }, + { field: 'N', width: 100 } + ]; +} + +@Component({ + template: ` + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class GroupByEmptyColumnFieldComponent extends DataParent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public instance: IgxGridComponent; + public width = '200px'; +} + +export class CustomSortingStrategy extends DefaultSortingStrategy { +} + +@Component({ + template: ` + + + + + + + + +

    Selected rows in the group: {{context.selectedCount}};

    +

    Total rows in the group: {{context.totalCount}};

    +

    Group Row instance: {{context.groupRow}};

    + + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxGroupByRowSelectorDirective, IgxCheckboxComponent] +}) +export class GridGroupByRowCustomSelectorsComponent extends DataParent { + @ViewChild('gridGroupByRowCustomSelectors', { read: IgxGridComponent, static: true }) + public instance: IgxGridComponent; + + public width = '800px'; + public height = '700px'; + public groupByRowClick: any; + public onGroupByRowClick(_event, _context) { + this.groupByRowClick = _event; + } +} + +@Component({ + template: ` + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class GridGroupByCaseSensitiveComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public instance: IgxGridComponent; + + public width = '800px'; + public height = null; + public testData = [ + { + ID: 1, + ContactTitle: "Owner" + }, + { + ID: 2, + ContactTitle: 'Order Administrator' + }, + { + ID: 3, + ContactTitle: "owner" + }, + { + ID: 4, + ContactTitle: "Owner" + }, + { + ID: 5, + ContactTitle: 'Order Administrator' + } + ]; +} + +@Component({ + template: ` + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class GridGroupByTestDateTimeDataComponent { + @ViewChild("grid", { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + + public width = '800px'; + public height = null; + public datesData = SampleTestData.generateTestDateTimeData(); +} + +@Component({ + template: ` + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxGridStateDirective] +}) +export class GridGroupByStateComponent extends GridGroupByTestDateTimeDataComponent { + @ViewChild(IgxGridStateDirective, { static: true }) + public state: IgxGridStateDirective; +} diff --git a/projects/igniteui-angular/grids/grid/src/grid.master-detail.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.master-detail.spec.ts new file mode 100644 index 00000000000..3315c47d56c --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid.master-detail.spec.ts @@ -0,0 +1,1402 @@ +import { Component, ViewChild, OnInit, DebugElement, QueryList, TemplateRef, ViewChildren } from '@angular/core'; +import { TestBed, ComponentFixture, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { By } from '@angular/platform-browser'; +import { UIInteractions, wait, waitForActiveNodeChange } from '../../../test-utils/ui-interactions.spec'; +import { IgxGridComponent } from './grid.component'; +import { IgxGridRowComponent } from './grid-row.component'; +import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; +import { GridFunctions, GridSelectionFunctions } from '../../../test-utils/grid-functions.spec'; +import { IgxGridExpandableCellComponent } from './expandable-cell.component'; +import { GridSummaryPosition, GridSelectionMode, CellType, IgxColumnComponent, IgxGridDetailTemplateDirective, IgxGridMRLNavigationService } from 'igniteui-angular/grids/core'; +import { clearGridSubs, setupGridScrollDetection } from '../../../test-utils/helper-utils.spec'; +import { IgxColumnLayoutComponent } from 'igniteui-angular/grids/core'; +import { GridSummaryCalculationMode, IgxStringFilteringOperand, SortingDirection } from 'igniteui-angular/core'; +import { IgxCheckboxComponent } from 'igniteui-angular/checkbox'; +import { IgxInputDirective, IgxInputGroupComponent } from 'igniteui-angular/input-group'; +import { IgxPaginatorComponent } from 'igniteui-angular/paginator'; + +const DEBOUNCE_TIME = 30; +const ROW_TAG = 'igx-grid-row'; +const GROUP_ROW_TAG = 'igx-grid-groupby-row'; +const SUMMARY_ROW_TAG = 'igx-grid-summary-row'; +const COLLAPSED_ICON_NAME = 'chevron_right'; +const EXPANDED_ICON_NAME = 'expand_more'; +const HIERARCHICAL_INDENT_CLASS = '.igx-grid__hierarchical-indent'; +const SELECTED_ROW_CLASS_NAME = 'igx-grid__tr--selected'; + +describe('IgxGrid Master Detail #grid', () => { + let fix: ComponentFixture; + let grid: IgxGridComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + DefaultGridMasterDetailComponent, + AllExpandedGridMasterDetailComponent, + MRLMasterDetailComponent + ], + providers: [ + IgxGridMRLNavigationService + ] + }).compileComponents(); + })); + + describe('Basic', () => { + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(DefaultGridMasterDetailComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + tick(100); + })); + + it('Should render an expand icon for all rows', () => { + const expandIcons = grid.rowList.filter((row) => { + const iconName = GridFunctions.getRowExpandIconName(row); + return iconName === COLLAPSED_ICON_NAME; + }); + expect(grid.rowList.length).toEqual(expandIcons.length); + }); + + it('Should correctly expand a basic detail view, update expansionStates and the context provided should be correct', () => { + GridFunctions.toggleMasterRow(fix, grid.rowList.first); + fix.detectChanges(); + + const firstRowIconName = GridFunctions.getRowExpandIconName(grid.rowList.first); + const firstRowDetail = GridFunctions.getMasterRowDetail(grid.rowList.first); + expect(grid.expansionStates.size).toEqual(1); + expect(grid.expansionStates.has(grid.rowList.first.key)).toBeTruthy(); + expect(grid.expansionStates.get(grid.rowList.first.key)).toBeTruthy(); + expect(firstRowIconName).toEqual(EXPANDED_ICON_NAME); + expect(getDetailAddressText(firstRowDetail)).toEqual('Obere Str. 57'); + }); + + it('Should render a detail view with dynamic elements and they should be clickable/focusable.', () => { + GridFunctions.toggleMasterRow(fix, grid.rowList.first); + fix.detectChanges(); + + const firstDetail = GridFunctions.getMasterRowDetailDebug(fix, grid.rowList.first); + const checkboxElem = firstDetail.query(By.directive(IgxCheckboxComponent)); + const checkboxPos = checkboxElem.nativeElement.getBoundingClientRect(); + const inputElem = firstDetail.query(By.directive(IgxInputGroupComponent)); + const inputElemPos = inputElem.nativeElement.getBoundingClientRect(); + + const tracedCheckbox: any = + document.elementFromPoint(checkboxPos.left + checkboxPos.height / 2, checkboxPos.top + checkboxPos.height / 2); + const tracedInput: any = + document.elementFromPoint(inputElemPos.left + inputElemPos.height / 2, inputElemPos.top + inputElemPos.height / 2); + + checkboxElem.componentInstance.nativeInput.nativeElement.click(); + fix.detectChanges(); + + expect(checkboxElem.nativeElement.contains(tracedCheckbox)).toBeTruthy(); + expect(checkboxElem.componentInstance.checked).toBeTruthy(); + + UIInteractions.simulateClickAndSelectEvent(inputElem); + fix.detectChanges(); + + expect(inputElem.nativeElement.contains(tracedInput)).toBeTruthy(); + expect(document.activeElement).toEqual(tracedInput); + }); + + it(`Should persist state of rendered templates, such as expansion state of expansion panel, + checkbox state, etc. after scrolling them in and out of view.`, (async () => { + GridFunctions.toggleMasterRow(fix, grid.rowList.first); + fix.detectChanges(); + + let firstDetail = GridFunctions.getMasterRowDetailDebug(fix, grid.rowList.first); + let checkboxElem = firstDetail.query(By.directive(IgxCheckboxComponent)); + let inputElem = firstDetail.query(By.directive(IgxInputGroupComponent)); + + expect(grid.rowList.first.key).toEqual('ALFKI'); + expect(checkboxElem.componentInstance.checked).toBeFalsy(); + expect(inputElem.componentInstance.input.value).toEqual(''); + expect(getDetailAddressText(firstDetail.nativeElement)).toEqual('Obere Str. 57'); + + inputElem.componentInstance.input.value = 'Test value'; + checkboxElem.componentInstance.checked = !checkboxElem.componentInstance.checked; + fix.detectChanges(); + + grid.navigateTo(20); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + const row = grid.gridAPI.get_row_by_index(20); + expect(GridFunctions.elementInGridView(grid, row.nativeElement)).toBeTruthy(); + + grid.navigateTo(0); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + firstDetail = GridFunctions.getMasterRowDetailDebug(fix, grid.rowList.first); + checkboxElem = firstDetail.query(By.directive(IgxCheckboxComponent)); + inputElem = firstDetail.query(By.directive(IgxInputGroupComponent)); + + expect(grid.rowList.first.key).toEqual('ALFKI'); + expect(checkboxElem.componentInstance.checked).toBeTruthy(); + expect(inputElem.componentInstance.input.value).toEqual('Test value'); + expect(getDetailAddressText(firstDetail.nativeElement)).toEqual('Obere Str. 57'); + })); + + it(`Should persist state of rendered templates, such as expansion state of expansion panel, + checkbox state, etc. after scrolling them in and out of view.`, () => { + GridFunctions.toggleMasterRow(fix, grid.rowList.first); + fix.detectChanges(); + + let firstRowDetail = GridFunctions.getMasterRowDetailDebug(fix, grid.rowList.first); + let checkboxElem = firstRowDetail.query(By.directive(IgxCheckboxComponent)).componentInstance; + let inputGroup = firstRowDetail.query(By.directive(IgxInputGroupComponent)).componentInstance; + + expect(grid.rowList.first.key).toEqual('ALFKI'); + expect(checkboxElem.checked).toBeFalsy(); + expect(inputGroup.input.value).toEqual(''); + expect(getDetailAddressText(firstRowDetail.nativeElement)).toEqual('Obere Str. 57'); + + inputGroup.input.value = 'Test value'; + checkboxElem.checked = !checkboxElem.checked; + fix.detectChanges(); + + GridFunctions.toggleMasterRow(fix, grid.rowList.first); + fix.detectChanges(); + + GridFunctions.toggleMasterRow(fix, grid.rowList.first); + fix.detectChanges(); + + firstRowDetail = GridFunctions.getMasterRowDetailDebug(fix, grid.rowList.first); + checkboxElem = firstRowDetail.query(By.directive(IgxCheckboxComponent)).componentInstance; + inputGroup = firstRowDetail.query(By.directive(IgxInputGroupComponent)).componentInstance; + + expect(grid.rowList.first.key).toEqual('ALFKI'); + expect(checkboxElem.checked).toBeTruthy(); + expect(inputGroup.input.value).toEqual('Test value'); + expect(getDetailAddressText(firstRowDetail.nativeElement)).toEqual('Obere Str. 57'); + }); + + it(`Should persist state of rendered templates, such as expansion state of expansion panel, + checkbox state, etc. after scrolling them in and out of view.`, (async () => { + fix.detectChanges(); + await wait(DEBOUNCE_TIME * 2); + + const verticalScrollbar = grid.verticalScrollContainer.getScroll(); + const verticalScrollHeight = (verticalScrollbar.firstElementChild as HTMLElement).offsetHeight; + + grid.navigateTo(26); + await wait(DEBOUNCE_TIME * 2); + fix.detectChanges(); + await wait(DEBOUNCE_TIME * 2); + fix.detectChanges(); + + GridFunctions.toggleMasterRow(fix, grid.rowList.last); + await wait(DEBOUNCE_TIME * 2); + fix.detectChanges(); + + const lastRowDetail = GridFunctions.getMasterRowDetail(grid.rowList.last); + expect(grid.expansionStates.size).toEqual(1); + expect(grid.expansionStates.has(grid.rowList.last.key)).toBeTruthy(); + expect(grid.expansionStates.get(grid.rowList.last.key)).toBeTruthy(); + expect(getDetailAddressText(lastRowDetail)).toEqual('Via Monte Bianco 34'); + expect(verticalScrollHeight + lastRowDetail.offsetHeight) + .toEqual((verticalScrollbar.firstElementChild as HTMLElement).offsetHeight); + })); + + it('Should update view when setting a new expansionState object.', () => { + const newExpanded = new Map(); + newExpanded.set('ALFKI', true); + newExpanded.set('ANTON', true); + newExpanded.set('AROUT', true); + + expect(grid.tbody.nativeElement.firstElementChild.children.length).toEqual(grid.rowList.length); + + grid.expansionStates = newExpanded; + fix.detectChanges(); + + const gridRows = grid.rowList.toArray(); + const firstDetail = GridFunctions.getMasterRowDetail(gridRows[0]); + const secondDetail = GridFunctions.getMasterRowDetail(gridRows[2]); + const thirdDetail = GridFunctions.getMasterRowDetail(gridRows[3]); + expect(grid.tbody.nativeElement.firstElementChild.children.length).toEqual(grid.rowList.length + 3); + expect(getDetailAddressText(firstDetail)).toEqual('Obere Str. 57'); + expect(getDetailAddressText(secondDetail)).toEqual('Mataderos 2312'); + expect(getDetailAddressText(thirdDetail)).toEqual('120 Hanover Sq.'); + }); + + it('Should update rendered detail templates after grid data is changed.', () => { + const newExpanded = new Map(); + newExpanded.set('ALFKI', true); + newExpanded.set('ANTON', true); + newExpanded.set('AROUT', true); + + expect(grid.tbody.nativeElement.firstElementChild.children.length).toEqual(grid.rowList.length); + + grid.expansionStates = newExpanded; + fix.detectChanges(); + + const newData = [...grid.data].slice(0, 4); + newData.splice(1, 1); + + grid.data = newData; + fix.detectChanges(); + + const gridRows = grid.rowList.toArray(); + const firstDetail = GridFunctions.getMasterRowDetail(gridRows[0]); + const secondDetail = GridFunctions.getMasterRowDetail(gridRows[1]); + const thirdDetail = GridFunctions.getMasterRowDetail(gridRows[2]); + expect(grid.tbody.nativeElement.firstElementChild.children.length).toEqual(grid.rowList.length + 3); + expect(getDetailAddressText(firstDetail)).toEqual('Obere Str. 57'); + expect(getDetailAddressText(secondDetail)).toEqual('Mataderos 2312'); + expect(getDetailAddressText(thirdDetail)).toEqual('120 Hanover Sq.'); + }); + + it('Should expand and collapse a row in view by using the expandRow(rowID) and collapseRow(rowID) methods.', async () => { + grid.expandRow(fix.componentInstance.data[0].ID); + await wait(); + fix.detectChanges(); + + const firstRow = grid.rowList.first; + let firstRowIconName = GridFunctions.getRowExpandIconName(firstRow); + expect(grid.expansionStates.size).toEqual(1); + expect(grid.expansionStates.has(firstRow.key)).toBeTruthy(); + expect(firstRow.expanded).toBeTruthy(); + expect(firstRowIconName).toEqual(EXPANDED_ICON_NAME); + + grid.collapseRow(fix.componentInstance.data[0].ID); + await wait(); + fix.detectChanges(); + + firstRowIconName = GridFunctions.getRowExpandIconName(firstRow); + expect(grid.expansionStates.get(fix.componentInstance.data[0].ID)).toBeFalsy(); + expect(firstRow.expanded).toBeFalsy(); + expect(firstRowIconName).toEqual(COLLAPSED_ICON_NAME); + }); + + it('Should expand a row out of view by using the collapseRow() method and update expansionStates.', async () => { + const lastIndex = fix.componentInstance.data.length - 1; + const lastDataRecID = fix.componentInstance.data[lastIndex].ID; + + grid.expandRow(lastDataRecID); + await wait(); + fix.detectChanges(); + + expect(grid.expansionStates.size).toEqual(1); + expect(grid.expansionStates.get(lastDataRecID)).toBeTruthy(); + }); + + it('Should collapse a row out of view by using the collapseRow() method and update expansionStates.', async () => { + GridFunctions.setAllExpanded(grid, fix.componentInstance.data); + await wait(); + fix.detectChanges(); + + const lastIndex = fix.componentInstance.data.length - 1; + const lastDataRecID = fix.componentInstance.data[lastIndex].ID; + + grid.collapseRow(lastDataRecID); + await wait(); + fix.detectChanges(); + + expect(grid.expansionStates.size).toEqual(fix.componentInstance.data.length); + expect(grid.expansionStates.get(lastDataRecID)).toBeFalsy(); + }); + + it('Should toggle a row expand state by using the toggleRow(rowID) method.', async () => { + grid.toggleRow(fix.componentInstance.data[0].ID); + await wait(); + fix.detectChanges(); + + expect(grid.expansionStates.size).toEqual(1); + expect(grid.expansionStates.has(grid.rowList.first.key)).toBeTruthy(); + expect(grid.rowList.toArray()[0].expanded).toBeTruthy(); + + grid.toggleRow(fix.componentInstance.data[0].ID); + await wait(); + fix.detectChanges(); + + expect(grid.expansionStates.get(fix.componentInstance.data[0].ID)).toBeFalsy(); + expect(grid.rowList.toArray()[0].expanded).toBeFalsy(); + }); + + it('Should expand all rows using the expandAll() method and the expansion state should be updated.', async () => { + grid.expandAll(); + await wait(); + fix.detectChanges(); + + expect(grid.expansionStates.size).toEqual(0); + grid.rowList.toArray().forEach(row => { + expect(row.expanded).toBeTruthy(); + }); + }); + + it('Should collapse all rows using the collapseAll() method and the expansion state should be updated.', async () => { + GridFunctions.setAllExpanded(grid, fix.componentInstance.data); + await wait(); + fix.detectChanges(); + + grid.rowList.toArray().forEach(row => { + expect(row.expanded).toBeTruthy(); + }); + + grid.collapseAll(); + await wait(); + fix.detectChanges(); + + expect(grid.expansionStates.size).toEqual(0); + grid.rowList.toArray().forEach(row => { + expect(row.expanded).toBeFalsy(); + }); + }); + + it('should allow setting external details template via Input.', () => { + grid = fix.componentInstance.grid; + grid.detailTemplate = fix.componentInstance.detailTemplate; + fix.detectChanges(); + grid.toggleRow(fix.componentInstance.data[0].ID); + fix.detectChanges(); + const gridRows = grid.rowList.toArray(); + const firstDetail = GridFunctions.getMasterRowDetail(gridRows[0]); + expect(firstDetail.textContent.trim()).toBe('NEW TEMPLATE'); + }); + + it('should allow grids in details view without breaking the column collection of the master grid', () => { + grid = fix.componentInstance.grid; + grid.detailTemplate = fix.componentInstance.gridTemplate; + fix.detectChanges(); + grid.toggleRow(fix.componentInstance.data[0].ID); + fix.detectChanges(); + expect(grid.unpinnedColumns.map(c => c.field)).toEqual(['ContactName', 'CompanyName']); + expect(fix.componentInstance.childGrid.first.unpinnedColumns.map(c => c.field)).toEqual(['ColA', 'ColB']); + }); + }); + + describe('Keyboard Navigation ', () => { + let gridContent: DebugElement; + beforeEach(async () => { + fix = TestBed.createComponent(AllExpandedGridMasterDetailComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + gridContent = GridFunctions.getGridContent(fix); + await wait(DEBOUNCE_TIME * 4); + fix.detectChanges(); + }); + + it('Should navigate down through a detail view by focusing the whole row and continuing onto the next with arrow down.', () => { + const targetCellElement = grid.gridAPI.get_cell_by_index(0, 'ContactName'); + UIInteractions.simulateClickAndSelectEvent(targetCellElement); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent); + fix.detectChanges(); + + const firstRowDetail = GridFunctions.getMasterRowDetail(grid.rowList.first); + GridFunctions.verifyMasterDetailRowFocused(firstRowDetail); + expect(targetCellElement.selected).toBeTruthy(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent); + fix.detectChanges(); + + expect(grid.getCellByColumn(2, 'ContactName').selected).toBeTruthy(); + }); + + it('Should navigate down through a detail view partially out of view by scrolling it so it becomes fully visible.', async () => { + const row = grid.gridAPI.get_row_by_index(4) as IgxGridRowComponent; + const targetCellElement = grid.gridAPI.get_cell_by_index(4, 'ContactName'); + UIInteractions.simulateClickAndSelectEvent(targetCellElement); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + const detailRow = GridFunctions.getMasterRowDetail(row); + GridFunctions.verifyMasterDetailRowFocused(detailRow); + expect(GridFunctions.elementInGridView(grid, detailRow)).toBeTruthy(); + }); + + it('Should navigate down through a detail view completely out of view by scrolling to it.', async () => { + grid.navigateTo(6, 0); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + const row = grid.gridAPI.get_row_by_index(6) as IgxGridRowComponent; + const targetCellElement = grid.gridAPI.get_cell_by_index(6, 'ContactName'); + UIInteractions.simulateClickAndSelectEvent(targetCellElement); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + const detailRow = GridFunctions.getMasterRowDetail(row); + GridFunctions.verifyMasterDetailRowFocused(detailRow); + expect(GridFunctions.elementInGridView(grid, detailRow)).toBeTruthy(); + }); + + it('Should navigate up through a detail view by focusing the whole row and continuing onto the next with arrow up.', () => { + const prevRow = grid.gridAPI.get_row_by_index(0) as IgxGridRowComponent; + const targetCellElement = grid.gridAPI.get_cell_by_index(2, 'ContactName'); + UIInteractions.simulateClickAndSelectEvent(targetCellElement); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent); + fix.detectChanges(); + + const detailRow = GridFunctions.getMasterRowDetail(prevRow); + GridFunctions.verifyMasterDetailRowFocused(detailRow); + expect(targetCellElement.selected).toBeTruthy(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent); + fix.detectChanges(); + + expect(prevRow.cells.toArray()[0].selected).toBeTruthy(); + }); + + it('Should navigate up through a detail view partially out of view by scrolling it so it becomes fully visible.', async () => { + grid.verticalScrollContainer.addScrollTop(90); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + const row = grid.gridAPI.get_row_by_index(2); + const targetCellElement = grid.gridAPI.get_cell_by_index(2, 'ContactName'); + UIInteractions.simulateClickAndSelectEvent(targetCellElement); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + const detailRow = row.nativeElement.previousElementSibling as HTMLElement; + GridFunctions.verifyMasterDetailRowFocused(detailRow); + expect(GridFunctions.elementInGridView(grid, detailRow)).toBeTruthy(); + }); + + it('Should navigate up through a detail view completely out of view by scrolling to it.', async () => { + grid.verticalScrollContainer.addScrollTop(170); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + let row = grid.gridAPI.get_row_by_index(2); + const targetCellElement = grid.gridAPI.get_cell_by_index(2, 'ContactName'); + UIInteractions.simulateClickAndSelectEvent(targetCellElement); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + row = grid.gridAPI.get_row_by_index(2); + const detailRow = row.nativeElement.previousElementSibling as HTMLElement; + GridFunctions.verifyMasterDetailRowFocused(detailRow); + expect(GridFunctions.elementInGridView(grid, detailRow)).toBeTruthy(); + }); + + it('Should expand and collapse using Alt + Right/Down and Alt + Left/Up without losing focus on current row.', async () => { + const row = grid.gridAPI.get_row_by_index(0) as IgxGridRowComponent; + const targetCellElement = grid.gridAPI.get_cell_by_index(0, 'ContactName'); + UIInteractions.simulateClickAndSelectEvent(targetCellElement); + fix.detectChanges(); + expect(targetCellElement.active).toBeTruthy(); + + // collapse with alt + arrowup + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent, true); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + expect(row.expanded).toBeFalsy(); + expect(targetCellElement.active).toBeTruthy(); + + // expand with alt + ArrowDown + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent, true); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + expect(row.expanded).toBeTruthy(); + expect(targetCellElement.active).toBeTruthy(); + + // collapse with alt + arrowleft + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent, true); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + expect(row.expanded).toBeFalsy(); + expect(targetCellElement.active).toBeTruthy(); + + // expand with alt + arrowright + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent, true); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + expect(row.expanded).toBeTruthy(); + expect(targetCellElement.active).toBeTruthy(); + }); + + it(`Should expand and collapse using Alt + Right/Down and Alt + Left/Up + at the bottom of the grid without losing focus.`, async () => { + // navigate to last + grid.verticalScrollContainer.scrollTo(grid.verticalScrollContainer.igxForOf.length - 1); + await wait(100); + fix.detectChanges(); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + const row = grid.gridAPI.get_row_by_index(52) as IgxGridRowComponent; + const targetCellElement = grid.gridAPI.get_cell_by_index(52, 'ContactName'); + + UIInteractions.simulateClickAndSelectEvent(targetCellElement); + fix.detectChanges(); + expect(targetCellElement.active).toBeTruthy(); + + // collapse with alt + arrowup + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent, true); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + expect(row.expanded).toBeFalsy(); + let targetCellElement2 = grid.getCellByColumn(52, 'ContactName'); + expect(targetCellElement2.active).toBeTruthy(); + + // expand with alt + ArrowDown + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent, true); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + expect(row.expanded).toBeTruthy(); + targetCellElement2 = grid.getCellByColumn(52, 'ContactName'); + expect(targetCellElement2.active).toBeTruthy(); + }); + + it('Should navigate to the correct row/cell when using the navigateTo method in a grid with expanded detail views.', async () => { + pending('This test should pass when the issue #7300 is fixed.'); + grid.navigateTo(20, 0); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + let row = grid.gridAPI.get_row_by_index(20) as IgxGridRowComponent; + expect(row).not.toBeNull(); + expect(GridFunctions.elementInGridView(grid, row.nativeElement)).toBeTruthy(); + + grid.navigateTo(21, 0); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + row = grid.gridAPI.get_row_by_index(20) as IgxGridRowComponent; + const detailRow = GridFunctions.getMasterRowDetail(row); + expect(GridFunctions.elementInGridView(grid, detailRow)).toBeTruthy(); + + }); + + it('Should navigate to the last data cell in the grid using Ctrl + End.', async () => { + setupGridScrollDetection(fix, grid); + const targetCellElement = grid.gridAPI.get_cell_by_index(0, 'ContactName'); + UIInteractions.simulateClickAndSelectEvent(targetCellElement); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('End', gridContent, false, false, true); + fix.detectChanges(); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + await wait(DEBOUNCE_TIME); + + const lastRow = grid.gridAPI.get_row_by_index(52); + expect(lastRow).not.toBeUndefined(); + expect(GridFunctions.elementInGridView(grid, lastRow.nativeElement)).toBeTruthy(); + expect((lastRow.cells as QueryList).last.active).toBeTruthy(); + clearGridSubs(); + }); + + it('Should navigate to the first data cell in the grid using Ctrl + Home.', async () => { + setupGridScrollDetection(fix, grid); + grid.verticalScrollContainer.scrollTo(grid.verticalScrollContainer.igxForOf.length - 1); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + const targetCellElement = grid.gridAPI.get_cell_by_index(52, 'ContactName'); + UIInteractions.simulateClickAndSelectEvent(targetCellElement); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('Home', gridContent, false, false, true); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + const fRow = grid.gridAPI.get_row_by_index(0); + expect(fRow).not.toBeUndefined(); + expect(GridFunctions.elementInGridView(grid, fRow.nativeElement)).toBeTruthy(); + expect((fRow.cells as QueryList).first.active).toBeTruthy(); + clearGridSubs(); + }); + + it('Should navigate to the last data row using Ctrl + ArrowDown when all rows are expanded.', async () => { + setupGridScrollDetection(fix, grid); + const targetCellElement = grid.gridAPI.get_cell_by_index(0, 'ContactName'); + UIInteractions.simulateClickAndSelectEvent(targetCellElement); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent, false, false, true); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + const lastRow = grid.gridAPI.get_row_by_index(52); + expect(lastRow).not.toBeUndefined(); + expect(GridFunctions.elementInGridView(grid, lastRow.nativeElement)).toBeTruthy(); + expect((lastRow.cells as QueryList).first.active).toBeTruthy(); + clearGridSubs(); + }); + + it('Should navigate to the first data row using Ctrl + ArrowUp when all rows are expanded.', async () => { + setupGridScrollDetection(fix, grid); + grid.verticalScrollContainer.scrollTo(grid.verticalScrollContainer.igxForOf.length - 1); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + const targetCellElement = grid.gridAPI.get_cell_by_index(52, 'CompanyName'); + UIInteractions.simulateClickAndSelectEvent(targetCellElement); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent, false, false, true); + await waitForActiveNodeChange(grid); + fix.detectChanges(); + + const fRow = grid.gridAPI.get_row_by_index(0); + expect(fRow).not.toBeUndefined(); + expect(GridFunctions.elementInGridView(grid, fRow.nativeElement)).toBeTruthy(); + expect((fRow.cells as QueryList).last.active).toBeTruthy(); + clearGridSubs(); + }); + + it(`Should navigate to the first/last row when using Ctrl+ArrowUp/ArrowDown + and focus is on the detail row container.`, async () => { + // Focus first cell + let row = grid.gridAPI.get_row_by_index(0); + let detailRow = GridFunctions.getMasterRowDetail(row); + UIInteractions.simulateClickAndSelectEvent(detailRow); + fix.detectChanges(); + + GridFunctions.verifyMasterDetailRowFocused(detailRow); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent, false, false, true); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + row = grid.gridAPI.get_row_by_index(0); + detailRow = GridFunctions.getMasterRowDetail(row); + GridFunctions.verifyMasterDetailRowFocused(detailRow); + + // Got to details row + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent, false, false, true); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + row = grid.gridAPI.get_row_by_index(0); + detailRow = GridFunctions.getMasterRowDetail(row); + GridFunctions.verifyMasterDetailRowFocused(detailRow); + }); + + it('Should not navigate if keydown is done on an element inside the details template.', () => { + const detailRow = GridFunctions.getMasterRowDetailDebug(fix, grid.rowList.first); + const input = detailRow.query(By.css('input[name="Comment"]')); + input.nativeElement.focus(); + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', input); + + fix.detectChanges(); + expect(document.activeElement).toBe(input.nativeElement); + }); + }); + + describe('Integration', () => { + describe('Paging', () => { + it('Should not take into account expanded detail views as additional records.', fakeAsync(() => { + fix = TestBed.createComponent(DefaultGridMasterDetailComponent); + grid = fix.componentInstance.grid; + fix.detectChanges(); + + fix.componentInstance.paging = true; + fix.detectChanges(); + + grid.expandRow(fix.componentInstance.data[0].ID); + fix.detectChanges(); + + const initialTotalRecords = grid.pagingState.metadata.countRecords; + expect(grid.pagingState.metadata.countRecords).toEqual(initialTotalRecords); + })); + + it('Should persist template state after paging to a page with fewer records and paging back.', fakeAsync(() => { + fix = TestBed.createComponent(DefaultGridMasterDetailComponent); + fix.componentInstance.perPage = 5; + grid = fix.componentInstance.grid; + fix.detectChanges(); + + fix.componentInstance.paging = true; + fix.detectChanges(); + + grid.expandRow(fix.componentInstance.data[4].ID); + fix.detectChanges(); + + // click the template checkbox + let checkbox = fix.debugElement.query(By.directive(IgxCheckboxComponent)); + checkbox.componentInstance.checked = !checkbox.componentInstance.checked; + fix.detectChanges(); + + // go to last page that doesn't contain this view + grid.page = grid.pagingState.metadata.countPages - 1; + fix.detectChanges(); + + // go back to first page + grid.page = 0; + fix.detectChanges(); + + // check checkbox state + checkbox = fix.debugElement.query(By.directive(IgxCheckboxComponent)); + expect(checkbox.componentInstance.checked).toBeTruthy(); + })); + }); + + describe('Hiding', () => { + it('Should set the expand/collapse icon to the new first visible column when hiding the first column.', fakeAsync(() => { + fix = TestBed.createComponent(DefaultGridMasterDetailComponent); + grid = fix.componentInstance.grid; + fix.detectChanges(); + + grid.columnList.first.hidden = true; + fix.detectChanges(); + + expect(grid.rowList.first.cells.first instanceof IgxGridExpandableCellComponent).toBeTruthy(); + })); + }); + + describe('Pinning', () => { + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(DefaultGridMasterDetailComponent); + grid = fix.componentInstance.grid; + fix.detectChanges(); + })); + + it('Should keep/move the expand/collapse icon to the correct column when pinning the first column or another one.', () => { + grid.columnList.last.pin(); + fix.detectChanges(); + + expect(grid.rowList.first.cells.first instanceof IgxGridExpandableCellComponent).toBeTruthy(); + + grid.pinnedColumns[0].unpin(); + fix.detectChanges(); + + expect(grid.rowList.first.cells.first instanceof IgxGridExpandableCellComponent).toBeTruthy(); + }); + + it('Should render detail view correctly when expanding a master row and there are pinned columns.', () => { + grid.columnList.last.pin(); + grid.expandRow(fix.componentInstance.data[0].ID); + fix.detectChanges(); + + const firstRowDetail = GridFunctions.getMasterRowDetail(grid.rowList.first); + expect(getDetailAddressText(firstRowDetail)).toEqual('Obere Str. 57'); + expect(firstRowDetail.querySelector(HIERARCHICAL_INDENT_CLASS)).toBeDefined(); + }); + }); + + describe('Column Moving', () => { + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(DefaultGridMasterDetailComponent); + grid = fix.componentInstance.grid; + fix.detectChanges(); + })); + + it('Should keep the expand/collapse icon in the first column, even when moving a column in first place.', fakeAsync(() => { + grid.moveColumn(grid.columnList.last, grid.columnList.first); + tick(); + fix.detectChanges(); + + expect(grid.rowList.first.cells.first instanceof IgxGridExpandableCellComponent).toBeTruthy(); + })); + + it('Should keep the expand/collapse icon in the first column, even when moving a column out of first place.', fakeAsync(() => { + grid.moveColumn(grid.columnList.first, grid.columnList.last); + tick(); + fix.detectChanges(); + + expect(grid.rowList.first.cells.first instanceof IgxGridExpandableCellComponent).toBeTruthy(); + })); + }); + + describe('Cell Selection', () => { + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(DefaultGridMasterDetailComponent); + grid = fix.componentInstance.grid; + fix.detectChanges(); + })); + + it('Should exclude expanded detail views when doing range cell selection', fakeAsync(() => { + grid.expandRow(fix.componentInstance.data[2].ID); + const selectionChangeSpy = spyOn(grid.rangeSelected, 'emit').and.callThrough(); + const startCell = grid.gridAPI.get_cell_by_index(1, 'ContactName'); + const endCell = grid.gridAPI.get_cell_by_index(6, 'CompanyName'); + const range = { rowStart: 1, rowEnd: 6, columnStart: 0, columnEnd: 1 }; + + UIInteractions.simulatePointerOverElementEvent('pointerdown', startCell.nativeElement); + startCell.nativeElement.dispatchEvent(new Event('click')); + grid.cdr.detectChanges(); + + expect(startCell.active).toBe(true); + + for (let i = 2; i < 6; i++) { + const cell = grid.gridAPI.get_cell_by_index(i, 'ContactName'); + if (!cell) { + UIInteractions.simulatePointerOverElementEvent('pointerenter', + fix.debugElement.query(By.css('.addressArea')).nativeElement); + continue; + } + UIInteractions.simulatePointerOverElementEvent('pointerenter', cell.nativeElement); + grid.cdr.detectChanges(); + } + UIInteractions.simulatePointerOverElementEvent('pointerenter', endCell.nativeElement); + UIInteractions.simulatePointerOverElementEvent('pointerup', endCell.nativeElement); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 1, 2, 0, 1, true); + GridSelectionFunctions.verifyCellsRegionSelected(grid, 4, 5, 0, 1, true); + grid.cdr.detectChanges(); + + expect(startCell.active).toBe(true); + + const rowDetail = GridFunctions.getMasterRowDetail(grid.rowList.toArray()[2]); + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + expect(selectionChangeSpy).toHaveBeenCalledWith(range); + expect(rowDetail.querySelector('[class*="selected"]')).toBeNull(); + })); + + it('getSelectedData should return correct values when there are master details', fakeAsync(() => { + const range = { rowStart: 0, rowEnd: 5, columnStart: 'ContactName', columnEnd: 'ContactName' }; + const expectedData = [ + { ContactName: 'Maria Anders' }, + { ContactName: 'Ana Trujillo' }, + { ContactName: 'Antonio Moreno' } + ]; + grid.expandAll(); + tick(100); + fix.detectChanges(); + + grid.selectRange(range); + fix.detectChanges(); + expect(grid.getSelectedData()).toEqual(expectedData); + })); + + it('getSelectedData should return correct values when there are master details and paging is enabled', fakeAsync(() => { + const range = { rowStart: 0, rowEnd: 5, columnStart: 'ContactName', columnEnd: 'ContactName' }; + const expectedDataFromSecondPage = [ + { ContactName: 'Hanna Moos' }, + { ContactName: 'Frédérique Citeaux' }, + { ContactName: 'Martín Sommer' } + ]; + fix.componentInstance.paging = true; + fix.detectChanges(); + grid.paginator.perPage = 5; + fix.detectChanges(); + tick(16); + grid.paginator.paginate(1); + fix.detectChanges(); + tick(16); + + grid.expandAll(); + tick(100); + fix.detectChanges(); + + grid.selectRange(range); + fix.detectChanges(); + expect(grid.getSelectedData()).toEqual(expectedDataFromSecondPage); + + const expectedDataFromThirdPage = [ + { ContactName: 'Victoria Ashworth' }, + { ContactName: 'Patricio Simpson' }, + { ContactName: 'Francisco Chang' } + ]; + grid.paginator.paginate(2); + fix.detectChanges(); + tick(16); + + grid.expandAll(); + tick(100); + fix.detectChanges(); + + grid.selectRange(range); + fix.detectChanges(); + expect(grid.getSelectedData()).toEqual(expectedDataFromThirdPage); + })); + }); + + describe('Row Selection', () => { + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(DefaultGridMasterDetailComponent); + grid = fix.componentInstance.grid; + fix.componentInstance.rowSelectable = true; + fix.detectChanges(); + })); + + it('Should not render row selection checkbox for detail views.', () => { + grid.expandRow(fix.componentInstance.data[2].ID); + fix.detectChanges(); + const rowDetail = GridFunctions.getMasterRowDetail(grid.rowList.toArray()[2]); + expect(GridSelectionFunctions.getRowCheckboxDiv(rowDetail)).toBeNull(); + }); + + it('Should highlight only the master row when selecting it and not the detail row.', () => { + grid.expandRow(fix.componentInstance.data[2].ID); + fix.detectChanges(); + + const row = grid.rowList.toArray()[2]; + GridSelectionFunctions.rowCheckboxClick(row); + fix.detectChanges(); + + const rowDetail = GridFunctions.getMasterRowDetail(row); + expect(row.nativeElement.classList).toContain(SELECTED_ROW_CLASS_NAME); + expect(rowDetail.querySelector('[class*="selected"]')).toBeNull(); + }); + }); + + describe('Search', () => { + it('Should scroll to the correct parent rows when searching in a grid with expanded detail views.', async () => { + fix = TestBed.createComponent(AllExpandedGridMasterDetailComponent); + fix.detectChanges(); + await wait(); + fix.detectChanges(); + grid = fix.componentInstance.grid; + + grid.findNext('Paolo'); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + let row = grid.gridAPI.get_row_by_index(52); + expect(row).not.toBeNull(); + GridFunctions.elementInGridView(grid, row.nativeElement); + grid.findPrev('Maria'); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + row = grid.gridAPI.get_row_by_index(0); + expect(row).not.toBeNull(); + GridFunctions.elementInGridView(grid, row.nativeElement); + }); + }); + + describe('Updating', () => { + beforeEach(async () => { + fix = TestBed.createComponent(AllExpandedGridMasterDetailComponent); + fix.detectChanges(); + await wait(); + fix.detectChanges(); + + grid = fix.componentInstance.grid; + }); + + it('Should remove expanded detail view after deleting its parent row.', () => { + let detailViews = GridFunctions.getAllMasterRowDetailDebug(fix); + expect(detailViews[0].context.index).toBe(1); + grid.deleteRow('ALFKI'); + fix.detectChanges(); + const row = grid.getRowByKey('ALFKI'); + expect(row).toBeUndefined(); + detailViews = GridFunctions.getAllMasterRowDetailDebug(fix); + expect(detailViews[0].context.index).toBe(1); + expect(detailViews[0].context.templateID.type).toBe('detailRow'); + expect(detailViews[0].context.templateID.id).toBe('ANATR'); + }); + + it('Should be able to expand detail view of newly added row.', async () => { + grid.addRow({ ID: '123', CompanyName: 'Test', ContactName: 'Test', Address: 'Test Address' }); + fix.detectChanges(); + // scroll to bottom + grid.verticalScrollContainer.scrollTo(grid.verticalScrollContainer.igxForOf.length - 1); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + // check row can be expanded + const lastRow = grid.rowList.last; + GridFunctions.toggleMasterRow(fix, lastRow); + await wait(); + fix.detectChanges(); + expect(lastRow.expanded).toBeTruthy(); + const lastRowDetail = GridFunctions.getMasterRowDetail(grid.rowList.last); + expect(getDetailAddressText(lastRowDetail)).toEqual('Test Address'); + }); + + }); + + describe('Sorting', () => { + it('Should rearrange detail views to their correct parents after sorting.', async () => { + fix = TestBed.createComponent(AllExpandedGridMasterDetailComponent); + fix.detectChanges(); + await wait(); + fix.detectChanges(); + grid = fix.componentInstance.grid; + + grid.sort({ fieldName: 'ContactName', dir: SortingDirection.Desc, ignoreCase: true }); + fix.detectChanges(); + + let row = grid.rowList.first; + let detailRow = GridFunctions.getMasterRowDetail(row); + + expect(row.data['ContactName']).toBe('Yang Wang'); + expect(getDetailAddressText(detailRow)).toEqual(row.data['Address']); + + row = grid.rowList.toArray()[1]; + detailRow = GridFunctions.getMasterRowDetail(row); + expect(row.data['ContactName']).toBe('Victoria Ashworth'); + expect(getDetailAddressText(detailRow)).toEqual(row.data['Address']); + }); + }); + + describe('Filtering', () => { + it('Should persist template state after filtering out the whole data and removing the filter.', fakeAsync(() => { + fix = TestBed.createComponent(AllExpandedGridMasterDetailComponent); + fix.detectChanges(); + tick(100); + fix.detectChanges(); + grid = fix.componentInstance.grid; + + let checkbox = fix.debugElement.query(By.directive(IgxCheckboxComponent)); + checkbox.componentInstance.checked = !checkbox.componentInstance.checked; + fix.detectChanges(); + + // check checkbox state + checkbox = fix.debugElement.query(By.directive(IgxCheckboxComponent)); + expect(checkbox.componentInstance.checked).toBeTruthy(); + + grid.filter('ContactName', 'NonExistingName', IgxStringFilteringOperand.instance().condition('equals'), true); + fix.detectChanges(); + tick(100); + expect(grid.rowList.length).toBe(0); + + grid.clearFilter(); + fix.detectChanges(); + tick(100); + + // check checkbox state is persisted. + checkbox = fix.debugElement.query(By.directive(IgxCheckboxComponent)); + expect(checkbox.componentInstance.checked).toBeTruthy(); + })); + }); + + describe('Multi-row layout', () => { + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(MRLMasterDetailComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + + GridFunctions.toggleMasterRow(fix, grid.rowList.first); + fix.detectChanges(); + })); + + it('Should render expand/collapse icon in the column with visible index 0.', () => { + const cell = grid.gridAPI.get_cell_by_key('ALFKI', 'CompanyName'); + expect(cell instanceof IgxGridExpandableCellComponent).toBeTruthy(); + const iconName = cell.nativeElement.querySelector('igx-icon').textContent; + expect(iconName).toEqual(EXPANDED_ICON_NAME); + const firstRowDetail = GridFunctions.getMasterRowDetail(grid.rowList.first); + expect(getDetailAddressText(firstRowDetail)).toEqual('Obere Str. 57'); + }); + + it('Should expand detail view without breaking multi-row layout.', () => { + // check row order + const rows = fix.debugElement.queryAll(By.css(ROW_TAG)); + const detailViews = GridFunctions.getAllMasterRowDetailDebug(fix); + expect(detailViews.length).toBe(1); + + expect(rows[0].context.index).toBe(0); + expect(detailViews[0].context.index).toBe(1); + expect(rows[1].context.index).toBe(2); + }); + + it(`Should navigate down through a detail view by focusing the whole row and continuing + onto the next with arrow down in multi-row layout grid.`, async () => { + const gridContent = GridFunctions.getGridContent(fix); + const targetCellElement = grid.gridAPI.get_cell_by_index(0, 'ContactName'); + UIInteractions.simulateClickAndSelectEvent(targetCellElement); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent); + fix.detectChanges(); + + let targetCellElement2 = grid.getCellByColumn(0, 'Address'); + expect(targetCellElement2.active).toBeTruthy(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent); + fix.detectChanges(); + + const firstRowDetail = GridFunctions.getMasterRowDetail(grid.rowList.first); + GridFunctions.verifyMasterDetailRowFocused(firstRowDetail); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent); + await wait(); + fix.detectChanges(); + + targetCellElement2 = grid.getCellByColumn(2, 'CompanyName'); + expect(grid.gridAPI.get_cell_by_index(2, 'CompanyName').active).toBeTruthy(); + }); + + it(`Should navigate up through a detail view by + focusing the whole row and continuing onto the next with arrow up in multi-row layout grid.`, async () => { + const gridContent = GridFunctions.getGridContent(fix); + const targetCellElement = grid.gridAPI.get_cell_by_index(2, 'ContactName'); + UIInteractions.simulateClickAndSelectEvent(targetCellElement); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent); + await wait(); + fix.detectChanges(); + + let targetCellElement2 = grid.getCellByColumn(2, 'CompanyName'); + expect(targetCellElement2.active).toBeTruthy(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent); + fix.detectChanges(); + + const firstRowDetail = GridFunctions.getMasterRowDetail(grid.rowList.first); + GridFunctions.verifyMasterDetailRowFocused(firstRowDetail); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent); + fix.detectChanges(); + + targetCellElement2 = grid.getCellByColumn(0, 'Address'); + expect(grid.gridAPI.get_cell_by_index(0, 'Address').active).toBeTruthy(); + }); + }); + + describe('GroupBy', () => { + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(DefaultGridMasterDetailComponent); + fix.detectChanges(); + + grid = fix.componentInstance.grid; + grid.getColumnByName('ContactName').hasSummary = true; + fix.detectChanges(); + + grid.summaryCalculationMode = GridSummaryCalculationMode.childLevelsOnly; + grid.groupingExpressions = + [{ fieldName: 'CompanyName', dir: SortingDirection.Asc, ignoreCase: false }]; + fix.detectChanges(); + })); + + it(`Should correctly position summary rows when summary row position is bottom + after grouping by and detail views for the group rows are expanded.`, async () => { + grid.expandAll(); + await wait(); + fix.detectChanges(); + + const allRows = grid.tbody.nativeElement.firstElementChild.children; + expect(allRows.length).toBe(8); + expect(allRows[0].tagName.toLowerCase()).toBe(GROUP_ROW_TAG); + expect(allRows[1].tagName.toLowerCase()).toBe(ROW_TAG); + expect(allRows[2].tagName.toLowerCase()).toBe('div'); + expect(allRows[2].getAttribute('detail')).toBe('true'); + expect(allRows[3].tagName.toLowerCase()).toBe(SUMMARY_ROW_TAG); + expect(allRows[4].tagName.toLowerCase()).toBe(GROUP_ROW_TAG); + expect(allRows[5].tagName.toLowerCase()).toBe(ROW_TAG); + expect(allRows[6].tagName.toLowerCase()).toBe('div'); + expect(allRows[6].getAttribute('detail')).toBe('true'); + expect(allRows[7].tagName.toLowerCase()).toBe(SUMMARY_ROW_TAG); + }); + + it(`Should correctly position summary rows when summary row position is top + after grouping by and detail views for the group rows are expanded.`, async () => { + grid.expandAll(); + await wait(); + fix.detectChanges(); + + grid.summaryPosition = GridSummaryPosition.top; + fix.detectChanges(); + + const allRows = grid.tbody.nativeElement.firstElementChild.children; + expect(allRows.length).toBe(8); + expect(allRows[0].tagName.toLowerCase()).toBe(GROUP_ROW_TAG); + expect(allRows[1].tagName.toLowerCase()).toBe(SUMMARY_ROW_TAG); + expect(allRows[2].tagName.toLowerCase()).toBe(ROW_TAG); + expect(allRows[3].tagName.toLowerCase()).toBe('div'); + expect(allRows[3].getAttribute('detail')).toBe('true'); + expect(allRows[4].tagName.toLowerCase()).toBe(GROUP_ROW_TAG); + expect(allRows[5].tagName.toLowerCase()).toBe(SUMMARY_ROW_TAG); + expect(allRows[6].tagName.toLowerCase()).toBe(ROW_TAG); + expect(allRows[7].tagName.toLowerCase()).toBe('div'); + expect(allRows[7].getAttribute('detail')).toBe('true'); + }); + + it(`Should correctly position summary rows when summary row position is top + after grouping by and detail views for the group rows are collapsed.`, () => { + grid.summaryPosition = GridSummaryPosition.top; + fix.detectChanges(); + const allRows = grid.tbody.nativeElement.firstElementChild.children; + expect(allRows.length).toBe(9); + expect(allRows[0].tagName.toLowerCase()).toBe(GROUP_ROW_TAG); + expect(allRows[1].tagName.toLowerCase()).toBe(SUMMARY_ROW_TAG); + expect(allRows[2].tagName.toLowerCase()).toBe(ROW_TAG); + expect(allRows[3].tagName.toLowerCase()).toBe(GROUP_ROW_TAG); + expect(allRows[4].tagName.toLowerCase()).toBe(SUMMARY_ROW_TAG); + expect(allRows[5].tagName.toLowerCase()).toBe(ROW_TAG); + expect(allRows[6].tagName.toLowerCase()).toBe(GROUP_ROW_TAG); + expect(allRows[7].tagName.toLowerCase()).toBe(SUMMARY_ROW_TAG); + expect(allRows[8].tagName.toLowerCase()).toBe(ROW_TAG); + }); + + it(`Should correctly position summary rows when summary + row position is bottom after grouping by and detail views for the group rows are collapsed.`, () => { + const allRows = grid.tbody.nativeElement.firstElementChild.children; + expect(allRows.length).toBe(9); + expect(allRows[0].tagName.toLowerCase()).toBe(GROUP_ROW_TAG); + expect(allRows[1].tagName.toLowerCase()).toBe(ROW_TAG); + expect(allRows[2].tagName.toLowerCase()).toBe(SUMMARY_ROW_TAG); + expect(allRows[3].tagName.toLowerCase()).toBe(GROUP_ROW_TAG); + expect(allRows[4].tagName.toLowerCase()).toBe(ROW_TAG); + expect(allRows[5].tagName.toLowerCase()).toBe(SUMMARY_ROW_TAG); + expect(allRows[6].tagName.toLowerCase()).toBe(GROUP_ROW_TAG); + expect(allRows[7].tagName.toLowerCase()).toBe(ROW_TAG); + expect(allRows[8].tagName.toLowerCase()).toBe(SUMMARY_ROW_TAG); + }); + }); + }); +}); + +@Component({ + template: ` + + @for (c of columns; track c.field) { + + + } + @if (paging) { + + } + + +

    +
    + + Available +
    +
    {{dataItem.Address}}
    + + + +
    +
    +
    + +
    + NEW TEMPLATE +
    +
    + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxGridDetailTemplateDirective, IgxCheckboxComponent, IgxPaginatorComponent, IgxInputGroupComponent, IgxInputDirective] +}) +export class DefaultGridMasterDetailComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + + @ViewChild('detailTemplate', { read: TemplateRef, static: true }) + public detailTemplate: TemplateRef; + + @ViewChild('gridTemplate', { read: TemplateRef, static: true }) + public gridTemplate: TemplateRef; + + @ViewChildren('childGrid', { read: IgxGridComponent }) + public childGrid: IgxGridComponent; + + public width = '800px'; + public height = '500px'; + public data = SampleTestData.contactInfoDataFull(); + public columns = [ + { field: 'ContactName', width: '400px' }, + { field: 'CompanyName', width: '400px' } + ]; + public paging = false; + public perPage = 15; + public rowSelectable = GridSelectionMode.none; +} + +@Component({ + template: ` + + @for (c of columns; track c.field) { + + + } + @if (paging) { + + } + + +
    +
    + + Available +
    +
    {{dataItem.Address}}
    +
    +
    +
    +
    + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxCheckboxComponent, IgxGridDetailTemplateDirective, IgxPaginatorComponent] +}) +export class AllExpandedGridMasterDetailComponent extends DefaultGridMasterDetailComponent implements OnInit { + public expStates = new Map(); + public ngOnInit(): void { + const allExpanded = new Map(); + this.data.forEach(item => { + allExpanded.set(item['ID'], true); + }); + this.expStates = allExpanded; + } +} + +@Component({ + template: ` + + + + + + + + + + + + + + + @if (paging) { + + } + +
    +
    + + Available +
    +
    {{dataItem.Address}}
    +
    +
    +
    +
    + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxGridDetailTemplateDirective, IgxColumnLayoutComponent, IgxCheckboxComponent, IgxPaginatorComponent] +}) +export class MRLMasterDetailComponent extends DefaultGridMasterDetailComponent { } + +const getDetailAddressText = (detailElem) => detailElem.querySelector('.addressArea').innerText; diff --git a/projects/igniteui-angular/grids/grid/src/grid.module.ts b/projects/igniteui-angular/grids/grid/src/grid.module.ts new file mode 100644 index 00000000000..adb8dd0b53a --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { IGX_GRID_DIRECTIVES } from './public_api'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + ...IGX_GRID_DIRECTIVES + ], + exports: [ + ...IGX_GRID_DIRECTIVES + ] +}) +export class IgxGridModule {} diff --git a/projects/igniteui-angular/grids/grid/src/grid.multi-row-layout.integration.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.multi-row-layout.integration.spec.ts new file mode 100644 index 00000000000..5295a492f6f --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid.multi-row-layout.integration.spec.ts @@ -0,0 +1,1430 @@ +import { TestBed, waitForAsync, ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxGridComponent } from './grid.component'; +import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; +import { ViewChild, Component, DebugElement } from '@angular/core'; +import { IgxColumnLayoutComponent, IgxGridMRLNavigationService, IgxGridToolbarActionsComponent, IgxGridToolbarComponent, IgxGridToolbarHidingComponent, IgxGridToolbarPinningComponent } from 'igniteui-angular/grids/core'; +import { wait, UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { GridFunctions, GRID_MRL_BLOCK } from '../../../test-utils/grid-functions.spec'; +import { ControlsFunction } from '../../../test-utils/controls-functions.spec'; +import { IgxColumnComponent } from 'igniteui-angular/grids/core'; +import { DefaultSortingStrategy, SortingDirection } from 'igniteui-angular/core'; + + +type FixtureType = ColumnLayoutGroupingTestComponent | ColumnLayoutHidingTestComponent | ColumnLayoutResizingTestComponent + | ColumnLayoutPinningTestComponent; +interface ColGroupsType { + group: string; + hidden?: boolean; + pinned?: boolean; + columns: any[]; +} + +describe('IgxGrid - multi-row-layout Integration #grid - ', () => { + let fixture: ComponentFixture; + let grid: IgxGridComponent; + const COLUMN_HEADER_CLASS = '.igx-grid-th'; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + ColumnLayoutPinningTestComponent, + ColumnLayoutFilteringTestComponent, + ColumnLayoutHidingTestComponent, + ColumnLayoutGroupingTestComponent, + ColumnLayoutResizingTestComponent + ], + providers: [ + IgxGridMRLNavigationService + ] + }).compileComponents(); + })); + + describe('Hiding ', () => { + beforeEach(() => { + fixture = TestBed.createComponent(ColumnLayoutHidingTestComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + }); + + it('should allow setting a whole group as hidden/shown.', () => { + + // group 1 should be hidden - all child columns should be hidden + expect(grid.getColumnByName('group1').hidden).toBeTruthy(); + expect(grid.getColumnByName('PostalCode').hidden).toBeTruthy(); + expect(grid.getColumnByName('City').hidden).toBeTruthy(); + expect(grid.getColumnByName('Country').hidden).toBeTruthy(); + expect(grid.getColumnByName('Address').hidden).toBeTruthy(); + + expect(grid.getColumnByName('group2').hidden).toBeFalsy(); + expect(grid.getColumnByName('ID').hidden).toBeFalsy(); + expect(grid.getColumnByName('CompanyName').hidden).toBeFalsy(); + expect(grid.getColumnByName('ContactName').hidden).toBeFalsy(); + expect(grid.getColumnByName('ContactTitle').hidden).toBeFalsy(); + + let gridFirstRow = grid.rowList.first; + + // headers are aligned to cells + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + + GridFunctions.verifyDOMMatchesLayoutSettings(grid, gridFirstRow, fixture.componentInstance.colGroups.slice(1, 2)); + + // show group + fixture.componentInstance.colGroups[0].hidden = false; + fixture.detectChanges(); + fixture.detectChanges(); + + expect(grid.getColumnByName('group1').hidden).toBeFalsy(); + expect(grid.getColumnByName('PostalCode').hidden).toBeFalsy(); + expect(grid.getColumnByName('City').hidden).toBeFalsy(); + expect(grid.getColumnByName('Country').hidden).toBeFalsy(); + expect(grid.getColumnByName('Address').hidden).toBeFalsy(); + + expect(grid.getColumnByName('group2').hidden).toBeFalsy(); + expect(grid.getColumnByName('ID').hidden).toBeFalsy(); + expect(grid.getColumnByName('CompanyName').hidden).toBeFalsy(); + expect(grid.getColumnByName('ContactName').hidden).toBeFalsy(); + expect(grid.getColumnByName('ContactTitle').hidden).toBeFalsy(); + + // headers are aligned to cells + gridFirstRow = grid.rowList.first; + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + + GridFunctions.verifyDOMMatchesLayoutSettings(grid, gridFirstRow, fixture.componentInstance.colGroups); + + // hide the other group + fixture.componentInstance.colGroups[1].hidden = true; + fixture.detectChanges(); + fixture.detectChanges(); + + expect(grid.getColumnByName('PostalCode').hidden).toBeFalsy(); + expect(grid.getColumnByName('City').hidden).toBeFalsy(); + expect(grid.getColumnByName('Country').hidden).toBeFalsy(); + expect(grid.getColumnByName('Address').hidden).toBeFalsy(); + + expect(grid.getColumnByName('ID').hidden).toBeTruthy(); + expect(grid.getColumnByName('CompanyName').hidden).toBeTruthy(); + expect(grid.getColumnByName('ContactName').hidden).toBeTruthy(); + expect(grid.getColumnByName('ContactTitle').hidden).toBeTruthy(); + + gridFirstRow = grid.rowList.first; + // headers are aligned to cells + GridFunctions.verifyLayoutHeadersAreAligned(grid, grid.rowList.first); + + GridFunctions.verifyDOMMatchesLayoutSettings(grid, grid.rowList.first, fixture.componentInstance.colGroups.slice(0, 1)); + }); + + it('should hide/show whole group if a single child column is hidden/shown.', () => { + // show PostalCode + grid.getColumnByName('PostalCode').hidden = false; + fixture.detectChanges(); + + // everything should be shown + expect(grid.getColumnByName('group1').hidden).toBeFalsy(); + expect(grid.getColumnByName('PostalCode').hidden).toBeFalsy(); + expect(grid.getColumnByName('City').hidden).toBeFalsy(); + expect(grid.getColumnByName('Country').hidden).toBeFalsy(); + expect(grid.getColumnByName('Address').hidden).toBeFalsy(); + + expect(grid.getColumnByName('group2').hidden).toBeFalsy(); + expect(grid.getColumnByName('ID').hidden).toBeFalsy(); + expect(grid.getColumnByName('CompanyName').hidden).toBeFalsy(); + expect(grid.getColumnByName('ContactName').hidden).toBeFalsy(); + expect(grid.getColumnByName('ContactTitle').hidden).toBeFalsy(); + + // hide ContactTitle + grid.getColumnByName('ContactTitle').hidden = true; + fixture.detectChanges(); + + // group2 should be hidden + expect(grid.getColumnByName('group1').hidden).toBeFalsy(); + expect(grid.getColumnByName('PostalCode').hidden).toBeFalsy(); + expect(grid.getColumnByName('City').hidden).toBeFalsy(); + expect(grid.getColumnByName('Country').hidden).toBeFalsy(); + expect(grid.getColumnByName('Address').hidden).toBeFalsy(); + + expect(grid.getColumnByName('group2').hidden).toBeTruthy(); + expect(grid.getColumnByName('ID').hidden).toBeTruthy(); + expect(grid.getColumnByName('CompanyName').hidden).toBeTruthy(); + expect(grid.getColumnByName('ContactName').hidden).toBeTruthy(); + expect(grid.getColumnByName('ContactTitle').hidden).toBeTruthy(); + }); + + it('verify visible column indexes when hide/show a column', () => { + + expect(grid.getColumnByName('ID').visibleIndex).toBe(0); + expect(grid.getColumnByName('CompanyName').visibleIndex).toBe(1); + expect(grid.getColumnByName('ContactName').visibleIndex).toBe(2); + expect(grid.getColumnByName('ContactTitle').visibleIndex).toBe(3); + // show PostalCode + grid.getColumnByName('PostalCode').hidden = false; + fixture.detectChanges(); + + expect(grid.getColumnByName('ID').visibleIndex).toBe(1); + expect(grid.getColumnByName('CompanyName').visibleIndex).toBe(2); + expect(grid.getColumnByName('ContactName').visibleIndex).toBe(3); + expect(grid.getColumnByName('ContactTitle').visibleIndex).toBe(6); + expect(grid.getColumnByName('PostalCode').visibleIndex).toBe(0); + expect(grid.getColumnByName('City').visibleIndex).toBe(4); + expect(grid.getColumnByName('Country').visibleIndex).toBe(5); + expect(grid.getColumnByName('Address').visibleIndex).toBe(7); + + // hide PostalCode + grid.getColumnByName('PostalCode').hidden = true; + fixture.detectChanges(); + expect(grid.getColumnByName('PostalCode').visibleIndex).toBe(-1); + expect(grid.getColumnByName('City').visibleIndex).toBe(-1); + expect(grid.getColumnByName('Country').visibleIndex).toBe(-1); + expect(grid.getColumnByName('Address').visibleIndex).toBe(-1); + + // show PostalCode + grid.getColumnByName('PostalCode').hidden = false; + fixture.detectChanges(); + expect(grid.getColumnByName('PostalCode').visibleIndex).toBe(0); + expect(grid.getColumnByName('City').visibleIndex).toBe(4); + expect(grid.getColumnByName('Country').visibleIndex).toBe(5); + expect(grid.getColumnByName('Address').visibleIndex).toBe(7); + }); + + + it('should work with horizontal virtualization when some groups are hidden/shown.', async () => { + const uniqueGroups: ColGroupsType[] = [ + { + group: 'group1', + hidden: true, + // total colspan 3 + columns: [ + { field: 'Address', rowStart: 1, colStart: 1, colEnd: 4, rowEnd: 3 }, + { field: 'County', rowStart: 3, colStart: 1 }, + { field: 'Region', rowStart: 3, colStart: 2 }, + { field: 'City', rowStart: 3, colStart: 3 } + ] + }, + { + group: 'group2', + // total colspan 2 + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1 }, + { field: 'Address', rowStart: 1, colStart: 2 }, + { field: 'ContactName', rowStart: 2, colStart: 1, colEnd: 3, rowEnd: 4 } + ] + }, + { + group: 'group3', + // total colspan 1 + columns: [ + { field: 'Phone', rowStart: 1, colStart: 1 }, + { field: 'Fax', rowStart: 2, colStart: 1, rowEnd: 4 } + ] + }, + { + group: 'group4', + // total colspan 4 + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'Phone', rowStart: 1, colStart: 3, rowEnd: 3 }, + { field: 'Address', rowStart: 1, colStart: 4, rowEnd: 4 }, + { field: 'Region', rowStart: 2, colStart: 1 }, + { field: 'City', rowStart: 2, colStart: 2 }, + { field: 'ContactName', rowStart: 3, colStart: 1, colEnd: 4 }, + ] + } + ]; + fixture.componentInstance.colGroups = uniqueGroups; + grid.columnWidth = '200px'; + fixture.componentInstance.grid.width = '600px'; + fixture.detectChanges(); + + const gridFirstRow = grid.rowList.first; + // group1 should be hidden on init, check DOM + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + GridFunctions.verifyDOMMatchesLayoutSettings(grid, gridFirstRow, fixture.componentInstance.colGroups.slice(1)); + + // check virtualization state + // 4 groups in total - 1 is hidden + const horizontalVirtualization = grid.rowList.first.virtDirRow; + expect(grid.hasHorizontalScroll()).toBeTruthy(); + expect(horizontalVirtualization.igxForOf.length).toBe(3); + // check order is correct + expect(horizontalVirtualization.igxForOf[0]).toBe(grid.getColumnByName('group2')); + expect(horizontalVirtualization.igxForOf[1]).toBe(grid.getColumnByName('group3')); + expect(horizontalVirtualization.igxForOf[2]).toBe(grid.getColumnByName('group4')); + // check their sizes are correct + expect(horizontalVirtualization.getSizeAt(0)).toBe(2 * 200); + expect(horizontalVirtualization.getSizeAt(1)).toBe(1 * 200); + expect(horizontalVirtualization.getSizeAt(2)).toBe(4 * 200); + + // check total widths sum + let horizontalScrElem = horizontalVirtualization.getScroll(); + // 7 column span in total + let totalExpected = 7 * 200; + expect(parseInt((horizontalScrElem.children[0] as HTMLElement).style.width, 10)).toBe(totalExpected); + + // hide group 3 + grid.getColumnByName('group3').hidden = true; + fixture.detectChanges(); + + // check virtualization state + // 4 groups in total - 2 is hidden + expect(grid.hasHorizontalScroll()).toBeTruthy(); + expect(horizontalVirtualization.igxForOf.length).toBe(2); + // check order is correct + expect(horizontalVirtualization.igxForOf[0]).toBe(grid.getColumnByName('group2')); + expect(horizontalVirtualization.igxForOf[1]).toBe(grid.getColumnByName('group4')); + // check their sizes are correct + expect(horizontalVirtualization.getSizeAt(0)).toBe(2 * 200); + expect(horizontalVirtualization.getSizeAt(1)).toBe(4 * 200); + + // check total widths sum + horizontalScrElem = horizontalVirtualization.getScroll(); + // 7 column span in total + totalExpected = 6 * 200; + expect(parseInt((horizontalScrElem.children[0] as HTMLElement).style.width, 10)).toBe(totalExpected); + + // show group1 + grid.getColumnByName('group1').hidden = false; + fixture.detectChanges(); + + // check virtualization state + // 4 groups in total - 1 is hidden + expect(grid.hasHorizontalScroll()).toBeTruthy(); + expect(horizontalVirtualization.igxForOf.length).toBe(3); + // check order is correct + expect(horizontalVirtualization.igxForOf[0]).toBe(grid.getColumnByName('group1')); + expect(horizontalVirtualization.igxForOf[1]).toBe(grid.getColumnByName('group2')); + expect(horizontalVirtualization.igxForOf[2]).toBe(grid.getColumnByName('group4')); + // check their sizes are correct + expect(horizontalVirtualization.getSizeAt(0)).toBe(3 * 200); + expect(horizontalVirtualization.getSizeAt(1)).toBe(2 * 200); + expect(horizontalVirtualization.getSizeAt(2)).toBe(4 * 200); + + // check total widths sum + horizontalScrElem = horizontalVirtualization.getScroll(); + // 7 column span in total + totalExpected = 9 * 200; + expect(parseInt((horizontalScrElem.children[0] as HTMLElement).style.width, 10)).toBe(totalExpected); + + // check last column group can be scrolled in view + horizontalVirtualization.scrollTo(2); + await wait(100); + fixture.detectChanges(); + + const lastCell = grid.rowList.first.cells.toArray()[5]; + expect(lastCell.column.field).toBe('Address'); + expect(lastCell.column.parent.field).toBe('group4'); + expect(lastCell.nativeElement.getBoundingClientRect().right) + .toEqual(grid.tbody.nativeElement.getBoundingClientRect().right); + + }); + + it('UI - hidden columns count and drop-down items text in hiding toolbar should be correct when group is hidden/shown. ', + waitForAsync(async () => { + // enable toolbar for hiding + fixture.componentInstance.showToolbar = true; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const hidingButton = GridFunctions.getColumnHidingButton(fixture); + hidingButton.click(); + fixture.detectChanges(); + // should show count for actual hidden igxColumns + expect(parseInt(hidingButton.querySelector('span').textContent.trim(), 10)).toBe(4); + const columnChooserElement = GridFunctions.getColumnHidingElement(fixture); + const checkboxes = columnChooserElement.queryAll(By.css('igx-checkbox')); + // should show 2 checkboxes - one for each group + expect(checkboxes.length).toBe(2); + expect(checkboxes[0].query(By.css('.igx-checkbox__label')).nativeElement.textContent.trim()).toBe('group1'); + expect(checkboxes[1].query(By.css('.igx-checkbox__label')).nativeElement.textContent.trim()).toBe('group2'); + + // verify checked state + expect(checkboxes[0].componentInstance.checked).toBeFalse(); + expect(checkboxes[1].componentInstance.checked).toBeTrue(); + })); + + it(`UI - toggling column checkbox checked state successfully changes the column's hidden state. `, waitForAsync(async () => { + // enable toolbar for hiding + fixture.componentInstance.showToolbar = true; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const hidingButton = GridFunctions.getColumnHidingButton(fixture); + hidingButton.click(); + fixture.detectChanges(); + + const verifyCheckbox = ControlsFunction.verifyCheckbox; + const columnChooserElement = GridFunctions.getColumnHidingElement(fixture); + const checkbox = ControlsFunction.getCheckboxInput('group1', columnChooserElement); + verifyCheckbox('group1', false, false, columnChooserElement); + + const column = grid.getColumnByName('group1'); + expect(column.hidden).toBeTrue(); + + let gridFirstRow = grid.rowList.first; + + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + GridFunctions.verifyDOMMatchesLayoutSettings(grid, gridFirstRow, fixture.componentInstance.colGroups.slice(1)); + + const checkboxEl = ControlsFunction.getCheckboxElement('group1', columnChooserElement); + checkboxEl.triggerEventHandler('click', new Event('click')); + fixture.detectChanges(); + + expect(checkbox.checked).toBe(true); + expect(column.hidden).toBeFalse(); + + gridFirstRow = grid.rowList.first; + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + GridFunctions.verifyDOMMatchesLayoutSettings(grid, gridFirstRow, fixture.componentInstance.colGroups); + + checkboxEl.triggerEventHandler('click', new Event('click')); + fixture.detectChanges(); + + expect(checkbox.checked).toBe(false); + expect(column.hidden).toBeTrue(); + })); + + }); + + describe('Pinning ', () => { + beforeEach(() => { + fixture = TestBed.createComponent(ColumnLayoutPinningTestComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + }); + + it('should allow pinning/unpinning a whole group.', () => { + // group 1 should be pinned - all child columns should be pinned + expect(grid.getColumnByName('PostalCode').pinned).toBeTruthy(); + expect(grid.getColumnByName('City').pinned).toBeTruthy(); + expect(grid.getColumnByName('Country').pinned).toBeTruthy(); + expect(grid.getColumnByName('Address').pinned).toBeTruthy(); + + expect(grid.getColumnByName('ID').pinned).toBeFalsy(); + expect(grid.getColumnByName('CompanyName').pinned).toBeFalsy(); + expect(grid.getColumnByName('ContactName').pinned).toBeFalsy(); + expect(grid.getColumnByName('ContactTitle').pinned).toBeFalsy(); + + + // headers are aligned to cells + // TODO MRL + let gridFirstRow = grid.rowList.first; + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow, true); + + GridFunctions.verifyDOMMatchesLayoutSettings(grid, gridFirstRow, fixture.componentInstance.colGroups); + + // unpin group + fixture.componentInstance.colGroups[0].pinned = false; + fixture.detectChanges(); + + expect(grid.getColumnByName('PostalCode').pinned).toBeFalsy(); + expect(grid.getColumnByName('City').pinned).toBeFalsy(); + expect(grid.getColumnByName('Country').pinned).toBeFalsy(); + expect(grid.getColumnByName('Address').pinned).toBeFalsy(); + + expect(grid.getColumnByName('ID').pinned).toBeFalsy(); + expect(grid.getColumnByName('CompanyName').pinned).toBeFalsy(); + expect(grid.getColumnByName('ContactName').pinned).toBeFalsy(); + expect(grid.getColumnByName('ContactTitle').pinned).toBeFalsy(); + + gridFirstRow = grid.rowList.first; + + // headers are aligned to cells + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + + GridFunctions.verifyDOMMatchesLayoutSettings(grid, gridFirstRow, fixture.componentInstance.colGroups); + + // pin the other group + fixture.componentInstance.colGroups[1].pinned = true; + fixture.detectChanges(); + + expect(grid.getColumnByName('PostalCode').pinned).toBeFalsy(); + expect(grid.getColumnByName('City').pinned).toBeFalsy(); + expect(grid.getColumnByName('Country').pinned).toBeFalsy(); + expect(grid.getColumnByName('Address').pinned).toBeFalsy(); + + expect(grid.getColumnByName('ID').pinned).toBeTruthy(); + expect(grid.getColumnByName('CompanyName').pinned).toBeTruthy(); + expect(grid.getColumnByName('ContactName').pinned).toBeTruthy(); + expect(grid.getColumnByName('ContactTitle').pinned).toBeTruthy(); + + }); + + it('should pin/unpin whole group if a single child column is pinned/unpinned.', () => { + // group 1 should be pinned - all child columns should be pinned + expect(grid.getColumnByName('PostalCode').pinned).toBeTruthy(); + expect(grid.getColumnByName('City').pinned).toBeTruthy(); + expect(grid.getColumnByName('Country').pinned).toBeTruthy(); + expect(grid.getColumnByName('Address').pinned).toBeTruthy(); + + expect(grid.getColumnByName('ID').pinned).toBeFalsy(); + expect(grid.getColumnByName('CompanyName').pinned).toBeFalsy(); + expect(grid.getColumnByName('ContactName').pinned).toBeFalsy(); + expect(grid.getColumnByName('ContactTitle').pinned).toBeFalsy(); + + + grid.unpinColumn('City'); + fixture.detectChanges(); + + expect(grid.getColumnByName('PostalCode').pinned).toBeFalsy(); + expect(grid.getColumnByName('City').pinned).toBeFalsy(); + expect(grid.getColumnByName('Country').pinned).toBeFalsy(); + expect(grid.getColumnByName('Address').pinned).toBeFalsy(); + + expect(grid.getColumnByName('ID').pinned).toBeFalsy(); + expect(grid.getColumnByName('CompanyName').pinned).toBeFalsy(); + expect(grid.getColumnByName('ContactName').pinned).toBeFalsy(); + expect(grid.getColumnByName('ContactTitle').pinned).toBeFalsy(); + + grid.pinColumn('ContactName'); + fixture.detectChanges(); + + expect(grid.getColumnByName('PostalCode').pinned).toBeFalsy(); + expect(grid.getColumnByName('City').pinned).toBeFalsy(); + expect(grid.getColumnByName('Country').pinned).toBeFalsy(); + expect(grid.getColumnByName('Address').pinned).toBeFalsy(); + + expect(grid.getColumnByName('ID').pinned).toBeTruthy(); + expect(grid.getColumnByName('CompanyName').pinned).toBeTruthy(); + expect(grid.getColumnByName('ContactName').pinned).toBeTruthy(); + expect(grid.getColumnByName('ContactTitle').pinned).toBeTruthy(); + }); + + it('should emit columnPin event with correct parameters', () => { + let allArgs = []; + grid.columnPin.subscribe((args) => { + allArgs.push(args); + }); + + grid.unpinColumn('City'); + fixture.detectChanges(); + // should unpin parent and all child cols - 4 child + 1 parent + expect(allArgs.length).toBe(5); + + expect(allArgs[0].column instanceof IgxColumnLayoutComponent).toBeTruthy(); + expect(allArgs[0].isPinned).toBeTrue(); + + expect(allArgs[1].column.field).toBe('PostalCode'); + expect(allArgs[1].isPinned).toBeTrue(); + + expect(allArgs[2].column.field).toBe('City'); + expect(allArgs[2].isPinned).toBeTrue(); + + expect(allArgs[3].column.field).toBe('Country'); + expect(allArgs[3].isPinned).toBeTrue(); + + expect(allArgs[4].column.field).toBe('Address'); + expect(allArgs[4].isPinned).toBeTrue(); + + allArgs = []; + grid.pinColumn('ID'); + fixture.detectChanges(); + // should pin parent and all child cols - 4 child + 1 parent + expect(allArgs.length).toBe(5); + + expect(allArgs[0].column instanceof IgxColumnLayoutComponent).toBeTruthy(); + expect(allArgs[0].isPinned).toBeFalse(); + + expect(allArgs[1].column.field).toBe('ID'); + expect(allArgs[1].isPinned).toBeFalse(); + + expect(allArgs[2].column.field).toBe('CompanyName'); + expect(allArgs[2].isPinned).toBeFalse(); + + expect(allArgs[3].column.field).toBe('ContactName'); + expect(allArgs[3].isPinned).toBeFalse(); + + expect(allArgs[4].column.field).toBe('ContactTitle'); + expect(allArgs[4].isPinned).toBeFalse(); + + }); + + it('should work with horizontal virtualization on the unpinned groups.', async () => { + const uniqueGroups = [ + { + group: 'group1', + // total colspan 3 + columns: [ + { field: 'Address', rowStart: 1, colStart: 1, colEnd: 4, rowEnd: 3 }, + { field: 'County', rowStart: 3, colStart: 1 }, + { field: 'Region', rowStart: 3, colStart: 2 }, + { field: 'City', rowStart: 3, colStart: 3 } + ] + }, + { + group: 'group2', + // total colspan 2 + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1 }, + { field: 'Address', rowStart: 1, colStart: 2 }, + { field: 'ContactName', rowStart: 2, colStart: 1, colEnd: 3, rowEnd: 4 } + ] + }, + { + group: 'group3', + // total colspan 1 + columns: [ + { field: 'Phone', rowStart: 1, colStart: 1 }, + { field: 'Fax', rowStart: 2, colStart: 1, rowEnd: 4 } + ] + }, + { + group: 'group4', + // total colspan 4 + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'Phone', rowStart: 1, colStart: 3, rowEnd: 3 }, + { field: 'Address', rowStart: 1, colStart: 4, rowEnd: 4 }, + { field: 'Region', rowStart: 2, colStart: 1 }, + { field: 'City', rowStart: 2, colStart: 2 }, + { field: 'ContactName', rowStart: 3, colStart: 1, colEnd: 4 }, + ] + } + ]; + fixture.componentInstance.colGroups = uniqueGroups; + fixture.detectChanges(); + grid.columnWidth = '200px'; + fixture.componentInstance.grid.width = '600px'; + fixture.detectChanges(); + + // pin group3 + grid.pinColumn('group3'); + fixture.detectChanges(); + // check group 3 is pinned + expect(grid.getColumnByName('group3').pinned).toBeTruthy(); + expect(grid.getColumnByName('Fax').pinned).toBeTruthy(); + expect(grid.getColumnByName('Phone').pinned).toBeTruthy(); + + const gridFirstRow = grid.rowList.first; + + GridFunctions.verifyDOMMatchesLayoutSettings(grid, gridFirstRow, fixture.componentInstance.colGroups.slice(2, 3)); + // headers are aligned to cells + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow, true); + + // check virtualization state + // 4 groups in total - 1 is pinned + const horizontalVirtualization = grid.rowList.first.virtDirRow; + expect(grid.hasHorizontalScroll()).toBeTruthy(); + expect(horizontalVirtualization.igxForOf.length).toBe(3); + // check order is correct + expect(horizontalVirtualization.igxForOf[0]).toBe(grid.getColumnByName('group1')); + expect(horizontalVirtualization.igxForOf[1]).toBe(grid.getColumnByName('group2')); + expect(horizontalVirtualization.igxForOf[2]).toBe(grid.getColumnByName('group4')); + // check their sizes are correct + expect(horizontalVirtualization.getSizeAt(0)).toBe(3 * 200); + expect(horizontalVirtualization.getSizeAt(1)).toBe(2 * 200); + expect(horizontalVirtualization.getSizeAt(2)).toBe(4 * 200); + + // check total widths sum + const horizontalScrElem = horizontalVirtualization.getScroll(); + // 9 column span in total + const totalExpected = 9 * 200; + expect(parseInt((horizontalScrElem.children[0] as HTMLElement).style.width, 10)).toBe(totalExpected); + + // check last column group can be scrolled in view + horizontalVirtualization.scrollTo(2); + await wait(100); + fixture.detectChanges(); + + const lastCell = grid.rowList.first.cells.toArray()[5]; + expect(lastCell.column.field).toBe('Address'); + expect(lastCell.column.parent.field).toBe('group4'); + expect(Math.round(lastCell.nativeElement.getBoundingClientRect().right) - + grid.tbody.nativeElement.getBoundingClientRect().right) + .toBeLessThanOrEqual(2); + }); + + it('UI - pinned columns count and drop-down items text in pinning toolbar should be correct when group is pinned. ', + waitForAsync(async () => { + // enable toolbar for pinning + fixture.componentInstance.showToolbar = true; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const pinningButton = GridFunctions.getColumnPinningButton(fixture); + const pinningButtonLabel = pinningButton.querySelector('span'); + pinningButton.click(); + fixture.detectChanges(); + // should show count for actual igxColumns displayed in the pinned area + expect(parseInt(pinningButtonLabel.textContent.trim(), 10)).toBe(4); + const columnChooserElement = GridFunctions.getColumnPinningElement(fixture); + const checkboxes = columnChooserElement.queryAll(By.css('igx-checkbox')); + // should show 2 checkboxes - one for each group + expect(checkboxes.length).toBe(2); + expect(checkboxes[0].query(By.css('.igx-checkbox__label')).nativeElement.textContent.trim()).toBe('group1'); + expect(checkboxes[1].query(By.css('.igx-checkbox__label')).nativeElement.textContent.trim()).toBe('group2'); + + // verify checked state + expect(checkboxes[0].componentInstance.checked).toBeTruthy(); + expect(checkboxes[1].componentInstance.checked).toBeFalsy(); + })); + + it(`UI - toggling column checkbox checked state successfully changes the column's pinned state. `, waitForAsync(async () => { + const uniqueGroups = [ + { + group: 'group1', + // total colspan 3 + columns: [ + { field: 'Address', rowStart: 1, colStart: 1, colEnd: 4, rowEnd: 3 }, + { field: 'County', rowStart: 3, colStart: 1 }, + { field: 'Region', rowStart: 3, colStart: 2 }, + { field: 'City', rowStart: 3, colStart: 3 } + ] + }, + { + group: 'group2', + // total colspan 2 + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1 }, + { field: 'Address', rowStart: 1, colStart: 2 }, + { field: 'ContactName', rowStart: 2, colStart: 1, colEnd: 3, rowEnd: 4 } + ] + }, + { + group: 'group3', + // total colspan 1 + columns: [ + { field: 'Phone', rowStart: 1, colStart: 1 }, + { field: 'Fax', rowStart: 2, colStart: 1, rowEnd: 4 } + ] + }, + { + group: 'group4', + // total colspan 4 + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'Phone', rowStart: 1, colStart: 3, rowEnd: 3 }, + { field: 'Address', rowStart: 1, colStart: 4, rowEnd: 4 }, + { field: 'Region', rowStart: 2, colStart: 1 }, + { field: 'City', rowStart: 2, colStart: 2 }, + { field: 'ContactName', rowStart: 3, colStart: 1, colEnd: 4 }, + ] + } + ]; + fixture.componentInstance.showToolbar = true; + fixture.componentInstance.colGroups = uniqueGroups; + grid.columnWidth = '200px'; + fixture.componentInstance.grid.width = '1000px'; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const pinningButton = GridFunctions.getColumnPinningButton(fixture); + pinningButton.click(); + fixture.detectChanges(); + const columnChooserElement = GridFunctions.getColumnPinningElement(fixture); + + const verifyCheckbox = ControlsFunction.verifyCheckbox; + const checkbox = ControlsFunction.getCheckboxInput('group1', columnChooserElement); + verifyCheckbox('group1', false, false, columnChooserElement); + + const column = grid.getColumnByName('group1'); + expect(column.pinned).toBeFalsy(); + + const checkboxEl = ControlsFunction.getCheckboxElement('group1', columnChooserElement); + checkboxEl.triggerEventHandler('click', new Event('click')); + fixture.detectChanges(); + + expect(checkbox.checked).toBe(true); + expect(column.pinned).toBeTruthy(); + + checkboxEl.triggerEventHandler('click', new Event('click')); + fixture.detectChanges(); + + expect(checkbox.checked).toBe(false); + expect(column.pinned).toBeFalsy(); + })); + + it('should work when pinning group with columns that do not have and the unpinned group has width in percentages.', async () => { + const uniqueGroups = [ + { + group: 'group1', + // total colspan 3 + columns: [ + { field: 'Address', rowStart: 1, colStart: 1, colEnd: 4, rowEnd: 3 }, + { field: 'County', rowStart: 3, colStart: 1 }, + { field: 'Region', rowStart: 3, colStart: 2 }, + { field: 'City', rowStart: 3, colStart: 3 } + ] + }, + { + group: 'group2', + // total colspan 2 + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, width: '50%' }, + { field: 'Address', rowStart: 1, colStart: 2, width: '15%' }, + { field: 'ContactName', rowStart: 2, colStart: 1, colEnd: 3, rowEnd: 4 } + ] + } + ]; + fixture.componentInstance.colGroups = uniqueGroups; + fixture.componentInstance.grid.width = (800 + grid.scrollSize) + 'px'; + fixture.detectChanges(); + + // pin group3 + grid.pinColumn('group1'); + fixture.detectChanges(); + + // check group 3 is pinned + expect(grid.getColumnByName('group1').pinned).toBeTruthy(); + expect(grid.getColumnByName('Address').pinned).toBeTruthy(); + expect(grid.getColumnByName('County').pinned).toBeTruthy(); + + const gridFirstRow = grid.rowList.first; + + GridFunctions.verifyDOMMatchesLayoutSettings(grid, gridFirstRow, fixture.componentInstance.colGroups.slice(2, 3)); + // headers are aligned to cells + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow, true); + + // check virtualization state + const horizontalVirtualization = grid.rowList.first.virtDirRow; + expect(grid.hasHorizontalScroll()).toBeTruthy(); + expect(horizontalVirtualization.igxForOf.length).toBe(1); + expect(horizontalVirtualization.igxForOf[0]).toBe(grid.getColumnByName('group2')); + // check their sizes are correct + const totalExpected = 0.65 * 800; + expect(horizontalVirtualization.getSizeAt(0)).toBe(totalExpected); + + // check width scrollbar + const horizontalScrElem = horizontalVirtualization.getScroll(); + expect(parseInt((horizontalScrElem.children[0] as HTMLElement).style.width, 10)).toBe(totalExpected); + }); + }); + + describe('Filtering ', () => { + beforeEach(() => { + fixture = TestBed.createComponent(ColumnLayoutFilteringTestComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + }); + + it('should enforce excel style filtering.', () => { + const filteringCells = fixture.debugElement.queryAll(By.css('igx-grid-filtering-cell')); + expect(filteringCells.length).toBe(0); + + const filterIcons = fixture.debugElement.queryAll(By.css('.igx-excel-filter__icon')); + expect(filterIcons.length).not.toBe(0); + + const gridFirstRow = grid.rowList.first; + + // headers are aligned to cells + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow, true); + + GridFunctions.verifyDOMMatchesLayoutSettings(grid, gridFirstRow, fixture.componentInstance.colGroups); + }); + + it('should render unpin and hide column buttons into the excel style filter', () => { + const filterIcons = fixture.debugElement.queryAll(By.css('.igx-excel-filter__icon')); + expect(filterIcons.length).not.toBe(0); + + filterIcons[0].nativeElement.click(); + fixture.detectChanges(); + + const excelMenu = grid.nativeElement.querySelector('.igx-excel-filter__menu'); + const unpinComponent = excelMenu.querySelector('.igx-excel-filter__actions-unpin'); + const hideComponent = excelMenu.querySelector('.igx-excel-filter__actions-hide'); + + expect(unpinComponent).toBeDefined(); + expect(hideComponent).toBeDefined(); + }); + }); + + describe('GroupBy ', () => { + beforeEach(() => { + fixture = TestBed.createComponent(ColumnLayoutGroupingTestComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + }); + + it('should render rows correctly when grouped by a column and scrolling to bottom should not leave empty space.', async () => { + await wait(16); // needed because of throttleTime on the resize observer + fixture.detectChanges(); + grid.height = '600px'; + grid.groupBy({ + dir: SortingDirection.Desc, + fieldName: 'Country', + ignoreCase: false, + strategy: DefaultSortingStrategy.instance() + }); + fixture.detectChanges(); + + expect(grid.rowList.length).toEqual(8); + expect((grid.verticalScrollContainer.getScroll().children[0] as HTMLElement).offsetHeight - + grid.verticalScrollContainer.getScroll().offsetHeight).toBeGreaterThan(0); + + const lastIndex = grid.data.length + grid.groupsRecords.length - 1; + grid.verticalScrollContainer.scrollTo(lastIndex); + await wait(16); // needed because of throttleTime on the resize observer + fixture.detectChanges(); + + const scrollTop = grid.verticalScrollContainer.getScroll().scrollTop; + const scrollHeight = grid.verticalScrollContainer.getScroll().scrollHeight; + const tbody = fixture.debugElement.query(By.css('.igx-grid__tbody')).nativeElement; + const scrolledToBottom = Math.round(scrollTop + tbody.scrollHeight) === scrollHeight; + expect(grid.rowList.length).toEqual(8); + expect(scrolledToBottom).toBeTruthy(); + + const lastRowOffset = grid.rowList.last.element.nativeElement.offsetTop + + grid.rowList.last.element.nativeElement.offsetHeight + parseInt(tbody.children[0].children[0].style.top, 10); + expect(lastRowOffset).toEqual(tbody.scrollHeight); + }); + + it('should render rows correctly and collapsing all should render all groups and there should be no scrollbar.', async () => { + await wait(16); // needed because of throttleTime on the resize observer + fixture.detectChanges(); + grid.height = '600px'; + fixture.detectChanges(); + grid.groupBy({ + dir: SortingDirection.Desc, + fieldName: 'Country', + ignoreCase: false, + strategy: DefaultSortingStrategy.instance() + }); + fixture.detectChanges(); + + expect(grid.rowList.length).toEqual(8); + expect((grid.verticalScrollContainer.getScroll().children[0] as HTMLElement).offsetHeight - + grid.verticalScrollContainer.getScroll().offsetHeight).toBeGreaterThan(0); + + grid.toggleAllGroupRows(); + await wait(16); // needed because of throttleTime on the resize observer + fixture.detectChanges(); + await wait(16); // needed because of throttleTime on the resize observer + fixture.detectChanges(); + + expect(grid.rowList.length).toEqual(12); + expect((grid.verticalScrollContainer.getScroll().children[0] as HTMLElement).offsetHeight - + grid.verticalScrollContainer.getScroll().offsetHeight).toBeLessThanOrEqual(0); + }); + + it('should create only one ghost element when dragging a column', async () => { + const headers: DebugElement[] = fixture.debugElement.queryAll(By.css(COLUMN_HEADER_CLASS)); + + const header = headers[1].nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 50, 50); + await wait(); + fixture.detectChanges(); + + UIInteractions.simulatePointerEvent('pointermove', header, 56, 56); + await wait(50); + fixture.detectChanges(); + + UIInteractions.simulatePointerEvent('pointermove', header, 230, 30); + await wait(); + fixture.detectChanges(); + + const ghost = fixture.debugElement.queryAll(By.css('.igx-grid__drag-ghost-image')); + expect(ghost.length).toEqual(1); + + UIInteractions.simulatePointerEvent('pointerup', header, 230, 30); + await wait(); + fixture.detectChanges(); + }); + }); + + describe('Resizing', () => { + const DEBOUNCE_TIME = 200; + const GRID_COL_GROUP_THEAD = 'igx-grid-header-group'; + const RESIZE_LINE_CLASS = '.igx-grid-th__resize-line'; + + beforeEach(() => { + fixture = TestBed.createComponent(ColumnLayoutResizingTestComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + }); + + it('should correctly resize column on upper level with 3 spans and the two cols below it with span 1 that have width', async () => { + grid.width = '1500px'; + fixture.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 4, width: '300px', resizable: true }, + { field: 'ContactTitle', rowStart: 1, colStart: 4, colEnd: 6, width: '200px', resizable: true }, + { field: 'Country', rowStart: 1, colStart: 6, colEnd: 7, width: '200px', resizable: true }, + { field: 'Phone', rowStart: 2, colStart: 1, colEnd: 3, width: '200px', resizable: true }, + { field: 'City', rowStart: 2, colStart: 3, colEnd: 5, resizable: true }, + { field: 'Address', rowStart: 2, colStart: 5, colEnd: 7, width: '200px', resizable: true }, + { field: 'CompanyName', rowStart: 3, colStart: 1, colEnd: 2, width: '200px', resizable: true }, + { field: 'PostalCode', rowStart: 3, colStart: 2, colEnd: 3, width: '200px', resizable: true }, + { field: 'Fax', rowStart: 3, colStart: 3, colEnd: 7 }, + ] + }]; + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + fixture.detectChanges(); + + // ContactName + expect(grid.columnList.get(1).width).toEqual('300px'); + expect(grid.columnList.get(1).cells[0].value).toEqual('Maria Anders'); + + const headerCells = fixture.debugElement.queryAll(By.css(GRID_COL_GROUP_THEAD)); + const headerResArea = headerCells[1].children[1].nativeElement; + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 450, 0); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + const resizer = fixture.debugElement.queryAll(By.css(RESIZE_LINE_CLASS))[0].nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 600, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 600, 5); + fixture.detectChanges(); + + const groupRowBlocks = fixture.debugElement.query(By.css('.igx-grid__tbody')).queryAll(By.css(`.${GRID_MRL_BLOCK}`)); + expect(groupRowBlocks[0].nativeElement.style.gridTemplateColumns).toEqual('250px 250px 150px 100px 100px 200px'); + }); + + it('should correctly resize column with span 2 and the ones below it that have span 1 with width set', async () => { + grid.width = '1500px'; + fixture.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 4, width: '300px', resizable: true }, + { field: 'ContactTitle', rowStart: 1, colStart: 4, colEnd: 6, width: '200px', resizable: true }, + { field: 'Country', rowStart: 1, colStart: 6, colEnd: 7, width: '200px', resizable: true }, + { field: 'Phone', rowStart: 2, colStart: 1, colEnd: 3, width: '200px', resizable: true }, + { field: 'City', rowStart: 2, colStart: 3, colEnd: 5, resizable: true }, + { field: 'Address', rowStart: 2, colStart: 5, colEnd: 7, width: '200px', resizable: true }, + { field: 'CompanyName', rowStart: 3, colStart: 1, colEnd: 2, width: '200px', resizable: true }, + { field: 'PostalCode', rowStart: 3, colStart: 2, colEnd: 3, width: '200px', resizable: true }, + { field: 'Fax', rowStart: 3, colStart: 3, colEnd: 7 }, + ] + }]; + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + fixture.detectChanges(); + + // Phone + expect(grid.columnList.get(4).width).toEqual('200px'); + expect(grid.columnList.get(4).cells[0].value).toEqual('030-0074321'); + + const headerCells = fixture.debugElement.queryAll(By.css(GRID_COL_GROUP_THEAD)); + const headerResArea = headerCells[4].children[1].nativeElement; + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 450, 0); + await wait(DEBOUNCE_TIME); + fixture.detectChanges(); + + const resizer = fixture.debugElement.queryAll(By.css(RESIZE_LINE_CLASS))[0].nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 550, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 550, 5); + fixture.detectChanges(); + + const groupRowBlocks = fixture.debugElement.query(By.css('.igx-grid__tbody')).queryAll(By.css(`.${GRID_MRL_BLOCK}`)); + expect(groupRowBlocks[0].nativeElement.style.gridTemplateColumns).toEqual('250px 250px 100px 100px 100px 200px'); + }); + + it('should correctly resize column that spans 1 column that is used to size the column templates', async () => { + grid.width = '1500px'; + fixture.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 4, width: '300px', resizable: true }, + { field: 'ContactTitle', rowStart: 1, colStart: 4, colEnd: 6, width: '200px', resizable: true }, + { field: 'Country', rowStart: 1, colStart: 6, colEnd: 7, width: '200px', resizable: true }, + { field: 'Phone', rowStart: 2, colStart: 1, colEnd: 3, width: '200px', resizable: true }, + { field: 'City', rowStart: 2, colStart: 3, colEnd: 5, resizable: true }, + { field: 'Address', rowStart: 2, colStart: 5, colEnd: 7, width: '200px', resizable: true }, + { field: 'CompanyName', rowStart: 3, colStart: 1, colEnd: 2, width: '200px', resizable: true }, + { field: 'PostalCode', rowStart: 3, colStart: 2, colEnd: 3, width: '200px', resizable: true }, + { field: 'Fax', rowStart: 3, colStart: 3, colEnd: 7 }, + ] + }]; + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + fixture.detectChanges(); + + // PostalCode + expect(grid.columnList.get(8).width).toEqual('200px'); + expect(grid.columnList.get(8).cells[0].value).toEqual('12209'); + + const headerCells = fixture.debugElement.queryAll(By.css(GRID_COL_GROUP_THEAD)); + const headerResArea = headerCells[8].children[1].nativeElement; + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 450, 0); + await wait(DEBOUNCE_TIME); + fixture.detectChanges(); + + const resizer = fixture.debugElement.queryAll(By.css(RESIZE_LINE_CLASS))[0].nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 550, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 550, 5); + fixture.detectChanges(); + + const groupRowBlocks = fixture.debugElement.query(By.css('.igx-grid__tbody')).queryAll(By.css(`.${GRID_MRL_BLOCK}`)); + expect(groupRowBlocks[0].nativeElement.style.gridTemplateColumns).toEqual('200px 300px 100px 100px 100px 200px'); + }); + + it('should correctly resize column with span 1 and bigger columns that start with same colStart with bigger span', async () => { + grid.width = '1500px'; + fixture.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 4, width: '300px', resizable: true }, + { field: 'ContactTitle', rowStart: 1, colStart: 4, colEnd: 6, width: '200px', resizable: true }, + { field: 'Country', rowStart: 1, colStart: 6, colEnd: 7, width: '200px', resizable: true }, + { field: 'Phone', rowStart: 2, colStart: 1, colEnd: 3, width: '200px', resizable: true }, + { field: 'City', rowStart: 2, colStart: 3, colEnd: 5, resizable: true }, + { field: 'Address', rowStart: 2, colStart: 5, colEnd: 7, width: '200px', resizable: true }, + { field: 'CompanyName', rowStart: 3, colStart: 1, colEnd: 2, width: '200px', resizable: true }, + { field: 'PostalCode', rowStart: 3, colStart: 2, colEnd: 3, width: '200px', resizable: true }, + { field: 'Fax', rowStart: 3, colStart: 3, colEnd: 7 }, + ] + }]; + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + fixture.detectChanges(); + + // CompanyName + expect(grid.columnList.get(7).width).toEqual('200px'); + expect(grid.columnList.get(7).cells[0].value).toEqual('Alfreds Futterkiste'); + + const headerCells = fixture.debugElement.queryAll(By.css(GRID_COL_GROUP_THEAD)); + const headerResArea = headerCells[7].children[1].nativeElement; + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 450, 0); + await wait(DEBOUNCE_TIME); + fixture.detectChanges(); + + const resizer = fixture.debugElement.queryAll(By.css(RESIZE_LINE_CLASS))[0].nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 550, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 550, 5); + fixture.detectChanges(); + + const groupRowBlocks = fixture.debugElement.query(By.css('.igx-grid__tbody')).queryAll(By.css(`.${GRID_MRL_BLOCK}`)); + expect(groupRowBlocks[0].nativeElement.style.gridTemplateColumns).toEqual('300px 200px 100px 100px 100px 200px'); + }); + + it('should correctly resize column while there is another column that does not have width set', async () => { + grid.width = 1500 + grid.scrollSize + 'px'; + fixture.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 4, resizable: true }, + { field: 'ContactTitle', rowStart: 1, colStart: 4, colEnd: 6, width: '200px', resizable: true }, + { field: 'Country', rowStart: 1, colStart: 6, colEnd: 7, width: '200px', resizable: true }, + { field: 'Phone', rowStart: 2, colStart: 1, colEnd: 3, width: '200px', resizable: true }, + { field: 'City', rowStart: 2, colStart: 3, colEnd: 5, resizable: true }, + { field: 'Address', rowStart: 2, colStart: 5, colEnd: 7, width: '200px', resizable: true }, + { field: 'CompanyName', rowStart: 3, colStart: 1, colEnd: 2, width: '200px', resizable: true }, + { field: 'PostalCode', rowStart: 3, colStart: 2, colEnd: 3, width: '200px', resizable: true }, + { field: 'Fax', rowStart: 3, colStart: 3, colEnd: 7 }, + ] + }]; + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + fixture.detectChanges(); + + // CompanyName + expect(grid.columnList.get(7).width).toEqual('200px'); + expect(grid.columnList.get(7).cells[0].value).toEqual('Alfreds Futterkiste'); + + const groupRowBlocks = fixture.debugElement.query(By.css('.igx-grid__tbody')).queryAll(By.css(`.${GRID_MRL_BLOCK}`)); + expect(groupRowBlocks[0].nativeElement.style.gridTemplateColumns).toEqual('200px 200px 700px 100px 100px 200px'); + + const headerCells = fixture.debugElement.queryAll(By.css(GRID_COL_GROUP_THEAD)); + const headerResArea = headerCells[7].children[1].nativeElement; + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 450, 0); + await wait(DEBOUNCE_TIME); + fixture.detectChanges(); + + const resizer = fixture.debugElement.queryAll(By.css(RESIZE_LINE_CLASS))[0].nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 550, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 550, 5); + fixture.detectChanges(); + + expect(groupRowBlocks[0].nativeElement.style.gridTemplateColumns).toEqual('300px 200px 600px 100px 100px 200px'); + }); + + it('should correctly resize column that does not have width set, but is intersected by a column with width set', async () => { + grid.width = 1500 + grid.scrollSize + 'px'; + fixture.detectChanges(); + fixture.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 4, resizable: true }, + { field: 'ContactTitle', rowStart: 1, colStart: 4, colEnd: 6, width: '200px', resizable: true }, + { field: 'Country', rowStart: 1, colStart: 6, colEnd: 7, width: '200px', resizable: true }, + { field: 'Phone', rowStart: 2, colStart: 1, colEnd: 3, width: '200px', resizable: true }, + { field: 'City', rowStart: 2, colStart: 3, colEnd: 5, resizable: true }, + { field: 'Address', rowStart: 2, colStart: 5, colEnd: 7, width: '200px', resizable: true }, + { field: 'CompanyName', rowStart: 3, colStart: 1, colEnd: 2, width: '200px', resizable: true }, + { field: 'PostalCode', rowStart: 3, colStart: 2, colEnd: 3, width: '200px', resizable: true }, + { field: 'Fax', rowStart: 3, colStart: 3, colEnd: 7 }, + ] + }]; + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + fixture.detectChanges(); + + // City + expect(grid.columnList.get(5).cells[0].value).toEqual('Berlin'); + + let groupRowBlocks = fixture.debugElement.query(By.css('.igx-grid__tbody')).queryAll(By.css(`.${GRID_MRL_BLOCK}`)); + expect(groupRowBlocks[0].nativeElement.style.gridTemplateColumns).toEqual('200px 200px 700px 100px 100px 200px'); + + const headerCells = fixture.debugElement.queryAll(By.css(GRID_COL_GROUP_THEAD)); + const headerResArea = headerCells[5].children[1].nativeElement; + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 950, 0); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + const resizer = fixture.debugElement.queryAll(By.css(RESIZE_LINE_CLASS))[0].nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 850, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 850, 5); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + fixture.detectChanges(); + + // Small misalignment in the third column occurs when cols are being intersected. + groupRowBlocks = fixture.debugElement.query(By.css('.igx-grid__tbody')).queryAll(By.css(`.${GRID_MRL_BLOCK}`)); + expect(groupRowBlocks[0].nativeElement.style.gridTemplateColumns).toEqual('200px 200px 650px 50px 100px 200px'); + }); + }); + + describe('Selection ', () => { + beforeEach(() => { + fixture = TestBed.createComponent(ColumnLayoutGroupingTestComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + }); + + it('should return correct selected data via getSelectedData API.', () => { + const selectedData1 = [{ + ID: 'ALFKI', + CompanyName: 'Alfreds Futterkiste', + ContactName: 'Maria Anders', + ContactTitle: 'Sales Representative' + }]; + const selectedData2 = [{ + PostalCode: '05021', + City: 'México D.F.', + Country: 'Mexico', + Address: 'Avda. de la Constitución 2222' + }]; + let cellElem = grid.gridAPI.get_cell_by_index(0, 'CompanyName'); + UIInteractions.simulateClickAndSelectEvent(cellElem); + fixture.detectChanges(); + + expect(grid.getSelectedData()).toEqual(selectedData1); + + cellElem = grid.gridAPI.get_cell_by_index(1, 'City'); + UIInteractions.simulateClickAndSelectEvent(cellElem); + fixture.detectChanges(); + + expect(grid.getSelectedData()).toEqual(selectedData2); + }); + }); + +}); + +@Component({ + template: ` + + @if (showToolbar) { + + + + + + } + @for (group of colGroups; track group.group) { + + @for (col of group.columns; track col.field) { + + } + + } + + `, + imports: [IgxGridComponent, IgxColumnLayoutComponent, IgxColumnComponent, IgxGridToolbarComponent, IgxGridToolbarHidingComponent, IgxGridToolbarActionsComponent] +}) +export class ColumnLayoutHidingTestComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + public showToolbar = false; + public cols1: Array = [ + { field: 'ID', rowStart: 1, colStart: 1 }, + { field: 'CompanyName', rowStart: 1, colStart: 2 }, + { field: 'ContactName', rowStart: 1, colStart: 3 }, + { field: 'ContactTitle', rowStart: 2, colStart: 1, rowEnd: 4, colEnd: 4 }, + ]; + public cols2: Array = [ + { field: 'PostalCode', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'City', rowStart: 2, colStart: 1 }, + { field: 'Country', rowStart: 2, colStart: 2 }, + { field: 'Address', rowStart: 3, colStart: 1, colEnd: 3 } + ]; + public colGroups: ColGroupsType[] = [ + { + group: 'group1', + hidden: true, + columns: this.cols2 + }, + { + group: 'group2', + hidden: false, + columns: this.cols1 + } + ]; + public data = SampleTestData.contactInfoDataFull(); +} + +@Component({ + template: ` + + @if (showToolbar) { + + + + + + } + @for (group of colGroups; track group.group) { + + @for (col of group.columns; track col.field) { + + } + + } + + `, + imports: [IgxGridComponent, IgxColumnLayoutComponent, IgxColumnComponent, IgxGridToolbarComponent, IgxGridToolbarPinningComponent, IgxGridToolbarActionsComponent] +}) +export class ColumnLayoutPinningTestComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + public showToolbar = false; + public cols1: Array = [ + { field: 'ID', rowStart: 1, colStart: 1 }, + { field: 'CompanyName', rowStart: 1, colStart: 2 }, + { field: 'ContactName', rowStart: 1, colStart: 3 }, + { field: 'ContactTitle', rowStart: 2, colStart: 1, rowEnd: 4, colEnd: 4 }, + ]; + public cols2: Array = [ + { field: 'PostalCode', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'City', rowStart: 2, colStart: 1 }, + { field: 'Country', rowStart: 2, colStart: 2 }, + { field: 'Address', rowStart: 3, colStart: 1, colEnd: 3 } + ]; + public colGroups: ColGroupsType[] = [ + { + group: 'group1', + pinned: true, + columns: this.cols2 + }, + { + group: 'group2', + pinned: false, + columns: this.cols1 + } + ]; + public data = SampleTestData.contactInfoDataFull(); +} + +@Component({ + template: ` + + @for (group of colGroups; track group.group) { + + @for (col of group.columns; track col.field) { + + } + + } + + `, + imports: [IgxGridComponent, IgxColumnLayoutComponent, IgxColumnComponent] +}) +export class ColumnLayoutFilteringTestComponent extends ColumnLayoutPinningTestComponent { +} + +@Component({ + template: ` + + @for (group of colGroups; track group.group) { + + @for (col of group.columns; track col.field) { + + } + + } + + `, + imports: [IgxGridComponent, IgxColumnLayoutComponent, IgxColumnComponent] +}) +export class ColumnLayoutGroupingTestComponent extends ColumnLayoutPinningTestComponent { + public override showToolbar = false; + public override cols1: Array = [ + { field: 'ID', rowStart: 1, colStart: 1 }, + { field: 'CompanyName', rowStart: 1, colStart: 2, groupable: true }, + { field: 'ContactName', rowStart: 1, colStart: 3, groupable: true }, + { field: 'ContactTitle', rowStart: 2, colStart: 1, rowEnd: 4, colEnd: 4, groupable: true }, + ]; + public override cols2: Array = [ + { field: 'PostalCode', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'City', rowStart: 2, colStart: 1, groupable: true }, + { field: 'Country', rowStart: 2, colStart: 2, groupable: true }, + { field: 'Address', rowStart: 3, colStart: 1, colEnd: 3 } + ]; + + public override colGroups: ColGroupsType[] = [ + { + group: 'group1', + pinned: true, + columns: this.cols2 + }, + { + group: 'group2', + pinned: false, + columns: this.cols1 + } + ]; +} +@Component({ + template: ` + + @for (group of colGroups; track group.group) { + + @for (col of group.columns; track col.field) { + + + } + + } + + `, + imports: [IgxGridComponent, IgxColumnLayoutComponent, IgxColumnComponent] +}) +export class ColumnLayoutResizingTestComponent { + + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + public showToolbar = false; + + public cols: Array = [ + { field: 'ID', rowStart: 1, colStart: 1, resizable: true }, + { field: 'CompanyName', rowStart: 1, colStart: 2, resizable: true }, + { field: 'ContactName', rowStart: 1, colStart: 3, resizable: true }, + { field: 'ContactTitle', rowStart: 2, colStart: 1, rowEnd: 4, colEnd: 4, resizable: true }, + ]; + public colGroups: ColGroupsType[] = [ + { + group: 'group1', + columns: this.cols + } + ]; + public data = SampleTestData.contactInfoDataFull(); +} diff --git a/projects/igniteui-angular/grids/grid/src/grid.multi-row-layout.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.multi-row-layout.spec.ts new file mode 100644 index 00000000000..c3f0fe67f67 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid.multi-row-layout.spec.ts @@ -0,0 +1,1206 @@ +import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { IgxGridComponent } from './grid.component'; +import { Component, ViewChild } from '@angular/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxColumnLayoutComponent, IgxGridMRLNavigationService } from 'igniteui-angular/grids/core'; +import { By } from '@angular/platform-browser'; +import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; +import { wait } from '../../../test-utils/ui-interactions.spec'; +import { ICellPosition } from 'igniteui-angular/grids/core'; +import { GridFunctions, GRID_MRL_BLOCK } from '../../../test-utils/grid-functions.spec'; +import { IgxColumnGroupComponent } from 'igniteui-angular/grids/core'; +import { IgxColumnComponent } from 'igniteui-angular/grids/core'; +import { DefaultSortingStrategy, SortingDirection } from 'igniteui-angular/core'; + +const GRID_COL_THEAD_CLASS = '.igx-grid-th'; +const GRID_MRL_BLOCK_CLASS = `.${GRID_MRL_BLOCK}`; + +describe('IgxGrid - multi-row-layout #grid', () => { + const DEBOUNCE_TIME = 60; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + ColumnLayoutTestComponent, + ColumnLayoutAndGroupsTestComponent + ], + providers: [ + IgxGridMRLNavigationService + ] + }).compileComponents(); + })); + + it('should initialize a grid with 1 column group', fakeAsync(() => { + const fixture = TestBed.createComponent(ColumnLayoutTestComponent); + fixture.detectChanges(); + const grid = fixture.componentInstance.grid; + const gridFirstRow = grid.rowList.first; + + // headers are aligned to cells + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + + GridFunctions.verifyDOMMatchesLayoutSettings(grid, gridFirstRow, fixture.componentInstance.colGroups); + + + const firstRowCellsArr = gridFirstRow.cells.toArray(); + // the last cell is spanned as much as the first 3 cells + const firstThreeCellsWidth = firstRowCellsArr[0].nativeElement.getBoundingClientRect().width + + firstRowCellsArr[1].nativeElement.getBoundingClientRect().width + + firstRowCellsArr[2].nativeElement.getBoundingClientRect().width; + const lastCellWidth = firstRowCellsArr[3].nativeElement.getBoundingClientRect().width; + expect(2 * firstRowCellsArr[0].nativeElement.offsetHeight).toEqual(firstRowCellsArr[3].nativeElement.offsetHeight); + expect(firstThreeCellsWidth).toEqual(lastCellWidth); + })); + + it('should initialize grid with 2 column groups', fakeAsync(() => { + const fixture = TestBed.createComponent(ColumnLayoutTestComponent); + fixture.detectChanges(); + fixture.componentInstance.colGroups.push({ + group: 'group2', + columns: [ + { field: 'ContactName', rowStart: 2, colStart: 1, colEnd: 4, rowEnd: 4 }, + { field: 'CompanyName', rowStart: 1, colStart: 1 }, + { field: 'PostalCode', rowStart: 1, colStart: 2 }, + { field: 'Fax', rowStart: 1, colStart: 3 } + ] + }); + fixture.detectChanges(); + const grid = fixture.componentInstance.grid; + const gridFirstRow = grid.rowList.first; + // headers are aligned to cells + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + + GridFunctions.verifyDOMMatchesLayoutSettings(grid, gridFirstRow, fixture.componentInstance.colGroups); + })); + + it('should not throw error when layout is incomplete and should render valid mrl block styles', fakeAsync(() => { + const fixture = TestBed.createComponent(ColumnLayoutTestComponent); + fixture.detectChanges(); + // creating an incomplete layout + fixture.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ContactName', rowStart: 2, colStart: 1, colEnd: 4, rowEnd: 3 }, + { field: 'CompanyName', rowStart: 1, colStart: 1 }, + { field: 'PostalCode', rowStart: 1, colStart: 2 }, + // { field: 'Fax', rowStart: 1, colStart: 3}, + { field: 'Country', rowStart: 3, colStart: 1 }, + // { field: 'Region', rowStart: 3, colStart: 2}, + { field: 'Phone', rowStart: 3, colStart: 3 } + ] + }]; + fixture.componentInstance.grid.width = '617px'; + fixture.detectChanges(); + const grid = fixture.componentInstance.grid; + const gridFirstRow = grid.rowList.first; + + // headers are aligned to cells + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + + // verify block style + let sizes = grid.columnList.first.getGridTemplate(false).split(' ').map(width => parseFloat(width).toFixed(2) + "px").join(' '); + + + expect(sizes).toBe('200.33px 200.33px 200.33px'); + expect(grid.columnList.first.getGridTemplate(true)).toBe('repeat(3,1fr)'); + + // creating an incomplete layout 2 + fixture.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 4, rowEnd: 3 }, + { field: 'CompanyName', rowStart: 3, colStart: 1 }, + // { field: 'PostalCode', rowStart: 1, colStart: 2}, + { field: 'Fax', rowStart: 3, colStart: 3 } + ] + }]; + fixture.componentInstance.grid.width = '617px'; + fixture.detectChanges(); + sizes = grid.columnList.first.getGridTemplate(false).split(' ').map(width => parseFloat(width).toFixed(2) + "px").join(' '); + expect(sizes).toBe('200.33px 200.33px 200.33px'); + expect(grid.columnList.first.getGridTemplate(true)).toBe('repeat(3,1fr)'); + + })); + it('should initialize correctly when no column widths are set.', fakeAsync(() => { + // test with single group + const fixture = TestBed.createComponent(ColumnLayoutTestComponent); + fixture.detectChanges(); + fixture.componentInstance.grid.width = '617px'; + fixture.detectChanges(); + const grid = fixture.componentInstance.grid; + // col span is 3 => columns should have grid width - scrollbarWidth/3 width + // check columns + expect(grid.gridAPI.get_cell_by_index(0, 'ID').nativeElement.offsetWidth).toBe(200); + expect(grid.gridAPI.get_cell_by_index(0, 'CompanyName').nativeElement.offsetWidth).toBe(200); + expect(grid.gridAPI.get_cell_by_index(0, 'ContactName').nativeElement.offsetWidth).toBe(200); + expect(+grid.gridAPI.get_cell_by_index(0, 'ContactTitle').nativeElement.getBoundingClientRect().width.toFixed(3)) + .toBe(+(grid.gridAPI.get_cell_by_index(0, 'ID').nativeElement.getBoundingClientRect().width * 3).toFixed(3)); + + // check group blocks + let groupHeaderBlocks = fixture.debugElement.query(By.css('.igx-grid-thead')).queryAll(By.css(GRID_MRL_BLOCK_CLASS)); + expect(+groupHeaderBlocks[0].nativeElement.getBoundingClientRect().width.toFixed(3)) + .toBe(+(grid.gridAPI.get_cell_by_index(0, 'ID').nativeElement.getBoundingClientRect().width * 3).toFixed(3)); + expect(groupHeaderBlocks[0].nativeElement.clientHeight).toBe(51 * 3); + + let gridFirstRow = grid.rowList.first; + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + + GridFunctions.verifyDOMMatchesLayoutSettings(grid, gridFirstRow, fixture.componentInstance.colGroups); + + // test with 2 groups + fixture.componentInstance.colGroups.push({ + group: 'group2', + columns: [ + { field: 'Country', rowStart: 1, colStart: 1, colEnd: 4, rowEnd: 3 }, + { field: 'Region', rowStart: 3, colStart: 1 }, + { field: 'PostalCode', rowStart: 3, colStart: 2 }, + { field: 'Fax', rowStart: 3, colStart: 3 } + ] + }); + fixture.componentInstance.grid.width = '917px'; + fixture.detectChanges(); + + // col span is 6 => columns should have grid width - scrollbarWidth/6 width + expect(grid.gridAPI.get_cell_by_index(0, 'ID').nativeElement.offsetWidth).toBe(150); + expect(grid.gridAPI.get_cell_by_index(0, 'CompanyName').nativeElement.offsetWidth).toBe(150); + expect(grid.gridAPI.get_cell_by_index(0, 'ContactName').nativeElement.offsetWidth).toBe(150); + expect(grid.gridAPI.get_cell_by_index(0, 'ContactTitle').nativeElement.offsetWidth).toBe(150 * 3); + + expect(grid.gridAPI.get_cell_by_index(0, 'Fax').nativeElement.offsetWidth).toBe(150); + expect(grid.gridAPI.get_cell_by_index(0, 'Region').nativeElement.offsetWidth).toBe(150); + expect(grid.gridAPI.get_cell_by_index(0, 'PostalCode').nativeElement.offsetWidth).toBe(150); + expect(grid.gridAPI.get_cell_by_index(0, 'Country').nativeElement.offsetWidth).toBe(150 * 3); + + // check group blocks + groupHeaderBlocks = fixture.debugElement.query(By.css('.igx-grid-thead')).queryAll(By.css(GRID_MRL_BLOCK_CLASS)); + expect(groupHeaderBlocks[0].nativeElement.clientWidth).toBe(150 * 3); + expect(groupHeaderBlocks[0].nativeElement.clientHeight).toBe(51 * 3); + expect(groupHeaderBlocks[1].nativeElement.clientWidth).toBe(150 * 3); + expect(groupHeaderBlocks[1].nativeElement.clientHeight).toBe(51 * 3); + + gridFirstRow = grid.rowList.first; + + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + GridFunctions.verifyDOMMatchesLayoutSettings(grid, gridFirstRow, fixture.componentInstance.colGroups); + + // test with 3 groups + fixture.componentInstance.colGroups.push({ + group: 'group3', + columns: [ + { field: 'Phone', rowStart: 1, colStart: 1, colEnd: 3, rowEnd: 4 } + ] + }); + fixture.detectChanges(); + + // col span is 8 => min-width exceeded should use 136px + expect(grid.gridAPI.get_cell_by_index(0, 'ID').nativeElement.offsetWidth).toBe(136); + expect(grid.gridAPI.get_cell_by_index(0, 'CompanyName').nativeElement.offsetWidth).toBe(136); + expect(grid.gridAPI.get_cell_by_index(0, 'ContactName').nativeElement.offsetWidth).toBe(136); + expect(grid.gridAPI.get_cell_by_index(0, 'ContactTitle').nativeElement.offsetWidth).toBe(136 * 3); + + expect(grid.gridAPI.get_cell_by_index(0, 'Fax').nativeElement.offsetWidth).toBe(136); + expect(grid.gridAPI.get_cell_by_index(0, 'Region').nativeElement.offsetWidth).toBe(136); + expect(grid.gridAPI.get_cell_by_index(0, 'PostalCode').nativeElement.offsetWidth).toBe(136); + expect(grid.gridAPI.get_cell_by_index(0, 'Country').nativeElement.offsetWidth).toBe(136 * 3); + + expect(grid.gridAPI.get_cell_by_index(0, 'Phone').nativeElement.offsetWidth).toBe(136 * 2); + + // check group blocks + groupHeaderBlocks = fixture.debugElement.query(By.css('.igx-grid-thead')).queryAll(By.css(GRID_MRL_BLOCK_CLASS)); + expect(groupHeaderBlocks[0].nativeElement.clientWidth).toBe(136 * 3); + expect(groupHeaderBlocks[0].nativeElement.clientHeight).toBe(51 * 3); + expect(groupHeaderBlocks[1].nativeElement.clientWidth).toBe(136 * 3); + expect(groupHeaderBlocks[1].nativeElement.clientHeight).toBe(51 * 3); + expect(groupHeaderBlocks[2].nativeElement.clientWidth).toBe(136 * 2); + // the following throws error because last colgroup row span in header does not fill content + // expect(groupHeaderBlocks[2].nativeElement.clientHeight).toBe(50 * 3); + + gridFirstRow = grid.rowList.first; + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + GridFunctions.verifyDOMMatchesLayoutSettings(grid, gridFirstRow, fixture.componentInstance.colGroups); + })); + + it('should initialize correctly when widths are set in px.', fakeAsync(() => { + // test with single group - all cols with colspan 1 have width + const fixture = TestBed.createComponent(ColumnLayoutTestComponent); + fixture.detectChanges(); + fixture.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ID', rowStart: 1, colStart: 1, width: '100px' }, + { field: 'CompanyName', rowStart: 1, colStart: 2, width: '200px' }, + { field: 'ContactName', rowStart: 1, colStart: 3, width: '300px' }, + { field: 'ContactTitle', rowStart: 2, colStart: 1, rowEnd: 4, colEnd: 4 }, + ] + }]; + fixture.detectChanges(); + fixture.detectChanges(); + const grid = fixture.componentInstance.grid; + // check columns + expect(grid.gridAPI.get_cell_by_index(0, 'ID').nativeElement.offsetWidth).toBe(100); + expect(grid.gridAPI.get_cell_by_index(0, 'CompanyName').nativeElement.offsetWidth).toBe(200); + expect(grid.gridAPI.get_cell_by_index(0, 'ContactName').nativeElement.offsetWidth).toBe(300); + expect(grid.gridAPI.get_cell_by_index(0, 'ContactTitle').nativeElement.offsetWidth).toBe(600); + + // check group blocks + let groupHeaderBlocks = fixture.debugElement.query(By.css('.igx-grid-thead')).queryAll(By.css(GRID_MRL_BLOCK_CLASS)); + expect(groupHeaderBlocks[0].nativeElement.clientWidth).toBe(600); + + let gridFirstRow = grid.rowList.first; + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + GridFunctions.verifyDOMMatchesLayoutSettings(grid, gridFirstRow, fixture.componentInstance.colGroups); + + + // test with 2 groups - only 2 columns with colspan1 have width + fixture.componentInstance.colGroups.push({ + group: 'group2', + columns: [ + { field: 'Country', rowStart: 1, colStart: 1, colEnd: 4, rowEnd: 3 }, + { field: 'Region', rowStart: 3, colStart: 1, width: '100px' }, + { field: 'PostalCode', rowStart: 3, colStart: 2 }, + { field: 'Fax', rowStart: 3, colStart: 3, width: '200px' } + ] + }); + fixture.componentInstance.grid.width = '1117px'; + fixture.detectChanges(); + fixture.detectChanges(); + + // first group takes 600px, 500px left for second group + // check columns + expect(grid.gridAPI.get_cell_by_index(0, 'ID').nativeElement.offsetWidth).toBe(100); + expect(grid.gridAPI.get_cell_by_index(0, 'CompanyName').nativeElement.offsetWidth).toBe(200); + expect(grid.gridAPI.get_cell_by_index(0, 'ContactName').nativeElement.offsetWidth).toBe(300); + expect(grid.gridAPI.get_cell_by_index(0, 'ContactTitle').nativeElement.offsetWidth).toBe(600); + + // This fails for unknown reasons only in travis with 2px difference + // expect(grid.gridAPI.get_cell_by_index(0, 'Country').nativeElement.offsetWidth).toBe(500); + // expect(grid.gridAPI.get_cell_by_index(0, 'Region').nativeElement.offsetWidth).toBe(100); + // // postal code has no width - auto width should be assigned based on available space. + // expect(grid.gridAPI.get_cell_by_index(0, 'PostalCode').nativeElement.offsetWidth).toBe(200); + // expect(grid.gridAPI.get_cell_by_index(0, 'Fax').nativeElement.offsetWidth).toBe(200); + + // // check group blocks + // groupHeaderBlocks = fixture.debugElement.query(By.css('.igx-grid-thead')).queryAll(By.css(GRID_MRL_BLOCK_CLASS)); + // expect(groupHeaderBlocks[1].nativeElement.clientWidth).toBe(500); + + gridFirstRow = grid.rowList.first; + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + GridFunctions.verifyDOMMatchesLayoutSettings(grid, gridFirstRow, fixture.componentInstance.colGroups); + + // test with 3 groups - only parent has width + fixture.componentInstance.colGroups.push({ + group: 'group3', + columns: [ + { field: 'Phone', rowStart: 1, colStart: 1, colEnd: 3, width: '500px' }, + { field: 'Phone1', rowStart: 2, colStart: 1, colEnd: 2, rowSpan: 'span 2' }, + { field: 'Phone2', rowStart: 2, colStart: 2, colEnd: 3, rowSpan: 'span 2' } + ] + }); + fixture.componentInstance.grid.width = '1617px'; + fixture.detectChanges(); + fixture.detectChanges(); + + // check columns + expect(grid.gridAPI.get_cell_by_index(0, 'Phone').nativeElement.offsetWidth).toBe(500); + expect(grid.gridAPI.get_cell_by_index(0, 'Phone1').nativeElement.offsetWidth).toBe(250); + expect(grid.gridAPI.get_cell_by_index(0, 'Phone2').nativeElement.offsetWidth).toBe(250); + + groupHeaderBlocks = fixture.debugElement.query(By.css('.igx-grid-thead')).queryAll(By.css(GRID_MRL_BLOCK_CLASS)); + expect(groupHeaderBlocks[2].nativeElement.clientWidth).toBe(500); + + gridFirstRow = grid.rowList.first; + // headerCells = grid.theadRow._groups.last.children; + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + })); + + it('should correctly autofit column without width when there are other set with width in pixels', fakeAsync(() => { + // In this case it would be for City column and 3rd template column. + const fixture = TestBed.createComponent(ColumnLayoutTestComponent); + fixture.detectChanges(); + // creating an incomplete layout + fixture.componentInstance.grid.width = '1200px'; + fixture.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 4 }, + { field: 'ContactTitle', rowStart: 1, colStart: 4, colEnd: 6, width: '200px' }, + { field: 'Country', rowStart: 1, colStart: 6, colEnd: 7, width: '200px' }, + { field: 'Phone', rowStart: 2, colStart: 1, colEnd: 3, width: '200px' }, + { field: 'City', rowStart: 2, colStart: 3, colEnd: 5 }, + { field: 'Address', rowStart: 2, colStart: 5, colEnd: 7, width: '200px' }, + { field: 'CompanyName', rowStart: 3, colStart: 1, colEnd: 2, width: '200px' }, + { field: 'PostalCode', rowStart: 3, colStart: 2, colEnd: 3, width: '200px' }, + { field: 'Fax', rowStart: 3, colStart: 3, colEnd: 7 }, + ] + }]; + fixture.detectChanges(); + const grid = fixture.componentInstance.grid; + const gridFirstRow = grid.rowList.first; + + // headers are aligned to cells + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + + const autoSizedColumnWidth = 400 - grid.scrollSize; + const groupRowBlocks = fixture.debugElement.query(By.css('.igx-grid__tbody')).queryAll(By.css(GRID_MRL_BLOCK_CLASS)); + expect(groupRowBlocks[0].nativeElement.style.gridTemplateColumns) + .toEqual('200px 200px ' + autoSizedColumnWidth + 'px 100px 100px 200px'); + })); + + it('should correctly size column without width when it overlaps partially with bigger column that has width above it', fakeAsync(() => { + // In this case it would be for City column and 3rd template column overlapping width ContactName. + const fixture = TestBed.createComponent(ColumnLayoutTestComponent); + // creating an incomplete layout + fixture.componentInstance.grid.width = '1200px'; + fixture.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 4, width: '300px' }, + { field: 'ContactTitle', rowStart: 1, colStart: 4, colEnd: 6, width: '200px' }, + { field: 'Country', rowStart: 1, colStart: 6, colEnd: 7, width: '200px' }, + { field: 'Phone', rowStart: 2, colStart: 1, colEnd: 3, width: '200px' }, + { field: 'City', rowStart: 2, colStart: 3, colEnd: 5 }, + { field: 'Address', rowStart: 2, colStart: 5, colEnd: 7, width: '200px' }, + { field: 'CompanyName', rowStart: 3, colStart: 1, colEnd: 2, width: '200px' }, + { field: 'PostalCode', rowStart: 3, colStart: 2, colEnd: 3, width: '200px' }, + { field: 'Fax', rowStart: 3, colStart: 3, colEnd: 7 }, + ] + }]; + fixture.detectChanges(); + const grid = fixture.componentInstance.grid; + const gridFirstRow = grid.rowList.first; + + // headers are aligned to cells + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + + const groupRowBlocks = fixture.debugElement.query(By.css('.igx-grid__tbody')).queryAll(By.css(GRID_MRL_BLOCK_CLASS)); + expect(groupRowBlocks[0].nativeElement.style.gridTemplateColumns).toEqual('200px 200px 100px 100px 100px 200px'); + })); + + it('should correctly size column without width when it overlaps partially with bigger column that has width bellow it', + fakeAsync(() => { + // In this case it would be for City column and 3rd template column overlapping width ContactName. + const fixture = TestBed.createComponent(ColumnLayoutTestComponent); + // creating an incomplete layout + fixture.componentInstance.grid.width = '1200px'; + fixture.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 5, width: '200px' }, + { field: 'ContactTitle', rowStart: 1, colStart: 5, colEnd: 6, width: '200px' }, + { field: 'Country', rowStart: 1, colStart: 6, colEnd: 7 }, + { field: 'Phone', rowStart: 2, colStart: 1, colEnd: 3, width: '200px' }, + { field: 'City', rowStart: 2, colStart: 3, colEnd: 5, width: '200px' }, + { field: 'Address', rowStart: 2, colStart: 5, colEnd: 7, width: '300px' }, + { field: 'CompanyName', rowStart: 3, colStart: 1, colEnd: 2, width: '200px' }, + { field: 'PostalCode', rowStart: 3, colStart: 2, colEnd: 3, width: '200px' }, + { field: 'Fax', rowStart: 3, colStart: 3, colEnd: 7, width: '200px' }, + ] + }]; + fixture.detectChanges(); + const grid = fixture.componentInstance.grid; + const gridFirstRow = grid.rowList.first; + + // headers are aligned to cells + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + + const groupRowBlocks = fixture.debugElement.query(By.css('.igx-grid__tbody')).queryAll(By.css(GRID_MRL_BLOCK_CLASS)); + expect(groupRowBlocks[0].nativeElement.style.gridTemplateColumns).toEqual('200px 200px 100px 100px 200px 150px'); + })); + + it('should correctly set column width when there is bigger column at the bottom where there is not width yet', fakeAsync(() => { + // In this case it would be for City column and 3rd template column. + const fixture = TestBed.createComponent(ColumnLayoutTestComponent); + // creating an incomplete layout + fixture.componentInstance.grid.width = '1200px'; + fixture.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 4 }, + { field: 'ContactTitle', rowStart: 1, colStart: 4, colEnd: 6, width: '200px' }, + { field: 'Country', rowStart: 1, colStart: 6, colEnd: 7, width: '200px' }, + { field: 'Phone', rowStart: 2, colStart: 1, colEnd: 3, width: '200px' }, + { field: 'City', rowStart: 2, colStart: 3, colEnd: 5 }, + { field: 'Address', rowStart: 2, colStart: 5, colEnd: 7, width: '200px' }, + { field: 'CompanyName', rowStart: 3, colStart: 1, colEnd: 2, width: '200px' }, + { field: 'PostalCode', rowStart: 3, colStart: 2, colEnd: 3, width: '200px' }, + { field: 'Fax', rowStart: 3, colStart: 3, colEnd: 7, width: '400px' }, + ] + }]; + fixture.detectChanges(); + const grid = fixture.componentInstance.grid; + const gridFirstRow = grid.rowList.first; + + // headers are aligned to cells + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + + const groupRowBlocks = fixture.debugElement.query(By.css('.igx-grid__tbody')).queryAll(By.css(GRID_MRL_BLOCK_CLASS)); + expect(groupRowBlocks[0].nativeElement.style.gridTemplateColumns).toEqual('200px 200px 100px 100px 100px 200px'); + })); + + it('should correctly set column width of column without width when there are two bigger columns that overlap with it', fakeAsync(() => { + // In this case it would be for City column and 3rd template column overlapping with ContactName and Fax. + const fixture = TestBed.createComponent(ColumnLayoutTestComponent); + // creating an incomplete layout + fixture.componentInstance.grid.width = '1200px'; + fixture.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 4, width: '360px' }, + { field: 'ContactTitle', rowStart: 1, colStart: 4, colEnd: 6, width: '200px' }, + { field: 'Country', rowStart: 1, colStart: 6, colEnd: 7, width: '200px' }, + { field: 'Phone', rowStart: 2, colStart: 1, colEnd: 3, width: '200px' }, + { field: 'City', rowStart: 2, colStart: 3, colEnd: 5 }, + { field: 'Address', rowStart: 2, colStart: 5, colEnd: 7, width: '200px' }, + { field: 'CompanyName', rowStart: 3, colStart: 1, colEnd: 2, width: '200px' }, + { field: 'PostalCode', rowStart: 3, colStart: 2, colEnd: 3, width: '200px' }, + { field: 'Fax', rowStart: 3, colStart: 3, colEnd: 7, width: '400px' }, + ] + }]; + fixture.detectChanges(); + const grid = fixture.componentInstance.grid; + const gridFirstRow = grid.rowList.first; + + // headers are aligned to cells + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + + const groupRowBlocks = fixture.debugElement.query(By.css('.igx-grid__tbody')).queryAll(By.css(GRID_MRL_BLOCK_CLASS)); + expect(groupRowBlocks[0].nativeElement.style.gridTemplateColumns).toEqual('200px 200px 120px 100px 100px 200px'); + })); + + it('should correctly autofit column without width when grid width is not enough and other cols are set in pixels', fakeAsync(() => { + // In this case it would be for City column and 3rd template column. + const fixture = TestBed.createComponent(ColumnLayoutTestComponent); + // creating an incomplete layout + fixture.componentInstance.grid.width = '700px'; + fixture.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 4 }, + { field: 'ContactTitle', rowStart: 1, colStart: 4, colEnd: 6, width: '200px' }, + { field: 'Country', rowStart: 1, colStart: 6, colEnd: 7, width: '200px' }, + { field: 'Phone', rowStart: 2, colStart: 1, colEnd: 3, width: '200px' }, + { field: 'City', rowStart: 2, colStart: 3, colEnd: 5 }, + { field: 'Address', rowStart: 2, colStart: 5, colEnd: 7, width: '200px' }, + { field: 'CompanyName', rowStart: 3, colStart: 1, colEnd: 2, width: '200px' }, + { field: 'PostalCode', rowStart: 3, colStart: 2, colEnd: 3, width: '200px' }, + { field: 'Fax', rowStart: 3, colStart: 3, colEnd: 7 }, + ] + }]; + fixture.detectChanges(); + const grid = fixture.componentInstance.grid; + const gridFirstRow = grid.rowList.first; + + // headers are aligned to cells + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + + const groupRowBlocks = fixture.debugElement.query(By.css('.igx-grid__tbody')).queryAll(By.css(GRID_MRL_BLOCK_CLASS)); + expect(groupRowBlocks[0].nativeElement.style.gridTemplateColumns).toEqual('200px 200px 136px 100px 100px 200px'); + })); + + it('should autofit a column with span 1 that does not have width set and is under a col with span 2 with width set', fakeAsync(() => { + // In this case it would be for Phone, CompanyName and PostalCode columns and first 2 template columns. + const fixture = TestBed.createComponent(ColumnLayoutTestComponent); + // creating an incomplete layout + fixture.componentInstance.grid.width = '700px'; + fixture.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 4 }, + { field: 'ContactTitle', rowStart: 1, colStart: 4, colEnd: 6, width: '200px' }, + { field: 'Country', rowStart: 1, colStart: 6, colEnd: 7, width: '200px' }, + { field: 'Phone', rowStart: 2, colStart: 1, colEnd: 3 }, + { field: 'City', rowStart: 2, colStart: 3, colEnd: 5 }, + { field: 'Address', rowStart: 2, colStart: 5, colEnd: 7, width: '200px' }, + { field: 'CompanyName', rowStart: 3, colStart: 1, colEnd: 2, width: '200px' }, + { field: 'PostalCode', rowStart: 3, colStart: 2, colEnd: 3 }, + { field: 'Fax', rowStart: 3, colStart: 3, colEnd: 7 }, + ] + }]; + fixture.detectChanges(); + const grid = fixture.componentInstance.grid; + const gridFirstRow = grid.rowList.first; + + // headers are aligned to cells + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + + const groupRowBlocks = fixture.debugElement.query(By.css('.igx-grid__tbody')).queryAll(By.css(GRID_MRL_BLOCK_CLASS)); + expect(groupRowBlocks[0].nativeElement.style.gridTemplateColumns).toEqual('200px 136px 136px 100px 100px 200px'); + })); + + it('should use column width of a column with span 2 that has width when there are no columns with span 1 to take width from', + fakeAsync(() => { + // In this case it would be for Phone, CompanyName and PostalCode columns and first 2 template columns. + const fixture = TestBed.createComponent(ColumnLayoutTestComponent); + // creating an incomplete layout + fixture.componentInstance.grid.width = '700px'; + fixture.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 4 }, + { field: 'ContactTitle', rowStart: 1, colStart: 4, colEnd: 6, width: '200px' }, + { field: 'Country', rowStart: 1, colStart: 6, colEnd: 7, width: '200px' }, + { field: 'Phone', rowStart: 2, colStart: 1, colEnd: 3, width: '200px' }, + { field: 'City', rowStart: 2, colStart: 3, colEnd: 5 }, + { field: 'Address', rowStart: 2, colStart: 5, colEnd: 7, width: '200px' }, + { field: 'CompanyName', rowStart: 3, colStart: 1, colEnd: 2 }, + { field: 'PostalCode', rowStart: 3, colStart: 2, colEnd: 3 }, + { field: 'Fax', rowStart: 3, colStart: 3, colEnd: 7 }, + ] + }]; + fixture.detectChanges(); + const grid = fixture.componentInstance.grid; + const gridFirstRow = grid.rowList.first; + + // headers are aligned to cells + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + + const groupRowBlocks = fixture.debugElement.query(By.css('.igx-grid__tbody')).queryAll(By.css(GRID_MRL_BLOCK_CLASS)); + expect(groupRowBlocks[0].nativeElement.style.gridTemplateColumns).toEqual('100px 100px 136px 100px 100px 200px'); + })); + + it('should use divided column width when there is stairway type of defined columns and they have widths set', fakeAsync(() => { + // In this case it would be for Country and Address columns and last 3 template columns. + const fixture = TestBed.createComponent(ColumnLayoutTestComponent); + // creating an incomplete layout + fixture.componentInstance.grid.width = '700px'; + fixture.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 4 }, + { field: 'ContactTitle', rowStart: 1, colStart: 4, colEnd: 6, width: '200px' }, + { field: 'Country', rowStart: 1, colStart: 6, colEnd: 8, width: '200px' }, + { field: 'Phone', rowStart: 2, colStart: 1, colEnd: 3, width: '200px' }, + { field: 'City', rowStart: 2, colStart: 3, colEnd: 5 }, + { field: 'Address', rowStart: 2, colStart: 5, colEnd: 8, width: '150px' }, + { field: 'CompanyName', rowStart: 3, colStart: 1, colEnd: 2 }, + { field: 'PostalCode', rowStart: 3, colStart: 2, colEnd: 3 }, + { field: 'Fax', rowStart: 3, colStart: 3, colEnd: 8 }, + ] + }]; + fixture.detectChanges(); + const grid = fixture.componentInstance.grid; + const gridFirstRow = grid.rowList.first; + + // headers are aligned to cells + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + + const groupRowBlocks = fixture.debugElement.query(By.css('.igx-grid__tbody')).queryAll(By.css(GRID_MRL_BLOCK_CLASS)); + expect(groupRowBlocks[0].nativeElement.style.gridTemplateColumns).toEqual('100px 100px 136px 100px 100px 100px 100px'); + })); + + it('should initialize correctly when widths are set in %.', fakeAsync(() => { + const fixture = TestBed.createComponent(ColumnLayoutTestComponent); + fixture.detectChanges(); + + const grid = fixture.componentInstance.grid; + fixture.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ID', rowStart: 1, colStart: 1, width: '10%' }, + { field: 'CompanyName', rowStart: 1, colStart: 2, width: '20%' }, + { field: 'ContactName', rowStart: 1, colStart: 3, width: '30%' }, + { field: 'ContactTitle', rowStart: 2, colStart: 1, rowEnd: 4, colEnd: 4 }, + ] + }]; + fixture.detectChanges(); + fixture.componentInstance.grid.width = (1000 + grid.scrollSize) + 'px'; + fixture.detectChanges(); + + // check columns + expect(grid.gridAPI.get_cell_by_index(0, 'ID').nativeElement.offsetWidth).toBe(100); + expect(grid.gridAPI.get_cell_by_index(0, 'CompanyName').nativeElement.offsetWidth).toBe(200); + expect(grid.gridAPI.get_cell_by_index(0, 'ContactName').nativeElement.offsetWidth).toBe(300); + expect(grid.gridAPI.get_cell_by_index(0, 'ContactTitle').nativeElement.offsetWidth).toBe(600); + + // check group blocks + // let groupHeaderBlocks = fixture.debugElement.query(By.css('.igx-grid-thead')).queryAll(By.css(GRID_MRL_BLOCK_CLASS)); + let groupHeaderBlocks = grid.theadRow.nativeElement.querySelectorAll(GRID_MRL_BLOCK_CLASS); + expect(groupHeaderBlocks[0].clientWidth).toBe(600); + + let gridFirstRow = grid.rowList.first; + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + GridFunctions.verifyDOMMatchesLayoutSettings(grid, gridFirstRow, fixture.componentInstance.colGroups); + + fixture.componentInstance.colGroups.push({ + group: 'group2', + columns: [ + { field: 'Country', rowStart: 1, colStart: 1, colEnd: 4, rowEnd: 3 }, + { field: 'Region', rowStart: 3, colStart: 1, width: '10%' }, + { field: 'PostalCode', rowStart: 3, colStart: 2 }, + { field: 'Fax', rowStart: 3, colStart: 3, width: '20%' } + ] + }); + fixture.detectChanges(); + fixture.detectChanges(); + + // check columns + expect(grid.gridAPI.get_cell_by_index(0, 'Country').nativeElement.offsetWidth).toBe(100 + 200 + 136); + expect(grid.gridAPI.get_cell_by_index(0, 'Region').nativeElement.offsetWidth).toBe(100); + expect(grid.gridAPI.get_cell_by_index(0, 'PostalCode').nativeElement.offsetWidth).toBe(136); + expect(grid.gridAPI.get_cell_by_index(0, 'Fax').nativeElement.offsetWidth).toBe(200); + + // check group blocks + // groupHeaderBlocks = fixture.debugElement.query(By.css('.igx-grid-thead')).queryAll(By.css(GRID_MRL_BLOCK_CLASS)); + groupHeaderBlocks = grid.theadRow.nativeElement.querySelectorAll(GRID_MRL_BLOCK_CLASS); + expect(groupHeaderBlocks[1].clientWidth).toBe(436); + + gridFirstRow = grid.rowList.first; + // headerCells = grid.theadRow._groups.last.children; + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + GridFunctions.verifyDOMMatchesLayoutSettings(grid, gridFirstRow, fixture.componentInstance.colGroups); + + fixture.componentInstance.colGroups = [{ + group: 'group3', + columns: [ + { field: 'ID', rowStart: 1, colStart: 1 }, + { field: 'CompanyName', rowStart: 1, colStart: 2 }, + { field: 'ContactName', rowStart: 1, colStart: 3 }, + { field: 'Country', rowStart: 2, colStart: 1, colEnd: 3 }, + { field: 'Region', rowStart: 2, colStart: 3 }, + { field: 'ContactTitle', rowStart: 3, colStart: 1, rowEnd: 5, colEnd: 4, width: '60%' }, + ] + }]; + fixture.detectChanges(); + fixture.detectChanges(); + + // check columns + expect(grid.gridAPI.get_cell_by_index(0, 'ID').nativeElement.offsetWidth).toBe(200); + expect(grid.gridAPI.get_cell_by_index(0, 'CompanyName').nativeElement.offsetWidth).toBe(200); + expect(grid.gridAPI.get_cell_by_index(0, 'ContactName').nativeElement.offsetWidth).toBe(200); + expect(grid.gridAPI.get_cell_by_index(0, 'ContactTitle').nativeElement.offsetWidth).toBe(600); + expect(grid.gridAPI.get_cell_by_index(0, 'Country').nativeElement.offsetWidth).toBe(400); + expect(grid.gridAPI.get_cell_by_index(0, 'Region').nativeElement.offsetWidth).toBe(200); + + // check group blocks + // groupHeaderBlocks = fixture.debugElement.query(By.css('.igx-grid-thead')).queryAll(By.css(GRID_MRL_BLOCK_CLASS)); + groupHeaderBlocks = grid.theadRow.nativeElement.querySelectorAll(GRID_MRL_BLOCK_CLASS); + expect(groupHeaderBlocks[0].clientWidth).toBe(600); + expect((groupHeaderBlocks[0] as HTMLElement).style.gridTemplateColumns).toEqual('200px 200px 200px'); + + gridFirstRow = grid.rowList.first; + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + GridFunctions.verifyDOMMatchesLayoutSettings(grid, gridFirstRow, fixture.componentInstance.colGroups); + })); + + it('should initialize correctly when grid width is in % and no widths are set for columns.', fakeAsync(() => { + const fixture = TestBed.createComponent(ColumnLayoutTestComponent); + const grid = fixture.componentInstance.grid; + fixture.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ID', rowStart: 1, colStart: 1 }, + { field: 'CompanyName', rowStart: 1, colStart: 2 }, + { field: 'ContactName', rowStart: 1, colStart: 3, colEnd: 5 }, + { field: 'ContactTitle', rowStart: 2, colStart: 1, rowEnd: 3, colEnd: 4 }, + ] + }]; + fixture.componentInstance.grid.width = '100%'; + fixture.detectChanges(); + + // check group blocks + // const groupHeaderBlocks = fixture.debugElement.query(By.css('.igx-grid-thead')).queryAll(By.css(GRID_MRL_BLOCK_CLASS)); + const groupHeaderBlocks = grid.theadRow.nativeElement.querySelectorAll(GRID_MRL_BLOCK_CLASS); + expect(groupHeaderBlocks[0].clientWidth).toBe(groupHeaderBlocks[0].parentElement.clientWidth); + + const gridFirstRow = grid.rowList.first; + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + GridFunctions.verifyDOMMatchesLayoutSettings(grid, gridFirstRow, fixture.componentInstance.colGroups); + })); + + it('should use columns with the smallest col spans when determining the column group’s column widths.', fakeAsync(() => { + const fixture = TestBed.createComponent(ColumnLayoutTestComponent); + const grid = fixture.componentInstance.grid; + fixture.componentInstance.colGroups = [{ + group: 'group2', + columns: [ + { field: 'ContactName', rowStart: 2, colStart: 1, colEnd: 4, rowEnd: 4, width: '500px' }, + { field: 'CompanyName', rowStart: 1, colStart: 1, width: '100px' }, + { field: 'PostalCode', rowStart: 1, colStart: 2, width: '200px' }, + { field: 'Fax', rowStart: 1, colStart: 3, width: '100px' } + ] + }]; + fixture.detectChanges(); + + // check group blocks + // let groupHeaderBlocks = fixture.debugElement.query(By.css('.igx-grid-thead')).queryAll(By.css(GRID_MRL_BLOCK_CLASS)); + let groupHeaderBlocks = grid.theadRow.nativeElement.querySelectorAll(GRID_MRL_BLOCK_CLASS); + expect(groupHeaderBlocks[0].clientWidth).toBe(400); + expect((groupHeaderBlocks[0] as HTMLElement).style.gridTemplateColumns).toBe('100px 200px 100px'); + fixture.componentInstance.colGroups = [{ + group: 'group2', + columns: [ + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 4, rowEnd: 2, width: '500px' }, + { field: 'CompanyName', rowStart: 2, colStart: 1, width: '100px' }, + { field: 'PostalCode', rowStart: 2, colStart: 2, width: '200px' }, + { field: 'Fax', rowStart: 2, colStart: 3, width: '100px' } + ] + }]; + fixture.detectChanges(); + // check group blocks + groupHeaderBlocks = grid.theadRow.nativeElement.querySelectorAll(GRID_MRL_BLOCK_CLASS); + expect(groupHeaderBlocks[0].clientWidth).toBe(400); + expect((groupHeaderBlocks[0] as HTMLElement).style.gridTemplateColumns).toBe('100px 200px 100px'); + })); + + it('should disregard column groups if multi-column layouts are also defined.', fakeAsync(() => { + const fixture = TestBed.createComponent(ColumnLayoutAndGroupsTestComponent); + fixture.detectChanges(); + const grid = fixture.componentInstance.grid; + + // check grid's columns collection + // 5 in total + expect(grid.columns.length).toBe(5); + // 1 column layout + expect(grid.columns.filter(x => x.columnLayout).length).toBe(1); + // 4 normal columns + expect(grid.columns.filter(x => !x.columnLayout && !x.columnGroup).length).toBe(4); + + // check header + expect(document.querySelectorAll('igx-grid-header-group').length).toEqual(5); + expect(document.querySelectorAll(GRID_COL_THEAD_CLASS).length).toEqual(4); + })); + + it('should render correct heights when groups have different total row span', fakeAsync(() => { + const fixture = TestBed.createComponent(ColumnLayoutAndGroupsTestComponent); + const grid = fixture.componentInstance.grid; + fixture.componentInstance.colGroups = [ + { + group: 'group1', + // group with total row span 1 + columns: [ + { field: 'Fax', rowStart: 1, colStart: 1 } + ] + }, { + group: 'group2', + // group with total row span 2 + columns: [ + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 4, rowEnd: 2 }, + { field: 'CompanyName', rowStart: 2, colStart: 1 }, + { field: 'PostalCode', rowStart: 2, colStart: 2 }, + { field: 'Fax', rowStart: 2, colStart: 3 } + ] + }]; + fixture.detectChanges(); + + // check first group has height of 2 row spans in header and rows but the header itself should span 1 row + // check group block and column header height + const firstLayout = grid.columns[0]; + expect(grid.multiRowLayoutRowSize).toEqual(2); + expect(firstLayout.getGridTemplate(true)).toEqual('repeat(2,1fr)'); + expect(firstLayout.headerGroup.nativeElement.offsetHeight).toBe((grid.rowHeight + 1) * 2); + expect(grid.getColumnByName('Fax').headerCell.nativeElement.offsetHeight).toBe(grid.rowHeight + 1); + + const secondLayout = grid.columns[2]; + const contactNameColumn = grid.getColumnByName('ContactName'); + expect(contactNameColumn.getGridTemplate(true)).toEqual('repeat(2,1fr)'); + expect(secondLayout.headerGroup.nativeElement.offsetHeight).toBe((grid.rowHeight + 1) * 2); + + // check cell height in row. By default should span 1 row + const firstCell = grid.gridAPI.get_cell_by_index(0, 'Fax').nativeElement; + expect(firstCell.offsetHeight).toEqual(grid.gridAPI.get_cell_by_index(0, 'ContactName').nativeElement.offsetHeight); + })); + + // Virtualization + + it('should apply horizontal virtualization based on the group blocks.', async () => { + const fixture = TestBed.createComponent(ColumnLayoutTestComponent); + const grid = fixture.componentInstance.grid; + const uniqueGroups = [ + { + group: 'group1', + // total colspan 3 + columns: [ + { field: 'Address', rowStart: 1, colStart: 1, colEnd: 4, rowEnd: 3 }, + { field: 'County', rowStart: 3, colStart: 1 }, + { field: 'Region', rowStart: 3, colStart: 2 }, + { field: 'City', rowStart: 3, colStart: 3 } + ] + }, + { + group: 'group2', + // total colspan 2 + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1 }, + { field: 'Address', rowStart: 1, colStart: 2 }, + { field: 'ContactName', rowStart: 2, colStart: 1, colEnd: 3, rowEnd: 4 } + ] + }, + { + group: 'group3', + // total colspan 1 + columns: [ + { field: 'Phone', rowStart: 1, colStart: 1 }, + { field: 'Fax', rowStart: 2, colStart: 1, rowEnd: 4 } + ] + }, + { + group: 'group4', + // total colspan 4 + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'Phone', rowStart: 1, colStart: 3, rowEnd: 3 }, + { field: 'Address', rowStart: 1, colStart: 4, rowEnd: 4 }, + { field: 'Region', rowStart: 2, colStart: 1 }, + { field: 'City', rowStart: 2, colStart: 2 }, + { field: 'ContactName', rowStart: 3, colStart: 1, colEnd: 4 }, + ] + } + ]; + let colGroups = []; + for (let i = 0; i < 3; i++) { + const groups = structuredClone(uniqueGroups) + .map(({ group, columns }) => ({ group: group + i, columns })); + colGroups = colGroups.concat(groups); + } + fixture.componentInstance.colGroups = colGroups; + grid.columnWidth = '200px'; + fixture.componentInstance.grid.width = '600px'; + fixture.detectChanges(); + + // 12 groups in total + const horizontalVirtualization = grid.rowList.first.virtDirRow; + expect(grid.hasHorizontalScroll()).toBeTruthy(); + expect(horizontalVirtualization.igxForOf.length).toBe(12); + + // check chunk size is correct + expect(horizontalVirtualization.state.chunkSize).toBe(3); + // check passed instances to igxFor are the groups + expect(horizontalVirtualization.igxForOf[0] instanceof IgxColumnLayoutComponent).toBeTruthy(); + // check their sizes are correct + expect(horizontalVirtualization.getSizeAt(0)).toBe(3 * 200); + expect(horizontalVirtualization.getSizeAt(1)).toBe(2 * 200); + expect(horizontalVirtualization.getSizeAt(2)).toBe(200); + expect(horizontalVirtualization.getSizeAt(3)).toBe(4 * 200); + + // check total widths sum - unique col groups col span 10 in total * 200px default width * 3 times repeated + const horizontalScrElem = horizontalVirtualization.getScroll(); + const totalExpected = 10 * 200 * 3; + expect(parseInt((horizontalScrElem.children[0] as HTMLElement).style.width, 10)).toBe(totalExpected); + // check groups are rendered correctly + + const gridFirstRow = grid.rowList.first; + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + GridFunctions.verifyDOMMatchesLayoutSettings(grid, gridFirstRow, + fixture.componentInstance.colGroups.slice(0, horizontalVirtualization.state.chunkSize)); + + // check last column group can be scrolled in view + horizontalVirtualization.scrollTo(11); + await wait(100); + fixture.detectChanges(); + + // last 3 blocks should be rendered + GridFunctions.verifyDOMMatchesLayoutSettings(grid, grid.rowList.first, + fixture.componentInstance.colGroups.slice( + horizontalVirtualization.state.startIndex, + horizontalVirtualization.state.startIndex + horizontalVirtualization.state.chunkSize)); + + }); + + it('should apply horizontal virtualization correctly for widths in px, % and no-width columns.', fakeAsync(() => { + const fixture = TestBed.createComponent(ColumnLayoutTestComponent); + // test with px + fixture.componentInstance.colGroups = [{ + group: 'group1', + // total colspan 3 + columns: [ + { field: 'Address', rowStart: 1, colStart: 1, colEnd: 4, rowEnd: 3 }, + { field: 'County', rowStart: 3, colStart: 1, width: '200px' }, + { field: 'Region', rowStart: 3, colStart: 2, width: '300px' }, + { field: 'City', rowStart: 3, colStart: 3, width: '200px' } + ] + }]; + fixture.componentInstance.grid.width = '617px'; + fixture.detectChanges(); + tick(); // Required to render scrollbars + const grid = fixture.componentInstance.grid; + + const horizontalVirtualization = grid.rowList.first.virtDirRow; + expect(grid.hasHorizontalScroll()).toBeTruthy(); + expect(horizontalVirtualization.igxForOf.length).toBe(1); + + // check group size is correct + expect(horizontalVirtualization.getSizeAt(0)).toBe(700); + + // check DOM + let gridFirstRow = grid.rowList.first; + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + GridFunctions.verifyDOMMatchesLayoutSettings(grid, gridFirstRow, fixture.componentInstance.colGroups); + + // test with % + fixture.componentInstance.colGroups.push({ + group: 'group2', + // total colspan 2 + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, width: '20%' }, + { field: 'Address1', rowStart: 1, colStart: 2, width: '30%' }, + { field: 'ContactName', rowStart: 2, colStart: 1, colEnd: 3, rowEnd: 4 } + ] + }); + fixture.detectChanges(); + expect(grid.hasHorizontalScroll()).toBeTruthy(); + expect(horizontalVirtualization.igxForOf.length).toBe(2); + + // check group size is correct + expect(horizontalVirtualization.getSizeAt(0)).toBe(700); + expect(horizontalVirtualization.getSizeAt(1)).toBe(300); + + // check DOM + gridFirstRow = grid.rowList.first; + // headerCells = grid.theadRow._groups.last.children; + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + GridFunctions.verifyDOMMatchesLayoutSettings(grid, gridFirstRow, fixture.componentInstance.colGroups); + + // test with no width + fixture.componentInstance.colGroups.push({ + group: 'group4', + // total colspan 4 + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'Phone', rowStart: 1, colStart: 3, rowEnd: 3 }, + { field: 'Address', rowStart: 1, colStart: 4, rowEnd: 4 }, + { field: 'Region', rowStart: 2, colStart: 1 }, + { field: 'City', rowStart: 2, colStart: 2 }, + { field: 'ContactName', rowStart: 3, colStart: 1, colEnd: 4 }, + ] + }); + + fixture.detectChanges(); + expect(grid.hasHorizontalScroll()).toBeTruthy(); + expect(horizontalVirtualization.igxForOf.length).toBe(3); + + // check group size is correct + expect(horizontalVirtualization.getSizeAt(0)).toBe(700); + expect(horizontalVirtualization.getSizeAt(1)).toBe(300); + expect(horizontalVirtualization.getSizeAt(2)).toBe(136 * 4); + + // check DOM + gridFirstRow = grid.rowList.first; + // headerCells = grid.theadRow._groups.last.children; + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + GridFunctions.verifyDOMMatchesLayoutSettings(grid, gridFirstRow, fixture.componentInstance.colGroups); + })); + + it('vertical virtualization should work as expected when there are multi-row layouts.', async () => { + const fixture = TestBed.createComponent(ColumnLayoutTestComponent); + const grid = fixture.componentInstance.grid; + fixture.componentInstance.colGroups = [{ + group: 'group4', + // total rowspan 3 + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3 }, + { field: 'Phone', rowStart: 1, colStart: 3, rowEnd: 3 }, + { field: 'Address', rowStart: 1, colStart: 4, rowEnd: 4 }, + { field: 'Region', rowStart: 2, colStart: 1 }, + { field: 'City', rowStart: 2, colStart: 2 }, + { field: 'ContactName', rowStart: 3, colStart: 1, colEnd: 4 }, + ] + }]; + fixture.detectChanges(); + + const rows = fixture.debugElement.query(By.css('.igx-grid__tbody')).queryAll(By.css('igx-grid-row')); + expect(rows.length).toEqual(4); + expect(grid.hasVerticalScroll()).toBeTruthy(); + + const verticalVirt = grid.verticalScrollContainer; + + fixture.detectChanges(); + + // scroll to bottom + const lastIndex = grid.data.length - 1; + verticalVirt.scrollTo(lastIndex); + fixture.detectChanges(); + await wait(100); + fixture.detectChanges(); + + const dataRows = grid.dataRowList.toArray(); + const lastRow = dataRows[dataRows.length - 1]; + + // check correct last row is rendered and is last in view + expect(lastRow.dataRowIndex).toBe(lastIndex); + expect(lastRow.data).toBe(grid.data[lastIndex]); + + // last in tbody + expect(lastRow.element.nativeElement.getBoundingClientRect().bottom).toBe(grid.tbody.nativeElement.getBoundingClientRect().bottom); + + // check size is correct + expect(grid.verticalScrollContainer.getSizeAt(lastIndex)).toBe(151); + + // check DOM + GridFunctions.verifyLayoutHeadersAreAligned(grid, lastRow); + GridFunctions.verifyDOMMatchesLayoutSettings(grid, lastRow, fixture.componentInstance.colGroups); + }); + + it('should correctly size columns without widths when default column width is set to percentages', fakeAsync(() => { + // In this case it would be for City column and 3rd template column overlapping width ContactName. + const fixture = TestBed.createComponent(ColumnLayoutTestComponent); + fixture.detectChanges(); + + fixture.componentInstance.grid.width = '1200px'; + fixture.componentInstance.grid.columnWidth = '10%'; + fixture.detectChanges(); + fixture.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 4 }, + { field: 'ContactTitle', rowStart: 1, colStart: 4, colEnd: 6 }, + { field: 'Country', rowStart: 1, colStart: 6, colEnd: 7 }, + { field: 'Phone', rowStart: 2, colStart: 1, colEnd: 3 }, + { field: 'City', rowStart: 2, colStart: 3, colEnd: 5 }, + { field: 'Address', rowStart: 2, colStart: 5, colEnd: 7 }, + { field: 'CompanyName', rowStart: 3, colStart: 1, colEnd: 2 }, + { field: 'PostalCode', rowStart: 3, colStart: 2, colEnd: 3 }, + { field: 'Fax', rowStart: 3, colStart: 3, colEnd: 7 }, + ] + }]; + fixture.detectChanges(); + const grid = fixture.componentInstance.grid; + const gridFirstRow = grid.rowList.first; + + // headers are aligned to cells + GridFunctions.verifyLayoutHeadersAreAligned(grid, gridFirstRow); + + const groupRowBlocks = fixture.debugElement.query(By.css('.igx-grid__tbody')).queryAll(By.css(GRID_MRL_BLOCK_CLASS)); + expect(groupRowBlocks[0].nativeElement.style.gridTemplateColumns).toEqual('118.4px 118.4px 118.4px 118.4px 118.4px 118.4px'); + })); + + it('should disregard hideGroupedColumns option and not hide columns when grouping when having column layouts.', fakeAsync(() => { + const fixture = TestBed.createComponent(ColumnLayoutTestComponent); + fixture.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'ContactName', rowStart: 1, colStart: 1, colEnd: 4 }, + { field: 'ContactTitle', rowStart: 1, colStart: 4, colEnd: 6 }, + { field: 'Country', rowStart: 1, colStart: 6, colEnd: 7 }, + { field: 'Phone', rowStart: 2, colStart: 1, colEnd: 3 }, + { field: 'City', rowStart: 2, colStart: 3, colEnd: 5 }, + { field: 'Address', rowStart: 2, colStart: 5, colEnd: 7 }, + { field: 'CompanyName', rowStart: 3, colStart: 1, colEnd: 2 }, + { field: 'PostalCode', rowStart: 3, colStart: 2, colEnd: 3 }, + { field: 'Fax', rowStart: 3, colStart: 3, colEnd: 7 }, + ] + }]; + const grid = fixture.componentInstance.grid; + grid.hideGroupedColumns = true; + fixture.detectChanges(); + + grid.groupBy({ + fieldName: 'ContactTitle', dir: SortingDirection.Desc, ignoreCase: false, + strategy: DefaultSortingStrategy.instance() + }); + fixture.detectChanges(); + + // check column and group are not hidden + const col = grid.getColumnByName('ContactTitle'); + expect(col.hidden).toBe(false); + expect(col.parent.hidden).toBe(false); + })); + + it('should get the correct next and previous cell when in MRL scenario', fakeAsync(() => { + const fixture = TestBed.createComponent(ColumnLayoutTestComponent); + fixture.componentInstance.colGroups = [{ + group: 'group1', + columns: [ + { field: 'CompanyName', rowStart: 1, rowEnd: 2, colStart: 3, colEnd: 4, dataType: 'number', editable: true }, + { field: 'ID', rowStart: 1, rowEnd: 2, colStart: 1, colEnd: 2, dataType: 'number', editable: false }, + { field: 'ContactName', rowStart: 1, rowEnd: 2, colStart: 2, colEnd: 3, dataType: 'string', editable: false }, + ] + }]; + const grid = fixture.componentInstance.grid; + fixture.detectChanges(); + let pos: ICellPosition; + pos = grid.getNextCell(0, 1, col => col.editable === true); + expect(pos.rowIndex).toEqual(0); + expect(pos.visibleColumnIndex).toEqual(2); + pos = grid.getNextCell(0, 2, col => col.editable === true); + expect(pos.rowIndex).toEqual(1); + expect(pos.visibleColumnIndex).toEqual(2); + pos = grid.getPreviousCell(1, 2); + expect(pos.rowIndex).toEqual(1); + expect(pos.visibleColumnIndex).toEqual(1); + pos = grid.getPreviousCell(1, 2, col => col.editable === true); + expect(pos.rowIndex).toEqual(0); + expect(pos.visibleColumnIndex).toEqual(2); + })); + + it('should navigate to the proper row in MRL scenario', (async () => { + const fix = TestBed.createComponent(ColumnLayoutTestComponent); + const grid = fix.componentInstance.grid; + const NAVIGATE = 20; + + fix.detectChanges(); + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + grid.navigateTo(NAVIGATE); + + await wait(DEBOUNCE_TIME); + fix.detectChanges(); + + expect(grid.verticalScrollContainer.getScroll().scrollTop).toBeGreaterThan(0); + + const row = grid.gridAPI.get_row_by_index(NAVIGATE); + expect(GridFunctions.elementInGridView(grid, row.nativeElement)).toBeTruthy(); + })); +}); + +@Component({ + template: ` + + @for (group of colGroups; track group.group) { + + @for (col of group.columns; track col.field) { + + } + + } + + `, + imports: [IgxGridComponent, IgxColumnLayoutComponent, IgxColumnComponent] +}) +export class ColumnLayoutTestComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + public cols: Array = [ + { field: 'ID', rowStart: 1, colStart: 1 }, + { field: 'CompanyName', rowStart: 1, colStart: 2 }, + { field: 'ContactName', rowStart: 1, colStart: 3 }, + { field: 'ContactTitle', rowStart: 2, colStart: 1, rowEnd: 4, colEnd: 4 }, + ]; + public colGroups = [ + { + group: 'group1', + columns: this.cols + } + ]; + public data = SampleTestData.contactInfoDataFull(); +} + +@Component({ + template: ` + + + + + + + + + @for (group of colGroups; track group.group) { + + @for (col of group.columns; track col.field) { + + } + + } + + `, + imports: [IgxGridComponent, IgxColumnLayoutComponent, IgxColumnComponent, IgxColumnGroupComponent] +}) +export class ColumnLayoutAndGroupsTestComponent extends ColumnLayoutTestComponent { + +} diff --git a/projects/igniteui-angular/grids/grid/src/grid.nested.props.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.nested.props.spec.ts new file mode 100644 index 00000000000..68ae5746d03 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid.nested.props.spec.ts @@ -0,0 +1,785 @@ +import { TestBed, ComponentFixture, fakeAsync, waitForAsync } from '@angular/core/testing'; +import { IgxGridComponent } from './grid.component'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Component, DebugElement, ViewChild } from '@angular/core'; +import { UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { GridFunctions } from '../../../test-utils/grid-functions.spec'; +import { IGridEditEventArgs, IgxCellEditorTemplateDirective, IgxCellTemplateDirective, IgxColumnComponent } from 'igniteui-angular/grids/core'; +import { FormsModule } from '@angular/forms'; +import { IgxComboComponent } from 'igniteui-angular/combo'; +import { cloneArray, columnFieldPath, IgxStringFilteringOperand, resolveNestedPath, SortingDirection } from 'igniteui-angular/core'; + +const first = (array: T[]): T => array[0]; + +const DATA = [ + { + id: 0, + user: { + name: { + first: 'John', + last: 'Doe' + }, + email: 'johndoe@mail.com', + age: 30, + address: { + zip: 1000, + country: 'USA' + } + }, + active: true + }, + { + id: 1, + user: { + name: { + first: 'Jane', + last: 'Doe' + }, + email: 'jane@gmail.com', + age: 23, + address: { + zip: 2000, + country: 'England' + } + }, + active: true + }, + { + id: 2, + user: { + name: { + first: 'Ivan', + last: 'Ivanov' + }, + email: 'ivanko@po6ta.bg', + age: 33, + address: { + zip: 1700, + country: 'Bulgaria' + } + }, + active: false + }, + { + id: 3, + user: { + name: { + first: 'Bianka', + last: 'Bartosik' + }, + email: 'bbb@gmail.pl', + age: 21, + address: { + zip: 6000, + country: 'Poland' + } + }, + active: true + } +]; + +const LOCATIONS = [ + { + id: 0, + shop: 'My Cool Market' + }, + { + id: 1, + shop: 'MarMaMarket' + }, + { + id: 2, + shop: 'Fun-Tasty Co.' + }, + { + id: 3, + shop: 'MarMaMarket' + } +]; + +const DATA2 = [ + { + id: 0, + productName: 'Chai', + locations: [{ ...LOCATIONS[2] }] + }, + { + id: 1, + productName: 'Chang', + locations: [{ ...LOCATIONS[0] }, { ...LOCATIONS[1] }] + }, + { + id: 2, + productName: 'Aniseed Syrup', + locations: [{ ...LOCATIONS[0] }, { ...LOCATIONS[1] }, { ...LOCATIONS[2] }] + }, + { + id: 3, + productName: 'Uncle Bobs Organic Dried Pears', + locations: [{ ...LOCATIONS[2] }, { ...LOCATIONS[3] }] + }, +]; + +@Component({ + template: ``, + imports: [IgxGridComponent] +}) +class NestedPropertiesGridComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent }) + public grid: IgxGridComponent; +} + +@Component({ + template: ` + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +class NestedPropertiesGrid2Component { + @ViewChild('grid', { static: true, read: IgxGridComponent }) + public grid: IgxGridComponent; +} + +@Component({ + template: ` + + + + + {{ parseArray(cell.value) }} + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxCellTemplateDirective, IgxCellEditorTemplateDirective, IgxComboComponent, FormsModule] +}) +class NestedPropertyGridComponent { + @ViewChild('grid', { static: true, read: IgxGridComponent }) + public grid: IgxGridComponent; + + @ViewChild(IgxComboComponent, { read: IgxComboComponent }) + public combo: IgxComboComponent; + + public locations = LOCATIONS; + public parseArray(arr: { id: number; shop: string }[]): string { + return (arr || []).map((e) => e.shop).join(', '); + } +} + +describe('Grid - nested data source properties #grid', () => { + + const NAMES = 'John Jane Ivan Bianka'.split(' '); + const AGES = [30, 23, 33, 21]; + + describe('API', () => { + + it('should correctly resolve key paths in nested data', () => { + expect( + DATA.map(record => resolveNestedPath(record, columnFieldPath("user.name.first"))) + ).toEqual(NAMES); + expect( + DATA.map(record => resolveNestedPath(record, columnFieldPath("user.age"))) + ).toEqual(AGES); + }); + }); + + describe('Grid base cases', () => { + + let fixture: ComponentFixture; + let grid: IgxGridComponent; + + const setupData = (data: Array) => { + grid.autoGenerate = true; + grid.data = data; + fixture.detectChanges(); + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, NestedPropertiesGridComponent + ] + }).compileComponents(); + })); + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(NestedPropertiesGridComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + })); + + it('should support column API with complex field', () => { + setupData(DATA); + + const column = grid.getColumnByName('user'); + column.field = 'user.name.first'; + fixture.detectChanges(); + + expect(grid.getColumnByName('user.name.first')).toBe(column); + }); + + it('should render the passed properties path', () => { + setupData(DATA); + + const column = grid.getColumnByName('user'); + column.field = 'user.name.first'; + fixture.detectChanges(); + + expect(column.cells.map(cell => cell.value)).toEqual(NAMES); + }); + + it('should work with sorting', () => { + setupData(DATA); + + const key = 'user.age'; + + const column = grid.getColumnByName('user'); + column.field = key; + fixture.detectChanges(); + + grid.sort({ fieldName: key, dir: SortingDirection.Asc }); + fixture.detectChanges(); + + expect(first(column.cells.map(cell => cell.value))).toEqual(21); + + grid.sort({ fieldName: key, dir: SortingDirection.Desc }); + fixture.detectChanges(); + + expect(first(column.cells.map(cell => cell.value))).toEqual(33); + }); + + it('should work with filtering', () => { + setupData(DATA); + + const key = 'user.name.first'; + const operand = IgxStringFilteringOperand.instance().condition('equals'); + + const column = grid.getColumnByName('user'); + column.field = key; + fixture.detectChanges(); + + grid.filter(key, 'Jane', operand); + fixture.detectChanges(); + + expect(grid.dataView.length).toEqual(1); + expect(first(column.cells).value).toEqual('Jane'); + }); + + it('should support copy/paste operations', () => { + setupData(DATA); + + grid.getColumnByName('user').field = 'user.name.first'; + fixture.detectChanges(); + + + grid.setSelection({ columnStart: 'user.name.first', columnEnd: 'user.name.first', rowStart: 0, rowEnd: 0 }); + fixture.detectChanges(); + + const selected = grid.getSelectedData(); + expect(selected.length).toEqual(1); + expect(first(selected)['user.name.first']).toMatch('John'); + }); + + it('should work with editing (cell)', () => { + const copiedData = cloneArray(DATA, true); + setupData(copiedData); + + const key = 'user.name.first'; + const column = grid.getColumnByName('user'); + column.field = key; + grid.primaryKey = 'id'; + fixture.detectChanges(); + + grid.updateCell('Anonymous', 0, key); + fixture.detectChanges(); + + expect(first(copiedData).user.name.first).toMatch('Anonymous'); + }); + + it('should work with editing (row)', () => { + const copiedData = cloneArray(DATA, true); + setupData(copiedData); + + grid.primaryKey = 'id'; + grid.getColumnByName('user').field = 'user.name.first'; + fixture.detectChanges(); + + grid.updateRow({ user: { name: { first: 'Updated!' } } }, 0); + fixture.detectChanges(); + + expect(first(copiedData).user.name.first).toMatch('Updated!'); + }); + }); +}); + +// related to fix of issue #8343 +describe('Grid nested data advanced editing #grid', () => { + + let fixture: ComponentFixture; + let grid: IgxGridComponent; + let gridContent: DebugElement; + + const setupData = (data: Array, rowEditable = false) => { + grid.data = data; + if (rowEditable) { + grid.primaryKey = 'id'; + grid.rowEditable = true; + } + fixture.detectChanges(); + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, NestedPropertiesGrid2Component + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(NestedPropertiesGrid2Component); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + gridContent = GridFunctions.getGridContent(fixture); + }); + + it('canceling the row editing should revert the uncommitted cell values', () => { + const copiedData = cloneArray(DATA, true); + setupData(copiedData, true); + + const cell1 = grid.getCellByColumn(0, 'user.name.first'); + const cell2 = grid.getCellByColumn(0, 'user.name.last'); + const cell3 = grid.getCellByColumn(0, 'user.email'); + + UIInteractions.simulateDoubleClickAndSelectEvent(grid.gridAPI.get_cell_by_index(0, 'user.name.first')); + fixture.detectChanges(); + expect(cell1.editMode).toBe(true); + cell1.editValue = 'Petar'; + + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + fixture.detectChanges(); + + expect(cell1.editMode).toBe(false); + expect(cell2.editMode).toBe(true); + + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + fixture.detectChanges(); + + expect(cell2.editMode).toBe(false); + expect(cell3.editMode).toBe(true); + + UIInteractions.triggerEventHandlerKeyDown('escape', gridContent); + fixture.detectChanges(); + + expect(cell3.editMode).toBe(false); + + expect(cell1.value).toBeDefined(true); + expect(cell2.value).toBeDefined(true); + expect(cell3.value).toBeDefined(true); + // related to issue #0000, comment out the below line after fixing the issue + expect(first(copiedData).user.name.first).toMatch('John'); + expect(first(copiedData).user.name.last).toMatch('Doe'); + }); + + it('after updating a cell value the value in the previous cell should persist', () => { + const copiedData = cloneArray(DATA, true); + setupData(copiedData, true); + + const cell1 = grid.getCellByColumn(0, 'user.name.first'); + const cell2 = grid.getCellByColumn(0, 'user.name.last'); + const cell3 = grid.getCellByColumn(0, 'user.email'); + + UIInteractions.simulateDoubleClickAndSelectEvent(grid.gridAPI.get_cell_by_index(0, 'user.name.last')); + fixture.detectChanges(); + expect(cell2.editMode).toBe(true); + cell2.editValue = 'Petrov'; + + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + fixture.detectChanges(); + + expect(cell2.editMode).toBe(false); + expect(cell3.editMode).toBe(true); + expect(cell1.value).toBeDefined(true); + }); + + it('updating values of multiple cells in a row should update the data correctly', () => { + const copiedData = cloneArray(DATA, true); + setupData(copiedData, true); + + const cell1 = grid.getCellByColumn(0, 'user.name.first'); + const cell2 = grid.getCellByColumn(0, 'user.name.last'); + const cell3 = grid.getCellByColumn(0, 'user.email'); + + UIInteractions.simulateDoubleClickAndSelectEvent(grid.gridAPI.get_cell_by_index(0, 'user.name.first')); + fixture.detectChanges(); + expect(cell1.editMode).toBe(true); + cell1.editValue = 'Petar'; + + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + fixture.detectChanges(); + + expect(cell1.editMode).toBe(false); + expect(cell2.editMode).toBe(true); + cell2.editValue = 'Petrov'; + + UIInteractions.triggerEventHandlerKeyDown('tab', gridContent); + fixture.detectChanges(); + + expect(cell2.editMode).toBe(false); + expect(cell3.editMode).toBe(true); + cell3.editValue = 'ppetrov@email.com'; + + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fixture.detectChanges(); + + expect(cell1.value).toBeDefined(true); + expect(cell2.value).toBeDefined(true); + expect(cell3.value).toBeDefined(true); + expect(first(copiedData).user.name.last).toMatch('Petrov'); + }); + + it('sorting the grid and modifying a cell within an unsorted column should not change the rows order', async () => { + const copiedData = cloneArray(DATA, true); + setupData(copiedData); + + const header = GridFunctions.getColumnHeader('user.age', fixture); + UIInteractions.simulateClickAndSelectEvent(header); + + expect(grid.headerGroupsList[0].isFiltered).toBeFalsy(); + GridFunctions.verifyHeaderSortIndicator(header, false, false); + + GridFunctions.clickHeaderSortIcon(header); + GridFunctions.clickHeaderSortIcon(header); + fixture.detectChanges(); + + GridFunctions.verifyHeaderSortIndicator(header, false, true); + + const cell1 = grid.gridAPI.get_cell_by_index(0, 'user.address.zip'); + expect(cell1.value).toEqual(1700); + + UIInteractions.simulateDoubleClickAndSelectEvent(cell1); + fixture.detectChanges(); + expect(cell1.editMode).toBe(true); + cell1.editValue = 1618; + + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(grid.getRowByIndex(0).data.user.address.zip).toMatch('1618'); + expect(copiedData[2].user.address.zip).toMatch('1618'); + }); +}); + +// related to issue #8343 +describe('Edit cell with data of type Array #grid', () => { + + let fixture: ComponentFixture; + let grid: IgxGridComponent; + let combo: IgxComboComponent; + let gridContent: DebugElement; + + const setupData = (data: Array, rowEditable = false) => { + grid.data = data; + if (rowEditable) { + grid.primaryKey = 'id'; + grid.rowEditable = true; + } + fixture.detectChanges(); + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, NestedPropertyGridComponent + ] + }).compileComponents(); + })); + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(NestedPropertyGridComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + gridContent = GridFunctions.getGridContent(fixture); + })); + + it('igxGrid should emit the correct args when cell editing is cancelled', async () => { + const copiedData = cloneArray(DATA2, true); + setupData(copiedData); + + spyOn(grid.cellEditEnter, 'emit').and.callThrough(); + spyOn(grid.cellEditExit, 'emit').and.callThrough(); + + const cell = grid.getCellByColumn(2, 'locations'); + let initialRowData = { ...cell.row.data }; + + UIInteractions.simulateDoubleClickAndSelectEvent(grid.gridAPI.get_cell_by_index(2, 'locations')); + fixture.detectChanges(); + combo = fixture.componentInstance.combo; + fixture.detectChanges(); + await fixture.whenStable(); + + const cellArgs: IGridEditEventArgs = { + primaryKey: cell.row.key, + rowID: cell.row.key, + rowKey: cell.row.key, + cellID: cell.id, + rowData: initialRowData, + oldValue: initialRowData.locations, + cancel: false, + column: cell.column, + owner: grid, + event: jasmine.anything() as any, + valid: true + }; + + expect(grid.cellEditEnter.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEditEnter.emit).toHaveBeenCalledWith(cellArgs); + expect(cell.editMode).toBeTruthy(); + expect(combo.selection.length).toEqual(3); + + combo.deselect([cell.editValue[0], cell.editValue[1]]); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(grid.data[2].locations.length).toEqual(3); + expect(cell.editValue.length).toEqual(1); + cellArgs.newValue = [cell.editValue[0]]; + + UIInteractions.triggerEventHandlerKeyDown('escape', gridContent); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(cell.editMode).toBeFalsy(); + + initialRowData = { ...cell.row.data }; + cellArgs.rowData = initialRowData; + + expect(cellArgs.newValue.length).toEqual(1); + expect(cellArgs.oldValue.length).toEqual(3); + + delete cellArgs.cancel; + + expect(grid.cellEditExit.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEditExit.emit).toHaveBeenCalledWith(cellArgs); + + expect(copiedData[2].locations.length).toEqual(3); + }); + + it('igxGrid should emit the correct args when submitting the changes', async () => { + const copiedData = cloneArray(DATA2, true); + setupData(copiedData); + + spyOn(grid.cellEditEnter, 'emit').and.callThrough(); + spyOn(grid.cellEdit, 'emit').and.callThrough(); + spyOn(grid.cellEditDone, 'emit').and.callThrough(); + spyOn(grid.cellEditExit, 'emit').and.callThrough(); + + const cell = grid.getCellByColumn(2, 'locations'); + let initialRowData = { ...cell.row.data }; + + UIInteractions.simulateDoubleClickAndSelectEvent(grid.gridAPI.get_cell_by_index(2, 'locations')); + fixture.detectChanges(); + combo = fixture.componentInstance.combo; + fixture.detectChanges(); + await fixture.whenStable(); + + const cellArgs: IGridEditEventArgs = { + primaryKey: cell.row.key, + rowID: cell.row.key, + rowKey: cell.row.key, + cellID: cell.id, + rowData: initialRowData, + oldValue: initialRowData.locations, + cancel: false, + column: cell.column, + owner: grid, + event: jasmine.anything() as any, + valid: true + }; + + expect(grid.cellEditEnter.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEditEnter.emit).toHaveBeenCalledWith(cellArgs); + expect(cell.editMode).toBeTruthy(); + expect(combo.selection.length).toEqual(3); + + combo.deselect([cell.editValue[0], cell.editValue[1]]); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(grid.data[2].locations.length).toEqual(3); + expect(cell.editValue.length).toEqual(1); + + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(cell.editMode).toBeFalsy(); + + initialRowData = { ...cell.row.data }; + cellArgs.rowData = initialRowData; + cellArgs.newValue = initialRowData.locations; + + expect(cellArgs.newValue.length).toEqual(1); + expect(cellArgs.oldValue.length).toEqual(3); + + expect(grid.cellEdit.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEdit.emit).toHaveBeenCalledWith(cellArgs); + + delete cellArgs.cancel; + + expect(grid.cellEditDone.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEditDone.emit).toHaveBeenCalledWith(cellArgs); + + expect(grid.cellEditExit.emit).toHaveBeenCalledTimes(1); + expect(grid.cellEditExit.emit).toHaveBeenCalledWith(cellArgs); + + expect(copiedData[2].locations.length).toEqual(1); + }); + + it('igxGrid should emit the correct args when row editing is cancelled', async () => { + const copiedData = cloneArray(DATA2, true); + setupData(copiedData, true); + + spyOn(grid.rowEditEnter, 'emit').and.callThrough(); + spyOn(grid.rowEditExit, 'emit').and.callThrough(); + + const cell = grid.getCellByColumn(2, 'locations'); + const row = grid.gridAPI.get_row_by_index(2); + let initialRowData = { ...cell.row.data }; + + UIInteractions.simulateDoubleClickAndSelectEvent(grid.gridAPI.get_cell_by_index(2, 'locations')); + fixture.detectChanges(); + combo = fixture.componentInstance.combo; + fixture.detectChanges(); + await fixture.whenStable(); + + // TODO ROW addRow + const rowArgs: IGridEditEventArgs = { + primaryKey: row.key, + rowID: row.key, + rowKey: cell.row.key, + rowData: initialRowData, + oldValue: row.data, + owner: grid, + isAddRow: row.addRowUI, + cancel: false, + event: jasmine.anything() as any, + valid: true + }; + + expect(grid.rowEditEnter.emit).toHaveBeenCalledTimes(1); + expect(grid.rowEditEnter.emit).toHaveBeenCalledWith(rowArgs); + expect(row.inEditMode).toBeTruthy(); + expect(cell.editMode).toBeTruthy(); + expect(combo.selection.length).toEqual(3); + + combo.deselect([cell.editValue[0], cell.editValue[1]]); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(grid.data[2].locations.length).toEqual(3); + expect(cell.editValue.length).toEqual(1); + + grid.gridAPI.crudService.endEdit(false); + fixture.detectChanges(); + await fixture.whenStable(); + rowArgs.event = undefined; + expect(row.inEditMode).toBeFalsy(); + expect(cell.editMode).toBeFalsy(); + + initialRowData = { ...cell.row.data }; + rowArgs.newValue = initialRowData; + + delete rowArgs.cancel; + + expect(rowArgs.newValue.locations.length).toEqual(3); + expect(rowArgs.oldValue.locations.length).toEqual(3); + expect(grid.rowEditExit.emit).toHaveBeenCalledTimes(1); + expect(grid.rowEditExit.emit).toHaveBeenCalledWith(rowArgs); + + expect(copiedData[2].locations.length).toEqual(3); + }); + + it('igxGrid should emit the correct args when submitting the row changes', async () => { + const copiedData = cloneArray(DATA2, true); + setupData(copiedData, true); + + spyOn(grid.rowEditEnter, 'emit').and.callThrough(); + spyOn(grid.rowEdit, 'emit').and.callThrough(); + spyOn(grid.rowEditDone, 'emit').and.callThrough(); + spyOn(grid.rowEditExit, 'emit').and.callThrough(); + + const cell = grid.getCellByColumn(2, 'locations'); + const row = grid.gridAPI.get_row_by_index(2); + let initialRowData = { ...cell.row.data }; + + UIInteractions.simulateDoubleClickAndSelectEvent(grid.gridAPI.get_cell_by_index(2, 'locations')); + fixture.detectChanges(); + combo = fixture.componentInstance.combo; + fixture.detectChanges(); + await fixture.whenStable(); + + // TODO ROW addRow + const rowArgs: IGridEditEventArgs = { + primaryKey: row.key, + rowID: row.key, + rowKey: cell.row.key, + rowData: initialRowData, + oldValue: row.data, + owner: grid, + isAddRow: row.addRowUI, + cancel: false, + event: jasmine.anything() as any, + valid: true + }; + + expect(grid.rowEditEnter.emit).toHaveBeenCalledTimes(1); + expect(grid.rowEditEnter.emit).toHaveBeenCalledWith(rowArgs); + expect(row.inEditMode).toBeTruthy(); + expect(cell.editMode).toBeTruthy(); + expect(combo.selection.length).toEqual(3); + + combo.deselect([cell.editValue[0], cell.editValue[1]]); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(grid.data[2].locations.length).toEqual(3); + expect(cell.editValue.length).toEqual(1); + + grid.gridAPI.crudService.endEdit(true); + fixture.detectChanges(); + await fixture.whenStable(); + rowArgs.event = undefined; + expect(row.inEditMode).toBeFalsy(); + expect(cell.editMode).toBeFalsy(); + + initialRowData = { ...cell.row.data }; + rowArgs.newValue = initialRowData; + + expect(rowArgs.newValue.locations.length).toEqual(1); + expect(rowArgs.oldValue.locations.length).toEqual(3); + + expect(grid.rowEdit.emit).toHaveBeenCalledTimes(1); + expect(grid.rowEdit.emit).toHaveBeenCalledWith(rowArgs); + + delete rowArgs.cancel; + rowArgs.rowData = initialRowData; + expect(grid.rowEditDone.emit).toHaveBeenCalledTimes(1); + expect(grid.rowEditDone.emit).toHaveBeenCalledWith(rowArgs); + + expect(grid.rowEditExit.emit).toHaveBeenCalledTimes(1); + expect(grid.rowEditExit.emit).toHaveBeenCalledWith(rowArgs); + + expect(copiedData[2].locations.length).toEqual(1); + }); +}); diff --git a/projects/igniteui-angular/grids/grid/src/grid.pagination.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.pagination.spec.ts new file mode 100644 index 00000000000..190a428e498 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid.pagination.spec.ts @@ -0,0 +1,482 @@ +import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { GridWithUndefinedDataComponent } from '../../../test-utils/grid-samples.spec'; +import { PagingComponent, RemotePagingComponent } from '../../../test-utils/grid-base-components.spec'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { wait } from '../../../test-utils/ui-interactions.spec'; +import { GridFunctions, PAGER_CLASS } from '../../../test-utils/grid-functions.spec'; +import { ControlsFunction, BUTTON_DISABLED_CLASS } from '../../../test-utils/controls-functions.spec'; +import { IgxNumberFilteringOperand } from 'igniteui-angular/core'; + +const verifyGridPager = (fix, rowsCount, firstCellValue, pagerText, buttonsVisibility) => { + const grid = fix.componentInstance.grid; + + expect(grid.getCellByColumn(0, 'ID').value).toMatch(firstCellValue); + expect(grid.rowList.length).toEqual(rowsCount, 'Invalid number of rows initialized'); + + if (pagerText != null) { + expect(grid.nativeElement.querySelector(PAGER_CLASS)).toBeDefined(); + expect(grid.nativeElement.querySelectorAll('igx-select').length).toEqual(1); + expect(grid.nativeElement.querySelector('.igx-page-nav__text').textContent).toMatch(pagerText); + } + if (buttonsVisibility != null && buttonsVisibility.length === 4) { + const pagingButtons = GridFunctions.getPagingButtons(grid.nativeElement); + expect(pagingButtons.length).toEqual(4); + expect(pagingButtons[0].className.includes(BUTTON_DISABLED_CLASS)).toBe(buttonsVisibility[0]); + expect(pagingButtons[1].className.includes(BUTTON_DISABLED_CLASS)).toBe(buttonsVisibility[1]); + expect(pagingButtons[2].className.includes(BUTTON_DISABLED_CLASS)).toBe(buttonsVisibility[2]); + expect(pagingButtons[3].className.includes(BUTTON_DISABLED_CLASS)).toBe(buttonsVisibility[3]); + } +}; + +describe('IgxGrid - Grid Paging #grid', () => { + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + PagingComponent, + GridWithUndefinedDataComponent, + RemotePagingComponent + ] + }).compileComponents(); + })); + + let fix; + let grid; + let paginator; + + describe('General', () => { + + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(PagingComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + paginator = grid.paginator; + })); + + it('should paginate data UI', () => { + fix.detectChanges(); + + expect(paginator).toBeDefined(); + verifyGridPager(fix, 3, '1', '1\xA0of\xA04', [true, true, false, false]); + + // Go to next page + GridFunctions.navigateToNextPage(grid.nativeElement); + fix.detectChanges(); + verifyGridPager(fix, 3, '4', '2\xA0of\xA04', [false, false, false, false]); + + // Go to last page + GridFunctions.navigateToLastPage(grid.nativeElement); + fix.detectChanges(); + verifyGridPager(fix, 1, '10', '4\xA0of\xA04', [false, false, true, true]); + + // Go to previous page + GridFunctions.navigateToPrevPage(grid.nativeElement); + fix.detectChanges(); + verifyGridPager(fix, 3, '7', '3\xA0of\xA04', [false, false, false, false]); + + // Go to first page + GridFunctions.navigateToFirstPage(grid.nativeElement); + fix.detectChanges(); + verifyGridPager(fix, 3, '1', '1\xA0of\xA04', [true, true, false, false]); + }); + + it('should paginate data API', () => { + fix.detectChanges(); + + // Goto page 3 through API and listen for event + spyOn(paginator.pagingDone, 'emit'); + paginator.paginate(2); + + fix.detectChanges(); + + expect(paginator.pagingDone.emit).toHaveBeenCalled(); + verifyGridPager(fix, 3, '7', '3\xA0of\xA04', []); + + // Go to next page + paginator.nextPage(); + fix.detectChanges(); + + expect(paginator.pagingDone.emit).toHaveBeenCalledTimes(2); + expect(paginator.isLastPage).toBe(true); + verifyGridPager(fix, 1, '10', '4\xA0of\xA04', []); + + // Go to next page when last page is selected + paginator.nextPage(); + fix.detectChanges(); + + expect(paginator.isLastPage).toBe(true); + expect(paginator.pagingDone.emit).toHaveBeenCalledTimes(2); + verifyGridPager(fix, 1, '10', '4\xA0of\xA04', []); + + // Go to previous page + paginator.previousPage(); + fix.detectChanges(); + + expect(paginator.pagingDone.emit).toHaveBeenCalledTimes(3); + verifyGridPager(fix, 3, '7', '3\xA0of\xA04', []); + expect(paginator.isLastPage).toBe(false); + expect(paginator.isFirstPage).toBe(false); + + // Go to first page + paginator.paginate(0); + fix.detectChanges(); + + expect(paginator.pagingDone.emit).toHaveBeenCalledTimes(4); + verifyGridPager(fix, 3, '1', '1\xA0of\xA04', []); + expect(paginator.isFirstPage).toBe(true); + + // Go to previous page when first page is selected + paginator.previousPage(); + fix.detectChanges(); + + expect(paginator.pagingDone.emit).toHaveBeenCalledTimes(4); + verifyGridPager(fix, 3, '1', '1\xA0of\xA04', []); + expect(paginator.isFirstPage).toBe(true); + + // Go to negative page number + paginator.paginate(-3); + fix.detectChanges(); + + expect(paginator.pagingDone.emit).toHaveBeenCalledTimes(4); + verifyGridPager(fix, 3, '1', '1\xA0of\xA04', []); + }); + + it('should be able to set totalRecords', () => { + paginator.perPage = 5; + fix.detectChanges(); + + expect(paginator).toBeDefined(); + expect(paginator.perPage).toEqual(5, 'Invalid page size'); + expect(grid.totalRecords).toBe(10); + verifyGridPager(fix, 5, '1', '1\xA0of\xA02', []); + + grid.totalRecords = 4; + fix.detectChanges(); + + expect(paginator.perPage).toEqual(5, 'Invalid page size'); + expect(grid.totalRecords).toBe(4); + verifyGridPager(fix, 4, '1', '1\xA0of\xA01', []); + }); + + + it('change paging settings UI', () => { + fix.detectChanges(); + expect(paginator).toBeDefined(); + expect(paginator.perPage).toEqual(3, 'Invalid page size'); + + verifyGridPager(fix, 3, '1', '1\xA0of\xA04', []); + + // Change page size + GridFunctions.clickOnPageSelectElement(fix); + fix.detectChanges(); + ControlsFunction.clickDropDownItem(fix, 2); + + expect(paginator).toBeDefined(); + expect(paginator.perPage).toEqual(10, 'Invalid page size'); + verifyGridPager(fix, 10, '1', '1\xA0of\xA01', []); + }); + + it('change paging settings API', () => { + fix.detectChanges(); + // Change page size + paginator.perPage = 2; + fix.detectChanges(); + + expect(paginator).toBeDefined(); + expect(paginator.perPage).toEqual(2, 'Invalid page size'); + verifyGridPager(fix, 2, '1', '1\xA0of\xA05', []); + + // Turn off paging + fix.componentInstance.paging = false; + fix.detectChanges(); + + expect(grid.paginator).not.toBeDefined(); + verifyGridPager(fix, 10, '1', null, []); + expect(GridFunctions.getGridPaginator(grid)).toBeNull(); + expect(grid.nativeElement.querySelectorAll('.igx-paginator > select').length).toEqual(0); + }); + + + it('change paging pages per page API', (async () => { + fix.detectChanges(); + grid.height = '300px'; + paginator.perPage = 2; + await wait(); + fix.detectChanges(); + + paginator.page = 1; + await wait(); + fix.detectChanges(); + + expect(grid.paginator).toBeDefined(); + expect(paginator.perPage).toEqual(2, 'Invalid page size'); + verifyGridPager(fix, 2, '3', '2\xA0of\xA05', []); + + // Change page size to be 5 + paginator.perPage = 5; + await wait(); + fix.detectChanges(); + + grid.notifyChanges(true); + fix.detectChanges(); + + let vScrollBar = grid.verticalScrollContainer.getScroll(); + verifyGridPager(fix, 5, '1', '1\xA0of\xA02', [true, true, false, false]); + expect(vScrollBar.scrollHeight).toBeGreaterThanOrEqual(250); + expect(vScrollBar.scrollHeight).toBeLessThanOrEqual(255); + + // Change page size to be 33 + paginator.perPage = 33; + await wait(); + fix.detectChanges(); + vScrollBar = grid.verticalScrollContainer.getScroll(); + // pagingDone should be emitted only if we have a change in the page number + verifyGridPager(fix, 5, '1', '1\xA0of\xA01', [true, true, true, true]); + expect(vScrollBar.scrollHeight).toBeGreaterThanOrEqual(500); + expect(vScrollBar.scrollHeight).toBeLessThanOrEqual(510); + + // Change page size to be negative + paginator.perPage = -7; + await wait(); + fix.detectChanges(); + verifyGridPager(fix, 5, '1', '1\xA0of\xA01', [true, true, true, true]); + expect(vScrollBar.scrollHeight).toBeGreaterThanOrEqual(500); + expect(vScrollBar.scrollHeight).toBeLessThanOrEqual(510); + })); + + it('activate/deactivate paging', () => { + let paginatorEl = GridFunctions.getGridPaginator(grid); + expect(paginatorEl).toBeDefined(); + + fix.componentInstance.paging = !fix.componentInstance.paging; + fix.detectChanges(); + + paginatorEl = GridFunctions.getGridPaginator(grid); + expect(paginatorEl).toBeNull(); + + fix.componentInstance.paging = !fix.componentInstance.paging; + fix.detectChanges(); + + paginatorEl = GridFunctions.getGridPaginator(grid); + expect(paginatorEl).not.toBeNull(); + }); + + it('should change not leave prev page data after scorlling', (async () => { + fix.componentInstance.perPage = 5; + fix.componentInstance.data = fix.componentInstance.data.slice(0, 7); + grid.height = '300px'; + fix.detectChanges(); + + fix.componentInstance.scrollTop(25); + + fix.detectChanges(); + await wait(100); + grid.paginator.paginate(1); + + fix.detectChanges(); + await wait(100); + grid.paginator.paginate(0); + + fix.detectChanges(); + await wait(100); + expect(grid.rowList.first._data).toEqual(grid.data[0]); + })); + + it('should work correct with filtering', () => { + grid.getColumnByName('ID').filterable = true; + fix.detectChanges(); + + // Filter column + grid.filter('ID', 1, IgxNumberFilteringOperand.instance().condition('greaterThan')); + fix.detectChanges(); + verifyGridPager(fix, 3, '2', '1\xA0of\xA03', [true, true, false, false]); + + // Filter column + grid.filter('ID', 1, IgxNumberFilteringOperand.instance().condition('equals')); + fix.detectChanges(); + verifyGridPager(fix, 1, '1', '1\xA0of\xA01', [true, true, true, true]); + + // Reset filters + grid.clearFilter('ID'); + fix.detectChanges(); + verifyGridPager(fix, 3, '1', '1\xA0of\xA04', [true, true, false, false]); + }); + + it('should work correct with crud operations', () => { + grid.primaryKey = 'ID'; + fix.detectChanges(); + + // Delete first row + grid.deleteRow(1); + fix.detectChanges(); + verifyGridPager(fix, 3, '2', '1\xA0of\xA03', [true, true, false, false]); + expect(paginator.totalPages).toBe(3); + + // Delete all rows on first page + grid.deleteRow(2); + grid.deleteRow(3); + grid.deleteRow(4); + fix.detectChanges(); + verifyGridPager(fix, 3, '5', '1\xA0of\xA02', []); + expect(paginator.totalPages).toBe(2); + + // Delete all rows on first page + grid.deleteRow(5); + grid.deleteRow(6); + grid.deleteRow(7); + fix.detectChanges(); + verifyGridPager(fix, 3, '8', '1\xA0of\xA01', [true, true, true, true]); + expect(paginator.totalPages).toBe(1); + + // Add new row + grid.addRow({ ID: 1, Name: 'Test Name', JobTitle: 'Test Job Title' }); + fix.detectChanges(); + verifyGridPager(fix, 3, '8', '1\xA0of\xA02', [true, true, false, false]); + expect(paginator.totalPages).toBe(2); + + paginator.nextPage(); + fix.detectChanges(); + verifyGridPager(fix, 1, '1', '2\xA0of\xA02', []); + + // Add new rows on second page + grid.addRow({ ID: 2, Name: 'Test Name', JobTitle: 'Test Job Title' }); + grid.addRow({ ID: 3, Name: 'Test Name', JobTitle: 'Test Job Title' }); + grid.addRow({ ID: 4, Name: 'Test Name', JobTitle: 'Test Job Title' }); + fix.detectChanges(); + verifyGridPager(fix, 3, '1', '2\xA0of\xA03', [false, false, false, false]); + expect(paginator.totalPages).toBe(3); + + // Go to last page and delete the row + paginator.nextPage(); + fix.detectChanges(); + grid.deleteRow(4); + fix.detectChanges(); + verifyGridPager(fix, 3, '1', '2\xA0of\xA02', [false, false, true, true]); + }); + + it('should not throw when initialized in a grid with % height', () => { + fix.componentInstance.paging = true; + expect(() => { + fix.detectChanges(); + }).not.toThrow(); + }); + + it('"paginate" method should paginate correctly', () => { + const page = (index: number) => paginator.paginate(index); + let desiredPageIndex = 2; + page(2); + fix.detectChanges(); + + expect(paginator.page).toBe(desiredPageIndex); + + // non-existent page, should not paginate + page(-2); + fix.detectChanges(); + expect(paginator.page).toBe(desiredPageIndex); + + // non-existent page, should not paginate + page(666); + fix.detectChanges(); + expect(paginator.page).toBe(desiredPageIndex); + + // first page + desiredPageIndex = 0; + page(desiredPageIndex); + fix.detectChanges(); + expect(paginator.page).toBe(desiredPageIndex); + + // last page + desiredPageIndex = paginator.totalPages - 1; + page(desiredPageIndex); + fix.detectChanges(); + expect(paginator.page).toBe(desiredPageIndex); + + // last page + 1, should not paginate + page(paginator.totalPages); + fix.detectChanges(); + expect(paginator.page).toBe(desiredPageIndex); + }); + + it('"page" property should paginate correctly', () => { + const page = (index: number) => paginator.page = index; + let desiredPageIndex = 2; + page(2); + fix.detectChanges(); + expect(paginator.page).toBe(desiredPageIndex); + + // non-existent page, should not paginate + page(-2); + fix.detectChanges(); + expect(paginator.page).toBe(desiredPageIndex); + + // non-existent page, should not paginate + page(666); + fix.detectChanges(); + expect(paginator.page).toBe(desiredPageIndex); + + // first page + desiredPageIndex = 0; + page(desiredPageIndex); + fix.detectChanges(); + expect(paginator.page).toBe(desiredPageIndex); + + // last page + desiredPageIndex = grid.paginator.totalPages - 1; + page(desiredPageIndex); + fix.detectChanges(); + expect(paginator.page).toBe(desiredPageIndex); + + // last page + 1, should not paginate + page(paginator.totalPages); + fix.detectChanges(); + expect(paginator.page).toBe(desiredPageIndex); + }); + }); + + it('should not throw error when data is undefined', fakeAsync(() => { + let errorMessage = ''; + fix = TestBed.createComponent(GridWithUndefinedDataComponent); + try { + fix.detectChanges(); + } catch (ex) { + errorMessage = ex.message; + } + expect(errorMessage).toBe(''); + grid = fix.componentInstance.grid; + expect(grid.rowList.length).toBe(0); + tick(16); + fix.detectChanges(); + + expect(grid.rowList.length).toBe(5); + })); + + it('paginator should show the exact number of pages when "totalRecords" is not set and "pagingMode" is remote', fakeAsync(() => { + fix = TestBed.createComponent(RemotePagingComponent); + fix.detectChanges(); + tick(); + + grid = fix.componentInstance.grid; + expect(grid.paginator.totalPages).toBe(4); + })); + + it('should get correct rowIndex in remote paging', fakeAsync(() => { + fix = TestBed.createComponent(RemotePagingComponent); + fix.detectChanges(); + tick(); + + grid = fix.componentInstance.grid; + expect(grid.paginator.totalPages).toBe(4); + const page = (index: number) => grid.page = index; + const desiredPageIndex = 2; + page(2); + fix.detectChanges(); + tick(); + expect(grid.page).toBe(desiredPageIndex); + + expect(grid.getRowByIndex(0).cells[1].value).toBe('Debra Morton') + expect(grid.getRowByIndex(0).viewIndex).toBe(6); + })); +}); + diff --git a/projects/igniteui-angular/grids/grid/src/grid.pinning.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.pinning.spec.ts new file mode 100644 index 00000000000..889bf6ac4b3 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid.pinning.spec.ts @@ -0,0 +1,1014 @@ +import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { GridSelectionMode, IgxGridHeaderRowComponent, IgxGridMRLNavigationService, IPinningConfig } from 'igniteui-angular/grids/core'; +import { wait, UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { + CELL_PINNED_CLASS, + GRID_MRL_BLOCK, + GRID_SCROLL_CLASS, + GridFunctions, + GridSelectionFunctions, + GridSummaryFunctions, + HEADER_PINNED_CLASS, + PINNED_SUMMARY +} from '../../../test-utils/grid-functions.spec'; +import { + GridFeaturesComponent, + GridPinningMRLComponent, + MRLTestComponent, + MultiColumnHeadersComponent, + MultiColumnHeadersWithGroupingComponent, + PinningComponent, + PinOnBothSidesInitComponent, + PinOnInitAndSelectionComponent +} from '../../../test-utils/grid-samples.spec'; +import { IgxGridComponent } from './grid.component'; +import { DropPosition } from 'igniteui-angular/grids/core'; +import { clearGridSubs, setupGridScrollDetection } from '../../../test-utils/helper-utils.spec'; +import { ColumnPinningPosition, IgxStringFilteringOperand, SortingDirection } from 'igniteui-angular/core'; + +describe('IgxGrid - Column Pinning #grid', () => { + + const DEBOUNCETIME = 30; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + PinningComponent, + PinOnInitAndSelectionComponent, + GridFeaturesComponent, + MultiColumnHeadersWithGroupingComponent, + GridPinningMRLComponent, + PinOnBothSidesInitComponent + ], + providers: [ + IgxGridMRLNavigationService + ] + }).compileComponents(); + })) + + describe('To Start', () => { + + describe('Initially pinned columns', () => { + + let fix; + let grid: IgxGridComponent; + beforeEach(() => { + fix = TestBed.createComponent(PinOnInitAndSelectionComponent); + grid = fix.componentInstance.grid; + fix.detectChanges(); + }); + + it('should correctly initialize when there are initially pinned columns.', () => { + + // verify pinned/unpinned collections + expect(grid.pinnedColumns.length).toEqual(2); + expect(grid.unpinnedColumns.length).toEqual(9); + + // verify DOM + const firstIndexCell = grid.gridAPI.get_cell_by_index(0, 'CompanyName'); + expect(firstIndexCell.visibleColumnIndex).toEqual(0); + + const lastIndexCell = grid.gridAPI.get_cell_by_index(0, 'ContactName'); + expect(lastIndexCell.visibleColumnIndex).toEqual(1); + expect(GridFunctions.isCellPinned(lastIndexCell)).toBe(true); + + const headers = GridFunctions.getColumnHeaders(fix); + + expect(headers[0].context.column.field).toEqual('CompanyName'); + + expect(headers[1].context.column.field).toEqual('ContactName'); + expect(GridFunctions.isHeaderPinned(headers[1].parent)).toBe(true); + + // verify container widths + GridFunctions.verifyPinnedStartAreaWidth(grid, 400); + GridFunctions.verifyUnpinnedAreaWidth(grid, 400); + }); + + it('should allow pinning/unpinning via the grid API', () => { + + // Unpin column + grid.unpinColumn('CompanyName'); + fix.detectChanges(); + + // verify column is unpinned + expect(grid.pinnedColumns.length).toEqual(1); + expect(grid.unpinnedColumns.length).toEqual(10); + + const col = grid.getColumnByName('CompanyName'); + expect(col.pinned).toBe(false); + expect(col.visibleIndex).toEqual(2); + + // verify DOM + let cell = grid.gridAPI.get_cell_by_index(0, 'CompanyName'); + expect(cell.visibleColumnIndex).toEqual(2); + expect(GridFunctions.isCellPinned(cell)).toBe(false); + + const thirdHeader = GridFunctions.getColumnHeaders(fix)[2]; + + expect(thirdHeader.context.column.field).toEqual('CompanyName'); + expect(GridFunctions.isHeaderPinned(thirdHeader)).toBe(false); + + // verify container widths + GridFunctions.verifyPinnedStartAreaWidth(grid, 200); + GridFunctions.verifyUnpinnedAreaWidth(grid, 600); + + // pin back the column. + grid.pinColumn('CompanyName'); + fix.detectChanges(); + + // verify column is pinned + expect(grid.pinnedColumns.length).toEqual(2); + expect(grid.unpinnedColumns.length).toEqual(9); + + // verify container widths + GridFunctions.verifyPinnedStartAreaWidth(grid, 400); + GridFunctions.verifyUnpinnedAreaWidth(grid, 400); + + expect(col.pinned).toBe(true); + expect(col.visibleIndex).toEqual(1); + + cell = grid.gridAPI.get_cell_by_index(0, 'CompanyName'); + expect(cell.visibleColumnIndex).toEqual(1); + expect(GridFunctions.isCellPinned(cell)).toBe(true); + }); + + it('should allow pinning/unpinning via the column API', () => { + const col = grid.getColumnByName('ID'); + + col.pinned = true; + fix.detectChanges(); + + // verify column is pinned + expect(col.pinned).toBe(true); + expect(col.visibleIndex).toEqual(2); + + expect(grid.pinnedColumns.length).toEqual(3); + expect(grid.unpinnedColumns.length).toEqual(8); + + // verify container widths + GridFunctions.verifyPinnedStartAreaWidth(grid, 600); + GridFunctions.verifyUnpinnedAreaWidth(grid, 200); + + col.pinned = false; + fix.detectChanges(); + + // verify column is unpinned + expect(col.pinned).toBe(false); + expect(col.visibleIndex).toEqual(2); + + expect(grid.pinnedColumns.length).toEqual(2); + expect(grid.unpinnedColumns.length).toEqual(9); + + // verify container widths + GridFunctions.verifyPinnedStartAreaWidth(grid, 400); + GridFunctions.verifyUnpinnedAreaWidth(grid, 400); + }); + + it('on unpinning should restore the original location(index) of the column', () => { + + const col = grid.getColumnByName('ContactName'); + expect(col.index).toEqual(2); + + // unpin + col.pinned = false; + fix.detectChanges(); + + // check props + expect(col.index).toEqual(2); + expect(col.visibleIndex).toEqual(2); + + // check DOM + const thirdHeader = GridFunctions.getColumnHeaders(fix)[2]; + + expect(thirdHeader.context.column.field).toEqual('ContactName'); + expect(GridFunctions.isHeaderPinned(thirdHeader)).toBe(false); + + }); + + it('should pin the column on the last position if the index for the last position is provided', () => { + grid.pinColumn('CompanyName'); + fix.detectChanges(); + + grid.pinColumn('City', 2); + fix.detectChanges(); + + expect(grid.pinnedColumns.length).toEqual(3); + expect(grid.pinnedColumns[2].field).toEqual('City'); + }); + + it('should correctly initialize pinned columns z-index values.', () => { + + const headers = GridFunctions.getColumnHeaders(fix); + + // First two headers are pinned + expect(headers[0].parent.componentInstance.zIndex).toEqual(9999); + expect(headers[1].parent.componentInstance.zIndex).toEqual(9998); + + grid.pinColumn('Region'); + fix.detectChanges(); + + // First three headers are pinned + const secondColumnGroupHeader = GridFunctions.getColumnHeaders(fix)[2]; + expect(secondColumnGroupHeader.parent.componentInstance.zIndex).toEqual(9997); + }); + + it('should not pin/unpin columns which are already pinned/unpinned', () => { + + const pinnedColumnsLength = grid.pinnedColumns.length; + const unpinnedColumnsLength = grid.unpinnedColumns.length; + + let result = grid.pinColumn('CompanyName'); + fix.detectChanges(); + + expect(grid.pinnedColumns.length).toEqual(pinnedColumnsLength); + expect(result).toBe(false); + + result = grid.unpinColumn('City'); + fix.detectChanges(); + + expect(grid.unpinnedColumns.length).toEqual(unpinnedColumnsLength); + expect(result).toBe(false); + }); + + it('should fix column when grid width is 100% and column width is set', fakeAsync(() => { + fix.componentInstance.grid.width = '100%'; + tick(DEBOUNCETIME); + fix.detectChanges(); + + expect(grid.pinnedColumns.length).toEqual(2); + expect(grid.unpinnedColumns.length).toEqual(9); + })); + + it('should allow navigating to/from pinned area', (async () => { + + const cellContactName = grid.gridAPI.get_cell_by_index(0, 'ContactName'); + const range = { + rowStart: cellContactName.row.index, + rowEnd: cellContactName.row.index, + columnStart: cellContactName.visibleColumnIndex, + columnEnd: cellContactName.visibleColumnIndex + }; + grid.selectRange(range); + grid.navigation.activeNode = { row: cellContactName.row.index, column: cellContactName.visibleColumnIndex }; + fix.detectChanges(); + + grid.navigation.dispatchEvent(UIInteractions.getKeyboardEvent('keydown', 'ArrowRight')); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + const cell = grid.gridAPI.get_cell_by_index(0, 'ID'); + expect(cell.active).toBe(true); + expect(cellContactName.active).toBe(false); + + grid.navigation.dispatchEvent(UIInteractions.getKeyboardEvent('keydown', 'ArrowLeft')); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + expect(cell.active).toBe(false); + expect(cellContactName.active).toBe(true); + })); + }); + + describe('Features', () => { + + let fix; + let grid: IgxGridComponent; + + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(GridFeaturesComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + })); + + it('should allow filter pinned columns', () => { + + // Contains filter + grid.filter('ProductName', 'Ignite', IgxStringFilteringOperand.instance().condition('contains'), true); + fix.detectChanges(); + const firstCell = grid.gridAPI.get_cell_by_index(0, 'ID'); + const secondCell = grid.gridAPI.get_cell_by_index(1, 'ID'); + + expect(grid.rowList.length).toEqual(2); + expect(parseInt(GridFunctions.getValueFromCellElement(firstCell), 10)).toEqual(1); + expect(parseInt(GridFunctions.getValueFromCellElement(secondCell), 10)).toEqual(3); + + // Unpin column + grid.unpinColumn('ProductName'); + fix.detectChanges(); + expect(grid.rowList.length).toEqual(2); + expect(parseInt(GridFunctions.getValueFromCellElement(firstCell), 10)).toEqual(1); + expect(parseInt(GridFunctions.getValueFromCellElement(secondCell), 10)).toEqual(3); + }); + + it('should allow sorting pinned columns', () => { + + const currentColumn = 'ProductName'; + const releasedColumn = 'Released'; + + grid.sort({ fieldName: currentColumn, dir: SortingDirection.Asc, ignoreCase: true }); + + fix.detectChanges(); + + expect(grid.getCellByColumn(0, currentColumn).value).toEqual(null); + expect(grid.getCellByColumn(0, releasedColumn).value).toEqual(true); + expect(grid.getCellByColumn(grid.data.length - 1, currentColumn).value).toEqual('Some other item with Script'); + expect(grid.getCellByColumn(grid.data.length - 1, releasedColumn).value).toEqual(null); + + // Unpin column + grid.unpinColumn('ProductName'); + fix.detectChanges(); + + expect(grid.getCellByColumn(0, currentColumn).value).toEqual(null); + expect(grid.getCellByColumn(0, releasedColumn).value).toEqual(true); + expect(grid.getCellByColumn(grid.data.length - 1, currentColumn).value).toEqual('Some other item with Script'); + expect(grid.getCellByColumn(grid.data.length - 1, releasedColumn).value).toEqual(null); + }); + + }); + + describe('', () => { + + let fix; + let grid: IgxGridComponent; + + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(PinningComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + })); + + it('should emit columnPin event and allow changing the insertAtIndex param.', () => { + + spyOn(grid.columnPin, 'emit').and.callThrough(); + + const idCol = grid.getColumnByName('ID'); + const idColIndex = idCol.index; + idCol.pinned = true; + fix.detectChanges(); + expect(grid.columnPin.emit).toHaveBeenCalledTimes(1); + expect(grid.columnPin.emit).toHaveBeenCalledWith({ + column: idCol, + insertAtIndex: 0, + isPinned: false, + cancel: false + }); + expect(idCol.visibleIndex).toEqual(0); + + const cityCol = grid.getColumnByName('City'); + cityCol.pinned = true; + fix.detectChanges(); + expect(grid.columnPin.emit).toHaveBeenCalledTimes(2); + expect(grid.columnPin.emit).toHaveBeenCalledWith({ + column: cityCol, + insertAtIndex: 0, + isPinned: false, + cancel: false + }); + expect(cityCol.visibleIndex).toEqual(0); + + idCol.pinned = false; + fix.detectChanges(); + expect(grid.columnPin.emit).toHaveBeenCalledTimes(3); + expect(grid.columnPin.emit).toHaveBeenCalledWith({ + column: idCol, + insertAtIndex: idColIndex, + isPinned: true, + cancel: false + }); + expect(cityCol.visibleIndex).toEqual(0); + + // check DOM + const headers = GridFunctions.getColumnHeaders(fix); + expect(headers[0].context.column.field).toEqual('City'); + expect(headers[1].context.column.field).toEqual('ID'); + }); + + it('should allow hiding/showing pinned column.', () => { + + const col = grid.getColumnByName('CompanyName'); + col.pinned = true; + fix.detectChanges(); + expect(grid.pinnedColumns.length).toEqual(1); + expect(grid.unpinnedColumns.length).toEqual(9); + + col.hidden = true; + fix.detectChanges(); + + expect(grid.pinnedColumns.length).toEqual(0); + expect(grid.unpinnedColumns.length).toEqual(9); + + let firstHeader = GridFunctions.getColumnHeaders(fix)[0]; + + expect(firstHeader.context.column.field).toEqual('ID'); + expect(GridFunctions.isHeaderPinned(firstHeader)).toBe(false); + + col.hidden = false; + fix.detectChanges(); + + expect(grid.pinnedColumns.length).toEqual(1); + expect(grid.unpinnedColumns.length).toEqual(9); + + firstHeader = GridFunctions.getColumnHeaders(fix)[0]; + + expect(firstHeader.context.column.field).toEqual('CompanyName'); + expect(GridFunctions.isHeaderPinned(firstHeader.parent)).toBe(true); + }); + + it('should allow pinning a hidden column.', () => { + + const col = grid.getColumnByName('CompanyName'); + + col.hidden = true; + col.pinned = true; + fix.detectChanges(); + + expect(grid.pinnedColumns.length).toEqual(0); + expect(grid.unpinnedColumns.length).toEqual(9); + + col.hidden = false; + fix.detectChanges(); + + expect(grid.pinnedColumns.length).toEqual(1); + expect(grid.unpinnedColumns.length).toEqual(9); + }); + + it('should allow hiding columns in the unpinned area.', () => { + + const col1 = grid.getColumnByName('CompanyName'); + const col2 = grid.getColumnByName('ID'); + + col1.pinned = true; + col2.hidden = true; + fix.detectChanges(); + + expect(grid.pinnedColumns.length).toEqual(1); + expect(grid.unpinnedColumns.length).toEqual(8); + + const headers = GridFunctions.getColumnHeaders(fix); + + expect(headers[0].context.column.field).toEqual('CompanyName'); + expect(headers[1].context.column.field).toEqual('ContactName'); + }); + + + it('should not reject pinning a column if unpinned area width is less than 20% of the grid width', () => { + + grid.columns.forEach((column) => { + switch (column.index) { + case 0: + case 1: + case 4: + case 6: + column.pinned = true; + } + }); + + fix.detectChanges(); + + grid.columns.forEach((column) => { + switch (column.index) { + case 0: + case 1: + case 4: + case 6: + expect(column.pinned).toBe(true); + } + }); + }); + }); + }); + + describe('To End', () => { + let fix; + let grid: IgxGridComponent; + const pinningConfig: IPinningConfig = { columns: ColumnPinningPosition.End }; + + describe('', () => { + + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(PinOnInitAndSelectionComponent); + fix.componentInstance.grid.pinning = pinningConfig; + fix.detectChanges(); + grid = fix.componentInstance.grid; + fix.detectChanges(); + })); + + it('should correctly initialize when there are initially pinned columns.', () => { + + const firstPinnedIndex = grid.unpinnedColumns.length; + const secondPinnedIndex = grid.unpinnedColumns.length + 1; + // verify pinned/unpinned collections + expect(grid.pinnedColumns.length).toEqual(2); + expect(grid.unpinnedColumns.length).toEqual(9); + + // verify DOM + const firstIndexCell = grid.gridAPI.get_cell_by_index(0, 'CompanyName'); + expect(firstIndexCell.visibleColumnIndex).toEqual(firstPinnedIndex); + expect(GridFunctions.isCellPinned(firstIndexCell)).toBe(true); + + const lastIndexCell = grid.gridAPI.get_cell_by_index(0, 'ContactName'); + expect(lastIndexCell.visibleColumnIndex).toEqual(secondPinnedIndex); + + // const headers = GridFunctions.getColumnHeaders(fix); + const headers = grid.headerCellList; + const penultimateColumnHeader = headers[headers.length - 2]; + const lastColumnHeader = headers[headers.length - 1]; + expect(penultimateColumnHeader.column.field).toEqual('CompanyName'); + + expect(lastColumnHeader.column.field).toEqual('ContactName'); + + // verify container widths + GridFunctions.verifyPinnedEndAreaWidth(grid, 400); + GridFunctions.verifyUnpinnedAreaWidth(grid, 400); + }); + + it('should allow pinning/unpinning via the grid API', () => { + + // Unpin column + grid.unpinColumn('CompanyName'); + fix.detectChanges(); + + // verify column is unpinned + expect(grid.pinnedColumns.length).toEqual(1); + expect(grid.unpinnedColumns.length).toEqual(10); + + const col = grid.getColumnByName('CompanyName'); + expect(col.pinned).toBe(false); + expect(col.visibleIndex).toEqual(1); + + // verify DOM + let cell = grid.gridAPI.get_cell_by_index(0, 'CompanyName'); + expect(cell.visibleColumnIndex).toEqual(1); + expect(GridFunctions.isCellPinned(cell)).toBe(false); + + const secondHeader = GridFunctions.getColumnHeaders(fix)[1]; + + expect(secondHeader.context.column.field).toEqual('CompanyName'); + expect(GridFunctions.isHeaderPinned(secondHeader)).toBe(false); + + // verify container widths + GridFunctions.verifyPinnedEndAreaWidth(grid, 200); + GridFunctions.verifyUnpinnedAreaWidth(grid, 600); + + // pin back the column. + grid.pinColumn('CompanyName'); + fix.detectChanges(); + + // verify column is pinned + expect(grid.pinnedColumns.length).toEqual(2); + expect(grid.unpinnedColumns.length).toEqual(9); + + // verify container widths + GridFunctions.verifyPinnedEndAreaWidth(grid, 400); + GridFunctions.verifyUnpinnedAreaWidth(grid, 400); + + expect(col.pinned).toBe(true); + expect(col.visibleIndex).toEqual(grid.unpinnedColumns.length + 1); + + cell = grid.gridAPI.get_cell_by_index(0, 'CompanyName'); + expect(cell.visibleColumnIndex).toEqual(grid.unpinnedColumns.length + 1); + expect(GridFunctions.isCellPinned(cell)).toBe(true); + }); + + it('should correctly pin column to right when row selectors are enabled.', () => { + grid.rowSelection = GridSelectionMode.multiple; + fix.detectChanges(); + + // check row DOM + const row = grid.gridAPI.get_row_by_index(0).nativeElement; + + GridSelectionFunctions.verifyRowHasCheckbox(row); + expect(GridFunctions.getRowDisplayContainer(fix, 0)).toBeDefined(); + + // check scrollbar DOM + const scrBarStartSection = fix.debugElement.query(By.css(`${GRID_SCROLL_CLASS}-start`)); + const scrBarMainSection = fix.debugElement.query(By.css(`${GRID_SCROLL_CLASS}-main`)); + const scrBarEndSection = fix.debugElement.query(By.css(`${GRID_SCROLL_CLASS}-end`)); + + // The default pinned-border-width in px + expect(scrBarStartSection.nativeElement.offsetWidth).toEqual(grid.featureColumnsWidth()); + + GridFunctions.verifyPinnedEndAreaWidth(grid, scrBarEndSection.nativeElement.offsetWidth); + GridFunctions.verifyUnpinnedAreaWidth(grid, scrBarMainSection.nativeElement.offsetWidth, false); + }); + + it('should pin an unpinned column when drag/drop it among pinned columns.', fakeAsync(() => { + + // move 'ID' column to the pinned area + grid.moveColumn(grid.getColumnByName('ID'), grid.getColumnByName('ContactName'), DropPosition.BeforeDropTarget); + tick(); + fix.detectChanges(); + + // verify column is pinned at the correct place + expect(grid.pinnedColumns[0].field).toEqual('CompanyName'); + expect(grid.pinnedColumns[1].field).toEqual('ID'); + expect(grid.pinnedColumns[2].field).toEqual('ContactName'); + expect(grid.getColumnByName('ID').pinned).toBeTruthy(); + })); + + it('should correctly pin columns with their summaries to end.', () => { + + grid.columns.forEach(col => { + if (col.field === 'CompanyName' || col.field === 'ContactName') { + col.hasSummary = true; + } + }); + fix.detectChanges(); + + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 9, + ['Count'], ['27']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 10, + ['Count'], ['27']); + + const pinnedSummaryCells = GridSummaryFunctions.getRootPinnedSummaryCells(fix); + expect(pinnedSummaryCells[0].classes[`${PINNED_SUMMARY}-first`]) + .toBeTruthy(); + expect(pinnedSummaryCells[1].classes[`${PINNED_SUMMARY}-first`]) + .toBeFalsy(); + }); + + it('should allow navigating to/from pinned area', (async () => { + setupGridScrollDetection(fix, grid); + const cellCompanyName = grid.gridAPI.get_cell_by_index(0, 'CompanyName'); + const range = { rowStart: 0, rowEnd: 0, columnStart: 9, columnEnd: 9 }; + grid.selectRange(range); + grid.navigation.activeNode = { row: 0, column: 9 }; + fix.detectChanges(); + expect(cellCompanyName.active).toBe(true); + + grid.navigation.dispatchEvent(UIInteractions.getKeyboardEvent('keydown', 'ArrowLeft')); + await wait(DEBOUNCETIME); + fix.detectChanges(); + const cellFax = grid.gridAPI.get_cell_by_index(0, 'Fax'); + expect(cellFax.active).toBe(true); + expect(cellCompanyName.active).toBe(false); + + grid.navigation.dispatchEvent(UIInteractions.getKeyboardEvent('keydown', 'ArrowRight')); + await wait(DEBOUNCETIME); + fix.detectChanges(); + expect(cellFax.active).toBe(false); + expect(cellCompanyName.active).toBe(true); + clearGridSubs(); + })); + + it('should allow navigating to/from pinned area using Ctrl+Left/Right', (async () => { + + const cellCompanyName = grid.gridAPI.get_cell_by_index(0, 'CompanyName'); + const range = { rowStart: 0, rowEnd: 0, columnStart: 9, columnEnd: 9 }; + grid.selectRange(range); + grid.navigation.activeNode = { row: 0, column: 9 }; + fix.detectChanges(); + expect(cellCompanyName.active).toBe(true); + + grid.navigation.dispatchEvent(UIInteractions.getKeyboardEvent('keydown', 'ArrowLeft', false, false, true)); + await wait(DEBOUNCETIME); + fix.detectChanges(); + const cell = grid.gridAPI.get_cell_by_index(0, 'ID'); + expect(cell.active).toBe(true); + expect(cellCompanyName.active).toBe(false); + + grid.navigation.dispatchEvent(UIInteractions.getKeyboardEvent('keydown', 'ArrowRight', false, false, true)); + await wait(DEBOUNCETIME); + fix.detectChanges(); + const cellContactName = grid.gridAPI.get_cell_by_index(0, 'ContactName'); + expect(cell.active).toBe(false); + expect(cellContactName.active).toBe(true); + })); + }); + + describe('MRL/MCH', () => { + it('should correctly pin column groups to end.', fakeAsync(() => { + + fix = TestBed.createComponent(MultiColumnHeadersWithGroupingComponent); + fix.componentInstance.isPinned = true; + fix.componentInstance.grid.pinning = pinningConfig; + fix.detectChanges(); + grid = fix.componentInstance.grid; + + const pinnedCols = grid.pinnedColumns.filter(x => !x.columnGroup); + expect(pinnedCols.length).toBe(3); + + expect(grid.getColumnByName('CompanyName').isFirstPinned).toBeTruthy(); + const row = grid.gridAPI.get_row_by_index(0).nativeElement; + // check cells are rendered after main display container and have left offset + const headerRowDisplayContainer = fix.debugElement.query(By.directive(IgxGridHeaderRowComponent)).nativeElement.querySelector(".igx-display-container"); + const displayContainerRect = headerRowDisplayContainer.getBoundingClientRect(); + let xAxis = displayContainerRect.x + displayContainerRect.width; + for (let i = 0; i <= pinnedCols.length - 1; i++) { + const elem = row.children[i + 1]; + const rect = elem.getBoundingClientRect(); + expect(rect.x).toBe(xAxis); + xAxis += rect.width; + } + + // check correct headers have left border + const pinnedHeaders = grid.headerGroupsList.filter(group => group.isPinned); + expect(pinnedHeaders[0].nativeElement.querySelector('[aria-label="General Information"]')).not.toBeNull(); + expect(pinnedHeaders[1].column.field).toBe('CompanyName'); + })); + + it('should correctly pin multi-row-layouts to end.', fakeAsync(() => { + + fix = TestBed.createComponent(GridPinningMRLComponent); + fix.componentInstance.grid.pinning = pinningConfig; + fix.detectChanges(); + grid = fix.componentInstance.grid; + // check row DOM + const row = grid.gridAPI.get_row_by_index(0).nativeElement; + expect(GridFunctions.getRowDisplayContainer(fix, 0)).toBeTruthy(); + + const headerRowdisplayContainer = fix.debugElement.query(By.directive(IgxGridHeaderRowComponent)).nativeElement.querySelector(".igx-display-container"); + const displayContainerRect = headerRowdisplayContainer.getBoundingClientRect(); + const xAxis = displayContainerRect.x + displayContainerRect.width; + + expect(row.children[1].classList.contains(`${CELL_PINNED_CLASS}-first`)).toBeTruthy(); + expect(row.children[1].classList.contains(GRID_MRL_BLOCK)).toBeTruthy(); + expect(row.children[1].getBoundingClientRect().x).toEqual(xAxis); + + // check correct headers have left border + const firstPinnedHeader = grid.headerGroupsList.find(group => group.isPinned); + // The first child of the header is the
    wrapping the MRL block + expect(firstPinnedHeader.nativeElement.firstElementChild.classList.contains(GRID_MRL_BLOCK)).toBeTrue(); + expect(firstPinnedHeader.nativeElement.firstElementChild.classList.contains(`${HEADER_PINNED_CLASS}-first`)).toBeTrue(); + })); + + it('should correctly add pinned columns to the right of the already fixed one', () => { + fix = TestBed.createComponent(GridPinningMRLComponent); + fix.componentInstance.grid.pinning = { columns: ColumnPinningPosition.Start }; + fix.detectChanges(); + grid = fix.componentInstance.grid; + grid.unpinColumn('ID'); + fix.detectChanges(); + + grid.pinColumn('Country'); + grid.pinColumn('ID'); + fix.detectChanges(); + expect(grid.pinnedColumns).toBeTruthy(); + expect(grid.pinnedColumns[1].field).toBe('Country'); + expect(grid.pinnedColumns[6].field).toBe('ID'); + }); + }); + }); + + describe('Both', () => { + let fix; + let grid: IgxGridComponent; + beforeEach(fakeAsync(() => { + // ContactName pinned to start, CompanyName pinned to end + fix = TestBed.createComponent(PinOnBothSidesInitComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + })); + + it('should correctly initialize when there are initially pinned columns.', () => { + + // verify pinned/unpinned collections + expect(grid.pinnedColumns.length).toEqual(2); + expect(grid.pinnedStartColumns.length).toEqual(1); + expect(grid.pinnedEndColumns.length).toEqual(1); + expect(grid.unpinnedColumns.length).toEqual(9); + + // verify DOM + // ContactName first, CompanyName last + const companyNameCell = grid.gridAPI.get_cell_by_index(0, 'CompanyName'); + expect(companyNameCell.visibleColumnIndex) + .toEqual(grid.pinnedStartColumns.length + grid.unpinnedColumns.length); + expect(GridFunctions.isCellPinned(companyNameCell)).toBe(true); + + const contactNameCell = grid.gridAPI.get_cell_by_index(0, 'ContactName'); + expect(contactNameCell.visibleColumnIndex).toEqual(0); + expect(GridFunctions.isCellPinned(contactNameCell)).toBe(true); + + const headers = grid.headerCellList; + const lastColumnHeader = headers[headers.length - 1]; + const firstColumnHeader = headers[0]; + expect(lastColumnHeader.column.field).toEqual('CompanyName'); + expect(firstColumnHeader.column.field).toEqual('ContactName'); + + // verify container widths + GridFunctions.verifyPinnedStartAreaWidth(grid, 200); + GridFunctions.verifyPinnedEndAreaWidth(grid, 200); + GridFunctions.verifyUnpinnedAreaWidth(grid, 400); + }); + + it('should allow pinning/unpinning via the grid API', () => { + const col = grid.getColumnByName('ID'); + expect(col.pinned).toBe(false); + expect(col.visibleIndex).toEqual(1); + + // pin ID to end, after CompanyName + grid.pinColumn('ID', null, ColumnPinningPosition.End); + fix.detectChanges(); + + // verify column is pinned to end + expect(grid.pinnedColumns.length).toEqual(3); + expect(grid.pinnedStartColumns.length).toEqual(1); + expect(grid.pinnedEndColumns.length).toEqual(2); + expect(grid.unpinnedColumns.length).toEqual(8); + + // verify container widths + GridFunctions.verifyPinnedStartAreaWidth(grid, 200); + GridFunctions.verifyPinnedEndAreaWidth(grid, 400); + GridFunctions.verifyUnpinnedAreaWidth(grid, 200); + + expect(col.pinned).toBe(true); + expect(col.visibleIndex) + .toEqual(grid.pinnedStartColumns.length + grid.unpinnedColumns.length + 1); + expect(col.pinningPosition).toBe(ColumnPinningPosition.End); + + const cell = grid.gridAPI.get_cell_by_index(0, 'ID'); + expect(cell.visibleColumnIndex) + .toEqual(grid.pinnedStartColumns.length + grid.unpinnedColumns.length + 1); + expect(GridFunctions.isCellPinned(cell)).toBe(true); + + // unpin ID + grid.unpinColumn('ID'); + fix.detectChanges(); + + // verify column is unpinned + expect(grid.pinnedColumns.length).toEqual(2); + expect(grid.pinnedStartColumns.length).toEqual(1); + expect(grid.pinnedEndColumns.length).toEqual(1); + expect(grid.unpinnedColumns.length).toEqual(9); + + expect(col.pinned).toBe(false); + expect(col.visibleIndex).toEqual(1); + + // verify container widths + GridFunctions.verifyPinnedStartAreaWidth(grid, 200); + GridFunctions.verifyPinnedEndAreaWidth(grid, 200); + GridFunctions.verifyUnpinnedAreaWidth(grid, 400); + }); + + it('should pin an unpinned column when drag/drop it among pinned columns.', fakeAsync(() => { + // move 'ID' column to the right pinned area, before CompanyName + grid.moveColumn(grid.getColumnByName('ID'), grid.getColumnByName('CompanyName'), DropPosition.BeforeDropTarget); + tick(); + fix.detectChanges(); + + // verify column is pinned at the correct place + expect(grid.pinnedEndColumns[0].field).toEqual('ID'); + expect(grid.pinnedEndColumns[1].field).toEqual('CompanyName'); + expect(grid.getColumnByName('ID').pinned).toBeTruthy(); + // move ID to unpinned area + grid.moveColumn(grid.getColumnByName('ID'), grid.getColumnByName('ContactTitle'), DropPosition.AfterDropTarget); + tick(); + fix.detectChanges(); + + // verify column is unpinned at the correct place + expect(grid.unpinnedColumns[0].field).toEqual('ContactTitle'); + expect(grid.unpinnedColumns[1].field).toEqual('ID'); + expect(grid.getColumnByName('ID').pinned).toBeFalsy(); + + // move 'ID' column to the left pinned area, before ContractName + grid.moveColumn(grid.getColumnByName('ID'), grid.getColumnByName('ContactName'), DropPosition.BeforeDropTarget); + tick(); + fix.detectChanges(); + + // verify column is pinned at the correct place + expect(grid.pinnedStartColumns[0].field).toEqual('ID'); + expect(grid.pinnedStartColumns[1].field).toEqual('ContactName'); + expect(grid.getColumnByName('ID').pinned).toBeTruthy(); + })); + + it('should allow navigating to/from pinned areas', (async () => { + setupGridScrollDetection(fix, grid); + + // navigate from right pinned area into unpinned and back + const cellCompanyName = grid.gridAPI.get_cell_by_index(0, 'CompanyName'); + grid.navigation.activeNode = { row: 0, column: 10 }; + fix.detectChanges(); + expect(cellCompanyName.active).toBe(true); + + grid.navigation.dispatchEvent(UIInteractions.getKeyboardEvent('keydown', 'ArrowLeft')); + await wait(DEBOUNCETIME); + fix.detectChanges(); + const cellFax = grid.gridAPI.get_cell_by_index(0, 'Fax'); + expect(cellFax.active).toBe(true); + expect(cellCompanyName.active).toBe(false); + + grid.navigation.dispatchEvent(UIInteractions.getKeyboardEvent('keydown', 'ArrowRight')); + await wait(DEBOUNCETIME); + fix.detectChanges(); + expect(cellFax.active).toBe(false); + expect(cellCompanyName.active).toBe(true); + + // navigate from left pinned area into unpinned and back + grid.navigation.activeNode = { row: 0, column: 0 }; + fix.detectChanges(); + expect(grid.getCellByColumn(0, "ContactName").active).toBe(true); + + grid.navigation.dispatchEvent(UIInteractions.getKeyboardEvent('keydown', 'ArrowRight')); + await wait(DEBOUNCETIME); + fix.detectChanges(); + const cellID = grid.gridAPI.get_cell_by_index(0, 'ID'); + expect(grid.getCellByColumn(0, "ContactName").active).toBe(false); + expect(cellID.active).toBe(true); + + grid.navigation.dispatchEvent(UIInteractions.getKeyboardEvent('keydown', 'ArrowLeft')); + await wait(DEBOUNCETIME); + fix.detectChanges(); + expect(grid.getCellByColumn(0, "ContactName").active).toBe(true); + expect(cellID.active).toBe(false); + + clearGridSubs(); + })); + + it('should correctly pin column groups to both sides.', () => { + fix = TestBed.createComponent(MultiColumnHeadersComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + + // 'General Information' & 'Address Information' + const rootGroups = grid.columns.filter(x => x.columnGroup && x.level === 0); + + //'General Information' to start + rootGroups[0].pin(null, ColumnPinningPosition.Start); + fix.detectChanges(); + //'Address Information' to end + rootGroups[1].pin(null, ColumnPinningPosition.End); + fix.detectChanges(); + + const pinnedCols = grid.pinnedColumns.filter(x => !x.columnGroup); + expect(pinnedCols.length).toBe(7); + + const pinnedStart = grid.pinnedStartColumns.filter(x => !x.columnGroup); + expect(pinnedStart.length).toBe(3); + + const pinnedEnd = grid.pinnedEndColumns.filter(x => !x.columnGroup); + expect(pinnedEnd.length).toBe(4); + + const unpinned = grid.unpinnedColumns.filter(x => !x.columnGroup); + expect(unpinned.length).toBe(2); + + expect(grid.getColumnByName('Country').isFirstPinned).toBeTruthy(); + expect(grid.getColumnByName('ContactTitle').isLastPinned).toBeTruthy(); + const row = grid.gridAPI.get_row_by_index(0).nativeElement; + fix.detectChanges(); + // check pinnedEnd cells are rendered after main display container + const displayContainerBoundingBox = row.querySelector('igx-display-container').getBoundingClientRect(); + let initialStart = displayContainerBoundingBox.x + displayContainerBoundingBox.width; + for (let i = pinnedStart.length; i <= pinnedStart.length + pinnedEnd.length - 1; i++) { + const elem = row.children[i + 1]; + const rect = elem.getBoundingClientRect(); + expect(rect.x).toBe(initialStart); + initialStart += rect.width + } + + // check pinnedStart cells are rendered before main display container + initialStart = displayContainerBoundingBox.x; + for (let i = pinnedStart.length - 1; i >= 0; i--) { + const elem = row.children[i]; + const rect = elem.getBoundingClientRect(); + expect(rect.x + rect.width).toBe(initialStart); + initialStart -= rect.width; + } + + // check correct headers are pinned and in correct order. + const pinnedHeaders = grid.headerGroupsList.filter(group => group.isPinned); + expect(pinnedHeaders.length).toBe(10); + expect(pinnedHeaders.map(x => x.column.header || x.column.field)) + .toEqual(['General Information', 'CompanyName', 'Person Details', + 'ContactName', 'ContactTitle', 'Address Information', + 'Country', 'Region', 'City', 'Address']); + + }); + + it('should correctly pin multi-row-layouts to both sides.', fakeAsync(() => { + fix = TestBed.createComponent(MRLTestComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + grid.width = "1500px"; + fix.detectChanges(); + + // ['group1', 'group2', 'group3'] + const rootMRLGroups = grid.columns.filter(x => x.columnLayout && x.level === 0); + + // pin group1 -> left, group2 -> end + rootMRLGroups[0].pin(null, ColumnPinningPosition.Start); + fix.detectChanges(); + rootMRLGroups[1].pin(null, ColumnPinningPosition.End); + fix.detectChanges(); + + // check collections + const pinnedCols = grid.pinnedColumns.filter(x => !x.columnGroup); + expect(pinnedCols.length).toBe(7); + + const pinnedStart = grid.pinnedStartColumns.filter(x => !x.columnGroup); + expect(pinnedStart.length).toBe(4); + + const pinnedEnd = grid.pinnedEndColumns.filter(x => !x.columnGroup); + expect(pinnedEnd.length).toBe(3); + + const unpinned = grid.unpinnedColumns.filter(x => !x.columnGroup); + expect(unpinned.length).toBe(3); + + // check visible indexes + expect(rootMRLGroups.map(x => x.visibleIndex)).toEqual([0, 2, 1]) + })); + }); +}); diff --git a/projects/igniteui-angular/grids/grid/src/grid.pipes.ts b/projects/igniteui-angular/grids/grid/src/grid.pipes.ts new file mode 100644 index 00000000000..a9adaae6fc0 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid.pipes.ts @@ -0,0 +1,221 @@ +import { inject, Pipe, PipeTransform } from '@angular/core'; +import { IGridSortingStrategy, IGridGroupingStrategy, cloneArray, DataUtil, FilteringExpressionsTree, FilterUtil, IFilteringExpressionsTree, IFilteringStrategy, IGridMergeStrategy, IGroupByExpandState, IGroupingExpression, ISortingExpression, IGroupByResult, ColumnType, IMergeByResult } from 'igniteui-angular/core'; +import { GridCellMergeMode, RowPinningPosition, GridType, IGX_GRID_BASE } from 'igniteui-angular/grids/core'; + +/** + * @hidden + */ +@Pipe({ + name: 'gridSort', + standalone: true +}) +export class IgxGridSortingPipe implements PipeTransform { + private grid = inject(IGX_GRID_BASE); + + public transform(collection: any[], sortExpressions: ISortingExpression[], groupExpressions: IGroupingExpression[], sorting: IGridSortingStrategy, + id: string, pipeTrigger: number, pinned?): any[] { + let result: any[]; + const expressions = groupExpressions.concat(sortExpressions); + if (!expressions.length) { + result = collection; + } else { + result = DataUtil.sort(cloneArray(collection), expressions, sorting, this.grid); + } + this.grid.setFilteredSortedData(result, pinned); + + return result; + } +} + +/** + * @hidden + */ +@Pipe({ + name: 'gridGroupBy', + standalone: true +}) +export class IgxGridGroupingPipe implements PipeTransform { + private grid = inject(IGX_GRID_BASE); + + + public transform(collection: any[], expression: IGroupingExpression | IGroupingExpression[], + expansion: IGroupByExpandState | IGroupByExpandState[], + groupingStrategy: IGridGroupingStrategy, defaultExpanded: boolean, + id: string, groupsRecords: any[], _pipeTrigger: number): IGroupByResult { + + const state = { expressions: [], expansion: [], defaultExpanded }; + state.expressions = this.grid.groupingExpressions; + let result: IGroupByResult; + const fullResult: IGroupByResult = { data: [], metadata: [] }; + + if (!state.expressions.length) { + // empty the array without changing reference + groupsRecords.splice(0, groupsRecords.length); + result = { + data: collection, + metadata: collection + }; + } else { + state.expansion = this.grid.groupingExpansionState; + state.defaultExpanded = this.grid.groupsExpanded; + result = DataUtil.group(cloneArray(collection), state, groupingStrategy, this.grid, groupsRecords, fullResult); + } + this.grid.groupingFlatResult = result.data; + this.grid.groupingResult = fullResult.data; + this.grid.groupingMetadata = fullResult.metadata; + return result; + } +} + +@Pipe({ + name: 'gridCellMerge', + standalone: true +}) +export class IgxGridCellMergePipe implements PipeTransform { + + private grid = inject(IGX_GRID_BASE); + + public transform(collection: any, colsToMerge: ColumnType[], mergeMode: GridCellMergeMode, mergeStrategy: IGridMergeStrategy, _pipeTrigger: number) { + if (colsToMerge.length === 0) { + return collection; + } + const result = DataUtil.merge(collection, colsToMerge, mergeStrategy, [], this.grid); + return result; + } +} + +@Pipe({ + name: 'gridUnmergeActive', + standalone: true +}) +export class IgxGridUnmergeActivePipe implements PipeTransform { + + private grid = inject(IGX_GRID_BASE); + + public transform(collection: any, colsToMerge: ColumnType[], activeRowIndexes: number[], pinned: boolean, _pipeTrigger: number) { + if (colsToMerge.length === 0) { + return collection; + } + if (this.grid.hasPinnedRecords && !pinned && this.grid.pinning.rows !== RowPinningPosition.Bottom) { + activeRowIndexes = activeRowIndexes.map(x => x - this.grid.pinnedRecordsCount); + } + activeRowIndexes = Array.from(new Set(activeRowIndexes)).filter(x => !isNaN(x)); + const rootsToUpdate = []; + activeRowIndexes.forEach(index => { + const target = collection[index]; + if (target && target.cellMergeMeta) { + colsToMerge.forEach(col => { + const colMeta = target.cellMergeMeta.get(col.field); + const root = colMeta.root || (colMeta.rowSpan > 1 ? target : null); + if (root) { + rootsToUpdate.push(root); + } + }); + } + }); + const uniqueRoots = Array.from(new Set(rootsToUpdate)); + if (uniqueRoots.length === 0) { + // if nothing to update, return + return collection; + } + + let result = cloneArray(collection) as any; + uniqueRoots.forEach(x => { + const index = collection.indexOf(x); + const colKeys = [...x.cellMergeMeta.keys()]; + const cols = colsToMerge.filter(col => colKeys.indexOf(col.field) !== -1); + for (const col of cols) { + const childData = x.cellMergeMeta.get(col.field).childRecords; + const childRecs = childData.map(rec => rec.recordRef); + if(childRecs.length === 0) { + // nothing to unmerge + continue; + } + const unmergedData = DataUtil.merge([x.recordRef, ...childRecs], [col], this.grid.mergeStrategy, activeRowIndexes.map(ri => ri - index), this.grid); + for (let i = 0; i < unmergedData.length; i++) { + const unmergedRec = unmergedData[i]; + const origRecord = result[index + i]; + if (unmergedRec.cellMergeMeta?.get(col.field)) { + // clone of object, since we don't want to pollute the original fully merged collection. + const objCopy = { + recordRef: origRecord.recordRef, + ghostRecord: origRecord.ghostRecord, + cellMergeMeta: new Map(origRecord.cellMergeMeta.entries()) + }; + // update copy with new meta from unmerged data record, but just for this column + objCopy.cellMergeMeta?.set(col.field, unmergedRec.cellMergeMeta.get(col.field)); + result[index + i] = objCopy; + } else { + // this is the unmerged record, with no merge metadata + result[index + i] = unmergedRec; + } + } + } + }); + return result; + } +} + +/** + * @hidden + */ +@Pipe({ + name: 'gridPaging', + standalone: true +}) +export class IgxGridPagingPipe implements PipeTransform { + private grid = inject(IGX_GRID_BASE); + + + public transform(collection: IGroupByResult, enabled: boolean, page = 0, perPage = 15, _: number): IGroupByResult { + if (!enabled || this.grid.pagingMode !== 'local') { + return collection; + } + const state = { + index: page, + recordsPerPage: perPage + }; + const total = this.grid._totalRecords >= 0 ? this.grid._totalRecords : collection.data?.length; + DataUtil.correctPagingState(state, total); + + const result = { + data: DataUtil.page(cloneArray(collection.data), state, total), + metadata: DataUtil.page(cloneArray(collection.metadata), state, total) + }; + if (this.grid.page !== state.index) { + this.grid.page = state.index; + } + this.grid.pagingState = state; + return result; + } +} + +/** + * @hidden + */ +@Pipe({ + name: 'gridFiltering', + standalone: true +}) +export class IgxGridFilteringPipe implements PipeTransform { + private grid = inject(IGX_GRID_BASE); + + + public transform(collection: any[], expressionsTree: IFilteringExpressionsTree, + filterStrategy: IFilteringStrategy, + advancedExpressionsTree: IFilteringExpressionsTree, id: string, pipeTrigger: number, filteringPipeTrigger: number, pinned?) { + const state = { + expressionsTree, + strategy: filterStrategy, + advancedExpressionsTree + }; + + if (FilteringExpressionsTree.empty(state.expressionsTree) && FilteringExpressionsTree.empty(state.advancedExpressionsTree)) { + return collection; + } + + const result = FilterUtil.filter(cloneArray(collection), state, this.grid); + this.grid.setFilteredData(result, pinned); + return result; + } +} diff --git a/projects/igniteui-angular/grids/grid/src/grid.search.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.search.spec.ts new file mode 100644 index 00000000000..05b706b6dc8 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid.search.spec.ts @@ -0,0 +1,1253 @@ +import { ComponentFixture, TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { IgxGridComponent } from './public_api'; +import { BasicGridSearchComponent } from '../../../test-utils/grid-base-components.spec'; +import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; +import { GridWithAvatarComponent, GroupableGridSearchComponent, ScrollableGridSearchComponent } from '../../../test-utils/grid-samples.spec'; +import { IForOfState } from 'igniteui-angular/directives'; +import { wait, UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { clearGridSubs, setupGridScrollDetection } from '../../../test-utils/helper-utils.spec'; +import { IgxTextHighlightDirective } from 'igniteui-angular/directives'; +import { GridFunctions } from '../../../test-utils/grid-functions.spec'; +import { firstValueFrom } from 'rxjs'; +import { DefaultSortingStrategy, GridColumnDataType, IgxStringFilteringOperand, SortingDirection } from 'igniteui-angular/core'; + +describe('IgxGrid - search API #grid', () => { + const CELL_CSS_CLASS = '.igx-grid__td'; + const HIGHLIGHT_CSS_CLASS = '.igx-highlight'; + const HIGHLIGHT_ACTIVE_CSS_CLASS = '.igx-highlight__active'; + let fix: ComponentFixture; + let component; let grid: IgxGridComponent; let fixNativeElement; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + BasicGridSearchComponent, + GridWithAvatarComponent, + GroupableGridSearchComponent, + ScrollableGridSearchComponent + ] + }).compileComponents(); + })) + + describe('BasicGrid - ', () => { + beforeEach(() => { + fix = TestBed.createComponent(BasicGridSearchComponent); + fix.componentInstance.data = SampleTestData.personJobDataFull(); + fix.detectChanges(); + component = fix.componentInstance; + grid = component.grid; + fixNativeElement = fix.debugElement.nativeElement; + }); + + it('Should clear all highlights', () => { + const count = grid.findNext('software'); + let spans = getSpans(); + expect(spans.length).toBe(5); + expect(count).toBe(5); + + grid.clearSearch(); + fix.detectChanges(); + spans = getSpans(); + expect(spans.length).toBe(0); + }); + + it('findNext highlights the correct cells', () => { + let count = grid.findNext('developer'); + fix.detectChanges(); + const spans = getSpans(); + expect(spans.length).toBe(4); + expect(count).toBe(4); + verifyActiveSpan(0); + + count = grid.findNext('developer'); + fix.detectChanges(); + verifyActiveSpan(1); + + count = grid.findNext('developer'); + fix.detectChanges(); + verifyActiveSpan(2); + + count = grid.findNext('developer'); + fix.detectChanges(); + verifyActiveSpan(3); + + count = grid.findNext('developer'); + fix.detectChanges(); + verifyActiveSpan(0); + }); + + it('findPrev highlights the correct cells', () => { + let count = grid.findNext('developer'); + const spans = getSpans(); + expect(spans.length).toBe(4); + expect(count).toBe(4); + verifyActiveSpan(0); + + count = grid.findPrev('developer'); + verifyActiveSpan(3); + + count = grid.findPrev('developer'); + verifyActiveSpan(2); + + count = grid.findPrev('developer'); + verifyActiveSpan(1); + + count = grid.findPrev('developer'); + verifyActiveSpan(0); + }); + + it('findPrev and findNext work properly for case sensitive searches', () => { + grid.gridAPI.get_cell_by_index(4, 'JobTitle').update('Senior Software DEVELOPER'); + fix.detectChanges(); + + let count = grid.findNext('Developer', true); + fix.detectChanges(); + let spans = getSpans(); + expect(spans.length).toBe(3); + expect(count).toBe(3); + verifyActiveSpan(0); + + count = grid.findPrev('Developer', true); + fix.detectChanges(); + verifyActiveSpan(2); + + count = grid.findNext('Developer', true); + fix.detectChanges(); + verifyActiveSpan(0); + + count = grid.findNext('Developer', true); + fix.detectChanges(); + verifyActiveSpan(1); + + count = grid.findPrev('developer', true); + fix.detectChanges(); + spans = getSpans(); + const activeSpan = getActiveSpan(); + expect(activeSpan).toBe(null); + expect(count).toBe(0); + expect(spans.length).toBe(0); + }); + + it('findNext and findPrev highlight nothing when there is no exact match, regardless of case sensitivity.', () => { + let count = grid.findNext('Developer', false, true); + let spans = getSpans(); + expect(spans.length).toBe(0); + expect(count).toBe(0); + + count = grid.findNext('Developer', true, true); + spans = getSpans(); + expect(spans.length).toBe(0); + expect(count).toBe(0); + + count = grid.findPrev('Developer', false, true); + fix.detectChanges(); + spans = getSpans(); + expect(spans.length).toBe(0); + expect(count).toBe(0); + + count = grid.findPrev('Developer', true, true); + fix.detectChanges(); + spans = getSpans(); + expect(spans.length).toBe(0); + expect(count).toBe(0); + }); + + it('findNext and findPrev highlight only exact matches when searching by exact match', () => { + let count = grid.findNext('Software Developer', false, false); + fix.detectChanges(); + let spans = getSpans(); + expect(spans.length).toBe(4); + expect(count).toBe(4); + + count = grid.findNext('Software Developer', false, true); + fix.detectChanges(); + verifyActiveSpan(0); + spans = getSpans(); + expect(spans.length).toBe(1); + expect(count).toBe(1); + + count = grid.findPrev('Software Developer', false, false); + fix.detectChanges(); + spans = getSpans(); + expect(spans.length).toBe(4); + expect(count).toBe(4); + + count = grid.findPrev('Software Developer', false, true); + fix.detectChanges(); + verifyActiveSpan(0); + spans = getSpans(); + expect(spans.length).toBe(1); + expect(count).toBe(1); + }); + + it('findNext and findPrev highlight only exact matches by respecting case sensitivity', () => { + grid.gridAPI.get_cell_by_index(5, 'JobTitle').update('director of Dev operations'); + fix.detectChanges(); + + // Case INsensitive and exact match + let count = grid.findNext('director', false, true); + fix.detectChanges(); + let spans = getSpans(); + verifyActiveSpan(0); + expect(spans.length).toBe(2); + expect(count).toBe(2); + + count = grid.findPrev('director', false, true); + verifyActiveSpan(1); + expect(spans.length).toBe(2); + expect(count).toBe(2); + + // Case sensitive and exact match + count = grid.findNext('director', true, true); + fix.detectChanges(); + spans = getSpans(); + expect(spans.length).toBe(0); + expect(count).toBe(0); + + count = grid.findPrev('director', true, true); + fix.detectChanges(); + spans = getSpans(); + expect(spans.length).toBe(0); + expect(count).toBe(0); + }); + + it('Should update exact match highlights when filtering.', () => { + grid.findNext('Software Developer', false, true); + let activeHighlight = getActiveHighlight(); + let highlights = getHighlights(); + expect(highlights.length).toBe(1); + verifyActiveHighlight(0); + + grid.filter('JobTitle', 'Associate', IgxStringFilteringOperand.instance().condition('contains')); + fix.detectChanges(); + activeHighlight = getActiveHighlight(); + highlights = getHighlights(); + expect(highlights.length).toBe(0); + expect(activeHighlight).toBeNull(); + + grid.clearFilter('JobTitle'); + fix.detectChanges(); + }); + + it('Should update exact match highlights when clearing filter.', fakeAsync(() => { + grid.filter('JobTitle', 'Associate', IgxStringFilteringOperand.instance().condition('contains')); + tick(16); + fix.detectChanges(); + + grid.findNext('Software Developer', false, true); + let activeHighlight = getActiveHighlight(); + let highlights = getHighlights(); + expect(highlights.length).toBe(0); + expect(activeHighlight).toBeNull(); + + grid.clearFilter('JobTitle'); + tick(16); + fix.detectChanges(); + activeHighlight = getActiveHighlight(); + highlights = getHighlights(); + expect(highlights.length).toBe(1); + verifyActiveHighlight(0); + })); + + it('Should update the active highlight when sorting', () => { + const allCells = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS)); + const rv = allCells[6].nativeElement; + const cell = grid.gridAPI.get_cell_by_index(0, 'JobTitle'); + const searchString = 'assoc'; + let activeHighlight = rv.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); + expect(activeHighlight !== null).toBeFalsy(); + + cell.column.sortable = true; + grid.findNext(searchString); + grid.findNext(searchString); + + grid.sort({ fieldName: 'JobTitle', dir: SortingDirection.Asc, ignoreCase: true }); + fix.detectChanges(); + activeHighlight = rv.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); + let highlights = rv.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(highlights.length).toBe(1); + expect(activeHighlight).toBe(highlights[0]); + + grid.sort({ fieldName: 'JobTitle', dir: SortingDirection.Desc, ignoreCase: true }); + fix.detectChanges(); + const scrolledCell = grid.gridAPI.get_cell_by_index(grid.data.length - 1, 'JobTitle').nativeElement; + activeHighlight = scrolledCell.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); + highlights = scrolledCell.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(highlights.length).toBe(1); + expect(activeHighlight).toBe(highlights[0]); + }); + + xit('Should scroll properly when using paging', () => { + fix.componentInstance.paging = true; + grid.height = '240px'; + grid.paginator.perPage = 7; + fix.detectChanges(); + + const searchString = 'assoc'; + grid.findNext(searchString); + fix.detectChanges(); + + expect(grid.paginator.page).toBe(0); + let activeHighlight = getActiveHighlight(); + let highlights = getHighlights(); + expect(activeHighlight).not.toBeNull(); + expect(highlights.length).toBe(1); + + grid.findNext(searchString); + fix.detectChanges(); + expect(grid.paginator.page).toBe(1); + activeHighlight = getActiveHighlight(); + highlights = getHighlights(); + expect(activeHighlight).not.toBeNull(); + expect(highlights.length).toBe(1); + + grid.findPrev(searchString); + fix.detectChanges(); + expect(grid.paginator.page).toBe(0); + activeHighlight = getActiveHighlight(); + highlights = getHighlights(); + expect(activeHighlight).not.toBeNull(); + expect(highlights.length).toBe(1); + }); + + it('Hidden columns shouldn\'t be part of the search', () => { + grid.columnList.get(1).hidden = true; + fix.detectChanges(); + + grid.findNext('casey'); + const activeHighlight = getActiveHighlight(); + const highlights = getHighlights(); + expect(highlights.length).toBe(0); + expect(activeHighlight).toBe(null); + }); + + it('Search should honor the visible columns order', () => { + grid.columnList.get(3).pinned = true; + const cell = grid.gridAPI.get_cell_by_index(0, 'HireDate').nativeElement; + + grid.findNext('1'); + const highlights = cell.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(highlights.length).toBe(5); + verifyActiveHighlight(0); + }); + + it('Active highlight should be updated when a column is pinned/unpinned', () => { + let cellName = grid.gridAPI.get_cell_by_index(0, 'Name').nativeElement; + let highlights: NodeListOf; + + grid.findNext('casey'); + cellName = grid.gridAPI.get_cell_by_index(0, 'Name').nativeElement; + highlights = cellName.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(highlights.length).toBe(1); + verifyActiveHighlight(0); + + grid.columnList.get(1).pinned = true; + fix.detectChanges(); + cellName = grid.gridAPI.get_cell_by_index(0, 'Name').nativeElement; + highlights = cellName.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(highlights.length).toBe(1); + verifyActiveHighlight(0); + + grid.columnList.get(1).pinned = false; + fix.detectChanges(); + cellName = grid.gridAPI.get_cell_by_index(0, 'Name').nativeElement; + highlights = cellName.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(highlights.length).toBe(1); + verifyActiveHighlight(0); + }); + + it('Active highlight should be updated when a column is hidden/shown', () => { + let cellName = grid.gridAPI.get_cell_by_index(0, 'Name').nativeElement; + let highlights: NodeListOf; + + grid.findNext('casey'); + highlights = cellName.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(highlights.length).toBe(1); + verifyActiveHighlight(0); + + grid.columnList.get(0).hidden = true; + fix.detectChanges(); + cellName = grid.gridAPI.get_cell_by_index(0, 'Name').nativeElement; + highlights = cellName.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(highlights.length).toBe(1); + verifyActiveHighlight(0); + + grid.columnList.get(0).hidden = false; + fix.detectChanges(); + cellName = grid.gridAPI.get_cell_by_index(0, 'Name').nativeElement; + highlights = cellName.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(highlights.length).toBe(1); + verifyActiveHighlight(0); + }); + + it('Highlights should be updated after a column is hidden and another column is already hidden', () => { + grid.columnList.get(0).hidden = true; + fix.detectChanges(); + + grid.findNext('an'); + fix.detectChanges(); + let highlights = getHighlights(); + expect(highlights.length).toBe(3); + verifyActiveHighlight(0); + expect(grid.lastSearchInfo.matchCount).toBe(3); + expect(grid.lastSearchInfo.activeMatchIndex).toBe(0); + + grid.columnList.get(1).hidden = true; + fix.detectChanges(); + highlights = getHighlights(); + expect(highlights.length).toBe(1); + verifyActiveHighlight(0); + expect(grid.lastSearchInfo.matchCount).toBe(1); + expect(grid.lastSearchInfo.activeMatchIndex).toBe(0); + }); + + it('Highlight should be updated when a column is hidden/shown and columns have different data types', () => { + grid.columnList.get(0).dataType = GridColumnDataType.Number; + fix.detectChanges(); + + let cell = grid.gridAPI.get_cell_by_index(0, 'ID').nativeElement; + grid.findNext('1'); + let highlights = cell.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(highlights.length).toBe(1); + verifyActiveHighlight(0); + + grid.columnList.get(0).hidden = true; + fix.detectChanges(); + + grid.columnList.get(0).hidden = false; + fix.detectChanges(); + cell = grid.gridAPI.get_cell_by_index(0, 'ID').nativeElement; + highlights = cell.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(highlights.length).toBe(1); + expect(cell.innerText).toBe('1'); + }); + + it('Highlight should be updated when a column is hidden and there are other hidden columns', () => { + grid.columnList.get(1).hidden = true; + fix.detectChanges(); + + let finds = grid.findNext('Director'); + expect(finds).toEqual(2); + + grid.columnList.get(2).hidden = true; + fix.detectChanges(); + + finds = grid.findNext('Director'); + expect(finds).toEqual(0); + }); + + it('Clear filter properly updates the highlights', () => { + let gilbertoDirectorCell = grid.gridAPI.get_cell_by_index(1, 'JobTitle').nativeElement; + let tanyaDirectorCell = grid.gridAPI.get_cell_by_index(2, 'JobTitle').nativeElement; + + grid.findNext('director'); + fix.detectChanges(); + gilbertoDirectorCell = grid.gridAPI.get_cell_by_index(1, 'JobTitle').nativeElement; + let activeHighlight = gilbertoDirectorCell.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); + let highlights = gilbertoDirectorCell.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(highlights.length).toBe(1); + verifyActiveHighlight(0); + + grid.filter('Name', 'Tanya', IgxStringFilteringOperand.instance().condition('contains')); + fix.detectChanges(); + tanyaDirectorCell = grid.gridAPI.get_cell_by_index(0, 'JobTitle').nativeElement; + activeHighlight = tanyaDirectorCell.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); + highlights = tanyaDirectorCell.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(highlights.length).toBe(1); + expect(activeHighlight).toBe(highlights[0]); + + grid.clearFilter(); + fix.detectChanges(); + tanyaDirectorCell = grid.gridAPI.get_cell_by_index(2, 'JobTitle').nativeElement; + activeHighlight = tanyaDirectorCell.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); + highlights = tanyaDirectorCell.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(highlights.length).toBe(1); + expect(activeHighlight).toBe(highlights[0]); + + grid.findNext('Director'); + fix.detectChanges(); + gilbertoDirectorCell = grid.gridAPI.get_cell_by_index(1, 'JobTitle').nativeElement; + activeHighlight = gilbertoDirectorCell.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); + highlights = gilbertoDirectorCell.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(highlights.length).toBe(1); + expect(activeHighlight).toBe(highlights[0]); + }); + + it('Unsearchable column should not interfere with active highlight for other columns on its right', () => { + grid.columnList.get(1).searchable = false; + grid.columnList.get(3).searchable = false; + fix.detectChanges(); + + const count = grid.findNext('Software'); + const spans = getSpans(); + expect(spans.length).toBe(5); + expect(count).toBe(5); + verifyActiveSpan(0); + + grid.findNext('Software'); + verifyActiveSpan(1); + }); + + it('Highlights should be properly updated when a row is deleted', () => { + // Specify primaryKey so record deletion is allowed. + grid.primaryKey = 'ID'; + let jackSoftwareCell = grid.gridAPI.get_cell_by_index(3, 'JobTitle').nativeElement; + let celiaSoftwareCell = grid.gridAPI.get_cell_by_index(4, 'JobTitle').nativeElement; + let leslieSoftwareCell = grid.gridAPI.get_cell_by_index(8, 'JobTitle').nativeElement; + + grid.findNext('software'); + jackSoftwareCell = grid.gridAPI.get_cell_by_index(3, 'JobTitle').nativeElement; + let activeHighlight = jackSoftwareCell.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); + let highlights = jackSoftwareCell.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(highlights.length).toBe(1); + expect(activeHighlight).toBe(highlights[0]); + + grid.deleteRow(4); + fix.detectChanges(); + celiaSoftwareCell = grid.gridAPI.get_cell_by_index(3, 'JobTitle').nativeElement; + activeHighlight = celiaSoftwareCell.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); + highlights = celiaSoftwareCell.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(highlights.length).toBe(1); + expect(activeHighlight).toBe(highlights[0]); + + grid.findPrev('software'); + leslieSoftwareCell = grid.gridAPI.get_cell_by_index(7, 'JobTitle').nativeElement; + activeHighlight = leslieSoftwareCell.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); + highlights = leslieSoftwareCell.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(highlights.length).toBe(1); + expect(activeHighlight).toBe(highlights[0]); + }); + + it('Highlights should be properly updated when a row is added', () => { + const tanyaDirectorCell = grid.gridAPI.get_cell_by_index(2, 'JobTitle').nativeElement; + + grid.findNext('director'); + grid.findNext('director'); + let activeHighlight = tanyaDirectorCell.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); + let highlights = tanyaDirectorCell.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(highlights.length).toBe(1); + expect(activeHighlight).toBe(highlights[0]); + + grid.addRow({ + ID: 11, + Name: 'John Doe', + JobTitle: 'Director', + HireDate: new Date() + }); + fix.detectChanges(); + activeHighlight = tanyaDirectorCell.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); + highlights = tanyaDirectorCell.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(highlights.length).toBe(1); + expect(activeHighlight).toBe(highlights[0]); + + grid.findNext('director'); + const johnDirectorCell = grid.gridAPI.get_cell_by_index(grid.rowList.length - 1, 'JobTitle').nativeElement; + activeHighlight = johnDirectorCell.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); + highlights = johnDirectorCell.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(highlights.length).toBe(1); + expect(activeHighlight).toBe(highlights[0]); + }); + + it('Active highlight should be updated when filtering is applied', () => { + grid.findNext('developer'); + + grid.filter('JobTitle', 'Associate', IgxStringFilteringOperand.instance().condition('contains')); + fix.detectChanges(); + const highlights = getHighlights(); + expect(highlights.length).toBe(2); + verifyActiveHighlight(0); + }); + + it('Active highlight should be preserved when all rows are filtered out', () => { + grid.height = '500px'; + fix.detectChanges(); + + grid.findNext('casey'); + let highlights = getHighlights(); + expect(highlights.length).toBe(1); + + grid.filter('Name', 'zxxz', IgxStringFilteringOperand.instance().condition('contains')); + fix.detectChanges(); + let activeHighlight = getActiveHighlight(); + highlights = getHighlights(); + expect(highlights.length).toBe(0); + expect(activeHighlight).toBeNull(); + + grid.clearFilter('Name'); + fix.detectChanges(); + activeHighlight = getActiveHighlight(); + highlights = getHighlights(); + expect(highlights.length).toBe(1); + verifyActiveHighlight(0); + }); + + it('Active highlight should be preserved when a column is moved', () => { + grid.findNext('casey'); + + const columns = grid.columnList.toArray(); + grid.moveColumn(columns[0], columns[1]); + fix.detectChanges(); + + const highlights = getHighlights(); + expect(highlights.length).toBe(1); + verifyActiveHighlight(0); + }); + + it('Should exit edit mode and search a cell', () => { + const cell = grid.getCellByColumn(0, 'Name'); + cell.column.editable = true; + fix.detectChanges(); + cell.editMode = true; + fix.detectChanges(); + expect(cell.editMode).toBeTruthy(); + + grid.findNext('casey'); + //grid.gridAPI.get_cell_by_index(0, 'Name').cdr.detectChanges(); + grid.gridAPI.get_cell_by_index(0, 'Name'); + grid.cdr.detectChanges(); + const highlights = grid.gridAPI.get_cell_by_index(0, 'Name').nativeElement.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(cell.editMode).toBeFalsy(); + expect(highlights.length).toBe(1); + verifyActiveHighlight(0); + + const nextCell = grid.gridAPI.get_cell_by_index(0, 'JobTitle').nativeElement; + nextCell.dispatchEvent(new Event('click')); + fix.detectChanges(); + expect(grid.gridAPI.get_cell_by_index(0, 'Name').nativeElement.innerText.trim()).toBe('Casey Houston'); + }); + + it('Search should not change the cell\'s value', () => { + grid.findNext('12'); + const rowIndexes = [1, 3, 4, 5]; + + rowIndexes.forEach((ind) => { + const cell = grid.gridAPI.get_cell_by_index(ind, 'HireDate'); + const highlights = cell.nativeElement.querySelectorAll(HIGHLIGHT_CSS_CLASS); + const activeHighlight = cell.nativeElement.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); + const cellChildren = cell.nativeElement.children as HTMLCollection; + + // Check whether search does not change the cell's value + expect(cellChildren.length).toBe(2); + expect(cell.nativeElement.innerText.trim()).toBe(cell.value); + expect((cellChildren[0] as HTMLElement).hidden).toBeTruthy(); + expect((cellChildren[1] as HTMLElement).hidden).toBeFalsy(); + + expect(highlights.length).toBe(1); + if (ind === 1) { + verifyActiveHighlight(0); + } else { + expect(activeHighlight).toBeNull(); + } + expect((highlights[0] as HTMLElement).innerText).toEqual('12'); + }); + }); + + it('Search should close row edit mode', () => { + grid.primaryKey = 'ID'; + grid.rowEditable = true; + grid.getColumnByName('Name').editable = true; + grid.cdr.detectChanges(); + fix.detectChanges(); + const row = grid.getRowByIndex(0); + const cell = grid.gridAPI.get_cell_by_index(0, 'Name'); + + grid.findNext('Casey'); + grid.cdr.detectChanges(); + fix.detectChanges(); + let highlights = cell.nativeElement.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(highlights.length).toBe(1); + expect(row.inEditMode).toBe(false); + cell.nativeElement.dispatchEvent(new Event('dblclick')); + fix.detectChanges(); + expect(row.inEditMode).toBe(true); + + let cellInput = null; + cellInput = cell.nativeElement.querySelector('[igxinput]'); + cellInput.value = 'newCellValue'; + cellInput.dispatchEvent(new Event('input')); + fix.detectChanges(); + + grid.findNext('Casey'); + grid.cdr.detectChanges(); + fix.detectChanges(); + highlights = cell.nativeElement.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(highlights.length).toBe(1); + expect(row.inEditMode).toBe(false); + }); + + it('Should keep edit mode when tabbing, after search is applied', () => { + grid.primaryKey = 'ID'; + grid.getColumnByName('Name').editable = true; + grid.getColumnByName('JobTitle').editable = true; + fix.detectChanges(); + const cell = grid.gridAPI.get_cell_by_index(1, 'Name'); + const caseyCell = grid.gridAPI.get_cell_by_index(0, 'Name'); + const newVal = 'newCellValue'; + + grid.findNext('Casey'); + fix.detectChanges(); + let highlights = caseyCell.nativeElement.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(highlights.length).toBe(1); + + UIInteractions.simulateDoubleClickAndSelectEvent(cell.nativeElement); + fix.detectChanges(); + + expect(cell.editMode).toBeTruthy(); + highlights = caseyCell.nativeElement.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(highlights.length).toBe(1); + + let cellInput = null; + cellInput = cell.nativeElement.querySelector('[igxinput]'); + UIInteractions.setInputElementValue(cellInput, newVal); + + // press tab on edited cell + GridFunctions.simulateGridContentKeydown(fix, 'tab'); + fix.detectChanges(); + + expect(cell.value).toBe(newVal); + expect(cell.editMode).toBeFalsy(); + highlights = caseyCell.nativeElement.querySelectorAll(HIGHLIGHT_CSS_CLASS); + + const nextCell = grid.gridAPI.get_cell_by_index(1, 'JobTitle'); + expect(nextCell.editMode).toBeTruthy(); + expect(highlights.length).toBe(1); + }); + }); + + describe('ScrollableGrid - ', () => { + beforeEach(() => { + fix = TestBed.createComponent(ScrollableGridSearchComponent); + component = fix.componentInstance; + grid = component.grid; + setupGridScrollDetection(fix, grid); + fix.detectChanges(); + + grid.data[29].HireDate = '1887-11-28T11:23:17.714Z'; + grid.width = '500px'; + grid.height = '600px'; + fixNativeElement = fix.debugElement.nativeElement; + fix.detectChanges(); + }); + + afterEach(() => { + clearGridSubs(); + }); + + it('findNext scrolls to cells out of view', async () => { + grid.findNext('30'); + await wait(100); + fix.detectChanges(); + expect(isInView(29, grid.virtualizationState)).toBeTruthy(); + + grid.findNext('1887'); + await wait(100); + fix.detectChanges(); + expect(isInView(3, grid.rowList.first.virtDirRow.state)).toBeTruthy(); + }); + + it('findPrev scrolls to cells out of view', async () => { + grid.findPrev('30'); + await wait(100); + fix.detectChanges(); + expect(isInView(29, grid.virtualizationState)).toBeTruthy(); + + grid.findPrev('1887'); + await wait(100); + fix.detectChanges(); + expect(isInView(3, grid.rowList.first.virtDirRow.state)).toBeTruthy(); + }); + + it('should keep the active highlight when active cell enters and exits edit mode', () => { + const rv = fix.debugElement.query(By.css(CELL_CSS_CLASS)).nativeElement; + const cell = grid.getCellByColumn(0, 'ID'); + const initialValue = rv.textContent; + let activeHighlight = rv.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); + expect(activeHighlight).toBeNull(); + + cell.column.editable = true; + grid.findNext('1'); + fix.detectChanges(); + activeHighlight = rv.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); + expect(activeHighlight).not.toBeNull(); + + cell.editMode = true; + fix.detectChanges(); + expect(cell.editMode).toBe(true); + activeHighlight = rv.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); + expect(activeHighlight).toBeNull(); + + cell.editMode = false; + fix.detectChanges(); + expect(rv.innerText).toBe(initialValue); + expect(rv.querySelectorAll(HIGHLIGHT_CSS_CLASS).length).toBe(1); + activeHighlight = rv.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); + expect(activeHighlight).not.toBeNull(); + }); + + it('should update highlights when a new value is entered', () => { + const rv = fix.debugElement.query(By.css(CELL_CSS_CLASS)); + const cell = grid.getCellByColumn(0, 'ID'); + cell.column.editable = true; + fix.detectChanges(); + let activeHighlight = rv.nativeElement.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); + expect(activeHighlight).toBeNull(); + + grid.findNext('1'); + fix.detectChanges(); + activeHighlight = rv.nativeElement.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); + expect(activeHighlight).not.toBeNull(); + + cell.editMode = true; + fix.detectChanges(); + expect(cell.editMode).toBe(true); + + const inputElem: HTMLInputElement = rv.nativeElement.querySelector('input') as HTMLInputElement; + inputElem.value = '11'; + fix.detectChanges(); + cell.update(inputElem.value); + fix.detectChanges(); + expect(rv.nativeElement.innerText).toBe('11'); + activeHighlight = rv.nativeElement.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); + const highlights = rv.nativeElement.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(highlights.length).toBe(2); + verifyActiveHighlight(0); + }); + + it('should update highlights when the cell value is cleared', () => { + const rv = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[1].nativeElement; + const rv2 = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[2].nativeElement; + const cell = grid.getCellByColumn(0, 'Name'); + let activeHighlight = rv.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); + expect(activeHighlight).toBeNull(); + + cell.column.editable = true; + grid.findNext('c'); + fix.detectChanges(); + activeHighlight = rv.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); + expect(activeHighlight).not.toBeNull(); + + cell.editMode = true; + fix.detectChanges(); + expect(cell.editMode).toBe(true); + + const inputElem: HTMLInputElement = rv.querySelector('input') as HTMLInputElement; + inputElem.value = ''; + cell.update(inputElem.value); + fix.detectChanges(); + activeHighlight = rv.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); + let highlights = rv.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(highlights.length).toBe(0); + expect(activeHighlight).toBeNull(); + + activeHighlight = rv2.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); + highlights = rv2.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(highlights.length).toBe(1); + expect(activeHighlight).toBe(highlights[0]); + }); + + it('Should update highlight when setting perPage option', () => { + fix.componentInstance.paging = true; + fix.detectChanges(); + + const searchString = 'casey'; + grid.findNext(searchString); + grid.findNext(searchString); + fix.detectChanges(); + let activeHighlight = getActiveHighlight(); + expect(activeHighlight).not.toBeNull(); + expect(grid.paginator.page).toBe(0); + + grid.paginator.perPage = 9; + fix.detectChanges(); + activeHighlight = getActiveHighlight(); + expect(activeHighlight).toBeNull(); + expect(grid.page).toBe(0); + + grid.paginator.page = 1; + fix.detectChanges(); + activeHighlight = getActiveHighlight(); + expect(activeHighlight).not.toBeNull(); + }); + }); + + describe('Groupable Grid', () => { + beforeEach(() => { + fix = TestBed.createComponent(GroupableGridSearchComponent); + fix.detectChanges(); + + component = fix.componentInstance; + grid = component.grid; + fixNativeElement = fix.debugElement.nativeElement; + }); + + it('Should be able to navigate through highlights with grouping enabled', () => { + grid.groupBy({ + fieldName: 'JobTitle', + dir: SortingDirection.Asc, + ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }); + fix.detectChanges(); + grid.findNext('Software'); + fix.detectChanges(); + let spans = getSpans(); + expect(spans.length).toBe(5); + verifyActiveHighlight(0); + + grid.findNext('Software'); + grid.findNext('Software'); + fix.detectChanges(); + spans = getSpans(); + expect(spans.length).toBe(5); + verifyActiveHighlight(2); + + grid.findPrev('Software'); + fix.detectChanges(); + spans = getSpans(); + expect(spans.length).toBe(5); + verifyActiveHighlight(1); + + grid.findPrev('Software'); + grid.findPrev('Software'); + fix.detectChanges(); + spans = getSpans(); + expect(spans.length).toBe(5); + verifyActiveHighlight(4); + }); + + it('Should be able to react to changes in grouping', () => { + grid.groupBy({ + fieldName: 'JobTitle', + dir: SortingDirection.Asc, + ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }); + fix.detectChanges(); + + let cell = grid.gridAPI.get_cell_by_index(1, 'JobTitle'); + grid.findNext('software'); + fix.detectChanges(); + let highlight = cell.nativeElement.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); + expect(highlight !== null).toBeTruthy(); + + grid.clearGrouping(); + fix.detectChanges(); + cell = grid.gridAPI.get_cell_by_index(6, 'JobTitle'); + highlight = cell.nativeElement.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); + expect(highlight !== null).toBeTruthy(); + + grid.groupBy([{ + fieldName: 'JobTitle', + dir: SortingDirection.Asc, + ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }, { + fieldName: 'Company', + dir: SortingDirection.Desc, + ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }]); + fix.detectChanges(); + cell = grid.gridAPI.get_cell_by_index(4, 'JobTitle'); + highlight = cell.nativeElement.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); + expect(highlight !== null).toBeTruthy(); + + grid.groupBy({ + fieldName: 'JobTitle', + dir: SortingDirection.Desc, + ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }); + fix.detectChanges(); + grid.findNext('software'); + fix.detectChanges(); + cell = grid.gridAPI.get_cell_by_index(5, 'JobTitle'); + highlight = cell.nativeElement.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); + expect(highlight !== null).toBeTruthy(); + }); + + it('Should be able to navigate through highlights with grouping and paging enabled', () => { + grid.groupBy({ + fieldName: 'JobTitle', + dir: SortingDirection.Asc, + ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }); + fix.componentInstance.paging = true; + fix.detectChanges(); + grid.paginator.perPage = 6; + fix.detectChanges(); + + grid.findNext('Software'); + fix.detectChanges(); + let spans = getSpans(); + expect(spans.length).toBe(2); + verifyActiveSpan(0); + expect(grid.paginator.page).toBe(0); + + grid.findPrev('Software'); + fix.detectChanges(); + spans = getSpans(); + verifyActiveSpan(1); + expect(spans.length).toBe(2); + expect(grid.paginator.page).toBe(2); + + grid.findPrev('Software'); + grid.findPrev('Software'); + fix.detectChanges(); + spans = getSpans(); + expect(spans.length).toBe(1); + verifyActiveSpan(0); + expect(grid.paginator.page).toBe(1); + }); + + it('Should be able to properly handle perPage changes with grouping and paging', () => { + fix.componentInstance.paging = true; + fix.detectChanges(); + grid.groupBy({ + fieldName: 'JobTitle', + dir: SortingDirection.Asc, + ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }); + grid.paginator.perPage = 16; + grid.cdr.detectChanges(); + fix.detectChanges(); + + grid.findNext('Software'); + grid.findNext('Software'); + grid.findNext('Software'); + fix.detectChanges(); + let spans = getSpans(); + verifyActiveSpan(2); + expect(spans.length).toBe(5); + expect(grid.paginator.page).toBe(0); + + grid.paginator.perPage = 8; + fix.detectChanges(); + spans = getSpans(); + const activeSpan = getActiveSpan(); + expect(spans.length).toBe(2); + expect(activeSpan).toBeNull(); + expect(grid.page).toBe(0); + + grid.paginator.page = 1; + fix.detectChanges(); + spans = getSpans(); + verifyActiveSpan(0); + expect(spans.length).toBe(3); + expect(grid.page).toBe(1); + }); + + it('Should be able to properly handle navigating through collapsed rows', () => { + grid.groupBy({ + fieldName: 'JobTitle', + dir: SortingDirection.Asc, + ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }); + fix.detectChanges(); + + grid.findNext('software'); + grid.findNext('software'); + grid.findNext('software'); + fix.detectChanges(); + + grid.toggleGroup(grid.groupsRecords[0]); + fix.detectChanges(); + let spans = getSpans(); + expect(spans.length).toBe(3); + verifyActiveSpan(0); + + grid.findNext('software'); + grid.findNext('software'); + grid.findNext('software'); + fix.detectChanges(); + spans = getSpans(); + expect(spans.length).toBe(5); + verifyActiveSpan(0); + expect(grid.isExpandedGroup(grid.groupsRecords[0])).toBeTruthy(); + }); + + it('Should be able to navigate through highlights when scrolling with grouping enabled', async () => { + grid.height = '500px'; + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'JobTitle', + dir: SortingDirection.Asc, + ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }); + fix.detectChanges(); + grid.findNext('a'); + await wait(); + fix.detectChanges(); + + (grid as any).scrollTo(9, 0); + await firstValueFrom(grid.verticalScrollContainer.chunkLoad); + fix.detectChanges(); + const row = grid.gridAPI.get_row_by_index(9); + const spans = row.nativeElement.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(spans.length).toBe(5); + }); + + it('Should be able to search when grouping is enabled', async () => { + grid.height = '400px'; + fix.detectChanges(); + + grid.groupBy({ + fieldName: 'JobTitle', + dir: SortingDirection.Asc, + ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }); + fix.detectChanges(); + grid.findNext('Casey'); + await wait(100); + fix.detectChanges(); + let row = grid.gridAPI.get_row_by_index(17); + let spans = row.nativeElement.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(spans.length).toBe(1); + + grid.toggleAllGroupRows(); + fix.detectChanges(); + (grid as any).scrollTo(0, 0); + await wait(100); + fix.detectChanges(); + grid.toggleGroup(grid.groupsRecords[0]); + fix.detectChanges(); + grid.toggleGroup(grid.groupsRecords[1]); + fix.detectChanges(); + + grid.findNext('Casey'); + await wait(100); + fix.detectChanges(); + row = grid.gridAPI.get_row_by_index(11); + spans = row.nativeElement.querySelectorAll(HIGHLIGHT_CSS_CLASS); + expect(spans.length).toBe(1); + }); + + it('Should be able to properly handle navigating through collapsed rows with paging', () => { + grid.groupBy({ + fieldName: 'JobTitle', + dir: SortingDirection.Asc, + ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }); + fix.componentInstance.paging = true; + fix.detectChanges(); + grid.paginator.perPage = 8; + fix.detectChanges(); + grid.findNext('software'); + grid.findNext('software'); + fix.detectChanges(); + grid.toggleGroup(grid.groupsRecords[0]); + grid.findNext('software'); + fix.detectChanges(); + let spans = getSpans(); + expect(spans.length).toBe(3); + verifyActiveSpan(0); + expect(grid.paginator.page).toBe(1); + + grid.findNext('software'); + grid.findNext('software'); + grid.findNext('software'); + fix.detectChanges(); + spans = getSpans(); + expect(spans.length).toBe(2); + verifyActiveSpan(0); + expect(grid.isExpandedGroup(grid.groupsRecords[0])).toBeTruthy(); + expect(grid.paginator.page).toBe(0); + }); + + it('Should highlight search results in pinned and unpinned row areas separately', () => { + grid.getRowByIndex(2).pin(); + fix.detectChanges(); + + grid.findNext('Tanya Bennett'); + fix.detectChanges(); + + const spans = getSpans(); + expect(spans.length).toBe(2); + verifyActiveSpan(0); + + grid.findNext('Tanya Bennett'); + fix.detectChanges(); + verifyActiveSpan(1); + }); + + it('Should differentiate IgxHighlightDirective\'s metadata of pinned and unpinned rows', () => { + grid.getRowByIndex(2).pin(); + fix.detectChanges(); + + grid.findNext('Tanya Bennett'); + fix.detectChanges(); + + const highlightDirectives = fix.debugElement.queryAll(By.css('div[igxtexthighlight]')).filter((el) => { + return el.nativeElement.innerText === 'Tanya Bennett'; + }); + const firstHighlight = highlightDirectives[0].injector.get(IgxTextHighlightDirective); + const secondHighlight = highlightDirectives[1].injector.get(IgxTextHighlightDirective); + + expect(firstHighlight.metadata.get('pinned')).toBe(true); + expect(secondHighlight.metadata.get('pinned')).toBe(false); + }); + }); + + describe('Grid with Avatar - ', () => { + beforeEach(() => { + fix = TestBed.createComponent(GridWithAvatarComponent); + grid = fix.componentInstance.grid; + fix.detectChanges(); + }); + + it('Cells with no text should be excluded from the search', () => { + const matches = grid.findNext('https'); + expect(matches).toBe(0); + }); + + it('Cells with custom template should be excluded from search when pin/unpin', () => { + grid.columnList.get(1).pinned = true; + fix.detectChanges(); + const matches = grid.findNext('https'); + expect(matches).toBe(0); + let cell = grid.gridAPI.get_cell_by_index(0, 'Avatar').nativeElement; + expect(cell.children.length).toBe(1); + let image = cell.querySelector('.cell__inner, .avatar-cell') as HTMLElement; + expect(image.hidden).toBeFalsy(); + + grid.columnList.get(1).pinned = false; + fix.detectChanges(); + cell = grid.gridAPI.get_cell_by_index(0, 'Avatar').nativeElement; + expect(cell.children.length).toBe(1); + image = cell.querySelector('.cell__inner, .avatar-cell'); + expect(image.hidden).toBeFalsy(); + }); + }); + + const isInView = (index, state: IForOfState): boolean => index > state.startIndex && index <= state.startIndex + state.chunkSize; + + const getSpans = () => fixNativeElement.querySelectorAll(HIGHLIGHT_CSS_CLASS); + + const getActiveSpan = () => fixNativeElement.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); + + const verifyActiveSpan = (expectedActiveSpanIndex: number) => { + const spans = getSpans(); + const activeSpan = getActiveSpan(); + expect(activeSpan).toBe(spans[expectedActiveSpanIndex]); + }; + + const getActiveHighlight = () => grid.nativeElement.querySelector(HIGHLIGHT_ACTIVE_CSS_CLASS); + + const getHighlights = () => grid.nativeElement.querySelectorAll(HIGHLIGHT_CSS_CLASS); + + const verifyActiveHighlight = (expectedActiveHighlightIndex: number) => { + const highlights = getHighlights(); + const activeHighlight = getActiveHighlight(); + expect(activeHighlight).toBe(highlights[expectedActiveHighlightIndex]); + }; +}); diff --git a/projects/igniteui-angular/grids/grid/src/grid.sorting.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.sorting.spec.ts new file mode 100644 index 00000000000..dbc3a45c4cf --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid.sorting.spec.ts @@ -0,0 +1,774 @@ +import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { IgxGridComponent } from './grid.component'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { GridFunctions } from '../../../test-utils/grid-functions.spec'; +import { GridDeclaredColumnsComponent, SortByParityComponent, GridWithPrimaryKeyComponent, SortByAnotherColumnComponent, SortOnInitComponent, IgxGridFormattedValuesSortingComponent } from '../../../test-utils/grid-samples.spec'; +import { UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; +import { CellType } from 'igniteui-angular/grids/core'; +import { DefaultSortingStrategy, FormattedValuesSortingStrategy, NoopSortingStrategy, SortingDirection } from 'igniteui-angular/core'; +import { By } from '@angular/platform-browser'; + +describe('IgxGrid - Grid Sorting #grid', () => { + + let fixture; + let grid: IgxGridComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + GridDeclaredColumnsComponent, + SortByParityComponent, + GridWithPrimaryKeyComponent, + NoopAnimationsModule, + IgxGridFormattedValuesSortingComponent + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(GridDeclaredColumnsComponent); + grid = fixture.componentInstance.grid; + grid.width = '800px'; + fixture.detectChanges(); + }); + + describe('API tests', () => { + + it('Should sort grid ascending by column name', fakeAsync(() => { + spyOn(grid.sorting, 'emit').and.callThrough(); + spyOn(grid.sortingDone, 'emit').and.callThrough(); + const currentColumn = 'Name'; + const lastNameColumn = 'LastName'; + const nameHeaderCell = GridFunctions.getColumnHeader(currentColumn, fixture); + grid.sort({ fieldName: currentColumn, dir: SortingDirection.Asc, ignoreCase: false }); + tick(30); + fixture.detectChanges(); + + expect(grid.sorting.emit).toHaveBeenCalledWith({ + cancel: false, + sortingExpressions: grid.sortingExpressions, + owner: grid + }); + expect(nameHeaderCell.attributes['aria-sort']).toEqual('ascending'); + + expect(grid.getCellByColumn(0, currentColumn).value).toEqual('ALex'); + expect(grid.getCellByColumn(0, lastNameColumn).value).toEqual('Smith'); + expect(grid.getCellByColumn(grid.data.length - 1, currentColumn).value).toEqual('Rick'); + expect(grid.getCellByColumn(grid.data.length - 1, lastNameColumn).value).toEqual('BRown'); + + // Ignore case on sorting set to true + grid.sort({ fieldName: currentColumn, dir: SortingDirection.Asc, ignoreCase: true }); + tick(30); + fixture.detectChanges(); + expect(grid.sorting.emit).toHaveBeenCalledWith({ + cancel: false, + sortingExpressions: grid.sortingExpressions, + owner: grid + }); + expect(grid.getCellByColumn(0, currentColumn).value).toEqual('ALex'); + expect(grid.sorting.emit).toHaveBeenCalledTimes(2); + expect(grid.sortingDone.emit).toHaveBeenCalledTimes(2); + })); + + it('Should sort grid descending by column name', () => { + const currentColumn = 'Name'; + const nameHeaderCell = GridFunctions.getColumnHeader(currentColumn, fixture); + // Ignore case on sorting set to false + grid.sort({ fieldName: currentColumn, dir: SortingDirection.Desc, ignoreCase: false }); + fixture.detectChanges(); + + + expect(grid.getCellByColumn(0, currentColumn).value).toEqual('Rick'); + expect(grid.getCellByColumn(grid.data.length - 1, currentColumn).value).toEqual('ALex'); + expect(nameHeaderCell.attributes['aria-sort']).toEqual('descending'); + + // Ignore case on sorting set to true + grid.sort({ fieldName: currentColumn, dir: SortingDirection.Desc, ignoreCase: true }); + fixture.detectChanges(); + + expect(grid.getCellByColumn(0, currentColumn).value).toEqual('Rick'); + expect(grid.getCellByColumn(grid.data.length - 1, currentColumn).value).not.toEqual('ALex'); + + }); + + it('Should sort grid by ISO 8601 date column', () => { + fixture = TestBed.createComponent(GridWithPrimaryKeyComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + + const currentColumn = 'HireDate'; + grid.sort({ fieldName: currentColumn, dir: SortingDirection.Asc, ignoreCase: false }); + + fixture.detectChanges(); + + expect(grid.getCellByColumn(0, currentColumn).value).toEqual('2005-10-14T11:23:17.714Z'); + expect(grid.getCellByColumn(1, currentColumn).value).toEqual('2005-11-18T11:23:17.714Z'); + expect(grid.getCellByColumn(2, currentColumn).value).toEqual('2005-11-19T11:23:17.714Z'); + expect(grid.getCellByColumn(3, currentColumn).value).toEqual('2007-12-19T11:23:17.714Z'); + expect(grid.getCellByColumn(4, currentColumn).value).toEqual('2008-12-18T11:23:17.714Z'); + expect(grid.getCellByColumn(5, currentColumn).value).toEqual('2011-11-28T11:23:17.714Z'); + expect(grid.getCellByColumn(grid.data.length - 1, currentColumn).value).toEqual('2017-06-19T11:43:07.714Z'); + + grid.sort({ fieldName: currentColumn, dir: SortingDirection.Desc, ignoreCase: true }); + fixture.detectChanges(); + + expect(grid.getCellByColumn(9, currentColumn).value).toEqual('2005-10-14T11:23:17.714Z'); + expect(grid.getCellByColumn(8, currentColumn).value).toEqual('2005-11-18T11:23:17.714Z'); + expect(grid.getCellByColumn(7, currentColumn).value).toEqual('2005-11-19T11:23:17.714Z'); + expect(grid.getCellByColumn(6, currentColumn).value).toEqual('2007-12-19T11:23:17.714Z'); + expect(grid.getCellByColumn(5, currentColumn).value).toEqual('2008-12-18T11:23:17.714Z'); + expect(grid.getCellByColumn(4, currentColumn).value).toEqual('2011-11-28T11:23:17.714Z'); + expect(grid.getCellByColumn(0, currentColumn).value).toEqual('2017-06-19T11:43:07.714Z'); + }); + + it('Should sort grid by milliseconds date column', () => { + fixture = TestBed.createComponent(GridWithPrimaryKeyComponent); + fixture.componentInstance.data = SampleTestData.personJobDataFull().map(rec => { + const newRec = Object.assign({}, rec) as any; + newRec.HireDate = new Date(rec.HireDate).getTime(); + return newRec; + }); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + + const currentColumn = 'HireDate'; + grid.sort({ fieldName: currentColumn, dir: SortingDirection.Asc, ignoreCase: false }); + + fixture.detectChanges(); + + expect(grid.getCellByColumn(0, currentColumn).value).toEqual(new Date('2005-10-14T11:23:17.714Z').getTime()); + expect(grid.getCellByColumn(1, currentColumn).value).toEqual(new Date('2005-11-18T11:23:17.714Z').getTime()); + expect(grid.getCellByColumn(2, currentColumn).value).toEqual(new Date('2005-11-19T11:23:17.714Z').getTime()); + expect(grid.getCellByColumn(3, currentColumn).value).toEqual(new Date('2007-12-19T11:23:17.714Z').getTime()); + expect(grid.getCellByColumn(4, currentColumn).value).toEqual(new Date('2008-12-18T11:23:17.714Z').getTime()); + expect(grid.getCellByColumn(5, currentColumn).value).toEqual(new Date('2011-11-28T11:23:17.714Z').getTime()); + expect(grid.getCellByColumn(grid.data.length - 1, currentColumn).value).toEqual(new Date('2017-06-19T11:43:07.714Z').getTime()); + + grid.sort({ fieldName: currentColumn, dir: SortingDirection.Desc, ignoreCase: true }); + fixture.detectChanges(); + + expect(grid.getCellByColumn(9, currentColumn).value).toEqual(new Date('2005-10-14T11:23:17.714Z').getTime()); + expect(grid.getCellByColumn(8, currentColumn).value).toEqual(new Date('2005-11-18T11:23:17.714Z').getTime()); + expect(grid.getCellByColumn(7, currentColumn).value).toEqual(new Date('2005-11-19T11:23:17.714Z').getTime()); + expect(grid.getCellByColumn(6, currentColumn).value).toEqual(new Date('2007-12-19T11:23:17.714Z').getTime()); + expect(grid.getCellByColumn(5, currentColumn).value).toEqual(new Date('2008-12-18T11:23:17.714Z').getTime()); + expect(grid.getCellByColumn(4, currentColumn).value).toEqual(new Date('2011-11-28T11:23:17.714Z').getTime()); + expect(grid.getCellByColumn(0, currentColumn).value).toEqual(new Date('2017-06-19T11:43:07.714Z').getTime()); + }); + + it('Should sort grid by datetime column', () => { + fixture = TestBed.createComponent(GridWithPrimaryKeyComponent); + fixture.componentInstance.data = SampleTestData.personJobDataFull().map(rec => { + const newRec = Object.assign({}, rec) as any; + newRec.HireDate = new Date(rec.HireDate); + return newRec; + }); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + const hireDateCol = grid.columns.findIndex(col => col.field === "HireDate"); + grid.columns[hireDateCol].dataType = 'dateTime'; + fixture.detectChanges(); + + const currentColumn = 'HireDate'; + grid.sort({ fieldName: currentColumn, dir: SortingDirection.Asc, ignoreCase: false }); + + fixture.detectChanges(); + + expect(grid.getCellByColumn(0, currentColumn).value.toISOString()).toEqual('2005-10-14T11:23:17.714Z'); + expect(grid.getCellByColumn(1, currentColumn).value.toISOString()).toEqual('2005-11-18T11:23:17.714Z'); + expect(grid.getCellByColumn(2, currentColumn).value.toISOString()).toEqual('2005-11-19T11:23:17.714Z'); + expect(grid.getCellByColumn(3, currentColumn).value.toISOString()).toEqual('2007-12-19T11:23:17.714Z'); + expect(grid.getCellByColumn(4, currentColumn).value.toISOString()).toEqual('2008-12-18T11:23:17.714Z'); + expect(grid.getCellByColumn(5, currentColumn).value.toISOString()).toEqual('2011-11-28T11:23:17.714Z'); + expect(grid.getCellByColumn(grid.data.length - 1, currentColumn).value.toISOString()).toEqual('2017-06-19T11:43:07.714Z'); + + grid.sort({ fieldName: currentColumn, dir: SortingDirection.Desc, ignoreCase: true }); + fixture.detectChanges(); + + expect(grid.getCellByColumn(9, currentColumn).value.toISOString()).toEqual('2005-10-14T11:23:17.714Z'); + expect(grid.getCellByColumn(8, currentColumn).value.toISOString()).toEqual('2005-11-18T11:23:17.714Z'); + expect(grid.getCellByColumn(7, currentColumn).value.toISOString()).toEqual('2005-11-19T11:23:17.714Z'); + expect(grid.getCellByColumn(6, currentColumn).value.toISOString()).toEqual('2007-12-19T11:23:17.714Z'); + expect(grid.getCellByColumn(5, currentColumn).value.toISOString()).toEqual('2008-12-18T11:23:17.714Z'); + expect(grid.getCellByColumn(4, currentColumn).value.toISOString()).toEqual('2011-11-28T11:23:17.714Z'); + expect(grid.getCellByColumn(0, currentColumn).value.toISOString()).toEqual('2017-06-19T11:43:07.714Z'); + }); + + it('Should not mutate original data when sorting date column', () => { + fixture = TestBed.createComponent(GridWithPrimaryKeyComponent); + fixture.componentInstance.data = SampleTestData.personJobDataFull().map(rec => { + return Object.assign({}, { ...rec, HireDate: new Date(rec.HireDate) }); + }); + fixture.detectChanges(); + + grid = fixture.componentInstance.grid; + const hireDateCol = grid.columns.findIndex(col => col.field === "HireDate"); + grid.columns[hireDateCol].dataType = 'date'; + fixture.detectChanges(); + + grid.sort({ fieldName: 'HireDate', dir: SortingDirection.Asc }); + fixture.detectChanges(); + + const timeParts = (date: Date) => date.getHours() + date.getMinutes() + date.getSeconds() + date.getMinutes(); + expect(grid.data.every(rec => timeParts(rec.HireDate) === 0)).toEqual(false); + }); + + it('Should not sort grid when trying to sort by invalid column', () => { + const invalidColumn = 'Age'; + grid.sort({ fieldName: invalidColumn, dir: SortingDirection.Desc, ignoreCase: false }); + + expect(grid.getCellByColumn(0, 'Name').value).toEqual('Jane'); + expect(grid.getCellByColumn(grid.data.length - 1, 'Name').value).toEqual('Connor'); + }); + + it('Should sort grid by current column by expression (Ascending)', () => { + const currentColumn = 'ID'; + grid.sortingExpressions = [{ + fieldName: currentColumn, dir: SortingDirection.Asc, ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }]; + + fixture.detectChanges(); + + expect(grid.getCellByColumn(0, currentColumn).value).toEqual(1); + }); + + it('Should sort grid by current column by expression (Descending with ignoreCase)', () => { + const currentColumn = 'Name'; + + grid.sortingExpressions = [{ + fieldName: currentColumn, dir: SortingDirection.Desc, ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }]; + + fixture.detectChanges(); + + expect(grid.getCellByColumn(grid.data.length - 1, currentColumn).value).toEqual('Alex'); + }); + + it('Should sort grid by multiple expressions and clear sorting through API', () => { + const firstColumn = 'ID'; + const secondColumn = 'Name'; + const thirdColumn = 'LastName'; + + grid.sortingExpressions = [ + { fieldName: secondColumn, dir: SortingDirection.Asc, ignoreCase: true }, + { fieldName: firstColumn, dir: SortingDirection.Desc, ignoreCase: true } + ]; + + fixture.detectChanges(); + + expect(grid.getCellByColumn(0, secondColumn).value).toEqual('ALex'); + expect(grid.getCellByColumn(grid.data.length - 1, secondColumn).value).toEqual('Rick'); + expect(grid.getCellByColumn(grid.data.length - 1, firstColumn).value).toEqual(6); + expect(grid.getCellByColumn(grid.data.length - 1, thirdColumn).value).toEqual('Jones'); + + expect(GridFunctions.getColumnSortingIndex(GridFunctions.getColumnHeader(secondColumn, fixture))).toEqual(1); + expect(GridFunctions.getColumnSortingIndex(GridFunctions.getColumnHeader(firstColumn, fixture))).toEqual(2); + expect(GridFunctions.getColumnSortingIndex(GridFunctions.getColumnHeader(thirdColumn, fixture))).toBeNull(); + + // Clear sorting on a column + grid.clearSort(firstColumn); + fixture.detectChanges(); + + expect(grid.sortingExpressions.length).toEqual(1); + expect(grid.sortingExpressions[0].fieldName).toEqual(secondColumn); + expect(GridFunctions.getColumnSortingIndex(GridFunctions.getColumnHeader(firstColumn, fixture))).toBeNull(); + expect(GridFunctions.getColumnSortingIndex(GridFunctions.getColumnHeader(secondColumn, fixture))).toEqual(1); + + grid.sortingExpressions = [ + { fieldName: firstColumn, dir: SortingDirection.Desc, ignoreCase: true }, + { fieldName: secondColumn, dir: SortingDirection.Asc, ignoreCase: true } + ]; + fixture.detectChanges(); + + expect(grid.sortingExpressions.length).toEqual(2); + expect(GridFunctions.getColumnSortingIndex(GridFunctions.getColumnHeader(firstColumn, fixture))).toEqual(1); + expect(GridFunctions.getColumnSortingIndex(GridFunctions.getColumnHeader(secondColumn, fixture))).toEqual(2); + + // Clear sorting on all columns + grid.clearSort(); + fixture.detectChanges(); + + expect(grid.sortingExpressions.length).toEqual(0); + expect(GridFunctions.getColumnSortingIndex(GridFunctions.getColumnHeader(firstColumn, fixture))).toBeNull(); + expect(GridFunctions.getColumnSortingIndex(GridFunctions.getColumnHeader(secondColumn, fixture))).toBeNull(); + }); + + it('Should sort grid by multiple expressions through API using ignoreCase for the second expression', () => { + const firstColumn = 'ID'; + const secondColumn = 'Name'; + const thirdColumn = 'LastName'; + const exprs = [ + { fieldName: secondColumn, dir: SortingDirection.Asc, ignoreCase: true }, + { fieldName: thirdColumn, dir: SortingDirection.Desc, ignoreCase: true } + ]; + + grid.sortingExpressions = exprs; + + fixture.detectChanges(); + expect(grid.getCellByColumn(0, secondColumn).value).toEqual('Alex'); + expect(grid.getCellByColumn(0, thirdColumn).value).toEqual('Wilson'); + expect(grid.getCellByColumn(0, firstColumn).value).toEqual(4); + expect(grid.getCellByColumn(grid.data.length - 1, secondColumn).value).toEqual('Rick'); + expect(grid.getCellByColumn(grid.data.length - 1, thirdColumn).value).toEqual('BRown'); + expect(grid.getCellByColumn(grid.data.length - 1, firstColumn).value).toEqual(7); + + grid.clearSort(); + fixture.detectChanges(); + + expect(grid.sortingExpressions.length).toEqual(0); + + grid.sort(exprs); + fixture.detectChanges(); + + expect(grid.getCellByColumn(0, secondColumn).value).toEqual('Alex'); + expect(grid.getCellByColumn(0, thirdColumn).value).toEqual('Wilson'); + expect(grid.getCellByColumn(0, firstColumn).value).toEqual(4); + expect(grid.getCellByColumn(grid.data.length - 1, secondColumn).value).toEqual('Rick'); + expect(grid.getCellByColumn(grid.data.length - 1, thirdColumn).value).toEqual('BRown'); + expect(grid.getCellByColumn(grid.data.length - 1, firstColumn).value).toEqual(7); + }); + + // sort now allows only params of type ISortingExpression hence it is not possible to pass invalid expressions + it(`Should sort grid by mixed valid and invalid expressions should update the + data only by valid ones`, () => { + const firstColumn = 'ID'; + const secondColumn = 'Name'; + const thirdColumn = 'LastName'; + const invalidAndValidExp = [ + { fieldName: secondColumn, dir: SortingDirection.Desc, ignoreCase: false }, + { fieldName: firstColumn, dir: SortingDirection.Asc, ignoreCase: true } + ]; + + grid.sort(invalidAndValidExp); + + fixture.detectChanges(); + + expect(grid.getCellByColumn(0, secondColumn).value).toEqual('Rick'); + expect(grid.getCellByColumn(0, thirdColumn).value).toEqual('Jones'); + expect(grid.getCellByColumn(0, firstColumn).value).toEqual(6); + expect(grid.getCellByColumn(grid.data.length - 1, secondColumn).value).toEqual('ALex'); + expect(grid.getCellByColumn(grid.data.length - 1, thirdColumn).value).toEqual('Smith'); + expect(grid.getCellByColumn(grid.data.length - 1, firstColumn).value).toEqual(5); + + }); + + it(`Should allow sorting using a custom Sorting Strategy.`, () => { + fixture = TestBed.createComponent(SortByParityComponent); + grid = fixture.componentInstance.grid; + fixture.componentInstance.data.push( + { ID: 8, Name: 'Brad', LastName: 'Walker', Region: 'DD' }, + { ID: 9, Name: 'Mary', LastName: 'Smith', Region: 'OC' }, + { ID: 10, Name: 'Brad', LastName: 'Smith', Region: 'BD' }, + ); + fixture.detectChanges(); + grid.sort({ + fieldName: 'ID', + dir: SortingDirection.Desc, + ignoreCase: false, + strategy: new SortByParityComponent() + }); + fixture.detectChanges(); + const oddHalf: CellType[] = grid.getColumnByName('ID').cells.slice(0, 5); + const evenHalf: CellType[] = grid.getColumnByName('ID').cells.slice(5); + const isFirstHalfOdd: boolean = oddHalf.every(cell => cell.value % 2 === 1); + const isSecondHalfEven: boolean = evenHalf.every(cell => cell.value % 2 === 0); + expect(isFirstHalfOdd).toEqual(true); + expect(isSecondHalfEven).toEqual(true); + }); + + it(`Should allow sorting using a custom Sorting Strategy in multiple mode`, () => { + fixture = TestBed.createComponent(SortByAnotherColumnComponent); + grid = fixture.componentInstance.grid; + fixture.detectChanges(); + + grid.primaryKey = 'ID'; + const column = grid.getColumnByName('ID'); + fixture.detectChanges(); + + column.groupingComparer = (a: any, b: any, currRec: any, groupRec: any) => { + return currRec.Name === groupRec.Name ? 0 : -1; + } + + fixture.detectChanges(); + grid.sortingExpressions = [ + { + dir: SortingDirection.Asc, + fieldName: 'ID', + strategy: new SortByAnotherColumnComponent, + }, + { + dir: SortingDirection.Asc, + fieldName: 'LastName', + strategy: DefaultSortingStrategy.instance(), + }, + ]; + fixture.detectChanges(); + expect(grid.getCellByKey(6, 'LastName').row.index).toBeGreaterThan(grid.getCellByKey(7, 'LastName').row.index); + expect(grid.getCellByKey(4, 'LastName').row.index).toBeGreaterThan(grid.getCellByKey(5, 'LastName').row.index); + }); + + it('Should sort grid by formatted values using FormattedValuesSortingStrategy', fakeAsync(() => { + fixture = TestBed.createComponent(IgxGridFormattedValuesSortingComponent); + tick(); + fixture.detectChanges(); + + grid = fixture.componentInstance.grid; + tick(); + fixture.detectChanges(); + + const productNameColumn = grid.getColumnByName("ProductName"); + const quantityColumn = grid.getColumnByName("QuantityPerUnit"); + + expect(productNameColumn.sortStrategy instanceof FormattedValuesSortingStrategy).toBeTruthy(); + expect(quantityColumn.sortStrategy instanceof FormattedValuesSortingStrategy).toBeTruthy(); + + const productNameHeaderCell = GridFunctions.getColumnHeader('ProductName', fixture); + + GridFunctions.clickHeaderSortIcon(productNameHeaderCell); + tick(30); + fixture.detectChanges(); + + const firstProductNameCell = fixture.debugElement.queryAll(By.css('.igx-grid__td'))[1]; + expect(firstProductNameCell.nativeElement.textContent.trim()).toBe("a-Alice Mutton"); + + GridFunctions.clickHeaderSortIcon(productNameHeaderCell); + tick(30); + fixture.detectChanges(); + + const lastProductNameCell = fixture.debugElement.queryAll(By.css('.igx-grid__td'))[1]; + expect(lastProductNameCell.nativeElement.textContent.trim()).toBe("b-Tofu"); + + grid.clearSort(); + tick(); + fixture.detectChanges(); + + const quantityPerUnitHeaderCell = GridFunctions.getColumnHeader('QuantityPerUnit', fixture); + + GridFunctions.clickHeaderSortIcon(quantityPerUnitHeaderCell); + tick(30); + fixture.detectChanges(); + + const firstQuantityCell = fixture.debugElement.queryAll(By.css('.igx-grid__td'))[2]; + expect(firstQuantityCell.nativeElement.textContent.trim()).toBe("c"); + + GridFunctions.clickHeaderSortIcon(quantityPerUnitHeaderCell); + tick(30); + fixture.detectChanges(); + + const lastQuantityCell = fixture.debugElement.queryAll(By.css('.igx-grid__td'))[2]; + expect(lastQuantityCell.nativeElement.textContent.trim()).toBe("d-36 boxes"); + })); + }); + + describe('UI tests', () => { + + it('Should sort grid ascending by clicking once on first header cell UI', fakeAsync(() => { + spyOn(grid.sorting, 'emit'); + spyOn(grid.sortingDone, 'emit'); + const firstHeaderCell = GridFunctions.getColumnHeader('ID', fixture); + + GridFunctions.clickHeaderSortIcon(firstHeaderCell); + tick(30); + fixture.detectChanges(); + + expect(grid.sorting.emit).toHaveBeenCalledWith({ + cancel: false, + sortingExpressions: grid.sortingExpressions, + owner: grid + }); + expect(firstHeaderCell.attributes['aria-sort']).toEqual('ascending'); + + const firstRowFirstCell = GridFunctions.getCurrentCellFromGrid(grid, 0, 0); + const firstRowSecondCell = GridFunctions.getCurrentCellFromGrid(grid, 0, 1); + expect(GridFunctions.getValueFromCellElement(firstRowSecondCell)).toEqual('Brad'); + expect(GridFunctions.getValueFromCellElement(firstRowFirstCell)).toEqual('1'); + + const lastRowFirstCell = GridFunctions.getCurrentCellFromGrid(grid, grid.data.length - 1, 0); + const lastRowSecondCell = GridFunctions.getCurrentCellFromGrid(grid, grid.data.length - 1, 1); + expect(GridFunctions.getValueFromCellElement(lastRowFirstCell)).toEqual('7'); + expect(GridFunctions.getValueFromCellElement(lastRowSecondCell)).toEqual('Rick'); + + expect(grid.sorting.emit).toHaveBeenCalledTimes(1); + expect(grid.sortingDone.emit).toHaveBeenCalledTimes(1); + })); + + it('Should sort grid descending by clicking twice on sort icon UI', fakeAsync(() => { + spyOn(grid.sorting, 'emit').and.callThrough(); + spyOn(grid.sortingDone, 'emit').and.callThrough(); + + const firstHeaderCell = GridFunctions.getColumnHeader('ID', fixture); + + GridFunctions.clickHeaderSortIcon(firstHeaderCell); + tick(30); + fixture.detectChanges(); + + expect(grid.sorting.emit).toHaveBeenCalledWith({ + cancel: false, + sortingExpressions: grid.sortingExpressions, + owner: grid + }); + expect(firstHeaderCell.attributes['aria-sort']).toEqual('ascending'); + + GridFunctions.clickHeaderSortIcon(firstHeaderCell); + tick(30); + fixture.detectChanges(); + + expect(grid.sorting.emit).toHaveBeenCalledWith({ + cancel: false, + sortingExpressions: grid.sortingExpressions, + owner: grid + }); + expect(firstHeaderCell.attributes['aria-sort']).toEqual('descending'); + + const firstRowFirstCell = GridFunctions.getCurrentCellFromGrid(grid, 0, 0); + const firstRowSecondCell = GridFunctions.getCurrentCellFromGrid(grid, 0, 1); + expect(GridFunctions.getValueFromCellElement(firstRowFirstCell)).toEqual('7'); + expect(GridFunctions.getValueFromCellElement(firstRowSecondCell)).toEqual('Rick'); + + const lastRowFirstCell = GridFunctions.getCurrentCellFromGrid(grid, grid.data.length - 1, 0); + const lastRowSecondCell = GridFunctions.getCurrentCellFromGrid(grid, grid.data.length - 1, 1); + expect(GridFunctions.getValueFromCellElement(lastRowFirstCell)).toEqual('1'); + expect(GridFunctions.getValueFromCellElement(lastRowSecondCell)).toEqual('Brad'); + + expect(grid.sorting.emit).toHaveBeenCalledTimes(2); + expect(grid.sortingDone.emit).toHaveBeenCalledTimes(2); + })); + + it('Should sort grid none when we click three time on header sort icon UI', fakeAsync(() => { + spyOn(grid.sorting, 'emit'); + spyOn(grid.sortingDone, 'emit'); + const firstHeaderCell = GridFunctions.getColumnHeader('ID', fixture); + + GridFunctions.clickHeaderSortIcon(firstHeaderCell); + tick(30); + fixture.detectChanges(); + GridFunctions.clickHeaderSortIcon(firstHeaderCell); + tick(30); + fixture.detectChanges(); + expect(GridFunctions.getColumnSortingIndex(GridFunctions.getColumnHeader('ID', fixture))).toEqual(1); + GridFunctions.clickHeaderSortIcon(firstHeaderCell); + tick(30); + fixture.detectChanges(); + + expect(grid.sorting.emit).toHaveBeenCalledWith({ + cancel: false, + sortingExpressions: [], + owner: grid + }); + + const firstRowSecondCell = GridFunctions.getCurrentCellFromGrid(grid, 0, 1); + expect(GridFunctions.getValueFromCellElement(firstRowSecondCell)).toEqual('Jane'); + + const lastRowSecondCell = GridFunctions.getCurrentCellFromGrid(grid, grid.data.length - 1, 1); + expect(GridFunctions.getValueFromCellElement(lastRowSecondCell)).toEqual('Connor'); + + expect(GridFunctions.getColumnSortingIndex(GridFunctions.getColumnHeader('ID', fixture))).toBeNull(); + expect(grid.sorting.emit).toHaveBeenCalledTimes(3); + expect(grid.sortingDone.emit).toHaveBeenCalledTimes(3); + expect(firstHeaderCell.attributes['aria-sort']).toEqual(undefined); + })); + + it('Should have a valid sorting icon when sorting using the API.', () => { + const firstHeaderCell = GridFunctions.getColumnHeader('ID', fixture); + GridFunctions.verifyHeaderSortIndicator(firstHeaderCell, false, false); + + grid.sort({ fieldName: 'ID', dir: SortingDirection.Asc, ignoreCase: true }); + fixture.detectChanges(); + GridFunctions.verifyHeaderSortIndicator(firstHeaderCell, true); + + grid.sort({ fieldName: 'ID', dir: SortingDirection.Desc, ignoreCase: true }); + fixture.detectChanges(); + + GridFunctions.verifyHeaderSortIndicator(firstHeaderCell, false, true); + grid.clearSort(); + fixture.detectChanges(); + GridFunctions.verifyHeaderSortIndicator(firstHeaderCell, false, false); + }); + + it('Should sort grid on sorting icon click when FilterRow is visible.', fakeAsync(/** Filtering showHideArrowButtons RAF */() => { + grid.allowFiltering = true; + fixture.detectChanges(); + + GridFunctions.clickFilterCellChipUI(fixture, 'Name'); + expect(GridFunctions.getFilterRow(fixture)).toBeDefined(); + + const firstHeaderCell = GridFunctions.getColumnHeader('ID', fixture); + UIInteractions.simulateClickAndSelectEvent(firstHeaderCell); + + expect(grid.headerGroupsList[0].isFiltered).toBeTruthy(); + + GridFunctions.verifyHeaderSortIndicator(firstHeaderCell, false, false); + + GridFunctions.clickHeaderSortIcon(firstHeaderCell); + GridFunctions.clickHeaderSortIcon(firstHeaderCell); + fixture.detectChanges(); + + GridFunctions.verifyHeaderSortIndicator(firstHeaderCell, false, true); + expect(grid.getCellByColumn(0, 'ID').value).toEqual(7); + + const secondHeaderCell = GridFunctions.getColumnHeader('Name', fixture); + UIInteractions.simulateClickAndSelectEvent(secondHeaderCell); + fixture.detectChanges(); + + expect(grid.headerGroupsList[1].isFiltered).toBeTruthy(); + })); + + it('Should disable sorting feature when using NoopSortingStrategy.', fakeAsync(() => { + spyOn(grid.sorting, 'emit'); + spyOn(grid.sortingDone, 'emit'); + grid.sortStrategy = NoopSortingStrategy.instance(); + fixture.detectChanges(); + + const firstHeaderCell = GridFunctions.getColumnHeader('ID', fixture); + + GridFunctions.clickHeaderSortIcon(firstHeaderCell); + tick(30); + fixture.detectChanges(); + + expect(grid.sorting.emit).toHaveBeenCalledWith({ + cancel: false, + sortingExpressions: grid.sortingExpressions, + owner: grid + }); + + // Verify that the grid is NOT sorted. + expect(GridFunctions.getValueFromCellElement(GridFunctions.getCurrentCellFromGrid(grid, 0, 1))).toEqual('Jane'); + + expect(GridFunctions.getValueFromCellElement(GridFunctions.getCurrentCellFromGrid(grid, grid.data.length - 1, 1))).toEqual('Connor'); + + expect(GridFunctions.getColumnSortingIndex(firstHeaderCell)).toEqual(1); + + GridFunctions.clickHeaderSortIcon(firstHeaderCell); + tick(30); + fixture.detectChanges(); + + expect(grid.sorting.emit).toHaveBeenCalledWith({ + cancel: false, + sortingExpressions: grid.sortingExpressions, + owner: grid + }); + + // Verify that the grid is NOT sorted. + expect(GridFunctions.getValueFromCellElement(GridFunctions.getCurrentCellFromGrid(grid, 0, 1))).toEqual('Jane'); + + expect(GridFunctions.getValueFromCellElement(GridFunctions.getCurrentCellFromGrid(grid, grid.data.length - 1, 1))).toEqual('Connor'); + + expect(GridFunctions.getColumnSortingIndex(firstHeaderCell)).toEqual(1); + expect(grid.sorting.emit).toHaveBeenCalledTimes(2); + expect(grid.sortingDone.emit).toHaveBeenCalledTimes(2); + })); + + it('Should allow setting custom templates for header sorting none/ascending/descending icons.', () => { + fixture = TestBed.createComponent(SortByParityComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + const fieldName = 'Name'; + const header = GridFunctions.getColumnHeader(fieldName, fixture, grid); + let icon = GridFunctions.getHeaderSortIcon(header); + + expect(icon.nativeElement.textContent.toLowerCase().trim()).toBe('unfold_more'); + + grid.sort({ fieldName, dir: SortingDirection.Asc, ignoreCase: false }); + fixture.detectChanges(); + icon = GridFunctions.getHeaderSortIcon(header); + expect(icon.nativeElement.textContent.toLowerCase().trim()).toBe('expand_less'); + + grid.sort({ fieldName, dir: SortingDirection.Desc, ignoreCase: false }); + fixture.detectChanges(); + icon = GridFunctions.getHeaderSortIcon(header); + expect(icon.nativeElement.textContent.toLowerCase().trim()).toBe('expand_more'); + }); + + it('Should allow setting custom templates for header sorting none/ascending/descending icons via Input.', () => { + fixture = TestBed.createComponent(SortByParityComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + grid.sortHeaderIconTemplate = fixture.componentInstance.sortIconTemplate; + grid.sortAscendingHeaderIconTemplate = fixture.componentInstance.sortAscIconTemplate; + grid.sortDescendingHeaderIconTemplate = fixture.componentInstance.sortDescIconTemplate; + fixture.detectChanges(); + const header = GridFunctions.getColumnHeader('Name', fixture, grid); + let icon = GridFunctions.getHeaderSortIcon(header); + expect(icon.nativeElement.textContent.toLowerCase().trim()).toBe('arrow_right'); + + grid.sort({ fieldName: 'Name', dir: SortingDirection.Asc, ignoreCase: false }); + fixture.detectChanges(); + icon = GridFunctions.getHeaderSortIcon(header); + expect(icon.nativeElement.textContent.toLowerCase().trim()).toBe('arrow_drop_up'); + + grid.sort({ fieldName: 'Name', dir: SortingDirection.Desc, ignoreCase: false }); + fixture.detectChanges(); + icon = GridFunctions.getHeaderSortIcon(header); + expect(icon.nativeElement.textContent.toLowerCase().trim()).toBe('arrow_drop_down'); + }); + + it('Should be able to set single sorting mode and sort one column at a time', fakeAsync(() => { + fixture = TestBed.createComponent(SortByParityComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + const fieldName = 'Name'; + const fieldLastName = 'LastName'; + const header = GridFunctions.getColumnHeader(fieldName, fixture, grid); + const headerLastName = GridFunctions.getColumnHeader(fieldLastName, fixture, grid); + let icon = GridFunctions.getHeaderSortIcon(header); + + grid.sortingOptions = { mode: 'single' }; + fixture.detectChanges(); + + expect(icon.nativeElement.textContent.toLowerCase().trim()).toBe('unfold_more'); + + GridFunctions.clickHeaderSortIcon(header); + tick(30); + fixture.detectChanges(); + + icon = GridFunctions.getHeaderSortIcon(header); + expect(icon.nativeElement.textContent.toLowerCase().trim()).toBe('expand_less'); + + GridFunctions.clickHeaderSortIcon(headerLastName); + tick(30); + fixture.detectChanges(); + + icon = GridFunctions.getHeaderSortIcon(header); + const iconLastName = GridFunctions.getHeaderSortIcon(headerLastName); + expect(icon.nativeElement.textContent.toLowerCase().trim()).toBe('unfold_more'); + expect(iconLastName.nativeElement.textContent.toLowerCase().trim()).toBe('expand_less'); + })); + + + it('should not display sorting index when sorting mode is set to "single"', fakeAsync(() => { + fixture = TestBed.createComponent(SortByParityComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + const fieldName = 'Name'; + const fieldLastName = 'LastName'; + const header = GridFunctions.getColumnHeader(fieldName, fixture, grid); + const headerLastName = GridFunctions.getColumnHeader(fieldLastName, fixture, grid); + + grid.sortingOptions = { mode: 'single' }; + fixture.detectChanges(); + + GridFunctions.clickHeaderSortIcon(header); + tick(30); + fixture.detectChanges(); + expect(GridFunctions.getColumnSortingIndex(header)).toBeNull(); + expect(grid.sortingExpressions.length).toBe(1); + + GridFunctions.clickHeaderSortIcon(headerLastName); + tick(30); + fixture.detectChanges(); + + expect(GridFunctions.getColumnSortingIndex(headerLastName)).toBeNull(); + expect(grid.sortingExpressions.length).toBe(1); + })); + + it('should not clear sortingExpressions when setting sortingOptions on init. ', fakeAsync(() => { + fixture = TestBed.createComponent(SortOnInitComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + expect(grid.sortingExpressions.length).toBe(1); + })); + }); +}); diff --git a/projects/igniteui-angular/grids/grid/src/grid.summary.pipe.ts b/projects/igniteui-angular/grids/grid/src/grid.summary.pipe.ts new file mode 100644 index 00000000000..253b9a43976 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid.summary.pipe.ts @@ -0,0 +1,139 @@ +import { Pipe, PipeTransform, inject } from '@angular/core'; +import { GridSummaryPosition, GridType, IGX_GRID_BASE } from 'igniteui-angular/grids/core'; +import { GridSummaryCalculationMode, IGroupByRecord, IGroupByResult, ISummaryRecord } from 'igniteui-angular/core'; + +/** @hidden */ +interface ISkipRecord { skip?: boolean } + +/** @hidden */ +@Pipe({ + name: 'gridSummary', + standalone: true +}) +export class IgxGridSummaryPipe implements PipeTransform { + private grid = inject(IGX_GRID_BASE); + + + public transform(collection: IGroupByResult, + hasSummary: boolean, + summaryCalculationMode: GridSummaryCalculationMode, + summaryPosition: GridSummaryPosition, + id: string, showSummary, _: number, __: number): any[] { + + if (!collection.data || !hasSummary || summaryCalculationMode === GridSummaryCalculationMode.rootLevelOnly) { + return collection.data; + } + + return this.addSummaryRows(id, collection, summaryPosition, showSummary); + } + + private addSummaryRows(gridId: string, collection: IGroupByResult, summaryPosition: GridSummaryPosition, showSummary): any[] { + const recordsWithSummary = []; + const lastChildMap = new Map(); + const maxSummaryHeight = this.grid.summaryService.calcMaxSummaryHeight(); + + if (collection.metadata.length && !this.grid.isGroupByRecord(collection.data[0]) && + this.grid.isGroupByRecord(collection.metadata[0]) && summaryPosition === GridSummaryPosition.bottom) { + const groups: Array = []; + groups.push(collection.metadata[0]); + while (groups[groups.length - 1].groupParent) { + groups.push(groups[groups.length - 1].groupParent); + } + groups.reverse(); + groups.forEach(g => g.skip = true); + collection.data.splice(0, 0, ...groups); + } + for (const record of collection.data) { + let skipAdd = false; + let recordId; + let groupByRecord: IGroupByRecord = null; + if (this.grid.isGroupByRecord(record)) { + skipAdd = !!record.skip; + record.skip = null; + groupByRecord = record as IGroupByRecord; + recordId = this.grid.gridAPI.get_groupBy_record_id(groupByRecord); + } else { + recordId = this.grid.gridAPI.get_row_id(record); + } + if (!skipAdd) { + recordsWithSummary.push(record); + } + + if (summaryPosition === GridSummaryPosition.bottom && showSummary && + (groupByRecord && !this.grid.isExpandedGroup(groupByRecord))) { + const records = this.removeDeletedRecord(this.grid, groupByRecord.records.slice()); + const summaries = this.grid.summaryService.calculateSummaries(recordId, records); + const summaryRecord: ISummaryRecord = { + summaries, + max: maxSummaryHeight + }; + recordsWithSummary.push(summaryRecord); + } + if (summaryPosition === GridSummaryPosition.bottom && lastChildMap.has(recordId)) { + const groupRecords = lastChildMap.get(recordId); + + for (const groupRecord of groupRecords) { + const groupRecordId = this.grid.gridAPI.get_groupBy_record_id(groupRecord); + const records = this.removeDeletedRecord(this.grid, groupRecord.records.slice()); + const summaries = this.grid.summaryService.calculateSummaries(groupRecordId, records, groupRecord); + const summaryRecord: ISummaryRecord = { + summaries, + max: maxSummaryHeight + }; + recordsWithSummary.push(summaryRecord); + } + } + + const showSummaries = showSummary ? false : (groupByRecord && !this.grid.isExpandedGroup(groupByRecord)); + if (groupByRecord === null || showSummaries) { + continue; + } + + if (summaryPosition === GridSummaryPosition.top) { + const records = this.removeDeletedRecord(this.grid, groupByRecord.records.slice()); + const summaries = this.grid.summaryService.calculateSummaries(recordId, records, groupByRecord); + const summaryRecord: ISummaryRecord = { + summaries, + max: maxSummaryHeight + }; + recordsWithSummary.push(summaryRecord); + } else if (summaryPosition === GridSummaryPosition.bottom) { + let lastChild = groupByRecord; + + while (lastChild.groups && lastChild.groups.length > 0 && this.grid.isExpandedGroup(lastChild)) { + lastChild = lastChild.groups[lastChild.groups.length - 1]; + } + + let lastChildId; + if (this.grid.isExpandedGroup(lastChild)) { + lastChildId = this.grid.gridAPI.get_row_id(lastChild.records[lastChild.records.length - 1]); + } else { + lastChildId = this.grid.gridAPI.get_groupBy_record_id(lastChild); + } + + let groupRecords = lastChildMap.get(lastChildId); + if (!groupRecords) { + groupRecords = []; + lastChildMap.set(lastChildId, groupRecords); + } + groupRecords.unshift(groupByRecord); + } + } + return recordsWithSummary; + } + + private removeDeletedRecord(grid: GridType, data: any[]) { + if (!grid.transactions.enabled) { + return data; + } + const deletedRows = grid.transactions.getTransactionLog().filter(t => t.type === 'delete').map(t => t.id); + deletedRows.forEach(rowID => { + const tempData = grid.primaryKey ? data.map(rec => rec[grid.primaryKey]) : data; + const index = tempData.indexOf(rowID); + if (index !== -1) { + data.splice(index, 1); + } + }); + return data; + } +} diff --git a/projects/igniteui-angular/grids/grid/src/groupby-row.component.html b/projects/igniteui-angular/grids/grid/src/groupby-row.component.html new file mode 100644 index 00000000000..7362a297960 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/groupby-row.component.html @@ -0,0 +1,78 @@ + + + @if (rowDraggable) { +
    + +
    + } + + @if (showRowSelectors) { +
    + + +
    + } + +
    + + +
    + +
    + + +
    + + + + + + + + + + + +
    + + + {{ groupRow.column && groupRow.column.header ? + groupRow.column.header : + (groupRow.expression ? groupRow.expression.fieldName : '') }}: + + + {{ + formatter + ? (groupRow.value | columnFormatter:formatter:groupRow.records[0]:null) + : dataType === "number" + ? (groupRow.value | number:groupRow.column.pipeArgs.digitsInfo:grid.locale) + : (dataType === 'date' || dataType === 'time' || dataType === 'dateTime') + ? (groupRow.value | date:groupRow.column.pipeArgs.format:groupRow.column.pipeArgs.timezone:grid.locale) + : dataType === 'currency' + ? (groupRow.value | currency:currencyCode:groupRow.column.pipeArgs.display:groupRow.column.pipeArgs.digitsInfo:grid.locale) + : dataType === 'percent' + ? (groupRow.value | percent:groupRow.column.pipeArgs.digitsInfo:grid.locale) + : groupRow.value + }} + + + +
    +
    + +
    + + +
    +
    +
    diff --git a/projects/igniteui-angular/grids/grid/src/groupby-row.component.ts b/projects/igniteui-angular/grids/grid/src/groupby-row.component.ts new file mode 100644 index 00000000000..825bea251bc --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/groupby-row.component.ts @@ -0,0 +1,329 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, HostBinding, HostListener, Input, ViewChild, TemplateRef, OnDestroy, inject } from '@angular/core'; +import { NgTemplateOutlet, DecimalPipe, DatePipe, getLocaleCurrencyCode, PercentPipe, CurrencyPipe } from '@angular/common'; +import { takeUntil } from 'rxjs/operators'; +import { Subject } from 'rxjs'; +import { + GridSelectionMode, + GridType, + IGX_GRID_BASE, + IgxColumnFormatterPipe, + IgxFilteringService, + IgxGridSelectionService, + ISelectionNode +} from 'igniteui-angular/grids/core'; +import { IgxGridRowComponent } from './grid-row.component'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxBadgeComponent } from 'igniteui-angular/badge'; +import { IgxCheckboxComponent } from 'igniteui-angular/checkbox'; +import { GridColumnDataType, IGroupByRecord } from 'igniteui-angular/core'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'igx-grid-groupby-row', + templateUrl: './groupby-row.component.html', + imports: [ + NgTemplateOutlet, + DecimalPipe, + DatePipe, + PercentPipe, + CurrencyPipe, + IgxIconComponent, + IgxBadgeComponent, + IgxCheckboxComponent, + IgxColumnFormatterPipe + ] +}) +export class IgxGridGroupByRowComponent implements OnDestroy { + public grid = inject(IGX_GRID_BASE); + public gridSelection = inject(IgxGridSelectionService); + public element = inject(ElementRef); + public cdr = inject(ChangeDetectorRef); + public filteringService = inject(IgxFilteringService); + + /** + * @hidden + */ + @Input() + public hideGroupRowSelectors: boolean; + + /** + * @hidden + */ + @Input() + public rowDraggable: boolean; + + /** + * Sets the index of the row. + * ```html + * + * ``` + */ + @Input() + public index: number; + + /** + * Sets the id of the grid the row belongs to. + * ```html + * + * ``` + */ + @Input() + public gridID: string; + + /** + * The group record the component renders for. + * ```typescript + * + * ``` + */ + @Input() + public groupRow: IGroupByRecord; + + /** + * Returns a reference of the content of the group. + * ```typescript + * const groupRowContent = this.grid1.rowList.first.groupContent; + * ``` + */ + @ViewChild('groupContent', { static: true }) + public groupContent: ElementRef; + + /** + * @hidden + */ + @Input() + protected isFocused = false; + + /** + * @hidden + */ + @ViewChild('defaultGroupByExpandedTemplate', { read: TemplateRef, static: true }) + protected defaultGroupByExpandedTemplate: TemplateRef; + + /** + * @hidden + */ + @ViewChild('defaultGroupByCollapsedTemplate', { read: TemplateRef, static: true }) + protected defaultGroupByCollapsedTemplate: TemplateRef; + + /** + * @hidden + */ + protected destroy$ = new Subject(); + + /** + * @hidden + */ + protected defaultCssClass = 'igx-grid__group-row'; + + /** + * @hidden + */ + protected paddingIndentationCssClass = 'igx-grid__group-row--padding-level'; + + /** + * Returns whether the row is focused. + * ``` + * let gridRowFocused = this.grid1.rowList.first.focused; + * ``` + */ + public get focused(): boolean { + return this.isActive(); + } + + /** @hidden @internal */ + public get currencyCode(): string { + return this.groupRow.column.pipeArgs.currencyCode ? + this.groupRow.column.pipeArgs.currencyCode : getLocaleCurrencyCode(this.grid.locale); + } + + constructor() { + this.gridSelection.selectedRowsChange.pipe(takeUntil(this.destroy$)).subscribe(() => { + this.cdr.markForCheck(); + }); + } + + + @HostListener('pointerdown') + public activate() { + this.grid.navigation.setActiveNode({ row: this.index }); + } + + @HostListener('click', ['$event']) + public onClick(event: MouseEvent) { + this.grid.rowClick.emit({ + row: this.grid.createRow(this.index), + event + }); + } + + /** + * @hidden + * @internal + */ + public ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** + * Returns whether the group row is expanded. + * ```typescript + * const groupRowExpanded = this.grid1.rowList.first.expanded; + * ``` + */ + @HostBinding('attr.aria-expanded') + public get expanded(): boolean { + return this.grid.isExpandedGroup(this.groupRow); + } + + /** + * @hidden + */ + @HostBinding('attr.aria-describedby') + public get describedBy(): string { + const grRowExpr = this.groupRow.expression !== undefined ? this.groupRow.expression.fieldName : ''; + return this.gridID + '_' + grRowExpr; + } + + @HostBinding('attr.data-rowIndex') + public get dataRowIndex() { + return this.index; + } + + /** + * Returns a reference to the underlying HTML element. + * ```typescript + * const groupRowElement = this.nativeElement; + * ``` + */ + public get nativeElement(): any { + return this.element.nativeElement; + } + + @HostBinding('attr.id') + public get attrCellID() { + return `${this.gridID}_${this.index}`; + } + + /** + * Returns the style classes applied to the group rows. + * ```typescript + * const groupCssStyles = this.grid1.rowList.first.styleClasses; + * ``` + */ + @HostBinding('class') + public get styleClasses(): string { + return `${this.defaultCssClass} ` + `${this.paddingIndentationCssClass}-` + this.groupRow.level + + (this.isActive() ? ` ${this.defaultCssClass}--active` : ''); + } + + public isActive() { + return this.grid.navigation.activeNode ? this.grid.navigation.activeNode.row === this.index : false; + } + + /** + * @hidden @internal + */ + public getRowID(rowData): IgxGridRowComponent { + return this.grid.primaryKey ? rowData[this.grid.primaryKey] : rowData; + } + + /** + * @hidden @internal + */ + public onGroupSelectorClick(event) { + if (!this.grid.isMultiRowSelectionEnabled) { + return; + } + event.stopPropagation(); + if (this.areAllRowsInTheGroupSelected) { + this.gridSelection.deselectRows(this.groupRow.records.map(x => this.getRowID(x))); + } else { + this.gridSelection.selectRows(this.groupRow.records.map(x => this.getRowID(x))); + } + } + + /** + * Toggles the group row. + * ```typescript + * this.grid1.rowList.first.toggle() + * ``` + */ + public toggle() { + this.grid.toggleGroup(this.groupRow); + } + + public get iconTemplate() { + if (this.expanded) { + return this.grid.rowExpandedIndicatorTemplate || this.defaultGroupByExpandedTemplate; + } else { + return this.grid.rowCollapsedIndicatorTemplate || this.defaultGroupByCollapsedTemplate; + } + } + + protected get selectionNode(): ISelectionNode { + return { + row: this.index, + column: this.gridSelection.activeElement ? this.gridSelection.activeElement.column : 0 + }; + } + + /** + * @hidden @internal + */ + public get dataType(): any { + const column = this.groupRow.column; + return (column && column.dataType) || GridColumnDataType.String; + } + + /** + * @hidden @internal + */ + public get formatter(): any { + const column = this.groupRow.column; + return (column && column.formatter) || null; + } + + /** + * @hidden @internal + */ + public get areAllRowsInTheGroupSelected(): boolean { + return this.groupRow.records.every(x => this.gridSelection.isRowSelected(this.getRowID(x))); + } + + /** + * @hidden @internal + */ + public get selectedRowsInTheGroup(): any[] { + const selectedIds = new Set(this.gridSelection.filteredSelectedRowIds); + return this.groupRow.records.filter(rowID => selectedIds.has(this.getRowID(rowID))); + } + + /** + * @hidden @internal + */ + public get groupByRowCheckboxIndeterminateState(): boolean { + if (this.selectedRowsInTheGroup.length > 0) { + return !this.areAllRowsInTheGroupSelected; + } + return false; + } + + /** + * @hidden @internal + */ + public get groupByRowSelectorBaseAriaLabel(): string { + const ariaLabel: string = this.areAllRowsInTheGroupSelected ? + this.grid.resourceStrings.igx_grid_groupByArea_deselect_message : this.grid.resourceStrings.igx_grid_groupByArea_select_message; + return ariaLabel.replace('{0}', this.groupRow.expression.fieldName).replace('{1}', this.groupRow.value); + } + + /** + * @hidden @internal + */ + public get showRowSelectors(): boolean { + return this.grid.rowSelection !== GridSelectionMode.none && !this.hideGroupRowSelectors; + } + +} diff --git a/projects/igniteui-angular/grids/grid/src/public_api.ts b/projects/igniteui-angular/grids/grid/src/public_api.ts new file mode 100644 index 00000000000..6e0e1131782 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/public_api.ts @@ -0,0 +1,193 @@ +import { IgxGridComponent } from './grid.component'; + +/* Imports that cannot be resolved from IGX_GRID_COMMON_DIRECTIVES spread + NOTE: Do not remove! Issue: https://github.com/IgniteUI/igniteui-angular/issues/13310 +*/ + +import { + IgxRowDirective, + IgxGridFooterComponent, + IgxAdvancedFilteringDialogComponent, + IgxHeaderCollapsedIndicatorDirective, + IgxHeaderExpandedIndicatorDirective, + IgxRowCollapsedIndicatorDirective, + IgxRowExpandedIndicatorDirective, + IgxSortAscendingHeaderIconDirective, + IgxSortDescendingHeaderIconDirective, + IgxSortHeaderIconDirective, + IgxGridEmptyTemplateDirective, + IgxGridLoadingTemplateDirective, + IgxExcelStyleHeaderIconDirective, + IgxDragIndicatorIconDirective, + IgxRowDragGhostDirective, + IgxGridStateDirective, + IgxGridHeaderComponent, + IgxGridHeaderGroupComponent, + IgxGridHeaderRowComponent, + IgxFilterCellTemplateDirective, + IgxSummaryTemplateDirective, + IgxCellTemplateDirective, + IgxCellValidationErrorDirective, + IgxCellHeaderTemplateDirective, + IgxCellFooterTemplateDirective, + IgxCellEditorTemplateDirective, + IgxCollapsibleIndicatorTemplateDirective, + IgxColumnComponent, + IgxColumnGroupComponent, + IgxColumnLayoutComponent, + IgxColumnRequiredValidatorDirective, + IgxColumnMinValidatorDirective, + IgxColumnMaxValidatorDirective, + IgxColumnEmailValidatorDirective, + IgxColumnMinLengthValidatorDirective, + IgxColumnMaxLengthValidatorDirective, + IgxColumnPatternValidatorDirective, + IgxColumnActionsComponent, + IgxColumnHidingDirective, + IgxColumnPinningDirective, + IgxRowSelectorDirective, + IgxGroupByRowSelectorDirective, + IgxHeadSelectorDirective, + IgxCSVTextDirective, + IgxExcelTextDirective, + IgxGridToolbarActionsComponent, + IgxGridToolbarAdvancedFilteringComponent, + IgxGridToolbarComponent, + IgxGridToolbarExporterComponent, + IgxGridToolbarHidingComponent, + IgxGridToolbarPinningComponent, + IgxGridToolbarTitleComponent, + IgxGridToolbarDirective, + IgxGridExcelStyleFilteringComponent, + IgxExcelStyleHeaderComponent, + IgxExcelStyleSortingComponent, + IgxExcelStylePinningComponent, + IgxExcelStyleHidingComponent, + IgxExcelStyleSelectingComponent, + IgxExcelStyleClearFiltersComponent, + IgxExcelStyleConditionalFilterComponent, + IgxExcelStyleMovingComponent, + IgxExcelStyleSearchComponent, + IgxExcelStyleColumnOperationsTemplateDirective, + IgxExcelStyleFilterOperationsTemplateDirective, + IgxExcelStyleLoadingValuesTemplateDirective, + IgxGridDetailTemplateDirective, + IgxGroupByRowTemplateDirective, + IgxRowAddTextDirective, + IgxRowEditActionsDirective, + IgxRowEditTabStopDirective, + IgxRowEditTextDirective, + IgxGridActionButtonComponent, + IgxGridPinningActionsComponent, + IgxGridActionsBaseDirective, + IgxGridEditingActionsComponent +} from "igniteui-angular/grids/core"; +import { + IgxPaginatorComponent, + IgxPageNavigationComponent, + IgxPageSizeSelectorComponent, + IgxPaginatorContentDirective, + IgxPaginatorDirective +} from 'igniteui-angular/paginator'; + +export * from './grid.component'; +export * from './grid-base.directive'; +export * from './grid.pipes'; +export * from './grid-row.component'; +export * from './expandable-cell.component'; + +/* NOTE: Grid directives collection for ease-of-use import in standalone components scenario */ +export const IGX_GRID_DIRECTIVES = [ + IgxGridComponent, + IgxGroupByRowTemplateDirective, + IgxGridDetailTemplateDirective, + IgxRowAddTextDirective, + IgxRowEditActionsDirective, + IgxRowEditTextDirective, + IgxRowEditTabStopDirective, + // IGX_GRID_COMMON_DIRECTIVES: + IgxRowDirective, + IgxGridFooterComponent, + IgxAdvancedFilteringDialogComponent, + IgxRowExpandedIndicatorDirective, + IgxRowCollapsedIndicatorDirective, + IgxHeaderExpandedIndicatorDirective, + IgxHeaderCollapsedIndicatorDirective, + IgxExcelStyleHeaderIconDirective, + IgxSortAscendingHeaderIconDirective, + IgxSortDescendingHeaderIconDirective, + IgxSortHeaderIconDirective, + IgxGridEmptyTemplateDirective, + IgxGridLoadingTemplateDirective, + IgxDragIndicatorIconDirective, + IgxRowDragGhostDirective, + IgxGridStateDirective, + // IGX_GRID_ACTIONS + IgxGridPinningActionsComponent, + IgxGridEditingActionsComponent, + IgxGridActionsBaseDirective, + IgxGridActionButtonComponent, + // IGX_GRID_HEADERS_DIRECTIVES: + IgxGridHeaderComponent, + IgxGridHeaderGroupComponent, + IgxGridHeaderRowComponent, + // IGX_GRID_COLUMN_DIRECTIVES: + IgxFilterCellTemplateDirective, + IgxSummaryTemplateDirective, + IgxCellTemplateDirective, + IgxCellValidationErrorDirective, + IgxCellHeaderTemplateDirective, + IgxCellFooterTemplateDirective, + IgxCellEditorTemplateDirective, + IgxCollapsibleIndicatorTemplateDirective, + IgxColumnComponent, + IgxColumnGroupComponent, + IgxColumnLayoutComponent, + // IGX_GRID_COLUMN_ACTIONS_DIRECTIVES: + IgxColumnActionsComponent, + IgxColumnHidingDirective, + IgxColumnPinningDirective, + // IGX_GRID_SELECTION_DIRECTIVES: + IgxRowSelectorDirective, + IgxGroupByRowSelectorDirective, + IgxHeadSelectorDirective, + // IGX_GRID_TOOLBAR_DIRECTIVES: + IgxCSVTextDirective, + IgxExcelTextDirective, + IgxGridToolbarActionsComponent, + IgxGridToolbarAdvancedFilteringComponent, + IgxGridToolbarComponent, + IgxGridToolbarExporterComponent, + IgxGridToolbarHidingComponent, + IgxGridToolbarPinningComponent, + IgxGridToolbarTitleComponent, + IgxGridToolbarDirective, + // IGX_GRID_EXCEL_STYLE_FILTER_DIRECTIVES: + IgxGridExcelStyleFilteringComponent, + IgxExcelStyleHeaderComponent, + IgxExcelStyleSortingComponent, + IgxExcelStylePinningComponent, + IgxExcelStyleHidingComponent, + IgxExcelStyleSelectingComponent, + IgxExcelStyleClearFiltersComponent, + IgxExcelStyleConditionalFilterComponent, + IgxExcelStyleMovingComponent, + IgxExcelStyleSearchComponent, + IgxExcelStyleColumnOperationsTemplateDirective, + IgxExcelStyleFilterOperationsTemplateDirective, + IgxExcelStyleLoadingValuesTemplateDirective, + // IGX_GRID_VALIDATION_DIRECTIVES: + IgxColumnRequiredValidatorDirective, + IgxColumnMinValidatorDirective, + IgxColumnMaxValidatorDirective, + IgxColumnEmailValidatorDirective, + IgxColumnMinLengthValidatorDirective, + IgxColumnMaxLengthValidatorDirective, + IgxColumnPatternValidatorDirective, + // IGX_PAGINATOR_DIRECTIVES: + IgxPaginatorComponent, + IgxPageNavigationComponent, + IgxPageSizeSelectorComponent, + IgxPaginatorContentDirective, + IgxPaginatorDirective +] as const; diff --git a/projects/igniteui-angular/grids/grid/src/row-drag.directive.spec.ts b/projects/igniteui-angular/grids/grid/src/row-drag.directive.spec.ts new file mode 100644 index 00000000000..a1d179f31f4 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/row-drag.directive.spec.ts @@ -0,0 +1,1551 @@ +import { Component, ViewChild, DebugElement, QueryList, TemplateRef } from '@angular/core'; +import { TestBed, ComponentFixture, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; +import { DataParent, SampleTestData } from '../../../test-utils/sample-test-data.spec'; + +import { IgxGridComponent } from './grid.component'; +import { IgxColumnComponent, IgxGridNavigationService } from 'igniteui-angular/grids/core'; +import { IgxDragIndicatorIconDirective, IgxRowDragDirective, IgxRowDragGhostDirective } from 'igniteui-angular/grids/core'; +import { IRowDragStartEventArgs, IRowDragEndEventArgs } from 'igniteui-angular/grids/core'; +import { IgxDropDirective } from 'igniteui-angular/directives'; + +import { GridSelectionMode } from 'igniteui-angular/grids/core'; +import { CellType, GridType, RowType } from 'igniteui-angular/grids/core'; +import { IgxRowDirective } from 'igniteui-angular/grids/core'; +import { NgStyle } from '@angular/common'; +import { IgxStringFilteringOperand, Point, SortingDirection } from 'igniteui-angular/core'; +import { IgxHierarchicalGridComponent, IgxRowIslandComponent } from 'igniteui-angular/grids/hierarchical-grid'; +import { IgxTreeGridComponent } from 'igniteui-angular/grids/tree-grid'; +import { IgxIconComponent } from 'igniteui-angular/icon'; + +const DEBOUNCE_TIME = 50; +const CSS_CLASS_DRAG_INDICATOR = '.igx-grid__drag-indicator'; +const CSS_CLASS_DRAG_INDICATOR_OFF = 'igx-grid__drag-indicator--off'; +const CSS_CLASS_GRID_ROW = '.igx-grid__tr'; +const CSS_CLASS_DRAG_ROW = 'igx-grid__tr--drag'; +const CSS_CLASS_GHOST_ROW = 'igx-grid__tr--ghost'; +const CSS_CLASS_SELECTED_ROW = 'igx-grid__tr--selected'; +const CSS_CLASS_SELECTION_CHECKBOX = '.igx-grid__cbx-selection'; +const CSS_CLASS_VIRTUAL_HSCROLLBAR = '.igx-vhelper--horizontal'; +const CSS_CLASS_LAST_PINNED_HEADER = 'igx-grid-th--pinned-last'; +const CSS_CLASS_DROPPABLE_AREA = '.droppable-area'; +const CSS_CLASS_NON_DROPPABLE_AREA = '.non-droppable-area'; + +describe('Row Drag Tests', () => { + + describe('Flat Grid', () => { + let fixture: ComponentFixture; + let dropAreaElement: Element; + let dragIndicatorElements: DebugElement[]; + let dragIndicatorElement: Element; + let rowDragDirective: IgxRowDragDirective; + let startPoint: Point; + let movePoint: Point; + let dropPoint: Point; + let pointerDownEvent: PointerEvent; + let pointerMoveEvent: PointerEvent; + let pointerUpEvent: PointerEvent; + + describe('General tests', () => { + describe('Drag and drop tests', () => { + let grid: IgxGridComponent; + let nonDroppableAreaElement: Element; + let rows: IgxRowDirective[]; + let dragRows: DebugElement[]; + let rowToDrag: IgxRowDirective; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxGridRowDraggableComponent + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(IgxGridRowDraggableComponent); + grid = fixture.componentInstance.instance; + fixture.detectChanges(); + rows = grid.rowList.toArray(); + dropAreaElement = fixture.debugElement.query(By.css(CSS_CLASS_DROPPABLE_AREA)).nativeElement; + nonDroppableAreaElement = fixture.debugElement.query(By.css(CSS_CLASS_NON_DROPPABLE_AREA)).nativeElement; + dragIndicatorElements = fixture.debugElement.queryAll(By.css(CSS_CLASS_DRAG_INDICATOR)); + dragRows = fixture.debugElement.queryAll(By.directive(IgxRowDragDirective)); + }); + + it('should drag and drop draggable row over droppable container', () => { + dragIndicatorElement = dragIndicatorElements[2].nativeElement; + rowToDrag = rows[1]; + rowDragDirective = dragRows[1].injector.get(IgxRowDragDirective); + + startPoint = UIInteractions.getPointFromElement(dragIndicatorElement); + movePoint = UIInteractions.getPointFromElement(rows[4].nativeElement); + dropPoint = UIInteractions.getPointFromElement(dropAreaElement); + pointerDownEvent = UIInteractions.createPointerEvent('pointerdown', startPoint); + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', movePoint); + pointerUpEvent = UIInteractions.createPointerEvent('pointerup', dropPoint); + + spyOn(grid.rowDragStart, 'emit'); + spyOn(grid.rowDragEnd, 'emit'); + + expect(rowToDrag.dragging).toBeFalsy(); + expect(rowToDrag.grid.rowDragging).toBeFalsy(); + + rowDragDirective.onPointerDown(pointerDownEvent); + rowDragDirective.onPointerMove(pointerMoveEvent); + expect(rowToDrag.dragging).toBeTruthy(); + expect(rowToDrag.grid.rowDragging).toBeTruthy(); + verifyRowDragStartEvent(grid, grid.getRowByIndex(rowToDrag.index), rowToDrag.nativeElement, rowDragDirective); + + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', dropPoint); + rowDragDirective.onPointerMove(pointerMoveEvent); + expect(rowToDrag.dragging).toBeTruthy(); + expect(rowToDrag.grid.rowDragging).toBeTruthy(); + + rowDragDirective.onPointerUp(pointerUpEvent); + expect(rowToDrag.dragging).toBeFalsy(); + expect(rowToDrag.grid.rowDragging).toBeFalsy(); + verifyRowDragEndEvent(grid, grid.getRowByIndex(rowToDrag.index), rowToDrag.nativeElement, rowDragDirective, false); + }); + it('should be able to drag row only by drag icon', async () => { + dragIndicatorElement = dragIndicatorElements[2].nativeElement; + rowDragDirective = dragRows[1].injector.get(IgxRowDragDirective); + rowToDrag = rows[1]; + const rowElement = rowToDrag.nativeElement; + + const dragIndicatorPoint = UIInteractions.getPointFromElement(dragIndicatorElement); + const rowPoint = UIInteractions.getPointFromElement(rowElement); + movePoint = UIInteractions.getPointFromElement(rows[4].nativeElement); + pointerDownEvent = UIInteractions.createPointerEvent('pointerdown', startPoint); + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', movePoint); + pointerUpEvent = UIInteractions.createPointerEvent('pointerup', dropPoint); + spyOn(grid.rowDragStart, 'emit'); + + expect(rowToDrag.dragging).toBeFalsy(); + expect(rowToDrag.grid.rowDragging).toBeFalsy(); + + await pointerDown(rowElement, rowPoint, fixture); + await pointerMove(rowElement, movePoint, fixture); + expect(rowToDrag.dragging).toBeFalsy(); + expect(rowToDrag.grid.rowDragging).toBeFalsy(); + expect(grid.rowDragStart.emit).toHaveBeenCalledTimes(0); + + await pointerDown(dragIndicatorElement, dragIndicatorPoint, fixture); + await pointerMove(dragIndicatorElement, movePoint, fixture); + expect(rowToDrag.dragging).toBeTruthy(); + expect(rowToDrag.grid.rowDragging).toBeTruthy(); + verifyRowDragStartEvent(grid, grid.getRowByIndex(rowToDrag.index), rowToDrag.nativeElement, rowDragDirective); + await pointerUp(dragIndicatorElement, movePoint, fixture); + }); + it('should not be able to drag grid header', () => { + const header = fixture.debugElement.query(By.css(CSS_CLASS_GRID_ROW)); + const headerDragDirective = header.injector.get(IgxRowDragDirective, false); + expect(headerDragDirective).toBe(false); + }); + it('should cancel dragging when ESCAPE key is pressed.', async () => { + dragIndicatorElement = dragIndicatorElements[2].nativeElement; + const row = rows[1]; + rowDragDirective = dragRows[1].injector.get(IgxRowDragDirective); + + startPoint = UIInteractions.getPointFromElement(dragIndicatorElement); + movePoint = UIInteractions.getPointFromElement(rows[4].nativeElement); + spyOn(grid.rowDragStart, 'emit'); + spyOn(grid.rowDragEnd, 'emit'); + + rowDragDirective.onPointerDown(UIInteractions.createPointerEvent('pointerdown', startPoint)); + rowDragDirective.onPointerMove(UIInteractions.createPointerEvent('pointermove', movePoint)); + expect(row.dragging).toBeTruthy(); + expect(grid.rowDragging).toBeTruthy(); + expect(grid.rowDragStart.emit).toHaveBeenCalledTimes(1); + + UIInteractions.triggerKeyDownEvtUponElem('Escape', dragIndicatorElement); + await wait(DEBOUNCE_TIME); + fixture.detectChanges(); + expect(row.dragging).toBeFalsy(); + expect(grid.rowDragging).toBeFalsy(); + expect(grid.rowDragEnd.emit).toHaveBeenCalledTimes(1); + }); + it('should create ghost element upon row dragging', () => { + dragIndicatorElement = dragIndicatorElements[2].nativeElement; + rowDragDirective = dragRows[1].injector.get(IgxRowDragDirective); + startPoint = UIInteractions.getPointFromElement(dragIndicatorElement); + movePoint = UIInteractions.getPointFromElement(rows[4].nativeElement); + dropPoint = UIInteractions.getPointFromElement(dropAreaElement); + pointerDownEvent = UIInteractions.createPointerEvent('pointerdown', startPoint); + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', movePoint); + pointerUpEvent = UIInteractions.createPointerEvent('pointerup', dropPoint); + let ghostElements: HTMLCollection; + + rowDragDirective.onPointerDown(pointerDownEvent); + rowDragDirective.onPointerMove(pointerMoveEvent); + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', dropPoint); + rowDragDirective.onPointerMove(pointerMoveEvent); + ghostElements = document.getElementsByClassName(CSS_CLASS_GHOST_ROW); + expect(ghostElements.length).toEqual(1); + + rowDragDirective.onPointerUp(pointerUpEvent); + ghostElements = document.getElementsByClassName(CSS_CLASS_GHOST_ROW); + expect(ghostElements.length).toEqual(0); + }); + it('should apply drag class to row upon row dragging', () => { + dragIndicatorElement = dragIndicatorElements[2].nativeElement; + rowDragDirective = dragRows[1].injector.get(IgxRowDragDirective); + rowToDrag = rows[1]; + + startPoint = UIInteractions.getPointFromElement(dragIndicatorElement); + movePoint = UIInteractions.getPointFromElement(rows[4].nativeElement); + dropPoint = UIInteractions.getPointFromElement(dropAreaElement); + pointerDownEvent = UIInteractions.createPointerEvent('pointerdown', startPoint); + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', movePoint); + pointerUpEvent = UIInteractions.createPointerEvent('pointerup', dropPoint); + + expect(rowToDrag.dragging).toBeFalsy(); + expect(rowToDrag.grid.rowDragging).toBeFalsy(); + expect(rowToDrag.element.nativeElement.classList.contains(CSS_CLASS_DRAG_ROW)).toBeFalsy(); + + rowDragDirective.onPointerDown(pointerDownEvent); + rowDragDirective.onPointerMove(pointerMoveEvent); + expect(rowToDrag.dragging).toBeTruthy(); + expect(rowToDrag.grid.rowDragging).toBeTruthy(); + expect(rowToDrag.element.nativeElement.classList.contains(CSS_CLASS_DRAG_ROW)).toBeTruthy(); + + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', dropPoint); + rowDragDirective.onPointerMove(pointerMoveEvent); + rowDragDirective.onPointerUp(pointerUpEvent); + expect(rowToDrag.dragging).toBeFalsy(); + expect(rowToDrag.grid.rowDragging).toBeFalsy(); + expect(rowToDrag.element.nativeElement.classList.contains(CSS_CLASS_DRAG_ROW)).toBeFalsy(); + }); + it('should align horizontal scrollbar with first column when column pinning is disabled', () => { + // has no draggable and selectable rows + grid.width = '400px'; + grid.rowSelection = GridSelectionMode.none; + grid.rowDraggable = false; + fixture.detectChanges(); + let rowSelectElement: DebugElement = fixture.debugElement.query(By.css(CSS_CLASS_SELECTION_CHECKBOX)); + let rowDragIndicatorElement: DebugElement = fixture.debugElement.query(By.css(CSS_CLASS_DRAG_INDICATOR)); + let horizontalScrollbarElement: DebugElement = fixture.debugElement.query(By.css(CSS_CLASS_VIRTUAL_HSCROLLBAR)); + expect(rowSelectElement).toBeNull(); + expect(rowDragIndicatorElement).toBeNull(); + + // has draggable rows and has no selectable rows + grid.rowSelection = GridSelectionMode.none; + grid.rowDraggable = true; + fixture.detectChanges(); + rowSelectElement = fixture.debugElement.query(By.css(CSS_CLASS_SELECTION_CHECKBOX)); + rowDragIndicatorElement = fixture.debugElement.query(By.css(CSS_CLASS_DRAG_INDICATOR)); + horizontalScrollbarElement = fixture.debugElement.query(By.css(CSS_CLASS_VIRTUAL_HSCROLLBAR)); + const dragIndicatorRect = rowDragIndicatorElement.nativeElement.getBoundingClientRect(); + let horizontalScrollbarRect = horizontalScrollbarElement.nativeElement.getBoundingClientRect(); + expect(rowSelectElement).toBeNull(); + expect(dragIndicatorRect.right).toBe(horizontalScrollbarRect.left); + + // has draggable and selectable rows + grid.rowSelection = GridSelectionMode.multiple; + grid.rowDraggable = true; + fixture.detectChanges(); + horizontalScrollbarElement = fixture.debugElement.query(By.css(CSS_CLASS_VIRTUAL_HSCROLLBAR)); + horizontalScrollbarRect = horizontalScrollbarElement.nativeElement.getBoundingClientRect(); + // The horizontal scrollbar should be visible + expect(horizontalScrollbarRect.left).not.toBe(0); + }); + it('should align horizontal scrollbar with first non-pinned column when column pinning is enabled', () => { + grid.width = '400px'; + grid.pinColumn('ProductName'); + fixture.detectChanges(); + + // selectable rows disabled + fixture.detectChanges(); + let horizontalScrollbarElement: DebugElement = fixture.debugElement.query(By.css(CSS_CLASS_VIRTUAL_HSCROLLBAR)); + let horizontalScrollbarRect = horizontalScrollbarElement.nativeElement.getBoundingClientRect(); + let pinnedColumnHeaderElement: DebugElement = fixture.debugElement.query(By.css('.' + CSS_CLASS_LAST_PINNED_HEADER)); + let pinnedColumnHeaderRect = pinnedColumnHeaderElement.nativeElement.getBoundingClientRect(); + + // The horizontal scrollbar should be visible + expect(horizontalScrollbarRect.left).not.toBe(0); + + // selectable rows enabled + grid.rowSelection = GridSelectionMode.multiple; + fixture.detectChanges(); + horizontalScrollbarElement = fixture.debugElement.query(By.css(CSS_CLASS_VIRTUAL_HSCROLLBAR)); + horizontalScrollbarRect = horizontalScrollbarElement.nativeElement.getBoundingClientRect(); + pinnedColumnHeaderElement = fixture.debugElement.query(By.css('.' + CSS_CLASS_LAST_PINNED_HEADER)); + pinnedColumnHeaderRect = pinnedColumnHeaderElement.nativeElement.getBoundingClientRect(); + expect(pinnedColumnHeaderRect.right).toBe(horizontalScrollbarRect.left); + }); + it('should fire drag events with correct values of event arguments.', () => { + rowToDrag = rows[2]; + rowDragDirective = dragRows[2].injector.get(IgxRowDragDirective); + dragIndicatorElement = dragIndicatorElements[3].nativeElement; + + startPoint = UIInteractions.getPointFromElement(dragIndicatorElement); + dropPoint = UIInteractions.getPointFromElement(dropAreaElement); + pointerDownEvent = UIInteractions.createPointerEvent('pointerdown', startPoint); + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', dropPoint); + pointerUpEvent = UIInteractions.createPointerEvent('pointerup', dropPoint); + + spyOn(grid.rowDragStart, 'emit').and.callThrough(); + spyOn(grid.rowDragEnd, 'emit').and.callThrough(); + + rowDragDirective.onPointerDown(pointerDownEvent); + rowDragDirective.onPointerMove(pointerMoveEvent); + verifyRowDragStartEvent(grid, grid.getRowByIndex(rowToDrag.index), rowToDrag.nativeElement, rowDragDirective); + + rowDragDirective.onPointerMove(pointerMoveEvent); + rowDragDirective.onPointerUp(pointerUpEvent); + verifyRowDragEndEvent(grid, grid.getRowByIndex(rowToDrag.index), rowToDrag.nativeElement, rowDragDirective, false); + }); + it('should emit dragdrop events if dropping a row on a non-interactive area', () => { + dragIndicatorElement = dragIndicatorElements[2].nativeElement; + rowToDrag = rows[1]; + rowDragDirective = dragRows[1].injector.get(IgxRowDragDirective); + + startPoint = UIInteractions.getPointFromElement(dragIndicatorElement); + movePoint = UIInteractions.getPointFromElement(rows[4].nativeElement); + dropPoint = UIInteractions.getPointFromElement(nonDroppableAreaElement); + pointerDownEvent = UIInteractions.createPointerEvent('pointerdown', startPoint); + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', movePoint); + pointerUpEvent = UIInteractions.createPointerEvent('pointerup', dropPoint); + + spyOn(grid.rowDragStart, 'emit'); + spyOn(grid.rowDragEnd, 'emit'); + + expect(rowToDrag.dragging).toBeFalsy(); + expect(rowToDrag.grid.rowDragging).toBeFalsy(); + + rowDragDirective.onPointerDown(pointerDownEvent); + rowDragDirective.onPointerMove(pointerMoveEvent); + expect(rowToDrag.dragging).toBeTruthy(); + expect(rowToDrag.grid.rowDragging).toBeTruthy(); + verifyRowDragStartEvent(grid, grid.getRowByIndex(rowToDrag.index), rowToDrag.nativeElement, rowDragDirective); + + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', dropPoint); + rowDragDirective.onPointerMove(pointerMoveEvent); + expect(rowToDrag.dragging).toBeTruthy(); + expect(rowToDrag.grid.rowDragging).toBeTruthy(); + + rowDragDirective.onPointerUp(pointerUpEvent); + expect(rowToDrag.dragging).toBeFalsy(); + expect(rowToDrag.grid.rowDragging).toBeFalsy(); + verifyRowDragEndEvent(grid, grid.getRowByIndex(rowToDrag.index), rowToDrag.nativeElement, rowDragDirective, false); + }); + it('should destroy the drag ghost if dropping a row on a non-interactive area when animations are enabled', () => { + grid.rowDragEnd.subscribe((e: IRowDragEndEventArgs) => { + e.animation = true; + }); + dragIndicatorElement = dragIndicatorElements[2].nativeElement; + rowToDrag = rows[1]; + rowDragDirective = dragRows[1].injector.get(IgxRowDragDirective); + + startPoint = UIInteractions.getPointFromElement(dragIndicatorElement); + movePoint = UIInteractions.getPointFromElement(rows[4].nativeElement); + dropPoint = UIInteractions.getPointFromElement(nonDroppableAreaElement); + pointerDownEvent = UIInteractions.createPointerEvent('pointerdown', startPoint); + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', movePoint); + pointerUpEvent = UIInteractions.createPointerEvent('pointerup', dropPoint); + + spyOn(grid.rowDragStart, 'emit'); + spyOn(grid.rowDragEnd, 'emit'); + + expect(rowToDrag.dragging).toBeFalsy(); + expect(rowToDrag.grid.rowDragging).toBeFalsy(); + + rowDragDirective.onPointerDown(pointerDownEvent); + rowDragDirective.onPointerMove(pointerMoveEvent); + expect(rowToDrag.dragging).toBeTruthy(); + expect(rowToDrag.grid.rowDragging).toBeTruthy(); + verifyRowDragStartEvent(grid, grid.getRowByIndex(rowToDrag.index), rowToDrag.nativeElement, rowDragDirective); + + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', dropPoint); + rowDragDirective.onPointerMove(pointerMoveEvent); + expect(rowToDrag.dragging).toBeTruthy(); + expect(rowToDrag.grid.rowDragging).toBeTruthy(); + + rowDragDirective.onPointerUp(pointerUpEvent); + expect(rowToDrag.dragging).toBeFalsy(); + expect(rowToDrag.grid.rowDragging).toBeFalsy(); + verifyRowDragEndEvent(grid, grid.getRowByIndex(rowToDrag.index), rowToDrag.nativeElement, rowDragDirective, false); + const ghostElements = document.getElementsByClassName(CSS_CLASS_GHOST_ROW); + expect(ghostElements.length).toEqual(0); + const dragIndicatorsOff = document.getElementsByClassName(CSS_CLASS_DRAG_INDICATOR_OFF); + expect(dragIndicatorsOff.length).toEqual(0); + }); + it('should be able to cancel rowDragStart event.', () => { + grid.rowDragStart.subscribe((e: IRowDragStartEventArgs) => { + e.cancel = true; + }); + rowToDrag = rows[2]; + rowDragDirective = dragRows[2].injector.get(IgxRowDragDirective); + dragIndicatorElement = dragIndicatorElements[rowToDrag.index].nativeElement; + + startPoint = UIInteractions.getPointFromElement(dragIndicatorElement); + dropPoint = UIInteractions.getPointFromElement(dropAreaElement); + pointerDownEvent = UIInteractions.createPointerEvent('pointerdown', startPoint); + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', dropPoint); + pointerUpEvent = UIInteractions.createPointerEvent('pointerup', dropPoint); + + spyOn(grid.rowDragStart, 'emit').and.callThrough(); + spyOn(grid.rowDragEnd, 'emit').and.callThrough(); + + rowDragDirective.onPointerDown(pointerDownEvent); + rowDragDirective.onPointerMove(pointerMoveEvent); + expect(grid.rowDragStart.emit).toHaveBeenCalledTimes(1); + + rowDragDirective.onPointerMove(pointerMoveEvent); + rowDragDirective.onPointerUp(pointerUpEvent); + expect(grid.rowDragEnd.emit).toHaveBeenCalledTimes(0); + const ghostElements = document.getElementsByClassName(CSS_CLASS_GHOST_ROW); + expect(ghostElements.length).toEqual(0); + }); + }); + describe('Custom ghost template tests', () => { + let grid: IgxGridComponent; + let rows: IgxRowDirective[]; + let dragRows: DebugElement[]; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxGridRowCustomGhostDraggableComponent + ] + }).compileComponents(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(IgxGridRowCustomGhostDraggableComponent); + grid = fixture.componentInstance.instance; + fixture.detectChanges(); + }); + it('should correctly create custom ghost element', () => { + dropAreaElement = fixture.debugElement.query(By.css(CSS_CLASS_DROPPABLE_AREA)).nativeElement; + rows = grid.rowList.toArray(); + dragIndicatorElements = fixture.debugElement.queryAll(By.css(CSS_CLASS_DRAG_INDICATOR)); + dragRows = fixture.debugElement.queryAll(By.directive(IgxRowDragDirective)); + rowDragDirective = dragRows[1].injector.get(IgxRowDragDirective); + dragIndicatorElement = dragIndicatorElements[2].nativeElement; + startPoint = UIInteractions.getPointFromElement(dragIndicatorElement); + movePoint = UIInteractions.getPointFromElement(rows[4].nativeElement); + dropPoint = UIInteractions.getPointFromElement(dropAreaElement); + pointerDownEvent = UIInteractions.createPointerEvent('pointerdown', startPoint); + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', movePoint); + + rowDragDirective.onPointerDown(pointerDownEvent); + rowDragDirective.onPointerMove(pointerMoveEvent); + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', dropPoint); + rowDragDirective.onPointerMove(pointerMoveEvent); + const ghostElements: HTMLCollection = document.getElementsByClassName(CSS_CLASS_GHOST_ROW); + expect(ghostElements.length).toEqual(1); + + expect((rowDragDirective as any).ghostContext.data.ProductName).toEqual('NetAdvantage'); + expect((rowDragDirective as any).ghostContext.data.ID).toEqual(2); + expect((rowDragDirective as any).ghostContext.grid).toEqual(grid); + + const ghostText = document.getElementsByClassName(CSS_CLASS_GHOST_ROW)[0].textContent; + expect(ghostText).toEqual(' Moving a row! '); + pointerUpEvent = UIInteractions.createPointerEvent('pointerup', dropPoint); + rowDragDirective.onPointerUp(pointerUpEvent); + }); + + it('should allow setting custom drag icon and ghost element via Input.', () => { + dropAreaElement = fixture.debugElement.query(By.css(CSS_CLASS_DROPPABLE_AREA)).nativeElement; + grid.dragIndicatorIconTemplate = fixture.componentInstance.rowDragTemplate; + grid.dragGhostCustomTemplate = fixture.componentInstance.rowDragGhostTemplate; + fixture.detectChanges(); + rows = grid.rowList.toArray(); + dragIndicatorElements = fixture.debugElement.queryAll(By.css(CSS_CLASS_DRAG_INDICATOR)); + dragIndicatorElement = dragIndicatorElements[2].nativeElement; + dragRows = fixture.debugElement.queryAll(By.directive(IgxRowDragDirective)); + rowDragDirective = dragRows[1].injector.get(IgxRowDragDirective); + + expect(dragIndicatorElement.textContent.trim()).toBe('expand_less'); + + startPoint = UIInteractions.getPointFromElement(dragIndicatorElement); + movePoint = UIInteractions.getPointFromElement(rows[4].nativeElement); + dropPoint = UIInteractions.getPointFromElement(dropAreaElement); + pointerDownEvent = UIInteractions.createPointerEvent('pointerdown', startPoint); + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', movePoint); + + rowDragDirective.onPointerDown(pointerDownEvent); + rowDragDirective.onPointerMove(pointerMoveEvent); + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', dropPoint); + rowDragDirective.onPointerMove(pointerMoveEvent); + const ghostElements: HTMLCollection = document.getElementsByClassName(CSS_CLASS_GHOST_ROW); + expect(ghostElements.length).toEqual(1); + + const ghostText = document.getElementsByClassName(CSS_CLASS_GHOST_ROW)[0].textContent; + expect(ghostText.trim()).toEqual('CUSTOM'); + + pointerUpEvent = UIInteractions.createPointerEvent('pointerup', dropPoint); + rowDragDirective.onPointerUp(pointerUpEvent); + + }); + }); + }); + + describe('Grid feature integration tests', () => { + let dragGrid: IgxGridComponent; + let dropGrid: IgxGridComponent; + let dragGridRows: IgxRowDirective[]; + let dropGridRows: IgxRowDirective[]; + let dragRows: DebugElement[]; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxGridFeaturesRowDragComponent + ] + }).compileComponents(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(IgxGridFeaturesRowDragComponent); + dragGrid = fixture.componentInstance.dragGrid; + dropGrid = fixture.componentInstance.dropGrid; + fixture.detectChanges(); + dragGridRows = dragGrid.rowList.toArray(); + dropAreaElement = fixture.debugElement.query(By.directive(IgxDropDirective)).nativeElement; + dragIndicatorElements = fixture.debugElement.queryAll(By.css(CSS_CLASS_DRAG_INDICATOR)); + dragRows = fixture.debugElement.queryAll(By.directive(IgxRowDragDirective)); + }); + const verifyDragAndDropRowCellValues = (dragRowIndex: number, dropRowIndex: number) => { + const dragRow = dragGrid.gridAPI.get_row_by_index(dragRowIndex); + const dragRowCells = (dragRow.cells as QueryList).toArray(); + + const dropRow = dropGrid.gridAPI.get_row_by_index(dropRowIndex); + const dropRowCells = (dropRow.cells as QueryList).toArray(); + for (let cellIndex = 0; cellIndex < dropRowCells.length; cellIndex++) { + expect(dropRowCells[cellIndex].value).toEqual(dragRowCells[cellIndex].value); + } + }; + it('should drop row data in the proper grid columns', () => { + dragIndicatorElement = dragIndicatorElements[2].nativeElement; + rowDragDirective = dragRows[1].injector.get(IgxRowDragDirective); + + startPoint = UIInteractions.getPointFromElement(dragIndicatorElement); + movePoint = UIInteractions.getPointFromElement(dragGridRows[2].nativeElement); + dropPoint = UIInteractions.getPointFromElement(dropAreaElement); + pointerDownEvent = UIInteractions.createPointerEvent('pointerdown', startPoint); + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', dropPoint); + pointerUpEvent = UIInteractions.createPointerEvent('pointerup', dropPoint); + + rowDragDirective.onPointerDown(pointerDownEvent); + rowDragDirective.onPointerMove(pointerMoveEvent); + rowDragDirective.onPointerUp(pointerUpEvent); + fixture.detectChanges(); + verifyDragAndDropRowCellValues(1, 0); + }); + it('should be able to drag grid row when column moving is enabled', () => { + const dragGridColumns = dragGrid.columns; + dragGrid.moveColumn(dragGridColumns[0], dragGridColumns[2]); + fixture.detectChanges(); + + dragIndicatorElement = dragIndicatorElements[2].nativeElement; + const row = dragGridRows[1]; + rowDragDirective = dragRows[1].injector.get(IgxRowDragDirective); + const dragRowCells = row.cells.toArray(); + + startPoint = UIInteractions.getPointFromElement(dragIndicatorElement); + movePoint = UIInteractions.getPointFromElement(dragGridRows[2].nativeElement); + dropPoint = UIInteractions.getPointFromElement(dropAreaElement); + pointerDownEvent = UIInteractions.createPointerEvent('pointerdown', startPoint); + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', movePoint); + pointerUpEvent = UIInteractions.createPointerEvent('pointerup', dropPoint); + + spyOn(dragGrid.rowDragStart, 'emit').and.callThrough(); + spyOn(dragGrid.rowDragEnd, 'emit').and.callThrough(); + + rowDragDirective.onPointerDown(pointerDownEvent); + rowDragDirective.onPointerMove(pointerMoveEvent); + expect(row.dragging).toBeTruthy(); + expect(row.grid.rowDragging).toBeTruthy(); + verifyRowDragStartEvent(dragGrid, dragGrid.getRowByIndex(row.index), row.nativeElement, rowDragDirective); + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', dropPoint); + rowDragDirective.onPointerMove(pointerMoveEvent); + rowDragDirective.onPointerUp(pointerUpEvent); + fixture.detectChanges(); + expect(row.dragging).toBeFalsy(); + expect(row.grid.rowDragging).toBeFalsy(); + verifyRowDragEndEvent(dragGrid, dragGrid.getRowByIndex(row.index), row.nativeElement, rowDragDirective, false); + + dropGridRows = dropGrid.rowList.toArray(); + const dropRowCells = dropGridRows[0].cells.toArray(); + expect(dropRowCells[0].value).toEqual(dragRowCells[2].value); + expect(dropRowCells[1].value).toEqual(dragRowCells[0].value); + expect(dropRowCells[2].value).toEqual(dragRowCells[1].value); + expect(dropRowCells[3].value).toEqual(dragRowCells[3].value); + expect(dropRowCells[4].value).toEqual(dragRowCells[4].value); + }); + it('should be able to drag grid row when column pinning is enabled', () => { + dragGrid.pinColumn('ProductName'); + fixture.detectChanges(); + + dragIndicatorElement = dragIndicatorElements[2].nativeElement; + const row = dragGridRows[1]; + rowDragDirective = dragRows[1].injector.get(IgxRowDragDirective); + const dragRowCells = row.cells.toArray(); + + startPoint = UIInteractions.getPointFromElement(dragIndicatorElement); + movePoint = UIInteractions.getPointFromElement(dragGridRows[2].nativeElement); + dropPoint = UIInteractions.getPointFromElement(dropAreaElement); + pointerDownEvent = UIInteractions.createPointerEvent('pointerdown', startPoint); + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', movePoint); + pointerUpEvent = UIInteractions.createPointerEvent('pointerup', dropPoint); + + spyOn(dragGrid.rowDragStart, 'emit').and.callThrough(); + spyOn(dragGrid.rowDragEnd, 'emit').and.callThrough(); + + rowDragDirective.onPointerDown(pointerDownEvent); + rowDragDirective.onPointerMove(pointerMoveEvent); + expect(row.dragging).toBeTruthy(); + expect(row.grid.rowDragging).toBeTruthy(); + verifyRowDragStartEvent(dragGrid, dragGrid.getRowByIndex(row.index), row.nativeElement, rowDragDirective); + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', dropPoint); + rowDragDirective.onPointerMove(pointerMoveEvent); + rowDragDirective.onPointerUp(pointerUpEvent); + fixture.detectChanges(); + expect(row.dragging).toBeFalsy(); + expect(row.grid.rowDragging).toBeFalsy(); + verifyRowDragEndEvent(dragGrid, dragGrid.getRowByIndex(row.index), row.nativeElement, rowDragDirective, false); + + dropGridRows = dropGrid.rowList.toArray(); + const dropRowCells = dropGridRows[0].cells.toArray(); + expect(dropRowCells[0].value).toEqual(dragRowCells[1].value); + expect(dropRowCells[1].value).toEqual(dragRowCells[2].value); + expect(dropRowCells[2].value).toEqual(dragRowCells[0].value); + expect(dropRowCells[3].value).toEqual(dragRowCells[3].value); + expect(dropRowCells[4].value).toEqual(dragRowCells[4].value); + }); + it('should be able to drag grid row when column hiding is enabled', () => { + const hiddenDragCellValue = dragGrid.getCellByColumn(1, 'Downloads').value; + const column = dragGrid.getColumnByName('Downloads'); + column.hidden = true; + + dragIndicatorElement = dragIndicatorElements[2].nativeElement; + const row = dragGridRows[1]; + rowDragDirective = dragRows[1].injector.get(IgxRowDragDirective); + startPoint = UIInteractions.getPointFromElement(dragIndicatorElement); + movePoint = UIInteractions.getPointFromElement(dragGridRows[2].nativeElement); + dropPoint = UIInteractions.getPointFromElement(dropAreaElement); + pointerDownEvent = UIInteractions.createPointerEvent('pointerdown', startPoint); + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', movePoint); + pointerUpEvent = UIInteractions.createPointerEvent('pointerup', dropPoint); + + spyOn(dragGrid.rowDragStart, 'emit').and.callThrough(); + spyOn(dragGrid.rowDragEnd, 'emit').and.callThrough(); + + rowDragDirective.onPointerDown(pointerDownEvent); + rowDragDirective.onPointerMove(pointerMoveEvent); + expect(row.dragging).toBeTruthy(); + expect(dragGrid.rowDragging).toBeTruthy(); + verifyRowDragStartEvent(dragGrid, dragGrid.getRowByIndex(row.index), row.nativeElement, rowDragDirective); + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', dropPoint); + rowDragDirective.onPointerMove(pointerMoveEvent); + rowDragDirective.onPointerUp(pointerUpEvent); + fixture.detectChanges(); + expect(row.dragging).toBeFalsy(); + expect(dragGrid.rowDragging).toBeFalsy(); + expect(dropGrid.rowList.length).toEqual(1); + verifyRowDragEndEvent(dragGrid, dragGrid.getRowByIndex(row.index), row.nativeElement, rowDragDirective, false); + + const hiddenDropCellValue = dropGrid.getCellByColumn(0, 'Downloads').value; + expect(hiddenDropCellValue).toEqual(hiddenDragCellValue); + }); + it('should be able to drag sorted grid row', () => { + dragGrid.sort({ fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: true }); + + dragIndicatorElement = dragIndicatorElements[2].nativeElement; + const row = dragGridRows[1]; + rowDragDirective = dragRows[1].injector.get(IgxRowDragDirective); + startPoint = UIInteractions.getPointFromElement(dragIndicatorElement); + movePoint = UIInteractions.getPointFromElement(dragGridRows[4].nativeElement); + dropPoint = UIInteractions.getPointFromElement(dropAreaElement); + pointerDownEvent = UIInteractions.createPointerEvent('pointerdown', startPoint); + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', movePoint); + pointerUpEvent = UIInteractions.createPointerEvent('pointerup', dropPoint); + + spyOn(dragGrid.rowDragStart, 'emit').and.callThrough(); + spyOn(dragGrid.rowDragEnd, 'emit').and.callThrough(); + + rowDragDirective.onPointerDown(pointerDownEvent); + rowDragDirective.onPointerMove(pointerMoveEvent); + expect(row.dragging).toBeTruthy(); + expect(row.grid.rowDragging).toBeTruthy(); + verifyRowDragStartEvent(dragGrid, dragGrid.getRowByIndex(row.index), row.nativeElement, rowDragDirective); + + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', dropPoint); + rowDragDirective.onPointerMove(pointerMoveEvent); + rowDragDirective.onPointerUp(pointerUpEvent); + fixture.detectChanges(); + expect(row.dragging).toBeFalsy(); + expect(row.grid.rowDragging).toBeFalsy(); + verifyRowDragEndEvent(dragGrid, dragGrid.getRowByIndex(row.index), row.nativeElement, rowDragDirective, false); + expect(dropGrid.rowList.length).toEqual(1); + verifyDragAndDropRowCellValues(1, 0); + }); + it('should be able to drag filtered grid row', () => { + dragGrid.filter('ProductName', 'Advantage', IgxStringFilteringOperand.instance().condition('contains'), true); + + dragIndicatorElement = dragIndicatorElements[2].nativeElement; + const row = dragGridRows[1]; + rowDragDirective = dragRows[1].injector.get(IgxRowDragDirective); + startPoint = UIInteractions.getPointFromElement(dragIndicatorElement); + movePoint = UIInteractions.getPointFromElement(dragGridRows[4].nativeElement); + dropPoint = UIInteractions.getPointFromElement(dropAreaElement); + pointerDownEvent = UIInteractions.createPointerEvent('pointerdown', startPoint); + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', movePoint); + pointerUpEvent = UIInteractions.createPointerEvent('pointerup', dropPoint); + + spyOn(dragGrid.rowDragStart, 'emit').and.callThrough(); + spyOn(dragGrid.rowDragEnd, 'emit').and.callThrough(); + + rowDragDirective.onPointerDown(pointerDownEvent); + rowDragDirective.onPointerMove(pointerMoveEvent); + expect(row.dragging).toBeTruthy(); + expect(row.grid.rowDragging).toBeTruthy(); + verifyRowDragStartEvent(dragGrid, dragGrid.getRowByIndex(row.index), row.nativeElement, rowDragDirective); + + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', dropPoint); + rowDragDirective.onPointerMove(pointerMoveEvent); + rowDragDirective.onPointerUp(pointerUpEvent); + fixture.detectChanges(); + expect(row.dragging).toBeFalsy(); + expect(row.grid.rowDragging).toBeFalsy(); + verifyRowDragEndEvent(dragGrid, dragGrid.getRowByIndex(row.index), row.nativeElement, rowDragDirective, false); + expect(dropGrid.rowList.length).toEqual(1); + verifyDragAndDropRowCellValues(1, 0); + }); + it('should be able to drag selected grid row', () => { + dragGrid.rowSelection = GridSelectionMode.multiple; + fixture.detectChanges(); + dragGrid.selectRows([2], false); + fixture.detectChanges(); + + dragIndicatorElement = dragIndicatorElements[2].nativeElement; + const row = dragGridRows[1]; + rowDragDirective = dragRows[1].injector.get(IgxRowDragDirective); + expect(row.selected).toBeTruthy(); + + startPoint = UIInteractions.getPointFromElement(dragIndicatorElement); + movePoint = UIInteractions.getPointFromElement(dragGridRows[4].nativeElement); + dropPoint = UIInteractions.getPointFromElement(dropAreaElement); + pointerDownEvent = UIInteractions.createPointerEvent('pointerdown', startPoint); + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', movePoint); + pointerUpEvent = UIInteractions.createPointerEvent('pointerup', dropPoint); + + spyOn(dragGrid.rowDragStart, 'emit').and.callThrough(); + spyOn(dragGrid.rowDragEnd, 'emit').and.callThrough(); + + rowDragDirective.onPointerDown(pointerDownEvent); + rowDragDirective.onPointerMove(pointerMoveEvent); + expect(row.dragging).toBeTruthy(); + expect(row.grid.rowDragging).toBeTruthy(); + verifyRowDragStartEvent(dragGrid, dragGrid.getRowByIndex(row.index), row.nativeElement, rowDragDirective); + + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', dropPoint); + rowDragDirective.onPointerMove(pointerMoveEvent); + rowDragDirective.onPointerUp(pointerUpEvent); + fixture.detectChanges(); + expect(row.dragging).toBeFalsy(); + expect(row.grid.rowDragging).toBeFalsy(); + verifyRowDragEndEvent(dragGrid, dragGrid.getRowByIndex(row.index), row.nativeElement, rowDragDirective, false); + expect(dropGrid.rowList.length).toEqual(1); + expect(row.selected).toBeTruthy(); + }); + it('should not apply selection class to ghost element when dragging selected grid row', () => { + dragGrid.rowSelection = GridSelectionMode.multiple; + fixture.detectChanges(); + dragGrid.selectRows([2], false); + fixture.detectChanges(); + + dragIndicatorElement = dragIndicatorElements[2].nativeElement; + rowDragDirective = dragRows[1].injector.get(IgxRowDragDirective); + const row = dragGridRows[1]; + expect(row.selected).toBeTruthy(); + + startPoint = UIInteractions.getPointFromElement(dragIndicatorElement); + movePoint = UIInteractions.getPointFromElement(dragGridRows[4].nativeElement); + dropPoint = UIInteractions.getPointFromElement(dropAreaElement); + pointerDownEvent = UIInteractions.createPointerEvent('pointerdown', startPoint); + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', movePoint); + pointerUpEvent = UIInteractions.createPointerEvent('pointerup', dropPoint); + + rowDragDirective.onPointerDown(pointerDownEvent); + rowDragDirective.onPointerMove(pointerMoveEvent); + expect(row.dragging).toBeTruthy(); + expect(row.grid.rowDragging).toBeTruthy(); + + const ghostElements = document.getElementsByClassName(CSS_CLASS_GHOST_ROW); + const ghostElement = ghostElements[0]; + expect(ghostElements.length).toEqual(1); + expect(ghostElement.classList.contains(CSS_CLASS_SELECTED_ROW)).toBeFalsy(); + + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', dropPoint); + rowDragDirective.onPointerMove(pointerMoveEvent); + rowDragDirective.onPointerUp(pointerUpEvent); + fixture.detectChanges(); + }); + it('should be able to drag grid row with selected cells', () => { + const range = { rowStart: 1, rowEnd: 1, columnStart: 0, columnEnd: 2 }; + dragGrid.selectRange(range); + fixture.detectChanges(); + + const verifyCellSelection = () => { + for (let index = 0; index < rowCells.length; index++) { + const cellSelected = index <= 2 ? true : false; + expect(rowCells[index].selected).toEqual(cellSelected); + } + }; + + dragIndicatorElement = dragIndicatorElements[2].nativeElement; + const row = dragGridRows[1]; + rowDragDirective = dragRows[1].injector.get(IgxRowDragDirective); + const rowCells = row.cells.toArray(); + verifyCellSelection(); + + startPoint = UIInteractions.getPointFromElement(dragIndicatorElement); + movePoint = UIInteractions.getPointFromElement(dragGridRows[4].nativeElement); + dropPoint = UIInteractions.getPointFromElement(dropAreaElement); + pointerDownEvent = UIInteractions.createPointerEvent('pointerdown', startPoint); + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', movePoint); + pointerUpEvent = UIInteractions.createPointerEvent('pointerup', dropPoint); + + spyOn(dragGrid.rowDragStart, 'emit').and.callThrough(); + spyOn(dragGrid.rowDragEnd, 'emit').and.callThrough(); + + rowDragDirective.onPointerDown(pointerDownEvent); + rowDragDirective.onPointerMove(pointerMoveEvent); + expect(row.dragging).toBeTruthy(); + expect(row.grid.rowDragging).toBeTruthy(); + verifyRowDragStartEvent(dragGrid, dragGrid.getRowByIndex(row.index), row.nativeElement, rowDragDirective); + + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', dropPoint); + rowDragDirective.onPointerMove(pointerMoveEvent); + rowDragDirective.onPointerUp(pointerUpEvent); + fixture.detectChanges(); + expect(row.dragging).toBeFalsy(); + expect(row.grid.rowDragging).toBeFalsy(); + verifyRowDragEndEvent(dragGrid, dragGrid.getRowByIndex(row.index), row.nativeElement, rowDragDirective, false); + expect(dropGrid.rowList.length).toEqual(1); + verifyCellSelection(); + }); + it('should be able to drag grouped grid row', () => { + dragGrid.groupBy({ fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: true }); + fixture.detectChanges(); + + dragIndicatorElement = dragIndicatorElements[3].nativeElement; + const row = dragGridRows[2]; + rowDragDirective = dragRows[2].injector.get(IgxRowDragDirective); + const rowCells = row.cells.toArray(); + const groupHeader = dragGrid.groupsRecords.find(element => element.value === rowCells[2].value); + let groupRow = groupHeader.records.find(element => element['ID'] === rowCells[1].value); + expect(groupHeader.records.length).toEqual(2); + expect(groupRow).toBeDefined(); + + startPoint = UIInteractions.getPointFromElement(dragIndicatorElement); + movePoint = UIInteractions.getPointFromElement(dragGridRows[4].nativeElement); + dropPoint = UIInteractions.getPointFromElement(dropAreaElement); + pointerDownEvent = UIInteractions.createPointerEvent('pointerdown', startPoint); + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', movePoint); + pointerUpEvent = UIInteractions.createPointerEvent('pointerup', dropPoint); + + spyOn(dragGrid.rowDragStart, 'emit').and.callThrough(); + spyOn(dragGrid.rowDragEnd, 'emit').and.callThrough(); + + rowDragDirective.onPointerDown(pointerDownEvent); + rowDragDirective.onPointerMove(pointerMoveEvent); + expect(row.dragging).toBeTruthy(); + expect(row.grid.rowDragging).toBeTruthy(); + verifyRowDragStartEvent(dragGrid, dragGrid.getRowByIndex(row.index), row.nativeElement, rowDragDirective); + + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', dropPoint); + rowDragDirective.onPointerMove(pointerMoveEvent); + rowDragDirective.onPointerUp(pointerUpEvent); + fixture.detectChanges(); + expect(row.dragging).toBeFalsy(); + expect(row.grid.rowDragging).toBeFalsy(); + verifyRowDragEndEvent(dragGrid, dragGrid.getRowByIndex(row.index), row.nativeElement, rowDragDirective, false); + expect(dropGrid.rowList.length).toEqual(1); + expect(groupHeader.records.length).toEqual(2); + groupRow = groupHeader.records.find(element => element['ID'] === rowCells[1].value); + expect(groupRow).toBeDefined(); + }); + it('should exit edit mode and discard changes on row dragging', () => { + dragGrid.rowEditable = true; + fixture.detectChanges(); + + dragIndicatorElement = dragIndicatorElements[2].nativeElement; + rowDragDirective = dragRows[1].injector.get(IgxRowDragDirective); + const row = dragGridRows[1]; + + startPoint = UIInteractions.getPointFromElement(dragIndicatorElement); + movePoint = UIInteractions.getPointFromElement(dragGridRows[2].nativeElement); + dropPoint = UIInteractions.getPointFromElement(dropAreaElement); + pointerDownEvent = UIInteractions.createPointerEvent('pointerdown', startPoint); + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', movePoint); + pointerUpEvent = UIInteractions.createPointerEvent('pointerup', dropPoint); + + const dragCell = dragGrid.gridAPI.get_cell_by_index(1, 'Downloads'); + const cacheValue = dragCell.value; + const cellElement = dragCell.nativeElement; + let cellInput = null; + + spyOn(dragGrid.gridAPI.crudService, 'endEdit').and.callThrough(); + + cellElement.dispatchEvent(new Event('focus')); + fixture.detectChanges(); + + cellElement.dispatchEvent(new Event('dblclick')); + fixture.detectChanges(); + + const newCellValue = 2000; + cellInput = cellElement.querySelector('[igxinput]'); + cellInput.value = newCellValue; + cellInput.dispatchEvent(new Event('input')); + fixture.detectChanges(); + expect(row.inEditMode).toBeTruthy(); + expect(dragCell.editMode).toEqual(true); + + rowDragDirective.onPointerDown(pointerDownEvent); + rowDragDirective.onPointerMove(pointerMoveEvent); + expect(row.dragging).toBeTruthy(); + expect(row.grid.rowDragging).toBeTruthy(); + expect(dragGrid.gridAPI.crudService.endEdit).toHaveBeenCalled(); + expect(row.inEditMode).toBeFalsy(); + expect(dragCell.editMode).toEqual(false); + + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', dropPoint); + rowDragDirective.onPointerMove(pointerMoveEvent); + rowDragDirective.onPointerUp(pointerUpEvent); + fixture.detectChanges(); + expect(row.dragging).toBeFalsy(); + expect(row.grid.rowDragging).toBeFalsy(); + + const dropCell = dropGrid.getCellByColumn(0, 'Downloads'); + expect(dropCell.value).toEqual(cacheValue); + expect(dragCell.value).toEqual(cacheValue); + }); + }); + }); + + describe('Hierarchical Grid', () => { + let fixture: ComponentFixture; + let dropAreaElement: Element; + let dragIndicatorElement: Element; + let rowDragDirective: IgxRowDragDirective; + let startPoint: Point; + let movePoint: Point; + let dropPoint: Point; + let pointerDownEvent: PointerEvent; + let pointerMoveEvent: PointerEvent; + let pointerUpEvent: PointerEvent; + let dragGrid: IgxHierarchicalGridComponent; + let dragRows: DebugElement[]; + let pointerMoveToDropEvent: PointerEvent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxHierarchicalGridTestComponent, + IgxHierarchicalGridCustomGhostTestComponent + ], + providers: [ + IgxGridNavigationService + ] + }).compileComponents(); + })); + it('should be able to drag row on every hierarchical level', () => { + fixture = TestBed.createComponent(IgxHierarchicalGridTestComponent); + fixture.detectChanges(); + dragGrid = fixture.componentInstance.hDragGrid; + dropAreaElement = fixture.debugElement.query(By.directive(IgxDropDirective)).nativeElement; + dragRows = fixture.debugElement.queryAll(By.directive(IgxRowDragDirective)); + + // first level row + let rowToDrag = dragGrid.gridAPI.get_row_by_index(0); + dragIndicatorElement = rowToDrag.nativeElement.querySelector(CSS_CLASS_DRAG_INDICATOR); + rowDragDirective = dragRows[0].injector.get(IgxRowDragDirective); + + startPoint = UIInteractions.getPointFromElement(dragIndicatorElement); + movePoint = UIInteractions.getPointFromElement(dragGrid.gridAPI.get_row_by_index(3).nativeElement); + dropPoint = UIInteractions.getPointFromElement(dropAreaElement); + pointerDownEvent = UIInteractions.createPointerEvent('pointerdown', startPoint); + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', movePoint); + pointerMoveToDropEvent = UIInteractions.createPointerEvent('pointermove', dropPoint); + pointerUpEvent = UIInteractions.createPointerEvent('pointerup', dropPoint); + + spyOn(dragGrid.rowDragStart, 'emit').and.callThrough(); + spyOn(dragGrid.rowDragEnd, 'emit').and.callThrough(); + + rowDragDirective.onPointerDown(pointerDownEvent); + rowDragDirective.onPointerMove(pointerMoveEvent); + verifyRowDragStartEvent(dragGrid, rowToDrag.grid.getRowByIndex(rowToDrag.index), + rowToDrag.nativeElement, rowDragDirective, 1); + // pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', dropPoint); + rowDragDirective.onPointerMove(pointerMoveToDropEvent); + rowDragDirective.onPointerUp(pointerUpEvent); + fixture.detectChanges(); + verifyRowDragEndEvent(dragGrid, rowToDrag.grid.getRowByIndex(rowToDrag.index), + rowToDrag.nativeElement, rowDragDirective, false, 1); + + // second level row + const childGrid = dragGrid.gridAPI.getChildGrids(false)[0]; + rowToDrag = childGrid.gridAPI.get_row_by_index(0); + dragIndicatorElement = rowToDrag.nativeElement.querySelector(CSS_CLASS_DRAG_INDICATOR); + rowDragDirective = dragRows[1].injector.get(IgxRowDragDirective); + startPoint = UIInteractions.getPointFromElement(dragIndicatorElement); + pointerDownEvent = UIInteractions.createPointerEvent('pointerdown', startPoint); + + spyOn(childGrid.rowDragStart, 'emit').and.callThrough(); + spyOn(childGrid.rowDragEnd, 'emit').and.callThrough(); + + rowDragDirective.onPointerDown(pointerDownEvent); + rowDragDirective.onPointerMove(pointerMoveEvent); + verifyRowDragStartEvent(childGrid, rowToDrag.grid.getRowByIndex(rowToDrag.index), + rowToDrag.nativeElement, rowDragDirective, 1); + rowDragDirective.onPointerMove(pointerMoveToDropEvent); + rowDragDirective.onPointerUp(pointerUpEvent); + fixture.detectChanges(); + verifyRowDragEndEvent(childGrid, rowToDrag.grid.getRowByIndex(rowToDrag.index), + rowToDrag.nativeElement, rowDragDirective, false, 1); + + // third level row + const nestedChildGrid = childGrid.gridAPI.getChildGrids(false)[0]; + rowToDrag = nestedChildGrid.gridAPI.get_row_by_index(0); + dragIndicatorElement = rowToDrag.nativeElement.querySelector(CSS_CLASS_DRAG_INDICATOR); + rowDragDirective = dragRows[2].injector.get(IgxRowDragDirective); + startPoint = UIInteractions.getPointFromElement(dragIndicatorElement); + pointerDownEvent = UIInteractions.createPointerEvent('pointerdown', startPoint); + + spyOn(nestedChildGrid.rowDragStart, 'emit').and.callThrough(); + spyOn(nestedChildGrid.rowDragEnd, 'emit').and.callThrough(); + + rowDragDirective.onPointerDown(pointerDownEvent); + rowDragDirective.onPointerMove(pointerMoveEvent); + verifyRowDragStartEvent(nestedChildGrid, rowToDrag.grid.getRowByIndex(rowToDrag.index), + rowToDrag.nativeElement, rowDragDirective, 1); + rowDragDirective.onPointerMove(pointerMoveToDropEvent); + rowDragDirective.onPointerUp(pointerUpEvent); + fixture.detectChanges(); + verifyRowDragEndEvent(nestedChildGrid, rowToDrag.grid.getRowByIndex(rowToDrag.index), + rowToDrag.nativeElement, rowDragDirective, false, 1); + }); + + it('should correctly create custom ghost element', () => { + fixture = TestBed.createComponent(IgxHierarchicalGridCustomGhostTestComponent); + dragGrid = fixture.componentInstance.hDragGrid; + fixture.detectChanges(); + dragRows = fixture.debugElement.queryAll(By.directive(IgxRowDragDirective)); + + // first level row + let rowToDrag = dragGrid.gridAPI.get_row_by_index(0); + dragIndicatorElement = rowToDrag.nativeElement.querySelector(CSS_CLASS_DRAG_INDICATOR); + rowDragDirective = dragRows[0].injector.get(IgxRowDragDirective); + + startPoint = UIInteractions.getPointFromElement(dragIndicatorElement); + movePoint = UIInteractions.getPointFromElement(dragGrid.gridAPI.get_row_by_index(3).nativeElement); + dropPoint = UIInteractions.getPointFromElement(dropAreaElement); + pointerDownEvent = UIInteractions.createPointerEvent('pointerdown', startPoint); + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', movePoint); + pointerMoveToDropEvent = UIInteractions.createPointerEvent('pointermove', dropPoint); + pointerUpEvent = UIInteractions.createPointerEvent('pointerup', dropPoint); + + rowDragDirective.onPointerDown(pointerDownEvent); + rowDragDirective.onPointerMove(pointerMoveEvent); + rowDragDirective.onPointerMove(pointerMoveToDropEvent); + rowDragDirective.onPointerUp(pointerUpEvent); + fixture.detectChanges(); + + expect((rowDragDirective as any).ghostContext.data.ProductName).toEqual('Product: A0'); + expect((rowDragDirective as any).ghostContext.grid).toEqual(dragGrid); + + // second level row + const childGrid = dragGrid.gridAPI.getChildGrids(false)[0]; + rowToDrag = childGrid.gridAPI.get_row_by_index(0); + dragIndicatorElement = rowToDrag.nativeElement.querySelector(CSS_CLASS_DRAG_INDICATOR); + rowDragDirective = dragRows[1].injector.get(IgxRowDragDirective); + startPoint = UIInteractions.getPointFromElement(dragIndicatorElement); + pointerDownEvent = UIInteractions.createPointerEvent('pointerdown', startPoint); + + rowDragDirective.onPointerDown(pointerDownEvent); + rowDragDirective.onPointerMove(pointerMoveEvent); + rowDragDirective.onPointerMove(pointerMoveToDropEvent); + rowDragDirective.onPointerUp(pointerUpEvent); + fixture.detectChanges(); + + expect((rowDragDirective as any).ghostContext.data.ProductName).toEqual('Product: A0'); + expect((rowDragDirective as any).ghostContext.data.ChildLevels).toEqual(2); + expect((rowDragDirective as any).ghostContext.grid).toEqual(childGrid); + }); + }); + + describe('Tree Grid', () => { + let fixture: ComponentFixture; + let dropAreaElement: Element; + let dragIndicatorElements: DebugElement[]; + let dragIndicatorElement: Element; + let rowDragDirective: IgxRowDragDirective; + let startPoint: Point; + let movePoint: Point; + let dropPoint: Point; + let pointerDownEvent: PointerEvent; + let pointerMoveEvent: PointerEvent; + let pointerUpEvent: PointerEvent; + let dragGrid: IgxTreeGridComponent; + let dragRows: DebugElement[]; + let pointerMoveToDropEvent: PointerEvent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxTreeGridTestComponent + ] + }).compileComponents(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(IgxTreeGridTestComponent); + fixture.detectChanges(); + dragGrid = fixture.componentInstance.treeGrid; + dropAreaElement = fixture.debugElement.query(By.directive(IgxDropDirective)).nativeElement; + dragIndicatorElements = fixture.debugElement.queryAll(By.css(CSS_CLASS_DRAG_INDICATOR)); + dragRows = fixture.debugElement.queryAll(By.directive(IgxRowDragDirective)); + }); + + it('should be able to drag row on every hierarchical level', () => { + // first level row + dragIndicatorElement = dragIndicatorElements[1].nativeElement; + let rowToDrag = dragGrid.gridAPI.get_row_by_index(0); + rowDragDirective = dragRows[0].injector.get(IgxRowDragDirective); + + startPoint = UIInteractions.getPointFromElement(dragIndicatorElement); + movePoint = UIInteractions.getPointFromElement(dragGrid.gridAPI.get_row_by_index(3).nativeElement); + dropPoint = UIInteractions.getPointFromElement(dropAreaElement); + pointerDownEvent = UIInteractions.createPointerEvent('pointerdown', startPoint); + pointerMoveEvent = UIInteractions.createPointerEvent('pointermove', movePoint); + pointerMoveToDropEvent = UIInteractions.createPointerEvent('pointermove', dropPoint); + pointerUpEvent = UIInteractions.createPointerEvent('pointerup', dropPoint); + + spyOn(dragGrid.rowDragStart, 'emit').and.callThrough(); + spyOn(dragGrid.rowDragEnd, 'emit').and.callThrough(); + + rowDragDirective.onPointerDown(pointerDownEvent); + rowDragDirective.onPointerMove(pointerMoveEvent); + verifyRowDragStartEvent(dragGrid, dragGrid.getRowByKey(rowToDrag.key), + rowToDrag.nativeElement, rowDragDirective, 1); + rowDragDirective.onPointerMove(pointerMoveToDropEvent); + rowDragDirective.onPointerUp(pointerUpEvent); + fixture.detectChanges(); + verifyRowDragEndEvent(dragGrid, dragGrid.getRowByKey(rowToDrag.key), + rowToDrag.nativeElement, rowDragDirective, false, 1); + + // second level row + dragIndicatorElement = dragIndicatorElements[2].nativeElement; + rowToDrag = dragGrid.gridAPI.get_row_by_index(1); + rowDragDirective = dragRows[1].injector.get(IgxRowDragDirective); + startPoint = UIInteractions.getPointFromElement(dragIndicatorElement); + pointerDownEvent = UIInteractions.createPointerEvent('pointerdown', startPoint); + + rowDragDirective.onPointerDown(pointerDownEvent); + rowDragDirective.onPointerMove(pointerMoveEvent); + verifyRowDragStartEvent(dragGrid, dragGrid.getRowByKey(rowToDrag.key), rowToDrag.nativeElement, rowDragDirective, 2); + rowDragDirective.onPointerMove(pointerMoveToDropEvent); + rowDragDirective.onPointerUp(pointerUpEvent); + fixture.detectChanges(); + verifyRowDragEndEvent(dragGrid, dragGrid.getRowByKey(rowToDrag.key), rowToDrag.nativeElement, rowDragDirective, false, 2); + + // third level row + dragIndicatorElement = dragIndicatorElements[3].nativeElement; + rowToDrag = dragGrid.gridAPI.get_row_by_index(2); + rowDragDirective = dragRows[2].injector.get(IgxRowDragDirective); + startPoint = UIInteractions.getPointFromElement(dragIndicatorElement); + pointerDownEvent = UIInteractions.createPointerEvent('pointerdown', startPoint); + + rowDragDirective.onPointerDown(pointerDownEvent); + rowDragDirective.onPointerMove(pointerMoveEvent); + verifyRowDragStartEvent(dragGrid, dragGrid.getRowByKey(rowToDrag.key), + rowToDrag.nativeElement, rowDragDirective, 3); + rowDragDirective.onPointerMove(pointerMoveToDropEvent); + rowDragDirective.onPointerUp(pointerUpEvent); + fixture.detectChanges(); + verifyRowDragEndEvent(dragGrid, dragGrid.getRowByKey(rowToDrag.key), + rowToDrag.nativeElement, rowDragDirective, false, 3); + }); + }); +}); + +@Component({ + template: ` + + +
    +
    +
    +
    + `, + imports: [IgxGridComponent, IgxDropDirective, NgStyle] +}) +export class IgxGridRowDraggableComponent extends DataParent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public instance: IgxGridComponent; + + @ViewChild('dropArea', { read: IgxDropDirective, static: true }) + public dropArea: IgxDropDirective; + + public width = '800px'; + public height = null; + + public enableSorting = false; + public enableFiltering = false; + public enableResizing = false; + public enableEditing = true; + public enableGrouping = true; + public enableRowEditing = true; + public enableRowDraggable = true; + public currentSortExpressions; + + public columnsCreated(column: IgxColumnComponent) { + column.sortable = this.enableSorting; + column.filterable = this.enableFiltering; + column.resizable = this.enableResizing; + column.editable = this.enableEditing; + column.groupable = this.enableGrouping; + } + public groupingDoneHandler(sortExpr) { + this.currentSortExpressions = sortExpr; + } + public onRowDrop(args) { + args.cancel = true; + } +} + +@Component({ + template: ` + + +
    + + Moving a row! +
    +
    +
    +
    +
    +
    +
    + + +
    + CUSTOM +
    +
    + + expand_less + + `, + imports: [IgxGridComponent, IgxIconComponent, IgxDropDirective, IgxRowDragGhostDirective, IgxDragIndicatorIconDirective, NgStyle] +}) +export class IgxGridRowCustomGhostDraggableComponent extends DataParent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public instance: IgxGridComponent; + + @ViewChild('rowDragGhostTemplate', {read: TemplateRef, static: true }) + public rowDragGhostTemplate: TemplateRef; + + @ViewChild('rowDragTemplate', {read: TemplateRef, static: true }) + public rowDragTemplate: TemplateRef; + + @ViewChild('dropArea', { read: IgxDropDirective, static: true }) + public dropArea: IgxDropDirective; + + public width = '800px'; + public height = null; + + public enableSorting = false; + public enableFiltering = false; + public enableResizing = false; + public enableEditing = true; + public enableGrouping = true; + public enableRowEditing = true; + public enableRowDraggable = true; + public currentSortExpressions; + + public columnsCreated(column: IgxColumnComponent) { + column.sortable = this.enableSorting; + column.filterable = this.enableFiltering; + column.resizable = this.enableResizing; + column.editable = this.enableEditing; + column.groupable = this.enableGrouping; + } + public groupingDoneHandler(sortExpr) { + this.currentSortExpressions = sortExpr; + } + public onRowDrop(args) { + args.cancel = true; + } +} + +@Component({ + template: ` + + +
    + + + + + + +
    + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxDropDirective] +}) +export class IgxGridFeaturesRowDragComponent extends DataParent { + @ViewChild('dragGrid', { read: IgxGridComponent, static: true }) + public dragGrid: IgxGridComponent; + @ViewChild('dropGrid', { read: IgxGridComponent, static: true }) + public dropGrid: IgxGridComponent; + public newData = []; + public currentSortExpressions; + + public groupingDoneHandler(sortExpr) { + this.currentSortExpressions = sortExpr; + } + public onRowDrop(args) { + args.cancel = true; + this.dropGrid.addRow(args.dragData.data); + } +} + +@Component({ + template: ` + + + + + + +
    + + + + + + + + +
    `, + imports: [IgxHierarchicalGridComponent, IgxColumnComponent, IgxRowIslandComponent, IgxDropDirective] +}) +export class IgxHierarchicalGridTestComponent { + @ViewChild('hierarchicalDragGrid', { read: IgxHierarchicalGridComponent, static: true }) public hDragGrid: IgxHierarchicalGridComponent; + @ViewChild('hierarchicalDropGrid', { read: IgxHierarchicalGridComponent, static: true }) public hDropGrid: IgxHierarchicalGridComponent; + @ViewChild('rowIsland', { read: IgxRowIslandComponent, static: true }) public rowIsland: IgxRowIslandComponent; + @ViewChild('rowIsland2', { read: IgxRowIslandComponent, static: true }) public rowIsland2: IgxRowIslandComponent; + + public data; + public newData = []; + + constructor() { + this.data = SampleTestData.generateHGridData(2, 3); + } + public onRowDrop(args) { + args.cancel = true; + this.hDropGrid.addRow(args.dragData.data); + } +} + +@Component({ + template: ` + + + + + +
    + Moving {{data.ProductName}}! +
    +
    +
    + +
    + Moving {{data.ProductName}}! +
    +
    +
    `, + imports: [IgxHierarchicalGridComponent, IgxRowIslandComponent, IgxRowDragGhostDirective] +}) +export class IgxHierarchicalGridCustomGhostTestComponent { + @ViewChild('hierarchicalDragGrid', { read: IgxHierarchicalGridComponent, static: true }) public hDragGrid: IgxHierarchicalGridComponent; + @ViewChild('rowIsland', { read: IgxRowIslandComponent, static: true }) public rowIsland: IgxRowIslandComponent; + @ViewChild('rowIsland2', { read: IgxRowIslandComponent, static: true }) public rowIsland2: IgxRowIslandComponent; + + public data; + public newData = []; + + constructor() { + this.data = SampleTestData.generateHGridData(2, 3); + } +} + +@Component({ + template: ` + + + + + + +
    + + + + + +
    + `, + imports: [IgxTreeGridComponent, IgxGridComponent, IgxColumnComponent, IgxDropDirective] +}) +export class IgxTreeGridTestComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + @ViewChild(IgxGridComponent, { static: true }) public dropGrid: IgxGridComponent; + public data = SampleTestData.employeeScrollingData(); + public newData = []; + + public onRowDrop(args) { + args.cancel = true; + this.dropGrid.addRow(args.dragData.data); + } +} + +/** + * Move pointer to the provided point and calls pointerdown event over provided element + * + * @param element Element to fire event on + * @param startPoint Point on which to move the pointer to + * @param fixture Test's ComponentFixture + * @returns Promise with reference to the generated event + */ +const pointerDown = async (element: Element, startPoint: Point, fixture: ComponentFixture): Promise => { + const pointerEvent = UIInteractions.simulatePointerEvent('pointerdown', element, startPoint.x, startPoint.y); + await wait(DEBOUNCE_TIME); + fixture.detectChanges(); + return pointerEvent; +}; + +/** + * Move pointer to the provided point and calls pointermove event over provided element + * + * @param element Element to fire event on + * @param startPoint Point on which to move the pointer to + * @param fixture Test's ComponentFixture + * @returns Promise with reference to the generated event + */ +const pointerMove = async (element: Element, startPoint: Point, fixture: ComponentFixture): Promise => { + const pointerEvent = UIInteractions.simulatePointerEvent('pointermove', element, startPoint.x, startPoint.y); + await wait(DEBOUNCE_TIME); + fixture.detectChanges(); + return pointerEvent; +}; + +/** + * Move pointer to the provided point and calls pointerup event over provided element + * + * @param element Element to fire event on + * @param startPoint Point on which to move the pointer to + * @param fixture Test's ComponentFixture + * @returns Promise with reference to the generated event + */ +const pointerUp = async (element: Element, startPoint: Point, fixture: ComponentFixture): Promise => { + const pointerEvent = UIInteractions.simulatePointerEvent('pointerup', element, startPoint.x, startPoint.y); + await wait(DEBOUNCE_TIME); + fixture.detectChanges(); + return pointerEvent; +}; + +/** + * Verifies weather the rowDragStart event has been emitted with the correct arguments + * + * @param grid IgxGrid from which a row is being dragged + * @param dragRow Grid row which is being dragged + * @param dragDirective IgxRowDragDirective of the dragged row + * @param timesCalled The number of times the rowDragStart event has been emitted. Defaults to 1. + * @param cancel Indicates weather the rowDragStart event is cancelled. Default value is false. + */ +const verifyRowDragStartEvent =( + grid: GridType, + dragRow: RowType, + dragElement: HTMLElement, + dragDirective: IgxRowDragDirective, + timesCalled = 1, + cancel = false) => { + expect(grid.rowDragStart.emit).toHaveBeenCalledTimes(timesCalled); + expect(grid.rowDragStart.emit).toHaveBeenCalledWith({ + dragData: dragRow, + dragElement, + dragDirective, + cancel, + owner: grid + }); +}; + +/** + * Verifies weather the rowDragEnd event has been emitted with the correct arguments + * + * @param grid IgxGrid from which a row is being dragged + * @param dragRow Grid row which is being dragged + * @param dragDirective IgxRowDragDirective of the dragged row + * @param timesCalled The number of times the rowDragEnd event has been emitted. Defaults to 1. + */ +const verifyRowDragEndEvent = ( + grid: GridType, + dragRow: RowType, + dragElement: HTMLElement, + dragDirective: IgxRowDragDirective, + animations: boolean, + timesCalled = 1) => { + expect(grid.rowDragEnd.emit).toHaveBeenCalledTimes(timesCalled); + expect(grid.rowDragEnd.emit).toHaveBeenCalledWith({ + dragDirective, + dragData: dragRow, + dragElement, + animation: animations, + owner: grid + }); +}; diff --git a/projects/igniteui-angular/grids/hierarchical-grid/index.ts b/projects/igniteui-angular/grids/hierarchical-grid/index.ts new file mode 100644 index 00000000000..f01fd0a4c82 --- /dev/null +++ b/projects/igniteui-angular/grids/hierarchical-grid/index.ts @@ -0,0 +1,9 @@ +/** + * IgxHierarchicalGrid - Hierarchical grid component for parent-child data + * + * Import hierarchical-grid-specific components and re-export core grid functionality + */ + +// Export hierarchical-grid-specific components +export * from './src/public_api'; +export * from './src/hierarchical-grid.module'; diff --git a/projects/igniteui-angular/grids/hierarchical-grid/ng-package.json b/projects/igniteui-angular/grids/hierarchical-grid/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/grids/hierarchical-grid/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/README.md b/projects/igniteui-angular/grids/hierarchical-grid/src/README.md new file mode 100644 index 00000000000..e05651b222f --- /dev/null +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/README.md @@ -0,0 +1,201 @@ +# igx-hierarchical-grid + +The **igx-hierarchical-grid** component provides the ability to represent and manipulate hierarchical data in which each level has a different schema. Each level is represented by a component derived from **igx-grid** and supports most of its functionality. +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/hierarchicalgrid.html). + +## Usage + +```html + + + + +``` + +## Getting Started + +### Dependencies +The hierarchical grid is exported as an `NgModule`, thus all you need to do in your application is to import the _IgxHierarchicalGridModule_ inside your `AppModule`. + +```typescript +// app.module.ts + +import { IgxHierarchicalGridModule } from 'igniteui-angular'; + +@NgModule({ + imports: [ + ... + IgxHierarchicalGridModule, + ... + ] +}) +export class AppModule {} +``` + +We can obtain a reference to the tree grid in typescript as follows: + +```typescript +@ViewChild('hgrid1', { read: IgxHierarchicalGridComponent }) +public hgrid1: IgxHierarchicalGridComponent; +``` + +### Basic configuration + +**igx-hierarchical-grid** derives from **igx-grid** and shares most of its functionality. The main difference is that it allows for defining multiple levels of hierarchy that that is configured through a separate tag within the definition of **igx-hierarchical-grid** called **igx-row-island**. The latter component defines the configuration for each child grid for the particular level. Multiple islands per level is also supported. + +Two ways of binding a hierarchical grid are supported + +#### Using hierarchical data + +If the application is designed to loads the whole data for the hierarchical grid in the form of array of objects referencing children arrays of objects, then the hierarchical grid can be configured to read it and bind to it automatically. Each **igx-row-island** should specify the key of the property that holds the children data. + +```html + + + + + + + + +``` + +Notice that instead of `data` the user configures only the `key` that the **igx-hierarchical-grid** needs to read to set the data automatically. + +#### Using load-on-demand + +Most applications are designed to load as little data as possible initially for faster load times. In such cases **igx-hierarchical-grid** may be configured to allow user-created services to feed it with data on demand. The following configuration uses a special `@Output` and a newly introduced loading-in-progress template to provide a fully-featured load-on-demand. + +```html + + + + + + +``` + +```typescript + gridCreated(event: IGridCreatedEventArgs, rowIsland: IgxRowIslandComponent) { + this.remoteService.getData( + { + parentID: event.parendID, + level: rowIsland.level, + key: rowIsland.key + }, (data) => { + event.grid.data = data['value']; + event.grid.isLoading = false; + event.grid.cdr.detectChanges(); + } + ); + } +``` + +### Features + +The following grid features work on a per grid level, which means that each grid instance manages these features independently from the rest of the grids. + +- Sorting +- Filtering +- Paging +- Multi-column headers +- Hiding +- Pinning +- Moving +- Summaries + +- Selection + Selection works globally for the whole **igx-hierarchical-grid** and does not allow selected cells to be present for two different child grids at once. + +- Navigation + When navigating up/down if next/prev element is child grid navigation will continue in the related child grid, marking the related cell as selected and focused. If the child cell is outside the current visible view port it is scrolled into view so that selected cell is always visible. + +The following features are no supported and not exposed in the API of the Hierarchical Grid. + +- Group By + +Enabling and configuring features is done through the **igx-row-island** markup and is applied for every grid that is created for it. Changing options on runtime through the row instance changes them for each of the grids it spawned. + +```html + + + + + + + + + + + + + + + + +``` + +### CRUD operations + +An important difference from the flat grid is that each instance for a given row island has the same transaction service instance and accumulates the same transaction log. In order to enable the CRUD functionality users should inject the `IgxHierarchicalTransactionServiceFactory`. + +Calling CRUD API methods should still be done through each separate grid instance. + +## API + +### Inputs + +Below is the list of all inputs that the developers may set to configure the grid look/behavior: + + +- `IgxHierarchicalGridBaseDirective` + + | Name | Description | Type | Default value | Valid values | + | ---- | ----------- | ---- | ------------- | ------------ | + | expansionStates | Returns a list of key-value pairs [row ID, expansion state]. Includes only states that differ from the default one. | `Map` | `new Map()` | | + +- `IgxHierarchicalGrid` extends `IgxHierarchicalGridBaseDirective` + + | Name | Description | Type | Default value | Valid values | + | ---- | ----------- | ---- | ------------- | ------------ | + | data | The hierarchical grid data source | `Array` | null | | + +- `IgxRowIslandComponent` extends `IgxHierarchicalGridBaseDirective` + + | Name | Description | Type | Default value | Valid values | + | ---- | ----------- | ---- | ------------- | ------------ | + | key | Unique identifier for the row island and the property to read for the array to bind to from a parent record | string | null | | + | expandChildren | Should child island be expanded. Setting it during runtime will expand or collapse all records | `boolean` | `false` | `true`|`false` | + + +### Outputs + +- A list of the events emitted by the **igx-row-island**: + + |Name|Description| + |--- |--- | + |_Event emitters_|_Notify for a change_| + | gridCreated | Emitted when a grid is being created for this row island | false | parentRecord: `any`, owner: `IgxRowIslandComponent`, grid: `IgxHierarchicalGridComponent` | + | gridInitialized | Emitted after a grid is being initialized for this row island. The emitting is done in `ngAfterViewInit` | false | parentRecord: `any`, owner: `IgxRowIslandComponent`, grid: `IgxHierarchicalGridComponent` | + + +### Properties + +- `IgxHierarchicalGrid` + |Name|Type|Getter|Setter|Description| + |--- |--- |--- |--- |--- | + | foreignKey | `any` | true | false | The unique identifier of the parent row | + + +Defining handlers for this event emitter is done using declarative event binding: + +```html + + +``` + +### Methods diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/child-grid-row.component.html b/projects/igniteui-angular/grids/hierarchical-grid/src/child-grid-row.component.html new file mode 100644 index 00000000000..6328f94963d --- /dev/null +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/child-grid-row.component.html @@ -0,0 +1,3 @@ +
    + +
    diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/events.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/events.ts new file mode 100644 index 00000000000..3c04305b18f --- /dev/null +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/events.ts @@ -0,0 +1,11 @@ +import { IBaseEventArgs } from 'igniteui-angular/core'; +import { IgxHierarchicalGridComponent } from './hierarchical-grid.component'; +import { IgxRowIslandComponent } from './row-island.component'; + + +export interface IGridCreatedEventArgs extends IBaseEventArgs { + owner: IgxRowIslandComponent; + parentID: any; + grid: IgxHierarchicalGridComponent; + parentRowData?: any; +} diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-cell.component.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-cell.component.ts new file mode 100644 index 00000000000..82ac3c643d4 --- /dev/null +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-cell.component.ts @@ -0,0 +1,84 @@ +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { HammerGesturesManager } from 'igniteui-angular/core'; +import { + IgxColumnFormatterPipe, + IgxGridCellComponent, + IgxGridCellImageAltPipe, + IgxStringReplacePipe +} from 'igniteui-angular/grids/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { NgClass, NgTemplateOutlet, DecimalPipe, PercentPipe, CurrencyPipe, DatePipe } from '@angular/common'; +import { IgxChipComponent } from 'igniteui-angular/chips'; +import { IgxDateTimeEditorDirective, IgxFocusDirective, IgxTextHighlightDirective, IgxTextSelectionDirective, IgxTooltipDirective, IgxTooltipTargetDirective } from 'igniteui-angular/directives'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxInputDirective, IgxInputGroupComponent, IgxPrefixDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; +import { IgxCheckboxComponent } from 'igniteui-angular/checkbox'; +import { IgxDatePickerComponent } from 'igniteui-angular/date-picker'; +import { IgxTimePickerComponent } from 'igniteui-angular/time-picker'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'igx-hierarchical-grid-cell', + templateUrl: '../../core/src/cell.component.html', + providers: [HammerGesturesManager], + imports: [IgxChipComponent, IgxTextHighlightDirective, IgxIconComponent, NgClass, FormsModule, ReactiveFormsModule, IgxInputGroupComponent, IgxInputDirective, IgxFocusDirective, IgxTextSelectionDirective, IgxCheckboxComponent, IgxDatePickerComponent, IgxTimePickerComponent, IgxDateTimeEditorDirective, IgxPrefixDirective, IgxSuffixDirective, NgTemplateOutlet, IgxTooltipTargetDirective, IgxTooltipDirective, IgxGridCellImageAltPipe, IgxStringReplacePipe, IgxColumnFormatterPipe, DecimalPipe, PercentPipe, CurrencyPipe, DatePipe] +}) +export class IgxHierarchicalGridCellComponent extends IgxGridCellComponent implements OnInit { + // protected hSelection; + protected _rootGrid; + + public override ngOnInit() { + super.ngOnInit(); + this._rootGrid = this._getRootGrid(); + } + + /** + * @hidden + * @internal + */ + public override activate(event: FocusEvent) { + this._clearAllHighlights(); + const currentElement = this.grid.nativeElement; + let parentGrid = this.grid; + let childGrid; + // add highligh to the current grid + if (this._rootGrid.id !== currentElement.id) { + currentElement.classList.add('igx-grid__tr--highlighted'); + } + + // add highligh to the current grid + while (this._rootGrid.id !== parentGrid.id) { + childGrid = parentGrid; + parentGrid = parentGrid.parent; + + const parentRowID = parentGrid.gridAPI.getParentRowId(childGrid); + parentGrid.highlightedRowID = parentRowID; + } + this.grid.navigation.activeNode.gridID = this.gridID; + super.activate(event); + } + + private _getRootGrid() { + let currGrid = this.grid; + while (currGrid.parent) { + currGrid = currGrid.parent; + } + return currGrid; + } + + // TODO: Extend the new selection service to avoid complete traversal + private _clearAllHighlights() { + [this._rootGrid, ...this._rootGrid.getChildGrids(true)].forEach(grid => { + if (grid !== this.grid && grid.navigation.activeNode) { + grid.selectionService.activeElement = null; + grid.navigation.clearActivation(); + grid.selectionService.initKeyboardState(); + grid.selectionService.clear(); + } + + grid.nativeElement.classList.remove('igx-grid__tr--highlighted'); + grid.highlightedRowID = null; + grid.cdr.markForCheck(); + }); + } +} diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid-add-row.spec.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid-add-row.spec.ts new file mode 100644 index 00000000000..b1cc31bd3d0 --- /dev/null +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid-add-row.spec.ts @@ -0,0 +1,98 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxActionStripComponent } from 'igniteui-angular/action-strip'; +import { IgxHierarchicalGridActionStripComponent } from '../../../test-utils/hierarchical-grid-components.spec'; +import { wait } from '../../../test-utils/ui-interactions.spec'; +import { By } from '@angular/platform-browser'; +import { IgxHierarchicalGridComponent } from './hierarchical-grid.component'; +import { IgxGridNavigationService } from 'igniteui-angular/grids/core'; + +describe('IgxHierarchicalGrid - Add Row UI #tGrid', () => { + let fixture; + let hierarchicalGrid: IgxHierarchicalGridComponent; + let _actionStrip: IgxActionStripComponent; + const endTransition = () => { + // transition end needs to be simulated + const animationElem = fixture.nativeElement.querySelector('.igx-grid__tr--inner'); + const endEvent = new AnimationEvent('animationend'); + animationElem.dispatchEvent(endEvent); + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, IgxHierarchicalGridActionStripComponent + ], + providers: [ + IgxGridNavigationService + ] + }).compileComponents(); + })); + + describe(' Basic', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxHierarchicalGridActionStripComponent); + fixture.detectChanges(); + hierarchicalGrid = fixture.componentInstance.hgrid; + _actionStrip = fixture.componentInstance.actionStrip; + }); + + it('Should collapse an expanded record when beginAddRow is called for it', () => { + const row = hierarchicalGrid.rowList.first; + hierarchicalGrid.expandRow(row.key); + fixture.detectChanges(); + expect(row.expanded).toBeTrue(); + + row.beginAddRow(); + fixture.detectChanges(); + expect(row.expanded).toBeFalse(); + expect(hierarchicalGrid.gridAPI.get_row_by_index(1).addRowUI).toBeTrue(); + }); + + it('Should allow the expansion of a newly added (commited) record', async () => { + const row = hierarchicalGrid.rowList.first; + hierarchicalGrid.expandRow(row.key); + fixture.detectChanges(); + expect(row.expanded).toBeTrue(); + + row.beginAddRow(); + fixture.detectChanges(); + endTransition(); + expect(row.expanded).toBeFalse(); + + expect(hierarchicalGrid.gridAPI.get_row_by_index(1).addRowUI).toBeTrue(); + hierarchicalGrid.gridAPI.crudService.endEdit(true); + fixture.detectChanges(); + hierarchicalGrid.addRowSnackbar.triggerAction(); + fixture.detectChanges(); + + await wait(100); + fixture.detectChanges(); + + const newRowData = hierarchicalGrid.data[hierarchicalGrid.data.length - 1]; + const newRow = hierarchicalGrid.rowList.find(r => r.key === newRowData[hierarchicalGrid.primaryKey]); + expect(newRow.expanded).toBeFalse(); + hierarchicalGrid.expandRow(newRow.key); + fixture.detectChanges(); + expect(newRow.expanded).toBeTrue(); + }); + + it('Should allow adding to child grid for parent row that has null/undefined child collection.', async () => { + const data = [{ ID: '1', ProductName: 'Product: A' }]; + hierarchicalGrid.data = data; + fixture.detectChanges(); + + hierarchicalGrid.expandRow('1'); + fixture.detectChanges(); + + const child1Grids = fixture.debugElement.queryAll(By.css('igx-child-grid-row')); + const child1Grid = child1Grids[0].query(By.css('igx-hierarchical-grid')); + const childComponent: IgxHierarchicalGridComponent = child1Grid.componentInstance; + const childDataToAdd = { ID: '2', ProductName: 'ChildProduct: A' }; + childComponent.addRow(childDataToAdd); + fixture.detectChanges(); + expect(data[0]['childData1'].length).toBe(1); + expect(data[0]['childData1'][0]).toBe(childDataToAdd); + }); + }); +}); diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid-api.service.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid-api.service.ts new file mode 100644 index 00000000000..b76e4f8a676 --- /dev/null +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid-api.service.ts @@ -0,0 +1,128 @@ +import { IgxRowIslandComponent } from './row-island.component'; +import { Subject } from 'rxjs'; +import { Injectable } from '@angular/core'; +import { GridBaseAPIService, GridType } from 'igniteui-angular/grids/core'; +import { IPathSegment } from 'igniteui-angular/core'; + +@Injectable() +export class IgxHierarchicalGridAPIService extends GridBaseAPIService { + protected childRowIslands: Map = new Map(); + protected childGrids: Map> = + new Map>(); + + public registerChildRowIsland(rowIsland: IgxRowIslandComponent) { + this.childRowIslands.set(rowIsland.key, rowIsland); + this.destroyMap.set(rowIsland.key, new Subject()); + } + + public unsetChildRowIsland(rowIsland: IgxRowIslandComponent) { + this.childGrids.delete(rowIsland.key); + this.childRowIslands.delete(rowIsland.key); + this.destroyMap.delete(rowIsland.key); + } + + public getChildRowIsland(key: string) { + return this.childRowIslands.get(key); + } + + public getChildGrid(path: Array): GridType | undefined { + const currPath = path; + let grid; + const pathElem = currPath.shift(); + const childrenForLayout = this.childGrids.get(pathElem.rowIslandKey); + if (childrenForLayout) { + const childGrid = childrenForLayout.get(pathElem.rowKey); + if (currPath.length === 0) { + grid = childGrid; + } else { + grid = childGrid.gridAPI.getChildGrid(currPath); + } + } + return grid; + } + + public getChildGrids(inDepth?: boolean) { + let allChildren: GridType [] = []; + this.childGrids.forEach((layoutMap) => { + layoutMap.forEach((grid) => { + allChildren.push(grid); + if (inDepth) { + const children = grid.gridAPI.getChildGrids(inDepth); + allChildren = allChildren.concat(children); + } + }); + }); + + return allChildren; + } + + public getParentRowId(childGrid: GridType) { + let rowID; + this.childGrids.forEach((layoutMap) => { + layoutMap.forEach((grid, key) => { + if (grid === childGrid) { + rowID = key; + return; + } + }); + }); + return rowID; + } + + public registerChildGrid(parentRowID: any, rowIslandKey: string, grid: GridType) { + let childrenForLayout = this.childGrids.get(rowIslandKey); + if (!childrenForLayout) { + this.childGrids.set(rowIslandKey, new Map()); + childrenForLayout = this.childGrids.get(rowIslandKey); + } + childrenForLayout.set(parentRowID, grid); + } + + public getChildGridsForRowIsland(rowIslandKey: string): GridType[] { + const childrenForLayout = this.childGrids.get(rowIslandKey); + const children = []; + if (childrenForLayout) { + childrenForLayout.forEach((child) => { + children.push(child); + }); + } + return children; + } + + public getChildGridByID(rowIslandKey, rowID) { + const childrenForLayout = this.childGrids.get(rowIslandKey); + return childrenForLayout.get(rowID); + } + + public override get_row_expansion_state(record: any): boolean { + let inState; + if (record.childGridsData !== undefined) { + const ri = record.key; + const states = this.grid.expansionStates; + const expanded = states.get(ri); + if (expanded !== undefined) { + return expanded; + } else { + return this.grid.getDefaultExpandState(record); + } + } else { + inState = !!super.get_row_expansion_state(record); + } + return inState && (this.grid as any).childLayoutList.length !== 0; + } + + public override allow_expansion_state_change(rowID, expanded): boolean { + const rec = this.get_rec_by_id(rowID); + const grid = (this.grid as any); + if (grid.hasChildrenKey && !rec[grid.hasChildrenKey]) { + return false; + } + return !!rec && this.grid.expansionStates.get(rowID) !== expanded; + } + + public override get_rec_by_id(rowID): any { + const data = this.get_all_data(false); + const index = this.get_row_index_in_data(rowID, data); + return data[index]; + } +} diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid-base.directive.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid-base.directive.ts new file mode 100644 index 00000000000..2a1d3bfec0a --- /dev/null +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid-base.directive.ts @@ -0,0 +1,231 @@ +import { + booleanAttribute, + createComponent, + Directive, + EventEmitter, + Input, + Output, + reflectComponentType, + inject +} from '@angular/core'; +import { IgxHierarchicalGridAPIService } from './hierarchical-grid-api.service'; +import { IgxRowIslandComponent } from './row-island.component'; +import { IgxSummaryOperand } from 'igniteui-angular/grids/core'; +import { IgxHierarchicalGridNavigationService } from './hierarchical-grid-navigation.service'; +import { GridType, IGX_GRID_SERVICE_BASE } from 'igniteui-angular/grids/core'; +import { IgxColumnGroupComponent } from 'igniteui-angular/grids/core'; +import { IgxColumnComponent } from 'igniteui-angular/grids/core'; +import { takeUntil } from 'rxjs/operators'; +import { IgxGridTransaction } from 'igniteui-angular/grids/core'; +import { IgxTransactionService, IPathSegment } from 'igniteui-angular/core'; +import { IForOfState } from 'igniteui-angular/directives'; +import { IgxGridBaseDirective } from 'igniteui-angular/grids/grid'; + +export const hierarchicalTransactionServiceFactory = () => new IgxTransactionService(); + +export const IgxHierarchicalTransactionServiceFactory = { + provide: IgxGridTransaction, + useFactory: hierarchicalTransactionServiceFactory +}; + +/* blazorIndirectRender + blazorComponent + omitModule + wcSkipComponentSuffix */ +@Directive() +export abstract class IgxHierarchicalGridBaseDirective extends IgxGridBaseDirective implements GridType { + public override gridAPI = inject(IGX_GRID_SERVICE_BASE); + public override navigation = inject(IgxHierarchicalGridNavigationService); + /** + * Gets/Sets the key indicating whether a row has children. If row has no children it does not render an expand indicator. + * + * @example + * ```html + * + * + * ``` + */ + @Input() + public hasChildrenKey: string; + + /** + * Gets/Sets whether the expand/collapse all button in the header should be rendered. + * + * @remarks + * The default value is false. + * @example + * ```html + * + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public showExpandAll = false; + + /** + * Emitted when a new chunk of data is loaded from virtualization. + * + * @example + * ```typescript + * + * + * ``` + */ + @Output() + public dataPreLoad = new EventEmitter(); + + /** @hidden @internal */ + public override get type(): GridType["type"] { + return 'hierarchical'; + } + + /* blazorSuppress */ + /** + * Gets the outlet used to attach the grid's overlays to. + * + * @remarks + * If set, returns the outlet defined outside the grid. Otherwise returns the grid's internal outlet directive. + */ + public override get outlet() { + return this.rootGrid ? this.rootGrid.resolveOutlet() : this.resolveOutlet(); + } + + /* blazorSuppress */ + /** + * Sets the outlet used to attach the grid's overlays to. + */ + public override set outlet(val: any) { + this._userOutletDirective = val; + } + + /** @hidden @internal */ + public batchEditingChange: EventEmitter = new EventEmitter(); + + public override get batchEditing(): boolean { + return this._batchEditing; + } + + public override set batchEditing(val: boolean) { + if (val !== this._batchEditing) { + delete this._transactions; + this.switchTransactionService(val); + this.subscribeToTransactions(); + this.batchEditingChange.emit(val); + this._batchEditing = val; + } + } + + /** + * @hidden + */ + public parentIsland: IgxRowIslandComponent; + public abstract rootGrid: GridType; + + /* blazorSuppress */ + public abstract expandChildren: boolean; + + /** + * @hidden + */ + public createColumnsList(cols: Array) { + const columns = []; + const topLevelCols = cols.filter(c => c.level === 0); + topLevelCols.forEach((col) => { + col.grid = this; + const ref = this._createColumn(col); + ref.changeDetectorRef.detectChanges(); + columns.push(ref.instance); + }); + const result = flatten(columns); + this.updateColumns(result); + this.initPinning(); + + result.forEach(col => { + this.columnInit.emit(col); + }); + + const mirror = reflectComponentType(IgxColumnComponent); + const outputs = mirror.outputs.filter(o => o.propName !== 'columnChange'); + outputs.forEach(output => { + this.columns.forEach(column => { + if (column[output.propName]) { + column[output.propName].pipe(takeUntil(column.destroy$)).subscribe((args) => { + const rowIslandColumn = this.parentIsland.columnList.find((col) => col.field + ? col.field === column.field + : col.header === column.header); + rowIslandColumn[output.propName].emit({ args, owner: this }); + }); + } + }); + }); + } + + protected _createColumn(col) { + let ref; + if (col instanceof IgxColumnGroupComponent) { + ref = this._createColGroupComponent(col); + } else { + ref = this._createColComponent(col); + } + return ref; + } + + protected _createColGroupComponent(col: IgxColumnGroupComponent) { + const ref = createComponent(IgxColumnGroupComponent, { environmentInjector: this.envInjector, elementInjector: this.injector }); + ref.changeDetectorRef.detectChanges(); + const mirror = reflectComponentType(IgxColumnGroupComponent); + mirror.inputs.forEach((input) => { + const propName = input.propName; + ref.instance[propName] = col[propName]; + }); + if (col.children.length > 0) { + const newChildren = []; + col.children.forEach(child => { + const newCol = this._createColumn(child).instance; + newCol.parent = ref.instance; + newChildren.push(newCol); + }); + ref.instance.children.reset(newChildren); + ref.instance.children.notifyOnChanges(); + } + return ref; + } + + protected _createColComponent(col) { + const ref = createComponent(IgxColumnComponent, { environmentInjector: this.envInjector, elementInjector: this.injector }); + const mirror = reflectComponentType(IgxColumnComponent); + mirror.inputs.forEach((input) => { + const propName = input.propName; + if (!(col[propName] instanceof IgxSummaryOperand)) { + ref.instance[propName] = col[propName]; + } else { + ref.instance[propName] = col[propName].constructor; + } + }); + ref.instance.validators = col.validators; + return ref; + } + + protected getGridsForIsland(rowIslandID: string) { + return this.gridAPI.getChildGridsForRowIsland(rowIslandID); + } + + protected getChildGrid(path: Array) { + if (!path) { + return; + } + return this.gridAPI.getChildGrid(path); + } +} + +const flatten = (arr: any[]) => { + let result = []; + + arr.forEach(el => { + result.push(el); + if (el.children) { + result = result.concat(flatten(el.children.toArray())); + } + }); + return result; +}; diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid-navigation.service.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid-navigation.service.ts new file mode 100644 index 00000000000..c2180c755be --- /dev/null +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid-navigation.service.ts @@ -0,0 +1,462 @@ +import { Injectable } from '@angular/core'; +import { first } from 'rxjs/operators'; +import { GridType, RowType } from 'igniteui-angular/grids/core'; +import { IActiveNode, IgxGridNavigationService } from 'igniteui-angular/grids/core'; +import { IPathSegment, NAVIGATION_KEYS, SUPPORTED_KEYS } from 'igniteui-angular/core'; + +@Injectable() +export class IgxHierarchicalGridNavigationService extends IgxGridNavigationService { + protected _pendingNavigation = false; + + + public override dispatchEvent(event: KeyboardEvent) { + const key = event.key.toLowerCase(); + const cellOrRowInEdit = this.grid.crudService.cell || this.grid.crudService.row; + if (!this.activeNode || !(SUPPORTED_KEYS.has(key) || (key === 'tab' && cellOrRowInEdit))) { + return; + } + + const targetGrid = this.getClosestElemByTag(event.target, 'igx-hierarchical-grid') + || this.getClosestElemByTag(event.target, 'igc-hierarchical-grid'); + if (targetGrid !== this.grid.nativeElement) { + return; + } + + if (this._pendingNavigation && NAVIGATION_KEYS.has(key)) { + // In case focus needs to be moved from one grid to another, however there is a pending scroll operation + // which is an async operation, any additional navigation keys should be ignored + // untill operation complete. + event.preventDefault(); + return; + } + super.dispatchEvent(event); + } + + public override navigateInBody(rowIndex, visibleColIndex, cb: (arg: any) => void = null): void { + const rec = this.grid.dataView[rowIndex]; + if (rec && this.grid.isChildGridRecord(rec)) { + // target is child grid + const virtState = this.grid.verticalScrollContainer.state; + const inView = rowIndex >= virtState.startIndex && rowIndex <= virtState.startIndex + virtState.chunkSize; + const isNext = this.activeNode.row < rowIndex; + const targetLayoutIndex = isNext ? null : this.grid.childLayoutKeys.length - 1; + if (inView) { + this._moveToChild(rowIndex, visibleColIndex, isNext, targetLayoutIndex, cb); + } else { + let scrollAmount = this.grid.verticalScrollContainer.getScrollForIndex(rowIndex, !isNext); + scrollAmount += isNext ? 1 : -1; + this.grid.verticalScrollContainer.getScroll().scrollTop = scrollAmount; + this._pendingNavigation = true; + this.grid.verticalScrollContainer.chunkLoad.pipe(first()).subscribe(() => { + this._moveToChild(rowIndex, visibleColIndex, isNext, targetLayoutIndex, cb); + this._pendingNavigation = false; + }); + } + return; + } + + const isLast = rowIndex === this.grid.dataView.length; + if ((rowIndex === -1 || isLast) && + this.grid.parent !== null) { + // reached end of child grid + const nextSiblingIndex = this.nextSiblingIndex(isLast); + if (nextSiblingIndex !== null) { + this.grid.parent.navigation._moveToChild(this.grid.childRow.index, visibleColIndex, isLast, nextSiblingIndex, cb); + } else { + this._moveToParent(isLast, visibleColIndex, cb); + } + return; + } + + if (this.grid.parent) { + const isNext = this.activeNode && typeof this.activeNode.row === 'number' ? rowIndex > this.activeNode.row : false; + const cbHandler = (args) => { + this._handleScrollInChild(rowIndex, isNext); + cb(args); + }; + if (!this.activeNode) { + this.activeNode = { row: null, column: null }; + } + super.navigateInBody(rowIndex, visibleColIndex, cbHandler); + return; + } + + if (!this.activeNode) { + this.activeNode = { row: null, column: null }; + } + super.navigateInBody(rowIndex, visibleColIndex, cb); + } + + public override shouldPerformVerticalScroll(index, visibleColumnIndex = -1, isNext?) { + const targetRec = this.grid.dataView[index]; + if (this.grid.isChildGridRecord(targetRec)) { + const scrollAmount = this.grid.verticalScrollContainer.getScrollForIndex(index, !isNext); + const currScroll = this.grid.verticalScrollContainer.getScroll().scrollTop; + const shouldScroll = !isNext ? scrollAmount > currScroll : currScroll < scrollAmount; + return shouldScroll; + } else { + return super.shouldPerformVerticalScroll(index, visibleColumnIndex); + } + } + + public override focusTbody(event) { + if (!this.activeNode || this.activeNode.row === null) { + this.activeNode = { + row: 0, + column: 0 + }; + + this.grid.navigateTo(0, 0, (obj) => { + this.grid.clearCellSelection(); + obj.target.activate(event); + }); + + } else { + super.focusTbody(event); + } + } + + protected nextSiblingIndex(isNext) { + const layoutKey = this.grid.childRow.layout.key; + const layoutIndex = this.grid.parent.childLayoutKeys.indexOf(layoutKey); + const nextIndex = isNext ? layoutIndex + 1 : layoutIndex - 1; + if (nextIndex <= this.grid.parent.childLayoutKeys.length - 1 && nextIndex > -1) { + return nextIndex; + } else { + return null; + } + } + + /** + * Handles scrolling in child grid and ensures target child row is in main grid view port. + * + * @param rowIndex The row index which should be in view. + * @param isNext Optional. Whether we are navigating to next. Used to determine scroll direction. + * @param cb Optional.Callback function called when operation is complete. + */ + protected _handleScrollInChild(rowIndex: number, isNext?: boolean, cb?: () => void) { + const shouldScroll = this.shouldPerformVerticalScroll(rowIndex, -1, isNext); + if (shouldScroll) { + this.grid.navigation.performVerticalScrollToCell(rowIndex, -1, () => { + this.positionInParent(rowIndex, isNext, cb); + }); + } else { + this.positionInParent(rowIndex, isNext, cb); + } + } + + /** + * + * @param rowIndex Row index that should come in view. + * @param isNext Whether we are navigating to next. Used to determine scroll direction. + * @param cb Optional.Callback function called when operation is complete. + */ + protected positionInParent(rowIndex, isNext, cb?: () => void) { + const row = this.grid.gridAPI.get_row_by_index(rowIndex); + if (!row) { + if (cb) { + cb(); + } + return; + } + const positionInfo = this.getPositionInfo(row, isNext); + if (!positionInfo.inView) { + // stop event from triggering multiple times before scrolling is complete. + this._pendingNavigation = true; + const scrollableGrid = isNext ? this.getNextScrollableDown(this.grid) : this.getNextScrollableUp(this.grid); + scrollableGrid.grid.verticalScrollContainer.recalcUpdateSizes(); + scrollableGrid.grid.verticalScrollContainer.addScrollTop(positionInfo.offset); + scrollableGrid.grid.verticalScrollContainer.chunkLoad.pipe(first()).subscribe(() => { + this._pendingNavigation = false; + if (cb) { + cb(); + } + }); + } else { + if (cb) { + cb(); + } + } + } + + /** + * Navigates to the specific child grid based on the array of paths leading to it + * + * @param pathToChildGrid Array of IPathSegments that describe the path to the child grid + * each segment is described by the rowKey of the parent row and the rowIslandKey. + */ + public navigateToChildGrid(pathToChildGrid: IPathSegment[], cb?: () => void) { + if (pathToChildGrid.length == 0) { + if (cb) { + cb(); + } + return; + } + const pathElem = pathToChildGrid.shift(); + const rowKey = pathElem.rowKey; + const rowIndex = this.grid.gridAPI.get_row_index_in_data(rowKey); + if (rowIndex === -1) { + if (cb) { + cb(); + } + return; + } + // scroll to row, since it can be out of view + this.performVerticalScrollToCell(rowIndex, -1, () => { + this.grid.cdr.detectChanges(); + // next, expand row, if it is collapsed + const row = this.grid.getRowByIndex(rowIndex); + if (!row.expanded) { + row.expanded = true; + // update sizes after expand + this.grid.verticalScrollContainer.recalcUpdateSizes(); + this.grid.cdr.detectChanges(); + } + + const childGrid = this.grid.gridAPI.getChildGrid([pathElem]); + if (!childGrid) { + if (cb) { + cb(); + } + return; + } + const positionInfo = this.getElementPosition(childGrid.nativeElement, false); + this.grid.verticalScrollContainer.addScrollTop(positionInfo.offset); + this.grid.verticalScrollContainer.chunkLoad.pipe(first()).subscribe(() => { + childGrid.navigation.navigateToChildGrid(pathToChildGrid, cb); + }); + }); + } + + /** + * Moves navigation to child grid. + * + * @param parentRowIndex The parent row index, at which the child grid is rendered. + * @param childLayoutIndex Optional. The index of the child row island to which the child grid belongs to. Uses first if not set. + */ + protected _moveToChild(parentRowIndex: number, visibleColIndex: number, isNext: boolean, childLayoutIndex?: number, + cb?: (arg: any) => void) { + const ri = typeof childLayoutIndex !== 'number' ? + this.grid.childLayoutList.first : this.grid.childLayoutList.toArray()[childLayoutIndex]; + const rowId = this.grid.dataView[parentRowIndex].rowID; + const pathSegment: IPathSegment = { + rowID: rowId, + rowKey: rowId, + rowIslandKey: ri.key + }; + const childGrid = this.grid.gridAPI.getChildGrid([pathSegment]); + const targetIndex = isNext ? 0 : childGrid.dataView.length - 1; + const targetRec = childGrid.dataView[targetIndex]; + if (!targetRec) { + // if no target rec, then move on in next sibling or parent + childGrid.navigation.navigateInBody(targetIndex, visibleColIndex, cb); + return; + } + if (childGrid.isChildGridRecord(targetRec)) { + // if target is a child grid record should move into it. + this.grid.navigation.activeNode.row = null; + childGrid.navigation.activeNode = { row: targetIndex, column: this.activeNode.column}; + childGrid.navigation._handleScrollInChild(targetIndex, isNext, () => { + const targetLayoutIndex = isNext ? 0 : childGrid.childLayoutList.toArray().length - 1; + childGrid.navigation._moveToChild(targetIndex, visibleColIndex, isNext, targetLayoutIndex, cb); + }); + return; + } + + const childGridNav = childGrid.navigation; + this.clearActivation(); + const lastVisibleIndex = childGridNav.lastColumnIndex; + const columnIndex = visibleColIndex <= lastVisibleIndex ? visibleColIndex : lastVisibleIndex; + childGridNav.activeNode = { row: targetIndex, column: columnIndex}; + childGrid.tbody.nativeElement.focus({preventScroll: true}); + this._pendingNavigation = false; + childGrid.navigation._handleScrollInChild(targetIndex, isNext, () => { + childGrid.navigateTo(targetIndex, columnIndex, cb); + }); + } + + /** + * Moves navigation back to parent grid. + * + * @param rowIndex + */ + protected _moveToParent(isNext: boolean, columnIndex, cb?) { + const indexInParent = this.grid.childRow.index; + const hasNextTarget = this.hasNextTarget(this.grid.parent, indexInParent, isNext); + if (!hasNextTarget) { + return; + } + this.clearActivation(); + const targetRowIndex = isNext ? indexInParent + 1 : indexInParent - 1; + const lastVisibleIndex = this.grid.parent.navigation.lastColumnIndex; + const nextColumnIndex = columnIndex <= lastVisibleIndex ? columnIndex : lastVisibleIndex; + this._pendingNavigation = true; + const cbFunc = (args) => { + this._pendingNavigation = false; + cb(args); + args.target.grid.tbody.nativeElement.focus(); + }; + this.grid.parent.navigation.navigateInBody(targetRowIndex, nextColumnIndex, cbFunc); + } + + /** + * Gets information on the row position relative to the root grid view port. + * Returns whether the row is in view and its offset. + * + * @param rowObj + * @param isNext + */ + protected getPositionInfo(row: RowType, isNext: boolean) { + // XXX: Fix type + let rowElem = row.nativeElement; + if ((row as any).layout) { + const childLayoutKeys = this.grid.childLayoutKeys; + const riKey = isNext ? childLayoutKeys[0] : childLayoutKeys[childLayoutKeys.length - 1]; + const pathSegment: IPathSegment = { + rowID: row.data.rowID, rowKey: row.data.rowID, + rowIslandKey: riKey + }; + const childGrid = this.grid.gridAPI.getChildGrid([pathSegment]); + rowElem = childGrid.tfoot.nativeElement; + } + + return this.getElementPosition(rowElem, isNext); + } + + protected getElementPosition(element: HTMLElement, isNext: boolean) { + // Special handling for scenarios where there is css transformations applied that affects scale. + // getBoundingClientRect().height returns size after transformations + // element.offsetHeight returns size without any transformations + // get the ratio to figure out if anything has applied transformations + const scaling = element.getBoundingClientRect().height / element.offsetHeight; + + const gridBottom = this._getMinBottom(this.grid); + const diffBottom = + element.getBoundingClientRect().bottom - gridBottom; + const gridTop = this._getMaxTop(this.grid); + const diffTop = element.getBoundingClientRect().bottom - + element.getBoundingClientRect().height - gridTop; + // Adding Math.Round because Chrome has some inconsistencies when the page is zoomed + const isInView = isNext ? Math.round(diffBottom) <= 0 : Math.round(diffTop) >= 0; + const calcOffset = isNext ? diffBottom : diffTop; + + return { inView: isInView, offset: calcOffset / scaling}; + } + + /** + * Gets closest element by its tag name. + * + * @param sourceElem The element from which to start the search. + * @param targetTag The target element tag name, for which to search. + */ + protected getClosestElemByTag(sourceElem, targetTag) { + let result = sourceElem; + while (result !== null && result.nodeType === 1) { + if (result.tagName.toLowerCase() === targetTag.toLowerCase()) { + return result; + } + result = result.parentNode; + } + return null; + } + + private clearActivation() { + // clear if previous activation exists. + if (this.activeNode && Object.keys(this.activeNode).length) { + this.activeNode = Object.assign({} as IActiveNode); + } + } + + private hasNextTarget(grid: GridType, index: number, isNext: boolean) { + const targetRowIndex = isNext ? index + 1 : index - 1; + const hasTargetRecord = !!grid.dataView[targetRowIndex]; + if (hasTargetRecord) { + return true; + } else { + let hasTargetRecordInParent = false; + if (grid.parent) { + const indexInParent = grid.childRow.index; + hasTargetRecordInParent = this.hasNextTarget(grid.parent, indexInParent, isNext); + } + return hasTargetRecordInParent; + } + } + + /** + * Gets the max top view in the current grid hierarchy. + * + * @param grid + */ + private _getMaxTop(grid) { + let currGrid = grid; + let top = currGrid.tbody.nativeElement.getBoundingClientRect().top; + while (currGrid.parent) { + currGrid = currGrid.parent; + const pinnedRowsHeight = currGrid.hasPinnedRecords && currGrid.isRowPinningToTop ? currGrid.pinnedRowHeight : 0; + top = Math.max(top, currGrid.tbody.nativeElement.getBoundingClientRect().top + pinnedRowsHeight); + } + return top; + } + + /** + * Gets the min bottom view in the current grid hierarchy. + * + * @param grid + */ + private _getMinBottom(grid) { + let currGrid = grid; + let bottom = currGrid.tbody.nativeElement.getBoundingClientRect().bottom; + while (currGrid.parent) { + currGrid = currGrid.parent; + const pinnedRowsHeight = currGrid.hasPinnedRecords && !currGrid.isRowPinningToTop ? currGrid.pinnedRowHeight : 0; + bottom = Math.min(bottom, currGrid.tbody.nativeElement.getBoundingClientRect().bottom - pinnedRowsHeight); + } + return bottom; + } + + /** + * Finds the next grid that allows scrolling down. + * + * @param grid The grid from which to begin the search. + */ + private getNextScrollableDown(grid) { + let currGrid = grid.parent; + if (!currGrid) { + return { grid, prev: null }; + } + let scrollTop = currGrid.verticalScrollContainer.scrollPosition; + let scrollHeight = currGrid.verticalScrollContainer.getScroll().scrollHeight; + let nonScrollable = scrollHeight === 0 || + Math.round(scrollTop + currGrid.verticalScrollContainer.igxForContainerSize) === scrollHeight; + let prev = grid; + while (nonScrollable && currGrid.parent !== null) { + prev = currGrid; + currGrid = currGrid.parent; + scrollTop = currGrid.verticalScrollContainer.scrollPosition; + scrollHeight = currGrid.verticalScrollContainer.getScroll().scrollHeight; + nonScrollable = scrollHeight === 0 || + Math.round(scrollTop + currGrid.verticalScrollContainer.igxForContainerSize) === scrollHeight; + } + return { grid: currGrid, prev }; + } + + /** + * Finds the next grid that allows scrolling up. + * + * @param grid The grid from which to begin the search. + */ + private getNextScrollableUp(grid) { + let currGrid = grid.parent; + if (!currGrid) { + return { grid, prev: null }; + } + let nonScrollable = currGrid.verticalScrollContainer.scrollPosition === 0; + let prev = grid; + while (nonScrollable && currGrid.parent !== null) { + prev = currGrid; + currGrid = currGrid.parent; + nonScrollable = currGrid.verticalScrollContainer.scrollPosition === 0; + } + return { grid: currGrid, prev }; + } +} diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.component.html b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.component.html new file mode 100644 index 00000000000..dd46674993c --- /dev/null +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.component.html @@ -0,0 +1,280 @@ + + + + + + +
    +
    + @if (moving && columnInDrag && pinnedColumns.length <= 0) { + + } + @if (moving && columnInDrag && pinnedColumns.length > 0) { + + } + @if (mergedDataInView && mergedDataInView.length > 0) { +
    + @for (rowData of mergedDataInView; track rowData.record;) { + + + } +
    + } + + @if (data + | gridTransaction:id:pipeTrigger + | visibleColumns:hasVisibleColumns + | gridAddRow:true:pipeTrigger + | gridRowPinning:id:true:pipeTrigger + | gridFiltering:filteringExpressionsTree:filterStrategy:advancedFilteringExpressionsTree:id:pipeTrigger:filteringPipeTrigger:true + | gridSort:sortingExpressions:[]:sortStrategy:id:pipeTrigger:true + | gridCellMerge:columnsToMerge:cellMergeMode:mergeStrategy:pipeTrigger + | gridUnmergeActive:columnsToMerge:activeRowIndexes:true:pipeTrigger; as pinnedData + ) { + @if (pinnedData.length > 0) { +
    + @for (rowData of pinnedData; track (rowData.recordRef || rowData); let rowIndex = $index) { + + + } +
    + } + } +
    + + + + + + + + + + + + + + + + + +
    + @for (layout of childLayoutList; track layout) { + + + } +
    +
    + + + + @if (moving && columnInDrag) { + + } +
    + @if (!this.parent) { + + } +
    +
    + @if (shouldOverlayLoading) { + + + } +
    + @if (moving && columnInDrag) { + + } +
    +
    +
    + +
    +
    +
    +
    + {{resourceStrings.igx_grid_snackbar_addrow_label}} +
    + +
    +
    + +
    +
    + @if (hasSummarizedColumns && rootSummariesEnabled) { + + + } +
    +
    +
    + +
    +
    +
    + + +
    +
    +
    + + + + + + {{emptyFilteredGridMessage}} + @if (showAddButton) { + + + + } + + + + + + {{emptyGridMessage}} + @if (showAddButton) { + + + + } + + + + + + + + +
    + + +
    +
    + + + + + + + + + + +@if (rowEditable) { +
    +
    + + +
    +
    +} + + {{ this.resourceStrings.igx_grid_row_edit_text | igxStringReplace:'{0}':rowChangesCount.toString() | igxStringReplace:'{1}':hiddenColumnsCount.toString() }} + + + + + + +
    + + + + +
    +
    +
    + + +
    +
    +
    + + + + + +@if (colResizingService.showResizer) { + +} +
    +
    +@if (platform.isElements) { +
    + + + +} diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.component.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.component.ts new file mode 100644 index 00000000000..11815766794 --- /dev/null +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.component.ts @@ -0,0 +1,1280 @@ +import { AfterContentInit, AfterViewInit, booleanAttribute, ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChildren, CUSTOM_ELEMENTS_SCHEMA, DoCheck, ElementRef, HostBinding, Input, OnDestroy, OnInit, QueryList, reflectComponentType, SimpleChanges, TemplateRef, ViewChild, ViewChildren, ViewContainerRef, inject } from '@angular/core'; +import { NgClass, NgTemplateOutlet, NgStyle } from '@angular/common'; + +import { IgxHierarchicalGridAPIService } from './hierarchical-grid-api.service'; +import { IgxRowIslandComponent } from './row-island.component'; +import { IgxFilteringService, IgxGridNavigationService, IgxGridValidationService } from 'igniteui-angular/grids/core'; +import { IgxColumnComponent, } from 'igniteui-angular/grids/core'; +import { IgxHierarchicalGridNavigationService } from './hierarchical-grid-navigation.service'; +import { IgxGridSummaryService } from 'igniteui-angular/grids/core'; +import { IgxHierarchicalGridBaseDirective } from './hierarchical-grid-base.directive'; +import { takeUntil } from 'rxjs/operators'; +import { CellType, GridType, IGX_GRID_BASE, IGX_GRID_SERVICE_BASE, RowType } from 'igniteui-angular/grids/core'; +import { IgxRowIslandAPIService } from './row-island-api.service'; +import { IgxGridCRUDService } from 'igniteui-angular/grids/core'; +import { IgxHierarchicalGridRow } from 'igniteui-angular/grids/core'; +import { IgxGridCell } from 'igniteui-angular/grids/core'; +import type { IgxPaginatorComponent } from 'igniteui-angular/paginator'; +import { IgxColumnResizingService } from 'igniteui-angular/grids/core'; +import { IgxGridExcelStyleFilteringComponent } from 'igniteui-angular/grids/core'; +import { IgxGridHierarchicalPipe, IgxGridHierarchicalPagingPipe } from './hierarchical-grid.pipes'; +import { IgxSummaryDataPipe } from 'igniteui-angular/grids/core'; +import { IgxGridTransactionPipe, IgxHasVisibleColumnsPipe, IgxGridRowPinningPipe, IgxGridAddRowPipe, IgxGridRowClassesPipe, IgxGridRowStylesPipe, IgxStringReplacePipe } from 'igniteui-angular/grids/core'; +import { IgxGridColumnResizerComponent } from 'igniteui-angular/grids/core'; +import { IgxRowEditTabStopDirective } from 'igniteui-angular/grids/core'; +import { IgxSummaryRowComponent } from 'igniteui-angular/grids/core'; +import { IgxHierarchicalRowComponent } from './hierarchical-row.component'; +import { IgxColumnMovingDropDirective } from 'igniteui-angular/grids/core'; +import { IgxGridDragSelectDirective } from 'igniteui-angular/grids/core'; +import { IgxGridBodyDirective } from 'igniteui-angular/grids/core'; +import { IgxGridHeaderRowComponent } from 'igniteui-angular/grids/core'; +import { IgxGridSelectionService } from 'igniteui-angular/grids/core'; +import { IgxButtonDirective, IgxForOfScrollSyncService, IgxForOfSyncService, IgxGridForOfDirective, IgxRippleDirective, IgxScrollInertiaDirective, IgxTemplateOutletDirective, IgxToggleDirective } from 'igniteui-angular/directives'; +import { IgxCircularProgressBarComponent } from 'igniteui-angular/progressbar'; +import { IgxSnackbarComponent } from 'igniteui-angular/snackbar'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { EntityType, FieldType, IFilteringExpressionsTree, IgxActionStripToken, IgxOverlayOutletDirective, flatten } from 'igniteui-angular/core'; +import { IgxPaginatorToken } from 'igniteui-angular/paginator'; +import { IgxGridCellMergePipe, IgxGridComponent, IgxGridFilteringPipe, IgxGridSortingPipe, IgxGridUnmergeActivePipe } from 'igniteui-angular/grids/grid'; + +let NEXT_ID = 0; + +/** + * @hidden @internal + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'igx-child-grid-row', + templateUrl: './child-grid-row.component.html', + imports: [NgClass] +}) +export class IgxChildGridRowComponent implements AfterViewInit, OnInit { + public readonly gridAPI = inject(IGX_GRID_SERVICE_BASE); + public element = inject>(ElementRef); + public cdr = inject(ChangeDetectorRef); + + @Input() + public layout: IgxRowIslandComponent; + + /** + * @hidden + */ + public get parentHasScroll() { + return !this.parentGrid.verticalScrollContainer.dc.instance.notVirtual; + } + + + /** + * @hidden + */ + @Input() + public parentGridID: string; + + /** + * The data passed to the row component. + * + * ```typescript + * // get the row data for the first selected row + * let selectedRowData = this.grid.selectedRows[0].data; + * ``` + */ + @Input() + public get data(): any { + return this._data || []; + } + + public set data(value: any) { + this._data = value; + if (this.hGrid && !this.hGrid.dataSetByUser) { + this.hGrid.setDataInternal(this._data.childGridsData[this.layout.key]); + } + } + + /** + * The index of the row. + * + * ```typescript + * // get the index of the second selected row + * let selectedRowIndex = this.grid.selectedRows[1].index; + * ``` + */ + @Input() + public index: number; + + /* blazorSuppress */ + @ViewChild('container', { read: ViewContainerRef, static: true }) + public container: ViewContainerRef; + + /** + * @hidden + */ + public hGrid: IgxHierarchicalGridComponent; + + /* blazorSuppress */ + /** + * Get a reference to the grid that contains the selected row. + * + * ```typescript + * handleRowSelection(event) { + * // the grid on which the rowSelected event was triggered + * const grid = event.row.grid; + * } + * ``` + * + * ```html + * + * + * ``` + */ + // TODO: Refactor + public get parentGrid(): IgxHierarchicalGridComponent { + return this.gridAPI.grid as IgxHierarchicalGridComponent; + } + + @HostBinding('attr.aria-level') + public get level() { + return this.layout.level; + } + + /** + * The native DOM element representing the row. Could be null in certain environments. + * + * ```typescript + * // get the nativeElement of the second selected row + * let selectedRowNativeElement = this.grid.selectedRows[1].nativeElement; + * ``` + */ + public get nativeElement() { + return this.element.nativeElement; + } + + /** + * Returns whether the row is expanded. + * ```typescript + * const RowExpanded = this.grid1.rowList.first.expanded; + * ``` + */ + public expanded = false; + + private _data: any; + + /** + * @hidden + */ + public ngOnInit() { + const ref = this.container.createComponent(IgxHierarchicalGridComponent, { injector: this.container.injector }); + this.hGrid = ref.instance; + this.hGrid.setDataInternal(this.data.childGridsData[this.layout.key]); + this.hGrid.nativeElement["__componentRef"] = ref; + this.layout.layoutChange.subscribe((ch) => { + this._handleLayoutChanges(ch); + }); + const changes = this.layout.initialChanges; + changes.forEach(change => { + this._handleLayoutChanges(change); + }); + this.hGrid.parent = this.parentGrid; + this.hGrid.parentIsland = this.layout; + this.hGrid.childRow = this; + // handler logic that re-emits hgrid events on the row island + this.setupEventEmitters(); + this.layout.gridCreated.emit({ + owner: this.layout, + parentID: this.data.rowID, + grid: this.hGrid, + parentRowData: this.data.parentRowData, + }); + } + + /** + * @hidden + */ + public ngAfterViewInit() { + this.hGrid.childLayoutList = this.layout.children; + const layouts = this.hGrid.childLayoutList.toArray(); + layouts.forEach((l) => this.hGrid.gridAPI.registerChildRowIsland(l)); + this.parentGrid.gridAPI.registerChildGrid(this.data.rowID, this.layout.key, this.hGrid); + this.layout.rowIslandAPI.registerChildGrid(this.data.rowID, this.hGrid); + + this.layout.gridInitialized.emit({ + owner: this.layout, + parentID: this.data.rowID, + grid: this.hGrid, + parentRowData: this.data.parentRowData, + }); + + this.hGrid.cdr.detectChanges(); + } + + private setupEventEmitters() { + const destructor = takeUntil(this.hGrid.destroy$); + + const mirror = reflectComponentType(IgxGridComponent); + // exclude outputs related to two-way binding functionality + const inputNames = mirror.inputs.map(input => input.propName); + const outputs = mirror.outputs.filter(o => { + const matchingInputPropName = o.propName.slice(0, o.propName.indexOf('Change')); + return inputNames.indexOf(matchingInputPropName) === -1; + }); + + // TODO: Skip the `rendered` output. Rendered should be called once per grid. + outputs.filter(o => o.propName !== 'rendered').forEach(output => { + if (this.hGrid[output.propName]) { + this.hGrid[output.propName].pipe(destructor).subscribe((args) => { + if (!args) { + args = {}; + } + args.owner = this.hGrid; + this.layout[output.propName].emit(args); + }); + } + }); + } + + + private _handleLayoutChanges(changes: SimpleChanges) { + for (const change in changes) { + if (changes.hasOwnProperty(change)) { + this.hGrid[change] = changes[change].currentValue; + } + } + } +} + + +/* blazorAdditionalDependency: Column */ +/* blazorAdditionalDependency: ColumnGroup */ +/* blazorAdditionalDependency: ColumnLayout */ +/* blazorAdditionalDependency: GridToolbar */ +/* blazorAdditionalDependency: GridToolbarActions */ +/* blazorAdditionalDependency: GridToolbarTitle */ +/* blazorAdditionalDependency: GridToolbarAdvancedFiltering */ +/* blazorAdditionalDependency: GridToolbarExporter */ +/* blazorAdditionalDependency: GridToolbarHiding */ +/* blazorAdditionalDependency: GridToolbarPinning */ +/* blazorAdditionalDependency: ActionStrip */ +/* blazorAdditionalDependency: GridActionsBaseDirective */ +/* blazorAdditionalDependency: GridEditingActions */ +/* blazorAdditionalDependency: GridPinningActions */ +/* blazorAdditionalDependency: RowIsland */ +/* blazorIndirectRender */ +/** + * Hierarchical grid + * + * @igxModule IgxHierarchicalGridModule + * + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'igx-hierarchical-grid', + templateUrl: 'hierarchical-grid.component.html', + providers: [ + IgxGridCRUDService, + IgxGridValidationService, + IgxGridSelectionService, + { provide: IGX_GRID_SERVICE_BASE, useClass: IgxHierarchicalGridAPIService }, + { provide: IGX_GRID_BASE, useExisting: IgxHierarchicalGridComponent }, + IgxGridSummaryService, + IgxFilteringService, + IgxGridNavigationService, + IgxHierarchicalGridNavigationService, + IgxColumnResizingService, + IgxForOfSyncService, + IgxForOfScrollSyncService, + IgxRowIslandAPIService + ], + imports: [ + NgClass, + NgTemplateOutlet, + NgStyle, + IgxGridHeaderRowComponent, + IgxGridBodyDirective, + IgxGridDragSelectDirective, + IgxColumnMovingDropDirective, + IgxGridForOfDirective, + IgxTemplateOutletDirective, + IgxHierarchicalRowComponent, + IgxOverlayOutletDirective, + IgxToggleDirective, + IgxCircularProgressBarComponent, + IgxSnackbarComponent, + IgxSummaryRowComponent, + IgxButtonDirective, + IgxRippleDirective, + IgxIconComponent, + IgxRowEditTabStopDirective, + IgxGridColumnResizerComponent, + IgxChildGridRowComponent, + IgxGridSortingPipe, + IgxGridFilteringPipe, + IgxGridTransactionPipe, + IgxHasVisibleColumnsPipe, + IgxGridRowPinningPipe, + IgxGridAddRowPipe, + IgxGridRowClassesPipe, + IgxGridRowStylesPipe, + IgxSummaryDataPipe, + IgxGridHierarchicalPipe, + IgxGridHierarchicalPagingPipe, + IgxStringReplacePipe, + IgxGridCellMergePipe, + IgxScrollInertiaDirective, + IgxGridUnmergeActivePipe + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] +}) +export class IgxHierarchicalGridComponent extends IgxHierarchicalGridBaseDirective + implements GridType, AfterViewInit, AfterContentInit, OnInit, OnDestroy, DoCheck { + + /** + * @hidden @internal + */ + @HostBinding('attr.role') + public role = 'grid'; + + /* contentChildren */ + /* blazorInclude */ + /* blazorTreatAsCollection */ + /* blazorCollectionName: RowIslandCollection */ + /* ngQueryListName: childLayoutList */ + /** + * @hidden + */ + @ContentChildren(IgxRowIslandComponent, { read: IgxRowIslandComponent, descendants: false }) + public childLayoutList: QueryList; + + /** + * @hidden + */ + @ContentChildren(IgxRowIslandComponent, { read: IgxRowIslandComponent, descendants: true }) + public allLayoutList: QueryList; + + /** @hidden @internal */ + @ContentChildren(IgxPaginatorToken, { descendants: true }) + public paginatorList: QueryList; + + /** @hidden @internal */ + @ViewChild('toolbarOutlet', { read: ViewContainerRef }) + public toolbarOutlet: ViewContainerRef; + + /** @hidden @internal */ + @ViewChild('paginatorOutlet', { read: ViewContainerRef }) + public paginatorOutlet: ViewContainerRef; + /** + * @hidden + */ + @ViewChildren(IgxTemplateOutletDirective, { read: IgxTemplateOutletDirective }) + public templateOutlets: QueryList; + + /** + * @hidden + */ + @ViewChildren(IgxChildGridRowComponent) + public hierarchicalRows: QueryList; + + @ViewChild('hierarchical_record_template', { read: TemplateRef, static: true }) + protected hierarchicalRecordTemplate: TemplateRef; + + @ViewChild('child_record_template', { read: TemplateRef, static: true }) + protected childTemplate: TemplateRef; + + // @ViewChild('headerHierarchyExpander', { read: ElementRef, static: true }) + protected get headerHierarchyExpander() { + return this.theadRow?.headerHierarchyExpander; + } + + /** + * @hidden + */ + public childLayoutKeys = []; + + /** @hidden @internal */ + public dataSetByUser = false; + + /** + * @hidden + */ + public highlightedRowID = null; + + /** + * @hidden + */ + public updateOnRender = false; + + /** + * @hidden + */ + public parent: IgxHierarchicalGridComponent = null; + + /** + * @hidden @internal + */ + public childRow: IgxChildGridRowComponent; + + @ContentChildren(IgxActionStripToken, { read: IgxActionStripToken, descendants: false }) + protected override actionStripComponents: QueryList; + + /** @hidden @internal */ + public override get actionStrip() { + return this.parentIsland ? this.parentIsland.actionStrip : super.actionStrip; + } + + public override get advancedFilteringExpressionsTree(): IFilteringExpressionsTree { + return super.advancedFilteringExpressionsTree; + } + + public override set advancedFilteringExpressionsTree(value: IFilteringExpressionsTree) { + if (!this._hGridSchema) { + this._hGridSchema = this.generateSchema(); + } + super.advancedFilteringExpressionsTree = value; + } + + private _data; + private h_id = `igx-hierarchical-grid-${NEXT_ID++}`; + private childGridTemplates: Map = new Map(); + + /** + * Gets/Sets the value of the `id` attribute. + * + * @remarks + * If not provided it will be automatically generated. + * @example + * ```html + * + * ``` + */ + @HostBinding('attr.id') + @Input() + public get id(): string { + return this.h_id; + } + public set id(value: string) { + this.h_id = value; + } + + /* treatAsRef */ + /** + * Gets/Sets the array of data that populates the component. + * ```html + * + * ``` + * + * @memberof IgxHierarchicalGridComponent + */ + @Input() + public set data(value: any[] | null) { + this.setDataInternal(value); + this.dataSetByUser = true; + this.checkPrimaryKeyField(); + } + + /** + * Returns an array of data set to the `IgxHierarchicalGridComponent`. + * ```typescript + * let filteredData = this.grid.filteredData; + * ``` + * + * @memberof IgxHierarchicalGridComponent + */ + public get data(): any[] | null { + return this._data; + } + + /** @hidden @internal */ + public override get paginator() { + const id = this.id; + return (!this.parentIsland && this.paginationComponents?.first) || this.rootGrid.paginatorList?.find((pg) => + pg.nativeElement.offsetParent?.id === id); + } + + /** @hidden @internal */ + public override get excelStyleFilteringComponent(): IgxGridExcelStyleFilteringComponent { + return this.parentIsland ? + this.parentIsland.excelStyleFilteringComponents.first : + super.excelStyleFilteringComponent; + } + + /** + * Gets/Sets the total number of records in the data source. + * + * @remarks + * This property is required for remote grid virtualization to function when it is bound to remote data. + * @example + * ```typescript + * const itemCount = this.grid1.totalItemCount; + * this.grid1.totalItemCount = 55; + * ``` + */ + @Input() + public set totalItemCount(count) { + this.verticalScrollContainer.totalItemCount = count; + } + + public get totalItemCount() { + return this.verticalScrollContainer.totalItemCount; + } + + /** + * Sets if all immediate children of the `IgxHierarchicalGridComponent` should be expanded/collapsed. + * Default value is false. + * ```html + * + * ``` + * + * @memberof IgxHierarchicalGridComponent + */ + @Input({ transform: booleanAttribute }) + public set expandChildren(value: boolean) { + this._defaultExpandState = value; + this.expansionStates = new Map(); + } + + /** + * Gets if all immediate children of the `IgxHierarchicalGridComponent` previously have been set to be expanded/collapsed. + * If previously set and some rows have been manually expanded/collapsed it will still return the last set value. + * ```typescript + * const expanded = this.grid.expandChildren; + * ``` + * + * @memberof IgxHierarchicalGridComponent + */ + public get expandChildren(): boolean { + return this._defaultExpandState; + } + + /* blazorSuppress */ + /** + * Gets/Sets the schema for the hierarchical grid. + * This schema defines the structure and properties of the data displayed in the grid. + * @Input() + * @param {EntityType[]} entities - An array of EntityType objects representing the grid's schema. + * @remarks + * This property is required in remote data filtering scenarios. + * @example + * ```typescript + * const schema = this.grid.schema; + * this.grid.schema = [{ name: 'Products', fields: [...], childEntities: [...] }]; + * ``` + */ + @Input() + public set schema(entities: EntityType[]) { + this._hGridSchema = entities; + } + + /* blazorSuppress */ + public get schema() { + if (!this._hGridSchema) { + this._hGridSchema = this.generateSchema(); + } + + return this._hGridSchema; + } + + /** + * Gets the unique identifier of the parent row. It may be a `string` or `number` if `primaryKey` of the + * parent grid is set or an object reference of the parent record otherwise. + * ```typescript + * const foreignKey = this.grid.foreignKey; + * ``` + * + * @memberof IgxHierarchicalGridComponent + */ + public get foreignKey() { + if (!this.parent) { + return null; + } + return this.parent.gridAPI.getParentRowId(this); + } + + /** + * @hidden + */ + public get hasExpandableChildren() { + return !!this.childLayoutKeys.length; + } + + /** + * @hidden + */ + public get resolveRowEditContainer() { + if (this.parentIsland && this.parentIsland.rowEditCustom) { + return this.parentIsland.rowEditContainer; + } + return this.rowEditContainer; + } + + /** + * @hidden + */ + public get resolveRowEditActions() { + return this.parentIsland ? this.parentIsland.rowEditActionsTemplate : this.rowEditActionsTemplate; + } + + /** + * @hidden + */ + public get resolveRowEditText() { + return this.parentIsland ? this.parentIsland.rowEditTextTemplate : this.rowEditTextTemplate; + } + + /** @hidden */ + public override hideActionStrip() { + if (!this.parent) { + // hide child layout actions strips when + // moving outside root grid. + super.hideActionStrip(); + this.allLayoutList.forEach(ri => { + ri.actionStrip?.hide(); + }); + } + } + + /** + * @hidden + */ + public override get parentRowOutletDirective() { + // Targeting parent outlet in order to prevent hiding when outlet + // is present at a child grid and is attached to a row. + return this.parent ? this.parent.rowOutletDirective : this.outlet; + } + + /** + * @hidden + */ + public override ngOnInit() { + // this.expansionStatesChange.pipe(takeUntil(this.destroy$)).subscribe((value: Map) => { + // const res = Array.from(value.entries()).filter(({1: v}) => v === true).map(([k]) => k); + // }); + this.batchEditing = !!this.rootGrid.batchEditing; + if (this.rootGrid !== this) { + this.rootGrid.batchEditingChange.pipe(takeUntil(this.destroy$)).subscribe((val: boolean) => { + this.batchEditing = val; + }); + } + super.ngOnInit(); + } + + /** + * @hidden + */ + public override ngAfterViewInit() { + super.ngAfterViewInit(); + this.verticalScrollContainer.beforeViewDestroyed.pipe(takeUntil(this.destroy$)).subscribe((view) => { + const rowData = view.context.$implicit; + if (this.isChildGridRecord(rowData)) { + const cachedData = this.childGridTemplates.get(rowData.rowID); + if (cachedData) { + const tmlpOutlet = cachedData.owner; + tmlpOutlet._viewContainerRef.detach(0); + } + } + }); + + if (this.parent) { + this.childLayoutKeys = this.parentIsland.children.map((item) => item.key); + } + + this.headSelectorsTemplates = this.parentIsland ? + this.parentIsland.headSelectorsTemplates : + this.headSelectorsTemplates; + + this.rowSelectorsTemplates = this.parentIsland ? + this.parentIsland.rowSelectorsTemplates : + this.rowSelectorsTemplates; + this.dragIndicatorIconTemplate = this.parentIsland ? + this.parentIsland.dragIndicatorIconTemplate : + this.dragIndicatorIconTemplate; + this.rowExpandedIndicatorTemplate = this.rootGrid.rowExpandedIndicatorTemplate; + this.rowCollapsedIndicatorTemplate = this.rootGrid.rowCollapsedIndicatorTemplate; + this.headerCollapsedIndicatorTemplate = this.rootGrid.headerCollapsedIndicatorTemplate; + this.headerExpandedIndicatorTemplate = this.rootGrid.headerExpandedIndicatorTemplate; + this.excelStyleHeaderIconTemplate = this.rootGrid.excelStyleHeaderIconTemplate; + this.sortAscendingHeaderIconTemplate = this.rootGrid.sortAscendingHeaderIconTemplate; + this.sortDescendingHeaderIconTemplate = this.rootGrid.sortDescendingHeaderIconTemplate; + this.sortHeaderIconTemplate = this.rootGrid.sortHeaderIconTemplate; + this.hasChildrenKey = this.parentIsland ? + this.parentIsland.hasChildrenKey || this.rootGrid.hasChildrenKey : + this.rootGrid.hasChildrenKey; + this.showExpandAll = this.parentIsland ? + this.parentIsland.showExpandAll : this.rootGrid.showExpandAll; + } + + /** + * @hidden + */ + public override ngAfterContentInit() { + this.updateColumnList(false); + this.childLayoutKeys = this.parent ? + this.parentIsland.children.map((item) => item.key) : + this.childLayoutKeys = this.childLayoutList.map((item) => item.key); + this.childLayoutList.notifyOnChanges(); + this.childLayoutList.changes.pipe(takeUntil(this.destroy$)).subscribe(() => + this.onRowIslandChange() + ); + super.ngAfterContentInit(); + } + + /** + * Returns the `RowType` by index. + * + * @example + * ```typescript + * const myRow = this.grid1.getRowByIndex(1); + * ``` + * @param index + */ + public getRowByIndex(index: number): RowType { + if (index < 0 || index >= this.dataView.length) { + return undefined; + } + return this.createRow(index); + } + + /** + * Returns the `RowType` by key. + * + * @example + * ```typescript + * const myRow = this.grid1.getRowByKey(1); + * ``` + * @param key + */ + public getRowByKey(key: any): RowType { + const data = this.dataView; + const rec = this.primaryKey ? + data.find(record => record[this.primaryKey] === key) : + data.find(record => record === key); + const index = data.indexOf(rec); + if (index < 0 || index > data.length) { + return undefined; + } + + return new IgxHierarchicalGridRow(this as any, index, rec); + } + + /** + * @hidden @internal + */ + public allRows(): RowType[] { + return this.dataView.map((rec, index) => this.createRow(index)); + } + + /** + * Returns the collection of `IgxHierarchicalGridRow`s for current page. + * + * @hidden @internal + */ + public dataRows(): RowType[] { + return this.allRows().filter(row => row instanceof IgxHierarchicalGridRow); + } + + /** + * Returns an array of the selected `IgxGridCell`s. + * + * @example + * ```typescript + * const selectedCells = this.grid.selectedCells; + * ``` + */ + public get selectedCells(): CellType[] { + return this.dataRows().map((row) => row.cells.filter((cell) => cell.selected)) + .reduce((a, b) => a.concat(b), []); + } + + /** + * Returns a `CellType` object that matches the conditions. + * + * @example + * ```typescript + * const myCell = this.grid1.getCellByColumn(2, "UnitPrice"); + * ``` + * @param rowIndex + * @param columnField + */ + public getCellByColumn(rowIndex: number, columnField: string): CellType { + const row = this.getRowByIndex(rowIndex); + const column = this.columns.find((col) => col.field === columnField); + if (row && row instanceof IgxHierarchicalGridRow && column) { + return new IgxGridCell(this, rowIndex, column); + } + } + + /** + * Returns a `CellType` object that matches the conditions. + * + * @remarks + * Requires that the primaryKey property is set. + * @example + * ```typescript + * grid.getCellByKey(1, 'index'); + * ``` + * @param rowSelector match any rowID + * @param columnField + */ + public getCellByKey(rowSelector: any, columnField: string): CellType { + const row = this.getRowByKey(rowSelector); + const column = this.columns.find((col) => col.field === columnField); + if (row && column) { + return new IgxGridCell(this, row.index, column); + } + } + + public override pinRow(rowID: any, index?: number): boolean { + const row = this.getRowByKey(rowID); + return super.pinRow(rowID, index, row); + } + + /** @hidden @internal */ + public setDataInternal(value: any) { + const oldData = this._data; + this._data = value || []; + this.summaryService.clearSummaryCache(); + if (!this._init) { + this.validation.updateAll(this._data); + } + if (this.autoGenerate && this._data.length > 0 && this.shouldRecreateColumns(oldData, this._data) && this.gridAPI.grid) { + this.setupColumns(); + this.reflow(); + } + this.cdr.markForCheck(); + if (this.parent && (this.height === null || this.height.indexOf('%') !== -1)) { + // If the height will change based on how much data there is, recalculate sizes in igxForOf. + this.notifyChanges(true); + } + } + + public override unpinRow(rowID: any): boolean { + const row = this.getRowByKey(rowID); + return super.unpinRow(rowID, row); + } + + /** + * @hidden @internal + */ + public dataLoading(event) { + this.dataPreLoad.emit(event); + } + + /** @hidden */ + public override featureColumnsWidth() { + return super.featureColumnsWidth(this.headerHierarchyExpander); + } + + /** + * @hidden + */ + public onRowIslandChange() { + if (this.parent) { + this.childLayoutKeys = this.parentIsland.children.filter(item => !(item as any)._destroyed).map((item) => item.key); + } else { + this.childLayoutKeys = this.childLayoutList.filter(item => !(item as any)._destroyed).map((item) => item.key); + } + if (!(this.cdr as any).destroyed) { + this.cdr.detectChanges(); + } + } + + /** @hidden @internal **/ + public override ngOnDestroy() { + if (!this.parent) { + this.gridAPI.getChildGrids(true).forEach((grid) => { + if (!grid.childRow.cdr.destroyed) { + grid.childRow.cdr.destroy(); + } + }); + } + if (this.parent && this.selectionService.activeElement) { + // in case selection is in destroyed child grid, selection should be cleared. + this._clearSeletionHighlights(); + } + super.ngOnDestroy(); + } + + /** + * @hidden + */ + public isRowHighlighted(rowData) { + return this.highlightedRowID === rowData.rowID; + } + + /** + * @hidden + */ + public isHierarchicalRecord(record: any): boolean { + if (this.isGhostRecord(record)) { + record = record.recordRef; + } + return this.childLayoutList.length !== 0 && record[this.childLayoutList.first.key]; + } + + /** + * @hidden + */ + public override isChildGridRecord(record: any): boolean { + // Can be null when there is defined layout but no child data was found + return record?.childGridsData !== undefined; + } + + /** + * @hidden + */ + public trackChanges(index, rec) { + if (rec.childGridsData !== undefined) { + // if is child rec + return rec.rowID; + } + return rec; + } + + /** + * @hidden + */ + public getContext(rowData, rowIndex, pinned): any { + if (this.isChildGridRecord(rowData)) { + const cachedData = this.childGridTemplates.get(rowData.rowID); + if (cachedData) { + const view = cachedData.view; + const tmlpOutlet = cachedData.owner; + return { + $implicit: rowData, + moveView: view, + owner: tmlpOutlet, + index: this.dataView.indexOf(rowData) + }; + } else { + // child rows contain unique grids, hence should have unique templates + return { + $implicit: rowData, + templateID: { + type: 'childRow', + id: rowData.rowID + }, + index: this.dataView.indexOf(rowData) + }; + } + } else { + return { + $implicit: this.isGhostRecord(rowData) || this.isRecordMerged(rowData) ? rowData.recordRef : rowData, + templateID: { + type: 'dataRow', + id: null + }, + index: this.getDataViewIndex(rowIndex, pinned), + disabled: this.isGhostRecord(rowData), + metaData: this.isRecordMerged(rowData) ? rowData : null + }; + } + } + + /** + * @hidden + */ + public get rootGrid(): GridType { + let currGrid = this as IgxHierarchicalGridComponent; + while (currGrid.parent) { + currGrid = currGrid.parent; + } + return currGrid; + } + + /** + * @hidden + */ + public get iconTemplate() { + const expanded = this.hasExpandedRecords() && this.hasExpandableChildren; + if (!expanded && this.showExpandAll) { + return this.headerCollapsedIndicatorTemplate || this.defaultCollapsedTemplate; + } else { + return this.headerExpandedIndicatorTemplate || this.defaultExpandedTemplate; + } + } + + /** + * @hidden + * @internal + */ + public override getDragGhostCustomTemplate(): TemplateRef { + if (this.parentIsland) { + return this.parentIsland.getDragGhostCustomTemplate(); + } + return super.getDragGhostCustomTemplate(); + } + + /** + * @hidden + * Gets the visible content height that includes header + tbody + footer. + * For hierarchical child grid it may be scrolled and not fully visible. + */ + public override getVisibleContentHeight() { + let height = super.getVisibleContentHeight(); + if (this.parent) { + const rootHeight = this.rootGrid.getVisibleContentHeight(); + const topDiff = this.nativeElement.getBoundingClientRect().top - this.rootGrid.nativeElement.getBoundingClientRect().top; + height = rootHeight - topDiff > height ? height : rootHeight - topDiff; + } + return height; + } + + /** + * @hidden + */ + public toggleAll() { + const expanded = this.hasExpandedRecords() && this.hasExpandableChildren; + if (!expanded && this.showExpandAll) { + this.expandAll(); + } else { + this.collapseAll(); + } + } + + + /** + * @hidden + * @internal + */ + public hasExpandedRecords() { + if (this.expandChildren) { + return true; + } + let hasExpandedEntry = false; + this.expansionStates.forEach(value => { + if (value) { + hasExpandedEntry = value; + } + }); + return hasExpandedEntry; + } + + public override getDefaultExpandState(record: any) { + if (this.hasChildrenKey && !record[this.hasChildrenKey]) { + return false; + } + return this.expandChildren; + + } + + /** + * @hidden + */ + public isExpanded(record: any): boolean { + return this.gridAPI.get_row_expansion_state(record); + } + + /** + * @hidden + */ + public viewCreatedHandler(args) { + if (this.isChildGridRecord(args.context.$implicit)) { + const key = args.context.$implicit.rowID; + this.childGridTemplates.set(key, args); + } + } + + /** + * @hidden + */ + public viewMovedHandler(args) { + if (this.isChildGridRecord(args.context.$implicit)) { + // view was moved, update owner in cache + const key = args.context.$implicit.rowID; + const cachedData = this.childGridTemplates.get(key); + cachedData.owner = args.owner; + + this.childLayoutList.forEach((layout) => { + const relatedGrid = this.gridAPI.getChildGridByID(layout.key, args.context.$implicit.rowID); + if (relatedGrid && relatedGrid.updateOnRender) { + // Detect changes if `expandChildren` has changed when the grid wasn't visible. This is for performance reasons. + relatedGrid.notifyChanges(true); + relatedGrid.updateOnRender = false; + } + }); + } + } + + /** @hidden @internal **/ + public onContainerScroll() { + this.hideOverlays(); + } + + /** + * @hidden + */ + public createRow(index: number, data?: any): RowType { + let row: RowType; + const dataIndex = this._getDataViewIndex(index); + const rec: any = data ?? this.dataView[dataIndex]; + + if (!row && rec && !rec.childGridsData) { + row = new IgxHierarchicalGridRow(this as any, index, rec); + } + + return row; + } + + /** @hidden @internal */ + public getChildGrids(inDeph?: boolean) { + return this.gridAPI.getChildGrids(inDeph); + } + + protected override generateDataFields(data: any[]): string[] { + return super.generateDataFields(data).filter((field) => { + const layoutsList = this.parentIsland ? this.parentIsland.children : this.childLayoutList; + const keys = layoutsList.map((item) => item.key); + return keys.indexOf(field) === -1; + }); + } + + protected resizeNotifyHandler() { + // do not trigger reflow if element is detached or if it is child grid. + if (this.nativeElement?.isConnected && !this.parent) { + this.notifyChanges(true); + } + } + + /** + * @hidden + */ + protected override initColumns(collection: IgxColumnComponent[], cb: (args: any) => void = null) { + if (this.hasColumnLayouts) { + // invalid configuration - hierarchical grid should not allow column layouts + // remove column layouts + const nonColumnLayoutColumns = this.columns.filter((col) => !col.columnLayout && !col.columnLayoutChild); + this.updateColumns(nonColumnLayoutColumns); + } + super.initColumns(collection, cb); + } + + + protected override setupColumns() { + if (this.parentIsland && this.parentIsland.childColumns.length > 0 && !this.autoGenerate) { + this.createColumnsList(this.parentIsland.childColumns.toArray()); + } else { + super.setupColumns(); + } + } + + protected override getColumnList() { + const childLayouts = this.parent ? this.childLayoutList : this.allLayoutList; + const nestedColumns = childLayouts.map((layout) => layout.columnList.toArray()); + const colsArray = [].concat.apply([], nestedColumns); + if (colsArray.length > 0) { + const topCols = this.columnList.filter((item) => colsArray.indexOf(item) === -1); + return topCols; + } else { + return this.columnList.toArray() + } + } + + protected override onColumnsChanged() { + Promise.resolve().then(() => { + this.updateColumnList(); + }); + } + + protected override _shouldAutoSize(renderedHeight) { + if (this.isPercentHeight && this.parent) { + return true; + } + return super._shouldAutoSize(renderedHeight); + } + + private updateColumnList(recalcColSizes = true) { + const childLayouts = this.parent ? this.childLayoutList : this.allLayoutList; + const nestedColumns = childLayouts.map((layout) => layout.columnList.toArray()); + const colsArray = [].concat.apply([], nestedColumns); + const colLength = this.columns.length; + const topCols = this.columnList.filter((item) => colsArray.indexOf(item) === -1); + if (topCols.length > 0) { + this.initColumns(topCols, (col: IgxColumnComponent) => this.columnInit.emit(col)); + if (recalcColSizes && this.columns.length !== colLength) { + this.calculateGridSizes(false); + } + } + } + + private _clearSeletionHighlights() { + [this.rootGrid, ...this.rootGrid.getChildGrids(true)].forEach(grid => { + grid.selectionService.clear(); + grid.selectionService.activeElement = null; + grid.nativeElement.classList.remove('igx-grid__tr--highlighted'); + grid.highlightedRowID = null; + grid.cdr.markForCheck(); + }); + } + + private generateSchema() { + const filterableFields = this.columns.filter((column) => !column.columnGroup && column.filterable); + let entities: EntityType[]; + + if(filterableFields.length !== 0) { + entities = [ + { + name: null, + fields: filterableFields.map(f => ({ + field: f.field, + dataType: f.dataType, + header: f.header, + editorOptions: f.editorOptions, + filters: f.filters, + pipeArgs: f.pipeArgs, + defaultTimeFormat: f.defaultTimeFormat, + defaultDateTimeFormat: f.defaultDateTimeFormat + })) as FieldType[] + } + ]; + + entities[0].childEntities = this.childLayoutList.reduce((acc, rowIsland) => { + const childFirstRowData = this.data?.length > 0 && this.data[0][rowIsland.key]?.length > 0 ? + this.data[0][rowIsland.key][0] : null; + return acc.concat(this.generateChildEntity(rowIsland, childFirstRowData)); + } + , []); + } + + return entities; + } + + private generateChildEntity(rowIsland: IgxRowIslandComponent, firstRowData: any[]): EntityType { + const entityName = rowIsland.key; + let fields = []; + let childEntities; + if (!rowIsland.autoGenerate) { + fields = flatten(rowIsland.childColumns.toArray()).filter(col => col.field) + .map(f => ({ field: f.field, dataType: f.dataType })) as FieldType[]; + } else if (firstRowData) { + const rowIslandFields = Object.keys(firstRowData).map(key => { + if (firstRowData[key] instanceof Array) { + return null; + } + + return { + field: key, + dataType: this.resolveDataTypes(firstRowData[key]) + } + }); + fields = rowIslandFields.filter(f => f !== null) as FieldType[]; + } + + const rowIslandChildEntities = rowIsland.childLayoutList.reduce((acc, childRowIsland) => { + if (!firstRowData) { + return null; + } + const childFirstRowData = firstRowData.length > 0 && firstRowData[childRowIsland.key]?.length > 0 ? + firstRowData[childRowIsland.key][0] : null; + return acc.concat(this.generateChildEntity(childRowIsland, childFirstRowData)); + }, []); + + if (rowIslandChildEntities?.length > 0) { + childEntities = rowIslandChildEntities; + } + + return { + name: entityName, + fields: fields, + childEntities: childEntities + } + } +} diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.integration.spec.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.integration.spec.ts new file mode 100644 index 00000000000..cc973c6df89 --- /dev/null +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.integration.spec.ts @@ -0,0 +1,1263 @@ +import { TestBed, tick, fakeAsync, ComponentFixture, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxChildGridRowComponent, IgxHierarchicalGridComponent } from './hierarchical-grid.component'; +import { wait, UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { IgxColumnMovingDragDirective, IgxGridNavigationService } from 'igniteui-angular/grids/core'; +import { IgxHierarchicalRowComponent } from './hierarchical-row.component'; +import { take } from 'rxjs/operators'; +import { + IgxHierarchicalGridTestBaseComponent, + IgxHierarchicalGridTestCustomToolbarComponent, + IgxHierarchicalGridTestInputPaginatorComponent, + IgxHierarchicalGridTestInputToolbarComponent, + IgxHierarchicalGridWithTransactionProviderComponent +} from '../../../test-utils/hierarchical-grid-components.spec'; +import { GridFunctions, GridSelectionFunctions } from '../../../test-utils/grid-functions.spec'; +import { HierarchicalGridFunctions } from '../../../test-utils/hierarchical-grid-functions.spec'; +import { GridSelectionMode, RowPinningPosition } from 'igniteui-angular/grids/core'; +import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; +import { setElementSize } from '../../../test-utils/helper-utils.spec'; +import { ColumnPinningPosition, DefaultSortingStrategy, IgxStringFilteringOperand, ɵSize, SortingDirection } from 'igniteui-angular/core'; +import { IgxPaginatorComponent } from 'igniteui-angular/paginator'; + +describe('IgxHierarchicalGrid Integration #hGrid', () => { + let fixture: ComponentFixture; + let hierarchicalGrid: IgxHierarchicalGridComponent; + + // Setting a DEBOUNCE_TIME that is bigger than the resize observer's throttleTime. + const DEBOUNCE_TIME = 50; + + const FILTERING_ROW_CLASS = 'igx-grid-filtering-row'; + const FILTERING_CELL_CLASS = 'igx-grid-filtering-cell'; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxHierarchicalGridTestBaseComponent, + IgxHierarchicalGridTestCustomToolbarComponent, + IgxHierarchicalGridWithTransactionProviderComponent, + IgxHierarchicalGridTestInputPaginatorComponent, + IgxHierarchicalGridTestInputToolbarComponent + ], + providers: [ + IgxGridNavigationService + ] + }).compileComponents(); + })) + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(IgxHierarchicalGridTestBaseComponent); + fixture.detectChanges(); + hierarchicalGrid = fixture.componentInstance.hgrid; + })); + + describe('MCH', () => { + it('should allow declaring column groups.', fakeAsync(() => { + const expectedColumnGroups = 1; + const expectedLevel = 1; + + expect(hierarchicalGrid.columns.filter(col => col.columnGroup).length).toEqual(expectedColumnGroups); + expect(hierarchicalGrid.getColumnByName('ProductName').level).toEqual(expectedLevel); + + expect(GridFunctions.getColumnHeaders(fixture).length).toEqual(3); + + const firstRow = hierarchicalGrid.dataRowList.first; + // the first row's cell should contain an expand indicator + expect(HierarchicalGridFunctions.hasExpander(firstRow)).toBeTruthy(); + hierarchicalGrid.expandRow(firstRow.key); + tick(DEBOUNCE_TIME); + fixture.detectChanges(); + + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + + expect(childGrid.columns.filter(col => col.columnGroup).length).toEqual(expectedColumnGroups); + expect(childGrid.getColumnByName('ProductName').level).toEqual(expectedLevel); + + expect(GridFunctions.getColumnHeaders(fixture).length).toEqual(6); + })); + + it('should apply height correctly with and without filtering', fakeAsync(() => { + let filteringCells = fixture.debugElement.queryAll(By.css(FILTERING_CELL_CLASS)); + expect(hierarchicalGrid.nativeElement.offsetHeight).toBe(600); + + hierarchicalGrid.height = '800px'; + tick(); + fixture.detectChanges(); + expect(hierarchicalGrid.nativeElement.offsetHeight).toBe(800); + expect(filteringCells.length).toBe(3); + + hierarchicalGrid.allowFiltering = false; + fixture.detectChanges(); + expect(hierarchicalGrid.nativeElement.offsetHeight).toBe(800); + filteringCells = fixture.debugElement.queryAll(By.css(FILTERING_CELL_CLASS)); + expect(filteringCells.length).toBe(0); + })); + + it('should recreate columns when data changes and autoGenerate is true', fakeAsync(() => { + hierarchicalGrid.width = '500px'; + hierarchicalGrid.height = '500px'; + hierarchicalGrid.autoGenerate = true; + fixture.detectChanges(); + + const initialData = [ + { id: 1, name: 'John' }, + { id: 2, name: 'Jane' } + ]; + hierarchicalGrid.data = initialData; + tick(); + fixture.detectChanges(); + + expect(hierarchicalGrid.columns.length).toBe(2); + expect(hierarchicalGrid.columns[0].field).toBe('id'); + expect(hierarchicalGrid.columns[1].field).toBe('name'); + + const newData = [ + { id: 1, firstName: 'John', lastName: 'Doe' }, + { id: 2, firstName: 'Jane', lastName: 'Smith' } + ]; + hierarchicalGrid.data = newData; + tick(); + fixture.detectChanges(); + + expect(hierarchicalGrid.columns.length).toBe(3); + expect(hierarchicalGrid.columns[0].field).toBe('id'); + expect(hierarchicalGrid.columns[1].field).toBe('firstName'); + expect(hierarchicalGrid.columns[2].field).toBe('lastName'); + })); + }); + + describe('Selection', () => { + it('should allow only one cell to be selected in the whole hierarchical grid.', (async () => { + let firstRow = hierarchicalGrid.dataRowList.first as IgxHierarchicalRowComponent; + hierarchicalGrid.expandRow(firstRow.key); + expect(firstRow.expanded).toBeTruthy(); + + let fCell = firstRow.cells.toArray()[0]; + + // select parent cell + GridFunctions.focusCell(fixture, fCell); + await wait(100); + fixture.detectChanges(); + + expect(fCell.selected).toBeTruthy(); + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const fChildCell = childGrid.dataRowList.first.cells.first; + + // select child cell + GridFunctions.focusCell(fixture, fChildCell); + await wait(100); + fixture.detectChanges(); + + expect(fChildCell.selected).toBeTruthy(); + expect(fCell.selected).toBeFalsy(); + + // select parent cell + firstRow = hierarchicalGrid.dataRowList.toArray()[0] as IgxHierarchicalRowComponent; + fCell = firstRow.cells.toArray()[0]; + GridFunctions.focusCell(fixture, fCell); + await wait(100); + fixture.detectChanges(); + expect(fChildCell.selected).toBeFalsy(); + expect(fCell.selected).toBeTruthy(); + })); + }); + + describe('Updating', () => { + it(`should have separate instances of updating service for + parent and children and the same for children of the same island`, fakeAsync(() => { + const firstLayoutInstances: IgxHierarchicalGridComponent[] = []; + hierarchicalGrid.childLayoutList.first.gridCreated.pipe(take(2)).subscribe((args) => { + firstLayoutInstances.push(args.grid); + }); + const dataRows = hierarchicalGrid.dataRowList.toArray(); + // expand 1st row + hierarchicalGrid.expandRow(dataRows[0].key); + tick(DEBOUNCE_TIME); + fixture.detectChanges(); + // expand 2nd row + hierarchicalGrid.expandRow(dataRows[1].key); + tick(DEBOUNCE_TIME); + fixture.detectChanges(); + // test instances + expect(firstLayoutInstances.length).toEqual(2); + expect(hierarchicalGrid.transactions).not.toBe(firstLayoutInstances[0].transactions); + expect(firstLayoutInstances[0].transactions).not.toBe(firstLayoutInstances[1].transactions); + })); + + it('should contain all transactions for a row island', fakeAsync(() => { + const firstLayoutInstances: IgxHierarchicalGridComponent[] = []; + hierarchicalGrid.childLayoutList.first.gridCreated.pipe(take(2)).subscribe((args) => { + firstLayoutInstances.push(args.grid); + }); + hierarchicalGrid.batchEditing = true; + tick(); + fixture.detectChanges(); + const dataRows = hierarchicalGrid.dataRowList.toArray(); + // expand 1st row + hierarchicalGrid.expandRow(dataRows[0].key); + tick(); + fixture.detectChanges(); + // expand 2nd row + hierarchicalGrid.expandRow(dataRows[1].key); + tick(); + fixture.detectChanges(); + + firstLayoutInstances[0].updateRow({ ProductName: 'Changed' }, '00'); + firstLayoutInstances[1].updateRow({ ProductName: 'Changed' }, '10'); + expect(hierarchicalGrid.transactions.getTransactionLog().length).toEqual(0); + expect(firstLayoutInstances[0].transactions.getTransactionLog().length).toEqual(1); + expect(fixture.componentInstance.rowIsland.transactions.getTransactionLog().length).toEqual(0); + })); + + it('should remove expand indicator for uncommitted added rows', fakeAsync(() => { + hierarchicalGrid.batchEditing = true; + fixture.detectChanges(); + hierarchicalGrid.data = hierarchicalGrid.data.slice(0, 3); + fixture.detectChanges(); + hierarchicalGrid.addRow({ ID: -1, ProductName: 'Name1' }); + fixture.detectChanges(); + const rows = HierarchicalGridFunctions.getHierarchicalRows(fixture); + const lastRow = rows[rows.length - 1]; + expect(lastRow.query(By.css('igx-icon')).nativeElement).toHaveClass('igx-icon--inactive'); + hierarchicalGrid.transactions.commit(hierarchicalGrid.data); + fixture.detectChanges(); + expect(lastRow.query(By.css('igx-icon')).nativeElement).not.toHaveClass('igx-icon--inactive'); + })); + + it('should now allow expanding uncommitted added rows', fakeAsync(() => { + /* using the API here assumes keyboard interactions to expand/collapse would also be blocked */ + hierarchicalGrid.batchEditing = true; + fixture.detectChanges(); + hierarchicalGrid.data = hierarchicalGrid.data.slice(0, 3); + fixture.detectChanges(); + hierarchicalGrid.addRow({ ID: -1, ProductName: 'Name1' }); + fixture.detectChanges(); + + const dataRows = hierarchicalGrid.dataRowList; + hierarchicalGrid.expandRow(dataRows.last.key); + let childRows = fixture.debugElement.queryAll(By.directive(IgxChildGridRowComponent)); + expect(childRows.length).toEqual(0); + + hierarchicalGrid.transactions.commit(hierarchicalGrid.data); + fixture.detectChanges(); + + hierarchicalGrid.expandRow(dataRows.last.key); + tick(DEBOUNCE_TIME); + fixture.detectChanges(); + childRows = fixture.debugElement.queryAll(By.directive(IgxChildGridRowComponent)); + expect(childRows.length).toEqual(1); + })); + + it('should revert changes when transactions are cleared for child grids', fakeAsync(() => { + hierarchicalGrid.batchEditing = true; + fixture.detectChanges(); + let childGrid; + hierarchicalGrid.childLayoutList.first.gridCreated.pipe(take(1)).subscribe((args) => { + childGrid = args.grid; + }); + // expand first row + hierarchicalGrid.expandRow(hierarchicalGrid.dataRowList.first.key); + childGrid.updateRow({ ProductName: 'Changed' }, '00'); + fixture.detectChanges(); + expect(childGrid.gridAPI.get_cell_by_index(0, 'ProductName').nativeElement.innerText).toEqual('Changed'); + childGrid.transactions.clear(); + fixture.detectChanges(); + expect(childGrid.gridAPI.get_cell_by_index(0, 'ProductName').nativeElement.innerText).toEqual('Product: A0'); + })); + + it('should return correctly the rowData', () => { + hierarchicalGrid.primaryKey = 'ID'; + fixture.detectChanges(); + + const rowData = hierarchicalGrid.getRowByKey('2').data; + expect(hierarchicalGrid.getRowData('2')).toEqual(rowData); + + hierarchicalGrid.sort({ fieldName: 'ChildLevels', dir: SortingDirection.Desc, ignoreCase: true }); + fixture.detectChanges(); + + expect(hierarchicalGrid.getRowData('2')).toEqual(rowData); + expect(hierarchicalGrid.getRowData('101')).toEqual({}); + + hierarchicalGrid.filter('ID', '1', IgxStringFilteringOperand.instance().condition('startsWith')); + fixture.detectChanges(); + + expect(hierarchicalGrid.getRowData('2')).toEqual(rowData); + expect(hierarchicalGrid.getRowData('101')).toEqual({}); + }); + + it('should respect transaction service that is provided in the providers array', fakeAsync(() => { + fixture = TestBed.createComponent(IgxHierarchicalGridWithTransactionProviderComponent); + tick(); + fixture.detectChanges(); + hierarchicalGrid = fixture.componentInstance.hgrid; + expect(hierarchicalGrid.transactions.enabled).toBeTruthy(); + expect(hierarchicalGrid.batchEditing).toBeFalsy(); + let childGrid: IgxHierarchicalGridComponent; + hierarchicalGrid.childLayoutList.first.gridCreated.pipe(take(1)).subscribe((args) => { + childGrid = args.grid; + }); + // expand first row + hierarchicalGrid.expandRow(hierarchicalGrid.dataRowList.first.key); + expect(childGrid).toBeDefined(); + expect(childGrid.transactions.enabled).toBeTruthy(); + childGrid.updateRow({ ProductName: 'Changed' }, '00'); + expect(childGrid.transactions.getAggregatedChanges(false).length).toBe(1); + })); + }); + + describe('Sorting', () => { + it('should display correct child data for expanded row after sorting.', fakeAsync(() => { + /* this test doesn't need scrolling as it only cares about the child grid getting assigned to the correct parent */ + hierarchicalGrid.data = hierarchicalGrid.data.slice(0, 3); + fixture.detectChanges(); + // expand first row + hierarchicalGrid.expandRow(hierarchicalGrid.dataRowList.first.key); + hierarchicalGrid.sort({ + fieldName: 'ID', dir: SortingDirection.Desc, ignoreCase: false, strategy: DefaultSortingStrategy.instance() + }); + fixture.detectChanges(); + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const firstChildCell = childGrid.dataRowList.first.cells.first; + expect(hierarchicalGrid.gridAPI.get_row_by_index(3) instanceof IgxChildGridRowComponent).toBeTruthy(); + expect(childGrid.data).toBe(fixture.componentInstance.data[0]['childData']); + expect(firstChildCell.value).toBe('00'); + })); + + it('should allow sorting via headers in child grids', fakeAsync(() => { + // expand first row + hierarchicalGrid.expandRow(hierarchicalGrid.dataRowList.first.key); + // enable sorting + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + childGrid.columns[0].sortable = true; + fixture.detectChanges(); + childGrid.cdr.detectChanges(); + + const childHeader = GridFunctions.getColumnHeader('ID', fixture, childGrid); + GridFunctions.clickHeaderSortIcon(childHeader); + fixture.detectChanges(); + GridFunctions.clickHeaderSortIcon(childHeader); + fixture.detectChanges(); + + expect(childGrid.dataRowList.first.cells.first.value).toBe('09'); + const icon = GridFunctions.getHeaderSortIcon(childHeader); + expect(icon).not.toBeNull(); + expect(icon.nativeElement.textContent.toLowerCase().trim()).toBe('arrow_downward'); + expect(childHeader.attributes['aria-sort']).toEqual('descending'); + })); + }); + + describe('Filtering', () => { + + it('should enable filter-row for root and child grids', fakeAsync(() => { + let filteringCells = fixture.debugElement.queryAll(By.css(FILTERING_CELL_CLASS)); + expect(filteringCells.length).toEqual(3); + + GridFunctions.clickFilterCellChipUI(fixture, 'ID'); + expect(document.querySelectorAll(FILTERING_ROW_CLASS).length).toEqual(1); + + // expand first row + hierarchicalGrid.expandRow(hierarchicalGrid.dataRowList.first.key); + + filteringCells = fixture.debugElement.queryAll(By.css(FILTERING_CELL_CLASS)); + expect(filteringCells.length).toEqual(6); + + GridFunctions.clickFilterCellChipUI(fixture, 'ProductName', hierarchicalGrid.gridAPI.getChildGrids(false)[0]); + expect(document.querySelectorAll(FILTERING_ROW_CLASS).length).toEqual(2); + })); + + it('should not lose child grid states after filtering in parent grid.', fakeAsync(() => { + // expand first row + hierarchicalGrid.expandRow(hierarchicalGrid.dataRowList.first.key); + tick(DEBOUNCE_TIME); + fixture.detectChanges(); + let childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + let firstChildCell = childGrid.dataRowList.first.cells.first; + UIInteractions.simulateClickAndSelectEvent(firstChildCell); + tick(DEBOUNCE_TIME); + fixture.detectChanges(); + expect(firstChildCell.selected).toBe(true); + + // apply some filter + hierarchicalGrid.filter('ID', '0', IgxStringFilteringOperand.instance().condition('contains'), true); + + expect(hierarchicalGrid.getRowByIndex(0).expanded).toBe(true); + expect(hierarchicalGrid.gridAPI.get_row_by_index(1) instanceof IgxChildGridRowComponent).toBeTruthy(); + + childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + firstChildCell = childGrid.dataRowList.first.cells.first; + expect(firstChildCell.selected).toBe(true); + })); + + it('should show empty filter message when there are no records matching the filter', fakeAsync(() => { + fixture.componentInstance.data = []; + fixture.detectChanges(); + + const gridBody = fixture.debugElement.query(By.css('.igx-grid__tbody-content')); + expect(gridBody.nativeElement.innerText).toMatch(hierarchicalGrid.emptyGridMessage); + + fixture.componentInstance.data = SampleTestData.generateHGridData(40, 3); + fixture.detectChanges(); + + hierarchicalGrid.filter('ID', '123450', IgxStringFilteringOperand.instance().condition('contains'), true); + fixture.detectChanges(); + expect(gridBody.nativeElement.innerText).toMatch(hierarchicalGrid.emptyFilteredGridMessage); + })); + + it('should apply classes to the header when filter row is visible', fakeAsync(() => { + hierarchicalGrid.rowSelection = GridSelectionMode.multiple; + fixture.detectChanges(); + const headerExpander: HTMLElement = HierarchicalGridFunctions.getExpander(fixture); + const headerCheckbox: HTMLElement = GridSelectionFunctions.getRowCheckboxDiv(fixture.nativeElement); + + expect(HierarchicalGridFunctions.isExpander(headerExpander, '--push')).toBeFalsy(); + expect(GridSelectionFunctions.isCheckbox(headerCheckbox, '--push')).toBeFalsy(); + + // open filter row + GridFunctions.clickFilterCellChipUI(fixture, 'ID'); + + expect(HierarchicalGridFunctions.isExpander(headerExpander, '--push')).toBeTruthy(); + expect(GridSelectionFunctions.isCheckbox(headerCheckbox, '--push')).toBeTruthy(); + })); + }); + + describe('Summaries', () => { + const SUMMARIES_MARGIN_CLASS = '.igx-grid__summaries-patch'; + it('should allow defining summaries for child grid and child should be sized correctly.', fakeAsync(() => { + // expand first row + hierarchicalGrid.expandRow(hierarchicalGrid.dataRowList.first.key); + // summaries seem to require this additional change detection call with Ivy disabled to display for the child grid + fixture.detectChanges(); + + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0] as IgxHierarchicalGridComponent; + const expander = (childGrid.dataRowList.first as IgxHierarchicalRowComponent).expander; + + // Expect expansion cell to be rendered and sized the same as the expansion cell inside the grid + const summaryRow = childGrid.summariesRowList.first.nativeElement; + const summaryRowIndentation = summaryRow.querySelector(SUMMARIES_MARGIN_CLASS); + expect(summaryRow.children.length).toEqual(2); + expect(summaryRowIndentation.offsetWidth).toEqual(expander.nativeElement.offsetWidth); + + const gridHeight = childGrid.nativeElement.offsetHeight; + const childElements: HTMLElement[] = Array.from(childGrid.nativeElement.children) as HTMLElement []; + const elementsHeight = childElements.map(elem => elem.offsetHeight).reduce((total, height) => total + height, 0); + + // Expect the combined height of all elements (header, body, footer etc) to equal the calculated height of the grid. + expect(elementsHeight).toEqual(gridHeight); + + // expand first row of child + childGrid.expandRow(childGrid.dataRowList.first.key); + + const grandChild = childGrid.gridAPI.getChildGrids(false)[0]; + const grandChildSummaryRow = grandChild.summariesRowList.first.nativeElement; + const childSummaryRowIndentation = grandChildSummaryRow.querySelector(SUMMARIES_MARGIN_CLASS); + + expect(grandChildSummaryRow.children.length).toEqual(1); + expect(childSummaryRowIndentation).toBeNull(); + })); + + it('should size summaries with row selectors for parent and child grids correctly.', fakeAsync(() => { + hierarchicalGrid.rowSelection = GridSelectionMode.multiple; + fixture.detectChanges(); + // expand first row + hierarchicalGrid.expandRow(hierarchicalGrid.dataRowList.first.key); + // summaries seem to require this additional change detection call with Ivy disabled to display for the child grid + fixture.detectChanges(); + + const rootExpander = (hierarchicalGrid.dataRowList.first as IgxHierarchicalRowComponent).expander; + const rootCheckbox = hierarchicalGrid.headerSelectorContainer; + const rootSummaryRow = hierarchicalGrid.summariesRowList.first.nativeElement; + const rootSummaryIndentation = rootSummaryRow.querySelector(SUMMARIES_MARGIN_CLASS); + + expect(rootSummaryRow.children.length).toEqual(2); + expect(rootSummaryIndentation.offsetWidth) + .toEqual(rootExpander.nativeElement.offsetWidth + rootCheckbox.nativeElement.offsetWidth); + + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const expander = childGrid.dataRowList.first.expander; + + // Expect expansion cell to be rendered and sized the same as the expansion cell inside the grid + const summaryRow = childGrid.summariesRowList.first.nativeElement; + const childSummaryIndentation = summaryRow.querySelector(SUMMARIES_MARGIN_CLASS); + + expect(summaryRow.children.length).toEqual(2); + expect(childSummaryIndentation.offsetWidth).toEqual(expander.nativeElement.offsetWidth); + })); + + it('should size summaries for parent and child grids correctly when grid size is changed and summaryRowHeight is set to falsy value', () => { + setElementSize(hierarchicalGrid.nativeElement, ɵSize.Large) + fixture.detectChanges(); + + hierarchicalGrid.expandRow(hierarchicalGrid.dataRowList.first.key); + hierarchicalGrid.summaryRowHeight = 0; + fixture.detectChanges(); + + let childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + let tFoot = hierarchicalGrid.nativeElement.querySelector('.igx-grid__tfoot'); + let childTFoot = childGrid.nativeElement.querySelector('.igx-grid__tfoot'); + + expect(tFoot.getBoundingClientRect().height).toBe(hierarchicalGrid.defaultSummaryHeight); + expect(childTFoot.getBoundingClientRect().height).toBe(hierarchicalGrid.defaultSummaryHeight); + + + setElementSize(hierarchicalGrid.nativeElement, ɵSize.Medium) + hierarchicalGrid.summaryRowHeight = 0; + childGrid.summaryRowHeight = 0; + fixture.detectChanges(); + + childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + tFoot = hierarchicalGrid.nativeElement.querySelectorAll('.igx-grid__tfoot')[1]; + childTFoot = childGrid.nativeElement.querySelector('.igx-grid__tfoot'); + + expect(tFoot.getBoundingClientRect().height).toBe(hierarchicalGrid.defaultSummaryHeight); + expect(childTFoot.getBoundingClientRect().height).toBe(hierarchicalGrid.defaultSummaryHeight); + + setElementSize(hierarchicalGrid.nativeElement, ɵSize.Small) + hierarchicalGrid.summaryRowHeight = 0; + childGrid.summaryRowHeight = 0; + fixture.detectChanges(); + + childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + tFoot = hierarchicalGrid.nativeElement.querySelectorAll('.igx-grid__tfoot')[1]; + childTFoot = childGrid.nativeElement.querySelector('.igx-grid__tfoot'); + + expect(tFoot.getBoundingClientRect().height).toBe(hierarchicalGrid.defaultSummaryHeight); + expect(childTFoot.getBoundingClientRect().height).toBe(hierarchicalGrid.defaultSummaryHeight); + }) + + it('should render summaries for column inside a column group.', fakeAsync(() => { + const count = fixture.componentInstance.rowIsland.columns.length - 1; + fixture.componentInstance.rowIsland.columns[0].hasSummary = false; + fixture.componentInstance.rowIsland.columns[count].hasSummary = true; + fixture.detectChanges(); + + // expand first row + hierarchicalGrid.expandRow(hierarchicalGrid.dataRowList.first.key); + // summaries seem to require this additional change detection call with Ivy disabled to display for the child grid + fixture.detectChanges(); + + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + + const summaryRow = childGrid.summariesRowList.first; + expect(summaryRow.nativeElement.children.length).toEqual(2); + expect(summaryRow.summaryCells.length).toEqual(3); + })); + }); + + describe('Paging', () => { + it('should work on data records only when paging is enabled and should not be affected by child grid rows.', fakeAsync(() => { + fixture.componentInstance.paging = true; + fixture.detectChanges(); + hierarchicalGrid.notifyChanges(); + fixture.detectChanges(); + + expect(hierarchicalGrid.dataView.length).toEqual(15); + + const dataRows = hierarchicalGrid.dataRowList.toArray(); + + // expand 1st row + hierarchicalGrid.expandRow(dataRows[0].key); + tick(DEBOUNCE_TIME); + fixture.detectChanges(); + expect(hierarchicalGrid.dataView.length).toEqual(16); + + // expand 2nd row + hierarchicalGrid.expandRow(dataRows[1].key); + tick(DEBOUNCE_TIME); + fixture.detectChanges(); + + expect(hierarchicalGrid.dataView.length).toEqual(17); + expect(hierarchicalGrid.dataView.pop().ID).toEqual('14'); + })); + + it('should preserve expansion states after changing pages.', fakeAsync(() => { + fixture.componentInstance.paging = true; + fixture.detectChanges(); + + let dataRows = hierarchicalGrid.dataRowList.toArray() as IgxHierarchicalRowComponent[]; + // expand 1st row + hierarchicalGrid.expandRow(dataRows[0].key); + // expand 2nd row + hierarchicalGrid.expandRow(dataRows[1].key); + + expect(dataRows[0].expanded).toBeTruthy(); + expect(dataRows[1].expanded).toBeTruthy(); + expect(hierarchicalGrid.dataView.length).toEqual(17); + + let childGrids = hierarchicalGrid.gridAPI.getChildGrids(false); + expect(childGrids.length).toEqual(2); + expect(childGrids[0].dataRowList.first.cells.first.value).toEqual('00'); + + // Go to next page + GridFunctions.navigateToNextPage(hierarchicalGrid.nativeElement); + fixture.detectChanges(); + + dataRows = hierarchicalGrid.dataRowList.toArray() as IgxHierarchicalRowComponent[]; + expect(dataRows[0].cells.first.value).toEqual('15'); + expect(dataRows[0].expanded).toBeFalsy(); + expect(dataRows[1].expanded).toBeFalsy(); + expect(hierarchicalGrid.dataView.length).toEqual(15); + + childGrids = hierarchicalGrid.gridAPI.getChildGrids(false); + + // Return to previous page + GridFunctions.navigateToPrevPage(hierarchicalGrid.nativeElement); + fixture.detectChanges(); + + dataRows = hierarchicalGrid.dataRowList.toArray() as IgxHierarchicalRowComponent[]; + expect(dataRows[0].cells.first.value).toEqual('0'); + expect(dataRows[0].expanded).toBeTruthy(); + expect(dataRows[1].expanded).toBeTruthy(); + expect(hierarchicalGrid.dataView.length).toEqual(17); + + childGrids = hierarchicalGrid.gridAPI.getChildGrids(false); + expect(childGrids[0].dataRowList.first.cells.first.value).toEqual('00'); + })); + + it('should allow scrolling to the last row after page size has been changed and rows are expanded.', fakeAsync(() => { + /* it's better to avoid scrolling and only check for scrollbar availability */ + /* scrollbar doesn't update its visibility in fakeAsync tests */ + fixture.componentInstance.paging = true; + fixture.detectChanges(); + hierarchicalGrid.perPage = 20; + hierarchicalGrid.height = '800px'; + fixture.componentInstance.rowIsland.height = '200px'; + tick(); + fixture.detectChanges(); + expect(hierarchicalGrid.hasVerticalScroll()).toBeTruthy(); + + hierarchicalGrid.perPage = 5; + tick(DEBOUNCE_TIME); + fixture.detectChanges(); + expect(hierarchicalGrid.hasVerticalScroll()).toBeFalsy(); + + const dataRows = hierarchicalGrid.dataRowList.toArray() as IgxHierarchicalRowComponent[]; + + // expand 1st row + hierarchicalGrid.expandRow(dataRows[0].key); + tick(DEBOUNCE_TIME); + fixture.detectChanges(); + + expect(hierarchicalGrid.hasVerticalScroll()).toBeFalsy(); + expect(hierarchicalGrid.gridAPI.get_row_by_index(1) instanceof IgxChildGridRowComponent).toBeTruthy(); + + // expand 3rd row + hierarchicalGrid.expandRow(dataRows[3].key); + tick(DEBOUNCE_TIME); + fixture.detectChanges(); + expect(hierarchicalGrid.gridAPI.get_row_by_index(4) instanceof IgxChildGridRowComponent).toBeTruthy(); + })); + + it('should correctly hide/show vertical scrollbar after page is changed.', (async () => { + /* scrollbar doesn't update its visibility in fakeAsync tests */ + fixture.componentInstance.paging = true; + fixture.detectChanges(); + hierarchicalGrid.perPage = 5; + fixture.detectChanges(); + + expect(hierarchicalGrid.hasVerticalScroll()).toBeFalsy(); + + // expand 1st row + hierarchicalGrid.expandRow(hierarchicalGrid.dataRowList.first.key); + await wait(DEBOUNCE_TIME); + + expect(hierarchicalGrid.hasVerticalScroll()).toBeTruthy(); + + // change page + hierarchicalGrid.page = 1; + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + expect(hierarchicalGrid.hasVerticalScroll()).toBeFalsy(); + + // change page + hierarchicalGrid.page = 0; + fixture.detectChanges(); + await wait(DEBOUNCE_TIME * 2); + + expect(hierarchicalGrid.hasVerticalScroll()).toBeTruthy(); + })); + + it('should be displayed correctly when using the template input', fakeAsync(() => { + fixture = TestBed.createComponent(IgxHierarchicalGridTestInputPaginatorComponent); + tick(); + fixture.detectChanges(); + hierarchicalGrid = fixture.componentInstance.hgrid; + + hierarchicalGrid.expandRow(hierarchicalGrid.dataRowList.first.key); + tick(DEBOUNCE_TIME); + fixture.detectChanges(); + + const paginators = fixture.debugElement.queryAll(By.css('igx-paginator')); + expect(paginators[0].query(By.css('button')).nativeElement.innerText.trim()).toEqual('childData1 Button'); + expect(paginators[1].query(By.css('button')).nativeElement.innerText.trim()).toEqual('childData2 Button'); + })) + }); + + describe('Hiding', () => { + it('should leave no feature UI elements when all columns are hidden', fakeAsync(() => { + fixture.componentInstance.paging = true; + fixture.detectChanges(); + hierarchicalGrid.rowSelection = GridSelectionMode.multiple; + hierarchicalGrid.rowDraggable = true; + tick(DEBOUNCE_TIME); + fixture.detectChanges(); + + let headers = GridFunctions.getColumnHeaders(fixture); + let gridRows = HierarchicalGridFunctions.getHierarchicalRows(fixture); + const paging = GridFunctions.getGridPaginator(fixture); + let rowSelectors = GridSelectionFunctions.getCheckboxes(fixture); + let dragIndicators = GridFunctions.getDragIndicators(fixture); + let expander = HierarchicalGridFunctions.getExpander(fixture, '[hidden]'); + + expect(headers.length).toBeGreaterThan(0); + expect(gridRows.length).toBeGreaterThan(0); + expect(paging).not.toBeNull(); + expect(rowSelectors.length).toBeGreaterThan(0); + expect(dragIndicators.length).toBeGreaterThan(0); + // this check executes correctly on Ivy only + // expect(Object.keys(expanders[0].attributes)).not.toContain('hidden'); + expect(expander).toBeNull(); + expect(hierarchicalGrid.hasVerticalScroll()).toBeTruthy(); + + hierarchicalGrid.columnList.forEach((col) => col.hidden = true); + tick(DEBOUNCE_TIME); + fixture.detectChanges(); + + headers = GridFunctions.getColumnHeaders(fixture); + gridRows = HierarchicalGridFunctions.getHierarchicalRows(fixture); + rowSelectors = GridSelectionFunctions.getCheckboxes(fixture); + dragIndicators = GridFunctions.getDragIndicators(fixture); + expander = HierarchicalGridFunctions.getExpander(fixture, '[hidden]'); + + expect(headers.length).toBe(0); + expect(gridRows.length).toBe(0); + expect(rowSelectors.length).toBe(0); + expect(dragIndicators.length).toBe(0); + // this check executes correctly on Ivy only + // expect(Object.keys(expanders[0].attributes)).toContain('hidden'); + expect(expander).not.toBeNull(); + expect(hierarchicalGrid.hasVerticalScroll()).toBeFalsy(); + })); + }); + + describe('Toolbar', () => { + it('should be displayed correctly for child layout and hiding should apply to the correct child.', fakeAsync(() => { + pending('Change test for new scrollbar structure'); + hierarchicalGrid.expandRow(hierarchicalGrid.dataRowList.first.key); + tick(); + fixture.detectChanges(); + + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const toolbar = childGrid.nativeElement.querySelector('igx-grid-toolbar'); + const hidingUI = toolbar.querySelector('igx-grid-toolbar-hiding') as HTMLElement; + + // Check if visible columns and headers are rendered correctly + expect(childGrid.visibleColumns.length).toEqual(4); + + // Check if hiding button & dropdown are init + expect(toolbar).toBeDefined(); + expect(hidingUI).toBeDefined(); + + hidingUI.click(); + tick(); + fixture.detectChanges(); + + // // Check if the child grid columns are the one used by the hiding UI + // childGrid.visibleColumns.forEach((column, index) => expect(toolbar.columnHidingUI.columns[index]).toEqual(column)); + + // // Instead of clicking we can just toggle the checkbox + // toolbar.columnHidingUI.columnItems.toArray()[2].toggle(); + // fixture.detectChanges(); + + // And it should hide the column of the child grid + // expect(childGrid.visibleColumns.length).toEqual(3); + })); + + it('should be displayed correctly for child layout and pinning should apply to the correct child.', fakeAsync(() => { + pending('Change test for new scrollbar structure'); + hierarchicalGrid.expandRow(hierarchicalGrid.dataRowList.first.key); + + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const toolbar = childGrid.nativeElement.querySelector('igx-grid-toolbar'); + + // Check if visible columns and headers are rendered correctly + expect(childGrid.visibleColumns.length).toEqual(4); + + // Check if pinning button & dropdown are init + expect(toolbar).toBeDefined(); + expect(toolbar.querySelector('igx-grid-toolbar-pinning')).toBeDefined(); + + // Check if the child grid columns are the one used by the pinning UI + // childGrid.visibleColumns.forEach((column, index) => expect(toolbar.columnPinningUI.columns[index]).toEqual(column)); + + // Instead of clicking we can just toggle the checkbox + // toolbar.columnPinningUI.columnItems.toArray()[1].toggle(); + fixture.detectChanges(); + + // Check pinned state + expect(childGrid.getColumnByName('ChildLevels').pinned).toBeTruthy(); + expect(childGrid.getColumnByName('ProductName').pinned).toBeTruthy(); + expect(childGrid.getColumnByName('ID').pinned).toBeFalsy(); + })); + + it('should read from custom templates per level', fakeAsync(() => { + pending('Change test for new scrollbar structure'); + fixture = TestBed.createComponent(IgxHierarchicalGridTestCustomToolbarComponent); + tick(); + fixture.detectChanges(); + hierarchicalGrid = fixture.componentInstance.hgrid; + hierarchicalGrid.expandRow(hierarchicalGrid.dataRowList.first.key); + tick(DEBOUNCE_TIME); + fixture.detectChanges(); + + const toolbars = fixture.debugElement.queryAll(By.css('igx-grid-toolbar')); + expect(toolbars.length).toEqual(3); + expect(toolbars[0].query(By.css('button')).nativeElement.innerText.trim()).toEqual('Parent Button'); + expect(toolbars[1].query(By.css('button')).nativeElement.innerText.trim()).toEqual('Child 1 Button'); + expect(toolbars[2].query(By.css('button')).nativeElement.innerText.trim()).toEqual('Child 2 Button'); + })); + + it('should have same width as the grid whole width', fakeAsync(() => { + fixture = TestBed.createComponent(IgxHierarchicalGridTestCustomToolbarComponent); + tick(); + fixture.detectChanges(); + hierarchicalGrid = fixture.componentInstance.hgrid; + + const toolbar = fixture.debugElement.query(By.css('igx-grid-toolbar')); + expect(toolbar.nativeElement.offsetWidth).toEqual(hierarchicalGrid.nativeElement.offsetWidth); + })); + + it('should be displayed correctly when using the template input', fakeAsync(() => { + fixture = TestBed.createComponent(IgxHierarchicalGridTestInputToolbarComponent); + tick(); + fixture.detectChanges(); + hierarchicalGrid = fixture.componentInstance.hgrid; + + hierarchicalGrid.expandRow(hierarchicalGrid.dataRowList.first.key); + tick(DEBOUNCE_TIME); + fixture.detectChanges(); + + const toolbars = fixture.debugElement.queryAll(By.css('igx-grid-toolbar')); + expect(toolbars[1].query(By.css('button')).nativeElement.innerText.trim()).toEqual('childData1 Button'); + expect(toolbars[2].query(By.css('button')).nativeElement.innerText.trim()).toEqual('childData2 Button'); + })) + }); + + describe('Moving', () => { + + // TODO: Revise this test! That DOM digging is sloppy + xit('should not be possible to drag move a column from another grid.', (async () => { + hierarchicalGrid.expandRow(hierarchicalGrid.dataRowList.first.key); + + const childGrids = fixture.debugElement.queryAll(By.css('igx-child-grid-row')); + const childHeader = childGrids[0].queryAll(By.css('igx-grid-header'))[0].nativeElement; + const mainHeaders = hierarchicalGrid.nativeElement + .querySelectorAll('igx-grid-header[ng-reflect-grid-i-d="' + hierarchicalGrid.id + '"]'); + + const childHeaderX = childHeader.getBoundingClientRect().x + childHeader.getBoundingClientRect().width / 2; + const childHeaderY = childHeader.getBoundingClientRect().y + childHeader.getBoundingClientRect().height / 2; + const mainHeaderX = mainHeaders[0].getBoundingClientRect().x + mainHeaders[0].getBoundingClientRect().width / 2; + const mainHeaderY = mainHeaders[0].getBoundingClientRect().y + mainHeaders[0].getBoundingClientRect().height / 2; + + UIInteractions.simulatePointerEvent('pointerdown', childHeader, childHeaderX, childHeaderY); + await wait(); + fixture.detectChanges(); + UIInteractions.simulatePointerEvent('pointermove', childHeader, childHeaderX, childHeaderY - 10); + await wait(100); + fixture.detectChanges(); + UIInteractions.simulatePointerEvent('pointermove', childHeader, mainHeaderX + 50, mainHeaderY); + await wait(100); + fixture.detectChanges(); + + // The moving indicator shouldn't show that a column can be moved. + const childGroupHeader = childGrids[0].query(By.css('igx-grid-header')).injector.get(IgxColumnMovingDragDirective); + const dragElem = childGroupHeader.ghostElement; + const dragIcon = dragElem.querySelector('i'); + expect(dragElem).toBeDefined(); + expect(dragIcon.innerText.trim()).toEqual('block'); + + UIInteractions.simulatePointerEvent('pointerup', childHeader, mainHeaderX + 50, mainHeaderY); + await wait(); + fixture.detectChanges(); + + expect(hierarchicalGrid.columnList.length).toEqual(4); + // expect(mainHeaders.length).toEqual(3); + // expect(mainHeaders[0].children[0].innerText.trim()).toEqual('ID'); + // expect(mainHeaders[1].children[0].innerText.trim()).toEqual('ChildLevels'); + // expect(mainHeaders[2].children[0].innerText.trim()).toEqual('ProductName'); + })); + }); + + describe('Pinning', () => { + it('should be possible by templating the header and getting column reference for child grid', fakeAsync(() => { + hierarchicalGrid.expandRow(hierarchicalGrid.dataRowList.first.key); + + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + let childHeader = GridFunctions.getColumnHeaders(fixture)[3]; + const firstHeaderIcon = childHeader.query(By.css('.igx-icon')); + + expect(GridFunctions.isHeaderPinned(childHeader.parent)).toBeFalsy(); + expect(childGrid.columns[0].pinned).toBeFalsy(); + expect(firstHeaderIcon).toBeDefined(); + + UIInteractions.simulateClickAndSelectEvent(firstHeaderIcon); + fixture.detectChanges(); + tick(); + + childHeader = GridFunctions.getColumnHeaders(fixture)[3]; + expect(childGrid.columns[0].pinned).toBeTruthy(); + expect(GridFunctions.isHeaderPinned(childHeader.parent)).toBeTruthy(); + })); + + it('should be applied correctly for child grid with multi-column header.', fakeAsync(() => { + fixture.componentInstance.rowIsland.columnList.find(x => x.header === 'Information').pinned = true; + tick(DEBOUNCE_TIME); + fixture.detectChanges(); + + hierarchicalGrid.expandRow(hierarchicalGrid.dataRowList.first.key); + tick(DEBOUNCE_TIME); + fixture.detectChanges(); + + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + // check unpinned/pinned columns + expect(childGrid.pinnedColumns.length).toBe(3); + expect(childGrid.unpinnedColumns.length).toBe(1); + // check cells + expect(childGrid.gridAPI.get_row_by_index(0).cells.length).toBe(3); + let cell = childGrid.gridAPI.get_cell_by_index(0, 'ChildLevels'); + expect(cell.visibleColumnIndex).toEqual(0); + expect(GridFunctions.isCellPinned(cell)).toBeTruthy(); + cell = childGrid.gridAPI.get_cell_by_index(0, 'ProductName'); + expect(cell.visibleColumnIndex).toEqual(1); + expect(GridFunctions.isCellPinned(cell)).toBeTruthy(); + cell = childGrid.gridAPI.get_cell_by_index(0, 'ID'); + expect(cell.visibleColumnIndex).toEqual(2); + expect(GridFunctions.isCellPinned(cell)).toBeFalsy(); + })); + + it('should be applied correctly even on the right side', fakeAsync(() => { + hierarchicalGrid = fixture.componentInstance.hgrid; + hierarchicalGrid.columnList.find(x => x.field === 'ID').pinned = true; + hierarchicalGrid.pinning.columns = 1; + hierarchicalGrid.cdr.detectChanges(); + tick(); + fixture.detectChanges(); + const rightMostGridPart = hierarchicalGrid.nativeElement.getBoundingClientRect().right; + const leftMostGridPart = hierarchicalGrid.nativeElement.getBoundingClientRect().left; + const leftMostRightPinnedCellsPart = hierarchicalGrid.gridAPI.get_cell_by_index(0, 'ID') + .nativeElement.getBoundingClientRect().left; + const pinnedCellWidth = hierarchicalGrid.getCellByColumn(0, 'ID').width; + // Expects that right pinning has been in action + expect(leftMostGridPart).not.toEqual(leftMostRightPinnedCellsPart); + // Expects that pinned column is in the visible grid's area + expect(leftMostRightPinnedCellsPart).toBeLessThan(rightMostGridPart); + // Expects that the whole pinned column is visible + expect(leftMostRightPinnedCellsPart + Number.parseInt(pinnedCellWidth, 10)).toBeLessThan(rightMostGridPart); + })); + }); + + describe('Row Pinning', () => { + const FIXED_ROW_CONTAINER = '.igx-grid__tr--pinned'; + const FIXED_ROW_CONTAINER_TOP = 'igx-grid__tr--pinned-top'; + const FIXED_ROW_CONTAINER_BOTTOM = 'igx-grid__tr--pinned-bottom'; + + beforeEach(() => { + hierarchicalGrid.width = '800px'; + hierarchicalGrid.height = '500px'; + fixture.detectChanges(); + }); + + it('should pin rows to top ', (() => { + hierarchicalGrid.pinRow('0'); + expect(hierarchicalGrid.pinnedRows.length).toBe(1); + + hierarchicalGrid.unpinRow('0'); + expect(hierarchicalGrid.pinnedRows.length).toBe(0); + + hierarchicalGrid.pinRow('0'); + expect(hierarchicalGrid.pinnedRows.length).toBe(1); + + let pinRowContainer = fixture.debugElement.queryAll(By.css(FIXED_ROW_CONTAINER)); + expect(pinRowContainer.length).toBe(1); + expect(pinRowContainer[0].nativeElement.classList.contains(FIXED_ROW_CONTAINER_TOP)).toBeTruthy(); + expect(pinRowContainer[0].nativeElement.classList.contains(FIXED_ROW_CONTAINER_BOTTOM)).toBeFalsy(); + + expect(pinRowContainer[0].children[0].context.key).toBe('0'); + expect(hierarchicalGrid.getRowByIndex(1).key).toBe('0'); + expect(hierarchicalGrid.getRowByIndex(2).key).toBe('1'); + expect(hierarchicalGrid.getRowByIndex(3).key).toBe('2'); + + hierarchicalGrid.pinRow('2'); + + pinRowContainer = fixture.debugElement.queryAll(By.css(FIXED_ROW_CONTAINER)); + expect(pinRowContainer[0].children.length).toBe(2); + + expect(pinRowContainer[0].children[0].context.key).toBe('0'); + expect(pinRowContainer[0].children[1].context.key).toBe('2'); + expect(hierarchicalGrid.getRowByIndex(2).key).toBe('0'); + expect(hierarchicalGrid.getRowByIndex(3).key).toBe('1'); + expect(hierarchicalGrid.getRowByIndex(4).key).toBe('2'); + + fixture.detectChanges(); + expect(hierarchicalGrid.pinnedRowHeight).toBe(2 * hierarchicalGrid.renderedRowHeight + 2); + const expectedHeight = parseInt(hierarchicalGrid.height, 10) - + hierarchicalGrid.pinnedRowHeight - 18 - hierarchicalGrid.theadRow.nativeElement.offsetHeight; + expect(hierarchicalGrid.calcHeight - expectedHeight).toBeLessThanOrEqual(1); + })); + + it('should pin rows to bottom', (() => { + fixture.componentInstance.pinningConfig = { columns: ColumnPinningPosition.Start, rows: RowPinningPosition.Bottom }; + fixture.detectChanges(); + + // Pin 2nd row + hierarchicalGrid.pinRow('1'); + fixture.detectChanges(); + + expect(hierarchicalGrid.pinnedRows.length).toBe(1); + let pinRowContainer = fixture.debugElement.queryAll(By.css(FIXED_ROW_CONTAINER)); + expect(pinRowContainer.length).toBe(1); + expect(pinRowContainer[0].nativeElement.classList.contains(FIXED_ROW_CONTAINER_TOP)).toBeFalsy(); + expect(pinRowContainer[0].nativeElement.classList.contains(FIXED_ROW_CONTAINER_BOTTOM)).toBeTruthy(); + + expect(pinRowContainer[0].children.length).toBe(1); + expect(pinRowContainer[0].children[0].context.key).toBe('1'); + expect(pinRowContainer[0].children[0].context.index).toBe(fixture.componentInstance.data.length); + expect(pinRowContainer[0].children[0].nativeElement) + .toBe(hierarchicalGrid.gridAPI.get_row_by_index(fixture.componentInstance.data.length).nativeElement); + + expect(hierarchicalGrid.getRowByIndex(0).key).toBe('0'); + expect(hierarchicalGrid.getRowByIndex(1).key).toBe('1'); + expect(hierarchicalGrid.getRowByIndex(2).key).toBe('2'); + + // Pin 1st row + hierarchicalGrid.pinRow('0'); + fixture.detectChanges(); + + pinRowContainer = fixture.debugElement.queryAll(By.css(FIXED_ROW_CONTAINER)); + expect(pinRowContainer[0].children.length).toBe(2); + expect(pinRowContainer[0].children[0].context.key).toBe('1'); + expect(pinRowContainer[0].children[1].context.key).toBe('0'); + expect(hierarchicalGrid.getRowByIndex(0).key).toBe('0'); + expect(hierarchicalGrid.getRowByIndex(1).key).toBe('1'); + expect(hierarchicalGrid.getRowByIndex(2).key).toBe('2'); + + fixture.detectChanges(); + // Check last pinned is fully in view + const last = pinRowContainer[0].children[1].context.nativeElement; + expect(last.getBoundingClientRect().bottom - hierarchicalGrid.tbody.nativeElement.getBoundingClientRect().bottom).toBe(0); + + // 2 records pinned + 2px border + expect(hierarchicalGrid.pinnedRowHeight).toBe(2 * hierarchicalGrid.renderedRowHeight + 2); + const expectedHeight = parseInt(hierarchicalGrid.height, 10) - + hierarchicalGrid.pinnedRowHeight - 18 - hierarchicalGrid.theadRow.nativeElement.offsetHeight; + expect(hierarchicalGrid.calcHeight - expectedHeight).toBeLessThanOrEqual(1); + })); + + it('should search in both pinned and unpinned rows.', () => { + let findCount = hierarchicalGrid.findNext('Product: A0'); + fixture.detectChanges(); + + let spans = fixture.debugElement.queryAll(By.css('.igx-highlight')); + expect(spans.length).toBe(1); + expect(findCount).toEqual(1); + + // Pin 1st row + hierarchicalGrid.pinRow('0'); + fixture.detectChanges(); + expect(hierarchicalGrid.pinnedRows.find(r => r.key === '0')).toBeDefined(); + + findCount = hierarchicalGrid.findNext('Product: A0'); + fixture.detectChanges(); + + spans = fixture.debugElement.queryAll(By.css('.igx-highlight')); + expect(spans.length).toBe(2); + expect(findCount).toEqual(2); + }); + + it('should apply filtering to both pinned and unpinned rows.', () => { + hierarchicalGrid.pinRow('1'); + fixture.detectChanges(); + hierarchicalGrid.pinRow('5'); + fixture.detectChanges(); + + let pinRowContainer = fixture.debugElement.queryAll(By.css(FIXED_ROW_CONTAINER)); + expect(pinRowContainer[0].children.length).toBe(2); + expect(pinRowContainer[0].children[0].context.key).toBe('1'); + expect(pinRowContainer[0].children[1].context.key).toBe('5'); + + hierarchicalGrid.filter('ID', '5', IgxStringFilteringOperand.instance().condition('contains'), false); + fixture.detectChanges(); + + const allRows = HierarchicalGridFunctions.getHierarchicalRows(fixture); + pinRowContainer = fixture.debugElement.queryAll(By.css(FIXED_ROW_CONTAINER)); + expect(pinRowContainer[0].children.length).toBe(1); + expect(pinRowContainer[0].children[0].context.key).toBe('5'); + expect(allRows[1].componentInstance.key).toEqual('5'); + }); + + it('should render paging with correct data and rows be correctly paged.', () => { + hierarchicalGrid.height = '700px'; + fixture.detectChanges(); + fixture.componentInstance.paging = true; + fixture.detectChanges(); + hierarchicalGrid.perPage = 5; + fixture.detectChanges(); + + + let rows = HierarchicalGridFunctions.getHierarchicalRows(fixture); + const paginator = fixture.debugElement.query(By.directive(IgxPaginatorComponent)); + expect(rows.length).toEqual(5); + expect(paginator.componentInstance.perPage).toEqual(5); + expect(paginator.componentInstance.totalPages).toEqual(8); + + hierarchicalGrid.pinRow('1'); + fixture.detectChanges(); + + rows = HierarchicalGridFunctions.getHierarchicalRows(fixture); + expect(rows.length).toEqual(6); + expect(paginator.componentInstance.perPage).toEqual(5); + expect(paginator.componentInstance.totalPages).toEqual(8); + + hierarchicalGrid.pinRow('3'); + fixture.detectChanges(); + + rows = HierarchicalGridFunctions.getHierarchicalRows(fixture); + expect(rows.length).toEqual(7); + expect(paginator.componentInstance.perPage).toEqual(5); + expect(paginator.componentInstance.totalPages).toEqual(8); + }); + + it('should apply sorting to both pinned and unpinned rows.', () => { + hierarchicalGrid.pinRow('1'); + hierarchicalGrid.pinRow('3'); + fixture.detectChanges(); + + expect(hierarchicalGrid.getRowByIndex(0).key).toBe('1'); + expect(hierarchicalGrid.getRowByIndex(1).key).toBe('3'); + + hierarchicalGrid.sort({ fieldName: 'ID', dir: SortingDirection.Desc, ignoreCase: false }); + fixture.detectChanges(); + + // check pinned rows data is sorted + expect(hierarchicalGrid.getRowByIndex(0).key).toBe('3'); + expect(hierarchicalGrid.getRowByIndex(1).key).toBe('1'); + + // check unpinned rows data is sorted + // Expect 9 since it is a string. + expect(hierarchicalGrid.getRowByIndex(2).key).toBe('9'); + }); + + it('should return pinned rows as well on multiple cell selection in both pinned and unpinned areas', async () => { + hierarchicalGrid.pinRow('1'); + fixture.detectChanges(); + + let range = { rowStart: 0, rowEnd: 2, columnStart: 'ID', columnEnd: 'ChildLevels' }; + hierarchicalGrid.selectRange(range); + fixture.detectChanges(); + + let selectedData = hierarchicalGrid.getSelectedData(); + expect(selectedData).toEqual([{ID: '1', ChildLevels: 3}, {ID: '0', ChildLevels: 3}, {ID: '1', ChildLevels: 3}]); + + fixture.componentInstance.pinningConfig = { columns: ColumnPinningPosition.Start, rows: RowPinningPosition.Bottom }; + fixture.detectChanges(); + + hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop = 5000; + await wait(); + + range = { rowStart: 38, rowEnd: 40, columnStart: 'ID', columnEnd: 'ChildLevels' }; + hierarchicalGrid.clearCellSelection(); + hierarchicalGrid.selectRange(range); + fixture.detectChanges(); + + selectedData = hierarchicalGrid.getSelectedData(); + expect(selectedData).toEqual([{ID: '38', ChildLevels: 3}, {ID: '39', ChildLevels: 3}, {ID: '1', ChildLevels: 3}]); + }); + + it('should return correct filterData collection after filtering.', () => { + hierarchicalGrid.pinRow('1'); + hierarchicalGrid.pinRow('11'); + fixture.detectChanges(); + + hierarchicalGrid.filter('ID', '1', IgxStringFilteringOperand.instance().condition('contains'), false); + fixture.detectChanges(); + + let gridFilterData = hierarchicalGrid.filteredData; + expect(gridFilterData.length).toBe(15); + expect(gridFilterData[0].ID).toBe('1'); + expect(gridFilterData[1].ID).toBe('11'); + expect(gridFilterData[2].ID).toBe('1'); + + fixture.componentInstance.pinningConfig = { columns: ColumnPinningPosition.Start, rows: RowPinningPosition.Bottom }; + fixture.detectChanges(); + + gridFilterData = hierarchicalGrid.filteredData; + expect(gridFilterData.length).toBe(15); + expect(gridFilterData[0].ID).toBe('1'); + expect(gridFilterData[1].ID).toBe('11'); + expect(gridFilterData[2].ID).toBe('1'); + }); + + it('should correctly apply paging state for grid and paginator when there are pinned rows.', fakeAsync(() => { + fixture.componentInstance.paging = true; + fixture.detectChanges(); + hierarchicalGrid.perPage = 5; + hierarchicalGrid.height = '700px'; + fixture.detectChanges(); + const paginator = fixture.debugElement.query(By.directive(IgxPaginatorComponent)).componentInstance; + // pin the first row + hierarchicalGrid.getRowByIndex(0).pin(); + + expect(hierarchicalGrid.rowList.length).toEqual(6); + expect(hierarchicalGrid.perPage).toEqual(5); + expect(paginator.perPage).toEqual(5); + expect(paginator.totalRecords).toEqual(40); + expect(paginator.totalPages).toEqual(8); + + // pin the second row + hierarchicalGrid.getRowByIndex(2).pin(); + + expect(hierarchicalGrid.rowList.length).toEqual(7); + expect(hierarchicalGrid.perPage).toEqual(5); + expect(paginator.perPage).toEqual(5); + expect(paginator.totalRecords).toEqual(40); + expect(paginator.totalPages).toEqual(8); + + // expand the first row + hierarchicalGrid.expandRow(hierarchicalGrid.dataRowList.first.key); + fixture.detectChanges(); + tick(50); + fixture.detectChanges(); + + expect(hierarchicalGrid.rowList.length).toEqual(8); + expect(hierarchicalGrid.perPage).toEqual(5); + expect(paginator.perPage).toEqual(5); + expect(paginator.totalRecords).toEqual(40); + expect(paginator.totalPages).toEqual(8); + + expect(hierarchicalGrid.rowList.toArray()[1] instanceof IgxChildGridRowComponent).toBeFalsy(); + expect(hierarchicalGrid.rowList.toArray()[3] instanceof IgxChildGridRowComponent).toBeTruthy(); + })); + + it('should have the correct records shown for pages with pinned rows', () => { + fixture.componentInstance.paging = true; + fixture.detectChanges(); + hierarchicalGrid.perPage = 6; + hierarchicalGrid.height = '700px'; + fixture.detectChanges(); + hierarchicalGrid.getRowByIndex(0).pin(); + + let rows = hierarchicalGrid.rowList.toArray(); + + [0, 0, 1, 2, 3, 4, 5].forEach((x, index) => expect(parseInt(rows[index].cells.first.value, 10)).toEqual(x)); + + hierarchicalGrid.paginator.paginate(6); + fixture.detectChanges(); + + rows = hierarchicalGrid.rowList.toArray(); + + [0, 36, 37, 38, 39].forEach((x, index) => expect(parseInt(rows[index].cells.first.value, 10)).toEqual(x)); + }); + }); +}); diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.module.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.module.ts new file mode 100644 index 00000000000..ab47e2dd9d8 --- /dev/null +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { IGX_HIERARCHICAL_GRID_DIRECTIVES } from './public_api'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + ...IGX_HIERARCHICAL_GRID_DIRECTIVES + ], + exports: [ + ...IGX_HIERARCHICAL_GRID_DIRECTIVES + ] +}) +export class IgxHierarchicalGridModule { +} diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.navigation.spec.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.navigation.spec.ts new file mode 100644 index 00000000000..e9b7ef3f5cc --- /dev/null +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.navigation.spec.ts @@ -0,0 +1,1129 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Component, ViewChild, DebugElement} from '@angular/core'; +import { IgxChildGridRowComponent, IgxHierarchicalGridComponent } from './hierarchical-grid.component'; +import { wait, UIInteractions, waitForSelectionChange } from '../../../test-utils/ui-interactions.spec'; +import { IgxRowIslandComponent } from './row-island.component'; +import { By } from '@angular/platform-browser'; +import { IgxHierarchicalRowComponent } from './hierarchical-row.component'; +import { clearGridSubs, setupHierarchicalGridScrollDetection } from '../../../test-utils/helper-utils.spec'; +import { GridFunctions } from '../../../test-utils/grid-functions.spec'; +import { IGridCellEventArgs, IgxColumnComponent, IgxGridCellComponent, IgxGridNavigationService } from 'igniteui-angular/grids/core'; +import { IPathSegment } from 'igniteui-angular/core'; + +const DEBOUNCE_TIME = 50; +const GRID_CONTENT_CLASS = '.igx-grid__tbody-content'; +const GRID_FOOTER_CLASS = '.igx-grid__tfoot'; + +describe('IgxHierarchicalGrid Navigation', () => { + let fixture; + let hierarchicalGrid: IgxHierarchicalGridComponent; + let baseHGridContent: DebugElement; + const defaultTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxHierarchicalGridTestBaseComponent, + IgxHierarchicalGridTestComplexComponent, + IgxHierarchicalGridMultiLayoutComponent, + IgxHierarchicalGridSmallerChildComponent + ], + providers: [ + IgxGridNavigationService + ] + }).compileComponents(); + jasmine.DEFAULT_TIMEOUT_INTERVAL = defaultTimeout * 2; + })); + + afterAll(() => jasmine.DEFAULT_TIMEOUT_INTERVAL = defaultTimeout); + + describe('IgxHierarchicalGrid Basic Navigation #hGrid', () => { + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(IgxHierarchicalGridTestBaseComponent); + fixture.detectChanges(); + hierarchicalGrid = fixture.componentInstance.hgrid; + setupHierarchicalGridScrollDetection(fixture, hierarchicalGrid); + baseHGridContent = GridFunctions.getGridContent(fixture); + GridFunctions.focusFirstCell(fixture, hierarchicalGrid); + })); + + afterEach(() => { + clearGridSubs(); + }); + + // simple tests + it('should allow navigating down from parent row into child grid.', async () => { + hierarchicalGrid.expandChildren = false; + hierarchicalGrid.height = '600px'; + hierarchicalGrid.width = '800px'; + fixture.componentInstance.rowIsland.height = '350px'; + fixture.detectChanges(); + await wait(); + + // expand row + const row1 = hierarchicalGrid.dataRowList.first as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row1.expander); + fixture.detectChanges(); + await wait(); + + // activate cell + const fCell = hierarchicalGrid.dataRowList.first.cells.first; + GridFunctions.focusCell(fixture, fCell); + + // arrow down + UIInteractions.triggerEventHandlerKeyDown('arrowdown', baseHGridContent, false, false, false); + fixture.detectChanges(); + + // verify selection in child. + const selectedCell = fixture.componentInstance.selectedCell; + expect(selectedCell.value).toEqual(0); + expect(selectedCell.column.field).toMatch('ID'); + }); + + it('should allow navigating up from child row into parent grid.', () => { + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const childFirstCell = childGrid.dataRowList.first.cells.first; + GridFunctions.focusCell(fixture, childFirstCell); + + // arrow up in child + const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; + UIInteractions.triggerEventHandlerKeyDown('arrowup', childGridContent, false, false, false); + fixture.detectChanges(); + + // verify selection in parent. + const selectedCell = fixture.componentInstance.selectedCell; + expect(selectedCell.value).toEqual(0); + expect(selectedCell.column.field).toMatch('ID'); + }); + + it('should allow navigating down in child grid when child grid selected cell moves outside the parent view port.', async () => { + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const childCell = childGrid.dataRowList.toArray()[3].cells.first; + GridFunctions.focusCell(fixture, childCell); + + // arrow down in child + const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; + UIInteractions.triggerEventHandlerKeyDown('arrowdown', childGridContent, false, false, false); + fixture.detectChanges(); + await wait(); + // parent should scroll down so that cell in child is in view. + const selectedCell = fixture.componentInstance.selectedCell; + const selectedCellElem = childGrid.gridAPI.get_cell_by_index(selectedCell.row.index, selectedCell.column.field) as IgxGridCellComponent; + const gridOffsets = hierarchicalGrid.tbody.nativeElement.getBoundingClientRect(); + const rowOffsets = selectedCellElem.intRow.nativeElement.getBoundingClientRect(); + expect(rowOffsets.top >= gridOffsets.top && rowOffsets.bottom <= gridOffsets.bottom).toBeTruthy(); + }); + + it('should allow navigating up in child grid when child grid selected cell moves outside the parent view port.', async () => { + await wait(DEBOUNCE_TIME); + fixture.detectChanges(); + hierarchicalGrid.verticalScrollContainer.scrollTo(2); + await wait(DEBOUNCE_TIME); + fixture.detectChanges(); + + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const childCell = childGrid.dataRowList.toArray()[4].cells.first; + GridFunctions.focusCell(fixture, childCell); + + const prevScrTop = hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop; + + const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; + UIInteractions.triggerEventHandlerKeyDown('arrowup', childGridContent, false, false, false); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + // parent should scroll up so that cell in child is in view. + const currScrTop = hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop; + expect(prevScrTop - currScrTop).toBeGreaterThanOrEqual(childGrid.rowHeight); + }); + + it('should allow navigating to end in child grid when child grid target row moves outside the parent view port.', async () => { + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const childCell = childGrid.dataRowList.toArray()[0].cells.toArray()[0]; + GridFunctions.focusCell(fixture, childCell); + + const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; + UIInteractions.triggerEventHandlerKeyDown('end', childGridContent, false, false, true); + fixture.detectChanges(); + await wait(); + + // verify selection in child. + const selectedCell = fixture.componentInstance.selectedCell; + expect(selectedCell.row.index).toEqual(9); + expect(selectedCell.column.field).toMatch('childData2'); + + // parent should be scrolled down + const currScrTop = hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop; + expect(currScrTop).toBeGreaterThanOrEqual(childGrid.rowHeight * 5); + }); + + it('should allow navigating to start in child grid when child grid target row moves outside the parent view port.', async () => { + hierarchicalGrid.verticalScrollContainer.scrollTo(2); + fixture.detectChanges(); + await wait(); + + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const horizontalScrDir = childGrid.dataRowList.toArray()[0].virtDirRow; + horizontalScrDir.scrollTo(6); + fixture.detectChanges(); + await wait(); + + const childLastCell = childGrid.dataRowList.toArray()[9].cells.toArray()[3]; + GridFunctions.focusCell(fixture, childLastCell); + + const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; + UIInteractions.triggerEventHandlerKeyDown('home', childGridContent, false, false, true); + await wait(DEBOUNCE_TIME * 3); + fixture.detectChanges(); + + const selectedCell = fixture.componentInstance.selectedCell; + expect(selectedCell.value).toEqual(0); + expect(selectedCell.column.index).toBe(0); + expect(selectedCell.row.index).toBe(0); + + // check if child row is in view of parent. + const gridOffsets = hierarchicalGrid.tbody.nativeElement.getBoundingClientRect(); + const rowElem = childGrid.gridAPI.get_row_by_index(selectedCell.row.index); + const rowOffsets = rowElem.nativeElement.getBoundingClientRect(); + expect(rowOffsets.top).toBeGreaterThanOrEqual(gridOffsets.top); + expect(rowOffsets.bottom).toBeLessThanOrEqual(gridOffsets.bottom); + }); + + it('should allow navigating to bottom in child grid when child grid target row moves outside the parent view port.', async () => { + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const childCell = childGrid.dataRowList.first.cells.first; + GridFunctions.focusCell(fixture, childCell); + + const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; + UIInteractions.triggerEventHandlerKeyDown('arrowdown', childGridContent, false, false, true); + // wait for parent grid to complete scroll to child cell. + await wait(); + fixture.detectChanges(); + + const selectedCell = fixture.componentInstance.selectedCell; + expect(selectedCell.value).toBe(9); + expect(selectedCell.column.index).toBe(0); + expect(selectedCell.row.index).toBe(9); + + const currScrTop = hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop; + expect(currScrTop).toBeGreaterThanOrEqual(childGrid.rowHeight * 5); + }); + + it('should not lose activation when pressing Ctrl+ArrowDown is pressed at the bottom row(expended) in a child grid.', async () => { + hierarchicalGrid.height = '600px'; + hierarchicalGrid.width = '800px'; + fixture.componentInstance.rowIsland.height = '400px'; + fixture.detectChanges(); + await wait(); + + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + childGrid.data = childGrid.data.slice(0, 5); + fixture.detectChanges(); + + childGrid.dataRowList.toArray()[4].expander.nativeElement.click(); + fixture.detectChanges(); + await wait(); + + const childCell = childGrid.dataRowList.toArray()[4].cells.toArray()[0]; + GridFunctions.focusCell(fixture, childCell); + + const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; + UIInteractions.triggerEventHandlerKeyDown('arrowdown', childGridContent, false, false, true); + await wait(); + fixture.detectChanges(); + + const childLastRowCell = childGrid.dataRowList.toArray()[4].cells.toArray()[0]; + const selectedCell = fixture.componentInstance.selectedCell; + expect(selectedCell.row.index).toBe(childLastRowCell.row.index); + expect(selectedCell.column.visibleIndex).toBe(childLastRowCell.column.visibleIndex); + expect(selectedCell.column.index).toBe(childLastRowCell.column.index); + expect(selectedCell.value).toBe(childLastRowCell.value); + + const currScrTop = hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop; + expect(currScrTop).toEqual(0); + }); + + it('should allow navigating to top in child grid when child grid target row moves outside the parent view port.', async () => { + hierarchicalGrid.verticalScrollContainer.scrollTo(2); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const childLastRowCell = childGrid.dataRowList.toArray()[9].cells.first; + const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; + GridFunctions.focusCell(fixture, childLastRowCell); + + UIInteractions.triggerEventHandlerKeyDown('arrowup', childGridContent, false, false, true); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + const childFirstRowCell = childGrid.dataRowList.first.cells.first; + const selectedCell = fixture.componentInstance.selectedCell; + expect(selectedCell.row.index).toBe(childFirstRowCell.row.index); + expect(selectedCell.column.visibleIndex).toBe(childFirstRowCell.column.visibleIndex); + expect(selectedCell.column.index).toBe(childFirstRowCell.column.index); + expect(selectedCell.value).toBe(childFirstRowCell.value); + + // check if child row is in view of parent. + const gridOffsets = hierarchicalGrid.tbody.nativeElement.getBoundingClientRect(); + const selectedCellElem = childGrid.gridAPI.get_cell_by_index(selectedCell.row.index, selectedCell.column.field) as IgxGridCellComponent; + const rowOffsets = selectedCellElem.intRow.nativeElement.getBoundingClientRect(); + expect(rowOffsets.top).toBeGreaterThanOrEqual(gridOffsets.top); + expect(rowOffsets.bottom).toBeLessThanOrEqual(gridOffsets.bottom); + }); + + it('should scroll top of child grid into view when pressing Ctrl + Arrow Up when cell is selected in it.', async () => { + hierarchicalGrid.verticalScrollContainer.scrollTo(2); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const childLastRowCell = childGrid.dataRowList.toArray()[9].cells.first; + const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; + GridFunctions.focusCell(fixture, childLastRowCell); + + UIInteractions.triggerEventHandlerKeyDown('arrowup', childGridContent, false, false, true); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + const childFirstRowCell = childGrid.dataRowList.first.cells.first; + const selectedCell = fixture.componentInstance.selectedCell; + expect(selectedCell.row.index).toBe(childFirstRowCell.row.index); + expect(selectedCell.column.visibleIndex).toBe(childFirstRowCell.column.visibleIndex); + expect(selectedCell.column.index).toBe(childFirstRowCell.column.index); + expect(selectedCell.value).toBe(childFirstRowCell.value); + + // check if child row is in view of parent. + const gridOffsets = hierarchicalGrid.tbody.nativeElement.getBoundingClientRect(); + const rowElem = childGrid.gridAPI.get_row_by_index(selectedCell.row.index); + const rowOffsets = rowElem.nativeElement.getBoundingClientRect(); + expect(rowOffsets.top).toBeGreaterThanOrEqual(gridOffsets.top); + expect(rowOffsets.bottom).toBeLessThanOrEqual(gridOffsets.bottom); + }); + + it('when navigating down from parent into child should scroll child grid to top and start navigation from first row.', async () => { + const ri = fixture.componentInstance.rowIsland; + ri.height = '200px'; + fixture.detectChanges(); + + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + childGrid.verticalScrollContainer.scrollTo(9); + await wait(DEBOUNCE_TIME); + fixture.detectChanges(); + + let currScrTop = childGrid.verticalScrollContainer.getScroll().scrollTop; + expect(currScrTop).toBeGreaterThan(0); + + const fCell = hierarchicalGrid.dataRowList.toArray()[0].cells.toArray()[0]; + GridFunctions.focusCell(fixture, fCell); + + UIInteractions.triggerEventHandlerKeyDown('arrowdown', baseHGridContent, false, false, false); + await wait(DEBOUNCE_TIME); + fixture.detectChanges(); + + const childFirstCell = childGrid.dataRowList.toArray()[0].cells.toArray()[0]; + const selectedCell = fixture.componentInstance.selectedCell; + expect(selectedCell.row.index).toBe(childFirstCell.row.index); + expect(selectedCell.column.index).toBe(childFirstCell.column.index); + currScrTop = childGrid.verticalScrollContainer.getScroll().scrollTop; + expect(currScrTop).toBe(0); + }); + + it('when navigating up from parent into child should scroll child grid to bottom and start navigation from last row.', async () => { + const ri = fixture.componentInstance.rowIsland; + ri.height = '200px'; + fixture.detectChanges(); + await wait(); + hierarchicalGrid.verticalScrollContainer.scrollTo(2); + fixture.detectChanges(); + await wait(); + + const parentCell = hierarchicalGrid.gridAPI.get_cell_by_key(1, 'ID'); + GridFunctions.focusCell(fixture, parentCell); + + UIInteractions.triggerEventHandlerKeyDown('arrowup', baseHGridContent, false, false, false); + fixture.detectChanges(); + await wait(); + + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const vertScr = childGrid.verticalScrollContainer.getScroll(); + const currScrTop = vertScr.scrollTop; + // should be scrolled to bottom + expect(currScrTop).toBe(vertScr.scrollHeight - vertScr.clientHeight); + }); + + it('should move activation to last data cell in grid when ctrl+end is used.', async () => { + const parentCell = hierarchicalGrid.dataRowList.first.cells.first; + GridFunctions.focusCell(fixture, parentCell); + + UIInteractions.triggerEventHandlerKeyDown('end', baseHGridContent, false, false, true); + fixture.detectChanges(); + await waitForSelectionChange(hierarchicalGrid); + + const lastDataCell = hierarchicalGrid.getCellByKey(19, 'childData2'); + const selectedCell = fixture.componentInstance.selectedCell; + expect(selectedCell.row.index).toBe(lastDataCell.row.index); + expect(selectedCell.column.index).toBe(lastDataCell.column.index); + }); + + it('if next child cell is not in view should scroll parent so that it is in view.', async () => { + await wait(DEBOUNCE_TIME); + fixture.detectChanges(); + hierarchicalGrid.verticalScrollContainer.scrollTo(4); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + const parentCell = hierarchicalGrid.dataRowList.toArray()[0].cells.toArray()[0]; + GridFunctions.focusCell(fixture, parentCell); + + const prevScroll = hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop; + UIInteractions.triggerEventHandlerKeyDown('arrowdown', baseHGridContent, false, false, false); + fixture.detectChanges(); + await wait(); + + // check if selected row is in view of parent. + const gridOffsets = hierarchicalGrid.tbody.nativeElement.getBoundingClientRect(); + const rowElem = hierarchicalGrid.gridAPI.get_row_by_index(parentCell.row.index); + const rowOffsets = rowElem.nativeElement.getBoundingClientRect(); + expect(rowOffsets.top >= gridOffsets.top && rowOffsets.bottom <= gridOffsets.bottom).toBeTruthy(); + expect(hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop - prevScroll).toBeGreaterThanOrEqual(100); + }); + + it('should expand/collapse hierarchical row using ALT+Arrow Right/ALT+Arrow Left.', async () => { + const parentRow = hierarchicalGrid.dataRowList.first as IgxHierarchicalRowComponent; + expect(parentRow.expanded).toBe(true); + const parentCell = parentRow.cells.first; + GridFunctions.focusCell(fixture, parentCell); + + // collapse + UIInteractions.triggerEventHandlerKeyDown('arrowleft', baseHGridContent, true, false, false); + fixture.detectChanges(); + await wait(); + + expect(parentRow.expanded).toBe(false); + // expand + UIInteractions.triggerEventHandlerKeyDown('arrowright', baseHGridContent, true, false, false); + await wait(); + fixture.detectChanges(); + expect(parentRow.expanded).toBe(true); + }); + + it('should retain active cell when expand/collapse hierarchical row using ALT+Arrow Right/ALT+Arrow Left.', async () => { + // scroll to last row + const lastDataIndex = hierarchicalGrid.dataView.length - 2; + hierarchicalGrid.verticalScrollContainer.scrollTo(lastDataIndex); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + hierarchicalGrid.verticalScrollContainer.scrollTo(lastDataIndex); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + let parentCell = hierarchicalGrid.gridAPI.get_cell_by_index(38, 'ID'); + GridFunctions.focusCell(fixture, parentCell); + + // collapse + UIInteractions.triggerEventHandlerKeyDown('arrowleft', baseHGridContent, true, false, false); + fixture.detectChanges(); + await wait(); + + parentCell = hierarchicalGrid.gridAPI.get_cell_by_index(38, 'ID'); + expect(parentCell.active).toBeTruthy(); + + // expand + UIInteractions.triggerEventHandlerKeyDown('arrowright', baseHGridContent, true, false, false); + fixture.detectChanges(); + await wait(); + + parentCell = hierarchicalGrid.gridAPI.get_cell_by_index(38, 'ID'); + expect(parentCell.active).toBeTruthy(); + }); + + it('should expand/collapse hierarchical row using ALT+Arrow Down/ALT+Arrow Up.', async () => { + const parentRow = hierarchicalGrid.dataRowList.first as IgxHierarchicalRowComponent; + expect(parentRow.expanded).toBe(true); + let parentCell = parentRow.cells.first; + GridFunctions.focusCell(fixture, parentCell); + + // collapse + UIInteractions.triggerEventHandlerKeyDown('arrowup', baseHGridContent, true, false, false); + fixture.detectChanges(); + await wait(); + + expect(parentRow.expanded).toBe(false); + // expand + parentCell = parentRow.cells.first; + UIInteractions.triggerEventHandlerKeyDown('arrowdown', baseHGridContent, true, false, false); + fixture.detectChanges(); + await wait(); + + expect(parentRow.expanded).toBe(true); + }); + + it('should skip child grids that have no data when navigating up/down', async () => { + // set first child to not have data + const child1 = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + child1.data = []; + fixture.detectChanges(); + await wait(); + + const parentRow = hierarchicalGrid.dataRowList.first; + const parentCell = parentRow.cells.first; + GridFunctions.focusCell(fixture, parentCell); + + UIInteractions.triggerEventHandlerKeyDown('arrowdown', baseHGridContent, false, false, false); + fixture.detectChanges(); + await wait(); + + // second data row in parent should be focused + const parentRow2 = hierarchicalGrid.getRowByIndex(2); + const parentCell2 = parentRow2.cells[0]; + + let selectedCell = fixture.componentInstance.selectedCell; + expect(selectedCell.row.index).toBe(parentCell2.row.index); + expect(selectedCell.column.index).toBe(parentCell2.column.index); + + UIInteractions.triggerEventHandlerKeyDown('arrowup', baseHGridContent, false, false, false); + fixture.detectChanges(); + await wait(); + + // first data row in parent should be selected + selectedCell = fixture.componentInstance.selectedCell; + expect(selectedCell.row.index).toBe(parentCell.row.index); + expect(parentCell.selected).toBeTruthy(); + }); + + it('should skip nested child grids that have no data when navigating up/down', async () => { + const child1 = hierarchicalGrid.gridAPI.getChildGrids(false)[0] as IgxHierarchicalGridComponent; + child1.height = '150px'; + await wait(); + fixture.detectChanges(); + const row = child1.dataRowList.first as IgxHierarchicalRowComponent; + row.toggle(); + await wait(); + fixture.detectChanges(); + // set nested child to not have data + const subChild = child1.gridAPI.getChildGrids(false)[0]; + subChild.data = []; + subChild.cdr.detectChanges(); + fixture.detectChanges(); + await wait(); + + const fchildRowCell = row.cells.first; + GridFunctions.focusCell(fixture, fchildRowCell); + + const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; + UIInteractions.triggerEventHandlerKeyDown('arrowdown', childGridContent, false, false, false); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + // second child row should be in view + const sChildRowCell = child1.getRowByIndex(2).cells[0]; + let selectedCell = fixture.componentInstance.selectedCell; + expect(selectedCell.value).toBe(sChildRowCell.value); + + expect(child1.verticalScrollContainer.getScroll().scrollTop).toBeGreaterThanOrEqual(150); + + UIInteractions.triggerEventHandlerKeyDown('arrowup', childGridContent, false, false, false); + await wait(DEBOUNCE_TIME); + fixture.detectChanges(); + + selectedCell = fixture.componentInstance.selectedCell; + expect(selectedCell.row.index).toBe(0); + expect(child1.verticalScrollContainer.getScroll().scrollTop).toBe(0); + }); + + it('should navigate inside summary row with Ctrl + Arrow Right/ Ctrl + Arrow Left', async () => { + const col = hierarchicalGrid.getColumnByName('ID'); + col.hasSummary = true; + fixture.detectChanges(); + + let summaryCells = hierarchicalGrid.summariesRowList.toArray()[0].summaryCells.toArray(); + + const firstCell = summaryCells[0]; + GridFunctions.focusCell(fixture, firstCell); + + const footerContent = fixture.debugElement.queryAll(By.css(GRID_FOOTER_CLASS))[2].children[0]; + UIInteractions.triggerEventHandlerKeyDown('arrowright', footerContent, false, false, true); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + summaryCells = hierarchicalGrid.summariesRowList.toArray()[0].summaryCells.toArray(); + const lastCell = summaryCells.find((s) => s.column.field === 'childData2'); + expect(lastCell.active).toBeTruthy(); + + UIInteractions.triggerEventHandlerKeyDown('arrowleft', footerContent, false, false, true); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + summaryCells = hierarchicalGrid.summariesRowList.toArray()[0].summaryCells.toArray(); + const fCell = summaryCells.find((s) => s.column.field === 'ID'); + expect(fCell.active).toBeTruthy(); + }); + + it('should navigate to Cancel button when there is row in edit mode', async () => { + hierarchicalGrid.columnList.forEach((c) => { + if (c.field !== hierarchicalGrid.primaryKey) { + c.editable = true; + } + }); + + hierarchicalGrid.rowEditable = true; + fixture.detectChanges(); + await wait(); + + const cellElem = hierarchicalGrid.gridAPI.get_cell_by_index(0, 'ID'); + GridFunctions.focusCell(fixture, cellElem); + + UIInteractions.triggerEventHandlerKeyDown('end', baseHGridContent, false, false, false); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + const cell = hierarchicalGrid.gridAPI.get_cell_by_index(0, 'childData2'); + UIInteractions.simulateDoubleClickAndSelectEvent(cell); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + UIInteractions.triggerKeyDownEvtUponElem('tab', cell.nativeElement, true); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + const activeEl = document.activeElement; + expect(activeEl.innerHTML).toEqual('Cancel'); + + UIInteractions.triggerKeyDownEvtUponElem('tab', activeEl, true, false, true); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + expect(document.activeElement.tagName.toLowerCase()).toBe('input'); + }); + + it('should navigate to row edit button "Done" on shift + tab', async () => { + hierarchicalGrid.columnList.forEach((c) => { + if (c.field !== hierarchicalGrid.primaryKey) { + c.editable = true; + } + }); + hierarchicalGrid.rowEditable = true; + fixture.detectChanges(); + await wait(); + + hierarchicalGrid.getColumnByName('ID').hidden = true; + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + hierarchicalGrid.navigateTo(2); + await wait(); + + const cell = hierarchicalGrid.gridAPI.get_cell_by_index(2, 'ChildLevels'); + UIInteractions.simulateDoubleClickAndSelectEvent(cell); + fixture.detectChanges(); + await wait(); + + UIInteractions.triggerKeyDownEvtUponElem('tab', cell.nativeElement, true, false, true); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + const activeEl = document.activeElement; + expect(activeEl.innerHTML).toEqual('Done'); + + UIInteractions.triggerKeyDownEvtUponElem('tab', activeEl, true); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + expect(document.activeElement.tagName.toLowerCase()).toBe('input'); + }); + }); + + + describe('IgxHierarchicalGrid Complex Navigation #hGrid', () => { + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(IgxHierarchicalGridTestComplexComponent); + fixture.detectChanges(); + hierarchicalGrid = fixture.componentInstance.hgrid; + setupHierarchicalGridScrollDetection(fixture, hierarchicalGrid); + baseHGridContent = GridFunctions.getGridContent(fixture); + GridFunctions.focusFirstCell(fixture, hierarchicalGrid); + })); + + afterEach(() => { + clearGridSubs(); + }); + + // complex tests + it('in case prev cell is not in view port should scroll the closest scrollable parent so that cell comes in view.', async () => { + // scroll parent so that child top is not in view + await wait(DEBOUNCE_TIME); + fixture.detectChanges(); + hierarchicalGrid.verticalScrollContainer.addScrollTop(300); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + const child = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const nestedChild = child.gridAPI.getChildGrids(false)[0]; + const nestedChildCell = nestedChild.dataRowList.toArray()[1].cells.toArray()[0]; + + GridFunctions.focusCell(fixture, nestedChildCell); + let oldScrTop = hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop; + fixture.detectChanges(); + + // navigate up + const nestedChildGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[2]; + UIInteractions.triggerEventHandlerKeyDown('arrowup', nestedChildGridContent, false, false, false); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + let nextCell = nestedChild.dataRowList.toArray()[0].cells.toArray()[0]; + let currScrTop = hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop; + const elemHeight = nestedChildCell.intRow.nativeElement.clientHeight; + // check if parent of parent has been scroll up so that the focused cell is in view + expect(oldScrTop - currScrTop).toEqual(elemHeight); + oldScrTop = currScrTop; + + expect(nextCell.selected).toBe(true); + expect(nextCell.active).toBe(true); + expect(nextCell.rowIndex).toBe(0); + + // navigate up into parent + UIInteractions.triggerEventHandlerKeyDown('arrowup', nestedChildGridContent, false, false, false); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + nextCell = child.dataRowList.toArray()[0].cells.toArray()[0]; + currScrTop = hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop; + expect(oldScrTop - currScrTop).toBeGreaterThanOrEqual(100); + + expect(nextCell.selected).toBe(true); + expect(nextCell.active).toBe(true); + expect(nextCell.rowIndex).toBe(0); + }); + + it('in case next cell is not in view port should scroll the closest scrollable parent so that cell comes in view.', async () => { + const child = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const nestedChild = child.gridAPI.getChildGrids(false)[0]; + const nestedChildCell = nestedChild.dataRowList.toArray()[1].cells.toArray()[0]; + + // navigate down in nested child + GridFunctions.focusCell(fixture, nestedChildCell); + + const nestedChildGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[2]; + UIInteractions.triggerEventHandlerKeyDown('arrowdown', nestedChildGridContent, false, false, false); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + // check if parent has scrolled down to show focused cell. + expect(child.verticalScrollContainer.getScroll().scrollTop).toBe(nestedChildCell.intRow.nativeElement.clientHeight); + const nextCell = nestedChild.dataRowList.toArray()[2].cells.toArray()[0]; + + expect(nextCell.selected).toBe(true); + expect(nextCell.active).toBe(true); + expect(nextCell.rowIndex).toBe(2); + }); + + it('should allow navigating up from parent into nested child grid', async () => { + hierarchicalGrid.verticalScrollContainer.scrollTo(2); + await wait(); + fixture.detectChanges(); + + const child = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const lastIndex = child.dataView.length - 1; + child.verticalScrollContainer.scrollTo(lastIndex); + await wait(); + fixture.detectChanges(); + + child.verticalScrollContainer.scrollTo(lastIndex); + await wait(); + fixture.detectChanges(); + + const parentCell = hierarchicalGrid.gridAPI.get_cell_by_index(2, 'ID'); + GridFunctions.focusCell(fixture, parentCell); + + UIInteractions.triggerEventHandlerKeyDown('arrowup', baseHGridContent , false, false, false); + await wait(DEBOUNCE_TIME); + fixture.detectChanges(); + + const nestedChild = child.gridAPI.getChildGrids(false)[5]; + const lastCell = nestedChild.gridAPI.get_cell_by_index(4, 'ID'); + expect(lastCell.selected).toBe(true); + expect(lastCell.active).toBe(true); + expect(lastCell.row.index).toBe(4); + }); + }); + + describe('IgxHierarchicalGrid sibling row islands Navigation #hGrid', () => { + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(IgxHierarchicalGridMultiLayoutComponent); + fixture.detectChanges(); + hierarchicalGrid = fixture.componentInstance.hgrid; + setupHierarchicalGridScrollDetection(fixture, hierarchicalGrid); + baseHGridContent = GridFunctions.getGridContent(fixture); + GridFunctions.focusFirstCell(fixture, hierarchicalGrid); + })); + + afterEach(() => { + clearGridSubs(); + }); + + it('should allow navigating up between sibling child grids.', async () => { + hierarchicalGrid.verticalScrollContainer.scrollTo(2); + fixture.detectChanges(); + await wait(); + + const child1 = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const child2 = hierarchicalGrid.gridAPI.getChildGrids(false)[5]; + + const child2Cell = child2.dataRowList.first.cells.first; + GridFunctions.focusCell(fixture, child2Cell); + + const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[2]; + UIInteractions.triggerEventHandlerKeyDown('arrowup', childGridContent, false, false, false); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + const lastCellPrevRI = child1.dataRowList.last.cells.first; + + expect(lastCellPrevRI.active).toBe(true); + expect(lastCellPrevRI.selected).toBe(true); + expect(lastCellPrevRI.rowIndex).toBe(9); + }); + + it('should allow navigating down between sibling child grids.', async () => { + hierarchicalGrid.verticalScrollContainer.scrollTo(2); + await wait(DEBOUNCE_TIME); + fixture.detectChanges(); + + const child1 = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const child2 = hierarchicalGrid.gridAPI.getChildGrids(false)[5]; + + child1.verticalScrollContainer.scrollTo(child1.dataView.length - 1); + await wait(DEBOUNCE_TIME); + fixture.detectChanges(); + + const child2Cell = child2.dataRowList.toArray()[0].cells.toArray()[0]; + const lastCellPrevRI = child1.dataRowList.last.cells.toArray()[0]; + GridFunctions.focusCell(fixture, lastCellPrevRI); + + const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; + UIInteractions.triggerEventHandlerKeyDown('arrowdown', childGridContent, false, false, false); + await wait(DEBOUNCE_TIME); + fixture.detectChanges(); + + expect(child2Cell.selected).toBe(true); + expect(child2Cell.active).toBe(true); + }); + + it('should navigate up from parent row to the correct child sibling.', async () => { + const parentCell = hierarchicalGrid.dataRowList.toArray()[1].cells.first; + GridFunctions.focusCell(fixture, parentCell); + + // Arrow Up into prev child grid + UIInteractions.triggerEventHandlerKeyDown('arrowup', baseHGridContent, false, false, false); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + const child2 = hierarchicalGrid.gridAPI.getChildGrids(false)[5]; + + const child2Cell = child2.dataRowList.last.cells.first; + expect(child2Cell.selected).toBe(true); + expect(child2Cell.active).toBe(true); + expect(child2Cell.rowIndex).toBe(9); + }); + + it('should navigate down from parent row to the correct child sibling.', async () => { + const parentCell = hierarchicalGrid.dataRowList.first.cells.first; + GridFunctions.focusCell(fixture, parentCell); + + // Arrow down into next child grid + UIInteractions.triggerEventHandlerKeyDown('arrowdown', baseHGridContent, false, false, false); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + const child1 = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const child1Cell = child1.dataRowList.toArray()[0].cells.toArray()[0]; + expect(child1Cell.selected).toBe(true); + expect(child1Cell.active).toBe(true); + expect(child1Cell.rowIndex).toBe(0); + }); + + it('should navigate to last cell in previous child using Arrow Up from last cell of sibling with more columns', async () => { + const childGrid2 = hierarchicalGrid.gridAPI.getChildGrids(false)[5]; + + childGrid2.dataRowList.first.virtDirRow.scrollTo(7); + fixture.detectChanges(); + await wait(); + + const child2LastCell = childGrid2.dataRowList.first.cells.first; + GridFunctions.focusCell(fixture, child2LastCell); + + const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[2]; + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + let childLastCell = childGrid.selectedCells; + expect(childLastCell.length).toBe(0); + + UIInteractions.triggerEventHandlerKeyDown('arrowup', childGridContent, false, false, false); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME * 2); + + childLastCell = childGrid.selectedCells; + expect(childLastCell.length).toBe(1); + expect(childLastCell[0].active).toBeTruthy(); + }); + }); + + describe('IgxHierarchicalGrid Smaller Child Navigation #hGrid', () => { + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(IgxHierarchicalGridSmallerChildComponent); + fixture.detectChanges(); + hierarchicalGrid = fixture.componentInstance.hgrid; + setupHierarchicalGridScrollDetection(fixture, hierarchicalGrid); + baseHGridContent = GridFunctions.getGridContent(fixture); + GridFunctions.focusFirstCell(fixture, hierarchicalGrid); + })); + + afterEach(() => { + clearGridSubs(); + }); + + it('should navigate to last cell in next row for child grid using Arrow Down from last cell of parent with more columns', async () => { + const parentCell = hierarchicalGrid.gridAPI.get_cell_by_index(0, 'Col2'); + GridFunctions.focusCell(fixture, parentCell); + + UIInteractions.triggerEventHandlerKeyDown('arrowdown', baseHGridContent, false, false, false); + + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + // last cell in child should be focused + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const childLastCell = childGrid.gridAPI.get_cell_by_index(0, 'Col1'); + + expect(childLastCell.selected).toBe(true); + expect(childLastCell.active).toBe(true); + }); + + it('should navigate to last cell in next row for child grid using Arrow Up from last cell of parent with more columns', async () => { + hierarchicalGrid.verticalScrollContainer.scrollTo(2); + fixture.detectChanges(); + await wait(); + + const parentCell = hierarchicalGrid.gridAPI.get_cell_by_index(2, 'Col2'); + GridFunctions.focusCell(fixture, parentCell); + + UIInteractions.triggerEventHandlerKeyDown('arrowup', baseHGridContent, false, false, false); + + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + // last cell in child should be focused + const childGrids = fixture.debugElement.queryAll(By.directive(IgxChildGridRowComponent)); + const childGrid = childGrids[1].query(By.directive(IgxHierarchicalGridComponent)).componentInstance; + const childLastCell = childGrid.gridAPI.get_cell_by_index(9, 'ProductName'); + + expect(childLastCell.selected).toBe(true); + expect(childLastCell.active).toBe(true); + }); + + it('should navigate to last cell in next child using Arrow Down from last cell of previous child with more columns', async () => { + const childGrids = fixture.debugElement.queryAll(By.directive(IgxChildGridRowComponent)); + const firstChildGrid = childGrids[0].query(By.directive(IgxHierarchicalGridComponent)).componentInstance; + const secondChildGrid = childGrids[1].query(By.directive(IgxHierarchicalGridComponent)).componentInstance; + + firstChildGrid.verticalScrollContainer.scrollTo(9); + fixture.detectChanges(); + await wait(); + + const firstChildCell = firstChildGrid.gridAPI.get_cell_by_index(9, 'Col1'); + GridFunctions.focusCell(fixture, firstChildCell); + + const childGridContent = fixture.debugElement.queryAll(By.css(GRID_CONTENT_CLASS))[1]; + UIInteractions.triggerEventHandlerKeyDown('arrowdown', childGridContent, false, false, false); + fixture.detectChanges(); + await wait(DEBOUNCE_TIME); + + + const secondChildCell = secondChildGrid.gridAPI.get_cell_by_index(0, 'ProductName'); + expect(secondChildCell.selected).toBe(true); + expect(secondChildCell.active).toBe(true); + }); + }); + + describe('IgxHierarchicalGrid Navigation API #hGrid', () => { + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(IgxHierarchicalGridMultiLayoutComponent); + fixture.detectChanges(); + hierarchicalGrid = fixture.componentInstance.hgrid; + setupHierarchicalGridScrollDetection(fixture, hierarchicalGrid); + baseHGridContent = GridFunctions.getGridContent(fixture); + GridFunctions.focusFirstCell(fixture, hierarchicalGrid); + })); + + afterEach(() => { + clearGridSubs(); + }); + + it('should navigate to exact child grid with navigateToChildGrid.', (done) => { + hierarchicalGrid.primaryKey = 'ID'; + hierarchicalGrid.expandChildren = false; + fixture.detectChanges(); + const path: IPathSegment = { + rowKey: 10, + rowIslandKey: 'childData2', + rowID: 10 + }; + hierarchicalGrid.navigation.navigateToChildGrid([path], () => { + fixture.detectChanges(); + const childGrid = hierarchicalGrid.gridAPI.getChildGrid([path]).nativeElement; + expect(childGrid).not.toBe(undefined); + + const parentBottom = hierarchicalGrid.tbody.nativeElement.getBoundingClientRect().bottom; + const parentTop = hierarchicalGrid.tbody.nativeElement.getBoundingClientRect().top; + // check it's in view within its parent + expect(childGrid.getBoundingClientRect().bottom <= parentBottom && childGrid.getBoundingClientRect().top >= parentTop); + done(); + }); + }); + it('should navigate to exact nested child grid with navigateToChildGrid.', (done) => { + hierarchicalGrid.expandChildren = false; + hierarchicalGrid.primaryKey = 'ID'; + hierarchicalGrid.childLayoutList.toArray()[0].primaryKey = 'ID'; + fixture.detectChanges(); + const targetRoot: IPathSegment = { + rowKey: 10, + rowIslandKey: 'childData', + rowID: 10 + }; + const targetNested: IPathSegment = { + rowKey: 5, + rowIslandKey: 'childData2', + rowID: 5 + }; + + hierarchicalGrid.navigation.navigateToChildGrid([targetRoot, targetNested], () => { + fixture.detectChanges(); + const childGrid = hierarchicalGrid.gridAPI.getChildGrid([targetRoot]).nativeElement; + expect(childGrid).not.toBe(undefined); + const childGridNested = hierarchicalGrid.gridAPI.getChildGrid([targetRoot, targetNested]).nativeElement; + expect(childGridNested).not.toBe(undefined); + + const parentBottom = childGrid.getBoundingClientRect().bottom; + const parentTop = childGrid.getBoundingClientRect().top; + // check it's in view within its parent + expect(childGridNested.getBoundingClientRect().bottom <= parentBottom && childGridNested.getBoundingClientRect().top >= parentTop); + done(); + }); + }); + }); +}); + + +@Component({ + template: ` + + + + + + `, + imports: [IgxHierarchicalGridComponent, IgxRowIslandComponent] +}) +export class IgxHierarchicalGridTestBaseComponent { + @ViewChild('hierarchicalGrid', { read: IgxHierarchicalGridComponent, static: true }) public hgrid: IgxHierarchicalGridComponent; + @ViewChild('rowIsland', { read: IgxRowIslandComponent, static: true }) public rowIsland: IgxRowIslandComponent; + @ViewChild('rowIsland2', { read: IgxRowIslandComponent, static: true }) public rowIsland2: IgxRowIslandComponent; + public data; + public selectedCell; + + constructor() { + // 3 level hierarchy + this.data = this.generateData(20, 3); + } + + public selected(event: IGridCellEventArgs) { + this.selectedCell = event.cell; + } + + public generateData(count: number, level: number) { + const prods = []; + const currLevel = level; + let children; + for (let i = 0; i < count; i++) { + if (level > 0 ) { + children = this.generateData(count / 2 , currLevel - 1); + } + prods.push({ + ID: i, ChildLevels: currLevel, ProductName: 'Product: A' + i, Col1: i, + Col2: i, Col3: i, childData: children, childData2: children }); + } + return prods; + } +} + +@Component({ + template: ` + + + + + + `, + imports: [IgxHierarchicalGridComponent, IgxRowIslandComponent] +}) +export class IgxHierarchicalGridTestComplexComponent extends IgxHierarchicalGridTestBaseComponent { + constructor() { + super(); + // 3 level hierarchy + this.data = this.generateData(20, 3); + } +} + +@Component({ + template: ` + + + + + + + + `, + imports: [IgxHierarchicalGridComponent, IgxRowIslandComponent] +}) +export class IgxHierarchicalGridMultiLayoutComponent extends IgxHierarchicalGridTestBaseComponent {} + +@Component({ + template: ` + + + + + + + + + + + + + + + + + + + + + + + `, + imports: [IgxHierarchicalGridComponent, IgxRowIslandComponent, IgxColumnComponent] +}) +export class IgxHierarchicalGridSmallerChildComponent extends IgxHierarchicalGridTestBaseComponent {} diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.pipes.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.pipes.ts new file mode 100644 index 00000000000..096d0bbc8ec --- /dev/null +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.pipes.ts @@ -0,0 +1,83 @@ +import { Pipe, PipeTransform, inject } from '@angular/core'; +import { GridType, IGX_GRID_BASE } from 'igniteui-angular/grids/core'; +import { cloneArray, columnFieldPath, DataUtil, resolveNestedPath } from 'igniteui-angular/core'; + +/** + * @hidden + */ +@Pipe({ + name: 'gridHierarchical', + standalone: true +}) +export class IgxGridHierarchicalPipe implements PipeTransform { + private grid = inject(IGX_GRID_BASE); + + public transform( + collection: any, + state = new Map(), + id: string, + primaryKey: any, + childKeys: string[], + _pipeTrigger: number + ): any[] { + if (childKeys.length === 0) { + return collection; + } + if (this.grid.verticalScrollContainer.isRemote) { + return collection; + } + const result = this.addHierarchy(this.grid, cloneArray(collection), state, primaryKey, childKeys); + + return result; + } + + public addHierarchy(grid, data: T[], state, primaryKey, childKeys: string[]): T[] { + const result = []; + + data.forEach((v) => { + result.push(v); + const childGridsData = {}; + childKeys.forEach((childKey) => { + if (!v[childKey]) { + v[childKey] = []; + } + const hasNestedPath = childKey?.includes('.'); + const childData = !hasNestedPath ? v[childKey] : resolveNestedPath(v, columnFieldPath(childKey)); + childGridsData[childKey] = childData; + }); + if (grid.gridAPI.get_row_expansion_state(v)) { + result.push({ rowID: primaryKey ? v[primaryKey] : v, childGridsData, parentRowData: v }); + } + }); + return result; + } +} + +/** + * @hidden + */ +@Pipe({ + name: 'gridHierarchicalPaging', + standalone: true +}) +export class IgxGridHierarchicalPagingPipe implements PipeTransform { + private grid = inject(IGX_GRID_BASE); + + + public transform(collection: any[], enabled: boolean, page = 0, perPage = 15, _id: string, _pipeTrigger: number): any[] { + if (!enabled || this.grid.pagingMode !== 'local') { + return collection; + } + + const state = { + index: page, + recordsPerPage: perPage + }; + + const total = this.grid._totalRecords >= 0 ? this.grid._totalRecords : collection.length; + const result: any[] = DataUtil.page(cloneArray(collection), state, total); + this.grid.pagingState = state; + return result; + + } +} diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.selection.spec.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.selection.spec.ts new file mode 100644 index 00000000000..7291eab9e8c --- /dev/null +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.selection.spec.ts @@ -0,0 +1,1573 @@ +import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxHierarchicalGridComponent } from './hierarchical-grid.component'; +import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; +import { IgxHierarchicalRowComponent } from './hierarchical-row.component'; +import { + IgxHierarchicalGridTestBaseComponent, + IgxHierarchicalGridRowSelectionComponent, + IgxHierarchicalGridRowSelectionTestSelectRowOnClickComponent, + IgxHierarchicalGridCustomSelectorsComponent, + IgxHierarchicalGridRowSelectionNoTransactionsComponent, + IgxHierGridExternalAdvancedFilteringComponent +} from '../../../test-utils/hierarchical-grid-components.spec'; +import { GridSelectionFunctions, GridFunctions } from '../../../test-utils/grid-functions.spec'; +import { CellType, GridSelectionMode, IgxGridNavigationService } from 'igniteui-angular/grids/core'; +import { QueryList } from '@angular/core'; +import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; +import { setElementSize } from '../../../test-utils/helper-utils.spec'; +import { IgxStringFilteringOperand, ɵSize } from 'igniteui-angular/core'; + +describe('IgxHierarchicalGrid selection #hGrid', () => { + let fix; + let hierarchicalGrid: IgxHierarchicalGridComponent; + let rowIsland1; + let rowIsland2; + const gridData = SampleTestData.generateHGridData(5, 3); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxHierarchicalGridTestBaseComponent, + IgxHierarchicalGridRowSelectionComponent, + IgxHierarchicalGridRowSelectionTestSelectRowOnClickComponent, + IgxHierarchicalGridCustomSelectorsComponent, + IgxHierarchicalGridRowSelectionNoTransactionsComponent, + IgxHierGridExternalAdvancedFilteringComponent, + ], + providers: [ + IgxGridNavigationService + ] + }).compileComponents(); + })) + + describe('Cell selection', () => { + beforeEach(() => { + fix = TestBed.createComponent(IgxHierarchicalGridTestBaseComponent); + fix.detectChanges(); + hierarchicalGrid = fix.componentInstance.hgrid; + rowIsland1 = fix.componentInstance.rowIsland; + rowIsland2 = fix.componentInstance.rowIsland2; + }); + + it('should allow only one cell to be selected in the whole hierarchical grid.', () => { + hierarchicalGrid.height = '500px'; + hierarchicalGrid.reflow(); + fix.detectChanges(); + + let firstRow = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + firstRow.toggle(); + expect(firstRow.expanded).toBeTruthy(); + + let fCell = firstRow.cells.toArray()[0]; + + // select parent cell + GridFunctions.focusCell(fix, fCell); + fix.detectChanges(); + + expect(fCell.selected).toBeTruthy(); + + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const firstChildRow = childGrid.gridAPI.get_row_by_index(0); + const fChildCell = (firstChildRow.cells as QueryList).toArray()[0]; + + // select child cell + GridFunctions.focusCell(fix, fChildCell); + fix.detectChanges(); + + expect(fChildCell.selected).toBeTruthy(); + expect(fCell.selected).toBeFalsy(); + + // select parent cell + const parentSpy = spyOn(hierarchicalGrid.selected, 'emit').and.callThrough(); + firstRow = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + fCell = firstRow.cells.toArray()[0]; + GridFunctions.focusCell(fix, fCell); + fix.detectChanges(); + expect(fChildCell.selected).toBeFalsy(); + expect(fCell.selected).toBeTruthy(); + expect(parentSpy).toHaveBeenCalledTimes(1); + + GridFunctions.focusCell(fix, fCell); + fix.detectChanges(); + expect(fCell.selected).toBeTruthy(); + expect(parentSpy).toHaveBeenCalledTimes(1); + }); + + it('should be able to set cellSelection mode per grid', () => { + setElementSize(hierarchicalGrid.nativeElement, ɵSize.Small); + fix.detectChanges(); + + const row = hierarchicalGrid.gridAPI.get_row_by_index(3) as IgxHierarchicalRowComponent; + row.toggle(); + expect(row.expanded).toBeTruthy(); + + const childGridLevel1 = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + childGridLevel1.cellSelection = GridSelectionMode.single; + fix.detectChanges(); + const startCell = hierarchicalGrid.gridAPI.get_cell_by_index(2, 'ID'); + + UIInteractions.simulatePointerOverElementEvent('pointerdown', startCell.nativeElement); + fix.detectChanges(); + + let cell = hierarchicalGrid.gridAPI.get_cell_by_index(2, 'ChildLevels'); + UIInteractions.simulatePointerOverElementEvent('pointerenter', cell.nativeElement); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellsRegionSelected(hierarchicalGrid, 2, 2, 0, 1); + + cell = hierarchicalGrid.gridAPI.get_cell_by_index(3, 'ChildLevels'); + UIInteractions.simulatePointerOverElementEvent('pointerenter', cell.nativeElement); + fix.detectChanges(); + UIInteractions.simulatePointerOverElementEvent('pointerup', cell.nativeElement); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellsRegionSelected(hierarchicalGrid, 2, 3, 0, 1); + GridSelectionFunctions.verifySelectedRange(hierarchicalGrid, 2, 3, 0, 1); + expect(startCell.active).toBe(true); + + cell = childGridLevel1.gridAPI.get_cell_by_index(0, 'ChildLevels'); + UIInteractions.simulatePointerOverElementEvent('pointerdown', cell.nativeElement); + fix.detectChanges(); + + cell = childGridLevel1.gridAPI.get_cell_by_index(1, 'ChildLevels'); + UIInteractions.simulatePointerOverElementEvent('pointerenter', cell.nativeElement); + fix.detectChanges(); + + cell = childGridLevel1.gridAPI.get_cell_by_index(3, 'ProductName'); + UIInteractions.simulatePointerOverElementEvent('pointerenter', cell.nativeElement); + fix.detectChanges(); + + UIInteractions.simulatePointerOverElementEvent('pointerup', cell.nativeElement); + fix.detectChanges(); + + expect(hierarchicalGrid.getSelectedRanges().length).toBe(0); + GridSelectionFunctions.verifyCellsRegionSelected(childGridLevel1, 0, 0, 1, 1); + GridSelectionFunctions.verifySelectedRange(childGridLevel1, 0, 0, 1, 1); + expect(startCell.active).toBe(false); + + childGridLevel1.cellSelection = GridSelectionMode.none; + fix.detectChanges(); + + cell = childGridLevel1.gridAPI.get_cell_by_index(2, 'ID'); + UIInteractions.simulateClickAndSelectEvent(cell.nativeElement, true); + fix.detectChanges(); + + expect(cell.active).toBeTrue(); + expect(cell.selected).toBeFalse(); + expect(childGridLevel1.getSelectedRanges().length).toBe(0); + + cell = hierarchicalGrid.gridAPI.get_cell_by_index(2, 'ID'); + + UIInteractions.simulatePointerOverElementEvent('pointerdown', cell.nativeElement); + fix.detectChanges(); + + cell = hierarchicalGrid.gridAPI.get_cell_by_index(2, 'ChildLevels'); + UIInteractions.simulatePointerOverElementEvent('pointerenter', cell.nativeElement); + fix.detectChanges(); + UIInteractions.simulatePointerOverElementEvent('pointerup', cell.nativeElement); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellsRegionSelected(hierarchicalGrid, 2, 2, 0, 1); + GridSelectionFunctions.verifySelectedRange(hierarchicalGrid, 2, 2, 0, 1); + }); + + it('should allow to select multiple cells in the same grid on mouse drag', () => { + setElementSize(hierarchicalGrid.nativeElement, ɵSize.Small); + fix.detectChanges(); + + const row = hierarchicalGrid.gridAPI.get_row_by_index(3) as IgxHierarchicalRowComponent; + row.toggle(); + expect(row.expanded).toBeTruthy(); + + const startCell = hierarchicalGrid.gridAPI.get_cell_by_index(1, 'ID'); + + UIInteractions.simulatePointerOverElementEvent('pointerdown', startCell.nativeElement); + fix.detectChanges(); + + let cell = hierarchicalGrid.gridAPI.get_cell_by_index(1, 'ChildLevels'); + UIInteractions.simulatePointerOverElementEvent('pointerenter', cell.nativeElement); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellsRegionSelected(hierarchicalGrid, 1, 1, 0, 1); + + cell = hierarchicalGrid.gridAPI.get_cell_by_index(2, 'ChildLevels'); + UIInteractions.simulatePointerOverElementEvent('pointerenter', cell.nativeElement); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellsRegionSelected(hierarchicalGrid, 1, 2, 0, 1); + + cell = hierarchicalGrid.gridAPI.get_cell_by_index(3, 'ChildLevels'); + UIInteractions.simulatePointerOverElementEvent('pointerenter', cell.nativeElement); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellsRegionSelected(hierarchicalGrid, 1, 3, 0, 1); + + hierarchicalGrid.navigateTo(5, -1); + fix.detectChanges(); + + cell = hierarchicalGrid.gridAPI.get_cell_by_index(5, 'ProductName'); + UIInteractions.simulatePointerOverElementEvent('pointerenter', cell.nativeElement); + fix.detectChanges(); + + UIInteractions.simulatePointerOverElementEvent('pointerup', cell.nativeElement); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellsRegionSelected(hierarchicalGrid, 1, 5, 0, 2); + GridSelectionFunctions.verifySelectedRange(hierarchicalGrid, 1, 5, 0, 2); + }); + + it('should NOT allow to select multiple cells in multiple grids on mouse drag', () => { + setElementSize(hierarchicalGrid.nativeElement, ɵSize.Small); + fix.detectChanges(); + + const row = hierarchicalGrid.gridAPI.get_row_by_index(3) as IgxHierarchicalRowComponent; + row.toggle(); + expect(row.expanded).toBeTruthy(); + + const startCell = hierarchicalGrid.gridAPI.get_cell_by_index(2, 'ID'); + + UIInteractions.simulatePointerOverElementEvent('pointerdown', startCell.nativeElement); + fix.detectChanges(); + + let cell = hierarchicalGrid.gridAPI.get_cell_by_index(2, 'ChildLevels'); + UIInteractions.simulatePointerOverElementEvent('pointerenter', cell.nativeElement); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellsRegionSelected(hierarchicalGrid, 2, 2, 0, 1); + + cell = hierarchicalGrid.gridAPI.get_cell_by_index(3, 'ChildLevels'); + UIInteractions.simulatePointerOverElementEvent('pointerenter', cell.nativeElement); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellsRegionSelected(hierarchicalGrid, 2, 3, 0, 1); + + const childGridLevel1 = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + cell = childGridLevel1.gridAPI.get_cell_by_index(0, 'ChildLevels'); + UIInteractions.simulatePointerOverElementEvent('pointerenter', cell.nativeElement); + fix.detectChanges(); + + cell = childGridLevel1.gridAPI.get_cell_by_index(1, 'ChildLevels'); + UIInteractions.simulatePointerOverElementEvent('pointerenter', cell.nativeElement); + fix.detectChanges(); + + cell = hierarchicalGrid.gridAPI.get_cell_by_index(3, 'ProductName'); + UIInteractions.simulatePointerOverElementEvent('pointerenter', cell.nativeElement); + fix.detectChanges(); + + UIInteractions.simulatePointerOverElementEvent('pointerup', cell.nativeElement); + fix.detectChanges(); + + expect(childGridLevel1.getSelectedRanges().length).toBe(0); + GridSelectionFunctions.verifyCellsRegionSelected(hierarchicalGrid, 2, 3, 0, 2); + GridSelectionFunctions.verifySelectedRange(hierarchicalGrid, 2, 3, 0, 2); + expect(startCell.active).toBe(true); + }); + + it('should be able to select range with shift + arrow keys in the parent grid', fakeAsync(() => { + setElementSize(hierarchicalGrid.nativeElement, ɵSize.Small); + fix.detectChanges(); + + let cell = hierarchicalGrid.gridAPI.get_cell_by_index(1, 'ChildLevels'); + + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('arrowright', cell.nativeElement, true, false, true, false); + tick(100); + fix.detectChanges(); + + cell = hierarchicalGrid.gridAPI.get_cell_by_index(1, 'ProductName'); + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', cell.nativeElement, true, false, true, false); + tick(100); + fix.detectChanges(); + + cell = hierarchicalGrid.gridAPI.get_cell_by_index(2, 'ProductName'); + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', cell.nativeElement, true, false, true, false); + tick(100); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellSelected(cell, true); + expect(cell.active).toBeFalse(); + GridSelectionFunctions.verifyCellsRegionSelected(hierarchicalGrid, 1, 3, 1, 2); + GridSelectionFunctions.verifySelectedRange(hierarchicalGrid, 1, 3, 1, 2); + })); + + it('should be able to select range with shift + arrow keys in the child grid', fakeAsync(() => { + setElementSize(hierarchicalGrid.nativeElement, ɵSize.Small); + fix.detectChanges(); + + const row = hierarchicalGrid.gridAPI.get_row_by_index(1) as IgxHierarchicalRowComponent; + row.toggle(); + expect(row.expanded).toBeTruthy(); + + const childGridLevel1 = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + + let cell = childGridLevel1.gridAPI.get_cell_by_index(1, 'ChildLevels'); + + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('arrowright', cell.nativeElement, true, false, true, false); + tick(100); + fix.detectChanges(); + + cell = childGridLevel1.gridAPI.get_cell_by_index(1, 'ProductName'); + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', cell.nativeElement, true, false, true, false); + tick(100); + fix.detectChanges(); + + cell = childGridLevel1.gridAPI.get_cell_by_index(2, 'ProductName'); + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', cell.nativeElement, true, false, true, false); + tick(100); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellSelected(cell, true); + expect(cell.active).toBeFalse(); + GridSelectionFunctions.verifyCellsRegionSelected(childGridLevel1, 1, 3, 1, 2); + GridSelectionFunctions.verifySelectedRange(childGridLevel1, 1, 3, 1, 2); + })); + + it('should be able to select range with shift + mouse click and skip the child grid', () => { + setElementSize(hierarchicalGrid.nativeElement, ɵSize.Small); + fix.detectChanges(); + + const forthRow = hierarchicalGrid.gridAPI.get_row_by_index(2) as IgxHierarchicalRowComponent; + forthRow.toggle(); + expect(forthRow.expanded).toBeTruthy(); + + let cell = hierarchicalGrid.gridAPI.get_cell_by_index(1, 'ChildLevels'); + + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + expect(cell.selected).toBeTrue(); + expect(cell.active).toBeTrue(); + + hierarchicalGrid.navigateTo(5, -1); + fix.detectChanges(); + + cell = hierarchicalGrid.gridAPI.get_cell_by_index(5, 'ProductName'); + UIInteractions.simulateClickAndSelectEvent(cell, true); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellSelected(cell, true); + expect(cell.active).toBeTrue(); + GridSelectionFunctions.verifyCellsRegionSelected(hierarchicalGrid, 1, 5, 1, 2); + GridSelectionFunctions.verifySelectedRange(hierarchicalGrid, 1, 5, 1, 2); + }); + + it('should be able to select multiple ranges holding ctrl key', () => { + setElementSize(hierarchicalGrid.nativeElement, ɵSize.Small); + fix.detectChanges(); + + const forthRow = hierarchicalGrid.gridAPI.get_row_by_index(2) as IgxHierarchicalRowComponent; + forthRow.toggle(); + expect(forthRow.expanded).toBeTruthy(); + + let cell = hierarchicalGrid.gridAPI.get_cell_by_index(1, 'ChildLevels'); + + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + expect(cell.selected).toBeTrue(); + expect(cell.active).toBeTrue(); + + hierarchicalGrid.navigateTo(5, -1); + fix.detectChanges(); + + cell = hierarchicalGrid.gridAPI.get_cell_by_index(5, 'ProductName'); + UIInteractions.simulateClickAndSelectEvent(cell, true); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellSelected(cell, true); + expect(cell.active).toBeTrue(); + GridSelectionFunctions.verifyCellsRegionSelected(hierarchicalGrid, 1, 5, 1, 2); + GridSelectionFunctions.verifySelectedRange(hierarchicalGrid, 1, 5, 1, 2); + + cell = hierarchicalGrid.gridAPI.get_cell_by_index(5, 'ID'); + UIInteractions.simulateClickAndSelectEvent(cell, false, true); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(hierarchicalGrid, 1, 5, 1, 2, 0, 2); + GridSelectionFunctions.verifySelectedRange(hierarchicalGrid, 5, 5, 0, 0, 1, 2); + }); + + it('should NOT be able to create multiple ranges in multiple grids holding ctrl key', () => { + setElementSize(hierarchicalGrid.nativeElement, ɵSize.Small); + fix.detectChanges(); + const row = hierarchicalGrid.gridAPI.get_row_by_index(2) as IgxHierarchicalRowComponent; + + row.toggle(); + expect(row.expanded).toBeTruthy(); + const childGridLevel1 = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + + let cell = hierarchicalGrid.gridAPI.get_cell_by_index(2, 'ChildLevels'); + + UIInteractions.simulateClickAndSelectEvent(cell, false, true); + fix.detectChanges(); + + cell = childGridLevel1.gridAPI.get_cell_by_index(0, 'ProductName'); + UIInteractions.simulateClickAndSelectEvent(cell, false, true); + fix.detectChanges(); + + expect(hierarchicalGrid.getSelectedRanges().length).toBe(0); + expect(cell.selected).toBeTrue(); + GridSelectionFunctions.verifySelectedRange(childGridLevel1, 0, 0, 2, 2); + + cell = hierarchicalGrid.gridAPI.get_cell_by_index(0, 'ProductName'); + + UIInteractions.simulateClickAndSelectEvent(cell, false, true); + fix.detectChanges(); + + expect(childGridLevel1.getSelectedRanges().length).toBe(0); + GridSelectionFunctions.verifySelectedRange(hierarchicalGrid, 0, 0, 2, 2); + }); + + it('should clear the selection in parent grid when continue navigation in the child grid', fakeAsync(() => { + setElementSize(hierarchicalGrid.nativeElement, ɵSize.Small) + fix.detectChanges(); + + const row = hierarchicalGrid.gridAPI.get_row_by_index(4) as IgxHierarchicalRowComponent; + row.toggle(); + expect(row.expanded).toBeTruthy(); + + let cell = hierarchicalGrid.gridAPI.get_cell_by_index(1, 'ChildLevels'); + + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + expect(cell.selected).toBeTrue(); + expect(cell.active).toBeTrue(); + + cell = hierarchicalGrid.gridAPI.get_cell_by_index(4, 'ChildLevels'); + + UIInteractions.simulateClickAndSelectEvent(cell, true); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellsRegionSelected(hierarchicalGrid, 1, 4, 1, 1); + GridSelectionFunctions.verifySelectedRange(hierarchicalGrid, 1, 4, 1, 1); + + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', cell.nativeElement, true, false, true, false); + tick(30); + fix.detectChanges(); + + expect(hierarchicalGrid.getSelectedRanges().length).toBe(0); + })); + + it('should NOT be able to create range selection between parent and child grid on mouse click + shift key', fakeAsync(() => { + setElementSize(hierarchicalGrid.nativeElement, ɵSize.Small) + fix.detectChanges(); + + const row = hierarchicalGrid.gridAPI.get_row_by_index(2) as IgxHierarchicalRowComponent; + row.toggle(); + expect(row.expanded).toBeTruthy(); + + let cell = hierarchicalGrid.gridAPI.get_cell_by_index(2, 'ChildLevels'); + + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(hierarchicalGrid, 2, 2, 1, 1); + + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', cell.nativeElement, true, false, true, false); + tick(30); + fix.detectChanges(); + + expect(hierarchicalGrid.getSelectedRanges().length).toBe(0); + + cell = hierarchicalGrid.gridAPI.get_cell_by_index(0, 'ProductName'); + + UIInteractions.simulateClickAndSelectEvent(cell, true); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(hierarchicalGrid, 0, 0, 2, 2); + })); + + it('Should not trigger range selection when CellTemplate is used and the user clicks on element inside it', () => { + fix = TestBed.createComponent(IgxHierGridExternalAdvancedFilteringComponent); + fix.detectChanges(); + + const component = fix.componentInstance; + hierarchicalGrid = fix.componentInstance.hgrid; + + expect(component.customCell).toBeDefined(); + + const column = hierarchicalGrid.getColumnByName('ID'); + column.bodyTemplate = component.customCell; + fix.detectChanges(); + + const selectionChangeSpy = spyOn(hierarchicalGrid.rangeSelected, 'emit').and.callThrough(); + const cell = hierarchicalGrid.gridAPI.get_cell_by_index(0, 'ID'); + const cellElement = cell.nativeElement; + const span = cellElement.querySelector('span'); + + expect(span).not.toBeNull(); + + UIInteractions.simulateClickAndSelectEvent(span); + fix.detectChanges(); + expect(selectionChangeSpy).not.toHaveBeenCalled(); + }); + }); + + describe('Row Selection', () => { + beforeEach(() => { + fix = TestBed.createComponent(IgxHierarchicalGridRowSelectionComponent); + fix.detectChanges(); + hierarchicalGrid = fix.componentInstance.hgrid; + rowIsland1 = fix.componentInstance.rowIsland; + rowIsland2 = fix.componentInstance.rowIsland2; + }); + + it('should have checkboxes on each row', fakeAsync(() => { + hierarchicalGrid.expandChildren = true; + tick(100); + fix.detectChanges(); + rowIsland1.expandChildren = true; + tick(100); + fix.detectChanges(); + + expect(hierarchicalGrid.rowSelection).toEqual(GridSelectionMode.multiple); + GridSelectionFunctions.verifyHeaderRowHasCheckbox(fix); + GridSelectionFunctions.verifySelectionCheckBoxesAlignment(hierarchicalGrid); + + for (const r of hierarchicalGrid.dataRowList.toArray()) { + GridSelectionFunctions.verifyRowHasCheckbox(r.nativeElement); + } + + let childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + expect(childGrid.rowSelection).toBe(GridSelectionMode.single); + GridSelectionFunctions.verifyHeaderRowHasCheckbox(childGrid, false); + GridSelectionFunctions.verifySelectionCheckBoxesAlignment(childGrid); + + for (const r of childGrid.dataRowList.toArray()) { + GridSelectionFunctions.verifyRowHasCheckbox(r.nativeElement); + } + + childGrid = childGrid.gridAPI.getChildGrids(false)[0]; + expect(childGrid.rowSelection).toBe(GridSelectionMode.none); + GridSelectionFunctions.verifyHeaderRowHasCheckbox(childGrid, false, false); + + for (const r of childGrid.dataRowList.toArray()) { + GridSelectionFunctions.verifyRowHasCheckbox(r.nativeElement, false, false); + } + })); + + it('should able to change rowSelection at runtime', async() => { + hierarchicalGrid.width = '1000px'; + fix.detectChanges(); + hierarchicalGrid.expandChildren = true; + fix.detectChanges(); + rowIsland1.expandChildren = true; + fix.detectChanges(); + await wait(30); + fix.detectChanges(); + + const childGridLevel1 = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const childGridLevel2 = childGridLevel1.gridAPI.getChildGrids(false)[0]; + + hierarchicalGrid.selectAllRows(); + childGridLevel1.selectedRows = ['00']; + fix.detectChanges(); + await wait(30); + fix.detectChanges(); + + // Change row selection for grids + hierarchicalGrid.rowSelection = GridSelectionMode.none; + rowIsland1.rowSelection = GridSelectionMode.multiple; + rowIsland2.rowSelection = GridSelectionMode.single; + fix.detectChanges(); + await wait(30); + fix.detectChanges(); + + expect(hierarchicalGrid.rowSelection).toBe(GridSelectionMode.none); + expect(hierarchicalGrid.selectedRows).toEqual([]); + GridSelectionFunctions.verifyHeaderRowHasCheckbox(hierarchicalGrid, false, false); + for (const r of hierarchicalGrid.dataRowList.toArray()) { + GridSelectionFunctions.verifyRowHasCheckbox(r.nativeElement, false, false); + } + + expect(childGridLevel1.rowSelection).toBe(GridSelectionMode.multiple); + expect(childGridLevel1.selectedRows).toEqual([]); + GridSelectionFunctions.verifyHeaderRowHasCheckbox(childGridLevel1); + for (const r of childGridLevel1.dataRowList.toArray()) { + GridSelectionFunctions.verifyRowHasCheckbox(r.nativeElement); + } + GridSelectionFunctions.verifySelectionCheckBoxesAlignment(childGridLevel1); + + expect(childGridLevel2.rowSelection).toBe(GridSelectionMode.single); + expect(childGridLevel2.selectedRows).toEqual([]); + GridSelectionFunctions.verifyHeaderRowHasCheckbox(childGridLevel2, false); + for (const r of childGridLevel2.dataRowList.toArray()) { + GridSelectionFunctions.verifyRowHasCheckbox(r.nativeElement); + } + GridSelectionFunctions.verifySelectionCheckBoxesAlignment(childGridLevel2); + }); + + it('should able to change showRowCheckboxes at runtime', () => { + hierarchicalGrid.expandChildren = true; + fix.detectChanges(); + + hierarchicalGrid.hideRowSelectors = true; + rowIsland1.hideRowSelectors = true; + fix.detectChanges(); + + expect(hierarchicalGrid.hideRowSelectors).toBe(true); + GridSelectionFunctions.verifyHeaderRowHasCheckbox(hierarchicalGrid, false, false); + for (const r of hierarchicalGrid.dataRowList.toArray()) { + GridSelectionFunctions.verifyRowHasCheckbox(r.nativeElement, false, false); + } + + const childGridLevel1 = hierarchicalGrid.gridAPI.getChildGrids(false)[0] as IgxHierarchicalGridComponent; + expect(childGridLevel1.hideRowSelectors).toBe(true); + GridSelectionFunctions.verifyHeaderRowHasCheckbox(childGridLevel1, false, false); + for (const r of childGridLevel1.dataRowList.toArray()) { + GridSelectionFunctions.verifyRowHasCheckbox(r.nativeElement, false, false); + } + + hierarchicalGrid.hideRowSelectors = false; + rowIsland1.hideRowSelectors = false; + fix.detectChanges(); + + expect(hierarchicalGrid.hideRowSelectors).toBe(false); + GridSelectionFunctions.verifyHeaderRowHasCheckbox(hierarchicalGrid); + for (const r of hierarchicalGrid.dataRowList.toArray()) { + GridSelectionFunctions.verifyRowHasCheckbox(r.nativeElement); + } + + expect(childGridLevel1.hideRowSelectors).toBe(false); + GridSelectionFunctions.verifyHeaderRowHasCheckbox(childGridLevel1, false); + for (const r of childGridLevel1.dataRowList.toArray()) { + GridSelectionFunctions.verifyRowHasCheckbox(r.nativeElement); + } + }); + + it('should have fire event rowSelectionChanging', () => { + hierarchicalGrid.expandChildren = true; + fix.detectChanges(); + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const secondChildGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[1]; + const parentSpy = spyOn(hierarchicalGrid.rowSelectionChanging, 'emit').and.callThrough(); + const childSpy = spyOn(childGrid.rowSelectionChanging, 'emit').and.callThrough(); + const secondChildSpy = spyOn(secondChildGrid.rowSelectionChanging, 'emit').and.callThrough(); + const mockEvent = new MouseEvent('click'); + + // Click on a row in child grid + let row = childGrid.gridAPI.get_row_by_index(0); + row.nativeElement.dispatchEvent(mockEvent); + fix.detectChanges(); + + expect(secondChildSpy).toHaveBeenCalledTimes(0); + expect(parentSpy).toHaveBeenCalledTimes(0); + expect(childSpy).toHaveBeenCalledTimes(1); + expect(childSpy).toHaveBeenCalledWith({ + added: [gridData[0].childData[0]], + cancel: false, + event: mockEvent, + newSelection: [gridData[0].childData[0]], + oldSelection: [], + removed: [], + owner: childGrid, + allRowsSelected: false, + }); + + // Click on checkbox on second row + GridSelectionFunctions.getRowCheckboxDiv(childGrid.gridAPI.get_row_by_index(1).nativeElement).dispatchEvent(mockEvent); + fix.detectChanges(); + + expect(secondChildSpy).toHaveBeenCalledTimes(0); + expect(parentSpy).toHaveBeenCalledTimes(0); + expect(childSpy).toHaveBeenCalledTimes(2); + expect(childSpy).toHaveBeenCalledWith({ + added: [gridData[0].childData[1]], + cancel: false, + event: mockEvent, + newSelection: [gridData[0].childData[1]], + oldSelection: [gridData[0].childData[0]], + removed: [gridData[0].childData[0]], + owner: childGrid, + allRowsSelected: false + }); + + // Click on a row in parent grid + row = hierarchicalGrid.gridAPI.get_row_by_index(2); + row.nativeElement.dispatchEvent(mockEvent); + fix.detectChanges(); + + expect(secondChildSpy).toHaveBeenCalledTimes(0); + expect(childSpy).toHaveBeenCalledTimes(2); + expect(parentSpy).toHaveBeenCalledTimes(1); + expect(parentSpy).toHaveBeenCalledWith({ + added: [gridData[1]], + cancel: false, + event: mockEvent, + newSelection: [gridData[1]], + oldSelection: [], + removed: [], + allRowsSelected: false, + owner: hierarchicalGrid + }); + + // Click on a header checkbox in parent grid + GridSelectionFunctions.getRowCheckboxDiv(GridSelectionFunctions.getHeaderRow(hierarchicalGrid)).dispatchEvent(mockEvent); + fix.detectChanges(); + + expect(secondChildSpy).toHaveBeenCalledTimes(0); + expect(childSpy).toHaveBeenCalledTimes(2); + expect(parentSpy).toHaveBeenCalledTimes(2); + expect(parentSpy).toHaveBeenCalledWith({ + added: [gridData[0], gridData[2], gridData[3], gridData[4]], + cancel: false, + event: mockEvent, + newSelection: [gridData[1], gridData[0], gridData[2], gridData[3], gridData[4]], + oldSelection: [gridData[1]], + removed: [], + allRowsSelected: true, + owner: hierarchicalGrid + }); + }); + it('should be able to select multiple rows only on checkbox click when selectRowOnClick is disabled', () => { + // Click first row expand button + hierarchicalGrid.selectRowOnClick = false; + const firstRow = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + firstRow.toggle(); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false); + // Click on the first row + UIInteractions.simulateClickEvent(firstRow.nativeElement); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false); + expect(hierarchicalGrid.selectedRows).toEqual([]); + + // Click on the first row checkbox + GridSelectionFunctions.clickRowCheckbox(firstRow); + fix.detectChanges(); + expect(hierarchicalGrid.selectedRows).toEqual(['0']); + + const secondRow = hierarchicalGrid.gridAPI.get_row_by_index(4); + GridSelectionFunctions.clickRowCheckbox(secondRow); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(secondRow); + expect(hierarchicalGrid.selectedRows).toEqual(['0', '3']); + }); + + it('should able to select multiple rows with Shift and click', () => { + expect(hierarchicalGrid.selectRowOnClick).toBe(true); + // Click first row expand button + const firstRow = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + firstRow.toggle(); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false); + + UIInteractions.simulateClickEvent(firstRow.nativeElement); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + expect(hierarchicalGrid.selectedRows).toEqual(['0']); + + const fourthRow = hierarchicalGrid.gridAPI.get_row_by_index(4); + UIInteractions.simulateClickEvent(fourthRow.nativeElement, true); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowsArraySelected( + [firstRow, hierarchicalGrid.gridAPI.get_row_by_index(2), hierarchicalGrid.gridAPI.get_row_by_index(3), fourthRow]); + expect(hierarchicalGrid.selectedRows).toEqual(['0', '1', '2', '3']); + + // Verify no rows are selected in the child grid + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + expect(childGrid.selectedRows).toEqual([]); + for (const r of childGrid.dataRowList.toArray()) { + GridSelectionFunctions.verifyRowSelected(r, false); + } + }); + + it('should NOT be able to select multiple rows with Shift and click when selectRowOnClick is disabled', () => { + hierarchicalGrid.selectRowOnClick = false; + // Click first row expand button + const firstRow = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + firstRow.toggle(); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false); + + UIInteractions.simulateClickEvent(firstRow.nativeElement); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false); + expect(hierarchicalGrid.selectedRows).toEqual([]); + + const fourthRow = hierarchicalGrid.gridAPI.get_row_by_index(4); + UIInteractions.simulateClickEvent(fourthRow.nativeElement, true); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowsArraySelected( + [firstRow, hierarchicalGrid.gridAPI.get_row_by_index(2), hierarchicalGrid.gridAPI.get_row_by_index(3), fourthRow], false); + expect(hierarchicalGrid.selectedRows).toEqual([]); + + // Verify no rows are selected in the child grid + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + // Row Islands selectRowOnClick should be true by default + expect(childGrid.selectRowOnClick).toBe(true); + + expect(childGrid.selectedRows).toEqual([]); + for (const r of childGrid.dataRowList.toArray()) { + GridSelectionFunctions.verifyRowSelected(r, false); + } + }); + + it('should able to select multiple rows with Ctrl and click', () => { + expect(hierarchicalGrid.selectRowOnClick).toBe(true); + // Expand first row + const firstRow = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + firstRow.toggle(); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false); + + UIInteractions.simulateClickEvent(firstRow.nativeElement); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + expect(hierarchicalGrid.selectedRows).toEqual(['0']); + + const fourthRow = hierarchicalGrid.gridAPI.get_row_by_index(4); + UIInteractions.simulateClickEvent(fourthRow.nativeElement, false, true); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowsArraySelected([firstRow, fourthRow]); + expect(hierarchicalGrid.selectedRows).toEqual(['0', '3']); + + UIInteractions.simulateClickEvent(fourthRow.nativeElement, false, true); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(fourthRow, false); + expect(hierarchicalGrid.selectedRows).toEqual(['0']); + + // Click on a row in the child grid + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + expect(childGrid.selectedRows).toEqual([]); + + const childGridFirstRow = childGrid.gridAPI.get_row_by_index(2); + UIInteractions.simulateClickEvent(childGridFirstRow.nativeElement, false, true); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + expect(hierarchicalGrid.selectedRows).toEqual(['0']); + GridSelectionFunctions.verifyRowSelected(childGridFirstRow); + expect(childGrid.selectedRows).toEqual(['02']); + }); + it('should NOT be able to select multiple rows with Ctrl and click when selectRowOnClick is disabled', () => { + fix = TestBed.createComponent(IgxHierarchicalGridRowSelectionTestSelectRowOnClickComponent); + fix.detectChanges(); + hierarchicalGrid = fix.componentInstance.hgrid; + rowIsland1 = fix.componentInstance.rowIsland; + rowIsland2 = fix.componentInstance.rowIsland2; + expect(hierarchicalGrid.selectRowOnClick).toBe(false); + // Expand first row + const firstRow = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + firstRow.toggle(); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false); + + UIInteractions.simulateClickEvent(firstRow.nativeElement); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false); + expect(hierarchicalGrid.selectedRows).toEqual([]); + + const fourthRow = hierarchicalGrid.gridAPI.get_row_by_index(4); + UIInteractions.simulateClickEvent(fourthRow.nativeElement, false, true); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowsArraySelected([]); + expect(hierarchicalGrid.selectedRows).toEqual([]); + + // Click on a row in the child grid + const childGrid1 = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + expect(childGrid1.selectedRows).toEqual([]); + // Row Islands selectRowOnClick should be true by default + expect(childGrid1.selectRowOnClick).toBe(true); + + const childGrid1FirstRow = childGrid1.gridAPI.get_row_by_index(2); + UIInteractions.simulateClickEvent(childGrid1FirstRow.nativeElement, false, true); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowsArraySelected([]); + expect(hierarchicalGrid.selectedRows).toEqual([]); + GridSelectionFunctions.verifyRowSelected(childGrid1FirstRow); + expect(childGrid1.selectedRows).toEqual(['02']); + + // Deselect selected rows in the child grid + GridSelectionFunctions.clickRowCheckbox(childGrid1FirstRow); + fix.detectChanges(); + GridSelectionFunctions.verifyRowSelected(childGrid1FirstRow, false); + expect(childGrid1.selectedRows).toEqual([]); + + // Disable the selectRowOnClick of the second child -> should not be able to select on click + childGrid1.selectRowOnClick = false; + + UIInteractions.simulateClickEvent(childGrid1FirstRow.nativeElement, false, true); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowsArraySelected([]); + expect(hierarchicalGrid.selectedRows).toEqual([]); + GridSelectionFunctions.verifyRowSelected(childGrid1FirstRow, false); + expect(childGrid1.selectedRows).toEqual([]); + }); + + it('should able to select only one row when rowSelection is single', () => { + expect(hierarchicalGrid.selectRowOnClick).toBe(true); + // Expand first row + const row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + row.toggle(); + fix.detectChanges(); + + // Click on a row in the child grid + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + expect(childGrid.selectedRows).toEqual([]); + + const firstRow = childGrid.gridAPI.get_row_by_index(0); + const secondRow = childGrid.gridAPI.get_row_by_index(2); + + UIInteractions.simulateClickEvent(firstRow.nativeElement); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + expect(childGrid.selectedRows).toEqual(['00']); + + // Click on second row holding Ctrl + UIInteractions.simulateClickEvent(secondRow.nativeElement, false, true); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyRowSelected(secondRow); + expect(childGrid.selectedRows).toEqual(['02']); + + // Click on first row holding Shift key + UIInteractions.simulateClickEvent(firstRow.nativeElement, true); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + GridSelectionFunctions.verifyRowSelected(secondRow, false); + expect(childGrid.selectedRows).toEqual(['00']); + + // Click on second row checkbox + GridSelectionFunctions.clickRowCheckbox(secondRow); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyRowSelected(secondRow); + expect(childGrid.selectedRows).toEqual(['02']); + }); + it('should NOT be able to select a row with click when rowSelection is single and selectRowOnClick is disabled', () => { + hierarchicalGrid.selectRowOnClick = false; + // Expand first row + const row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + row.toggle(); + fix.detectChanges(); + + expect(hierarchicalGrid.selectedRows).toEqual([]); + UIInteractions.simulateClickEvent(row.nativeElement); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(row, false); + expect(hierarchicalGrid.selectedRows).toEqual([]); + + // Click on a row in the child grid + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + childGrid.selectRowOnClick = false; + expect(childGrid.selectedRows).toEqual([]); + + const firstRow = childGrid.gridAPI.get_row_by_index(0); + const secondRow = childGrid.gridAPI.get_row_by_index(2); + + UIInteractions.simulateClickEvent(firstRow.nativeElement); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false); + expect(childGrid.selectedRows).toEqual([]); + + // Click on second row holding Ctrl + UIInteractions.simulateClickEvent(secondRow.nativeElement, false, true); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyRowSelected(secondRow, false); + expect(childGrid.selectedRows).toEqual([]); + + // Click on first row holding Shift key + UIInteractions.simulateClickEvent(firstRow.nativeElement, true); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyRowSelected(secondRow, false); + expect(childGrid.selectedRows).toEqual([]); + + // Click on second row checkbox + GridSelectionFunctions.clickRowCheckbox(secondRow); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyRowSelected(secondRow); + expect(childGrid.selectedRows).toEqual(['02']); + }); + + it('should able to select/deselect all rows by clicking on the header checkbox', () => { + // Set multiple selection to first row island + rowIsland1.rowSelection = GridSelectionMode.multiple; + fix.detectChanges(); + + // Expand first row + let row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + row.toggle(); + fix.detectChanges(); + + // Expand second row + row = hierarchicalGrid.gridAPI.get_row_by_index(2) as IgxHierarchicalRowComponent; + row.toggle(); + fix.detectChanges(); + + // Select all rows in parent + GridSelectionFunctions.clickHeaderRowCheckbox(hierarchicalGrid); + fix.detectChanges(); + + expect(hierarchicalGrid.selectedRows).toEqual(['0', '1', '2', '3', '4']); + GridSelectionFunctions.verifyHeaderRowCheckboxState(hierarchicalGrid, true); + + const childGrid1 = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const childGrid2 = hierarchicalGrid.gridAPI.getChildGrids(false)[1]; + expect(childGrid1.selectedRows).toEqual([]); + expect(childGrid2.selectedRows).toEqual([]); + GridSelectionFunctions.verifyHeaderRowCheckboxState(childGrid1); + GridSelectionFunctions.verifyHeaderRowCheckboxState(childGrid2); + + // Select all rows in child + GridSelectionFunctions.clickHeaderRowCheckbox(childGrid1); + fix.detectChanges(); + + expect(hierarchicalGrid.selectedRows).toEqual(['0', '1', '2', '3', '4']); + GridSelectionFunctions.verifyHeaderRowCheckboxState(hierarchicalGrid, true); + + expect(childGrid1.selectedRows).toEqual(['00', '01', '02']); + GridSelectionFunctions.verifyHeaderRowCheckboxState(childGrid1, true); + expect(childGrid2.selectedRows).toEqual([]); + GridSelectionFunctions.verifyHeaderRowCheckboxState(childGrid2); + + // Deselect all rows in parent + GridSelectionFunctions.clickHeaderRowCheckbox(hierarchicalGrid); + fix.detectChanges(); + + expect(hierarchicalGrid.selectedRows).toEqual([]); + GridSelectionFunctions.verifyHeaderRowCheckboxState(hierarchicalGrid); + + expect(childGrid1.selectedRows).toEqual(['00', '01', '02']); + GridSelectionFunctions.verifyHeaderRowCheckboxState(childGrid1, true); + expect(childGrid2.selectedRows).toEqual([]); + GridSelectionFunctions.verifyHeaderRowCheckboxState(childGrid2); + + // Deselect all rows in child + GridSelectionFunctions.clickHeaderRowCheckbox(childGrid1); + fix.detectChanges(); + + expect(hierarchicalGrid.selectedRows).toEqual([]); + GridSelectionFunctions.verifyHeaderRowCheckboxState(hierarchicalGrid); + + expect(childGrid1.selectedRows).toEqual([]); + GridSelectionFunctions.verifyHeaderRowCheckboxState(childGrid1); + expect(childGrid2.selectedRows).toEqual([]); + GridSelectionFunctions.verifyHeaderRowCheckboxState(childGrid2); + }); + + it('should have correct header checkbox state when selecting rows', () => { + const firstRow = hierarchicalGrid.gridAPI.get_row_by_index(0); + const secondRow = hierarchicalGrid.gridAPI.get_row_by_index(1); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix); + + // Select all rows + hierarchicalGrid.rowList.toArray().forEach(row => { + GridSelectionFunctions.clickRowCheckbox(row); + fix.detectChanges(); + GridSelectionFunctions.verifyRowSelected(row); + }); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + expect(hierarchicalGrid.selectedRows).toEqual(['0', '1', '2', '3', '4']); + + // Unselect a row + GridSelectionFunctions.clickRowCheckbox(firstRow); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + expect(hierarchicalGrid.selectedRows).toEqual(['1', '2', '3', '4']); + + // Click on a row + secondRow.onClick(UIInteractions.getMouseEvent('click')); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(secondRow); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + expect(hierarchicalGrid.selectedRows).toEqual(['1']); + }); + + it('should retain selected row when filtering', () => { + const firstRow = hierarchicalGrid.gridAPI.get_row_by_index(0); + firstRow.onRowSelectorClick(UIInteractions.getMouseEvent('click')); + fix.detectChanges(); + + hierarchicalGrid.filter('ID', '1', IgxStringFilteringOperand.instance().condition('doesNotContain'), true); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(hierarchicalGrid.gridAPI.get_row_by_index(0)); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + }); + + it('child grid selection should not be changed when filter parent', () => { + rowIsland1.rowSelection = GridSelectionMode.multiple; + fix.detectChanges(); + + // expand first row + let row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + row.toggle(); + fix.detectChanges(); + + // select second row + const secondRow = hierarchicalGrid.gridAPI.get_row_by_index(2); + GridSelectionFunctions.clickRowCheckbox(secondRow); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(hierarchicalGrid, false, true); + + // Select all rows in child grid + let childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + GridSelectionFunctions.clickHeaderRowCheckbox(childGrid); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(childGrid, true); + + // filter parent grid + hierarchicalGrid.filter('ID', '1', IgxStringFilteringOperand.instance().condition('equals'), true); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(hierarchicalGrid, true); + + // Expand filtered row + row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + row.toggle(); + fix.detectChanges(); + + childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[1]; + GridSelectionFunctions.verifyHeaderRowCheckboxState(childGrid); + GridSelectionFunctions.verifyRowsArraySelected(childGrid.dataRowList.toArray(), false); + + // Clear filter + hierarchicalGrid.clearFilter(); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(hierarchicalGrid, false, true); + childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + GridSelectionFunctions.verifyHeaderRowCheckboxState(childGrid, true); + GridSelectionFunctions.verifyRowsArraySelected(childGrid.dataRowList.toArray()); + }); + + it('should not be able to select deleted row', fakeAsync(() => { + // Expand first row + const firstRow = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + fix.detectChanges(); + + firstRow.toggle(); + fix.detectChanges(); + + firstRow.onClick(UIInteractions.getMouseEvent('click')); + tick(); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + + // delete selected row + hierarchicalGrid.deleteRow('0'); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix); + expect(hierarchicalGrid.selectedRows).toEqual([]); + + // Click on deleted row + firstRow.onClick(UIInteractions.getMouseEvent('click')); + tick(); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix); + expect(hierarchicalGrid.selectedRows).toEqual([]); + + // Click on checkbox for deleted row + firstRow.onRowSelectorClick(UIInteractions.getMouseEvent('click')); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix); + expect(hierarchicalGrid.selectedRows).toEqual([]); + + // Select all rows + hierarchicalGrid.selectAllRows(); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + expect(hierarchicalGrid.selectedRows).toEqual(['1', '2', '3', '4']); + + // Click on a row in the child grid + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + expect(childGrid.selectedRows).toEqual([]); + + const childGridFirstRow = childGrid.gridAPI.get_row_by_index(0); + childGridFirstRow.onClick(UIInteractions.getMouseEvent('click', false, false, true)); + tick(); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(firstRow, false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, true); + expect(hierarchicalGrid.selectedRows).toEqual(['1', '2', '3', '4']); + expect(childGrid.selectedRows).toEqual(['00']); + })); + + it('should be able to select added row', () => { + // Set multiple selection to first row island + rowIsland1.rowSelection = GridSelectionMode.multiple; + fix.detectChanges(); + + // Expand first row + const firstRow = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + firstRow.toggle(); + fix.detectChanges(); + + GridSelectionFunctions.clickHeaderRowCheckbox(hierarchicalGrid); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(hierarchicalGrid, true); + expect(hierarchicalGrid.selectedRows).toEqual(['0', '1', '2', '3', '4']); + + hierarchicalGrid.addRow({ ID: '5', ChildLevels: 3, ProductName: 'New Product' }); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(hierarchicalGrid, false, true); + expect(hierarchicalGrid.selectedRows).toEqual(['0', '1', '2', '3', '4']); + let lastRow = hierarchicalGrid.gridAPI.get_row_by_index(6); + GridSelectionFunctions.verifyRowSelected(lastRow, false); + + GridSelectionFunctions.clickRowCheckbox(lastRow); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(hierarchicalGrid, true); + expect(hierarchicalGrid.selectedRows).toEqual(['0', '1', '2', '3', '4', '5']); + + // Add row in child grid + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0] as IgxHierarchicalGridComponent; + expect(childGrid.selectedRows).toEqual([]); + childGrid.addRow({ ID: '03', ChildLevels: 2, ProductName: 'New Product' }); + fix.detectChanges(); + + GridSelectionFunctions.clickHeaderRowCheckbox(childGrid); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(childGrid, true); + expect(childGrid.selectedRows).toEqual(['00', '01', '02', '03']); + lastRow = childGrid.gridAPI.get_row_by_index(3); + GridSelectionFunctions.verifyRowSelected(lastRow); + }); + + it('should not select row on expander click.', () => { + const firstRow = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(firstRow.expander); + fix.detectChanges(); + + // check row is not selected + GridSelectionFunctions.verifyRowSelected(firstRow, false); + }); + + it('Should bind selectedRows properly', () => { + rowIsland1.rowSelection = GridSelectionMode.multiple; + fix.componentInstance.selectedRows = ['0', '2', '3']; + fix.detectChanges(); + + expect(hierarchicalGrid.getRowByKey('0').selected).toBeTrue(); + expect(hierarchicalGrid.getRowByKey('1').selected).toBeFalse(); + + fix.componentInstance.selectedRows = ['2']; + fix.detectChanges(); + + expect(hierarchicalGrid.getRowByKey('2').selected).toBeTrue(); + expect(hierarchicalGrid.getRowByKey('0').selected).toBeFalse(); + }); + + it('Should not clear root selection state when changing selection mode of child grid', () => { + rowIsland1.rowSelection = GridSelectionMode.multiple; + fix.componentInstance.selectedRows = ['0', '1']; + fix.detectChanges(); + expect(hierarchicalGrid.getRowByKey('0').selected).toBeTrue(); + + const thirdRow = hierarchicalGrid.gridAPI.get_row_by_index(2) as IgxHierarchicalRowComponent; + thirdRow.toggle(); + fix.detectChanges(); + + const childGrid = rowIsland1.rowIslandAPI.getChildGrids()[0]; + childGrid.selectedRows = ['20', '21']; + fix.detectChanges(); + expect(hierarchicalGrid.selectedRows.length).toEqual(2); + expect(childGrid.selectedRows.length).toEqual(2); + + rowIsland1.rowSelection = GridSelectionMode.single; + fix.detectChanges(); + expect(hierarchicalGrid.selectedRows.length).toEqual(2); + expect(childGrid.selectedRows.length).toEqual(0); + }); + }); + + describe('Row Selection CRUD', () => { + beforeEach(() => { + fix = TestBed.createComponent(IgxHierarchicalGridRowSelectionNoTransactionsComponent); + fix.detectChanges(); + hierarchicalGrid = fix.componentInstance.hgrid; + rowIsland1 = fix.componentInstance.rowIsland; + rowIsland2 = fix.componentInstance.rowIsland2; + }); + + it('should deselect deleted row', () => { + hierarchicalGrid.onHeaderSelectorClick(UIInteractions.getMouseEvent('click')); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(hierarchicalGrid, true); + expect(hierarchicalGrid.selectedRows).toEqual(['0', '1', '2', '3', '4']); + + hierarchicalGrid.deleteRow('1'); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(hierarchicalGrid, true); + expect(hierarchicalGrid.selectedRows).toEqual(['0', '2', '3', '4']); + expect(hierarchicalGrid.dataRowList.length).toEqual(4); + + const firstRow = hierarchicalGrid.gridAPI.get_row_by_index(0); + GridSelectionFunctions.clickRowCheckbox(firstRow); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(hierarchicalGrid, false, true); + expect(hierarchicalGrid.selectedRows).toEqual(['2', '3', '4']); + + hierarchicalGrid.deleteRow('0'); + fix.detectChanges(); + + expect(hierarchicalGrid.dataRowList.length).toEqual(3); + GridSelectionFunctions.verifyHeaderRowCheckboxState(hierarchicalGrid, true); + expect(hierarchicalGrid.selectedRows).toEqual(['2', '3', '4']); + }); + + it('should be able to select added row', () => { + // Expand first row + const firstRow = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + firstRow.toggle(); + fix.detectChanges(); + + hierarchicalGrid.addRow({ ID: '5', ChildLevels: 3, ProductName: 'New Product' }); + fix.detectChanges(); + + expect(hierarchicalGrid.dataRowList.length).toEqual(6); + GridSelectionFunctions.verifyHeaderRowCheckboxState(hierarchicalGrid); + + hierarchicalGrid.selectAllRows(); + fix.detectChanges(); + + let addedRow = hierarchicalGrid.gridAPI.get_row_by_index(5); + GridSelectionFunctions.verifyHeaderRowCheckboxState(hierarchicalGrid, true); + GridSelectionFunctions.verifyRowSelected(addedRow); + + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0] as IgxHierarchicalGridComponent; + GridSelectionFunctions.clickHeaderRowCheckbox(childGrid); + fix.detectChanges(); + + GridSelectionFunctions.verifyHeaderRowCheckboxState(childGrid, true); + + childGrid.addRow({ ID: '03', ChildLevels: 3, ProductName: 'New Product' }); + fix.detectChanges(); + + addedRow = childGrid.gridAPI.get_row_by_index(3); + GridSelectionFunctions.verifyRowSelected(addedRow, false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(childGrid, false, true); + GridSelectionFunctions.verifyHeaderRowCheckboxState(hierarchicalGrid, true); + + GridSelectionFunctions.clickRowCheckbox(addedRow); + fix.detectChanges(); + GridSelectionFunctions.verifyRowSelected(addedRow); + GridSelectionFunctions.verifyHeaderRowCheckboxState(childGrid, true); + }); + }); + + describe('Custom row selectors', () => { + let hGrid; + + beforeEach(() => { + fix = TestBed.createComponent(IgxHierarchicalGridCustomSelectorsComponent); + fix.detectChanges(); + hGrid = fix.componentInstance.hGrid; + hGrid.rowSelection = GridSelectionMode.multiple; + }); + + it('Row context `select` method selects a single row', () => { + // root grid + const firstRootRow = hGrid.gridAPI.get_row_by_index(0); + GridSelectionFunctions.clickRowCheckbox(firstRootRow); + fix.detectChanges(); + GridSelectionFunctions.verifyRowSelected(hGrid.gridAPI.get_row_by_index(0)); + GridSelectionFunctions.verifyHeaderRowCheckboxState(fix, false, true); + + // child grid + GridSelectionFunctions.expandRowIsland(2); + fix.detectChanges(); + + const childGrid = hGrid.gridAPI.getChildGrids(false)[0]; + const childRow = childGrid.gridAPI.get_row_by_index(0); + GridSelectionFunctions.clickRowCheckbox(childRow); + fix.detectChanges(); + + GridSelectionFunctions.verifyRowSelected(childRow); + GridSelectionFunctions.verifyHeaderRowCheckboxState(childGrid, false, true); + }); + + it('Row context `deselect` method deselects an already selected row', () => { + // root grid + const firstRootRow = hGrid.gridAPI.get_row_by_index(1); + GridSelectionFunctions.clickRowCheckbox(firstRootRow); + fix.detectChanges(); + GridSelectionFunctions.verifyRowSelected(firstRootRow); + GridSelectionFunctions.verifyHeaderRowCheckboxState(hGrid, false, true); + + GridSelectionFunctions.clickRowCheckbox(firstRootRow); + fix.detectChanges(); + GridSelectionFunctions.verifyRowSelected(firstRootRow, false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(hGrid, false, false); + + // child grid + GridSelectionFunctions.expandRowIsland(2); + fix.detectChanges(); + + const childGrid = hGrid.gridAPI.getChildGrids(false)[0]; + const childRow = childGrid.gridAPI.get_row_by_index(0); + + GridSelectionFunctions.clickRowCheckbox(childRow); + fix.detectChanges(); + GridSelectionFunctions.verifyRowSelected(childRow); + GridSelectionFunctions.verifyHeaderRowCheckboxState(childGrid, false, true); + + GridSelectionFunctions.clickRowCheckbox(childRow); + fix.detectChanges(); + GridSelectionFunctions.verifyRowSelected(childRow, false); + GridSelectionFunctions.verifyHeaderRowCheckboxState(childGrid, false, false); + }); + + it('Header context `selectAll` method selects all rows', () => { + // root grid + GridSelectionFunctions.clickHeaderRowCheckbox(hGrid); + fix.detectChanges(); + GridSelectionFunctions.verifyHeaderRowCheckboxState(hGrid, true, false); + expect(hGrid.selectionService.areAllRowSelected()).toBeTruthy(); + + // child grid + GridSelectionFunctions.expandRowIsland(2); + fix.detectChanges(); + + const childGrid = hGrid.gridAPI.getChildGrids(false)[0]; + GridSelectionFunctions.headerCheckboxClick(childGrid); + fix.detectChanges(); + + expect(childGrid.selectionService.areAllRowSelected()).toBeTruthy(); + }); + + it('Header context `deselectAll` method deselects all rows', () => { + // root grid + GridSelectionFunctions.clickHeaderRowCheckbox(hGrid); + fix.detectChanges(); + GridSelectionFunctions.verifyHeaderRowCheckboxState(hGrid, true, false); + expect(hGrid.selectionService.areAllRowSelected()).toBeTruthy(); + + GridSelectionFunctions.clickHeaderRowCheckbox(hGrid); + fix.detectChanges(); + GridSelectionFunctions.verifyHeaderRowCheckboxState(hGrid, false, false); + expect(hGrid.selectionService.areAllRowSelected()).toBeFalsy(); + + // child grid + GridSelectionFunctions.expandRowIsland(2); + fix.detectChanges(); + + const childGrid = hGrid.gridAPI.getChildGrids(false)[0]; + GridSelectionFunctions.headerCheckboxClick(childGrid); + fix.detectChanges(); + GridSelectionFunctions.verifyHeaderRowCheckboxState(childGrid, true, false); + expect(childGrid.selectionService.areAllRowSelected()).toBeTruthy(); + + GridSelectionFunctions.headerCheckboxClick(childGrid); + fix.detectChanges(); + GridSelectionFunctions.verifyHeaderRowCheckboxState(childGrid, false, false); + expect(childGrid.selectionService.areAllRowSelected()).toBeFalsy(); + }); + + it('Should have the correct properties in the custom row selector header template context', () => { + spyOn(fix.componentInstance, 'handleHeadSelectorClick').and.callThrough(); + + GridSelectionFunctions.headerCheckboxClick(hGrid); + fix.detectChanges(); + + expect(fix.componentInstance.handleHeadSelectorClick).toHaveBeenCalledWith({ + selectedCount: 0, + totalCount: hGrid.data.length, + selectAll: jasmine.anything(), + deselectAll: jasmine.anything() + }); + }); + + it('Should have the correct properties in the custom row selector template context', () => { + spyOn(fix.componentInstance, 'handleRowSelectorClick').and.callThrough(); + + GridSelectionFunctions.rowCheckboxClick(hGrid.gridAPI.get_row_by_index(1)); + fix.detectChanges(); + + expect(fix.componentInstance.handleRowSelectorClick).toHaveBeenCalledWith({ + index: 1, + rowID: '1', + key: '1', + selected: false, + select: jasmine.anything(), + deselect: jasmine.anything() + }); + }); + + it('Should have correct indices on all pages', fakeAsync(() => { + // root grid + hGrid.paginator.nextPage(); + tick(100); + fix.detectChanges(); + expect(hGrid.gridAPI.get_row_by_index(0).nativeElement.querySelector('.rowNumber').textContent).toEqual('15'); + + // child grid + GridSelectionFunctions.expandRowIsland(3); + fix.detectChanges(); + + const childGrid = hGrid.gridAPI.getChildGrids(false)[0]; + tick(100); + fix.detectChanges(); + + childGrid.paginator.nextPage(); + fix.detectChanges(); + tick(100); + fix.detectChanges(); + + expect(childGrid.gridAPI.get_row_by_index(2).nativeElement.querySelector('.rowNumberChild').textContent).toEqual('17'); + })); + }); +}); diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.spec.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.spec.ts new file mode 100644 index 00000000000..0bfe2bcab69 --- /dev/null +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.spec.ts @@ -0,0 +1,2550 @@ +import { TestBed, fakeAsync, tick, ComponentFixture, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { ChangeDetectorRef, Component, ViewChild, AfterViewInit, QueryList, inject } from '@angular/core'; +import { IgxChildGridRowComponent, IgxHierarchicalGridComponent } from './hierarchical-grid.component'; +import { wait, UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { IgxRowIslandComponent } from './row-island.component'; +import { IgxHierarchicalRowComponent } from './hierarchical-row.component'; +import { By } from '@angular/platform-browser'; +import { CellType, GridSelectionMode, IGridCellEventArgs, IgxColumnComponent, IgxColumnGroupComponent, IgxGridNavigationService, IgxHeaderCollapsedIndicatorDirective, IgxHeaderExpandedIndicatorDirective, IgxRowCollapsedIndicatorDirective, IgxRowEditActionsDirective, IgxRowEditTextDirective, IgxRowExpandedIndicatorDirective } from 'igniteui-angular/grids/core'; +import { GridFunctions } from '../../../test-utils/grid-functions.spec'; +import { IgxGridCellComponent } from 'igniteui-angular/grids/core'; +import { IgxExcelStyleColumnOperationsTemplateDirective, IgxExcelStyleFilterOperationsTemplateDirective, IgxGridExcelStyleFilteringComponent } from 'igniteui-angular/grids/core'; +import { IgxExcelStyleHeaderComponent } from 'igniteui-angular/grids/core'; +import { IgxExcelStyleSortingComponent } from 'igniteui-angular/grids/core'; +import { IgxExcelStyleSearchComponent } from 'igniteui-angular/grids/core'; +import { IgxCellHeaderTemplateDirective } from 'igniteui-angular/grids/core'; +import { setElementSize } from '../../../test-utils/helper-utils.spec'; +import { ColumnType, IgxStringFilteringOperand, ɵSize, getComponentSize } from 'igniteui-angular/core'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IGridCreatedEventArgs } from './events'; + +describe('Basic IgxHierarchicalGrid #hGrid', () => { + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxHierarchicalGridTestBaseComponent, + IgxHierarchicalGridMultiLayoutComponent, + IgxHierarchicalGridSizingComponent, + IgxHGridRemoteOnDemandComponent, + IgxHierarchicalGridColumnsUpdateComponent, + IgxHierarchicalGridHidingPinningColumnsComponent, + IgxHierarchicalGridToggleRIComponent, + IgxHierarchicalGridCustomRowEditOverlayComponent, + IgxHierarchicalGridAutoSizeColumnsComponent, + IgxHierarchicalGridCustomTemplateComponent, + IgxHierarchicalGridCustomFilteringTemplateComponent, + IgxHierarchicalGridToggleRIAndColsComponent, + IgxHierarchicalGridMCHComponent + ], + providers: [ + IgxGridNavigationService + ] + }).compileComponents(); + })) + + describe('Init IgxHierarchicalGrid #hGrid', () => { + let fixture; + let hierarchicalGrid: IgxHierarchicalGridComponent; + + beforeEach(() => { + fixture = TestBed.createComponent(IgxHierarchicalGridTestBaseComponent); + fixture.detectChanges(); + hierarchicalGrid = fixture.componentInstance.hgrid; + }); + + it('should render expansion indicator as the first element of each expandable row.', () => { + fixture.componentInstance.data = [ + { ID: 0, ProductName: 'Product: A0' }, + { ID: 1, ProductName: 'Product: A1', childData: fixture.componentInstance.generateDataUneven(1, 1) }, + { ID: 2, ProductName: 'Product: A2', childData: fixture.componentInstance.generateDataUneven(1, 1) } + ]; + fixture.detectChanges(); + const row1 = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + expect(row1.hasChildren).toBe(true); + const rowElems = fixture.debugElement.queryAll(By.directive(IgxHierarchicalRowComponent)); + expect(rowElems[0].query(By.css('igx-icon')).nativeElement.innerText).toEqual('chevron_right'); + const row2 = hierarchicalGrid.gridAPI.get_row_by_index(1) as IgxHierarchicalRowComponent; + expect(row2.hasChildren).toBe(true); + expect(rowElems[1].query(By.css('igx-icon')).nativeElement.innerText).toEqual('chevron_right'); + + const row3 = hierarchicalGrid.gridAPI.get_row_by_index(1) as IgxHierarchicalRowComponent; + expect(row3.hasChildren).toBe(true); + expect(rowElems[2].query(By.css('igx-icon')).nativeElement.innerText).toEqual('chevron_right'); + }); + + it('should allow expand/collapse rows through the UI', () => { + const row1 = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + expect(row1.expanded).toBe(false); + UIInteractions.simulateClickAndSelectEvent(row1.expander); + fixture.detectChanges(); + expect(row1.expanded).toBe(true); + expect(hierarchicalGrid.gridAPI.getChildGrids(false).length).toBe(1); + expect(hierarchicalGrid.gridAPI.get_row_by_index(1) instanceof IgxChildGridRowComponent).toBe(true); + UIInteractions.simulateClickAndSelectEvent(row1.expander); + fixture.detectChanges(); + expect(row1.expanded).toBe(false); + expect(hierarchicalGrid.gridAPI.get_row_by_index(1) instanceof IgxHierarchicalRowComponent).toBe(true); + }); + + it('should change expand/collapse indicators when state of the row changes', () => { + const row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + const rowElem = fixture.debugElement.queryAll(By.directive(IgxHierarchicalRowComponent))[0]; + expect(rowElem.query(By.css('igx-icon')).nativeElement.innerText).toEqual('chevron_right'); + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + + expect(rowElem.query(By.css('igx-icon')).nativeElement.innerText).toEqual('expand_more'); + }); + + it('should collapse all rows that belongs to a grid via header collapse icon', () => { + const headerExpanderElem = fixture.debugElement.queryAll(By.css('.igx-grid__hierarchical-expander--header'))[0]; + let icon = headerExpanderElem.query(By.css('igx-icon')).componentInstance; + let iconTxt = headerExpanderElem.query(By.css('igx-icon')).nativeElement.textContent.toLowerCase(); + expect(iconTxt).toBe('unfold_less'); + expect(icon.getActive).toBe(false); + // expand row + const row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + + icon = headerExpanderElem.query(By.css('igx-icon')).componentInstance; + iconTxt = headerExpanderElem.query(By.css('igx-icon')).nativeElement.textContent.toLowerCase(); + expect(iconTxt).toBe('unfold_less'); + expect(icon.getActive).toBe(true); + expect(hierarchicalGrid.expansionStates.size).toEqual(1); + + UIInteractions.simulateClickAndSelectEvent(icon.el); + fixture.detectChanges(); + const rows = hierarchicalGrid.dataRowList.toArray() as IgxHierarchicalRowComponent[]; + rows.forEach((r) => { + expect(r.expanded).toBe(false); + }); + icon = headerExpanderElem.query(By.css('igx-icon')).componentInstance; + iconTxt = headerExpanderElem.query(By.css('igx-icon')).nativeElement.textContent.toLowerCase(); + expect(iconTxt).toBe('unfold_less'); + expect(icon.getActive).toBe(false); + expect(hierarchicalGrid.expansionStates.size).toEqual(0); + }); + + it('checks if attributes are correctly assigned when grid has or does not have data', () => { + + // Checks if igx-grid__tbody-content attribute is null when there is data in the grid + const container = fixture.nativeElement.querySelectorAll('.igx-grid__tbody-content')[0]; + expect(container.getAttribute('role')).toBe(null); + + //Filter grid so no results are available and grid is empty + hierarchicalGrid.filter('index', '111', IgxStringFilteringOperand.instance().condition('contains'), true); + hierarchicalGrid.markForCheck(); + fixture.detectChanges(); + expect(container.getAttribute('role')).toMatch('row'); + + // clear grid data and check if attribute is now 'row' + hierarchicalGrid.clearFilter(); + fixture.componentInstance.clearData(); + fixture.detectChanges(); + + expect(container.getAttribute('role')).toMatch('row'); + }); + + it('should allow applying initial expansions state for certain rows through expansionStates option', () => { + // set first row as expanded. + const state = new Map(); + state.set(fixture.componentInstance.data[0], true); + hierarchicalGrid.expansionStates = state; + hierarchicalGrid.cdr.detectChanges(); + const row1 = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + // verify row is expanded + expect(row1.expanded).toBe(true); + expect(hierarchicalGrid.gridAPI.getChildGrids(false).length).toBe(1); + expect(hierarchicalGrid.gridAPI.get_row_by_index(1) instanceof IgxChildGridRowComponent).toBe(true); + }); + + it('should allow defining more than one nested row islands', () => { + const row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const childRow = childGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(childRow.expander); + fixture.detectChanges(); + + // should have 3 level hierarchy + const allChildren = hierarchicalGrid.gridAPI.getChildGrids(true); + expect(allChildren.length).toBe(2); + expect(hierarchicalGrid.gridAPI.get_row_by_index(1) instanceof IgxChildGridRowComponent).toBe(true); + expect(childGrid.gridAPI.get_row_by_index(1) instanceof IgxChildGridRowComponent).toBe(true); + }); + + it('should retain expansion states when scrolling', async () => { + const row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + expect(row.expanded).toBe(true); + // scroll to bottom + hierarchicalGrid.verticalScrollContainer.scrollTo(hierarchicalGrid.dataView.length - 1); + await wait(100); + fixture.detectChanges(); + // scroll to top + hierarchicalGrid.verticalScrollContainer.scrollTo(0); + await wait(100); + fixture.detectChanges(); + expect((hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent).expanded).toBe(true); + }); + + it('should show header collapse button if grid has data and row island is defined.', () => { + fixture.componentInstance.data = [ + { ID: 0, ProductName: 'Product: A0' } + ]; + fixture.detectChanges(); + const headerExpanderElem = fixture.debugElement.queryAll(By.css('.igx-grid__hierarchical-expander--header'))[0]; + const icon = headerExpanderElem.query(By.css('igx-icon')); + expect(icon).toBeDefined(); + }); + + it('should render last cell of rows fully visible when columns does not have width specified and without scrollbar', () => { + const firstRowCell: HTMLElement = (hierarchicalGrid.gridAPI.get_row_by_index(0).cells as QueryList).first.nativeElement; + const cellLeftOffset = firstRowCell.offsetLeft + firstRowCell.parentElement.offsetLeft + firstRowCell.offsetWidth; + const gridWidth = hierarchicalGrid.nativeElement.offsetWidth; + expect(cellLeftOffset).not.toBeGreaterThan(gridWidth); + + const hScroll = hierarchicalGrid.headerContainer.getScroll(); + expect((hScroll.firstElementChild as HTMLElement).offsetWidth).not.toBeGreaterThan(hScroll.offsetWidth); + }); + + it('should allow extracting child grids using hgridAPI', () => { + // set first row as expanded. + const state = new Map(); + state.set(fixture.componentInstance.data[0], true); + hierarchicalGrid.expansionStates = state; + hierarchicalGrid.cdr.detectChanges(); + const row1 = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + // verify row is expanded + expect(row1.expanded).toBe(true); + const childGrid = hierarchicalGrid.gridAPI.getChildGrid([{ rowID: fixture.componentInstance.data[0], rowKey: fixture.componentInstance.data[0], rowIslandKey: 'childData' }]); + expect(childGrid).not.toBeNull(); + const childState = new Map(); + childState.set(fixture.componentInstance.data[0].childData[0], true); + childGrid.expansionStates = childState; + childGrid.cdr.detectChanges(); + const grandChildGrid = hierarchicalGrid.gridAPI.getChildGrid([ + { rowID: fixture.componentInstance.data[0], rowKey: fixture.componentInstance.data[0], rowIslandKey: 'childData' }, + { rowID: fixture.componentInstance.data[0].childData[0], rowKey: fixture.componentInstance.data[0].childData[0], rowIslandKey: 'childData' } + ]); + expect(grandChildGrid).not.toBeNull(); + + const rowIsland1 = hierarchicalGrid.gridAPI.getChildRowIsland('childData'); + const rowIsland2 = hierarchicalGrid.allLayoutList.find(layout => layout.id === 'igx-row-island-childData-childData'); + expect(rowIsland1.key).toBe('childData'); + expect(rowIsland2.key).toBe('childData'); + }); + + it('should allow setting expandChildren after bound to data', () => { + // set first row as expanded. + const state = new Map(); + state.set(fixture.componentInstance.data[0], true); + hierarchicalGrid.expansionStates = state; + hierarchicalGrid.cdr.detectChanges(); + let row1 = hierarchicalGrid.gridAPI.get_row_by_index(0); + // verify row is expanded + expect(row1.expanded).toBe(true); + hierarchicalGrid.expandChildren = false; + hierarchicalGrid.cdr.detectChanges(); + row1 = hierarchicalGrid.gridAPI.get_row_by_index(0); + expect(row1.expanded).toBe(false); + const expandIcons = fixture.debugElement.queryAll(By.css('#igx-icon-15')); + expect(expandIcons.length).toBe(0); + let rows = hierarchicalGrid.dataRowList.toArray() as IgxHierarchicalRowComponent[]; + rows.forEach((r) => { + expect(r.expanded).toBe(false); + }); + hierarchicalGrid.expandChildren = true; + hierarchicalGrid.cdr.detectChanges(); + rows = hierarchicalGrid.dataRowList.toArray() as IgxHierarchicalRowComponent[]; + rows.forEach((r) => { + expect(r.expanded).toBe(true); + }); + + row1 = hierarchicalGrid.gridAPI.get_row_by_index(0); + expect(row1.expanded).toBe(true); + }); + + it('should correctly expand children on init if parents have hasChild key', () => { + hierarchicalGrid.expandChildren = true; + hierarchicalGrid.hasChildrenKey = 'hasChild'; + fixture.componentInstance.data = [ + { ID: 1, ProductName: 'Product: A1', hasChild: false, childData: fixture.componentInstance.generateDataUneven(1, 1) }, + { ID: 2, ProductName: 'Product: A2', hasChild: true, childData: fixture.componentInstance.generateDataUneven(1, 1) } + ]; + fixture.detectChanges(); + expect(hierarchicalGrid.gridAPI.get_row_by_index(0)).toBeInstanceOf(IgxHierarchicalRowComponent); + expect(hierarchicalGrid.gridAPI.get_row_by_index(1)).toBeInstanceOf(IgxHierarchicalRowComponent); + expect(hierarchicalGrid.gridAPI.get_row_by_index(2)).toBeInstanceOf(IgxChildGridRowComponent); + const rowElems = fixture.debugElement.queryAll(By.directive(IgxHierarchicalRowComponent)); + expect(rowElems[0].query(By.css('igx-icon')).nativeElement.innerText).toEqual(''); + expect(rowElems[1].query(By.css('igx-icon')).nativeElement.innerText).toEqual('expand_more'); + }); + + it('should allow setting expandChildren after bound to data to rowIsland', () => { + // set first row as expanded. + const state = new Map(); + state.set(fixture.componentInstance.data[0], true); + hierarchicalGrid.expansionStates = state; + hierarchicalGrid.cdr.detectChanges(); + const row1 = hierarchicalGrid.gridAPI.get_row_by_index(0); + // verify row is expanded + expect(row1.expanded).toBe(true); + // expand children for the rowIsland should be false by default + expect(fixture.componentInstance.rowIsland.expandChildren).toBeFalsy(); + fixture.componentInstance.rowIsland.expandChildren = true; + fixture.detectChanges(); + const childGrid = hierarchicalGrid.gridAPI.getChildGrid([{ rowID: fixture.componentInstance.data[0], rowKey: fixture.componentInstance.data[0], rowIslandKey: 'childData' }]); + const childRow = childGrid.getRowByIndex(0); + expect(childRow.expanded).toBe(true); + let rows = childGrid.dataRowList.toArray(); + rows.forEach((r) => { + expect(r.expanded).toBe(true); + }); + fixture.componentInstance.rowIsland.expandChildren = false; + fixture.detectChanges(); + rows = childGrid.dataRowList.toArray(); + rows.forEach((r) => { + expect(r.expanded).toBe(false); + }); + + }); + + it('should be able to prevent exiting of edit mode when a row is toggled #10634', async () => { + hierarchicalGrid.primaryKey = 'ID'; + hierarchicalGrid.rowEditable = true; + hierarchicalGrid.rowToggle.subscribe((e) => { + e.cancel = true; + }); + fixture.detectChanges(); + wait(); + + const masterGridFirstRow = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + expect(masterGridFirstRow.expanded).toBe(false); + + const masterGridSecondCell = masterGridFirstRow.cells.find((c: IgxGridCellComponent) => c.columnIndex === 1); + expect(masterGridSecondCell.editMode).toBe(false); + + masterGridSecondCell.setEditMode(true); + fixture.detectChanges(); + wait(); + + expect(masterGridSecondCell.editMode).toBe(true); + + UIInteractions.simulateClickAndSelectEvent(masterGridFirstRow.expander); + fixture.detectChanges(); + wait(); + + expect(masterGridFirstRow.expanded).toBe(false); + expect(masterGridSecondCell.editMode).toBe(true); + + hierarchicalGrid.rowToggle.subscribe((e) => { + e.cancel = false; + }); + UIInteractions.simulateClickAndSelectEvent(masterGridFirstRow.expander); + fixture.detectChanges(); + wait(); + + expect(masterGridFirstRow.expanded).toBe(true); + expect(masterGridSecondCell.editMode).toBe(true); + + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0] as IgxHierarchicalGridComponent; + expect(childGrid).toBeDefined(); + + childGrid.primaryKey = 'ID'; + childGrid.rowEditable = true; + childGrid.rowToggle.subscribe((e) => { + e.cancel = true; + }); + fixture.detectChanges(); + wait(); + + childGrid.columns.find(c => c.index === 1).editable = true; + const childGridSecondRow = childGrid.gridAPI.get_row_by_index(1) as IgxHierarchicalRowComponent; + expect(childGridSecondRow.expanded).toBe(false); + + const childGridSecondCell = childGridSecondRow.cells.find((c: IgxGridCellComponent) => c.columnIndex === 1); + expect(childGridSecondCell.editMode).toBe(false); + + childGridSecondCell.setEditMode(true); + fixture.detectChanges(); + wait(); + + expect(childGridSecondCell.editMode).toBe(true); + + UIInteractions.simulateClickAndSelectEvent(childGridSecondRow.expander); + fixture.detectChanges(); + wait(); + + expect(childGrid.gridAPI.crudService.cellInEditMode).toBe(true); + expect(childGridSecondRow.inEditMode).toBe(true); + }); + + it('should render correctly when grid size is changed', () => { + const row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + const childGrids = fixture.debugElement.queryAll(By.css('igx-child-grid-row')); + const childGrid = childGrids[0].query(By.css('igx-hierarchical-grid')).componentInstance; + + expect(hierarchicalGrid.gridSize).toEqual(ɵSize.Large); + expect(getComponentSize(hierarchicalGrid.nativeElement)).toEqual('3'); + + setElementSize(hierarchicalGrid.nativeElement, ɵSize.Medium) + fixture.detectChanges(); + + expect(childGrid.gridSize).toBe(ɵSize.Medium); + expect(getComponentSize(hierarchicalGrid.nativeElement)).toEqual('2'); + + setElementSize(hierarchicalGrid.nativeElement, ɵSize.Small) + fixture.detectChanges(); + + expect(childGrid.gridSize).toBe(ɵSize.Small); + expect(getComponentSize(hierarchicalGrid.nativeElement)).toEqual('1'); + }); + + it('should update child grid data when root grid data is changed.', () => { + const newData1 = [ + { + ID: 0, ChildLevels: 0, ProductName: 'Product: A', childData: [{ ID: 1, ProductName: 'Product: Child A' }] + }, + { + ID: 1, ChildLevels: 0, ProductName: 'Product: A1', childData: [{ ID: 2, ProductName: 'Product: Child A' }] + }, + { + ID: 2, ChildLevels: 0, ProductName: 'Product: A2', childData: [{ ID: 3, ProductName: 'Product: Child A' }] + } + ]; + fixture.componentInstance.data = newData1; + fixture.detectChanges(); + let row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + let childGrids = fixture.debugElement.queryAll(By.css('igx-child-grid-row')); + let childGrid = childGrids[0].query(By.css('igx-hierarchical-grid')).componentInstance; + + expect(childGrid.data).toBe(newData1[0].childData); + + const newData2 = [ + { + ID: 0, ChildLevels: 0, ProductName: 'Product: A', childData: [{ ID: 10, ProductName: 'Product: New Child A' }] + }, + { + ID: 1, ChildLevels: 0, ProductName: 'Product: A1', childData: [{ ID: 20, ProductName: 'Product: New Child A' }] + }, + { + ID: 2, ChildLevels: 0, ProductName: 'Product: A2', childData: [{ ID: 30, ProductName: 'Product: New Child A' }] + } + ]; + fixture.componentInstance.data = newData2; + fixture.detectChanges(); + + row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + + childGrids = fixture.debugElement.queryAll(By.css('igx-child-grid-row')); + childGrid = childGrids[0].query(By.css('igx-hierarchical-grid')).componentInstance; + + expect(childGrid.data).toBe(newData2[0].childData); + }); + + it('should update already created child grid with new records added to the root data', () => { + const row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + + // check by adding multiple rows + for (let i = 0; i < 3; i++) { + let childGrids = fixture.debugElement.queryAll(By.css('igx-child-grid-row')); + let childGrid = childGrids[0].query(By.css('igx-hierarchical-grid')).componentInstance; + + fixture.componentInstance.data[0].childData = [...hierarchicalGrid.data[0].childData ?? [], { ID: i * 10, ProductName: 'New child' + i.toString() }]; + fixture.componentInstance.data = [...fixture.componentInstance.data]; + fixture.detectChanges(); + + childGrids = fixture.debugElement.queryAll(By.css('igx-child-grid-row')); + childGrid = childGrids[0].query(By.css('igx-hierarchical-grid')).componentInstance; + + const length = fixture.componentInstance.data[0].childData.length; + const newRow = childGrid.gridAPI.get_row_by_index(length - 1) as IgxHierarchicalRowComponent; + + expect(newRow).not.toBeUndefined(); + expect(childGrid.data).toBe(fixture.componentInstance.data[0].childData); + } + }); + + it('when child width is in percents its width should be update if parent width changes while parent row is collapsed. ', async () => { + const row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + + const childGrids = fixture.debugElement.queryAll(By.css('igx-child-grid-row')); + const childGrid = childGrids[0].query(By.css('igx-hierarchical-grid')).componentInstance; + expect(childGrid.calcWidth - 370).toBeLessThan(3); + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + fixture.componentInstance.width = '300px'; + fixture.detectChanges(); + UIInteractions.simulateClickAndSelectEvent(row.expander); + await wait(); + fixture.detectChanges(); + + expect(childGrid.calcWidth - 170).toBeLessThan(3); + }); + + it('should exit edit mode on row expand/collapse through the UI', () => { + hierarchicalGrid.primaryKey = 'ID'; + hierarchicalGrid.rowEditable = true; + fixture.detectChanges(); + + const masterGridFirstRow = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + expect(masterGridFirstRow.expanded).toBe(false); + + const masterGridSecondCell = masterGridFirstRow.cells.find((c: IgxGridCellComponent) => c.columnIndex === 1); + expect(masterGridSecondCell.editMode).toBe(false); + + masterGridSecondCell.setEditMode(true); + fixture.detectChanges(); + + expect(masterGridSecondCell.editMode).toBe(true); + + UIInteractions.simulateClickAndSelectEvent(masterGridFirstRow.expander); + fixture.detectChanges(); + + expect(masterGridFirstRow.expanded).toBe(true); + expect(masterGridSecondCell.editMode).toBe(true); + + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0] as IgxHierarchicalGridComponent; + expect(childGrid).toBeDefined(); + + childGrid.columns.find(c => c.index === 1).editable = true; + const childGridSecondRow = childGrid.gridAPI.get_row_by_index(1) as IgxHierarchicalRowComponent; + expect(childGridSecondRow.expanded).toBe(false); + + const childGridSecondCell = childGridSecondRow.cells.find((c: IgxGridCellComponent) => c.columnIndex === 1); + expect(childGridSecondCell.editMode).toBe(false); + + childGridSecondCell.setEditMode(true); + fixture.detectChanges(); + + expect(childGridSecondCell.editMode).toBe(true); + + UIInteractions.simulateClickAndSelectEvent(masterGridFirstRow.expander); + fixture.detectChanges(); + + expect(childGrid.gridAPI.crudService.cellInEditMode).toBe(true); + expect(childGridSecondRow.inEditMode).toBe(false); + }); + + it('child grid width should be recalculated if parent no longer shows scrollbar.', fakeAsync(() => { + hierarchicalGrid.height = '1000px'; + fixture.detectChanges(); + hierarchicalGrid.filter('ProductName', 'A0', IgxStringFilteringOperand.instance().condition('contains'), true); + fixture.detectChanges(); + const row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + const childGrids = fixture.debugElement.queryAll(By.css('igx-child-grid-row')); + const childGrid = childGrids[0].query(By.css('igx-hierarchical-grid')).componentInstance; + expect(childGrid.calcWidth - 370 - childGrid.scrollSize).toBeLessThanOrEqual(5); + + hierarchicalGrid.clearFilter(); + // Required to recalculate and reflect child grid size + tick(); + fixture.detectChanges(); + + expect(childGrid.calcWidth - 370).toBeLessThan(3); + })); + + it('should not expand children when hasChildrenKey is false for the row', () => { + hierarchicalGrid.hasChildrenKey = 'hasChild'; + fixture.componentInstance.data = [ + { ID: 1, ProductName: 'Product: A1', hasChild: false, childData: fixture.componentInstance.generateDataUneven(1, 1) }, + { ID: 2, ProductName: 'Product: A2', hasChild: true, childData: fixture.componentInstance.generateDataUneven(1, 1) } + ]; + fixture.detectChanges(); + const row1 = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + const rowElems = fixture.debugElement.queryAll(By.directive(IgxHierarchicalRowComponent)); + expect(rowElems[0].query(By.css('igx-icon')).nativeElement.innerText).toEqual(''); + const row2 = hierarchicalGrid.gridAPI.get_row_by_index(1) as IgxHierarchicalRowComponent; + expect(rowElems[1].query(By.css('igx-icon')).nativeElement.innerText).toEqual('chevron_right'); + hierarchicalGrid.expandRow(row1.data); + hierarchicalGrid.expandRow(row2.data); + expect(row1.expanded).toBe(false); + expect(row2.expanded).toBe(true); + hierarchicalGrid.expandAll(); + expect(row1.expanded).toBe(false); + }); + + it('should not expand children when hasChildrenKey is false for the row and there is primaryKey', () => { + hierarchicalGrid.hasChildrenKey = 'hasChild'; + hierarchicalGrid.primaryKey = 'ID'; + fixture.componentInstance.data = [ + { ID: 1, ProductName: 'Product: A1', hasChild: false, childData: fixture.componentInstance.generateDataUneven(1, 1) }, + { ID: 2, ProductName: 'Product: A2', hasChild: true, childData: fixture.componentInstance.generateDataUneven(1, 1) } + ]; + fixture.detectChanges(); + const row1 = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + const rowElems = fixture.debugElement.queryAll(By.directive(IgxHierarchicalRowComponent)); + expect(rowElems[0].query(By.css('igx-icon')).nativeElement.innerText).toEqual(''); + const row2 = hierarchicalGrid.gridAPI.get_row_by_index(1) as IgxHierarchicalRowComponent; + expect(rowElems[1].query(By.css('igx-icon')).nativeElement.innerText).toEqual('chevron_right'); + hierarchicalGrid.expandRow(1); + hierarchicalGrid.expandRow(2); + expect(row1.expanded).toBe(false); + expect(row2.expanded).toBe(true); + }); + + it('should update aria-activeDescendants when navigating around', () => { + hierarchicalGrid.cellSelection = 'single'; + // aria-activedescendant on the tbody should not be defined unless a cell among it is active + expect(hierarchicalGrid.tbody.nativeElement.attributes['aria-activedescendant']).not.toBeDefined(); + + let cellElem = (hierarchicalGrid.gridAPI.get_row_by_index(0).cells as QueryList).toArray()[1]; + UIInteractions.simulatePointerOverElementEvent('pointerdown', cellElem.nativeElement); + fixture.detectChanges(); + expect(hierarchicalGrid.tbody.nativeElement.attributes['aria-activedescendant'].value).toEqual(`${hierarchicalGrid.id}_0_1`); + + const row1 = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row1.expander); + fixture.detectChanges(); + + const childGrid = hierarchicalGrid.getChildGrids()[0]; + + cellElem = (childGrid.gridAPI.get_row_by_index(0).cells as QueryList).toArray()[1]; + UIInteractions.simulatePointerOverElementEvent('pointerdown', cellElem.nativeElement); + fixture.detectChanges(); + + expect(childGrid.tbody.nativeElement.attributes['aria-activedescendant'].value).toEqual(`${childGrid.id}_0_1`); + }); + + it('should emit columnInit when a column is added runtime.', async () => { + spyOn(hierarchicalGrid.columnInit, 'emit').and.callThrough(); + fixture.detectChanges(); + fixture.componentInstance.showAnotherCol = true; + fixture.detectChanges(); + await wait(30); + fixture.detectChanges(); + expect(hierarchicalGrid.columnInit.emit).toHaveBeenCalled(); + }); + + it('should throw a warning when primaryKey is set to a non-existing data field', () => { + jasmine.getEnv().allowRespy(true); + spyOn(console, 'warn'); + hierarchicalGrid.primaryKey = 'testField'; + fixture.componentInstance.rowIsland.primaryKey = 'testField-rowIsland'; + fixture.componentInstance.rowIsland2.primaryKey = 'testField-rowIsland2'; + fixture.detectChanges(); + + expect(console.warn).toHaveBeenCalledWith( + `Field "${hierarchicalGrid.primaryKey}" is not defined in the data. Set \`primaryKey\` to a valid field.` + ); + + let row1 = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row1.expander); + fixture.detectChanges(); + + let rowIsland = fixture.componentInstance.rowIsland; + expect(console.warn).toHaveBeenCalledWith( + `Field "${rowIsland.primaryKey}" is not defined in the data. Set \`primaryKey\` to a valid field.` + ); + + const secondLevelGrid = hierarchicalGrid.gridAPI.getChildGrids()[0]; + row1 = secondLevelGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row1.expander); + fixture.detectChanges(); + + rowIsland = fixture.componentInstance.rowIsland2; + expect(console.warn).toHaveBeenCalledWith( + `Field "${rowIsland.primaryKey}" is not defined in the data. Set \`primaryKey\` to a valid field.` + ); + jasmine.getEnv().allowRespy(false); + }); + + it('should calculate correct column headers width when rowSelection + expand indicators', () => { + hierarchicalGrid.rowSelection = 'multiple'; + fixture.detectChanges(); + + const headerRowElement = hierarchicalGrid.nativeElement.querySelector("igx-grid-header-row"); + const headerRowDiv = headerRowElement.querySelector(".igx-grid__tr"); + const headerRowChildren = Array.from(headerRowDiv.children); + + const elementsWidth = headerRowChildren.reduce((acc,el) => acc+(el as HTMLElement).offsetWidth, 0); + expect(elementsWidth).toEqual((headerRowDiv as HTMLElement).offsetWidth); + }); + }); + + describe('IgxHierarchicalGrid Row Islands #hGrid', () => { + let fixture; + let hierarchicalGrid: IgxHierarchicalGridComponent; + + beforeEach(() => { + fixture = TestBed.createComponent(IgxHierarchicalGridMultiLayoutComponent); + fixture.detectChanges(); + hierarchicalGrid = fixture.componentInstance.hgrid; + }); + + it('should allow defining row islands on the same level', () => { + const row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + const childGrids = hierarchicalGrid.gridAPI.getChildGrids(false); + const childRows = fixture.debugElement.queryAll(By.directive(IgxChildGridRowComponent)); + expect(childGrids.length).toBe(2); + expect(childRows.length).toBe(2); + const ri1 = fixture.componentInstance.rowIsland1; + const ri2 = fixture.componentInstance.rowIsland2; + expect(childRows[0].componentInstance.layout).toBe(ri1); + expect(childRows[1].componentInstance.layout).toBe(ri2); + }); + + it('should display correct data for sibling row islands', () => { + const uniqueData = [ + { + ID: 1, + ProductName: 'Parent Name', + childData: [ + { + ID: 11, + ProductName: 'Child1 Name' + } + ], + childData2: [ + { + ID: 12, + Col1: 'Child2 Col1', + Col2: 'Child2 Col2', + Col3: 'Child2 Col3' + } + ] + } + ]; + + fixture.componentInstance.data = uniqueData; + fixture.detectChanges(); + + const row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + const childGrids = hierarchicalGrid.gridAPI.getChildGrids(false); + + // check if data for each is correct + const child1 = childGrids[0] as IgxHierarchicalGridComponent; + const child2 = childGrids[1] as IgxHierarchicalGridComponent; + + expect(child1.data).toBe(fixture.componentInstance.data[0].childData); + expect(child2.data).toBe(fixture.componentInstance.data[0].childData2); + + expect(child1.getCellByColumn(0, 'ID').value).toBe(11); + expect(child1.getColumnByVisibleIndex(0).field).toBe('ID'); + expect(child1.getCellByColumn(0, 'ProductName').value).toBe('Child1 Name'); + + expect(child2.getCellByColumn(0, 'Col1').value).toBe('Child2 Col1'); + expect(child2.getCellByColumn(0, 'Col2').value).toBe('Child2 Col2'); + expect(child2.getCellByColumn(0, 'Col3').value).toBe('Child2 Col3'); + + }); + + it('should apply the set options on the row island to all of its related child grids.', () => { + fixture.componentInstance.height = '200px'; + fixture.detectChanges(); + const row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + const childGrids = hierarchicalGrid.gridAPI.getChildGrids(false) as IgxHierarchicalGridComponent[]; + expect(childGrids[0].height).toBe('200px'); + expect(childGrids[1].height).toBe('200px'); + }); + + it('Should apply runtime option changes to all related child grids (both existing and not yet initialized).', () => { + const row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + + const ri1 = fixture.componentInstance.rowIsland1; + ri1.rowSelection = GridSelectionMode.multiple; + fixture.detectChanges(); + + // check rendered grid + let childGrids = hierarchicalGrid.gridAPI.getChildGrids(false); + expect(childGrids[0].rowSelection).toBe(GridSelectionMode.multiple); + expect(childGrids[1].rowSelection).toBe(GridSelectionMode.none); + + // expand new row and check newly generated grid + const row2 = hierarchicalGrid.gridAPI.get_row_by_index(3) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row2.expander); + fixture.detectChanges(); + + childGrids = hierarchicalGrid.gridAPI.getChildGrids(false); + expect(childGrids[0].rowSelection).toBe(GridSelectionMode.multiple); + expect(childGrids[1].rowSelection).toBe(GridSelectionMode.multiple); + expect(childGrids[2].rowSelection).toBe(GridSelectionMode.none); + expect(childGrids[3].rowSelection).toBe(GridSelectionMode.none); + }); + + it('should apply column settings applied to the row island to all related child grids.', () => { + const row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + + const ri1 = fixture.componentInstance.rowIsland1 as IgxRowIslandComponent; + const ri2 = fixture.componentInstance.rowIsland2 as IgxRowIslandComponent; + + const childGrids = hierarchicalGrid.gridAPI.getChildGrids(false); + + const child1Cols = childGrids[0].columns; + const riCols = ri1.columns; + expect(child1Cols.length).toEqual(riCols.length); + for (const column of riCols) { + const col = child1Cols.find((c) => c.field === column.field); + expect(col).not.toBeNull(); + } + const child2Cols = childGrids[1].columns; + const ri2Cols = ri2.columns; + expect(child2Cols.length).toEqual(ri2Cols.length); + for (let j = 0; j < riCols.length; j++) { + const col = child2Cols.find((c) => c.field === ri2Cols[j].field); + expect(col).not.toBeNull(); + } + }); + + it('should allow setting different height/width in px/percent for row islands and grids should be rendered correctly.', () => { + const ri1 = fixture.componentInstance.rowIsland1; + + // test px + ri1.height = '200px'; + ri1.width = '200px'; + + fixture.detectChanges(); + + let row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + let childGrids = fixture.debugElement.queryAll(By.css('igx-child-grid-row')); + let childGrid = childGrids[0].query(By.css('igx-hierarchical-grid')).componentInstance; + + // check sizes are applied + expect(childGrid.width).toBe(ri1.width); + expect(childGrid.height).toBe(ri1.height); + expect(childGrid.nativeElement.style.height).toBe(ri1.height); + expect(childGrid.nativeElement.style.width).toBe(ri1.width); + // check virtualization state + expect(childGrid.verticalScrollContainer.state.chunkSize).toBe(4); + expect(childGrid.verticalScrollContainer.getScroll().scrollHeight).toBe(357); + + let hVirt = childGrid.gridAPI.get_row_by_index(0).virtDirRow; + expect(hVirt.state.chunkSize).toBe(2); + expect(hVirt.getScroll().scrollWidth).toBe(272); + // collapse row + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + // test % + ri1.height = '50%'; + ri1.width = '50%'; + + fixture.detectChanges(); + row = hierarchicalGrid.gridAPI.get_row_by_index(1) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + + + childGrids = fixture.debugElement.queryAll(By.css('igx-child-grid-row')); + childGrid = childGrids[0].query(By.css('igx-hierarchical-grid')).componentInstance; + + // check sizes are applied + expect(childGrid.width).toBe(ri1.width); + expect(childGrid.height).toBe(ri1.height); + expect(childGrid.nativeElement.style.height).toBe(ri1.height); + expect(childGrid.nativeElement.style.width).toBe(ri1.width); + // check virtualization state + expect(childGrid.verticalScrollContainer.state.chunkSize).toBe(11); + expect(childGrid.verticalScrollContainer.getScroll().scrollHeight).toBe(714); + hVirt = childGrid.gridAPI.get_row_by_index(0).virtDirRow; + expect(hVirt.getScroll().scrollWidth).toBe(272); + }); + + it('should destroy cached instances of child grids when root grid is destroyed', async () => { + const row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + + const children = hierarchicalGrid.gridAPI.getChildGrids(true); + expect(children.length).toBe(2); + const child1 = children[0] as IgxHierarchicalGridComponent; + const child2 = children[1] as IgxHierarchicalGridComponent; + expect(child1._destroyed).toBeFalsy(); + expect(child2._destroyed).toBeFalsy(); + hierarchicalGrid.verticalScrollContainer.scrollTo(hierarchicalGrid.dataView.length - 1); + await wait(); + fixture.detectChanges(); + + // check that we have child is not destroyed + expect(child1._destroyed).toBeFalsy(); + expect(child2._destroyed).toBeFalsy(); + + // destroy hgrid + fixture.destroy(); + + expect(child1._destroyed).toBeTruthy(); + expect(child2._destroyed).toBeTruthy(); + }); + + it('should emit child grid events with the related child grid instance as an event arg.', () => { + hierarchicalGrid.cellSelection = 'single'; + fixture.detectChanges(); + const row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + + const childGrids = fixture.debugElement.queryAll(By.css('igx-child-grid-row')); + const childGrid = childGrids[0].query(By.css('igx-hierarchical-grid')).componentInstance; + const cellElem = childGrid.gridAPI.get_row_by_index(0).cells.toArray()[0]; + const cell = childGrid.getRowByIndex(0).cells[0] as CellType; + const ri1 = fixture.componentInstance.rowIsland1; + + expect(cell.active).toBeFalse(); + expect(cell.selected).toBeFalse(); + + spyOn(ri1.cellClick, 'emit').and.callThrough(); + + const event = new Event('click'); + cellElem.nativeElement.dispatchEvent(event); + const args: IGridCellEventArgs = { + cell, + event, + owner: childGrid + }; + + fixture.detectChanges(); + expect(ri1.cellClick.emit).toHaveBeenCalledTimes(1); + expect(ri1.cellClick.emit).toHaveBeenCalledWith(args); + + cell.selected = true; + fixture.detectChanges(); + + expect(cell.selected).toBeTrue(); + expect(childGrid.selectedCells[0].row.index).toEqual(cell.row.index); + expect(childGrid.selectedCells[0].column.field).toEqual(cell.column.field); + }); + + it('should filter correctly on row island', () => { + const uniqueData = [ + { + ID: 1, + ProductName: 'Parent Name', + childData: [ + { + ID: 11, + ProductName: 'Child11 ProductName' + }, + { + ID: 12, + ProductName: 'Child12 ProductName' + } + ], + childData2: [ + { + ID: 21, + Col1: 'Child21 Col1', + Col2: 'Child21 Col2', + Col3: 'Child21 Col3' + }, + { + ID: 22, + Col1: 'Child22 Col1', + Col2: 'Child22 Col2', + Col3: 'Child22 Col3' + } + ] + } + ]; + fixture.componentInstance.data = uniqueData; + fixture.detectChanges(); + + const rowIsland1 = fixture.componentInstance.rowIsland1 as IgxRowIslandComponent; + rowIsland1.filter('ProductName', 'Child12', IgxStringFilteringOperand.instance().condition('contains'), true); + + const row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + + const childGrids = fixture.debugElement.queryAll(By.css('igx-child-grid-row')); + const childGrid1 = childGrids[0].query(By.css('igx-hierarchical-grid')).componentInstance; + expect(childGrid1.data.length).toEqual(2); + expect(childGrid1.filteredData.length).toEqual(1); + expect(childGrid1.rowList.length).toEqual(1); + expect(childGrid1.gridAPI.get_cell_by_index(0, 'ProductName').nativeElement.innerText).toEqual('Child12 ProductName'); + }); + + it('should allow binding to complex object.', () => { + const rowIsland1 = fixture.componentInstance.rowIsland1 as IgxRowIslandComponent; + const rowIsland2 = fixture.componentInstance.rowIsland2 as IgxRowIslandComponent; + rowIsland1.key = 'childData.Records'; + rowIsland2.key = 'childData2.Records'; + + hierarchicalGrid.childLayoutKeys = ['childData.Records', 'childData2.Records']; + const complexObjData = [ + { + ID: 1, + ProductName: 'Parent Name', + childData: { + Records: [ + { + ID: 11, + ProductName: 'Child11 ProductName' + }, + { + ID: 12, + ProductName: 'Child12 ProductName' + } + ] + }, + childData2: { + Records: [ + { + ID: 21, + Col1: 'Child21 Col1', + Col2: 'Child21 Col2', + Col3: 'Child21 Col3' + }, + { + ID: 22, + Col1: 'Child22 Col1', + Col2: 'Child22 Col2', + Col3: 'Child22 Col3' + } + ] + } + } + ]; + fixture.componentInstance.data = complexObjData; + fixture.detectChanges(); + + const row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + + const childGrids = fixture.debugElement.queryAll(By.css('igx-child-grid-row')); + const childGrid1 = childGrids[0].query(By.css('igx-hierarchical-grid')).componentInstance; + const childGrid2 = childGrids[1].query(By.css('igx-hierarchical-grid')).componentInstance; + expect(childGrid1.data.length).toEqual(2); + expect(childGrid2.data.length).toEqual(2); + + expect(childGrid1.data[0].ID).toBe(11); + expect(childGrid2.data[0].ID).toBe(21); + }); + + it('should expose the (child) grid instance as context of the empty grid template', () => { + fixture = TestBed.createComponent(IgxHierarchicalGridEmptyTemplateComponent); + fixture.detectChanges(); + hierarchicalGrid = fixture.componentInstance.hgrid; + expect(fixture.componentInstance.childGridRef).toBe(null); + + const firstDataItem = fixture.componentInstance.data[0]; + firstDataItem.childData = []; + fixture.componentInstance.data[0] = firstDataItem; + fixture.detectChanges(); + + hierarchicalGrid.expandRow(fixture.componentInstance.data[0]); + fixture.detectChanges(); + + const child1Grids = fixture.debugElement.queryAll(By.css('igx-child-grid-row')); + const child1Grid = child1Grids[0].query(By.css('igx-hierarchical-grid')); + const gridBody = child1Grid.query(By.css('.igx-grid__tbody')); + expect(gridBody.nativeElement.innerText).toBe('Get child grid ref'); //text from custom template button + + const button = gridBody.nativeElement.querySelector('button'); + button.click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.childGridRef.elementRef.nativeElement).toEqual(child1Grid.nativeElement); + }); + + it('should update columns property of row islands on columns change.', fakeAsync(() => { + + expect(hierarchicalGrid.childLayoutList.first.columns.length).toEqual(2, 'Initial columns length should be 2'); + expect(hierarchicalGrid.childLayoutList.first.columnList.length).toEqual(2, 'Initial columnList length should be 2'); + + fixture.componentInstance.toggleColumns = false; + fixture.detectChanges(); + tick(); + + expect(hierarchicalGrid.childLayoutList.first.columns.length).toEqual(0, 'Columns length should be 0 after toggle'); + expect(hierarchicalGrid.childLayoutList.first.columnList.length).toEqual(0, 'ColumnList length should be 0 after toggle'); + })); + + it('should resolve child grid cols default editable prop correctly based on row island\'s rowEditable.', () => { + hierarchicalGrid.rowEditable = false; + hierarchicalGrid.childLayoutList.first.rowEditable = true; + fixture.detectChanges(); + // expand row + const row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + + //check child grid column are editable + const childGrids = fixture.debugElement.queryAll(By.css('igx-child-grid-row')); + const childGrid1 = childGrids[0].query(By.css('igx-hierarchical-grid')).componentInstance; + + expect(childGrid1.columns[0].editable).toBeTrue(); + expect(childGrid1.columns[1].editable).toBeTrue(); + }); + + it('should update the row island summary UI when disabledSummaries is changed at runtime', fakeAsync(() => { + const masterRow = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(masterRow.expander); + fixture.detectChanges(); + + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0] as IgxHierarchicalGridComponent; + fixture.detectChanges(); + + childGrid.columns.forEach(c => c.hasSummary = true); + fixture.detectChanges(); + tick(); + + const column = childGrid.columns.find(c => c.field === 'ProductName'); + expect(column).toBeDefined(); + fixture.detectChanges(); + tick(); + + const summaryCells = childGrid.nativeElement.querySelectorAll('igx-grid-summary-cell'); + const summaryCell = summaryCells[1]; + + expect(summaryCell).toBeDefined(); + expect(summaryCell.textContent.trim().length).toBeGreaterThan(0); + + const getterSpy = spyOnProperty(column, 'disabledSummaries', 'get').and.callThrough(); + + column.disabledSummaries = ['count']; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + expect(getterSpy).toHaveBeenCalledTimes(7); + expect(summaryCell.textContent.trim()).toEqual(''); + })); + + it('should verify gridCreated and gridInitialized events emit correct parentRowData', () => { + const row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + const rowIsland = fixture.componentInstance.rowIsland1; + + spyOn(rowIsland.gridCreated, 'emit').and.callThrough(); + spyOn(rowIsland.gridInitialized, 'emit').and.callThrough(); + + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + + expect(rowIsland.gridCreated.emit).toHaveBeenCalledTimes(1); + expect(rowIsland.gridCreated.emit).toHaveBeenCalledWith(jasmine.objectContaining({ parentRowData: row.data })); + expect(rowIsland.gridInitialized.emit).toHaveBeenCalledTimes(1); + expect(rowIsland.gridInitialized.emit).toHaveBeenCalledWith(jasmine.objectContaining({ parentRowData: row.data })); + }); + }); + + describe('IgxHierarchicalGrid Children Sizing #hGrid', () => { + let fixture: ComponentFixture; + let hierarchicalGrid: IgxHierarchicalGridComponent; + const TBODY_CLASS = '.igx-grid__tbody-content'; + + beforeEach(() => { + fixture = TestBed.createComponent(IgxHierarchicalGridSizingComponent); + fixture.detectChanges(); + hierarchicalGrid = fixture.componentInstance.hgrid; + }); + + it('should create a child grid with null height when its data is unset then set to a number under 10', () => { + fixture.detectChanges(); + // expansion + const row = hierarchicalGrid.rowList.first as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + const childGrids = fixture.debugElement.queryAll(By.css('igx-child-grid-row')); + const childGrid = childGrids[0].query(By.css('igx-hierarchical-grid')).componentInstance; + + let defaultHeight = childGrids[0].query(By.css(TBODY_CLASS)).styles.height; + expect(defaultHeight).toBeFalsy(); + expect(childGrid.calcHeight).toBeNull(); + childGrid.data = fixture.componentInstance.data; + fixture.detectChanges(); + + defaultHeight = childGrids[0].query(By.css(TBODY_CLASS)).styles.height; + expect(defaultHeight).toBeFalsy(); + expect(childGrid.calcHeight).toBeNull(); + expect(childGrid.data.length).toEqual(1); + expect(childGrid.rowList.length).toEqual(1); + }); + + it('should create a child grid with auto-size when its data is unset then set to a number above 10', () => { + fixture.detectChanges(); + // expansion + const row = hierarchicalGrid.rowList.first as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + const childGrids = fixture.debugElement.queryAll(By.css('igx-child-grid-row')); + const childGrid = childGrids[0].query(By.css('igx-hierarchical-grid')).componentInstance; + + let defaultHeight = childGrids[0].query(By.css(TBODY_CLASS)).styles.height; + expect(defaultHeight).toBeFalsy(); + expect(childGrid.calcHeight).toBeNull(); + childGrid.data = fixture.componentInstance.fullData; + fixture.detectChanges(); + + defaultHeight = childGrids[0].query(By.css(TBODY_CLASS)).styles.height; + expect(defaultHeight).toBe('510px'); + expect(childGrid.calcHeight).toBe(510); + expect(childGrid.data.length).toEqual(100000); + expect(childGrid.rowList.length).toEqual(11); + }); + + it('should create a child grid with auto-size when its data is unset then set to a number above 10 and height is 50%', () => { + fixture.componentInstance.childHeight = '50%'; + fixture.detectChanges(); + // expansion + const row = hierarchicalGrid.rowList.first as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + const childGrids = fixture.debugElement.queryAll(By.css('igx-child-grid-row')); + const childGrid = childGrids[0].query(By.css('igx-hierarchical-grid')).componentInstance; + + let defaultHeight = childGrids[0].query(By.css(TBODY_CLASS)).styles.height; + expect(defaultHeight).toBeFalsy(); + expect(childGrid.calcHeight).toBeNull(); + childGrid.data = fixture.componentInstance.fullData; + fixture.detectChanges(); + + defaultHeight = childGrids[0].query(By.css(TBODY_CLASS)).styles.height; + expect(defaultHeight).toBe('510px'); + expect(childGrid.calcHeight).toBe(510); + expect(childGrid.data.length).toEqual(100000); + expect(childGrid.rowList.length).toEqual(11); + }); + + it('should create a child grid fixed size when height is set to px', () => { + fixture.componentInstance.childHeight = '600px'; + fixture.detectChanges(); + // expansion + const row = hierarchicalGrid.rowList.first as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + const childGrids = fixture.debugElement.queryAll(By.css('igx-child-grid-row')); + const childGrid = childGrids[0].query(By.css('igx-hierarchical-grid')).componentInstance; + + let defaultHeight = childGrids[0].query(By.css(TBODY_CLASS)).styles.height; + let defaultHeightNum = parseInt(defaultHeight, 10); + expect(defaultHeightNum).toBeGreaterThan(500); + expect(defaultHeightNum).toBeLessThan(600); + expect(childGrid.calcHeight).toBeGreaterThan(500); + expect(childGrid.calcHeight).toBeLessThan(600); + expect(childGrid.rowList.length).toEqual(0); + childGrid.data = fixture.componentInstance.fullData; + fixture.detectChanges(); + + defaultHeight = childGrids[0].query(By.css(TBODY_CLASS)).styles.height; + defaultHeightNum = parseInt(defaultHeight, 10); + expect(defaultHeightNum).toBeGreaterThan(500); + expect(defaultHeightNum).toBeLessThan(600); + expect(childGrid.calcHeight).toBeGreaterThan(500); + expect(childGrid.calcHeight).toBeLessThan(600); + expect(childGrid.data.length).toEqual(100000); + expect(childGrid.rowList.length).toEqual(12); + }); + + it('should create a child grid null height regardless of data when height is set to null', () => { + fixture.componentInstance.childHeight = null; + fixture.detectChanges(); + // expansion + const row = hierarchicalGrid.rowList.first as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + const childGrids = fixture.debugElement.queryAll(By.css('igx-child-grid-row')); + const childGrid = childGrids[0].query(By.css('igx-hierarchical-grid')).componentInstance; + + let defaultHeight = childGrids[0].query(By.css(TBODY_CLASS)).styles.height; + expect(defaultHeight).toBeFalsy(); + expect(childGrid.calcHeight).toBeNull(); + childGrid.data = fixture.componentInstance.semiData; + fixture.detectChanges(); + + defaultHeight = childGrids[0].query(By.css(TBODY_CLASS)).styles.height; + expect(defaultHeight).toBeFalsy(); + expect(childGrid.calcHeight).toBeNull(); + expect(childGrid.data.length).toEqual(15); + expect(childGrid.rowList.length).toEqual(15); + }); + }); + + describe('IgxHierarchicalGrid Remote Scenarios #hGrid', () => { + let fixture: ComponentFixture; + const TBODY_CLASS = '.igx-grid__tbody-content'; + const THEAD_CLASS = '.igx-grid-thead'; + + beforeEach(() => { + fixture = TestBed.createComponent(IgxHGridRemoteOnDemandComponent); + fixture.detectChanges(); + }); + + // To investigate why it times out + it('should render loading indicator when loading and autoGenerate are enabled', fakeAsync(() => { + fixture.detectChanges(); + + const grid = fixture.componentInstance.instance; + const gridBody = fixture.debugElement.query(By.css(TBODY_CLASS)); + const gridHead = fixture.debugElement.query(By.css(THEAD_CLASS)); + let loadingIndicator = gridBody.query(By.css('.igx-grid__loading')); + let colHeaders = gridHead.queryAll(By.css('igx-grid-header')); + + expect(loadingIndicator).not.toBeNull(); + expect(colHeaders.length).toBe(0); + expect(gridBody.nativeElement.textContent).not.toEqual(grid.emptyFilteredGridMessage); + + // Check for loaded rows in grid's container + fixture.componentInstance.databind(); + fixture.detectChanges(); + + loadingIndicator = gridBody.query(By.css('.igx-grid__loading')); + colHeaders = gridHead.queryAll(By.css('igx-grid-header')); + expect(colHeaders.length).toBeGreaterThan(0); + expect(loadingIndicator).toBeNull(); + + const row = grid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + // Tick is required for height to be recalculated and rerendered + tick(); + fixture.detectChanges(); + + const rowIslandDOM = fixture.debugElement.query(By.css('.igx-grid__hierarchical-indent .igx-grid')); + const rowIslandBody = rowIslandDOM.query(By.css('.igx-grid__tbody-content')); + expect(parseInt(window.getComputedStyle(rowIslandBody.nativeElement).height, 10)).toBe(255); + })); + + it('should render disabled collapse all icon for child grid even when it has no data but with child row island', () => { + const hierarchicalGrid = fixture.componentInstance.instance; + + fixture.componentInstance.databind(); + fixture.detectChanges(); + + const row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + + const gridHead = fixture.debugElement.queryAll(By.css(THEAD_CLASS))[1]; + const headerExpanderElem = gridHead.queryAll(By.css('.igx-grid__hierarchical-expander--header'))[0]; + const icon = headerExpanderElem.query(By.css('igx-icon')).componentInstance; + const iconTxt = headerExpanderElem.query(By.css('igx-icon')).nativeElement.textContent.toLowerCase(); + expect(iconTxt).toBe('unfold_less'); + expect(icon.getActive).toBe(false); + }); + + it('should keep already expanded child grids\' data when expanding subsequent ones', fakeAsync(() => { + const hierarchicalGrid = fixture.componentInstance.instance; + + fixture.componentInstance.databind(); + fixture.detectChanges(); + + const row0 = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row0.expander); + fixture.detectChanges(); + tick(); + + let childGrids = hierarchicalGrid.gridAPI.getChildGrids(); + expect(childGrids.length).toBe(1); + expect(childGrids[0].data.length).toBeGreaterThan(0); + + const row1 = hierarchicalGrid.gridAPI.get_row_by_index(2) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row1.expander); + fixture.detectChanges(); + tick(); + + childGrids = hierarchicalGrid.gridAPI.getChildGrids(); + expect(childGrids.length).toBe(2); + expect(childGrids[0].data.length).toBeGreaterThan(0); + expect(childGrids[1].data.length).toBeGreaterThan(0); + })); + }); + + describe('IgxHierarchicalGrid Template Changing Scenarios #hGrid', () => { + const THEAD_CLASS = '.igx-grid-thead'; + let fixture: ComponentFixture; + let hierarchicalGrid: IgxHierarchicalGridComponent; + + beforeEach(() => { + fixture = TestBed.createComponent(IgxHierarchicalGridColumnsUpdateComponent); + fixture.detectChanges(); + hierarchicalGrid = fixture.componentInstance.hgrid; + }); + + it('should render correct columns when setting columns for child in AfterViewInit using @for', () => { + const gridHead = fixture.debugElement.query(By.css(THEAD_CLASS)); + const colHeaders = gridHead.queryAll(By.css('igx-grid-header')); + expect(colHeaders.length).toEqual(2); + expect(colHeaders[0].nativeElement.innerText).toEqual('ID'); + expect(colHeaders[1].nativeElement.innerText).toEqual('ProductName'); + + const row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + + const child1Grids = fixture.debugElement.queryAll(By.css('igx-child-grid-row')); + const child1Grid = child1Grids[0].query(By.css('igx-hierarchical-grid')); + const child1Headers = child1Grid.queryAll(By.css('igx-grid-header')); + + expect(child1Headers.length).toEqual(5); + expect(child1Headers[0].nativeElement.innerText).toEqual('ID'); + expect(child1Headers[1].nativeElement.innerText).toEqual('ProductName'); + expect(child1Headers[2].nativeElement.innerText).toEqual('Col1'); + expect(child1Headers[3].nativeElement.innerText).toEqual('Col2'); + expect(child1Headers[4].nativeElement.innerText).toEqual('Col3'); + + const row1 = child1Grid.componentInstance.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row1.expander); + fixture.detectChanges(); + + const child2Grids = child1Grid.queryAll(By.css('igx-child-grid-row')); + const child2Grid = child2Grids[0].query(By.css('igx-hierarchical-grid')); + const child2Headers = child2Grid.queryAll(By.css('igx-grid-header')); + + expect(child2Headers.length).toEqual(3); + expect(child2Headers[0].nativeElement.innerText).toEqual('ID'); + expect(child2Headers[1].nativeElement.innerText).toEqual('ProductName'); + expect(child2Headers[2].nativeElement.innerText).toEqual('Col1'); + }); + + it('should render correct columns when setting columns for parent and child post init using @for', fakeAsync(() => { + const row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + + const child1Grids = fixture.debugElement.queryAll(By.css('igx-child-grid-row')); + const child1Grid = child1Grids[0].query(By.css('igx-hierarchical-grid')).componentInstance; + + fixture.componentInstance.parentCols = ['Col1', 'Col2']; + fixture.componentInstance.islandCols1 = ['ID', 'ProductName', 'Col1']; + fixture.detectChanges(); + tick(); + + // check parent cols + expect(hierarchicalGrid.columns.length).toBe(4); + expect(hierarchicalGrid.columns[0].field).toBe('ID'); + expect(hierarchicalGrid.columns[1].field).toBe('ProductName'); + expect(hierarchicalGrid.columns[2].field).toBe('Col1'); + expect(hierarchicalGrid.columns[3].field).toBe('Col2'); + // check child cols + expect(child1Grid.columns.length).toBe(3); + expect(child1Grid.columns[0].field).toBe('ID'); + expect(child1Grid.columns[1].field).toBe('ProductName'); + expect(child1Grid.columns[2].field).toBe('Col1'); + })); + + it('should update columns for expanded child when adding column to row island', fakeAsync(() => { + const row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + + const child1Grids = fixture.debugElement.queryAll(By.css('igx-child-grid-row')); + const child1Grid = child1Grids[0].query(By.css('igx-hierarchical-grid')); + + const row1 = child1Grid.componentInstance.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row1.expander); + fixture.detectChanges(); + + const child2Grids = child1Grid.queryAll(By.css('igx-child-grid-row')); + const child2Grid = child2Grids[0].query(By.css('igx-hierarchical-grid')); + let child2Headers = child2Grid.queryAll(By.css('igx-grid-header')); + + expect(child2Headers.length).toEqual(3); + expect(child2Headers[0].nativeElement.innerText).toEqual('ID'); + expect(child2Headers[1].nativeElement.innerText).toEqual('ProductName'); + expect(child2Headers[2].nativeElement.innerText).toEqual('Col1'); + + fixture.componentInstance.islandCols2.push('Col2'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + child2Headers = child2Grid.queryAll(By.css('igx-grid-header')); + expect(child2Headers.length).toEqual(4); + expect(child2Headers[0].nativeElement.innerText).toEqual('ID'); + expect(child2Headers[1].nativeElement.innerText).toEqual('ProductName'); + expect(child2Headers[2].nativeElement.innerText).toEqual('Col1'); + expect(child2Headers[3].nativeElement.innerText).toEqual('Col2'); + + const child1Headers = child1Grid.query(By.css(THEAD_CLASS)).queryAll(By.css('igx-grid-header')); + expect(child1Headers.length).toEqual(5); + expect(child1Headers[0].nativeElement.innerText).toEqual('ID'); + expect(child1Headers[1].nativeElement.innerText).toEqual('ProductName'); + expect(child1Headers[2].nativeElement.innerText).toEqual('Col1'); + expect(child1Headers[3].nativeElement.innerText).toEqual('Col2'); + expect(child1Headers[4].nativeElement.innerText).toEqual('Col3'); + + const gridHead = fixture.debugElement.query(By.css(THEAD_CLASS)); + const colHeaders = gridHead.queryAll(By.css('igx-grid-header')); + expect(colHeaders.length).toEqual(2); + expect(colHeaders[0].nativeElement.innerText).toEqual('ID'); + expect(colHeaders[1].nativeElement.innerText).toEqual('ProductName'); + })); + + it('should update columns for rendered child that is collapsed when adding column to row island', fakeAsync(() => { + const row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + + const child1Grids = fixture.debugElement.queryAll(By.css('igx-child-grid-row')); + const child1Grid = child1Grids[0].query(By.css('igx-hierarchical-grid')); + + const row1 = child1Grid.componentInstance.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row1.expander); + fixture.detectChanges(); + + const child2Grids = child1Grid.queryAll(By.css('igx-child-grid-row')); + const child2Grid = child2Grids[0].query(By.css('igx-hierarchical-grid')); + let child2Headers = child2Grid.queryAll(By.css('igx-grid-header')); + + expect(child2Headers.length).toEqual(3); + expect(child2Headers[0].nativeElement.innerText).toEqual('ID'); + expect(child2Headers[1].nativeElement.innerText).toEqual('ProductName'); + expect(child2Headers[2].nativeElement.innerText).toEqual('Col1'); + + UIInteractions.simulateClickAndSelectEvent(row1.expander); + fixture.detectChanges(); + + fixture.componentInstance.islandCols2.push('Col2'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + UIInteractions.simulateClickAndSelectEvent(row1.expander); + fixture.detectChanges(); + + child2Headers = child2Grid.queryAll(By.css('igx-grid-header')); + expect(child2Headers.length).toEqual(4); + expect(child2Headers[0].nativeElement.innerText).toEqual('ID'); + expect(child2Headers[1].nativeElement.innerText).toEqual('ProductName'); + expect(child2Headers[2].nativeElement.innerText).toEqual('Col1'); + expect(child2Headers[3].nativeElement.innerText).toEqual('Col2'); + })); + + it('test getRowByIndex API methods', () => { + const nonExistingRow = hierarchicalGrid.getRowByKey('nonexisting'); + expect(nonExistingRow).toBeUndefined(); + + const nonExistingRow2 = hierarchicalGrid.getRowByIndex(-1); + expect(nonExistingRow2).toBeUndefined(); + + const cell00 = hierarchicalGrid.getCellByColumn(0, 'ID'); + expect(cell00.row.index).toBe(0); + expect(cell00.column.visibleIndex).toBe(0); + + const row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + + const child1Grids = fixture.debugElement.queryAll(By.css('igx-child-grid-row')); + const child1Grid = child1Grids[0].query(By.css('igx-hierarchical-grid')); + + const firstRow = child1Grid.componentInstance.getRowByIndex(0); + firstRow.expanded = true; + + expect(firstRow.hasChildren).toBe(true); + expect(firstRow.children).toBeUndefined(); + expect(firstRow.viewIndex).toEqual(0); + expect(firstRow.key).toBeDefined(); + expect(firstRow.data.ID).toEqual('00'); + expect(firstRow.pinned).toBe(false); + expect(firstRow.selected).toBe(false); + expect(firstRow.expanded).toBe(true); + expect(firstRow.deleted).toBe(false); + expect(firstRow.inEditMode).toBe(false); + + // Toggle expanded state + firstRow.expanded = false; + expect(firstRow.expanded).toBe(false); + }); + }); + + describe('IgxHierarchicalGrid hide child columns', () => { + let fixture: ComponentFixture; + let hierarchicalGrid: IgxHierarchicalGridComponent; + + beforeEach(() => { + fixture = TestBed.createComponent(IgxHierarchicalGridHidingPinningColumnsComponent); + fixture.detectChanges(); + hierarchicalGrid = fixture.componentInstance.hgrid; + }); + + it('should fire hiddenChange and pinnedChange events for child grid.', () => { + const row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + + const child1Grids = fixture.debugElement.queryAll(By.css('igx-child-grid-row')); + const child1Grid = child1Grids[0].query(By.css('igx-hierarchical-grid')); + + // Pinning + + const childHeader1 = GridFunctions.getColumnHeaders(fixture)[2]; + + const firstHeaderIcon = childHeader1.query(By.css('.igx-icon')); + + spyOn(child1Grid.componentInstance.columns[0].pinnedChange, 'emit').and.callThrough(); + + expect(GridFunctions.isHeaderPinned(childHeader1.parent)).toBeFalsy(); + expect(child1Grid.componentInstance.columns[0].pinned).toBeFalsy(); + expect(firstHeaderIcon).toBeDefined(); + + UIInteractions.simulateClickAndSelectEvent(firstHeaderIcon); + fixture.detectChanges(); + + expect(child1Grid.componentInstance.columns[0].pinnedChange.emit).toHaveBeenCalledTimes(1); + expect(child1Grid.componentInstance.columns[0].pinned).toBeTruthy(); + + // Hiding + + const childHeader2 = GridFunctions.getColumnHeaders(fixture)[4]; + + const secondHeaderIcon = childHeader2.query(By.css('.igx-icon')); + + const lastIndex = child1Grid.componentInstance.columns.length - 1; + spyOn(child1Grid.componentInstance.columns[lastIndex].hiddenChange, 'emit').and.callThrough(); + + expect(child1Grid.componentInstance.columns[lastIndex].hidden).toBeFalsy(); + expect(secondHeaderIcon).toBeDefined(); + + UIInteractions.simulateClickAndSelectEvent(secondHeaderIcon); + fixture.detectChanges(); + + expect(child1Grid.componentInstance.columns[lastIndex].hiddenChange.emit).toHaveBeenCalledTimes(1); + expect(child1Grid.componentInstance.columns[lastIndex].hidden).toBeTruthy(); + }); + }); + + describe('IgxHierarchicalGrid Runtime Row Island change Scenarios #hGrid', () => { + let fixture: ComponentFixture; + let hierarchicalGrid: IgxHierarchicalGridComponent; + + beforeEach(() => { + fixture = TestBed.createComponent(IgxHierarchicalGridToggleRIComponent); + fixture.detectChanges(); + hierarchicalGrid = fixture.componentInstance.hgrid; + }); + + it('should allow changing row islands runtime in root grid.', () => { + let row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + + let hGrids = fixture.debugElement.queryAll(By.css('igx-hierarchical-grid')); + let childGrids = hierarchicalGrid.gridAPI.getChildGrids(); + expect(childGrids.length).toBe(1); + expect(hGrids.length).toBe(2); + + fixture.componentInstance.toggleRI = false; + fixture.detectChanges(); + + hGrids = fixture.debugElement.queryAll(By.css('igx-hierarchical-grid')); + childGrids = hierarchicalGrid.gridAPI.getChildGrids(); + expect(childGrids.length).toBe(0); + expect(hGrids.length).toBe(1); + expect(row.expander).toBe(undefined); + + fixture.componentInstance.toggleRI = true; + fixture.detectChanges(); + + hGrids = fixture.debugElement.queryAll(By.css('igx-hierarchical-grid')); + childGrids = hierarchicalGrid.gridAPI.getChildGrids(); + row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + expect(childGrids.length).toBe(1); + expect(hGrids.length).toBe(2); + expect(row.expander).not.toBe(undefined); + }); + + it('should allow changing row islands runtime in child grid.', () => { + const row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + + let childGrid = hierarchicalGrid.gridAPI.getChildGrids()[0]; + const childRow = childGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(childRow.expander); + fixture.detectChanges(); + + let hGrids = fixture.debugElement.queryAll(By.css('igx-hierarchical-grid')); + expect(hGrids.length).toBe(3); + expect(childGrid.gridAPI.getChildGrids().length).toBe(1); + + fixture.componentInstance.toggleChildRI = false; + fixture.detectChanges(); + + hGrids = fixture.debugElement.queryAll(By.css('igx-hierarchical-grid')); + childGrid = hierarchicalGrid.gridAPI.getChildGrids()[0]; + expect(hGrids.length).toBe(2); + expect(childGrid.gridAPI.getChildGrids().length).toBe(0); + + fixture.componentInstance.toggleChildRI = true; + fixture.detectChanges(); + + hGrids = fixture.debugElement.queryAll(By.css('igx-hierarchical-grid')); + childGrid = hierarchicalGrid.gridAPI.getChildGrids()[0]; + expect(hGrids.length).toBe(3); + expect(childGrid.gridAPI.getChildGrids().length).toBe(1); + + }); + + it('should allow changing row islands runtime in nested child grid.', () => { + const row = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(row.expander); + fixture.detectChanges(); + + const childGrid = hierarchicalGrid.gridAPI.getChildGrids()[0]; + const childRow = childGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(childRow.expander); + fixture.detectChanges(); + + let hGrids = fixture.debugElement.queryAll(By.css('igx-hierarchical-grid')); + expect(hGrids.length).toBe(3); + expect(childGrid.gridAPI.getChildGrids().length).toBe(1); + + fixture.componentInstance.toggleRINested = true; + fixture.detectChanges(); + + const nestedChildGrid = childGrid.gridAPI.getChildGrids()[0]; + const nestedChildRow = nestedChildGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(nestedChildRow.expander); + fixture.detectChanges(); + + hGrids = fixture.debugElement.queryAll(By.css('igx-hierarchical-grid')); + expect(hGrids.length).toBe(4); + expect(nestedChildGrid.gridAPI.getChildGrids().length).toBe(1); + }); + + it(`Should apply template to both parent and child grids`, () => { + const customFixture = TestBed.createComponent(IgxHierarchicalGridCustomRowEditOverlayComponent); + customFixture.detectChanges(); + hierarchicalGrid = customFixture.componentInstance.hgrid; + hierarchicalGrid.primaryKey = 'ID'; + hierarchicalGrid.rowEditable = true; + + let cellElem = hierarchicalGrid.gridAPI.get_cell_by_index(0, 'ProductName'); + let row = hierarchicalGrid.gridAPI.get_row_by_index(0); + + UIInteractions.simulateDoubleClickAndSelectEvent(cellElem); + customFixture.detectChanges(); + expect(row.inEditMode).toBe(true); + + const mainGridOverlay = GridFunctions.getRowEditingOverlay(customFixture); + expect(mainGridOverlay).not.toBeNull(); + + const mainGridOverlayTextContent = mainGridOverlay.querySelector('.igx-banner__text').textContent; + const mainGridOverlayActionsContent = mainGridOverlay.querySelector('.igx-banner__actions').textContent; + + expect(mainGridOverlayTextContent).toBe(' You have 0 changes in this row and 0 hidden columns\n'); + expect(mainGridOverlayActionsContent).toBe('CancelDone'); + + hierarchicalGrid.expandRow(hierarchicalGrid.getRowByIndex(0).key); + customFixture.detectChanges(); + + const secondLevelGrid = hierarchicalGrid.gridAPI.getChildGrids()[0]; + expect(secondLevelGrid).not.toBeNull(); + + secondLevelGrid.primaryKey = 'ID'; + customFixture.detectChanges(); + + expect(GridFunctions.getRowEditingOverlay(customFixture)).toBeDefined(); + + cellElem = secondLevelGrid.gridAPI.get_cell_by_index(0, 'ProductName'); + row = secondLevelGrid.gridAPI.get_row_by_index(0); + + UIInteractions.simulateDoubleClickAndSelectEvent(cellElem); + customFixture.detectChanges(); + expect(row.inEditMode).toBe(true); + + const nestedGridOverlay = GridFunctions.getRowEditingOverlay(customFixture); + expect(nestedGridOverlay).not.toBeNull(); + + const nestedGridOverlayTextContent = nestedGridOverlay.querySelector('.igx-banner__text').textContent; + const nestedGridOverlayActionsContent = nestedGridOverlay.querySelector('.igx-banner__actions').textContent; + + expect(nestedGridOverlayTextContent).toBe('Row Edit Text'); + expect(nestedGridOverlayActionsContent).toBe('Row Edit Actions'); + }); + + it(`Should set ID column's width property to auto on init`, () => { + const customFixture = TestBed.createComponent(IgxHierarchicalGridAutoSizeColumnsComponent); + hierarchicalGrid.primaryKey = 'ID'; + hierarchicalGrid = customFixture.componentInstance.hgrid; + customFixture.detectChanges(); + + expect(hierarchicalGrid).not.toBeNull(); + expect(hierarchicalGrid).not.toBeUndefined(); + }); + + it(`Should keep the overlay when scrolling an igxHierarchicalGrid with an opened + row island with <= 2 data records`, async () => { + hierarchicalGrid.primaryKey = 'ID'; + hierarchicalGrid.rowEditable = true; + hierarchicalGrid.getRowByIndex(0).expanded = true; + fixture.detectChanges(); + + const secondLevelGrid = hierarchicalGrid.gridAPI.getChildGrids()[0]; + expect(secondLevelGrid).not.toBeNull(); + secondLevelGrid.getRowByIndex(0).expanded = true; + fixture.detectChanges(); + + const thirdLevelGrid = secondLevelGrid.gridAPI.getChildGrids()[0]; + thirdLevelGrid.primaryKey = 'ID'; + thirdLevelGrid.rowEditable = true; + fixture.detectChanges(); + + expect(thirdLevelGrid).not.toBeNull(); + expect(thirdLevelGrid.data.length).toBe(2); + + const cellElem = thirdLevelGrid.gridAPI.get_cell_by_index(0, 'ChildLevels'); + const row = thirdLevelGrid.gridAPI.get_row_by_index(0); + + UIInteractions.simulateDoubleClickAndSelectEvent(cellElem); + fixture.detectChanges(); + expect(row.inEditMode).toBe(true); + fixture.detectChanges(); + + let overlay = GridFunctions.getRowEditingOverlay(fixture); + expect(overlay).not.toBeNull(); + + await hierarchicalGrid.dragScroll({ left: 0, top: 10 }); + fixture.detectChanges(); + await wait(30); + + overlay = GridFunctions.getRowEditingOverlay(fixture); + expect(overlay).not.toBeNull(); + }); + + }); + + describe('Columns and row islands runtime change', () => { + let fixture: ComponentFixture; + let hierarchicalGrid: IgxHierarchicalGridComponent; + + it('should allow changing columns runtime in root grid when there are no row islands.', fakeAsync(() => { + fixture = TestBed.createComponent(IgxHierarchicalGridToggleRIAndColsComponent); + fixture.detectChanges(); + hierarchicalGrid = fixture.componentInstance.hgrid; + expect(hierarchicalGrid.childLayoutList.length).toBe(0); + expect(hierarchicalGrid.columns.length).toBe(0); + fixture.componentInstance.toggleColumns = true; + fixture.detectChanges(); + tick(); + + expect(hierarchicalGrid.columns.length).toBe(2); + + fixture.componentInstance.toggleRI = true; + fixture.detectChanges(); + expect(hierarchicalGrid.childLayoutList.length).toBe(1); + })); + }); + + describe('IgxHierarchicalGrid custom template #hGrid', () => { + + it('should allow setting custom template for expand/collapse icons', () => { + const fixture = TestBed.createComponent(IgxHierarchicalGridCustomTemplateComponent); + fixture.detectChanges(); + + const hierarchicalGrid = fixture.componentInstance.hgrid; + + let rows = hierarchicalGrid.dataRowList.toArray(); + for (const row of rows) { + const expander = row.nativeElement.querySelector('.igx-grid__hierarchical-expander'); + expect((expander as HTMLElement).innerText).toBe('COLLAPSED'); + } + hierarchicalGrid.expandChildren = true; + fixture.detectChanges(); + rows = hierarchicalGrid.dataRowList.toArray(); + for (const row of rows) { + const expander = row.nativeElement.querySelector('.igx-grid__hierarchical-expander'); + expect((expander as HTMLElement).innerText).toBe('EXPANDED'); + } + + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const childRows = childGrid.dataRowList.toArray(); + for (const row of childRows) { + const expander = row.nativeElement.querySelector('.igx-grid__hierarchical-expander'); + expect((expander as HTMLElement).innerText).toBe('COLLAPSED'); + } + + expect((hierarchicalGrid as any).headerHierarchyExpander.nativeElement.innerText).toBe('EXPANDED'); + expect((childGrid as any).headerHierarchyExpander.nativeElement.innerText).toBe('COLLAPSED'); + + childRows[0].toggle(); + fixture.detectChanges(); + expect((childGrid as any).headerHierarchyExpander.nativeElement.innerText).toBe('EXPANDED'); + hierarchicalGrid.expandChildren = false; + fixture.detectChanges(); + expect((hierarchicalGrid as any).headerHierarchyExpander.nativeElement.innerText).toBe('COLLAPSED'); + }); + + it('should allow setting custom template for excel style filtering on row island.', () => { + const fixture = TestBed.createComponent(IgxHierarchicalGridCustomFilteringTemplateComponent); + fixture.detectChanges(); + + const hierarchicalGrid = fixture.componentInstance.hgrid; + const ri = fixture.componentInstance.rowIsland; + const firstRow = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(firstRow.expander); + fixture.detectChanges(); + + const childGrid = hierarchicalGrid.gridAPI.getChildGrids()[0] as IgxHierarchicalGridComponent; + expect(childGrid.excelStyleFilteringComponent).toBe(ri.excelStyleFilteringComponent); + }); + + it('should correctly filter templated row island in hierarchical grid', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxHierarchicalGridCustomFilteringTemplateComponent); + fixture.detectChanges(); + + const hierarchicalGrid = fixture.componentInstance.hgrid; + const firstRow = hierarchicalGrid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + UIInteractions.simulateClickAndSelectEvent(firstRow.expander); + fixture.detectChanges(); + + GridFunctions.clickExcelFilterIconFromCode(fixture, hierarchicalGrid, 'ProductName'); + + const searchComponent = GridFunctions.getExcelStyleSearchComponent(fixture, null, 'igx-hierarchical-grid'); + const inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fixture, searchComponent, 'igx-hierarchical-grid'); + UIInteractions.clickAndSendInputElementValue(inputNativeElement, 'A4', fixture); + + GridFunctions.clickApplyExcelStyleFiltering(fixture, null, 'igx-hierarchical-grid'); + fixture.detectChanges(); + + const gridCellValues = GridFunctions.getColumnCells(fixture, 'ProductName', 'igx-hierarchical-grid-cell') + .map(c => c.nativeElement.innerText) + .sort(); + + expect(gridCellValues.length).toBe(1); + })); + }); + + describe('IgxHierarchicalGrid Multi-Column Headers', () => { + let fixture: ComponentFixture; + let hierarchicalGrid: IgxHierarchicalGridComponent; + + beforeEach(() => { + fixture = TestBed.createComponent(IgxHierarchicalGridMCHComponent); + fixture.detectChanges(); + hierarchicalGrid = fixture.componentInstance.hGrid; + }); + + it('should fire expandedChange, hiddenChange and pinnedChange events for child grid.', () => { + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const columnGroup2 = childGrid.columns[4] as IgxColumnGroupComponent; + const columnGroup2Header = GridFunctions.getColumnGroupHeaders(fixture)[3]; + const expandIcon = columnGroup2Header.queryAll(By.css('.igx-icon'))[0]; + const pinIcon = columnGroup2Header.queryAll(By.css('.igx-icon'))[1]; + + expect(columnGroup2.expanded).toBeFalse(); + expect(columnGroup2.pinned).toBeFalse(); + + UIInteractions.simulateClickEvent(expandIcon.nativeElement); + fixture.detectChanges(); + + expect(columnGroup2.expanded).toBeTrue(); + + expect(fixture.componentInstance.expandedArgs).toBeDefined(); + expect(fixture.componentInstance.expandedArgs.args).toBeTrue(); + expect(fixture.componentInstance.hiddenArgs).toBeDefined(); + expect(fixture.componentInstance.hiddenArgs.args).toBeTrue(); + + UIInteractions.simulateClickEvent(pinIcon.nativeElement); + fixture.detectChanges(); + + expect(columnGroup2.pinned).toBeTrue(); + expect(fixture.componentInstance.pinnedArgs).toBeDefined(); + expect(fixture.componentInstance.pinnedArgs.args).toBeTrue(); + }); + }); +}); + +@Component({ + template: ` + + + @if (showAnotherCol) { + + } + + + + + + + + + + + `, + imports: [IgxHierarchicalGridComponent, IgxColumnComponent, IgxRowIslandComponent] +}) +export class IgxHierarchicalGridTestBaseComponent { + @ViewChild('hierarchicalGrid', { read: IgxHierarchicalGridComponent, static: true }) public hgrid: IgxHierarchicalGridComponent; + @ViewChild('rowIsland', { read: IgxRowIslandComponent, static: true }) public rowIsland: IgxRowIslandComponent; + @ViewChild('rowIsland2', { read: IgxRowIslandComponent, static: true }) public rowIsland2: IgxRowIslandComponent; + public data; + public width = '500px'; + public showAnotherCol = false; + constructor() { + // 3 level hierarchy + this.data = this.generateDataUneven(20, 3); + } + public generateDataUneven(count: number, level: number, parentID: string = null) { + const prods = []; + const currLevel = level; + let children; + for (let i = 0; i < count; i++) { + const rowID = parentID ? parentID + i : i.toString(); + if (level > 0) { + // Have child grids for row with even id less rows by not multiplying by 2 + children = this.generateDataUneven((i % 2 + 1) * Math.round(count / 3), currLevel - 1, rowID); + } + prods.push({ + ID: rowID, ChildLevels: currLevel, ProductName: 'Product: A' + i, Col1: i, + Col2: i, Col3: i, childData: children, childData2: children + }); + } + return prods; + } + + public clearData() { + this.data = []; + } +} + +@Component({ + template: ` + + + + + @if (toggleColumns) { + + } + @if (toggleColumns) { + + } + + + + + + + `, + imports: [IgxHierarchicalGridComponent, IgxColumnComponent, IgxRowIslandComponent] +}) +export class IgxHierarchicalGridMultiLayoutComponent extends IgxHierarchicalGridTestBaseComponent { + @ViewChild('rowIsland1', { read: IgxRowIslandComponent, static: true }) public rowIsland1: IgxRowIslandComponent; + @ViewChild('rowIsland2', { read: IgxRowIslandComponent, static: true }) public override rowIsland2: IgxRowIslandComponent; + public height = '100px'; + public toggleColumns = true; +} + +@Component({ + template: ` + + + + + + + + + `, + imports: [IgxHierarchicalGridComponent, IgxColumnComponent, IgxRowIslandComponent] +}) +export class IgxHGridRemoteOnDemandComponent { + public cdr = inject(ChangeDetectorRef); + + @ViewChild(IgxHierarchicalGridComponent, { read: IgxHierarchicalGridComponent, static: true }) + public instance: IgxHierarchicalGridComponent; + + @ViewChild('rowIsland1', { read: IgxRowIslandComponent, static: true }) + public rowIsland: IgxRowIslandComponent; + + @ViewChild('rowIsland2', { read: IgxRowIslandComponent, static: true }) + public rowIsland2: IgxRowIslandComponent; + + public data; + + public generateDataUneven(count: number, level: number, parendID: string = null) { + const prods = []; + const currLevel = level; + for (let i = 0; i < count; i++) { + const rowID = parendID ? parendID + i : i.toString(); + prods.push({ + ID: rowID, ChildLevels: currLevel, ProductName: 'Product: A' + i, Col1: i, + Col2: i, Col3: i + }); + } + return prods; + } + + public databind() { + this.data = this.generateDataUneven(20, 3); + } + + public generateRowIslandData(count: number) { + const prods = []; + for (let i = 0; i < count; i++) { + prods.push({ ID: i, ProductName: 'Product: A' + i }); + } + return prods; + } + + public gridCreated(event: IGridCreatedEventArgs, _rowIsland: IgxRowIslandComponent) { + setTimeout(() => { + event.grid.data = this.generateRowIslandData(5); + event.grid.cdr.detectChanges(); + }); + } +} + +@Component({ + template: ` + + + + @for (colField of parentCols; track colField) { + + } + + @for (colField of islandCols1; track colField) { + + } + + @for (colField of islandCols2; track colField) { + + } + + + `, + imports: [IgxHierarchicalGridComponent, IgxColumnComponent, IgxRowIslandComponent] +}) +export class IgxHierarchicalGridColumnsUpdateComponent extends IgxHierarchicalGridTestBaseComponent implements AfterViewInit { + public cdr = inject(ChangeDetectorRef); + + public cols1 = ['ID', 'ProductName', 'Col1', 'Col2', 'Col3']; + public cols2 = ['ID', 'ProductName', 'Col1']; + public parentCols = []; + public islandCols1 = []; + public islandCols2 = []; + + public ngAfterViewInit() { + this.islandCols1 = this.cols1; + this.islandCols2 = this.cols2; + this.cdr.detectChanges(); + } +} + +@Component({ + template: ` + + + + + + + + `, + imports: [IgxHierarchicalGridComponent, IgxColumnComponent, IgxRowIslandComponent] +}) +export class IgxHierarchicalGridSizingComponent { + @ViewChild('hierarchicalGrid', { read: IgxHierarchicalGridComponent, static: true }) + public hgrid: IgxHierarchicalGridComponent; + + @ViewChild('rowIsland', { read: IgxRowIslandComponent, static: true }) + public rowIsland: IgxRowIslandComponent; + + public childHeight = '100%'; + public data = [ + { + ID: 1, + ProductName: 'Car' + } + ]; + public fullData = Array.from({ length: 100000 }, (_, i) => ({ ID: i, ProductName: 'PN' + i })); + public semiData = Array.from({ length: 15 }, (_, i) => ({ ID: i, ProductName: 'PN' + i })); +} + +@Component({ + template: ` + + @if (toggleRI) { + + @if (toggleChildRI) { + + @if (toggleRINested) { + + + } + + } + + } + `, + imports: [IgxHierarchicalGridComponent, IgxRowIslandComponent] +}) +export class IgxHierarchicalGridToggleRIComponent extends IgxHierarchicalGridTestBaseComponent { + public toggleRI = true; + public toggleChildRI = true; + public toggleRINested = false; +} + +@Component({ + template: ` + + @if (toggleColumns) { + + } + @if (toggleColumns) { + + } + @if (toggleRI) { + + + } + `, + imports: [IgxHierarchicalGridComponent, IgxColumnComponent, IgxRowIslandComponent] +}) +export class IgxHierarchicalGridToggleRIAndColsComponent extends IgxHierarchicalGridToggleRIComponent { + public override toggleRI = false; + public toggleColumns = false; +} + +@Component({ + template: ` + + + + + + + + + + + EXPANDED + + + COLLAPSED + + + COLLAPSED + + + EXPANDED + + `, + imports: [IgxHierarchicalGridComponent, IgxColumnComponent, IgxRowIslandComponent, IgxRowExpandedIndicatorDirective, IgxRowCollapsedIndicatorDirective, IgxHeaderExpandedIndicatorDirective, IgxHeaderCollapsedIndicatorDirective] +}) +export class IgxHierarchicalGridCustomTemplateComponent extends IgxHierarchicalGridTestBaseComponent { } + +@Component({ + template: ` + + + + + + filter_alt + + + + + + + + + + + + + + + + + + EXPANDED + + + COLLAPSED + + + COLLAPSED + + + EXPANDED + + `, + imports: [ + IgxHierarchicalGridComponent, + IgxColumnComponent, + IgxRowIslandComponent, + IgxIconComponent, + IgxGridExcelStyleFilteringComponent, + IgxExcelStyleColumnOperationsTemplateDirective, + IgxExcelStyleHeaderComponent, + IgxExcelStyleSortingComponent, + IgxExcelStyleFilterOperationsTemplateDirective, + IgxExcelStyleSearchComponent, + IgxRowExpandedIndicatorDirective, + IgxRowCollapsedIndicatorDirective, + IgxHeaderExpandedIndicatorDirective, + IgxHeaderCollapsedIndicatorDirective + ] +}) +export class IgxHierarchicalGridCustomFilteringTemplateComponent extends IgxHierarchicalGridTestBaseComponent { } + +@Component({ + template: ` + +
    + {{column.header || column.field}} + lock +
    +
    + +
    + {{column.header || column.field}} + hide_source +
    +
    + + + + + + + + + `, + imports: [IgxHierarchicalGridComponent, IgxColumnComponent, IgxRowIslandComponent, IgxIconComponent, IgxCellHeaderTemplateDirective] +}) +export class IgxHierarchicalGridHidingPinningColumnsComponent extends IgxHierarchicalGridTestBaseComponent { + public cdr = inject(ChangeDetectorRef); + + + public pinColumn(col: ColumnType) { + col.pin(); + } + + public hideColumn(col: ColumnType) { + col.hidden = true; + } +} + +@Component({ + template: ` + + + + + + + + + + Row Edit Text + + + Row Edit Actions + + + `, + imports: [IgxHierarchicalGridComponent, IgxColumnComponent, IgxRowIslandComponent, IgxRowEditTextDirective, IgxRowEditActionsDirective] +}) +export class IgxHierarchicalGridCustomRowEditOverlayComponent extends IgxHierarchicalGridTestBaseComponent { } + +@Component({ + template: ` + + + + + + + + + + Row Edit Text + + + Row Edit Actions + + + `, + imports: [IgxHierarchicalGridComponent, IgxColumnComponent, IgxRowIslandComponent, IgxRowEditTextDirective, IgxRowEditActionsDirective] +}) +export class IgxHierarchicalGridAutoSizeColumnsComponent extends IgxHierarchicalGridTestBaseComponent { } + +@Component({ + template: ` + + {{ col.header ? col.header : col.field}} + push_pin + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `, + imports: [IgxHierarchicalGridComponent, IgxRowIslandComponent, IgxColumnComponent, IgxColumnGroupComponent, IgxIconComponent, IgxCellHeaderTemplateDirective] +}) +export class IgxHierarchicalGridMCHComponent { + @ViewChild('hGrid', { read: IgxHierarchicalGridComponent, static: true }) + public hGrid: IgxHierarchicalGridComponent; + + public expandedArgs: any; + public hiddenArgs: any; + public pinnedArgs: any; + + public data = [ + { + CustomerID: "VINET", + CompanyName: "Vins et alcools Chevalier", + ContactName: "Paul Henriot", + ContactTitle: "Accounting Manager", + Location: "59 rue de l'Abbaye, Reims, France", + Address: "59 rue de l'Abbaye", + City: "Reims", + Country: "France", + PostalCode: "51100", + Orders: [ + { + OrderID: 10248, + OrderDate: new Date("1996-07-04T00:00:00"), + RequiredDate: new Date("1996-08-01T00:00:00"), + ShippedDate: new Date("1996-07-16T00:00:00"), + ShipVia: 3, + Freight: 32.38, + ShipName: "Vins et alcools Chevalier", + }, + ], + }, + ]; + + public pinColumn(col: ColumnType) { + col.pinned ? col.unpin() : col.pin(); + } + + public expandedChange(args: any) { + this.expandedArgs = args; + } + + public hiddenChange(args: any) { + this.hiddenArgs = args; + } + + public pinnedChange(args: any) { + this.pinnedArgs = args; + } +} + +@Component({ + template: ` + + + + + + + + + + + `, + imports: [IgxHierarchicalGridComponent, IgxColumnComponent, IgxRowIslandComponent] +}) +export class IgxHierarchicalGridEmptyTemplateComponent extends IgxHierarchicalGridTestBaseComponent { + public childGridRef: IgxHierarchicalGridComponent = null; + constructor() { + super(); + const firstDataItem = this.data[0]; + firstDataItem.childData = []; + this.data[0] = firstDataItem; + } + + public getChildGridRef(grid: IgxHierarchicalGridComponent) { + this.childGridRef = grid; + } +} + +@Component({ + template: ` + + + + + + + + + + + + `, + imports: [IgxHierarchicalGridComponent, IgxColumnComponent, IgxRowIslandComponent] +}) +export class IgxHierarchicalGridMissingChildDataComponent { + @ViewChild('hGrid', { read: IgxHierarchicalGridComponent, static: true }) + public hGrid: IgxHierarchicalGridComponent; + + public data = [ + { root1: 1, root2: 1, level1data: [{ level1child1: 11, level1child2: 12 }] }, // missing level2data + { root1: 2, root2: 2, level1data: [{ level1child1: 21, level1child2: 22, level2data: [{ level2child1: 31, level2child2: 32 }] }] }, + { root1: 3, root2: 3, level1data: [] } + ]; +} diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.virtualization.spec.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.virtualization.spec.ts new file mode 100644 index 00000000000..59dab92f738 --- /dev/null +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid.virtualization.spec.ts @@ -0,0 +1,544 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Component, ViewChild } from '@angular/core'; +import { IgxHierarchicalGridComponent } from './hierarchical-grid.component'; +import { IgxRowIslandComponent } from './row-island.component'; +import { wait, UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { By } from '@angular/platform-browser'; +import { first, delay } from 'rxjs/operators'; +import { setupHierarchicalGridScrollDetection, clearGridSubs } from '../../../test-utils/helper-utils.spec'; +import { GridFunctions } from '../../../test-utils/grid-functions.spec'; +import { HierarchicalGridFunctions } from '../../../test-utils/hierarchical-grid-functions.spec'; +import { IgxHierarchicalRowComponent } from './hierarchical-row.component'; +import { IgxHierarchicalGridDefaultComponent } from '../../../test-utils/hierarchical-grid-components.spec'; +import { firstValueFrom } from 'rxjs'; +import { FilteringExpressionsTree, FilteringLogic, IgxStringFilteringOperand } from 'igniteui-angular/core'; +import { IgxGridNavigationService } from 'igniteui-angular/grids/core'; + +describe('IgxHierarchicalGrid Virtualization #hGrid', () => { + let fixture; + let hierarchicalGrid: IgxHierarchicalGridComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxHierarchicalGridTestBaseComponent, + IgxHierarchicalGridDefaultComponent + ], + providers: [ + IgxGridNavigationService + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(IgxHierarchicalGridTestBaseComponent); + fixture.detectChanges(); + hierarchicalGrid = fixture.componentInstance.hgrid; + }); + + it('should retain expansion state when scrolling.', async () => { + const firstRow = hierarchicalGrid.dataRowList.toArray()[0] as IgxHierarchicalRowComponent; + // first child of the row should expand indicator + (firstRow.nativeElement.children[0] as HTMLElement).click(); + fixture.detectChanges(); + expect(firstRow.expanded).toBeTruthy(); + const verticalScroll = fixture.componentInstance.hgrid.verticalScrollContainer; + const elem = verticalScroll['scrollComponent'].elementRef.nativeElement; + + // scroll down + elem.scrollTop = 1000; + await wait(100); + fixture.detectChanges(); + fixture.componentRef.hostView.detectChanges(); + expect(firstRow.expanded).toBeFalsy(); + + // scroll to top + elem.scrollTop = 0; + await wait(100); + fixture.detectChanges(); + fixture.componentRef.hostView.detectChanges(); + + expect(firstRow.expanded).toBeTruthy(); + }); + + it('Should retain child scroll position when expanding and collapsing through rows', async () => { + const firstRow = hierarchicalGrid.dataRowList.toArray()[0]; + // first child of the row should expand indicator + (firstRow.nativeElement.children[0] as HTMLElement).click(); + fixture.detectChanges(); + + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const verticalScroll = childGrid.verticalScrollContainer; + const elem = verticalScroll['scrollComponent'].elementRef.nativeElement; + + // scroll down + elem.scrollTop = 400; + fixture.detectChanges(); + fixture.componentRef.hostView.detectChanges(); + await wait(30); + fixture.detectChanges(); + + // collapse and expand the row + (firstRow.nativeElement.children[0] as HTMLElement).click(); + fixture.detectChanges(); + await wait(30); + fixture.detectChanges(); + (firstRow.nativeElement.children[0] as HTMLElement).click(); + fixture.detectChanges(); + await wait(30); + fixture.detectChanges(); + + expect(elem.scrollTop).toBe(400); + /** row toggle rAF */ + await wait(3 * 16); + }); + + it('Should retain child grid states (scroll position, selection, filtering, paging etc.) when scrolling', async () => { + setupHierarchicalGridScrollDetection(fixture, hierarchicalGrid); + const firstRow = hierarchicalGrid.dataRowList.toArray()[0]; + // first child of the row should expand indicator + (firstRow.nativeElement.children[0] as HTMLElement).click(); + await wait(); + fixture.detectChanges(); + + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0] as IgxHierarchicalGridComponent; + const childCell = childGrid.gridAPI.get_cell_by_index(0, 'ID'); + GridFunctions.focusCell(fixture, childCell); + fixture.detectChanges(); + + const filteringExpressionsTree = new FilteringExpressionsTree(FilteringLogic.And, 'ProductName'); + const expression = { + fieldName: 'ProductName', + searchVal: 'Product: A0', + condition: IgxStringFilteringOperand.instance().condition('startsWith'), + conditionName: 'startsWith' + }; + filteringExpressionsTree.filteringOperands.push(expression); + childGrid.filter('ProductName', null, filteringExpressionsTree); + await wait(); + fixture.detectChanges(); + expect(childGrid.rowList.length).toEqual(1); + expect(childGrid.getCellByColumn(0, 'ID').selected).toBeTruthy(); + + const verticalScroll = fixture.componentInstance.hgrid.verticalScrollContainer; + const elem = verticalScroll['scrollComponent'].elementRef.nativeElement; + // scroll down + elem.scrollTop = 1000; + await wait(); + fixture.detectChanges(); + + // scroll to top + elem.scrollTop = 0; + await wait(); + fixture.detectChanges(); + + expect(childGrid.rowList.length).toEqual(1); + expect(childGrid.getCellByColumn(0, 'ID').selected).toBeTruthy(); + clearGridSubs(); + }); + + it('should render correct data for child grid after scrolling and start index changes.', async () => { + const firstRow = hierarchicalGrid.dataRowList.toArray()[0]; + // first child of the row should expand indicator + (firstRow.nativeElement.children[0] as HTMLElement).click(); + fixture.detectChanges(); + const secondRow = hierarchicalGrid.dataRowList.toArray()[1]; + (secondRow.nativeElement.children[0] as HTMLElement).click(); + fixture.detectChanges(); + + const childGrid1 = hierarchicalGrid.gridAPI.getChildGrids(false)[0] as IgxHierarchicalGridComponent; + const expectedChildData1 = fixture.componentInstance.data[0].childData; + expect(childGrid1.data).toBe(expectedChildData1); + expect(childGrid1.getCellByColumn(0, 'ID').value).toBe('00'); + + const childGrid2 = hierarchicalGrid.gridAPI.getChildGrids(false)[1] as IgxHierarchicalGridComponent; + const expectedChildData2 = fixture.componentInstance.data[1].childData; + expect(childGrid2.data).toBe(expectedChildData2); + expect(childGrid2.getCellByColumn(0, 'ID').value).toBe('10'); + + hierarchicalGrid.verticalScrollContainer.scrollNext(); + + await wait(100); + fixture.detectChanges(); + expect(childGrid1.data).toBe(expectedChildData1); + expect(childGrid1.getCellByColumn(0, 'ID').value).toBe('00'); + expect(childGrid2.data).toBe(expectedChildData2); + expect(childGrid2.getCellByColumn(0, 'ID').value).toBe('10'); + }); + + it('should not lose scroll position after expanding/collapsing a row.', async () => { + hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop = 750; + await wait(100); + fixture.detectChanges(); + const startIndex = hierarchicalGrid.verticalScrollContainer.state.startIndex; + const topOffset = GridFunctions.getGridDisplayContainer(fixture).nativeElement.style.top; + const secondRow = hierarchicalGrid.dataRowList.toArray()[1]; + // expand second row + (secondRow.nativeElement.children[0] as HTMLElement).click(); + await wait(100); + fixture.detectChanges(); + + expect(hierarchicalGrid.verticalScrollContainer.state.startIndex).toEqual(startIndex); + expect( + parseInt(GridFunctions.getGridDisplayContainer(fixture).nativeElement.style.top, 10) - + parseInt(topOffset, 10) + ).toBeLessThanOrEqual(1); + + (secondRow.nativeElement.children[0] as HTMLElement).click(); + await wait(100); + fixture.detectChanges(); + // collapse second row + expect(hierarchicalGrid.verticalScrollContainer.state.startIndex).toEqual(startIndex); + expect( + parseInt(GridFunctions.getGridDisplayContainer(fixture).nativeElement.style.top, 10) - + parseInt(topOffset, 10) + ).toBeLessThanOrEqual(1); + }); + + it('should not lose scroll position after expanding a row when there are already expanded rows above.', async () => { + + // Expand two rows at the top + (hierarchicalGrid.dataRowList.toArray()[2].nativeElement.children[0] as HTMLElement).click(); + await wait(); + fixture.detectChanges(); + + (hierarchicalGrid.dataRowList.toArray()[1].nativeElement.children[0] as HTMLElement).click(); + await wait(); + fixture.detectChanges(); + + // Scroll to bottom + hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop = 5000; + await firstValueFrom(hierarchicalGrid.verticalScrollContainer.chunkLoad); + fixture.detectChanges(); + // Expand two rows at the bottom + (hierarchicalGrid.dataRowList.toArray()[6].nativeElement.children[0] as HTMLElement).click(); + await wait(); + fixture.detectChanges(); + + (hierarchicalGrid.dataRowList.toArray()[4].nativeElement.children[0] as HTMLElement).click(); + await wait(); + fixture.detectChanges(); + + // Scroll to top to make sure top. + hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop = 0; + await firstValueFrom(hierarchicalGrid.verticalScrollContainer.chunkLoad); + fixture.detectChanges(); + + // Scroll to somewhere in the middle and make sure scroll position stays when expanding/collapsing. + hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop = 1250; + await firstValueFrom(hierarchicalGrid.verticalScrollContainer.chunkLoad); + fixture.detectChanges(); + const startIndex = hierarchicalGrid.verticalScrollContainer.state.startIndex; + const topOffset = GridFunctions.getGridDisplayContainer(fixture).nativeElement.style.top; + const secondRow = hierarchicalGrid.rowList.toArray()[2]; + // expand second row + (secondRow.nativeElement.children[0] as HTMLElement).click(); + await wait(); + fixture.detectChanges(); + + expect(hierarchicalGrid.verticalScrollContainer.state.startIndex).toEqual(startIndex); + expect( + parseInt(GridFunctions.getGridDisplayContainer(fixture).nativeElement.style.top, 10) - + parseInt(topOffset, 10) + ).toBeLessThanOrEqual(1); + + (secondRow.nativeElement.children[0] as HTMLElement).click(); + await wait(); + fixture.detectChanges(); + // collapse second row + expect(hierarchicalGrid.verticalScrollContainer.state.startIndex).toEqual(startIndex); + expect( + parseInt(GridFunctions.getGridDisplayContainer(fixture).nativeElement.style.top, 10) - + parseInt(topOffset, 10) + ).toBeLessThanOrEqual(1); + }); + + it('should be able to scroll last row in view after all rows get expanded.', async () => { + // expand all + hierarchicalGrid.expandAll(); + fixture.detectChanges(); + await wait(100); + // scroll to bottom + hierarchicalGrid.verticalScrollContainer.scrollTo(hierarchicalGrid.dataView.length - 1); + fixture.detectChanges(); + await wait(100); + fixture.detectChanges(); + + expect( + hierarchicalGrid.verticalScrollContainer.state.startIndex + + hierarchicalGrid.verticalScrollContainer.state.chunkSize).toBe(80); + expect(hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop) + .toEqual(hierarchicalGrid.verticalScrollContainer.getScroll().scrollHeight - + parseInt(hierarchicalGrid.verticalScrollContainer.igxForContainerSize, 10)); + }); + + it('should update scroll height after expanding/collapsing rows.', async () => { + const scrHeight = hierarchicalGrid.verticalScrollContainer.getScroll().scrollHeight; + const firstRow = hierarchicalGrid.dataRowList.toArray()[0]; + UIInteractions.simulateClickAndSelectEvent(firstRow.nativeElement.children[0]); + fixture.detectChanges(); + await wait(200); + const childGrid1 = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + expect(hierarchicalGrid.verticalScrollContainer.getScroll().scrollHeight) + .toBeGreaterThan(scrHeight + childGrid1.calcHeight); + expect(childGrid1.nativeElement.parentElement.className.indexOf('igx-grid__hierarchical-indent--scroll')) + .not.toBe(-1); + }); + + it('should update scroll height after expanding/collapsing row in a nested child grid that has no height.', async () => { + fixture.componentInstance.data = [ + { ID: 0, ChildLevels: 3, ProductName: 'Product: A0 ' }, + { ID: 1, ChildLevels: 3, ProductName: 'Product: A0 ' }, + { + ID: 2, ChildLevels: 2, ProductName: 'Product: A0 ', + childData: [ + { + ID: 1, ChildLevels: 2, ProductName: 'Product: A1', + childData: fixture.componentInstance.generateData(100, 0) + } + ] + }]; + fixture.detectChanges(); + + let scrHeight = hierarchicalGrid.verticalScrollContainer.getScroll().scrollHeight; + expect(scrHeight).toBe(0); + + (hierarchicalGrid.dataRowList.toArray()[2].nativeElement.children[0] as HTMLElement).click(); + await wait(200); + fixture.detectChanges(); + hierarchicalGrid.verticalScrollContainer.scrollNext(); + await wait(200); + fixture.detectChanges(); + + const childGrid1 = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + scrHeight = hierarchicalGrid.verticalScrollContainer.getScroll().scrollHeight; + expect(scrHeight).toBe(3 * 51 + (childGrid1.nativeElement.closest('.igx-grid__tr-container') as HTMLElement).offsetHeight - 1); + + // expand + (childGrid1.dataRowList.toArray()[0].nativeElement.children[0] as HTMLElement).click(); + await wait(200); + fixture.detectChanges(); + scrHeight = hierarchicalGrid.verticalScrollContainer.getScroll().scrollHeight; + expect(scrHeight).toBe(3 * 51 + (childGrid1.nativeElement.closest('.igx-grid__tr-container') as HTMLElement).offsetHeight - 1); + + // collapse + (childGrid1.dataRowList.toArray()[0].nativeElement.children[0] as HTMLElement).click(); + await wait(200); + fixture.detectChanges(); + scrHeight = hierarchicalGrid.verticalScrollContainer.getScroll().scrollHeight; + expect(scrHeight).toBe(3 * 51 + (childGrid1.nativeElement.closest('.igx-grid__tr-container') as HTMLElement).offsetHeight - 1); + }); + + it('should update context information correctly for child grid container after scrolling', async () => { + // expand 3rd row + const row = hierarchicalGrid.dataRowList.toArray()[3]; + (row.nativeElement.children[0] as HTMLElement).click(); + fixture.detectChanges(); + + // verify index and rowData + let childRowComponent = fixture.debugElement.query(By.css('igx-child-grid-row')).componentInstance; + expect(childRowComponent.data.rowID).toBe('3'); + expect(childRowComponent.index).toBe(4); + + hierarchicalGrid.verticalScrollContainer.scrollNext(); + await wait(200); + fixture.detectChanges(); + childRowComponent = fixture.debugElement.query(By.css('igx-child-grid-row')).componentInstance; + expect(childRowComponent.data.rowID).toBe('3'); + expect(childRowComponent.index).toBe(4); + }); + + it('should update scrollbar when expanding a row with data loaded after initial view initialization', (done) => { + fixture.componentInstance.data = fixture.componentInstance.generateData(10, 0); + fixture.detectChanges(); + + fixture.componentInstance.rowIsland.gridCreated.pipe(first(), delay(200)).subscribe( + async (args) => { + args.grid.data = fixture.componentInstance.generateData(10, 0); + await wait(200); + fixture.detectChanges(); + + expect((hierarchicalGrid.verticalScrollContainer.getScroll().children[0] as HTMLElement).offsetHeight).toEqual(958); + done(); + } + ); + + expect((hierarchicalGrid.verticalScrollContainer.getScroll().children[0] as HTMLElement).offsetHeight).toEqual(510); + + // expand 1st row + const row = hierarchicalGrid.dataRowList.toArray()[0]; + (row.nativeElement.children[0] as HTMLElement).click(); + fixture.detectChanges(); + + expect((hierarchicalGrid.verticalScrollContainer.getScroll().children[0] as HTMLElement).offsetHeight).toEqual(561); + }); + + it('should emit onScroll and dataPreLoad on row island when child grid is scrolled.', async () => { + const ri = fixture.componentInstance.rowIsland; + const firstRow = hierarchicalGrid.dataRowList.toArray()[0]; + (firstRow.nativeElement.children[0] as HTMLElement).click(); + fixture.detectChanges(); + + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + const verticalScroll = childGrid.verticalScrollContainer; + const elem = verticalScroll['scrollComponent'].elementRef.nativeElement; + + + spyOn(ri.gridScroll, 'emit').and.callThrough(); + spyOn(ri.dataPreLoad, 'emit').and.callThrough(); + + + // scroll down + elem.scrollTop = 400; + fixture.detectChanges(); + fixture.componentRef.hostView.detectChanges(); + await wait(); + fixture.detectChanges(); + + expect(ri.gridScroll.emit).toHaveBeenCalled(); + expect(ri.dataPreLoad.emit).toHaveBeenCalled(); + }); + + it('should recalculate and update content correctly after filter is cleared, ensuring no empty areas post-filtering and scrolling', async () => { + // eslint-disable-next-line @typescript-eslint/no-shadow + const fixture = TestBed.createComponent(IgxHierarchicalGridDefaultComponent); + fixture.detectChanges(); + // eslint-disable-next-line @typescript-eslint/no-shadow + const hierarchicalGrid = fixture.componentInstance.hierarchicalGrid; + fixture.detectChanges(); + await wait(50); + + hierarchicalGrid.filter('Artist', 'd', IgxStringFilteringOperand.instance().condition('contains')); + fixture.detectChanges(); + await wait(50); + + hierarchicalGrid.expandRow(6); + fixture.detectChanges(); + await wait(50); + + hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop = 2000; + fixture.detectChanges(); + await wait(50); + + hierarchicalGrid.clearFilter(); + fixture.detectChanges(); + await wait(50); + + hierarchicalGrid.verticalScrollContainer.getScroll().scrollTop = 2000; + fixture.detectChanges(); + await wait(50); + fixture.detectChanges(); + + const hierarchicalGridRect = hierarchicalGrid.tbody.nativeElement.getBoundingClientRect(); + const lastRowRect = hierarchicalGrid.dataRowList.last.nativeElement.getBoundingClientRect(); + + const emptySpace = hierarchicalGridRect.bottom - lastRowRect.bottom; + + expect(emptySpace).toBeLessThan(5); + }); +}); + +describe('IgxHierarchicalGrid Virtualization Custom Scenarios #hGrid', () => { + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxHierarchicalGridTestBaseComponent, + IgxHierarchicalGridNoScrollTestComponent + ], + providers: [ + IgxGridNavigationService + ] + }).compileComponents(); + })); + + it('should show scrollbar after expanding a row with data loaded after initial view initialization', async () => { + const fixture = TestBed.createComponent(IgxHierarchicalGridNoScrollTestComponent); + fixture.detectChanges(); + await wait(); + + const hierarchicalGrid = fixture.componentInstance.hgrid; + const initialBodyWidth = hierarchicalGrid.tbody.nativeElement.offsetWidth; + const verticalScrollWrapper = HierarchicalGridFunctions.getVerticalScrollWrapper(fixture, hierarchicalGrid.id); + expect(verticalScrollWrapper.hidden).toBeTruthy(); + + // expand 1st row + const row = hierarchicalGrid.dataRowList.toArray()[0]; + (row.nativeElement.children[0] as HTMLElement).click(); + fixture.detectChanges(); + await wait(200); + + expect(verticalScrollWrapper.hidden).toBeTruthy(); + expect(hierarchicalGrid.tbody.nativeElement.offsetWidth).toEqual(initialBodyWidth); + + const childGrid = hierarchicalGrid.gridAPI.getChildGrids(false)[0]; + childGrid.data = fixture.componentInstance.generateData(10, 0); + fixture.detectChanges(); + await wait(200); + fixture.detectChanges(); + + expect(verticalScrollWrapper.hidden).toBeFalsy(); + expect(hierarchicalGrid.tbody.nativeElement.offsetWidth).toBeLessThan(initialBodyWidth); + }); +}); + +@Component({ + template: ` + + + + + + `, + imports: [IgxHierarchicalGridComponent, IgxRowIslandComponent] +}) +export class IgxHierarchicalGridTestBaseComponent { + @ViewChild('hierarchicalGrid', { read: IgxHierarchicalGridComponent, static: true }) public hgrid: IgxHierarchicalGridComponent; + @ViewChild('rowIsland', { read: IgxRowIslandComponent, static: true }) public rowIsland: IgxRowIslandComponent; + @ViewChild('rowIsland2', { read: IgxRowIslandComponent, static: true }) public rowIsland2: IgxRowIslandComponent; + + public data; + + constructor() { + // 3 level hierarchy + this.data = this.generateData(40, 3); + } + public generateData(count: number, level: number, parendID?) { + const prods = []; + const currLevel = level; + let children; + for (let i = 0; i < count; i++) { + const rowID = parendID ? parendID + i : i.toString(); + if (level > 0) { + children = this.generateData(count / 2, currLevel - 1, rowID); + } + prods.push({ + ID: rowID, ChildLevels: currLevel, ProductName: 'Product: A' + i, Col1: i, + Col2: i, Col3: i, childData: children, childData2: children + }); + } + return prods; + } +} + +@Component({ + template: ` + + + + + + `, + imports: [IgxHierarchicalGridComponent, IgxRowIslandComponent] +}) +export class IgxHierarchicalGridNoScrollTestComponent extends IgxHierarchicalGridTestBaseComponent { + constructor() { + super(); + this.data = this.generateData(3, 0); + } +} diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-row.component.html b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-row.component.html new file mode 100644 index 00000000000..9929fd298f2 --- /dev/null +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-row.component.html @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + +
    + +
    +
    + + + @if (rowDraggable) { +
    + +
    + } + + + @if (showRowSelectors) { +
    + + +
    + } + + + @if (hasChildren) { +
    + + +
    + } + + @if (pinnedStartColumns.length > 0) { + + } + + + @if (this.hasMergedCells) { +
    + +
    + } + @else { + + } +
    + + @if (pinnedEndColumns.length > 0) { + + } + + +
    + + +
    +
    + + + @for (col of columns | igxNotGrouped; track trackPinnedColumn(col)) { + + + } + +
    + + + + + diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-row.component.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-row.component.ts new file mode 100644 index 00000000000..a5d9985ff3e --- /dev/null +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/hierarchical-row.component.ts @@ -0,0 +1,170 @@ +import { + ChangeDetectionStrategy, + Component, + HostBinding, + forwardRef, + ElementRef, + ViewChildren, + QueryList, + ViewChild, + TemplateRef +} from '@angular/core'; +import { IgxRowDirective } from 'igniteui-angular/grids/core'; +import { IgxHierarchicalGridCellComponent } from './hierarchical-cell.component'; +import { GridType } from 'igniteui-angular/grids/core'; +import { IgxGridNotGroupedPipe, IgxGridCellStylesPipe, IgxGridCellStyleClassesPipe, IgxGridDataMapperPipe, IgxGridTransactionStatePipe } from 'igniteui-angular/grids/core'; +import { IgxRowDragDirective } from 'igniteui-angular/grids/core'; +import { NgTemplateOutlet, NgClass, NgStyle } from '@angular/common'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxGridForOfDirective } from 'igniteui-angular/directives'; +import { IgxCheckboxComponent } from 'igniteui-angular/checkbox'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'igx-hierarchical-grid-row', + templateUrl: './hierarchical-row.component.html', + providers: [{ provide: IgxRowDirective, useExisting: forwardRef(() => IgxHierarchicalRowComponent) }], + imports: [NgTemplateOutlet, IgxIconComponent, IgxRowDragDirective, NgClass, IgxGridForOfDirective, IgxHierarchicalGridCellComponent, NgStyle, IgxCheckboxComponent, IgxGridNotGroupedPipe, IgxGridCellStylesPipe, IgxGridCellStyleClassesPipe, IgxGridDataMapperPipe, IgxGridTransactionStatePipe] +}) +export class IgxHierarchicalRowComponent extends IgxRowDirective { + @ViewChild('expander', { read: ElementRef }) + public expander: ElementRef; + + @ViewChildren(forwardRef(() => IgxHierarchicalGridCellComponent), { read: IgxHierarchicalGridCellComponent }) + protected override _cells: QueryList; + + /** + * @hidden + */ + @ViewChild('defaultExpandedTemplate', { read: TemplateRef, static: true }) + protected defaultExpandedTemplate: TemplateRef; + + /** + * @hidden + */ + @ViewChild('defaultEmptyTemplate', { read: TemplateRef, static: true }) + protected defaultEmptyTemplate: TemplateRef; + + /** + * @hidden + */ + @ViewChild('defaultCollapsedTemplate', { read: TemplateRef, static: true }) + protected defaultCollapsedTemplate: TemplateRef; + + protected expanderClass = 'igx-grid__hierarchical-expander'; + protected rolActionClass = 'igx-grid__tr-action'; + + /** + * @hidden + */ + public get expanderClassResolved() { + return { + [`${this.expanderClass} ${this.rolActionClass}`]: !this.pinned || this.disabled, + [`${this.expanderClass}--empty`]: this.pinned && !this.disabled + }; + } + + public override get viewIndex(): number { + return this.index + this.grid.page * this.grid.perPage; + } + + /** + * Returns whether the row is expanded. + * ```typescript + * const RowExpanded = this.grid1.rowList.first.expanded; + * ``` + */ + public override get expanded() { + return this.grid.gridAPI.get_row_expansion_state(this.data); + } + + /** + * @hidden + */ + @HostBinding('class.igx-grid__tr--expanded') + public get expandedClass() { + return this.expanded && !this.pinned; + } + + public override get hasChildren() { + return !!this.grid.childLayoutKeys.length; + } + + /** + * @hidden + */ + @HostBinding('class.igx-grid__tr--highlighted') + public get highlighted() { + return this.grid && this.grid.highlightedRowID === this.key; + } + + /** + * @hidden + */ + public expanderClick(event) { + event.stopPropagation(); + this.toggle(); + } + + /** + * Toggles the hierarchical row. + * ```typescript + * this.grid1.rowList.first.toggle() + * ``` + */ + public toggle() { + if (this.added) { + return; + } + // K.D. 28 Feb, 2022 #10634 Don't trigger endEdit/commit upon row expansion state change + // this.endEdit(this.grid.rootGrid); + this.grid.gridAPI.set_row_expansion_state(this.key, !this.expanded); + this.grid.cdr.detectChanges(); + } + + /** + * @hidden + * @internal + */ + public select = () => { + this.grid.selectRows([this.key]); + }; + + /** + * @hidden + * @internal + */ + public deselect = () => { + this.grid.deselectRows([this.key]); + }; + + /** + * @hidden + */ + public get iconTemplate() { + let expandable = true; + if (this.grid.hasChildrenKey) { + expandable = this.data[this.grid.hasChildrenKey]; + } + if (!expandable || (this.pinned && !this.disabled)) { + return this.defaultEmptyTemplate; + } + if (this.expanded) { + return this.grid.rowExpandedIndicatorTemplate || this.defaultExpandedTemplate; + } else { + return this.grid.rowCollapsedIndicatorTemplate || this.defaultCollapsedTemplate; + } + } + + // TODO: consider moving into CRUD + protected endEdit(grid: GridType) { + if (grid.gridAPI.crudService.cellInEditMode) { + grid.gridAPI.crudService.endEdit(); + } + grid.gridAPI.getChildGrids(true).forEach(g => { + if (g.gridAPI.crudService.cellInEditMode) { + g.gridAPI.crudService.endEdit(); + } + }); + } +} diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/public_api.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/public_api.ts new file mode 100644 index 00000000000..d1377663180 --- /dev/null +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/public_api.ts @@ -0,0 +1,186 @@ +import { IgxHierarchicalGridComponent } from './hierarchical-grid.component'; +import { IgxRowIslandComponent } from './row-island.component'; + +export * from './events'; +export * from './hierarchical-grid.component'; +export * from './row-island.component'; +export * from './hierarchical-grid.pipes'; + +/* Imports that cannot be resolved from IGX_GRID_COMMON_DIRECTIVES spread + NOTE: Do not remove! Issue: https://github.com/IgniteUI/igniteui-angular/issues/13310 +*/ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { + IgxRowDirective, + IgxRowEditTextDirective, + IgxRowAddTextDirective, + IgxRowEditActionsDirective, + IgxRowEditTabStopDirective, + IgxGridFooterComponent, + IgxAdvancedFilteringDialogComponent, + IgxHeaderCollapsedIndicatorDirective, + IgxHeaderExpandedIndicatorDirective, + IgxRowCollapsedIndicatorDirective, + IgxRowExpandedIndicatorDirective, + IgxSortAscendingHeaderIconDirective, + IgxSortDescendingHeaderIconDirective, + IgxSortHeaderIconDirective, + IgxExcelStyleHeaderIconDirective, + IgxDragIndicatorIconDirective, + IgxRowDragGhostDirective, + IgxGridStateDirective, + IgxGridHeaderComponent, + IgxGridHeaderGroupComponent, + IgxGridHeaderRowComponent, + IgxFilterCellTemplateDirective, + IgxSummaryTemplateDirective, + IgxCellTemplateDirective, + IgxCellValidationErrorDirective, + IgxCellHeaderTemplateDirective, + IgxCellFooterTemplateDirective, + IgxCellEditorTemplateDirective, + IgxCollapsibleIndicatorTemplateDirective, + IgxColumnComponent, + IgxColumnGroupComponent, + IgxColumnLayoutComponent, + IgxColumnRequiredValidatorDirective, + IgxColumnMinValidatorDirective, + IgxColumnMaxValidatorDirective, + IgxColumnEmailValidatorDirective, + IgxColumnMinLengthValidatorDirective, + IgxColumnMaxLengthValidatorDirective, + IgxColumnPatternValidatorDirective, + IgxColumnActionsComponent, + IgxColumnHidingDirective, + IgxColumnPinningDirective, + IgxRowSelectorDirective, + IgxGroupByRowSelectorDirective, + IgxHeadSelectorDirective, + IgxCSVTextDirective, + IgxExcelTextDirective, + IgxGridToolbarActionsComponent, + IgxGridToolbarAdvancedFilteringComponent, + IgxGridToolbarComponent, + IgxGridToolbarExporterComponent, + IgxGridToolbarHidingComponent, + IgxGridToolbarPinningComponent, + IgxGridToolbarTitleComponent, + IgxGridToolbarDirective, + IgxGridExcelStyleFilteringComponent, + IgxExcelStyleHeaderComponent, + IgxExcelStyleSortingComponent, + IgxExcelStylePinningComponent, + IgxExcelStyleHidingComponent, + IgxExcelStyleSelectingComponent, + IgxExcelStyleClearFiltersComponent, + IgxExcelStyleConditionalFilterComponent, + IgxExcelStyleMovingComponent, + IgxExcelStyleSearchComponent, + IgxExcelStyleColumnOperationsTemplateDirective, + IgxExcelStyleFilterOperationsTemplateDirective, + IgxExcelStyleLoadingValuesTemplateDirective, + IgxGridActionButtonComponent, + IgxGridActionsBaseDirective, + IgxGridEditingActionsComponent, + IgxGridPinningActionsComponent +} from "igniteui-angular/grids/core"; +import { + IgxPaginatorComponent, + IgxPageNavigationComponent, + IgxPageSizeSelectorComponent, + IgxPaginatorContentDirective, + IgxPaginatorDirective +} from 'igniteui-angular/paginator'; + +/* NOTE: Hierarchical grid directives collection for ease-of-use import in standalone components scenario */ +export const IGX_HIERARCHICAL_GRID_DIRECTIVES = [ + IgxHierarchicalGridComponent, + IgxRowIslandComponent, + IgxRowAddTextDirective, + IgxRowEditActionsDirective, + IgxRowEditTextDirective, + IgxRowEditTabStopDirective, + // IGX_GRID_COMMON_DIRECTIVES: + IgxRowDirective, + IgxGridFooterComponent, + IgxAdvancedFilteringDialogComponent, + IgxRowExpandedIndicatorDirective, + IgxRowCollapsedIndicatorDirective, + IgxHeaderExpandedIndicatorDirective, + IgxHeaderCollapsedIndicatorDirective, + IgxExcelStyleHeaderIconDirective, + IgxSortAscendingHeaderIconDirective, + IgxSortDescendingHeaderIconDirective, + IgxSortHeaderIconDirective, + IgxDragIndicatorIconDirective, + IgxRowDragGhostDirective, + IgxGridStateDirective, + // IGX_GRID_ACTIONS + IgxGridPinningActionsComponent, + IgxGridEditingActionsComponent, + IgxGridActionsBaseDirective, + IgxGridActionButtonComponent, + // IGX_GRID_HEADERS_DIRECTIVES: + IgxGridHeaderComponent, + IgxGridHeaderGroupComponent, + IgxGridHeaderRowComponent, + // IGX_GRID_COLUMN_DIRECTIVES: + IgxFilterCellTemplateDirective, + IgxSummaryTemplateDirective, + IgxCellTemplateDirective, + IgxCellValidationErrorDirective, + IgxCellHeaderTemplateDirective, + IgxCellFooterTemplateDirective, + IgxCellEditorTemplateDirective, + IgxCollapsibleIndicatorTemplateDirective, + IgxColumnComponent, + IgxColumnGroupComponent, + IgxColumnLayoutComponent, + // IGX_GRID_COLUMN_ACTIONS_DIRECTIVES: + IgxColumnActionsComponent, + IgxColumnHidingDirective, + IgxColumnPinningDirective, + // IGX_GRID_SELECTION_DIRECTIVES: + IgxRowSelectorDirective, + IgxGroupByRowSelectorDirective, + IgxHeadSelectorDirective, + // IGX_GRID_TOOLBAR_DIRECTIVES: + IgxCSVTextDirective, + IgxExcelTextDirective, + IgxGridToolbarActionsComponent, + IgxGridToolbarAdvancedFilteringComponent, + IgxGridToolbarComponent, + IgxGridToolbarExporterComponent, + IgxGridToolbarHidingComponent, + IgxGridToolbarPinningComponent, + IgxGridToolbarTitleComponent, + IgxGridToolbarDirective, + // IGX_GRID_EXCEL_STYLE_FILTER_DIRECTIVES: + IgxGridExcelStyleFilteringComponent, + IgxExcelStyleHeaderComponent, + IgxExcelStyleSortingComponent, + IgxExcelStylePinningComponent, + IgxExcelStyleHidingComponent, + IgxExcelStyleSelectingComponent, + IgxExcelStyleClearFiltersComponent, + IgxExcelStyleConditionalFilterComponent, + IgxExcelStyleMovingComponent, + IgxExcelStyleSearchComponent, + IgxExcelStyleColumnOperationsTemplateDirective, + IgxExcelStyleFilterOperationsTemplateDirective, + IgxExcelStyleLoadingValuesTemplateDirective, + // IGX_GRID_VALIDATION_DIRECTIVES: + IgxColumnRequiredValidatorDirective, + IgxColumnMinValidatorDirective, + IgxColumnMaxValidatorDirective, + IgxColumnEmailValidatorDirective, + IgxColumnMinLengthValidatorDirective, + IgxColumnMaxLengthValidatorDirective, + IgxColumnPatternValidatorDirective, + // IGX_PAGINATOR_DIRECTIVES: + IgxPaginatorComponent, + IgxPageNavigationComponent, + IgxPageSizeSelectorComponent, + IgxPaginatorContentDirective, + IgxPaginatorDirective +] as const; diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/row-island-api.service.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/row-island-api.service.ts new file mode 100644 index 00000000000..c6d6160df14 --- /dev/null +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/row-island-api.service.ts @@ -0,0 +1,84 @@ +import { IgxHierarchicalGridComponent } from './hierarchical-grid.component'; +import { IgxRowIslandComponent } from './row-island.component'; +import { Subject } from 'rxjs'; +import { Injectable } from '@angular/core'; + +@Injectable() +export class IgxRowIslandAPIService { + public rowIsland: IgxRowIslandComponent; + public change: Subject = new Subject(); + protected state: Map = new Map(); + protected destroyMap: Map> = new Map>(); + + protected childRowIslands: Map = new Map(); + protected childGrids: Map = new Map(); + + public register(rowIsland: IgxRowIslandComponent) { + this.state.set(rowIsland.id, rowIsland); + this.destroyMap.set(rowIsland.id, new Subject()); + } + + public unsubscribe(rowIsland: IgxRowIslandComponent) { + this.state.delete(rowIsland.id); + } + + public get(id: string): IgxRowIslandComponent { + return this.state.get(id); + } + + public unset(id: string) { + this.state.delete(id); + this.destroyMap.delete(id); + } + + public reset(oldId: string, newId: string) { + const destroy = this.destroyMap.get(oldId); + const rowIsland = this.get(oldId); + + this.unset(oldId); + + if (rowIsland) { + this.state.set(newId, rowIsland); + } + + if (destroy) { + this.destroyMap.set(newId, destroy); + } + } + + public registerChildRowIsland(rowIsland: IgxRowIslandComponent) { + this.childRowIslands.set(rowIsland.key, rowIsland); + this.destroyMap.set(rowIsland.key, new Subject()); + } + + public unsetChildRowIsland(rowIsland: IgxRowIslandComponent) { + this.childRowIslands.delete(rowIsland.key); + this.destroyMap.delete(rowIsland.key); + } + + public getChildRowIsland(rowIslandKey: string) { + return this.childRowIslands.get(rowIslandKey); + } + + public registerChildGrid(parentRowID: any, grid: IgxHierarchicalGridComponent) { + this.childGrids.set(parentRowID, grid); + } + + public getChildGrids(inDepth?: boolean) { + let allChildren = []; + this.childGrids.forEach((grid) => { + allChildren.push(grid); + }); + if (inDepth) { + this.childRowIslands.forEach((layout) => { + allChildren = allChildren.concat(layout.rowIslandAPI.getChildGrids(inDepth)); + }); + } + + return allChildren; + } + + public getChildGridByID(rowID) { + return this.childGrids.get(rowID); + } +} diff --git a/projects/igniteui-angular/grids/hierarchical-grid/src/row-island.component.ts b/projects/igniteui-angular/grids/hierarchical-grid/src/row-island.component.ts new file mode 100644 index 00000000000..bcda1fed647 --- /dev/null +++ b/projects/igniteui-angular/grids/hierarchical-grid/src/row-island.component.ts @@ -0,0 +1,531 @@ +import { + AfterContentInit, + AfterViewInit, + booleanAttribute, + ChangeDetectionStrategy, + Component, + ContentChild, + ContentChildren, + EventEmitter, + forwardRef, + Input, + IterableChangeRecord, + OnChanges, + OnDestroy, + OnInit, + Output, + QueryList, + TemplateRef, + inject +} from '@angular/core'; +import { + GridType, + IgxColumnComponent, + IgxFilteringService, + IgxGridPaginatorTemplateContext, + IgxGridSelectionService, + IgxGridToolbarDirective, + IgxGridToolbarTemplateContext, + ISearchInfo +} from 'igniteui-angular/grids/core'; +import { IgxHierarchicalGridBaseDirective } from './hierarchical-grid-base.directive'; +import { IgxActionStripToken } from 'igniteui-angular/core'; +import { first, filter, takeUntil, pluck } from 'rxjs/operators'; +import { IgxRowIslandAPIService } from './row-island-api.service'; +import { IGridCreatedEventArgs } from './events'; +import { IgxPaginatorComponent, IgxPaginatorDirective } from 'igniteui-angular/paginator'; +import { IForOfState } from 'igniteui-angular/directives'; + +/* blazorCopyInheritedMembers */ +/* blazorElement */ +/* wcElementTag: igc-row-island */ +/* blazorIndirectRender */ +/* jsonAPIManageCollectionInMarkup */ +/* jsonAPIManageItemInMarkup */ +/* mustUseNGParentAnchor */ +/* additionalIdentifier: ChildDataKey */ +/* contentParent: RowIsland */ +/* contentParent: HierarchicalGrid */ +/** + * Row island + * + * @igxModule IgxHierarchicalGridModule + * @igxParent IgxHierarchicalGridComponent, IgxRowIslandComponent + * + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'igx-row-island', + template: `@if (platform.isElements) { + + + }`, + providers: [ + IgxRowIslandAPIService, + IgxFilteringService, + IgxGridSelectionService + ], + standalone: true +}) +export class IgxRowIslandComponent extends IgxHierarchicalGridBaseDirective + implements AfterContentInit, AfterViewInit, OnChanges, OnInit, OnDestroy { + public rowIslandAPI = inject(IgxRowIslandAPIService); + + + /* blazorSuppress */ + /** + * Sets the key of the row island by which child data would be taken from the row data if such is provided. + * ```html + * + * + * + * + * + * ``` + * + * @memberof IgxRowIslandComponent + */ + @Input() + public key: string; + + /* blazorInclude,wcInclude TODO: Move to Elements-only component */ + /** + * Sets the key of the row island by which child data would be taken from the row data if such is provided. + * @hidden @internal + */ + @Input() + public get childDataKey() { + return this.key; + } + /* blazorInclude,wcInclude */ + public set childDataKey(value: string) { + this.key = value; + } + + /** + * @hidden + */ + @ContentChildren(forwardRef(() => IgxRowIslandComponent), { read: IgxRowIslandComponent, descendants: false }) + public children = new QueryList(); + + /* contentChildren */ + /* blazorInclude */ + /* blazorTreatAsCollection */ + /* blazorCollectionName: RowIslandCollection */ + /** + * @hidden @internal + */ + @ContentChildren(forwardRef(() => IgxRowIslandComponent), { read: IgxRowIslandComponent, descendants: false }) + public childLayoutList = new QueryList(); + + /** + * @hidden + */ + @ContentChildren(IgxColumnComponent, { read: IgxColumnComponent, descendants: false }) + public childColumns = new QueryList(); + + @ContentChild(IgxGridToolbarDirective, { read: TemplateRef, descendants: false }) + protected toolbarDirectiveTemplate: TemplateRef; + + @ContentChild(IgxPaginatorDirective, { read: TemplateRef, descendants: false }) + protected paginatorDirectiveTemplate: TemplateRef; + + /* csSuppress */ + /** + * Sets/Gets the toolbar template for each child grid created from this row island. + */ + @Input() + public get toolbarTemplate(): TemplateRef { + return this._toolbarTemplate || this.toolbarDirectiveTemplate; + } + + public set toolbarTemplate(template: TemplateRef) { + this._toolbarTemplate = template; + } + + + /* csSuppress */ + /** + * Sets/Gets the paginator template for each child grid created from this row island. + */ + @Input() + public get paginatorTemplate(): TemplateRef { + return this._paginatorTemplate || this.paginatorDirectiveTemplate; + } + + public set paginatorTemplate(template: TemplateRef) { + this._paginatorTemplate = template; + } + + // TODO(api-analyzer): Shouldn't need all tags to copy from base or hidden/internal due to include tag + /* contentChildren */ + /* blazorInclude */ + /* blazorTreatAsCollection */ + /* blazorCollectionName: ActionStripCollection */ + /* blazorCollectionItemName: ActionStrip */ + /* ngQueryListName: actionStripComponents */ + /** @hidden @internal */ + @ContentChildren(IgxActionStripToken, { read: IgxActionStripToken, descendants: false }) + protected override actionStripComponents: QueryList; + + /** + * @hidden + */ + @Output() + public layoutChange = new EventEmitter(); + + /** + * Event emitted when a grid is being created based on this row island. + * ```html + * + * + * + * + * + * ``` + * + * @memberof IgxRowIslandComponent + */ + @Output() + public gridCreated = new EventEmitter(); + + /** + * Emitted after a grid is being initialized for this row island. + * The emitting is done in `ngAfterViewInit`. + * ```html + * + * + * + * + * + * ``` + * + * @memberof IgxRowIslandComponent + */ + @Output() + public gridInitialized = new EventEmitter(); + + /** + * @hidden + */ + public initialChanges = []; + + /** + * @hidden + */ + public rootGrid: GridType = null; + + /** @hidden */ + public readonly data: any[] | null; + + /** @hidden */ + public override get hiddenColumnsCount(): number { + return 0; + } + + /** @hidden */ + public override get pinnedColumnsCount(): number { + return 0; + } + + /** @hidden */ + public override get lastSearchInfo(): ISearchInfo { + return null; + } + + /** @hidden */ + public override get filteredData(): any { + return []; + } + + /** @hidden */ + public override get filteredSortedData(): any[] { + return []; + } + + /** @hidden */ + public override get virtualizationState(): IForOfState { + return null; + } + + /** @hidden */ + public override get pinnedColumns(): IgxColumnComponent[] { + return []; + } + + /** @hidden */ + public override get unpinnedColumns(): IgxColumnComponent[] { + return []; + } + + /** @hidden */ + public override get visibleColumns(): IgxColumnComponent[] { + return []; + } + + /** @hidden */ + public override get dataView(): any[] { + return []; + } + + //#region inert, not-a-grid component + /** @hidden @internal */ + public override tabindex = -1; + + /** @hidden @internal */ + public override hostRole = null; + + protected override baseClass = null; + + /** @hidden @internal */ + public override get hostWidth(): any { + return null; + } + + protected override displayStyle = 'none'; + protected override templateRows = null; + //#endregion + + private ri_columnListDiffer; + private layout_id = `igx-row-island-`; + private isInit = false; + private _toolbarTemplate: TemplateRef; + private _paginatorTemplate: TemplateRef; + + /** + * Sets if all immediate children of the grids for this `IgxRowIslandComponent` should be expanded/collapsed. + * ```html + * + * + * + * + * + * ``` + * + * @memberof IgxRowIslandComponent + */ + @Input({ transform: booleanAttribute }) + public set expandChildren(value: boolean) { + this._defaultExpandState = value; + this.rowIslandAPI.getChildGrids().forEach((grid) => { + if (this.document.body.contains(grid.nativeElement)) { + // Detect changes right away if the grid is visible + grid.expandChildren = value; + grid.cdr.detectChanges(); + } else { + // Else defer the detection on changes when the grid gets into view for performance. + grid.updateOnRender = true; + } + }); + } + + /** + * Gets if all immediate children of the grids for this `IgxRowIslandComponent` have been set to be expanded/collapsed. + * ```typescript + * const expanded = this.rowIsland.expandChildren; + * ``` + * + * @memberof IgxRowIslandComponent + */ + public get expandChildren(): boolean { + return this._defaultExpandState; + } + + /** + * @hidden + */ + public get id() { + const pId = this.parentId ? this.parentId.substring(this.parentId.indexOf(this.layout_id) + this.layout_id.length) + '-' : ''; + return this.layout_id + pId + this.key; + } + + /** + * @hidden + */ + public get parentId() { + return this.parentIsland ? this.parentIsland.id : null; + } + + /** + * @hidden + */ + public get level() { + let ptr = this.parentIsland; + let lvl = 0; + while (ptr) { + lvl++; + ptr = ptr.parentIsland; + } + return lvl + 1; + } + + /** + * @hidden + */ + public override ngOnInit() { + this.filteringService.grid = this as GridType; + this.rootGrid = this.gridAPI.grid; + this.rowIslandAPI.rowIsland = this; + this.ri_columnListDiffer = this.differs.find([]).create(null); + } + + /** + * @hidden + */ + public override ngAfterContentInit() { + this.updateChildren(); + this.children.notifyOnChanges(); + this.children.changes.pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.updateChildren(); + // update existing grids since their child ri have been changed. + this.rowIslandAPI.getChildGrids(false).forEach(grid => { + (grid as any).onRowIslandChange(this.children); + }); + }); + const nestedColumns = this.children.map((layout) => layout.columnList.toArray()); + const colsArray = [].concat.apply([], nestedColumns); + const topCols = this.columnList.filter((item) => colsArray.indexOf(item) === -1); + this._childColumns = topCols; + this.updateColumns(this._childColumns); + this.columnList.changes.pipe(takeUntil(this.destroy$)).subscribe(() => { + Promise.resolve().then(() => { + this.updateColumnList(); + }); + }); + + // handle column changes so that they are passed to child grid instances when columnChange is emitted. + this.ri_columnListDiffer.diff(this.childColumns); + this._childColumns.forEach(x => x.columnChange.pipe(takeUntil(x.destroy$)).subscribe(() => this.updateColumnList())); + this.childColumns.changes.pipe(takeUntil(this.destroy$)).subscribe((change: QueryList) => { + const diff = this.ri_columnListDiffer.diff(change); + if (diff) { + diff.forEachAddedItem((record: IterableChangeRecord) => { + record.item.columnChange.pipe(takeUntil(record.item.destroy$)).subscribe(() => this.updateColumnList()); + }); + } + }); + + if (this.actionStrip) { + this.actionStrip.menuOverlaySettings.outlet = this.outlet; + } + } + + /** + * @hidden + */ + public override ngAfterViewInit() { + this.rowIslandAPI.register(this); + if (this.parentIsland) { + this.parentIsland.rowIslandAPI.registerChildRowIsland(this); + } else { + this.rootGrid.gridAPI.registerChildRowIsland(this); + } + this._init = false; + + // Create the child toolbar if the parent island has a toolbar definition + this.gridCreated.pipe(pluck('grid'), takeUntil(this.destroy$)).subscribe(grid => { + grid.rendered$.pipe(first(), filter(() => !!this.toolbarTemplate)) + .subscribe(() => grid.toolbarOutlet.createEmbeddedView(this.toolbarTemplate, { $implicit: grid }, { injector: grid.toolbarOutlet.injector })); + grid.rendered$.pipe(first(), filter(() => !!this.paginatorTemplate)) + .subscribe(() => { + this.rootGrid.paginatorList.changes.pipe(takeUntil(this.destroy$)).subscribe((changes: QueryList) => { + changes.forEach(p => { + if (p.nativeElement.offsetParent?.id === grid.id) { + // Optimize update only for those grids that have related changed paginator. + grid.setUpPaginator() + } + }); + }); + grid.paginatorOutlet.createEmbeddedView(this.paginatorTemplate, { $implicit: grid }); + }); + }); + } + + /** + * @hidden + */ + public ngOnChanges(changes) { + this.layoutChange.emit(changes); + if (!this.isInit) { + this.initialChanges.push(changes); + } + } + + /** + * @hidden + */ + public override ngOnDestroy() { + // Override the base destroy because we have not rendered anything to use removeEventListener on + this.destroy$.next(true); + this.destroy$.complete(); + this._destroyed = true; + this.rowIslandAPI.unset(this.id); + if (this.parentIsland) { + this.getGridsForIsland(this.key).forEach(grid => { + this.cleanGridState(grid); + grid.gridAPI.unsetChildRowIsland(this); + }); + this.parentIsland.rowIslandAPI.unsetChildRowIsland(this); + } else { + this.rootGrid.gridAPI.unsetChildRowIsland(this); + this.cleanGridState(this.rootGrid); + } + } + + /** + * @hidden + */ + public override reflow() { } + + /** + * @hidden + */ + public override calculateGridHeight() { } + + /** + * @hidden + */ + public override calculateGridWidth() { } + + protected _childColumns = []; + + protected updateColumnList() { + const nestedColumns = this.children.map((layout) => layout.columnList.toArray()); + const colsArray = [].concat.apply([], nestedColumns); + const topCols = this.columnList.filter((item) => { + if (colsArray.indexOf(item) === -1) { + /* Reset the default width of the columns that come into this row island, + because the root catches them first during the detectChanges() and sets their defaultWidth. */ + item.defaultWidth = undefined; + return true; + } + return false; + }); + this._childColumns = topCols; + this.updateColumns(this._childColumns); + this.rowIslandAPI.getChildGrids().forEach((grid: GridType) => { + grid.createColumnsList(this._childColumns); + if (!this.document.body.contains(grid.nativeElement)) { + grid.updateOnRender = true; + } + }); + } + + protected updateChildren() { + if (this.children.first === this) { + this.children.reset(this.children.toArray().slice(1)); + } + this.children.forEach(child => { + child.parentIsland = this; + }); + } + + private cleanGridState(grid) { + grid.childGridTemplates.forEach((tmpl) => { + tmpl.owner.cleanView(tmpl.context.templateID); + }); + grid.childGridTemplates.clear(); + grid.onRowIslandChange(); + } +} diff --git a/projects/igniteui-angular/grids/pivot-grid/index.ts b/projects/igniteui-angular/grids/pivot-grid/index.ts new file mode 100644 index 00000000000..4e82085cc9e --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/index.ts @@ -0,0 +1,9 @@ +/** + * IgxPivotGrid - Pivot grid component for data analysis + * + * Import pivot-grid-specific components and re-export core grid functionality + */ + +// Export pivot-grid-specific components +export * from './src/public_api'; +export * from './src/pivot-grid.module'; diff --git a/projects/igniteui-angular/grids/pivot-grid/ng-package.json b/projects/igniteui-angular/grids/pivot-grid/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/grids/pivot-grid/src/README.md b/projects/igniteui-angular/grids/pivot-grid/src/README.md new file mode 100644 index 00000000000..5405944f097 --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/src/README.md @@ -0,0 +1,105 @@ +# igx-pivot-grid +**igx-pivot-grid** is a data presentation control for displaying data in a pivot table. It enables users to perform complex analysis on the supplied data. Main purpose is to transform and display a flat array of data into a complex grouped structure with aggregated values based on the main 3 dimensions: rows, columns and values, which the user may specify depending on his/her business needs. +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/pivotgrid) + +## Usage +```html + + +``` + +## Getting Started + +### Dependencies +The grid is exported as as an `NgModule`, thus all you need to do in your application is to import the _IgxPivotGridModule_ inside your `AppModule` + +```typescript +// app.module.ts + +import { IgxPivotGridModule } from 'igniteui-angular'; + +@NgModule({ + imports: [ + ... + IgxPivotGridModule, + ... + ] +}) +export class AppModule {} +``` + +Each of the components, directives and helper classes in the _IgxPivotGridModule_ can be imported through _igniteui-angular_. While you don't need to import all of them to instantiate and use the pivot-grid, you usually will import them (or your editor will auto-import them for you) when declaring types that are part of the grid API. + +```typescript +import { IgxPivotGridComponent } from 'igniteui-angular'; +... + +@ViewChild('myGrid', { read: IgxPivotGridComponent }) +public grid: IgxPivotGridComponent; +``` + +### Basic configuration + +Define the grid +```html + + +``` + +```typescript +public pivotConfigHierarchy: IPivotConfiguration = { + columns: [{ + memberName: 'Product', + memberFunction: (data) => data.Product.Name, + enabled: true + }], + rows: [{ + memberName: 'City', + enabled: true + }], + values: [{ + member: 'NumberOfUnits', + aggregate: { + aggregator: IgxPivotNumericAggregate.sum, + key: 'sum', + label: 'Sum' + }, + enabled: true + }], + filters: null + }; +``` + +## API + +### Inputs + +Below is the list of all inputs that are specific to the pivot-grid look/behavior: + +|Name|Type|Description| +|--- |--- |--- | +|`pivotConfiguration`|IPivotConfiguration|Gets/Sets the pivot configuration with all related dimensions and values.| +|`pivotUI`|IPivotUISettings|Gets/Sets whether to show the ui for the pivot grid configuration - chips and their corresponding containers for row, filter, column dimensions and values. Also enables/disabled row dimension headers.| +|`defaultExpandState`| boolean | Gets/Sets the default expand state for all rows. | + +Note that the pivot-grid extends base igx-grid, so most of the @Input properties make sense and work in the pivot-grid as well. Keep in mind that due to some specifics, not all grid features and @Input properties will work. + +### Outputs + +A list of the specific events emitted by the **igx-pivot-grid**: + +|Name|Description| +|--- |--- | +|_Event emitters_|_Notify for a change_| +|`dimensionsChange`|Emitted when the dimension collection is changed via the grid chip area.| +|`valuesChange`|Emitted when the values collection is changed via the grid chip area.| + + +Defining handlers for these event emitters is done using declarative event binding: + +```html + + +``` + +### Methods diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.component.html b/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.component.html new file mode 100644 index 00000000000..5a41d48b3bb --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.component.html @@ -0,0 +1,189 @@ +
    + + + + + + @for ( + item of dims | filterPivotItems: input.value:grid?.pipeTrigger; + track item.memberName + ) { + + + {{ item.displayName || item.memberName }} + + } + @for ( + item of values | filterPivotItems: input.value:grid?.pipeTrigger; + track item + ) { + + + {{ item.displayName || item.member }} + + } + +
    + + + @for (panel of _panels; track panel) { + + + +
    + {{ grid?.resourceStrings[panel.i18n] }} +
    +
    + + {{ this.grid ? this.grid[panel.dataKey].length : 0 }} +
    +
    +
    + + @if (this.grid && this.grid[panel.dataKey].length > 0) { + + @for ( + item of this.grid[panel.dataKey]; + track (item.memberName || item.member) + ) { + +
    +
    +
    + @if (panel.type === null) { + {{ + item.aggregate.key + }} + } + @if (panel.type === null) { + ( + } + {{ item[panel.displayKey] || item[panel.itemKey] }} + @if (panel.type === null) { + ) + } +
    + @if (panel.sortable && item.sortDirection) { + + + } +
    +
    + @if (panel.type !== null) { + + + } + @if (panel.type === null) { + + + } + @if (panel.dragChannels.length > 0) { + + + } +
    +
    +
    + } +
    + } + @if (this.grid && this.grid[panel.dataKey].length === 0) { +
    + {{ grid?.resourceStrings.igx_grid_pivot_selector_panel_empty }} +
    + } +
    +
    + } +
    + + + @for (item of aggregateList; track item) { + + {{ item.label }} + + } + + + +
    +
    + + {{ ghostText }} +
    + +
    +
    diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.component.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.component.ts new file mode 100644 index 00000000000..a8a6307ea64 --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.component.ts @@ -0,0 +1,672 @@ +import { useAnimation } from "@angular/animations"; +import { ChangeDetectorRef, Component, EventEmitter, HostBinding, Input, Output, Renderer2, booleanAttribute, inject } from "@angular/core"; +import { first } from "rxjs/operators"; +import { IgxFilterPivotItemsPipe } from "./pivot-grid.pipes"; +import { fadeIn, fadeOut } from 'igniteui-angular/animations'; +import { IgxInputDirective, IgxInputGroupComponent, IgxPrefixDirective } from 'igniteui-angular/input-group'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxListComponent, IgxListItemComponent } from 'igniteui-angular/list'; +import { IgxCheckboxComponent } from 'igniteui-angular/checkbox'; +import { IgxAccordionComponent } from 'igniteui-angular/accordion'; +import { IgxExpansionPanelBodyComponent, IgxExpansionPanelComponent, IgxExpansionPanelHeaderComponent, IgxExpansionPanelTitleDirective } from 'igniteui-angular/expansion-panel'; +import { IDragBaseEventArgs, IDragGhostBaseEventArgs, IDragMoveEventArgs, IDropBaseEventArgs, IDropDroppedEventArgs, IgxDragDirective, IgxDragHandleDirective, IgxDropDirective } from 'igniteui-angular/directives'; +import { IgxChipComponent } from 'igniteui-angular/chips'; +import { IgxDropDownComponent, IgxDropDownItemComponent, IgxDropDownItemNavigationDirective, ISelectionEventArgs } from 'igniteui-angular/drop-down'; +import { AbsoluteScrollStrategy, AutoPositionStrategy, ColumnType, OverlaySettings, PositionSettings, ɵSize, SortingDirection, VerticalAlignment } from 'igniteui-angular/core'; +import { IPivotAggregator, IPivotDimension, IPivotValue, PivotDimensionType, PivotGridType, PivotUtil } from 'igniteui-angular/grids/core'; + +interface IDataSelectorPanel { + name: string; + i18n: string; + type?: PivotDimensionType; + dataKey: string; + icon: string; + itemKey: string; + displayKey?: string; + sortable: boolean; + dragChannels: string[]; +} + +/* blazorIndirectRender + blazorComponent */ +/* wcElementTag: igc-pivot-data-selector */ +/** + * Pivot Data Selector provides means to configure the pivot state of the Pivot Grid via a vertical panel UI + * + * @igxModule IgxPivotGridModule + * @igxGroup Grids & Lists + * @igxKeywords data selector, pivot, grid + * @igxTheme pivot-data-selector-theme + * @remarks + * The Ignite UI Data Selector has a searchable list with the grid data columns, + * there are also four expandable areas underneath for filters, rows, columns, and values + * is used for grouping and aggregating simple flat data into a pivot table. + * @example + * ```html + * + * + * + * ``` + */ +@Component({ + selector: "igx-pivot-data-selector", + templateUrl: "./pivot-data-selector.component.html", + imports: [IgxInputGroupComponent, IgxIconComponent, IgxPrefixDirective, IgxInputDirective, IgxListComponent, IgxListItemComponent, IgxCheckboxComponent, IgxAccordionComponent, IgxExpansionPanelComponent, IgxExpansionPanelHeaderComponent, IgxDropDirective, IgxExpansionPanelTitleDirective, IgxChipComponent, IgxExpansionPanelBodyComponent, IgxDragDirective, IgxDropDownItemNavigationDirective, IgxDragHandleDirective, IgxDropDownComponent, IgxDropDownItemComponent, IgxFilterPivotItemsPipe] +}) +export class IgxPivotDataSelectorComponent { + private renderer = inject(Renderer2); + private cdr = inject(ChangeDetectorRef); + + + /** + * Gets/sets whether the columns panel is expanded + * Get + * ```typescript + * const columnsPanelState: boolean = this.dataSelector.columnsExpanded; + * ``` + * Set + * ```html + * + * ``` + * + * Two-way data binding: + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public columnsExpanded = true; + + /** + * Emitted when the columns panel is expanded or collapsed. + * + * @example + * ```html + * + * ``` + */ + @Output() + public columnsExpandedChange = new EventEmitter(); + + /** + * Gets/sets whether the rows panel is expanded + * Get + * ```typescript + * const rowsPanelState: boolean = this.dataSelector.rowsExpanded; + * ``` + * Set + * ```html + * + * ``` + * + * Two-way data binding: + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public rowsExpanded = true; + + /** + * Emitted when the rows panel is expanded or collapsed. + * + * @example + * ```html + * + * ``` + */ + @Output() + public rowsExpandedChange = new EventEmitter(); + + /** + * Gets/sets whether the filters panel is expanded + * Get + * ```typescript + * const filtersPanelState: boolean = this.dataSelector.filtersExpanded; + * ``` + * Set + * ```html + * + * ``` + * + * Two-way data binding: + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public filtersExpanded = true; + + /** + * Emitted when the filters panel is expanded or collapsed. + * + * @example + * ```html + * + * ``` + */ + @Output() + public filtersExpandedChange = new EventEmitter(); + + /** + * Gets/sets whether the values panel is expanded + * Get + * ```typescript + * const valuesPanelState: boolean = this.dataSelector.valuesExpanded; + * ``` + * Set + * ```html + * + * ``` + * + * Two-way data binding: + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public valuesExpanded = true; + + /** + * Emitted when the values panel is expanded or collapsed. + * + * @example + * ```html + * + * ``` + */ + @Output() + public valuesExpandedChange = new EventEmitter(); + + private _grid: PivotGridType; + private _dropDelta = 0; + + /** @hidden @internal **/ + @HostBinding("class.igx-pivot-data-selector") + public cssClass = "igx-pivot-data-selector"; + + @HostBinding("style.--ig-size") + protected get size(): ɵSize { + return this.grid?.gridSize; + } + + /** @hidden @internal **/ + public dimensions: IPivotDimension[]; + + private _subMenuPositionSettings: PositionSettings = { + verticalStartPoint: VerticalAlignment.Bottom, + closeAnimation: undefined, + }; + + private _subMenuOverlaySettings: OverlaySettings = { + closeOnOutsideClick: true, + modal: false, + positionStrategy: new AutoPositionStrategy( + this._subMenuPositionSettings + ), + scrollStrategy: new AbsoluteScrollStrategy(), + }; + + /* blazorSuppress */ + public animationSettings = { + closeAnimation: useAnimation(fadeOut, { + params: { + duration: "0ms", + }, + }), + openAnimation: useAnimation(fadeIn, { + params: { + duration: "0ms", + }, + }), + }; + + /** @hidden @internal */ + public aggregateList: IPivotAggregator[] = []; + /** @hidden @internal */ + public value: IPivotValue; + /** @hidden @internal */ + public ghostText: string; + /** @hidden @internal */ + public ghostWidth: number; + /** @hidden @internal */ + public dropAllowed: boolean; + /** @hidden @internal */ + public get dims(): IPivotDimension[] { + return this._grid?.allDimensions || []; + } + /** @hidden @internal */ + public get values(): IPivotValue[] { + return this._grid?.pivotConfiguration.values || []; + } + + /** + * @hidden @internal + */ + public _panels: IDataSelectorPanel[] = [ + { + name: "Filters", + i18n: 'igx_grid_pivot_selector_filters', + type: PivotDimensionType.Filter, + dataKey: "filterDimensions", + icon: "filter_list", + itemKey: "memberName", + displayKey: 'displayName', + sortable: false, + dragChannels: ["Filters", "Columns", "Rows"] + }, + { + name: "Columns", + i18n: 'igx_grid_pivot_selector_columns', + type: PivotDimensionType.Column, + dataKey: "columnDimensions", + icon: "view_column", + itemKey: "memberName", + displayKey: 'displayName', + sortable: true, + dragChannels: ["Filters", "Columns", "Rows"] + }, + { + name: "Rows", + i18n: 'igx_grid_pivot_selector_rows', + type: PivotDimensionType.Row, + dataKey: "rowDimensions", + icon: "table_rows", + itemKey: "memberName", + displayKey: 'displayName', + sortable: true, + dragChannels: ["Filters", "Columns", "Rows"] + }, + { + name: "Values", + i18n: 'igx_grid_pivot_selector_values', + type: null, + dataKey: "values", + icon: "functions", + itemKey: "member", + displayKey: 'displayName', + sortable: false, + dragChannels: ["Values"] + }, + ]; + + + /* treatAsRef */ + /** + * Sets the grid. + */ + @Input() + public set grid(value: PivotGridType) { + this._grid = value; + } + + /* treatAsRef */ + /** + * Returns the grid. + */ + public get grid(): PivotGridType { + return this._grid; + } + + /** + * @hidden + * @internal + */ + public onItemSort( + _: Event, + dimension: IPivotDimension, + dimensionType: PivotDimensionType + ) { + if ( + !this._panels.find( + (panel: IDataSelectorPanel) => panel.type === dimensionType + ).sortable + ) + return; + + const startDirection = dimension.sortDirection || SortingDirection.None; + const direction = startDirection + 1 > SortingDirection.Desc ? + SortingDirection.None : startDirection + 1; + this.grid.sortDimension(dimension, direction); + } + + /** + * @hidden + * @internal + */ + public onFilteringIconPointerDown(event: PointerEvent) { + event.stopPropagation(); + event.preventDefault(); + } + + /** + * @hidden + * @internal + */ + public onFilteringIconClick(event: MouseEvent, dimension: IPivotDimension) { + event.stopPropagation(); + event.preventDefault(); + + let dim = dimension; + let col: ColumnType; + + while (dim) { + col = this.grid.dimensionDataColumns.find( + (x) => x.field === dim.memberName + ); + if (col) { + break; + } else { + dim = dim.childLevel; + } + } + + this.grid.filteringService.toggleFilterDropdown(event.target, col); + } + + /** + * @hidden + * @internal + */ + protected getDimensionState(dimensionType: PivotDimensionType) { + switch (dimensionType) { + case PivotDimensionType.Row: + return this.grid.rowDimensions; + case PivotDimensionType.Column: + return this.grid.columnDimensions; + case PivotDimensionType.Filter: + return this.grid.filterDimensions; + default: + return null; + } + } + + /** + * @hidden + * @internal + */ + protected moveValueItem(itemId: string) { + const aggregation = this.grid.pivotConfiguration.values; + const valueIndex = + aggregation.findIndex((x) => x.member === itemId) !== -1 + ? aggregation?.findIndex((x) => x.member === itemId) + : aggregation.length; + const newValueIndex = + valueIndex + this._dropDelta < 0 ? 0 : valueIndex + this._dropDelta; + + const aggregationItem = aggregation.find( + (x) => x.member === itemId || x.displayName === itemId + ); + + if (aggregationItem) { + this.grid.moveValue(aggregationItem, newValueIndex); + this.grid.valuesChange.emit({ + values: this.grid.pivotConfiguration.values, + }); + } + } + + /** + * @hidden + * @internal + */ + public onItemDropped( + event: IDropDroppedEventArgs, + dimensionType: PivotDimensionType + ) { + if (!this.dropAllowed) { + return; + } + + const dimension = this.grid.getDimensionsByType(dimensionType); + const dimensionState = this.getDimensionState(dimensionType); + const itemId = event.drag.element.nativeElement.id; + const targetId = event.owner.element.nativeElement.id; + const dimensionItem = dimension?.find((x) => x.memberName === itemId); + const itemIndex = + dimension?.findIndex((x) => x?.memberName === itemId) !== -1 + ? dimension?.findIndex((x) => x.memberName === itemId) + : dimension?.length; + const dimensions = this.grid.allDimensions.filter((x) => x && x.memberName === itemId); + + const reorder = + dimensionState?.findIndex((item) => item.memberName === itemId) !== + -1; + + let targetIndex = + targetId !== "" + ? dimension?.findIndex((x) => x.memberName === targetId) + : dimension?.length; + + if (!dimension) { + this.moveValueItem(itemId); + } + + if (reorder) { + targetIndex = + itemIndex + this._dropDelta < 0 + ? 0 + : itemIndex + this._dropDelta; + } + + if (dimensionItem) { + this.grid.moveDimension(dimensionItem, dimensionType, targetIndex); + } else { + const newDim = dimensions.find((x) => x.memberName === itemId); + this.grid.moveDimension(newDim, dimensionType, targetIndex); + } + + this.grid.dimensionsChange.emit({ + dimensions: dimension, + dimensionCollectionType: dimensionType, + }); + } + + /** + * @hidden + * @internal + */ + protected updateDropDown( + value: IPivotValue, + dropdown: IgxDropDownComponent + ) { + this.value = value; + dropdown.width = "200px"; + this.aggregateList = PivotUtil.getAggregateList(value, this.grid); + this.cdr.detectChanges(); + dropdown.open(this._subMenuOverlaySettings); + } + + /** + * @hidden + * @internal + */ + public onSummaryClick( + event: MouseEvent, + value: IPivotValue, + dropdown: IgxDropDownComponent + ) { + this._subMenuOverlaySettings.target = + event.currentTarget as HTMLElement; + + if (dropdown.collapsed) { + this.updateDropDown(value, dropdown); + } else { + // close for previous chip + dropdown.close(); + dropdown.closed.pipe(first()).subscribe(() => { + this.updateDropDown(value, dropdown); + }); + } + } + + /** + * @hidden + * @internal + */ + public onAggregationChange(event: ISelectionEventArgs) { + + if (!this.isSelected(event.newSelection.value)) { + this.value.aggregate = event.newSelection.value; + const isSingleValue = this.grid.values.length === 1; + + PivotUtil.updateColumnTypeByAggregator(this.grid.columns, this.value, isSingleValue); + + this.grid.pipeTrigger++; + this.grid.cdr.markForCheck(); + } + } + + /** + * @hidden + * @internal + */ + public isSelected(val: IPivotAggregator) { + return this.value.aggregate.key === val.key; + } + + /** + * @hidden + * @internal + */ + public ghostCreated(event: IDragGhostBaseEventArgs, value: string) { + const { width: itemWidth } = + event.owner.element.nativeElement.getBoundingClientRect(); + this.ghostWidth = itemWidth; + this.ghostText = value; + this.renderer.setStyle( + event.owner.element.nativeElement, + "position", + "absolute" + ); + this.renderer.setStyle( + event.owner.element.nativeElement, + "visibility", + "hidden" + ); + } + + /** + * @hidden + * @internal + */ + public toggleItem(item: IPivotDimension | IPivotValue) { + if (item as IPivotValue) { + this.grid.toggleValue(item as IPivotValue); + } + + if (item as IPivotDimension) { + this.grid.toggleDimension(item as IPivotDimension); + } + } + + /** + * @hidden + * @internal + */ + public onPanelEntry(event: IDropBaseEventArgs, panel: string) { + this.dropAllowed = event.dragData.gridID === this.grid.id && event.dragData.selectorChannels?.some( + (channel: string) => channel === panel + ); + } + + /** + * @hidden + * @internal + */ + public onItemDragMove(event: IDragMoveEventArgs) { + const clientRect = + event.owner.element.nativeElement.getBoundingClientRect(); + this._dropDelta = Math.round( + (event.nextPageY - event.startY) / clientRect.height + ); + } + + /** + * @hidden + * @internal + */ + public onItemDragEnd(event: IDragBaseEventArgs) { + this.renderer.setStyle( + event.owner.element.nativeElement, + "position", + "static" + ); + this.renderer.setStyle( + event.owner.element.nativeElement, + "visibility", + "visible" + ); + } + + /** + * @hidden + * @internal + */ + public onItemDragOver(event: IDropBaseEventArgs) { + if (this.dropAllowed) { + this.renderer.addClass( + event.owner.element.nativeElement, + "igx-drag--push" + ); + } + } + + /** + * @hidden + * @internal + */ + public onItemDragLeave(event: IDropBaseEventArgs) { + if (this.dropAllowed) { + this.renderer.removeClass( + event.owner.element.nativeElement, + "igx-drag--push" + ); + } + } + + /** + * @hidden + * @internal + */ + public getPanelCollapsed(panelType: PivotDimensionType): boolean { + switch (panelType) { + case PivotDimensionType.Column: + return !this.columnsExpanded; + case PivotDimensionType.Filter: + return !this.filtersExpanded; + case PivotDimensionType.Row: + return !this.rowsExpanded; + default: + return !this.valuesExpanded; + } + } + + /** + * @hidden + * @internal + */ + public onCollapseChange(value: boolean, panelType: PivotDimensionType): void { + switch (panelType) { + case PivotDimensionType.Column: + this.columnsExpanded = !value; + this.columnsExpandedChange.emit(this.columnsExpanded); + break; + case PivotDimensionType.Filter: + this.filtersExpanded = !value; + this.filtersExpandedChange.emit(this.filtersExpanded); + break; + case PivotDimensionType.Row: + this.rowsExpanded = !value; + this.rowsExpandedChange.emit(this.rowsExpanded); + break; + default: + this.valuesExpanded = !value; + this.valuesExpandedChange.emit(this.valuesExpanded) + } + } +} diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.spec.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.spec.ts new file mode 100644 index 00000000000..e5d1ac8f61b --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.spec.ts @@ -0,0 +1,481 @@ +import { DebugElement } from "@angular/core"; +import { fakeAsync, TestBed, waitForAsync } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { IgxExpansionPanelHeaderComponent } from 'igniteui-angular/expansion-panel'; +import { IgxExpansionPanelComponent } from 'igniteui-angular/expansion-panel'; +import { IgxInputDirective } from 'igniteui-angular/input-group'; +import { IgxPivotGridTestBaseComponent } from "../../../test-utils/pivot-grid-samples.spec"; +import { UIInteractions, wait } from "../../../test-utils/ui-interactions.spec"; +import { IgxPivotDataSelectorComponent } from "./pivot-data-selector.component"; +import { + IgxGridNavigationService, + IPivotDimension, + IPivotValue, + PivotDimensionType, + PivotGridType +} from "igniteui-angular/grids/core"; +import { setElementSize } from '../../../test-utils/helper-utils.spec'; +import { ɵSize, SortingDirection } from 'igniteui-angular/core'; +import { IgxCheckboxComponent } from 'igniteui-angular/checkbox'; + +describe("Pivot data selector", () => { + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, IgxPivotDataSelectorComponent + ] + }).compileComponents(); + })); + + it("should initialize standalone before a grid is set ", () => { + const fixture = TestBed.createComponent(IgxPivotDataSelectorComponent); + fixture.detectChanges(); + expect(fixture.componentInstance).toBeDefined(); + }); +}); + +describe("Pivot data selector integration", () => { + let fixture; + let grid: PivotGridType; + let selector: IgxPivotDataSelectorComponent; + let pivotItems: (IPivotDimension | IPivotValue)[]; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxPivotGridTestBaseComponent + ], + providers: [ + IgxGridNavigationService + ] + }).compileComponents(); + })); + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(IgxPivotGridTestBaseComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.pivotGrid; + selector = fixture.componentInstance.dataSelector; + pivotItems = [ + ...grid.pivotConfiguration.rows, + ...grid.pivotConfiguration.columns, + ...grid.pivotConfiguration.filters, + ...grid.pivotConfiguration.values, + ]; + })); + + it("should set its size based on the passed grid instance size", () => { + setElementSize(grid.nativeElement, ɵSize.Small) + fixture.detectChanges(); + expect((selector as any).size).toEqual(ɵSize.Small); + }); + + it("should set through API expand states for panels with two way data binding", () => { + spyOn(selector.filtersExpandedChange, "emit"); + spyOn(selector.columnsExpandedChange, "emit"); + spyOn(selector.rowsExpandedChange, "emit"); + spyOn(selector.valuesExpandedChange, "emit"); + + const expansionPanels = fixture.debugElement.queryAll(By.directive(IgxExpansionPanelComponent)); + expect(expansionPanels.length).toEqual(4); + expect(expansionPanels[0].componentInstance.collapsed).toBeFalse(); + expect(expansionPanels[1].componentInstance.collapsed).toBeFalse(); + expect(expansionPanels[2].componentInstance.collapsed).toBeFalse(); + expect(expansionPanels[3].componentInstance.collapsed).toBeFalse(); + + fixture.componentInstance.filterExpandState = false; + fixture.detectChanges(); + + expect(expansionPanels[0].componentInstance.collapsed).toBeTruthy(); + expect(expansionPanels[1].componentInstance.collapsed).toBeFalse(); + expect(expansionPanels[2].componentInstance.collapsed).toBeFalse(); + expect(expansionPanels[3].componentInstance.collapsed).toBeFalse(); + + fixture.componentInstance.columnExpandState = false; + fixture.detectChanges(); + + expect(expansionPanels[0].componentInstance.collapsed).toBeTrue(); + expect(expansionPanels[1].componentInstance.collapsed).toBeTrue(); + expect(expansionPanels[2].componentInstance.collapsed).toBeFalse(); + expect(expansionPanels[3].componentInstance.collapsed).toBeFalse(); + + fixture.componentInstance.rowExpandState = false; + fixture.detectChanges(); + + expect(expansionPanels[0].componentInstance.collapsed).toBeTrue(); + expect(expansionPanels[1].componentInstance.collapsed).toBeTrue(); + expect(expansionPanels[2].componentInstance.collapsed).toBeTrue(); + expect(expansionPanels[3].componentInstance.collapsed).toBeFalse(); + + fixture.componentInstance.valueExpandState = false; + fixture.detectChanges(); + + expect(expansionPanels[0].componentInstance.collapsed).toBeTrue(); + expect(expansionPanels[1].componentInstance.collapsed).toBeTrue(); + expect(expansionPanels[2].componentInstance.collapsed).toBeTrue(); + expect(expansionPanels[3].componentInstance.collapsed).toBeTrue(); + + expect(selector.filtersExpandedChange.emit).not.toHaveBeenCalled(); + expect(selector.columnsExpandedChange.emit).not.toHaveBeenCalled(); + expect(selector.rowsExpandedChange.emit).not.toHaveBeenCalled(); + expect(selector.valuesExpandedChange.emit).not.toHaveBeenCalled(); + }); + + it("should reflect expansion of panels through two way data binding", async() => { + const expansionPanels = fixture.debugElement.queryAll(By.directive(IgxExpansionPanelComponent)); + const panelHeaders = fixture.debugElement.queryAll(By.directive(IgxExpansionPanelHeaderComponent)); + expect(expansionPanels.length).toEqual(4); + + expect(fixture.componentInstance.filterExpandState).toBeTrue(); + expect(fixture.componentInstance.columnExpandState).toBeTrue(); + expect(fixture.componentInstance.rowExpandState).toBeTrue(); + expect(fixture.componentInstance.valueExpandState).toBeTrue(); + expect(expansionPanels[0].componentInstance.collapsed).toBeFalse(); + expect(expansionPanels[1].componentInstance.collapsed).toBeFalse(); + expect(expansionPanels[2].componentInstance.collapsed).toBeFalse(); + expect(expansionPanels[3].componentInstance.collapsed).toBeFalse(); + + UIInteractions.simulateClickEvent(panelHeaders[0].nativeElement); + fixture.detectChanges(); + await wait(100); + + expect(fixture.componentInstance.filterExpandState).toBeFalse(); + expect(expansionPanels[0].componentInstance.collapsed).toBeTrue(); + + UIInteractions.simulateClickEvent(panelHeaders[1].nativeElement); + fixture.detectChanges(); + await wait(100); + + expect(fixture.componentInstance.columnExpandState).toBeFalse(); + expect(expansionPanels[1].componentInstance.collapsed).toBeTrue(); + + UIInteractions.simulateClickEvent(panelHeaders[2].nativeElement); + fixture.detectChanges(); + await wait(100); + + expect(fixture.componentInstance.rowExpandState).toBeFalse(); + expect(expansionPanels[2].componentInstance.collapsed).toBeTrue(); + + UIInteractions.simulateClickEvent(panelHeaders[3].nativeElement); + fixture.detectChanges(); + await wait(100); + + expect(fixture.componentInstance.valueExpandState).toBeFalse(); + expect(expansionPanels[3].componentInstance.collapsed).toBeTrue(); + }); + + it("should render a list of all row, column, filter, and value dimensions", () => { + const valueList = Array.from( + fixture.debugElement + .query(By.directive(IgxPivotDataSelectorComponent)) + .nativeElement.querySelectorAll( + ".igx-pivot-data-selector__filter > igx-list > igx-list-item" + ) as NodeList + ); + + valueList.forEach((li, index) => { + expect(li.textContent).toEqual( + (pivotItems[index] as any).memberName || + (pivotItems[index] as any).member + ); + }); + }); + + it("should filter the dimension list based on a search term", () => { + const term = ( + Object.values( + fixture.componentInstance.pivotConfigHierarchy + )[0][0] as IPivotDimension + ).memberName; + + const inputElement = fixture.debugElement + .query(By.directive(IgxPivotDataSelectorComponent)) + .query(By.directive(IgxInputDirective)).nativeElement; + + inputElement.value = term; + inputElement.dispatchEvent(new Event("input")); + fixture.detectChanges(); + + const valueList = Array.from( + fixture.debugElement + .query(By.directive(IgxPivotDataSelectorComponent)) + .nativeElement.querySelectorAll( + ".igx-pivot-data-selector__filter > igx-list > igx-list-item" + ) as NodeList + ); + + valueList.forEach((li) => { + expect(li.textContent).toContain(term); + }); + + expect(valueList.length).toBe(1); + + // Clear the filter + inputElement.value = undefined; + inputElement.dispatchEvent(new Event("input")); + fixture.detectChanges(); + }); + + it("should enable/disable dimensions from the pivot config on checkbox click", () => { + const dimension = grid.pivotConfiguration.columns[0]; + let items = getPanelItemsByDimensionType(PivotDimensionType.Column); + + const checkbox = fixture.debugElement + .query(By.directive(IgxPivotDataSelectorComponent)) + .queryAll(By.directive(IgxCheckboxComponent)) + .find( + (el: DebugElement) => + el.componentInstance.ariaLabelledBy === dimension.memberName + ); + + // Initial State + expect(dimension.enabled).toBe(true); + expect(items.length).toEqual(grid.pivotConfiguration.columns.length); + checkbox.nativeElement.dispatchEvent(new Event("click")); + + fixture.detectChanges(); + + // After clicking on the checkbox + items = getPanelItemsByDimensionType(PivotDimensionType.Column); + expect(dimension.enabled).toBe(false); + expect(items.length).toEqual( + grid.pivotConfiguration.columns.length - 1 + ); + }); + + it("should sort column and row dimensions on item click", () => { + const colDimension = grid.pivotConfiguration.columns[0]; + const rowDimension = grid.pivotConfiguration.rows[0]; + const colSortEl = getPanelItemsByDimensionType( + PivotDimensionType.Column + ) + .find((item) => item.textContent.includes(colDimension.memberName)) + .parentNode.querySelector(".igx-pivot-data-selector__action-sort"); + const rowSortEl = getPanelItemsByDimensionType(PivotDimensionType.Row) + .find((item) => item.textContent.includes(rowDimension.memberName)) + .parentNode.querySelector(".igx-pivot-data-selector__action-sort"); + + colSortEl.dispatchEvent(new Event("click")); + rowSortEl.dispatchEvent(new Event("click")); + fixture.detectChanges(); + + expect(colDimension.sortDirection).toEqual(SortingDirection.Asc); + expect(rowDimension.sortDirection).toEqual(SortingDirection.Asc); + + colSortEl.dispatchEvent(new Event("click")); + rowSortEl.dispatchEvent(new Event("click")); + fixture.detectChanges(); + + expect(colDimension.sortDirection).toEqual(SortingDirection.Desc); + expect(rowDimension.sortDirection).toEqual(SortingDirection.Desc); + + colSortEl.dispatchEvent(new Event("click")); + rowSortEl.dispatchEvent(new Event("click")); + fixture.detectChanges(); + + expect(colDimension.sortDirection).toEqual(SortingDirection.None); + expect(rowDimension.sortDirection).toEqual(SortingDirection.None); + }); + + it("should render panel header sections for all pivot dimensions", () => { + Object.values(PivotDimensionType).forEach((dt) => { + if (isNaN(Number(dt))) return; + const headerNode = getPanelHeaderByDimensionType( + dt as PivotDimensionType + ); + const headerTitle = selector._panels.find( + (panel) => panel.type === (dt as PivotDimensionType) + ).name; + const dimensionSize = grid.getDimensionsByType( + dt as PivotDimensionType + ).length; + + expect(headerNode.textContent).toContain(headerTitle); + expect(headerNode.textContent).toContain(dimensionSize); + }); + }); + + it("should render panel header section for the values", () => { + const headerNode = getPanelHeaderByDimensionType(null); + const headerTitle = selector._panels.find( + (panel) => panel.type === null + ).name; + const valuesSize = grid.pivotConfiguration.values?.length; + + expect(headerNode.textContent).toContain(headerTitle); + expect(headerNode.textContent).toContain(valuesSize.toString()); + }); + + it("should render a section of all dimension items in a panel", () => { + Object.values(PivotDimensionType).forEach((dt) => { + if (isNaN(Number(dt))) return; + expectConfigToMatchPanels(dt as PivotDimensionType); + }); + }); + + it("should render a section of all value items in a panel", () => { + expectConfigToMatchPanels(null); // pass an invalid type (null) to test for values + }); + + it("should fire event handlers on reorder in a panel using drag and drop gestures", () => { + // Get all value items + const items = getPanelItemsByDimensionType(null); + + spyOn(selector, "ghostCreated"); + spyOn(selector, "onItemDragMove"); + spyOn(selector, "onItemDragEnd"); + spyOn(selector, "onItemDropped"); + + // Get the drag handle of the last item in the panel + const dragHandle = items[0].parentNode + .querySelectorAll("igx-list-item")[items.length - 1].querySelector("[igxDragHandle]"); + + dragHandle.scrollIntoView(); + fixture.detectChanges(); + + let { x: handleX, y: handleY } = dragHandle.getBoundingClientRect(); + // Take into account that the window offset, since pointer events automatically add it. + handleY = handleY + window.pageYOffset + + UIInteractions.simulatePointerEvent("pointerdown", dragHandle, handleX, handleY); + fixture.detectChanges(); + + UIInteractions.simulatePointerEvent("pointermove", dragHandle, handleX, handleY - 10); + fixture.detectChanges(); + + const ghost = document.body.querySelector(".igx-pivot-data-selector__item-ghost"); + expect(selector.ghostCreated).toHaveBeenCalled(); + + UIInteractions.simulatePointerEvent("pointermove", ghost, handleX, handleY - 36); + fixture.detectChanges(); + expect(selector.onItemDragMove).toHaveBeenCalled(); + + UIInteractions.simulatePointerEvent("pointerup", ghost, handleX, handleY - 36); + fixture.detectChanges(); + + expect(selector.onItemDragEnd).toHaveBeenCalled(); + expect(selector.onItemDropped).toHaveBeenCalled(); + }); + + it("should reorder items in a panel using drag and drop gestures", () => { + // Get all value items + const items = getPanelItemsByDimensionType(null); + + expect(fixture.componentInstance.pivotGrid.pivotConfiguration.values[0].member).toEqual('UnitsSold'); + expect(fixture.componentInstance.pivotGrid.pivotConfiguration.values[1].member).toEqual('UnitPrice'); + + // Get the drag handle of the last item in the panel + const dragHandle = items[0].parentNode + .querySelectorAll("igx-list-item")[items.length - 1].querySelector("[igxDragHandle]"); + + dragHandle.scrollIntoView(); + fixture.detectChanges(); + + let { x: handleX, y: handleY } = dragHandle.getBoundingClientRect(); + // Take into account that the window offset, since pointer events automatically add it. + handleY = handleY + window.pageYOffset + + UIInteractions.simulatePointerEvent("pointerdown", dragHandle, handleX, handleY); + fixture.detectChanges(); + + UIInteractions.simulatePointerEvent("pointermove", dragHandle, handleX, handleY - 10); + fixture.detectChanges(); + + const ghost = document.body.querySelector(".igx-pivot-data-selector__item-ghost"); + fixture.componentInstance.dataSelector.dropAllowed = true; + + UIInteractions.simulatePointerEvent("pointermove", ghost, handleX, handleY - 36); + fixture.detectChanges(); + + UIInteractions.simulatePointerEvent("pointerup", ghost, handleX, handleY - 36); + fixture.detectChanges(); + + expect(fixture.componentInstance.pivotGrid.pivotConfiguration.values[0].member).toEqual('UnitPrice'); + expect(fixture.componentInstance.pivotGrid.pivotConfiguration.values[1].member).toEqual('UnitsSold'); + }); + + it("should call filtering menu on column and row filter click", () => { + spyOn(grid.filteringService, "toggleFilterDropdown"); + + const columnItems = getPanelItemsByDimensionType( + PivotDimensionType.Column + ); + const rowItems = getPanelItemsByDimensionType(PivotDimensionType.Row); + + const getFilteringIcon = (item: Node) => + item.parentNode + .querySelector("igx-list-item") + .querySelector(".igx-pivot-data-selector__action-filter"); + + const colFilterActions = columnItems.map(getFilteringIcon); + const rowFilterActions = rowItems.map(getFilteringIcon); + + colFilterActions[0].dispatchEvent(new Event('click')); + fixture.detectChanges(); + + expect(grid.filteringService.toggleFilterDropdown).toHaveBeenCalled(); + + rowFilterActions[0].dispatchEvent(new Event('click')); + fixture.detectChanges(); + + expect(grid.filteringService.toggleFilterDropdown).toHaveBeenCalled(); + }); + + const expectConfigToMatchPanels = (dimensionType: PivotDimensionType) => { + const items = getPanelItemsByDimensionType(dimensionType); + let dimension: IPivotDimension[] | IPivotValue[]; + + switch (dimensionType) { + case PivotDimensionType.Filter: + dimension = grid.pivotConfiguration.filters; + break; + case PivotDimensionType.Column: + dimension = grid.pivotConfiguration.columns; + break; + case PivotDimensionType.Row: + dimension = grid.pivotConfiguration.rows; + break; + default: + dimension = grid.pivotConfiguration.values; + break; + } + + expect(items.length).toEqual(dimension.length); + + items.forEach((li, index) => { + const item = dimension[index] as any; + expect(li.textContent).toContain(item.memberName || item.member); + }); + }; + + const getPanelHeaderByDimensionType = ( + dimensionType: PivotDimensionType + ) => { + const panelIndex = selector._panels.findIndex( + (panel) => panel.type === dimensionType + ); + + return fixture.debugElement + .query(By.directive(IgxPivotDataSelectorComponent)) + .nativeElement.querySelectorAll("igx-expansion-panel-header")[ + panelIndex + ] as Node; + }; + + const getPanelItemsByDimensionType = ( + dimensionType: PivotDimensionType + ) => { + const panelIndex = selector._panels.findIndex( + (panel) => panel.type === dimensionType + ); + + return Array.from( + fixture.debugElement + .query(By.directive(IgxPivotDataSelectorComponent)) + .nativeElement.querySelectorAll("igx-expansion-panel-body")[panelIndex].querySelectorAll("igx-list-item") as NodeList + ); + }; +}); diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-filtering.service.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-filtering.service.ts new file mode 100644 index 00000000000..ab6e0706130 --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-filtering.service.ts @@ -0,0 +1,91 @@ +import { Injectable } from '@angular/core'; +import { first, takeUntil } from 'rxjs/operators'; +import { DimensionValuesFilteringStrategy, PivotUtil } from 'igniteui-angular/grids/core'; +import { IgxFilteringService } from 'igniteui-angular/grids/core'; +import { ColumnType, FilteringExpressionsTree, FilteringLogic, IFilteringExpressionsTree, IFilteringOperation } from 'igniteui-angular/core'; + +@Injectable() +export class IgxPivotFilteringService extends IgxFilteringService { + private filtersESFId; + + public override clearFilter(field: string): void { + this.clear_filter(field); + } + + public override clear_filter(fieldName: string) { + super.clear_filter(fieldName); + const grid = this.grid; + const allDimensions = grid.allDimensions; + const allDimensionsFlat = PivotUtil.flatten(allDimensions); + const dim = allDimensionsFlat.find(x => x.memberName === fieldName); + dim.filter = undefined; + grid.filteringPipeTrigger++; + if (allDimensions.indexOf(dim) !== -1) { + // update columns + (grid as any).setupColumns(); + } + } + protected override filter_internal(fieldName: string, term, conditionOrExpressionsTree: IFilteringOperation | IFilteringExpressionsTree, + ignoreCase: boolean) { + super.filter_internal(fieldName, term, conditionOrExpressionsTree, ignoreCase); + const grid = this.grid; + const config = grid.pivotConfiguration; + const allDimensions = PivotUtil.flatten(config.rows.concat(config.columns).concat(config.filters).filter(x => x !== null && x !== undefined)); + const enabledDimensions = allDimensions.filter(x => x && x.enabled); + const dim = enabledDimensions.find(x => x.memberName === fieldName || x.member === fieldName); + const filteringTree = dim.filter || new FilteringExpressionsTree(FilteringLogic.And); + const fieldFilterIndex = filteringTree.findIndex(fieldName); + if (fieldFilterIndex > -1) { + filteringTree.filteringOperands.splice(fieldFilterIndex, 1); + } + + this.prepare_filtering_expression(filteringTree, fieldName, term, conditionOrExpressionsTree, ignoreCase, fieldFilterIndex); + dim.filter = filteringTree; + grid.filteringPipeTrigger++; + grid.filterStrategy = grid.filterStrategy ?? new DimensionValuesFilteringStrategy(); + if (allDimensions.indexOf(dim) !== -1) { + // update columns + (grid as any).setupColumns(); + } + } + + public toggleFiltersESF(dropdown: any, element: HTMLElement, column: ColumnType, shouldReattach: boolean) { + const filterIcon = column.filteringExpressionsTree ? 'igx-excel-filter__icon--filtered' : 'igx-excel-filter__icon'; + const filterIconTarget = element.querySelector(`.${filterIcon}`) as HTMLElement || element; + + const { id, ref } = this.grid.createFilterESF(dropdown, column, { + ...this._filterMenuOverlaySettings, + ...{ target: filterIconTarget } + }, shouldReattach); + + this.filtersESFId = id; + + if (shouldReattach) { + this._overlayService.opening + .pipe( + first(overlay => overlay.id === id), + takeUntil(this.destroy$) + ) + .subscribe(() => this.lastActiveNode = this.grid.navigation.activeNode); + + this._overlayService.closed + .pipe( + first(overlay => overlay.id === id), + takeUntil(this.destroy$) + ) + .subscribe(() => { + this._overlayService.detach(id); + ref?.destroy(); + this.grid.navigation.activeNode = this.lastActiveNode; + this.grid.theadRow.nativeElement.focus(); + }); + + this.grid.columnPinned.pipe(first()).subscribe(() => ref?.destroy()); + this._overlayService.show(id); + } + } + + public hideESF() { + this._overlayService.hide(this.filtersESFId); + } +} diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid-keyboard-nav.spec.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid-keyboard-nav.spec.ts new file mode 100644 index 00000000000..b3a85254b9b --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid-keyboard-nav.spec.ts @@ -0,0 +1,407 @@ +import { TestBed, fakeAsync, ComponentFixture, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { GridFunctions } from '../../../test-utils/grid-functions.spec'; +import { IgxPivotGridMultipleRowComponent, IgxPivotGridTestBaseComponent } from '../../../test-utils/pivot-grid-samples.spec'; +import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; +import { IgxPivotGridComponent } from './pivot-grid.component'; +import { IgxPivotRowDimensionHeaderComponent } from './pivot-row-dimension-header.component'; +import { DebugElement } from '@angular/core'; +import { IgxPivotHeaderRowComponent } from './pivot-header-row.component'; +import { IgxGridNavigationService, PivotRowLayoutType } from 'igniteui-angular/grids/core'; + +const DEBOUNCE_TIME = 250; +const PIVOT_TBODY_CSS_CLASS = '.igx-grid__tbody'; +const PIVOT_ROW_DIMENSION_CONTENT = 'igx-pivot-row-dimension-content'; +const PIVOT_HEADER_ROW = 'igx-pivot-header-row'; +const HEADER_CELL_CSS_CLASS = '.igx-grid-th'; +const ACTIVE_CELL_CSS_CLASS = '.igx-grid-th--active'; +const CSS_CLASS_ROW_DIMENSION_CONTAINER = '.igx-grid__tbody-pivot-dimension' +const CSS_CLASS_TBODY_CONTENT = '.igx-grid__tbody-content'; + +describe('IgxPivotGrid - Keyboard navigation #pivotGrid', () => { + describe('General Keyboard Navigation', () => { + let fixture: ComponentFixture; + let pivotGrid: IgxPivotGridComponent; + let rowDimension: DebugElement; + let headerRow: DebugElement; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxPivotGridMultipleRowComponent + ], + providers: [ + IgxGridNavigationService + ] + }).compileComponents(); + })); + + beforeEach(fakeAsync(async () => { + fixture = TestBed.createComponent(IgxPivotGridMultipleRowComponent); + fixture.detectChanges(); + pivotGrid = fixture.componentInstance.pivotGrid; + await fixture.whenStable(); + rowDimension = fixture.debugElement.query( + By.css(CSS_CLASS_ROW_DIMENSION_CONTAINER)); + headerRow = fixture.debugElement.query(By.directive(IgxPivotHeaderRowComponent)); + })); + + it('should allow navigating between row headers', () => { + const allGroups = fixture.debugElement.queryAll( + By.directive(IgxPivotRowDimensionHeaderComponent)); + const firstCell = allGroups[0]; + const secondCell = allGroups.filter(x => x.componentInstance.column.field === 'Country')[0]; + UIInteractions.simulateClickAndSelectEvent(firstCell); + fixture.detectChanges(); + + GridFunctions.verifyHeaderIsFocused(firstCell.parent); + // for the row dimensions headers, the active descendant is set on the div having + // tabindex="0" and class '.igx-grid__tbody-pivot-dimension'; + GridFunctions.verifyPivotElementActiveDescendant(rowDimension, firstCell.nativeElement.id); + expect(firstCell.nativeElement.getAttribute('role')).toBe('rowheader'); + let activeCells = fixture.debugElement.queryAll(By.css(`${ACTIVE_CELL_CSS_CLASS}`)); + expect(activeCells.length).toBe(1); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', firstCell.nativeElement); + fixture.detectChanges(); + GridFunctions.verifyHeaderIsFocused(secondCell.parent); + GridFunctions.verifyPivotElementActiveDescendant(rowDimension, secondCell.nativeElement.id); + expect(firstCell.nativeElement.getAttribute('role')).toBe('rowheader'); + activeCells = fixture.debugElement.queryAll(By.css(`${ACTIVE_CELL_CSS_CLASS}`)); + expect(activeCells.length).toBe(1); + + // should do nothing if wrong key is pressed + UIInteractions.simulateClickAndSelectEvent(firstCell); + fixture.detectChanges(); + GridFunctions.verifyHeaderIsFocused(firstCell.parent); + GridFunctions.verifyPivotElementActiveDescendant(rowDimension, firstCell.nativeElement.id); + activeCells = fixture.debugElement.queryAll(By.css(`${ACTIVE_CELL_CSS_CLASS}`)); + expect(activeCells.length).toBe(1); + UIInteractions.triggerKeyDownEvtUponElem('h', firstCell.nativeElement); + fixture.detectChanges(); + GridFunctions.verifyHeaderIsFocused(firstCell.parent); + GridFunctions.verifyPivotElementActiveDescendant(rowDimension, firstCell.nativeElement.id); + }); + + it('should not go outside of the boundaries of the row dimensions content', () => { + const allGroups = fixture.debugElement.queryAll( + By.directive(IgxPivotRowDimensionHeaderComponent)); + const firstCell = allGroups[0]; + const thirdCell = allGroups.filter(x => x.componentInstance.column.field === 'Date')[0]; + UIInteractions.simulateClickAndSelectEvent(firstCell); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', firstCell.nativeElement); + fixture.detectChanges(); + + GridFunctions.verifyHeaderIsFocused(firstCell.parent); + GridFunctions.verifyPivotElementActiveDescendant(rowDimension, firstCell.nativeElement.id); + let activeCells = fixture.debugElement.queryAll(By.css(`${ACTIVE_CELL_CSS_CLASS}`)); + expect(activeCells.length).toBe(1); + + UIInteractions.simulateClickAndSelectEvent(thirdCell); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', thirdCell.nativeElement); + fixture.detectChanges(); + + GridFunctions.verifyHeaderIsFocused(thirdCell.parent); + GridFunctions.verifyPivotElementActiveDescendant(rowDimension, thirdCell.nativeElement.id); + activeCells = fixture.debugElement.queryAll(By.css(`${ACTIVE_CELL_CSS_CLASS}`)); + expect(activeCells.length).toBe(1); + }); + + it('should allow navigating from first to last row headers in a row(Home/End)', () => { + const allGroups = fixture.debugElement.queryAll( + By.directive(IgxPivotRowDimensionHeaderComponent)); + const firstCell = allGroups[0]; + const thirdCell = allGroups.filter(x => x.componentInstance.column.field === 'Date')[0]; + UIInteractions.simulateClickAndSelectEvent(firstCell); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('End', firstCell.nativeElement); + fixture.detectChanges(); + GridFunctions.verifyHeaderIsFocused(thirdCell.parent); + GridFunctions.verifyPivotElementActiveDescendant(rowDimension, thirdCell.nativeElement.id); + let activeCells = fixture.debugElement.queryAll(By.css(`${ACTIVE_CELL_CSS_CLASS}`)); + expect(activeCells.length).toBe(1); + + UIInteractions.triggerKeyDownEvtUponElem('Home', thirdCell.nativeElement); + fixture.detectChanges(); + GridFunctions.verifyHeaderIsFocused(firstCell.parent); + GridFunctions.verifyPivotElementActiveDescendant(rowDimension, firstCell.nativeElement.id); + activeCells = fixture.debugElement.queryAll(By.css(`${ACTIVE_CELL_CSS_CLASS}`)); + expect(activeCells.length).toBe(1); + }); + + it('should allow navigating from first to last row headers(Ctrl + ArrowDown)', () => { + let allGroups = fixture.debugElement.queryAll( + By.directive(IgxPivotRowDimensionHeaderComponent)); + const thirdCell = allGroups.filter(x => x.componentInstance.column.field === 'Date')[0]; + UIInteractions.simulateClickAndSelectEvent(thirdCell); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', thirdCell.nativeElement, true, false, false, true); + fixture.detectChanges(); + + allGroups = fixture.debugElement.queryAll( + By.directive(IgxPivotRowDimensionHeaderComponent)); + const lastCell = allGroups[allGroups.length - 1]; + GridFunctions.verifyHeaderIsFocused(lastCell.parent); + GridFunctions.verifyPivotElementActiveDescendant(rowDimension, lastCell.nativeElement.id); + const activeCells = fixture.debugElement.queryAll(By.css(`${ACTIVE_CELL_CSS_CLASS}`)); + expect(activeCells.length).toBe(1); + }); + + it('should allow navigating from any to first row headers(Ctrl + ArrowUp)', () => { + // Ctrl + arrowup + let allGroups = fixture.debugElement.queryAll( + By.directive(IgxPivotRowDimensionHeaderComponent)); + const thirdCell = allGroups.filter(x => x.componentInstance.column.field === 'ProductCategory')[2] + UIInteractions.simulateClickAndSelectEvent(thirdCell); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', thirdCell.nativeElement, true, false, false, true); + fixture.detectChanges(); + + allGroups = fixture.debugElement.queryAll( + By.directive(IgxPivotRowDimensionHeaderComponent)); + const firstCell = allGroups[0]; + GridFunctions.verifyHeaderIsFocused(firstCell.parent); + GridFunctions.verifyPivotElementActiveDescendant(rowDimension, firstCell.nativeElement.id); + let activeCells = fixture.debugElement.queryAll(By.css(`${ACTIVE_CELL_CSS_CLASS}`)); + expect(activeCells.length).toBe(1); + + // just arrow up + UIInteractions.simulateClickAndSelectEvent(thirdCell); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', thirdCell.nativeElement, true, false, false, false); + fixture.detectChanges(); + allGroups = fixture.debugElement.queryAll( + By.directive(IgxPivotRowDimensionHeaderComponent)); + const secondCell = allGroups.filter(x => x.componentInstance.column.field === 'ProductCategory')[1]; + GridFunctions.verifyHeaderIsFocused(secondCell.parent); + GridFunctions.verifyPivotElementActiveDescendant(rowDimension, secondCell.nativeElement.id); + activeCells = fixture.debugElement.queryAll(By.css(`${ACTIVE_CELL_CSS_CLASS}`)); + expect(activeCells.length).toBe(1); + + }); + + it('should allow navigating between column headers', () => { + let firstHeader = fixture.debugElement.queryAll( + By.css(`${PIVOT_HEADER_ROW} ${HEADER_CELL_CSS_CLASS}`))[0]; + UIInteractions.simulateClickAndSelectEvent(firstHeader); + fixture.detectChanges(); + + firstHeader = fixture.debugElement.queryAll( + By.css(`${PIVOT_HEADER_ROW} ${HEADER_CELL_CSS_CLASS}`))[0]; + GridFunctions.verifyHeaderIsFocused(firstHeader.parent); + // for the column headers, the active descendant is set on the header row element + GridFunctions.verifyPivotElementActiveDescendant(headerRow, firstHeader.nativeElement.id); + let activeCells = fixture.debugElement.queryAll(By.css(`${ACTIVE_CELL_CSS_CLASS}`)); + expect(activeCells.length).toBe(1); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', pivotGrid.theadRow.nativeElement); + fixture.detectChanges(); + + const secondHeader = fixture.debugElement.queryAll( + By.css(`${PIVOT_HEADER_ROW} ${HEADER_CELL_CSS_CLASS}`))[1]; + GridFunctions.verifyHeaderIsFocused(secondHeader.parent); + GridFunctions.verifyPivotElementActiveDescendant(headerRow, secondHeader.nativeElement.id); + activeCells = fixture.debugElement.queryAll(By.css(`${ACTIVE_CELL_CSS_CLASS}`)); + expect(activeCells.length).toBe(1); + }); + + it('should allow navigating from first to last column headers', async () => { + let firstHeader = fixture.debugElement.queryAll( + By.css(`${PIVOT_HEADER_ROW} ${HEADER_CELL_CSS_CLASS}`))[0]; + UIInteractions.simulateClickAndSelectEvent(firstHeader); + fixture.detectChanges(); + + firstHeader = fixture.debugElement.queryAll( + By.css(`${PIVOT_HEADER_ROW} ${HEADER_CELL_CSS_CLASS}`))[0]; + + GridFunctions.verifyHeaderIsFocused(firstHeader.parent); + GridFunctions.verifyPivotElementActiveDescendant(headerRow, firstHeader.nativeElement.id); + let activeCells = fixture.debugElement.queryAll(By.css(`${ACTIVE_CELL_CSS_CLASS}`)); + expect(activeCells.length).toBe(1); + + UIInteractions.triggerKeyDownEvtUponElem('End', pivotGrid.theadRow.nativeElement); + await wait(DEBOUNCE_TIME); + fixture.detectChanges(); + + const allHeaders = fixture.debugElement.queryAll( + By.css(`${PIVOT_HEADER_ROW} ${HEADER_CELL_CSS_CLASS}`)); + const lastHeader = allHeaders[allHeaders.length - 1]; + GridFunctions.verifyHeaderIsFocused(lastHeader.parent); + GridFunctions.verifyPivotElementActiveDescendant(headerRow, lastHeader.nativeElement.id); + activeCells = fixture.debugElement.queryAll(By.css(`${ACTIVE_CELL_CSS_CLASS}`)); + expect(activeCells.length).toBe(1); + }); + + it('should allow navigating in column headers when switching focus from rows to columns', () => { + const [firstCell] = fixture.debugElement.queryAll( + By.css(`${PIVOT_TBODY_CSS_CLASS} ${PIVOT_ROW_DIMENSION_CONTENT} ${HEADER_CELL_CSS_CLASS}`)); + UIInteractions.simulateClickAndSelectEvent(firstCell); + fixture.detectChanges(); + + let firstHeader = fixture.debugElement.queryAll( + By.css(`${PIVOT_HEADER_ROW} ${HEADER_CELL_CSS_CLASS}`))[0]; + UIInteractions.simulateClickAndSelectEvent(firstHeader); + fixture.detectChanges(); + + firstHeader = fixture.debugElement.queryAll( + By.css(`${PIVOT_HEADER_ROW} ${HEADER_CELL_CSS_CLASS}`))[0]; + GridFunctions.verifyHeaderIsFocused(firstHeader.parent); + GridFunctions.verifyPivotElementActiveDescendant(headerRow, firstHeader.nativeElement.id); + let activeCells = fixture.debugElement.queryAll(By.css(`${ACTIVE_CELL_CSS_CLASS}`)); + expect(activeCells.length).toBe(1); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', firstHeader.nativeElement); + fixture.detectChanges(); + const secondHeader = fixture.debugElement.queryAll( + By.css(`${PIVOT_HEADER_ROW} ${HEADER_CELL_CSS_CLASS}`))[1]; + GridFunctions.verifyHeaderIsFocused(secondHeader.parent); + GridFunctions.verifyPivotElementActiveDescendant(headerRow, secondHeader.nativeElement.id); + activeCells = fixture.debugElement.queryAll(By.css(`${ACTIVE_CELL_CSS_CLASS}`)); + expect(activeCells.length).toBe(1); + }); + + it('should navigate properly among row dimension column headers for horizontal row layout', () => { + pivotGrid.pivotUI = { + ...pivotGrid.pivotUI, + rowLayout: PivotRowLayoutType.Horizontal, + showRowHeaders: true + }; + fixture.detectChanges(); + + let firstHeader = fixture.debugElement.queryAll( + By.css(`${PIVOT_HEADER_ROW} ${HEADER_CELL_CSS_CLASS}`))[0]; + UIInteractions.simulateClickAndSelectEvent(firstHeader); + fixture.detectChanges(); + + firstHeader = fixture.debugElement.queryAll( + By.css(`${PIVOT_HEADER_ROW} ${HEADER_CELL_CSS_CLASS}`))[0]; + GridFunctions.verifyHeaderIsFocused(firstHeader.parent); + // for the row dimensions column headers in horizontal layout, + // the active descendant is set on the header row element. + GridFunctions.verifyPivotElementActiveDescendant(headerRow, firstHeader.nativeElement.id); + expect(firstHeader.nativeElement.getAttribute('role')).toBe('columnheader'); + let activeCells = fixture.debugElement.queryAll(By.css(`${ACTIVE_CELL_CSS_CLASS}`)); + expect(activeCells.length).toBe(1); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', pivotGrid.theadRow.nativeElement); + fixture.detectChanges(); + + const secondHeader = fixture.debugElement.queryAll( + By.css(`${PIVOT_HEADER_ROW} ${HEADER_CELL_CSS_CLASS}`))[1]; + GridFunctions.verifyHeaderIsFocused(secondHeader.parent); + GridFunctions.verifyPivotElementActiveDescendant(headerRow, secondHeader.nativeElement.id); + expect(firstHeader.nativeElement.getAttribute('role')).toBe('columnheader'); + activeCells = fixture.debugElement.queryAll(By.css(`${ACTIVE_CELL_CSS_CLASS}`)); + expect(activeCells.length).toBe(1); + }); + + it('should allow navigating within the cells of the body', async () => { + const cell = pivotGrid.rowList.first.cells.first; + const tBodyContent = fixture.debugElement.query(By.css(CSS_CLASS_TBODY_CONTENT)); + GridFunctions.focusFirstCell(fixture, pivotGrid); + fixture.detectChanges(); + expect(pivotGrid.navigation.activeNode.row).toBeUndefined(); + expect(pivotGrid.navigation.activeNode.column).toBeUndefined(); + + UIInteractions.simulateClickAndSelectEvent(cell.nativeElement); + fixture.detectChanges(); + + GridFunctions.focusFirstCell(fixture, pivotGrid); + fixture.detectChanges(); + expect(pivotGrid.navigation.activeNode.row).toBeDefined(); + expect(pivotGrid.navigation.activeNode.column).toBeDefined(); + // The activedescendant attribute for cells in the grid body + // is set on the tbody content div with tabindex='0' + GridFunctions.verifyPivotElementActiveDescendant(tBodyContent, cell.nativeElement.id); + + let activeCells = fixture.debugElement.queryAll(By.css(`.igx-grid__td--active`)); + expect(activeCells.length).toBe(1); + expect(cell.column.field).toEqual('Stanley-UnitsSold'); + + const gridContent = GridFunctions.getGridContent(fixture); + UIInteractions.triggerEventHandlerKeyDown('arrowright', gridContent); + await wait(30); + fixture.detectChanges(); + + activeCells = fixture.debugElement.queryAll(By.css(`.igx-grid__td--active`)); + expect(activeCells.length).toBe(1); + expect(activeCells[0].componentInstance.column.field).toEqual('Stanley-UnitPrice') + GridFunctions.verifyPivotElementActiveDescendant(tBodyContent, activeCells[0].nativeElement.id); + }); + }); + describe('Row Dimension Expand/Collapse Keyboard Interactions', () => { + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxPivotGridTestBaseComponent + ], + providers: [ + IgxGridNavigationService + ] + }).compileComponents(); + })); + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(IgxPivotGridTestBaseComponent); + fixture.detectChanges(); + })); + + it('should allow row dimension expand(Alt + ArrowDown/ArrowRight) and collapse(Alt + ArrowUp/ArrowLeft)', async () => { + const rowDimension = fixture.debugElement.queryAll( + By.css(CSS_CLASS_ROW_DIMENSION_CONTAINER)); + let allHeaders = fixture.debugElement.queryAll( + By.directive(IgxPivotRowDimensionHeaderComponent)); + + expect(allHeaders.length).toBe(5, 'There should initially be 5 row dimension headers'); + + UIInteractions.simulateClickAndSelectEvent(allHeaders[0]); + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', rowDimension[0], true); + fixture.detectChanges(); + + allHeaders = fixture.debugElement.queryAll( + By.directive(IgxPivotRowDimensionHeaderComponent)); + + expect(allHeaders.length).toBe(1, 'There should be only 1 row dimension header after collapse with Alt + ArrowUp'); + + UIInteractions.simulateClickAndSelectEvent(allHeaders[0]); + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', rowDimension[0], true); + fixture.detectChanges(); + + allHeaders = fixture.debugElement.queryAll( + By.directive(IgxPivotRowDimensionHeaderComponent)); + + expect(allHeaders.length).toBe(5, 'There should be 5 row dimension headers after expand with Alt + ArrowDown'); + + UIInteractions.simulateClickAndSelectEvent(allHeaders[0]); + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', rowDimension[0], true); + fixture.detectChanges(); + + allHeaders = fixture.debugElement.queryAll( + By.directive(IgxPivotRowDimensionHeaderComponent)); + + expect(allHeaders.length).toBe(1, 'There should be 1 row dimension header after collapse with Alt + ArrowLeft'); + + UIInteractions.simulateClickAndSelectEvent(allHeaders[0]); + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', rowDimension[0], true); + fixture.detectChanges(); + + allHeaders = fixture.debugElement.queryAll( + By.directive(IgxPivotRowDimensionHeaderComponent)); + + expect(allHeaders.length).toBe(5, 'There should be 5 row dimension headers after expand with Alt + ArrowRight'); + }); + }); +}); diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid-navigation.service.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid-navigation.service.ts new file mode 100644 index 00000000000..5e0e2140a23 --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid-navigation.service.ts @@ -0,0 +1,404 @@ +import { IActiveNode, IgxGridNavigationService, IMultiRowLayoutNode, IPivotDimension, IPivotGridRecord, PivotSummaryPosition, PivotUtil } from 'igniteui-angular/grids/core'; +import { Injectable } from '@angular/core'; +import { IgxPivotGridComponent } from './pivot-grid.component'; +import { IgxPivotRowDimensionMrlRowComponent } from './pivot-row-dimension-mrl-row.component'; +import { take, timeout } from 'rxjs/operators'; +import { HEADER_KEYS, ROW_COLLAPSE_KEYS, ROW_EXPAND_KEYS, SortingDirection } from 'igniteui-angular/core'; + +@Injectable() +export class IgxPivotGridNavigationService extends IgxGridNavigationService { + public override grid: IgxPivotGridComponent; + public isRowHeaderActive = false; + public isRowDimensionHeaderActive = false; + + public get lastRowDimensionsIndex() { + return this.grid.visibleRowDimensions.length - 1; + } + + public get lastRowDimensionMRLRowIndex() { + return this.grid.verticalRowDimScrollContainers.first.igxGridForOf.length - 1; + } + + public focusOutRowHeader() { + this.isRowHeaderActive = false; + this.isRowDimensionHeaderActive = false; + } + + public override async handleNavigation(event: KeyboardEvent) { + if (this.isRowHeaderActive) { + const key = event.key.toLowerCase(); + const ctrl = event.ctrlKey; + if (!HEADER_KEYS.has(key)) { + return; + } + event.preventDefault(); + + const newActiveNode: IActiveNode = { + row: this.activeNode.row, + column: this.activeNode.column, + level: null, + mchCache: null, + layout: this.activeNode.layout + } + + if (event.altKey) { + this.handleAlt(key, event); + return; + } + + let verticalContainer; + if (this.grid.hasHorizontalLayout) { + let newPosition = { + row: this.activeNode.row, + column: this.activeNode.column, + layout: this.activeNode.layout + }; + verticalContainer = this.grid.verticalRowDimScrollContainers.first; + if (key.includes('left')) { + newPosition = await this.getNextHorizontalPosition(true, ctrl); + } + if (key.includes('right')) { + newPosition = await this.getNextHorizontalPosition(false, ctrl); + } + if (key.includes('up') || key === 'home') { + newPosition = await this.getNextVerticalPosition(true, ctrl || key === 'home', key === 'home'); + } + + if (key.includes('down') || key === 'end') { + newPosition = await this.getNextVerticalPosition(false, ctrl || key === 'end', key === 'end'); + } + + newActiveNode.row = newPosition.row; + newActiveNode.column = newPosition.column; + newActiveNode.layout = newPosition.layout; + } else { + if ((key.includes('left') || key === 'home') && this.activeNode.column > 0) { + newActiveNode.column = ctrl || key === 'home' ? 0 : this.activeNode.column - 1; + } + if ((key.includes('right') || key === 'end') && this.activeNode.column < this.lastRowDimensionsIndex) { + newActiveNode.column = ctrl || key === 'end' ? this.lastRowDimensionsIndex : this.activeNode.column + 1; + } + + verticalContainer = this.grid.verticalRowDimScrollContainers.toArray()[newActiveNode.column]; + if (key.includes('up')) { + if (ctrl) { + newActiveNode.row = 0; + } else if (this.activeNode.row > 0) { + newActiveNode.row = this.activeNode.row - 1; + } else { + newActiveNode.row = -1; + newActiveNode.column = newActiveNode.layout ? newActiveNode.layout.colStart - 1 : 0; + newActiveNode.layout = null; + this.isRowDimensionHeaderActive = true; + this.isRowHeaderActive = false; + this.grid.theadRow.nativeElement.focus(); + } + } + + if (key.includes('down') && this.activeNode.row < this.findLastDataRowIndex()) { + newActiveNode.row = ctrl ? verticalContainer.igxForOf.length - 1 : Math.min(this.activeNode.row + 1, verticalContainer.igxForOf.length - 1); + } + + if (key.includes('left') || key.includes('right')) { + const prevRIndex = this.activeNode.row; + const prevScrContainer = this.grid.verticalRowDimScrollContainers.toArray()[this.activeNode.column]; + const src = prevScrContainer.getScrollForIndex(prevRIndex); + newActiveNode.row = this.activeNode.mchCache && this.activeNode.mchCache.level === newActiveNode.column ? + this.activeNode.mchCache.visibleIndex : + verticalContainer.getIndexAtScroll(src); + newActiveNode.mchCache = { + visibleIndex: this.activeNode.row, + level: this.activeNode.column + }; + } + } + + this.setActiveNode(newActiveNode); + if (!this.grid.hasHorizontalLayout && verticalContainer.isIndexOutsideView(newActiveNode.row)) { + verticalContainer.scrollTo(newActiveNode.row); + } + } else { + super.handleNavigation(event); + } + } + + public override handleAlt(key: string, event: KeyboardEvent): void { + event.preventDefault(); + + let rowData, dimIndex; + if (!this.grid.hasHorizontalLayout) { + dimIndex = this.activeNode.column; + const scrContainer = this.grid.verticalRowDimScrollContainers.toArray()[dimIndex]; + rowData = scrContainer.igxGridForOf[this.activeNode.row]; + } else { + const mrlRow = this.grid.rowDimensionMrlRowsCollection.find(mrl => mrl.rowIndex === this.activeNode.row); + rowData = mrlRow.rowGroup[this.activeNode.layout.rowStart - 1]; + dimIndex = this.activeNode.layout.colStart - 1; + } + const dimension = this.grid.visibleRowDimensions[dimIndex]; + const expansionRowKey = PivotUtil.getRecordKey(rowData, dimension); + const isExpanded = this.grid.expansionStates.get(expansionRowKey) ?? true; + + let prevCellLayout; + if (this.grid.hasHorizontalLayout) { + const parentRow = this.grid.rowDimensionMrlRowsCollection.find(row => row.rowIndex === this.activeNode.row); + prevCellLayout = this.getNextVerticalColumnIndex( + parentRow, + Math.min(parentRow.rowGroup.length, this.activeNode.layout.rowStart), + this.activeNode.layout.colStart); + } + + if (ROW_EXPAND_KEYS.has(key) && !isExpanded) { + this.grid.gridAPI.set_row_expansion_state(expansionRowKey, true, event) + } else if (ROW_COLLAPSE_KEYS.has(key) && isExpanded) { + this.grid.gridAPI.set_row_expansion_state(expansionRowKey, false, event) + } + + if ((ROW_EXPAND_KEYS.has(key) && !isExpanded) || (ROW_COLLAPSE_KEYS.has(key) && isExpanded)) { + this.onRowToggle(!isExpanded, dimension, rowData, prevCellLayout); + } + this.updateActiveNodeLayout(); + this.grid.notifyChanges(); + } + + public updateActiveNodeLayout() { + if (this.grid.hasHorizontalLayout) { + const mrlRow = this.grid.rowDimensionMrlRowsCollection.find(row => row.rowIndex === this.activeNode.row); + const activeCell = mrlRow.contentCells.toArray()[this.activeNode.column]; + this.activeNode.layout = activeCell.layout; + } + } + + /** Update active cell when toggling row expand when horizontal summaries have position set to top */ + public onRowToggle(newExpandState: boolean, dimension: IPivotDimension, rowData: IPivotGridRecord, prevCellLayout: IMultiRowLayoutNode){ + if (this.grid.hasHorizontalLayout && + rowData.totalRecordDimensionName !== dimension.memberName && + dimension.horizontalSummary && this.grid.pivotUI.horizontalSummariesPosition === PivotSummaryPosition.Top) { + const maxActiveRow = Math.min(this.lastRowDimensionMRLRowIndex, this.activeNode.row); + const parentRowUpdated = this.grid.rowDimensionMrlRowsCollection.find(row => row.rowIndex === maxActiveRow); + const maxRowEnd = parentRowUpdated.rowGroup.length + 1; + const nextRowStart = Math.max(1, this.activeNode.layout.rowStart + (!newExpandState ? -1 : 1)); + const curValidRowStart = Math.min(parentRowUpdated.rowGroup.length, nextRowStart); + // Get current cell layout, because the actineNode the rowStart might be different, based on where we come from(might be smaller cell). + + const curCellLayout = this.getNextVerticalColumnIndex(parentRowUpdated, curValidRowStart, this.activeNode.layout.colStart); + const nextBlock = (!newExpandState && prevCellLayout.rowStart === 1) || (newExpandState && prevCellLayout.rowEnd >= maxRowEnd); + this.activeNode.row += nextBlock ? (!newExpandState ? -1 : 1) : 0; + this.activeNode.column = curCellLayout.columnVisibleIndex; + this.activeNode.layout = curCellLayout; + } + } + + public override async headerNavigation(event: KeyboardEvent) { + const key = event.key.toLowerCase(); + const ctrl = event.ctrlKey; + if (!HEADER_KEYS.has(key)) { + return; + } + + if (this.isRowDimensionHeaderActive) { + event.preventDefault(); + + const newActiveNode: IActiveNode = { + row: this.activeNode.row, + column: this.activeNode.column, + level: null, + mchCache: this.activeNode.mchCache, + layout: null + } + + if (ctrl) { + const dimIndex = this.activeNode.column; + const dim = this.grid.visibleRowDimensions[dimIndex]; + if (this.activeNode.row === -1) { + if (key.includes('down') || key.includes('up')) { + let newSortDirection = SortingDirection.None; + if (key.includes('down')) { + newSortDirection = (dim.sortDirection === SortingDirection.Desc) ? SortingDirection.None : SortingDirection.Desc; + } else if (key.includes('up')) { + newSortDirection = (dim.sortDirection === SortingDirection.Asc) ? SortingDirection.None : SortingDirection.Asc; + } + this.grid.sortDimension(dim, newSortDirection); + return; + } + } + } + if ((key.includes('left') || key === 'home') && this.activeNode.column > 0) { + newActiveNode.column = ctrl || key === 'home' ? 0 : this.activeNode.column - 1; + } + if ((key.includes('right') || key === 'end') && this.activeNode.column < this.lastRowDimensionsIndex) { + newActiveNode.column = ctrl || key === 'end' ? this.lastRowDimensionsIndex : this.activeNode.column + 1; + } else if (key.includes('right')) { + this.isRowDimensionHeaderActive = false; + newActiveNode.column = 0; + newActiveNode.level = this.activeNode.mchCache?.level || 0; + newActiveNode.mchCache = this.activeNode.mchCache || { + level: 0, + visibleIndex: 0 + }; + } + + if (key.includes('down')) { + if (this.grid.hasHorizontalLayout) { + this.activeNode.row = 0; + this.activeNode.layout = { + rowStart: 1, + rowEnd: 2, + colStart: newActiveNode.column + 1, + colEnd: newActiveNode.column + 2, + columnVisibleIndex: newActiveNode.column + }; + + const newPosition = await this.getNextVerticalPosition(true, ctrl || key === 'home', key === 'home'); + newActiveNode.row = 0; + newActiveNode.column = newPosition.column; + newActiveNode.layout = newPosition.layout; + } else { + const verticalContainer = this.grid.verticalRowDimScrollContainers.toArray()[newActiveNode.column]; + newActiveNode.row = ctrl ? verticalContainer.igxForOf.length - 1 : 0; + } + + this.isRowDimensionHeaderActive = false; + this.isRowHeaderActive = true; + this.grid.rowDimensionContainer.toArray()[this.grid.hasHorizontalLayout ? 0 : newActiveNode.column].nativeElement.focus(); + } + + this.setActiveNode(newActiveNode); + } else if (key.includes('left') && this.activeNode.column === 0 && this.grid.pivotUI.showRowHeaders) { + this.isRowDimensionHeaderActive = true; + const newActiveNode: IActiveNode = { + row: this.activeNode.row, + column: this.lastRowDimensionsIndex, + level: null, + mchCache: this.activeNode.mchCache, + layout: null + } + + this.setActiveNode(newActiveNode); + } else { + super.headerNavigation(event); + } + } + + public override focusTbody(event) { + if (!this.activeNode || this.activeNode.row === null || this.activeNode.row === undefined) { + this.activeNode = this.lastActiveNode; + } else { + super.focusTbody(event); + } + } + + public async getNextVerticalPosition(previous, ctrl, homeEnd) { + const parentRow = this.grid.rowDimensionMrlRowsCollection.find(row => row.rowIndex === this.activeNode.row); + const maxRowEnd = parentRow.rowGroup.length + 1; + const curValidRowStart = Math.min(parentRow.rowGroup.length, this.activeNode.layout.rowStart); + // Get current cell layout, because the actineNode the rowStart might be different, based on where we come from(might be smaller cell). + const curCellLayout = this.getNextVerticalColumnIndex(parentRow, curValidRowStart, this.activeNode.layout.colStart); + const nextBlock = (previous && curCellLayout.rowStart === 1) || (!previous && curCellLayout.rowEnd === maxRowEnd); + if (nextBlock && + ((previous && this.activeNode.row === 0) || + (!previous && this.activeNode.row === this.lastRowDimensionMRLRowIndex))) { + if (previous && this.grid.pivotUI.showRowHeaders) { + this.isRowDimensionHeaderActive = true; + this.isRowHeaderActive = false; + this.grid.theadRow.nativeElement.focus(); + return { row: -1, column: this.activeNode.layout.colStart - 1, layout: this.activeNode.layout }; + } + return { row: this.activeNode.row, column: this.activeNode.column, layout: this.activeNode.layout }; + } + + const nextMRLRowIndex = previous ? + (ctrl ? 0 : this.activeNode.row - 1) : + (ctrl ? this.lastRowDimensionMRLRowIndex : this.activeNode.row + 1) ; + let nextRow = nextBlock || ctrl ? this.grid.rowDimensionMrlRowsCollection.find(row => row.rowIndex === nextMRLRowIndex) : parentRow; + if (!nextRow) { + const nextDataViewIndex = previous ? + (ctrl ? 0 : parentRow.rowGroup[curCellLayout.rowStart - 1].dataIndex - 1) : + (ctrl ? this.grid.dataView.length - 1 : parentRow.rowGroup[curCellLayout.rowEnd - 2].dataIndex + 1); + await this.scrollToNextHorizontalDimRow(nextDataViewIndex); + nextRow = nextBlock || ctrl ? this.grid.rowDimensionMrlRowsCollection.find(row => row.rowIndex === nextMRLRowIndex) : parentRow; + } + + const nextRowStart = nextBlock ? + (previous ? nextRow.rowGroup.length : 1) : + (previous ? curCellLayout.rowStart - 1 : curCellLayout.rowEnd); + const maxColEnd = Math.max(...nextRow.contentCells.map(cell => cell.layout.colEnd)); + const nextColumnLayout = this.getNextVerticalColumnIndex( + nextRow, + ctrl ? (previous ? 1 : nextRow.rowGroup.length) : nextRowStart, + homeEnd ? (previous ? 1 : maxColEnd - 1) : this.activeNode.layout.colStart + ); + + const nextDataViewIndex = previous ? + nextRow.rowGroup[nextColumnLayout.rowStart - 1].dataIndex: + nextRow.rowGroup[nextColumnLayout.rowEnd - 2].dataIndex; + await this.scrollToNextHorizontalDimRow(nextDataViewIndex); + + return { + row: nextBlock || ctrl ? nextMRLRowIndex : this.activeNode.row, + column: nextColumnLayout.columnVisibleIndex, + layout: { + rowStart: nextColumnLayout.rowStart, + rowEnd: nextColumnLayout.rowEnd, + colStart: homeEnd ? nextColumnLayout.colStart : this.activeNode.layout.colStart, + colEnd: nextColumnLayout.colEnd, + columnVisibleIndex: nextColumnLayout.columnVisibleIndex + } as IMultiRowLayoutNode + }; + } + + public async getNextHorizontalPosition(previous, ctrl) { + const parentRow = this.grid.rowDimensionMrlRowsCollection.find(row => row.rowIndex === this.activeNode.row); + const maxColEnd = Math.max(...parentRow.contentCells.map(cell => cell.layout.colEnd)); + // Get current cell layout, because the actineNode the rowStart might be different, based on where we come from(might be smaller cell). + const curCellLayout = this.getNextVerticalColumnIndex(parentRow, this.activeNode.layout.rowStart, this.activeNode.layout.colStart); + + if ((previous && curCellLayout.colStart === 1) || (!previous && curCellLayout.colEnd === maxColEnd)) { + return { row: this.activeNode.row, column: this.activeNode.column, layout: this.activeNode.layout }; + } + + const nextColStartNormal = curCellLayout.colStart + (previous ? -1 : curCellLayout.colEnd - curCellLayout.colStart); + const nextColumnLayout = this.getNextVerticalColumnIndex( + parentRow, + this.activeNode.layout.rowStart, + ctrl ? (previous ? 1 : maxColEnd - 1) : nextColStartNormal + ); + + const nextDataViewIndex = parentRow.rowGroup[nextColumnLayout.rowStart - 1].dataIndex + await this.scrollToNextHorizontalDimRow(nextDataViewIndex); + + return { + row: this.activeNode.row, + column: nextColumnLayout.columnVisibleIndex, + layout: { + rowStart: this.activeNode.layout.rowStart, + rowEnd: nextColumnLayout.rowEnd, + colStart: nextColumnLayout.colStart, + colEnd: nextColumnLayout.colEnd, + columnVisibleIndex: nextColumnLayout.columnVisibleIndex + } as IMultiRowLayoutNode + }; + } + + private async scrollToNextHorizontalDimRow(nextDataViewIndex: number) { + const verticalContainer = this.grid.verticalScrollContainer; + if (verticalContainer.isIndexOutsideView(nextDataViewIndex)) { + verticalContainer.scrollTo(nextDataViewIndex); + await new Promise((resolve) => { + this.grid.gridScroll.pipe(take(1), timeout({ first: 10000 })).subscribe({ + next: (value) => resolve(value), + error: (err) => resolve(err) + }); + }); + } + } + + + private getNextVerticalColumnIndex(nextRow: IgxPivotRowDimensionMrlRowComponent, newRowStart, newColStart) { + const nextCell = nextRow.contentCells.find(cell => { + return cell.layout.rowStart <= newRowStart && newRowStart < cell.layout.rowEnd && + cell.layout.colStart <= newColStart && newColStart < cell.layout.colEnd; + }); + return nextCell.layout; + } +} diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid-row.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid-row.ts new file mode 100644 index 00000000000..d71208f3547 --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid-row.ts @@ -0,0 +1,71 @@ +import { PivotUtil, RowType } from 'igniteui-angular/grids/core'; +import { IgxPivotGridComponent } from './pivot-grid.component'; + + +export class IgxPivotGridRow implements RowType { + + /** The index of the row within the grid */ + public index: number; + + /** + * The grid that contains the row. + */ + public grid: IgxPivotGridComponent; + private _data?: any; + + constructor(grid: IgxPivotGridComponent, index: number, data?: any) { + this.grid = grid; + this.index = index; + this._data = data && data.addRow && data.recordRef ? data.recordRef : data; + } + + /** + * The data passed to the row component. + */ + public get data(): any { + return this._data ?? this.grid.dataView[this.index]; + } + + /** + * Returns the view index calculated per the grid page. + */ + public get viewIndex(): number { + return this.index + this.grid.page * this.grid.perPage; + } + + /** + * Gets the row key. + * A row in the grid is identified either by: + * - primaryKey data value, + * - the whole rowData, if the primaryKey is omitted. + * + * ```typescript + * let rowKey = row.key; + * ``` + */ + public get key(): any { + const dimension = this.grid.visibleRowDimensions[this.grid.visibleRowDimensions.length - 1]; + const recordKey = PivotUtil.getRecordKey(this.data, dimension); + return recordKey ? recordKey : null; + } + + /** + * Gets whether the row is selected. + * Default value is `false`. + * ```typescript + * row.selected = true; + * ``` + */ + public get selected(): boolean { + return this.grid.selectionService.isRowSelected(this.key); + } + + public set selected(val: boolean) { + if (val) { + this.grid.selectionService.selectRowsWithNoEvent([this.key]); + } else { + this.grid.selectionService.deselectRowsWithNoEvent([this.key]); + } + this.grid.cdr.markForCheck(); + } +} diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.html b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.html new file mode 100644 index 00000000000..558cf1c4721 --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.html @@ -0,0 +1,229 @@ + + + + + + + +
    + +
    + @if (hasMovableColumns && columnInDrag && pinnedColumns.length <= 0) { + + } + @if (hasMovableColumns && columnInDrag && pinnedColumns.length > 0) { + + } + + + + + + + + + + + +
    +
    + @if (shouldOverlayLoading) { + + + } +
    + @if (hasMovableColumns && columnInDrag) { + + } +
    +
    +
    + +
    +
    +
    + +
    + {{resourceStrings.igx_grid_snackbar_addrow_label}} +
    + +
    +
    + +
    +
    +
    + + +
    +
    +
    + +
    +
    + + + + + + {{emptyFilteredGridMessage}} + + + + + + {{emptyGridMessage}} + + + + +
    + + +
    +
    +@if (colResizingService.showResizer) { + +} +
    +
    + + +
    + + + {{column.header}} +
    +
    + + + @for (dim of rowDimensions; track dim.memberName; let dimIndex = $index) { +
    + + + + + +
    + } +
    + + +
    + @if (dataView | pivotGridHorizontalRowGrouping:pivotConfiguration:pipeTrigger:regroupTrigger; as groupedData) { + + + + } + +
    +
    + + +
    + @if ((columnDimensions.length > 0 || values.length > 0) && data.length > 0) { + + + } + +
    +
    + + + + {{resourceStrings.igx_grid_pivot_empty_message}} + + + + + @if (emptyBottomSize > 0) { +
    + +
    +
    +
    +
    + } +
    + +
    + +
    + + + +
    +
    + +@if (platform.isElements) { +
    + +} diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.ts new file mode 100644 index 00000000000..beea46424aa --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.component.ts @@ -0,0 +1,2488 @@ +import { AfterContentInit, AfterViewInit, ChangeDetectionStrategy, Component, EventEmitter, ElementRef, HostBinding, Input, OnInit, Output, QueryList, TemplateRef, ViewChild, ViewChildren, ContentChild, createComponent, CUSTOM_ELEMENTS_SCHEMA, booleanAttribute, OnChanges, SimpleChanges, inject } from '@angular/core'; +import { NgTemplateOutlet, NgClass, NgStyle } from '@angular/common'; + +import { first, take, takeUntil } from 'rxjs/operators'; +import { DEFAULT_PIVOT_KEYS, IDimensionsChange, IgxFilteringService, IgxGridNavigationService, IgxGridValidationService, IgxPivotDateDimension, IgxPivotGridValueTemplateContext, IPivotConfiguration, IPivotConfigurationChangedEventArgs, IPivotDimension, IPivotUISettings, IPivotValue, IValuesChange, PivotDimensionType, PivotRowLayoutType, PivotSummaryPosition, PivotUtil } from 'igniteui-angular/grids/core'; +import { IgxGridSelectionService } from 'igniteui-angular/grids/core'; +import { GridType, IGX_GRID_BASE, IGX_GRID_SERVICE_BASE, IgxColumnTemplateContext, PivotGridType, RowType } from 'igniteui-angular/grids/core'; +import { IgxGridCRUDService } from 'igniteui-angular/grids/core'; +import { IgxGridSummaryService } from 'igniteui-angular/grids/core'; +import { IgxPivotHeaderRowComponent } from './pivot-header-row.component'; +import { IgxColumnGroupComponent } from 'igniteui-angular/grids/core'; +import { IgxColumnComponent } from 'igniteui-angular/grids/core'; +import { FilterMode, GridPagingMode, GridSummaryPosition } from 'igniteui-angular/grids/core'; +import { WatchChanges } from 'igniteui-angular/grids/core'; +import { cloneArray, ColumnType, DataUtil, DefaultDataCloneStrategy, GridColumnDataType, GridSummaryCalculationMode, IDataCloneStrategy, IFilteringExpressionsTree, IFilteringOperation, IFilteringStrategy, ISortingExpression, OverlaySettings, resizeObservable, ɵSize, SortingDirection, IgxOverlayOutletDirective } from 'igniteui-angular/core'; +import { + IGridEditEventArgs, + ICellPosition, + IColumnMovingEndEventArgs, IColumnMovingEventArgs, IColumnMovingStartEventArgs, + IColumnVisibilityChangedEventArgs, + IGridEditDoneEventArgs, + IGridToolbarExportEventArgs, + IPinColumnCancellableEventArgs, + IPinColumnEventArgs, + IPinRowEventArgs, + IRowDataCancelableEventArgs, + IRowDataEventArgs, + IRowDragEndEventArgs, + IRowDragStartEventArgs +} from 'igniteui-angular/grids/core'; +import { DropPosition } from 'igniteui-angular/grids/core'; +import { DimensionValuesFilteringStrategy, NoopPivotDimensionsStrategy } from 'igniteui-angular/grids/core'; +import { IgxGridExcelStyleFilteringComponent, IgxExcelStyleColumnOperationsTemplateDirective, IgxExcelStyleFilterOperationsTemplateDirective } from 'igniteui-angular/grids/core'; +import { IgxPivotGridNavigationService } from './pivot-grid-navigation.service'; +import { IgxPivotColumnResizingService } from 'igniteui-angular/grids/core'; +import { State, Transaction, TransactionService } from 'igniteui-angular/core'; +import { IgxPivotFilteringService } from './pivot-filtering.service'; +import { GridBaseAPIService } from 'igniteui-angular/grids/core'; +import { IgxPivotRowDimensionContentComponent } from './pivot-row-dimension-content.component'; +import { IgxPivotGridColumnResizerComponent } from 'igniteui-angular/grids/core'; +import { PivotSortUtil } from './pivot-sort-util'; +import { IgxPivotRowDimensionHeaderTemplateDirective, IgxPivotValueChipTemplateDirective } from './pivot-grid.directives'; +import { IgxPivotRowPipe, IgxPivotRowExpansionPipe, IgxPivotAutoTransform, IgxPivotColumnPipe, IgxPivotGridFilterPipe, IgxPivotGridSortingPipe, IgxPivotGridColumnSortingPipe, IgxPivotCellMergingPipe, IgxPivotGridHorizontalRowGrouping } from './pivot-grid.pipes'; +import { IgxGridRowClassesPipe, IgxGridRowStylesPipe } from 'igniteui-angular/grids/core'; +import { IgxExcelStyleSearchComponent } from 'igniteui-angular/grids/core'; +import { IgxPivotRowComponent } from './pivot-row.component'; +import { IgxColumnMovingDropDirective } from 'igniteui-angular/grids/core'; +import { IgxGridDragSelectDirective } from 'igniteui-angular/grids/core'; +import { IgxGridBodyDirective } from 'igniteui-angular/grids/core'; +import { IgxColumnResizingService } from 'igniteui-angular/grids/core'; +import { IgxPivotRowHeaderGroupComponent } from './pivot-row-header-group.component'; +import { IgxPivotRowDimensionMrlRowComponent } from './pivot-row-dimension-mrl-row.component'; +import { IForOfDataChangingEventArgs, IgxForOfScrollSyncService, IgxForOfSyncService, IgxGridForOfDirective, IgxTemplateOutletDirective, IgxToggleDirective } from 'igniteui-angular/directives'; +import { IgxCircularProgressBarComponent } from 'igniteui-angular/progressbar'; +import { IgxSnackbarComponent } from 'igniteui-angular/snackbar'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxPivotGridRow } from './pivot-grid-row'; +import { IgxGridBaseDirective, IgxGridRowComponent } from 'igniteui-angular/grids/grid'; + +let NEXT_ID = 0; +const MINIMUM_COLUMN_WIDTH = 200; +const MINIMUM_COLUMN_WIDTH_SUPER_COMPACT = 104; + +/* blazorAdditionalDependency: Column */ +/* blazorAdditionalDependency: ColumnGroup */ +/* blazorAdditionalDependency: ColumnLayout */ +/* blazorAdditionalDependency: GridToolbar */ +/* blazorAdditionalDependency: GridToolbarActions */ +/* blazorAdditionalDependency: GridToolbarTitle */ +/* blazorAdditionalDependency: GridToolbarAdvancedFiltering */ +/* blazorAdditionalDependency: GridToolbarExporter */ +/* blazorAdditionalDependency: GridToolbarHiding */ +/* blazorAdditionalDependency: GridToolbarPinning */ +/* blazorAdditionalDependency: ActionStrip */ +/* blazorAdditionalDependency: GridActionsBaseDirective */ +/* blazorAdditionalDependency: GridEditingActions */ +/* blazorAdditionalDependency: GridPinningActions */ +/* blazorAdditionalDependency: PivotDateDimension */ +/* blazorIndirectRender */ +/** + * Pivot Grid provides a way to present and manipulate data in a pivot table view. + * + * @igxModule IgxPivotGridModule + * @igxGroup Grids & Lists + * @igxKeywords pivot, grid, table + * @igxTheme igx-grid-theme + * @remarks + * The Ignite UI Pivot Grid is used for grouping and aggregating simple flat data into a pivot table. Once data + * has been bound and the dimensions and values configured it can be manipulated via sorting and filtering. + * @example + * ```html + * + * + * ``` + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + preserveWhitespaces: false, + selector: 'igx-pivot-grid', + templateUrl: 'pivot-grid.component.html', + providers: [ + IgxGridCRUDService, + IgxGridValidationService, + IgxGridSummaryService, + IgxGridSelectionService, + IgxColumnResizingService, + GridBaseAPIService, + { provide: IGX_GRID_SERVICE_BASE, useClass: GridBaseAPIService }, + { provide: IGX_GRID_BASE, useExisting: IgxPivotGridComponent }, + { provide: IgxFilteringService, useClass: IgxPivotFilteringService }, + IgxGridNavigationService, + IgxPivotGridNavigationService, + IgxPivotColumnResizingService, + IgxForOfSyncService, + IgxForOfScrollSyncService + ], + imports: [ + NgClass, + NgStyle, + NgTemplateOutlet, + IgxPivotHeaderRowComponent, + IgxGridBodyDirective, + IgxGridDragSelectDirective, + IgxColumnMovingDropDirective, + IgxGridForOfDirective, + IgxTemplateOutletDirective, + IgxPivotRowComponent, + IgxToggleDirective, + IgxCircularProgressBarComponent, + IgxSnackbarComponent, + IgxOverlayOutletDirective, + IgxPivotGridColumnResizerComponent, + IgxIconComponent, + IgxPivotRowDimensionContentComponent, + IgxGridExcelStyleFilteringComponent, + IgxExcelStyleColumnOperationsTemplateDirective, + IgxExcelStyleFilterOperationsTemplateDirective, + IgxExcelStyleSearchComponent, + IgxGridRowClassesPipe, + IgxGridRowStylesPipe, + IgxPivotRowPipe, + IgxPivotRowExpansionPipe, + IgxPivotAutoTransform, + IgxPivotColumnPipe, + IgxPivotGridFilterPipe, + IgxPivotGridSortingPipe, + IgxPivotGridColumnSortingPipe, + IgxPivotCellMergingPipe, + IgxPivotGridHorizontalRowGrouping, + IgxPivotRowDimensionMrlRowComponent + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] +}) +export class IgxPivotGridComponent extends IgxGridBaseDirective implements OnInit, AfterContentInit, + PivotGridType, AfterViewInit, OnChanges { + public override readonly gridAPI = inject>(GridBaseAPIService); + public override navigation = inject(IgxPivotGridNavigationService); + protected override colResizingService = inject(IgxPivotColumnResizingService); + + /** + * Emitted when the dimension collection is changed via the grid chip area. + * + * @remarks + * Returns the new dimension collection and its type: + * @example + * ```html + * + * ``` + */ + @Output() + public dimensionsChange = new EventEmitter(); + + /** + * Emitted when any of the pivotConfiguration properties is changed via the grid chip area. + * + * @example + * ```html + * + * ``` + */ + @Output() + public pivotConfigurationChange = new EventEmitter(); + + + /** + * Emitted when the dimension is initialized. + * @remarks + * Emits the dimension that is about to be initialized. + * @example + * ```html + * + * ``` + */ + @Output() + public dimensionInit = new EventEmitter(); + + /** + * Emitted when the value is initialized. + * @remarks + * Emits the value that is about to be initialized. + * @example + * ```html + * + * ``` + */ + @Output() + public valueInit = new EventEmitter(); + + + /** + * Emitted when a dimension is sorted. + * + * @example + * ```html + * + * ``` + */ + @Output() + public dimensionsSortingExpressionsChange = new EventEmitter(); + + /** + * Emitted when the values collection is changed via the grid chip area. + * + * @remarks + * Returns the new dimension + * @example + * ```html + * + * ``` + */ + @Output() + public valuesChange = new EventEmitter(); + + + /** + * Gets the sorting expressions generated for the dimensions. + * + * @example + * ```typescript + * const expressions = this.grid.dimensionsSortingExpressions; + * ``` + */ + public get dimensionsSortingExpressions() { + const allEnabledDimensions = this.rowDimensions.concat(this.columnDimensions); + const dimensionsSortingExpressions = PivotSortUtil.generateDimensionSortingExpressions(allEnabledDimensions); + return dimensionsSortingExpressions; + } + + /** @hidden @internal */ + @ViewChild(IgxPivotHeaderRowComponent, { static: true }) + public override theadRow: IgxPivotHeaderRowComponent; + + /** + * @hidden @internal + */ + @ContentChild(IgxPivotValueChipTemplateDirective, { read: IgxPivotValueChipTemplateDirective }) + protected valueChipTemplateDirective: IgxPivotValueChipTemplateDirective; + + /** + * @hidden @internal + */ + @ContentChild(IgxPivotRowDimensionHeaderTemplateDirective, { read: IgxPivotRowDimensionHeaderTemplateDirective }) + protected rowDimensionHeaderDirective: IgxPivotRowDimensionHeaderTemplateDirective; + + /** + * Gets/Sets a custom template for the value chips. + * + * @example + * ```html + * + * ``` + */ + @Input() + public valueChipTemplate: TemplateRef; + + @Input() + public rowDimensionHeaderTemplate: TemplateRef; + + /* mustSetInCodePlatforms: WebComponents;Blazor;React */ + /* @tsTwoWayProperty (true, "PivotConfigurationChange", "Detail.PivotConfiguration", false) */ + /** + * Gets/Sets the pivot configuration with all related dimensions and values. + * + * @example + * ```html + * + * ``` + */ + @Input() + public set pivotConfiguration(value: IPivotConfiguration) { + this._pivotConfiguration = value; + this.emitInitEvents(this._pivotConfiguration); + this.filteringExpressionsTree = PivotUtil.buildExpressionTree(value); + if (!this._init) { + this.setupColumns(); + } + this.notifyChanges(true); + } + + /* mustSetInCodePlatforms: WebComponents;Blazor */ + public get pivotConfiguration() { + return this._pivotConfiguration || { rows: null, columns: null, values: null, filters: null }; + } + + /** + * Gets/Sets whether to auto-generate the pivot configuration based on the provided data. + * + * @remarks + * The default value is false. When set to true, it will override all dimensions and values in the pivotConfiguration. + * @example + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public autoGenerateConfig = false; + + @Input() + /** + * Gets/Sets the pivot ui settings for the pivot grid - chips and their + * corresponding containers for row, filter, column dimensions and values + * as well as headers for the row dimensions values. + * @example + * ```html + * + * ``` + */ + public set pivotUI(value: IPivotUISettings) { + this._pivotUI = Object.assign(this._pivotUI, value || {}); + this.pipeTrigger++; + this.notifyChanges(true); + } + + public get pivotUI() { + return this._pivotUI; + } + + /** + * @hidden @internal + */ + @HostBinding('attr.role') + public role = 'grid'; + + /** + * Enables a super compact theme for the component. + * @remarks + * Overrides the grid size option if one is set. + * @example + * ```html + * + * ``` + */ + @HostBinding('class.igx-grid__pivot--super-compact') + @Input() + public get superCompactMode() { + return this._superCompactMode; + } + + public set superCompactMode(value) { + this._superCompactMode = value; + } + + /** @hidden @internal */ + public override get gridSize() { + if (this.superCompactMode) { + return ɵSize.Small; + } + return super.gridSize; + } + + + /** + * Gets/Sets the values clone strategy of the pivot grid when assigning them to different dimensions. + * + * @example + * ```html + * + * ``` + * @hidden @internal + */ + @Input() + public get pivotValueCloneStrategy(): IDataCloneStrategy { + return this._pivotValueCloneStrategy; + } + + public set pivotValueCloneStrategy(strategy: IDataCloneStrategy) { + if (strategy) { + this._pivotValueCloneStrategy = strategy; + } + } + + /** + * @hidden @internal + */ + @ViewChild('record_template', { read: TemplateRef, static: true }) + public recordTemplate: TemplateRef; + + /** + * @hidden @internal + */ + @ViewChild(IgxPivotRowDimensionMrlRowComponent, { read: IgxPivotRowDimensionMrlRowComponent }) + public rowDimensionMrlComponent: IgxPivotRowDimensionMrlRowComponent; + + /** + * @hidden @internal + */ + @ViewChild('headerTemplate', { read: TemplateRef, static: true }) + public headerTemplate: TemplateRef; + + /** + * @hidden @internal + */ + @ViewChildren('rowDimensionContainer', { read: ElementRef }) + public rowDimensionContainer: QueryList>; + + /** + * @hidden @internal + */ + @ViewChild(IgxPivotGridColumnResizerComponent) + public override resizeLine: IgxPivotGridColumnResizerComponent; + + /** + * @hidden @internal + */ + @ViewChildren(IgxGridExcelStyleFilteringComponent, { read: IgxGridExcelStyleFilteringComponent }) + public override excelStyleFilteringComponents: QueryList; + + /** + * @hidden @internal + */ + @ViewChildren(IgxPivotRowDimensionContentComponent) + protected rowDimensionContentCollection: QueryList; + + /** + * @hidden @internal + */ + public override get minColumnWidth() { + if (this.superCompactMode) { + return MINIMUM_COLUMN_WIDTH_SUPER_COMPACT; + } else { + return MINIMUM_COLUMN_WIDTH; + } + } + + /** + * @hidden @internal + */ + @ViewChildren('verticalRowDimScrollContainer', { read: IgxGridForOfDirective }) + public verticalRowDimScrollContainers: QueryList>; + + /** + * @hidden @internal + */ + @ViewChildren(IgxPivotRowDimensionMrlRowComponent) + public rowDimensionMrlRowsCollection: QueryList; + + /** + * @hidden @internal + */ + @Input() + public override addRowEmptyTemplate: TemplateRef; + + /** + * @hidden @internal + */ + @Input() + public override autoGenerateExclude: string[] = []; + + /** + * @hidden @internal + */ + @Input() + public override snackbarDisplayTime = 6000; + + /** + * @hidden @internal + */ + @Output() + public override cellEdit = new EventEmitter(); + + /** + * @hidden @internal + */ + @Output() + public override cellEditDone = new EventEmitter(); + + /** + * @hidden @internal + */ + @Output() + public override cellEditEnter = new EventEmitter(); + + /** + * @hidden @internal + */ + @Output() + public override cellEditExit = new EventEmitter(); + + /** + * @hidden @internal + */ + @Output() + public override columnMovingStart = new EventEmitter(); + + /** + * @hidden @internal + */ + @Output() + public override columnMoving = new EventEmitter(); + + /** + * @hidden @internal + */ + @Output() + public override columnMovingEnd = new EventEmitter(); + + /** + * @hidden @internal + */ + @Output() + public override columnPin = new EventEmitter(); + + /** + * @hidden @internal + */ + @Output() + public override columnPinned = new EventEmitter(); + + /** + * @hidden @internal + */ + @Output() + public override rowAdd = new EventEmitter(); + + /** + * @hidden @internal + */ + @Output() + public override rowAdded = new EventEmitter(); + + /** + * @hidden @internal + */ + @Output() + public override rowDeleted = new EventEmitter(); + + /** + * @hidden @internal + */ + @Output() + public override rowDelete = new EventEmitter(); + + /** + * @hidden @internal + */ + @Output() + public override rowDragStart = new EventEmitter(); + + /** + * @hidden @internal + */ + @Output() + public override rowDragEnd = new EventEmitter(); + + /** + * @hidden @internal + */ + @Output() + public override rowEditEnter = new EventEmitter(); + + /** + * @hidden @internal + */ + @Output() + public override rowEdit = new EventEmitter(); + + /** + * @hidden @internal + */ + @Output() + public override rowEditDone = new EventEmitter(); + + /** + * @hidden @internal + */ + @Output() + public override rowEditExit = new EventEmitter(); + + /** + * @hidden @internal + */ + @Output() + public override rowPinning = new EventEmitter(); + + /** + * @hidden @internal + */ + @Output() + public override rowPinned = new EventEmitter(); + + /** @hidden @internal */ + public columnGroupStates = new Map(); + /** @hidden @internal */ + public dimensionDataColumns: any[]; + /** @hidden @internal */ + public get pivotKeys() { + return this.pivotConfiguration.pivotKeys || DEFAULT_PIVOT_KEYS; + } + /** @hidden @internal */ + public override get type(): GridType["type"] { + return 'pivot'; + } + + /** + * @hidden @internal + */ + public override dragRowID = null; + + /** + * @hidden @internal + */ + public override get rootSummariesEnabled(): boolean { + return false; + } + + /** + * @hidden @internal + */ + public rowDimensionResizing = true; + + private _emptyRowDimension: IPivotDimension = { memberName: '', enabled: true, level: 0 }; + /** + * @hidden @internal + */ + public get emptyRowDimension(): IPivotDimension { + return this._emptyRowDimension; + } + + protected _pivotValueCloneStrategy: IDataCloneStrategy = new DefaultDataCloneStrategy(); + protected override _defaultExpandState = false; + protected override _filterStrategy: IFilteringStrategy = new DimensionValuesFilteringStrategy(); + protected regroupTrigger = 0; + private _data; + private _pivotConfiguration: IPivotConfiguration = { rows: null, columns: null, values: null, filters: null }; + private p_id = `igx-pivot-grid-${NEXT_ID++}`; + private _superCompactMode = false; + private _pivotUI: IPivotUISettings = { + showConfiguration: true, + showRowHeaders: false, + rowLayout: PivotRowLayoutType.Vertical, + horizontalSummariesPosition: PivotSummaryPosition.Bottom + }; + private _sortableColumns = true; + private _visibleRowDimensions: IPivotDimension[] = []; + private _shouldUpdateSizes = false; + + /** + * Gets/Sets the default expand state for all rows. + */ + @Input({ transform: booleanAttribute }) + public get defaultExpandState() { + return this._defaultExpandState; + } + + public set defaultExpandState(val: boolean) { + this._defaultExpandState = val; + } + + /** + * @hidden @internal + */ + @Input() + public override get pagingMode(): GridPagingMode { + return 'local'; + } + + public override set pagingMode(_val: GridPagingMode) { + } + + /** + * @hidden @internal + */ + @WatchChanges() + @Input({ transform: booleanAttribute }) + public override get hideRowSelectors(): boolean { + return false; + } + + public override set hideRowSelectors(_value: boolean) { + } + + /** + * @hidden @internal + */ + public override autoGenerate = true; + + /** + * @hidden @internal + */ + public override get actionStrip() { + return undefined as any; + } + + /** + * @hidden @internal + * @deprecated in version 18.2.0. This property is no longer supported. + */ + public override get shouldGenerate(): boolean { + return false; + } + + public override set shouldGenerate(value: boolean) { + } + + /** + * @hidden @internal + */ + public override moving = false; + + /** + * @hidden @internal + */ + public override toolbarExporting = new EventEmitter(); + + /** + * @hidden @internal + */ + @Input({ transform: booleanAttribute }) + public override get rowDraggable(): boolean { + return false; + } + + + public override set rowDraggable(_val: boolean) { + } + + /** + * @hidden @internal + */ + @Input({ transform: booleanAttribute }) + public override get allowAdvancedFiltering() { + return false; + } + + public override set allowAdvancedFiltering(_value) { + } + + /** + * @hidden @internal + */ + @Input() + public override get filterMode() { + return FilterMode.quickFilter; + } + + public override set filterMode(_value: FilterMode) { + } + + /** + * @hidden @internal + */ + @Input({ transform: booleanAttribute }) + public override get allowFiltering() { + return false; + } + + public override set allowFiltering(_value) { + } + + /** + * @hidden @internal + */ + @Input() + public override get page(): number { + return 0; + } + + public override set page(_val: number) { + } + + /** + * @hidden @internal + */ + @Input() + public override get perPage(): number { + return 0; + } + + public override set perPage(_val: number) { + } + + /** + * @hidden @internal + */ + public override get pinnedColumns(): IgxColumnComponent[] { + return []; + } + + /** + * @hidden @internal + */ + public override get unpinnedColumns(): IgxColumnComponent[] { + return super.unpinnedColumns; + } + + /** + * @hidden @internal + */ + public override get unpinnedDataView(): any[] { + return super.unpinnedDataView; + } + + /** + * @hidden @internal + */ + public override get unpinnedWidth() { + return super.unpinnedWidth; + } + + /** + * @hidden @internal + */ + public override get pinnedStartWidth() { + return super.pinnedStartWidth; + } + + /** + * @hidden @internal + */ + @Input() + public override set summaryRowHeight(_value: number) { + } + + public override get summaryRowHeight(): number { + return 0; + } + + /** + * @hidden @internal + */ + public override get transactions(): TransactionService { + return this._transactions; + } + + + + /** + * @hidden @internal + */ + public override get dragIndicatorIconTemplate(): TemplateRef { + return; + } + + public override set dragIndicatorIconTemplate(_val: TemplateRef) { + } + + /** + * @hidden @internal + */ + @WatchChanges() + @Input({ transform: booleanAttribute }) + public override get rowEditable(): boolean { + return; + } + + public override set rowEditable(_val: boolean) { + } + + /** + * @hidden @internal + */ + @Input() + public override get pinning() { + return {}; + } + public override set pinning(_value) { + } + + /** + * @hidden @internal + */ + @Input() + public override get summaryPosition() { + return; + } + + public override set summaryPosition(_value: GridSummaryPosition) { + } + + /** + * @hidden @internal + */ + @Input() + public override get summaryCalculationMode() { + return; + } + + public override set summaryCalculationMode(_value: GridSummaryCalculationMode) { + } + + /** + * @hidden @internal + */ + @Input({ transform: booleanAttribute }) + public override get showSummaryOnCollapse() { + return; + } + + public override set showSummaryOnCollapse(_value: boolean) { + } + + /** + * @hidden @internal + */ + public override get hiddenColumnsCount(): number { + return 0; + } + + /** + * @hidden @internal + */ + public override get pinnedColumnsCount(): number { + return 0; + } + + /** + * @hidden @internal + */ + @Input({ transform: booleanAttribute }) + public override get batchEditing(): boolean { + return false; + } + + public override set batchEditing(_val: boolean) { + } + + /* csSuppress */ + public override get selectedRows(): any[] { + if (this.selectionService.getSelectedRows().length === 0) { + return []; + } + const selectedRowIds = []; + this.dataView.forEach(record => { + const prev = []; + for (const dim of this.rowDimensions) { + let currDim = dim; + let shouldBreak = false; + do { + const key = PivotUtil.getRecordKey(record, currDim); + if (this.selectionService.isPivotRowSelected(key) && !selectedRowIds.find(x => x === record)) { + selectedRowIds.push(record); + shouldBreak = true; + break; + } + currDim = currDim.childLevel; + } while (currDim); + prev.push(dim); + if (shouldBreak) { + break; + } + } + + }); + + return selectedRowIds; + } + + /** + * @hidden + */ + public override ngOnInit() { + // pivot grid always generates columns automatically. + this.autoGenerate = true; + super.ngOnInit(); + } + + /** + * @hidden + */ + public override ngAfterContentInit() { + // ignore any user defined columns and auto-generate based on pivot config. + this.updateColumns([]); + Promise.resolve().then(() => { + if (this.autoGenerateConfig) { + this.generateConfig(); + } + this.setupColumns(); + }); + if (this.valueChipTemplateDirective) { + this.valueChipTemplate = this.valueChipTemplateDirective.template; + } + if (this.rowDimensionHeaderDirective) { + this.rowDimensionHeaderTemplate = this.rowDimensionHeaderDirective.template; + } + } + + /** + * @hidden @internal + */ + public override ngAfterViewInit() { + Promise.resolve().then(() => { + super.ngAfterViewInit(); + }); + } + + /** + * @hidden @internal + */ + public ngOnChanges(changes: SimpleChanges) { + if (changes.superCompactMode && !changes.superCompactMode.isFirstChange()) { + this._shouldUpdateSizes = true; + resizeObservable(this.verticalScrollContainer.displayContainer).pipe(take(1), takeUntil(this.destroy$)).subscribe(() => this.resizeNotify.next()); + } + } + + /** + * Notifies for dimension change. + */ + public notifyDimensionChange(regenerateColumns = false) { + if (regenerateColumns) { + this.setupColumns(); + } + this.pipeTrigger++; + this.cdr.detectChanges(); + } + + /** + * Gets the full list of dimensions. + * + * @example + * ```typescript + * const dimensions = this.grid.allDimensions; + * ``` + */ + public get allDimensions() { + const config = this._pivotConfiguration; + if (!config) return []; + return (config.rows || []).concat((config.columns || [])).concat(config.filters || []).filter(x => x !== null && x !== undefined); + } + + protected get allVisibleDimensions() { + const config = this._pivotConfiguration; + if (!config) return []; + const uniqueVisibleRowDims = this.visibleRowDimensions.filter(dim => !config.rows.find(configRow => configRow.memberName === dim.memberName)); + const rows = (config.rows || []).concat(...uniqueVisibleRowDims); + return rows.concat((config.columns || [])).concat(config.filters || []).filter(x => x !== null && x !== undefined); + } + + protected override get shouldResize(): boolean { + if (!this.dataRowList.first?.cells || this.dataRowList.first.cells.length === 0) { + return false; + } + const isSizePropChanged = super.shouldResize; + if (isSizePropChanged || this._shouldUpdateSizes) { + this._shouldUpdateSizes = false; + return true; + } + return false; + } + + protected get emptyBottomSize() { + return this.totalHeight - (this.verticalScroll).scrollComponent.size; + } + + /** @hidden @internal */ + public createFilterESF(dropdown: any, column: ColumnType, options: OverlaySettings, shouldReatach: boolean) { + options.outlet = this.outlet; + if (dropdown) { + dropdown.initialize(column, this.overlayService); + dropdown.populateData(); + if (shouldReatach) { + const id = this.overlayService.attach(dropdown.element, options); + dropdown.overlayComponentId = id; + return { id, ref: undefined }; + } + return { id: dropdown.overlayComponentId, ref: undefined }; + } + } + + /** @hidden */ + public override featureColumnsWidth() { + return this.pivotRowWidths || 0; + } + + /* blazorSuppress */ + /** + * Gets/Sets the value of the `id` attribute. + * + * @remarks + * If not provided it will be automatically generated. + * @example + * ```html + * + * ``` + */ + @HostBinding('attr.id') + @Input() + public get id(): string { + return this.p_id; + } + /* blazorSuppress */ + public set id(value: string) { + this.p_id = value; + } + + /* treatAsRef */ + /* blazorAlternateType: object */ + /** + * Gets/Sets the array of data that populates the component. + * ```html + * + * ``` + */ + @Input() + public set data(value: any[] | null) { + this._data = value || []; + if (!this._init) { + if (this.autoGenerateConfig) { + this.generateConfig(); + } + this.setupColumns(); + this.reflow(); + } + this.cdr.markForCheck(); + if (this.height === null || this.height.indexOf('%') !== -1) { + // If the height will change based on how much data there is, recalculate sizes in igxForOf. + this.notifyChanges(true); + } + } + + /* treatAsRef */ + /* blazorAlternateType: object */ + /** + * Returns an array of data set to the component. + * ```typescript + * let data = this.grid.data; + * ``` + */ + public get data(): any[] | null { + return this._data; + } + + /** + * @hidden + */ + public getContext(rowData, rowIndex): any { + return { + $implicit: rowData, + templateID: { + type: 'dataRow', + id: null + }, + index: this.getDataViewIndex(rowIndex, false) + }; + } + + /** + * @hidden @internal + */ + public get pivotRowWidths() { + return this.visibleRowDimensions.length ? this.visibleRowDimensions.reduce((accumulator, dim) => accumulator + this.rowDimensionWidthToPixels(dim), 0) : + this.rowDimensionWidthToPixels(this.emptyRowDimension); + } + + /** + * @hidden @internal + */ + public rowDimensionWidth(dim): string { + const isAuto = dim.width && dim.width.indexOf('auto') !== -1; + if (isAuto) { + return dim.autoWidth ? dim.autoWidth + 'px' : 'fit-content'; + } else { + return this.rowDimensionWidthToPixels(dim) + 'px'; + } + } + + /** + * @hidden @internal + */ + public rowDimensionWidthToPixels(dim: IPivotDimension): number { + if (!dim?.width) { + return MINIMUM_COLUMN_WIDTH; + } + const isPercent = dim.width && dim.width.indexOf('%') !== -1; + const isAuto = dim.width && dim.width.indexOf('auto') !== -1; + if (isPercent) { + return Math.round(parseFloat(dim.width) / 100 * this.calcWidth); + } else if (isAuto) { + return dim.autoWidth; + } else { + return parseInt(dim.width, 10); + } + } + + /** + * @hidden @internal + */ + public reverseDimensionWidthToPercent(width: number): number { + return (width * 100 / this.calcWidth); + } + + /** @hidden @internal */ + public get pivotContentCalcWidth() { + if (!this.platform.isBrowser) { + return undefined; + } + if (!this.visibleRowDimensions.length) { + return Math.max(0, this.calcWidth - this.pivotRowWidths); + } + + const totalDimWidth = this.visibleRowDimensions.length > 0 ? + this.visibleRowDimensions.map((dim) => this.rowDimensionWidthToPixels(dim)).reduce((prev, cur) => prev + cur) : + 0; + return this.calcWidth - totalDimWidth; + } + + /** @hidden @internal */ + public get pivotPinnedStartWidth() { + return !this._init ? this.pinnedStartWidth : 0; + } + + /** @hidden @internal */ + public get pivotPinnedEndWidth() { + return !this._init ? this.pinnedEndWidth : 0; + } + + /** @hidden @internal */ + public get pivotUnpinnedWidth() { + return this.unpinnedWidth || 0; + } + + /** @hidden @internal */ + public get rowDimensions() { + return this.pivotConfiguration.rows?.filter(x => x.enabled) || []; + } + + /** @hidden @internal */ + public set visibleRowDimensions(value: IPivotDimension[]) { + this._visibleRowDimensions = value; + } + + public get visibleRowDimensions() { + return this._visibleRowDimensions || this.rowDimensions; + } + + /** @hidden @internal */ + public get columnDimensions() { + return this.pivotConfiguration.columns?.filter(x => x.enabled) || []; + } + + /** @hidden @internal */ + public get filterDimensions() { + return this.pivotConfiguration.filters?.filter(x => x.enabled) || []; + } + + /** @hidden @internal */ + public get values() { + return this.pivotConfiguration.values?.filter(x => x.enabled) || []; + } + + public toggleColumn(col: IgxColumnComponent) { + const state = this.columnGroupStates.get(col.field); + const newState = !state; + this.columnGroupStates.set(col.field, newState); + this.toggleRowGroup(col, newState); + this.reflow(); + } + + /** + * @hidden @internal + */ + public override isRecordPinnedByIndex(_rowIndex: number) { + return false; + } + + /** + * @hidden @internal + */ + public override toggleColumnVisibility(_args: IColumnVisibilityChangedEventArgs) { + return; + } + + /** + * @hidden @internal + */ + public override expandAll() { + } + + /** + * @hidden @internal + */ + public override collapseAll() { + } + + /** + * @hidden @internal + */ + public override expandRow(_rowID: any) { + } + + /** + * @hidden @internal + */ + public override collapseRow(_rowID: any) { + } + + /** + * @hidden @internal + */ + public override get pinnedRows(): IgxGridRowComponent[] { + return; + } + + /** + * @hidden @internal + */ + @Input() + public override get totalRecords(): number { + return; + } + + public override set totalRecords(_total: number) { + } + + /** + * @hidden @internal + */ + public override moveColumn(_column: IgxColumnComponent, _target: IgxColumnComponent, _pos: DropPosition = DropPosition.AfterDropTarget) { + } + + /** + * @hidden @internal + */ + public override addRow(_data: any): void { + } + + /** + * @hidden @internal + */ + public override deleteRow(_rowSelector: any): any { + } + + /** + * @hidden @internal + */ + public override updateCell(_value: any, _rowSelector: any, _column: string): void { + } + + /** + * @hidden @internal + */ + public override updateRow(_value: any, _rowSelector: any): void { + } + + /** + * @hidden @internal + */ + public override enableSummaries(..._rest) { + } + + /** + * @hidden @internal + */ + public override disableSummaries(..._rest) { + } + + /** + * @hidden @internal + */ + public override pinColumn(_columnName: string | IgxColumnComponent, _index?): boolean { + return; + } + + /** + * @hidden @internal + */ + public override unpinColumn(_columnName: string | IgxColumnComponent, _index?): boolean { + return; + } + + /** + * @hidden @internal + */ + public override pinRow(_rowID: any, _index?: number, _row?: RowType): boolean { + return; + } + + /** + * @hidden @internal + */ + public override unpinRow(_rowID: any, _row?: RowType): boolean { + return; + } + + /** + * @hidden @internal + */ + public override get pinnedRowHeight() { + return; + } + + /** + * @hidden @internal + */ + public override get hasEditableColumns(): boolean { + return; + } + + /** + * @hidden @internal + */ + public override get hasSummarizedColumns(): boolean { + return; + } + + /** + * @hidden @internal + */ + public override get hasMovableColumns(): boolean { + return; + } + + /** + * @hidden @internal + */ + public override get pinnedDataView(): any[] { + return []; + } + + /** + * @hidden @internal + */ + public override openAdvancedFilteringDialog(_overlaySettings?: OverlaySettings) { + } + + /** + * @hidden @internal + */ + public override closeAdvancedFilteringDialog(_applyChanges: boolean) { + } + + /** + * @hidden @internal + */ + public override endEdit(_commit = true, _event?: Event): boolean { + return; + } + + /** + * @hidden @internal + */ + public override beginAddRowById(_rowID: any, _asChild?: boolean): void { + } + + /** + * @hidden @internal + */ + public override beginAddRowByIndex(_index: number): void { + } + + /** + * @hidden @internal + */ + public override clearSearch() { } + + /** + * @hidden @internal + */ + public override refreshSearch(_updateActiveInfo?: boolean, _endEdit = true): number { + return 0; + } + + /** + * @hidden @internal + */ + public override findNext(_text: string, _caseSensitive?: boolean, _exactMatch?: boolean): number { + return 0; + } + + /** + * @hidden @internal + */ + public override findPrev(_text: string, _caseSensitive?: boolean, _exactMatch?: boolean): number { + return 0; + } + + /** + * @hidden @internal + */ + public override getNextCell(currRowIndex: number, curVisibleColIndex: number, + callback: (IgxColumnComponent) => boolean = null): ICellPosition { + return super.getNextCell(currRowIndex, curVisibleColIndex, callback); + } + + /** + * @hidden @internal + */ + public override getPreviousCell(currRowIndex: number, curVisibleColIndex: number, + callback: (IgxColumnComponent) => boolean = null): ICellPosition { + return super.getPreviousCell(currRowIndex, curVisibleColIndex, callback); + } + + /** + * @hidden @internal + */ + public override getPinnedStartWidth(takeHidden = false) { + return super.getPinnedStartWidth(takeHidden); + } + + /** + * @hidden @internal + */ + public override get totalHeight() { + return this.calcHeight; + } + + public getColumnGroupExpandState(col: IgxColumnComponent) { + const state = this.columnGroupStates.get(col.field); + // columns are expanded by default? + return state !== undefined && state !== null ? state : false; + } + + public toggleRowGroup(col: IgxColumnComponent, newState: boolean) { + if (!col) return; + if (this.hasMultipleValues) { + const parentCols = col.parent ? col.parent.children.toArray() : this._autoGeneratedCols.filter(x => x.level === 0); + const siblingCol = parentCols.filter(x => x.header === col.header && x !== col)[0]; + const currIndex = parentCols.indexOf(col); + const siblingIndex = parentCols.indexOf(siblingCol); + if (currIndex < siblingIndex) { + // clicked on the full hierarchy header + this.resolveToggle(col, newState); + siblingCol.headerTemplate = this.headerTemplate; + } else { + // clicked on summary parent column that contains just the measures + col.headerTemplate = undefined; + this.resolveToggle(siblingCol, newState); + } + } else { + const parentCols = col.parent ? col.parent.children : this._autoGeneratedCols.filter(x => x.level === 0); + const fieldColumn = parentCols.filter(x => x.header === col.header && !x.columnGroup)[0]; + const groupColumn = parentCols.filter(x => x.header === col.header && x.columnGroup)[0]; + this.resolveToggle(groupColumn, newState); + if (newState) { + fieldColumn.headerTemplate = this.headerTemplate; + } else { + fieldColumn.headerTemplate = undefined; + } + } + } + + /** + * @hidden @internal + */ + public override setupColumns() { + super.setupColumns(); + } + + /** + * @hidden @internal + */ + public override dataRebinding(event: IForOfDataChangingEventArgs) { + if (this.hasHorizontalLayout) { + this.dimensionDataColumns = this.generateDimensionColumns(); + } + + super.dataRebinding(event); + } + + /** + * Auto-sizes row dimension cells. + * + * @remarks + * Only sizes based on the dimension cells in view. + * @example + * ```typescript + * this.grid.autoSizeRowDimension(dimension); + * ``` + * @param dimension The row dimension to size. + */ + public autoSizeRowDimension(dimension: IPivotDimension) { + if (this.getDimensionType(dimension) === PivotDimensionType.Row) { + const relatedDims: string[] = PivotUtil.flatten([dimension]).map((x: IPivotDimension) => x.memberName); + const contentCollection = this.getContentCollection(dimension); + const content = contentCollection.filter(x => relatedDims.indexOf(x.dimension.memberName) !== -1); + const headers = content.map(x => x.headerGroups.toArray()).flat().map(x => x.header && x.header.refInstance); + if (this.pivotUI.showRowHeaders) { + const dimensionHeader = this.theadRow.rowDimensionHeaders.find(x => x.column.field === dimension.memberName); + headers.push(dimensionHeader); + } + const autoWidth = this.getLargesContentWidth(headers); + if (dimension.width === "auto") { + dimension.autoWidth = parseFloat(autoWidth); + } else { + dimension.width = autoWidth; + } + this.pipeTrigger++; + this.cdr.detectChanges(); + } + } + + /** + * Inserts dimension in target collection by type at specified index or at the collection's end. + * + * @example + * ```typescript + * this.grid.insertDimensionAt(dimension, PivotDimensionType.Row, 1); + * ``` + * @param dimension The dimension that will be added. + * @param targetCollectionType The target collection type to add to. Can be Row, Column or Filter. + * @param index The index in the collection at which to add. + * This parameter is optional. If not set it will add it to the end of the collection. + */ + public insertDimensionAt(dimension: IPivotDimension, targetCollectionType: PivotDimensionType, index?: number) { + const targetCollection = this.getDimensionsByType(targetCollectionType); + if (index !== undefined) { + targetCollection.splice(index, 0, dimension); + } else { + targetCollection.push(dimension); + } + if (targetCollectionType === PivotDimensionType.Column) { + this.setupColumns(); + } + this.pipeTrigger++; + this.dimensionsChange.emit({ dimensions: targetCollection, dimensionCollectionType: targetCollectionType }); + if (targetCollectionType === PivotDimensionType.Filter) { + this.dimensionDataColumns = this.generateDimensionColumns(); + this.reflow(); + } + this.pivotConfigurationChange.emit({ pivotConfiguration: this.pivotConfiguration }); + } + + /** + * Move dimension from its currently collection to the specified target collection by type at specified index or at the collection's end. + * + * @example + * ```typescript + * this.grid.moveDimension(dimension, PivotDimensionType.Row, 1); + * ``` + * @param dimension The dimension that will be moved. + * @param targetCollectionType The target collection type to move it to. Can be Row, Column or Filter. + * @param index The index in the collection at which to add. + * This parameter is optional. If not set it will add it to the end of the collection. + */ + public moveDimension(dimension: IPivotDimension, targetCollectionType: PivotDimensionType, index?: number) { + const prevCollectionType = this.getDimensionType(dimension); + if (prevCollectionType === null) return; + // remove from old collection + this._removeDimensionInternal(dimension); + // add to target + this.insertDimensionAt(dimension, targetCollectionType, index); + + if (prevCollectionType === PivotDimensionType.Column) { + this.setupColumns(); + } + } + + /** + * Removes dimension from its currently collection. + * @remarks + * This is different than toggleDimension that enabled/disables the dimension. + * This completely removes the specified dimension from the collection. + * @example + * ```typescript + * this.grid.removeDimension(dimension); + * ``` + * @param dimension The dimension to be removed. + */ + public removeDimension(dimension: IPivotDimension) { + const prevCollectionType = this.getDimensionType(dimension); + this._removeDimensionInternal(dimension); + if (prevCollectionType === PivotDimensionType.Column) { + this.setupColumns(); + } + if (prevCollectionType === PivotDimensionType.Filter) { + this.reflow(); + } + this.pipeTrigger++; + this.cdr.detectChanges(); + } + + /** + * Toggles the dimension's enabled state on or off. + * @remarks + * The dimension remains in its current collection. This just changes its enabled state. + * @example + * ```typescript + * this.grid.toggleDimension(dimension); + * ``` + * @param dimension The dimension to be toggled. + */ + public toggleDimension(dimension: IPivotDimension) { + const dimType = this.getDimensionType(dimension); + if (dimType === null) return; + const collection = this.getDimensionsByType(dimType); + dimension.enabled = !dimension.enabled; + if (dimType === PivotDimensionType.Column) { + this.setupColumns(); + } + if (!dimension.enabled && dimension.filter) { + this.filteringService.clearFilter(dimension.memberName); + } + this.pipeTrigger++; + this.dimensionsChange.emit({ dimensions: collection, dimensionCollectionType: dimType }); + this.cdr.detectChanges(); + if (dimType === PivotDimensionType.Filter) { + this.reflow(); + } + this.pivotConfigurationChange.emit({ pivotConfiguration: this.pivotConfiguration }); + } + + /** + * Inserts value at specified index or at the end. + * + * @example + * ```typescript + * this.grid.insertValueAt(value, 1); + * ``` + * @param value The value definition that will be added. + * @param index The index in the collection at which to add. + * This parameter is optional. If not set it will add it to the end of the collection. + */ + public insertValueAt(value: IPivotValue, index?: number) { + if (!this.pivotConfiguration.values) { + this.pivotConfiguration.values = []; + } + const values = this.pivotConfiguration.values; + if (index !== undefined) { + values.splice(index, 0, value); + } else { + values.push(value); + } + this.setupColumns(); + this.pipeTrigger++; + this.cdr.detectChanges(); + this.valuesChange.emit({ values }); + this.pivotConfigurationChange.emit({ pivotConfiguration: this.pivotConfiguration }); + } + + /** + * Move value from its currently at specified index or at the end. + * + * @example + * ```typescript + * this.grid.moveValue(value, 1); + * ``` + * @param value The value that will be moved. + * @param index The index in the collection at which to add. + * This parameter is optional. If not set it will add it to the end of the collection. + */ + public moveValue(value: IPivotValue, index?: number) { + if (this.pivotConfiguration.values.indexOf(value) === -1) return; + // remove from old index + this.removeValue(value); + // add to new + this.insertValueAt(value, index); + } + + /** + * Removes value from collection. + * @remarks + * This is different than toggleValue that enabled/disables the value. + * This completely removes the specified value from the collection. + * @example + * ```typescript + * this.grid.removeValue(dimension); + * ``` + * @param value The value to be removed. + */ + public removeValue(value: IPivotValue,) { + const values = this.pivotConfiguration.values; + const currentIndex = values.indexOf(value); + if (currentIndex !== -1) { + values.splice(currentIndex, 1); + this.setupColumns(); + this.pipeTrigger++; + this.valuesChange.emit({ values }); + this.pivotConfigurationChange.emit({ pivotConfiguration: this.pivotConfiguration }); + } + } + + /** + * Toggles the value's enabled state on or off. + * @remarks + * The value remains in its current collection. This just changes its enabled state. + * @example + * ```typescript + * this.grid.toggleValue(value); + * ``` + * @param value The value to be toggled. + */ + public toggleValue(value: IPivotValue) { + if (this.pivotConfiguration.values.indexOf(value) === -1) return; + value.enabled = !value.enabled; + this.setupColumns(); + this.pipeTrigger++; + this.valuesChange.emit({ values: this.pivotConfiguration.values }); + this.reflow(); + this.pivotConfigurationChange.emit({ pivotConfiguration: this.pivotConfiguration }); + } + + /** + * Sort the dimension and its children in the provided direction. + * @example + * ```typescript + * this.grid.sortDimension(dimension, SortingDirection.Asc); + * ``` + * @param value The value to be toggled. + */ + public sortDimension(dimension: IPivotDimension, sortDirection: SortingDirection) { + const dimensionType = this.getDimensionType(dimension); + dimension.sortDirection = sortDirection; + // apply same sort direction to children. + let dim = dimension; + if (this.pivotUI.rowLayout === PivotRowLayoutType.Vertical) { + while (dim.childLevel) { + dim.childLevel.sortDirection = dimension.sortDirection; + dim = dim.childLevel; + } + } + + this.pipeTrigger++; + this.dimensionsSortingExpressionsChange.emit(this.dimensionsSortingExpressions); + if (dimensionType === PivotDimensionType.Column) { + this.setupColumns(); + } + this.cdr.detectChanges(); + this.pivotConfigurationChange.emit({ pivotConfiguration: this.pivotConfiguration }); + } + + /** + * Filters a single `IPivotDimension`. + * + * @example + * ```typescript + * public filter() { + * const set = new Set(); + * set.add('Value 1'); + * set.add('Value 2'); + * this.grid1.filterDimension(this.pivotConfigHierarchy.rows[0], set, IgxStringFilteringOperand.instance().condition('in')); + * } + * ``` + */ + public filterDimension(dimension: IPivotDimension, value: any, conditionOrExpressionTree?: IFilteringOperation | IFilteringExpressionsTree) { + this.filteringService.filter(dimension.memberName, value, conditionOrExpressionTree); + const dimensionType = this.getDimensionType(dimension); + if (dimensionType === PivotDimensionType.Column) { + this.setupColumns(); + } + this.cdr.detectChanges(); + } + + /** + * @hidden @internal + */ + public getRowDimensionByName(memberName: string) { + const visibleRows = this.pivotUI.rowLayout === PivotRowLayoutType.Vertical ? + this.pivotConfiguration.rows : + PivotUtil.flatten(this.pivotConfiguration.rows); + const dimIndex = visibleRows.findIndex((target) => target.memberName === memberName); + const dim = visibleRows[dimIndex]; + return dim; + } + + /** + * @hidden @internal + */ + public getDimensionsByType(dimension: PivotDimensionType) { + switch (dimension) { + case PivotDimensionType.Row: + if (!this.pivotConfiguration.rows) { + this.pivotConfiguration.rows = []; + } + return this.pivotConfiguration.rows; + case PivotDimensionType.Column: + if (!this.pivotConfiguration.columns) { + this.pivotConfiguration.columns = []; + } + return this.pivotConfiguration.columns; + case PivotDimensionType.Filter: + if (!this.pivotConfiguration.filters) { + this.pivotConfiguration.filters = []; + } + return this.pivotConfiguration.filters; + default: + return null; + } + } + + /** + * @hidden @internal + */ + public resizeRowDimensionPixels(dimension: IPivotDimension, newWidth: number) { + const isPercentageWidth = dimension.width && typeof dimension.width === 'string' && dimension.width.indexOf('%') !== -1; + if (isPercentageWidth) { + dimension.width = this.reverseDimensionWidthToPercent(newWidth).toFixed(2) + '%'; + } else { + dimension.width = newWidth + 'px'; + } + + // Notify the grid to reflow, to update if horizontal scrollbar needs to be rendered/removed. + this.pipeTrigger++; + this.cdr.detectChanges(); + } + + /* + * @hidden + * @internal + */ + protected _removeDimensionInternal(dimension) { + const prevCollectionType = this.getDimensionType(dimension); + if (prevCollectionType === null) return; + const prevCollection = this.getDimensionsByType(prevCollectionType); + const currentIndex = prevCollection.indexOf(dimension); + prevCollection.splice(currentIndex, 1); + this.pipeTrigger++; + this.cdr.detectChanges(); + } + + protected getDimensionType(dimension: IPivotDimension): PivotDimensionType { + return PivotUtil.flatten(this.pivotConfiguration.rows).indexOf(dimension) !== -1 ? PivotDimensionType.Row : + PivotUtil.flatten(this.pivotConfiguration.columns).indexOf(dimension) !== -1 ? PivotDimensionType.Column : + (!!this.pivotConfiguration.filters && PivotUtil.flatten(this.pivotConfiguration.filters).indexOf(dimension) !== -1) ? + PivotDimensionType.Filter : null; + } + + protected getPivotRowHeaderContentWidth(headerGroup: IgxPivotRowHeaderGroupComponent) { + const headerSizes = this.getHeaderCellWidth(headerGroup.nativeElement); + return headerSizes.width + headerSizes.padding; + } + + protected getLargesContentWidth(contents: ElementRef[]): string { + const largest = new Map(); + if (contents.length > 0) { + const cellsContentWidths = []; + contents.forEach((elem) => { + elem instanceof IgxPivotRowHeaderGroupComponent ? + cellsContentWidths.push(this.getPivotRowHeaderContentWidth(elem)) : + cellsContentWidths.push(this.getHeaderCellWidth(elem.nativeElement).width); + }); + const index = cellsContentWidths.indexOf(Math.max(...cellsContentWidths)); + const cellStyle = this.document.defaultView.getComputedStyle(contents[index].nativeElement); + const cellPadding = parseFloat(cellStyle.paddingLeft) + parseFloat(cellStyle.paddingRight) + + parseFloat(cellStyle.borderLeftWidth) + parseFloat(cellStyle.borderRightWidth); + largest.set(Math.max(...cellsContentWidths), cellPadding); + } + const largestCell = Math.max(...Array.from(largest.keys())); + const width = Math.ceil(largestCell + largest.get(largestCell)); + + if (Number.isNaN(width)) { + return null; + } else { + return width + 'px'; + } + } + + /** @hidden @internal */ + public get hasHorizontalLayout() { + return this.pivotUI.rowLayout === PivotRowLayoutType.Horizontal; + } + + /** + * @hidden + */ + public get hasMultipleValues() { + return this.values.length > 1; + } + + /** + * @hidden + */ + public get excelStyleFilterMaxHeight() { + // max 10 rows, row size depends on grid size + const maxHeight = this.renderedRowHeight * 10; + return `${maxHeight}px`; + } + + /** + * @hidden + */ + public get excelStyleFilterMinHeight(): string { + // min 5 rows, row size depends on grid size + const minHeight = this.renderedRowHeight * 5; + return `${minHeight}px`; + } + + /** @hidden @internal */ + public override get activeDescendant(): string | undefined { + if (this.navigation.isRowHeaderActive || this.navigation.isRowDimensionHeaderActive) { + return; + } + return super.activeDescendant; + } + + /** @hidden @internal */ + public get headerRowActiveDescendant() { + const activeElem = this.navigation.activeNode; + if (!activeElem || !Object.keys(activeElem).length || !this.navigation.isRowHeaderActive) { + return null; + } + + const rowDimensions = this.rowDimensionContentCollection.length > 0 ? + this.rowDimensionContentCollection.toArray() : + this.rowDimensionMrlComponent.rowDimensionContentCollection.toArray(); + + const rowDimensionContentActive = rowDimensions.find(rd => rd && rd.headerGroups?.some(hg => hg.active)); + const activeHeader = rowDimensionContentActive?.headerGroups.toArray().find(hg => hg.active); + + return activeHeader ? `${this.id}_${activeHeader.title}` : null; + } + + protected resolveToggle(groupColumn: ColumnType, state: boolean) { + if (!groupColumn) return; + groupColumn.hidden = state; + this.columnGroupStates.set(groupColumn.field, state); + const childrenTotal = this.hasMultipleValues ? + groupColumn.children.filter(x => x.columnGroup && x.children.filter(y => !y.columnGroup).length === this.values.length) : + groupColumn.children.filter(x => !x.columnGroup); + const childrenSubgroups = this.hasMultipleValues ? + groupColumn.children.filter(x => x.columnGroup && x.children.filter(y => !y.columnGroup).length === 0) : + groupColumn.children.filter(x => x.columnGroup); + childrenTotal.forEach(group => { + const newState = this.columnGroupStates.get(group.field) || state; + if (newState) { + group.headerTemplate = this.headerTemplate; + } else { + group.headerTemplate = undefined; + } + }); + if (!groupColumn.hidden && childrenSubgroups.length > 0) { + childrenSubgroups.forEach(group => { + const newState = this.columnGroupStates.get(group.field) || state; + this.resolveToggle(group, newState); + }); + } + } + + protected override buildDataView(data: any[]) { + this._dataView = data; + } + + /** + * @hidden @internal + */ + protected override getDataBasedBodyHeight(): number { + const dvl = this.dataView?.length || 0; + return dvl < this._defaultTargetRecordNumber ? 0 : this.defaultTargetBodyHeight; + } + + protected override horizontalScrollHandler(event) { + const scrollLeft = event.target.scrollLeft; + this.theadRow.headerContainers.forEach(headerForOf => { + headerForOf.onHScroll(scrollLeft); + }); + super.horizontalScrollHandler(event); + } + + protected override verticalScrollHandler(event) { + this.verticalRowDimScrollContainers.forEach(x => { + x.onScroll(event); + }); + super.verticalScrollHandler(event); + } + + /** + * @hidden + */ + protected override autogenerateColumns() { + let columns = []; + const data = this.gridAPI.filterDataByExpressions(this.filteringExpressionsTree); + this.dimensionDataColumns = this.generateDimensionColumns(); + const flattenedColumnsWithSorting = PivotUtil.flatten(this.columnDimensions).filter(dim => dim.sortDirection); + const expressions = flattenedColumnsWithSorting.length > 0 ? PivotSortUtil.generateDimensionSortingExpressions(flattenedColumnsWithSorting) : []; + let sortedData = data; + if (expressions.length > 0) { + sortedData = DataUtil.sort(cloneArray(data), expressions, this.sortStrategy, this); + } + let fieldsMap; + if (this.pivotConfiguration.columnStrategy && this.pivotConfiguration.columnStrategy instanceof NoopPivotDimensionsStrategy) { + const fields = this.generateDataFields(sortedData); + if (fields.length === 0) return; + const rowFields = PivotUtil.flatten(this.pivotConfiguration.rows).map(x => x.memberName); + const keyFields = Object.values(this.pivotKeys); + const filteredFields = fields.filter(x => rowFields.indexOf(x) === -1 && keyFields.indexOf(x) === -1 && + x.indexOf(this.pivotKeys.rowDimensionSeparator + this.pivotKeys.level) === -1 && + x.indexOf(this.pivotKeys.rowDimensionSeparator + this.pivotKeys.records) === -1); + fieldsMap = this.generateFromData(filteredFields); + } else { + fieldsMap = PivotUtil.getFieldsHierarchy( + sortedData, + this.columnDimensions, + PivotDimensionType.Column, + this.pivotKeys, + this.pivotValueCloneStrategy + ); + } + columns = this.generateColumnHierarchy(fieldsMap, sortedData); + this._autoGeneratedCols = columns; + // reset expansion states if any are stored. + this.columnGroupStates.forEach((value, key) => { + if (value) { + const primaryColumn = columns.find(x => x.field === key && x.headerTemplate === this.headerTemplate); + const groupSummaryColumn = columns.find(x => x.field === key && x.headerTemplate !== this.headerTemplate); + this.toggleRowGroup(primaryColumn, value); + if (groupSummaryColumn) { + groupSummaryColumn.headerTemplate = this.headerTemplate; + } + } + }); + + this.updateColumns(columns); + this.pipeTrigger++; + this.reflow(); + } + + protected generateDimensionColumns(): IgxColumnComponent[] { + const columns = []; + this.allVisibleDimensions.forEach((dim) => { + const ref = createComponent(IgxColumnComponent, { environmentInjector: this.envInjector, elementInjector: this.injector }); + ref.instance.field = dim.memberName; + ref.instance.header = dim.displayName || dim.memberName; + ref.instance.headerTemplate = this.rowDimensionHeaderTemplate; + ref.instance.resizable = this.rowDimensionResizing; + ref.instance.sortable = dim.sortable === undefined ? true : dim.sortable; + ref.instance.width = this.rowDimensionWidth(dim); + ref.instance.filteringIgnoreCase = false; + ref.changeDetectorRef.detectChanges(); + columns.push(ref.instance); + }); + return columns; + } + + protected override calculateGridSizes(recalcFeatureWidth = true) { + super.calculateGridSizes(recalcFeatureWidth); + if (this.hasDimensionsToAutosize) { + this.cdr.detectChanges(); + this.zone.onStable.pipe(first()).subscribe(() => { + requestAnimationFrame(() => { + this.autoSizeDimensionsInView(); + }); + }); + } + } + + protected getContentCollection(dimenstion: IPivotDimension) { + let contentCollection; + if (this.hasHorizontalLayout) { + const allMrlContents = this.rowDimensionMrlRowsCollection.map(mrlRow => mrlRow.contentCells.toArray()).flat(); + contentCollection = allMrlContents.filter(cell => cell.rootDimension === dimenstion); + } else { + contentCollection = this.rowDimensionContentCollection.toArray(); + } + return contentCollection; + } + + protected autoSizeDimensionsInView() { + if (!this.hasDimensionsToAutosize) return; + for (const dim of this.visibleRowDimensions) { + if (dim.width === 'auto') { + const contentWidths = []; + const relatedDims = PivotUtil.flatten([dim]).map(x => x.memberName); + const contentCollection = this.getContentCollection(dim); + const content = contentCollection.filter(x => relatedDims.indexOf(x.dimension.memberName) !== -1); + const headers = content.map(x => x.headerGroups.toArray()).flat().map(x => x.header && x.header.refInstance); + headers.forEach((header) => contentWidths.push(header?.nativeElement?.offsetWidth || 0)); + if (this.pivotUI.showRowHeaders) { + const dimensionHeader = this.theadRow.rowDimensionHeaders.find(x => x.column.field === dim.memberName); + contentWidths.push(parseFloat(this.getLargesContentWidth([dimensionHeader]))); + } + const max = Math.max(...contentWidths); + if (max === 0) { + // cells not in DOM yet... + continue; + } + const maxSize = Math.ceil(Math.max(...contentWidths)); + dim.autoWidth = maxSize; + } + } + + if (this.isColumnWidthSum) { + this.calcWidth = this.getColumnWidthSum(); + } + } + + /** @hidden @internal */ + public get hasDimensionsToAutosize() { + return this.rowDimensions.some(x => x.width === 'auto' && !x.autoWidth); + } + + protected generateFromData(fields: string[]) { + const separator = this.pivotKeys.columnDimensionSeparator; + const dataArr = fields.map(x => x.split(separator)).sort(x => x.length); + const hierarchy = new Map(); + const columnDimensions = PivotUtil.flatten(this.columnDimensions); + dataArr.forEach(arr => { + let currentHierarchy = hierarchy; + const path = []; + let index = 0; + for (const val of arr) { + path.push(val); + const newPath = path.join(separator); + let targetHierarchy = currentHierarchy.get(newPath); + if (!targetHierarchy) { + const currentColumnDimension = columnDimensions[index]; + currentHierarchy.set(newPath, { value: newPath, expandable: !!currentColumnDimension.childLevel, children: new Map(), dimension: currentColumnDimension }); + targetHierarchy = currentHierarchy.get(newPath); + } + currentHierarchy = targetHierarchy.children; + index++; + } + }); + return hierarchy; + } + protected generateColumnHierarchy(fields: Map, data, parent = null): IgxColumnComponent[] { + let columns = []; + if (fields.size === 0) { + this.values.forEach((value) => { + const ref = createComponent(IgxColumnComponent, { environmentInjector: this.envInjector, elementInjector: this.injector }); + let columnDataType = value.dataType || this.resolveDataTypes(data.length ? data[0][value.member] : null); + + if (value.aggregate?.key?.toLowerCase() === 'count' && (columnDataType === GridColumnDataType.Currency || columnDataType == GridColumnDataType.Percent)) { + columnDataType = GridColumnDataType.Number; + } + + ref.instance.header = value.displayName; + ref.instance.field = value.member; + ref.instance.parent = parent; + ref.instance.sortable = true; + ref.instance.dataType = columnDataType; + ref.instance.formatter = value.formatter; + columns.push(ref.instance); + }); + return columns; + } + const currentFields = fields; + currentFields.forEach((value) => { + let shouldGenerate = true; + if (data.length === 0) { + shouldGenerate = false; + } + if (shouldGenerate && (value.children == null || value.children.length === 0 || value.children.size === 0)) { + const col = this.createColumnForDimension(value, data, parent, this.hasMultipleValues); + + if (!this.hasMultipleValues && this.values.length > 0) { + PivotUtil.updateColumnTypeByAggregator([col], this.values[0], true); + } + + columns.push(col); + if (this.hasMultipleValues) { + const measureChildren = this.getMeasureChildren(data, col, false, value.dimension.width); + + measureChildren.forEach((child, index) => { + const pivotValue = this.values[index]; + PivotUtil.updateColumnTypeByAggregator([child], pivotValue, this.values.length === 1); + }); + + col.children.reset(measureChildren); + columns = columns.concat(measureChildren); + } + + } else if (shouldGenerate) { + const col = this.createColumnForDimension(value, data, parent, true); + if (value.expandable) { + col.headerTemplate = this.headerTemplate; + } + const children = this.generateColumnHierarchy(value.children, data, col); + const filteredChildren = children.filter(x => x.level === col.level + 1); + columns.push(col); + if (this.hasMultipleValues) { + let measureChildren = this.getMeasureChildren(data, col, true, value.dimension.width); + const nestedChildren = filteredChildren; + //const allChildren = children.concat(measureChildren); + col.children.reset(nestedChildren); + columns = columns.concat(children); + if (value.dimension.childLevel) { + const sibling = this.createColumnForDimension(value, data, parent, true); + columns.push(sibling); + + measureChildren = this.getMeasureChildren(data, sibling, false, value.dimension?.width); + sibling.children.reset(measureChildren); + columns = columns.concat(measureChildren); + } + + } else { + col.children.reset(filteredChildren); + columns = columns.concat(children); + if (value.dimension.childLevel) { + const sibling = this.createColumnForDimension(value, data, parent, false); + columns.push(sibling); + } + } + } + }); + + return columns; + } + + + protected generateConfig() { + if (!this.data) return; + + const data = this.data; + const fields = this.generateDataFields(data); + const columnDimensions: IPivotDimension[] = []; + const rowDimensions: IPivotDimension[] = []; + const values: IPivotValue[] = []; + let isFirstDate = true; + fields.forEach((field) => { + const dataType = this.resolveDataTypes(data[0][field]); + switch (dataType) { + case "number": + { + const value: IPivotValue = { + member: field, + displayName: field, + dataType: dataType, + aggregate: { + key: 'sum', + label: 'Sum', + aggregatorName: "SUM" + }, + enabled: true + }; + values.push(value); + break; + } + case "date": + { + const dimension: IPivotDimension = new IgxPivotDateDimension( + { + memberName: field, + enabled: isFirstDate, + dataType: dataType + } + ) + rowDimensions.push(dimension); + isFirstDate = false; + break; + } + default: { + const dimension: IPivotDimension = { + memberName: field, + enabled: false, + dataType: dataType + }; + columnDimensions.push(dimension); + break; + } + } + }); + const config: IPivotConfiguration = { + columns: columnDimensions, + rows: rowDimensions, + values: values + }; + this.pivotConfiguration = config; + } + + protected createColumnForDimension(value: any, data: any, parent: ColumnType, isGroup: boolean) { + const key = value.value; + const ref = isGroup ? + createComponent(IgxColumnGroupComponent, { environmentInjector: this.envInjector, elementInjector: this.injector }) : + createComponent(IgxColumnComponent, { environmentInjector: this.envInjector, elementInjector: this.injector }); + ref.instance.header = parent != null ? key.split(parent.header + this.pivotKeys.columnDimensionSeparator)[1] : key; + ref.instance.field = key; + ref.instance.parent = parent; + if (value.dimension.width) { + ref.instance.width = value.dimension.width; + } + const valueDefinition = this.values[0]; + ref.instance.dataType = valueDefinition?.dataType || this.resolveDataTypes(data[0][valueDefinition?.member]); + ref.instance.formatter = valueDefinition?.formatter; + ref.instance.sortable = true; + ref.changeDetectorRef.detectChanges(); + return ref.instance; + } + + protected resolveColumnDimensionWidth(dim: IPivotDimension) { + if (dim.width) { + return dim.width; + } + return this.minColumnWidth + 'px'; + } + + protected getMeasureChildren(data, parent, hidden, parentWidth) { + const cols = []; + const count = this.values.length; + const childWidth = parseInt(parentWidth, 10) / count; + const isPercent = parentWidth && parentWidth.indexOf('%') !== -1; + const isAuto = parentWidth && parentWidth.indexOf('auto') !== -1; + this.values.forEach(val => { + const ref = createComponent(IgxColumnComponent, { environmentInjector: this.envInjector, elementInjector: this.injector }); + ref.instance.header = val.displayName || val.member; + ref.instance.field = parent.field + this.pivotKeys.columnDimensionSeparator + val.member; + ref.instance.parent = parent; + if (parentWidth) { + ref.instance.width = isAuto ? 'auto' : isPercent ? childWidth + '%' : childWidth + 'px'; + } + ref.instance.hidden = hidden; + ref.instance.sortable = this._sortableColumns; + ref.instance.dataType = val.dataType || this.resolveDataTypes(data[0][val.member]); + ref.instance.formatter = val.formatter; + ref.changeDetectorRef.detectChanges(); + cols.push(ref.instance); + }); + return cols; + } + + /** + * @hidden @internal + */ + @ViewChild('emptyPivotGridTemplate', { read: TemplateRef, static: true }) + public defaultEmptyPivotGridTemplate: TemplateRef; + + /** + * Gets/Sets a custom template when pivot grid is empty. + * + * @example + * ```html + * + * ``` + */ + @Input() + public emptyPivotGridTemplate: TemplateRef; + + /** + * @hidden @internal + */ + public override get template(): TemplateRef { + const allEnabledDimensions = this.rowDimensions.concat(this.columnDimensions); + if (allEnabledDimensions.length === 0 && this.values.length === 0) { + // no enabled values and dimensions + return this.emptyPivotGridTemplate || this.defaultEmptyPivotGridTemplate; + } + return super.template; + } + + private emitInitEvents(pivotConfig: IPivotConfiguration) { + const dimensions = PivotUtil.flatten(this.allDimensions); + dimensions.forEach(dim => { + this.dimensionInit.emit(dim); + }); + const values = pivotConfig?.values; + values?.forEach(val => { + this.valueInit.emit(val); + }); + } + + protected rowDimensionByName(memberName: string) { + return this.visibleRowDimensions.find((rowDim) => rowDim.memberName === memberName); + } + + protected calculateResizerTop() { + return this.pivotUI.showRowHeaders ? + (this.theadRow.pivotFilterContainer?.nativeElement.offsetHeight || 0) + (this.theadRow.pivotRowContainer?.nativeElement.offsetHeight || 0) : + this.theadRow.nativeElement.offsetHeight; + } + + protected override updateDefaultRowHeight() { + super.updateDefaultRowHeight(); + if (this.hasHorizontalLayout) { + // Trigger pipes to recalc heights for the horizontal layout mrl rows. + this.regroupTrigger++; + } + } + + /** + * @hidden @internal + */ + public createRow(index: number, data?: any): RowType { + let row: RowType; + + const dataIndex = this._getDataViewIndex(index); + const rec = data ?? this.dataView[dataIndex]; + + + if (!row && rec) { + row = new IgxPivotGridRow(this, index, rec); + } + return row; + } +} diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.directives.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.directives.ts new file mode 100644 index 00000000000..88d4367f4e9 --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.directives.ts @@ -0,0 +1,33 @@ +import { Directive, TemplateRef, inject } from '@angular/core'; +import { IgxColumnTemplateContext, IgxPivotGridValueTemplateContext } from 'igniteui-angular/grids/core'; +/** + * @hidden + */ +@Directive({ + selector: '[igxPivotValueChip]', + standalone: true +}) +export class IgxPivotValueChipTemplateDirective { + public template = inject>(TemplateRef); + + public static ngTemplateContextGuard(_directive: IgxPivotValueChipTemplateDirective, + context: unknown): context is IgxPivotGridValueTemplateContext { + return true; + } +} + +/** + * @hidden + */ +@Directive({ + selector: '[igxPivotRowDimensionHeader]', + standalone: true +}) +export class IgxPivotRowDimensionHeaderTemplateDirective { + public template = inject>(TemplateRef); + + public static ngTemplateContextGuard(_directive: IgxPivotRowDimensionHeaderTemplateDirective, + context: unknown): context is IgxColumnTemplateContext { + return true; + } +} diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.module.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.module.ts new file mode 100644 index 00000000000..d4be32ab394 --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from "@angular/core"; +import { IGX_PIVOT_GRID_DIRECTIVES } from './public_api'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + ...IGX_PIVOT_GRID_DIRECTIVES + ], + exports: [ + ...IGX_PIVOT_GRID_DIRECTIVES + ] +}) +export class IgxPivotGridModule {} diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.pipes.spec.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.pipes.spec.ts new file mode 100644 index 00000000000..31fda83a400 --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.pipes.spec.ts @@ -0,0 +1,1371 @@ +import { IGX_GRID_BASE, IgxPivotAggregate, IgxPivotDateAggregate, IgxPivotDateDimension, IgxPivotNumericAggregate, IgxPivotTimeAggregate, IPivotConfiguration, NoopPivotDimensionsStrategy } from 'igniteui-angular/grids/core'; +import { IgxPivotAutoTransform, IgxPivotColumnPipe, IgxPivotRowExpansionPipe, IgxPivotRowPipe } from './pivot-grid.pipes'; +import { PivotGridFunctions } from '../../../test-utils/pivot-grid-functions.spec'; +import { DATA } from 'src/app/shared/pivot-data'; +import { DefaultDataCloneStrategy, IDataCloneStrategy } from 'igniteui-angular/core'; +import { TestBed } from '@angular/core/testing'; + +describe('Pivot pipes #pivotGrid', () => { + let rowPipe: IgxPivotRowPipe; + let rowStatePipe: IgxPivotRowExpansionPipe; + let columnPipe: IgxPivotColumnPipe; + let autoTransformPipe: IgxPivotAutoTransform; + let expansionStates: Map; + let data: any[]; + let pivotConfig: IPivotConfiguration; + let cloneStrategy: IDataCloneStrategy; + + beforeEach(() => { + + + data = [ + { + ProductCategory: 'Clothing', UnitPrice: 12.81, SellerName: 'Stanley', + Country: 'Bulgaria', Date: '01/01/2021', UnitsSold: 282 + }, + { ProductCategory: 'Clothing', UnitPrice: 49.57, SellerName: 'Elisa', Country: 'USA', Date: '01/05/2019', UnitsSold: 296 }, + { ProductCategory: 'Bikes', UnitPrice: 3.56, SellerName: 'Lydia', Country: 'Uruguay', Date: '01/06/2020', UnitsSold: 68 }, + { ProductCategory: 'Accessories', UnitPrice: 85.58, SellerName: 'David', Country: 'USA', Date: '04/07/2021', UnitsSold: 293 }, + { ProductCategory: 'Components', UnitPrice: 18.13, SellerName: 'John', Country: 'USA', Date: '12/08/2021', UnitsSold: 240 }, + { ProductCategory: 'Clothing', UnitPrice: 68.33, SellerName: 'Larry', Country: 'Uruguay', Date: '05/12/2020', UnitsSold: 456 }, + { + ProductCategory: 'Clothing', UnitPrice: 16.05, SellerName: 'Walter', + Country: 'Bulgaria', Date: '02/19/2020', UnitsSold: 492 + }]; + pivotConfig = { + columns: [{ + memberName: 'All', + memberFunction: () => 'All', + enabled: true, + childLevel: { + memberName: 'Country', + enabled: true + } + }], + rows: [{ + memberName: 'AllCategory', + memberFunction: () => 'All', + enabled: true, + childLevel: { + memberName: 'ProductCategory', + memberFunction: (d) => d.ProductCategory, + enabled: true + } + }], + values: [ + { + member: 'UnitsSold', + aggregate: { + aggregator: IgxPivotNumericAggregate.sum, + key: 'sum', + label: 'Sum' + }, + enabled: true + } + ], + filters: null + }; + TestBed.configureTestingModule({ + providers: [ + IgxPivotRowPipe, + IgxPivotRowExpansionPipe, + { provide: IGX_GRID_BASE, useValue: null }, + ] + }); + + expansionStates = new Map(); + rowPipe = TestBed.inject(IgxPivotRowPipe); + rowStatePipe = TestBed.inject(IgxPivotRowExpansionPipe); + columnPipe = new IgxPivotColumnPipe(); + autoTransformPipe = new IgxPivotAutoTransform(); + cloneStrategy = new DefaultDataCloneStrategy(); + }); + + it('transforms flat data to pivot data', () => { + const rowPipeResult = rowPipe.transform(data, pivotConfig, cloneStrategy, expansionStates); + const columnPipeResult = columnPipe.transform(rowPipeResult, pivotConfig, cloneStrategy, expansionStates); + const rowStatePipeResult = rowStatePipe.transform(columnPipeResult, pivotConfig, expansionStates, true); + const dimensionValues = PivotGridFunctions.getDimensionValues(rowStatePipeResult); + expect(dimensionValues).toEqual([ + { 'AllCategory': 'All' }, + { 'ProductCategory': 'Clothing' }, + { 'ProductCategory': 'Bikes' }, + { 'ProductCategory': 'Accessories' }, + { 'ProductCategory': 'Components' }]); + + const aggregations = PivotGridFunctions.getAggregationValues(rowStatePipeResult); + expect(aggregations).toEqual([ + { 'All-Bulgaria': 774, 'All-USA': 829, 'All-Uruguay': 524, 'All': 2127 }, + { 'All-Bulgaria': 774, 'All-USA': 296, 'All-Uruguay': 456, 'All': 1526 }, + { 'All-Uruguay': 68, 'All': 68 }, + { 'All-USA': 293, 'All': 293 }, + { 'All-USA': 240, 'All': 240 }]); + }); + + it('transforms flat data to pivot data single row dimension and no children are defined', () => { + pivotConfig.rows = [{ + memberName: 'ProductCategory', + enabled: true + }]; + const rowPipeResult = rowPipe.transform(data, pivotConfig, cloneStrategy, expansionStates); + const columnPipeResult = columnPipe.transform(rowPipeResult, pivotConfig, cloneStrategy, expansionStates); + const rowStatePipeResult = rowStatePipe.transform(columnPipeResult, pivotConfig, expansionStates, true); + + const dimensionValues = PivotGridFunctions.getDimensionValues(rowStatePipeResult); + expect(dimensionValues).toEqual([ + { 'ProductCategory': 'Clothing' }, + { 'ProductCategory': 'Bikes' }, + { 'ProductCategory': 'Accessories' }, + { 'ProductCategory': 'Components' }]); + }); + + it('allows setting expand/collapse state.', () => { + const expanded = new Map(); + expanded.set('All', false); + const rowPipeResult = rowPipe.transform(data, pivotConfig, cloneStrategy, expanded); + const columnPipeResult = columnPipe.transform(rowPipeResult, pivotConfig, cloneStrategy, expansionStates); + const rowPipeCollapseResult = rowStatePipe.transform(columnPipeResult, pivotConfig, expanded, true); + let dimensionValues = PivotGridFunctions.getDimensionValues(rowPipeCollapseResult); + expect(dimensionValues).toEqual([ + { 'AllCategory': 'All' } + ]); + + expanded.set('All', true); + const rowPipeExpandResult = rowStatePipe.transform(columnPipeResult, pivotConfig, expanded, true); + dimensionValues = PivotGridFunctions.getDimensionValues(rowPipeExpandResult); + expect(dimensionValues).toEqual([ + { 'AllCategory': 'All' }, + { 'ProductCategory': 'Clothing' }, + { 'ProductCategory': 'Bikes' }, + { 'ProductCategory': 'Accessories' }, + { 'ProductCategory': 'Components' }]); + }); + + it('transforms flat data to pivot data multiple row dimensions', () => { + pivotConfig.rows = [ + { + memberName: 'ProductCategory', + enabled: true + }, + { + memberName: 'Date', + enabled: true + } + ]; + const rowPipeResult = rowPipe.transform(data, pivotConfig, cloneStrategy, expansionStates); + const columnPipeResult = columnPipe.transform(rowPipeResult, pivotConfig, cloneStrategy, expansionStates); + const rowStatePipeResult = rowStatePipe.transform(columnPipeResult, pivotConfig, expansionStates, true); + const dimensionValues = PivotGridFunctions.getDimensionValues(rowStatePipeResult); + expect(dimensionValues).toEqual( + [ + { 'ProductCategory': 'Clothing', 'Date': '01/01/2021' }, + { 'ProductCategory': 'Clothing', 'Date': '01/05/2019' }, + { 'ProductCategory': 'Clothing', 'Date': '05/12/2020' }, + { 'ProductCategory': 'Clothing', 'Date': '02/19/2020', }, + { 'ProductCategory': 'Bikes', 'Date': '01/06/2020' }, + { 'ProductCategory': 'Accessories', 'Date': '04/07/2021' }, + { 'ProductCategory': 'Components', 'Date': '12/08/2021' } + ] + ); + }); + + it('transforms flat data to pivot data with multiple nested row dimensions', () => { + pivotConfig.rows = [{ + memberName: 'AllProd', + memberFunction: () => 'AllProd', + enabled: true, + childLevel: { + memberName: 'ProductCategory', + enabled: true + } + }, + { + memberName: 'AllDate', + memberFunction: () => 'AllDate', + enabled: true, + childLevel: { + memberName: 'Date', + enabled: true + } + }]; + const rowPipeResult = rowPipe.transform(data, pivotConfig, cloneStrategy, expansionStates); + const columnPipeResult = columnPipe.transform(rowPipeResult, pivotConfig, cloneStrategy, expansionStates); + const rowStatePipeResult = rowStatePipe.transform(columnPipeResult, pivotConfig, expansionStates, true); + const dimensionValues = PivotGridFunctions.getDimensionValues(rowStatePipeResult); + expect(dimensionValues).toEqual([ + { 'AllProd': 'AllProd', 'AllDate': 'AllDate' }, + { 'AllProd': 'AllProd', 'Date': '01/01/2021' }, + { 'AllProd': 'AllProd', 'Date': '01/05/2019' }, + { 'AllProd': 'AllProd', 'Date': '05/12/2020' }, + { 'AllProd': 'AllProd', 'Date': '02/19/2020', }, + { 'AllProd': 'AllProd', 'Date': '01/06/2020' }, + { 'AllProd': 'AllProd', 'Date': '04/07/2021' }, + { 'AllProd': 'AllProd', 'Date': '12/08/2021' }, + { 'ProductCategory': 'Clothing', 'AllDate': 'AllDate' }, + { 'ProductCategory': 'Clothing', 'Date': '01/01/2021' }, + { 'ProductCategory': 'Clothing', 'Date': '01/05/2019' }, + { 'ProductCategory': 'Clothing', 'Date': '05/12/2020' }, + { 'ProductCategory': 'Clothing', 'Date': '02/19/2020' }, + { 'ProductCategory': 'Bikes', 'AllDate': 'AllDate', }, + { 'ProductCategory': 'Bikes', 'Date': '01/06/2020' }, + { 'ProductCategory': 'Accessories', 'AllDate': 'AllDate' }, + { 'ProductCategory': 'Accessories', 'Date': '04/07/2021' }, + { 'ProductCategory': 'Components', 'AllDate': 'AllDate' }, + { 'ProductCategory': 'Components', 'Date': '12/08/2021' }]); + }); + + it('transforms flat data to pivot data 2 column dimensions', () => { + pivotConfig.columns = [{ + memberName: 'Country', + enabled: true + }, + { + memberName: 'Date', + enabled: true + }]; + const rowPipeResult = rowPipe.transform(data, pivotConfig, cloneStrategy, new Map()); + const columnPipeResult = columnPipe.transform(rowPipeResult, pivotConfig, cloneStrategy, new Map()); + const rowStatePipeResult = rowStatePipe.transform(columnPipeResult, pivotConfig, new Map(), true); + const dimensionValues = PivotGridFunctions.getDimensionValues(rowStatePipeResult); + expect(dimensionValues).toEqual([ + { 'AllCategory': 'All' }, + { 'ProductCategory': 'Clothing' }, + { 'ProductCategory': 'Bikes' }, + { 'ProductCategory': 'Accessories' }, + { 'ProductCategory': 'Components' }]); + // for columns we need to check aggregations + const aggregations = PivotGridFunctions.getAggregationValues(rowStatePipeResult); + expect(aggregations).toEqual([ + { 'Bulgaria-01/01/2021': 282, 'Bulgaria-02/19/2020': 492, 'Bulgaria': 774, 'USA-01/05/2019': 296, 'USA-04/07/2021': 293, 'USA-12/08/2021': 240, 'USA': 829, 'Uruguay-05/12/2020': 456, 'Uruguay-01/06/2020': 68, 'Uruguay': 524 }, + { 'Bulgaria-01/01/2021': 282, 'Bulgaria-02/19/2020': 492, 'Bulgaria': 774, 'USA-01/05/2019': 296, 'USA': 296, 'Uruguay-05/12/2020': 456, 'Uruguay': 456 }, + { 'Uruguay-01/06/2020': 68, 'Uruguay': 68 }, + { 'USA-04/07/2021': 293, 'USA': 293 }, + { 'USA-12/08/2021': 240, 'USA': 240 } + ]); + }); + + it('transforms flat data to pivot data 3 column dimensions', () => { + pivotConfig.columns = [{ + memberName: 'Country', + enabled: true + }, + { + memberName: 'SellerName', + enabled: true + }, + { + memberName: 'Date', + enabled: true + }]; + const rowPipeResult = rowPipe.transform(data, pivotConfig, cloneStrategy, new Map()); + const columnPipeResult = columnPipe.transform(rowPipeResult, pivotConfig, cloneStrategy, new Map()); + const rowStateResult = rowStatePipe.transform(columnPipeResult, pivotConfig, new Map(), true); + const aggregations = PivotGridFunctions.getAggregationValues(rowStateResult); + expect(aggregations).toEqual([ + { 'Bulgaria-Stanley-01/01/2021': 282, 'Bulgaria-Stanley': 282, 'Bulgaria-Walter-02/19/2020': 492, 'Bulgaria-Walter': 492, 'Bulgaria': 774, 'USA-Elisa-01/05/2019': 296, 'USA-Elisa': 296, 'USA-David-04/07/2021': 293, 'USA-David': 293, 'USA-John-12/08/2021': 240, 'USA-John': 240, 'USA': 829, 'Uruguay-Larry-05/12/2020': 456, 'Uruguay-Larry': 456, 'Uruguay-Lydia-01/06/2020': 68, 'Uruguay-Lydia': 68, 'Uruguay': 524 }, + { 'Bulgaria-Stanley-01/01/2021': 282, 'Bulgaria-Stanley': 282, 'Bulgaria-Walter-02/19/2020': 492, 'Bulgaria-Walter': 492, 'Bulgaria': 774, 'USA-Elisa-01/05/2019': 296, 'USA-Elisa': 296, 'USA': 296, 'Uruguay-Larry-05/12/2020': 456, 'Uruguay-Larry': 456, 'Uruguay': 456 }, + { 'Uruguay-Lydia-01/06/2020': 68, 'Uruguay-Lydia': 68, 'Uruguay': 68 }, + { 'USA-David-04/07/2021': 293, 'USA-David': 293, 'USA': 293 }, + { 'USA-John-12/08/2021': 240, 'USA-John': 240, 'USA': 240 }]); + }); + + it('transforms flat data to pivot data 2 value dimensions', () => { + pivotConfig.values = [ + { + member: 'UnitsSold', + aggregate: { + aggregator: IgxPivotNumericAggregate.sum, + key: 'sum', + label: 'SUM', + }, + enabled: true + }, + { + member: 'UnitPrice', + aggregate: { + aggregator: IgxPivotNumericAggregate.sum, + key: 'sum', + label: 'SUM', + }, + enabled: true + } + ]; + const rowPipeResult = rowPipe.transform(data, pivotConfig, cloneStrategy, expansionStates); + const columnPipeResult = columnPipe.transform(rowPipeResult, pivotConfig, cloneStrategy, expansionStates); + const rowStatePipeResult = rowStatePipe.transform(columnPipeResult, pivotConfig, new Map(), true); + const aggregations = PivotGridFunctions.getAggregationValues(rowStatePipeResult); + + expect(aggregations).toEqual([ + { 'All-Bulgaria-UnitsSold': 774, 'All-Bulgaria-UnitPrice': 28.86, 'All-USA-UnitsSold': 829, 'All-USA-UnitPrice': 153.28, 'All-Uruguay-UnitsSold': 524, 'All-Uruguay-UnitPrice': 71.89, 'All-UnitsSold': 2127, 'All-UnitPrice': 254.02999999999997 }, + { 'All-Bulgaria-UnitsSold': 774, 'All-Bulgaria-UnitPrice': 28.86, 'All-USA-UnitsSold': 296, 'All-USA-UnitPrice': 49.57, 'All-Uruguay-UnitsSold': 456, 'All-Uruguay-UnitPrice': 68.33, 'All-UnitsSold': 1526, 'All-UnitPrice': 146.76 }, + { 'All-Uruguay-UnitsSold': 68, 'All-Uruguay-UnitPrice': 3.56, 'All-UnitsSold': 68, 'All-UnitPrice': 3.56 }, { 'All-USA-UnitsSold': 293, 'All-USA-UnitPrice': 85.58, 'All-UnitsSold': 293, 'All-UnitPrice': 85.58 }, + { 'All-USA-UnitsSold': 240, 'All-USA-UnitPrice': 18.13, 'All-UnitsSold': 240, 'All-UnitPrice': 18.13 }]); + }); + + it('should return correct values for each pivot aggregation type', () => { + // check each aggregator has correct aggregations + expect(IgxPivotAggregate.aggregators().map(x => x.key)).toEqual(['COUNT']); + expect(IgxPivotNumericAggregate.aggregators().map(x => x.key)).toEqual(['COUNT', 'MIN', 'MAX', 'SUM', 'AVG']); + expect(IgxPivotDateAggregate.aggregators().map(x => x.key)).toEqual(['COUNT', 'LATEST', 'EARLIEST']); + expect(IgxPivotTimeAggregate.aggregators().map(x => x.key)).toEqual(['COUNT', 'LATEST', 'EARLIEST']); + + // check aggregations are applied correctly + expect(IgxPivotAggregate.count([1, 2, 3])).toEqual(3); + + expect(IgxPivotNumericAggregate.count([1, 2, 3])).toEqual(3); + expect(IgxPivotNumericAggregate.min([1, 2, 3])).toEqual(1); + expect(IgxPivotNumericAggregate.max([1, 2, 3])).toEqual(3); + expect(IgxPivotNumericAggregate.sum([1, 2, 3])).toEqual(6); + expect(IgxPivotNumericAggregate.average([1, 2, 3])).toEqual(2); + + expect(IgxPivotDateAggregate.latest(['01/01/2021', '01/01/2022', '02/01/2021'])).toEqual('01/01/2022'); + expect(IgxPivotDateAggregate.earliest(['01/01/2021', '01/01/2022', '02/01/2021'])).toEqual('01/01/2021'); + + + expect(IgxPivotTimeAggregate.latestTime(['01/01/2021 8:00', '01/01/2021 1:00', '01/01/2021 22:00'])).toEqual(new Date('01/01/2021 22:00')); + expect(IgxPivotTimeAggregate.earliestTime(['01/01/2021 8:00', '01/01/2021 1:00', '01/01/2021 22:00'])).toEqual(new Date('01/01/2021 1:00')); + + // check string can be changed + // This test no longer covers functionality that is provided. Overriding labels is done by extending the class. + // IgxPivotTimeAggregate.aggregators().find(x => x.key === 'EARLIEST').label = 'Earliest Custom Time'; + + // expect(IgxPivotTimeAggregate.aggregators().find(x => x.key === 'EARLIEST').label).toEqual('Earliest Custom Time'); + }); + + it('allow setting NoopPivotDimensionsStrategy for rows/columns', () => { + const preprocessedData = [ + { + All: 2127, AllCategory: 'All', AllCategory_records: [ + { ProductCategory: 'Clothing', All: 1526, 'All-Bulgaria': 774, 'All-USA': 296, 'All-Uruguay': 456 }, + { ProductCategory: 'Bikes', All: 68, 'All-Uruguay': 68 }, + { ProductCategory: 'Accessories', All: 293, 'All-USA': 293 }, + { ProductCategory: 'Components', All: 240, 'All-USA': 240 }] + , 'All-Bulgaria': 774, 'All-USA': 829, 'All-Uruguay': 524 + }]; + pivotConfig.columnStrategy = NoopPivotDimensionsStrategy.instance(); + pivotConfig.columns[0].memberName = 'All'; + pivotConfig.rowStrategy = NoopPivotDimensionsStrategy.instance(); + pivotConfig.rows[0].memberName = 'AllCategory'; + + const rowPipeResult = rowPipe.transform(preprocessedData, pivotConfig, cloneStrategy, new Map()); + const columnPipeResult = columnPipe.transform(rowPipeResult, pivotConfig, cloneStrategy, new Map()); + const autoTransformResult = autoTransformPipe.transform(columnPipeResult, pivotConfig); + const rowStateResult = rowStatePipe.transform(autoTransformResult, pivotConfig, new Map(), true); + + // same data but expanded and transformed to IPivotRecord + const dimensionValues = PivotGridFunctions.getDimensionValues(rowStateResult); + expect(dimensionValues).toEqual([ + { 'AllCategory': 'All' }, + { 'ProductCategory': 'Clothing' }, + { 'ProductCategory': 'Bikes' }, + { 'ProductCategory': 'Accessories' }, + { 'ProductCategory': 'Components' }]); + }); + + it('should generate correct levels when using predefined date dimension', () => { + data = [ + { + ProductCategory: 'Clothing', UnitPrice: 12.81, SellerName: 'Stanley', + Country: 'Bulgaria', City: 'Sofia', Date: '01/01/2021', UnitsSold: 282 + }, + { + ProductCategory: 'Clothing', UnitPrice: 49.57, SellerName: 'Elisa', + Country: 'USA', City: 'New York', Date: '01/05/2019', UnitsSold: 296 + }, + { + ProductCategory: 'Bikes', UnitPrice: 3.56, SellerName: 'Lydia', + Country: 'Uruguay', City: 'Ciudad de la Costa', Date: '01/06/2020', UnitsSold: 68 + }, + { + ProductCategory: 'Accessories', UnitPrice: 85.58, SellerName: 'David', + Country: 'USA', City: 'New York', Date: '04/07/2021', UnitsSold: 293 + }, + { + ProductCategory: 'Components', UnitPrice: 18.13, SellerName: 'John', + Country: 'USA', City: 'New York', Date: '12/08/2021', UnitsSold: 240 + }, + { + ProductCategory: 'Clothing', UnitPrice: 68.33, SellerName: 'Larry', + Country: 'Uruguay', City: 'Ciudad de la Costa', Date: '05/12/2020', UnitsSold: 456 + }, + { + ProductCategory: 'Clothing', UnitPrice: 16.05, SellerName: 'Walter', + Country: 'Bulgaria', City: 'Plovdiv', Date: '02/19/2020', UnitsSold: 492 + }]; + + pivotConfig.rows = [ + new IgxPivotDateDimension( + { + memberName: 'Date', + enabled: true + }, + { + months: false + } + ) + ]; + + const rowPipeResult = rowPipe.transform(data, pivotConfig, cloneStrategy, expansionStates); + const rowStateResult = rowStatePipe.transform(rowPipeResult, pivotConfig, new Map(), true); + const dimensionValues = PivotGridFunctions.getDimensionValues(rowStateResult); + expect(dimensionValues).toEqual( + [ + { 'AllPeriods': 'All Periods' }, + { 'Years': '2021' }, + { 'Date': '01/01/2021' }, + { 'Date': '04/07/2021' }, + { 'Date': '12/08/2021' }, + { 'Years': '2019' }, + { 'Date': '01/05/2019' }, + { 'Years': '2020' }, + { 'Date': '01/06/2020' }, + { 'Date': '05/12/2020' }, + { 'Date': '02/19/2020' }] + ); + }); + + it('should generate correct levels when using predefined date dimension with other row dimensions', () => { + data = [ + { + ProductCategory: 'Clothing', UnitPrice: 12.81, SellerName: 'Stanley', + Country: 'Bulgaria', City: 'Sofia', Date: '01/01/2021', UnitsSold: 282 + }, + { + ProductCategory: 'Clothing', UnitPrice: 49.57, SellerName: 'Elisa', + Country: 'USA', City: 'New York', Date: '01/05/2019', UnitsSold: 296 + }, + { + ProductCategory: 'Bikes', UnitPrice: 3.56, SellerName: 'Lydia', + Country: 'Uruguay', City: 'Ciudad de la Costa', Date: '01/06/2020', UnitsSold: 68 + }, + { + ProductCategory: 'Accessories', UnitPrice: 85.58, SellerName: 'David', + Country: 'USA', City: 'New York', Date: '04/07/2021', UnitsSold: 293 + }, + { + ProductCategory: 'Components', UnitPrice: 18.13, SellerName: 'John', + Country: 'USA', City: 'New York', Date: '12/08/2021', UnitsSold: 240 + }, + { + ProductCategory: 'Clothing', UnitPrice: 68.33, SellerName: 'Larry', + Country: 'Uruguay', City: 'Ciudad de la Costa', Date: '05/12/2020', UnitsSold: 456 + }, + { + ProductCategory: 'Clothing', UnitPrice: 16.05, SellerName: 'Walter', + Country: 'Bulgaria', City: 'Plovdiv', Date: '02/19/2020', UnitsSold: 492 + }]; + pivotConfig.rows = [ + new IgxPivotDateDimension( + { + memberName: 'Date', + enabled: true + }, + { + months: false + } + ), + { + memberName: 'City', + enabled: true + }, + { + memberFunction: () => 'All', + memberName: 'AllProducts', + enabled: true, + childLevel: { + memberName: 'ProductCategory', + enabled: true + } + } + ]; + + const rowPipeResult = rowPipe.transform(data, pivotConfig, cloneStrategy, expansionStates); + const columnPipeResult = columnPipe.transform(rowPipeResult, pivotConfig, cloneStrategy, new Map()); + const rowStatePipeResult = rowStatePipe.transform(columnPipeResult, pivotConfig, expansionStates, true); + + const date_city_product = PivotGridFunctions.getDimensionValues(rowStatePipeResult); + expect(rowStatePipeResult.length).toEqual(37); + const allPeriodsRecords = date_city_product.filter(x => x['AllPeriods'] === 'All Periods'); + expect(allPeriodsRecords).toEqual([ + { AllPeriods: 'All Periods', City: 'Sofia', AllProducts: 'All' }, + { AllPeriods: 'All Periods', City: 'Sofia', ProductCategory: 'Clothing' }, + { AllPeriods: 'All Periods', City: 'New York', AllProducts: 'All' }, + { AllPeriods: 'All Periods', City: 'New York', ProductCategory: 'Accessories' }, + { AllPeriods: 'All Periods', City: 'New York', ProductCategory: 'Components' }, + { AllPeriods: 'All Periods', City: 'New York', ProductCategory: 'Clothing' }, + { AllPeriods: 'All Periods', City: 'Ciudad de la Costa', AllProducts: 'All' }, + { AllPeriods: 'All Periods', City: 'Ciudad de la Costa', ProductCategory: 'Bikes' }, + { AllPeriods: 'All Periods', City: 'Ciudad de la Costa', ProductCategory: 'Clothing' }, + { AllPeriods: 'All Periods', City: 'Plovdiv', AllProducts: 'All' }, + { AllPeriods: 'All Periods', City: 'Plovdiv', ProductCategory: 'Clothing' } + ]); + + const year2021Records = date_city_product.filter(x => x['Years'] === '2021'); + expect(year2021Records).toEqual([ + { Years: '2021', City: 'Sofia', AllProducts: 'All' }, + { Years: '2021', City: 'Sofia', ProductCategory: 'Clothing' }, + { Years: '2021', City: 'New York', AllProducts: 'All' }, + { Years: '2021', City: 'New York', ProductCategory: 'Accessories' }, + { Years: '2021', City: 'New York', ProductCategory: 'Components' } + ]); + + const date2021Records = date_city_product.filter(x => x['Date'] === '01/01/2021'); + expect(date2021Records).toEqual([ + { Date: '01/01/2021', City: 'Sofia', AllProducts: 'All' }, + { Date: '01/01/2021', City: 'Sofia', ProductCategory: 'Clothing' } + ]); + }); + it('should generate correct row data with 2 dimensions with varying depth.', () => { + // one dimension with 4 depth and one with 1 depth + pivotConfig.rows = [ + new IgxPivotDateDimension( + { + memberName: 'Date', + enabled: true + }, + { + months: true, + total: true + } + ), + { + memberName: 'ProductCategory', + enabled: true + }]; + + let rowPipeResult = rowPipe.transform(data, pivotConfig, cloneStrategy, expansionStates); + let columnPipeResult = columnPipe.transform(rowPipeResult, pivotConfig, cloneStrategy, new Map()); + let rowStatePipeResult = rowStatePipe.transform(columnPipeResult, pivotConfig, expansionStates, true); + expect(rowStatePipeResult.length).toBe(24); + + const date_product = PivotGridFunctions.getDimensionValues(rowStatePipeResult); + + expect(date_product).toEqual( + [ + { AllPeriods: 'All Periods', ProductCategory: 'Clothing' }, + { AllPeriods: 'All Periods', ProductCategory: 'Accessories' }, + { AllPeriods: 'All Periods', ProductCategory: 'Components' }, + { AllPeriods: 'All Periods', ProductCategory: 'Bikes' }, + { Years: '2021', ProductCategory: 'Clothing' }, + { Years: '2021', ProductCategory: 'Accessories' }, + { Years: '2021', ProductCategory: 'Components' }, + { Months: 'January', ProductCategory: 'Clothing' }, + { Date: '01/01/2021', ProductCategory: 'Clothing' }, + { Months: 'April', ProductCategory: 'Accessories' }, + { Date: '04/07/2021', ProductCategory: 'Accessories' }, + { Months: 'December', ProductCategory: 'Components' }, + { Date: '12/08/2021', ProductCategory: 'Components' }, + { Years: '2019', ProductCategory: 'Clothing' }, + { Months: 'January', ProductCategory: 'Clothing' }, + { Date: '01/05/2019', ProductCategory: 'Clothing' }, + { Years: '2020', ProductCategory: 'Bikes' }, + { Years: '2020', ProductCategory: 'Clothing' }, + { Months: 'January', ProductCategory: 'Bikes' }, + { Date: '01/06/2020', ProductCategory: 'Bikes' }, + { Months: 'May', ProductCategory: 'Clothing' }, + { Date: '05/12/2020', ProductCategory: 'Clothing' }, + { Months: 'February', ProductCategory: 'Clothing' }, + { Date: '02/19/2020', ProductCategory: 'Clothing' } + ] + ); + + pivotConfig.rows = [ + { + memberName: 'ProductCategory', + enabled: true + }, + new IgxPivotDateDimension( + { + memberName: 'Date', + enabled: true + }, + { + months: true, + total: true + } + ) + ]; + + rowPipeResult = rowPipe.transform(data, pivotConfig, cloneStrategy, expansionStates); + columnPipeResult = columnPipe.transform(rowPipeResult, pivotConfig, cloneStrategy, new Map()); + rowStatePipeResult = rowStatePipe.transform(columnPipeResult, pivotConfig, expansionStates, true); + expect(rowStatePipeResult.length).toBe(24); + const product_date = PivotGridFunctions.getDimensionValues(rowStatePipeResult); + + expect(product_date).toEqual( + [ + { ProductCategory: 'Clothing', AllPeriods: 'All Periods' }, + { ProductCategory: 'Clothing', Years: '2021' }, + { ProductCategory: 'Clothing', Months: 'January' }, + { ProductCategory: 'Clothing', Date: '01/01/2021' }, + { ProductCategory: 'Clothing', Years: '2019' }, + { ProductCategory: 'Clothing', Months: 'January' }, + { ProductCategory: 'Clothing', Date: '01/05/2019' }, + { ProductCategory: 'Clothing', Years: '2020' }, + { ProductCategory: 'Clothing', Months: 'May' }, + { ProductCategory: 'Clothing', Date: '05/12/2020' }, + { ProductCategory: 'Clothing', Months: 'February' }, + { ProductCategory: 'Clothing', Date: '02/19/2020' }, + { ProductCategory: 'Bikes', AllPeriods: 'All Periods' }, + { ProductCategory: 'Bikes', Years: '2020' }, + { ProductCategory: 'Bikes', Months: 'January' }, + { ProductCategory: 'Bikes', Date: '01/06/2020' }, + { ProductCategory: 'Accessories', AllPeriods: 'All Periods' }, + { ProductCategory: 'Accessories', Years: '2021' }, + { ProductCategory: 'Accessories', Months: 'April' }, + { ProductCategory: 'Accessories', Date: '04/07/2021' }, + { ProductCategory: 'Components', AllPeriods: 'All Periods' }, + { ProductCategory: 'Components', Years: '2021' }, + { ProductCategory: 'Components', Months: 'December' }, + { ProductCategory: 'Components', Date: '12/08/2021' } + ] + ); + }); + it('should generate correct row data with 3 dimensions with varying depth.', () => { + // one dimension with 3 depth, one with 2 and one with 1 + const dims = [ + new IgxPivotDateDimension( + { + memberName: 'Date', + enabled: true + }, + { + months: false, + total: true + } + ), + { + memberName: 'AllProduct', + memberFunction: () => 'All Products', + enabled: true, + childLevel: { + memberName: 'ProductCategory', + enabled: true + } + }, + { + memberName: 'SellerName', + enabled: true + }]; + pivotConfig.rows = [ + dims[0], + dims[1], + dims[2] + ]; + let rowPipeResult = rowPipe.transform(data, pivotConfig, cloneStrategy, expansionStates); + let columnPipeResult = columnPipe.transform(rowPipeResult, pivotConfig, cloneStrategy, new Map()); + let rowStatePipeResult = rowStatePipe.transform(columnPipeResult, pivotConfig, expansionStates, true); + + const date_prod_seller = PivotGridFunctions.getDimensionValues(rowStatePipeResult); + expect(rowStatePipeResult.length).toBe(42); + + const allPeriodsRecords = date_prod_seller.filter(x => x['AllPeriods'] === 'All Periods'); + expect(allPeriodsRecords).toEqual([ + { AllPeriods: 'All Periods', AllProduct: 'All Products', SellerName: 'Stanley' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', SellerName: 'Elisa' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', SellerName: 'Larry' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', SellerName: 'Walter' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', SellerName: 'David' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', SellerName: 'John' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', SellerName: 'Lydia' }, + { AllPeriods: 'All Periods', ProductCategory: 'Clothing', SellerName: 'Stanley' }, + { AllPeriods: 'All Periods', ProductCategory: 'Clothing', SellerName: 'Elisa' }, + { AllPeriods: 'All Periods', ProductCategory: 'Clothing', SellerName: 'Larry' }, + { AllPeriods: 'All Periods', ProductCategory: 'Clothing', SellerName: 'Walter' }, + { AllPeriods: 'All Periods', ProductCategory: 'Accessories', SellerName: 'David' }, + { AllPeriods: 'All Periods', ProductCategory: 'Components', SellerName: 'John' }, + { AllPeriods: 'All Periods', ProductCategory: 'Bikes', SellerName: 'Lydia' } + ]); + + const year2021Records = date_prod_seller.filter(x => x['Years'] === '2021'); + expect(year2021Records).toEqual([ + { Years: '2021', AllProduct: 'All Products', SellerName: 'Stanley' }, + { Years: '2021', AllProduct: 'All Products', SellerName: 'David' }, + { Years: '2021', AllProduct: 'All Products', SellerName: 'John' }, + { Years: '2021', ProductCategory: 'Clothing', SellerName: 'Stanley' }, + { Years: '2021', ProductCategory: 'Accessories', SellerName: 'David' }, + { Years: '2021', ProductCategory: 'Components', SellerName: 'John' } + ]); + + const date2021Records = date_prod_seller.filter(x => x['Date'] === '01/01/2021'); + expect(date2021Records).toEqual([ + { Date: '01/01/2021', AllProduct: 'All Products', SellerName: 'Stanley' }, + { Date: '01/01/2021', ProductCategory: 'Clothing', SellerName: 'Stanley' } + ]); + + pivotConfig.rows = [ + dims[1], + dims[0], + dims[2] + ]; + rowPipeResult = rowPipe.transform(data, pivotConfig, cloneStrategy, expansionStates); + columnPipeResult = columnPipe.transform(rowPipeResult, pivotConfig, cloneStrategy, new Map()); + rowStatePipeResult = rowStatePipe.transform(columnPipeResult, pivotConfig, expansionStates, true); + + const prod_date_seller = PivotGridFunctions.getDimensionValues(rowStatePipeResult); + expect(rowStatePipeResult.length).toBe(42); + const allProdsRecords = prod_date_seller.filter(x => x['AllProduct'] === 'All Products'); + expect(allProdsRecords).toEqual([ + { AllProduct: 'All Products', AllPeriods: 'All Periods', SellerName: 'Stanley' }, + { AllProduct: 'All Products', AllPeriods: 'All Periods', SellerName: 'David' }, + { AllProduct: 'All Products', AllPeriods: 'All Periods', SellerName: 'John' }, + { AllProduct: 'All Products', AllPeriods: 'All Periods', SellerName: 'Elisa' }, + { AllProduct: 'All Products', AllPeriods: 'All Periods', SellerName: 'Larry' }, + { AllProduct: 'All Products', AllPeriods: 'All Periods', SellerName: 'Walter' }, + { AllProduct: 'All Products', AllPeriods: 'All Periods', SellerName: 'Lydia' }, + { AllProduct: 'All Products', Years: '2021', SellerName: 'Stanley' }, + { AllProduct: 'All Products', Years: '2021', SellerName: 'David' }, + { AllProduct: 'All Products', Years: '2021', SellerName: 'John' }, + { AllProduct: 'All Products', Date: '01/01/2021', SellerName: 'Stanley' }, + { AllProduct: 'All Products', Date: '04/07/2021', SellerName: 'David' }, + { AllProduct: 'All Products', Date: '12/08/2021', SellerName: 'John' }, + { AllProduct: 'All Products', Years: '2019', SellerName: 'Elisa' }, + { AllProduct: 'All Products', Date: '01/05/2019', SellerName: 'Elisa' }, + { AllProduct: 'All Products', Years: '2020', SellerName: 'Larry' }, + { AllProduct: 'All Products', Years: '2020', SellerName: 'Walter' }, + { AllProduct: 'All Products', Years: '2020', SellerName: 'Lydia' }, + { AllProduct: 'All Products', Date: '05/12/2020', SellerName: 'Larry' }, + { AllProduct: 'All Products', Date: '02/19/2020', SellerName: 'Walter' }, + { AllProduct: 'All Products', Date: '01/06/2020', SellerName: 'Lydia' }, + ]); + + const clothingRecords = prod_date_seller.filter(x => x['ProductCategory'] === 'Clothing'); + expect(clothingRecords).toEqual([ + { ProductCategory: 'Clothing', AllPeriods: 'All Periods', SellerName: 'Stanley' }, + { ProductCategory: 'Clothing', AllPeriods: 'All Periods', SellerName: 'Elisa' }, + { ProductCategory: 'Clothing', AllPeriods: 'All Periods', SellerName: 'Larry' }, + { ProductCategory: 'Clothing', AllPeriods: 'All Periods', SellerName: 'Walter' }, + { ProductCategory: 'Clothing', Years: '2021', SellerName: 'Stanley' }, + { ProductCategory: 'Clothing', Date: '01/01/2021', SellerName: 'Stanley' }, + { ProductCategory: 'Clothing', Years: '2019', SellerName: 'Elisa' }, + { ProductCategory: 'Clothing', Date: '01/05/2019', SellerName: 'Elisa' }, + { ProductCategory: 'Clothing', Years: '2020', SellerName: 'Larry' }, + { ProductCategory: 'Clothing', Years: '2020', SellerName: 'Walter' }, + { ProductCategory: 'Clothing', Date: '05/12/2020', SellerName: 'Larry' }, + { ProductCategory: 'Clothing', Date: '02/19/2020', SellerName: 'Walter' } + ]); + + pivotConfig.rows = [ + dims[2], + dims[1], + dims[0] + ]; + rowPipeResult = rowPipe.transform(data, pivotConfig, cloneStrategy, expansionStates); + columnPipeResult = columnPipe.transform(rowPipeResult, pivotConfig, cloneStrategy, new Map()); + rowStatePipeResult = rowStatePipe.transform(columnPipeResult, pivotConfig, expansionStates, true); + expect(rowStatePipeResult.length).toBe(42); + const seller_prod_date = PivotGridFunctions.getDimensionValues(rowStatePipeResult); + const stanleyRecords = seller_prod_date.filter(x => x['SellerName'] === 'Stanley'); + expect(stanleyRecords).toEqual([ + { SellerName: 'Stanley', AllProduct: 'All Products', AllPeriods: 'All Periods' }, + { SellerName: 'Stanley', AllProduct: 'All Products', Years: '2021' }, + { SellerName: 'Stanley', AllProduct: 'All Products', Date: '01/01/2021' }, + { SellerName: 'Stanley', ProductCategory: 'Clothing', AllPeriods: 'All Periods' }, + { SellerName: 'Stanley', ProductCategory: 'Clothing', Years: '2021' }, + { SellerName: 'Stanley', ProductCategory: 'Clothing', Date: '01/01/2021' }, + ]); + }); + + it('should generate correct row data with 4 dimensions with varying depth.', () => { + + // 4 dimensions - depths 3, 2, 1, 1 + const dims = [ + new IgxPivotDateDimension( + { + memberName: 'Date', + enabled: true + }, + { + months: false, + total: true + } + ), + { + memberName: 'AllProduct', + memberFunction: () => 'All Products', + enabled: true, + childLevel: { + memberName: 'ProductCategory', + enabled: true + } + }, + { + memberName: 'Country', + enabled: true + }, + { + memberName: 'SellerName', + enabled: true + }]; + // Date, Product, Country, Seller + pivotConfig.rows = [ + dims[0], + dims[1], + dims[2], + dims[3] + ]; + + let rowPipeResult = rowPipe.transform(data, pivotConfig, cloneStrategy, expansionStates); + let columnPipeResult = columnPipe.transform(rowPipeResult, pivotConfig, cloneStrategy, new Map()); + let rowStatePipeResult = rowStatePipe.transform(columnPipeResult, pivotConfig, expansionStates, true); + + const date_prod_country_seller = PivotGridFunctions.getDimensionValues(rowStatePipeResult); + expect(rowStatePipeResult.length).toBe(42); + const allPeriodsData = date_prod_country_seller.filter(x => x['AllPeriods'] === 'All Periods'); + expect(allPeriodsData).toEqual([ + { AllPeriods: 'All Periods', AllProduct: 'All Products', Country: 'Bulgaria', SellerName: 'Stanley' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', Country: 'Bulgaria', SellerName: 'Walter' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', Country: 'USA', SellerName: 'Elisa' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', Country: 'USA', SellerName: 'David' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', Country: 'USA', SellerName: 'John' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', Country: 'Uruguay', SellerName: 'Larry' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', Country: 'Uruguay', SellerName: 'Lydia' }, + { AllPeriods: 'All Periods', ProductCategory: 'Clothing', Country: 'Bulgaria', SellerName: 'Stanley' }, + { AllPeriods: 'All Periods', ProductCategory: 'Clothing', Country: 'Bulgaria', SellerName: 'Walter' }, + { AllPeriods: 'All Periods', ProductCategory: 'Clothing', Country: 'USA', SellerName: 'Elisa' }, + { AllPeriods: 'All Periods', ProductCategory: 'Clothing', Country: 'Uruguay', SellerName: 'Larry' }, + { AllPeriods: 'All Periods', ProductCategory: 'Accessories', Country: 'USA', SellerName: 'David' }, + { AllPeriods: 'All Periods', ProductCategory: 'Components', Country: 'USA', SellerName: 'John' }, + { AllPeriods: 'All Periods', ProductCategory: 'Bikes', Country: 'Uruguay', SellerName: 'Lydia' } + ]); + + const year2021Records = date_prod_country_seller.filter(x => x['Years'] === '2021'); + expect(year2021Records).toEqual([ + { Years: '2021', AllProduct: 'All Products', Country: 'Bulgaria', SellerName: 'Stanley' }, + { Years: '2021', AllProduct: 'All Products', Country: 'USA', SellerName: 'David' }, + { Years: '2021', AllProduct: 'All Products', Country: 'USA', SellerName: 'John' }, + { Years: '2021', ProductCategory: 'Clothing', Country: 'Bulgaria', SellerName: 'Stanley' }, + { Years: '2021', ProductCategory: 'Accessories', Country: 'USA', SellerName: 'David' }, + { Years: '2021', ProductCategory: 'Components', Country: 'USA', SellerName: 'John' } + ]); + + const date2021Records = date_prod_country_seller.filter(x => x['Date'] === '01/01/2021'); + expect(date2021Records).toEqual([ + { Date: '01/01/2021', AllProduct: 'All Products', Country: 'Bulgaria', SellerName: 'Stanley' }, + { Date: '01/01/2021', ProductCategory: 'Clothing', Country: 'Bulgaria', SellerName: 'Stanley' } + ]); + + // Country, Product, Seller, Date + pivotConfig.rows = [ + dims[2], + dims[1], + dims[3], + dims[0] + ]; + + rowPipeResult = rowPipe.transform(data, pivotConfig, cloneStrategy, expansionStates); + columnPipeResult = columnPipe.transform(rowPipeResult, pivotConfig, cloneStrategy, new Map()); + rowStatePipeResult = rowStatePipe.transform(columnPipeResult, pivotConfig, expansionStates, true); + expect(rowStatePipeResult.length).toBe(42); + + const country_prod_seller_date = PivotGridFunctions.getDimensionValues(rowStatePipeResult); + const bgRecords = country_prod_seller_date.filter(x => x['Country'] === 'Bulgaria'); + expect(bgRecords).toEqual([ + { Country: 'Bulgaria', AllProduct: 'All Products', SellerName: 'Stanley', AllPeriods: 'All Periods' }, + { Country: 'Bulgaria', AllProduct: 'All Products', SellerName: 'Stanley', Years: '2021' }, + { Country: 'Bulgaria', AllProduct: 'All Products', SellerName: 'Stanley', Date: '01/01/2021' }, + { Country: 'Bulgaria', AllProduct: 'All Products', SellerName: 'Walter', AllPeriods: 'All Periods' }, + { Country: 'Bulgaria', AllProduct: 'All Products', SellerName: 'Walter', Years: '2020' }, + { Country: 'Bulgaria', AllProduct: 'All Products', SellerName: 'Walter', Date: '02/19/2020' }, + { Country: 'Bulgaria', ProductCategory: 'Clothing', SellerName: 'Stanley', AllPeriods: 'All Periods' }, + { Country: 'Bulgaria', ProductCategory: 'Clothing', SellerName: 'Stanley', Years: '2021' }, + { Country: 'Bulgaria', ProductCategory: 'Clothing', SellerName: 'Stanley', Date: '01/01/2021' }, + { Country: 'Bulgaria', ProductCategory: 'Clothing', SellerName: 'Walter', AllPeriods: 'All Periods' }, + { Country: 'Bulgaria', ProductCategory: 'Clothing', SellerName: 'Walter', Years: '2020' }, + { Country: 'Bulgaria', ProductCategory: 'Clothing', SellerName: 'Walter', Date: '02/19/2020' } + ]); + + // Product, Country, Date, Seller + pivotConfig.rows = [ + dims[1], + dims[2], + dims[0], + dims[3] + ]; + + rowPipeResult = rowPipe.transform(data, pivotConfig, cloneStrategy, expansionStates); + columnPipeResult = columnPipe.transform(rowPipeResult, pivotConfig, cloneStrategy, new Map()); + rowStatePipeResult = rowStatePipe.transform(columnPipeResult, pivotConfig, expansionStates, true); + expect(rowStatePipeResult.length).toBe(42); + + const prod_country_date_seller = PivotGridFunctions.getDimensionValues(rowStatePipeResult); + const allProdsRecords = prod_country_date_seller.filter(x => x['AllProduct'] === 'All Products'); + expect(allProdsRecords).toEqual([ + { AllProduct: 'All Products', Country: 'Bulgaria', AllPeriods: 'All Periods', SellerName: 'Stanley' }, + { AllProduct: 'All Products', Country: 'Bulgaria', AllPeriods: 'All Periods', SellerName: 'Walter' }, + { AllProduct: 'All Products', Country: 'Bulgaria', Years: '2021', SellerName: 'Stanley' }, + { AllProduct: 'All Products', Country: 'Bulgaria', Date: '01/01/2021', SellerName: 'Stanley' }, + { AllProduct: 'All Products', Country: 'Bulgaria', Years: '2020', SellerName: 'Walter' }, + { AllProduct: 'All Products', Country: 'Bulgaria', Date: '02/19/2020', SellerName: 'Walter' }, + { AllProduct: 'All Products', Country: 'USA', AllPeriods: 'All Periods', SellerName: 'Elisa' }, + { AllProduct: 'All Products', Country: 'USA', AllPeriods: 'All Periods', SellerName: 'David' }, + { AllProduct: 'All Products', Country: 'USA', AllPeriods: 'All Periods', SellerName: 'John' }, + { AllProduct: 'All Products', Country: 'USA', Years: '2019', SellerName: 'Elisa' }, + { AllProduct: 'All Products', Country: 'USA', Date: '01/05/2019', SellerName: 'Elisa' }, + { AllProduct: 'All Products', Country: 'USA', Years: '2021', SellerName: 'David' }, + { AllProduct: 'All Products', Country: 'USA', Years: '2021', SellerName: 'John' }, + { AllProduct: 'All Products', Country: 'USA', Date: '04/07/2021', SellerName: 'David' }, + { AllProduct: 'All Products', Country: 'USA', Date: '12/08/2021', SellerName: 'John' }, + { AllProduct: 'All Products', Country: 'Uruguay', AllPeriods: 'All Periods', SellerName: 'Larry' }, + { AllProduct: 'All Products', Country: 'Uruguay', AllPeriods: 'All Periods', SellerName: 'Lydia' }, + { AllProduct: 'All Products', Country: 'Uruguay', Years: '2020', SellerName: 'Larry' }, + { AllProduct: 'All Products', Country: 'Uruguay', Years: '2020', SellerName: 'Lydia' }, + { AllProduct: 'All Products', Country: 'Uruguay', Date: '05/12/2020', SellerName: 'Larry' }, + { AllProduct: 'All Products', Country: 'Uruguay', Date: '01/06/2020', SellerName: 'Lydia' }, + ]); + const clothingRecords = prod_country_date_seller.filter(x => x['ProductCategory'] === 'Clothing'); + expect(clothingRecords).toEqual([ + { ProductCategory: 'Clothing', Country: 'Bulgaria', AllPeriods: 'All Periods', SellerName: 'Stanley' }, + { ProductCategory: 'Clothing', Country: 'Bulgaria', AllPeriods: 'All Periods', SellerName: 'Walter' }, + { ProductCategory: 'Clothing', Country: 'Bulgaria', Years: '2021', SellerName: 'Stanley' }, + { ProductCategory: 'Clothing', Country: 'Bulgaria', Date: '01/01/2021', SellerName: 'Stanley' }, + { ProductCategory: 'Clothing', Country: 'Bulgaria', Years: '2020', SellerName: 'Walter' }, + { ProductCategory: 'Clothing', Country: 'Bulgaria', Date: '02/19/2020', SellerName: 'Walter' }, + { ProductCategory: 'Clothing', Country: 'USA', AllPeriods: 'All Periods', SellerName: 'Elisa' }, + { ProductCategory: 'Clothing', Country: 'USA', Years: '2019', SellerName: 'Elisa' }, + { ProductCategory: 'Clothing', Country: 'USA', Date: '01/05/2019', SellerName: 'Elisa' }, + { ProductCategory: 'Clothing', Country: 'Uruguay', AllPeriods: 'All Periods', SellerName: 'Larry' }, + { ProductCategory: 'Clothing', Country: 'Uruguay', Years: '2020', SellerName: 'Larry' }, + { ProductCategory: 'Clothing', Country: 'Uruguay', Date: '05/12/2020', SellerName: 'Larry' } + ]); + }); + + it('should generate correct row data with 5 dimensions with varying depth.', () => { + data = [ + { + ProductCategory: 'Clothing', UnitPrice: 12.81, SellerName: 'Stanley', + Country: 'Bulgaria', Date: '01/01/2021', UnitsSold: 282, Discontinued: false + }, + { ProductCategory: 'Clothing', UnitPrice: 49.57, SellerName: 'Elisa', Country: 'USA', Date: '01/05/2019', UnitsSold: 296, Discontinued: true }, + { ProductCategory: 'Bikes', UnitPrice: 3.56, SellerName: 'Lydia', Country: 'Uruguay', Date: '01/06/2020', UnitsSold: 68, Discontinued: true }, + { ProductCategory: 'Accessories', UnitPrice: 85.58, SellerName: 'David', Country: 'USA', Date: '04/07/2021', UnitsSold: 293, Discontinued: false }, + { ProductCategory: 'Components', UnitPrice: 18.13, SellerName: 'John', Country: 'USA', Date: '12/08/2021', UnitsSold: 240, Discontinued: false }, + { ProductCategory: 'Clothing', UnitPrice: 68.33, SellerName: 'Larry', Country: 'Uruguay', Date: '05/12/2020', UnitsSold: 456, Discontinued: true }, + { + ProductCategory: 'Clothing', UnitPrice: 16.05, SellerName: 'Walter', + Country: 'Bulgaria', Date: '02/19/2020', UnitsSold: 492, Discontinued: false + }]; + // 5 dimensions - depths 3, 2, 2, 1, 1 + const dims = [ + new IgxPivotDateDimension( + { + memberName: 'Date', + enabled: true + }, + { + months: false, + total: true + } + ), + { + memberName: 'AllProduct', + memberFunction: () => 'All Products', + enabled: true, + childLevel: { + memberName: 'ProductCategory', + enabled: true + } + }, + { + memberName: 'AllCountries', + memberFunction: () => 'All Countries', + enabled: true, + childLevel: { + memberName: 'Country', + enabled: true + } + }, + { + memberName: 'SellerName', + enabled: true + }, { + memberName: 'Discontinued', + enabled: true, + memberFunction: (rowData) => { + return rowData.Discontinued.toString(); + } + }]; + // Date, Product, Country, Seller, Discontinued + pivotConfig.rows = [ + dims[0], // Date + dims[1], // Product + dims[2], // Country + dims[3], // Seller + dims[4] // Discontinued + ]; + + let rowPipeResult = rowPipe.transform(data, pivotConfig, cloneStrategy, expansionStates); + let columnPipeResult = columnPipe.transform(rowPipeResult, pivotConfig, cloneStrategy, new Map()); + let rowStatePipeResult = rowStatePipe.transform(columnPipeResult, pivotConfig, expansionStates, true); + expect(rowStatePipeResult.length).toBe(84); + const prod_country_date_seller_discontinued = PivotGridFunctions.getDimensionValues(rowStatePipeResult); + const allPeriods_allProducts_records = prod_country_date_seller_discontinued.filter(x => x['AllPeriods'] === 'All Periods' && + x['AllProduct'] === 'All Products'); + expect(allPeriods_allProducts_records).toEqual( + [ + { AllPeriods: 'All Periods', AllProduct: 'All Products', AllCountries: 'All Countries', SellerName: 'Stanley', Discontinued: 'false' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', AllCountries: 'All Countries', SellerName: 'Walter', Discontinued: 'false' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', AllCountries: 'All Countries', SellerName: 'Elisa', Discontinued: 'true' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', AllCountries: 'All Countries', SellerName: 'David', Discontinued: 'false' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', AllCountries: 'All Countries', SellerName: 'John', Discontinued: 'false' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', AllCountries: 'All Countries', SellerName: 'Larry', Discontinued: 'true' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', AllCountries: 'All Countries', SellerName: 'Lydia', Discontinued: 'true' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', Country: 'Bulgaria', SellerName: 'Stanley', Discontinued: 'false' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', Country: 'Bulgaria', SellerName: 'Walter', Discontinued: 'false' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', Country: 'USA', SellerName: 'Elisa', Discontinued: 'true' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', Country: 'USA', SellerName: 'David', Discontinued: 'false' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', Country: 'USA', SellerName: 'John', Discontinued: 'false' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', Country: 'Uruguay', SellerName: 'Larry', Discontinued: 'true' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', Country: 'Uruguay', SellerName: 'Lydia', Discontinued: 'true' } + ] + ); + + const allPeriods_clothing_records = prod_country_date_seller_discontinued.filter(x => x['AllPeriods'] === 'All Periods' && x['ProductCategory'] === 'Clothing'); + expect(allPeriods_clothing_records).toEqual([ + { AllPeriods: 'All Periods', ProductCategory: 'Clothing', AllCountries: 'All Countries', SellerName: 'Stanley', Discontinued: 'false' }, + { AllPeriods: 'All Periods', ProductCategory: 'Clothing', AllCountries: 'All Countries', SellerName: 'Walter', Discontinued: 'false' }, + { AllPeriods: 'All Periods', ProductCategory: 'Clothing', AllCountries: 'All Countries', SellerName: 'Elisa', Discontinued: 'true' }, + { AllPeriods: 'All Periods', ProductCategory: 'Clothing', AllCountries: 'All Countries', SellerName: 'Larry', Discontinued: 'true' }, + { AllPeriods: 'All Periods', ProductCategory: 'Clothing', Country: 'Bulgaria', SellerName: 'Stanley', Discontinued: 'false' }, + { AllPeriods: 'All Periods', ProductCategory: 'Clothing', Country: 'Bulgaria', SellerName: 'Walter', Discontinued: 'false' }, + { AllPeriods: 'All Periods', ProductCategory: 'Clothing', Country: 'USA', SellerName: 'Elisa', Discontinued: 'true' }, + { AllPeriods: 'All Periods', ProductCategory: 'Clothing', Country: 'Uruguay', SellerName: 'Larry', Discontinued: 'true' } + ]); + const allPeriods_accessories_records = prod_country_date_seller_discontinued.filter(x => x['AllPeriods'] === 'All Periods' && x['ProductCategory'] === 'Accessories'); + expect(allPeriods_accessories_records).toEqual([ + { AllPeriods: 'All Periods', ProductCategory: 'Accessories', AllCountries: 'All Countries', SellerName: 'David', Discontinued: 'false' }, + { AllPeriods: 'All Periods', ProductCategory: 'Accessories', Country: 'USA', SellerName: 'David', Discontinued: 'false' } + ]); + const allPeriods_components_records = prod_country_date_seller_discontinued.filter(x => x['AllPeriods'] === 'All Periods' && x['ProductCategory'] === 'Components'); + expect(allPeriods_components_records).toEqual([ + { AllPeriods: 'All Periods', ProductCategory: 'Components', AllCountries: 'All Countries', SellerName: 'John', Discontinued: 'false' }, + { AllPeriods: 'All Periods', ProductCategory: 'Components', Country: 'USA', SellerName: 'John', Discontinued: 'false' } + ]); + const allPeriods_bikes_records = prod_country_date_seller_discontinued.filter(x => x['AllPeriods'] === 'All Periods' && x['ProductCategory'] === 'Bikes'); + expect(allPeriods_bikes_records).toEqual([ + { AllPeriods: 'All Periods', ProductCategory: 'Bikes', AllCountries: 'All Countries', SellerName: 'Lydia', Discontinued: 'true' }, + { AllPeriods: 'All Periods', ProductCategory: 'Bikes', Country: 'Uruguay', SellerName: 'Lydia', Discontinued: 'true' } + ]); + + // 2021 + const year_2021_records = prod_country_date_seller_discontinued.filter(x => x['Years'] === '2021'); + expect(year_2021_records).toEqual([ + { Years: '2021', AllProduct: 'All Products', AllCountries: 'All Countries', SellerName: 'Stanley', Discontinued: 'false' }, + { Years: '2021', AllProduct: 'All Products', AllCountries: 'All Countries', SellerName: 'David', Discontinued: 'false' }, + { Years: '2021', AllProduct: 'All Products', AllCountries: 'All Countries', SellerName: 'John', Discontinued: 'false' }, + { Years: '2021', AllProduct: 'All Products', Country: 'Bulgaria', SellerName: 'Stanley', Discontinued: 'false' }, + { Years: '2021', AllProduct: 'All Products', Country: 'USA', SellerName: 'David', Discontinued: 'false' }, + { Years: '2021', AllProduct: 'All Products', Country: 'USA', SellerName: 'John', Discontinued: 'false' }, + { Years: '2021', ProductCategory: 'Clothing', AllCountries: 'All Countries', SellerName: 'Stanley', Discontinued: 'false' }, + { Years: '2021', ProductCategory: 'Clothing', Country: 'Bulgaria', SellerName: 'Stanley', Discontinued: 'false' }, + { Years: '2021', ProductCategory: 'Accessories', AllCountries: 'All Countries', SellerName: 'David', Discontinued: 'false' }, + { Years: '2021', ProductCategory: 'Accessories', Country: 'USA', SellerName: 'David', Discontinued: 'false' }, + { Years: '2021', ProductCategory: 'Components', AllCountries: 'All Countries', SellerName: 'John', Discontinued: 'false' }, + { Years: '2021', ProductCategory: 'Components', Country: 'USA', SellerName: 'John', Discontinued: 'false' }, + ]); + + // 01/01/2021 + const date_2021_clothing_records = prod_country_date_seller_discontinued.filter(x => x['Date'] === '01/01/2021'); + expect(date_2021_clothing_records).toEqual([ + { Date: '01/01/2021', AllProduct: 'All Products', AllCountries: 'All Countries', SellerName: 'Stanley', Discontinued: 'false' }, + { Date: '01/01/2021', AllProduct: 'All Products', Country: 'Bulgaria', SellerName: 'Stanley', Discontinued: 'false' }, + { Date: '01/01/2021', ProductCategory: 'Clothing', AllCountries: 'All Countries', SellerName: 'Stanley', Discontinued: 'false' }, + { Date: '01/01/2021', ProductCategory: 'Clothing', Country: 'Bulgaria', SellerName: 'Stanley', Discontinued: 'false' } + ]); + // Discontinued, Date, Product, Country, Seller + pivotConfig.rows = [ + dims[4], + dims[0], + dims[1], + dims[2], + dims[3] + ]; + + rowPipeResult = rowPipe.transform(data, pivotConfig, cloneStrategy, expansionStates); + columnPipeResult = columnPipe.transform(rowPipeResult, pivotConfig, cloneStrategy, new Map()); + rowStatePipeResult = rowStatePipe.transform(columnPipeResult, pivotConfig, expansionStates, true); + expect(rowStatePipeResult.length).toBe(84); + const discontinued_prod_country_date_seller = PivotGridFunctions.getDimensionValues(rowStatePipeResult); + const ongoing_records = discontinued_prod_country_date_seller.filter(x => x['Discontinued'] === 'false'); + const discontinued_records = discontinued_prod_country_date_seller.filter(x => x['Discontinued'] === 'true'); + expect(discontinued_records.length).toBe(36); + expect(ongoing_records.length).toBe(48); + const ongoing_allPeriods = ongoing_records.filter(x => x['AllPeriods'] === 'All Periods'); + expect(ongoing_allPeriods).toEqual([ + { Discontinued: 'false', AllPeriods: 'All Periods', AllProduct: 'All Products', AllCountries: 'All Countries', SellerName: 'Stanley' }, + { Discontinued: 'false', AllPeriods: 'All Periods', AllProduct: 'All Products', AllCountries: 'All Countries', SellerName: 'Walter' }, + { Discontinued: 'false', AllPeriods: 'All Periods', AllProduct: 'All Products', AllCountries: 'All Countries', SellerName: 'David' }, + { Discontinued: 'false', AllPeriods: 'All Periods', AllProduct: 'All Products', AllCountries: 'All Countries', SellerName: 'John' }, + { Discontinued: 'false', AllPeriods: 'All Periods', AllProduct: 'All Products', Country: 'Bulgaria', SellerName: 'Stanley' }, + { Discontinued: 'false', AllPeriods: 'All Periods', AllProduct: 'All Products', Country: 'Bulgaria', SellerName: 'Walter' }, + { Discontinued: 'false', AllPeriods: 'All Periods', AllProduct: 'All Products', Country: 'USA', SellerName: 'David' }, + { Discontinued: 'false', AllPeriods: 'All Periods', AllProduct: 'All Products', Country: 'USA', SellerName: 'John' }, + { Discontinued: 'false', AllPeriods: 'All Periods', ProductCategory: 'Clothing', AllCountries: 'All Countries', SellerName: 'Stanley' }, + { Discontinued: 'false', AllPeriods: 'All Periods', ProductCategory: 'Clothing', AllCountries: 'All Countries', SellerName: 'Walter' }, + { Discontinued: 'false', AllPeriods: 'All Periods', ProductCategory: 'Clothing', Country: 'Bulgaria', SellerName: 'Stanley' }, + { Discontinued: 'false', AllPeriods: 'All Periods', ProductCategory: 'Clothing', Country: 'Bulgaria', SellerName: 'Walter' }, + { Discontinued: 'false', AllPeriods: 'All Periods', ProductCategory: 'Accessories', AllCountries: 'All Countries', SellerName: 'David' }, + { Discontinued: 'false', AllPeriods: 'All Periods', ProductCategory: 'Accessories', Country: 'USA', SellerName: 'David' }, + { Discontinued: 'false', AllPeriods: 'All Periods', ProductCategory: 'Components', AllCountries: 'All Countries', SellerName: 'John' }, + { Discontinued: 'false', AllPeriods: 'All Periods', ProductCategory: 'Components', Country: 'USA', SellerName: 'John' } + ]); + const ongoing_2021 = ongoing_records.filter(x => x['Years'] === '2021'); + expect(ongoing_2021).toEqual([ + { Discontinued: 'false', Years: '2021', AllProduct: 'All Products', AllCountries: 'All Countries', SellerName: 'Stanley' }, + { Discontinued: 'false', Years: '2021', AllProduct: 'All Products', AllCountries: 'All Countries', SellerName: 'David' }, + { Discontinued: 'false', Years: '2021', AllProduct: 'All Products', AllCountries: 'All Countries', SellerName: 'John' }, + { Discontinued: 'false', Years: '2021', AllProduct: 'All Products', Country: 'Bulgaria', SellerName: 'Stanley' }, + { Discontinued: 'false', Years: '2021', AllProduct: 'All Products', Country: 'USA', SellerName: 'David' }, + { Discontinued: 'false', Years: '2021', AllProduct: 'All Products', Country: 'USA', SellerName: 'John' }, + { Discontinued: 'false', Years: '2021', ProductCategory: 'Clothing', AllCountries: 'All Countries', SellerName: 'Stanley' }, + { Discontinued: 'false', Years: '2021', ProductCategory: 'Clothing', Country: 'Bulgaria', SellerName: 'Stanley' }, + { Discontinued: 'false', Years: '2021', ProductCategory: 'Accessories', AllCountries: 'All Countries', SellerName: 'David' }, + { Discontinued: 'false', Years: '2021', ProductCategory: 'Accessories', Country: 'USA', SellerName: 'David' }, + { Discontinued: 'false', Years: '2021', ProductCategory: 'Components', AllCountries: 'All Countries', SellerName: 'John' }, + { Discontinued: 'false', Years: '2021', ProductCategory: 'Components', Country: 'USA', SellerName: 'John' }, + ]); + + + // Seller, Country, Date, Product, Discontinued + pivotConfig.rows = [ + dims[3], // Seller + dims[2], // Country + dims[0], // Date + dims[1], // Product + dims[4] // Discontinued + ]; + + rowPipeResult = rowPipe.transform(data, pivotConfig, cloneStrategy, expansionStates); + columnPipeResult = columnPipe.transform(rowPipeResult, pivotConfig, cloneStrategy, new Map()); + rowStatePipeResult = rowStatePipe.transform(columnPipeResult, pivotConfig, expansionStates, true); + expect(rowStatePipeResult.length).toBe(84); + const seller_country_date_prod_disc = PivotGridFunctions.getDimensionValues(rowStatePipeResult); + const stanley_allCountries_allPeriods = seller_country_date_prod_disc.filter(x => x['SellerName'] === 'Stanley' && + x['AllCountries'] === 'All Countries' && x['AllPeriods'] === 'All Periods'); + expect(stanley_allCountries_allPeriods).toEqual([ + { SellerName: 'Stanley', AllCountries: 'All Countries', AllPeriods: 'All Periods', AllProduct: 'All Products', Discontinued: 'false' }, + { SellerName: 'Stanley', AllCountries: 'All Countries', AllPeriods: 'All Periods', ProductCategory: 'Clothing', Discontinued: 'false' } + ]); + + // TODO - check the rest of the 'AllCountries' fields here once issue: https://github.com/IgniteUI/igniteui-angular/issues/10662 is resolved. + + const stanley_allCountries_2021 = seller_country_date_prod_disc.filter(x => x['SellerName'] === 'Stanley' && + x['AllCountries'] === 'All Countries' && x['Years'] === '2021'); + expect(stanley_allCountries_2021).toEqual([ + { SellerName: 'Stanley', AllCountries: 'All Countries', Years: '2021', AllProduct: 'All Products', Discontinued: 'false' }, + { SellerName: 'Stanley', AllCountries: 'All Countries', Years: '2021', ProductCategory: 'Clothing', Discontinued: 'false' } + ]); + + // Date, Product, Discontinued, Countries, Seller + pivotConfig.rows = [ + dims[0], // Date + dims[1], // Product + dims[4], // Discontinued + dims[2], // Country + dims[3], // Seller + ]; + + rowPipeResult = rowPipe.transform(data, pivotConfig, cloneStrategy, expansionStates); + columnPipeResult = columnPipe.transform(rowPipeResult, pivotConfig, cloneStrategy, new Map()); + rowStatePipeResult = rowStatePipe.transform(columnPipeResult, pivotConfig, expansionStates, true); + expect(rowStatePipeResult.length).toBe(84); + const date_prod_disc_seller = PivotGridFunctions.getDimensionValues(rowStatePipeResult); + + const date_allPeriods_allProducts_records = date_prod_disc_seller.filter(x => x['AllPeriods'] === 'All Periods' && x['AllProduct'] === 'All Products'); + expect(date_allPeriods_allProducts_records).toEqual([ + { AllPeriods: 'All Periods', AllProduct: 'All Products', Discontinued: 'false', AllCountries: 'All Countries', SellerName: 'Stanley' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', Discontinued: 'false', AllCountries: 'All Countries', SellerName: 'Walter' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', Discontinued: 'false', AllCountries: 'All Countries', SellerName: 'David' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', Discontinued: 'false', AllCountries: 'All Countries', SellerName: 'John' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', Discontinued: 'false', Country: 'Bulgaria', SellerName: 'Stanley' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', Discontinued: 'false', Country: 'Bulgaria', SellerName: 'Walter' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', Discontinued: 'false', Country: 'USA', SellerName: 'David' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', Discontinued: 'false', Country: 'USA', SellerName: 'John' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', Discontinued: 'true', AllCountries: 'All Countries', SellerName: 'Elisa' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', Discontinued: 'true', AllCountries: 'All Countries', SellerName: 'Larry' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', Discontinued: 'true', AllCountries: 'All Countries', SellerName: 'Lydia' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', Discontinued: 'true', Country: 'USA', SellerName: 'Elisa' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', Discontinued: 'true', Country: 'Uruguay', SellerName: 'Larry' }, + { AllPeriods: 'All Periods', AllProduct: 'All Products', Discontinued: 'true', Country: 'Uruguay', SellerName: 'Lydia' } + ]); + const date_2021_allProducts_records = date_prod_disc_seller.filter(x => x['Years'] === '2021' && x['AllProduct'] === 'All Products'); + expect(date_2021_allProducts_records).toEqual([ + { Years: '2021', AllProduct: 'All Products', Discontinued: 'false', AllCountries: 'All Countries', SellerName: 'Stanley' }, + { Years: '2021', AllProduct: 'All Products', Discontinued: 'false', AllCountries: 'All Countries', SellerName: 'David' }, + { Years: '2021', AllProduct: 'All Products', Discontinued: 'false', AllCountries: 'All Countries', SellerName: 'John' }, + { Years: '2021', AllProduct: 'All Products', Discontinued: 'false', Country: 'Bulgaria', SellerName: 'Stanley' }, + { Years: '2021', AllProduct: 'All Products', Discontinued: 'false', Country: 'USA', SellerName: 'David' }, + { Years: '2021', AllProduct: 'All Products', Discontinued: 'false', Country: 'USA', SellerName: 'John' } + ]); + }); + // // automation for https://github.com/IgniteUI/igniteui-angular/issues/10545 + it('should generate last dimension values for all records.', () => { + data = [ + { + ProductCategory: 'Clothing', UnitPrice: 12.81, SellerName: 'Stanley', + Country: 'Bulgaria', City: 'Sofia', Date: '01/01/2021', UnitsSold: 282 + }, + { + ProductCategory: 'Clothing', UnitPrice: 49.57, SellerName: 'Elisa', + Country: 'USA', City: 'New York', Date: '01/05/2019', UnitsSold: 296 + }, + { + ProductCategory: 'Bikes', UnitPrice: 3.56, SellerName: 'Lydia', + Country: 'Uruguay', City: 'Ciudad de la Costa', Date: '01/06/2020', UnitsSold: 68 + }, + { + ProductCategory: 'Accessories', UnitPrice: 85.58, SellerName: 'David', + Country: 'USA', City: 'New York', Date: '04/07/2021', UnitsSold: 293 + }, + { + ProductCategory: 'Components', UnitPrice: 18.13, SellerName: 'John', + Country: 'USA', City: 'New York', Date: '12/08/2021', UnitsSold: 240 + }, + { + ProductCategory: 'Clothing', UnitPrice: 68.33, SellerName: 'Larry', + Country: 'Uruguay', City: 'Ciudad de la Costa', Date: '05/12/2020', UnitsSold: 456 + }, + { + ProductCategory: 'Clothing', UnitPrice: 16.05, SellerName: 'Walter', + Country: 'Bulgaria', City: 'Plovdiv', Date: '02/19/2020', UnitsSold: 492 + }]; + pivotConfig.columns = [{ + memberName: 'Country', + enabled: true + }]; + pivotConfig.rows = [ + new IgxPivotDateDimension( + { + memberName: 'Date', + enabled: true + }, + { + months: false + } + ), + { + memberName: 'City', + enabled: true + }, + { + memberFunction: () => 'All', + memberName: 'AllProducts', + enabled: true, + childLevel: { + memberFunction: (recData) => recData.ProductCategory, + memberName: 'ProductCategory', + enabled: true + } + }, + { + memberName: 'SellerName', + enabled: true + } + ]; + const rowPipeResult = rowPipe.transform(data, pivotConfig, cloneStrategy, expansionStates); + const columnPipeResult = columnPipe.transform(rowPipeResult, pivotConfig, cloneStrategy, new Map()); + const rowStatePipeResult = rowStatePipe.transform(columnPipeResult, pivotConfig, expansionStates, true); + const dimensionValues = PivotGridFunctions.getDimensionValues(rowStatePipeResult); + const sellers = dimensionValues.map(x => x['SellerName']); + // there should be no empty values. + expect(sellers.filter(x => x === undefined).length).toBe(0); + }); + + // // automation for https://github.com/IgniteUI/igniteui-angular/issues/10662 + it('should retain processed values for last dimension when bound to complex object.', () => { + data = DATA; + pivotConfig.rows = [ + { + memberName: 'Date', + enabled: true, + }, + { + memberName: 'AllProduct', + memberFunction: () => 'All Products', + enabled: true, + childLevel: + { + + memberName: 'Product', + memberFunction: (recData) => recData.Product.Name, + enabled: true + } + }, + { + memberName: 'AllSeller', + memberFunction: () => 'All Sellers', + enabled: true, + childLevel: + { + memberName: 'Seller', + memberFunction: (recData) => recData.Seller.Name, + enabled: true, + }, + }, + ]; + + const rowPipeResult = rowPipe.transform(data, pivotConfig, cloneStrategy, expansionStates); + const columnPipeResult = columnPipe.transform(rowPipeResult, pivotConfig, cloneStrategy, new Map()); + const rowStatePipeResult = rowStatePipe.transform(columnPipeResult, pivotConfig, expansionStates, true); + const dimensionValues = PivotGridFunctions.getDimensionValues(rowStatePipeResult); + const res = dimensionValues.filter(x => x['AllSeller'] === undefined).map(x => x['Seller']); + // all values should be strings as the result of the processed member function is string. + expect(res.filter(x => typeof x !== 'string').length).toBe(0); + }); + + it('should generate correct values for IgxPivotDateDimension with quarters enabled.', () => { + data = [ + { Date: '01/19/2020', UnitsSold: 492 }, + { Date: '02/19/2020', UnitsSold: 200 }, + { Date: '03/19/2020', UnitsSold: 178 }, + { Date: '04/19/2020', UnitsSold: 456 }, + { Date: '05/19/2020', UnitsSold: 456 }, + { Date: '06/19/2020', UnitsSold: 0 }, + { Date: '07/19/2020', UnitsSold: 500 }, + { Date: '08/19/2020', UnitsSold: 100 }, + { Date: '09/19/2020', UnitsSold: 300 }, + { Date: '10/19/2020', UnitsSold: 100 }, + { Date: '11/19/2020', UnitsSold: 200 }, + { Date: '12/19/2020', UnitsSold: 456 } + ]; + pivotConfig.columns = []; + pivotConfig.rows = [ + new IgxPivotDateDimension( + { + memberName: 'Date', + enabled: true + }, + { + months: true, + quarters: true, + fullDate: false + } + ) + ] + const rowPipeResult = rowPipe.transform(data, pivotConfig, cloneStrategy, expansionStates); + const columnPipeResult = columnPipe.transform(rowPipeResult, pivotConfig, cloneStrategy, new Map()); + const rowStatePipeResult = rowStatePipe.transform(columnPipeResult, pivotConfig, expansionStates, true); + + const dateData = PivotGridFunctions.getDimensionValues(rowStatePipeResult); + expect(dateData).toEqual([ + { 'AllPeriods': 'All Periods' }, + { 'Years': '2020' }, + { 'Quarters': 'Q1' }, + { 'Months': 'January' }, + { 'Months': 'February' }, + { 'Months': 'March' }, + { 'Quarters': 'Q2' }, + { 'Months': 'April' }, + { 'Months': 'May' }, + { 'Months': 'June' }, + { 'Quarters': 'Q3' }, + { 'Months': 'July' }, + { 'Months': 'August' }, + { 'Months': 'September' }, + { 'Quarters': 'Q4' }, + { 'Months': 'October' }, + { 'Months': 'November' }, + { 'Months': 'December' } + ]); + }); +}); diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.pipes.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.pipes.ts new file mode 100644 index 00000000000..95c14a58183 --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.pipes.ts @@ -0,0 +1,553 @@ +import { Pipe, PipeTransform, inject } from '@angular/core'; +import { + DEFAULT_PIVOT_KEYS, + DimensionValuesFilteringStrategy, + GridBaseAPIService, + GridType, + IGX_GRID_BASE, + IPivotConfiguration, + IPivotDimension, + IPivotGridColumn, + IPivotGridGroupRecord, + IPivotGridHorizontalGroup, + IPivotGridRecord, + IPivotKeys, + IPivotValue, + PivotColumnDimensionsStrategy, + PivotGridType, + PivotRowDimensionsStrategy, + PivotUtil +} from 'igniteui-angular/grids/core'; +import { cloneArray, columnFieldPath, DataUtil, FilteringExpressionsTree, FilterUtil, IDataCloneStrategy, IFilteringExpressionsTree, IFilteringStrategy, IGridSortingStrategy, ISortingExpression, resolveNestedPath } from 'igniteui-angular/core'; +import { IgxGridBaseDirective } from 'igniteui-angular/grids/grid'; +import { PivotSortUtil } from './pivot-sort-util'; +import { DefaultPivotGridRecordSortingStrategy } from './pivot-sort-strategy'; + +/** + * @hidden + */ +@Pipe({ + name: 'pivotGridRow', + pure: true, + standalone: true +}) +export class IgxPivotRowPipe implements PipeTransform { + private grid = inject(IGX_GRID_BASE); + + + public transform( + collection: any, + config: IPivotConfiguration, + cloneStrategy: IDataCloneStrategy, + _: Map, + _pipeTrigger?: number, + __? + ): IPivotGridRecord[] { + const pivotKeys = config.pivotKeys || DEFAULT_PIVOT_KEYS; + const enabledRows = config.rows?.filter(x => x.enabled) || []; + const enabledColumns = config.columns?.filter(x => x.enabled) || []; + const enabledValues = config.values?.filter(x => x.enabled) || []; + if (enabledRows.length === 0 && enabledColumns.length === 0 && enabledValues.length === 0) { + // nothing to group and aggregate by ... + return []; + } + const rowStrategy = config.rowStrategy || PivotRowDimensionsStrategy.instance(); + const data = cloneArray(collection, true); + return rowStrategy.process(data, enabledRows, config.values, cloneStrategy, pivotKeys); + } +} + +/** + * @hidden + * Transforms generic array data into IPivotGridRecord[] + */ +@Pipe({ + name: 'pivotGridAutoTransform', + pure: true, + standalone: true +}) +export class IgxPivotAutoTransform implements PipeTransform { + public transform( + collection: any[], + config: IPivotConfiguration, + _pipeTrigger?: number, + __?, + ): IPivotGridRecord[] { + let needsTransformation = false; + if (collection.length > 0) { + needsTransformation = !this.isPivotRecord(collection[0]); + } + + if (!needsTransformation) return collection; + + const res = this.processCollectionToPivotRecord(config, collection); + return res; + } + + protected isPivotRecord(arg: IPivotGridRecord): arg is IPivotGridRecord { + return !!(arg as IPivotGridRecord).aggregationValues; + } + + protected processCollectionToPivotRecord(config: IPivotConfiguration, collection: any[]): IPivotGridRecord[] { + const pivotKeys: IPivotKeys = config.pivotKeys || DEFAULT_PIVOT_KEYS; + const enabledRows = config.rows.filter(x => x.enabled); + const allFlat: IPivotDimension[] = PivotUtil.flatten(enabledRows); + const result: IPivotGridRecord[] = []; + for (const rec of collection) { + const pivotRec: IPivotGridRecord = { + dimensionValues: new Map(), + aggregationValues: new Map(), + children: new Map(), + dimensions: [] + }; + const keys = Object.keys(rec) + for (const key of keys) { + const dim = allFlat.find(x => x.memberName === key); + if (dim) { + //field has matching dimension + pivotRec.dimensions.push(dim); + pivotRec.dimensionValues.set(key, rec[key]); + } else if (key.indexOf(pivotKeys.rowDimensionSeparator + pivotKeys.records) !== -1) { + // field that contains child collection + const dimKey = key.slice(0, key.indexOf(pivotKeys.rowDimensionSeparator + pivotKeys.records)); + const childData = rec[key]; + const childPivotData = this.processCollectionToPivotRecord(config, childData); + pivotRec.children.set(dimKey, childPivotData); + } else { + // an aggregation + pivotRec.aggregationValues.set(key, rec[key]); + } + } + const flattened = PivotUtil.flatten(config.rows); + pivotRec.dimensions.sort((x, y) => flattened.indexOf(x) - flattened.indexOf(y)); + result.push(pivotRec); + } + return result; + } + +} + +/** + * @hidden + */ +@Pipe({ + name: 'pivotGridRowExpansion', + pure: true, + standalone: true +}) +export class IgxPivotRowExpansionPipe implements PipeTransform { + private grid = inject(IGX_GRID_BASE); + + + public transform( + collection: IPivotGridRecord[], + config: IPivotConfiguration, + expansionStates: Map, + defaultExpand: boolean, + _pipeTrigger?: number, + __?, + ): IPivotGridRecord[] { + const enabledRows = config.rows?.filter(x => x.enabled) || []; + const data = collection ? cloneArray(collection, true) : []; + const horizontalRowDimensions = []; + for (const row of enabledRows) { + if (this.grid?.hasHorizontalLayout) { + PivotUtil.flattenGroupsHorizontally( + data, + row, + expansionStates, + defaultExpand, + horizontalRowDimensions, + this.grid.pivotUI.horizontalSummariesPosition + ); + } else { + PivotUtil.flattenGroups(data, row, expansionStates, defaultExpand); + } + } + + let finalData = data; + if (this.grid?.hasHorizontalLayout) { + const allRowDims = PivotUtil.flatten(this.grid.rowDimensions); + this.grid.visibleRowDimensions = allRowDims.filter((rowDim) => horizontalRowDimensions.some(targetDim => targetDim.memberName === rowDim.memberName)); + } else { + if (this.grid) { + this.grid.visibleRowDimensions = enabledRows; + } + finalData = enabledRows.length > 0 ? + finalData.filter(x => x.dimensions.length === enabledRows.length) : finalData; + } + + if (this.grid) { + this.grid.setFilteredSortedData(finalData, false); + } + return finalData; + } +} + +/** + * @hidden + */ +@Pipe({ + name: 'pivotGridCellMerging', + pure: true, + standalone: true +}) +export class IgxPivotCellMergingPipe implements PipeTransform { + private grid = inject(IGX_GRID_BASE); + + public transform( + collection: IPivotGridRecord[], + config: IPivotConfiguration, + dim: IPivotDimension, + _pipeTrigger?: number + ): IPivotGridGroupRecord[] { + if (collection.length === 0 || config.rows.length === 0) return collection; + const data: IPivotGridGroupRecord[] = collection ? cloneArray(collection, true) : []; + const res: IPivotGridGroupRecord[] = []; + + let groupData: IPivotGridGroupRecord[] = []; + let prevId; + const enabledRows = this.grid.hasHorizontalLayout ? (this.grid as any).visibleRowDimensions : config.rows?.filter(x => x.enabled); + const dimIndex = enabledRows.indexOf(dim); + for (const rec of data) { + let currentDim; + if (this.grid.hasHorizontalLayout) { + currentDim = dim; + rec.dimensions = enabledRows; + } else { + currentDim = rec.dimensions[dimIndex]; + } + + const id = PivotUtil.getRecordKey(rec, currentDim); + if (groupData.length > 0 && prevId !== id) { + const h = groupData.length > 1 ? groupData.length * this.grid.renderedRowHeight : undefined; + groupData[0].height = h; + groupData[0].rowSpan = groupData.length; + res.push(groupData[0]); + groupData = []; + } + groupData.push(rec); + prevId = id; + } + if (groupData.length > 0) { + const h = groupData.length > 1 ? groupData.length * this.grid.rowHeight + (groupData.length - 1) + 1 : undefined; + groupData[0].height = h; + groupData[0].rowSpan = groupData.length; + res.push(groupData[0]); + } + return res; + } +} + +/** + * @hidden + */ +@Pipe({ + name: "pivotGridHorizontalRowGrouping", + standalone: true +}) +export class IgxPivotGridHorizontalRowGrouping implements PipeTransform { + private grid = inject(IGX_GRID_BASE); + + public transform( + collection: IPivotGridRecord[], + config: IPivotConfiguration, + _pipeTrigger?: number, + _regroupTrigger?: number + ): IPivotGridRecord[][] { + if (collection.length === 0 || config.rows.length === 0) return null; + const data: IPivotGridRecord[] = collection ? cloneArray(collection, true) : []; + const res: IPivotGridRecord[][] = []; + + const groupDim = config.rows.filter(dim => dim.enabled)[0]; + let curGroup = []; + let curGroupValue = data[0].dimensionValues.get(groupDim.memberName); + for (const [index, curRec] of data.entries()) { + curRec.dataIndex = index; + const curRecValue = curRec.dimensionValues.get(groupDim.memberName); + if (curGroup.length === 0 || curRecValue === curGroupValue) { + curGroup.push(curRec); + } else { + curGroup["height"] = this.grid.renderedRowHeight * curGroup.length; + res.push(curGroup); + curGroup = [curRec]; + curGroupValue = curRecValue; + } + } + res.push(curGroup); + + return res; + } +} + +/** + * @hidden + */ +@Pipe({ + name: "pivotGridHorizontalRowCellMerging", + standalone: true +}) +export class IgxPivotGridHorizontalRowCellMerging implements PipeTransform { + private grid = inject(IGX_GRID_BASE); + + public transform( + collection: IPivotGridRecord[], + config: IPivotConfiguration, + _pipeTrigger?: number + ): IPivotGridHorizontalGroup[] { + if (collection.length === 0 || config.rows.length === 0) return [{ + colStart: 1, + colSpan: 1, + rowStart: 1, + rowSpan: 1, + records: collection + }]; + const data: IPivotGridRecord[] = collection ? cloneArray(collection, true) : []; + const res: IPivotGridHorizontalGroup[] = []; + + // Merge vertically for each row dimension. + const verticalMergeGroups: IPivotGridHorizontalGroup[][] = [...data.map(_ => [])]; + for (let dimIndex = 0; dimIndex < this.grid.visibleRowDimensions.length; dimIndex++) { + const curDim = this.grid.visibleRowDimensions[dimIndex]; + let curGroup: IPivotGridHorizontalGroup = { + colStart: dimIndex + 1, + colSpan: 1, + rowStart: 1, + rowSpan: 1, + value: data[0].dimensionValues.get(curDim.memberName), + rootDimension: curDim, + dimensions: [curDim], + records: [data[0]] + }; + for (let i = 1; i < data.length; i++) { + const curRec = data[i]; + const curRecValue = curRec.dimensionValues.get(curDim.memberName); + const previousRowCell = verticalMergeGroups[i][verticalMergeGroups[i].length - 1]; + if (curRecValue === curGroup.value && !previousRowCell) { + // If previousRowCell is non existing, its merged so we can push in this vertigal group as well. + curGroup.rowSpan++; + curGroup.records.push(curRec); + } else { + verticalMergeGroups[curGroup.rowStart - 1].push(curGroup); + curGroup = { + colStart: dimIndex + 1, + colSpan: 1, + rowStart: curGroup.rowStart + curGroup.rowSpan, + rowSpan: 1, + value: curRec.dimensionValues.get(curDim.memberName), + rootDimension: curDim, + dimensions: [curDim], + records: [curRec] + }; + } + } + + verticalMergeGroups[curGroup.rowStart - 1].push(curGroup); + } + + // Merge rows in a single array + const sortedGroups = verticalMergeGroups.reduce((prev, cur) => prev.concat(...cur), []); + + // Horizontally merge any groups that can be merged or have been + res.push(sortedGroups[0]); + let prevGroup = sortedGroups[0]; + for (let i = 1; i < sortedGroups.length; i++) { + const curGroup = sortedGroups[i]; + if (curGroup.value && prevGroup.value !== curGroup.value) { + prevGroup = curGroup; + res.push(curGroup); + } else { + prevGroup.dimensions.push(curGroup.rootDimension); + prevGroup.colSpan++; + } + } + + return res; + } +} + +/** + * @hidden + */ +@Pipe({ + name: 'pivotGridColumn', + pure: true, + standalone: true +}) +export class IgxPivotColumnPipe implements PipeTransform { + + public transform( + collection: IPivotGridRecord[], + config: IPivotConfiguration, + cloneStrategy: IDataCloneStrategy, + _: Map, + _pipeTrigger?: number, + __? + ): IPivotGridRecord[] { + const pivotKeys = config.pivotKeys || DEFAULT_PIVOT_KEYS; + const enabledColumns = config.columns?.filter(x => x.enabled) || []; + const enabledValues = config.values?.filter(x => x.enabled) || []; + + const colStrategy = config.columnStrategy || PivotColumnDimensionsStrategy.instance(); + const data = cloneArray(collection, true); + return colStrategy.process(data, enabledColumns, enabledValues, cloneStrategy, pivotKeys); + } +} + +/** + * @hidden + */ +@Pipe({ + name: 'pivotGridFilter', + pure: true, + standalone: true +}) +export class IgxPivotGridFilterPipe implements PipeTransform { + private gridAPI = inject>(GridBaseAPIService); + + public transform(collection: any[], + config: IPivotConfiguration, + filterStrategy: IFilteringStrategy, + advancedExpressionsTree: IFilteringExpressionsTree, + _filterPipeTrigger: number, + _pipeTrigger: number): any[] { + const expressionsTree = PivotUtil.buildExpressionTree(config); + + const state = { + expressionsTree, + strategy: filterStrategy || new DimensionValuesFilteringStrategy(), + advancedExpressionsTree + }; + + if (FilteringExpressionsTree.empty(state.expressionsTree) && FilteringExpressionsTree.empty(state.advancedExpressionsTree)) { + return collection; + } + + const result = FilterUtil.filter(cloneArray(collection, true), state, this.gridAPI.grid); + + return result; + } +} + + +/** + * @hidden + */ +@Pipe({ + name: 'pivotGridColumnSort', + pure: true, + standalone: true +}) +export class IgxPivotGridColumnSortingPipe implements PipeTransform { + public transform( + collection: IPivotGridRecord[], + expressions: ISortingExpression[], + sorting: IGridSortingStrategy, + _pipeTrigger: number + ): IPivotGridRecord[] { + let result: IPivotGridRecord[]; + + if (!expressions.length) { + result = collection; + } else { + for (const expr of expressions) { + expr.strategy = DefaultPivotGridRecordSortingStrategy.instance(); + } + result = PivotUtil.sort(cloneArray(collection), expressions, sorting); + } + return result; + } +} + +/** + * @hidden + */ +@Pipe({ + name: 'pivotGridSort', + pure: true, + standalone: true +}) +export class IgxPivotGridSortingPipe implements PipeTransform { + private gridAPI = inject>(GridBaseAPIService); + + public transform(collection: any[], config: IPivotConfiguration, sorting: IGridSortingStrategy, _pipeTrigger: number): any[] { + let result: any[]; + const allDimensions = config.rows || []; + const enabledDimensions = allDimensions.filter(x => x && x.enabled); + const expressions = PivotSortUtil.generateDimensionSortingExpressions(enabledDimensions); + if (!expressions.length) { + result = collection; + } else { + result = DataUtil.sort(cloneArray(collection), expressions, sorting, this.gridAPI.grid); + } + + return result; + } +} + +/** + * @hidden + */ +@Pipe({ + name: "filterPivotItems", + standalone: true +}) +export class IgxFilterPivotItemsPipe implements PipeTransform { + public transform( + collection: (IPivotDimension | IPivotValue)[], + filterCriteria: string, + _pipeTrigger: number + ): any[] { + if (!collection) { + return collection; + } + let copy = collection.slice(0); + if (filterCriteria && filterCriteria.length > 0) { + const filterFunc = (c) => { + const filterText = c.member || c.memberName; + if (!filterText) { + return false; + } + return ( + filterText + .toLocaleLowerCase() + .indexOf(filterCriteria.toLocaleLowerCase()) >= 0 || + (c.children?.some(filterFunc) ?? false) + ); + }; + copy = collection.filter(filterFunc); + } + return copy; + } +} + +export interface GridStyleCSSProperty { + [prop: string]: any; +} + +@Pipe({ + name: 'igxPivotCellStyleClasses', + standalone: true +}) +export class IgxPivotGridCellStyleClassesPipe implements PipeTransform { + + public transform(cssClasses: GridStyleCSSProperty, _: any, rowData: IPivotGridRecord, columnData: IPivotGridColumn, index: number, __: number): string { + if (!cssClasses) { + return ''; + } + + const result = []; + const pathParts = columnFieldPath(columnData.field); + + for (const cssClass of Object.keys(cssClasses)) { + const callbackOrValue = cssClasses[cssClass]; + const apply = typeof callbackOrValue === 'function' ? + callbackOrValue(rowData, columnData, resolveNestedPath(rowData, pathParts), index) : callbackOrValue; + if (apply) { + result.push(cssClass); + } + } + + return result.join(' '); + } +} diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.spec.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.spec.ts new file mode 100644 index 00000000000..9202231dc96 --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.spec.ts @@ -0,0 +1,3502 @@ +import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { FilteringExpressionsTree, FilteringLogic, GridColumnDataType, IgxStringFilteringOperand, ISortingExpression, ɵSize, SortingDirection } from 'igniteui-angular/core'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxChipComponent, IgxChipsAreaComponent } from 'igniteui-angular/chips'; +import { DefaultPivotSortingStrategy } from 'igniteui-angular/grids/pivot-grid'; +import { DimensionValuesFilteringStrategy, IgxGridNavigationService, IgxPivotDateAggregate, IgxPivotDateDimension, IgxPivotNumericAggregate, NoopPivotDimensionsStrategy } from 'igniteui-angular/grids/core'; +import { GridFunctions, GridSelectionFunctions } from '../../../test-utils/grid-functions.spec'; +import { PivotGridFunctions } from '../../../test-utils/pivot-grid-functions.spec'; +import { IgxPivotGridFlexContainerComponent, IgxPivotGridTestBaseComponent, IgxPivotGridTestComplexHierarchyComponent, IgxTotalSaleAggregate } from '../../../test-utils/pivot-grid-samples.spec'; +import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; +import { IPivotGridColumn, IPivotGridRecord, PivotDimensionType, PivotRowLayoutType, PivotSummaryPosition } from '../../core/src/pivot-grid.interface'; +import { IgxPivotHeaderRowComponent } from './pivot-header-row.component'; +import { IgxPivotRowDimensionHeaderComponent } from './pivot-row-dimension-header.component'; +import { IgxPivotRowComponent } from './pivot-row.component'; +import { IgxPivotRowDimensionHeaderGroupComponent } from './pivot-row-dimension-header-group.component'; +import { setElementSize } from '../../../test-utils/helper-utils.spec'; +import { IgxPivotRowDimensionMrlRowComponent } from './pivot-row-dimension-mrl-row.component'; +import { IgxPivotRowDimensionContentComponent } from './pivot-row-dimension-content.component'; +import { IgxPivotGridComponent } from './pivot-grid.component'; +import { IgxGridCell } from 'igniteui-angular/grids/core'; +import { IGridCellEventArgs } from 'igniteui-angular/grids/core'; + +const CSS_CLASS_LIST = 'igx-drop-down__list'; +const CSS_CLASS_ITEM = 'igx-drop-down__item'; +const ACTIVE_CELL_CSS_CLASS = '.igx-grid-th--active'; + +describe('IgxPivotGrid #pivotGrid', () => { + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxPivotGridTestBaseComponent, + IgxPivotGridTestComplexHierarchyComponent, + IgxPivotGridFlexContainerComponent + ], + providers: [ + IgxGridNavigationService + ] + }).compileComponents(); + })); + + describe('Basic IgxPivotGrid #pivotGrid', () => { + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(IgxPivotGridTestBaseComponent); + fixture.detectChanges(); + })); + + it('should show empty template when there are no dimensions and values', () => { + // whole pivotConfiguration is undefined + const pivotGrid = fixture.componentInstance.pivotGrid as IgxPivotGridComponent; + pivotGrid.pivotConfiguration = undefined; + fixture.detectChanges(); + + // no rows, just empty message + expect(pivotGrid.rowList.length).toBe(0); + expect(pivotGrid.tbody.nativeElement.textContent).toBe('Pivot grid has no dimensions and values.'); + + // configuration is defined but all collections are empty + pivotGrid.pivotConfiguration = { + columns: [], + rows: [], + values: [] + }; + fixture.detectChanges(); + + // no rows, just empty message + expect(pivotGrid.rowList.length).toBe(0); + expect(pivotGrid.tbody.nativeElement.textContent).toBe('Pivot grid has no dimensions and values.'); + + + // has dimensions and values, but they are disabled + pivotGrid.pivotConfiguration = { + columns: [ + { + enabled: false, + memberName: 'Country' + } + ], + rows: [ + { + enabled: false, + memberName: 'ProductCategory' + } + ], + values: [ + { + enabled: false, + member: 'UnitsSold', + aggregate: { + aggregator: IgxPivotNumericAggregate.sum, + key: 'SUM', + label: 'Sum', + }, + } + ] + }; + fixture.detectChanges(); + + // no rows, just empty message + expect(pivotGrid.rowList.length).toBe(0); + expect(pivotGrid.tbody.nativeElement.textContent).toBe('Pivot grid has no dimensions and values.'); + }); + + it('should show allow setting custom empty template.', () => { + const pivotGrid = fixture.componentInstance.pivotGrid as IgxPivotGridComponent; + pivotGrid.emptyPivotGridTemplate = fixture.componentInstance.emptyTemplate; + pivotGrid.pivotConfiguration = undefined; + fixture.detectChanges(); + + // no rows, just empty message + expect(pivotGrid.rowList.length).toBe(0); + expect(pivotGrid.tbody.nativeElement.textContent).toBe('Custom empty template.'); + }); + + it('should allow setting custom chip value template', () => { + const pivotGrid = fixture.componentInstance.pivotGrid as IgxPivotGridComponent; + pivotGrid.valueChipTemplate = fixture.componentInstance.chipValueTemplate; + fixture.detectChanges(); + + const headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); + const valueChip = headerRow.querySelector('igx-chip[id="UnitsSold"]'); + const content = valueChip.querySelector('.igx-chip__content'); + expect(content.textContent.trim()).toBe('UnitsSold'); + }); + + it('should apply formatter and dataType from measures', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.width = '1500px'; + fixture.detectChanges(); + + const actualFormatterValue = pivotGrid.rowList.first.cells.first.title; + expect(actualFormatterValue).toEqual('774$'); + const actualDataTypeValue = pivotGrid.rowList.first.cells.last.title; + expect(actualDataTypeValue).toEqual('$71.89'); + }); + + it('should apply css class to cells from measures', () => { + fixture.detectChanges(); + const pivotGrid = fixture.componentInstance.pivotGrid; + const cells = pivotGrid.rowList.first.cells; + expect(cells.first.nativeElement.classList).toContain('test'); + expect(cells.last.nativeElement.classList).not.toContain('test'); + }); + + it('should remove row dimensions from chip', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.pivotConfiguration.rows.push({ + memberName: 'SellerName', + enabled: true + }); + pivotGrid.pipeTrigger++; + fixture.detectChanges(); + expect(pivotGrid.rowDimensions.length).toBe(2); + let pivotRecord = (pivotGrid.rowList.first as IgxPivotRowComponent).data; + expect(pivotRecord.dimensionValues.get('SellerName')).not.toBeUndefined(); + + const headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); + const rowChip = headerRow.querySelector('igx-chip[id="SellerName"]'); + const removeIcon = rowChip.querySelectorAll('igx-icon')[2]; + removeIcon.click(); + fixture.detectChanges(); + expect(pivotGrid.pivotConfiguration.rows[1].enabled).toBeFalse(); + expect(pivotGrid.rowDimensions.length).toBe(1); + pivotRecord = (pivotGrid.rowList.first as IgxPivotRowComponent).data; + expect(pivotRecord.dimensionValues.get('SellerName')).toBeUndefined(); + }); + + it('should remove column dimensions from chip', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + expect(pivotGrid.columns.length).toBe(9); + pivotGrid.pivotConfiguration.columns.push({ + memberName: 'SellerName', + enabled: true + }); + pivotGrid.pipeTrigger++; + pivotGrid.setupColumns(); + fixture.detectChanges(); + expect(pivotGrid.columnDimensions.length).toBe(2); + expect(pivotGrid.columns.length).not.toBe(9); + + const headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); + const rowChip = headerRow.querySelector('igx-chip[id="SellerName"]'); + const removeIcon = rowChip.querySelectorAll('igx-icon')[2]; + removeIcon.click(); + fixture.detectChanges(); + expect(pivotGrid.pivotConfiguration.columns[1].enabled).toBeFalse(); + expect(pivotGrid.columnDimensions.length).toBe(1); + expect(pivotGrid.columns.length).toBe(9); + }); + + it('should remove value from chip', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.pivotConfiguration.values[1].displayName = 'Units Price'; + pivotGrid.notifyDimensionChange(true); + fixture.detectChanges(); + + expect(pivotGrid.columns.length).toBe(9); + expect(pivotGrid.values.length).toBe(2); + + const headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); + let rowChip = headerRow.querySelector('igx-chip[id="UnitsSold"]'); + let removeIcon = rowChip.querySelectorAll('igx-icon')[2]; + removeIcon.click(); + fixture.detectChanges(); + expect(pivotGrid.pivotConfiguration.values[0].enabled).toBeFalse(); + expect(pivotGrid.values.length).toBe(1); + expect(pivotGrid.columns.length).not.toBe(9); + + // should remove the second one as well + rowChip = headerRow.querySelector('igx-chip[id="Units Price"]'); + removeIcon = rowChip.querySelectorAll('igx-icon')[2]; + removeIcon.click(); + fixture.detectChanges(); + expect(pivotGrid.pivotConfiguration.values[1].enabled).toBeFalse(); + expect(pivotGrid.values.length).toBe(0); + expect(pivotGrid.columns.length).toBe(3); + }); + + it('should remove filter dimension from chip', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + + const filteringExpressionTree = new FilteringExpressionsTree(FilteringLogic.And); + filteringExpressionTree.filteringOperands = [ + { + condition: IgxStringFilteringOperand.instance().condition('equals'), + conditionName: 'equals', + fieldName: 'SellerName', + searchVal: 'Stanley' + } + ]; + const filterDimension = { + memberName: 'SellerName', + enabled: true, + filter: filteringExpressionTree + }; + pivotGrid.pivotConfiguration.filters = [filterDimension]; + pivotGrid.pipeTrigger++; + fixture.detectChanges(); + expect(pivotGrid.pivotConfiguration.filters[0].enabled).toBeTrue(); + expect(pivotGrid.rowList.length).toBe(2); + + const headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); + const rowChip = headerRow.querySelector('igx-chip[id="SellerName"]'); + const removeIcon = rowChip.querySelectorAll('igx-icon')[1]; + removeIcon.click(); + fixture.detectChanges(); + expect(pivotGrid.pivotConfiguration.filters[0].enabled).toBeFalse(); + expect(pivotGrid.rowList.length).toBe(5); + }); + + it('should correctly remove chip from filters dropdown', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.pivotConfiguration = { + columns: [], + rows: [ + { + memberName: 'SellerName', + enabled: true + } + ], + filters: [ + { + memberName: 'Date', + enabled: true + }, + { + memberName: 'ProductCategory', + enabled: true + }, + { + memberName: 'Country', + enabled: true + } + ], + values: null + }; + pivotGrid.pipeTrigger++; + pivotGrid.setupColumns(); + fixture.detectChanges(); + + const headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); + const dropdownIcon = headerRow.querySelector('.igx-grid__tr-pivot--filter').querySelectorAll('igx-icon')[2]; + expect(dropdownIcon).not.toBeUndefined(); + expect(headerRow.querySelector('igx-badge').innerText).toBe('2'); + dropdownIcon.click(); + fixture.detectChanges(); + + const excelMenu = GridFunctions.getExcelStyleFilteringComponents(fixture, 'igx-pivot-grid')[0]; + const chip = excelMenu.querySelectorAll('igx-chip')[0]; + const removeIcon = chip.querySelectorAll('igx-icon')[1]; + removeIcon.click(); + fixture.detectChanges(); + + const filtersChip = headerRow.querySelector('igx-chip[id="Date"]'); + expect(filtersChip).toBeDefined(); + expect(headerRow.querySelector('igx-chip[id="ProductCategory"]')).toBeNull(); + }); + + it('should collapse column with 1 value dimension', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.pivotConfiguration.values.pop(); + pivotGrid.pivotConfiguration.columns = [{ + memberName: 'AllCountries', + memberFunction: () => 'All Countries', + enabled: true, + childLevel: { + memberName: 'Country', + enabled: true + } + }]; + pivotGrid.pivotConfiguration.rows[0] = new IgxPivotDateDimension( + { + memberName: 'Date', + enabled: true + }, { + total: false + } + ); + pivotGrid.notifyDimensionChange(true); + expect(pivotGrid.columns.length).toBe(5); + expect(pivotGrid.columnGroupStates.size).toBe(0); + const headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); + const header = headerRow.querySelector('igx-grid-header-group'); + const expander = header.querySelectorAll('igx-icon')[0]; + expander.click(); + fixture.detectChanges(); + expect(pivotGrid.columnGroupStates.size).toBe(1); + const value = pivotGrid.columnGroupStates.entries().next().value; + expect(value[0]).toEqual('All Countries'); + expect(value[1]).toBeTrue(); + }); + + it('should collapse column with 2 value dimension', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.pivotConfiguration.columns = [{ + memberName: 'AllCountries', + memberFunction: () => 'All Countries', + enabled: true, + childLevel: { + memberName: 'Country', + enabled: true + } + }, + { + memberName: 'SellerName', + enabled: true + }]; + pivotGrid.notifyDimensionChange(true); + fixture.detectChanges(); + expect(pivotGrid.columnGroupStates.size).toBe(0); + let headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); + let header = headerRow.querySelector('igx-grid-header-group'); + let expander = header.querySelectorAll('igx-icon')[0]; + expander.click(); + fixture.detectChanges(); + expect(pivotGrid.columnGroupStates.size).toBe(1); + let value = pivotGrid.columnGroupStates.entries().next().value; + expect(value[0]).toEqual('All Countries'); + expect(value[1]).toBeTrue(); + + headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); + header = headerRow.querySelector('igx-grid-header-group'); + expander = header.querySelectorAll('igx-icon')[0]; + expander.click(); + fixture.detectChanges(); + value = pivotGrid.columnGroupStates.entries().next().value; + expect(value[0]).toEqual('All Countries'); + expect(value[1]).toBeFalse(); + }); + + it('should collapse row', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + + expect(pivotGrid.rowList.length).toEqual(5); + expect(pivotGrid.expansionStates.size).toEqual(0); + const headerRow = fixture.nativeElement.querySelector('igx-pivot-row-dimension-content'); + const header = headerRow.querySelector('igx-pivot-row-dimension-header'); + const expander = header.querySelectorAll('igx-icon')[0]; + expander.click(); + fixture.detectChanges(); + expect(pivotGrid.rowList.length).toEqual(1); + expect(pivotGrid.expansionStates.size).toEqual(1); + expect(pivotGrid.expansionStates.get('All')).toBeFalse(); + }); + + it('should display aggregations when no row dimensions are enabled', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.pivotConfiguration.columns = [ + new IgxPivotDateDimension( + { + memberName: 'Date', + enabled: true + }, + { + months: false + } + ) + ]; + pivotGrid.pivotConfiguration.rows = []; + pivotGrid.notifyDimensionChange(true); + fixture.detectChanges(); + expect(pivotGrid.rowList.first.cells.length).toBeGreaterThanOrEqual(1); + expect(pivotGrid.rowList.first.cells.first.title).toEqual('282$'); + }); + + it('should display aggregations when no col dimensions are enabled', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.pivotConfiguration.rows = [ + { + memberName: 'City', + enabled: true, + } + ]; + pivotGrid.pivotConfiguration.columns = []; + pivotGrid.notifyDimensionChange(true); + fixture.detectChanges(); + expect(pivotGrid.rowList.first.cells.length).toEqual(pivotGrid.values.length); + expect(pivotGrid.rowList.first.cells.first.title).toEqual('2127$'); + }); + + it('should display aggregations when neither col nor row dimensions are set', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.pivotConfiguration.rows = []; + pivotGrid.pivotConfiguration.columns = []; + pivotGrid.notifyDimensionChange(true); + fixture.detectChanges(); + + expect(pivotGrid.rowList.first.cells.length).toEqual(pivotGrid.values.length); + expect(pivotGrid.rowList.first.cells.first.title).toEqual('2127$'); + }); + + it('should reevaluate aggregated values when all row dimensions are removed', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.height = '700px'; + pivotGrid.width = '1000px'; + pivotGrid.pivotConfiguration.columns = [ + new IgxPivotDateDimension( + { + memberName: 'Date', + enabled: true + }, + { + months: false + } + ) + ]; + pivotGrid.pivotConfiguration.rows = [ + { + memberName: 'AllSeller', + memberFunction: () => 'All Sellers', + enabled: true, + childLevel: { + enabled: true, + memberName: 'SellerName' + } + } + ]; + pivotGrid.notifyDimensionChange(true); + fixture.detectChanges(); + + const uniqueVals = Array.from(new Set(pivotGrid.data.map(x => x.SellerName))).length; + expect(pivotGrid.rowList.length).toEqual(uniqueVals + 1); + expect(pivotGrid.rowList.first.cells.first.title).toEqual('282$'); + expect(pivotGrid.rowDimensions.length).toEqual(1); + + pivotGrid.pivotConfiguration.rows = []; + pivotGrid.notifyDimensionChange(true); + fixture.detectChanges(); + expect(pivotGrid.rowList.length).toEqual(1); + expect(pivotGrid.rowDimensions.length).toEqual(0); + }); + + it('should reevaluate aggregated values when all col dimensions are removed', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.height = '700px'; + pivotGrid.width = '1000px'; + pivotGrid.pivotConfiguration.columns = [ + new IgxPivotDateDimension( + { + memberName: 'Date', + enabled: true + }, + { + months: false + } + ) + ]; + pivotGrid.pivotConfiguration.rows = [ + { + memberName: 'AllSeller', + memberFunction: () => 'All Sellers', + enabled: true, + childLevel: { + enabled: true, + memberName: 'SellerName' + } + } + ]; + pivotGrid.notifyDimensionChange(true); + fixture.detectChanges(); + + const uniqueVals = Array.from(new Set(pivotGrid.data.map(x => x.SellerName))).length; + expect(pivotGrid.rowList.length).toEqual(uniqueVals + 1); + expect(pivotGrid.rowList.first.cells.first.title).toEqual('282$'); + expect(pivotGrid.rowList.first.cells.length).toEqual(5); + expect(pivotGrid.columnDimensions.length).toEqual(1); + + pivotGrid.pivotConfiguration.columns = []; + pivotGrid.notifyDimensionChange(true); + fixture.detectChanges(); + + expect(pivotGrid.rowList.first.cells.length).toEqual(pivotGrid.values.length); + expect(pivotGrid.columnDimensions.length).toEqual(0); + }); + + it('should change grid size', fakeAsync(() => { + const pivotGrid = fixture.componentInstance.pivotGrid; + const minWidthComf = '80'; + const minWidthSupercompact = '56'; + const cellHeightComf = 50; + const cellHeightSuperCompact = 24; + + pivotGrid.superCompactMode = true; + tick(); + fixture.detectChanges(); + + expect(pivotGrid.gridSize).toBe(ɵSize.Small); + const dimensionContents = fixture.debugElement.queryAll(By.css('.igx-grid__tbody-pivot-dimension')); + let rowHeaders = dimensionContents[0].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + expect(rowHeaders[0].componentInstance.column.defaultMinWidth).toBe(minWidthSupercompact); + expect(pivotGrid.rowList.first.cells.first.nativeElement.offsetHeight).toBe(cellHeightSuperCompact); + + pivotGrid.superCompactMode = false; + fixture.detectChanges(); + tick(); + + setElementSize(pivotGrid.nativeElement, ɵSize.Large) + fixture.detectChanges(); + + expect(pivotGrid.gridSize).toBe(ɵSize.Large); + rowHeaders = dimensionContents[0].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + expect(rowHeaders[0].componentInstance.column.defaultMinWidth).toBe(minWidthComf); + expect(pivotGrid.rowList.first.cells.first.nativeElement.offsetHeight).toBe(cellHeightComf); + })); + + it('should render correct auto-widths for dimensions with no width', () => { + const pivotGrid = fixture.componentInstance.pivotGrid as IgxPivotGridComponent; + pivotGrid.data = [{ + ProductCategory: 'Clothing', UnitPrice: 12.81, SellerName: 'Stanley', + Country: 'Bulgaria', Date: '01/01/2021', UnitsSold: 282 + }]; + fixture.detectChanges(); + + // there should be just 1 dimension column and 2 value columns and they should auto-fill the available space + expect(pivotGrid.columns.length).toBe(3); + const dimColumn = pivotGrid.columns.find(x => x.field === 'Bulgaria'); + expect(dimColumn.width).toBe((pivotGrid.calcWidth - pivotGrid.featureColumnsWidth()) + 'px'); + const unitPriceCol = pivotGrid.columns.find(x => x.field === 'Bulgaria-UnitPrice'); + const unitsSoldCol = pivotGrid.columns.find(x => x.field === 'Bulgaria-UnitsSold'); + expect(unitPriceCol.width).toBe(parseInt(dimColumn.width, 10) / 2 + 'px'); + expect(unitsSoldCol.width).toBe(parseInt(dimColumn.width, 10) / 2 + 'px'); + + // change data to have many columns so that they no longer fit in the grid + pivotGrid.data = [{ + ProductCategory: 'Clothing', UnitPrice: 12.81, SellerName: 'Stanley', + Country: 'Bulgaria', Date: '01/01/2021', UnitsSold: 282 + }, { + ProductCategory: 'Clothing', UnitPrice: 12.81, SellerName: 'Stanley', + Country: 'USA', Date: '01/01/2021', UnitsSold: 282 + }, + { + ProductCategory: 'Clothing', UnitPrice: 12.81, SellerName: 'Stanley', + Country: 'Spain', Date: '01/01/2021', UnitsSold: 282 + }, + { + ProductCategory: 'Clothing', UnitPrice: 12.81, SellerName: 'Stanley', + Country: 'Italy', Date: '01/01/2021', UnitsSold: 282 + }, + { + ProductCategory: 'Clothing', UnitPrice: 12.81, SellerName: 'Stanley', + Country: 'Greece', Date: '01/01/2021', UnitsSold: 282 + }, + { + ProductCategory: 'Clothing', UnitPrice: 12.81, SellerName: 'Stanley', + Country: 'Uruguay', Date: '01/01/2021', UnitsSold: 282 + }, + { + ProductCategory: 'Clothing', UnitPrice: 12.81, SellerName: 'Stanley', + Country: 'Mexico', Date: '01/01/2021', UnitsSold: 282 + } + ]; + fixture.detectChanges(); + + // all should take grid size default min-width (200 for default grid size) as they exceed the size of the grid + const colGroups = pivotGrid.columns.filter(x => x.columnGroup); + const childCols = pivotGrid.columns.filter(x => !x.columnGroup); + expect(colGroups.every(x => x.width === '400px')).toBeTrue(); + expect(childCols.every(x => x.width === '200px')).toBeTrue(); + }); + + it('should render correct grid with noop strategies', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.data = [ + { + AllProducts: 'All Products', All: 2127, 'Bulgaria': 774, 'USA': 829, 'Uruguay': 524, 'AllProducts_records': [ + { ProductCategory: 'Clothing', All: 1523, 'Bulgaria': 774, 'USA': 296, 'Uruguay': 456, }, + { ProductCategory: 'Bikes', All: 68, 'Uruguay': 68 }, + { ProductCategory: 'Accessories', All: 293, 'USA': 293 }, + { ProductCategory: 'Components', All: 240, 'USA': 240 } + ] + } + ]; + + pivotGrid.pivotConfiguration = { + columnStrategy: NoopPivotDimensionsStrategy.instance(), + rowStrategy: NoopPivotDimensionsStrategy.instance(), + columns: [ + { + memberName: 'Country', + enabled: true + }, + ] + , + rows: [ + { + memberFunction: () => 'All', + memberName: 'AllProducts', + enabled: true, + width: '25%', + childLevel: { + memberName: 'ProductCategory', + enabled: true + } + } + ], + values: [ + { + member: 'UnitsSold', + aggregate: { + aggregator: IgxPivotNumericAggregate.sum, + key: 'sum', + label: 'Sum' + }, + enabled: true + }, + ], + filters: null + }; + + pivotGrid.notifyDimensionChange(true); + fixture.detectChanges(); + + expect(pivotGrid.rowList.first.cells.toArray().map(x => x.value)).toEqual([2127, 774, 829, 524]); + }); + + it('should display displayName when provided for a pivot column and/or row', () => { + const pivotGrid = fixture.componentInstance.pivotGrid as IgxPivotGridComponent; + pivotGrid.pivotConfiguration = { + columns: [ + { + enabled: true, + memberName: 'ColumnMember', + displayName: 'ColumnDisplay' + } + ], + rows: [ + { + enabled: true, + memberName: 'RowMember', + displayName: 'RowDisplay' + } + ], + values: [ + { + enabled: true, + member: 'ValueMember', + displayName: 'ValueDisplay', + aggregate: { + aggregator: IgxPivotNumericAggregate.sum, + key: 'SUM', + label: 'Sum', + } + } + ] + } + + fixture.detectChanges(); + + const displayedColumn = fixture.nativeElement.querySelector(`#${pivotGrid.pivotConfiguration.columns[0].memberName} .igx-chip__content`).textContent; + expect(displayedColumn).toContain('ColumnDisplay'); + expect(displayedColumn).not.toContain('ColumnMember'); + + const displayedRow = fixture.nativeElement.querySelector(`#${pivotGrid.pivotConfiguration.rows[0].memberName} .igx-chip__content`).textContent; + expect(displayedRow).toContain('RowDisplay'); + expect(displayedRow).not.toContain('RowMember'); + }); + + it('should should dallback to using memberName for columns and rows if displayName is not provided', () => { + const pivotGrid = fixture.componentInstance.pivotGrid as IgxPivotGridComponent; + pivotGrid.pivotConfiguration = { + columns: [ + { + enabled: true, + memberName: 'ColumnMember', + displayName: undefined + } + ], + rows: [ + { + enabled: true, + memberName: 'RowMember', + displayName: undefined + } + ], + values: [ + { + enabled: true, + member: 'ValueMember', + displayName: 'ValueDisplay', + aggregate: { + aggregator: IgxPivotNumericAggregate.sum, + key: 'SUM', + label: 'Sum', + } + } + ] + } + + fixture.detectChanges(); + + const displayedColumn = fixture.nativeElement.querySelector(`#${pivotGrid.pivotConfiguration.columns[0].memberName} .igx-chip__content`).textContent; + expect(displayedColumn).toContain('ColumnMember'); + + const displayedRow = fixture.nativeElement.querySelector(`#${pivotGrid.pivotConfiguration.rows[0].memberName} .igx-chip__content`).textContent; + expect(displayedRow).toContain('RowMember'); + }); + + it('should render correctly when going from all dimensions and values disabled to single column dimension enabled.', () => { + const pivotGrid = fixture.componentInstance.pivotGrid as IgxPivotGridComponent; + // disable all + pivotGrid.pivotConfiguration.rows.forEach(x => pivotGrid.toggleDimension(x)); + pivotGrid.pivotConfiguration.columns.forEach(x => pivotGrid.toggleDimension(x)); + pivotGrid.pivotConfiguration.filters.forEach(x => pivotGrid.toggleDimension(x)); + pivotGrid.pivotConfiguration.values.forEach(x => pivotGrid.toggleValue(x)); + fixture.detectChanges(); + + // no rows, just empty message + expect(pivotGrid.rowList.length).toBe(0); + expect(pivotGrid.tbody.nativeElement.textContent).toBe('Pivot grid has no dimensions and values.'); + + pivotGrid.toggleDimension(pivotGrid.pivotConfiguration.columns[0]); + fixture.detectChanges(); + + // 1 row, 3 columns + expect(pivotGrid.rowList.length).toBe(1); + expect(pivotGrid.columns.length).toBe(3); + }); + + it('should calculate row headers according to grid size', async () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + const rowHeightSmall = 32; + const rowHeightMedium = 40; + const rowHeightLarge = 50; + + pivotGrid.superCompactMode = false; + setElementSize(pivotGrid.nativeElement, ɵSize.Large); + + await wait(100); + fixture.detectChanges(); + + expect(pivotGrid.gridSize).toBe(ɵSize.Large); + const dimensionContents = fixture.debugElement.queryAll(By.css('.igx-grid__tbody-pivot-dimension')); + let rowHeaders = dimensionContents[0].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + let rowHeader = rowHeaders[0].queryAll(By.directive(IgxPivotRowDimensionHeaderComponent)); + expect(rowHeader[0].nativeElement.offsetHeight).toBe(rowHeightLarge); + + setElementSize(pivotGrid.nativeElement, ɵSize.Small); + await wait(100); + fixture.detectChanges(); + + expect(pivotGrid.gridSize).toBe(ɵSize.Small); + rowHeaders = dimensionContents[0].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + rowHeader = rowHeaders[0].queryAll(By.directive(IgxPivotRowDimensionHeaderComponent)); + expect(rowHeader[0].nativeElement.offsetHeight).toBe(rowHeightSmall); + + setElementSize(pivotGrid.nativeElement, ɵSize.Medium); + await wait(100); + fixture.detectChanges(); + + expect(pivotGrid.gridSize).toBe(ɵSize.Medium); + rowHeaders = dimensionContents[0].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + rowHeader = rowHeaders[0].queryAll(By.directive(IgxPivotRowDimensionHeaderComponent)); + expect(rowHeader[0].nativeElement.offsetHeight).toBe(rowHeightMedium); + }); + + it('should render with correct width when set to 100% inside of flex container', async () => { + fixture = TestBed.createComponent(IgxPivotGridFlexContainerComponent); + fixture.detectChanges(); + await wait(100); + fixture.detectChanges(); + const pivotGrid = fixture.componentInstance.pivotGrid; + const colSum = pivotGrid.featureColumnsWidth() + pivotGrid.columns.filter(x => !x.columnGroup).map(x => x.calcPixelWidth).reduce((x, y) => x + y); + const expectedSize = Math.min(window.innerWidth, colSum); + expect(pivotGrid.nativeElement.clientWidth - expectedSize).toBeLessThan(50, "should take sum of columns as width."); + }); + + it('should render cell values for dimension columns containing dots - issue #16445', () => { + let data = fixture.componentInstance.data; + data = data.map(item => { + return { + ...item, + Country: `${item['Country']}.Test` + }; + }); + + fixture.componentInstance.data = [...data]; + fixture.detectChanges(); + + const cell = fixture.componentInstance.pivotGrid.gridAPI.get_cell_by_index(0, 'Bulgaria.Test-UnitsSold'); + expect(cell.value).not.toBeUndefined(); + }); + + describe('IgxPivotGrid Features #pivotGrid', () => { + it('should show excel style filtering via dimension chip.', async () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + expect(pivotGrid.filterStrategy).toBeInstanceOf(DimensionValuesFilteringStrategy); + const excelMenu = GridFunctions.getExcelStyleFilteringComponents(fixture, 'igx-pivot-grid')[1]; + const headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); + const rowChip = headerRow.querySelector('igx-chip[id="All"]'); + const filterIcon = rowChip.querySelectorAll('igx-icon')[1]; + + expect(excelMenu.parentElement.parentElement.attributes.hidden).not.toBeUndefined(); + filterIcon.click(); + await wait(100); + fixture.detectChanges(); + const esfSearch = GridFunctions.getExcelFilteringSearchComponent(fixture, excelMenu, 'igx-pivot-grid'); + + const checkBoxes = esfSearch.querySelectorAll('igx-checkbox'); + // should show Select All checkbox + expect(excelMenu.parentElement.parentElement.attributes.hidden).toBeUndefined(); + expect((checkBoxes[0].querySelector('.igx-checkbox__label') as HTMLElement).innerText).toEqual('Select All'); + + // expand tree hierarchy + GridFunctions.clickExcelTreeNodeExpandIcon(fixture, 0); + await wait(100); + fixture.detectChanges(); + // should show correct tree items + const treeItems = GridFunctions.getExcelStyleSearchComponentTreeNodes(fixture, excelMenu, 'igx-pivot-grid'); + expect(treeItems.length).toBe(5); + + expect(treeItems[1].innerText).toBe('Clothing'); + expect(treeItems[2].innerText).toBe('Bikes'); + expect(treeItems[3].innerText).toBe('Accessories'); + expect(treeItems[4].innerText).toBe('Components'); + }); + + it('should filter rows via excel style filtering dimension chip.', async () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + const headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); + const rowChip = headerRow.querySelector('igx-chip[id="All"]'); + const filterIcon = rowChip.querySelectorAll('igx-icon')[1]; + filterIcon.click(); + await wait(100); + fixture.detectChanges(); + + // expand tree hierarchy + GridFunctions.clickExcelTreeNodeExpandIcon(fixture, 0); + await wait(100); + fixture.detectChanges(); + + const excelMenu = GridFunctions.getExcelStyleFilteringComponents(fixture, 'igx-pivot-grid')[1]; + const checkboxes = GridFunctions.getExcelStyleFilteringCheckboxes(fixture, excelMenu, 'igx-tree-grid'); + // uncheck Accessories + checkboxes[4].click(); + fixture.detectChanges(); + + // uncheck Bikes + checkboxes[3].click(); + fixture.detectChanges(); + + // Click 'apply' button to apply filter. + GridFunctions.clickApplyExcelStyleFiltering(fixture, excelMenu, 'igx-pivot-grid'); + fixture.detectChanges(); + + // check rows + const rows = pivotGrid.rowList.toArray(); + expect(rows.length).toBe(3); + const expectedHeaders = ['All', 'Clothing', 'Components']; + const rowHeaders = fixture.debugElement.queryAll( + By.directive(IgxPivotRowDimensionHeaderComponent)); + const rowDimensionHeaders = rowHeaders.map(x => x.componentInstance.column.header); + expect(rowDimensionHeaders).toEqual(expectedHeaders); + }); + + it('should filter columns via excel style filtering dimension chip.', async () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + const headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); + const rowChip = headerRow.querySelector('igx-chip[id="Country"]'); + const filterIcon = rowChip.querySelectorAll('igx-icon')[1]; + filterIcon.click(); + await wait(100); + fixture.detectChanges(); + const excelMenu = GridFunctions.getExcelStyleFilteringComponents(fixture, 'igx-pivot-grid')[1]; + const checkboxes: any[] = Array.from(GridFunctions.getExcelStyleFilteringCheckboxes(fixture, excelMenu, 'igx-pivot-grid')); + + // uncheck Bulgaria + checkboxes[1].click(); + fixture.detectChanges(); + + // uncheck Uruguay + checkboxes[3].click(); + fixture.detectChanges(); + + + // Click 'apply' button to apply filter. + GridFunctions.clickApplyExcelStyleFiltering(fixture, excelMenu, 'igx-pivot-grid'); + fixture.detectChanges(); + + // check columns + const colHeaders = pivotGrid.columns.filter(x => x.level === 0).map(x => x.header); + const expected = ['USA']; + expect(colHeaders).toEqual(expected); + }); + + it('should show filters chips', async () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.pivotConfiguration.rows = [ + { + memberName: 'All', + memberFunction: () => 'All', + enabled: true, + width: "300px", + childLevel: { + memberName: 'ProductCategory', + memberFunction: (data) => data.ProductCategory, + enabled: true + } + } + ]; + pivotGrid.pivotConfiguration.filters = [{ + memberName: 'SellerName', + enabled: true + }]; + pivotGrid.pipeTrigger++; + pivotGrid.setupColumns(); + fixture.detectChanges(); + const excelMenu = GridFunctions.getExcelStyleFilteringComponents(fixture, 'igx-pivot-grid')[1]; + const headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); + const filtersChip = headerRow.querySelector('igx-chip[id="SellerName"]'); + const filterIcon = filtersChip.querySelectorAll('igx-icon')[0]; + + expect(excelMenu.parentElement.parentElement.attributes.hidden).not.toBeUndefined(); + filterIcon.click(); + await wait(100); + fixture.detectChanges(); + const esfSearch = GridFunctions.getExcelFilteringSearchComponent(fixture, excelMenu, 'igx-pivot-grid'); + const checkBoxes = esfSearch.querySelectorAll('igx-checkbox'); + // should show and should display correct checkboxes. + expect(excelMenu.parentElement.parentElement.attributes.hidden).toBeUndefined(); + expect((checkBoxes[0].querySelector('.igx-checkbox__label') as HTMLElement).innerText).toEqual('Select All'); + expect((checkBoxes[1].querySelector('.igx-checkbox__label') as HTMLElement).innerText).toEqual('Stanley'); + expect((checkBoxes[2].querySelector('.igx-checkbox__label') as HTMLElement).innerText).toEqual('Elisa'); + expect((checkBoxes[3].querySelector('.igx-checkbox__label') as HTMLElement).innerText).toEqual('Lydia'); + expect((checkBoxes[4].querySelector('.igx-checkbox__label') as HTMLElement).innerText).toEqual('David'); + }); + + it('should show filters in chips dropdown button', async () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.pivotConfiguration.filters = [ + { + memberName: 'SellerName', + enabled: true + }, + { + memberName: 'ProductCategory', + enabled: true + } + ]; + pivotGrid.pipeTrigger++; + pivotGrid.setupColumns(); + fixture.detectChanges(); + const excelMenu = GridFunctions.getExcelStyleFilteringComponents(fixture, 'igx-pivot-grid')[0]; + const headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); + const dropdownIcon = headerRow.querySelector('.igx-grid__tr-pivot--filter').querySelectorAll('igx-icon')[0]; + + expect(excelMenu.parentElement.parentElement.attributes.hidden).not.toBeUndefined(); + dropdownIcon.click(); + await wait(100); + fixture.detectChanges(); + + const chips = excelMenu.querySelectorAll('igx-chip'); + expect(chips[0].id).toBe('SellerName'); + expect(chips[0].attributes.getNamedItem('aria-selected').nodeValue).toEqual('true'); + expect(chips[1].id).toBe('ProductCategory'); + expect(chips[1].attributes.getNamedItem('aria-selected').nodeValue).toEqual('false'); + + let esfSearch = GridFunctions.getExcelFilteringSearchComponent(fixture, excelMenu, 'igx-pivot-grid'); + let checkBoxes = esfSearch.querySelectorAll('igx-checkbox'); + // should show and should display correct checkboxes. + expect(excelMenu.parentElement.parentElement.attributes.hidden).toBeUndefined(); + expect((checkBoxes[0].querySelector('.igx-checkbox__label') as HTMLElement).innerText).toEqual('Select All'); + expect((checkBoxes[1].querySelector('.igx-checkbox__label') as HTMLElement).innerText).toEqual('Stanley'); + expect((checkBoxes[2].querySelector('.igx-checkbox__label') as HTMLElement).innerText).toEqual('Elisa'); + expect((checkBoxes[3].querySelector('.igx-checkbox__label') as HTMLElement).innerText).toEqual('Lydia'); + expect((checkBoxes[4].querySelector('.igx-checkbox__label') as HTMLElement).innerText).toEqual('David'); + + // switch to the `ProductCategory` filters + const chipAreaElement = fixture.debugElement.queryAll(By.directive(IgxChipsAreaComponent)); + const chipComponents = chipAreaElement[4].queryAll(By.directive(IgxChipComponent)); + chipComponents[1].triggerEventHandler('chipClick', { + owner: { + id: chips[1].id + } + }); + await wait(500); + fixture.detectChanges(); + + esfSearch = GridFunctions.getExcelFilteringSearchComponent(fixture, excelMenu, 'igx-pivot-grid'); + checkBoxes = esfSearch.querySelectorAll('igx-checkbox'); + expect((checkBoxes[0].querySelector('.igx-checkbox__label') as HTMLElement).innerText).toEqual('Select All'); + expect((checkBoxes[1].querySelector('.igx-checkbox__label') as HTMLElement).innerText).toEqual('Clothing'); + expect((checkBoxes[2].querySelector('.igx-checkbox__label') as HTMLElement).innerText).toEqual('Bikes'); + expect((checkBoxes[3].querySelector('.igx-checkbox__label') as HTMLElement).innerText).toEqual('Accessories'); + expect((checkBoxes[4].querySelector('.igx-checkbox__label') as HTMLElement).innerText).toEqual('Components'); + }); + + it('should be able to filter from chips dropdown button', async () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.pivotConfiguration.filters = [ + { + memberName: 'SellerName', + enabled: true + }, + { + memberName: 'ProductCategory', + enabled: true + } + ]; + pivotGrid.pipeTrigger++; + pivotGrid.setupColumns(); + fixture.detectChanges(); + const excelMenu = GridFunctions.getExcelStyleFilteringComponents(fixture, 'igx-pivot-grid')[0]; + const headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); + const dropdownIcon = headerRow.querySelector('.igx-grid__tr-pivot--filter').querySelectorAll('igx-icon')[0]; + + expect(excelMenu.parentElement.parentElement.attributes.hidden).not.toBeUndefined(); + dropdownIcon.click(); + await wait(100); + fixture.detectChanges(); + + const checkBoxes: any[] = Array.from(GridFunctions.getExcelStyleFilteringCheckboxes(fixture, excelMenu, 'igx-pivot-grid')); + // uncheck David + checkBoxes[4].click(); + fixture.detectChanges(); + + // uncheck Lydia + checkBoxes[3].click(); + fixture.detectChanges(); + + // Click 'apply' button to apply filter. + GridFunctions.clickApplyExcelStyleFiltering(fixture, excelMenu, 'igx-pivot-grid'); + fixture.detectChanges(); + + // check rows + const expectedHeaders = ['All', 'Clothing', 'Components']; + const rowHeaders = fixture.debugElement.queryAll( + By.directive(IgxPivotRowDimensionHeaderComponent)); + const rowDimensionHeaders = rowHeaders.map(x => x.componentInstance.column.header); + expect(rowHeaders.length).toBe(3); + expect(rowDimensionHeaders).toEqual(expectedHeaders); + }); + + it('should show chips and dropdown if there is not enough space', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.pivotConfiguration.filters = [ + { + memberName: 'Date', + enabled: true + }, + { + memberName: 'ProductCategory', + enabled: true + } + ]; + + pivotGrid.pivotConfiguration.rows = [{ + memberName: 'SellerName', + enabled: true + }]; + pivotGrid.pipeTrigger++; + pivotGrid.setupColumns(); + fixture.detectChanges(); + const headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); + const dropdownIcon = headerRow.querySelector('.igx-grid__tr-pivot--filter').querySelectorAll('igx-icon')[2]; + expect(dropdownIcon).not.toBeUndefined(); + expect(headerRow.querySelector('igx-badge').innerText).toBe('1'); + const filtersChip = headerRow.querySelector('igx-chip[id="Date"]'); + expect(filtersChip).not.toBeUndefined(); + }); + + it('should show complex tree and allow filtering for Date dimension', async () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.pivotConfiguration.rows = [new IgxPivotDateDimension( + { + memberName: 'Date', + enabled: true + }, + { + months: true, + quarters: true, + years: true, + fullDate: true, + total: true + } + )]; + + pivotGrid.pipeTrigger++; + pivotGrid.setupColumns(); + fixture.detectChanges(); + + const headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); + const rowChip = headerRow.querySelector('igx-chip[id="AllPeriods"]'); + const filterIcon = rowChip.querySelectorAll('igx-icon')[1]; + filterIcon.click(); + await wait(100); + fixture.detectChanges(); + + const excelMenu = GridFunctions.getExcelStyleFilteringComponents(fixture, 'igx-pivot-grid')[1]; + + // expand tree hierarchy + GridFunctions.clickExcelTreeNodeExpandIcon(fixture, 0); + await wait(100); + fixture.detectChanges(); + // should show correct tree items + let treeItems = GridFunctions.getExcelStyleSearchComponentTreeNodes(fixture, excelMenu, 'igx-pivot-grid'); + + expect(treeItems.length).toBe(4); + expect(treeItems[0].querySelector('.igx-tree-node__content').textContent).toBe('All Periods'); + expect(treeItems[1].querySelector('.igx-tree-node__content').textContent).toBe('2021'); + expect(treeItems[2].querySelector('.igx-tree-node__content').textContent).toBe('2019'); + expect(treeItems[3].querySelector('.igx-tree-node__content').textContent).toBe('2020'); + + + // expand tree hierarchy 2021 + GridFunctions.clickExcelTreeNodeExpandIcon(fixture, 1); + await wait(100); + fixture.detectChanges(); + + treeItems = GridFunctions.getExcelStyleSearchComponentTreeNodes(fixture, excelMenu, 'igx-pivot-grid'); + + expect(treeItems.length).toBe(7); + expect(treeItems[0].querySelector('.igx-tree-node__content').textContent).toBe('All Periods'); + expect(treeItems[1].querySelector('.igx-tree-node__content').textContent).toBe('2021'); + expect(treeItems[2].querySelector('.igx-tree-node__content').textContent).toBe('Q1'); + expect(treeItems[3].querySelector('.igx-tree-node__content').textContent).toBe('Q2'); + expect(treeItems[4].querySelector('.igx-tree-node__content').textContent).toBe('Q4'); + expect(treeItems[5].querySelector('.igx-tree-node__content').textContent).toBe('2019'); + expect(treeItems[6].querySelector('.igx-tree-node__content').textContent).toBe('2020'); + + // expand tree hierarchy Q1 + GridFunctions.clickExcelTreeNodeExpandIcon(fixture, 2); + await wait(100); + fixture.detectChanges(); + + treeItems = GridFunctions.getExcelStyleSearchComponentTreeNodes(fixture, excelMenu, 'igx-pivot-grid'); + expect(treeItems.length).toBe(8); + expect(treeItems[0].querySelector('.igx-tree-node__content').textContent).toBe('All Periods'); + expect(treeItems[1].querySelector('.igx-tree-node__content').textContent).toBe('2021'); + expect(treeItems[2].querySelector('.igx-tree-node__content').textContent).toBe('Q1'); + expect(treeItems[3].querySelector('.igx-tree-node__content').textContent).toBe('January'); + expect(treeItems[4].querySelector('.igx-tree-node__content').textContent).toBe('Q2'); + expect(treeItems[5].querySelector('.igx-tree-node__content').textContent).toBe('Q4'); + expect(treeItems[6].querySelector('.igx-tree-node__content').textContent).toBe('2019'); + expect(treeItems[7].querySelector('.igx-tree-node__content').textContent).toBe('2020'); + + // expand tree hierarchy January + GridFunctions.clickExcelTreeNodeExpandIcon(fixture, 3); + await wait(100); + fixture.detectChanges(); + + treeItems = GridFunctions.getExcelStyleSearchComponentTreeNodes(fixture, excelMenu, 'igx-pivot-grid'); + expect(treeItems.length).toBe(9); + expect(treeItems[0].querySelector('.igx-tree-node__content').textContent).toBe('All Periods'); + expect(treeItems[1].querySelector('.igx-tree-node__content').textContent).toBe('2021'); + expect(treeItems[2].querySelector('.igx-tree-node__content').textContent).toBe('Q1'); + expect(treeItems[3].querySelector('.igx-tree-node__content').textContent).toBe('January'); + expect(treeItems[4].querySelector('.igx-tree-node__content').textContent).toBe('01/01/2021'); + expect(treeItems[5].querySelector('.igx-tree-node__content').textContent).toBe('Q2'); + expect(treeItems[6].querySelector('.igx-tree-node__content').textContent).toBe('Q4'); + expect(treeItems[7].querySelector('.igx-tree-node__content').textContent).toBe('2019'); + expect(treeItems[8].querySelector('.igx-tree-node__content').textContent).toBe('2020'); + + + const checkBoxes: any[] = Array.from(GridFunctions.getExcelStyleFilteringCheckboxes(fixture, excelMenu, 'igx-pivot-grid')); + // uncheck Q1 + checkBoxes[3].click(); + fixture.detectChanges(); + + // uncheck Q2 + checkBoxes[6].click(); + fixture.detectChanges(); + + // uncheck 2019 + checkBoxes[8].click(); + fixture.detectChanges(); + + // uncheck 2020 + checkBoxes[9].click(); + fixture.detectChanges(); + + // Click 'apply' button to apply filter. + GridFunctions.clickApplyExcelStyleFiltering(fixture, excelMenu, 'igx-pivot-grid'); + fixture.detectChanges(); + + // check rows + const rows = pivotGrid.rowList.toArray(); + expect(rows.length).toBe(5); + const expectedHeaders = ['All Periods', '2021', 'Q4', 'December', '12/08/2021']; + const rowHeaders = fixture.debugElement.queryAll( + By.directive(IgxPivotRowDimensionHeaderComponent)); + const rowDimensionHeaders = rowHeaders.map(x => x.componentInstance.column.header); + expect(rowDimensionHeaders).toEqual(expectedHeaders); + }); + + it('should do nothing on filtering pointer down', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.pivotConfiguration.filters = [ + { + memberName: 'Date', + enabled: true + }, + { + memberName: 'ProductCategory', + enabled: true + } + ]; + + pivotGrid.pivotConfiguration.rows = [{ + memberName: 'SellerName', + enabled: true + }]; + pivotGrid.pipeTrigger++; + pivotGrid.setupColumns(); + fixture.detectChanges(); + + const headerRow = fixture.debugElement.queryAll( + By.directive(IgxPivotHeaderRowComponent))[0].componentInstance; + const filtersChip = headerRow.nativeElement.querySelector('igx-chip[id="Date"]'); + expect(filtersChip).not.toBeUndefined(); + const filterIcon = filtersChip.querySelectorAll('igx-icon')[0]; + + spyOn(headerRow, 'onFilteringIconPointerDown').and.callThrough(); + filterIcon.dispatchEvent(new Event('pointerdown')); + expect(headerRow.onFilteringIconPointerDown).toHaveBeenCalledTimes(1); + }); + + it('should apply sorting for dimension via row chip', () => { + fixture.detectChanges(); + const pivotGrid = fixture.componentInstance.pivotGrid; + spyOn(pivotGrid.dimensionsSortingExpressionsChange, 'emit'); + const headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); + const rowChip = headerRow.querySelector('igx-chip[id="All"]'); + rowChip.click(); + fixture.detectChanges(); + let rowHeaders = fixture.debugElement.queryAll( + By.directive(IgxPivotRowDimensionHeaderComponent)); + let expectedOrder = ['All', 'Accessories', 'Bikes', 'Clothing', 'Components']; + let rowDimensionHeaders = rowHeaders.map(x => x.componentInstance.column.header); + expect(rowDimensionHeaders).toEqual(expectedOrder); + + rowChip.click(); + fixture.detectChanges(); + expectedOrder = ['All', 'Components', 'Clothing', 'Bikes', 'Accessories']; + rowHeaders = fixture.debugElement.queryAll( + By.directive(IgxPivotRowDimensionHeaderComponent)); + rowDimensionHeaders = rowHeaders.map(x => x.componentInstance.column.header); + expect(rowDimensionHeaders).toEqual(expectedOrder); + + // should have emitted event + expect(pivotGrid.dimensionsSortingExpressionsChange.emit).toHaveBeenCalledTimes(2); + const expectedExpressions: ISortingExpression[] = [ + { dir: SortingDirection.Desc, fieldName: 'All', strategy: DefaultPivotSortingStrategy.instance() }, + { dir: SortingDirection.Desc, fieldName: 'ProductCategory', strategy: DefaultPivotSortingStrategy.instance() }, + { dir: SortingDirection.None, fieldName: 'Country', strategy: DefaultPivotSortingStrategy.instance() } + ]; + expect(pivotGrid.dimensionsSortingExpressionsChange.emit).toHaveBeenCalledWith(expectedExpressions); + }); + + it('should apply sorting for dimension via column chip', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + const headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); + const colChip = headerRow.querySelector('igx-chip[id="Country"]'); + spyOn(pivotGrid.dimensionsSortingExpressionsChange, 'emit'); + // sort + colChip.click(); + fixture.detectChanges(); + + let colHeaders = pivotGrid.columns.filter(x => x.level === 0).map(x => x.header); + let expected = ['Bulgaria', 'USA', 'Uruguay']; + expect(colHeaders).toEqual(expected); + + // sort + colChip.click(); + fixture.detectChanges(); + + colHeaders = pivotGrid.columns.filter(x => x.level === 0).map(x => x.header); + expected = ['Uruguay', 'USA', 'Bulgaria']; + expect(colHeaders).toEqual(expected); + const expectedExpressions: ISortingExpression[] = [ + { dir: SortingDirection.None, fieldName: 'All', strategy: DefaultPivotSortingStrategy.instance() }, + { dir: SortingDirection.None, fieldName: 'ProductCategory', strategy: DefaultPivotSortingStrategy.instance() }, + { dir: SortingDirection.Desc, fieldName: 'Country', strategy: DefaultPivotSortingStrategy.instance() } + ]; + expect(pivotGrid.dimensionsSortingExpressionsChange.emit).toHaveBeenCalledWith(expectedExpressions); + expect(pivotGrid.dimensionsSortingExpressions).toEqual(expectedExpressions); + }); + + it('should apply sorting for dimension via column chip when dimension has memberFunction', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.pivotConfiguration.columns = [{ + memberName: 'Country', + memberFunction: (data) => { + return data['Country']; + }, + enabled: true + }]; + pivotGrid.notifyDimensionChange(true); + fixture.detectChanges(); + + const headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); + const colChip = headerRow.querySelector('igx-chip[id="Country"]'); + spyOn(pivotGrid.dimensionsSortingExpressionsChange, 'emit'); + // sort + colChip.click(); + fixture.detectChanges(); + + let colHeaders = pivotGrid.columns.filter(x => x.level === 0).map(x => x.header); + let expected = ['Bulgaria', 'USA', 'Uruguay']; + expect(colHeaders).toEqual(expected); + + // sort + colChip.click(); + fixture.detectChanges(); + + colHeaders = pivotGrid.columns.filter(x => x.level === 0).map(x => x.header); + expected = ['Uruguay', 'USA', 'Bulgaria']; + expect(colHeaders).toEqual(expected); + const expectedExpressions: ISortingExpression[] = [ + { dir: SortingDirection.None, fieldName: 'All', strategy: DefaultPivotSortingStrategy.instance() }, + { dir: SortingDirection.None, fieldName: 'ProductCategory', strategy: DefaultPivotSortingStrategy.instance() }, + { dir: SortingDirection.Desc, fieldName: 'Country', strategy: DefaultPivotSortingStrategy.instance() } + ]; + expect(pivotGrid.dimensionsSortingExpressionsChange.emit).toHaveBeenCalledWith(expectedExpressions); + expect(pivotGrid.dimensionsSortingExpressions).toEqual(expectedExpressions); + }); + + it('should apply correct sorting for IgxPivotDateDimension', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + + pivotGrid.pivotConfiguration.columns = [ + new IgxPivotDateDimension( + { + memberName: 'Date', + memberFunction: (data) => { + return data['Date']; + }, + enabled: true, + dataType: GridColumnDataType.Date + }, + { + total: false, + years: false, + quarters: false, + months: false, + fullDate: true + } + ) + ]; + pivotGrid.notifyDimensionChange(true); + fixture.detectChanges(); + + const headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); + const colChip = headerRow.querySelector('igx-chip[id="Date"]'); + + //sort asc + colChip.click(); + fixture.detectChanges(); + + let colHeaders = pivotGrid.columns.filter(x => x.level === 0).map(x => x.header); + let expected = ['01/05/2019', '01/06/2020', '02/19/2020', '05/12/2020', '01/01/2021', '04/07/2021', '12/08/2021'] + expect(colHeaders).toEqual(expected); + + // sort desc + colChip.click(); + fixture.detectChanges(); + + colHeaders = pivotGrid.columns.filter(x => x.level === 0).map(x => x.header); + expected = ['12/08/2021', '04/07/2021', '01/01/2021', '05/12/2020', '02/19/2020', '01/06/2020', '01/05/2019']; + expect(colHeaders).toEqual(expected); + }); + + it('should sort on column for single row dimension.', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + let headerCell = GridFunctions.getColumnHeader('USA-UnitsSold', fixture); + + // sort asc + GridFunctions.clickHeaderSortIcon(headerCell); + fixture.detectChanges(); + expect(pivotGrid.sortingExpressions.length).toBe(1); + let expectedOrder = [829, undefined, 240, 293, 296]; + let columnValues = pivotGrid.dataView.map(x => (x as IPivotGridRecord).aggregationValues.get('USA-UnitsSold')); + expect(columnValues).toEqual(expectedOrder); + expect(headerCell.attributes['aria-sort']).toBe('ascending'); + + headerCell = GridFunctions.getColumnHeader('USA-UnitsSold', fixture); + // sort desc + GridFunctions.clickHeaderSortIcon(headerCell); + fixture.detectChanges(); + expect(pivotGrid.sortingExpressions.length).toBe(1); + expectedOrder = [829, 296, 293, 240, undefined]; + columnValues = pivotGrid.dataView.map(x => (x as IPivotGridRecord).aggregationValues.get('USA-UnitsSold')); + expect(columnValues).toEqual(expectedOrder); + expect(headerCell.attributes['aria-sort']).toBe('descending'); + + // remove sort + headerCell = GridFunctions.getColumnHeader('USA-UnitsSold', fixture); + GridFunctions.clickHeaderSortIcon(headerCell); + fixture.detectChanges(); + expect(pivotGrid.sortingExpressions.length).toBe(0); + expectedOrder = [829, 296, undefined, 293, 240]; + columnValues = pivotGrid.dataView.map(x => (x as IPivotGridRecord).aggregationValues.get('USA-UnitsSold')); + expect(columnValues).toEqual(expectedOrder); + }); + + // xit-ing because of https://github.com/IgniteUI/igniteui-angular/issues/10546 + xit('should sort on column for all sibling dimensions.', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.height = '1500px'; + pivotGrid.pivotConfiguration.rows = [ + { + memberName: 'ProductCategory', + enabled: true + }, + { + memberName: 'SellerName', + enabled: true + } + ]; + // add a bit more data to sort. + pivotGrid.data = [ + { + ProductCategory: 'Clothing', UnitPrice: 12.81, SellerName: 'Stanley', + Country: 'Bulgaria', Date: '01/01/2021', UnitsSold: 282 + }, + { + ProductCategory: 'Clothing', UnitPrice: 49.57, SellerName: 'Elisa', + Country: 'USA', Date: '01/05/2019', UnitsSold: 296 + }, + { + ProductCategory: 'Bikes', UnitPrice: 3.56, SellerName: 'Lydia', + Country: 'Uruguay', Date: '01/06/2020', UnitsSold: 68 + }, + { + ProductCategory: 'Accessories', UnitPrice: 85.58, SellerName: 'David', + Country: 'USA', Date: '04/07/2021', UnitsSold: 293 + }, + { + ProductCategory: 'Components', UnitPrice: 18.13, SellerName: 'John', + Country: 'USA', Date: '12/08/2021', UnitsSold: 240 + }, + { + ProductCategory: 'Clothing', UnitPrice: 68.33, SellerName: 'Larry', + Country: 'Uruguay', Date: '05/12/2020', UnitsSold: 456 + }, + { + ProductCategory: 'Clothing', UnitPrice: 16.05, SellerName: 'Walter', + Country: 'Bulgaria', Date: '02/19/2020', UnitsSold: 492 + }, + { + ProductCategory: 'Clothing', UnitPrice: 16.05, SellerName: 'Elisa', + Country: 'Bulgaria', Date: '02/19/2020', UnitsSold: 267 + }, + { + ProductCategory: 'Clothing', UnitPrice: 16.05, SellerName: 'Larry', + Country: 'Bulgaria', Date: '02/19/2020', UnitsSold: 100 + } + ]; + pivotGrid.pipeTrigger++; + fixture.detectChanges(); + const headerCell = GridFunctions.getColumnHeader('Bulgaria-UnitsSold', fixture); + // sort asc + GridFunctions.clickHeaderSortIcon(headerCell); + fixture.detectChanges(); + expect(pivotGrid.sortingExpressions.length).toBe(1); + let expectedOrder = [undefined, undefined, undefined, 100, 267, 282, 492]; + let columnValues = pivotGrid.dataView.map(x => x['Bulgaria-UnitsSold']); + expect(columnValues).toEqual(expectedOrder); + + // sort desc + GridFunctions.clickHeaderSortIcon(headerCell); + fixture.detectChanges(); + expect(pivotGrid.sortingExpressions.length).toBe(1); + expectedOrder = [492, 282, 267, 100, undefined, undefined, undefined]; + columnValues = pivotGrid.dataView.map(x => x['Bulgaria-UnitsSold']); + expect(columnValues).toEqual(expectedOrder); + }); + + it('should sort date values', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.height = '700px'; + pivotGrid.width = '1000px'; + pivotGrid.pivotConfiguration.columns = [ + { + memberName: 'Date', + enabled: true, + dataType: 'date' + } + ]; + pivotGrid.pivotConfiguration.rows = [ + { + memberName: 'AllSeller', + memberFunction: () => 'All Sellers', + enabled: true, + childLevel: { + enabled: true, + memberName: 'SellerName' + } + } + ]; + pivotGrid.notifyDimensionChange(true); + fixture.detectChanges(); + + const headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); + const colChip = headerRow.querySelector('igx-chip[id="Date"]'); + + // sort asc + colChip.click(); + fixture.detectChanges(); + + let colHeaders = pivotGrid.columns.filter(x => x.level === 0).map(x => x.header); + let expected = ['01/05/2019', '01/06/2020', '02/19/2020', '05/12/2020', '01/01/2021', '04/07/2021', '12/08/2021'] + expect(colHeaders).toEqual(expected); + + // sort desc + colChip.click(); + fixture.detectChanges(); + + colHeaders = pivotGrid.columns.filter(x => x.level === 0).map(x => x.header); + expected = ['12/08/2021', '04/07/2021', '01/01/2021', '05/12/2020', '02/19/2020', '01/06/2020', '01/05/2019']; + expect(colHeaders).toEqual(expected); + + //remove sort + colChip.click(); + fixture.detectChanges(); + + colHeaders = pivotGrid.columns.filter(x => x.level === 0).map(x => x.header); + expected = ['01/01/2021', '01/05/2019', '01/06/2020', '04/07/2021', '12/08/2021', '05/12/2020', '02/19/2020'] + expect(colHeaders).toEqual(expected); + + }); + + it('should allow changing default aggregation via value chip drop-down.', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.width = '1500px'; + fixture.detectChanges(); + const headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); + const valueChip = headerRow.querySelector('igx-chip[id="UnitsSold"]'); + let content = valueChip.querySelector('.igx-chip__content'); + expect(content.textContent.trim()).toBe('SUM(UnitsSold)'); + + const aggregatesIcon = valueChip.querySelectorAll('igx-icon')[1]; + aggregatesIcon.click(); + fixture.detectChanges(); + const items = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_ITEM}`)); + expect(items.length).toBe(5); + // select count + items[0].triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + + // check chip and row + content = valueChip.querySelector('.igx-chip__content'); + expect(content.textContent.trim()).toBe('COUNT(UnitsSold)'); + expect(pivotGrid.gridAPI.get_cell_by_index(0, 'Bulgaria-UnitsSold').value).toBe(2); + expect(pivotGrid.gridAPI.get_cell_by_index(0, 'USA-UnitsSold').value).toBe(3); + expect(pivotGrid.gridAPI.get_cell_by_index(0, 'Uruguay-UnitsSold').value).toBe(2); + }); + + it('should allow showing custom aggregations via pivot configuration.', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.pivotConfiguration.values = []; + pivotGrid.pivotConfiguration.values.push({ + member: 'AmountOfSale', + displayName: 'Amount of Sale', + aggregate: { + key: 'SUM', + aggregator: IgxTotalSaleAggregate.totalSale, + label: 'Sum of Sale' + }, + aggregateList: [{ + key: 'SUM', + aggregator: IgxTotalSaleAggregate.totalSale, + label: 'Sum of Sale' + }, { + key: 'MIN', + aggregator: IgxTotalSaleAggregate.totalMin, + label: 'Minimum of Sale' + }, { + key: 'MAX', + aggregator: IgxTotalSaleAggregate.totalMax, + label: 'Maximum of Sale' + }], + enabled: true + }); + pivotGrid.pipeTrigger++; + pivotGrid.setupColumns(); + fixture.detectChanges(); + const headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); + const valueChip = headerRow.querySelector('igx-chip[id="Amount of Sale"]'); + let content = valueChip.querySelector('.igx-chip__content'); + expect(content.textContent.trim()).toBe('SUM(Amount of Sale)'); + + const aggregatesIcon = valueChip.querySelectorAll('igx-icon')[1]; + aggregatesIcon.click(); + fixture.detectChanges(); + + const items = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_ITEM}`)); + expect(items.length).toBe(3); + // select min + items[1].triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + // check chip and row values + content = valueChip.querySelector('.igx-chip__content'); + expect(content.textContent.trim()).toBe('MIN(Amount of Sale)'); + expect(pivotGrid.gridAPI.get_cell_by_index(0, 'Bulgaria').value).toBe(3612.42); + expect(pivotGrid.gridAPI.get_cell_by_index(0, 'USA').value).toBe(0); + expect(pivotGrid.gridAPI.get_cell_by_index(0, 'Uruguay').value).toBe(242.08); + }); + it('should show one aggregations drop-down at a time', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.width = '1500px'; + fixture.detectChanges(); + const headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); + const valueChipUnitsSold = headerRow.querySelector('igx-chip[id="UnitsSold"]'); + + const aggregatesIconUnitsSold = valueChipUnitsSold.querySelectorAll('igx-icon')[1]; + aggregatesIconUnitsSold.click(); + fixture.detectChanges(); + + let dropDown = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_LIST}`)); + expect(dropDown.length).toBe(2); + + const valueChipUnitPrice = headerRow.querySelector('igx-chip[id="UnitPrice"]'); + + const aggregatesIconUnitPrice = valueChipUnitPrice.querySelectorAll('igx-icon')[1]; + aggregatesIconUnitPrice.click(); + fixture.detectChanges(); + + dropDown = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_LIST}`)); + expect(dropDown.length).toBe(2); + }); + + it('should allow reorder in row chip area.', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.pivotConfiguration.rows = [ + { + memberName: 'ProductCategory', + enabled: true + }, + { + memberName: 'SellerName', + enabled: true + } + ]; + pivotGrid.pipeTrigger++; + pivotGrid.setupColumns(); + fixture.detectChanges(); + + const headerRow: IgxPivotHeaderRowComponent = fixture.debugElement.query(By.directive(IgxPivotHeaderRowComponent)).componentInstance; + const chipAreas = fixture.debugElement.queryAll(By.directive(IgxChipsAreaComponent)); + const rowChipArea: IgxChipsAreaComponent = chipAreas[3].componentInstance; + const rowChip1 = rowChipArea.chipsList.toArray()[0]; + const rowChip2 = rowChipArea.chipsList.toArray()[1]; + + // start drag in row chip area. + headerRow.onDimDragStart({}, rowChipArea); + fixture.detectChanges(); + + // move first chip over the second one + headerRow.onDimDragOver({ + dragChip: { + id: 'ProductCategory', + data: { pivotArea: 'row' } + }, + owner: rowChip2, + originalEvent: { + offsetX: 100 + } + }, PivotDimensionType.Row); + fixture.detectChanges(); + + // check drop indicator has shown after the second chip + expect((rowChip2.nativeElement.nextElementSibling as any).style.visibility).toBe(''); + + // drop chip + headerRow.onDimDrop({ + dragChip: { + id: 'ProductCategory', + data: { pivotArea: 'row' } + }, + owner: rowChip2 + }, rowChipArea, PivotDimensionType.Row); + pivotGrid.cdr.detectChanges(); + //check chip order is updated. + expect(rowChipArea.chipsList.toArray()[0].id).toBe(rowChip2.id); + expect(rowChipArea.chipsList.toArray()[1].id).toBe(rowChip1.id); + // check dimension order is updated. + expect(pivotGrid.pivotConfiguration.rows.map(x => x.memberName)).toEqual(['SellerName', 'ProductCategory']); + }); + + it('should allow reorder in column chip area.', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.pivotConfiguration.columns.push({ + memberName: 'SellerName', + enabled: true + }); + pivotGrid.pipeTrigger++; + pivotGrid.setupColumns(); + fixture.detectChanges(); + + const headerRow: IgxPivotHeaderRowComponent = fixture.debugElement.query(By.directive(IgxPivotHeaderRowComponent)).componentInstance; + const chipAreas = fixture.debugElement.queryAll(By.directive(IgxChipsAreaComponent)); + const colChipArea: IgxChipsAreaComponent = chipAreas[1].componentInstance; + const colChip1 = colChipArea.chipsList.toArray()[0]; + const colChip2 = colChipArea.chipsList.toArray()[1]; + + // start drag in col chip area. + headerRow.onDimDragStart({}, colChipArea); + fixture.detectChanges(); + + // move first chip over the second one + headerRow.onDimDragOver({ + dragChip: { + id: 'Country', + data: { pivotArea: 'column' } + }, + owner: colChip2, + originalEvent: { + offsetX: 100 + } + }, PivotDimensionType.Column); + fixture.detectChanges(); + + // check drop indicator has shown after the second chip + expect((colChip2.nativeElement.nextElementSibling as any).style.visibility).toBe(''); + + // drop chip + headerRow.onDimDrop({ + dragChip: { + id: 'Country', + data: { pivotArea: 'column' } + }, + owner: colChip2 + }, colChipArea, PivotDimensionType.Column); + pivotGrid.cdr.detectChanges(); + + headerRow.onDimDragLeave({ + owner: colChip2 + }); + expect((colChip2.nativeElement.previousElementSibling as any).style.visibility).toBe('hidden'); + expect((colChip2.nativeElement.nextElementSibling as any).style.visibility).toBe('hidden'); + + //check chip order is updated. + expect(colChipArea.chipsList.toArray()[0].id).toBe(colChip2.id); + expect(colChipArea.chipsList.toArray()[1].id).toBe(colChip1.id); + // check dimension order is updated. + expect(pivotGrid.pivotConfiguration.columns.map(x => x.memberName)).toEqual(['SellerName', 'Country']); + // check columns reflect new order of dims + const cols = pivotGrid.columns; + expect(cols.filter(x => x.level === 0).map(x => x.field)).toEqual(['Stanley', 'Elisa', 'Lydia', 'David', 'John', 'Larry', 'Walter']); + }); + it('should allow reorder in the value chip area', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + fixture.detectChanges(); + const headerRow: IgxPivotHeaderRowComponent = fixture.debugElement.query(By.directive(IgxPivotHeaderRowComponent)).componentInstance; + let chipAreas = fixture.debugElement.queryAll(By.directive(IgxChipsAreaComponent)); + let valuesChipArea: IgxChipsAreaComponent = chipAreas[2].componentInstance; + let valChip1 = valuesChipArea.chipsList.toArray()[0]; + let valChip2 = valuesChipArea.chipsList.toArray()[1]; + + // move first chip over the second one + headerRow.onDimDragOver({ + dragChip: { + id: 'UnitsSold', + data: { pivotArea: 'value' } + }, + owner: valChip2, + originalEvent: { + offsetX: 110 + } + }); + fixture.detectChanges(); + + // check drop indicator has shown after the second chip + expect((valChip2.nativeElement.nextElementSibling as any).style.visibility).toBe(''); + + // drop chip + headerRow.onValueDrop({ + dragChip: valChip1, + owner: valChip2 + }, valuesChipArea); + pivotGrid.cdr.detectChanges(); + fixture.detectChanges(); + + //check chip order is updated. + expect(valuesChipArea.chipsList.toArray()[0].id).toBe(valChip2.id); + expect(valuesChipArea.chipsList.toArray()[1].id).toBe(valChip1.id); + // check dimension order is updated. + expect(pivotGrid.pivotConfiguration.values.map(x => x.member)).toEqual(['UnitPrice', 'UnitsSold']); + + // should be able to move on the opposite side + chipAreas = fixture.debugElement.queryAll(By.directive(IgxChipsAreaComponent)); + valuesChipArea = chipAreas[2].componentInstance; + valChip1 = valuesChipArea.chipsList.toArray()[0]; + valChip2 = valuesChipArea.chipsList.toArray()[1]; + headerRow.onDimDragOver({ + dragChip: { + id: 'UnitsSold', + data: { pivotArea: 'value' } + }, + owner: valChip1, + originalEvent: { + offsetX: -110 + } + }); + fixture.detectChanges(); + + headerRow.onValueDrop({ + dragChip: valChip2, + owner: valChip1 + }, valuesChipArea); + pivotGrid.cdr.detectChanges(); + fixture.detectChanges(); + //check chip order is updated. + expect(valuesChipArea.chipsList.toArray()[0].id).toBe(valChip2.id); + expect(valuesChipArea.chipsList.toArray()[1].id).toBe(valChip1.id); + // check dimension order is updated. + expect(pivotGrid.pivotConfiguration.values.map(x => x.member)).toEqual(['UnitsSold', 'UnitPrice']); + + //should not be able to drag value to row + headerRow.onDimDragOver({ + dragChip: { + id: 'UnitsSold', + data: { pivotArea: 'value' } + }, + owner: valChip2, + originalEvent: { + offsetX: 110 + } + }, PivotDimensionType.Row); + fixture.detectChanges(); + + expect(pivotGrid.pivotConfiguration.values.map(x => x.member)).toEqual(['UnitsSold', 'UnitPrice']); + expect(pivotGrid.pivotConfiguration.rows.length).toBe(1); + }); + it('should allow moving dimension between rows, columns and filters.', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.pivotConfiguration.rows = [ + { + memberName: 'All', + memberFunction: () => 'All', + enabled: true, + width: "300px", + childLevel: { + memberName: 'ProductCategory', + memberFunction: (data) => data.ProductCategory, + enabled: true + } + } + ]; + pivotGrid.pivotConfiguration.filters = [{ + memberName: 'SellerName', + enabled: true + }]; + pivotGrid.pipeTrigger++; + pivotGrid.setupColumns(); + fixture.detectChanges(); + const headerRow: IgxPivotHeaderRowComponent = fixture.debugElement.query(By.directive(IgxPivotHeaderRowComponent)).componentInstance; + const chipAreas = fixture.debugElement.queryAll(By.directive(IgxChipsAreaComponent)); + const colChipArea: IgxChipsAreaComponent = chipAreas[1].componentInstance; + const rowChipArea: IgxChipsAreaComponent = chipAreas[3].componentInstance; + const filterChipArea: IgxChipsAreaComponent = chipAreas[0].componentInstance; + const filterChip = filterChipArea.chipsList.first; + // start drag in filter chip area. + headerRow.onDimDragStart({}, filterChipArea); + fixture.detectChanges(); + + // check drop here chips are displayed in other areas + expect(headerRow.notificationChips.toArray()[1].nativeElement.hidden).toBeFalse(); + expect(headerRow.notificationChips.toArray()[2].nativeElement.hidden).toBeFalse(); + + const dropHereRowChip = headerRow.notificationChips.toArray()[2]; + // move Seller onto the drop here chip + + // drop chip + headerRow.onDimDrop({ + dragChip: filterChip, + owner: dropHereRowChip + }, rowChipArea, PivotDimensionType.Row); + fixture.detectChanges(); + pivotGrid.cdr.detectChanges(); + + // check dimensions + expect(pivotGrid.pivotConfiguration.filters.filter(x => x.enabled).length).toBe(0); + expect(pivotGrid.pivotConfiguration.rows.filter(x => x.enabled).length).toBe(2); + + const rowSellerChip = rowChipArea.chipsList.toArray()[1]; + const colChip = colChipArea.chipsList.first; + // start drag in row chip area. + headerRow.onDimDragStart({}, rowChipArea); + fixture.detectChanges(); + + // drag Seller from row dimension as first chip in columns + headerRow.onDimDragOver({ + dragChip: rowSellerChip, + owner: colChip, + originalEvent: { + offsetX: 0 + } + }, PivotDimensionType.Column); + fixture.detectChanges(); + //check drop indicator between chips + expect((colChip.nativeElement.previousElementSibling as any).style.visibility).toBe(''); + expect((colChip.nativeElement.nextElementSibling as any).style.visibility).toBe('hidden'); + + // drop chip + headerRow.onDimDrop({ + dragChip: rowSellerChip, + owner: colChip + }, colChipArea, PivotDimensionType.Column); + pivotGrid.cdr.detectChanges(); + fixture.detectChanges(); + + // check dimensions + expect(pivotGrid.pivotConfiguration.filters.filter(x => x.enabled).length).toBe(0); + expect(pivotGrid.pivotConfiguration.rows.filter(x => x.enabled).length).toBe(1); + expect(pivotGrid.pivotConfiguration.columns.filter(x => x.enabled).length).toBe(2); + + // drag Seller over filter area + const colSellerChip = colChipArea.chipsList.toArray()[0]; + // start drag in col chip area. + headerRow.onDimDragStart({}, colChipArea); + // drop chip + headerRow.onDimDrop({ + dragChip: colSellerChip, + owner: {} + }, filterChipArea, PivotDimensionType.Filter); + pivotGrid.cdr.detectChanges(); + pivotGrid.pipeTrigger++; + fixture.detectChanges(); + + expect(pivotGrid.pivotConfiguration.filters.filter(x => x.enabled).length).toBe(1); + expect(pivotGrid.pivotConfiguration.rows.filter(x => x.enabled).length).toBe(1); + expect(pivotGrid.pivotConfiguration.columns.filter(x => x.enabled).length).toBe(1); + }); + + it('should hide drop indicators when moving out of the drop area.', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.pivotConfiguration.rows = [ + { + memberName: 'ProductCategory', + enabled: true + }, + { + memberName: 'SellerName', + enabled: true + } + ]; + pivotGrid.pipeTrigger++; + pivotGrid.setupColumns(); + fixture.detectChanges(); + + const headerRow: IgxPivotHeaderRowComponent = fixture.debugElement.query(By.directive(IgxPivotHeaderRowComponent)).componentInstance; + const chipAreas = fixture.debugElement.queryAll(By.directive(IgxChipsAreaComponent)); + const rowChipArea: IgxChipsAreaComponent = chipAreas[3].componentInstance; + const rowChip1 = rowChipArea.chipsList.toArray()[0]; + const rowChip2 = rowChipArea.chipsList.toArray()[1]; + + // start drag in row chip area. + headerRow.onDimDragStart({}, rowChipArea); + fixture.detectChanges(); + + // drag second chip before prev chip + headerRow.onDimDragOver({ + dragChip: rowChip2, + owner: rowChip1, + originalEvent: { + offsetX: 0 + } + }, PivotDimensionType.Row); + fixture.detectChanges(); + + // should show the prev drop indicator for the 1st chip + expect((rowChip1.nativeElement.previousElementSibling as any).style.visibility).toBe(''); + expect((rowChip1.nativeElement.nextElementSibling as any).style.visibility).toBe('hidden'); + + // simulate drag area leave + headerRow.onAreaDragLeave({}, rowChipArea); + + expect((rowChip1.nativeElement.previousElementSibling as any).style.visibility).toBe('hidden'); + expect((rowChip1.nativeElement.nextElementSibling as any).style.visibility).toBe('hidden'); + }); + + it('should auto-size row dimension via the API.', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + const rowDimension = pivotGrid.pivotConfiguration.rows[0]; + expect(rowDimension.width).toBeUndefined(); + expect(pivotGrid.rowDimensionWidthToPixels(rowDimension)).toBe(200); + pivotGrid.autoSizeRowDimension(rowDimension); + fixture.detectChanges(); + expect(rowDimension.width).toBe('162px'); + expect(pivotGrid.rowDimensionWidthToPixels(rowDimension)).toBe(162); + }); + + it('should auto-size row dimension when width is set to auto.', fakeAsync(() => { + const pivotGrid = fixture.componentInstance.pivotGrid; + let rowDimension = pivotGrid.pivotConfiguration.rows[0]; + expect(rowDimension.width).toBeUndefined(); + expect(pivotGrid.rowDimensionWidthToPixels(rowDimension)).toBe(200); + fixture.componentInstance.pivotConfigHierarchy = { + columns: [{ + memberName: 'Country', + enabled: true + }, + ], + rows: [{ + memberName: 'All', + memberFunction: () => 'All', + enabled: true, + width: "auto", + childLevel: { + memberName: 'ProductCategory', + memberFunction: (data) => data.ProductCategory, + enabled: true + } + }], + values: [ + { + member: 'UnitPrice', + aggregate: { + aggregator: IgxPivotNumericAggregate.sum, + key: 'SUM', + label: 'Sum', + }, + enabled: true, + dataType: 'currency' + } + ], + filters: [] + }; + + fixture.detectChanges(); + tick(200); + rowDimension = pivotGrid.pivotConfiguration.rows[0]; + expect(rowDimension.autoWidth).toBe(162); + expect(rowDimension.width).toBe('auto'); + expect(pivotGrid.rowDimensionWidthToPixels(rowDimension)).toBe(162); + })); + + it('should auto-generate pivot config when autoGenerateConfig is set to true.', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.pivotConfiguration = undefined; + pivotGrid.data = []; + fixture.detectChanges(); + + expect(pivotGrid.pivotConfiguration).toEqual({ rows: null, columns: null, values: null, filters: null }); + pivotGrid.autoGenerateConfig = true; + fixture.detectChanges(); + expect(pivotGrid.pivotConfiguration).toEqual({ rows: null, columns: null, values: null, filters: null }); + + pivotGrid.data = [{ + ProductCategory: 'Clothing', UnitPrice: 12.81, SellerName: 'Stanley', + Country: 'Bulgaria', Date: new Date('01/01/2021'), UnitsSold: 282 + }]; + fixture.detectChanges(); + + expect(pivotGrid.allDimensions.length).toEqual(4); + // only date is row dimension and is enabled by default + expect(pivotGrid.pivotConfiguration.rows.map(x => x.memberName)).toEqual(['AllPeriods']); + expect(pivotGrid.pivotConfiguration.rows.map(x => x.enabled)).toEqual([true]); + // all other are disabled column dimensions. + expect(pivotGrid.pivotConfiguration.columns.map(x => x.memberName)).toEqual(['ProductCategory', 'SellerName', 'Country']); + expect(pivotGrid.pivotConfiguration.columns.map(x => x.enabled)).toEqual([false, false, false]); + // values are all enabled. + expect(pivotGrid.values.length).toEqual(2); + expect(pivotGrid.values.map(x => x.member)).toEqual(['UnitPrice', 'UnitsSold']); + expect(pivotGrid.values.map(x => x.enabled)).toEqual([true, true]); + }); + + it('should allow creating IgxPivotDateDimension with no base dimension and setting it later.', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + const dateDimension = new IgxPivotDateDimension(); + dateDimension.baseDimension = { + memberName: 'Date', + enabled: true, + }; + dateDimension.options = { + fullDate: true, + months: false, + total: false, + years: true, + quarters: false + } + + expect(dateDimension.enabled).toBe(true); + + pivotGrid.pivotConfiguration.rows = [dateDimension]; + pivotGrid.pipeTrigger++; + fixture.detectChanges(); + + expect(pivotGrid.rowList.length).toBe(10); + }); + + it('should have the correct IGridCellEventArgs when clicking on a cell', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + spyOn(pivotGrid.cellClick, 'emit').and.callThrough(); + fixture.detectChanges(); + + const cell = pivotGrid.gridAPI.get_cell_by_index(0, 'Bulgaria-UnitsSold'); + + const event = new Event('click'); + cell.nativeElement.dispatchEvent(event); + fixture.detectChanges(); + + const expectedCell = new IgxGridCell(pivotGrid, 0, cell.column); + + const cellClickArgs: IGridCellEventArgs = { cell: expectedCell, event }; + expect(pivotGrid.cellClick.emit).toHaveBeenCalledOnceWith(cellClickArgs); + }); + }); + }); + + describe('IgxPivotGrid complex hierarchy #pivotGrid', () => { + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(IgxPivotGridTestComplexHierarchyComponent); + fixture.detectChanges(); + })); + + it('should select/deselect the correct row', () => { + fixture.detectChanges(); + const pivotGrid = fixture.componentInstance.pivotGrid; + expect(pivotGrid.selectedRows).toEqual([]); + const pivotRows = GridFunctions.getPivotRows(fixture); + const row = pivotRows[1].componentInstance; + const rowHeaders = fixture.debugElement.queryAll( + By.directive(IgxPivotRowDimensionHeaderComponent)); + const secondDimCell = rowHeaders.find(x => x.componentInstance.column.header === 'Clothing'); + secondDimCell.nativeElement.click(); + fixture.detectChanges(); + expect(row.selected).toBeTrue(); + expect(pivotGrid.selectedRows).not.toBeNull(); + expect(pivotGrid.selectedRows.length).toBe(1); + expect((pivotGrid.selectedRows[0] as IPivotGridRecord).dimensionValues.get('All cities')).toBe('All Cities'); + expect((pivotGrid.selectedRows[0] as IPivotGridRecord).dimensionValues.get('ProductCategory')).toBe('Clothing'); + + //deselect + secondDimCell.nativeElement.click(); + fixture.detectChanges(); + expect(row.selected).toBeFalse(); + expect(pivotGrid.selectedRows.length).toBe(0); + }); + + it('should select/deselect the correct group of rows', () => { + fixture.detectChanges(); + const pivotGrid = fixture.componentInstance.pivotGrid; + const pivotRows = GridFunctions.getPivotRows(fixture); + const rowHeaders = fixture.debugElement.queryAll( + By.directive(IgxPivotRowDimensionHeaderComponent)); + const firstDimCell = rowHeaders.find(x => x.componentInstance.column.header === 'All Cities'); + firstDimCell.nativeElement.click(); + fixture.detectChanges(); + for (let i = 0; i < 5; ++i) { + expect(pivotRows[i].componentInstance.selected).toBeTrue(); + } + expect(pivotGrid.selectedRows).not.toBeNull(); + expect(pivotGrid.selectedRows.length).toBe(5); + const dimensionValues = PivotGridFunctions.getDimensionValues(pivotGrid.selectedRows); + const expected = + [ + { + AllProducts: 'AllProducts', 'All cities': 'All Cities' + }, + { + ProductCategory: 'Clothing', 'All cities': 'All Cities' + }, + { + ProductCategory: 'Bikes', 'All cities': 'All Cities' + }, + { + ProductCategory: 'Accessories', 'All cities': 'All Cities' + }, + { + ProductCategory: 'Components', 'All cities': 'All Cities' + } + ]; + expect(dimensionValues).toEqual(expected); + }); + + it('should select/deselect the correct column', () => { + fixture.detectChanges(); + const pivotGrid = fixture.componentInstance.pivotGrid; + const unitsSold = pivotGrid.getColumnByName('Bulgaria-UnitsSold'); + GridFunctions.clickColumnHeaderUI('Bulgaria-UnitsSold', fixture); + GridSelectionFunctions.verifyColumnAndCellsSelected(unitsSold); + }); + + it('should select/deselect the correct column group', () => { + fixture.detectChanges(); + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.width = '1500px'; + fixture.detectChanges(); + const group = GridFunctions.getColGroup(pivotGrid, 'Bulgaria'); + const unitsSold = pivotGrid.getColumnByName('Bulgaria-UnitsSold'); + const amountOfSale = pivotGrid.getColumnByName('Bulgaria-AmountOfSale'); + const unitsSoldUSA = pivotGrid.getColumnByName('US-UnitsSold'); + const amountOfSaleUSA = pivotGrid.getColumnByName('US-AmountOfSale'); + + GridFunctions.clickColumnGroupHeaderUI('Bulgaria', fixture); + fixture.detectChanges(); + + GridSelectionFunctions.verifyColumnSelected(unitsSold); + GridSelectionFunctions.verifyColumnSelected(amountOfSale); + GridSelectionFunctions.verifyColumnGroupSelected(fixture, group); + + GridSelectionFunctions.verifyColumnsSelected([unitsSoldUSA, amountOfSaleUSA], false); + + GridFunctions.clickColumnGroupHeaderUI('Bulgaria', fixture); + + GridSelectionFunctions.verifyColumnSelected(unitsSold, false); + GridSelectionFunctions.verifyColumnSelected(amountOfSale, false); + GridSelectionFunctions.verifyColumnGroupSelected(fixture, group, false); + }); + + it('should provide value formatter column data for second value', () => { + let correctFirstColumnData = true; + let correctSecondColumnData = true; + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.pivotConfiguration = { + columns: fixture.componentInstance.pivotConfigHierarchy.columns, + rows: fixture.componentInstance.pivotConfigHierarchy.rows, + values: [ + { + member: 'UnitsSold', + aggregate: { + aggregator: IgxPivotNumericAggregate.sum, + key: 'SUM', + label: 'Sum' + }, + enabled: true, + formatter: (value, row, column) => { + if (!column || !column.value || column.value.member !== 'UnitsSold') { + correctFirstColumnData = false; + } + return value; + } + }, + { + member: 'AmountOfSale', + displayName: 'Amount of Sale', + aggregate: { + aggregator: IgxTotalSaleAggregate.totalSale, + key: 'TOTAL', + label: 'Total' + }, + enabled: true, + formatter: (value, row, column) => { + if (!column || !column.value || column.value.member !== 'AmountOfSale') { + correctSecondColumnData = false; + } + return value; + } + } + ] + }; + + pivotGrid.width = '1500px'; + fixture.detectChanges(); + expect(correctFirstColumnData).toBeTruthy(); + expect(correctSecondColumnData).toBeTruthy(); + }); + }); + + describe('IgxPivotGrid Resizing #pivotGrid', () => { + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(IgxPivotGridTestComplexHierarchyComponent); + fixture.detectChanges(); + })); + + it('should define grid with resizable columns.', fakeAsync(() => { + const dimensionContents = fixture.debugElement.queryAll(By.css('.igx-grid__tbody-pivot-dimension')); + + let rowHeaders = dimensionContents[0].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + expect(rowHeaders[0].componentInstance.column.resizable).toBeTrue(); + expect(rowHeaders[3].componentInstance.column.resizable).toBeTrue(); + + rowHeaders = dimensionContents[1].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + expect(rowHeaders[0].componentInstance.column.resizable).toBeTrue(); + expect(rowHeaders[1].componentInstance.column.resizable).toBeTrue(); + expect(rowHeaders[5].componentInstance.column.resizable).toBeTrue(); + expect(rowHeaders[7].componentInstance.column.resizable).toBeTrue(); + })); + + it('should update grid after resizing a top dimension header to be bigger.', fakeAsync(() => { + const dimensionContents = fixture.debugElement.queryAll(By.css('.igx-grid__tbody-pivot-dimension')); + + let rowHeaders = dimensionContents[0].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + expect(rowHeaders[0].componentInstance.column.width).toEqual('200px'); + expect(rowHeaders[3].componentInstance.column.width).toEqual('200px'); + + rowHeaders = dimensionContents[1].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + expect(rowHeaders[0].componentInstance.column.width).toEqual('200px'); + expect(rowHeaders[1].componentInstance.column.width).toEqual('200px'); + expect(rowHeaders[5].componentInstance.column.width).toEqual('200px'); + expect(rowHeaders[7].componentInstance.column.width).toEqual('200px'); + + rowHeaders = dimensionContents[0].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + const headerResArea = GridFunctions.getHeaderResizeArea(rowHeaders[0]).nativeElement; + + // Resize first column + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 100, 0); + tick(200); + fixture.detectChanges(); + + const resizer = GridFunctions.getResizer(fixture).nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 300, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 300, 5); + fixture.detectChanges(); + + rowHeaders = dimensionContents[0].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + expect(rowHeaders[0].componentInstance.column.width).toEqual('400px'); + expect(rowHeaders[3].componentInstance.column.width).toEqual('400px'); + + rowHeaders = dimensionContents[1].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + expect(rowHeaders[0].componentInstance.column.width).toEqual('200px'); + expect(rowHeaders[1].componentInstance.column.width).toEqual('200px'); + expect(rowHeaders[5].componentInstance.column.width).toEqual('200px'); + expect(rowHeaders[7].componentInstance.column.width).toEqual('200px'); + })); + + it('should update grid after resizing a child dimension header to be bigger.', fakeAsync(() => { + const dimensionContents = fixture.debugElement.queryAll(By.css('.igx-grid__tbody-pivot-dimension')); + + let rowHeaders = dimensionContents[0].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + expect(rowHeaders[0].componentInstance.column.width).toEqual('200px'); + expect(rowHeaders[3].componentInstance.column.width).toEqual('200px'); + + const headerResArea = GridFunctions.getHeaderResizeArea(rowHeaders[3]).nativeElement; + + // Resize first column + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 100, 0); + tick(200); + fixture.detectChanges(); + + const resizer = GridFunctions.getResizer(fixture).nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 300, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 300, 5); + fixture.detectChanges(); + + rowHeaders = dimensionContents[0].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + expect(rowHeaders[0].componentInstance.column.width).toEqual('400px'); + expect(rowHeaders[3].componentInstance.column.width).toEqual('400px'); + + rowHeaders = dimensionContents[1].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + expect(rowHeaders[0].componentInstance.column.width).toEqual('200px'); + expect(rowHeaders[1].componentInstance.column.width).toEqual('200px'); + expect(rowHeaders[5].componentInstance.column.width).toEqual('200px'); + expect(rowHeaders[7].componentInstance.column.width).toEqual('200px'); + })); + + it('should update grid after resizing with double click', fakeAsync(() => { + const dimensionContents = fixture.debugElement.queryAll(By.css('.igx-grid__tbody-pivot-dimension')); + + let rowHeaders = dimensionContents[0].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + expect(rowHeaders[0].componentInstance.column.width).toEqual('200px'); + expect(rowHeaders[3].componentInstance.column.width).toEqual('200px'); + + const headerResArea = GridFunctions.getHeaderResizeArea(rowHeaders[3]).nativeElement; + + // Resize first column + UIInteractions.simulateMouseEvent('dblclick', headerResArea, 100, 0); + tick(200); + fixture.detectChanges(); + + rowHeaders = dimensionContents[0].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + expect(parseFloat(rowHeaders[0].componentInstance.column.width)).toBeGreaterThan(200); + expect(parseFloat(rowHeaders[3].componentInstance.column.width)).toBeGreaterThan(200); + + rowHeaders = dimensionContents[1].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + expect(rowHeaders[0].componentInstance.column.width).toEqual('200px'); + expect(rowHeaders[1].componentInstance.column.width).toEqual('200px'); + expect(rowHeaders[5].componentInstance.column.width).toEqual('200px'); + expect(rowHeaders[7].componentInstance.column.width).toEqual('200px'); + })); + + it('should update grid after resizing to equal min width', fakeAsync(() => { + const dimensionContents = fixture.debugElement.queryAll(By.css('.igx-grid__tbody-pivot-dimension')); + + let rowHeaders = dimensionContents[0].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + expect(rowHeaders[0].componentInstance.column.width).toEqual('200px'); + expect(rowHeaders[3].componentInstance.column.width).toEqual('200px'); + + const headerResArea = GridFunctions.getHeaderResizeArea(rowHeaders[3]).nativeElement; + + // Resize first column + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 100, 0); + tick(200); + fixture.detectChanges(); + + const resizer = GridFunctions.getResizer(fixture).nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, -400, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, -400, 5); + fixture.detectChanges(); + + rowHeaders = dimensionContents[0].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + const minWdith = parseFloat(rowHeaders[0].componentInstance.column.defaultMinWidth); + expect(parseFloat(rowHeaders[0].componentInstance.column.width)).toEqual(minWdith); + expect(parseFloat(rowHeaders[3].componentInstance.column.width)).toEqual(minWdith); + + rowHeaders = dimensionContents[1].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + expect(rowHeaders[0].componentInstance.column.width).toEqual('200px'); + expect(rowHeaders[1].componentInstance.column.width).toEqual('200px'); + expect(rowHeaders[5].componentInstance.column.width).toEqual('200px'); + expect(rowHeaders[7].componentInstance.column.width).toEqual('200px'); + })); + + it('should update grid after resizing with percentages', fakeAsync(() => { + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.width = '1000px'; + pivotGrid.pivotConfiguration.rows[0].width = '20%'; + pivotGrid.notifyDimensionChange(true); + fixture.detectChanges; + + const dimensionContents = fixture.debugElement.queryAll(By.css('.igx-grid__tbody-pivot-dimension')); + + let rowHeaders = dimensionContents[0].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + expect(parseFloat(rowHeaders[0].componentInstance.column.width)).toBeGreaterThan(150); + expect(parseFloat(rowHeaders[3].componentInstance.column.width)).toBeGreaterThan(150); + expect(pivotGrid.pivotConfiguration.rows[0].width).toEqual('20%'); + + const headerResArea = GridFunctions.getHeaderResizeArea(rowHeaders[3]).nativeElement; + + // Resize first column + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 100, 0); + tick(200); + fixture.detectChanges(); + + const resizer = GridFunctions.getResizer(fixture).nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, -100, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, -100, 5); + fixture.detectChanges(); + + rowHeaders = dimensionContents[0].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + expect(parseFloat(rowHeaders[0].componentInstance.column.width)).toBeLessThan(150); + expect(parseFloat(rowHeaders[3].componentInstance.column.width)).toBeLessThan(150); + // less than 10% + expect(parseFloat(pivotGrid.pivotConfiguration.rows[0].width)).toBeLessThan(10); + + + rowHeaders = dimensionContents[1].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + expect(rowHeaders[0].componentInstance.column.width).toEqual('200px'); + })); + + + it('Should not expand columns if collapsed after sorting', () => { + const pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.width = '1600px'; + fixture.detectChanges(); + pivotGrid.pivotConfiguration.columns = [ + pivotGrid.pivotConfiguration.rows[1] + ]; + pivotGrid.pivotConfiguration.rows.pop(); + pivotGrid.notifyDimensionChange(true); + fixture.detectChanges(); + + expect(pivotGrid.columns.length).toBe(16); + expect(pivotGrid.rowList.first.cells.length).toBe(8); + + const headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); + const header = headerRow.querySelector('igx-grid-header-group'); + const expander = header.querySelectorAll('igx-icon')[0]; + expander.click(); + fixture.detectChanges(); + expect(pivotGrid.columnGroupStates.size).toBe(1); + expect(pivotGrid.rowList.first.cells.length).toBe(2); + + const colChip = headerRow.querySelector('igx-chip[id="AllProducts"]'); + + // sort + colChip.click(); + fixture.detectChanges(); + + expect(pivotGrid.columnGroupStates.size).toBe(1); + expect(pivotGrid.rowList.first.cells.length).toBe(2); + }); + + it("should position correct the horizontal scrollbar", () => { + fixture.detectChanges(); + const scrollBarPosition = fixture.nativeElement.querySelector("igx-horizontal-virtual-helper").getBoundingClientRect(); + const displayContainerPosition = fixture.nativeElement.querySelector(".igx-grid__tbody-content").getBoundingClientRect() + expect(scrollBarPosition.x).toEqual(displayContainerPosition.x); + }); + }); + + describe('IgxPivotGrid APIs #pivotGrid', () => { + let fixture: ComponentFixture; + let pivotGrid: IgxPivotGridComponent; + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(IgxPivotGridTestComplexHierarchyComponent); + fixture.detectChanges(); + + pivotGrid = fixture.componentInstance.pivotGrid; + })); + + + it('should allow inserting new dimension.', () => { + //insert wtihout index + pivotGrid.insertDimensionAt({ memberName: 'Date', enabled: true }, PivotDimensionType.Row); + fixture.detectChanges(); + expect(pivotGrid.pivotConfiguration.rows[2].memberName).toBe('Date'); + + // At Index + // insert in rows + pivotGrid.insertDimensionAt({ memberName: 'SellerName', enabled: true }, PivotDimensionType.Row, 1); + fixture.detectChanges(); + expect(pivotGrid.pivotConfiguration.rows[1].memberName).toBe('SellerName'); + // check rows + const dimensionContents = fixture.debugElement.queryAll(By.css('.igx-grid__tbody-pivot-dimension')); + const rowHeaders = dimensionContents[1].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + const first = rowHeaders.map(x => x.componentInstance.column.header)[0]; + expect(first).toBe('Stanley Brooker'); + + // insert in columns + pivotGrid.insertDimensionAt({ memberName: 'SellerNameColumn', memberFunction: (rec) => rec.SellerName, enabled: true }, PivotDimensionType.Column, 0); + fixture.detectChanges(); + expect(pivotGrid.pivotConfiguration.columns[0].memberName).toBe('SellerNameColumn'); + expect(pivotGrid.columnDimensions.length).toBe(2); + expect(pivotGrid.columns.length).toBe(28); + + // insert in filter + pivotGrid.insertDimensionAt({ memberName: 'SellerNameFilter', memberFunction: (rec) => rec.SellerName, enabled: true }, PivotDimensionType.Filter, 1); + fixture.detectChanges(); + expect(pivotGrid.pivotConfiguration.filters.length).toBe(1); + expect(pivotGrid.pivotConfiguration.filters[0].memberName).toBe('SellerNameFilter'); + + const headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); + const chip = headerRow.querySelector('igx-chip[id="SellerNameFilter"]'); + expect(chip).not.toBeNull(); + }); + + it('should allow removing dimension.', () => { + const filter = { memberName: 'SellerNameFilter', memberFunction: (rec) => rec.SellerName, enabled: true }; + pivotGrid.pivotConfiguration.filters = [filter]; + fixture.detectChanges(); + + // remove row + pivotGrid.removeDimension(pivotGrid.pivotConfiguration.rows[0]); + fixture.detectChanges(); + + expect(pivotGrid.pivotConfiguration.rows.length).toBe(1); + expect(pivotGrid.pivotConfiguration.rows[0].memberName).toBe('AllProducts'); + + const dimensionContents = fixture.debugElement.queryAll(By.css('.igx-grid__tbody-pivot-dimension')); + const rowHeaders = dimensionContents[0].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + const headers = rowHeaders.map(x => x.componentInstance.column.header); + expect(headers.length).toBe(5); + + // remove col + pivotGrid.removeDimension(pivotGrid.pivotConfiguration.columns[0]); + fixture.detectChanges(); + + expect(pivotGrid.pivotConfiguration.columns.length).toBe(0); + expect(pivotGrid.columnDimensions.length).toBe(0); + expect(pivotGrid.columns.length).toBe(pivotGrid.values.length); + + // remove filter + pivotGrid.removeDimension(filter); + fixture.detectChanges(); + + expect(pivotGrid.pivotConfiguration.filters.length).toBe(0); + const headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); + const chip = headerRow.querySelector('igx-chip[id="SellerNameFilter"]'); + expect(chip).toBeNull(); + + // remove something that is not part of the config + pivotGrid.removeDimension({ memberName: 'Test', enabled: true }); + fixture.detectChanges(); + // nothing should change + expect(pivotGrid.pivotConfiguration.filters.length).toBe(0); + expect(pivotGrid.pivotConfiguration.columns.length).toBe(0); + expect(pivotGrid.pivotConfiguration.rows.length).toBe(1); + }); + + it('should allow toggling dimension.', () => { + const filter = { memberName: 'SellerNameFilter', memberFunction: (rec) => rec.SellerName, enabled: true }; + pivotGrid.pivotConfiguration.filters = [filter]; + fixture.detectChanges(); + + // toggle row + pivotGrid.toggleDimension(pivotGrid.pivotConfiguration.rows[0]); + fixture.detectChanges(); + + // there are still 2 + expect(pivotGrid.pivotConfiguration.rows.length).toBe(2); + // 1 is disabled + expect(pivotGrid.rowDimensions.length).toBe(1); + + const dimensionContents = fixture.debugElement.queryAll(By.css('.igx-grid__tbody-pivot-dimension')); + const rowHeaders = dimensionContents[0].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + const headers = rowHeaders.map(x => x.componentInstance.column.header); + expect(headers.length).toBe(5); + + // toggle column + pivotGrid.toggleDimension(pivotGrid.pivotConfiguration.columns[0]); + fixture.detectChanges(); + + expect(pivotGrid.pivotConfiguration.columns.length).toBe(1); + expect(pivotGrid.columnDimensions.length).toBe(0); + expect(pivotGrid.columns.length).toBe(pivotGrid.values.length); + + // toggle filter + pivotGrid.toggleDimension(filter); + fixture.detectChanges(); + + expect(pivotGrid.pivotConfiguration.filters.length).toBe(1); + expect(pivotGrid.filterDimensions.length).toBe(0); + const headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); + const chip = headerRow.querySelector('igx-chip[id="SellerNameFilter"]'); + expect(chip).toBeNull(); + + // toggle something that is not part of the config + pivotGrid.toggleDimension({ memberName: 'Test', enabled: false }); + fixture.detectChanges(); + + // nothing should change + expect(pivotGrid.filterDimensions.length).toBe(0); + expect(pivotGrid.columnDimensions.length).toBe(0); + expect(pivotGrid.rowDimensions.length).toBe(1); + + expect(pivotGrid.pivotConfiguration.filters.length).toBe(1); + expect(pivotGrid.pivotConfiguration.columns.length).toBe(1); + expect(pivotGrid.pivotConfiguration.rows.length).toBe(2); + + }); + + it('should allow moving dimension.', () => { + const dim = pivotGrid.pivotConfiguration.rows[0]; + + // from row to column + pivotGrid.moveDimension(dim, PivotDimensionType.Column, 0); + fixture.detectChanges(); + + expect(pivotGrid.rowDimensions.length).toBe(1); + expect(pivotGrid.columnDimensions.length).toBe(2); + + let dimensionContents = fixture.debugElement.queryAll(By.css('.igx-grid__tbody-pivot-dimension')); + let rowHeaders = dimensionContents[0].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + const headers = rowHeaders.map(x => x.componentInstance.column.header); + expect(headers.length).toBe(5); + + expect(pivotGrid.pivotConfiguration.columns[0].memberName).toBe(dim.memberName); + expect(pivotGrid.columnDimensions.length).toBe(2); + expect(pivotGrid.columns.length).toBe(28); + + // from column to filter + pivotGrid.moveDimension(dim, PivotDimensionType.Filter, 0); + fixture.detectChanges(); + + expect(pivotGrid.pivotConfiguration.columns.length).toBe(1); + expect(pivotGrid.pivotConfiguration.filters.length).toBe(1); + expect(pivotGrid.columns.length).toBe(15); + + const headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); + const chip = headerRow.querySelector('igx-chip[id="All cities"]'); + expect(chip).not.toBeNull(); + + + // from filter to row + pivotGrid.moveDimension(dim, PivotDimensionType.Row, 1); + fixture.detectChanges(); + + expect(pivotGrid.pivotConfiguration.rows.length).toBe(2); + expect(pivotGrid.pivotConfiguration.rows[1].memberName).toBe('All cities'); + expect(pivotGrid.pivotConfiguration.filters.length).toBe(0); + + dimensionContents = fixture.debugElement.queryAll(By.css('.igx-grid__tbody-pivot-dimension')); + rowHeaders = dimensionContents[1].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + const first = rowHeaders.map(x => x.componentInstance.column.header)[0]; + expect(first).toBe('All Cities'); + }); + + it('should allow inserting new value.', () => { + const value = { + member: 'Date', + aggregate: { + aggregator: IgxPivotDateAggregate.latest, + key: 'LATEST', + label: 'Latest' + }, + enabled: true + }; + // At Index + pivotGrid.insertValueAt(value, 1); + fixture.detectChanges(); + expect(pivotGrid.values.length).toBe(3); + expect(pivotGrid.values[1].member).toBe('Date'); + expect(pivotGrid.columns.length).toBe(20); + + // With no Index + pivotGrid.pivotConfiguration.values = undefined; + pivotGrid.notifyDimensionChange(true); + fixture.detectChanges(); + pivotGrid.insertValueAt({ + member: 'Date', + displayName: 'DateNew', + aggregate: { + aggregator: IgxPivotDateAggregate.earliest, + key: 'EARLIEST', + label: 'Earliest' + }, + enabled: true + }); + expect(pivotGrid.values.length).toBe(1); + expect(pivotGrid.values[0].member).toBe('Date'); + expect(pivotGrid.values[0].displayName).toBe('DateNew'); + expect(pivotGrid.columns.length).toBe(5); + }); + + it('should allow removing value.', () => { + pivotGrid.removeValue(pivotGrid.values[1]); + fixture.detectChanges(); + expect(pivotGrid.pivotConfiguration.values.length).toBe(1); + expect(pivotGrid.values[0].member).toBe('UnitsSold'); + expect(pivotGrid.columns.length).toBe(5); + }); + + it('should allow toggling value.', () => { + // toggle off + pivotGrid.toggleValue(pivotGrid.pivotConfiguration.values[1]); + fixture.detectChanges(); + expect(pivotGrid.pivotConfiguration.values.length).toBe(2); + expect(pivotGrid.values.length).toBe(1); + expect(pivotGrid.values[0].member).toBe('UnitsSold'); + expect(pivotGrid.columns.length).toBe(5); + // toggle on + pivotGrid.toggleValue(pivotGrid.pivotConfiguration.values[1]); + fixture.detectChanges(); + expect(pivotGrid.pivotConfiguration.values.length).toBe(2); + expect(pivotGrid.values.length).toBe(2); + expect(pivotGrid.values[0].member).toBe('UnitsSold'); + expect(pivotGrid.values[1].member).toBe('AmountOfSale'); + expect(pivotGrid.columns.length).toBe(15); + }); + + it('should allow moving value.', () => { + const val = pivotGrid.pivotConfiguration.values[0]; + + //should do nothing if value is not present in the configuration + pivotGrid.moveValue({ + member: 'NotPresent', + enabled: true, + aggregate: { + aggregator: () => { }, + key: 'Test', + label: 'test' + } + }); + fixture.detectChanges(); + expect(pivotGrid.values.length).toBe(2); + expect(pivotGrid.values[0].member).toBe('UnitsSold'); + expect(pivotGrid.values[1].member).toBe('AmountOfSale'); + + // move after + pivotGrid.moveValue(val, 1); + fixture.detectChanges(); + + expect(pivotGrid.values[0].member).toBe('AmountOfSale'); + expect(pivotGrid.values[1].member).toBe('UnitsSold'); + + let valueCols = pivotGrid.columns.filter(x => x.level === 1); + expect(valueCols[0].header).toBe('Amount of Sale'); + expect(valueCols[1].header).toBe('UnitsSold'); + + // move before + pivotGrid.moveValue(val, 0); + fixture.detectChanges(); + + expect(pivotGrid.values[0].member).toBe('UnitsSold'); + expect(pivotGrid.values[1].member).toBe('AmountOfSale'); + valueCols = pivotGrid.columns.filter(x => x.level === 1); + expect(valueCols[0].header).toBe('UnitsSold'); + expect(valueCols[1].header).toBe('Amount of Sale'); + }); + + it('should allow changing the whole pivotConfiguration object', () => { + pivotGrid.pivotConfiguration = { + columns: [ + { + memberName: 'City', + enabled: true + } + ], + rows: [ + { + memberName: 'ProductCategory', + enabled: true + }], + values: [ + { + member: 'UnitsSold', + aggregate: { + aggregator: IgxPivotNumericAggregate.sum, + key: 'SUM', + label: 'Sum' + }, + enabled: true + } + ] + }; + fixture.detectChanges(); + + //check rows + const rows = pivotGrid.rowList.toArray(); + expect(rows.length).toBe(4); + const expectedHeaders = ['Clothing', 'Bikes', 'Accessories', 'Components']; + const rowHeaders = fixture.debugElement.queryAll( + By.directive(IgxPivotRowDimensionHeaderComponent)); + const rowDimensionHeaders = rowHeaders.map(x => x.componentInstance.column.header); + expect(rowDimensionHeaders).toEqual(expectedHeaders); + + // check columns + const colHeaders = pivotGrid.columns.filter(x => x.level === 0).map(x => x.header); + const expected = ['Plovdiv', 'New York', 'Ciudad de la Costa', 'London', 'Yokohama', 'Sofia']; + expect(colHeaders).toEqual(expected); + + // check data + const pivotRecord = (pivotGrid.rowList.first as IgxPivotRowComponent).data; + expect(pivotRecord.aggregationValues.get('New York')).toBe(296); + + }); + + it('should allow formatting based on additional record and column data', () => { + pivotGrid.pivotConfiguration = { + columns: [ + { + memberName: 'City', + enabled: true + } + ], + rows: [ + { + memberName: 'ProductCategory', + enabled: true + }], + values: [ + { + member: 'UnitsSold', + aggregate: { + aggregator: IgxPivotNumericAggregate.sum, + key: 'SUM', + label: 'Sum' + }, + enabled: true, + formatter: (value, rowData: IPivotGridRecord, columnData: IPivotGridColumn) => { + return rowData.dimensionValues.get('ProductCategory') + '/' + columnData.dimensionValues.get('City') + ':' + value; + } + } + ] + }; + fixture.detectChanges(); + expect(pivotGrid.gridAPI.get_cell_by_index(0, 0).nativeElement.innerText).toBe('Clothing/Plovdiv:282'); + expect(pivotGrid.gridAPI.get_cell_by_index(0, 3).nativeElement.innerText).toBe('Clothing/London:undefined'); + }); + + it('should allow filtering a dimension runtime.', () => { + const colValues = new Set(); + colValues.add('US'); + colValues.add('UK'); + pivotGrid.filterDimension(pivotGrid.pivotConfiguration.columns[0], colValues, IgxStringFilteringOperand.instance().condition('in')); + expect(pivotGrid.columns.length).toBe(6); + expect(pivotGrid.columns.filter(x => x.columnGroup).map(x => x.field)).toEqual(['US', 'UK']); + expect(pivotGrid.filteringExpressionsTree.filteringOperands.length).toEqual(1); + + const rowValues = new Set(); + rowValues.add('Clothing'); + pivotGrid.filterDimension(pivotGrid.pivotConfiguration.rows[1].childLevel, rowValues, IgxStringFilteringOperand.instance().condition('in')); + expect(pivotGrid.rowList.length).toBe(4); + const rowDimData = pivotGrid.rowList.map(x => (x as IgxPivotRowComponent).data.dimensionValues.get('ProductCategory')) + expect(rowDimData).toEqual([undefined, 'Clothing', undefined, 'Clothing']); + expect(pivotGrid.filteringExpressionsTree.filteringOperands.length).toEqual(2); + }); + + it('should update filtering on pivot configuration change.', () => { + fixture.detectChanges(); + expect(pivotGrid.filteringExpressionsTree.filteringOperands.length).toEqual(0); + const filterColumnExpTree = new FilteringExpressionsTree(FilteringLogic.And); + filterColumnExpTree.filteringOperands = [ + { + condition: IgxStringFilteringOperand.instance().condition('in'), + conditionName: 'in', + fieldName: 'City', + searchVal: new Set(['Ciudad de la Costa']) + } + ]; + const filterRowExpTree = new FilteringExpressionsTree(FilteringLogic.And); + filterRowExpTree.filteringOperands = [ + { + condition: IgxStringFilteringOperand.instance().condition('in'), + conditionName: 'in', + fieldName: 'ProductCategory', + searchVal: new Set(['Bikes']) + } + ]; + pivotGrid.pivotConfiguration = { + columns: [ + { + memberName: 'City', + enabled: true, + filter: filterColumnExpTree + } + ], + rows: [ + { + memberName: 'ProductCategory', + enabled: true, + filter: filterRowExpTree + }], + values: [ + { + member: 'UnitsSold', + aggregate: { + aggregator: IgxPivotNumericAggregate.sum, + key: 'SUM', + label: 'Sum' + }, + enabled: true + } + ] + }; + fixture.detectChanges(); + expect(pivotGrid.filteringExpressionsTree.filteringOperands.length).toEqual(2); + + expect(pivotGrid.columns.length).toBe(1); + expect(pivotGrid.columns[0].field).toEqual('Ciudad de la Costa'); + expect(pivotGrid.rowList.length).toBe(1); + expect(pivotGrid.rowList.toArray()[0].data.dimensionValues.get('ProductCategory')).toBe('Bikes'); + }); + + it('should allow setting aggregatorName instead of aggregator.', () => { + pivotGrid.pivotConfiguration.values = [ + { + member: 'UnitsSold', + aggregate: { + aggregatorName: 'SUM', + key: 'SUM', + label: 'Sum', + }, + enabled: true + } + ]; + pivotGrid.notifyDimensionChange(true); + fixture.detectChanges(); + const pivotRecord = (pivotGrid.rowList.first as IgxPivotRowComponent).data; + expect(pivotRecord.aggregationValues.get('US')).toBe(296); + expect(pivotRecord.aggregationValues.get('Bulgaria')).toBe(774); + expect(pivotRecord.aggregationValues.get('UK')).toBe(293); + expect(pivotRecord.aggregationValues.get('Japan')).toBe(240); + }); + + it('should use aggregatorName if both aggregatorName and aggregator are set at the same time.', () => { + pivotGrid.pivotConfiguration.values = [ + { + member: 'UnitsSold', + aggregate: { + aggregatorName: 'SUM', + aggregator: IgxPivotNumericAggregate.average, + key: 'SUM', + label: 'Sum', + }, + enabled: true + } + ]; + pivotGrid.notifyDimensionChange(true); + fixture.detectChanges(); + const pivotRecord = (pivotGrid.rowList.first as IgxPivotRowComponent).data; + expect(pivotRecord.aggregationValues.get('US')).toBe(296); + expect(pivotRecord.aggregationValues.get('Bulgaria')).toBe(774); + expect(pivotRecord.aggregationValues.get('UK')).toBe(293); + expect(pivotRecord.aggregationValues.get('Japan')).toBe(240); + }); + }); + + + describe('IgxPivotGrid Horizontal Layout #pivotGrid', () => { + let fixture: ComponentFixture; + let pivotGrid: IgxPivotGridComponent; + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(IgxPivotGridTestComplexHierarchyComponent); + fixture.detectChanges(); + + pivotGrid = fixture.componentInstance.pivotGrid; + pivotGrid.pivotUI = { + showRowHeaders: true, + showConfiguration: true, + rowLayout: PivotRowLayoutType.Horizontal + }; + fixture.detectChanges(); + })); + + it("should render row hierarchy horizontally.", () => { + fixture.detectChanges(); + // check rows + const rows = pivotGrid.rowList.toArray(); + expect(rows.length).toBe(7); + const layoutContainer = fixture.debugElement.query( + By.directive(IgxPivotRowDimensionMrlRowComponent)); + const contentRowHeaders = layoutContainer.queryAll( + By.directive(IgxPivotRowDimensionContentComponent)); + + // check each dimension from hierarchy is on another column. + const rowDimensionCol1 = contentRowHeaders.filter(y => y.componentInstance.layout.colStart === 1); + let dimensions = rowDimensionCol1.map(x => x.componentInstance.dimension); + expect(dimensions.every(x => x.memberName === "All cities")).toBeTruthy(); + const rowDimensionHeadersCol1 = rowDimensionCol1.map(x => x.componentInstance.rowDimensionColumn.header); + expect(rowDimensionHeadersCol1).toEqual(["All Cities"]); + + const rowDimensionCol2 = contentRowHeaders.filter(y => y.componentInstance.layout.colStart === 2); + const rowDimensionHeadersCol2 = rowDimensionCol2.map(x => x.componentInstance.rowDimensionColumn.header); + dimensions = rowDimensionCol2.map(x => x.componentInstance.dimension); + expect(dimensions.every(x => x.memberName === "City")).toBeTruthy(); + expect(rowDimensionHeadersCol2).toEqual(['Plovdiv', 'New York', 'Ciudad de la Costa', 'London', 'Yokohama', 'Sofia']); + + const rowDimensionCol3 = contentRowHeaders.filter(y => y.componentInstance.layout.colStart === 3); + const rowDimensionHeadersCol3 = rowDimensionCol3.map(x => x.componentInstance.rowDimensionColumn.header); + dimensions = rowDimensionCol3.map(x => x.componentInstance.dimension); + expect(dimensions.every(x => x.memberName === "AllProducts")).toBeTruthy(); + expect(rowDimensionHeadersCol3).toEqual(["AllProducts", "AllProducts", "AllProducts", "AllProducts", "AllProducts", "AllProducts"]); + + const rowDimensionCol4 = contentRowHeaders.filter(y => y.componentInstance.layout.colStart === 4); + const rowDimensionHeadersCol4 = contentRowHeaders.filter(y => y.componentInstance.layout.colStart === 4) + .map(x => x.componentInstance.rowDimensionColumn.header); + dimensions = rowDimensionCol4.map(x => x.componentInstance.dimension); + expect(dimensions.every(x => x.memberName === "ProductCategory")).toBeTruthy(); + expect(rowDimensionHeadersCol4).toEqual(["Clothing", "Clothing", "Bikes", + "Clothing", "Accessories", "Components", "Components"]); + }); + + it("should horizontally expand/collapse on a single dimension hierarchy.", () => { + fixture.detectChanges(); + let layoutContainer = fixture.debugElement.query( + By.directive(IgxPivotRowDimensionMrlRowComponent)); + let contentRowHeaders = layoutContainer.queryAll( + By.directive(IgxPivotRowDimensionContentComponent)); + + // collapse All Products + let rowDimensionCol3 = contentRowHeaders.filter(y => y.componentInstance.layout.colStart === 3)[0]; + expect(rowDimensionCol3.componentInstance.layout.colStart).toEqual(3); + expect(rowDimensionCol3.componentInstance.layout.colEnd).toEqual(4); + let expander = rowDimensionCol3.query(By.directive(IgxIconComponent)); + expect(expander.nativeElement.innerText).toBe("expand_more"); + expander.nativeElement.click(); + fixture.detectChanges(); + + // check cells are merged + layoutContainer = fixture.debugElement.query( + By.directive(IgxPivotRowDimensionMrlRowComponent)); + contentRowHeaders = layoutContainer.queryAll( + By.directive(IgxPivotRowDimensionContentComponent)); + rowDimensionCol3 = contentRowHeaders.filter(y => y.componentInstance.layout.colStart === 3)[0]; + expect(rowDimensionCol3.componentInstance.layout.colStart).toEqual(3); + expect(rowDimensionCol3.componentInstance.layout.colEnd).toEqual(5); + + // check icon is updated + expander = rowDimensionCol3.query(By.directive(IgxIconComponent)); + expect(expander.nativeElement.innerText).toBe("chevron_right"); + + // toggle All Products + expander.nativeElement.click(); + fixture.detectChanges(); + + // check cell is no longer merged. + layoutContainer = fixture.debugElement.query( + By.directive(IgxPivotRowDimensionMrlRowComponent)); + contentRowHeaders = layoutContainer.queryAll( + By.directive(IgxPivotRowDimensionContentComponent)); + rowDimensionCol3 = contentRowHeaders.filter(y => y.componentInstance.layout.colStart === 3)[0]; + expect(rowDimensionCol3.componentInstance.layout.colStart).toEqual(3); + expect(rowDimensionCol3.componentInstance.layout.colEnd).toEqual(4); + expander = rowDimensionCol3.query(By.directive(IgxIconComponent)); + expect(expander.nativeElement.innerText).toBe("expand_more"); + }); + + it("should collapse fully the last dimension if all parent dimensions get collapsed.", () => { + pivotGrid.data = [{ + ProductCategory: 'Clothing', UnitPrice: 12.81, SellerName: 'Stanley Brooker', + Country: 'Bulgaria', City: 'Plovdiv', Date: '01/01/2012', UnitsSold: 282 + }]; + fixture.detectChanges(); + let layoutContainer = fixture.debugElement.query( + By.directive(IgxPivotRowDimensionMrlRowComponent)); + let contentRowHeaders = layoutContainer.queryAll( + By.directive(IgxPivotRowDimensionContentComponent)); + + const rowDimensionsCol3 = contentRowHeaders.filter(y => y.componentInstance.layout.colStart === 3); + let rowDimensionsCol4 = contentRowHeaders.filter(y => y.componentInstance.layout.colStart === 4); + expect(rowDimensionsCol4.length).toBe(1); + // collapse all from All Products + const expander = rowDimensionsCol3[0].query(By.directive(IgxIconComponent)); + expander.nativeElement.click(); + fixture.detectChanges(); + + layoutContainer = fixture.debugElement.query( + By.directive(IgxPivotRowDimensionMrlRowComponent)); + contentRowHeaders = layoutContainer.queryAll( + By.directive(IgxPivotRowDimensionContentComponent)); + rowDimensionsCol4 = contentRowHeaders.filter(y => y.componentInstance.layout.colStart === 4); + // nothing is now on column 4 since all are collapsed. + expect(rowDimensionsCol4.length).toBe(0); + }); + + it("should render summary rows when enabled.", () => { + pivotGrid.pivotConfiguration.rows = [ + { + memberName: 'All cities', + memberFunction: () => 'All Cities', + enabled: true, + horizontalSummary: true, + childLevel: { + memberName: 'City', + enabled: true + } + } + ]; + pivotGrid.pipeTrigger++; + fixture.detectChanges(); + + // check rows + const rows = pivotGrid.rowList.toArray(); + expect(rows.length).toBe(7); + let layoutContainers = fixture.debugElement.queryAll( + By.directive(IgxPivotRowDimensionMrlRowComponent)); + expect(layoutContainers.length).toBe(2); + const contentRowHeaders = layoutContainers[0].queryAll( + By.directive(IgxPivotRowDimensionContentComponent)); + const summaryRowHeaders = layoutContainers[1].queryAll( + By.directive(IgxPivotRowDimensionContentComponent)); + + // check first column of data contains summary + const summaryRowHeader = summaryRowHeaders.map(x => x.componentInstance.rowDimensionColumn.header); + expect(summaryRowHeader).toEqual(["All Cities Total"]); + + // check summary hides on collapse + const rowDimensionCol1 = contentRowHeaders.filter(y => y.componentInstance.layout.colStart === 1); + const expander = rowDimensionCol1[0].query(By.directive(IgxIconComponent)); + expander.nativeElement.click(); + fixture.detectChanges(); + + layoutContainers = fixture.debugElement.queryAll( + By.directive(IgxPivotRowDimensionMrlRowComponent)); + expect(layoutContainers.length).toBe(1); + expect(pivotGrid.rowList.toArray().length).toBe(1); + }); + + it("should render summary rows on top when enabled", () => { + pivotGrid.pivotUI.horizontalSummariesPosition = PivotSummaryPosition.Top; + pivotGrid.pivotConfiguration.rows = [ + { + memberName: 'All cities', + memberFunction: () => 'All Cities', + enabled: true, + horizontalSummary: true, + childLevel: { + memberName: 'City', + enabled: true + } + } + ]; + pivotGrid.pipeTrigger++; + fixture.detectChanges(); + + // check rows + const rows = pivotGrid.rowList.toArray(); + expect(rows.length).toBe(7); + let layoutContainers = fixture.debugElement.queryAll(By.directive(IgxPivotRowDimensionMrlRowComponent)); + expect(layoutContainers.length).toBe(2); + + const contentRowHeaders = layoutContainers[1].queryAll(By.directive(IgxPivotRowDimensionContentComponent)); + const summaryRowHeaders = layoutContainers[0].queryAll(By.directive(IgxPivotRowDimensionContentComponent)); + const summaryRowHeader = summaryRowHeaders.map(x => x.componentInstance.rowDimensionColumn.header); + expect(summaryRowHeader).toEqual(["All Cities Total"]); + + // check summary hides on collapse + const rowDimensionCol1 = contentRowHeaders.filter(y => y.componentInstance.layout.colStart === 1); + + const header = rowDimensionCol1[0].query(By.directive(IgxPivotRowDimensionHeaderComponent)); + header.nativeElement.dispatchEvent(new PointerEvent('pointerdown')); + fixture.detectChanges(); + + expect(pivotGrid.navigation.activeNode.row).toEqual(1); + + const expander = rowDimensionCol1[0].query(By.directive(IgxIconComponent)); + expander.nativeElement.click(); + fixture.detectChanges(); + + layoutContainers = fixture.debugElement.queryAll( + By.directive(IgxPivotRowDimensionMrlRowComponent)); + expect(layoutContainers.length).toBe(1); + expect(pivotGrid.rowList.toArray().length).toBe(1); + expect(pivotGrid.navigation.activeNode.row).toEqual(0); + }); + + it("should update active node when top summary rows on and dimension is collapsed/expanded", () => { + pivotGrid.pivotUI.horizontalSummariesPosition = PivotSummaryPosition.Top; + pivotGrid.pivotConfiguration.rows = [ + { + memberName: 'All cities', + memberFunction: () => 'All Cities', + enabled: true, + horizontalSummary: true, + childLevel: { + memberName: 'City', + enabled: true + } + } + ]; + pivotGrid.pipeTrigger++; + fixture.detectChanges(); + + + let layoutContainers = fixture.debugElement.queryAll(By.directive(IgxPivotRowDimensionMrlRowComponent)); + let contentRowHeaders = layoutContainers[1].queryAll(By.directive(IgxPivotRowDimensionContentComponent)); + let rowDimensionCol1 = contentRowHeaders.filter(y => y.componentInstance.layout.colStart === 1); + const header = rowDimensionCol1[0].query(By.directive(IgxPivotRowDimensionHeaderComponent)); + header.nativeElement.dispatchEvent(new PointerEvent('pointerdown')); + fixture.detectChanges(); + + expect(pivotGrid.navigation.activeNode.row).toEqual(1); + + let expander = rowDimensionCol1[0].query(By.directive(IgxIconComponent)); + expander.nativeElement.click(); + fixture.detectChanges(); + + expect(pivotGrid.rowList.toArray().length).toBe(1); + expect(pivotGrid.navigation.activeNode.row).toEqual(0); + + layoutContainers = fixture.debugElement.queryAll(By.directive(IgxPivotRowDimensionMrlRowComponent)); + contentRowHeaders = layoutContainers[0].queryAll(By.directive(IgxPivotRowDimensionContentComponent)); + rowDimensionCol1 = contentRowHeaders.filter(y => y.componentInstance.layout.colStart === 1); + expander = rowDimensionCol1[0].query(By.directive(IgxIconComponent)); + expander.nativeElement.click(); + fixture.detectChanges(); + + expect(pivotGrid.rowList.toArray().length).toBe(7); + expect(pivotGrid.navigation.activeNode.row).toEqual(1); + }); + + it("should allow navigation in the row layouts.", fakeAsync(() => { + fixture.detectChanges(); + const layoutContainer = fixture.debugElement.query( + By.directive(IgxPivotRowDimensionMrlRowComponent)); + const allGroups = layoutContainer.queryAll( + By.directive(IgxPivotRowDimensionHeaderComponent)); + const row0Col0 = allGroups[0]; + const row0Col1 = allGroups.filter(x => x.componentInstance.column.header === "Plovdiv")[0]; + const row1Col1 = allGroups.filter(x => x.componentInstance.column.header === "New York")[0]; + + const row0Col2 = allGroups.filter(x => x.componentInstance.column.header === "AllProducts")[0]; + const row1Col2 = allGroups.filter(x => x.componentInstance.column.header === "AllProducts")[1]; + const row0Col3 = allGroups.filter(x => x.componentInstance.column.header === "Clothing")[0]; + const row1Col3 = allGroups.filter(x => x.componentInstance.column.header === "Clothing")[1]; + const row2Col3 = allGroups.filter(x => x.componentInstance.column.header === "Bikes")[0]; + UIInteractions.simulateClickAndSelectEvent(row0Col0); + fixture.detectChanges(); + + GridFunctions.verifyHeaderIsFocused(row0Col0.parent); + let activeCells = fixture.debugElement.queryAll(By.css(`${ACTIVE_CELL_CSS_CLASS}`)); + expect(activeCells.length).toBe(1); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', row0Col0.nativeElement); + tick(); + fixture.detectChanges(); + + GridFunctions.verifyHeaderIsFocused(row0Col1.parent); + activeCells = fixture.debugElement.queryAll(By.css(`${ACTIVE_CELL_CSS_CLASS}`)); + expect(activeCells.length).toBe(1); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', row0Col1.nativeElement); + tick(); + fixture.detectChanges(); + GridFunctions.verifyHeaderIsFocused(row0Col2.parent); + activeCells = fixture.debugElement.queryAll(By.css(`${ACTIVE_CELL_CSS_CLASS}`)); + expect(activeCells.length).toBe(1); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', row0Col2.nativeElement); + tick(); + fixture.detectChanges(); + GridFunctions.verifyHeaderIsFocused(row0Col3.parent); + activeCells = fixture.debugElement.queryAll(By.css(`${ACTIVE_CELL_CSS_CLASS}`)); + expect(activeCells.length).toBe(1); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', row0Col3.nativeElement); + tick(); + fixture.detectChanges(); + GridFunctions.verifyHeaderIsFocused(row1Col3.parent); + activeCells = fixture.debugElement.queryAll(By.css(`${ACTIVE_CELL_CSS_CLASS}`)); + expect(activeCells.length).toBe(1); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', row1Col3.nativeElement); + tick(); + fixture.detectChanges(); + GridFunctions.verifyHeaderIsFocused(row2Col3.parent); + activeCells = fixture.debugElement.queryAll(By.css(`${ACTIVE_CELL_CSS_CLASS}`)); + expect(activeCells.length).toBe(1); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', row2Col3.nativeElement); + tick(); + fixture.detectChanges(); + GridFunctions.verifyHeaderIsFocused(row1Col3.parent); + activeCells = fixture.debugElement.queryAll(By.css(`${ACTIVE_CELL_CSS_CLASS}`)); + expect(activeCells.length).toBe(1); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', row1Col3.nativeElement); + tick(); + fixture.detectChanges(); + GridFunctions.verifyHeaderIsFocused(row1Col2.parent); + activeCells = fixture.debugElement.queryAll(By.css(`${ACTIVE_CELL_CSS_CLASS}`)); + expect(activeCells.length).toBe(1); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', row1Col2.nativeElement); + tick(); + fixture.detectChanges(); + GridFunctions.verifyHeaderIsFocused(row1Col1.parent); + activeCells = fixture.debugElement.queryAll(By.css(`${ACTIVE_CELL_CSS_CLASS}`)); + expect(activeCells.length).toBe(1); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', row1Col1.nativeElement); + tick(); + fixture.detectChanges(); + GridFunctions.verifyHeaderIsFocused(row0Col0.parent); + activeCells = fixture.debugElement.queryAll(By.css(`${ACTIVE_CELL_CSS_CLASS}`)); + expect(activeCells.length).toBe(1); + })); + + it("should allow resizing the row dimension.", fakeAsync(() => { + const dimensionContents = fixture.debugElement.queryAll(By.css('.igx-grid__tbody-pivot-dimension')); + let rowHeaders = dimensionContents[0].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + expect(rowHeaders[0].componentInstance.column.width).toEqual('200px'); + + rowHeaders = dimensionContents[0].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + const headerResArea = GridFunctions.getHeaderResizeArea(rowHeaders[0]).nativeElement; + + // Resize first column + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 100, 0); + tick(200); + fixture.detectChanges(); + + const resizer = GridFunctions.getResizer(fixture).nativeElement; + expect(resizer).toBeDefined(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 300, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 300, 5); + fixture.detectChanges(); + + rowHeaders = dimensionContents[0].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); + expect(rowHeaders[0].componentInstance.column.width).toEqual('400px'); + })); + + it("should allow sorting each dimension column from the hierarchy.", () => { + pivotGrid.pivotConfiguration.rows = [ + { + memberFunction: () => 'AllProducts', + memberName: 'AllProducts', + enabled: true, + childLevel: + { + memberFunction: (data) => data.ProductCategory, + memberName: 'ProductCategory', + enabled: true + } + } + ]; + pivotGrid.pipeTrigger++; + fixture.detectChanges(); + let rowHeaders = fixture.debugElement.queryAll( + By.directive(IgxPivotRowDimensionHeaderComponent)); + + const productsHeaderColumn = rowHeaders.filter(x => x.componentInstance.column.header === "ProductCategory")[0].nativeElement; + const productRowContents = rowHeaders.filter(x => x.componentInstance.column.field === "ProductCategory"); + const productRowContentsHeaders = productRowContents.map(x => x.componentInstance.column.header); + + expect(productRowContentsHeaders).toEqual(['ProductCategory', 'Clothing', 'Bikes', 'Accessories', 'Components']); + + const sortIcon = productsHeaderColumn.querySelectorAll('igx-icon')[0]; + sortIcon.click(); + fixture.detectChanges(); + sortIcon.click(); + fixture.detectChanges(); + + rowHeaders = fixture.debugElement.queryAll( + By.directive(IgxPivotRowDimensionHeaderComponent)); + const updatedProductRowContents = rowHeaders.filter(x => x.componentInstance.column.field === "ProductCategory"); + const updatedProductRowContentsHeaders = updatedProductRowContents.map(x => x.componentInstance.column.header); + expect(updatedProductRowContentsHeaders).toEqual(['ProductCategory', 'Components', 'Clothing', 'Bikes', 'Accessories']); + }); + + it("should allow select/deselect the correct rows on row header click.", () => { + fixture.detectChanges(); + const layoutContainer = fixture.debugElement.query( + By.directive(IgxPivotRowDimensionMrlRowComponent)); + const rowHeaders = layoutContainer.queryAll( + By.directive(IgxPivotRowDimensionHeaderComponent)); + expect(pivotGrid.selectedRows).toEqual([]); + const pivotRows = GridFunctions.getPivotRows(fixture); + const row = pivotRows[4].componentInstance; + const secondDimCell = rowHeaders.find(x => x.componentInstance.column.header === 'Accessories'); + secondDimCell.nativeElement.click(); + fixture.detectChanges(); + expect(row.selected).toBeTrue(); + expect(pivotGrid.selectedRows).not.toBeNull(); + expect(pivotGrid.selectedRows.length).toBe(1); + expect((pivotGrid.selectedRows[0] as IPivotGridRecord).dimensionValues.get('ProductCategory')).toBe('Accessories'); + expect((pivotGrid.selectedRows[0] as IPivotGridRecord).dimensionValues.get('AllProducts')).toBe('AllProducts'); + expect((pivotGrid.selectedRows[0] as IPivotGridRecord).dimensionValues.get('City')).toBe('London'); + expect((pivotGrid.selectedRows[0] as IPivotGridRecord).dimensionValues.get('All cities')).toBe("All Cities"); + }); + + it("should render correct rows with noop strategies.", () => { + pivotGrid.data = [ + { + AllProducts: 'All Products', All: 2127, 'Bulgaria': 774, 'USA': 829, 'Uruguay': 524, 'AllProducts_records': [ + { ProductCategory: 'Clothing', All: 1523, 'Bulgaria': 774, 'USA': 296, 'Uruguay': 456, }, + { ProductCategory: 'Bikes', All: 68, 'Uruguay': 68 }, + { ProductCategory: 'Accessories', All: 293, 'USA': 293 }, + { ProductCategory: 'Components', All: 240, 'USA': 240 } + ] + } + ]; + pivotGrid.pivotConfiguration = { + columnStrategy: NoopPivotDimensionsStrategy.instance(), + rowStrategy: NoopPivotDimensionsStrategy.instance(), + columns: [ + { + memberName: 'Country', + enabled: true + }, + ] + , + rows: [ + { + memberFunction: () => 'All', + memberName: 'AllProducts', + enabled: true, + width: '25%', + childLevel: { + memberName: 'ProductCategory', + enabled: true + } + } + ], + values: [ + { + member: 'UnitsSold', + aggregate: { + aggregator: IgxPivotNumericAggregate.sum, + key: 'sum', + label: 'Sum' + }, + enabled: true + }, + ], + filters: null + }; + pivotGrid.pipeTrigger++; + fixture.detectChanges(); + + const pivotRows = GridFunctions.getPivotRows(fixture); + expect(pivotRows.length).toBe(4); + }); + }); +}); diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-header-row.component.html b/projects/igniteui-angular/grids/pivot-grid/src/pivot-header-row.component.html new file mode 100644 index 00000000000..3772f3958c4 --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-header-row.component.html @@ -0,0 +1,370 @@ +
    +
    +
    +
    + @if (grid.pivotUI.showConfiguration) { +
    + + + @if (grid.filterDimensions.length === 0) { + {{grid.resourceStrings.igx_grid_pivot_empty_filter_drop_area}} + } + @for (filter of this.filterAreaDimensions; track filter; let last = $last) { + + + + + {{filter.displayName || filter.memberName}} + + @if (last) { + + } + } + + {{grid.resourceStrings.igx_grid_pivot_filter_drop_chip}} + + + @if (isFiltersButton && grid.filterDimensions.length !== 0) { +
    + + + +
    + } +
    + } +
    + @if (grid.pivotUI.showConfiguration && grid.pivotUI.showRowHeaders) { +
    + + @if (grid.rowDimensions.length === 0) { + {{grid.resourceStrings.igx_grid_pivot_empty_row_drop_area}} + } + @for (row of grid.rowDimensions; track row.memberName; let last = $last) { + + + + + + {{ row.displayName || row.memberName}} + @if (row.sortDirection) { + + + } + + @if (last) { + + } + } + + {{grid.resourceStrings.igx_grid_pivot_row_drop_chip}} + + +
    + } +
    +
    + +
    + @if (grid.pivotUI.showConfiguration) { +
    + + + @if (grid.columnDimensions.length === 0) { + + {{grid.resourceStrings.igx_grid_pivot_empty_column_drop_area}} + } + @for (col of grid.columnDimensions; track col.memberName; let last = $last) { + + + + + + + {{col.displayName || col.memberName}} + @if (col.sortDirection) { + + + } + + @if (last) { + + } + } + + {{grid.resourceStrings.igx_grid_pivot_column_drop_chip}} + + +
    + } + + @if (grid.pivotUI.showConfiguration) { +
    + + + @if (grid.values.length === 0) { + {{grid.resourceStrings.igx_grid_pivot_empty_value_drop_area}} + } + @for (value of grid.values; track value.member; let last = $last) { + + +
    + + +
    + +
    + @if (last) { + + } + } + + {{grid.resourceStrings.igx_grid_pivot_value_drop_chip}} + +
    +
    + } +
    +
    +
    +
    + + +
    + @if (!grid.pivotUI.showRowHeaders || grid.rowDimensions.length === 0) { +
    + + + @if (grid.pivotUI.showConfiguration || grid.rowDimensions.length === 0) { + @if (grid.rowDimensions.length === 0) { + {{grid.resourceStrings.igx_grid_pivot_empty_row_drop_area}} + } + @for (row of grid.rowDimensions; track row.memberName; let last = $last) { + + + + + + {{ row.displayName || row.memberName}} + @if (row.sortDirection) { + + + } + + @if (last) { + + } + } + + {{grid.resourceStrings.igx_grid_pivot_row_drop_chip}} + + } + +
    + } + + @if (grid.pivotUI.showRowHeaders && grid.rowDimensions.length > 0) { +
    + @for (dim of grid.visibleRowDimensions; track dim; let colIndex = $index; let isLast = $last) { + @if (getRowDimensionColumn(dim); as dimCol) { + + + } + } +
    + } + + + @if (pinnedStartColumnCollection.length) { + @for (column of pinnedStartColumnCollection | igxTopLevel; track column) { + + + } + } +
    + @for (dimLevelColumns of columnDimensionsByLevel; track $index; let i = $index) { +
    + + + + +
    + } +
    + + + @if (pinnedEndColumnCollection.length) { + @for (column of pinnedEndColumnCollection | igxTopLevel; track column) { + + + } + } +
    + + +
    + @for (column of visibleLeafColumns; track column.index) { +
    {{ column.header || column.field }}
    + } +
    +
    +
    + +
    +
    + + + @for (item of aggregateList; track item.key) { + + {{ item.label }} + + } + + +
    + +
    + + @for (filter of this.filterDropdownDimensions; track filter) { + + {{filter.displayName || filter.memberName}} + + } + +
    + + + +
    +
    + +
    + + @for (filter of grid.filterDimensions; track filter) { + + + {{filter.displayName || filter.memberName}} + + } + +
    + + + {{value.aggregate.key}}({{value.displayName || value.member}}) + diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-header-row.component.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-header-row.component.ts new file mode 100644 index 00000000000..9794230d230 --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-header-row.component.ts @@ -0,0 +1,584 @@ +import { + ChangeDetectionStrategy, + Component, + OnChanges, + QueryList, + Renderer2, + ViewChild, + SimpleChanges, + ViewChildren, + HostBinding, + inject +} from '@angular/core'; +import { NgTemplateOutlet, NgClass, NgStyle } from '@angular/common'; + +import { AbsoluteScrollStrategy, AutoPositionStrategy, ColumnType, OverlaySettings, PositionSettings, SortingDirection, VerticalAlignment } from 'igniteui-angular/core'; +import { + DropPosition, + IGX_GRID_BASE, + IgxExcelStyleColumnOperationsTemplateDirective, + IgxExcelStyleFilterOperationsTemplateDirective, + IgxExcelStyleSearchComponent, + IgxGridExcelStyleFilteringComponent, + IgxGridHeaderGroupComponent, + IgxGridHeaderRowComponent, + IgxGridTopLevelColumns, + IgxHeaderGroupStylePipe, + IPivotAggregator, + IPivotDimension, + IPivotValue, + PivotDimensionType, + PivotGridType, + PivotUtil +} from 'igniteui-angular/grids/core'; +import { IgxPivotRowHeaderGroupComponent } from './pivot-row-header-group.component'; +import { IgxDropDirective, IgxGridForOfDirective } from 'igniteui-angular/directives'; +import { IBaseChipEventArgs, IgxChipComponent, IgxChipsAreaComponent } from 'igniteui-angular/chips'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxPrefixDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; +import { IgxBadgeComponent } from 'igniteui-angular/badge'; +import { IgxDropDownComponent, IgxDropDownItemComponent, IgxDropDownItemNavigationDirective, ISelectionEventArgs } from 'igniteui-angular/drop-down'; + +/** + * + * For all intents & purposes treat this component as what a usually is in the default element. + * + * This container holds the pivot grid header elements and their behavior/interactions. + * + * @hidden @internal + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'igx-pivot-header-row', + templateUrl: './pivot-header-row.component.html', + imports: [IgxDropDirective, IgxChipsAreaComponent, IgxChipComponent, IgxIconComponent, + IgxPrefixDirective, IgxBadgeComponent, IgxSuffixDirective, IgxDropDownItemNavigationDirective, + NgTemplateOutlet, IgxGridHeaderGroupComponent, NgClass, NgStyle, IgxGridForOfDirective, + IgxDropDownComponent, IgxDropDownItemComponent, IgxGridExcelStyleFilteringComponent, + IgxExcelStyleColumnOperationsTemplateDirective, IgxExcelStyleFilterOperationsTemplateDirective, + IgxExcelStyleSearchComponent, IgxHeaderGroupStylePipe, IgxGridTopLevelColumns, + IgxPivotRowHeaderGroupComponent] +}) +export class IgxPivotHeaderRowComponent extends IgxGridHeaderRowComponent implements OnChanges { + public override grid = inject(IGX_GRID_BASE); + protected renderer = inject(Renderer2); + + public aggregateList: IPivotAggregator[] = []; + + public value: IPivotValue; + public filterDropdownDimensions: Set = new Set(); + public filterAreaDimensions: Set = new Set(); + private _dropPos = DropPosition.AfterDropTarget; + private _subMenuPositionSettings: PositionSettings = { + verticalStartPoint: VerticalAlignment.Bottom, + closeAnimation: undefined + }; + private _subMenuOverlaySettings: OverlaySettings = { + closeOnOutsideClick: true, + modal: false, + positionStrategy: new AutoPositionStrategy(this._subMenuPositionSettings), + scrollStrategy: new AbsoluteScrollStrategy() + }; + + /** + * @hidden @internal + */ + @ViewChild('esf') public esf: any; + + /** + * @hidden @internal + */ + @ViewChild('filterAreaHidden', { static: false }) public filterArea; + + /** + * @hidden @internal + */ + @ViewChild('filterIcon') public filtersButton; + + /** + * @hidden @internal + */ + @ViewChild('dropdownChips') public dropdownChips; + + /** + * @hidden @internal + */ + @ViewChild('pivotFilterContainer') public pivotFilterContainer; + + /** + * @hidden @internal + */ + @ViewChild('pivotRowContainer') public pivotRowContainer; + + /** + * @hidden + * @internal + */ + @ViewChildren('notifyChip') + public notificationChips: QueryList; + + /** + * @hidden + * @internal + * The virtualized part of the header row containing the unpinned header groups. + */ + @ViewChildren('headerVirtualContainer', { read: IgxGridForOfDirective }) + public headerContainers: QueryList>; + + /** + * @hidden + * @internal + */ + @ViewChildren('rowDimensionHeaders') + public rowDimensionHeaders: QueryList; + + public override get headerForOf() { + return this.headerContainers?.last; + } + + @HostBinding('attr.aria-activedescendant') + public override get activeDescendant(): string { + const activeElem = this.navigation.activeNode; + if (!activeElem || !Object.keys(activeElem).length || this.grid.navigation.headerRowActiveDescendant) { + return null; + } + + if (this.navigation.isRowDimensionHeaderActive) { + const activeHeader = this.grid.theadRow.rowDimensionHeaders.find(h => h.active); + if (activeHeader) { + const key = activeHeader.title ?? activeHeader.rootDimension?.memberName; + return key ? `${this.grid.id}_${key}` : null; + } + return null; + } + + return super.activeDescendant; + } + + /** + * @hidden + * @internal + * Default is a single empty level since default depth is 1 + */ + public columnDimensionsByLevel: any[] = [[]]; + + /** + * @hidden @internal + */ + public get isFiltersButton(): boolean { + let chipsWidth = 0; + this.filterDropdownDimensions.clear(); + this.filterAreaDimensions.clear(); + if (this.filterArea?.chipsList && this.filterArea.chipsList.length !== 0) { + const styles = getComputedStyle(this.pivotFilterContainer.nativeElement); + const containerPaddings = parseFloat(styles.paddingLeft) + parseFloat(styles.paddingRight); + chipsWidth += containerPaddings + (this.filtersButton && this.filterArea?.chipsList.length > 1 ? this.filtersButton.el.nativeElement.getBoundingClientRect().width : 0); + this.filterArea.chipsList.forEach(chip => { + const dim = this.grid.filterDimensions.find(x => x.memberName === chip.id); + if (dim) { + // 8 px margin between chips + const currentChipWidth = chip.nativeElement.getBoundingClientRect().width + 8; + if (chipsWidth + currentChipWidth < this.grid.pivotRowWidths) { + this.filterAreaDimensions.add(dim); + } else { + this.filterDropdownDimensions.add(dim); + } + chipsWidth += currentChipWidth; + } + }); + return this.filterDropdownDimensions.size > 0; + } + return false; + } + + /** + * @hidden + * @internal + */ + public get totalDepth() { + const columnDimensions = this.grid.columnDimensions; + if (columnDimensions.length === 0) { + return 1; + } + let totalDepth = columnDimensions.map(x => this.grid.data?.length > 0 ? PivotUtil.getDimensionDepth(x) + 1 : 0).reduce((acc, val) => acc + val); + if (this.grid.hasMultipleValues) { + totalDepth += 1; + } + return totalDepth; + } + + /** + * @hidden + * @internal + */ + public get maxContainerHeight() { + return this.totalDepth * this.grid.renderedRowHeight; + } + + /** + * @hidden + * @internal + */ + public override get isLeafHeaderAriaHidden(): boolean { + return super.isLeafHeaderAriaHidden || this.grid.navigation.isRowHeaderActive || this.grid.navigation.isRowDimensionHeaderActive; + } + + /** + * @hidden + * @internal + */ + public calcHeight(col: ColumnType, index: number) { + return !col.columnGroup && col.level < this.totalDepth && col.level === index ? (this.totalDepth - col.level) * this.grid.rowHeight : this.grid.rowHeight; + } + + /** + * @hidden + * @internal + */ + public isDuplicateOfExistingParent(col: ColumnType, lvl: number) { + const parentCollection = lvl > 0 ? this.columnDimensionsByLevel[lvl - 1] : []; + const duplicate = parentCollection.indexOf(col) !== -1; + + return duplicate; + } + + /** + * @hidden + * @internal + */ + public isMultiRow(col: ColumnType, lvl: number) { + const isLeaf = !col.columnGroup; + return isLeaf && lvl !== this.totalDepth - 1; + } + + /** + * @hidden + * @internal + */ + public populateColumnDimensionsByLevel() { + const res = []; + for (let i = 0; i < this.totalDepth; i++) { + res[i] = []; + } + const cols = this.unpinnedColumnCollection; + // populate column dimension matrix recursively + this.populateDimensionRecursively(cols.filter(x => x.level === 0), 0, res); + this.columnDimensionsByLevel = res; + } + + protected populateDimensionRecursively(currentLevelColumns: ColumnType[], level = 0, res: any[]) { + currentLevelColumns.forEach(col => { + if (res[level]) { + res[level].push(col); + if (col.columnGroup && col.children.length > 0) { + const visibleColumns = col.children.toArray().filter(x => !x.hidden); + this.populateDimensionRecursively(visibleColumns, level + 1, res); + } else if (level < this.totalDepth - 1) { + for (let i = level + 1; i <= this.totalDepth - 1; i++) { + res[i].push(col); + } + } + } + }); + } + + /** + * @hidden + * @internal + */ + public ngOnChanges(changes: SimpleChanges) { + if (changes.unpinnedColumnCollection) { + this.populateColumnDimensionsByLevel(); + } + } + + /** + * @hidden + * @internal + */ + public onDimDragStart(event, area) { + this.cdr.detectChanges(); + for (const chip of this.notificationChips) { + const parent = chip.nativeElement.parentElement; + if (area.chipsList.toArray().indexOf(chip) === -1 && + parent.children.length > 0 && + parent.children.item(0).id !== 'empty') { + chip.nativeElement.hidden = false; + parent.parentElement.scrollTo({ left: chip.nativeElement.offsetLeft }); + } + } + } + + /** + * @hidden + * @internal + */ + public onDimDragEnd() { + for (const chip of this.notificationChips) { + chip.nativeElement.hidden = true; + } + } + + /** + * @hidden + * @internal + */ + public getAreaHeight(area: IgxChipsAreaComponent) { + const chips = area.chipsList; + return chips && chips.length > 0 ? chips.first.nativeElement.offsetHeight : 0; + } + + /** + * @hidden + * @internal + */ + public rowRemoved(event: IBaseChipEventArgs) { + const row = this.grid.pivotConfiguration.rows.find(x => x.memberName === event.owner.id); + this.grid.toggleDimension(row); + } + + /** + * @hidden + * @internal + */ + public columnRemoved(event: IBaseChipEventArgs) { + const col = this.grid.pivotConfiguration.columns.find(x => x.memberName === event.owner.id); + this.grid.toggleDimension(col); + } + + /** + * @hidden + * @internal + */ + public valueRemoved(event: IBaseChipEventArgs) { + const value = this.grid.pivotConfiguration.values.find(x => x.member === event.owner.id || x.displayName === event.owner.id); + this.grid.toggleValue(value); + } + + /** + * @hidden + * @internal + */ + public filterRemoved(event: IBaseChipEventArgs) { + const filter = this.grid.pivotConfiguration.filters.find(x => x.memberName === event.owner.id); + this.grid.toggleDimension(filter); + if (this.filterDropdownDimensions.size > 0) { + this.onFiltersAreaDropdownClick({ target: this.filtersButton.el.nativeElement }, undefined, false); + } else { + this.grid.filteringService.hideESF(); + } + } + + public onFiltersSelectionChanged(event?: IBaseChipEventArgs) { + this.dropdownChips.chipsList.forEach(chip => { + if (chip.id !== event.owner.id) { + chip.selected = false + } + }); + this.onFiltersAreaDropdownClick({ target: this.filtersButton.el.nativeElement }, this.grid.filterDimensions.find(dim => dim.memberName === event.owner.id), false); + } + + /** + * @hidden + * @internal + */ + public onFilteringIconPointerDown(event) { + event.stopPropagation(); + event.preventDefault(); + } + + /** + * @hidden + * @internal + */ + public onFilteringIconClick(event, dimension) { + event.stopPropagation(); + event.preventDefault(); + const dim = dimension; + const col = this.grid.dimensionDataColumns.find(x => x.field === dim.memberName || x.field === dim.member); + this.grid.filteringService.toggleFilterDropdown(event.target, col); + } + + /** + * @hidden + * @internal + */ + public onSummaryClick(eventArgs, value: IPivotValue, dropdown: IgxDropDownComponent, chip: IgxChipComponent) { + this._subMenuOverlaySettings.target = eventArgs.currentTarget; + this.updateDropDown(value, dropdown, chip); + } + + /** + * @hidden @internal + */ + public onFiltersAreaDropdownClick(event, dimension?, shouldReattach = true) { + const dim = dimension || this.filterDropdownDimensions.values().next().value; + const col = this.grid.dimensionDataColumns.find(x => x.field === dim.memberName || x.field === dim.member); + if (shouldReattach) { + this.dropdownChips.chipsList.forEach(chip => { + chip.selected = false + }); + this.dropdownChips.chipsList.first.selected = true; + } + this.grid.filteringService.toggleFiltersESF(this.esf, event.target, col, shouldReattach); + } + + /** + * @hidden + * @internal + */ + public onAggregationChange(event: ISelectionEventArgs) { + + if (!this.isSelected(event.newSelection.value)) { + this.value.aggregate = event.newSelection.value; + const isSingleValue = this.grid.values.length === 1; + + PivotUtil.updateColumnTypeByAggregator(this.grid.columns, this.value, isSingleValue); + + this.grid.pipeTrigger++; + } + } + + /** + * @hidden + * @internal + */ + public isSelected(val: IPivotAggregator) { + return this.value.aggregate.key === val.key; + } + + /** + * @hidden + * @internal + */ + public onChipSort(_event, dimension: IPivotDimension) { + if (dimension.sortable === undefined || dimension.sortable) { + const startDirection = dimension.sortDirection || SortingDirection.None; + const direction = startDirection + 1 > SortingDirection.Desc ? + SortingDirection.None : startDirection + 1; + this.grid.sortDimension(dimension, direction); + } + } + + /** + * @hidden + * @internal + */ + public onDimDragOver(event, dimension?: PivotDimensionType) { + if (!event.dragChip || !event.dragChip.data?.pivotArea) return; + const typeMismatch = dimension !== undefined ? this.grid.pivotConfiguration.values.find(x => x.member === event.dragChip.id + || x.displayName === event.dragChip.id) : + !this.grid.pivotConfiguration.values.find(x => x.member === event.dragChip.id || x.displayName === event.dragChip.id); + if (typeMismatch) { + // cannot drag between dimensions and value + return; + } + // if we are in the left half of the chip, drop on the left + // else drop on the right of the chip + const clientRect = event.owner.nativeElement.getBoundingClientRect(); + const pos = clientRect.width / 2; + + this._dropPos = event.originalEvent.offsetX > pos ? DropPosition.AfterDropTarget : DropPosition.BeforeDropTarget; + if (this._dropPos === DropPosition.AfterDropTarget) { + event.owner.nativeElement.previousElementSibling.style.visibility = 'hidden'; + event.owner.nativeElement.nextElementSibling.style.visibility = ''; + } else { + event.owner.nativeElement.nextElementSibling.style.visibility = 'hidden'; + event.owner.nativeElement.previousElementSibling.style.visibility = ''; + } + } + + /** + * @hidden + * @internal + */ + public onDimDragLeave(event) { + event.owner.nativeElement.previousElementSibling.style.visibility = 'hidden'; + event.owner.nativeElement.nextElementSibling.style.visibility = 'hidden'; + this._dropPos = DropPosition.AfterDropTarget; + } + + /** + * @hidden + * @internal + */ + public onAreaDragLeave(event, area) { + const dataChips = area.chipsList.toArray().filter(x => this.notificationChips.toArray().indexOf(x) === -1); + dataChips.forEach(element => { + if (element.nativeElement.previousElementSibling) { + element.nativeElement.previousElementSibling.style.visibility = 'hidden'; + } + if (element.nativeElement.nextElementSibling) { + element.nativeElement.nextElementSibling.style.visibility = 'hidden'; + } + }); + } + + /** + * @hidden + * @internal + */ + public onValueDrop(event, area) { + if (!(event.dragChip && event.dragChip.data?.pivotArea) && !(event.dragData?.chip && !!event.dragData.chip.data.pivotArea)) return; + //values can only be reordered + const values = this.grid.pivotConfiguration.values; + const dragId = event.dragChip?.id || event.dragData?.chip.id; + const chipsArray = area.chipsList.toArray(); + let chipIndex = chipsArray.indexOf(event.owner) !== -1 ? chipsArray.indexOf(event.owner) : chipsArray.length; + chipIndex = this._dropPos === DropPosition.AfterDropTarget ? chipIndex + 1 : chipIndex; + const value = values.find(x => x.member === dragId || x.displayName === dragId); + if (value) { + const dragChipIndex = chipsArray.indexOf(event.dragChip || event.dragData.chip); + this.grid.moveValue(value, dragChipIndex >= chipIndex ? chipIndex : chipIndex - 1); + } + } + + /** + * @hidden + * @internal + */ + public onDimDrop(event, area, dimensionType: PivotDimensionType) { + if (!(event.dragChip && event.dragChip.data?.pivotArea) && !(event.dragData?.chip && !!event.dragData.chip.data.pivotArea)) return; + const dragId = event.dragChip?.id || event.dragData?.chip.id; + const currentDim = this.grid.getDimensionsByType(dimensionType); + const chipsArray = area.chipsList.toArray(); + const chip = chipsArray.find(x => x.id === dragId); + const isNewChip = chip === undefined; + const isReorder = event.owner.id !== undefined; + //const chipIndex = chipsArray.indexOf(event.owner) !== -1 ? chipsArray.indexOf(event.owner) : chipsArray.length; + const chipIndex = currentDim.findIndex(x => x.memberName === event.owner.id) !== -1 ? + currentDim.findIndex(x => x.memberName === event.owner.id) : currentDim.length; + const targetIndex = this._dropPos === DropPosition.AfterDropTarget ? chipIndex + 1 : chipIndex; + if (isNewChip) { + // chip moved from an external collection + const dim = this.grid.allDimensions.find(x => x && x.memberName === dragId); + if (!dim) { + // you have dragged something that is not a dimension + return; + } + this.grid.moveDimension(dim, dimensionType, targetIndex); + } else if (isReorder) { + // chip from same collection, reordered. + const newDim = currentDim.find(x => x.memberName === dragId); + const dragChipIndex = currentDim.findIndex(x => x.memberName === dragId); + this.grid.moveDimension(newDim, dimensionType, dragChipIndex > chipIndex ? targetIndex : targetIndex - 1); + } + this.grid.pipeTrigger++; + this.grid.dimensionsChange.emit({ dimensions: currentDim, dimensionCollectionType: dimensionType }); + // clean states + this.onDimDragEnd(); + this.onAreaDragLeave(event, area); + } + + protected updateDropDown(value: IPivotValue, dropdown: IgxDropDownComponent, chip: IgxChipComponent) { + this.value = value; + dropdown.width = chip.nativeElement.clientWidth + 'px'; + this.aggregateList = PivotUtil.getAggregateList(value, this.grid); + this.cdr.detectChanges(); + dropdown.open(this._subMenuOverlaySettings); + } + + protected getRowDimensionColumn(dim: IPivotDimension): ColumnType { + return this.grid.dimensionDataColumns ? this.grid.dimensionDataColumns.find((col) => col.field === dim.memberName) : null; + } +} diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-row-dimension-content.component.html b/projects/igniteui-angular/grids/pivot-grid/src/pivot-row-dimension-content.component.html new file mode 100644 index 00000000000..44b857d76dd --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-row-dimension-content.component.html @@ -0,0 +1,33 @@ +
    +
    + + +
    +
    + + +
    + + + {{column.header}} +
    +
    + + +
    + + + {{column.header}} +
    +
    diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-row-dimension-content.component.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-row-dimension-content.component.ts new file mode 100644 index 00000000000..5abfa05e44b --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-row-dimension-content.component.ts @@ -0,0 +1,207 @@ +import { + ChangeDetectionStrategy, + Component, + createComponent, + EnvironmentInjector, + HostBinding, + inject, + Injector, + Input, + OnChanges, + QueryList, + SimpleChanges, + TemplateRef, + ViewChild, + ViewChildren, + ViewContainerRef +} from '@angular/core'; +import { NgClass, NgStyle } from '@angular/common'; + +import { + IGX_GRID_BASE, + IgxColumnComponent, + IgxGridHeaderRowComponent, + IgxHeaderGroupStylePipe, + IMultiRowLayoutNode, + IPivotDimension, + IPivotDimensionData, + IPivotGridGroupRecord, + PivotGridType, + PivotUtil +} from 'igniteui-angular/grids/core'; +import { IgxPivotRowDimensionHeaderGroupComponent } from './pivot-row-dimension-header-group.component'; +import { IgxIconComponent } from 'igniteui-angular/icon'; + +/** + * + * For all intents & purposes treat this component as what a
    usually is in the default
    element. + * + * This container holds the pivot grid header elements and their behavior/interactions. + * + * @hidden @internal + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'igx-pivot-row-dimension-content', + templateUrl: './pivot-row-dimension-content.component.html', + imports: [IgxPivotRowDimensionHeaderGroupComponent, NgClass, NgStyle, IgxIconComponent, IgxHeaderGroupStylePipe] +}) +export class IgxPivotRowDimensionContentComponent extends IgxGridHeaderRowComponent implements OnChanges { + public override grid = inject(IGX_GRID_BASE); + protected injector = inject(Injector); + protected envInjector = inject(EnvironmentInjector); + protected viewRef = inject(ViewContainerRef); + + @HostBinding('style.grid-row-start') + public get rowStart(): string { + return this.layout ? `${this.layout.rowStart}` : ""; + } + + @HostBinding('style.grid-row-end') + public get rowsEnd(): string { + return this.layout ? `${this.layout.rowEnd}` : ""; + } + + @HostBinding('style.grid-column-start') + public get colStart(): string { + return this.layout ? `${this.layout.colStart}` : ""; + } + + @HostBinding('style.grid-column-end') + public get colEnd(): string { + return this.layout ? `${this.layout.colEnd}` : ""; + } + + /** + * @hidden + * @internal + */ + @Input() + public rowIndex: number; + + /** + * @hidden + * @internal + */ + @Input() + public colIndex: number; + + @Input() + public layout: IMultiRowLayoutNode; + + @Input() + public dimension: IPivotDimension; + + @Input() + public rootDimension: IPivotDimension; + + @Input() + public rowData: IPivotGridGroupRecord; + + /** + * @hidden @internal + */ + @ViewChild('headerTemplate', { read: TemplateRef, static: true }) + public headerTemplate: TemplateRef; + + /** + * @hidden @internal + */ + @ViewChild('headerDefaultTemplate', { read: TemplateRef, static: true }) + public headerTemplateDefault: TemplateRef; + + @ViewChildren(IgxPivotRowDimensionHeaderGroupComponent) + public headerGroups: QueryList + + /** + * @hidden @internal + */ + public rowDimensionData: IPivotDimensionData; + + public get rowDimensionColumn() { + return this.rowDimensionData?.column; + } + + /** + * @hidden + * @internal + */ + public ngOnChanges(changes: SimpleChanges) { + if (changes.rowData) { + // generate new rowDimension on row data change + this.rowDimensionData = null; + this.viewRef.clear(); + this.extractFromDimensions(); + this.viewRef.clear(); + } + if (changes.width && this.rowDimensionData) { + const data = this.rowDimensionData; + data.column.width = this.grid.rowDimensionWidthToPixels(this.rootDimension) + 'px'; + } + } + + /** + * @hidden + * @internal + */ + public toggleRowDimension(event) { + this.grid.toggleRow(this.getRowDimensionKey()); + this.grid.navigation.onRowToggle(this.getExpandState(), this.dimension, this.rowData, this.layout); + event?.stopPropagation(); + } + + + /** + * @hidden + * @internal + */ + public getRowDimensionKey() { + const dimData = this.rowDimensionData; + const key = PivotUtil.getRecordKey(this.rowData, dimData.dimension); + return key; + } + + public getExpandState() { + return this.grid.gridAPI.get_row_expansion_state(this.getRowDimensionKey()); + } + + public getLevel() { + return this.grid.hasHorizontalLayout ? 0 : this.dimension.level; + } + + protected extractFromDimensions() { + if (this.dimension && this.rowData) { + const col = this.extractFromDimension(this.dimension, this.rowData); + const prevDims = []; + this.rowDimensionData = { + column: col, + dimension: this.dimension, + prevDimensions: prevDims + }; + } + } + + protected extractFromDimension(dim: IPivotDimension, rowData: IPivotGridGroupRecord) { + const field = dim.memberName; + const header = rowData?.dimensionValues.get(field); + const col = this._createColComponent(field, header, dim); + return col; + } + + protected _createColComponent(field: string, header: string, dim: IPivotDimension) { + const ref = createComponent(IgxColumnComponent, { environmentInjector: this.envInjector, elementInjector: this.injector}); + ref.instance.field = field; + ref.instance.header = header; + ref.instance.width = this.grid.rowDimensionWidthToPixels(this.rootDimension) + 'px'; + ref.instance.resizable = this.grid.rowDimensionResizing; + (ref as any).instance._vIndex = this.grid.columns.length + this.rowIndex + this.rowIndex * this.grid.pivotConfiguration.rows.length; + + + if (header && dim.childLevel && (!this.rowData.totalRecordDimensionName || this.rowData.totalRecordDimensionName !== dim.memberName)) { + ref.instance.headerTemplate = this.headerTemplate; + } else { + ref.instance.headerTemplate = this.headerTemplateDefault; + } + return ref.instance; + } +} diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-row-dimension-header-group.component.html b/projects/igniteui-angular/grids/pivot-grid/src/pivot-row-dimension-header-group.component.html new file mode 100644 index 00000000000..d73a2520541 --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-row-dimension-header-group.component.html @@ -0,0 +1,44 @@ + + {{column.header}} + + + + + + + +@if (!column.columnGroup) { + @if (grid.hasMovableColumns) { + + } + + + @if (!column.columnGroup && column.resizable) { + + + } + @if (grid.hasMovableColumns) { + + } +} diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-row-dimension-header-group.component.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-row-dimension-header-group.component.ts new file mode 100644 index 00000000000..fda18cb8240 --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-row-dimension-header-group.component.ts @@ -0,0 +1,192 @@ +import { ChangeDetectionStrategy, Component, HostBinding, HostListener, inject, Input, NgZone, ViewChild } from '@angular/core'; +import { NgClass, NgStyle } from '@angular/common'; + +import { + IGX_GRID_BASE, + IgxColumnComponent, + IgxColumnMovingDragDirective, + IgxColumnMovingDropDirective, + IgxGridHeaderGroupComponent, + IgxHeaderGroupStylePipe, + IgxPivotColumnResizingService, + IgxPivotResizeHandleDirective, + IMultiRowLayoutNode, + IPivotDimension, + PivotGridType, + PivotRowHeaderGroupType +} from 'igniteui-angular/grids/core'; +import { IgxPivotRowDimensionHeaderComponent } from './pivot-row-dimension-header.component'; +import { IgxIconComponent } from 'igniteui-angular/icon'; + +/** + * @hidden + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'igx-pivot-row-dimension-header-group', + templateUrl: './pivot-row-dimension-header-group.component.html', + imports: [IgxIconComponent, IgxPivotRowDimensionHeaderComponent, NgClass, NgStyle, IgxColumnMovingDragDirective, IgxColumnMovingDropDirective, IgxPivotResizeHandleDirective, IgxHeaderGroupStylePipe] +}) +export class IgxPivotRowDimensionHeaderGroupComponent extends IgxGridHeaderGroupComponent implements PivotRowHeaderGroupType { + public override grid = inject(IGX_GRID_BASE); + public override colResizingService = inject(IgxPivotColumnResizingService); + protected zone = inject(NgZone); + + /** + * @hidden + */ + @HostBinding('style.user-select') + public userSelect = 'none'; + + /** + * @hidden + */ + public get role(): string { + return 'rowheader'; + } + + /** + * @hidden + * @internal + */ + @Input() + public rowIndex: number; + + /** + * @hidden + * @internal + */ + @Input() + public colIndex: number; + + + /** + * @hidden + * @internal + */ + @Input() + public layout: IMultiRowLayoutNode; + + /** + * @hidden + * @internal + */ + @Input() + public parent: any; + + @ViewChild(IgxPivotRowDimensionHeaderComponent) + public override header: IgxPivotRowDimensionHeaderComponent; + + @HostBinding('attr.id') + public override get headerID() { + return `${this.grid.id}_-2_${this.rowIndex}_${this.visibleIndex}`; + } + + @HostBinding('attr.title') + public override get title() { + return this.column.header; + } + + /** + * @hidden @internal + */ + @HostListener('click', ['$event']) + public onClick(event: MouseEvent) { + if (this.grid.rowSelection === 'none') { + return; + } + event?.stopPropagation(); + const key = this.parent.getRowDimensionKey(this.column as IgxColumnComponent); + if (this.grid.selectionService.isRowSelected(key)) { + this.grid.selectionService.deselectRow(key, event); + } else { + this.grid.selectionService.selectRowById(key, true, event); + } + + this.zone.run(() => {}); + } + + /** + * @hidden + * @internal + */ + public get visibleIndex(): number { + if (this.grid.hasHorizontalLayout) { + return this.colIndex; + } + + const field = this.column.field; + const rows = this.grid.rowDimensions; + const rootDimension = this.findRootDimension(field); + return rows.indexOf(rootDimension); + } + + @HostBinding('class.igx-grid-th--active') + public override get active() { + const nav = this.grid.navigation; + const node = nav.activeNode; + return node && !this.column.columnGroup ? + nav.isRowHeaderActive && + node.row === this.rowIndex && + node.column === this.visibleIndex : + false; + } + + protected override get activeNode() { + this.grid.navigation.isRowHeaderActive = true; + this.grid.navigation.isRowDimensionHeaderActive = false; + return { + row: this.rowIndex, column: this.visibleIndex, level: null, + mchCache: null, + layout: this.layout || null + }; + } + + + protected getHeaderWidthFromDimension() { + if (this.grid.hasHorizontalLayout) { + return this.parent.width === -1 ? 'fit-content' : this.width; + } + return this.grid.rowDimensionWidth(this.parent.rootDimension); + } + + private findRootDimension(field: string): IPivotDimension { + const rows = this.grid.rowDimensions; + let tempRow; + let result = null; + rows.forEach(row => { + tempRow = row; + do { + if (tempRow.memberName === field) { + result = row; + } + tempRow = tempRow.childLevel; + } while (tempRow) + }); + return result; + } + + + public override activate() { + this.grid.navigation.isRowHeader = true; + this.grid.navigation.setActiveNode(this.activeNode); + } + + /** + * @hidden @internal + */ + public override pointerdown(_event: PointerEvent): void { + this.activate(); + } + + /** + * @hidden @internal + */ + public override onMouseDown(_event: MouseEvent): void { + this.activate(); + } + + public override get selectable(): boolean { + return false; + } +} diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-row-dimension-header.component.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-row-dimension-header.component.ts new file mode 100644 index 00000000000..89c27579e5a --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-row-dimension-header.component.ts @@ -0,0 +1,93 @@ +import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, HostListener, inject } from '@angular/core'; + +import { PivotGridType, PivotRowLayoutType, PivotUtil } from 'igniteui-angular/grids/core'; + +import { IgxGridHeaderComponent } from 'igniteui-angular/grids/core'; +import { IgxPivotColumnResizingService } from 'igniteui-angular/grids/core'; +import { SortingIndexPipe } from 'igniteui-angular/grids/core'; +import { NgTemplateOutlet, NgClass } from '@angular/common'; +import { takeUntil } from 'rxjs/operators'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { ISortingExpression, SortingDirection } from 'igniteui-angular/core'; + +/** + * @hidden + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'igx-pivot-row-dimension-header', + templateUrl: '../../core/src/headers/grid-header.component.html', + imports: [IgxIconComponent, NgTemplateOutlet, NgClass, SortingIndexPipe] +}) +export class IgxPivotRowDimensionHeaderComponent extends IgxGridHeaderComponent implements AfterViewInit { + public override colResizingService = inject(IgxPivotColumnResizingService); + public refInstance = inject(ElementRef); + + private pivotGrid: PivotGridType; + + constructor() { + super(); + + this.pivotGrid = this.grid as PivotGridType; + this.pivotGrid.dimensionsSortingExpressionsChange + .pipe(takeUntil(this._destroy$)) + .subscribe((_: ISortingExpression[]) => this.setSortIndex()); + } + + public ngAfterViewInit(): void { + this.setSortIndex(); + } + + @HostListener('click', ['$event']) + public override onClick(event: MouseEvent) { + event.preventDefault(); + } + + /** + * @hidden @internal + */ + public override get selectable(): boolean { + return false; + } + + /** + * @hidden @internal + */ + public override onSortingIconClick(event) { + event.stopPropagation(); + const dim = this.pivotGrid.getRowDimensionByName(this.column.field); + const startDirection = dim.sortDirection || SortingDirection.None; + const direction = startDirection + 1 > SortingDirection.Desc ? + SortingDirection.None : startDirection + 1; + this.pivotGrid.sortDimension(dim, direction); + } + + protected override getSortDirection() { + const dim = this.pivotGrid.getRowDimensionByName(this.column.field); + this.sortDirection = dim?.sortDirection || SortingDirection.None; + } + + protected setSortIndex() { + if (this.column.sortable && this.sortIconContainer) { + const visibleRows = this.pivotGrid.pivotUI.rowLayout === PivotRowLayoutType.Vertical ? + this.pivotGrid.pivotConfiguration.rows : + PivotUtil.flatten(this.pivotGrid.pivotConfiguration.rows); + const dimIndex = visibleRows.findIndex((target) => target.memberName === this.column.field); + const dim = visibleRows[dimIndex]; + let newSortIndex = -1; + if (dim.sortDirection) { + let priorSortedDims = 0; + for (let i = 0; i < dimIndex; i++) { + if (visibleRows[i].sortDirection) { + priorSortedDims++; + } + } + + // Sort index starts from 1. + newSortIndex = priorSortedDims + 1; + } + + this.sortIconContainer.nativeElement.setAttribute("data-sortIndex", newSortIndex >= 0 ? newSortIndex : ""); + } + } +} diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-row-dimension-mrl-row.component.html b/projects/igniteui-angular/grids/pivot-grid/src/pivot-row-dimension-mrl-row.component.html new file mode 100644 index 00000000000..d52e66262da --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-row-dimension-mrl-row.component.html @@ -0,0 +1,21 @@ +@for ( + cell of rowGroup | pivotGridHorizontalRowCellMerging:grid.pivotConfiguration:grid.pipeTrigger; + track getGroupKey(cell); let cellIndex = $index +) { + + +} diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-row-dimension-mrl-row.component.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-row-dimension-mrl-row.component.ts new file mode 100644 index 00000000000..91ba907da94 --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-row-dimension-mrl-row.component.ts @@ -0,0 +1,116 @@ +import { + ChangeDetectionStrategy, + Component, + EnvironmentInjector, + HostBinding, + inject, + Injector, + Input, + QueryList, + ViewChildren, + ViewContainerRef +} from '@angular/core'; +import { IGX_GRID_BASE, IPivotDimension, IPivotDimensionData, IPivotGridHorizontalGroup, IPivotGridRecord, PivotGridType, PivotUtil } from 'igniteui-angular/grids/core'; +import { IgxGridHeaderRowComponent } from 'igniteui-angular/grids/core'; +import { IgxPivotRowDimensionContentComponent } from './pivot-row-dimension-content.component'; +import { IgxPivotGridHorizontalRowCellMerging } from './pivot-grid.pipes'; + +/** + * + * For all intents & purposes treat this component as what a usually is in the default
    element. + * + * This container holds the pivot grid header elements and their behavior/interactions. + * + * @hidden @internal + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'igx-pivot-row-dimension-mrl-row', + templateUrl: './pivot-row-dimension-mrl-row.component.html', + imports: [IgxPivotRowDimensionContentComponent, IgxPivotGridHorizontalRowCellMerging] +}) +export class IgxPivotRowDimensionMrlRowComponent extends IgxGridHeaderRowComponent { + public override grid = inject(IGX_GRID_BASE); + protected injector = inject(Injector); + protected envInjector = inject(EnvironmentInjector); + protected viewRef = inject(ViewContainerRef); + + @ViewChildren(IgxPivotRowDimensionContentComponent) + public rowDimensionContentCollection: QueryList; + + @HostBinding('class.igx-grid__tbody-pivot-dimension') + public pivotDim = true; + + @HostBinding('class.igx-grid__mrl-block') + public mrlBlock = true; + + @HostBinding('style.grid-template-rows') + public get rowsTemplate(): string { + return this.getRowMRLTemplate(true, this.rowGroup); + } + + @HostBinding('style.grid-template-columns') + public get colsTemplate(): string { + return this.getRowMRLTemplate(false, this.rowGroup); + } + + /** + * @hidden @internal + */ + @Input() + public rowIndex: number; + + /** + * @hidden @internal + */ + @Input() + public rowGroup: IPivotGridRecord[]; + + /** + * @hidden @internal + */ + @Input() + public groupedData: IPivotGridRecord[][]; + + /** + * @hidden @internal + */ + @ViewChildren(IgxPivotRowDimensionContentComponent) + public contentCells: QueryList + + /** + * @hidden @internal + */ + public rowDimensionData: IPivotDimensionData; + + protected getRowMRLTemplate(forRows: boolean, rows: IPivotGridRecord[]) { + if (forRows) { + return `repeat(${rows.length},1fr)`; + } else if (this.grid.visibleRowDimensions && this.grid.dimensionDataColumns) { + const res = []; + this.grid.visibleRowDimensions.forEach(dim => { + res.push(this.grid.rowDimensionWidth(dim)); + }); + return res.join(' '); + } + } + + public rowDimensionWidthCombined(dims: IPivotDimension[]) { + let resWidth = 0; + for (const dim of (dims || [])) { + const rowDimWidth = this.grid.rowDimensionWidth(dim); + if (rowDimWidth === 'fit-content') { + return -1; + } else { + resWidth += parseFloat(rowDimWidth); + } + } + return resWidth; + } + + protected getGroupKey(group: IPivotGridHorizontalGroup) { + const rec = group.records[0]; + const key = PivotUtil.getRecordKey(rec, group.rootDimension); + return key; + } +} diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-row-header-group.component.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-row-header-group.component.ts new file mode 100644 index 00000000000..5dfcef45b9f --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-row-header-group.component.ts @@ -0,0 +1,158 @@ +import { ChangeDetectionStrategy, Component, HostBinding, inject, Input, ViewChild } from '@angular/core'; +import { NgClass, NgStyle } from '@angular/common'; +import { + IGX_GRID_BASE, + IgxColumnMovingDragDirective, + IgxColumnMovingDropDirective, + IgxGridHeaderGroupComponent, + IgxHeaderGroupStylePipe, + IgxPivotColumnResizingService, + IgxPivotResizeHandleDirective, + IPivotDimension, + PivotGridType, + PivotRowHeaderGroupType +} from 'igniteui-angular/grids/core'; +import { IgxPivotRowDimensionHeaderComponent } from './pivot-row-dimension-header.component'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { SortingDirection } from 'igniteui-angular/core'; + +/** + * @hidden + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'igx-pivot-row-header-group', + templateUrl: './pivot-row-dimension-header-group.component.html', + imports: [IgxIconComponent, IgxPivotRowDimensionHeaderComponent, NgClass, NgStyle, IgxColumnMovingDragDirective, IgxColumnMovingDropDirective, IgxPivotResizeHandleDirective, IgxHeaderGroupStylePipe] +}) +export class IgxPivotRowHeaderGroupComponent extends IgxGridHeaderGroupComponent implements PivotRowHeaderGroupType { + public override grid = inject(IGX_GRID_BASE); + public override colResizingService = inject(IgxPivotColumnResizingService); + + /** + * @hidden + */ + @HostBinding('style.user-select') + public userSelect = 'none'; + + /** + * @hidden + */ + public get role(): string { + return 'columnheader'; + } + + /** + * @hidden + * @internal + */ + @Input() + public rowIndex: number; + + @Input() + public set dimWidth(value: number) { + this.column.width = value + 'px'; + } + public get dimWidth() { + return parseFloat(this.column.width); + } + + public get parent() { + return this; + }; + + @Input() + public rootDimension: IPivotDimension; + + @ViewChild(IgxPivotRowDimensionHeaderComponent) + public override header: IgxPivotRowDimensionHeaderComponent; + + @HostBinding('attr.id') + public override get headerID() { + return `${this.grid.id}_-2_${this.rootDimension.memberName}_${this.visibleIndex}`; + } + + @HostBinding('attr.title') + public override get title() { + return this.rootDimension.displayName; + } + + /** + * @hidden + * @internal + */ + public get visibleIndex(): number { + const rows = this.grid.visibleRowDimensions; + return rows.indexOf(this.rootDimension); + } + + @HostBinding('class.igx-grid-th--active') + public override get active() { + const nav = this.grid.navigation; + const node = nav.activeNode; + return node && !this.column.columnGroup ? + nav.isRowDimensionHeaderActive && + node.row === this.rowIndex && + node.column === this.visibleIndex : + false; + } + + @HostBinding('class.asc') + public get sortAscendingStyle() { + return this.rootDimension.sortDirection === SortingDirection.Asc; + } + + @HostBinding('class.desc') + public get sortDescendingStyle() { + return this.rootDimension.sortDirection === SortingDirection.Desc; + } + + @HostBinding('class.igx-grid-th--sortable') + public get sortableStyle() { + return true; + } + + @HostBinding('class.igx-grid-th--sorted') + public get sortedStyle() { + return this.rootDimension.sortDirection !== undefined && this.rootDimension.sortDirection !== SortingDirection.None; + } + + protected override get activeNode() { + this.grid.navigation.isRowDimensionHeaderActive = true; + this.grid.navigation.isRowHeaderActive = false; + return { + row: this.rowIndex, column: this.visibleIndex, level: null, + mchCache: { + level: 0, + visibleIndex: this.visibleIndex + }, + layout: null + }; + } + + public override activate() { + this.grid.navigation.setActiveNode(this.activeNode); + } + + /** + * @hidden @internal + */ + public override pointerdown(_event: PointerEvent): void { + this.activate(); + } + + /** + * @hidden @internal + */ + public override onMouseDown(_event: MouseEvent): void { + this.activate(); + } + + public override get selectable(): boolean { + return false; + } + + protected getHeaderWidthFromDimension() { + return this.grid.hasHorizontalLayout && this.dimWidth === -1 ? 'fit-content' : null; + } +} diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-row.component.html b/projects/igniteui-angular/grids/pivot-grid/src/pivot-row.component.html new file mode 100644 index 00000000000..118c665e2d5 --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-row.component.html @@ -0,0 +1,31 @@ + + + + + + +
    + + +
    +
    + diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-row.component.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-row.component.ts new file mode 100644 index 00000000000..71ca5a240a5 --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-row.component.ts @@ -0,0 +1,200 @@ +import { + ChangeDetectionStrategy, + Component, + forwardRef, + HostBinding, inject, Input, ViewContainerRef +} from '@angular/core'; +import { NgClass, NgStyle } from '@angular/common'; +import { + IGX_GRID_BASE, + IgxColumnComponent, + IgxGridCellComponent, + IgxGridCellStylesPipe, + IgxGridNotGroupedPipe, + IgxGridTransactionStatePipe, + IgxRowDirective, + IPivotGridColumn, + IPivotGridRecord, + PivotGridType, + PivotUtil +} from 'igniteui-angular/grids/core'; +import { IgxPivotGridCellStyleClassesPipe } from './pivot-grid.pipes'; +import { IgxGridForOfDirective } from 'igniteui-angular/directives'; +import { IgxCheckboxComponent } from 'igniteui-angular/checkbox'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'igx-pivot-row', + templateUrl: './pivot-row.component.html', + providers: [{ provide: IgxRowDirective, useExisting: forwardRef(() => IgxPivotRowComponent) }], + imports: [IgxGridForOfDirective, IgxGridCellComponent, NgClass, NgStyle, IgxCheckboxComponent, IgxGridNotGroupedPipe, IgxGridCellStylesPipe, IgxGridTransactionStatePipe, IgxPivotGridCellStyleClassesPipe] +}) +export class IgxPivotRowComponent extends IgxRowDirective { + public override grid = inject(IGX_GRID_BASE); + protected viewRef = inject(ViewContainerRef); + + /** + * @hidden + */ + @Input() + @HostBinding('attr.aria-selected') + public override get selected(): boolean { + let isSelected = false; + for (const rowDim of this.data.dimensions) { + const key = PivotUtil.getRecordKey(this.data, rowDim); + if (this.selectionService.isPivotRowSelected(key)) { + isSelected = true; + } + } + return isSelected; + } + + /** + * @hidden + * @internal + */ + public override get viewIndex(): number { + return this.index; + } + + /** + * @hidden + * @internal + */ + public override disabled = false; + + /** + * @hidden + * @internal + */ + public override get addRowUI(): any { + return false; + } + + /** + * @hidden + * @internal + */ + public override get inEditMode(): boolean { + return false; + } + + /** + * @hidden + * @internal + */ + public override set pinned(_value: boolean) { + } + + public override get pinned(): boolean { + return false; + } + + /** + * @hidden + * @internal + */ + public override delete() { + } + + /** + * @hidden + * @internal + */ + public override beginAddRow() { + } + + /** + * @hidden + * @internal + */ + public override update(_value: any) { + } + + /** + * @hidden + * @internal + */ + public override pin() { + return false; + } + + /** + * @hidden + * @internal + */ + public override unpin() { + return false; + } + + /** + * The pivot record data passed to the row component. + * + * ```typescript + * // get the pivot row data for the first selected row + * let selectedRowData = this.grid.selectedRows[0].data; + * ``` + */ + @Input() + public override get data(): IPivotGridRecord { + return this._data; + } + + public override set data(v: IPivotGridRecord) { + this._data = v; + } + + /** + * @hidden + * @internal + */ + public get pivotAggregationData() { + const aggregations = this.data.aggregationValues; + const obj = {}; + aggregations.forEach((value, key) => { + obj[key] = value; + }); + return obj; + } + + public getCellClass(col: IgxColumnComponent) { + const values = this.grid.values; + if (values.length === 1) { + return values[0].styles; + } + const colName = col.field.split(this.grid.pivotKeys.columnDimensionSeparator); + const measureName = colName[colName.length - 1]; + return values.find(v => v.member === measureName)?.styles; + } + + public override isCellActive(visibleColumnIndex) { + const nav = this.grid.navigation + const node = nav.activeNode; + return node && Object.keys(node).length !== 0 ? + !nav.isRowHeaderActive && + !nav.isRowDimensionHeaderActive && + super.isCellActive(visibleColumnIndex) : + false; + } + + public getColumnData(col: IgxColumnComponent) : IPivotGridColumn { + const path = col.field.split(this.grid.pivotKeys.columnDimensionSeparator); + const keyValueMap = new Map(); + const colDimensions = PivotUtil.flatten(this.grid.columnDimensions); + for (const dim of colDimensions) { + keyValueMap.set(dim.memberName, path.shift()); + } + let pivotValue; + if (this.grid.hasMultipleValues && path.length) { + pivotValue = this.grid.values.find(x => x.member === path[0]); + } else { + pivotValue = this.grid.values ? this.grid.values[0] : undefined; + } + return { + field: col.field, + dimensions: this.grid.columnDimensions, + dimensionValues: keyValueMap, + value: pivotValue + }; + } +} diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-sort-strategy.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-sort-strategy.ts new file mode 100644 index 00000000000..d5c68b5f1c2 --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-sort-strategy.ts @@ -0,0 +1,60 @@ +import { parseDate } from 'igniteui-angular/core'; +import { GridColumnDataType } from 'igniteui-angular/core'; +import { DefaultSortingStrategy, SortingDirection } from 'igniteui-angular/core'; +import { IPivotGridRecord, PivotGridType, PivotUtil } from 'igniteui-angular/grids/core'; + +export class DefaultPivotGridRecordSortingStrategy extends DefaultSortingStrategy { + protected static override _instance: DefaultPivotGridRecordSortingStrategy = null; + public static override instance(): DefaultPivotGridRecordSortingStrategy { + return this._instance || (this._instance = new this()); + } + public override sort(data: any[], + fieldName: string, + dir: SortingDirection, + ignoreCase: boolean, + valueResolver: (obj: any, key: string, isDate?: boolean) => any, + isDate?: boolean, + isTime?: boolean, + _grid?: PivotGridType) { + return super.sort(data, fieldName, dir, ignoreCase, this.getFieldValue, isDate, isTime); + } + + protected getFieldValue(obj: IPivotGridRecord, key: string, _isDate = false, _isTime = false): any { + return obj.aggregationValues.get(key); + } +} + + +export class DefaultPivotSortingStrategy extends DefaultSortingStrategy { + protected static override _instance: DefaultPivotSortingStrategy = null; + protected dimension; + public static override instance(): DefaultPivotSortingStrategy { + return this._instance || (this._instance = new this()); + } + public override sort(data: any[], + fieldName: string, + dir: SortingDirection, + ignoreCase: boolean, + valueResolver: (obj: any, key: string, isDate?: boolean) => any, + isDate?: boolean, + isTime?: boolean, + grid?: PivotGridType) { + const key = fieldName; + const allDimensions = grid.allDimensions; + const enabledDimensions = allDimensions.filter(x => x && x.enabled); + this.dimension = PivotUtil.flatten(enabledDimensions).find(x => x.memberName === key); + return super.sort(data, key, dir, ignoreCase, this.getFieldValue, isDate, isTime); + } + + protected getFieldValue(obj: any, key: string, _isDate = false, isTime = false): any { + let resolvedValue = PivotUtil.extractValueFromDimension(this.dimension, obj) || obj[0]; + const formatAsDate = this.dimension.dataType === GridColumnDataType.Date || this.dimension.dataType === GridColumnDataType.DateTime; + if (formatAsDate) { + const date = parseDate(resolvedValue); + resolvedValue = isTime && date ? + new Date().setHours(date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds()) : date; + + } + return resolvedValue; + } +} diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-sort-util.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-sort-util.ts new file mode 100644 index 00000000000..bc13e964562 --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-sort-util.ts @@ -0,0 +1,25 @@ +import { ISortingExpression, SortingDirection } from 'igniteui-angular/core'; +import { IPivotDimension, PivotUtil } from 'igniteui-angular/grids/core'; +import { DefaultPivotSortingStrategy } from './pivot-sort-strategy'; + +export class PivotSortUtil { + public static generateDimensionSortingExpressions(dimensions: IPivotDimension[]): ISortingExpression[] { + const expressions: ISortingExpression[] = []; + PivotUtil.flatten(dimensions).forEach(x => { + if (x.sortDirection) { + expressions.push({ + dir: x.sortDirection, + fieldName: x.memberName, + strategy: DefaultPivotSortingStrategy.instance() + }); + } else { + expressions.push({ + dir: SortingDirection.None, + fieldName: x.memberName, + strategy: DefaultPivotSortingStrategy.instance() + }); + } + }); + return expressions; + } +} diff --git a/projects/igniteui-angular/grids/pivot-grid/src/public_api.ts b/projects/igniteui-angular/grids/pivot-grid/src/public_api.ts new file mode 100644 index 00000000000..e92957892cb --- /dev/null +++ b/projects/igniteui-angular/grids/pivot-grid/src/public_api.ts @@ -0,0 +1,152 @@ +import { IgxPivotDataSelectorComponent } from './pivot-data-selector.component'; +import { IgxPivotGridComponent } from './pivot-grid.component'; +import { IgxPivotValueChipTemplateDirective } from './pivot-grid.directives'; + +export * from './pivot-grid.component'; +export * from './pivot-data-selector.component'; +export * from './pivot-grid.directives'; +export * from './pivot-sort-strategy'; + +/* Imports that cannot be resolved from IGX_GRID_COMMON_DIRECTIVES spread + NOTE: Do not remove! Issue: https://github.com/IgniteUI/igniteui-angular/issues/13310 +*/ + +import { + IgxRowDirective, + IgxGridFooterComponent, + IgxAdvancedFilteringDialogComponent, + IgxHeaderCollapsedIndicatorDirective, + IgxHeaderExpandedIndicatorDirective, + IgxRowCollapsedIndicatorDirective, + IgxRowExpandedIndicatorDirective, + IgxSortAscendingHeaderIconDirective, + IgxSortDescendingHeaderIconDirective, + IgxSortHeaderIconDirective, + IgxExcelStyleHeaderIconDirective, + IgxDragIndicatorIconDirective, + IgxRowDragGhostDirective, + IgxGridStateDirective, + IgxGridHeaderComponent, + IgxGridHeaderGroupComponent, + IgxGridHeaderRowComponent, + IgxFilterCellTemplateDirective, + IgxSummaryTemplateDirective, + IgxCellTemplateDirective, + IgxCellValidationErrorDirective, + IgxCellHeaderTemplateDirective, + IgxCellFooterTemplateDirective, + IgxCellEditorTemplateDirective, + IgxCollapsibleIndicatorTemplateDirective, + IgxColumnComponent, + IgxColumnGroupComponent, + IgxColumnLayoutComponent, + IgxColumnActionsComponent, + IgxColumnHidingDirective, + IgxColumnPinningDirective, + IgxRowSelectorDirective, + IgxGroupByRowSelectorDirective, + IgxHeadSelectorDirective, + IgxCSVTextDirective, + IgxExcelTextDirective, + IgxGridToolbarActionsComponent, + IgxGridToolbarAdvancedFilteringComponent, + IgxGridToolbarComponent, + IgxGridToolbarExporterComponent, + IgxGridToolbarHidingComponent, + IgxGridToolbarPinningComponent, + IgxGridToolbarTitleComponent, + IgxGridToolbarDirective, + IgxGridExcelStyleFilteringComponent, + IgxExcelStyleHeaderComponent, + IgxExcelStyleSortingComponent, + IgxExcelStylePinningComponent, + IgxExcelStyleHidingComponent, + IgxExcelStyleSelectingComponent, + IgxExcelStyleClearFiltersComponent, + IgxExcelStyleConditionalFilterComponent, + IgxExcelStyleMovingComponent, + IgxExcelStyleSearchComponent, + IgxExcelStyleColumnOperationsTemplateDirective, + IgxExcelStyleFilterOperationsTemplateDirective, + IgxExcelStyleLoadingValuesTemplateDirective, + IgxGridActionButtonComponent, + IgxGridActionsBaseDirective, + IgxGridEditingActionsComponent, + IgxGridPinningActionsComponent +} from "igniteui-angular/grids/core"; + +/* NOTE: Pivot grid directives collection for ease-of-use import in standalone components scenario */ +export const IGX_PIVOT_GRID_DIRECTIVES = [ + IgxPivotGridComponent, + IgxPivotDataSelectorComponent, + IgxPivotValueChipTemplateDirective, + // IGX_GRID_COMMON_DIRECTIVES: + IgxRowDirective, + IgxGridFooterComponent, + IgxAdvancedFilteringDialogComponent, + IgxRowExpandedIndicatorDirective, + IgxRowCollapsedIndicatorDirective, + IgxHeaderExpandedIndicatorDirective, + IgxHeaderCollapsedIndicatorDirective, + IgxExcelStyleHeaderIconDirective, + IgxSortAscendingHeaderIconDirective, + IgxSortDescendingHeaderIconDirective, + IgxSortHeaderIconDirective, + IgxDragIndicatorIconDirective, + IgxRowDragGhostDirective, + IgxGridStateDirective, + // IGX_GRID_ACTIONS + IgxGridPinningActionsComponent, + IgxGridEditingActionsComponent, + IgxGridActionsBaseDirective, + IgxGridActionButtonComponent, + // IGX_GRID_HEADERS_DIRECTIVES: + IgxGridHeaderComponent, + IgxGridHeaderGroupComponent, + IgxGridHeaderRowComponent, + // IGX_GRID_COLUMN_DIRECTIVES: + IgxFilterCellTemplateDirective, + IgxSummaryTemplateDirective, + IgxCellTemplateDirective, + IgxCellValidationErrorDirective, + IgxCellHeaderTemplateDirective, + IgxCellFooterTemplateDirective, + IgxCellEditorTemplateDirective, + IgxCollapsibleIndicatorTemplateDirective, + IgxColumnComponent, + IgxColumnGroupComponent, + IgxColumnLayoutComponent, + // IGX_GRID_COLUMN_ACTIONS_DIRECTIVES: + IgxColumnActionsComponent, + IgxColumnHidingDirective, + IgxColumnPinningDirective, + // IGX_GRID_SELECTION_DIRECTIVES: + IgxRowSelectorDirective, + IgxGroupByRowSelectorDirective, + IgxHeadSelectorDirective, + // IGX_GRID_TOOLBAR_DIRECTIVES: + IgxCSVTextDirective, + IgxExcelTextDirective, + IgxGridToolbarActionsComponent, + IgxGridToolbarAdvancedFilteringComponent, + IgxGridToolbarComponent, + IgxGridToolbarExporterComponent, + IgxGridToolbarHidingComponent, + IgxGridToolbarPinningComponent, + IgxGridToolbarTitleComponent, + IgxGridToolbarDirective, + // IGX_GRID_EXCEL_STYLE_FILTER_DIRECTIVES: + IgxGridExcelStyleFilteringComponent, + IgxExcelStyleHeaderComponent, + IgxExcelStyleSortingComponent, + IgxExcelStylePinningComponent, + IgxExcelStyleHidingComponent, + IgxExcelStyleSelectingComponent, + IgxExcelStyleClearFiltersComponent, + IgxExcelStyleConditionalFilterComponent, + IgxExcelStyleMovingComponent, + IgxExcelStyleSearchComponent, + IgxExcelStyleColumnOperationsTemplateDirective, + IgxExcelStyleFilterOperationsTemplateDirective, + IgxExcelStyleLoadingValuesTemplateDirective +] as const; diff --git a/projects/igniteui-angular/grids/tree-grid/index.ts b/projects/igniteui-angular/grids/tree-grid/index.ts new file mode 100644 index 00000000000..6766dc96fd2 --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/index.ts @@ -0,0 +1,9 @@ +/** + * IgxTreeGrid - Tree grid component for hierarchical data + * + * Import tree-grid-specific components and re-export core grid functionality + */ + +// Export tree-grid-specific components +export * from './src/public_api'; +export * from './src/tree-grid.module'; diff --git a/projects/igniteui-angular/grids/tree-grid/ng-package.json b/projects/igniteui-angular/grids/tree-grid/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/grids/tree-grid/src/README.md b/projects/igniteui-angular/grids/tree-grid/src/README.md new file mode 100644 index 00000000000..9be91a85b53 --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/README.md @@ -0,0 +1,163 @@ +# igx-tree-grid +**igx-tree-grid** component provides the capability to represent and manipulate hierarchical data with consistent schema, formatted as a table. +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/treegrid.html). + +## Usage +```html + + +``` + +## Getting Started + +### Dependencies +The tree grid is exported as an `NgModule`, thus all you need to do in your application is to import the _IgxTreeGridModule_ inside your `AppModule`. + +```typescript +// app.module.ts + +import { IgxTreeGridModule } from 'igniteui-angular'; + +@NgModule({ + imports: [ + ... + IgxTreeGridModule, + ... + ] +}) +export class AppModule {} +``` + +We can obtain a reference to the tree grid in typescript as follows: + +```typescript +@ViewChild('treegrid1', { read: IgxTreeGridComponent }) +public treegrid1: IgxTreeGridComponent; +``` + +### Basic configuration + +The `IgxTreeGridComponent` shares a lot of features with the `IgxGridComponent`, but it also adds the ability to display its data hierarchically. In order to achieve this, the `IgxTreeGridComponent` provides us with a couple of ways to define the relations among our data objects - by using a **child collection** for every data object or by using **primary and foreign keys** for every data object. + +#### Using child collection + +When we are using the child collection option, every data object contains a child collection, that is populated with items of the same type as the parent data object. + + +```typescript +export const EMPLOYEE_DATA = [ + { + Name: "Johnathan Winchester", + ID: 1, + HireDate: new Date(2008, 3, 20), + Age: 55, + Employees: [ + { + Name: "Michael Burke", + ID: 3, + HireDate: new Date(2011, 6, 3), + Age: 43, + Employees: [] + }, + { + Name: "Thomas Anderson" + ID: 2, + HireDate: new Date(2009, 6, 19), + Age: 29, + Employees: [] + }, + ... + ] + }, + ... +] +``` + +In order for the `IgxTreeGridComponent` to build the hierarchy, we will have to set its `childDataKey` property to the name of the child collection that is used in each of our data objects. + +```html + + + + + + +``` +#### Using primary and foreign keys + +When we are using the primary and foreign keys option, every data object contains a primary key and a foreign key. The **primary key** is the unique identifier of the current data object and the **foreign key** is the unique identifier of its parent. In this case the data property of our tree grid that contains the original data source will be a flat collection. + +```typescript +export const data = [ + { ID: 1, ParentID: -1, Name: "Casey Houston", JobTitle: "Vice President", Age: 32 }, + { ID: 2, ParentID: 1, Name: "Gilberto Todd", JobTitle: "Director", Age: 41 }, + { ID: 3, ParentID: 2, Name: "Tanya Bennett", JobTitle: "Director", Age: 29 }, + { ID: 4, ParentID: 2, Name: "Jack Simon", JobTitle: "Software Developer", Age: 33 }, + { ID: 5, ParentID: 8, Name: "Celia Martinez", JobTitle: "Senior Software Developer", Age: 44 }, + { ID: 6, ParentID: -1, Name: "Erma Walsh", JobTitle: "CEO", Age: 52 }, + { ID: 7, ParentID: 2, Name: "Debra Morton", JobTitle: "Associate Software Developer", Age: 35 }, + { ID: 8, ParentID: 10, Name: "Erika Wells", JobTitle: "Software Development Team Lead", Age: 50 }, + { ID: 9, ParentID: 8, Name: "Leslie Hansen", JobTitle: "Associate Software Developer", Age: 28 }, + { ID: 10, ParentID: -1, Name: "Eduardo Ramirez", JobTitle: "Development Manager", Age: 53 } +]; +``` + +In order for the `IgxTreeGridComponent` to build the hierarchy, we will have to set its `primaryKey` and `foreignKey` properties to the respective names of the data object properties. If a row has a ParentID that does not match any row in the tree grid, then that means this row is a root row. + +```html + + + + + + + +``` + +### CRUD operations + +- Adding a new row can be done by using the `addRow` method of the tree grid. The method takes a second **optional** parameter - parentRowID. If a parentRowID is not specified, the newly created row would be added at the root level, otherwise it would be added as a child of the row whose primaryKey matches the specified parentRowID. + +```typescript +const record = { + ID: this.treegrid1.data[this.treegrid1.data.length - 1].ID + 1, + Name: this.newRecord +}; +this.treegrid1.addRow(record, 1); // Adds a new child row to the row with ID=1. +``` + +- Updating an existing row or cell is done the same way as it is in the `igx-grid`. + +```typescript +const selectedCell = this.treegrid1.selectedCells[0]; +this.treegrid1.updateCell('new value', selectedCell.rowIndex, selectedCell.column.field); + +const record = { + ID: 123, + Name: 'New Name', + ... +}; +this.treegrid1.updateRow(record, 123); +``` + +- Deleting an existing row can be done by using the `deleteRow` method, which takes as an argument the primary key of the row that should be deleted. +The `deleteRow` method takes the `cascadeOnDelete` property of the tree grid into account. This property indicates whether the child records should be deleted when their parent gets deleted (by default, it is set to **true**). + +```typescript +const rowForDel = this.treegrid1.selectedCells[0].row; +this.treegrid1.deleteRow(rowForDel.key); +``` + +**NOTE:** The `cascadeOnDelete` property is taken into account only if our tree grid is defined with **primary and foreign keys**. If **child collection** is used instead, then child records will always be deleted when their respective parent is deleted. + +### Known Limitations + +|Limitation|Description| +|--- |--- | +|Templating Tree Cells|When templating a tree cell, content that spans outside the boundaries of the cell will not be shown unless positioned in an overlay.| +|Group By|Group By feature is not supported, because it is inherent to the tree grid.| diff --git a/projects/igniteui-angular/grids/tree-grid/src/public_api.ts b/projects/igniteui-angular/grids/tree-grid/src/public_api.ts new file mode 100644 index 00000000000..5a04376acea --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/public_api.ts @@ -0,0 +1,188 @@ +import { IgxTreeGridComponent } from './tree-grid.component'; +import { IgxTreeGridGroupByAreaComponent } from './tree-grid-group-by-area.component'; +import { IgxTreeGridGroupingPipe } from './tree-grid.grouping.pipe'; + +export * from './tree-grid.component'; +export * from './tree-grid.grouping.pipe'; +export * from './tree-grid-group-by-area.component'; +export * from './tree-grid.grouping.pipe'; + +/* Imports that cannot be resolved from IGX_GRID_COMMON_DIRECTIVES spread + NOTE: Do not remove! Issue: https://github.com/IgniteUI/igniteui-angular/issues/13310 +*/ + +import { + IgxRowDirective, + IgxRowEditTextDirective, + IgxRowAddTextDirective, + IgxRowEditActionsDirective, + IgxRowEditTabStopDirective, + IgxGridFooterComponent, + IgxAdvancedFilteringDialogComponent, + IgxHeaderCollapsedIndicatorDirective, + IgxHeaderExpandedIndicatorDirective, + IgxRowCollapsedIndicatorDirective, + IgxRowExpandedIndicatorDirective, + IgxSortAscendingHeaderIconDirective, + IgxSortDescendingHeaderIconDirective, + IgxSortHeaderIconDirective, + IgxExcelStyleHeaderIconDirective, + IgxDragIndicatorIconDirective, + IgxRowDragGhostDirective, + IgxGridStateDirective, + IgxGridHeaderComponent, + IgxGridHeaderGroupComponent, + IgxGridHeaderRowComponent, + IgxFilterCellTemplateDirective, + IgxSummaryTemplateDirective, + IgxCellTemplateDirective, + IgxCellValidationErrorDirective, + IgxCellHeaderTemplateDirective, + IgxCellFooterTemplateDirective, + IgxCellEditorTemplateDirective, + IgxCollapsibleIndicatorTemplateDirective, + IgxColumnComponent, + IgxColumnGroupComponent, + IgxColumnLayoutComponent, + IgxColumnRequiredValidatorDirective, + IgxColumnMinValidatorDirective, + IgxColumnMaxValidatorDirective, + IgxColumnEmailValidatorDirective, + IgxColumnMinLengthValidatorDirective, + IgxColumnMaxLengthValidatorDirective, + IgxColumnPatternValidatorDirective, + IgxColumnActionsComponent, + IgxColumnHidingDirective, + IgxColumnPinningDirective, + IgxRowSelectorDirective, + IgxGroupByRowSelectorDirective, + IgxHeadSelectorDirective, + IgxCSVTextDirective, + IgxExcelTextDirective, + IgxGridToolbarActionsComponent, + IgxGridToolbarAdvancedFilteringComponent, + IgxGridToolbarComponent, + IgxGridToolbarExporterComponent, + IgxGridToolbarHidingComponent, + IgxGridToolbarPinningComponent, + IgxGridToolbarTitleComponent, + IgxGridToolbarDirective, + IgxGridExcelStyleFilteringComponent, + IgxExcelStyleHeaderComponent, + IgxExcelStyleSortingComponent, + IgxExcelStylePinningComponent, + IgxExcelStyleHidingComponent, + IgxExcelStyleSelectingComponent, + IgxExcelStyleClearFiltersComponent, + IgxExcelStyleConditionalFilterComponent, + IgxExcelStyleMovingComponent, + IgxExcelStyleSearchComponent, + IgxExcelStyleColumnOperationsTemplateDirective, + IgxExcelStyleFilterOperationsTemplateDirective, + IgxExcelStyleLoadingValuesTemplateDirective, + IgxGridActionButtonComponent, + IgxGridActionsBaseDirective, + IgxGridEditingActionsComponent, + IgxGridPinningActionsComponent +} from "igniteui-angular/grids/core"; +import { + IgxPaginatorComponent, + IgxPageNavigationComponent, + IgxPageSizeSelectorComponent, + IgxPaginatorContentDirective, + IgxPaginatorDirective +} from 'igniteui-angular/paginator'; + +/* NOTE: Tree grid directives collection for ease-of-use import in standalone components scenario */ +export const IGX_TREE_GRID_DIRECTIVES = [ + IgxTreeGridComponent, + IgxTreeGridGroupByAreaComponent, + IgxTreeGridGroupingPipe, + IgxRowAddTextDirective, + IgxRowEditActionsDirective, + IgxRowEditTextDirective, + IgxRowEditTabStopDirective, + // IGX_GRID_COMMON_DIRECTIVES: + IgxRowDirective, + IgxGridFooterComponent, + IgxAdvancedFilteringDialogComponent, + IgxRowExpandedIndicatorDirective, + IgxRowCollapsedIndicatorDirective, + IgxHeaderExpandedIndicatorDirective, + IgxHeaderCollapsedIndicatorDirective, + IgxExcelStyleHeaderIconDirective, + IgxSortAscendingHeaderIconDirective, + IgxSortDescendingHeaderIconDirective, + IgxSortHeaderIconDirective, + IgxDragIndicatorIconDirective, + IgxRowDragGhostDirective, + IgxGridStateDirective, + // IGX_GRID_ACTIONS + IgxGridPinningActionsComponent, + IgxGridEditingActionsComponent, + IgxGridActionsBaseDirective, + IgxGridActionButtonComponent, + // IGX_GRID_HEADERS_DIRECTIVES: + IgxGridHeaderComponent, + IgxGridHeaderGroupComponent, + IgxGridHeaderRowComponent, + // IGX_GRID_COLUMN_DIRECTIVES: + IgxFilterCellTemplateDirective, + IgxSummaryTemplateDirective, + IgxCellTemplateDirective, + IgxCellValidationErrorDirective, + IgxCellHeaderTemplateDirective, + IgxCellFooterTemplateDirective, + IgxCellEditorTemplateDirective, + IgxCollapsibleIndicatorTemplateDirective, + IgxColumnComponent, + IgxColumnGroupComponent, + IgxColumnLayoutComponent, + // IGX_GRID_COLUMN_ACTIONS_DIRECTIVES: + IgxColumnActionsComponent, + IgxColumnHidingDirective, + IgxColumnPinningDirective, + // IGX_GRID_SELECTION_DIRECTIVES: + IgxRowSelectorDirective, + IgxGroupByRowSelectorDirective, + IgxHeadSelectorDirective, + // IGX_GRID_TOOLBAR_DIRECTIVES: + IgxCSVTextDirective, + IgxExcelTextDirective, + IgxGridToolbarActionsComponent, + IgxGridToolbarAdvancedFilteringComponent, + IgxGridToolbarComponent, + IgxGridToolbarExporterComponent, + IgxGridToolbarHidingComponent, + IgxGridToolbarPinningComponent, + IgxGridToolbarTitleComponent, + IgxGridToolbarDirective, + // IGX_GRID_EXCEL_STYLE_FILTER_DIRECTIVES: + IgxGridExcelStyleFilteringComponent, + IgxExcelStyleHeaderComponent, + IgxExcelStyleSortingComponent, + IgxExcelStylePinningComponent, + IgxExcelStyleHidingComponent, + IgxExcelStyleSelectingComponent, + IgxExcelStyleClearFiltersComponent, + IgxExcelStyleConditionalFilterComponent, + IgxExcelStyleMovingComponent, + IgxExcelStyleSearchComponent, + IgxExcelStyleColumnOperationsTemplateDirective, + IgxExcelStyleFilterOperationsTemplateDirective, + IgxExcelStyleLoadingValuesTemplateDirective, + // IGX_PAGINATOR_DIRECTIVES: + IgxColumnRequiredValidatorDirective, + IgxColumnMinValidatorDirective, + IgxColumnMaxValidatorDirective, + IgxColumnEmailValidatorDirective, + IgxColumnMinLengthValidatorDirective, + IgxColumnMaxLengthValidatorDirective, + IgxColumnPatternValidatorDirective, + // IGX_PAGINATOR_DIRECTIVES: + IgxPaginatorComponent, + IgxPageNavigationComponent, + IgxPageSizeSelectorComponent, + IgxPaginatorContentDirective, + IgxPaginatorDirective +] as const; diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-cell.component.html b/projects/igniteui-angular/grids/tree-grid/src/tree-cell.component.html new file mode 100644 index 00000000000..5a38877025d --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-cell.component.html @@ -0,0 +1,311 @@ + + @if (displayPinnedChip) { + {{ grid.resourceStrings.igx_grid_pinned_row_indicator }} + } + + + @if (column.dataType !== 'boolean' && column.dataType !== 'image' || (column.dataType === 'boolean' && this.formatter)) { +
    {{ + formatter + ? (value | columnFormatter:formatter:rowData) + : column.dataType === "number" + ? (value | number:column.pipeArgs.digitsInfo:grid.locale) + : (column.dataType === 'date' || column.dataType === 'time' || column.dataType === 'dateTime') + ? (value | date:column.pipeArgs.format:column.pipeArgs.timezone:grid.locale) + : column.dataType === 'currency' + ? (value | currency:currencyCode:column.pipeArgs.display:column.pipeArgs.digitsInfo:grid.locale) + : column.dataType === 'percent' + ? (value | percent:column.pipeArgs.digitsInfo:grid.locale) + : value + }}
    + } + @if (column.dataType === 'boolean' && !this.formatter) { + + + } + @if (column.dataType === 'image') { + + } +
    + + @if (column.dataType !== 'boolean' || (column.dataType === 'boolean' && this.formatter)) { +
    {{ + !isEmptyAddRowCell ? value : (column.header || column.field) + }}
    + } +
    + + @if (column.dataType === 'string' || column.dataType === 'image') { + + + + + + } + @if (column.dataType === 'number') { + + + + } + @if (column.dataType === 'boolean') { + + + + } + @if (column.dataType === 'date') { + + + + + } + @if (column.dataType === 'time') { + + + + } + @if (column.dataType === 'dateTime') { + + + + } + @if (column.dataType === 'currency') { + + @if (grid.currencyPositionLeft) { + {{ currencyCodeSymbol }} + } + + @if (!grid.currencyPositionLeft) { + {{ currencyCodeSymbol }} + } + + } + @if (column.dataType === 'percent') { + + + {{ editValue | percent:column.pipeArgs.digitsInfo:grid.locale }} + + } + +@if (!editMode) { + @if (level > 0) { +
    + } + @if (!isLoading) { +
    + + + + +
    + } + @if (isLoading) { +
    + + +
    + } + + + +} + +@if (isInvalid) { + + +
    +
    + +
    +
    +} + + + + + + + + + @if (formGroup?.get(column?.field).errors?.['required']) { +
    + {{grid.resourceStrings.igx_grid_required_validation_error}} +
    + } + @if (formGroup?.get(column?.field).errors?.['minlength']) { +
    + {{grid.resourceStrings.igx_grid_min_length_validation_error | igxStringReplace:'{0}':formGroup.get(column.field).errors.minlength.requiredLength }} +
    + } + @if (formGroup?.get(column?.field).errors?.['maxlength']) { +
    + {{grid.resourceStrings.igx_grid_max_length_validation_error | igxStringReplace:'{0}':formGroup.get(column.field).errors.maxlength.requiredLength }} +
    + } + @if (formGroup?.get(column?.field).errors?.['min']) { +
    + {{grid.resourceStrings.igx_grid_min_validation_error | igxStringReplace:'{0}':formGroup.get(column.field).errors.min.min }} +
    + } + @if (formGroup?.get(column?.field).errors?.['max']) { +
    + {{grid.resourceStrings.igx_grid_max_validation_error | igxStringReplace:'{0}':formGroup.get(column.field).errors.max.max }} +
    + } + @if (formGroup?.get(column?.field).errors?.['email']) { +
    + {{grid.resourceStrings.igx_grid_email_validation_error }} +
    + } + @if (formGroup?.get(column?.field).errors?.['pattern']) { +
    + {{grid.resourceStrings.igx_grid_pattern_validation_error}} +
    + } +
    diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-cell.component.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-cell.component.ts new file mode 100644 index 00000000000..41d28114829 --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-cell.component.ts @@ -0,0 +1,106 @@ +import { + ChangeDetectionStrategy, + Component, + Input +} from '@angular/core'; +import { NgClass, NgStyle, NgTemplateOutlet, DecimalPipe, PercentPipe, CurrencyPipe, DatePipe } from '@angular/common'; + +import { IgxTreeGridRow } from 'igniteui-angular/grids/core'; +import { RowType } from 'igniteui-angular/grids/core'; +import { IgxGridCellImageAltPipe, IgxStringReplacePipe, IgxColumnFormatterPipe } from 'igniteui-angular/grids/core'; +import { ReactiveFormsModule } from '@angular/forms'; +import { HammerGesturesManager } from 'igniteui-angular/core'; +import { IgxChipComponent } from 'igniteui-angular/chips'; +import { IgxDateTimeEditorDirective, IgxFocusDirective, IgxTextHighlightDirective, IgxTextSelectionDirective, IgxTooltipDirective, IgxTooltipTargetDirective } from 'igniteui-angular/directives'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxInputDirective, IgxInputGroupComponent, IgxPrefixDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; +import { IgxCheckboxComponent } from 'igniteui-angular/checkbox'; +import { IgxDatePickerComponent } from 'igniteui-angular/date-picker'; +import { IgxTimePickerComponent } from 'igniteui-angular/time-picker'; +import { IgxCircularProgressBarComponent } from 'igniteui-angular/progressbar'; +import { IgxGridExpandableCellComponent } from 'igniteui-angular/grids/grid'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'igx-tree-grid-cell', + templateUrl: 'tree-cell.component.html', + providers: [HammerGesturesManager], + imports: [ + NgClass, + NgStyle, + NgTemplateOutlet, + DecimalPipe, + PercentPipe, + CurrencyPipe, + DatePipe, + IgxChipComponent, + IgxTextHighlightDirective, + IgxIconComponent, + ReactiveFormsModule, + IgxInputGroupComponent, + IgxInputDirective, + IgxFocusDirective, + IgxCheckboxComponent, + IgxDatePickerComponent, + IgxTimePickerComponent, + IgxDateTimeEditorDirective, + IgxPrefixDirective, + IgxSuffixDirective, + IgxCircularProgressBarComponent, + IgxTooltipTargetDirective, + IgxTooltipDirective, + IgxGridCellImageAltPipe, + IgxStringReplacePipe, + IgxColumnFormatterPipe, + IgxTextSelectionDirective + ] +}) +export class IgxTreeGridCellComponent extends IgxGridExpandableCellComponent { + + /** + * @hidden + */ + @Input() + public level = 0; + + /** + * @hidden + */ + @Input() + public showIndicator = false; + + /** + * @hidden + */ + @Input() + public isLoading: boolean; + + /** + * Gets the row of the cell. + * ```typescript + * let cellRow = this.cell.row; + * ``` + * + * @memberof IgxGridCellComponent + */ + @Input() + public override get row(): RowType { + // TODO: Fix types + return new IgxTreeGridRow(this.grid as any, this.intRow.index, this.intRow.data); + } + + /** + * @hidden + */ + public override toggle(event: Event) { + event.stopPropagation(); + this.grid.gridAPI.set_row_expansion_state(this.intRow.key, !this.intRow.expanded, event); + } + + /** + * @hidden + */ + public onLoadingDblClick(event: Event) { + event.stopPropagation(); + } +} diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-add-row-ui.spec.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-add-row-ui.spec.ts new file mode 100644 index 00000000000..17e30376260 --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-add-row-ui.spec.ts @@ -0,0 +1,261 @@ + +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { IgxTreeGridComponent } from './public_api'; +import { IgxTreeGridEditActionsComponent, IgxTreeGridEditActionsPinningComponent } from '../../../test-utils/tree-grid-components.spec'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxActionStripComponent } from 'igniteui-angular/action-strip'; +import { IgxTreeGridRowComponent } from './tree-grid-row.component'; +import { first } from 'rxjs/operators'; +import { wait } from '../../../test-utils/ui-interactions.spec'; +import { IRowDataCancelableEventArgs } from 'igniteui-angular/grids/core'; + +describe('IgxTreeGrid - Add Row UI #tGrid', () => { + let fix; + let treeGrid: IgxTreeGridComponent; + let actionStrip: IgxActionStripComponent; + const endTransition = () => { + // transition end needs to be simulated + const animationElem = fix.nativeElement.querySelector('.igx-grid__tr--inner'); + const endEvent = new AnimationEvent('animationend'); + animationElem.dispatchEvent(endEvent); + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxTreeGridEditActionsComponent, + IgxTreeGridEditActionsPinningComponent + ] + }).compileComponents(); + })); + + describe('Basic', () => { + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridEditActionsComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + actionStrip = fix.componentInstance.actionStrip; + }); + + it('should show action strip "add row" button only for root level rows.', () => { + actionStrip.show(treeGrid.rowList.first); + fix.detectChanges(); + + const editActions = fix.debugElement.queryAll(By.css(`igx-grid-action-button`)); + expect(editActions.length).toBe(4); + + expect(editActions[1].componentInstance.iconName).toBe('add_row'); + expect(editActions[2].componentInstance.iconName).toBe('add_child'); + }); + + it('should show action strip "add child" button for all rows.', () => { + actionStrip.show(treeGrid.rowList.toArray()[1]); + fix.detectChanges(); + + const editActions = fix.debugElement.queryAll(By.css(`igx-grid-action-button`)); + expect(editActions.length).toBe(3); + expect(editActions[1].componentInstance.iconName).toBe('add_child'); + }); + + it('should allow adding child to row via the UI.', () => { + const row = treeGrid.rowList.toArray()[1]; + actionStrip.show(row); + fix.detectChanges(); + + expect(treeGrid.rowList.length).toBe(8); + + const editActions = fix.debugElement.queryAll(By.css(`igx-grid-action-button`)); + expect(editActions[1].componentInstance.iconName).toBe('add_child'); + const addChildBtn = editActions[1].componentInstance; + addChildBtn.actionClick.emit(); + fix.detectChanges(); + endTransition(); + + const addRow = treeGrid.gridAPI.get_row_by_index(2); + expect(addRow.addRowUI).toBeTrue(); + + treeGrid.gridAPI.crudService.endEdit(true); + fix.detectChanges(); + + expect(treeGrid.rowList.length).toBe(9); + const addedRow = treeGrid.getRowByIndex(4); + expect(addedRow.data.Name).toBe(undefined); + + }); + + it('should be able to enter add row mode through the exposed API method - beginAddChild', () => { + const row = treeGrid.rowList.toArray()[1] as IgxTreeGridRowComponent; + row.beginAddChild(); + fix.detectChanges(); + const addRow = treeGrid.gridAPI.get_row_by_index(2); + expect(addRow.addRowUI).toBeTrue(); + }); + + it('should allow adding sibling to child row via the API.', () => { + const row = treeGrid.rowList.toArray()[2] as IgxTreeGridRowComponent; + // adds row as sibling + row.beginAddRow(); + fix.detectChanges(); + endTransition(); + + treeGrid.gridAPI.crudService.endEdit(true); + fix.detectChanges(); + + // check row is added as sibling + expect(treeGrid.rowList.length).toBe(9); + const addedRow = treeGrid.rowList.toArray()[4] as IgxTreeGridRowComponent; + expect(addedRow.data.Name).toBe(undefined); + // should have same parent record. + expect(addedRow.treeRow.parent).toBe(row.treeRow.parent); + }); + + it('should allow adding row to empty grid', () => { + treeGrid.data = []; + fix.detectChanges(); + + expect(treeGrid.rowList.length).toBe(0); + + // begin add row for empty grid + // TODO how to start begin add row for empty grid? + treeGrid.crudService.enterAddRowMode(null); + fix.detectChanges(); + endTransition(); + + treeGrid.gridAPI.crudService.endEdit(true); + fix.detectChanges(); + + expect(treeGrid.rowList.length).toBe(1); + }); + + it('should add row on correct position and enter edit mode from pinned row - pinning position: Top', () => { + fix = TestBed.createComponent(IgxTreeGridEditActionsPinningComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + actionStrip = fix.componentInstance.actionStrip; + + treeGrid.pinRow(1); + treeGrid.pinRow(6); + + expect(treeGrid.getRowByKey(1).pinned).toBeTrue(); + expect(treeGrid.getRowByKey(6).pinned).toBeTrue(); + + actionStrip.show(treeGrid.rowList.toArray()[1]); + fix.detectChanges(); + + const editActions = fix.debugElement.queryAll(By.css(`igx-grid-action-button`)); + + expect(editActions[3].componentInstance.iconName).toBe('add_row'); + const addRowBtn = editActions[3].componentInstance; + addRowBtn.actionClick.emit(); + fix.detectChanges(); + endTransition(); + + const addRow = treeGrid.gridAPI.get_row_by_index(2); + expect(addRow.addRowUI).toBeTrue(); + expect(addRow.inEditMode).toBeTrue(); + + treeGrid.gridAPI.crudService.endEdit(true); + fix.detectChanges(); + }); + + it('should add row on correct position and enter edit mode from pinned row - pinning position: Bottom', () => { + fix = TestBed.createComponent(IgxTreeGridEditActionsPinningComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + actionStrip = fix.componentInstance.actionStrip; + + treeGrid.pinning = fix.componentInstance.pinningConfig; + fix.detectChanges(); + + treeGrid.pinRow(1); + treeGrid.pinRow(6); + + expect(treeGrid.getRowByKey(1).pinned).toBeTrue(); + expect(treeGrid.getRowByKey(6).pinned).toBeTrue(); + + actionStrip.show(treeGrid.rowList.last); + fix.detectChanges(); + + const editActions = fix.debugElement.queryAll(By.css(`igx-grid-action-button`)); + + expect(editActions[3].componentInstance.iconName).toBe('add_row'); + const addRowBtn = editActions[3].componentInstance; + addRowBtn.actionClick.emit(); + fix.detectChanges(); + endTransition(); + + const addRow = treeGrid.gridAPI.get_row_by_index(10); + expect(addRow.addRowUI).toBeTrue(); + expect(addRow.inEditMode).toBeTrue(); + + treeGrid.gridAPI.crudService.endEdit(true); + fix.detectChanges(); + }); + + it('should have correct foreignKey value for the data record in rowAdd event arguments', () => { + let newRowId = null; + treeGrid.rowAdd.pipe(first()).subscribe((args: IRowDataCancelableEventArgs) => { + expect(args.rowData[treeGrid.foreignKey]).toBe(2); + newRowId = args.rowData[treeGrid.primaryKey]; + }); + + treeGrid.beginAddRowById(2, true); + fix.detectChanges(); + endTransition(); + + const addRow = treeGrid.gridAPI.get_row_by_index(2); + expect(addRow.addRowUI).toBeTrue(); + + treeGrid.gridAPI.crudService.endEdit(true); + fix.detectChanges(); + + expect(treeGrid.rowList.length).toBe(9); + const addedRow = treeGrid.getRowByKey(newRowId); + expect(addedRow.data[treeGrid.foreignKey]).toBe(2); + }); + + it('should collapse row when child row adding begins and it added row should go under correct parent.', async() => { + treeGrid.data = [ + { ID: 1, ParentID: -1, Name: 'Casey Houston', JobTitle: 'Vice President', Age: 32 }, + { ID: 2, ParentID: 10, Name: 'Gilberto Todd', JobTitle: 'Director', Age: 41 }, + { ID: 3, ParentID: 10, Name: 'Tanya Bennett', JobTitle: 'Director', Age: 29 }, + { ID: 4, ParentID: 6, Name: 'Jack Simon', JobTitle: 'Software Developer', Age: 33 }, + { ID: 6, ParentID: -1, Name: 'Erma Walsh', JobTitle: 'CEO', Age: 52 }, + { ID: 7, ParentID: 10, Name: 'Debra Morton', JobTitle: 'Associate Software Developer', Age: 35 }, + { ID: 9, ParentID: 10, Name: 'Leslie Hansen', JobTitle: 'Associate Software Developer', Age: 44 }, + { ID: 10, ParentID: -1, Name: 'Eduardo Ramirez', JobTitle: 'Manager', Age: 53 } + ]; + fix.detectChanges(); + treeGrid.collapseAll(); + treeGrid.height = "350px"; + fix.detectChanges(); + const parentRow1 = treeGrid.rowList.toArray()[1] as IgxTreeGridRowComponent; + treeGrid.expandRow(parentRow1.key); + const parentRow2 = treeGrid.rowList.toArray()[3] as IgxTreeGridRowComponent; + treeGrid.expandRow(parentRow2.key); + treeGrid.triggerPipes(); + fix.detectChanges(); + + // scroll bottom + treeGrid.verticalScrollContainer.scrollTo(treeGrid.dataView.length - 1); + await wait(50); + fix.detectChanges(); + // start add row + parentRow2.beginAddChild(); + fix.detectChanges(); + // last row should be add row + const addRow = treeGrid.gridAPI.get_row_by_index(4); + expect(addRow.addRowUI).toBeTrue(); + endTransition(); + + // end edit + treeGrid.gridAPI.crudService.endEdit(true); + fix.detectChanges(); + + // row should be added under correct parent + expect(treeGrid.data[treeGrid.data.length - 1].ParentID).toBe(10); + }); + }); +}); diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-api.service.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-api.service.ts new file mode 100644 index 00000000000..a7d710f48c3 --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-api.service.ts @@ -0,0 +1,342 @@ +import { GridBaseAPIService } from 'igniteui-angular/grids/core'; +import { + HierarchicalTransaction, + TransactionType, + State, + IgxDataRecordSorting, + TreeGridFilteringStrategy, + cloneArray, + DataUtil, + FilterUtil, + GridColumnDataType, + IFilteringExpressionsTree, + ISortingExpression, + mergeObjects, + ColumnType, + ITreeGridRecord +} from 'igniteui-angular/core'; +import { Injectable } from '@angular/core'; +import { GridType } from 'igniteui-angular/grids/core'; + +@Injectable() +export class IgxTreeGridAPIService extends GridBaseAPIService { + + public override get_all_data(transactions?: boolean): any[] { + const grid = this.grid; + let data = grid && grid.flatData ? grid.flatData : []; + data = transactions ? grid.dataWithAddedInTransactionRows : data; + return data; + } + + public override get_summary_data(): any[] | null { + const grid = this.grid; + const data = grid.processedRootRecords?.filter(row => row.isFilteredOutParent === undefined || row.isFilteredOutParent === false) + .map(rec => rec.data); + if (data && grid.transactions.enabled) { + const deletedRows = grid.transactions.getTransactionLog().filter(t => t.type === TransactionType.DELETE).map(t => t.id); + deletedRows.forEach(rowID => { + const tempData = grid.primaryKey ? data.map(rec => rec[grid.primaryKey]) : data; + const index = tempData.indexOf(rowID); + if (index !== -1) { + data.splice(index, 1); + } + }); + } + return data; + } + + public override allow_expansion_state_change(rowID, expanded): boolean { + const grid = this.grid; + const row = grid.records.get(rowID); + if (row.expanded === expanded || + ((!row.children || !row.children.length) && (!grid.loadChildrenOnDemand || + (grid.hasChildrenKey && !row.data[grid.hasChildrenKey])))) { + return false; + } + return true; + } + + public expand_path_to_record(record: ITreeGridRecord) { + const grid = this.grid; + const expandedStates = grid.expansionStates; + + while (record.parent) { + record = record.parent; + const expanded = this.get_row_expansion_state(record); + + if (!expanded) { + expandedStates.set(record.key, true); + } + } + grid.expansionStates = expandedStates; + + if (grid.rowEditable) { + grid.gridAPI.crudService.endEdit(false); + } + } + + public override get_row_expansion_state(record: ITreeGridRecord): boolean { + const grid = this.grid; + const states = grid.expansionStates; + const expanded = states.get(record.key); + + if (expanded !== undefined) { + return expanded; + } else { + return record.children && record.children.length && record.level < grid.expansionDepth; + } + } + + public override should_apply_number_style(column: ColumnType): boolean { + return column.dataType === GridColumnDataType.Number && column.visibleIndex !== 0; + } + + public override deleteRowById(rowID: any): any { + const treeGrid = this.grid; + const flatDataWithCascadeOnDeleteAndTransactions = + treeGrid.primaryKey && + treeGrid.foreignKey && + treeGrid.cascadeOnDelete && + treeGrid.transactions.enabled; + + if (flatDataWithCascadeOnDeleteAndTransactions) { + treeGrid.transactions.startPending(); + } + + const record = super.deleteRowById(rowID); + + if (flatDataWithCascadeOnDeleteAndTransactions) { + treeGrid.transactions.endPending(true); + } + + return record; + } + + public override deleteRowFromData(rowID: any, index: number) { + const treeGrid = this.grid; + const record = treeGrid.records.get(rowID); + + if (treeGrid.primaryKey && treeGrid.foreignKey) { + index = treeGrid.primaryKey ? + treeGrid.data.map(c => c[treeGrid.primaryKey]).indexOf(rowID) : + treeGrid.data.indexOf(rowID); + super.deleteRowFromData(rowID, index); + + if (treeGrid.cascadeOnDelete) { + if (record && record.children) { + for (const child of record.children) { + super.deleteRowById(child.key); + } + } + } + } else { + const collection = record.parent ? record.parent.data[treeGrid.childDataKey] : treeGrid.data; + index = treeGrid.primaryKey ? + collection.map(c => c[treeGrid.primaryKey]).indexOf(rowID) : + collection.indexOf(rowID); + + const selectedChildren = []; + this.get_selected_children(record, selectedChildren); + if (selectedChildren.length > 0) { + treeGrid.deselectRows(selectedChildren); + } + + if (treeGrid.transactions.enabled) { + const path = treeGrid.generateRowPath(rowID); + treeGrid.transactions.add({ + id: rowID, + type: TransactionType.DELETE, + newValue: null, + path + } as HierarchicalTransaction, + collection[index] + ); + } else { + collection.splice(index, 1); + } + this.grid.validation.clear(rowID); + } + } + + public get_selected_children(record: ITreeGridRecord, selectedRowIDs: any[]) { + const grid = this.grid; + if (!record.children || record.children.length === 0) { + return; + } + for (const child of record.children) { + if (grid.selectionService.isRowSelected(child.key)) { + selectedRowIDs.push(child.key); + } + this.get_selected_children(child, selectedRowIDs); + } + } + + public override row_deleted_transaction(rowID: any): boolean { + return this.row_deleted_parent(rowID) || super.row_deleted_transaction(rowID); + } + + public override get_rec_by_id(rowID) { + return this.grid.records.get(rowID); + } + + /** + * Returns the index of the record in the data view by pk or -1 if not found or primaryKey is not set. + * + * @param pk + * @param dataCollection + */ + public override get_rec_index_by_id(pk: string | number, dataCollection?: any[]): number { + dataCollection = dataCollection || this.grid.data; + return this.grid.primaryKey ? dataCollection.findIndex(rec => rec.data[this.grid.primaryKey] === pk) : -1; + } + + public override addRowToData(data: any, parentRowID?: any) { + if (parentRowID !== undefined && parentRowID !== null) { + + const state = this.grid.transactions.getState(parentRowID); + // we should not allow adding of rows as child of deleted row + if (state && state.type === TransactionType.DELETE) { + throw Error(`Cannot add child row to deleted parent row`); + } + + const parentRecord = this.grid.records.get(parentRowID); + + if (!parentRecord) { + throw Error('Invalid parent row ID!'); + } + this.grid.summaryService.clearSummaryCache({ rowID: parentRecord.key }); + if (this.grid.primaryKey && this.grid.foreignKey) { + data[this.grid.foreignKey] = parentRowID; + super.addRowToData(data); + } else { + const parentData = parentRecord.data; + const childKey = this.grid.childDataKey; + if (this.grid.transactions.enabled) { + const rowId = this.grid.primaryKey ? data[this.grid.primaryKey] : data; + const path: any[] = []; + path.push(...this.grid.generateRowPath(parentRowID)); + path.push(parentRowID); + this.grid.transactions.add({ + id: rowId, + path, + newValue: data, + type: TransactionType.ADD + } as HierarchicalTransaction, + null); + } else { + if (!parentData[childKey]) { + parentData[childKey] = []; + } + parentData[childKey].push(data); + } + } + } else { + super.addRowToData(data); + } + } + + public override filterDataByExpressions(expressionsTree: IFilteringExpressionsTree): any[] { + const records = this.filterTreeDataByExpressions(expressionsTree); + const data = []; + + this.getFlatDataFromFilteredRecords(records, data); + + return data; + } + + public override sortDataByExpressions(data: ITreeGridRecord[], expressions: ISortingExpression[]) { + const records: ITreeGridRecord[] = DataUtil.sort( + cloneArray(data), + expressions, + this.grid.sortStrategy ?? new IgxDataRecordSorting(), + this.grid); + return records.map(r => r.data); + } + + public filterTreeDataByExpressions(expressionsTree: IFilteringExpressionsTree): ITreeGridRecord[] { + let records = this.grid.rootRecords; + + if (expressionsTree.filteringOperands.length) { + const state = { + expressionsTree, + strategy: this.grid.filterStrategy ?? new TreeGridFilteringStrategy() + }; + records = FilterUtil.filter(cloneArray(records), state, this.grid); + } + + return records; + } + + protected override update_row_in_array(value: any, rowID: any, index: number) { + const grid = this.grid; + if (grid.primaryKey && grid.foreignKey) { + super.update_row_in_array(value, rowID, index); + } else { + const record = grid.records.get(rowID); + const childData = record.parent ? record.parent.data[grid.childDataKey] : grid.data; + index = grid.primaryKey ? childData.map(c => c[grid.primaryKey]).indexOf(rowID) : + childData.indexOf(rowID); + childData[index] = value; + } + } + + /** + * 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 override updateData( + grid: GridType, + rowID: any, + rowValueInDataSource: any, + rowCurrentValue: any, + rowNewValue: { [x: string]: any }) { + if (grid.transactions.enabled) { + const path = grid.generateRowPath(rowID); + const transaction: HierarchicalTransaction = { + id: rowID, + type: TransactionType.UPDATE, + newValue: rowNewValue, + path + }; + grid.transactions.add(transaction, rowCurrentValue); + } else { + mergeObjects(rowValueInDataSource, rowNewValue); + } + } + + private row_deleted_parent(rowID: any): boolean { + const grid = this.grid; + if (!grid) { + return false; + } + if ((grid.cascadeOnDelete && grid.foreignKey) || grid.childDataKey) { + let node = grid.records.get(rowID); + while (node) { + const state: State = grid.transactions.getState(node.key); + if (state && state.type === TransactionType.DELETE) { + return true; + } + node = node.parent; + } + } + return false; + } + + private getFlatDataFromFilteredRecords(records: ITreeGridRecord[], data: any[]) { + if (!records || records.length === 0) { + return; + } + + for (const record of records) { + if (!record.isFilteredOutParent) { + data.push(record); + } + this.getFlatDataFromFilteredRecords(record.children, data); + } + } +} diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-crud.spec.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-crud.spec.ts new file mode 100644 index 00000000000..5cfae2b3c6f --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-crud.spec.ts @@ -0,0 +1,1140 @@ + +import { TestBed, waitForAsync, ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { IgxTreeGridSimpleComponent, IgxTreeGridPrimaryForeignKeyComponent } from '../../../test-utils/tree-grid-components.spec'; +import { TreeGridFunctions } from '../../../test-utils/tree-grid-functions.spec'; +import { first } from 'rxjs/operators'; +import { UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { DropPosition } from 'igniteui-angular/grids/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { GridFunctions } from '../../../test-utils/grid-functions.spec'; +import { DebugElement } from '@angular/core'; +import { IgxTreeGridComponent } from './tree-grid.component'; + +const CELL_CSS_CLASS = '.igx-grid__td'; + + +describe('IgxTreeGrid - CRUD #tGrid', () => { + let treeGrid: IgxTreeGridComponent; + let gridContent: DebugElement; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxTreeGridSimpleComponent, + IgxTreeGridPrimaryForeignKeyComponent + ] + }).compileComponents(); + })); + + describe('Create', () => { + describe('Child Collection', () => { + + let fix: ComponentFixture; + + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridSimpleComponent); + treeGrid = fix.componentInstance.treeGrid; + treeGrid.height = '800px'; + fix.detectChanges(); + }); + + it('should support adding root row through treeGrid API', () => { + verifyRowsCount(fix, 3, 10); + verifyTreeGridRecordsCount(fix, 3, 10); + verifyProcessedTreeGridRecordsCount(fix, 3, 10); + + const newRow = { + ID: 777, + Name: 'New Employee', + HireDate: new Date(2018, 3, 22), + Age: 25, + Employees: [] + }; + treeGrid.addRow(newRow); + fix.detectChanges(); + + verifyRowsCount(fix, 4, 11); + verifyTreeGridRecordsCount(fix, 4, 11); + verifyProcessedTreeGridRecordsCount(fix, 4, 11); + }); + + it('should support adding child rows through treeGrid API', () => { + verifyRowsCount(fix, 3, 10); + verifyTreeGridRecordsCount(fix, 3, 10); + verifyProcessedTreeGridRecordsCount(fix, 3, 10); + + let newRow = { + ID: 777, + Name: 'TEST NAME 1', + HireDate: new Date(2018, 3, 22), + Age: 25, + Employees: [] + }; + treeGrid.addRow(newRow, 847); + fix.detectChanges(); + + verifyRowsCount(fix, 3, 11); + verifyTreeGridRecordsCount(fix, 3, 11); + verifyProcessedTreeGridRecordsCount(fix, 3, 11); + + // Add child row on level 3 + newRow = { + ID: 999, + Name: 'TEST NAME 2', + HireDate: new Date(2018, 5, 17), + Age: 35, + Employees: [] + }; + treeGrid.addRow(newRow, 317); + fix.detectChanges(); + + verifyRowsCount(fix, 3, 12); + verifyTreeGridRecordsCount(fix, 3, 12); + verifyProcessedTreeGridRecordsCount(fix, 3, 12); + }); + + it('should do nothing when adding child row to a non-existing parent row', () => { + verifyRowsCount(fix, 3, 10); + verifyTreeGridRecordsCount(fix, 3, 10); + verifyProcessedTreeGridRecordsCount(fix, 3, 10); + + const newRow = { + ID: 383, + Name: 'TEST NAME 1', + HireDate: new Date(2018, 3, 22), + Age: 55, + Employees: [] + }; + let error = ''; + try { + treeGrid.addRow(newRow, 12345); + fix.detectChanges(); + } catch (ex) { + error = (ex as Error).message; + } + expect(error).toMatch('Invalid parent row ID!'); + + verifyRowsCount(fix, 3, 10); + verifyTreeGridRecordsCount(fix, 3, 10); + verifyProcessedTreeGridRecordsCount(fix, 3, 10); + }); + + it('should support adding child row to \'null\' collection through treeGrid API', () => { + const newRow = { + ID: 888, + Name: 'TEST Child', + HireDate: new Date(2011, 1, 11), + Age: 25, + Employees: [] + }; + treeGrid.addRow(newRow, 475); + fix.detectChanges(); + + verifyRowsCount(fix, 3, 11); + verifyTreeGridRecordsCount(fix, 3, 11); + verifyProcessedTreeGridRecordsCount(fix, 3, 11); + }); + + it('should support adding child row to \'undefined\' collection through treeGrid API', () => { + const newRow = { + ID: 888, + Name: 'TEST Child', + HireDate: new Date(2011, 1, 11), + Age: 25, + Employees: [] + }; + treeGrid.addRow(newRow, 957); + fix.detectChanges(); + + verifyRowsCount(fix, 3, 11); + verifyTreeGridRecordsCount(fix, 3, 11); + verifyProcessedTreeGridRecordsCount(fix, 3, 11); + }); + + it('should support adding child row to \'non-existing\' collection through treeGrid API', () => { + const newRow = { + ID: 888, + Name: 'TEST Child', + HireDate: new Date(2011, 1, 11), + Age: 25, + Employees: [] + }; + treeGrid.addRow(newRow, 711); + fix.detectChanges(); + + verifyRowsCount(fix, 3, 11); + verifyTreeGridRecordsCount(fix, 3, 11); + verifyProcessedTreeGridRecordsCount(fix, 3, 11); + }); + }); + + describe('Primary/Foreign key', () => { + let fix: ComponentFixture; + + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridPrimaryForeignKeyComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + treeGrid.height = '800px'; + }); + + it('should support adding root row through treeGrid API', () => { + verifyRowsCount(fix, 8, 8); + verifyTreeGridRecordsCount(fix, 3, 8); + verifyProcessedTreeGridRecordsCount(fix, 3, 8); + + const newRow = { + ID: 777, + ParentID: -1, + Name: 'New Employee', + JobTitle: 'Senior Web Developer', + Age: 33 + }; + treeGrid.addRow(newRow); + fix.detectChanges(); + + verifyRowsCount(fix, 9, 9); + verifyTreeGridRecordsCount(fix, 4, 9); + verifyProcessedTreeGridRecordsCount(fix, 4, 9); + }); + + it('should support adding child rows through treeGrid API', () => { + verifyRowsCount(fix, 8, 8); + verifyTreeGridRecordsCount(fix, 3, 8); + verifyProcessedTreeGridRecordsCount(fix, 3, 8); + + let newRow = { + ID: 777, + ParentID: 1, + Name: 'New Employee 1', + JobTitle: 'Senior Web Developer', + Age: 33 + }; + treeGrid.addRow(newRow, 1); + fix.detectChanges(); + + verifyRowsCount(fix, 9, 9); + verifyTreeGridRecordsCount(fix, 3, 9); + verifyProcessedTreeGridRecordsCount(fix, 3, 9); + + // Add child row on level 2 + newRow = { + ID: 333, + ParentID: 4, + Name: 'New Employee 2', + JobTitle: 'Senior Web Developer', + Age: 33 + }; + treeGrid.addRow(newRow, 4); + fix.detectChanges(); + + verifyRowsCount(fix, 10, 10); + verifyTreeGridRecordsCount(fix, 3, 10); + verifyProcessedTreeGridRecordsCount(fix, 3, 10); + }); + + it('should do nothing when adding child row to a non-existing parent row', () => { + verifyRowsCount(fix, 8, 8); + verifyTreeGridRecordsCount(fix, 3, 8); + verifyProcessedTreeGridRecordsCount(fix, 3, 8); + + let error = ''; + const newRow = { + ID: 777, + ParentID: 12345, // there is no row with ID=12345 + Name: 'New Employee 1', + JobTitle: 'Senior Web Developer', + Age: 33 + }; + try { + treeGrid.addRow(newRow, 12345); + fix.detectChanges(); + } catch (ex) { + error = (ex as Error).message; + } + expect(error).toMatch('Invalid parent row ID!'); + + verifyRowsCount(fix, 8, 8); + verifyTreeGridRecordsCount(fix, 3, 8); + verifyProcessedTreeGridRecordsCount(fix, 3, 8); + }); + + it('should support adding child rows to a parent with ID=0 through treeGrid API', () => { + verifyRowsCount(fix, 8, 8); + verifyTreeGridRecordsCount(fix, 3, 8); + verifyProcessedTreeGridRecordsCount(fix, 3, 8); + + let newRow = { + ID: 0, + Name: 'New Employee 1', + JobTitle: 'Senior Web Developer', + Age: 33 + }; + treeGrid.addRow(newRow); + fix.detectChanges(); + + verifyRowsCount(fix, 9, 9); + verifyTreeGridRecordsCount(fix, 4, 9); + verifyProcessedTreeGridRecordsCount(fix, 4, 9); + + // Add child row to the parent with ID=0 + newRow = { + ID: 333, + Name: 'New Employee 2', + JobTitle: 'Senior Web Developer', + Age: 33 + }; + treeGrid.addRow(newRow, 0); + fix.detectChanges(); + + verifyRowsCount(fix, 10, 10); + verifyTreeGridRecordsCount(fix, 4, 10); + verifyProcessedTreeGridRecordsCount(fix, 4, 10); + }); + }); + }); + + describe('Update API', () => { + describe('Child Collection', () => { + let fix: ComponentFixture; + + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridSimpleComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + }); + + it('should support updating a root row through the treeGrid API', () => { + verifyCellValue(fix, 0, 'Name', 'John Winchester'); + verifyRowsCount(fix, 3, 10); + + const newRow = { + ID: 999, + Name: 'New Name', + HireDate: new Date(2001, 1, 1), + Age: 60, + Employees: null + }; + treeGrid.updateRow(newRow, 147); + fix.detectChanges(); + + verifyCellValue(fix, 0, 'Name', 'New Name'); + verifyRowsCount(fix, 3, 4); + }); + + it('should support updating a child row through the treeGrid API', () => { + verifyCellValue(fix, 6, 'Name', 'Peter Lewis'); + verifyRowsCount(fix, 3, 10); + const newRow = { + ID: 888, + Name: 'New Name', + HireDate: new Date(2010, 11, 11), + Age: 42, + Employees: [] + }; + treeGrid.updateRow(newRow, 299); + fix.detectChanges(); + + verifyCellValue(fix, 6, 'Name', 'New Name'); + verifyRowsCount(fix, 3, 10); + }); + + it('should support updating a child row through the rowObject API', () => { + verifyCellValue(fix, 6, 'Name', 'Peter Lewis'); + verifyRowsCount(fix, 3, 10); + + const newRow = { + ID: 888, + Name: 'New Name', + HireDate: new Date(2010, 11, 11), + Age: 42, + Employees: [] + }; + treeGrid.getRowByKey(299).update(newRow); + fix.detectChanges(); + + verifyCellValue(fix, 6, 'Name', 'New Name'); + verifyRowsCount(fix, 3, 10); + }); + + it('should support updating a child tree-cell through the treeGrid API', () => { + // Test prerequisites: move 'Age' column so it becomes the tree-column + const sourceColumn = treeGrid.columnList.filter(c => c.field === 'Age')[0]; + const targetColumn = treeGrid.columnList.filter(c => c.field === 'ID')[0]; + treeGrid.moveColumn(sourceColumn, targetColumn, DropPosition.BeforeDropTarget); + fix.detectChanges(); + + verifyCellValue(fix, 6, 'Age', '25'); + verifyRowsCount(fix, 3, 10); + + const newCellValue = 18; + treeGrid.updateCell(newCellValue, 299, 'Age'); + fix.detectChanges(); + + verifyCellValue(fix, 6, 'Age', '18'); + verifyRowsCount(fix, 3, 10); + }); + + it('should support updating a child tree-cell through the cellObject API', () => { + // Test prerequisites: move 'Age' column so it becomes the tree-column + const sourceColumn = treeGrid.columnList.filter(c => c.field === 'Age')[0]; + const targetColumn = treeGrid.columnList.filter(c => c.field === 'ID')[0]; + treeGrid.moveColumn(sourceColumn, targetColumn, DropPosition.BeforeDropTarget); + fix.detectChanges(); + + + verifyCellValue(fix, 6, 'Age', '25'); + verifyRowsCount(fix, 3, 10); + + const newCellValue = 18; + treeGrid.getCellByKey(299, 'Age').update(newCellValue); + fix.detectChanges(); + + verifyCellValue(fix, 6, 'Age', '18'); + verifyRowsCount(fix, 3, 10); + }); + }); + + describe('Primary/Foreign key', () => { + let fix: ComponentFixture; + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridPrimaryForeignKeyComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + }); + + it('should support updating a root row through the treeGrid API', () => { + verifyCellValue(fix, 0, 'Name', 'Casey Houston'); + verifyRowsCount(fix, 8, 8); + verifyTreeGridRecordsCount(fix, 3, 8); + + // Update row on level 1 + const newRow = { + ID: 1, + ParentID: -1, + Name: 'New Name', + JobTitle: 'CFO', + Age: 40 + }; + treeGrid.updateRow(newRow, 1); + fix.detectChanges(); + + verifyCellValue(fix, 0, 'Name', 'New Name'); + verifyRowsCount(fix, 8, 8); + verifyTreeGridRecordsCount(fix, 3, 8); + }); + + it('should support updating a root row by changing its ID (its children should become root rows)', () => { + verifyCellValue(fix, 0, 'Name', 'Casey Houston'); + verifyRowsCount(fix, 8, 8); + verifyTreeGridRecordsCount(fix, 3, 8); + TreeGridFunctions.verifyRowIndentationLevelByIndex(fix, 1, 1); // Second visible row is on level 2 (childrow) + + const newRow = { + ID: 999, // Original ID is 1 and the new one is 999, which will transform its child rows into root rows. + ParentID: -1, + Name: 'New Name', + JobTitle: 'CFO', + Age: 40 + }; + treeGrid.updateRow(newRow, 1); + fix.detectChanges(); + + verifyCellValue(fix, 0, 'Name', 'New Name'); + verifyRowsCount(fix, 8, 8); + verifyTreeGridRecordsCount(fix, 5, 8); // Root records increment count with 2 + TreeGridFunctions.verifyRowIndentationLevelByIndex(fix, 1, 0); // Second visible row is now on level 1 (rootrow) + }); + + it('should support updating a child row through the treeGrid API', () => { + verifyCellValue(fix, 3, 'Name', 'Debra Morton'); + verifyRowsCount(fix, 8, 8); + const newRow = { + ID: 888, + ParentID: 2, + Name: 'New Name', + JobTitle: 'Web Developer', + Age: 42 + }; + treeGrid.updateRow(newRow, 7); + fix.detectChanges(); + + verifyCellValue(fix, 3, 'Name', 'New Name'); + verifyRowsCount(fix, 8, 8); + }); + + it('should support updating a child row through the rowObject API', () => { + verifyCellValue(fix, 3, 'Name', 'Debra Morton'); + verifyRowsCount(fix, 8, 8); + + const newRow = { + ID: 888, + ParentID: 2, + Name: 'New Name', + JobTitle: 'Web Developer', + Age: 42 + }; + treeGrid.getRowByKey(7).update(newRow); + fix.detectChanges(); + + verifyCellValue(fix, 3, 'Name', 'New Name'); + verifyRowsCount(fix, 8, 8); + }); + + it('should support updating a child row by changing its original parentID', () => { + verifyCellValue(fix, 3, 'Name', 'Debra Morton'); + verifyCellValue(fix, 5, 'Name', 'Erma Walsh'); + verifyRowsCount(fix, 8, 8); + + verifyTreeGridRecordsCount(fix, 3, 8); + const newRow = { + ID: 888, + ParentID: -1, // Original ID is 2 and the new one is -1, which will make the row a root row. + Name: 'New Name', + JobTitle: 'Web Developer', + Age: 42 + }; + treeGrid.getRowByKey(7).update(newRow); + fix.detectChanges(); + + verifyCellValue(fix, 3, 'Name', 'Jack Simon'); + verifyCellValue(fix, 5, 'Name', 'New Name'); + verifyRowsCount(fix, 8, 8); + verifyTreeGridRecordsCount(fix, 4, 8); // Root rows count increment with 1 due to the row update. + }); + + it('should support updating a child tree-cell through the treeGrid API', () => { + // Test prerequisites: move 'Name' column so it becomes the tree-column + const sourceColumn = treeGrid.columnList.filter(c => c.field === 'Name')[0]; + const targetColumn = treeGrid.columnList.filter(c => c.field === 'ID')[0]; + treeGrid.moveColumn(sourceColumn, targetColumn, DropPosition.BeforeDropTarget); + fix.detectChanges(); + + verifyCellValue(fix, 3, 'Name', 'Debra Morton'); + verifyRowsCount(fix, 8, 8); + + const newCellValue = 'Michael Myers'; + treeGrid.updateCell(newCellValue, 7, 'Name'); + fix.detectChanges(); + + verifyCellValue(fix, 3, 'Name', 'Michael Myers'); + verifyRowsCount(fix, 8, 8); + }); + + it('should support updating a child tree-cell through the cellObject API', () => { + // Test prerequisites: move 'Name' column so it becomes the tree-column + const sourceColumn = treeGrid.columnList.filter(c => c.field === 'Name')[0]; + const targetColumn = treeGrid.columnList.filter(c => c.field === 'ID')[0]; + treeGrid.moveColumn(sourceColumn, targetColumn, DropPosition.BeforeDropTarget); + fix.detectChanges(); + + verifyCellValue(fix, 3, 'Name', 'Debra Morton'); + verifyRowsCount(fix, 8, 8); + const newCellValue = 'Michael Myers'; + + treeGrid.getCellByKey(7, 'Name').update(newCellValue); + fix.detectChanges(); + + verifyCellValue(fix, 3, 'Name', 'Michael Myers'); + verifyRowsCount(fix, 8, 8); + }); + }); + }); + + describe('Update UI', () => { + describe('Child Collection', () => { + let fix: ComponentFixture; + + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridSimpleComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + for (const col of treeGrid.columnList) { + col.editable = true; + } + gridContent = GridFunctions.getGridContent(fix); + }); + + it('should be able to enter edit mode of a tree-grid column on dblclick, enter and F2', () => { + const cell = treeGrid.getCellByColumn(0, 'ID'); + + UIInteractions.simulateDoubleClickAndSelectEvent(treeGrid.gridAPI.get_cell_by_index(0, 'ID')); + fix.detectChanges(); + expect(cell.editMode).toBe(true, 'cannot enter edit mode with double click'); + + UIInteractions.triggerEventHandlerKeyDown('escape', gridContent); + fix.detectChanges(); + expect(cell.editMode).toBe(false, 'cannot exit edit mode after entering with double click'); + + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fix.detectChanges(); + + expect(cell.editMode).toBe(true, 'cannot enter edit mode with enter'); + + UIInteractions.triggerEventHandlerKeyDown('escape', gridContent); + fix.detectChanges(); + expect(cell.editMode).toBe(false, 'cannot exit edit mode after entering with enter'); + + UIInteractions.triggerEventHandlerKeyDown('f2', gridContent); + fix.detectChanges(); + expect(cell.editMode).toBe(true, 'cannot enter edit mode with F2'); + + UIInteractions.triggerEventHandlerKeyDown('escape', gridContent); + fix.detectChanges(); + expect(cell.editMode).toBe(false, 'cannot exit edit mode after entering with F2'); + }); + + it('should be able to enter edit mode of a non-tree-grid column on dblclick, enter and F2', () => { + const cell = treeGrid.getCellByColumn(0, 'Name'); + + UIInteractions.simulateDoubleClickAndSelectEvent(treeGrid.gridAPI.get_cell_by_index(0, 'Name')); + fix.detectChanges(); + expect(cell.editMode).toBe(true, 'cannot enter edit mode with double click'); + + UIInteractions.triggerEventHandlerKeyDown('escape', gridContent); + fix.detectChanges(); + expect(cell.editMode).toBe(false, 'cannot exit edit mode after entering with double click'); + + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fix.detectChanges(); + expect(cell.editMode).toBe(true, 'cannot enter edit mode with enter'); + + UIInteractions.triggerEventHandlerKeyDown('escape', gridContent); + fix.detectChanges(); + expect(cell.editMode).toBe(false, 'cannot exit edit mode after entering with enter'); + + UIInteractions.triggerEventHandlerKeyDown('f2', gridContent); + fix.detectChanges(); + expect(cell.editMode).toBe(true, 'cannot enter edit mode with F2'); + + UIInteractions.triggerEventHandlerKeyDown('escape', gridContent); + fix.detectChanges(); + expect(cell.editMode).toBe(false, 'cannot exit edit mode after entering with F2'); + }); + + it('should be able to edit a tree-grid cell through UI', () => { + const cell = treeGrid.getCellByColumn(0, 'ID'); + const cellDomNumber = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[0]; + + UIInteractions.simulateDoubleClickAndSelectEvent(treeGrid.gridAPI.get_cell_by_index(0, 'ID')); + fix.detectChanges(); + + expect(cell.editMode).toBe(true); + const editTemplate = cellDomNumber.query(By.css('input')); + expect(editTemplate).toBeDefined(); + + UIInteractions.clickAndSendInputElementValue(editTemplate, 146); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fix.detectChanges(); + + expect(cell.editMode).toBe(false); + expect(parseInt(cell.value, 10)).toBe(146); + expect(editTemplate.nativeElement.type).toBe('number'); + verifyCellValue(fix, 0, 'ID', '146'); + }); + + it('should be able to edit a non-tree-grid cell through UI', () => { + const cell = treeGrid.getCellByColumn(0, 'Name'); + const cellDomNumber = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[1]; + + UIInteractions.simulateDoubleClickAndSelectEvent(treeGrid.gridAPI.get_cell_by_index(0, 'Name')); + fix.detectChanges(); + + expect(cell.editMode).toBe(true); + const editTemplate = cellDomNumber.query(By.css('input')); + expect(editTemplate).toBeDefined(); + + UIInteractions.clickAndSendInputElementValue(editTemplate, 'Abc Def'); + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fix.detectChanges(); + + expect(cell.editMode).toBe(false); + expect(cell.value).toBe('Abc Def'); + expect(editTemplate.nativeElement.type).toBe('text'); + verifyCellValue(fix, 0, 'Name', 'Abc Def'); + }); + + it('should emit an event when editing a tree-grid cell through UI', () => { + const cellComponent = treeGrid.getCellByColumn(0, 'ID'); + const cellDomNumber = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[0]; + + treeGrid.cellEdit.pipe(first()).subscribe((args) => { + expect(args.newValue).toBe(146); + }); + + UIInteractions.simulateDoubleClickAndSelectEvent(treeGrid.gridAPI.get_cell_by_index(0, 'ID')); + fix.detectChanges(); + + expect(cellComponent.editMode).toBe(true); + let editTemplate = cellDomNumber.query(By.css('input')); + expect(editTemplate).toBeDefined(); + + UIInteractions.clickAndSendInputElementValue(editTemplate, '146'); + + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fix.detectChanges(); + + expect(cellComponent.editMode).toBe(false); + expect(cellComponent.value).toBe(146); + editTemplate = cellDomNumber.query(By.css('input')); + expect(editTemplate).toBeNull(); + }); + }); + + describe('Primary/Foreign key', () => { + let fix: ComponentFixture; + + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridPrimaryForeignKeyComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + for (const col of treeGrid.columnList) { + col.editable = true; + } + gridContent = GridFunctions.getGridContent(fix); + }); + + it('should be able to enter edit mode of a tree-grid column on dblclick, enter and F2', () => { + const cell = treeGrid.getCellByColumn(0, 'ID'); + + UIInteractions.simulateDoubleClickAndSelectEvent(treeGrid.gridAPI.get_cell_by_index(0, 'ID')); + fix.detectChanges(); + expect(cell.editMode).toBe(true, 'cannot enter edit mode with double click'); + + UIInteractions.triggerEventHandlerKeyDown('escape', gridContent); + fix.detectChanges(); + expect(cell.editMode).toBe(false, 'cannot exit edit mode after entering with double click'); + + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fix.detectChanges(); + expect(cell.editMode).toBe(true, 'cannot enter edit mode with enter'); + + UIInteractions.triggerEventHandlerKeyDown('escape', gridContent); + fix.detectChanges(); + expect(cell.editMode).toBe(false, 'cannot exit edit mode after entering with enter'); + + UIInteractions.triggerEventHandlerKeyDown('f2', gridContent); + fix.detectChanges(); + expect(cell.editMode).toBe(true, 'cannot enter edit mode with F2'); + + UIInteractions.triggerEventHandlerKeyDown('escape', gridContent); + fix.detectChanges(); + expect(cell.editMode).toBe(false, 'cannot exit edit mode after entering with F2'); + }); + + it('should be able to enter edit mode of a non-tree-grid column on dblclick, enter and F2', () => { + const cell = treeGrid.getCellByColumn(0, 'Name'); + + UIInteractions.simulateDoubleClickAndSelectEvent(treeGrid.gridAPI.get_cell_by_index(0, 'Name')); + fix.detectChanges(); + expect(cell.editMode).toBe(true, 'cannot enter edit mode with double click'); + + UIInteractions.triggerEventHandlerKeyDown('escape', gridContent); + fix.detectChanges(); + expect(cell.editMode).toBe(false, 'cannot exit edit mode after entering with double click'); + + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fix.detectChanges(); + expect(cell.editMode).toBe(true, 'cannot enter edit mode with enter'); + + UIInteractions.triggerEventHandlerKeyDown('escape', gridContent); + fix.detectChanges(); + expect(cell.editMode).toBe(false, 'cannot exit edit mode after entering with enter'); + + UIInteractions.triggerEventHandlerKeyDown('f2', gridContent); + fix.detectChanges(); + expect(cell.editMode).toBe(true, 'cannot enter edit mode with F2'); + + UIInteractions.triggerEventHandlerKeyDown('escape', gridContent); + fix.detectChanges(); + expect(cell.editMode).toBe(false, 'cannot exit edit mode after entering with F2'); + }); + + it('should be able to edit a tree-grid cell through UI', () => { + const cell = treeGrid.getCellByColumn(0, 'ID'); + const cellDomNumber = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[0]; + + UIInteractions.simulateDoubleClickAndSelectEvent(treeGrid.gridAPI.get_cell_by_index(0, 'ID')); + fix.detectChanges(); + + expect(cell.editMode).toBe(true); + const editTemplate = cellDomNumber.query(By.css('input')); + expect(editTemplate).toBeDefined(); + + UIInteractions.clickAndSendInputElementValue(editTemplate, 146); + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fix.detectChanges(); + + expect(cell.editMode).toBe(false); + expect(parseInt(cell.value, 10)).toBe(146); + expect(editTemplate.nativeElement.type).toBe('number'); + verifyCellValue(fix, 0, 'ID', '146'); + }); + + it('should be able to edit a non-tree-grid cell through UI', () => { + const cell = treeGrid.getCellByColumn(0, 'Name'); + const cellDomNumber = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[2]; + + UIInteractions.simulateDoubleClickAndSelectEvent(treeGrid.gridAPI.get_cell_by_index(0, 'Name')); + fix.detectChanges(); + + expect(cell.editMode).toBe(true); + const editTemplate = cellDomNumber.query(By.css('input')); + expect(editTemplate).toBeDefined(); + + UIInteractions.clickAndSendInputElementValue(editTemplate, 'Abc Def'); + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fix.detectChanges(); + + expect(cell.editMode).toBe(false); + expect(cell.value).toBe('Abc Def'); + expect(editTemplate.nativeElement.type).toBe('text'); + verifyCellValue(fix, 0, 'Name', 'Abc Def'); + }); + + it('should emit an event when editing a tree-grid cell through UI', () => { + const cellComponent = treeGrid.getCellByColumn(0, 'ID'); + const cellDomNumber = fix.debugElement.queryAll(By.css(CELL_CSS_CLASS))[0]; + + treeGrid.cellEdit.pipe(first()).subscribe((args) => { + expect(args.newValue).toBe(146); + }); + + UIInteractions.simulateDoubleClickAndSelectEvent(treeGrid.gridAPI.get_cell_by_index(0, 'ID')); + fix.detectChanges(); + + expect(cellComponent.editMode).toBe(true); + let editTemplate = cellDomNumber.query(By.css('input')); + expect(editTemplate).toBeDefined(); + + UIInteractions.clickAndSendInputElementValue(editTemplate, '146'); + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fix.detectChanges(); + + expect(cellComponent.editMode).toBe(false); + expect(cellComponent.value).toBe(146); + editTemplate = cellDomNumber.query(By.css('input')); + expect(editTemplate).toBeNull(); + }); + + it('should allow changing the row edit mode runtime.', () => { + const cell = treeGrid.getCellByColumn(0, 'Name'); + cell.column.editable = true; + treeGrid.rowEditable = true; + fix.detectChanges(); + cell.editMode = true; + fix.detectChanges(); + expect(cell.row.inEditMode).toBeTrue(); + treeGrid.rowEditable = false; + fix.detectChanges(); + cell.editValue = true; + fix.detectChanges(); + expect(cell.row.inEditMode).toBeFalse(); + }); + }); + + }); + + describe('Delete', () => { + describe('Child Collection', () => { + let fix: ComponentFixture; + + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridSimpleComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + }); + + it('should delete a root level row by ID', () => { + let someRow = treeGrid.getRowByIndex(0); + expect(someRow.key).toBe(147); + + verifyRowsCount(fix, 3, 10); + verifyTreeGridRecordsCount(fix, 3, 10); + verifyProcessedTreeGridRecordsCount(fix, 3, 10); + + treeGrid.deleteRow(someRow.key); + fix.detectChanges(); + someRow = treeGrid.getRowByIndex(0); + expect(someRow.key).toBe(19); + + verifyRowsCount(fix, 2, 3); + verifyTreeGridRecordsCount(fix, 2, 3); + verifyProcessedTreeGridRecordsCount(fix, 2, 3); + }); + + it('should delete a child level row by ID', () => { + let someRow = treeGrid.getRowByIndex(3); + expect(someRow.key).toBe(317); + + verifyRowsCount(fix, 3, 10); + verifyTreeGridRecordsCount(fix, 3, 10); + verifyProcessedTreeGridRecordsCount(fix, 3, 10); + + treeGrid.deleteRow(someRow.key); + fix.detectChanges(); + someRow = treeGrid.getRowByIndex(3); + expect(someRow.key).toBe(19); + + verifyRowsCount(fix, 3, 6); + verifyTreeGridRecordsCount(fix, 3, 6); + verifyProcessedTreeGridRecordsCount(fix, 3, 6); + }); + + it('should delete a root level row through the row object', () => { + let someRow = treeGrid.getRowByIndex(0); + expect(someRow.key).toBe(147); + + verifyRowsCount(fix, 3, 10); + verifyTreeGridRecordsCount(fix, 3, 10); + verifyProcessedTreeGridRecordsCount(fix, 3, 10); + + someRow.delete(); + fix.detectChanges(); + someRow = treeGrid.getRowByIndex(0); + expect(someRow.key).toBe(19); + + verifyRowsCount(fix, 2, 3); + verifyTreeGridRecordsCount(fix, 2, 3); + verifyProcessedTreeGridRecordsCount(fix, 2, 3); + }); + + it('should delete a child level row through the row object', () => { + let someRow = treeGrid.getRowByIndex(3); + expect(someRow.key).toBe(317); + + verifyRowsCount(fix, 3, 10); + verifyTreeGridRecordsCount(fix, 3, 10); + verifyProcessedTreeGridRecordsCount(fix, 3, 10); + + someRow.delete(); + fix.detectChanges(); + someRow = treeGrid.getRowByIndex(3); + expect(someRow.key).toBe(19); + + verifyRowsCount(fix, 3, 6); + verifyTreeGridRecordsCount(fix, 3, 6); + verifyProcessedTreeGridRecordsCount(fix, 3, 6); + }); + + }); + + describe('Primary/Foreign key', () => { + let fix: ComponentFixture; + + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridPrimaryForeignKeyComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + treeGrid.cascadeOnDelete = false; + }); + + it('should delete a root level row by ID', () => { + spyOn(treeGrid.rowDelete, 'emit').and.callThrough(); + spyOn(treeGrid.rowDeleted, 'emit').and.callThrough(); + let someRow = treeGrid.getRowByIndex(0); + expect(someRow.key).toBe(1); + + verifyRowsCount(fix, 8, 8); + verifyTreeGridRecordsCount(fix, 3, 8); + verifyProcessedTreeGridRecordsCount(fix, 3, 8); + + const rowDeleteArgs = { + rowID: someRow.key, + primaryKey: someRow.key, + rowKey: someRow.key, + cancel: false, + rowData: treeGrid.getRowData(someRow.key), + data: treeGrid.getRowData(someRow.key), + oldValue: null, + owner: treeGrid + }; + + const rowDeletedArgs = { + data: treeGrid.getRowData(someRow.key), + rowData: treeGrid.getRowData(someRow.key), + primaryKey: someRow.key, + owner: treeGrid, + rowKey: someRow.key, + }; + + treeGrid.deleteRow(someRow.key); + fix.detectChanges(); + + expect(treeGrid.rowDelete.emit).toHaveBeenCalledOnceWith(rowDeleteArgs); + expect(treeGrid.rowDeleted.emit).toHaveBeenCalledOnceWith(rowDeletedArgs); + + someRow = treeGrid.getRowByIndex(0); + expect(someRow.key).toBe(2); + + verifyRowsCount(fix, 7, 7); + verifyTreeGridRecordsCount(fix, 4, 7); + verifyProcessedTreeGridRecordsCount(fix, 4, 7); + }); + + it('should cancel rowDelete event', () => { + spyOn(treeGrid.rowDelete, 'emit').and.callThrough(); + spyOn(treeGrid.rowDeleted, 'emit').and.callThrough(); + let someRow = treeGrid.getRowByIndex(0); + expect(someRow.key).toBe(1); + + verifyRowsCount(fix, 8, 8); + verifyTreeGridRecordsCount(fix, 3, 8); + verifyProcessedTreeGridRecordsCount(fix, 3, 8); + + treeGrid.rowDelete.subscribe((e: any) => { + e.cancel = true; + }); + + const rowDeleteArgs = { + rowID: someRow.key, + primaryKey: someRow.key, + rowKey: someRow.key, + cancel: true, + rowData: treeGrid.getRowData(someRow.key), + data: treeGrid.getRowData(someRow.key), + oldValue: null, + owner: treeGrid, + }; + + treeGrid.deleteRow(someRow.key); + fix.detectChanges(); + + expect(treeGrid.rowDelete.emit).toHaveBeenCalledOnceWith(rowDeleteArgs); + expect(treeGrid.rowDeleted.emit).toHaveBeenCalledTimes(0); + + someRow = treeGrid.getRowByIndex(0); + expect(someRow.key).toBe(1); + + verifyRowsCount(fix, 8, 8); + verifyTreeGridRecordsCount(fix, 3, 8); + verifyProcessedTreeGridRecordsCount(fix, 3, 8); + }); + + it('should delete a child level row by ID', () => { + let someRow = treeGrid.getRowByIndex(1); + expect(someRow.key).toBe(2); + + verifyRowsCount(fix, 8, 8); + verifyTreeGridRecordsCount(fix, 3, 8); + verifyProcessedTreeGridRecordsCount(fix, 3, 8); + + treeGrid.deleteRow(someRow.key); + fix.detectChanges(); + someRow = treeGrid.getRowByIndex(1); + expect(someRow.key).toBe(4); + + verifyRowsCount(fix, 7, 7); + verifyTreeGridRecordsCount(fix, 5, 7); + verifyProcessedTreeGridRecordsCount(fix, 5, 7); + }); + + it('should delete a root level row through the row object', () => { + let someRow = treeGrid.getRowByIndex(0); + expect(someRow.key).toBe(1); + + verifyRowsCount(fix, 8, 8); + verifyTreeGridRecordsCount(fix, 3, 8); + verifyProcessedTreeGridRecordsCount(fix, 3, 8); + + someRow.delete(); + fix.detectChanges(); + someRow = treeGrid.getRowByIndex(0); + expect(someRow.key).toBe(2); + + verifyRowsCount(fix, 7, 7); + verifyTreeGridRecordsCount(fix, 4, 7); + verifyProcessedTreeGridRecordsCount(fix, 4, 7); + }); + + it('should delete a child level row through the row object', () => { + let someRow = treeGrid.getRowByIndex(1); + expect(someRow.key).toBe(2); + + verifyRowsCount(fix, 8, 8); + verifyTreeGridRecordsCount(fix, 3, 8); + verifyProcessedTreeGridRecordsCount(fix, 3, 8); + + someRow.delete(); + fix.detectChanges(); + someRow = treeGrid.getRowByIndex(1); + expect(someRow.key).toBe(4); + + verifyRowsCount(fix, 7, 7); + verifyTreeGridRecordsCount(fix, 5, 7); + verifyProcessedTreeGridRecordsCount(fix, 5, 7); + }); + + it('should delete child rows of a parent row when the "cascadeOnDelete" is set (delete by ID)', () => { + treeGrid.cascadeOnDelete = true; + + let aRow = treeGrid.getRowByIndex(0); + expect(aRow.key).toBe(1); + + verifyRowsCount(fix, 8, 8); + verifyTreeGridRecordsCount(fix, 3, 8); + verifyProcessedTreeGridRecordsCount(fix, 3, 8); + + treeGrid.deleteRow(aRow.key); + fix.detectChanges(); + aRow = treeGrid.getRowByIndex(0); + expect(aRow.key).toBe(6); + + verifyRowsCount(fix, 3, 3); + verifyTreeGridRecordsCount(fix, 2, 3); + verifyProcessedTreeGridRecordsCount(fix, 2, 3); + }); + + it('should delete child rows of a parent row when the "cascadeOnDelete" is set (delete by API)', () => { + treeGrid.cascadeOnDelete = true; + + let aRow = treeGrid.getRowByIndex(0); + expect(aRow.key).toBe(1); + + verifyRowsCount(fix, 8, 8); + verifyTreeGridRecordsCount(fix, 3, 8); + verifyProcessedTreeGridRecordsCount(fix, 3, 8); + + aRow.delete(); + fix.detectChanges(); + aRow = treeGrid.getRowByIndex(0); + expect(aRow.key).toBe(6); + + verifyRowsCount(fix, 3, 3); + verifyTreeGridRecordsCount(fix, 2, 3); + verifyProcessedTreeGridRecordsCount(fix, 2, 3); + }); + }); + }); +}); + +const verifyRowsCount = (fix, expectedRootRowsCount, expectedVisibleRowsCount) => { + const treeGrid = fix.componentInstance.treeGrid; + expect(TreeGridFunctions.getAllRows(fix).length).toBe(expectedVisibleRowsCount, 'Incorrect DOM rows length.'); + expect(treeGrid.data.length).toBe(expectedRootRowsCount, 'Incorrect data length.'); + expect(treeGrid.dataRowList.length).toBe(expectedVisibleRowsCount, 'Incorrect dataRowList length.'); +}; + +const verifyTreeGridRecordsCount = (fix, expectedRootRecordsCount, expectedFlatRecordsCount) => { + const treeGrid = fix.componentInstance.treeGrid as IgxTreeGridComponent; + expect(treeGrid.rootRecords.length).toBe(expectedRootRecordsCount); + expect(treeGrid.records.size).toBe(expectedFlatRecordsCount); +}; + +const verifyProcessedTreeGridRecordsCount = (fix, expectedProcessedRootRecordsCount, expectedProcessedFlatRecordsCount) => { + const treeGrid = fix.componentInstance.treeGrid as IgxTreeGridComponent; + expect(treeGrid.processedRootRecords.length).toBe(expectedProcessedRootRecordsCount); + expect(treeGrid.processedRecords.size).toBe(expectedProcessedFlatRecordsCount); +}; + +const verifyCellValue = (fix, rowIndex, columnKey, expectedCellValue) => { + const treeGrid = fix.componentInstance.treeGrid; + const actualValue = TreeGridFunctions.getCellValue(fix, rowIndex, columnKey); + const actualAPIValue = treeGrid.gridAPI.get_row_by_index(rowIndex).cells.filter((c) => c.column.field === columnKey)[0].value; + expect(actualValue.toString()).toBe(expectedCellValue, 'incorrect cell value'); + expect(actualAPIValue.toString()).toBe(expectedCellValue, 'incorrect api cell value'); +}; diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-expanding.spec.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-expanding.spec.ts new file mode 100644 index 00000000000..b1ab72d1c22 --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-expanding.spec.ts @@ -0,0 +1,1425 @@ +import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { + IgxTreeGridExpandingComponent, + IgxTreeGridPrimaryForeignKeyComponent, + IgxTreeGridRowEditingComponent, + IgxTreeGridLoadOnDemandComponent, + IgxTreeGridLoadOnDemandHasChildrenComponent, + IgxTreeGridLoadOnDemandChildDataComponent, + IgxTreeGridCustomExpandersTemplateComponent +} from '../../../test-utils/tree-grid-components.spec'; +import { TreeGridFunctions } from '../../../test-utils/tree-grid-functions.spec'; +import { first } from 'rxjs/operators'; +import { wait } from '../../../test-utils/ui-interactions.spec'; +import { GridFunctions } from '../../../test-utils/grid-functions.spec'; +import { CellType, GridSelectionMode } from 'igniteui-angular/grids/core'; +import { IgxTreeGridComponent } from './tree-grid.component'; +import { QueryList } from '@angular/core'; +import { IgxTreeGridAPIService } from './tree-grid-api.service'; + +describe('IgxTreeGrid - Expanding / Collapsing #tGrid', () => { + let fix; + let treeGrid: IgxTreeGridComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxTreeGridExpandingComponent, + IgxTreeGridPrimaryForeignKeyComponent, + IgxTreeGridLoadOnDemandComponent, + IgxTreeGridLoadOnDemandHasChildrenComponent, + IgxTreeGridLoadOnDemandChildDataComponent, + IgxTreeGridCustomExpandersTemplateComponent, + IgxTreeGridRowEditingComponent + ] + }).compileComponents(); + })); + + describe('Child Collection', () => { + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridExpandingComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + }); + + it('check row expanding and collapsing are changing rows count (UI)', () => { + let rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(4); + + const firstRow = rows[0]; + const indicatorDiv = TreeGridFunctions.getExpansionIndicatorDiv(firstRow); + indicatorDiv.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(7); + + indicatorDiv.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(4); + }); + + it('check row expanding and collapsing are changing rows count (API)', () => { + let rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(4); + + treeGrid.toggleRow(treeGrid.gridAPI.get_row_by_index(0).key); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(7); + + treeGrid.toggleRow(treeGrid.gridAPI.get_row_by_index(0).key); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(4); + }); + + it('check expand/collapse indicator changes (UI)', () => { + const rows = TreeGridFunctions.getAllRows(fix); + rows.forEach(row => { + TreeGridFunctions.verifyTreeRowHasCollapsedIcon(row); + }); + + for (let rowToToggle = 0; rowToToggle < rows.length; rowToToggle++) { + const indicatorDiv = TreeGridFunctions.getExpansionIndicatorDiv(rows[rowToToggle]); + indicatorDiv.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + for (let rowToCheck = 0; rowToCheck < rows.length; rowToCheck++) { + if (rowToCheck === rowToToggle) { + TreeGridFunctions.verifyTreeRowHasExpandedIcon(rows[rowToCheck]); + } else { + TreeGridFunctions.verifyTreeRowHasCollapsedIcon(rows[rowToCheck]); + } + } + + indicatorDiv.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + } + + rows.forEach(row => { + TreeGridFunctions.verifyTreeRowHasCollapsedIcon(row); + }); + }); + + it('check expand/collapse indicator changes (API)', () => { + fix.detectChanges(); + const rows = TreeGridFunctions.getAllRows(fix); + rows.forEach(row => { + TreeGridFunctions.verifyTreeRowHasCollapsedIcon(row); + }); + + for (let rowToToggle = 0; rowToToggle < rows.length; rowToToggle++) { + treeGrid.toggleRow(treeGrid.getRowByIndex(rowToToggle).key); + fix.detectChanges(); + + for (let rowToCheck = 0; rowToCheck < rows.length; rowToCheck++) { + if (rowToCheck === rowToToggle) { + TreeGridFunctions.verifyTreeRowHasExpandedIcon(rows[rowToCheck]); + } else { + TreeGridFunctions.verifyTreeRowHasCollapsedIcon(rows[rowToCheck]); + } + } + treeGrid.toggleRow(treeGrid.getRowByIndex(rowToToggle).key); + fix.detectChanges(); + } + + rows.forEach(row => { + TreeGridFunctions.verifyTreeRowHasCollapsedIcon(row); + }); + fix.detectChanges(); + }); + + it('check second level records are having the correct indentation (UI)', () => { + const rows = TreeGridFunctions.getAllRows(fix); + const indicatorDiv = TreeGridFunctions.getExpansionIndicatorDiv(rows[0]); + indicatorDiv.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + TreeGridFunctions.verifyRowIndentationLevelByIndex(fix, 1, 1); // fix, rowIndex, expectedLevel + TreeGridFunctions.verifyRowIndentationLevelByIndex(fix, 2, 1); + TreeGridFunctions.verifyRowIndentationLevelByIndex(fix, 3, 1); + }); + + it('check second level records are having the correct indentation (API)', () => { + treeGrid.toggleRow(treeGrid.getRowByIndex(0).key); + fix.detectChanges(); + + TreeGridFunctions.verifyRowIndentationLevelByIndex(fix, 1, 1); // fix, rowIndex, expectedLevel + TreeGridFunctions.verifyRowIndentationLevelByIndex(fix, 2, 1); + TreeGridFunctions.verifyRowIndentationLevelByIndex(fix, 3, 1); + }); + + it('check third level records are having the correct indentation (UI)', () => { + // expand second level records + let rows = TreeGridFunctions.getAllRows(fix); + let indicatorDiv = TreeGridFunctions.getExpansionIndicatorDiv(rows[0]); + indicatorDiv.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + // expand third level record + rows = TreeGridFunctions.getAllRows(fix); + indicatorDiv = TreeGridFunctions.getExpansionIndicatorDiv(rows[3]); + indicatorDiv.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + // check third level records indentation + TreeGridFunctions.verifyRowIndentationLevelByIndex(fix, 4, 2); // fix, rowIndex, expectedLevel + TreeGridFunctions.verifyRowIndentationLevelByIndex(fix, 5, 2); + }); + + it('check third level records are having the correct indentation (API)', () => { + // expand second level records + treeGrid.toggleRow(treeGrid.getRowByIndex(0).key); + fix.detectChanges(); + + // expand third level record + treeGrid.toggleRow(treeGrid.getRowByIndex(3).key); + fix.detectChanges(); + + // check third level records indentation + TreeGridFunctions.verifyRowIndentationLevelByIndex(fix, 4, 2); // fix, rowIndex, expectedLevel + TreeGridFunctions.verifyRowIndentationLevelByIndex(fix, 5, 2); + }); + + it('check grand children are not visible when collapsing their grand parent', () => { + let rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(4); + + // expand second level records + treeGrid.toggleRow(treeGrid.getRowByIndex(0).key); + fix.detectChanges(); + + // expand third level record + treeGrid.toggleRow(treeGrid.getRowByIndex(3).key); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(9); + + // collapse first row with all its children and grand children + treeGrid.toggleRow(treeGrid.getRowByIndex(0).key); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(4); + }); + + it('should expand/collapse rows when changing the \'expanded\' property', () => { + let rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(4); + + // expand a root level row + let aRow = treeGrid.gridAPI.get_row_by_index(0); + let cells = (aRow.cells as QueryList).toArray(); + expect(cells[0].value).toBe(147, 'wrong root level row'); + expect(aRow.expanded).toBe(false); + aRow.expanded = true; + fix.detectChanges(); + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(7, 'root level row expanding problem'); + + // expand a second level row + aRow = treeGrid.gridAPI.get_row_by_index(3); + cells = (aRow.cells as QueryList).toArray(); + expect(cells[0].value).toBe(317, 'wrong second level row'); + expect(aRow.expanded).toBe(false); + aRow.expanded = true; + fix.detectChanges(); + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(9, 'second level row expanding problem'); + + // check third level rows are having the correct values + aRow = treeGrid.gridAPI.get_row_by_index(4); + cells = (aRow.cells as QueryList).toArray(); + expect(cells[0].value).toBe(711, 'wrong third level row'); + aRow = treeGrid.gridAPI.get_row_by_index(5); + cells = (aRow.cells as QueryList).toArray(); + expect(cells[0].value).toBe(998, 'wrong third level row'); + + // collapse a second level row + aRow = treeGrid.gridAPI.get_row_by_index(3); + aRow.expanded = false; + fix.detectChanges(); + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(7, 'second level row collapsing problem'); + + // collapse a root level row + aRow = treeGrid.gridAPI.get_row_by_index(0); + aRow.expanded = false; + fix.detectChanges(); + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(4, 'root level row collapsing problem'); + }); + + it('should expand/collapse when using \'expandAll\' and \'collapseAll\' methods', () => { + treeGrid.paginator.perPage = 50; + fix.detectChanges(); + + let rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(4); + + treeGrid.expandAll(); + fix.detectChanges(); + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBeGreaterThan(10); + expect(rows.length).toBeLessThan(14); + + treeGrid.collapseAll(); + fix.detectChanges(); + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(4); + }); + + it('should emit an event when expanding rows (API)', (done) => { + const aRow = treeGrid.gridAPI.get_row_by_index(0); + treeGrid.rowToggle.pipe(first()).subscribe((args) => { + expect(args.cancel).toBe(false); + expect(args.event).toBeUndefined(); + expect(args.expanded).toBe(true); + expect(args.rowID.ID).toBe(147); + done(); + }); + aRow.expanded = true; + }); + + it('should emit an event when collapsing rows (API)', (done) => { + const aRow = treeGrid.gridAPI.get_row_by_index(0); + aRow.expanded = true; + fix.detectChanges(); + treeGrid.rowToggle.pipe(first()).subscribe((args) => { + expect(args.cancel).toBe(false); + expect(args.event).toBeUndefined(); + expect(args.expanded).toBe(false); + expect(args.rowID.ID).toBe(147); + done(); + }); + aRow.expanded = false; + fix.detectChanges(); + }); + + it('should emit an event when expanding rows (UI)', (done) => { + treeGrid.rowToggle.pipe(first()).subscribe((args) => { + expect(args.cancel).toBe(false); + expect(args.event).toBeDefined(); + expect(args.expanded).toBe(true); + expect(args.rowID.ID).toBe(147); + done(); + }); + const rowsDOM = TreeGridFunctions.getAllRows(fix); + const indicatorDivDOM = TreeGridFunctions.getExpansionIndicatorDiv(rowsDOM[0]); + indicatorDivDOM.triggerEventHandler('click', new Event('click')); + }); + + it('should emit an event when collapsing rows (UI)', (done) => { + const rowsDOM = TreeGridFunctions.getAllRows(fix); + const indicatorDivDOM = TreeGridFunctions.getExpansionIndicatorDiv(rowsDOM[0]); + indicatorDivDOM.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + treeGrid.rowToggle.pipe(first()).subscribe((args) => { + expect(args.cancel).toBe(false); + expect(args.event).toBeDefined(); + expect(args.expanded).toBe(false); + expect(args.rowID.ID).toBe(147); + done(); + }); + indicatorDivDOM.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + }); + + it('should update current page when \'collapseAll\' ', () => { + // Test prerequisites + treeGrid.paginator.perPage = 4; + fix.detectChanges(); + + treeGrid.expandAll(); + fix.detectChanges(); + + // Verify current page + verifyGridPager(fix, 4, '147', '1\xA0of\xA05', [true, true, false, false]); + expect(treeGrid.paginator.totalPages).toBe(5); + + // Go to fourth page + treeGrid.paginator.page = 3; + fix.detectChanges(); + + // Verify current page + verifyGridPager(fix, 4, '17', '4\xA0of\xA05', [false, false, false, false]); + expect(treeGrid.paginator.totalPages).toBe(5); + + treeGrid.collapseAll(); + fix.detectChanges(); + + // Verify current page is the first one and only root rows are visible. + verifyGridPager(fix, 4, '147', '1\xA0of\xA01', [true, true, true, true]); + expect(treeGrid.paginator.totalPages).toBe(1); + }); + + it('Should update the paginator when a row of any level is expanded', fakeAsync(() => { + // Test prerequisites + treeGrid.paginator.perPage = 5; + fix.detectChanges(); + tick(16); + + treeGrid.collapseAll(); + fix.detectChanges(); + tick(16); + + // Verify current page + verifyGridPager(fix, 4, '147', '1\xA0of\xA01', [true, true, true, true]); + expect(treeGrid.paginator.totalPages).toBe(1); + + // Expand a row + const rowsDOM = TreeGridFunctions.getAllRows(fix); + let indicatorDivDOM = TreeGridFunctions.getExpansionIndicatorDiv(rowsDOM[3]); + indicatorDivDOM.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + // Verify current page + verifyGridPager(fix, 5, '147', '1\xA0of\xA02', [true, true, false, false]); + expect(treeGrid.paginator.totalPages).toBe(2); + + // Expand another row + indicatorDivDOM = TreeGridFunctions.getExpansionIndicatorDiv(rowsDOM[1]); + indicatorDivDOM.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + treeGrid.page = 1; + fix.detectChanges(); + tick(16); + + indicatorDivDOM = TreeGridFunctions.getExpansionIndicatorDiv(rowsDOM[1]); + indicatorDivDOM.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + // Verify current page + verifyGridPager(fix, 5, '17', '2\xA0of\xA03', [false, false, false, false]); + expect(treeGrid.paginator.totalPages).toBe(3); + })); + + it('Should update the paginator when a row of any level is collapsed', () => { + // Test prerequisites + treeGrid.paginator.perPage = 5; + fix.detectChanges(); + + treeGrid.expandAll(); + fix.detectChanges(); + + // Verify current page + verifyGridPager(fix, 5, '147', '1\xA0of\xA04', [true, true, false, false]); + expect(treeGrid.paginator.totalPages).toBe(4); + + // Go to third page + treeGrid.paginator.page = 2; + fix.detectChanges(); + verifyGridPager(fix, 5, '19', '3\xA0of\xA04', [false, false, false, false]); + expect(treeGrid.paginator.totalPages).toBe(4); + + const rowsDOM = TreeGridFunctions.getAllRows(fix); + let indicatorDivDOM = TreeGridFunctions.getExpansionIndicatorDiv(rowsDOM[0]); + indicatorDivDOM.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + indicatorDivDOM = TreeGridFunctions.getExpansionIndicatorDiv(rowsDOM[2]); + indicatorDivDOM.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + // // Verify current page + verifyGridPager(fix, 4, '19', '3\xA0of\xA03', [false, false, true, true]); + expect(treeGrid.paginator.totalPages).toBe(3); + + treeGrid.paginator.page = 0; + fix.detectChanges(); + indicatorDivDOM = TreeGridFunctions.getExpansionIndicatorDiv(rowsDOM[0]); + indicatorDivDOM.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + // // Verify current page + verifyGridPager(fix, 5, '147', '1\xA0of\xA02', [true, true, false, false]); + expect(treeGrid.paginator.totalPages).toBe(2); + }); + + it('Should update the paginator when navigating through pages', async () => { + // Async tick is required for the pager to be rendered + fix.detectChanges(); + await wait(); + + // Verify current page + verifyGridPager(fix, 4, '147', '1\xA0of\xA01', [true, true, true, true]); + expect(treeGrid.paginator.totalPages).toBe(1); + + // Go to third page + const rowsDOM = TreeGridFunctions.getAllRows(fix); + let indicatorDivDOM = TreeGridFunctions.getExpansionIndicatorDiv(rowsDOM[3]); + indicatorDivDOM.triggerEventHandler('click', new Event('click')); + indicatorDivDOM = TreeGridFunctions.getExpansionIndicatorDiv(rowsDOM[1]); + indicatorDivDOM.triggerEventHandler('click', new Event('click')); + indicatorDivDOM = TreeGridFunctions.getExpansionIndicatorDiv(rowsDOM[0]); + indicatorDivDOM.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + const tGrid: HTMLElement = treeGrid.nativeElement; + GridFunctions.navigateToNextPage(tGrid); + fix.detectChanges(); + // Verify current page + verifyGridPager(fix, 1, '101', '2\xA0of\xA02', [false, false, true, true]); + expect(treeGrid.paginator.totalPages).toBe(2); + + GridFunctions.navigateToFirstPage(tGrid); + fix.detectChanges(); + // Verify current page + verifyGridPager(fix, 10, '147', '1\xA0of\xA02', [true, true, false, false]); + expect(treeGrid.paginator.totalPages).toBe(2); + }); + }); + + describe('Primary/Foreign key', () => { + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridPrimaryForeignKeyComponent); + treeGrid = fix.componentInstance.treeGrid; + treeGrid.expansionDepth = 0; + fix.detectChanges(); + }); + + it('check row expanding and collapsing are changing rows count (UI)', () => { + let rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(3); + + const firstRow = rows[0]; + const indicatorDiv = TreeGridFunctions.getExpansionIndicatorDiv(firstRow); + indicatorDiv.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(5); + indicatorDiv.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(3); + }); + + it('check row expanding and collapsing are changing rows count (API)', () => { + let rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(3); + + treeGrid.toggleRow(treeGrid.getRowByIndex(0).key); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(5); + + treeGrid.toggleRow(treeGrid.getRowByIndex(0).key); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(3); + }); + + it('check expand/collapse indicator changes (UI)', () => { + const rows = TreeGridFunctions.getAllRows(fix); + rows.forEach(row => { + TreeGridFunctions.verifyTreeRowHasCollapsedIcon(row); + }); + + for (let rowToToggle = 0; rowToToggle < rows.length; rowToToggle++) { + if (rowToToggle === 1) { + continue; + } + const indicatorDiv = TreeGridFunctions.getExpansionIndicatorDiv(rows[rowToToggle]); + indicatorDiv.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + for (let rowToCheck = 0; rowToCheck < rows.length; rowToCheck++) { + if (rowToCheck === rowToToggle) { + TreeGridFunctions.verifyTreeRowHasExpandedIcon(rows[rowToCheck]); + } else { + TreeGridFunctions.verifyTreeRowHasCollapsedIcon(rows[rowToCheck]); + } + } + + indicatorDiv.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + } + + rows.forEach(row => { + TreeGridFunctions.verifyTreeRowHasCollapsedIcon(row); + }); + }); + + it('check expand/collapse indicator changes (API)', () => { + const rows = TreeGridFunctions.getAllRows(fix); + rows.forEach(row => { + TreeGridFunctions.verifyTreeRowHasCollapsedIcon(row); + }); + + for (let rowToToggle = 0; rowToToggle < rows.length; rowToToggle++) { + const ri = treeGrid.getRowByIndex(rowToToggle).key; + treeGrid.toggleRow(ri); + fix.detectChanges(); + for (let rowToCheck = 0; rowToCheck < rows.length; rowToCheck++) { + if (rowToCheck === rowToToggle && (treeGrid.gridAPI as IgxTreeGridAPIService).allow_expansion_state_change(ri, false)) { + TreeGridFunctions.verifyTreeRowHasExpandedIcon(rows[rowToCheck]); + } else { + TreeGridFunctions.verifyTreeRowHasCollapsedIcon(rows[rowToCheck]); + } + } + treeGrid.toggleRow(treeGrid.getRowByIndex(rowToToggle).key); + fix.detectChanges(); + } + + rows.forEach(row => { + TreeGridFunctions.verifyTreeRowHasCollapsedIcon(row); + }); + }); + + it('check second level records are having the correct indentation (UI)', () => { + const rows = TreeGridFunctions.getAllRows(fix); + const indicatorDiv = TreeGridFunctions.getExpansionIndicatorDiv(rows[0]); + indicatorDiv.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + TreeGridFunctions.verifyRowIndentationLevelByIndex(fix, 1, 1); // fix, rowIndex, expectedLevel + TreeGridFunctions.verifyRowIndentationLevelByIndex(fix, 2, 1); + TreeGridFunctions.verifyRowIndentationLevelByIndex(fix, 3, 0); + }); + + it('check second level records are having the correct indentation (API)', () => { + treeGrid.toggleRow(treeGrid.getRowByIndex(0).key); + fix.detectChanges(); + + TreeGridFunctions.verifyRowIndentationLevelByIndex(fix, 1, 1); // fix, rowIndex, expectedLevel + TreeGridFunctions.verifyRowIndentationLevelByIndex(fix, 2, 1); + TreeGridFunctions.verifyRowIndentationLevelByIndex(fix, 3, 0); + }); + + it('check third level records are having the correct indentation (UI)', () => { + // expand second level records + let rows = TreeGridFunctions.getAllRows(fix); + let indicatorDiv = TreeGridFunctions.getExpansionIndicatorDiv(rows[0]); + indicatorDiv.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + // expand third level record + rows = TreeGridFunctions.getAllRows(fix); + indicatorDiv = TreeGridFunctions.getExpansionIndicatorDiv(rows[1]); + indicatorDiv.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + // check third level records indentation + TreeGridFunctions.verifyRowIndentationLevelByIndex(fix, 2, 2); // fix, rowIndex, expectedLevel + TreeGridFunctions.verifyRowIndentationLevelByIndex(fix, 3, 2); + }); + + it('check third level records are having the correct indentation (API)', () => { + // expand second level records + treeGrid.toggleRow(treeGrid.getRowByIndex(0).key); + fix.detectChanges(); + // expand third level record + treeGrid.toggleRow(treeGrid.getRowByIndex(1).key); + fix.detectChanges(); + + // check third level records indentation + TreeGridFunctions.verifyRowIndentationLevelByIndex(fix, 2, 2); // fix, rowIndex, expectedLevel + TreeGridFunctions.verifyRowIndentationLevelByIndex(fix, 3, 2); + }); + + it('check grand children are not visible when collapsing their grand parent', () => { + let rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(3); + + // expand second level records + treeGrid.toggleRow(treeGrid.getRowByIndex(0).key); + fix.detectChanges(); + + // expand third level record + treeGrid.toggleRow(treeGrid.getRowByIndex(1).key); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(7); + + // collapse first row with all its children and grand children + treeGrid.toggleRow(treeGrid.getRowByIndex(0).key); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(3); + }); + + it('should expand/collapse rows when changing the \'expanded\' property', () => { + let rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(3); + + // expand a root level row + let aRow = treeGrid.gridAPI.get_row_by_index(0); + let cells = (aRow.cells as QueryList).toArray(); + expect(cells[0].value).toBe(1, 'wrong root level row'); + expect(aRow.expanded).toBe(false); + aRow.expanded = true; + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(5, 'root level row expanding problem'); + + // expand a second level row + aRow = treeGrid.gridAPI.get_row_by_index(1); + cells = (aRow.cells as QueryList).toArray(); + expect(cells[0].value).toBe(2, 'wrong second level row'); + expect(aRow.expanded).toBe(false); + aRow.expanded = true; + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(7, 'second level row expanding problem'); + + // check third level rows are having the correct values + aRow = treeGrid.gridAPI.get_row_by_index(2); + cells = (aRow.cells as QueryList).toArray(); + expect(cells[0].value).toBe(3, 'wrong third level row'); + aRow = treeGrid.gridAPI.get_row_by_index(3); + cells = (aRow.cells as QueryList).toArray(); + expect(cells[0].value).toBe(7, 'wrong third level row'); + + // collapse a second level row + aRow = treeGrid.gridAPI.get_row_by_index(1); + aRow.expanded = false; + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(5, 'second level row collapsing problem'); + + // collapse a root level row + aRow = treeGrid.gridAPI.get_row_by_index(0); + aRow.expanded = false; + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(3, 'root level row collapsing problem'); + }); + + it('should expand/collapse when using \'expandAll\' and \'collapseAll\' methods', () => { + let rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(3); + + treeGrid.expandAll(); + fix.detectChanges(); + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(8); + + treeGrid.collapseAll(); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(3); + }); + + it('should emit an event when expanding rows (API)', (done) => { + const aRow = treeGrid.getRowByIndex(0); + treeGrid.rowToggle.pipe(first()).subscribe((args) => { + expect(args.cancel).toBe(false); + expect(args.event).toBeUndefined(); + expect(args.expanded).toBe(true); + expect(args.rowID).toBe(1); + done(); + }); + aRow.expanded = true; + }); + + it('should emit an event when collapsing rows (API)', (done) => { + const aRow = treeGrid.getRowByIndex(0); + aRow.expanded = true; + fix.detectChanges(); + + treeGrid.rowToggle.pipe(first()).subscribe((args) => { + expect(args.cancel).toBe(false); + expect(args.event).toBeUndefined(); + expect(args.expanded).toBe(false); + expect(args.rowID).toBe(1); + done(); + }); + aRow.expanded = false; + }); + + it('should emit an event when expanding rows (UI)', (done) => { + treeGrid.rowToggle.pipe(first()).subscribe((args) => { + expect(args.cancel).toBe(false); + expect(args.event).toBeDefined(); + expect(args.expanded).toBe(true); + expect(args.rowID).toBe(1); + done(); + }); + const rowsDOM = TreeGridFunctions.getAllRows(fix); + const indicatorDivDOM = TreeGridFunctions.getExpansionIndicatorDiv(rowsDOM[0]); + indicatorDivDOM.triggerEventHandler('click', new Event('click')); + }); + + it('should emit an event when collapsing rows (UI)', (done) => { + const rowsDOM = TreeGridFunctions.getAllRows(fix); + const indicatorDivDOM = TreeGridFunctions.getExpansionIndicatorDiv(rowsDOM[0]); + indicatorDivDOM.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + treeGrid.rowToggle.pipe(first()).subscribe((args) => { + expect(args.cancel).toBe(false); + expect(args.event).toBeDefined(); + expect(args.expanded).toBe(false); + expect(args.rowID).toBe(1); + done(); + }); + indicatorDivDOM.triggerEventHandler('click', new Event('click')); + }); + + it('should update current page when \'collapseAll\' ', () => { + // Test prerequisites + fix.componentInstance.paging = true; + fix.detectChanges(); + treeGrid.paginator.perPage = 2; + fix.detectChanges(); + + treeGrid.expandAll(); + + fix.detectChanges(); + // Verify current page + verifyGridPager(fix, 2, '1', '1\xA0of\xA04', [true, true, false, false]); + expect(treeGrid.paginator.totalPages).toBe(4); + + // Go to fourth page + treeGrid.paginator.page = 3; + fix.detectChanges(); + // Verify current page + verifyGridPager(fix, 2, '10', '4\xA0of\xA04', [false, false, true, true]); + expect(treeGrid.paginator.totalPages).toBe(4); + + treeGrid.collapseAll(); + fix.detectChanges(); + // Verify current page is the last one and only root rows are visible. + verifyGridPager(fix, 1, '10', '2\xA0of\xA02', [false, false, true, true]); + expect(treeGrid.paginator.totalPages).toBe(2); + }); + + it('Should update the paginator when a row of any level is expanded', () => { + // Test prerequisites + fix.componentInstance.paging = true; + fix.detectChanges(); + treeGrid.paginator.perPage = 5; + fix.detectChanges(); + + treeGrid.collapseAll(); + fix.detectChanges(); + + // Verify current page + verifyGridPager(fix, 3, '1', '1\xA0of\xA01', [true, true, true, true]); + expect(treeGrid.paginator.totalPages).toBe(1); + + // Expand a row + const rowsDOM = TreeGridFunctions.getAllRows(fix); + let indicatorDivDOM = TreeGridFunctions.getExpansionIndicatorDiv(rowsDOM[0]); + indicatorDivDOM.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + // Verify current page + verifyGridPager(fix, 5, '1', '1\xA0of\xA01', [true, true, true, true]); + expect(treeGrid.paginator.totalPages).toBe(1); + + // Expand another row + indicatorDivDOM = TreeGridFunctions.getExpansionIndicatorDiv(rowsDOM[1]); + indicatorDivDOM.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + // Verify current page + verifyGridPager(fix, 5, '1', '1\xA0of\xA02', [true, true, false, false]); + expect(treeGrid.paginator.totalPages).toBe(2); + + treeGrid.paginator.page = 1; + fix.detectChanges(); + indicatorDivDOM = TreeGridFunctions.getExpansionIndicatorDiv(rowsDOM[1]); + indicatorDivDOM.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + // Verify current page + verifyGridPager(fix, 3, '6', '2\xA0of\xA02', [false, false, true, true]); + expect(treeGrid.paginator.totalPages).toBe(2); + }); + + it('Should update the paginator when a row of any level is collapsed', () => { + // Test prerequisites + fix.componentInstance.paging = true; + fix.detectChanges(); + treeGrid.paginator.perPage = 5; + fix.detectChanges(); + + treeGrid.expandAll(); + fix.detectChanges(); + + // Verify current page + verifyGridPager(fix, 5, '1', '1\xA0of\xA02', [true, true, false, false]); + expect(treeGrid.paginator.totalPages).toBe(2); + + // Go to third page + fix.detectChanges(); + const rowsDOM = TreeGridFunctions.getAllRows(fix); + let indicatorDivDOM = TreeGridFunctions.getExpansionIndicatorDiv(rowsDOM[0]); + indicatorDivDOM.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + indicatorDivDOM = TreeGridFunctions.getExpansionIndicatorDiv(rowsDOM[2]); + indicatorDivDOM.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + // // Verify current page + verifyGridPager(fix, 3, '1', '1\xA0of\xA01', [true, true, true, true]); + expect(treeGrid.paginator.totalPages).toBe(1); + }); + + it('Should update the paginator when navigating through pages', () => { + // Test prerequisites + fix.componentInstance.paging = true; + fix.detectChanges(); + treeGrid.paginator.perPage = 5; + fix.detectChanges(); + + // Verify current page + verifyGridPager(fix, 3, '1', '1\xA0of\xA01', [true, true, true, true]); + expect(treeGrid.paginator.totalPages).toBe(1); + + // Go to third page + const rowsDOM = TreeGridFunctions.getAllRows(fix); + let indicatorDivDOM = TreeGridFunctions.getExpansionIndicatorDiv(rowsDOM[2]); + indicatorDivDOM.triggerEventHandler('click', new Event('click')); + indicatorDivDOM = TreeGridFunctions.getExpansionIndicatorDiv(rowsDOM[0]); + indicatorDivDOM.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + // Verify current page + verifyGridPager(fix, 5, '1', '1\xA0of\xA02', [true, true, false, false]); + expect(treeGrid.paginator.totalPages).toBe(2); + + const tGrid: HTMLElement = treeGrid.nativeElement; + GridFunctions.navigateToLastPage(tGrid); + fix.detectChanges(); + // Verify current page + verifyGridPager(fix, 1, '9', '2\xA0of\xA02', [false, false, true, true]); + expect(treeGrid.paginator.totalPages).toBe(2); + + GridFunctions.navigateToPrevPage(tGrid); + fix.detectChanges(); + // Verify current page + verifyGridPager(fix, 5, '1', '1\xA0of\xA02', [true, true, false, false]); + expect(treeGrid.paginator.totalPages).toBe(2); + }); + }); + + describe('Load On Demand', () => { + + describe('Primary/Foreign key', () => { + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridLoadOnDemandComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + }); + + it('check expanding and collapsing a row with children', fakeAsync(() => { + let rows = TreeGridFunctions.getAllRows(fix); + const row = rows[0]; + TreeGridFunctions.verifyTreeRowIndicator(row, false); + expect(rows.length).toBe(3); + + let indicatorDiv = TreeGridFunctions.getExpansionIndicatorDiv(row); + indicatorDiv.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + TreeGridFunctions.verifyTreeRowIndicator(row, true); + expect(rows.length).toBe(3); + + // Wait for loading RAF on first expand to complete (RAF is in test component) + tick(16); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + TreeGridFunctions.verifyTreeRowIndicator(row, false); + expect(rows.length).toBe(5); + indicatorDiv = TreeGridFunctions.getExpansionIndicatorDiv(row); + indicatorDiv.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + TreeGridFunctions.verifyTreeRowIndicator(row, false); + expect(rows.length).toBe(3); + })); + + it('check expanding and collapsing a row without children', fakeAsync(() => { + let rows = TreeGridFunctions.getAllRows(fix); + const row = rows[1]; + const indicatorDiv = TreeGridFunctions.getExpansionIndicatorDiv(row); + TreeGridFunctions.verifyTreeRowIndicator(row, false); + expect(rows.length).toBe(3); + indicatorDiv.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + TreeGridFunctions.verifyTreeRowIndicator(row, true); + expect(rows.length).toBe(3); + + // Wait for loading RAF on first expand to complete (RAF is in test component) + tick(16); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + TreeGridFunctions.verifyTreeRowIndicator(row, false, false); + expect(rows.length).toBe(3); + })); + + it('check row selection when expand a row', fakeAsync(() => { + treeGrid.rowSelection = GridSelectionMode.multiple; + fix.detectChanges(); + + treeGrid.selectAllRows(); + fix.detectChanges(); + + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, true); + expect(treeGrid.selectedRows).toEqual([1, 6, 10]); + + let rows = TreeGridFunctions.getAllRows(fix); + const row = rows[0]; + expect(rows.length).toBe(3); + + const indicatorDiv = TreeGridFunctions.getExpansionIndicatorDiv(row); + indicatorDiv.triggerEventHandler('click', new Event('click')); + + // Wait for loading RAF on first expand to complete (RAF is in test component) + tick(16); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(5); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 0, true); + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 1, false); + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 2, false); + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 3, true); + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 4, true); + expect(treeGrid.selectedRows).toEqual([1, 6, 10]); + })); + + it('check row selection within multipleCascade selection mode when expand a row', fakeAsync(() => { + treeGrid.rowSelection = GridSelectionMode.multipleCascade; + fix.detectChanges(); + + treeGrid.selectRows([1]); + fix.detectChanges(); + + expect(treeGrid.selectedRows).toEqual([1]); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, true, true); + + treeGrid.expandRow(1); + // Wait for loading RAF on first expand to complete (RAF is in test component) + tick(16); + fix.detectChanges(); + + expect(treeGrid.rowList.length).toBe(5); + expect(treeGrid.selectedRows.length).toBe(3); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 1, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 2, true, true); + + treeGrid.expandRow(2); + // Wait for loading RAF on first expand to complete (RAF is in test component) + tick(16); + fix.detectChanges(); + + expect(treeGrid.rowList.length).toBe(7); + expect(treeGrid.selectedRows.length).toBe(5); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 1, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 2, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 4, true, true); + })); + }); + + describe('ChildDataKey', () => { + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridLoadOnDemandChildDataComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + }); + + it('check expanding and collapsing a row with children', fakeAsync(() => { + let rows = TreeGridFunctions.getAllRows(fix); + const row = rows[0]; + TreeGridFunctions.verifyTreeRowIndicator(row, false); + expect(rows.length).toBe(3); + + let indicatorDiv = TreeGridFunctions.getExpansionIndicatorDiv(row); + indicatorDiv.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + TreeGridFunctions.verifyTreeRowIndicator(row, true); + expect(rows.length).toBe(3); + + // Wait for loading RAF on first expand to complete (RAF is in test component) + tick(16); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + TreeGridFunctions.verifyTreeRowIndicator(row, false); + expect(rows.length).toBe(5); + indicatorDiv = TreeGridFunctions.getExpansionIndicatorDiv(row); + indicatorDiv.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + TreeGridFunctions.verifyTreeRowIndicator(row, false); + expect(rows.length).toBe(3); + })); + + it('check expanding and collapsing a row without children', fakeAsync(() => { + let rows = TreeGridFunctions.getAllRows(fix); + const row = rows[1]; + const indicatorDiv = TreeGridFunctions.getExpansionIndicatorDiv(row); + TreeGridFunctions.verifyTreeRowIndicator(row, false); + expect(rows.length).toBe(3); + indicatorDiv.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + TreeGridFunctions.verifyTreeRowIndicator(row, true); + expect(rows.length).toBe(3); + + // Wait for loading RAF on first expand to complete (RAF is in test component) + tick(16); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + TreeGridFunctions.verifyTreeRowIndicator(row, false, false); + expect(rows.length).toBe(3); + })); + }); + + describe('HasChildrenKey', () => { + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridLoadOnDemandHasChildrenComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + }); + + it('check expanding and collapsing a row with children', fakeAsync(() => { + let rows = TreeGridFunctions.getAllRows(fix); + const firstRow = rows[0]; + const secondRow = rows[1]; + TreeGridFunctions.verifyTreeRowIndicator(firstRow, false); + TreeGridFunctions.verifyTreeRowIndicator(secondRow, false, false); + expect(rows.length).toBe(3); + + let indicatorDiv = TreeGridFunctions.getExpansionIndicatorDiv(firstRow); + indicatorDiv.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + TreeGridFunctions.verifyTreeRowIndicator(firstRow, true); + expect(rows.length).toBe(3); + + // Wait for loading RAF on first expand to complete (RAF is in test component) + tick(16); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + TreeGridFunctions.verifyTreeRowIndicator(firstRow, false); + expect(rows.length).toBe(5); + indicatorDiv = TreeGridFunctions.getExpansionIndicatorDiv(firstRow); + indicatorDiv.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + TreeGridFunctions.verifyTreeRowIndicator(firstRow, false); + expect(rows.length).toBe(3); + })); + }); + }); + + describe('Row editing expanding/collapsing #tGrid', () => { + + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridRowEditingComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + }); + + it('Do not hide banner with collapsing a node, using UI', () => { + const rows = TreeGridFunctions.getAllRows(fix); + const cell = treeGrid.getCellByColumn(1, 'Name'); + const overlayContent = GridFunctions.getRowEditingBanner(fix); + expect(overlayContent.getBoundingClientRect().height).toBe(0); + expect(treeGrid.rowEditingOverlay.collapsed).toBeTruthy('Edit overlay should not be visible'); + + cell.editMode = true; + fix.detectChanges(); + expect(treeGrid.rowEditingOverlay.collapsed).toBeFalsy('Edit overlay should be visible'); + expect(overlayContent.getBoundingClientRect().height).toBeGreaterThan(0); + expect(overlayContent.getBoundingClientRect().top).toBeLessThan(treeGrid.nativeElement.getBoundingClientRect().top + + treeGrid.nativeElement.getBoundingClientRect().height); + + const firstRow = rows[0]; + const indicatorDiv = TreeGridFunctions.getExpansionIndicatorDiv(firstRow); + indicatorDiv.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + expect(treeGrid.rowEditingOverlay.collapsed).toBeFalsy('Edit overlay should not hide'); + expect(overlayContent.getBoundingClientRect().height).toBeGreaterThan(0); + expect(overlayContent.getBoundingClientRect().top).toBeLessThan(treeGrid.nativeElement.getBoundingClientRect().top + + treeGrid.nativeElement.getBoundingClientRect().height); + + indicatorDiv.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + expect(treeGrid.rowEditingOverlay.collapsed).toBeFalsy('Edit overlay should still be shown'); + expect(overlayContent.getBoundingClientRect().height).toBeGreaterThan(0); + expect(overlayContent.getBoundingClientRect().top).toBeLessThan(treeGrid.nativeElement.getBoundingClientRect().top + + treeGrid.nativeElement.getBoundingClientRect().height); + }); + + it('Do not hide banner with collapsing a node, using API', () => { + const cell = treeGrid.getCellByColumn(1, 'Name'); + const overlayContent = GridFunctions.getRowEditingBanner(fix); + expect(overlayContent.getBoundingClientRect().height).toBe(0); + expect(treeGrid.rowEditingOverlay.collapsed).toBeTruthy('Edit overlay should not be visible'); + + cell.editMode = true; + + fix.detectChanges(); + expect(treeGrid.rowEditingOverlay.collapsed).toBeFalsy('Edit overlay should be visible'); + expect(overlayContent.getBoundingClientRect().height).toBeGreaterThan(0); + expect(overlayContent.getBoundingClientRect().top).toBeLessThan(treeGrid.nativeElement.getBoundingClientRect().top + + treeGrid.nativeElement.getBoundingClientRect().height); + + treeGrid.toggleRow(treeGrid.getRowByIndex(0).key); + fix.detectChanges(); + expect(treeGrid.rowEditingOverlay.collapsed).toBeFalsy('Edit overlay should not hide'); + expect(overlayContent.getBoundingClientRect().height).toBeGreaterThan(0); + expect(overlayContent.getBoundingClientRect().top).toBeLessThan(treeGrid.nativeElement.getBoundingClientRect().top + + treeGrid.nativeElement.getBoundingClientRect().height); + + treeGrid.toggleRow(treeGrid.getRowByIndex(0).key); + fix.detectChanges(); + expect(treeGrid.rowEditingOverlay.collapsed).toBeFalsy('Edit overlay should still be shown'); + expect(overlayContent.getBoundingClientRect().height).toBeGreaterThan(0); + expect(overlayContent.getBoundingClientRect().top).toBeLessThan(treeGrid.nativeElement.getBoundingClientRect().top + + treeGrid.nativeElement.getBoundingClientRect().height); + }); + + // The following tests were written, + // when collapsing/expanding was not causing cell/row to exit edit mode. + // They were checking if row edit was hidden/shown. + // Later any collapse/expand of the tree grid was exiting edit mode. + // Please delete those test if you don't think this functionality will be reverted + // and cell will stay in edit mode even row is collapsed/expanded. + // K.D. 01 Mar, 2022 #10634 The functionality is restored and tests brought back + it('Do not hide parent banner while collapsing the parent node, using UI', () => { + // Test summary: Edit parent row, then collapse parent row and see that row overlay is still visible. + // Then expand again parent row and see that again it is visible. All this clicking row indicator. + const rows = TreeGridFunctions.getAllRows(fix); + const cell = treeGrid.getCellByColumn(0, 'Name'); + const overlayContent = GridFunctions.getRowEditingBanner(fix); + expect(overlayContent.getBoundingClientRect().height).toBe(0); + expect(treeGrid.rowEditingOverlay.collapsed).toBeTruthy('Edit overlay should not be visible'); + + cell.editMode = true; + fix.detectChanges(); + + const firstRow = rows[0]; + const indicatorDiv = TreeGridFunctions.getExpansionIndicatorDiv(firstRow); + indicatorDiv.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + expect(overlayContent.getBoundingClientRect().height).toBeGreaterThan(0); + expect(treeGrid.rowEditingOverlay.collapsed).toBeFalsy('Edit overlay should be visible'); + expect(overlayContent.getBoundingClientRect().top).toBeLessThan(treeGrid.nativeElement.getBoundingClientRect().top + + treeGrid.nativeElement.getBoundingClientRect().height); + + indicatorDiv.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + expect(overlayContent.getBoundingClientRect().height).toBeGreaterThan(0); + expect(treeGrid.rowEditingOverlay.collapsed).toBeFalsy('Edit overlay should be visible'); + expect(overlayContent.getBoundingClientRect().top).toBeLessThan(treeGrid.nativeElement.getBoundingClientRect().top + + treeGrid.nativeElement.getBoundingClientRect().height); + }); + + it('Do not hide parent banner while collapsing the parent node, using API', () => { + // Test summary: Edit parent row, then collapse parent row and see that row overlay is still visible. + // Then expand again parent row and see that again it is visible. All this using API. + const cell = treeGrid.getCellByColumn(1, 'Name'); + const overlayContent = GridFunctions.getRowEditingBanner(fix); + expect(overlayContent.getBoundingClientRect().height).toBe(0); + expect(treeGrid.rowEditingOverlay.collapsed).toBeTruthy('Edit overlay should not be visible'); + + cell.editMode = true; + fix.detectChanges(); + + treeGrid.toggleRow(treeGrid.getRowByIndex(0).key); + fix.detectChanges(); + + expect(overlayContent.getBoundingClientRect().height).toBeGreaterThan(0); + expect(treeGrid.rowEditingOverlay.collapsed).toBeFalsy('Edit overlay should be visible'); + expect(overlayContent.getBoundingClientRect().top).toBeLessThan(treeGrid.nativeElement.getBoundingClientRect().top + + treeGrid.nativeElement.getBoundingClientRect().height); + + treeGrid.toggleRow(treeGrid.getRowByIndex(0).key); + fix.detectChanges(); + + expect(overlayContent.getBoundingClientRect().height).toBeGreaterThan(0); + expect(treeGrid.rowEditingOverlay.collapsed).toBeFalsy('Edit overlay should be visible'); + expect(overlayContent.getBoundingClientRect().top).toBeLessThan(treeGrid.nativeElement.getBoundingClientRect().top + + treeGrid.nativeElement.getBoundingClientRect().height); + }); + + it('Do not hide banner while collapsing node that is NOT a parent one, using UI', () => { + // Test summary: Edit a row, then collapse row that is not parent of the edit row - then row overlay should be visible. + // Then expand again parent row and see that again it is visible. All this clicking row indicator. + const rows = TreeGridFunctions.getAllRows(fix); + const cell = treeGrid.getCellByColumn(9, 'Name'); + const overlayContent = GridFunctions.getRowEditingBanner(fix); + expect(overlayContent.getBoundingClientRect().height).toBe(0); + expect(treeGrid.rowEditingOverlay.collapsed).toBeTruthy('Edit overlay should not be visible'); + + cell.editMode = true; + fix.detectChanges(); + + const firstRow = rows[0]; + const indicatorDiv = TreeGridFunctions.getExpansionIndicatorDiv(firstRow); + indicatorDiv.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + expect(overlayContent.getBoundingClientRect().height).toBeGreaterThan(0); + expect(treeGrid.rowEditingOverlay.collapsed).toBeFalsy('Edit overlay should be visible'); + expect(overlayContent.getBoundingClientRect().top).toBeLessThan(treeGrid.nativeElement.getBoundingClientRect().top + + treeGrid.nativeElement.getBoundingClientRect().height); + + indicatorDiv.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + expect(overlayContent.getBoundingClientRect().height).toBeGreaterThan(0); + expect(treeGrid.rowEditingOverlay.collapsed).toBeFalsy('Edit overlay should be visible'); + expect(overlayContent.getBoundingClientRect().top).toBeLessThan(treeGrid.nativeElement.getBoundingClientRect().top + + treeGrid.nativeElement.getBoundingClientRect().height); + }); + + it('Do not hide banner while collapsing node that is NOT a parent one, using API', () => { + // Test summary: Edit a row, then collapse row that is not parent of the edit row - then row overlay should be visible. + // Then expand again parent row and see that again it is visible. All this using API. + const cell = treeGrid.getCellByColumn(9, 'Name'); + const overlayContent = GridFunctions.getRowEditingBanner(fix); + expect(overlayContent.getBoundingClientRect().height).toBe(0); + expect(treeGrid.rowEditingOverlay.collapsed).toBeTruthy('Edit overlay should not be visible'); + + cell.editMode = true; + fix.detectChanges(); + + treeGrid.toggleRow(treeGrid.getRowByIndex(0).key); + fix.detectChanges(); + + expect(overlayContent.getBoundingClientRect().height).toBeGreaterThan(0); + expect(treeGrid.rowEditingOverlay.collapsed).toBeFalsy('Edit overlay should be visible'); + expect(overlayContent.getBoundingClientRect().top).toBeLessThan(treeGrid.nativeElement.getBoundingClientRect().top + + treeGrid.nativeElement.getBoundingClientRect().height); + + treeGrid.toggleRow(treeGrid.getRowByIndex(0).key); + fix.detectChanges(); + + expect(overlayContent.getBoundingClientRect().height).toBeGreaterThan(0); + expect(treeGrid.rowEditingOverlay.collapsed).toBeFalsy('Edit overlay should be visible'); + expect(overlayContent.getBoundingClientRect().top).toBeLessThan(treeGrid.nativeElement.getBoundingClientRect().top + + treeGrid.nativeElement.getBoundingClientRect().height); + }); + + it('Hide banner while collapsing node that is NOT a parent one, but goes outside visible area, using UI', () => { + // Test summary: First make grid 300px. + // Edit a row, then expand row that is not parent of the edit row, but the expanded row has so many records + // that they push the edit row outside the visible area - then row overlay should be hidden. + // Then collapse again previously expanded row and see that again it is visible. All this clicking row indicator. + + treeGrid.height = '300px'; + treeGrid.expansionDepth = 0; + fix.detectChanges(); + + const rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(3); + expect(treeGrid.getRowByIndex(0).expanded).toBe(false); + + const firstRow = rows[0]; + const indicatorDiv = TreeGridFunctions.getExpansionIndicatorDiv(firstRow); + + let overlayContent = GridFunctions.getRowEditingBanner(fix); + + expect(overlayContent.getBoundingClientRect().height).toBe(0); + expect(treeGrid.rowEditingOverlay.collapsed).toBeTruthy('Edit overlay should not be visible'); + const cell = treeGrid.getCellByColumn(2, 'Name'); + cell.editMode = true; + fix.detectChanges(); + overlayContent = treeGrid.rowEditingOverlay.element.parentElement; + expect(overlayContent.getBoundingClientRect().height).toBeGreaterThan(0); + + indicatorDiv.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + expect(treeGrid.rowEditingOverlay.collapsed).toBeFalsy('Edit overlay should be visible'); + expect(overlayContent.getBoundingClientRect().top).toBeGreaterThan(treeGrid.nativeElement.getBoundingClientRect().top + + treeGrid.nativeElement.getBoundingClientRect().height); + + indicatorDiv.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + + expect(treeGrid.rowEditingOverlay.collapsed).toBeFalsy('Edit overlay should be visible but outside of bounds'); + expect(overlayContent.getBoundingClientRect().top).toBeLessThan(treeGrid.nativeElement.getBoundingClientRect().top + + treeGrid.nativeElement.getBoundingClientRect().height); + }); + }); + + describe('Custom expand/collapse template #tGrid', () => { + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridCustomExpandersTemplateComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + }); + + it('should allow setting custom template for expand/collapse icons', async () => { + const rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(12); + const expander = TreeGridFunctions.getExpansionIndicatorDiv(rows[0]); + expect(expander.nativeElement.innerText).toBe('EXPANDED'); + + expander.triggerEventHandler('click', new Event('click')); + fix.detectChanges(); + expect(expander.nativeElement.innerText).toBe('COLLAPSED'); + }); + }); +}); + +const verifyGridPager = (fix, rowsCount, firstCellValue, pagerText, buttonsVisibility) => { + const disabled = 'igx-button--disabled'; + const grid = fix.componentInstance.treeGrid; + const gridElement: HTMLElement = fix.nativeElement.querySelector('.igx-grid'); + + expect(grid.getCellByColumn(0, 'ID').value).toMatch(firstCellValue); + expect(grid.rowList.length).toEqual(rowsCount, 'Invalid number of rows initialized'); + + if (pagerText != null) { + expect(gridElement.querySelector('igx-page-nav')).toBeDefined(); + expect(gridElement.querySelectorAll('igx-select').length).toEqual(1); + expect(gridElement.querySelector('.igx-page-nav__text').textContent).toMatch(pagerText); + } + if (buttonsVisibility != null && buttonsVisibility.length === 4) { + const pagingButtons = GridFunctions.getPagingButtons(gridElement); + expect(pagingButtons.length).toEqual(4); + expect(pagingButtons[0].className.includes(disabled)).toBe(buttonsVisibility[0]); + expect(pagingButtons[1].className.includes(disabled)).toBe(buttonsVisibility[1]); + expect(pagingButtons[2].className.includes(disabled)).toBe(buttonsVisibility[2]); + expect(pagingButtons[3].className.includes(disabled)).toBe(buttonsVisibility[3]); + } +}; diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-filtering.spec.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-filtering.spec.ts new file mode 100644 index 00000000000..409b315df20 --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-filtering.spec.ts @@ -0,0 +1,1024 @@ + +import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxTreeGridComponent } from './public_api'; +import { IgxTreeGridFilteringComponent, IgxTreeGridFilteringESFTemplatesComponent, IgxTreeGridFilteringRowEditingComponent } from '../../../test-utils/tree-grid-components.spec'; +import { TreeGridFunctions } from '../../../test-utils/tree-grid-functions.spec'; +import { FilterMode } from 'igniteui-angular/grids/core'; +import { GridFunctions } from '../../../test-utils/grid-functions.spec'; +import { UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; +import { By } from '@angular/platform-browser'; +import { FilteringStrategy, GridColumnDataType, IgxDateFilteringOperand, IgxNumberFilteringOperand, IgxStringFilteringOperand, TreeGridFilteringStrategy, TreeGridFormattedValuesFilteringStrategy, TreeGridMatchingRecordsOnlyFilteringStrategy } from 'igniteui-angular/core'; + +const IGX_CHECKBOX_LABEL = '.igx-checkbox__label'; + +describe('IgxTreeGrid - Filtering actions #tGrid', () => { + let fix; + let grid; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxTreeGridFilteringComponent, + IgxTreeGridFilteringRowEditingComponent, + IgxTreeGridFilteringESFTemplatesComponent + ] + }).compileComponents(); + })); + + beforeEach(waitForAsync(() => { + fix = TestBed.createComponent(IgxTreeGridFilteringComponent); + fix.detectChanges(); + grid = fix.componentInstance.treeGrid; + })); + + it('should correctly filter a string column using the \'contains\' filtering conditions', () => { + for (let i = 0; i < 5; i++) { + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(i))).toEqual(true); + } + + grid.filter('Name', 'an', IgxStringFilteringOperand.instance().condition('contains'), true); + fix.detectChanges(); + + expect(TreeGridFunctions.checkRowIsGrayedOut(grid.gridAPI.get_row_by_index(0))).toEqual(true); + expect(grid.getCellByColumn(0, 'Name').value).toEqual('John Winchester'); + + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(1))).toEqual(true); + expect(grid.getCellByColumn(1, 'Name').value).toEqual('Michael Langdon'); + + expect(TreeGridFunctions.checkRowIsGrayedOut(grid.gridAPI.get_row_by_index(2))).toEqual(true); + expect(grid.getCellByColumn(2, 'Name').value).toEqual('Monica Reyes'); + + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(3))).toEqual(true); + expect(grid.getCellByColumn(3, 'Name').value).toEqual('Roland Mendel'); + + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(4))).toEqual(true); + expect(grid.getCellByColumn(4, 'Name').value).toEqual('Ana Sanders'); + + grid.clearFilter(); + fix.detectChanges(); + + for (let i = 0; i < 5; i++) { + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(i))).toEqual(true); + } + }); + + it('should correctly filter a string column using the \'endswith\' filtering conditions', () => { + for (let i = 0; i < 5; i++) { + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(i))).toEqual(true); + } + + grid.filter('Name', 'n', IgxStringFilteringOperand.instance().condition('endsWith'), true); + fix.detectChanges(); + + expect(TreeGridFunctions.checkRowIsGrayedOut(grid.gridAPI.get_row_by_index(0))).toEqual(true); + expect(grid.getCellByColumn(0, 'Name').value).toEqual('John Winchester'); + + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(1))).toEqual(true); + expect(grid.getCellByColumn(1, 'Name').value).toEqual('Michael Langdon'); + + expect(TreeGridFunctions.checkRowIsGrayedOut(grid.gridAPI.get_row_by_index(2))).toEqual(true); + expect(grid.getCellByColumn(2, 'Name').value).toEqual('Ana Sanders'); + + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(3))).toEqual(true); + expect(grid.getCellByColumn(3, 'Name').value).toEqual('Laurence Johnson'); + + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(4))).toEqual(true); + expect(grid.getCellByColumn(4, 'Name').value).toEqual('Victoria Lincoln'); + + grid.clearFilter(); + fix.detectChanges(); + + for (let i = 0; i < 5; i++) { + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(i))).toEqual(true); + } + }); + + it('should correctly filter a number column using the \'greaterThan\' filtering conditions', () => { + for (let i = 0; i < 5; i++) { + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(i))).toEqual(true); + } + + grid.filter('ID', 500, IgxNumberFilteringOperand.instance().condition('greaterThan')); + fix.detectChanges(); + + expect(TreeGridFunctions.checkRowIsGrayedOut(grid.gridAPI.get_row_by_index(0))).toEqual(true); + expect(grid.getCellByColumn(0, 'ID').value).toEqual(147); + + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(1))).toEqual(true); + expect(grid.getCellByColumn(1, 'ID').value).toEqual(957); + + expect(TreeGridFunctions.checkRowIsGrayedOut(grid.gridAPI.get_row_by_index(2))).toEqual(true); + expect(grid.getCellByColumn(2, 'ID').value).toEqual(317); + + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(3))).toEqual(true); + expect(grid.getCellByColumn(3, 'ID').value).toEqual(711); + + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(4))).toEqual(true); + expect(grid.getCellByColumn(4, 'ID').value).toEqual(998); + + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(5))).toEqual(true); + expect(grid.getCellByColumn(5, 'ID').value).toEqual(847); + + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(6))).toEqual(true); + expect(grid.getCellByColumn(6, 'ID').value).toEqual(663); + + grid.clearFilter(); + fix.detectChanges(); + + for (let i = 0; i < 5; i++) { + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(i))).toEqual(true); + } + }); + + it('should correctly filter a number column using the \'lessThan\' filtering conditions', () => { + for (let i = 0; i < 5; i++) { + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(i))).toEqual(true); + } + + grid.filter('ID', 200, IgxNumberFilteringOperand.instance().condition('lessThan')); + fix.detectChanges(); + + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(0))).toEqual(true); + expect(grid.getCellByColumn(0, 'ID').value).toEqual(147); + + expect(TreeGridFunctions.checkRowIsGrayedOut(grid.gridAPI.get_row_by_index(1))).toEqual(true); + expect(grid.getCellByColumn(1, 'ID').value).toEqual(847); + + expect(TreeGridFunctions.checkRowIsGrayedOut(grid.gridAPI.get_row_by_index(2))).toEqual(true); + expect(grid.getCellByColumn(2, 'ID').value).toEqual(663); + + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(3))).toEqual(true); + expect(grid.getCellByColumn(3, 'ID').value).toEqual(141); + + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(4))).toEqual(true); + expect(grid.getCellByColumn(4, 'ID').value).toEqual(19); + + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(5))).toEqual(true); + expect(grid.getCellByColumn(5, 'ID').value).toEqual(15); + + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(6))).toEqual(true); + expect(grid.getCellByColumn(6, 'ID').value).toEqual(17); + + grid.clearFilter(); + fix.detectChanges(); + + for (let i = 0; i < 5; i++) { + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(i))).toEqual(true); + } + }); + + it('should correctly filter a date column using the \'before\' filtering conditions', () => { + for (let i = 0; i < 5; i++) { + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(i))).toEqual(true); + } + + grid.filter('HireDate', new Date(2010, 6, 25), IgxDateFilteringOperand.instance().condition('before')); + fix.detectChanges(); + + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(0))).toEqual(true); + expect(grid.getCellByColumn(0, 'ID').value).toEqual(147); + + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(1))).toEqual(true); + expect(grid.getCellByColumn(1, 'ID').value).toEqual(957); + + expect(TreeGridFunctions.checkRowIsGrayedOut(grid.gridAPI.get_row_by_index(2))).toEqual(true); + expect(grid.getCellByColumn(2, 'ID').value).toEqual(317); + + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(3))).toEqual(true); + expect(grid.getCellByColumn(3, 'ID').value).toEqual(998); + + expect(TreeGridFunctions.checkRowIsGrayedOut(grid.gridAPI.get_row_by_index(4))).toEqual(true); + expect(grid.getCellByColumn(4, 'ID').value).toEqual(847); + + expect(TreeGridFunctions.checkRowIsGrayedOut(grid.gridAPI.get_row_by_index(5))).toEqual(true); + expect(grid.getCellByColumn(5, 'ID').value).toEqual(663); + + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(6))).toEqual(true); + expect(grid.getCellByColumn(6, 'ID').value).toEqual(141); + + grid.clearFilter(); + fix.detectChanges(); + + for (let i = 0; i < 5; i++) { + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(i))).toEqual(true); + } + }); + + it('should correctly filter a date column using the \'after\' filtering conditions', () => { + for (let i = 0; i < 5; i++) { + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(i))).toEqual(true); + } + + grid.filter('HireDate', new Date(2015, 6, 25), IgxDateFilteringOperand.instance().condition('after')); + fix.detectChanges(); + + expect(TreeGridFunctions.checkRowIsGrayedOut(grid.gridAPI.get_row_by_index(0))).toEqual(true); + expect(grid.getCellByColumn(0, 'ID').value).toEqual(147); + + expect(TreeGridFunctions.checkRowIsGrayedOut(grid.gridAPI.get_row_by_index(1))).toEqual(true); + expect(grid.getCellByColumn(1, 'ID').value).toEqual(317); + + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(2))).toEqual(true); + expect(grid.getCellByColumn(2, 'ID').value).toEqual(711); + + expect(TreeGridFunctions.checkRowIsGrayedOut(grid.gridAPI.get_row_by_index(3))).toEqual(true); + expect(grid.getCellByColumn(3, 'ID').value).toEqual(847); + + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(4))).toEqual(true); + expect(grid.getCellByColumn(4, 'ID').value).toEqual(663); + + expect(TreeGridFunctions.checkRowIsGrayedOut(grid.gridAPI.get_row_by_index(5))).toEqual(true); + expect(grid.getCellByColumn(5, 'ID').value).toEqual(17); + + expect(TreeGridFunctions.checkRowIsGrayedOut(grid.gridAPI.get_row_by_index(6))).toEqual(true); + expect(grid.getCellByColumn(6, 'ID').value).toEqual(12); + + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(7))).toEqual(true); + expect(grid.getCellByColumn(7, 'ID').value).toEqual(109); + + grid.clearFilter(); + fix.detectChanges(); + + for (let i = 0; i < 5; i++) { + expect(TreeGridFunctions.checkRowIsNotGrayedOut(grid.gridAPI.get_row_by_index(i))).toEqual(true); + } + }); + + it('should allow row collapsing after filtering is applied', () => { + grid.filter('Name', 'an', IgxStringFilteringOperand.instance().condition('contains'), true); + fix.detectChanges(); + + // check initial rows count after applying filtering + let rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(10); + + // collapse first row + grid.toggleRow(grid.getRowByIndex(0).key); + fix.detectChanges(); + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(7); + }); + + it('should update expand indicator after filtering is applied', () => { + grid.filter('ID', 147, IgxStringFilteringOperand.instance().condition('equals'), true); + fix.detectChanges(); + + let rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(1); + TreeGridFunctions.verifyTreeRowExpandIndicatorVisibility(rows[0], 'hidden'); + + grid.clearFilter('ID'); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + TreeGridFunctions.verifyTreeRowExpandIndicatorVisibility(rows[0]); + TreeGridFunctions.verifyTreeRowHasExpandedIcon(rows[0]); + }); + + it('should filter cell by its formatted data when using FormattedValuesFilteringStrategy', () => { + const formattedStrategy = new TreeGridFormattedValuesFilteringStrategy(); + grid.filterStrategy = formattedStrategy; + const idFormatter = (val: number): number => val % 2; + grid.columnList.get(0).formatter = idFormatter; + fix.detectChanges(); + + grid.filter('ID', 0, IgxNumberFilteringOperand.instance().condition('equals')); + fix.detectChanges(); + let rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toEqual(5, 'Wrong rows count'); + + grid.filter('ID', 1, IgxNumberFilteringOperand.instance().condition('equals')); + fix.detectChanges(); + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toEqual(17, 'Wrong rows count'); + }); + + it('\'Blanks\' should be always visible', fakeAsync(() => { + const formattedStrategy = new TreeGridFormattedValuesFilteringStrategy(); + grid.filterStrategy = formattedStrategy; + const idFormatter = (val: Date): string => { + if (val) { + if (val.getFullYear() <= 2010) { + return 'Senior'; + } else if (val.getFullYear() < 2014) { + return 'Middle'; + } else { + return 'Junior'; + } + } else { + return null; + } + }; + const newData = SampleTestData.employeeTreeData(); + newData[0].HireDate = null; + newData[1].HireDate = null; + + grid.data = newData; + grid.allowFiltering = true; + grid.filterMode = FilterMode.excelStyleFilter; + grid.columnList.get(2).formatter = idFormatter; + grid.columnList.get(2).dataType = 'string'; + fix.detectChanges(); + + GridFunctions.clickExcelFilterIcon(fix, 'HireDate'); + tick(); + fix.detectChanges(); + let searchComponent = GridFunctions.getExcelFilteringSearchComponent(fix, null, 'igx-tree-grid'); + let items = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent); + expect(items.length).toBe(5); + expect(items[1].textContent).toBe(' (Blanks) '); + + const checkboxes = GridFunctions.getExcelStyleFilteringCheckboxes(fix, null, 'igx-tree-grid'); + checkboxes[0].click(); + checkboxes[2].click(); + tick(); + GridFunctions.clickApplyExcelStyleFiltering(fix, null, 'igx-tree-grid'); + fix.detectChanges(); + + GridFunctions.clickExcelFilterIcon(fix, 'HireDate'); + tick(); + fix.detectChanges(); + searchComponent = GridFunctions.getExcelFilteringSearchComponent(fix, null, 'igx-tree-grid'); + items = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent); + expect(items.length).toBe(5); + expect(items[1].textContent).toBe(' (Blanks) '); + })); + + describe('Tree grid ESF', () => { + let tGrid: IgxTreeGridComponent; + + beforeEach(waitForAsync(() => { + fix = TestBed.createComponent(IgxTreeGridFilteringComponent); + tGrid = fix.componentInstance.treeGrid; + + const hierarchicalFilterStrategy = new TreeGridFilteringStrategy(['ID']); + tGrid.filterStrategy = hierarchicalFilterStrategy; + tGrid.allowFiltering = true; + tGrid.filterMode = FilterMode.excelStyleFilter; + fix.detectChanges(); + })); + + it('Should render and expand tree nodes correctly', fakeAsync(() => { + GridFunctions.clickExcelFilterIcon(fix, 'ID'); + fix.detectChanges(); + tick(); + + let treeItems = GridFunctions.getExcelStyleSearchComponentTreeNodes(fix, null); + expect(treeItems.length).toBe(4, 'incorrect rendered tree node count'); + + GridFunctions.clickExcelTreeNodeExpandIcon(fix, 0); + fix.detectChanges(); + tick(); + + treeItems = GridFunctions.getExcelStyleSearchComponentTreeNodes(fix, null); + expect(treeItems.length).toBe(6, 'incorrect rendered tree node count'); + })); + + it('Should change arrow icon on expand', fakeAsync(() => { + GridFunctions.clickExcelFilterIcon(fix, 'ID'); + fix.detectChanges(); + tick(); + + const icon = GridFunctions.getExcelFilterTreeNodeIcon(fix, 0); + let iconText = icon.children[0].innerText; + expect(iconText).toBe('chevron_right', 'incorrect rendered icon'); + + GridFunctions.clickExcelTreeNodeExpandIcon(fix, 0); + fix.detectChanges(); + tick(); + + iconText = icon.children[0].innerText; + expect(iconText).toBe('expand_more', 'incorrect rendered icon'); + })); + + it('Should display Select All item', fakeAsync(() => { + GridFunctions.clickExcelFilterIcon(fix, 'ID'); + fix.detectChanges(); + tick(); + + const label = fix.debugElement.queryAll(By.css(IGX_CHECKBOX_LABEL))[0].nativeElement; + expect(label.innerText).toBe('Select All'); + })); + + it('Should display "Add current selection to filter" item correctly', fakeAsync(() => { + GridFunctions.clickExcelFilterIcon(fix, 'ID'); + fix.detectChanges(); + tick(); + + const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix, null, 'igx-tree-grid'); + const inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix, searchComponent, 'igx-tree-grid'); + UIInteractions.clickAndSendInputElementValue(inputNativeElement, '1', fix); + fix.detectChanges(); + tick(); + + const label = fix.debugElement.queryAll(By.css(IGX_CHECKBOX_LABEL))[1].nativeElement; + expect(label.innerText).toBe('Add current selection to filter'); + })); + + it('Should set indeterminate state correctly', fakeAsync(() => { + GridFunctions.clickExcelFilterIcon(fix, 'ID'); + fix.detectChanges(); + tick(); + + GridFunctions.clickExcelTreeNodeExpandIcon(fix, 0); + fix.detectChanges(); + tick(); + + let excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix, 'igx-tree-grid'); + + let checkboxes: any[] = GridFunctions.getExcelStyleFilteringCheckboxes(fix, excelMenu, 'igx-tree-grid'); + checkboxes[2].parentElement.click(); + fix.detectChanges(); + tick(); + + checkboxes = Array.from(GridFunctions.getExcelStyleFilteringCheckboxes(fix, excelMenu, 'igx-tree-grid')); + expect(checkboxes[0].indeterminate && !checkboxes[0].checked).toBe(true); + expect(checkboxes[1].indeterminate && !checkboxes[1].checked).toBe(true); + expect(checkboxes[2].checked).toBe(false); + + // Click Select All twice to deselect all items and check only one child item + checkboxes[0].click(); + checkboxes[0].click(); + fix.detectChanges(); + tick(); + + checkboxes[2].click(); + fix.detectChanges(); + tick(); + + // Apply changes and open excel style filter dialog + GridFunctions.clickApplyExcelStyleFiltering(fix, null, 'igx-tree-grid'); + fix.detectChanges(); + tick(); + + GridFunctions.clickExcelFilterIcon(fix, 'ID'); + fix.detectChanges(); + tick(); + + // Verify Select All is indeterminate + excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix, 'igx-tree-grid'); + checkboxes = Array.from(GridFunctions.getExcelStyleFilteringCheckboxes(fix, excelMenu, 'igx-tree-grid')); + expect(checkboxes[0].indeterminate).toBe(true); + })); + + it('Should filter items and clear the search component correctly', fakeAsync(() => { + GridFunctions.clickExcelFilterIcon(fix, 'ID'); + fix.detectChanges(); + tick(); + + const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix, null, 'igx-tree-grid'); + + let treeItems = GridFunctions.getExcelStyleSearchComponentTreeNodes(fix, searchComponent); + expect(treeItems.length).toBe(4, 'incorrect rendered items count'); + + const inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix, searchComponent, 'igx-tree-grid'); + UIInteractions.clickAndSendInputElementValue(inputNativeElement, '6', fix); + fix.detectChanges(); + tick(); + + treeItems = GridFunctions.getExcelStyleSearchComponentTreeNodes(fix, searchComponent); + expect(treeItems.length).toBe(2, 'incorrect rendered items count'); + + const clearIcon: any = Array.from(searchComponent.querySelectorAll('igx-icon')) + .find((icon: any) => icon.innerText === 'clear'); + + clearIcon.click(); + fix.detectChanges(); + + treeItems = GridFunctions.getExcelStyleSearchComponentTreeNodes(fix, searchComponent); + expect(treeItems.length).toBe(4, 'incorrect rendered items count'); + })); + + it('Should filter items and clear filters correctly', fakeAsync(() => { + let gridCellValues = GridFunctions.getColumnCells(fix, 'ID', 'igx-tree-grid-cell') + .map(c => c.nativeElement.innerText) + .sort(); + + expect(gridCellValues.length).toEqual(18); + + GridFunctions.clickExcelFilterIcon(fix, 'ID'); + fix.detectChanges(); + tick(); + + let searchComponent = GridFunctions.getExcelStyleSearchComponent(fix, null, 'igx-tree-grid'); + let treeItems = GridFunctions.getExcelStyleSearchComponentTreeNodes(fix, searchComponent); + expect(treeItems.length).toBe(4, 'incorrect rendered items count'); + + const inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix, searchComponent, 'igx-tree-grid'); + UIInteractions.clickAndSendInputElementValue(inputNativeElement, '8', fix); + fix.detectChanges(); + tick(); + + treeItems = GridFunctions.getExcelStyleSearchComponentTreeNodes(fix, searchComponent); + expect(treeItems.length).toBe(4, 'incorrect rendered items count'); + + GridFunctions.clickApplyExcelStyleFiltering(fix, null, 'igx-tree-grid'); + fix.detectChanges(); + tick(); + + gridCellValues = GridFunctions.getColumnCells(fix, 'ID', 'igx-tree-grid-cell') + .map(c => c.nativeElement.innerText) + .sort(); + + expect(gridCellValues.length).toEqual(7); + + GridFunctions.clickExcelFilterIcon(fix, 'ID'); + fix.detectChanges(); + tick(); + + const excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix, 'igx-tree-grid'); + const btn = GridFunctions.getExcelFilteringClearFiltersComponent(fix, excelMenu); + const clearIcon: any = btn.querySelector('igx-icon'); + clearIcon.click(); + fix.detectChanges(); + tick(); + + searchComponent = GridFunctions.getExcelStyleSearchComponent(fix, null, 'igx-tree-grid'); + treeItems = GridFunctions.getExcelStyleSearchComponentTreeNodes(fix, searchComponent); + expect(treeItems.length).toBe(4, 'incorrect rendered tree node items count'); + + GridFunctions.clickApplyExcelStyleFiltering(fix, null, 'igx-tree-grid'); + fix.detectChanges(); + tick(); + + gridCellValues = GridFunctions.getColumnCells(fix, 'ID', 'igx-tree-grid-cell') + .map(c => c.nativeElement.innerText) + .sort(); + + expect(gridCellValues.length).toEqual(18, 'incorrect rendered grid items count'); + })); + + it('Should update checkboxes after clearing column filters correctly', fakeAsync(() => { + GridFunctions.clickExcelFilterIcon(fix, 'ID'); + fix.detectChanges(); + tick(); + + let searchComponent = GridFunctions.getExcelStyleSearchComponent(fix, null, 'igx-tree-grid'); + + let inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix, searchComponent, 'igx-tree-grid'); + UIInteractions.clickAndSendInputElementValue(inputNativeElement, '8', fix); + fix.detectChanges(); + tick(); + + GridFunctions.clickApplyExcelStyleFiltering(fix, null, 'igx-tree-grid'); + fix.detectChanges(); + tick(); + + GridFunctions.clickExcelFilterIcon(fix, 'ID'); + fix.detectChanges(); + tick(); + + const excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix, 'igx-tree-grid'); + const btn = GridFunctions.getExcelFilteringClearFiltersComponent(fix, excelMenu); + const clearIcon: any = btn.querySelector('igx-icon'); + clearIcon.click(); + fix.detectChanges(); + tick(); + + let checkboxes: any[] = Array.from(GridFunctions.getExcelStyleFilteringCheckboxes(fix, excelMenu, 'igx-tree-grid')); + checkboxes.forEach(ch => expect(ch.checked).toBe(true, 'incorrect checkbox state')); + + searchComponent = GridFunctions.getExcelStyleSearchComponent(fix, null, 'igx-tree-grid'); + inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix, searchComponent, 'igx-tree-grid'); + UIInteractions.clickAndSendInputElementValue(inputNativeElement, '8', fix); + fix.detectChanges(); + tick(); + + checkboxes = Array.from(GridFunctions.getExcelStyleFilteringCheckboxes(fix, excelMenu, 'igx-tree-grid')); + const addToFilterCheckbox = checkboxes.splice(1,1)[0]; + + expect(addToFilterCheckbox.checked).toBe(false, 'incorrect checkbox state') + checkboxes.forEach(ch => expect(ch.checked).toBe(true, 'incorrect checkbox state')); + })); + + it('Should filter tree grid correctly', fakeAsync(() => { + GridFunctions.clickExcelFilterIcon(fix, 'ID'); + fix.detectChanges(); + tick(); + + const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix, null, 'igx-tree-grid'); + const inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix, searchComponent, 'igx-tree-grid'); + + UIInteractions.clickAndSendInputElementValue(inputNativeElement, '6', fix); + fix.detectChanges(); + tick(); + + const treeItems = GridFunctions.getExcelStyleSearchComponentTreeNodes(fix, searchComponent); + expect(treeItems.length).toEqual(2, 'incorrect rendered items count'); + + GridFunctions.clickApplyExcelStyleFiltering(fix, null, 'igx-tree-grid'); + fix.detectChanges(); + tick(); + + const gridCellValues = GridFunctions.getColumnCells(fix, 'ID', 'igx-tree-grid-cell') + .map(c => c.nativeElement.innerText) + .sort(); + + expect(gridCellValues.length).toEqual(3); + + GridFunctions.clickExcelFilterIcon(fix, 'ID'); + fix.detectChanges(); + tick(); + + const excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix, 'igx-tree-grid'); + const checkboxes: any[] = Array.from(GridFunctions.getExcelStyleFilteringCheckboxes(fix, excelMenu, 'igx-tree-grid')); + expect(!checkboxes[1].checked && !checkboxes[2].checked && !checkboxes[3].checked && checkboxes[4].indeterminate).toBe(true); + })); + + it('Should add list items to current filtered items when "Add current selection to filter" is selected', fakeAsync(() => { + GridFunctions.clickExcelFilterIcon(fix, 'ID'); + fix.detectChanges(); + tick(); + + let searchComponent = GridFunctions.getExcelStyleSearchComponent(fix, null, 'igx-tree-grid'); + let inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix, searchComponent, 'igx-tree-grid'); + + UIInteractions.clickAndSendInputElementValue(inputNativeElement, '6', fix); + fix.detectChanges(); + tick(); + + GridFunctions.clickApplyExcelStyleFiltering(fix, null, 'igx-tree-grid'); + fix.detectChanges(); + tick(); + + let gridCellValues = GridFunctions.getColumnCells(fix, 'ID', 'igx-tree-grid-cell') + .map(c => c.nativeElement.innerText) + .sort(); + + expect(gridCellValues.length).toEqual(3); + + GridFunctions.clickExcelFilterIcon(fix, 'ID'); + fix.detectChanges(); + tick(); + + searchComponent = GridFunctions.getExcelStyleSearchComponent(fix, null, 'igx-tree-grid'); + inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix, searchComponent, 'igx-tree-grid'); + + UIInteractions.clickAndSendInputElementValue(inputNativeElement, '15', fix); + fix.detectChanges(); + tick(); + + const excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix, 'igx-tree-grid'); + const checkbox = GridFunctions.getExcelStyleFilteringCheckboxes(fix, excelMenu, 'igx-tree-grid')[1]; + checkbox.click(); + fix.detectChanges(); + tick(); + + GridFunctions.clickApplyExcelStyleFiltering(fix, null, 'igx-tree-grid'); + fix.detectChanges(); + tick(); + + gridCellValues = GridFunctions.getColumnCells(fix, 'ID', 'igx-tree-grid-cell') + .map(c => c.nativeElement.innerText) + .sort(); + + expect(gridCellValues.length).toEqual(5); + })); + + it('Should display message when search results are empty', fakeAsync(() => { + GridFunctions.clickExcelFilterIcon(fix, 'ID'); + fix.detectChanges(); + tick(); + + const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix, null, 'igx-tree-grid'); + const inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix, searchComponent, 'igx-tree-grid'); + + UIInteractions.clickAndSendInputElementValue(inputNativeElement, '77', fix); + fix.detectChanges(); + tick(); + + const emptyTextEl = searchComponent.querySelector('.igx-excel-filter__empty'); + expect(emptyTextEl.innerText).toEqual('No matches'); + })); + + it('Should display message when there is no data', fakeAsync(() => { + const data = tGrid.data; + tGrid.data = []; + GridFunctions.clickExcelFilterIcon(fix, 'ID'); + fix.detectChanges(); + tick(); + + let searchComponent = GridFunctions.getExcelStyleSearchComponent(fix, null, 'igx-tree-grid'); + let emptyTextEl = searchComponent.querySelector('.igx-excel-filter__empty'); + expect(emptyTextEl.innerText).toEqual('No matches'); + + tGrid.data = data; + GridFunctions.clickExcelFilterIcon(fix, 'ID'); + fix.detectChanges(); + tick(); + + searchComponent = GridFunctions.getExcelStyleSearchComponent(fix, null, 'igx-tree-grid'); + emptyTextEl = searchComponent.querySelector('.igx-excel-filter__empty'); + expect(emptyTextEl).toBeFalsy(); + + })); + + it('Should display message when the last row is deleted', fakeAsync(() => { + tGrid.data = []; + tGrid.primaryKey = 'ID'; + const row = { + ID: 0, + Name: 'John Winchester', + HireDate: new Date(2008, 3, 20), + Age: 55, + OnPTO: false, + Employees: [] + }; + tGrid.addRow(row); + fix.detectChanges(); + + GridFunctions.clickExcelFilterIcon(fix, 'ID'); + fix.detectChanges(); + tick(); + + let searchComponent = GridFunctions.getExcelStyleSearchComponent(fix, null, 'igx-tree-grid'); + let emptyTextEl = searchComponent.querySelector('.igx-excel-filter__empty'); + expect(emptyTextEl).toBeFalsy(); + + tGrid.deleteRowById(0); + GridFunctions.clickExcelFilterIcon(fix, 'ID'); + fix.detectChanges(); + tick(); + + searchComponent = GridFunctions.getExcelStyleSearchComponent(fix, null, 'igx-tree-grid'); + emptyTextEl = searchComponent.querySelector('.igx-excel-filter__empty'); + expect(emptyTextEl.innerText).toEqual('No matches'); + })); + + it('Should not throw console error when number column with dataType string is filtered.', fakeAsync(() => { + tGrid.columns[0].dataType = GridColumnDataType.String; + fix.detectChanges(); + spyOn(console, 'error'); + + GridFunctions.clickExcelFilterIcon(fix, 'ID'); + fix.detectChanges(); + tick(); + + const excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix, 'igx-tree-grid'); + const checkboxes: any[] = Array.from(GridFunctions.getExcelStyleFilteringCheckboxes(fix, excelMenu, 'igx-tree-grid')); + + checkboxes[2].click(); + tick(); + fix.detectChanges(); + + GridFunctions.clickApplyExcelStyleFiltering(fix, null, 'igx-tree-grid'); + fix.detectChanges(); + tick(); + + expect(console.error).not.toHaveBeenCalled(); + })); + }); + + describe('Tree grid ESF templates', () => { + let tGrid: IgxTreeGridComponent; + + beforeEach(waitForAsync(() => { + fix = TestBed.createComponent(IgxTreeGridFilteringESFTemplatesComponent); + tGrid = fix.componentInstance.treeGrid; + + const hierarchicalFilterStrategy = new TreeGridFilteringStrategy(['ID']); + tGrid.filterStrategy = hierarchicalFilterStrategy; + tGrid.allowFiltering = true; + tGrid.filterMode = FilterMode.excelStyleFilter; + fix.detectChanges(); + })); + + it('Should use custom templates for ESF components instead of default ones.', fakeAsync(() => { + GridFunctions.clickExcelFilterIcon(fix, 'ID'); + fix.detectChanges(); + tick(); + + const excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix, 'igx-tree-grid'); + + expect(excelMenu.querySelector('igx-excel-style-column-operations')).not.toBeNull(); + expect(excelMenu.querySelector('igx-excel-style-filter-operations')).not.toBeNull(); + expect(GridFunctions.getExcelFilteringSortComponent(fix, excelMenu)).not.toBeNull(); + expect(GridFunctions.getExcelFilteringSearchComponent(fix, excelMenu)).not.toBeNull(); + + expect(GridFunctions.getExcelFilteringHeaderComponent(fix, excelMenu)).toBeNull(); + expect(GridFunctions.getExcelFilteringMoveComponent(fix, excelMenu)).toBeNull(); + expect(GridFunctions.getExcelFilteringPinComponent(fix, excelMenu)).toBeNull(); + expect(GridFunctions.getExcelFilteringHideComponent(fix, excelMenu)).toBeNull(); + expect(GridFunctions.getExcelFilteringColumnSelectionComponent(fix, excelMenu)).toBeNull(); + expect(GridFunctions.getExcelFilteringClearFiltersComponent(fix, excelMenu)).toBeNull(); + expect(GridFunctions.getExcelFilteringConditionalFilterComponent(fix, excelMenu)).toBeNull(); + })); + + it('Should filter tree grid with templates correctly', fakeAsync(() => { + GridFunctions.clickExcelFilterIcon(fix, 'ID'); + fix.detectChanges(); + tick(); + + const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix, null, 'igx-tree-grid'); + const inputNativeElement = GridFunctions.getExcelStyleSearchComponentInput(fix, searchComponent, 'igx-tree-grid'); + + UIInteractions.clickAndSendInputElementValue(inputNativeElement, '6', fix); + fix.detectChanges(); + tick(); + + const treeItems = GridFunctions.getExcelStyleSearchComponentTreeNodes(fix, searchComponent); + expect(treeItems.length).toEqual(2, 'incorrect rendered items count'); + + GridFunctions.clickApplyExcelStyleFiltering(fix, null, 'igx-tree-grid'); + fix.detectChanges(); + tick(); + + const gridCellValues = GridFunctions.getColumnCells(fix, 'ID', 'igx-tree-grid-cell') + .map(c => c.nativeElement.innerText) + .sort(); + + expect(gridCellValues.length).toEqual(3); + + GridFunctions.clickExcelFilterIcon(fix, 'ID'); + fix.detectChanges(); + tick(); + + const excelMenu = GridFunctions.getExcelStyleFilteringComponent(fix, 'igx-tree-grid'); + const checkboxes: any[] = Array.from(GridFunctions.getExcelStyleFilteringCheckboxes(fix, excelMenu, 'igx-tree-grid')); + expect(!checkboxes[1].checked && !checkboxes[2].checked && !checkboxes[3].checked && checkboxes[4].indeterminate).toBe(true); + })); + + it('Should use custom excel style filter icon instead of default one.', () => { + const header = GridFunctions.getColumnHeader('ID', fix); + fix.detectChanges(); + const icon = GridFunctions.getHeaderFilterIcon(header); + fix.detectChanges(); + expect(icon).not.toBeNull(); + expect(icon.nativeElement.textContent.toLowerCase().trim()).toBe('filter_alt'); + }); + }); + + describe('Filtering: Row editing', () => { + let treeGrid: IgxTreeGridComponent; + beforeEach(waitForAsync(() => { + fix = TestBed.createComponent(IgxTreeGridFilteringRowEditingComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + })); + + it('should remove a filtered parent row from the filtered list', fakeAsync(() => { + const newCellValue = 'John McJohn'; + treeGrid.filter('Name', 'in', IgxStringFilteringOperand.instance().condition('contains'), true); + tick(); + + // modify the first filtered node + const targetCell = treeGrid.getCellByColumn(0, 'Name'); + targetCell.update(newCellValue); + tick(); + fix.detectChanges(); + + // verify that the edited row was removed from the filtered list + expect(treeGrid.filteredData.length).toBe(1); + + treeGrid.clearFilter(); + tick(); + fix.detectChanges(); + + // check if the changes made were preserved + expect(treeGrid.data.filter(c => c.Name === newCellValue).length).toBeGreaterThan(0); + })); + + it('should not remove an edited parent node from the filtered list if it has a child node that meets the criteria', + fakeAsync(() => { + const newCellValue = 'John McJohn'; + treeGrid.filter('Name', 'on', IgxStringFilteringOperand.instance().condition('contains'), true); + tick(); + + // modify a parent node which has a child that matches the filtering condition + const targetCell = treeGrid.getCellByColumn(0, 'Name'); + targetCell.update(newCellValue); + tick(); + fix.detectChanges(); + + // verify that the parent node is still in the filtered list + expect(treeGrid.filteredData.filter(p => p.Name === targetCell.value).length).toBeGreaterThan(0); + + treeGrid.clearFilter(); + tick(); + fix.detectChanges(); + + // verify the changes were preserved after the filtering is removed + expect(treeGrid.data.filter(p => p.Name === targetCell.value).length).toBeGreaterThan(0); + })); + + it(`should remove the parent node from the filtered list if + its only matching child is modified and does not match the filtering condition anymore`, + fakeAsync(() => { + const newCellValue = 'John McJohn'; + const filterValue = 'Langdon'; + treeGrid.filter('Name', filterValue, IgxStringFilteringOperand.instance().condition('contains'), true); + tick(); + + // modify the first child node that meets the filtering condition + const targetCell = treeGrid.getCellByColumn(1, 'Name'); + targetCell.update(newCellValue); + tick(); + fix.detectChanges(); + + // verify that the parent node is no longer in the filtered list + expect(grid.filteredData).toBeFalsy(); + + treeGrid.clearFilter(); + tick(); + fix.detectChanges(); + + // verify that there is a parent which contains the updated child node + const filteredParentNodes = treeGrid.data.filter(n => n.Employees.filter(e => e.Name === newCellValue).length !== 0); + + // if there are any parent nodes in this collection then the changes were preserved + expect(filteredParentNodes.length).toBeGreaterThan(0); + })); + + it('should not remove a parent node from the filtered list if it has at least one child node which matches the filtering condition', + fakeAsync(() => { + const newCellValue = 'Peter Peterson'; + treeGrid.filter('Name', 'h', IgxStringFilteringOperand.instance().condition('contains'), true); + tick(); + + // modify the first child node which meets the filtering condition + const targetCell = treeGrid.getCellByColumn(1, 'Name'); + targetCell.update(newCellValue); + tick(); + fix.detectChanges(); + + // check if the edited child row is removed + expect(treeGrid.filteredData.filter(c => c.Name === newCellValue).length).toBe(0); + + // check if the parent which contains the edited row is not removed + expect(treeGrid.filteredData.filter(p => p.Name === targetCell.row.parent.data.Name).length).toBeGreaterThan(0); + + treeGrid.clearFilter(); + tick(); + fix.detectChanges(); + + // verify that there is a parent which contains the updated child node + const filteredParentNodes = treeGrid.data.filter(n => n.Employees.filter(e => e.Name === newCellValue).length !== 0); + + // if there are any parent nodes in this collection then the changes were preserved + expect(filteredParentNodes.length).toBeGreaterThan(0); + })); + + it('should be able to apply custom filter strategy', fakeAsync(() => { + expect(treeGrid.filterStrategy).toBeDefined(); + treeGrid.filter('Name', 'd', IgxStringFilteringOperand.instance().condition('contains'), true); + tick(); + fix.detectChanges(); + + expect(treeGrid.rowList.length).toBe(9); + + treeGrid.clearFilter(); + fix.detectChanges(); + + const customFilter = new CustomTreeGridFilterStrategy(); + // apply the same filter condition but with custu + treeGrid.filterStrategy = customFilter; + fix.detectChanges(); + + treeGrid.filter('Name', 'd', IgxStringFilteringOperand.instance().condition('contains'), true); + tick(); + fix.detectChanges(); + + expect(treeGrid.rowList.length).toBe(4); + expect(treeGrid.filteredData.map(rec => rec.ID)).toEqual([ 847, 225, 663, 141]); + })); + + it('should display only the filtered records when using TreeGridMatchingRecordsOnlyFilteringStrategy', fakeAsync(() => { + expect(treeGrid.filterStrategy).toBeDefined(); + treeGrid.filter('Name', 'Trevor', IgxStringFilteringOperand.instance().condition('contains'), true); + tick(); + fix.detectChanges(); + + expect(treeGrid.rowList.length).toBe(3); + + const matchingRecordsOnlyStrategy = new TreeGridMatchingRecordsOnlyFilteringStrategy(); + treeGrid.filterStrategy = matchingRecordsOnlyStrategy; + fix.detectChanges(); + + treeGrid.filter('Name', 'Trevor', IgxStringFilteringOperand.instance().condition('contains'), true); + tick(); + fix.detectChanges(); + + expect(treeGrid.rowList.length).toBe(1); + expect(treeGrid.filteredData.map(rec => rec.ID)).toEqual([141]); + })); + }); + class CustomTreeGridFilterStrategy extends FilteringStrategy { + + public override filter(data: [], expressionsTree): any[] { + const result = []; + if (!expressionsTree || !expressionsTree.filteringOperands || + expressionsTree.filteringOperands.length === 0 || !data.length) { + return data; + } + data.forEach((rec: any) => { + if (this.matchRecord(rec.data, expressionsTree)) { + result.push(rec); + } + }); + return result; + } + } +}); diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-group-by-area.component.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-group-by-area.component.ts new file mode 100644 index 00000000000..f7a9ea6a6de --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-group-by-area.component.ts @@ -0,0 +1,132 @@ +import { AfterContentInit, Component, Input, IterableDiffer, IterableDiffers, OnDestroy, booleanAttribute, inject } from '@angular/core'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { IChipsAreaReorderEventArgs, IgxChipComponent, IgxChipsAreaComponent } from 'igniteui-angular/chips'; +import { NgTemplateOutlet } from '@angular/common'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxSuffixDirective } from 'igniteui-angular/input-group'; +import { IgxDropDirective } from 'igniteui-angular/directives'; +import { IGroupingExpression, ISortingExpression } from 'igniteui-angular/core'; +import { IgxGroupAreaDropDirective, IgxGroupByAreaDirective, IgxGroupByMetaPipe } from 'igniteui-angular/grids/core'; + +/** + * An internal component representing the group-by drop area for the igx-grid component. + * + * @hidden @internal + */ +@Component({ + selector: 'igx-tree-grid-group-by-area', + templateUrl: '../../core/src/grouping/group-by-area.component.html', + providers: [{ provide: IgxGroupByAreaDirective, useExisting: IgxTreeGridGroupByAreaComponent }], + imports: [IgxChipsAreaComponent, IgxChipComponent, IgxIconComponent, IgxSuffixDirective, IgxGroupAreaDropDirective, IgxDropDirective, NgTemplateOutlet, IgxGroupByMetaPipe] +}) +export class IgxTreeGridGroupByAreaComponent extends IgxGroupByAreaDirective implements AfterContentInit, OnDestroy { + private differs = inject(IterableDiffers); + + @Input({ transform: booleanAttribute }) + public get hideGroupedColumns() { + return this._hideGroupedColumns; + } + + public set hideGroupedColumns(value: boolean) { + if (this.grid?.columns && this.expressions) { + this.setColumnsVisibility(value); + } + + this._hideGroupedColumns = value; + } + + private _hideGroupedColumns = false; + private groupingDiffer: IterableDiffer; + private destroy$ = new Subject(); + + public ngAfterContentInit(): void { + if (this.grid.columns && this.expressions) { + this.groupingDiffer = this.differs.find(this.expressions).create(); + this.updateColumnsVisibility(); + } + + this.grid.sortingExpressionsChange.pipe(takeUntil(this.destroy$)).subscribe((sortingExpressions: ISortingExpression[]) => { + if (!this.expressions || !this.expressions.length) { + return; + } + + let changed = false; + + sortingExpressions.forEach((sortExpr: ISortingExpression) => { + const fieldName = sortExpr.fieldName; + const groupingExpr = this.expressions.find(ex => ex.fieldName === fieldName); + if (groupingExpr && groupingExpr.dir !== sortExpr.dir) { + groupingExpr.dir = sortExpr.dir; + changed = true; + } + }); + + if (changed) { + this.expressions = [...this.expressions]; + } + }); + } + + public ngOnDestroy(): void { + this.destroy$.next(true); + this.destroy$.complete(); + } + + public handleReorder(event: IChipsAreaReorderEventArgs) { + const { chipsArray, originalEvent } = event; + const newExpressions = this.getReorderedExpressions(chipsArray); + + this.chipExpressions = newExpressions; + + // When reordered using keyboard navigation, we don't have `onMoveEnd` event. + if (originalEvent instanceof KeyboardEvent) { + this.expressions = newExpressions; + } + } + + public handleMoveEnd() { + this.expressions = this.chipExpressions; + } + + public groupBy(expression: IGroupingExpression) { + this.expressions.push(expression); + this.expressions = [...this.expressions]; + } + + public clearGrouping(name: string) { + this.expressions = this.expressions.filter(item => item.fieldName !== name); + this.grid.sortingExpressions = this.grid.sortingExpressions.filter(item => item.fieldName !== name); + this.grid.notifyChanges(true); + } + + protected override expressionsChanged() { + this.updateColumnsVisibility(); + } + + private updateColumnsVisibility() { + if (this.groupingDiffer && this.grid.columns && !this.grid.hasColumnLayouts) { + const changes = this.groupingDiffer.diff(this.expressions); + if (changes && this.grid.columns.length > 0) { + changes.forEachAddedItem((rec) => { + const col = this.grid.getColumnByName(rec.item.fieldName); + col.hidden = this.hideGroupedColumns; + }); + changes.forEachRemovedItem((rec) => { + const col = this.grid.getColumnByName(rec.item.fieldName); + col.hidden = false; + }); + } + } + } + + private setColumnsVisibility(value) { + if (this.grid.columns.length > 0 && !this.grid.hasColumnLayouts) { + this.expressions.forEach((expr) => { + const col = this.grid.getColumnByName(expr.fieldName); + col.hidden = value; + }); + } + } +} + diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-grouping.pipe.spec.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-grouping.pipe.spec.ts new file mode 100644 index 00000000000..b000d5af946 --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-grouping.pipe.spec.ts @@ -0,0 +1,192 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { DefaultSortingStrategy, IGroupingExpression } from 'igniteui-angular/core'; +import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; +import { IgxTreeGridSimpleComponent, IgxTreeGridPrimaryForeignKeyComponent } from '../../../test-utils/tree-grid-components.spec'; +import { IgxTreeGridGroupingPipe } from './tree-grid.grouping.pipe'; + + +describe('TreeGrid Grouping Pipe', () => { + let groupPipe: IgxTreeGridGroupingPipe; + let data: any[]; + let grid: any; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, IgxTreeGridSimpleComponent, IgxTreeGridPrimaryForeignKeyComponent] + }).compileComponents(); + })); + + beforeEach(() => { + groupPipe = new IgxTreeGridGroupingPipe(); + data = SampleTestData.employeeTreeDataPrimaryForeignKeyExt(); + data.forEach(element => { + element['HireDate'] = null; + }); + }); + + it('doesn\'t change the data when no groupingExpressions are passed.', () => { + const result = groupPipe.transform(data, [], 'Employees', 'CK', null); + expect(result).toEqual(data); + }); + + it('handles gracefully an empty input data', () => { + const result = groupPipe.transform([], [], 'Employees', 'CK', null); + expect(result).toEqual([]); + }); + + it('handles gracefully an empty input data when groupingExpressions are specified', () => { + const groupingExpressions: IGroupingExpression[] = [ + groupingExpression('OnPTO') + ]; + const result = groupPipe.transform([], groupingExpressions, 'Employees', 'CK', null); + expect(result).toEqual([]); + }); + + it('groups the data properly by a single boolean field', () => { + const groupingExpressions = [groupingExpression('OnPTO')]; + transformAndVerify(data, groupedByPTO, groupingExpressions, 'Employees', 'CK'); + }); + + it('groups the data properly by a single number field', () => { + const groupingExpressions = + [groupingExpression('ParentID')]; + transformAndVerify(data, groupedByParentID, groupingExpressions, 'Employees', 'CK'); + }); + + it('groups the data properly by a single string field', () => { + const groupingExpressions = + [groupingExpression('JobTitle')]; + transformAndVerify(data, groupedByJobTitle, groupingExpressions, 'Employees', 'CK'); + }); + + it('groups the data properly by two fields.', () => { + const groupingExpressions = + [ + groupingExpression('OnPTO', 2), + groupingExpression('JobTitle'), + ]; + transformAndVerify(data, groupedByPTODescJobTitle, groupingExpressions, 'Employees', 'CK'); + }); + + it('groups the data properly by three fields.', () => { + const groupingExpressions = + [ + groupingExpression('OnPTO'), + groupingExpression('JobTitle', 2), + groupingExpression('ParentID', 1) + ]; + transformAndVerify(data, groupedByPTOJobDescPID, groupingExpressions, 'Employees', 'CK'); + }); + + it('check result based on \'groupKey\' parameter.', () => { + const groupingExpressions = [groupingExpression('OnPTO')]; + const groupKeys = [ null, undefined, 'OOF']; + + groupKeys.forEach((groupKey) => { + const result = groupPipe.transform(data, groupingExpressions, groupKey, 'CK', null); + + expect(result[0][groupKey]).toBe('false (13)'); + expect(result[1][groupKey]).toBe('true (5)'); + }); + }); + + it('check result based on \'childDataKey\' parameter.', () => { + const groupingExpressions = [groupingExpression('OnPTO')]; + const childDataKeys = [ null, undefined, 'CK']; + const groupKey = 'Group'; + + childDataKeys.forEach((childDataKey) => { + const result = groupPipe.transform(data, groupingExpressions, groupKey, childDataKey, null); + + expect(result[0][childDataKey]).toBeInstanceOf(Array); + expect(result[0][childDataKey].length).toBe(13); + expect(result[1][childDataKey]).toBeInstanceOf(Array); + expect(result[1][childDataKey].length).toBe(5); + }); + }); + + it('check result with aggregations.', () => { + const groupingExpressions = [groupingExpression('OnPTO')]; + const aggregations = [{ + field: 'Age', + aggregate: (parent: any, children: any[]) => children.map((c) => c.Age).reduce((min, c) => min < c ? min : c, new Date()) + }]; + + const result = groupPipe.transform(data, groupingExpressions, 'Group', 'CK', grid, aggregations); + expect(result[0]['Age']).toEqual(25); + expect(result[1]['Age']).toEqual(29); + }); + + describe('By Date', () => { + beforeEach(() => { + const fix = TestBed.createComponent(IgxTreeGridSimpleComponent); + fix.detectChanges(); + grid = fix.componentInstance.treeGrid; + groupPipe = new IgxTreeGridGroupingPipe(); + data = SampleTestData.employeeTreeDataPrimaryForeignKeyExt(); + data.forEach(element => { + element['HireDate'].toJSON = function(){ + return this.toDateString(); + }; + }); + }); + + it('groups the data properly by a single date field', () => { + const groupingExpressions = + [groupingExpression('HireDate')]; + transformAndVerify(data, groupedByHireDate, groupingExpressions, 'Employees', 'CK', grid); + }); + }); + + describe('By String', () => { + beforeEach(() => { + const fix = TestBed.createComponent(IgxTreeGridPrimaryForeignKeyComponent); + fix.detectChanges(); + grid = fix.componentInstance.treeGrid; + groupPipe = new IgxTreeGridGroupingPipe(); + data = SampleTestData.employeeTreeDataCaseSensitive(); + data.forEach(element => { + element['HireDate'] = null; + }); + }); + + it('groups the data properly by a single string field with lower case value', () => { + const groupingExpressions = + [groupingExpression('JobTitle')]; + transformAndVerify(data, groupedByJobTitleCaseSensitive, groupingExpressions, 'Employees', 'CK', grid); + }); + }); + + const groupingExpression = (fieldName: string, dir = 1, ignoreCase = true, strategy = DefaultSortingStrategy.instance()) => ( + {fieldName, dir, ignoreCase, strategy }); + + const transformAndVerify = ( + inputData: any[], + expectedResult: string, + groupingExpressions = [], + groupKey: string, + childDataKey: string, + treeGrid = null, + aggregations = []) => { + + const result = groupPipe.transform(inputData, groupingExpressions, groupKey, childDataKey, treeGrid, aggregations); + + expect(JSON.stringify(result)).toEqual(expectedResult); + }; + + + const groupedByPTO = '[{"CK":[{"ID":147,"ParentID":-1,"Name":"John Winchester","HireDate":null,"Age":55,"OnPTO":false,"JobTitle":"Director"},{"ID":475,"ParentID":147,"Name":"Michael Langdon","HireDate":null,"Age":43,"OnPTO":false,"Employees":null,"JobTitle":"Software Developer"},{"ID":317,"ParentID":147,"Name":"Monica Reyes","HireDate":null,"Age":31,"OnPTO":false,"JobTitle":"Software Developer"},{"ID":998,"ParentID":317,"Name":"Sven Ottlieb","HireDate":null,"Age":44,"OnPTO":false,"JobTitle":"Senior Software Developer"},{"ID":847,"ParentID":-1,"Name":"Ana Sanders","HireDate":null,"Age":42,"OnPTO":false,"JobTitle":"Vice President"},{"ID":663,"ParentID":847,"Name":"Elizabeth Richards","HireDate":null,"Age":25,"OnPTO":false,"JobTitle":"Associate Software Developer"},{"ID":141,"ParentID":663,"Name":"Trevor Ashworth","HireDate":null,"OnPTO":false,"Age":39,"JobTitle":"Software Developer"},{"ID":19,"ParentID":-1,"Name":"Victoria Lincoln","HireDate":null,"Age":49,"OnPTO":false,"JobTitle":"Director"},{"ID":17,"ParentID":-1,"Name":"Yang Wang","HireDate":null,"Age":61,"OnPTO":false,"JobTitle":"Director"},{"ID":12,"ParentID":17,"Name":"Pedro Afonso","HireDate":null,"Age":50,"OnPTO":false,"JobTitle":"Director"},{"ID":109,"ParentID":12,"Name":"Patricio Simpson","HireDate":null,"Age":25,"OnPTO":false,"Employees":[],"JobTitle":"Associate Software Developer"},{"ID":299,"ParentID":12,"Name":"Peter Lewis","HireDate":null,"OnPTO":false,"Age":25,"JobTitle":"Associate Software Developer"},{"ID":101,"ParentID":17,"Name":"Casey Harper","HireDate":null,"OnPTO":false,"Age":27,"JobTitle":"Software Developer"}],"Employees":"false (13)","_Igx_Hidden_Data_":{"OnPTO":false}},{"CK":[{"ID":957,"ParentID":147,"Name":"Thomas Hardy","HireDate":null,"Age":29,"OnPTO":true,"JobTitle":"Associate Software Developer"},{"ID":711,"ParentID":317,"Name":"Roland Mendel","HireDate":null,"Age":35,"OnPTO":true,"JobTitle":"Software Developer"},{"ID":225,"ParentID":847,"Name":"Laurence Johnson","HireDate":null,"OnPTO":true,"Age":44,"JobTitle":"Senior Software Developer"},{"ID":15,"ParentID":19,"Name":"Antonio Moreno","HireDate":null,"Age":44,"OnPTO":true,"Employees":[],"JobTitle":"Senior Software Developer, TL"},{"ID":99,"ParentID":12,"Name":"Francisco Chang","HireDate":null,"OnPTO":true,"Age":39,"JobTitle":"Senior Software Developer"}],"Employees":"true (5)","_Igx_Hidden_Data_":{"OnPTO":true}}]'; + + const groupedByParentID = '[{"CK":[{"ID":147,"ParentID":-1,"Name":"John Winchester","HireDate":null,"Age":55,"OnPTO":false,"JobTitle":"Director"},{"ID":847,"ParentID":-1,"Name":"Ana Sanders","HireDate":null,"Age":42,"OnPTO":false,"JobTitle":"Vice President"},{"ID":19,"ParentID":-1,"Name":"Victoria Lincoln","HireDate":null,"Age":49,"OnPTO":false,"JobTitle":"Director"},{"ID":17,"ParentID":-1,"Name":"Yang Wang","HireDate":null,"Age":61,"OnPTO":false,"JobTitle":"Director"}],"Employees":"-1 (4)","_Igx_Hidden_Data_":{"ParentID":-1}},{"CK":[{"ID":475,"ParentID":147,"Name":"Michael Langdon","HireDate":null,"Age":43,"OnPTO":false,"Employees":null,"JobTitle":"Software Developer"},{"ID":957,"ParentID":147,"Name":"Thomas Hardy","HireDate":null,"Age":29,"OnPTO":true,"JobTitle":"Associate Software Developer"},{"ID":317,"ParentID":147,"Name":"Monica Reyes","HireDate":null,"Age":31,"OnPTO":false,"JobTitle":"Software Developer"}],"Employees":"147 (3)","_Igx_Hidden_Data_":{"ParentID":147}},{"CK":[{"ID":711,"ParentID":317,"Name":"Roland Mendel","HireDate":null,"Age":35,"OnPTO":true,"JobTitle":"Software Developer"},{"ID":998,"ParentID":317,"Name":"Sven Ottlieb","HireDate":null,"Age":44,"OnPTO":false,"JobTitle":"Senior Software Developer"}],"Employees":"317 (2)","_Igx_Hidden_Data_":{"ParentID":317}},{"CK":[{"ID":225,"ParentID":847,"Name":"Laurence Johnson","HireDate":null,"OnPTO":true,"Age":44,"JobTitle":"Senior Software Developer"},{"ID":663,"ParentID":847,"Name":"Elizabeth Richards","HireDate":null,"Age":25,"OnPTO":false,"JobTitle":"Associate Software Developer"}],"Employees":"847 (2)","_Igx_Hidden_Data_":{"ParentID":847}},{"CK":[{"ID":141,"ParentID":663,"Name":"Trevor Ashworth","HireDate":null,"OnPTO":false,"Age":39,"JobTitle":"Software Developer"}],"Employees":"663 (1)","_Igx_Hidden_Data_":{"ParentID":663}},{"CK":[{"ID":15,"ParentID":19,"Name":"Antonio Moreno","HireDate":null,"Age":44,"OnPTO":true,"Employees":[],"JobTitle":"Senior Software Developer, TL"}],"Employees":"19 (1)","_Igx_Hidden_Data_":{"ParentID":19}},{"CK":[{"ID":12,"ParentID":17,"Name":"Pedro Afonso","HireDate":null,"Age":50,"OnPTO":false,"JobTitle":"Director"},{"ID":101,"ParentID":17,"Name":"Casey Harper","HireDate":null,"OnPTO":false,"Age":27,"JobTitle":"Software Developer"}],"Employees":"17 (2)","_Igx_Hidden_Data_":{"ParentID":17}},{"CK":[{"ID":109,"ParentID":12,"Name":"Patricio Simpson","HireDate":null,"Age":25,"OnPTO":false,"Employees":[],"JobTitle":"Associate Software Developer"},{"ID":99,"ParentID":12,"Name":"Francisco Chang","HireDate":null,"OnPTO":true,"Age":39,"JobTitle":"Senior Software Developer"},{"ID":299,"ParentID":12,"Name":"Peter Lewis","HireDate":null,"OnPTO":false,"Age":25,"JobTitle":"Associate Software Developer"}],"Employees":"12 (3)","_Igx_Hidden_Data_":{"ParentID":12}}]'; + + const groupedByJobTitle = '[{"CK":[{"ID":147,"ParentID":-1,"Name":"John Winchester","HireDate":null,"Age":55,"OnPTO":false,"JobTitle":"Director"},{"ID":19,"ParentID":-1,"Name":"Victoria Lincoln","HireDate":null,"Age":49,"OnPTO":false,"JobTitle":"Director"},{"ID":17,"ParentID":-1,"Name":"Yang Wang","HireDate":null,"Age":61,"OnPTO":false,"JobTitle":"Director"},{"ID":12,"ParentID":17,"Name":"Pedro Afonso","HireDate":null,"Age":50,"OnPTO":false,"JobTitle":"Director"}],"Employees":"Director (4)","_Igx_Hidden_Data_":{"JobTitle":"Director"}},{"CK":[{"ID":475,"ParentID":147,"Name":"Michael Langdon","HireDate":null,"Age":43,"OnPTO":false,"Employees":null,"JobTitle":"Software Developer"},{"ID":317,"ParentID":147,"Name":"Monica Reyes","HireDate":null,"Age":31,"OnPTO":false,"JobTitle":"Software Developer"},{"ID":711,"ParentID":317,"Name":"Roland Mendel","HireDate":null,"Age":35,"OnPTO":true,"JobTitle":"Software Developer"},{"ID":141,"ParentID":663,"Name":"Trevor Ashworth","HireDate":null,"OnPTO":false,"Age":39,"JobTitle":"Software Developer"},{"ID":101,"ParentID":17,"Name":"Casey Harper","HireDate":null,"OnPTO":false,"Age":27,"JobTitle":"Software Developer"}],"Employees":"Software Developer (5)","_Igx_Hidden_Data_":{"JobTitle":"Software Developer"}},{"CK":[{"ID":957,"ParentID":147,"Name":"Thomas Hardy","HireDate":null,"Age":29,"OnPTO":true,"JobTitle":"Associate Software Developer"},{"ID":663,"ParentID":847,"Name":"Elizabeth Richards","HireDate":null,"Age":25,"OnPTO":false,"JobTitle":"Associate Software Developer"},{"ID":109,"ParentID":12,"Name":"Patricio Simpson","HireDate":null,"Age":25,"OnPTO":false,"Employees":[],"JobTitle":"Associate Software Developer"},{"ID":299,"ParentID":12,"Name":"Peter Lewis","HireDate":null,"OnPTO":false,"Age":25,"JobTitle":"Associate Software Developer"}],"Employees":"Associate Software Developer (4)","_Igx_Hidden_Data_":{"JobTitle":"Associate Software Developer"}},{"CK":[{"ID":998,"ParentID":317,"Name":"Sven Ottlieb","HireDate":null,"Age":44,"OnPTO":false,"JobTitle":"Senior Software Developer"},{"ID":225,"ParentID":847,"Name":"Laurence Johnson","HireDate":null,"OnPTO":true,"Age":44,"JobTitle":"Senior Software Developer"},{"ID":99,"ParentID":12,"Name":"Francisco Chang","HireDate":null,"OnPTO":true,"Age":39,"JobTitle":"Senior Software Developer"}],"Employees":"Senior Software Developer (3)","_Igx_Hidden_Data_":{"JobTitle":"Senior Software Developer"}},{"CK":[{"ID":847,"ParentID":-1,"Name":"Ana Sanders","HireDate":null,"Age":42,"OnPTO":false,"JobTitle":"Vice President"}],"Employees":"Vice President (1)","_Igx_Hidden_Data_":{"JobTitle":"Vice President"}},{"CK":[{"ID":15,"ParentID":19,"Name":"Antonio Moreno","HireDate":null,"Age":44,"OnPTO":true,"Employees":[],"JobTitle":"Senior Software Developer, TL"}],"Employees":"Senior Software Developer, TL (1)","_Igx_Hidden_Data_":{"JobTitle":"Senior Software Developer, TL"}}]'; + + const groupedByJobTitleCaseSensitive = '[{"CK":[{"ID":147,"ParentID":-1,"Name":"John Winchester","HireDate":null,"Age":55,"OnPTO":false,"JobTitle":"Director"},{"ID":19,"ParentID":-1,"Name":"Victoria Lincoln","HireDate":null,"Age":49,"OnPTO":false,"JobTitle":"Director"}],"Employees":"Director (2)","_Igx_Hidden_Data_":{"JobTitle":"Director"}},{"CK":[{"ID":475,"ParentID":147,"Name":"Michael Langdon","HireDate":null,"Age":43,"OnPTO":false,"Employees":null,"JobTitle":"Software Developer"},{"ID":957,"ParentID":147,"Name":"Thomas Hardy","HireDate":null,"Age":29,"OnPTO":true,"JobTitle":"Software developer"},{"ID":317,"ParentID":147,"Name":"Monica Reyes","HireDate":null,"Age":31,"OnPTO":false,"JobTitle":"Software Developer"}],"Employees":"Software Developer (3)","_Igx_Hidden_Data_":{"JobTitle":"Software Developer"}}]'; + + const groupedByPTODescJobTitle = '[{"CK":[{"CK":[{"ID":147,"ParentID":-1,"Name":"John Winchester","HireDate":null,"Age":55,"OnPTO":false,"JobTitle":"Director"},{"ID":19,"ParentID":-1,"Name":"Victoria Lincoln","HireDate":null,"Age":49,"OnPTO":false,"JobTitle":"Director"},{"ID":17,"ParentID":-1,"Name":"Yang Wang","HireDate":null,"Age":61,"OnPTO":false,"JobTitle":"Director"},{"ID":12,"ParentID":17,"Name":"Pedro Afonso","HireDate":null,"Age":50,"OnPTO":false,"JobTitle":"Director"}],"Employees":"Director (4)","_Igx_Hidden_Data_":{"JobTitle":"Director"}},{"CK":[{"ID":475,"ParentID":147,"Name":"Michael Langdon","HireDate":null,"Age":43,"OnPTO":false,"Employees":null,"JobTitle":"Software Developer"},{"ID":317,"ParentID":147,"Name":"Monica Reyes","HireDate":null,"Age":31,"OnPTO":false,"JobTitle":"Software Developer"},{"ID":141,"ParentID":663,"Name":"Trevor Ashworth","HireDate":null,"OnPTO":false,"Age":39,"JobTitle":"Software Developer"},{"ID":101,"ParentID":17,"Name":"Casey Harper","HireDate":null,"OnPTO":false,"Age":27,"JobTitle":"Software Developer"}],"Employees":"Software Developer (4)","_Igx_Hidden_Data_":{"JobTitle":"Software Developer"}},{"CK":[{"ID":998,"ParentID":317,"Name":"Sven Ottlieb","HireDate":null,"Age":44,"OnPTO":false,"JobTitle":"Senior Software Developer"}],"Employees":"Senior Software Developer (1)","_Igx_Hidden_Data_":{"JobTitle":"Senior Software Developer"}},{"CK":[{"ID":847,"ParentID":-1,"Name":"Ana Sanders","HireDate":null,"Age":42,"OnPTO":false,"JobTitle":"Vice President"}],"Employees":"Vice President (1)","_Igx_Hidden_Data_":{"JobTitle":"Vice President"}},{"CK":[{"ID":663,"ParentID":847,"Name":"Elizabeth Richards","HireDate":null,"Age":25,"OnPTO":false,"JobTitle":"Associate Software Developer"},{"ID":109,"ParentID":12,"Name":"Patricio Simpson","HireDate":null,"Age":25,"OnPTO":false,"Employees":[],"JobTitle":"Associate Software Developer"},{"ID":299,"ParentID":12,"Name":"Peter Lewis","HireDate":null,"OnPTO":false,"Age":25,"JobTitle":"Associate Software Developer"}],"Employees":"Associate Software Developer (3)","_Igx_Hidden_Data_":{"JobTitle":"Associate Software Developer"}}],"Employees":"false (13)","_Igx_Hidden_Data_":{"OnPTO":false}},{"CK":[{"CK":[{"ID":957,"ParentID":147,"Name":"Thomas Hardy","HireDate":null,"Age":29,"OnPTO":true,"JobTitle":"Associate Software Developer"}],"Employees":"Associate Software Developer (1)","_Igx_Hidden_Data_":{"JobTitle":"Associate Software Developer"}},{"CK":[{"ID":711,"ParentID":317,"Name":"Roland Mendel","HireDate":null,"Age":35,"OnPTO":true,"JobTitle":"Software Developer"}],"Employees":"Software Developer (1)","_Igx_Hidden_Data_":{"JobTitle":"Software Developer"}},{"CK":[{"ID":225,"ParentID":847,"Name":"Laurence Johnson","HireDate":null,"OnPTO":true,"Age":44,"JobTitle":"Senior Software Developer"},{"ID":99,"ParentID":12,"Name":"Francisco Chang","HireDate":null,"OnPTO":true,"Age":39,"JobTitle":"Senior Software Developer"}],"Employees":"Senior Software Developer (2)","_Igx_Hidden_Data_":{"JobTitle":"Senior Software Developer"}},{"CK":[{"ID":15,"ParentID":19,"Name":"Antonio Moreno","HireDate":null,"Age":44,"OnPTO":true,"Employees":[],"JobTitle":"Senior Software Developer, TL"}],"Employees":"Senior Software Developer, TL (1)","_Igx_Hidden_Data_":{"JobTitle":"Senior Software Developer, TL"}}],"Employees":"true (5)","_Igx_Hidden_Data_":{"OnPTO":true}}]'; + + const groupedByPTOJobDescPID = '[{"CK":[{"CK":[{"CK":[{"ID":147,"ParentID":-1,"Name":"John Winchester","HireDate":null,"Age":55,"OnPTO":false,"JobTitle":"Director"},{"ID":19,"ParentID":-1,"Name":"Victoria Lincoln","HireDate":null,"Age":49,"OnPTO":false,"JobTitle":"Director"},{"ID":17,"ParentID":-1,"Name":"Yang Wang","HireDate":null,"Age":61,"OnPTO":false,"JobTitle":"Director"}],"Employees":"-1 (3)","_Igx_Hidden_Data_":{"ParentID":-1}},{"CK":[{"ID":12,"ParentID":17,"Name":"Pedro Afonso","HireDate":null,"Age":50,"OnPTO":false,"JobTitle":"Director"}],"Employees":"17 (1)","_Igx_Hidden_Data_":{"ParentID":17}}],"Employees":"Director (4)","_Igx_Hidden_Data_":{"JobTitle":"Director"}},{"CK":[{"CK":[{"ID":475,"ParentID":147,"Name":"Michael Langdon","HireDate":null,"Age":43,"OnPTO":false,"Employees":null,"JobTitle":"Software Developer"},{"ID":317,"ParentID":147,"Name":"Monica Reyes","HireDate":null,"Age":31,"OnPTO":false,"JobTitle":"Software Developer"}],"Employees":"147 (2)","_Igx_Hidden_Data_":{"ParentID":147}},{"CK":[{"ID":141,"ParentID":663,"Name":"Trevor Ashworth","HireDate":null,"OnPTO":false,"Age":39,"JobTitle":"Software Developer"}],"Employees":"663 (1)","_Igx_Hidden_Data_":{"ParentID":663}},{"CK":[{"ID":101,"ParentID":17,"Name":"Casey Harper","HireDate":null,"OnPTO":false,"Age":27,"JobTitle":"Software Developer"}],"Employees":"17 (1)","_Igx_Hidden_Data_":{"ParentID":17}}],"Employees":"Software Developer (4)","_Igx_Hidden_Data_":{"JobTitle":"Software Developer"}},{"CK":[{"CK":[{"ID":998,"ParentID":317,"Name":"Sven Ottlieb","HireDate":null,"Age":44,"OnPTO":false,"JobTitle":"Senior Software Developer"}],"Employees":"317 (1)","_Igx_Hidden_Data_":{"ParentID":317}}],"Employees":"Senior Software Developer (1)","_Igx_Hidden_Data_":{"JobTitle":"Senior Software Developer"}},{"CK":[{"CK":[{"ID":847,"ParentID":-1,"Name":"Ana Sanders","HireDate":null,"Age":42,"OnPTO":false,"JobTitle":"Vice President"}],"Employees":"-1 (1)","_Igx_Hidden_Data_":{"ParentID":-1}}],"Employees":"Vice President (1)","_Igx_Hidden_Data_":{"JobTitle":"Vice President"}},{"CK":[{"CK":[{"ID":663,"ParentID":847,"Name":"Elizabeth Richards","HireDate":null,"Age":25,"OnPTO":false,"JobTitle":"Associate Software Developer"}],"Employees":"847 (1)","_Igx_Hidden_Data_":{"ParentID":847}},{"CK":[{"ID":109,"ParentID":12,"Name":"Patricio Simpson","HireDate":null,"Age":25,"OnPTO":false,"Employees":[],"JobTitle":"Associate Software Developer"},{"ID":299,"ParentID":12,"Name":"Peter Lewis","HireDate":null,"OnPTO":false,"Age":25,"JobTitle":"Associate Software Developer"}],"Employees":"12 (2)","_Igx_Hidden_Data_":{"ParentID":12}}],"Employees":"Associate Software Developer (3)","_Igx_Hidden_Data_":{"JobTitle":"Associate Software Developer"}}],"Employees":"false (13)","_Igx_Hidden_Data_":{"OnPTO":false}},{"CK":[{"CK":[{"CK":[{"ID":957,"ParentID":147,"Name":"Thomas Hardy","HireDate":null,"Age":29,"OnPTO":true,"JobTitle":"Associate Software Developer"}],"Employees":"147 (1)","_Igx_Hidden_Data_":{"ParentID":147}}],"Employees":"Associate Software Developer (1)","_Igx_Hidden_Data_":{"JobTitle":"Associate Software Developer"}},{"CK":[{"CK":[{"ID":711,"ParentID":317,"Name":"Roland Mendel","HireDate":null,"Age":35,"OnPTO":true,"JobTitle":"Software Developer"}],"Employees":"317 (1)","_Igx_Hidden_Data_":{"ParentID":317}}],"Employees":"Software Developer (1)","_Igx_Hidden_Data_":{"JobTitle":"Software Developer"}},{"CK":[{"CK":[{"ID":225,"ParentID":847,"Name":"Laurence Johnson","HireDate":null,"OnPTO":true,"Age":44,"JobTitle":"Senior Software Developer"}],"Employees":"847 (1)","_Igx_Hidden_Data_":{"ParentID":847}},{"CK":[{"ID":99,"ParentID":12,"Name":"Francisco Chang","HireDate":null,"OnPTO":true,"Age":39,"JobTitle":"Senior Software Developer"}],"Employees":"12 (1)","_Igx_Hidden_Data_":{"ParentID":12}}],"Employees":"Senior Software Developer (2)","_Igx_Hidden_Data_":{"JobTitle":"Senior Software Developer"}},{"CK":[{"CK":[{"ID":15,"ParentID":19,"Name":"Antonio Moreno","HireDate":null,"Age":44,"OnPTO":true,"Employees":[],"JobTitle":"Senior Software Developer, TL"}],"Employees":"19 (1)","_Igx_Hidden_Data_":{"ParentID":19}}],"Employees":"Senior Software Developer, TL (1)","_Igx_Hidden_Data_":{"JobTitle":"Senior Software Developer, TL"}}],"Employees":"true (5)","_Igx_Hidden_Data_":{"OnPTO":true}}]'; + + const groupedByHireDate = '[{"CK":[{"ID":147,"ParentID":-1,"Name":"John Winchester","HireDate":"Sun Apr 20 2008","Age":55,"OnPTO":false,"JobTitle":"Director"}],"Employees":"Apr 20, 2008 (1)","_Igx_Hidden_Data_":{"HireDate":"Apr 20, 2008"}},{"CK":[{"ID":475,"ParentID":147,"Name":"Michael Langdon","HireDate":"Sun Jul 03 2011","Age":43,"OnPTO":false,"Employees":null,"JobTitle":"Software Developer"}],"Employees":"Jul 3, 2011 (1)","_Igx_Hidden_Data_":{"HireDate":"Jul 3, 2011"}},{"CK":[{"ID":957,"ParentID":147,"Name":"Thomas Hardy","HireDate":"Sun Jul 19 2009","Age":29,"OnPTO":true,"JobTitle":"Associate Software Developer"}],"Employees":"Jul 19, 2009 (1)","_Igx_Hidden_Data_":{"HireDate":"Jul 19, 2009"}},{"CK":[{"ID":317,"ParentID":147,"Name":"Monica Reyes","HireDate":"Thu Sep 18 2014","Age":31,"OnPTO":false,"JobTitle":"Software Developer"}],"Employees":"Sep 18, 2014 (1)","_Igx_Hidden_Data_":{"HireDate":"Sep 18, 2014"}},{"CK":[{"ID":711,"ParentID":317,"Name":"Roland Mendel","HireDate":"Sat Oct 17 2015","Age":35,"OnPTO":true,"JobTitle":"Software Developer"}],"Employees":"Oct 17, 2015 (1)","_Igx_Hidden_Data_":{"HireDate":"Oct 17, 2015"}},{"CK":[{"ID":998,"ParentID":317,"Name":"Sven Ottlieb","HireDate":"Wed Nov 11 2009","Age":44,"OnPTO":false,"JobTitle":"Senior Software Developer"}],"Employees":"Nov 11, 2009 (1)","_Igx_Hidden_Data_":{"HireDate":"Nov 11, 2009"}},{"CK":[{"ID":847,"ParentID":-1,"Name":"Ana Sanders","HireDate":"Sat Feb 22 2014","Age":42,"OnPTO":false,"JobTitle":"Vice President"},{"ID":19,"ParentID":-1,"Name":"Victoria Lincoln","HireDate":"Sat Feb 22 2014","Age":49,"OnPTO":false,"JobTitle":"Director"}],"Employees":"Feb 22, 2014 (2)","_Igx_Hidden_Data_":{"HireDate":"Feb 22, 2014"}},{"CK":[{"ID":225,"ParentID":847,"Name":"Laurence Johnson","HireDate":"Sun May 04 2014","OnPTO":true,"Age":44,"JobTitle":"Senior Software Developer"},{"ID":15,"ParentID":19,"Name":"Antonio Moreno","HireDate":"Sun May 04 2014","Age":44,"OnPTO":true,"Employees":[],"JobTitle":"Senior Software Developer, TL"}],"Employees":"May 4, 2014 (2)","_Igx_Hidden_Data_":{"HireDate":"May 4, 2014"}},{"CK":[{"ID":663,"ParentID":847,"Name":"Elizabeth Richards","HireDate":"Sat Dec 09 2017","Age":25,"OnPTO":false,"JobTitle":"Associate Software Developer"},{"ID":109,"ParentID":12,"Name":"Patricio Simpson","HireDate":"Sat Dec 09 2017","Age":25,"OnPTO":false,"Employees":[],"JobTitle":"Associate Software Developer"}],"Employees":"Dec 9, 2017 (2)","_Igx_Hidden_Data_":{"HireDate":"Dec 9, 2017"}},{"CK":[{"ID":141,"ParentID":663,"Name":"Trevor Ashworth","HireDate":"Thu Apr 22 2010","OnPTO":false,"Age":39,"JobTitle":"Software Developer"},{"ID":99,"ParentID":12,"Name":"Francisco Chang","HireDate":"Thu Apr 22 2010","OnPTO":true,"Age":39,"JobTitle":"Senior Software Developer"},{"ID":101,"ParentID":17,"Name":"Casey Harper","HireDate":"Thu Apr 22 2010","OnPTO":false,"Age":27,"JobTitle":"Software Developer"}],"Employees":"Apr 22, 2010 (3)","_Igx_Hidden_Data_":{"HireDate":"Apr 22, 2010"}},{"CK":[{"ID":17,"ParentID":-1,"Name":"Yang Wang","HireDate":"Mon Feb 01 2010","Age":61,"OnPTO":false,"JobTitle":"Director"}],"Employees":"Feb 1, 2010 (1)","_Igx_Hidden_Data_":{"HireDate":"Feb 1, 2010"}},{"CK":[{"ID":12,"ParentID":17,"Name":"Pedro Afonso","HireDate":"Tue Dec 18 2007","Age":50,"OnPTO":false,"JobTitle":"Director"}],"Employees":"Dec 18, 2007 (1)","_Igx_Hidden_Data_":{"HireDate":"Dec 18, 2007"}},{"CK":[{"ID":299,"ParentID":12,"Name":"Peter Lewis","HireDate":"Wed Apr 18 2018","OnPTO":false,"Age":25,"JobTitle":"Associate Software Developer"}],"Employees":"Apr 18, 2018 (1)","_Igx_Hidden_Data_":{"HireDate":"Apr 18, 2018"}}]'; +}); diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-grouping.spec.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-grouping.spec.ts new file mode 100644 index 00000000000..1c76b5d6bc9 --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-grouping.spec.ts @@ -0,0 +1,215 @@ +import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { clearGridSubs, setupGridScrollDetection } from '../../../test-utils/helper-utils.spec'; +import { IgxTreeGridGroupByAreaTestComponent, IgxTreeGridGroupingComponent } from '../../../test-utils/tree-grid-components.spec'; +import { IgxTreeGridGroupByAreaComponent } from 'igniteui-angular/grids/tree-grid'; +import { TreeGridFunctions } from '../../../test-utils/tree-grid-functions.spec'; +import { IgxTreeGridComponent } from './tree-grid.component'; +import { DefaultSortingStrategy } from 'igniteui-angular/core'; + +describe('IgxTreeGrid', () => { + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxTreeGridGroupingComponent, + IgxTreeGridGroupByAreaTestComponent + ] + }).compileComponents(); + })); + + let fix; + let treeGrid: IgxTreeGridComponent; + let groupByArea: IgxTreeGridGroupByAreaComponent; + + const DROP_AREA_MSG = 'Drag a column header and drop it here to group by that column.'; + describe(' GroupByArea Standalone', ()=> { + + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridGroupByAreaTestComponent); + fix.detectChanges(); + + groupByArea = fix.componentInstance.groupByArea; + treeGrid = fix.componentInstance.treeGrid; + }); + + it('loads successfully', fakeAsync(() => { + const groupByAreaElement = fix.debugElement.nativeElement.querySelector('igx-tree-grid-group-by-area'); + const chipsAreaElement = groupByAreaElement.querySelector('igx-chips-area'); + + expect(groupByAreaElement).toBeDefined(); + expect(chipsAreaElement.children.length).toEqual(1); + + const dropAreaElement = chipsAreaElement.children[0]; + expect(dropAreaElement.children.length).toEqual(2); + + const iconElement = dropAreaElement.querySelector('igx-icon'); + expect(iconElement).toBeDefined(); + expect(iconElement.innerText).toEqual('group_work'); + + const spanElement = dropAreaElement.querySelector('span'); + expect(spanElement).toBeDefined(); + expect(spanElement.innerText).toEqual(DROP_AREA_MSG); + })); + + it ('has the expected default properties\' values', fakeAsync(() => { + expect(groupByArea).toBeDefined(); + expect(groupByArea.grid).toEqual(treeGrid); + expect(groupByArea.expressions).toEqual([]); + expect(groupByArea.hideGroupedColumns).toBeFalse(); + expect(groupByArea.dropAreaMessage).toMatch(DROP_AREA_MSG); + expect(groupByArea.dropAreaTemplate).toBeUndefined(); + expect(groupByArea.dropAreaVisible).toBeTrue(); + })); + + it('allows changing the drop area message', fakeAsync(() => { + const dropMsg = 'New drop message'; + groupByArea.dropAreaMessage = dropMsg; + fix.detectChanges(); + tick(); + + expect(groupByArea.dropAreaMessage).toEqual(dropMsg); + expect(fix.debugElement.nativeElement.querySelector('.igx-drop-area__text').innerText).toEqual(dropMsg); + })); + + it('allows setting the `hideGroupedColumns` property', fakeAsync(() => { + groupByArea.hideGroupedColumns = true; + fix.detectChanges(); + tick(); + + expect(groupByArea.hideGroupedColumns).toBeTrue(); + })); + }); + + describe('', () => { + let groupingExpressions; + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridGroupingComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + groupByArea = fix.componentInstance.groupByArea; + groupingExpressions = fix.componentInstance.groupingExpressions; + setupGridScrollDetection(fix, treeGrid); + }); + + afterEach(() => { + clearGridSubs(); + }); + + it ('GroupByArea has the expected properties\' values set', fakeAsync(() => { + expect(groupByArea).toBeDefined(); + expect(groupByArea.expressions.length).toEqual(2); + expect(groupByArea.grid).toEqual(treeGrid); + expect(groupByArea.hideGroupedColumns).toBeFalse(); + expect(groupByArea.dropAreaMessage).toMatch(DROP_AREA_MSG); + expect(groupByArea.dropAreaTemplate).toBeUndefined(); + expect(groupByArea.dropAreaVisible).toBeFalse(); + })); + + it('is loaded grouped by two fields.', fakeAsync(() => { + const groupArea = fix.debugElement.nativeElement.querySelector('igx-tree-grid-group-by-area'); + expect(groupArea).toBeDefined(); + const chips = fix.debugElement.nativeElement.querySelectorAll('igx-chip'); + expect(chips.length).toBe(2); + + let rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(2); + + treeGrid.expandAll(); + fix.detectChanges(); + tick(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(treeGrid.rowList.length); + })); + + it('shows a new group chip when adding a grouping expression', fakeAsync(() => { + expect(groupByArea.expressions).toEqual(groupingExpressions); + let chips = getChips(fix); + + expect(chips.length).toEqual(2); + expect(chips[0].id).toEqual('OnPTO'); + expect(chips[1].id).toEqual('HireDate'); + + groupingExpressions.push({ fieldName: 'JobTitle', dir: 2, ignoreCase: true, strategy: DefaultSortingStrategy.instance()}); + fix.detectChanges(); + tick(); + + chips = getChips(fix); + expect(chips.length).toEqual(3); + expect(chips[2].id).toEqual('JobTitle'); + })); + + it('removes a group chip when removing a grouping expression', fakeAsync(() => { + groupingExpressions.pop(); + fix.detectChanges(); + + expect(groupByArea.expressions.length).toEqual(1); + expect(getChips(fix).length).toEqual(1); + expect(getChips(fix)[0].id).toEqual('OnPTO'); + })); + + it('group columns stay visible by default', fakeAsync(() => { + expect(treeGrid.getColumnByName('OnPTO').hidden).toBeFalse(); + expect(treeGrid.getColumnByName('HireDate').hidden).toBeFalse(); + + })); + + it('keeps the group columns visible by default', fakeAsync(() => { + expect(treeGrid.getColumnByName('HireDate').hidden).toBeFalse(); + + groupingExpressions.pop(); + groupByArea.expressions = [...groupingExpressions]; + fix.detectChanges(); + tick(); + + expect(treeGrid.getColumnByName('HireDate').hidden).toBeFalse(); + })); + + it('hides/shows the grouped by column when hideGroupedColumns=true', fakeAsync(() => { + groupByArea.hideGroupedColumns = true; + fix.detectChanges(); + + expect(treeGrid.getColumnByName('HireDate').hidden).toBeTrue(); + + groupingExpressions.pop(); + groupByArea.expressions = [...groupingExpressions]; + fix.detectChanges(); + tick(); + + expect(treeGrid.getColumnByName('HireDate').hidden).toBeFalse(); + + groupingExpressions.push({ fieldName: 'JobTitle', dir: 2, ignoreCase: true, strategy: DefaultSortingStrategy.instance()}); + groupByArea.expressions = [...groupingExpressions]; + fix.detectChanges(); + tick(); + + expect(treeGrid.getColumnByName('JobTitle').hidden).toBeTrue(); + })); + + it('shows aggregated values in parent records properly', fakeAsync(() => { + expect(treeGrid.getCellByColumn(0, 'HireDate').value).toBeUndefined(); + expect(treeGrid.getCellByColumn(1, 'HireDate').value).toBeUndefined(); + + const aggregations = [{ + field: 'HireDate', + aggregate: (parent: any, children: any[]) => children.map((c) => c.HireDate) + .reduce((min, c) => min < c ? min : c, new Date()) + }]; + + fix.componentInstance.aggregations = aggregations; + fix.detectChanges(); + tick(); + + expect(treeGrid.rowList.length).toEqual(2); + expect(treeGrid.getCellByColumn(0, 'HireDate').value).toEqual(new Date(2009, 6, 19)); + expect(treeGrid.getCellByColumn(1, 'HireDate').value).toEqual(new Date(2007, 11, 18)); + })); + }); + + const getChips = (fixture) => { + const chipsAreaElement = fixture.debugElement.nativeElement.querySelector('igx-chips-area'); + return chipsAreaElement.querySelectorAll('igx-chip'); + }; +}); diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-indentation.spec.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-indentation.spec.ts new file mode 100644 index 00000000000..a26d7e2490b --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-indentation.spec.ts @@ -0,0 +1,365 @@ +import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { IgxTreeGridComponent } from './tree-grid.component'; +import { IgxTreeGridSimpleComponent, IgxTreeGridPrimaryForeignKeyComponent } from '../../../test-utils/tree-grid-components.spec'; +import { TreeGridFunctions, NUMBER_CELL_CSS_CLASS } from '../../../test-utils/tree-grid-functions.spec'; +import { By } from '@angular/platform-browser'; +import { UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { DropPosition } from 'igniteui-angular/grids/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxNumberFilteringOperand, SortingDirection } from 'igniteui-angular/core'; + +const GRID_RESIZE_CLASS = '.igx-grid-th__resize-handle'; + +describe('IgxTreeGrid - Indentation #tGrid', () => { + let fix; + let treeGrid: IgxTreeGridComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, IgxTreeGridSimpleComponent, IgxTreeGridPrimaryForeignKeyComponent] + }).compileComponents(); + })); + + describe('Child Collection', () => { + + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridSimpleComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + }); + + it('should have the tree-cell as a first cell on every row', () => { + // Verify all rows are present + const rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(10); + + // Verify the tree cell is the first cell for every row + TreeGridFunctions.verifyCellsPosition(rows, 4); + }); + + it('should have correct indentation for every record of each level', () => { + const rows = TreeGridFunctions.sortElementsVertically(TreeGridFunctions.getAllRows(fix)); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(0), rows[0], 0); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(1), rows[1], 1); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(2), rows[2], 1); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(3), rows[3], 1); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(4), rows[4], 2); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(5), rows[5], 2); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(6), rows[6], 2); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(7), rows[7], 0); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(8), rows[8], 0); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(9), rows[9], 1); + }); + + it('should persist the indentation after sorting', () => { + treeGrid.sort({ fieldName: 'Age', dir: SortingDirection.Asc, ignoreCase: false }); + fix.detectChanges(); + + const rows = TreeGridFunctions.sortElementsVertically(TreeGridFunctions.getAllRows(fix)); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(0), rows[0], 0); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(1), rows[1], 1); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(2), rows[2], 0); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(3), rows[3], 1); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(4), rows[4], 1); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(5), rows[5], 1); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(6), rows[6], 2); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(7), rows[7], 2); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(8), rows[8], 2); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(9), rows[9], 0); + }); + + it('should persist the indentation after filtering', fakeAsync(() => { + treeGrid.filter('Age', 40, IgxNumberFilteringOperand.instance().condition('greaterThan')); + fix.detectChanges(); + + const rows = TreeGridFunctions.sortElementsVertically(TreeGridFunctions.getAllRows(fix)); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(0), rows[0], 0); + + // This row does not satisfy the filtering, but is present in the DOM with lowered opacity + // in order to indicate that it is a parent of another record that satisfies the filtering. + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(1), rows[1], 1); + + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(2), rows[2], 2); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(3), rows[3], 0); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(4), rows[4], 0); + })); + + it('should persist the indentation on all pages when using paging', fakeAsync(() => { + fix.componentInstance.paging = true; + fix.detectChanges(); + + treeGrid.paginator.perPage = 4; + fix.detectChanges(); + tick(16); + + // Verify page 1 + let rows = TreeGridFunctions.sortElementsVertically(TreeGridFunctions.getAllRows(fix)); + expect(rows.length).toBe(4, 'Incorrect number of rows on page 1.'); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(0), rows[0], 0); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(1), rows[1], 1); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(2), rows[2], 1); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(3), rows[3], 1); + + treeGrid.page = 1; + fix.detectChanges(); + tick(16); + + // Verify page 2 + rows = TreeGridFunctions.sortElementsVertically(TreeGridFunctions.getAllRows(fix)); + expect(rows.length).toBe(4, 'Incorrect number of rows on page 2.'); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(0), rows[0], 2); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(1), rows[1], 2); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(2), rows[2], 2); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(3), rows[3], 0); + + treeGrid.page = 2; + fix.detectChanges(); + tick(16); + + // Verify page 3 + rows = TreeGridFunctions.sortElementsVertically(TreeGridFunctions.getAllRows(fix)); + expect(rows.length).toBe(2, 'Incorrect number of rows on page 3.'); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(0), rows[0], 0); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(1), rows[1], 1); + })); + + it('should persist the indentation after resizing the tree-column', fakeAsync(() => { + const column = treeGrid.columnList.filter(c => c.field === 'ID')[0]; + column.resizable = true; + fix.detectChanges(); + treeGrid.cdr.detectChanges(); + + const header = TreeGridFunctions.getHeaderCell(fix, 'ID'); + const resizer = header.parent.query(By.css(GRID_RESIZE_CLASS)).nativeElement; + + // Verify before resizing width + expect(header.nativeElement.getBoundingClientRect().width).toBe(225); + + // Resize the tree column + UIInteractions.simulateMouseEvent('mousedown', resizer, 225, 5); + tick(200); + fix.detectChanges(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 370, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 370, 5); + tick(200); + fix.detectChanges(); + + // Verify after resizing width and row indentation + expect(header.nativeElement.getBoundingClientRect().width).toBe(370); + const rows = TreeGridFunctions.sortElementsVertically(TreeGridFunctions.getAllRows(fix)); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(0), rows[0], 0); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(1), rows[1], 1); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(2), rows[2], 1); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(3), rows[3], 1); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(4), rows[4], 2); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(5), rows[5], 2); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(6), rows[6], 2); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(7), rows[7], 0); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(8), rows[8], 0); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(9), rows[9], 1); + })); + + it('should change cell content alignment of tree-column with number dataType when it is no longer tree-column', fakeAsync(() => { + TreeGridFunctions.verifyTreeColumn(fix, 'ID', 4); + verifyCellsContentAlignment(fix, 'ID', true); // Verify cells of 'ID' are left-aligned. + + // Moving 'ID' column + const sourceColumn = treeGrid.columnList.filter(c => c.field === 'ID')[0]; + let targetColumn = treeGrid.columnList.filter(c => c.field === 'Age')[0]; + treeGrid.moveColumn(sourceColumn, targetColumn, DropPosition.BeforeDropTarget); + tick(); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeColumn(fix, 'Name', 4); + verifyCellsContentAlignment(fix, 'ID', false); // Verify cells of 'ID' are right-aligned. + + // Moving 'ID' column + targetColumn = treeGrid.columnList.filter(c => c.field === 'Name')[0]; + treeGrid.moveColumn(sourceColumn, targetColumn, DropPosition.BeforeDropTarget); + tick(); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeColumn(fix, 'ID', 4); + verifyCellsContentAlignment(fix, 'ID', true); // Verify cells of 'ID' are left-aligned. + })); + }); + + describe('Primary/Foreign key', () => { + + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridPrimaryForeignKeyComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + }); + + it('should have the tree-cell as a first cell on every row', () => { + // Verify all rows are present + const rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(8); + + // Verify the tree cell is the first cell for every row + TreeGridFunctions.verifyCellsPosition(rows, 5); + }); + + it('should have correct indentation for every record of each level', () => { + const rows = TreeGridFunctions.sortElementsVertically(TreeGridFunctions.getAllRows(fix)); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(0), rows[0], 0); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(1), rows[1], 1); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(2), rows[2], 2); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(3), rows[3], 2); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(4), rows[4], 1); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(5), rows[5], 0); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(6), rows[6], 0); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(7), rows[7], 1); + }); + + it('should persist the indentation after sorting', () => { + treeGrid.sort({ fieldName: 'Age', dir: SortingDirection.Asc, ignoreCase: false }); + fix.detectChanges(); + + const rows = TreeGridFunctions.sortElementsVertically(TreeGridFunctions.getAllRows(fix)); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(0), rows[0], 0); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(1), rows[1], 1); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(2), rows[2], 1); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(3), rows[3], 2); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(4), rows[4], 2); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(5), rows[5], 0); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(6), rows[6], 0); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(7), rows[7], 1); + }); + + it('should persist the indentation after filtering', fakeAsync(() => { + + treeGrid.filter('Age', 35, IgxNumberFilteringOperand.instance().condition('greaterThan')); + fix.detectChanges(); + + const rows = TreeGridFunctions.sortElementsVertically(TreeGridFunctions.getAllRows(fix)); + + // This row does not satisfy the filtering, but is present in the DOM with lowered opacity + // in order to indicate that it is a parent of another record that satisfies the filtering. + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(0), rows[0], 0); + + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(1), rows[1], 1); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(2), rows[2], 0); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(3), rows[3], 0); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(4), rows[4], 1); + })); + + it('should persist the indentation on all pages when using paging', fakeAsync(() => { + fix.componentInstance.paging = true; + fix.detectChanges(); + + treeGrid.paginator.perPage = 3; + fix.detectChanges(); + tick(16); + + // Verify page 1 + let rows = TreeGridFunctions.sortElementsVertically(TreeGridFunctions.getAllRows(fix)); + expect(rows.length).toBe(3, 'Incorrect number of rows on page 1.'); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(0), rows[0], 0); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(1), rows[1], 1); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(2), rows[2], 2); + + treeGrid.paginator.page = 1; + fix.detectChanges(); + tick(16); + + // Verify page 2 + rows = TreeGridFunctions.sortElementsVertically(TreeGridFunctions.getAllRows(fix)); + expect(rows.length).toBe(3, 'Incorrect number of rows on page 2.'); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(0), rows[0], 2); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(1), rows[1], 1); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(2), rows[2], 0); + + treeGrid.paginator.page = 2; + fix.detectChanges(); + tick(16); + + // Verify page 3 + rows = TreeGridFunctions.sortElementsVertically(TreeGridFunctions.getAllRows(fix)); + expect(rows.length).toBe(2, 'Incorrect number of rows on page 3.'); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(0), rows[0], 0); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(1), rows[1], 1); + })); + + it('should persist the indentation after resizing the tree-column', fakeAsync(() => { + const column = treeGrid.columnList.filter(c => c.field === 'ID')[0]; + column.resizable = true; + fix.detectChanges(); + treeGrid.cdr.detectChanges(); + tick(16); + + const header = TreeGridFunctions.getHeaderCell(fix, 'ID'); + const resizer = header.parent.query(By.css(GRID_RESIZE_CLASS)).nativeElement; + + // Verify before resizing width + expect(header.nativeElement.getBoundingClientRect().width).toBe(180); + + // Resize the tree column + UIInteractions.simulateMouseEvent('mousedown', resizer, 180, 5); + tick(200); + fix.detectChanges(); + UIInteractions.simulateMouseEvent('mousemove', resizer, 370, 5); + UIInteractions.simulateMouseEvent('mouseup', resizer, 370, 5); + tick(200); + fix.detectChanges(); + + // Verify after resizing width and row indentation + expect(header.nativeElement.getBoundingClientRect().width).toBe(370); + const rows = TreeGridFunctions.sortElementsVertically(TreeGridFunctions.getAllRows(fix)); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(0), rows[0], 0); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(1), rows[1], 1); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(2), rows[2], 2); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(3), rows[3], 2); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(4), rows[4], 1); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(5), rows[5], 0); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(6), rows[6], 0); + TreeGridFunctions.verifyRowIndentationLevel(treeGrid.getRowByIndex(7), rows[7], 1); + })); + + it('should change cell content alignment of tree-column with number dataType when it is no longer tree-column', fakeAsync(() => { + TreeGridFunctions.verifyTreeColumn(fix, 'ID', 5); + verifyCellsContentAlignment(fix, 'ID', true); // Verify cells of 'ID' are left-aligned. + + // Moving 'ID' column + const sourceColumn = treeGrid.columnList.filter(c => c.field === 'ID')[0]; + let targetColumn = treeGrid.columnList.filter(c => c.field === 'Age')[0]; + treeGrid.moveColumn(sourceColumn, targetColumn, DropPosition.BeforeDropTarget); + tick(); + fix.detectChanges(); + TreeGridFunctions.verifyTreeColumn(fix, 'ParentID', 5); + verifyCellsContentAlignment(fix, 'ID', false); // Verify cells of 'ID' are right-aligned. + + // Moving 'ID' column + targetColumn = treeGrid.columnList.filter(c => c.field === 'ParentID')[0]; + treeGrid.moveColumn(sourceColumn, targetColumn, DropPosition.BeforeDropTarget); + tick(); + fix.detectChanges(); + TreeGridFunctions.verifyTreeColumn(fix, 'ID', 5); + verifyCellsContentAlignment(fix, 'ID', true); // Verify cells of 'ID' are left-aligned. + })); + }); +}); + +const verifyCellsContentAlignment = (fix, columnKey, shouldBeLeftAligned: boolean) => { + const cells = TreeGridFunctions.getColumnCells(fix, columnKey); + if (shouldBeLeftAligned) { + cells.forEach((cell) => { + expect(cell.nativeElement.classList.contains(NUMBER_CELL_CSS_CLASS)) + .toBe(false, 'cell has number css class'); + + // TreeCells have either 2 or 3 div children (2 for root rows and 3 for child rows). + const cellDivChildren = cell.queryAll(By.css('div')); + expect((cellDivChildren.length === 2) || (cellDivChildren.length === 3)).toBe(true); + }); + } else { // Should be right-aligned + cells.forEach((cell) => { + expect(cell.nativeElement.classList.contains(NUMBER_CELL_CSS_CLASS)) + .toBe(true, 'cell does not have number css class'); + + // NormalCells have 1 div child (no div for indentation and no div for expander). + const cellDivChildren = cell.queryAll(By.css('div')); + expect(cellDivChildren.length === 1).toBe(true); + }); + } +}; diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-integration.spec.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-integration.spec.ts new file mode 100644 index 00000000000..592925bec61 --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-integration.spec.ts @@ -0,0 +1,1830 @@ +import { TestBed, ComponentFixture, waitForAsync, fakeAsync, tick } from '@angular/core/testing'; +import { DebugElement } from '@angular/core'; +import { IgxTreeGridComponent } from './tree-grid.component'; +import { + IgxTreeGridSimpleComponent, IgxTreeGridPrimaryForeignKeyComponent, + IgxTreeGridStringTreeColumnComponent, IgxTreeGridDateTreeColumnComponent, IgxTreeGridBooleanTreeColumnComponent, + IgxTreeGridRowEditingComponent, IgxTreeGridMultiColHeadersComponent, + IgxTreeGridRowEditingTransactionComponent, + IgxTreeGridRowEditingHierarchicalDSTransactionComponent, + IgxTreeGridRowPinningComponent +} from '../../../test-utils/tree-grid-components.spec'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TreeGridFunctions } from '../../../test-utils/tree-grid-functions.spec'; +import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; +import { By } from '@angular/platform-browser'; +import { CellType, DropPosition, IgxTreeGridRow } from 'igniteui-angular/grids/core'; +import { IgxTreeGridRowComponent } from './tree-grid-row.component'; +import { IgxGridTransaction } from 'igniteui-angular/grids/core'; +import { HierarchicalTransaction, IgxHierarchicalTransactionService, IgxNumberFilteringOperand, IgxStringFilteringOperand, SortingDirection, TransactionType } from 'igniteui-angular/core'; + +const CSS_CLASS_BANNER = 'igx-banner'; +const CSS_CLASS_ROW_EDITED = 'igx-grid__tr--edited'; +const GRID_RESIZE_CLASS = '.igx-grid-th__resize-handle'; + +describe('IgxTreeGrid - Integration #tGrid', () => { + let fix: ComponentFixture; + let treeGrid: IgxTreeGridComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxTreeGridSimpleComponent, + IgxTreeGridPrimaryForeignKeyComponent, + IgxTreeGridStringTreeColumnComponent, + IgxTreeGridDateTreeColumnComponent, + IgxTreeGridBooleanTreeColumnComponent, + IgxTreeGridRowEditingComponent, + IgxTreeGridRowPinningComponent, + IgxTreeGridMultiColHeadersComponent, + IgxTreeGridRowEditingTransactionComponent, + IgxTreeGridRowEditingHierarchicalDSTransactionComponent + ], + providers: [ + { provide: IgxGridTransaction, useClass: IgxHierarchicalTransactionService } + ] + }).compileComponents(); + })); + + it('should have tree-column with a \'string\' dataType', () => { + // Init test + fix = TestBed.createComponent(IgxTreeGridStringTreeColumnComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + + TreeGridFunctions.verifyTreeColumn(fix, 'Name', 4); + }); + + it('should have tree-column with a \'date\' dataType', () => { + // Init test + fix = TestBed.createComponent(IgxTreeGridDateTreeColumnComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + + TreeGridFunctions.verifyTreeColumn(fix, 'HireDate', 4); + }); + + it('should have tree-column with a \'boolean\' dataType', () => { + // Init test + fix = TestBed.createComponent(IgxTreeGridBooleanTreeColumnComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + + TreeGridFunctions.verifyTreeColumn(fix, 'PTO', 5); + }); + + describe('Child Collection', () => { + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridSimpleComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + }); + + it('should transform a non-tree column into a tree column when pinning it', () => { + TreeGridFunctions.verifyTreeColumn(fix, 'ID', 4); + + treeGrid.pinColumn('Age'); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeColumn(fix, 'Age', 4); + + treeGrid.unpinColumn('Age'); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeColumn(fix, 'ID', 4); + }); + + it('should transform a non-tree column into a tree column when hiding the original tree-column', () => { + TreeGridFunctions.verifyTreeColumn(fix, 'ID', 4); + + const column = treeGrid.columnList.filter(c => c.field === 'ID')[0]; + column.hidden = true; + fix.detectChanges(); + + TreeGridFunctions.verifyTreeColumn(fix, 'Name', 3); + + column.hidden = false; + fix.detectChanges(); + + TreeGridFunctions.verifyTreeColumn(fix, 'ID', 4); + }); + + it('should transform the first visible column into tree column when pin and hide another column before that', () => { + TreeGridFunctions.verifyTreeColumn(fix, 'ID', 4); + + treeGrid.pinColumn('Age'); + fix.detectChanges(); + + const column = treeGrid.columnList.filter(c => c.field === 'Age')[0]; + column.hidden = true; + fix.detectChanges(); + + TreeGridFunctions.verifyTreeColumn(fix, 'ID', 3); + }); + + it('(UI) should transform a non-tree column into a tree column when moving the original tree-column through', async () => { + TreeGridFunctions.verifyTreeColumn(fix, 'ID', 4); + + treeGrid.moving = true; + + const header = TreeGridFunctions.getHeaderCell(fix, 'ID').nativeElement; + const headerRect = header.getBoundingClientRect(); + const startX = headerRect.width / 2; + const startY = headerRect.height / 2; + + UIInteractions.simulatePointerEvent('pointerdown', header, startX, startY); + await wait(16); + UIInteractions.simulatePointerEvent('pointermove', header, startX + headerRect.width, startY); + await wait(16); + UIInteractions.simulatePointerEvent('pointerup', header, startX + headerRect.width, startY); + await wait(16); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeColumn(fix, 'Name', 4); + }); + + it('(API) should transform a non-tree column into a tree column when moving the original tree-column through', () => { + TreeGridFunctions.verifyTreeColumn(fix, 'ID', 4); + + // Move tree-column + const sourceColumn = treeGrid.columnList.filter(c => c.field === 'ID')[0]; + const targetColumn = treeGrid.columnList.filter(c => c.field === 'HireDate')[0]; + treeGrid.moveColumn(sourceColumn, targetColumn); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeColumn(fix, 'Name', 4); + }); + + it('(API) should autosize tree-column', () => { + const headerCell = TreeGridFunctions.getHeaderCell(fix, 'ID'); + const column = treeGrid.columnList.filter(c => c.field === 'ID')[0]; + + expect(headerCell.nativeElement.getBoundingClientRect().width).toBe(225, 'incorrect column width'); + expect(parseInt(column.width, 10)).toBe(225); + + // API autosizing + column.autosize(); + fix.detectChanges(); + + expect(headerCell.nativeElement.getBoundingClientRect().width).toBe(148, 'incorrect headerCell width'); + expect(parseInt(column.width, 10)).toBe(148); + }); + + it('(UI) should autosize tree-column', () => { + const headerCell = TreeGridFunctions.getHeaderCell(fix, 'ID').parent; + const column = treeGrid.columnList.filter(c => c.field === 'ID')[0]; + column.resizable = true; + treeGrid.cdr.detectChanges(); + + expect(headerCell.nativeElement.getBoundingClientRect().width).toBe(225, 'incorrect column width'); + expect(parseInt(column.width, 10)).toBe(225); + + // UI autosizing + const resizer = headerCell.query(By.css(GRID_RESIZE_CLASS)).nativeElement; + UIInteractions.simulateMouseEvent('dblclick', resizer, 225, 5); + fix.detectChanges(); + + expect(headerCell.nativeElement.getBoundingClientRect().width).toBe(148, 'incorrect headerCell width'); + expect(parseInt(column.width, 10)).toBe(148); + }); + }); + + describe('Primary/Foreign key', () => { + + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridPrimaryForeignKeyComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + }); + + it('should preserve the order of records on inner levels', () => { + fix = TestBed.createComponent(IgxTreeGridPrimaryForeignKeyComponent); + fix.componentInstance.sortByName = true; + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + + const expectedFlatData = [ + { + "ID": 1, + "ParentID": -1, + "Name": "Casey Houston", + "JobTitle": "Vice President", + "Age": 32 + }, + { + "ID": 2, + "ParentID": 1, + "Name": "Gilberto Todd", + "JobTitle": "Director", + "Age": 41 + }, + { + "ID": 7, + "ParentID": 2, + "Name": "Debra Morton", + "JobTitle": "Associate Software Developer", + "Age": 35 + }, + { + "ID": 3, + "ParentID": 2, + "Name": "Tanya Bennett", + "JobTitle": "Director", + "Age": 29 + }, + { + "ID": 4, + "ParentID": 1, + "Name": "Jack Simon", + "JobTitle": "Software Developer", + "Age": 33 + }, + { + "ID": 10, + "ParentID": -1, + "Name": "Eduardo Ramirez", + "JobTitle": "Manager", + "Age": 53 + }, + { + "ID": 9, + "ParentID": 10, + "Name": "Leslie Hansen", + "JobTitle": "Associate Software Developer", + "Age": 44 + }, + { + "ID": 6, + "ParentID": -1, + "Name": "Erma Walsh", + "JobTitle": "CEO", + "Age": 52 + }, + ] + expect(treeGrid.flatData).toEqual(expectedFlatData); + }); + + it('should transform a non-tree column into a tree column when pinning it', () => { + TreeGridFunctions.verifyTreeColumn(fix, 'ID', 5); + + treeGrid.pinColumn('Name'); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeColumn(fix, 'Name', 5); + + treeGrid.unpinColumn('Name'); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeColumn(fix, 'ID', 5); + }); + + it('should transform a non-tree column into a tree column when hiding the original tree-column', () => { + TreeGridFunctions.verifyTreeColumn(fix, 'ID', 5); + + const column = treeGrid.columnList.filter(c => c.field === 'ID')[0]; + column.hidden = true; + fix.detectChanges(); + + TreeGridFunctions.verifyTreeColumn(fix, 'ParentID', 4); + + column.hidden = false; + fix.detectChanges(); + + TreeGridFunctions.verifyTreeColumn(fix, 'ID', 5); + }); + + it('should transform the first visible column into tree column when pin and hide another column before that', () => { + TreeGridFunctions.verifyTreeColumn(fix, 'ID', 5); + + treeGrid.pinColumn('Age'); + fix.detectChanges(); + + const column = treeGrid.columnList.filter(c => c.field === 'Age')[0]; + column.hidden = true; + fix.detectChanges(); + + TreeGridFunctions.verifyTreeColumn(fix, 'ID', 4); + }); + + it('(UI) should transform a non-tree column into a tree column when moving the original tree-column through', async () => { + TreeGridFunctions.verifyTreeColumn(fix, 'ID', 5); + + treeGrid.moving = true; + + const header = TreeGridFunctions.getHeaderCell(fix, 'ID').nativeElement; + const headerRect = header.getBoundingClientRect(); + const startX = headerRect.width / 2; + const startY = headerRect.height / 2; + + UIInteractions.simulatePointerEvent('pointerdown', header, startX, startY); + await wait(16); + UIInteractions.simulatePointerEvent('pointermove', header, startX + headerRect.width, startY); + await wait(16); + UIInteractions.simulatePointerEvent('pointerup', header, startX + headerRect.width, startY); + await wait(16); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeColumn(fix, 'ParentID', 5); + }); + + it('(API) should transform a non-tree column into a tree column when moving the original tree-column through', () => { + TreeGridFunctions.verifyTreeColumn(fix, 'ID', 5); + + // Move tree-column + const sourceColumn = treeGrid.columnList.filter(c => c.field === 'ID')[0]; + const targetColumn = treeGrid.columnList.filter(c => c.field === 'JobTitle')[0]; + treeGrid.moveColumn(sourceColumn, targetColumn); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeColumn(fix, 'ParentID', 5); + }); + + it('(API) should autosize tree-column', () => { + const headerCell = TreeGridFunctions.getHeaderCell(fix, 'ID'); + const column = treeGrid.columnList.filter(c => c.field === 'ID')[0]; + + expect(headerCell.nativeElement.getBoundingClientRect().width).toBe(180, 'incorrect column width'); + expect(parseInt(column.width, 10)).toBe(180); + + // API autosizing + column.autosize(); + fix.detectChanges(); + + expect(headerCell.nativeElement.getBoundingClientRect().width).toBe(135, 'incorrect headerCell width'); + expect(parseInt(column.width, 10)).toBe(135); + }); + + it('(UI) should autosize tree-column', () => { + const headerCell = TreeGridFunctions.getHeaderCell(fix, 'ID').parent; + const column = treeGrid.columnList.filter(c => c.field === 'ID')[0]; + column.resizable = true; + treeGrid.cdr.detectChanges(); + + expect(headerCell.nativeElement.getBoundingClientRect().width).toBe(180, 'incorrect column width'); + expect(parseInt(column.width, 10)).toBe(180); + + // UI autosizing + const resizer = headerCell.query(By.css(GRID_RESIZE_CLASS)).nativeElement; + UIInteractions.simulateMouseEvent('dblclick', resizer, 225, 5); + fix.detectChanges(); + + expect(headerCell.nativeElement.getBoundingClientRect().width).toBe(135, 'incorrect headerCell width'); + expect(parseInt(column.width, 10)).toBe(135); + }); + }); + + describe('Row editing', () => { + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridRowEditingComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + }); + + it('should show the banner below the edited parent node', () => { + // Collapsed state + const grid = fix.componentInstance.treeGrid as IgxTreeGridComponent; + + const verifyBannerPositioning = (columnIndex: number) => { + const cellElem = grid.gridAPI.get_cell_by_index(columnIndex, 'Name'); + const cell = grid.getCellByColumn(columnIndex, 'Name'); + cell.editMode = true; + fix.detectChanges(); + + const editRow = (cellElem as any).intRow.nativeElement; + const banner = fix.debugElement.query(By.css('.' + CSS_CLASS_BANNER)).nativeElement; + + const bannerTop = banner.getBoundingClientRect().top; + const editRowBottom = editRow.getBoundingClientRect().bottom; + + // The banner appears below the row + expect(bannerTop).toBeGreaterThanOrEqual(editRowBottom); + // No much space between the row and the banner + expect(bannerTop - editRowBottom).toBeLessThan(2); + }; + + grid.collapseAll(); + fix.detectChanges(); + verifyBannerPositioning(0); + + // Expanded state + grid.expandAll(); + fix.detectChanges(); + verifyBannerPositioning(3); + }); + + it('should show the banner below the edited child node', () => { + const grid = fix.componentInstance.treeGrid as IgxTreeGridComponent; + grid.expandAll(); + fix.detectChanges(); + + const cell = grid.gridAPI.get_cell_by_index(1, 'Name'); + cell.setEditMode(true); + fix.detectChanges(); + + // const editRow = cell.row.nativeElement; + const editRow = grid.gridAPI.get_row_by_index(1).nativeElement; + const banner = fix.debugElement.query(By.css('.' + CSS_CLASS_BANNER)).nativeElement; + + const bannerTop = banner.getBoundingClientRect().top; + const editRowBottom = editRow.getBoundingClientRect().bottom; + + // The banner appears below the row + expect(bannerTop).toBeGreaterThanOrEqual(editRowBottom); + // No much space between the row and the banner + expect(bannerTop - editRowBottom).toBeLessThan(2); + }); + + it('should show the banner above the last parent node when in edit mode', () => { + const grid = fix.componentInstance.treeGrid as IgxTreeGridComponent; + grid.height = '200px'; + fix.detectChanges(); + + grid.collapseAll(); + fix.detectChanges(); + + const cell = grid.gridAPI.get_cell_by_index(2, 'Name'); + cell.setEditMode(true); + fix.detectChanges(); + + // const editRow = cell.row.nativeElement; + const editRow = grid.gridAPI.get_row_by_index(2).nativeElement; + const banner = fix.debugElement.query(By.css('.' + CSS_CLASS_BANNER)).nativeElement; + + const bannerBottom = banner.getBoundingClientRect().bottom; + const editRowTop = editRow.getBoundingClientRect().top; + + // The banner appears below the row + expect(bannerBottom).toBeLessThanOrEqual(editRowTop); + // No much space between the row and the banner + expect(editRowTop - bannerBottom).toBeLessThan(2); + }); + + 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.gridAPI.get_cell_by_index(grid.rowList.length - 1, 'Name'); + cell.setEditMode(true); + fix.detectChanges(); + + // const editRow = cell.row.nativeElement; + const editRow = grid.gridAPI.get_row_by_index(grid.rowList.length - 1).nativeElement; + const banner = fix.debugElement.query(By.css('.' + CSS_CLASS_BANNER)).nativeElement; + + const bannerBottom = banner.getBoundingClientRect().bottom; + const editRowTop = editRow.getBoundingClientRect().top; + + // The banner appears below the row + expect(bannerBottom).toBeLessThanOrEqual(editRowTop); + // No much space between the row and the banner + expect(editRowTop - bannerBottom).toBeLessThan(2); + }); + + 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.editMode = true; + fix.detectChanges(); + + 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.key); + fix.detectChanges(); + + banner = fix.debugElement.query(By.css('.' + CSS_CLASS_BANNER)); + // K.D. 01 Mar, 2022 #10634 Don't trigger endEdit/commit upon row expansion state change + expect(cell.editMode).toBe(true); + expect(banner.parent.attributes['aria-hidden']).toEqual('false'); + + // Edit parent row cell + cell.editMode = true; + fix.detectChanges(); + + banner = fix.debugElement.query(By.css('.' + CSS_CLASS_BANNER)); + expect(banner.parent.attributes['aria-hidden']).toEqual('false'); + + // Collapse parent row + grid.collapseRow(cell.row.key); + fix.detectChanges(); + + banner = fix.debugElement.query(By.css('.' + CSS_CLASS_BANNER)); + // K.D. 01 Mar, 2022 #10634 Don't trigger endEdit/commit upon row expansion state change + expect(cell.editMode).toBe(true); + expect(banner.parent.attributes['aria-hidden']).toEqual('false'); + }); + + 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.editMode = true; + fix.detectChanges(); + + let banner = fix.debugElement.query(By.css('.' + CSS_CLASS_BANNER)); + expect(banner.parent.attributes['aria-hidden']).toEqual('false'); + + // Collapse parent child row + let parentRow = grid.getRowByIndex(3); + grid.collapseRow(parentRow.key); + fix.detectChanges(); + + banner = fix.debugElement.query(By.css('.' + CSS_CLASS_BANNER)); + expect(childCell.editMode).toBe(false); + // K.D. 28 Feb, 2022 #10634 Don't trigger endEdit/commit upon row expansion state change + expect(banner.parent.attributes['aria-hidden']).toEqual('false'); + + // Edit child row cell + const parentCell = grid.getCellByColumn(3, 'Name'); + parentCell.editMode = 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.key); + fix.detectChanges(); + + banner = fix.debugElement.query(By.css('.' + CSS_CLASS_BANNER)); + expect(parentCell.editMode).toBe(false); + // K.D. 01 Mar, 2022 #10634 Don't trigger endEdit/commit upon row expansion state change + expect(banner.parent.attributes['aria-hidden']).toEqual('false'); + }); + + it('TAB navigation should not leave the edited row and the banner.', async () => { + const grid = fix.componentInstance.treeGrid as IgxTreeGridComponent; + const dateCell = grid.getCellByColumn(2, 'HireDate'); + const nameCell = grid.getCellByColumn(2, 'Name'); + const idCell = grid.getCellByColumn(2, 'ID'); + const ageCell = grid.getCellByColumn(2, 'Age'); + UIInteractions.simulateDoubleClickAndSelectEvent(grid.gridAPI.get_cell_by_index(2, 'HireDate')); + await wait(30); + fix.detectChanges(); + + await TreeGridFunctions.moveGridCellWithTab(fix, grid.gridAPI.get_cell_by_index(2, 'HireDate')); + expect(dateCell.editMode).toBeFalsy(); + expect(nameCell.editMode).toBeTruthy(); + + await TreeGridFunctions.moveGridCellWithTab(fix, grid.gridAPI.get_cell_by_index(2, 'Name')); + expect(nameCell.editMode).toBeFalsy(); + expect(idCell.editMode).toBeFalsy(); + expect(ageCell.editMode).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, grid.gridAPI.get_cell_by_index(2, 'Age')); + 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 as any).move).not.toHaveBeenCalled(); + expect(mockObj.preventDefault).not.toHaveBeenCalled(); + expect(mockObj.stopPropagation).toHaveBeenCalled(); + + doneBtn.triggerEventHandler('keydown.tab', mockObj); + await wait(30); + fix.detectChanges(); + expect(dateCell.editMode).toBeTruthy(); + expect((grid.rowEditTabs.last as any).move).toHaveBeenCalled(); + expect(mockObj.preventDefault).toHaveBeenCalled(); + expect(mockObj.stopPropagation).toHaveBeenCalled(); + }); + + it('should preserve updates after removing Filtering', () => { + const grid = fix.componentInstance.treeGrid as IgxTreeGridComponent; + grid.filter('Age', 40, IgxNumberFilteringOperand.instance().condition('greaterThan')); + fix.detectChanges(); + + const childCell = grid.getCellByColumn(2, 'Age'); + const childRowID = childCell.row.key; + const parentCell = grid.getCellByColumn(0, 'Age'); + const parentRowID = parentCell.row.key; + + childCell.update(18); + parentCell.update(33); + fix.detectChanges(); + + grid.clearFilter(); + fix.detectChanges(); + + const childRow = grid.rowList.filter(r => r.key === childRowID)[0] as IgxTreeGridRowComponent; + const editedChildCell = childRow.cells.filter(c => c.column.field === 'Age')[0]; + expect(editedChildCell.value).toEqual(18); + + const parentRow = grid.rowList.filter(r => r.key === parentRowID)[0] as IgxTreeGridRowComponent; + const editedParentCell = parentRow.cells.filter(c => c.column.field === 'Age')[0]; + expect(editedParentCell.value).toEqual(33); + + }); + + it('should preserve updates after removing Sorting', () => { + const grid = fix.componentInstance.treeGrid as IgxTreeGridComponent; + grid.sort({ fieldName: 'Age', dir: SortingDirection.Desc, ignoreCase: false }); + fix.detectChanges(); + + const childCell = grid.gridAPI.get_cell_by_index(0, 'Age'); + const childRowID = childCell.row.key; + childCell.update(14); + const parentCell = grid.gridAPI.get_cell_by_index(1, 'Age'); + const parentRowID = parentCell.row.key; + parentCell.update(80); + fix.detectChanges(); + + grid.clearSort(); + fix.detectChanges(); + + const childRow = grid.rowList.filter(r => r.key === childRowID)[0] as IgxTreeGridRowComponent; + const editedChildCell = childRow.cells.filter(c => c.column.field === 'Age')[0]; + expect(editedChildCell.value).toEqual(14); + + const parentRow = grid.rowList.filter(r => r.key === parentRowID)[0] as IgxTreeGridRowComponent; + const editedParentCell = parentRow.cells.filter(c => c.column.field === 'Age')[0]; + expect(editedParentCell.value).toEqual(80); + }); + + it('should select the text when the first cell (tree grid cell) enters edit mode', fakeAsync(() => { + const grid = fix.componentInstance.treeGrid as IgxTreeGridComponent; + grid.expandAll(); + fix.detectChanges(); + + // move the 'string' column 'Name' to first position, so its cells are the tree grid cells + const colName = grid.getColumnByName('Name'); + const colHireDate = grid.getColumnByName('HireDate'); + grid.moveColumn(colName, colHireDate, DropPosition.BeforeDropTarget); + fix.detectChanges(); + tick(100); + + const cell = grid.gridAPI.get_cell_by_index(0, 'Name'); + cell.setEditMode(true); + fix.detectChanges(); + tick(100); + + expect(cell.editMode).toBe(true); + expect(document.activeElement.nodeName).toEqual('INPUT') + expect((document.activeElement as HTMLInputElement).value).toBe('John Winchester'); + expect((document.activeElement as HTMLInputElement).selectionStart).toEqual(0); + expect((document.activeElement as HTMLInputElement).selectionEnd).toEqual(15); + })); + }); + + describe('Batch Editing', () => { + it('Children are transformed into parent nodes after their parent is deleted', () => { + fix = TestBed.createComponent(IgxTreeGridRowEditingTransactionComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid as IgxTreeGridComponent; + + const row: HTMLElement = treeGrid.gridAPI.get_row_by_index(0).nativeElement; + treeGrid.cascadeOnDelete = false; + const trans = treeGrid.transactions; + + treeGrid.deleteRowById(1); + fix.detectChanges(); + + 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); + fix.detectChanges(); + + 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); + + expect(treeGrid.getRowByIndex(-1)).toBeUndefined(); + expect(treeGrid.getRowByKey(-1)).toBeUndefined(); + }); + + it('Children are deleted along with their parent', () => { + fix = TestBed.createComponent(IgxTreeGridRowEditingTransactionComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid as IgxTreeGridComponent; + treeGrid.cascadeOnDelete = true; + const trans = treeGrid.transactions; + + treeGrid.deleteRowById(1); + fix.detectChanges(); + + for (let i = 0; i < 5; i++) { + const curRow: HTMLElement = treeGrid.gridAPI.get_row_by_index(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); + fix.detectChanges(); + + 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', () => { + fix = TestBed.createComponent(IgxTreeGridRowEditingHierarchicalDSTransactionComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid as IgxTreeGridComponent; + const trans = treeGrid.transactions; + + const targetCell = treeGrid.getCellByColumn(3, 'Age'); + targetCell.editMode = true; + targetCell.update('333'); + fix.detectChanges(); + + // ged DONE button and click it + const rowEditingBannerElement = fix.debugElement.query(By.css('.igx-banner__row')).nativeElement; + const doneButtonElement = rowEditingBannerElement.lastElementChild; + doneButtonElement.dispatchEvent(new Event('click')); + fix.detectChanges(); + + // Verify the value is updated and the correct style is applied before committing + expect(targetCell.editMode).toBeFalsy(); + expect(targetCell.value).toBe(333); + expect(treeGrid.gridAPI.get_cell_by_index(3, 'Age').nativeElement.classList).toContain('igx-grid__td--edited'); + + // Commit + trans.commit(treeGrid.data, treeGrid.primaryKey, treeGrid.childDataKey); + + // 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 }); + fix.detectChanges(); + + // Edit a cell value and check it is correctly updated + const newTargetCell = treeGrid.getCellByColumn(10, 'Age'); + newTargetCell.editMode = true; + newTargetCell.update('666'); + fix.detectChanges(); + + expect(newTargetCell.value).toBe(666); + expect(treeGrid.gridAPI.get_cell_by_index(10, 'Age').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).data), + 475: Object.assign({}, treeGrid.getRowByKey(475).data), + 19: Object.assign({}, treeGrid.getRowByKey(19).data) + }; + const initialData = treeGrid.data.map(e => Object.assign({}, e)); + let targetCell: CellType; + // Get 147 row + targetCell = treeGrid.getCellByKey(147, 'Name'); + expect(targetCell.value).toEqual('John Winchester'); + // Edit 'Name' + targetCell.update('Testy Testington'); + fix.detectChanges(); + // Get 475 row (1st child of 147) + targetCell = treeGrid.getCellByKey(475, 'Age'); + expect(targetCell.value).toEqual(30); + // Edit Age + targetCell.update(42); + fix.detectChanges(); + // Get 19 row + targetCell = treeGrid.getCellByKey(19, 'Name'); + // Edit Name + expect(targetCell.value).toEqual('Yang Wang'); + targetCell.update('Old Richard'); + fix.detectChanges(); + expect(rowData[147].Name).not.toEqual(treeGrid.getRowByKey(147).data.Name); + expect(rowData[475].Age).not.toEqual(treeGrid.getRowByKey(475).data.Age); + expect(rowData[19].Name).not.toEqual(treeGrid.getRowByKey(19).data.Name); + expect(treeGridData[0].Employees[475]).toEqual(initialData[0].Employees[475]); + expect(trans.canUndo).toBeTruthy(); + expect(trans.canRedo).toBeFalsy(); + trans.undo(); + fix.detectChanges(); + trans.undo(); + fix.detectChanges(); + trans.undo(); + fix.detectChanges(); + expect(rowData[147].Name).toEqual(treeGrid.getRowByKey(147).data.Name); + expect(rowData[475].Age).toEqual(treeGrid.getRowByKey(475).data.Age); + expect(rowData[19].Name).toEqual(treeGrid.getRowByKey(19).data.Name); + expect(trans.canUndo).toBeFalsy(); + expect(trans.canRedo).toBeTruthy(); + trans.redo(); + fix.detectChanges(); + trans.redo(); + fix.detectChanges(); + trans.redo(); + fix.detectChanges(); + expect(rowData[147].Name).not.toEqual(treeGrid.getRowByKey(147).data.Name); + expect(rowData[475].Age).not.toEqual(treeGrid.getRowByKey(475).data.Age); + expect(rowData[19].Name).not.toEqual(treeGrid.getRowByKey(19).data.Name); + expect(treeGridData[0].Employees[475]).toEqual(initialData[0].Employees[475]); + trans.commit(treeGridData, treeGrid.primaryKey, treeGrid.childDataKey); + fix.detectChanges(); + 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', () => { + 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(); + + expect(trans.canUndo).toBe(true); + expect(treeGrid.gridAPI.get_row_by_key(11).nativeElement.classList).toContain(CSS_CLASS_ROW_EDITED); + + trans.commit(treeGrid.data); + fix.detectChanges(); + + expect(treeGrid.gridAPI.get_row_by_key(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(); + + expect(trans.canUndo).toBe(true); + expect(treeGrid.gridAPI.get_row_by_key(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: HierarchicalTransaction = { + id: addedRowId_1, + type: TransactionType.ADD, + newValue: newRow + }; + expect(trans.add).toHaveBeenCalledWith(transParams); + + expect(treeGrid.records.get(addedRowId_1).level).toBe(0); + expect(treeGrid.gridAPI.get_row_by_key(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.gridAPI.get_row_by_key(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.gridAPI.get_row_by_key(addedRowId_2).nativeElement.classList).toContain(CSS_CLASS_ROW_EDITED); + expect(treeGrid.gridAPI.get_row_by_key(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); + fix.detectChanges(); + + treeGrid.addRow(childRow, 11); + fix.detectChanges(); + + expect(treeGrid.gridAPI.get_row_by_key(11).nativeElement.classList).toContain(CSS_CLASS_ROW_EDITED); + expect(treeGrid.gridAPI.get_row_by_key(12).nativeElement.classList).toContain(CSS_CLASS_ROW_EDITED); + + trans.commit(treeGrid.data); + fix.detectChanges(); + + expect(treeGrid.gridAPI.get_row_by_key(11).nativeElement.classList).not.toContain(CSS_CLASS_ROW_EDITED); + expect(treeGrid.gridAPI.get_row_by_key(12).nativeElement.classList).not.toContain(CSS_CLASS_ROW_EDITED); + + treeGrid.addRow(grandChildRow, 12); + fix.detectChanges(); + + expect(treeGrid.gridAPI.get_row_by_key(11).nativeElement.classList).not.toContain(CSS_CLASS_ROW_EDITED); + expect(treeGrid.gridAPI.get_row_by_key(12).nativeElement.classList).not.toContain(CSS_CLASS_ROW_EDITED); + expect(treeGrid.gridAPI.get_row_by_key(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); + fix.detectChanges(); + // 2. Add a child row to that parent + treeGrid.addRow(rowData.child, rowData.parent.ID); + fix.detectChanges(); + // 3. Verify the new rows are pending with the correct styles + expect(treeGrid.gridAPI.get_row_by_key(13).nativeElement.classList).toContain(CSS_CLASS_ROW_EDITED); + expect(treeGrid.gridAPI.get_row_by_key(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); + fix.detectChanges(); + // 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.gridAPI.get_row_by_key(13).nativeElement.classList).not.toContain(CSS_CLASS_ROW_EDITED); + expect(treeGrid.gridAPI.get_row_by_key(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); + fix.detectChanges(); + // 7. verify the pending styles is applied only to the newly added row + // and not to the previously added rows + expect(treeGrid.gridAPI.get_row_by_key(13).nativeElement.classList).not.toContain(CSS_CLASS_ROW_EDITED); + expect(treeGrid.gridAPI.get_row_by_key(133).nativeElement.classList).not.toContain(CSS_CLASS_ROW_EDITED); + expect(treeGrid.gridAPI.get_row_by_key(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; + treeGrid.batchEditing = true; + fix.detectChanges(); + 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); + fix.detectChanges(); + + const addedRow = treeGrid.rowList.filter(r => r.key === addedRowId)[0] as IgxTreeGridRowComponent; + treeGrid.selectRows([treeGrid.getRowByIndex(addedRow.index).key], true); + fix.detectChanges(); + expect(treeGrid.transactions.getTransactionLog().length).toEqual(1); + expect(trans.add).toHaveBeenCalled(); + expect(trans.add).toHaveBeenCalledTimes(1); + const transParams: HierarchicalTransaction = { + id: addedRowId, + type: TransactionType.ADD, + newValue: newRow + }; + expect(trans.add).toHaveBeenCalledWith(transParams); + + treeGrid.deleteRowById(treeGrid.selectedRows[0]); + fix.detectChanges(); + expect(treeGrid.rowList.filter(r => r.key === 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.key === 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', () => { + fix = TestBed.createComponent(IgxTreeGridRowEditingHierarchicalDSTransactionComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + const trans = treeGrid.transactions; + spyOn(trans, 'add').and.callThrough(); + + const parentRow = treeGrid.getRowByIndex(0); + 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.key); + fix.detectChanges(); + + const addedRow = treeGrid.rowList.filter(r => r.key === addedRowId)[0] as IgxTreeGridRowComponent; + treeGrid.selectRows([treeGrid.getRowByIndex(addedRow.index).key], true); + fix.detectChanges(); + expect(treeGrid.transactions.getTransactionLog().length).toEqual(1); + expect(trans.add).toHaveBeenCalled(); + expect(trans.add).toHaveBeenCalledTimes(1); + const transParams: HierarchicalTransaction = { + id: addedRowId, + path: [parentRow.key], + newValue: newRow, + type: TransactionType.ADD + }; + expect(trans.add).toHaveBeenCalledWith(transParams, null); + + treeGrid.deleteRowById(treeGrid.selectedRows[0]); + fix.detectChanges(); + expect(treeGrid.rowList.filter(r => r.key === 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.key === 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; + treeGrid.batchEditing = true; + fix.detectChanges(); + 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.key === addedRowId)[0] as IgxTreeGridRowComponent; + treeGrid.selectRows([treeGrid.getRowByIndex(addedRow.index).key], true); + fix.detectChanges(); + expect(treeGrid.transactions.getTransactionLog().length).toEqual(1); + expect(trans.add).toHaveBeenCalled(); + expect(trans.add).toHaveBeenCalledTimes(1); + const transParams: HierarchicalTransaction = { id: addedRowId, type: TransactionType.ADD, newValue: newRow }; + expect(trans.add).toHaveBeenCalledWith(transParams); + + treeGrid.deleteRowById(treeGrid.selectedRows[0]); + fix.detectChanges(); + expect(treeGrid.rowList.filter(r => r.key === 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.key === 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', () => { + fix = TestBed.createComponent(IgxTreeGridRowEditingHierarchicalDSTransactionComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + const trans = treeGrid.transactions; + spyOn(trans, 'add').and.callThrough(); + + const parentRow = treeGrid.getRowByIndex(1); + 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.key); + fix.detectChanges(); + + const addedRow = treeGrid.rowList.filter(r => r.key === addedRowId)[0] as IgxTreeGridRowComponent; + treeGrid.selectRows([treeGrid.getRowByIndex(addedRow.index).key], true); + fix.detectChanges(); + expect(treeGrid.transactions.getTransactionLog().length).toEqual(1); + expect(trans.add).toHaveBeenCalled(); + expect(trans.add).toHaveBeenCalledTimes(1); + const transPasrams: HierarchicalTransaction = { + id: addedRowId, + path: [treeGrid.getRowByIndex(0).key, parentRow.key], + newValue: newRow, + type: TransactionType.ADD + }; + expect(trans.add).toHaveBeenCalledWith(transPasrams, null); + + treeGrid.deleteRowById(treeGrid.selectedRows[0]); + fix.detectChanges(); + expect(treeGrid.rowList.filter(r => r.key === 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.key === 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); + }); + + it('Should not add child row to deleted parent row - Hierarchical DS', () => { + const fixture = TestBed.createComponent(IgxTreeGridRowEditingHierarchicalDSTransactionComponent); + const grid = fixture.componentInstance.treeGrid; + fixture.detectChanges(); + + grid.deleteRowById(147); + expect(grid.transactions.getTransactionLog().length).toBe(1); + + expect(() => grid.addRow(grid.data, 147)).toThrow(Error(`Cannot add child row to deleted parent row`)); + expect(grid.transactions.getTransactionLog().length).toBe(1); + }); + + it('Should not add child row to deleted parent row - Flat DS', () => { + const fixture = TestBed.createComponent(IgxTreeGridRowEditingTransactionComponent); + const grid = (fixture as ComponentFixture).componentInstance.treeGrid; + grid.cascadeOnDelete = false; + fixture.detectChanges(); + + grid.deleteRowById(1); + expect(grid.transactions.getTransactionLog().length).toBe(1); + + expect(() => grid.addRow(grid.data, 1)).toThrow(Error(`Cannot add child row to deleted parent row`)); + expect(grid.transactions.getTransactionLog().length).toBe(1); + }); + + it('should return correctly the rowData', () => { + const fixture = TestBed.createComponent(IgxTreeGridRowEditingTransactionComponent); + const grid = (fixture as ComponentFixture).componentInstance.treeGrid; + grid.cascadeOnDelete = false; + fixture.detectChanges(); + + const row = {ID: 2, ParentID: 1, Name: 'Gilberto Todd', JobTitle: 'Director', Age: 41}; + expect(grid.getRowData(2)).toEqual(row); + + grid.sort({ fieldName: 'Age', dir: SortingDirection.Desc, ignoreCase: true }); + fixture.detectChanges(); + + expect(grid.getRowData(2)).toEqual(row); + expect(grid.getRowData(11)).toEqual({}); + + grid.filter('Age', 43, IgxNumberFilteringOperand.instance().condition('greaterThan')); + fixture.detectChanges(); + + expect(grid.getRowData(2)).toEqual(row); + expect(grid.getRowData(11)).toEqual({}); + + const newRow = {ID: 11, ParentID: 1, Name: 'Joe Peterson', JobTitle: 'Manager', Age: 37}; + grid.addRow(newRow); + fixture.detectChanges(); + + grid.clearFilter(); + fixture.detectChanges(); + + expect(grid.transactions.getTransactionLog().length).toEqual(1); + expect(grid.getRowData(11)).toEqual(newRow); + }); + + it('Should have transactions enabled when batchEditing === false and service is provider', () => { + const fixture = TestBed.createComponent(IgxTreeGridRowEditingHierarchicalDSTransactionComponent); + const grid = fixture.componentInstance.treeGrid; + fixture.detectChanges(); + + grid.batchEditing = false; + fixture.detectChanges(); + + expect(grid.batchEditing).toBeFalsy(); + expect(grid.transactions.enabled).toBeTruthy(); + + grid.deleteRowById(147); + expect(grid.transactions.getTransactionLog().length).toBe(1); + }); + }); + + describe('Multi-column header', () => { + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridMultiColHeadersComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + }); + + it('Should transform a hidden column to a tree column when it becomes visible and it is part of a column group', () => { + TreeGridFunctions.verifyTreeColumnInMultiColHeaders(fix, 'ID', 4); + + const column = treeGrid.columnList.filter(c => c.field === 'ID')[0]; + column.hidden = true; + fix.detectChanges(); + + TreeGridFunctions.verifyTreeColumnInMultiColHeaders(fix, 'Name', 3); + + column.hidden = false; + fix.detectChanges(); + + TreeGridFunctions.verifyTreeColumnInMultiColHeaders(fix, 'ID', 4); + }); + + it('Should transform a hidden column to a tree column when all columns from left-most group are hidden', () => { + // hide Name column so that the tested columns (ID and HireDate) are not part of the same group + const columnName = treeGrid.columnList.filter(c => c.field === 'Name')[0]; + columnName.hidden = true; + fix.detectChanges(); + + TreeGridFunctions.verifyTreeColumnInMultiColHeaders(fix, 'ID', 3); + + const column = treeGrid.columnList.filter(c => c.field === 'ID')[0]; + column.hidden = true; + fix.detectChanges(); + + TreeGridFunctions.verifyTreeColumnInMultiColHeaders(fix, 'HireDate', 2); + + column.hidden = false; + fix.detectChanges(); + + TreeGridFunctions.verifyTreeColumnInMultiColHeaders(fix, 'ID', 3); + }); + + it('(API) Should transform a non-tree column into a tree column when moving it first and both are part of the same group', () => { + TreeGridFunctions.verifyTreeColumnInMultiColHeaders(fix, 'ID', 4); + + // Move tree-column + const sourceColumn = treeGrid.columnList.filter(c => c.field === 'ID')[0]; + const targetColumn = treeGrid.columnList.filter(c => c.field === 'Name')[0]; + treeGrid.moveColumn(sourceColumn, targetColumn); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeColumnInMultiColHeaders(fix, 'Name', 4); + }); + + it('(UI) Should transform a non-tree column into a tree column when moving it first within a group', async () => { + TreeGridFunctions.verifyTreeColumnInMultiColHeaders(fix, 'ID', 4); + + treeGrid.moving = true; + + const header = TreeGridFunctions.getHeaderCellMultiColHeaders(fix, 'ID').nativeElement; + UIInteractions.simulatePointerEvent('pointerdown', header, 100, 90); + UIInteractions.simulatePointerEvent('pointermove', header, 106, 96); + await wait(); + UIInteractions.simulatePointerEvent('pointermove', header, 420, 90); + UIInteractions.simulatePointerEvent('pointerup', header, 420, 90); + await wait() + fix.detectChanges(); + + TreeGridFunctions.verifyTreeColumnInMultiColHeaders(fix, 'Name', 4); + }); + + it('(API) Should transform a non-tree column of a column group to a tree column when its group is moved first', () => { + TreeGridFunctions.verifyTreeColumnInMultiColHeaders(fix, 'ID', 4); + + // Move group-column + const sourceColumn = treeGrid.columnList.filter(c => c.header === 'General Information')[0]; + const targetColumn = treeGrid.columnList.filter(c => c.header === 'Additional Information')[0]; + treeGrid.moveColumn(sourceColumn, targetColumn); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeColumnInMultiColHeaders(fix, 'HireDate', 4); + }); + + it('(UI) Should transform a non-tree column of a column group to a tree column when its group is moved first', async () => { + TreeGridFunctions.verifyTreeColumnInMultiColHeaders(fix, 'ID', 4); + + treeGrid.moving = true; + fix.detectChanges(); + + const header = fix.debugElement.queryAll(By.css('.igx-grid-thead__item'))[3].nativeElement; + // const header = treeGrid.headerGroups[0].nativeElement; + + UIInteractions.simulatePointerEvent('pointerdown', header, 100, 40); + fix.detectChanges(); + await wait(100); + + UIInteractions.simulatePointerEvent('pointermove', header, 106, 46); + fix.detectChanges(); + await wait(100); + + UIInteractions.simulatePointerEvent('pointermove', header, 700, 40); + fix.detectChanges(); + await wait(100); + UIInteractions.simulatePointerEvent('pointerup', header, 700, 40); + fix.detectChanges(); + + 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 = []; + fix.detectChanges(); + + 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); + fix.detectChanges(); + treeGrid.addRow(childRow, 11); + fix.detectChanges(); + expect(treeGrid.gridAPI.get_row_by_key(11).nativeElement.classList).toContain(CSS_CLASS_ROW_EDITED); + expect(treeGrid.gridAPI.get_row_by_key(12).nativeElement.classList).toContain(CSS_CLASS_ROW_EDITED); + trans.commit(treeGrid.data, treeGrid.primaryKey, treeGrid.childDataKey); + fix.detectChanges(); + expect(treeGrid.gridAPI.get_row_by_key(11).nativeElement.classList).not.toContain(CSS_CLASS_ROW_EDITED); + expect(treeGrid.gridAPI.get_row_by_key(12).nativeElement.classList).not.toContain(CSS_CLASS_ROW_EDITED); + treeGrid.addRow(grandChildRow, 12); + fix.detectChanges(); + expect(treeGrid.gridAPI.get_row_by_key(11).nativeElement.classList).not.toContain(CSS_CLASS_ROW_EDITED); + expect(treeGrid.gridAPI.get_row_by_key(12).nativeElement.classList).not.toContain(CSS_CLASS_ROW_EDITED); + expect(treeGrid.gridAPI.get_row_by_key(13).nativeElement.classList).toContain(CSS_CLASS_ROW_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 = []; + fix.detectChanges(); + + 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); + fix.detectChanges(); + + treeGrid.addRow(childRow, 11); + fix.detectChanges(); + + expect(treeGrid.gridAPI.get_row_by_key(11).nativeElement.classList).toContain(CSS_CLASS_ROW_EDITED); + expect(treeGrid.gridAPI.get_row_by_key(12).nativeElement.classList).toContain(CSS_CLASS_ROW_EDITED); + + trans.commit(treeGrid.data); + fix.detectChanges(); + + expect(treeGrid.gridAPI.get_row_by_key(11).nativeElement.classList).not.toContain(CSS_CLASS_ROW_EDITED); + expect(treeGrid.gridAPI.get_row_by_key(12).nativeElement.classList).not.toContain(CSS_CLASS_ROW_EDITED); + + treeGrid.addRow(grandChildRow, 12); + fix.detectChanges(); + + expect(treeGrid.gridAPI.get_row_by_key(11).nativeElement.classList).not.toContain(CSS_CLASS_ROW_EDITED); + expect(treeGrid.gridAPI.get_row_by_key(12).nativeElement.classList).not.toContain(CSS_CLASS_ROW_EDITED); + expect(treeGrid.gridAPI.get_row_by_key(13).nativeElement.classList).toContain(CSS_CLASS_ROW_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); + }); + }); + + describe('Column Pinning', () => { + it('should have right pinning applied correctly', () => { + fix = TestBed.createComponent(IgxTreeGridRowEditingHierarchicalDSTransactionComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid as IgxTreeGridComponent; + treeGrid.pinning.columns = 1; + treeGrid.columnList.find(x => x.field === 'Age').pinned = true; + + fix.detectChanges(); + const rightMostGridPart = treeGrid.nativeElement.getBoundingClientRect().right; + const leftMostGridPart = treeGrid.nativeElement.getBoundingClientRect().left; + const leftMostRightPinnedCellsPart = treeGrid.gridAPI.get_cell_by_index(0, 'Age').nativeElement.getBoundingClientRect().left; + const pinnedCellWidth = treeGrid.gridAPI.get_cell_by_index(0, 'Age').width; + // Expects that right pinning has been in action + expect(leftMostGridPart !== leftMostRightPinnedCellsPart).toBeTruthy(); + // Expects that pinned column is in the visible grid's area + expect(leftMostRightPinnedCellsPart < rightMostGridPart).toBeTruthy(); + // Expects that the whole pinned column is visible + expect(leftMostRightPinnedCellsPart + Number.parseInt(pinnedCellWidth, 10) <= rightMostGridPart).toBeTruthy(); + }); + }); + + describe('Row Pinning', () => { + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridRowPinningComponent); + fix.detectChanges(); + + treeGrid = fix.componentInstance.treeGrid as IgxTreeGridComponent; + }); + + it('should pin/unpin a row', () => { + treeGrid.pinRow(711); + + expect(treeGrid.pinnedRecordsCount).toBe(1); + expect(treeGrid.getRowByKey(711).pinned).toBe(true); + + treeGrid.getRowByKey(711).pinned = false; + fix.detectChanges(); + + expect(treeGrid.pinnedRecordsCount).toBe(0); + expect(treeGrid.getRowByKey(711).pinned).toBe(false); + + treeGrid.getRowByKey(711).pin(); + expect(treeGrid.pinnedRecordsCount).toBe(1); + + treeGrid.getRowByKey(711).unpin(); + expect(treeGrid.pinnedRecordsCount).toBe(0); + + + treeGrid.getRowByKey(711).pinned = true; + fix.detectChanges(); + expect(treeGrid.pinnedRecordsCount).toBe(1); + + treeGrid.getRowByKey(711).pinned = false; + fix.detectChanges(); + expect(treeGrid.pinnedRecordsCount).toBe(0); + }); + + it('should pin/unpin a row at the bottom', () => { + /* Pin rows to bottom */ + treeGrid.pinning.rows = 1; + + const visibleRecordsLength = treeGrid.records.size; + treeGrid.pinRow(711); + + expect(treeGrid.getRowByIndex(visibleRecordsLength).key).toBe(711); + }); + + it('should calculate row indices correctly after row pinning', () => { + const firstRow = treeGrid.getRowByIndex(0); + const secondRow = treeGrid.getRowByIndex(1); + + treeGrid.pinRow(711); + + expect(treeGrid.getRowByIndex(0).key).toBe(711); + expect(treeGrid.getRowByIndex(1).key).toBe(firstRow.key); + expect(treeGrid.getRowByIndex(2).key).toBe(secondRow.key); + + treeGrid.unpinRow(711); + + expect(treeGrid.getRowByIndex(0).key).toBe(firstRow.key); + expect(treeGrid.getRowByIndex(1).key).toBe(secondRow.key); + }); + + it('should disable pinned row instance in the body', () => { + const rowToPin = treeGrid.getRowByIndex(0); + const primaryKey = treeGrid.primaryKey; + + treeGrid.pinRow(rowToPin.data[primaryKey]); + + expect(treeGrid.gridAPI.get_row_by_index(0).disabled).toBe(false); + expect(treeGrid.gridAPI.get_row_by_index(1).disabled).toBe(true); + + treeGrid.unpinRow(rowToPin.data[primaryKey]); + + expect(treeGrid.gridAPI.get_row_by_index(0).disabled).toBe(false); + expect(treeGrid.gridAPI.get_row_by_index(1).disabled).toBe(false); + + }); + + it('should add pinned chip in the pinned row instance in the body', () => { + const rowToPin = treeGrid.getRowByIndex(0); + const primaryKey = treeGrid.primaryKey; + + treeGrid.pinRow(rowToPin.data[primaryKey]); + + const firstColumnField = treeGrid.columnList.get(0).field; + const pinnedChipPosition = treeGrid.gridAPI.get_cell_by_index(1, firstColumnField); + const pinnedRowCell = treeGrid.gridAPI.get_cell_by_index(0, firstColumnField); + const wrongChipPosition = treeGrid.gridAPI.get_cell_by_index(2, firstColumnField); + + expect(pinnedChipPosition.nativeElement.getElementsByClassName('igx-grid__td--pinned-chip').length).toBe(1); + expect(pinnedRowCell.nativeElement.getElementsByClassName('igx-grid__td--pinned-chip').length).toBe(0); + expect(wrongChipPosition.nativeElement.getElementsByClassName('igx-grid__td--pinned-chip').length).toBe(0); + }); + + it('pinned chip should always be in the first column', () => { + const rowToPin = treeGrid.getRowByIndex(0); + const primaryKey = treeGrid.primaryKey; + + treeGrid.pinRow(rowToPin.data[primaryKey]); + + const thirdColumnField = treeGrid.columnList.get(2).field; + + treeGrid.moveColumn(treeGrid.columnList.get(2), treeGrid.columnList.get(0), DropPosition.BeforeDropTarget); + fix.detectChanges(); + + const pinnedChipExpectedPosition = treeGrid.gridAPI.get_cell_by_index(1, thirdColumnField); + expect(pinnedChipExpectedPosition.nativeElement.getElementsByClassName('igx-grid__td--pinned-chip').length).toBe(1); + }); + + it('should expand/collapse a pinned row with children', () => { + let rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(10); + const rowToPin = treeGrid.getRowByIndex(0); + + rowToPin.pin(); + + // collapse pinned row + treeGrid.toggleRow(rowToPin.key); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(5); + + // expand the pinned row + treeGrid.toggleRow(rowToPin.key); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(11); + }); + + it('should search in both pinned and unpinned rows', () => { + let searchResultsCount = treeGrid.findNext('John'); + expect(searchResultsCount).toBe(1); + + const rowToPin = treeGrid.getRowByIndex(0); + rowToPin.pin(); + + searchResultsCount = treeGrid.findNext('John'); + expect(searchResultsCount).toBe(2); + }); + + it('should apply filtering to both pinned and unpinned rows', () => { + treeGrid.pinRow(147); + treeGrid.pinRow(711); + + treeGrid.filter('ID', 147, IgxStringFilteringOperand.instance().condition('contains'), false); + fix.detectChanges(); + + const gridFilterData = treeGrid.filteredData; + expect(gridFilterData.length).toBe(2); + expect(gridFilterData[0].ID).toBe(147); + expect(gridFilterData[1].ID).toBe(147); + }); + + it('should apply sorting to both pinned and unpinned rows', () => { + treeGrid.pinRow(147); + treeGrid.pinRow(711); + + expect(treeGrid.getRowByIndex(0).key).toBe(147); + expect(treeGrid.getRowByIndex(1).key).toBe(711); + expect(treeGrid.getRowByIndex(2).key).toBe(147); + + treeGrid.sort({ fieldName: 'ID', dir: SortingDirection.Desc, ignoreCase: false }); + fix.detectChanges(); + + expect(treeGrid.getRowByIndex(0).key).toBe(711); + expect(treeGrid.getRowByIndex(1).key).toBe(147); + expect(treeGrid.getRowByIndex(2).key).toBe(847); + }); + + it('should not take into account pinned rows when changing items per page', () => { + treeGrid.pinRow(147); + + fix.componentInstance.paging = true; + fix.detectChanges(); + treeGrid.paginator.perPage = 5; + fix.detectChanges(); + + expect(treeGrid.dataView.length).toBe(6); + + treeGrid.paginator.perPage = 10; + fix.detectChanges(); + + expect(treeGrid.dataView.length).toBe(11); + }); + + it('should correctly apply paging state for grid and paginator when there are pinned rows.', () => { + fix.componentInstance.paging = true; + fix.detectChanges(); + const paginator = treeGrid.paginator; + paginator.perPage = 3; + treeGrid.height = '700px'; + fix.detectChanges(); + // pin the first row + treeGrid.getRowByIndex(0).pin(); + expect(treeGrid.rowList.length).toEqual(4); + expect(paginator.perPage).toEqual(3); + expect(paginator.totalRecords).toEqual(10); + expect(paginator.totalPages).toEqual(4); + + // pin the second row + treeGrid.getRowByIndex(2).pin(); + + expect(treeGrid.rowList.length).toEqual(5); + expect(paginator.perPage).toEqual(3); + expect(paginator.totalRecords).toEqual(10); + expect(paginator.totalPages).toEqual(4); + }); + + it('should have the correct records shown for pages with pinned rows', () => { + fix.componentInstance.paging = true; + fix.detectChanges(); + treeGrid.paginator.perPage = 6; + treeGrid.height = '700px'; + fix.detectChanges(); + treeGrid.getRowByIndex(0).pin(); + + let rows = treeGrid.rowList.toArray(); + + [147, 147, 475, 957, 317, 711, 998].forEach((x, index) => expect(parseInt(rows[index].cells.first.value, 10)).toEqual(x)); + + treeGrid.paginator.paginate(1); + fix.detectChanges(); + + rows = treeGrid.rowList.toArray(); + + [147, 299, 19, 847, 663].forEach((x, index) => expect(parseInt(rows[index].cells.first.value, 10)).toEqual(x)); + }); + + it('should make a correct selection', () => { + treeGrid.pinRow(147); + + const range = { rowStart: 0, rowEnd: 2, columnStart: 'ID', columnEnd: 'Name' }; + treeGrid.selectRange(range); + fix.detectChanges(); + + const selectedRange = treeGrid.getSelectedData(); + expect(selectedRange).toEqual([ + {ID: 147, Name: 'John Winchester'}, + {ID: 147, Name: 'John Winchester'}, + {ID: 475, Name: 'Michael Langdon'}, + ]); + }); + + it('should remove the pinned chip for filtered out parent', () => { + treeGrid.pinRow(147); + + treeGrid.filter('ID', 957, IgxStringFilteringOperand.instance().condition('contains'), false); + fix.detectChanges(); + + const firstRow = treeGrid.getRowByIndex(0); + + // Check getRowByIndex expanded, children and parent members + expect(firstRow.expanded).toBe(true); + // children.length equals the filtered our chidlren! + expect(firstRow.children.length).toEqual(1); + expect(treeGrid.getRowByIndex(1).parent.key).toEqual(147); + + const firstColumnField = treeGrid.columnList.get(0).field; + const pinnedChipExpectedPosition = treeGrid.gridAPI.get_cell_by_index(1, firstColumnField); + + expect(pinnedChipExpectedPosition.nativeElement.getElementsByClassName('igx-grid__td--pinned-chip').length).toBe(0); + }); + + it('should test getRowByIndex API members', () => { + treeGrid.filter('ID', 957, IgxStringFilteringOperand.instance().condition('contains'), false); + fix.detectChanges(); + + const firstRow = treeGrid.getRowByIndex(0); + + // Check getRowByIndex expanded, children and parent members + expect(firstRow.expanded).toBe(true); + expect(firstRow.hasChildren).toBe(true); + expect(firstRow.children[0].hasChildren).toBeFalse(); + // children.length equals the filtered our chidlren! + expect(firstRow.children.length).toEqual(1); + expect(firstRow.children[0] instanceof IgxTreeGridRow).toBeTrue(); + expect(firstRow.children[0].parent instanceof IgxTreeGridRow).toBeTrue(); + expect(firstRow.children[0].parent.key).toBe(firstRow.key); + expect(treeGrid.getRowByIndex(1).parent.key).toEqual(147); + + firstRow.expanded = false; + expect(firstRow.expanded).toBe(false); + + expect(firstRow.pinned).toBeFalse(); + firstRow.pinned = true; + expect(firstRow.pinned).toBeTrue(); + }); + + it('should delete pinned row without errors', () => { + treeGrid.pinRow(147); + fix.detectChanges(); + const firstRow = treeGrid.pinnedRows[0]; + + expect(firstRow.isRoot).toBe(true); + expect(firstRow.pinned).toBeTrue(); + expect(firstRow.data.ID).toEqual(147); + + treeGrid.deleteRowById(147); + fix.detectChanges(); + + expect(firstRow.isRoot).toBe(false); + }); + }); +}); diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-keyBoardNav.spec.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-keyBoardNav.spec.ts new file mode 100644 index 00000000000..26a57acba1e --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-keyBoardNav.spec.ts @@ -0,0 +1,845 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxTreeGridComponent } from './public_api'; +import { IgxTreeGridWithNoScrollsComponent, IgxTreeGridWithScrollsComponent } from '../../../test-utils/tree-grid-components.spec'; +import { TreeGridFunctions } from '../../../test-utils/tree-grid-functions.spec'; +import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; +import { clearGridSubs, setupGridScrollDetection } from '../../../test-utils/helper-utils.spec'; +import { GridFunctions } from '../../../test-utils/grid-functions.spec'; +import { DebugElement } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { CellType } from 'igniteui-angular/grids/core'; + +const DEBOUNCETIME = 30; + +describe('IgxTreeGrid - Key Board Navigation #tGrid', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxTreeGridWithNoScrollsComponent, + IgxTreeGridWithScrollsComponent + ] + }).compileComponents(); + })); + + describe('Navigation with no scroll', () => { + let fix; + let treeGrid: IgxTreeGridComponent; + let gridContent; + + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridWithNoScrollsComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + gridContent = GridFunctions.getGridContent(fix); + }); + + it('should navigate with arrow keys', () => { + spyOn(treeGrid.selected, 'emit').and.callThrough(); + let cell = treeGrid.gridAPI.get_cell_by_index(0, 'ID'); + + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + UIInteractions.triggerEventHandlerKeyDown('arrowdown', gridContent); + fix.detectChanges(); + + cell = treeGrid.gridAPI.get_cell_by_index(1, 'ID'); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + UIInteractions.triggerEventHandlerKeyDown('arrowright', gridContent); + fix.detectChanges(); + + cell = treeGrid.gridAPI.get_cell_by_index(1, 'Name'); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + UIInteractions.triggerEventHandlerKeyDown('arrowup', gridContent); + fix.detectChanges(); + + cell = treeGrid.gridAPI.get_cell_by_index(0, 'Name'); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + UIInteractions.triggerEventHandlerKeyDown('arrowleft', gridContent); + fix.detectChanges(); + + cell = treeGrid.gridAPI.get_cell_by_index(0, 'ID'); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(5); + }); + + it('should move to the top/bottom cell when navigate with Ctrl + arrow Up/Down keys', () => { + spyOn(treeGrid.selected, 'emit').and.callThrough(); + let cell = treeGrid.gridAPI.get_cell_by_index(5, 'ID'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + UIInteractions.triggerEventHandlerKeyDown('arrowdown', gridContent, false, false, true); + fix.detectChanges(); + + cell = treeGrid.gridAPI.get_cell_by_index(9, 'ID'); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + // press Ctrl+Arrow down on the last cell + UIInteractions.triggerEventHandlerKeyDown('arrowdown', gridContent, false, false, true); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + UIInteractions.triggerEventHandlerKeyDown('arrowup', gridContent, false, false, true); + fix.detectChanges(); + + cell = treeGrid.gridAPI.get_cell_by_index(0, 'ID'); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + // press Ctrl+Arrow up on the first cell + UIInteractions.triggerEventHandlerKeyDown('arrowup', gridContent, false, false, true); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(3); + }); + + it('should move to the leftmost/rightmost cell when navigate with Ctrl + arrow Left/Right keys', () => { + spyOn(treeGrid.selected, 'emit').and.callThrough(); + let cell = treeGrid.gridAPI.get_cell_by_index(0, 'HireDate'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + UIInteractions.triggerEventHandlerKeyDown('arrowright', gridContent, false, false, true); + fix.detectChanges(); + + cell = treeGrid.gridAPI.get_cell_by_index(0, 'OnPTO'); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + // press Ctrl+Arrow right on the last cell + UIInteractions.triggerEventHandlerKeyDown('arrowright', gridContent, false, false, true); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + UIInteractions.triggerEventHandlerKeyDown('arrowleft', gridContent, false, false, true); + fix.detectChanges(); + + cell = treeGrid.gridAPI.get_cell_by_index(0, 'ID'); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + // press Ctrl+Arrow left on the first cell + UIInteractions.triggerEventHandlerKeyDown('arrowleft', gridContent, false, false, true); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(3); + }); + + it('should move to the top left/bottom right cell when navigate with Ctrl + Home/End keys', () => { + spyOn(treeGrid.selected, 'emit').and.callThrough(); + let cell = treeGrid.gridAPI.get_cell_by_index(4, 'Name'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + UIInteractions.triggerEventHandlerKeyDown('end', gridContent, false, false, true); + fix.detectChanges(); + + cell = treeGrid.gridAPI.get_cell_by_index(9, 'OnPTO'); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + // press Ctrl+end on the last cell + UIInteractions.triggerEventHandlerKeyDown('end', gridContent, false, false, true); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + UIInteractions.triggerEventHandlerKeyDown('home', gridContent, false, false, true); + fix.detectChanges(); + + cell = treeGrid.gridAPI.get_cell_by_index(0, 'ID'); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + // press Ctrl+home on the first cell + UIInteractions.triggerEventHandlerKeyDown('home', gridContent, false, false, true); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(3); + }); + + it('should expand/collapse row when Alt + arrow Left/Right keys are pressed', () => { + spyOn(treeGrid.rowToggle, 'emit').and.callThrough(); + const cell = treeGrid.gridAPI.get_cell_by_index(0, 'ID'); + let rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(10); + + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent, true); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(4); + TreeGridFunctions.verifyTreeRowHasCollapsedIcon(rows[0]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.rowToggle.emit).toHaveBeenCalledTimes(1); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent, true); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(4); + TreeGridFunctions.verifyTreeRowHasCollapsedIcon(rows[0]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.rowToggle.emit).toHaveBeenCalledTimes(1); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent, true); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(10); + TreeGridFunctions.verifyTreeRowHasExpandedIcon(rows[0]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.rowToggle.emit).toHaveBeenCalledTimes(2); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent, true); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(10); + TreeGridFunctions.verifyTreeRowHasExpandedIcon(rows[0]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.rowToggle.emit).toHaveBeenCalledTimes(2); + }); + + it('should expand/collapse row when Alt + arrow Up/Down keys are pressed', () => { + spyOn(treeGrid.rowToggle, 'emit').and.callThrough(); + const cell = treeGrid.gridAPI.get_cell_by_index(3, 'HireDate'); + let rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(10); + + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent, true); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(7); + TreeGridFunctions.verifyTreeRowHasCollapsedIcon(rows[3]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.rowToggle.emit).toHaveBeenCalledTimes(1); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent, true); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(7); + TreeGridFunctions.verifyTreeRowHasCollapsedIcon(rows[3]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.rowToggle.emit).toHaveBeenCalledTimes(1); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent, true); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(10); + TreeGridFunctions.verifyTreeRowHasExpandedIcon(rows[3]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.rowToggle.emit).toHaveBeenCalledTimes(2); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent, true); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(10); + TreeGridFunctions.verifyTreeRowHasExpandedIcon(rows[3]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.rowToggle.emit).toHaveBeenCalledTimes(2); + }); + + it('should not change selection when press Alt + arrow Left/Right keys on a cell in a row without children', () => { + spyOn(treeGrid.rowToggle, 'emit').and.callThrough(); + const cell = treeGrid.gridAPI.get_cell_by_index(1, 'Name'); + let rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(10); + + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent, true); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(10); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.rowToggle.emit).toHaveBeenCalledTimes(0); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent, true); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(10); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.rowToggle.emit).toHaveBeenCalledTimes(0); + }); + + it('should change editable cell when Tab key is pressed', () => { + treeGrid.getColumnByName('ID').editable = true; + treeGrid.getColumnByName('HireDate').editable = true; + treeGrid.getColumnByName('Age').editable = true; + treeGrid.getColumnByName('OnPTO').editable = true; + fix.detectChanges(); + + let cell = treeGrid.gridAPI.get_cell_by_index(3, 'Age'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + UIInteractions.triggerEventHandlerKeyDown('Enter', gridContent); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(cell.editMode).toBe(true); + + UIInteractions.triggerEventHandlerKeyDown('Tab', gridContent); + fix.detectChanges(); + + expect(cell.editMode).toBe(false); + cell = treeGrid.gridAPI.get_cell_by_index(3, 'OnPTO'); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(cell.editMode).toBe(true); + + UIInteractions.triggerEventHandlerKeyDown('Tab', gridContent); + fix.detectChanges(); + + expect(cell.editMode).toBe(false); + cell = treeGrid.gridAPI.get_cell_by_index(4, 'ID'); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(cell.editMode).toBe(true); + + // Press tab when next cell is not editable + UIInteractions.triggerEventHandlerKeyDown('Tab', gridContent); + fix.detectChanges(); + + // The next editable cell should be opened in edit mode + expect(cell.editMode).toBe(false); + cell = treeGrid.gridAPI.get_cell_by_index(4, 'HireDate'); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(cell.editMode).toBe(true); + }); + + it('should change editable cell when Shift + Tab keys are pressed', () => { + treeGrid.getColumnByName('ID').editable = true; + treeGrid.getColumnByName('Name').editable = true; + treeGrid.getColumnByName('HireDate').editable = true; + treeGrid.getColumnByName('OnPTO').editable = true; + fix.detectChanges(); + + let cell = treeGrid.gridAPI.get_cell_by_index(3, 'Name'); + + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + UIInteractions.triggerEventHandlerKeyDown('Enter', gridContent); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(cell.editMode).toBe(true); + + UIInteractions.triggerEventHandlerKeyDown('Tab', gridContent, false, true); + fix.detectChanges(); + + expect(cell.editMode).toBe(false); + cell = treeGrid.gridAPI.get_cell_by_index(3, 'ID'); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(cell.editMode).toBe(true); + + UIInteractions.triggerEventHandlerKeyDown('Tab', gridContent, false, true); + fix.detectChanges(); + + expect(cell.editMode).toBe(false); + cell = treeGrid.gridAPI.get_cell_by_index(2, 'OnPTO'); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(cell.editMode).toBe(true); + + // Press Shift+Tab when next cell is not editable + UIInteractions.triggerEventHandlerKeyDown('Tab', gridContent, false, true); + fix.detectChanges(); + + expect(cell.editMode).toBe(false); + cell = treeGrid.gridAPI.get_cell_by_index(2, 'HireDate'); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(cell.editMode).toBe(true); + }); + }); + + describe('Navigation with scrolls', () => { + let fix; + let treeGrid: IgxTreeGridComponent; + let gridContent: DebugElement; + const treeColumns = ['ID', 'Name', 'HireDate', 'Age', 'OnPTO']; + + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridWithScrollsComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + gridContent = GridFunctions.getGridContent(fix); + setupGridScrollDetection(fix, treeGrid); + }); + + afterEach(() => { + clearGridSubs(); + }); + + it('should navigate with arrow Up and Down keys', async () => { + spyOn(treeGrid.selected, 'emit').and.callThrough(); + const firstCell: CellType = treeGrid.gridAPI.get_cell_by_index(5, 'ID'); + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, treeGrid.gridAPI.get_cell_by_index(5, 'ID')); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(1); + + for (let i = 5; i < 9; i++) { + let cell = treeGrid.gridAPI.get_cell_by_index(i, 'ID'); + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent); + await firstValueFrom(treeGrid.verticalScrollContainer.chunkLoad); + fix.detectChanges(); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell, false); + cell = treeGrid.gridAPI.get_cell_by_index(i + 1, 'ID'); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + } + + for (let i = 9; i > 0; i--) { + let cell = treeGrid.gridAPI.get_cell_by_index(i, 'ID'); + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent); + if (i <= 4) + await firstValueFrom(treeGrid.verticalScrollContainer.chunkLoad); + else + await wait(); + fix.detectChanges(); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell, false); + cell = treeGrid.gridAPI.get_cell_by_index(i - 1, 'ID'); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + } + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(14); + }); + + it('should navigate with arrow Left and Right', async () => { + const firstCell = treeGrid.gridAPI.get_cell_by_index(3, treeColumns[0]); + spyOn(treeGrid.selected, 'emit').and.callThrough(); + + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, firstCell); + + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(1); + + for (let i = 0; i < treeColumns.length - 1; i++) { + let cell = treeGrid.gridAPI.get_cell_by_index(3, treeColumns[i]); + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell, false); + cell = treeGrid.gridAPI.get_cell_by_index(3, treeColumns[i + 1]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(i + 2); + } + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent); + await wait(); + fix.detectChanges(); + + let lastCell = treeGrid.gridAPI.get_cell_by_index(3, treeColumns[treeColumns.length - 1]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, lastCell); + + for (let i = treeColumns.length - 1; i > 0; i--) { + let cell = treeGrid.gridAPI.get_cell_by_index(3, treeColumns[i]); + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell, false); + cell = treeGrid.gridAPI.get_cell_by_index(3, treeColumns[i - 1]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(2 * treeColumns.length - i); + } + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent); + await wait(); + fix.detectChanges(); + + lastCell = treeGrid.gridAPI.get_cell_by_index(3, treeColumns[0]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, lastCell); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(2 * treeColumns.length - 1); + }); + + it('should move to the top/bottom cell when navigate with Ctrl + arrow Up/Down', async () => { + spyOn(treeGrid.selected, 'emit').and.callThrough(); + let cell = treeGrid.gridAPI.get_cell_by_index(1, 'Name'); + + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(1); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent, false, false, true); + await wait(100); + fix.detectChanges(); + + cell = treeGrid.gridAPI.get_cell_by_index(9, 'Name'); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(2); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent, false, false, true); + await wait(100); + fix.detectChanges(); + + cell = treeGrid.gridAPI.get_cell_by_index(0, 'Name'); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(3); + }); + + it('should move to the leftmost/rightmost cell when navigate with Ctrl + arrow Left/Right keys', async () => { + spyOn(treeGrid.selected, 'emit').and.callThrough(); + let cell = treeGrid.gridAPI.get_cell_by_index(4, treeColumns[1]); + + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(1); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent, false, false, true); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + cell = treeGrid.gridAPI.get_cell_by_index(4, treeColumns[treeColumns.length - 1]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(2); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent, false, false, true); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + cell = treeGrid.gridAPI.get_cell_by_index(4, treeColumns[0]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(3); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent, false, false, true); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + cell = treeGrid.gridAPI.get_cell_by_index(4, treeColumns[treeColumns.length - 1]); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(4); + }); + + it('should move to the top left/bottom right cell when navigate with Ctrl + Home/End keys', async () => { + spyOn(treeGrid.selected, 'emit').and.callThrough(); + let cell = treeGrid.gridAPI.get_cell_by_index(2, treeColumns[2]); + + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(1); + + UIInteractions.triggerEventHandlerKeyDown('End', gridContent, false, false, true); + await wait(100); + fix.detectChanges(); + + cell = treeGrid.gridAPI.get_cell_by_index(9, treeColumns[treeColumns.length - 1]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(2); + + UIInteractions.triggerEventHandlerKeyDown('Home', gridContent, false, false, true); + await wait(100); + fix.detectChanges(); + + cell = treeGrid.gridAPI.get_cell_by_index(0, treeColumns[0]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + expect(treeGrid.selected.emit).toHaveBeenCalledTimes(3); + }); + + it('should expand/collapse row when Alt + arrow Left/Right keys are pressed', async () => { + treeGrid.width = '400px'; + await wait(DEBOUNCETIME); + fix.detectChanges(); + treeGrid.headerContainer.scrollTo(4); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + const cell = treeGrid.gridAPI.get_cell_by_index(3, 'OnPTO'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent, true); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + let rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(7); + TreeGridFunctions.verifyTreeRowHasCollapsedIcon(rows[3]); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent, true); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(8); + TreeGridFunctions.verifyTreeRowHasExpandedIcon(rows[3]); + }); + + it('should allow pageup/pagedown navigation when the treeGrid is focused', async () => { + let currScrollTop; + const cell = treeGrid.gridAPI.get_cell_by_index(1, 'Name'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + // testing the pagedown key + UIInteractions.triggerEventHandlerKeyDown('PageDown', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + currScrollTop = treeGrid.verticalScrollContainer.getScroll().scrollTop; + expect(currScrollTop).toBeGreaterThan(100); + + // testing the pageup key + UIInteractions.triggerEventHandlerKeyDown('PageUp', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + currScrollTop = treeGrid.headerContainer.getScroll().scrollTop; + expect(currScrollTop).toEqual(0); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + }); + + it('should change editable cell and scroll when Tab and Shift + Tab keys are pressed', async () => { + treeGrid.getColumnByName('ID').editable = true; + treeGrid.getColumnByName('Name').editable = true; + treeGrid.getColumnByName('HireDate').editable = true; + treeGrid.getColumnByName('Age').editable = true; + treeGrid.getColumnByName('OnPTO').editable = true; + fix.detectChanges(); + + const firstCell = treeGrid.gridAPI.get_cell_by_index(5, treeColumns[2]); + UIInteractions.simulateDoubleClickAndSelectEvent(firstCell); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, firstCell); + expect(firstCell.editMode).toBe(true); + + for (let i = 2; i < treeColumns.length - 1; i++) { + let cell = treeGrid.gridAPI.get_cell_by_index(5, treeColumns[i]); + UIInteractions.triggerEventHandlerKeyDown('Tab', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + cell = treeGrid.gridAPI.get_cell_by_index(5, treeColumns[i + 1]); + expect(cell.editMode).toBe(true); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + } + + let newCell = treeGrid.gridAPI.get_cell_by_index(5, treeColumns[4]); + UIInteractions.triggerEventHandlerKeyDown('Tab', gridContent); + await wait(DEBOUNCETIME * 2); + fix.detectChanges(); + + newCell = treeGrid.gridAPI.get_cell_by_index(6, treeColumns[0]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, newCell); + expect(newCell.editMode).toBe(true); + expect( treeGrid.verticalScrollContainer.getScroll().scrollTop).toBeGreaterThan(0); + + UIInteractions.triggerEventHandlerKeyDown('Tab', gridContent, false, true); + await wait(DEBOUNCETIME * 2); + fix.detectChanges(); + + newCell = treeGrid.gridAPI.get_cell_by_index(5, treeColumns[4]); + expect(newCell.editMode).toBe(true); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, newCell); + + for (let i = 4; i > 0; i--) { + let cell = treeGrid.gridAPI.get_cell_by_index(5, treeColumns[i]); + UIInteractions.triggerEventHandlerKeyDown('Tab', gridContent, false, true); + await wait(DEBOUNCETIME); + fix.detectChanges(); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell, false); + cell = treeGrid.gridAPI.get_cell_by_index(5, treeColumns[i - 1]); + expect(cell.editMode).toBe(true); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + } + }); + + it('should navigate with arrow Left key when there is a pinned column', async () => { + treeGrid.getColumnByName('HireDate').pinned = true; + fix.detectChanges(); + + const columns = ['HireDate', 'ID', 'Name', 'Age', 'OnPTO']; + + const firstCell = treeGrid.gridAPI.get_cell_by_index(3, 'HireDate'); + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('End', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + const lastCell = treeGrid.gridAPI.get_cell_by_index(3, columns[4]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, lastCell); + expect(treeGrid.headerContainer.getScroll().scrollLeft).toBeGreaterThan(0); + + for (let i = 4; i > 0 ; i--) { + let cell = treeGrid.gridAPI.get_cell_by_index(3, columns[i]); + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell, false); + cell = treeGrid.gridAPI.get_cell_by_index(3, columns[i - 1]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + } + + expect(treeGrid.headerContainer.getScroll().scrollLeft).toEqual(0); + }); + + it('should navigate with arrow Right key when there is a pinned column', async () => { + treeGrid.getColumnByName('HireDate').pinned = true; + fix.detectChanges(); + + const columns = ['HireDate', 'ID', 'Name', 'Age', 'OnPTO']; + + const firstCell = treeGrid.gridAPI.get_cell_by_index(0, 'HireDate'); + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('End', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + let newCell = treeGrid.gridAPI.get_cell_by_index(0, columns[4]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, newCell); + const scrollLeft = treeGrid.headerContainer.getScroll().scrollLeft; + expect(treeGrid.headerContainer.getScroll().scrollLeft).toBeGreaterThan(0); + + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + for (let i = 0; i < columns.length - 1; i++) { + let cell = treeGrid.gridAPI.get_cell_by_index(0, columns[i]); + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell, false); + cell = treeGrid.gridAPI.get_cell_by_index(0, columns[i + 1]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + } + + UIInteractions.triggerEventHandlerKeyDown('Home', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + newCell = treeGrid.gridAPI.get_cell_by_index(0, columns[0]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, newCell); + expect(treeGrid.headerContainer.getScroll().scrollLeft).toEqual(scrollLeft); + }); + + it('should select correct cells after expand/collapse row', async () => { + // Select first cell and expand collapse + let rows; + let cell = treeGrid.gridAPI.get_cell_by_index(0, 'ID'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent, true); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(4); + TreeGridFunctions.verifyTreeRowHasCollapsedIcon(rows[0]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + TreeGridFunctions.moveCellUpDown(fix, treeGrid, 0, 'ID', true); + + TreeGridFunctions.moveCellUpDown(fix, treeGrid, 1, 'ID', false); + + TreeGridFunctions.moveCellLeftRight(fix, treeGrid, 0, 'ID', 'Name', true); + + TreeGridFunctions.moveCellLeftRight(fix, treeGrid, 0, 'Name', 'ID', false); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent, true); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + cell = treeGrid.gridAPI.get_cell_by_index(0, 'ID'); + expect(rows.length).toBe(8); + TreeGridFunctions.verifyTreeRowHasExpandedIcon(rows[0]); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + TreeGridFunctions.moveCellUpDown(fix, treeGrid, 0, 'ID', true); + + TreeGridFunctions.moveCellUpDown(fix, treeGrid, 1, 'ID', false); + + TreeGridFunctions.moveCellLeftRight(fix, treeGrid, 0, 'ID', 'Name', true); + + TreeGridFunctions.moveCellLeftRight(fix, treeGrid, 0, 'Name', 'ID', false); + + // Go to the last parent row and expand collapse + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent, false, false, true); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + cell = treeGrid.gridAPI.get_cell_by_index(9, 'ID'); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + TreeGridFunctions.moveCellUpDown(fix, treeGrid, 9, 'ID', false); + cell = treeGrid.gridAPI.get_cell_by_index(8, 'ID'); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent, true); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(8); + TreeGridFunctions.verifyTreeRowHasCollapsedIcon(rows[7]); + cell = treeGrid.gridAPI.get_cell_by_index(8, 'ID'); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + + TreeGridFunctions.moveCellLeftRight(fix, treeGrid, 8, 'ID', 'Name', true); + TreeGridFunctions.moveCellLeftRight(fix, treeGrid, 8, 'Name', 'ID', false); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent, true); + await wait(DEBOUNCETIME); + fix.detectChanges(); + + rows = TreeGridFunctions.getAllRows(fix); + expect(rows.length).toBe(8); + TreeGridFunctions.verifyTreeRowHasExpandedIcon(rows[6]); + cell = treeGrid.gridAPI.get_cell_by_index(8, 'ID'); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell); + }); + }); +}); diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-multi-cell-selection.spec.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-multi-cell-selection.spec.ts new file mode 100644 index 00000000000..ea22664eb2c --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-multi-cell-selection.spec.ts @@ -0,0 +1,1114 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { + IgxTreeGridSelectionKeyComponent, + IgxTreeGridSelectionComponent, + IgxTreeGridSelectionWithTransactionComponent, + IgxTreeGridFKeySelectionWithTransactionComponent +} from '../../../test-utils/tree-grid-components.spec'; +import { clearGridSubs, setupGridScrollDetection } from '../../../test-utils/helper-utils.spec'; +import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; +import { GridSelectionFunctions, GridSummaryFunctions, GridFunctions } from '../../../test-utils/grid-functions.spec'; +import { GridSelectionMode } from 'igniteui-angular/grids/core'; +import { IgxStringFilteringOperand } from 'igniteui-angular/core'; + +describe('IgxTreeGrid - Multi Cell selection #tGrid', () => { + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxTreeGridSelectionKeyComponent, + IgxTreeGridSelectionComponent, + IgxTreeGridSelectionWithTransactionComponent, + IgxTreeGridFKeySelectionWithTransactionComponent + ] + }).compileComponents(); + })); + + describe('Flat Data', () => { + let fix; + let treeGrid; + let detect; + + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridSelectionKeyComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + setupGridScrollDetection(fix, treeGrid); + detect = () => treeGrid.cdr.detectChanges(); + }); + + afterEach(() => { + clearGridSubs(); + }); + + it('Should select a region', () => { + verifySelectingRegion(fix, treeGrid); + }); + + it('Should return correct data when expand and collapse rows', () => { + verifySelectingExpandCollapse(fix, treeGrid); + }); + + it('Should be able to select a range with mouse dragging', () => { + verifySelectingRangeWithMouseDrag(fix, treeGrid, detect); + }); + + it('Should not be possible to select a range when change cellSelection to none', () => { + const rangeChangeSpy = spyOn(treeGrid.rangeSelected, 'emit').and.callThrough(); + const startCell = treeGrid.gridAPI.get_cell_by_index(0, 'ID'); + const endCell = treeGrid.gridAPI.get_cell_by_index(2, 'ID'); + + expect(treeGrid.cellSelection).toEqual(GridSelectionMode.multiple); + GridSelectionFunctions.selectCellsRangeNoWait(fix, startCell, endCell); + detect(); + + expect(rangeChangeSpy).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 0, 2, 0, 0); + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 2, 0, 0); + + treeGrid.cellSelection = GridSelectionMode.none; + fix.detectChanges(); + + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 0, 2, 0, 0, false); + expect(treeGrid.getSelectedData()).toEqual([]); + expect(treeGrid.getSelectedRanges()).toEqual([]); + + // Try to select a range + GridSelectionFunctions.selectCellsRangeNoWait(fix, endCell, startCell); + detect(); + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 0, 2, 0, 0, false); + expect(rangeChangeSpy).toHaveBeenCalledTimes(1); + expect(treeGrid.selectedCells.length).toBe(0); + expect(treeGrid.getSelectedData().length).toBe(1); + expect(treeGrid.getSelectedRanges()).toEqual([]); + }); + + it('Should not be possible to select a range when change cellSelection to single', () => { + const rangeChangeSpy = spyOn(treeGrid.rangeSelected, 'emit').and.callThrough(); + const startCell = treeGrid.gridAPI.get_cell_by_index(0, 'ID'); + const middleCell = treeGrid.gridAPI.get_cell_by_index(1, 'ID'); + const endCell = treeGrid.gridAPI.get_cell_by_index(2, 'ID'); + + expect(treeGrid.cellSelection).toEqual(GridSelectionMode.multiple); + GridSelectionFunctions.selectCellsRangeNoWait(fix, startCell, endCell); + detect(); + + expect(rangeChangeSpy).toHaveBeenCalledTimes(1); + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 0, 2, 0, 0); + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 2, 0, 0); + + treeGrid.cellSelection = GridSelectionMode.single; + fix.detectChanges(); + + expect(treeGrid.cellSelection).toEqual(GridSelectionMode.single); + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 0, 2, 0, 0, false); + expect(treeGrid.getSelectedData()).toEqual([]); + expect(treeGrid.getSelectedRanges()).toEqual([]); + + // Try to select a range + // Try to select a range + UIInteractions.simulatePointerOverElementEvent('pointerdown', endCell.nativeElement); + endCell.nativeElement.dispatchEvent(new MouseEvent('click')); + fix.detectChanges(); + + UIInteractions.simulatePointerOverElementEvent('pointerenter', startCell.nativeElement); + UIInteractions.simulatePointerOverElementEvent('pointerup', startCell.nativeElement); + fix.detectChanges(); + detect(); + GridSelectionFunctions.verifyCellSelected(startCell, false); + GridSelectionFunctions.verifyCellSelected(middleCell, false); + GridSelectionFunctions.verifyCellSelected(endCell); + expect(rangeChangeSpy).toHaveBeenCalledTimes(1); + expect(treeGrid.selectedCells.length).toBe(1); + expect(treeGrid.getSelectedData()).toEqual([{ ID: 957 }]); + }); + + it('Should not change selection when expand collapse row with keyboard', (async () => { + const expectedData1 = [ + { ID: 19 }, + { ID: 15 } + ]; + const expectedData2 = [ + { ID: 19 }, + { ID: 17 } + ]; + + treeGrid.verticalScrollContainer.scrollTo(treeGrid.dataView.length - 1); + await wait(30); + fix.detectChanges(); + + let startCell = treeGrid.gridAPI.get_cell_by_index(10, 'ID'); + const endCell = treeGrid.gridAPI.get_cell_by_index(11, 'ID'); + await GridSelectionFunctions.selectCellsRange(fix, startCell, endCell); + + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 10, 11, 0, 0); + GridSelectionFunctions.verifySelectedRange(treeGrid, 10, 11, 0, 0); + expect(treeGrid.getSelectedData()).toEqual(expectedData1); + + UIInteractions.triggerKeyDownEvtUponElem('arrowleft', startCell.nativeElement, true, true); + await wait(30); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 10, 11, 0, 0); + GridSelectionFunctions.verifySelectedRange(treeGrid, 10, 11, 0, 0); + expect(treeGrid.getSelectedData()).toEqual(expectedData2); + + startCell = treeGrid.gridAPI.get_cell_by_index(10, 'ID'); + UIInteractions.triggerKeyDownEvtUponElem('arrowright', startCell.nativeElement, true, true); + await wait(30); + fix.detectChanges(); + + startCell = treeGrid.gridAPI.get_cell_by_index(10, 'ID'); + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 10, 11, 0, 0); + GridSelectionFunctions.verifySelectedRange(treeGrid, 10, 11, 0, 0); + expect(treeGrid.getSelectedData()).toEqual(expectedData1); + })); + + it('Should be able to select a range with holding Shift key', (async () => { + const selectionChangeSpy = spyOn(treeGrid.rangeSelected, 'emit').and.callThrough(); + const firstCell = treeGrid.gridAPI.get_cell_by_index(6, 'Age'); + UIInteractions.simulateClickAndSelectEvent(firstCell); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellSelected(firstCell); + + treeGrid.verticalScrollContainer.scrollTo(treeGrid.dataView.length - 1); + await wait(200); + fix.detectChanges(); + + treeGrid.dataRowList.first.virtDirRow.scrollTo(4); + await wait(200); + fix.detectChanges(); + + const secondCell = treeGrid.gridAPI.get_cell_by_index(16, 'HireDate'); + UIInteractions.simulateClickAndSelectEvent(secondCell, true); + fix.detectChanges(); + + let range = { rowStart: 6, rowEnd: 16, columnStart: 2, columnEnd: 4 }; + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + expect(selectionChangeSpy).toHaveBeenCalledWith(range); + expect(treeGrid.getSelectedRanges()).toEqual([range]); + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 6, 16, 2, 4); + + treeGrid.verticalScrollContainer.scrollTo(0); + await wait(200); + fix.detectChanges(); + + treeGrid.dataRowList.first.virtDirRow.scrollTo(0); + await wait(200); + fix.detectChanges(); + + const thirdCell = treeGrid.gridAPI.get_cell_by_index(4, 'ID'); + UIInteractions.simulateClickAndSelectEvent(thirdCell, true); + fix.detectChanges(); + + range = { rowStart: 4, rowEnd: 6, columnStart: 0, columnEnd: 2 }; + expect(selectionChangeSpy).toHaveBeenCalledTimes(2); + expect(selectionChangeSpy).toHaveBeenCalledWith(range); + expect(treeGrid.getSelectedRanges()).toEqual([range]); + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 4, 6, 0, 2); + })); + + it('Should be able to select a range with keyboard', (async () => { + const selectionChangeSpy = spyOn(treeGrid.rangeSelected, 'emit').and.callThrough(); + let cell = treeGrid.gridAPI.get_cell_by_index(9, 'Age'); + + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellSelected(cell); + + for (let i = 9; i < 14; i++) { + cell = treeGrid.gridAPI.get_cell_by_index(i, 'Age'); + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', cell.nativeElement, true, false, true); + await wait(30); + fix.detectChanges(); + } + + expect(selectionChangeSpy).toHaveBeenCalledTimes(5); + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 9, 14, 2, 2); + + cell = treeGrid.gridAPI.get_cell_by_index(14, 'Age'); + UIInteractions.triggerKeyDownEvtUponElem('arrowright', cell.nativeElement, true, false, true); + await wait(30); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(6); + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 9, 14, 2, 3); + + cell = treeGrid.gridAPI.get_cell_by_index(14, 'OnPTO'); + UIInteractions.triggerKeyDownEvtUponElem('arrowright', cell.nativeElement, true, false, true); + await wait(30); + fix.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(7); + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 9, 14, 2, 4); + + for (let i = 14; i > 3; i--) { + cell = treeGrid.gridAPI.get_cell_by_index(i, 'HireDate'); + UIInteractions.triggerKeyDownEvtUponElem('arrowup', cell.nativeElement, true, false, true); + await wait(30); + fix.detectChanges(); + } + + expect(selectionChangeSpy).toHaveBeenCalledTimes(18); + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 3, 9, 2, 4); + + for (let i = 4; i > 2; i--) { + cell = treeGrid.gridAPI.get_cell_by_index(3, treeGrid.columnList.get(i).field); + UIInteractions.triggerKeyDownEvtUponElem('arrowleft', cell.nativeElement, true, false, true); + await wait(30); + fix.detectChanges(); + } + + expect(selectionChangeSpy).toHaveBeenCalledTimes(20); + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 3, 9, 2, 2); + GridSelectionFunctions.verifySelectedRange(treeGrid, 3, 9, 2, 2); + })); + + it('Summaries: should select correct data when summaries are enabled', () => { + const selectionChangeSpy = spyOn(treeGrid.rangeSelected, 'emit').and.callThrough(); + const range = { rowStart: 0, rowEnd: 10, columnStart: 0, columnEnd: 2 }; + const expectedData = [ + { ID: 147, Name: 'John Winchester', Age: 55 }, + { ID: 475, Name: 'Michael Langdon', Age: 43 }, + { ID: 957, Name: 'Thomas Hardy', Age: 29 }, + { ID: 317, Name: 'Monica Reyes', Age: 31 }, + { ID: 711, Name: 'Roland Mendel', Age: 35 }, + { ID: 998, Name: 'Sven Ottlieb', Age: 44 }, + { ID: 847, Name: 'Ana Sanders', Age: 42 }, + { ID: 225, Name: 'Laurence Johnson', Age: 44 } + ]; + const startCell = treeGrid.gridAPI.get_cell_by_index(0, 'ID'); + + treeGrid.getColumnByName('Name').hasSummary = true; + treeGrid.summaryCalculationMode = 'childLevelsOnly'; + treeGrid.summaryPosition = 'top'; + fix.detectChanges(); + + UIInteractions.simulatePointerOverElementEvent('pointerdown', startCell.nativeElement); + startCell.nativeElement.dispatchEvent(new MouseEvent('click')); + detect(); + + expect(startCell.active).toBe(true); + + for (let i = 1; i < 11; i++) { + let cell = treeGrid.gridAPI.get_cell_by_index(i, 'ID'); + if (!cell) { + const summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, i); + cell = GridSummaryFunctions.getSummaryCellByVisibleIndex(summaryRow, 0); + } + UIInteractions.simulatePointerOverElementEvent('pointerenter', cell.nativeElement); + detect(); + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 0, i, 0, 0); + } + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + + let newCell = treeGrid.gridAPI.get_cell_by_index(10, 'Name'); + UIInteractions.simulatePointerOverElementEvent('pointerenter', newCell.nativeElement); + detect(); + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 0, 10, 0, 1); + + newCell = treeGrid.gridAPI.get_cell_by_index(10, 'Age'); + UIInteractions.simulatePointerOverElementEvent('pointerenter', newCell.nativeElement); + UIInteractions.simulatePointerOverElementEvent('pointerup', newCell.nativeElement); + detect(); + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 0, 10, 0, 2); + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + expect(selectionChangeSpy).toHaveBeenCalledWith(range); + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 10, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(expectedData); + }); + + it('Summaries: verify selected data when change summaries position', () => { + const selectionChangeSpy = spyOn(treeGrid.rangeSelected, 'emit').and.callThrough(); + const range = { rowStart: 0, rowEnd: 10, columnStart: 0, columnEnd: 2 }; + const expectedData1 = [ + { ID: 147, Name: 'John Winchester', Age: 55 }, + { ID: 475, Name: 'Michael Langdon', Age: 43 }, + { ID: 957, Name: 'Thomas Hardy', Age: 29 }, + { ID: 317, Name: 'Monica Reyes', Age: 31 }, + { ID: 711, Name: 'Roland Mendel', Age: 35 }, + { ID: 998, Name: 'Sven Ottlieb', Age: 44 }, + { ID: 847, Name: 'Ana Sanders', Age: 42 }, + { ID: 225, Name: 'Laurence Johnson', Age: 44 } + ]; + + const expectedData2 = [ + { ID: 147, Name: 'John Winchester', Age: 55 }, + { ID: 475, Name: 'Michael Langdon', Age: 43 }, + { ID: 957, Name: 'Thomas Hardy', Age: 29 }, + { ID: 317, Name: 'Monica Reyes', Age: 31 }, + { ID: 711, Name: 'Roland Mendel', Age: 35 }, + { ID: 998, Name: 'Sven Ottlieb', Age: 44 }, + { ID: 847, Name: 'Ana Sanders', Age: 42 }, + { ID: 225, Name: 'Laurence Johnson', Age: 44 }, + { ID: 663, Name: 'Elizabeth Richards', Age: 25 } + ]; + + treeGrid.getColumnByName('Name').hasSummary = true; + treeGrid.summaryCalculationMode = 'childLevelsOnly'; + fix.detectChanges(); + + treeGrid.selectRange(range); + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 10, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(expectedData2); + + treeGrid.summaryPosition = 'top'; + fix.detectChanges(); + + // Setting range through the API must NOT call the event emitter + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 0, 10, 0, 2); + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 10, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(expectedData1); + }); + + it('Summaries: should select range with keyboard', (async () => { + const selectionChangeSpy = spyOn(treeGrid.rangeSelected, 'emit').and.callThrough(); + treeGrid.getColumnByName('Name').hasSummary = true; + treeGrid.summaryCalculationMode = 'childLevelsOnly'; + fix.detectChanges(); + + const cell = treeGrid.gridAPI.get_cell_by_index(8, 'Name'); + UIInteractions.simulateClickAndSelectEvent(cell); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellSelected(cell); + + for (let i = 8; i < 16; i++) { + let cellObj = treeGrid.gridAPI.get_cell_by_index(i, 'Name'); + if (!cellObj) { + cellObj = treeGrid.summariesRowList.find(row => row.index === i) + .summaryCells.find(sCell => sCell.visibleColumnIndex === 1); + } + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', cellObj.nativeElement, true, false, true); + await wait(30); + fix.detectChanges(); + } + + expect(selectionChangeSpy).toHaveBeenCalledTimes(5); + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 8, 15, 1, 1); + + for (let i = 1; i < 3; i++) { + const cellObject = treeGrid.summariesRowList.find(row => row.index === 16) + .summaryCells.find(sCell => sCell.visibleColumnIndex === i); + UIInteractions.triggerKeyDownEvtUponElem('arrowright', cellObject.nativeElement, true, false, true); + await wait(30); + fix.detectChanges(); + } + + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 8, 15, 1, 1); + + const summaryCell = treeGrid.summariesRowList.find(row => row.index === 16) + .summaryCells.find(sCell => sCell.visibleColumnIndex === 3); + UIInteractions.triggerKeyDownEvtUponElem('arrowup', summaryCell.nativeElement, true, false, true); + await wait(30); + fix.detectChanges(); + expect(selectionChangeSpy).toHaveBeenCalledTimes(6); + GridSelectionFunctions.verifySelectedRange(treeGrid, 8, 15, 1, 3); + })); + + it('Summaries: should clear selected range when navigate from summary cell without pressed shift', (async () => { + const selectionChangeSpy = spyOn(treeGrid.rangeSelected, 'emit').and.callThrough(); + treeGrid.getColumnByName('Name').hasSummary = true; + treeGrid.summaryCalculationMode = 'childLevelsOnly'; + fix.detectChanges(); + + const cellElem = treeGrid.gridAPI.get_cell_by_index(8, 'Name'); + UIInteractions.simulateClickAndSelectEvent(cellElem); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellSelected(cellElem); + const gridContent = GridFunctions.getGridContent(fix); + for (let i = 8; i < 16; i++) { + UIInteractions.triggerEventHandlerKeyDown('arrowdown', gridContent, false, true); + await wait(30); + fix.detectChanges(); + } + + expect(selectionChangeSpy).toHaveBeenCalledTimes(5); + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 8, 15, 1, 1); + + UIInteractions.triggerEventHandlerKeyDown('arrowdown', gridContent); + await wait(30); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(treeGrid, 17, 17, 1, 1); + })); + + it('Filtering: selection should not change when perform filtering', () => { + const range = { rowStart: 0, rowEnd: 3, columnStart: 'ID', columnEnd: 'Age' }; + treeGrid.selectRange(range); + fix.detectChanges(); + + const selectedData = [ + { ID: 147, Name: 'John Winchester', Age: 55 }, + { ID: 475, Name: 'Michael Langdon', Age: 43 }, + { ID: 957, Name: 'Thomas Hardy', Age: 29 }, + { ID: 317, Name: 'Monica Reyes', Age: 31 } + ]; + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 3, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(selectedData); + + treeGrid.filter('Name', 'la', IgxStringFilteringOperand.instance().condition('contains'), false); + fix.detectChanges(); + + const filterData = [ + { ID: 147, Name: 'John Winchester', Age: 55 }, + { ID: 475, Name: 'Michael Langdon', Age: 43 }, + { ID: 317, Name: 'Monica Reyes', Age: 31 }, + { ID: 711, Name: 'Roland Mendel', Age: 35 } + ]; + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 3, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(filterData); + }); + + it('CRUD: selected range should not change when delete row', () => { + const range = { rowStart: 0, rowEnd: 3, columnStart: 'ID', columnEnd: 'Age' }; + treeGrid.selectRange(range); + fix.detectChanges(); + + const selectedData = [ + { ID: 147, Name: 'John Winchester', Age: 55 }, + { ID: 475, Name: 'Michael Langdon', Age: 43 }, + { ID: 957, Name: 'Thomas Hardy', Age: 29 }, + { ID: 317, Name: 'Monica Reyes', Age: 31 } + ]; + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 3, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(selectedData); + + const row = treeGrid.getRowByIndex(2); + row.delete(); + fix.detectChanges(); + + const newSelectedData = [ + { ID: 147, Name: 'John Winchester', Age: 55 }, + { ID: 475, Name: 'Michael Langdon', Age: 43 }, + { ID: 317, Name: 'Monica Reyes', Age: 31 }, + { ID: 711, Name: 'Roland Mendel', Age: 35 }, + ]; + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 3, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(newSelectedData); + }); + + it('CRUD: selected range should not change when update row', () => { + const range = { rowStart: 0, rowEnd: 3, columnStart: 'ID', columnEnd: 'Age' }; + treeGrid.selectRange(range); + fix.detectChanges(); + + const selectedData = [ + { ID: 147, Name: 'John Winchester', Age: 55 }, + { ID: 475, Name: 'Michael Langdon', Age: 43 }, + { ID: 957, Name: 'Thomas Hardy', Age: 29 }, + { ID: 317, Name: 'Monica Reyes', Age: 31 } + ]; + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 3, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(selectedData); + + const row = treeGrid.getRowByIndex(2); + row.update({ ID: 258, Name: 'Michael Cooper', Age: 33, OnPTO: false }); + fix.detectChanges(); + + const newSelectedData = [ + { ID: 147, Name: 'John Winchester', Age: 55 }, + { ID: 475, Name: 'Michael Langdon', Age: 43 }, + { ID: 258, Name: 'Michael Cooper', Age: 33 }, + { ID: 317, Name: 'Monica Reyes', Age: 31 } + ]; + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 3, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(newSelectedData); + }); + + it('CRUD: selected range should not change when add row', () => { + const range = { rowStart: 3, rowEnd: 6, columnStart: 'ID', columnEnd: 'Age' }; + treeGrid.selectRange(range); + fix.detectChanges(); + + const selectedData = [ + { ID: 317, Name: 'Monica Reyes', Age: 31 }, + { ID: 711, Name: 'Roland Mendel', Age: 35 }, + { ID: 998, Name: 'Sven Ottlieb', Age: 44 }, + { ID: 847, Name: 'Ana Sanders', Age: 42 } + ]; + GridSelectionFunctions.verifySelectedRange(treeGrid, 3, 6, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(selectedData); + + treeGrid.addRow({ ID: 13, Name: 'Michael Cooper', Age: 33, OnPTO: false }, 317); + fix.detectChanges(); + + const newSelectedData = [ + { ID: 317, Name: 'Monica Reyes', Age: 31 }, + { ID: 711, Name: 'Roland Mendel', Age: 35 }, + { ID: 998, Name: 'Sven Ottlieb', Age: 44 }, + { ID: 13, Name: 'Michael Cooper', Age: 33 } + ]; + GridSelectionFunctions.verifySelectedRange(treeGrid, 3, 6, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(newSelectedData); + }); + }); + + describe('ChildDataKey', () => { + let fix; + let treeGrid; + let detect; + + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridSelectionComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + setupGridScrollDetection(fix, treeGrid); + detect = () => treeGrid.cdr.detectChanges(); + }); + + afterEach(() => { + clearGridSubs(); + }); + + it('Should select a region', () => { + verifySelectingRegion(fix, treeGrid); + }); + + it('Should return correct data when expand and collapse rows', () => { + verifySelectingExpandCollapse(fix, treeGrid); + }); + + it('Should be able to select a range with mouse dragging', () => { + verifySelectingRangeWithMouseDrag(fix, treeGrid, detect); + }); + + it('Filtering: selection should not change when perform filtering', () => { + const range = { rowStart: 0, rowEnd: 3, columnStart: 'ID', columnEnd: 'Age' }; + treeGrid.selectRange(range); + fix.detectChanges(); + + const selectedData = [ + { ID: 147, Name: 'John Winchester', Age: 55 }, + { ID: 475, Name: 'Michael Langdon', Age: 43 }, + { ID: 957, Name: 'Thomas Hardy', Age: 29 }, + { ID: 317, Name: 'Monica Reyes', Age: 31 } + ]; + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 3, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(selectedData); + + treeGrid.filter('Name', 'la', IgxStringFilteringOperand.instance().condition('contains'), false); + fix.detectChanges(); + + const filterData = [ + { ID: 147, Name: 'John Winchester', Age: 55 }, + { ID: 475, Name: 'Michael Langdon', Age: 43 }, + { ID: 317, Name: 'Monica Reyes', Age: 31 }, + { ID: 711, Name: 'Roland Mendel', Age: 35 } + ]; + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 3, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(filterData); + }); + + it('CRUD: selected range should not change when update row', () => { + const range = { rowStart: 0, rowEnd: 3, columnStart: 'ID', columnEnd: 'Age' }; + treeGrid.selectRange(range); + fix.detectChanges(); + + const selectedData = [ + { ID: 147, Name: 'John Winchester', Age: 55 }, + { ID: 475, Name: 'Michael Langdon', Age: 43 }, + { ID: 957, Name: 'Thomas Hardy', Age: 29 }, + { ID: 317, Name: 'Monica Reyes', Age: 31 } + ]; + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 3, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(selectedData); + + const row = treeGrid.getRowByIndex(2); + row.update({ ID: 258, Name: 'Michael Cooper', Age: 33, OnPTO: false }); + fix.detectChanges(); + + const newSelectedData = [ + { ID: 147, Name: 'John Winchester', Age: 55 }, + { ID: 475, Name: 'Michael Langdon', Age: 43 }, + { ID: 258, Name: 'Michael Cooper', Age: 33 }, + { ID: 317, Name: 'Monica Reyes', Age: 31 } + ]; + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 3, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(newSelectedData); + }); + + it('CRUD: selected range should not change when add row', () => { + treeGrid.primaryKey = 'ID'; + const range = { rowStart: 3, rowEnd: 6, columnStart: 'ID', columnEnd: 'Age' }; + treeGrid.selectRange(range); + fix.detectChanges(); + + const selectedData = [ + { ID: 317, Name: 'Monica Reyes', Age: 31 }, + { ID: 711, Name: 'Roland Mendel', Age: 35 }, + { ID: 998, Name: 'Sven Ottlieb', Age: 44 }, + { ID: 847, Name: 'Ana Sanders', Age: 42 } + ]; + GridSelectionFunctions.verifySelectedRange(treeGrid, 3, 6, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(selectedData); + + treeGrid.addRow({ ID: 13, ParentID: 317, Name: 'Michael Cooper', Age: 33, OnPTO: false }, 317); + fix.detectChanges(); + + const newSelectedData = [ + { ID: 317, Name: 'Monica Reyes', Age: 31 }, + { ID: 711, Name: 'Roland Mendel', Age: 35 }, + { ID: 998, Name: 'Sven Ottlieb', Age: 44 }, + { ID: 13, Name: 'Michael Cooper', Age: 33 } + ]; + GridSelectionFunctions.verifySelectedRange(treeGrid, 3, 6, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(newSelectedData); + }); + }); + + describe('ChildDataKeyGrid with transactions enabled', () => { + let fix; + let treeGrid; + + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridSelectionWithTransactionComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + setupGridScrollDetection(fix, treeGrid); + }); + + afterEach(() => { + clearGridSubs(); + }); + + it('CRUD: selected range should not change when delete row', () => { + const range = { rowStart: 0, rowEnd: 3, columnStart: 'ID', columnEnd: 'Age' }; + treeGrid.selectRange(range); + fix.detectChanges(); + + const selectedData = [ + { ID: 147, Name: 'John Winchester', Age: 55 }, + { ID: 475, Name: 'Michael Langdon', Age: 43 }, + { ID: 957, Name: 'Thomas Hardy', Age: 29 }, + { ID: 317, Name: 'Monica Reyes', Age: 31 } + ]; + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 3, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(selectedData); + + const row = treeGrid.getRowByIndex(2); + row.delete(); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 3, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(selectedData); + + treeGrid.transactions.undo(); + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 3, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(selectedData); + + treeGrid.transactions.redo(); + fix.detectChanges(); + treeGrid.transactions.commit(treeGrid.data, 'ID', 'Employees'); + fix.detectChanges(); + + const nesSelData = [ + { ID: 147, Name: 'John Winchester', Age: 55 }, + { ID: 475, Name: 'Michael Langdon', Age: 43 }, + { ID: 317, Name: 'Monica Reyes', Age: 31 }, + { ID: 711, Name: 'Roland Mendel', Age: 35 } + ]; + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 3, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(nesSelData); + }); + + it('CRUD: selected range should not change when update row', () => { + const range = { rowStart: 0, rowEnd: 3, columnStart: 'ID', columnEnd: 'Age' }; + treeGrid.selectRange(range); + fix.detectChanges(); + + const selectedData = [ + { ID: 147, Name: 'John Winchester', Age: 55 }, + { ID: 475, Name: 'Michael Langdon', Age: 43 }, + { ID: 957, Name: 'Thomas Hardy', Age: 29 }, + { ID: 317, Name: 'Monica Reyes', Age: 31 } + ]; + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 3, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(selectedData); + + const row = treeGrid.getRowByIndex(2); + row.update({ ID: 258, Name: 'Michael Cooper', Age: 33, OnPTO: false, Employees: [] }); + fix.detectChanges(); + + const newSelectedData = [ + { ID: 147, Name: 'John Winchester', Age: 55 }, + { ID: 475, Name: 'Michael Langdon', Age: 43 }, + { ID: 258, Name: 'Michael Cooper', Age: 33 }, + { ID: 317, Name: 'Monica Reyes', Age: 31 } + ]; + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 3, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(newSelectedData); + + treeGrid.transactions.undo(); + fix.detectChanges(); + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 3, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(selectedData); + + treeGrid.transactions.redo(); + fix.detectChanges(); + treeGrid.transactions.commit(treeGrid.data, 'ID', 'Employees'); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 3, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(newSelectedData); + }); + + it('CRUD: selected range should not change when add row', () => { + const range = { rowStart: 3, rowEnd: 6, columnStart: 'ID', columnEnd: 'Age' }; + treeGrid.selectRange(range); + fix.detectChanges(); + + const selectedData = [ + { ID: 317, Name: 'Monica Reyes', Age: 31 }, + { ID: 711, Name: 'Roland Mendel', Age: 35 }, + { ID: 998, Name: 'Sven Ottlieb', Age: 44 }, + { ID: 847, Name: 'Ana Sanders', Age: 42 } + ]; + GridSelectionFunctions.verifySelectedRange(treeGrid, 3, 6, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(selectedData); + + treeGrid.addRow({ ID: 13, Name: 'Michael Cooper', Age: 33, OnPTO: false, HireDate: null, Employees: [] }, 147); + fix.detectChanges(); + + const newSelectedData = [ + { ID: 317, Name: 'Monica Reyes', Age: 31 }, + { ID: 711, Name: 'Roland Mendel', Age: 35 }, + { ID: 998, Name: 'Sven Ottlieb', Age: 44 }, + { ID: 13, Name: 'Michael Cooper', Age: 33 } + ]; + GridSelectionFunctions.verifySelectedRange(treeGrid, 3, 6, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(newSelectedData); + treeGrid.transactions.commit(treeGrid.data, 'ID', 'Employees'); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(treeGrid, 3, 6, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(newSelectedData); + }); + + }); + + describe('FlatGrid with transactions enabled', () => { + let fix; + let treeGrid; + + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridFKeySelectionWithTransactionComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + setupGridScrollDetection(fix, treeGrid); + }); + + afterEach(() => { + clearGridSubs(); + }); + + it('CRUD: selected range should not change when delete row', () => { + const range = { rowStart: 0, rowEnd: 3, columnStart: 'ID', columnEnd: 'Age' }; + treeGrid.selectRange(range); + fix.detectChanges(); + + const selectedData = [ + { ID: 147, Name: 'John Winchester', Age: 55 }, + { ID: 475, Name: 'Michael Langdon', Age: 43 }, + { ID: 957, Name: 'Thomas Hardy', Age: 29 }, + { ID: 317, Name: 'Monica Reyes', Age: 31 } + ]; + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 3, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(selectedData); + const row = treeGrid.getRowByIndex(2); + row.delete(); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 3, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(selectedData); + + treeGrid.transactions.undo(); + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 3, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(selectedData); + + treeGrid.transactions.redo(); + fix.detectChanges(); + treeGrid.transactions.commit(fix.componentInstance.data); + fix.detectChanges(); + + const nesSelData = [ + { ID: 147, Name: 'John Winchester', Age: 55 }, + { ID: 475, Name: 'Michael Langdon', Age: 43 }, + { ID: 317, Name: 'Monica Reyes', Age: 31 }, + { ID: 711, Name: 'Roland Mendel', Age: 35 } + ]; + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 3, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(nesSelData); + }); + + it('CRUD: selected range should not change when update row', () => { + const range = { rowStart: 0, rowEnd: 3, columnStart: 'ID', columnEnd: 'Age' }; + treeGrid.selectRange(range); + fix.detectChanges(); + + const selectedData = [ + { ID: 147, Name: 'John Winchester', Age: 55 }, + { ID: 475, Name: 'Michael Langdon', Age: 43 }, + { ID: 957, Name: 'Thomas Hardy', Age: 29 }, + { ID: 317, Name: 'Monica Reyes', Age: 31 } + ]; + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 3, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(selectedData); + const row = treeGrid.getRowByIndex(2); + row.update({ ID: 258, Name: 'Michael Cooper', Age: 33, OnPTO: false, Employees: [] }); + fix.detectChanges(); + + const newSelectedData = [ + { ID: 147, Name: 'John Winchester', Age: 55 }, + { ID: 475, Name: 'Michael Langdon', Age: 43 }, + { ID: 258, Name: 'Michael Cooper', Age: 33 }, + { ID: 317, Name: 'Monica Reyes', Age: 31 } + ]; + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 3, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(newSelectedData); + + treeGrid.transactions.undo(); + fix.detectChanges(); + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 3, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(selectedData); + + treeGrid.transactions.redo(); + fix.detectChanges(); + treeGrid.transactions.commit(fix.componentInstance.data); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 3, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(newSelectedData); + }); + + it('CRUD: selected range should not change when add row', () => { + const range = { rowStart: 3, rowEnd: 6, columnStart: 'ID', columnEnd: 'Age' }; + treeGrid.selectRange(range); + fix.detectChanges(); + + const selectedData = [ + { ID: 317, Name: 'Monica Reyes', Age: 31 }, + { ID: 711, Name: 'Roland Mendel', Age: 35 }, + { ID: 998, Name: 'Sven Ottlieb', Age: 44 }, + { ID: 847, Name: 'Ana Sanders', Age: 42 } + ]; + GridSelectionFunctions.verifySelectedRange(treeGrid, 3, 6, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(selectedData); + treeGrid.addRow({ ID: 13, Name: 'Michael Cooper', Age: 33, OnPTO: false, HireDate: null, Employees: [] }, 147); + fix.detectChanges(); + + const newSelectedData = [ + { ID: 317, Name: 'Monica Reyes', Age: 31 }, + { ID: 711, Name: 'Roland Mendel', Age: 35 }, + { ID: 998, Name: 'Sven Ottlieb', Age: 44 }, + { ID: 13, Name: 'Michael Cooper', Age: 33 } + ]; + GridSelectionFunctions.verifySelectedRange(treeGrid, 3, 6, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(newSelectedData); + + treeGrid.transactions.undo(); + fix.detectChanges(); + GridSelectionFunctions.verifySelectedRange(treeGrid, 3, 6, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(selectedData); + + treeGrid.transactions.redo(); + fix.detectChanges(); + treeGrid.transactions.commit(fix.componentInstance.data); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(treeGrid, 3, 6, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(newSelectedData); + }); + }); + + const verifySelectingRegion = (fix, treeGrid) => { + const selectionChangeSpy = spyOn(treeGrid.rangeSelected, 'emit').and.callThrough(); + const range1 = { rowStart: 0, rowEnd: 6, columnStart: 'ID', columnEnd: 'Age' }; + const range2 = { rowStart: 11, rowEnd: 16, columnStart: 'ID', columnEnd: 'OnPTO' }; + const expectedData1 = [ + { ID: 147, Name: 'John Winchester', Age: 55 }, + { ID: 475, Name: 'Michael Langdon', Age: 43 }, + { ID: 957, Name: 'Thomas Hardy', Age: 29 }, + { ID: 317, Name: 'Monica Reyes', Age: 31 }, + { ID: 711, Name: 'Roland Mendel', Age: 35 }, + { ID: 998, Name: 'Sven Ottlieb', Age: 44 }, + { ID: 847, Name: 'Ana Sanders', Age: 42 } + ]; + const expectedData2 = [ + { ID: 15, Name: 'Antonio Moreno', Age: 44, OnPTO: true }, + { ID: 17, Name: 'Yang Wang', Age: 61, OnPTO: false }, + { ID: 12, Name: 'Pedro Afonso', Age: 50, OnPTO: false }, + { ID: 109, Name: 'Patricio Simpson', Age: 25, OnPTO: false }, + { ID: 99, Name: 'Francisco Chang', Age: 39, OnPTO: true }, + { ID: 299, Name: 'Peter Lewis', Age: 25, OnPTO: false } + ]; + treeGrid.selectRange(range1); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 0, 6, 0, 2); + GridSelectionFunctions.verifySelectedRange(treeGrid, 0, 6, 0, 2); + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + expect(treeGrid.getSelectedData()).toEqual(expectedData1); + + treeGrid.selectRange(); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 0, 6, 0, 2, false); + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + expect(treeGrid.getSelectedRanges().length).toBe(0); + + treeGrid.selectRange(range2); + fix.detectChanges(); + + GridSelectionFunctions.verifySelectedRange(treeGrid, 11, 16, 0, 3); + expect(selectionChangeSpy).toHaveBeenCalledTimes(0); + expect(treeGrid.getSelectedData()).toEqual(expectedData2); + }; + + const verifySelectingExpandCollapse = (fix, treeGrid) => { + const range = { rowStart: 1, rowEnd: 6, columnStart: 'ID', columnEnd: 'Age' }; + const expectedData1 = [ + { ID: 475, Name: 'Michael Langdon', Age: 43 }, + { ID: 957, Name: 'Thomas Hardy', Age: 29 }, + { ID: 317, Name: 'Monica Reyes', Age: 31 }, + { ID: 711, Name: 'Roland Mendel', Age: 35 }, + { ID: 998, Name: 'Sven Ottlieb', Age: 44 }, + { ID: 847, Name: 'Ana Sanders', Age: 42 } + ]; + const expectedData2 = [ + { ID: 475, Name: 'Michael Langdon', Age: 43 }, + { ID: 957, Name: 'Thomas Hardy', Age: 29 }, + { ID: 317, Name: 'Monica Reyes', Age: 31 }, + { ID: 847, Name: 'Ana Sanders', Age: 42 }, + { ID: 225, Name: 'Laurence Johnson', Age: 44 }, + { ID: 663, Name: 'Elizabeth Richards', Age: 25 } + ]; + + const expectedData3 = [ + { ID: 847, Name: 'Ana Sanders', Age: 42 }, + { ID: 225, Name: 'Laurence Johnson', Age: 44 }, + { ID: 663, Name: 'Elizabeth Richards', Age: 25 }, + { ID: 141, Name: 'Trevor Ashworth', Age: 39 }, + { ID: 19, Name: 'Victoria Lincoln', Age: 49 }, + { ID: 15, Name: 'Antonio Moreno', Age: 44 } + ]; + + const expectedData4 = [ + { ID: 847, Name: 'Ana Sanders', Age: 42 }, + { ID: 19, Name: 'Victoria Lincoln', Age: 49 }, + { ID: 17, Name: 'Yang Wang', Age: 61 } + ]; + + treeGrid.selectRange(range); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 1, 6, 0, 2); + GridSelectionFunctions.verifySelectedRange(treeGrid, 1, 6, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(expectedData1); + + treeGrid.toggleRow(treeGrid.getRowByIndex(3).key); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 1, 6, 0, 2); + GridSelectionFunctions.verifySelectedRange(treeGrid, 1, 6, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(expectedData2); + + treeGrid.toggleRow(treeGrid.getRowByIndex(0).key); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 1, 6, 0, 2); + GridSelectionFunctions.verifySelectedRange(treeGrid, 1, 6, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(expectedData3); + + treeGrid.collapseAll(); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 1, 3, 0, 2); + GridSelectionFunctions.verifySelectedRange(treeGrid, 1, 6, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(expectedData4); + + treeGrid.expandAll(); + fix.detectChanges(); + + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 1, 6, 0, 2); + GridSelectionFunctions.verifySelectedRange(treeGrid, 1, 6, 0, 2); + expect(treeGrid.getSelectedData()).toEqual(expectedData1); + }; + + const verifySelectingRangeWithMouseDrag = (fix, treeGrid, detect) => { + const selectionChangeSpy = spyOn(treeGrid.rangeSelected, 'emit').and.callThrough(); + const startCell = treeGrid.gridAPI.get_cell_by_index(4, 'Name'); + const endCell = treeGrid.gridAPI.get_cell_by_index(7, 'Age'); + const range = { rowStart: 4, rowEnd: 7, columnStart: 1, columnEnd: 2 }; + const expectedData = [ + { Name: 'Ana Sanders', Age: 42 }, + { Name: 'Victoria Lincoln', Age: 49 }, + { Name: 'Antonio Moreno', Age: 44 }, + { Name: 'Yang Wang', Age: 61 } + ]; + + treeGrid.toggleRow(treeGrid.getRowByIndex(3).key); + fix.detectChanges(); + + treeGrid.toggleRow(treeGrid.getRowByIndex(4).key); + fix.detectChanges(); + + UIInteractions.simulatePointerOverElementEvent('pointerdown', startCell.nativeElement); + startCell.nativeElement.dispatchEvent(new Event('focus')); + detect(); + + expect(startCell.active).toBe(true); + + for (let i = 5; i < 7; i++) { + const cell = treeGrid.gridAPI.get_cell_by_index(i, treeGrid.columnList.get(i - 3).field); + UIInteractions.simulatePointerOverElementEvent('pointerenter', cell.nativeElement); + detect(); + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 4, i, 1, i - 3); + } + + for (let i = 5; i > 0; i--) { + const cell = treeGrid.gridAPI.get_cell_by_index(i, 'OnPTO'); + UIInteractions.simulatePointerOverElementEvent('pointerenter', cell.nativeElement); + detect(); + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 4, i, 1, 3); + } + + for (let i = 2; i >= 0; i--) { + const cell = treeGrid.gridAPI.get_cell_by_index(1, treeGrid.columnList.get(i).field); + UIInteractions.simulatePointerOverElementEvent('pointerenter', cell.nativeElement); + detect(); + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 4, 1, 1, i); + } + + for (let i = 2; i < 10; i++) { + const cell = treeGrid.gridAPI.get_cell_by_index(i, 'ID'); + UIInteractions.simulatePointerOverElementEvent('pointerenter', cell.nativeElement); + detect(); + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 4, i, 1, 0); + } + + for (let i = 8; i > 6; i--) { + const cell = treeGrid.gridAPI.get_cell_by_index(i, treeGrid.columnList.get(9 - i).field); + UIInteractions.simulatePointerOverElementEvent('pointerenter', cell.nativeElement); + detect(); + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 4, i, 1, 9 - i); + } + + UIInteractions.simulatePointerOverElementEvent('pointerup', endCell.nativeElement); + detect(); + + expect(startCell.active).toBe(true); + GridSelectionFunctions.verifyCellsRegionSelected(treeGrid, 4, 7, 1, 2); + GridSelectionFunctions.verifySelectedRange(treeGrid, 4, 7, 1, 2); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + expect(selectionChangeSpy).toHaveBeenCalledWith(range); + expect(treeGrid.getSelectedData()).toEqual(expectedData); + }; +}); diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-row.component.html b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-row.component.html new file mode 100644 index 00000000000..abfea16a930 --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-row.component.html @@ -0,0 +1,151 @@ + + + +
    + +
    +
    + + @if (rowDraggable) { +
    + +
    + } + @if (showRowSelectors) { +
    + + +
    + } + @if (pinnedStartColumns.length > 0) { + + } + + @if (this.hasMergedCells) { +
    + +
    + } + @else { + + } +
    + @if (pinnedEndColumns.length > 0) { + + } +
    + + +
    + + +
    +
    + + + @for (col of columns | igxNotGrouped; track trackPinnedColumn(col)) { + @if (this.hasMergedCells) { +
    + +
    + } + @else { + + } + } +
    + + + + + + + + + + diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-row.component.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-row.component.ts new file mode 100644 index 00000000000..1a6a79bc33f --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-row.component.ts @@ -0,0 +1,168 @@ +import { Component, forwardRef, Input, ViewChildren, QueryList, HostBinding, DoCheck, ChangeDetectionStrategy } from '@angular/core'; +import { IgxRowDirective } from 'igniteui-angular/grids/core'; +import { IgxGridNotGroupedPipe, IgxGridCellStylesPipe, IgxGridCellStyleClassesPipe, IgxGridDataMapperPipe, IgxGridTransactionStatePipe } from 'igniteui-angular/grids/core'; +import { IgxTreeGridCellComponent } from './tree-cell.component'; +import { IgxGridCellComponent } from 'igniteui-angular/grids/core'; +import { IgxRowDragDirective } from 'igniteui-angular/grids/core'; +import { NgTemplateOutlet, NgClass, NgStyle } from '@angular/common'; +import { IgxGridForOfDirective } from 'igniteui-angular/directives'; +import { IgxCheckboxComponent } from 'igniteui-angular/checkbox'; +import { ITreeGridRecord } from 'igniteui-angular/core'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'igx-tree-grid-row', + templateUrl: 'tree-grid-row.component.html', + providers: [{ provide: IgxRowDirective, useExisting: forwardRef(() => IgxTreeGridRowComponent) }], + imports: [NgTemplateOutlet, IgxRowDragDirective, IgxGridForOfDirective, IgxGridCellComponent, NgClass, NgStyle, IgxTreeGridCellComponent, IgxCheckboxComponent, IgxGridNotGroupedPipe, IgxGridCellStylesPipe, IgxGridCellStyleClassesPipe, IgxGridDataMapperPipe, IgxGridTransactionStatePipe] +}) +export class IgxTreeGridRowComponent extends IgxRowDirective implements DoCheck { + @ViewChildren('treeCell') + protected override _cells: QueryList; + + /** + * @hidden + */ + public isLoading: boolean; + + private _treeRow: ITreeGridRecord; + + /** + * The `ITreeGridRecord` passed to the row component. + * + * ```typescript + * const row = this.grid.getRowByKey(1) as IgxTreeGridRowComponent; + * const treeRow = row.treeRow; + * ``` + */ + @Input() + public get treeRow(): ITreeGridRecord { + return this._treeRow; + } + + public set treeRow(value: ITreeGridRecord) { + if (this._treeRow !== value) { + this._treeRow = value; + this.data = this._treeRow.data; + } + } + + /** + * Sets whether the row is pinned. + * Default value is `false`. + * ```typescript + * this.grid.selectedRows[0].pinned = true; + * ``` + */ + public override set pinned(value: boolean) { + if (value) { + this.grid.pinRow(this.key); + } else { + this.grid.unpinRow(this.key); + } + } + + /** + * Gets whether the row is pinned. + * ```typescript + * let isPinned = row.pinned; + * ``` + */ + public override get pinned() { + return this.grid.isRecordPinned(this._treeRow); + } + + /** + * @hidden + */ + public override get isRoot(): boolean { + let treeRec = this.treeRow; + const isPinnedArea = this.pinned && !this.disabled; + if (isPinnedArea) { + treeRec = this.grid.unpinnedRecords.find(x => x.data === this.data); + } + return treeRec?.level === 0; + } + + /** + * @hidden + */ + public override get hasChildren(): boolean { + return true; + } + + /** + * Returns a value indicating whether the row component is expanded. + * + * ```typescript + * const row = this.grid.getRowByKey(1) as IgxTreeGridRowComponent; + * const expanded = row.expanded; + * ``` + */ + @HostBinding('attr.aria-expanded') + public override get expanded(): boolean { + return this._treeRow.expanded; + } + + /** + * Sets a value indicating whether the row component is expanded. + * + * ```typescript + * const row = this.grid.getRowByKey(1) as IgxTreeGridRowComponent; + * row.expanded = true; + * ``` + */ + public override set expanded(value: boolean) { + this.grid.gridAPI.set_row_expansion_state(this._treeRow.key, value); + } + + /** + * @hidden + * @internal + */ + public override get viewIndex(): number { + return this.index + this.grid.page * this.grid.perPage; + } + + /** + * @hidden + */ + public get showIndicator() { + return this.grid.loadChildrenOnDemand ? + this.grid.expansionStates.has(this.key) ? + this.treeRow.children && this.treeRow.children.length : + this.grid.hasChildrenKey ? + this.data[this.grid.hasChildrenKey] : + true : + this.treeRow.children && this.treeRow.children.length; + } + + /** + * @hidden + */ + public get indeterminate(): boolean { + return this.selectionService.isRowInIndeterminateState(this.key); + } + + /** + * @hidden + */ + public override ngDoCheck() { + this.isLoading = this.grid.loadChildrenOnDemand ? this.grid.loadingRows.has(this.key) : false; + super.ngDoCheck(); + } + + /** + * Spawns the add child row UI for the specific row. + * + * @example + * ```typescript + * const row = this.grid.getRowByKey(1) as IgxTreeGridRowComponent; + * row.beginAddChild(); + * ``` + * @param rowID + */ + public beginAddChild() { + this.grid.crudService.enterAddRowMode(this, true); + } +} diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-search.spec.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-search.spec.ts new file mode 100644 index 00000000000..a9cfd4007e9 --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-search.spec.ts @@ -0,0 +1,448 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { IgxTreeGridComponent } from './tree-grid.component'; +import { TreeGridFunctions, CELL_VALUE_DIV_CSS_CLASS } from '../../../test-utils/tree-grid-functions.spec'; +import { + IgxTreeGridSearchComponent, + IgxTreeGridPrimaryForeignKeyComponent, + IgxTreeGridSummariesScrollingComponent } from '../../../test-utils/tree-grid-components.spec'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { wait } from '../../../test-utils/ui-interactions.spec'; +import { IgxStringFilteringOperand, SortingDirection } from 'igniteui-angular/core'; + +const HIGHLIGHT_CLASS = 'igx-highlight'; +const ACTIVE_CLASS = 'igx-highlight__active'; + +describe('IgxTreeGrid - search API #tGrid', () => { + let fix; + let fixNativeElement; + let treeGrid: IgxTreeGridComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxTreeGridSearchComponent, + IgxTreeGridPrimaryForeignKeyComponent, + IgxTreeGridSummariesScrollingComponent + ] + }).compileComponents(); + })); + + describe('Child Collection', () => { + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridSearchComponent); + fix.detectChanges(); + fixNativeElement = fix.debugElement.nativeElement; + treeGrid = fix.componentInstance.treeGrid; + + treeGrid.getColumnByName('JobTitle').autosize(); + fix.detectChanges(); + }); + + it('Search highlights should work within tree cell', () => { + let actualCount = treeGrid.findNext('ev'); + + // Verify total number of occurrences in treeGrid. + verifySearchResult(fixNativeElement, 10, 0, actualCount); + + // Verify occurrences within a tree cell + const treeCell = TreeGridFunctions.getTreeCell(TreeGridFunctions.getAllRows(fix)[1]); + expect(getHighlightedCellValue(treeCell.nativeElement)).toBe('Software Developer Evangelist'); + + // Active highlight is in second tree cell. + let spans = getHighlightSpans(treeCell.nativeElement); + let activeSpan = getActiveSpan(treeCell.nativeElement); + expect(spans.length).toBe(2); + expect(activeSpan).toBe(spans[0]); + + // Find next + actualCount = treeGrid.findNext('ev'); + + // Active highlight is still in second tree cell. + spans = getHighlightSpans(treeCell.nativeElement); + activeSpan = getActiveSpan(treeCell.nativeElement); + expect(spans.length).toBe(2); + expect(activeSpan).toBe(spans[1]); + + // Find next + actualCount = treeGrid.findNext('ev'); + + // Active highlight is no longer in the second tree cell. + spans = getHighlightSpans(treeCell.nativeElement); + activeSpan = getActiveSpan(treeCell.nativeElement); + expect(spans.length).toBe(2); + expect(activeSpan).not.toBe(spans[0]); + expect(activeSpan).not.toBe(spans[1]); + + const othertreeCell = TreeGridFunctions.getTreeCell(TreeGridFunctions.getAllRows(fix)[2]); + expect(getHighlightedCellValue(othertreeCell.nativeElement)).toBe('Junior Software Developer'); + + // Active highlight is now in the third tree cell. + spans = getHighlightSpans(othertreeCell.nativeElement); + activeSpan = getActiveSpan(othertreeCell.nativeElement); + expect(spans.length).toBe(1); + expect(activeSpan).toBe(spans[0]); + }); + + it('Search highlights should work for root and child rows', () => { + let actualCount = treeGrid.findNext('Software Developer'); + verifySearchResult(fixNativeElement, 6, 0, actualCount); + + actualCount = treeGrid.findNext('Software Developer'); + verifySearchResult(fixNativeElement, 6, 1, actualCount); + + actualCount = treeGrid.findPrev('Software Developer'); + verifySearchResult(fixNativeElement, 6, 0, actualCount); + + actualCount = treeGrid.findNext('Software Developer'); + verifySearchResult(fixNativeElement, 6, 1, actualCount); + + actualCount = treeGrid.findNext('Software Developer'); + verifySearchResult(fixNativeElement, 6, 2, actualCount); + + actualCount = treeGrid.findPrev('Software Developer'); + verifySearchResult(fixNativeElement, 6, 1, actualCount); + }); + }); + + describe('Primary/Foreign key', () => { + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridPrimaryForeignKeyComponent); + fix.detectChanges(); + fixNativeElement = fix.debugElement.nativeElement; + treeGrid = fix.componentInstance.treeGrid; + }); + + it('Search highlights should work for tree cells', () => { + treeGrid.findNext('1'); + fix.detectChanges(); + + const cell = TreeGridFunctions.getCell(fix, 0, 'ID').nativeElement; + const highlights = getHighlightSpans(cell); + const activeHighlight = getActiveSpan(cell); + expect(highlights.length).toBe(1); + expect(activeHighlight).not.toBeNull(); + expect(getHighlightedCellValue(cell)).toBe('1'); + }); + + it('Search highlights should work for root and child rows', () => { + let actualCount = treeGrid.findNext('re'); + verifySearchResult(fixNativeElement, 7, 0, actualCount); + + actualCount = treeGrid.findNext('re'); + verifySearchResult(fixNativeElement, 7, 1, actualCount); + + actualCount = treeGrid.findPrev('re'); + verifySearchResult(fixNativeElement, 7, 0, actualCount); + + actualCount = treeGrid.findNext('re'); + verifySearchResult(fixNativeElement, 7, 1, actualCount); + + actualCount = treeGrid.findNext('re'); + verifySearchResult(fixNativeElement, 7, 2, actualCount); + + actualCount = treeGrid.findPrev('re'); + verifySearchResult(fixNativeElement, 7, 1, actualCount); + }); + + it('Should update search highlights when filtering', () => { + treeGrid.findNext('Software Developer'); + + verifySearchResult(fixNativeElement, 3, 0); + + // Apply filter + treeGrid.filter('JobTitle', 'Associate', IgxStringFilteringOperand.instance().condition('contains')); + fix.detectChanges(); + + verifySearchResult(fixNativeElement, 2, 0); + }); + + it('Should update search highlights when clearing filter', () => { + // Apply filter + treeGrid.filter('JobTitle', 'Associate', IgxStringFilteringOperand.instance().condition('contains')); + fix.detectChanges(); + + treeGrid.findNext('Software Developer'); + + verifySearchResult(fixNativeElement, 2, 0); + + // Clear filter + treeGrid.clearFilter(); + fix.detectChanges(); + + verifySearchResult(fixNativeElement, 3, 0); + }); + + it('Should update search highlights when sorting', () => { + treeGrid.findNext('er'); + + verifySearchResult(fixNativeElement, 6, 0); + + // Apply asc sorting + treeGrid.columnList.filter(c => c.field === 'JobTitle')[0].sortable = true; + fix.detectChanges(); + treeGrid.sort({fieldName: 'JobTitle', dir: SortingDirection.Asc, ignoreCase: true }); + fix.detectChanges(); + + verifySearchResult(fixNativeElement, 6, 3); + + // Apply desc sorting + treeGrid.sort({fieldName: 'JobTitle', dir: SortingDirection.Desc, ignoreCase: true }); + fix.detectChanges(); + + verifySearchResult(fixNativeElement, 6, 1); + }); + + it('Should update search highlights when clearing sorting', () => { + // Apply asc sorting + treeGrid.columnList.filter(c => c.field === 'JobTitle')[0].sortable = true; + fix.detectChanges(); + treeGrid.sort({fieldName: 'JobTitle', dir: SortingDirection.Asc, ignoreCase: true }); + fix.detectChanges(); + + treeGrid.findNext('er'); + + verifySearchResult(fixNativeElement, 6, 0); + + // Clear sorting + treeGrid.clearSort(); + fix.detectChanges(); + + verifySearchResult(fixNativeElement, 6, 3); + }); + + it('Should update search highlights when a column is pinned/unpinned', () => { + treeGrid.findNext('casey'); + fix.detectChanges(); + + // Verify a 'Name' cell is unpinned and has active search result in it. + let treeCell = TreeGridFunctions.getTreeCell(TreeGridFunctions.getAllRows(fix)[0]); + let cell = TreeGridFunctions.getCell(fix, 0, 'Name'); + expect(cell).not.toBe(treeCell); + verifySearchResult(cell.nativeElement, 1, 0); + + // Pin column + const column = treeGrid.columnList.filter(c => c.field === 'Name')[0]; + column.pinned = true; + fix.detectChanges(); + + // Verify a 'Name' cell is pinned tree cell and has active search result in it. + treeCell = TreeGridFunctions.getTreeCell(TreeGridFunctions.getAllRows(fix)[0]); + cell = TreeGridFunctions.getCell(fix, 0, 'Name'); + expect(cell).toBe(treeCell); + verifySearchResult(cell.nativeElement, 1, 0); + + // Unpin column + column.pinned = false; + fix.detectChanges(); + + // Verify a 'Name' cell is unpinned and has active search result in it. + treeCell = TreeGridFunctions.getTreeCell(TreeGridFunctions.getAllRows(fix)[0]); + cell = TreeGridFunctions.getCell(fix, 0, 'Name'); + expect(cell).not.toBe(treeCell); + verifySearchResult(cell.nativeElement, 1, 0); + }); + + it('Should update search highlights when a column that doesnt contain search results is hidden/shown', () => { + treeGrid.findNext('casey'); + + let cell = TreeGridFunctions.getCell(fix, 0, 'Name'); + verifySearchResult(cell.nativeElement, 1, 0); + + // Hide 'Age' column + const column = treeGrid.columnList.filter(c => c.field === 'Age')[0]; + column.hidden = true; + fix.detectChanges(); + + cell = TreeGridFunctions.getCell(fix, 0, 'Name'); + verifySearchResult(cell.nativeElement, 1, 0); + + // Show 'Age' column + column.hidden = false; + fix.detectChanges(); + + cell = TreeGridFunctions.getCell(fix, 0, 'Name'); + verifySearchResult(cell.nativeElement, 1, 0); + }); + + it('Should update search highlights when a column that contains search results is hidden/shown', () => { + treeGrid.findNext('casey'); + + let cell = TreeGridFunctions.getCell(fix, 0, 'Name'); + verifySearchResult(cell.nativeElement, 1, 0); + + // Hide 'Name' column + const column = treeGrid.columnList.filter(c => c.field === 'Name')[0]; + column.hidden = true; + fix.detectChanges(); + + verifySearchResult(fixNativeElement, 0, -1); + + // Show 'Name' column + column.hidden = false; + fix.detectChanges(); + + cell = TreeGridFunctions.getCell(fix, 0, 'Name'); + verifySearchResult(cell.nativeElement, 1, 0); + + verifyVisibleCellValueDivsCount(fix); + }); + + it('Search highlights should work for case sensitive and exact match searches', () => { + let actualCount = treeGrid.findNext('er'); + fix.detectChanges(); + verifySearchResult(fixNativeElement, 6, 0, actualCount); + + actualCount = treeGrid.findNext('er', true, false); + fix.detectChanges(); + verifySearchResult(fixNativeElement, 5, 0, actualCount); + + actualCount = treeGrid.findNext('Software Developer'); + fix.detectChanges(); + verifySearchResult(fixNativeElement, 3, 0, actualCount); + + actualCount = treeGrid.findNext('Software Developer', false, true); + fix.detectChanges(); + verifySearchResult(fixNativeElement, 1, 0, actualCount); + }); + }); + + describe('Scrollable TreeGrid', () => { + beforeEach(async () => { + fix = TestBed.createComponent(IgxTreeGridSummariesScrollingComponent); + fix.detectChanges(); + fixNativeElement = fix.debugElement.nativeElement; + treeGrid = fix.componentInstance.treeGrid; + treeGrid.expansionDepth = 0; + treeGrid.height = '400px'; + treeGrid.columnList.get(3).hasSummary = false; + fix.detectChanges(); + }); + + const expectedValues = ['Andrew', 'Janet', 'Anne', 'Danielle', 'Callahan', 'Jonathan', + 'Nancy', 'Wang', 'Buchanan', 'Buchanan', 'Armand', 'Dane', 'Declan']; + + it('findNext should navigate search highlights with collapsed rows', async () => { + for (let i = 0; i < 14; i++) { + const expectedValue = expectedValues[i % expectedValues.length]; + const actualCount = treeGrid.findNext('an'); + await wait(50); + fix.detectChanges(); + expect(actualCount).toBe(expectedValues.length); + verifyActiveCellValue(fixNativeElement, expectedValue); + } + }); + + it('findPrev should navigate search highlights with collapsed rows', async () => { + for (let i = 13; i >= 0; i--) { + const expectedValue = expectedValues[i % expectedValues.length]; + const actualCount = treeGrid.findPrev('an'); + await wait(50); + fix.detectChanges(); + expect(actualCount).toBe(expectedValues.length); + verifyActiveCellValue(fixNativeElement, expectedValue); + } + }); + + it('findNext should navigate search highlights with paging', async () => { + fix.componentInstance.paging = true; + fix.detectChanges(); + treeGrid.expansionDepth = Infinity; + treeGrid.perPage = 5; + await wait(50); + fix.detectChanges(); + + const expectedPages = [0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3]; + + for (let i = 0; i < 14; i++) { + const index = i % expectedValues.length; + const expectedValue = expectedValues[index]; + const actualCount = treeGrid.findNext('an'); + await wait(50); + fix.detectChanges(); + + expect(treeGrid.page).toBe(expectedPages[index]); + expect(actualCount).toBe(expectedValues.length); + verifyActiveCellValue(fixNativeElement, expectedValue); + } + }); + + it('findNext should navigate search highlights with paging and collapsed rows', async () => { + fix.componentInstance.paging = true; + fix.detectChanges(); + treeGrid.perPage = 5; + await wait(50); + fix.detectChanges(); + + const expectedPages = [0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3]; + const expectedPageCounts = [1, 2, 3, 3, 3, 3, 3, 3, 3, 3, 4, 4, 5]; + + for (let i = 0; i < 13; i++) { + const index = i % expectedValues.length; + const expectedValue = expectedValues[index]; + const actualCount = treeGrid.findNext('an'); + await wait(50); + fix.detectChanges(); + + expect(treeGrid.paginator.page).toBe(expectedPages[index]); + expect(treeGrid.paginator.totalPages).toBe(expectedPageCounts[index]); + expect(actualCount).toBe(expectedValues.length); + verifyActiveCellValue(fixNativeElement, expectedValue); + } + }); + }); +}); + +const getHighlightSpans = (nativeParent: HTMLElement) => nativeParent.querySelectorAll('.' + HIGHLIGHT_CLASS); + +const getActiveSpan = (nativeParent: HTMLElement) => nativeParent.querySelector('.' + ACTIVE_CLASS); + +/** + * Verifies the results from a search execution by providing the expected highlighted elements count + * and the expected active span index. + * expectedActiveSpanIndex should be passed as -1 if there should be no active span element. + * (Optionally the result from findNext/findPrev methods - the actualAPISearchCount, can also be checked.) + */ +const verifySearchResult = (nativeParent, expectedHighlightSpansCount, expectedActiveSpanIndex, actualAPISearchCount?) => { + const spans = getHighlightSpans(nativeParent); + const activeSpan = getActiveSpan(nativeParent); + + if (actualAPISearchCount) { + expect(actualAPISearchCount).toBe(expectedHighlightSpansCount, 'incorrect highlight elements count returned from api'); + } + + expect(spans.length).toBe(expectedHighlightSpansCount, 'incorrect highlight elements count'); + + if (expectedActiveSpanIndex !== -1) { + // If active element should exist. + expect(activeSpan).toBe(spans[expectedActiveSpanIndex], 'incorrect active element'); + } else { + // If active element should not exist. (used when spans.length is expected to be 0 as well) + expect(activeSpan).toBeNull('active element was found'); + } +}; + +const getHighlightedCellValue = (cell: HTMLElement) => { + const valueDivs: HTMLElement[] = Array.from(cell.querySelectorAll(CELL_VALUE_DIV_CSS_CLASS)); + return valueDivs.filter(v => !v.hidden).map(v => v.innerText.trim()).join(''); +}; + +/** + * Verifies that every single cell contains only one visible div with the cell value in it. + */ +const verifyVisibleCellValueDivsCount = (fix) => { + // Verify that there is NO cell with a duplicated value. + const allCells = TreeGridFunctions.getAllCells(fix); + allCells.forEach(cell => { + const valueDivs: HTMLElement[] = Array.from(cell.nativeElement.querySelectorAll(CELL_VALUE_DIV_CSS_CLASS)); + // only one visible 'value div' should be present + expect(valueDivs.filter(div => !div.hidden).length).toBe(1, 'incorrect visible value divs count'); + }); +}; + +const verifyActiveCellValue = (nativeParent: HTMLElement, expectedValue: string) => { + const activeSpan = getActiveSpan(nativeParent); + const cell = activeSpan.parentElement.parentElement; + const cellValue = getHighlightedCellValue(cell); + expect(cellValue).toBe(expectedValue); +}; diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-selection.service.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-selection.service.ts new file mode 100644 index 00000000000..9bcce2f607d --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-selection.service.ts @@ -0,0 +1,277 @@ +import { Injectable } from '@angular/core'; +import { GridSelectionMode } from 'igniteui-angular/grids/core'; +import { IgxGridSelectionService } from 'igniteui-angular/grids/core'; +import { ITreeGridRecord } from 'igniteui-angular/core'; + +@Injectable() +export class IgxTreeGridSelectionService extends IgxGridSelectionService { + private rowsToBeSelected: Set; + private rowsToBeIndeterminate: Set; + + /** Select specified rows. No event is emitted. */ + public override selectRowsWithNoEvent(rowIDs: any[], clearPrevSelection?): void { + if (this.grid && this.grid.rowSelection === GridSelectionMode.multipleCascade) { + this.cascadeSelectRowsWithNoEvent(rowIDs, clearPrevSelection); + return; + } + super.selectRowsWithNoEvent(rowIDs, clearPrevSelection); + } + + /** Deselect specified rows. No event is emitted. */ + public override deselectRowsWithNoEvent(rowIDs: any[]): void { + if (this.grid.rowSelection === GridSelectionMode.multipleCascade) { + this.cascadeDeselectRowsWithNoEvent(rowIDs); + return; + } + super.deselectRowsWithNoEvent(rowIDs); + } + + public override emitRowSelectionEvent(newSelection, added, removed, event?): boolean { + if (this.grid.rowSelection === GridSelectionMode.multipleCascade) { + this.emitCascadeRowSelectionEvent(newSelection, added, removed, event); + return; + } + + super.emitRowSelectionEvent(newSelection, added, removed, event); + } + + public updateCascadeSelectionOnFilterAndCRUD( + parents: Set, + crudRowID?: any, + visibleRowIDs: Set = null) { + if (visibleRowIDs === null) { + // if the tree grid has flat structure + // do not explicitly handle the selection state of the rows + if (!parents.size) { + return; + } + visibleRowIDs = new Set(this.getRowIDs(this.allData)); + this.rowsToBeSelected = new Set(this.rowSelection); + this.rowsToBeIndeterminate = new Set(this.indeterminateRows); + if (crudRowID) { + this.rowSelection.delete(crudRowID); + } + } + if (!parents.size) { + this.rowSelection = new Set(this.rowsToBeSelected); + this.indeterminateRows = new Set(this.rowsToBeIndeterminate); + // TODO: emit selectionChangeD event, calculate its args through the handleAddedAndRemovedArgs method + this.clearHeaderCBState(); + this.selectedRowsChange.next(this.getSelectedRows()); + return; + } + const newParents = new Set(); + parents.forEach(parent => { + this.handleRowSelectionState(parent, visibleRowIDs); + if (parent && parent.parent) { + newParents.add(parent.parent); + } + }); + this.updateCascadeSelectionOnFilterAndCRUD(newParents, null, visibleRowIDs); + } + + private cascadeSelectRowsWithNoEvent(rowIDs: any[], clearPrevSelection?: boolean): void { + if (clearPrevSelection) { + this.indeterminateRows.clear(); + this.rowSelection.clear(); + this.calculateRowsNewSelectionState({ added: rowIDs, removed: [] }); + } else { + const oldSelection = this.getSelectedRows(); + const newSelection = [...oldSelection, ...rowIDs]; + const args = { oldSelection, newSelection }; + + // retrieve only the rows without their parents/children which has to be added to the selection + this.handleAddedAndRemovedArgs(args); + + this.calculateRowsNewSelectionState(args); + } + this.rowSelection = new Set(this.rowsToBeSelected); + this.indeterminateRows = new Set(this.rowsToBeIndeterminate); + this.clearHeaderCBState(); + this.selectedRowsChange.next(this.getSelectedRows()); + } + + private cascadeDeselectRowsWithNoEvent(rowIDs: any[]): void { + const args = { added: [], removed: rowIDs }; + this.calculateRowsNewSelectionState(args); + + this.rowSelection = new Set(this.rowsToBeSelected); + this.indeterminateRows = new Set(this.rowsToBeIndeterminate); + this.clearHeaderCBState(); + this.selectedRowsChange.next(this.getSelectedRows()); + } + + public get selectionService(): IgxGridSelectionService { + return this.grid.selectionService; + } + + private emitCascadeRowSelectionEvent(newSelection, added, removed, event?): boolean { + const currSelection = this.getSelectedRows(); + if (this.areEqualCollections(currSelection, newSelection)) { + return; + } + + const args = { + owner: this.grid, oldSelection: this.getSelectedRowsData(), newSelection, + added, removed, event, cancel: false + }; + + this.calculateRowsNewSelectionState(args, !!this.grid.primaryKey); + args.newSelection = Array.from(this.grid.gridAPI.get_all_data().filter(r => this.rowsToBeSelected.has(this.grid.primaryKey ? r[this.grid.primaryKey] : r))); + + // retrieve rows/parents/children which has been added/removed from the selection + this.handleAddedAndRemovedArgs(args); + + this.grid.rowSelectionChanging.emit(args); + + if (args.cancel) { + return; + } + const newSelectionIDs = args.newSelection.map(r => this.grid.primaryKey? r[this.grid.primaryKey] : r) + // if args.newSelection hasn't been modified + if (this.areEqualCollections(Array.from(this.rowsToBeSelected), newSelectionIDs)) { + this.rowSelection = new Set(this.rowsToBeSelected); + this.indeterminateRows = new Set(this.rowsToBeIndeterminate); + this.clearHeaderCBState(); + this.selectedRowsChange.next(this.getSelectedRows()); + } else { + // select the rows within the modified args.newSelection with no event + this.cascadeSelectRowsWithNoEvent(newSelectionIDs, true); + } + } + + + /** + * retrieve the rows which should be added/removed to/from the old selection + */ + private handleAddedAndRemovedArgs(args: any) { + const newSelectionSet = new Set(args.newSelection); + const oldSelectionSet = new Set(args.oldSelection); + args.removed = args.oldSelection.filter(x => !newSelectionSet.has(x)); + args.added = args.newSelection.filter(x => !oldSelectionSet.has(x)); + } + + /** + * adds to rowsToBeProcessed set all visible children of the rows which was initially within the rowsToBeProcessed set + * + * @param rowsToBeProcessed set of the rows (without their parents/children) to be selected/deselected + * @param visibleRowIDs list of all visible rowIds + * @returns a new set with all direct parents of the rows within rowsToBeProcessed set + */ + private collectRowsChildrenAndDirectParents(rowsToBeProcessed: Set, visibleRowIDs: Set, adding: boolean, shouldConvert: boolean): Set { + const processedRowsParents = new Set(); + Array.from(rowsToBeProcessed).forEach((row) => { + const rowID = shouldConvert ? row[this.grid.primaryKey] : row; + this.selectDeselectRow(rowID, adding); + const rowTreeRecord = this.grid.gridAPI.get_rec_by_id(rowID); + const rowAndAllChildren = this.get_all_children(rowTreeRecord); + rowAndAllChildren.forEach(r => { + if (visibleRowIDs.has(r.key)) { + this.selectDeselectRow(r.key, adding); + } + }); + if (rowTreeRecord && rowTreeRecord.parent) { + processedRowsParents.add(rowTreeRecord.parent); + } + }); + return processedRowsParents; + } + + + /** + * populates the rowsToBeSelected and rowsToBeIndeterminate sets + * with the rows which will be eventually in selected/indeterminate state + */ + private calculateRowsNewSelectionState(args: any, shouldConvert = false) { + this.rowsToBeSelected = new Set(args.oldSelection ? shouldConvert ? args.oldSelection.map(r => r[this.grid.primaryKey]) : args.oldSelection : this.getSelectedRows()); + this.rowsToBeIndeterminate = new Set(this.getIndeterminateRows()); + + const visibleRowIDs = new Set(this.getRowIDs(this.allData)); + + const removed = new Set(args.removed); + const added = new Set(args.added); + + if (removed && removed.size) { + let removedRowsParents = new Set(); + + removedRowsParents = this.collectRowsChildrenAndDirectParents(removed, visibleRowIDs, false, shouldConvert); + + Array.from(removedRowsParents).forEach((parent) => { + this.handleParentSelectionState(parent, visibleRowIDs); + }); + } + + if (added && added.size) { + let addedRowsParents = new Set(); + + addedRowsParents = this.collectRowsChildrenAndDirectParents(added, visibleRowIDs, true, shouldConvert); + + Array.from(addedRowsParents).forEach((parent) => { + this.handleParentSelectionState(parent, visibleRowIDs); + }); + } + } + + /** + * recursively handle the selection state of the direct and indirect parents + */ + private handleParentSelectionState(treeRow: ITreeGridRecord, visibleRowIDs: Set) { + if (!treeRow) { + return; + } + this.handleRowSelectionState(treeRow, visibleRowIDs); + if (treeRow.parent) { + this.handleParentSelectionState(treeRow.parent, visibleRowIDs); + } + } + + /** + * Handle the selection state of a given row based the selection states of its direct children + */ + private handleRowSelectionState(treeRow: ITreeGridRecord, visibleRowIDs: Set) { + let visibleChildren = []; + if (treeRow && treeRow.children) { + visibleChildren = treeRow.children.filter(child => visibleRowIDs.has(child.key)); + } + if (visibleChildren.length) { + if (visibleChildren.every(row => this.rowsToBeSelected.has(row.key))) { + this.selectDeselectRow(treeRow.key, true); + } else if (visibleChildren.some(row => this.rowsToBeSelected.has(row.key) || this.rowsToBeIndeterminate.has(row.key))) { + this.rowsToBeIndeterminate.add(treeRow.key); + this.rowsToBeSelected.delete(treeRow.key); + } else { + this.selectDeselectRow(treeRow.key, false); + } + } else { + // if the children of the row has been deleted and the row was selected do not change its state + if (this.isRowSelected(treeRow.key)) { + this.selectDeselectRow(treeRow.key, true); + } else { + this.selectDeselectRow(treeRow.key, false); + } + } + } + + private get_all_children(record: ITreeGridRecord): any[] { + const children = []; + if (record && record.children && record.children.length) { + for (const child of record.children) { + children.push(...this.get_all_children(child)); + children.push(child); + } + } + return children; + + } + + private selectDeselectRow(rowID: any, select: boolean) { + if (select) { + this.rowsToBeSelected.add(rowID); + this.rowsToBeIndeterminate.delete(rowID); + } else { + this.rowsToBeSelected.delete(rowID); + this.rowsToBeIndeterminate.delete(rowID); + } + } + +} diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-selection.spec.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-selection.spec.ts new file mode 100644 index 00000000000..ecb5458ddbe --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-selection.spec.ts @@ -0,0 +1,2247 @@ +import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { IgxTreeGridComponent } from './tree-grid.component'; +import { + IgxTreeGridSimpleComponent, + IgxTreeGridCellSelectionComponent, + IgxTreeGridSelectionRowEditingComponent, + IgxTreeGridSelectionWithTransactionComponent, + IgxTreeGridRowEditingTransactionComponent, + IgxTreeGridCustomRowSelectorsComponent, + IgxTreeGridCascadingSelectionComponent, + IgxTreeGridCascadingSelectionTransactionComponent, + IgxTreeGridPrimaryForeignKeyCascadeSelectionComponent +} from '../../../test-utils/tree-grid-components.spec'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { + TreeGridFunctions, + TREE_ROW_SELECTION_CSS_CLASS, + ROW_EDITING_BANNER_OVERLAY_CLASS, + TREE_ROW_DIV_SELECTION_CHECKBOX_CSS_CLASS +} from '../../../test-utils/tree-grid-functions.spec'; +import { wait, UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { IgxActionStripComponent } from 'igniteui-angular/action-strip'; +import { GridFunctions } from '../../../test-utils/grid-functions.spec'; +import { GridSelectionMode, IgxGridCell } from 'igniteui-angular/grids/core'; +import { By } from '@angular/platform-browser'; +import { IRowSelectionEventArgs } from 'igniteui-angular/grids/core'; +import { FilteringExpressionsTree, FilteringLogic, IgxNumberFilteringOperand, IgxStringFilteringOperand, SortingDirection } from 'igniteui-angular/core'; + +describe('IgxTreeGrid - Selection #tGrid', () => { + let fix; + let treeGrid: IgxTreeGridComponent; + let actionStrip: IgxActionStripComponent; + const endTransition = () => { + // transition end needs to be simulated + const animationElem = fix.nativeElement.querySelector('.igx-grid__tr--inner'); + const endEvent = new AnimationEvent('animationend'); + animationElem.dispatchEvent(endEvent); + }; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxTreeGridSimpleComponent, + IgxTreeGridCellSelectionComponent, + IgxTreeGridSelectionRowEditingComponent, + IgxTreeGridSelectionWithTransactionComponent, + IgxTreeGridRowEditingTransactionComponent, + IgxTreeGridCustomRowSelectorsComponent, + IgxTreeGridCascadingSelectionComponent, + IgxTreeGridCascadingSelectionTransactionComponent, + IgxTreeGridPrimaryForeignKeyCascadeSelectionComponent + ] + }).compileComponents(); + })); + + describe('API Row Selection', () => { + + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridSimpleComponent); + + treeGrid = fix.componentInstance.treeGrid; + treeGrid.rowSelection = GridSelectionMode.multiple; + fix.detectChanges(); + }); + + it('should have checkbox on each row if rowSelection is not none', () => { + const rows = TreeGridFunctions.getAllRows(fix); + + expect(rows.length).toBe(10); + rows.forEach((row) => { + const checkBoxElement = row.nativeElement.querySelector(TREE_ROW_DIV_SELECTION_CHECKBOX_CSS_CLASS); + expect(checkBoxElement).not.toBeNull(); + }); + + treeGrid.rowSelection = GridSelectionMode.none; + fix.detectChanges(); + + expect(rows.length).toBe(10); + rows.forEach((row) => { + const checkBoxElement = row.nativeElement.querySelector(TREE_ROW_DIV_SELECTION_CHECKBOX_CSS_CLASS); + expect(checkBoxElement).toBeNull(); + }); + }); + + it('should be able to select/deselect all rows', () => { + treeGrid.selectAllRows(); + fix.detectChanges(); + + TreeGridFunctions.verifyDataRowsSelection(fix, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], true); + expect(treeGrid.selectedRows.length).toEqual(10); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, true); + + treeGrid.deselectAllRows(); + fix.detectChanges(); + + expect(treeGrid.selectedRows).toEqual([]); + TreeGridFunctions.verifyDataRowsSelection(fix, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], false); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, false); + }); + + it('when all items are selected and then some of the selected rows are deleted, still all the items should be selected', () => { + treeGrid.selectAllRows(); + fix.detectChanges(); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, true); + + treeGrid.deleteRowById(treeGrid.selectedRows[0]); + fix.detectChanges(); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, true); + + treeGrid.deleteRowById(treeGrid.selectedRows[0]); + fix.detectChanges(); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, true); + + treeGrid.deleteRowById(treeGrid.selectedRows[0]); + fix.detectChanges(); + // When deleting the last selected row, header checkbox will be unchecked. + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, false); + }); + + it('should be able to select row of any level', () => { + treeGrid.selectRows([treeGrid.gridAPI.get_row_by_index(0).key], true); + fix.detectChanges(); + + // Verify selection. + TreeGridFunctions.verifyDataRowsSelection(fix, [0], true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + treeGrid.selectRows([treeGrid.gridAPI.get_row_by_index(2).key], false); + fix.detectChanges(); + + // Verify new selection by keeping the old one. + TreeGridFunctions.verifyDataRowsSelection(fix, [0, 2], true); + + treeGrid.selectRows([treeGrid.gridAPI.get_row_by_index(1).key, treeGrid.gridAPI.get_row_by_index(3).key, + treeGrid.gridAPI.get_row_by_index(6).key, treeGrid.gridAPI.get_row_by_index(8).key], true); + fix.detectChanges(); + + // Verify new selection by NOT keeping the old one. + TreeGridFunctions.verifyDataRowsSelection(fix, [0, 2], false); + TreeGridFunctions.verifyDataRowsSelection(fix, [1, 3, 6, 8], true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + }); + + it('should be able to deselect row of any level', () => { + treeGrid.selectRows([treeGrid.gridAPI.get_row_by_index(1).key, treeGrid.gridAPI.get_row_by_index(3).key, + treeGrid.gridAPI.get_row_by_index(6).key, treeGrid.gridAPI.get_row_by_index(8).key, + treeGrid.gridAPI.get_row_by_index(9).key], true); + fix.detectChanges(); + + treeGrid.deselectRows([treeGrid.gridAPI.get_row_by_index(1).key, treeGrid.gridAPI.get_row_by_index(3).key]); + fix.detectChanges(); + + // Verify modified selection + TreeGridFunctions.verifyDataRowsSelection(fix, [1, 3], false); + TreeGridFunctions.verifyDataRowsSelection(fix, [6, 8, 9], true); + }); + + it('should persist the selection after sorting', () => { + treeGrid.selectRows([treeGrid.gridAPI.get_row_by_index(0).key, treeGrid.gridAPI.get_row_by_index(4).key], true); + fix.detectChanges(); + + treeGrid.sort({ fieldName: 'Age', dir: SortingDirection.Asc, ignoreCase: false }); + fix.detectChanges(); + + // Verification indices are different since the sorting changes rows' positions. + TreeGridFunctions.verifyDataRowsSelection(fix, [2, 7], true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + treeGrid.clearSort(); + fix.detectChanges(); + + TreeGridFunctions.verifyDataRowsSelection(fix, [0, 4], true); + }); + + it('should persist the selection after filtering', fakeAsync(() => { + treeGrid.selectRows([treeGrid.gridAPI.get_row_by_index(0).key, treeGrid.gridAPI.get_row_by_index(5).key, + treeGrid.gridAPI.get_row_by_index(8).key], true); + fix.detectChanges(); + + treeGrid.filter('Age', 40, IgxNumberFilteringOperand.instance().condition('greaterThan')); + fix.detectChanges(); + tick(); + + // Verification indices are different since the sorting changes rows' positions. + TreeGridFunctions.verifyDataRowsSelection(fix, [0, 2, 4], true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + treeGrid.clearFilter(); + fix.detectChanges(); + tick(); + + TreeGridFunctions.verifyDataRowsSelection(fix, [0, 5, 8], true); + })); + + it('should be able to select and select only filtered data', () => { + treeGrid.selectRows([299, 147]); + fix.detectChanges(); + + treeGrid.filter('Age', 40, IgxNumberFilteringOperand.instance().condition('greaterThan')); + fix.detectChanges(); + + expect(treeGrid.selectedRows).toEqual([299, 147]); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + treeGrid.selectAllRows(true); + fix.detectChanges(); + + expect(treeGrid.selectedRows).toEqual([299, 147, 317, 998, 19, 847]); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, true); + + treeGrid.deselectAllRows(true); + fix.detectChanges(); + + expect(treeGrid.selectedRows).toEqual([299]); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, false); + + treeGrid.clearFilter(); + fix.detectChanges(); + + expect(treeGrid.selectedRows).toEqual([299]); + TreeGridFunctions.verifyDataRowsSelection(fix, [6], true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + }); + + it('should persist the selection after expand/collapse', () => { + treeGrid.selectRows([treeGrid.gridAPI.get_row_by_index(0).key, treeGrid.gridAPI.get_row_by_index(3).key, + treeGrid.gridAPI.get_row_by_index(5).key], true); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(3); + + // Collapse row and verify visible selected rows + treeGrid.toggleRow(treeGrid.gridAPI.get_row_by_index(0).key); + fix.detectChanges(); + expect(getVisibleSelectedRows(fix).length).toBe(1); + + // Expand same row and verify visible selected rows + treeGrid.toggleRow(treeGrid.gridAPI.get_row_by_index(0).key); + fix.detectChanges(); + expect(getVisibleSelectedRows(fix).length).toBe(3); + + TreeGridFunctions.verifyDataRowsSelection(fix, [0, 3, 5], true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + }); + + it('should persist selection after paging', fakeAsync(() => { + treeGrid.selectRows([treeGrid.gridAPI.get_row_by_index(0).key, treeGrid.gridAPI.get_row_by_index(3).key, + treeGrid.gridAPI.get_row_by_index(5).key], true); + fix.detectChanges(); + tick(16); + + fix.componentInstance.paging = true; + fix.detectChanges(); + treeGrid.paginator.perPage = 4; + fix.detectChanges(); + tick(16); + + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 0, true); + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 1, false); + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 2, false); + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 3, true); + + treeGrid.paginator.page = 1; + fix.detectChanges(); + tick(16); + + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 0, false); + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 1, true); + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 2, false); + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 3, false); + + treeGrid.paginator.page = 2; + fix.detectChanges(); + tick(16); + + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 0, false); + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 1, false); + })); + + it('Should bind selectedRows properly', () => { + fix.componentInstance.selectedRows = [147, 19, 957]; + fix.detectChanges(); + + expect(treeGrid.gridAPI.get_row_by_index(0).selected).toBeTrue(); + expect(treeGrid.gridAPI.get_row_by_index(7).selected).toBeTrue(); + expect(treeGrid.gridAPI.get_row_by_index(4).selected).toBeFalse(); + + fix.componentInstance.selectedRows = [847, 711]; + fix.detectChanges(); + + expect(treeGrid.gridAPI.get_row_by_index(0).selected).toBeFalse(); + expect(treeGrid.gridAPI.get_row_by_index(4).selected).toBeTrue(); + expect(treeGrid.gridAPI.get_row_by_index(8).selected).toBeTrue(); + }); + }); + + describe('UI Row Selection', () => { + + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridSimpleComponent); + + treeGrid = fix.componentInstance.treeGrid; + treeGrid.rowSelection = GridSelectionMode.multiple; + fix.detectChanges(); + }); + + it('should be able to select/deselect all rows', () => { + TreeGridFunctions.clickHeaderRowSelectionCheckbox(fix); + fix.detectChanges(); + + TreeGridFunctions.verifyDataRowsSelection(fix, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, true); + + TreeGridFunctions.clickHeaderRowSelectionCheckbox(fix); + fix.detectChanges(); + + TreeGridFunctions.verifyDataRowsSelection(fix, [], true); + TreeGridFunctions.verifyDataRowsSelection(fix, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], false); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, false); + }); + + it('Header checkbox should NOT select/deselect all rows when selectionMode is single', () => { + spyOn(treeGrid.rowSelectionChanging, 'emit').and.callThrough(); + treeGrid.rowSelection = GridSelectionMode.single; + fix.detectChanges(); + + TreeGridFunctions.clickHeaderRowSelectionCheckbox(fix); + fix.detectChanges(); + + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, false); + TreeGridFunctions.verifyDataRowsSelection(fix, [], false); + expect(treeGrid.selectedRows).toEqual([]); + expect(treeGrid.rowSelectionChanging.emit).toHaveBeenCalledTimes(0); + + TreeGridFunctions.clickHeaderRowSelectionCheckbox(fix); + fix.detectChanges(); + + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, false); + TreeGridFunctions.verifyDataRowsSelection(fix, [], false); + expect(treeGrid.selectedRows).toEqual([]); + expect(treeGrid.rowSelectionChanging.emit).toHaveBeenCalledTimes(0); + }); + + it('should be able to select row of any level', () => { + TreeGridFunctions.clickRowSelectionCheckbox(fix, 0); + fix.detectChanges(); + TreeGridFunctions.verifyDataRowsSelection(fix, [0], true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + TreeGridFunctions.clickRowSelectionCheckbox(fix, 2); + fix.detectChanges(); + TreeGridFunctions.verifyDataRowsSelection(fix, [0, 2], true); + + // Deselect rows + TreeGridFunctions.clickRowSelectionCheckbox(fix, 0); + TreeGridFunctions.clickRowSelectionCheckbox(fix, 2); + fix.detectChanges(); + + // Select new rows + TreeGridFunctions.clickRowSelectionCheckbox(fix, 1); + TreeGridFunctions.clickRowSelectionCheckbox(fix, 3); + TreeGridFunctions.clickRowSelectionCheckbox(fix, 6); + TreeGridFunctions.clickRowSelectionCheckbox(fix, 8); + fix.detectChanges(); + + // Verify new selection + TreeGridFunctions.verifyDataRowsSelection(fix, [0, 2], false); + TreeGridFunctions.verifyDataRowsSelection(fix, [1, 3, 6, 8], true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + }); + + it('should be able to deselect row of any level', () => { + // Select rows + TreeGridFunctions.clickRowSelectionCheckbox(fix, 1); + TreeGridFunctions.clickRowSelectionCheckbox(fix, 3); + TreeGridFunctions.clickRowSelectionCheckbox(fix, 6); + TreeGridFunctions.clickRowSelectionCheckbox(fix, 8); + TreeGridFunctions.clickRowSelectionCheckbox(fix, 9); + fix.detectChanges(); + + // Deselect rows + TreeGridFunctions.clickRowSelectionCheckbox(fix, 1); + TreeGridFunctions.clickRowSelectionCheckbox(fix, 3); + fix.detectChanges(); + + // Verify modified selection + TreeGridFunctions.verifyDataRowsSelection(fix, [1, 3], false); + TreeGridFunctions.verifyDataRowsSelection(fix, [6, 8, 9], true); + }); + it('Rows would be selected only from checkboxes if selectRowOnClick is disabled', () => { + expect(treeGrid.selectRowOnClick).toBe(true); + const firstRow = treeGrid.gridAPI.get_row_by_index(1); + const secondRow = treeGrid.gridAPI.get_row_by_index(4); + expect(treeGrid.selectedRows).toEqual([]); + + // selectRowOnClick = true; + UIInteractions.simulateClickEvent(firstRow.nativeElement); + fix.detectChanges(); + UIInteractions.simulateClickEvent(secondRow.nativeElement, false, true); + fix.detectChanges(); + TreeGridFunctions.verifyDataRowsSelection(fix, [1, 4], true); + + TreeGridFunctions.clickRowSelectionCheckbox(fix, 1); + fix.detectChanges(); + TreeGridFunctions.clickRowSelectionCheckbox(fix, 4); + fix.detectChanges(); + expect(treeGrid.selectedRows).toEqual([]); + + // selectRowOnClick = false + treeGrid.selectRowOnClick = false; + UIInteractions.simulateClickEvent(firstRow.nativeElement); + fix.detectChanges(); + TreeGridFunctions.verifyDataRowsSelection(fix, [1], false); + TreeGridFunctions.clickRowSelectionCheckbox(fix, 1); + fix.detectChanges(); + TreeGridFunctions.verifyDataRowsSelection(fix, [1], true); + TreeGridFunctions.clickRowSelectionCheckbox(fix, 4); + fix.detectChanges(); + TreeGridFunctions.verifyDataRowsSelection(fix, [1, 4], true); + }); + + it('should persist the selection after sorting', () => { + TreeGridFunctions.clickRowSelectionCheckbox(fix, 0); + TreeGridFunctions.clickRowSelectionCheckbox(fix, 4); + + treeGrid.columnList.filter(c => c.field === 'Age')[0].sortable = true; + fix.detectChanges(); + treeGrid.sort({ fieldName: 'Age', dir: SortingDirection.Asc, ignoreCase: false }); + fix.detectChanges(); + + // Verification indices are different since the sorting changes rows' positions. + TreeGridFunctions.verifyDataRowsSelection(fix, [2, 7], true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + treeGrid.clearSort(); + fix.detectChanges(); + + TreeGridFunctions.verifyDataRowsSelection(fix, [0, 4], true); + }); + + it('should persist the selection after filtering', fakeAsync(() => { + TreeGridFunctions.clickRowSelectionCheckbox(fix, 0); + TreeGridFunctions.clickRowSelectionCheckbox(fix, 5); + TreeGridFunctions.clickRowSelectionCheckbox(fix, 8); + + treeGrid.filter('Age', 40, IgxNumberFilteringOperand.instance().condition('greaterThan')); + fix.detectChanges(); + tick(100); + + // Verification indices are different since the sorting changes rows' positions. + TreeGridFunctions.verifyDataRowsSelection(fix, [0, 2, 4], true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + treeGrid.clearFilter(); + fix.detectChanges(); + tick(100); + + TreeGridFunctions.verifyDataRowsSelection(fix, [0, 5, 8], true); + })); + + it('should update header checkbox when reselecting all filtered-in rows', fakeAsync(() => { + treeGrid.filter('Age', 30, IgxNumberFilteringOperand.instance().condition('lessThan')); + tick(100); + + TreeGridFunctions.clickHeaderRowSelectionCheckbox(fix); + fix.detectChanges(); + + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, true); // Verify header checkbox is selected + TreeGridFunctions.clickRowSelectionCheckbox(fix, 0); // Unselect row + fix.detectChanges(); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); // Verify header checkbox is indeterminate + TreeGridFunctions.clickRowSelectionCheckbox(fix, 0); // Reselect same row + fix.detectChanges(); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, true); // Verify header checkbox is selected + })); + + it('should persist the selection after expand/collapse', () => { + TreeGridFunctions.clickRowSelectionCheckbox(fix, 0); + TreeGridFunctions.clickRowSelectionCheckbox(fix, 3); + TreeGridFunctions.clickRowSelectionCheckbox(fix, 5); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(3); + + // Collapse row and verify visible selected rows + TreeGridFunctions.clickRowIndicator(fix, 0); + fix.detectChanges(); + expect(getVisibleSelectedRows(fix).length).toBe(1); + + // Expand same row and verify visible selected rows + TreeGridFunctions.clickRowIndicator(fix, 0); + fix.detectChanges(); + expect(getVisibleSelectedRows(fix).length).toBe(3); + + TreeGridFunctions.verifyDataRowsSelection(fix, [0, 3, 5], true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + }); + + it('should persist selection after paging', fakeAsync(() => { + TreeGridFunctions.clickRowSelectionCheckbox(fix, 0); + TreeGridFunctions.clickRowSelectionCheckbox(fix, 3); + TreeGridFunctions.clickRowSelectionCheckbox(fix, 5); + fix.detectChanges(); + + fix.componentInstance.paging = true; + fix.detectChanges(); + treeGrid.paginator.perPage = 4; + fix.detectChanges(); + tick(16); + + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 0, true); + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 1, false); + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 2, false); + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 3, true); + + treeGrid.paginator.page = 1; + fix.detectChanges(); + tick(16); + + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 0, false); + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 1, true); + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 2, false); + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 3, false); + + treeGrid.paginator.page = 2; + fix.detectChanges(); + tick(16); + + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 0, false); + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 1, false); + })); + + it('Should update selectedRows when selecting rows from UI', () => { + TreeGridFunctions.clickRowSelectionCheckbox(fix, 0); + TreeGridFunctions.clickRowSelectionCheckbox(fix, 3); + TreeGridFunctions.clickRowSelectionCheckbox(fix, 5); + fix.detectChanges(); + + expect(treeGrid.selectedRows.length).toBe(3); + }); + }); + + describe('Row Selection with transactions - Hierarchical DS', () => { + + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridSelectionWithTransactionComponent); + + treeGrid = fix.componentInstance.treeGrid; + treeGrid.rowSelection = GridSelectionMode.multiple; + fix.detectChanges(); + }); + + it('should deselect row when delete its parent', () => { + treeGrid.selectRows([treeGrid.gridAPI.get_row_by_index(3).key, treeGrid.gridAPI.get_row_by_index(5).key], true); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 3, true); + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 5, true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + treeGrid.deleteRow(147); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 3, false); + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 5, false); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, false); + expect(treeGrid.selectedRows).toEqual([]); + + // try to select deleted row + UIInteractions.simulateClickEvent(treeGrid.gridAPI.get_row_by_index(0).nativeElement); + TreeGridFunctions.clickRowSelectionCheckbox(fix, 3); + TreeGridFunctions.clickRowSelectionCheckbox(fix, 5); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 0, false); + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 3, false); + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 5, false); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, false); + expect(treeGrid.selectedRows).toEqual([]); + + // undo transaction + treeGrid.transactions.undo(); + fix.detectChanges(); + + // select rows + UIInteractions.simulateClickEvent(treeGrid.gridAPI.get_row_by_index(0).nativeElement); + TreeGridFunctions.clickRowSelectionCheckbox(fix, 3); + TreeGridFunctions.clickRowSelectionCheckbox(fix, 5); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 0, true); + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 3, true); + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 5, true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + expect(treeGrid.selectedRows).toEqual([147, 317, 998]); + + // redo transaction + treeGrid.transactions.redo(); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 0, false); + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 3, false); + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 5, false); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, false); + expect(treeGrid.selectedRows).toEqual([]); + }); + + it('should have correct header checkbox when delete a row', () => { + treeGrid.selectAllRows(); + fix.detectChanges(); + + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, true); + + treeGrid.deleteRow(317); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 3, false); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, true); + expect(treeGrid.selectedRows.includes(317)).toEqual(false); + expect(treeGrid.selectedRows.includes(711)).toEqual(false); + expect(treeGrid.selectedRows.includes(998)).toEqual(false); + + // undo transaction + treeGrid.transactions.undo(); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 3, false); + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 4, false); + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 5, false); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + // redo transaction + treeGrid.transactions.redo(); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 3, false); + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 4, false); + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 5, false); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, true); + }); + + it('should have correct header checkbox when add a row', () => { + treeGrid.selectAllRows(); + fix.detectChanges(); + + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, true); + + treeGrid.addRow({ ID: 13, Name: 'Michael Cooper', Age: 33, OnPTO: false }, 317); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 6, false); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + expect(treeGrid.selectedRows.includes(13)).toEqual(false); + + // undo transaction + treeGrid.transactions.undo(); + fix.detectChanges(); + + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, true); + }); + + it('should have correct header checkbox when add a row and then selectAll rows', () => { + treeGrid.addRow({ ID: 13, Name: 'Michael Cooper', Age: 33, OnPTO: false }, 317); + fix.detectChanges(); + + TreeGridFunctions.clickHeaderRowSelectionCheckbox(fix); + fix.detectChanges(); + + expect(treeGrid.selectedRows.length).toBeGreaterThan(treeGrid.flatData.length); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, true); + }); + + it('should have correct header checkbox when add a row and undo transaction', fakeAsync(() => { + treeGrid.addRow({ ID: 13, Name: 'Michael Cooper', Age: 33, OnPTO: false }, 317); + tick(); + fix.detectChanges(); + + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, false); + TreeGridFunctions.clickRowSelectionCheckbox(fix, 6); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, 6, true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + // undo transaction + treeGrid.transactions.undo(); + tick(); + fix.detectChanges(); + + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, false); + expect(treeGrid.selectedRows.includes(13)).toEqual(false); + })); + + it('Should be able to select deleted rows through API - Hierarchical DS', () => { + treeGrid.deleteRowById(663); + fix.detectChanges(); + expect(treeGrid.selectedRows).toEqual([]); + treeGrid.selectRows([663]); + fix.detectChanges(); + expect(treeGrid.selectedRows).toEqual([663]); + /** Select row with deleted parent */ + treeGrid.deleteRowById(147); + fix.detectChanges(); + // 147 -> 475 + treeGrid.selectRows([475]); + fix.detectChanges(); + expect(treeGrid.selectedRows).toEqual([663, 475]); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, false); + }); + + it('Should not be able to select deleted rows through API with selectAllRows - Hierarchical DS', () => { + treeGrid.deleteRowById(663); + treeGrid.deleteRowById(147); + fix.detectChanges(); + expect(treeGrid.selectedRows).toEqual([]); + + treeGrid.selectAllRows(); + fix.detectChanges(); + + expect(treeGrid.selectedRows.includes(663)).toBe(false); + expect(treeGrid.selectedRows.includes(147)).toBe(false); + expect(treeGrid.selectedRows.includes(475)).toBe(false); + expect(treeGrid.selectedRows.includes(19)).toBe(true); + expect(treeGrid.selectedRows.includes(847)).toBe(true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, true); + }); + }); + + describe('Row Selection with transactions - flat DS', () => { + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridRowEditingTransactionComponent); + + treeGrid = fix.componentInstance.treeGrid; + treeGrid.rowSelection = GridSelectionMode.multiple; + fix.detectChanges(); + }); + + it('Should select deleted rows through API', () => { + treeGrid.deleteRowById(6); + fix.detectChanges(); + expect(treeGrid.selectedRows).toEqual([]); + treeGrid.selectRows([6]); + fix.detectChanges(); + expect(treeGrid.selectedRows).toEqual([6]); + /** Select row with deleted parent */ + treeGrid.deleteRowById(10); + fix.detectChanges(); + // 10 -> 9 + treeGrid.selectRows([9]); + fix.detectChanges(); + expect(treeGrid.selectedRows).toEqual([6, 9]); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, false); + }); + + it('Should not be able to select deleted rows through API with selectAllRows', () => { + treeGrid.deleteRowById(6); + treeGrid.deleteRowById(10); + fix.detectChanges(); + expect(treeGrid.selectedRows).toEqual([]); + + treeGrid.selectAllRows(); + fix.detectChanges(); + + expect(treeGrid.selectedRows.includes(6)).toBe(false); + expect(treeGrid.selectedRows.includes(10)).toBe(false); + expect(treeGrid.selectedRows.includes(9)).toBe(false); + expect(treeGrid.selectedRows.includes(1)).toBe(true); + expect(treeGrid.selectedRows.includes(2)).toBe(true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, true); + }); + }); + + describe('Cell Selection', () => { + + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridCellSelectionComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + }); + + it('should return the correct type of cell when clicking on a cells', () => { + const rows = TreeGridFunctions.getAllRows(fix); + const normalCells = TreeGridFunctions.getNormalCells(rows[0]); + UIInteractions.simulateClickAndSelectEvent(normalCells[0]); + fix.detectChanges(); + + expect(treeGrid.selectedCells.length).toBe(1); + expect(treeGrid.selectedCells[0] instanceof IgxGridCell).toBe(true); + + let treeGridCell = TreeGridFunctions.getTreeCell(rows[0]); + UIInteractions.simulateClickAndSelectEvent(treeGridCell); + fix.detectChanges(); + + expect(treeGrid.selectedCells.length).toBe(1); + expect(treeGrid.selectedCells[0] instanceof IgxGridCell).toBe(true); + + // perform 2 clicks and check selection again + treeGridCell = TreeGridFunctions.getTreeCell(rows[0]); + UIInteractions.simulateClickAndSelectEvent(treeGridCell); + treeGridCell = TreeGridFunctions.getTreeCell(rows[0]); + UIInteractions.simulateClickAndSelectEvent(treeGridCell); + fix.detectChanges(); + + expect(treeGrid.selectedCells.length).toBe(1); + expect(treeGrid.selectedCells[0] instanceof IgxGridCell).toBe(true); + }); + + it('should return the correct type of cell when clicking on child cells', () => { + const rows = TreeGridFunctions.getAllRows(fix); + + // level 1 + let treeGridCell = TreeGridFunctions.getTreeCell(rows[0]); + UIInteractions.simulateClickAndSelectEvent(treeGridCell); + fix.detectChanges(); + expect(treeGrid.selectedCells.length).toBe(1); + expect(treeGrid.selectedCells[0] instanceof IgxGridCell).toBe(true); + expect(treeGrid.selectedCells[0].value).toBe(147); + + // level 2 + treeGridCell = TreeGridFunctions.getTreeCell(rows[1]); + UIInteractions.simulateClickAndSelectEvent(treeGridCell); + fix.detectChanges(); + expect(treeGrid.selectedCells.length).toBe(1); + expect(treeGrid.selectedCells[0] instanceof IgxGridCell).toBe(true); + expect(treeGrid.selectedCells[0].value).toBe(475); + + // level 3 + treeGridCell = TreeGridFunctions.getTreeCell(rows[2]); + UIInteractions.simulateClickAndSelectEvent(treeGridCell); + fix.detectChanges(); + expect(treeGrid.selectedCells.length).toBe(1); + expect(treeGrid.selectedCells[0] instanceof IgxGridCell).toBe(true); + expect(treeGrid.selectedCells[0].value).toBe(957); + }); + + it('should not persist selection after paging', () => { + let rows = TreeGridFunctions.getAllRows(fix); + let treeGridCell = TreeGridFunctions.getTreeCell(rows[0]); + UIInteractions.simulateClickAndSelectEvent(treeGridCell); + fix.detectChanges(); + + expect(treeGrid.selectedCells.length).toBe(1); + expect(treeGrid.selectedCells[0] instanceof IgxGridCell).toBe(true); + expect(TreeGridFunctions.verifyGridCellHasSelectedClass(treeGridCell)).toBe(true); + + // Clicking on the pager buttons triggers a blur event. + + GridFunctions.navigateToNextPage(treeGrid.nativeElement); + treeGridCell.nativeElement.dispatchEvent(new Event('blur')); + fix.detectChanges(); + GridFunctions.navigateToFirstPage(treeGrid.nativeElement); + fix.detectChanges(); + + expect(treeGrid.selectedCells.length).toBe(0); + + rows = TreeGridFunctions.getAllRows(fix); + treeGridCell = TreeGridFunctions.getTreeCell(rows[0]); + UIInteractions.simulateClickAndSelectEvent(treeGridCell); + fix.detectChanges(); + + expect(treeGrid.selectedCells.length).toBe(1); + expect(treeGrid.selectedCells[0] instanceof IgxGridCell).toBe(true); + expect(TreeGridFunctions.verifyGridCellHasSelectedClass(treeGridCell)).toBe(true); + + GridFunctions.navigateToLastPage(treeGrid.nativeElement); + treeGridCell.nativeElement.dispatchEvent(new Event('blur')); + fix.detectChanges(); + GridFunctions.navigateToFirstPage(treeGrid.nativeElement); + fix.detectChanges(); + + expect(treeGrid.selectedCells.length).toBe(0); + }); + + it('should persist selection after filtering', fakeAsync(() => { + const rows = TreeGridFunctions.getAllRows(fix); + const treeGridCell = TreeGridFunctions.getTreeCell(rows[0]); + UIInteractions.simulateClickAndSelectEvent(treeGridCell); + fix.detectChanges(); + + treeGrid.filter('ID', '14', IgxStringFilteringOperand.instance().condition('startsWith'), true); + fix.detectChanges(); + + expect(treeGrid.selectedCells.length).toBe(1); + expect(treeGrid.selectedCells[0] instanceof IgxGridCell).toBe(true); + expect(TreeGridFunctions.verifyGridCellHasSelectedClass(treeGridCell)).toBe(true); + expect(treeGrid.selectedCells[0].value).toBe(147); + + // set new filtering + treeGrid.clearFilter('ProductName'); + treeGrid.filter('ID', '8', IgxStringFilteringOperand.instance().condition('startsWith'), true); + fix.detectChanges(); + + expect(treeGrid.selectedCells.length).toBe(1); + expect(treeGrid.selectedCells[0] instanceof IgxGridCell).toBe(true); + expect(TreeGridFunctions.verifyGridCellHasSelectedClass(treeGridCell)).toBe(true); + expect(treeGrid.selectedCells[0].value).toBe(847); + })); + + it('should persist selection after scrolling', async () => { + const rows = TreeGridFunctions.getAllRows(fix); + const treeGridCell = TreeGridFunctions.getTreeCell(rows[0]); + UIInteractions.simulateClickAndSelectEvent(treeGridCell); + fix.detectChanges(); + + // scroll down 150 pixels + treeGrid.verticalScrollContainer.getScroll().scrollTop = 150; + treeGrid.headerContainer.getScroll().dispatchEvent(new Event('scroll')); + await wait(100); + fix.detectChanges(); + + // then scroll back to top + treeGrid.verticalScrollContainer.getScroll().scrollTop = 0; + treeGrid.headerContainer.getScroll().dispatchEvent(new Event('scroll')); + await wait(100); + fix.detectChanges(); + + expect(treeGrid.selectedCells.length).toBe(1); + expect(treeGrid.selectedCells[0] instanceof IgxGridCell).toBe(true); + expect(treeGrid.selectedCells[0].value).toBe(147); + }); + + it('should persist selection after sorting', () => { + const rows = TreeGridFunctions.getAllRows(fix); + const treeGridCell = TreeGridFunctions.getTreeCell(rows[0]); + UIInteractions.simulateClickAndSelectEvent(treeGridCell); + fix.detectChanges(); + + expect(treeGrid.selectedCells.length).toBe(1); + expect(treeGrid.selectedCells[0] instanceof IgxGridCell).toBe(true); + expect(treeGrid.selectedCells[0].value).toBe(147); + + treeGrid.sort({ fieldName: 'ID', dir: SortingDirection.Desc, ignoreCase: false }); + fix.detectChanges(); + + expect(treeGrid.selectedCells.length).toBe(1); + expect(treeGrid.selectedCells[0] instanceof IgxGridCell).toBe(true); + expect(treeGrid.selectedCells[0].value).toBe(847); + }); + + it('should persist selection after row delete', () => { + const rows = TreeGridFunctions.getAllRows(fix); + const treeGridCell = TreeGridFunctions.getTreeCell(rows[0]); + UIInteractions.simulateClickAndSelectEvent(treeGridCell); + fix.detectChanges(); + + expect(treeGrid.selectedCells.length).toBe(1); + expect(treeGrid.selectedCells[0] instanceof IgxGridCell).toBe(true); + expect(treeGrid.selectedCells[0].value).toBe(147); + + treeGrid.deleteRow(847); + fix.detectChanges(); + + expect(treeGrid.selectedCells.length).toBe(1); + expect(treeGrid.selectedCells[0] instanceof IgxGridCell).toBe(true); + expect(treeGrid.selectedCells[0].value).toBe(147); + + treeGrid.deleteRow(147); + fix.detectChanges(); + + expect(treeGrid.selectedCells.length).toBe(1); + expect(treeGrid.selectedCells[0] instanceof IgxGridCell).toBe(true); + expect(treeGrid.selectedCells[0].value).toBe(19); + }); + + it('Should not trigger range selection when CellTemplate is used and the user clicks on element inside it', () => { + const component = fix.componentInstance; + + expect(component.customCell).toBeDefined(); + + const column = treeGrid.getColumnByName('ID'); + column.bodyTemplate = component.customCell; + fix.detectChanges(); + + const selectionChangeSpy = spyOn(treeGrid.rangeSelected, 'emit').and.callThrough(); + const cell = treeGrid.gridAPI.get_cell_by_index(0, 'ID'); + const cellElement = cell.nativeElement; + const span = cellElement.querySelector('span'); + + expect(span).not.toBeNull(); + + UIInteractions.simulateClickAndSelectEvent(span); + fix.detectChanges(); + expect(selectionChangeSpy).not.toHaveBeenCalled(); + }); + }); + + describe('Cell/Row Selection With Row Editing', () => { + + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridSelectionRowEditingComponent); + + treeGrid = fix.componentInstance.treeGrid; + fix.detectChanges(); + }); + + it('should display the banner correctly on row selection', fakeAsync(() => { + const targetCell = treeGrid.getCellByColumn(1, 'Name'); + treeGrid.rowSelection = GridSelectionMode.multiple; + treeGrid.rowEditable = true; + + // select the second row + treeGrid.selectRows([targetCell.id.rowID], true); + tick(16); + fix.detectChanges(); + + // check if any rows were selected + expect(treeGrid.selectedRows.length).toBeGreaterThan(0); + + // enter edit mode + targetCell.editMode = true; + tick(16); + fix.detectChanges(); + + // the banner should appear + const banner = document.getElementsByClassName(ROW_EDITING_BANNER_OVERLAY_CLASS); + expect(banner).toBeTruthy(); + expect(banner[0]).toBeTruthy(); + })); + + it('should display the banner correctly on cell selection', fakeAsync(() => { + treeGrid.rowEditable = true; + + const allRows = TreeGridFunctions.getAllRows(fix); + const treeGridCells = TreeGridFunctions.getNormalCells(allRows[0]); + + // select a cell + const targetCell = treeGridCells[0]; + UIInteractions.simulateClickAndSelectEvent(targetCell); + fix.detectChanges(); + + // there should be at least one selected cell + expect(treeGrid.selectedCells.length).toBeGreaterThan(0); + + // enter edit mode + targetCell.triggerEventHandler('dblclick', new Event('dblclick')); + tick(16); + fix.detectChanges(); + + // the banner should appear + const banner = document.getElementsByClassName(ROW_EDITING_BANNER_OVERLAY_CLASS); + expect(banner).toBeTruthy(); + expect(banner[0]).toBeTruthy(); + })); + }); + + describe('Cascading Row Selection - Child collection data', () => { + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridCascadingSelectionComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + actionStrip = fix.componentInstance.actionStrip; + }); + + it('Should select/deselect all leaf nodes and set the correct state to their checkboxes on parent rows checkbox click', () => { + TreeGridFunctions.clickRowSelectionCheckbox(fix, 0); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(7); + TreeGridFunctions.verifyDataRowsSelection(fix, [0, 1, 2, 3, 4, 5, 6], true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + // Deselect rows + TreeGridFunctions.clickRowSelectionCheckbox(fix, 0); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(0); + TreeGridFunctions.verifyDataRowsSelection(fix, [0, 1, 2, 3, 4, 5, 6], false); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, false); + }); + + it('Should be able to select/deselect row when primaryKey is not set', () => { + treeGrid.primaryKey = undefined; + fix.detectChanges(); + + expect(treeGrid.primaryKey).not.toBeDefined(); + TreeGridFunctions.clickRowSelectionCheckbox(fix, 0); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(7); + TreeGridFunctions.verifyDataRowsSelection(fix, [0, 1, 2, 3, 4, 5, 6], true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + // Deselect rows + TreeGridFunctions.clickRowSelectionCheckbox(fix, 0); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(0); + TreeGridFunctions.verifyDataRowsSelection(fix, [0, 1, 2, 3, 4, 5, 6], false); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, false); + + treeGrid.selectRows([treeGrid.getRowByIndex(1).data, treeGrid.getRowByIndex(2).data, treeGrid.getRowByIndex(3).data], true); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(7); + TreeGridFunctions.verifyDataRowsSelection(fix, [0, 1, 2, 3, 4, 5, 6], true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + }); + + it('Should select/deselect parent row by selecting/deselecting all its children', () => { + treeGrid.selectRows([475, 957, 711, 998, 299], true); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(7); + TreeGridFunctions.verifyDataRowsSelection(fix, [0, 1, 2, 3, 4, 5, 6], true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + // Deselect rows + treeGrid.deselectRows([475, 957, 711, 998, 299]); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(0); + TreeGridFunctions.verifyDataRowsSelection(fix, [0, 1, 2, 3, 4, 5, 6], false); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, false); + }); + + it('Should select/deselect parent row by selecting/deselecting the last deselected/selected child', () => { + treeGrid.selectRows([475, 957, 711, 998], true); + fix.detectChanges(); + + TreeGridFunctions.clickRowSelectionCheckbox(fix, 6); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(7); + TreeGridFunctions.verifyDataRowsSelection(fix, [0, 1, 2, 3, 4, 5, 6], true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + // Deselect rows + treeGrid.deselectRows([475, 957, 711, 998]); + fix.detectChanges(); + + TreeGridFunctions.clickRowSelectionCheckbox(fix, 6); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(0); + TreeGridFunctions.verifyDataRowsSelection(fix, [0, 1, 2, 3, 4, 5, 6], false); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, false); + }); + + it(`Should set parent row checkbox to indeterminate by selecting/deselecting + a child row when all child rows are deselected/selected`, () => { + TreeGridFunctions.clickRowSelectionCheckbox(fix, 6); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(1); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + // Deselect one row + treeGrid.selectRows([475, 957, 711, 998, 299], true); + fix.detectChanges(); + + TreeGridFunctions.clickRowSelectionCheckbox(fix, 6); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(4); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + }); + + it('Should select all children of record on Shift + click even if they are not in the selected range. ', () => { + const firstRow = treeGrid.gridAPI.get_row_by_index(1); + const secondRow = treeGrid.gridAPI.get_row_by_index(4); + const mockEvent = new MouseEvent('click', { shiftKey: true }); + + UIInteractions.simulateClickEvent(firstRow.nativeElement); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toEqual(1); + TreeGridFunctions.verifyDataRowsSelection(fix, [1], true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + // Click on other row holding Shift key + secondRow.nativeElement.dispatchEvent(mockEvent); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(7); + TreeGridFunctions.verifyDataRowsSelection(fix, [0, 1, 2, 3, 4, 5, 6], true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + }); + + it('Should select only the newly clicked parent row and its children and deselect the previous selection.', () => { + treeGrid.selectRows([19, 847], true); + fix.detectChanges(); + expect(getVisibleSelectedRows(fix).length).toBe(3); + + const firstRow = treeGrid.gridAPI.get_row_by_index(0); + UIInteractions.simulateClickEvent(firstRow.nativeElement); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(7); + TreeGridFunctions.verifyDataRowsSelection(fix, [0, 1, 2, 3, 4, 5, 6], true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + }); + + it('Should add a row and its children to the selected rows collection using Ctrl + click.', () => { + treeGrid.selectRows([847], true); + fix.detectChanges(); + expect(getVisibleSelectedRows(fix).length).toBe(2); + + // select a child of the first parent and all of its children + const firstRow = treeGrid.gridAPI.get_row_by_index(3); + UIInteractions.simulateClickEvent(firstRow.nativeElement, false, true); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(6); + TreeGridFunctions.verifyDataRowsSelection(fix, [3, 4, 5, 6, 8, 9], true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + // select the first parent and all of its children + const secondRow = treeGrid.gridAPI.get_row_by_index(0); + UIInteractions.simulateClickEvent(secondRow.nativeElement, false, true); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(9); + TreeGridFunctions.verifyDataRowsSelection(fix, [0, 1, 2, 3, 4, 5, 6, 8, 9], true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + }); + + it('Should deselect a selected row and its children using Ctrl + click.', () => { + treeGrid.selectRows([847], true); + fix.detectChanges(); + expect(getVisibleSelectedRows(fix).length).toBe(2); + + // select a child of the first parent and all of its children + const firstRow = treeGrid.gridAPI.get_row_by_index(3); + UIInteractions.simulateClickEvent(firstRow.nativeElement, false, true); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(6); + + const firstChildRow = treeGrid.gridAPI.get_row_by_index(4); + + UIInteractions.simulateClickEvent(firstChildRow.nativeElement, false, true); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(4); + + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, null); + + const secondRow = treeGrid.gridAPI.get_row_by_index(8); + UIInteractions.simulateClickEvent(secondRow.nativeElement, false, true); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(2); + }); + + it('After adding a new child row to a selected parent its checkbox state SHOULD be indeterminate.', async () => { + treeGrid.selectRows([847], true); + fix.detectChanges(); + expect(getVisibleSelectedRows(fix).length).toBe(2); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 8, true, true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + const row = treeGrid.gridAPI.get_row_by_index(8); + actionStrip.show(row); + fix.detectChanges(); + + // add new child through the UI + const editActions = fix.debugElement.queryAll(By.css(`igx-grid-action-button`)); + const addChildBtn = editActions[2].componentInstance; + addChildBtn.actionClick.emit(); + fix.detectChanges(); + endTransition(); + + const addRow = treeGrid.gridAPI.get_row_by_index(9); + expect(addRow.addRowUI).toBeTrue(); + + treeGrid.gridAPI.crudService.endEdit(true); + await wait(100); + fix.detectChanges(); + const addedRow = treeGrid.gridAPI.get_row_by_index(10); + expect(addedRow.data.Name).toBe(undefined); + + TreeGridFunctions.verifyDataRowsSelection(fix, [9], true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 8, false, null); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + }); + + it('After adding child to a selected parent with no children, parent checkbox state SHOULD NOT be selected.', async () => { + treeGrid.selectRows([957], true); + fix.detectChanges(); + expect(getVisibleSelectedRows(fix).length).toBe(1); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + treeGrid.addRow({ + ID: -1, + Name: undefined, + HireDate: undefined, + Age: undefined + }, 957); + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + const addedRow = treeGrid.gridAPI.get_row_by_index(3); + expect(addedRow.data.Name).toBe(undefined); + + expect(getVisibleSelectedRows(fix).length).toBe(0); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, false); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 2, false, false); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, false); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, false); + }); + + it('If parent has one non-selected child and we delete it, the parent checkbox state SHOULD be selected.', async () => { + treeGrid.selectRows([711, 299], true); + fix.detectChanges(); + expect(getVisibleSelectedRows(fix).length).toBe(2); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + expect(treeGrid.dataRowList.length).toBe(10); + + const childRow = treeGrid.gridAPI.get_row_by_index(5); + actionStrip.show(childRow); + fix.detectChanges(); + + // delete the child through the UI + const editActions = fix.debugElement.queryAll(By.css(`igx-grid-action-button`)); + const deleteBtn = editActions[2].componentInstance; + deleteBtn.actionClick.emit(); + fix.detectChanges(); + + await wait(100); + fix.detectChanges(); + + expect(treeGrid.dataRowList.length).toBe(9); + expect(getVisibleSelectedRows(fix).length).toBe(3); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + }); + + it('If we delete the only selected child of a parent row, the parent checkbox state SHOULD be deselected', async () => { + treeGrid.selectRows([711], true); + fix.detectChanges(); + expect(getVisibleSelectedRows(fix).length).toBe(1); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + expect(treeGrid.dataRowList.length).toBe(10); + + // delete the child through the API + const childRow = treeGrid.gridAPI.get_row_by_index(4); + childRow.delete(); + fix.detectChanges(); + + await wait(100); + fix.detectChanges(); + + expect(treeGrid.dataRowList.length).toBe(9); + expect(getVisibleSelectedRows(fix).length).toBe(0); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, false); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, false); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, false); + }); + + it(`If there is only one selected leaf row for a particular parent and we filter it out parent's checkbox state -> non-selected. + All non-direct parents’ checkbox states should be set correctly as well`, async () => { + treeGrid.selectRows([711], true); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(1); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, null); + + treeGrid.filter('ID', 711, IgxNumberFilteringOperand.instance().condition('doesNotEqual')); + fix.detectChanges(); + + await wait(100); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(0); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, false); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, false); + }); + + it(`If there is only one non-selected row for a particular parent and we filter it out parent's checkbox state -> selected. + All non-direct parents’ checkbox states should be set correctly as well`, async () => { + treeGrid.selectRows([711, 998], true); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(2); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 4, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 5, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 6, false, false); + + treeGrid.filter('ID', 299, IgxNumberFilteringOperand.instance().condition('doesNotEqual')); + fix.detectChanges(); + + await wait(100); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(3); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 4, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 5, true, true); + }); + + it(`No rows are selected. Filter out all children for certain parent. Select this parent. It should be the only one within + the selectedRows collection. Remove filtering. The selectedRows collection should be empty. + All non-direct parents’ checkbox states should be set correctly as well`, async () => { + + const expressionTree = new FilteringExpressionsTree(FilteringLogic.And, 'ID'); + expressionTree.filteringOperands = [ + { + condition: IgxNumberFilteringOperand.instance().condition('doesNotEqual'), + conditionName: 'doesNotEqual', + fieldName: 'ID', + searchVal: 711 + }, + { + condition: IgxNumberFilteringOperand.instance().condition('doesNotEqual'), + conditionName: 'doesNotEqual', + fieldName: 'ID', + searchVal: 998 + }, + { + condition: IgxNumberFilteringOperand.instance().condition('doesNotEqual'), + conditionName: 'doesNotEqual', + fieldName: 'ID', + searchVal: 299 + } + ]; + treeGrid.filter('ID', null, expressionTree); + + await wait(100); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(0); + + treeGrid.selectRows([317], true); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(1); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + + treeGrid.clearFilter(); + + await wait(100); + fix.detectChanges(); + expect(getVisibleSelectedRows(fix).length).toBe(0); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, false); + }); + + it(`Filter out all selected children for a certain parent and explicitly deselect it. + Remove filtering. Parent row should be selected again. All non-direct parents’ + checkbox states should be set correctly as well`, async () => { + + treeGrid.selectRows([317], true); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(4); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, true, true); + + const expressionTree = new FilteringExpressionsTree(FilteringLogic.And, 'ID'); + expressionTree.filteringOperands = [ + { + condition: IgxNumberFilteringOperand.instance().condition('doesNotEqual'), + conditionName: 'doesNotEqual', + fieldName: 'ID', + searchVal: 711 + }, + { + condition: IgxNumberFilteringOperand.instance().condition('doesNotEqual'), + conditionName: 'doesNotEqual', + fieldName: 'ID', + searchVal: 998 + }, + { + condition: IgxNumberFilteringOperand.instance().condition('doesNotEqual'), + conditionName: 'doesNotEqual', + fieldName: 'ID', + searchVal: 299 + } + ]; + treeGrid.filter('ID', null, expressionTree); + + await wait(100); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(1); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, false); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, true, true); + + treeGrid.deselectRows([317]); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(0); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, false); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, false); + + treeGrid.clearFilter(); + + await wait(100); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(4); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, true, true); + }); + + it(`Parent with two or more children. Select parent. Filter out one of the children. Deselect all the others -> children + and parent checkbox state becomes deselected. Filter the other child back in. This child should remain selected. + Parent checkbox state should be indeterminate.`, fakeAsync(() => { + treeGrid.selectRows([147], true); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(7); + + const expressionTree = new FilteringExpressionsTree(FilteringLogic.And, 'ID'); + expressionTree.filteringOperands = [ + { + condition: IgxNumberFilteringOperand.instance().condition('doesNotEqual'), + conditionName: 'doesNotEqual', + fieldName: 'ID', + searchVal: 957 + }, + ]; + treeGrid.filter('ID', null, expressionTree); + + fix.detectChanges(); + tick(100); + + expect(getVisibleSelectedRows(fix).length).toBe(6); + + treeGrid.deselectRows([475, 317]); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(0); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, false); + + treeGrid.clearFilter(); + + tick(1000); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(1); + expect(treeGrid.selectionService.indeterminateRows.size).toBe(1); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 2, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, null, null); + })); + + it(`Parent in indeterminate state. Filter out its children -> parent not selected. Select parent and add new child. + Parent -> not selected. Revert filtering so that previous records are back in the view and parent should become in + indeterminate state because one of it children is selected`, fakeAsync(() => { + + treeGrid.selectRows([998], true); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(1); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, null); + + const expressionTree = new FilteringExpressionsTree(FilteringLogic.And, 'ID'); + expressionTree.filteringOperands = [ + { + condition: IgxNumberFilteringOperand.instance().condition('doesNotEqual'), + conditionName: 'doesNotEqual', + fieldName: 'ID', + searchVal: 711 + }, + { + condition: IgxNumberFilteringOperand.instance().condition('doesNotEqual'), + conditionName: 'doesNotEqual', + fieldName: 'ID', + searchVal: 998 + }, + { + condition: IgxNumberFilteringOperand.instance().condition('doesNotEqual'), + conditionName: 'doesNotEqual', + fieldName: 'ID', + searchVal: 299 + } + ]; + treeGrid.filter('ID', null, expressionTree); + fix.detectChanges(); + + tick(100); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(0); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, false); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, false); + + treeGrid.selectRows([317]); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(1); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, false); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, true, true); + + treeGrid.addRow({ + ID: -1, + Name: undefined, + HireDate: undefined, + Age: undefined + }, 317); + fix.detectChanges(); + + tick(100); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(0); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, false); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, false); + + treeGrid.clearFilter(); + fix.detectChanges(); + + tick(100); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(1); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, null); + })); + + it(`Selected parent. Filter out some of the children and delete otheres. + Parent should be not selected`, fakeAsync(() => { + + treeGrid.selectRows([317], true); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(4); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, true, true); + + const expressionTree = new FilteringExpressionsTree(FilteringLogic.And, 'ID'); + expressionTree.filteringOperands = [ + { + condition: IgxNumberFilteringOperand.instance().condition('doesNotEqual'), + conditionName: 'doesNotEqual', + fieldName: 'ID', + searchVal: 711 + }, + { + condition: IgxNumberFilteringOperand.instance().condition('doesNotEqual'), + conditionName: 'doesNotEqual', + fieldName: 'ID', + searchVal: 998 + } + ]; + treeGrid.filter('ID', null, expressionTree); + fix.detectChanges(); + + tick(100); + fix.detectChanges(); + + treeGrid.deleteRow(299); + fix.detectChanges(); + + tick(100); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(0); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, false); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, false); + })); + + it(`Set nested child row, that has its own children, as initially selected and verify + that both direct and indirect parent's checkboxes are set in the correct state.`, fakeAsync(() => { + + treeGrid.selectedRows = [317]; + fix.detectChanges(); + tick(100); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(4); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 4, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 5, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 6, true, true); + })); + + it(`Setting true to the cancel property of the rowSelectionChanging event should not modify the selected rows collection`, () => { + + treeGrid.rowSelectionChanging.subscribe((e: IRowSelectionEventArgs) => { + e.cancel = true; + }); + + spyOn(treeGrid.rowSelectionChanging, 'emit').and.callThrough(); + + treeGrid.selectionService.selectRowsWithNoEvent([317]); + fix.detectChanges(); + + treeGrid.selectionService.deselectRow(299); + fix.detectChanges(); + + const args: IRowSelectionEventArgs = { + oldSelection: [ treeGrid.getRowData(317), treeGrid.getRowData(711), treeGrid.getRowData(998), treeGrid.getRowData(299)], + newSelection: [treeGrid.getRowData(711), treeGrid.getRowData(998)], + added: [], + removed: [treeGrid.getRowData(317), treeGrid.getRowData(299)], + event: undefined, + cancel: true, + owner: treeGrid + }; + + expect(treeGrid.rowSelectionChanging.emit).toHaveBeenCalledWith(args); + + fix.detectChanges(); + expect(getVisibleSelectedRows(fix).length).toBe(4); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 4, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 5, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 6, true, true); + }); + + it(`selectionService clearRowSelection method should work correctly`, () => { + treeGrid.selectionService.selectRowsWithNoEvent([711]); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(1); + expect(treeGrid.selectionService.indeterminateRows.size).toBe(2); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 1, false, false); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 2, false, false); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 4, true, true); + + treeGrid.selectionService.clearRowSelection(); + treeGrid.cdr.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(0); + expect(treeGrid.selectionService.indeterminateRows.size).toBe(0); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, false); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 1, false, false); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 2, false, false); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, false); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 4, false, false); + }); + + it(`selectionService selectAllRows method should work correctly`, () => { + treeGrid.selectionService.selectRowsWithNoEvent([711]); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(1); + expect(treeGrid.selectionService.indeterminateRows.size).toBe(2); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 1, false, false); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 2, false, false); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 4, true, true); + + treeGrid.selectionService.selectAllRows(); + treeGrid.cdr.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(10); + expect(treeGrid.selectionService.indeterminateRows.size).toBe(0); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 1, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 2, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 4, true, true); + }); + + it('selectRowById event SHOULD be emitted correctly with valid arguments.', () => { + spyOn(treeGrid.rowSelectionChanging, 'emit').and.callThrough(); + treeGrid.selectionService.selectRowsWithNoEvent([317]); + fix.detectChanges(); + + expect(treeGrid.rowSelectionChanging.emit).toHaveBeenCalledTimes(0); + expect(getVisibleSelectedRows(fix).length).toBe(4); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 4, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 5, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 6, true, true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + treeGrid.selectionService.selectRowById(847, true); + + const args: IRowSelectionEventArgs = { + oldSelection: [ treeGrid.getRowData(317), treeGrid.getRowData(711), treeGrid.getRowData(998), treeGrid.getRowData(299)], + newSelection: [treeGrid.getRowData(847), treeGrid.getRowData(663)], + added: [treeGrid.getRowData(847), treeGrid.getRowData(663)], + removed: [ treeGrid.getRowData(317), treeGrid.getRowData(711), treeGrid.getRowData(998), treeGrid.getRowData(299)], + event: undefined, + cancel: false, + owner: treeGrid + }; + + expect(treeGrid.rowSelectionChanging.emit).toHaveBeenCalledWith(args); + + treeGrid.cdr.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(2); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, false); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, false); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 4, false, false); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 5, false, false); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 6, false, false); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 8, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 9, true, true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + }); + + it('After changing the newSelection arguments of onSelectedRowChange, the arguments SHOULD be correct.', () => { + treeGrid.rowSelectionChanging.subscribe((e: IRowSelectionEventArgs) => { + e.newSelection = [treeGrid.getRowData(847), treeGrid.getRowData(663)]; + }); + spyOn(treeGrid.rowSelectionChanging, 'emit').and.callThrough(); + + treeGrid.selectionService.selectRowsWithNoEvent([317], true); + fix.detectChanges(); + + treeGrid.selectionService.selectRowById(19, true); + + const selectionArgs: IRowSelectionEventArgs = { + oldSelection: [ treeGrid.getRowData(317), treeGrid.getRowData(711), treeGrid.getRowData(998), treeGrid.getRowData(299)], + newSelection: [treeGrid.getRowData(847), treeGrid.getRowData(663)], + added: [treeGrid.getRowData(19)], + removed: [treeGrid.getRowData(317), treeGrid.getRowData(711), treeGrid.getRowData(998), treeGrid.getRowData(299)], + event: undefined, + cancel: false, + owner: treeGrid + }; + + expect(treeGrid.rowSelectionChanging.emit).toHaveBeenCalledWith(selectionArgs); + + treeGrid.cdr.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(2); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 8, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 9, true, true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + }); + }); + + describe('Cascading Row Selection - Primary/Foreign key data', () => { + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridPrimaryForeignKeyCascadeSelectionComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + actionStrip = fix.componentInstance.actionStrip; + }); + + it(`Filter out all children for a certain parent, except for one. Select it. + Parent should also become selected. Clear filters. Parent should become in + indeterminate state as there are non-selected children.`, async () => { + treeGrid.filter('ID', 475, IgxNumberFilteringOperand.instance().condition('equals')); + await wait(100); + fix.detectChanges(); + + treeGrid.selectRows([475], true); + await wait(100); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(2); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 1, true, true); + + treeGrid.clearFilter(); + await wait(100); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(1); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 1, true, true); + }); + + it(`If there is only one selected leaf row for a particular parent and we filter it out parent's checkbox state -> non-selected. + All non-direct parents’ checkbox states should be set correctly as well`, async () => { + treeGrid.selectRows([711], true); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(1); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, null); + + treeGrid.filter('ID', 711, IgxNumberFilteringOperand.instance().condition('doesNotEqual')); + fix.detectChanges(); + + await wait(100); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(0); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, false); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, false); + }); + + it(`If there is only one non-selected row for a particular parent and we filter it out parent's checkbox state -> selected. + All non-direct parents’ checkbox states should be set correctly as well`, async () => { + treeGrid.selectRows([711, 998], true); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(2); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 4, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 5, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 6, false, false); + + treeGrid.filter('ID', 299, IgxNumberFilteringOperand.instance().condition('doesNotEqual')); + fix.detectChanges(); + + await wait(200); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(3); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 4, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 5, true, true); + }); + + it('After adding a new child row to a selected parent its checkbox state SHOULD be indeterminate.', async () => { + treeGrid.selectRows([847], true); + fix.detectChanges(); + expect(getVisibleSelectedRows(fix).length).toBe(2); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 8, true, true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + const row = treeGrid.gridAPI.get_row_by_index(8); + actionStrip.show(row); + fix.detectChanges(); + + // add new child through the UI + const editActions = fix.debugElement.queryAll(By.css(`igx-grid-action-button`)); + const addChildBtn = editActions[2].componentInstance; + addChildBtn.actionClick.emit(); + fix.detectChanges(); + endTransition(); + + const addRow = treeGrid.gridAPI.get_row_by_index(9); + expect(addRow.addRowUI).toBeTrue(); + + treeGrid.gridAPI.crudService.endEdit(true); + await wait(100); + fix.detectChanges(); + const addedRow = treeGrid.gridAPI.get_row_by_index(10); + expect(addedRow.data.Name).toBe(undefined); + + TreeGridFunctions.verifyDataRowsSelection(fix, [9], true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 8, false, null); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + }); + + it('If parent and its children are selected and we delete a child, parent SHOULD be still selected.', async () => { + treeGrid.selectRows([147], true); + fix.detectChanges(); + expect(getVisibleSelectedRows(fix).length).toBe(7); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, true, true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + expect(treeGrid.dataRowList.length).toBe(10); + + const childRow = treeGrid.gridAPI.get_row_by_index(5); + actionStrip.show(childRow); + fix.detectChanges(); + + // delete the child through the UI + const editActions = fix.debugElement.queryAll(By.css(`igx-grid-action-button`)); + const deleteBtn = editActions[2].componentInstance; + deleteBtn.actionClick.emit(); + fix.detectChanges(); + + await wait(100); + fix.detectChanges(); + + expect(treeGrid.dataRowList.length).toBe(9); + expect(getVisibleSelectedRows(fix).length).toBe(6); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, true, true); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + }); + + it('If we delete the only selected child of a parent row, the parent checkbox state SHOULD be deselected', async () => { + treeGrid.selectRows([711], true); + fix.detectChanges(); + expect(getVisibleSelectedRows(fix).length).toBe(1); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + expect(treeGrid.dataRowList.length).toBe(10); + + // delete the child through the API + const childRow = treeGrid.gridAPI.get_row_by_index(4); + childRow.delete(); + fix.detectChanges(); + + await wait(100); + fix.detectChanges(); + + expect(treeGrid.dataRowList.length).toBe(9); + expect(getVisibleSelectedRows(fix).length).toBe(0); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, false); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, false); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, false); + }); + + it(`Set nested child row, that has its own children, as initially selected and verify + that both direct and indirect parent's checkboxes are set in the correct state.`, fakeAsync(() => { + treeGrid.selectedRows = [317]; + fix.detectChanges(); + tick(100); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(4); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 4, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 5, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 6, true, true); + })); + }); + + describe('Cascading Row Selection with Transaction', () => { + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridCascadingSelectionTransactionComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + actionStrip = fix.componentInstance.actionStrip; + }); + + it('Add a new leaf row to a selected parent and revert the transaction. The parent SHOULD be selected.', async () => { + const trans = treeGrid.transactions; + + treeGrid.selectRows([317], true); + fix.detectChanges(); + expect(getVisibleSelectedRows(fix).length).toBe(4); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + treeGrid.addRow({ + ID: -1, + Name: undefined, + HireDate: undefined, + Age: undefined + }, 317); + + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(3); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + trans.undo(); + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(4); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + }); + it('Add a new row to a selected parent and commit the transaction.The parent checkbox state SHOULD be indeterminate', async () => { + const trans = treeGrid.transactions; + + treeGrid.selectRows([317], true); + fix.detectChanges(); + expect(getVisibleSelectedRows(fix).length).toBe(4); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + treeGrid.addRow({ + ID: -1, + Name: undefined, + HireDate: undefined, + Age: undefined + }, 317); + + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(3); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + trans.commit(treeGrid.data); + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(3); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + }); + it('Delete one of the children of selected parent. Parent checkbox state SHOULD be selected.', async () => { + const trans = treeGrid.transactions; + treeGrid.selectRows([317], true); + fix.detectChanges(); + expect(getVisibleSelectedRows(fix).length).toBe(4); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + const childRow = treeGrid.gridAPI.get_row_by_index(4); + childRow.delete(); + fix.detectChanges(); + + await wait(100); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(3); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + trans.undo(); + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(2); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + trans.redo(); + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + expect(getVisibleSelectedRows(fix).length).toBe(3); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + }); + it('After delete the only non-selected child, the parent checkbox state SHOULD be selected.', async () => { + treeGrid.selectRows([711, 299], true); + fix.detectChanges(); + expect(getVisibleSelectedRows(fix).length).toBe(2); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + const childRow = treeGrid.gridAPI.get_row_by_index(5); + actionStrip.show(childRow); + fix.detectChanges(); + + // delete the child through the UI + const editActions = fix.debugElement.queryAll(By.css(`igx-grid-action-button`)); + const deleteBtn = editActions[2].componentInstance; + deleteBtn.actionClick.emit(); + fix.detectChanges(); + + await wait(100); + fix.detectChanges(); + expect(getVisibleSelectedRows(fix).length).toBe(3); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, true, true); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + }); + it('Delete the only selected child of a parent row. Parent checkbox state SHOULD NOT be selected.', async () => { + treeGrid.selectRows([998], true); + fix.detectChanges(); + expect(getVisibleSelectedRows(fix).length).toBe(1); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, null); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, null); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, null); + + const row = treeGrid.gridAPI.get_row_by_index(5); + row.delete(); + fix.detectChanges(); + await wait(100); + fix.detectChanges(); + + expect(getVisibleSelectedRows(fix).length).toBe(0); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 3, false, false); + TreeGridFunctions.verifyRowByIndexSelectionAndCheckboxState(fix, 0, false, false); + TreeGridFunctions.verifyHeaderCheckboxSelection(fix, false); + }); + + }); + + describe('Custom row selectors', () => { + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridCustomRowSelectorsComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + }); + + it('Should have the correct properties in the custom row selector template', () => { + const firstRow = treeGrid.gridAPI.get_row_by_index(0); + const firstCheckbox = firstRow.nativeElement.querySelector('.igx-checkbox__composite'); + const context = { index: 0, rowID: 1, key: 1, selected: false }; + const contextUnselect = { index: 0, rowID: 1, key: 1, selected: true }; + spyOn(fix.componentInstance, 'onRowCheckboxClick').and.callThrough(); + (firstCheckbox as HTMLElement).click(); + fix.detectChanges(); + + expect(fix.componentInstance.onRowCheckboxClick).toHaveBeenCalledTimes(1); + expect(fix.componentInstance.onRowCheckboxClick).toHaveBeenCalledWith(fix.componentInstance.rowCheckboxClick, context); + + // Verify correct properties when unselecting a row + (firstCheckbox as HTMLElement).click(); + fix.detectChanges(); + + expect(fix.componentInstance.onRowCheckboxClick).toHaveBeenCalledTimes(2); + expect(fix.componentInstance.onRowCheckboxClick).toHaveBeenCalledWith(fix.componentInstance.rowCheckboxClick, contextUnselect); + }); + + it('Should have the correct properties in the custom row selector header template', () => { + const context = { selectedCount: 0, totalCount: 8 }; + const contextUnselect = { selectedCount: 8, totalCount: 8 }; + const headerCheckbox = treeGrid.theadRow.nativeElement.querySelector('.igx-checkbox__composite') as HTMLElement; + spyOn(fix.componentInstance, 'onHeaderCheckboxClick').and.callThrough(); + headerCheckbox.click(); + fix.detectChanges(); + + expect(fix.componentInstance.onHeaderCheckboxClick).toHaveBeenCalledTimes(1); + expect(fix.componentInstance.onHeaderCheckboxClick).toHaveBeenCalledWith(fix.componentInstance.headerCheckboxClick, context); + + headerCheckbox.click(); + fix.detectChanges(); + + expect(fix.componentInstance.onHeaderCheckboxClick).toHaveBeenCalledTimes(2); + expect(fix.componentInstance.onHeaderCheckboxClick). + toHaveBeenCalledWith(fix.componentInstance.headerCheckboxClick, contextUnselect); + }); + + it('Should have correct indices on all pages', () => { + treeGrid.paginator.nextPage(); + fix.detectChanges(); + + const firstRootRow = treeGrid.gridAPI.get_row_by_index(0); + expect(firstRootRow.nativeElement.querySelector('.rowNumber').textContent).toEqual('5'); + }); + }); +}); + + +const getVisibleSelectedRows = (fix) => TreeGridFunctions.getAllRows(fix).filter( + (row) => row.nativeElement.classList.contains(TREE_ROW_SELECTION_CSS_CLASS)); diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-sorting.spec.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-sorting.spec.ts new file mode 100644 index 00000000000..e2e93d4b71d --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-sorting.spec.ts @@ -0,0 +1,365 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { IgxTreeGridComponent } from './tree-grid.component'; +import { IgxTreeGridSortingComponent } from '../../../test-utils/tree-grid-components.spec'; +import { TreeGridFunctions } from '../../../test-utils/tree-grid-functions.spec'; +import { DefaultSortingStrategy, SortingDirection } from '../../../core/src/data-operations/sorting-strategy'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { GridFunctions } from '../../../test-utils/grid-functions.spec'; + +describe('IgxTreeGrid - Sorting #tGrid', () => { + let fix; + let treeGrid: IgxTreeGridComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, IgxTreeGridSortingComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridSortingComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + }); + + describe('API sorting', () => { + it('should sort descending all treeGrid levels by column name through API', () => { + treeGrid.sort({ fieldName: 'Name', dir: SortingDirection.Desc, ignoreCase: false, + strategy: DefaultSortingStrategy.instance() }); + const nameHeaderCell = GridFunctions.getColumnHeader('Name', fix); + + fix.detectChanges(); + + // Verify first level records are desc sorted + expect(treeGrid.getCellByColumn(0, 'Name').value).toEqual('Yang Wang'); + expect(treeGrid.getCellByColumn(1, 'Name').value).toEqual('John Winchester'); + expect(treeGrid.getCellByColumn(8, 'Name').value).toEqual('Ana Sanders'); + expect(nameHeaderCell.attributes['aria-sort']).toEqual('descending'); + + // Verify second level records are desc sorted + expect(treeGrid.getCellByColumn(2, 'Name').value).toEqual('Thomas Hardy'); + expect(treeGrid.getCellByColumn(3, 'Name').value).toEqual('Monica Reyes'); + expect(treeGrid.getCellByColumn(7, 'Name').value).toEqual('Michael Langdon'); + + // Verify third level records are desc sorted + expect(treeGrid.getCellByColumn(4, 'Name').value).toEqual('Sven Ottlieb'); + expect(treeGrid.getCellByColumn(5, 'Name').value).toEqual('Roland Mendel'); + expect(treeGrid.getCellByColumn(6, 'Name').value).toEqual('Peter Lewis'); + }); + + it('should sort ascending all treeGrid levels by column name through API', () => { + treeGrid.sort({ fieldName: 'Age', dir: SortingDirection.Asc, ignoreCase: false }); + fix.detectChanges(); + + // Verify first level records are asc sorted + expect(treeGrid.getCellByColumn(0, 'Age').value).toEqual(42); + expect(treeGrid.getCellByColumn(2, 'Age').value).toEqual(55); + expect(treeGrid.getCellByColumn(9, 'Age').value).toEqual(61); + + // Verify second level records are asc sorted + expect(treeGrid.getCellByColumn(3, 'Age').value).toEqual(29); + expect(treeGrid.getCellByColumn(4, 'Age').value).toEqual(30); + expect(treeGrid.getCellByColumn(5, 'Age').value).toEqual(31); + + // Verify third level records are asc sorted + expect(treeGrid.getCellByColumn(6, 'Age').value).toEqual(25); + expect(treeGrid.getCellByColumn(7, 'Age').value).toEqual(35); + expect(treeGrid.getCellByColumn(8, 'Age').value).toEqual(44); + }); + + it('should not sort treeGrid when trying to sort by invalid column through API', () => { + treeGrid.sort({ fieldName: 'TEST', dir: SortingDirection.Desc, ignoreCase: false, + strategy: DefaultSortingStrategy.instance() }); + fix.detectChanges(); + + // Verify first level records with default order + expect(treeGrid.getCellByColumn(0, 'Name').value).toEqual('John Winchester'); + expect(treeGrid.getCellByColumn(7, 'Name').value).toEqual('Yang Wang'); + expect(treeGrid.getCellByColumn(8, 'Name').value).toEqual('Ana Sanders'); + + // Verify second level records with default order + expect(treeGrid.getCellByColumn(1, 'Name').value).toEqual('Michael Langdon'); + expect(treeGrid.getCellByColumn(2, 'Name').value).toEqual('Thomas Hardy'); + expect(treeGrid.getCellByColumn(3, 'Name').value).toEqual('Monica Reyes'); + + // Verify third level records with default order + expect(treeGrid.getCellByColumn(4, 'Name').value).toEqual('Roland Mendel'); + expect(treeGrid.getCellByColumn(5, 'Name').value).toEqual('Sven Ottlieb'); + expect(treeGrid.getCellByColumn(6, 'Name').value).toEqual('Peter Lewis'); + }); + + it('should clear sorting of treeGrid through API', () => { + // Verify first record of all 3 levels (default layout) + expect(treeGrid.getCellByColumn(0, 'Age').value).toEqual(55); + expect(treeGrid.getCellByColumn(1, 'Age').value).toEqual(30); + expect(treeGrid.getCellByColumn(4, 'Age').value).toEqual(35); + + treeGrid.sort({ fieldName: 'Age', dir: SortingDirection.Asc, ignoreCase: false }); + fix.detectChanges(); + + // Verify first record of all 3 levels (sorted layout) + expect(treeGrid.getCellByColumn(0, 'Age').value).toEqual(42); + expect(treeGrid.getCellByColumn(3, 'Age').value).toEqual(29); + expect(treeGrid.getCellByColumn(6, 'Age').value).toEqual(25); + + treeGrid.clearSort(); + fix.detectChanges(); + + // Verify first record of all 3 levels (default layout) + expect(treeGrid.getCellByColumn(0, 'Age').value).toEqual(55); + expect(treeGrid.getCellByColumn(1, 'Age').value).toEqual(30); + expect(treeGrid.getCellByColumn(4, 'Age').value).toEqual(35); + }); + + it('should sort treeGrid by multiple expressions through API', () => { + pending('figure out how was this passing before'); + // Test prerequisites (need to have multiple records with the same name on every level) + treeGrid.data[0].Name = 'Ana Sanders'; + treeGrid.data[0].Employees[1].Name = 'Michael Langdon'; + treeGrid.data[0].Employees[2].Employees[0].Name = 'Peter Lewis'; + fix.detectChanges(); + + const exprs = [ + { fieldName: 'Name', dir: SortingDirection.Asc, ignoreCase: true }, + { fieldName: 'Age', dir: SortingDirection.Desc, ignoreCase: true } + ]; + + treeGrid.sort(exprs); + fix.detectChanges(); + + expect(treeGrid.sortingExpressions.length).toBe(2); + + // Verify first level multiple expressions sorting + expect(treeGrid.getCellByColumn(0, 'Name').value).toEqual('Ana Sanders'); + expect(treeGrid.getCellByColumn(0, 'Age').value).toEqual(55); + + expect(treeGrid.getCellByColumn(7, 'Name').value).toEqual('Ana Sanders'); + expect(treeGrid.getCellByColumn(7, 'Age').value).toEqual(42); + + expect(treeGrid.getCellByColumn(9, 'Name').value).toEqual('Yang Wang'); + expect(treeGrid.getCellByColumn(9, 'Age').value).toEqual(61); + + // Verify second level multiple expressions sorting + expect(treeGrid.getCellByColumn(1, 'Name').value).toEqual('Michael Langdon'); + expect(treeGrid.getCellByColumn(1, 'Age').value).toEqual(30); + + expect(treeGrid.getCellByColumn(2, 'Name').value).toEqual('Michael Langdon'); + expect(treeGrid.getCellByColumn(2, 'Age').value).toEqual(29); + + expect(treeGrid.getCellByColumn(3, 'Name').value).toEqual('Monica Reyes'); + expect(treeGrid.getCellByColumn(3, 'Age').value).toEqual(31); + + // Verify third level multiple expressions sorting + expect(treeGrid.getCellByColumn(4, 'Name').value).toEqual('Peter Lewis'); + expect(treeGrid.getCellByColumn(4, 'Age').value).toEqual(35); + + expect(treeGrid.getCellByColumn(5, 'Name').value).toEqual('Peter Lewis'); + expect(treeGrid.getCellByColumn(5, 'Age').value).toEqual(25); + + expect(treeGrid.getCellByColumn(6, 'Name').value).toEqual('Sven Ottlieb'); + expect(treeGrid.getCellByColumn(6, 'Age').value).toEqual(44); + }); + + it('should clear sorting of treeGrid for one column only through API', () => { + // Test prerequisites (need to have multiple records with the same name on every level) + treeGrid.getCellByColumn(0, 'Name').value = 'Ana Sanders'; + treeGrid.getCellByColumn(2, 'Name').value = 'Michael Langdon'; + treeGrid.getCellByColumn(4, 'Name').value = 'Peter Lewis'; + fix.detectChanges(); + + const exprs = [ + { fieldName: 'Name', dir: SortingDirection.Asc, ignoreCase: true }, + { fieldName: 'Age', dir: SortingDirection.Desc, ignoreCase: true } + ]; + + treeGrid.sort(exprs); + fix.detectChanges(); + + treeGrid.clearSort('Name'); + fix.detectChanges(); + + expect(treeGrid.sortingExpressions.length).toBe(1); + + // Verify first level single expression sorting + expect(treeGrid.getCellByColumn(0, 'Age').value).toEqual(61); + expect(treeGrid.getCellByColumn(1, 'Age').value).toEqual(55); + expect(treeGrid.getCellByColumn(8, 'Age').value).toEqual(42); + + // Verify second level single expression sorting + expect(treeGrid.getCellByColumn(2, 'Age').value).toEqual(31); + expect(treeGrid.getCellByColumn(6, 'Age').value).toEqual(30); + expect(treeGrid.getCellByColumn(7, 'Age').value).toEqual(29); + + // Verify third level single expression sorting + expect(treeGrid.getCellByColumn(3, 'Age').value).toEqual(44); + expect(treeGrid.getCellByColumn(4, 'Age').value).toEqual(35); + expect(treeGrid.getCellByColumn(5, 'Age').value).toEqual(25); + }); + }); + + describe('UI sorting', () => { + it('should sort descending all treeGrid levels by column name through UI', () => { + const header = TreeGridFunctions.getHeaderCell(fix, 'Name'); + GridFunctions.clickHeaderSortIcon(header); + GridFunctions.clickHeaderSortIcon(header); + fix.detectChanges(); + + // Verify first level records are desc sorted + expect(treeGrid.getCellByColumn(0, 'Name').value).toEqual('Yang Wang'); + expect(treeGrid.getCellByColumn(1, 'Name').value).toEqual('John Winchester'); + expect(treeGrid.getCellByColumn(8, 'Name').value).toEqual('Ana Sanders'); + expect(header.attributes['aria-sort']).toEqual('descending'); + + // Verify second level records are desc sorted + expect(treeGrid.getCellByColumn(2, 'Name').value).toEqual('Thomas Hardy'); + expect(treeGrid.getCellByColumn(3, 'Name').value).toEqual('Monica Reyes'); + expect(treeGrid.getCellByColumn(7, 'Name').value).toEqual('Michael Langdon'); + + // Verify third level records are desc sorted + expect(treeGrid.getCellByColumn(4, 'Name').value).toEqual('Sven Ottlieb'); + expect(treeGrid.getCellByColumn(5, 'Name').value).toEqual('Roland Mendel'); + expect(treeGrid.getCellByColumn(6, 'Name').value).toEqual('Peter Lewis'); + }); + + it('should sort ascending all treeGrid levels by column name through UI', () => { + const header = TreeGridFunctions.getHeaderCell(fix, 'Age'); + GridFunctions.clickHeaderSortIcon(header); + fix.detectChanges(); + + // Verify first level records are asc sorted + expect(treeGrid.getCellByColumn(0, 'Age').value).toEqual(42); + expect(treeGrid.getCellByColumn(2, 'Age').value).toEqual(55); + expect(treeGrid.getCellByColumn(9, 'Age').value).toEqual(61); + expect(header.attributes['aria-sort']).toEqual('ascending'); + + // Verify second level records are asc sorted + expect(treeGrid.getCellByColumn(3, 'Age').value).toEqual(29); + expect(treeGrid.getCellByColumn(4, 'Age').value).toEqual(30); + expect(treeGrid.getCellByColumn(5, 'Age').value).toEqual(31); + + // Verify third level records are asc sorted + expect(treeGrid.getCellByColumn(6, 'Age').value).toEqual(25); + expect(treeGrid.getCellByColumn(7, 'Age').value).toEqual(35); + expect(treeGrid.getCellByColumn(8, 'Age').value).toEqual(44); + }); + + it('should clear sorting of treeGrid when header cell is clicked 3 times through UI', () => { + // Verify first record of all 3 levels (default layout) + expect(treeGrid.getCellByColumn(0, 'Age').value).toEqual(55); + expect(treeGrid.getCellByColumn(1, 'Age').value).toEqual(30); + expect(treeGrid.getCellByColumn(4, 'Age').value).toEqual(35); + + // Click header once + const header = TreeGridFunctions.getHeaderCell(fix, 'Age'); + GridFunctions.clickHeaderSortIcon(header); + fix.detectChanges(); + + // Verify first record of all 3 levels (sorted layout) + expect(treeGrid.getCellByColumn(0, 'Age').value).toEqual(42); + expect(treeGrid.getCellByColumn(3, 'Age').value).toEqual(29); + expect(treeGrid.getCellByColumn(6, 'Age').value).toEqual(25); + + // Click header two more times + GridFunctions.clickHeaderSortIcon(header); + fix.detectChanges(); + GridFunctions.clickHeaderSortIcon(header); + fix.detectChanges(); + + // Verify first record of all 3 levels (default layout) + expect(treeGrid.getCellByColumn(0, 'Age').value).toEqual(55); + expect(treeGrid.getCellByColumn(1, 'Age').value).toEqual(30); + expect(treeGrid.getCellByColumn(4, 'Age').value).toEqual(35); + }); + + it('should sort treeGrid by multiple expressions through UI', () => { + // Test prerequisites (need to have multiple records with the same name on every level) + treeGrid.data[0].Name = 'Ana Sanders'; + treeGrid.data[0].Employees[1].Name = 'Michael Langdon'; + treeGrid.data[0].Employees[2].Employees[0].Name = 'Peter Lewis'; + fix.detectChanges(); + + // Sort by 'Name' in asc order and by 'Age' in desc order + const headerName = TreeGridFunctions.getHeaderCell(fix, 'Name'); + const headerAge = TreeGridFunctions.getHeaderCell(fix, 'Age'); + GridFunctions.clickHeaderSortIcon(headerName); + fix.detectChanges(); + GridFunctions.clickHeaderSortIcon(headerAge); + fix.detectChanges(); + GridFunctions.clickHeaderSortIcon(headerAge); + fix.detectChanges(); + + expect(treeGrid.sortingExpressions.length).toBe(2); + + // Verify first level multiple expressions sorting + expect(treeGrid.getCellByColumn(0, 'Name').value).toEqual('Ana Sanders'); + expect(treeGrid.getCellByColumn(0, 'Age').value).toEqual(55); + + expect(treeGrid.getCellByColumn(7, 'Name').value).toEqual('Ana Sanders'); + expect(treeGrid.getCellByColumn(7, 'Age').value).toEqual(42); + + expect(treeGrid.getCellByColumn(9, 'Name').value).toEqual('Yang Wang'); + expect(treeGrid.getCellByColumn(9, 'Age').value).toEqual(61); + + // Verify second level multiple expressions sorting + expect(treeGrid.getCellByColumn(1, 'Name').value).toEqual('Michael Langdon'); + expect(treeGrid.getCellByColumn(1, 'Age').value).toEqual(30); + + expect(treeGrid.getCellByColumn(2, 'Name').value).toEqual('Michael Langdon'); + expect(treeGrid.getCellByColumn(2, 'Age').value).toEqual(29); + + expect(treeGrid.getCellByColumn(3, 'Name').value).toEqual('Monica Reyes'); + expect(treeGrid.getCellByColumn(3, 'Age').value).toEqual(31); + + // Verify third level multiple expressions sorting + expect(treeGrid.getCellByColumn(4, 'Name').value).toEqual('Peter Lewis'); + expect(treeGrid.getCellByColumn(4, 'Age').value).toEqual(35); + + expect(treeGrid.getCellByColumn(5, 'Name').value).toEqual('Peter Lewis'); + expect(treeGrid.getCellByColumn(5, 'Age').value).toEqual(25); + + expect(treeGrid.getCellByColumn(6, 'Name').value).toEqual('Sven Ottlieb'); + expect(treeGrid.getCellByColumn(6, 'Age').value).toEqual(44); + }); + + it('should clear sorting of treeGrid for one column only through UI', () => { + // Test prerequisites (need to have multiple records with the same name on every level) + treeGrid.getCellByColumn(0, 'Name').value = 'Ana Sanders'; + treeGrid.getCellByColumn(2, 'Name').value = 'Michael Langdon'; + treeGrid.getCellByColumn(4, 'Name').value = 'Peter Lewis'; + fix.detectChanges(); + + // Sort by 'Name' in asc order and by 'Age' in desc order + const headerName = TreeGridFunctions.getHeaderCell(fix, 'Name'); + const headerAge = TreeGridFunctions.getHeaderCell(fix, 'Age'); + GridFunctions.clickHeaderSortIcon(headerName); + fix.detectChanges(); + GridFunctions.clickHeaderSortIcon(headerAge); + fix.detectChanges(); + GridFunctions.clickHeaderSortIcon(headerAge); + fix.detectChanges(); + + // Clear sorting for 'Name' column + GridFunctions.clickHeaderSortIcon(headerName); + fix.detectChanges(); + GridFunctions.clickHeaderSortIcon(headerName); + fix.detectChanges(); + + expect(treeGrid.sortingExpressions.length).toBe(1); + + // Verify first level single expression sorting + expect(treeGrid.getCellByColumn(0, 'Age').value).toEqual(61); + expect(treeGrid.getCellByColumn(1, 'Age').value).toEqual(55); + expect(treeGrid.getCellByColumn(8, 'Age').value).toEqual(42); + + // Verify second level single expression sorting + expect(treeGrid.getCellByColumn(2, 'Age').value).toEqual(31); + expect(treeGrid.getCellByColumn(6, 'Age').value).toEqual(30); + expect(treeGrid.getCellByColumn(7, 'Age').value).toEqual(29); + + // Verify third level single expression sorting + expect(treeGrid.getCellByColumn(3, 'Age').value).toEqual(44); + expect(treeGrid.getCellByColumn(4, 'Age').value).toEqual(35); + expect(treeGrid.getCellByColumn(5, 'Age').value).toEqual(25); + }); + }); +}); diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid-summaries.spec.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-summaries.spec.ts new file mode 100644 index 00000000000..bd5777ceb4e --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid-summaries.spec.ts @@ -0,0 +1,1781 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { + IgxTreeGridSummariesComponent, + IgxTreeGridSummariesKeyComponent, + IgxTreeGridCustomSummariesComponent, + IgxTreeGridSummariesTransactionsComponent, + IgxTreeGridSummariesScrollingComponent, + IgxTreeGridSummariesKeyScroliingComponent +} from '../../../test-utils/tree-grid-components.spec'; +import { clearGridSubs, setupGridScrollDetection } from '../../../test-utils/helper-utils.spec'; +import { wait, UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { GridSummaryFunctions, GridFunctions } from '../../../test-utils/grid-functions.spec'; +import { DebugElement } from '@angular/core'; +import { IgxTreeGridComponent } from './tree-grid.component'; +import { IgxSummaryRow, IgxTreeGridRow } from 'igniteui-angular/grids/core'; +import { IgxNumberFilteringOperand } from 'igniteui-angular/core'; + +describe('IgxTreeGrid - Summaries #tGrid', () => { + const DEBOUNCETIME = 30; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxTreeGridSummariesComponent, + IgxTreeGridSummariesKeyComponent, + IgxTreeGridCustomSummariesComponent, + IgxTreeGridSummariesTransactionsComponent, + IgxTreeGridSummariesScrollingComponent, + IgxTreeGridSummariesKeyScroliingComponent + ] + }).compileComponents(); + })); + + describe('', () => { + let fix; + let treeGrid: IgxTreeGridComponent; + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridSummariesKeyComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + setupGridScrollDetection(fix, treeGrid); + }); + + afterEach(() => { + clearGridSubs(); + }); + + it('should render summaries for all the rows when have parentKey', () => { + verifyTreeBaseSummaries(fix); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(1); + // Expand second row and verify summaries + treeGrid.toggleRow(treeGrid.getRowByIndex(1).key); + fix.detectChanges(); + + const secondRow = treeGrid.getRowByIndex(1); + const thirdRow = treeGrid.getRowByIndex(2); + const summaryRow = treeGrid.getRowByIndex(4); + + // First row is IgxTreeRow 4thRow is IgxSummaryRow + expect(secondRow instanceof IgxTreeGridRow).toBe(true); + expect(thirdRow instanceof IgxTreeGridRow).toBe(true); + expect(secondRow.index).toBe(1); + expect(secondRow.viewIndex).toBe(1); + expect(thirdRow.index).toBe(2); + expect(thirdRow.viewIndex).toBe(2); + + expect(thirdRow.parent.data).toBe(secondRow.data); + expect(secondRow.children[0].data).toBe(thirdRow.data); + + expect(summaryRow instanceof IgxSummaryRow).toBe(true); + + verifyTreeBaseSummaries(fix); + verifySummaryForRow847(fix, 4); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(2); + + // Expand child row and verify summaries + treeGrid.toggleRow(treeGrid.getRowByIndex(3).key); + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(3); + + verifyTreeBaseSummaries(fix); + verifySummaryForRow663(fix, 5); + verifySummaryForRow847(fix, 6); + }); + + it('should render summaries on top when position is top ', () => { + treeGrid.summaryPosition = 'top'; + fix.detectChanges(); + + verifyTreeBaseSummaries(fix); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(1); + // Expand first row and verify summaries + treeGrid.toggleRow(treeGrid.getRowByIndex(0).key); + fix.detectChanges(); + + verifyTreeBaseSummaries(fix); + verifySummaryForRow147(fix, 1); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(2); + + const firstRow = treeGrid.getRowByIndex(0); + const summaryRow = treeGrid.getRowByIndex(1); + + // First row is IgxTreeRow 4thRow is IgxSummaryRow + expect(firstRow instanceof IgxTreeGridRow).toBe(true); + expect(firstRow.index).toBe(0); + expect(firstRow.viewIndex).toBe(0); + + expect(summaryRow instanceof IgxSummaryRow).toBe(true); + + // Expand second row and verify summaries + treeGrid.toggleRow(treeGrid.getRowByIndex(5).key); + fix.detectChanges(); + + verifyTreeBaseSummaries(fix); + verifySummaryForRow847(fix, 6); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(3); + + // Expand first row child and verify summaries + treeGrid.toggleRow(treeGrid.getRowByIndex(4).key); + fix.detectChanges(); + + verifySummaryForRow317(fix, 5); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(4); + }); + + it('should be able to change summaryPosition at runtime', () => { + treeGrid.expandAll(); + fix.detectChanges(); + + verifyTreeBaseSummaries(fix); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(3); + let rootSummaryIndex = treeGrid.dataView.length; + expect(GridSummaryFunctions.getAllVisibleSummariesRowIndexes(fix)).toEqual([6, 7, rootSummaryIndex]); + + treeGrid.summaryPosition = 'top'; + fix.detectChanges(); + + verifyTreeBaseSummaries(fix); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(4); + + rootSummaryIndex = treeGrid.dataView.length; + expect(GridSummaryFunctions.getAllVisibleSummariesRowIndexes(fix)).toEqual([1, 5, 9, rootSummaryIndex]); + + treeGrid.summaryPosition = 'bottom'; + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(3); + + rootSummaryIndex = treeGrid.dataView.length; + expect(GridSummaryFunctions.getAllVisibleSummariesRowIndexes(fix)).toEqual([6, 7, rootSummaryIndex]); + }); + + it('should be able to change summaryCalculationMode at runtime', async () => { + treeGrid.expandAll(); + fix.detectChanges(); + + verifyTreeBaseSummaries(fix); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(3); + + let rootSummaryIndex = treeGrid.dataView.length; + expect(GridSummaryFunctions.getAllVisibleSummariesRowIndexes(fix)).toEqual([6, 7, rootSummaryIndex]); + + treeGrid.summaryCalculationMode = 'rootLevelOnly'; + fix.detectChanges(); + await wait(50); + + verifyTreeBaseSummaries(fix); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(1); + + treeGrid.summaryCalculationMode = 'childLevelsOnly'; + fix.detectChanges(); + await wait(50); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(4); + expect(GridSummaryFunctions.getAllVisibleSummariesRowIndexes(fix)).toEqual([6, 7, 12, 13]); + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + expect(summaryRow).toBeNull(); + + treeGrid.summaryCalculationMode = 'rootAndChildLevels'; + fix.detectChanges(); + await wait(50); + + verifyTreeBaseSummaries(fix); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(3); + rootSummaryIndex = treeGrid.dataView.length; + expect(GridSummaryFunctions.getAllVisibleSummariesRowIndexes(fix)).toEqual([6, 7, rootSummaryIndex]); + }); + + it('should be able to show/hide summaries for collapsed parent rows runtime', () => { + treeGrid.summaryCalculationMode = 'childLevelsOnly'; + fix.detectChanges(); + + let summaries = GridSummaryFunctions.getAllVisibleSummaries(fix); + expect(summaries.length).toBe(0); + + treeGrid.showSummaryOnCollapse = true; + fix.detectChanges(); + + let secondRow = treeGrid.getRowByIndex(1); + expect(secondRow.index).toEqual(1); + expect(secondRow.viewIndex).toEqual(1); + expect(secondRow instanceof IgxSummaryRow).toBe(true); + + summaries = GridSummaryFunctions.getAllVisibleSummaries(fix); + expect(summaries.length).toBe(4); + + treeGrid.showSummaryOnCollapse = false; + fix.detectChanges(); + + secondRow = treeGrid.getRowByIndex(1); + expect(secondRow.index).toEqual(1); + expect(secondRow.viewIndex).toEqual(1); + expect(secondRow instanceof IgxSummaryRow).toBe(false); + + summaries = GridSummaryFunctions.getAllVisibleSummaries(fix); + expect(summaries.length).toBe(0); + }); + + it('should position correctly summary row for collapsed rows -- bottom position', async () => { + treeGrid.expandAll(); + fix.detectChanges(); + + treeGrid.summaryCalculationMode = 'childLevelsOnly'; + fix.detectChanges(); + await wait(30); + + let summaries = GridSummaryFunctions.getAllVisibleSummaries(fix); + expect(summaries.length).toBe(4); + + treeGrid.showSummaryOnCollapse = true; + fix.detectChanges(); + await wait(30); + + treeGrid.toggleRow(treeGrid.getRowByIndex(3).key); + fix.detectChanges(); + + const gridSummaryRow = treeGrid.getRowByIndex(4); + expect(gridSummaryRow.index).toEqual(4); + expect(gridSummaryRow.viewIndex).toEqual(4); + expect(gridSummaryRow instanceof IgxSummaryRow).toBe(true); + + summaries = GridSummaryFunctions.getAllVisibleSummaries(fix); + expect(summaries.length).toBe(4); + + let summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 4); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, + ['Count', 'Earliest', 'Latest'], ['2', 'Nov 11, 2009', 'Oct 17, 2015']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 5); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, + ['Count', 'Earliest', 'Latest'], ['3', 'Jul 19, 2009', 'Sep 18, 2014']); + + treeGrid.summaryPosition = 'top'; + fix.detectChanges(); + + summaries = GridSummaryFunctions.getAllVisibleSummaries(fix); + expect(summaries.length).toBe(4); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 1); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, + ['Count', 'Earliest', 'Latest'], ['3', 'Jul 19, 2009', 'Sep 18, 2014']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 5); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, + ['Count', 'Earliest', 'Latest'], ['2', 'Nov 11, 2009', 'Oct 17, 2015']); + }); + + it('should position correctly summary row for collapsed rows -- top position', async () => { + treeGrid.expandAll(); + fix.detectChanges(); + + treeGrid.summaryCalculationMode = 'childLevelsOnly'; + fix.detectChanges(); + await wait(30); + + treeGrid.showSummaryOnCollapse = true; + fix.detectChanges(); + await wait(30); + + let summaries = GridSummaryFunctions.getAllVisibleSummaries(fix); + expect(summaries.length).toBe(4); + + treeGrid.toggleRow(treeGrid.getRowByIndex(3).key); + fix.detectChanges(); + + treeGrid.summaryPosition = 'top'; + fix.detectChanges(); + + summaries = GridSummaryFunctions.getAllVisibleSummaries(fix); + expect(summaries.length).toBe(4); + + let summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 1); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, + ['Count', 'Earliest', 'Latest'], ['3', 'Jul 19, 2009', 'Sep 18, 2014']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 5); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, + ['Count', 'Earliest', 'Latest'], ['2', 'Nov 11, 2009', 'Oct 17, 2015']); + }); + + it('should be able to enable/disable summaries at runtime', () => { + treeGrid.expandAll(); + fix.detectChanges(); + + treeGrid.getColumnByName('Age').hasSummary = false; + fix.detectChanges(); + + GridSummaryFunctions.verifyVisibleSummariesHeight(fix, 3); + + let summaries = GridSummaryFunctions.getAllVisibleSummaries(fix); + summaries.forEach(summary => { + GridSummaryFunctions.verifyColumnSummaries(summary, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 1, ['Count'], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 2, ['Count', 'Earliest', 'Latest'], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 3, [], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 4, ['Count'], []); + }); + + // Disable all summaries + treeGrid.getColumnByName('Name').hasSummary = false; + treeGrid.getColumnByName('HireDate').hasSummary = false; + treeGrid.getColumnByName('OnPTO').hasSummary = false; + fix.detectChanges(); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(0); + + treeGrid.collapseAll(); + fix.detectChanges(); + + treeGrid.getColumnByName('Name').hasSummary = true; + fix.detectChanges(); + + treeGrid.toggleRow(treeGrid.getRowByIndex(0).key); + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(2); + summaries = GridSummaryFunctions.getAllVisibleSummaries(fix); + summaries.forEach(summary => { + GridSummaryFunctions.verifyColumnSummaries(summary, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 1, ['Count'], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 2, [], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 3, [], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 4, [], []); + }); + GridSummaryFunctions.verifyVisibleSummariesHeight(fix, 1); + }); + + it('should be able to enable/disable summaries with API', () => { + treeGrid.disableSummaries([{ fieldName: 'Age' }, { fieldName: 'HireDate' }]); + fix.detectChanges(); + + GridSummaryFunctions.verifyVisibleSummariesHeight(fix, 1); + + treeGrid.toggleRow(treeGrid.getRowByIndex(0).key); + fix.detectChanges(); + + let summaries = GridSummaryFunctions.getAllVisibleSummaries(fix); + summaries.forEach(summary => { + GridSummaryFunctions.verifyColumnSummaries(summary, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 1, ['Count'], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 2, [], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 3, [], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 4, ['Count'], []); + }); + + GridSummaryFunctions.verifyVisibleSummariesHeight(fix, 1); + + treeGrid.disableSummaries('Name'); + treeGrid.disableSummaries('OnPTO'); + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(0); + + treeGrid.enableSummaries('HireDate'); + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(2); + + GridSummaryFunctions.verifyVisibleSummariesHeight(fix, 3); + + summaries = GridSummaryFunctions.getAllVisibleSummaries(fix); + summaries.forEach(summary => { + GridSummaryFunctions.verifyColumnSummaries(summary, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 1, [], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 2, ['Count', 'Earliest', 'Latest'], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 3, [], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 4, [], []); + }); + + let summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 4); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, + ['Count', 'Earliest', 'Latest'], ['3', 'Jul 19, 2009', 'Sep 18, 2014']); + + treeGrid.enableSummaries([{ fieldName: 'Age' }, { fieldName: 'ID' }]); + fix.detectChanges(); + + GridSummaryFunctions.verifyVisibleSummariesHeight(fix, 5); + + summaries = GridSummaryFunctions.getAllVisibleSummaries(fix); + summaries.forEach(summary => { + GridSummaryFunctions.verifyColumnSummaries(summary, 0, ['Count', 'Min', 'Max', 'Sum', 'Avg'], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 1, [], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 2, ['Count', 'Earliest', 'Latest'], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 3, ['Count', 'Min', 'Max', 'Sum', 'Avg'], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 4, [], []); + }); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 4); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['3', '29', '43', '103', '34.333']); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['4', '42', '61', '207', '51.75']); + }); + + it('should be able to change summary operant at runtime', () => { + treeGrid.expandAll(); + fix.detectChanges(); + + GridSummaryFunctions.verifyVisibleSummariesHeight(fix, 5); + + treeGrid.getColumnByName('Age').summaries = fix.componentInstance.ageSummaryTest; + fix.detectChanges(); + + GridSummaryFunctions.verifyVisibleSummariesHeight(fix, 6); + + let summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 7); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Min', 'Max', 'Sum', 'Avg', 'Test'], ['3', '29', '43', '103', '34.333', '2']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 6); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Min', 'Max', 'Sum', 'Avg', 'Test'], ['2', '35', '44', '79', '39.5', '1']); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Min', 'Max', 'Sum', 'Avg', 'Test'], ['4', '42', '61', '207', '51.75', '0']); + }); + + it('should be able to change summary operant with API', () => { + treeGrid.expandAll(); + fix.detectChanges(); + + GridSummaryFunctions.verifyVisibleSummariesHeight(fix, 5); + + treeGrid.enableSummaries([{ fieldName: 'Age', customSummary: fix.componentInstance.ageSummary }]); + fix.detectChanges(); + + GridSummaryFunctions.verifyVisibleSummariesHeight(fix, 3); + + let summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 7); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Sum', 'Avg'], ['3', '103', '34.33']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 6); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Sum', 'Avg'], ['2', '79', '39.5']); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Sum', 'Avg'], ['4', '207', '51.75']); + }); + + it('Hiding: should render correct summaries when show/hide a column', () => { + treeGrid.expandAll(); + fix.detectChanges(); + + treeGrid.getColumnByName('Age').hidden = true; + fix.detectChanges(); + + let summaries = GridSummaryFunctions.getAllVisibleSummaries(fix); + summaries.forEach(summary => { + GridSummaryFunctions.verifyColumnSummaries(summary, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 1, ['Count'], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 2, ['Count', 'Earliest', 'Latest'], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 3, ['Count'], []); + }); + + GridSummaryFunctions.verifyVisibleSummariesHeight(fix, 3); + + treeGrid.getColumnByName('Name').hidden = true; + treeGrid.getColumnByName('HireDate').hidden = true; + treeGrid.getColumnByName('OnPTO').hidden = true; + fix.detectChanges(); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(0); + + treeGrid.getColumnByName('HireDate').hidden = false; + treeGrid.getColumnByName('OnPTO').hidden = false; + fix.detectChanges(); + + summaries = GridSummaryFunctions.getAllVisibleSummaries(fix); + summaries.forEach(summary => { + GridSummaryFunctions.verifyColumnSummaries(summary, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 1, ['Count', 'Earliest', 'Latest'], []); + GridSummaryFunctions.verifyColumnSummaries(summary, 2, ['Count'], []); + }); + + GridSummaryFunctions.verifyVisibleSummariesHeight(fix, 3); + + let summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 7); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['3']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 6); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['2']); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count'], ['4']); + }); + + it('Filtering: should render correct summaries when filter and found only children', () => { + treeGrid.filter('ID', 12, IgxNumberFilteringOperand.instance().condition('lessThanOrEqualTo')); + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(2); + let summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 2); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['1']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, + ['Count', 'Earliest', 'Latest'], ['1', 'Dec 18, 2007', 'Dec 18, 2007']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['1', '50', '50', '50', '50']); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + verifySummaryIsEmpty(summaryRow); + }); + + it('Filtering: should render correct summaries when filter and no results are found', () => { + treeGrid.filter('ID', 0, IgxNumberFilteringOperand.instance().condition('lessThanOrEqualTo')); + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(1); + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + verifySummaryIsEmpty(summaryRow); + }); + + it('Filtering: should render correct summaries when filter', () => { + treeGrid.filter('ID', 17, IgxNumberFilteringOperand.instance().condition('lessThanOrEqualTo')); + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(3); + + let summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 5); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['1']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, + ['Count', 'Earliest', 'Latest'], ['1', 'Dec 18, 2007', 'Dec 18, 2007']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['1', '50', '50', '50', '50']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 2); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['1']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count', 'Earliest', 'Latest'], ['1', 'May 4, 2014', 'May 4, 2014']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['1', '44', '44', '44', '44']); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['1']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['1', '61', '61', '61', '61']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count', 'Earliest', 'Latest'], ['1', 'Feb 1, 2010', 'Feb 1, 2010']); + }); + + it('Paging: should render correct summaries when paging is enable and position is bottom', () => { + fix.componentInstance.paging = true; + fix.detectChanges(); + treeGrid.paginator.perPage = 4; + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(1); + verifyTreeBaseSummaries(fix); + + treeGrid.toggleRow(treeGrid.getRowByIndex(0).key); + fix.detectChanges(); + + let summaryRow = treeGrid.getRowByIndex(4); + expect(summaryRow.index).toEqual(4); + expect(summaryRow.viewIndex).toEqual(4); + expect(summaryRow instanceof IgxSummaryRow).toBe(true); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(2); + verifyTreeBaseSummaries(fix); + verifySummaryForRow147(fix, 4); + + treeGrid.toggleRow(treeGrid.getRowByIndex(3).key); + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(1); + + treeGrid.paginator.page = 1; + fix.detectChanges(); + + // TODO FIX + const firstRow = treeGrid.getRowByIndex(0); + summaryRow = treeGrid.getRowByIndex(2); + expect(firstRow.index).toEqual(0); + expect(firstRow.viewIndex).toEqual(4); + expect(summaryRow.index).toEqual(2); + expect(summaryRow.viewIndex).toEqual(6); + expect(summaryRow instanceof IgxSummaryRow).toBe(true); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(3); + verifyTreeBaseSummaries(fix); + verifySummaryForRow147(fix, 3); + verifySummaryForRow317(fix, 2); + }); + + it('Paging: should render correct summaries when paging is enable and position is top', () => { + fix.componentInstance.paging = true; + fix.detectChanges(); + treeGrid.paginator.perPage = 4; + treeGrid.summaryPosition = 'top'; + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(1); + verifyTreeBaseSummaries(fix); + + treeGrid.toggleRow(treeGrid.getRowByIndex(0).key); + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(2); + verifyTreeBaseSummaries(fix); + verifySummaryForRow147(fix, 1); + + treeGrid.toggleRow(treeGrid.getRowByIndex(4).key); + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(3); + verifySummaryForRow317(fix, 5); + verifySummaryForRow147(fix, 1); + + treeGrid.paginator.page = 1; + fix.detectChanges(); + + const firstRow = treeGrid.getRowByIndex(0); + expect(firstRow.index).toEqual(0); + expect(firstRow.viewIndex).toEqual(5); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(1); + verifyTreeBaseSummaries(fix); + + treeGrid.toggleRow(treeGrid.getRowByIndex(2).key); + fix.detectChanges(); + + const summaryRow = treeGrid.getRowByIndex(3); + expect(summaryRow.index).toEqual(3); + expect(summaryRow.viewIndex).toEqual(8); + expect(summaryRow instanceof IgxSummaryRow).toBe(true); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(2); + verifySummaryForRow847(fix, 3); + }); + + it('CRUD: Add root node', () => { + treeGrid.expandAll(); + fix.detectChanges(); + + const newRow = { + ID: 777, + ParentID: -1, + Name: 'New Employee', + HireDate: new Date(2019, 3, 3), + Age: 19 + }; + treeGrid.addRow(newRow); + fix.detectChanges(); + + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['5']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, + ['Count', 'Earliest', 'Latest'], ['5', 'Apr 20, 2008', 'Apr 3, 2019']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['5', '19', '61', '226', '45.2']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count'], ['5']); + + verifySummaryForRow147(fix, 7); + }); + + it('CRUD: Add child node', () => { + treeGrid.expandAll(); + fix.detectChanges(); + + const newRow = { + ID: 777, + ParentID: 147, + Name: 'New Employee', + HireDate: new Date(2019, 3, 3), + Age: 19 + }; + treeGrid.addRow(newRow); + fix.detectChanges(); + + const summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 8); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['4']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, + ['Count', 'Earliest', 'Latest'], ['4', 'Jul 19, 2009', 'Apr 3, 2019']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count'], ['4']); + + verifyTreeBaseSummaries(fix); + }); + + it('CRUD: add child row whick contains null or undefined values', () => { + treeGrid.expandAll(); + fix.detectChanges(); + + const newRow = { + ID: 777, + ParentID: 475, + Name: 'New Employee', + HireDate: undefined, + Age: null + }; + expect(() => { + treeGrid.addRow(newRow); + fix.detectChanges(); + }).not.toThrow(); + + const summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 3); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['1']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count', 'Earliest', 'Latest'], ['1', '', '']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['1', '0', '0', '0', '0']); + + verifyTreeBaseSummaries(fix); + }); + + it('CRUD: delete root node', () => { + treeGrid.expandAll(); + fix.detectChanges(); + + treeGrid.deleteRow(treeGrid.getRowByIndex(0).key); + fix.detectChanges(); + + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, + ['Count', 'Earliest', 'Latest'], ['3', 'Feb 1, 2010', 'Feb 22, 2014']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['3', '42', '61', '152', '50.667']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count'], ['3']); + + verifySummaryForRow847(fix, 5); + }); + + it('CRUD: delete all root nodes', () => { + treeGrid.toggleRow(treeGrid.getRowByIndex(0).key); + fix.detectChanges(); + + treeGrid.toggleRow(treeGrid.getRowByIndex(5).key); + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(3); + + treeGrid.deleteRow(treeGrid.getRowByIndex(5).key); + fix.detectChanges(); + + let summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(2); + + treeGrid.deleteRow(treeGrid.getRowByIndex(5).key); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['2']); + + treeGrid.deleteRow(treeGrid.getRowByIndex(5).key); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['1']); + + treeGrid.deleteRow(treeGrid.getRowByIndex(0).key); + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(1); + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + verifySummaryIsEmpty(summaryRow); + }); + + it('CRUD: delete child node', () => { + treeGrid.toggleRow(treeGrid.getRowByIndex(0).key); + fix.detectChanges(); + + treeGrid.toggleRow(treeGrid.getRowByIndex(3).key); + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(3); + + treeGrid.deleteRow(treeGrid.getRowByIndex(3).key); + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(2); + verifyTreeBaseSummaries(fix); + + const summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 3); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, + ['Count', 'Earliest', 'Latest'], ['2', 'Jul 19, 2009', 'Jul 3, 2011']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['2', '29', '43', '72', '36']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count'], ['2']); + + treeGrid.deleteRow(treeGrid.getRowByIndex(2).key); + fix.detectChanges(); + + treeGrid.deleteRow(treeGrid.getRowByIndex(1).key); + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(1); + verifyTreeBaseSummaries(fix); + }); + + it('CRUD: Update root node', () => { + const newRow = { + ID: 147, + ParentID: -1, + Name: 'New Employee', + HireDate: new Date(2019, 3, 3), + Age: 19 + }; + treeGrid.getRowByKey(147).update(newRow); + fix.detectChanges(); + + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['4']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count', 'Earliest', 'Latest'], ['4', 'Feb 1, 2010', 'Apr 3, 2019']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['4', '19', '61', '171', '42.75']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count'], ['4']); + }); + + it('CRUD: Update child node', () => { + treeGrid.toggleRow(treeGrid.getRowByIndex(1).key); + fix.detectChanges(); + + treeGrid.toggleRow(treeGrid.getRowByIndex(3).key); + fix.detectChanges(); + + const newRow = { + ID: 663, + ParentID: 847, + Name: 'New Employee', + HireDate: new Date(2019, 3, 3), + Age: 19 + }; + treeGrid.getRowByKey(663).update(newRow); + fix.detectChanges(); + + verifyTreeBaseSummaries(fix); + + let summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 6); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count', 'Earliest', 'Latest'], ['2', 'May 4, 2014', 'Apr 3, 2019']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['2', '19', '44', '63', '31.5']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count'], ['2']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 5); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['1']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, + ['Count', 'Earliest', 'Latest'], ['1', 'Apr 22, 2010', 'Apr 22, 2010']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['1', '39', '39', '39', '39']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count'], ['1']); + }); + }); + + describe('CRUD with transactions', () => { + let fix; + let treeGrid; + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridSummariesTransactionsComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + setupGridScrollDetection(fix, treeGrid); + }); + + afterEach(() => { + clearGridSubs(); + }); + + it('Delete root node', () => { + treeGrid.toggleRow(847); + fix.detectChanges(); + + treeGrid.deleteRow(847); + fix.detectChanges(); + + let summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['49', '61']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count'], ['3']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 4); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['0']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['0', '0']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count'], ['0']); + + // Undo transactions + treeGrid.transactions.undo(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['4']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['42', '61']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count'], ['4']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 4); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['25', '44']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count'], ['2']); + + // Redo transactions + treeGrid.transactions.redo(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['49', '61']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count'], ['3']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 4); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['0']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['0', '0']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count'], ['0']); + + // Commit transactions + treeGrid.transactions.commit(fix.componentInstance.data); + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(1); + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + }); + + it('Delete a root node with cascadeOnDelete set to false', () => { + treeGrid.cascadeOnDelete = false; + treeGrid.expandAll(); + fix.detectChanges(); + + treeGrid.deleteRow(147); + fix.detectChanges(); + + // Verify summary is updated + let summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 6); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['2']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 7); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + + // Commit transactions + treeGrid.transactions.commit(fix.componentInstance.data); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['6']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 5); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['2']); + }); + + it('Delete child node', () => { + treeGrid.deleteRow(317); + fix.detectChanges(); + + let summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['4']); + + treeGrid.expandAll(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 6); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['0']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['0', '0']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count'], ['0']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 7); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['2']); + + // Undo transactions + treeGrid.transactions.undo(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['4']); + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 6); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['2']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 7); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + + // Redo transactions + treeGrid.transactions.redo(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['4']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 6); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['0']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['0', '0']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count'], ['0']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 7); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['2']); + + // Clear transactions + treeGrid.transactions.clear(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['4']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 6); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['2']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 7); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + }); + + it('Delete child node cascadeOnDelete set to false', () => { + treeGrid.cascadeOnDelete = false; + treeGrid.expandAll(); + fix.detectChanges(); + + treeGrid.deleteRow(317); + fix.detectChanges(); + + // Verify summaries are not changed + let summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['4']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 6); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['2']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 7); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + + // Commit + treeGrid.transactions.commit(fix.componentInstance.data); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['6']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 3); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['2']); + }); + + it('Add root node', () => { + const newRow = { + ID: 11, + ParentID: -1, + Name: 'New Employee', + HireDate: new Date(1984, 3, 3), + Age: 70 + }; + treeGrid.addRow(newRow); + fix.detectChanges(); + + let summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['5']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['42', '70']); + + // Undo transactions + treeGrid.transactions.undo(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['4']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['42', '61']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count'], ['4']); + + // Redo transactions + treeGrid.transactions.redo(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['5']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['42', '70']); + + // Commit transactions + treeGrid.transactions.commit(fix.componentInstance.data); + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(1); + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['5']); + }); + + it('Add child node', () => { + const newRow = { + ID: 11, + ParentID: 317, + Name: 'New Employee', + HireDate: new Date(1984, 3, 3), + Age: 70 + }; + treeGrid.addRow(newRow); + fix.detectChanges(); + + let summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['4']); + + treeGrid.expandAll(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 7); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['35', '70']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 8); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + + // Undo transactions + treeGrid.transactions.undo(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 6); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['35', '44']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 7); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + + // Redo transactions + treeGrid.transactions.redo(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 7); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['35', '70']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 8); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + }); + + it('Update root node', () => { + const newRow = { + ID: 847, + ParentID: -1, + Name: 'New Employee', + HireDate: new Date(1984, 3, 3), + Age: 13 + }; + treeGrid.updateRow(newRow, 847); + fix.detectChanges(); + + let summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['4']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['13', '61']); + + // Undo transactions + treeGrid.transactions.undo(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['4']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['42', '61']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count'], ['4']); + + // Redo transactions + treeGrid.transactions.redo(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['4']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['13', '61']); + + // Commit transactions + treeGrid.transactions.commit(fix.componentInstance.data); + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(1); + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['4']); + }); + + it('Update child node', () => { + const newRow = { + ID: 317, + ParentID: 147, + Name: 'New Employee', + HireDate: new Date(1984, 3, 3), + Age: 13 + }; + treeGrid.updateRow(newRow, 317); + fix.detectChanges(); + + let summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['4']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['42', '61']); + + treeGrid.expandAll(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 6); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['35', '44']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 7); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['13', '43']); + + // Undo transactions + treeGrid.transactions.undo(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 6); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['35', '44']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 7); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['29', '43']); + + // Redo transactions + treeGrid.transactions.redo(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 6); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['35', '44']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 7); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['13', '43']); + }); + + it('Update child node and change tree structure', () => { + treeGrid.expandAll(); + fix.detectChanges(); + + const newRow = { + ID: 317, + ParentID: -1, + Name: 'New Employee', + HireDate: new Date(1984, 3, 3), + Age: 13 + }; + treeGrid.getRowByKey(317).update(newRow); + fix.detectChanges(); + + let summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['5']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['13', '61']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 3); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['29', '43']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 7); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['35', '44']); + + // Undo transactions + treeGrid.transactions.undo(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['4']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 6); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['35', '44']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 7); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['29', '43']); + + // Redo transactions + treeGrid.transactions.redo(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['5']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['13', '61']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 3); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['29', '43']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 7); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['35', '44']); + + // Clear transactions + treeGrid.transactions.clear(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['4']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 6); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['35', '44']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 7); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['29', '43']); + }); + + it('Update cell', () => { + treeGrid.summaryPosition = 'top'; + treeGrid.expandAll(); + fix.detectChanges(); + + treeGrid.updateCell(-1, 147, 'Age'); + const cell = treeGrid.getCellByColumn(4, 'Age'); + cell.update(100); + fix.detectChanges(); + + let summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['4']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['-1', '61']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 1); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['29', '100']); + + // Clear transactions + treeGrid.transactions.clear(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['4']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['42', '61']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 1); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['29', '43']); + }); + + it('Update cell and change tree grid structure', () => { + treeGrid.summaryPosition = 'top'; + treeGrid.expandAll(); + fix.detectChanges(); + + treeGrid.updateCell(317, 17, 'ParentID'); + fix.detectChanges(); + + let summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['42', '55']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 1); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 5); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['35', '61']); + + // Undo transactions + treeGrid.transactions.undo(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['4']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 5); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['35', '44']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 1); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['29', '43']); + + // Redo transactions + treeGrid.transactions.redo(); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['42', '55']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 1); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 5); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Min', 'Max'], ['35', '61']); + }); + }); + + describe('Keyboard Navigation', () => { + let fix; + let treeGrid; + let gridContent: DebugElement; + let gridFooter: DebugElement; + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridSummariesKeyScroliingComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + gridContent = GridFunctions.getGridContent(fix); + gridFooter = GridFunctions.getGridFooter(fix); + setupGridScrollDetection(fix, treeGrid); + }); + + afterEach(() => { + clearGridSubs(); + }); + + it('should be able to select root summaries with arrow keys', async () => { + let summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + const summaryCell = GridSummaryFunctions.getSummaryCellByVisibleIndex(summaryRow, 0); + UIInteractions.simulateClickAndSelectEvent(summaryCell); + fix.detectChanges(); + await wait(DEBOUNCETIME); + + for (let i = 0; i < 5; i++) { + GridSummaryFunctions.verifySummaryCellActive(fix, summaryRow, i); + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridFooter); + await wait(DEBOUNCETIME); + fix.detectChanges(); + } + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridFooter); + await wait(DEBOUNCETIME); + fix.detectChanges(); + GridSummaryFunctions.verifySummaryCellActive(fix, summaryRow, 5); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['4', '42', '61', '207', '51.75']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 5, ['Count'], ['4']); + + for (let i = 5; i > 0; i--) { + GridSummaryFunctions.verifySummaryCellActive(fix, summaryRow, i); + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridFooter); + await wait(DEBOUNCETIME); + fix.detectChanges(); + } + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridFooter); + await wait(DEBOUNCETIME); + fix.detectChanges(); + GridSummaryFunctions.verifySummaryCellActive(fix, summaryRow, 0); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['4', '-1', '-1', '-4', '-1']); + }); + + it('Verify first summary cell is activated when focus footer', () => { + gridFooter.triggerEventHandler('focus', {}); + fix.detectChanges(); + + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifySummaryCellActive(fix, summaryRow, 0); + }); + + it('should be able to navigate with Arrow keys and Ctrl', async () => { + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + const summaryCell = GridSummaryFunctions.getSummaryCellByVisibleIndex(summaryRow, 0); + UIInteractions.simulateClickAndSelectEvent(summaryCell); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridFooter, false, false, true); + await wait(100); + fix.detectChanges(); + + GridSummaryFunctions.verifySummaryCellActive(fix, summaryRow, 5); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['4', '42', '61', '207', '51.75']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 5, ['Count'], ['4']); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridFooter, false, false, true); + await wait(100); + fix.detectChanges(); + GridSummaryFunctions.verifySummaryCellActive(fix, summaryRow, 0); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['4', '-1', '-1', '-4', '-1']); + }); + + it('Should be able to select child summaries with arrow keys', async () => { + treeGrid.expandAll(); + treeGrid.summaryPosition = 'top'; + fix.detectChanges(); + + const summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 1); + const summaryCell = GridSummaryFunctions.getSummaryCellByVisibleIndex(summaryRow, 0); + UIInteractions.simulateClickAndSelectEvent(summaryCell); + fix.detectChanges(); + + for (let i = 0; i < 5; i++) { + GridSummaryFunctions.verifySummaryCellActive(fix, 1, i); + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + } + + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['3', '29', '43', '103', '34.333']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 5, ['Count'], ['3']); + + for (let i = 5; i > 0; i--) { + GridSummaryFunctions.verifySummaryCellActive(fix, 1, i); + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + } + + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['3', '147', '147', '441', '147']); + }); + + it('Should not change active summary cell when press Ctrl+ArrowUp/Down', async () => { + treeGrid.expandAll(); + fix.detectChanges(); + + const summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 6); + const summaryCell = GridSummaryFunctions.getSummaryCellByVisibleIndex(summaryRow, 1); + UIInteractions.simulateClickAndSelectEvent(summaryCell); + fix.detectChanges(); + + GridSummaryFunctions.verifySummaryCellActive(fix, 6, 1); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent, false, false, true); + await wait(DEBOUNCETIME); + fix.detectChanges(); + GridSummaryFunctions.verifySummaryCellActive(fix, 6, 1); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent, false, false, true); + await wait(DEBOUNCETIME); + fix.detectChanges(); + GridSummaryFunctions.verifySummaryCellActive(fix, 6, 1); + }); + + it('Should not change active summary cell when press Arrow Down and it is last summary row', async () => { + treeGrid.expandAll(); + fix.detectChanges(); + + treeGrid.verticalScrollContainer.scrollTo(treeGrid.dataView.length - 1); + await wait(100); + fix.detectChanges(); + + GridSummaryFunctions.focusSummaryCell(fix, 24, 1); + GridSummaryFunctions.verifySummaryCellActive(fix, 24, 1); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent); + await wait(DEBOUNCETIME); + fix.detectChanges(); + GridSummaryFunctions.verifySummaryCellActive(fix, 24, 1); + }); + + it('Should be able to navigate with Arrow keys Left/Right and Ctrl on a child summary', async () => { + treeGrid.expandAll(); + fix.detectChanges(); + + GridSummaryFunctions.focusSummaryCell(fix, 6, 1); + await wait(DEBOUNCETIME); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent, false, false, true); + await wait(100); + fix.detectChanges(); + + GridSummaryFunctions.verifySummaryCellActive(fix, 6, 5); + let summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 6); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 5, ['Count'], ['2']); + + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', gridContent, false, false, true); + await wait(100); + fix.detectChanges(); + GridSummaryFunctions.verifySummaryCellActive(fix, 6, 0); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 6); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['2', '317', '317', '634', '317']); + }); + + it('Should navigate with arrow keys from treeGrid cell to summary row ', () => { + treeGrid.expandAll(); + treeGrid.summaryPosition = 'top'; + fix.detectChanges(); + + let cell = treeGrid.getCellByColumn(0, 'ID'); + UIInteractions.simulateClickAndSelectEvent(treeGrid.gridAPI.get_cell_by_index(0, 'ID')); + fix.detectChanges(); + + expect(cell.selected).toBe(true); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent); + fix.detectChanges(); + + cell = treeGrid.getCellByColumn(0, 'ID'); + expect(cell.selected).toBe(true); + GridSummaryFunctions.verifySummaryCellActive(fix, 1, 0); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', gridContent); + fix.detectChanges(); + + cell = treeGrid.getCellByColumn(0, 'ID'); + expect(cell.selected).toBe(true); + GridSummaryFunctions.verifySummaryCellActive(fix, 1, 1); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', gridContent); + fix.detectChanges(); + + cell = treeGrid.getCellByColumn(2, 'ParentID'); + expect(cell.selected).toBe(true); + GridSummaryFunctions.verifySummaryCellActive(fix, 1, 1, false); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent); + fix.detectChanges(); + + cell = treeGrid.getCellByColumn(2, 'ParentID'); + expect(cell.selected).toBe(true); + GridSummaryFunctions.verifySummaryCellActive(fix, 1, 1); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', gridContent); + fix.detectChanges(); + + cell = treeGrid.getCellByColumn(2, 'ParentID'); + expect(cell.selected).toBe(false); + cell = treeGrid.getCellByColumn(0, 'ParentID'); + expect(cell.selected).toBe(true); + GridSummaryFunctions.verifySummaryCellActive(fix, 1, 1, false); + }); + }); + + it('should render correct custom summaries', () => { + const fix = TestBed.createComponent(IgxTreeGridCustomSummariesComponent); + fix.detectChanges(); + const treeGrid = fix.componentInstance.treeGrid; + treeGrid.expandAll(); + fix.detectChanges(); + + GridSummaryFunctions.verifyVisibleSummariesHeight(fix, 3); + + let summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 7); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Sum', 'Avg'], ['3', '103', '34.33']); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 6); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Sum', 'Avg'], ['2', '79', '39.5']); + + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Sum', 'Avg'], ['4', '207', '51.75']); + }); + + it('should render summaries for all the rows', () => { + const fix = TestBed.createComponent(IgxTreeGridSummariesComponent); + fix.detectChanges(); + const treeGrid = fix.componentInstance.treeGrid; + + verifyTreeBaseSummaries(fix); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(1); + + treeGrid.toggleRow(treeGrid.getRowByIndex(1).key); + fix.detectChanges(); + + verifyTreeBaseSummaries(fix); + verifySummaryForRow847(fix, 4); + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(2); + + treeGrid.toggleRow(treeGrid.getRowByIndex(3).key); + fix.detectChanges(); + + expect(GridSummaryFunctions.getAllVisibleSummariesLength(fix)).toEqual(3); + + verifyTreeBaseSummaries(fix); + verifySummaryForRow663(fix, 5); + verifySummaryForRow847(fix, 6); + }); + + it('should be able to access alldata from each summary', () => { + const fix = TestBed.createComponent(IgxTreeGridCustomSummariesComponent); + fix.detectChanges(); + const treeGrid = fix.componentInstance.treeGrid; + + treeGrid.expandAll(); + fix.detectChanges(); + + let summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 6); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['2']); + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 7); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['4']); + + treeGrid.getColumnByName('Name').summaries = fix.componentInstance.ptoSummary; + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 6); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count', 'People on PTO'], ['2', '1']); + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 7); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count', 'People on PTO'], ['3', '1']); + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count', 'People on PTO'], ['4', '0']); + + treeGrid.getCellByColumn(5, 'OnPTO').update(true); + fix.detectChanges(); + + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 6); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count', 'People on PTO'], ['2', '2']); + summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, 7); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count', 'People on PTO'], ['3', '1']); + summaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count', 'People on PTO'], ['4', '0']); + }); + + it('should render rows correctly after collapse and expand', async () => { + const fix = TestBed.createComponent(IgxTreeGridSummariesScrollingComponent); + const treeGrid = fix.componentInstance.treeGrid; + setupGridScrollDetection(fix, treeGrid); + fix.detectChanges(); + await wait(30); + fix.detectChanges(); + (treeGrid as any).scrollTo(23, 0, 0); + fix.detectChanges(); + await wait(30); + fix.detectChanges(); + + let row = treeGrid.getRowByKey(15); + row.expanded = false; + fix.detectChanges(); + await wait(30); + fix.detectChanges(); + + row = treeGrid.getRowByKey(15); + row.expanded = true; + fix.detectChanges(); + await wait(30); + fix.detectChanges(); + + expect(treeGrid.dataRowList.length).toEqual(10); + clearGridSubs(); + }); + + const verifySummaryForRow147 = (fixture, visibleIndex) => { + const summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fixture, visibleIndex); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['3']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count', 'Earliest', 'Latest'], ['3', 'Jul 19, 2009', 'Sep 18, 2014']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, + ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['3', '29', '43', '103', '34.333']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count'], ['3']); + }; + + const verifySummaryForRow317 = (fixture, visibleIndex) => { + const summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fixture, visibleIndex); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count', 'Earliest', 'Latest'], ['2', 'Nov 11, 2009', 'Oct 17, 2015']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['2', '35', '44', '79', '39.5']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count'], ['2']); + }; + + const verifySummaryForRow847 = (fixture, visibleIndex) => { + const summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fixture, visibleIndex); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['2']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count', 'Earliest', 'Latest'], ['2', 'May 4, 2014', 'Dec 9, 2017']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['2', '25', '44', '69', '34.5']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count'], ['2']); + }; + + const verifySummaryForRow663 = (fixture, visibleIndex) => { + const summaryRow = GridSummaryFunctions.getSummaryRowByDataRowIndex(fixture, visibleIndex); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['1']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count', 'Earliest', 'Latest'], ['1', 'Apr 22, 2010', 'Apr 22, 2010']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['1', '39', '39', '39', '39']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count'], ['1']); + }; + + const verifySummaryIsEmpty = (summaryRow) => { + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['0']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count', 'Earliest', 'Latest'], ['0', '', '']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['0', '0', '0', '0', '0']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count'], ['0']); + }; + + const verifyTreeBaseSummaries = (fixture) => { + const summaryRow = GridSummaryFunctions.getRootSummaryRow(fixture); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 0, [], []); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 1, ['Count'], ['4']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 2, ['Count', 'Earliest', 'Latest'], ['4', 'Apr 20, 2008', 'Feb 22, 2014']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 3, ['Count', 'Min', 'Max', 'Sum', 'Avg'], ['4', '42', '61', '207', '51.75']); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, 4, ['Count'], ['4']); + }; +}); diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.html b/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.html new file mode 100644 index 00000000000..3f435bdb30e --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.html @@ -0,0 +1,259 @@ + + + + + +
    +
    + @if (moving && columnInDrag && pinnedColumns.length <= 0) { + + } + @if (moving && columnInDrag && pinnedColumns.length > 0) { + + } + + @if (mergedDataInView && mergedDataInView.length > 0) { +
    + @for (rowData of mergedDataInView; track rowData.record;) { + + + } +
    + } + + + @if (data + | treeGridTransaction:pipeTrigger + | visibleColumns:hasVisibleColumns + | treeGridNormalizeRecord:pipeTrigger + | treeGridAddRow:true:pipeTrigger + | gridRowPinning:id:true:pipeTrigger + | treeGridFiltering:filteringExpressionsTree:filterStrategy:advancedFilteringExpressionsTree:pipeTrigger:filteringPipeTrigger:true + | treeGridSorting:sortingExpressions:treeGroupArea?.expressions:sortStrategy:pipeTrigger:true + | gridCellMerge:columnsToMerge:cellMergeMode:mergeStrategy:pipeTrigger + | gridUnmergeActive:columnsToMerge:activeRowIndexes:true:pipeTrigger; as pinnedData + ) { + @if (pinnedData.length > 0) { +
    + @for (rowData of pinnedData; track trackPinnedRowData(rowData); let rowIndex = $index) { + + + } +
    + } + } +
    + + + + + + + + + + + + + + + + + + + +
    + +
    +
    + @if (shouldOverlayLoading) { + + + } +
    + @if (moving && columnInDrag) { + + } +
    +
    +
    + +
    +
    +
    +
    + {{resourceStrings.igx_grid_snackbar_addrow_label}} +
    + +
    +
    + +
    +
    + @if (hasSummarizedColumns && rootSummariesEnabled) { + + + } +
    +
    +
    + +
    +
    +
    + + +
    +
    +
    + + + + + + {{emptyFilteredGridMessage}} + @if (showAddButton) { + + + + } + + + + + + {{emptyGridMessage}} + @if (showAddButton) { + + + + } + + + + + + + + +
    + + +
    +
    + +@if (rowEditable) { +
    +
    + + +
    +
    +} + + + {{ this.resourceStrings.igx_grid_row_edit_text | igxStringReplace:'{0}':rowChangesCount.toString() | igxStringReplace:'{1}':hiddenColumnsCount.toString() }} + + + + + + + + +
    + + + + +
    +
    +
    + + +
    +
    +
    + + + + + +@if (colResizingService.showResizer) { + +} +
    +
    +@if (platform.isElements) { +
    + + +} diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.spec.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.spec.ts new file mode 100644 index 00000000000..c661e8897ee --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.spec.ts @@ -0,0 +1,422 @@ +import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxTreeGridComponent } from './tree-grid.component'; +import { By } from '@angular/platform-browser'; +import { + IgxTreeGridWrappedInContComponent, + IgxTreeGridDefaultLoadingComponent, + IgxTreeGridCellSelectionComponent, + IgxTreeGridSummariesTransactionsComponent, + IgxTreeGridNoDataComponent, + IgxTreeGridWithNoForeignKeyComponent +} from '../../../test-utils/tree-grid-components.spec'; +import { wait } from '../../../test-utils/ui-interactions.spec'; +import { GridSelectionMode } from 'igniteui-angular/grids/core'; +import { SampleTestData } from '../../../test-utils/sample-test-data.spec'; +import { SAFE_DISPOSE_COMP_ID } from '../../../test-utils/grid-functions.spec'; +import { setElementSize } from '../../../test-utils/helper-utils.spec'; +import { IgxStringFilteringOperand, ɵSize } from 'igniteui-angular/core'; + + +describe('IgxTreeGrid Component Tests #tGrid', () => { + const TBODY_CLASS = '.igx-grid__tbody-content'; + let fix; + let grid: IgxTreeGridComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxTreeGridWrappedInContComponent, + IgxTreeGridDefaultLoadingComponent, + IgxTreeGridCellSelectionComponent, + IgxTreeGridSummariesTransactionsComponent, + IgxTreeGridNoDataComponent, + IgxTreeGridWithNoForeignKeyComponent + ] + }).compileComponents(); + })); + + describe('IgxTreeGrid - default rendering for rows and columns', () => { + + beforeEach(waitForAsync(() => { + fix = TestBed.createComponent(IgxTreeGridWrappedInContComponent); + grid = fix.componentInstance.treeGrid; + fix.detectChanges(); + })); + + it('should render 10 records if height is unset and parent container\'s height is unset', () => { + fix.detectChanges(); + const defaultHeight = fix.debugElement.query(By.css(TBODY_CLASS)).styles.height; + expect(defaultHeight).not.toBeNull(); + expect(parseInt(defaultHeight, 10)).toBeGreaterThan(400); + expect(fix.componentInstance.isVerticalScrollbarVisible()).toBeTruthy(); + expect(grid.rowList.length).toBeGreaterThanOrEqual(10); + }); + + it('should match width and height of parent container when width/height are set in %', () => { + fix.componentInstance.outerWidth = 800; + fix.componentInstance.outerHeight = 600; + grid.width = '50%'; + grid.height = '50%'; + fix.detectChanges(); + // fakeAsync is not needed. Need a second change detection cycle for height changes to be applied. + fix.detectChanges(); + + expect(window.getComputedStyle(grid.nativeElement).height).toMatch('300px'); + expect(window.getComputedStyle(grid.nativeElement).width).toMatch('400px'); + expect(grid.rowList.length).toBeGreaterThan(0); + }); + + it('should render 10 records if height is 100% and parent container\'s height is unset', () => { + grid.height = '600px'; + fix.detectChanges(); + // fakeAsync is not needed. Need a second change detection cycle for height changes to be applied. + fix.detectChanges(); + const defaultHeight = fix.debugElement.query(By.css(TBODY_CLASS)).styles.height; + expect(defaultHeight).not.toBeNull(); + expect(parseInt(defaultHeight, 10)).toBeGreaterThan(400); + expect(fix.componentInstance.isVerticalScrollbarVisible()).toBeTruthy(); + expect(grid.rowList.length).toBeGreaterThanOrEqual(10); + }); + + it(`should render all records exactly if height is 100% and parent container\'s height is unset and + there are fewer than 10 records in the data view`, () => { + grid.height = '100%'; + fix.componentInstance.data = fix.componentInstance.data.slice(0, 1); + fix.detectChanges(); + // fakeAsync is not needed. Need a second change detection cycle for height changes to be applied. + fix.detectChanges(); + const defaultHeight = fix.debugElement.query(By.css(TBODY_CLASS)).styles.height; + expect(defaultHeight).toBeFalsy(); + expect(fix.componentInstance.isVerticalScrollbarVisible()).toBeFalsy(); + expect(grid.rowList.length).toEqual(6); + }); + + it(`should render 11 records if height is 100% and parent container\'s height is unset and grid size is changed`, async () => { + grid.height = '100%'; + fix.detectChanges(); + setElementSize(grid.nativeElement, ɵSize.Small); + fix.detectChanges(); + await wait(32); // needed because of the throttleTime on the resize observer + fix.detectChanges(); + + const defaultHeight = fix.debugElement.query(By.css(TBODY_CLASS)).styles.height; + const defaultHeightNum = parseInt(defaultHeight, 10); + expect(defaultHeight).not.toBeFalsy(); + expect(defaultHeightNum).toBeGreaterThan(300); + expect(defaultHeightNum).toBeLessThanOrEqual(330); + expect(fix.componentInstance.isVerticalScrollbarVisible()).toBeTruthy(); + expect(grid.rowList.length).toEqual(11); + }); + + it('should display horizontal scroll bar when column width is set in %', () => { + fix.detectChanges(); + + grid.columnList.get(0).width = '50%'; + fix.detectChanges(); + + const horizontalScroll = fix.nativeElement.querySelector('igx-horizontal-virtual-helper'); + expect(horizontalScroll.offsetWidth).toBeGreaterThanOrEqual(782); + expect(horizontalScroll.offsetWidth).toBeLessThanOrEqual(786); + expect(horizontalScroll.children[0].offsetWidth).toBeGreaterThanOrEqual(799); + expect(horizontalScroll.children[0].offsetWidth).toBeLessThanOrEqual(801); + }); + + it('checks if attributes are correctly assigned when grid has or does not have data', fakeAsync(() => { + // Checks if igx-grid__tbody-content attribute is null when there is data in the grid + const container = fix.nativeElement.querySelectorAll('.igx-grid__tbody-content')[0]; + expect(container.getAttribute('role')).toBe(null); + + //Filter grid so no results are available and grid is empty + grid.filter('Name', '111', IgxStringFilteringOperand.instance().condition('contains'), true); + fix.detectChanges(); + fix.detectChanges(); + expect(container.getAttribute('role')).toMatch('row'); + + // clear grid data and check if attribute is now 'row' + grid.clearFilter(); + fix.componentInstance.clearData(); + fix.detectChanges(); + tick(); + + expect(container.getAttribute('role')).toMatch('row'); + })); + + it('should display flat data even if no foreignKey is set', () => { + fix = TestBed.createComponent(IgxTreeGridWithNoForeignKeyComponent); + grid = fix.componentInstance.treeGrid; + fix.detectChanges(); + + expect(grid.dataView.length).toBeGreaterThan(0); + }); + + it('should throw a warning when primaryKey is set to a non-existing data field', () => { + jasmine.getEnv().allowRespy(true); + const warnSpy = spyOn(console, 'warn'); + grid.primaryKey = 'testField'; + fix.detectChanges(); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + `Field "${grid.primaryKey}" is not defined in the data. Set \`primaryKey\` to a valid field.` + ); + warnSpy.calls.reset(); + + const oldData = fix.componentInstance.data; + const newData = fix.componentInstance.data.map(rec => Object.assign({}, rec, { testField: 0 })); + fix.componentInstance.data = newData; + fix.detectChanges(); + + expect(console.warn).toHaveBeenCalledTimes(0); + + fix.componentInstance.data = oldData; + fix.detectChanges(); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + `Field "${grid.primaryKey}" is not defined in the data. Set \`primaryKey\` to a valid field.` + ); + jasmine.getEnv().allowRespy(false); + }); + }); + + describe('Auto-generated columns', () => { + beforeEach(waitForAsync(() => { + fix = TestBed.createComponent(IgxTreeGridComponent); + grid = fix.componentInstance; + grid.autoGenerate = true; + + // When doing pure unit tests, the grid doesn't get removed after the test, because it overrides + // the element ID and the testbed cannot find it to remove it. + // The testbed is looking up components by [id^=root], so working around this by forcing root id + grid.id = SAFE_DISPOSE_COMP_ID; + })); + + // afterEach(() => { + // // When doing pure unit tests, the grid doesn't get removed after the test, because it overrides + // // the element ID and the testbed cannot find it to remove it. + // // this is needed when we don't force a root id + // grid.ngOnDestroy(); + // element.remove(); + // }); + + it('should auto-generate all columns', fakeAsync(() => { + grid.data = []; + tick(); + fix.detectChanges(); + + grid.data = SampleTestData.employeePrimaryForeignKeyTreeData(); + tick(); + fix.detectChanges(); + + grid.primaryKey = 'ID'; + grid.foreignKey = 'ParentID'; + tick(); + fix.detectChanges(); + + const expectedColumns = [...Object.keys(grid.data[0])]; + + expect(grid.columns.map(c => c.field)).toEqual(expectedColumns); + // Verify that records are also rendered by checking the first record cell + expect(grid.getCellByColumn(0, 'ID').value).toEqual(1); + })); + + it('should auto-generate columns without childDataKey', fakeAsync(() => { + grid.data = []; + tick(); + fix.detectChanges(); + + grid.childDataKey = 'Employees'; + tick(); + fix.detectChanges(); + + grid.data = SampleTestData.employeeAllTypesTreeData(); + tick(); + fix.detectChanges(); + + const expectedColumns = [...Object.keys(grid.data[0])].filter(col => col !== grid.childDataKey); + + // Employees shouldn't be in the columns + expect(grid.columns.map(c => c.field)).toEqual(expectedColumns); + // Verify that records are also rendered by checking the first record cell + expect(grid.getCellByColumn(0, 'ID').value).toEqual(147); + })); + + it('should recreate columns when data changes and autoGenerate is true', fakeAsync(() => { + grid.width = '500px'; + grid.height = '500px'; + grid.autoGenerate = true; + fix.detectChanges(); + + const initialData = [ + { id: 1, name: 'John' }, + { id: 2, name: 'Jane' } + ]; + grid.data = initialData; + tick(); + fix.detectChanges(); + + expect(grid.columns.length).toBe(2); + expect(grid.columns[0].field).toBe('id'); + expect(grid.columns[1].field).toBe('name'); + + const newData = [ + { id: 1, firstName: 'John', lastName: 'Doe' }, + { id: 2, firstName: 'Jane', lastName: 'Smith' } + ]; + grid.data = newData; + tick(); + fix.detectChanges(); + + expect(grid.columns.length).toBe(3); + expect(grid.columns[0].field).toBe('id'); + expect(grid.columns[1].field).toBe('firstName'); + expect(grid.columns[2].field).toBe('lastName'); + })); + }); + + describe('Loading Template', () => { + beforeEach(waitForAsync(() => { + fix = TestBed.createComponent(IgxTreeGridDefaultLoadingComponent); + grid = fix.componentInstance.treeGrid; + })); + + it('should auto-generate columns', async () => { + fix.detectChanges(); + const gridElement = fix.debugElement.query(By.css('.igx-grid')); + let loadingIndicator = gridElement.query(By.css('.igx-grid__loading')); + expect(loadingIndicator).not.toBeNull(); + expect(grid.dataRowList.length).toBe(0); + + await wait(1000); + fix.detectChanges(); + loadingIndicator = gridElement.query(By.css('.igx-grid__loading')); + expect(loadingIndicator).toBeNull(); + expect(grid.dataRowList.length).toBeGreaterThan(0); + }); + }); + + describe('Hide All', () => { + beforeEach(waitForAsync(() => { + fix = TestBed.createComponent(IgxTreeGridCellSelectionComponent); + grid = fix.componentInstance.treeGrid; + fix.detectChanges(); + })); + + it('should not render rows and headers group when all cols are hidden', fakeAsync(() => { + grid.rowSelection = GridSelectionMode.multiple; + grid.rowDraggable = true; + tick(); + fix.detectChanges(); + + let fixEl = fix.nativeElement; let gridEl = grid.nativeElement; + let tHeadItems = fixEl.querySelector('igx-grid-header-group'); + let gridRows = fixEl.querySelector('igx-tree-grid-row'); + let paging = fixEl.querySelector('.igx-paginator'); + let rowSelectors = gridEl.querySelector('.igx-checkbox'); + let dragIndicators = gridEl.querySelector('.igx-grid__drag-indicator'); + let verticalScrollBar = gridEl.querySelector('.igx-grid__tbody-scrollbar[hidden]'); + + expect(tHeadItems).not.toBeNull(); + expect(gridRows).not.toBeNull(); + expect(paging).not.toBeNull(); + expect(rowSelectors).not.toBeNull(); + expect(dragIndicators).not.toBeNull(); + expect(verticalScrollBar).toBeNull(); + + grid.columnList.forEach((col) => col.hidden = true); + tick(); + fix.detectChanges(); + fixEl = fix.nativeElement; + gridEl = grid.nativeElement; + + tHeadItems = fixEl.querySelector('igx-grid-header-group'); + gridRows = fixEl.querySelector('igx-tree-grid-row'); + paging = fixEl.querySelector('.igx-paginator'); + rowSelectors = gridEl.querySelector('.igx-checkbox'); + dragIndicators = gridEl.querySelector('.igx-grid__drag-indicator'); + verticalScrollBar = gridEl.querySelector('.igx-grid__tbody-scrollbar[hidden]'); + + expect(tHeadItems).toBeNull(); + expect(gridRows).toBeNull(); + expect(paging).not.toBeNull(); + expect(rowSelectors).toBeNull(); + expect(dragIndicators).toBeNull(); + expect(verticalScrollBar).not.toBeNull(); + })); + + }); + + describe('Setting null data', () => { + it('should not throw error when data is null', () => { + fix = TestBed.createComponent(IgxTreeGridNoDataComponent); + fix.componentInstance.treeGrid.batchEditing = true; + expect(() => fix.detectChanges()).not.toThrow(); + }); + + it('should not throw error when data is set to null', () => { + fix = TestBed.createComponent(IgxTreeGridCellSelectionComponent); + fix.componentInstance.data = null; + expect(() => fix.detectChanges()).not.toThrow(); + }); + + it('should not throw error when data is set to null and transactions are enabled', () => { + fix = TestBed.createComponent(IgxTreeGridSummariesTransactionsComponent); + fix.componentInstance.data = null; + expect(() => fix.detectChanges()).not.toThrow(); + }); + + it('should not throw error when data is null and row is pinned', () => { + fix = TestBed.createComponent(IgxTreeGridNoDataComponent); + grid = fix.componentInstance.treeGrid; + grid.pinRow(4); + expect(() => fix.detectChanges()).not.toThrow(); + }); + }); + + describe('Displaying empty grid message', () => { + beforeEach(waitForAsync(() => { + fix = TestBed.createComponent(IgxTreeGridWrappedInContComponent); + grid = fix.componentInstance.treeGrid; + fix.detectChanges(); + })); + + it('should display empty grid message when there is no data', () => { + const data: any[] = grid.data; + grid.data = []; + fix.detectChanges(); + let emptyGridMessage = fix.debugElement.query(By.css('.igx-grid__tbody-message')); + expect(emptyGridMessage).toBeTruthy(); + expect(emptyGridMessage.nativeElement.innerText).toBe('Grid has no data.'); + + grid.data = data; + fix.detectChanges(); + emptyGridMessage = fix.debugElement.query(By.css('.igx-grid__tbody-message')); + expect(emptyGridMessage).toBeFalsy(); + }); + + it('should display empty grid message when last row is deleted', () => { + grid.data = []; + grid.addRow({ + ID: 0, + Name: 'John Winchester', + HireDate: new Date(2008, 3, 20), + Age: 55, + OnPTO: false, + Employees: [] + }); + + fix.detectChanges(); + let emptyGridMessage = fix.debugElement.query(By.css('.igx-grid__tbody-message')); + expect(emptyGridMessage).toBeFalsy(); + + grid.deleteRowById(0); + fix.detectChanges(); + emptyGridMessage = fix.debugElement.query(By.css('.igx-grid__tbody-message')); + expect(emptyGridMessage).toBeTruthy(); + expect(emptyGridMessage.nativeElement.innerText).toBe('Grid has no data.'); + }); + }); + +}); diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.ts new file mode 100644 index 00000000000..111e589abb0 --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid.component.ts @@ -0,0 +1,1108 @@ +import { ChangeDetectionStrategy, Component, HostBinding, Input, OnInit, TemplateRef, ContentChild, AfterContentInit, ViewChild, DoCheck, AfterViewInit, CUSTOM_ELEMENTS_SCHEMA, booleanAttribute, inject } from '@angular/core'; +import { NgClass, NgTemplateOutlet, NgStyle } from '@angular/common'; +import { IgxTreeGridAPIService } from './tree-grid-api.service'; +import { IgxGridBaseDirective, IgxGridCellMergePipe, IgxGridUnmergeActivePipe } from 'igniteui-angular/grids/grid'; +import { + CellType, + GridSelectionMode, + GridType, + IGX_GRID_BASE, + IGX_GRID_SERVICE_BASE, + IgxColumnComponent, + IgxColumnMovingDropDirective, + IgxColumnResizingService, + IgxFilteringService, + IgxGridBodyDirective, + IgxGridCell, + IgxGridColumnResizerComponent, + IgxGridCRUDService, + IgxGridDragSelectDirective, + IgxGridHeaderRowComponent, + IgxGridNavigationService, + IgxGridRowClassesPipe, + IgxGridRowPinningPipe, + IgxGridRowStylesPipe, + IgxGridSelectionService, + IgxGridSummaryService, + IgxGridTransaction, + IgxGridValidationService, + IgxHasVisibleColumnsPipe, + IgxRowEditTabStopDirective, + IgxStringReplacePipe, + IgxSummaryDataPipe, + IgxSummaryRow, + IgxSummaryRowComponent, + IgxTreeGridRow, + IRowDataCancelableEventArgs, + IRowDataEventArgs, + IRowToggleEventArgs, + RowType +} from 'igniteui-angular/grids/core'; +import { first, takeUntil } from 'rxjs/operators'; +import { IgxRowLoadingIndicatorTemplateDirective } from './tree-grid.directives'; +import { IgxTreeGridSelectionService } from './tree-grid-selection.service'; +import { DefaultTreeGridMergeStrategy, HierarchicalState, HierarchicalTransaction, HierarchicalTransactionService, IGridMergeStrategy, IgxHierarchicalTransactionFactory, IgxOverlayOutletDirective, ITreeGridRecord, mergeObjects, StateUpdateEvent, TransactionEventOrigin, TransactionType, TreeGridFilteringStrategy } from 'igniteui-angular/core'; +import { IgxTreeGridSummaryPipe } from './tree-grid.summary.pipe'; +import { IgxTreeGridFilteringPipe } from './tree-grid.filtering.pipe'; +import { IgxTreeGridHierarchizingPipe, IgxTreeGridFlatteningPipe, IgxTreeGridSortingPipe, IgxTreeGridPagingPipe, IgxTreeGridTransactionPipe, IgxTreeGridNormalizeRecordsPipe, IgxTreeGridAddRowPipe } from './tree-grid.pipes'; +import { IgxTreeGridRowComponent } from './tree-grid-row.component'; +import { IgxButtonDirective, IgxForOfScrollSyncService, IgxForOfSyncService, IgxGridForOfDirective, IgxRippleDirective, IgxScrollInertiaDirective, IgxTemplateOutletDirective, IgxToggleDirective } from 'igniteui-angular/directives'; +import { IgxCircularProgressBarComponent } from 'igniteui-angular/progressbar'; +import { IgxSnackbarComponent } from 'igniteui-angular/snackbar'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxTreeGridGroupByAreaComponent } from './tree-grid-group-by-area.component'; + +let NEXT_ID = 0; + +/* blazorAdditionalDependency: Column */ +/* blazorAdditionalDependency: ColumnGroup */ +/* blazorAdditionalDependency: ColumnLayout */ +/* blazorAdditionalDependency: GridToolbar */ +/* blazorAdditionalDependency: GridToolbarActions */ +/* blazorAdditionalDependency: GridToolbarTitle */ +/* blazorAdditionalDependency: GridToolbarAdvancedFiltering */ +/* blazorAdditionalDependency: GridToolbarExporter */ +/* blazorAdditionalDependency: GridToolbarHiding */ +/* blazorAdditionalDependency: GridToolbarPinning */ +/* blazorAdditionalDependency: ActionStrip */ +/* blazorAdditionalDependency: GridActionsBaseDirective */ +/* blazorAdditionalDependency: GridEditingActions */ +/* blazorAdditionalDependency: GridPinningActions */ +/* blazorIndirectRender */ +/** + * **Ignite UI for Angular Tree Grid** - + * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/grid/grid) + * + * The Ignite UI Tree Grid displays and manipulates hierarchical data with consistent schema formatted as a table and + * provides features such as sorting, filtering, editing, column pinning, paging, column moving and hiding. + * + * Example: + * ```html + * + * + * + * + * + * ``` + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'igx-tree-grid', + templateUrl: 'tree-grid.component.html', + providers: [ + IgxGridCRUDService, + IgxGridValidationService, + IgxGridSummaryService, + IgxGridNavigationService, + { provide: IgxGridSelectionService, useClass: IgxTreeGridSelectionService }, + { provide: IGX_GRID_SERVICE_BASE, useClass: IgxTreeGridAPIService }, + { provide: IGX_GRID_BASE, useExisting: IgxTreeGridComponent }, + IgxFilteringService, + IgxColumnResizingService, + IgxForOfSyncService, + IgxForOfScrollSyncService + ], + imports: [ + NgClass, + NgStyle, + NgTemplateOutlet, + IgxGridHeaderRowComponent, + IgxGridBodyDirective, + IgxGridDragSelectDirective, + IgxColumnMovingDropDirective, + IgxGridForOfDirective, + IgxTemplateOutletDirective, + IgxTreeGridRowComponent, + IgxSummaryRowComponent, + IgxOverlayOutletDirective, + IgxToggleDirective, + IgxCircularProgressBarComponent, + IgxSnackbarComponent, + IgxButtonDirective, + IgxRippleDirective, + IgxRowEditTabStopDirective, + IgxIconComponent, + IgxGridColumnResizerComponent, + IgxHasVisibleColumnsPipe, + IgxGridRowPinningPipe, + IgxGridRowClassesPipe, + IgxGridRowStylesPipe, + IgxSummaryDataPipe, + IgxTreeGridHierarchizingPipe, + IgxTreeGridFlatteningPipe, + IgxTreeGridSortingPipe, + IgxTreeGridFilteringPipe, + IgxTreeGridPagingPipe, + IgxTreeGridTransactionPipe, + IgxTreeGridSummaryPipe, + IgxTreeGridNormalizeRecordsPipe, + IgxTreeGridAddRowPipe, + IgxStringReplacePipe, + IgxGridCellMergePipe, + IgxScrollInertiaDirective, + IgxGridUnmergeActivePipe + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] +}) +export class IgxTreeGridComponent extends IgxGridBaseDirective implements GridType, OnInit, AfterViewInit, DoCheck, AfterContentInit { + protected override _diTransactions = inject>(IgxGridTransaction, { optional: true, }); + protected override transactionFactory = inject(IgxHierarchicalTransactionFactory); + + /** + * Sets the child data key of the `IgxTreeGridComponent`. + * ```html + * + * ``` + * + * @memberof IgxTreeGridComponent + */ + @Input() + public childDataKey: string; + + /** + * Sets the foreign key of the `IgxTreeGridComponent`. + * ```html + * + * + * ``` + * + * @memberof IgxTreeGridComponent + */ + @Input() + public foreignKey: string; + + /** + * Sets the key indicating whether a row has children. + * This property is only used for load on demand scenarios. + * ```html + * + * + * ``` + * + * @memberof IgxTreeGridComponent + */ + @Input() + public hasChildrenKey: string; + + /** + * Sets whether child records should be deleted when their parent gets deleted. + * By default it is set to true and deletes all children along with the parent. + * ```html + * + * + * ``` + * + * @memberof IgxTreeGridComponent + */ + @Input({ transform: booleanAttribute }) + public cascadeOnDelete = true; + + /* csSuppress */ + /** + * Sets a callback for loading child rows on demand. + * ```html + * + * + * ``` + * ```typescript + * public loadChildren = (parentID: any, done: (children: any[]) => void) => { + * this.dataService.getData(parentID, children => done(children)); + * } + * ``` + * + * @memberof IgxTreeGridComponent + */ + @Input() + public loadChildrenOnDemand: (parentID: any, done: (children: any[]) => void) => void; + + /** + * @hidden @internal + */ + @HostBinding('attr.role') + public role = 'treegrid'; + + /** + * Sets the value of the `id` attribute. If not provided it will be automatically generated. + * ```html + * + * ``` + * + * @memberof IgxTreeGridComponent + */ + @HostBinding('attr.id') + @Input() + public id = `igx-tree-grid-${NEXT_ID++}`; + + /** + * @hidden + * @internal + */ + @ContentChild(IgxTreeGridGroupByAreaComponent, { read: IgxTreeGridGroupByAreaComponent }) + public treeGroupArea: IgxTreeGridGroupByAreaComponent; + + /** + * @hidden @internal + */ + @ViewChild('record_template', { read: TemplateRef, static: true }) + protected recordTemplate: TemplateRef; + + /** + * @hidden @internal + */ + @ViewChild('summary_template', { read: TemplateRef, static: true }) + protected summaryTemplate: TemplateRef; + + /** + * @hidden + */ + @ContentChild(IgxRowLoadingIndicatorTemplateDirective, { read: IgxRowLoadingIndicatorTemplateDirective }) + protected rowLoadingTemplate: IgxRowLoadingIndicatorTemplateDirective; + + /** + * @hidden + */ + public flatData: any[] | null; + + /** + * @hidden + */ + public processedExpandedFlatData: any[] | null; + + /** + * Returns an array of the root level `ITreeGridRecord`s. + * ```typescript + * // gets the root record with index=2 + * const states = this.grid.rootRecords[2]; + * ``` + * + * @memberof IgxTreeGridComponent + */ + public rootRecords: ITreeGridRecord[]; + + /* blazorSuppress */ + /** + * Returns a map of all `ITreeGridRecord`s. + * ```typescript + * // gets the record with primaryKey=2 + * const states = this.grid.records.get(2); + * ``` + * + * @memberof IgxTreeGridComponent + */ + public records: Map = new Map(); + + /** + * Returns an array of processed (filtered and sorted) root `ITreeGridRecord`s. + * ```typescript + * // gets the processed root record with index=2 + * const states = this.grid.processedRootRecords[2]; + * ``` + * + * @memberof IgxTreeGridComponent + */ + public processedRootRecords: ITreeGridRecord[]; + + /* blazorSuppress */ + /** + * Returns a map of all processed (filtered and sorted) `ITreeGridRecord`s. + * ```typescript + * // gets the processed record with primaryKey=2 + * const states = this.grid.processedRecords.get(2); + * ``` + * + * @memberof IgxTreeGridComponent + */ + public processedRecords: Map = new Map(); + + /** + * @hidden + */ + public loadingRows = new Set(); + + protected override _filterStrategy = new TreeGridFilteringStrategy(); + protected override _transactions: HierarchicalTransactionService; + protected override _mergeStrategy: IGridMergeStrategy = new DefaultTreeGridMergeStrategy(); + private _data; + private _rowLoadingIndicatorTemplate: TemplateRef; + private _expansionDepth = Infinity; + + /* treatAsRef */ + /** + * Gets/Sets the array of data that populates the component. + * ```html + * + * ``` + * + * @memberof IgxTreeGridComponent + */ + @Input() + public get data(): any[] | null { + return this._data; + } + + /* treatAsRef */ + public set data(value: any[] | null) { + const oldData = this._data; + this._data = value || []; + this.summaryService.clearSummaryCache(); + if (!this._init) { + this.validation.updateAll(this._data); + } + if (this.autoGenerate && this._data.length > 0 && this.shouldRecreateColumns(oldData, this._data)) { + this.setupColumns(); + } + this.checkPrimaryKeyField(); + this.cdr.markForCheck(); + } + + /** @hidden @internal */ + public override get type(): GridType["type"] { + return 'tree'; + } + + /** + * Get transactions service for the grid. + * + * @experimental @hidden + */ + public override get transactions() { + if (this._diTransactions && !this.batchEditing) { + return this._diTransactions; + } + return this._transactions; + } + + /** + * Sets the count of levels to be expanded in the `IgxTreeGridComponent`. By default it is + * set to `Infinity` which means all levels would be expanded. + * ```html + * + * ``` + * + * @memberof IgxTreeGridComponent + */ + @Input() + public get expansionDepth(): number { + return this._expansionDepth; + } + + public set expansionDepth(value: number) { + this._expansionDepth = value; + this.notifyChanges(); + } + + /** + * Template for the row loading indicator when load on demand is enabled. + * ```html + * + * loop + * + * + * + * + * ``` + * + * @memberof IgxTreeGridComponent + */ + @Input() + public get rowLoadingIndicatorTemplate(): TemplateRef { + return this._rowLoadingIndicatorTemplate; + } + + public set rowLoadingIndicatorTemplate(value: TemplateRef) { + this._rowLoadingIndicatorTemplate = value; + this.notifyChanges(); + } + + // Kind of stupid + // private get _gridAPI(): IgxTreeGridAPIService { + // return this.gridAPI as IgxTreeGridAPIService; + // } + + /** + * @hidden + */ + public override ngOnInit() { + super.ngOnInit(); + + this.rowToggle.pipe(takeUntil(this.destroy$)).subscribe((args) => { + this.loadChildrenOnRowExpansion(args); + }); + + // TODO: cascade selection logic should be refactor to be handled in the already existing subs + this.rowAddedNotifier.pipe(takeUntil(this.destroy$)).subscribe(args => { + if (this.rowSelection === GridSelectionMode.multipleCascade) { + let rec = this.gridAPI.get_rec_by_id(this.primaryKey ? args.data[this.primaryKey] : args.data); + if (rec && rec.parent) { + this.gridAPI.grid.selectionService.updateCascadeSelectionOnFilterAndCRUD( + new Set([rec.parent]), rec.parent.key); + } else { + // The record is still not available + // Wait for the change detection to update records through pipes + requestAnimationFrame(() => { + rec = this.gridAPI.get_rec_by_id(this.primaryKey ? + args.data[this.primaryKey] : args.data); + if (rec && rec.parent) { + this.gridAPI.grid.selectionService.updateCascadeSelectionOnFilterAndCRUD( + new Set([rec.parent]), rec.parent.key); + } + this.notifyChanges(); + }); + } + } + }); + + this.rowDeletedNotifier.pipe(takeUntil(this.destroy$)).subscribe(args => { + if (this.rowSelection === GridSelectionMode.multipleCascade) { + if (args.data) { + const rec = this.gridAPI.get_rec_by_id( + this.primaryKey ? args.data[this.primaryKey] : args.data); + this.handleCascadeSelection(args, rec); + } else { + // if a row has been added and before commiting the transaction deleted + const leafRowsDirectParents = new Set(); + this.records.forEach(record => { + if (record && (!record.children || record.children.length === 0) && record.parent) { + leafRowsDirectParents.add(record.parent); + } + }); + // Wait for the change detection to update records through pipes + requestAnimationFrame(() => { + this.gridAPI.grid.selectionService.updateCascadeSelectionOnFilterAndCRUD(leafRowsDirectParents); + this.notifyChanges(); + }); + } + } + }); + + this.filteringDone.pipe(takeUntil(this.destroy$)).subscribe(() => { + if (this.rowSelection === GridSelectionMode.multipleCascade) { + const leafRowsDirectParents = new Set(); + this.records.forEach(record => { + if (record && (!record.children || record.children.length === 0) && record.parent) { + leafRowsDirectParents.add(record.parent); + } + }); + this.gridAPI.grid.selectionService.updateCascadeSelectionOnFilterAndCRUD(leafRowsDirectParents); + this.notifyChanges(); + } + }); + } + + /** + * @hidden + */ + public override ngAfterViewInit() { + super.ngAfterViewInit(); + // TODO: pipesExectured event + // run after change detection in super triggers pipes for records structure + if (this.rowSelection === GridSelectionMode.multipleCascade && this.selectedRows.length) { + const selRows = this.selectedRows; + this.selectionService.clearRowSelection(); + this.selectRows(selRows, true); + this.cdr.detectChanges(); + } + } + + /** + * @hidden + */ + public override ngAfterContentInit() { + if (this.rowLoadingTemplate) { + this._rowLoadingIndicatorTemplate = this.rowLoadingTemplate.template; + } + super.ngAfterContentInit(); + } + + public override getDefaultExpandState(record: ITreeGridRecord): boolean { + return record.children && record.children.length && record.level < this.expansionDepth; + } + + /** + * Expands all rows. + * ```typescript + * this.grid.expandAll(); + * ``` + * + * @memberof IgxTreeGridComponent + */ + public override expandAll() { + this._expansionDepth = Infinity; + this.expansionStates = new Map(); + } + + /** + * Collapses all rows. + * + * ```typescript + * this.grid.collapseAll(); + * ``` + * + * @memberof IgxTreeGridComponent + */ + public override collapseAll() { + this._expansionDepth = 0; + this.expansionStates = new Map(); + } + + /** + * @hidden + */ + public override refreshGridState(args?: IRowDataEventArgs) { + super.refreshGridState(); + if (this.primaryKey && this.foreignKey && args) { + const rowID = args.data[this.foreignKey]; + this.summaryService.clearSummaryCache({ rowID }); + this.pipeTrigger++; + this.cdr.detectChanges(); + } + } + + /* blazorCSSuppress */ + /** + * Creates a new `IgxTreeGridRowComponent` with the given data. If a parentRowID is not specified, the newly created + * row would be added at the root level. Otherwise, it would be added as a child of the row whose primaryKey matches + * the specified parentRowID. If the parentRowID does not exist, an error would be thrown. + * ```typescript + * const record = { + * ID: this.grid.data[this.grid1.data.length - 1].ID + 1, + * Name: this.newRecord + * }; + * this.grid.addRow(record, 1); // Adds a new child row to the row with ID=1. + * ``` + * + * @param data + * @param parentRowID + * @memberof IgxTreeGridComponent + */ + // TODO: remove evt emission + public override addRow(data: any, parentRowID?: any) { + this.crudService.endEdit(true); + this.gridAPI.addRowToData(data, parentRowID); + + this.rowAddedNotifier.next({ + data: data, + rowData: data, owner: this, + primaryKey: data[this.primaryKey], + rowKey: data[this.primaryKey] + }); + this.pipeTrigger++; + this.notifyChanges(); + } + + /** + * Enters add mode by spawning the UI with the context of the specified row by index. + * + * @remarks + * Accepted values for index are integers from 0 to this.grid.dataView.length + * @remarks + * When adding the row as a child, the parent row is the specified row. + * @remarks + * To spawn the UI on top, call the function with index = null or a negative number. + * In this case trying to add this row as a child will result in error. + * @example + * ```typescript + * this.grid.beginAddRowByIndex(10); + * this.grid.beginAddRowByIndex(10, true); + * this.grid.beginAddRowByIndex(null); + * ``` + * @param index - The index to spawn the UI at. Accepts integers from 0 to this.grid.dataView.length + * @param asChild - Whether the record should be added as a child. Only applicable to igxTreeGrid. + */ + public override beginAddRowByIndex(index: number, asChild?: boolean): void { + if (index === null || index < 0) { + return this.beginAddRowById(null, asChild); + } + return this._addRowForIndex(index - 1, asChild); + } + + /** + * @hidden + */ + public getContext(rowData: any, rowIndex: number, pinned?: boolean): any { + return { + $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, + metaData: this.isRecordMerged(rowData) ? rowData : null + }; + } + + /** + * @hidden + * @internal + */ + public override getInitialPinnedIndex(rec) { + const id = this.gridAPI.get_row_id(rec); + return this._pinnedRecordIDs.indexOf(id); + } + + /** + * @hidden + * @internal + */ + public override isRecordPinned(rec) { + return this.getInitialPinnedIndex(rec.data) !== -1; + } + + /** + * + * Returns an array of the current cell selection in the form of `[{ column.field: cell.value }, ...]`. + * + * @remarks + * If `formatters` is enabled, the cell value will be formatted by its respective column formatter (if any). + * If `headers` is enabled, it will use the column header (if any) instead of the column field. + */ + public override getSelectedData(formatters = false, headers = false): any[] { + let source = []; + + const process = (record) => { + if (record.summaries) { + source.push(null); + return; + } + source.push(record.data); + }; + + this.unpinnedDataView.forEach(process); + source = this.isRowPinningToTop ? [...this.pinnedDataView, ...source] : [...source, ...this.pinnedDataView]; + return this.extractDataFromSelection(source, formatters, headers); + } + + /** + * @hidden @internal + */ + public override getEmptyRecordObjectFor(inTreeRow: RowType) { + const treeRowRec = inTreeRow?.treeRow || null; + const row = { ...treeRowRec }; + const data = treeRowRec?.data || {}; + row.data = { ...data }; + Object.keys(row.data).forEach(key => { + // persist foreign key if one is set. + if (this.foreignKey && key === this.foreignKey) { + row.data[key] = treeRowRec.data[this.crudService.addRowParent?.asChild ? this.primaryKey : key]; + } else { + row.data[key] = undefined; + } + }); + let id = this.generateRowID(); + const rootRecPK = this.foreignKey && this.rootRecords && this.rootRecords.length > 0 ? + this.rootRecords[0].data[this.foreignKey] : null; + if (id === rootRecPK) { + // safeguard in case generated id matches the root foreign key. + id = this.generateRowID(); + } + row.key = id; + row.data[this.primaryKey] = id; + return { rowID: id, data: row.data, recordRef: row }; + } + + /** @hidden */ + public override deleteRowById(rowId: any): any { + // 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 args: IRowDataCancelableEventArgs = { + rowID: rowId, + primaryKey: rowId, + rowKey: rowId, + cancel: false, + rowData: this.getRowData(rowId), + data: this.getRowData(rowId), + oldValue: null, + owner: this + }; + this.rowDelete.emit(args); + if (args.cancel) { + return; + } + + const record = this.gridAPI.deleteRowById(rowId); + const key = record[this.primaryKey]; + if (record !== null && record !== undefined) { + const rowDeletedEventArgs: IRowDataEventArgs = { + data: record, + rowData: record, + owner: this, + primaryKey: key, + rowKey: key + }; + this.rowDeleted.emit(rowDeletedEventArgs); + } + return record; + } + + /** + * Returns the `IgxTreeGridRow` by index. + * + * @example + * ```typescript + * const myRow = treeGrid.getRowByIndex(1); + * ``` + * @param index + */ + public getRowByIndex(index: number): RowType { + if (index < 0 || index >= this.dataView.length) { + return undefined; + } + return this.createRow(index); + } + + /** + * Returns the `RowType` object by the specified primary key. + * + * @example + * ```typescript + * const myRow = this.treeGrid.getRowByIndex(1); + * ``` + * @param index + */ + public getRowByKey(key: any): RowType { + const rec = this.filteredSortedData ? this.primaryKey ? this.filteredSortedData.find(r => r[this.primaryKey] === key) : + this.filteredSortedData.find(r => r === key) : undefined; + const index = this.dataView.findIndex(r => r.data && r.data === rec); + if (index < 0 || index >= this.filteredSortedData.length) { + return undefined; + } + return new IgxTreeGridRow(this as any, index, rec); + } + + /** + * Returns the collection of all RowType for current page. + * + * @hidden @internal + */ + public allRows(): RowType[] { + return this.dataView.map((rec, index) => this.createRow(index)); + } + + /** + * Returns the collection of `IgxTreeGridRow`s for current page. + * + * @hidden @internal + */ + public dataRows(): RowType[] { + return this.allRows().filter(row => row instanceof IgxTreeGridRow); + } + + /** + * Returns an array of the selected `IgxGridCell`s. + * + * @example + * ```typescript + * const selectedCells = this.grid.selectedCells; + * ``` + */ + public get selectedCells(): CellType[] { + return this.dataRows().map((row) => row.cells.filter((cell) => cell.selected)) + .reduce((a, b) => a.concat(b), []); + } + + /** + * Returns a `CellType` object that matches the conditions. + * + * @example + * ```typescript + * const myCell = this.grid1.getCellByColumn(2, "UnitPrice"); + * ``` + * @param rowIndex + * @param columnField + */ + public getCellByColumn(rowIndex: number, columnField: string): CellType { + const row = this.getRowByIndex(rowIndex); + const column = this.columns.find((col) => col.field === columnField); + if (row && row instanceof IgxTreeGridRow && column) { + return new IgxGridCell(this as any, rowIndex, column); + } + } + + /** + * Returns a `CellType` object that matches the conditions. + * + * @remarks + * Requires that the primaryKey property is set. + * @example + * ```typescript + * grid.getCellByKey(1, 'index'); + * ``` + * @param rowSelector match any rowID + * @param columnField + */ + public getCellByKey(rowSelector: any, columnField: string): CellType { + const row = this.getRowByKey(rowSelector); + const column = this.columns.find((col) => col.field === columnField); + if (row && column) { + return new IgxGridCell(this as any, row.index, column); + } + } + + public override pinRow(rowID: any, index?: number): boolean { + const row = this.getRowByKey(rowID); + return super.pinRow(rowID, index, row); + } + + public override unpinRow(rowID: any): boolean { + const row = this.getRowByKey(rowID); + return super.unpinRow(rowID, row); + } + + /** @hidden */ + public generateRowPath(rowId: any): any[] { + const path: any[] = []; + let record = this.records.get(rowId); + + while (record.parent) { + path.push(record.parent.key); + record = record.parent; + } + + return path.reverse(); + } + + /** @hidden */ + public isTreeRow(record: any): boolean { + return record.key !== undefined && record.data; + } + + /** @hidden */ + public override getUnpinnedIndexById(id) { + return this.unpinnedRecords.findIndex(x => x.data[this.primaryKey] === id); + } + + /** + * @hidden + */ + public createRow(index: number, data?: any): RowType { + let row: RowType; + const dataIndex = this._getDataViewIndex(index); + const rec: any = data ?? this.dataView[dataIndex]; + + if (this.isSummaryRow(rec)) { + row = new IgxSummaryRow(this as any, index, rec.summaries); + } + + if (!row && rec) { + const isTreeRow = this.isTreeRow(rec); + const dataRec = isTreeRow ? rec.data : rec; + const treeRow = isTreeRow ? rec : undefined; + row = new IgxTreeGridRow(this as any, index, dataRec, treeRow); + } + + return row; + } + + protected override generateDataFields(data: any[]): string[] { + return super.generateDataFields(data).filter(field => field !== this.childDataKey); + } + + protected override transactionStatusUpdate(event: StateUpdateEvent) { + let actions = []; + if (event.origin === TransactionEventOrigin.REDO) { + actions = event.actions ? event.actions.filter(x => x.transaction.type === TransactionType.DELETE) : []; + if (this.rowSelection === GridSelectionMode.multipleCascade) { + this.handleCascadeSelection(event); + } + } else if (event.origin === TransactionEventOrigin.UNDO) { + actions = event.actions ? event.actions.filter(x => x.transaction.type === TransactionType.ADD) : []; + if (this.rowSelection === GridSelectionMode.multipleCascade) { + if (event.actions[0].transaction.type === 'add') { + const rec = this.gridAPI.get_rec_by_id(event.actions[0].transaction.id); + this.handleCascadeSelection(event, rec); + } else { + this.handleCascadeSelection(event); + } + } + } + if (actions.length) { + for (const action of actions) { + this.deselectChildren(action.transaction.id); + } + } + super.transactionStatusUpdate(event); + } + + protected findRecordIndexInView(rec) { + return this.dataView.findIndex(x => x.data[this.primaryKey] === rec[this.primaryKey]); + } + + /** + * @hidden @internal + */ + protected override getDataBasedBodyHeight(): number { + return !this.flatData || (this.flatData.length < this._defaultTargetRecordNumber) ? + 0 : this.defaultTargetBodyHeight; + } + + /** + * @hidden + */ + protected override scrollTo(row: any | number, column: any | number): void { + let delayScrolling = false; + let record: ITreeGridRecord; + + if (typeof (row) !== 'number') { + const rowData = row; + const rowID = this.gridAPI.get_row_id(rowData); + record = this.processedRecords.get(rowID); + this.gridAPI.expand_path_to_record(record); + + if (this.paginator) { + const rowIndex = this.processedExpandedFlatData.indexOf(rowData); + const page = Math.floor(rowIndex / this.perPage); + + if (this.page !== page) { + delayScrolling = true; + this.page = page; + } + } + } + + if (delayScrolling) { + this.verticalScrollContainer.dataChanged.pipe(first()).subscribe(() => { + this.scrollDirective(this.verticalScrollContainer, + typeof (row) === 'number' ? row : this.unpinnedDataView.indexOf(record)); + }); + } else { + this.scrollDirective(this.verticalScrollContainer, + typeof (row) === 'number' ? row : this.unpinnedDataView.indexOf(record)); + } + + this.scrollToHorizontally(column); + } + + protected override writeToData(rowIndex: number, value: any) { + mergeObjects(this.flatData[rowIndex], value); + } + + /** + * @hidden + */ + protected override initColumns(collection: IgxColumnComponent[], cb: (args: any) => void = null) { + if (this.hasColumnLayouts) { + // invalid configuration - tree grid should not allow column layouts + // remove column layouts + const nonColumnLayoutColumns = this.columns.filter((col) => !col.columnLayout && !col.columnLayoutChild); + this.updateColumns(nonColumnLayoutColumns); + } + super.initColumns(collection, cb); + } + + /** + * @hidden @internal + */ + protected override getGroupAreaHeight(): number { + return this.treeGroupArea ? this.getComputedHeight(this.treeGroupArea.nativeElement) : 0; + } + + /** {@link triggerPipes} will re-create pinnedData on CRUD operations */ + protected trackPinnedRowData(record: ITreeGridRecord) { + // TODO FIX: pipeline data doesn't match end interface (¬_¬ ) + // return record.key || (record as any).rowID; + return record; + } + + /** + * @description A recursive way to deselect all selected children of a given record + * @param recordID ID of the record whose children to deselect + * @hidden + * @internal + */ + private deselectChildren(recordID): void { + const selectedChildren = []; + // G.E. Apr 28, 2021 #9465 Records which are not in view can also be selected so we need to + // deselect them as well, hence using 'records' map instead of getRowByKey() method which will + // return only row components (i.e. records in view). + const rowToDeselect = this.records.get(recordID); + this.selectionService.deselectRowsWithNoEvent([recordID]); + this.gridAPI.get_selected_children(rowToDeselect, selectedChildren); + if (selectedChildren.length > 0) { + selectedChildren.forEach(x => this.deselectChildren(x)); + } + } + + private addChildRows(children: any[], parentID: any) { + if (this.primaryKey && this.foreignKey) { + for (const child of children) { + child[this.foreignKey] = parentID; + } + this.data.push(...children); + } else if (this.childDataKey) { + let parent = this.records.get(parentID); + let parentData = parent.data; + + if (this.transactions.enabled && this.transactions.getAggregatedChanges(true).length) { + const path = []; + while (parent) { + path.push(parent.key); + parent = parent.parent; + } + + let collection = this.data; + let record: any; + for (let i = path.length - 1; i >= 0; i--) { + const pid = path[i]; + record = collection.find(r => r[this.primaryKey] === pid); + + if (!record) { + break; + } + collection = record[this.childDataKey]; + } + if (record) { + parentData = record; + } + } + + parentData[this.childDataKey] = children; + } + this.selectionService.clearHeaderCBState(); + this.pipeTrigger++; + if (this.rowSelection === GridSelectionMode.multipleCascade) { + // Force pipe triggering for building the data structure + this.cdr.detectChanges(); + if (this.selectionService.isRowSelected(parentID)) { + this.selectionService.rowSelection.delete(parentID); + this.selectionService.selectRowsWithNoEvent([parentID]); + } + } + } + + private loadChildrenOnRowExpansion(args: IRowToggleEventArgs) { + if (this.loadChildrenOnDemand) { + const parentID = args.rowID; + + if (args.expanded && !this._expansionStates.has(parentID)) { + this.loadingRows.add(parentID); + + this.loadChildrenOnDemand(parentID, children => { + this.loadingRows.delete(parentID); + this.addChildRows(children, parentID); + this.notifyChanges(); + }); + } + } + } + + private handleCascadeSelection(event: IRowDataEventArgs | StateUpdateEvent, rec: ITreeGridRecord = null) { + // Wait for the change detection to update records through the pipes + requestAnimationFrame(() => { + if (rec === null) { + rec = this.gridAPI.get_rec_by_id((event as StateUpdateEvent).actions[0].transaction.id); + } + if (rec && rec.parent) { + this.gridAPI.grid.selectionService.updateCascadeSelectionOnFilterAndCRUD( + new Set([rec.parent]), rec.parent.key + ); + this.notifyChanges(); + } + }); + } +} diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid.directives.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid.directives.ts new file mode 100644 index 00000000000..df9876f6e4a --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid.directives.ts @@ -0,0 +1,12 @@ +import { Directive, TemplateRef, inject } from '@angular/core'; + +/** + * @hidden + */ +@Directive({ + selector: '[igxRowLoadingIndicator]', + standalone: true +}) +export class IgxRowLoadingIndicatorTemplateDirective { + public template = inject>(TemplateRef); +} diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid.filtering.pipe.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid.filtering.pipe.ts new file mode 100644 index 00000000000..dadfee31389 --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid.filtering.pipe.ts @@ -0,0 +1,56 @@ +import { Pipe, PipeTransform, inject } from '@angular/core'; +import { GridType, IGX_GRID_BASE } from 'igniteui-angular/grids/core'; +import { FilteringExpressionsTree, IFilteringExpressionsTree, IFilteringState, IFilteringStrategy, ITreeGridRecord, TreeGridFilteringStrategy } from 'igniteui-angular/core'; + +/** @hidden */ +@Pipe({ + name: 'treeGridFiltering', + standalone: true +}) +export class IgxTreeGridFilteringPipe implements PipeTransform { + private grid = inject(IGX_GRID_BASE); + + + public transform(hierarchyData: ITreeGridRecord[], expressionsTree: IFilteringExpressionsTree, + filterStrategy: IFilteringStrategy, + advancedFilteringExpressionsTree: IFilteringExpressionsTree, + _: number, __: number, pinned?): ITreeGridRecord[] { + const state: IFilteringState = { + expressionsTree, + advancedExpressionsTree: advancedFilteringExpressionsTree, + strategy: new TreeGridFilteringStrategy() + }; + + if (filterStrategy) { + state.strategy = filterStrategy; + } + + if (FilteringExpressionsTree.empty(state.expressionsTree) && FilteringExpressionsTree.empty(state.advancedExpressionsTree)) { + this.grid.setFilteredData(null, pinned); + return hierarchyData; + } + + const result = this.filter(hierarchyData, state, this.grid); + const filteredData: any[] = []; + this.expandAllRecursive(this.grid, result, this.grid.expansionStates, filteredData); + this.grid.setFilteredData(filteredData, pinned); + + return result; + } + + private expandAllRecursive(grid: GridType, data: ITreeGridRecord[], + expandedStates: Map, filteredData: any[]) { + for (const rec of data) { + filteredData.push(rec.data); + + if (rec.children && rec.children.length > 0) { + expandedStates.set(rec.key, true); + this.expandAllRecursive(grid, rec.children, expandedStates, filteredData); + } + } + } + + private filter(data: ITreeGridRecord[], state: IFilteringState, grid?: GridType): ITreeGridRecord[] { + return state.strategy.filter(data, state.expressionsTree, state.advancedExpressionsTree, grid); + } +} diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid.grouping.pipe.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid.grouping.pipe.ts new file mode 100644 index 00000000000..00b8b3f96dd --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid.grouping.pipe.ts @@ -0,0 +1,147 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { GridType } from 'igniteui-angular/grids/core'; +import { formatDate, GridColumnDataType, IGroupingExpression, IgxSorting } from 'igniteui-angular/core'; + +const HIDDEN_FIELD_NAME = '_Igx_Hidden_Data_'; + +/** + * @hidden + * @internal + */ +class GroupByRecord { + public key: string; + public value: any; + public groups: GroupByRecord[]; + public records: any[]; +} + +export class ITreeGridAggregation { + public field: string; + public aggregate: (parent: any, children: any[]) => any; +} + +export class IgxGroupedTreeGridSorting extends IgxSorting { + private static _instance: IgxGroupedTreeGridSorting = null; + + public static instance() { + return this._instance || (this._instance = new IgxGroupedTreeGridSorting()); + } + + protected override getFieldValue(obj: any, key: string, isDate = false, isTime = false): any { + const data = obj.data[HIDDEN_FIELD_NAME] ? + obj.data.hasOwnProperty(key) ? + obj.data : + obj.data[HIDDEN_FIELD_NAME] : + obj.data; + + return super.getFieldValue(data, key, isDate, isTime); + } +} + +/** @hidden */ +@Pipe({ + name: 'treeGridGrouping', + standalone: true +}) +export class IgxTreeGridGroupingPipe implements PipeTransform { + private grid: GridType; + + public transform(collection: any[], + groupingExpressions: IGroupingExpression[], + groupKey: string, + childDataKey: string, + grid: GridType, + aggregations?: ITreeGridAggregation[] + ): any[] { + if (groupingExpressions.length === 0) { + return collection; + } + + if (groupKey?.toLowerCase() === childDataKey?.toLowerCase()) { + throw new Error('Group key and child data key cannot be the same.'); + } + + this.grid = grid; + + const result = []; + const groupedRecords = this.groupByMultiple(collection, groupingExpressions); + this.flattenGrouping(groupedRecords, groupKey, + childDataKey, result, aggregations); + + return result; + } + + private flattenGrouping(groupRecords: GroupByRecord[], + groupKey: string, + childDataKey: string, + data: any[], + aggregations: ITreeGridAggregation[] = []) { + for (const groupRecord of groupRecords) { + const parent = {}; + const children = groupRecord.records; + + parent[childDataKey] = []; + + for (const aggregation of aggregations) { + parent[aggregation.field] = aggregation.aggregate(parent, children); + } + + parent[groupKey] = groupRecord.value + ` (${groupRecord.records.length})`; + parent[HIDDEN_FIELD_NAME] = { [groupRecord.key]: groupRecord.value }; + data.push(parent); + + if (groupRecord.groups) { + this.flattenGrouping(groupRecord.groups, groupKey, childDataKey, + parent[childDataKey], aggregations); + } else { + parent[childDataKey] = children; + } + } + } + + private groupByMultiple(array: any[], groupingExpressions: IGroupingExpression[], index = 0): GroupByRecord[] { + const res = this.groupBy(array, groupingExpressions[index]); + + if (index + 1 < groupingExpressions.length) { + for (const groupByRecord of res) { + groupByRecord.groups = this.groupByMultiple(groupByRecord.records, groupingExpressions, index + 1); + } + } + + return res; + } + + private groupBy(array: any[], groupingExpression: IGroupingExpression): GroupByRecord[] { + const key = groupingExpression.fieldName; + const column = this.grid?.getColumnByName(key); + const isDateTime = column?.dataType === GridColumnDataType.Date || + column?.dataType === GridColumnDataType.DateTime || + column?.dataType === GridColumnDataType.Time; + const map: Map = new Map(); + for (const record of array) { + const value = isDateTime + ? formatDate(record[key], column.pipeArgs.format, this.grid.locale) + : record[key]; + + let valueCase = value; + let groupByRecord: GroupByRecord; + + if (groupingExpression.ignoreCase) { + valueCase = value?.toString().toLowerCase(); + } + if (map.has(valueCase)) { + groupByRecord = map.get(valueCase); + } else { + groupByRecord = new GroupByRecord(); + groupByRecord.key = key; + groupByRecord.value = value; + groupByRecord.records = []; + map.set(valueCase, groupByRecord); + } + + groupByRecord.records.push(record); + } + + return Array.from(map.values()); + } +} diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid.module.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid.module.ts new file mode 100644 index 00000000000..6e744534f53 --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { IGX_TREE_GRID_DIRECTIVES } from './public_api'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + ...IGX_TREE_GRID_DIRECTIVES + ], + exports: [ + ...IGX_TREE_GRID_DIRECTIVES + ] +}) +export class IgxTreeGridModule { +} diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid.pipes.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid.pipes.ts new file mode 100644 index 00000000000..5ecda9dff6f --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid.pipes.ts @@ -0,0 +1,356 @@ +import { Pipe, PipeTransform, inject } from '@angular/core'; +import { GridType, IGX_GRID_BASE } from 'igniteui-angular/grids/core'; +import { cloneArray, cloneHierarchicalArray, DataUtil, IGroupingExpression, ISortingExpression, TransactionType, IGridSortingStrategy, ITreeGridRecord } from 'igniteui-angular/core'; +import { IgxAddRow } from 'igniteui-angular/grids/core'; + +/** + * @hidden + */ +@Pipe({ + name: 'treeGridHierarchizing', + standalone: true +}) +export class IgxTreeGridHierarchizingPipe implements PipeTransform { + private grid = inject(IGX_GRID_BASE); + + + public transform(collection: any[], primaryKey: string, foreignKey: string, childDataKey: string, _: number): ITreeGridRecord[] { + let hierarchicalRecords: ITreeGridRecord[] = []; + const treeGridRecordsMap = new Map(); + const flatData: any[] = []; + + if (!collection || !collection.length) { + this.grid.flatData = collection; + this.grid.records = treeGridRecordsMap; + this.grid.rootRecords = collection; + return collection; + } + + if (childDataKey) { + hierarchicalRecords = this.hierarchizeRecursive(collection, primaryKey, childDataKey, undefined, + flatData, 0, treeGridRecordsMap); + } else if (primaryKey) { + hierarchicalRecords = this.hierarchizeFlatData(collection, primaryKey, foreignKey, treeGridRecordsMap, flatData); + } + + this.grid.flatData = this.grid.transactions.enabled ? + flatData.filter(rec => { + const state = this.grid.transactions.getState(this.getRowID(primaryKey, rec)); + return !state || state.type !== TransactionType.ADD; + }) : flatData; + this.grid.records = treeGridRecordsMap; + this.grid.rootRecords = hierarchicalRecords; + return hierarchicalRecords; + } + + private getRowID(primaryKey: any, rowData: any) { + return primaryKey ? rowData[primaryKey] : rowData; + } + + /** + * Converts a flat array of data into a hierarchical (tree) structure, + * preserving the original order of the records among siblings. + * + * It uses a two-pass approach: + * 1. Creates all ITreeGridRecord objects and populates the Map for quick lookup. + * 2. Links the records by iterating again, ensuring children are added to + * their parent's children array in the order they appeared in the + * original collection. + * + * @param collection The flat array of data to be hierarchized. This is the array whose order should be preserved. + * @param primaryKey The name of the property in the data objects that serves as the unique identifier (e.g., 'id'). + * @param foreignKey The name of the property in the data objects that links to the parent's primary key (e.g., 'parentId'). + * @param map A pre-existing Map object (key: primaryKey value, value: ITreeGridRecord) used to store and quickly look up all created records. + * @param flatData The original flat data array. Used for passing to the setIndentationLevels method (not directly used for hierarchy building). + * @returns An array of ITreeGridRecord objects representing the root nodes of the hierarchy, ordered as they appeared in the original collection. + */ + private hierarchizeFlatData( + collection: any[], + primaryKey: string, + foreignKey: string, + map: Map, + flatData: any[] + ): ITreeGridRecord[] { + collection.forEach(row => { + const record: ITreeGridRecord = { + key: this.getRowID(primaryKey, row), + data: row, + children: [] + }; + map.set(row[primaryKey], record); + }); + + const result: ITreeGridRecord[] = []; + collection.forEach(row => { + const record: ITreeGridRecord = map.get(row[primaryKey])!; + const parent = map.get(row[foreignKey]); + + if (parent) { + record.parent = parent; + parent.children.push(record); + } else { + result.push(record); + } + }); + + this.setIndentationLevels(result, 0, flatData); + + return result; + } + + private setIndentationLevels(collection: ITreeGridRecord[], indentationLevel: number, flatData: any[]) { + for (const record of collection) { + record.level = indentationLevel; + record.expanded = this.grid.gridAPI.get_row_expansion_state(record); + flatData.push(record.data); + + if (record.children && record.children.length > 0) { + this.setIndentationLevels(record.children, indentationLevel + 1, flatData); + } + } + } + + private hierarchizeRecursive(collection: any[], primaryKey: string, childDataKey: string, + parent: ITreeGridRecord, flatData: any[], indentationLevel: number, map: Map): ITreeGridRecord[] { + const result: ITreeGridRecord[] = []; + + for (const item of collection) { + const record: ITreeGridRecord = { + key: this.getRowID(primaryKey, item), + data: item, + parent, + level: indentationLevel + }; + record.expanded = this.grid.gridAPI.get_row_expansion_state(record); + flatData.push(item); + map.set(record.key, record); + record.children = item[childDataKey] ? + this.hierarchizeRecursive(item[childDataKey], primaryKey, childDataKey, record, flatData, indentationLevel + 1, map) : + undefined; + result.push(record); + } + + return result; + } +} + +/** + * @hidden + */ +@Pipe({ + name: 'treeGridFlattening', + standalone: true +}) +export class IgxTreeGridFlatteningPipe implements PipeTransform { + private grid = inject(IGX_GRID_BASE); + + + public transform(collection: ITreeGridRecord[], + expandedLevels: number, expandedStates: Map, _: number): any[] { + + const data: ITreeGridRecord[] = []; + + this.grid.processedRootRecords = collection; + this.grid.processedRecords = new Map(); + + this.getFlatDataRecursive(collection, data, expandedLevels, expandedStates, true); + + this.grid.processedExpandedFlatData = data.map(r => r.data); + + return data; + } + + private getFlatDataRecursive(collection: ITreeGridRecord[], data: ITreeGridRecord[], + expandedLevels: number, expandedStates: Map, parentExpanded: boolean) { + if (!collection || !collection.length) { + return; + } + + for (const hierarchicalRecord of collection) { + if (parentExpanded) { + data.push(hierarchicalRecord); + } + + hierarchicalRecord.expanded = this.grid.gridAPI.get_row_expansion_state(hierarchicalRecord); + + this.updateNonProcessedRecordExpansion(this.grid, hierarchicalRecord); + + this.grid.processedRecords.set(hierarchicalRecord.key, hierarchicalRecord); + + this.getFlatDataRecursive(hierarchicalRecord.children, data, expandedLevels, + expandedStates, parentExpanded && hierarchicalRecord.expanded); + } + } + + private updateNonProcessedRecordExpansion(grid: GridType, record: ITreeGridRecord) { + const rec = grid.records.get(record.key); + rec.expanded = record.expanded; + } +} + +/** @hidden */ +@Pipe({ + name: 'treeGridSorting', + standalone: true +}) +export class IgxTreeGridSortingPipe implements PipeTransform { + private grid = inject(IGX_GRID_BASE); + + + public transform( + hierarchicalData: ITreeGridRecord[], + sortExpressions: ISortingExpression[], + groupExpressions: IGroupingExpression[], + sorting: IGridSortingStrategy, + _: number, + pinned?: boolean): ITreeGridRecord[] { + + const expressions = groupExpressions ? groupExpressions.concat(sortExpressions) : sortExpressions; + let result: ITreeGridRecord[]; + if (!expressions.length) { + result = hierarchicalData; + } else { + result = DataUtil.treeGridSort(hierarchicalData, expressions, sorting, this.grid); + } + + const filteredSortedData = []; + this.flattenTreeGridRecords(result, filteredSortedData); + this.grid.setFilteredSortedData(filteredSortedData, pinned); + + return result; + } + + private flattenTreeGridRecords(records: ITreeGridRecord[], flatData: any[]) { + if (records && records.length) { + for (const record of records) { + flatData.push(record.data); + this.flattenTreeGridRecords(record.children, flatData); + } + } + } +} + +/** @hidden */ +@Pipe({ + name: 'treeGridPaging', + standalone: true +}) +export class IgxTreeGridPagingPipe implements PipeTransform { + private grid = inject(IGX_GRID_BASE); + + + public transform(collection: ITreeGridRecord[], enabled: boolean, page = 0, perPage = 15, _: number): ITreeGridRecord[] { + if (!enabled || this.grid.pagingMode !== 'local') { + return collection; + } + + const len = this.grid._totalRecords >= 0 ? this.grid._totalRecords : collection.length; + const totalPages = Math.ceil(len / perPage); + + const state = { + index: (totalPages > 0 && page >= totalPages) ? totalPages - 1 : page, + recordsPerPage: perPage + }; + + const result: ITreeGridRecord[] = DataUtil.page(cloneArray(collection), state, len); + this.grid.pagingState = state; + this.grid.page = state.index; + + return result; + } +} +/** @hidden */ +@Pipe({ + name: 'treeGridTransaction', + standalone: true +}) +export class IgxTreeGridTransactionPipe implements PipeTransform { + private grid = inject(IGX_GRID_BASE); + + + public transform(collection: any[], _: number): any[] { + + if (this.grid.transactions.enabled) { + const aggregatedChanges = this.grid.transactions.getAggregatedChanges(true); + if (aggregatedChanges.length > 0) { + const primaryKey = this.grid.primaryKey; + if (!primaryKey) { + return collection; + } + + const childDataKey = this.grid.childDataKey; + + if (childDataKey) { + const hierarchicalDataClone = cloneHierarchicalArray(collection, childDataKey); + return DataUtil.mergeHierarchicalTransactions( + hierarchicalDataClone, + aggregatedChanges, + childDataKey, + this.grid.primaryKey, + this.grid.dataCloneStrategy + ); + } else { + const flatDataClone = cloneArray(collection); + return DataUtil.mergeTransactions( + flatDataClone, + aggregatedChanges, + this.grid.primaryKey, + this.grid.dataCloneStrategy); + } + } + } + return collection; + } +} + +/** + * This pipe maps the original record to ITreeGridRecord format used in TreeGrid. + */ +@Pipe({ + name: 'treeGridNormalizeRecord', + standalone: true +}) +export class IgxTreeGridNormalizeRecordsPipe implements PipeTransform { + private grid = inject(IGX_GRID_BASE); + + + public transform(_: any[], __: number): any[] { + const primaryKey = this.grid.primaryKey; + // using flattened data because origin data may be hierarchical. + const flatData = this.grid.flatData; + const res = flatData ? flatData.map(rec => + ({ + rowID: this.grid.primaryKey ? rec[primaryKey] : rec, + data: rec, + level: 0, + children: [] + })) : []; + return res; + } +} + +@Pipe({ + name: 'treeGridAddRow', + standalone: true +}) +export class IgxTreeGridAddRowPipe implements PipeTransform { + private grid = inject(IGX_GRID_BASE); + + + public transform(collection: any, isPinned = false, _pipeTrigger: number) { + if (!this.grid.rowEditable || !this.grid.crudService.row || !this.grid.crudService.row.isAddRow || + !this.grid.gridAPI.crudService.addRowParent || isPinned !== this.grid.gridAPI.crudService.addRowParent.isPinned) { + return collection; + } + const copy = collection.slice(0); + const rec = (this.grid.crudService.row as IgxAddRow).recordRef; + if (this.grid.crudService.addRowParent.isPinned) { + const parentRowIndex = copy.findIndex(record => record.rowID === this.grid.crudService.addRowParent.rowID); + copy.splice(parentRowIndex + 1, 0, rec); + } else { + copy.splice(this.grid.crudService.row.index, 0, rec); + } + this.grid.records.set(rec.key, rec); + return copy; + } +} diff --git a/projects/igniteui-angular/grids/tree-grid/src/tree-grid.summary.pipe.ts b/projects/igniteui-angular/grids/tree-grid/src/tree-grid.summary.pipe.ts new file mode 100644 index 00000000000..dd7e48de22e --- /dev/null +++ b/projects/igniteui-angular/grids/tree-grid/src/tree-grid.summary.pipe.ts @@ -0,0 +1,112 @@ +import { Pipe, PipeTransform, inject } from '@angular/core'; +import { GridType, IGX_GRID_BASE, GridSummaryPosition } from 'igniteui-angular/grids/core'; +import { GridSummaryCalculationMode, ISummaryRecord, ITreeGridRecord } from 'igniteui-angular/core'; + +/** @hidden */ +@Pipe({ + name: 'treeGridSummary', + standalone: true +}) +export class IgxTreeGridSummaryPipe implements PipeTransform { + private grid = inject(IGX_GRID_BASE); + + + public transform(flatData: ITreeGridRecord[], + hasSummary: boolean, + summaryCalculationMode: GridSummaryCalculationMode, + summaryPosition: GridSummaryPosition, showSummaryOnCollapse: boolean, _: number, __: number): any[] { + + if (!flatData || !hasSummary || summaryCalculationMode === GridSummaryCalculationMode.rootLevelOnly) { + return flatData; + } + + return this.addSummaryRows(this.grid, flatData, summaryPosition, showSummaryOnCollapse); + } + + private addSummaryRows(grid: GridType, collection: ITreeGridRecord[], + summaryPosition: GridSummaryPosition, showSummaryOnCollapse: boolean): any[] { + const recordsWithSummary = []; + const maxSummaryHeight = grid.summaryService.calcMaxSummaryHeight(); + + for (const record of collection) { + recordsWithSummary.push(record); + + const isCollapsed = !record.expanded && record.children && record.children.length > 0 && showSummaryOnCollapse; + if (isCollapsed) { + let childData = record.children.filter(r => !r.isFilteredOutParent).map(r => r.data); + childData = this.removeDeletedRecord(grid, record.key, childData); + const summaries = grid.summaryService.calculateSummaries(record.key, childData); + const summaryRecord: ISummaryRecord = { + summaries, + max: maxSummaryHeight, + cellIndentation: record.level + 1 + }; + recordsWithSummary.push(summaryRecord); + } + const isExpanded = record.children && record.children.length > 0 && record.expanded; + if (summaryPosition === GridSummaryPosition.bottom && !isExpanded) { + let childRecord = record; + let parent = record.parent; + + while (parent) { + const children = parent.children; + + if (children[children.length - 1] === childRecord ) { + let childData = children.filter(r => !r.isFilteredOutParent).map(r => r.data); + childData = this.removeDeletedRecord(grid, parent.key, childData); + const summaries = grid.summaryService.calculateSummaries(parent.key, childData); + const summaryRecord: ISummaryRecord = { + summaries, + max: maxSummaryHeight, + cellIndentation: parent.level + 1 + }; + recordsWithSummary.push(summaryRecord); + + childRecord = parent; + parent = childRecord.parent; + } else { + break; + } + } + } else if (summaryPosition === GridSummaryPosition.top && isExpanded) { + let childData = record.children.filter(r => !r.isFilteredOutParent).map(r => r.data); + childData = this.removeDeletedRecord(grid, record.key, childData); + const summaries = grid.summaryService.calculateSummaries(record.key, childData); + const summaryRecord: ISummaryRecord = { + summaries, + max: maxSummaryHeight, + cellIndentation: record.level + 1 + }; + recordsWithSummary.push(summaryRecord); + } + } + return recordsWithSummary; + } + + private removeDeletedRecord(grid, rowId, data) { + if (!grid.transactions.enabled || !grid.cascadeOnDelete) { + return data; + } + const deletedRows = grid.transactions.getTransactionLog().filter(t => t.type === 'delete').map(t => t.id); + let row = grid.records.get(rowId); + if (!row && deletedRows.lenght === 0) { + return []; + } + row = row.children ? row : row.parent; + while (row) { + rowId = row.key; + if (deletedRows.indexOf(rowId) !== -1) { + return []; + } + row = row.parent; + } + deletedRows.forEach(rowID => { + const tempData = grid.primaryKey ? data.map(rec => rec[grid.primaryKey]) : data; + const index = tempData.indexOf(rowID); + if (index !== -1) { + data.splice(index, 1); + } + }); + return data; + } +} diff --git a/projects/igniteui-angular/icon/README.md b/projects/igniteui-angular/icon/README.md new file mode 100644 index 00000000000..66b6b41709a --- /dev/null +++ b/projects/igniteui-angular/icon/README.md @@ -0,0 +1,43 @@ +# igx-icon + +**igx-icon** supports icon component that unifies various icon/font sets to allow their usage interchangeably. + +With the igx-icon you can add **material-icons** and other font-based icon sets while also using custom SVG icons in your markup. +A guide on how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/icon) + +# Usage + +```html + +``` + +You can set the family to `family="material"` to select the material icons set (default). + +You can set the icon by providing its name from the official [material icons set](https://material.io/icons/) `name="home"`. + +You can set the icon to active/inactive by providing setting `active` to true or false (default is true). + +You can access all properties of the icon component with the following attributes: + +`id` + +`family` + +`name` + +`active` + + +**Setters** +You can programmatically set all of the icon properties with the following icon setters: + +`family(fontFamily: string)` sets the icon family +`name(icon: string)` sets the icon name +`active(state: boolean)` sets the icon style to inactive if set the false + +**Getters** +You can programmatically get all of the icon properties with the following icon getters: + +`getFamily()` returns the icon family. +`getName()` returns the icon name. +`getActive()` returns the icon's active state. diff --git a/projects/igniteui-angular/icon/index.ts b/projects/igniteui-angular/icon/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/icon/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/icon/ng-package.json b/projects/igniteui-angular/icon/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/icon/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/icon/src/icon/icon.component.html b/projects/igniteui-angular/icon/src/icon/icon.component.html new file mode 100644 index 00000000000..03ed1c1649c --- /dev/null +++ b/projects/igniteui-angular/icon/src/icon/icon.component.html @@ -0,0 +1,13 @@ + + +@if (!iconRef.name) { + +} + +@switch (iconRef.type) { + @case ("liga") {{{ iconRef.name }}} + + @case ("svg") { +
    + } +} diff --git a/projects/igniteui-angular/icon/src/icon/icon.component.spec.ts b/projects/igniteui-angular/icon/src/icon/icon.component.spec.ts new file mode 100644 index 00000000000..e44b1ec8666 --- /dev/null +++ b/projects/igniteui-angular/icon/src/icon/icon.component.spec.ts @@ -0,0 +1,246 @@ +import { Component } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { IgxIconComponent } from "./icon.component"; +import { IgxIconService } from "./icon.service"; +import { IconFamily } from './types'; +import type { IconType } from './types'; + +import { By } from "@angular/platform-browser"; + +describe("Icon", () => { + + describe("Component", () => { + let fixture: ComponentFixture; + let instance: IgxIconComponent; + let el: HTMLElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [IgxIconComponent, IconTestComponent], + providers: [IgxIconService], + }).compileComponents(); + + fixture = TestBed.createComponent(IconTestComponent); + + const debugElement = fixture.debugElement.query( + By.directive(IgxIconComponent), + ); + + instance = debugElement.componentInstance; + el = debugElement.nativeElement; + }); + + it("should instantiate with defaults", () => { + fixture.detectChanges(); + + expect(instance.getFamily).toBe("material"); + expect(instance.getActive).toBe(true); + expect(instance.ariaHidden).toBe(true); + }); + + it("should be able to set the aria-hidden attribute", () => { + fixture.detectChanges(); + + expect(el.getAttribute('aria-hidden')).toBe("true"); + + instance.ariaHidden = false; + fixture.detectChanges(); + + expect(el.getAttribute('aria-hidden')).toBe("false"); + }); + + it("should set icon as inactive", () => { + instance.active = false; + fixture.detectChanges(); + + expect(el.classList).toContain("igx-icon--inactive"); + + instance.active = true; + fixture.detectChanges(); + + expect(el.classList).not.toContain("igx-icon--inactive"); + }); + + it("should properly render ligature-based icons", () => { + const iconService = TestBed.inject(IgxIconService); + iconService.setFamily("liga", { className: "liga", type: "liga" }); + + instance.name = "home"; + instance.family = "liga"; + + fixture.detectChanges(); + + assertRenderedIcon(el, { + family: instance.family, + name: instance.name, + classList: ["igx-icon", "my-class", instance.family], + type: "liga", + }); + }); + + it("should properly render font-based icons", () => { + const iconService = TestBed.inject(IgxIconService); + iconService.setFamily("fonty", { + className: "fonty", + type: "font", + }); + + instance.name = "home"; + instance.family = "fonty"; + + fixture.detectChanges(); + + assertRenderedIcon(el, { + family: instance.family, + name: instance.name, + classList: ["igx-icon", "my-class", instance.family, instance.name], + type: "font", + }); + }); + + it("should properly render SVG-based icons", () => { + const savage = `...`; + const iconService = TestBed.inject(IgxIconService); + + iconService.setFamily("savage", { + className: "savage", + type: "svg", + }); + + iconService.addSvgIconFromText("savvy", savage, "savage"); + + instance.name = "savvy"; + instance.family = "savage"; + + fixture.detectChanges(); + + assertRenderedIcon(el, { + family: instance.family, + name: instance.name, + classList: ["igx-icon", "my-class", instance.family], + type: "svg", + svg: savage, + }); + }); + }); + + describe("Integration with Service", () => { + let fa: IconFamily; + let iconName: string; + + beforeEach(() => { + fa = { + name: "fa-solid", + meta: { + className: "fa", + type: "font", + prefix: "fa-", + }, + }; + + iconName = `${fa.meta.prefix}home`; + }); + + it("should respond when default family changes", () => { + const iconService = TestBed.inject(IgxIconService); + + // Change the default family ahead of time to font-awesome solid. + iconService.defaultFamily = fa; + + const fixture = TestBed.createComponent(IconTestComponent); + const debugElement = fixture.debugElement.query( + By.directive(IgxIconComponent), + ); + + const instance = debugElement.componentInstance; + const el = debugElement.nativeElement; + + fixture.detectChanges(); + + expect(instance.getFamily).toBe(fa.name); + expect(instance.getName).toBe(iconName); + + assertRenderedIcon(el, { + family: fa.name, + name: iconName, + classList: ["igx-icon", "my-class", fa.meta.className, iconName], + type: fa.meta.type, + }); + }); + + it("should work with families by reference", () => { + const iconService = TestBed.inject(IgxIconService); + + iconService.setFamily(fa.name, fa.meta); + iconService.setIconRef("home", "default", { + name: "home", + family: fa.name, + }); + + const fixture = TestBed.createComponent(MetaIconComponent); + const debugElement = fixture.debugElement.query( + By.directive(IgxIconComponent), + ); + + const instance = debugElement.componentInstance; + const el = debugElement.nativeElement; + + fixture.detectChanges(); + + expect(instance.getFamily).toBe(fa.name); + expect(instance.getName).toBe(iconName); + + assertRenderedIcon(el, { + family: fa.name, + name: iconName, + classList: ["igx-icon", "my-class", fa.meta.className, iconName], + type: fa.meta.type, + }); + }); + }); +}); + +interface ProtoIgxIcon { + name: string; + family: string; + classList: string[]; + type: IconType; + svg?: string; +} + +function assertRenderedIcon(el: HTMLElement, icon: ProtoIgxIcon) { + expect(el.classList.length).toEqual(icon.classList.length); + + icon.classList.forEach((className) => { + expect(el.classList).toContain(className); + }); + + switch (icon.type) { + case "svg": + expect(el.innerHTML).toContain(icon.svg); + break; + case "font": + expect(el.textContent).toBeFalsy(); + break; + case "liga": + default: + expect(el.textContent).toEqual(icon.name); + break; + } +} + +@Component({ + template: ``, + imports: [IgxIconComponent] +}) +class IconTestComponent {} + +@Component({ + template: ``, + imports: [IgxIconComponent] +}) +class MetaIconComponent {} diff --git a/projects/igniteui-angular/icon/src/icon/icon.component.ts b/projects/igniteui-angular/icon/src/icon/icon.component.ts new file mode 100644 index 00000000000..bc1ddca7ee6 --- /dev/null +++ b/projects/igniteui-angular/icon/src/icon/icon.component.ts @@ -0,0 +1,251 @@ +import { Component, ElementRef, HostBinding, Input, OnInit, OnDestroy, OnChanges, ChangeDetectorRef, booleanAttribute, inject } from "@angular/core"; +import { IgxIconService } from "./icon.service"; +import type { IconReference } from "./types"; +import { filter, takeUntil } from "rxjs/operators"; +import { Subject } from "rxjs"; +import { SafeHtml } from "@angular/platform-browser"; + +/** + * Icon provides a way to include material icons to markup + * + * @igxModule IgxIconModule + * + * @igxTheme igx-icon-theme + * + * @igxKeywords icon, picture + * + * @igxGroup Display + * + * @remarks + * + * The Ignite UI Icon makes it easy for developers to include material design icons directly in their markup. The icons + * support different icon families and can be marked as active or disabled using the `active` property. This will change the appearance + * of the icon. + * + * @example + * ```html + * home + * ``` + */ +@Component({ + selector: "igx-icon", + templateUrl: "icon.component.html", +}) +export class IgxIconComponent implements OnInit, OnChanges, OnDestroy { + public el = inject(ElementRef); + private iconService = inject(IgxIconService); + private ref = inject(ChangeDetectorRef); + + private _iconRef: IconReference; + private _destroy$ = new Subject(); + private _userClasses = new Set(); + private _iconClasses = new Set(); + + @HostBinding("class") + protected get elementClasses() { + const icon = Array.from(this._iconClasses).join(" "); + const user = Array.from(this._userClasses).join(" "); + + return `igx-icon ${icon} ${user}`.trim(); + } + + private addIconClass(className: string) { + this._iconClasses.add(className); + } + + private clearIconClasses() { + this._iconClasses.clear(); + } + + /** + * An accessor that returns inactive property. + * + * @example + * ```typescript + * @ViewChild("MyIcon") + * public icon: IgxIconComponent; + * ngAfterViewInit() { + * let iconActive = this.icon.getInactive; + * } + * ``` + */ + @HostBinding("class.igx-icon--inactive") + public get getInactive(): boolean { + return !this.active; + } + + /** + * The `aria-hidden` attribute of the icon. + * By default is set to 'true'. + */ + @HostBinding("attr.aria-hidden") + @Input() + public ariaHidden = true; + + /** + * An @Input property that sets the value of the `family`. By default it's "material". + * + * @example + * ```html + * settings + * ``` + */ + @Input() + public family: string; + + /** + * Set the `name` of the icon. + * + * @example + * ```html + * + * ``` + */ + @Input() + public name: string; + + /** + * An @Input property that allows you to disable the `active` property. By default it's applied. + * + * @example + * ```html + * settings + * ``` + */ + @Input({ transform: booleanAttribute }) + public active = true; + + constructor() { + this.family = this.iconService.defaultFamily.name; + + this.iconService.iconLoaded + .pipe( + filter((e) => e.name === this.name && e.family === this.family), + takeUntil(this._destroy$), + ) + .subscribe(() => { + this.setIcon(); + this.ref.detectChanges() + }); + } + + /** + * @hidden + * @internal + */ + public ngOnInit() { + this.setIcon(); + } + + /** + * @hidden + * @internal + */ + public ngOnChanges() { + this.setIcon(); + } + + /** + * @hidden + * @internal + */ + public ngOnDestroy() { + this._destroy$.next(); + this._destroy$.complete(); + } + + protected get iconRef() { + return this._iconRef; + } + + protected set iconRef(ref: IconReference) { + this._iconRef = ref; + } + + /** + * An accessor that returns the value of the family property. + * + * @example + * ```typescript + * @ViewChild("MyIcon") + * public icon: IgxIconComponent; + * ngAfterViewInit() { + * let iconFamily = this.icon.getFamily; + * } + * ``` + */ + public get getFamily(): string { + return this.iconRef.family; + } + + /** + * An accessor that returns the value of the active property. + * + * @example + * ```typescript + * @ViewChild("MyIcon") + * public icon: IgxIconComponent; + * ngAfterViewInit() { + * let iconActive = this.icon.getActive; + * } + * ``` + */ + public get getActive(): boolean { + return this.active; + } + + /** + * An accessor that returns the value of the iconName property. + * + * @example + * ```typescript + * @ViewChild("MyIcon") + * public icon: IgxIconComponent; + * ngAfterViewInit() { + * let name = this.icon.getName; + * } + * ``` + */ + public get getName(): string { + return this.iconRef.name; + } + + /** + * An accessor that returns the underlying SVG image as SafeHtml. + * + * @example + * ```typescript + * @ViewChild("MyIcon") + * public icon: IgxIconComponent; + * ngAfterViewInit() { + * let svg: SafeHtml = this.icon.getSvg; + * } + * ``` + */ + public get getSvg(): SafeHtml { + const { name, family } = this.iconRef; + + if (this.iconService.isSvgIconCached(name, family)) { + return this.iconService.getSvgIcon(name, family); + } + + return null; + } + + /** + * @hidden + * @internal + */ + private setIcon() { + this.iconRef = this.iconService.getIconRef(this.name, this.family); + this.clearIconClasses(); + + const { name, type, className } = this.iconRef; + + if (name && type === "font") { + this.addIconClass(name); + } + + this.addIconClass(className); + } +} diff --git a/projects/igniteui-angular/icon/src/icon/icon.module.ts b/projects/igniteui-angular/icon/src/icon/icon.module.ts new file mode 100644 index 00000000000..78c0d41d782 --- /dev/null +++ b/projects/igniteui-angular/icon/src/icon/icon.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from '@angular/core'; +import { IgxIconComponent } from './icon.component'; + +/** + * @hidden + * @deprecated + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + exports: [IgxIconComponent], + imports: [IgxIconComponent] +}) +export class IgxIconModule { } diff --git a/projects/igniteui-angular/icon/src/icon/icon.references.ts b/projects/igniteui-angular/icon/src/icon/icon.references.ts new file mode 100644 index 00000000000..64ca01888fa --- /dev/null +++ b/projects/igniteui-angular/icon/src/icon/icon.references.ts @@ -0,0 +1,773 @@ +/** READ BEFORE YOU MODIFY THIS FILE! + * + * Before you add/modify an icon reference, please think about the semantics of the icon you are adding/modifying. + * + * Icon aliases have sematic meaning depending on the context in which they are used. + * For instance, if your component handles toggling between expanded and collapsed states, + * you may want to use the already existing `expand` and `collapse` aliases that point to + * the `expand_more` and `expand_less` icons in the material font set. + * + * It may so happen, however, that the design of your component requires you to use the `chevron_right` for the + * expand icon and the `expand_more` for the collapse icon. In this case the `tree_expand` and `tree_collapse` aliases + * would be appropriate. + * This distinction is important when choosing which icon to use for your component as it will have an impact + * when a user decides to rewire the `expand`/`collapse` icons to some other icons. + * + * Likewise, modifying existing references should be handled with caution as many component in the framework already + * share icons that have equivalent semantic meaning. For example, the `Paginator`, `Grid Filtering Row`, + * and `Tabs` components in Ignite UI for Angular all use the `prev` and `next` icons for navigating between pages + * or lists of items. Changing the underlying target for those icons should be done in a way that suits all components. + * + * Keep in mind that icon aliases and their underlying names are shared between Ignite UI component frameworks + * and changing an alias name here should be reflected in the other frameworks as well. + * + * To get acquainted with which component uses what icon, please make sure to read the + * [docs](https://infragistics.com/products/ignite-ui-angular/Angular/components/icon-service#internal-usage). + */ +import { IconMeta } from "./types"; +import type { IconReference, IconThemeKey, MetaReference } from './types'; + +type Icon = { [key in IconThemeKey]?: IconMeta }; + +const makeIconRefs = (icons: Icon) => { + return new Map( + Object.entries(icons).map((icon) => { + return icon as [theme: IconThemeKey, IconReference]; + }) + ); +}; + +const addIcon = (name: string, target: Icon) => { + const icon = { + alias: { + name, + family: 'default' + }, + target: makeIconRefs(target) + }; + + return icon as MetaReference; +} + +const loadIconRefs = () => [ + addIcon('more_vert', { + default: { + name: 'more_vert', + family: 'material', + } + }), + addIcon('arrow_prev', { + default: { + name: 'chevron_left', + family: 'material', + }, + fluent: { + name: 'arrow_upward', + family: 'material', + }, + indigo: { + name: 'chevron_left', + family: 'internal_indigo', + }, + }), + addIcon('arrow_next', { + default: { + name: 'chevron_right', + family: 'material', + }, + fluent: { + name: 'arrow_downward', + family: 'material', + }, + indigo: { + name: 'chevron_right', + family: 'internal_indigo', + }, + }), + addIcon('expand', { + default: { + name: 'expand_more', + family: 'material', + }, + indigo: { + name: 'chevron_down', + family: 'internal_indigo', + } + }), + addIcon('collapse', { + default: { + name: 'expand_less', + family: 'material', + }, + indigo: { + name: 'chevron_up', + family: 'internal_indigo', + } + }), + addIcon('carousel_prev', { + default: { + name: 'keyboard_arrow_left', + family: 'material', + }, + indigo: { + name: 'chevron_left', + family: 'internal_indigo', + }, + }), + addIcon('carousel_next', { + default: { + name: 'keyboard_arrow_right', + family: 'material', + }, + indigo: { + name: 'chevron_right', + family: 'internal_indigo', + }, + }), + addIcon('arrow_back', { + default: { + name: 'arrow_back', + family: 'material', + }, + indigo: { + name: 'arrow_back', + family: 'internal_indigo', + }, + }), + addIcon('arrow_forward', { + default: { + name: 'arrow_forward', + family: 'material', + }, + indigo: { + name: 'arrow_forward', + family: 'internal_indigo', + }, + }), + addIcon('selected', { + default: { + name: 'done', + family: 'material', + }, + indigo: { + name: 'check', + family: 'internal_indigo', + }, + }), + addIcon('remove', { + default: { + name: 'cancel', + family: 'material', + }, + indigo: { + name: 'cancel', + family: 'internal_indigo', + }, + }), + addIcon('input_clear', { + default: { + name: 'clear', + family: 'material', + }, + indigo: { + name: 'clear', + family: 'internal_indigo', + }, + }), + addIcon('input_expand', { + default: { + name: 'expand_more', + family: 'material', + }, + indigo: { + name: 'chevron_down', + family: 'internal_indigo', + }, + }), + addIcon('input_collapse', { + default: { + name: 'expand_less', + family: 'material', + }, + indigo: { + name: 'chevron_up', + family: 'internal_indigo', + }, + }), + addIcon('arrow_drop_down', { + default: { + name: 'keyboard_arrow_down', + family: 'material', + }, + indigo: { + name: 'chevron_down', + family: 'internal_indigo', + }, + }), + addIcon('case_sensitive', { + default: { + name: 'case-sensitive', + family: 'imx-icons', + }, + }), + addIcon('today', { + default: { + name: 'calendar_today', + family: 'material', + }, + indigo: { + name: 'calendar_today', + family: 'internal_indigo', + }, + }), + addIcon('clock', { + default: { + name: 'access_time', + family: 'material', + }, + indigo: { + name: 'access_time', + family: 'internal_indigo', + } + }), + addIcon('date_range', { + default: { + name: 'date_range', + family: 'material', + }, + indigo: { + name: 'calendar_today', + family: 'internal_indigo', + }, + }), + addIcon('prev', { + default: { + name: 'navigate_before', + family: 'material', + }, + indigo: { + name: 'chevron_left', + family: 'internal_indigo', + }, + }), + addIcon('next', { + default: { + name: 'navigate_next', + family: 'material', + }, + indigo: { + name: 'chevron_right', + family: 'internal_indigo', + }, + }), + addIcon('first_page', { + default: { + name: 'first_page', + family: 'material', + }, + indigo: { + name: 'first_page', + family: 'internal_indigo', + }, + }), + addIcon('last_page', { + default: { + name: 'last_page', + family: 'material', + }, + indigo: { + name: 'last_page', + family: 'internal_indigo', + }, + }), + addIcon('add', { + default: { + name: 'add', + family: 'material', + }, + indigo: { + name: 'add', + family: 'internal_indigo', + } + }), + addIcon('close', { + default: { + name: 'close', + family: 'material', + }, + indigo: { + name: 'clear', + family: 'internal_indigo', + }, + }), + addIcon('error', { + default: { + name: 'error', + family: 'material', + }, + indigo: { + name: 'error', + family: 'internal_indigo', + } + }), + addIcon('confirm', { + default: { + name: 'check', + family: 'material', + }, + indigo: { + name: 'check', + family: 'internal_indigo', + }, + }), + addIcon('cancel', { + default: { + name: 'close', + family: 'material', + }, + indigo: { + name: 'clear', + family: 'internal_indigo', + }, + }), + addIcon('edit', { + default: { + name: 'edit', + family: 'material', + }, + }), + addIcon('delete', { + default: { + name: 'delete', + family: 'material', + }, + }), + addIcon('pin', { + default: { + name: 'pin-left', + family: 'imx-icons', + }, + indigo: { + name: 'pin', + family: 'internal_indigo', + }, + }), + addIcon('unpin', { + default: { + name: 'unpin-left', + family: 'imx-icons', + }, + indigo: { + name: 'unpin', + family: 'internal_indigo', + }, + }), + addIcon('show', { + default: { + name: 'visibility', + family: 'material', + }, + }), + addIcon('hide', { + default: { + name: 'visibility_off', + family: 'material', + }, + }), + addIcon('tree_expand', { + default: { + name: 'chevron_right', + family: 'material', + }, + indigo: { + name: 'chevron_right', + family: 'internal_indigo', + }, + }), + addIcon('tree_collapse', { + default: { + name: 'expand_more', + family: 'material', + }, + indigo: { + name: 'chevron_down', + family: 'internal_indigo', + }, + }), + addIcon('chevron_right', { + default: { + name: 'chevron_right', + family: 'material', + }, + indigo: { + name: 'chevron_right', + family: 'internal_indigo', + }, + }), + addIcon('chevron_left', { + default: { + name: 'chevron_left', + family: 'material', + }, + indigo: { + name: 'chevron_left', + family: 'internal_indigo', + }, + }), + addIcon('expand_more', { + default: { + name: 'expand_more', + family: 'material', + }, + indigo: { + name: 'chevron_down', + family: 'internal_indigo', + }, + }), + addIcon('filter_list', { + default: { + name: 'filter_list', + family: 'material', + }, + indigo: { + name: 'filter_list', + family: 'internal_indigo', + }, + }), + addIcon('import_export', { + default: { + name: 'import_export', + family: 'material', + }, + }), + addIcon('unfold_more', { + default: { + name: 'unfold_more', + family: 'material', + }, + indigo: { + name: 'unfold_more', + family: 'internal_indigo', + }, + }), + addIcon('unfold_less', { + default: { + name: 'unfold_less', + family: 'material', + }, + indigo: { + name: 'unfold_less', + family: 'internal_indigo', + }, + }), + addIcon('drag_indicator', { + default: { + name: 'drag_indicator', + family: 'material', + }, + }), + addIcon('group_work', { + default: { + name: 'group_work', + family: 'material', + }, + }), + addIcon('sort_asc', { + default: { + name: 'arrow_upward', + family: 'material', + }, + indigo: { + name: 'arrow_upward', + family: 'internal_indigo', + }, + }), + addIcon('sort_desc', { + default: { + name: 'arrow_downward', + family: 'material', + }, + indigo: { + name: 'arrow_downward', + family: 'internal_indigo', + }, + }), + addIcon('search', { + default: { + name: 'search', + family: 'material', + }, + indigo: { + name: 'search', + family: 'internal_indigo', + }, + }), + addIcon('functions', { + default: { + name: 'functions', + family: 'material', + }, + }), + addIcon('table_rows', { + default: { + name: 'table_rows', + family: 'material', + }, + }), + addIcon('view_column', { + default: { + name: 'view_column', + family: 'material', + }, + }), + addIcon('refresh', { + default: { + name: 'refresh', + family: 'material', + }, + indigo: { + name: 'refresh', + family: 'internal_indigo', + }, + }), + addIcon('add_row', { + default: { + name: 'add-row', + family: 'imx-icons', + }, + }), + addIcon('add_child', { + default: { + name: 'add-child', + family: 'imx-icons', + }, + }), + addIcon('jump_up', { + default: { + name: 'jump-up', + family: 'imx-icons', + }, + }), + addIcon('jump_down', { + default: { + name: 'jump-down', + family: 'imx-icons', + }, + }), + addIcon('filter_null', { + default: { + name: 'is-null', + family: 'imx-icons', + }, + }), + addIcon('filter_not_null', { + default: { + name: 'is-not-null', + family: 'imx-icons', + }, + }), + addIcon('filter_in', { + default: { + name: 'is-in', + family: 'imx-icons', + }, + }), + addIcon('filter_all', { + default: { + name: 'select-all', + family: 'imx-icons', + }, + }), + addIcon('filter_true', { + default: { + name: 'is-true', + family: 'imx-icons', + }, + }), + addIcon('filter_false', { + default: { + name: 'is-false', + family: 'imx-icons', + }, + }), + addIcon('filter_empty', { + default: { + name: 'is-empty', + family: 'imx-icons', + }, + }), + addIcon('filter_not_empty', { + default: { + name: 'not-empty', + family: 'imx-icons', + }, + }), + addIcon('filter_equal', { + default: { + name: 'equals', + family: 'imx-icons', + }, + }), + addIcon('filter_not_equal', { + default: { + name: 'not-equal', + family: 'imx-icons', + }, + }), + addIcon('filter_before', { + default: { + name: 'is-before', + family: 'imx-icons', + }, + }), + addIcon('filter_after', { + default: { + name: 'is-after', + family: 'imx-icons', + }, + }), + addIcon('filter_today', { + default: { + name: 'today', + family: 'imx-icons', + }, + }), + addIcon('filter_yesterday', { + default: { + name: 'yesterday', + family: 'imx-icons', + }, + }), + addIcon('filter_this_month', { + default: { + name: 'this-month', + family: 'imx-icons', + }, + }), + addIcon('filter_last_month', { + default: { + name: 'last-month', + family: 'imx-icons', + }, + }), + addIcon('filter_next_month', { + default: { + name: 'next-month', + family: 'imx-icons', + }, + }), + addIcon('filter_this_year', { + default: { + name: 'this-year', + family: 'imx-icons', + }, + }), + addIcon('filter_last_year', { + default: { + name: 'last-year', + family: 'imx-icons', + }, + }), + addIcon('filter_next_year', { + default: { + name: 'next-year', + family: 'imx-icons', + }, + }), + addIcon('filter_greater_than', { + default: { + name: 'greater-than', + family: 'imx-icons', + }, + }), + addIcon('filter_less_than', { + default: { + name: 'less-than', + family: 'imx-icons', + }, + }), + addIcon('filter_greater_than_or_equal', { + default: { + name: 'greater-than-or-equal', + family: 'imx-icons', + }, + }), + addIcon('filter_less_than_or_equal', { + default: { + name: 'less-than-or-equal', + family: 'imx-icons', + }, + }), + addIcon('filter_contains', { + default: { + name: 'contains', + family: 'imx-icons', + }, + }), + addIcon('filter_does_not_contain', { + default: { + name: 'does-not-contain', + family: 'imx-icons', + }, + }), + addIcon('filter_starts_with', { + default: { + name: 'starts-with', + family: 'imx-icons', + }, + }), + addIcon('filter_ends_with', { + default: { + name: 'ends-with', + family: 'imx-icons', + }, + }), + addIcon('ungroup', { + default: { + name: 'ungroup', + family: 'imx-icons', + }, + }), + addIcon('file_download', { + default: { + name: 'file_download', + family: 'material', + }, + indigo: { + name: 'file_download', + family: 'internal_indigo', + } + }), + addIcon('file_upload', { + default: { + name: 'file_upload', + family: 'material', + }, + indigo: { + name: 'file_upload', + family: 'internal_indigo', + } + }), + addIcon('horizontal_rule', { + default: { + name: 'horizontal_rule', + family: 'material', + }, + indigo: { + name: 'horizontal_rule', + family: 'internal_indigo', + } + }), + addIcon('menu', { + default: { + name: 'menu', + family: 'material', + }, + indigo: { + name: 'menu', + family: 'internal_indigo', + } + }), +]; + +export const iconReferences = /*@__PURE__*/ loadIconRefs(); diff --git a/projects/igniteui-angular/icon/src/icon/icon.service.spec.ts b/projects/igniteui-angular/icon/src/icon/icon.service.spec.ts new file mode 100644 index 00000000000..08ee0e27266 --- /dev/null +++ b/projects/igniteui-angular/icon/src/icon/icon.service.spec.ts @@ -0,0 +1,290 @@ +import { TestBed, fakeAsync } from "@angular/core/testing"; +import { IconFamily, IconMeta } from "./types"; +import { IgxIconService } from './icon.service'; + +import { first } from 'rxjs/operators'; +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { Component, inject } from "@angular/core"; +import { IgxIconComponent } from "./icon.component"; +import { By } from "@angular/platform-browser"; +import { IgxTheme, THEME_TOKEN, ThemeToken } from 'igniteui-angular/core';; + +describe("Icon Service", () => { + const FAMILY: IconFamily = { + name: "awesome", + meta: { className: "my-awesome-icons", type: "font" }, + }; + + const svgText = ` + + +`; + + let iconRef: { name: string; family: string; icon: IconMeta }; + let iconService: IgxIconService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + providers: [IgxIconService, provideHttpClient(withInterceptorsFromDi())], + }).compileComponents(); + + iconService = TestBed.inject(IgxIconService); + + iconService.setFamily("fa-solid", { + className: "fa", + prefix: "fa-", + type: "font", + }); + + iconRef = { + name: "car", + family: "default", + icon: { + name: "car", + family: "fa-solid", + }, + }; + + iconService.addIconRef(iconRef.name, iconRef.family, iconRef.icon); + }); + + it("should have material as the default icon set", () => { + expect(iconService.defaultFamily.name).toBe("material"); + expect(iconService.defaultFamily.meta.className).toBe("material-icons"); + expect(iconService.defaultFamily.meta.type).toBe("liga"); + }); + + it("should set the default icon set", () => { + expect(() => { + iconService.defaultFamily = FAMILY; + }).not.toThrow(); + }); + + it("should get the default icon set", () => { + iconService.defaultFamily = FAMILY; + + expect(iconService.defaultFamily).toBe(FAMILY); + }); + + it("should associate a name with family metadata", () => { + expect(() => { + iconService.setFamily(FAMILY.name, FAMILY.meta); + }).not.toThrow(); + }); + + it("should create meta icon and family reference to an icon in another family", () => { + const icon = iconService.getIconRef(iconRef.name, iconRef.family); + + expect(icon).toEqual({ + className: "fa", + type: "font", + name: "fa-car", + family: "fa-solid", + }); + }); + + it("should not be able to overwrite an icon reference by calling addIconRef", () => { + iconService.addIconRef(iconRef.name, iconRef.family, { + name: "android", + family: "material", + }); + + const icon = iconService.getIconRef(iconRef.name, iconRef.family); + + expect(icon).toEqual({ + className: "fa", + type: "font", + name: "fa-car", + family: "fa-solid", + }); + }); + + it("should overwrite an icon reference via setIconRef", () => { + iconService.setIconRef(iconRef.name, iconRef.family, { + name: "android", + family: "material", + }); + + const icon = iconService.getIconRef(iconRef.name, iconRef.family); + + expect(icon).toEqual({ + className: "material-icons", + type: "liga", + name: "android", + family: "material", + }); + }); + + it("should get the className by family name", () => { + iconService.setFamily(FAMILY.name, FAMILY.meta); + + expect(iconService.familyClassName(FAMILY.name)).toBe( + FAMILY.meta.className, + ); + }); + + it("should add custom svg icon from url", fakeAsync(( + done: () => object, + ) => { + const iconName = "test"; + const familyName = "svg-icons"; + + spyOn(XMLHttpRequest.prototype, "open").and.callThrough(); + spyOn(XMLHttpRequest.prototype, "send"); + + iconService.addSvgIcon(iconName, "test.svg", familyName); + + expect(XMLHttpRequest.prototype.open).toHaveBeenCalledTimes(1); + expect(XMLHttpRequest.prototype.send).toHaveBeenCalledTimes(1); + + iconService.iconLoaded.pipe().subscribe(() => { + expect( + iconService.isSvgIconCached(iconName, familyName), + ).toBeTruthy(); + done(); + }); + })); + + it("should add custom svg icon from text", () => { + const iconName = "test"; + const familyName = "svg-icons"; + + iconService.addSvgIconFromText(iconName, svgText, familyName); + expect(iconService.isSvgIconCached(iconName, familyName)).toBeTruthy(); + }); + + it("should be able to extend family of type font/liga with svg icons", () => { + const fixture = TestBed.createComponent(IconTestComponent); + const iconName = "test"; + const familyName = "material"; + + iconService.addSvgIconFromText(iconName, svgText, familyName); + fixture.detectChanges(); + + const extendedIcon = fixture.debugElement.query(By.css("igx-icon[extended] svg")).nativeElement; + const builtinIcon = fixture.debugElement.query(By.css("igx-icon[builtin]")).nativeElement; + + expect(extendedIcon).toBeTruthy(); + expect(builtinIcon).toBeTruthy(); + expect(svgText).toContain(extendedIcon.innerHTML); + expect(builtinIcon.textContent).toContain("home"); + expect(iconService.defaultFamily.name).toBe(familyName); + expect(iconService.defaultFamily.meta.type).toBe("liga"); + }); + + it("should be able to reference icons in extended font/liga families", () => { + const fixture = TestBed.createComponent(IconRefComponent); + const iconName = "test"; + const familyName = "material"; + + iconService.addSvgIconFromText(iconName, svgText, familyName); + iconService.addIconRef("reference", "default", { + name: iconName, + family: familyName, + type: "svg", + }); + + fixture.detectChanges(); + + const svg = fixture.debugElement.query(By.css("svg")).nativeElement; + + expect(svg).toBeTruthy(); + expect(svgText).toContain(svg.innerHTML); + }); + + it("should emit loading event for a custom svg icon from url", (done) => { + iconService.iconLoaded.pipe(first()).subscribe((event) => { + expect(event.name).toMatch("test"); + expect(event.family).toMatch("svg-icons"); + done(); + }); + + const iconName = "test"; + const familyName = "svg-icons"; + + spyOn(XMLHttpRequest.prototype, "open").and.callThrough(); + spyOn(XMLHttpRequest.prototype, "send").and.callFake(() => { + (iconService as any)._iconLoaded.next({ + name: iconName, + value: svgText, + family: familyName, + }); + }); + + iconService.addSvgIcon(iconName, "test.svg", familyName); + }); + + it('should change icon references dynamically when the value of THEME_TOKEN changes', () => { + const fixture = TestBed.createComponent(IconWithThemeTokenComponent); + fixture.detectChanges(); + + let arrow_prev = fixture.debugElement.query(By.css("igx-icon[name='arrow_prev']")); + let expand_more = fixture.debugElement.query(By.css("igx-icon[name='expand_more']")); + + expect(fixture.componentInstance.themeToken.theme).toBe('material'); + expect(arrow_prev).toBeTruthy(); + expect(arrow_prev.classes['material-icons']).toBeTrue(); + expect(expand_more).toBeTruthy(); + expect(expand_more.classes['material-icons']).toBeTrue(); + + fixture.componentInstance.setTheme('indigo'); + fixture.detectChanges(); + + arrow_prev = fixture.debugElement.query(By.css("igx-icon[name='arrow_prev']")); + expand_more = fixture.debugElement.query(By.css("igx-icon[name='expand_more']")); + + expect(fixture.componentInstance.themeToken.theme).toBe('indigo'); + + // The class change should be reflected as the family changes + expect(arrow_prev).toBeTruthy(); + expect(arrow_prev.classes['internal_indigo']).toBeTrue(); + + // The expand_more shouldn't change as its reference is set explicitly + expect(expand_more).toBeTruthy(); + expect(expand_more.classes['material-icons']).toBeTrue(); + }); +}); + +@Component({ + template: ` + + + `, + imports: [IgxIconComponent] +}) +class IconTestComponent { } + +@Component({ + template: ``, + imports: [IgxIconComponent] +}) +class IconRefComponent { } + +@Component({ + template: ` + + + `, + providers: [ + { + provide: THEME_TOKEN, + useFactory: () => new ThemeToken() + }, + IgxIconService + ], + imports: [IgxIconComponent] +}) +class IconWithThemeTokenComponent { + public iconService = inject(IgxIconService); + + public themeToken = inject(THEME_TOKEN); + + constructor() { + this.iconService.setIconRef('expand_more', 'default', { family: 'material', name: 'home' }); + } + + public setTheme(theme: IgxTheme) { + this.themeToken.set(theme); + } +} diff --git a/projects/igniteui-angular/icon/src/icon/icon.service.ts b/projects/igniteui-angular/icon/src/icon/icon.service.ts new file mode 100644 index 00000000000..1371da2a988 --- /dev/null +++ b/projects/igniteui-angular/icon/src/icon/icon.service.ts @@ -0,0 +1,407 @@ +import { DestroyRef, Injectable, SecurityContext, DOCUMENT, inject } from "@angular/core"; +import { DomSanitizer, SafeHtml } from "@angular/platform-browser"; +import { HttpClient } from "@angular/common/http"; +import { Observable, Subject } from "rxjs"; +import { PlatformUtil, IgxTheme, THEME_TOKEN, ThemeToken } from "igniteui-angular/core"; +import { iconReferences } from './icon.references' +import { IconFamily, IconMeta, FamilyMeta } from "./types"; +import type { IconType, IconReference } from './types'; +import { IndigoIcons } from "./icons.indigo"; + +/** + * Event emitted when a SVG icon is loaded through + * a HTTP request. + */ +export interface IgxIconLoadedEvent { + /** Name of the icon */ + name: string; + /** The actual SVG text, if any */ + value?: string; + /** The font-family for the icon. Defaults to material. */ + family: string; +} + +/** + * **Ignite UI for Angular Icon Service** - + * + * The Ignite UI Icon Service makes it easy for developers to include custom SVG images and use them with IgxIconComponent. + * In addition it could be used to associate a custom class to be applied on IgxIconComponent according to given font-family. + * + * Example: + * ```typescript + * this.iconService.setFamily('material', { className: 'material-icons', type: 'font' }); + * this.iconService.addSvgIcon('aruba', '/assets/svg/country_flags/aruba.svg', 'svg-flags'); + * ``` + */ +@Injectable({ + providedIn: "root", +}) +export class IgxIconService { + private _sanitizer = inject(DomSanitizer, { optional: true }); + private _httpClient = inject(HttpClient, { optional: true }); + private _platformUtil = inject(PlatformUtil, { optional: true }); + private _themeToken = inject(THEME_TOKEN, { optional: true }); + private _destroyRef = inject(DestroyRef, { optional: true }); + protected document = inject(DOCUMENT, { optional: true }); + + /** + * Observable that emits when an icon is successfully loaded + * through a HTTP request. + * + * @example + * ```typescript + * this.service.iconLoaded.subscribe((ev: IgxIconLoadedEvent) => ...); + * ``` + */ + public iconLoaded: Observable; + + private _defaultFamily: IconFamily = { + name: "material", + meta: { className: "material-icons", type: "liga" }, + }; + private _iconRefs = new Map>(); + private _families = new Map(); + private _cachedIcons = new Map>(); + private _iconLoaded = new Subject(); + private _domParser: DOMParser; + + constructor() { + + this.iconLoaded = this._iconLoaded.asObservable(); + this.setFamily(this._defaultFamily.name, this._defaultFamily.meta); + + const themeChange = this._themeToken?.onChange((theme) => { + this.setRefsByTheme(theme); + }); + + this._destroyRef.onDestroy(() => themeChange?.unsubscribe()); + + if (this._platformUtil?.isBrowser) { + this._domParser = new DOMParser(); + + for (const [name, svg] of IndigoIcons) { + this.addSvgIconFromText(name, svg.value, `internal_${svg.fontSet}`, true); + } + } + } + + /** + * Returns the default font-family. + * ```typescript + * const defaultFamily = this.iconService.defaultFamily; + * ``` + */ + public get defaultFamily(): IconFamily { + return this._defaultFamily; + } + + /** + * Sets the default font-family. + * ```typescript + * this.iconService.defaultFamily = 'svg-flags'; + * ``` + */ + public set defaultFamily(family: IconFamily) { + this._defaultFamily = family; + this.setFamily(this._defaultFamily.name, this._defaultFamily.meta); + } + + /** + * Registers a custom class to be applied to IgxIconComponent for a given font-family. + * ```typescript + * this.iconService.registerFamilyAlias('material', 'material-icons'); + * ``` + * @deprecated in version 18.1.0. Use `setFamily` instead. + */ + public registerFamilyAlias( + alias: string, + className: string = alias, + type: IconType = "font", + ): this { + this.setFamily(alias, { className, type }); + return this; + } + + /** + * Returns the custom class, if any, associated to a given font-family. + * ```typescript + * const familyClass = this.iconService.familyClassName('material'); + * ``` + */ + public familyClassName(alias: string): string { + return this._families.get(alias)?.className || alias; + } + + /** @hidden @internal */ + private familyType(alias: string): IconType { + return this._families.get(alias)?.type; + } + + /** @hidden @internal */ + public setRefsByTheme(theme: IgxTheme) { + for (const { alias, target } of iconReferences) { + const external = this._iconRefs.get(alias.family)?.get(alias.name)?.external; + + const _ref = this._iconRefs.get('default')?.get(alias.name) ?? {}; + const _target = target.get(theme) ?? target.get('default')!; + + const icon = target.get(theme) ?? target.get('default')!; + const overwrite = !external && !(JSON.stringify(_ref) === JSON.stringify(_target)); + + this._setIconRef( + alias.name, + alias.family, + icon, + overwrite + ); + } + } + + /** + * Creates a family to className relationship that is applied to the IgxIconComponent + * whenever that family name is used. + * ```typescript + * this.iconService.setFamily('material', { className: 'material-icons', type: 'liga' }); + * ``` + */ + public setFamily(name: string, meta: FamilyMeta) { + this._families.set(name, meta); + } + + /** + * Adds an icon reference meta for an icon in a meta family. + * Executes only if no icon reference is found. + * ```typescript + * this.iconService.addIconRef('aruba', 'default', { name: 'aruba', family: 'svg-flags' }); + * ``` + */ + public addIconRef(name: string, family: string, icon: IconMeta) { + const iconRef = this._iconRefs.get(family)?.get(name); + + if (!iconRef) { + this.setIconRef(name, family, icon); + } + } + + private _setIconRef(name: string, family: string, icon: IconMeta, overwrite = false) { + if (overwrite) { + this.setIconRef(name, family, { + ...icon, + external: false + }); + } + } + + /** + * Similar to addIconRef, but always sets the icon reference meta for an icon in a meta family. + * ```typescript + * this.iconService.setIconRef('aruba', 'default', { name: 'aruba', family: 'svg-flags' }); + * ``` + */ + public setIconRef(name: string, family: string, icon: IconMeta) { + let familyRef = this._iconRefs.get(family); + + if (!familyRef) { + familyRef = new Map(); + this._iconRefs.set(family, familyRef); + } + + const external = icon.external ?? true; + const familyType = this.familyType(icon?.family); + familyRef.set(name, { ...icon, type: icon.type ?? familyType, external }); + + this._iconLoaded.next({ name, family }); + } + + /** + * Returns the icon reference meta for an icon in a given family. + * ```typescript + * const iconRef = this.iconService.getIconRef('aruba', 'default'); + * ``` + */ + public getIconRef(name: string, family: string): IconReference { + const icon = this._iconRefs.get(family)?.get(name); + + const iconFamily = icon?.family ?? family; + const _name = icon?.name ?? name; + const className = this.familyClassName(iconFamily); + const prefix = this._families.get(iconFamily)?.prefix; + + // Handle name prefixes + let iconName = _name; + + if (iconName && prefix) { + iconName = _name.includes(prefix) ? _name : `${prefix}${_name}`; + } + + const cached = this.isSvgIconCached(iconName, iconFamily); + const type = cached ? "svg" : icon?.type ?? this.familyType(iconFamily); + + return { + className, + type, + name: iconName, + family: iconFamily, + }; + } + + private getOrCreateSvgFamily(family: string) { + if (!this._families.has(family)) { + this._families.set(family, { className: family, type: "svg" }); + } + + return this._families.get(family); + } + /** + * Adds an SVG image to the cache. SVG source is an url. + * ```typescript + * this.iconService.addSvgIcon('aruba', '/assets/svg/country_flags/aruba.svg', 'svg-flags'); + * ``` + */ + public addSvgIcon( + name: string, + url: string, + family = this._defaultFamily.name, + stripMeta = false, + ) { + if (name && url) { + const safeUrl = this._sanitizer.bypassSecurityTrustResourceUrl(url); + + if (!safeUrl) { + throw new Error( + `The provided URL could not be processed as trusted resource URL by Angular's DomSanitizer: "${url}".`, + ); + } + + const sanitizedUrl = this._sanitizer.sanitize( + SecurityContext.RESOURCE_URL, + safeUrl, + ); + + if (!sanitizedUrl) { + throw new Error( + `The URL provided was not trusted as a resource URL: "${url}".`, + ); + } + + if (!this.isSvgIconCached(name, family)) { + this.getOrCreateSvgFamily(family); + + this.fetchSvg(url).subscribe((res) => { + this.cacheSvgIcon(name, res, family, stripMeta); + }); + } + } else { + throw new Error( + "You should provide at least `name` and `url` to register an svg icon.", + ); + } + } + + /** + * Adds an SVG image to the cache. SVG source is its text. + * ```typescript + * this.iconService.addSvgIconFromText('simple', ' + * ', 'svg-flags'); + * ``` + */ + public addSvgIconFromText( + name: string, + iconText: string, + family = this._defaultFamily.name, + stripMeta = false, + ) { + if (name && iconText) { + if (this.isSvgIconCached(name, family)) { + return; + } + + this.getOrCreateSvgFamily(family); + this.cacheSvgIcon(name, iconText, family, stripMeta); + } else { + throw new Error( + "You should provide at least `name` and `iconText` to register an svg icon.", + ); + } + } + + /** + * Returns whether a given SVG image is present in the cache. + * ```typescript + * const isSvgCached = this.iconService.isSvgIconCached('aruba', 'svg-flags'); + * ``` + */ + public isSvgIconCached(name: string, family: string): boolean { + if (this._cachedIcons.has(family)) { + const familyRegistry = this._cachedIcons.get( + family, + ) as Map; + + return familyRegistry.has(name); + } + + return false; + } + + /** + * Returns the cached SVG image as string. + * ```typescript + * const svgIcon = this.iconService.getSvgIcon('aruba', 'svg-flags'); + * ``` + */ + public getSvgIcon(name: string, family: string) { + return this._cachedIcons.get(family)?.get(name); + } + + /** + * @hidden + */ + private fetchSvg(url: string): Observable { + const req = this._httpClient.get(url, { responseType: "text" }); + return req; + } + + /** + * @hidden + */ + private cacheSvgIcon( + name: string, + value: string, + family = this._defaultFamily.name, + stripMeta: boolean, + ) { + if (this._platformUtil?.isBrowser && name && value) { + const doc = this._domParser.parseFromString(value, "image/svg+xml"); + const svg = doc.querySelector("svg") as SVGElement; + + if (!this._cachedIcons.has(family)) { + this._cachedIcons.set(family, new Map()); + } + + if (svg) { + svg.setAttribute("fit", ""); + svg.setAttribute("preserveAspectRatio", "xMidYMid meet"); + + if (stripMeta) { + const title = svg.querySelector("title"); + const desc = svg.querySelector("desc"); + + if (title) { + svg.removeChild(title); + } + + if (desc) { + svg.removeChild(desc); + } + } + + const safeSvg = this._sanitizer.bypassSecurityTrustHtml( + svg.outerHTML, + ); + + this._cachedIcons.get(family).set(name, safeSvg); + this._iconLoaded.next({ name, value, family }); + } + } + } +} diff --git a/projects/igniteui-angular/icon/src/icon/icons.indigo.ts b/projects/igniteui-angular/icon/src/icon/icons.indigo.ts new file mode 100644 index 00000000000..a1a0bb91639 --- /dev/null +++ b/projects/igniteui-angular/icon/src/icon/icons.indigo.ts @@ -0,0 +1,294 @@ +import { IMXIcon } from "@igniteui/material-icons-extended" + +const clear: IMXIcon = { + name: 'clear', + value: ` + +`, + categories: ['editor'], + fontSet: 'indigo' +} + +const unfold_more: IMXIcon = { + name: 'unfold_more', + value: ` + + +`, + categories: ['editor'], + fontSet: 'indigo' +} + +const unfold_less: IMXIcon = { + name: 'unfold_less', + value: ` + + +`, + categories: ['editor'], + fontSet: 'indigo' +} + +const arrow_forward: IMXIcon = { + name: 'arrow_forward', + value: ` + +`, + categories: ['editor'], + fontSet: 'indigo' +} + +const arrow_back: IMXIcon = { + name: 'arrow_back', + value: ` + +`, + categories: ['editor'], + fontSet: 'indigo' +} + +const arrow_downward: IMXIcon = { + name: 'arrow_downward', + value: ` + +`, + categories: ['editor'], + fontSet: 'indigo' +} + +const arrow_upward: IMXIcon = { + name: 'arrow_upward', + value: ` + +`, + categories: ['editor'], + fontSet: 'indigo' +} + +const chevron_down: IMXIcon = { + name: 'chevron_down', + value: ` + +`, + categories: ['editor'], + fontSet: 'indigo' +} + +const chevron_up: IMXIcon = { + name: 'chevron_up', + value: ` + +`, + categories: ['editor'], + fontSet: 'indigo' +} + +const chevron_right: IMXIcon = { + name: 'chevron_right', + value: ` + +`, + categories: ['editor'], + fontSet: 'indigo' +} + +const chevron_left: IMXIcon = { + name: 'chevron_left', + value: ` + +`, + categories: ['editor'], + fontSet: 'indigo' +} + +const check: IMXIcon = { + name: 'check', + value: ` + +`, + categories: ['editor'], + fontSet: 'indigo' +} + +const first_page: IMXIcon = { + name: 'first_page', + value: ``, + categories: ['editor'], + fontSet: 'indigo' +} + +const last_page: IMXIcon = { + name: 'last_page', + value: ``, + categories: ['editor'], + fontSet: 'indigo' +} + +const access_time: IMXIcon = { + name: 'access_time', + value: ``, + categories: ['editor'], + fontSet: 'indigo' +} + +const add: IMXIcon = { + name: 'add', + value: ``, + categories: ['editor'], + fontSet: 'indigo' +} + +const attach_file: IMXIcon = { + name: 'attach_file', + value: ``, + categories: ['editor'], + fontSet: 'indigo' +} + +const block: IMXIcon = { + name: 'block', + value: ``, + categories: ['editor'], + fontSet: 'indigo' +} + +const calendar_today: IMXIcon = { + name: 'calendar_today', + value: ``, + categories: ['editor'], + fontSet: 'indigo' +} + +const cancel: IMXIcon = { + name: 'cancel', + value: ``, + categories: ['editor'], + fontSet: 'indigo' +} + +const check_circle: IMXIcon = { + name: 'check_circle', + value: ``, + categories: ['editor'], + fontSet: 'indigo' +} + +const error: IMXIcon = { + name: 'error', + value: ``, + categories: ['editor'], + fontSet: 'indigo' +} + +const file_download: IMXIcon = { + name: 'file_download', + value: ``, + categories: ['editor'], + fontSet: 'indigo' +} + +const file_upload: IMXIcon = { + name: 'file_upload', + categories: ['editor'], + value: ``, + fontSet: 'indigo' +} + +const filter_list: IMXIcon = { + name: 'filter_list', + value: ``, + categories: ['editor'], + fontSet: 'indigo' +} + +const horizontal_rule: IMXIcon = { + name: 'horizontal_rule', + value: ``, + categories: ['editor'], + fontSet: 'indigo' +} + +const info: IMXIcon = { + name: 'info', + value: ``, + categories: ['editor'], + fontSet: 'indigo' +} + +const menu: IMXIcon = { + name: 'menu', + value: ``, + categories: ['editor'], + fontSet: 'indigo' +} + +const pin: IMXIcon = { + name: 'pin', + value: ``, + categories: ['editor'], + fontSet: 'indigo' +} + +const refresh: IMXIcon = { + name: 'refresh', + value: ``, + categories: ['editor'], + fontSet: 'indigo' +} + +const search: IMXIcon = { + name: 'search', + value: ``, + categories: ['editor'], + fontSet: 'indigo' +} + +const send: IMXIcon = { + name: 'send', + value: ``, + categories: ['editor'], + fontSet: 'indigo' +} + +const unpin: IMXIcon = { + name: 'unpin', + value: ``, + categories: ['editor'], + fontSet: 'indigo' +} + +export const IndigoIcons: Map = new Map(Object.entries({ + clear, + unfold_more, + unfold_less, + arrow_forward, + arrow_back, + arrow_downward, + arrow_upward, + chevron_down, + chevron_up, + chevron_right, + chevron_left, + check, + first_page, + last_page, + access_time, + add, + attach_file, + block, + calendar_today, + cancel, + check_circle, + error, + file_download, + file_upload, + filter_list, + horizontal_rule, + info, + menu, + pin, + refresh, + search, + send, + unpin +})); diff --git a/projects/igniteui-angular/icon/src/icon/public_api.ts b/projects/igniteui-angular/icon/src/icon/public_api.ts new file mode 100644 index 00000000000..6f2893f325e --- /dev/null +++ b/projects/igniteui-angular/icon/src/icon/public_api.ts @@ -0,0 +1,6 @@ +export * from './icon.component'; +export * from './icon.service'; +export * from './icons.indigo'; +export { IconMeta, FamilyMeta, IconFamily } from './types'; +export type { IconReference } from './types'; +export * from './icon.module'; diff --git a/projects/igniteui-angular/icon/src/icon/types.ts b/projects/igniteui-angular/icon/src/icon/types.ts new file mode 100644 index 00000000000..7a62e1bd88e --- /dev/null +++ b/projects/igniteui-angular/icon/src/icon/types.ts @@ -0,0 +1,39 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +import { IgxTheme } from "igniteui-angular/core"; + +// Exported internal types +export type IconThemeKey = IgxTheme | 'default'; + +export type IconReferencePair = { + alias: IconMeta; + target: IconMeta; + overwrite: boolean; +}; +export type IconType = "svg" | "font" | "liga"; + +export type IconReference = IconMeta & FamilyMeta; + +export type MetaReference = { + alias: IconMeta; + target: Map; +}; + +// Exported public types +export interface IconMeta { + name: string; + family: string; + type?: IconType; + /** @hidden @internal */ + external?: boolean; +} + +export interface FamilyMeta { + className: string; + type: IconType; + prefix?: string; +} + +export interface IconFamily { + name: string; + meta: FamilyMeta; +} diff --git a/projects/igniteui-angular/icon/src/public_api.ts b/projects/igniteui-angular/icon/src/public_api.ts new file mode 100644 index 00000000000..6e2706efbbe --- /dev/null +++ b/projects/igniteui-angular/icon/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './icon/public_api'; +export * from './icon/icon.module'; diff --git a/projects/igniteui-angular/input-group/README.md b/projects/igniteui-angular/input-group/README.md new file mode 100644 index 00000000000..0a3ac91ef35 --- /dev/null +++ b/projects/igniteui-angular/input-group/README.md @@ -0,0 +1,62 @@ +# igx-input-group + +#### Category +_Components_ + +## Description +_igx-input-group represents a input field._ +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/input-group) + +## Usage +```html + + +359 + + + + phone + + +``` + +### Elements +The following directives could be wrapped in an container - igxInput, igxLabel, igxPrefix, igxSuffix or igxHint. + +#### Prefix & Suffix +Both directives can contain html elements, strings, icons or even other components. Let's add a new input field with string prefix (+359) and igxIcon suffix (phone) + +#### Hints +Ignite UI for Angular Hint provides a helper text placed below the input. The hint can be placed at the start or at the end of the input. The position of the igxHint can be set using the position property. Let's add a hint to our phone input: + +```html + + +359 + + + + phone + + Ex.: +359 888 123 456 + +``` + + +## API + +### Inputs + +| Name | Description | +| :--- | :--- | +| type | How the input will be styled. The allowed values are line, box, border and search. The default is line.| +| theme | Allows the user to change the theme of the input group. | +| position | **`Hint` API**. Where the hint will be placed. The allowed values are start and end. The default value is start. | + + +### Methods + +| Name | Description | +| :--- | :--- | +| isTypeLine() | Whether the `igxInputGroup` type is line | +| isTypeBox() | Whether the igxInputGroup type is box | +| isTypeBorder() | Whether the igxInputGroup type is border | +| isTypeSearch() | Whether the igxInputGroup type is search. | diff --git a/projects/igniteui-angular/input-group/index.ts b/projects/igniteui-angular/input-group/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/input-group/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/input-group/ng-package.json b/projects/igniteui-angular/input-group/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/input-group/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/input-group/src/input-group/directives-hint/hint.directive.spec.ts b/projects/igniteui-angular/input-group/src/input-group/directives-hint/hint.directive.spec.ts new file mode 100644 index 00000000000..b7561ac161c --- /dev/null +++ b/projects/igniteui-angular/input-group/src/input-group/directives-hint/hint.directive.spec.ts @@ -0,0 +1,60 @@ +import { Component } from '@angular/core'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { IgxHintDirective } from './hint.directive'; + +describe('IgxHint', () => { + const HINT_START_CSS_CLASS = 'igx-input-group__hint-item--start'; + const HINT_END_CSS_CLASS = 'igx-input-group__hint-item--end'; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + HintComponent, + StartHintComponent, + EndHintComponent + ] + }) + .compileComponents(); + })); + + it('Initializes a hint.', () => { + const fixture = TestBed.createComponent(HintComponent); + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('.' + HINT_START_CSS_CLASS))).toBeTruthy(); + }); + + it('Initializes a hint with position start.', () => { + const fixture = TestBed.createComponent(StartHintComponent); + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('.' + HINT_START_CSS_CLASS))).toBeTruthy(); + }); + + it('Initializes a hint with position end.', () => { + const fixture = TestBed.createComponent(EndHintComponent); + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('.' + HINT_END_CSS_CLASS))).toBeTruthy(); + }); +}); + +@Component({ + template: `regular hint`, + imports: [IgxHintDirective] +}) +class HintComponent { +} + +@Component({ + template: `hin with position start`, + imports: [IgxHintDirective] +}) +class StartHintComponent { +} + +@Component({ + template: `hint with position end`, + imports: [IgxHintDirective] +}) +class EndHintComponent { +} diff --git a/projects/igniteui-angular/input-group/src/input-group/directives-hint/hint.directive.ts b/projects/igniteui-angular/input-group/src/input-group/directives-hint/hint.directive.ts new file mode 100644 index 00000000000..12611d13f3e --- /dev/null +++ b/projects/igniteui-angular/input-group/src/input-group/directives-hint/hint.directive.ts @@ -0,0 +1,98 @@ +import { Directive, HostBinding, Input, OnInit } from '@angular/core'; + +enum IgxHintPosition { + START, + END +} + +@Directive({ + selector: 'igx-hint,[igxHint]', + standalone: true +}) +export class IgxHintDirective implements OnInit { + /** + * Sets/gets whether the hint position is at the start. + * Default value is `false`. + * ```typescript + * @ViewChild('hint', {read: IgxHintDirective}) + * public igxHint: IgxHintDirective; + * this.igxHint.isPositionStart = true; + * ``` + * ```typescript + * let isHintPositionStart = this.igxHint.isPositionStart; + * ``` + * + * @memberof IgxHintDirective + */ + @HostBinding('class.igx-input-group__hint-item--start') + public isPositionStart = false; + /** + * Sets/gets whether the hint position is at the end. + * Default value is `false`. + * ```typescript + * @ViewChild('hint', {read: IgxHintDirective}) + * public igxHint: IgxHintDirective; + * this.igxHint.isPositionEnd = true; + * ``` + * ```typescript + * let isHintPositionEnd = this.igxHint.isPositionEnd; + * ``` + * + * @memberof IgxHintDirective + */ + @HostBinding('class.igx-input-group__hint-item--end') + public isPositionEnd = false; + + private _position: IgxHintPosition = IgxHintPosition.START; + /** + * Sets the position of the hint. + * ```html + * + * + * IgxHint displayed at the start + * + * ``` + * + * @memberof IgxHintDirective + */ + @Input() + public set position(value: string) { + const position: IgxHintPosition = (IgxHintPosition as any)[value.toUpperCase()]; + if (position !== undefined) { + this._position = position; + this._applyPosition(this._position); + } + } + /** + * Gets the position of the hint. + * ```typescript + * @ViewChild('hint', {read: IgxHintDirective}) + * public igxHint: IgxHintDirective; + * let hintPosition = this.igxHint.position; + * ``` + * + * @memberof IgxHintDirective + */ + public get position() { + return this._position.toString(); + } + /** + * @hidden + */ + public ngOnInit() { + this._applyPosition(this._position); + } + + private _applyPosition(position: IgxHintPosition) { + this.isPositionStart = this.isPositionEnd = false; + switch (position) { + case IgxHintPosition.START: + this.isPositionStart = true; + break; + case IgxHintPosition.END: + this.isPositionEnd = true; + break; + default: break; + } + } +} diff --git a/projects/igniteui-angular/input-group/src/input-group/directives-input/README.md b/projects/igniteui-angular/input-group/src/input-group/directives-input/README.md new file mode 100644 index 00000000000..97db31e434f --- /dev/null +++ b/projects/igniteui-angular/input-group/src/input-group/directives-input/README.md @@ -0,0 +1,14 @@ +# igxInput + +**igxInput** +With the igxInput directive, you can add **inputs** in your markup. +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/label_input.html) + +# Usage + +```html +
    + + +
    +``` diff --git a/projects/igniteui-angular/input-group/src/input-group/directives-input/input.directive.spec.ts b/projects/igniteui-angular/input-group/src/input-group/directives-input/input.directive.spec.ts new file mode 100644 index 00000000000..7d82af8c502 --- /dev/null +++ b/projects/igniteui-angular/input-group/src/input-group/directives-input/input.directive.spec.ts @@ -0,0 +1,1385 @@ +import { Component, ViewChild, ViewChildren, QueryList, DebugElement, inject } from '@angular/core'; +import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { FormsModule, UntypedFormBuilder, ReactiveFormsModule, Validators, UntypedFormControl, UntypedFormGroup, FormControl } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { IgxInputGroupComponent } from '../input-group.component'; +import { IgxInputDirective, IgxInputState } from './input.directive'; +import { UIInteractions } from '../../../../test-utils/ui-interactions.spec'; + +import { IgxIconComponent } from '../../../../icon/src/icon/icon.component'; +import { IgxLabelDirective } from '../directives-label/label.directive'; +import { IgxMaskDirective } from 'igniteui-angular/directives'; +import { IgxSuffixDirective } from '../directives-suffix/suffix.directive'; + +const INPUT_CSS_CLASS = 'igx-input-group__input'; +const CSS_CLASS_INPUT_GROUP_LABEL = 'igx-input-group__label'; +const TEXTAREA_CSS_CLASS = 'igx-input-group__textarea'; + +const INPUT_GROUP_FOCUSED_CSS_CLASS = 'igx-input-group--focused'; +const INPUT_GROUP_PLACEHOLDER_CSS_CLASS = 'igx-input-group--placeholder'; +const INPUT_GROUP_FILLED_CSS_CLASS = 'igx-input-group--filled'; +const INPUT_GROUP_DISABLED_CSS_CLASS = 'igx-input-group--disabled'; + +const INPUT_GROUP_REQUIRED_CSS_CLASS = 'igx-input-group--required'; +const INPUT_GROUP_VALID_CSS_CLASS = 'igx-input-group--valid'; +const INPUT_GROUP_INVALID_CSS_CLASS = 'igx-input-group--invalid'; + +describe('IgxInput', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + InputComponent, + TextareaComponent, + InputWithPlaceholderComponent, + InitiallyFilledInputComponent, + FilledInputComponent, + DisabledInputComponent, + RequiredInputComponent, + RequiredTwoWayDataBoundInputComponent, + DataBoundDisabledInputComponent, + DataBoundDisabledInputWithoutValueComponent, + ReactiveFormComponent, + InputsWithSameNameAttributesComponent, + ToggleRequiredWithNgModelInputComponent, + InputReactiveFormComponent, + FileInputFormComponent + ] + }).compileComponents(); + })); + + it('Initializes an input.', () => { + const fixture = TestBed.createComponent(InputComponent); + fixture.detectChanges(); + + const inputElement = fixture.debugElement.query(By.directive(IgxInputDirective)).nativeElement; + expect(inputElement.classList.length).toBe(1); + expect(inputElement.classList.contains(INPUT_CSS_CLASS)).toBe(true); + }); + + it('Initializes a textarea.', () => { + const fixture = TestBed.createComponent(TextareaComponent); + fixture.detectChanges(); + + const inputElement = fixture.debugElement.query(By.directive(IgxInputDirective)).nativeElement; + expect(inputElement.classList.length).toBe(1); + expect(inputElement.classList.contains(TEXTAREA_CSS_CLASS)).toBe(true); + }); + + it('should apply focused style.', () => { + const fixture = TestBed.createComponent(InputComponent); + fixture.detectChanges(); + + const inputElement = fixture.debugElement.query(By.directive(IgxInputDirective)).nativeElement; + inputElement.dispatchEvent(new Event('focus')); + fixture.detectChanges(); + + const igxInput = fixture.componentInstance.igxInput; + const inputGroupElement = fixture.debugElement.query(By.css('igx-input-group')).nativeElement; + expect(inputGroupElement.classList.contains(INPUT_GROUP_FOCUSED_CSS_CLASS)).toBe(true); + expect(igxInput.focused).toBe(true); + + inputElement.dispatchEvent(new Event('blur')); + fixture.detectChanges(); + + expect(inputGroupElement.classList.contains(INPUT_GROUP_FOCUSED_CSS_CLASS)).toBe(false); + expect(igxInput.focused).toBe(false); + }); + + it('should have a placeholder style. Placeholder API.', () => { + const fixture = TestBed.createComponent(InputWithPlaceholderComponent); + fixture.detectChanges(); + + const inputGroupElement = fixture.debugElement.query(By.css('igx-input-group')).nativeElement; + expect(inputGroupElement.classList.contains(INPUT_GROUP_PLACEHOLDER_CSS_CLASS)).toBe(true); + + const igxInput = fixture.componentInstance.igxInput; + expect(igxInput.hasPlaceholder).toBe(true); + expect(igxInput.placeholder).toBe('Test'); + }); + + it('should have an initial filled style when data bound.', fakeAsync(() => { + const fixture = TestBed.createComponent(InitiallyFilledInputComponent); + fixture.detectChanges(); + + tick(); + fixture.detectChanges(); + + const notFilledUndefined = fixture.componentInstance.igxInputGroupNotFilledUndefined.element.nativeElement; + expect(notFilledUndefined.classList.contains(INPUT_GROUP_FILLED_CSS_CLASS)).toBe(false); + + const notFilledNull = fixture.componentInstance.igxInputGroupNotFilledNull.element.nativeElement; + expect(notFilledNull.classList.contains(INPUT_GROUP_FILLED_CSS_CLASS)).toBe(false); + + const notFilledEmpty = fixture.componentInstance.igxInputGroupNotFilledEmpty.element.nativeElement; + expect(notFilledEmpty.classList.contains(INPUT_GROUP_FILLED_CSS_CLASS)).toBe(false); + + const filledString = fixture.componentInstance.igxInputGroupFilledString.element.nativeElement; + expect(filledString.classList.contains(INPUT_GROUP_FILLED_CSS_CLASS)).toBe(true); + + const filledNumber = fixture.componentInstance.igxInputGroupFilledNumber.element.nativeElement; + expect(filledNumber.classList.contains(INPUT_GROUP_FILLED_CSS_CLASS)).toBe(true); + + const filledBoolFalse = fixture.componentInstance.igxInputGroupFilledBoolFalse.element.nativeElement; + expect(filledBoolFalse.classList.contains(INPUT_GROUP_FILLED_CSS_CLASS)).toBe(true); + + const filledBoolTrue = fixture.componentInstance.igxInputGroupFilledBoolTrue.element.nativeElement; + expect(filledBoolTrue.classList.contains(INPUT_GROUP_FILLED_CSS_CLASS)).toBe(true); + + const filledDate = fixture.componentInstance.igxInputGroupFilledDate.element.nativeElement; + expect(filledDate.classList.contains(INPUT_GROUP_FILLED_CSS_CLASS)).toBe(true); + })); + + it('should have a filled style. Value API.', () => { + const fixture = TestBed.createComponent(FilledInputComponent); + fixture.detectChanges(); + + const inputGroupElement = fixture.debugElement.query(By.css('igx-input-group')).nativeElement; + expect(inputGroupElement.classList.contains(INPUT_GROUP_FILLED_CSS_CLASS)).toBe(true); + + const inputElement = fixture.debugElement.query(By.directive(IgxInputDirective)).nativeElement; + inputElement.value = ''; + inputElement.dispatchEvent(new Event('input')); + fixture.detectChanges(); + expect(inputGroupElement.classList.contains(INPUT_GROUP_FILLED_CSS_CLASS)).toBe(false); + + const igxInput = fixture.componentInstance.igxInput; + igxInput.value = 'test'; + inputElement.dispatchEvent(new Event('input')); + fixture.detectChanges(); + expect(inputGroupElement.classList.contains(INPUT_GROUP_FILLED_CSS_CLASS)).toBe(true); + expect(igxInput.value).toBe('test'); + }); + + it('should have a disabled style. Disabled API.', () => { + const fixture = TestBed.createComponent(DisabledInputComponent); + fixture.detectChanges(); + + const igxInput = fixture.componentInstance.igxInput; + const inputElement = fixture.debugElement.query(By.directive(IgxInputDirective)).nativeElement; + const inputGroupElement = fixture.debugElement.query(By.css('igx-input-group')).nativeElement; + expect(inputGroupElement.classList.contains(INPUT_GROUP_DISABLED_CSS_CLASS)).toBe(true); + expect(inputElement.disabled).toBe(true); + expect(igxInput.disabled).toBe(true); + + igxInput.disabled = false; + fixture.detectChanges(); + + expect(inputGroupElement.classList.contains(INPUT_GROUP_DISABLED_CSS_CLASS)).toBe(false); + expect(inputElement.disabled).toBe(false); + expect(igxInput.disabled).toBe(false); + }); + + it('should have a disabled style via binding', () => { + const fixture = TestBed.createComponent(DataBoundDisabledInputComponent); + fixture.detectChanges(); + + const igxInput = fixture.componentInstance.igxInput; + const inputElement = fixture.debugElement.query(By.directive(IgxInputDirective)).nativeElement; + const inputGroupElement = fixture.debugElement.query(By.css('igx-input-group')).nativeElement; + expect(inputGroupElement.classList.contains(INPUT_GROUP_DISABLED_CSS_CLASS)).toBe(false); + expect(inputElement.disabled).toBe(false); + expect(igxInput.disabled).toBe(false); + + fixture.componentInstance.disabled = true; + fixture.detectChanges(); + + expect(inputGroupElement.classList.contains(INPUT_GROUP_DISABLED_CSS_CLASS)).toBe(true); + expect(inputElement.disabled).toBe(true); + expect(igxInput.disabled).toBe(true); + }); + + it('should have disabled style if disabled attr is set without value', () => { + const fixture = TestBed.createComponent(DataBoundDisabledInputWithoutValueComponent); + fixture.detectChanges(); + + const igxInput = fixture.componentInstance.igxInput; + const inputElement = fixture.debugElement.query(By.directive(IgxInputDirective)).nativeElement; + const inputGroupElement = fixture.debugElement.query(By.css('igx-input-group')).nativeElement; + expect(inputGroupElement.classList.contains(INPUT_GROUP_DISABLED_CSS_CLASS)).toBe(true); + expect(inputElement.disabled).toBe(true); + expect(igxInput.disabled).toBe(true); + + fixture.componentInstance.changeDisabledState(); + fixture.detectChanges(); + + expect(inputGroupElement.classList.contains(INPUT_GROUP_DISABLED_CSS_CLASS)).toBe(false); + expect(inputElement.disabled).toBe(false); + expect(igxInput.disabled).toBe(false); + }); + + it('should style required input correctly.', () => { + const fixture = TestBed.createComponent(RequiredInputComponent); + fixture.detectChanges(); + + const inputElement = fixture.debugElement.query(By.directive(IgxInputDirective)).nativeElement; + testRequiredValidation(inputElement, fixture); + }); + + it('should update style when required input\'s value is set.', () => { + const fixture = TestBed.createComponent(RequiredInputComponent); + fixture.detectChanges(); + + const igxInput = fixture.componentInstance.igxInput; + const inputElement = fixture.debugElement.query(By.directive(IgxInputDirective)).nativeElement; + + dispatchInputEvent('focus', inputElement, fixture); + dispatchInputEvent('blur', inputElement, fixture); + + const inputGroupElement = fixture.debugElement.query(By.css('igx-input-group')).nativeElement; + expect(inputGroupElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(true); + expect(igxInput.valid).toBe(IgxInputState.INVALID); + + dispatchInputEvent('focus', inputElement, fixture); + igxInput.value = 'test'; + fixture.detectChanges(); + + expect(inputGroupElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(false); + expect(igxInput.valid).toBe(IgxInputState.VALID); + + + igxInput.value = ''; + fixture.detectChanges(); + + dispatchInputEvent('focus', inputElement, fixture); + dispatchInputEvent('blur', inputElement, fixture); + + expect(inputGroupElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(true); + expect(igxInput.valid).toBe(IgxInputState.INVALID); + }); + + it('should style required input with two-way databinding correctly.', () => { + const fixture = TestBed.createComponent(RequiredTwoWayDataBoundInputComponent); + fixture.detectChanges(); + + const inputElement = fixture.debugElement.query(By.directive(IgxInputDirective)).nativeElement; + testRequiredValidation(inputElement, fixture); + }); + + it('should work properly with reactive forms validation.', () => { + const fixture = TestBed.createComponent(ReactiveFormComponent); + fixture.detectChanges(); + + fixture.debugElement.componentInstance.markAsTouched(); + fixture.detectChanges(); + + const invalidInputGroups = fixture.debugElement.nativeElement.querySelectorAll(`.igx-input-group--invalid`); + expect(invalidInputGroups.length).toBe(6); + + const requiredInputGroups = fixture.debugElement.nativeElement.querySelectorAll(`.igx-input-group--required`); + expect(requiredInputGroups.length).toBe(6); + }); + + it('When updating two inputs with same attribute names through ngModel, label should responds', fakeAsync(() => { + + const fix = TestBed.createComponent(InputsWithSameNameAttributesComponent); + fix.detectChanges(); + + let igxInputGroups = fix.debugElement.queryAll(By.css('igx-input-group')); + igxInputGroups.forEach(element => { + const inputGroup = element.nativeElement; + expect(inputGroup.classList.contains('igx-input-group--filled')).toBe(false); + }); + + fix.componentInstance.model.firstName = 'Mike'; + fix.detectChanges(); + + tick(); + fix.detectChanges(); + + igxInputGroups = fix.debugElement.queryAll(By.css('igx-input-group')); + igxInputGroups.forEach(element => { + const inputGroup = element.nativeElement; + expect(inputGroup.classList.contains('igx-input-group--filled')).toBe(true); + }); + })); + + it('should not draw input as invalid when updated through ngModel and input is pristine and untouched', fakeAsync(() => { + const fix = TestBed.createComponent(RequiredTwoWayDataBoundInputComponent); + fix.detectChanges(); + + const inputGroup = fix.debugElement.children[0]; + + fix.componentInstance.user.firstName = 'Bobby'; + fix.detectChanges(); + tick(); + fix.detectChanges(); + expect(inputGroup.nativeElement.classList.contains('igx-input-group--filled')).toBe(true); + + fix.componentInstance.user.firstName = undefined; + fix.detectChanges(); + tick(); + fix.detectChanges(); + expect(inputGroup.nativeElement.classList.contains('igx-input-group--invalid')).toBe(false); + + fix.componentInstance.user.firstName = ''; + fix.detectChanges(); + tick(); + fix.detectChanges(); + expect(inputGroup.nativeElement.classList.contains('igx-input-group--invalid')).toBe(false); + })); + + it('should not draw input as invalid when value is changed via reactive form and input is pristine and untouched', () => { + const fix = TestBed.createComponent(ReactiveFormComponent); + fix.detectChanges(); + + const igxInputGroups = fix.debugElement.queryAll(By.css('igx-input-group')); + const firstInputGroup = igxInputGroups[0]; + + fix.componentInstance.form.patchValue({ str: 'test' }); + fix.detectChanges(); + expect(firstInputGroup.nativeElement.classList.contains('igx-input-group--filled')).toBe(true); + + fix.componentInstance.form.patchValue({ str: undefined }); + fix.detectChanges(); + expect(firstInputGroup.nativeElement.classList.contains('igx-input-group--invalid')).toBe(false); + + fix.componentInstance.form.patchValue({ str: '' }); + fix.detectChanges(); + expect(firstInputGroup.nativeElement.classList.contains('igx-input-group--invalid')).toBe(false); + }); + + it('should correctly update state of input without model when updated trough code', fakeAsync(() => { + const fixture = TestBed.createComponent(RequiredInputComponent); + fixture.detectChanges(); + + const igxInput = fixture.componentInstance.igxInput; + + const inputGroupElement = fixture.debugElement.query(By.css('igx-input-group')).nativeElement; + expect(inputGroupElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(false); + expect(igxInput.valid).toBe(IgxInputState.INITIAL); + + igxInput.value = 'test'; + fixture.detectChanges(); + + expect(inputGroupElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(false); + expect(igxInput.valid).toBe(IgxInputState.INITIAL); + + + igxInput.value = ''; + fixture.detectChanges(); + + expect(inputGroupElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(true); + expect(igxInput.valid).toBe(IgxInputState.INVALID); + })); + + it('should correctly update state of input when updated through ngModel, no user interactions', fakeAsync(() => { + const fixture = TestBed.createComponent(RequiredTwoWayDataBoundInputComponent); + fixture.detectChanges(); + + const inputGroupElement = fixture.componentInstance.igxInputGroup.element.nativeElement; + const igxInput = fixture.componentInstance.igxInput; + const inputElement = igxInput.nativeElement; + expect(inputGroupElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(false); + expect(igxInput.valid).toBe(IgxInputState.INITIAL); + + fixture.componentInstance.user.firstName = 'Bobby'; + + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + expect(inputGroupElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(false); + expect(igxInput.valid).toBe(IgxInputState.INITIAL); + expect(inputElement.attributes.getNamedItem('aria-invalid').nodeValue).toEqual('false'); + + igxInput.value = ''; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + expect(inputGroupElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(false); + expect(igxInput.valid).toBe(IgxInputState.INITIAL); + expect(inputElement.attributes.getNamedItem('aria-invalid').nodeValue).toEqual('false'); + + igxInput.value = undefined; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + expect(inputGroupElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(false); + expect(igxInput.valid).toBe(IgxInputState.INITIAL); + expect(inputElement.attributes.getNamedItem('aria-invalid').nodeValue).toEqual('false'); + })); + + it('should correctly update state of input when value is changed via reactive, no user interactions', fakeAsync(() => { + const fixture = TestBed.createComponent(ReactiveFormComponent); + fixture.detectChanges(); + + const igxInputGroups = fixture.debugElement.queryAll(By.css('igx-input-group')); + const inputGroupElement = igxInputGroups[0].nativeElement; + const igxInput = fixture.componentInstance.strIgxInput; + const inputElement = igxInput.nativeElement; + + expect(inputGroupElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(false); + expect(igxInput.valid).toBe(IgxInputState.INITIAL); + + fixture.componentInstance.form.patchValue({ str: 'Bobby' }); + fixture.detectChanges(); + + expect(inputGroupElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(false); + expect(igxInput.valid).toBe(IgxInputState.INITIAL); + expect(inputElement.attributes.getNamedItem('aria-invalid').nodeValue).toEqual('false'); + + fixture.componentInstance.form.patchValue({ str: '' }); + fixture.detectChanges(); + + expect(inputGroupElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(false); + expect(igxInput.valid).toBe(IgxInputState.INITIAL); + expect(inputElement.attributes.getNamedItem('aria-invalid').nodeValue).toEqual('false'); + + fixture.componentInstance.form.patchValue({ str: undefined }); + fixture.detectChanges(); + + expect(inputGroupElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(false); + expect(igxInput.valid).toBe(IgxInputState.INITIAL); + expect(inputElement.attributes.getNamedItem('aria-invalid').nodeValue).toEqual('false'); + + fixture.componentInstance.form.reset(); + fixture.detectChanges(); + + expect(inputGroupElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(false); + expect(igxInput.valid).toBe(IgxInputState.INITIAL); + expect(inputElement.attributes.getNamedItem('aria-invalid').nodeValue).toEqual('false'); + })); + + it('should correctly update state of input when updated through ngModel, with user interactions', fakeAsync(() => { + const fixture = TestBed.createComponent(RequiredTwoWayDataBoundInputComponent); + fixture.detectChanges(); + + const inputGroupElement = fixture.componentInstance.igxInputGroup.element.nativeElement; + const igxInput = fixture.componentInstance.igxInput; + const inputElement = igxInput.nativeElement; + + dispatchInputEvent('focus', inputElement, fixture); + dispatchInputEvent('blur', inputElement, fixture); + + tick(); + fixture.detectChanges(); + + expect(inputGroupElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(true); + expect(igxInput.valid).toBe(IgxInputState.INVALID); + expect(inputElement.attributes.getNamedItem('aria-invalid').nodeValue).toEqual('true'); + + fixture.componentInstance.user.firstName = 'Bobby'; + + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + expect(inputGroupElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(false); + expect(igxInput.valid).toBe(IgxInputState.INITIAL); + expect(inputElement.attributes.getNamedItem('aria-invalid').nodeValue).toEqual('false'); + + fixture.componentInstance.user.firstName = ''; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + expect(inputGroupElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(true); + expect(igxInput.valid).toBe(IgxInputState.INVALID); + expect(inputElement.attributes.getNamedItem('aria-invalid').nodeValue).toEqual('true'); + + fixture.componentInstance.user.firstName = undefined; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + expect(inputGroupElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(true); + expect(igxInput.valid).toBe(IgxInputState.INVALID); + expect(inputElement.attributes.getNamedItem('aria-invalid').nodeValue).toEqual('true'); + })); + + it('should correctly update state of input when value is changed via reactive, with user interactions', fakeAsync(() => { + const fixture = TestBed.createComponent(ReactiveFormComponent); + fixture.detectChanges(); + + const igxInputGroups = fixture.debugElement.queryAll(By.css('igx-input-group')); + const inputGroupElement = igxInputGroups[0].nativeElement; + const igxInput = fixture.componentInstance.strIgxInput; + const input = igxInput.nativeElement; + + dispatchInputEvent('focus', input, fixture); + dispatchInputEvent('blur', input, fixture); + dispatchInputEvent('input', input, fixture); + + expect(inputGroupElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(true); + expect(igxInput.valid).toBe(IgxInputState.INVALID); + expect(input.attributes.getNamedItem('aria-invalid').nodeValue).toEqual('true'); + + fixture.componentInstance.form.patchValue({ str: 'Bobby' }); + fixture.detectChanges(); + + expect(inputGroupElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(false); + expect(igxInput.valid).toBe(IgxInputState.INITIAL); + expect(input.attributes.getNamedItem('aria-invalid').nodeValue).toEqual('false'); + + fixture.componentInstance.form.patchValue({ str: '' }); + fixture.detectChanges(); + + expect(inputGroupElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(true); + expect(igxInput.valid).toBe(IgxInputState.INVALID); + expect(input.attributes.getNamedItem('aria-invalid').nodeValue).toEqual('true'); + + fixture.componentInstance.form.patchValue({ str: undefined }); + fixture.detectChanges(); + + expect(inputGroupElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(true); + expect(igxInput.valid).toBe(IgxInputState.INVALID); + expect(input.attributes.getNamedItem('aria-invalid').nodeValue).toEqual('true'); + + fixture.componentInstance.form.reset(); + fixture.detectChanges(); + + expect(inputGroupElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(false); + expect(igxInput.valid).toBe(IgxInputState.INITIAL); + expect(input.attributes.getNamedItem('aria-invalid').nodeValue).toEqual('false'); + })); + + it('should correctly update enabled/disabled state of igxInput when changed via reactive form', fakeAsync(() => { + const fixture = TestBed.createComponent(ReactiveFormComponent); + fixture.detectChanges(); + const igxInput = fixture.componentInstance.strIgxInput; + + expect(igxInput.disabled).toBe(false); + expect(igxInput.inputGroup.disabled).toBe(false); + + fixture.componentInstance.form.disable(); + expect(igxInput.disabled).toBe(true); + expect(igxInput.inputGroup.disabled).toBe(true); + + fixture.componentInstance.form.get('str').enable(); + expect(igxInput.disabled).toBe(false); + expect(igxInput.inputGroup.disabled).toBe(false); + })); + + it('should style input when required is toggled dynamically.', () => { + const fixture = TestBed.createComponent(ToggleRequiredWithNgModelInputComponent); + fixture.detectChanges(); + + const instance = fixture.componentInstance; + const input = instance.igxInputs.toArray()[1]; + const inputGroup = instance.igxInputGroups.toArray()[1]; + + expect(input.required).toBe(false); + expect(inputGroup.isRequired).toBeFalsy(); + expect(input.valid).toBe(IgxInputState.INITIAL); + expect(inputGroup.element.nativeElement.classList.contains(INPUT_GROUP_REQUIRED_CSS_CLASS)).toBe(false); + + dispatchInputEvent('focus', input.nativeElement, fixture); + expect(input.valid).toBe(IgxInputState.INITIAL); + + input.value = '123'; + dispatchInputEvent('input', input.nativeElement, fixture); + expect(input.valid).toBe(IgxInputState.INITIAL); + expect(inputGroup.element.nativeElement.classList.contains(INPUT_GROUP_VALID_CSS_CLASS)).toBe(false); + + dispatchInputEvent('blur', input.nativeElement, fixture); + expect(input.valid).toBe(IgxInputState.INITIAL); + expect(inputGroup.element.nativeElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(false); + expect(inputGroup.element.nativeElement.classList.contains(INPUT_GROUP_VALID_CSS_CLASS)).toBe(false); + + instance.isRequired = true; + fixture.detectChanges(); + + expect(input.required).toBe(true); + + expect(inputGroup.isRequired).toBeTruthy(); + expect(input.valid).toBe(IgxInputState.INITIAL); + expect(inputGroup.element.nativeElement.classList.contains(INPUT_GROUP_REQUIRED_CSS_CLASS)).toBe(true); + + dispatchInputEvent('focus', input.nativeElement, fixture); + expect(input.valid).toBe(IgxInputState.INITIAL); + + input.value = ''; + dispatchInputEvent('input', input.nativeElement, fixture); + expect(inputGroup.element.nativeElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(true); + + dispatchInputEvent('blur', input.nativeElement, fixture); + expect(inputGroup.element.nativeElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(true); + expect(input.valid).toBe(IgxInputState.INVALID); + + dispatchInputEvent('focus', input.nativeElement, fixture); + input.value = '123'; + dispatchInputEvent('input', input.nativeElement, fixture); + expect(inputGroup.element.nativeElement.classList.contains(INPUT_GROUP_VALID_CSS_CLASS)).toBe(true); + expect(input.valid).toBe(IgxInputState.VALID); + + dispatchInputEvent('blur', input.nativeElement, fixture); + expect(input.valid).toBe(IgxInputState.INITIAL); + expect(inputGroup.element.nativeElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(false); + expect(inputGroup.element.nativeElement.classList.contains(INPUT_GROUP_VALID_CSS_CLASS)).toBe(false); + }); + + it('should style input with ngModel when required is toggled dynamically.', () => { + const fixture = TestBed.createComponent(ToggleRequiredWithNgModelInputComponent); + fixture.detectChanges(); + + const instance = fixture.componentInstance; + const input = instance.igxInputs.toArray()[0]; + const inputGroup = instance.igxInputGroups.toArray()[0]; + + expect(input.required).toBe(false); + expect(inputGroup.isRequired).toBeFalsy(); + expect(input.valid).toBe(IgxInputState.INITIAL); + expect(inputGroup.element.nativeElement.classList.contains(INPUT_GROUP_REQUIRED_CSS_CLASS)).toBe(false); + + dispatchInputEvent('focus', input.nativeElement, fixture); + expect(input.valid).toBe(IgxInputState.INITIAL); + + input.value = '123'; + dispatchInputEvent('input', input.nativeElement, fixture); + expect(inputGroup.element.nativeElement.classList.contains(INPUT_GROUP_VALID_CSS_CLASS)).toBe(true); + + dispatchInputEvent('blur', input.nativeElement, fixture); + expect(input.valid).toBe(IgxInputState.INITIAL); + expect(inputGroup.element.nativeElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(false); + expect(inputGroup.element.nativeElement.classList.contains(INPUT_GROUP_VALID_CSS_CLASS)).toBe(false); + + instance.isRequired = true; + fixture.detectChanges(); + + expect(input.required).toBe(true); + + expect(inputGroup.isRequired).toBeTruthy(); + expect(input.valid).toBe(IgxInputState.INITIAL); + expect(inputGroup.element.nativeElement.classList.contains(INPUT_GROUP_REQUIRED_CSS_CLASS)).toBe(true); + + dispatchInputEvent('focus', input.nativeElement, fixture); + expect(input.valid).toBe(IgxInputState.INITIAL); + + input.value = ''; + dispatchInputEvent('input', input.nativeElement, fixture); + dispatchInputEvent('blur', input.nativeElement, fixture); + expect(inputGroup.element.nativeElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(true); + + dispatchInputEvent('focus', input.nativeElement, fixture); + expect(inputGroup.element.nativeElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(true); + + input.value = '123'; + dispatchInputEvent('input', input.nativeElement, fixture); + expect(inputGroup.element.nativeElement.classList.contains(INPUT_GROUP_VALID_CSS_CLASS)).toBe(true); + + dispatchInputEvent('blur', input.nativeElement, fixture); + expect(input.valid).toBe(IgxInputState.INITIAL); + expect(inputGroup.element.nativeElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(false); + expect(inputGroup.element.nativeElement.classList.contains(INPUT_GROUP_VALID_CSS_CLASS)).toBe(false); + }); + + it('should not set null or undefined as input value', () => { + const fixture = TestBed.createComponent(InputComponent); + fixture.detectChanges(); + + const igxInput = fixture.componentInstance.igxInput; + expect(igxInput.value).toBe(''); + + igxInput.value = undefined; + expect(igxInput.value).toBe(''); + + igxInput.value = null; + expect(igxInput.value).toBe(''); + + igxInput.value = 0; + expect(igxInput.value).toBe('0'); + + igxInput.value = false; + expect(igxInput.value).toBe('false'); + + igxInput.value = 'Test'; + expect(igxInput.value).toBe('Test'); + }); + + it('Should properly initialize when used as a reactive form control - without initial validators/toggle validators', fakeAsync(() => { + const fix = TestBed.createComponent(InputReactiveFormComponent); + fix.detectChanges(); + // 1) check if label's --required class and its asterisk are applied + const dom = fix.debugElement; + const input = fix.componentInstance.input; + const inputGroup = fix.componentInstance.igxInputGroup.element.nativeElement; + const formGroup: UntypedFormGroup = fix.componentInstance.reactiveForm; + + // interaction test - expect actual asterisk + // The only way to get a pseudo elements like :before OR :after is to use getComputedStyle(element [, pseudoElt]), + // as these are not in the actual DOM + let asterisk = window.getComputedStyle(dom.query(By.css('.' + CSS_CLASS_INPUT_GROUP_LABEL)).nativeElement, ':after').content; + expect(asterisk).toBe('"*"'); + expect(inputGroup.classList.contains(INPUT_GROUP_REQUIRED_CSS_CLASS)).toBe(true); + expect(input.nativeElement.attributes.getNamedItem('aria-required').nodeValue).toEqual('true'); + + // 2) check that input group's --invalid class is NOT applied + expect(inputGroup.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(false); + + // interaction test - focus&blur, so the --invalid and --required classes are applied + // *Use markAsTouched() instead of user interaction ( calling focus + blur) because: + // Angular handles blur and marks the component as touched, however: + // in order to ensure Angular handles blur prior to our blur handler (where we check touched), + // we have to call blur twice. + fix.debugElement.componentInstance.markAsTouched(); + tick(); + fix.detectChanges(); + + expect(inputGroup.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(true); + expect(inputGroup.classList.contains(INPUT_GROUP_REQUIRED_CSS_CLASS)).toBe(true); + expect(input.nativeElement.attributes.getNamedItem('aria-required').nodeValue).toEqual('true'); + + // 3) Check if the input group's --invalid and --required classes are removed when validator is dynamically cleared + fix.componentInstance.removeValidators(formGroup); + fix.detectChanges(); + tick(); + + const formReference = fix.componentInstance.reactiveForm.controls.fullName; + // interaction test - expect no asterisk + asterisk = window.getComputedStyle(dom.query(By.css('.' + CSS_CLASS_INPUT_GROUP_LABEL)).nativeElement, ':after').content; + expect(formReference).toBeDefined(); + expect(input).toBeDefined(); + expect(input.nativeElement.value).toEqual(''); + expect(input.nativeElement.attributes.getNamedItem('aria-required').nodeValue).toEqual('false'); + expect(inputGroup.classList.contains(INPUT_GROUP_REQUIRED_CSS_CLASS)).toEqual(false); + expect(asterisk).toBe('none'); + expect(input.valid).toEqual(IgxInputState.INITIAL); + + // interact with the input and expect no changes + input.nativeElement.dispatchEvent(new Event('focus')); + input.nativeElement.dispatchEvent(new Event('blur')); + tick(); + fix.detectChanges(); + expect(input.valid).toEqual(IgxInputState.INITIAL); + + // Re-add all Validators + fix.componentInstance.addValidators(formGroup); + fix.detectChanges(); + + expect(inputGroup.classList.contains(INPUT_GROUP_REQUIRED_CSS_CLASS)).toBe(true); + // interaction test - expect actual asterisk + asterisk = window.getComputedStyle(dom.query(By.css('.' + CSS_CLASS_INPUT_GROUP_LABEL)).nativeElement, ':after').content; + expect(asterisk).toBe('"*"'); + expect(input.nativeElement.attributes.getNamedItem('aria-required').nodeValue).toEqual('true'); + })); + + it('should not hold old file input value in form after clearing the input', () => { + const fixture = TestBed.createComponent(FileInputFormComponent); + fixture.detectChanges(); + + const form = fixture.componentInstance.formWithFileInput; + const igxInput = fixture.componentInstance.input; + const igxInputGroup = fixture.componentInstance.igxInputGroup; + const inputElement = igxInput.nativeElement; + + expect(igxInput.value).toEqual(''); + expect(form.controls['fileInput'].value).toEqual(''); + + const list = new DataTransfer(); + const file = new File(["content"], "filename.jpg"); + list.items.add(file); + const myFileList = list.files; + + inputElement.files = myFileList; + inputElement.dispatchEvent(new Event('input')); + fixture.detectChanges(); + + expect(igxInput.value).toEqual('C:\\fakepath\\filename.jpg'); + expect(form.controls['fileInput'].value).toEqual('C:\\fakepath\\filename.jpg') + + const clearButton = igxInputGroup.element.nativeElement.querySelector('.igx-input-group__clear-icon'); + expect(clearButton).toBeDefined(); + + UIInteractions.simulateClickEvent(clearButton); + fixture.detectChanges(); + + expect(igxInput.value).toEqual(''); + expect(form.controls['fileInput'].value).toEqual(''); + }); + + it('should not hold old file input value after clearing the input when ngModel is used', () => { + const fixture = TestBed.createComponent(FileInputFormComponent); + fixture.detectChanges(); + + const igxInput = fixture.componentInstance.inputWithNgModel; + const igxInputGroup = fixture.componentInstance.igxInputGroupNgModel; + const inputElement = igxInput.nativeElement; + const model = fixture.componentInstance.model; + + expect(igxInput.value).toEqual(''); + expect(model.inputValue).toEqual(null); + + const list = new DataTransfer(); + const file = new File(["content"], "filename.jpg"); + list.items.add(file); + const myFileList = list.files; + + inputElement.files = myFileList; + inputElement.dispatchEvent(new Event('input')); + fixture.detectChanges(); + + expect(igxInput.value).toEqual('C:\\fakepath\\filename.jpg'); + expect(model.inputValue).toEqual('C:\\fakepath\\filename.jpg'); + + const clearButton = igxInputGroup.element.nativeElement.querySelector('.igx-input-group__clear-icon'); + expect(clearButton).toBeDefined(); + + UIInteractions.simulateClickEvent(clearButton); + fixture.detectChanges(); + + expect(igxInput.value).toEqual(''); + expect(model.inputValue).toEqual(''); + }); + + it('Should update validity state when programmatically setting errors on reactive form controls', fakeAsync(() => { + const fix = TestBed.createComponent(InputReactiveFormComponent); + fix.detectChanges(); + + const inputGroup = fix.componentInstance.igxInputGroup.element.nativeElement; + const formGroup = fix.componentInstance.reactiveForm; + + // the form control has validators + formGroup.markAllAsTouched(); + formGroup.get('fullName').setErrors({ error: true }); + fix.detectChanges(); + + expect(inputGroup.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(true); + expect(inputGroup.classList.contains(INPUT_GROUP_REQUIRED_CSS_CLASS)).toBe(true); + + // remove the validators and check the same + fix.componentInstance.removeValidators(formGroup); + formGroup.markAsUntouched(); + tick(); + fix.detectChanges(); + + expect(inputGroup.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(false); + expect(inputGroup.classList.contains(INPUT_GROUP_REQUIRED_CSS_CLASS)).toBe(false); + + formGroup.markAllAsTouched(); + formGroup.get('fullName').setErrors({ error: true }); + fix.detectChanges(); + + // no validator, but there is a set error + expect(inputGroup.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(true); + expect(inputGroup.classList.contains(INPUT_GROUP_REQUIRED_CSS_CLASS)).toBe(false); + })); + + it('should keep state as initial on type when there are no errors and validators on reactive form controls', fakeAsync(() => { + const fix = TestBed.createComponent(InputReactiveFormComponent); + fix.detectChanges(); + + const formGroup = fix.componentInstance.reactiveForm; + + fix.componentInstance.removeValidators(formGroup); + formGroup.markAsUntouched(); + fix.detectChanges(); + + const igxInput = fix.componentInstance.input; + const inputElement = fix.debugElement.query(By.directive(IgxInputDirective)).nativeElement; + + dispatchInputEvent('focus', inputElement, fix); + dispatchInputEvent('blur', inputElement, fix); + + const inputGroupElement = fix.debugElement.query(By.css('igx-input-group')).nativeElement; + expect(inputGroupElement.classList.contains(INPUT_GROUP_VALID_CSS_CLASS)).toBe(false); + expect(inputGroupElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(false); + expect(inputGroupElement.classList.contains(INPUT_GROUP_FILLED_CSS_CLASS)).toBe(false); + expect(igxInput.valid).toBe(IgxInputState.INITIAL); + + dispatchInputEvent('focus', inputElement, fix); + igxInput.value = 'test'; + fix.detectChanges(); + + expect(inputGroupElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(false); + expect(inputGroupElement.classList.contains(INPUT_GROUP_VALID_CSS_CLASS)).toBe(false); + expect(inputGroupElement.classList.contains(INPUT_GROUP_FILLED_CSS_CLASS)).toBe(true); + + expect(igxInput.valid).toBe(IgxInputState.INITIAL); + })); + + it('should mark the reactive form control as touched when igxInput loses focus', fakeAsync(() => { + const fixture = TestBed.createComponent(ReactiveFormComponent); + fixture.detectChanges(); + + const component = fixture.componentInstance; + const formControl = component.form.get('str'); + const inputDebug = fixture.debugElement.query(By.css('input[formControlName="str"]')); + const input = inputDebug.nativeElement; + + expect(formControl.touched).toBe(false); + + input.dispatchEvent(new Event('focus')); + fixture.detectChanges(); + + input.dispatchEvent(new Event('blur')); + tick(); + fixture.detectChanges(); + + expect(formControl.touched).toBe(true); + })); + + it('should update validity when control is marked as touched', fakeAsync(() => { + const fixture = TestBed.createComponent(ReactiveFormComponent); + fixture.detectChanges(); + + const component = fixture.componentInstance; + const igxInput = component.strIgxInput; + + expect(igxInput.valid).toBe(IgxInputState.INITIAL); + + component.markAllAsTouched(); + tick(); + fixture.detectChanges(); + + expect(igxInput.valid).toBe(IgxInputState.INVALID); + })); +}); + +@Component({ + template: ` +
    + + + + + + + + + `, + imports: [IgxInputGroupComponent, IgxLabelDirective, IgxInputDirective, FormsModule] +}) +class InputsWithSameNameAttributesComponent { + @ViewChildren('igxInputGroup') public igxInputGroup: QueryList; + @ViewChild(IgxInputDirective, { static: true }) public igxInput: IgxInputDirective; + + public model = { + firstName: null + }; +} + + +@Component({ + template: ` + + + + + `, + imports: [IgxInputGroupComponent, IgxLabelDirective, IgxInputDirective] +}) +class InputComponent { + @ViewChild('igxInputGroup', { static: true }) public igxInputGroup: IgxInputGroupComponent; + @ViewChild(IgxInputDirective, { static: true }) public igxInput: IgxInputDirective; +} + +@Component({ + template: ` + + + + + `, + imports: [IgxInputGroupComponent, IgxLabelDirective, IgxInputDirective] +}) +class TextareaComponent { +} + +@Component({ + template: ` + + + + + `, + imports: [IgxInputGroupComponent, IgxLabelDirective, IgxInputDirective] +}) +class InputWithPlaceholderComponent { + @ViewChild(IgxInputDirective, { static: true }) public igxInput: IgxInputDirective; +} + +@Component({ + template: ` + + + + + `, + imports: [IgxInputGroupComponent, IgxLabelDirective, IgxInputDirective] +}) +class FilledInputComponent { + @ViewChild('igxInputGroup', { static: true }) public igxInputGroup: IgxInputGroupComponent; + @ViewChild(IgxInputDirective, { static: true }) public igxInput: IgxInputDirective; +} + +@Component({ + template: ` + + + + + `, + imports: [IgxInputGroupComponent, IgxLabelDirective, IgxInputDirective] +}) +class DisabledInputComponent { + @ViewChild('igxInputGroup', { static: true }) public igxInputGroup: IgxInputGroupComponent; + @ViewChild(IgxInputDirective, { static: true }) public igxInput: IgxInputDirective; +} + +@Component({ + template: ` + + + + + `, + imports: [IgxInputGroupComponent, IgxLabelDirective, IgxInputDirective] +}) +class RequiredInputComponent { + @ViewChild('igxInputGroup', { static: true }) public igxInputGroup: IgxInputGroupComponent; + @ViewChild(IgxInputDirective, { static: true }) public igxInput: IgxInputDirective; +} + +@Component({ + template: ` + + + `, + imports: [IgxInputGroupComponent, IgxLabelDirective, IgxInputDirective, FormsModule] +}) +class RequiredTwoWayDataBoundInputComponent { + @ViewChild('igxInputGroup', { static: true }) public igxInputGroup: IgxInputGroupComponent; + @ViewChild(IgxInputDirective, { static: true }) public igxInput: IgxInputDirective; + + public user = { + firstName: '' + }; +} + +@Component({ + template: ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `, + imports: [IgxInputGroupComponent, IgxLabelDirective, IgxInputDirective, FormsModule] +}) +class InitiallyFilledInputComponent { + @ViewChild('igxInputGroupNotFilledUndefined', { static: true }) public igxInputGroupNotFilledUndefined: IgxInputGroupComponent; + @ViewChild('igxInputGroupNotFilledNull', { static: true }) public igxInputGroupNotFilledNull: IgxInputGroupComponent; + @ViewChild('igxInputGroupNotFilledEmpty', { static: true }) public igxInputGroupNotFilledEmpty: IgxInputGroupComponent; + + @ViewChild('igxInputGroupFilledString', { static: true }) public igxInputGroupFilledString: IgxInputGroupComponent; + @ViewChild('igxInputGroupFilledNumber', { static: true }) public igxInputGroupFilledNumber: IgxInputGroupComponent; + @ViewChild('igxInputGroupFilledBoolFalse', { static: true }) public igxInputGroupFilledBoolFalse: IgxInputGroupComponent; + @ViewChild('igxInputGroupFilledBoolTrue', { static: true }) public igxInputGroupFilledBoolTrue: IgxInputGroupComponent; + @ViewChild('igxInputGroupFilledDate', { static: true }) public igxInputGroupFilledDate: IgxInputGroupComponent; + + public notFilledUndefined = undefined; + public notFilledNull = null; + public notFilledEmpty = ''; + public user = { + firstName: 'Oke', + age: 30, + vegetarian: false, + smoker: true, + birthDate: new Date(1988, 1, 1) + }; +} + +@Component({ + template: ` + + + + + `, + imports: [IgxInputGroupComponent, IgxLabelDirective, IgxInputDirective] +}) +class DataBoundDisabledInputComponent { + @ViewChild('igxInputGroup', { static: true }) public igxInputGroup: IgxInputGroupComponent; + @ViewChild(IgxInputDirective, { static: true }) public igxInput: IgxInputDirective; + + public disabled = false; +} + +@Component({ + template: ` + + + + + `, + imports: [IgxInputGroupComponent, IgxLabelDirective, IgxInputDirective] +}) +class DataBoundDisabledInputWithoutValueComponent extends DataBoundDisabledInputComponent { + public changeDisabledState() { + this.igxInput.disabled = !this.igxInput.disabled; + } +} + +@Component({ + template: ` +
    +
    + + + + +
    + + + + +
    + + + + +
    +
    + + + + +
    + +
    +
    + + + + + + + + +
    + + `, + imports: [IgxInputGroupComponent, IgxLabelDirective, IgxInputDirective, IgxMaskDirective, ReactiveFormsModule] +}) +class ReactiveFormComponent { + private fb = inject(UntypedFormBuilder); + + @ViewChild('strinput', { static: true, read: IgxInputDirective }) public strIgxInput: IgxInputDirective; + + public form = this.fb.group({ + str: ['', Validators.required], + textarea: ['', Validators.required], + password: ['', Validators.required], + num: [null, Validators.required] + }); + + public inputControl = new FormControl('', [Validators.required]); + public textareaControl = new FormControl('', [Validators.required]); + + public markAsTouched() { + if (!this.form.valid) { + for (const key in this.form.controls) { + if (this.form.controls[key]) { + this.form.controls[key].markAsTouched(); + this.form.controls[key].updateValueAndValidity(); + } + } + } + + this.inputControl.markAsTouched(); + this.inputControl.updateValueAndValidity(); + + this.textareaControl.markAsTouched(); + this.textareaControl.updateValueAndValidity(); + } + + public markAllAsTouched() { + if (!this.form.valid) { + this.form.markAllAsTouched(); + } + } +} + +@Component({ + template: ` + + + + + + + + + `, + imports: [IgxInputGroupComponent, IgxLabelDirective, IgxInputDirective, FormsModule] +}) +class ToggleRequiredWithNgModelInputComponent { + @ViewChildren(IgxInputDirective) + public igxInputs: QueryList; + + @ViewChildren(IgxInputGroupComponent) + public igxInputGroups: QueryList; + + public data = ''; + public data1 = ''; + public isRequired = false; +} +@Component({ + template: ` +
    + + + + + person + + + +`, + imports: [IgxInputGroupComponent, IgxLabelDirective, IgxInputDirective, IgxSuffixDirective, IgxIconComponent, ReactiveFormsModule] +}) + +class InputReactiveFormComponent { + @ViewChild('igxInputGroup', { static: true }) public igxInputGroup: IgxInputGroupComponent; + @ViewChild('inputReactive', { read: IgxInputDirective }) public input: IgxInputDirective; + public reactiveForm: UntypedFormGroup; + + public validationType = { + fullName: [Validators.required] + }; + + constructor() { + const fb = inject(UntypedFormBuilder); + + this.reactiveForm = fb.group({ + fullName: new UntypedFormControl('', Validators.required) + }); + } + public onSubmitReactive() { } + + public removeValidators(form: UntypedFormGroup) { + for (const key in form.controls) { + if (form.controls.hasOwnProperty(key)) { + form.get(key).clearValidators(); + form.get(key).updateValueAndValidity(); + } + } + } + + public addValidators(form: UntypedFormGroup) { + for (const key in form.controls) { + if (form.controls.hasOwnProperty(key)) { + form.get(key).setValidators(this.validationType[key]); + form.get(key).updateValueAndValidity(); + } + } + } + + public markAsTouched() { + if (!this.reactiveForm.valid) { + for (const key in this.reactiveForm.controls) { + if (this.reactiveForm.controls.hasOwnProperty(key)) { + if (this.reactiveForm.controls[key]) { + this.reactiveForm.controls[key].markAsTouched(); + this.reactiveForm.controls[key].updateValueAndValidity(); + } + } + } + } + } +} + +@Component({ + template: ` +
    + + + + + + + + + +`, + imports: [IgxInputGroupComponent, IgxLabelDirective, IgxInputDirective, ReactiveFormsModule, FormsModule] +}) + +class FileInputFormComponent { + @ViewChild('igxInputGroup', { static: true }) public igxInputGroup: IgxInputGroupComponent; + @ViewChild('fileInput', { read: IgxInputDirective }) public input: IgxInputDirective; + @ViewChild('igxInputGroupNgModel', { static: true }) public igxInputGroupNgModel: IgxInputGroupComponent; + @ViewChild('inputNgModel', { read: IgxInputDirective }) public inputWithNgModel: IgxInputDirective; + public formWithFileInput: UntypedFormGroup; + public model = { + inputValue: null + }; + + constructor() { + const fb = inject(UntypedFormBuilder); + + this.formWithFileInput = fb.group({ + fileInput: new UntypedFormControl('') + }); + } +} + +const testRequiredValidation = (inputElement, fixture) => { + dispatchInputEvent('focus', inputElement, fixture); + inputElement.value = 'test'; + dispatchInputEvent('input', inputElement, fixture); + + const inputGroupElement = fixture.debugElement.query(By.css('igx-input-group')).nativeElement; + expect(inputGroupElement.classList.contains(INPUT_GROUP_VALID_CSS_CLASS)).toBe(true); + const igxInput = fixture.componentInstance.igxInput; + expect(igxInput.valid).toBe(IgxInputState.VALID); + + dispatchInputEvent('blur', inputElement, fixture); + + expect(inputGroupElement.classList.contains(INPUT_GROUP_VALID_CSS_CLASS)).toBe(false); + expect(igxInput.valid).toBe(IgxInputState.INITIAL); + + dispatchInputEvent('focus', inputElement, fixture); + inputElement.value = ''; + + dispatchInputEvent('input', inputElement, fixture); + + expect(inputGroupElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(true); + expect(igxInput.valid).toBe(IgxInputState.INVALID); + + dispatchInputEvent('blur', inputElement, fixture); + + expect(inputGroupElement.classList.contains(INPUT_GROUP_INVALID_CSS_CLASS)).toBe(true); + expect(igxInput.valid).toBe(IgxInputState.INVALID); +}; + +const dispatchInputEvent = (eventName, inputNativeElement, fixture) => { + inputNativeElement.dispatchEvent(new Event(eventName)); + fixture.detectChanges(); +}; diff --git a/projects/igniteui-angular/input-group/src/input-group/directives-input/input.directive.ts b/projects/igniteui-angular/input-group/src/input-group/directives-input/input.directive.ts new file mode 100644 index 00000000000..cb846b104a6 --- /dev/null +++ b/projects/igniteui-angular/input-group/src/input-group/directives-input/input.directive.ts @@ -0,0 +1,516 @@ +import { AfterViewInit, ChangeDetectorRef, Directive, ElementRef, HostBinding, HostListener, Input, OnDestroy, Renderer2, booleanAttribute, inject } from '@angular/core'; +import { + AbstractControl, + NgControl, + NgModel, + TouchedChangeEvent +} from '@angular/forms'; +import { filter, Subscription } from 'rxjs'; +import { IgxInputGroupBase } from '../input-group.common'; + +const nativeValidationAttributes = [ + 'required', + 'pattern', + 'minlength', + 'maxlength', + 'min', + 'max', + 'step', +]; + +export enum IgxInputState { + INITIAL, + VALID, + INVALID, +} + +/** + * The `igxInput` directive creates single- or multiline text elements, covering common scenarios when dealing with form inputs. + * + * @igxModule IgxInputGroupModule + * + * @igxParent Data Entry & Display + * + * @igxTheme igx-input-group-theme + * + * @igxKeywords input, input group, form, field, validation + * + * @igxGroup presentation + * + * @example + * ```html + * + * + * + * + * ``` + */ +@Directive({ + selector: '[igxInput]', + exportAs: 'igxInput', + standalone: true +}) +export class IgxInputDirective implements AfterViewInit, OnDestroy { + public inputGroup = inject(IgxInputGroupBase); + protected ngModel = inject(NgModel, { optional: true, self: true }); + protected formControl = inject(NgControl, { optional: true, self: true }); + protected element = inject>(ElementRef); + protected cdr = inject(ChangeDetectorRef); + protected renderer = inject(Renderer2); + + /** + * Sets/gets whether the `"igx-input-group__input"` class is added to the host element. + * Default value is `false`. + * + * @example + * ```typescript + * this.igxInput.isInput = true; + * ``` + * + * @example + * ```typescript + * let isCLassAdded = this.igxInput.isInput; + * ``` + */ + @HostBinding('class.igx-input-group__input') + public isInput = false; + /** + * Sets/gets whether the `"class.igx-input-group__textarea"` class is added to the host element. + * Default value is `false`. + * + * @example + * ```typescript + * this.igxInput.isTextArea = true; + * ``` + * + * @example + * ```typescript + * let isCLassAdded = this.igxInput.isTextArea; + * ``` + */ + @HostBinding('class.igx-input-group__textarea') + public isTextArea = false; + + private _valid = IgxInputState.INITIAL; + private _statusChanges$: Subscription; + private _valueChanges$: Subscription; + private _touchedChanges$: Subscription; + private _fileNames: string; + private _disabled = false; + + private get ngControl(): NgControl { + return this.ngModel ? this.ngModel : this.formControl; + } + + /** + * Sets the `value` property. + * + * @example + * ```html + * + * + * + * ``` + */ + @Input() + public set value(value: any) { + this.nativeElement.value = value ?? ''; + this.updateValidityState(); + } + /** + * Gets the `value` property. + * + * @example + * ```typescript + * @ViewChild('igxInput', {read: IgxInputDirective}) + * public igxInput: IgxInputDirective; + * let inputValue = this.igxInput.value; + * ``` + */ + public get value() { + return this.nativeElement.value; + } + /** + * Sets the `disabled` property. + * + * @example + * ```html + * + * + * + * ``` + */ + @Input({ transform: booleanAttribute }) + @HostBinding('disabled') + public set disabled(value: boolean) { + this._disabled = this.inputGroup.disabled = value; + if (this.focused && this._disabled) { + // Browser focus may not fire in good time and mess with change detection, adjust here in advance: + this.inputGroup.isFocused = false; + } + } + /** + * Gets the `disabled` property + * + * @example + * ```typescript + * @ViewChild('igxInput', {read: IgxInputDirective}) + * public igxInput: IgxInputDirective; + * let isDisabled = this.igxInput.disabled; + * ``` + */ + public get disabled() { + return this._disabled; + } + + /** + * Sets the `required` property. + * + * @example + * ```html + * + * + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public set required(value: boolean) { + this.nativeElement.required = this.inputGroup.isRequired = value; + } + + /** + * Gets whether the igxInput is required. + * + * @example + * ```typescript + * let isRequired = this.igxInput.required; + * ``` + */ + public get required() { + let validation; + if (this.ngControl && (this.ngControl.control.validator || this.ngControl.control.asyncValidator)) { + validation = this.ngControl.control.validator({} as AbstractControl); + } + return validation && validation.required || this.nativeElement.hasAttribute('required'); + } + /** + * @hidden + * @internal + */ + @HostListener('focus') + public onFocus() { + this.inputGroup.isFocused = true; + } + /** + * @param event The event to invoke the handler + * + * @hidden + * @internal + */ + @HostListener('blur') + public onBlur() { + this.inputGroup.isFocused = false; + if (this.ngControl?.control) { + this.ngControl.control.markAsTouched(); + } + this.updateValidityState(); + } + /** @hidden @internal */ + @HostListener('input') + public onInput() { + this.checkNativeValidity(); + } + /** @hidden @internal */ + @HostListener('change', ['$event']) + public change(event: Event) { + if (this.type === 'file') { + const fileList: FileList | null = (event.target as HTMLInputElement) + .files; + const fileArray: File[] = []; + + if (fileList) { + for (const file of Array.from(fileList)) { + fileArray.push(file); + } + } + + this._fileNames = (fileArray || []).map((f: File) => f.name).join(', '); + + if (this.required && fileList?.length > 0) { + this._valid = IgxInputState.INITIAL; + } + } + } + + /** @hidden @internal */ + public get fileNames() { + return this._fileNames; + } + + /** @hidden @internal */ + public clear() { + this.ngControl?.control?.setValue(''); + this.nativeElement.value = null; + this._fileNames = ''; + } + + /** @hidden @internal */ + public ngAfterViewInit() { + this.inputGroup.hasPlaceholder = this.nativeElement.hasAttribute( + 'placeholder' + ); + + if (this.ngControl && this.ngControl.disabled !== null) { + this.disabled = this.ngControl.disabled; + } + this.inputGroup.disabled = + this.inputGroup.disabled || + this.nativeElement.hasAttribute('disabled'); + this.inputGroup.isRequired = this.nativeElement.hasAttribute( + 'required' + ); + + // Make sure we do not invalidate the input on init + if (!this.ngControl) { + this._valid = IgxInputState.INITIAL; + } + // Also check the control's validators for required + if (this.required && !this.inputGroup.isRequired) { + this.inputGroup.isRequired = this.required; + } + + this.renderer.setAttribute(this.nativeElement, 'aria-required', this.required.toString()); + + const elTag = this.nativeElement.tagName.toLowerCase(); + if (elTag === 'textarea') { + this.isTextArea = true; + + if (this.nativeElement.getAttribute('rows') === null) { + this.renderer.setAttribute(this.nativeElement, 'rows', '3'); + } + } else { + this.isInput = true; + } + + if (this.ngControl) { + this._statusChanges$ = this.ngControl.statusChanges.subscribe( + this.onStatusChanged.bind(this) + ); + + this._valueChanges$ = this.ngControl.valueChanges.subscribe( + this.onValueChanged.bind(this) + ); + + if (this.ngControl.control) { + this._touchedChanges$ = this.ngControl.control.events + .pipe(filter(e => e instanceof TouchedChangeEvent)) + .subscribe( + this.updateValidityState.bind(this) + ); + } + } + + this.cdr.detectChanges(); + } + /** @hidden @internal */ + public ngOnDestroy() { + if (this._statusChanges$) { + this._statusChanges$.unsubscribe(); + } + + if (this._valueChanges$) { + this._valueChanges$.unsubscribe(); + } + + if (this._touchedChanges$) { + this._touchedChanges$.unsubscribe(); + } + } + /** + * Sets a focus on the igxInput. + * + * @example + * ```typescript + * this.igxInput.focus(); + * ``` + */ + public focus() { + this.nativeElement.focus(); + } + /** + * Gets the `nativeElement` of the igxInput. + * + * @example + * ```typescript + * let igxInputNativeElement = this.igxInput.nativeElement; + * ``` + */ + public get nativeElement() { + return this.element.nativeElement; + } + /** @hidden @internal */ + protected onStatusChanged() { + // Enable/Disable control based on ngControl #7086 + if (this.disabled !== this.ngControl.disabled) { + this.disabled = this.ngControl.disabled; + } + this.updateValidityState(); + } + + /** @hidden @internal */ + protected onValueChanged() { + if (this._fileNames && !this.value) { + this._fileNames = ''; + } + } + + /** + * @hidden + * @internal + */ + protected updateValidityState() { + if (this.ngControl) { + if (!this.disabled && this.isTouchedOrDirty) { + if (this.hasValidators) { + // Run the validation with empty object to check if required is enabled. + const error = this.ngControl.control.validator({} as AbstractControl); + this.inputGroup.isRequired = error && error.required; + if (this.focused) { + this._valid = this.ngControl.valid ? IgxInputState.VALID : IgxInputState.INVALID; + } else { + this._valid = this.ngControl.valid ? IgxInputState.INITIAL : IgxInputState.INVALID; + } + } else { + // If validator is dynamically cleared, reset label's required class(asterisk) and IgxInputState #10010 + this.inputGroup.isRequired = false; + this._valid = this.ngControl.valid ? IgxInputState.INITIAL : IgxInputState.INVALID; + } + } else { + this._valid = IgxInputState.INITIAL; + } + this.renderer.setAttribute(this.nativeElement, 'aria-required', this.required.toString()); + const ariaInvalid = this.valid === IgxInputState.INVALID; + this.renderer.setAttribute(this.nativeElement, 'aria-invalid', ariaInvalid.toString()); + } else { + this.checkNativeValidity(); + } + } + + private get isTouchedOrDirty(): boolean { + return (this.ngControl.control.touched || this.ngControl.control.dirty); + } + + private get hasValidators(): boolean { + return (!!this.ngControl.control.validator || !!this.ngControl.control.asyncValidator); + } + + /** + * Gets whether the igxInput has a placeholder. + * + * @example + * ```typescript + * let hasPlaceholder = this.igxInput.hasPlaceholder; + * ``` + */ + public get hasPlaceholder() { + return this.nativeElement.hasAttribute('placeholder'); + } + /** + * Gets the placeholder element of the igxInput. + * + * @example + * ```typescript + * let igxInputPlaceholder = this.igxInput.placeholder; + * ``` + */ + public get placeholder() { + return this.nativeElement.placeholder; + } + + /** + * @returns An indicator of whether the input has validator attributes or not + * + * @hidden + * @internal + */ + private _hasValidators(): boolean { + for (const nativeValidationAttribute of nativeValidationAttributes) { + if (this.nativeElement.hasAttribute(nativeValidationAttribute)) { + return true; + } + } + return false; + } + + /** + * Gets whether the igxInput is focused. + * + * @example + * ```typescript + * let isFocused = this.igxInput.focused; + * ``` + */ + public get focused() { + return this.inputGroup.isFocused; + } + /** + * Gets the state of the igxInput. + * + * @example + * ```typescript + * let igxInputState = this.igxInput.valid; + * ``` + */ + public get valid(): IgxInputState { + return this._valid; + } + + /** + * Sets the state of the igxInput. + * + * @example + * ```typescript + * this.igxInput.valid = IgxInputState.INVALID; + * ``` + */ + public set valid(value: IgxInputState) { + this._valid = value; + } + + /** + * Gets whether the igxInput is valid. + * + * @example + * ```typescript + * let valid = this.igxInput.isValid; + * ``` + */ + public get isValid(): boolean { + return this.valid !== IgxInputState.INVALID; + } + + /** + * A function to assign a native validity property of an input. + * This should be used when there's no ngControl + * + * @hidden + * @internal + */ + private checkNativeValidity() { + if (!this.disabled && this._hasValidators()) { + this._valid = this.nativeElement.checkValidity() ? + this.focused ? IgxInputState.VALID : IgxInputState.INITIAL : + IgxInputState.INVALID; + } + } + + /** + * Returns the input type. + * + * @hidden + * @internal + */ + public get type() { + return this.nativeElement.type; + } +} diff --git a/projects/igniteui-angular/input-group/src/input-group/directives-input/read-only-input.directive.spec.ts b/projects/igniteui-angular/input-group/src/input-group/directives-input/read-only-input.directive.spec.ts new file mode 100644 index 00000000000..0cf117e4b4d --- /dev/null +++ b/projects/igniteui-angular/input-group/src/input-group/directives-input/read-only-input.directive.spec.ts @@ -0,0 +1,62 @@ +import { Component, ViewChild } from '@angular/core'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxReadOnlyInputDirective } from './read-only-input.directive'; +import { IgxDatePickerComponent } from 'igniteui-angular/date-picker'; +import { IgxInputGroupComponent } from 'igniteui-angular/input-group';; +import { By } from '@angular/platform-browser'; + +describe('IgxReadOnlyInputDirective', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + TestComponent + ] + }) + .compileComponents(); + })); + + it('should update readOnly property and `igx-input-group--readonly` class correctly', () => { + const fixture = TestBed.createComponent(TestComponent); + fixture.detectChanges(); + + const inputGroupDebug = fixture.debugElement.query(By.directive(IgxInputGroupComponent)); + const inputGroupEl = inputGroupDebug.nativeElement as HTMLElement; + expect(inputGroupEl.classList.contains('igx-input-group--readonly')).toBeFalse(); + + const inputDebug = fixture.debugElement.query(By.css('input')); + const inputEl = inputDebug.nativeElement as HTMLInputElement; + expect(inputEl.readOnly).toBeFalse(); + + fixture.componentInstance.datePicker.readOnly = true; + fixture.detectChanges(); + expect(inputGroupEl.classList.contains('igx-input-group--readonly')).toBeTrue(); + expect(inputEl.readOnly).toBeTrue(); + + fixture.componentInstance.datePicker.readOnly = false; + fixture.detectChanges(); + expect(inputGroupEl.classList.contains('igx-input-group--readonly')).toBeFalse(); + expect(inputEl.readOnly).toBeFalse(); + + // When the date-picker component is in dialog mode, the native input is always readonly + fixture.componentInstance.datePicker.mode = 'dialog'; + fixture.detectChanges(); + expect(inputGroupEl.classList.contains('igx-input-group--readonly')).toBeFalse(); + expect(inputEl.readOnly).toBeTrue(); + + fixture.componentInstance.datePicker.readOnly = true; + fixture.detectChanges(); + expect(inputGroupEl.classList.contains('igx-input-group--readonly')).toBeTrue(); + expect(inputEl.readOnly).toBeTrue(); + }); +}); + +@Component({ + template: ``, + imports: [IgxDatePickerComponent, IgxReadOnlyInputDirective] +}) +class TestComponent { + @ViewChild(IgxDatePickerComponent, { static: true }) + public datePicker!: IgxDatePickerComponent; +} diff --git a/projects/igniteui-angular/input-group/src/input-group/directives-input/read-only-input.directive.ts b/projects/igniteui-angular/input-group/src/input-group/directives-input/read-only-input.directive.ts new file mode 100644 index 00000000000..92dd2deb9db --- /dev/null +++ b/projects/igniteui-angular/input-group/src/input-group/directives-input/read-only-input.directive.ts @@ -0,0 +1,26 @@ +import { Directive, effect, inject, input } from '@angular/core'; +import { IgxInputGroupComponent } from '../input-group.component'; + +@Directive({ + selector: '[igxReadOnlyInput]', + exportAs: 'igxReadOnlyInput', + standalone: true +}) +export class IgxReadOnlyInputDirective { + public igxReadOnlyInput = input(false); + + private _inputGroup: IgxInputGroupComponent | null = inject( + IgxInputGroupComponent, + { + optional: true + } + ); + + constructor() { + effect(() => { + if (this._inputGroup) { + this._inputGroup.readOnly = this.igxReadOnlyInput(); + } + }); + } +} diff --git a/projects/igniteui-angular/input-group/src/input-group/directives-label/README.md b/projects/igniteui-angular/input-group/src/input-group/directives-label/README.md new file mode 100644 index 00000000000..4fc7b9c3a5c --- /dev/null +++ b/projects/igniteui-angular/input-group/src/input-group/directives-label/README.md @@ -0,0 +1,16 @@ +# igxLabel + +The **igxLabel** directive is intended to be used on single-line text elements to add additional CSS styles. It is especially useful when combined with igx-switch, igx-checkbox, and or igx-avatar components. +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/label_input.html) + +# Usage + +```html +Click me +``` +# Examples + +Using `igxLabel` to turn a span element into a styled label for an input. +```html +Single line text +``` diff --git a/projects/igniteui-angular/input-group/src/input-group/directives-label/label.directive.ts b/projects/igniteui-angular/input-group/src/input-group/directives-label/label.directive.ts new file mode 100644 index 00000000000..520e568a1e9 --- /dev/null +++ b/projects/igniteui-angular/input-group/src/input-group/directives-label/label.directive.ts @@ -0,0 +1,19 @@ +import { Directive, HostBinding, Input } from '@angular/core'; + +let NEXT_ID = 0; + +@Directive({ + selector: '[igxLabel]', + standalone: true +}) +export class IgxLabelDirective { + @HostBinding('class.igx-input-group__label') + public defaultClass = true; + + /** + * @hidden + */ + @HostBinding('attr.id') + @Input() + public id = `igx-label-${NEXT_ID++}`; +} diff --git a/projects/igniteui-angular/input-group/src/input-group/directives-prefix/prefix.directive.ts b/projects/igniteui-angular/input-group/src/input-group/directives-prefix/prefix.directive.ts new file mode 100644 index 00000000000..2dcd6fb00c7 --- /dev/null +++ b/projects/igniteui-angular/input-group/src/input-group/directives-prefix/prefix.directive.ts @@ -0,0 +1,15 @@ +import { Directive } from '@angular/core'; + +/** + * @hidden + */ +@Directive({ + selector: 'igx-prefix,[igxPrefix],[igxStart]', + standalone: true +}) +export class IgxPrefixDirective { } + +/** + * @hidden + */ + diff --git a/projects/igniteui-angular/input-group/src/input-group/directives-suffix/suffix.directive.ts b/projects/igniteui-angular/input-group/src/input-group/directives-suffix/suffix.directive.ts new file mode 100644 index 00000000000..71942ffd322 --- /dev/null +++ b/projects/igniteui-angular/input-group/src/input-group/directives-suffix/suffix.directive.ts @@ -0,0 +1,15 @@ +import { Directive } from '@angular/core'; + +/** + * @hidden + */ +@Directive({ + selector: 'igx-suffix,[igxSuffix],[igxEnd]', + standalone: true +}) +export class IgxSuffixDirective { } + +/** + * @hidden + */ + diff --git a/projects/igniteui-angular/input-group/src/input-group/input-group.common.ts b/projects/igniteui-angular/input-group/src/input-group/input-group.common.ts new file mode 100644 index 00000000000..ddeb267adfc --- /dev/null +++ b/projects/igniteui-angular/input-group/src/input-group/input-group.common.ts @@ -0,0 +1,7 @@ +/** @hidden */ +export abstract class IgxInputGroupBase { + public disabled: boolean; + public isFocused: boolean; + public isRequired: boolean; + public hasPlaceholder: boolean; +} diff --git a/projects/igniteui-angular/input-group/src/input-group/input-group.component.html b/projects/igniteui-angular/input-group/src/input-group/input-group.component.html new file mode 100644 index 00000000000..488a6851f85 --- /dev/null +++ b/projects/igniteui-angular/input-group/src/input-group/input-group.component.html @@ -0,0 +1,164 @@ +@if (isTypeBox) { +
    + +
    +} @else { + +} + +
    + +
    + + + + + + + + + + + + + + + + + + @if (isFileType) { +
    + +
    + } +
    + + + @if (isFileType) { +
    + {{ fileNames }} +
    + } +
    + + + @if (isFileType && isFilled) { + + + + } + + + + + + + +
    +
    + +
    + + +
    + +
    +
    + +
    + + + +
    + +
    + +
    + + +
    + + @if (hasBorder) { +
    + } +
    +
    + + + + +
    +
    + +
    + + +
    + + + +
    + +
    + + +
    + + @if (hasBorder) { +
    + } +
    +
    + + + + +
    +
    + +
    + + + + + +
    + + +
    +
    +
    + + + @switch (theme) { + @case ('bootstrap') { + + } + @case ('fluent') { + + } + @case ('indigo') { + + } + @default { + + } + } + diff --git a/projects/igniteui-angular/input-group/src/input-group/input-group.component.spec.ts b/projects/igniteui-angular/input-group/src/input-group/input-group.component.spec.ts new file mode 100644 index 00000000000..a190d14c8f5 --- /dev/null +++ b/projects/igniteui-angular/input-group/src/input-group/input-group.component.spec.ts @@ -0,0 +1,377 @@ +import { Component, ViewChild, ElementRef, inject } from '@angular/core'; +import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { IgxInputGroupComponent } from './input-group.component'; +import { UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { IgxInputDirective, IgxPrefixDirective, IgxSuffixDirective } from '../public_api'; +import { IGX_INPUT_GROUP_TYPE, IgxInputGroupType } from './inputGroupType'; + +const INPUT_GROUP_CSS_CLASS = 'igx-input-group'; +const INPUT_GROUP_BOX_CSS_CLASS = 'igx-input-group--box'; +const INPUT_GROUP_BORDER_CSS_CLASS = 'igx-input-group--border'; +const INPUT_GROUP_SEARCH_CSS_CLASS = 'igx-input-group--search'; + +describe('IgxInputGroup', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + InputGroupComponent, + InputGroupBoxComponent, + InputGroupBorderComponent, + InputGroupSearchComponent, + InputGroupFileComponent, + InputGroupDisabledComponent, + InputGroupDisabledByDefaultComponent, + InputGroupDisabledWithoutValueComponent + ] + }).compileComponents(); + })); + + it('Initializes an input group.', fakeAsync(() => { + const fixture = TestBed.createComponent(InputGroupDisabledComponent); + fixture.detectChanges(); + + const inputGroupElement = fixture.debugElement.query(By.css('igx-input-group')).nativeElement; + expect(inputGroupElement.classList.contains(INPUT_GROUP_CSS_CLASS)).toBe(true); + + const igxInputGroup = fixture.componentInstance.igxInputGroup; + tick(); + fixture.detectChanges(); + // the default type should be line + testInputGroupType('line', igxInputGroup, inputGroupElement); + })); + + it('Initializes an input group with type box.', fakeAsync(() => { + const fixture = TestBed.createComponent(InputGroupBoxComponent); + fixture.detectChanges(); + + const inputGroupElement = fixture.debugElement.query(By.css('igx-input-group')).nativeElement; + expect(inputGroupElement.classList.contains(INPUT_GROUP_CSS_CLASS)).toBe(true); + + const igxInputGroup = fixture.componentInstance.igxInputGroup; + tick(); + fixture.detectChanges(); + testInputGroupType('box', igxInputGroup, inputGroupElement); + })); + + it('Initializes an input group with type border.', fakeAsync(() => { + const fixture = TestBed.createComponent(InputGroupBorderComponent); + fixture.detectChanges(); + + const inputGroupElement = fixture.debugElement.query(By.css('igx-input-group')).nativeElement; + expect(inputGroupElement.classList.contains(INPUT_GROUP_CSS_CLASS)).toBe(true); + + const igxInputGroup = fixture.componentInstance.igxInputGroup; + tick(); + fixture.detectChanges(); + testInputGroupType('border', igxInputGroup, inputGroupElement); + })); + + it('Initializes an input group with type search.', fakeAsync(() => { + const fixture = TestBed.createComponent(InputGroupSearchComponent); + fixture.detectChanges(); + + const inputGroupElement = fixture.debugElement.query(By.css('igx-input-group')).nativeElement; + expect(inputGroupElement.classList.contains(INPUT_GROUP_CSS_CLASS)).toBe(true); + + const igxInputGroup = fixture.componentInstance.igxInputGroup; + tick(); + fixture.detectChanges(); + testInputGroupType('search', igxInputGroup, inputGroupElement); + })); + + it('Initializes upload file button with type=\'button\'.', () => { + const fixture = TestBed.createComponent(InputGroupFileComponent); + fixture.detectChanges(); + + const uploadFileButton = fixture.debugElement.query(By.css('button')).nativeElement; + + expect(uploadFileButton.getAttribute('type')).toEqual('button'); + }); + + it('Should respect type Token and be able to change input group type programmatically.', fakeAsync(() => { + const fixture = TestBed.createComponent(InputGroupComponent); + fixture.detectChanges(); + + const inputGroupElement = fixture.debugElement.query(By.css('igx-input-group')).nativeElement; + const igxInputGroup = fixture.componentInstance.igxInputGroup; + + tick(); + fixture.detectChanges(); + + // a Token is passed and can be obtained + expect(fixture.componentInstance.IGTOKEN).toBe('box'); + + // type set via Token is 'box' + testInputGroupType('box', igxInputGroup, inputGroupElement); + + // user can override Token passing other igxInputGroup types + igxInputGroup.type = 'border'; + fixture.detectChanges(); + testInputGroupType('border', igxInputGroup, inputGroupElement); + + igxInputGroup.type = 'box'; + fixture.detectChanges(); + testInputGroupType('box', igxInputGroup, inputGroupElement); + + igxInputGroup.type = 'search'; + fixture.detectChanges(); + testInputGroupType('search', igxInputGroup, inputGroupElement); + + igxInputGroup.type = 'line'; + fixture.detectChanges(); + testInputGroupType('line', igxInputGroup, inputGroupElement); + + // Set type as null, so the Token type should be used again + igxInputGroup.type = null; + fixture.detectChanges(); + testInputGroupType('box', igxInputGroup, inputGroupElement); + })); + + it('disabled input should properly detect changes.', () => { + const fixture = TestBed.createComponent(InputGroupDisabledComponent); + fixture.detectChanges(); + + const component = fixture.componentInstance; + const igxInputGroup = component.igxInputGroup; + expect(igxInputGroup.disabled).toBeFalsy(); + + component.changeDisableState(); + fixture.detectChanges(); + expect(igxInputGroup.disabled).toBeTruthy(); + + component.changeDisableState(); + fixture.detectChanges(); + expect(igxInputGroup.disabled).toBeFalsy(); + }); + + it('disabled by default should properly work.', () => { + const fixture = TestBed.createComponent(InputGroupDisabledByDefaultComponent); + fixture.detectChanges(); + + const component = fixture.componentInstance; + const igxInputGroup = component.igxInputGroup; + expect(igxInputGroup.disabled).toBeTruthy(); + }); + + it('should handle disabled attribute without value', () => { + const fixture = TestBed.createComponent(InputGroupDisabledWithoutValueComponent); + fixture.detectChanges(); + + const component = fixture.componentInstance; + const igxInputGroup = component.igxInputGroup; + expect(igxInputGroup.disabled).toBeTruthy(); + + component.changeDisableState(); + fixture.detectChanges(); + expect(igxInputGroup.disabled).toBeFalsy(); + + component.changeDisableState(); + fixture.detectChanges(); + expect(igxInputGroup.disabled).toBeTruthy(); + }); + + it('should correctly prevent default on pointer down', () => { + const fixture = TestBed.createComponent(InputGroupComponent); + fixture.detectChanges(); + + const inputGroup = fixture.componentInstance.igxInputGroup; + const prefix = fixture.componentInstance.prefix; + const input = fixture.componentInstance.igxInput; + fixture.componentInstance.suppressInputAutofocus = false; + + const pointOnPrefix = UIInteractions.getPointFromElement(prefix.nativeElement); + const pointerEvent = UIInteractions.createPointerEvent('pointerdown', pointOnPrefix); + const preventDefaultSpy = spyOn(pointerEvent, 'preventDefault'); + + Object.defineProperty(pointerEvent, 'target', { value: input.nativeElement, configurable: true }); + const inputGroupDebugElement = fixture.debugElement.query(By.directive(IgxInputGroupComponent)); + + // input group is not focused we should not prevent default on pointer down + inputGroupDebugElement.triggerEventHandler('pointerdown', pointerEvent); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(preventDefaultSpy).toHaveBeenCalledTimes(0); + + Object.defineProperty(pointerEvent, 'target', { value: prefix.nativeElement, configurable: true }); + + // input group is not focused we should not prevent default on pointer down + inputGroupDebugElement.triggerEventHandler('pointerdown', pointerEvent); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(preventDefaultSpy).toHaveBeenCalledTimes(0); + + // input group is focused we should prevent default on pointer down on prefix/suffix + inputGroup.isFocused = true; + inputGroupDebugElement.triggerEventHandler('pointerdown', pointerEvent); + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(preventDefaultSpy).toHaveBeenCalledTimes(1); + }); + + it('should not focus input on prefix/suffix click when group is not focused and suppressInputAutofocus=true', () => { + const fixture = TestBed.createComponent(InputGroupComponent); + fixture.detectChanges(); + + const inputGroup = fixture.componentInstance.igxInputGroup; + const prefix = fixture.componentInstance.prefix; + const input = fixture.componentInstance.igxInput; + + const pointerEvent = UIInteractions.getMouseEvent('click'); + Object.defineProperty(pointerEvent, 'target', { value: prefix.nativeElement }); + const inputGroupDebugElement = fixture.debugElement.query(By.directive(IgxInputGroupComponent)); + + // input group is not focused and suppressInputAutofocus is true - click on prefix/suffix should not focus the input + fixture.componentInstance.suppressInputAutofocus = true; + inputGroup.isFocused = false; + fixture.detectChanges(); + inputGroupDebugElement.triggerEventHandler('click', pointerEvent); + expect(document.activeElement).not.toEqual(input.nativeElement); + + // input group is not focused and suppressInputAutofocus is false - click on prefix/suffix should focus the input + fixture.componentInstance.suppressInputAutofocus = false; + inputGroup.isFocused = false; + fixture.detectChanges(); + inputGroupDebugElement.triggerEventHandler('click', pointerEvent); + expect(document.activeElement).toEqual(input.nativeElement); + + // input group is focused and suppressInputAutofocus is true - click on prefix/suffix should focus the input + fixture.componentInstance.suppressInputAutofocus = true; + inputGroup.isFocused = true; + fixture.detectChanges(); + inputGroupDebugElement.triggerEventHandler('click', pointerEvent); + expect(document.activeElement).toEqual(input.nativeElement); + }); +}); + +@Component({ + template: ` + PREFIX + SUFFIX + + `, + providers: [{ provide: IGX_INPUT_GROUP_TYPE, useValue: 'box' }], + imports: [IgxInputGroupComponent, IgxInputDirective, IgxPrefixDirective, IgxSuffixDirective] +}) +class InputGroupComponent { + public IGTOKEN = inject(IGX_INPUT_GROUP_TYPE); + + @ViewChild('igxInputGroup', { static: true }) public igxInputGroup: IgxInputGroupComponent; + @ViewChild('igxInput', { read: IgxInputDirective, static: true }) public igxInput: IgxInputDirective; + @ViewChild(IgxPrefixDirective, { read: ElementRef }) public prefix: ElementRef; + @ViewChild(IgxSuffixDirective, { read: ElementRef }) public suffix: ElementRef; + public suppressInputAutofocus = false; +} + +@Component({ + template: ` + + `, + imports: [IgxInputGroupComponent, IgxInputDirective] +}) +class InputGroupBoxComponent { + @ViewChild('igxInputGroup', { static: true }) public igxInputGroup: IgxInputGroupComponent; +} + +@Component({ + template: ` + + `, + imports: [IgxInputGroupComponent, IgxInputDirective] +}) +class InputGroupBorderComponent { + @ViewChild('igxInputGroup', { static: true }) public igxInputGroup: IgxInputGroupComponent; +} + +@Component({ + template: ` + + `, + imports: [IgxInputDirective, IgxInputGroupComponent] +}) +class InputGroupSearchComponent { + @ViewChild('igxInputGroup', { static: true }) public igxInputGroup: IgxInputGroupComponent; +} + +@Component({ + template: ` + + `, + imports: [IgxInputGroupComponent, IgxInputDirective] +}) +class InputGroupFileComponent { } + +const testInputGroupType = (type: IgxInputGroupType, component: IgxInputGroupComponent, nativeElement: HTMLInputElement) => { + let isLine = false; + let isBorder = false; + let isBox = false; + let isSearch = false; + + switch (type) { + case 'line': + isLine = true; + break; + case 'border': + isBorder = true; + break; + case 'box': + isBox = true; + break; + case 'search': + isSearch = true; + break; + default: break; + } + + expect(nativeElement.classList.contains(INPUT_GROUP_BOX_CSS_CLASS)).toBe(isBox); + expect(nativeElement.classList.contains(INPUT_GROUP_BORDER_CSS_CLASS)).toBe(isBorder); + expect(nativeElement.classList.contains(INPUT_GROUP_SEARCH_CSS_CLASS)).toBe(isSearch); + + expect(component.isTypeLine).toBe(isLine); + expect(component.isTypeBorder).toBe(isBorder); + expect(component.isTypeBox).toBe(isBox); + expect(component.isTypeSearch).toBe(isSearch); +}; + +@Component({ + template: ` + + `, + imports: [IgxInputGroupComponent, IgxInputDirective] +}) +class InputGroupDisabledComponent { + @ViewChild('igxInputGroup', { static: true }) public igxInputGroup: IgxInputGroupComponent; + + public disabled = false; + + public changeDisableState() { + this.disabled = !this.disabled; + } +} + +@Component({ + template: ` + + `, + imports: [IgxInputGroupComponent, IgxInputDirective] +}) +class InputGroupDisabledWithoutValueComponent { + @ViewChild('igxInputGroup') + public igxInputGroup: IgxInputGroupComponent; + + @ViewChild(IgxInputDirective) + public inputDir: IgxInputDirective; + + public changeDisableState() { + this.inputDir.disabled = !this.inputDir.disabled; + } +} + +@Component({ + template: ` + + `, + imports: [IgxInputGroupComponent, IgxInputDirective] +}) +class InputGroupDisabledByDefaultComponent { + @ViewChild('igxInputGroup', { static: true }) public igxInputGroup: IgxInputGroupComponent; + + public disabled = true; +} diff --git a/projects/igniteui-angular/input-group/src/input-group/input-group.component.ts b/projects/igniteui-angular/input-group/src/input-group/input-group.component.ts new file mode 100644 index 00000000000..29f4a17ee91 --- /dev/null +++ b/projects/igniteui-angular/input-group/src/input-group/input-group.component.ts @@ -0,0 +1,489 @@ +import { NgTemplateOutlet } from '@angular/common'; +import { + ChangeDetectorRef, + Component, + ContentChild, + ContentChildren, + DestroyRef, + ElementRef, + HostBinding, + HostListener, Input, + QueryList, booleanAttribute, + inject, + DOCUMENT, + AfterContentChecked +} from '@angular/core'; +import { IInputResourceStrings, InputResourceStringsEN } from 'igniteui-angular/core'; +import { PlatformUtil, getComponentTheme } from 'igniteui-angular/core'; +import { IgxButtonDirective } from 'igniteui-angular/directives'; +import { IgxHintDirective } from './directives-hint/hint.directive'; +import { + IgxInputDirective, + IgxInputState +} from './directives-input/input.directive'; +import { IgxPrefixDirective } from './directives-prefix/prefix.directive'; +import { IgxSuffixDirective } from './directives-suffix/suffix.directive'; + +import { IgxInputGroupBase } from './input-group.common'; +import { IgxInputGroupType, IGX_INPUT_GROUP_TYPE } from './inputGroupType'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { getCurrentResourceStrings } from 'igniteui-angular/core'; +import { IgxTheme, THEME_TOKEN, ThemeToken } from 'igniteui-angular/core'; + +@Component({ + selector: 'igx-input-group', + templateUrl: 'input-group.component.html', + providers: [{ provide: IgxInputGroupBase, useExisting: IgxInputGroupComponent }], + imports: [NgTemplateOutlet, IgxPrefixDirective, IgxButtonDirective, IgxSuffixDirective, IgxIconComponent] +}) +export class IgxInputGroupComponent implements IgxInputGroupBase, AfterContentChecked { + public element = inject>(ElementRef); + private _inputGroupType = inject(IGX_INPUT_GROUP_TYPE, { optional: true }); + private document = inject(DOCUMENT); + private platform = inject(PlatformUtil); + private cdr = inject(ChangeDetectorRef); + private themeToken = inject(THEME_TOKEN); + + /** + * Sets the resource strings. + * By default it uses EN resources. + */ + @Input() + public set resourceStrings(value: IInputResourceStrings) { + this._resourceStrings = Object.assign({}, this._resourceStrings, value); + } + + /** + * Returns the resource strings. + */ + public get resourceStrings(): IInputResourceStrings { + return this._resourceStrings; + } + + /** + * Property that enables/disables the auto-generated class of the `IgxInputGroupComponent`. + * By default applied the class is applied. + * ```typescript + * @ViewChild("MyInputGroup") + * public inputGroup: IgxInputGroupComponent; + * ngAfterViewInit(){ + * this.inputGroup.defaultClass = false; + * ``` + * } + */ + @HostBinding('class.igx-input-group') + public defaultClass = true; + + /** @hidden */ + @HostBinding('class.igx-input-group--placeholder') + public hasPlaceholder = false; + + /** @hidden */ + @HostBinding('class.igx-input-group--required') + public isRequired = false; + + /** @hidden */ + @HostBinding('class.igx-input-group--focused') + public isFocused = false; + + /** + * @hidden @internal + * When truthy, disables the `IgxInputGroupComponent`. + * Controlled by the underlying `IgxInputDirective`. + * ```html + * + * ``` + */ + @HostBinding('class.igx-input-group--disabled') + public disabled = false; + + /** + * Prevents automatically focusing the input when clicking on other elements in the input group (e.g. prefix or suffix). + * + * @remarks Automatic focus causes software keyboard to show on mobile devices. + * + * @example + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public suppressInputAutofocus = false; + + /** @hidden */ + @HostBinding('class.igx-input-group--warning') + public hasWarning = false; + + /** @hidden */ + @ContentChildren(IgxHintDirective, { read: IgxHintDirective }) + protected hints: QueryList; + + @ContentChildren(IgxPrefixDirective, { read: IgxPrefixDirective, descendants: true }) + protected _prefixes: QueryList; + + @ContentChildren(IgxSuffixDirective, { read: IgxSuffixDirective, descendants: true }) + protected _suffixes: QueryList; + + /** @hidden */ + @ContentChild(IgxInputDirective, { read: IgxInputDirective, static: true }) + protected input: IgxInputDirective; + + private _destroyRef = inject(DestroyRef); + private _type: IgxInputGroupType = null; + private _filled = false; + private _theme: IgxTheme; + private _resourceStrings = getCurrentResourceStrings(InputResourceStringsEN); + private _readOnly: undefined | boolean; + + /** @hidden @internal */ + @HostBinding('class.igx-input-group--readonly') + public get readOnly(): boolean { + return this._readOnly ?? (this.input?.nativeElement.readOnly || false); + } + + /** @hidden @internal */ + public set readOnly(value: boolean) { + this._readOnly = value; + } + + /** @hidden */ + @HostBinding('class.igx-input-group--valid') + public get validClass(): boolean { + return this.input.valid === IgxInputState.VALID; + } + + /** @hidden */ + @HostBinding('class.igx-input-group--invalid') + public get invalidClass(): boolean { + return this.input.valid === IgxInputState.INVALID; + } + + /** @hidden */ + @HostBinding('class.igx-input-group--filled') + public get isFilled() { + return this._filled || (this.input && this.input.value); + } + + /** @hidden */ + @HostBinding('class.igx-input-group--textarea-group') + public get textAreaClass(): boolean { + return this.input.isTextArea; + } + + /** + * Sets how the input will be styled. + * Allowed values of type IgxInputGroupType. + * ```html + * + * ``` + */ + @Input() + public set type(value: IgxInputGroupType) { + this._type = value; + } + + /** + * Returns the type of the `IgxInputGroupComponent`. How the input is styled. + * The default is `line`. + * ```typescript + * @ViewChild("MyInputGroup") + * public inputGroup: IgxInputGroupComponent; + * ngAfterViewInit(){ + * let inputType = this.inputGroup.type; + * } + * ``` + */ + public get type() { + return this._type || this._inputGroupType || 'line'; + } + + /** + * Sets the theme of the input. + * Allowed values of type IgxInputGroupTheme. + * ```typescript + * @ViewChild("MyInputGroup") + * public inputGroup: IgxInputGroupComponent; + * ngAfterViewInit() { + * let inputTheme = 'fluent'; + * } + */ + @Input() + public set theme(value: IgxTheme) { + this._theme = value; + } + + /** + * Returns the theme of the input. + * The returned value is of type IgxInputGroupType. + * ```typescript + * @ViewChild("MyInputGroup") + * public inputGroup: IgxInputGroupComponent; + * ngAfterViewInit() { + * let inputTheme = this.inputGroup.theme; + * } + */ + public get theme(): IgxTheme { + return this._theme; + } + + constructor() { + this._theme = this.themeToken.theme; + const themeChange = this.themeToken.onChange((theme) => { + if (this._theme !== theme) { + this._theme = theme; + this.cdr.detectChanges(); + } + }); + this._destroyRef.onDestroy(() => themeChange.unsubscribe()); + } + + /** @hidden */ + @HostListener('click', ['$event']) + public onClick(event: MouseEvent) { + if ( + !this.isFocused && + event.target !== this.input.nativeElement && + !this.suppressInputAutofocus + ) { + this.input.focus(); + } + } + + /** @hidden */ + @HostListener('pointerdown', ['$event']) + public onPointerDown(event: PointerEvent) { + if (this.isFocused && event.target !== this.input.nativeElement) { + event.preventDefault(); + } + } + + /** @hidden @internal */ + public hintClickHandler(event: MouseEvent) { + event.stopPropagation(); + } + + /** + * Returns whether the `IgxInputGroupComponent` has hints. + * ```typescript + * @ViewChild("MyInputGroup") + * public inputGroup: IgxInputGroupComponent; + * ngAfterViewInit(){ + * let inputHints = this.inputGroup.hasHints; + * } + * ``` + */ + public get hasHints() { + return this.hints.length > 0; + } + + /** @hidden @internal */ + @HostBinding('class.igx-input-group--prefixed') + public get hasPrefixes() { + return this._prefixes.length > 0; + } + + /** @hidden @internal */ + public set prefixes(items: QueryList) { + this._prefixes = items; + } + + /** @hidden @internal */ + @HostBinding('class.igx-input-group--suffixed') + public get hasSuffixes() { + return this._suffixes.length > 0 || this.isFileType && this.isFilled; + } + + /** @hidden @internal */ + public set suffixes(items: QueryList) { + this._suffixes = items; + } + + /** + * Returns whether the `IgxInputGroupComponent` has border. + * ```typescript + * @ViewChild("MyInputGroup") + * public inputGroup: IgxInputGroupComponent; + * ngAfterViewInit(){ + * let inputBorder = this.inputGroup.hasBorder; + * } + * ``` + */ + public get hasBorder() { + return ( + (this.type === 'line' || this.type === 'box') && + this._theme === 'material' + ); + } + + /** + * Returns whether the `IgxInputGroupComponent` type is line. + * ```typescript + * @ViewChild("MyInputGroup1") + * public inputGroup: IgxInputGroupComponent; + * ngAfterViewInit(){ + * let isTypeLine = this.inputGroup.isTypeLine; + * } + * ``` + */ + public get isTypeLine(): boolean { + return this.type === 'line' && this._theme === 'material'; + } + + /** + * Returns whether the `IgxInputGroupComponent` type is box. + * ```typescript + * @ViewChild("MyInputGroup1") + * public inputGroup: IgxInputGroupComponent; + * ngAfterViewInit(){ + * let isTypeBox = this.inputGroup.isTypeBox; + * } + * ``` + */ + @HostBinding('class.igx-input-group--box') + public get isTypeBox() { + return this.type === 'box' && this._theme === 'material'; + } + + /** @hidden @internal */ + public clearValueHandler() { + this.input.clear(); + } + + /** @hidden @internal */ + @HostBinding('class.igx-input-group--file') + public get isFileType() { + return this.input.type === 'file'; + } + + /** @hidden @internal */ + @HostBinding('class.igx-file-input') + public get isFileInput() { + return this.input.type === 'file'; + } + + /** @hidden @internal */ + @HostBinding('class.igx-file-input--filled') + public get isFileInputFilled() { + return this.isFileType && this.isFilled; + } + + /** @hidden @internal */ + @HostBinding('class.igx-file-input--focused') + public get isFileInputFocused() { + return this.isFileType && this.isFocused; + } + + /** @hidden @internal */ + @HostBinding('class.igx-file-input--disabled') + public get isFileInputDisabled() { + return this.isFileType && this.disabled; + } + + /** @hidden @internal */ + public get fileNames() { + return this.input.fileNames || this._resourceStrings.igx_input_file_placeholder; + } + + /** + * Returns whether the `IgxInputGroupComponent` type is border. + * ```typescript + * @ViewChild("MyInputGroup1") + * public inputGroup: IgxInputGroupComponent; + * ngAfterViewInit(){ + * let isTypeBorder = this.inputGroup.isTypeBorder; + * } + * ``` + */ + @HostBinding('class.igx-input-group--border') + public get isTypeBorder() { + return this.type === 'border' && this._theme === 'material'; + } + + /** + * Returns true if the `IgxInputGroupComponent` theme is Fluent. + * ```typescript + * @ViewChild("MyInputGroup1") + * public inputGroup: IgxInputGroupComponent; + * ngAfterViewInit(){ + * let isTypeFluent = this.inputGroup.isTypeFluent; + * } + * ``` + */ + @HostBinding('class.igx-input-group--fluent') + public get isTypeFluent() { + return this._theme === 'fluent'; + } + + /** + * Returns true if the `IgxInputGroupComponent` theme is Bootstrap. + * ```typescript + * @ViewChild("MyInputGroup1") + * public inputGroup: IgxInputGroupComponent; + * ngAfterViewInit(){ + * let isTypeBootstrap = this.inputGroup.isTypeBootstrap; + * } + * ``` + */ + @HostBinding('class.igx-input-group--bootstrap') + public get isTypeBootstrap() { + return this._theme === 'bootstrap'; + } + + /** + * Returns true if the `IgxInputGroupComponent` theme is Indigo. + * ```typescript + * @ViewChild("MyInputGroup1") + * public inputGroup: IgxInputGroupComponent; + * ngAfterViewInit(){ + * let isTypeIndigo = this.inputGroup.isTypeIndigo; + * } + * ``` + */ + @HostBinding('class.igx-input-group--indigo') + public get isTypeIndigo() { + return this._theme === 'indigo'; + } + + /** + * Returns whether the `IgxInputGroupComponent` type is search. + * ```typescript + * @ViewChild("MyInputGroup1") + * public inputGroup: IgxInputGroupComponent; + * ngAfterViewInit(){ + * let isTypeSearch = this.inputGroup.isTypeSearch; + * } + * ``` + */ + @HostBinding('class.igx-input-group--search') + public get isTypeSearch() { + if(!this.isFileType && !this.input.isTextArea) { + return this.type === 'search'; + } + } + + /** @hidden */ + public get filled() { + return this._filled; + } + + /** @hidden */ + public set filled(val) { + this._filled = val; + } + + private setComponentTheme() { + if (!this.themeToken.preferToken) { + const theme = getComponentTheme(this.element.nativeElement); + + if (theme && theme !== this._theme) { + this.theme = theme; + this.cdr.markForCheck(); + } + } + } + + /** @hidden @internal */ + public ngAfterContentChecked() { + this.setComponentTheme(); + } +} diff --git a/projects/igniteui-angular/input-group/src/input-group/input-group.module.ts b/projects/igniteui-angular/input-group/src/input-group/input-group.module.ts new file mode 100644 index 00000000000..741a8826ef7 --- /dev/null +++ b/projects/igniteui-angular/input-group/src/input-group/input-group.module.ts @@ -0,0 +1,18 @@ +import { NgModule } from '@angular/core'; +import { IGX_INPUT_GROUP_DIRECTIVES } from './public_api'; + +/** + * @hidden + * @deprecated + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + ...IGX_INPUT_GROUP_DIRECTIVES + ], + exports: [ + ...IGX_INPUT_GROUP_DIRECTIVES + ] +}) + +export class IgxInputGroupModule {} diff --git a/projects/igniteui-angular/input-group/src/input-group/inputGroupType.ts b/projects/igniteui-angular/input-group/src/input-group/inputGroupType.ts new file mode 100644 index 00000000000..ada6a2c2492 --- /dev/null +++ b/projects/igniteui-angular/input-group/src/input-group/inputGroupType.ts @@ -0,0 +1,19 @@ +import { InjectionToken } from '@angular/core'; + +export const IgxInputGroupEnum = { + Line: 'line', + Box: 'box', + Border: 'border', + Search: 'search' +} as const; + +/** + * Defines the InputGroupType DI token. + */ +// Should this go trough Interface https://angular.io/api/core/InjectionToken +export const IGX_INPUT_GROUP_TYPE = /*@__PURE__*/new InjectionToken('InputGroupType'); + +/** + * Determines the InputGroupType. + */ +export type IgxInputGroupType = (typeof IgxInputGroupEnum)[keyof typeof IgxInputGroupEnum]; diff --git a/projects/igniteui-angular/input-group/src/input-group/public_api.ts b/projects/igniteui-angular/input-group/src/input-group/public_api.ts new file mode 100644 index 00000000000..9ded200923b --- /dev/null +++ b/projects/igniteui-angular/input-group/src/input-group/public_api.ts @@ -0,0 +1,26 @@ +import { IgxHintDirective } from './directives-hint/hint.directive'; +import { IgxInputDirective } from './directives-input/input.directive'; +import { IgxLabelDirective } from './directives-label/label.directive'; +import { IgxPrefixDirective } from './directives-prefix/prefix.directive'; +import { IgxSuffixDirective } from './directives-suffix/suffix.directive'; +import { IgxInputGroupComponent } from './input-group.component'; + +export * from './input-group.component'; +export * from './input-group.common'; +export * from './directives-hint/hint.directive'; +export * from './directives-input/input.directive'; +export * from './directives-input/read-only-input.directive'; +export * from './directives-label/label.directive'; +export * from './directives-prefix/prefix.directive'; +export * from './directives-suffix/suffix.directive'; +export * from './inputGroupType'; + +/* NOTE: Input group directives collection for ease-of-use import in standalone components scenario */ +export const IGX_INPUT_GROUP_DIRECTIVES = [ + IgxInputGroupComponent, + IgxInputDirective, + IgxLabelDirective, + IgxPrefixDirective, + IgxSuffixDirective, + IgxHintDirective +] as const; diff --git a/projects/igniteui-angular/input-group/src/public_api.ts b/projects/igniteui-angular/input-group/src/public_api.ts new file mode 100644 index 00000000000..cfdce15c459 --- /dev/null +++ b/projects/igniteui-angular/input-group/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './input-group/public_api'; +export * from './input-group/input-group.module'; diff --git a/projects/igniteui-angular/list/README.md b/projects/igniteui-angular/list/README.md new file mode 100644 index 00000000000..80dce3a1abc --- /dev/null +++ b/projects/igniteui-angular/list/README.md @@ -0,0 +1,212 @@ +# Igx-List + +#### Category +_Components_ + +## Description +_Igx-List represents a list of identical items._ +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/list) + +- `IgxList` - since v7.3.4 The list component has been refactored. It now includes several new supporting directives: + - `igxListThumbnail` - Use it to mark the target as list thumbnail which will be automatically positioned as a first item in the list item; + - `igxListAction` - Use it to mark the target as list action which will be automatically positioned as a last item in the list item; + - `igxListLine` - Use it to mark the target as list content which will be automatically positioned between the thumbnail and action; + - `igxListLineTitle` - Use it to mark the target as list title which will be automatically formatted as a list-item title; + - `igxListLineSubTitle` - Use it to mark the target as list subtitle which will be automatically formatted as a list-item subtitle; + +Example using the new directives: + + ```html + + List items + + +

    List item title

    +

    List item subtitle

    + info +
    +
    + + + List items + + + Some content + info + + + ``` + +## Usage +```html + + + Work Contacts + + Terrance Orta + Richard Mahoney + Donna Price + + Family Contacts + + John Smith + Mary Smith + +``` + +### List elements +The children components of the Igx-List are *Igx-List-Item* components. Based on their `isHeader` property, the list items can have different roles within the list: + +- List item with `isHeader` set to **false** - interactive list item. +- List item with `isHeader` set to **true** - non-interactive list item which role is to label, describe and unify the next list items, composed below it + +All list items implement `IListChild`. +In order to access its elements, the list provides the following: + +- a collection that contains all the children: items and headers +- an array with items only +- an array with headers only + +### Empty list template + +By default if there are no items in the list, the default empty list template will be displayed. +In order to use your own custom template, you can use the `igxEmptyList` directive. It basically replaces the deprecated `emptyListImage`, `emptyListMessage`, `emptyListButtonText` inputs and the `emptyListButtonClick` event, which were previously used to template the list when it is empty. + +```html + + +

    My custom empty list template

    +
    +
    +``` + +### List Items Panning +The IgxList's items support left and right panning. You can enable this feature separately for each direction using the `allowLeftPanning` and `allowRightPanning` properties. There are separate templates for left and right panning shown under the panned list item. The templates are defined using **ng-template** and specifying the directives `igxListItemLeftPanning` and `igxListItemRightPanning`. When panning the list items beyond a certain threshold an event will be emitted. This threshold is specified using the `panEndTriggeringThreshold` property. By default this property has a value of 0.5 which means 50% of list item's width. The events emitted are `leftPan` and `rightPan` and their event argument is of type `IListItemPanningEventArgs` and has the following fields: +- item - a reference to the `IgxListItemComponent` being dragged +- direction - field of type `IgxListPanState` showing the panning direction +- keepItem - this property specifies whether the list item will be kept in the list after a successful panning. By default it is `false`. May be set to `true` in the event handler. + +```html + + +
    Message
    +
    + +
    Dial
    +
    + ... +
    +``` + +```typescript +public leftPanPerformed(args) { + args.keepItem = true; +} + +public rightPanPerformed(args) { + args.keepItem = true; +} +``` + +## API + +### Inputs + +| Name | Description | +| :--- | :--- | +| id | Unique identifier of the component. If not provided it will be automatically generated.| +| allowLeftPanning | Determines whether the left panning of an item is allowed | +| allowRightPanning | Determines whether the right panning of an item is allowed | +| emptyListTemplate | Sets a reference to a custom empty list template, otherwise default template is used | +| dataLoadingTemplate | Sets a reference to a custom data loading template, otherwise default template is used | +| panEndTriggeringThreshold | Number | Specifies the threshold after which a panning event is emitted. By default this property has a value of 0.5 which means 50% of list item's width. | + +### Properties + +| Name | Description | +| :--- | :--- | +| children | Collection of all `IListChild` components: items and headers | +| items | Array of items in the list | +| headers | Array of headers in the list | +| innerStyle | Currently used inner style depending on whether the list is empty or not | +| role | Gets the role of the list | + + +### Outputs + +| Name | Description | +| :--- | :--- | +| *Event emitters* | *Notify for a change* | +| panStateChange | Triggered when pan gesture is executed on list item | +| leftPan | Triggered when left pan gesture is executed on list item | +| rightPan | Triggered when right pan gesture is executed on list item | +| itemClicked | Triggered when a list item has been clicked | + + +---------- +# Igx-List-Item + +#### Category +_Child components_ + +## Description +Based on its `isHeader` property, the list item has a specific role within the list: + +| `isHeader` | Description | +| :--- | :--- | +| false | _Child component of Igx-List, that represents a single interactive item. Its content can be text or any other HTML content._ | +| true | _Child component of Igx-List, that represents a single non-interactive item, that is used as a header of the following items._ | + +## Usage +- List item +```html + + Lisa Landers + +``` + +- List item as header +```html + + Contacts + +``` + +All list items implement `IListChild`. + +## API + +### Inputs + +| Name | Description | +| :--- | :--- | +| index | The index of item in children collection | +| hidden | Determines whether the item should be displayed | +| isHeader | Determines whether the item should be displayed as a header, default value is _false_ | + +### Directives + +| name | description +| :--- | :---| +| igxListThumbnail | Use it to mark the target as list thumbnail which will be automatically positioned as a first item in the list item; +| igxListAction | Use it to mark the target as list action which will be automatically positioned as a last item in the list item; +| igxListLine | Use it to mark the target as list content which will be automatically positioned between the thumbnail and action; +| igxListLineTitle | Use it to mark the target as list title which will be automatically formatted as a list-item title; +| igxListLineSubTitle | Use it to mark the target as list subtitle which will be automatically formatted as a list-item subtitle; + + +### Properties + +| Name | Description | +| :--- | :--- | +| panState | Gets the item's pan state | +| list | Gets the list that is associated with the item | +| role | Gets the role of the item within its respective list - _separator_ if isHeader is true and _listitem_ otherwise | +| element | Gets the native element that is associated with the item | +| width | Gets the width of the item | +| maxLeft | Gets the maximum left position of the item | +| maxRight | Gets the maximum right position of the item | +| touchAction | Determines in what way the item can be manipulated by the user via a touch action | +| headerStyle | Gets if the item is styled as header item | +| innerStyle | Gets if the item is styled as list item | diff --git a/projects/igniteui-angular/list/index.ts b/projects/igniteui-angular/list/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/list/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/list/ng-package.json b/projects/igniteui-angular/list/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/list/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/list/src/list/list-item.component.html b/projects/igniteui-angular/list/src/list/list-item.component.html new file mode 100644 index 00000000000..ef538fa89f4 --- /dev/null +++ b/projects/igniteui-angular/list/src/list/list-item.component.html @@ -0,0 +1,51 @@ + +@if (!isHeader && list.listItemLeftPanningTemplate) { +
    + + +
    +} + +@if (!isHeader && list.listItemRightPanningTemplate) { +
    + + +
    +} + + + + + + +
    + +
    +
    + + +
    + +
    +
    + + +
    + +
    +
    + +@if (isHeader) { + +} + +@if (!isHeader) { +
    + + + + +
    +} diff --git a/projects/igniteui-angular/list/src/list/list-item.component.ts b/projects/igniteui-angular/list/src/list/list-item.component.ts new file mode 100644 index 00000000000..2616f6c47b8 --- /dev/null +++ b/projects/igniteui-angular/list/src/list/list-item.component.ts @@ -0,0 +1,511 @@ +import { ChangeDetectionStrategy, Component, ElementRef, HostBinding, HostListener, Input, Renderer2, ViewChild, booleanAttribute, inject } from '@angular/core'; + +import { + IgxListPanState, + IListChild, + IgxListBaseDirective +} from './list.common'; + +import { HammerGesturesManager } from 'igniteui-angular/core'; +import { rem } from 'igniteui-angular/core'; +import { NgTemplateOutlet } from '@angular/common'; + +/** + * The Ignite UI List Item component is a container intended for row items in the Ignite UI for Angular List component. + * + * Example: + * ```html + * + * Contacts + * + * {{ contact.name }} + * {{ contact.phone }} + * + * + * ``` + */ +@Component({ + providers: [HammerGesturesManager], + selector: 'igx-list-item', + templateUrl: 'list-item.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgTemplateOutlet] +}) +export class IgxListItemComponent implements IListChild { + public list = inject(IgxListBaseDirective); + private elementRef = inject(ElementRef); + private _renderer = inject(Renderer2); + + /** + * Provides a reference to the template's base element shown when left panning a list item. + * ```typescript + * const leftPanTmpl = this.listItem.leftPanningTemplateElement; + * ``` + */ + @ViewChild('leftPanningTmpl') + public leftPanningTemplateElement; + + /** + * Provides a reference to the template's base element shown when right panning a list item. + * ```typescript + * const rightPanTmpl = this.listItem.rightPanningTemplateElement; + * ``` + */ + @ViewChild('rightPanningTmpl') + public rightPanningTemplateElement; + + /** + * Sets/gets whether the `list item` is a header. + * ```html + * Header + * ``` + * ```typescript + * let isHeader = this.listItem.isHeader; + * ``` + * + * @memberof IgxListItemComponent + */ + @Input({ transform: booleanAttribute }) + public isHeader: boolean; + + /** + * Sets/gets whether the `list item` is hidden. + * By default the `hidden` value is `false`. + * ```html + * Hidden Item + * ``` + * ```typescript + * let isHidden = this.listItem.hidden; + * ``` + * + * @memberof IgxListItemComponent + */ + @Input({ transform: booleanAttribute }) + public hidden = false; + + /** + * Sets/gets the `aria-label` attribute of the `list item`. + * ```typescript + * this.listItem.ariaLabel = "Item1"; + * ``` + * ```typescript + * let itemAriaLabel = this.listItem.ariaLabel; + * ``` + * + * @memberof IgxListItemComponent + */ + @HostBinding('attr.aria-label') + public ariaLabel: string; + + /** + * Gets the `touch-action` style of the `list item`. + * ```typescript + * let touchAction = this.listItem.touchAction; + * ``` + */ + @HostBinding('style.touch-action') + public touchAction = 'pan-y'; + + /** + * @hidden + */ + private _panState: IgxListPanState = IgxListPanState.NONE; + + /** + * @hidden + */ + private panOffset = 0; + + /** + * @hidden + */ + private _index: number = null; + + /** + * @hidden + */ + private lastPanDir = IgxListPanState.NONE; + + private _role: string = ''; + private _selected = false;; + + /** + * Gets the `panState` of a `list item`. + * ```typescript + * let itemPanState = this.listItem.panState; + * ``` + * + * @memberof IgxListItemComponent + */ + public get panState(): IgxListPanState { + return this._panState; + } + + /** + * Gets the `index` of a `list item`. + * ```typescript + * let itemIndex = this.listItem.index; + * ``` + * + * @memberof IgxListItemComponent + */ + @Input() + public get index(): number { + return this._index !== null ? this._index : this.list.children.toArray().indexOf(this); + } + + /** + * Sets the `index` of the `list item`. + * ```typescript + * this.listItem.index = index; + * ``` + * + * @memberof IgxListItemComponent + */ + public set index(value: number) { + this._index = value; + } + + /** + * Returns an element reference to the list item. + * ```typescript + * let listItemElement = this.listItem.element. + * ``` + * + * @memberof IgxListItemComponent + */ + public get element() { + return this.elementRef.nativeElement; + } + + /** + * Returns a reference container which contains the list item's content. + * ```typescript + * let listItemContainer = this.listItem.contentElement. + * ``` + * + * @memberof IgxListItemComponent + */ + public get contentElement() { + const candidates = this.element.getElementsByClassName('igx-list__item-content'); + return (candidates && candidates.length > 0) ? candidates[0] : null; + } + + /** + * Returns the `context` object which represents the `template context` binding into the `list item container` + * by providing the `$implicit` declaration which is the `IgxListItemComponent` itself. + * ```typescript + * let listItemComponent = this.listItem.context; + * ``` + */ + public get context(): any { + return { + $implicit: this + }; + } + + /** + * Gets the width of a `list item`. + * ```typescript + * let itemWidth = this.listItem.width; + * ``` + * + * @memberof IgxListItemComponent + */ + public get width() { + if (this.element) { + return this.element.offsetWidth; + } + } + + /** + * Gets the maximum left position of the `list item`. + * ```typescript + * let maxLeft = this.listItem.maxLeft; + * ``` + * + * @memberof IgxListItemComponent + */ + public get maxLeft() { + return -this.width; + } + + /** + * Gets the maximum right position of the `list item`. + * ```typescript + * let maxRight = this.listItem.maxRight; + * ``` + * + * @memberof IgxListItemComponent + */ + public get maxRight() { + return this.width; + } + + /** @hidden @internal */ + public get offsetWidthInRem() { + return rem(this.element.offsetWidth); + } + + /** @hidden @internal */ + public get offsetHeightInRem() { + return rem(this.element.offsetHeight); + } + + /** + * Gets/Sets the `role` attribute of the `list item`. + * ```typescript + * let itemRole = this.listItem.role; + * ``` + * + * @memberof IgxListItemComponent + */ + @HostBinding('attr.role') + @Input() + public get role() { + return this._role ? this._role : this.isHeader ? 'separator' : 'listitem'; + } + + public set role(val: string) { + this._role = val; + } + + /** + * Sets/gets whether the `list item` is selected. + * Selection is only applied to non-header items. + * When selected, the CSS class 'igx-list__item-base--selected' is added to the item. + * ```html + * Selected Item + * ``` + * ```typescript + * let isSelected = this.listItem.selected; + * this.listItem.selected = true; + * ``` + * + * @memberof IgxListItemComponent + */ + @HostBinding('class.igx-list__item-base--selected') + @Input({ transform: booleanAttribute }) + public get selected() { + return this._selected && !this.isHeader; + } + + public set selected(value: boolean) { + this._selected = value; + } + + /** + * Indicates whether `list item` should have header style. + * ```typescript + * let headerStyle = this.listItem.headerStyle; + * ``` + * + * @memberof IgxListItemComponent + */ + @HostBinding('class.igx-list__header') + public get headerStyle(): boolean { + return this.isHeader; + } + + /** + * Applies the inner style of the `list item` if the item is not counted as header. + * ```typescript + * let innerStyle = this.listItem.innerStyle; + * ``` + * + * @memberof IgxListItemComponent + */ + @HostBinding('class.igx-list__item-base') + public get innerStyle(): boolean { + return !this.isHeader; + } + + /** + * Returns string value which describes the display mode of the `list item`. + * ```typescript + * let isHidden = this.listItem.display; + * ``` + * + * @memberof IgxListItemComponent + */ + @HostBinding('style.display') + public get display(): string { + return this.hidden ? 'none' : ''; + } + + /** + * @hidden + */ + @HostListener('click', ['$event']) + public clicked(evt) { + this.list.itemClicked.emit({ item: this, event: evt, direction: this.lastPanDir }); + this.lastPanDir = IgxListPanState.NONE; + } + + /** + * @hidden + */ + @HostListener('panstart') + public panStart() { + if (this.isTrue(this.isHeader)) { + return; + } + if (!this.isTrue(this.list.allowLeftPanning) && !this.isTrue(this.list.allowRightPanning)) { + return; + } + + this.list.startPan.emit({ item: this, direction: this.lastPanDir, keepitem: false }); + } + + /** + * @hidden + */ + @HostListener('pancancel') + public panCancel() { + this.resetPanPosition(); + this.list.endPan.emit({ item: this, direction: this.lastPanDir, keepItem: false }); + } + + /** + * @hidden + */ + @HostListener('panmove', ['$event']) + public panMove(ev) { + if (this.isTrue(this.isHeader)) { + return; + } + if (!this.isTrue(this.list.allowLeftPanning) && !this.isTrue(this.list.allowRightPanning)) { + return; + } + const isPanningToLeft = ev.deltaX < 0; + if (isPanningToLeft && this.isTrue(this.list.allowLeftPanning)) { + this.showLeftPanTemplate(); + this.setContentElementLeft(Math.max(this.maxLeft, ev.deltaX)); + } else if (!isPanningToLeft && this.isTrue(this.list.allowRightPanning)) { + this.showRightPanTemplate(); + this.setContentElementLeft(Math.min(this.maxRight, ev.deltaX)); + } + } + + /** + * @hidden + */ + @HostListener('panend') + public panEnd() { + if (this.isTrue(this.isHeader)) { + return; + } + if (!this.isTrue(this.list.allowLeftPanning) && !this.isTrue(this.list.allowRightPanning)) { + return; + } + + // the translation offset of the current list item content + const relativeOffset = this.panOffset; + const widthTriggeringGrip = this.width * this.list.panEndTriggeringThreshold; + + if (relativeOffset === 0) { + return; // no panning has occured + } + + const dir = relativeOffset > 0 ? IgxListPanState.RIGHT : IgxListPanState.LEFT; + this.lastPanDir = dir; + + const args = { item: this, direction: dir, keepItem: false }; + this.list.endPan.emit(args); + + const oldPanState = this._panState; + if (Math.abs(relativeOffset) < widthTriggeringGrip) { + this.resetPanPosition(); + this.list.resetPan.emit(this); + return; + } + + if (dir === IgxListPanState.LEFT) { + this.list.leftPan.emit(args); + } else { + this.list.rightPan.emit(args); + } + + if (args.keepItem === true) { + this.setContentElementLeft(0); + this._panState = IgxListPanState.NONE; + } else { + if (dir === IgxListPanState.LEFT) { + this.setContentElementLeft(this.maxLeft); + this._panState = IgxListPanState.LEFT; + } else { + this.setContentElementLeft(this.maxRight); + this._panState = IgxListPanState.RIGHT; + } + } + + if (oldPanState !== this._panState) { + const args2 = { oldState: oldPanState, newState: this._panState, item: this }; + this.list.panStateChange.emit(args2); + } + this.hideLeftAndRightPanTemplates(); + } + + /** + * @hidden + */ + private showLeftPanTemplate() { + this.setLeftAndRightTemplatesVisibility('visible', 'hidden'); + } + + /** + * @hidden + */ + private showRightPanTemplate() { + this.setLeftAndRightTemplatesVisibility('hidden', 'visible'); + } + + /** + * @hidden + */ + private hideLeftAndRightPanTemplates() { + setTimeout(() => { + this.setLeftAndRightTemplatesVisibility('hidden', 'hidden'); + }, 500); + } + + /** + * @hidden + */ + private setLeftAndRightTemplatesVisibility(leftVisibility, rightVisibility) { + if (this.leftPanningTemplateElement && this.leftPanningTemplateElement.nativeElement) { + this.leftPanningTemplateElement.nativeElement.style.visibility = leftVisibility; + } + if (this.rightPanningTemplateElement && this.rightPanningTemplateElement.nativeElement) { + this.rightPanningTemplateElement.nativeElement.style.visibility = rightVisibility; + } + } + + /** + * @hidden + */ + private setContentElementLeft(value: number) { + this.panOffset = value; + this.contentElement.style.transform = 'translateX(' + value + 'px)'; + } + + /** + * @hidden + */ + private isTrue(value: boolean): boolean { + if (typeof (value) === 'boolean') { + return value; + } else { + return value === 'true'; + } + } + + /** + * @hidden + */ + private resetPanPosition() { + this.setContentElementLeft(0); + this._panState = IgxListPanState.NONE; + this.hideLeftAndRightPanTemplates(); + } +} diff --git a/projects/igniteui-angular/list/src/list/list.common.ts b/projects/igniteui-angular/list/src/list/list.common.ts new file mode 100644 index 00000000000..e49d9305171 --- /dev/null +++ b/projects/igniteui-angular/list/src/list/list.common.ts @@ -0,0 +1,62 @@ +import { Directive, TemplateRef, EventEmitter, QueryList, ElementRef, inject } from '@angular/core'; + +export interface IListChild { + index: number; +} + +/** @hidden */ +@Directive({ + selector: '[igxListBase]', + standalone: true +}) +export class IgxListBaseDirective { + protected el = inject(ElementRef, { optional: true }); + + public itemClicked: EventEmitter; + public allowLeftPanning: boolean; + public allowRightPanning: boolean; + public panEndTriggeringThreshold: number; + public leftPan: EventEmitter; + public rightPan: EventEmitter; + public startPan: EventEmitter; + public endPan: EventEmitter; + public resetPan: EventEmitter; + public panStateChange: EventEmitter; + public children: QueryList; + public listItemLeftPanningTemplate: IgxListItemLeftPanningTemplateDirective; + public listItemRightPanningTemplate: IgxListItemRightPanningTemplateDirective; +} + +export enum IgxListPanState { NONE, LEFT, RIGHT } + +@Directive({ + selector: '[igxEmptyList]', + standalone: true +}) +export class IgxEmptyListTemplateDirective { + public template = inject>(TemplateRef); +} + +@Directive({ + selector: '[igxDataLoading]', + standalone: true +}) +export class IgxDataLoadingTemplateDirective { + public template = inject>(TemplateRef); +} + +@Directive({ + selector: '[igxListItemLeftPanning]', + standalone: true +}) +export class IgxListItemLeftPanningTemplateDirective { + public template = inject>(TemplateRef); +} + +@Directive({ + selector: '[igxListItemRightPanning]', + standalone: true +}) +export class IgxListItemRightPanningTemplateDirective { + public template = inject>(TemplateRef); +} diff --git a/projects/igniteui-angular/list/src/list/list.component.html b/projects/igniteui-angular/list/src/list/list.component.html new file mode 100644 index 00000000000..46bafca512b --- /dev/null +++ b/projects/igniteui-angular/list/src/list/list.component.html @@ -0,0 +1,18 @@ + + + +
    + {{resourceStrings.igx_list_no_items}} +
    +
    + + +
    + {{resourceStrings.igx_list_loading}} +
    +
    + +@if (!children || children.length === 0 || isLoading) { + + +} diff --git a/projects/igniteui-angular/list/src/list/list.component.spec.ts b/projects/igniteui-angular/list/src/list/list.component.spec.ts new file mode 100644 index 00000000000..b9414781111 --- /dev/null +++ b/projects/igniteui-angular/list/src/list/list.component.spec.ts @@ -0,0 +1,830 @@ +import { QueryList } from '@angular/core'; +import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { IgxListItemComponent } from './list-item.component'; +import { IgxListPanState } from './list.common'; +import { + IgxListActionDirective, + IgxListComponent, + IgxListLineDirective, + IgxListLineSubTitleDirective, + IgxListLineTitleDirective, + IgxListThumbnailDirective, + IListItemClickEventArgs +} from './list.component'; + +import { + ListWithHeaderComponent, + ListWithPanningComponent, + EmptyListComponent, + CustomEmptyListComponent, + ListLoadingComponent, + ListWithPanningTemplatesComponent, + ListCustomLoadingComponent, + ListWithIgxForAndScrollingComponent, + TwoHeadersListComponent, + TwoHeadersListNoPanningComponent, + ListDirectivesComponent, + ListWithSelectedItemComponent +} from '../../../test-utils/list-components.spec'; +import { wait } from '../../../test-utils/ui-interactions.spec'; +import { GridFunctions } from '../../../test-utils/grid-functions.spec'; + +describe('List', () => { + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + CustomEmptyListComponent, + EmptyListComponent, + ListCustomLoadingComponent, + ListLoadingComponent, + ListWithHeaderComponent, + ListWithPanningComponent, + TwoHeadersListComponent, + TwoHeadersListNoPanningComponent, + ListWithPanningTemplatesComponent, + ListWithIgxForAndScrollingComponent, + ListDirectivesComponent, + ListWithSelectedItemComponent + ] + }).compileComponents(); + })); + + it('should initialize igx-list with item and header', () => { + const fixture = TestBed.createComponent(ListWithHeaderComponent); + const list = fixture.componentInstance.list; + const domList = fixture.debugElement.query(By.css('igx-list')).nativeElement; + + expect(list).toBeDefined(); + expect(list.id).toContain('igx-list-'); + expect(list instanceof IgxListComponent).toBeTruthy(); + expect(list.cssClass).toBeFalsy(); + expect(list.isListEmpty).toBeTruthy(); + expect(list.items instanceof Array).toBeTruthy(); + expect(list.items.length).toBe(0); + expect(list.headers instanceof Array).toBeTruthy(); + expect(list.headers.length).toBe(0); + + fixture.detectChanges(); + expect(domList.id).toContain('igx-list-'); + expect(list.items instanceof Array).toBeTruthy(); + expect(list.cssClass).toBeTruthy(); + expect(list.isListEmpty).toBeFalsy(); + expect(list.items.length).toBe(3); + expect(list.items[0] instanceof IgxListItemComponent).toBeTruthy(); + expect(list.headers instanceof Array).toBeTruthy(); + expect(list.headers.length).toBe(1); + expect(list.headers[0] instanceof IgxListItemComponent).toBeTruthy(); + + list.id = 'customList'; + fixture.detectChanges(); + + expect(list.id).toBe('customList'); + expect(domList.id).toBe('customList'); + }); + + it('should set/get properly layout properties: width, left, maxLeft, maxRight', () => { + const fixture = TestBed.createComponent(ListWithHeaderComponent); + const list = fixture.componentInstance.list; + const testWidth = 400; + const testLeft = 0; + + fixture.detectChanges(); + + fixture.componentInstance.wrapper.nativeElement.style.width = testWidth + 'px'; + + fixture.detectChanges(); + expect(list.items.length).toBe(3); + const item = list.items[0]; + expect(item instanceof IgxListItemComponent).toBeTruthy(); + expect(item.width).toBe(testWidth); + expect(item.maxLeft).toBe(-testWidth); + expect(item.maxRight).toBe(testWidth); + expect(item.element.offsetLeft).toBe(testLeft); + }); + + it('should calculate properly item index', () => { + const fixture = TestBed.createComponent(ListWithHeaderComponent); + const list = fixture.componentInstance.list; + fixture.detectChanges(); + + expect(list.children instanceof QueryList).toBeTruthy(); + expect(list.items instanceof Array).toBeTruthy(); + expect(list.headers instanceof Array).toBeTruthy(); + + expect(list.children.length).toBe(4); + expect(list.items.length).toBe(3); + expect(list.headers.length).toBe(1); + + for (let i = 0; i < list.children.length; i++) { + const item: IgxListItemComponent = list.children.find(((child) => (child.index === i))); + expect(item.index).toBe(i); + } + }); + + it('should pan right and pan left.', () => { + const fixture = TestBed.createComponent(ListWithPanningComponent); + const list: IgxListComponent = fixture.componentInstance.list; + + fixture.detectChanges(); + + spyOn(list.leftPan, 'emit').and.callThrough(); + spyOn(list.panStateChange, 'emit').and.callThrough(); + spyOn(list.rightPan, 'emit').and.callThrough(); + + const itemNativeElements = fixture.debugElement.queryAll(By.css('igx-list-item')); + const listItems = list.items; + + /* Pan item right */ + panItem(itemNativeElements[0], 0.6); + expect(listItems[0].panState).toBe(IgxListPanState.RIGHT); + + /* Pan item left */ + panItem(itemNativeElements[1], -0.6); + expect(listItems[1].panState).toBe(IgxListPanState.LEFT); + + expect(list.leftPan.emit).toHaveBeenCalledTimes(1); + expect(list.panStateChange.emit).toHaveBeenCalledTimes(2); + expect(list.rightPan.emit).toHaveBeenCalledTimes(1); + }); + + it('should emit startPan and endPan when panning left or right', () => { + const fixture = TestBed.createComponent(ListWithPanningComponent); + const list: IgxListComponent = fixture.componentInstance.list; + + fixture.detectChanges(); + + spyOn(list.startPan, 'emit').and.callThrough(); + spyOn(list.endPan, 'emit').and.callThrough(); + + const itemNativeElements = fixture.debugElement.queryAll(By.css('igx-list-item')); + + /* Pan item right */ + panItem(itemNativeElements[0], 0.6); + + /* Pan item left */ + panItem(itemNativeElements[1], -0.6); + + expect(list.startPan.emit).toHaveBeenCalledTimes(2); + expect(list.endPan.emit).toHaveBeenCalledTimes(2); + }); + + it('should pan right only.', () => { + const fixture = TestBed.createComponent(ListWithPanningComponent); + fixture.componentInstance.allowLeftPanning = false; + + fixture.detectChanges(); + + const list: IgxListComponent = fixture.componentInstance.list; + + spyOn(list.leftPan, 'emit').and.callThrough(); + spyOn(list.panStateChange, 'emit').and.callThrough(); + spyOn(list.rightPan, 'emit').and.callThrough(); + + const itemNativeElements = fixture.debugElement.queryAll(By.css('igx-list-item')); + const listItems = list.items; + + /* Pan item right */ + panItem(itemNativeElements[0], 0.6); + expect(listItems[0].panState).toBe(IgxListPanState.RIGHT); + + /* Pan item left */ + panItem(itemNativeElements[1], -0.6); + expect(listItems[1].panState).toBe(IgxListPanState.NONE); + + expect(list.leftPan.emit).toHaveBeenCalledTimes(0); + expect(list.panStateChange.emit).toHaveBeenCalledTimes(1); + expect(list.rightPan.emit).toHaveBeenCalledTimes(1); + }); + + it('should pan left only.', () => { + const fixture = TestBed.createComponent(ListWithPanningComponent); + fixture.componentInstance.allowRightPanning = false; + fixture.detectChanges(); + + const list: IgxListComponent = fixture.componentInstance.list; + + spyOn(list.leftPan, 'emit').and.callThrough(); + spyOn(list.panStateChange, 'emit').and.callThrough(); + spyOn(list.rightPan, 'emit').and.callThrough(); + + const itemNativeElements = fixture.debugElement.queryAll(By.css('igx-list-item')); + const listItems = list.items; + + /* Pan item left */ + panItem(itemNativeElements[0], -0.6); + expect(listItems[0].panState).toBe(IgxListPanState.LEFT); + + /* Pan item right */ + panItem(itemNativeElements[1], 0.6); + expect(listItems[1].panState).toBe(IgxListPanState.NONE); + + expect(list.leftPan.emit).toHaveBeenCalledTimes(1); + expect(list.panStateChange.emit).toHaveBeenCalledTimes(1); + expect(list.rightPan.emit).toHaveBeenCalledTimes(0); + }); + + it('Should have default no items template.', () => { + const fixture = TestBed.createComponent(EmptyListComponent); + const list = fixture.componentInstance.list; + const listNoItemsMessage = 'There are no items in the list.'; + + fixture.detectChanges(); + + verifyItemsCount(list, 0); + expect(list.cssClass).toBeFalsy(); + expect(list.isListEmpty).toBeTruthy(); + + const noItemsMessage = fixture.debugElement.query(By.css('.igx-list__message')); + expect(noItemsMessage.nativeElement.textContent.trim()).toBe(listNoItemsMessage); + }); + + it('Should have custom no items template.', () => { + const fixture = TestBed.createComponent(CustomEmptyListComponent); + const list = fixture.componentInstance.list; + const listCustomNoItemsTemplateContent = 'Custom no items message.'; + + fixture.detectChanges(); + + verifyItemsCount(list, 0); + expect(list.cssClass).toBeFalsy(); + expect(list.isListEmpty).toBeTruthy(); + + const noItemsParagraphEl = fixture.debugElement.query(By.css('h3')); + expect(noItemsParagraphEl.nativeElement.textContent.trim()).toBe(listCustomNoItemsTemplateContent); + }); + + it('Should have default loading template.', () => { + const fixture = TestBed.createComponent(ListLoadingComponent); + const list = fixture.componentInstance.list; + const listLoadingItemsMessage = 'Loading data from the server...'; + + fixture.detectChanges(); + + verifyItemsCount(list, 0); + expect(list.cssClass).toBeFalsy(); + expect(list.isListEmpty).toBeTruthy(); + + const noItemsMessage = fixture.debugElement.query(By.css('.igx-list__message')); + expect(noItemsMessage.nativeElement.textContent.trim()).toBe(listLoadingItemsMessage); + }); + + it('Should show loading template when isLoading=\'true\' even when there are children.', () => { + const fixture = TestBed.createComponent(ListWithHeaderComponent); + const list = fixture.componentInstance.list; + list.isLoading = true; + const listLoadingItemsMessage = 'Loading data from the server...'; + + fixture.detectChanges(); + + verifyItemsCount(list, 3); + + const noItemsMessage = fixture.debugElement.query(By.css('.igx-list__message')); + expect(noItemsMessage.nativeElement.textContent.trim()).toBe(listLoadingItemsMessage); + + list.isLoading = false; + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.css('p'))).toBeFalsy(); + }); + + it('Should have custom loading template.', () => { + const fixture = TestBed.createComponent(ListCustomLoadingComponent); + const list = fixture.componentInstance.list; + const listLoadingItemsMessage = 'Loading data...'; + + fixture.detectChanges(); + + verifyItemsCount(list, 0); + expect(list.cssClass).toBeFalsy(); + expect(list.isListEmpty).toBeTruthy(); + + const noItemsParagraphEl = fixture.debugElement.query(By.css('h3')); + expect(noItemsParagraphEl.nativeElement.textContent.trim()).toBe(listLoadingItemsMessage); + }); + + it('should fire ItemClicked on click.', () => { + const fixture = TestBed.createComponent(ListWithHeaderComponent); + const list: IgxListComponent = fixture.componentInstance.list; + fixture.detectChanges(); + + spyOn(list.itemClicked, 'emit').and.callThrough(); + + const event = new Event('click'); + list.items[0].element.dispatchEvent(event); + const args: IListItemClickEventArgs = { + item: list.items[0], + event: event, + direction: IgxListPanState.NONE + }; + expect(list.itemClicked.emit).toHaveBeenCalledOnceWith(args); + + // Click the same item again and verify click is fired again + list.items[0].element.dispatchEvent(event); + + expect(list.itemClicked.emit).toHaveBeenCalledTimes(2); + expect(list.itemClicked.emit).toHaveBeenCalledWith(args); + + list.headers[0].element.dispatchEvent(event); + + expect(list.itemClicked.emit).toHaveBeenCalledTimes(3); + expect(list.itemClicked.emit).toHaveBeenCalledWith(args); + }); + + it('should emit ItemClicked with correct direction argument when swiping left', () => { + const fixture = TestBed.createComponent(ListWithPanningTemplatesComponent); + const list = fixture.componentInstance.list; + + spyOn(list.itemClicked, 'emit').and.callThrough(); + + fixture.detectChanges(); + const itemNativeElements = fixture.debugElement.queryAll(By.css('igx-list-item')); + panItemWithClick(itemNativeElements[1], -0.3); // operating over the second list item because the first one is a header + + const args: IListItemClickEventArgs = { + item: list.items[0], + event: null, + direction: IgxListPanState.LEFT + }; + expect(list.itemClicked.emit).toHaveBeenCalledOnceWith(args); + }); + + it('should emit ItemClicked with correct direction argument when swiping right', () => { + const fixture = TestBed.createComponent(ListWithPanningTemplatesComponent); + const list = fixture.componentInstance.list; + + spyOn(list.itemClicked, 'emit').and.callThrough(); + + fixture.detectChanges(); + const itemNativeElements = fixture.debugElement.queryAll(By.css('igx-list-item')); + panItemWithClick(itemNativeElements[1], 0.3); // operating over the second list item because the first one is a header + + const args: IListItemClickEventArgs = { + item: list.items[0], + event: null, + direction: IgxListPanState.RIGHT + }; + expect(list.itemClicked.emit).toHaveBeenCalledOnceWith(args); + }); + + it('should display multiple headers properly.', () => { + const fixture = TestBed.createComponent(TwoHeadersListComponent); + const list = fixture.componentInstance.list; + + fixture.detectChanges(); + + verifyItemsCount(list, 3); + verifyHeadersCount(list, 2); + + const headerClasses = fixture.debugElement.queryAll(By.css('.igx-list__header')); + expect(headerClasses.length).toBe(2); + }); + + it('should set items\' isHeader property properly.', () => { + const fixture = TestBed.createComponent(TwoHeadersListComponent); + const list = fixture.componentInstance.list; + + fixture.detectChanges(); + + const childrenArray = list.children.toArray(); + expect(childrenArray[0].isHeader).toBe(true); + expect(childrenArray[1].isHeader).toBe(false); + expect(childrenArray[2].isHeader).toBe(true); + expect(childrenArray[3].isHeader).toBeFalsy(); + expect(childrenArray[4].isHeader).toBeFalsy(); + }); + + it('should set items\' role property properly.', () => { + const fixture = TestBed.createComponent(TwoHeadersListComponent); + const list = fixture.componentInstance.list; + + fixture.detectChanges(); + + const childrenArray = list.children.toArray(); + expect(childrenArray[0].role).toBe('separator'); + expect(childrenArray[1].role).toBe('listitem'); + expect(childrenArray[2].role).toBe('separator'); + expect(childrenArray[3].role).toBe('listitem'); + expect(childrenArray[4].role).toBe('listitem'); + }); + + it('should hide items when hidden is true.', () => { + const fixture = TestBed.createComponent(TwoHeadersListComponent); + const list = fixture.componentInstance.list; + + fixture.detectChanges(); + + const hiddenItems = list.items.filter((item) => item.hidden === true); + expect(hiddenItems.length).toBe(1); + + const hiddenTags = list.children.filter((item) => item.element.style.display === 'none'); + expect(hiddenTags.length).toBe(1); + }); + + it('should not pan when panning is not allowed.', () => { + const fixture = TestBed.createComponent(TwoHeadersListNoPanningComponent); + const list: IgxListComponent = fixture.componentInstance.list; + let elementRefCollection; + + fixture.detectChanges(); + + const item = list.items[0] as IgxListItemComponent; + + spyOn(list.leftPan, 'emit').and.callThrough(); + spyOn(list.rightPan, 'emit').and.callThrough(); + spyOn(list.panStateChange, 'emit').and.callThrough(); + + elementRefCollection = fixture.debugElement.queryAll(By.css('igx-list-item')); + panItem(elementRefCollection[1], 0.8); + + expect(item.panState).toBe(IgxListPanState.NONE); + + elementRefCollection = fixture.debugElement.queryAll(By.css('igx-list-item')); + panItem(elementRefCollection[1], -0.8); + + expect(item.panState).toBe(IgxListPanState.NONE); + expect(list.leftPan.emit).toHaveBeenCalledTimes(0); + expect(list.rightPan.emit).toHaveBeenCalledTimes(0); + expect(list.panStateChange.emit).toHaveBeenCalledTimes(0); + }); + + it('checking the panLeftTemplate is visible when left-panning a list item.', () => { + const fixture = TestBed.createComponent(ListWithPanningTemplatesComponent); + const list = fixture.componentInstance.list; + fixture.detectChanges(); + + const firstItem = list.items[0] as IgxListItemComponent; + const leftPanTmpl = firstItem.leftPanningTemplateElement; + const rightPanTmpl = firstItem.rightPanningTemplateElement; + const itemNativeElements = fixture.debugElement.queryAll(By.css('igx-list-item')); + + /* Click and drag item left */ + clickAndDrag(itemNativeElements[1], -0.3); + expect(leftPanTmpl.nativeElement.style.visibility).toBe('visible'); + expect(rightPanTmpl.nativeElement.style.visibility).toBe('hidden'); + }); + + it('checking the panRightTemplate is visible when right-panning a list item.', () => { + const fixture = TestBed.createComponent(ListWithPanningTemplatesComponent); + const list = fixture.componentInstance.list; + fixture.detectChanges(); + + const firstItem = list.items[0] as IgxListItemComponent; + const leftPanTmpl = firstItem.leftPanningTemplateElement; + const rightPanTmpl = firstItem.rightPanningTemplateElement; + const itemNativeElements = fixture.debugElement.queryAll(By.css('igx-list-item')); + + /* Click and drag item right */ + clickAndDrag(itemNativeElements[1], 0.3); + expect(leftPanTmpl.nativeElement.style.visibility).toBe('hidden'); + expect(rightPanTmpl.nativeElement.style.visibility).toBe('visible'); + }); + + it('should emit resetPan when releasing a list item before end threshold is triggered', () => { + const fixture = TestBed.createComponent(ListWithPanningTemplatesComponent); + const list = fixture.componentInstance.list; + fixture.detectChanges(); + + const itemNativeElements = fixture.debugElement.queryAll(By.css('igx-list-item')); + + spyOn(list.startPan, 'emit').and.callThrough(); + spyOn(list.endPan, 'emit').and.callThrough(); + spyOn(list.resetPan, 'emit').and.callThrough(); + + /* Pan item left */ + panItem(itemNativeElements[1], -0.3); + + expect(list.startPan.emit).toHaveBeenCalledTimes(1); + expect(list.endPan.emit).toHaveBeenCalledTimes(1); + expect(list.resetPan.emit).toHaveBeenCalledTimes(1); + }); + + it('checking the panLeftTemplate is not visible when releasing a list item.', fakeAsync(() => { + const fixture = TestBed.createComponent(ListWithPanningTemplatesComponent); + const list = fixture.componentInstance.list; + fixture.detectChanges(); + + const firstItem = list.items[0] as IgxListItemComponent; + const leftPanTmpl = firstItem.leftPanningTemplateElement; + const rightPanTmpl = firstItem.rightPanningTemplateElement; + const itemNativeElements = fixture.debugElement.queryAll(By.css('igx-list-item')); + + /* Pan item left */ + panItem(itemNativeElements[1], -0.3); + tick(600); + expect(leftPanTmpl.nativeElement.style.visibility).toBe('hidden'); + expect(rightPanTmpl.nativeElement.style.visibility).toBe('hidden'); + })); + + it('checking the panRightTemplate is not visible when releasing a list item.', fakeAsync(() => { + const fixture = TestBed.createComponent(ListWithPanningTemplatesComponent); + const list = fixture.componentInstance.list; + fixture.detectChanges(); + + const firstItem = list.items[0] as IgxListItemComponent; + const leftPanTmpl = firstItem.leftPanningTemplateElement; + const rightPanTmpl = firstItem.rightPanningTemplateElement; + const itemNativeElements = fixture.debugElement.queryAll(By.css('igx-list-item')); + + /* Pan item right */ + panItem(itemNativeElements[1], 0.3); + tick(600); + expect(leftPanTmpl.nativeElement.style.visibility).toBe('hidden'); + expect(rightPanTmpl.nativeElement.style.visibility).toBe('hidden'); + })); + + it('cancel left panning', fakeAsync(() => { + const fixture = TestBed.createComponent(ListWithPanningTemplatesComponent); + const list = fixture.componentInstance.list; + fixture.detectChanges(); + + spyOn(list.startPan, 'emit').and.callThrough(); + spyOn(list.endPan, 'emit').and.callThrough(); + + const firstItem = list.items[0] as IgxListItemComponent; + const leftPanTmpl = firstItem.leftPanningTemplateElement; + const rightPanTmpl = firstItem.rightPanningTemplateElement; + const itemNativeElements = fixture.debugElement.queryAll(By.css('igx-list-item')); + + /* Pan item left */ + cancelItemPanning(itemNativeElements[1], -2, -8); + tick(600); + + expect(firstItem.panState).toBe(IgxListPanState.NONE); + expect(leftPanTmpl.nativeElement.style.visibility).toBe('hidden'); + expect(rightPanTmpl.nativeElement.style.visibility).toBe('hidden'); + expect(list.startPan.emit).toHaveBeenCalledTimes(1); + expect(list.endPan.emit).toHaveBeenCalledTimes(1); + })); + + it('cancel right panning', fakeAsync(() => { + const fixture = TestBed.createComponent(ListWithPanningTemplatesComponent); + const list = fixture.componentInstance.list; + fixture.detectChanges(); + + spyOn(list.startPan, 'emit').and.callThrough(); + spyOn(list.endPan, 'emit').and.callThrough(); + + const firstItem = list.items[0] as IgxListItemComponent; + const leftPanTmpl = firstItem.leftPanningTemplateElement; + const rightPanTmpl = firstItem.rightPanningTemplateElement; + const itemNativeElements = fixture.debugElement.queryAll(By.css('igx-list-item')); + + /* Pan item right */ + cancelItemPanning(itemNativeElements[1], 2, 8); + tick(600); + + expect(firstItem.panState).toBe(IgxListPanState.NONE); + expect(leftPanTmpl.nativeElement.style.visibility).toBe('hidden'); + expect(rightPanTmpl.nativeElement.style.visibility).toBe('hidden'); + expect(list.startPan.emit).toHaveBeenCalledTimes(1); + expect(list.endPan.emit).toHaveBeenCalledTimes(1); + })); + + it('checking the header list item does not have panning and content containers.', () => { + const fixture = TestBed.createComponent(ListWithPanningTemplatesComponent); + const list = fixture.componentInstance.list; + fixture.detectChanges(); + + const headers = list.headers; + for (const header of headers) { + expect(header.leftPanningTemplateElement).toBeUndefined(); + expect(header.rightPanningTemplateElement).toBeUndefined(); + expect(header.contentElement).toBe(null); + } + }); + + it('checking the list item is returning back in the list when canceling the pan left event', () => { + const fixture = TestBed.createComponent(ListWithPanningTemplatesComponent); + const list = fixture.componentInstance.list; + fixture.detectChanges(); + + list.leftPan.subscribe((args) => { + args.keepItem = true; + }); + + const firstItem = list.items[0] as IgxListItemComponent; + const itemNativeElements = fixture.debugElement.queryAll(By.css('igx-list-item')); + panItem(itemNativeElements[1], -0.6); + expect(firstItem.panState).toBe(IgxListPanState.NONE); + + unsubscribeEvents(list); + }); + + it('checking the list item is returning back in the list when canceling the pan right event', () => { + const fixture = TestBed.createComponent(ListWithPanningTemplatesComponent); + const list = fixture.componentInstance.list; + fixture.detectChanges(); + + list.rightPan.subscribe((args) => { + args.keepItem = true; + }); + + const firstItem = list.items[0] as IgxListItemComponent; + const itemNativeElements = fixture.debugElement.queryAll(By.css('igx-list-item')); + panItem(itemNativeElements[1], 0.6); + expect(firstItem.panState).toBe(IgxListPanState.NONE); + + unsubscribeEvents(list); + }); + + it('should allow setting the index of list items', (async () => { + const fixture = TestBed.createComponent(ListWithIgxForAndScrollingComponent); + fixture.detectChanges(); + await wait(50); + + fixture.componentInstance.igxFor.scrollTo(8); + await wait(50); + fixture.detectChanges(); + + const items = fixture.debugElement.queryAll(By.css('igx-list-item')); + const len = items.length; + expect(items[0].nativeElement.textContent).toContain('4'); + expect(fixture.componentInstance.forOfList.items[0].index).toEqual(3); + expect(items[len - 1].nativeElement.textContent).toContain('10'); + expect(fixture.componentInstance.forOfList.items[len - 1].index).toEqual(9); + })); + + it('should return items as they appear in the list with virtualization', (async () => { + const fixture = TestBed.createComponent(ListWithIgxForAndScrollingComponent); + fixture.detectChanges(); + await wait(50); + + fixture.componentInstance.igxFor.scrollTo(6); + await wait(50); + fixture.detectChanges(); + + const dItems = GridFunctions.sortDebugElementsVertically(fixture.debugElement.queryAll(By.css('igx-list-item'))); + const pItems = fixture.componentInstance.forOfList.items; + const len = dItems.length; + for (let i = 0; i < len; i++) { + expect(dItems[i].nativeElement).toEqual(pItems[i].element); + } + })); + + it('should properly set and get the selected property of list items', () => { + const fixture = TestBed.createComponent(ListWithSelectedItemComponent); + const list = fixture.componentInstance.list; + fixture.detectChanges(); + + // Get all list items + const items = list.children.toArray(); + const headerItem = items[0]; + const firstItem = items[1]; + const secondItem = items[2]; + + // Verify initial selected state + expect(headerItem.selected).toBe(false); // Headers should never be selected even if selected=true + expect(firstItem.selected).toBe(true); + expect(secondItem.selected).toBe(false); + + // Check if the selected class is applied correctly + expect(headerItem.element.classList.contains('igx-list__item-base--selected')).toBe(false); + expect(firstItem.element.classList.contains('igx-list__item-base--selected')).toBe(true); + expect(secondItem.element.classList.contains('igx-list__item-base--selected')).toBe(false); + + // Change selected state programmatically + secondItem.selected = true; + fixture.detectChanges(); + expect(secondItem.selected).toBe(true); + expect(secondItem.element.classList.contains('igx-list__item-base--selected')).toBe(true); + + // Try to select a header item (should not apply) + headerItem.selected = true; + fixture.detectChanges(); + expect(headerItem.selected).toBe(false); + expect(headerItem.element.classList.contains('igx-list__item-base--selected')).toBe(false); + }); + + it('Initializes igxListThumbnail directive', () => { + const fixture = TestBed.createComponent(ListDirectivesComponent); + fixture.detectChanges(); + const thumbnail = fixture.debugElement.query(By.directive(IgxListThumbnailDirective)); + + expect(thumbnail).toBeDefined(); + // Check if the directive removes the classes from the target element + expect(thumbnail.nativeElement).toHaveClass('igx-icon'); + // Check if the directive wraps the target element and sets the correct class on the parent element + expect(thumbnail.parent.nativeElement).toHaveClass('igx-list__item-thumbnail'); + }); + + it('Initializes igxListLine directive', () => { + const fixture = TestBed.createComponent(ListDirectivesComponent); + fixture.detectChanges(); + const listLine = fixture.debugElement.query(By.directive(IgxListLineDirective)); + + expect(listLine).toBeDefined(); + // Check if the directive removes the classes from the target element + expect(listLine.nativeElement).toHaveClass('text-line'); + // Check if the directive wraps the target element and sets the correct class on the parent element + expect(listLine.parent.nativeElement).toHaveClass('igx-list__item-lines'); + }); + + it('Initializes igxListAction directive', () => { + const fixture = TestBed.createComponent(ListDirectivesComponent); + fixture.detectChanges(); + const listLine = fixture.debugElement.query(By.directive(IgxListActionDirective)); + + expect(listLine).toBeDefined(); + // Check if the directive removes the classes from the target element + expect(listLine.nativeElement).toHaveClass('action-icon'); + // Check if the directive wraps the target element and sets the correct class on the parent element + expect(listLine.parent.nativeElement).toHaveClass('igx-list__item-actions'); + }); + + it('Initializes igxListLineTitle directive', () => { + const fixture = TestBed.createComponent(ListDirectivesComponent); + fixture.detectChanges(); + const listLine = fixture.debugElement.query(By.directive(IgxListLineTitleDirective)); + + expect(listLine).toBeDefined(); + // Check if the directive removes the custom classes from the target element + expect(listLine.nativeElement).toHaveClass('custom'); + // Check if the directive add the correct class on the target element + expect(listLine.nativeElement).toHaveClass('igx-list__item-line-title'); + // Check if the directive wraps the target element and sets the correct class on the parent element + expect(listLine.parent.nativeElement).toHaveClass('igx-list__item-lines'); + }); + + it('Initializes igxListLineSubTitle directive', () => { + const fixture = TestBed.createComponent(ListDirectivesComponent); + fixture.detectChanges(); + const listLine = fixture.debugElement.query(By.directive(IgxListLineSubTitleDirective)); + + expect(listLine).toBeDefined(); + // Check if the directive removes the custom classes from the target element + expect(listLine.nativeElement).toHaveClass('custom'); + // Check if the directive add the correct class on the target element + expect(listLine.nativeElement).toHaveClass('igx-list__item-line-subtitle'); + // Check if the directive wraps the target element and sets the correct class on the parent element + expect(listLine.parent.nativeElement).toHaveClass('igx-list__item-lines'); + }); + + /* factorX - the coefficient used to calculate deltaX. + Pan left by providing negative factorX; + Pan right - positive factorX. */ + const panItem = (elementRefObject, factorX) => { + const itemWidth = elementRefObject.nativeElement.offsetWidth; + + elementRefObject.triggerEventHandler('panstart', { + deltaX: factorX < 0 ? -10 : 10 + }); + elementRefObject.triggerEventHandler('panmove', { + deltaX: factorX * itemWidth, duration: 200 + }); + elementRefObject.triggerEventHandler('panend', null); + return new Promise(resolve => { + resolve(); + }); + }; + + const panItemWithClick = (elementRefObject, factorX) => { + panItem(elementRefObject, factorX); + elementRefObject.triggerEventHandler('click', null); + }; + + const clickAndDrag = (itemNativeElement, factorX) => { + const itemWidth = itemNativeElement.nativeElement.offsetWidth; + + itemNativeElement.triggerEventHandler('panstart', { + deltaX: factorX < 0 ? -10 : 10 + }); + itemNativeElement.triggerEventHandler('panmove', { + deltaX: factorX * itemWidth, duration: 200 + }); + }; + + const cancelItemPanning = (itemNativeElement, factorX, factorY) => { + itemNativeElement.triggerEventHandler('panstart', { + deltaX: factorX + }); + itemNativeElement.triggerEventHandler('panmove', { + deltaX: factorX, + deltaY: factorY, + additionalEvent: 'panup' + }); + + itemNativeElement.triggerEventHandler('pancancel', null); + }; + + const verifyItemsCount = (list, expectedCount) => { + expect(list.items instanceof Array).toBeTruthy(); + expect(list.items.length).toBe(expectedCount); + }; + + const verifyHeadersCount = (list, expectedCount) => { + expect(list.headers instanceof Array).toBeTruthy(); + expect(list.headers.length).toBe(expectedCount); + }; + + const unsubscribeEvents = list => { + list.leftPan.unsubscribe(); + list.panStateChange.unsubscribe(); + list.rightPan.unsubscribe(); + list.itemClicked.unsubscribe(); + list.startPan.unsubscribe(); + list.resetPan.unsubscribe(); + list.endPan.unsubscribe(); + }; +}); diff --git a/projects/igniteui-angular/list/src/list/list.component.ts b/projects/igniteui-angular/list/src/list/list.component.ts new file mode 100644 index 00000000000..20a36bb0631 --- /dev/null +++ b/projects/igniteui-angular/list/src/list/list.component.ts @@ -0,0 +1,582 @@ +import { NgTemplateOutlet } from '@angular/common'; +import { Component, ContentChild, ContentChildren, ElementRef, EventEmitter, forwardRef, HostBinding, Input, Output, QueryList, TemplateRef, ViewChild, Directive, booleanAttribute, inject } from '@angular/core'; + + + +import { IgxListItemComponent } from './list-item.component'; +import { + IgxListBaseDirective, + IgxDataLoadingTemplateDirective, + IgxEmptyListTemplateDirective, + IgxListPanState, + IgxListItemLeftPanningTemplateDirective, + IgxListItemRightPanningTemplateDirective +} from './list.common'; +import { IBaseEventArgs } from 'igniteui-angular/core'; +import { IListResourceStrings, ListResourceStringsEN } from 'igniteui-angular/core'; +import { getCurrentResourceStrings } from 'igniteui-angular/core'; + +let NEXT_ID = 0; + +/** + * Interface for the panStateChange igxList event arguments + */ +export interface IPanStateChangeEventArgs extends IBaseEventArgs { + oldState: IgxListPanState; + newState: IgxListPanState; + item: IgxListItemComponent; +} + +/** + * Interface for the listItemClick igxList event arguments + */ +export interface IListItemClickEventArgs extends IBaseEventArgs { + item: IgxListItemComponent; + event: Event; + direction: IgxListPanState; +} + +/** + * Interface for the listItemPanning igxList event arguments + */ +export interface IListItemPanningEventArgs extends IBaseEventArgs { + item: IgxListItemComponent; + direction: IgxListPanState; + keepItem: boolean; +} + +/** + * igxListThumbnail is container for the List media + * Use it to wrap anything you want to be used as a thumbnail. + */ +@Directive({ + selector: '[igxListThumbnail]', + standalone: true +}) +export class IgxListThumbnailDirective { } + +/** + * igxListAction is container for the List action + * Use it to wrap anything you want to be used as a list action: icon, checkbox... + */ +@Directive({ + selector: '[igxListAction]', + standalone: true +}) +export class IgxListActionDirective { } + +/** + * igxListLine is container for the List text content + * Use it to wrap anything you want to be used as a plane text. + */ +@Directive({ + selector: '[igxListLine]', + standalone: true +}) +export class IgxListLineDirective { } + +/** + * igxListLineTitle is a directive that add class to the target element + * Use it to make anything to look like list Title. + */ +@Directive({ + selector: '[igxListLineTitle]', + standalone: true +}) +export class IgxListLineTitleDirective { + @HostBinding('class.igx-list__item-line-title') + public cssClass = 'igx-list__item-line-title'; +} + +/** + * igxListLineSubTitle is a directive that add class to the target element + * Use it to make anything to look like list Subtitle. + */ +@Directive({ + selector: '[igxListLineSubTitle]', + standalone: true +}) +export class IgxListLineSubTitleDirective { + @HostBinding('class.igx-list__item-line-subtitle') + public cssClass = 'igx-list__item-line-subtitle'; +} + +/** + * Displays a collection of data items in a templatable list format + * + * @igxModule IgxListModule + * + * @igxTheme igx-list-theme + * + * @igxKeywords list, data + * + * @igxGroup Grids & Lists + * + * @remarks + * The Ignite UI List displays rows of items and supports one or more header items as well as search and filtering + * of list items. Each list item is completely templatable and will support any valid HTML or Angular component. + * + * @example + * ```html + * + * Contacts + * + * {{ contact.name }} + * {{ contact.phone }} + * + * + * ``` + */ +@Component({ + selector: 'igx-list', + templateUrl: 'list.component.html', + providers: [{ provide: IgxListBaseDirective, useExisting: IgxListComponent }], + imports: [NgTemplateOutlet] +}) +export class IgxListComponent extends IgxListBaseDirective { + public element = inject(ElementRef); + + /** + * Returns a collection of all items and headers in the list. + * + * @example + * ```typescript + * let listChildren: QueryList = this.list.children; + * ``` + */ + @ContentChildren(forwardRef(() => IgxListItemComponent), { descendants: true }) + public override children: QueryList; + + /** + * Sets/gets the empty list template. + * + * @remarks + * This template is used by IgxList in case there are no list items + * defined and `isLoading` is set to `false`. + * + * @example + * ```html + * + * + *

    No contacts! :(

    + *
    + *
    + * ``` + * ```typescript + * let emptyTemplate = this.list.emptyListTemplate; + * ``` + */ + @ContentChild(IgxEmptyListTemplateDirective, { read: IgxEmptyListTemplateDirective }) + public emptyListTemplate: IgxEmptyListTemplateDirective; + + /** + * Sets/gets the list loading template. + * + * @remarks + * This template is used by IgxList in case there are no list items defined and `isLoading` is set to `true`. + * + * @example + * ```html + * + * + *

    Patience, we are currently loading your data...

    + *
    + *
    + * ``` + * ```typescript + * let loadingTemplate = this.list.dataLoadingTemplate; + * ``` + */ + @ContentChild(IgxDataLoadingTemplateDirective, { read: IgxDataLoadingTemplateDirective }) + public dataLoadingTemplate: IgxDataLoadingTemplateDirective; + + /** + * Sets/gets the template for left panning a list item. + * + * @remarks + * Default value is `null`. + * + * @example + * ```html + * + * + * deleteDelete + * + * + * ``` + * ```typescript + * let itemLeftPanTmpl = this.list.listItemLeftPanningTemplate; + * ``` + */ + @ContentChild(IgxListItemLeftPanningTemplateDirective, { read: IgxListItemLeftPanningTemplateDirective }) + public override listItemLeftPanningTemplate: IgxListItemLeftPanningTemplateDirective; + + /** + * Sets/gets the template for right panning a list item. + * + * @remarks + * Default value is `null`. + * + * @example + * ```html + * + * + * callDial + * + * + * ``` + * ```typescript + * let itemRightPanTmpl = this.list.listItemRightPanningTemplate; + * ``` + */ + @ContentChild(IgxListItemRightPanningTemplateDirective, { read: IgxListItemRightPanningTemplateDirective }) + public override listItemRightPanningTemplate: IgxListItemRightPanningTemplateDirective; + + /** + * Provides a threshold after which the item's panning will be completed automatically. + * + * @remarks + * By default this property is set to 0.5 which is 50% of the list item's width. + * + * @example + * ```html + * + * ``` + */ + @Input() + public override panEndTriggeringThreshold = 0.5; + + /** + * Sets/gets the `id` of the list. + * + * @remarks + * If not set, the `id` of the first list component will be `"igx-list-0"`. + * + * @example + * ```html + * + * ``` + * ```typescript + * let listId = this.list.id; + * ``` + */ + @HostBinding('attr.id') + @Input() + public id = `igx-list-${NEXT_ID++}`; + + /** + * Sets/gets whether the left panning of an item is allowed. + * + * @remarks + * Default value is `false`. + * + * @example + * ```html + * + * ``` + * ```typescript + * let isLeftPanningAllowed = this.list.allowLeftPanning; + * ``` + */ + @Input({ transform: booleanAttribute }) + public override allowLeftPanning = false; + + /** + * Sets/gets whether the right panning of an item is allowed. + * + * @remarks + * Default value is `false`. + * + * @example + * ```html + * + * ``` + * ```typescript + * let isRightPanningAllowed = this.list.allowRightPanning; + * ``` + */ + @Input({ transform: booleanAttribute }) + public override allowRightPanning = false; + + /** + * Sets/gets whether the list is currently loading data. + * + * @remarks + * Set it to display the dataLoadingTemplate while data is being retrieved. + * Default value is `false`. + * + * @example + * ```html + * + * ``` + * ```typescript + * let isLoading = this.list.isLoading; + * ``` + */ + @Input({ transform: booleanAttribute }) + public isLoading = false; + + /** + * Event emitted when a left pan gesture is executed on a list item. + * + * @remarks + * Provides a reference to an object of type `IListItemPanningEventArgs` as an event argument. + * + * @example + * ```html + * + * ``` + */ + @Output() + public override leftPan = new EventEmitter(); + + /** + * Event emitted when a right pan gesture is executed on a list item. + * + * @remarks + * Provides a reference to an object of type `IListItemPanningEventArgs` as an event argument. + * + * @example + * ```html + * + * ``` + */ + @Output() + public override rightPan = new EventEmitter(); + + /** + * Event emitted when a pan gesture is started. + * + * @remarks + * Provides a reference to an object of type `IListItemPanningEventArgs` as an event argument. + * + * @example + * ```html + * + * ``` + */ + @Output() + public override startPan = new EventEmitter(); + + /** + * Event emitted when a pan gesture is completed or canceled. + * + * @remarks + * Provides a reference to an object of type `IListItemPanningEventArgs` as an event argument. + * + * @example + * ```html + * + * ``` + */ + @Output() + public override endPan = new EventEmitter(); + + /** + * Event emitted when a pan item is returned to its original position. + * + * @remarks + * Provides a reference to an object of type `IgxListComponent` as an event argument. + * + * @example + * ```html + * + * ``` + */ + @Output() + public override resetPan = new EventEmitter(); + + /** + * + * Event emitted when a pan gesture is executed on a list item. + * + * @remarks + * Provides references to the `IgxListItemComponent` and `IgxListPanState` as event arguments. + * + * @example + * ```html + * + * ``` + */ + @Output() + public override panStateChange = new EventEmitter(); + + /** + * Event emitted when a list item is clicked. + * + * @remarks + * Provides references to the `IgxListItemComponent` and `Event` as event arguments. + * + * @example + * ```html + * + * ``` + */ + @Output() + public override itemClicked = new EventEmitter(); + + /** + * @hidden + * @internal + */ + @ViewChild('defaultEmptyList', { read: TemplateRef, static: true }) + protected defaultEmptyListTemplate: TemplateRef; + + /** + * @hidden + * @internal + */ + @ViewChild('defaultDataLoading', { read: TemplateRef, static: true }) + protected defaultDataLoadingTemplate: TemplateRef; + + private _resourceStrings = getCurrentResourceStrings(ListResourceStringsEN); + + /** + * Sets the resource strings. + * By default it uses EN resources. + */ + @Input() + public set resourceStrings(value: IListResourceStrings) { + this._resourceStrings = Object.assign({}, this._resourceStrings, value); + } + + /** + * Returns the resource strings. + */ + public get resourceStrings(): IListResourceStrings { + return this._resourceStrings; + } + + /** + * @hidden + * @internal + */ + protected get sortedChildren(): IgxListItemComponent[] { + if (this.children !== undefined) { + return this.children.toArray() + .sort((a: IgxListItemComponent, b: IgxListItemComponent) => a.index - b.index); + } + return null; + } + + private _role = 'list'; + + /** + * Gets/Sets the `role` attribute value. + * + * @example + * ```typescript + * let listRole = this.list.role; + * ``` + */ + @HostBinding('attr.role') + @Input() + public get role() { + return this._role; + } + + public set role(val: string) { + this._role = val; + } + + /** + * Gets a boolean indicating if the list is empty. + * + * @example + * ```typescript + * let isEmpty = this.list.isListEmpty; + * ``` + */ + @HostBinding('class.igx-list--empty') + public get isListEmpty(): boolean { + return !this.children || this.children.length === 0; + } + + /** + * @hidden + * @internal + */ + @HostBinding('class.igx-list') + public get cssClass(): boolean { + return !this.isListEmpty; + } + + /** + * Gets the list `items` excluding the header ones. + * + * @example + * ```typescript + * let listItems: IgxListItemComponent[] = this.list.items; + * ``` + */ + public get items(): IgxListItemComponent[] { + const items: IgxListItemComponent[] = []; + if (this.children !== undefined) { + for (const child of this.sortedChildren) { + if (!child.isHeader) { + items.push(child); + } + } + } + return items; + } + + /** + * Gets the header list `items`. + * + * @example + * ```typescript + * let listHeaders: IgxListItemComponent[] = this.list.headers; + * ``` + */ + public get headers(): IgxListItemComponent[] { + const headers: IgxListItemComponent[] = []; + if (this.children !== undefined) { + for (const child of this.children.toArray()) { + if (child.isHeader) { + headers.push(child); + } + } + } + return headers; + } + + /** + * Gets the `context` object of the template binding. + * + * @remarks + * Gets the `context` object which represents the `template context` binding into the `list container` + * by providing the `$implicit` declaration which is the `IgxListComponent` itself. + * + * @example + * ```typescript + * let listComponent = this.list.context; + * ``` + */ + public get context(): any { + return { + $implicit: this + }; + } + + /** + * Gets a `TemplateRef` to the currently used template. + * + * @example + * ```typescript + * let listTemplate = this.list.template; + * ``` + */ + public get template(): TemplateRef { + if (this.isLoading) { + return this.dataLoadingTemplate ? this.dataLoadingTemplate.template : this.defaultDataLoadingTemplate; + } else { + return this.emptyListTemplate ? this.emptyListTemplate.template : this.defaultEmptyListTemplate; + } + } +} + +/** + * @hidden + */ + diff --git a/projects/igniteui-angular/list/src/list/list.module.ts b/projects/igniteui-angular/list/src/list/list.module.ts new file mode 100644 index 00000000000..e4b36c642ba --- /dev/null +++ b/projects/igniteui-angular/list/src/list/list.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { IGX_LIST_DIRECTIVES } from './public_api'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + ...IGX_LIST_DIRECTIVES + ], + exports: [ + ...IGX_LIST_DIRECTIVES + ] +}) + +export class IgxListModule {} diff --git a/projects/igniteui-angular/list/src/list/public_api.ts b/projects/igniteui-angular/list/src/list/public_api.ts new file mode 100644 index 00000000000..a36798c4315 --- /dev/null +++ b/projects/igniteui-angular/list/src/list/public_api.ts @@ -0,0 +1,29 @@ +import { IgxListItemComponent } from './list-item.component'; +import { IgxDataLoadingTemplateDirective, IgxEmptyListTemplateDirective, IgxListItemLeftPanningTemplateDirective, IgxListItemRightPanningTemplateDirective } from './list.common'; +import { IgxListActionDirective, IgxListComponent, IgxListLineDirective, IgxListLineSubTitleDirective, IgxListLineTitleDirective, IgxListThumbnailDirective } from './list.component'; + +export * from './list.component'; +export { + IgxListBaseDirective, + IgxListPanState, + IgxEmptyListTemplateDirective, + IgxDataLoadingTemplateDirective, + IgxListItemLeftPanningTemplateDirective, + IgxListItemRightPanningTemplateDirective +} from './list.common'; +export * from './list-item.component'; + +/* NOTE: List directives collection for ease-of-use import in standalone components scenario */ +export const IGX_LIST_DIRECTIVES = [ + IgxListComponent, + IgxListItemComponent, + IgxListThumbnailDirective, + IgxListActionDirective, + IgxListLineDirective, + IgxListLineTitleDirective, + IgxListLineSubTitleDirective, + IgxDataLoadingTemplateDirective, + IgxEmptyListTemplateDirective, + IgxListItemLeftPanningTemplateDirective, + IgxListItemRightPanningTemplateDirective +] as const; diff --git a/projects/igniteui-angular/list/src/public_api.ts b/projects/igniteui-angular/list/src/public_api.ts new file mode 100644 index 00000000000..70996d00090 --- /dev/null +++ b/projects/igniteui-angular/list/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './list/public_api'; +export * from './list/list.module'; diff --git a/projects/igniteui-angular/migrations/update-21_0_0/README.md b/projects/igniteui-angular/migrations/update-21_0_0/README.md new file mode 100644 index 00000000000..05baf45b75c --- /dev/null +++ b/projects/igniteui-angular/migrations/update-21_0_0/README.md @@ -0,0 +1,59 @@ +# Update to 21.0.0 + +## Migration to Multiple Entry Points + +This migration automatically updates your imports from the main `igniteui-angular` package to the new entry point structure. + +### What Changed + +Ignite UI for Angular v21.0.0 introduces multiple entry points for better tree-shaking and code splitting. Instead of importing everything from `igniteui-angular`, you now import from specific entry points like `igniteui-angular/core`, `igniteui-angular/grids`, etc. + +### Breaking Changes + +#### 1. Entry Point Changes + +The following directives have been moved to new entry points: + +**Input Directives** → `igniteui-angular/input-group` + - `IgxInputDirective` + - `IgxLabelDirective` + - `IgxHintDirective` + - `IgxPrefixDirective` + - `IgxSuffixDirective` + +**Autocomplete** → `igniteui-angular/drop-down` + - `IgxAutocompleteDirective` + +**Radio Group** → `igniteui-angular/radio` + - `IgxRadioGroupDirective` + +#### 2. Type Renames + +The following types have been renamed to avoid conflicts: + +- `Direction` → `CarouselAnimationDirection` (carousel) + +### Example + +**Before:** +```typescript +import { + IgxGridComponent, + IgxInputDirective, + DisplayDensity, + Direction +} from 'igniteui-angular'; +``` + +**After:** +```typescript +import { DisplayDensity } from 'igniteui-angular/core'; +import { IgxGridComponent } from 'igniteui-angular/grids/grid'; +import { IgxInputDirective } from 'igniteui-angular/input-group'; +``` + +### Note + +The migration script will automatically update your imports and rename types. No manual changes are required. + +The main `igniteui-angular` package still exports everything for backwards compatibility, but using specific entry points is recommended for optimal bundle sizes. diff --git a/projects/igniteui-angular/migrations/update-21_0_0/changes/classes.json b/projects/igniteui-angular/migrations/update-21_0_0/changes/classes.json new file mode 100644 index 00000000000..551e1723dcc --- /dev/null +++ b/projects/igniteui-angular/migrations/update-21_0_0/changes/classes.json @@ -0,0 +1,13 @@ +{ + "$schema": "../../common/schema/class.schema.json", + "changes": [ + { + "name": "Direction", + "replaceWith": "CarouselAnimationDirection" + }, + { + "name": "IgxColumPatternValidatorDirective", + "replaceWith": "IgxColumnPatternValidatorDirective" + } + ] +} diff --git a/projects/igniteui-angular/migrations/update-21_0_0/changes/imports.json b/projects/igniteui-angular/migrations/update-21_0_0/changes/imports.json new file mode 100644 index 00000000000..7fd2774ab95 --- /dev/null +++ b/projects/igniteui-angular/migrations/update-21_0_0/changes/imports.json @@ -0,0 +1,4 @@ +{ + "$schema": "../common/schema/imports.schema.json", + "changes": [] +} diff --git a/projects/igniteui-angular/migrations/update-21_0_0/changes/theme-changes.json b/projects/igniteui-angular/migrations/update-21_0_0/changes/theme-changes.json new file mode 100644 index 00000000000..f2e2397bfea --- /dev/null +++ b/projects/igniteui-angular/migrations/update-21_0_0/changes/theme-changes.json @@ -0,0 +1,53 @@ +{ + "$schema": "../../common/schema/theme-changes.schema.json", + "changes": [ + { + "name": "$resting-shadow", + "remove": true, + "owner": "outlined-button-theme", + "type": "property" + }, + { + "name": "$hover-shadow", + "remove": true, + "owner": "outlined-button-theme", + "type": "property" + }, + { + "name": "$focus-shadow", + "remove": true, + "owner": "outlined-button-theme", + "type": "property" + }, + { + "name": "$active-shadow", + "remove": true, + "owner": "outlined-button-theme", + "type": "property" + }, + { + "name": "$resting-shadow", + "remove": true, + "owner": "flat-button-theme", + "type": "property" + }, + { + "name": "$hover-shadow", + "remove": true, + "owner": "flat-button-theme", + "type": "property" + }, + { + "name": "$focus-shadow", + "remove": true, + "owner": "flat-button-theme", + "type": "property" + }, + { + "name": "$active-shadow", + "remove": true, + "owner": "flat-button-theme", + "type": "property" + } + ] +} \ No newline at end of file diff --git a/projects/igniteui-angular/migrations/update-21_0_0/index.spec.ts b/projects/igniteui-angular/migrations/update-21_0_0/index.spec.ts new file mode 100644 index 00000000000..1fa8555f17a --- /dev/null +++ b/projects/igniteui-angular/migrations/update-21_0_0/index.spec.ts @@ -0,0 +1,63 @@ +import * as path from 'path'; + +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; +import { setupTestTree } from '../common/setup.spec'; + +const version = '21.0.0'; + +describe(`Update to ${version}`, () => { + let appTree: UnitTestTree; + const schematicRunner = new SchematicTestRunner('ig-migrate', path.join(__dirname, '../migration-collection.json')); + + beforeEach(() => { + appTree = setupTestTree(); + }); + + const migrationName = 'migration-50'; + + it('should remove properties related to box-shadows from the outlined-button theme', async () => { + const testFilePath = `/testSrc/appPrefix/component/test.component.scss`; + + appTree.create( + testFilePath, + `$my-outlined-button-theme: outlined-button-theme( + $shadow-color: #ffff00, + $resting-shadow: 5px 5px #ff0000, + $hover-shadow: 5px 5px #0000ff, + $focus-shadow: 5px 5px #008000, + $active-shadow: 5px 5px #ffa500 + );` + ); + + const tree = await schematicRunner.runSchematic(migrationName, {}, appTree); + + expect(tree.readContent(testFilePath)).toEqual( + `$my-outlined-button-theme: outlined-button-theme( + $shadow-color: #ffff00 + );` + ); + }); + + it('should remove properties related to box-shadows from the flat-button theme', async () => { + const testFilePath = `/testSrc/appPrefix/component/test.component.scss`; + + appTree.create( + testFilePath, + `$my-flat-button-theme: flat-button-theme( + $shadow-color: #ffff00, + $resting-shadow: 5px 5px #ff0000, + $hover-shadow: 5px 5px #0000ff, + $focus-shadow: 5px 5px #008000, + $active-shadow: 5px 5px #ffa500 + );` + ); + + const tree = await schematicRunner.runSchematic(migrationName, {}, appTree); + + expect(tree.readContent(testFilePath)).toEqual( + `$my-flat-button-theme: flat-button-theme( + $shadow-color: #ffff00 + );` + ); + }); +}); diff --git a/projects/igniteui-angular/migrations/update-21_0_0/index.ts b/projects/igniteui-angular/migrations/update-21_0_0/index.ts new file mode 100644 index 00000000000..19db0ecf7d2 --- /dev/null +++ b/projects/igniteui-angular/migrations/update-21_0_0/index.ts @@ -0,0 +1,23 @@ +import type { + Rule, + SchematicContext, + Tree +} from '@angular-devkit/schematics'; +import { UpdateChanges } from '../common/UpdateChanges'; + +const version = '21.0.0'; + +export default function migrate(): Rule { + return async (host: Tree, context: SchematicContext) => { + context.logger.info(`Applying migration for Ignite UI for Angular to version ${version}`); + + const update = new UpdateChanges(__dirname, host, context); + + context.logger.info('The library now supports granular entry points for better tree-shaking.'); + context.logger.info('You can continue using the main entry point (igniteui-angular), or'); + context.logger.info('migrate to granular entry points by running:'); + context.logger.info(' ng update igniteui-angular --migrate-only --from=20.1.0 --to=21.0.0 --name=migration-51'); + + update.applyChanges(); + }; +} diff --git a/projects/igniteui-angular/migrations/update-21_0_0_import-migration/index.ts b/projects/igniteui-angular/migrations/update-21_0_0_import-migration/index.ts new file mode 100644 index 00000000000..17798ef8aa0 --- /dev/null +++ b/projects/igniteui-angular/migrations/update-21_0_0_import-migration/index.ts @@ -0,0 +1,897 @@ +import type { + FileVisitor, + Rule, + SchematicContext, + Tree +} from '@angular-devkit/schematics'; +import * as ts from 'typescript'; +import { IG_PACKAGE_NAME, IG_LICENSED_PACKAGE_NAME, igNamedImportFilter } from '../common/tsUtils'; + +const version = '21.0.0'; + +// Comprehensive entry point mapping for ALL exports from all 42 entry points +const ENTRY_POINT_MAP = new Map([ + // Core - Services, Utilities, Types, Enums + // ['IgxOverlayService', 'core'], + // ['IgxNavigationService', 'core'], + // ['DisplayDensity', 'core'], + // ['DisplayDensityToken', 'core'], + // ['DisplayDensityBase', 'core'], + // ['IDisplayDensityOptions', 'core'], + // ['OverlaySettings', 'core'], + // ['PositionSettings', 'core'], + // ['ScrollStrategy', 'core'], + // ['GlobalPositionStrategy', 'core'], + // ['AutoPositionStrategy', 'core'], + // ['ConnectedPositioningStrategy', 'core'], + // ['ElasticPositionStrategy', 'core'], + // ['AbsoluteScrollStrategy', 'core'], + // ['BlockScrollStrategy', 'core'], + // ['CloseScrollStrategy', 'core'], + // ['NoOpScrollStrategy', 'core'], + // ['HorizontalAlignment', 'core'], + // ['VerticalAlignment', 'core'], + // ['PositionStrategy', 'core'], + // ['OverlayEventArgs', 'core'], + // ['OverlayCancelableEventArgs', 'core'], + // ['OverlayClosingEventArgs', 'core'], + // ['OverlayAnimationEventArgs', 'core'], + // ['Size', 'core'], + // ['OffsetMode', 'core'], + // ['ConnectedFit', 'core'], + // ['IFilteringExpressionsTree', 'core'], + // ['IFilteringExpression', 'core'], + // ['FilteringLogic', 'core'], + // ['IFilteringOperation', 'core'], + // ['ISortingExpression', 'core'], + // ['SortingDirection', 'core'], + // ['IGroupingExpression', 'core'], + // ['IGroupByExpandState', 'core'], + // ['IPagingState', 'core'], + // ['PagingError', 'core'], + // ['DataUtil', 'core'], + // ['DatePart', 'core'], + // ['DatePartInfo', 'core'], + // ['DatePickerUtil', 'core'], + // ['IBaseCancelableBrowserEventArgs', 'core'], + // ['IBaseCancelableEventArgs', 'core'], + // ['IBaseEventArgs', 'core'], + // ['ICancelableBrowserEventArgs', 'core'], + // ['ICancelableEventArgs', 'core'], + // ['PlatformUtil', 'core'], + // ['Transaction', 'core'], + // ['TransactionType', 'core'], + // ['IgxTransactionService', 'core'], + // ['State', 'core'], + + // Accordion + ['IgxAccordionComponent', 'accordion'], + ['IgxAccordionModule', 'accordion'], + ['IGX_ACCORDION_DIRECTIVES', 'accordion'], + ['IAccordionEventArgs', 'accordion'], + ['IAccordionCancelableEventArgs', 'accordion'], + + // Action Strip + ['IgxActionStripComponent', 'action-strip'], + ['IgxActionStripModule', 'action-strip'], + ['IGX_ACTION_STRIP_DIRECTIVES', 'action-strip'], + ['IgxActionStripMenuItemDirective', 'action-strip'], + + // Avatar + ['IgxAvatarComponent', 'avatar'], + ['IgxAvatarModule', 'avatar'], + ['AvatarType', 'avatar'], + ['IgxAvatarSize', 'avatar'], + ['IgxAvatarShape', 'avatar'], + + // Badge + ['IgxBadgeComponent', 'badge'], + ['IgxBadgeModule', 'badge'], + ['BadgeType', 'badge'], + ['IgxBadgeVariant', 'badge'], + + // Banner + ['IgxBannerComponent', 'banner'], + ['IgxBannerModule', 'banner'], + ['IGX_BANNER_DIRECTIVES', 'banner'], + ['IgxBannerActionsDirective', 'banner'], + ['IBannerEventArgs', 'banner'], + ['IBannerCancelEventArgs', 'banner'], + + // Bottom Nav + ['IgxBottomNavComponent', 'bottom-nav'], + ['IgxBottomNavModule', 'bottom-nav'], + ['IgxBottomNavItemComponent', 'bottom-nav'], + ['IgxBottomNavHeaderComponent', 'bottom-nav'], + ['IgxBottomNavContentComponent', 'bottom-nav'], + ['IgxBottomNavHeaderLabelDirective', 'bottom-nav'], + ['IgxBottomNavHeaderIconDirective', 'bottom-nav'], + ['IGX_BOTTOM_NAV_DIRECTIVES', 'bottom-nav'], + + // Button Group + ['IgxButtonGroupComponent', 'button-group'], + ['IgxButtonGroupModule', 'button-group'], + ['IGX_BUTTON_GROUP_DIRECTIVES', 'button-group'], + ['IgxButtonDirective', 'button-group'], + ['IgxIconButtonDirective', 'button-group'], + ['IButtonGroupEventArgs', 'button-group'], + ['ButtonGroupAlignment', 'button-group'], + + // Calendar + ['IgxCalendarComponent', 'calendar'], + ['IgxCalendarModule', 'calendar'], + ['IGX_CALENDAR_DIRECTIVES', 'calendar'], + ['IgxDaysViewComponent', 'calendar'], + ['IgxMonthsViewComponent', 'calendar'], + ['IgxYearsViewComponent', 'calendar'], + ['IgxMonthPickerComponent', 'calendar'], + ['CalendarSelection', 'calendar'], + ['ICalendarDate', 'calendar'], + ['ICalendarViewChangingEventArgs', 'calendar'], + ['WeekDays', 'calendar'], + ['IFormattingOptions', 'calendar'], + ['IgxCalendarView', 'calendar'], + ['IgxCalendarHeaderTemplateDirective', 'calendar'], + ['IgxCalendarSubheaderTemplateDirective', 'calendar'], + ['IViewDateChangeEventArgs', 'calendar'], + + // Card + ['IgxCardComponent', 'card'], + ['IgxCardModule', 'card'], + ['IGX_CARD_DIRECTIVES', 'card'], + ['IgxCardHeaderComponent', 'card'], + ['IgxCardMediaDirective', 'card'], + ['IgxCardContentDirective', 'card'], + ['IgxCardActionsComponent', 'card'], + ['IgxCardHeaderTitleDirective', 'card'], + ['IgxCardHeaderSubtitleDirective', 'card'], + ['IgxCardThumbnailDirective', 'card'], + ['IgxCardType', 'card'], + + // Carousel + ['IgxCarouselComponent', 'carousel'], + ['IgxCarouselModule', 'carousel'], + ['IGX_CAROUSEL_DIRECTIVES', 'carousel'], + ['IgxSlideComponent', 'carousel'], + ['CarouselAnimationDirection', 'carousel'], // Renamed from Direction + ['ISlideEventArgs', 'carousel'], + ['ISlideCarouselBaseEventArgs', 'carousel'], + ['CarouselAnimationType', 'carousel'], + ['CarouselIndicatorsOrientation', 'carousel'], + + // Checkbox + ['IgxCheckboxComponent', 'checkbox'], + ['IgxCheckboxModule', 'checkbox'], + ['IChangeCheckboxEventArgs', 'checkbox'], + ['LabelPosition', 'checkbox'], + + // Chips + ['IgxChipsComponent', 'chips'], + ['IgxChipsModule', 'chips'], + ['IGX_CHIPS_DIRECTIVES', 'chips'], + ['IgxChipComponent', 'chips'], + ['IgxChipsAreaComponent', 'chips'], + ['IBaseChipEventArgs', 'chips'], + ['IChipClickEventArgs', 'chips'], + ['IChipKeyDownEventArgs', 'chips'], + ['IChipEnterDragAreaEventArgs', 'chips'], + ['IChipSelectEventArgs', 'chips'], + ['IChipsAreaReorderEventArgs', 'chips'], + + // Combo + ['IgxComboComponent', 'combo'], + ['IgxComboModule', 'combo'], + ['IGX_COMBO_DIRECTIVES', 'combo'], + ['IComboSelectionChangingEventArgs', 'combo'], + ['IComboItemAdditionEvent', 'combo'], + ['IComboSearchInputEventArgs', 'combo'], + ['IgxComboState', 'combo'], + ['IgxComboClearIconDirective', 'combo'], + ['IgxComboItemDirective', 'combo'], + ['IgxComboAddItemDirective', 'combo'], + ['IgxComboEmptyDirective', 'combo'], + ['IgxComboFooterDirective', 'combo'], + ['IgxComboHeaderDirective', 'combo'], + ['IgxComboHeaderItemDirective', 'combo'], + ['IgxComboToggleIconDirective', 'combo'], + + // Date and Date Range Picker + ['IgxDatePickerComponent', 'date-picker'], + ['IgxDatePickerModule', 'date-picker'], + ['IGX_DATE_PICKER_DIRECTIVES', 'date-picker'], + ['IGX_DATE_RANGE_PICKER_DIRECTIVES', 'date-picker'], + ['InteractionMode', 'date-picker'], + ['IDatePickerCancelEventArgs', 'date-picker'], + ['IDatePickerDisabledDateEventArgs', 'date-picker'], + ['IDatePickerValidationFailedEventArgs', 'date-picker'], + ['IgxDateRangePickerComponent', 'date-picker'], + ['IgxDateRangePickerModule', 'date-picker'], + ['DateRangeDescriptor', 'date-picker'], + ['IDateRangePickerCancelEventArgs', 'date-picker'], + ['IgxDateRangeEndComponent', 'date-picker'], + ['IgxDateRangeStartComponent', 'date-picker'], + + // Dialog + ['IgxDialogComponent', 'dialog'], + ['IgxDialogModule', 'dialog'], + ['IGX_DIALOG_DIRECTIVES', 'dialog'], + ['IgxDialogActionsDirective', 'dialog'], + ['IgxDialogTitleDirective', 'dialog'], + ['IDialogEventArgs', 'dialog'], + ['IDialogCancelEventArgs', 'dialog'], + + // Drop Down + ['IgxDropDownComponent', 'drop-down'], + ['IgxDropDownModule', 'drop-down'], + ['IGX_DROP_DOWN_DIRECTIVES', 'drop-down'], + ['IgxDropDownItemComponent', 'drop-down'], + ['IgxDropDownGroupComponent', 'drop-down'], + ['IgxDropDownItemBaseDirective', 'drop-down'], + ['IgxAutocompleteDirective', 'drop-down'], // Breaking change - moved from directives + ['ISelectionEventArgs', 'drop-down'], + ['IDropDownNavigationDirective', 'drop-down'], + ['IgxDropDownItemNavigationDirective', 'drop-down'], + ['IgxAutocompleteModule', 'drop-down'], + + // Expansion Panel + ['IgxExpansionPanelComponent', 'expansion-panel'], + ['IgxExpansionPanelModule', 'expansion-panel'], + ['IGX_EXPANSION_PANEL_DIRECTIVES', 'expansion-panel'], + ['IgxExpansionPanelBase', 'expansion-panel'], + ['IExpansionPanelEventArgs', 'expansion-panel'], + ['IExpansionPanelCancelableEventArgs', 'expansion-panel'], + ['IgxExpansionPanelHeaderComponent', 'expansion-panel'], + ['IgxExpansionPanelBodyComponent', 'expansion-panel'], + ['IgxExpansionPanelTitleDirective', 'expansion-panel'], + ['IgxExpansionPanelDescriptionDirective', 'expansion-panel'], + ['IgxExpansionPanelIconDirective', 'expansion-panel'], + ['ToggleAnimationSettings', 'expansion-panel'], + + // Grids - Components, Services, Types + // Note: All grid exports are available from 'igniteui-angular/grids' + // For better tree-shaking, you can use specific grid entry points: + // - 'igniteui-angular/grids/core' - Shared grid infrastructure (columns, toolbar, etc.) + // - 'igniteui-angular/grids/grid' - Standard grid (IgxGridComponent) + // - 'igniteui-angular/grids/tree-grid' - Tree grid (IgxTreeGridComponent) + // - 'igniteui-angular/grids/hierarchical-grid' - Hierarchical grid (IgxHierarchicalGridComponent, IgxRowIslandComponent) + // - 'igniteui-angular/grids/pivot-grid' - Pivot grid (IgxPivotGridComponent, IgxPivotDataSelectorComponent) + ['IgxGridComponent', 'grids/grid'], + ['IGX_GRID_DIRECTIVES', 'grids/grid'], + ['IgxTreeGridComponent', 'grids/tree-grid'], + ['IGX_TREE_GRID_DIRECTIVES', 'grids/tree-grid'], + ['IgxHierarchicalGridComponent', 'grids/hierarchical-grid'], + ['IGX_HIERARCHICAL_GRID_DIRECTIVES', 'grids/hierarchical-grid'], + ['IgxPivotGridComponent', 'grids/pivot-grid'], + ['IGX_PIVOT_GRID_DIRECTIVES', 'grids/pivot-grid'], + ['IgxPivotDataSelectorComponent', 'grids/pivot-grid'], + ['IgxRowIslandComponent', 'grids/hierarchical-grid'], + ['IgxGridModule', 'grids/grid'], + ['IgxTreeGridModule', 'grids/tree-grid'], + ['IgxHierarchicalGridModule', 'grids/hierarchical-grid'], + ['IgxPivotGridModule', 'grids/pivot-grid'], + ['IgxColumnComponent', 'grids/core'], + ['IgxColumnGroupComponent', 'grids/core'], + ['IgxCollapsibleIndicatorTemplateDirective', 'grids/core'], + ['IgxRowDirective', 'grids/core'], + ['IgxCellComponent', 'grids/core'], + ['IgxGridCellComponent', 'grids/core'], + ['IgxGridHeaderComponent', 'grids/core'], + ['IgxGridToolbarComponent', 'grids/core'], + ['IgxGridToolbarActionsComponent', 'grids/core'], + ['IgxGridToolbarAdvancedFilteringComponent', 'grids/core'], + ['IgxGridToolbarExporterComponent', 'grids/core'], + ['IgxGridToolbarHidingComponent', 'grids/core'], + ['IgxGridToolbarPinningComponent', 'grids/core'], + ['IgxGridToolbarTitleComponent', 'grids/core'], + ['GridBaseAPIService', 'grids/core'], + ['IgxGridAPIService', 'grids/grid'], + ['IgxTreeGridAPIService', 'grids/tree-grid'], + ['IgxHierarchicalGridAPIService', 'grids/hierarchical-grid'], + ['IgxGridSelectionService', 'grids/core'], + ['IgxGridNavigationService', 'grids/core'], + ['IgxGridCRUDService', 'grids/core'], + ['IgxGridSummaryService', 'grids/core'], + ['IgxFilteringService', 'grids/core'], + ['IGridCellEventArgs', 'grids/core'], + ['IGridEditEventArgs', 'grids/core'], + ['IRowDataEventArgs', 'grids/core'], + ['IRowSelectionEventArgs', 'grids/core'], + ['ICellPosition', 'grids/core'], + ['IColumnResizeEventArgs', 'grids/core'], + ['IColumnMovingEventArgs', 'grids/core'], + ['IColumnMovingEndEventArgs', 'grids/core'], + ['IColumnMovingStartEventArgs', 'grids/core'], + ['IGridKeydownEventArgs', 'grids/core'], + ['IRowDragEndEventArgs', 'grids/core'], + ['IRowDragStartEventArgs', 'grids/core'], + ['GridSelectionMode', 'grids/core'], + ['FilterMode', 'grids/core'], + ['GridSummaryPosition', 'grids/core'], + ['RowPinningPosition', 'grids/core'], + ['GridInstanceType', 'grids/core'], + ['IgxSummaryOperand', 'grids/core'], + ['IgxNumberSummaryOperand', 'grids/core'], + ['IgxDateSummaryOperand', 'grids/core'], + ['IgxSummaryTemplateDirective', 'grids/core'], + ['IgxCellTemplateDirective', 'grids/core'], + ['IgxCellHeaderTemplateDirective', 'grids/core'], + ['IgxFilterCellTemplateDirective', 'grids/core'], + ['IGridFormGroupCreatedEventArgs', 'grids/core'], + ['IgxCellValidationErrorDirective', 'grids/core'], + ['IgxColumnMaxValidatorDirective', 'grids/core'], + ['IgxColumnMinValidatorDirective', 'grids/core'], + ['IgxColumnEmailValidatorDirective', 'grids/core'], + ['IgxColumnMinLengthValidatorDirective', 'grids/core'], + ['IgxColumnMaxLengthValidatorDirective', 'grids/core'], + ['IgxColumnPatternValidatorDirective', 'grids/core'], + ['IgxColumnRequiredValidatorDirective', 'grids/core'], + ['CellType', 'grids/core'], + ['IPinningConfig', 'grids/core'], + ['RowType', 'grids/core'], + ['IgxCellEditorTemplateDirective', 'grids/core'], + ['IGridToolbarExportEventArgs', 'grids/core'], + ['SortingIndexFilteringStrategy', 'grids/core'], + ['IgxHeadSelectorDirective', 'grids/core'], + ['IgxRowSelectorDirective', 'grids/core'], + ['GridFeatures', 'grids/core'], + ['IGridState', 'grids/core'], + ['IGridStateOptions', 'grids/core'], + ['IgxGridStateDirective', 'grids/core'], + ['IgxRowEditActionsDirective', 'grids/core'], + ['IgxRowEditTabStopDirective', 'grids/core'], + ['IgxRowEditTextDirective', 'grids/core'], + ['IgxRowAddTextDirective', 'grids/core'], + ['GridPagingMode', 'grids/core'], + ['IgxAdvancedFilteringDialogComponent', 'grids/core'], + ['IgxExcelStyleColumnOperationsTemplateDirective', 'grids/core'], + ['IgxExcelStyleFilterOperationsTemplateDirective', 'grids/core'], + ['IgxExcelStyleLoadingValuesTemplateDirective', 'grids/core'], + ['IgxExcelStyleHeaderComponent', 'grids/core'], + ['IgxExcelStyleHeaderIconDirective', 'grids/core'], + ['IgxExcelStyleSearchComponent', 'grids/core'], + ['IgxExcelStyleSortingComponent', 'grids/core'], + ['IgxExcelStylePinningComponent', 'grids/core'], + ['IgxGridExcelStyleFilteringComponent', 'grids/core'], + ['IgxExcelTextDirective', 'grids/core'], + ['IgxCSVTextDirective', 'grids/core'], + ['GridCellMergeMode', 'grids/core'], + ['IActiveNodeChangeEventArgs', 'grids/core'], + ['IPivotAggregator', 'grids/core'], + ['PivotAggregation', 'grids/core'], + ['PivotAggregationType', 'grids/core'], + ['PivotRowLayoutType', 'grids/core'], + ['IPivotConfiguration', 'grids/core'], + ['IPivotDimension', 'grids/core'], + ['IPivotDimensionData', 'grids/core'], + ['IPivotValue', 'grids/core'], + ['IgxPivotDateDimension', 'grids/core'], + ['IgxPivotAggregate', 'grids/core'], + ['IgxPivotNumericAggregate', 'grids/core'], + ['IgxPivotDateAggregate', 'grids/core'], + ['IgxPivotTimeAggregate', 'grids/core'], + ['IPivotUISettings', 'grids/core'], + ['PivotSummaryPosition', 'grids/core'], + ['NoopPivotDimensionsStrategy', 'grids/core'], + ['IgxGridToolbarDirective', 'grids/core'], + ['IgxGroupByRowTemplateDirective', 'grids/core'], + ['IgxGridDetailTemplateDirective', 'grids/core'], + ['GridType', 'grids/core'], + ['IGX_GRID_BASE', 'grids/core'], + ['IColumnSelectionEventArgs', 'grids/core'], + ['IgxDragIndicatorIconDirective', 'grids/core'], + ['IgxRowDragGhostDirective', 'grids/core'], + ['IgxGridFooterComponent', 'grids/core'], + ['IgxColumnLayoutComponent', 'grids/core'], + ['IgxExporterEvent', 'grids/core'], + ['IGridEditDoneEventArgs', 'grids/core'], + ['IgxGridRow', 'grids/core'], + ['IgxGridEditingActions', 'grids/core'], // Grid actions moved to grids + ['IgxGridPinningActions', 'grids/core'], // Grid actions moved to grids + ['IgxGridActionButtonComponent', 'grids/core'], // Grid actions moved to grids + ['IgxGridActionsBaseDirective', 'grids/core'], // Grid actions moved to grids + ['IgxGridEditingActionsComponent', 'grids/core'], // Grid actions moved to grids + ['IgxGridPinningActionsComponent', 'grids/core'], // Grid actions moved to grids + ['IgxColumnActionsComponent', 'grids/core'], + ['IgxColumnHidingDirective', 'grids/core'], + ['IgxColumnPinningDirective', 'grids/core'], + ['IgxTreeGridGroupByAreaComponent', 'grids/tree-grid'], + ['ITreeGridAggregation', 'grids/tree-grid'], + ['IgxGroupedTreeGridSorting', 'grids/tree-grid'], + ['IgxTreeGridGroupingPipe', 'grids/tree-grid'], + ['IGridCreatedEventArgs', 'grids/hierarchical-grid'], + + // Exporter services and types (moved from core to grids/core in 21.0.0) + ['IgxBaseExporter', 'grids/core'], + ['IgxExporterOptionsBase', 'grids/core'], + ['ExportUtilities', 'grids/core'], + ['ExportRecordType', 'grids/core'], + ['ExportHeaderType', 'grids/core'], + ['IExportRecord', 'grids/core'], + ['IColumnList', 'grids/core'], + ['IColumnInfo', 'grids/core'], + ['IRowExportingEventArgs', 'grids/core'], + ['IColumnExportingEventArgs', 'grids/core'], + ['DEFAULT_OWNER', 'grids/core'], + ['GRID_ROOT_SUMMARY', 'grids/core'], + ['GRID_PARENT', 'grids/core'], + ['GRID_LEVEL_COL', 'grids/core'], + // CSV Exporter + ['IgxCsvExporterService', 'grids/core'], + ['IgxCsvExporterOptions', 'grids/core'], + ['ICsvExportEndedEventArgs', 'grids/core'], + ['CsvFileTypes', 'grids/core'], + ['CharSeparatedValueData', 'grids/core'], + // Excel Exporter + ['IgxExcelExporterService', 'grids/core'], + ['IgxExcelExporterOptions', 'grids/core'], + ['IExcelExportEndedEventArgs', 'grids/core'], + ['ExcelFolderTypes', 'grids/core'], + ['ExcelFileTypes', 'grids/core'], + ['IExcelFile', 'grids/core'], + ['IExcelFolder', 'grids/core'], + ['ExcelStrings', 'grids/core'], + ['ExcelElementsFactory', 'grids/core'], + ['WorksheetData', 'grids/core'], + ['WorksheetDataDictionary', 'grids/core'], + ['RootExcelFolder', 'grids/core'], + ['RootRelsExcelFolder', 'grids/core'], + ['DocPropsExcelFolder', 'grids/core'], + ['XLExcelFolder', 'grids/core'], + ['XLRelsExcelFolder', 'grids/core'], + ['ThemeExcelFolder', 'grids/core'], + ['WorksheetsExcelFolder', 'grids/core'], + ['TablesExcelFolder', 'grids/core'], + ['WorksheetsRelsExcelFolder', 'grids/core'], + ['RootRelsFile', 'grids/core'], + ['AppFile', 'grids/core'], + ['CoreFile', 'grids/core'], + ['WorkbookRelsFile', 'grids/core'], + ['ThemeFile', 'grids/core'], + ['WorksheetFile', 'grids/core'], + ['StyleFile', 'grids/core'], + ['WorkbookFile', 'grids/core'], + ['ContentTypesFile', 'grids/core'], + ['SharedStringsFile', 'grids/core'], + ['TablesFile', 'grids/core'], + ['WorksheetRelsFile', 'grids/core'], + // PDF Exporter + ['IgxPdfExporterService', 'grids/core'], + ['IgxPdfExporterOptions', 'grids/core'], + ['IPdfExportEndedEventArgs', 'grids/core'], + + // Icon + ['IgxIconComponent', 'icon'], + ['IgxIconModule', 'icon'], + ['IgxIconService', 'icon'], + ['IconMeta', 'icon'], + + // Input Group + ['IgxInputGroupComponent', 'input-group'], + ['IgxInputGroupModule', 'input-group'], + ['IGX_INPUT_GROUP_DIRECTIVES', 'input-group'], + ['IgxInputDirective', 'input-group'], // Breaking change - moved from directives + ['IgxLabelDirective', 'input-group'], // Breaking change - moved from directives + ['IgxHintDirective', 'input-group'], // Breaking change - moved from directives + ['IgxPrefixDirective', 'input-group'], // Breaking change - moved from directives + ['IgxSuffixDirective', 'input-group'], // Breaking change - moved from directives + ['IgxInputState', 'input-group'], + ['IgxInputGroupType', 'input-group'], + ['IGX_INPUT_GROUP_TYPE', 'input-group'], + + // List + ['IgxListComponent', 'list'], + ['IgxListModule', 'list'], + ['IGX_LIST_DIRECTIVES', 'list'], + ['IgxListItemComponent', 'list'], + ['IgxListHeaderComponent', 'list'], + ['IListItemClickEventArgs', 'list'], + ['IgxListPanState', 'list'], + ['IgxEmptyListTemplateDirective', 'list'], + ['IgxListLineDirective', 'list'], + ['IgxListLineSubTitleDirective', 'list'], + ['IgxListLineTitleDirective', 'list'], + ['IgxDataLoadingTemplateDirective', 'list'], + ['IgxListActionDirective', 'list'], + ['IgxListThumbnailDirective', 'list'], + ['IgxListItemLeftPanningTemplateDirective', 'list'], + ['IgxListItemRightPanningTemplateDirective', 'list'], + + // Navbar + ['IgxNavbarComponent', 'navbar'], + ['IgxNavbarModule', 'navbar'], + ['IGX_NAVBAR_DIRECTIVES', 'navbar'], + ['IgxNavbarActionDirective', 'navbar'], + ['IgxNavbarTitleDirective', 'navbar'], + + // Navigation Drawer + ['IgxNavigationDrawerComponent', 'navigation-drawer'], + ['IgxNavigationDrawerModule', 'navigation-drawer'], + ['IGX_NAVIGATION_DRAWER_DIRECTIVES', 'navigation-drawer'], + ['IgxNavigationDrawerItemComponent', 'navigation-drawer'], + ['INavigationDrawerEventArgs', 'navigation-drawer'], + ['IgxNavDrawerMode', 'navigation-drawer'], + ['IgxNavDrawerItemDirective', 'navigation-drawer'], + ['IgxNavDrawerTemplateDirective', 'navigation-drawer'], + ['IgxNavDrawerMiniTemplateDirective', 'navigation-drawer'], + + // Paginator + ['IgxPaginatorComponent', 'paginator'], + ['IGX_PAGINATOR_DIRECTIVES', 'paginator'], + ['IgxPaginatorDirective', 'paginator'], + ['IgxPageNavigationComponent', 'paginator'], + ['IgxPageSizeSelectorComponent', 'paginator'], + ['IgxPaginatorContentDirective', 'paginator'], + ['IgxPaginatorModule', 'paginator'], + ['IPageEventArgs', 'paginator'], + ['IPageCancelableEventArgs', 'paginator'], + + // Progressbar + ['IgxCircularProgressBarComponent', 'progressbar'], + ['IgxLinearProgressBarComponent', 'progressbar'], + ['IgxProgressBarModule', 'progressbar'], + ['IGX_PROGRESS_BAR_DIRECTIVES', 'progressbar'], + ['IgxProgressType', 'progressbar'], + ['IgxTextAlign', 'progressbar'], + ['IgxProgressBarGradientMode', 'progressbar'], + ['IgxProgressBarGradientDirective', 'progressbar'], + + // Query Builder + ['IgxQueryBuilderComponent', 'query-builder'], + ['IgxQueryBuilderModule', 'query-builder'], + ['IGX_QUERY_BUILDER_DIRECTIVES', 'query-builder'], + ['IExpressionGroup', 'query-builder'], + ['IgxQueryBuilderHeaderComponent', 'query-builder'], + ['IgxQueryBuilderSearchValueTemplateDirective', 'query-builder'], + + // Radio + ['IgxRadioComponent', 'radio'], + ['IgxRadioModule', 'radio'], + ['IGX_RADIO_GROUP_DIRECTIVES', 'radio'], + ['RadioGroupAlignment', 'radio'], + ['IgxRadioGroupDirective', 'radio'], + + // Select + ['IgxSelectComponent', 'select'], + ['IgxSelectModule', 'select'], + ['IGX_SELECT_DIRECTIVES', 'select'], + ['IgxSelectItemComponent', 'select'], + ['IgxSelectHeaderDirective', 'select'], + ['IgxSelectFooterDirective', 'select'], + ['IgxSelectToggleIconDirective', 'select'], + ['ISelectionChangedEventArgs', 'select'], + ['IgxSelectGroupComponent', 'select'], + + // Simple Combo + ['IgxSimpleComboComponent', 'simple-combo'], + ['IGX_SIMPLE_COMBO_DIRECTIVES', 'simple-combo'], + ['ISimpleComboSelectionChangingEventArgs', 'simple-combo'], + ['IgxSimpleComboModule', 'simple-combo'], + + // Slider + ['IgxSliderComponent', 'slider'], + ['IgxSliderModule', 'slider'], + ['IGX_SLIDER_DIRECTIVES', 'slider'], + ['ISliderValueChangeEventArgs', 'slider'], + ['IRangeSliderValue', 'slider'], + ['SliderType', 'slider'], + ['IgxSliderType', 'slider'], + ['TickLabelsOrientation', 'slider'], + ['TicksOrientation', 'slider'], + ['IgxTickLabelTemplateDirective', 'slider'], + ['IgxThumbToTemplateDirective', 'slider'], + ['IgxThumbFromTemplateDirective', 'slider'], + + // Snackbar + ['IgxSnackbarComponent', 'snackbar'], + ['IgxSnackbarModule', 'snackbar'], + + // Splitter + ['IgxSplitterComponent', 'splitter'], + ['IgxSplitterModule', 'splitter'], + ['IGX_SPLITTER_DIRECTIVES', 'splitter'], + ['IgxSplitterPaneComponent', 'splitter'], + ['ISplitterEventArgs', 'splitter'], + ['SplitterType', 'splitter'], + + // Stepper + ['IgxStepperComponent', 'stepper'], + ['IgxStepperModule', 'stepper'], + ['IGX_STEPPER_DIRECTIVES', 'stepper'], + ['IgxStepComponent', 'stepper'], + ['IStepChangingEventArgs', 'stepper'], + ['IStepChangedEventArgs', 'stepper'], + ['IgxStepperOrientation', 'stepper'], + ['IgxStepType', 'stepper'], + ['IgxStepActiveIndicatorDirective', 'stepper'], + ['IgxStepCompletedIndicatorDirective', 'stepper'], + ['IgxStepContentDirective', 'stepper'], + ['IgxStepTitleDirective', 'stepper'], + ['IgxStepSubtitleDirective', 'stepper'], + ['IgxStepInvalidIndicatorDirective', 'stepper'], + ['IgxStepIndicatorDirective', 'stepper'], + ['IgxStepperTitlePosition', 'stepper'], + + // Switch + ['IgxSwitchComponent', 'switch'], + ['IgxSwitchModule', 'switch'], + + // Tabs + ['IgxTabsComponent', 'tabs'], + ['IgxTabsModule', 'tabs'], + ['IGX_TABS_DIRECTIVES', 'tabs'], + ['IgxTabItemComponent', 'tabs'], + ['IgxTabHeaderComponent', 'tabs'], + ['IgxTabContentComponent', 'tabs'], + ['IgxTabsGroupComponent', 'tabs'], + ['ITabsSelectedItemChangeEventArgs', 'tabs'], + ['IgxTabsType', 'tabs'], + ['IgxTabHeaderIconDirective', 'tabs'], + ['IgxTabHeaderLabelDirective', 'tabs'], + + // Time Picker + ['IgxTimePickerComponent', 'time-picker'], + ['IgxTimePickerModule', 'time-picker'], + ['IGX_TIME_PICKER_DIRECTIVES', 'time-picker'], + ['IgxTimePickerActionsDirective', 'time-picker'], + ['IgxHourItemDirective', 'time-picker'], + ['IgxMinuteItemDirective', 'time-picker'], + ['IgxAmPmItemDirective', 'time-picker'], + ['IgxItemListDirective', 'time-picker'], + + // Toast + ['IgxToastComponent', 'toast'], + ['IgxToastModule', 'toast'], + ['IgxToastPosition', 'toast'], + + // Tree + ['IgxTreeComponent', 'tree'], + ['IgxTreeModule', 'tree'], + ['IGX_TREE_DIRECTIVES', 'tree'], + ['IgxTreeNodeComponent', 'tree'], + ['ITreeNodeSelectionEvent', 'tree'], + ['ITreeNodeTogglingEventArgs', 'tree'], + ['IgxTreeSelectionType', 'tree'], + ['IgxTreeNodeLinkDirective', 'tree'], + + // Directives (re-exports from other entry points) + ['IgxForOfDirective', 'directives'], + ['IForOfState', 'directives'], + ['IgxForOfModule', 'directives'], + ['IgxTemplateOutletDirective', 'directives'], + ['IgxTextSelectionDirective', 'directives'], + ['IgxTextSelectionModule', 'directives'], + ['IgxTextHighlightDirective', 'directives'], + ['IgxTextHighlightModule', 'directives'], + ['IgxDateTimeEditorDirective', 'directives'], + ['IgxMaskDirective', 'directives'], + ['IgxMaskModule', 'directives'], + ['IgxDividerDirective', 'directives'], + ['IgxDividerModule', 'directives'], + ['IgxFilterDirective', 'directives'], + ['IgxButtonDirective', 'directives'], + ['IgxButtonModule', 'directives'], + ['IgxIconButtonDirective', 'directives'], + ['IgxToggleActionDirective', 'directives'], + ['IgxLayoutDirective', 'directives'], + ['IgxLayoutModule', 'directives'], + ['IgxFlexDirective', 'directives'], + ['IgxFocusDirective', 'directives'], + ['IgxFocusModule', 'directives'], + ['IgxTooltipDirective', 'directives'], + ['IgxTooltipTargetDirective', 'directives'], + ['TooltipPositionStrategy', 'directives'], + ['IgxTooltipModule', 'directives'], + ['IgxRippleDirective', 'directives'], + ['IgxRippleModule', 'directives'], + ['IDropDroppedEventArgs', 'directives'], + ['IDragGhostCreatedEventArgs', 'directives'], + ['IDragStartEventArgs', 'directives'], + ['IDragBaseEventArgs', 'directives'], + ['IDropBaseEventArgs', 'directives'], + ['IDragMoveEventArgs', 'directives'], + ['IgxDragDirective', 'directives'], + ['IgxDragHandleDirective', 'directives'], + ['IgxDragLocation', 'directives'], + ['IgxDropDirective', 'directives'], + ['IgxDragDropModule', 'directives'], + ['IGX_DRAG_DROP_DIRECTIVES', 'directives'], + ['IgxFocusTrapDirective', 'directives'], + ['IgxToggleDirective', 'directives'], + ['IgxToggleModule', 'directives'], + ['IgxFilterOptions', 'directives'], + ['IgxFilterPipe', 'directives'], + ['IgxFilterModule', 'directives'], + ['IgcFormControlDirective', 'directives'], + ['IgxTextHighlightService', 'directives'] +]); + +// Type renames (old name -> new name and entry point) +const TYPE_RENAMES = new Map([ + ['Direction', { newName: 'CarouselAnimationDirection', entryPoint: 'carousel' }], + ['IgxColumPatternValidatorDirective', { newName: 'IgxColumnPatternValidatorDirective', entryPoint: 'grids/core' }], +]); + +function migrateImportDeclaration(node: ts.ImportDeclaration, sourceFile: ts.SourceFile): { start: number, end: number, replacement: string } | null { + if (!igNamedImportFilter(node)) { + return null; + } + + const importPath = node.moduleSpecifier.text; + const namedBindings = node.importClause.namedBindings; + + // Only process main entry imports (not sub-entry points which igNamedImportFilter will allow) + if (importPath !== IG_PACKAGE_NAME && importPath !== IG_LICENSED_PACKAGE_NAME) { + return null; + } + + // Group imports by entry point + const entryPointGroups = new Map(); + + for (const element of namedBindings.elements) { + const name = element.name.text; + const alias = element.propertyName?.text; + const importName = alias || name; + let actualImportName = importName; + + // Check if this is a renamed type + if (TYPE_RENAMES.has(importName)) { + const rename = TYPE_RENAMES.get(importName)!; + actualImportName = rename.newName; + } + + const fullImport = alias ? `${actualImportName} as ${name}` : actualImportName; + + // Determine target entry point + let targetEntryPoint = 'core'; // Default to core + + // Check if it's a renamed type first + if (TYPE_RENAMES.has(importName)) { + targetEntryPoint = TYPE_RENAMES.get(importName)!.entryPoint; + } else if (ENTRY_POINT_MAP.has(importName)) { + targetEntryPoint = ENTRY_POINT_MAP.get(importName)!; + } + + if (!entryPointGroups.has(targetEntryPoint)) { + entryPointGroups.set(targetEntryPoint, []); + } + entryPointGroups.get(targetEntryPoint)!.push(fullImport); + } + + // Generate new import statements + const newImports: string[] = []; + for (const [entryPoint, imports] of entryPointGroups) { + const sortedImports = imports.sort(); + newImports.push(`import { ${sortedImports.join(', ')} } from '${importPath}/${entryPoint}';`); + } + + return { + start: node.getStart(sourceFile), + end: node.getEnd(), + replacement: newImports.join('\n') + }; +} + +function migrateFile(filePath: string, content: string): string { + const sourceFile = ts.createSourceFile( + filePath, + content, + ts.ScriptTarget.Latest, + true + ); + + const changes: { start: number, end: number, replacement: string }[] = []; + + // Track which old type names are imported in this file + const importedOldTypes = new Set(); + + function visit(node: ts.Node) { + if (ts.isImportDeclaration(node)) { + const change = migrateImportDeclaration(node, sourceFile); + if (change) { + changes.push(change); + + // Track old type names that were imported + const moduleSpecifier = node.moduleSpecifier; + if (ts.isStringLiteral(moduleSpecifier) && (moduleSpecifier.text === IG_PACKAGE_NAME || moduleSpecifier.text === IG_LICENSED_PACKAGE_NAME)) { + const importClause = node.importClause; + if (importClause?.namedBindings && ts.isNamedImports(importClause.namedBindings)) { + for (const element of importClause.namedBindings.elements) { + const importName = element.propertyName?.text || element.name.text; + if (TYPE_RENAMES.has(importName)) { + importedOldTypes.add(importName); + } + } + } + } + } + } else if (ts.isIdentifier(node) && importedOldTypes.has(node.text)) { + // Rename type references in the code (but only if not aliased in import) + const oldName = node.text; + const rename = TYPE_RENAMES.get(oldName)!; + + // Check if this identifier is part of an import statement + // We don't want to rename it there as we already handled it + let isInImport = false; + let parent = node.parent; + while (parent) { + if (ts.isImportDeclaration(parent)) { + isInImport = true; + break; + } + parent = parent.parent; + } + + if (!isInImport) { + changes.push({ + start: node.getStart(sourceFile), + end: node.getEnd(), + replacement: rename.newName + }); + } + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); + + // Apply changes in reverse order to maintain positions + changes.sort((a, b) => b.start - a.start); + + let result = content; + for (const change of changes) { + result = result.substring(0, change.start) + change.replacement + result.substring(change.end); + } + + return result; +} + +export default function migrate(): Rule { + return async (host: Tree, context: SchematicContext) => { + context.logger.info(`Applying optional import migration for Ignite UI for Angular to version ${version}`); + context.logger.info('Migrating imports to new entry points...'); + + const visit: FileVisitor = (filePath) => { + // Only process TypeScript files + if (!filePath.endsWith('.ts')) { + return; + } + + // Skip node_modules and dist + if (filePath.includes('node_modules') || filePath.includes('dist')) { + return; + } + + const content = host.read(filePath); + if (!content) { + return; + } + + const originalContent = content.toString(); + + // Check if file has base igniteui-angular imports + if (!originalContent.includes(`from '${IG_PACKAGE_NAME}'`) && !originalContent.includes(`from "${IG_PACKAGE_NAME}"`) && + !originalContent.includes(`from '${IG_LICENSED_PACKAGE_NAME}'`) && !originalContent.includes(`from "${IG_LICENSED_PACKAGE_NAME}"`)) { + return; + } + + const migratedContent = migrateFile(filePath, originalContent); + + if (migratedContent !== originalContent) { + host.overwrite(filePath, migratedContent); + context.logger.info(` ✓ Migrated ${filePath}`); + } + }; + + host.visit(visit); + + context.logger.info('Migration complete!'); + context.logger.info('Breaking changes:'); + context.logger.info(' - Input directives moved to igniteui-angular/input-group'); + context.logger.info(' - IgxAutocompleteDirective moved to igniteui-angular/drop-down'); + context.logger.info(' - IgxRadioGroupDirective moved to igniteui-angular/radio'); + context.logger.info(' - Exporter services (CSV, Excel, PDF) moved to igniteui-angular/grids/core'); + context.logger.info('Type renames:'); + context.logger.info(' - Direction → CarouselAnimationDirection'); + }; +} diff --git a/projects/igniteui-angular/navbar/README.md b/projects/igniteui-angular/navbar/README.md new file mode 100644 index 00000000000..30c18012d10 --- /dev/null +++ b/projects/igniteui-angular/navbar/README.md @@ -0,0 +1,54 @@ +# igx-navbar + +**igx-navbar** is position on top and represents current state and enables a user defined action. +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/navbar) + +# Usage + +## Simple Navbar + +```html + + +``` + +You can be more descriptive and set title `title="User settings"`. + + +## Navbar with back button + +```html + + +``` + +You can set the id of the component by `id="myNavbar"` or will be automatically generated; + +You can set the title of the navbar by setting `title="Settings"`; + +You can set the action button icon of the navbar by setting `actionButtonIcon="arrow_back"`; + +You can set the visible state of the navbar by setting `isActionButtonVisible="true"`; + +You can set the action of the navbar button by setting `(action)="executeAction()"`; + +## Navbar with custom action icon + +The navbar component provides us with the ability to use a template for the action icon instead of the default one. This can be done by simply using the `igx-action-icon` tag. + +```html + + + Navigate back: + arrow_back + + +``` + +If a custom `igx-action-icon` is provided, the default action icon will not be used. diff --git a/projects/igniteui-angular/navbar/index.ts b/projects/igniteui-angular/navbar/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/navbar/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/navbar/ng-package.json b/projects/igniteui-angular/navbar/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/navbar/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/navbar/src/navbar/navbar.component.html b/projects/igniteui-angular/navbar/src/navbar/navbar.component.html new file mode 100644 index 00000000000..772a0a5e3f7 --- /dev/null +++ b/projects/igniteui-angular/navbar/src/navbar/navbar.component.html @@ -0,0 +1,23 @@ + diff --git a/projects/igniteui-angular/navbar/src/navbar/navbar.component.spec.ts b/projects/igniteui-angular/navbar/src/navbar/navbar.component.spec.ts new file mode 100644 index 00000000000..1d8aaef07d0 --- /dev/null +++ b/projects/igniteui-angular/navbar/src/navbar/navbar.component.spec.ts @@ -0,0 +1,285 @@ +import { Component, ViewChild } from '@angular/core'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { IgxNavbarComponent, IgxNavbarTitleDirective, IgxNavbarActionDirective } from './navbar.component'; + +import { wait } from '../../../test-utils/ui-interactions.spec'; +import { IgxIconComponent } from 'igniteui-angular/icon'; + +const LEFT_AREA_CSS_CLAS = '.igx-navbar__left'; + +describe('IgxNavbar', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NavbarIntializeTestComponent, + NavbarCustomActionIconTestComponent, + NavbarCustomIgxIconTestComponent, + NavbarCustomTitleTestComponent, + NavbarCustomTitleDirectiveTestComponent, + NavbarCustomIgxIconDirectiveTestComponent + ] + }).compileComponents(); + })); + + let fixture; let component; let domNavbar; + + describe('Default Action Icon', () => { + beforeEach(() => { + fixture = TestBed.createComponent(NavbarIntializeTestComponent); + fixture.detectChanges(); + component = fixture.componentInstance; + domNavbar = fixture.debugElement.query(By.css('igx-navbar')).nativeElement; + }); + + it('should properly initialize properties', () => { + expect(component.navbar.id).toContain('igx-navbar-'); + expect(domNavbar.id).toContain('igx-navbar-'); + expect(component.navbar.title).toBeUndefined(); + expect(component.navbar.isActionButtonVisible).toBeFalsy(); + expect(component.navbar.actionButtonIcon).toBeUndefined(); + + component.navbar.id = 'customNavbar'; + fixture.detectChanges(); + + expect(component.navbar.id).toBe('customNavbar'); + expect(domNavbar.id).toBe('customNavbar'); + }); + + it('should change properties default values', () => { + const title = 'Test title'; + const isActionButtonVisible = true; + const actionButtonIcon = 'Test icon'; + + component.title = title; + component.isActionButtonVisible = isActionButtonVisible; + component.actionButtonIcon = actionButtonIcon; + fixture.detectChanges(); + + expect(component.navbar.title).toBe(title); + expect(component.navbar.isActionButtonVisible).toBeTruthy(); + expect(component.navbar.actionButtonIcon).toBe(actionButtonIcon); + }); + + it('should trigger on action', () => { + component.isActionButtonVisible = true; + component.actionButtonIcon = 'home'; + fixture.detectChanges(); + + spyOn(component.navbar.action, 'emit'); + fixture.debugElement.nativeElement.querySelector('igx-icon').click(); + fixture.detectChanges(); + + expect(component.navbar.action.emit) + .toHaveBeenCalledWith(component.navbar); + }); + + it('should have default action icon/content when user has not provided one', () => { + // Test prerequisites + component.isActionButtonVisible = true; + component.actionButtonIcon = 'home'; + fixture.detectChanges(); + + const leftArea = fixture.debugElement.query(By.css(LEFT_AREA_CSS_CLAS)); + + // Verify there is no custom content on the left + const customContent = leftArea.query(By.css('igx-navbar-action')); + expect(customContent).toBeNull('Custom action icon content is found on the left.'); + + // Verify there is a default icon on the left. + const defaultIcon = leftArea.query(By.css('igx-icon')); + expect(defaultIcon).not.toBeNull('Default icon is not found on the left.'); + const leftAreaLeft = leftArea.nativeElement.getBoundingClientRect().left; + const defaultIconLeft = defaultIcon.nativeElement.getBoundingClientRect().left; + expect(leftAreaLeft).toBe(defaultIconLeft, 'Default icon is not first on the left.'); + }); + }); + + describe('Custom Action Icon', () => { + it('should have custom action icon/content when user has provided one', () => { + fixture = TestBed.createComponent(NavbarCustomActionIconTestComponent); + fixture.detectChanges(); + + const leftArea = fixture.debugElement.query(By.css(LEFT_AREA_CSS_CLAS)); + + // Verify there is no default icon on the left. + const defaultIcon = leftArea.query(By.css('igx-icon')); + expect(defaultIcon).toBeNull('Default icon is found on the left.'); + + // Verify there is a custom content on the left. + const customContent = leftArea.query(By.css('igx-navbar-action')); + expect(customContent).not.toBeNull('Custom action icon content is not found on the left.'); + const leftAreaLeft = leftArea.nativeElement.getBoundingClientRect().left; + const customContentLeft = customContent.nativeElement.getBoundingClientRect().left; + expect(leftAreaLeft).toBe(customContentLeft, 'Custom action icon content is not first on the left.'); + }); + + it('should have vertically-centered custom action icon content', (async () => { + fixture = TestBed.createComponent(NavbarCustomIgxIconTestComponent); + fixture.detectChanges(); + + await wait(100); + + domNavbar = fixture.debugElement.query(By.css('igx-navbar')); + const customActionIcon = domNavbar.query(By.css('igx-navbar-action')); + const customIcon = customActionIcon.query(By.css('igx-icon')); + + const navbarRect = domNavbar.nativeElement.getBoundingClientRect(); + const iconRect = customIcon.nativeElement.getBoundingClientRect(); + + const navbarMidpoint = Math.round(navbarRect.top + (navbarRect.height / 2)); + const iconMidpoint = Math.round(iconRect.top + (iconRect.height / 2)); + + expect(navbarMidpoint).toBe(iconMidpoint, 'Custom icon is not exactly vertically centered within the navbar.'); + })); + + it('action icon via directive', (async () => { + fixture = TestBed.createComponent(NavbarCustomIgxIconDirectiveTestComponent); + fixture.detectChanges(); + + const leftArea = fixture.debugElement.query(By.css(LEFT_AREA_CSS_CLAS)); + const customContent = leftArea.query(By.directive(IgxNavbarActionDirective)); + expect(customContent).not.toBeNull('Custom action icon content is not found on the left.'); + + const leftAreaLeft = leftArea.nativeElement.getBoundingClientRect().left; + const customContentLeft = customContent.nativeElement.getBoundingClientRect().left; + expect(leftAreaLeft).toBe(customContentLeft, 'Custom action icon content is not first on the left.'); + })); + }); + + describe('Custom title content', () => { + beforeEach(() => { + fixture = TestBed.createComponent(NavbarIntializeTestComponent); + fixture.detectChanges(); + component = fixture.componentInstance; + domNavbar = fixture.debugElement.query(By.css('igx-navbar')).nativeElement; + }); + + it('Custom content should override the title property value', () => { + fixture = TestBed.createComponent(NavbarCustomTitleTestComponent); + fixture.detectChanges(); + + const midArea = fixture.debugElement.query(By.css('.igx-navbar__middle')); + + // Verify there is no default icon on the left. + const customTitle = midArea.query(By.css('igx-navbar-title')); + expect(customTitle.nativeElement.textContent).toBe('Custom Title', 'Custom title is missing'); + + const defaultTitle = midArea.query(By.css('igx-navbar__title')); + expect(defaultTitle).toBeNull('Default title should not be present'); + }); + + it('Custom content should override the default title property', () => { + fixture = TestBed.createComponent(NavbarCustomTitleDirectiveTestComponent); + fixture.detectChanges(); + + const midArea = fixture.debugElement.query(By.css('.igx-navbar__middle')); + + // Verify there is no default icon on the left. + const customTitle = midArea.query(By.directive(IgxNavbarTitleDirective)); + expect(customTitle.nativeElement.children[0].textContent).toBe('Custom', 'Custom title is missing'); + expect(customTitle.nativeElement.children[1].textContent).toBe('Title', 'Custom title is missing'); + + const defaultTitle = midArea.query(By.css('igx-navbar__title')); + expect(defaultTitle).toBeNull('Default title should not be present'); + }); + }); +}); +@Component({ + selector: 'igx-navbar-test-component', + template: ` + `, + imports: [IgxNavbarComponent] +}) +class NavbarIntializeTestComponent { + @ViewChild(IgxNavbarComponent, { static: true }) public navbar: IgxNavbarComponent; + public title: string; + public actionButtonIcon: string; + public isActionButtonVisible: boolean; +} + +@Component({ + selector: 'igx-navbar-custom-icon-component', + template: ` + + + + `, + imports: [IgxNavbarComponent, IgxNavbarActionDirective] +}) +class NavbarCustomActionIconTestComponent { + @ViewChild(IgxNavbarComponent, { static: true }) public navbar: IgxNavbarComponent; +} + +@Component({ + selector: 'igx-navbar-custom-igxicon-component', + template: ` + + arrow_back + + `, + imports: [IgxNavbarComponent, IgxNavbarActionDirective, IgxIconComponent] +}) +class NavbarCustomIgxIconTestComponent { + @ViewChild(IgxNavbarComponent, { static: true }) public navbar: IgxNavbarComponent; +} + +@Component({ + selector: 'igx-navbar-custom-igxicon-component', + template: ` + arrow_back + `, + imports: [IgxNavbarComponent, IgxIconComponent, IgxNavbarActionDirective] +}) +class NavbarCustomIgxIconDirectiveTestComponent { + @ViewChild(IgxNavbarComponent, { static: true }) public navbar: IgxNavbarComponent; +} + +@Component({ + selector: 'igx-navbar-custom-title', + template: ` + + arrow_back + + Custom Title + `, + imports: [IgxNavbarComponent, IgxNavbarActionDirective, IgxNavbarTitleDirective, IgxIconComponent] +}) +class NavbarCustomTitleTestComponent { + @ViewChild(IgxNavbarComponent, { static: true }) public navbar: IgxNavbarComponent; +} + +@Component({ + selector: 'igx-navbar-custom-title', + template: ` + + arrow_back + +
    +
    Custom
    + Title +
    +
    `, + imports: [IgxNavbarComponent, IgxNavbarActionDirective, IgxNavbarTitleDirective, IgxIconComponent] +}) +class NavbarCustomTitleDirectiveTestComponent { + @ViewChild(IgxNavbarComponent, { static: true }) public navbar: IgxNavbarComponent; +} diff --git a/projects/igniteui-angular/navbar/src/navbar/navbar.component.ts b/projects/igniteui-angular/navbar/src/navbar/navbar.component.ts new file mode 100644 index 00000000000..8825f61e1ca --- /dev/null +++ b/projects/igniteui-angular/navbar/src/navbar/navbar.component.ts @@ -0,0 +1,164 @@ +import { + Component, + EventEmitter, + HostBinding, + Input, + Output, + Directive, + ContentChild, + booleanAttribute +} from '@angular/core'; + +import { IgxIconComponent } from 'igniteui-angular/icon'; + +/** + * IgxActionIcon is a container for the action nav icon of the IgxNavbar. + */ +@Directive({ + selector: 'igx-navbar-action,[igxNavbarAction]', + standalone: true +}) +export class IgxNavbarActionDirective { } + +@Directive({ + selector: 'igx-navbar-title,[igxNavbarTitle]', + standalone: true +}) +export class IgxNavbarTitleDirective { } + +let NEXT_ID = 0; +/** + * **Ignite UI for Angular Navbar** - + * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/navbar.html) + * + * The Ignite UI Navbar is most commonly used to provide an app header with a hamburger menu and navigation + * state such as a "Go Back" button. It also supports other actions represented by icons. + * + * Example: + * ```html + * + * search + * favorite + * more_vert + * + * ``` + */ + +@Component({ + selector: 'igx-navbar', + templateUrl: 'navbar.component.html', + styles: [` + :host { + display: block; + width: 100%; + } + ` + ], + imports: [IgxIconComponent] +}) + +export class IgxNavbarComponent { + /** + * Sets the value of the `id` attribute. If not provided it will be automatically generated. + * ```html + * + * ``` + */ + @HostBinding('attr.id') + @Input() + public id = `igx-navbar-${NEXT_ID++}`; + + /** + * Sets the icon of the `IgxNavbarComponent`. + * ```html + * + * ``` + */ + @Input() public actionButtonIcon: string; + + /** + * Sets the title of the `IgxNavbarComponent`. + * ```html + * + * ``` + */ + @Input() public title: string; + + /** + * The event that will be thrown when the action is executed, + * provides reference to the `IgxNavbar` component as argument + * ```typescript + * public actionExc(event){ + * alert("Action Execute!"); + * } + * //.. + * ``` + * ```html + * + * ``` + */ + @Output() public action = new EventEmitter(); + + /** + * Sets the titleId of the `IgxNavbarComponent`. If not set it will be automatically generated. + * ```html + * + * ``` + */ + @Input() + public titleId = `igx-navbar-title-${NEXT_ID++}`; + + /** + * @hidden + */ + @ContentChild(IgxNavbarActionDirective, { read: IgxNavbarActionDirective }) + protected actionIconTemplate: IgxNavbarActionDirective; + + /** + * @hidden + */ + @ContentChild(IgxNavbarTitleDirective, { read: IgxNavbarTitleDirective }) + protected titleContent: IgxNavbarTitleDirective; + + private isVisible = true; + + /** + * Sets whether the action button of the `IgxNavbarComponent` is visible. + * ```html + * + * ``` + */ + public set isActionButtonVisible(value: boolean) { + this.isVisible = value; + } + + /** + * Returns whether the `IgxNavbarComponent` action button is visible, true/false. + * ```typescript + * @ViewChild("MyChild") + * public navBar: IgxNavbarComponent; + * ngAfterViewInit(){ + * let actionButtonVisibile = this.navBar.isActionButtonVisible; + * } + * ``` + */ + @Input({ transform: booleanAttribute }) + public get isActionButtonVisible(): boolean { + if (this.actionIconTemplate || !this.actionButtonIcon) { + return false; + } + return this.isVisible; + } + + public get isTitleContentVisible(): boolean { + return this.titleContent ? true : false; + } + + /** + * @hidden + */ + public _triggerAction() { + this.action.emit(this); + } +} + diff --git a/projects/igniteui-angular/navbar/src/navbar/navbar.module.ts b/projects/igniteui-angular/navbar/src/navbar/navbar.module.ts new file mode 100644 index 00000000000..468198036ad --- /dev/null +++ b/projects/igniteui-angular/navbar/src/navbar/navbar.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { IGX_NAVBAR_DIRECTIVES } from './public_api'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + ...IGX_NAVBAR_DIRECTIVES + ], + exports: [ + ...IGX_NAVBAR_DIRECTIVES + ] +}) + +export class IgxNavbarModule {} diff --git a/projects/igniteui-angular/navbar/src/navbar/public_api.ts b/projects/igniteui-angular/navbar/src/navbar/public_api.ts new file mode 100644 index 00000000000..25013ef9724 --- /dev/null +++ b/projects/igniteui-angular/navbar/src/navbar/public_api.ts @@ -0,0 +1,10 @@ +import { IgxNavbarActionDirective, IgxNavbarComponent, IgxNavbarTitleDirective } from './navbar.component'; + +export * from './navbar.component'; + +/* NOTE: Navbar directives collection for ease-of-use import in standalone components scenario */ +export const IGX_NAVBAR_DIRECTIVES = [ + IgxNavbarComponent, + IgxNavbarActionDirective, + IgxNavbarTitleDirective +] as const; diff --git a/projects/igniteui-angular/navbar/src/public_api.ts b/projects/igniteui-angular/navbar/src/public_api.ts new file mode 100644 index 00000000000..b9a8c27abb3 --- /dev/null +++ b/projects/igniteui-angular/navbar/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './navbar/public_api'; +export * from './navbar/navbar.module'; diff --git a/projects/igniteui-angular/navigation-drawer/README.md b/projects/igniteui-angular/navigation-drawer/README.md new file mode 100644 index 00000000000..295ca5d6e58 --- /dev/null +++ b/projects/igniteui-angular/navigation-drawer/README.md @@ -0,0 +1,219 @@ +# IgxNavigationDrawer Component + +The **igx-nav-drawer** is a container element for side navigation, providing quick access between views. It can be used for navigation apps and with top-level views. Drawer will be hidden until invoked by the user. +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/navdrawer) + +## Dependencies +To started with all needed dependencies you can use the `IgxNavigationDrawerModule` and import it in your application's `AppModule`: +```typescript +import { IgxNavigationDrawerModule } from 'igniteui-angular/main'; +``` +or +```typescript +import { IgxNavigationDrawerModule } from 'igniteui-angular/navigation-drawer'; +``` +And include it in the app module: +```typescript +@NgModule({ + imports: [ + IgxNavigationDrawerModule, + ... + ] +}) +export class AppModule { +} +``` + +> Alternatively both barrels export the `IgxNavigationDrawerComponent` and additional directives, so those can be declared/referenced separately if needed. + +## Usage + +With the dependencies imported, the Navigation Drawer can be defined in the app component template: + +```html + + + +``` +The content for the drawer should be provided via `` decorated with `igxDrawer` directive: +```html + + + Drawer Title + Item + Selected Item + + +``` +> An additional template decorated with `igxDrawerMini` directive can be provided for the alternative [Mini variant](#mini-variant) as closed state. + +While any content can be provided in the template, the [`igxDrawerItem`](#item-styling) directive is available to apply out-of-the-box styling to items. The `igxRipple` directive completes the look and feel. + +The navigation drawer can either sit above content or be pinned alongside it and by default will switch between those depending the view size. See [Modes](#modes) for more. + +## API + +### Inputs +| Name | Type| Description | +|:----------|:----:|:------| +| `id`| string | Unique identifier of the Grid. ID required to register with provided `IgxNavigationService` allow directives to target the control from other template files. | +| `position` | string | Position of the Navigation Drawer. Can be "left"(default) or "right". Only has effect when not pinned.| +| `enableGestures`| boolean | Enables the use of touch gestures to manipulate the drawer - such as swipe/pan from edge to open, swipe toggle and pan drag. | +| `isOpen` | boolean | State of the drawer. | +| `pin` | boolean | When pinned the drawer is relatively positioned instead of sitting above content. May require additional layout styling. | +| `pinThreshold` | number | Minimum device width required for automatic pin to be toggled. Default is 1024, can be set to a falsy value to disable this behavior. | +| `width` | string| Width of the drawer in its open state. Defaults to "280px".| +| `miniWidth` | string | Width of the drawer in its mini variant. Defaults to "60px". | +| `disableAnimation` | boolean | Enables/disables the animation, when toggling the drawer. Set to `false` by default. + +### Outputs +| Name | Description | +|:----------|:------| +| `pinChange` | Pinned state change output for two-way binding of the pin property. Example ` ..` | +| *Event emitters* | *Notify for a change* | +| `opening` | Event fired as the Navigation Drawer is about to open. | +| `opened` | Event fired when the Navigation Drawer has opened. | +| `closing` | Event fired as the Navigation Drawer is about to close. | +| `closed` | Event fired when the Navigation Drawer has closed. | + +### Methods +| Signature | Description | +|:----------|:------| +| `open` | Open the Navigation Drawer. Has no effect if already opened. Returns `Promise` that is resolved once the operation completes. | +| `close` | Close the Navigation Drawer. Has no effect if already closed. Returns `Promise` that is resolved once the operation completes. | +| `toggle` | Toggle the open state of the Navigation Drawer. Returns `Promise` that is resolved once the operation completes. | + + +## Modes + +Unpinned (elevated above content) mode is the normal behavior where the drawer sits above and applies a darkened overlay over all content. Generally used to provide a temporary navigation suitable for mobile devices. + +The drawer can be pinned to take advantage of larger screens, placing it within normal content flow with relative position. Depending on whether the app provides a way to toggle the drawer, the pinned mode can be used to achieve either [permanent or persistent behavior](https://material.io/guidelines/patterns/navigation-drawer.html#navigation-drawer-behavior). + +> By default the Navigation Drawer is responsive, actively changing between unpinned and pinned mode based on screen size. This behavior is controlled by the `pinThreshold` property and can be disabled by setting a falsy value (e.g. 0). + + +### Pinned (persistent) setup +Pin changes the position of the drawer from `fixed` to `relative` to put it on the same flow as content. Therefore, the app styling should account for such layout, especially if the drawer needs to be toggled in this mode. While there's more than one way to achieve such fluid layout (including programmatically), the easiest way is using `igxLayout` and `igxFlex` directives: + +```html +
    + +
    + +
    +
    +``` +```css +.main { + width: 100%; +} +``` +The drawer applies `flex-basis` on its host element, allowing the rest of the content to take up the remaining width. +Alternatively, skipping using directives, manual styling can be applied similar to: +```css +.main { + position: absolute; + display: flex; + flex-flow: row nowrap; + top: 0; + right: 0; + bottom: 0; + left: 0; + width: 100%; +} + +.main > * { + width: 100%; +} +``` + +### Mini variant +With the mini variant the Navigation Drawer changes its width instead of closing. +Most commonly used to maintain quick selection available on the side at all times, leaving just the icons. + +This variant is enabled simply by the presence of an alternative mini template marked with `igxDrawerMini`, for example: + +```html + + + Header + + home + Home + + + + + home + + + +``` + +## Item Styling + +The content of the Navigation Drawer can be anything provided by the template, however for scenarios using the standard list of navigation items the optional `igxDrawerItem` directive can be used style them. This will apply default styles and patterns to your items as well as the appropriate theme colors. + +The directive has two `@Input` properties: +- `active` to style an item as selected. +- `isHeader` to style an item as a group header, cannot be active. + +```html + + + Header + Selected Item + +``` +The directive is exported both from the main `IgxNavigationDrawerModule` and separately as `IgxNavDrawerItemDirective`. + +## Example: Use default item styles with Angular Router +To make use of the `igxDrawerItem` directive to style items normally the `active` input should be set, however if the state is controlled externally as is the case with routing. + +Take the following items defined in `app.component.ts` like: + +```typescript +export class AppComponent { + public componentLinks = [ + { + link: "/avatar", + name: "Avatar" + }, + { + link: "/badge", + name: "Badge" + } + // ... + ]; +} +``` +One way to tie in the active state is to directly use the [`routerLinkActive`](https://angular.io/api/router/RouterLinkActive) default functionality and pass the drawer items active class `igx-nav-drawer__item--active`, so the `` template would look like: + +```html + + + + + +``` +This approach, of course, does not affect the actual directive active state and could be affected by styling changes. An alternative would be the more advanced use of `routerLinkActive` where it's assigned to a template variable and the `isActive` can be used for binding: +```html + + + + + +``` diff --git a/projects/igniteui-angular/navigation-drawer/index.ts b/projects/igniteui-angular/navigation-drawer/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/navigation-drawer/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/navigation-drawer/ng-package.json b/projects/igniteui-angular/navigation-drawer/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/navigation-drawer/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/navigation-drawer/src/navigation-drawer/navigation-drawer.component.html b/projects/igniteui-angular/navigation-drawer/src/navigation-drawer/navigation-drawer.component.html new file mode 100644 index 00000000000..c219a5a691d --- /dev/null +++ b/projects/igniteui-angular/navigation-drawer/src/navigation-drawer/navigation-drawer.component.html @@ -0,0 +1,28 @@ + +
    Navigation Drawer
    +
    Start by adding
    +
    <ng-template igxDrawer>
    +
    And some items inside
    +
    Style with igxDrawerItem
    +
    and igxRipple directives
    +
    Disabled Item
    +
    + +
    +
    + +
    diff --git a/projects/igniteui-angular/navigation-drawer/src/navigation-drawer/navigation-drawer.component.spec.ts b/projects/igniteui-angular/navigation-drawer/src/navigation-drawer/navigation-drawer.component.spec.ts new file mode 100644 index 00000000000..6361e9eaa1e --- /dev/null +++ b/projects/igniteui-angular/navigation-drawer/src/navigation-drawer/navigation-drawer.component.spec.ts @@ -0,0 +1,761 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { Component, ElementRef, Renderer2, ViewChild } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { wait } from '../../../test-utils/ui-interactions.spec'; +import { IgxNavigationDrawerComponent } from './navigation-drawer.component'; +import { HammerGesturesManager, IgxNavigationService } from 'igniteui-angular/core'; +import { IgxNavDrawerItemDirective, IgxNavDrawerMiniTemplateDirective, IgxNavDrawerTemplateDirective } from './navigation-drawer.directives'; +import { IgxNavbarComponent } from 'igniteui-angular/navbar'; +import { IgxFlexDirective, IgxLayoutDirective } from 'igniteui-angular/directives'; + +// HammerJS simulator from https://github.com/hammerjs/simulator, manual typings TODO +declare let Simulator: any; + +describe('Navigation Drawer', () => { + let widthSpyOverride: jasmine.Spy; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + TestComponentPinComponent, + TestComponentMiniComponent, + TestComponent, + TestComponentDIComponent + ], + providers: [ + IgxNavigationDrawerComponent, + { provide: ElementRef, useValue: null }, + Renderer2, + HammerGesturesManager + ] + }).compileComponents(); + + // Using Window through DI causes AOT error (https://github.com/angular/angular/issues/15640) + // so for tests just force override the the `getWindowWidth` + widthSpyOverride = spyOn(IgxNavigationDrawerComponent.prototype as any, 'getWindowWidth') + .and.returnValue(915 /* chosen at random by fair dice roll*/); + })); + + it('should initialize without DI service', waitForAsync(() => { + TestBed.compileComponents().then(() => { + const fixture = TestBed.createComponent(TestComponent); + fixture.detectChanges(); + expect(fixture.componentInstance.navDrawer instanceof + IgxNavigationDrawerComponent).toBeTruthy(); + expect(fixture.componentInstance.navDrawer.state).not.toBeNull(); + }); + })); + + it('should initialize with DI service', waitForAsync(() => { + TestBed.compileComponents().then(() => { + const fixture = TestBed.createComponent(TestComponentDIComponent); + fixture.detectChanges(); + + expect(fixture.componentInstance.navDrawer).toBeDefined(); + expect(fixture.componentInstance.navDrawer instanceof + IgxNavigationDrawerComponent).toBeTruthy(); + expect(fixture.componentInstance.navDrawer.state instanceof IgxNavigationService) + .toBeTruthy(); + }); + })); + + it('should initialize with pinThreshold disabled', waitForAsync(() => { + const template = ``; + TestBed.overrideComponent(TestComponent, { set: { template } }); + TestBed.compileComponents().then(() => { + const fixture = TestBed.createComponent(TestComponent); + fixture.detectChanges(); + + expect(fixture.componentInstance.navDrawer).toBeDefined(); + expect(fixture.componentInstance.navDrawer instanceof + IgxNavigationDrawerComponent).toBeTruthy(); + expect(() => fixture.destroy()).not.toThrow(); + }); + })); + + it('should properly initialize all elements and properties', waitForAsync(() => { + TestBed.compileComponents().then(() => { + const fixture = TestBed.createComponent(TestComponentDIComponent); + fixture.detectChanges(); + const domNavDrawer = fixture.debugElement.query(By.css('igx-nav-drawer')).nativeElement; + + expect(fixture.componentInstance.navDrawer.id).toContain('igx-nav-drawer-'); + expect(domNavDrawer.id).toContain('igx-nav-drawer-'); + expect(fixture.componentInstance.navDrawer.drawer.classList).toContain('igx-nav-drawer__aside'); + expect(fixture.componentInstance.navDrawer.overlay.classList).toContain('igx-nav-drawer__overlay'); + expect(fixture.componentInstance.navDrawer.styleDummy.classList).toContain('igx-nav-drawer__style-dummy'); + expect(fixture.componentInstance.navDrawer.hasAnimateWidth).toBeFalsy(); + + fixture.componentInstance.navDrawer.id = 'customNavDrawer'; + fixture.detectChanges(); + + expect(fixture.componentInstance.navDrawer.id).toBe('customNavDrawer'); + expect(domNavDrawer.id).toBe('customNavDrawer'); + + }).catch((reason) => Promise.reject(reason)); + })); + + it('should attach events and register to nav service and detach on destroy', waitForAsync(() => { + const template = ''; + TestBed.overrideComponent(TestComponentDIComponent, { + set: { + template + } + }); + + // compile after overrides, not in before each: https://github.com/angular/angular/issues/10712 + TestBed.compileComponents().then(() => { + const fixture = TestBed.createComponent(TestComponentDIComponent); + fixture.detectChanges(); + const state: IgxNavigationService = fixture.componentInstance.navDrawer.state; + const touchManager = fixture.componentInstance.navDrawer.touchManager; + + expect(state.get('testNav')).toBeDefined(); + expect(touchManager.getManagerForElement(document) instanceof Hammer.Manager).toBeTruthy(); + + fixture.destroy(); + expect(state.get('testNav')).toBeUndefined(); + expect(touchManager.getManagerForElement(document)).toBe(null); + + }).catch((reason) => Promise.reject(reason)); + })); + + it('should open and close with API calls', waitForAsync(() => { + TestBed.compileComponents().then(() => { + const fixture = TestBed.createComponent(TestComponentDIComponent); + fixture.detectChanges(); + const drawer: IgxNavigationDrawerComponent = fixture.componentInstance.navDrawer; + expect(drawer.isOpen).toBeFalsy(); + + drawer.open(); + expect(drawer.isOpen).toBeTruthy(); + drawer.open(); // should do nothing + expect(drawer.isOpen).toBeTruthy(); + + drawer.close(); + expect(drawer.isOpen).toBeFalsy(); + drawer.close(); // should do nothing + expect(drawer.isOpen).toBeFalsy(); + + drawer.toggle(); + expect(drawer.isOpen).toBeTruthy(); + drawer.toggle(); + expect(drawer.isOpen).toBeFalsy(); + + }).catch((reason) => Promise.reject(reason)); + })); + + it('async API calls should emit events', waitForAsync(() => { + let fixture: ComponentFixture; + let drawer; + + // compile after overrides, not in before each: https://github.com/angular/angular/issues/10712 + TestBed.compileComponents().then(() => { + fixture = TestBed.createComponent(TestComponentDIComponent); + fixture.detectChanges(); + drawer = fixture.componentInstance.navDrawer; + + spyOn(drawer.closing, 'emit'); + spyOn(drawer.closed, 'emit'); + spyOn(drawer.opening, 'emit'); + spyOn(drawer.opened, 'emit'); + + const _re = drawer.open(true); + fixture.detectChanges(); + fixture.debugElement.children[0].nativeElement.dispatchEvent(new Event('transitionend')); + }) + .then(() => { + expect(drawer.opening.emit).toHaveBeenCalled(); + expect(drawer.opened.emit).toHaveBeenCalled(); + + const _re = drawer.toggle(true); + fixture.detectChanges(); + fixture.debugElement.children[0].nativeElement.dispatchEvent(new Event('transitionend')); + }) + .then(() => { + expect(drawer.closing.emit).toHaveBeenCalled(); + expect(drawer.closed.emit).toHaveBeenCalled(); + // resolver(); + }); + })); + + it('should properly initialize with min template', waitForAsync(() => { + const template = ` + + + `; + TestBed.overrideComponent(TestComponentDIComponent, { + set: { + template, + imports: [IgxNavigationDrawerComponent, IgxNavDrawerTemplateDirective, IgxNavDrawerMiniTemplateDirective] + } + }); + + // compile after overrides, not in before each: https://github.com/angular/angular/issues/10712 + TestBed.compileComponents().then(() => { + const fixture = TestBed.createComponent(TestComponentDIComponent); + fixture.detectChanges(); + + expect(fixture.componentInstance.navDrawer.hasAnimateWidth).toBeTruthy(); + expect(fixture.debugElement.query(By.css('.igx-nav-drawer__aside')).nativeElement.classList) + .toContain('igx-nav-drawer__aside--mini'); + }).catch((reason) => Promise.reject(reason)); + })); + + it('should update with dynamic min template', waitForAsync(() => { + + // immediate requestAnimationFrame for testing + spyOn(window, 'requestAnimationFrame').and.callFake(callback => { + callback(0); return 0; + }); + const template = ` + + @if (miniView) { } + `; + TestBed.overrideComponent(TestComponentMiniComponent, { + set: { + template, + imports: [IgxNavigationDrawerComponent, IgxNavDrawerTemplateDirective, IgxNavDrawerMiniTemplateDirective] + } + }); + let fixture; + let asideElem; + let cssProp; + + // compile after overrides, not in before each: https://github.com/angular/angular/issues/10712 + TestBed.compileComponents().then(() => { + fixture = TestBed.createComponent(TestComponentMiniComponent); + fixture.detectChanges(); + asideElem = fixture.debugElement.query(By.css('.igx-nav-drawer__aside--mini')); + cssProp = getComputedStyle(asideElem.nativeElement).getPropertyValue('--igx-nav-drawer-size--mini'); + + expect(cssProp).toEqual('56px'); + + fixture.componentInstance.miniView = false; + fixture.detectChanges(); + + expect(asideElem.styles['width']).toBeFalsy(); + fixture.componentInstance.miniView = true; + fixture.detectChanges(); + + expect(cssProp).toEqual(fixture.componentInstance.navDrawer.miniWidth); + }).catch((reason) => Promise.reject(reason)); + })); + + it('should set pin, gestures options', waitForAsync(() => { + const template = ` + `; + TestBed.overrideComponent(TestComponentPinComponent, { + set: { + template, + imports: [IgxNavigationDrawerComponent, IgxNavDrawerTemplateDirective, IgxNavDrawerMiniTemplateDirective] + } + }); + + // compile after overrides, not in before each: https://github.com/angular/angular/issues/10712 + TestBed.compileComponents().then(() => { + const fixture = TestBed.createComponent(TestComponentPinComponent); + fixture.detectChanges(); + + expect(fixture.componentInstance.navDrawer.pin).toBeTruthy(); + expect(fixture.debugElement.query(By.css('.igx-nav-drawer__aside')).nativeElement.classList) + .toContain('igx-nav-drawer__aside--pinned'); + + expect(fixture.componentInstance.navDrawer.enableGestures).toBe(false); + + fixture.componentInstance.enableGestures = true; + fixture.detectChanges(); + expect(fixture.componentInstance.navDrawer.enableGestures).toBeTruthy(); + + }).catch((reason) => Promise.reject(reason)); + })); + + it('should stay at 100% parent height when pinned', waitForAsync(() => { + const template = `
    + + +
    `; + + TestBed.overrideComponent(TestComponentPinComponent, { set: { template } }); + TestBed.compileComponents() + .then(() => { + document.body.style.overflow = 'hidden'; + const fixture = TestBed.createComponent(TestComponentPinComponent); + fixture.detectChanges(); + const windowHeight = window.innerHeight; + const container = fixture.debugElement.query(By.css('div')).nativeElement; + const navdrawer = fixture.debugElement.query(By.css('igx-nav-drawer > .igx-nav-drawer__aside')).nativeElement; + + fixture.componentInstance.pin = false; + fixture.detectChanges(); + expect(navdrawer.clientHeight).toEqual(windowHeight); + + fixture.componentInstance.pin = true; + fixture.detectChanges(); + expect(navdrawer.clientHeight).toEqual(container.clientHeight); + + container.style.height = `${windowHeight - 50}px`; + fixture.detectChanges(); + expect(navdrawer.clientHeight).toEqual(windowHeight - 50); + + // unpin : + fixture.componentInstance.pin = false; + fixture.detectChanges(); + expect(navdrawer.clientHeight).toEqual(windowHeight); + }); + })); + + it('should set flex-basis and order when pinned', waitForAsync(() => { + const template = ``; + TestBed.overrideComponent(TestComponentPinComponent, { set: { template } }); + let fixture: ComponentFixture; + TestBed.compileComponents() + .then(() => { + fixture = TestBed.createComponent(TestComponentPinComponent); + const drawer = fixture.componentInstance.navDrawer; + drawer.isOpen = true; + fixture.detectChanges(); + const drawerElem = fixture.debugElement.query((x) => x.nativeNode.nodeName === 'IGX-NAV-DRAWER').nativeElement; + const flexBasis = getComputedStyle(drawerElem).getPropertyValue('flex-basis').trim(); + + expect(drawer.pin).toBeTruthy(); + expect(flexBasis).toEqual('240px'); + expect(drawerElem.style.order).toEqual('0'); + + drawer.width = '345px'; + drawer.position = 'right'; + fixture.detectChanges(); + + // Adjusting for transition duration + return new Promise(resolve => setTimeout(resolve, 350)); + }).then(()=> { + const drawer = fixture.componentInstance.navDrawer; + + const drawerElem = fixture.debugElement.query(By.directive(IgxNavigationDrawerComponent)).nativeElement; + const flexBasis = getComputedStyle(drawerElem).getPropertyValue('flex-basis').trim(); + + expect(flexBasis).toEqual('345px'); + expect(drawerElem.style.order).toEqual('1'); + + fixture.componentInstance.pin = false; + fixture.detectChanges(); + expect(drawer.pin).toBeFalsy(); + + // Adjusting for transition duration + return new Promise(resolve => setTimeout(resolve, 350)); + }).then(()=> { + const drawerElem = fixture.debugElement.query(By.directive(IgxNavigationDrawerComponent)).nativeElement; + const flexBasis = getComputedStyle(drawerElem).getPropertyValue('flex-basis').trim(); + + expect(flexBasis).toEqual('0px'); + expect(drawerElem.style.order).toEqual('0'); + }); + })); + + it('should toggle on edge swipe gesture', (done) => { + let fixture: ComponentFixture; + + TestBed.compileComponents().then(() => { + fixture = TestBed.createComponent(TestComponentDIComponent); + fixture.detectChanges(); + expect(fixture.componentInstance.navDrawer.isOpen).toEqual(false); + + // timeouts are +50 on the gesture to allow the swipe to be detected and triggered after the touches: + return swipe(document.body, 80, 10, 100, 250, 0); + }) + .then(() => { + expect(fixture.componentInstance.navDrawer.isOpen) + .toEqual(false, 'should ignore swipes too far away from the edge'); + return swipe(document.body, 10, 10, 150, 250, 0); + }) + .then(() => { + expect(fixture.componentInstance.navDrawer.isOpen).toEqual(true, 'Should accept edge swipe'); + return swipe(document.body, 180, 10, 150, -180, 0); + }) + .then(() => { + expect(fixture.componentInstance.navDrawer.isOpen).toEqual(false); + done(); + }) + .catch(() => { + done(); + }); + }, 10000); + + it('should toggle on edge pan gesture', (done) => { + let navDrawer; + let fixture: ComponentFixture; + + // Using bare minimum of timeouts, jasmine.DEFAULT_TIMEOUT_INTERVAL can be modified only in beforeEach + TestBed.compileComponents().then(() => { + fixture = TestBed.createComponent(TestComponentDIComponent); + fixture.detectChanges(); + navDrawer = fixture.componentInstance.navDrawer; + navDrawer.width = '280px'; + navDrawer.miniWidth = '68px'; + fixture.detectChanges(); + + expect(fixture.componentInstance.navDrawer.isOpen).toEqual(false); + + const listener = navDrawer.renderer.listen(document.body, 'panmove', () => { + + // mid gesture + expect(navDrawer.drawer.classList).toContain('panning'); + expect(navDrawer.drawer.style.transform) + .toMatch(/translate3d\(-2\d\dpx, 0px, 0px\)/, 'Drawer should be moving with the pan'); + listener(); + }); + + return pan(document.body, 10, 10, 150, 20, 0); + }) + .then(() => { + expect(navDrawer.isOpen).toEqual(false, 'should ignore too short pan'); + + // valid pan + return pan(document.body, 10, 10, 100, 200, 0); + }).then(() => { + expect(navDrawer.isOpen).toEqual(true, 'should open on valid pan'); + + // not enough distance, closing + return pan(document.body, 200, 10, 100, -20, 0); + }).then(() => { + expect(navDrawer.isOpen).toEqual(true, 'should remain open on too short pan'); + + // close + return pan(document.body, 250, 10, 100, -200, 0); + }).then(() => { + expect(navDrawer.isOpen).toEqual(false, 'should close on valid pan'); + done(); + }).catch(() => { + done(); + }); + }, 10000); + + it('should update edge zone with mini width', waitForAsync(() => { + const template = ` + + + `; + let fixture: ComponentFixture; + TestBed.overrideComponent(TestComponentDIComponent, { + set: { + template, + imports: [IgxNavigationDrawerComponent, IgxNavDrawerTemplateDirective, IgxNavDrawerMiniTemplateDirective] + } + }); + + // compile after overrides, not in before each: https://github.com/angular/angular/issues/10712 + TestBed.compileComponents().then(() => { + fixture = TestBed.createComponent(TestComponentDIComponent); + fixture.detectChanges(); + + fixture.componentInstance.drawerMiniWidth = 68; + fixture.detectChanges(); + expect(fixture.componentInstance.navDrawer.maxEdgeZone) + .toBe(fixture.componentInstance.drawerMiniWidth * 1.1); + + fixture.componentInstance.drawerMiniWidth = 80; + fixture.detectChanges(); + expect(fixture.componentInstance.navDrawer.maxEdgeZone) + .toBe(fixture.componentInstance.drawerMiniWidth * 1.1); + + }).catch((reason) => Promise.reject(reason)); + })); + + it('should update width from css or property', async () => { + const template = ` + + + `; + TestBed.overrideComponent(TestComponentDIComponent, { + set: { + template, + imports: [IgxNavigationDrawerComponent, IgxNavDrawerTemplateDirective, IgxNavDrawerMiniTemplateDirective] + } + }); + + // compile after overrides, not in before each: https://github.com/angular/angular/issues/10712 + await TestBed.compileComponents(); + const fixture: ComponentFixture = TestBed.createComponent(TestComponentDIComponent); + fixture.detectChanges(); + + // const comp: DebugElement = fixture.debugElement.query(By.component(IgxNavbarComponent)); + const asideElem = fixture.debugElement.query(By.css('igx-nav-drawer > .igx-nav-drawer__aside')).nativeElement; + + const computedStyle = window.getComputedStyle(asideElem); + let asideWidth = computedStyle.getPropertyValue('width'); + + // Default sizes: + // Mini default: + expect(asideWidth).toBe('56px'); + + fixture.componentInstance.navDrawer.open(); + fixture.detectChanges(); + + await wait(350); + asideWidth = computedStyle.getPropertyValue('width'); + + // Standard default: + expect(asideWidth).toBe('240px'); + + // Change sizes: + fixture.componentInstance.drawerMiniWidth = '80px'; + fixture.componentInstance.drawerWidth = '250px'; + + fixture.detectChanges(); + + await wait(350); + asideWidth = computedStyle.getPropertyValue('width'); + + expect(asideWidth).toBe('250px'); + + fixture.componentInstance.navDrawer.close(); + fixture.detectChanges(); + + await wait(350); + asideWidth = computedStyle.getPropertyValue('width'); + + expect(asideWidth).toBe('80px'); + + fixture.componentInstance.drawerWidth = '350px'; + fixture.componentInstance.navDrawer.open(); + fixture.detectChanges(); + + await wait(350); + asideWidth = computedStyle.getPropertyValue('width'); + + expect(asideWidth).toBe('350px'); + }); + + it('should update pin based on window width (pinThreshold)', async () => { + const template = `''`; + TestBed.overrideComponent(TestComponentPinComponent, { + set: { + template + } + }); + + // compile after overrides, not in before each: https://github.com/angular/angular/issues/10712 + await TestBed.compileComponents(); + const fixture: ComponentFixture = TestBed.createComponent(TestComponentPinComponent); + + // watch for initial pin with 2-way bind expression changed errors + expect(() => fixture.detectChanges()).not.toThrow(); + await fixture.whenStable(); + + // defaults: + expect(fixture.componentInstance.navDrawer.pin).toBe(false, 'Should be initially unpinned'); + expect(fixture.componentInstance.pin).toBe(false, 'Parent component pin should update initially'); + + // manual pin override + fixture.componentInstance.pin = true; + fixture.detectChanges(); + window.dispatchEvent(new Event('resize')); + + // wait for debounce + await wait(200); + expect(fixture.componentInstance.navDrawer.pin).toBe(false, `Shouldn't change state on resize if window width is the same`); + expect(fixture.componentInstance.pin).toBe(true, 'Parent component pin remain on resize if window width is the same'); + fixture.componentInstance.pin = true; + fixture.detectChanges(); + + widthSpyOverride.and.returnValue(fixture.componentInstance.pinThreshold); + window.dispatchEvent(new Event('resize')); + + // wait for debounce + await wait(200); + expect(fixture.componentInstance.navDrawer.pin).toBe(true, 'Should pin on window resize over threshold'); + expect(fixture.componentInstance.pin).toBe(true, 'Parent pin update on window resize over threshold'); + + widthSpyOverride.and.returnValue(768); + window.dispatchEvent(new Event('resize')); + + // wait for debounce + await wait(200); + expect(fixture.componentInstance.navDrawer.pin).toBe(false, 'Should un-pin on window resize below threshold'); + expect(fixture.componentInstance.pin).toBe(false, 'Parent pin update on window resize below threshold'); + fixture.componentInstance.pinThreshold = 500; + expect(() => fixture.detectChanges()).not.toThrow(); + await fixture.whenStable(); + expect(fixture.componentInstance.navDrawer.pin).toBe(true, 'Should re-pin on window resize over threshold'); + expect(fixture.componentInstance.pin).toBe(true, 'Parent pin update on re-pin'); + }); + + it('should get correct window width', (done) => { + const originalWidth = window.innerWidth; + const drawer = TestBed.inject(IgxNavigationDrawerComponent); + + // re-enable `getWindowWidth` + const widthSpy = (widthSpyOverride as jasmine.Spy).and.callThrough(); + let width = widthSpy.call(drawer); + expect(width).toEqual(originalWidth); + + (window as any).innerWidth = 0; // not that readonly in Chrome + width = widthSpy.call(drawer); + expect(width).toEqual(screen.width); + (window as any).innerWidth = originalWidth; + done(); + }); + + it('should retain classes added in markup, fix for #6508', () => { + const fix = TestBed.createComponent(TestComponent); + fix.detectChanges(); + + expect(fix.componentInstance.navDrawer.element.classList.contains('markupClass')).toBeTruthy(); + expect(fix.componentInstance.navDrawer.element.classList.contains('igx-nav-drawer')).toBeTruthy(); + }); + + it('should maintain size when mini pinned has `fixed` position', async () => { + const fix = TestBed.createComponent(TestFixedMiniComponent); + fix.detectChanges(); + + fix.componentInstance.navDrawer.pin = true; + fix.detectChanges(); + + // Account for transition duration + await wait(350); + + const drawerEl = fix.debugElement.query(By.directive(IgxNavigationDrawerComponent)).nativeElement; + const navbarEl = fix.debugElement.query(By.directive(IgxNavbarComponent)).nativeElement; + + let flexBasis = getComputedStyle(drawerEl).width; + + // Mini variant pinned by default + expect(parseInt(flexBasis)).toBeGreaterThan(0); + expect(navbarEl.offsetLeft).toEqual(parseInt(flexBasis)); + + fix.componentInstance.navDrawer.toggle(); + fix.detectChanges(); + + // Account for transition duration + await wait(350); + + flexBasis = getComputedStyle(drawerEl).getPropertyValue('flex-basis'); + + expect(flexBasis).toEqual('240px');; + expect(navbarEl.offsetLeft).toEqual(parseInt(flexBasis)); + }); + + const swipe = (element, posX, posY, duration, deltaX, deltaY) => { + const swipeOptions = { + deltaX, + deltaY, + duration, + pos: [posX, posY] + }; + + return new Promise(resolve => { + + // force touch (https://github.com/hammerjs/hammer.js/issues/1065) + Simulator.setType('touch'); + Simulator.gestures.swipe(element, swipeOptions, () => { + resolve(); + }); + }); + }; + + const pan = (element, posX, posY, duration, deltaX, deltaY) => { + const swipeOptions = { + deltaX, + deltaY, + duration, + pos: [posX, posY] + }; + + return new Promise(resolve => { + + // force touch (https://github.com/hammerjs/hammer.js/issues/1065) + Simulator.setType('touch'); + Simulator.gestures.pan(element, swipeOptions, () => { + resolve(); + }); + }); + }; +}); + +@Component({ + selector: 'igx-test-cmp', + template: '', + imports: [IgxNavigationDrawerComponent] +}) +class TestComponent { + @ViewChild(IgxNavigationDrawerComponent, { static: true }) public navDrawer: IgxNavigationDrawerComponent; +} + +@Component({ + providers: [IgxNavigationService], + selector: 'igx-test-cmp-di', + template: '', + imports: [IgxNavigationDrawerComponent] +}) +class TestComponentDIComponent { + @ViewChild(IgxNavigationDrawerComponent, { static: true }) public navDrawer: IgxNavigationDrawerComponent; + public drawerMiniWidth: string | number; + public drawerWidth: string | number; +} + +@Component({ + selector: 'igx-test-cmp-pin', + providers: [IgxNavigationService], + template: '', + imports: [IgxNavigationDrawerComponent] +}) +class TestComponentPinComponent extends TestComponentDIComponent { + public pin = true; + public enableGestures = false; + public pinThreshold = 1024; +} + +@Component({ + selector: 'igx-test-cmp-mini', + providers: [IgxNavigationService], + template: '', + imports: [IgxNavigationDrawerComponent] +}) +class TestComponentMiniComponent extends TestComponentDIComponent { + public miniView = true; +} + +@Component({ + selector: 'igx--test-fixed-mini', + providers: [IgxNavigationService], + imports: [ + IgxNavigationDrawerComponent, + IgxNavDrawerTemplateDirective, + IgxNavDrawerMiniTemplateDirective, + IgxNavDrawerItemDirective, + IgxNavbarComponent, + IgxFlexDirective, + IgxLayoutDirective + ], + styles: ` + .igx-nav-drawer__aside--pinned { + position: fixed; + } + `, + template: ` +
    + + + Item + + + @if (nav.pin) { + + + + } + + +
    + + +
    +
    +
    + ` +}) +class TestFixedMiniComponent extends TestComponentDIComponent { } diff --git a/projects/igniteui-angular/navigation-drawer/src/navigation-drawer/navigation-drawer.component.ts b/projects/igniteui-angular/navigation-drawer/src/navigation-drawer/navigation-drawer.component.ts new file mode 100644 index 00000000000..caab69a8c45 --- /dev/null +++ b/projects/igniteui-angular/navigation-drawer/src/navigation-drawer/navigation-drawer.component.ts @@ -0,0 +1,839 @@ +import { AfterContentInit, Component, ContentChild, ElementRef, EventEmitter, HostBinding, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChange, ViewChild, Renderer2, booleanAttribute, inject } from '@angular/core'; +import { fromEvent, interval, Subscription } from 'rxjs'; +import { debounce } from 'rxjs/operators'; +import { IgxNavigationService, IToggleView } from 'igniteui-angular/core'; +import { HammerGesturesManager } from 'igniteui-angular/core'; +import { IgxNavDrawerMiniTemplateDirective, IgxNavDrawerTemplateDirective, IgxNavDrawerItemDirective } from './navigation-drawer.directives'; +import { PlatformUtil } from 'igniteui-angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import { HammerInput } from 'igniteui-angular/core'; + +let NEXT_ID = 0; +/** + * **Ignite UI for Angular Navigation Drawer** - + * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/navdrawer) + * + * The Ignite UI Navigation Drawer is a collapsible side navigation container commonly used in combination with the Navbar. + * + * Example: + * ```html + * + * + * + * + * + * ``` + */ +@Component({ + providers: [HammerGesturesManager], + selector: 'igx-nav-drawer', + templateUrl: 'navigation-drawer.component.html', + styles: [` + :host { + display: block; + height: 100%; + } + `], + imports: [IgxNavDrawerItemDirective, NgTemplateOutlet] +}) +export class IgxNavigationDrawerComponent implements + IToggleView, + OnInit, + AfterContentInit, + OnDestroy, + OnChanges { + private elementRef = inject(ElementRef); + private _state = inject(IgxNavigationService, { optional: true }); + protected renderer = inject(Renderer2); + private _touchManager = inject(HammerGesturesManager); + private platformUtil = inject(PlatformUtil); + + + /** @hidden @internal */ + @HostBinding('class.igx-nav-drawer') + public cssClass = true; + + /** + * ID of the component + * + * ```typescript + * // get + * let myNavDrawerId = this.navdrawer.id; + * ``` + * + * ```html + * + * + * ``` + */ + @HostBinding('attr.id') + @Input() public id = `igx-nav-drawer-${NEXT_ID++}`; + + /** + * Position of the Navigation Drawer. Can be "left"(default) or "right". + * + * ```typescript + * // get + * let myNavDrawerPosition = this.navdrawer.position; + * ``` + * + * ```html + * + * + * ``` + */ + @Input() public position = 'left'; + + /** + * Enables the use of touch gestures to manipulate the drawer: + * - swipe/pan from edge to open, swipe-toggle and pan-drag. + * + * ```typescript + * // get + * let gesturesEnabled = this.navdrawer.enableGestures; + * ``` + * + * ```html + * + * + * ``` + */ + @Input({ transform: booleanAttribute }) public enableGestures = true; + + /** + * @hidden + */ + @Output() public isOpenChange = new EventEmitter(); + + /** + * Minimum device width required for automatic pin to be toggled. + * Default is 1024, can be set to a falsy value to disable this behavior. + * + * ```typescript + * // get + * let navDrawerPinThreshold = this.navdrawer.pinThreshold; + * ``` + * + * ```html + * + * + * ``` + */ + @Input() public pinThreshold = 1024; + + /** + * When pinned the drawer is relatively positioned instead of sitting above content. + * May require additional layout styling. + * + * ```typescript + * // get + * let navDrawerIsPinned = this.navdrawer.pin; + * ``` + * + * ```html + * + * + * ``` + */ + @Input({ transform: booleanAttribute }) public pin = false; + + /** + * Width of the drawer in its open state. + * + * ```typescript + * // get + * let navDrawerWidth = this.navdrawer.width; + * ``` + * + * ```html + * + * + * ``` + */ + private _width: string; + + @Input() + public get width() { + return this._width; + } + public set width(value: string) { + this._width = value; + } + + + /** + * Enables/disables the animation, when toggling the drawer. Set to `false` by default. + * ````html + * + * ```` + */ + @HostBinding('class.igx-nav-drawer--disable-animation') + @Input({ transform: booleanAttribute }) public disableAnimation = false; + + /** + * Width of the drawer in its mini state. + * + * ```typescript + * // get + * let navDrawerMiniWidth = this.navdrawer.miniWidth; + * ``` + * + * ```html + * + * + * ``` + */ + @Input() public miniWidth: string; + + /** + * Pinned state change output for two-way binding. + * + * ```html + * + * ``` + */ + @Output() public pinChange = new EventEmitter(true); + /** + * Event fired as the Navigation Drawer is about to open. + * + * ```html + * + * ``` + */ + @Output() public opening = new EventEmitter(); + /** + * Event fired when the Navigation Drawer has opened. + * + * ```html + * + * ``` + */ + @Output() public opened = new EventEmitter(); + /** + * Event fired as the Navigation Drawer is about to close. + * + * ```html + * + * ``` + */ + @Output() public closing = new EventEmitter(); + /** + * Event fired when the Navigation Drawer has closed. + * + * ```html + * + * ``` + */ + @Output() public closed = new EventEmitter(); + + /** + * @hidden + */ + @ContentChild(IgxNavDrawerTemplateDirective, { read: IgxNavDrawerTemplateDirective }) + protected contentTemplate: IgxNavDrawerTemplateDirective; + + @ViewChild('aside', { static: true }) private _drawer: ElementRef; + @ViewChild('overlay', { static: true }) private _overlay: ElementRef; + @ViewChild('dummy', { static: true }) private _styleDummy: ElementRef; + + private _isOpen = false; + + /** + * State of the drawer. + * + * ```typescript + * // get + * let navDrawerIsOpen = this.navdrawer.isOpen; + * ``` + * + * ```html + * + * + * ``` + * + * Two-way data binding. + * ```html + * + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public get isOpen() { + return this._isOpen; + } + public set isOpen(value) { + this._isOpen = value; + this.isOpenChange.emit(this._isOpen); + } + + /** + * Returns nativeElement of the component. + * + * @hidden + */ + public get element() { + return this.elementRef.nativeElement; + } + + /** + * @hidden + */ + public get template() { + if (this.miniTemplate && !this.isOpen) { + return this.miniTemplate.template; + } else if (this.contentTemplate) { + return this.contentTemplate.template; + } + } + + private _miniTemplate: IgxNavDrawerMiniTemplateDirective; + /** + * @hidden + */ + public get miniTemplate(): IgxNavDrawerMiniTemplateDirective { + return this._miniTemplate; + } + + /** + * @hidden + */ + @ContentChild(IgxNavDrawerMiniTemplateDirective, { read: IgxNavDrawerMiniTemplateDirective }) + public set miniTemplate(v: IgxNavDrawerMiniTemplateDirective) { + this._miniTemplate = v; + } + + /** @hidden @internal */ + @HostBinding('class.igx-nav-drawer--mini') + public get isMini(): boolean { + return !!this._miniTemplate && !this.isOpen; + } + + /** @hidden @internal */ + @HostBinding('class.igx-nav-drawer--pinned') + public get pinned(): boolean { + return !!this.pin; + } + + /** + * @hidden + */ + @HostBinding('style.--igx-nav-drawer-size') + public get normalSize() { + if (!this.isOpen) { + return '0px'; + } + + return this.width; + } + + /** + * @hidden + */ + @HostBinding('style.--igx-nav-drawer-size--mini') + public get miniSize() { + if (this.miniTemplate && this.miniWidth) { + return this.miniWidth; + } + } + + /** @hidden */ + @HostBinding('style.order') + public get isPinnedRight() { + return this.pin && this.position === 'right' ? '1' : '0'; + } + + private _gesturesAttached = false; + private _widthCache: { width: number; miniWidth: number; windowWidth: number } = { width: null, miniWidth: null, windowWidth: null }; + private _resizeObserver: Subscription; + private css: { [name: string]: string } = { + drawer: 'igx-nav-drawer__aside', + mini: 'igx-nav-drawer__aside--mini', + overlay: 'igx-nav-drawer__overlay', + styleDummy: 'igx-nav-drawer__style-dummy' + }; + + /** + * @hidden + */ + public get drawer() { + return this._drawer.nativeElement; + } + + /** + * @hidden + */ + public get overlay() { + return this._overlay.nativeElement; + } + + /** + * @hidden + */ + public get styleDummy() { + return this._styleDummy.nativeElement; + } + + /** Pan animation properties */ + private _panning = false; + private _panStartWidth: number; + private _panLimit: number; + + /** + * Property to decide whether to change width or translate the drawer from pan gesture. + * + * @hidden + */ + public get hasAnimateWidth(): boolean { + return this.pin || !!this.miniTemplate; + } + + private _maxEdgeZone = 50; + /** + * Used for touch gestures (swipe and pan). + * Defaults to 50 (in px) and is extended to at least 110% of the mini template width if available. + * + * @hidden + */ + public get maxEdgeZone() { + return this._maxEdgeZone; + } + + /** + * Gets the Drawer width for specific state. + * Will attempt to evaluate requested state and cache. + * + * + * @hidden + */ + public get expectedWidth() { + return this.getExpectedWidth(false); + } + + /** + * Get the Drawer mini width for specific state. + * Will attempt to evaluate requested state and cache. + * + * @hidden + */ + public get expectedMiniWidth() { + return this.getExpectedWidth(true); + } + + /** + * @hidden + */ + public get touchManager() { + return this._touchManager; + } + + /** + * Exposes optional navigation service + * + * @hidden + */ + public get state() { + return this._state; + } + + /** + * @hidden + */ + public ngOnInit() { + // DOM and @Input()-s initialized + if (this._state) { + this._state.add(this.id, this); + } + } + + /** + * @hidden + */ + public ngAfterContentInit() { + // wait for template and ng-content to be ready + this.updateEdgeZone(); + this.checkPinThreshold(); + + this.ensureEvents(); + + // TODO: apply platform-safe Ruler from http://plnkr.co/edit/81nWDyreYMzkunihfRgX?p=preview + // (https://github.com/angular/angular/issues/6515), blocked by https://github.com/angular/angular/issues/6904 + } + + /** + * @hidden + */ + public ngOnDestroy() { + this._touchManager.destroy(); + if (this._state) { + this._state.remove(this.id); + } + if (this._resizeObserver) { + this._resizeObserver.unsubscribe(); + } + } + + /** + * @hidden + */ + public ngOnChanges(changes: { [propName: string]: SimpleChange }) { + // simple settings can come from attribute set (rather than binding), make sure boolean props are converted + if (changes.enableGestures && changes.enableGestures.currentValue !== undefined) { + this.enableGestures = !!(this.enableGestures && this.enableGestures.toString() === 'true'); + this.ensureEvents(); + } + if (changes.pin && changes.pin.currentValue !== undefined) { + this.pin = !!(this.pin && this.pin.toString() === 'true'); + if (this.pin) { + this._touchManager.destroy(); + this._gesturesAttached = false; + } else { + this.ensureEvents(); + } + } + + if (changes.pinThreshold) { + if (this.pinThreshold) { + this.ensureEvents(); + this.checkPinThreshold(); + } + } + + if (changes.miniWidth) { + this.updateEdgeZone(); + } + } + + /** + * Toggle the open state of the Navigation Drawer. + * + * ```typescript + * this.navdrawer.toggle(); + * ``` + */ + public toggle() { + if (this.isOpen) { + this.close(); + } else { + this.open(); + } + } + + /** + * Open the Navigation Drawer. Has no effect if already opened. + * + * ```typescript + * this.navdrawer.open(); + * ``` + */ + public open() { + if (this._panning) { + this.resetPan(); + } + + if (this.isOpen) { + return; + } + + this.opening.emit(); + this.isOpen = true; + + // TODO: Switch to animate API when available + // var animationCss = this.animate.css(); + // animationCss + // .setStyles({'width':'50px'}, {'width':'400px'}) + // .start(this.elementRef.nativeElement) + // .onComplete(() => animationCss.setToStyles({'width':'auto'}).start(this.elementRef.nativeElement)); + + this.elementRef.nativeElement.addEventListener('transitionend', this.toggleOpenedEvent, false); + + requestAnimationFrame(()=>{}); + } + + /** + * Close the Navigation Drawer. Has no effect if already closed. + * + * ```typescript + * this.navdrawer.close(); + * ``` + */ + public close() { + if (this._panning) { + this.resetPan(); + } + + if (!this.isOpen) { + return; + } + + this.closing.emit(); + + this.isOpen = false; + this.elementRef.nativeElement.addEventListener('transitionend', this.toggleClosedEvent, false); + } + + /** + * @hidden + */ + protected set_maxEdgeZone(value: number) { + this._maxEdgeZone = value; + } + + /** + * Get the Drawer width for specific state. Will attempt to evaluate requested state and cache. + * + * @hidden + * @param [mini] - Request mini width instead + */ + protected getExpectedWidth(mini?: boolean): number { + if (mini) { + if (!this.miniTemplate) { + return 0; + } + if (this.miniWidth) { + return parseFloat(this.miniWidth); + } else { + // if (!this.isOpen) { // This WON'T work due to transition timings... + // return this.elementRef.nativeElement.children[1].offsetWidth; + // } else { + if (this._widthCache.miniWidth === null) { + // force class for width calc. TODO? + // force class for width calc. TODO? + this.renderer.addClass(this.styleDummy, this.css.drawer); + this.renderer.addClass(this.styleDummy, this.css.mini); + this._widthCache.miniWidth = this.styleDummy.offsetWidth; + this.renderer.removeClass(this.styleDummy, this.css.drawer); + this.renderer.removeClass(this.styleDummy, this.css.mini); + } + return this._widthCache.miniWidth; + } + } else { + if (this.width) { + return parseFloat(this.width); + } else { + if (this._widthCache.width === null) { + // force class for width calc. TODO? + // force class for width calc. TODO? + this.renderer.addClass(this.styleDummy, this.css.drawer); + this._widthCache.width = this.styleDummy.offsetWidth; + this.renderer.removeClass(this.styleDummy, this.css.drawer); + } + return this._widthCache.width; + } + } + } + + private getWindowWidth() { + return (window.innerWidth > 0) ? window.innerWidth : screen.width; + } + + /** + * Get current Drawer width. + */ + private getDrawerWidth(): number { + return this.drawer.offsetWidth; + } + + private ensureEvents() { + // set listeners for swipe/pan only if needed, but just once + if (this.enableGestures && !this.pin && !this._gesturesAttached) { + // Built-in manager handler(L20887) causes endless loop and max stack exception. + // https://github.com/angular/angular/issues/6993 + // Use ours for now (until beta.10): + // this.renderer.listen(document, "swipe", this.swipe); + this._touchManager.addGlobalEventListener('document', 'swipe', this.swipe); + this._gesturesAttached = true; + + // this.renderer.listen(document, "panstart", this.panstart); + // this.renderer.listen(document, "pan", this.pan); + this._touchManager.addGlobalEventListener('document', 'panstart', this.panstart); + this._touchManager.addGlobalEventListener('document', 'panmove', this.pan); + this._touchManager.addGlobalEventListener('document', 'panend', this.panEnd); + } + if (!this._resizeObserver && this.platformUtil.isBrowser) { + this._resizeObserver = fromEvent(window, 'resize').pipe(debounce(() => interval(150))) + .subscribe((value) => { + this.checkPinThreshold(value); + }); + } + } + + private updateEdgeZone() { + let maxValue; + + if (this.miniTemplate) { + maxValue = Math.max(this._maxEdgeZone, this.getExpectedWidth(true) * 1.1); + this.set_maxEdgeZone(maxValue); + } + } + + private checkPinThreshold = (evt?: Event) => { + if (!this.platformUtil.isBrowser) { + return; + } + let windowWidth; + if (this.pinThreshold) { + windowWidth = this.getWindowWidth(); + if (evt && this._widthCache.windowWidth === windowWidth) { + return; + } + this._widthCache.windowWidth = windowWidth; + if (!this.pin && windowWidth >= this.pinThreshold) { + this.pin = true; + this.pinChange.emit(true); + } else if (this.pin && windowWidth < this.pinThreshold) { + this.pin = false; + this.pinChange.emit(false); + } + } + }; + + private swipe = (evt: HammerInput) => { + // TODO: Could also force input type: http://stackoverflow.com/a/27108052 + if (!this.enableGestures || evt.pointerType !== 'touch') { + return; + } + + // HammerJS swipe is horizontal-only by default, don't check deltaY + let deltaX; + let startPosition; + if (this.position === 'right') { + // when on the right use inverse of deltaX + deltaX = -evt.deltaX; + startPosition = this.getWindowWidth() - (evt.center.x + evt.distance); + } else { + deltaX = evt.deltaX; + startPosition = evt.center.x - evt.distance; + } + // only accept closing swipe (ignoring minEdgeZone) when the drawer is expanded: + if ((this.isOpen && deltaX < 0) || + // positive deltaX from the edge: + (deltaX > 0 && startPosition < this.maxEdgeZone)) { + this.toggle(); + } + }; + + private panstart = (evt: HammerInput) => { // TODO: test code + if (!this.enableGestures || this.pin || evt.pointerType !== 'touch') { + return; + } + const startPosition = this.position === 'right' ? this.getWindowWidth() - (evt.center.x + evt.distance) + : evt.center.x - evt.distance; + + // cache width during animation, flag to allow further handling + if (this.isOpen || (startPosition < this.maxEdgeZone)) { + this._panning = true; + this._panStartWidth = this.getExpectedWidth(!this.isOpen); + this._panLimit = this.getExpectedWidth(this.isOpen); + + this.renderer.addClass(this.overlay, 'panning'); + this.renderer.addClass(this.drawer, 'panning'); + } + }; + + private pan = (evt: HammerInput) => { + // TODO: input.deltaX = prevDelta.x + (center.x - offset.x); + // get actual delta (not total session one) from event? + // pan WILL also fire after a full swipe, only resize on flag + if (!this._panning) { + return; + } + const right: boolean = this.position === 'right'; + // when on the right use inverse of deltaX + const deltaX = right ? -evt.deltaX : evt.deltaX; + let newX; + let percent; + const visibleWidth = this._panStartWidth + deltaX; + + if (this.isOpen && deltaX < 0) { + // when visibleWidth hits limit - stop animating + if (visibleWidth <= this._panLimit) { + return; + } + + if (this.hasAnimateWidth) { + percent = (visibleWidth - this._panLimit) / (this._panStartWidth - this._panLimit); + newX = visibleWidth; + } else { + percent = visibleWidth / this._panStartWidth; + newX = evt.deltaX; + } + this.setXSize(newX, percent.toPrecision(2)); + + } else if (!this.isOpen && deltaX > 0) { + // when visibleWidth hits limit - stop animating + if (visibleWidth >= this._panLimit) { + return; + } + + if (this.hasAnimateWidth) { + percent = (visibleWidth - this._panStartWidth) / (this._panLimit - this._panStartWidth); + newX = visibleWidth; + } else { + percent = visibleWidth / this._panLimit; + newX = (this._panLimit - visibleWidth) * (right ? 1 : -1); + } + this.setXSize(newX, percent.toPrecision(2)); + } + }; + + private panEnd = (evt: HammerInput) => { + if (this._panning) { + const deltaX = this.position === 'right' ? -evt.deltaX : evt.deltaX; + const visibleWidth: number = this._panStartWidth + deltaX; + this.resetPan(); + + // check if pan brought the drawer to 50% + if (this.isOpen && visibleWidth <= this._panStartWidth / 2) { + this.close(); + } else if (!this.isOpen && visibleWidth >= this._panLimit / 2) { + this.open(); + } + this._panStartWidth = null; + } + }; + + private resetPan() { + this._panning = false; + /* styles fail to apply when set on parent due to extra attributes, prob ng bug */ + /* styles fail to apply when set on parent due to extra attributes, prob ng bug */ + this.renderer.removeClass(this.overlay, 'panning'); + this.renderer.removeClass(this.drawer, 'panning'); + this.setXSize(0, ''); + } + + /** + * Sets the absolute position or width in case the drawer doesn't change position. + * + * @param x the number pixels to translate on the X axis or the width to set. 0 width will clear the style instead. + * @param opacity optional value to apply to the overlay + */ + private setXSize(x: number, opacity?: string) { + // Angular polyfills patches window.requestAnimationFrame, but switch to DomAdapter API (TODO) + window.requestAnimationFrame(() => { + if (this.hasAnimateWidth) { + this.renderer.setStyle(this.drawer, 'width', x ? Math.abs(x) + 'px' : ''); + } else { + this.renderer.setStyle(this.drawer, 'transform', x ? 'translate3d(' + x + 'px,0,0)' : ''); + this.renderer.setStyle(this.drawer, '-webkit-transform', x ? 'translate3d(' + x + 'px,0,0)' : ''); + } + if (opacity !== undefined) { + this.renderer.setStyle(this.overlay, 'opacity', opacity); + } + }); + } + + private toggleOpenedEvent = () => { + this.elementRef.nativeElement.removeEventListener('transitionend', this.toggleOpenedEvent, false); + this.opened.emit(); + }; + + private toggleClosedEvent = () => { + this.elementRef.nativeElement.removeEventListener('transitionend', this.toggleClosedEvent, false); + this.closed.emit(); + }; +} diff --git a/projects/igniteui-angular/navigation-drawer/src/navigation-drawer/navigation-drawer.directives.ts b/projects/igniteui-angular/navigation-drawer/src/navigation-drawer/navigation-drawer.directives.ts new file mode 100644 index 00000000000..373f15cb487 --- /dev/null +++ b/projects/igniteui-angular/navigation-drawer/src/navigation-drawer/navigation-drawer.directives.ts @@ -0,0 +1,100 @@ +import { Directive, HostBinding, Input, TemplateRef, booleanAttribute, inject } from '@angular/core'; + +@Directive({ + selector: '[igxDrawerItem]', + exportAs: 'igxDrawerItem', + standalone: true +}) +export class IgxNavDrawerItemDirective { + + /** + * Styles a navigation drawer item as selected. + * If not set, `active` will have default value `false`. + * + * @example + * ```html + * Active Item + * ``` + */ + @Input({ transform: booleanAttribute }) public active = false; + + /** + * Disables a navigation drawer item. + * If not set, `disabled` will have default value `false`. + * + * @example + * ```html + * Disabled Item + * ``` + */ + @Input({ transform: booleanAttribute }) public disabled = false; + + /** + * Styles a navigation drawer item as a group header. + * If not set, `isHeader` will have default value `false`. + * + * @example + * ```html + * Header + * ``` + */ + @Input({ transform: booleanAttribute }) public isHeader = false; + + /** + * @hidden + */ + public readonly activeClass = 'igx-nav-drawer__item--active'; + + /** + * @hidden + */ + public readonly disabledClass = 'igx-nav-drawer__item--disabled'; + + /** + * @hidden + */ + @HostBinding('class.igx-nav-drawer__item') + public get defaultCSS(): boolean { + return !this.active && !this.isHeader; + } + + /** + * @hidden + */ + @HostBinding('class.igx-nav-drawer__item--active') + public get currentCSS(): boolean { + return this.active && !this.isHeader && !this.disabled; + } + + /** + * @hidden + */ + @HostBinding('class.igx-nav-drawer__item--header') + public get headerCSS(): boolean { + return this.isHeader; + } + + /** + * @hidden + */ + @HostBinding('class.igx-nav-drawer__item--disabled') + public get disabledCSS(): boolean { + return this.disabled; + } +} + +@Directive({ + selector: '[igxDrawer]', + standalone: true +}) +export class IgxNavDrawerTemplateDirective { + public template = inject>(TemplateRef); +} + +@Directive({ + selector: '[igxDrawerMini]', + standalone: true +}) +export class IgxNavDrawerMiniTemplateDirective { + public template = inject>(TemplateRef); +} diff --git a/projects/igniteui-angular/navigation-drawer/src/navigation-drawer/navigation-drawer.module.ts b/projects/igniteui-angular/navigation-drawer/src/navigation-drawer/navigation-drawer.module.ts new file mode 100644 index 00000000000..61b9fe144f3 --- /dev/null +++ b/projects/igniteui-angular/navigation-drawer/src/navigation-drawer/navigation-drawer.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from '@angular/core'; +import { IGX_NAVIGATION_DRAWER_DIRECTIVES } from './public_api'; + +/** + * @hidden + */ +@NgModule({ + imports: [ + ...IGX_NAVIGATION_DRAWER_DIRECTIVES + ], + exports: [ + ...IGX_NAVIGATION_DRAWER_DIRECTIVES + ] +}) +export class IgxNavigationDrawerModule {} diff --git a/projects/igniteui-angular/navigation-drawer/src/navigation-drawer/public_api.ts b/projects/igniteui-angular/navigation-drawer/src/navigation-drawer/public_api.ts new file mode 100644 index 00000000000..c8c9fbeb629 --- /dev/null +++ b/projects/igniteui-angular/navigation-drawer/src/navigation-drawer/public_api.ts @@ -0,0 +1,13 @@ +import { IgxNavigationDrawerComponent } from './navigation-drawer.component'; +import { IgxNavDrawerItemDirective, IgxNavDrawerMiniTemplateDirective, IgxNavDrawerTemplateDirective } from './navigation-drawer.directives'; + +export * from './navigation-drawer.component'; +export * from './navigation-drawer.directives'; + +/* NOTE: Navigation drawer directives collection for ease-of-use import in standalone components scenario */ +export const IGX_NAVIGATION_DRAWER_DIRECTIVES = [ + IgxNavigationDrawerComponent, + IgxNavDrawerItemDirective, + IgxNavDrawerMiniTemplateDirective, + IgxNavDrawerTemplateDirective +] as const; diff --git a/projects/igniteui-angular/navigation-drawer/src/public_api.ts b/projects/igniteui-angular/navigation-drawer/src/public_api.ts new file mode 100644 index 00000000000..29a33ae4690 --- /dev/null +++ b/projects/igniteui-angular/navigation-drawer/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './navigation-drawer/public_api'; +export * from './navigation-drawer/navigation-drawer.module'; diff --git a/projects/igniteui-angular/package.json b/projects/igniteui-angular/package.json index c051bc13e2e..b370a082872 100644 --- a/projects/igniteui-angular/package.json +++ b/projects/igniteui-angular/package.json @@ -73,7 +73,7 @@ "tslib": "^2.3.0", "igniteui-trial-watermark": "^3.1.0", "lodash-es": "^4.17.21", - "igniteui-theming": "^21.0.2", + "igniteui-theming": "^23.0.0", "@igniteui/material-icons-extended": "^3.1.0" }, "peerDependencies": { diff --git a/projects/igniteui-angular/paginator/README.md b/projects/igniteui-angular/paginator/README.md new file mode 100644 index 00000000000..b55a5e7ed29 --- /dev/null +++ b/projects/igniteui-angular/paginator/README.md @@ -0,0 +1,102 @@ +# igx-paginator + +Pagination component for Ignite UI for Angular. + +This entry point provides the paginator UI used across the grid family to display paging information, let users pick a page size, and navigate through large data sets. + +## Getting Started + +```ts +import { Component } from '@angular/core'; +import { IgxPaginatorComponent } from 'igniteui-angular/paginator'; + +@Component({ + selector: 'app-sample', + standalone: true, + imports: [IgxPaginatorComponent], + template: ` + + + ` +}) +export class SampleComponent { + public total = 250; + public perPage = 25; + + public handlePage(index: number): void { + // Load the data chunk for the requested page. + } +} +``` + +## Basic Configuration + +```html + + + + + + +``` + +1. Bind `totalRecords` to the total data size (remote or local). +2. Handle `pageChange` to request or compute the correct data slice. +3. Optionally provide custom `selectOptions` to limit the page-size dropdown. + +## Customization + +- **Custom content** – project markup with `igxPaginatorContent` for bespoke layouts. +- **Overlay settings** – provide the `overlaySettings` input to align the page-size dropdown with your app shell. +- **Localization** – set `resourceStrings` with your own `IPaginatorResourceStrings` implementation. + +```html + + Displaying {{ page + 1 }} / {{ totalPages }} + +``` + +## API Reference + +### Inputs + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `page` | number | `0` | Current zero-based page index. | +| `perPage` | number | `15` | Number of records shown per page. Updating recalculates `totalPages`. | +| `totalRecords` | number | `undefined` | Total records in the bound data source. | +| `selectOptions` | number[] | `[5,10,15,25,50,100,500]` | Values displayed in the page-size selector; merged with `perPage` for uniqueness. | +| `overlaySettings` | `OverlaySettings` | `{}` | Customizes how the dropdown for page-size is rendered. | +| `resourceStrings` | `IPaginatorResourceStrings` | `PaginatorResourceStringsEN` | Localizes button labels and tooltips. | + +### Outputs + +| Event | Payload | Description | +| --- | --- | --- | +| `perPageChange` | `number` | Fires after the page-size changes. | +| `pageChange` | `number` | Fires after the current page changes. | +| `paging` | `IPageCancellableEventArgs` | Fires before paging; set `cancel = true` to block navigation. | +| `pagingDone` | `IPageEventArgs` | Fires after paging completes with previous/current page info. | + +### Methods and Convenience Getters + +- `nextPage()`, `previousPage()` – move the current page forward or backward when possible. +- `paginate(index: number)` – jump to a specific page programmatically. +- `isFirstPage`, `isLastPage` – booleans that indicate boundary conditions for navigation controls. +- `nativeElement` – underlying DOM element, useful when integrating with lower-level libraries. + +## Related Packages + +- [Grids](../grids/README.md) – demonstrates the paginator in action inside data grids. +- [Core Overlay Services](../core/src/services/overlay/README.md) – configure advanced dropdown positioning shared with the paginator. + +Consult the [official paginator documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/paginator) for tutorials and live examples. diff --git a/projects/igniteui-angular/paginator/index.ts b/projects/igniteui-angular/paginator/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/paginator/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/paginator/ng-package.json b/projects/igniteui-angular/paginator/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/paginator/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/paginator/src/paginator/page-size-selector.component.html b/projects/igniteui-angular/paginator/src/paginator/page-size-selector.component.html new file mode 100644 index 00000000000..3adfdbc050d --- /dev/null +++ b/projects/igniteui-angular/paginator/src/paginator/page-size-selector.component.html @@ -0,0 +1,10 @@ + +
    + + @for (val of paginator.selectOptions; track val) { + + {{ val }} + + } + +
    diff --git a/projects/igniteui-angular/paginator/src/paginator/pager.component.html b/projects/igniteui-angular/paginator/src/paginator/pager.component.html new file mode 100644 index 00000000000..7d9e7568250 --- /dev/null +++ b/projects/igniteui-angular/paginator/src/paginator/pager.component.html @@ -0,0 +1,58 @@ + + +
    + {{ paginator.page + 1 }} +  {{ + paginator.resourceStrings.igx_paginator_pager_text + }}  + {{ paginator.totalPages || 1 }} +
    + + + diff --git a/projects/igniteui-angular/paginator/src/paginator/paginator-interfaces.ts b/projects/igniteui-angular/paginator/src/paginator/paginator-interfaces.ts new file mode 100644 index 00000000000..e3f63cad8c6 --- /dev/null +++ b/projects/igniteui-angular/paginator/src/paginator/paginator-interfaces.ts @@ -0,0 +1,20 @@ +import { Directive, TemplateRef, inject } from '@angular/core'; +import { CancelableEventArgs, IBaseEventArgs } from 'igniteui-angular/core'; + +export interface IPageEventArgs extends IBaseEventArgs { + previous: number; + current: number; +} + +export interface IPageCancellableEventArgs extends CancelableEventArgs { + current: number; + next: number; +} + +@Directive({ + selector: '[igxPaginator]', + standalone: true +}) +export class IgxPaginatorDirective { + public template = inject>(TemplateRef); +} diff --git a/projects/igniteui-angular/paginator/src/paginator/paginator.component.html b/projects/igniteui-angular/paginator/src/paginator/paginator.component.html new file mode 100644 index 00000000000..c16b31ff537 --- /dev/null +++ b/projects/igniteui-angular/paginator/src/paginator/paginator.component.html @@ -0,0 +1,8 @@ + + +@if (!customContent) { + +} +@if (!customContent) { + +} diff --git a/projects/igniteui-angular/paginator/src/paginator/paginator.component.spec.ts b/projects/igniteui-angular/paginator/src/paginator/paginator.component.spec.ts new file mode 100644 index 00000000000..9e7672c3839 --- /dev/null +++ b/projects/igniteui-angular/paginator/src/paginator/paginator.component.spec.ts @@ -0,0 +1,320 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { ViewChild, Component } from '@angular/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxPaginatorComponent, IgxPaginatorContentDirective } from './paginator.component'; +import { GridFunctions } from '../../../test-utils/grid-functions.spec'; +import { ControlsFunction } from '../../../test-utils/controls-functions.spec'; +import { first } from 'rxjs/operators'; +import { IgxButtonDirective } from '../../../directives/src/directives/button/button.directive'; + +describe('IgxPaginator with default settings', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, DefaultPaginatorComponent] + }).compileComponents(); + })); + it('should calculate number of pages correctly', () => { + const fix = TestBed.createComponent(DefaultPaginatorComponent); + fix.detectChanges(); + const paginator = fix.componentInstance.paginator; + + let totalPages = paginator.totalPages; + expect(totalPages).toBe(3); + + paginator.perPage = 10; + + fix.detectChanges(); + totalPages = paginator.totalPages; + expect(totalPages).toBe(5); + }); + + it('should change current page to equal last page, after changing perPage', () => { + const fix = TestBed.createComponent(DefaultPaginatorComponent); + fix.detectChanges(); + const paginator = fix.componentInstance.paginator; + + paginator.paginate(paginator.totalPages - 1); + paginator.perPage = paginator.totalRecords / 2; + + fix.detectChanges(); + const page = paginator.page; + expect(page).toBe(1); + }); + + it('should disable go to first page when paginator is on first page', () => { + const fix = TestBed.createComponent(DefaultPaginatorComponent); + fix.detectChanges(); + const paginator = fix.componentInstance.paginator; + + const goToFirstPageButton = fix.debugElement.query(By.css('button')).nativeElement; + + expect(goToFirstPageButton.className.includes('igx-button--disabled')).toBe(true); + + paginator.nextPage(); + fix.detectChanges(); + + expect(goToFirstPageButton.className.includes('igx-button--disabled')).toBe(false); + + paginator.previousPage(); + fix.detectChanges(); + + expect(goToFirstPageButton.className.includes('igx-button--disabled')).toBe(true); + }); + + it('should disable go to last page button when paginator is on last page', () => { + const fix = TestBed.createComponent(DefaultPaginatorComponent); + fix.detectChanges(); + const paginator = fix.componentInstance.paginator; + + const goToLastPageButton = fix.debugElement.query(By.css('button:last-child')).nativeElement; + + expect(goToLastPageButton.className.includes('igx-button--disabled')).toBe(false); + + paginator.paginate(paginator.totalPages - 1); + fix.detectChanges(); + + expect(goToLastPageButton.className.includes('igx-button--disabled')).toBe(true); + + paginator.previousPage(); + fix.detectChanges(); + + expect(goToLastPageButton.className.includes('igx-button--disabled')).toBe(false); + }); + + + it('should disable all buttons in the paginate if perPage > total records', () => { + const fix = TestBed.createComponent(DefaultPaginatorComponent); + fix.detectChanges(); + const paginator = fix.componentInstance.paginator; + + paginator.perPage = 100; + fix.detectChanges(); + + const pagingButtons = GridFunctions.getPagingButtons(fix.nativeElement); + pagingButtons.forEach(element => { + expect(element.className.includes('igx-button--disabled')).toBe(true); + }); + }); + + it('should be able to set custom pagination template', () => { + const fix = TestBed.createComponent(DefaultPaginatorComponent); + fix.detectChanges(); + + fix.componentInstance.setCustomPager(); + fix.detectChanges(); + + const customPaging = fix.debugElement.query(By.css('#numberPager')).nativeElement; + const prevBtn = fix.debugElement.query(By.css('.customPrev')); + const nextBtn = fix.debugElement.query(By.css('.customNext')); + const currPage = fix.debugElement.query(By.css('.currPage')); + + expect(customPaging).toBeDefined(); + expect(prevBtn.properties.disabled).toBeTrue(); + expect(currPage.nativeElement.innerText).toEqual('0'); + expect(nextBtn.properties.disabled).toBeFalse(); + }); + + it('should be able to operate correctly with paging api from custom template', () => { + const fix = TestBed.createComponent(DefaultPaginatorComponent); + fix.detectChanges(); + + fix.componentInstance.setCustomPager(); + fix.detectChanges(); + + const nextBtn = fix.debugElement.query(By.css('.customNext')); + + nextBtn.nativeElement.click(); + fix.detectChanges(); + + let currPage = fix.debugElement.query(By.css('.currPage')); + + expect(currPage.nativeElement.innerText).toEqual('1'); + expect(nextBtn.properties.disabled).toBeFalse(); + + nextBtn.nativeElement.click(); + fix.detectChanges(); + + currPage = fix.debugElement.query(By.css('.currPage')); + + expect(currPage.nativeElement.innerText).toEqual('2'); + expect(nextBtn.properties.disabled).toBeTrue(); + }); + + it('paging and pagingDone events should be emitted correctly', () => { + const fix = TestBed.createComponent(DefaultPaginatorComponent); + fix.detectChanges(); + + const paginator = fix.componentInstance.paginator; + + spyOn(paginator.paging, 'emit').and.callThrough(); + spyOn(paginator.pagingDone, 'emit').and.callThrough(); + const allBtns = fix.debugElement.queryAll(By.css('.igx-icon-button')); + + const prevBtn = allBtns[1]; + const nextBtn = allBtns[2]; + const lastBtn = allBtns[3]; + + nextBtn.nativeElement.click(); + fix.detectChanges(); + + lastBtn.nativeElement.click(); + fix.detectChanges(); + + expect(paginator.paging.emit).toHaveBeenCalledWith({current: 1, next: 2, cancel: false}); + expect(paginator.pagingDone.emit).toHaveBeenCalledWith({current: 2, previous: 1}); + expect(paginator.paging.emit).toHaveBeenCalledTimes(2); + expect(paginator.pagingDone.emit).toHaveBeenCalledTimes(2); + + paginator.paging.pipe(first()).subscribe(args => { + args.cancel = true; + }); + + prevBtn.nativeElement.click(); + fix.detectChanges(); + + expect(paginator.paging.emit).toHaveBeenCalledTimes(3); + expect(paginator.pagingDone.emit).toHaveBeenCalledTimes(2); + }); + + it('pageChange event should be emitted correctly', () => { + const fix = TestBed.createComponent(DefaultPaginatorComponent); + fix.detectChanges(); + + const paginator = fix.componentInstance.paginator; + spyOn(paginator.pageChange, 'emit').and.callThrough(); + const allBtns = fix.debugElement.queryAll(By.css('.igx-icon-button ')); + const nextBtn = allBtns[2]; + + nextBtn.nativeElement.click(); + fix.detectChanges(); + + expect(paginator.pageChange.emit).toHaveBeenCalledTimes(1); + + paginator.paging.pipe(first()).subscribe(args => { + args.cancel = true; + }); + + nextBtn.nativeElement.click(); + fix.detectChanges(); + + expect(paginator.pageChange.emit).toHaveBeenCalledTimes(1); + }); + + it('perPageChange event should be emitted correctly', () => { + const fix = TestBed.createComponent(DefaultPaginatorComponent); + fix.detectChanges(); + + const paginator = fix.componentInstance.paginator; + spyOn(paginator.perPageChange, 'emit').and.callThrough(); + + paginator.perPage = 3; + + expect(paginator.perPageChange.emit).toHaveBeenCalledTimes(1); + }); + + it('should display "1 of 1" when there are no records to show', () => { + const fix = TestBed.createComponent(DefaultPaginatorComponent); + fix.detectChanges(); + + const totalPages = fix.debugElement.query(By.css('.igx-page-nav__text > span:last-child')).nativeElement; + const paginator = fix.componentInstance.paginator; + + paginator.totalRecords = null; + fix.detectChanges(); + + expect(totalPages.innerText).toBe('1'); + + paginator.totalRecords = 0; + fix.detectChanges(); + + expect(totalPages.innerText).toBe('1'); + }); + +}); + +describe('IgxPaginator with custom settings', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, CustomizedPaginatorComponent] + }).compileComponents(); + })); + + it('should calculate correctly pages when custom select options are given', () => { + const fix = TestBed.createComponent(CustomizedPaginatorComponent); + fix.detectChanges(); + const paginator = fix.componentInstance.paginator; + + let totalPages = paginator.totalPages; + expect(totalPages).toBe(4); + + const select = fix.debugElement.query(By.css('igx-select')).nativeElement; + select.click(); + fix.detectChanges(); + + ControlsFunction.clickDropDownItem(fix, 3); + + totalPages = paginator.totalPages; + expect(totalPages).toBe(1); + }); + + it('should add perPage in the select options if not already there', () => { + const fix = TestBed.createComponent(CustomizedPaginatorComponent); + fix.detectChanges(); + const paginator = fix.componentInstance.paginator; + const selectOptions = paginator.selectOptions; + + expect(selectOptions).toEqual([3, 7, 10, 25, 40]); + }); + + it('should be able to render custom select label', () => { + const fix = TestBed.createComponent(CustomizedPaginatorComponent); + fix.detectChanges(); + const paginator = fix.componentInstance.paginator; + paginator.resourceStrings.igx_paginator_label = 'Per page'; + + fix.detectChanges(); + expect(paginator.resourceStrings.igx_paginator_label).toEqual('Per page'); + }); + +}); +@Component({ + template: ` + + @if (customContent) { + +
    + + {{pg.page}} + +
    +
    + } +
    `, + imports: [IgxPaginatorComponent, IgxPaginatorContentDirective, IgxButtonDirective] +}) +export class DefaultPaginatorComponent { + @ViewChild(IgxPaginatorComponent, { static: true }) public paginator: IgxPaginatorComponent; + public customContent = false; + + public setCustomPager() { + this.customContent = true; + } +} +@Component({ + template: ` + `, + imports: [IgxPaginatorComponent] +}) +export class CustomizedPaginatorComponent { + @ViewChild(IgxPaginatorComponent, { static: true }) public paginator: IgxPaginatorComponent; +} + diff --git a/projects/igniteui-angular/paginator/src/paginator/paginator.component.ts b/projects/igniteui-angular/paginator/src/paginator/paginator.component.ts new file mode 100644 index 00000000000..99ba65cf416 --- /dev/null +++ b/projects/igniteui-angular/paginator/src/paginator/paginator.component.ts @@ -0,0 +1,396 @@ +import { ChangeDetectorRef, Component, ContentChild, Directive, ElementRef, EventEmitter, HostBinding, Input, Output, forwardRef, inject } from '@angular/core'; +import { IPageCancellableEventArgs, IPageEventArgs } from './paginator-interfaces'; +import { + IPaginatorResourceStrings, + PaginatorResourceStringsEN, + OverlaySettings, + getCurrentResourceStrings +} from 'igniteui-angular/core'; +import { FormsModule } from '@angular/forms'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxRippleDirective, IgxIconButtonDirective } from 'igniteui-angular/directives'; +import { IgxSelectComponent, IgxSelectItemComponent } from 'igniteui-angular/select'; +import { IgxPaginatorToken } from './token'; + +@Directive({ + selector: '[igxPaginatorContent],igx-paginator-content', + standalone: true +}) +export class IgxPaginatorContentDirective { + /** + * @internal + * @hidden + */ + @HostBinding('class.igx-paginator-content') + public cssClass = 'igx-paginator-content'; +} + +/* blazorElement */ +/* mustUseNGParentAnchor */ +/* wcElementTag: igc-paginator */ +/* blazorIndirectRender */ +/* singleInstanceIdentifier */ +/* contentParent: GridBaseDirective */ +/* contentParent: RowIsland */ +/* contentParent: HierarchicalGrid */ +/* jsonAPIManageCollectionInMarkup */ +/** + * Paginator component description + * @igxParent IgxGridComponent, IgxTreeGridComponent, IgxHierarchicalGridComponent, IgxPivotGridComponent, * + */ +@Component({ + selector: 'igx-paginator', + templateUrl: 'paginator.component.html', + imports: [forwardRef(() => IgxPageSizeSelectorComponent), forwardRef(() => IgxPageNavigationComponent)], + providers: [ + { provide: IgxPaginatorToken, useExisting: IgxPaginatorComponent } + ] +}) +// switch IgxPaginatorToken to extends once density is dropped +export class IgxPaginatorComponent implements IgxPaginatorToken { + private elementRef = inject(ElementRef); + private cdr = inject(ChangeDetectorRef); + + + /** + * @hidden + * @internal + */ + @ContentChild(IgxPaginatorContentDirective) + public customContent: IgxPaginatorContentDirective; + + /** + * Emitted when `perPage` property value of the paginator is changed. + * + * @example + * ```html + * + * ``` + * ```typescript + * public onPerPageChange(perPage: number) { + * this.perPage = perPage; + * } + * ``` + */ + @Output() + public perPageChange = new EventEmitter(); + + /** + * Emitted after the current page is changed. + * + * @example + * ```html + * + * ``` + * ```typescript + * public onPageChange(page: number) { + * this.currentPage = page; + * } + * ``` + */ + @Output() + public pageChange = new EventEmitter(); + + /** + * Emitted before paging is performed. + * + * @remarks + * Returns an object consisting of the current and next pages. + * @example + * ```html + * + * ``` + */ + @Output() + public paging = new EventEmitter(); + + /** + * Emitted after paging is performed. + * + * @remarks + * Returns an object consisting of the previous and current pages. + * @example + * ```html + * + * ``` + */ + @Output() + public pagingDone = new EventEmitter(); + + /** + * Total pages calculated from totalRecords and perPage + */ + public totalPages: number; + protected _page = 0; + protected _totalRecords: number; + protected _selectOptions = [5, 10, 15, 25, 50, 100, 500]; + protected _perPage = 15; + + private _resourceStrings = getCurrentResourceStrings(PaginatorResourceStringsEN); + private _overlaySettings: OverlaySettings = {}; + private defaultSelectValues = [5, 10, 15, 25, 50, 100, 500]; + + /** @hidden @internal */ + @HostBinding('class.igx-paginator') + public cssClass = 'igx-paginator'; + + /** + * Gets/Sets the current page of the paginator. + * The default is 0. + * ```typescript + * let page = this.paginator.page; + * ``` + * + * @memberof IgxPaginatorComponent + */ + @Input() + public get page() { + return this._page; + } + + public set page(value: number) { + if (this._page === value || value < 0 || value > this.totalPages) { + return; + } + const cancelEventArgs: IPageCancellableEventArgs = { current: this._page, next: value, cancel: false }; + const eventArgs: IPageEventArgs = { previous: this._page, current: value }; + + this.paging.emit(cancelEventArgs); + if (cancelEventArgs.cancel) { + return; + } + this._page = value; + this.pageChange.emit(this._page); + + this.pagingDone.emit(eventArgs); + } + + /** + * Gets/Sets the number of visible items per page in the paginator. + * The default is 15. + * ```typescript + * let itemsPerPage = this.paginator.perPage; + * ``` + * + * @memberof IgxPaginatorComponent + */ + @Input() + public get perPage() { + return this._perPage; + } + + public set perPage(value: number) { + if (value < 0 || this.perPage === value) { + return; + } + this._perPage = Number(value); + this.perPageChange.emit(this._perPage); + this._selectOptions = this.sortUniqueOptions(this.defaultSelectValues, this._perPage); + this.totalPages = Math.ceil(this.totalRecords / this._perPage); + if (this.totalPages !== 0 && this.page >= this.totalPages) { + this.page = this.totalPages - 1; + } + } + + /** + * Sets the total records. + * ```typescript + * let totalRecords = this.paginator.totalRecords; + * ``` + * + * @memberof IgxPaginatorComponent + */ + @Input() + public get totalRecords() { + return this._totalRecords; + } + + public set totalRecords(value: number) { + this._totalRecords = value; + this.totalPages = Math.ceil(this.totalRecords / this.perPage); + if (this.page > this.totalPages) { + this.page = 0; + } + this.cdr.detectChanges(); + } + + /** + * Sets custom options in the select of the paginator + * ```typescript + * let options = this.paginator.selectOptions; + * ``` + * + * @memberof IgxPaginatorComponent + */ + @Input() + public get selectOptions() { + return this._selectOptions; + } + + public set selectOptions(value: Array) { + this._selectOptions = this.sortUniqueOptions(value, this._perPage); + this.defaultSelectValues = [...value]; + } + + /** + * Sets custom OverlaySettings. + * ```html + * + * ``` + */ + @Input() + public get overlaySettings(): OverlaySettings { + return this._overlaySettings; + } + + public set overlaySettings(value: OverlaySettings) { + this._overlaySettings = Object.assign({}, this._overlaySettings, value); + } + + /* mustSetInCodePlatforms: WebComponents;Blazor;React */ + /** + * An accessor that sets the resource strings. + * By default it uses EN resources. + */ + @Input() + public set resourceStrings(value: IPaginatorResourceStrings) { + this._resourceStrings = Object.assign({}, this._resourceStrings, value); + } + + /** + * An accessor that returns the resource strings. + */ + public get resourceStrings(): IPaginatorResourceStrings { + return this._resourceStrings; + } + + /** + * Returns if the current page is the last page. + * ```typescript + * const lastPage = this.paginator.isLastPage; + * ``` + */ + public get isLastPage(): boolean { + return this.page + 1 >= this.totalPages; + } + + /** + * Returns if the current page is the first page. + * ```typescript + * const lastPage = this.paginator.isFirstPage; + * ``` + */ + public get isFirstPage(): boolean { + return this.page === 0; + } + + + /** + * Returns if the first pager buttons should be disabled + * @hidden + * @deprecated in version 18.1.0. Use the `isFirstPage` property instead. + */ + public get isFirstPageDisabled(): boolean { + return this.isFirstPage; + } + + /** + * Returns if the last pager buttons should be disabled + * @hidden + * @deprecated in version 18.1.0. Use the `isLastPage` property instead. + */ + public get isLastPageDisabled(): boolean { + return this.isLastPage; + } + + public get nativeElement() { + return this.elementRef.nativeElement; + } + + /** + * Goes to the next page of the `IgxPaginatorComponent`, if the paginator is not already at the last page. + * ```typescript + * this.paginator.nextPage(); + * ``` + * + * @memberof IgxPaginatorComponent + */ + public nextPage(): void { + if (!this.isLastPage) { + this.page += 1; + } + } + /** + * Goes to the previous page of the `IgxPaginatorComponent`, if the paginator is not already at the first page. + * ```typescript + * this.paginator.previousPage(); + * ``` + * + * @memberof IgxPaginatorComponent + */ + public previousPage(): void { + if (!this.isFirstPage) { + this.page -= 1; + } + } + /** + * Goes to the desired page index. + * ```typescript + * this.paginator.paginate(1); + * ``` + * + * @param val + * @memberof IgxPaginatorComponent + */ + public paginate(val: number): void { + if (val < 0 || val > this.totalPages - 1) { + return; + } + this.page = val; + } + + private sortUniqueOptions(values: Array, newOption: number): number[] { + return Array.from(new Set([...values, newOption])).sort((a, b) => a - b); + } +} + + +@Component({ + selector: 'igx-page-size', + templateUrl: 'page-size-selector.component.html', + imports: [IgxSelectComponent, FormsModule, IgxSelectItemComponent] +}) +export class IgxPageSizeSelectorComponent { + public paginator = inject(IgxPaginatorComponent, { host: true }); + + /** + * @internal + * @hidden + */ + @HostBinding('class.igx-page-size') + public cssClass = 'igx-page-size'; +} + + +@Component({ + selector: 'igx-page-nav', + templateUrl: 'pager.component.html', + imports: [IgxRippleDirective, IgxIconComponent, IgxIconButtonDirective] +}) +export class IgxPageNavigationComponent { + public paginator = inject(IgxPaginatorComponent, { host: true }); + + /** + * @internal + * @hidden + */ + @HostBinding('class.igx-page-nav') + public cssClass = 'igx-page-nav'; + + /** + * Sets the `role` attribute of the element. + */ + @HostBinding('attr.role') + @Input() + public role = 'navigation'; +} diff --git a/projects/igniteui-angular/paginator/src/paginator/paginator.module.ts b/projects/igniteui-angular/paginator/src/paginator/paginator.module.ts new file mode 100644 index 00000000000..948d6ea324c --- /dev/null +++ b/projects/igniteui-angular/paginator/src/paginator/paginator.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { IGX_PAGINATOR_DIRECTIVES } from './public_api'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + ...IGX_PAGINATOR_DIRECTIVES + ], + exports: [ + ...IGX_PAGINATOR_DIRECTIVES + ] +}) +export class IgxPaginatorModule { } diff --git a/projects/igniteui-angular/paginator/src/paginator/public_api.ts b/projects/igniteui-angular/paginator/src/paginator/public_api.ts new file mode 100644 index 00000000000..575400a0716 --- /dev/null +++ b/projects/igniteui-angular/paginator/src/paginator/public_api.ts @@ -0,0 +1,15 @@ +import { IgxPaginatorDirective } from './paginator-interfaces'; +import { IgxPageNavigationComponent, IgxPageSizeSelectorComponent, IgxPaginatorComponent, IgxPaginatorContentDirective } from './paginator.component'; + +export * from './paginator.component'; +export * from './paginator-interfaces'; +export { IgxPaginatorToken } from './token'; + +/* NOTE: Paginator directives collection for ease-of-use import in standalone components scenario */ +export const IGX_PAGINATOR_DIRECTIVES = [ + IgxPaginatorComponent, + IgxPageNavigationComponent, + IgxPageSizeSelectorComponent, + IgxPaginatorContentDirective, + IgxPaginatorDirective +] as const; diff --git a/projects/igniteui-angular/paginator/src/paginator/token.ts b/projects/igniteui-angular/paginator/src/paginator/token.ts new file mode 100644 index 00000000000..b5e05f742bc --- /dev/null +++ b/projects/igniteui-angular/paginator/src/paginator/token.ts @@ -0,0 +1,12 @@ +import { EventEmitter } from '@angular/core'; + +/** @hidden @internal */ +export abstract class IgxPaginatorToken { + public abstract page: number; + public abstract perPage: number; + public abstract totalRecords: number; + + public abstract pageChange: EventEmitter; + + public abstract paginate(val: number): void +} diff --git a/projects/igniteui-angular/paginator/src/public_api.ts b/projects/igniteui-angular/paginator/src/public_api.ts new file mode 100644 index 00000000000..b145938468b --- /dev/null +++ b/projects/igniteui-angular/paginator/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './paginator/public_api'; +export * from './paginator/paginator.module'; diff --git a/projects/igniteui-angular/progressbar/README.md b/projects/igniteui-angular/progressbar/README.md new file mode 100644 index 00000000000..aad3a4d7240 --- /dev/null +++ b/projects/igniteui-angular/progressbar/README.md @@ -0,0 +1,61 @@ +# igx-linear-bar and igx-circular-bar + +The `linear` progress bar component provides the ability to display a progress bar and update its appearance as its state changes. The indicator can be styled with a choice of colors in stripes or solids. You can also manage where the text is aligned. +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/linear-progress) + + +The `circular` progress indicator component provides the ability to display progress in a circle and update its appearance as its state changes. You can also manage if the text is visible or not. +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/circular-progress) + +# Usage +To get started with the Ignite UI for Angular Linear and Circular Progress Indicator, we should first import the **IgxProgressBarModule** in the **app.module.ts** file: +```typescript +// app.module.ts +... +import { IgxProgressBarModule } from 'igniteui-angular/main'; + +@NgModule({ + ... + imports: [..., IgxProgressBarModule], + ... +}) +export class AppModule {} +``` +### Basic configuration + +```html + + +> +``` + +# API Summary +## igx-linear-bar +| Name | Type | Description | +|:----------|:-------------:|:------| +| `id` | string | Unique identifier of the component. If not provided it will be automatically generated.| +| `max` | number | Set maximum value that can be passed. By default it is set to 100. | +| `type` | string | Set type of the linear bar. Possible options - `success`, `info`, `warning`, and `error`. | +| `value` | number | Set value that indicates the completed bar position. | +| `striped` | boolean | Set bar to have striped style. | +| `animate` | boolean | animation on progress bar. | +| `textAlign` | enum | Set the position that define where the text is aligned. Possible options - `IgxTextAlign.START` (default), `IgxTextAlign.CENTER`, `IgxTextAlign.END`. | +| `textVisibility` | boolean | Set the text to be visible. By default is set to `true`. | +| `textTop` | boolean | Set the position that defene is text to be aligned above the progress line. By default is set to `false`. | +| `text` | string | Set a custom text that is displayed according defined position. | +| `indeterminate` | boolean | Display the indicator continually growing and shrinking along the track. | +## igx-circular-bar +| Name | Type | Description | +|:----------|:-------------:|:------| +| `id` | string | Unique identifier of the component. If not provided it will be automatically generated.| +| `max` | number | Set maximum value that can be passed. Default `max` value is 100. | +| `value` | number | Set value that indicates the completed bar position. | +| `animate` | boolean | animation on progress bar. | +| `textVisibility` | boolean | Set the text to be visible. By default is set to `true`. | +| `indeterminate` | boolean | Display the indicator continually growing and shrinking along the track. | +## Common +| Name | Description | +|:----------|:------| +| `getValue()` | Return passed value to progress bar to be in range between min(0) and max. | +| `getPercentValue()` | Calculate the percentage based on passed value. | +| `progressChanged` | Exposed event, that could be handled to track progress changing | diff --git a/projects/igniteui-angular/progressbar/index.ts b/projects/igniteui-angular/progressbar/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/progressbar/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/progressbar/ng-package.json b/projects/igniteui-angular/progressbar/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/progressbar/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/progressbar/src/progressbar/circularbar.component.spec.ts b/projects/igniteui-angular/progressbar/src/progressbar/circularbar.component.spec.ts new file mode 100644 index 00000000000..66eb86e9d72 --- /dev/null +++ b/projects/igniteui-angular/progressbar/src/progressbar/circularbar.component.spec.ts @@ -0,0 +1,140 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { IgxCircularProgressBarComponent } from './progressbar.component'; +import { hasClass } from "../../../test-utils/helper-utils.spec"; + +describe('IgxCircularProgressBarComponent', () => { + let fixture: ComponentFixture; + let progress: IgxCircularProgressBarComponent; + let circularBar: HTMLElement; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [IgxCircularProgressBarComponent] + }).compileComponents(); + })); + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [IgxCircularProgressBarComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(IgxCircularProgressBarComponent); + progress = fixture.componentInstance; + // For test fixture destroy + progress.id = "root1"; + fixture.detectChanges(); + circularBar = fixture.debugElement.nativeElement; + }); + + it('should initialize with default attributes', () => { + expect(progress.cssClass).toBe('igx-circular-bar'); + expect(progress.textVisibility).toBe(true); + expect(progress.hasText).toBe(false); + }); + + it('should correctly apply the ID attribute', () => { + const customId = 'custom-circular-bar-id'; + progress.id = customId; + fixture.detectChanges(); + + expect(progress.id).toBe(customId); + // For test fixture destroy + progress.id = "root1"; + fixture.detectChanges(); + }); + + it('should correctly toggle the indeterminate mode', () => { + hasClass(circularBar, 'igx-circular-bar--indeterminate', false); + + progress.indeterminate = true; + fixture.detectChanges(); + + hasClass(circularBar, 'igx-circular-bar--indeterminate', true); + }); + + it('should correctly toggle animation', () => { + hasClass(circularBar, 'igx-circular-bar--animation-none', false); + + progress.animate = false; + fixture.detectChanges(); + + hasClass(circularBar, 'igx-circular-bar--animation-none', true); + }); + + it('should toggle counter visibility when custom text is provided', () => { + // Default state: no custom text + expect(progress.hasText).toBe(false); + hasClass(circularBar, 'igx-circular-bar--hide-counter', false); + + // Provide custom text + progress.text = 'Custom Text'; + fixture.detectChanges(); + expect(progress.hasText).toBe(true); + hasClass(circularBar, 'igx-circular-bar--hide-counter', true); + + // Remove custom text + progress.text = null; + fixture.detectChanges(); + expect(progress.hasText).toBe(false); + hasClass(circularBar, 'igx-circular-bar--hide-counter', false); + }); + + it('should toggle text visibility when textVisibility is changed', () => { + // Default state: textVisibility is true, text container is present + expect(progress.textVisibility).toBe(true); + let textElement = circularBar.querySelector('.igx-circular-bar__text'); + expect(textElement).not.toBeNull(); + + // Set textVisibility to false + progress.textVisibility = false; + fixture.detectChanges(); + + textElement = circularBar.querySelector('.igx-circular-bar__text'); + expect(progress.textVisibility).toBe(false); + expect(textElement).toBeNull(); + + // Set textVisibility back to true + progress.textVisibility = true; + fixture.detectChanges(); + + textElement = circularBar.querySelector('.igx-circular-bar__text'); + expect(progress.textVisibility).toBe(true); + expect(textElement).not.toBeNull(); + }); + + it('should correctly apply the gradient ID', async () => { + const gradientId = progress.gradientId; + expect(gradientId).toContain('igx-circular-gradient-'); + + fixture.detectChanges(); + await fixture.whenStable(); + + const outerCircle = circularBar.querySelector('.igx-circular-bar__outer') as SVGElement; + expect(outerCircle).not.toBeNull(); + + // Use getComputedStyle to get the applied stroke + const strokeStyle = getComputedStyle(outerCircle).stroke; + + // Removing quotes from the stroke style + const normalizedStrokeStyle = strokeStyle?.replace(/"/g, ''); + expect(normalizedStrokeStyle).toBe(`url(#${gradientId})`); + }); + + it('should correctly provide the context object', () => { + const context = progress.context; + + expect(context.$implicit.value).toBe(progress.value); + expect(context.$implicit.valueInPercent).toBe(progress.valueInPercent); + expect(context.$implicit.max).toBe(progress.max); + }); + + it('should correctly update aria attributes', () => { + progress.max = 200; + progress.value = 50; + + fixture.detectChanges(); + + expect(circularBar.getAttribute('aria-valuenow')).toBe('50'); + expect(circularBar.getAttribute('aria-valuemax')).toBe('200'); + }); +}); diff --git a/projects/igniteui-angular/progressbar/src/progressbar/linearbar.component.spec.ts b/projects/igniteui-angular/progressbar/src/progressbar/linearbar.component.spec.ts new file mode 100644 index 00000000000..5f24e39ba7a --- /dev/null +++ b/projects/igniteui-angular/progressbar/src/progressbar/linearbar.component.spec.ts @@ -0,0 +1,177 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { IgxLinearProgressBarComponent } from './progressbar.component'; +import { hasClass } from "../../../test-utils/helper-utils.spec"; + +describe('IgxLinearProgressBarComponent', () => { + let fixture: ComponentFixture; + let progress: IgxLinearProgressBarComponent; + let linearBar: HTMLElement; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [IgxLinearProgressBarComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(IgxLinearProgressBarComponent); + progress = fixture.componentInstance; + // For test fixture destroy + progress.id = "root1"; + fixture.detectChanges(); + linearBar = fixture.debugElement.nativeElement; + }); + + it('should initialize with default attributes', () => { + expect(progress.valueMin).toBe(0); + expect(progress.cssClass).toBe('igx-linear-bar'); + expect(progress.striped).toBe(false); + expect(progress.role).toBe('progressbar'); + expect(progress.type).toBe('default'); + }); + + it('should correctly toggle the striped style', () => { + hasClass(linearBar, 'igx-linear-bar--striped', false); + + progress.striped = true; + fixture.detectChanges(); + + hasClass(linearBar, 'igx-linear-bar--striped', true); + }); + + it('should correctly toggle the indeterminate mode', () => { + hasClass(linearBar, 'igx-linear-bar--indeterminate', false); + + progress.indeterminate = true; + fixture.detectChanges(); + + hasClass(linearBar, 'igx-linear-bar--indeterminate', true); + }); + + it('should correctly toggle animation', () => { + hasClass(linearBar, 'igx-linear-bar--animation-none', false); + + progress.animate = false; + fixture.detectChanges(); + + hasClass(linearBar, 'igx-linear-bar--animation-none', true); + }); + + it('should correctly indicate if custom text is provided via hasText', () => { + expect(progress.hasText).toBe(false); + + progress.text = 'Custom Text'; + fixture.detectChanges(); + + expect(progress.hasText).toBe(true); + }); + + it('should toggle counter visibility when custom text is provided', () => { + // Default state: no custom text + expect(progress.hasText).toBe(false); + hasClass(linearBar, 'igx-linear-bar--hide-counter', false); + + // Provide custom text + progress.text = 'Custom Text'; + fixture.detectChanges(); + expect(progress.hasText).toBe(true); + hasClass(linearBar, 'igx-linear-bar--hide-counter', true); + + // Remove custom text + progress.text = null; + fixture.detectChanges(); + expect(progress.hasText).toBe(false); + hasClass(linearBar, 'igx-linear-bar--hide-counter', false); + }); + + it('should toggle text visibility when textVisibility is changed', () => { + const valueElement = linearBar.querySelector('.igx-linear-bar__value') as HTMLElement; + + // Default state: textVisibility is true + hasClass(valueElement, 'igx-linear-bar__value--hidden', false); + + // Set textVisibility to false + progress.textVisibility = false; + fixture.detectChanges(); // Ensure bindings are updated + hasClass(valueElement, 'igx-linear-bar__value--hidden', true); + + // Set textVisibility back to true + progress.textVisibility = true; + fixture.detectChanges(); // Ensure bindings are updated + hasClass(valueElement, 'igx-linear-bar__value--hidden', false); + }); + + it('should correctly set text alignment', () => { + expect(progress.textAlign).toBe('start'); + + progress.textAlign = 'center'; + fixture.detectChanges(); + expect(progress.textAlign).toBe('center'); + + progress.textAlign = 'end'; + fixture.detectChanges(); + expect(progress.textAlign).toBe('end'); + }); + + it('should correctly toggle text position above progress line', () => { + const valueElement = linearBar.querySelector('.igx-linear-bar__value') as HTMLElement; + + // Default state: textTop is false, and class should not be present + hasClass(valueElement, 'igx-linear-bar__value--top', false); + + // Enable textTop + progress.textTop = true; + fixture.detectChanges(); // Ensure bindings are updated + hasClass(valueElement, 'igx-linear-bar__value--top', true); + + // Disable textTop + progress.textTop = false; + fixture.detectChanges(); // Ensure bindings are updated + hasClass(valueElement, 'igx-linear-bar__value--top', false); + }); + + it('should correctly apply the ID attribute', () => { + const customId = 'custom-linear-bar-id'; + progress.id = customId; + fixture.detectChanges(); + + expect(progress.id).toBe(customId); + expect(linearBar.id).toBe(customId); + // For test fixture destroy + progress.id = "root1"; + fixture.detectChanges(); + }); + + it('should apply type-specific classes correctly', () => { + hasClass(linearBar, 'igx-linear-bar--danger', false); + hasClass(linearBar, 'igx-linear-bar--info', false); + hasClass(linearBar, 'igx-linear-bar--warning', false); + hasClass(linearBar, 'igx-linear-bar--success', false); + + progress.type = 'success'; + fixture.detectChanges(); + hasClass(linearBar, 'igx-linear-bar--success', true); + + progress.type = 'error'; + fixture.detectChanges(); + hasClass(linearBar, 'igx-linear-bar--danger', true); + + progress.type = 'info'; + fixture.detectChanges(); + hasClass(linearBar, 'igx-linear-bar--info', true); + + progress.type = 'warning'; + fixture.detectChanges(); + hasClass(linearBar, 'igx-linear-bar--warning', true); + }); + + it('should correctly update aria attributes', () => { + progress.max = 200; + progress.value = 50; + + fixture.detectChanges(); + + expect(linearBar.getAttribute('aria-valuenow')).toBe('50'); + expect(linearBar.getAttribute('aria-valuemax')).toBe('200'); + }); +}); diff --git a/projects/igniteui-angular/progressbar/src/progressbar/progressbar.common.ts b/projects/igniteui-angular/progressbar/src/progressbar/progressbar.common.ts new file mode 100644 index 00000000000..cf7b1666a44 --- /dev/null +++ b/projects/igniteui-angular/progressbar/src/progressbar/progressbar.common.ts @@ -0,0 +1,18 @@ +import { Directive, TemplateRef, inject } from '@angular/core'; + +@Directive({ + selector: '[igxProgressBarText]', + standalone: true +}) +export class IgxProgressBarTextTemplateDirective { + public template = inject>(TemplateRef); +} + +@Directive({ + selector: '[igxProgressBarGradient]', + standalone: true +}) +export class IgxProgressBarGradientDirective { + public template = inject>(TemplateRef); +} + diff --git a/projects/igniteui-angular/progressbar/src/progressbar/progressbar.component.spec.ts b/projects/igniteui-angular/progressbar/src/progressbar/progressbar.component.spec.ts new file mode 100644 index 00000000000..d77c076568d --- /dev/null +++ b/projects/igniteui-angular/progressbar/src/progressbar/progressbar.component.spec.ts @@ -0,0 +1,205 @@ +import { Component } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { BaseProgressDirective } from './progressbar.component'; + +@Component({ + template: ``, +}) +class TestComponent extends BaseProgressDirective {} + +describe('BaseProgressDirective', () => { + let fixture: ComponentFixture; + let component: TestComponent; + let nativeElement: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TestComponent], // Declare the test component + }).compileComponents(); + + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + nativeElement = fixture.nativeElement; + fixture.detectChanges(); + }); + + it('should initialize with default values', () => { + expect(component.indeterminate).toBe(false); + expect(component.animationDuration).toBe(2000); + expect(component.max).toBe(100); + expect(component.value).toBe(0); + expect(component.animate).toBe(true); + }); + + it('should correctly update the value within range', () => { + component.value = 50; + expect(component.value).toBe(50); + + // Value below range is not allowed + component.value = -10; + expect(component.value).toBe(0); + + // Value above range is not allowed + component.value = 150; + expect(component.value).toBe(100); + }); + + it('should update value if indeterminate is true', () => { + component.indeterminate = true; + component.value = 50; + expect(component.value).toBe(50); + }); + + it('should correctly calculate value in percentage', () => { + component.value = 50; + expect(component.valueInPercent).toBe(50); + + component.value = 25; + component.max = 200; + expect(component.valueInPercent).toBe(12.5); + }); + + it('should not exceed maximum or minimum value when max is updated', () => { + component.value = 80; + + component.max = 50; // Reduce max below value + expect(component.value).toBe(50); + + component.max = 200; // Increase max + expect(component.value).toBe(50); + }); + + it('should handle floating-point numbers correctly', () => { + component.max = 2.5; + component.value = 2.67; + + // Expect value to be clamped to max + expect(component.value).toBe(2.5); + expect(component.valueInPercent).toBe(100); + + component.value = -0.3; + + // Expect value to be clamped to 0 + expect(component.value).toBe(0); + expect(component.valueInPercent).toBe(0); + }); + + it('should handle max set to 0 correctly', () => { + component.max = 0; + + // Expect value to be clamped to max + component.value = 10; + expect(component.value).toBe(0); + expect(component.valueInPercent).toBe(0); + }); + + it('should calculate step as 1% of max by default', () => { + const defaultStep = component.max * 0.01; + expect(component.step).toBe(defaultStep); + }); + + it('should not allow step larger than max', () => { + component.step = 150; + expect(component.step).toBe(component.max * 0.01); + }); + + it('should not constantly update progress value when value and max differ', () => { + component.max = 3.25; + component.value = 2.55; + + fixture.detectChanges(); + + const progressBar = fixture.debugElement.nativeElement; + expect(parseFloat(progressBar.attributes['aria-valuenow'].textContent)).toBe(component.value); + expect(component.value).toBe(2.55); + }); + + it('should adjust value correctly when max is decreased', () => { + component.max = 100; + component.value = 80; + + component.max = 50; // Decrease max below value + expect(component.value).toBe(50); + }); + + it('should not adjust value when max is increased', () => { + component.max = 50; + component.value = 40; + + component.max = 100; // Increase max + expect(component.value).toBe(40); + }); + + it('should correctly calculate step based on max', () => { + expect(component.step).toBe(1); // Default step is 1% of max (100) + + component.max = 200; + expect(component.step).toBe(2); // 1% of 200 + + component.step = 10; // Custom step + expect(component.step).toBe(10); + }); + + it('should correctly toggle animation', () => { + component.animate = false; + expect(component.animate).toBe(false); + + component.animate = true; + expect(component.animate).toBe(true); + }); + + it('should correctly update host styles', fakeAsync(() => { + component.value = 50; + + tick(50); + + fixture.detectChanges(); + + expect(nativeElement.style.getPropertyValue('--_progress-integer')).toBe('50'); + expect(nativeElement.style.getPropertyValue('--_progress-fraction')).toBe('0'); + expect(nativeElement.style.getPropertyValue('--_progress-whole')).toBe('50.00'); + expect(nativeElement.style.getPropertyValue('--_transition-duration')).toBe('2000ms'); + })); + + it('should correctly calculate fraction and integer values for progress', fakeAsync(() => { + component.value = 75.25; + + tick(50); + fixture.detectChanges(); + + expect(nativeElement.style.getPropertyValue('--_progress-integer')).toBe('75'); + expect(nativeElement.style.getPropertyValue('--_progress-fraction')).toBe('25'); + expect(nativeElement.style.getPropertyValue('--_progress-whole')).toBe('75.25'); + })); + + it('should trigger progressChanged event when value changes', () => { + spyOn(component.progressChanged, 'emit'); + + component.value = 30; + expect(component.progressChanged.emit).toHaveBeenCalledWith({ + previousValue: 0, + currentValue: 30, + }); + + component.value = 50; + expect(component.progressChanged.emit).toHaveBeenCalledWith({ + previousValue: 30, + currentValue: 50, + }); + }); + + it('should not trigger progressChanged event when value remains the same', () => { + spyOn(component.progressChanged, 'emit'); + + component.value = 0; // Default value is already 0 + expect(component.progressChanged.emit).not.toHaveBeenCalled(); + }); + + it('should trigger progressChanged event when indeterminate is true', () => { + spyOn(component.progressChanged, 'emit'); + + component.indeterminate = true; + component.value = 30; + expect(component.progressChanged.emit).toHaveBeenCalled(); + }); +}); diff --git a/projects/igniteui-angular/progressbar/src/progressbar/progressbar.component.ts b/projects/igniteui-angular/progressbar/src/progressbar/progressbar.component.ts new file mode 100644 index 00000000000..18aad18850c --- /dev/null +++ b/projects/igniteui-angular/progressbar/src/progressbar/progressbar.component.ts @@ -0,0 +1,656 @@ +import { NgClass, NgTemplateOutlet } from '@angular/common'; +import { + Component, + ElementRef, + EventEmitter, + HostBinding, + Input, + Output, + Renderer2, + ViewChild, + ContentChild, + AfterContentInit, + Directive, + booleanAttribute, + inject, + ChangeDetectorRef, + NgZone, +} from '@angular/core'; +import { + IgxProgressBarTextTemplateDirective, + IgxProgressBarGradientDirective, +} from './progressbar.common'; +import { IBaseEventArgs } from 'igniteui-angular/core'; +const ONE_PERCENT = 0.01; +const MIN_VALUE = 0; + +export const IgxTextAlign = { + START: 'start', + CENTER: 'center', + END: 'end' +} as const; +export type IgxTextAlign = (typeof IgxTextAlign)[keyof typeof IgxTextAlign]; + +export const IgxProgressType = { + ERROR: 'error', + INFO: 'info', + WARNING: 'warning', + SUCCESS: 'success' +} as const; +export type IgxProgressType = (typeof IgxProgressType)[keyof typeof IgxProgressType]; + +export interface IChangeProgressEventArgs extends IBaseEventArgs { + previousValue: number; + currentValue: number; +} +export const valueInRange = (value: number, max: number, min = 0): number => Math.max(Math.min(value, max), min); + +/** + * @hidden + */ +@Directive() +export abstract class BaseProgressDirective { + /** + * An event, which is triggered after progress is changed. + * ```typescript + * public progressChange(event) { + * alert("Progress made!"); + * } + * //... + * ``` + * ```html + * + * + * ``` + */ + @Output() + public progressChanged = new EventEmitter(); + + /** + * Sets/Gets progressbar animation duration. By default, it is 2000ms. + * ```html + * + * + * ``` + */ + @Input() + public animationDuration = 2000; + + protected _contentInit = false; + protected _indeterminate = false; + protected _text: string; + protected _max = 100; + protected _value = MIN_VALUE; + protected _animate = true; + protected _step: number; + protected _fraction = 0; + protected _integer = 0; + protected _cdr = inject(ChangeDetectorRef); + protected _zone = inject(NgZone); + + /** + * Sets progressbar in indeterminate. By default, it is set to false. + * ```html + * + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public set indeterminate(isIndeterminate: boolean) { + this._indeterminate = isIndeterminate; + this._resetCounterValues(this._indeterminate); // Use the helper for indeterminate condition + } + + /** + * Gets the current state of the progress bar: + * - `true` if in the indeterminate state (no progress value displayed), + * - `false` if the progress bar shows the actual progress. + * + * ```typescript + * const isIndeterminate = progressBar.indeterminate; + * ``` + */ + public get indeterminate(): boolean { + return this._indeterminate; + } + + /** + * Returns the value which update the progress indicator of the `progress bar`. + * ```typescript + * @ViewChild("MyProgressBar") + * public progressBar: IgxLinearProgressBarComponent | IgxCircularBarComponent; + * public stepValue(event) { + * let step = this.progressBar.step; + * alert(step); + * } + * ``` + */ + @Input() + public get step(): number { + if (this._step) { + return this._step; + } + return this._max * ONE_PERCENT; + } + + /** + * Sets the value by which progress indicator is updated. By default, it is 1. + * ```html + * + * + * ``` + */ + public set step(val: number) { + const step = Number(val); + if (step > this.max) { + return; + } + + this._step = step; + } + + + /** + * Set a custom text. This will hide the counter value. + * ```html + * + * ``` + */ + @Input() + public set text(value: string) { + this._text = value; + this._resetCounterValues(!!this._text); // Use the helper for text condition + } + + /** + * Gets a custom text. + * ```typescript + * let text = this.circularBar.text; + * ``` + */ + public get text(): string { + return this._text; + } + + /** + * Animating the progress. By default, it is set to true. + * ```html + * + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public set animate(animate: boolean) { + this._animate = animate; + } + + /** + * Returns whether the `progress bar` has animation true/false. + * ```typescript + * @ViewChild("MyProgressBar") + * public progressBar: IgxLinearProgressBarComponent | IgxCircularBarComponent; + * public animationStatus(event) { + * let animationStatus = this.progressBar.animate; + * alert(animationStatus); + * } + * ``` + */ + public get animate(): boolean { + return this._animate; + } + + /** + * Set maximum value that can be passed. By default it is set to 100. + * ```html + * + * + * ``` + */ + @HostBinding('attr.aria-valuemax') + @Input() + public set max(maxNum: number) { + // Ignore invalid or unchanged max + if (maxNum < MIN_VALUE || this._max === maxNum) { + return; + } + + this._max = maxNum; + + // Revalidate current value + this._value = valueInRange(this._value, this._max); + + // Refresh CSS variables + this._updateProgressValues(); + } + + /** + * Returns the maximum progress value of the `progress bar`. + * ```typescript + * @ViewChild("MyProgressBar") + * public progressBar: IgxLinearProgressBarComponent | IgxCircularBarComponent; + * public maxValue(event) { + * let max = this.progressBar.max; + * alert(max); + * } + * ``` + */ + public get max() { + return this._max; + } + + @HostBinding('style.--_progress-integer') + private get progressInteger() { + return this._integer.toString(); + } + + @HostBinding('style.--_progress-fraction') + private get progressFraction() { + return this._fraction.toString(); + } + + @HostBinding('style.--_progress-whole') + private get progressWhole() { + return this.valueInPercent.toFixed(2); + } + + @HostBinding('style.--_transition-duration') + private get transitionDuration() { + return `${this.animationDuration}ms`; + } + + /** + * @hidden + */ + protected get hasFraction(): boolean { + const percentage = this.valueInPercent; + const integerPart = Math.floor(percentage); + const fractionalPart = percentage - integerPart; + + return fractionalPart > 0; + } + + /** + * Returns the `IgxLinearProgressBarComponent`/`IgxCircularProgressBarComponent` value in percentage. + * ```typescript + * @ViewChild("MyProgressBar") + * public progressBar: IgxLinearProgressBarComponent / IgxCircularProgressBarComponent + * public valuePercent(event){ + * let percentValue = this.progressBar.valueInPercent; + * alert(percentValue); + * } + * ``` + */ + public get valueInPercent(): number { + const result = this.max > 0 ? (this._value / this.max) * 100 : 0; + return Math.round(result * 100) / 100; // Round to two decimal places + } + + /** + * Returns value that indicates the current `IgxLinearProgressBarComponent`/`IgxCircularProgressBarComponent` position. + * ```typescript + * @ViewChild("MyProgressBar") + * public progressBar: IgxLinearProgressBarComponent / IgxCircularProgressBarComponent; + * public getValue(event) { + * let value = this.progressBar.value; + * alert(value); + * } + * ``` + */ + @HostBinding('attr.aria-valuenow') + @Input() + public get value(): number { + return this._value; + } + + /** + * @hidden + */ + protected _updateProgressValues(): void { + const percentage = this.valueInPercent; + const integerPart = Math.floor(percentage); + const fractionalPart = Math.round((percentage % 1) * 100); + + this._integer = integerPart; + this._fraction = fractionalPart; + } + + private _resetCounterValues(condition: boolean) { + if (condition) { + this._integer = 0; + this._fraction = 0; + } else { + this._zone.runOutsideAngular(() => { + setTimeout(() => { + this._updateProgressValues(); + this._cdr.markForCheck(); + }); + }); + } + } + + /** + * Set value that indicates the current `IgxLinearProgressBarComponent / IgxCircularProgressBarComponent` position. + * ```html + * + * + * ``` + */ + public set value(val) { + const valInRange = valueInRange(val, this.max); // Ensure value is in range + + // Avoid redundant updates + if (isNaN(valInRange) || this._value === valInRange) { + return; + } + + const previousValue = this._value; + + // Update internal value + this._value = valInRange; + + this._zone.runOutsideAngular(() => { + setTimeout(() => { + this._updateProgressValues(); + this._cdr.markForCheck(); + }); + }); + + // Emit the progressChanged event + this.progressChanged.emit({ + previousValue, + currentValue: this._value, + }); + } +} +let NEXT_LINEAR_ID = 0; +let NEXT_CIRCULAR_ID = 0; +let NEXT_GRADIENT_ID = 0; +@Component({ + selector: 'igx-linear-bar', + templateUrl: 'templates/linear-bar.component.html', + imports: [NgClass] +}) +export class IgxLinearProgressBarComponent extends BaseProgressDirective implements AfterContentInit { + @HostBinding('attr.aria-valuemin') + public valueMin = 0; + + @HostBinding('class.igx-linear-bar') + public cssClass = 'igx-linear-bar'; + + /** + * Set `IgxLinearProgressBarComponent` to have striped style. By default it is set to false. + * ```html + * + * ``` + */ + @HostBinding('class.igx-linear-bar--striped') + @Input({ transform: booleanAttribute }) + public striped = false; + + /** + * @hidden + * ``` + */ + @HostBinding('class.igx-linear-bar--indeterminate') + public get isIndeterminate() { + return this.indeterminate; + } + + /** + * Sets the value of the `role` attribute. If not provided it will be automatically set to `progressbar`. + * ```html + * + * ``` + */ + @HostBinding('attr.role') + @Input() + public role = 'progressbar'; + + /** + * Sets the value of `id` attribute. If not provided it will be automatically generated. + * ```html + * + * ``` + */ + @HostBinding('attr.id') + @Input() + public id = `igx-linear-bar-${NEXT_LINEAR_ID++}`; + + /** + * @hidden + */ + @HostBinding('class.igx-linear-bar--animation-none') + public get disableAnimationClass(): boolean { + return !this._animate; + } + + /** + * @hidden + */ + @HostBinding('class.igx-linear-bar--hide-counter') + public get hasText(): boolean { + return !!this.text; + } + + /** + * Set the position that defines where the text is aligned. + * Possible options - `IgxTextAlign.START` (default), `IgxTextAlign.CENTER`, `IgxTextAlign.END`. + * ```typescript + * public positionCenter: IgxTextAlign; + * public ngOnInit() { + * this.positionCenter = IgxTextAlign.CENTER; + * } + * //... + * ``` + * ```html + * + * ``` + */ + @Input() + public textAlign: IgxTextAlign = IgxTextAlign.START; + + /** + * Set the text to be visible. By default, it is set to true. + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public textVisibility = true; + + /** + * Set the position that defines if the text should be aligned above the progress line. By default, is set to false. + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public textTop = false; + + /** + * Set type of the `IgxLinearProgressBarComponent`. Possible options - `default`, `success`, `info`, `warning`, and `error`. + * ```html + * + * ``` + */ + @Input() + public type = 'default'; + + /** + * @hidden + */ + @HostBinding('class.igx-linear-bar--danger') + public get error() { + return this.type === IgxProgressType.ERROR; + } + + /** + * @hidden + */ + @HostBinding('class.igx-linear-bar--info') + public get info() { + return this.type === IgxProgressType.INFO; + } + + /** + * @hidden + */ + @HostBinding('class.igx-linear-bar--warning') + public get warning() { + return this.type === IgxProgressType.WARNING; + } + + /** + * @hidden + */ + @HostBinding('class.igx-linear-bar--success') + public get success() { + return this.type === IgxProgressType.SUCCESS; + } + + public ngAfterContentInit() { + this._contentInit = true; + } +} + +@Component({ + selector: 'igx-circular-bar', + templateUrl: 'templates/circular-bar.component.html', + imports: [NgTemplateOutlet, NgClass] +}) +export class IgxCircularProgressBarComponent extends BaseProgressDirective implements AfterContentInit { + private renderer = inject(Renderer2); + + /** + * @hidden + */ + @HostBinding('class.igx-circular-bar') + public cssClass = 'igx-circular-bar'; + + /** + * Sets the value of `id` attribute. If not provided it will be automatically generated. + * ```html + * + * ``` + */ + @HostBinding('attr.id') + @Input() + public id = `igx-circular-bar-${NEXT_CIRCULAR_ID++}`; + + /** + * @hidden + */ + @HostBinding('class.igx-circular-bar--indeterminate') + public get isIndeterminate() { + return this.indeterminate; + } + + /** + * @hidden + */ + @HostBinding('class.igx-circular-bar--animation-none') + public get disableAnimationClass(): boolean { + return !this._animate; + } + + /** + * @hidden + */ + @HostBinding('class.igx-circular-bar--hide-counter') + public get hasText(): boolean { + return !!this.text; + } + + /** + * Sets the text visibility. By default, it is set to true. + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public textVisibility = true; + + @ContentChild(IgxProgressBarTextTemplateDirective, { read: IgxProgressBarTextTemplateDirective }) + public textTemplate: IgxProgressBarTextTemplateDirective; + + @ContentChild(IgxProgressBarGradientDirective, { read: IgxProgressBarGradientDirective }) + public gradientTemplate: IgxProgressBarGradientDirective; + + @ViewChild('circle', { static: true }) + private _svgCircle: ElementRef; + + /** + * @hidden + */ + public gradientId = `igx-circular-gradient-${NEXT_GRADIENT_ID++}`; + + /** + * @hidden + */ + public get context(): any { + return { + $implicit: { value: this.value, valueInPercent: this.valueInPercent, max: this.max } + }; + } + + /** + * @hidden + */ + public get textContent(): string { + return this.text; + } + + /** + * Set type of the `IgxCircularProgressBarComponent`. Possible options - `default`, `success`, `info`, `warning`, and `error`. + * ```html + * + * ``` + */ + @Input() + public type = 'default'; + + /** + * @hidden + */ + @HostBinding('class.igx-circular-bar--danger') + public get error() { + return this.type === IgxProgressType.ERROR; + } + + /** + * @hidden + */ + @HostBinding('class.igx-circular-bar--info') + public get info() { + return this.type === IgxProgressType.INFO; + } + + /** + * @hidden + */ + @HostBinding('class.igx-circular-bar--warning') + public get warning() { + return this.type === IgxProgressType.WARNING; + } + + /** + * @hidden + */ + @HostBinding('class.igx-circular-bar--success') + public get success() { + return this.type === IgxProgressType.SUCCESS; + } + + /** + * @hidden + */ + @HostBinding('style.stroke') + public get strokeStyle() { + return this.type === 'default' ? `url(#${this.gradientId})` : 'none'; + } + + public ngAfterContentInit() { + this._contentInit = true; + } + +} diff --git a/projects/igniteui-angular/progressbar/src/progressbar/progressbar.module.ts b/projects/igniteui-angular/progressbar/src/progressbar/progressbar.module.ts new file mode 100644 index 00000000000..58a7e43a983 --- /dev/null +++ b/projects/igniteui-angular/progressbar/src/progressbar/progressbar.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { IGX_PROGRESS_BAR_DIRECTIVES } from './public_api'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + ...IGX_PROGRESS_BAR_DIRECTIVES + ], + exports: [ + ...IGX_PROGRESS_BAR_DIRECTIVES + ] +}) +export class IgxProgressBarModule { } diff --git a/projects/igniteui-angular/progressbar/src/progressbar/public_api.ts b/projects/igniteui-angular/progressbar/src/progressbar/public_api.ts new file mode 100644 index 00000000000..2e5508447d4 --- /dev/null +++ b/projects/igniteui-angular/progressbar/src/progressbar/public_api.ts @@ -0,0 +1,32 @@ +import { IgxProgressBarGradientDirective, IgxProgressBarTextTemplateDirective } from './progressbar.common'; +import { IgxCircularProgressBarComponent, IgxLinearProgressBarComponent } from './progressbar.component'; + +export * from './progressbar.common'; +export { + IgxTextAlign, + IgxProgressType, + IChangeProgressEventArgs, + IgxLinearProgressBarComponent, + IgxCircularProgressBarComponent +} from './progressbar.component'; + +/* NOTE: Progress bar (linear and circular) directives collection for ease-of-use import in standalone components scenario */ +export const IGX_PROGRESS_BAR_DIRECTIVES = [ + IgxLinearProgressBarComponent, + IgxCircularProgressBarComponent, + IgxProgressBarTextTemplateDirective, + IgxProgressBarGradientDirective +] as const; + +/* NOTE: Linear progress bar directives collection for ease-of-use import in standalone components scenario */ +export const IGX_LINEAR_PROGRESS_BAR_DIRECTIVES = [ + IgxLinearProgressBarComponent, + IgxProgressBarGradientDirective +] as const; + +/* NOTE: Circular progress bar directives collection for ease-of-use import in standalone components scenario */ +export const IGX_CIRCULAR_PROGRESS_BAR_DIRECTIVES = [ + IgxCircularProgressBarComponent, + IgxProgressBarTextTemplateDirective, + IgxProgressBarGradientDirective +] as const; diff --git a/projects/igniteui-angular/progressbar/src/progressbar/templates/circular-bar.component.html b/projects/igniteui-angular/progressbar/src/progressbar/templates/circular-bar.component.html new file mode 100644 index 00000000000..b2d9ce057c5 --- /dev/null +++ b/projects/igniteui-angular/progressbar/src/progressbar/templates/circular-bar.component.html @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + +@if (textVisibility) { + + + + +} + + + {{textContent}} + + + diff --git a/projects/igniteui-angular/progressbar/src/progressbar/templates/linear-bar.component.html b/projects/igniteui-angular/progressbar/src/progressbar/templates/linear-bar.component.html new file mode 100644 index 00000000000..088baf0a008 --- /dev/null +++ b/projects/igniteui-angular/progressbar/src/progressbar/templates/linear-bar.component.html @@ -0,0 +1,19 @@ + +
    +
    +
    +
    + + + {{text}} + + diff --git a/projects/igniteui-angular/progressbar/src/public_api.ts b/projects/igniteui-angular/progressbar/src/public_api.ts new file mode 100644 index 00000000000..e54be4bc9c9 --- /dev/null +++ b/projects/igniteui-angular/progressbar/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './progressbar/public_api'; +export * from './progressbar/progressbar.module'; diff --git a/projects/igniteui-angular/query-builder/README.md b/projects/igniteui-angular/query-builder/README.md new file mode 100644 index 00000000000..e5a3cb923f0 --- /dev/null +++ b/projects/igniteui-angular/query-builder/README.md @@ -0,0 +1,46 @@ +# igx-query-builder +The **IgxQueryBuilder** component provides a way to build complex queries through the UI. By specifying AND/OR operators, conditions and values the user creates an expression tree which describes the query. +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/query-builder) + +## Usage +```html + + + + + + + +``` + +## API + +### igx-query-builder + +#### Properties + +| Name | Type | Description | +| :--- | :--- | :--- | +| `entities` | EntityType[] | An array of entities. Contains information about name and fields. | +| `expressionTree` | IExpressionTree | Gets/Sets the displayed expressions tree. | +| `locale` | string | Locale settings for the component. If this locale is not set, its value to be determined based on the global Angular application LOCALE_ID. | +| `resourceStrings` | IQueryBuilderResourceStrings | Gets/sets the resource strings. | +| `showEntityChangeDialog` | boolean | Gets/sets whether the confirmation dialog should be shown when changing entity. | +| `disableEntityChange` | boolean | Gets/sets whether the entity select on root level should be disabled after the initial selection. | +| `disableReturnFieldsChange` | boolean | Gets/sets whether the return fields combo on root level should be disabled. | + +#### Events + +| Name | Description | +| :--- | :--- | +| `expressionTreeChange` | Emitted when entity, return fields, condition, field, operand, value is changed. | no | - | + +### igx-query-builder-header + +#### Properties + +| Name | Type | Description | +| :--- | :--- | :--- | +| `title` | string | Sets the title displayed in the header. | diff --git a/projects/igniteui-angular/query-builder/index.ts b/projects/igniteui-angular/query-builder/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/query-builder/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/query-builder/ng-package.json b/projects/igniteui-angular/query-builder/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/query-builder/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/query-builder/src/public_api.ts b/projects/igniteui-angular/query-builder/src/public_api.ts new file mode 100644 index 00000000000..4b41ab7f297 --- /dev/null +++ b/projects/igniteui-angular/query-builder/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './query-builder/public_api'; +export * from './query-builder/query-builder.module'; diff --git a/projects/igniteui-angular/query-builder/src/query-builder/public_api.ts b/projects/igniteui-angular/query-builder/src/query-builder/public_api.ts new file mode 100644 index 00000000000..b711eedcd59 --- /dev/null +++ b/projects/igniteui-angular/query-builder/src/query-builder/public_api.ts @@ -0,0 +1,16 @@ +import { IgxQueryBuilderHeaderComponent } from './query-builder-header.component'; +import { IgxQueryBuilderComponent } from './query-builder.component'; +import { IgxQueryBuilderSearchValueTemplateDirective } from './query-builder.directives'; + +export { + IgxQueryBuilderComponent +} from './query-builder.component'; +export * from './query-builder-header.component'; +export * from './query-builder.directives'; + +/* NOTE: Query builder directives collection for ease-of-use import in standalone components scenario */ +export const IGX_QUERY_BUILDER_DIRECTIVES = [ + IgxQueryBuilderComponent, + IgxQueryBuilderHeaderComponent, + IgxQueryBuilderSearchValueTemplateDirective, +] as const; diff --git a/projects/igniteui-angular/query-builder/src/query-builder/query-builder-drag.service.ts b/projects/igniteui-angular/query-builder/src/query-builder/query-builder-drag.service.ts new file mode 100644 index 00000000000..72755e724fb --- /dev/null +++ b/projects/igniteui-angular/query-builder/src/query-builder/query-builder-drag.service.ts @@ -0,0 +1,539 @@ +import { filter, fromEvent, sampleTime, Subscription } from 'rxjs'; +import { IgxQueryBuilderTreeComponent } from './query-builder-tree.component'; +import { ElementRef, Injectable } from '@angular/core'; +import { ExpressionGroupItem, ExpressionItem, ExpressionOperandItem, QueryBuilderSelectors } from './query-builder.common'; +import { IgxChipComponent } from 'igniteui-angular/chips'; + +const DEFAULT_SET_Z_INDEX_DELAY = 10; +const Z_INDEX_TO_SET = 10010; //overlay z-index is 10005 + +/** @hidden @internal */ +@Injectable() +export class IgxQueryBuilderDragService { + + /** The ExpressionItem that's actually the drop ghost's content */ + public dropGhostExpression: ExpressionItem; + public isKeyboardDrag: boolean; + private _queryBuilderTreeComponent: IgxQueryBuilderTreeComponent; + private _queryBuilderTreeComponentElRef: ElementRef; + private _sourceExpressionItem: ExpressionItem; + private _sourceElement: HTMLElement; + private _targetExpressionItem: ExpressionItem; + private _dropUnder: boolean; + private _ghostChipMousemoveSubscription$: Subscription; + private _keyboardSubscription$: Subscription; + private _keyDragCurrentIndex: number = 0; + private _keyDragInitialIndex: number = 0; + private _isKeyDragsFirstMove: boolean = true; + /** Stores a flat ordered list of possible drop locations as Tuple <[targetExpression, dropUnder]>, while performing the keyboard drag&drop */ + private _possibleDropLocations: Array<[ExpressionItem, boolean]>; + private _timeoutId: any; + + + /** Get the dragged ghost as a HTMLElement*/ + private get getDragGhostElement(): HTMLElement { + return (document.querySelector(`.${QueryBuilderSelectors.CHIP_GHOST}[ghostclass="${QueryBuilderSelectors.CHIP_GHOST}"]`) as HTMLElement); + } + + /** Get the drop ghost chip component */ + private get getDropGhostElement(): IgxChipComponent { + return this._queryBuilderTreeComponent.expressionsChips.find(x => x.data === this.dropGhostExpression); + } + + private get getMainExpressionTree(): HTMLElement { + return this._queryBuilderTreeComponentElRef.nativeElement.querySelector(`.${QueryBuilderSelectors.FILTER_TREE}`); + } + + + public register(tree: IgxQueryBuilderTreeComponent, el: ElementRef) { + this._queryBuilderTreeComponent = tree; + this._queryBuilderTreeComponentElRef = el; + } + + /** When chip is picked up for dragging + * + * @param sourceDragElement The HTML element of the chip that's been dragged + * @param sourceExpressionItem The expressionItem of the chip that's been dragged + * @param isKeyboardDrag If it's a mouse drag or keyboard reorder + * + */ + public onMoveStart(sourceDragElement: HTMLElement, sourceExpressionItem: ExpressionItem, isKeyboardDrag: boolean): void { + this.resetDragAndDrop(true); + this._queryBuilderTreeComponent._expressionTreeCopy = this._queryBuilderTreeComponent._expressionTree; + this.isKeyboardDrag = isKeyboardDrag; + this._sourceExpressionItem = sourceExpressionItem; + this._sourceElement = sourceDragElement; + + this.listenToKeyboard(); + + if (!this.isKeyboardDrag) { + this._sourceElement.style.display = 'none'; + this.setDragGhostZIndex(); + } + } + + /** When dragged chip is let go outside a proper drop zone */ + public onMoveEnd(): void { + if (!this._sourceElement || !this._sourceExpressionItem) { + return; + } + + if (this.dropGhostExpression) { + //If there is a ghost chip presented to the user, execute drop + this.onChipDropped(); + } else { + this.resetDragAndDrop(true); + } + + this._ghostChipMousemoveSubscription$?.unsubscribe(); + this._keyboardSubscription$?.unsubscribe(); + } + + /** When mouse drag enters a chip's area + * @param targetDragElement The HTML element of the drop area chip that's been dragged to + * @param targetExpressionItem The expressionItem of the drop area chip that's been dragged to + */ + public onChipEnter(targetDragElement: HTMLElement, targetExpressionItem: ExpressionItem) { + if (!this._sourceElement || !this._sourceExpressionItem) { + return; + } + + //If entering the one that's been picked up don't do any thing + if (targetExpressionItem === this.dropGhostExpression) { + return; + } + + //Simulate leaving the last entered chip in case of no Leave event triggered due to the artificial drop zone of a north positioned ghost chip + if (this._targetExpressionItem) { + this.resetDragAndDrop(false); + } + + this._targetExpressionItem = targetExpressionItem; + + //Determine the middle point of the chip. + const appendUnder = this.ghostInLowerPart(targetDragElement); + + this.renderDropGhostChip(appendUnder); + } + + /** When mouse drag moves in a div's drop area + * @param targetDragElement The HTML element of the drop area chip that's been dragged to + * @param targetExpressionItem The expressionItem of the drop area chip that's been dragged to + */ + public onDivOver(targetDragElement: HTMLElement, targetExpressionItem: ExpressionItem) { + if (this._targetExpressionItem === targetExpressionItem) { + this.onChipOver(targetDragElement) + } else { + this.onChipEnter(targetDragElement, targetExpressionItem); + } + } + + /** When mouse drag moves in a chip's drop area + * @param targetDragElement The HTML element of the drop area chip that's been dragged to + */ + public onChipOver(targetDragElement: HTMLElement): void { + if (!this._sourceElement || !this._sourceExpressionItem) { + return; + } + + //Determine the middle point of the chip. + const appendUnder = this.ghostInLowerPart(targetDragElement); + + this.renderDropGhostChip(appendUnder); + } + + /** When mouse drag leaves a chip's drop area */ + public onChipLeave() { + if (!this._sourceElement || !this._sourceExpressionItem) { + return; + } + + //if the drag ghost is on the drop ghost row don't trigger leave + if (this.dragGhostIsOnDropGhostRow()) { + return; + } + + if (this._targetExpressionItem) { + this.resetDragAndDrop(false) + } + } + + /** When dragged chip is let go in div's drop area + * @param targetExpressionItem The expressionItem of the drop area chip that's been dragged to + */ + public onDivDropped(targetExpressionItem: ExpressionItem) { + if (targetExpressionItem !== this._sourceExpressionItem) { + this.onChipDropped(); + } + } + + /** When dragged chip is let go in chip's drop area */ + public onChipDropped() { + if (!this._sourceElement || !this._sourceExpressionItem) { + return; + } + + //Determine which chip to be focused after drop completes + const [dropLocationIndex, _] = this.countChipsBeforeDropLocation(this._queryBuilderTreeComponent.rootGroup); + + //Delete from old place + this._queryBuilderTreeComponent.deleteItem(this._sourceExpressionItem); + this.dropGhostExpression = null; + + this._queryBuilderTreeComponent.focusChipAfterDrag(dropLocationIndex); + + this.resetDragAndDrop(true); + + this._queryBuilderTreeComponent.exitEditAddMode(); + } + + /** When mouse drag moves in a AND/OR drop area + * @param targetDragElement The HTML element of the drop area chip that's been dragged to + * @param targetExpressionItem The expressionItem of the drop area chip that's been dragged to + */ + public onGroupRootOver(targetDragElement: HTMLElement, targetExpressionItem: ExpressionGroupItem) { + if (!this._sourceElement || !this._sourceExpressionItem) { + return; + } + + let newTargetExpressionItem; + + if (this.ghostInLowerPart(targetDragElement) || !targetExpressionItem.parent) { + //if ghost is in lower part of the AND/OR (or it's the main group) => drop as first child of that group + //accounting for the fact that the drop ghost might already be there as first child + if (targetExpressionItem.children[0] !== this.dropGhostExpression) { + newTargetExpressionItem = targetExpressionItem.children[0]; + } else { + newTargetExpressionItem = targetExpressionItem.children[1]; + } + } else { + //if ghost is in upper part => drop before the group starts + newTargetExpressionItem = targetExpressionItem; + } + + if (this._targetExpressionItem !== newTargetExpressionItem) { + this.resetDragAndDrop(false); + this._targetExpressionItem = newTargetExpressionItem; + this.renderDropGhostChip(false); + } + } + + /** When mouse drag moves in 'Add condition' button's drop area + * @param addConditionElement The Add condition button HTML Element + * @param rootGroup The root group of the query tree + */ + public onAddConditionEnter(addConditionElement: HTMLElement, rootGroup: ExpressionGroupItem) { + if (!this._sourceElement || !this._sourceExpressionItem) { + return; + } + const lastElement = addConditionElement.parentElement.previousElementSibling.lastElementChild; + + //simulate entering in the lower part of the last chip/group + this.onChipEnter(lastElement as HTMLElement, rootGroup.children[rootGroup.children.length - 1]); + } + + /** When chip's drag indicator is focused + * + * @param sourceDragElement The HTML element of the chip that's been dragged + * @param sourceExpressionItem The expressionItem of the chip that's been dragged + * + */ + public onChipDragIndicatorFocus(sourceDragElement: HTMLElement, sourceExpressionItem: ExpressionItem) { + //if drag is not underway, already + if (!this.getDropGhostElement) { + this.onMoveStart(sourceDragElement, sourceExpressionItem, true); + } + } + + /** When chip's drag indicator looses focus*/ + public onChipDragIndicatorFocusOut() { + if (this._sourceElement?.style?.display !== 'none') { + this.resetDragAndDrop(true); + this._keyboardSubscription$?.unsubscribe(); + } + } + + /** Upon blurring the tree, if Keyboard drag is underway and the next active item is not the drop ghost's drag indicator icon, cancel the drag&drop procedure*/ + public onDragFocusOut() { + if (this.isKeyboardDrag && this.getDropGhostElement) { + //have to wait a tick because upon blur, the next activeElement is always body, right before the next element gains focus + setTimeout(() => { + if (document.activeElement.className.indexOf(QueryBuilderSelectors.DRAG_INDICATOR) === -1) { + this.resetDragAndDrop(true); + this._keyboardSubscription$?.unsubscribe(); + } + }, 0); + } + } + + /** Checks if the dragged ghost is horizontally on the same line with the drop ghost*/ + private dragGhostIsOnDropGhostRow() { + const dragGhostBounds = this.getDragGhostElement.getBoundingClientRect(); + + const dropGhostBounds = this.getDropGhostElement?.nativeElement?.parentElement.getBoundingClientRect(); + + if (!dragGhostBounds || !dropGhostBounds) { + return false; + } + + const tolerance = dragGhostBounds.bottom - dragGhostBounds.top; + + return !(dragGhostBounds.bottom < dropGhostBounds.top - tolerance || dragGhostBounds.top > dropGhostBounds.bottom + tolerance); + } + + /** Checks if the dragged ghost is north or south of a target element's center*/ + private ghostInLowerPart(ofElement: HTMLElement) { + const ghostBounds = this.getDragGhostElement.getBoundingClientRect(); + const targetBounds = ofElement.getBoundingClientRect(); + + return ((ghostBounds.top + ghostBounds.bottom) / 2) >= ((targetBounds.top + targetBounds.bottom) / 2); + } + + /** Make a copy of the _sourceExpressionItem's chip and paste it in the tree north or south of the _targetExpressionItem's chip */ + private renderDropGhostChip(appendUnder: boolean): void { + if (appendUnder !== this._dropUnder || this.isKeyboardDrag) { + this.clearDropGhost(); + + //Copy dragged chip + const dragCopy = { ...this._sourceExpressionItem }; + dragCopy.parent = this._targetExpressionItem.parent; + this.dropGhostExpression = dragCopy; + + //Paste chip + this._dropUnder = appendUnder; + const pasteIndex = this._targetExpressionItem.parent.children.indexOf(this._targetExpressionItem); + this._targetExpressionItem.parent.children.splice(pasteIndex + (this._dropUnder ? 1 : 0), 0, dragCopy); + } + + //Put focus on the drag icon of the ghost while performing keyboard drag + if (this.isKeyboardDrag) { + setTimeout(() => { // this will make the execution after the drop ghost is rendered + const dropGhostDragIndicator = this.getDropGhostElement?.nativeElement?.querySelector(`.${QueryBuilderSelectors.DRAG_INDICATOR}`) as HTMLElement; + if (dropGhostDragIndicator) { + dropGhostDragIndicator.focus(); + } + }, 0); + } + + //Attach a mousemove event listener (if not already in place) to the dragged ghost (if present) + if (!this.isKeyboardDrag && this.getDragGhostElement && (!this._ghostChipMousemoveSubscription$ || this._ghostChipMousemoveSubscription$?.closed === true)) { + const mouseMoves = fromEvent(this.getDragGhostElement, 'mousemove'); + + //When mouse moves and there is a drop ghost => trigger onChipLeave to check if the drop ghost has to be removed + //effectively solving the case when mouse leaves the QB and a drop ghost is still in place + this._ghostChipMousemoveSubscription$ = mouseMoves.pipe(sampleTime(100)).subscribe(() => { + if (this.getDropGhostElement) { + this.onChipLeave(); + } + }); + } + + this.setDragCursor('grab'); + } + + /** Set the cursor when dragging a ghost*/ + private setDragCursor(cursor: string) { + if (this.getDragGhostElement) { + this.getDragGhostElement.style.cursor = cursor; + } + } + + /** Removes the drop ghost expression from the tree and it's chip effectively */ + private clearDropGhost() { + if (this.dropGhostExpression) { + const children = this.dropGhostExpression.parent.children; + const delIndex = children.indexOf(this.dropGhostExpression); + children.splice(delIndex, 1); + this.dropGhostExpression = null; + } + } + + /** Reset Drag&Drop vars. Optionally the drag source vars too*/ + private resetDragAndDrop(clearDragged: boolean) { + this._targetExpressionItem = null; + this._dropUnder = null; + this.clearDropGhost(); + this._keyDragInitialIndex = 0; + this._keyDragCurrentIndex = 0; + this._possibleDropLocations = null; + this._isKeyDragsFirstMove = true; + this.setDragCursor('no-drop'); + + if (this._queryBuilderTreeComponent._expressionTreeCopy) { + this._queryBuilderTreeComponent._expressionTree = this._queryBuilderTreeComponent._expressionTreeCopy; + } + + if ((clearDragged || this.isKeyboardDrag) && this._sourceElement) { + this._sourceElement.style.display = ''; + } + + if (clearDragged) { + this._queryBuilderTreeComponent._expressionTreeCopy = null; + this._sourceExpressionItem = null; + this._sourceElement = null; + } + } + + /** Start listening for drag and drop specific keys */ + private listenToKeyboard() { + this._keyboardSubscription$?.unsubscribe(); + this._keyboardSubscription$ = fromEvent(this.getMainExpressionTree, 'keydown') + .pipe(filter(e => ['ArrowUp', 'ArrowDown', 'Enter', 'Space', 'Escape', 'Tab'].includes(e.key))) + // .pipe(tap(e => { + // //Inhibit Tabs if keyboard drag is underway (don't allow to loose focus of the drop ghost's drag indicator) + // if (e.key === 'Tab' && this.getDropGhostElement) { + // e.preventDefault(); + // } + // })) + .pipe(filter(event => !event.repeat)) + .subscribe(e => { + if (e.key === 'Escape') { + //TODO cancel mouse drag once it's implemented in igx-chip draggable + this.resetDragAndDrop(false); + //Regain focus on the drag icon after keyboard drag cancel + if (this.isKeyboardDrag) { + (this._sourceElement.firstElementChild.firstElementChild.firstElementChild.firstElementChild as HTMLElement).focus(); + } + } else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + this.arrowDrag(e.key); + } else if (e.key === 'Enter' || e.key === 'Space') { + //this.platform.isActivationKey(eventArgs) Maybe use this rather that Enter/Space? + this.onChipDropped(); + this._keyboardSubscription$.unsubscribe(); + } + }); + } + + /** Perform up/down movement of drop ghost along the expression tree*/ + private arrowDrag(key: string) { + if (!this._sourceElement || !this._sourceExpressionItem) { + return; + } + + const rootGroup = this._queryBuilderTreeComponent.rootGroup; + + if (this._isKeyDragsFirstMove) { + this._possibleDropLocations = this.getPossibleDropLocations(rootGroup, true); + this._keyDragInitialIndex = this._possibleDropLocations.findIndex(e => e[0] === this._sourceExpressionItem && e[1] === true); + this._keyDragCurrentIndex = this._keyDragInitialIndex; + if (this._keyDragInitialIndex === -1) { + console.error("Dragged expression not found"); + } + this._sourceElement.style.display = 'none'; + } + + let newKeyIndexOffset = this._keyDragCurrentIndex; + if (key === 'ArrowUp') { + //decrease index capped at top of tree + newKeyIndexOffset && newKeyIndexOffset--; + } else if (key === 'ArrowDown') { + //increase index capped at bottom of tree + newKeyIndexOffset < this._possibleDropLocations.length - 1 && newKeyIndexOffset++; + } else { + console.error('wrong key'); + return; + } + + //if drop location has no change + if (newKeyIndexOffset !== this._keyDragCurrentIndex || this._isKeyDragsFirstMove) { + this._keyDragCurrentIndex = newKeyIndexOffset; + + const newDropTarget = this._possibleDropLocations[this._keyDragCurrentIndex]; + this._targetExpressionItem = newDropTarget[0] + + this.renderDropGhostChip(newDropTarget[1]); + + //Situations when drop ghost hasn't really moved, run one more time + if (this._keyDragCurrentIndex === this._keyDragInitialIndex || + (this._isKeyDragsFirstMove && this._keyDragCurrentIndex === this._keyDragInitialIndex - 1)) { + this._isKeyDragsFirstMove = false; + this.arrowDrag(key); + } + + this._isKeyDragsFirstMove = false; + } + + return; + } + + /** Produces a flat ordered list of possible drop locations as Tuple <[targetExpression, dropUnder]>, while performing the keyboard drag&drop */ + private getPossibleDropLocations(group: ExpressionGroupItem, isRoot: boolean): Array<[ExpressionItem, boolean]> { + const result = new Array() as Array<[ExpressionItem, boolean]>; + + //Add dropZone under AND/OR (as first child of group) + result.push([(group as ExpressionGroupItem).children[0], false]); + + for (let i = 0; i < group.children.length; i++) { + if (group.children[i] instanceof ExpressionGroupItem) { + result.push(...this.getPossibleDropLocations(group.children[i] as ExpressionGroupItem, false)); + } else { + result.push([group.children[i], true]); + } + } + + //Add dropZone under the whole group + if (!isRoot) { + result.push([group, true]); + } + + return result; + } + + /** Counts how many chips will be in the tree (from top to bottom) before the dropped one */ + private countChipsBeforeDropLocation(group: ExpressionGroupItem): [number, boolean] { + let count = 0, totalCount = 0, targetReached = false; + + for (let i = 0; i < group.children.length; i++) { + const child = group.children[i]; + + if (targetReached) { + break; + } + + if (child instanceof ExpressionGroupItem) { + if (child === this._targetExpressionItem) { + if (this._dropUnder) { + [count] = this.countChipsBeforeDropLocation(child as ExpressionGroupItem); + totalCount += count; + } + targetReached = true; + } else { + [count, targetReached] = this.countChipsBeforeDropLocation(child as ExpressionGroupItem); + totalCount += count; + } + } else { + if (child !== this._sourceExpressionItem && //not the hidden source chip + child !== this.dropGhostExpression && //not the drop ghost + !((child as ExpressionOperandItem).inEditMode && this._queryBuilderTreeComponent.operandCanBeCommitted() !== true) //not a chip in edit mode that will be discarded + ) { + totalCount++; + } + + if (child === this._targetExpressionItem) { + targetReached = true; + if (!this._dropUnder && + !((child as ExpressionOperandItem).inEditMode && this._queryBuilderTreeComponent.operandCanBeCommitted() !== true)) { + totalCount--; + } + } + } + } + + totalCount === -1 && totalCount++; + + return [totalCount, targetReached]; + } + + /** Sets the z-index of the drag ghost with a little delay, since we don't have access to ghostCreated() but we know it's executed right after moveStart() */ + private setDragGhostZIndex() { + if (this._timeoutId) { + clearTimeout(this._timeoutId); + } + + this._timeoutId = setTimeout(() => { + if (this.getDragGhostElement?.style) { + this.getDragGhostElement.style.zIndex = `${Z_INDEX_TO_SET}`; + } + }, DEFAULT_SET_Z_INDEX_DELAY); + } +} diff --git a/projects/igniteui-angular/query-builder/src/query-builder/query-builder-functions.spec.ts b/projects/igniteui-angular/query-builder/src/query-builder/query-builder-functions.spec.ts new file mode 100644 index 00000000000..4fd473be534 --- /dev/null +++ b/projects/igniteui-angular/query-builder/src/query-builder/query-builder-functions.spec.ts @@ -0,0 +1,1007 @@ +import { DebugElement } from '@angular/core'; +import { ComponentFixture, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { FilteringExpressionsTree, FilteringLogic, IgxStringFilteringOperand, IgxBooleanFilteringOperand, IgxNumberFilteringOperand, IgxDateFilteringOperand } from 'igniteui-angular/core'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxChipComponent } from 'igniteui-angular/chips'; +import { ControlsFunction } from '../../../test-utils/controls-functions.spec'; +import { UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { QueryBuilderSelectors } from './query-builder.common'; + +export const SampleEntities = [ + { + name: 'Products', fields: [ + { field: 'Id', dataType: 'number' }, + { field: 'ProductName', dataType: 'string' }, + { field: 'OrderId', dataType: 'number' }, + { field: 'Released', dataType: 'boolean' } + ] + }, + { + name: 'Orders', fields: [ + { field: 'OrderId', dataType: 'number' }, + { field: 'OrderName', dataType: 'string' }, + { field: 'OrderDate', dataType: 'date' }, + { field: 'Delivered', dataType: 'boolean' } + ] + } +]; + +export class QueryBuilderFunctions { + public static generateExpressionTree(): FilteringExpressionsTree { + const innerTree = new FilteringExpressionsTree(FilteringLogic.And, null, 'Products', ['Id']); + innerTree.filteringOperands.push({ + fieldName: 'ProductName', + condition: IgxStringFilteringOperand.instance().condition('contains'), + conditionName: 'contains', + searchVal: 'a' + }); + innerTree.filteringOperands.push({ + fieldName: 'Released', + condition: IgxBooleanFilteringOperand.instance().condition('true'), + conditionName: 'true', + }); + + const tree = new FilteringExpressionsTree(FilteringLogic.And, null, 'Orders', ['*']); + tree.filteringOperands.push({ + fieldName: 'OrderId', + condition: IgxStringFilteringOperand.instance().condition('inQuery'), + conditionName: 'inQuery', + searchTree: innerTree + }); + tree.filteringOperands.push({ + fieldName: 'OrderId', + condition: IgxNumberFilteringOperand.instance().condition('greaterThan'), + conditionName: 'greaterThan', + searchVal: 3, + ignoreCase: true + }); + tree.filteringOperands.push({ + fieldName: 'OrderDate', + condition: IgxDateFilteringOperand.instance().condition('after'), + conditionName: 'after', + searchVal: new Date() + }); + return tree; + } + + public static generateExpressionTreeWithSubGroup(): FilteringExpressionsTree { + const innerTree = new FilteringExpressionsTree(FilteringLogic.And, undefined, 'Products', ['OrderId']); + innerTree.filteringOperands.push({ + fieldName: 'Id', + condition: IgxNumberFilteringOperand.instance().condition('equals'), + conditionName: IgxNumberFilteringOperand.instance().condition('equals').name, + searchVal: 123 + }); + innerTree.filteringOperands.push({ + fieldName: 'ProductName', + condition: IgxStringFilteringOperand.instance().condition('equals'), + conditionName: IgxStringFilteringOperand.instance().condition('equals').name, + searchVal: 'abc' + }); + + + const tree = new FilteringExpressionsTree(FilteringLogic.And, null, 'Orders', ['*']); + tree.filteringOperands.push({ + fieldName: 'OrderName', + condition: IgxStringFilteringOperand.instance().condition('equals'), + conditionName: IgxStringFilteringOperand.instance().condition('equals').name, + searchVal: 'foo' + }); + + tree.filteringOperands.push({ + fieldName: 'OrderId', + condition: IgxStringFilteringOperand.instance().condition('inQuery'), + conditionName: IgxStringFilteringOperand.instance().condition('inQuery').name, + searchTree: innerTree + }); + + const subGroup = new FilteringExpressionsTree(FilteringLogic.Or, undefined, 'Orders', ['*']); + subGroup.filteringOperands.push({ + fieldName: 'OrderName', + condition: IgxStringFilteringOperand.instance().condition('endsWith'), + conditionName: IgxStringFilteringOperand.instance().condition('endsWith').name, + searchVal: 'a' + }); + subGroup.filteringOperands.push({ + fieldName: 'OrderDate', + condition: IgxDateFilteringOperand.instance().condition('today'), + conditionName: IgxDateFilteringOperand.instance().condition('today').name + }); + tree.filteringOperands.push(subGroup); + + return tree; + } + + + public static getQueryBuilderHeader(fix: ComponentFixture) { + const queryBuilderElement: HTMLElement = fix.debugElement.queryAll(By.css(`.${QueryBuilderSelectors.QUERY_BUILDER}`))[0].nativeElement; + const header = queryBuilderElement.querySelector(`.${QueryBuilderSelectors.QUERY_BUILDER_HEADER}`); + return header; + } + + public static getQueryBuilderHeaderText(fix: ComponentFixture) { + const header = QueryBuilderFunctions.getQueryBuilderHeader(fix); + const title = header.querySelector('.igx-query-builder__title'); + return title.textContent.trim(); + } + + /** + * Get the expressions container that contains all groups and expressions. + */ + public static getQueryBuilderExpressionsContainer(fix: ComponentFixture, level = 0) { + const searchClass = `${QueryBuilderSelectors.QUERY_BUILDER_TREE}--level-${level}` + const queryBuilderElement: HTMLElement = fix.debugElement.queryAll(By.css(`.${searchClass}`))[0].nativeElement; + const exprContainer = queryBuilderElement.querySelector(`.${QueryBuilderSelectors.QUERY_BUILDER_BODY}`); + return exprContainer; + } + + /** + * Get the initial condition adding buttons when the dialog does not contain any filters. + */ + public static getQueryBuilderInitialAddConditionBtn(fix: ComponentFixture, level = 0) { + const exprContainer = this.getQueryBuilderExpressionsContainer(fix, level); + const initialButton = Array.from(exprContainer.querySelectorAll('button')).filter(item => item.checkVisibility()).at(-1); + return initialButton; + } + + public static getQueryBuilderAllGroups(fix: ComponentFixture): any[] { + const queryBuilderElement: HTMLElement = fix.debugElement.queryAll(By.css(`.${QueryBuilderSelectors.QUERY_BUILDER}`))[0].nativeElement; + const allGroups = Array.from(QueryBuilderFunctions.getQueryBuilderTreeChildGroups(queryBuilderElement, false)); + return allGroups; + } + + /** + * Get the root group. + */ + public static getQueryBuilderTreeRootGroup(fix: ComponentFixture, level = 0) { + const exprContainer = QueryBuilderFunctions.getQueryBuilderExpressionsContainer(fix, level).children[1]; + const rootGroup = exprContainer.querySelector(`:scope > .${QueryBuilderSelectors.FILTER_TREE}`); + return rootGroup; + } + + /** + * Get all child groups of the given 'group' by specifying whether to include its direct child groups only + * or all of its child groups in the hierarchy. (NOTE: Expressions do not have children!) + */ + public static getQueryBuilderTreeChildGroups(group: HTMLElement, directChildrenOnly = true) { + const pattern = (directChildrenOnly ? ':scope > ' : '') + `.${QueryBuilderSelectors.FILTER_TREE}`; + const childrenContainer = group.querySelector('.igx-filter-tree__expressions').children[1]; + const childGroups = Array.from(childrenContainer.querySelectorAll(pattern)); + return childGroups; + } + + /** + * Get all child expressions of the given 'group' by specifying whether to include its direct child expressions only + * or all of its child expressions in the hierarchy. + */ + public static getQueryBuilderTreeChildExpressions(group: HTMLElement, directChildrenOnly = true) { + const pattern = (directChildrenOnly ? ':scope > ' : '') + `.${QueryBuilderSelectors.FILTER_TREE_EXPRESSION_ITEM}`; + const childrenContainer = group.querySelector('.igx-filter-tree__expressions').children[1]; + const childExpressions = Array.from(childrenContainer.querySelectorAll(pattern)); + return childExpressions; + } + + /** + * Get all child groups and expressions of the given 'group' by specifying whether to include its + * direct child groups and expressions only or all of its child groups and expressions in the hierarchy. + */ + public static getQueryBuilderTreeChildItems(group: HTMLElement, directChildrenOnly = true) { + const childGroups = Array.from(QueryBuilderFunctions.getQueryBuilderTreeChildGroups(group, directChildrenOnly)); + const childExpressions = Array.from(QueryBuilderFunctions.getQueryBuilderTreeChildExpressions(group, directChildrenOnly)); + return childGroups.concat(childExpressions); + } + + /** + * Get a specific item from the tree (could be a group or an expression) + * by specifying its hierarchical path (not including the root group). + * (Example: [2 ,1] will first get the third item of the root group, + * and then it will get the second item of the root group's third item.) + * (NOTE: Only the items that are groups have children.) + * The returned element is the one that has been gotten last. + */ + public static getQueryBuilderTreeItem(fix: ComponentFixture, + path: number[], + level = 0) { + let node = QueryBuilderFunctions.getQueryBuilderTreeRootGroup(fix, level); + for (const pos of path) { + const directChildren = QueryBuilderFunctions.getQueryBuilderTreeChildItems(node as HTMLElement, true); + node = directChildren[pos]; + } + return node; + } + + /** + * Get the operator line of the root group. + */ + public static getQueryBuilderTreeRootGroupOperatorLine(fix: ComponentFixture) { + const rootGroup = QueryBuilderFunctions.getQueryBuilderTreeRootGroup(fix); + const directOperatorLine = rootGroup.querySelector(':scope > .igx-filter-tree__line'); + return directOperatorLine; + } + + /** + * Get the operator line of the group that is located on the provided 'path'. + */ + public static getQueryBuilderTreeGroupOperatorLine(fix: ComponentFixture, path: number[]) { + const group = QueryBuilderFunctions.getQueryBuilderTreeItem(fix, path); + const directOperatorLine = group.querySelector(':scope > .igx-filter-tree__line'); + return directOperatorLine; + } + + public static getQueryBuilderEditModeContainer(fix: ComponentFixture, entityContainer = true, level = 0) { + const exprContainer = QueryBuilderFunctions.getQueryBuilderExpressionsContainer(fix, level); + const editModeContainers = Array.from(exprContainer.querySelectorAll('.igx-filter-tree__inputs')); + const entityEditModeContainer = editModeContainers.find(container => container.children.length == 2); + const conditionEditModeContainer = editModeContainers.find(container => container.children.length >= 4); + return entityContainer ? entityEditModeContainer : conditionEditModeContainer; + } + + public static getQueryBuilderEntitySelect(fix: ComponentFixture, level = 0) { + const editModeContainer = QueryBuilderFunctions.getQueryBuilderEditModeContainer(fix, true, level); + const entitySelect = editModeContainer.querySelector('igx-select'); + return entitySelect; + } + + public static getQueryBuilderFieldsCombo(fix: ComponentFixture, level = 0) { + const editModeContainer = QueryBuilderFunctions.getQueryBuilderEditModeContainer(fix, true, level); + const fieldCombo = level == 0 ? editModeContainer.querySelector('igx-combo') : editModeContainer.querySelectorAll('igx-select')[1]; + return fieldCombo; + } + + public static getQueryBuilderColumnSelect(fix: ComponentFixture, level = 0) { + const editModeContainer = QueryBuilderFunctions.getQueryBuilderEditModeContainer(fix, false, level); + const selects = Array.from(editModeContainer.querySelectorAll('igx-select')); + const columnSelect = selects[0]; + return columnSelect; + } + + public static getQueryBuilderOperatorSelect(fix: ComponentFixture, level = 0) { + const editModeContainer = QueryBuilderFunctions.getQueryBuilderEditModeContainer(fix, false, level); + const selects = Array.from(editModeContainer.querySelectorAll('igx-select')); + const operatorSelect = selects[1]; + return operatorSelect; + } + + public static getQueryBuilderValueInput(fix: ComponentFixture, dateType = false, level = 0) { + const editModeContainer = QueryBuilderFunctions.getQueryBuilderEditModeContainer(fix, false, level); + const input = dateType ? + editModeContainer.querySelector('igx-date-picker').querySelector('input') : + Array.from(editModeContainer.querySelectorAll('igx-input-group'))[2]; + return input; + } + + public static getQueryBuilderExpressionCommitButton(fix: ComponentFixture, level = 0) { + const actionButtons = fix.debugElement.queryAll(By.css('.igx-filter-tree__inputs-actions > button')); + const commitButton = actionButtons.filter((el: DebugElement) => { + const icon = el.query(By.directive(IgxIconComponent)).componentInstance; + return icon.name === 'confirm'; + }); + + return commitButton[level].nativeElement; + } + + public static getQueryBuilderExpressionCloseButton(fix: ComponentFixture, level = 0) { + const actionButtons = fix.debugElement.queryAll(By.css('.igx-filter-tree__inputs-actions > button')); + const closeButton = actionButtons.filter((el: DebugElement) => { + const icon = el.query(By.directive(IgxIconComponent)).componentInstance; + return icon.name === 'close'; + }); + + return closeButton[level].nativeElement; + } + + /** + * Get the adding buttons and the cancel button of the root group by specifying the + * index position of the buttons container. + */ + public static getQueryBuilderTreeRootGroupButtons(fix: ComponentFixture, buttonsIndex: number) { + const buttonsContainer: any = this.getQueryBuilderTreeRootGroupButtonsContainer(fix, buttonsIndex); + const buttons = Array.from(buttonsContainer.querySelectorAll('button')); + return buttons; + } + + public static getQueryBuilderTreeRootGroupButtonsContainer(fix: ComponentFixture, buttonsIndex: number) { + const group = QueryBuilderFunctions.getQueryBuilderTreeRootGroup(fix); + const childrenContainer = group.querySelector('.igx-filter-tree__expressions'); + const buttonsContainers = Array.from(childrenContainer.querySelectorAll(':scope > .igx-filter-tree__buttons')); + return buttonsContainers[buttonsIndex]; + } + + public static getQueryBuilderOutlet(queryBuilderElement: HTMLElement) { + const outlet = queryBuilderElement.querySelector(':scope > .igx-query-builder__outlet'); + return outlet; + } + + public static getQueryBuilderSelectDropdown(queryBuilderElement: HTMLElement, index = 0) { + const outlet = QueryBuilderFunctions.getQueryBuilderOutlet(queryBuilderElement); + const selectDropdown = outlet.querySelectorAll(`.${QueryBuilderSelectors.DROP_DOWN_LIST_SCROLL}`).item(index); + return selectDropdown; + } + + public static getQueryBuilderSelectDropdownItems(queryBuilderElement: HTMLElement, index = 0) { + const selectDropdown = QueryBuilderFunctions.getQueryBuilderSelectDropdown(queryBuilderElement, index); + const items = Array.from(selectDropdown.querySelectorAll('.igx-drop-down__item')); + return items; + } + + public static getQueryBuilderCalendar(fix: ComponentFixture) { + const calendar = fix.debugElement.queryAll(By.css(`.igx-calendar`))[0].nativeElement; + return calendar; + } + + /** + * Get the underlying chip of the expression that is located on the provided 'path'. + */ + public static getQueryBuilderTreeExpressionChip(fix: ComponentFixture, path: number[], level = 0) { + const treeItem = QueryBuilderFunctions.getQueryBuilderTreeItem(fix, path, level); + const chip = treeItem.querySelector('igx-chip'); + return chip; + } + + /** + * Get the action icons ('edit' and 'add') of the expression that is located on the provided 'path'. + */ + public static getQueryBuilderTreeExpressionActionsContainer(fix: ComponentFixture, path: number[]) { + const treeItem = QueryBuilderFunctions.getQueryBuilderTreeItem(fix, path); + const actionsContainer = treeItem.querySelector('.igx-filter-tree__expression-actions'); + return actionsContainer; + } + + /** + * Get the specified icon (add, close) of the expression that is located on the provided 'path'. + */ + public static getQueryBuilderTreeExpressionIcon(fix: ComponentFixture, path: number[], iconType: string) { + const actionsContainer = QueryBuilderFunctions.getQueryBuilderTreeExpressionActionsContainer(fix, path); + const icons = Array.from(actionsContainer.querySelectorAll('igx-icon')); + return icons.find((icon: any) => icon.innerText === iconType) as any; + } + + /** + * Get the adding buttons and the cancel button of a group by specifying the + * path of the group and the index position of the buttons container. + * (NOTE: The buttons are returned in an array and are sorted in ascending order based on 'X' value.) + */ + public static getQueryBuilderTreeGroupButtons(fix: ComponentFixture, path: number[], buttonsIndex: number) { + const group = QueryBuilderFunctions.getQueryBuilderTreeItem(fix, path); + const childrenContainer = group.querySelector('.igx-filter-tree__expression'); + const buttonsContainers = Array.from(childrenContainer.querySelectorAll(':scope > .igx-filter-tree__buttons')); + const buttonsContainer: any = buttonsContainers[buttonsIndex]; + const buttons = Array.from(buttonsContainer.querySelectorAll('button')); + return buttons; + } + + public static getQueryBuilderGroupContextMenus(fix: ComponentFixture) { + return fix.debugElement.queryAll(By.css(`.${QueryBuilderSelectors.FILTER_TREE_EXPRESSION_CONTEXT_MENU}`)); + } + + public static getQueryBuilderGroupContextMenuDropDownItems(fix: ComponentFixture) { + const dropDownItems = fix.nativeElement.querySelectorAll('igx-drop-down-item') + return dropDownItems; + } + + public static verifyContextMenuItemDisabled(fix: ComponentFixture, index: number, disabled: boolean) { + const contextMenuItems = QueryBuilderFunctions.getQueryBuilderGroupContextMenuDropDownItems(fix); + expect(contextMenuItems[index].classList.contains(QueryBuilderSelectors.DROP_DOWN_ITEM_DISABLED)).toBe(disabled); + } + + public static clickQueryBuilderGroupContextMenu(fix: ComponentFixture, index = 0) { + const contextMenuButton = QueryBuilderFunctions.getQueryBuilderGroupContextMenus(fix)[index].queryAll(By.css('.igx-button'))[0].nativeElement; + contextMenuButton.click(); + } + + public static clickContextMenuItem(fix: ComponentFixture, index: number) { + const dropDownItems = this.getQueryBuilderGroupContextMenuDropDownItems(fix); + dropDownItems[index].click(); + } + + /* + * Get tabbable elements in a container element. Result is returned as node elements ordered they way they will be tabbed + */ + public static getTabbableElements(inElement: HTMLElement) { + const focusableElements = + 'a:not([disabled]), button:not([disabled]), input[type=text]:not([disabled]), [tabindex]:not([disabled]):not([tabindex="-1"])'; + + return Array.prototype.filter.call( + inElement.querySelectorAll(focusableElements), + element => { + return (element.offsetWidth > 0 || element.offsetHeight > 0); + } + ); + } + + public static clickQueryBuilderInitialAddConditionBtn(fix: ComponentFixture, level = 0) { + const btn = this.getQueryBuilderInitialAddConditionBtn(fix, level); + btn.click(); + } + + /** + * Click the entity select for the expression that is currently in edit mode. + */ + public static clickQueryBuilderEntitySelect(fix: ComponentFixture, level = 0) { + const entityInputGroup = QueryBuilderFunctions.getQueryBuilderEntitySelect(fix, level).querySelector('igx-input-group') as HTMLElement; + entityInputGroup.click(); + } + + /** + * Click the fields combo for the expression that is currently in edit mode. + */ + public static clickQueryBuilderFieldsCombo(fix: ComponentFixture, level = 0) { + const fieldInputGroup = QueryBuilderFunctions.getQueryBuilderFieldsCombo(fix, level).querySelector('igx-input-group') as HTMLElement; + fieldInputGroup.click(); + } + + /** + * Click the column select for the expression that is currently in edit mode. + */ + public static clickQueryBuilderColumnSelect(fix: ComponentFixture, level = 0) { + const columnInputGroup = QueryBuilderFunctions.getQueryBuilderColumnSelect(fix, level).querySelector('igx-input-group') as HTMLElement; + columnInputGroup.click(); + } + + /** + * Click the operator select for the expression that is currently in edit mode. + */ + public static clickQueryBuilderOperatorSelect(fix: ComponentFixture, level = 0) { + const operatorInputGroup = QueryBuilderFunctions.getQueryBuilderOperatorSelect(fix, level).querySelector('igx-input-group') as HTMLElement + operatorInputGroup.click(); + } + + /** + * Click the value input for the expression that is currently in edit mode. + * (NOTE: The value input could be either an input group or a date picker.) + */ + public static clickQueryBuilderValueInput(fix: ComponentFixture, dateType = false) { + // Could be either an input group or a date picker. + const valueInput = QueryBuilderFunctions.getQueryBuilderValueInput(fix, dateType) as HTMLElement; + valueInput.click(); + } + + /** + * Click the the select dropdown's element that is positioned at the specified 'index'. + * (NOTE: This method presumes that the select dropdown is already opened.) + */ + public static clickQueryBuilderSelectDropdownItem(queryBuilderElement: HTMLElement, index: number) { + const selectDropdownItems = Array.from(QueryBuilderFunctions.getQueryBuilderSelectDropdownItems(queryBuilderElement)); + const item = selectDropdownItems[index] as HTMLElement; + item.click(); + } + + /** + * Click the commit button of the expression that is currently in edit mode. + */ + public static clickQueryBuilderExpressionCommitButton(fix: ComponentFixture, level = 0) { + const commitButton = QueryBuilderFunctions.getQueryBuilderExpressionCommitButton(fix, level); + commitButton.click(); + } + + /** + * (Double)Click the underlying chip of the expression that is located on the provided 'path'. + */ + public static clickQueryBuilderTreeExpressionChip(fix: ComponentFixture, path: number[], level = 0) { + const chip = QueryBuilderFunctions.getQueryBuilderTreeExpressionChip(fix, path, level) as HTMLElement; + + chip.click(); + } + + /** + * Click the remove icon of the expression that is located on the provided 'path'. + */ + public static clickQueryBuilderTreeExpressionChipRemoveIcon(fix: ComponentFixture, path: number[]) { + const chip = QueryBuilderFunctions.getQueryBuilderTreeExpressionChip(fix, path) as HTMLElement; + ControlsFunction.clickChipRemoveButton(chip); + } + + /** + * Click the specified icon (add, close) of the expression that is located on the provided 'path'. + */ + public static clickQueryBuilderTreeExpressionChipIcon(fix: ComponentFixture, path: number[], iconType: string) { + const chipIcon = QueryBuilderFunctions.getQueryBuilderTreeExpressionIcon(fix, path, iconType); + chipIcon.click(); + } + + /** + * Click 'add condition' or 'add group' item + */ + public static clickQueryBuilderTreeAddOption(fix: ComponentFixture, index: number) { + const outlet = Array.from(fix.debugElement.nativeElement.querySelectorAll(`.igx-drop-down__list-scroll`)).filter(item => (item as HTMLElement).checkVisibility())[0]; + const item = Array.from((outlet as HTMLElement).querySelectorAll('.igx-drop-down__item'))[index] as HTMLElement; + UIInteractions.simulateClickAndSelectEvent(item) + tick(100); + fix.detectChanges(); + } + + /* + * Hit a keyboard button upon element, wait for the desired time and detect changes + */ + //TODO maybe move to more commonly used class + public static hitKeyUponElementAndDetectChanges(fix: ComponentFixture, key: string, elem: HTMLElement, waitT: number = null) { + UIInteractions.triggerKeyDownEvtUponElem(key, elem, true); + tick(waitT); + fix.detectChanges(); + } + + /** + * Verifies the type of the operator line ('and' or 'or'). + * (NOTE: The 'operator' argument must be a string with a value that is either 'and' or 'or'.) + */ + public static verifyOperatorLine(operatorLine: HTMLElement, operator: string) { + expect(operator === 'and' || operator === 'or').toBe(true, 'operator must be \'and\' or \'or\''); + + if (operator === 'and') { + expect(operatorLine.classList.contains(QueryBuilderSelectors.FILTER_TREE_LINE_AND)).toBe(true, 'incorrect operator line'); + expect(operatorLine.classList.contains(QueryBuilderSelectors.FILTER_TREE_LINE_OR)).toBe(false, 'incorrect operator line'); + } else { + expect(operatorLine.classList.contains(QueryBuilderSelectors.FILTER_TREE_LINE_AND)).toBe(false, 'incorrect operator line'); + expect(operatorLine.classList.contains(QueryBuilderSelectors.FILTER_TREE_LINE_OR)).toBe(true, 'incorrect operator line'); + } + } + + public static verifyEditModeQueryExpressionInputStates(fix, + entitySelectEnabled: boolean, + fieldComboEnabled: boolean, + columnSelectEnabled?: boolean, + operatorSelectEnabled?: boolean, + valueInputEnabled?: boolean, + commitButtonEnabled?: boolean, + level = 0) { + // Verify the entity select state. + const entityInputGroup = QueryBuilderFunctions.getQueryBuilderEntitySelect(fix, level).querySelector('igx-input-group'); + expect(!entityInputGroup.classList.contains('igx-input-group--disabled')).toBe(entitySelectEnabled, + 'incorrect entity select state'); + // Verify the fields combo state. + const fieldInputGroup = QueryBuilderFunctions.getQueryBuilderFieldsCombo(fix, level).querySelector('igx-input-group'); + expect(!fieldInputGroup.classList.contains('igx-input-group--disabled')).toBe(fieldComboEnabled, + 'incorrect fields combo state'); + + if (columnSelectEnabled || operatorSelectEnabled || valueInputEnabled || commitButtonEnabled) { + QueryBuilderFunctions.verifyEditModeExpressionInputStates(fix, columnSelectEnabled, operatorSelectEnabled, valueInputEnabled, commitButtonEnabled, level); + } + }; + + public static verifyEditModeExpressionInputStates(fix, + columnSelectEnabled: boolean, + operatorSelectEnabled: boolean, + valueInputEnabled: boolean, + commitButtonEnabled: boolean, + level = 0) { + // Verify the column select state. + const columnInputGroup = QueryBuilderFunctions.getQueryBuilderColumnSelect(fix, level).querySelector('igx-input-group'); + expect(!columnInputGroup.classList.contains('igx-input-group--disabled')).toBe(columnSelectEnabled, + 'incorrect column select state'); + + // Verify the operator select state. + const operatorInputGroup = QueryBuilderFunctions.getQueryBuilderOperatorSelect(fix, level).querySelector('igx-input-group'); + expect(!operatorInputGroup.classList.contains('igx-input-group--disabled')).toBe(operatorSelectEnabled, + 'incorrect operator select state'); + + // Verify the value input state. + const editModeContainer = QueryBuilderFunctions.getQueryBuilderEditModeContainer(fix, false, level); + const valueInputGroup = Array.from(editModeContainer.querySelectorAll('igx-input-group'))[2]; + expect(!valueInputGroup.classList.contains('igx-input-group--disabled')).toBe(valueInputEnabled, + 'incorrect value input state'); + + // Verify commit expression button state + const commitButton = QueryBuilderFunctions.getQueryBuilderExpressionCommitButton(fix, level); + ControlsFunction.verifyButtonIsDisabled(commitButton, !commitButtonEnabled); + + // Verify close expression button is enabled. + const closeButton = QueryBuilderFunctions.getQueryBuilderExpressionCloseButton(fix, level); + ControlsFunction.verifyButtonIsDisabled(closeButton, false); + }; + + public static verifyQueryEditModeExpressionInputValues(fix, + entityText: string, + fieldsText: string, + columnText?: string, + operatorText?: string, + valueText?: string, + level = 0) { + const entityInput = QueryBuilderFunctions.getQueryBuilderEntitySelect(fix, level).querySelector('input'); + const fieldInput = QueryBuilderFunctions.getQueryBuilderFieldsCombo(fix, level).querySelector('input'); + expect(entityInput.value).toBe(entityText); + expect(fieldInput.value).toBe(fieldsText); + + if (columnText || operatorText || valueText) { + QueryBuilderFunctions.verifyEditModeExpressionInputValues(fix, columnText, operatorText, valueText, level); + } + }; + + public static verifyEditModeExpressionInputValues(fix, + columnText: string, + operatorText: string, + valueText: string, + level = 0) { + const columnInput = QueryBuilderFunctions.getQueryBuilderColumnSelect(fix, level).querySelector('input'); + const operatorInput = QueryBuilderFunctions.getQueryBuilderOperatorSelect(fix, level).querySelector('input'); + const valueInput = QueryBuilderFunctions.getQueryBuilderValueInput(fix, false, level).querySelector('input') as HTMLInputElement; + expect(columnInput.value).toBe(columnText); + expect(operatorInput.value).toBe(operatorText); + expect(valueInput.value).toBe(valueText); + }; + + public static verifyQueryBuilderTabbableElements = (fixture: ComponentFixture) => { + const tabElements = QueryBuilderFunctions.getTabbableElements(fixture.nativeElement); + + let i = 0; + tabElements.forEach((element: HTMLElement) => { + switch (i) { + case 0: expect(element).toHaveClass('igx-input-group__input'); break; + case 1: expect(element).toHaveClass('igx-input-group__input'); break; + case 2: expect(element).toHaveClass('igx-combo__toggle-button'); break; + case 3: expect(element).toHaveClass('igx-button'); + expect(element.innerText).toContain('and'); break; + case 4: expect(element).toHaveClass('igx-chip'); break; + case 5: expect(element).toHaveClass('igx-icon'); break; + case 6: expect(element).toHaveClass('igx-chip__remove'); break; + case 7: expect(element).toHaveClass('igx-chip'); break; + case 8: expect(element).toHaveClass('igx-icon'); break; + case 9: expect(element).toHaveClass('igx-chip__remove'); break; + case 10: expect(element).toHaveClass('igx-chip'); break; + case 11: expect(element).toHaveClass('igx-icon'); break; + case 12: expect(element).toHaveClass('igx-chip__remove'); break; + case 13: expect(element).toHaveClass('igx-button'); + expect(element.innerText).toContain('Condition'); break; + case 14: expect(element).toHaveClass('igx-button'); + expect(element.innerText).toContain('Group'); break; + } + i++; + }); + }; + + public static verifyTabbableChipActions = (chipActions: DebugElement) => { + const tabElements = QueryBuilderFunctions.getTabbableElements(chipActions.nativeElement); + + let i = 0; + tabElements.forEach((element: HTMLElement) => { + switch (i) { + case 0: expect(element.firstChild).toHaveClass('igx-icon'); + expect(element.firstChild.textContent).toContain('add'); + break; + } + i++; + }); + }; + + public static verifyFocusedChip = (columnText: string, conditionText: string, valueText?: string) => { + expect(document.activeElement.tagName).toEqual('IGX-CHIP'); + const chipElement = document.activeElement; + expect((chipElement.querySelector('.igx-filter-tree__expression-column') as HTMLElement).innerText).toEqual(columnText); + expect((chipElement.querySelector('.igx-filter-tree__expression-condition') as HTMLElement).innerText).toEqual(conditionText); + + if (valueText) { + expect((chipElement.querySelector('.igx-chip__content') as HTMLElement).innerText).toEqual(valueText); + } + } + + public static verifyTabbableConditionEditLineElements = (editLine: DebugElement) => { + const tabElements = QueryBuilderFunctions.getTabbableElements(editLine.nativeElement); + + let i = 0; + tabElements.forEach((element: HTMLElement) => { + switch (i) { + case 0: expect(element).toHaveClass('igx-input-group__input'); break; + case 1: expect(element).toHaveClass('igx-input-group__input'); break; + case 2: expect(element).toHaveClass('igx-icon-button'); break; + case 3: expect(element).toHaveClass('igx-icon-button'); break; + } + i++; + }); + }; + + public static verifyTabbableInConditionDialogElements = (editDialog: DebugElement) => { + const tabElements = QueryBuilderFunctions.getTabbableElements(editDialog.nativeElement); + + let i = 0; + tabElements.forEach((element: HTMLElement) => { + switch (i) { + case 0: expect(element).toHaveClass('igx-input-group__input'); break; + case 1: expect(element).toHaveClass('igx-input-group__input'); break; + case 2: expect(element).toHaveClass('igx-button'); break; + case 3: expect(element).toHaveClass('igx-chip'); break; + case 4: expect(element).toHaveClass('igx-icon'); break; + case 5: expect(element).toHaveClass('igx-chip__remove'); break; + case 6: expect(element).toHaveClass('igx-chip'); break; + case 7: expect(element).toHaveClass('igx-icon'); break; + case 8: expect(element).toHaveClass('igx-chip__remove'); break; + case 9: expect(element).toHaveClass('igx-button'); + expect(element.innerText).toContain('Condition'); + break; + case 10: expect(element).toHaveClass('igx-button'); + expect(element.innerText).toContain('Group'); + break; + } + i++; + }); + }; + + public static verifyExpressionChipContent(fix, path: number[], columnText: string, operatorText: string, valueText = undefined, level = 0) { + const chip = QueryBuilderFunctions.getQueryBuilderTreeExpressionChip(fix, path, level); + const chipSpans = Array.from(chip.querySelectorAll('span')); + const columnSpan = chipSpans[0]; + const operatorSpan = chipSpans[1]; + const valueSpan = chipSpans[2]; + expect(columnSpan.textContent.toLowerCase().trim()).toBe(columnText.toLowerCase(), 'incorrect chip column'); + expect(operatorSpan.textContent.toLowerCase().trim()).toBe(operatorText.toLowerCase(), 'incorrect chip operator'); + if (valueSpan != undefined && valueText != undefined) { + expect(valueSpan.textContent.toLowerCase().trim().replaceAll(/\s/g, '')).toBe(valueText.toLowerCase().replaceAll(/\s/g, ''), 'incorrect chip filter value'); + } + }; + + public static verifyGroupLineCount(fix: ComponentFixture, andLineCount: number = null, orLineCount: number = null) { + const andLines = fix.debugElement.queryAll(By.css(`.${QueryBuilderSelectors.FILTER_TREE_LINE_AND}`)); + const orLines = fix.debugElement.queryAll(By.css(`.${QueryBuilderSelectors.FILTER_TREE_LINE_OR}`)); + + if (andLineCount) expect(andLines.length).toBe(andLineCount, "AND groups not the right count"); + if (orLineCount) expect(orLines.length).toBe(orLineCount, "OR groups not the right count"); + }; + + public static verifyRootAndSubGroupExpressionsCount(fix: ComponentFixture, rootDirect: number, rootTotal: number = null, subGroupPath: number[] = null, subGroupDirect: number = null, subGroupTotal: number = null) { + const rootGroup = QueryBuilderFunctions.getQueryBuilderTreeRootGroup(fix) as HTMLElement; + expect(rootGroup).not.toBeNull('There is no root group.'); + expect(QueryBuilderFunctions.getQueryBuilderTreeChildItems(rootGroup, true).length).toBe(rootDirect, 'Root direct condition count not correct'); + expect(QueryBuilderFunctions.getQueryBuilderTreeChildItems(rootGroup, false).length).toBe(rootTotal, 'Root direct + child condition count not correct'); + if (subGroupPath) { + const subGroup = QueryBuilderFunctions.getQueryBuilderTreeItem(fix, subGroupPath) as HTMLElement; + if (subGroupDirect) expect(QueryBuilderFunctions.getQueryBuilderTreeChildItems(subGroup, true).length).toBe(subGroupDirect, 'Child direct condition count not correct'); + if (subGroupTotal) expect(QueryBuilderFunctions.getQueryBuilderTreeChildItems(subGroup, false).length).toBe(subGroupTotal, 'Child direct + child condition count not correct'); + } + }; + + public static selectEntityInEditModeExpression(fix: ComponentFixture, dropdownItemIndex: number, level = 0) { + QueryBuilderFunctions.clickQueryBuilderEntitySelect(fix, level); + fix.detectChanges(); + + const outlet = Array.from(fix.debugElement.nativeElement.querySelectorAll(`.igx-drop-down__list-scroll`)).filter(item => (item as HTMLElement).checkVisibility())[0]; + const item = Array.from((outlet as HTMLElement).querySelectorAll('.igx-drop-down__item'))[dropdownItemIndex] as HTMLElement; + UIInteractions.simulateClickAndSelectEvent(item) + tick(); + fix.detectChanges(); + } + + public static selectFieldsInEditModeExpression(fix, deselectItemIndexes, level = 0) { + QueryBuilderFunctions.clickQueryBuilderFieldsCombo(fix, level); + fix.detectChanges(); + + const outlet = Array.from(fix.debugElement.nativeElement.querySelectorAll(`.igx-drop-down__list-scroll`)).filter(item => (item as HTMLElement).checkVisibility())[0]; + deselectItemIndexes.forEach(index => { + const item = Array.from((outlet as HTMLElement).querySelectorAll('.igx-drop-down__item'))[index] as HTMLElement; + UIInteractions.simulateClickAndSelectEvent(item) + tick(); + fix.detectChanges(); + }); + + if (level == 0) { + //close combo drop-down + QueryBuilderFunctions.clickQueryBuilderFieldsCombo(fix); + fix.detectChanges(); + } + } + + public static selectColumnInEditModeExpression(fix, dropdownItemIndex: number, level = 0) { + QueryBuilderFunctions.clickQueryBuilderColumnSelect(fix, level); + fix.detectChanges(); + + const searchClass = `${QueryBuilderSelectors.QUERY_BUILDER_TREE}--level-${level}` + const queryBuilderElement: HTMLElement = fix.debugElement.queryAll(By.css(`.${searchClass}`))[0].nativeElement; + QueryBuilderFunctions.clickQueryBuilderSelectDropdownItem(queryBuilderElement, dropdownItemIndex); + tick(); + fix.detectChanges(); + } + + public static selectOperatorInEditModeExpression(fix, dropdownItemIndex: number, level = 0) { + QueryBuilderFunctions.clickQueryBuilderOperatorSelect(fix, level); + fix.detectChanges(); + const searchClass = `${QueryBuilderSelectors.QUERY_BUILDER_TREE}--level-${level}` + const queryBuilderElement: HTMLElement = fix.debugElement.queryAll(By.css(`.${searchClass}`))[0].nativeElement; + QueryBuilderFunctions.clickQueryBuilderSelectDropdownItem(queryBuilderElement, dropdownItemIndex); + tick(); + fix.detectChanges(); + tick(100); + fix.detectChanges(); + } + + public static addAndValidateChildGroup(fix: ComponentFixture, level: number) { + // Enter values in the nested query + QueryBuilderFunctions.selectEntityInEditModeExpression(fix, 0, level); // Select 'Products' entity + tick(100); + fix.detectChanges(); + + // Select return field + QueryBuilderFunctions.selectFieldsInEditModeExpression(fix, [0], level); + tick(100); + fix.detectChanges(); + + // Click the initial 'Add Condition' button. + QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fix, level); + tick(100); + fix.detectChanges(); + + QueryBuilderFunctions.verifyEditModeQueryExpressionInputStates(fix, true, true, false, false, false, false, level); + + QueryBuilderFunctions.verifyEditModeQueryExpressionInputStates(fix, true, true, true, false, false, false, level); + + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 1, level); // Select 'ProductName' column. + + QueryBuilderFunctions.verifyEditModeQueryExpressionInputStates(fix, true, true, true, true, true, false, level); + + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 0, level); // Select 'Contains' operator. + + QueryBuilderFunctions.verifyEditModeQueryExpressionInputStates(fix, true, true, true, true, true, false, level); + + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix, false, level).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(input, 'a'); + tick(100); + fix.detectChanges(); + + // Verify all inputs + QueryBuilderFunctions.verifyEditModeExpressionInputStates(fix, true, true, false, true, level - 1); // Parent commit button should be disabled + QueryBuilderFunctions.verifyEditModeQueryExpressionInputStates(fix, true, true, true, true, true, true, level); + QueryBuilderFunctions.verifyQueryEditModeExpressionInputValues(fix, 'Products', 'Id', 'ProductName', 'Contains', 'a', level); + + //Commit the populated expression. + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix, level); + fix.detectChanges(); + } + + public static selectEntityAndClickInitialAddCondition(fix: ComponentFixture, entityIndex: number, level = 0) { + QueryBuilderFunctions.selectEntityInEditModeExpression(fix, entityIndex, level); + tick(100); + fix.detectChanges(); + + // Click the initial 'Add Condition' button. + QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fix, level); + tick(100); + fix.detectChanges(); + } + + public static GetChipsContentAsArray(fix: ComponentFixture) { + const contents: string[] = []; + + const queryTreeElement: HTMLElement = fix.debugElement.queryAll(By.css(QueryBuilderSelectors.QUERY_BUILDER_TREE))[0].nativeElement; + + queryTreeElement.querySelectorAll('.igx-chip').forEach(chip => { + contents.push(QueryBuilderFunctions.getChipContent(chip)); + }); + + return contents; + } + + public static getChipContent(chip: Element): string { + if (chip && chip.checkVisibility()) { + let text: string = ''; + + Array.from(chip.querySelectorAll('span')).forEach(element => { + if (element?.textContent) text += element.textContent; + }); + + return text.trim(); + } + } + + + public static getVisibleChips(fixture: ComponentFixture): DebugElement[] { + return fixture.debugElement.queryAll(By.directive(IgxChipComponent)).filter(chip => chip.nativeElement.offsetHeight > 0); + } + + public static getDropGhost(fixture: ComponentFixture): Element { + var expressionsContainer = QueryBuilderFunctions.getQueryBuilderExpressionsContainer(fixture); + return expressionsContainer.querySelector(`div.${QueryBuilderSelectors.FILTER_TREE_EXPRESSION_ITEM_DROP_GHOST}`) ?? + expressionsContainer.querySelector(`div.${QueryBuilderSelectors.FILTER_TREE_EXPRESSION_ITEM_KEYBOARD_GHOST}`); + } + + public static getDropGhostBounds(fixture: ComponentFixture): DOMRect { + return QueryBuilderFunctions.getDropGhost(fixture)?.getBoundingClientRect(); + } + + public static getElementCenter(element: HTMLElement) { + const bounds = element.getBoundingClientRect(); + return { + X: (bounds.left + bounds.right) / 2, + Y: (bounds.top + bounds.bottom) / 2 + } + } + + public static dragMove(dragDirective, X: number, Y: number, pointerUp?: boolean) { + //mouse down + dragDirective.onPointerMove({ pointerId: 1, pageX: X, pageY: Y }); + //duplicate the mousemove as dispatched Event, so we can trigger the RxJS listener + dragDirective.ghostElement?.dispatchEvent(new MouseEvent('mousemove', { clientX: X, clientY: Y })); + + //mouse up + if (pointerUp) { + tick(); + dragDirective.onPointerUp({ pointerId: 1, pageX: X, pageY: Y }); + } + } + + public static getDropGhostAndItsSiblings(fixture: ComponentFixture): [Element, string, string, string[]] { + const dropGhost = this.getDropGhost(fixture); + const newChipContents = QueryBuilderFunctions.GetChipsContentAsArray(fixture); + let prevElement: string, nextElement: string; + + if (dropGhost) { + if (dropGhost.previousElementSibling?.className && + dropGhost.previousElementSibling?.className?.indexOf(QueryBuilderSelectors.FILTER_TREE_SUBQUERY) !== -1) { + prevElement = QueryBuilderFunctions.getChipContent(dropGhost.previousElementSibling.previousElementSibling); + } else if (dropGhost.previousElementSibling?.previousElementSibling) { + prevElement = QueryBuilderFunctions.getChipContent(dropGhost.previousElementSibling); + } + + nextElement = QueryBuilderFunctions.getChipContent(dropGhost.nextElementSibling?.nextElementSibling); + nextElement ??= QueryBuilderFunctions.getChipContent(dropGhost.nextElementSibling?.nextElementSibling?.nextElementSibling?.nextElementSibling); + } + + prevElement ??= null; + nextElement ??= null; + + return [dropGhost, prevElement, nextElement, newChipContents]; + } + + public static verifyGhostPositionOnMouseDrag(fix: ComponentFixture, draggedChip: any, X: number, Y: number, moveDown: boolean) { + const ghostPositionVisits: boolean[] = [false, false, false, false, false, false, false, false]; + const draggedChipCenter = QueryBuilderFunctions.getElementCenter(draggedChip.chipArea.nativeElement); + const dragDir = draggedChip.dragDirective; + + //pickup chip + dragDir.onPointerDown({ pointerId: 1, pageX: draggedChipCenter.X, pageY: draggedChipCenter.Y }); + fix.detectChanges(); + + //trigger ghost + QueryBuilderFunctions.dragMove(dragDir, draggedChipCenter.X + 10, draggedChipCenter.Y + 10); + fix.detectChanges(); + + spyOn(dragDir.ghostElement, 'dispatchEvent').and.callThrough(); + + const target = moveDown ? 350 : 0; + const shift = moveDown ? 1 : -1 + //Drag ghost up or down and check if drop ghost is rendered in the expected positions + for (let i = moveDown ? 0 : 350; moveDown ? i <= target : i >= target; i += shift) { + Y += moveDown ? 1 : -1; + + QueryBuilderFunctions.dragMove(dragDir, X, Y); + tick(); + fix.detectChanges(); + + const [dropGhost, prevElement, nextElement] = QueryBuilderFunctions.getDropGhostAndItsSiblings(fix); + + if (i < 40 && !ghostPositionVisits[0]) { + if (i <= 42) tick(50); + if (!dropGhost) ghostPositionVisits[0] = true; + } + + if (i > 35 && i < 122 && !ghostPositionVisits[1]) { + if (dropGhost && !prevElement && nextElement == 'OrderName Equals foo') ghostPositionVisits[1] = true; + } + + if (i > 120 && i < 165 && !ghostPositionVisits[2]) { + if (dropGhost && prevElement == 'OrderName Equals foo' && nextElement === 'or OrderName Ends With a OrderDate Today') ghostPositionVisits[2] = true; + } + + if (i > 166 && i < 201 && !ghostPositionVisits[3]) { + if (dropGhost && !prevElement && nextElement == 'OrderName Ends With a') ghostPositionVisits[3] = true; + } + + if (i > 202 && i < 241 && !ghostPositionVisits[4]) { + if (dropGhost && prevElement == 'OrderName Ends With a' && nextElement === 'OrderDate Today') ghostPositionVisits[4] = true; + } + + if (i > 240 && i < 273 && !ghostPositionVisits[5]) { + if (dropGhost && prevElement == 'OrderDate Today' && !nextElement) ghostPositionVisits[5] = true; + } + + if (i > 256 && i < 316 && !ghostPositionVisits[6]) { + if (X > 400 || (dropGhost && prevElement == 'or OrderName Ends With a OrderDate Today' && !nextElement)) ghostPositionVisits[6] = true; + } + + if (i > 320 && !ghostPositionVisits[7]) { + if (i >= 340) tick(50); + if (!dropGhost) ghostPositionVisits[7] = true; + } + } + + //When dragged to the end, check results + expect(ghostPositionVisits).not.toContain(false, + `Ghost was not rendered on position(s) ${ghostPositionVisits.reduce((arr, e, ix) => ((e == false) && arr.push(ix), arr), []).toString()}`); + } +} diff --git a/projects/igniteui-angular/query-builder/src/query-builder/query-builder-header.component.html b/projects/igniteui-angular/query-builder/src/query-builder/query-builder-header.component.html new file mode 100644 index 00000000000..2ab47a303b0 --- /dev/null +++ b/projects/igniteui-angular/query-builder/src/query-builder/query-builder-header.component.html @@ -0,0 +1,2 @@ +
    {{ title }}
    + diff --git a/projects/igniteui-angular/query-builder/src/query-builder/query-builder-header.component.ts b/projects/igniteui-angular/query-builder/src/query-builder/query-builder-header.component.ts new file mode 100644 index 00000000000..96bd70e68e3 --- /dev/null +++ b/projects/igniteui-angular/query-builder/src/query-builder/query-builder-header.component.ts @@ -0,0 +1,60 @@ +import { Component, HostBinding, Input } from '@angular/core'; +import { IQueryBuilderResourceStrings, QueryBuilderResourceStringsEN } from 'igniteui-angular/core'; +import { getCurrentResourceStrings } from 'igniteui-angular/core'; + +@Component({ + selector: 'igx-query-builder-header', + templateUrl: 'query-builder-header.component.html' +}) +export class IgxQueryBuilderHeaderComponent { + + private _resourceStrings = getCurrentResourceStrings(QueryBuilderResourceStringsEN); + + /** + * @hidden @internal + */ + @HostBinding('class') public get getClass() { + return 'igx-query-builder__header'; + } + + /** + * Sets the title of the `IgxQueryBuilderHeaderComponent`. + * + * @example + * ```html + * + * ``` + */ + @Input() + public title: string; + + /** + * Show/hide the legend. + * + * @example + * ```html + * + * ``` + * @deprecated in version 19.1.0. + */ + @Input() + public showLegend = true; + + /** + * Sets the resource strings. + * By default it uses EN resources. + * + * @deprecated in version 19.1.0. + */ + @Input() + public set resourceStrings(value: IQueryBuilderResourceStrings) { + this._resourceStrings = Object.assign({}, this._resourceStrings, value); + } + + /** + * Returns the resource strings. + */ + public get resourceStrings(): IQueryBuilderResourceStrings { + return this._resourceStrings; + } +} diff --git a/projects/igniteui-angular/query-builder/src/query-builder/query-builder-tree.component.html b/projects/igniteui-angular/query-builder/src/query-builder/query-builder-tree.component.html new file mode 100644 index 00000000000..4d6696ad3ea --- /dev/null +++ b/projects/igniteui-angular/query-builder/src/query-builder/query-builder-tree.component.html @@ -0,0 +1,607 @@ + + + + + + + + + +
    +
    + {{ this.resourceStrings.igx_query_builder_from_label }} + + @for (entity of entities; track entity.name) { + + {{entity.name}} + + } + +
    + +
    + @if (!this.isHierarchicalNestedQuery()) { + {{ this.resourceStrings.igx_query_builder_select_label }} + } + @if (!parentExpression) { + + +
    + + +
    + {{ this.resourceStrings.igx_query_builder_select_all }} +
    +
    +
    +
    + } + @else { + + @for (field of fields; track field.field) { + + {{ field.field }} + + } + + } +
    +
    +
    + +
    + + + + + + @if (this.rootGroup) { + + } + + + + @if (!expressionItem.inEditMode) { + @if(dragService.dropGhostExpression && expressionItem === dragService.dropGhostExpression && dragService.isKeyboardDrag === false){ +
    + + {{this.resourceStrings.igx_query_builder_drop_ghost_text}} + +
    + } @else { +
    + + + + + {{expressionItem.fieldLabel || expressionItem.expression.fieldName}} + + + {{ + getConditionFriendlyName( + expressionItem.expression.condition?.name + ) + }} + + @if (!expressionItem.expression.condition?.isUnary) { + + @if (expressionItem.expression.searchTree) { + + {{expressionItem.expression.searchTree.entity}} / {{formatReturnFields(expressionItem.expression.searchTree)}} + + } + @else { + + @if(isDate(expressionItem.expression.searchVal)) { + @if(getFormatter(expressionItem.expression.fieldName)) { + {{ + expressionItem.expression.searchVal + | fieldFormatter + : getFormatter( + expressionItem.expression.fieldName + ) + : undefined + }} + } @else { + {{ + expressionItem.expression.searchVal + | date + : getFormat( + expressionItem.expression.fieldName + ) + : undefined + : this.locale + }} + } + } @else { + @if (getFormatter(expressionItem.expression.fieldName)) { + {{ + expressionItem.expression.searchVal + | fieldFormatter + : getFormatter(expressionItem.expression.fieldName) + : (expressionItem.expression.conditionName || expressionItem.expression.condition?.name) + }} + } @else { + {{ expressionItem.expression.searchVal }} + } + } + + } + + } + +
    + @if (expressionItem.expression.searchTree){ + {{expressionItem.expression.searchTree.returnFields.join(', ')}} + } @else if (expressionItem.expression.condition?.isUnary) { + {{getConditionFriendlyName(expressionItem.expression.condition?.name)}} + } @else { + @if(getFormatter(expressionItem.expression.fieldName)) { + {{ + expressionItem.expression.searchVal + | fieldFormatter + : getFormatter(expressionItem.expression.fieldName) + : (expressionItem.expression.conditionName || expressionItem.expression.condition?.name) + }} + } @else { + {{ expressionItem.expression.searchVal }} + } + } +
    + + @if (expressionItem.focused || expressionItem.hovered) { +
    + + + + {{this.resourceStrings.igx_query_builder_add_condition}} + + + {{this.resourceStrings.igx_query_builder_add_group}} + + +
    + } +
    + } + } +
    + @if (expressionItem.inEditMode) { +
    + + + @for (field of fields; track field) { + + {{ field.label || field.header || field.field }} + + } + + + + @if ( + selectedField && + conditionSelect.value && + selectedField.filters.condition(conditionSelect.value) + ) { + + + + + } + + @for (condition of getConditionList(); track condition) { + +
    + + + {{ + getConditionFriendlyName(condition) + }} +
    +
    + } +
    + + + + + + @if(!selectedField || + (selectedField.dataType !== 'date' && selectedField.dataType !== 'time' && selectedField.dataType !== 'dateTime')) { + + + + + } + @else if (selectedField && selectedField.dataType === 'date') { + + + + + + } + @else if (selectedField && selectedField.dataType === 'time') { + + + + + + } + @else if (selectedField && selectedField.dataType === 'dateTime') { + + + + } + + +
    + + +
    +
    + } + + @if ( + (!expressionItem.inEditMode && expressionItem.expression.searchTree && expressionItem.expression.searchTree.filteringOperands?.length > 0) || + (expressionItem.inEditMode && selectedField?.filters?.condition(selectedCondition)?.isNestedQuery) + ) { + + + + + } +
    +
    + + +
    +
    + +
    +
    + + + + {{getSwitchGroupText(expressionItem)}} + + + {{this.resourceStrings.igx_query_builder_ungroup}} + + +
    +
    + @for (expr of expressionItem?.children; track trackExpressionItem(expr)) { + + + + + } +
    + + @if (expressionItem === rootGroup && !hasEditedExpression) { +
    + + +
    + } +
    +
    +
    + + @if (rootGroup || (!rootGroup && (selectedEntity || (entities?.length === 1 && !entities[0]?.name)))) { +
    + @if (!this.isAdvancedFiltering()) { + Where + } + +
    + } +
    + +
    + + +
    +

    {{ this.resourceStrings.igx_query_builder_dialog_message }}

    + + {{ this.resourceStrings.igx_query_builder_dialog_checkbox_text }} + +
    +
    diff --git a/projects/igniteui-angular/query-builder/src/query-builder/query-builder-tree.component.ts b/projects/igniteui-angular/query-builder/src/query-builder/query-builder-tree.component.ts new file mode 100644 index 00000000000..0785ee0018b --- /dev/null +++ b/projects/igniteui-angular/query-builder/src/query-builder/query-builder-tree.component.ts @@ -0,0 +1,1747 @@ +import { AfterViewInit, EventEmitter, LOCALE_ID, Output, TemplateRef, inject } from '@angular/core'; +import { getLocaleFirstDayOfWeek, NgTemplateOutlet, NgClass, DatePipe } from '@angular/common'; + +import { + Component, Input, ViewChild, ChangeDetectorRef, ViewChildren, QueryList, ElementRef, OnDestroy, HostBinding +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Subject } from 'rxjs'; +import { IgxChipComponent } from 'igniteui-angular/chips'; +import { + IQueryBuilderResourceStrings, + QueryBuilderResourceStringsEN, + PlatformUtil, + trackByIdentity, + GridColumnDataType, + DataUtil, + IgxBooleanFilteringOperand, + IgxDateFilteringOperand, + IgxDateTimeFilteringOperand, + IgxNumberFilteringOperand, + IgxStringFilteringOperand, + IgxTimeFilteringOperand, + FilteringLogic, + IFilteringExpression, + FilteringExpressionsTree, + IExpressionTree, + IFilteringExpressionsTree, + FieldType, + EntityType, + HorizontalAlignment, + OverlaySettings, + VerticalAlignment, + AbsoluteScrollStrategy, + AutoPositionStrategy, + CloseScrollStrategy, + ConnectedPositioningStrategy, + IgxPickerToggleComponent, + IgxPickerClearComponent, + getCurrentResourceStrings, + isTree, + IgxOverlayOutletDirective +} from 'igniteui-angular/core'; +import { IgxDatePickerComponent } from 'igniteui-angular/date-picker'; + +import { + IgxButtonDirective, + IgxDateTimeEditorDirective, + IgxIconButtonDirective, + IgxTooltipDirective, + IgxTooltipTargetDirective, + IgxDragIgnoreDirective, + IgxDropDirective +} from 'igniteui-angular/directives'; +import { IgxSelectComponent } from 'igniteui-angular/select'; +import { IgxTimePickerComponent } from 'igniteui-angular/time-picker'; +import { IgxInputGroupComponent, IgxInputDirective, IgxPrefixDirective } from 'igniteui-angular/input-group'; +import { IgxSelectItemComponent } from 'igniteui-angular/select'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IComboSelectionChangingEventArgs, IgxComboComponent, IgxComboHeaderDirective } from 'igniteui-angular/combo'; +import { IgxCheckboxComponent, IChangeCheckboxEventArgs } from 'igniteui-angular/checkbox'; +import { IgxDialogComponent } from 'igniteui-angular/dialog'; +import { + ISelectionEventArgs, + IgxDropDownComponent, + IgxDropDownItemComponent, + IgxDropDownItemNavigationDirective +} from 'igniteui-angular/drop-down'; +import { IgxQueryBuilderSearchValueTemplateDirective } from './query-builder.directives'; +import { IgxQueryBuilderComponent } from './query-builder.component'; +import { IgxQueryBuilderDragService } from './query-builder-drag.service'; +import { ExpressionGroupItem, ExpressionItem, ExpressionOperandItem, IgxFieldFormatterPipe } from './query-builder.common'; + +const DEFAULT_PIPE_DATE_FORMAT = 'mediumDate'; +const DEFAULT_PIPE_TIME_FORMAT = 'mediumTime'; +const DEFAULT_PIPE_DATE_TIME_FORMAT = 'medium'; +const DEFAULT_PIPE_DIGITS_INFO = '1.0-3'; +const DEFAULT_CHIP_FOCUS_DELAY = 50; + +/** @hidden */ +@Component({ + selector: 'igx-query-builder-tree', + templateUrl: './query-builder-tree.component.html', + host: { 'class': 'igx-query-builder-tree' }, + imports: [ + DatePipe, + FormsModule, + IgxButtonDirective, + IgxCheckboxComponent, + IgxChipComponent, + IgxComboComponent, + IgxComboHeaderDirective, + IgxDatePickerComponent, + IgxDateTimeEditorDirective, + IgxDialogComponent, + IgxDragIgnoreDirective, + IgxDropDirective, + IgxDropDownComponent, + IgxDropDownItemComponent, + IgxDropDownItemNavigationDirective, + IgxFieldFormatterPipe, + IgxIconButtonDirective, + IgxIconComponent, + IgxInputDirective, + IgxInputGroupComponent, + IgxOverlayOutletDirective, + IgxPickerClearComponent, + IgxPickerToggleComponent, + IgxPrefixDirective, + IgxSelectComponent, + IgxSelectItemComponent, + IgxTimePickerComponent, + IgxTooltipDirective, + IgxTooltipTargetDirective, + NgClass, + NgTemplateOutlet + ], + providers: [ + IgxQueryBuilderDragService + ], +}) +export class IgxQueryBuilderTreeComponent implements AfterViewInit, OnDestroy { + public cdr = inject(ChangeDetectorRef); + public dragService = inject(IgxQueryBuilderDragService); + protected platform = inject(PlatformUtil); + private elRef = inject(ElementRef); + protected _localeId = inject(LOCALE_ID); + + /** + * @hidden @internal + */ + public _expressionTree: IExpressionTree; + + /** + * @hidden @internal + */ + public _expressionTreeCopy: IExpressionTree; + + /** + * @hidden @internal + */ + @HostBinding('class') public get getClass() { + return `igx-query-builder-tree--level-${this.level}`; + } + + /** + * Sets/gets the entities. + */ + @Input() + public entities: EntityType[]; + + /** + * Sets/gets the parent query builder component. + */ + @Input() + public queryBuilder: IgxQueryBuilderComponent; + + /** + * Sets/gets the search value template. + */ + @Input() + public searchValueTemplate: TemplateRef = null; + + /** + * Returns the parent expression operand. + */ + @Input() + public get parentExpression(): ExpressionOperandItem { + return this._parentExpression; + } + + /** + * Sets the parent expression operand. + */ + public set parentExpression(value: ExpressionOperandItem) { + this._parentExpression = value; + } + + /** + * Returns the fields. + */ + public get fields(): FieldType[] { + if (!this._fields && this.isAdvancedFiltering()) { + this._fields = this.entities[0].fields; + } + + return this._fields; + } + + /** + * Sets the fields. + */ + @Input() + public set fields(fields: FieldType[]) { + this._fields = fields; + + this._fields = this._fields?.map(f => ({...f, filters: this.getFilters(f), pipeArgs: this.getPipeArgs(f) })); + + if (!this._fields && this.isAdvancedFiltering()) { + this._fields = this.entities[0].fields; + } + } + + /** + * Returns the expression tree. + */ + public get expressionTree(): IExpressionTree { + return this._expressionTree; + } + + /** + * Sets the expression tree. + */ + @Input() + public set expressionTree(expressionTree: IExpressionTree) { + this._expressionTree = expressionTree; + if (!expressionTree) { + this._selectedEntity = this.isAdvancedFiltering() && this.entities.length === 1 ? this.entities[0] : null; + this._selectedReturnFields = this._selectedEntity ? this._selectedEntity.fields?.map(f => f.field) : []; + } + + if (!this._preventInit) { + this.init(); + } + } + + /** + * Gets the `locale` of the query builder. + * If not set, defaults to application's locale. + */ + @Input() + public get locale(): string { + return this._locale; + } + + /** + * Sets the `locale` of the query builder. + * Expects a valid BCP 47 language tag. + */ + public set locale(value: string) { + this._locale = value; + // if value is invalid, set it back to _localeId + try { + getLocaleFirstDayOfWeek(this._locale); + } catch { + this._locale = this._localeId; + } + } + + /** + * Sets the resource strings. + * By default it uses EN resources. + */ + @Input() + public set resourceStrings(value: IQueryBuilderResourceStrings) { + this._resourceStrings = Object.assign({}, this._resourceStrings, value); + } + + /** + * Returns the resource strings. + */ + public get resourceStrings(): IQueryBuilderResourceStrings { + return this._resourceStrings; + } + + /** + * Gets/sets the expected return field. + */ + @Input() public expectedReturnField: string = null; + + /** + * Event fired as the expression tree is changed. + */ + @Output() + public expressionTreeChange = new EventEmitter(); + + /** + * Event fired if a nested query builder tree is being edited. + */ + @Output() + public inEditModeChange = new EventEmitter(); + + @ViewChild('entitySelect', { read: IgxSelectComponent }) + protected entitySelect: IgxSelectComponent; + + @ViewChild('editingInputs', { read: ElementRef }) + private editingInputs: ElementRef; + + @ViewChild('returnFieldsCombo', { read: IgxComboComponent }) + private returnFieldsCombo: IgxComboComponent; + + @ViewChild('returnFieldSelect', { read: IgxSelectComponent }) + protected returnFieldSelect: IgxSelectComponent; + + @ViewChild('fieldSelect', { read: IgxSelectComponent }) + private fieldSelect: IgxSelectComponent; + + @ViewChild('conditionSelect', { read: IgxSelectComponent }) + private conditionSelect: IgxSelectComponent; + + @ViewChild('searchValueInput', { read: ElementRef }) + private searchValueInput: ElementRef; + + @ViewChild('picker') + private picker: IgxDatePickerComponent | IgxTimePickerComponent; + + @ViewChild('addRootAndGroupButton', { read: ElementRef }) + private addRootAndGroupButton: ElementRef; + + @ViewChild('addConditionButton', { read: ElementRef }) + private addConditionButton: ElementRef; + + @ViewChild('entityChangeDialog', { read: IgxDialogComponent }) + private entityChangeDialog: IgxDialogComponent; + + @ViewChild('addOptionsDropDown', { read: IgxDropDownComponent }) + private addExpressionItemDropDown: IgxDropDownComponent; + + @ViewChild('groupContextMenuDropDown', { read: IgxDropDownComponent }) + private groupContextMenuDropDown: IgxDropDownComponent; + + /** + * @hidden @internal + */ + @ViewChildren(IgxChipComponent, { read: IgxChipComponent }) + public expressionsChips: QueryList; + + @ViewChild('editingInputsContainer', { read: ElementRef }) + protected set editingInputsContainer(value: ElementRef) { + if ((value && !this._editingInputsContainer) || + (value && this._editingInputsContainer && this._editingInputsContainer.nativeElement !== value.nativeElement)) { + requestAnimationFrame(() => { + this.scrollElementIntoView(value.nativeElement); + }); + } + + this._editingInputsContainer = value; + } + + /** @hidden */ + protected get editingInputsContainer(): ElementRef { + return this._editingInputsContainer; + } + + @ViewChild('currentGroupButtonsContainer', { read: ElementRef }) + protected set currentGroupButtonsContainer(value: ElementRef) { + if ((value && !this._currentGroupButtonsContainer) || + (value && this._currentGroupButtonsContainer && this._currentGroupButtonsContainer.nativeElement !== value.nativeElement)) { + requestAnimationFrame(() => { + this.scrollElementIntoView(value.nativeElement); + }); + } + + this._currentGroupButtonsContainer = value; + } + + /** @hidden */ + protected get currentGroupButtonsContainer(): ElementRef { + return this._currentGroupButtonsContainer; + } + + @ViewChild('expressionsContainer') + private expressionsContainer: ElementRef; + + @ViewChild('overlayOutlet', { read: IgxOverlayOutletDirective, static: true }) + private overlayOutlet: IgxOverlayOutletDirective; + + @ViewChildren(IgxQueryBuilderTreeComponent) + private innerQueries: QueryList; + + /** + * @hidden @internal + */ + public innerQueryNewExpressionTree: IExpressionTree; + + /** + * @hidden @internal + */ + public rootGroup: ExpressionGroupItem; + + /** + * @hidden @internal + */ + public selectedExpressions: ExpressionOperandItem[] = []; + + /** + * @hidden @internal + */ + public currentGroup: ExpressionGroupItem; + + /** + * @hidden @internal + */ + public contextualGroup: ExpressionGroupItem; + + /** + * @hidden @internal + */ + public filteringLogics; + + /** + * @hidden @internal + */ + public selectedCondition: string; + + /** + * @hidden @internal + */ + public searchValue: { value: any } = { value: null }; + + /** + * @hidden @internal + */ + public pickerOutlet: IgxOverlayOutletDirective | ElementRef; + + /** + * @hidden @internal + */ + public prevFocusedExpression: ExpressionOperandItem; + + /** + * @hidden @internal + */ + public initialOperator = 0; + + /** + * @hidden @internal + */ + public returnFieldSelectOverlaySettings: OverlaySettings = { + scrollStrategy: new AbsoluteScrollStrategy(), + modal: false, + closeOnOutsideClick: true + }; + + /** + * @hidden @internal + */ + public entitySelectOverlaySettings: OverlaySettings = { + scrollStrategy: new AbsoluteScrollStrategy(), + modal: false, + closeOnOutsideClick: true + }; + + /** + * @hidden @internal + */ + public fieldSelectOverlaySettings: OverlaySettings = { + scrollStrategy: new AbsoluteScrollStrategy(), + modal: false, + closeOnOutsideClick: true + }; + + /** + * @hidden @internal + */ + public conditionSelectOverlaySettings: OverlaySettings = { + scrollStrategy: new AbsoluteScrollStrategy(), + modal: false, + closeOnOutsideClick: true + }; + + /** + * @hidden @internal + */ + public addExpressionDropDownOverlaySettings: OverlaySettings = { + scrollStrategy: new AbsoluteScrollStrategy(), + modal: false, + closeOnOutsideClick: true + }; + + /** + * @hidden @internal + */ + public groupContextMenuDropDownOverlaySettings: OverlaySettings = { + scrollStrategy: new AbsoluteScrollStrategy(), + modal: false, + closeOnOutsideClick: true + }; + + private destroy$ = new Subject(); + private _timeoutId: any; + private _lastFocusedChipIndex: number; + private _focusDelay = DEFAULT_CHIP_FOCUS_DELAY; + private _parentExpression: ExpressionOperandItem; + private _selectedEntity: EntityType; + private _selectedReturnFields: string | string[]; + private _selectedField: FieldType; + private _editingInputsContainer: ElementRef; + private _currentGroupButtonsContainer: ElementRef; + private _addModeExpression: ExpressionOperandItem; + private _editedExpression: ExpressionOperandItem; + private _preventInit = false; + private _prevFocusedContainer: ElementRef; + private _expandedExpressions: IFilteringExpression[] = []; + private _fields: FieldType[]; + private _locale; + private _entityNewValue: EntityType; + private _resourceStrings = getCurrentResourceStrings(QueryBuilderResourceStringsEN); + + /** + * Returns if the select entity dropdown at the root level is disabled after the initial selection. + */ + public get disableEntityChange(): boolean { + + return !this.parentExpression && this.selectedEntity ? this.queryBuilder.disableEntityChange : false; + } + + /** + * Returns if the fields combo at the root level is disabled. + */ + public get disableReturnFieldsChange(): boolean { + + return !this.selectedEntity || this.queryBuilder.disableReturnFieldsChange; + } + + /** + * Returns the current level. + */ + public get level(): number { + let parent = this.elRef.nativeElement.parentElement; + let _level = 0; + while (parent) { + if (parent.localName === 'igx-query-builder-tree') { + _level++; + } + parent = parent.parentElement; + } + return _level; + } + + private _positionSettings = { + horizontalStartPoint: HorizontalAlignment.Right, + verticalStartPoint: VerticalAlignment.Top + }; + + private _overlaySettings: OverlaySettings = { + closeOnOutsideClick: false, + modal: false, + positionStrategy: new ConnectedPositioningStrategy(this._positionSettings), + scrollStrategy: new CloseScrollStrategy() + }; + + /** @hidden */ + protected isAdvancedFiltering(): boolean { + return (this.entities?.length === 1 && !this.entities[0]?.name) || + this.entities?.find(e => e.childEntities?.length > 0) !== undefined || + (this.entities?.length > 0 && this.queryBuilder?.entities?.length > 0 && this.entities !== this.queryBuilder?.entities); + } + + /** @hidden */ + protected isHierarchicalNestedQuery(): boolean { + return this.queryBuilder.entities !== this.entities + } + + /** @hidden */ + protected isSearchValueInputDisabled(): boolean { + return !this.selectedField || + !this.selectedCondition || + (this.selectedField && + (this.selectedField.filters.condition(this.selectedCondition).isUnary || + this.selectedField.filters.condition(this.selectedCondition).isNestedQuery)); + } + + constructor() { + const elRef = this.elRef; + + this.locale = this.locale || this._localeId; + this.dragService.register(this, elRef); + } + + /** + * @hidden @internal + */ + public ngAfterViewInit(): void { + this._overlaySettings.outlet = this.overlayOutlet; + this.entitySelectOverlaySettings.outlet = this.overlayOutlet; + this.fieldSelectOverlaySettings.outlet = this.overlayOutlet; + this.conditionSelectOverlaySettings.outlet = this.overlayOutlet; + this.returnFieldSelectOverlaySettings.outlet = this.overlayOutlet; + this.addExpressionDropDownOverlaySettings.outlet = this.overlayOutlet; + this.groupContextMenuDropDownOverlaySettings.outlet = this.overlayOutlet; + + if (this.isAdvancedFiltering() && this.entities?.length === 1) { + this.selectedEntity = this.entities[0].name; + if (this._selectedEntity.fields.find(f => f.field === this.expectedReturnField)) { + this._selectedReturnFields = [this.expectedReturnField]; + } + } + + // Trigger additional change detection cycle + this.cdr.detectChanges(); + } + + /** + * @hidden @internal + */ + public ngOnDestroy(): void { + this.destroy$.next(true); + this.destroy$.complete(); + } + + /** + * @hidden @internal + */ + public set selectedEntity(value: string) { + this._selectedEntity = this.entities?.find(el => el.name === value); + } + + /** + * @hidden @internal + */ + public get selectedEntity(): EntityType { + return this._selectedEntity; + } + + /** + * @hidden @internal + */ + public onEntitySelectChanging(event: ISelectionEventArgs) { + event.cancel = true; + this._entityNewValue = event.newSelection.value; + if (event.oldSelection.value && this.queryBuilder.showEntityChangeDialog) { + this.entityChangeDialog.open(); + } else { + this.onEntityChangeConfirm(); + } + } + + /** + * @hidden + */ + public onShowEntityChangeDialogChange(eventArgs: IChangeCheckboxEventArgs) { + this.queryBuilder.showEntityChangeDialog = !eventArgs.checked; + } + + /** + * @hidden + */ + public onEntityChangeCancel() { + this.entityChangeDialog.close(); + this.entitySelect.close(); + this._entityNewValue = null; + } + + /** + * @hidden + */ + public onEntityChangeConfirm() { + if (this._parentExpression) { + this._expressionTree = this.createExpressionTreeFromGroupItem(this.createExpressionGroupItem(this._expressionTree)); + } + + this._selectedEntity = this._entityNewValue; + if (!this._selectedEntity.fields) { + this._selectedEntity.fields = []; + } + this.fields = this._entityNewValue ? this._entityNewValue.fields : []; + + if (this._selectedEntity.fields.find(f => f.field === this.expectedReturnField)) { + this._selectedReturnFields = [this.expectedReturnField]; + } else { + this._selectedReturnFields = this.parentExpression ? [] : this._entityNewValue.fields?.map(f => f.field); + } + + if (this._expressionTree) { + this._expressionTree.entity = this._entityNewValue.name; + + const returnFields = Array.isArray(this._selectedReturnFields) ? this._selectedReturnFields : [this._selectedReturnFields]; + this._expressionTree.returnFields = this.fields.length === returnFields.length ? ['*'] : returnFields; + + this._expressionTree.filteringOperands = []; + + this._editedExpression = null; + if (!this.parentExpression) { + this.expressionTreeChange.emit(this._expressionTree); + } + + this.rootGroup = null; + this.currentGroup = this.rootGroup; + } + + this._selectedField = null; + this.selectedCondition = null; + this.searchValue.value = null; + + this.entityChangeDialog.close(); + this.entitySelect.close(); + + this._entityNewValue = null; + this.innerQueryNewExpressionTree = null; + + this.initExpressionTree(this._selectedEntity.name, this.selectedReturnFields); + } + + /** + * @hidden @internal + */ + public set selectedReturnFields(value: string[]) { + if (this._selectedReturnFields !== value) { + this._selectedReturnFields = value; + + if (this._expressionTree && !this.parentExpression) { + this._expressionTree.returnFields = value.length === this.fields.length ? ['*'] : value; + this.expressionTreeChange.emit(this._expressionTree); + } + } + } + + /** + * @hidden @internal + */ + public get selectedReturnFields(): string[] { + if (typeof this._selectedReturnFields == 'string') { + return [this._selectedReturnFields]; + } + return this._selectedReturnFields; + } + + /** + * @hidden @internal + */ + public set selectedField(value: FieldType) { + const oldValue = this._selectedField; + + if (this._selectedField !== value) { + this._selectedField = value; + if (this._selectedField && !this._selectedField.dataType) { + this._selectedField.filters = this.getFilters(this._selectedField); + } + + this.selectDefaultCondition(); + if (oldValue && this._selectedField && this._selectedField.dataType !== oldValue.dataType) { + this.searchValue.value = null; + this.cdr.detectChanges(); + } + } + } + + /** + * @hidden @internal + */ + public get selectedField(): FieldType { + return this._selectedField; + } + + /** + * @hidden @internal + * + * used by the grid + */ + public setPickerOutlet(outlet?: IgxOverlayOutletDirective | ElementRef) { + this.pickerOutlet = outlet; + } + + /** + * @hidden @internal + * + * used by the grid + */ + public get isContextMenuVisible(): boolean { + return !this.groupContextMenuDropDown.collapsed; + } + + /** + * @hidden @internal + */ + public get hasEditedExpression(): boolean { + return this._editedExpression !== undefined && this._editedExpression !== null; + } + + /** + * @hidden @internal + */ + public addCondition(parent: ExpressionGroupItem, afterExpression?: ExpressionOperandItem, isUIInteraction?: boolean) { + this.cancelOperandAdd(); + + const operandItem = new ExpressionOperandItem({ + fieldName: null, + condition: null, + conditionName: null, + ignoreCase: true, + searchVal: null + }, parent); + + const groupItem = new ExpressionGroupItem(this.getOperator(null) ?? FilteringLogic.And, parent); + this.contextualGroup = groupItem; + this.initialOperator = null; + + this._lastFocusedChipIndex = this._lastFocusedChipIndex === undefined ? -1 : this._lastFocusedChipIndex; + + if (parent) { + if (afterExpression) { + const index = parent.children.indexOf(afterExpression); + parent.children.splice(index + 1, 0, operandItem); + } else { + parent.children.push(operandItem); + } + this._lastFocusedChipIndex++; + } else { + this.rootGroup = groupItem; + operandItem.parent = groupItem; + this.rootGroup.children.push(operandItem); + this._lastFocusedChipIndex = 0; + } + + this._focusDelay = 250; + + if (isUIInteraction && !afterExpression) { + this._lastFocusedChipIndex = this.expressionsChips.length; + this._focusDelay = DEFAULT_CHIP_FOCUS_DELAY; + } + + this.enterExpressionEdit(operandItem); + } + + /** + * @hidden @internal + */ + public addReverseGroup(parent?: ExpressionGroupItem, afterExpression?: ExpressionItem) { + parent = parent ?? this.rootGroup; + + if (parent.operator === FilteringLogic.And) { + this.addGroup(FilteringLogic.Or, parent, afterExpression); + } else { + this.addGroup(FilteringLogic.And, parent, afterExpression); + } + } + + /** + * @hidden @internal + */ + public endGroup(groupItem: ExpressionGroupItem) { + this.currentGroup = groupItem.parent; + } + + /** + * @hidden @internal + */ + public commitExpression() { + this.commitOperandEdit(); + this.focusEditedExpressionChip(); + } + + /** + * @hidden @internal + */ + public discardExpression(expressionItem?: ExpressionOperandItem) { + this.cancelOperandEdit(); + if (expressionItem && expressionItem.expression.fieldName) { + this.focusEditedExpressionChip(); + } + } + + /** + * @hidden @internal + */ + public commitOperandEdit() { + const actualSearchValue = this.searchValue.value; + if (this._editedExpression) { + this._editedExpression.expression.fieldName = this.selectedField.field; + this._editedExpression.expression.condition = this.selectedField.filters.condition(this.selectedCondition); + this._editedExpression.expression.conditionName = this.selectedCondition; + this._editedExpression.expression.searchVal = DataUtil.parseValue(this.selectedField.dataType, actualSearchValue) || actualSearchValue; + this._editedExpression.fieldLabel = this.selectedField.label + ? this.selectedField.label + : this.selectedField.header + ? this.selectedField.header + : this.selectedField.field; + + const innerQuery = this.innerQueries.filter(q => q.isInEditMode())[0] + if (innerQuery && this.selectedField?.filters?.condition(this.selectedCondition)?.isNestedQuery) { + innerQuery.exitEditAddMode(); + this._editedExpression.expression.searchTree = this.getExpressionTreeCopy(innerQuery.expressionTree); + const returnFields = innerQuery.selectedReturnFields.length > 0 ? + innerQuery.selectedReturnFields : + [innerQuery.fields[0].field]; + this._editedExpression.expression.searchTree.returnFields = returnFields; + } else { + this._editedExpression.expression.searchTree = null; + } + this.innerQueryNewExpressionTree = null; + + if (this.selectedField.filters.condition(this.selectedCondition)?.isUnary || this.selectedField.filters.condition(this.selectedCondition)?.isNestedQuery) { + this._editedExpression.expression.searchVal = null; + } + + this._editedExpression.inEditMode = false; + this._editedExpression = null; + } + + if (this.selectedReturnFields.length === 0) { + this.selectedReturnFields = this.fields.map(f => f.field); + } + + this._expressionTree = this.createExpressionTreeFromGroupItem(this.rootGroup, this.selectedEntity?.name, this.selectedReturnFields); + if (!this.parentExpression) { + this.expressionTreeChange.emit(this._expressionTree); + } + } + + /** + * @hidden @internal + */ + public cancelOperandAdd() { + if (this._addModeExpression) { + this._addModeExpression.inAddMode = false; + this._addModeExpression = null; + } + } + + /** + * @hidden @internal + */ + public deleteItem = (expressionItem: ExpressionItem, skipEmit: boolean = false) => { + if (!expressionItem.parent) { + this.rootGroup = null; + this.currentGroup = null; + //this._expressionTree = null; + return; + } + + if (expressionItem === this.currentGroup) { + this.currentGroup = this.currentGroup.parent; + } + + const children = expressionItem.parent.children; + const index = children.indexOf(expressionItem); + children.splice(index, 1); + const entity = this.expressionTree ? this.expressionTree.entity : null; + const returnFields = this.expressionTree ? this.expressionTree.returnFields : null; + this._expressionTree = this.createExpressionTreeFromGroupItem(this.rootGroup, entity, returnFields); // TODO: don't recreate if not necessary + + if (!children.length) { + this.deleteItem(expressionItem.parent, true); + } + + if (!this.parentExpression && !skipEmit) { + this.expressionTreeChange.emit(this._expressionTree); + } + } + + /** + * @hidden @internal + */ + public cancelOperandEdit() { + if (this.innerQueries) { + const innerQuery = this.innerQueries.filter(q => q.isInEditMode())[0]; + if (innerQuery) { + if (innerQuery._editedExpression) { + innerQuery.cancelOperandEdit(); + } + + innerQuery.expressionTree = this.getExpressionTreeCopy(this._editedExpression.expression.searchTree); + this.innerQueryNewExpressionTree = null; + } + } + + if (this._editedExpression) { + this._editedExpression.inEditMode = false; + + if (!this._editedExpression.expression.fieldName) { + this.deleteItem(this._editedExpression); + } + + this._editedExpression = null; + } + + if (!this.expressionTree && this.contextualGroup) { + this.initialOperator = this.contextualGroup.operator; + } + } + + /** + * @hidden @internal + */ + public operandCanBeCommitted(): boolean { + const innerQuery = this.innerQueries.filter(q => q.isInEditMode())[0]; + return this.selectedField && this.selectedCondition && + ( + ( + ((!Array.isArray(this.searchValue.value) && !!this.searchValue.value) || (Array.isArray(this.searchValue.value) && this.searchValue.value.length !== 0)) && + !(this.selectedField?.filters?.condition(this.selectedCondition)?.isNestedQuery) + ) || + ( + this.selectedField?.filters?.condition(this.selectedCondition)?.isNestedQuery && innerQuery && !!innerQuery.expressionTree && innerQuery.selectedReturnFields?.length > 0 + ) || + this.selectedField.filters.condition(this.selectedCondition)?.isUnary + ); + } + + /** + * @hidden @internal + */ + public canCommitCurrentState(): boolean { + const innerQuery = this.innerQueries.filter(q => q.isInEditMode())[0]; + if (innerQuery) { + return this.selectedReturnFields?.length > 0 && innerQuery.canCommitCurrentState(); + } else { + return this.selectedReturnFields?.length > 0 && + ( + (!this._editedExpression) || // no edited expr + (this._editedExpression && !this.selectedField) || // empty edited expr + (this._editedExpression && this.operandCanBeCommitted() === true) // valid edited expr + ); + } + } + + /** + * @hidden @internal + */ + public commitCurrentState(): void { + const innerQuery = this.innerQueries.filter(q => q.isInEditMode())[0]; + if (innerQuery) { + innerQuery.commitCurrentState(); + } + + if (this._editedExpression) { + if (this.selectedField) { + this.commitOperandEdit(); + } else { + this.deleteItem(this._editedExpression); + this._editedExpression = null; + } + } + } + + /** + * @hidden @internal + */ + public exitEditAddMode(shouldPreventInit = false) { + if (!this._editedExpression) { + return; + } + + this.exitOperandEdit(); + this.cancelOperandAdd(); + + if (shouldPreventInit) { + this._preventInit = true; + } + } + + /** + * @hidden @internal + * + * used by the grid + */ + public exitOperandEdit() { + if (!this._editedExpression) { + return; + } + + if (this.operandCanBeCommitted()) { + this.commitOperandEdit(); + } else { + this.cancelOperandEdit(); + } + } + + /** + * @hidden @internal + */ + public isExpressionGroup(expression: ExpressionItem): boolean { + return expression instanceof ExpressionGroupItem; + } + + /** + * @hidden @internal + */ + public onExpressionFocus(expressionItem: ExpressionOperandItem) { + if (this.prevFocusedExpression) { + this.prevFocusedExpression.focused = false; + } + expressionItem.focused = true; + this.prevFocusedExpression = expressionItem; + } + + /** + * @hidden @internal + */ + public onExpressionBlur(event, expressionItem: ExpressionOperandItem) { + if (this._prevFocusedContainer && this._prevFocusedContainer !== event.target.closest('.igx-filter-tree__expression-item')) { + expressionItem.focused = false; + } + this._prevFocusedContainer = event.target.closest('.igx-filter-tree__expression-item'); + } + + /** + * @hidden @internal + */ + public onChipRemove(expressionItem: ExpressionItem) { + this.exitEditAddMode(); + this.deleteItem(expressionItem); + } + + /** + * @hidden @internal + */ + public focusChipAfterDrag = (index: number) => { + this._lastFocusedChipIndex = index; + this.focusEditedExpressionChip(); + } + /** + * @hidden @internal + */ + public addExpressionBlur() { + if (this.prevFocusedExpression) { + this.prevFocusedExpression.focused = false; + } + if (this.addExpressionItemDropDown && !this.addExpressionItemDropDown.collapsed) { + this.addExpressionItemDropDown.close(); + } + } + + /** + * @hidden @internal + */ + public onChipClick(expressionItem: ExpressionOperandItem, chip: IgxChipComponent) { + this.enterExpressionEdit(expressionItem, chip); + } + + /** + * @hidden @internal + */ + public enterExpressionEdit(expressionItem: ExpressionOperandItem, chip?: IgxChipComponent) { + this.exitEditAddMode(true); + this.cdr.detectChanges(); + this._lastFocusedChipIndex = chip ? this.expressionsChips.toArray().findIndex(expr => expr === chip) : this._lastFocusedChipIndex; + this.enterEditMode(expressionItem); + } + + + /** + * @hidden @internal + */ + public clickExpressionAdd(targetButton: HTMLElement, chip: IgxChipComponent) { + this.exitEditAddMode(true); + this.cdr.detectChanges(); + this._lastFocusedChipIndex = this.expressionsChips.toArray().findIndex(expr => expr === chip); + this.openExpressionAddDialog(targetButton); + } + + /** + * @hidden @internal + */ + public openExpressionAddDialog(targetButton: HTMLElement) { + this.addExpressionDropDownOverlaySettings.target = targetButton; + this.addExpressionDropDownOverlaySettings.positionStrategy = new ConnectedPositioningStrategy({ + horizontalDirection: HorizontalAlignment.Right, + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Bottom + }); + + this.addExpressionItemDropDown.open(this.addExpressionDropDownOverlaySettings); + } + + /** + * @hidden @internal + */ + public enterExpressionAdd(event: ISelectionEventArgs, expressionItem: ExpressionOperandItem) { + if (this._addModeExpression) { + this._addModeExpression.inAddMode = false; + } + + if (this.parentExpression) { + this.inEditModeChange.emit(this.parentExpression); + } + + const parent = expressionItem.parent ?? this.rootGroup; + requestAnimationFrame(() => { + if (event.newSelection.value === 'addCondition') { + this.addCondition(parent, expressionItem); + } else if (event.newSelection.value === 'addGroup') { + this.addReverseGroup(parent, expressionItem); + } + expressionItem.inAddMode = true; + this._addModeExpression = expressionItem; + }) + } + + /** + * @hidden @internal + */ + public enterEditMode(expressionItem: ExpressionOperandItem) { + if (this._editedExpression) { + this._editedExpression.inEditMode = false; + } + + if (this.parentExpression) { + this.inEditModeChange.emit(this.parentExpression); + } + + expressionItem.hovered = false; + this.fields = this.selectedEntity ? this.selectedEntity.fields : null; + this.selectedField = + expressionItem.expression.fieldName ? + this.fields?.find(field => field.field === expressionItem.expression.fieldName) + : null; + this.selectedCondition = + expressionItem.expression.condition ? + expressionItem.expression.condition.name : + null; + this.searchValue.value = expressionItem.expression.searchVal instanceof Set ? + Array.from(expressionItem.expression.searchVal) : + expressionItem.expression.searchVal; + + expressionItem.inEditMode = true; + this._editedExpression = expressionItem; + this.cdr.detectChanges(); + + this.entitySelectOverlaySettings.target = this.entitySelect.getEditElement(); + this.entitySelectOverlaySettings.excludeFromOutsideClick = [this.entitySelect.getEditElement() as HTMLElement]; + this.entitySelectOverlaySettings.positionStrategy = new AutoPositionStrategy(); + + if (this.returnFieldSelect) { + this.returnFieldSelectOverlaySettings.target = this.returnFieldSelect.getEditElement(); + this.returnFieldSelectOverlaySettings.excludeFromOutsideClick = [this.returnFieldSelect.getEditElement() as HTMLElement]; + this.returnFieldSelectOverlaySettings.positionStrategy = new AutoPositionStrategy(); + } + if (this.fieldSelect) { + this.fieldSelectOverlaySettings.target = this.fieldSelect.getEditElement(); + this.fieldSelectOverlaySettings.excludeFromOutsideClick = [this.fieldSelect.getEditElement() as HTMLElement]; + this.fieldSelectOverlaySettings.positionStrategy = new AutoPositionStrategy(); + } + if (this.conditionSelect) { + this.conditionSelectOverlaySettings.target = this.conditionSelect.getEditElement(); + this.conditionSelectOverlaySettings.excludeFromOutsideClick = [this.conditionSelect.getEditElement() as HTMLElement]; + this.conditionSelectOverlaySettings.positionStrategy = new AutoPositionStrategy(); + } + + if (!this.selectedField) { + this.fieldSelect.input.nativeElement.focus(); + } else if (this.selectedField.filters.condition(this.selectedCondition)?.isUnary) { + this.conditionSelect?.input.nativeElement.focus(); + } else { + const input = this.searchValueInput?.nativeElement || this.picker?.getEditElement(); + input?.focus(); + } + + (this.editingInputs?.nativeElement.parentElement as HTMLElement)?.scrollIntoView({ block: "nearest", inline: "nearest" }); + } + + /** + * @hidden @internal + */ + public onConditionSelectChanging(event: ISelectionEventArgs) { + event.cancel = true; + this.selectedCondition = event.newSelection.value; + this.conditionSelect.close(); + this.cdr.detectChanges(); + } + + /** + * @hidden @internal + */ + public onKeyDown(eventArgs: KeyboardEvent) { + eventArgs.stopPropagation(); + } + + /** + * @hidden @internal + */ + public onGroupClick(groupContextMenuDropDown: any, targetButton: HTMLButtonElement, groupItem: ExpressionGroupItem) { + this.exitEditAddMode(); + this.cdr.detectChanges(); + + this.groupContextMenuDropDown = groupContextMenuDropDown; + this.groupContextMenuDropDownOverlaySettings.target = targetButton; + this.groupContextMenuDropDownOverlaySettings.positionStrategy = new ConnectedPositioningStrategy({ + horizontalDirection: HorizontalAlignment.Right, + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Bottom + }); + + if (groupContextMenuDropDown.collapsed) { + this.contextualGroup = groupItem; + groupContextMenuDropDown.open(this.groupContextMenuDropDownOverlaySettings); + } else { + groupContextMenuDropDown.close(); + } + } + + /** + * @hidden @internal + */ + public getOperator(expressionItem: any) { + // if (!expressionItem && !this.expressionTree && !this.initialOperator) { + // this.initialOperator = 0; + // } + + const operator = expressionItem ? + expressionItem.operator : + this.expressionTree ? + this.expressionTree.operator : + this.initialOperator; + return operator; + } + + /** + * @hidden @internal + */ + public getSwitchGroupText(expressionItem: any) { + const operator = this.getOperator(expressionItem); + const condition = operator === FilteringLogic.Or ? this.resourceStrings.igx_query_builder_and_label : this.resourceStrings.igx_query_builder_or_label + return this.resourceStrings.igx_query_builder_switch_group.replace('{0}', condition.toUpperCase()); + } + + /** + * @hidden @internal + */ + public onGroupContextMenuDropDownSelectionChanging(event: ISelectionEventArgs) { + event.cancel = true; + + if (event.newSelection.value === 'switchCondition') { + const newOperator = (!this.expressionTree ? this.initialOperator : (this.contextualGroup ?? this._expressionTree).operator) === 0 ? 1 : 0; + this.selectFilteringLogic(newOperator); + } else if (event.newSelection.value === 'ungroup') { + this.ungroup(); + } + + this.groupContextMenuDropDown.close(); + } + + /** + * @hidden @internal + */ + public ungroup() { + const selectedGroup = this.contextualGroup; + const parent = selectedGroup.parent; + if (parent) { + const index = parent.children.indexOf(selectedGroup); + parent.children.splice(index, 1, ...selectedGroup.children); + + for (const expr of selectedGroup.children) { + expr.parent = parent; + } + } + this.commitOperandEdit(); + } + + /** + * @hidden @internal + */ + public selectFilteringLogic(index: number) { + if (!this.expressionTree) { + this.initialOperator = index; + return; + } + + if (this.contextualGroup) { + this.contextualGroup.operator = index as FilteringLogic; + this.commitOperandEdit(); + } else if (this.expressionTree) { + this._expressionTree.operator = index as FilteringLogic; + } + + this.initialOperator = null; + } + + /** + * @hidden @internal + */ + public getConditionFriendlyName(name: string): string { + // As we have an 'In' condition already used in ESF to search in a Set, we add the 'Query' suffix to the newly introduced nested query condition names. + // So instead of in/notIn we end up with 'inQuery'/'notInQuery', hence removing the suffix from the friendly name. + return this.resourceStrings[`igx_query_builder_filter_${name?.replace('Query', '')}`] || name; + } + + /** + * @hidden @internal + */ + public isDate(value: any) { + return value instanceof Date; + } + + /** + * @hidden @internal + */ + public invokeClick(eventArgs: KeyboardEvent) { + if (!this.dragService.dropGhostExpression && this.platform.isActivationKey(eventArgs)) { + eventArgs.preventDefault(); + (eventArgs.currentTarget as HTMLElement).click(); + } + } + + /** + * @hidden @internal + */ + public openPicker(args: KeyboardEvent) { + if (this.platform.isActivationKey(args)) { + args.preventDefault(); + this.picker.open(); + } + } + + /** + * @hidden @internal + */ + public onOutletPointerDown(event) { + // This prevents closing the select's dropdown when clicking the scroll + event.preventDefault(); + } + + /** + * @hidden @internal + */ + public getConditionList(): string[] { + if (!this.selectedField) return []; + + if (!this.selectedField.filters) { + this.selectedField.filters = this.getFilters(this.selectedField); + } + + if ((this.isAdvancedFiltering() && !this.entities[0].childEntities) || + (this.isHierarchicalNestedQuery() && this.selectedEntity.name && !this.selectedEntity.childEntities)) { + return this.selectedField.filters.conditionList(); + } + + return this.selectedField.filters.extendedConditionList(); + } + + /** + * @hidden @internal + */ + public getFormatter(field: string) { + return this.fields?.find(el => el.field === field)?.formatter; + } + + /** + * @hidden @internal + */ + public getFormat(field: string) { + return this.fields?.find(el => el.field === field).pipeArgs.format; + } + + /** + * @hidden @internal + * + * used by the grid + */ + public setAddButtonFocus() { + if (this.addRootAndGroupButton) { + this.addRootAndGroupButton.nativeElement.focus(); + } else if (this.addConditionButton) { + this.addConditionButton.nativeElement.focus(); + } + } + + /** + * @hidden @internal + */ + public context(expression: ExpressionItem, afterExpression?: ExpressionItem) { + return { + $implicit: expression, + afterExpression + }; + } + + public formatReturnFields(innerTree: IFilteringExpressionsTree) { + const returnFields = innerTree.returnFields; + let text = returnFields.join(', '); + const innerTreeEntity = this.entities?.find(el => el.name === innerTree.entity); + if (returnFields.length === innerTreeEntity?.fields.length) { + text = this.resourceStrings.igx_query_builder_all_fields; + } else { + text = returnFields.join(', '); + text = text.length > 25 ? text.substring(0, 25) + ' ...' : text; + } + return text; + } + + public isInEditMode(): boolean { + return !this.parentExpression || (this.parentExpression && this.parentExpression.inEditMode); + } + + public onInEditModeChanged(expressionItem: ExpressionOperandItem) { + if (!expressionItem.inEditMode) { + this.enterExpressionEdit(expressionItem); + } + } + + public getExpressionTreeCopy(expressionTree: IExpressionTree, shouldAssignInnerQueryExprTree?: boolean): IExpressionTree { + if (!expressionTree) { + return null; + } + + const exprTreeCopy = new FilteringExpressionsTree(expressionTree.operator, expressionTree.fieldName, expressionTree.entity, expressionTree.returnFields); + exprTreeCopy.filteringOperands = []; + + expressionTree.filteringOperands.forEach(o => isTree(o) ? exprTreeCopy.filteringOperands.push(this.getExpressionTreeCopy(o)) : exprTreeCopy.filteringOperands.push(o)); + + if (!this.innerQueryNewExpressionTree && shouldAssignInnerQueryExprTree) { + this.innerQueryNewExpressionTree = exprTreeCopy; + } + + return exprTreeCopy; + } + + public onSelectAllClicked() { + if ( + (this._selectedReturnFields.length > 0 && this._selectedReturnFields.length < this._selectedEntity.fields.length) || + this._selectedReturnFields.length == this._selectedEntity.fields.length + ) { + this.returnFieldsCombo.deselectAllItems(); + } else { + this.returnFieldsCombo.selectAllItems(); + } + } + + public onReturnFieldSelectChanging(event: IComboSelectionChangingEventArgs | ISelectionEventArgs) { + let newSelection = []; + if (Array.isArray(event.newSelection)) { + newSelection = event.newSelection.map(item => item.field) + } else { + newSelection.push(event.newSelection.value); + this._selectedReturnFields = newSelection; + } + + this.initExpressionTree(this.selectedEntity.name, newSelection); + } + + public initExpressionTree(selectedEntityName: string, selectedReturnFields: string[]) { + if (!this._expressionTree) { + this._expressionTree = this.createExpressionTreeFromGroupItem(new ExpressionGroupItem(FilteringLogic.And, this.rootGroup), selectedEntityName, selectedReturnFields); + } + + if (!this.parentExpression) { + this.expressionTreeChange.emit(this._expressionTree); + } + } + + public getSearchValueTemplateContext(defaultSearchValueTemplate): any { + const ctx = { + $implicit: this.searchValue, + selectedField: this.selectedField, + selectedCondition: this.selectedCondition, + defaultSearchValueTemplate: defaultSearchValueTemplate + }; + return ctx; + } + + private getPipeArgs(field: FieldType) { + let pipeArgs = {...field.pipeArgs}; + if (!pipeArgs) { + pipeArgs = { digitsInfo: DEFAULT_PIPE_DIGITS_INFO }; + } + + if (!pipeArgs.format) { + pipeArgs.format = field.dataType === GridColumnDataType.Time ? + DEFAULT_PIPE_TIME_FORMAT : field.dataType === GridColumnDataType.DateTime ? + DEFAULT_PIPE_DATE_TIME_FORMAT : DEFAULT_PIPE_DATE_FORMAT; + } + + return pipeArgs; + } + + private selectDefaultCondition() { + if (this.selectedField && this.selectedField.filters) { + this.selectedCondition = this.selectedField.filters.conditionList().indexOf('equals') >= 0 ? 'equals' : this.selectedField.filters.conditionList()[0]; + } + } + + private getFilters(field: FieldType) { + if (!field.filters) { + switch (field.dataType) { + case GridColumnDataType.Boolean: + return IgxBooleanFilteringOperand.instance(); + case GridColumnDataType.Number: + case GridColumnDataType.Currency: + case GridColumnDataType.Percent: + return IgxNumberFilteringOperand.instance(); + case GridColumnDataType.Date: + return IgxDateFilteringOperand.instance(); + case GridColumnDataType.Time: + return IgxTimeFilteringOperand.instance(); + case GridColumnDataType.DateTime: + return IgxDateTimeFilteringOperand.instance(); + case GridColumnDataType.String: + default: + return IgxStringFilteringOperand.instance(); + } + } else { + return field.filters; + } + } + + + private addGroup(operator: FilteringLogic, parent?: ExpressionGroupItem, afterExpression?: ExpressionItem) { + this.cancelOperandAdd(); + + const groupItem = new ExpressionGroupItem(operator, parent); + + if (parent) { + if (afterExpression) { + const index = parent.children.indexOf(afterExpression); + parent.children.splice(index + 1, 0, groupItem); + } else { + parent.children.push(groupItem); + } + } else { + this.rootGroup = groupItem; + } + + this.addCondition(groupItem); + this.currentGroup = groupItem; + } + + private createExpressionGroupItem(expressionTree: IExpressionTree, parent?: ExpressionGroupItem, entityName?: string): ExpressionGroupItem { + let groupItem: ExpressionGroupItem; + if (expressionTree) { + groupItem = new ExpressionGroupItem(expressionTree.operator, parent); + if (!expressionTree.filteringOperands) { + return groupItem; + } + + for (let i = 0; i < expressionTree.filteringOperands.length; i++) { + const expr = expressionTree.filteringOperands[i]; + + if (isTree(expr)) { + groupItem.children.push(this.createExpressionGroupItem(expr, groupItem, expressionTree.entity)); + } else { + const filteringExpr = expr as IFilteringExpression; + const exprCopy: IFilteringExpression = { + fieldName: filteringExpr.fieldName, + condition: filteringExpr.condition, + conditionName: filteringExpr.condition?.name || filteringExpr.conditionName, + searchVal: filteringExpr.searchVal, + searchTree: filteringExpr.searchTree, + ignoreCase: filteringExpr.ignoreCase + }; + const operandItem = new ExpressionOperandItem(exprCopy, groupItem); + const field = this.fields?.find(el => el.field === filteringExpr.fieldName); + operandItem.fieldLabel = field?.label || field?.header || field?.field; + if (this._expandedExpressions.filter(e => e.searchTree == operandItem.expression.searchTree).length > 0) { + operandItem.expanded = true; + } + groupItem.children.push(operandItem); + } + } + + + if (expressionTree.entity) { + entityName = expressionTree.entity; + } + const entity = this.entities?.find(el => el.name === entityName); + if (entity) { + this.fields = entity.fields; + } + + this._selectedEntity = this.entities?.find(el => el.name === entityName); + this._selectedReturnFields = + !expressionTree.returnFields || expressionTree.returnFields.includes('*') || expressionTree.returnFields.includes('All') || expressionTree.returnFields.length === 0 + ? this.fields?.map(f => f.field) + : this.fields?.filter(f => expressionTree.returnFields.indexOf(f.field) >= 0).map(f => f.field); + } + return groupItem; + } + + private createExpressionTreeFromGroupItem(groupItem: ExpressionGroupItem, entity?: string, returnFields?: string[]): FilteringExpressionsTree { + if (!groupItem) { + return null; + } + + const expressionTree = new FilteringExpressionsTree(groupItem.operator, undefined, entity, returnFields); + + for (let i = 0; i < groupItem.children.length; i++) { + const item = groupItem.children[i]; + + if (item instanceof ExpressionGroupItem) { + const subTree = this.createExpressionTreeFromGroupItem((item as ExpressionGroupItem), entity, returnFields); + expressionTree.filteringOperands.push(subTree); + } else { + expressionTree.filteringOperands.push((item as ExpressionOperandItem).expression); + } + } + + return expressionTree; + } + + private scrollElementIntoView(target: HTMLElement) { + const container = this.expressionsContainer.nativeElement; + const targetOffset = target.offsetTop - container.offsetTop; + const delta = 10; + + if (container.scrollTop + delta > targetOffset) { + container.scrollTop = targetOffset - delta; + } else if (container.scrollTop + container.clientHeight < targetOffset + target.offsetHeight + delta) { + container.scrollTop = targetOffset + target.offsetHeight + delta - container.clientHeight; + } + } + + private focusEditedExpressionChip() { + if (this._timeoutId) { + clearTimeout(this._timeoutId); + } + + this._timeoutId = setTimeout(() => { + if (this._lastFocusedChipIndex != -1) { + //Sort the expression chip list. + //If there was a recent drag&drop and the tree hasn't rerendered(child query), they will be unordered + const sortedChips = this.expressionsChips.toArray().sort(function (a, b) { + if (a === b) return 0; + if (a.chipArea.nativeElement.compareDocumentPosition(b.chipArea.nativeElement) & 2) { + // b comes before a + return 1; + } + return -1; + }); + const chipElement = sortedChips[this._lastFocusedChipIndex]?.nativeElement; + if (chipElement) { + chipElement.focus(); + } + this._lastFocusedChipIndex = -1; + this._focusDelay = DEFAULT_CHIP_FOCUS_DELAY; + } + }, this._focusDelay); + } + + private init() { + this.cancelOperandAdd(); + this.cancelOperandEdit(); + + // Ignore values of certain properties for the comparison + const propsToIgnore = ['parent', 'hovered', 'ignoreCase', 'inEditMode', 'inAddMode']; + const propsReplacer = function replacer(key, value) { + if (propsToIgnore.indexOf(key) >= 0) { + return undefined; + } else { + return value; + } + }; + + // Skip root being recreated if the same + const newRootGroup = this.createExpressionGroupItem(this.expressionTree); + if (JSON.stringify(this.rootGroup, propsReplacer) !== JSON.stringify(newRootGroup, propsReplacer)) { + this.rootGroup = this.createExpressionGroupItem(this.expressionTree); + this.currentGroup = this.rootGroup; + } + + if (this.rootGroup?.children?.length == 0) { + this.rootGroup = null; + this.currentGroup = null; + } + } + + /** rootGroup is recreated after clicking Apply, which sets new expressionTree and calls init()*/ + protected trackExpressionItem = trackByIdentity; +} diff --git a/projects/igniteui-angular/query-builder/src/query-builder/query-builder.common.ts b/projects/igniteui-angular/query-builder/src/query-builder/query-builder.common.ts new file mode 100644 index 00000000000..eed4b27a7f0 --- /dev/null +++ b/projects/igniteui-angular/query-builder/src/query-builder/query-builder.common.ts @@ -0,0 +1,84 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { FilteringLogic, IFilteringExpression } from 'igniteui-angular/core'; + +@Pipe({ + name: 'fieldFormatter', + standalone: true +}) +export class IgxFieldFormatterPipe implements PipeTransform { + + public transform(value: any, formatter: (v: any, data: any, fieldData?: any) => any, rowData: any, fieldData?: any) { + return formatter(value, rowData, fieldData); + } +} + +/** + * @hidden @internal + */ +export class ExpressionItem { + public parent: ExpressionGroupItem; + public expanded: boolean; + constructor(parent?: ExpressionGroupItem) { + this.parent = parent; + } +} + +/** + * @hidden @internal + */ +export class ExpressionGroupItem extends ExpressionItem { + public operator: FilteringLogic; + public children: ExpressionItem[]; + constructor(operator: FilteringLogic, parent?: ExpressionGroupItem) { + super(parent); + this.operator = operator; + this.children = []; + } +} + +/** + * @hidden @internal + */ +export class ExpressionOperandItem extends ExpressionItem { + public expression: IFilteringExpression; + public inEditMode: boolean; + public inAddMode: boolean; + public hovered: boolean; + public focused: boolean; + public fieldLabel: string; + constructor(expression: IFilteringExpression, parent: ExpressionGroupItem) { + super(parent); + this.expression = expression; + } +} + +const IGX_QUERY_BUILDER = 'igx-query-builder'; +const IGX_FILTER_TREE = 'igx-filter-tree'; + +/** + * @hidden @internal + */ +export const QueryBuilderSelectors = { + DRAG_INDICATOR: 'igx-drag-indicator', + CHIP_GHOST: 'igx-chip__ghost', + + DROP_DOWN_LIST_SCROLL: 'igx-drop-down__list-scroll', + DROP_DOWN_ITEM_DISABLED: 'igx-drop-down__item--disabled', + + FILTER_TREE: IGX_FILTER_TREE, + FILTER_TREE_EXPRESSION_CONTEXT_MENU: IGX_FILTER_TREE + '__expression-context-menu', + FILTER_TREE_EXPRESSION_ITEM: IGX_FILTER_TREE + '__expression-item', + FILTER_TREE_EXPRESSION_ITEM_DROP_GHOST: IGX_FILTER_TREE + '__expression-item-drop-ghost', + FILTER_TREE_EXPRESSION_ITEM_KEYBOARD_GHOST: IGX_FILTER_TREE + '__expression-item-keyboard-ghost', + FILTER_TREE_EXPRESSION_ITEM_GHOST: IGX_FILTER_TREE + '__expression-item-ghost', + FILTER_TREE_EXPRESSION_SECTION: IGX_FILTER_TREE + '__expression-section', + + FILTER_TREE_LINE_AND: IGX_FILTER_TREE + '__line--and', + FILTER_TREE_LINE_OR: IGX_FILTER_TREE + '__line--or', + FILTER_TREE_SUBQUERY: IGX_FILTER_TREE + '__subquery', + + QUERY_BUILDER: IGX_QUERY_BUILDER, + QUERY_BUILDER_BODY: IGX_QUERY_BUILDER + '__main', + QUERY_BUILDER_HEADER: IGX_QUERY_BUILDER + '__header', + QUERY_BUILDER_TREE: IGX_QUERY_BUILDER + '-tree', +} diff --git a/projects/igniteui-angular/query-builder/src/query-builder/query-builder.component.html b/projects/igniteui-angular/query-builder/src/query-builder/query-builder.component.html new file mode 100644 index 00000000000..20eeb62f206 --- /dev/null +++ b/projects/igniteui-angular/query-builder/src/query-builder/query-builder.component.html @@ -0,0 +1,11 @@ + + + + diff --git a/projects/igniteui-angular/query-builder/src/query-builder/query-builder.component.spec.ts b/projects/igniteui-angular/query-builder/src/query-builder/query-builder.component.spec.ts new file mode 100644 index 00000000000..ff9428c85c8 --- /dev/null +++ b/projects/igniteui-angular/query-builder/src/query-builder/query-builder.component.spec.ts @@ -0,0 +1,3339 @@ +import { waitForAsync, TestBed, ComponentFixture, fakeAsync, tick, flush } from '@angular/core/testing'; +import { FilteringExpressionsTree, FilteringLogic, IExpressionTree, IgxDateFilteringOperand, IgxNumberFilteringOperand } from 'igniteui-angular/core'; +import { IgxChipComponent } from 'igniteui-angular/chips'; +import { IgxComboComponent } from 'igniteui-angular/combo'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxInputGroupComponent } from 'igniteui-angular/input-group'; +import { IgxSelectComponent } from 'igniteui-angular/select';; +import { Component, OnInit, ViewChild } from '@angular/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { By } from '@angular/platform-browser'; +import { ControlsFunction } from '../../../test-utils/controls-functions.spec'; +import { QueryBuilderFunctions, SampleEntities } from './query-builder-functions.spec'; +import { UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { FormsModule } from '@angular/forms'; +import { NgTemplateOutlet } from '@angular/common'; +import { QueryBuilderSelectors } from './query-builder.common'; +import { IgxQueryBuilderComponent } from './query-builder.component'; +import { IgxQueryBuilderHeaderComponent } from './query-builder-header.component'; +import { IgxQueryBuilderSearchValueTemplateDirective } from './query-builder.directives'; + +describe('IgxQueryBuilder', () => { + let fix: ComponentFixture; + let queryBuilder: IgxQueryBuilderComponent; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxQueryBuilderComponent, + IgxQueryBuilderSampleTestComponent, + IgxQueryBuilderCustomTemplateSampleTestComponent, + IgxComboComponent + ] + }).compileComponents(); + })); + + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(IgxQueryBuilderSampleTestComponent); + fix.detectChanges(); + queryBuilder = fix.componentInstance.queryBuilder; + })); + + describe('Basic', () => { + it('Should render empty Query Builder properly.', fakeAsync(() => { + tick(100); + fix.detectChanges(); + const queryBuilderElement: HTMLElement = fix.debugElement.queryAll(By.css(`.${QueryBuilderSelectors.QUERY_BUILDER}`))[0].nativeElement; + expect(queryBuilderElement).toBeDefined(); + expect(queryBuilderElement.children.length).toEqual(1); + + const queryTreeElement = queryBuilderElement.children[0]; + expect(queryTreeElement).toHaveClass(QueryBuilderSelectors.QUERY_BUILDER_TREE); + + expect(queryBuilder.expressionTree).toBeUndefined(); + + expect(queryTreeElement.children.length).toEqual(3); + const bodyElement = queryTreeElement.children[0]; + expect(bodyElement).toHaveClass(QueryBuilderSelectors.QUERY_BUILDER_BODY); + expect(bodyElement.children.length).toEqual(1); + + QueryBuilderFunctions.verifyEditModeQueryExpressionInputStates(fix, true, false); + QueryBuilderFunctions.verifyQueryEditModeExpressionInputValues(fix, '', ''); + + // Select 'Products' entity + QueryBuilderFunctions.selectEntityInEditModeExpression(fix, 0); + tick(100); + fix.detectChanges(); + + QueryBuilderFunctions.verifyEditModeQueryExpressionInputStates(fix, true, true); + QueryBuilderFunctions.verifyQueryEditModeExpressionInputValues(fix, 'Products', 'Id, ProductName, OrderId, Released'); + })); + + it('Should render Query Builder with initially set expression tree properly.', fakeAsync(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + fix.detectChanges(); + + const queryTreeElement: HTMLElement = fix.debugElement.queryAll(By.css(QueryBuilderSelectors.QUERY_BUILDER_TREE))[0].nativeElement; + const bodyElement = queryTreeElement.children[0]; + expect(bodyElement).toHaveClass(QueryBuilderSelectors.QUERY_BUILDER_BODY); + expect(bodyElement.children.length).toEqual(2); + + // Verify the operator line of the root group is an 'And' line. + QueryBuilderFunctions.verifyOperatorLine(QueryBuilderFunctions.getQueryBuilderTreeRootGroupOperatorLine(fix) as HTMLElement, 'and'); + // all inputs should be displayed correctly + const selectFromContainer = bodyElement.children[0]; + expect(selectFromContainer).toHaveClass('igx-filter-tree__inputs'); + expect(selectFromContainer.children[0].children[1].tagName).toEqual('IGX-SELECT'); + expect(selectFromContainer.children[1].children[1].tagName).toEqual('IGX-COMBO'); + const queryTreeExpressionContainer = bodyElement.children[1].children[1]; + expect(queryTreeExpressionContainer).toHaveClass('igx-filter-tree'); + expect(queryTreeExpressionContainer.children[1]).toHaveClass('igx-filter-tree__expressions'); + + const selectEntity = QueryBuilderFunctions.getQueryBuilderEntitySelect(fix, 0); + expect(selectEntity.children[0].classList.contains('igx-input-group--disabled')).toBeFalse(); + + const fieldsCombo = QueryBuilderFunctions.getQueryBuilderFieldsCombo(fix, 0); + expect(fieldsCombo.children[0].classList.contains('igx-input-group--disabled')).toBeFalse(); + + const expressionItems = queryTreeExpressionContainer.children[1].children[1].querySelectorAll(':scope > .igx-filter-tree__expression-item'); + expect(expressionItems.length).toEqual(queryBuilder.expressionTree.filteringOperands.length); + // entity select should have proper value + expect(queryBuilder.queryTree.selectedEntity.name).toEqual(queryBuilder.expressionTree.entity); + // fields input should have proper value + expect(queryBuilder.queryTree.selectedReturnFields.length).toEqual(4); + // nested queries should be collapsed + const nestedQueryTrees = queryTreeExpressionContainer.querySelectorAll(QueryBuilderSelectors.QUERY_BUILDER_TREE); + for (let i = 0; i < nestedQueryTrees.length; i++) { + expect(nestedQueryTrees[i].checkVisibility()).toBeFalse(); + } + // adding buttons should be enabled + const buttons = QueryBuilderFunctions.getQueryBuilderTreeRootGroupButtons(fix, 0); + for (const button of buttons) { + ControlsFunction.verifyButtonIsDisabled(button as HTMLElement, false); + } + })); + + it('Should render combo for main entity return fields and select for nested entity return field.', fakeAsync(() => { + QueryBuilderFunctions.selectEntityAndClickInitialAddCondition(fix, 1); + + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 0); // Select 'OrderId' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 10); // Select 'In' operator. + + const mainEntityContainer = QueryBuilderFunctions.getQueryBuilderEditModeContainer(fix, true, 0); + const nestedEntityContainer = QueryBuilderFunctions.getQueryBuilderEditModeContainer(fix, true, 1); + + expect(mainEntityContainer.children[1].children[1].tagName).toBe('IGX-COMBO'); + expect(nestedEntityContainer.children[1].children[1].tagName).toBe('IGX-SELECT'); + })); + + it('Should return proper fields collection without additional props.', fakeAsync(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + fix.detectChanges(); + + queryBuilder.entities[0].fields.forEach(field => { + expect(field.filters).toBeUndefined(); + expect(field.pipeArgs).toBeUndefined(); + }); + })); + + it('Should not throw error when entities are empty and expressionTree is set.', fakeAsync(() => { + expect(() => { + fix = TestBed.createComponent(IgxQueryBuilderInvalidSampleTestComponent); + fix.detectChanges(); + }).not.toThrow(); + })); + }); + + describe('Interactions', () => { + it('Should correctly initialize a newly added \'And\' group.', fakeAsync(() => { + QueryBuilderFunctions.selectEntityInEditModeExpression(fix, 1); // Select 'Orders' entity + tick(100); + fix.detectChanges(); + + // Click the initial 'Add Condition' button. + QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fix, 0); + tick(100); + fix.detectChanges(); + + // Verify there is a new root group, which is empty. + QueryBuilderFunctions.verifyRootAndSubGroupExpressionsCount(fix, 0, 0); + + // Verify the operator line of the root group is an 'And' line. + QueryBuilderFunctions.verifyOperatorLine(QueryBuilderFunctions.getQueryBuilderTreeRootGroupOperatorLine(fix) as HTMLElement, 'and'); + + // Verify the enabled/disabled state of each input of the expression in edit mode. + QueryBuilderFunctions.verifyEditModeQueryExpressionInputStates(fix, true, true, true, false, false, false); + + // Verify the edit inputs are empty. + QueryBuilderFunctions.verifyQueryEditModeExpressionInputValues(fix, 'Orders', 'OrderId, OrderName, OrderDate, Delivered', '', '', ''); + + // Verify adding buttons are not displayed + expect(QueryBuilderFunctions.getQueryBuilderTreeRootGroupButtonsContainer(fix, 0)).toBe(undefined); + })); + + it(`Should discard newly added group when clicking on the 'cancel' button of its initial condition.`, fakeAsync(() => { + spyOn(queryBuilder.expressionTreeChange, 'emit').and.callThrough(); + expect(queryBuilder.expressionTreeChange.emit).toHaveBeenCalledTimes(0); + + QueryBuilderFunctions.selectEntityAndClickInitialAddCondition(fix, 1); + + expect(queryBuilder.expressionTreeChange.emit).toHaveBeenCalledTimes(1); + + // Verify there is a new root group, which is empty. + const group = QueryBuilderFunctions.getQueryBuilderTreeRootGroup(fix); + expect(group).not.toBeNull('There is no root group.'); + + // Click on the 'cancel' button + const closeButton = QueryBuilderFunctions.getQueryBuilderExpressionCloseButton(fix); + UIInteractions.simulateClickEvent(closeButton); + tick(100); + fix.detectChanges(); + + // Verify there is a new root group, which is empty. + QueryBuilderFunctions.verifyRootAndSubGroupExpressionsCount(fix, 0, 0); + })); + + it('Should add a new condition to existing group by using add buttons.', fakeAsync(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + fix.detectChanges(); + + spyOn(queryBuilder.expressionTreeChange, 'emit').and.callThrough(); + + // Verify group's children count before adding a new child. + let group = QueryBuilderFunctions.getQueryBuilderTreeRootGroup(fix) as HTMLElement; + QueryBuilderFunctions.verifyRootAndSubGroupExpressionsCount(fix, 3, 6); + + + // Add new 'expression'. + const buttonsContainer = Array.from(group.querySelectorAll('.igx-filter-tree__buttons'))[1]; + const buttons = Array.from(buttonsContainer.querySelectorAll('button')); + (buttons[0] as HTMLElement).click(); + tick(); + fix.detectChanges(); + + // Newly added condition should be empty + QueryBuilderFunctions.verifyEditModeExpressionInputStates(fix, true, false, false, false); + QueryBuilderFunctions.verifyEditModeExpressionInputValues(fix, '', '', ''); + + // Populate edit inputs. + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 1); // Select 'OrderName' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 0); // Select 'Contains' operator. + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(input, 'a'); + tick(100); + fix.detectChanges(); + // Commit the populated expression. + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix); + fix.detectChanges(); + + group = QueryBuilderFunctions.getQueryBuilderTreeRootGroup(fix) as HTMLElement; + QueryBuilderFunctions.verifyRootAndSubGroupExpressionsCount(fix, 4, 7); + expect(queryBuilder.expressionTreeChange.emit).toHaveBeenCalled(); + })); + + it(`Should add a new 'Or' group to existing group by using add buttons.`, fakeAsync(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + fix.detectChanges(); + + spyOn(queryBuilder.expressionTreeChange, 'emit').and.callThrough(); + + // Verify group's children count before adding a new child. + let group = QueryBuilderFunctions.getQueryBuilderTreeRootGroup(fix) as HTMLElement; + QueryBuilderFunctions.verifyRootAndSubGroupExpressionsCount(fix, 3, 6); + + // Add new 'Or' group. + const buttonsContainer = Array.from(group.querySelectorAll('.igx-filter-tree__buttons'))[1]; + const buttons = Array.from(buttonsContainer.querySelectorAll('button')); + (buttons[1] as HTMLElement).click(); + tick(); + fix.detectChanges(); + + // Newly added condition should be empty + QueryBuilderFunctions.verifyEditModeExpressionInputStates(fix, true, false, false, false); + QueryBuilderFunctions.verifyEditModeExpressionInputValues(fix, '', '', ''); + + // Verify adding buttons are not displayed + expect(QueryBuilderFunctions.getQueryBuilderTreeRootGroupButtonsContainer(fix, 0)).toBe(undefined); + + // Populate edit inputs. + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 1); // Select 'OrderName' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 0); // Select 'Contains' operator. + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(input, 'a'); + tick(100); + fix.detectChanges(); + // Commit the populated expression. + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix); + fix.detectChanges(); + + // Verify the operator line of the new group is an 'And' line. + QueryBuilderFunctions.verifyOperatorLine(QueryBuilderFunctions.getQueryBuilderTreeGroupOperatorLine(fix, [0]) as HTMLElement, 'or'); + + // adding buttons should be enabled + const addingButtons = QueryBuilderFunctions.getQueryBuilderTreeRootGroupButtons(fix, 0); + expect(addingButtons.length).toBe(2); + for (const button of addingButtons) { + ControlsFunction.verifyButtonIsDisabled(button as HTMLElement, false); + } + + group = QueryBuilderFunctions.getQueryBuilderTreeRootGroup(fix) as HTMLElement; + QueryBuilderFunctions.verifyRootAndSubGroupExpressionsCount(fix, 4, 8); + expect(queryBuilder.expressionTreeChange.emit).toHaveBeenCalled(); + })); + + it(`Should add a new 'And' group to existing group by using add buttons.`, fakeAsync(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + fix.detectChanges(); + + spyOn(queryBuilder.expressionTreeChange, 'emit').and.callThrough(); + + // Verify group's children count before adding a new child. + let group = QueryBuilderFunctions.getQueryBuilderTreeRootGroup(fix) as HTMLElement; + QueryBuilderFunctions.verifyRootAndSubGroupExpressionsCount(fix, 3, 6); + + // Change root group to 'Or' group + QueryBuilderFunctions.clickQueryBuilderGroupContextMenu(fix, 0); + tick(100); + fix.detectChanges(); + + QueryBuilderFunctions.clickContextMenuItem(fix, 0); + tick(100); + fix.detectChanges(); + + // Add new 'And' group. + const buttonsContainer = Array.from(group.querySelectorAll('.igx-filter-tree__buttons'))[1]; + const buttons = Array.from(buttonsContainer.querySelectorAll('button')); + (buttons[1] as HTMLElement).click(); + tick(); + fix.detectChanges(); + + // Newly added condition should be empty + QueryBuilderFunctions.verifyEditModeExpressionInputStates(fix, true, false, false, false); + QueryBuilderFunctions.verifyEditModeExpressionInputValues(fix, '', '', ''); + + // Populate edit inputs. + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 1); // Select 'OrderName' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 0); // Select 'Contains' operator. + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(input, 'a'); + tick(100); + fix.detectChanges(); + // Commit the populated expression. + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix); + fix.detectChanges(); + + // Verify the operator line of the new group is an 'And' line. + QueryBuilderFunctions.verifyOperatorLine(QueryBuilderFunctions.getQueryBuilderTreeGroupOperatorLine(fix, [0]) as HTMLElement, 'and'); + + group = QueryBuilderFunctions.getQueryBuilderTreeRootGroup(fix) as HTMLElement; + QueryBuilderFunctions.verifyRootAndSubGroupExpressionsCount(fix, 4, 8); + expect(queryBuilder.expressionTreeChange.emit).toHaveBeenCalled(); + })); + + it(`Should remove a condition from an existing group by using the 'close' icon of the respective chip.`, fakeAsync(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + fix.detectChanges(); + + spyOn(queryBuilder.expressionTreeChange, 'emit').and.callThrough(); + + // Verify tree layout before deleting chips. + QueryBuilderFunctions.verifyRootAndSubGroupExpressionsCount(fix, 3, 6); + + // Delete a chip and verify layout. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChipRemoveIcon(fix, [0]); + tick(100); + fix.detectChanges(); + + QueryBuilderFunctions.verifyRootAndSubGroupExpressionsCount(fix, 2, 2); + expect(queryBuilder.expressionTreeChange.emit).toHaveBeenCalled(); + + // Delete a chip and verify layout. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChipRemoveIcon(fix, [1]); + tick(100); + flush(); + fix.detectChanges(); + QueryBuilderFunctions.verifyRootAndSubGroupExpressionsCount(fix, 1, 1); + + // Verify remaining chip's content. + QueryBuilderFunctions.verifyExpressionChipContent(fix, [0], 'OrderId', 'Greater Than', '3'); + + // Delete the last chip and verify that the group is deleted as well. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChipRemoveIcon(fix, [0]); + tick(100); + flush(); + fix.detectChanges(); + + QueryBuilderFunctions.verifyRootAndSubGroupExpressionsCount(fix, 0, 0); + })); + + it('Should be able to add and define a new group through initial adding button.', fakeAsync(() => { + expect(fix.componentInstance.queryBuilder.expressionTree).toBeUndefined(); + // Select an entity + // TO DO: refactor the methods when entity and fields drop-downs are in the correct overlay + QueryBuilderFunctions.selectEntityAndClickInitialAddCondition(fix, 0); + + // Verify the enabled/disabled state of each input of the expression in edit mode. + expect(fix.componentInstance.queryBuilder.expressionTree).toBeDefined(); + QueryBuilderFunctions.verifyEditModeQueryExpressionInputStates(fix, true, true, false, false, false, false); + + // Select fields + QueryBuilderFunctions.selectFieldsInEditModeExpression(fix, [2, 3]) + tick(100); + fix.detectChanges(); + QueryBuilderFunctions.verifyEditModeQueryExpressionInputStates(fix, true, true); + + // Click the initial 'Add Condition' button. + QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fix); + tick(100); + fix.detectChanges(); + + //Select Column + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 1); + QueryBuilderFunctions.verifyEditModeQueryExpressionInputStates(fix, true, true, true, true, true, false); + + //Select Operator + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 0); + QueryBuilderFunctions.verifyEditModeQueryExpressionInputStates(fix, true, true, true, true, true, false); + + //Type Value + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(input, 'a'); + tick(100); + fix.detectChanges(); + QueryBuilderFunctions.verifyEditModeQueryExpressionInputStates(fix, true, true, true, true, true, true); + + // Verify all inputs values + QueryBuilderFunctions.verifyQueryEditModeExpressionInputValues(fix, 'Products', 'Id, ProductName, Released', 'ProductName', 'Contains', 'a'); + + //Commit the group + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix); + tick(100); + fix.detectChanges(); + + //Verify that expressionTree is correct + const exprTree = JSON.stringify(fix.componentInstance.queryBuilder.expressionTree, null, 2); + expect(exprTree).toBe(`{ + "filteringOperands": [ + { + "fieldName": "ProductName", + "condition": { + "name": "contains", + "isUnary": false, + "iconName": "filter_contains" + }, + "conditionName": "contains", + "ignoreCase": true, + "searchVal": "a", + "searchTree": null + } + ], + "operator": 0, + "entity": "Products", + "returnFields": [ + "Id", + "ProductName", + "Released" + ] +}`); + })); + + it('Value input should be disabled for unary operator.', fakeAsync(() => { + QueryBuilderFunctions.selectEntityAndClickInitialAddCondition(fix, 0); + + //Select Column + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 3); + QueryBuilderFunctions.verifyEditModeQueryExpressionInputStates(fix, true, true, true, true, false, true); + + //Select Operator + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 0); + QueryBuilderFunctions.verifyEditModeQueryExpressionInputStates(fix, true, true, true, true, false, true); + })); + + it('Fields dropdown should contain proper fields based on the entity.', fakeAsync(() => { + QueryBuilderFunctions.selectEntityAndClickInitialAddCondition(fix, 0); + + // Open fields dropdown and verify the items. + QueryBuilderFunctions.clickQueryBuilderFieldsCombo(fix); + fix.detectChanges(); + + // TO DO: refactor when overlay issue is fixed + const outlet = fix.debugElement.queryAll(By.css(`.igx-drop-down__list-scroll`))[1].nativeElement; + const dropdownItems = Array.from(outlet.querySelectorAll('.igx-drop-down__item'));; + expect(dropdownItems.length).toBe(5); + expect((dropdownItems[0] as HTMLElement).innerText).toBe('Select All'); + expect((dropdownItems[1] as HTMLElement).innerText).toBe('Id'); + expect((dropdownItems[2] as HTMLElement).innerText).toBe('ProductName'); + expect((dropdownItems[3] as HTMLElement).innerText).toBe('OrderId'); + expect((dropdownItems[4] as HTMLElement).innerText).toBe('Released'); + })); + + it('ReturnFields should be properly calculated on entity change.', fakeAsync(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + queryBuilder.showEntityChangeDialog = false; + fix.detectChanges(); + + // Verify the returnFields + let exprTreeReturnFields = JSON.stringify(fix.componentInstance.queryBuilder.expressionTree.returnFields); + expect(exprTreeReturnFields).toBe(`["*"]`); + + // Change the selected return fields + QueryBuilderFunctions.selectFieldsInEditModeExpression(fix, [1]); + tick(100); + fix.detectChanges(); + + // Verify the returnFields + exprTreeReturnFields = JSON.stringify(fix.componentInstance.queryBuilder.expressionTree.returnFields); + expect(exprTreeReturnFields).toBe(`["OrderId"]`); + + // Change the entity + QueryBuilderFunctions.selectEntityAndClickInitialAddCondition(fix, 0); + + // Verify the returnFields + exprTreeReturnFields = JSON.stringify(fix.componentInstance.queryBuilder.expressionTree.returnFields); + expect(exprTreeReturnFields).toBe(`["*"]`); + })); + + it('ReturnFields should be properly calculated on selectAll click.', fakeAsync(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + queryBuilder.showEntityChangeDialog = false; + fix.detectChanges(); + + // Click selectAll button in order to deselect all fields + QueryBuilderFunctions.selectFieldsInEditModeExpression(fix, [0]); + tick(100); + fix.detectChanges(); + + // Verify the returnFields + let exprTreeReturnFields = JSON.stringify(fix.componentInstance.queryBuilder.expressionTree.returnFields); + expect(exprTreeReturnFields).toBe(`[]`); + + // Click selectAll button in order to select all fields + QueryBuilderFunctions.selectFieldsInEditModeExpression(fix, [0]); + tick(100); + fix.detectChanges(); + + // Verify the returnFields + exprTreeReturnFields = JSON.stringify(fix.componentInstance.queryBuilder.expressionTree.returnFields); + expect(exprTreeReturnFields).toBe(`["*"]`); + })); + + it('Column dropdown should contain proper fields based on the entity.', fakeAsync(() => { + const queryBuilderElement: HTMLElement = fix.debugElement.queryAll(By.css(`.${QueryBuilderSelectors.QUERY_BUILDER_TREE}`))[0].nativeElement; + + QueryBuilderFunctions.selectEntityAndClickInitialAddCondition(fix, 1); + + // Open columns dropdown and verify the items. + QueryBuilderFunctions.clickQueryBuilderColumnSelect(fix); + fix.detectChanges(); + const dropdownItems = QueryBuilderFunctions.getQueryBuilderSelectDropdownItems(queryBuilderElement); + expect(dropdownItems.length).toBe(4); + expect((dropdownItems[0] as HTMLElement).innerText).toBe('OrderId'); + expect((dropdownItems[1] as HTMLElement).innerText).toBe('OrderName'); + expect((dropdownItems[2] as HTMLElement).innerText).toBe('OrderDate'); + })); + + it('Operator dropdown should contain operators based on the column\'s datatype (\'string\' or \'number\' or \'date\').', fakeAsync(() => { + const queryBuilderElement: HTMLElement = fix.debugElement.queryAll(By.css(`.${QueryBuilderSelectors.QUERY_BUILDER_TREE}`))[0].nativeElement; + + QueryBuilderFunctions.selectEntityAndClickInitialAddCondition(fix, 1); + + // Select 'string' type column ('OrderName'). + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 1); + // Open the operator dropdown and verify they are 'string' specific. + QueryBuilderFunctions.clickQueryBuilderOperatorSelect(fix); + fix.detectChanges(); + let dropdownValues: string[] = QueryBuilderFunctions.getQueryBuilderSelectDropdownItems(queryBuilderElement).map((x: any) => x.innerText); + let expectedValues = ['Contains', 'Does Not Contain', 'Starts With', 'Ends With', 'Equals', + 'Does Not Equal', 'Empty', 'Not Empty', 'Null', 'Not Null', 'In', 'Not In']; + expect(dropdownValues).toEqual(expectedValues); + + // Close current dropdown by a random select. + QueryBuilderFunctions.clickQueryBuilderSelectDropdownItem(queryBuilderElement, 0); + tick(); + fix.detectChanges(); + + // Select 'number' type column ('OrderId'). + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 0); + // Open the operator dropdown and verify they are 'number' specific. + QueryBuilderFunctions.clickQueryBuilderOperatorSelect(fix); + fix.detectChanges(); + dropdownValues = QueryBuilderFunctions.getQueryBuilderSelectDropdownItems(queryBuilderElement).map((x: any) => x.innerText); + expectedValues = ['Equals', 'Does Not Equal', 'Greater Than', 'Less Than', 'Greater Than Or Equal To', + 'Less Than Or Equal To', 'Empty', 'Not Empty', 'Null', 'Not Null', 'In', 'Not In']; + expect(dropdownValues).toEqual(expectedValues); + + // Close current dropdown by a random select. + QueryBuilderFunctions.clickQueryBuilderSelectDropdownItem(queryBuilderElement, 0); + tick(); + fix.detectChanges(); + + // Select 'date' type column ('OrderDate'). + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 2); + // Open the operator dropdown and verify they are 'date' specific. + QueryBuilderFunctions.clickQueryBuilderOperatorSelect(fix); + fix.detectChanges(); + dropdownValues = QueryBuilderFunctions.getQueryBuilderSelectDropdownItems(queryBuilderElement).map((x: any) => x.innerText); + expectedValues = ['Equals', 'Does Not Equal', 'Before', 'After', 'Today', 'Yesterday', + 'This Month', 'Last Month', 'Next Month', 'This Year', 'Last Year', + 'Next Year', 'Empty', 'Not Empty', 'Null', 'Not Null', 'In', 'Not In']; + expect(dropdownValues).toEqual(expectedValues); + })); + + it('Operator dropdown should contain operators based on the column\'s datatype (\'boolean\').', fakeAsync(() => { + const queryBuilderElement: HTMLElement = fix.debugElement.queryAll(By.css(`.${QueryBuilderSelectors.QUERY_BUILDER_TREE}`))[0].nativeElement; + + QueryBuilderFunctions.selectEntityAndClickInitialAddCondition(fix, 0); + + // Select 'boolean' type column ('Released'). + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 3); + // Open the operator dropdown and verify they are 'boolean' specific. + QueryBuilderFunctions.clickQueryBuilderOperatorSelect(fix); + fix.detectChanges(); + const dropdownValues: string[] = QueryBuilderFunctions.getQueryBuilderSelectDropdownItems(queryBuilderElement).map((x: any) => x.innerText); + const expectedValues = ['All', 'True', 'False', 'Empty', 'Not Empty', 'Null', 'Not Null', 'In', 'Not In']; + expect(dropdownValues).toEqual(expectedValues); + })); + + it('Should correctly apply a \'string\' column condition through UI.', fakeAsync(() => { + // Verify there is no expression. + expect(queryBuilder.expressionTree).toBeUndefined(); + + QueryBuilderFunctions.selectEntityAndClickInitialAddCondition(fix, 0); + + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 1); // Select 'ProductName' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 2); // Select 'Starts With' operator. + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + // Verify value input placeholder + expect(input.placeholder).toEqual('Value'); + // Type Value + UIInteractions.clickAndSendInputElementValue(input, 'a'); + tick(100); + fix.detectChanges(); + + // Commit the populated expression. + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix); + fix.detectChanges(); + + //Verify that expressionTree is correct + const exprTree = JSON.stringify(fix.componentInstance.queryBuilder.expressionTree, null, 2); + expect(exprTree).toBe(`{ + "filteringOperands": [ + { + "fieldName": "ProductName", + "condition": { + "name": "startsWith", + "isUnary": false, + "iconName": "filter_starts_with" + }, + "conditionName": "startsWith", + "ignoreCase": true, + "searchVal": "a", + "searchTree": null + } + ], + "operator": 0, + "entity": "Products", + "returnFields": [ + "Id", + "ProductName", + "OrderId", + "Released" + ] +}`); + })); + + it('Should correctly apply a \'Greater Than\' with \'number\' column condition through UI.', fakeAsync(() => { + QueryBuilderFunctions.selectEntityAndClickInitialAddCondition(fix, 0); + + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 0); // Select 'Id' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 2); // Select 'Greater Than' operator + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + // Verify value input placeholder + expect(input.placeholder).toEqual('Value'); + // Type Value + UIInteractions.clickAndSendInputElementValue(input, '5'); + tick(100); + fix.detectChanges(); + + // Commit the populated expression. + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix); + fix.detectChanges(); + + //Verify that expressionTree is correct + const exprTree = JSON.stringify(fix.componentInstance.queryBuilder.expressionTree, null, 2); + expect(exprTree).toBe(`{ + "filteringOperands": [ + { + "fieldName": "Id", + "condition": { + "name": "greaterThan", + "isUnary": false, + "iconName": "filter_greater_than" + }, + "conditionName": "greaterThan", + "ignoreCase": true, + "searchVal": 5, + "searchTree": null + } + ], + "operator": 0, + "entity": "Products", + "returnFields": [ + "Id", + "ProductName", + "OrderId", + "Released" + ] +}`); + })); + + it('Should correctly apply a \'Equals\' with \'number\' column condition through UI.', fakeAsync(() => { + QueryBuilderFunctions.selectEntityAndClickInitialAddCondition(fix, 0); + + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 0); // Select 'Id' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 0); // Select 'Equals' operator + //Type Value + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(input, '5'); + tick(100); + fix.detectChanges(); + + // Commit the populated expression. + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix); + fix.detectChanges(); + + //Verify that expressionTree is correct + const exprTree = JSON.stringify(fix.componentInstance.queryBuilder.expressionTree, null, 2); + expect(exprTree).toBe(`{ + "filteringOperands": [ + { + "fieldName": "Id", + "condition": { + "name": "equals", + "isUnary": false, + "iconName": "filter_equal" + }, + "conditionName": "equals", + "ignoreCase": true, + "searchVal": 5, + "searchTree": null + } + ], + "operator": 0, + "entity": "Products", + "returnFields": [ + "Id", + "ProductName", + "OrderId", + "Released" + ] +}`); + })); + + it('Should correctly apply a \'boolean\' column condition through UI.', fakeAsync(() => { + QueryBuilderFunctions.selectEntityAndClickInitialAddCondition(fix, 0); + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 3); // Select 'Released' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 1); // Select 'True' operator. + + // Verify value input placeholder + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + // Verify value input placeholder + expect(input.placeholder).toEqual('Value'); + + // Commit the populated expression. + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix); + fix.detectChanges(); + + //Verify that expressionTree is correct + const exprTree = JSON.stringify(fix.componentInstance.queryBuilder.expressionTree, null, 2); + expect(exprTree).toBe(`{ + "filteringOperands": [ + { + "fieldName": "Released", + "condition": { + "name": "true", + "isUnary": true, + "iconName": "filter_true" + }, + "conditionName": "true", + "ignoreCase": true, + "searchVal": null, + "searchTree": null + } + ], + "operator": 0, + "entity": "Products", + "returnFields": [ + "Id", + "ProductName", + "OrderId", + "Released" + ] +}`); + })); + + it('Should correctly apply a \'date\' column condition through UI with unary operator.', fakeAsync(() => { + QueryBuilderFunctions.selectEntityAndClickInitialAddCondition(fix, 1); + + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 2); // Select 'OrderDate' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 9); // Select 'This Year' operator. + + // Verify value input placeholder + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + // Verify value input placeholder + 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. + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix); + fix.detectChanges(); + + //Verify that expressionTree is correct + const exprTree = JSON.stringify(fix.componentInstance.queryBuilder.expressionTree, null, 2); + expect(exprTree).toBe(`{ + "filteringOperands": [ + { + "fieldName": "OrderDate", + "condition": { + "name": "thisYear", + "isUnary": true, + "iconName": "filter_this_year" + }, + "conditionName": "thisYear", + "ignoreCase": true, + "searchVal": null, + "searchTree": null + } + ], + "operator": 0, + "entity": "Orders", + "returnFields": [ + "OrderId", + "OrderName", + "OrderDate", + "Delivered" + ] +}`); + })); + + it('Should correctly apply a \'date\' column condition through UI with value from calendar.', fakeAsync(() => { + QueryBuilderFunctions.selectEntityAndClickInitialAddCondition(fix, 1); + + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 2); // Select 'OrderDate' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 0); // Select 'Equals' operator. + QueryBuilderFunctions.verifyEditModeExpressionInputStates(fix, true, true, true, false); + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix, true) as HTMLElement; + input.click(); + fix.detectChanges(); + + // Click on 'today' item in calendar. + const calendar = QueryBuilderFunctions.getQueryBuilderCalendar(fix); + const todayItem = calendar.querySelector('.igx-days-view__date--current'); + todayItem.firstChild.click(); + tick(100); + fix.detectChanges(); + + QueryBuilderFunctions.verifyEditModeExpressionInputStates(fix, true, true, true, true); + + flush(); + })); + + it('Should correctly apply an \'in\' column condition through UI.', fakeAsync(() => { + QueryBuilderFunctions.selectEntityAndClickInitialAddCondition(fix, 1); + + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 0); // Select 'OrderId' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 10); // Select 'In' operator. + + // Verify operator icon + const operatorSelectDebugElement = fix.debugElement.queryAll(By.directive(IgxSelectComponent))[2]; + const inputDebugElement = operatorSelectDebugElement.query(By.directive(IgxInputGroupComponent)); + const iconDebugElem = inputDebugElement.query(By.directive(IgxIconComponent)); + expect(iconDebugElem.componentInstance.name).toEqual('in'); + + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + // Verify value input placeholder + expect(input.placeholder).toEqual('Sub-query results'); + + // Verify inputs states + QueryBuilderFunctions.verifyEditModeExpressionInputStates(fix, true, true, false, false); + + // Should render empty query builder tree + const queryTreeElement = fix.debugElement.queryAll(By.css(QueryBuilderSelectors.QUERY_BUILDER_TREE))[0] + const nestedTree = queryTreeElement.query(By.css(QueryBuilderSelectors.QUERY_BUILDER_TREE)); + expect(nestedTree).toBeDefined(); + + QueryBuilderFunctions.addAndValidateChildGroup(fix, 1); + + QueryBuilderFunctions.verifyEditModeExpressionInputStates(fix, true, true, false, true); // Parent commit button should be enabled + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix); + fix.detectChanges(); + + //Verify that expressionTree is correct + const exprTree = JSON.stringify(fix.componentInstance.queryBuilder.expressionTree, null, 2); + expect(exprTree).toBe(`{ + "filteringOperands": [ + { + "fieldName": "OrderId", + "condition": { + "name": "inQuery", + "isUnary": false, + "isNestedQuery": true, + "iconName": "in" + }, + "conditionName": "inQuery", + "ignoreCase": true, + "searchVal": null, + "searchTree": { + "filteringOperands": [ + { + "fieldName": "ProductName", + "condition": { + "name": "contains", + "isUnary": false, + "iconName": "filter_contains" + }, + "conditionName": "contains", + "ignoreCase": true, + "searchVal": "a", + "searchTree": null + } + ], + "operator": 0, + "entity": "Products", + "returnFields": [ + "Id" + ] + } + } + ], + "operator": 0, + "entity": "Orders", + "returnFields": [ + "OrderId", + "OrderName", + "OrderDate", + "Delivered" + ] +}`); + })); + + it('Should correctly apply a \'not-in\' column condition through UI.', fakeAsync(() => { + QueryBuilderFunctions.selectEntityAndClickInitialAddCondition(fix, 1); + + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 0); // Select 'OrderId' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 11); // Select 'Not-In' operator. + + // Verify operator icon + const operatorSelectDebugElement = fix.debugElement.queryAll(By.directive(IgxSelectComponent))[2]; + const inputDebugElement = operatorSelectDebugElement.query(By.directive(IgxInputGroupComponent)); + const iconDebugElem = inputDebugElement.query(By.directive(IgxIconComponent)); + expect(iconDebugElem.componentInstance.name).toEqual('not-in'); + + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + // Verify value input placeholder + expect(input.placeholder).toEqual('Sub-query results'); + + // Verify inputs states + QueryBuilderFunctions.verifyEditModeExpressionInputStates(fix, true, true, false, false); + + // Should render empty query builder tree + const queryTreeElement = fix.debugElement.queryAll(By.css(QueryBuilderSelectors.QUERY_BUILDER_TREE))[0] + const nestedTree = queryTreeElement.query(By.css(QueryBuilderSelectors.QUERY_BUILDER_TREE)); + expect(nestedTree).toBeDefined(); + + QueryBuilderFunctions.addAndValidateChildGroup(fix, 1); + + QueryBuilderFunctions.verifyEditModeExpressionInputStates(fix, true, true, false, true); // Parent commit button should be enabled + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix); + fix.detectChanges(); + + //Verify that expressionTree is correct + const exprTree = JSON.stringify(fix.componentInstance.queryBuilder.expressionTree, null, 2); + expect(exprTree).toBe(`{ + "filteringOperands": [ + { + "fieldName": "OrderId", + "condition": { + "name": "notInQuery", + "isUnary": false, + "isNestedQuery": true, + "iconName": "not-in" + }, + "conditionName": "notInQuery", + "ignoreCase": true, + "searchVal": null, + "searchTree": { + "filteringOperands": [ + { + "fieldName": "ProductName", + "condition": { + "name": "contains", + "isUnary": false, + "iconName": "filter_contains" + }, + "conditionName": "contains", + "ignoreCase": true, + "searchVal": "a", + "searchTree": null + } + ], + "operator": 0, + "entity": "Products", + "returnFields": [ + "Id" + ] + } + } + ], + "operator": 0, + "entity": "Orders", + "returnFields": [ + "OrderId", + "OrderName", + "OrderDate", + "Delivered" + ] +}`); + })); + + it('Should disable value fields when isNestedQuery condition is selected', fakeAsync(() => { + //Run test for all data type fields of the Order entity + for (let i = 0; i <= 3; i++) { + QueryBuilderFunctions.selectEntityAndClickInitialAddCondition(fix, 1); + + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, i); // Select 'OrderId','OrderName','OrderDate','Delivered' column. + + let InConditionIndex; + switch (i) { + case 0: + case 1: InConditionIndex = 10; break;// for string and number + case 2: InConditionIndex = 16; break; //for date + case 3: InConditionIndex = 7; break; // for boolean + } + + //Verify 'In' disables value input and renders empty sub query + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, InConditionIndex); // Select 'In' operator. + QueryBuilderFunctions.verifyEditModeExpressionInputStates(fix, true, true, false, false); + let nestedTree = fix.debugElement.query(By.css(QueryBuilderSelectors.QUERY_BUILDER_TREE)); + expect(nestedTree).toBeDefined(); + + //Verify 'NotIn' disables value input and renders empty sub query + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, InConditionIndex + 1); // Select 'NotIn' operator. + QueryBuilderFunctions.verifyEditModeExpressionInputStates(fix, true, true, false, false); + nestedTree = fix.debugElement.query(By.css(QueryBuilderSelectors.QUERY_BUILDER_TREE)); + expect(nestedTree).toBeDefined(); + + const closeBtn = QueryBuilderFunctions.getQueryBuilderExpressionCloseButton(fix); + closeBtn.click(); + fix.detectChanges(); + } + })); + + it('Should correctly focus the search value input when editing the filtering expression', fakeAsync(() => { + //Create dateTime filtering expression + const tree = new FilteringExpressionsTree(FilteringLogic.And, null, 'Orders', ['OrderId']); + tree.filteringOperands.push({ + fieldName: 'OrderDate', + searchVal: new Date('2024-09-17T21:00:00.000Z'), + conditionName: 'equals', + condition: IgxDateFilteringOperand.instance().condition('equals') + }); + + queryBuilder.expressionTree = tree; + fix.detectChanges(); + + // Click the edit icon to enter edit mode of the expression. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [0]); + tick(200); + fix.detectChanges(); + + //Check for the active element + const searchValueInput = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + expect(document.activeElement).toBe(searchValueInput, 'The input should be the active element.'); + })); + + it('Should display add button when hovering a chip.', fakeAsync(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + fix.detectChanges(); + + // Verify actions container is not visible. (This container contains the 'add' button.) + expect(QueryBuilderFunctions.getQueryBuilderTreeExpressionActionsContainer(fix, [0])) + .toBeNull('actions container is visible'); + + // Hover the first chip and verify actions container is visible. + UIInteractions.hoverElement(QueryBuilderFunctions.getQueryBuilderTreeItem(fix, [0]) as HTMLElement); + tick(50); + fix.detectChanges(); + expect(QueryBuilderFunctions.getQueryBuilderTreeExpressionActionsContainer(fix, [0])) + .not.toBeNull('actions container is not visible'); + + // Unhover the first chip and verify actions container is not visible. + UIInteractions.unhoverElement(QueryBuilderFunctions.getQueryBuilderTreeItem(fix, [0]) as HTMLElement); + tick(50); + fix.detectChanges(); + expect(QueryBuilderFunctions.getQueryBuilderTreeExpressionActionsContainer(fix, [0])) + .toBeNull('actions container is visible'); + })); + + it('Should have disabled adding buttons when an expression is in edit mode.', fakeAsync(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + fix.detectChanges(); + + // Verify adding buttons are enabled + let buttons = QueryBuilderFunctions.getQueryBuilderTreeRootGroupButtons(fix, 0); + for (const button of buttons) { + ControlsFunction.verifyButtonIsDisabled(button as HTMLElement, false); + } + + // Enter edit mode + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [1]); + tick(50); + fix.detectChanges(); + + // Verify adding buttons are not displayed + expect(QueryBuilderFunctions.getQueryBuilderTreeRootGroupButtonsContainer(fix, 0)).toBe(undefined); + + // Exit edit mode + const closeButton = QueryBuilderFunctions.getQueryBuilderExpressionCloseButton(fix); + UIInteractions.simulateClickEvent(closeButton); + tick(100); + fix.detectChanges(); + + // Verify adding buttons are enabled + buttons = QueryBuilderFunctions.getQueryBuilderTreeRootGroupButtons(fix, 0); + for (const button of buttons) { + ControlsFunction.verifyButtonIsDisabled(button as HTMLElement, false); + } + })); + + it('Clicking a condition should put it in edit mode.', fakeAsync(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + fix.detectChanges(); + + // Click the existing chip to enter edit mode. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [1]); + tick(50); + fix.detectChanges(); + // Verify inputs values + QueryBuilderFunctions.verifyEditModeExpressionInputValues(fix, 'OrderId', 'Greater Than', '3'); + // Edit the operator + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 0); // Select 'Equals' operator. + // Commit the change + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix); + fix.detectChanges(); + // Verify the chip + QueryBuilderFunctions.verifyExpressionChipContent(fix, [1], 'OrderId', 'Equals', '3'); + + // Verify that the nested query is not expanded + expect(fix.debugElement.query(By.css(`.${QueryBuilderSelectors.QUERY_BUILDER_TREE}--level-1`)).nativeElement.checkVisibility()).toBeFalse(); + + // Click the nested query chip to enter edit mode. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [0]); + tick(50); + fix.detectChanges(); + // Verify the query is expanded + expect(fix.debugElement.query(By.css(`.${QueryBuilderSelectors.QUERY_BUILDER_TREE}--level-1`)).nativeElement.checkVisibility()).toBeTrue(); + // Click a chip in the nested query three to enter edit mode. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [0], 1); + tick(50); + fix.detectChanges(); + // Verify inputs values + QueryBuilderFunctions.verifyEditModeExpressionInputValues(fix, 'ProductName', 'Contains', 'a', 1); + // Edit the operator + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 2, 1); // Select 'Starts With' operator. + // Commit the change + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix, 1); + fix.detectChanges(); + // Verify the chip + QueryBuilderFunctions.verifyExpressionChipContent(fix, [0], 'ProductName', 'Starts With', 'a', 1); + })); + + it('Should switch edit mode on click on chip on the same level.', fakeAsync(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + fix.detectChanges(); + + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [0]); + tick(50); + fix.detectChanges(); + + // Click the existing chip to enter edit mode. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [0], 1); + tick(50); + fix.detectChanges(); + // Verify inputs values + QueryBuilderFunctions.verifyEditModeExpressionInputValues(fix, 'ProductName', 'Contains', 'a', 1); + + // Click the existing chip to enter edit mode. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [0], 1); + tick(50); + fix.detectChanges(); + // Verify inputs values + QueryBuilderFunctions.verifyEditModeExpressionInputValues(fix, 'Released', 'True', '', 1); + QueryBuilderFunctions.verifyExpressionChipContent(fix, [0], 'ProductName', 'Contains', 'a', 1); + })); + + it('Should exit edit mode on add, change group buttons, entity and fields select click.', fakeAsync(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + fix.detectChanges(); + + // Click chip to enter edit mode. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [1]); + tick(50); + fix.detectChanges(); + + // Hover exprssion and click add button + UIInteractions.hoverElement(QueryBuilderFunctions.getQueryBuilderTreeItem(fix, [0]) as HTMLElement); + tick(50); + fix.detectChanges(); + (QueryBuilderFunctions.getQueryBuilderTreeExpressionIcon(fix, [0], 'add') as HTMLElement).click(); + tick(50); + fix.detectChanges(); + + expect(queryBuilder.queryTree.hasEditedExpression).toBeFalse(); + + // Click chip to enter edit mode. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [1]); + tick(50); + fix.detectChanges(); + + // Click change group button + QueryBuilderFunctions.clickQueryBuilderGroupContextMenu(fix, 0); + tick(100); + fix.detectChanges(); + + expect(queryBuilder.queryTree.hasEditedExpression).toBeFalse(); + + // Click chip to enter edit mode. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [1]); + tick(50); + fix.detectChanges(); + + // Click fields select + QueryBuilderFunctions.clickQueryBuilderFieldsCombo(fix); + fix.detectChanges(); + + expect(queryBuilder.queryTree.hasEditedExpression).toBeFalse(); + + // Click chip to enter edit mode. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [1]); + tick(50); + fix.detectChanges(); + + // Click entity select + QueryBuilderFunctions.clickQueryBuilderEntitySelect(fix); + fix.detectChanges(); + + expect(queryBuilder.queryTree.hasEditedExpression).toBeFalse(); + })); + + it('Should show add expression button when there is an expression in add mode.', fakeAsync(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + fix.detectChanges(); + + // Hover expression and click add button + UIInteractions.hoverElement(QueryBuilderFunctions.getQueryBuilderTreeItem(fix, [0]) as HTMLElement); + tick(50); + fix.detectChanges(); + (QueryBuilderFunctions.getQueryBuilderTreeExpressionIcon(fix, [0], 'add') as HTMLElement).click(); + tick(50); + fix.detectChanges(); + + // Click 'add condition' option + QueryBuilderFunctions.clickQueryBuilderTreeAddOption(fix, 0); + + // Hover the first chip and verify actions container is visible. + UIInteractions.hoverElement(QueryBuilderFunctions.getQueryBuilderTreeItem(fix, [0]) as HTMLElement); + tick(50); + fix.detectChanges(); + expect(QueryBuilderFunctions.getQueryBuilderTreeExpressionActionsContainer(fix, [0])) + .not.toBeNull('actions container is not visible'); + + // Hover the second chip and verify actions container is visible. + UIInteractions.hoverElement(QueryBuilderFunctions.getQueryBuilderTreeItem(fix, [1]) as HTMLElement); + tick(50); + fix.detectChanges(); + expect(QueryBuilderFunctions.getQueryBuilderTreeExpressionActionsContainer(fix, [1])) + .not.toBeNull('actions container is not visible'); + })); + + it('Should display an alert dialog when the entity is changed and showEntityChangeDialog is true.', fakeAsync(() => { + const queryBuilderElement: HTMLElement = fix.debugElement.queryAll(By.css(`.${QueryBuilderSelectors.QUERY_BUILDER}`))[0].nativeElement; + const queryTreeElement = queryBuilderElement.querySelector(`.${QueryBuilderSelectors.QUERY_BUILDER_TREE}`); + const dialog = queryTreeElement.querySelector('igx-dialog'); + const dialogOutlet = document.querySelector('.igx-dialog__window'); + expect(dialog).toBeDefined(); + + QueryBuilderFunctions.selectEntityAndClickInitialAddCondition(fix, 0); + + // Alert dialog should not be opened if there is no previous selection + expect(dialog.checkVisibility()).toBeFalse(); + + // Select entity + QueryBuilderFunctions.selectEntityInEditModeExpression(fix, 1); + tick(100); + fix.detectChanges(); + + // Alert dialog should be opened + expect(dialog.checkVisibility()).toBeTrue(); + + // Show again checkbox should be unchecked + const checkbox = dialogOutlet.querySelector('igx-checkbox'); + expect(checkbox).toBeDefined(); + expect(checkbox).not.toHaveClass('igx-checkbox--checked'); + expect(queryBuilder.showEntityChangeDialog).toBeTrue(); + + // Close dialog + const cancelButton = Array.from(dialogOutlet.querySelectorAll('button'))[0]; + cancelButton.click(); + tick(100); + fix.detectChanges(); + + // Select entity + QueryBuilderFunctions.selectEntityInEditModeExpression(fix, 1); + tick(100); + fix.detectChanges(); + + // Alert dialog should NOT be opened + expect(dialog.checkVisibility()).toBeTrue(); + })); + + it('Should not display an alert dialog when the entity changed once showEntityChangeDialog is disabled.', fakeAsync(() => { + const queryBuilderElement: HTMLElement = fix.debugElement.queryAll(By.css(`.${QueryBuilderSelectors.QUERY_BUILDER}`))[0].nativeElement; + const queryTreeElement = queryBuilderElement.querySelector(`.${QueryBuilderSelectors.QUERY_BUILDER_TREE}`); + const dialog = queryTreeElement.querySelector('igx-dialog'); + const dialogOutlet = document.querySelector('.igx-dialog__window'); + expect(dialog).toBeDefined(); + + QueryBuilderFunctions.selectEntityAndClickInitialAddCondition(fix, 0); + + // Alert dialog should not be opened if there is no previous selection + expect(dialog.checkVisibility()).toBeFalse(); + + // Select entity + QueryBuilderFunctions.selectEntityInEditModeExpression(fix, 1); + tick(100); + fix.detectChanges(); + + // Alert dialog should be opened + expect(dialog.checkVisibility()).toBeTrue(); + + // Check show again checkbox + const checkbox = dialogOutlet.querySelector('igx-checkbox') as HTMLElement; + expect(checkbox).toBeDefined(); + + checkbox.click(); + tick(100); + fix.detectChanges(); + expect(checkbox).toHaveClass('igx-checkbox--checked'); + expect(queryBuilder.showEntityChangeDialog).toBeFalse(); + + // Close dialog + const cancelButton = Array.from(dialogOutlet.querySelectorAll('button'))[0]; + cancelButton.click(); + tick(100); + fix.detectChanges(); + + // Select entity + QueryBuilderFunctions.selectEntityInEditModeExpression(fix, 1); + tick(100); + fix.detectChanges(); + + // Alert dialog should NOT be opened + expect(dialog.checkVisibility()).toBeFalse(); + })); + + it('Initially should not display an alert dialog when the entity is changed if hideEntityChangeDialog is disabled through API.', fakeAsync(() => { + queryBuilder.showEntityChangeDialog = false; + const queryBuilderElement: HTMLElement = fix.debugElement.queryAll(By.css(`.${QueryBuilderSelectors.QUERY_BUILDER}`))[0].nativeElement; + const queryTreeElement = queryBuilderElement.querySelector(`.${QueryBuilderSelectors.QUERY_BUILDER_TREE}`); + const dialog = queryTreeElement.querySelector('igx-dialog'); + expect(dialog).toBeDefined(); + + QueryBuilderFunctions.selectEntityAndClickInitialAddCondition(fix, 0); + + // Alert dialog should not be opened if there is no previous selection + expect(dialog.checkVisibility()).toBeFalse(); + + // Select entity + QueryBuilderFunctions.selectEntityInEditModeExpression(fix, 1); + tick(100); + fix.detectChanges(); + + // Alert dialog should NOT be opened + expect(dialog.checkVisibility()).toBeFalse(); + })); + + it('Should reset all inputs when the entity is changed.', fakeAsync(() => { + QueryBuilderFunctions.selectEntityAndClickInitialAddCondition(fix, 0); + + //Select Column + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 0); + + //Select Operator + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 0); + + //Type Value + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(input, '1'); + tick(100); + fix.detectChanges(); + + // Verify all inputs values + QueryBuilderFunctions.verifyQueryEditModeExpressionInputValues(fix, 'Products', 'Id, ProductName, OrderId, Released', 'Id', 'Equals', '1'); + + // Commit the change + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix); + + // Change the selected entity + QueryBuilderFunctions.selectEntityInEditModeExpression(fix, 1); + tick(100); + fix.detectChanges(); + + // Confirm the change + const dialogOutlet: HTMLElement = fix.debugElement.queryAll(By.css(`.igx-dialog`))[0].nativeElement; + const confirmButton = Array.from(dialogOutlet.querySelectorAll('button'))[1]; + expect(confirmButton.innerText).toEqual('Confirm'); + confirmButton.click(); + fix.detectChanges(); + + QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fix, 0); + tick(100); + fix.detectChanges(); + + // Verify all inputs + QueryBuilderFunctions.verifyQueryEditModeExpressionInputValues(fix, 'Orders', 'OrderId, OrderName, OrderDate, Delivered', '', '', ''); + })); + + it('Should NOT reset all inputs when the entity is not changed.', fakeAsync(() => { + QueryBuilderFunctions.selectEntityAndClickInitialAddCondition(fix, 0); + + //Select Column + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 0); + + //Select Operator + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 0); + + //Type Value + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(input, '1'); + tick(100); + fix.detectChanges(); + + // Verify all inputs values + QueryBuilderFunctions.verifyQueryEditModeExpressionInputValues(fix, 'Products', 'Id, ProductName, OrderId, Released', 'Id', 'Equals', '1'); + + // Commit the change + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix); + + // Change the selected entity + QueryBuilderFunctions.selectEntityInEditModeExpression(fix, 1); + tick(100); + fix.detectChanges(); + + // Decline the change + const dialogOutlet: HTMLElement = fix.debugElement.queryAll(By.css(`.igx-dialog`))[0].nativeElement; + const cancelButton = Array.from(dialogOutlet.querySelectorAll('button'))[0]; + expect(cancelButton.innerText).toEqual('Cancel'); + cancelButton.click(); + tick(100); + fix.detectChanges(); + + // Verify all inputs + QueryBuilderFunctions.verifyQueryEditModeExpressionInputValues(fix, 'Products', 'Id, ProductName, OrderId, Released'); + QueryBuilderFunctions.verifyExpressionChipContent(fix, [0], 'Id', 'Equals', '1'); + })); + + it(`"commit" button should be enabled/disabled properly when editing an expression.`, fakeAsync(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + fix.detectChanges(); + tick(100); + fix.detectChanges(); + + // Click the 'OrderId' chip to enter edit mode. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [1]); + tick(50); + fix.detectChanges(); + + // Verify "commit" button is enabled + let commitBtn = QueryBuilderFunctions.getQueryBuilderExpressionCommitButton(fix); + ControlsFunction.verifyButtonIsDisabled(commitBtn as HTMLElement, false); + // Delete the value + let input = QueryBuilderFunctions.getQueryBuilderValueInput(fix, false).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(input, ''); + tick(100); + fix.detectChanges(); + // Verify "commit" button is disabled + commitBtn = QueryBuilderFunctions.getQueryBuilderExpressionCommitButton(fix); + ControlsFunction.verifyButtonIsDisabled(commitBtn as HTMLElement); + // Enter some value + input = QueryBuilderFunctions.getQueryBuilderValueInput(fix, false).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(input, '5'); + tick(100); + fix.detectChanges(); + // Verify "commit" button is enabled + commitBtn = QueryBuilderFunctions.getQueryBuilderExpressionCommitButton(fix); + ControlsFunction.verifyButtonIsDisabled(commitBtn as HTMLElement, false); + })); + + it(`Parent "commit" button should be enabled if a child condition is edited.`, fakeAsync(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + fix.detectChanges(); + tick(100); + fix.detectChanges(); + + // Click the parent chip 'Products' to enter edit mode. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [0]); + tick(50); + fix.detectChanges(); + + // Click the child chip 'Released' to enter edit mode. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [1], 1); + tick(50); + fix.detectChanges(); + + // Change the 'Released' operator + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 2, 1); // Select 'False' operator. + + // Verify both parent and child commit buttons are enabled + let parentCommitBtn = QueryBuilderFunctions.getQueryBuilderExpressionCommitButton(fix); + let childCommitBtn = QueryBuilderFunctions.getQueryBuilderExpressionCommitButton(fix, 1); + + ControlsFunction.verifyButtonIsDisabled(parentCommitBtn as HTMLElement, false); + ControlsFunction.verifyButtonIsDisabled(childCommitBtn as HTMLElement, false); + + // Commit the change + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix, 1); + + // Click the child chip 'ProductName' to enter edit mode. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [0], 1); + tick(50); + fix.detectChanges(); + + // Change the 'ProductName' column to 'Id' + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 0, 1); + + // Verify input values + QueryBuilderFunctions.verifyEditModeExpressionInputValues(fix, 'Id', 'Equals', '', 1); + + // Verify parent and child commit buttons are disabled + parentCommitBtn = QueryBuilderFunctions.getQueryBuilderExpressionCommitButton(fix); + childCommitBtn = QueryBuilderFunctions.getQueryBuilderExpressionCommitButton(fix, 1); + + ControlsFunction.verifyButtonIsDisabled(parentCommitBtn as HTMLElement, false); + ControlsFunction.verifyButtonIsDisabled(childCommitBtn as HTMLElement); + + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 0, 1); + //Type Value + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix, false, 1).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(input, '1'); + tick(100); + fix.detectChanges(); + + // Commit the child + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix, 1); + tick(50); + fix.detectChanges(); + + // Verify parent is enabled + parentCommitBtn = QueryBuilderFunctions.getQueryBuilderExpressionCommitButton(fix); + ControlsFunction.verifyButtonIsDisabled(parentCommitBtn as HTMLElement, false); + })); + + it(`Clicking parent "commit" button should properly exit edit mode of inner query.`, fakeAsync(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + fix.detectChanges(); + tick(100); + fix.detectChanges(); + + // Click the parent chip 'Products' to enter edit mode. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [0]); + tick(50); + fix.detectChanges(); + + // Click the child chip 'Released' to enter edit mode. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [1], 1); + tick(50); + fix.detectChanges(); + + // Change the 'Released' operator + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 2, 1); // Select 'False' operator. + + // Commit the change through the parent + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix); + tick(50); + fix.detectChanges(); + + // Verify the changes in the child query are commited + let exprTree = JSON.stringify(fix.componentInstance.queryBuilder.expressionTree.filteringOperands[0]); + expect(exprTree).toBe(`{"fieldName":"OrderId","condition":{"name":"inQuery","isUnary":false,"isNestedQuery":true,"iconName":"in"},"conditionName":"inQuery","searchVal":null,"searchTree":{"filteringOperands":[{"fieldName":"ProductName","condition":{"name":"contains","isUnary":false,"iconName":"filter_contains"},"conditionName":"contains","searchVal":"a"},{"fieldName":"Released","condition":{"name":"false","isUnary":true,"iconName":"filter_false"},"conditionName":"false","searchVal":null,"searchTree":null}],"operator":0,"entity":"Products","returnFields":["Id"]}}`); + // Enter edit mode again + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [0]); + tick(50); + fix.detectChanges(); + + // Click the child chip 'ProductName' to enter edit mode. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [0], 1); + tick(50); + fix.detectChanges(); + + // Change the 'ProductName' column to 'Id' + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 0, 1); + + // Verify input values + QueryBuilderFunctions.verifyEditModeExpressionInputValues(fix, 'Id', 'Equals', '', 1); + + // Verify parent and child commit buttons are disabled + const parentCommitBtn = QueryBuilderFunctions.getQueryBuilderExpressionCommitButton(fix); + const childCommitBtn = QueryBuilderFunctions.getQueryBuilderExpressionCommitButton(fix, 1); + + ControlsFunction.verifyButtonIsDisabled(parentCommitBtn as HTMLElement, false); + ControlsFunction.verifyButtonIsDisabled(childCommitBtn as HTMLElement); + + // Commit the parent + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix); + tick(50); + fix.detectChanges(); + + // Verify the changes in the child query are discarded + exprTree = JSON.stringify(fix.componentInstance.queryBuilder.expressionTree.filteringOperands[0]); + expect(exprTree).toBe(`{"fieldName":"OrderId","condition":{"name":"inQuery","isUnary":false,"isNestedQuery":true,"iconName":"in"},"conditionName":"inQuery","searchVal":null,"searchTree":{"filteringOperands":[{"fieldName":"ProductName","condition":{"name":"contains","isUnary":false,"iconName":"filter_contains"},"conditionName":"contains","searchVal":"a"},{"fieldName":"Released","condition":{"name":"false","isUnary":true,"iconName":"filter_false"},"conditionName":"false","searchVal":null,"searchTree":null}],"operator":0,"entity":"Products","returnFields":["Id"]}}`); + })); + + it('Should collapse nested query when it is committed.', fakeAsync(() => { + QueryBuilderFunctions.selectEntityAndClickInitialAddCondition(fix, 1); + + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 0); // Select 'OrderId' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 10); // Select 'In' operator. + + QueryBuilderFunctions.addAndValidateChildGroup(fix, 1); + + // Verify that the nested query is expanded + expect(fix.debugElement.query(By.css(`.${QueryBuilderSelectors.QUERY_BUILDER_TREE}--level-1`)).nativeElement.checkVisibility()).toBeTrue(); + + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix); + tick(100); + fix.detectChanges(); + + // Verify that the nested query is collapsed + expect(fix.debugElement.query(By.css(`.${QueryBuilderSelectors.QUERY_BUILDER_TREE}--level-1`)).nativeElement.checkVisibility()).toBeFalse(); + })); + + it(`Should discard the changes in the fields if 'close' button of nested query condition is clicked.`, fakeAsync(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + fix.detectChanges(); + tick(100); + fix.detectChanges(); + // Verify parent chip expression + QueryBuilderFunctions.verifyExpressionChipContent(fix, [0], 'OrderId', 'In', 'Products / Id'); + + // Click the parent chip 'Products' to enter edit mode. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [0]); + tick(50); + fix.detectChanges(); + + // Select 'Product Name' fields + QueryBuilderFunctions.selectFieldsInEditModeExpression(fix, [1, 2], 1); + const closeBtn = QueryBuilderFunctions.getQueryBuilderExpressionCloseButton(fix); + closeBtn.click(); + tick(50); + fix.detectChanges(); + // Verify parent chip expression is not changed + QueryBuilderFunctions.verifyExpressionChipContent(fix, [0], 'OrderId', 'In', 'Products / Id'); + })); + + it('Should be able to open edit mode on click, close the edited condition on "close" button click and not commit it.', fakeAsync(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + fix.detectChanges(); + tick(100); + fix.detectChanges(); + + //Enter edit mode + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [1]); + tick(200); + fix.detectChanges(); + + const closeBtn = QueryBuilderFunctions.getQueryBuilderExpressionCloseButton(fix); + + // Verify the enabled/disabled state of each input of the expression in edit mode. + QueryBuilderFunctions.verifyEditModeExpressionInputStates(fix, true, true, true, true); + + // Verify the edit inputs values. + QueryBuilderFunctions.verifyEditModeExpressionInputValues(fix, 'OrderId', 'Greater Than', '3'); + + //edit condition fields + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 1); // Select 'OrderName' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 0); // Select 'Contains' operator. + const value = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(value, '5'); + tick(200); + fix.detectChanges(); + + //cancel edit + closeBtn.click(); + tick(); + fix.detectChanges(); + + //Verify changes are reverted + QueryBuilderFunctions.verifyExpressionChipContent(fix, [1], 'OrderId', 'Greater Than', '3'); + })); + + it(`Should focus edited expression chip after click on the 'commit'/'discard' button.`, fakeAsync(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + fix.detectChanges(); + tick(100); + fix.detectChanges(); + + // Click the 'OrderId' chip to enter edit mode. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [1]); + tick(100); + fix.detectChanges(); + + // Click on the 'commit' button + const commitBtn = QueryBuilderFunctions.getQueryBuilderExpressionCommitButton(fix); + commitBtn.click(); + fix.detectChanges(); + tick(100); + fix.detectChanges(); + // Verify focused chip + QueryBuilderFunctions.verifyFocusedChip('OrderId', 'Greater Than', '3'); + + // Click the 'OrderId' chip to enter edit mode. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [1]); + tick(100); + fix.detectChanges(); + + // Click on the 'discard' button + const closeBtn = QueryBuilderFunctions.getQueryBuilderExpressionCloseButton(fix); + closeBtn.click(); + fix.detectChanges(); + tick(100); + fix.detectChanges(); + // Verify focused chip + QueryBuilderFunctions.verifyFocusedChip('OrderId', 'Greater Than', '3'); + })); + + it(`Should focus proper expression chip after switching edit mode and click on the 'commit'/'discard' button.`, fakeAsync(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + fix.detectChanges(); + tick(100); + fix.detectChanges(); + + // Click the 'OrderDate' chip to enter edit mode. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [2]); + tick(100); + fix.detectChanges(); + + // Click the 'OrderId' chip to enter edit mode. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [1]); + tick(100); + fix.detectChanges(); + + // Click on the 'commit' button + const commitBtn = QueryBuilderFunctions.getQueryBuilderExpressionCommitButton(fix); + commitBtn.click(); + fix.detectChanges(); + tick(100); + fix.detectChanges(); + // Verify focused chip + QueryBuilderFunctions.verifyFocusedChip('OrderId', 'Greater Than', '3'); + })); + + it('Should focus added through group add buttons expression chip if it is commited.', fakeAsync(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + fix.detectChanges(); + tick(100); + fix.detectChanges(); + + const group = QueryBuilderFunctions.getQueryBuilderTreeRootGroup(fix) as HTMLElement; + + // Add new 'expression'. + const buttonsContainer = Array.from(group.querySelectorAll('.igx-filter-tree__buttons'))[1]; + const buttons = Array.from(buttonsContainer.querySelectorAll('button')); + (buttons[0] as HTMLElement).click(); + tick(); + fix.detectChanges(); + + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 3); // Select 'Delivered' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 1); // Select 'True' operator. + tick(100); + fix.detectChanges(); + + // Click on the 'commit' button + const commitBtn = QueryBuilderFunctions.getQueryBuilderExpressionCommitButton(fix); + commitBtn.click(); + fix.detectChanges(); + tick(300); + fix.detectChanges(); + + // Verify focused chip + QueryBuilderFunctions.verifyFocusedChip('Delivered', 'True'); + })); + + it('Should NOT focus an expression chip if added expression is discarded.', fakeAsync(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + fix.detectChanges(); + tick(100); + fix.detectChanges(); + + const group = QueryBuilderFunctions.getQueryBuilderTreeRootGroup(fix) as HTMLElement; + + // Add new 'expression'. + const buttonsContainer = Array.from(group.querySelectorAll('.igx-filter-tree__buttons'))[1]; + const buttons = Array.from(buttonsContainer.querySelectorAll('button')); + (buttons[0] as HTMLElement).click(); + tick(); + fix.detectChanges(); + + // Click on the 'close' button + const closeBtn = QueryBuilderFunctions.getQueryBuilderExpressionCloseButton(fix); + closeBtn.click(); + fix.detectChanges(); + tick(300); + fix.detectChanges(); + + // Verify chip is not focused + expect(document.activeElement.tagName).toEqual('BODY'); + })); + + it('Should not make bug where existing inner query is leaking to a newly created one.', fakeAsync(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + fix.detectChanges(); + tick(100); + fix.detectChanges(); + + const group = QueryBuilderFunctions.getQueryBuilderTreeRootGroup(fix) as HTMLElement; + + // Add new 'expression'. + const buttonsContainer = Array.from(group.querySelectorAll('.igx-filter-tree__buttons'))[1]; + const buttons = Array.from(buttonsContainer.querySelectorAll('button')); + (buttons[0] as HTMLElement).click(); + tick(); + fix.detectChanges(); + + // Add condition with 'in' operator to open inner query + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 0); // Select 'OrderName' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 10); // Select 'Contains' operator. + tick(100); + fix.detectChanges(); + + //New empty inner query should be displayed + const queryBuilderElement: HTMLElement = fix.debugElement.queryAll(By.css(`.${QueryBuilderSelectors.QUERY_BUILDER}`))[0].nativeElement; + const bodyElement = queryBuilderElement.children[0].children[0]; + const actionArea = bodyElement.children[0].querySelector('.igx-query-builder__root-actions'); + expect(actionArea).toBeNull(); + expect(bodyElement.children[1].children[1].children[1].children[1].children[6].children[1]).toHaveClass(QueryBuilderSelectors.QUERY_BUILDER_TREE); + expect(bodyElement.children[1].children[1].children[1].children[1].children[6].children[1].children.length).toEqual(3); + const tree = bodyElement.children[1].children[1].children[1].children[1].children[6].children[1].querySelector('.igx-filter-tree__expression'); + expect(tree).toBeNull(); + })); + + it('canCommit should return the correct validity state of currently edited condition.', fakeAsync(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + fix.detectChanges(); + + // Click the existing chip to enter edit mode. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [1]); + tick(50); + fix.detectChanges(); + expect(queryBuilder.canCommit()).toBeTrue(); + + // Verify the Query Builder validity state while editing a condition. + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 1); + expect(queryBuilder.canCommit()).toBeFalse(); + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 0); + expect(queryBuilder.canCommit()).toBeFalse(); + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(input, 'a'); + tick(100); + fix.detectChanges(); + expect(queryBuilder.canCommit()).toBeTrue(); + })); + + it('canCommit should return the correct validity state of currently added condition.', fakeAsync(() => { + // Verify the Query Builder validity state while adding a condition. + QueryBuilderFunctions.selectEntityInEditModeExpression(fix, 1); // Select 'Orders' entity + tick(100); + fix.detectChanges(); + expect(queryBuilder.canCommit()).withContext('Entity selected').toBeTrue(); + + // Click the 'Add condition' button. + QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fix, 0); + tick(100); + fix.detectChanges(); + expect(queryBuilder.canCommit()).withContext('Add condition clicked').toBeTrue(); + + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 1); + expect(queryBuilder.canCommit()).withContext('Column selected').toBeFalse(); + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 0); + expect(queryBuilder.canCommit()).withContext('Operator contains selected').toBeFalse(); + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(input, 'a'); + tick(100); + fix.detectChanges(); + expect(queryBuilder.canCommit()).withContext('Search value filled').toBeTrue(); + + // Click on the 'cancel' button + const closeButton = QueryBuilderFunctions.getQueryBuilderExpressionCloseButton(fix); + UIInteractions.simulateClickEvent(closeButton); + tick(100); + fix.detectChanges(); + expect(queryBuilder.canCommit()).withContext('Entity remains selected').toBeTrue(); + + // Verify the Query Builder validity state for UNARY condition. + QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fix, 0); + tick(100); + fix.detectChanges(); + expect(queryBuilder.canCommit()).withContext('Add condition clicked again').toBeTrue(); + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 3); + expect(queryBuilder.canCommit()).withContext('Column selected again').toBeTrue(); + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 1); + expect(queryBuilder.canCommit()).withContext('Unary operator selected').toBeTrue(); + })); + + it('Should be able to commit nested query without where condition.', fakeAsync(() => { + QueryBuilderFunctions.selectEntityAndClickInitialAddCondition(fix, 1); + + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 0); // Select 'OrderId' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 10); // Select 'In' operator. + + let commitBtn = QueryBuilderFunctions.getQueryBuilderExpressionCommitButton(fix); + ControlsFunction.verifyButtonIsDisabled(commitBtn as HTMLElement, true); + + // Enter values in the nested query + QueryBuilderFunctions.selectEntityInEditModeExpression(fix, 0, 1); // Select 'Products' entity + tick(100); + fix.detectChanges(); + + // Change return field from preselected 'OrderId' to 'Id' + QueryBuilderFunctions.selectFieldsInEditModeExpression(fix, [0], 1); + tick(100); + fix.detectChanges(); + + commitBtn = QueryBuilderFunctions.getQueryBuilderExpressionCommitButton(fix); + ControlsFunction.verifyButtonIsDisabled(commitBtn as HTMLElement, false); + + QueryBuilderFunctions.verifyEditModeExpressionInputStates(fix, true, true, false, true); // Parent commit button should be enabled + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fix); + fix.detectChanges(); + + //Verify that expressionTree is correct + const exprTree = JSON.stringify(fix.componentInstance.queryBuilder.expressionTree, null, 2); + expect(exprTree).toBe(`{ + "filteringOperands": [ + { + "fieldName": "OrderId", + "condition": { + "name": "inQuery", + "isUnary": false, + "isNestedQuery": true, + "iconName": "in" + }, + "conditionName": "inQuery", + "ignoreCase": true, + "searchVal": null, + "searchTree": { + "filteringOperands": [], + "operator": 0, + "entity": "Products", + "returnFields": [ + "Id" + ] + } + } + ], + "operator": 0, + "entity": "Orders", + "returnFields": [ + "OrderId", + "OrderName", + "OrderDate", + "Delivered" + ] +}`); + })); + + it(`Should be able to enter edit mode from condition in an inner query.`, fakeAsync(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + fix.detectChanges(); + + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [0]); + tick(100); + fix.detectChanges(); + + // Click the child chip 'Released' to enter edit mode. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [1], 1); + tick(50); + fix.detectChanges(); + + // Verify both parent and child commit buttons are enabled + const parentCommitBtn = QueryBuilderFunctions.getQueryBuilderExpressionCommitButton(fix); + const childCommitBtn = QueryBuilderFunctions.getQueryBuilderExpressionCommitButton(fix, 1); + + ControlsFunction.verifyButtonIsDisabled(parentCommitBtn as HTMLElement, false); + ControlsFunction.verifyButtonIsDisabled(childCommitBtn as HTMLElement, false); + + // Verify inputs values on both levels + QueryBuilderFunctions.verifyEditModeExpressionInputValues(fix, 'OrderId', 'In', '', 0); + QueryBuilderFunctions.verifyEditModeExpressionInputValues(fix, 'Released', 'True', '', 1); + })); + + it(`Should be able to switch group condition and ungroup from group context menu.`, fakeAsync(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTreeWithSubGroup(); + fix.detectChanges(); + + // Verify there is one subgroup + expect(queryBuilder.expressionTree.filteringOperands.filter(o => o instanceof FilteringExpressionsTree).length).toBe(1); + + // Verify the operator of the subgroup is an 'And' line. + QueryBuilderFunctions.verifyOperatorLine(QueryBuilderFunctions.getQueryBuilderTreeRootGroupOperatorLine(fix) as HTMLElement, 'and'); + QueryBuilderFunctions.verifyOperatorLine(QueryBuilderFunctions.getQueryBuilderTreeGroupOperatorLine(fix, [0]) as HTMLElement, 'or'); + + // Click the 'OR' subgroup button + QueryBuilderFunctions.clickQueryBuilderGroupContextMenu(fix, 2); + tick(100); + fix.detectChanges(); + + // Click the 'Switch to AND' drop down item + QueryBuilderFunctions.clickContextMenuItem(fix, 0); + tick(100); + fix.detectChanges(); + + // Verify the operator of the subgroup is an 'And' line. + QueryBuilderFunctions.verifyOperatorLine(QueryBuilderFunctions.getQueryBuilderTreeGroupOperatorLine(fix, [0]) as HTMLElement, 'and'); + + // Click the 'AND' subgroup button + QueryBuilderFunctions.clickQueryBuilderGroupContextMenu(fix, 2); + tick(100); + fix.detectChanges(); + + // Click the 'Ungroup' drop down item + QueryBuilderFunctions.clickContextMenuItem(fix, 1); + tick(100); + fix.detectChanges(); + + // Verify there are no subgroups anymore + expect(queryBuilder.expressionTree.filteringOperands.filter(o => o instanceof FilteringExpressionsTree).length).toBe(0); + })); + + it('Should disable changing a selected entity when "disableEntityChange"=true', () => { + queryBuilder.disableEntityChange = true; + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTreeWithSubGroup(); + fix.detectChanges(); + + const selectEntity = QueryBuilderFunctions.getQueryBuilderEntitySelect(fix, 0); + expect(selectEntity.children[0].classList.contains('igx-input-group--disabled')).toBeTrue(); + }); + + it('Should disable changing a selected entity when "disableEntityChange"=true only after initial selection', fakeAsync(() => { + queryBuilder.disableEntityChange = true; + fix.detectChanges(); + + const selectEntity = QueryBuilderFunctions.getQueryBuilderEntitySelect(fix, 0); + expect(selectEntity.children[0].classList.contains('igx-input-group--disabled')).toBeFalse(); + QueryBuilderFunctions.selectEntityInEditModeExpression(fix, 0, 0); + + expect(selectEntity.children[0].classList.contains('igx-input-group--disabled')).toBeTrue(); + })); + + it('Should disable changing the selected fields when "disableReturnFieldsChange"=true', () => { + queryBuilder.disableReturnFieldsChange = true; + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTreeWithSubGroup(); + fix.detectChanges(); + + const fieldsCombo = QueryBuilderFunctions.getQueryBuilderFieldsCombo(fix, 0); + expect(fieldsCombo.children[0].classList.contains('igx-input-group--disabled')).toBeTrue(); + }); + + it(`Should show 'Ungroup' as disabled in root group context menu.`, fakeAsync(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTreeWithSubGroup(); + fix.detectChanges(); + + // Click the 'AND' root group button + QueryBuilderFunctions.clickQueryBuilderGroupContextMenu(fix, 0); + tick(100); + fix.detectChanges(); + + // Verify 'Ungroup' is disabled + QueryBuilderFunctions.verifyContextMenuItemDisabled(fix, 1, true); + + // Click the 'OR' subgroup button + QueryBuilderFunctions.clickQueryBuilderGroupContextMenu(fix, 2); + tick(100); + fix.detectChanges(); + + // Verify 'Ungroup' is enabled + QueryBuilderFunctions.verifyContextMenuItemDisabled(fix, 1, false); + })); + }); + + describe('API', () => { + beforeEach(fakeAsync(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + fix.detectChanges(); + + spyOn(queryBuilder.expressionTreeChange, 'emit').and.callThrough(); + })); + + it(`Should commit the changes in a valid edited condition when the 'commit' method is called.`, fakeAsync(() => { + // Click the existing chip to enter edit mode. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [1]); + tick(50); + fix.detectChanges(); + + // Change the current condition + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 1); + expect(queryBuilder.canCommit()).toBeFalse(); + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 0); + expect(queryBuilder.canCommit()).toBeFalse(); + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(input, 'a'); + tick(100); + fix.detectChanges(); + + // Apply the changes + queryBuilder.commit(); + tick(100); + fix.detectChanges(); + + // Verify expression is commited + QueryBuilderFunctions.verifyExpressionChipContent(fix, [1], 'OrderName', 'Contains', 'a'); + + // Verify event is not fired + expect(queryBuilder.expressionTreeChange.emit).toHaveBeenCalledTimes(0); + })); + + it(`Should discard the changes in a valid edited condition when the 'discard' method is called.`, fakeAsync(() => { + // Click the existing chip to enter edit mode. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [1]); + tick(50); + fix.detectChanges(); + + // Change the current condition + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 1); + expect(queryBuilder.canCommit()).toBeFalse(); + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 0); + expect(queryBuilder.canCommit()).toBeFalse(); + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(input, 'a'); + tick(100); + fix.detectChanges(); + + // Discard the changes + queryBuilder.discard(); + tick(100); + fix.detectChanges(); + + // Verify expression is not commited + QueryBuilderFunctions.verifyExpressionChipContent(fix, [1], 'OrderId', 'Greater Than', '3'); + + // Verify event is not fired + expect(queryBuilder.expressionTreeChange.emit).toHaveBeenCalledTimes(0); + })); + + it('Should properly commit/discard changes in nested query.', fakeAsync(() => { + // Click the existing chip to enter edit mode. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [0]); + tick(50); + fix.detectChanges(); + expect(queryBuilder.canCommit()).toBeTrue(); + + // Start editing expression in the nested query + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [1], 1); + tick(50); + fix.detectChanges(); + expect(queryBuilder.canCommit()).toBeTrue(); + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 0, 1); + expect(queryBuilder.canCommit()).toBeFalse(); + + // Discard the changes + queryBuilder.discard(); + tick(100); + fix.detectChanges(); + + // Verify the nested query is collapsed + expect(fix.debugElement.query(By.css(`.${QueryBuilderSelectors.QUERY_BUILDER_TREE}--level-1`)).nativeElement.checkVisibility()).toBeFalse(); + + // Click the existing chip to enter edit mode. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [0]); + tick(50); + fix.detectChanges(); + + // Start editing expression in the nested query + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [0], 1); + tick(50); + fix.detectChanges(); + expect(queryBuilder.canCommit()).toBeTrue(); + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 0, 1); + expect(queryBuilder.canCommit()).toBeFalse(); + QueryBuilderFunctions.selectOperatorInEditModeExpression(fix, 0, 1); + expect(queryBuilder.canCommit()).toBeFalse(); + const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix, false, 1).querySelector('input'); + UIInteractions.clickAndSendInputElementValue(input, '1'); + tick(100); + fix.detectChanges(); + expect(queryBuilder.canCommit()).toBeTrue(); + + // Apply the changes + queryBuilder.commit(); + tick(100); + fix.detectChanges(); + + // Verify the nested query is collapsed + expect(fix.debugElement.query(By.css(`.${QueryBuilderSelectors.QUERY_BUILDER_TREE}--level-1`)).nativeElement.checkVisibility()).toBeFalse(); + + // Expand the nested query by putting it in edit mode + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [0]); + tick(50); + fix.detectChanges(); + + // Verify edited expressions + QueryBuilderFunctions.verifyExpressionChipContent(fix, [0], 'Id', 'Equals', '1', 1); + QueryBuilderFunctions.verifyExpressionChipContent(fix, [1], 'Released', 'True', undefined, 1); + + // close chip + queryBuilder.discard(); + tick(100); + fix.detectChanges(); + + // Click the existing chip to enter edit mode. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [0]); + tick(50); + fix.detectChanges(); + + QueryBuilderFunctions.selectEntityInEditModeExpression(fix, 1, 1); + tick(100); + fix.detectChanges(); + + // Confirm the change + const dialogOutlet: HTMLElement = fix.debugElement.queryAll(By.css(`.igx-dialog`))[0].nativeElement; + const confirmButton = Array.from(dialogOutlet.querySelectorAll('button'))[1]; + expect(confirmButton.innerText).toEqual('Confirm'); + confirmButton.click(); + tick(100); + fix.detectChanges(); + + // Discard the changes + queryBuilder.discard(); + tick(100); + fix.detectChanges(); + + // Expand the nested query by putting it in edit mode + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [0]); + tick(50); + fix.detectChanges(); + + // Verify edited expressions + QueryBuilderFunctions.verifyExpressionChipContent(fix, [0], 'Id', 'Equals', '1', 1); + QueryBuilderFunctions.verifyExpressionChipContent(fix, [1], 'Released', 'True', undefined, 1); + })); + + it('Should NOT throw errors when an invalid condition is committed through API.', fakeAsync(() => { + spyOn(console, 'error'); + // Click the existing chip to enter edit mode. + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fix, [2]); + tick(50); + fix.detectChanges(); + + // Change the current condition + QueryBuilderFunctions.selectColumnInEditModeExpression(fix, 1); + expect(queryBuilder.canCommit()).toBeFalse(); + + let errMessage = ''; + // Apply the changes + try { + queryBuilder.commit(); + tick(100); + fix.detectChanges(); + } catch (err) { + errMessage = err.message; + } + + expect(errMessage).toBe("Expression tree can't be committed in the current state. Use `canCommit` method to check if the current state is valid."); + })); + }); + + describe('Keyboard navigation', () => { + it('Should navigate with Tab/Shift+Tab through entity and fields inputs, chips, their respective drop & delete icons and operator drop-down button', fakeAsync(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + fix.detectChanges(); + + QueryBuilderFunctions.verifyQueryBuilderTabbableElements(fix); + })); + + it('Should navigate with Tab/Shift+Tab through chips" "edit", "cancel" buttons, fields of a condition in edit mode.', fakeAsync(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + fix.detectChanges(); + + const chip = fix.debugElement.queryAll(By.directive(IgxChipComponent))[0]; + + QueryBuilderFunctions.hitKeyUponElementAndDetectChanges(fix, ' ', chip.nativeElement, 200); + + // const chipActions = fix.debugElement.query(By.css('.igx-filter-tree__expression-actions')); + // QueryBuilderFunctions.verifyTabbableChipActions(chipActions); + + // // Open Edit mode and check condition line elements + // QueryBuilderFunctions.hitKeyUponElementAndDetectChanges(fix, ' ', chipActions.children[0].nativeElement, 200); + + const editLine = fix.debugElement.queryAll(By.css('.igx-filter-tree__inputs'))[1]; + QueryBuilderFunctions.verifyTabbableConditionEditLineElements(editLine); + + const editDialog = fix.debugElement.queryAll(By.css(`.${QueryBuilderSelectors.QUERY_BUILDER_BODY}`))[1]; + QueryBuilderFunctions.verifyTabbableInConditionDialogElements(editDialog); + })); + + it('Should start editing a condition when pressing \'Enter\' on its respective chip.', fakeAsync(() => { + //!Both Enter and Space should work + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + fix.detectChanges(); + + const chip = fix.debugElement.queryAll(By.directive(IgxChipComponent))[0]; + + QueryBuilderFunctions.hitKeyUponElementAndDetectChanges(fix, ' ', chip.nativeElement, 200); + + let editLine = fix.debugElement.queryAll(By.css('.igx-filter-tree__inputs'))[1]; + QueryBuilderFunctions.verifyTabbableConditionEditLineElements(editLine); + + // Discard the changes + queryBuilder.discard(); + tick(100); + fix.detectChanges(); + + QueryBuilderFunctions.hitKeyUponElementAndDetectChanges(fix, 'Enter', chip.nativeElement, 200); + + editLine = fix.debugElement.queryAll(By.css('.igx-filter-tree__inputs'))[1]; + QueryBuilderFunctions.verifyTabbableConditionEditLineElements(editLine); + })); + + it('Should remove a chip in when pressing \'Enter\' on its \'remove\' icon.', fakeAsync(() => { + //!Both Enter and Space should work + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + fix.detectChanges(); + + // Verify there are three chip expressions. + QueryBuilderFunctions.verifyRootAndSubGroupExpressionsCount(fix, 3, 6); + + // Press 'Enter' on the remove icon of the second chip. + const chip = QueryBuilderFunctions.getQueryBuilderTreeExpressionChip(fix, [1]); + const removeIcon = ControlsFunction.getChipRemoveButton(chip as HTMLElement); + QueryBuilderFunctions.hitKeyUponElementAndDetectChanges(fix, 'Enter', removeIcon); + QueryBuilderFunctions.verifyRootAndSubGroupExpressionsCount(fix, 2, 5); + + // Press 'Space' on the remove icon of the second chip. + const chip2 = QueryBuilderFunctions.getQueryBuilderTreeExpressionChip(fix, [0]); + const removeIcon2 = ControlsFunction.getChipRemoveButton(chip2 as HTMLElement); + QueryBuilderFunctions.hitKeyUponElementAndDetectChanges(fix, ' ', removeIcon2); + QueryBuilderFunctions.verifyRootAndSubGroupExpressionsCount(fix, 1, 1); + })); + }); + + describe('Templates', () => { + let fixture: ComponentFixture; + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(IgxQueryBuilderCustomTemplateSampleTestComponent); + fixture.detectChanges(); + queryBuilder = fixture.componentInstance.queryBuilder; + })); + + it('Should render custom header properly.', () => { + expect(QueryBuilderFunctions.getQueryBuilderHeaderText(fixture)).toBe('Custom Title'); + }); + + it('Should render custom input template properly.', fakeAsync(() => { + //Enter edit mode + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fixture, [0]); + tick(200); + fixture.detectChanges(); + + const editModeContainer = QueryBuilderFunctions.getQueryBuilderEditModeContainer(fixture, false); + const input = editModeContainer.querySelector('input.custom-class') as HTMLInputElement; + const selectedField = editModeContainer.querySelector('p.selectedField') as HTMLInputElement; + const selectedCondition = editModeContainer.querySelector('p.selectedCondition') as HTMLInputElement; + + expect(input).toBeDefined(); + expect(input.value).toBe('3'); + expect(selectedField).toBeDefined(); + expect(selectedField.innerText).toBe('OrderId'); + expect(selectedCondition).toBeDefined(); + expect(selectedCondition.innerText).toBe('greaterThan'); + + //Edit input value + UIInteractions.clickAndSendInputElementValue(input, '5'); + tick(100); + fixture.detectChanges(); + + // Commit the populated expression. + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fixture); + tick(100); + fixture.detectChanges(); + + //Verify that expressionTree is correct + const exprTree = JSON.stringify(fixture.componentInstance.queryBuilder.expressionTree, null, 2); + expect(exprTree).toBe(`{ + "filteringOperands": [ + { + "fieldName": "OrderId", + "condition": { + "name": "greaterThan", + "isUnary": false, + "iconName": "filter_greater_than" + }, + "conditionName": "greaterThan", + "searchVal": 5, + "searchTree": null, + "ignoreCase": true + } + ], + "operator": 0, + "entity": "Orders", + "returnFields": [ + "OrderId", + "OrderName", + "OrderDate", + "Delivered" + ] +}`); + })); + + it('Should apply field formatter properly.', fakeAsync(() => { + // Add new expression + const btn = QueryBuilderFunctions.getQueryBuilderTreeRootGroupButtons(fixture, 0)[0] as HTMLElement; + btn.click(); + fixture.detectChanges(); + + // Populate edit inputs. + QueryBuilderFunctions.selectColumnInEditModeExpression(fixture, 0); // Select 'OrderId' column. + QueryBuilderFunctions.selectOperatorInEditModeExpression(fixture, 0); // Select 'Equals' operator. + + // Verify combo template is displayed + let editModeContainer = Array.from(QueryBuilderFunctions.getQueryBuilderExpressionsContainer(fixture).querySelectorAll('.igx-filter-tree__inputs'))[1]; + let combo = editModeContainer.querySelector('.igx-combo'); + expect(combo).toBeDefined(); + + // Open the combo + (combo.querySelector('igx-input-group') as HTMLElement).click(); + tick(); + fixture.detectChanges(); + // Select item + const outlet = Array.from(document.querySelectorAll(`.igx-drop-down__list-scroll`)) + .filter(item => (item as HTMLElement).checkVisibility())[0] as HTMLElement; + + const comboItem = outlet.querySelectorAll(`.igx-drop-down__item`)[0] as HTMLElement; + comboItem.click(); + tick(); + fixture.detectChanges(); + + // Commit the expression + QueryBuilderFunctions.clickQueryBuilderExpressionCommitButton(fixture); + fixture.detectChanges(); + // Verify chips + QueryBuilderFunctions.verifyExpressionChipContent(fixture, [0], 'OrderId', 'Greater Than', '3'); + QueryBuilderFunctions.verifyExpressionChipContent(fixture, [1], 'OrderId', 'Equals', '0'); + + // Enter edit mode + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(fixture, [1]); + tick(50); + fixture.detectChanges(); + // Verify inputs values + editModeContainer = Array.from(QueryBuilderFunctions.getQueryBuilderExpressionsContainer(fixture).querySelectorAll('.igx-filter-tree__inputs'))[1]; + const selects = Array.from(editModeContainer.querySelectorAll('igx-select')); + combo = editModeContainer.querySelector('.igx-combo'); + expect(selects[0].querySelector('input').value).toBe('OrderId'); + expect(selects[1].querySelector('input').value).toBe('Equals'); + expect(combo.querySelector('input').value).toBe('A'); + })); + }); + + describe('Localization', () => { + it('Should correctly change resource strings for Query Builder.', fakeAsync(() => { + queryBuilder.resourceStrings = Object.assign({}, queryBuilder.resourceStrings, { + igx_query_builder_filter_operator_and: 'My and', + igx_query_builder_filter_operator_or: 'My or', + igx_query_builder_and_label: 'My and', + igx_query_builder_or_label: 'My or', + igx_query_builder_switch_group: 'My switch to {0}', + igx_query_builder_add_condition_root: 'My condition', + igx_query_builder_add_group_root: 'My group', + igx_query_builder_ungroup: 'My ungroup', + igx_query_builder_dialog_title: 'My Confirmation', + igx_query_builder_dialog_message: 'My changing entity message', + igx_query_builder_dialog_checkbox_text: 'My do not show this dialog again', + igx_query_builder_dialog_cancel: 'My Cancel', + igx_query_builder_dialog_confirm: 'My Confirm', + igx_query_builder_drop_ghost_text: 'My Drop here to insert' + }); + fix.detectChanges(); + + // Select 'Orders' entity + QueryBuilderFunctions.selectEntityInEditModeExpression(fix, 1); + tick(100); + fix.detectChanges(); + + expect((QueryBuilderFunctions.getQueryBuilderInitialAddConditionBtn(fix, 0) as HTMLElement).querySelector('span').innerText) + .toBe('My condition'); + + // Click the 'My and' group button + QueryBuilderFunctions.clickQueryBuilderGroupContextMenu(fix); + tick(100); + fix.detectChanges(); + + expect((QueryBuilderFunctions.getQueryBuilderGroupContextMenuDropDownItems(fix)[0]).querySelector('span').innerText) + .toBe('My switch to MY OR'); + expect((QueryBuilderFunctions.getQueryBuilderGroupContextMenuDropDownItems(fix)[1]).querySelector('span').innerText) + .toBe('My ungroup'); + + // Show changing entity alert dialog + QueryBuilderFunctions.selectEntityInEditModeExpression(fix, 0); + tick(100); + fix.detectChanges(); + const dialogOutlet = document.querySelector('.igx-dialog__window'); + expect(dialogOutlet).toBeDefined(); + + expect(dialogOutlet.querySelector('.igx-dialog__window-title').textContent.trim()).toBe('My Confirmation'); + expect(dialogOutlet.querySelector('.igx-query-builder-dialog').children[0].textContent.trim()).toBe('My changing entity message'); + expect(dialogOutlet.querySelector('.igx-query-builder-dialog').children[1].textContent.trim()).toBe('My do not show this dialog again'); + expect(dialogOutlet.querySelector('.igx-dialog__window-actions').children[0].textContent.trim()).toBe('My Cancel'); + expect(dialogOutlet.querySelector('.igx-dialog__window-actions').children[1].textContent.trim()).toBe('My Confirm'); + + //Drag ghost text check + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + fix.detectChanges(); + const draggedChip = fix.debugElement.queryAll(By.directive(IgxChipComponent))[3].componentInstance; + UIInteractions.moveDragDirective(fix, draggedChip.dragDirective, 10, 10, false); + const dropGhost = QueryBuilderFunctions.getDropGhost(fix) as HTMLElement; + expect(draggedChip.dragDirective.ghostElement).toBeTruthy(); + expect(dropGhost).toBeDefined(); + expect(dropGhost.innerText).toBe('My Drop here to insert'); + })); + }); + + describe('Drag and drop', () => { + const ROW_HEIGHT = 40; + const DROP_CONDITION_HERE = "Drop here to insert"; + let chipComponents = []; + beforeEach(() => { + queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTreeWithSubGroup(); + fix.detectChanges(); + + chipComponents = fix.debugElement.queryAll(By.directive(IgxChipComponent)); + }); + + it('Should render ghost when mouse drag operation starts.', () => { + const draggedChip = chipComponents[1].componentInstance; + + UIInteractions.moveDragDirective(fix, draggedChip.dragDirective, 100, 10, false); + const dropGhost = QueryBuilderFunctions.getDropGhost(fix) as HTMLElement; + + expect(draggedChip.dragDirective.ghostElement).toBeTruthy(); + expect(dropGhost).toBeDefined(); + expect(dropGhost.innerText).toBe(DROP_CONDITION_HERE); + }); + + it('Should collapse the condition when mouse drag operation starts.', () => { + const secondChip = chipComponents[1].componentInstance; + + UIInteractions.moveDragDirective(fix, secondChip.dragDirective, 100, 10, false); + expect(chipComponents[1].nativeElement.getBoundingClientRect().height).toBe(0); + }); + + it('Should render drop ghost properly when mouse dragged down on the left.', fakeAsync(() => { + const draggedChip = chipComponents[1].componentInstance; + QueryBuilderFunctions.verifyGhostPositionOnMouseDrag(fix, draggedChip, 100, 75, true); + })); + + it('Should render drop ghost properly when mouse dragged up on the left.', fakeAsync(() => { + const draggedChip = chipComponents[1].componentInstance; + QueryBuilderFunctions.verifyGhostPositionOnMouseDrag(fix, draggedChip, 100, 75 + 350, false); + })); + + it('Should render drop ghost properly when mouse dragged down on the right.', fakeAsync(() => { + const draggedChip = chipComponents[1].componentInstance; + QueryBuilderFunctions.verifyGhostPositionOnMouseDrag(fix, draggedChip, 500, 75, true); + })); + + it('Should render drop ghost properly when mouse dragged up on the right.', fakeAsync(() => { + const draggedChip = chipComponents[1].componentInstance; + QueryBuilderFunctions.verifyGhostPositionOnMouseDrag(fix, draggedChip, 500, 75 + 350, false); + })); + + it('Should position drop ghost below the target condition on dragging down.', () => { + const draggedChip = chipComponents[0].componentInstance; + const draggedChipCenter = QueryBuilderFunctions.getElementCenter(draggedChip.chipArea.nativeElement); + const dragDir = draggedChip.dragDirective; + + //pickup chip + dragDir.onPointerDown({ pointerId: 1, pageX: draggedChipCenter.X, pageY: draggedChipCenter.Y }); + fix.detectChanges(); + + //trigger ghost + QueryBuilderFunctions.dragMove(dragDir, draggedChipCenter.X, draggedChipCenter.Y + 10); + fix.detectChanges(); + + const dropGhost = QueryBuilderFunctions.getDropGhost(fix); + + expect(dropGhost).not.toBe(null); + const dropGhostBounds = dropGhost.getBoundingClientRect(); + const targetChipBounds = chipComponents[1].nativeElement.getBoundingClientRect(); + expect(dropGhostBounds.x).toBe(targetChipBounds.x); + expect(dropGhostBounds.y).toBeCloseTo(targetChipBounds.y + ROW_HEIGHT); + }); + + it('Should position drop ghost above the target condition on dragging up.', fakeAsync(() => { + const draggedChip = chipComponents[1].componentInstance; + const draggedChipCenter = QueryBuilderFunctions.getElementCenter(draggedChip.chipArea.nativeElement); + const dragDir = draggedChip.dragDirective; + + //pickup chip + dragDir.onPointerDown({ pointerId: 1, pageX: draggedChipCenter.X, pageY: draggedChipCenter.Y }); + fix.detectChanges(); + + //trigger ghost + QueryBuilderFunctions.dragMove(dragDir, draggedChipCenter.X, draggedChipCenter.Y - 30); + tick(50); + fix.detectChanges(); + + const dropGhost = QueryBuilderFunctions.getDropGhost(fix); + + expect(dropGhost).not.toBe(null); + const dropGhostBounds = dropGhost.getBoundingClientRect(); + const targetChipBounds = chipComponents[0].nativeElement.getBoundingClientRect(); + expect(dropGhostBounds.x).toBe(targetChipBounds.x); + expect(dropGhostBounds.y).toBeCloseTo(targetChipBounds.y + ROW_HEIGHT); + })); + + it('Should position drop ghost at the top inside the inner group when dragged over the first inner level condition.', () => { + const secondChip = chipComponents[0].componentInstance; + const secondChipElem = secondChip.chipArea.nativeElement; + + const dragDir = secondChip.dragDirective; + UIInteractions.moveDragDirective(fix, dragDir, 100, 4 * secondChipElem.offsetHeight, false); + + const dropGhostBounds = QueryBuilderFunctions.getDropGhostBounds(fix); + const targetChipBounds = chipComponents[4].nativeElement.getBoundingClientRect(); + expect(dropGhostBounds.x).toBe(targetChipBounds.x); + expect(dropGhostBounds.y).toBeCloseTo(targetChipBounds.y + ROW_HEIGHT); + }); + + it('Should position drop ghost outside the inner group aligned with the outer level conditions when the top inner level condition is dragged up.', () => { + const draggedChip = chipComponents[4].componentInstance; // "OrderName Ends With a" chip + const draggedChipCenter = QueryBuilderFunctions.getElementCenter(draggedChip.chipArea.nativeElement); + const dragDir = draggedChip.dragDirective; + + //pickup chip + dragDir.onPointerDown({ pointerId: 1, pageX: draggedChipCenter.X, pageY: draggedChipCenter.Y }); + fix.detectChanges(); + + //drag + QueryBuilderFunctions.dragMove(dragDir, draggedChipCenter.X, draggedChipCenter.Y - 2 * ROW_HEIGHT, false); + fix.detectChanges(); + + const dropGhostBounds = QueryBuilderFunctions.getDropGhostBounds(fix); + const targetChipBounds = chipComponents[1].nativeElement.getBoundingClientRect(); // "OrderId in Products/OrderId" chip + expect(dropGhostBounds.x).toBe(targetChipBounds.x); + expect(dropGhostBounds.y).toBeCloseTo(targetChipBounds.y + ROW_HEIGHT); + }); + + // TODO: Currently doesn't work as expected. The drop ghost is not shown on the first action. + xit('Should position drop ghost below the inner group aligned with the outer level conditions when the bottom inner level condition is dragged down.', () => { + const draggedChip = chipComponents[5].componentInstance; // "OrderDate Today" chip + const dragDir = draggedChip.dragDirective; + UIInteractions.moveDragDirective(fix, dragDir, -50, 10, false); + + const dropGhostBounds = QueryBuilderFunctions.getDropGhostBounds(fix); + const previousLevelChipBounds = chipComponents[1].nativeElement.getBoundingClientRect(); // "OrderId in Products/OrderId" chip + expect(dropGhostBounds.x).toBe(previousLevelChipBounds.x); + const innerGroupElement = QueryBuilderFunctions.getQueryBuilderTreeChildGroups(QueryBuilderFunctions.getQueryBuilderTreeRootGroup(fix) as HTMLElement)[0]; + const innerGroupBounds = innerGroupElement.getBoundingClientRect(); + expect(Math.abs(dropGhostBounds.top - innerGroupBounds.bottom)).toBeLessThan(20); + }); + + it('Should hide drop ghost on dragging the mouse far down outside the query builder.', () => { + const draggedChip = chipComponents[0].componentInstance; + const draggedChipElem = draggedChip.chipArea.nativeElement; + + UIInteractions.moveDragDirective(fix, draggedChip.dragDirective, 0, 10 * draggedChipElem.offsetHeight, false); + + const dropGhost = QueryBuilderFunctions.getDropGhost(fix); + expect(dropGhost).toBe(null); + expect(QueryBuilderFunctions.getVisibleChips(fix).length).toBe(3); + }); + + it('Should drop the condition above the target condition on dragging up.', fakeAsync(() => { + const secondChip = chipComponents[1].componentInstance; // "OrderId In Products/ OrderId" chip + + expect(QueryBuilderFunctions.getChipContent(chipComponents[0].nativeElement)).toBe("OrderName Equals foo"); + const draggedChipCenter = QueryBuilderFunctions.getElementCenter(secondChip.chipArea.nativeElement); + const dragDir = secondChip.dragDirective; + + //pickup chip + dragDir.onPointerDown({ pointerId: 1, pageX: draggedChipCenter.X, pageY: draggedChipCenter.Y }); + fix.detectChanges(); + + //trigger ghost + QueryBuilderFunctions.dragMove(dragDir, draggedChipCenter.X + 50, draggedChipCenter.Y - 50); + fix.detectChanges(); + + //drag + QueryBuilderFunctions.dragMove(dragDir, draggedChipCenter.X + 50, draggedChipCenter.Y - 50, true); + fix.detectChanges(); + + chipComponents = QueryBuilderFunctions.getVisibleChips(fix); + expect(QueryBuilderFunctions.getChipContent(chipComponents[0].nativeElement)).toBe("OrderId In Products / OrderId"); + expect(QueryBuilderFunctions.getChipContent(chipComponents[1].nativeElement)).toBe("OrderName Equals foo"); + })); + + it('Should drop the condition below the target condition on dragging down.', fakeAsync(() => { + const secondChip = chipComponents[0].componentInstance; // "OrderName Equals foo" chip + const secondChipElem = secondChip.nativeElement; + + expect(QueryBuilderFunctions.getChipContent(chipComponents[0].nativeElement)).toBe("OrderName Equals foo"); + + UIInteractions.moveDragDirective(fix, secondChip.dragDirective, 0, secondChipElem.offsetHeight, true); + tick(50); + fix.detectChanges(); + QueryBuilderFunctions.verifyFocusedChip('OrderName', 'Equals', 'foo'); + + chipComponents = QueryBuilderFunctions.getVisibleChips(fix); + expect(QueryBuilderFunctions.getChipContent(chipComponents[0].nativeElement)).toBe("OrderId In Products / OrderId"); + expect(QueryBuilderFunctions.getChipContent(chipComponents[1].nativeElement)).toBe("OrderName Equals foo"); + })); + + it('Should drop the condition inside the inner group when dropped over the group.', fakeAsync(() => { + const draggedChip = chipComponents[0].componentInstance; // "OrderName Equals foo" chip + const draggedChipElem = draggedChip.nativeElement; + + const dragDir = draggedChip.dragDirective; + UIInteractions.moveDragDirective(fix, dragDir, 50, 2 * draggedChipElem.offsetHeight + 25, true); + tick(50); + fix.detectChanges(); + QueryBuilderFunctions.verifyFocusedChip('OrderName', 'Equals', 'foo'); + + chipComponents = QueryBuilderFunctions.getVisibleChips(fix); + const droppedChipBounds = chipComponents[1].nativeElement.getBoundingClientRect(); + const targetChipBounds = chipComponents[2].nativeElement.getBoundingClientRect(); + expect(droppedChipBounds.x).toBe(targetChipBounds.x); + expect(droppedChipBounds.y).toBeCloseTo(targetChipBounds.y - ROW_HEIGHT); + + expect(QueryBuilderFunctions.getChipContent(chipComponents[0].nativeElement)).toBe("OrderId In Products / OrderId"); + expect(QueryBuilderFunctions.getChipContent(chipComponents[1].nativeElement)).toBe("OrderName Equals foo"); + })); + + it('Should drop the condition outside the inner group aligned with the outer level conditions when dropped above the inner group.', fakeAsync(() => { + const draggedChip = chipComponents[5].componentInstance; // "OrderDate Today" chip + const draggedChipElem = draggedChip.nativeElement; + + UIInteractions.moveDragDirective(fix, draggedChip.dragDirective, 0, -3.5 * draggedChipElem.offsetHeight, true); + tick(50); + fix.detectChanges(); + QueryBuilderFunctions.verifyFocusedChip('OrderDate', 'Today'); + + chipComponents = QueryBuilderFunctions.getVisibleChips(fix); + const droppedChipBounds = chipComponents[2].nativeElement.getBoundingClientRect(); + const targetChipBounds = chipComponents[1].nativeElement.getBoundingClientRect(); // "OrderId in Products/OrderId" chip + expect(droppedChipBounds.x).toBe(targetChipBounds.x); + expect(droppedChipBounds.y).toBeCloseTo(targetChipBounds.y + ROW_HEIGHT); + + expect(QueryBuilderFunctions.getChipContent(chipComponents[0].nativeElement)).toBe("OrderName Equals foo"); + expect(QueryBuilderFunctions.getChipContent(chipComponents[1].nativeElement)).toBe("OrderId In Products / OrderId"); + expect(QueryBuilderFunctions.getChipContent(chipComponents[2].nativeElement)).toBe("OrderDate Today"); + })); + + it('Should drop the condition at the last position of the root group when dropped above the buttons.', fakeAsync(() => { + const draggedChip = chipComponents[5].componentInstance; // "OrderDate Today" chip + const draggedChipCenter = QueryBuilderFunctions.getElementCenter(draggedChip.chipArea.nativeElement); + const dragDir = draggedChip.dragDirective; + + //pickup chip + dragDir.onPointerDown({ pointerId: 1, pageX: draggedChipCenter.X, pageY: draggedChipCenter.Y }); + fix.detectChanges(); + + //trigger ghost + QueryBuilderFunctions.dragMove(dragDir, draggedChipCenter.X + 10, draggedChipCenter.Y + 10); + fix.detectChanges(); + + spyOn(dragDir.ghostElement, 'dispatchEvent').and.callThrough(); + + const addConditionButton = QueryBuilderFunctions.getQueryBuilderTreeRootGroupButtons(fix, 0)[0] as HTMLElement; + const addConditionButtonCenter = QueryBuilderFunctions.getElementCenter(addConditionButton); + + //move over +Condition + QueryBuilderFunctions.dragMove(dragDir, addConditionButtonCenter.X, addConditionButtonCenter.Y); + fix.detectChanges(); + + const dropGhost = QueryBuilderFunctions.getDropGhost(fix) as HTMLElement; + chipComponents = QueryBuilderFunctions.getVisibleChips(fix); + expect(QueryBuilderFunctions.getElementCenter(dropGhost).Y).toBeGreaterThan(QueryBuilderFunctions.getElementCenter(chipComponents[2].nativeElement).Y) + expect(QueryBuilderFunctions.getElementCenter(dropGhost).Y).toBeLessThan(QueryBuilderFunctions.getElementCenter(addConditionButton).Y) + + //drop condition + dragDir.onPointerUp({ pointerId: 1, pageX: addConditionButtonCenter.X, pageY: addConditionButtonCenter.Y }); + tick(20); + fix.detectChanges(); + + const exprTree = JSON.stringify(fix.componentInstance.queryBuilder.expressionTree, null, 2); + expect(exprTree).toBe(`{ + "filteringOperands": [ + { + "fieldName": "OrderName", + "condition": { + "name": "equals", + "isUnary": false, + "iconName": "filter_equal" + }, + "conditionName": "equals", + "searchVal": "foo" + }, + { + "fieldName": "OrderId", + "condition": { + "name": "inQuery", + "isUnary": false, + "isNestedQuery": true, + "iconName": "in" + }, + "conditionName": "inQuery", + "searchTree": { + "filteringOperands": [ + { + "fieldName": "Id", + "condition": { + "name": "equals", + "isUnary": false, + "iconName": "filter_equal" + }, + "conditionName": "equals", + "searchVal": 123 + }, + { + "fieldName": "ProductName", + "condition": { + "name": "equals", + "isUnary": false, + "iconName": "filter_equal" + }, + "conditionName": "equals", + "searchVal": "abc" + } + ], + "operator": 0, + "entity": "Products", + "returnFields": [ + "OrderId" + ] + } + }, + { + "filteringOperands": [ + { + "fieldName": "OrderName", + "condition": { + "name": "endsWith", + "isUnary": false, + "iconName": "filter_ends_with" + }, + "conditionName": "endsWith", + "searchVal": "a" + } + ], + "operator": 1, + "entity": "Orders", + "returnFields": [ + "*" + ] + }, + { + "fieldName": "OrderDate", + "condition": { + "name": "today", + "isUnary": true, + "iconName": "filter_today" + }, + "conditionName": "today" + } + ], + "operator": 0, + "entity": "Orders", + "returnFields": [ + "*" + ] +}`); + })); + + it('Should remove the inner group when the last condition is dragged out.', fakeAsync(() => { + const draggedChip = chipComponents[5].componentInstance; // "OrderDate Today" chip + const heightOffset = draggedChip.nativeElement.offsetHeight; + + UIInteractions.moveDragDirective(fix, draggedChip.dragDirective, 0, -4 * heightOffset, true); + tick(50); + fix.detectChanges(); + QueryBuilderFunctions.verifyFocusedChip('OrderDate', 'Today'); + + UIInteractions.moveDragDirective(fix, chipComponents[4].componentInstance.dragDirective, 0, -4 * heightOffset, true); + tick(50); + fix.detectChanges(); + QueryBuilderFunctions.verifyFocusedChip('OrderName', 'Ends With', 'a'); + + chipComponents = QueryBuilderFunctions.getVisibleChips(fix); + + const firstChipBounds = chipComponents[0].nativeElement.getBoundingClientRect(); + const droppedChipBounds = chipComponents[3].nativeElement.getBoundingClientRect(); + expect(QueryBuilderFunctions.getQueryBuilderTreeChildGroups(QueryBuilderFunctions.getQueryBuilderTreeRootGroup(fix) as HTMLElement).length).toBe(0); + expect(droppedChipBounds.x).toBeCloseTo(firstChipBounds.x); + expect(chipComponents.length).toBe(4); + })); + + it('Should drop the condition above the currently edited condition on dragging up.', fakeAsync(() => { + chipComponents = QueryBuilderFunctions.getVisibleChips(fix); + const draggedChip = chipComponents[3].componentInstance; // "OrderDate Today" chip + const draggedChipElem = draggedChip.nativeElement; + + chipComponents[2].nativeElement.click(); + + UIInteractions.moveDragDirective(fix, draggedChip.dragDirective, 0, -2.5 * draggedChipElem.offsetHeight, true); + tick(50); + fix.detectChanges(); + QueryBuilderFunctions.verifyFocusedChip('OrderDate', 'Today'); + + chipComponents = QueryBuilderFunctions.getVisibleChips(fix); + expect(QueryBuilderFunctions.getChipContent(chipComponents[2].nativeElement)).toBe("OrderDate Today"); + })); + + it('Should be able to drag a top-level condition while a sub-query is expanded.', () => { + chipComponents[1].nativeElement.click(); + + const draggedChip = chipComponents[0].componentInstance; + const draggedChipElem = draggedChip.nativeElement; + + expect(draggedChip.draggable).toBeTrue(); + UIInteractions.moveDragDirective(fix, draggedChip.dragDirective, 0, draggedChipElem.offsetHeight, false); + expect(QueryBuilderFunctions.getDropGhost(fix)).not.toBe(null); + }); + + it('Should allow dragging a sub-query condition while a sub-query is expanded.', () => { + chipComponents[1].nativeElement.click(); + + const draggedChip = chipComponents[2].componentInstance; + const draggedChipElem = draggedChip.nativeElement; + + UIInteractions.moveDragDirective(fix, draggedChip.dragDirective, 0, draggedChipElem.offsetHeight, false); + expect(QueryBuilderFunctions.getDropGhost(fix)).not.toBe(null); + }); + + it('Should successfully rearrange sub-query conditions via mouse drag.', fakeAsync(() => { + chipComponents[1].nativeElement.click(); + + const draggedChip = chipComponents[2].componentInstance; + const draggedChipElem = draggedChip.nativeElement; + + UIInteractions.moveDragDirective(fix, draggedChip.dragDirective, 0, draggedChipElem.offsetHeight, true); + tick(50); + fix.detectChanges(); + QueryBuilderFunctions.verifyFocusedChip('Id', 'Equals', '123'); + + chipComponents = QueryBuilderFunctions.getVisibleChips(fix); + expect(chipComponents.length).toBe(5); + expect(QueryBuilderFunctions.getChipContent(chipComponents[1].nativeElement)).toBe("ProductName Equals abc"); + expect(QueryBuilderFunctions.getChipContent(chipComponents[2].nativeElement)).toBe("Id Equals 123"); + })); + + it('Should not allow dragging a sub-query condition outside the sub-query.', () => { + chipComponents[1].nativeElement.click(); + + const draggedChip = chipComponents[2].componentInstance; + const draggedChipElem = draggedChip.nativeElement; + + UIInteractions.moveDragDirective(fix, draggedChip.dragDirective, 0, -10 * draggedChipElem.offsetHeight, false); + + expect(QueryBuilderFunctions.getVisibleChips(fix).length).toBe(4); + expect(QueryBuilderFunctions.getDropGhost(fix)).toBe(null); + }); + + it('Should successfully drop a condition inside a newly created group.', fakeAsync(() => { + var addGroupButton = QueryBuilderFunctions.getQueryBuilderTreeRootGroupButtons(fix, 0).pop(); + QueryBuilderFunctions.verifyGroupLineCount(fix, 2, 1); + + (addGroupButton as HTMLElement).click(); + const draggedChip = chipComponents.pop().componentInstance; + const draggedChipElem = draggedChip.nativeElement; + UIInteractions.moveDragDirective(fix, draggedChip.dragDirective, 0, 3 * draggedChipElem.offsetHeight, true); + tick(300); + fix.detectChanges(); + QueryBuilderFunctions.verifyFocusedChip('OrderDate', 'Today'); + + chipComponents = QueryBuilderFunctions.getVisibleChips(fix); + expect(chipComponents.length).toBe(4); + QueryBuilderFunctions.verifyGroupLineCount(fix, 2, 2); + const newGroup = QueryBuilderFunctions.getQueryBuilderAllGroups(fix).pop(); + const newGroupConditions = newGroup.querySelectorAll('igx-chip'); + expect(newGroupConditions.length).toBe(1); + expect(QueryBuilderFunctions.getChipContent(newGroupConditions[0])).toBe("OrderDate Today"); + })); + + it('Should render drop ghost properly when keyboard dragged.', fakeAsync(() => { + const draggedIndicator = fix.debugElement.queryAll(By.css('.igx-drag-indicator'))[1]; + const tree = fix.debugElement.query(By.css('.igx-filter-tree')); + + draggedIndicator.triggerEventHandler('focus', {}); + draggedIndicator.nativeElement.focus(); + + spyOn(tree.nativeElement, 'dispatchEvent').and.callThrough(); + const dropGhostContent = QueryBuilderFunctions.GetChipsContentAsArray(fix)[1]; + + //pass 1 down to bottom + let keyPress = new KeyboardEvent('keydown', { key: 'ArrowDown' }); + for (let i = 0; i <= 5; i++) { + tree.nativeElement.dispatchEvent(keyPress); + tick(20); + fix.detectChanges(); + + const [dropGhost, prevElement, nextElement, newChipContents] = QueryBuilderFunctions.getDropGhostAndItsSiblings(fix); + + switch (true) { + case i === 0: + expect(dropGhost).toBeDefined(); + expect(prevElement).toBeNull(); + expect(nextElement).toEqual('OrderName Ends With a'); + expect(newChipContents[4]).toBe(dropGhostContent); + break; + case i === 1: + expect(dropGhost).toBeDefined(); + expect(prevElement).toEqual('OrderName Ends With a'); + expect(nextElement).toEqual('OrderDate Today'); + expect(newChipContents[5]).toBe(dropGhostContent); + break; + case i === 2: + expect(dropGhost).toBeDefined(); + expect(prevElement).toEqual('OrderDate Today'); + expect(nextElement).toBeNull(); + expect(newChipContents[6]).toBe(dropGhostContent); + break; + case i >= 3: + expect(dropGhost).toBeDefined(); + expect(prevElement).toEqual('or OrderName Ends With a OrderDate Today'); + expect(nextElement).toBeNull(); + expect(newChipContents[6]).toBe(dropGhostContent); + break; + } + } + + //pass 2 up to top + keyPress = new KeyboardEvent('keydown', { key: 'ArrowUp' }); + for (let i = 0; i <= 10; i++) { + tree.nativeElement.dispatchEvent(keyPress); + tick(20); + fix.detectChanges(); + + const [dropGhost, prevElement, nextElement, newChipContents] = QueryBuilderFunctions.getDropGhostAndItsSiblings(fix); + + switch (true) { + case i === 0: + expect(dropGhost).toBeDefined(); + expect(prevElement).toEqual('OrderDate Today'); + expect(nextElement).toBeNull(); + expect(newChipContents[6]).toBe(dropGhostContent); + break; + case i === 1: + expect(dropGhost).toBeDefined(); + expect(prevElement).toEqual('OrderName Ends With a'); + expect(nextElement).toEqual('OrderDate Today'); + expect(newChipContents[5]).toBe(dropGhostContent); + break; + case i === 2: + expect(dropGhost).toBeDefined(); + expect(prevElement).toBeNull(); + expect(nextElement).toEqual('OrderName Ends With a'); + expect(newChipContents[4]).toBe(dropGhostContent); + break; + case i === 3: + expect(dropGhost).toBeDefined(); + expect(prevElement).toEqual('OrderName Equals foo'); + expect(nextElement).toEqual('or OrderName Ends With a OrderDate Today'); + expect(newChipContents[1]).toBe(dropGhostContent); + break; + case i >= 4: + expect(dropGhost).toBeDefined(); + expect(prevElement).toBeNull(); + expect(nextElement).toEqual('OrderName Equals foo'); + expect(newChipContents[0]).toBe(dropGhostContent); + break; + } + } + + //pass 3 down to bottom again + keyPress = new KeyboardEvent('keydown', { key: 'ArrowDown' }); + for (let i = 0; i <= 10; i++) { + tree.nativeElement.dispatchEvent(keyPress); + tick(20); + fix.detectChanges(); + + const [dropGhost, prevElement, nextElement, newChipContents] = QueryBuilderFunctions.getDropGhostAndItsSiblings(fix); + + switch (true) { + case i === 0: + expect(dropGhost).toBeDefined(); + expect(prevElement).toEqual('OrderName Equals foo'); + expect(nextElement).toEqual('or OrderName Ends With a OrderDate Today'); + expect(newChipContents[1]).toBe(dropGhostContent); + break; + case i === 1: + expect(dropGhost).toBeDefined(); + expect(prevElement).toBeNull(); + expect(nextElement).toEqual('OrderName Ends With a'); + expect(newChipContents[4]).toBe(dropGhostContent); + break; + case i === 2: + expect(dropGhost).toBeDefined(); + expect(prevElement).toEqual('OrderName Ends With a'); + expect(nextElement).toEqual('OrderDate Today'); + expect(newChipContents[5]).toBe(dropGhostContent); + break; + case i === 3: + expect(dropGhost).toBeDefined(); + expect(prevElement).toEqual('OrderDate Today'); + expect(nextElement).toBeNull(); + expect(newChipContents[6]).toBe(dropGhostContent); + break; + case i >= 4: + expect(dropGhost).toBeDefined(); + expect(prevElement).toEqual('or OrderName Ends With a OrderDate Today'); + expect(nextElement).toBeNull(); + expect(newChipContents[6]).toBe(dropGhostContent); + break; + } + } + })); + + it('Should commit drop upon hitting \'Enter\' when keyboard dragged.', fakeAsync(() => { + const draggedIndicator = fix.debugElement.queryAll(By.css('.igx-drag-indicator'))[4]; + const tree = fix.debugElement.query(By.css('.igx-filter-tree')); + + draggedIndicator.triggerEventHandler('focus', {}); + draggedIndicator.nativeElement.focus(); + + spyOn(tree.nativeElement, 'dispatchEvent').and.callThrough(); + + tree.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); + tick(20); + fix.detectChanges(); + + tree.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + tick(20); + fix.detectChanges(); + + //Verify that expressionTree is correct + const exprTree = JSON.stringify(fix.componentInstance.queryBuilder.expressionTree, null, 2); + expect(exprTree).toBe(`{ + "filteringOperands": [ + { + "fieldName": "OrderName", + "condition": { + "name": "equals", + "isUnary": false, + "iconName": "filter_equal" + }, + "conditionName": "equals", + "searchVal": "foo" + }, + { + "fieldName": "OrderId", + "condition": { + "name": "inQuery", + "isUnary": false, + "isNestedQuery": true, + "iconName": "in" + }, + "conditionName": "inQuery", + "searchTree": { + "filteringOperands": [ + { + "fieldName": "Id", + "condition": { + "name": "equals", + "isUnary": false, + "iconName": "filter_equal" + }, + "conditionName": "equals", + "searchVal": 123 + }, + { + "fieldName": "ProductName", + "condition": { + "name": "equals", + "isUnary": false, + "iconName": "filter_equal" + }, + "conditionName": "equals", + "searchVal": "abc" + } + ], + "operator": 0, + "entity": "Products", + "returnFields": [ + "OrderId" + ] + } + }, + { + "filteringOperands": [ + { + "fieldName": "OrderDate", + "condition": { + "name": "today", + "isUnary": true, + "iconName": "filter_today" + }, + "conditionName": "today" + }, + { + "fieldName": "OrderName", + "condition": { + "name": "endsWith", + "isUnary": false, + "iconName": "filter_ends_with" + }, + "conditionName": "endsWith", + "searchVal": "a" + } + ], + "operator": 1, + "entity": "Orders", + "returnFields": [ + "*" + ] + } + ], + "operator": 0, + "entity": "Orders", + "returnFields": [ + "*" + ] +}`); + })); + + it('Should cancel drop upon hitting \'Escape\' when keyboard dragged.', fakeAsync(() => { + const draggedIndicator = fix.debugElement.queryAll(By.css('.igx-drag-indicator'))[4]; + const tree = fix.debugElement.query(By.css('.igx-filter-tree')); + + draggedIndicator.triggerEventHandler('focus', {}); + draggedIndicator.nativeElement.focus(); + + spyOn(tree.nativeElement, 'dispatchEvent').and.callThrough(); + + tree.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); + tick(20); + fix.detectChanges(); + + tree.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + tick(20); + fix.detectChanges(); + + chipComponents = QueryBuilderFunctions.getVisibleChips(fix); + expect(QueryBuilderFunctions.getChipContent(chipComponents[2].nativeElement)).toBe("OrderName Ends With a"); + expect(QueryBuilderFunctions.getChipContent(chipComponents[3].nativeElement)).toBe("OrderDate Today"); + })); + + }); +}); + +@Component({ + template: ` + + + `, + standalone: true, + imports: [ + IgxQueryBuilderComponent + ] +}) +export class IgxQueryBuilderSampleTestComponent implements OnInit { + @ViewChild(IgxQueryBuilderComponent) public queryBuilder: IgxQueryBuilderComponent; + public entities: Array; + + public ngOnInit(): void { + this.entities = SampleEntities.map(a => ({ ...a })); + } +} + +@Component({ + template: ` + + + `, + standalone: true, + imports: [ + IgxQueryBuilderComponent + ] +}) +export class IgxQueryBuilderInvalidSampleTestComponent implements OnInit { + @ViewChild(IgxQueryBuilderComponent) public queryBuilder: IgxQueryBuilderComponent; + public entities: Array; + public expressionTree: IExpressionTree; + + public ngOnInit(): void { + this.entities = []; + this.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + } +} + +@Component({ + template: ` + + + + @if (selectedField?.field === 'OrderId' && selectedCondition === 'greaterThan'){ + +

    {{selectedField.field}}

    +

    {{selectedCondition}}

    + } @else if (selectedField?.field === 'OrderId' && selectedCondition === 'equals') { + + + } @else { + + } +
    +
    + `, + standalone: true, + imports: [ + IgxQueryBuilderComponent, + IgxQueryBuilderHeaderComponent, + IgxQueryBuilderSearchValueTemplateDirective, + IgxComboComponent, + NgTemplateOutlet, + FormsModule + ] +}) +export class IgxQueryBuilderCustomTemplateSampleTestComponent implements OnInit { + @ViewChild(IgxQueryBuilderComponent) public queryBuilder: IgxQueryBuilderComponent; + @ViewChild('searchValueTemplate', { read: IgxQueryBuilderSearchValueTemplateDirective, static: true }) + public searchValueTemplate: IgxQueryBuilderSearchValueTemplateDirective; + public entities: Array; + public expressionTree: IExpressionTree; + public comboData: any[]; + + + public ngOnInit(): void { + this.entities = SampleEntities.map(a => ({ ...a })); + this.entities[1].fields[0].formatter = (value: any, rowData: any) => rowData === 'equals' ? (Array.from(value)[0] as any).id : value; + + const tree = new FilteringExpressionsTree(FilteringLogic.And, null, 'Orders', ['*']); + tree.filteringOperands.push({ + fieldName: 'OrderId', + condition: IgxNumberFilteringOperand.instance().condition('greaterThan'), + conditionName: 'greaterThan', + searchVal: 3, + ignoreCase: true + }); + + this.expressionTree = tree; + + this.comboData = [ + { id: 0, field: 'A' }, + { id: 1, field: 'B' } + ]; + } +} diff --git a/projects/igniteui-angular/query-builder/src/query-builder/query-builder.component.ts b/projects/igniteui-angular/query-builder/src/query-builder/query-builder.component.ts new file mode 100644 index 00000000000..d654e3008f1 --- /dev/null +++ b/projects/igniteui-angular/query-builder/src/query-builder/query-builder.component.ts @@ -0,0 +1,358 @@ +import { booleanAttribute, ContentChild, EventEmitter, Output, TemplateRef, inject } from '@angular/core'; +import { + Component, Input, ViewChild, ElementRef, OnDestroy, HostBinding +} from '@angular/core'; +import { Subject } from 'rxjs'; +import { + EntityType, + FieldType, + IExpressionTree, + IQueryBuilderResourceStrings, + QueryBuilderResourceStringsEN, + recreateTree, + getCurrentResourceStrings, + IgxOverlayOutletDirective +} from 'igniteui-angular/core'; +import { IgxQueryBuilderTreeComponent } from './query-builder-tree.component'; +import { IgxIconService } from 'igniteui-angular/icon'; +import { editor } from '@igniteui/material-icons-extended'; +import { IgxQueryBuilderSearchValueTemplateDirective } from './query-builder.directives'; + +/** + * A component used for operating with complex filters by creating or editing conditions + * and grouping them using AND/OR logic. + * It is used internally in the Advanced Filtering of the Grid. + * + * @example + * ```html + * + * + * ``` + */ +@Component({ + selector: 'igx-query-builder', + templateUrl: './query-builder.component.html', + imports: [IgxQueryBuilderTreeComponent] +}) +export class IgxQueryBuilderComponent implements OnDestroy { + protected iconService = inject(IgxIconService); + + /** + * @hidden @internal + */ + @HostBinding('class.igx-query-builder') + public cssClass = 'igx-query-builder'; + + /** + * @hidden @internal + */ + @HostBinding('style.display') + public display = 'block'; + + /** + * Gets/sets whether the confirmation dialog should be shown when changing entity. + * Default value is `true`. + */ + @Input({ transform: booleanAttribute }) + public showEntityChangeDialog = true; + + /** + * Gets the list of entities available for the IgxQueryBuilderComponent. + * + * Each entity describes a logical group of fields that can be used in queries. + * An entity can optionally have child entities, allowing nested sub-queries. + * + * @returns An array of {@link EntityType} objects. + */ + public get entities(): EntityType[] { + return this._entities; + } + + /** + * Sets the list of entities for the IgxQueryBuilderComponent. + * If the `expressionTree` is defined, it will be recreated with the new entities. + * + * Each entity should be an {@link EntityType} object describing the fields and optionally child entities. + * + * Example: + * ```ts + * [ + * { + * name: 'Orders', + * fields: [{ field: 'OrderID', dataType: 'number' }], + * childEntities: [ + * { + * name: 'OrderDetails', + * fields: [{ field: 'ProductID', dataType: 'number' }] + * } + * ] + * } + * ] + * ``` + * + * @param entities - The array of entities to set. + */ + @Input() + public set entities(entities: EntityType[]) { + if (entities !== this._entities) { + if (entities && this.expressionTree) { + this._expressionTree = recreateTree(this._expressionTree, entities); + } + } + this._entities = entities; + } + + /** + * Gets the list of fields for the QueryBuilder. + * + * @deprecated since version 19.1.0. Use the `entities` property instead. + * @hidden + */ + public get fields(): FieldType[] { + return this._fields; + } + + /** + * Sets the list of fields for the QueryBuilder. + * Automatically wraps them into a single entity to maintain backward compatibility. + * + * @param fields - The array of fields to set. + * @deprecated since version 19.1.0. Use the `entities` property instead. + * @hidden + */ + @Input() + public set fields(fields: FieldType[]) { + if (fields) { + this._fields = fields; + this.entities = [ + { + name: null, + fields: fields + } + ]; + } + } + + /** + * Returns the expression tree. + */ + public get expressionTree(): IExpressionTree { + return this._expressionTree; + } + + /** + * Sets the expression tree. + */ + @Input() + public set expressionTree(expressionTree: IExpressionTree) { + if (expressionTree !== this._expressionTree) { + if (this.entities && expressionTree) { + this._expressionTree = recreateTree(expressionTree, this.entities); + } else { + this._expressionTree = expressionTree; + } + } + } + + /** + * Gets the `locale` of the query builder. + * If not set, defaults to application's locale. + */ + @Input() + public locale: string; + + /** + * Sets the resource strings. + * By default it uses EN resources. + */ + @Input() + public set resourceStrings(value: IQueryBuilderResourceStrings) { + this._resourceStrings = Object.assign({}, this._resourceStrings, value); + } + + /** + * Returns the resource strings. + */ + public get resourceStrings(): IQueryBuilderResourceStrings { + return this._resourceStrings; + } + + /** + * Disables subsequent entity changes at the root level after the initial selection. + */ + @Input() + public disableEntityChange = false; + + /** + * Disables return fields changes at the root level. + */ + @Input() + public disableReturnFieldsChange = false; + + /** + * Event fired as the expression tree is changed. + * + * ```html + * + * ``` + */ + @Output() + public expressionTreeChange = new EventEmitter(); + + /** + * @hidden @internal + */ + @ContentChild(IgxQueryBuilderSearchValueTemplateDirective, { read: TemplateRef }) + public searchValueTemplate: TemplateRef; + + /** + * @hidden @internal + */ + @ViewChild(IgxQueryBuilderTreeComponent) + public queryTree: IgxQueryBuilderTreeComponent; + + private destroy$ = new Subject(); + private _resourceStrings = getCurrentResourceStrings(QueryBuilderResourceStringsEN); + private _expressionTree: IExpressionTree; + private _fields: FieldType[]; + private _entities: EntityType[]; + private _shouldEmitTreeChange = true; + + constructor() { + this.registerSVGIcons(); + } + + /** + * Returns whether the expression tree can be committed in the current state. + */ + public canCommit(): boolean { + return this.queryTree?.canCommitCurrentState() === true; + } + + /** + * Commits the expression tree in the current state if it is valid. If not throws an exception. + */ + public commit(): void { + if (this.canCommit()) { + this._shouldEmitTreeChange = false; + this.queryTree.commitCurrentState(); + this._shouldEmitTreeChange = true; + } else { + throw new Error('Expression tree can\'t be committed in the current state. Use `canCommit` method to check if the current state is valid.'); + } + } + + /** + * Discards all unsaved changes to the expression tree. + */ + public discard(): void { + this.queryTree.cancelOperandEdit(); + } + + /** + * @hidden @internal + */ + public ngOnDestroy(): void { + this.destroy$.next(true); + this.destroy$.complete(); + } + + /** + * @hidden @internal + * + * used by the grid + */ + public setPickerOutlet(outlet?: IgxOverlayOutletDirective | ElementRef) { + this.queryTree.setPickerOutlet(outlet); + } + + /** + * @hidden @internal + * + * used by the grid + */ + public get isContextMenuVisible(): boolean { + return this.queryTree.isContextMenuVisible; + } + + /** + * @hidden @internal + * + * used by the grid + */ + public exitOperandEdit() { + this.queryTree.exitOperandEdit(); + } + + /** + * @hidden @internal + * + * used by the grid + */ + public setAddButtonFocus() { + this.queryTree.setAddButtonFocus(); + } + + public onExpressionTreeChange(tree: IExpressionTree) { + if (tree && this.entities && tree !== this._expressionTree) { + this._expressionTree = recreateTree(tree, this.entities); + } else { + this._expressionTree = tree; + } + if (this._shouldEmitTreeChange) { + this.expressionTreeChange.emit(tree); + } + } + + private registerSVGIcons(): void { + const editorIcons = editor as any[]; + + editorIcons.forEach((icon) => { + this.iconService.addSvgIconFromText(icon.name, icon.value, 'imx-icons'); + this.iconService.addIconRef(icon.name, 'default', { + name: icon.name, + family: 'imx-icons' + }); + }); + + const inIcon = ''; + this.iconService.addSvgIconFromText('in', inIcon, 'imx-icons'); + this.iconService.addIconRef('in', 'default', { + name: 'in', + family: 'imx-icons' + }); + + const notInIcon = ''; + this.iconService.addSvgIconFromText('not-in', notInIcon, 'imx-icons'); + this.iconService.addIconRef('not-in', 'default', { + name: 'not-in', + family: 'imx-icons' + }); + + this.iconService.addIconRef('add', 'default', { + name: 'add', + family: 'material', + }); + + this.iconService.addIconRef('close', 'default', { + name: 'close', + family: 'material', + }); + + this.iconService.addIconRef('check', 'default', { + name: 'check', + family: 'material', + }); + + this.iconService.addIconRef('delete', 'default', { + name: 'delete', + family: 'material', + }); + + this.iconService.addIconRef('edit', 'default', { + name: 'edit', + family: 'material', + }); + } +} + diff --git a/projects/igniteui-angular/query-builder/src/query-builder/query-builder.directives.ts b/projects/igniteui-angular/query-builder/src/query-builder/query-builder.directives.ts new file mode 100644 index 00000000000..4dd8201850c --- /dev/null +++ b/projects/igniteui-angular/query-builder/src/query-builder/query-builder.directives.ts @@ -0,0 +1,23 @@ +import { Directive, TemplateRef, inject } from '@angular/core'; + +/** + * Defines the custom template that will be used for the search value input of condition in edit mode + * + * @igxModule IgxQueryBuilderModule + * @igxKeywords query builder, query builder search value + * @igxGroup Data entry and display + * + * @example + * + * + * + * + * + */ +@Directive({ + selector: '[igxQueryBuilderSearchValue]', + standalone: true +}) +export class IgxQueryBuilderSearchValueTemplateDirective { + public template = inject>(TemplateRef); +} diff --git a/projects/igniteui-angular/query-builder/src/query-builder/query-builder.module.ts b/projects/igniteui-angular/query-builder/src/query-builder/query-builder.module.ts new file mode 100644 index 00000000000..a47f59380a3 --- /dev/null +++ b/projects/igniteui-angular/query-builder/src/query-builder/query-builder.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { IGX_QUERY_BUILDER_DIRECTIVES } from './public_api'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + ...IGX_QUERY_BUILDER_DIRECTIVES + ], + exports: [ + ...IGX_QUERY_BUILDER_DIRECTIVES + ] +}) +export class IgxQueryBuilderModule { } diff --git a/projects/igniteui-angular/radio/README.md b/projects/igniteui-angular/radio/README.md new file mode 100644 index 00000000000..3a5c9b980e0 --- /dev/null +++ b/projects/igniteui-angular/radio/README.md @@ -0,0 +1,97 @@ +# igx-radio + +**igx-radio** renders a set of radio buttons to allow the user make a choice and submit data. The user is able to select just one from the available options. +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/radio_button.html) + +# Usage + +A number of options, attributes and events are available to customize the component look and feel and the way the radio button is working. + +__IMPORTANT__ + +Currently the checked state of radio button will update automatically only when its value is bound to ngModel. See the samples below for reference. + +## Initialize +```html + + {{item}} + +``` + +The above markup will render three radio buttons, one for each item of the ['Foo', 'Bar', 'Baz'] array. The `value` property is mapped to the input element value attribute, while the content of the tag is what gets displayed in the label associated with the input. + +You can assign unique ids by using the 'id' property. Use the 'name' property to group buttons together. + +The rest of the properties are also standard and control the tabindex, disabled and checked attributes of the input element that gets rendered: +```html + + {{item}} + +``` + +You can attach to a change event using `(onchange)="doAlert($event)"`: + +```html + + {{user.name}} + +``` + +```typescript +import { Component } from "@angular/core"; +import { IgxRadioModule } from "../../src/radio/radio"; + +@Component({ + selector: "radio-button", + templateUrl: "radio-button.html" +}) +export class RadioSampleComponent { + user = { + name: 'John Doe', + favouriteVarName: 'Foo', + id: 12, + }; + + doAlert() { + alert("Thank you for selecting this option!"); + } +} +``` + +# API Summary +| Name | Type | Description | +|:----------|:-------------:|:------| +| `@Input()` id | string | The unique `id` attribute to be used for the radio button. If you do not provide a value, it will be auto-generated. | +| `@Input()` labelId | string | The unique `id` attribute to be used for the radio button label. If you do not provide a value, it will be auto-generated. | +| `@Input()` name | string | The `name` attribute to be used for the radio button. | +| `@Input()` value | any | The value to be set for the radio button. | +| `@Input()` tabindex | number | Specifies the tabbing order of the radio button. | +| `@Input()` checked | boolean | Specifies the checked state of the radio button. | +| `@Input()` required | boolean | Specifies the required state of the radio button. | +| `@Input()` disabled | boolean | Specifies the disabled state of the radio button. | +| `@Input()` disableRipple | boolean | Specifies the whether the ripple effect should be disabled for the radio button. | +| `@Input()` labelPosition | string `|` enum RadioLabelPosition | Specifies the position of the text label relative to the radio button element. Possible values are "before" and "after". | +| `@Input("aria-labelledby")` ariaLabelledBy | string | Specify an external element by id to be used as label for the radio button. | +| `@Output()` change | EventEmitter | Emitted when the radio button checked value changes. | + +### Methods + +| select | +|:----------| +| Selects the radio button. | diff --git a/projects/igniteui-angular/radio/index.ts b/projects/igniteui-angular/radio/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/radio/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/radio/ng-package.json b/projects/igniteui-angular/radio/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/radio/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/radio/src/public_api.ts b/projects/igniteui-angular/radio/src/public_api.ts new file mode 100644 index 00000000000..6aca07adab5 --- /dev/null +++ b/projects/igniteui-angular/radio/src/public_api.ts @@ -0,0 +1 @@ +export * from './radio/public_api'; diff --git a/projects/igniteui-angular/radio/src/radio/public_api.ts b/projects/igniteui-angular/radio/src/radio/public_api.ts new file mode 100644 index 00000000000..a331cd63e48 --- /dev/null +++ b/projects/igniteui-angular/radio/src/radio/public_api.ts @@ -0,0 +1,3 @@ +export * from './radio.component'; +export * from './radio-group/public_api'; +export * from './radio-group/radio-group.module'; diff --git a/projects/igniteui-angular/radio/src/radio/radio-group/public_api.ts b/projects/igniteui-angular/radio/src/radio/radio-group/public_api.ts new file mode 100644 index 00000000000..575e799a3e8 --- /dev/null +++ b/projects/igniteui-angular/radio/src/radio/radio-group/public_api.ts @@ -0,0 +1,10 @@ +import { IgxRadioComponent } from '../../radio/radio.component'; +import { IgxRadioGroupDirective } from './radio-group.directive'; + +export * from './radio-group.directive'; + +/* NOTE: Radio Group directives collection for ease-of-use import in standalone components scenario */ +export const IGX_RADIO_GROUP_DIRECTIVES = [ + IgxRadioGroupDirective, + IgxRadioComponent +] as const; diff --git a/projects/igniteui-angular/radio/src/radio/radio-group/radio-group.directive.spec.ts b/projects/igniteui-angular/radio/src/radio/radio-group/radio-group.directive.spec.ts new file mode 100644 index 00000000000..9767c6ab702 --- /dev/null +++ b/projects/igniteui-angular/radio/src/radio/radio-group/radio-group.directive.spec.ts @@ -0,0 +1,981 @@ +import { ChangeDetectionStrategy, Component, ComponentRef, OnInit, ViewChild, ViewContainerRef, inject } from '@angular/core'; +import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { IgxRadioGroupDirective } from './radio-group.directive'; +import { FormsModule, ReactiveFormsModule, UntypedFormGroup, UntypedFormBuilder, FormGroup, FormControl } from '@angular/forms'; + +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { By } from '@angular/platform-browser'; +import { IgxRadioComponent } from '../../radio/radio.component'; + +describe('IgxRadioGroupDirective', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + FormsModule, + ReactiveFormsModule, + NoopAnimationsModule, + RadioGroupComponent, + RadioGroupOnPushComponent, + RadioGroupSimpleComponent, + RadioGroupWithModelComponent, + RadioGroupRequiredComponent, + RadioGroupReactiveFormsComponent, + RadioGroupDeepProjectionComponent, + RadioGroupTestComponent, + DynamicRadioGroupComponent, + RadioGroupVerticalComponent + ] + }) + .compileComponents(); + })); + + it('Properly initialize the radio group buttons\' properties.', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupComponent); + const radioInstance = fixture.componentInstance.radioGroup; + + fixture.detectChanges(); + tick(); + + expect(radioInstance.radioButtons).toBeDefined(); + expect(radioInstance.radioButtons.length).toEqual(3); + + const allRequiredButtons = radioInstance.radioButtons.filter((btn) => btn.required); + expect(allRequiredButtons.length).toEqual(radioInstance.radioButtons.length); + + const allButtonsWithGroupName = radioInstance.radioButtons.filter((btn) => btn.name === radioInstance.name); + expect(allButtonsWithGroupName.length).toEqual(radioInstance.radioButtons.length); + + const buttonWithGroupValue = radioInstance.radioButtons.find((btn) => btn.value === radioInstance.value); + expect(buttonWithGroupValue).toBeDefined(); + expect(buttonWithGroupValue).toEqual(radioInstance.selected); + })); + + it('Properly initializes FormControlValue with OnPush change detection strategy', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupOnPushComponent); + const radioInstance = fixture.componentInstance.radio; + + fixture.detectChanges(); + tick(); + + expect(radioInstance.checked).toBeTrue(); + })); + + it('Setting radioGroup\'s properties should affect all radio buttons.', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupComponent); + const radioInstance = fixture.componentInstance.radioGroup; + + fixture.detectChanges(); + tick(); + + expect(radioInstance.radioButtons).toBeDefined(); + + // name + radioInstance.name = 'newGroupName'; + fixture.detectChanges(); + tick(); + + const allButtonsWithNewName = radioInstance.radioButtons.filter((btn) => btn.name === 'newGroupName'); + expect(allButtonsWithNewName.length).toEqual(radioInstance.radioButtons.length); + + // required + radioInstance.required = true; + fixture.detectChanges(); + tick(); + + const allRequiredButtons = radioInstance.radioButtons.filter((btn) => btn.required); + expect(allRequiredButtons.length).toEqual(radioInstance.radioButtons.length); + + // invalid + radioInstance.invalid = true; + fixture.detectChanges(); + + const allInvalidButtons = radioInstance.radioButtons.filter((btn) => btn.invalid); + expect(allInvalidButtons.length).toEqual(radioInstance.radioButtons.length); + })); + + it('Set value should change selected property', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupComponent); + const radioInstance = fixture.componentInstance.radioGroup; + + fixture.detectChanges(); + tick(); + + expect(radioInstance.value).toBeDefined(); + expect(radioInstance.value).toEqual('Baz'); + + expect(radioInstance.selected).toBeDefined(); + expect(radioInstance.selected).toEqual(radioInstance.radioButtons.last); + + spyOn(radioInstance.change, 'emit'); + + radioInstance.value = 'Foo'; + fixture.detectChanges(); + + expect(radioInstance.value).toEqual('Foo'); + expect(radioInstance.selected).toEqual(radioInstance.radioButtons.first); + expect(radioInstance.change.emit).not.toHaveBeenCalled(); + })); + + it('Set selected property should change value', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupComponent); + const radioInstance = fixture.componentInstance.radioGroup; + + fixture.detectChanges(); + tick(); + + expect(radioInstance.value).toBeDefined(); + expect(radioInstance.value).toEqual('Baz'); + + expect(radioInstance.selected).toBeDefined(); + expect(radioInstance.selected).toEqual(radioInstance.radioButtons.last); + + spyOn(radioInstance.change, 'emit'); + + radioInstance.selected = radioInstance.radioButtons.first; + fixture.detectChanges(); + + expect(radioInstance.value).toEqual('Foo'); + expect(radioInstance.selected).toEqual(radioInstance.radioButtons.first); + expect(radioInstance.change.emit).not.toHaveBeenCalled(); + })); + + it('Clicking on a radio button should update the model.', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupWithModelComponent); + const radioInstance = fixture.componentInstance.radioGroup; + + fixture.detectChanges(); + tick(); + + radioInstance.radioButtons.first.nativeLabel.nativeElement.click(); + fixture.detectChanges(); + tick(); + + expect(radioInstance.value).toEqual('Winter'); + expect(radioInstance.selected).toEqual(radioInstance.radioButtons.first); + })); + + it('Updating the model should select a radio button.', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupWithModelComponent); + const radioInstance = fixture.componentInstance.radioGroup; + + fixture.detectChanges(); + tick(); + + fixture.componentInstance.personBob.favoriteSeason = 'Winter'; + fixture.detectChanges(); + tick(); + + expect(radioInstance.value).toEqual('Winter'); + expect(radioInstance.selected).toEqual(radioInstance.radioButtons.first); + })); + + it('Properly update the model when radio group is hosted in Reactive forms.', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupReactiveFormsComponent); + + fixture.detectChanges(); + tick(); + + expect(fixture.componentInstance.personForm).toBeDefined(); + expect(fixture.componentInstance.model).toBeDefined(); + expect(fixture.componentInstance.newModel).toBeUndefined(); + + fixture.componentInstance.personForm.patchValue({ favoriteSeason: fixture.componentInstance.seasons[0] }); + fixture.componentInstance.updateModel(); + fixture.detectChanges(); + tick(); + + expect(fixture.componentInstance.newModel).toBeDefined(); + expect(fixture.componentInstance.newModel.name).toEqual(fixture.componentInstance.model.name); + expect(fixture.componentInstance.newModel.favoriteSeason).toEqual(fixture.componentInstance.seasons[0]); + })); + + it('Properly initialize selection when value is falsy in deep content projection', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupDeepProjectionComponent); + fixture.detectChanges(); + tick(); + + const radioGroup = fixture.componentInstance.radioGroup; + expect(radioGroup.value).toEqual(0); + expect(radioGroup.radioButtons.first.checked).toEqual(true); + })); + + it('Properly rebind dynamically added components', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupDeepProjectionComponent); + const radioInstance = fixture.componentInstance.radioGroup; + fixture.detectChanges(); + tick(); + + fixture.componentInstance.choices = [ 0, 1, 4, 7 ]; + fixture.detectChanges(); + tick(); + + radioInstance.radioButtons.last.nativeLabel.nativeElement.click(); + fixture.detectChanges(); + tick(); + + expect(radioInstance.value).toEqual(7); + expect(radioInstance.selected).toEqual(radioInstance.radioButtons.last); + })); + + it('Updates checked radio button correctly', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupSimpleComponent); + fixture.detectChanges(); + tick(); + + const radioGroup = fixture.componentInstance.radioGroup; + expect(radioGroup.radioButtons.first.checked).toEqual(true); + expect(radioGroup.radioButtons.last.checked).toEqual(false); + + radioGroup.radioButtons.last.select(); + fixture.detectChanges(); + tick(); + + expect(radioGroup.radioButtons.first.checked).toEqual(false); + expect(radioGroup.radioButtons.last.checked).toEqual(true); + })); + + it('Should update styles correctly when required radio group\'s value is set.', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupRequiredComponent); + const radioGroup = fixture.componentInstance.radioGroup; + fixture.detectChanges(); + tick(); + + const domRadio = fixture.debugElement.query(By.css('igx-radio')).nativeElement; + expect(domRadio.classList.contains('igx-radio--invalid')).toBe(false); + expect(radioGroup.selected).toBeUndefined; + expect(radioGroup.invalid).toBe(false); + + dispatchRadioEvent('keyup', domRadio, fixture); + expect(domRadio.classList.contains('igx-radio--focused')).toBe(true); + dispatchRadioEvent('blur', domRadio, fixture); + fixture.detectChanges(); + tick(); + + expect(radioGroup.invalid).toBe(true); + expect(domRadio.classList.contains('igx-radio--invalid')).toBe(true); + + dispatchRadioEvent('keyup', domRadio, fixture); + expect(domRadio.classList.contains('igx-radio--focused')).toBe(true); + + radioGroup.radioButtons.first.select(); + fixture.detectChanges(); + tick(); + + expect(domRadio.classList.contains('igx-radio--checked')).toBe(true); + expect(radioGroup.invalid).toBe(false); + expect(radioGroup.radioButtons.first.checked).toEqual(true); + expect(domRadio.classList.contains('igx-radio--invalid')).toBe(false); + })); + + it('Should select radio button when added programmatically after group value is set', (() => { + const fixture = TestBed.createComponent(DynamicRadioGroupComponent); + const component = fixture.componentInstance; + const radioGroup = component.radioGroup; + + // Simulate AppBuilder configurator setting value before radio buttons exist + radioGroup.value = 'option2'; + + // Verify no radio buttons exist yet + expect(radioGroup.radioButtons.length).toBe(0); + expect(radioGroup.selected).toBeNull(); + + fixture.detectChanges(); + + component.addRadioButton('option1', 'Option 1'); + component.addRadioButton('option2', 'Option 2'); + component.addRadioButton('option3', 'Option 3'); + + fixture.detectChanges(); + + // Radio button with value 'option2' should be selected + expect(radioGroup.value).toBe('option2'); + expect(radioGroup.selected).toBeDefined(); + expect(radioGroup.selected.value).toBe('option2'); + expect(radioGroup.selected.checked).toBe(true); + + // Verify only one radio button is selected + const checkedButtons = radioGroup.radioButtons.filter(btn => btn.checked); + expect(checkedButtons.length).toBe(1); + expect(checkedButtons[0].value).toBe('option2'); + })); + + describe('Required input', () => { + it('Should propagate required property to all child radio buttons when set to true', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupComponent); + const radioGroup = fixture.componentInstance.radioGroup; + fixture.detectChanges(); + tick(); + + // RadioGroupComponent already has required="true" + expect(radioGroup.required).toBe(true); + + radioGroup.radioButtons.forEach(button => { + expect(button.required).toBe(true); + }); + })); + + it('Should propagate required property to all child radio buttons when set to false', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupSimpleComponent); + const radioGroup = fixture.componentInstance.radioGroup; + fixture.detectChanges(); + tick(); + + expect(radioGroup.required).toBe(false); + + radioGroup.radioButtons.forEach(button => { + expect(button.required).toBe(false); + }); + })); + + it('Should update all child radio buttons when required property changes', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupSimpleComponent); + const radioGroup = fixture.componentInstance.radioGroup; + fixture.detectChanges(); + tick(); + + // Initially not required + expect(radioGroup.required).toBe(false); + radioGroup.radioButtons.forEach(button => { + expect(button.required).toBe(false); + }); + + // Set to required + radioGroup.required = true; + fixture.detectChanges(); + tick(); + + radioGroup.radioButtons.forEach(button => { + expect(button.required).toBe(true); + }); + + // Set back to not required + radioGroup.required = false; + fixture.detectChanges(); + tick(); + + radioGroup.radioButtons.forEach(button => { + expect(button.required).toBe(false); + }); + })); + + it('Should propagate required to dynamically added radio buttons', fakeAsync(() => { + const fixture = TestBed.createComponent(DynamicRadioGroupComponent); + const component = fixture.componentInstance; + const radioGroup = component.radioGroup; + + radioGroup.required = true; + fixture.detectChanges(); + tick(); + + component.addRadioButton('option1', 'Option 1'); + component.addRadioButton('option2', 'Option 2'); + fixture.detectChanges(); + tick(); + + radioGroup.radioButtons.forEach(button => { + expect(button.required).toBe(true); + }); + })); + }); + + describe('Keyboard navigation', () => { + it('Should navigate to next radio button with ArrowDown key', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupComponent); + const radioGroup = fixture.componentInstance.radioGroup; + fixture.detectChanges(); + tick(); + + const firstButton = radioGroup.radioButtons.first; + firstButton.select(); + fixture.detectChanges(); + tick(); + + expect(radioGroup.selected).toBe(firstButton); + + const groupElement = fixture.debugElement.query(By.css('igx-radio-group')).nativeElement; + const event = new KeyboardEvent('keydown', { key: 'ArrowDown' }); + groupElement.dispatchEvent(event); + fixture.detectChanges(); + tick(); + + expect(radioGroup.selected).toBe(radioGroup.radioButtons.toArray()[1]); + expect(radioGroup.radioButtons.toArray()[1].checked).toBe(true); + })); + + it('Should navigate to previous radio button with ArrowUp key', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupComponent); + const radioGroup = fixture.componentInstance.radioGroup; + fixture.detectChanges(); + tick(); + + const secondButton = radioGroup.radioButtons.toArray()[1]; + secondButton.select(); + fixture.detectChanges(); + tick(); + + expect(radioGroup.selected).toBe(secondButton); + + const groupElement = fixture.debugElement.query(By.css('igx-radio-group')).nativeElement; + const event = new KeyboardEvent('keydown', { key: 'ArrowUp' }); + groupElement.dispatchEvent(event); + fixture.detectChanges(); + tick(); + + expect(radioGroup.selected).toBe(radioGroup.radioButtons.first); + expect(radioGroup.radioButtons.first.checked).toBe(true); + })); + + it('Should navigate to next radio button with ArrowRight key in LTR', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupComponent); + const radioGroup = fixture.componentInstance.radioGroup; + fixture.detectChanges(); + tick(); + + const firstButton = radioGroup.radioButtons.first; + firstButton.select(); + fixture.detectChanges(); + tick(); + + const groupElement = fixture.debugElement.query(By.css('igx-radio-group')).nativeElement; + const event = new KeyboardEvent('keydown', { key: 'ArrowRight' }); + groupElement.dispatchEvent(event); + fixture.detectChanges(); + tick(); + + expect(radioGroup.selected).toBe(radioGroup.radioButtons.toArray()[1]); + expect(radioGroup.radioButtons.toArray()[1].checked).toBe(true); + })); + + it('Should navigate to previous radio button with ArrowLeft key in LTR', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupComponent); + const radioGroup = fixture.componentInstance.radioGroup; + fixture.detectChanges(); + tick(); + + const secondButton = radioGroup.radioButtons.toArray()[1]; + secondButton.select(); + fixture.detectChanges(); + tick(); + + const groupElement = fixture.debugElement.query(By.css('igx-radio-group')).nativeElement; + const event = new KeyboardEvent('keydown', { key: 'ArrowLeft' }); + groupElement.dispatchEvent(event); + fixture.detectChanges(); + tick(); + + expect(radioGroup.selected).toBe(radioGroup.radioButtons.first); + expect(radioGroup.radioButtons.first.checked).toBe(true); + })); + + it('Should wrap around to last button when pressing ArrowUp on first button', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupComponent); + const radioGroup = fixture.componentInstance.radioGroup; + fixture.detectChanges(); + tick(); + + const firstButton = radioGroup.radioButtons.first; + firstButton.select(); + fixture.detectChanges(); + tick(); + + const groupElement = fixture.debugElement.query(By.css('igx-radio-group')).nativeElement; + const event = new KeyboardEvent('keydown', { key: 'ArrowUp' }); + groupElement.dispatchEvent(event); + fixture.detectChanges(); + tick(); + + expect(radioGroup.selected).toBe(radioGroup.radioButtons.last); + expect(radioGroup.radioButtons.last.checked).toBe(true); + })); + + it('Should wrap around to first button when pressing ArrowDown on last button', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupComponent); + const radioGroup = fixture.componentInstance.radioGroup; + fixture.detectChanges(); + tick(); + + const lastButton = radioGroup.radioButtons.last; + lastButton.select(); + fixture.detectChanges(); + tick(); + + const groupElement = fixture.debugElement.query(By.css('igx-radio-group')).nativeElement; + const event = new KeyboardEvent('keydown', { key: 'ArrowDown' }); + groupElement.dispatchEvent(event); + fixture.detectChanges(); + tick(); + + expect(radioGroup.selected).toBe(radioGroup.radioButtons.first); + expect(radioGroup.radioButtons.first.checked).toBe(true); + })); + + it('Should skip disabled buttons when navigating with arrow keys', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupComponent); + const radioGroup = fixture.componentInstance.radioGroup; + fixture.detectChanges(); + tick(); + + // Disable the second button + const buttons = radioGroup.radioButtons.toArray(); + buttons[1].disabled = true; + fixture.detectChanges(); + tick(); + + // Select first button and navigate down + buttons[0].select(); + fixture.detectChanges(); + tick(); + + const groupElement = fixture.debugElement.query(By.css('igx-radio-group')).nativeElement; + const event = new KeyboardEvent('keydown', { key: 'ArrowDown' }); + groupElement.dispatchEvent(event); + fixture.detectChanges(); + tick(); + + // Should skip the disabled second button and select the third + expect(radioGroup.selected).toBe(buttons[2]); + expect(buttons[2].checked).toBe(true); + })); + + it('Should set focus on selected radio button during keyboard navigation', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupComponent); + const radioGroup = fixture.componentInstance.radioGroup; + fixture.detectChanges(); + tick(); + + const firstButton = radioGroup.radioButtons.first; + firstButton.select(); + fixture.detectChanges(); + tick(); + + spyOn(radioGroup.radioButtons.toArray()[1].nativeElement, 'focus'); + + const groupElement = fixture.debugElement.query(By.css('igx-radio-group')).nativeElement; + const event = new KeyboardEvent('keydown', { key: 'ArrowDown' }); + groupElement.dispatchEvent(event); + fixture.detectChanges(); + tick(); + + expect(radioGroup.radioButtons.toArray()[1].nativeElement.focus).toHaveBeenCalled(); + })); + + it('Should deselect previous button and blur it when navigating', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupComponent); + const radioGroup = fixture.componentInstance.radioGroup; + fixture.detectChanges(); + tick(); + + const firstButton = radioGroup.radioButtons.first; + firstButton.select(); + firstButton.focused = true; + fixture.detectChanges(); + tick(); + + spyOn(firstButton.nativeElement, 'blur'); + + const groupElement = fixture.debugElement.query(By.css('igx-radio-group')).nativeElement; + const event = new KeyboardEvent('keydown', { key: 'ArrowDown' }); + groupElement.dispatchEvent(event); + fixture.detectChanges(); + tick(); + + expect(firstButton.checked).toBe(false); + expect(firstButton.nativeElement.blur).toHaveBeenCalled(); + })); + + it('Should prevent default behavior when navigating with arrow keys', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupComponent); + const radioGroup = fixture.componentInstance.radioGroup; + fixture.detectChanges(); + tick(); + + radioGroup.radioButtons.first.select(); + fixture.detectChanges(); + tick(); + + const groupElement = fixture.debugElement.query(By.css('igx-radio-group')).nativeElement; + const event = new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true }); + spyOn(event, 'preventDefault'); + + groupElement.dispatchEvent(event); + fixture.detectChanges(); + tick(); + + expect(event.preventDefault).toHaveBeenCalled(); + })); + + it('Should update tab index to 0 on checked button and -1 on others', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupComponent); + const radioGroup = fixture.componentInstance.radioGroup; + fixture.detectChanges(); + tick(); + + const buttons = radioGroup.radioButtons.toArray(); + buttons[1].select(); + fixture.detectChanges(); + tick(); + + expect(buttons[1].nativeElement.tabIndex).toBe(0); + expect(buttons[0].nativeElement.tabIndex).toBe(-1); + expect(buttons[2].nativeElement.tabIndex).toBe(-1); + })); + }); + + describe('Alignment', () => { + it('Should have horizontal alignment by default', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupSimpleComponent); + const radioGroup = fixture.componentInstance.radioGroup; + fixture.detectChanges(); + tick(); + + const groupElement = fixture.debugElement.query(By.css('igx-radio-group')).nativeElement; + + expect(radioGroup.alignment).toBe('horizontal'); + expect(groupElement.classList.contains('igx-radio-group--vertical')).toBe(false); + })); + + it('Should apply vertical CSS class when alignment is set to vertical', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupSimpleComponent); + const radioGroup = fixture.componentInstance.radioGroup; + fixture.detectChanges(); + tick(); + + radioGroup.alignment = 'vertical'; + fixture.detectChanges(); + tick(); + + const groupElement = fixture.debugElement.query(By.css('igx-radio-group')).nativeElement; + + expect(radioGroup.alignment).toBe('vertical'); + expect(groupElement.classList.contains('igx-radio-group--vertical')).toBe(true); + })); + + it('Should remove vertical CSS class when alignment is changed back to horizontal', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupSimpleComponent); + const radioGroup = fixture.componentInstance.radioGroup; + fixture.detectChanges(); + tick(); + + radioGroup.alignment = 'vertical'; + fixture.detectChanges(); + tick(); + + let groupElement = fixture.debugElement.query(By.css('igx-radio-group')).nativeElement; + expect(groupElement.classList.contains('igx-radio-group--vertical')).toBe(true); + + radioGroup.alignment = 'horizontal'; + fixture.detectChanges(); + tick(); + + groupElement = fixture.debugElement.query(By.css('igx-radio-group')).nativeElement; + expect(radioGroup.alignment).toBe('horizontal'); + expect(groupElement.classList.contains('igx-radio-group--vertical')).toBe(false); + })); + + it('Should initialize with vertical alignment when set in template', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupVerticalComponent); + const radioGroup = fixture.componentInstance.radioGroup; + fixture.detectChanges(); + tick(); + + const groupElement = fixture.debugElement.query(By.css('igx-radio-group')).nativeElement; + + expect(radioGroup.alignment).toBe('vertical'); + expect(groupElement.classList.contains('igx-radio-group--vertical')).toBe(true); + })); + + it('Should accept RadioGroupAlignment enum values', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupSimpleComponent); + const radioGroup = fixture.componentInstance.radioGroup; + fixture.detectChanges(); + tick(); + + // Import RadioGroupAlignment from the directive + const RadioGroupAlignment = { horizontal: 'horizontal', vertical: 'vertical' } as const; + + radioGroup.alignment = RadioGroupAlignment.vertical as any; + fixture.detectChanges(); + tick(); + + expect(radioGroup.alignment).toBe('vertical'); + + radioGroup.alignment = RadioGroupAlignment.horizontal as any; + fixture.detectChanges(); + tick(); + + expect(radioGroup.alignment).toBe('horizontal'); + })); + }); +}); + +@Component({ + template: ` + + Option 1 + Option 2 + +`, + imports: [IgxRadioGroupDirective, IgxRadioComponent] +}) +class RadioGroupSimpleComponent { + @ViewChild('radioGroup', { read: IgxRadioGroupDirective, static: true }) public radioGroup: IgxRadioGroupDirective; +} + +@Component({ + template: ` + @for (item of ['Foo', 'Bar', 'Baz']; track item) { + + {{item}} + + } + + `, + imports: [IgxRadioComponent, IgxRadioGroupDirective] +}) +class RadioGroupComponent { + @ViewChild('radioGroup', { read: IgxRadioGroupDirective, static: true }) public radioGroup: IgxRadioGroupDirective; +} + +@Component({ + template: ` + @for (item of ['Foo', 'Bar', 'Baz']; track item) { + + {{item}} + + } + + `, + imports: [IgxRadioComponent, IgxRadioGroupDirective] +}) +class RadioGroupRequiredComponent { + @ViewChild('radioGroup', { read: IgxRadioGroupDirective, static: true }) public radioGroup: IgxRadioGroupDirective; +} + +interface Person { + name: string; + favoriteSeason: string; +} + +@Component({ + template: ` +
    + + value1 + value2 + value3 + + +`, + imports: [IgxRadioComponent, IgxRadioGroupDirective, ReactiveFormsModule], + changeDetection: ChangeDetectionStrategy.OnPush +}) +class RadioGroupOnPushComponent { + @ViewChild('checkedRadio', { read: IgxRadioComponent, static: true }) + public radio: IgxRadioComponent; + + public form = new FormGroup({ + radio: new FormControl('value1'), + }); +} + +@Component({ + template: ` + @for (item of seasons; track item) { + + {{item}} + + } + + `, + imports: [IgxRadioComponent, IgxRadioGroupDirective, FormsModule] +}) +class RadioGroupWithModelComponent { + @ViewChild('radioGroupSeasons', { read: IgxRadioGroupDirective, static: true }) public radioGroup: IgxRadioGroupDirective; + + public seasons = [ + 'Winter', + 'Spring', + 'Summer', + 'Autumn', + ]; + + public personBob: Person = { name: 'Bob', favoriteSeason: 'Summer' }; +} + +@Component({ + template: ` +
    + + @for (item of seasons; track item) { + + {{item}} + + } + + +`, + imports: [IgxRadioComponent, IgxRadioGroupDirective, ReactiveFormsModule] +}) +class RadioGroupReactiveFormsComponent { + private _formBuilder = inject(UntypedFormBuilder); + + public seasons = [ + 'Winter', + 'Spring', + 'Summer', + 'Autumn', + ]; + + public newModel: Person; + public model: Person = { name: 'Kirk', favoriteSeason: this.seasons[1] }; + public personForm: UntypedFormGroup; + + constructor() { + this._createForm(); + } + + public updateModel() { + const formModel = this.personForm.value; + + this.newModel = { + name: formModel.name as string, + favoriteSeason: formModel.favoriteSeason as string + }; + } + + private _createForm() { + // create form + this.personForm = this._formBuilder.group({ + name: '', + favoriteSeason: '' + }); + + // simulate model loading from service + this.personForm.setValue({ + name: this.model.name, + favoriteSeason: this.model.favoriteSeason + }); + } +} + +@Component({ + template: ` +
    + + @for (choice of choices; track choice) { +
    +

    {{ choice }}

    +
    + } +
    + + `, + imports: [IgxRadioComponent, IgxRadioGroupDirective, ReactiveFormsModule] +}) +class RadioGroupDeepProjectionComponent { + private _builder = inject(UntypedFormBuilder); + + + @ViewChild(IgxRadioGroupDirective, { static: true }) + public radioGroup: IgxRadioGroupDirective; + + public choices = [0, 1, 2]; + public group1: UntypedFormGroup; + + constructor() { + this._createForm(); + } + + private _createForm() { + this.group1 = this._builder.group({ + favouriteChoice: 0 + }); + } +} + +@Component({ + template: ` + + + + `, + imports: [IgxRadioComponent, IgxRadioGroupDirective] +}) + +class RadioGroupTestComponent implements OnInit { + @ViewChild('radioContainer', { read: ViewContainerRef, static: true }) + public container!: ViewContainerRef; + + public alignment = 'horizontal'; + public required = false; + public value: any; + + public radios: { label: string; value: any }[] = []; + + public handleChange(args: any) { + this.value = args.value; + } + + public ngOnInit(): void { + this.container.clear(); + this.radios.forEach((option) => { + const componentRef: ComponentRef = + this.container.createComponent(IgxRadioComponent); + + componentRef.instance.placeholderLabel.nativeElement.textContent = + option.label; + componentRef.instance.value = option.value; + }); + } +} + +@Component({ + template: ` + + + + `, + imports: [IgxRadioGroupDirective, IgxRadioComponent] +}) +class DynamicRadioGroupComponent { + @ViewChild('radioGroup', { read: IgxRadioGroupDirective, static: true }) + public radioGroup: IgxRadioGroupDirective; + + @ViewChild('radioContainer', { read: ViewContainerRef, static: true }) + public radioContainer: ViewContainerRef; + + /** + * Simulates how AppBuilder adds radio buttons programmatically + * via ViewContainerRef.createComponent() + */ + public addRadioButton(value: string, label: string): void { + const componentRef = this.radioContainer.createComponent(IgxRadioComponent); + componentRef.instance.value = value; + componentRef.instance.placeholderLabel.nativeElement.textContent = label; + componentRef.changeDetectorRef.detectChanges(); + } +} + +@Component({ + template: ` + + Option 1 + Option 2 + Option 3 + +`, + imports: [IgxRadioGroupDirective, IgxRadioComponent] +}) +class RadioGroupVerticalComponent { + @ViewChild('radioGroup', { read: IgxRadioGroupDirective, static: true }) public radioGroup: IgxRadioGroupDirective; +} + +const dispatchRadioEvent = (eventName, radioNativeElement, fixture) => { + radioNativeElement.dispatchEvent(new Event(eventName)); + fixture.detectChanges(); +}; diff --git a/projects/igniteui-angular/radio/src/radio/radio-group/radio-group.directive.ts b/projects/igniteui-angular/radio/src/radio/radio-group/radio-group.directive.ts new file mode 100644 index 00000000000..1c85a6b50f8 --- /dev/null +++ b/projects/igniteui-angular/radio/src/radio/radio-group/radio-group.directive.ts @@ -0,0 +1,685 @@ +import { + ChangeDetectorRef, + Directive, + DoCheck, + EventEmitter, + HostBinding, + HostListener, + Input, + OnDestroy, + Output, + QueryList, + booleanAttribute, + effect, + signal, + inject +} from '@angular/core'; +import { ControlValueAccessor, NgControl, Validators } from '@angular/forms'; +import { fromEvent, noop, Subject, takeUntil } from 'rxjs'; +import { IgxRadioComponent } from '../radio.component'; +import { ɵIgxDirectionality } from 'igniteui-angular/core'; +import { IChangeCheckboxEventArgs } from 'igniteui-angular/directives'; +/** + * Determines the Radio Group alignment + */ +export const RadioGroupAlignment = { + horizontal: 'horizontal', + vertical: 'vertical' +} as const; +export type RadioGroupAlignment = typeof RadioGroupAlignment[keyof typeof RadioGroupAlignment]; + +let nextId = 0; + +/** + * Radio group directive renders set of radio buttons. + * + * @igxModule IgxRadioModule + * + * @igxTheme igx-radio-theme + * + * @igxKeywords radiogroup, radio, button, input + * + * @igxGroup Data Entry & Display + * + * @remarks + * The Ignite UI Radio Group allows the user to select a single option from an available set of options that are listed side by side. + * + * @example: + * ```html + * + * + * {{item}} + * + * + * ``` + */ +@Directive({ + exportAs: 'igxRadioGroup', + selector: 'igx-radio-group, [igxRadioGroup]', + standalone: true +}) +export class IgxRadioGroupDirective implements ControlValueAccessor, OnDestroy, DoCheck { + public ngControl = inject(NgControl, { optional: true, self: true }); + private _directionality = inject(ɵIgxDirectionality); + private cdr = inject(ChangeDetectorRef); + + private _radioButtons = signal([]); + private _radioButtonsList = new QueryList(); + + /** + * Returns reference to the child radio buttons. + * + * @example + * ```typescript + * let radioButtons = this.radioGroup.radioButtons; + * ``` + */ + public get radioButtons(): QueryList { + this._radioButtonsList.reset(this._radioButtons()); + return this._radioButtonsList; + } + + /** + * Sets/gets the `value` attribute. + * + * @example + * ```html + * + * ``` + */ + @Input() + public get value(): any { + return this._value; + } + + public set value(newValue: any) { + if (this._value !== newValue) { + this._value = newValue; + this._selectRadioButton(); + } + } + + /** + * Sets/gets the `name` attribute of the radio group component. All child radio buttons inherits this name. + * + * @example + * ```html + * + * ``` + */ + @Input() + public get name(): string { + return this._name; + } + public set name(newValue: string) { + if (this._name !== newValue) { + this._name = newValue; + this._setRadioButtonNames(); + } + } + + /** + * Sets/gets whether the radio group is required. + * + * @remarks + * If not set, `required` will have value `false`. + * + * @example + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public get required(): boolean { + return this._required; + } + + public set required(value: boolean) { + this._required = value; + this._setRadioButtonsRequired(); + } + + /** + * Sets/gets the selected child radio button. + * + * @example + * ```typescript + * let selectedButton = this.radioGroup.selected; + * this.radioGroup.selected = selectedButton; + * ``` + */ + @Input() + public get selected() { + return this._selected; + } + + public set selected(selected: IgxRadioComponent | null) { + if (this._selected !== selected) { + this._selected = selected; + this.value = selected ? selected.value : null; + } + } + + /** + * Sets/gets whether the radio group is invalid. + * + * @remarks + * If not set, `invalid` will have value `false`. + * + * @example + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public get invalid(): boolean { + return this._invalid; + } + + public set invalid(value: boolean) { + this._invalid = value; + this._setRadioButtonsInvalid(); + } + + /** + * An event that is emitted after the radio group `value` is changed. + * + * @remarks + * Provides references to the selected `IgxRadioComponent` and the `value` property as event arguments. + * + * @example + * ```html + * + * ``` + */ + // eslint-disable-next-line @angular-eslint/no-output-native + @Output() public readonly change: EventEmitter = new EventEmitter(); + + /** + * The css class applied to the component. + * + * @hidden + * @internal + */ + @HostBinding('class.igx-radio-group') + public cssClass = 'igx-radio-group'; + + /** + * @hidden + * @internal + * Sets vertical alignment to the radio group, if `alignment` is set to `vertical`. + * By default the alignment is horizontal. + * + * @example + * ```html + * + * ``` + */ + @HostBinding('class.igx-radio-group--vertical') + protected vertical = false; + + /** + * A css class applied to the component if any of the + * child radio buttons labelPosition is set to `before`. + * + * @hidden + * @internal + */ + @HostBinding('class.igx-radio-group--before') + protected get labelBefore() { + return this._radioButtons().some((radio) => radio.labelPosition === 'before'); + } + + /** + * A css class applied to the component if all + * child radio buttons are disabled. + * + * @hidden + * @internal + */ + @HostBinding('class.igx-radio-group--disabled') + protected get disabled() { + return this._radioButtons().every((radio) => radio.disabled); + } + + @HostListener('click', ['$event']) + protected handleClick(event: MouseEvent) { + event.stopPropagation(); + + if (this.selected) { + this.selected.nativeElement.focus(); + } + } + + @HostListener('keydown', ['$event']) + protected handleKeyDown(event: KeyboardEvent) { + const { key } = event; + const buttons = this._radioButtons().filter(radio => !radio.disabled); + const checked = buttons.find((radio) => radio.checked); + + if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(key)) { + let index = checked ? buttons.indexOf(checked) : -1; + const ltr = this._directionality.value === 'ltr'; + + switch (key) { + case 'ArrowUp': + index += -1; + break; + case 'ArrowLeft': + index += ltr ? -1 : 1; + break; + case 'ArrowRight': + index += ltr ? 1 : -1; + break; + default: + index += 1; + } + + if (index < 0) index = buttons.length - 1; + if (index > buttons.length - 1) index = 0; + + buttons.forEach((radio) => { + radio.deselect(); + radio.nativeElement.blur(); + }); + + buttons[index].focused = true; + buttons[index].nativeElement.focus(); + buttons[index].select(); + event.preventDefault(); + } + + if (event.key === "Tab") { + buttons.forEach((radio) => { + if (radio !== checked) { + event.stopPropagation(); + } + }); + } + } + + /** + * Returns the alignment of the `igx-radio-group`. + * ```typescript + * @ViewChild("MyRadioGroup") + * public radioGroup: IgxRadioGroupDirective; + * ngAfterViewInit(){ + * let radioAlignment = this.radioGroup.alignment; + * } + * ``` + */ + @Input() + public get alignment(): RadioGroupAlignment { + return this.vertical ? RadioGroupAlignment.vertical : RadioGroupAlignment.horizontal; + } + /** + * Allows you to set the radio group alignment. + * Available options are `RadioGroupAlignment.horizontal` (default) and `RadioGroupAlignment.vertical`. + * ```typescript + * public alignment = RadioGroupAlignment.vertical; + * //.. + * ``` + * ```html + * + * ``` + */ + public set alignment(value: RadioGroupAlignment) { + this.vertical = value === RadioGroupAlignment.vertical; + } + + /** + * @hidden + * @internal + */ + private _onChangeCallback: (_: any) => void = noop; + + /** + * @hidden + * @internal + */ + private _name = `igx-radio-group-${nextId++}`; + + /** + * @hidden + * @internal + */ + private _value: any = null; + + /** + * @hidden + * @internal + */ + private _selected: IgxRadioComponent | null = null; + + /** + * @hidden + * @internal + */ + private _isInitialized = signal(false); + + /** + * @hidden + * @internal + */ + private _required = false; + + /** + * @hidden + * @internal + */ + private _invalid = false; + + /** + * @hidden + * @internal + */ + private destroy$ = new Subject(); + + /** + * @hidden + * @internal + */ + private queryChange$ = new Subject(); + + /** + * @hidden + * @internal + */ + private updateValidityOnBlur() { + this._radioButtons().forEach((button) => { + button.focused = false; + + if (button.invalid) { + this.invalid = true; + } + }); + } + + /** + * @hidden + * @internal + */ + private updateOnKeyUp(event: KeyboardEvent) { + const checked = this._radioButtons().find(x => x.checked); + + if (event.key === "Tab") { + this._radioButtons().forEach((radio) => { + if (radio === checked) { + checked.focused = true; + } + }); + } + } + + public ngDoCheck(): void { + this._updateTabIndex(); + } + + private _updateTabIndex() { + // Needed so that the keyboard navigation of a radio group + // placed inside a dialog works properly + if (this._radioButtons) { + const checked = this._radioButtons().find(x => x.checked); + + if (checked) { + this._radioButtons().forEach((button) => { + checked.nativeElement.tabIndex = 0; + + if (button !== checked) { + button.nativeElement.tabIndex = -1; + button.focused = false; + } + }); + } + } + } + + /** + * Sets the "checked" property value on the radio input element. + * + * @remarks + * Checks whether the provided value is consistent to the current radio button. + * If it is, the checked attribute will have value `true` and selected property will contain the selected `IgxRadioComponent`. + * + * @example + * ```typescript + * this.radioGroup.writeValue('radioButtonValue'); + * ``` + */ + public writeValue(value: any) { + this.value = value; + } + + /** + * Registers a function called when the control value changes. + * + * @hidden + * @internal + */ + public registerOnChange(fn: (_: any) => void) { + this._onChangeCallback = fn; + } + + /** + * Registers a function called when the control is touched. + * + * @hidden + * @internal + */ + public registerOnTouched(fn: () => void) { + if (this._radioButtons) { + this._radioButtons().forEach((button) => { + button.registerOnTouched(fn); + }); + } + } + + /** + * @hidden + * @internal + */ + public ngOnDestroy(): void { + this.destroy$.next(true); + this.destroy$.complete(); + } + + constructor() { + if (this.ngControl !== null) { + this.ngControl.valueAccessor = this; + } + + effect(() => { + this.initialize(); + this.setRadioButtons(); + }); + } + + /** + * @hidden + * @internal + */ + private initialize() { + // The initial value can possibly be set by NgModel and it is possible that + // the OnInit of the NgModel occurs after the OnInit of this class. + this._isInitialized.set(true); + + if (this.ngControl) { + this.ngControl.statusChanges + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.invalid = false; + }); + + if (this.ngControl.control.validator || this.ngControl.control.asyncValidator) { + this._required = this.ngControl?.control?.hasValidator(Validators.required); + } + + this._radioButtons().forEach((button) => { + if (this.ngControl.disabled) { + button.disabled = this.ngControl.disabled; + } + }); + } + } + + /** + * @hidden + * @internal + */ + private setRadioButtons() { + this._radioButtons().forEach((button) => { + Promise.resolve().then(() => { + button.name = this._name; + button.required = this._required; + }); + + if (button.value === this._value) { + button.checked = true; + this._selected = button; + this.cdr.markForCheck(); + } + + this._setRadioButtonEvents(button); + }); + } + + /** + * @hidden + * @internal + */ + private _setRadioButtonEvents(button: any) { + button.change.pipe( + takeUntil(button.destroy$), + takeUntil(this.destroy$), + takeUntil(this.queryChange$) + ).subscribe((ev: IChangeCheckboxEventArgs) => this._selectedRadioButtonChanged(ev)); + + button.blurRadio + .pipe(takeUntil(this.destroy$)) + .subscribe(() => this.updateValidityOnBlur()); + + fromEvent(button.nativeElement, 'keyup') + .pipe(takeUntil(this.destroy$)) + .subscribe((event: KeyboardEvent) => this.updateOnKeyUp(event)); + } + + /** + * @hidden + * @internal + */ + private _selectedRadioButtonChanged(args: IChangeCheckboxEventArgs) { + this._radioButtons().forEach((button) => { + button.checked = button.id === args.owner.id; + if (button.checked && button.ngControl) { + this.invalid = button.ngControl.invalid; + } else if (button.checked) { + this.invalid = false; + } + }); + + this._selected = args.owner; + this._value = args.value; + + if (this._isInitialized) { + this.change.emit(args); + this._onChangeCallback(this.value); + } + } + + /** + * @hidden + * @internal + */ + private _setRadioButtonNames() { + if (this._radioButtons) { + this._radioButtons().forEach((button) => { + button.name = this._name; + }); + } + } + + /** + * @hidden + * @internal + */ + private _selectRadioButton() { + if (this._radioButtons) { + this._radioButtons().forEach((button) => { + if (this._value === null) { + // no value - uncheck all radio buttons + if (button.checked) { + button.checked = false; + } + } else { + if (this._value === button.value) { + // selected button + if (this._selected !== button) { + this._selected = button; + } + + if (!button.checked) { + button.checked = true; + } + } else { + // non-selected button + if (button.checked) { + button.checked = false; + } + } + } + }); + } + } + + /** + * @hidden + * @internal + */ + private _setRadioButtonsRequired() { + if (this._radioButtons) { + this._radioButtons().forEach((button) => { + button.required = this._required; + }); + } + } + + + /** + * Registers a radio button with this radio group. + * This method is called by radio button components when they are created. + * @hidden @internal + */ + public _addRadioButton(radioButton: IgxRadioComponent): void { + this._radioButtons.update(buttons => { + if (!buttons.includes(radioButton)) { + this._setRadioButtonEvents(radioButton); + + return [...buttons, radioButton]; + } + return buttons; + }); + } + + /** + * Unregisters a radio button from this radio group. + * This method is called by radio button components when they are destroyed. + * @hidden @internal + */ + public _removeRadioButton(radioButton: IgxRadioComponent): void { + this._radioButtons.update(buttons => + buttons.filter(btn => btn !== radioButton) + ); + } + + /** + * @hidden + * @internal + */ + private _setRadioButtonsInvalid() { + if (this._radioButtons) { + this._radioButtons().forEach((button) => { + button.invalid = this._invalid; + }); + } + } +} diff --git a/projects/igniteui-angular/radio/src/radio/radio-group/radio-group.module.ts b/projects/igniteui-angular/radio/src/radio/radio-group/radio-group.module.ts new file mode 100644 index 00000000000..a68634e27f9 --- /dev/null +++ b/projects/igniteui-angular/radio/src/radio/radio-group/radio-group.module.ts @@ -0,0 +1,14 @@ +import { NgModule } from '@angular/core'; +import { IgxRadioGroupDirective } from './radio-group.directive'; +import { IgxRadioComponent } from '../radio.component'; + +/** + * @hidden + * @deprecated + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [IgxRadioGroupDirective, IgxRadioComponent], + exports: [IgxRadioGroupDirective, IgxRadioComponent] +}) +export class IgxRadioModule {} diff --git a/projects/igniteui-angular/radio/src/radio/radio.component.html b/projects/igniteui-angular/radio/src/radio/radio.component.html new file mode 100644 index 00000000000..7bf9fb5cb06 --- /dev/null +++ b/projects/igniteui-angular/radio/src/radio/radio.component.html @@ -0,0 +1,28 @@ + + + +
    +
    + + + + diff --git a/projects/igniteui-angular/radio/src/radio/radio.component.spec.ts b/projects/igniteui-angular/radio/src/radio/radio.component.spec.ts new file mode 100644 index 00000000000..cd8447ce251 --- /dev/null +++ b/projects/igniteui-angular/radio/src/radio/radio.component.spec.ts @@ -0,0 +1,393 @@ +import { Component, ViewChild, ViewChildren, inject } from '@angular/core'; +import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { FormsModule, NgForm, ReactiveFormsModule, UntypedFormBuilder, Validators } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { IgxRadioComponent } from './radio.component'; + +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +describe('IgxRadio', () => { + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxRadioComponent, + InitRadioComponent, + DisabledRadioComponent, + RequiredRadioComponent, + RadioFormComponent, + RadioWithModelComponent, + ReactiveFormComponent, + RadioExternalLabelComponent, + RadioInvisibleLabelComponent + ] + }).compileComponents(); + })); + + it('Init a radio', () => { + const fixture = TestBed.createComponent(InitRadioComponent); + const radioInstance = fixture.componentInstance.radio; + fixture.detectChanges(); + + const nativeRadio = radioInstance.nativeInput.nativeElement; + const nativeLabel = radioInstance.nativeLabel.nativeElement; + const placeholderLabel = radioInstance.placeholderLabel.nativeElement; + + expect(nativeRadio).toBeTruthy(); + expect(nativeRadio.type).toBe('radio'); + + expect(nativeLabel).toBeTruthy(); + + expect(placeholderLabel).toBeTruthy(); + expect(placeholderLabel.textContent.trim()).toEqual('Radio'); + }); + + it('Init a radio with id property', () => { + const fixture = TestBed.createComponent(InitRadioComponent); + fixture.detectChanges(); + + const radio = fixture.componentInstance.radio; + const domRadio = fixture.debugElement.query(By.css('igx-radio')).nativeElement; + + expect(radio.id).toContain('igx-checkbox-'); + expect(domRadio.id).toContain('igx-checkbox-'); + + radio.id = 'customRadio'; + fixture.detectChanges(); + expect(radio.id).toBe('customRadio'); + expect(domRadio.id).toBe('customRadio'); + }); + + it('Binding to ngModel', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioWithModelComponent); + fixture.detectChanges(); + + const radios = fixture.componentInstance.radios.toArray(); + expect(radios.length).toEqual(3); + + // Change the model to change + // the selected radio button in the UI + fixture.componentInstance.selected = 'Baz'; + fixture.detectChanges(); + tick(); + + fixture.detectChanges(); + expect(radios[2].checked).toBe(true); + + // Change the model through UI interaction + // with the native label element + radios[0].nativeLabel.nativeElement.click(); + fixture.detectChanges(); + tick(); + + expect(radios[0].checked).toBe(true); + expect(fixture.componentInstance.selected).toEqual('Foo'); + + // Change the model through UI interaction + // with the placeholder label element + radios[1].placeholderLabel.nativeElement.click(); + fixture.detectChanges(); + tick(); + + expect(radios[1].checked).toBe(true); + expect(fixture.componentInstance.selected).toEqual('Bar'); + })); + + it('Positions label before and after radio button', () => { + const fixture = TestBed.createComponent(InitRadioComponent); + const radioInstance = fixture.componentInstance.radio; + const placeholderLabel = radioInstance.placeholderLabel.nativeElement; + const labelStyles = window.getComputedStyle(placeholderLabel); + fixture.detectChanges(); + + expect(labelStyles.order).toEqual('0'); + + radioInstance.labelPosition = 'before'; + fixture.detectChanges(); + + expect(labelStyles.order).toEqual('-1'); + }); + + it('Initializes with external label', () => { + const fixture = TestBed.createComponent(RadioExternalLabelComponent); + const radioInstance = fixture.componentInstance.radio; + const nativeRadio = radioInstance.nativeInput.nativeElement; + const externalLabel = fixture.debugElement.query(By.css('#my-label')).nativeElement; + fixture.detectChanges(); + + expect(nativeRadio.getAttribute('aria-labelledby')).toMatch(externalLabel.getAttribute('id')); + expect(externalLabel.textContent).toMatch(fixture.componentInstance.label); + }); + + it('Initializes with invisible label', () => { + const fixture = TestBed.createComponent(RadioInvisibleLabelComponent); + const radioInstance = fixture.componentInstance.radio; + const nativeRadio = radioInstance.nativeInput.nativeElement; + fixture.detectChanges(); + + expect(nativeRadio.getAttribute('aria-label')).toMatch(fixture.componentInstance.label); + // aria-labelledby should not be present when aria-label is + expect(nativeRadio.getAttribute('aria-labelledby')).toEqual(null); + }); + + it('Disabled state', fakeAsync(() => { + const fixture = TestBed.createComponent(DisabledRadioComponent); + // Requires two async change detection cycles to setup disabled on the component and then native element + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + tick(); + const testInstance = fixture.componentInstance; + + // get the disabled radio button + const componentInstance = testInstance.radios.last as IgxRadioComponent; + const radio = componentInstance.nativeInput.nativeElement as HTMLInputElement; + + expect(componentInstance.disabled).toBe(true); + expect(radio.disabled).toBe(true); + + radio.click(); + fixture.detectChanges(); + + // Should not update + expect(componentInstance.nativeInput.nativeElement.checked).toBe(false); + expect(radio.checked).toBe(false); + expect(testInstance.selected).not.toEqual('Bar'); + })); + + it('Required state', () => { + const fixture = TestBed.createComponent(RequiredRadioComponent); + fixture.detectChanges(); + + const testInstance = fixture.componentInstance; + const radios = testInstance.radios.toArray(); + + // get the required radio button + const radioInstance = radios[1]; + const nativeRadio = radioInstance.nativeInput.nativeElement; + + expect(radioInstance.required).toBe(true); + expect(nativeRadio.required).toBe(true); + }); + + it('Should update style when required radio\'s value is set.', () => { + const fixture = TestBed.createComponent(RequiredRadioComponent); + fixture.detectChanges(); + + // Get the required radio button + const testInstance = fixture.componentInstance; + const radios = testInstance.radios.toArray(); + const radioInstance = radios[0]; + const domRadio = fixture.debugElement.query(By.css('igx-radio')).nativeElement; + + expect(domRadio.classList.contains('igx-radio--invalid')).toBe(false); + expect(radioInstance.invalid).toBe(false); + expect(radioInstance.checked).toBe(false); + + dispatchRadioEvent('keyup', domRadio, fixture); + expect(domRadio.classList.contains('igx-radio--focused')).toBe(true); + dispatchRadioEvent('blur', domRadio, fixture); + + expect(radioInstance.invalid).toBe(true); + expect(domRadio.classList.contains('igx-radio--invalid')).toBe(true); + + dispatchRadioEvent('keyup', domRadio, fixture); + expect(domRadio.classList.contains('igx-radio--focused')).toBe(true); + + radioInstance.select(); + fixture.detectChanges(); + + expect(domRadio.classList.contains('igx-radio--checked')).toBe(true); + expect(radioInstance.checked).toBe(true); + expect(radioInstance.invalid).toBe(false); + expect(domRadio.classList.contains('igx-radio--invalid')).toBe(false); + + radioInstance.deselect(); + fixture.detectChanges(); + + expect(radioInstance.checked).toBe(false); + }); + + it('Should work properly with ngModel', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioFormComponent); + fixture.detectChanges(); + tick(); + + const radioInstance = fixture.componentInstance.radio; + expect(radioInstance.invalid).toEqual(false); + + radioInstance.onBlur(); + expect(radioInstance.invalid).toEqual(true); + + fixture.componentInstance.ngForm.resetForm(); + tick(); + expect(radioInstance.invalid).toEqual(false); + })); + + it('Should work properly with reactive forms validation.', () => { + const fixture = TestBed.createComponent(ReactiveFormComponent); + fixture.detectChanges(); + + const radio = fixture.componentInstance.radio; + radio.checked = false; + expect(radio.required).toBe(true); + expect(radio.nativeElement.getAttribute('aria-required')).toEqual('true'); + expect(radio.nativeElement.getAttribute('aria-invalid')).toEqual('false'); + + fixture.debugElement.componentInstance.markAsTouched(); + fixture.detectChanges(); + + const invalidRadio = fixture.debugElement.nativeElement.querySelectorAll(`.igx-radio--invalid`); + expect(invalidRadio.length).toBe(1); + expect(radio.invalid).toBe(true); + expect(radio.nativeElement.getAttribute('aria-invalid')).toEqual('true'); + }); + + describe('EditorProvider', () => { + it('Should return correct edit element', () => { + const fixture = TestBed.createComponent(InitRadioComponent); + fixture.detectChanges(); + + const radioInstance = fixture.componentInstance.radio; + const editElement = fixture.debugElement.query(By.css('.igx-radio__input')).nativeElement; + + expect(radioInstance.getEditElement()).toBe(editElement); + }); + }); +}); + +@Component({ + template: `Radio`, + imports: [IgxRadioComponent] +}) +class InitRadioComponent { + @ViewChild('radio', { static: true }) public radio: IgxRadioComponent; +} + +@Component({ + template: ` + @for (item of ['Foo', 'Bar', 'Baz']; track item) { + {{item}} + }`, + imports: [FormsModule, IgxRadioComponent] +}) +class RadioWithModelComponent { + @ViewChildren(IgxRadioComponent) public radios; + + public selected = 'Foo'; +} + +@Component({ + template: ` + @for (item of items; track item.value) { + + {{item.value}} + + }`, + imports: [FormsModule, IgxRadioComponent] +}) +class DisabledRadioComponent { + @ViewChildren(IgxRadioComponent) public radios; + + public items = [{ + value: 'Foo', + disabled: false + }, { + value: 'Bar', + disabled: true + }]; + + public selected = 'Foo'; +} + +@Component({ + template: ` + @for (item of ['Foo', 'Bar']; track item) { + + {{item}} + + }`, + imports: [FormsModule, IgxRadioComponent] +}) +class RequiredRadioComponent { + @ViewChildren(IgxRadioComponent) public radios; +} + +@Component({ + template: `

    {{label}}

    + `, + imports: [IgxRadioComponent] +}) +class RadioExternalLabelComponent { + @ViewChild('radio', { static: true }) public radio: IgxRadioComponent; + public label = 'My Label'; +} + +@Component({ + template: ``, + imports: [IgxRadioComponent] +}) +class RadioInvisibleLabelComponent { + @ViewChild('radio', { static: true }) public radio: IgxRadioComponent; + public label = 'Invisible Label'; +} + +@Component({ + template: ` +
    + Option 1 + + +`, + imports: [FormsModule, IgxRadioComponent] +}) +class RadioFormComponent { + @ViewChild('radioInForm', { read: IgxRadioComponent, static: true }) + public radio: IgxRadioComponent; + @ViewChild(NgForm, { static: true }) + public ngForm: NgForm; + + public isRequired = true; + public selected: string; +} + +@Component({ + template: `
    + Radio + `, + imports: [ReactiveFormsModule, IgxRadioComponent] +}) +class ReactiveFormComponent { + private fb = inject(UntypedFormBuilder); + + @ViewChild('radio', { read: IgxRadioComponent, static: true }) + public radio: IgxRadioComponent; + + public reactiveForm = this.fb.group({ + radio: ['', Validators.required], + }); + + public markAsTouched() { + if (!this.reactiveForm.valid) { + for (const key in this.reactiveForm.controls) { + if (this.reactiveForm.controls[key]) { + this.reactiveForm.controls[key].markAsTouched(); + this.reactiveForm.controls[key].updateValueAndValidity(); + } + } + } + } +} + +const dispatchRadioEvent = (eventName, radioNativeElement, fixture) => { + radioNativeElement.dispatchEvent(new Event(eventName)); + fixture.detectChanges(); +}; diff --git a/projects/igniteui-angular/radio/src/radio/radio.component.ts b/projects/igniteui-angular/radio/src/radio/radio.component.ts new file mode 100644 index 00000000000..69b725fead3 --- /dev/null +++ b/projects/igniteui-angular/radio/src/radio/radio.component.ts @@ -0,0 +1,231 @@ +import { + AfterViewInit, + Component, + EventEmitter, + HostBinding, + HostListener, + Input, + booleanAttribute, + OnDestroy, + inject +} from '@angular/core'; +import { ControlValueAccessor } from '@angular/forms'; +import { EditorProvider, EDITOR_PROVIDER } from 'igniteui-angular/core'; +import { CheckboxBaseDirective, IgxRippleDirective, IChangeCheckboxEventArgs } from 'igniteui-angular/directives'; +import { IgxRadioGroupDirective } from './radio-group/radio-group.directive'; + +/** + * **Ignite UI for Angular Radio Button** - + * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/radio_button.html) + * + * The Ignite UI Radio Button allows the user to select a single option from an available set of options that are listed side by side. + * + * Example: + * ```html + * + * Simple radio button + * + * ``` + */ +@Component({ + selector: 'igx-radio', + providers: [{ + provide: EDITOR_PROVIDER, + useExisting: IgxRadioComponent, + multi: true + }], + templateUrl: 'radio.component.html', + imports: [IgxRippleDirective] +}) +export class IgxRadioComponent + extends CheckboxBaseDirective + implements AfterViewInit, OnDestroy, ControlValueAccessor, EditorProvider { + /** @hidden @internal */ + public blurRadio = new EventEmitter(); + + private radioGroup = inject(IgxRadioGroupDirective, { optional: true, skipSelf: true }); + + /** + * Returns the class of the radio component. + * ```typescript + * let radioClass = this.radio.cssClass; + * ``` + * + * @memberof IgxRadioComponent + */ + @HostBinding('class.igx-radio') + public override cssClass = 'igx-radio'; + + /** + * Sets/gets the `checked` attribute. + * Default value is `false`. + * ```html + * + * ``` + * ```typescript + * let isChecked = this.radio.checked; + * ``` + * + * @memberof IgxRadioComponent + */ + @HostBinding('class.igx-radio--checked') + @Input({ transform: booleanAttribute }) + public override set checked(value: boolean) { + this._checked = value; + } + public override get checked() { + return this._checked; + } + + /** + * Sets/gets the `disabled` attribute. + * Default value is `false`. + * ```html + * + * ``` + * ```typescript + * let isDisabled = this.radio.disabled; + * ``` + * + * @memberof IgxRadioComponent + */ + @HostBinding('class.igx-radio--disabled') + @Input({ transform: booleanAttribute }) + public override disabled = false; + + /** + * Sets/gets whether the radio button is invalid. + * Default value is `false`. + * ```html + * + * ``` + * ```typescript + * let isInvalid = this.radio.invalid; + * ``` + * + * @memberof IgxRadioComponent + */ + @HostBinding('class.igx-radio--invalid') + @Input({ transform: booleanAttribute }) + public override invalid = false; + + /** + * Sets/gets whether the radio component is on focus. + * Default value is `false`. + * ```typescript + * this.radio.focus = true; + * ``` + * ```typescript + * let isFocused = this.radio.focused; + * ``` + * + * @memberof IgxRadioComponent + */ + @HostBinding('class.igx-radio--focused') + public override focused = false; + + /** + * @hidden + * @internal + */ + @HostListener('change', ['$event']) + public _changed(event: IChangeCheckboxEventArgs) { + if (event instanceof Event) { + event.preventDefault(); + } + } + + /** + * @hidden + */ + @HostListener('click') + public override _onCheckboxClick() { + this.select(); + } + + /** + * Selects the current radio button. + * ```typescript + * this.radio.select(); + * ``` + * + * @memberof IgxRadioComponent + */ + public select() { + if (!this.checked) { + this.checked = true; + this.change.emit({ + value: this.value, + owner: this, + checked: this.checked, + }); + this._onChangeCallback(this.value); + } + } + + /** + * Deselects the current radio button. + * ```typescript + * this.radio.deselect(); + * ``` + * + * @memberof IgxRadioComponent + */ + public deselect() { + this.checked = false; + this.focused = false; + this.cdr.markForCheck(); + } + + /** + * Checks whether the provided value is consistent to the current radio button. + * If it is, the checked attribute will have value `true`; + * ```typescript + * this.radio.writeValue('radioButtonValue'); + * ``` + */ + public override writeValue(value: any) { + this.value = this.value ?? value; + + if (value === this.value) { + if (!this.checked) { + this.checked = true; + } + } else { + this.deselect(); + } + } + + /** + * @hidden + */ + @HostListener('blur') + public override onBlur() { + super.onBlur(); + this.blurRadio.emit(); + } + + /** + * @hidden + * @internal + */ + public override ngAfterViewInit(): void { + super.ngAfterViewInit(); + + // Register with parent radio group if it exists + if (this.radioGroup) { + this.radioGroup._addRadioButton(this); + } + } + + /** + * @hidden + * @internal + */ + public ngOnDestroy(): void { + // Unregister from parent radio group if it exists + if (this.radioGroup) { + this.radioGroup._removeRadioButton(this); + } + } +} diff --git a/projects/igniteui-angular/select/README.md b/projects/igniteui-angular/select/README.md new file mode 100644 index 00000000000..0743d29e2a2 --- /dev/null +++ b/projects/igniteui-angular/select/README.md @@ -0,0 +1,309 @@ +# igx-select +The `IgxSelectComponent` allows you to select a single item from a drop-down list, by using the mouse or the keyboard to quickly navigate through the items. Using the `igxSelect` you can also iterate selection through all items based on the input of a specific character or multiple characters. + +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/select.html) + +# Usage +Basic use of `igx-select` setting the items declaratively: + +```html + + Sofia + London + Paris + New York + +``` + +`igx-select` can also be put inside of a `form` element and in order to do that, you first have to create the template for your control and add the items that it will display: + +```html + + Orange + Apple + Banana + +``` + +Another way to do it would be to simply pass in an array of the items that we want to display to the [*ngForOf*](https://angular.io/api/common/NgForOf) directive: +```html + + + {{fruit}} + + +``` + +Since we are using two-way binding, your class should look something like this: +```ts +export class MyClass { + public fruit: string = "Apple"; +} +``` + +`igx-select` supports prefixes, suffixes, label, hint and placeholder. You can read more about them [*here*](https://www.infragistics.com/products/ignite-ui-angular/angular/components/input-group) AND [*here*](https://www.infragistics.com/products/ignite-ui-angular/angular/components/label-input). +- The items' list default exapansion panel arrow uses `IgxSuffix` and it can be changed by the user. +- If more than one `IgxSuffix` is used, the expansion arrow will be displayed always last. + + +## Features + +### Value +Sets/Gets the IgxSelect value. +```html + + + {{fruit}} + + +``` +
    + +### ngModel +Use the component inside form using ngModel. +```html + + Orange + Apple + Banana + +``` +
    + +### Disabled +You can disable select using the following code: + +```html + +``` +
    + + +### OverlaySettings +It is possible to pass custom overlay setting to override the default select overlay settings. +With `igx-select` you are not bound to use any of the [*OverlaySettings*](https://www.infragistics.com/products/ignite-ui-angular/docs/typescript/interfaces/overlaysettings.html) that we provide, instead you may create settings of your own and pass them to it. + +To do this you first define your template like so: +```html + + + {{item}} + + +``` +Where the `overlaySettings` propety is bound to your custom settings. +Inside of your class you would have something along the lines of: +```ts +export class MyClass implements OnInit { + @ViewChild(IgxSelectComponent) + public igxSelect: IgxSelectComponent; + public items: string[] = ["Orange", "Apple", "Banana", "Mango", "Tomato"]; + public customOverlaySettings: OverlaySettings; + public ngOnInit(): void { + const positionSettings: PositionSettings = { + closeAnimation: slideOutRight, + horizontalDirection: HorizontalAlignment.Right, + horizontalStartPoint: HorizontalAlignment.Left, + openAnimation: slideInLeft, + target: this.igxSelect.inputGroup.element.nativeElement, + verticalDirection: VerticalAlignment.Bottom, + verticalStartPoint: VerticalAlignment.Bottom + }; + this.customOverlaySettings = { + closeOnOutsideClick: false, + modal: true, + positionStrategy: new ConnectedPositioningStrategy( + positionSettings + ), + scrollStrategy: new AbsoluteScrollStrategy() + }; + } +} +``` + +### Type +Sets Input Group style type. Choose from `line`, `box` or `border`. + +```html + + Orange + Apple + Banana + +``` + +### Placeholder +Sets the select placeholder, to be displayed if no selection/value is set. +```html + + Orange + Apple + Banana + +``` + +### Templates +Templates for different parts of the control can be defined, including header and footer, toggle icon, etc. + +#### Defining header template: + +```html + + +
    Custom Header
    +
    +
    +``` + +#### Defining footer template: + +```html + + + + + +``` + +#### Defining toggle icon template: +```ts +const myCustomTemplate: TemplateRef = myComponent.customTemplate; +myComponent.select.toggleIconTemplate = myCustomTemplate; +``` + +```html + + + ... + + {{ collapsed ? 'remove_circle' : 'remove_circle_outline'}} + + +``` +
    + +## Keyboard Navigation + +* Dropdown list gets displayed when: + * input field is clicked + * dropdown button is clicked + * up/down arrow + ALT keys are pressed + * ENTER key is pressed when select is active + * SPACE key is pressed when select is active + * using API open()/toggle() methods + +* When opened the dropdown list can be closed by: + * click on an item of the dropdown list + * pressing up/down arrow + ALT keys + * pressing ENTER key + * pressing SPACE key + * pressing ESC key + * clicking outside the dropdown list + * dropdown button is clicked again + * using API close()/toggle() methods + +* When no select-items are declared, there is no items container displayed. +![](https://i.ibb.co/nm8PVHN/no-items.png) + +* Opening/closing events are emitted on input click. +* Closing events are emitted on item click. +* Opening/closing events are emitted on toggle button click. +* Opening/closing events are triggered on key interaction. +* Closing events are emitted on clicking outside the component(input blur). +* When dropdown list is opened, items are navigable with Home, End and arrow keys. +* When dropdown list is opened, items are navigable with Up/Down arrow keys until there are list items and selection is not wrapped. +* When dropdown list is opened, navigation with Up/Down arrow starts from the selected item if any or first list item otherwise. +* When dropdown list is opened, navigation with Up/Down arrow keys skips disabled items. +* When Dropdown list is opened, pressing character key/s iteratively navigates through all item values that start with the corresponding character +* Character key navigation when dropdown is opened is case insensitive +* Character key navigation when dropdown is opened wraps selection +* When Dropdown list is opened, pressing foreign character key/s iteratively navigates through all item values that start with the corresponding character +* Character key navigation when dropdown is opened does not change focus on pressing non-matching characters +* When Dropdown list is closed, interaction with Up/Down arrow keys navigates through items selecting the current one until there are list items and selection is not wrapped. +* When dropdown list is closed, navigation with Up/Down arrow starts from the selected item if any or first list item otherwise. +* When dropdown list is closed, navigation with Up/Down arrow keys skips and does not select disabled items. +* In case there are is an item with no value set, it will be possible to navigate with Up/Down arrow keys trough it when the select is in collapsed state(clearing input value). +* When Dropdown list is closed, pressing character key/s iteratively selects through all item values that start with the corresponding character +* Character key navigation when dropdown is closed is case insensitive +* Character key navigation when dropdown is closed wraps selection +* When Dropdown list is closed, pressing foreign character key/s iteratively selects through all item values that start with the corresponding character +* Character key navigation when dropdown is closed does not change selection on pressing non-matching characters + +* An item from the dropdown list can be selected by: + * mouse click + * ENTER key when item is focused + * SPACE key when item is focused + * setting the value property in code + * setting item's selected property + * using the API selectItem() method +* The igxSelect allows single-selection only +* First item in the dropdown list is focused if there is not a selected item. +* The input box is populated with the selected item value +* The input box text is updated when the selected option text is changed +* The input box is not populated with the text of an item that is focused but not selected +* No text is appended to the input box when no item is selected and value is not set or does not match any item +* Selection is unchanged when setting the value property to non-existing item value +* Disabled items are not selectable +* Selection is removed if selected option has been deleted +* When value is set to the value of duplicated items, the first one gets selected +* selectionChanging event is emitted on item selection by mouse click +* selectionChanging event is emitted on item selection by ENTER/SPACE key +* selectionChanging event is emitted on setting the value property +* selectionChanging event is emitted on item selection using the API selectItem() method +* selectionChanging event is emitted on setting item's selected property +* The component renders all aria attributes properly +* All aria attributes of the dropdown items are set properly +* Selected item is displayed over the input when there is enough space above and below the input. +* The component scrolls to the selected item and displays it over the input when there is enough space above and below the input. +* When there is some space above the input for one/several items to be displayed and first item is selected, the list displays starting from the input top left point so that the selected item is over the input. +* When there is some space above the input for one/several items to be displayed and the selected item is in the middle of the list, the list displays above as many items as possible so that the selected item is over the input. +* When there is some space above the input for the dropdown list to be displayed and one of the last items is selected, the dropdown is displayed over the input so that it starts from its top left point and the selected item is visible. +* When there is some space below the input for one/several items to be displayed and last item is selected, the list displays starting from the input bottom left point so that the selected item is over the input. +* When there is some space below the input for one/several items to be displayed and the selected item is in the middle of the list, the list displays above and below as many items as possible so that the selected item is over the input. +* When there is some space below the input for the dropdown list to be displayed and first item in list is selected, the dropdown is displayed over the input so that it starts from its bottom left point and the selected item is visible. +* The items list default expansion arrow uses IgxSuffix and can be changed by the user. +* If more then one IgxSuffix is used, the expansion arrow will be displayed always as last. + + +## API +### Properties + + `IgxSelectComponent` + + | Name | Description | Type | + |-----------------|---------------------------------------------------|-------------------------------------| + | value | Sets/Gets the IgxSelect value. | any | + | collapsed | Gets if the IgxSelect is collapsed. | boolean | + | overlaySettings | Sets optional overlay settings. | overlaySettings | + | disabled | Sets/Gets if the IgxSelect is disabled. | boolean | + | type | Sets Input Group style type. | string / `line`, `box` or `border` | + | placeholder | Sets the Select placeholder. | string | + + + `IgxSelectItemComponent` + + | Name | Description | Type | + |-----------------|---------------------------------------------------------------------|----------------| + | value | The item value. | any | + | selected | Sets/Gets if the item is the currently selected one in the dropdown | boolean | + | disabled | Sets/Gets if the given item is disabled | boolean | + +### Methods +`IgxSelectComponent` + + | Name | Description | Parameters | + |-----------------|----------------------------|-------------------------| + | toggle | Toggles the IgxSelect. | overlaySettings? | + | open | Opens the IgxSelect. | overlaySettings? | + | close | Closes the IgxSelect. | none | + +### Events +`IgxSelectComponent` + + | Name | Description | Cancelable | Parameters | + |-----------|-------------------------------------------------------------------------|------------|----------------------------------| + | selecting | Emitted when item selection is changing, before the selection completes | true | `ISelectionEventArgs` | + | opening | Emitted before the IgxSelect is opened. | true | `IBaseCancelableBrowserEventArgs`| + | opened | Emitted after the IgxSelect is opened. | false | `IBaseEventArgs` | + | closing | Emitted before the IgxSelect is closed. | true | `IBaseCancelableBrowserEventArgs`| + | closed | Emitted after the IgxSelect is closed. | false | `IBaseEventArgs` | diff --git a/projects/igniteui-angular/select/index.ts b/projects/igniteui-angular/select/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/select/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/select/ng-package.json b/projects/igniteui-angular/select/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/select/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/select/src/public_api.ts b/projects/igniteui-angular/select/src/public_api.ts new file mode 100644 index 00000000000..59344209690 --- /dev/null +++ b/projects/igniteui-angular/select/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './select/public_api'; +export * from './select/select.module'; diff --git a/projects/igniteui-angular/select/src/select/public_api.ts b/projects/igniteui-angular/select/src/select/public_api.ts new file mode 100644 index 00000000000..07d7e47894a --- /dev/null +++ b/projects/igniteui-angular/select/src/select/public_api.ts @@ -0,0 +1,22 @@ +import { IgxHintDirective, IgxLabelDirective, IgxPrefixDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; +import { IgxSelectGroupComponent } from './select-group.component'; +import { IgxSelectItemComponent } from './select-item.component'; +import { IgxSelectComponent, IgxSelectFooterDirective, IgxSelectHeaderDirective, IgxSelectToggleIconDirective } from './select.component'; + +export * from './select-group.component'; +export * from './select-item.component'; +export * from './select.component'; + +/* NOTE: Select directives collection for ease-of-use import in standalone components scenario */ +export const IGX_SELECT_DIRECTIVES = [ + IgxSelectComponent, + IgxSelectItemComponent, + IgxSelectGroupComponent, + IgxSelectHeaderDirective, + IgxSelectFooterDirective, + IgxSelectToggleIconDirective, + IgxLabelDirective, + IgxPrefixDirective, + IgxSuffixDirective, + IgxHintDirective +] as const; diff --git a/projects/igniteui-angular/select/src/select/select-group.component.ts b/projects/igniteui-angular/select/src/select/select-group.component.ts new file mode 100644 index 00000000000..e6445486747 --- /dev/null +++ b/projects/igniteui-angular/select/src/select/select-group.component.ts @@ -0,0 +1,17 @@ +import { Component } from '@angular/core'; +import { IgxDropDownGroupComponent } from 'igniteui-angular/drop-down'; + +/** + * The `` is a container intended for row items in + * a `` container. + */ +@Component({ + selector: 'igx-select-item-group', + template: ` + + + `, + standalone: true +}) +export class IgxSelectGroupComponent extends IgxDropDownGroupComponent { +} diff --git a/projects/igniteui-angular/select/src/select/select-item.component.html b/projects/igniteui-angular/select/src/select/select-item.component.html new file mode 100644 index 00000000000..7db5ac63ea5 --- /dev/null +++ b/projects/igniteui-angular/select/src/select/select-item.component.html @@ -0,0 +1,5 @@ + + + + + diff --git a/projects/igniteui-angular/select/src/select/select-item.component.ts b/projects/igniteui-angular/select/src/select/select-item.component.ts new file mode 100644 index 00000000000..ea85e176f47 --- /dev/null +++ b/projects/igniteui-angular/select/src/select/select-item.component.ts @@ -0,0 +1,64 @@ +import { Component, Input } from '@angular/core'; +import { IgxDropDownItemComponent } from 'igniteui-angular/drop-down'; + +@Component({ + selector: 'igx-select-item', + templateUrl: 'select-item.component.html', + standalone: true +}) +export class IgxSelectItemComponent extends IgxDropDownItemComponent { + /** @hidden @internal */ + public override isHeader: boolean; + + private _text: any; + + /** + * Gets/Sets the item's text to be displayed in the select component's input when the item is selected. + * + * ```typescript + * //get + * let mySelectedItem = this.dropDown.selectedItem; + * let selectedItemText = mySelectedItem.text; + * ``` + * + * ```html + * // set + * + * ``` + */ + @Input() + public get text(): string { + return this._text; + } + + public set text(text: string) { + this._text = text; + } + + /** @hidden @internal */ + public get itemText() { + if (this._text !== undefined) { + return this._text; + } + // If text @Input is undefined, try extract a meaningful item text out of the item template + return this.elementRef.nativeElement.textContent.trim(); + } + + /** + * Sets/Gets if the item is the currently selected one in the select + * + * ```typescript + * let mySelectedItem = this.select.selectedItem; + * let isMyItemSelected = mySelectedItem.selected; // true + * ``` + */ + public override get selected() { + return !this.isHeader && !this.disabled && this.selection.is_item_selected(this.dropDown.id, this); + } + + public override set selected(value: any) { + if (value && !this.isHeader && !this.disabled) { + this.dropDown.selectItem(this); + } + } +} diff --git a/projects/igniteui-angular/select/src/select/select-navigation.directive.ts b/projects/igniteui-angular/select/src/select/select-navigation.directive.ts new file mode 100644 index 00000000000..cb32883cdb1 --- /dev/null +++ b/projects/igniteui-angular/select/src/select/select-navigation.directive.ts @@ -0,0 +1,130 @@ +import { Directive, Input, OnDestroy } from '@angular/core'; +import { Subscription, timer } from 'rxjs'; +import { IgxSelectItemComponent } from './select-item.component'; +import { IgxSelectBase } from './select.common'; +import { IgxDropDownItemNavigationDirective } from 'igniteui-angular/drop-down'; + +/** @hidden @internal */ +@Directive({ + selector: '[igxSelectItemNavigation]', + standalone: true +}) +export class IgxSelectItemNavigationDirective extends IgxDropDownItemNavigationDirective implements OnDestroy { + protected override _target: IgxSelectBase = null; + + @Input('igxSelectItemNavigation') + public override get target(): IgxSelectBase { + return this._target; + } + public override set target(target: IgxSelectBase) { + this._target = target ? target : this.dropdown as IgxSelectBase; + } + + /** Captures keydown events and calls the appropriate handlers on the target component */ + public override handleKeyDown(event: KeyboardEvent) { + if (!event) { + return; + } + + const key = event.key.toLowerCase(); + if (event.altKey && (key === 'arrowdown' || key === 'arrowup' || key === 'down' || key === 'up')) { + this.target.toggle(); + return; + } + + if (this.target.collapsed) { + switch (key) { + case 'space': + case 'spacebar': + case ' ': + case 'enter': + event.preventDefault(); + this.target.open(); + return; + case 'arrowdown': + case 'down': + this.target.navigateNext(); + this.target.selectItem(this.target.focusedItem); + event.preventDefault(); + return; + case 'arrowup': + case 'up': + this.target.navigatePrev(); + this.target.selectItem(this.target.focusedItem); + event.preventDefault(); + return; + default: + break; + } + } else if (key === 'tab' || event.shiftKey && key === 'tab') { + this.target.close(); + } + + super.handleKeyDown(event); + this.captureKey(event); + } + + private inputStream = ''; + private clearStream$ = Subscription.EMPTY; + + public captureKey(event: KeyboardEvent) { + // relying only on key, available on all major browsers: + // https://caniuse.com/#feat=keyboardevent-key (IE/Edge quirk doesn't affect letter typing) + if (!event || !event.key || event.key.length > 1 || event.key === ' ' || event.key === 'spacebar') { + // ignore longer keys ('Alt', 'ArrowDown', etc) AND spacebar (used of open/close) + return; + } + + this.clearStream$.unsubscribe(); + this.clearStream$ = timer(500).subscribe(() => { + this.inputStream = ''; + }); + + this.inputStream += event.key; + const focusedItem = this.target.focusedItem as IgxSelectItemComponent; + + // select the item + if (focusedItem && this.inputStream.length > 1 && focusedItem.itemText.toLowerCase().startsWith(this.inputStream.toLowerCase())) { + return; + } + this.activateItemByText(this.inputStream); + } + + public activateItemByText(text: string) { + const items = this.target.items as IgxSelectItemComponent[]; + + // ^ this is focused OR selected if the dd is closed + + let nextItem = this.findNextItem(items, text); + + // If there is no such an item starting with the current text input stream AND the last Char in the input stream + // is the same as the first one, find next item starting with the same first Char. + // Covers cases of holding down the same key Ex: "pppppp" that iterates trough list items starting with "p". + if (!nextItem && text.charAt(0) === text.charAt(text.length - 1)) { + text = text.slice(0, 1); + nextItem = this.findNextItem(items, text); + } + + // If there is no other item to be found, do not change the active item. + if (!nextItem) { + return; + } + + if (this.target.collapsed) { + this.target.selectItem(nextItem); + } + this.target.navigateItem(items.indexOf(nextItem)); + } + + public ngOnDestroy(): void { + this.clearStream$.unsubscribe(); + } + + private findNextItem(items: IgxSelectItemComponent[], text: string) { + const activeItemIndex = items.indexOf(this.target.focusedItem as IgxSelectItemComponent) || 0; + + // Match next item in ddl items and wrap around if needed + return items.slice(activeItemIndex + 1).find(x => !x.disabled && (x.itemText.toLowerCase().startsWith(text.toLowerCase()))) || + items.slice(0, activeItemIndex).find(x => !x.disabled && (x.itemText.toLowerCase().startsWith(text.toLowerCase()))); + } +} diff --git a/projects/igniteui-angular/select/src/select/select-positioning-strategy.ts b/projects/igniteui-angular/select/src/select/select-positioning-strategy.ts new file mode 100644 index 00000000000..8faa5e08f88 --- /dev/null +++ b/projects/igniteui-angular/select/src/select/select-positioning-strategy.ts @@ -0,0 +1,231 @@ +import { VerticalAlignment, HorizontalAlignment, PositionSettings, ConnectedFit, Point, Size, BaseFitPositionStrategy, Util } from 'igniteui-angular/core'; +import { IPositionStrategy } from 'igniteui-angular/core'; + +import { IgxSelectBase } from './select.common'; +import { PlatformUtil } from 'igniteui-angular/core'; +import { Optional } from '@angular/core'; +import { fadeIn, fadeOut } from 'igniteui-angular/animations'; + +/** @hidden @internal */ +export class SelectPositioningStrategy extends BaseFitPositionStrategy implements IPositionStrategy { + private _selectDefaultSettings = { + horizontalDirection: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Bottom, + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Top, + openAnimation: fadeIn, + closeAnimation: fadeOut + }; + + // Global variables required for cases of !initialCall (page scroll/overlay repositionAll) + private global_yOffset = 0; + private global_xOffset = 0; + private global_styles: SelectStyles = {}; + + constructor(public select: IgxSelectBase, settings?: PositionSettings, @Optional() protected platform?: PlatformUtil) { + super(); + this.settings = Object.assign({}, this._selectDefaultSettings, settings); + } + + /** + * Position the element based on the PositionStrategy implementing this interface. + * + * @param contentElement The HTML element to be positioned + * @param size Size of the element + * @param document reference to the Document object + * @param initialCall should be true if this is the initial call to the method + * @param target attaching target for the component to show + * ```typescript + * settings.positionStrategy.position(content, size, document, true); + * ``` + */ + public override position(contentElement: HTMLElement, + size: Size, + document?: Document, + initialCall?: boolean, + target?: Point | HTMLElement): void { + const targetElement = target; + const rects = super.calculateElementRectangles(contentElement, targetElement); + // selectFit obj, to be used for both cases of initialCall and !initialCall(page scroll/overlay repositionAll) + const selectFit: SelectFit = { + verticalOffset: this.global_yOffset, + horizontalOffset: this.global_xOffset, + targetRect: rects.targetRect, + contentElementRect: rects.elementRect, + styles: this.global_styles, + scrollContainer: this.select.scrollContainer, + scrollContainerRect: this.select.scrollContainer.getBoundingClientRect() + }; + + if (initialCall) { + this.select.scrollContainer.scrollTop = 0; + // Fill in the required selectFit object properties. + selectFit.viewPortRect = Util.getViewportRect(document); + selectFit.itemElement = this.getInteractionItemElement(); + selectFit.itemRect = selectFit.itemElement.getBoundingClientRect(); + + // Calculate input and selected item elements style related variables + selectFit.styles = this.calculateStyles(selectFit, targetElement); + + selectFit.scrollAmount = this.calculateScrollAmount(selectFit); + // Calculate how much to offset the overlay container. + this.calculateYoffset(selectFit); + this.calculateXoffset(selectFit); + + super.updateViewPortFit(selectFit); + // container does not fit in viewPort and is out on Top or Bottom + if (selectFit.fitVertical.back < 0 || selectFit.fitVertical.forward < 0) { + this.fitInViewport(contentElement, selectFit); + } + // Calculate scrollTop independently of the dropdown, as we cover all `igsSelect` specific positioning and + // scrolling to selected item scenarios here. + this.select.scrollContainer.scrollTop = selectFit.scrollAmount; + } + this.setStyles(contentElement, selectFit); + } + + /** + * Obtain the selected item if there is such one or otherwise use the first one + */ + public getInteractionItemElement(): HTMLElement { + let itemElement; + if (this.select.selectedItem) { + itemElement = this.select.selectedItem.element.nativeElement; + } else { + itemElement = this.select.getFirstItemElement(); + } + return itemElement; + } + + /** + * Position the items outer container so selected item text is positioned over input text and if header + * And/OR footer - both header/footer are visible + * + * @param selectFit selectFit to use for computation. + */ + protected fitInViewport(contentElement: HTMLElement, selectFit: SelectFit) { + const footer = selectFit.scrollContainerRect.bottom - selectFit.contentElementRect.bottom; + const header = selectFit.scrollContainerRect.top - selectFit.contentElementRect.top; + const lastItemFitSize = selectFit.targetRect.bottom + selectFit.styles.itemTextToInputTextDiff - footer; + const firstItemFitSize = selectFit.targetRect.top - selectFit.styles.itemTextToInputTextDiff - header; + // out of viewPort on Top + if (selectFit.fitVertical.back < 0) { + const possibleScrollAmount = selectFit.scrollContainer.scrollHeight - + selectFit.scrollContainerRect.height - selectFit.scrollAmount; + if (possibleScrollAmount + selectFit.fitVertical.back > 0 && firstItemFitSize > selectFit.viewPortRect.top) { + selectFit.scrollAmount -= selectFit.fitVertical.back; + selectFit.verticalOffset -= selectFit.fitVertical.back; + this.global_yOffset = selectFit.verticalOffset; + } else { + selectFit.verticalOffset = 0 ; + this.global_yOffset = 0; + } + // out of viewPort on Bottom + } else if (selectFit.fitVertical.forward < 0) { + if (selectFit.scrollAmount + selectFit.fitVertical.forward > 0 && lastItemFitSize < selectFit.viewPortRect.bottom) { + selectFit.scrollAmount += selectFit.fitVertical.forward; + selectFit.verticalOffset += selectFit.fitVertical.forward; + this.global_yOffset = selectFit.verticalOffset; + } else { + selectFit.verticalOffset = -selectFit.contentElementRect.height + selectFit.targetRect.height; + this.global_yOffset = selectFit.verticalOffset; + } + } + } + + /** + * Sets element's style which effectively positions the provided element + * + * @param element Element to position + * @param selectFit selectFit to use for computation. + * @param initialCall should be true if this is the initial call to the position method calling setStyles + */ + protected setStyles(contentElement: HTMLElement, selectFit: SelectFit) { + super.setStyle(contentElement, selectFit.targetRect, selectFit.contentElementRect, selectFit); + contentElement.style.width = `${selectFit.styles.contentElementNewWidth}px`; // manage container based on paddings? + this.global_styles.contentElementNewWidth = selectFit.styles.contentElementNewWidth; + } + + /** + * Calculate selected item scroll position. + */ + private calculateScrollAmount(selectFit: SelectFit): number { + const itemElementRect = selectFit.itemRect; + const scrollContainer = selectFit.scrollContainer; + const scrollContainerRect = selectFit.scrollContainerRect; + const scrollDelta = scrollContainerRect.top - itemElementRect.top; + let scrollPosition = scrollContainer.scrollTop - scrollDelta; + + const dropDownHeight = scrollContainer.clientHeight; + scrollPosition -= dropDownHeight / 2; + scrollPosition += itemElementRect.height / 2; + + return Math.round(Math.min(Math.max(0, scrollPosition), scrollContainer.scrollHeight - scrollContainerRect.height)); + } + + /** + * Calculate the necessary input and selected item styles to be used for positioning item text over input text. + * Calculate & Set default items container width. + * + * @param selectFit selectFit to use for computation. + */ + private calculateStyles(selectFit: SelectFit, target: Point | HTMLElement): SelectStyles { + const styles: SelectStyles = {}; + const inputElementStyles = window.getComputedStyle(target as Element); + const itemElementStyles = window.getComputedStyle(selectFit.itemElement); + const numericInputFontSize = parseFloat(inputElementStyles.fontSize); + const numericInputPaddingTop = parseFloat(inputElementStyles.paddingTop); + const numericInputPaddingBottom = parseFloat(inputElementStyles.paddingBottom); + const numericItemFontSize = parseFloat(itemElementStyles.fontSize); + const inputTextToInputTop = ((selectFit.targetRect.bottom - numericInputPaddingBottom) + - (selectFit.targetRect.top + numericInputPaddingTop) - numericInputFontSize) / 2; + const itemTextToItemTop = (selectFit.itemRect.height - numericItemFontSize) / 2; + styles.itemTextToInputTextDiff = Math.round(itemTextToItemTop - inputTextToInputTop - numericInputPaddingTop); + + const numericLeftPadding = parseFloat(itemElementStyles.paddingLeft); + const numericTextIndent = parseFloat(itemElementStyles.textIndent); + + styles.itemTextPadding = numericLeftPadding; + styles.itemTextIndent = numericTextIndent; + // 24 is the input's toggle ddl icon width + styles.contentElementNewWidth = selectFit.targetRect.width + 24 + numericLeftPadding * 2; + + return styles; + } + + /** + * Calculate how much to offset the overlay container for Y-axis. + */ + private calculateYoffset(selectFit: SelectFit) { + selectFit.verticalOffset = -(selectFit.itemRect.top - selectFit.contentElementRect.top + + selectFit.styles.itemTextToInputTextDiff - selectFit.scrollAmount); + this.global_yOffset = selectFit.verticalOffset; + } + + /** + * Calculate how much to offset the overlay container for X-axis. + */ + private calculateXoffset(selectFit: SelectFit) { + selectFit.horizontalOffset = selectFit.styles.itemTextIndent - selectFit.styles.itemTextPadding; + this.global_xOffset = selectFit.horizontalOffset; + } +} + +/** @hidden */ +export interface SelectFit extends ConnectedFit { + itemElement?: HTMLElement; + scrollContainer: HTMLElement; + scrollContainerRect: ClientRect; + itemRect?: ClientRect; + styles?: SelectStyles; + scrollAmount?: number; +} + +/** @hidden */ +export interface SelectStyles { + itemTextPadding?: number; + itemTextIndent?: number; + itemTextToInputTextDiff?: number; + contentElementNewWidth?: number; + numericLeftPadding?: number; +} diff --git a/projects/igniteui-angular/select/src/select/select.common.ts b/projects/igniteui-angular/select/src/select/select.common.ts new file mode 100644 index 00000000000..9c1c54aa6ab --- /dev/null +++ b/projects/igniteui-angular/select/src/select/select.common.ts @@ -0,0 +1,15 @@ +import { IgxInputDirective } from 'igniteui-angular/input-group'; +import { OverlaySettings } from 'igniteui-angular/core'; +import { IgxDropDownBaseDirective, IgxDropDownItemBaseDirective } from 'igniteui-angular/drop-down'; + +/** @hidden @internal */ +export interface IgxSelectBase extends IgxDropDownBaseDirective { + input: IgxInputDirective; + readonly selectedItem: IgxDropDownItemBaseDirective; + open(overlaySettings?: OverlaySettings); + close(); + toggle(overlaySettings?: OverlaySettings); + calculateScrollPosition(item: IgxDropDownItemBaseDirective): number; + getFirstItemElement(): HTMLElement; + getEditElement(): HTMLElement; // returns input HTMLElement +} diff --git a/projects/igniteui-angular/select/src/select/select.component.html b/projects/igniteui-angular/select/src/select/select.component.html new file mode 100644 index 00000000000..46a8a63c0b5 --- /dev/null +++ b/projects/igniteui-angular/select/src/select/select.component.html @@ -0,0 +1,62 @@ + + + + + + + + + + + + + @if (toggleIconTemplate) { + + } + @if (!toggleIconTemplate) { + + } + + + + + +
    + + @if (headerTemplate) { +
    + +
    + } + + +
    + +
    + + @if (footerTemplate) { + + } +
    diff --git a/projects/igniteui-angular/select/src/select/select.component.spec.ts b/projects/igniteui-angular/select/src/select/select.component.spec.ts new file mode 100644 index 00000000000..d0ee4095bc1 --- /dev/null +++ b/projects/igniteui-angular/select/src/select/select.component.spec.ts @@ -0,0 +1,3143 @@ +import { Component, ViewChild, DebugElement, OnInit, ElementRef, inject, ChangeDetectorRef, DOCUMENT, Injector } from '@angular/core'; +import { NgStyle } from '@angular/common'; +import { TestBed, tick, fakeAsync, waitForAsync, discardPeriodicTasks } from '@angular/core/testing'; +import { FormsModule, UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators, ReactiveFormsModule, NgForm, NgControl } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { IGX_DROPDOWN_BASE, IgxDropDownItemComponent, ISelectionEventArgs } from '../../../drop-down/src/drop-down/public_api'; +import { IgxHintDirective, IgxInputState, IgxLabelDirective, IgxPrefixDirective, IgxSuffixDirective } from '../../../input-group/src/public_api'; +import { IgxSelectComponent, IgxSelectFooterDirective, IgxSelectHeaderDirective } from './select.component'; +import { IgxSelectItemComponent } from './select-item.component'; +import { HorizontalAlignment, VerticalAlignment, ConnectedPositioningStrategy, AbsoluteScrollStrategy, IgxSelectionAPIService } from 'igniteui-angular/core'; +import { UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { IgxButtonDirective } from '../../../directives/src/directives/button/button.directive'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxSelectGroupComponent } from './select-group.component'; +import { IgxDropDownItemBaseDirective } from '../../../drop-down/src/drop-down/drop-down-item.base'; +import { addScrollDivToElement } from 'igniteui-angular/core/src/services/overlay/overlay.spec'; + +const CSS_CLASS_INPUT_GROUP = 'igx-input-group'; +const CSS_CLASS_INPUT = 'igx-input-group__input'; +const CSS_CLASS_TOGGLE_BUTTON = 'igx-icon'; +const CSS_CLASS_DROPDOWN_LIST_SCROLL = 'igx-drop-down__list-scroll'; +const CSS_CLASS_DROPDOWN_LIST = 'igx-drop-down__list'; +const CSS_CLASS_DROPDOWN_SELECT_HEADER = 'igx-drop-down__select-header'; +const CSS_CLASS_DROPDOWN_SELECT_FOOTER = 'igx-drop-down__select-footer'; +const CSS_CLASS_DROPDOWN_LIST_ITEM = 'igx-drop-down__item'; +const CSS_CLASS_SELECTED_ITEM = 'igx-drop-down__item--selected'; +const CSS_CLASS_DISABLED_ITEM = 'igx-drop-down__item--disabled'; +const CSS_CLASS_FOCUSED_ITEM = 'igx-drop-down__item--focused'; +const CSS_CLASS_INPUT_GROUP_BOX = 'igx-input-group--box'; +const CSS_CLASS_INPUT_GROUP_REQUIRED = 'igx-input-group--required'; +const CSS_CLASS_INPUT_GROUP_INVALID = 'igx-input-group--invalid'; +const CSS_CLASS_INPUT_GROUP_LABEL = 'igx-input-group__label'; +const CSS_CLASS_INPUT_GROUP_BORDER = 'igx-input-group--border'; + +const arrowDownKeyEvent = new KeyboardEvent('keydown', { key: 'ArrowDown' }); +const arrowUpKeyEvent = new KeyboardEvent('keydown', { key: 'ArrowUp' }); +const altArrowDownKeyEvent = new KeyboardEvent('keydown', { altKey: true, key: 'ArrowDown' }); +const altArrowUpKeyEvent = new KeyboardEvent('keydown', { altKey: true, key: 'ArrowUp' }); +const spaceKeyEvent = new KeyboardEvent('keydown', { key: 'Space' }); +const escapeKeyEvent = new KeyboardEvent('keydown', { key: 'Escape' }); +const enterKeyEvent = new KeyboardEvent('keydown', { key: 'Enter' }); +const endKeyEvent = new KeyboardEvent('keydown', { key: 'End' }); +const homeKeyEvent = new KeyboardEvent('keydown', { key: 'Home' }); +const tabKeyEvent = new KeyboardEvent('keydown', { key: 'Tab' }); +const shiftTabKeysEvent = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true }); + +describe('igxSelect', () => { + let fixture; + let select: IgxSelectComponent; + let inputElement: DebugElement; + let selectList: DebugElement; + let selectListWrapper: DebugElement; + + const verifyFocusedItem = focusedItemIndex => { + const focusedItems = fixture.debugElement.queryAll(By.css('.' + CSS_CLASS_FOCUSED_ITEM)); + expect(focusedItems.length).toEqual(1); + expect(selectList.children[focusedItemIndex].nativeElement.classList.contains(CSS_CLASS_FOCUSED_ITEM)).toBeTruthy(); + expect(select.focusedItem).toBe(select.items[focusedItemIndex]); + expect(select.items[focusedItemIndex].focused).toBeTruthy(); + }; + + const verifySelectedItem = itemIndex => { + expect(select.input.value).toEqual(select.items[itemIndex].value); + expect(select.value).toEqual(select.items[itemIndex].value); + const selectedItems = fixture.debugElement.queryAll(By.css('.' + CSS_CLASS_SELECTED_ITEM)); + expect(selectedItems.length).toEqual(1); + expect(selectList.children[itemIndex].nativeElement.classList.contains(CSS_CLASS_SELECTED_ITEM)).toBeTruthy(); + expect(select.selectedItem).toBe(select.items[itemIndex] as IgxSelectItemComponent); + expect(select.items[itemIndex].selected).toBeTruthy(); + }; + + const verifyOpenCloseEvents = (openEventCounter = 0, closeEventCounter = 0, toggleCallCounter = 0) => { + expect(select.opening.emit).toHaveBeenCalledTimes(openEventCounter); + expect(select.opened.emit).toHaveBeenCalledTimes(openEventCounter); + expect(select.open).toHaveBeenCalledTimes(openEventCounter); + expect(select.closing.emit).toHaveBeenCalledTimes(closeEventCounter); + expect(select.closed.emit).toHaveBeenCalledTimes(closeEventCounter); + expect(select.close).toHaveBeenCalledTimes(closeEventCounter); + expect(select.toggle).toHaveBeenCalledTimes(toggleCallCounter); + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxSelectSimpleComponent, + IgxSelectGroupsComponent, + IgxSelectMiddleComponent, + IgxSelectTopComponent, + IgxSelectBottomComponent, + IgxSelectAffixComponent, + IgxSelectReactiveFormComponent, + IgxSelectTemplateFormComponent, + IgxSelectHeaderFooterComponent, + IgxSelectCDRComponent, + IgxSelectWithIdComponent + ] + }).compileComponents(); + })); + + beforeEach(() => { + UIInteractions.clearOverlay(); + }); + + afterAll(() => { + UIInteractions.clearOverlay(); + }); + + describe('General tests: ', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxSelectSimpleComponent); + fixture.detectChanges(); + select = fixture.componentInstance.select; + inputElement = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT)); + selectList = fixture.debugElement.query(By.css('.' + CSS_CLASS_DROPDOWN_LIST_SCROLL)); + selectListWrapper = fixture.debugElement.query(By.css('.' + CSS_CLASS_DROPDOWN_LIST)); + }); + + it('should initialize the select component properly', () => { + const inputGroup = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT_GROUP)); + expect(fixture.componentInstance).toBeDefined(); + expect(select).toBeDefined(); + expect(inputGroup).toBeTruthy(); + expect(select.placeholder).toBeDefined(); + expect(select.value).toBeNull(); + expect(select.disabled).toBeFalsy(); + expect(select.overlaySettings).toBeUndefined(); + expect(select.collapsed).toBeDefined(); + expect(select.collapsed).toBeTruthy(); + select.open(); + fixture.detectChanges(); + expect(select.collapsed).toBeFalsy(); + }); + + it('should properly accept input properties', () => { + expect(select.width).toEqual('300px'); + expect(select.height).toEqual('200px'); + expect(select.maxHeight).toEqual('256px'); + expect(select.disabled).toBeFalsy(); + expect(select.placeholder).toEqual('Choose a city'); + expect(select.value).toBeNull(); + // Default type will be set - currently 'line' + expect(select.type).toEqual('line'); + expect(select.overlaySettings).toBeUndefined(); + expect(select.items).toBeDefined(); + // Reset input values + select.width = '500px'; + expect(select.width).toEqual('500px'); + select.height = '450px'; + expect(select.height).toEqual('450px'); + select.maxHeight = '300px'; + expect(select.maxHeight).toEqual('300px'); + select.placeholder = 'Your home town'; + expect(select.placeholder).toEqual('Your home town'); + select.value = 'Hamburg'; + expect(select.value).toEqual('Hamburg'); + select.type = 'box'; + expect(select.type).toEqual('box'); + select.items[3].disabled = true; + expect(select.items[3].disabled).toBeTruthy(); + select.items[10].selected = true; + expect(select.items[10].selected).toBeTruthy(); + select.items[11].value = 'Milano'; + expect(select.items[11].value).toEqual('Milano'); + + const positionSettings = { + target: select.inputGroup.element.nativeElement, + horizontalDirection: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Bottom, + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Bottom + }; + const customOverlaySettings = { + modal: true, + closeOnOutsideClick: false, + positionStrategy: new ConnectedPositioningStrategy( + positionSettings + ), + scrollStrategy: new AbsoluteScrollStrategy() + }; + select.overlaySettings = customOverlaySettings; + expect(select.overlaySettings).toBe(customOverlaySettings); + + expect(select.collapsed).toBeTruthy(); + select.toggle(); + fixture.detectChanges(); + expect(select.collapsed).toBeFalsy(); + select.disabled = true; + expect(select.disabled).toBeTruthy(); + }); + + it('should open dropdown on input click', () => { + const inputGroup = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT_GROUP)); + expect(select.collapsed).toBeTruthy(); + + inputGroup.nativeElement.click(); + fixture.detectChanges(); + expect(select.collapsed).toBeFalsy(); + }); + + it('should close dropdown on item click', fakeAsync(() => { + const selectedItemEl = selectList.children[2]; + expect(select.collapsed).toBeTruthy(); + + select.toggle(); + fixture.detectChanges(); + expect(select.collapsed).toBeFalsy(); + + selectedItemEl.nativeElement.click(); + tick(); + fixture.detectChanges(); + expect(select.collapsed).toBeTruthy(); + })); + + it('should close dropdown on clicking selected item', fakeAsync(() => { + spyOn(select.selectionChanging, 'emit'); + select.items[1].selected = true; + select.open(); + fixture.detectChanges(); + const selectedItemEl = selectList.children[1]; + expect(select.collapsed).toBeFalsy(); + selectedItemEl.nativeElement.click(); + tick(); + fixture.detectChanges(); + expect(select.collapsed).toBeTruthy(); + + select.open(); + fixture.detectChanges(); + expect(select.collapsed).toBeFalsy(); + selectedItemEl.nativeElement.click(); + tick(); + fixture.detectChanges(); + expect(select.collapsed).toBeTruthy(); + expect(select.selectionChanging.emit).toHaveBeenCalledTimes(1); + })); + + it('should toggle dropdown on toggle button click', fakeAsync(() => { + const toggleBtn = fixture.debugElement.query(By.css('.' + CSS_CLASS_TOGGLE_BUTTON)); + expect(select.collapsed).toBeTruthy(); + + toggleBtn.nativeElement.click(); + fixture.detectChanges(); + expect(select.collapsed).toBeFalsy(); + + toggleBtn.nativeElement.click(); + tick(); + fixture.detectChanges(); + expect(select.collapsed).toBeTruthy(); + })); + + it('should toggle dropdown using API methods', fakeAsync(() => { + select.items[0].selected = true; + expect(select.collapsed).toBeTruthy(); + + select.open(); + fixture.detectChanges(); + expect(select.collapsed).toBeFalsy(); + + select.close(); + tick(); + fixture.detectChanges(); + expect(select.collapsed).toBeTruthy(); + + select.toggle(); + fixture.detectChanges(); + expect(select.collapsed).toBeFalsy(); + + select.toggle(); + tick(); + fixture.detectChanges(); + expect(select.collapsed).toBeTruthy(); + })); + + it('should not display dropdown list when no select items', fakeAsync(() => { + fixture.componentInstance.items = []; + fixture.detectChanges(); + + const inputGroup = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT_GROUP)); + inputGroup.nativeElement.click(); + tick(); + fixture.detectChanges(); + expect(select.collapsed).toBeTruthy(); + expect(selectListWrapper.nativeElement.classList.contains('igx-toggle--hidden')).toBeTruthy(); + })); + + it('should properly emit opening/closing events on input click', fakeAsync(() => { + const inputGroup = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT_GROUP)); + expect(select).toBeTruthy(); + + spyOn(select.opening, 'emit'); + spyOn(select.opened, 'emit'); + spyOn(select.closing, 'emit'); + spyOn(select.closed, 'emit'); + spyOn(select, 'toggle').and.callThrough(); + spyOn(select, 'open').and.callThrough(); + spyOn(select, 'close').and.callThrough(); + + inputGroup.nativeElement.click(); + tick(); + fixture.detectChanges(); + verifyOpenCloseEvents(1, 0, 1); + + inputGroup.nativeElement.click(); + tick(); + fixture.detectChanges(); + verifyOpenCloseEvents(1, 1, 2); + + select.disabled = true; + fixture.detectChanges(); + inputGroup.nativeElement.click(); + tick(); + fixture.detectChanges(); + + // No additional calls, because select is disabled + expect(select.closing.emit).toHaveBeenCalledTimes(1); + expect(select.closed.emit).toHaveBeenCalledTimes(1); + expect(select.opening.emit).toHaveBeenCalledTimes(1); + expect(select.opened.emit).toHaveBeenCalledTimes(1); + })); + + it('should properly emit closing events on item click', fakeAsync(() => { + const selectedItemEl = selectList.children[2]; + + select.toggle(); + tick(); + fixture.detectChanges(); + expect(select.collapsed).toBeFalsy(); + + spyOn(select.closing, 'emit'); + spyOn(select.closed, 'emit'); + + selectedItemEl.nativeElement.click(); + tick(); + fixture.detectChanges(); + expect(select.closing.emit).toHaveBeenCalledTimes(1); + expect(select.closed.emit).toHaveBeenCalledTimes(1); + })); + + it('should properly emit opening/closing events on toggle button click', fakeAsync(() => { + const toggleBtn = fixture.debugElement.query(By.css('.' + CSS_CLASS_TOGGLE_BUTTON)); + expect(select).toBeTruthy(); + + spyOn(select.opening, 'emit'); + spyOn(select.opened, 'emit'); + spyOn(select.closing, 'emit'); + spyOn(select.closed, 'emit'); + spyOn(select, 'toggle').and.callThrough(); + spyOn(select, 'open').and.callThrough(); + spyOn(select, 'close').and.callThrough(); + + toggleBtn.nativeElement.click(); + tick(); + fixture.detectChanges(); + verifyOpenCloseEvents(1, 0, 1); + + toggleBtn.nativeElement.click(); + tick(); + fixture.detectChanges(); + verifyOpenCloseEvents(1, 1, 2); + })); + + it('should emit closing events on input blur when closeOnOutsideClick: true (default value)', fakeAsync(() => { + const dummyInput = fixture.componentInstance.dummyInput.nativeElement; + spyOn(select.closing, 'emit'); + spyOn(select.closed, 'emit'); + + expect(select).toBeDefined(); + select.toggle(); + tick(); + fixture.detectChanges(); + expect(select.collapsed).toBeFalsy(); + + dummyInput.focus(); + dummyInput.click(); + tick(); + fixture.detectChanges(); + + expect(dummyInput).toEqual(document.activeElement); + expect(select.collapsed).toBeTruthy(); + expect(select.closing.emit).toHaveBeenCalledTimes(1); + expect(select.closed.emit).toHaveBeenCalledTimes(1); + })); + + it('should NOT emit closing events on input blur when closeOnOutsideClick: false', fakeAsync(() => { + const dummyInput = fixture.componentInstance.dummyInput.nativeElement; + spyOn(select.closing, 'emit'); + spyOn(select.closed, 'emit'); + + const customOverlaySettings = { + closeOnOutsideClick: false + }; + select.overlaySettings = customOverlaySettings; + expect(select.overlaySettings).toBe(customOverlaySettings); + expect(select.collapsed).toBeTruthy(); + fixture.detectChanges(); + + expect(select).toBeDefined(); + select.toggle(); + tick(); + fixture.detectChanges(); + expect(select.collapsed).toBeFalsy(); + + dummyInput.focus(); + dummyInput.click(); + + tick(); + fixture.detectChanges(); + + expect(dummyInput).toEqual(document.activeElement); + expect(select.collapsed).toBeFalsy(); + expect(select.closing.emit).toHaveBeenCalledTimes(0); + expect(select.closed.emit).toHaveBeenCalledTimes(0); + })); + + it('should render aria attributes properly', fakeAsync(() => { + const dropdownListElement = fixture.debugElement.query(By.css('.' + CSS_CLASS_DROPDOWN_LIST_SCROLL)); + const dropdownWrapper = fixture.debugElement.query(By.css('.' + CSS_CLASS_DROPDOWN_LIST)); + const toggleBtn = fixture.debugElement.query(By.css('.' + CSS_CLASS_TOGGLE_BUTTON)); + const labelID = fixture.componentInstance.label1.nativeElement.getAttribute('id'); + expect(inputElement.nativeElement.getAttribute('role')).toEqual('combobox'); + expect(inputElement.nativeElement.getAttribute('aria-haspopup')).toEqual('listbox'); + expect(inputElement.nativeElement.getAttribute('aria-labelledby')).toEqual(labelID); + expect(dropdownListElement.nativeElement.getAttribute('aria-labelledby')).toEqual(labelID); + expect(inputElement.nativeElement.getAttribute('aria-required')).toEqual('false'); + expect(inputElement.nativeElement.getAttribute('aria-owns')).toEqual(select.listId); + expect(inputElement.nativeElement.getAttribute('aria-expanded')).toEqual('false'); + expect(toggleBtn.nativeElement.getAttribute('aria-hidden')).toEqual('true'); + expect(dropdownListElement.nativeElement.getAttribute('role')).toEqual('listbox'); + expect(dropdownWrapper.nativeElement.getAttribute('aria-hidden')).toEqual('true'); + + select.toggle(); + tick(); + fixture.detectChanges(); + expect(inputElement.nativeElement.getAttribute('aria-expanded')).toEqual('true'); + expect(dropdownWrapper.nativeElement.getAttribute('aria-hidden')).toEqual('false'); + + select.toggle(); + tick(); + fixture.detectChanges(); + expect(inputElement.nativeElement.getAttribute('aria-expanded')).toEqual('false'); + expect(dropdownWrapper.nativeElement.getAttribute('aria-hidden')).toEqual('true'); + })); + + it('should render aria attributes on dropdown items properly', () => { + const selectItems = fixture.debugElement.queryAll(By.css('.' + CSS_CLASS_DROPDOWN_LIST_ITEM)); + selectItems.forEach(item => { + expect(item.nativeElement.getAttribute('role')).toEqual('option'); + expect(item.nativeElement.getAttribute('aria-selected')).toEqual('false'); + expect(item.nativeElement.getAttribute('aria-disabled')).toEqual('false'); + }); + const selectedItem = select.items[2]; + const disabledItem = select.items[8]; + selectedItem.selected = true; + disabledItem.disabled = true; + fixture.detectChanges(); + expect(selectItems[selectedItem.index].nativeElement.getAttribute('aria-selected')).toEqual('true'); + expect(selectItems[disabledItem.index].nativeElement.getAttribute('aria-disabled')).toEqual('true'); + }); + + it('should render input type properly', fakeAsync(() => { + const inputGroup = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT_GROUP)); + // Default type will be set - currently 'line' + expect(select.type).toEqual('line'); + expect(inputGroup.nativeElement.classList.contains(CSS_CLASS_INPUT_GROUP_BOX)).toBeFalsy(); + expect(inputGroup.nativeElement.classList.contains(CSS_CLASS_INPUT_GROUP_BORDER)).toBeFalsy(); + select.type = 'box'; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + expect(inputGroup.nativeElement.classList.contains(CSS_CLASS_INPUT_GROUP_BOX)).toBeTruthy(); + select.type = 'border'; + fixture.detectChanges(); + tick(); + expect(inputGroup.nativeElement.classList.contains(CSS_CLASS_INPUT_GROUP_BORDER)).toBeTruthy(); + })); + + it('should close dropdown on blur when closeOnOutsideClick: true (default value)', fakeAsync(() => { + const dummyInput = fixture.componentInstance.dummyInput.nativeElement; + expect(select.collapsed).toBeTruthy(); + select.toggle(); + fixture.detectChanges(); + expect(select.collapsed).toBeFalsy(); + + dummyInput.focus(); + dummyInput.click(); + + tick(); + fixture.detectChanges(); + expect(dummyInput).toEqual(document.activeElement); + expect(select.collapsed).toBeTruthy(); + })); + + it('should NOT close dropdown on blur when closeOnOutsideClick: false', fakeAsync(() => { + const dummyInput = fixture.componentInstance.dummyInput.nativeElement; + const customOverlaySettings = { + closeOnOutsideClick: false + }; + select.overlaySettings = customOverlaySettings; + expect(select.overlaySettings).toBe(customOverlaySettings); + expect(select.collapsed).toBeTruthy(); + fixture.detectChanges(); + select.toggle(); + expect(select.collapsed).toBeFalsy(); + + dummyInput.focus(); + dummyInput.click(); + + tick(); + fixture.detectChanges(); + expect(dummyInput).toEqual(document.activeElement); + expect(select.collapsed).toBeFalsy(); + })); + + it('should set the id attribute when using property binding', () => { + fixture = TestBed.createComponent(IgxSelectWithIdComponent); + fixture.detectChanges(); + + select = fixture.componentInstance.select; + fixture.detectChanges(); + + const selectElement = fixture.debugElement.query(By.css('igx-select')).nativeElement; + fixture.detectChanges(); + + expect(select).toBeTruthy(); + expect(select.id).toEqual("id1"); + expect(selectElement.getAttribute('id')).toBe('id1'); + }); + }); + + describe('Form tests: ', () => { + it('Should properly initialize when used as a reactive form control - with validators', fakeAsync(() => { + const fix = TestBed.createComponent(IgxSelectReactiveFormComponent); + const inputGroupIsRequiredClass = fix.debugElement.query(By.css('.' + CSS_CLASS_INPUT_GROUP_REQUIRED)); + fix.detectChanges(); + const selectComp = fix.componentInstance.select; + const selectFormReference = fix.componentInstance.reactiveForm.controls.optionsSelect; + expect(selectFormReference).toBeDefined(); + expect(selectComp).toBeDefined(); + expect(selectComp.selectedItem).toBeUndefined(); + expect(selectComp.value).toEqual(''); + expect(inputGroupIsRequiredClass).toBeDefined(); + expect(selectComp.input.valid).toEqual(IgxInputState.INITIAL); + + selectComp.toggle(); + expect(selectComp.collapsed).toEqual(false); + + expect(selectComp.input.valid).toEqual(IgxInputState.INITIAL); + + selectComp.onBlur(); + + expect(selectComp.input.valid).toEqual(IgxInputState.INVALID); + + selectComp.selectItem(selectComp.items[4]); + expect(selectComp.value).toEqual('Option 5'); + + expect(selectComp.input.valid).toEqual(IgxInputState.INITIAL); + + selectComp.onBlur(); + + expect(selectComp.input.valid).toEqual(IgxInputState.INITIAL); + + selectComp.value = 'Option 1'; + + expect(selectComp.input.valid).toEqual(IgxInputState.INITIAL); + })); + + it('Should properly initialize when used as a reactive form control - without initial validators', fakeAsync(() => { + const fix = TestBed.createComponent(IgxSelectReactiveFormComponent); + fix.detectChanges(); + // 1) check if label's --required class and its asterisk are applied + const dom = fix.debugElement; + const selectComp = fix.componentInstance.select; + const formGroup: UntypedFormGroup = fix.componentInstance.reactiveForm; + inputElement = dom.query(By.css('.' + CSS_CLASS_INPUT)); + let inputGroupIsRequiredClass = dom.query(By.css('.' + CSS_CLASS_INPUT_GROUP_REQUIRED)); + let inputGroupInvalidClass = dom.query(By.css('.' + CSS_CLASS_INPUT_GROUP_INVALID)); + // interaction test - expect actual asterisk + // The only way to get a pseudo elements like :before OR :after is to use getComputedStyle(element [, pseudoElt]), + // as these are not in the actual DOM + let asterisk = window.getComputedStyle(dom.query(By.css('.' + CSS_CLASS_INPUT_GROUP_LABEL)).nativeElement, ':after').content; + expect(asterisk).toBe('"*"'); + expect(inputGroupIsRequiredClass).toBeDefined(); + expect(inputGroupIsRequiredClass).not.toBeNull(); + expect(inputElement.nativeElement.getAttribute('aria-required')).toEqual('true'); + + // 2) check that input group's --invalid class is NOT applied + expect(inputGroupInvalidClass).toBeNull(); + + // interaction test - markAsTouched + open&close so the --invalid and --required classes are applied + fix.debugElement.componentInstance.markAsTouched(); + const inputGroup = fix.debugElement.query(By.css('.' + CSS_CLASS_INPUT_GROUP)); + inputGroup.nativeElement.click(); + const toggleBtn = fix.debugElement.query(By.css('.' + CSS_CLASS_TOGGLE_BUTTON)); + toggleBtn.nativeElement.click(); + tick(); + fix.detectChanges(); + expect(selectComp.collapsed).toEqual(true); + + inputGroupInvalidClass = dom.query(By.css('.' + CSS_CLASS_INPUT_GROUP_INVALID)); + expect(inputGroupInvalidClass).not.toBeNull(); + expect(inputGroupInvalidClass).not.toBeUndefined(); + + inputGroupIsRequiredClass = dom.query(By.css('.' + CSS_CLASS_INPUT_GROUP_REQUIRED)); + expect(inputGroupIsRequiredClass).not.toBeNull(); + expect(inputGroupIsRequiredClass).not.toBeUndefined(); + expect(inputElement.nativeElement.getAttribute('aria-required')).toEqual('true'); + + // 3) Check if the input group's --invalid and --required classes are removed when validator is dynamically cleared + fix.componentInstance.removeValidators(formGroup); + fix.detectChanges(); + tick(); + expect(inputElement.nativeElement.getAttribute('aria-required')).toEqual('false'); + + inputGroupIsRequiredClass = dom.query(By.css('.' + CSS_CLASS_INPUT_GROUP_REQUIRED)); + const selectFormReference = fix.componentInstance.reactiveForm.controls.optionsSelect; + // interaction test - expect no asterisk + asterisk = window.getComputedStyle(dom.query(By.css('.' + CSS_CLASS_INPUT_GROUP_LABEL)).nativeElement, ':after').content; + expect(selectFormReference).toBeDefined(); + expect(selectComp).toBeDefined(); + expect(selectComp.selectedItem).toBeUndefined(); + expect(selectComp.value).toEqual(''); + expect(inputGroupIsRequiredClass).toBeNull(); + expect(asterisk).toBe('none'); + expect(selectComp.input.valid).toEqual(IgxInputState.INITIAL); + + selectComp.onBlur(); + + expect(selectComp.input.valid).toEqual(IgxInputState.INITIAL); + + selectComp.selectItem(selectComp.items[4]); + + expect(selectComp.input.valid).toEqual(IgxInputState.INITIAL); + + document.documentElement.dispatchEvent(new Event('click')); + expect(selectComp.collapsed).toEqual(true); + + expect(selectComp.input.valid).toEqual(IgxInputState.INITIAL); + + selectComp.onBlur(); + + expect(selectComp.input.valid).toEqual(IgxInputState.INITIAL); + + // Re-add all Validators + fix.componentInstance.addValidators(formGroup); + fix.detectChanges(); + expect(inputElement.nativeElement.getAttribute('aria-required')).toEqual('true'); + + inputGroupIsRequiredClass = dom.query(By.css('.' + CSS_CLASS_INPUT_GROUP_REQUIRED)); + expect(inputGroupIsRequiredClass).toBeDefined(); + expect(inputGroupIsRequiredClass).not.toBeNull(); + expect(inputGroupIsRequiredClass).not.toBeUndefined(); + // interaction test - expect actual asterisk + asterisk = window.getComputedStyle(dom.query(By.css('.' + CSS_CLASS_INPUT_GROUP_LABEL)).nativeElement, ':after').content; + expect(asterisk).toBe('"*"'); + })); + + it('should update validity state when programmatically setting errors on reactive form controls', fakeAsync(() => { + const fix = TestBed.createComponent(IgxSelectReactiveFormComponent); + fix.detectChanges(); + const selectComp = fix.componentInstance.select; + const formGroup: UntypedFormGroup = fix.componentInstance.reactiveForm; + + // the form control has validators + formGroup.markAllAsTouched(); + formGroup.get('optionsSelect').setErrors({ error: true }); + fix.detectChanges(); + + expect(selectComp.input.valid).toBe(IgxInputState.INVALID); + expect((selectComp as any).inputGroup.element.nativeElement.classList.contains(CSS_CLASS_INPUT_GROUP_INVALID)).toBe(true); + expect((selectComp as any).inputGroup.element.nativeElement.classList.contains(CSS_CLASS_INPUT_GROUP_REQUIRED)).toBe(true); + + // remove the validators and set errors + (fix.componentInstance as IgxSelectReactiveFormComponent).removeValidators(formGroup); + formGroup.markAsUntouched(); + fix.detectChanges(); + + formGroup.markAllAsTouched(); + formGroup.get('optionsSelect').setErrors({ error: true }); + fix.detectChanges(); + + // no validator, but there is a set error + expect(selectComp.input.valid).toBe(IgxInputState.INVALID); + expect((selectComp as any).inputGroup.element.nativeElement).toHaveClass(CSS_CLASS_INPUT_GROUP_INVALID); + expect((selectComp as any).inputGroup.element.nativeElement.classList.contains(CSS_CLASS_INPUT_GROUP_REQUIRED)).toBe(false); + })); + + it('Should properly initialize when used as a form control - with initial validators', fakeAsync(() => { + const fix = TestBed.createComponent(IgxSelectTemplateFormComponent); + + let inputGroupIsRequiredClass = fix.debugElement.query(By.css('.' + CSS_CLASS_INPUT_GROUP_REQUIRED)); + fix.detectChanges(); + const selectComp = fix.componentInstance.select; + const selectFormReference = fix.componentInstance.ngForm.form; + expect(selectFormReference).toBeDefined(); + expect(selectComp).toBeDefined(); + tick(); + fix.detectChanges(); + expect(selectComp.selectedItem).toBeUndefined(); + expect(selectComp.value).toBeNull(); + expect(inputGroupIsRequiredClass).toBeDefined(); + expect(selectComp.input.valid).toEqual(IgxInputState.INITIAL); + + selectComp.toggle(); + expect(selectComp.collapsed).toEqual(false); + + expect(selectComp.input.valid).toEqual(IgxInputState.INITIAL); + + selectComp.onBlur(); + + expect(selectComp.input.valid).toEqual(IgxInputState.INVALID); + + selectComp.selectItem(selectComp.items[4]); + expect(selectComp.value).toEqual('Option 5'); + + expect(selectComp.input.valid).toEqual(IgxInputState.INITIAL); + + selectComp.onBlur(); + expect(selectComp.input.valid).toEqual(IgxInputState.INITIAL); + + selectComp.value = 'Option 1'; + + expect(selectComp.input.valid).toEqual(IgxInputState.INITIAL); + + fix.componentInstance.isRequired = false; + fix.detectChanges(); + inputGroupIsRequiredClass = fix.debugElement.query(By.css('.' + CSS_CLASS_INPUT_GROUP_REQUIRED)); + expect(inputGroupIsRequiredClass).toBeNull(); + })); + + it('Should properly initialize when used as a form control - without initial validators', fakeAsync(() => { + const fix = TestBed.createComponent(IgxSelectTemplateFormComponent); + + let inputGroupIsRequiredClass = fix.debugElement.query(By.css('.' + CSS_CLASS_INPUT_GROUP_REQUIRED)); + fix.detectChanges(); + const selectComp = fix.componentInstance.select; + const selectFormReference = fix.componentInstance.ngForm.form; + selectFormReference.clearValidators(); + expect(selectFormReference).toBeDefined(); + expect(selectComp).toBeDefined(); + expect(selectComp.selectedItem).toBeUndefined(); + expect(selectComp.value).toBeUndefined(); + expect(inputGroupIsRequiredClass).toBeNull(); + expect(selectComp.input.valid).toEqual(IgxInputState.INITIAL); + + selectComp.onBlur(); + + expect(selectComp.input.valid).toEqual(IgxInputState.INITIAL); + + selectComp.selectItem(selectComp.items[4]); + + expect(selectComp.input.valid).toEqual(IgxInputState.INITIAL); + + document.documentElement.dispatchEvent(new Event('click')); + expect(selectComp.collapsed).toEqual(true); + + expect(selectComp.input.valid).toEqual(IgxInputState.INITIAL); + + selectComp.onBlur(); + + expect(selectComp.input.valid).toEqual(IgxInputState.INITIAL); + + fix.componentInstance.isRequired = true; + fix.detectChanges(); + inputGroupIsRequiredClass = fix.debugElement.query(By.css('.' + CSS_CLASS_INPUT_GROUP_REQUIRED)); + expect(inputGroupIsRequiredClass).toBeDefined(); + })); + + it('Should have correctly bound focus and blur handlers', () => { + const fix = TestBed.createComponent(IgxSelectTemplateFormComponent); + fix.detectChanges(); + select = fix.componentInstance.select; + const input = fix.debugElement.query(By.css(`.${CSS_CLASS_INPUT}`)); + + spyOn(select, 'onFocus'); + spyOn(select, 'onBlur'); + + input.triggerEventHandler('focus', {}); + expect(select.onFocus).toHaveBeenCalled(); + expect(select.onFocus).toHaveBeenCalledWith(); + + input.triggerEventHandler('blur', {}); + expect(select.onBlur).toHaveBeenCalled(); + expect(select.onFocus).toHaveBeenCalledWith(); + }); + + // Bug #6025 Select does not disable in reactive form + it('Should disable when form is disabled', fakeAsync(() => { + const fix = TestBed.createComponent(IgxSelectReactiveFormComponent); + fix.detectChanges(); + const formGroup: UntypedFormGroup = fix.componentInstance.reactiveForm; + const selectComp = fix.componentInstance.select; + const inputGroup = fix.debugElement.query(By.css('.' + CSS_CLASS_INPUT_GROUP)); + + inputGroup.nativeElement.click(); + tick(); + fix.detectChanges(); + expect(selectComp.collapsed).toBeFalsy(); + + selectComp.close(); + fix.detectChanges(); + + formGroup.disable(); + tick(); + fix.detectChanges(); + + inputGroup.nativeElement.click(); + tick(); + fix.detectChanges(); + expect(selectComp.collapsed).toBeTruthy(); + })); + + it('should set validity to initial when the form is reset', fakeAsync(() => { + const fix = TestBed.createComponent(IgxSelectTemplateFormComponent); + fix.detectChanges(); + tick(); + + const selectComp = fix.componentInstance.select; + selectComp.onBlur(); + expect(selectComp.input.valid).toEqual(IgxInputState.INVALID); + + fix.componentInstance.ngForm.resetForm(); + tick(); + expect(selectComp.input.valid).toEqual(IgxInputState.INITIAL); + })); + }); + + describe('Selection tests: ', () => { + describe('Using simple select component', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxSelectSimpleComponent); + select = fixture.componentInstance.select; + fixture.detectChanges(); + inputElement = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT)); + selectList = fixture.debugElement.query(By.css('.' + CSS_CLASS_DROPDOWN_LIST_SCROLL)); + }); + + it('should select item with mouse click', fakeAsync(() => { + let selectedItemIndex = 5; + + select.toggle(); + tick(); + fixture.detectChanges(); + selectList.children[selectedItemIndex].nativeElement.click(); + tick(); + fixture.detectChanges(); + verifySelectedItem(selectedItemIndex); + + selectedItemIndex = 15; + select.toggle(); + tick(); + fixture.detectChanges(); + selectList.children[selectedItemIndex].nativeElement.click(); + tick(); + fixture.detectChanges(); + verifySelectedItem(selectedItemIndex); + })); + + it('should select item with API selectItem() method', fakeAsync(() => { + let selectedItemIndex = 15; + select.selectItem(select.items[selectedItemIndex]); + tick(); + fixture.detectChanges(); + verifySelectedItem(selectedItemIndex); + + selectedItemIndex = 1; + select.selectItem(select.items[selectedItemIndex]); + tick(); + fixture.detectChanges(); + verifySelectedItem(selectedItemIndex); + })); + + it('should select item on setting value property', fakeAsync(() => { + let selectedItemIndex = 7; + select.value = select.items[selectedItemIndex].value.toString(); + fixture.detectChanges(); + tick(); + verifySelectedItem(selectedItemIndex); + + selectedItemIndex = 12; + select.value = select.items[selectedItemIndex].value.toString(); + fixture.detectChanges(); + tick(); + verifySelectedItem(selectedItemIndex); + })); + + it('should select item on setting item\'s selected property', () => { + let selectedItemIndex = 9; + select.items[selectedItemIndex].selected = true; + fixture.detectChanges(); + verifySelectedItem(selectedItemIndex); + + selectedItemIndex = 14; + select.items[selectedItemIndex].selected = true; + fixture.detectChanges(); + verifySelectedItem(selectedItemIndex); + }); + + it('should select item with ENTER/SPACE keys', fakeAsync(() => { + let selectedItemIndex = 2; + select.toggle(); + tick(); + fixture.detectChanges(); + inputElement.triggerEventHandler('keydown', arrowDownKeyEvent); + inputElement.triggerEventHandler('keydown', arrowDownKeyEvent); + inputElement.triggerEventHandler('keydown', spaceKeyEvent); + tick(); + fixture.detectChanges(); + verifySelectedItem(selectedItemIndex); + + selectedItemIndex = 4; + select.toggle(); + tick(); + fixture.detectChanges(); + inputElement.triggerEventHandler('keydown', arrowDownKeyEvent); + inputElement.triggerEventHandler('keydown', arrowDownKeyEvent); + inputElement.triggerEventHandler('keydown', enterKeyEvent); + tick(); + fixture.detectChanges(); + verifySelectedItem(selectedItemIndex); + })); + + it('should allow single selection only', fakeAsync(() => { + let selectedItemIndex = 5; + select.toggle(); + tick(); + fixture.detectChanges(); + selectList.children[selectedItemIndex].nativeElement.click(); + tick(); + fixture.detectChanges(); + verifySelectedItem(selectedItemIndex); + + selectedItemIndex = 15; + select.selectItem(select.items[selectedItemIndex]); + tick(); + fixture.detectChanges(); + verifySelectedItem(selectedItemIndex); + + selectedItemIndex = 8; + select.value = select.items[selectedItemIndex].value.toString(); + fixture.detectChanges(); + tick(); + verifySelectedItem(selectedItemIndex); + })); + + it('should clear selection when value property does not match any item', fakeAsync(() => { + const selectedItemIndex = 5; + select.value = select.items[selectedItemIndex].value.toString(); + fixture.detectChanges(); + tick(); + verifySelectedItem(selectedItemIndex); + + select.value = 'Ghost city'; + tick(); + fixture.detectChanges(); + const selectedItems = fixture.debugElement.queryAll(By.css('.' + CSS_CLASS_SELECTED_ITEM)); + expect(selectedItems.length).toEqual(0); + expect(select.selectedItem).toBeUndefined(); + expect(select.input.value).toEqual(''); + })); + + it('should focus first item in dropdown if there is not selected item', fakeAsync(() => { + const focusedItemIndex = 0; + const selectedItemIndex = 8; + select.toggle(); + tick(); + fixture.detectChanges(); + verifyFocusedItem(focusedItemIndex); + + selectList.children[selectedItemIndex].nativeElement.click(); + tick(); + fixture.detectChanges(); + verifySelectedItem(selectedItemIndex); + expect(select.items[focusedItemIndex].focused).toBeFalsy(); + + // Unselect selected item + select.value = ''; + fixture.detectChanges(); + + select.toggle(); + tick(); + fixture.detectChanges(); + verifyFocusedItem(focusedItemIndex); + })); + + it('should populate the input box with the selected item value', fakeAsync(() => { + let selectedItemIndex = 5; + let selectedItemValue = select.items[selectedItemIndex].value; + + const checkInputValue = () => { + expect(select.selectedItem.value).toEqual(selectedItemValue); + expect(select.value).toEqual(selectedItemValue); + expect(inputElement.nativeElement.value.toString().trim()).toEqual(selectedItemValue); + }; + + // There is not a selected item initially + const selectedItems = fixture.debugElement.queryAll(By.css('.' + CSS_CLASS_SELECTED_ITEM)); + expect(selectedItems.length).toEqual(0); + expect(select.value).toBeNull(); + expect(select.input.value).toEqual(''); + expect(inputElement.nativeElement.value).toEqual(''); + + // Select item - mouse click + select.toggle(); + tick(); + fixture.detectChanges(); + selectList.children[selectedItemIndex].nativeElement.click(); + tick(); + fixture.detectChanges(); + checkInputValue(); + + // Select item - selectItem method + selectedItemIndex = 0; + selectedItemValue = select.items[selectedItemIndex].value; + select.selectItem(select.items[selectedItemIndex]); + tick(); + fixture.detectChanges(); + select.toggle(); + tick(); + fixture.detectChanges(); + checkInputValue(); + + // Select item - item selected property + selectedItemIndex = 12; + selectedItemValue = select.items[selectedItemIndex].value; + select.items[selectedItemIndex].selected = true; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + checkInputValue(); + + // Select item - value property + selectedItemIndex = 8; + selectedItemValue = select.items[selectedItemIndex].value; + select.value = select.items[selectedItemIndex].value.toString(); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + checkInputValue(); + })); + + it('should populate the input with the selected item text', fakeAsync(() => { + let selectedItemIndex = 0; + + const checkInputValue = () => { + expect(select.selectedItem.text).toEqual(select.input.value); + expect(inputElement.nativeElement.value.toString().trim()).toEqual(select.selectedItem.text); + }; + + // There is not a selected item initially + const selectedItems = fixture.debugElement.queryAll(By.css('.' + CSS_CLASS_SELECTED_ITEM)); + expect(selectedItems.length).toEqual(0); + expect(select.value).toBeNull(); + expect(select.input.value).toEqual(''); + expect(inputElement.nativeElement.value).toEqual(''); + + // Select item - mouse click + select.toggle(); + tick(); + fixture.detectChanges(); + selectList.children[selectedItemIndex].nativeElement.click(); + tick(); + fixture.detectChanges(); + checkInputValue(); + + // Select item - selectItem method + selectedItemIndex = 1; + select.selectItem(select.items[selectedItemIndex]); + tick(); + fixture.detectChanges(); + select.toggle(); + tick(); + fixture.detectChanges(); + checkInputValue(); + + // Select item - item selected property + selectedItemIndex = 2; + select.items[selectedItemIndex].selected = true; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + checkInputValue(); + })); + + it('should not append any text to the input box when no item is selected and value is not set or does not match any item', + fakeAsync(() => { + // There is not a selected item initially + expect(select.selectedItem).toBeUndefined(); + expect(select.value).toBeNull(); + expect(select.input.value).toEqual(''); + expect(inputElement.nativeElement.textContent).toEqual(''); + + select.value = 'Ghost city'; + tick(); + fixture.detectChanges(); + select.toggle(); + tick(); + fixture.detectChanges(); + expect(select.selectedItem).toBeUndefined(); + expect(select.input.value).toEqual(''); + expect(inputElement.nativeElement.value).toEqual(''); + const selectedItems = fixture.debugElement.nativeElement.querySelectorAll('.' + CSS_CLASS_SELECTED_ITEM); + expect(selectedItems.length).toEqual(0); + })); + + it('should not append any text to the input box when an item is focused but not selected', fakeAsync(() => { + let focusedItem = select.items[2]; + let selectedItem: IgxSelectItemComponent = null; + const navigationStep = focusedItem.index; + + const navigateDropdownItems = (keydownEvent: KeyboardEvent) => { + for (let index = 0; index < navigationStep; index++) { + inputElement.triggerEventHandler('keydown', keydownEvent); + } + tick(); + fixture.detectChanges(); + }; + + const verifyFocusedItemIsNotSelected = () => { + expect(focusedItem.element.nativeElement.classList.contains(CSS_CLASS_FOCUSED_ITEM)).toBeTruthy(); + expect(focusedItem.element.nativeElement.classList.contains(CSS_CLASS_SELECTED_ITEM)).toBeFalsy(); + expect(select.focusedItem).toEqual(focusedItem); + if (selectedItem) { + expect(selectedItem.element.nativeElement.classList.contains(CSS_CLASS_SELECTED_ITEM)).toBeTruthy(); + expect(select.selectedItem).toEqual(selectedItem); + expect(select.value).toEqual(selectedItem.value); + expect(select.input.value).toEqual(selectedItem.value); + } + }; + + // Focus item when there is not a selected item + select.toggle(); + tick(); + fixture.detectChanges(); + navigateDropdownItems(arrowDownKeyEvent); + expect(select.value).toBeNull(); + expect(select.input.value).toEqual(''); + verifyFocusedItemIsNotSelected(); + + // Focus item when there is a selected item + selectedItem = select.items[13] as IgxSelectItemComponent; + selectedItem.element.nativeElement.click(); + tick(); + fixture.detectChanges(); + select.toggle(); + tick(); + fixture.detectChanges(); + navigateDropdownItems(arrowUpKeyEvent); + focusedItem = select.items[selectedItem.index - navigationStep]; + verifyFocusedItemIsNotSelected(); + + // Change focused item when there is a selected item + navigateDropdownItems(arrowUpKeyEvent); + focusedItem = select.items[selectedItem.index - navigationStep * 2]; + verifyFocusedItemIsNotSelected(); + })); + + it('should not select disabled item', () => { + const disabledItem = select.items[2]; + disabledItem.disabled = true; + fixture.detectChanges(); + disabledItem.selected = true; + fixture.detectChanges(); + + expect(select.value).toBeNull(); + expect(select.input.value).toEqual(''); + expect(inputElement.nativeElement.value).toEqual(''); + expect(disabledItem.element.nativeElement.classList.contains(CSS_CLASS_DISABLED_ITEM)).toBeTruthy(); + expect(disabledItem.element.nativeElement.classList.contains(CSS_CLASS_SELECTED_ITEM)).toBeFalsy(); + }); + + it('should remove selection if option has been removed', fakeAsync(() => { + const selectedItemIndex = 2; + select.items[selectedItemIndex].selected = true; + tick(); + fixture.detectChanges(); + verifySelectedItem(selectedItemIndex); + + fixture.componentInstance.items = []; + fixture.detectChanges(); + tick(); + expect(select.selectedItem).toBeUndefined(); + })); + + it('should select first match out of duplicated values', fakeAsync(() => { + fixture.componentInstance.itemTrack = (_item: string, index: number) => index; + fixture.componentInstance.items = ['Paris', 'London', 'Paris', 'Hamburg', 'London']; + fixture.detectChanges(); + + let selectedItemIndex = 4; + select.toggle(); + tick(); + fixture.detectChanges(); + + select.items[selectedItemIndex].element.nativeElement.click(); + tick(); + fixture.detectChanges(); + verifySelectedItem(selectedItemIndex); + + const previousItem = select.items[selectedItemIndex]; + selectedItemIndex = 1; + select.items[selectedItemIndex].element.nativeElement.click(); + tick(); + fixture.detectChanges(); + verifySelectedItem(selectedItemIndex); + expect(previousItem.focused).toBeFalsy(); + })); + + it('should not change selection when setting value to non-existing item', fakeAsync(() => { + const selectedItemEl = selectList.children[2]; + const selectedItem = select.items[2] as IgxSelectItemComponent; + + inputElement.nativeElement.click(); + tick(); + fixture.detectChanges(); + selectedItemEl.nativeElement.click(); + tick(); + fixture.detectChanges(); + expect(selectedItem.selected).toBeTruthy(); + expect(select.value).toEqual(selectedItem.value); + expect(select.input.value.toString().trim()).toEqual(selectedItem.value); + expect(select.selectedItem).toEqual(selectedItem); + expect(selectedItemEl.nativeElement.classList.contains(CSS_CLASS_SELECTED_ITEM)).toBeTruthy(); + + // Throws an error 'Cannot read property disabled of null' + select.selectItem(null); + fixture.detectChanges(); + expect(selectedItem.selected).toBeTruthy(); + expect(select.value).toEqual(selectedItem.value); + expect(select.input.value.toString().trim()).toEqual(selectedItem.value); + expect(select.selectedItem).toEqual(selectedItem); + expect(selectedItemEl.nativeElement.classList.contains(CSS_CLASS_SELECTED_ITEM)).toBeTruthy(); + const selectedItems = fixture.debugElement.nativeElement.querySelectorAll('.' + CSS_CLASS_SELECTED_ITEM); + expect(selectedItems.length).toEqual(1); + })); + + it('should properly emit selectionChanging event on item click', fakeAsync(() => { + let selectedItemEl = selectList.children[5]; + let selectedItem = select.items[5]; + spyOn(select.selectionChanging, 'emit'); + spyOn(select, 'selectItem').and.callThrough(); + const args: ISelectionEventArgs = { + oldSelection: {}, + newSelection: selectedItem, + cancel: false, + owner: select + }; + + select.toggle(); + tick(); + fixture.detectChanges(); + selectedItemEl.nativeElement.click(); + tick(); + fixture.detectChanges(); + expect(select.selectionChanging.emit).toHaveBeenCalledTimes(1); + expect(select.selectItem).toHaveBeenCalledTimes(1); + expect(select.selectionChanging.emit).toHaveBeenCalledWith(args); + + args.oldSelection = selectedItem; + selectedItem = select.items[10]; + selectedItemEl = selectList.children[10]; + args.newSelection = selectedItem; + select.toggle(); + tick(); + fixture.detectChanges(); + selectedItemEl.nativeElement.click(); + tick(); + fixture.detectChanges(); + expect(select.selectionChanging.emit).toHaveBeenCalledTimes(2); + expect(select.selectItem).toHaveBeenCalledTimes(2); + expect(select.selectionChanging.emit).toHaveBeenCalledWith(args); + })); + + it('should properly emit selectionChanging event on item selected property setting', () => { + let selectedItem = select.items[3]; + spyOn(select.selectionChanging, 'emit'); + spyOn(select, 'selectItem').and.callThrough(); + const args: ISelectionEventArgs = { + oldSelection: {}, + newSelection: selectedItem, + cancel: false, + owner: select + }; + + selectedItem.selected = true; + fixture.detectChanges(); + expect(select.selectionChanging.emit).toHaveBeenCalledTimes(1); + expect(select.selectItem).toHaveBeenCalledTimes(1); + expect(select.selectionChanging.emit).toHaveBeenCalledWith(args); + + args.oldSelection = selectedItem; + selectedItem = select.items[9]; + selectedItem.selected = true; + args.newSelection = selectedItem; + fixture.detectChanges(); + expect(select.selectionChanging.emit).toHaveBeenCalledTimes(2); + expect(select.selectItem).toHaveBeenCalledTimes(2); + expect(select.selectionChanging.emit).toHaveBeenCalledWith(args); + }); + + it('should properly emit selectionChanging/close events on key interaction', fakeAsync(() => { + let selectedItem = select.items[3]; + spyOn(select.opening, 'emit'); + spyOn(select.opened, 'emit'); + spyOn(select.closing, 'emit'); + spyOn(select.closed, 'emit'); + spyOn(select, 'close').and.callThrough(); + spyOn(select.selectionChanging, 'emit'); + spyOn(select, 'selectItem').and.callThrough(); + const args: ISelectionEventArgs = { + oldSelection: {}, + newSelection: selectedItem, + cancel: false, + owner: select + }; + + const navigateDropdownItems = (selectEvent: KeyboardEvent) => { + inputElement.triggerEventHandler('keydown', altArrowDownKeyEvent); + tick(); + fixture.detectChanges(); + for (let itemIndex = 0; itemIndex < selectedItem.index; itemIndex++) { + inputElement.triggerEventHandler('keydown', arrowDownKeyEvent); + } + inputElement.triggerEventHandler('keydown', selectEvent); + tick(); + fixture.detectChanges(); + }; + + navigateDropdownItems(enterKeyEvent); + expect(select.opening.emit).toHaveBeenCalledTimes(1); + expect(select.opened.emit).toHaveBeenCalledTimes(1); + expect(select.selectionChanging.emit).toHaveBeenCalledTimes(1); + expect(select.selectItem).toHaveBeenCalledTimes(1); + expect(select.selectionChanging.emit).toHaveBeenCalledWith(args); + expect(select.closing.emit).toHaveBeenCalledTimes(1); + expect(select.closed.emit).toHaveBeenCalledTimes(1); + expect(select.close).toHaveBeenCalledTimes(1); + + // Correct event order + expect(select.opening.emit).toHaveBeenCalledBefore(select.opened.emit); + expect(select.opened.emit).toHaveBeenCalledBefore(select.selectionChanging.emit); + expect(select.selectionChanging.emit).toHaveBeenCalledBefore(select.closing.emit); + expect(select.closing.emit).toHaveBeenCalledBefore(select.closed.emit); + + args.oldSelection = selectedItem; + selectedItem = select.items[9]; + args.newSelection = selectedItem; + navigateDropdownItems(spaceKeyEvent); + expect(select.opening.emit).toHaveBeenCalledTimes(2); + expect(select.opened.emit).toHaveBeenCalledTimes(2); + expect(select.selectionChanging.emit).toHaveBeenCalledTimes(2); + expect(select.selectItem).toHaveBeenCalledTimes(2); + expect(select.closing.emit).toHaveBeenCalledTimes(2); + expect(select.closed.emit).toHaveBeenCalledTimes(2); + expect(select.close).toHaveBeenCalledTimes(2); + })); + + // it('should properly emit selecting event on value setting', fakeAsync(() => { + // spyOn(select.selectionChanging, 'emit'); + // spyOn(select, 'selectItem').and.callThrough(); + + // select.value = select.items[4].value.toString(); + // fixture.detectChanges(); + // tick(); + // expect(select.selectionChanging.emit).toHaveBeenCalledTimes(1); + // expect(select.selectItem).toHaveBeenCalledTimes(1); + // expect(select.selectionChanging.emit).toHaveBeenCalledWith(null); + + // select.value = 'Padua'; + // fixture.detectChanges(); + // expect(select.selectionChanging.emit).toHaveBeenCalledTimes(2); + // expect(select.selectItem).toHaveBeenCalledTimes(2); + // expect(select.selectionChanging.emit).toHaveBeenCalledWith(null); + + // // selecting should not be fired when value is set to non-existing item + // select.value = 'Ghost city'; + // fixture.detectChanges(); + // expect(select.selectionChanging.emit).toHaveBeenCalledTimes(2); + // expect(select.selectItem).toHaveBeenCalledTimes(2); + // })); + + it('should properly emit selectionChanging event using selectItem method', () => { + let selectedItem = select.items[4]; + spyOn(select.selectionChanging, 'emit'); + const args: ISelectionEventArgs = { + oldSelection: {}, + newSelection: selectedItem, + cancel: false, + owner: select + }; + + select.selectItem(selectedItem); + fixture.detectChanges(); + expect(select.selectionChanging.emit).toHaveBeenCalledTimes(1); + expect(select.selectionChanging.emit).toHaveBeenCalledWith(args); + + args.oldSelection = selectedItem; + selectedItem = select.items[14]; + args.newSelection = selectedItem; + select.selectItem(selectedItem); + fixture.detectChanges(); + expect(select.selectionChanging.emit).toHaveBeenCalledTimes(2); + expect(select.selectionChanging.emit).toHaveBeenCalledWith(args); + }); + + it('should not emit selectionChanging when selection does not change', () => { + const item = select.items[5]; + spyOn(select.selectionChanging, 'emit'); + select.selectItem(item); + expect(select.selectionChanging.emit).toHaveBeenCalledTimes(1); + select.selectItem(item); + expect(select.selectionChanging.emit).toHaveBeenCalledTimes(1); + select.selectItem(item); + expect(select.selectionChanging.emit).toHaveBeenCalledTimes(1); + select.selectItem(item); + expect(select.selectionChanging.emit).toHaveBeenCalledTimes(1); + }); + + it('should not select header items passed through selectItem method', () => { + const item = select.items[5]; + spyOn(select.selectionChanging, 'emit'); + expect(select.selectedItem).toBeFalsy(); + item.isHeader = true; + select.selectItem(item); + expect(select.selectedItem).toBeFalsy(); + expect(select.selectionChanging.emit).not.toHaveBeenCalled(); + }); + }); + + describe('Using more complex select component', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxSelectGroupsComponent); + select = fixture.componentInstance.select; + fixture.detectChanges(); + inputElement = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT)); + selectList = fixture.debugElement.query(By.css('.' + CSS_CLASS_DROPDOWN_LIST_SCROLL)); + }); + + it('should populate the input with the specified selected item text @input, instead of the selected item element innerText', + fakeAsync(() => { + let selectedItemIndex = 1; + const groupIndex = 0; + const groupElement = selectList.children[groupIndex]; + const itemElementToSelect = groupElement.children[selectedItemIndex].nativeElement; + + const checkInputValue = () => { + expect(select.selectedItem.text).toEqual(select.input.value); + expect(inputElement.nativeElement.value.toString().trim()).toEqual(select.selectedItem.text); + }; + + // There is not a selected item initially + const selectedItems = fixture.debugElement.queryAll(By.css('.' + CSS_CLASS_SELECTED_ITEM)); + expect(selectedItems.length).toEqual(0); + expect(select.value).toBeNull(); + expect(select.input.value).toEqual(''); + expect(inputElement.nativeElement.value).toEqual(''); + + // Select item - mouse click + select.toggle(); + tick(); + fixture.detectChanges(); + itemElementToSelect.click(); + fixture.detectChanges(); + checkInputValue(); + + // Select item - selectItem method + selectedItemIndex = 2; + select.selectItem(select.items[selectedItemIndex]); + tick(); + fixture.detectChanges(); + select.toggle(); + tick(); + fixture.detectChanges(); + checkInputValue(); + + // Select item - item selected property + selectedItemIndex = 3; + select.items[selectedItemIndex].selected = true; + fixture.detectChanges(); + checkInputValue(); + })); + + it('Should populate the input with the selected item element innerText, when text @Input is undefined(not set)', + fakeAsync(() => { + const selectedItemIndex = 2; + // const groupIndex = 0; + // const groupElement = selectList.children[groupIndex]; + // const itemElementToSelect = groupElement.children[selectedItemIndex].nativeElement; + const expectedInputText = 'Paris star'; + + const checkInputValue = () => { + expect(select.selectedItem.itemText).toEqual(expectedInputText); + expect(select.selectedItem.itemText).toEqual(select.input.value); + expect(inputElement.nativeElement.value.toString().trim()).toEqual(select.selectedItem.itemText); + }; + + // Select item - no select-item text. Should set item;s element innerText as input value. + (select.items[selectedItemIndex] as IgxSelectItemComponent).text = undefined; + select.items[selectedItemIndex].selected = true; + fixture.detectChanges(); + tick(); + checkInputValue(); + })); + }); + }); + + describe('Grouped items tests: ', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxSelectGroupsComponent); + select = fixture.componentInstance.select; + fixture.detectChanges(); + inputElement = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT)); + selectList = fixture.debugElement.query(By.css('.' + CSS_CLASS_DROPDOWN_LIST_SCROLL)); + }); + + it('should select group item and close dropdown with mouse click', fakeAsync(() => { + const groupIndex = 0; + const groupElement = selectList.children[groupIndex]; + const selectedItemIndex = 2; + const selectedItemElement = groupElement.children[selectedItemIndex].nativeElement; + + select.toggle(); + tick(); + fixture.detectChanges(); + selectedItemElement.click(); + tick(); + fixture.detectChanges(); + expect(select.input.value).toEqual(select.items[selectedItemIndex - 1].value); + expect(select.value).toEqual(select.items[selectedItemIndex - 1].value); + const selectedItems = fixture.debugElement.queryAll(By.css('.' + CSS_CLASS_SELECTED_ITEM)); + expect(selectedItems.length).toEqual(1); + expect(selectedItemElement.classList.contains(CSS_CLASS_SELECTED_ITEM)).toBeTruthy(); + expect(select.selectedItem).toBe(select.items[selectedItemIndex - 1] as IgxSelectItemComponent); + expect(select.items[selectedItemIndex - 1].selected).toBeTruthy(); + expect(select.collapsed).toBeTruthy(); + })); + + it('should select group item on setting value property', fakeAsync(() => { + const groupIndex = 1; + const groupElement = selectList.children[groupIndex]; + const selectedItemIndex = 2; + const selectedItemElement = groupElement.children[selectedItemIndex].nativeElement; + const itemIndex = 4; + + select.value = select.items[itemIndex].value.toString(); + tick(); + fixture.detectChanges(); + expect(select.input.value).toEqual(select.items[itemIndex].value); + expect(select.value).toEqual(select.items[itemIndex].value); + const selectedItems = fixture.debugElement.queryAll(By.css('.' + CSS_CLASS_SELECTED_ITEM)); + expect(selectedItems.length).toEqual(1); + expect(selectedItemElement.classList.contains(CSS_CLASS_SELECTED_ITEM)).toBeTruthy(); + expect(select.selectedItem).toBe(select.items[itemIndex] as IgxSelectItemComponent); + expect(select.items[itemIndex].selected).toBeTruthy(); + })); + + it('should not select on setting value property to group header', fakeAsync(() => { + const groupIndex = 0; + const groupElement = selectList.children[groupIndex].nativeElement; + + select.value = fixture.componentInstance.locations[groupIndex].continent; + tick(); + fixture.detectChanges(); + expect(select.input.value).toEqual(''); + const selectedItems = fixture.debugElement.queryAll(By.css('.' + CSS_CLASS_SELECTED_ITEM)); + expect(selectedItems.length).toEqual(0); + expect(groupElement.classList.contains(CSS_CLASS_SELECTED_ITEM)).toBeFalsy(); + expect(select.selectedItem).toBeUndefined(); + })); + + it('should not focus group header in dropdown if there is not selected item', fakeAsync(() => { + const groupElement = selectList.children[0]; + const focusedItemIndex = 1; + const focusedItemElement = groupElement.children[focusedItemIndex].nativeElement; + + select.toggle(); + tick(); + fixture.detectChanges(); + const focusedItems = fixture.debugElement.queryAll(By.css('.' + CSS_CLASS_FOCUSED_ITEM)); + expect(focusedItems.length).toEqual(1); + expect(focusedItemElement.classList.contains(CSS_CLASS_FOCUSED_ITEM)).toBeTruthy(); + expect(select.focusedItem).toBe(select.items[0]); + expect(select.items[0].focused).toBeTruthy(); + expect(groupElement.nativeElement.classList.contains(CSS_CLASS_FOCUSED_ITEM)).toBeFalsy(); + })); + }); + + describe('Key navigation tests: ', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxSelectSimpleComponent); + select = fixture.componentInstance.select; + fixture.detectChanges(); + inputElement = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT)); + selectList = fixture.debugElement.query(By.css('.' + CSS_CLASS_DROPDOWN_LIST_SCROLL)); + }); + + it('should toggle dropdown on ALT+ArrowUp/Down keys interaction', fakeAsync(() => { + expect(select.collapsed).toBeTruthy(); + + inputElement.triggerEventHandler('keydown', altArrowDownKeyEvent); + fixture.detectChanges(); + expect(select.collapsed).toBeFalsy(); + + inputElement.triggerEventHandler('keydown', altArrowUpKeyEvent); + tick(); + fixture.detectChanges(); + expect(select.collapsed).toBeTruthy(); + })); + + it('should toggle dropdown on pressing ENTER key', fakeAsync(() => { + expect(select.collapsed).toBeTruthy(); + + inputElement.triggerEventHandler('keydown', enterKeyEvent); + fixture.detectChanges(); + expect(select.collapsed).toBeFalsy(); + + inputElement.triggerEventHandler('keydown', enterKeyEvent); + tick(); + fixture.detectChanges(); + expect(select.collapsed).toBeTruthy(); + })); + + it('should toggle dropdown on pressing SPACE key', fakeAsync(() => { + expect(select.collapsed).toBeTruthy(); + + inputElement.triggerEventHandler('keydown', spaceKeyEvent); + fixture.detectChanges(); + expect(select.collapsed).toBeFalsy(); + + inputElement.triggerEventHandler('keydown', spaceKeyEvent); + tick(); + fixture.detectChanges(); + expect(select.collapsed).toBeTruthy(); + })); + + it('should close dropdown on pressing ESC/TAB/SHIFT+TAB key', fakeAsync(() => { + expect(select.collapsed).toBeTruthy(); + + select.toggle(); + fixture.detectChanges(); + expect(select.collapsed).toBeFalsy(); + + inputElement.triggerEventHandler('keydown', escapeKeyEvent); + tick(); + fixture.detectChanges(); + expect(select.collapsed).toBeTruthy(); + + select.toggle(); + fixture.detectChanges(); + expect(select.collapsed).toBeFalsy(); + inputElement.triggerEventHandler('keydown', tabKeyEvent); + inputElement.nativeElement.dispatchEvent(new Event('blur')); + tick(); + fixture.detectChanges(); + expect(select.collapsed).toBeTruthy(); + + select.toggle(); + fixture.detectChanges(); + expect(select.collapsed).toBeFalsy(); + inputElement.triggerEventHandler('keydown', shiftTabKeysEvent); + inputElement.nativeElement.dispatchEvent(new Event('blur')); + tick(); + fixture.detectChanges(); + expect(select.collapsed).toBeTruthy(); + })); + + it('should properly emit opening/closing events on ALT+ArrowUp/Down keys interaction', fakeAsync(() => { + spyOn(select.opening, 'emit'); + spyOn(select.opened, 'emit'); + spyOn(select.closing, 'emit'); + spyOn(select.closed, 'emit'); + spyOn(select, 'toggle').and.callThrough(); + spyOn(select, 'open').and.callThrough(); + spyOn(select, 'close').and.callThrough(); + + inputElement.triggerEventHandler('keydown', altArrowDownKeyEvent); + tick(); + fixture.detectChanges(); + verifyOpenCloseEvents(1, 0, 1); + + inputElement.triggerEventHandler('keydown', altArrowUpKeyEvent); + tick(); + fixture.detectChanges(); + verifyOpenCloseEvents(1, 1, 2); + })); + + it('should properly emit opening/closing events on ENTER/ESC key interaction', fakeAsync(() => { + spyOn(select.opening, 'emit'); + spyOn(select.opened, 'emit'); + spyOn(select.closing, 'emit'); + spyOn(select.closed, 'emit'); + spyOn(select, 'open').and.callThrough(); + spyOn(select, 'close').and.callThrough(); + spyOn(select, 'toggle').and.callThrough(); + + inputElement.triggerEventHandler('keydown', enterKeyEvent); + tick(); + fixture.detectChanges(); + verifyOpenCloseEvents(1, 0, 0); + + inputElement.triggerEventHandler('keydown', escapeKeyEvent); + tick(); + fixture.detectChanges(); + verifyOpenCloseEvents(1, 1, 0); + })); + + it('should properly emit opening/closing events on SPACE/ESC key interaction', fakeAsync(() => { + spyOn(select.opening, 'emit'); + spyOn(select.opened, 'emit'); + spyOn(select.closing, 'emit'); + spyOn(select.closed, 'emit'); + spyOn(select, 'open').and.callThrough(); + spyOn(select, 'close').and.callThrough(); + spyOn(select, 'toggle').and.callThrough(); + + inputElement.triggerEventHandler('keydown', spaceKeyEvent); + tick(); + fixture.detectChanges(); + verifyOpenCloseEvents(1, 0, 0); + + inputElement.triggerEventHandler('keydown', escapeKeyEvent); + tick(); + fixture.detectChanges(); + verifyOpenCloseEvents(1, 1, 0); + })); + + it('should navigate through dropdown items using Up/Down/Home/End keys', () => { + let currentItemIndex = 0; + select.toggle(); + fixture.detectChanges(); + + inputElement.triggerEventHandler('keydown', arrowDownKeyEvent); + fixture.detectChanges(); + currentItemIndex++; + verifyFocusedItem(currentItemIndex); + + currentItemIndex++; + inputElement.triggerEventHandler('keydown', arrowDownKeyEvent); + fixture.detectChanges(); + verifyFocusedItem(currentItemIndex); + + currentItemIndex = select.items.length - 1; + inputElement.triggerEventHandler('keydown', endKeyEvent); + fixture.detectChanges(); + verifyFocusedItem(currentItemIndex); + + currentItemIndex--; + inputElement.triggerEventHandler('keydown', arrowUpKeyEvent); + fixture.detectChanges(); + verifyFocusedItem(currentItemIndex); + + currentItemIndex--; + inputElement.triggerEventHandler('keydown', arrowUpKeyEvent); + fixture.detectChanges(); + verifyFocusedItem(currentItemIndex); + + currentItemIndex = 0; + inputElement.triggerEventHandler('keydown', homeKeyEvent); + fixture.detectChanges(); + verifyFocusedItem(currentItemIndex); + }); + + it('should navigate through items skipping the disabled ones when dropdown is opened', () => { + let focusedItemIndex = 1; + select.items[0].disabled = true; + select.items[2].disabled = true; + select.items[3].disabled = true; + select.items[select.items.length - 1].disabled = true; + fixture.detectChanges(); + + select.toggle(); + fixture.detectChanges(); + verifyFocusedItem(focusedItemIndex); + + // Skip two disabled items + focusedItemIndex = 4; + inputElement.triggerEventHandler('keydown', arrowDownKeyEvent); + fixture.detectChanges(); + verifyFocusedItem(focusedItemIndex); + + // Focus the element before the last one as it is disabled + focusedItemIndex = select.items.length - 2; + inputElement.triggerEventHandler('keydown', endKeyEvent); + fixture.detectChanges(); + verifyFocusedItem(focusedItemIndex); + + focusedItemIndex--; + inputElement.triggerEventHandler('keydown', arrowUpKeyEvent); + fixture.detectChanges(); + verifyFocusedItem(focusedItemIndex); + + // Home key should focus second item since the first one is disabled + focusedItemIndex = 1; + inputElement.triggerEventHandler('keydown', homeKeyEvent); + fixture.detectChanges(); + verifyFocusedItem(focusedItemIndex); + }); + + it('should navigate and select items skipping the disabled ones when dropdown is closed', () => { + let selectedItemIndex = 1; + select.items[0].disabled = true; + select.items[2].disabled = true; + select.items[3].disabled = true; + select.items[select.items.length - 1].disabled = true; + fixture.detectChanges(); + + inputElement.nativeElement.focus(); + inputElement.triggerEventHandler('keydown', arrowDownKeyEvent); + fixture.detectChanges(); + + verifySelectedItem(selectedItemIndex); + + // Skip two disabled items + selectedItemIndex = 4; + inputElement.triggerEventHandler('keydown', arrowDownKeyEvent); + fixture.detectChanges(); + + verifySelectedItem(selectedItemIndex); + + // Select item before the last one + selectedItemIndex = select.items.length - 2; + select.toggle(); + fixture.detectChanges(); + selectList.children[selectedItemIndex].nativeElement.click(); + fixture.detectChanges(); + + // The item before the last one should remain selected + inputElement.triggerEventHandler('keydown', arrowDownKeyEvent); + fixture.detectChanges(); + + verifySelectedItem(selectedItemIndex); + }); + + it('should start navigation from selected item when dropdown is opened', fakeAsync(() => { + let selectedItem = select.items[4]; + let focusedItemIndex = selectedItem.index + 1; + + select.items[4].selected = true; + fixture.detectChanges(); + + select.toggle(); + tick(); + fixture.detectChanges(); + + inputElement.triggerEventHandler('keydown', arrowDownKeyEvent); + tick(); + fixture.detectChanges(); + expect(selectedItem.selected).toBeTruthy(); + verifyFocusedItem(focusedItemIndex); + + inputElement.triggerEventHandler('keydown', arrowDownKeyEvent); + inputElement.triggerEventHandler('keydown', arrowDownKeyEvent); + inputElement.triggerEventHandler('keydown', enterKeyEvent); + fixture.detectChanges(); + + select.toggle(); + tick(); + fixture.detectChanges(); + + inputElement.triggerEventHandler('keydown', arrowUpKeyEvent); + tick(); + fixture.detectChanges(); + selectedItem = select.items[7]; + focusedItemIndex = selectedItem.index - 1; + verifyFocusedItem(focusedItemIndex); + })); + + it('should start navigation from selected item when dropdown is closed', () => { + let selectedItemIndex = 4; + select.items[selectedItemIndex].selected = true; + fixture.detectChanges(); + + inputElement.nativeElement.focus(); + inputElement.triggerEventHandler('keydown', arrowDownKeyEvent); + fixture.detectChanges(); + verifySelectedItem(++selectedItemIndex); + + selectedItemIndex = select.items.length - 1; + select.items[selectedItemIndex].selected = true; + + // Does not wrap selection - arrow down stays on the last item if selected + fixture.detectChanges(); + inputElement.triggerEventHandler('keydown', arrowDownKeyEvent); + + fixture.detectChanges(); + verifySelectedItem(selectedItemIndex); + inputElement.triggerEventHandler('keydown', arrowUpKeyEvent); + + fixture.detectChanges(); + verifySelectedItem(--selectedItemIndex); + // Does not wrap selection - arrow up stays on the first item if selected + selectedItemIndex = 0; + select.items[selectedItemIndex].selected = true; + + fixture.detectChanges(); + inputElement.triggerEventHandler('keydown', arrowUpKeyEvent); + + fixture.detectChanges(); + verifySelectedItem(selectedItemIndex); + }); + + it('should navigate through items using Up/Down keys until there are items when dropdown is opened', () => { + select.toggle(); + fixture.detectChanges(); + + for (let focusedItemIndex = 1; focusedItemIndex < select.items.length; focusedItemIndex++) { + inputElement.triggerEventHandler('keydown', arrowDownKeyEvent); + fixture.detectChanges(); + verifyFocusedItem(focusedItemIndex); + } + + // Verify the focus stays on the last item + inputElement.triggerEventHandler('keydown', arrowDownKeyEvent); + fixture.detectChanges(); + verifyFocusedItem(select.items.length - 1); + + for (let focusedItemIndex = select.items.length - 2; focusedItemIndex > -1; focusedItemIndex--) { + inputElement.triggerEventHandler('keydown', arrowUpKeyEvent); + fixture.detectChanges(); + verifyFocusedItem(focusedItemIndex); + } + + // Verify the focus stays on the first item + inputElement.triggerEventHandler('keydown', arrowUpKeyEvent); + fixture.detectChanges(); + verifyFocusedItem(0); + }); + + it('should navigate through items using Up/Down keys until there are items when dropdown is closed', () => { + for (let itemIndex = 0; itemIndex < select.items.length; itemIndex++) { + inputElement.triggerEventHandler('keydown', arrowDownKeyEvent); + fixture.detectChanges(); + verifySelectedItem(itemIndex); + } + + // Verify the last item remains selected + inputElement.triggerEventHandler('keydown', arrowDownKeyEvent); + fixture.detectChanges(); + verifySelectedItem(select.items.length - 1); + + for (let itemIndex = select.items.length - 2; itemIndex > -1; itemIndex--) { + inputElement.triggerEventHandler('keydown', arrowUpKeyEvent); + fixture.detectChanges(); + verifySelectedItem(itemIndex); + } + + // Verify the first item remains selected + inputElement.triggerEventHandler('keydown', arrowUpKeyEvent); + fixture.detectChanges(); + verifySelectedItem(0); + }); + + it('should filter and navigate through items on character key navigation when dropdown is opened', fakeAsync(() => { + select.open(); + fixture.detectChanges(); + + const filteredItemsInxs = fixture.componentInstance.filterCities('pa'); + for (const item of filteredItemsInxs) { + inputElement.triggerEventHandler('keydown', { key: 'p' }); + tick(); + fixture.detectChanges(); + inputElement.triggerEventHandler('keydown', { key: 'a' }); + tick(); + fixture.detectChanges(); + verifyFocusedItem(item); + tick(500); + fixture.detectChanges(); + } + discardPeriodicTasks(); + })); + + it('Character key navigation when dropdown is opened should be case insensitive', fakeAsync(() => { + select.open(); + fixture.detectChanges(); + + const filteredItemsInxs = fixture.componentInstance.filterCities('l'); + inputElement.triggerEventHandler('keydown', { key: 'l' }); + tick(); + fixture.detectChanges(); + verifyFocusedItem(filteredItemsInxs[0]); + tick(500); + fixture.detectChanges(); + + inputElement.triggerEventHandler('keydown', { key: 'L' }); + tick(); + fixture.detectChanges(); + verifyFocusedItem(filteredItemsInxs[1]); + discardPeriodicTasks(); + })); + + it('Character key navigation when dropdown is opened should wrap selection', fakeAsync(() => { + select.open(); + fixture.detectChanges(); + + const filteredItemsInxs = fixture.componentInstance.filterCities('l'); + for (const item of filteredItemsInxs) { + inputElement.triggerEventHandler('keydown', { key: 'l' }); + tick(); + fixture.detectChanges(); + verifyFocusedItem(item); + tick(500); + fixture.detectChanges(); + } + // Navigate back to the first filtered item to verify that selection is wrapped + inputElement.triggerEventHandler('keydown', { key: 'l' }); + tick(); + fixture.detectChanges(); + verifyFocusedItem(filteredItemsInxs[0]); + + discardPeriodicTasks(); + })); + + it('should filter and navigate items properly when pressing non-english character', fakeAsync(() => { + fixture.componentInstance.items = [ + 'Berlin', + 'Überherrn', + 'София', + 'München', + 'Überlingen', + 'Stuttgart', + 'Смолян', + 'Übersee', + 'Бургас', + 'Karlsruhe', + 'Östringen']; + fixture.detectChanges(); + select.open(); + fixture.detectChanges(); + + // German characters + let filteredItemsInxs = fixture.componentInstance.filterCities('ü'); + for (const item of filteredItemsInxs) { + inputElement.triggerEventHandler('keydown', { key: 'ü' }); + tick(); + fixture.detectChanges(); + verifyFocusedItem(item); + tick(500); + fixture.detectChanges(); + } + + // Cyrillic characters + filteredItemsInxs = fixture.componentInstance.filterCities('с'); + for (const item of filteredItemsInxs) { + inputElement.triggerEventHandler('keydown', { key: 'с' }); + tick(); + fixture.detectChanges(); + verifyFocusedItem(item); + tick(500); + fixture.detectChanges(); + } + discardPeriodicTasks(); + })); + + it('should not change focus when pressing non-matching character and dropdown is opened', fakeAsync(() => { + select.open(); + tick(); + fixture.detectChanges(); + + const filteredItemsInxs = fixture.componentInstance.filterCities('l'); + inputElement.triggerEventHandler('keydown', { key: 'l' }); + tick(); + fixture.detectChanges(); + verifyFocusedItem(filteredItemsInxs[0]); + tick(500); + fixture.detectChanges(); + + // Verify that focus is unchanged + inputElement.triggerEventHandler('keydown', { key: 'w' }); + tick(); + fixture.detectChanges(); + verifyFocusedItem(filteredItemsInxs[0]); + + discardPeriodicTasks(); + })); + + it('should filter and select items on character key navigation when dropdown is closed', fakeAsync(() => { + const filteredItemsInxs = fixture.componentInstance.filterCities('pa'); + for (const item of filteredItemsInxs) { + inputElement.triggerEventHandler('keydown', { key: 'p' }); + tick(); + fixture.detectChanges(); + inputElement.triggerEventHandler('keydown', { key: 'a' }); + tick(); + fixture.detectChanges(); + verifySelectedItem(item); + tick(500); + fixture.detectChanges(); + } + discardPeriodicTasks(); + })); + + it('character key navigation when dropdown is closed should be case insensitive', fakeAsync(() => { + const filteredItemsInxs = fixture.componentInstance.filterCities('l'); + inputElement.triggerEventHandler('keydown', { key: 'l' }); + tick(); + fixture.detectChanges(); + verifySelectedItem(filteredItemsInxs[0]); + tick(500); + fixture.detectChanges(); + + inputElement.triggerEventHandler('keydown', { key: 'L' }); + tick(); + fixture.detectChanges(); + verifySelectedItem(filteredItemsInxs[1]); + + discardPeriodicTasks(); + })); + + it('character key navigation when dropdown is closed should wrap selection', fakeAsync(() => { + const filteredItemsInxs = fixture.componentInstance.filterCities('l'); + for (const item of filteredItemsInxs) { + inputElement.triggerEventHandler('keydown', { key: 'l' }); + tick(); + fixture.detectChanges(); + verifySelectedItem(item); + tick(500); + fixture.detectChanges(); + } + // Navigate back to the first filtered item to verify that selection is wrapped + inputElement.triggerEventHandler('keydown', { key: 'l' }); + tick(); + fixture.detectChanges(); + verifySelectedItem(filteredItemsInxs[0]); + + discardPeriodicTasks(); + })); + + it('should filter and select items properly when pressing non-english characters', fakeAsync(() => { + fixture.componentInstance.items = [ + 'Berlin', + 'Überherrn', + 'София', + 'München', + 'Überlingen', + 'Stuttgart', + 'Смолян', + 'Übersee', + 'Бургас', + 'Karlsruhe', + 'Östringen']; + fixture.detectChanges(); + + // German characters + let filteredItemsInxs = fixture.componentInstance.filterCities('ü'); + for (const item of filteredItemsInxs) { + inputElement.triggerEventHandler('keydown', { key: 'ü' }); + tick(); + fixture.detectChanges(); + verifySelectedItem(item); + tick(500); + fixture.detectChanges(); + } + + // Cyrillic characters + filteredItemsInxs = fixture.componentInstance.filterCities('с'); + for (const item of filteredItemsInxs) { + inputElement.triggerEventHandler('keydown', { key: 'с' }); + tick(); + fixture.detectChanges(); + verifySelectedItem(item); + tick(500); + fixture.detectChanges(); + } + discardPeriodicTasks(); + })); + + it('should not change selection when pressing non-matching character and dropdown is closed', fakeAsync(() => { + const filteredItemsInxs = fixture.componentInstance.filterCities('l'); + inputElement.triggerEventHandler('keydown', { key: 'l' }); + tick(); + fixture.detectChanges(); + verifyFocusedItem(filteredItemsInxs[0]); + tick(500); + fixture.detectChanges(); + + // Verify that selection is unchanged + inputElement.triggerEventHandler('keydown', { key: 'q' }); + tick(); + fixture.detectChanges(); + verifyFocusedItem(filteredItemsInxs[0]); + tick(500); + fixture.detectChanges(); + + inputElement.triggerEventHandler('keydown', { key: 'l' }); + tick(); + fixture.detectChanges(); + verifyFocusedItem(filteredItemsInxs[1]); + + discardPeriodicTasks(); + })); + + it('Should navigate through items when dropdown is closed and initial value is passed', fakeAsync(() => { + select.close(); + fixture.detectChanges(); + spyOn(select, 'navigateNext').and.callThrough(); + const choices = select.children.toArray(); + select.value = choices[5].value; + fixture.detectChanges(); + select.input.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); + fixture.detectChanges(); + expect(select.navigateNext).toHaveBeenCalled(); + expect(select.value).toEqual(choices[6].value); + })); + }); + describe('Positioning tests: ', () => { + const defaultWindowToListOffset = 16; + const defaultItemLeftPadding = 24; + const defaultItemTopPadding = 0; + const defaultItemBottomPadding = 0; + const defItemFontSize = 14; + const defInputFontSize = 16; + let selectedItemIndex: number; + let listRect: DOMRect; + let inputRect: DOMRect; + let selectedItemRect: DOMRect; + let listTop: number; + + const getBoundingRectangles = () => { + listRect = selectList.nativeElement.getBoundingClientRect(); + inputRect = inputElement.nativeElement.getBoundingClientRect(); + selectedItemRect = select.items[selectedItemIndex].element.nativeElement.getBoundingClientRect(); + }; + // Verifies that the selected item bounding rectangle is positioned over the input bounding rectangle + const verifySelectedItemPositioning = (reversed = false) => { + expect(selectedItemRect.left).toBeCloseTo(inputRect.left - defaultItemLeftPadding, 0); + const expectedInputItemFontTop = reversed + ? document.body.getBoundingClientRect().bottom - defaultWindowToListOffset - selectedItemRect.height + : inputRect.top + defaultItemTopPadding + + (inputRect.height - defaultItemBottomPadding - defaultItemTopPadding - defInputFontSize) / 2; + const expectedSelectItemFontTop = selectedItemRect.top + (selectedItemRect.height - defItemFontSize) / 2; + expect(Math.abs(expectedSelectItemFontTop - expectedInputItemFontTop)).toBeLessThan(2); + }; + const verifyListPositioning = () => { + expect(listRect.left).toBeCloseTo(inputRect.left - defaultItemLeftPadding, 0); + // check with precision of 2 digits after decimal point, as it is the meaningful portion anyways. + expect(listRect.top).toBeCloseTo(listTop, 2); + }; + + describe('Ample space to open positioning tests: ', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxSelectMiddleComponent); + select = fixture.componentInstance.select; + fixture.detectChanges(); + inputElement = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT)); + selectList = fixture.debugElement.query(By.css('.' + CSS_CLASS_DROPDOWN_LIST_SCROLL)); + addScrollDivToElement(fixture.nativeElement); + }); + + it('should display selected item over input and all other items without scroll', fakeAsync(() => { + selectedItemIndex = 1; + select.items[selectedItemIndex].selected = true; + fixture.detectChanges(); + select.toggle(); + tick(); + fixture.detectChanges(); + getBoundingRectangles(); + verifySelectedItemPositioning(); + listTop = selectedItemRect.top - selectedItemRect.height; + verifyListPositioning(); + + selectedItemIndex = 2; + select.toggle(); + tick(); + fixture.detectChanges(); + select.items[selectedItemIndex].selected = true; + fixture.detectChanges(); + select.toggle(); + tick(); + fixture.detectChanges(); + getBoundingRectangles(); + verifySelectedItemPositioning(); + listTop = selectedItemRect.top - selectedItemRect.height * 2; + verifyListPositioning(); + + selectedItemIndex = 0; + select.toggle(); + tick(); + fixture.detectChanges(); + select.items[selectedItemIndex].selected = true; + fixture.detectChanges(); + select.toggle(); + tick(); + fixture.detectChanges(); + getBoundingRectangles(); + verifySelectedItemPositioning(); + listTop = selectedItemRect.top; + verifyListPositioning(); + })); + + it('should scroll and display selected item over input and all other possible items', fakeAsync(() => { + fixture.componentInstance.items = [ + 'Option 1', + 'Option 2', + 'Option 3', + 'Option 4', + 'Option 5', + 'Option 6', + 'Option 7', + 'Option 8', + 'Option 9', + 'Option 10']; + fixture.detectChanges(); + + selectedItemIndex = 0; + select.items[selectedItemIndex].selected = true; + fixture.detectChanges(); + select.toggle(); + tick(); + fixture.detectChanges(); + getBoundingRectangles(); + verifySelectedItemPositioning(); + + selectedItemIndex = 5; + select.toggle(); + tick(); + fixture.detectChanges(); + select.items[selectedItemIndex].selected = true; + fixture.detectChanges(); + select.toggle(); + tick(); + fixture.detectChanges(); + getBoundingRectangles(); + verifySelectedItemPositioning(); + + selectedItemIndex = 9; + select.toggle(); + tick(); + fixture.detectChanges(); + select.items[selectedItemIndex].selected = true; + fixture.detectChanges(); + select.toggle(); + tick(); + fixture.detectChanges(); + getBoundingRectangles(); + verifySelectedItemPositioning(); + })); + }); + + describe('Not enough space above to open positioning tests: ', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxSelectTopComponent); + select = fixture.componentInstance.select; + fixture.detectChanges(); + inputElement = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT)); + selectList = fixture.debugElement.query(By.css('.' + CSS_CLASS_DROPDOWN_LIST_SCROLL)); + }); + + it('should display selected item over input when first item is selected', fakeAsync(() => { + selectedItemIndex = 0; + select.items[selectedItemIndex].selected = true; + fixture.detectChanges(); + select.toggle(); + tick(); + fixture.detectChanges(); + getBoundingRectangles(); + verifySelectedItemPositioning(); + })); + + it('should display selected item over input and possible items above and below when item in the middle of the list is selected', + // there is enough scroll left in scroll container so the dropdown is NOT REPOSITIONED below the input + fakeAsync(() => { + selectedItemIndex = 3; + select.items[selectedItemIndex].selected = true; + (select.element as HTMLElement).style.marginTop = '10px'; + fixture.detectChanges(); + select.toggle(); + tick(); + fixture.detectChanges(); + getBoundingRectangles(); + verifySelectedItemPositioning(); + (select.element as HTMLElement).parentElement.style.marginTop = '10px'; + fixture.detectChanges(); + })); + + it('should display selected item and all possible items above when last item is selected', + // there is NO enough scroll left in scroll container so the dropdown is REPOSITIONED below the input + fakeAsync(() => { + selectedItemIndex = 9; + select.items[selectedItemIndex].selected = true; + fixture.detectChanges(); + select.toggle(); + tick(); + fixture.detectChanges(); + getBoundingRectangles(); + })); + }); + + describe('Not enough space below to open positioning tests: ', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxSelectBottomComponent); + select = fixture.componentInstance.select; + fixture.detectChanges(); + inputElement = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT)); + selectList = fixture.debugElement.query(By.css('.' + CSS_CLASS_DROPDOWN_LIST_SCROLL)); + }); + + it('should display list with selected item and all possible items after it when first item is selected', + fakeAsync(() => { + selectedItemIndex = 0; + select.items[selectedItemIndex].selected = true; + fixture.detectChanges(); + select.toggle(); + tick(); + fixture.detectChanges(); + getBoundingRectangles(); + })); + it('should display selected item and all possible items above and below when item in the middle of the list is selected', + // there is NO enough scroll atop the scroll container so the dropdown is REPOSITIONED above the input + fakeAsync(() => { + selectedItemIndex = 3; + select.items[selectedItemIndex].selected = true; + fixture.detectChanges(); + select.toggle(); + tick(); + fixture.detectChanges(); + getBoundingRectangles(); + })); + + it(`should display selected item and all possible items above and position selected item over input + when item is close to the end of the list is selected`, + // there is enough scroll left in scroll container so the dropdown is NOT REPOSITIONED above the input + fakeAsync(() => { + selectedItemIndex = 7; + select.items[selectedItemIndex].selected = true; + fixture.detectChanges(); + select.toggle(); + tick(); + fixture.detectChanges(); + getBoundingRectangles(); + })); + }); + + describe('Document bigger than the visible viewport tests: ', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxSelectMiddleComponent); + fixture.detectChanges(); + select = fixture.componentInstance.select; + inputElement = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT)); + selectList = fixture.debugElement.query(By.css('.' + CSS_CLASS_DROPDOWN_LIST_SCROLL)); + addScrollDivToElement(fixture.nativeElement); + }); + + it('should correctly reposition the items container when perform horizontal scroll', fakeAsync(() => { + selectedItemIndex = 1; + select.items[selectedItemIndex].selected = true; + fixture.detectChanges(); + select.toggle(); + tick(); + fixture.detectChanges(); + document.documentElement.scrollLeft += 50; + document.dispatchEvent(new Event('scroll')); + tick(); + getBoundingRectangles(); + verifySelectedItemPositioning(); + listTop = selectedItemRect.top - selectedItemRect.height; + verifyListPositioning(); + + selectedItemIndex = 2; + select.toggle(); + tick(); + fixture.detectChanges(); + select.items[selectedItemIndex].selected = true; + fixture.detectChanges(); + select.toggle(); + tick(); + fixture.detectChanges(); + document.documentElement.scrollLeft += 50; + document.dispatchEvent(new Event('scroll')); + tick(); + getBoundingRectangles(); + verifySelectedItemPositioning(); + listTop = selectedItemRect.top - selectedItemRect.height * 2; + verifyListPositioning(); + + selectedItemIndex = 0; + select.toggle(); + tick(); + fixture.detectChanges(); + select.items[selectedItemIndex].selected = true; + fixture.detectChanges(); + select.toggle(); + tick(); + fixture.detectChanges(); + document.documentElement.scrollLeft += 50; + document.dispatchEvent(new Event('scroll')); + tick(); + getBoundingRectangles(); + verifySelectedItemPositioning(); + listTop = selectedItemRect.top; + verifyListPositioning(); + })); + + it('should correctly reposition the items container when perform vertical scroll', fakeAsync(() => { + selectedItemIndex = 1; + select.items[selectedItemIndex].selected = true; + fixture.detectChanges(); + select.toggle(); + tick(); + fixture.detectChanges(); + document.documentElement.scrollTop += 20; + document.dispatchEvent(new Event('scroll')); + tick(); + getBoundingRectangles(); + verifySelectedItemPositioning(); + listTop = selectedItemRect.top - selectedItemRect.height; + verifyListPositioning(); + + selectedItemIndex = 2; + select.toggle(); + tick(); + fixture.detectChanges(); + select.items[selectedItemIndex].selected = true; + fixture.detectChanges(); + select.toggle(); + tick(); + fixture.detectChanges(); + document.documentElement.scrollTop += 20; + document.dispatchEvent(new Event('scroll')); + tick(); + getBoundingRectangles(); + verifySelectedItemPositioning(); + listTop = selectedItemRect.top - selectedItemRect.height * 2; + verifyListPositioning(); + + selectedItemIndex = 0; + select.toggle(); + tick(); + fixture.detectChanges(); + select.items[selectedItemIndex].selected = true; + fixture.detectChanges(); + select.toggle(); + tick(); + fixture.detectChanges(); + document.documentElement.scrollTop += 20; + document.dispatchEvent(new Event('scroll')); + tick(); + getBoundingRectangles(); + verifySelectedItemPositioning(); + listTop = selectedItemRect.top; + verifyListPositioning(); + })); + }); + }); + describe('EditorProvider', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxSelectSimpleComponent); + fixture.detectChanges(); + }); + + it('Should return correct edit element', () => { + inputElement = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT)).nativeElement; + const selectInstance = fixture.componentInstance.select; + expect(selectInstance.getEditElement()).toEqual(inputElement); + }); + }); + describe('Header & Footer', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxSelectHeaderFooterComponent); + fixture.detectChanges(); + select = fixture.componentInstance.select; + selectList = fixture.debugElement.query(By.css('.' + CSS_CLASS_DROPDOWN_LIST_SCROLL)); + selectListWrapper = fixture.debugElement.query(By.css('.' + CSS_CLASS_DROPDOWN_LIST)); + }); + + it('Should render header and footer elements where expected', () => { + const selectHeader = fixture.debugElement.query(By.css('.' + CSS_CLASS_DROPDOWN_SELECT_HEADER)); + const selectFooter = fixture.debugElement.query(By.css('.' + CSS_CLASS_DROPDOWN_SELECT_FOOTER)); + // elements exist + expect(selectHeader).toBeDefined(); + expect(selectFooter).toBeDefined(); + // elements structure is correct + expect(selectListWrapper.nativeElement.firstElementChild).toHaveClass(CSS_CLASS_DROPDOWN_SELECT_HEADER); + expect(selectListWrapper.nativeElement.lastElementChild).toHaveClass(CSS_CLASS_DROPDOWN_SELECT_FOOTER); + expect(selectList.nativeElement.previousElementSibling).toHaveClass(CSS_CLASS_DROPDOWN_SELECT_HEADER); + expect(selectList.nativeElement.nextElementSibling).toHaveClass(CSS_CLASS_DROPDOWN_SELECT_FOOTER); + }); + + it('Should NOT render header and footer elements, if template is not defined', fakeAsync(() => { + select.headerTemplate = null; + select.footerTemplate = null; + fixture.detectChanges(); + tick(); + const selectHeader = fixture.debugElement.query(By.css('.' + CSS_CLASS_DROPDOWN_SELECT_HEADER)); + const selectFooter = fixture.debugElement.query(By.css('.' + CSS_CLASS_DROPDOWN_SELECT_FOOTER)); + // elements do not exist + expect(selectHeader).toBeNull(); + expect(selectFooter).toBeNull(); + // elements structure is correct + expect(selectListWrapper.nativeElement.firstElementChild).toHaveClass(CSS_CLASS_DROPDOWN_LIST_SCROLL); + expect(selectListWrapper.nativeElement.lastElementChild).toHaveClass(CSS_CLASS_DROPDOWN_LIST_SCROLL); + expect(selectList.nativeElement.previousElementSibling).toBeNull(); + expect(selectList.nativeElement.nextElementSibling).toBeNull(); + })); + }); + describe('Test CDR - Expression changed after it was checked', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxSelectCDRComponent); + fixture.detectChanges(); + }); + + it('Should NOT throw console Warning for "Expression changed after it was checked"', () => { + let selectCDR = fixture.componentInstance.select; + + expect(selectCDR).toBeDefined(); + expect(selectCDR.value).toBe('ID'); + + fixture.componentInstance.render = !fixture.componentInstance.render; + fixture.detectChanges(); + selectCDR = fixture.componentInstance.select; + expect(selectCDR).toBeUndefined(); + + fixture.componentInstance.render = !fixture.componentInstance.render; + fixture.detectChanges(); + selectCDR = fixture.componentInstance.select; + expect(selectCDR).toBeDefined(); + expect(selectCDR.value).toBe('ID'); + }); + }); + describe('Input with input group directives - hint, label, prefix, suffix: ', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxSelectAffixComponent); + select = fixture.componentInstance.select; + fixture.detectChanges(); + }); + + it('should not open dropdown on hint container click', + fakeAsync(() => { + const hint = fixture.debugElement.query(By.directive(IgxHintDirective)); + const hintContainer: HTMLElement = hint.nativeElement.parentElement; + + expect(select.collapsed).toBeTruthy(); + hintContainer.click(); + tick(); + fixture.detectChanges(); + expect(select.collapsed).toBeTruthy(); + })); + + it('should not open dropdown on hint element click', () => { + const hint = fixture.debugElement.query(By.directive(IgxHintDirective)); + expect(select.collapsed).toBeTruthy(); + hint.nativeElement.click(); + fixture.detectChanges(); + expect(select.collapsed).toBeTruthy(); + }); + }); +}); + +describe('igxSelect ControlValueAccessor Unit', () => { + let select: IgxSelectComponent; + it('Should correctly implement interface methods', () => { + const mockSelection = jasmine.createSpyObj('IgxSelectionAPIService', ['get', 'set', 'clear', 'delete', 'first_item']); + const mockCdr = jasmine.createSpyObj('ChangeDetectorRef', ['detectChanges']); + const mockNgControl = jasmine.createSpyObj('NgControl', ['registerOnChangeCb', 'registerOnTouchedCb']); + const mockInjector = jasmine.createSpyObj('Injector', { + get: mockNgControl + }); + const mockDocument = jasmine.createSpyObj('DOCUMENT', [], { 'defaultView': { getComputedStyle: () => null }}); + + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], + providers: [ + { provide: ElementRef, useValue: null }, + { provide: IgxSelectionAPIService, useValue: mockSelection }, + { provide: ChangeDetectorRef, useValue: mockCdr }, + { provide: DOCUMENT, useValue: mockDocument }, + { provide: Injector, useValue: mockInjector }, + { provide: IGX_DROPDOWN_BASE, useValue: {} }, + IgxSelectComponent, + IgxDropDownItemComponent, + ] + }); + + // init + select = TestBed.inject(IgxSelectComponent); + select.ngOnInit(); + select.registerOnChange(mockNgControl.registerOnChangeCb); + select.registerOnTouched(mockNgControl.registerOnTouchedCb); + expect(mockInjector.get).toHaveBeenCalledWith(NgControl, null); + + // writeValue + expect(select.value).toBeUndefined(); + select.writeValue('test'); + expect(mockSelection.clear).toHaveBeenCalled(); + expect(select.value).toBe('test'); + + // setDisabledState + select.setDisabledState(true); + expect(select.disabled).toBe(true); + select.setDisabledState(false); + expect(select.disabled).toBe(false); + + // OnChange callback + const item = TestBed.inject(IgxDropDownItemComponent); + item.value = 'itemValue'; + select.selectItem(item); + expect(mockSelection.set).toHaveBeenCalledWith(select.id, new Set([item])); + expect(mockNgControl.registerOnChangeCb).toHaveBeenCalledWith('itemValue'); + + // OnTouched callback + select.onFocus(); + expect(mockNgControl.registerOnTouchedCb).toHaveBeenCalledTimes(1); + + select.input = {} as any; + spyOnProperty(select, 'collapsed').and.returnValue(true); + select.onBlur(); + expect(mockNgControl.registerOnTouchedCb).toHaveBeenCalledTimes(2); + + // destroy + select.ngOnDestroy(); + expect(mockSelection.delete).toHaveBeenCalled(); + }); + + it('Should correctly handle ngControl validity', () => { + pending('Convert existing form test here'); + }); +}); + +@Component({ + template: ` + + + + @for (item of items; track itemTrack(item, $index)) { + + {{ item }} {{'©'}} + + } + + `, + imports: [FormsModule, IgxSelectComponent, IgxSelectItemComponent, IgxLabelDirective] +}) +class IgxSelectSimpleComponent { + @ViewChild('dummyInput') public dummyInput: ElementRef; + @ViewChild('select', { read: IgxSelectComponent, static: true }) + public select: IgxSelectComponent; + @ViewChild('simpleLabel', { read: ElementRef, static: true }) + public label1: ElementRef; + public items: string[] = [ + 'New York', + 'Sofia', + 'Istanbul', + 'Paris', + 'Hamburg', + 'Berlin', + 'London', + 'Oslo', + 'Los Angeles', + 'Rome', + 'Madrid', + 'Ottawa', + 'Prague', + 'Padua', + 'Palermo', + 'Palma de Mallorca', + 'Amsterdam']; + public value = null; + + // returns an array of the filtered items indexes + public filterCities(startsWith: string) { + return this.items.map((city, index) => city.toString().toLowerCase().startsWith(startsWith.toLowerCase()) ? index : undefined) + .filter(x => x); + } + + public itemTrack = (item: string, _index: number) => item; +} +@Component({ + template: ` + + @for (location of locations; track location.continent) { + {{location.continent}} + @for (capital of location.capitals; track capital) { + + {{ capital }} star + + } + + } + +`, + imports: [FormsModule, IgxSelectComponent, IgxSelectGroupComponent, IgxSelectItemComponent, IgxIconComponent] +}) +class IgxSelectGroupsComponent { + @ViewChild('select', { read: IgxSelectComponent, static: true }) + public select: IgxSelectComponent; + public locations: { continent: string, capitals: string[] } [] = [ + { continent: 'Europe', capitals: ['Berlin', 'London', 'Paris'] }, + { continent: 'South America', capitals: ['Buenos Aires', 'Caracas', 'Lima'] }, + { continent: 'North America', capitals: ['Washington', 'Ottawa', 'Mexico City'] } + ]; +} + +@Component({ + template: ` +
    + + @for (item of items; track item) { + + {{ item }} + + } + +
    +`, + styles: [':host-context { display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; }'], + imports: [FormsModule, IgxSelectComponent, IgxSelectItemComponent] +}) +class IgxSelectMiddleComponent { + @ViewChild('select', { read: IgxSelectComponent, static: true }) + public select: IgxSelectComponent; + public items: string[] = [ + 'Option 1', + 'Option 2', + 'Option 3' + ]; +} +@Component({ + template: ` + + @for (item of items; track item) { + + {{ item }} + + } + + `, + selector: 'igx-select-top', + imports: [FormsModule, IgxSelectComponent, IgxSelectItemComponent, NgStyle] +}) +class IgxSelectTopComponent { + @ViewChild('select', { read: IgxSelectComponent, static: true }) + public select: IgxSelectComponent; + public items: string[] = [ + 'Option 1', + 'Option 2', + 'Option 3', + 'Option 4', + 'Option 5', + 'Option 6', + 'Option 7', + 'Option 8', + 'Option 9', + 'Option 10']; +} +@Component({ + template: ` + + @for (item of items; track item) { + + {{ item }} + + } + + `, + selector: 'igx-select-bottom', + imports: [FormsModule, IgxSelectComponent, IgxSelectItemComponent, NgStyle] +}) +class IgxSelectBottomComponent { + @ViewChild('select', { read: IgxSelectComponent, static: true }) + public select: IgxSelectComponent; + public items: string[] = [ + 'Option 1', + 'Option 2', + 'Option 3', + 'Option 4', + 'Option 5', + 'Option 6', + 'Option 7', + 'Option 8', + 'Option 9', + 'Option 10']; +} +@Component({ + template: ` + + + favorite + home + search + + + alarm + + I am a Hint + @for (item of items; track item) { + + {{ item }} + + } + + `, + imports: [FormsModule, IgxSelectComponent, IgxSelectItemComponent, IgxIconComponent, IgxPrefixDirective, IgxSuffixDirective, IgxHintDirective, NgStyle] +}) +class IgxSelectAffixComponent { + @ViewChild('select', { read: IgxSelectComponent, static: true }) + public select: IgxSelectComponent; + public items: string[] = [ + 'Option 1', + 'Option 2', + 'Option 3', + 'Option 4', + 'Option 5', + 'Option 6', + 'Option 7' + ]; +} + +@Component({ + template: ` +
    +

    + + +

    +

    + + +

    +

    + + + + alarm + + @for (item of items; track item) { + + {{ item }} + + } + +

    +

    + +

    + + `, + imports: [ReactiveFormsModule, IgxSelectComponent, IgxSelectItemComponent, IgxPrefixDirective, IgxLabelDirective, IgxIconComponent] +}) +class IgxSelectReactiveFormComponent { + @ViewChild('selectReactive', { read: IgxSelectComponent, static: true }) + public select: IgxSelectComponent; + public reactiveForm: UntypedFormGroup; + public items: string[] = [ + 'Option 1', + 'Option 2', + 'Option 3', + 'Option 4', + 'Option 5', + 'Option 6', + 'Option 7' + ]; + + public validationType = { + firstName: [Validators.required, Validators.pattern('^[\\w\\s/-/(/)]{3,50}$')], + password: [Validators.required, Validators.maxLength(12)], + optionsSelect: [Validators.required] + }; + + constructor() { + const fb = inject(UntypedFormBuilder); + + this.reactiveForm = fb.group({ + firstName: new UntypedFormControl('', Validators.required), + password: ['', Validators.required], + optionsSelect: ['', Validators.required] + }); + } + public onSubmitReactive() { } + + public removeValidators(form: UntypedFormGroup) { + for (const key in form.controls) { + form.get(key).clearValidators(); + form.get(key).updateValueAndValidity(); + } + } + + public addValidators(form: UntypedFormGroup) { + for (const key in form.controls) { + form.get(key).setValidators(this.validationType[key]); + form.get(key).updateValueAndValidity(); + } + } + + public markAsTouched() { + if (!this.reactiveForm.valid) { + for (const key in this.reactiveForm.controls) { + if (this.reactiveForm.controls[key]) { + this.reactiveForm.controls[key].markAsTouched(); + this.reactiveForm.controls[key].updateValueAndValidity(); + } + } + } + } +} + +@Component({ + template: ` +
    +

    + + + + + alarm + + @for (item of items; track item) { + + {{ item }} + + } + +

    +

    + +

    + + `, + imports: [FormsModule, IgxSelectComponent, IgxSelectItemComponent, IgxPrefixDirective, IgxLabelDirective, IgxIconComponent] +}) +class IgxSelectTemplateFormComponent { + @ViewChild('selectInForm', { read: IgxSelectComponent, static: true }) + public select: IgxSelectComponent; + @ViewChild(NgForm, { static: true }) + public ngForm: NgForm; + + public isRequired = true; + public model = { + option: null + }; + public items: string[] = [ + 'Option 1', + 'Option 2', + 'Option 3', + 'Option 4', + 'Option 5', + 'Option 6', + 'Option 7' + ]; + + public onSubmit() { } +} +@Component({ + template: ` +

    Select with ngModel, set items OnInit

    + + + + alarm + + None + @for (item of items; track item.field) { + + {{ item.field }} + + } + +
    iHEADER
    +
    + + + +
    + `, + styles: [` + .custom-select-header, + .custom-select-footer { + padding: 4px 8px; + background: gray; + text-align: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, .08); + } + `], + imports: [FormsModule, IgxSelectComponent, IgxSelectItemComponent, IgxButtonDirective, IgxLabelDirective, IgxPrefixDirective, IgxIconComponent, IgxSelectHeaderDirective, IgxSelectFooterDirective] +}) +class IgxSelectHeaderFooterComponent implements OnInit { + @ViewChild('headerFooterSelect', { read: IgxSelectComponent, static: true }) + public select: IgxSelectComponent; + + public items: any[] = []; + public ngOnInit() { + for (let i = 1; i < 10; i++) { + const item = { field: 'opt' + i }; + this.items.push(item); + } + } +} + +@Component({ + template: ` +

    'if' test select for 'expression changed...console Warning'

    + @if (render) { +
    + + + @for (column of columns; track column.field) { + + {{column.field}} + + } + +
    + } + `, + imports: [IgxSelectComponent, IgxSelectItemComponent, IgxLabelDirective] +}) +class IgxSelectCDRComponent { + @ViewChild('selectCDR', { read: IgxSelectComponent, static: false }) + public select: IgxSelectComponent; + + public render = true; + public columns: Array = [ + { field: 'ID', type: 'string' }, + { field: 'CompanyName', type: 'string' }, + { field: 'ContactName', type: 'string' } + ]; +} + +@Component({ + template: ` + + @for (item of items; track item) { + + {{item}} + + } + + `, + imports: [IgxSelectComponent, IgxSelectItemComponent] +}) +class IgxSelectWithIdComponent { + @ViewChild(IgxSelectComponent, { read: IgxSelectComponent, static: true }) + public select: IgxSelectComponent; + + public items: string[] = ['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5']; +} diff --git a/projects/igniteui-angular/select/src/select/select.component.ts b/projects/igniteui-angular/select/src/select/select.component.ts new file mode 100644 index 00000000000..ef1ea836949 --- /dev/null +++ b/projects/igniteui-angular/select/src/select/select.component.ts @@ -0,0 +1,600 @@ +import { AfterContentChecked, AfterContentInit, AfterViewInit, booleanAttribute, Component, ContentChild, ContentChildren, Directive, ElementRef, EventEmitter, forwardRef, HostBinding, Injector, Input, OnDestroy, OnInit, Output, QueryList, TemplateRef, ViewChild, ViewChildren, inject } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import { AbstractControl, ControlValueAccessor, NgControl, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { noop } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +import { + EditorProvider, + IBaseCancelableBrowserEventArgs, + IBaseEventArgs, + AbsoluteScrollStrategy, + OverlaySettings +} from 'igniteui-angular/core'; +import { IgxSelectItemComponent } from './select-item.component'; +import { SelectPositioningStrategy } from './select-positioning-strategy'; +import { IgxSelectBase } from './select.common'; +import { IgxHintDirective, IgxInputGroupType, IgxPrefixDirective, IGX_INPUT_GROUP_TYPE, IgxInputGroupComponent, IgxInputDirective, IgxInputState, IgxLabelDirective, IgxReadOnlyInputDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; +import { ToggleViewCancelableEventArgs, ToggleViewEventArgs, IgxToggleDirective } from 'igniteui-angular/directives'; +import { IgxOverlayService } from 'igniteui-angular/core'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxSelectItemNavigationDirective } from './select-navigation.directive'; +import { IGX_DROPDOWN_BASE, IgxDropDownComponent, IgxDropDownItemBaseDirective, ISelectionEventArgs, Navigate } from 'igniteui-angular/drop-down'; + +/** @hidden @internal */ +@Directive({ + selector: '[igxSelectToggleIcon]', + standalone: true +}) +export class IgxSelectToggleIconDirective { +} + +/** @hidden @internal */ +@Directive({ + selector: '[igxSelectHeader]', + standalone: true +}) +export class IgxSelectHeaderDirective { +} + +/** @hidden @internal */ +@Directive({ + selector: '[igxSelectFooter]', + standalone: true +}) +export class IgxSelectFooterDirective { +} + +/** + * **Ignite UI for Angular Select** - + * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/select) + * + * The `igxSelect` provides an input with dropdown list allowing selection of a single item. + * + * Example: + * ```html + * + * + * + * {{ item.field }} + * + * + * ``` + */ +@Component({ + selector: 'igx-select', + templateUrl: './select.component.html', + providers: [ + { provide: NG_VALUE_ACCESSOR, useExisting: IgxSelectComponent, multi: true }, + { provide: IGX_DROPDOWN_BASE, useExisting: IgxSelectComponent } + ], + styles: [` + :host { + display: block; + } + `], + imports: [IgxInputGroupComponent, IgxInputDirective, IgxSelectItemNavigationDirective, IgxSuffixDirective, IgxReadOnlyInputDirective, NgTemplateOutlet, IgxIconComponent, IgxToggleDirective] +}) +export class IgxSelectComponent extends IgxDropDownComponent implements IgxSelectBase, ControlValueAccessor, + AfterContentInit, OnInit, AfterViewInit, OnDestroy, EditorProvider, AfterContentChecked { + protected overlayService = inject(IgxOverlayService); + private _inputGroupType = inject(IGX_INPUT_GROUP_TYPE, { optional: true }); + private _injector = inject(Injector); + + + /** @hidden @internal */ + @ViewChild('inputGroup', { read: IgxInputGroupComponent, static: true }) public inputGroup: IgxInputGroupComponent; + + /** @hidden @internal */ + @ViewChild('input', { read: IgxInputDirective, static: true }) public input: IgxInputDirective; + + /** @hidden @internal */ + @ContentChildren(forwardRef(() => IgxSelectItemComponent), { descendants: true }) + public override children: QueryList; + + @ContentChildren(IgxPrefixDirective, { descendants: true }) + protected prefixes: QueryList; + + @ContentChildren(IgxSuffixDirective, { descendants: true }) + protected suffixes: QueryList; + + @ViewChildren(IgxSuffixDirective) + protected internalSuffixes: QueryList; + + /** @hidden @internal */ + @ContentChild(forwardRef(() => IgxLabelDirective), { static: true }) public label: IgxLabelDirective; + + /** + * Sets input placeholder. + * + */ + @Input() public placeholder; + + + /** + * Disables the component. + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) public disabled = false; + + /** + * Sets custom OverlaySettings `IgxSelectComponent`. + * ```html + * + * ``` + */ + @Input() + public overlaySettings: OverlaySettings; + + /** @hidden @internal */ + @HostBinding('style.maxHeight') + public override maxHeight = '256px'; + + /** + * Emitted before the dropdown is opened + * + * ```html + * + * ``` + */ + @Output() + public override opening = new EventEmitter(); + + /** + * Emitted after the dropdown is opened + * + * ```html + * + * ``` + */ + @Output() + public override opened = new EventEmitter(); + + /** + * Emitted before the dropdown is closed + * + * ```html + * + * ``` + */ + @Output() + public override closing = new EventEmitter(); + + /** + * Emitted after the dropdown is closed + * + * ```html + * + * ``` + */ + @Output() + public override closed = new EventEmitter(); + + /** + * The custom template, if any, that should be used when rendering the select TOGGLE(open/close) button + * + * ```typescript + * // Set in typescript + * const myCustomTemplate: TemplateRef = myComponent.customTemplate; + * myComponent.select.toggleIconTemplate = myCustomTemplate; + * ``` + * ```html + * + * + * ... + * + * {{ collapsed ? 'remove_circle' : 'remove_circle_outline'}} + * + * + * ``` + */ + @ContentChild(IgxSelectToggleIconDirective, { read: TemplateRef }) + public toggleIconTemplate: TemplateRef = null; + + /** + * The custom template, if any, that should be used when rendering the HEADER for the select items list + * + * ```typescript + * // Set in typescript + * const myCustomTemplate: TemplateRef = myComponent.customTemplate; + * myComponent.select.headerTemplate = myCustomTemplate; + * ``` + * ```html + * + * + * ... + * + *
    + * This is a custom header + *
    + *
    + *
    + * ``` + */ + @ContentChild(IgxSelectHeaderDirective, { read: TemplateRef, static: false }) + public headerTemplate: TemplateRef = null; + + /** + * The custom template, if any, that should be used when rendering the FOOTER for the select items list + * + * ```typescript + * // Set in typescript + * const myCustomTemplate: TemplateRef = myComponent.customTemplate; + * myComponent.select.footerTemplate = myCustomTemplate; + * ``` + * ```html + * + * + * ... + * + * + * + * + * ``` + */ + @ContentChild(IgxSelectFooterDirective, { read: TemplateRef, static: false }) + public footerTemplate: TemplateRef = null; + + @ContentChild(IgxHintDirective, { read: ElementRef }) private hintElement: ElementRef; + + /** @hidden @internal */ + public override width: string; + + /** @hidden @internal do not use the drop-down container class */ + public override cssClass = false; + + /** @hidden @internal */ + public override allowItemsFocus = false; + + /** @hidden @internal */ + public override height: string; + + private ngControl: NgControl = null; + private _overlayDefaults: OverlaySettings; + private _value: any; + private _type = null; + + /** + * Gets/Sets the component value. + * + * ```typescript + * // get + * let selectValue = this.select.value; + * ``` + * + * ```typescript + * // set + * this.select.value = 'London'; + * ``` + * ```html + * + * ``` + */ + @Input() + public get value(): any { + return this._value; + } + public set value(v: any) { + if (this._value === v) { + return; + } + this._value = v; + this.setSelection(this.items.find(x => x.value === this.value)); + } + + /** + * Sets how the select will be styled. + * The allowed values are `line`, `box` and `border`. The input-group default is `line`. + * ```html + * + * ``` + */ + @Input() + public get type(): IgxInputGroupType { + return this._type || this._inputGroupType || 'line'; + } + + public set type(val: IgxInputGroupType) { + this._type = val; + } + + /** @hidden @internal */ + public get selectionValue() { + const selectedItem = this.selectedItem; + return selectedItem ? selectedItem.itemText : ''; + } + + /** @hidden @internal */ + public override get selectedItem(): IgxSelectItemComponent { + return this.selection.first_item(this.id); + } + + private _onChangeCallback: (_: any) => void = noop; + private _onTouchedCallback: () => void = noop; + + //#region ControlValueAccessor + + /** @hidden @internal */ + public writeValue = (value: any) => { + this.value = value; + }; + + /** @hidden @internal */ + public registerOnChange(fn: any): void { + this._onChangeCallback = fn; + } + + /** @hidden @internal */ + public registerOnTouched(fn: any): void { + this._onTouchedCallback = fn; + } + + /** @hidden @internal */ + public setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + //#endregion + + /** @hidden @internal */ + public getEditElement(): HTMLInputElement { + return this.input.nativeElement; + } + + /** @hidden @internal */ + public override selectItem(newSelection: IgxDropDownItemBaseDirective, event?) { + const oldSelection = this.selectedItem ?? {}; + + if (newSelection === null || newSelection.disabled || newSelection.isHeader) { + return; + } + + if (newSelection === oldSelection) { + this.toggleDirective.close(); + return; + } + + const args: ISelectionEventArgs = { oldSelection, newSelection, cancel: false, owner: this }; + this.selectionChanging.emit(args); + + if (args.cancel) { + return; + } + + this.setSelection(newSelection); + this._value = newSelection.value; + + if (event) { + this.toggleDirective.close(); + } + + this.cdr.detectChanges(); + this._onChangeCallback(this.value); + } + + /** @hidden @internal */ + public getFirstItemElement(): HTMLElement { + return this.children.first.element.nativeElement; + } + + /** + * Opens the select + * + * ```typescript + * this.select.open(); + * ``` + */ + public override open(overlaySettings?: OverlaySettings) { + if (this.disabled || this.items.length === 0) { + return; + } + if (!this.selectedItem) { + this.navigateFirst(); + } + + super.open(Object.assign({}, this._overlayDefaults, this.overlaySettings, overlaySettings)); + } + + public inputGroupClick(event: MouseEvent, overlaySettings?: OverlaySettings) { + const targetElement = event.target as HTMLElement; + + if (this.hintElement && targetElement.contains(this.hintElement.nativeElement)) { + return; + } + this.toggle(Object.assign({}, this._overlayDefaults, this.overlaySettings, overlaySettings)); + } + + /** @hidden @internal */ + public ngAfterContentInit() { + this._overlayDefaults = { + target: this.getEditElement(), + modal: false, + positionStrategy: new SelectPositioningStrategy(this), + scrollStrategy: new AbsoluteScrollStrategy(), + excludeFromOutsideClick: [this.inputGroup.element.nativeElement as HTMLElement] + }; + const changes$ = this.children.changes.pipe(takeUntil(this.destroy$)).subscribe(() => { + this.setSelection(this.items.find(x => x.value === this.value)); + this.cdr.detectChanges(); + }); + Promise.resolve().then(() => { + if (!changes$.closed) { + this.children.notifyOnChanges(); + } + }); + } + + /** + * Event handlers + * + * @hidden @internal + */ + public handleOpening(e: ToggleViewCancelableEventArgs) { + const args: IBaseCancelableBrowserEventArgs = { owner: this, event: e.event, cancel: e.cancel }; + this.opening.emit(args); + + e.cancel = args.cancel; + if (args.cancel) { + return; + } + } + + /** @hidden @internal */ + public override onToggleContentAppended(event: ToggleViewEventArgs) { + const info = this.overlayService.getOverlayById(event.id); + if (info?.settings?.positionStrategy instanceof SelectPositioningStrategy) { + return; + } + super.onToggleContentAppended(event); + } + + /** @hidden @internal */ + public handleOpened() { + this.updateItemFocus(); + this.opened.emit({ owner: this }); + } + + /** @hidden @internal */ + public handleClosing(e: ToggleViewCancelableEventArgs) { + const args: IBaseCancelableBrowserEventArgs = { owner: this, event: e.event, cancel: e.cancel }; + this.closing.emit(args); + e.cancel = args.cancel; + } + + /** @hidden @internal */ + public handleClosed() { + this.focusItem(false); + this.closed.emit({ owner: this }); + } + + /** @hidden @internal */ + public onBlur(): void { + this._onTouchedCallback(); + if (this.ngControl && this.ngControl.invalid) { + this.input.valid = IgxInputState.INVALID; + } else { + this.input.valid = IgxInputState.INITIAL; + } + } + + /** @hidden @internal */ + public onFocus(): void { + this._onTouchedCallback(); + } + + /** + * @hidden @internal + */ + public override ngOnInit() { + this.ngControl = this._injector.get(NgControl, null); + } + + /** + * @hidden @internal + */ + public override ngAfterViewInit() { + super.ngAfterViewInit(); + + if (this.ngControl) { + this.ngControl.statusChanges.pipe(takeUntil(this.destroy$)).subscribe(this.onStatusChanged.bind(this)); + this.manageRequiredAsterisk(); + } + + this.cdr.detectChanges(); + } + + /** @hidden @internal */ + public ngAfterContentChecked() { + if (this.inputGroup && this.prefixes?.length > 0) { + this.inputGroup.prefixes = this.prefixes; + } + + if (this.inputGroup) { + const suffixesArray = this.suffixes?.toArray() ?? []; + const internalSuffixesArray = this.internalSuffixes?.toArray() ?? []; + const mergedSuffixes = new QueryList(); + mergedSuffixes.reset([ + ...suffixesArray, + ...internalSuffixesArray + ]); + this.inputGroup.suffixes = mergedSuffixes; + } + } + + /** @hidden @internal */ + public get toggleIcon(): string { + return this.collapsed ? 'input_expand' : 'input_collapse'; + } + + /** + * @hidden @internal + * Prevent input blur - closing the items container on Header/Footer Template click. + */ + public mousedownHandler(event) { + event.preventDefault(); + } + + protected onStatusChanged() { + this.manageRequiredAsterisk(); + if (this.ngControl && !this.disabled && this.isTouchedOrDirty) { + if (this.hasValidators && this.inputGroup.isFocused) { + this.input.valid = this.ngControl.valid ? IgxInputState.VALID : IgxInputState.INVALID; + } else { + // B.P. 18 May 2021: IgxDatePicker does not reset its state upon resetForm #9526 + this.input.valid = this.ngControl.valid ? IgxInputState.INITIAL : IgxInputState.INVALID; + } + } else { + this.input.valid = IgxInputState.INITIAL; + } + } + + private get isTouchedOrDirty(): boolean { + return (this.ngControl.control.touched || this.ngControl.control.dirty); + } + + private get hasValidators(): boolean { + return (!!this.ngControl.control.validator || !!this.ngControl.control.asyncValidator); + } + + protected override navigate(direction: Navigate, currentIndex?: number) { + if (this.collapsed && this.selectedItem) { + this.navigateItem(this.selectedItem.itemIndex); + } + super.navigate(direction, currentIndex); + } + + protected manageRequiredAsterisk(): void { + const hasRequiredHTMLAttribute = this.elementRef.nativeElement.hasAttribute('required'); + let isRequired = false; + + if (this.ngControl && this.ngControl.control.validator) { + const error = this.ngControl.control.validator({} as AbstractControl); + isRequired = !!(error && error.required); + } + + this.inputGroup.isRequired = isRequired; + + if (this.input?.nativeElement) { + this.input.nativeElement.setAttribute('aria-required', isRequired.toString()); + } + + // Handle validator removal case + if (!isRequired && !hasRequiredHTMLAttribute) { + this.input.valid = IgxInputState.INITIAL; + } + + this.cdr.markForCheck(); + } + + private setSelection(item: IgxDropDownItemBaseDirective) { + if (item && item.value !== undefined && item.value !== null) { + this.selection.set(this.id, new Set([item])); + } else { + this.selection.clear(this.id); + } + } +} + diff --git a/projects/igniteui-angular/select/src/select/select.module.ts b/projects/igniteui-angular/select/src/select/select.module.ts new file mode 100644 index 00000000000..53ab4d2cdbc --- /dev/null +++ b/projects/igniteui-angular/select/src/select/select.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { IGX_SELECT_DIRECTIVES } from './public_api'; + +/** + * @hidden + * @deprecated + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + ...IGX_SELECT_DIRECTIVES + ], + exports: [ + ...IGX_SELECT_DIRECTIVES + ] +}) +export class IgxSelectModule { } diff --git a/projects/igniteui-angular/simple-combo/README.md b/projects/igniteui-angular/simple-combo/README.md new file mode 100644 index 00000000000..a854fdb969d --- /dev/null +++ b/projects/igniteui-angular/simple-combo/README.md @@ -0,0 +1,315 @@ +# igx-simple-combo +The `igx-simple-combo` is a modification of the `igx-combo` component that allows single selection and has the appropriate UI and behavior for that. It inherits most of the `igx-combo`'s API. +It provides an editable input used for filtering data while also using the IgniteUI for Angular's `igx-drop-down` component to display the items in the data set. +Alongside easy filtering and selection of a single item, the control provides grouping and adding of custom values to the data set. +Templates can be provided in order to customize different areas of the components, such as items, header, footer, etc. +Additionally, the control is integrated with the Template Driven and Reactive Forms. +It also exposes intuitive keyboard navigation and it is accessibility compliant. +Another thing worth mentioning is that the Drop Down items are virtualized, which guarantees smooth work, even if the control is bound to data source with a lot of items. + + +A walk through of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/simple-combo.html) + +# Usage +Basic usage of `igx-simple-combo` bound to a local data source, defining `valueKey` and `displayKey`: + +```html + +``` + +Remote binding, defining `valueKey` and `displayKey`, and exposing `dataPreLoad` that allows to load new chunk of remote data to the combo (see the sample above as a reference): + +```html + +``` + +```typescript +public ngOnInit(): void { + this.remoteData = this.remoteService.remoteData; +} + +public ngAfterViewInit(): void { + this.remoteService.getData(this.combo.virtualizationState, (data) => { + this.combo.totalItemCount = data.length; + }); +} + +public dataLoading(evt): void { + if (this.prevRequest) { + this.prevRequest.unsubscribe(); + } + + this.prevRequest = this.remoteService.getData(this.combo.virtualizationState, () => { + this.cdr.detectChanges(); + this.combo.triggerCheck(); + }); +} +``` + +> Note: In order to have a combo with remote data, what you need is to have a service that retrieves data chunks from a server. +What the combo exposes is a `virtualizationState` property that gives state of the combo - first index and the number of items that needs to be loaded. +The service, should inform the combo for the total items that are on the server - using the `totalItemCount` property. + +## Features + +### Selection + +Combo selection depends on the `[valueKey]` input property: + +- If a `[valueKey]` is specified, **all** methods and events tied to the selection operate w/ the value key property of the combo's `[data]` items: +```html + +``` +```typescript +export class MyCombo { + ... + public combo: IgxSimpleComboComponent; + public myCustomData: { id: number, text: string } = [{ id: 0, name: "One" }, ...]; + ... + public ngOnInit(): void { + // Selection is done only by valueKey property value + this.combo.select(0); + } +} +``` + +- When **no** `valueKey` is specified, selection is handled by **equality (===)**. To select items by object reference, the `valueKey` property should be removed: +```html + +``` +```typescript +export class MyCombo { + public ngOnInit(): void { + this.combo.select(this.data[0]); + } +} +``` + +### Value Binding + +If we want to use a two-way data-binding, we could just use `ngModel` like this: + +```html + +``` +```typescript +export class MyExampleComponent { + ... + public data: {text: string, id: number, ... }[] = ...; + ... + public value: number = ...; +} +``` + +When the `data` input is made up of complex types (i.e. objects), it is advised to bind the selected data via `valueKey` (as in the above code snippet). Specify a property that is unique for each data entry and pass in a value to the combo that is the same as the unique identifier in the data set. + +If you want to bind the selected data by reference, **do not** specify a `valueKey`: + +```html + +``` +```typescript +export class MyExampleComponent { + ... + public data: {text: string, id: number, ... }[] = ...; + ... + public value: {text: string, id: number, ...} = this.items[0]; +} +``` + +
    + +### Filtering +Unlike the `igx-combo`, filtering in the `igx-simple-combo` is always enabled. + +
    + +### Custom Values +Enabling the custom values will add values that are missing from the list, using the combo's interface. + +```html + +``` + +
    + +### Disabled +You can disable the combo using the following code: + +```html + +``` + +
    + +### Grouping +Defining a combo's groupKey option will group the items, according to that key. + +```html + +``` + +
    + +### Templates +Templates for different parts of the control can be defined, including items, header and footer, etc. +When defining one of them, you need to reference list of predefined names, as follows: + +#### Defining item template: +```html + + +
    + State: {{ display[key] }} + Region: {{ display.region }} +
    +
    +
    +``` + +#### Defining group headers template: + +```html + + +
    + Header for {{ headerItem[key] }} +
    +
    +
    +``` +#### Defining header template: + +```html + + +
    Custom header
    + +
    +
    +``` + +#### Defining footer template: + +```html + + + + + + +``` + +#### Defining empty template: + +```html + + + List is empty + + +``` + +#### Defining add template: + +```html + + + Add town + + +``` + +#### Defining toggle icon template: + +```html + + + {{ collapsed ? 'remove_circle' : 'remove_circle_outline'}} + + +``` + +#### Defining toggle icon template: + +```html + + + clear + + +``` + +## Keyboard Navigation + +When the combo is closed and focused: +- `ArrowDown` or `Alt` + `ArrowDown` will open the dropdown and will move focus to the selected item, if no selected item is present, the first item in the list will be focused. + +When the combo is opened: +- `ArrowUp` will close the dropdown if the search input is focused. If the active item is the first one in the list, the focus will be moved back to the search input while also selecting all of the text in the input. Otherwise `ArrowUp` will move to the previous list item. +- `ArrowDown` will move focus from the search input to the first list item. If list is empty and custom values are enabled will move it to the Add new item button. +- `Alt` + `ArrowUp` will close the dropdown. + +When the combo is opened and a list item is focused: +- `End` will move to last list item. +- `Home` will move to first list item. +- `Space` will select/deselect active list item without closing the dropdown. +- `Enter` will confirm the currently focused item as selected and will close the dropdown. +- `Esc` will close the dropdown. + +When the combo is opened, allow custom values are enabled and add item button is focused: +- `Enter` will add new item with `valueKey` and `displayKey` equal to the text in the input and will select the new item. +- `ArrowUp` will move the focus back to the last list item or if the list is empty will move it to the input. + +
    + +### Properties +| Name | Description | Type | +|--------------------------|---------------------------------------------------|-----------------------------| +| `id` | The combo's id. | `string` | +| `data` | The combo's data source. | `any[]` | +| `value` | The combo's value. | `any` | +| `selection` | The combo's selected item. | `any` | +| `allowCustomValue` | Enables/disables combo custom value. | `boolean` | +| `valueKey` | Determines which column in the data source is used to determine the value. | `string` | +| `displayKey` | Determines which column in the data source is used to determine the display value. | `string` | +| `groupKey` | The combo's item group. | `string` | +| `virtualizationState` | Defines the current state of the virtualized data. It contains `startIndex` and `chunkSize`. | `IForOfState` | +| `totalItemCount` | Total count of the virtual data items, when using remote service. | `number` | +| `width ` | Defines combo width. | `string` | +| `height` | Defines combo height. | `string` | +| `itemsMaxHeight ` | Defines dropdown maximum height. | `number` | +| `itemsWidth ` | Defines dropdown width. | `string` | +| `itemHeight ` | Defines dropdown item height. | `number` | +| `placeholder ` | Defines the "empty value" text. | `string` | +| `collapsed` | Gets the dropdown state. | `boolean` | +| `disabled` | Defines whether the control is active or not. | `boolean` | +| `ariaLabelledBy` | Defines label ID related to combo. | `boolean` | +| `valid` | gets if control is valid, when used in a form. | `boolean` | +| `overlaySettings` | Controls how the dropdown is displayed. | `OverlaySettings` | +| `selected` | Get current selection state. | `Array` | +| `filteringOptions` | Configures the way combo items will be filtered | IComboFilteringOptions | +| `filterFunction` | Gets/Sets the custom filtering function of the combo | `(collection: any[], searchValue: any, caseSensitive: boolean) => any[]` | + + +### Methods +| Name | Description | Return type | Parameters | +|----------------- |-----------------------------|----------------------|-----------------------------| +| `open` | Opens dropdown | `void` | `None` | +| `close` | Closes dropdown | `void` | `None` | +| `toggle` | Toggles dropdown | `void` | `None` | +| `select` | Select a defined item | `void` | newItem: `any` | +| `deselect` | Deselect the currently selected item | `void` | `None` | + + +### Events +| Name | Description | Cancelable | Parameters | +|------------------ |-------------------------------------------------------------------------|------------- |-----------------------------------------| +| `selectionChanging` | Emitted when item selection is changing, before the selection completes | true | { oldSelection: `any`, newSelection: `any`, displayText: `string`, owner: `IgxSimpleComboComponent` } | +| `searchInputUpdate` | Emitted when an the search input's input event is triggered | true | `IComboSearchInputEventArgs` | +| `addition` | Emitted when an item is being added to the data collection | false | { oldCollection: `Array`, addedItem: ``, newCollection: `Array`, owner: `IgxSimpleComboComponent` }| +| `onDataPreLoad` | Emitted when new chunk of data is loaded from the virtualization | false | `IForOfState` | +| `opening` | Emitted before the dropdown is opened | true | `IBaseCancelableBrowserEventArgs` | +| `opened` | Emitted after the dropdown is opened | false | `IBaseEventArgs` | +| `closing` | Emitted before the dropdown is closed | true | `IBaseCancelableBrowserEventArgs` | +| `closed` | Emitted after the dropdown is closed | false | `IBaseEventArgs` | diff --git a/projects/igniteui-angular/simple-combo/index.ts b/projects/igniteui-angular/simple-combo/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/simple-combo/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/simple-combo/ng-package.json b/projects/igniteui-angular/simple-combo/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/simple-combo/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/simple-combo/src/public_api.ts b/projects/igniteui-angular/simple-combo/src/public_api.ts new file mode 100644 index 00000000000..8e1d394159e --- /dev/null +++ b/projects/igniteui-angular/simple-combo/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './simple-combo/public_api'; +export * from './simple-combo/simple-combo.module'; diff --git a/projects/igniteui-angular/simple-combo/src/simple-combo/public_api.ts b/projects/igniteui-angular/simple-combo/src/simple-combo/public_api.ts new file mode 100644 index 00000000000..d3887bd3039 --- /dev/null +++ b/projects/igniteui-angular/simple-combo/src/simple-combo/public_api.ts @@ -0,0 +1,22 @@ +import { IgxHintDirective, IgxLabelDirective, IgxPrefixDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; +import { IgxSimpleComboComponent } from './simple-combo.component'; +import { IgxComboAddItemDirective, IgxComboClearIconDirective, IgxComboEmptyDirective, IgxComboFooterDirective, IgxComboHeaderDirective, IgxComboHeaderItemDirective, IgxComboItemDirective, IgxComboToggleIconDirective } from 'igniteui-angular/combo'; + +export * from './simple-combo.component'; + +/* NOTE: Simple combo directives collection for ease-of-use import in standalone components scenario */ +export const IGX_SIMPLE_COMBO_DIRECTIVES = [ + IgxSimpleComboComponent, + IgxComboAddItemDirective, + IgxComboClearIconDirective, + IgxComboEmptyDirective, + IgxComboFooterDirective, + IgxComboHeaderDirective, + IgxComboHeaderItemDirective, + IgxComboItemDirective, + IgxComboToggleIconDirective, + IgxLabelDirective, + IgxPrefixDirective, + IgxSuffixDirective, + IgxHintDirective +] as const; diff --git a/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.html b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.html new file mode 100644 index 00000000000..21252f137c7 --- /dev/null +++ b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.html @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + @if (hasSelectedItem) { + + @if (clearIconTemplate) { + + } + @if (!clearIconTemplate) { + + } + + } + + @if (showSearchCaseIcon) { + + + + + } + + + @if (toggleIconTemplate) { + + } + @if (!toggleIconTemplate) { + + } + + + + + + + +
    + + @if (item?.isHeader) { + + + } + + @if (!item?.isHeader) { + + + } + +
    + + @if (filteredData.length === 0 || isAddButtonVisible()) { +
    + @if (filteredData.length === 0) { +
    + + +
    + } + @if (isAddButtonVisible()) { + + + + + } +
    + } + + +
    + + + {{display[key]}} + + + {{display}} + + + {{resourceStrings.igx_combo_empty_message}} + + + + + + {{ item[key] }} + diff --git a/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.spec.ts b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.spec.ts new file mode 100644 index 00000000000..1030bcc7ab5 --- /dev/null +++ b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.spec.ts @@ -0,0 +1,3447 @@ +import { AsyncPipe } from '@angular/common'; +import { AfterViewInit, ChangeDetectorRef, Component, DOCUMENT, DebugElement, ElementRef, Injector, OnDestroy, OnInit, ViewChild, inject } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { FormControl, FormGroup, FormsModule, NgForm, ReactiveFormsModule, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxSelectionAPIService, PlatformUtil } from 'igniteui-angular/core'; +import { IBaseCancelableBrowserEventArgs } from 'igniteui-angular/core'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxInputState, IgxLabelDirective } from '../../../input-group/src/public_api'; +import { AbsoluteScrollStrategy, AutoPositionStrategy, ConnectedPositioningStrategy } from 'igniteui-angular/core'; +import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; +import { IgxSimpleComboComponent, ISimpleComboSelectionChangingEventArgs } from './public_api'; +import { IGX_GRID_DIRECTIVES, IgxGridComponent } from 'igniteui-angular/grids/grid'; +import { IComboSelectionChangingEventArgs, IgxComboAPIService, IgxComboDropDownComponent, IgxComboFooterDirective, IgxComboHeaderDirective, IgxComboItemDirective, IgxComboToggleIconDirective } from 'igniteui-angular/combo'; +import { RemoteDataService } from 'igniteui-angular/combo/src/combo/combo.component.spec'; + + +const CSS_CLASS_COMBO = 'igx-combo'; +const SIMPLE_COMBO_ELEMENT = 'igx-simple-combo'; +const CSS_CLASS_COMBO_DROPDOWN = 'igx-combo__drop-down'; +const CSS_CLASS_DROPDOWN = 'igx-drop-down'; +const CSS_CLASS_DROPDOWNLIST = 'igx-drop-down__list'; +const CSS_CLASS_DROPDOWNLIST_SCROLL = 'igx-drop-down__list-scroll'; +const CSS_CLASS_CONTENT = 'igx-combo__content'; +const CSS_CLASS_CONTAINER = 'igx-display-container'; +const CSS_CLASS_DROPDOWNLISTITEM = 'igx-drop-down__item'; +const CSS_CLASS_TOGGLEBUTTON = 'igx-combo__toggle-button'; +const CSS_CLASS_CLEARBUTTON = 'igx-combo__clear-button'; +const CSS_CLASS_ADDBUTTON = 'igx-combo__add-item'; +const CSS_CLASS_FOCUSED = 'igx-drop-down__item--focused'; +const CSS_CLASS_INPUTGROUP = 'igx-input-group'; +const CSS_CLASS_COMBO_INPUTGROUP = 'igx-input-group__input'; +const CSS_CLASS_INPUTGROUP_REQUIRED = 'igx-input-group--required'; +const CSS_CLASS_HEADER = 'header-class'; +const CSS_CLASS_FOOTER = 'footer-class'; +const CSS_CLASS_INPUT_GROUP_REQUIRED = 'igx-input-group--required'; +const CSS_CLASS_INPUT_GROUP_INVALID = 'igx-input-group--invalid'; +const defaultDropdownItemHeight = 40; +const defaultDropdownItemMaxHeight = 240; + +describe('IgxSimpleCombo', () => { + let fixture: ComponentFixture; + let combo: IgxSimpleComboComponent; + let input: DebugElement; + let reactiveForm: NgForm; + let reactiveControl: any; + + describe('Unit tests: ', () => { + const data = ['Item1', 'Item2', 'Item3', 'Item4', 'Item5', 'Item6', 'Item7']; + const complexData = [ + { country: 'UK', city: 'London' }, + { country: 'France', city: 'Paris' }, + { country: 'Germany', city: 'Berlin' }, + { country: 'Bulgaria', city: 'Sofia' }, + { country: 'Austria', city: 'Vienna' }, + { country: 'Spain', city: 'Madrid' }, + { country: 'Italy', city: 'Rome' } + ]; + const elementRef = { nativeElement: null }; + const mockSelection: { + [key: string]: jasmine.Spy; + } = jasmine.createSpyObj('IgxSelectionAPIService', ['get', 'set', 'add_items', 'select_items', 'delete']); + const mockCdr = jasmine.createSpyObj('ChangeDetectorRef', ['markForCheck', 'detectChanges']); + const mockComboService = jasmine.createSpyObj('IgxComboAPIService', ['register', 'clear']); + const mockNgControl = jasmine.createSpyObj('NgControl', ['registerOnChangeCb', 'registerOnTouchedCb']); + const mockInjector = jasmine.createSpyObj('Injector', { + get: mockNgControl + }); + mockSelection.get.and.returnValue(new Set([])); + const platformUtil = null; + const mockDocument = jasmine.createSpyObj('DOCUMENT', [], { + 'body': document.createElement('div'), + 'defaultView': { + getComputedStyle: () => ({}) + } + }); + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [IgxSimpleComboComponent], + providers: [ + { provide: ElementRef, useValue: elementRef }, + { provide: ChangeDetectorRef, useValue: mockCdr }, + { provide: IgxSelectionAPIService, useValue: mockSelection }, + { provide: IgxComboAPIService, useValue: mockComboService }, + { provide: PlatformUtil, useValue: platformUtil }, + { provide: DOCUMENT, useValue: mockDocument }, + { provide: Injector, useValue: mockInjector }, + IgxSimpleComboComponent, + IgxSelectionAPIService + ] + }); + + combo = TestBed.inject(IgxSimpleComboComponent); + }); + + jasmine.getEnv().allowRespy(true); + + afterAll(() => { + jasmine.getEnv().allowRespy(false); + }); + + it('should properly call dropdown methods on toggle', () => { + const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['open', 'close', 'toggle']); + combo.ngOnInit(); + combo.dropdown = dropdown; + dropdown.collapsed = true; + + combo.open(); + dropdown.collapsed = false; + expect(combo.dropdown.open).toHaveBeenCalledTimes(1); + expect(combo.collapsed).toBe(false); + + combo.close(); + dropdown.collapsed = true; + expect(combo.dropdown.close).toHaveBeenCalledTimes(1); + expect(combo.collapsed).toBe(true); + + combo.toggle(); + dropdown.collapsed = false; + expect(combo.dropdown.toggle).toHaveBeenCalledTimes(1); + expect(combo.collapsed).toBe(false); + }); + it('should call dropdown toggle with correct overlaySettings', () => { + const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['toggle']); + combo.ngOnInit(); + combo.dropdown = dropdown; + const defaultSettings = (combo as any)._overlaySettings; + combo.toggle(); + expect(combo.dropdown.toggle).toHaveBeenCalledWith(defaultSettings || {}); + const newSettings = { + positionStrategy: new ConnectedPositioningStrategy(), + scrollStrategy: new AbsoluteScrollStrategy() + }; + combo.overlaySettings = newSettings; + const expectedSettings = Object.assign({}, defaultSettings, newSettings); + combo.toggle(); + expect(combo.dropdown.toggle).toHaveBeenCalledWith(expectedSettings); + }); + it('should properly get/set displayKey', () => { + combo.ngOnInit(); + combo.valueKey = 'field'; + expect(combo.displayKey).toEqual(combo.valueKey); + combo.displayKey = 'region'; + expect(combo.displayKey).toEqual('region'); + expect(combo.displayKey === combo.valueKey).toBeFalsy(); + }); + it('should select items through select method', () => { + const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); + const comboInput = jasmine.createSpyObj('IgxInputDirective', ['value']); + combo.ngOnInit(); + combo.comboInput = comboInput; + combo.data = complexData; + combo.valueKey = 'country'; // with valueKey + combo.dropdown = dropdown; + spyOnProperty(combo, 'totalItemCount').and.returnValue(combo.data.length); + + let selectedItem = combo.data[0]; + let selectedValue = combo.data[0].country; + combo.select('UK'); + expect(combo.selection).toEqual(selectedItem); + expect(combo.value).toEqual(selectedValue); + combo.select('Germany'); + selectedItem = combo.data[2]; + selectedValue = combo.data[2].country; + expect(combo.selection).toEqual(selectedItem); + expect(combo.value).toEqual(selectedValue); + + combo.valueKey = null; // without valueKey + selectedItem = combo.data[5]; + combo.select(combo.data[5]); + expect(combo.selection).toEqual(selectedItem); + expect(combo.value).toEqual(selectedItem); + selectedItem = combo.data[1]; + combo.select(combo.data[1]); + expect(combo.selection).toEqual(selectedItem); + expect(combo.value).toEqual(selectedItem); + }); + it('should emit owner on `opening` and `closing`', () => { + combo.ngOnInit(); + spyOn(combo.opening, 'emit').and.callThrough(); + spyOn(combo.closing, 'emit').and.callThrough(); + const mockObj = {}; + const mockEvent = new Event('mock'); + const inputEvent: IBaseCancelableBrowserEventArgs = { + cancel: false, + owner: mockObj, + event: mockEvent + }; + combo.comboInput = { + nativeElement: { + focus: () => { } + } + } as any; + (combo as any).textSelection = { selected: false, trigger: () => { } }; + combo.handleOpening(inputEvent); + const expectedCall: IBaseCancelableBrowserEventArgs = { owner: combo, event: inputEvent.event, cancel: inputEvent.cancel }; + expect(combo.opening.emit).toHaveBeenCalledWith(expectedCall); + combo.handleClosing(inputEvent); + expect(combo.closing.emit).toHaveBeenCalledWith(expectedCall); + let sub = combo.opening.subscribe((e: IBaseCancelableBrowserEventArgs) => { + e.cancel = true; + }); + combo.handleOpening(inputEvent); + expect(inputEvent.cancel).toEqual(true); + sub.unsubscribe(); + inputEvent.cancel = false; + + sub = combo.closing.subscribe((e: IBaseCancelableBrowserEventArgs) => { + e.cancel = true; + }); + combo.handleClosing(inputEvent); + expect(inputEvent.cancel).toEqual(true); + sub.unsubscribe(); + }); + it('should fire selectionChanging event on item selection', () => { + const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); + combo.ngOnInit(); + combo.data = data; + combo.dropdown = dropdown; + const comboInput = jasmine.createSpyObj('IgxInputDirective', ['value']); + comboInput.value = 'test'; + combo.comboInput = comboInput; + spyOnProperty(combo, 'totalItemCount').and.returnValue(combo.data.length); + spyOn(combo.selectionChanging, 'emit'); + + let oldSelection = undefined; + let newSelection = [combo.data[1]]; + + combo.select(combo.data[1]); + expect(combo.selectionChanging.emit).toHaveBeenCalledTimes(1); + expect(combo.selectionChanging.emit).toHaveBeenCalledWith({ + oldValue: undefined, + newValue: newSelection[0], + oldSelection, + newSelection: newSelection[0], + owner: combo, + displayText: newSelection[0].trim(), + cancel: false + }); + + oldSelection = [...newSelection]; + newSelection = [combo.data[0]]; + combo.select(combo.data[0]); + expect(combo.selectionChanging.emit).toHaveBeenCalledTimes(2); + expect(combo.selectionChanging.emit).toHaveBeenCalledWith({ + oldValue: oldSelection[0], + newValue: newSelection[0], + oldSelection: oldSelection[0], + newSelection: newSelection[0], + owner: combo, + displayText: newSelection[0].trim(), + cancel: false + }); + }); + it('should properly emit added and removed values in change event on single value selection', () => { + const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); + combo.ngOnInit(); + combo.data = complexData; + combo.valueKey = 'country'; + combo.dropdown = dropdown; + spyOnProperty(combo, 'totalItemCount').and.returnValue(combo.data.length); + const selectionSpy = spyOn(combo.selectionChanging, 'emit'); + const expectedResults: ISimpleComboSelectionChangingEventArgs = { + newValue: combo.data[0][combo.valueKey], + oldValue: undefined, + newSelection: combo.data[0], + oldSelection: undefined, + owner: combo, + displayText: `${combo.data[0][combo.displayKey]}`, + cancel: false + }; + const comboInput = jasmine.createSpyObj('IgxInputDirective', ['value']); + comboInput.value = 'test'; + combo.comboInput = comboInput; + combo.select(combo.data[0][combo.valueKey]); + expect(selectionSpy).toHaveBeenCalledWith(expectedResults); + Object.assign(expectedResults, { + newValue: undefined, + oldValue: combo.data[0][combo.valueKey], + newSelection: undefined, + oldSelection: combo.data[0], + displayText: '' + }); + combo.deselect(); + expect(selectionSpy).toHaveBeenCalledWith(expectedResults); + }); + it('should properly handle selection manipulation through selectionChanging emit', () => { + const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); + combo.ngOnInit(); + combo.data = data; + combo.dropdown = dropdown; + spyOnProperty(combo, 'totalItemCount').and.returnValue(combo.data.length); + spyOn(combo.selectionChanging, 'emit').and.callFake((event: IComboSelectionChangingEventArgs) => event.newValue = undefined); + const comboInput = jasmine.createSpyObj('IgxInputDirective', ['value']); + combo.comboInput = comboInput; + // No items are initially selected + expect(combo.selection).toEqual(undefined); + // Select the first item + combo.select(combo.data[0]); + // selectionChanging fires and overrides, selection should remain undefined + expect(combo.selection).toEqual(undefined); + }); + it('should not throw error when setting data to null', () => { + combo.ngOnInit(); + let errorMessage = ''; + try { + combo.data = null; + } catch (ex) { + errorMessage = ex.message; + } + expect(errorMessage).toBe(''); + expect(combo.data).not.toBeUndefined(); + expect(combo.data).not.toBeNull(); + expect(combo.data.length).toBe(0); + }); + it('should not throw error when setting data to undefined', () => { + combo.ngOnInit(); + let errorMessage = ''; + try { + combo.data = undefined; + } catch (ex) { + errorMessage = ex.message; + } + expect(errorMessage).toBe(''); + expect(combo.data).not.toBeUndefined(); + expect(combo.data).not.toBeNull(); + expect(combo.data.length).toBe(0); + }); + it('should properly handleInputChange', () => { + const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem', 'navigateFirst']); + combo.ngOnInit(); + combo.data = data; + combo.dropdown = dropdown; + const matchSpy = spyOn(combo, 'checkMatch').and.callThrough(); + spyOn(combo.searchInputUpdate, 'emit'); + const comboInput = jasmine.createSpyObj('IgxInputDirective', ['value']); + comboInput.value = 'test'; + combo.comboInput = comboInput; + + combo.handleInputChange(); + expect(matchSpy).toHaveBeenCalledTimes(1); + expect(combo.searchInputUpdate.emit).toHaveBeenCalledTimes(0); + + const args = { + searchText: 'Fake', + owner: combo, + cancel: false + }; + combo.handleInputChange('Fake'); + expect(matchSpy).toHaveBeenCalledTimes(2); + expect(combo.searchInputUpdate.emit).toHaveBeenCalledTimes(1); + expect(combo.searchInputUpdate.emit).toHaveBeenCalledWith(args); + + args.searchText = ''; + combo.handleInputChange(''); + expect(matchSpy).toHaveBeenCalledTimes(3); + expect(combo.searchInputUpdate.emit).toHaveBeenCalledTimes(2); + expect(combo.searchInputUpdate.emit).toHaveBeenCalledWith(args); + + combo.handleInputChange(); + expect(matchSpy).toHaveBeenCalledTimes(4); + expect(combo.searchInputUpdate.emit).toHaveBeenCalledTimes(2); + }); + it('should be able to cancel searchInputUpdate', () => { + combo.ngOnInit(); + combo.data = data; + combo.searchInputUpdate.subscribe((e) => { + e.cancel = true; + }); + const matchSpy = spyOn(combo, 'checkMatch').and.callThrough(); + const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem', 'collapsed', 'open', 'navigateFirst']); + combo.dropdown = dropdown; + spyOn(combo.searchInputUpdate, 'emit').and.callThrough(); + const comboInput = jasmine.createSpyObj('IgxInputDirective', ['value', 'focused']); + comboInput.value = 'test'; + combo.comboInput = comboInput; + + combo.handleInputChange('Item1'); + expect(combo.searchInputUpdate.emit).toHaveBeenCalledTimes(1); + expect(matchSpy).toHaveBeenCalledTimes(1); + }); + it('should not open on click if combo is disabled', () => { + const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['open', 'close', 'toggle']); + const spyObj = jasmine.createSpyObj('event', ['stopPropagation', 'preventDefault']); + const comboInput = jasmine.createSpyObj('IgxInputDirective', ['value']); + comboInput.value = 'test'; + combo.comboInput = comboInput; + combo.ngOnInit(); + combo.dropdown = dropdown; + dropdown.collapsed = true; + + combo.disabled = true; + combo.onClick(spyObj); + expect(combo.dropdown.collapsed).toBeTruthy(); + }); + it('should not clear value when combo is disabled', () => { + const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem', 'focusedItem']); + const spyObj = jasmine.createSpyObj('event', ['stopPropagation']); + combo.ngOnInit(); + combo.data = data; + combo.dropdown = dropdown; + combo.disabled = true; + const comboInput = jasmine.createSpyObj('IgxInputDirective', ['value', 'focus']); + comboInput.value = 'test'; + combo.comboInput = comboInput; + spyOnProperty(combo, 'totalItemCount').and.returnValue(combo.data.length); + + const item = combo.data.slice(0, 1); + combo.select(item); + combo.handleClear(spyObj); + expect(combo.displayValue).toEqual(item[0]); + }); + + it('should delete the selection on destroy', () => { + jasmine.getEnv().allowRespy(true); + const selectionService = TestBed.inject(IgxSelectionAPIService); + const comboClearSpy = spyOn(mockComboService, 'clear'); + const selectionDeleteSpy = spyOn(selectionService, 'delete'); + combo.ngOnDestroy(); + expect(comboClearSpy).toHaveBeenCalled(); + expect(selectionDeleteSpy).toHaveBeenCalled(); + jasmine.getEnv().allowRespy(false); + }); + }); + + describe('Initialization and rendering tests: ', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + ReactiveFormsModule, + FormsModule, + IgxSimpleComboSampleComponent, + IgxSimpleComboEmptyComponent, + IgxSimpleComboFormControlRequiredComponent, + IgxSimpleComboFormWithFormControlComponent, + IgxSimpleComboNgModelComponent + ] + }).compileComponents(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(IgxSimpleComboSampleComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.combo; + input = fixture.debugElement.query(By.css(`.${CSS_CLASS_COMBO_INPUTGROUP}`)); + }); + it('should initialize the combo component properly', () => { + const toggleButton = fixture.debugElement.query(By.css('.' + CSS_CLASS_TOGGLEBUTTON)); + expect(fixture.componentInstance).toBeDefined(); + expect(combo).toBeDefined(); + expect(combo.collapsed).toBeDefined(); + expect(combo.collapsed).toBeTruthy(); + expect(input).toBeDefined(); + expect(toggleButton).toBeDefined(); + expect(combo.placeholder).toBeDefined(); + }); + it('should initialize input properties properly', () => { + expect(combo.data).toBeDefined(); + expect(combo.valueKey).toEqual('field'); + expect(combo.displayKey).toEqual('field'); + expect(combo.groupKey).toEqual('region'); + expect(combo.width).toEqual('400px'); + expect(combo.placeholder).toEqual('Location'); + expect(combo.allowCustomValues).toEqual(false); + expect(combo.cssClass).toEqual(CSS_CLASS_COMBO); + expect(combo.type).toEqual('box'); + }); + it('should apply all appropriate classes on combo initialization', () => { + const comboWrapper = fixture.nativeElement.querySelector(SIMPLE_COMBO_ELEMENT); + expect(comboWrapper).not.toBeNull(); + expect(comboWrapper.classList.contains(CSS_CLASS_COMBO)).toBeTruthy(); + expect(comboWrapper.childElementCount).toEqual(2); + + const dropDownElement = comboWrapper.children[1]; + expect(dropDownElement.classList.contains(CSS_CLASS_COMBO_DROPDOWN)).toBeTruthy(); + expect(dropDownElement.classList.contains(CSS_CLASS_DROPDOWN)).toBeTruthy(); + expect(dropDownElement.childElementCount).toEqual(1); + + const dropDownList = dropDownElement.children[0]; + const dropDownScrollList = dropDownElement.children[0].children[0]; + expect(dropDownList.classList.contains(CSS_CLASS_DROPDOWNLIST)).toBeTruthy(); + expect(dropDownList.classList.contains('igx-toggle--hidden')).toBeTruthy(); + expect(dropDownScrollList.childElementCount).toEqual(0); + }); + it('should render aria attributes properly', fakeAsync(() => { + expect(input.nativeElement.getAttribute('role')).toEqual('combobox'); + expect(input.nativeElement.getAttribute('aria-haspopup')).toEqual('listbox'); + expect(input.nativeElement.getAttribute('aria-readonly')).toMatch('false'); + expect(input.nativeElement.getAttribute('aria-expanded')).toMatch('false'); + expect(input.nativeElement.getAttribute('aria-controls')).toEqual(combo.dropdown.listId); + expect(input.nativeElement.getAttribute('aria-labelledby')).toEqual(combo.placeholder); + expect(input.nativeElement.getAttribute('aria-label')).toEqual('No options selected'); + + const dropdownListBox = fixture.debugElement.query(By.css(`[role='listbox']`)); + expect(dropdownListBox.nativeElement.getAttribute('aria-labelledby')).toEqual(combo.placeholder); + + combo.open(); + tick(); + fixture.detectChanges(); + + const list = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTENT}`)); + expect(list.nativeElement.getAttribute('aria-activedescendant')).toEqual(combo.dropdown.focusedItem.id); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', list); + tick(); + fixture.detectChanges(); + expect(list.nativeElement.getAttribute('aria-activedescendant')).toEqual(combo.dropdown.focusedItem.id); + + combo.select('Illinois'); + fixture.detectChanges(); + expect(input.nativeElement.getAttribute('aria-label')).toEqual('Selected options'); + })); + it('should render aria-expanded attribute properly', fakeAsync(() => { + expect(input.nativeElement.getAttribute('aria-expanded')).toMatch('false'); + combo.open(); + tick(); + fixture.detectChanges(); + expect(input.nativeElement.getAttribute('aria-expanded')).toMatch('true'); + combo.close(); + tick(); + fixture.detectChanges(); + expect(input.nativeElement.getAttribute('aria-expanded')).toMatch('false'); + })); + it('should render placeholder values for inputs properly', () => { + combo.toggle(); + fixture.detectChanges(); + expect(combo.collapsed).toBeFalsy(); + expect(combo.placeholder).toEqual('Location'); + expect(combo.comboInput.nativeElement.placeholder).toEqual('Location'); + + combo.placeholder = 'States'; + fixture.detectChanges(); + expect(combo.placeholder).toEqual('States'); + expect(combo.comboInput.nativeElement.placeholder).toEqual('States'); + }); + it('should render dropdown list and item height properly', fakeAsync(() => { + // NOTE: Minimum itemHeight is 2 rem, per Material Design Guidelines (for mobile only) + let itemHeight = defaultDropdownItemHeight; + let itemMaxHeight = defaultDropdownItemMaxHeight; + fixture.componentInstance.size = "large"; + fixture.detectChanges(); + combo.toggle(); + tick(); + fixture.detectChanges(); + const dropdownItems = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`)); + const dropdownList = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTENT}`)); + + const verifyDropdownItemHeight = () => { + expect(dropdownItems[0].nativeElement.clientHeight).toEqual(itemHeight); + expect(dropdownList.nativeElement.clientHeight).toEqual(itemMaxHeight); + }; + verifyDropdownItemHeight(); + + itemHeight = 48; + itemMaxHeight = 480; + combo.itemHeight = itemHeight; + tick(); + fixture.detectChanges(); + verifyDropdownItemHeight(); + + itemMaxHeight = 438; + combo.itemsMaxHeight = 438; + tick(); + fixture.detectChanges(); + verifyDropdownItemHeight(); + + itemMaxHeight = 1171; + combo.itemsMaxHeight = 1171; + tick(); + fixture.detectChanges(); + verifyDropdownItemHeight(); + + itemHeight = 83; + combo.itemHeight = 83; + tick(); + fixture.detectChanges(); + verifyDropdownItemHeight(); + })); + it('should render focused items properly', () => { + const dropdown = combo.dropdown; + combo.toggle(); + fixture.detectChanges(); + + dropdown.navigateItem(2); // Component is virtualized, so this will focus the ACTUAL 3rd item + fixture.detectChanges(); + + const dropdownList = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROPDOWNLIST_SCROLL}`)).nativeElement; + const dropdownItems = dropdownList.querySelectorAll(`.${CSS_CLASS_DROPDOWNLISTITEM}`); + const focusedItem_1 = dropdownItems[1]; + expect(focusedItem_1.classList.contains(CSS_CLASS_FOCUSED)).toBeTruthy(); + + // Change focus + dropdown.navigateItem(5); + fixture.detectChanges(); + const focusedItem_2 = dropdownItems[4]; + expect(focusedItem_2.classList.contains(CSS_CLASS_FOCUSED)).toBeTruthy(); + expect(focusedItem_1.classList.contains(CSS_CLASS_FOCUSED)).toBeFalsy(); + }); + it('should properly initialize templates', () => { + expect(combo).toBeDefined(); + expect(combo.footerTemplate).toBeDefined(); + expect(combo.headerTemplate).toBeDefined(); + expect(combo.itemTemplate).toBeDefined(); + expect(combo.addItemTemplate).toBeUndefined(); + expect(combo.headerItemTemplate).toBeUndefined(); + }); + it('should properly render header and footer templates', () => { + let headerElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_HEADER}`)); + let footerElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_FOOTER}`)); + expect(headerElement).toBeNull(); + expect(footerElement).toBeNull(); + combo.toggle(); + fixture.detectChanges(); + expect(combo.headerTemplate).toBeDefined(); + expect(combo.footerTemplate).toBeDefined(); + const dropdownList: HTMLElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROPDOWNLIST_SCROLL}`)).nativeElement; + headerElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_HEADER}`)); + footerElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_FOOTER}`)); + expect(headerElement).not.toBeNull(); + const headerHTMLElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_HEADER}`)).nativeElement; + expect(headerHTMLElement.parentNode).toEqual(dropdownList); + expect(headerHTMLElement.textContent).toEqual('This is a header'); + expect(footerElement).not.toBeNull(); + const footerHTMLElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_FOOTER}`)).nativeElement; + expect(footerHTMLElement.parentNode).toEqual(dropdownList); + expect(footerHTMLElement.textContent).toEqual('This is a footer'); + }); + xit('should initialize the component with empty data and bindings', () => { + fixture = TestBed.createComponent(IgxSimpleComboEmptyComponent); + expect(() => { + fixture.detectChanges(); + }).not.toThrow(); + expect(fixture.componentInstance.combo).toBeDefined(); + }); + it('should not show clear icon button when no value is selected initially with FormControl and required', fakeAsync(() => { + fixture = TestBed.createComponent(IgxSimpleComboFormControlRequiredComponent); + fixture.detectChanges(); + + const comboComponent = fixture.componentInstance; + tick(); + fixture.detectChanges(); + + const clearButton = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + expect(clearButton).toBeNull(); + + comboComponent.formControl.setValue(1); + tick(); + fixture.detectChanges(); + + const clearButtonAfterSelection = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + expect(clearButtonAfterSelection).not.toBeNull(); + })); + it('should not show clear icon button when no value is selected initially in a form with FormControl', fakeAsync(() => { + fixture = TestBed.createComponent(IgxSimpleComboFormWithFormControlComponent); + fixture.detectChanges(); + + const comboComponent = fixture.componentInstance; + tick(); + fixture.detectChanges(); + + const clearButton = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + expect(clearButton).toBeNull(); + + comboComponent.formControl.setValue(1); + tick(); + fixture.detectChanges(); + + const clearButtonAfterSelection = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + expect(clearButtonAfterSelection).not.toBeNull(); + })); + it('should show clear icon button when valid value is set with ngModel', fakeAsync(() => { + fixture = TestBed.createComponent(IgxSimpleComboNgModelComponent); + fixture.detectChanges(); + + const comboComponent = fixture.componentInstance; + tick(); + fixture.detectChanges(); + + const clearButton = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + expect(clearButton).toBeNull(); + + comboComponent.selectedItem = { id: 1, text: 'Option 1' }; + fixture.detectChanges(); + + fixture.whenStable().then(() => { + fixture.detectChanges(); + const clearButtonAfterSelection = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + expect(clearButtonAfterSelection).not.toBeNull(); + }); + })); + it('should show clear icon button when falsy value is set with ngModel', fakeAsync(() => { + fixture = TestBed.createComponent(IgxSimpleComboNgModelComponent); + fixture.detectChanges(); + + const comboComponent = fixture.componentInstance; + tick(); + fixture.detectChanges(); + + const clearButton = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + expect(clearButton).toBeNull(); + + comboComponent.selectedItem = null; + tick(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + fixture.detectChanges(); + const clearButtonAfterFalsyValue = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + expect(clearButtonAfterFalsyValue).not.toBeNull(); + }); + })); + it('should show clear icon button when valid value is set with reactive form', fakeAsync(() => { + fixture = TestBed.createComponent(IgxSimpleComboFormWithFormControlComponent); + fixture.detectChanges(); + + const comboComponent = fixture.componentInstance; + tick(); + fixture.detectChanges(); + + const clearButton = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + expect(clearButton).toBeNull(); + + comboComponent.formControl.setValue({ id: 2, text: 'Option 2' }); + tick(); + fixture.detectChanges(); + + const clearButtonAfterSelection = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + expect(clearButtonAfterSelection).not.toBeNull(); + })); + it('should show clear icon button when falsy value is set with reactive form', fakeAsync(() => { + fixture = TestBed.createComponent(IgxSimpleComboFormWithFormControlComponent); + fixture.detectChanges(); + + const comboComponent = fixture.componentInstance; + tick(); + fixture.detectChanges(); + + const clearButton = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + expect(clearButton).toBeNull(); + + comboComponent.formControl.setValue(''); + tick(); + fixture.detectChanges(); + + const clearButtonAfterFalsyValue = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + expect(clearButtonAfterFalsyValue).not.toBeNull(); + })); + it('should not show clear icon button when empty string is set with ngModel', fakeAsync(() => { + fixture = TestBed.createComponent(IgxSimpleComboNgModelComponent); + fixture.detectChanges(); + + const comboComponent = fixture.componentInstance; + tick(); + fixture.detectChanges(); + + const clearButton = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + expect(clearButton).toBeNull(); + + comboComponent.selectedItem = ''; + tick(); + fixture.detectChanges(); + + const clearButtonAfterEmptyString = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + expect(clearButtonAfterEmptyString).toBeNull(); + })); + it('should not show clear icon button when undefined value is set with ngModel', fakeAsync(() => { + fixture = TestBed.createComponent(IgxSimpleComboNgModelComponent); + fixture.detectChanges(); + + const comboComponent = fixture.componentInstance; + tick(); + fixture.detectChanges(); + + const clearButton = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + expect(clearButton).toBeNull(); + + comboComponent.selectedItem = undefined; + tick(); + fixture.detectChanges(); + + const clearButtonAfterUndefined = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + expect(clearButtonAfterUndefined).toBeNull(); + })); + it('should show clear icon button when empty object is set with ngModel', fakeAsync(() => { + fixture = TestBed.createComponent(IgxSimpleComboNgModelComponent); + fixture.detectChanges(); + + const comboComponent = fixture.componentInstance; + tick(); + fixture.detectChanges(); + + const clearButton = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + expect(clearButton).toBeNull(); + + comboComponent.selectedItem = {}; + tick(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + fixture.detectChanges(); + const clearButtonAfterEmptyObject = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + expect(clearButtonAfterEmptyObject).not.toBeNull(); + }); + })); + it('should properly assign the resource string to the aria-label of the clear button', () => { + combo.toggle(); + fixture.detectChanges(); + + combo.select(['Illinois', 'Mississippi', 'Ohio']); + fixture.detectChanges(); + + const clearBtn = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + expect(clearBtn.nativeElement.ariaLabel).toEqual('Clear Selection'); + }); + }); + + describe('Binding tests: ', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + ReactiveFormsModule, + FormsModule, + IgxSimpleComboSampleComponent, + IgxComboInContainerTestComponent, + IgxComboRemoteDataComponent, + ComboModelBindingComponent + ] + }).compileComponents(); + })); + it('should bind combo data to array of primitive data', () => { + fixture = TestBed.createComponent(IgxComboInContainerTestComponent); + fixture.detectChanges(); + const data = [...fixture.componentInstance.citiesData]; + combo = fixture.componentInstance.combo; + const comboData = combo.data; + expect(comboData).toEqual(data); + }); + it('should remove undefined from array of primitive data', () => { + fixture = TestBed.createComponent(IgxComboInContainerTestComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.combo; + combo.data = ['New York', 'Sofia', undefined, 'Istanbul', 'Paris']; + + expect(combo.data).toEqual(['New York', 'Sofia', 'Istanbul', 'Paris']); + }); + it('should bind combo data to array of objects', () => { + fixture = TestBed.createComponent(IgxSimpleComboSampleComponent); + fixture.detectChanges(); + const data = [...fixture.componentInstance.items]; + combo = fixture.componentInstance.combo; + const comboData = combo.data; + expect(comboData).toEqual(data); + }); + it('should render empty template when combo data source is not set', () => { + fixture = TestBed.createComponent(IgxComboInContainerTestComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.combo; + combo.data = []; + fixture.detectChanges(); + combo.toggle(); + fixture.detectChanges(); + const dropdownList = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROPDOWNLIST_SCROLL}`)).nativeElement; + const dropdownItemsContainer = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTENT}`)).nativeElement; + const dropDownContainer = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTAINER}`)).nativeElement; + const listItems = dropDownContainer.querySelectorAll(`.${CSS_CLASS_DROPDOWNLISTITEM}`); + expect(listItems.length).toEqual(0); + // Expect no items to be rendered in the virtual container + expect(dropdownItemsContainer.children[0].childElementCount).toEqual(0); + // Expect the list child (NOT COMBO ITEM) to be a container with "The list is empty"; + const dropdownItem = dropdownList.lastElementChild as HTMLElement; + expect(dropdownItem.firstElementChild.textContent).toEqual('The list is empty'); + }); + it('should bind combo data properly when changing data source runtime', () => { + const newData = ['Item 1', 'Item 2']; + fixture = TestBed.createComponent(IgxComboInContainerTestComponent); + fixture.detectChanges(); + const data = [...fixture.componentInstance.citiesData]; + combo = fixture.componentInstance.combo; + expect(combo.data).toEqual(data); + combo.data = newData; + fixture.detectChanges(); + expect(combo.data).toEqual(newData); + }); + it('should properly bind to object value w/ valueKey', fakeAsync(() => { + fixture = TestBed.createComponent(ComboModelBindingComponent); + fixture.detectChanges(); + tick(); + const component = fixture.componentInstance; + combo = fixture.componentInstance.combo; + combo.valueKey = 'id'; + component.selectedItem = 1; + fixture.detectChanges(); + tick(); + expect(combo.selection).toEqual(combo.data[1]); + expect(combo.value).toEqual(combo.data[1][combo.valueKey]); + combo.select(combo.data[4][combo.valueKey]); + fixture.detectChanges(); + expect(component.selectedItem).toEqual(4); + })); + it('should properly bind to object value w/o valueKey', fakeAsync(() => { + fixture = TestBed.createComponent(ComboModelBindingComponent); + fixture.detectChanges(); + tick(); + const component = fixture.componentInstance; + combo = fixture.componentInstance.combo; + component.selectedItem = component.items[0]; + fixture.detectChanges(); + tick(); + expect(combo.selection).toEqual(combo.data[0]); + expect(combo.value).toEqual(combo.data[0]); + combo.select(combo.data[4]); + fixture.detectChanges(); + expect(component.selectedItem).toEqual(combo.data[4]); + })); + + it('should clear selection w/o valueKey', fakeAsync(() => { + fixture = TestBed.createComponent(ComboModelBindingComponent); + fixture.detectChanges(); + const component = fixture.componentInstance; + combo = fixture.componentInstance.combo; + component.items = ['One', 'Two', 'Three', 'Four', 'Five']; + combo.select('Three'); + fixture.detectChanges(); + expect(combo.selection).toEqual('Three'); + expect(combo.value).toEqual('Three'); + combo.handleClear(new MouseEvent('click')); + fixture.detectChanges(); + expect(combo.displayValue).toEqual(''); + })); + + it('should properly bind to values w/o valueKey', fakeAsync(() => { + fixture = TestBed.createComponent(ComboModelBindingComponent); + fixture.detectChanges(); + const component = fixture.componentInstance; + combo = fixture.componentInstance.combo; + component.items = ['One', 'Two', 'Three', 'Four', 'Five']; + component.selectedItem = 'One'; + fixture.detectChanges(); + tick(); + expect(combo.selection).toEqual(component.selectedItem); + expect(combo.value).toEqual(component.selectedItem); + combo.select('Three'); + fixture.detectChanges(); + expect(fixture.componentInstance.selectedItem).toEqual('Three'); + })); + + it('should bind combo data to remote data and clear selection properly', (async () => { + fixture = TestBed.createComponent(IgxComboRemoteDataComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.instance; + expect(combo).toBeDefined(); + expect(combo.valueKey).toBeDefined(); + let selectedItem = combo.data[1]; + const spyObj = jasmine.createSpyObj('event', ['stopPropagation']); + combo.toggle(); + + combo.select(combo.data[1][combo.valueKey]); + expect(combo.displayValue).toEqual(`${selectedItem[combo.displayKey]}`); + expect(combo.selection).toEqual(selectedItem); + expect(combo.value).toEqual(selectedItem[combo.valueKey]); + // Clear items while they are in view + combo.handleClear(spyObj); + expect(combo.selection).toEqual(undefined); + expect(combo.displayValue).toEqual(''); + expect(combo.value).toEqual(undefined); + selectedItem = combo.data[2]; + combo.select(combo.data[2][combo.valueKey]); + expect(combo.displayValue).toEqual(`${selectedItem[combo.displayKey]}`); + + // Scroll selected items out of view + combo.virtualScrollContainer.scrollTo(40); + await wait(); + fixture.detectChanges(); + combo.handleClear(spyObj); + expect(combo.selection).toEqual(undefined); + expect(combo.displayValue).toEqual(''); + expect(combo.value).toEqual(undefined); + combo.select(combo.data[7][combo.valueKey]); + expect(combo.displayValue).toEqual(combo.data[7][combo.displayKey]); + })); + }); + + describe('Keyboard navigation and interactions', () => { + let dropdown: IgxComboDropDownComponent; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + ReactiveFormsModule, + FormsModule, + IgxSimpleComboSampleComponent, + IgxComboInContainerTestComponent, + IgxSimpleComboIconTemplatesComponent, + IgxSimpleComboDirtyCheckTestComponent, + IgxSimpleComboTabBehaviorTestComponent + ] + }).compileComponents(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(IgxSimpleComboSampleComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.combo; + input = fixture.debugElement.query(By.css(`.${CSS_CLASS_COMBO_INPUTGROUP}`)); + dropdown = combo.dropdown; + }); + + it('should toggle dropdown list with arrow down/up keys', fakeAsync(() => { + spyOn(combo, 'open').and.callThrough(); + spyOn(combo, 'close').and.callThrough(); + + combo.onArrowDown(UIInteractions.getKeyboardEvent('keydown', 'ArrowDown')); + tick(); + fixture.detectChanges(); + expect(combo.open).toHaveBeenCalledTimes(1); + + combo.onArrowDown(UIInteractions.getKeyboardEvent('keydown', 'ArrowDown', true)); + tick(); + fixture.detectChanges(); + expect(combo.collapsed).toEqual(false); + expect(combo.open).toHaveBeenCalledTimes(1); + + combo.handleKeyDown(UIInteractions.getKeyboardEvent('keydown', 'ArrowUp')); + tick(); + fixture.detectChanges(); + expect(combo.close).toHaveBeenCalledTimes(1); + + combo.handleKeyDown(UIInteractions.getKeyboardEvent('keydown', 'ArrowUp', true)); + fixture.detectChanges(); + tick(); + expect(combo.close).toHaveBeenCalledTimes(2); + })); + + it('should not close the dropdown on ArrowUp key if the active item is the first one in the list', fakeAsync(() => { + combo.open(); + tick(); + fixture.detectChanges(); + + const list = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTENT}`)); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', list); + tick(); + fixture.detectChanges(); + expect(combo.collapsed).toEqual(false); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', list); + tick(); + fixture.detectChanges(); + expect(combo.collapsed).toEqual(false); + })); + + it('should select an item from the dropdown list with the Space key without closing it', () => { + combo.open(); + fixture.detectChanges(); + + const dropdownContent = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTENT}`)); + expect(dropdownContent).not.toBeFalsy(); + + combo.handleKeyUp(UIInteractions.getKeyboardEvent('keyup', 'ArrowDown')); + fixture.detectChanges(); + expect(dropdown.focusedItem).toBeTruthy(); + expect(dropdown.focusedItem.index).toEqual(1); + + spyOn(combo.closed, 'emit').and.callThrough(); + UIInteractions.triggerEventHandlerKeyDown('Space', dropdownContent); + fixture.detectChanges(); + expect(combo.closed.emit).not.toHaveBeenCalled(); + expect(combo.selection).toBeDefined() + }); + + it('should close the dropdown list on pressing Tab key', fakeAsync(() => { + combo.open(); + fixture.detectChanges(); + + const dropdownContent = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTENT}`)); + UIInteractions.triggerEventHandlerKeyDown('Tab', dropdownContent); + tick(); + fixture.detectChanges(); + expect(combo.collapsed).toBeTruthy(); + })); + + it('should clear the selection on tab/blur if the search text does not match any value', () => { + // allowCustomValues does not matter + combo.select(combo.data[2][combo.valueKey]); + fixture.detectChanges(); + expect(combo.selection).toBeDefined() + expect(input.nativeElement.value).toEqual('Massachusetts'); + + UIInteractions.simulateTyping('L', input, 13, 14); + const event = { + target: input.nativeElement, + key: 'L', + stopPropagation: () => { }, + stopImmediatePropagation: () => { }, + preventDefault: () => { } + }; + combo.onArrowDown(new KeyboardEvent('keydown', { ...event })); + fixture.detectChanges(); + UIInteractions.triggerEventHandlerKeyDown('Tab', input); + fixture.detectChanges(); + expect(input.nativeElement.value.length).toEqual(0); + expect(combo.selection).not.toBeDefined() + }); + + it('should not clear selection on tab/blur after filtering and selecting a value', () => { + combo.focusSearchInput(); + UIInteractions.simulateTyping('con', input); + expect(combo.comboInput.value).toEqual('con'); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('Enter', input.nativeElement); + expect(combo.selection).toBeDefined() + expect(combo.displayValue).toEqual('Wisconsin'); + + UIInteractions.triggerEventHandlerKeyDown('Tab', input); + fixture.detectChanges(); + expect(combo.selection).toBeDefined() + expect(combo.displayValue).toEqual('Wisconsin'); + }); + + it('should toggle combo dropdown on Enter of the focused toggle icon', fakeAsync(() => { + spyOn(combo, 'toggle').and.callThrough(); + const toggleBtn = fixture.debugElement.query(By.css(`.${CSS_CLASS_TOGGLEBUTTON}`)); + + UIInteractions.triggerEventHandlerKeyDown('Enter', toggleBtn); + tick(); + fixture.detectChanges(); + expect(combo.toggle).toHaveBeenCalledTimes(1); + expect(combo.collapsed).toEqual(false); + + UIInteractions.triggerEventHandlerKeyDown('Enter', toggleBtn); + tick(); + fixture.detectChanges(); + expect(combo.toggle).toHaveBeenCalledTimes(2); + expect(combo.collapsed).toEqual(true); + })); + + it('should clear the selection on Enter of the focused clear icon', () => { + combo.select(combo.data[2][combo.valueKey]); + fixture.detectChanges(); + expect(combo.selection).toBeDefined() + expect(input.nativeElement.value).toEqual('Massachusetts'); + + const clearBtn = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + UIInteractions.triggerEventHandlerKeyDown('Enter', clearBtn); + fixture.detectChanges(); + expect(input.nativeElement.value.length).toEqual(0); + expect(combo.selection).not.toBeDefined(); + }); + + it('should not filter the data when disableFiltering is true', () => { + combo.disableFiltering = true; + fixture.detectChanges(); + combo.focusSearchInput(); + + UIInteractions.simulateTyping('con', input); + expect(combo.comboInput.value).toEqual('con'); + fixture.detectChanges(); + + expect(combo.filteredData.length).toEqual(combo.data.length); + UIInteractions.triggerEventHandlerKeyDown('Tab', input); + fixture.detectChanges(); + + combo.disableFiltering = false; + fixture.detectChanges(); + combo.focusSearchInput(); + combo.comboInput.value = ''; + fixture.detectChanges(); + UIInteractions.simulateTyping('con', input); + expect(combo.comboInput.value).toEqual('con'); + fixture.detectChanges(); + expect(combo.filteredData.length).toEqual(2); + }); + + it('should display the AddItem button when allowCustomValues is true and there is a partial match', fakeAsync(() => { + fixture.componentInstance.allowCustomValues = true; + fixture.detectChanges(); + combo.open(); + fixture.detectChanges(); + UIInteractions.setInputElementValue(input.nativeElement, 'Massachuset'); + fixture.detectChanges(); + + expect(combo.isAddButtonVisible()).toBeTruthy(); + let addItemButton = fixture.debugElement.query(By.css(`.${CSS_CLASS_ADDBUTTON}`)); + expect(addItemButton).not.toBeNull(); + + // after adding the item, the addItem button should not be displayed (there is a full match) + addItemButton.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + + expect(combo.collapsed).toBeFalsy(); + expect(combo.data.findIndex(i => i.field === 'Massachuset')).not.toBe(-1); + addItemButton = fixture.debugElement.query(By.css(`.${CSS_CLASS_ADDBUTTON}`)); + expect(combo.isAddButtonVisible()).toBeFalsy(); + expect(addItemButton).toBeNull(); + })); + + it('should move the focus to the AddItem button with ArrowDown when allowCustomValues is true', fakeAsync(() => { + fixture.componentInstance.allowCustomValues = true; + fixture.detectChanges(); + UIInteractions.setInputElementValue(input.nativeElement, 'MassachusettsL'); + fixture.detectChanges(); + combo.open(); + fixture.detectChanges(); + const addItemButton = fixture.debugElement.query(By.css(`.${CSS_CLASS_ADDBUTTON}`)); + expect(addItemButton).toBeDefined(); + + input.nativeElement.focus(); + fixture.detectChanges(); + + combo.onArrowDown(new Event('keydown')); + fixture.detectChanges(); + expect(document.activeElement).toEqual(addItemButton.nativeElement); + })); + + it('should close when an item is clicked on', () => { + spyOn(combo, 'close').and.callThrough(); + combo.open(); + fixture.detectChanges(); + const item1 = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`)); + expect(item1).toBeDefined(); + + item1.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + + expect(combo.close).toHaveBeenCalledTimes(1); + }); + + it('should retain selection after blurring', () => { + combo.open(); + fixture.detectChanges(); + const item1 = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`)); + expect(item1).toBeDefined(); + + item1.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('Tab', input); + fixture.detectChanges(); + + expect(combo.selection).toBeDefined() + }); + + it('should scroll to top when opened and there is no selection', () => { + combo.deselect(); + fixture.detectChanges(); + + spyOn(combo, 'onClick').and.callThrough(); + spyOn((combo as any).virtDir, 'scrollTo').and.callThrough(); + + const toggleButton = fixture.debugElement.query(By.directive(IgxIconComponent)); + expect(toggleButton).toBeDefined(); + + toggleButton.nativeElement.click(); + fixture.detectChanges(); + + expect(combo.collapsed).toBeFalsy(); + expect(combo.onClick).toHaveBeenCalledTimes(1); + expect((combo as any).virtDir.scrollTo).toHaveBeenCalledWith(0); + }); + + it('should close the dropdown with Alt + ArrowUp', fakeAsync(() => { + combo.open(); + fixture.detectChanges(); + spyOn(combo, 'close').and.callThrough(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', input); + tick(); + fixture.detectChanges(); + expect(document.activeElement).toHaveClass('igx-combo__content'); + + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', input, true, true); + fixture.detectChanges(); + expect(combo.close).toHaveBeenCalledTimes(1); + })); + + it('should select the first filtered item with Enter', () => { + combo.focusSearchInput(); + UIInteractions.simulateTyping('con', input); + expect(combo.comboInput.value).toEqual('con'); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('Enter', input.nativeElement); + expect(input.nativeElement.value).toEqual('Wisconsin'); + }); + + it('should navigate to next filtered item on ArrowDown', () => { + combo.allowCustomValues = true; + + input.triggerEventHandler('focus', {}); + fixture.detectChanges(); + + UIInteractions.simulateTyping('con', input); + expect(combo.comboInput.value).toEqual('con'); + fixture.detectChanges(); + + // filtered data -> Wisconsin, Connecticut + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', input.nativeElement); + UIInteractions.triggerKeyDownEvtUponElem('Enter', input.nativeElement); + expect(input.nativeElement.value).toEqual('Connecticut'); + }); + + it('should clear selection when all text in input is removed by Backspace and Delete', () => { + combo.select('Wisconsin'); + fixture.detectChanges(); + + input.triggerEventHandler('focus', {}); + fixture.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('Backspace', input); + fixture.detectChanges(); + expect(combo.selection).not.toBeDefined() + + input.triggerEventHandler('blur', {}); + fixture.detectChanges(); + + combo.select('Wisconsin'); + fixture.detectChanges(); + + input.triggerEventHandler('focus', {}); + fixture.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('Backspace', input); + fixture.detectChanges(); + expect(combo.selection).not.toBeDefined() + }); + + it('should display all list items when clearing the input by Space', () => { + combo.select('Wisconsin'); + fixture.detectChanges(); + + expect(combo.selection).toBeDefined() + + UIInteractions.simulateTyping(' ', input, 0, 9); + fixture.detectChanges(); + + expect(combo.selection).not.toBeDefined() + expect(combo.filteredData.length).toEqual(combo.data.length); + }); + + it('should close the dropdown (if opened) when tabbing outside of the input', () => { + combo.open(); + fixture.detectChanges(); + + spyOn(combo, 'close').and.callThrough(); + + UIInteractions.triggerEventHandlerKeyDown('Tab', input); + fixture.detectChanges(); + expect(combo.close).toHaveBeenCalledTimes(1); + }); + + it('should select first match item on tab key pressed', () => { + input.triggerEventHandler('focus', {}); + fixture.detectChanges(); + + const toggleButton = fixture.debugElement.query(By.css('.' + CSS_CLASS_TOGGLEBUTTON)); + toggleButton.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + + UIInteractions.simulateTyping('connecticut', input); + fixture.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('Tab', input); + fixture.detectChanges(); + + expect(combo.displayValue).toEqual('Connecticut'); + expect(combo.comboInput.value).toEqual('Connecticut'); + }); + + it('should not select any item if input does not match data on tab key pressed', () => { + input.triggerEventHandler('focus', {}); + fixture.detectChanges(); + + const toggleButton = fixture.debugElement.query(By.css('.' + CSS_CLASS_TOGGLEBUTTON)); + toggleButton.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + + UIInteractions.simulateTyping('nonexistent', input); + fixture.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('Tab', input); + fixture.detectChanges(); + + expect(combo.displayValue).toEqual(''); + expect(combo.comboInput.value).toEqual(''); + }); + + it('should not clear the input on blur with a partial match but it should select the match item', () => { + spyOn(combo.dropdown.closing, 'emit').and.callThrough(); + + input.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateTyping('mic', input); + + UIInteractions.triggerEventHandlerKeyDown('Tab', input); + fixture.detectChanges(); + + expect(combo.displayValue).toEqual('Michigan'); + expect(combo.dropdown.closing.emit).toHaveBeenCalledTimes(1); + }); + + it('should not clear the selection and input on blur with a match', () => { + fixture = TestBed.createComponent(IgxComboInContainerTestComponent); + combo = fixture.componentInstance.combo; + fixture.detectChanges(); + + input = fixture.debugElement.query(By.css(`.${CSS_CLASS_COMBO_INPUTGROUP}`)); + combo.data = ['Apples', 'Apple']; + + combo.select(combo.data[1]); + fixture.detectChanges(); + + expect(combo.selection).toEqual('Apple'); + expect(combo.value).toEqual('Apple'); + + combo.open(); + fixture.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('Tab', input); + fixture.detectChanges(); + + expect(combo.displayValue).toEqual('Apple'); + expect(combo.selection).toBeDefined() + }); + + it('should not clear input on blur when dropdown is collapsed with match', () => { + input.triggerEventHandler('focus', {}); + fixture.detectChanges(); + + UIInteractions.simulateTyping('new', input); + + const toggleButton = fixture.debugElement.query(By.css('.' + CSS_CLASS_TOGGLEBUTTON)); + toggleButton.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('Tab', input); + fixture.detectChanges(); + + expect(combo.displayValue).toEqual('New Jersey'); + expect(combo.selection).toBeDefined() + }); + + it('should open the combo when input is focused', () => { + spyOn(combo, 'open').and.callThrough(); + spyOn(combo, 'close').and.callThrough(); + + input.triggerEventHandler('focus', {}); + fixture.detectChanges(); + + input.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + + expect(combo.open).toHaveBeenCalledTimes(1); + + input.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + + expect(combo.open).toHaveBeenCalledTimes(1); + + UIInteractions.triggerEventHandlerKeyDown('Tab', input); + fixture.detectChanges(); + + expect(combo.close).toHaveBeenCalledTimes(1); + + input.triggerEventHandler('focus', {}); + fixture.detectChanges(); + + input.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + + expect(combo.open).toHaveBeenCalledTimes(1); + }); + + it('should empty any invalid item values', () => { + combo.valueKey = 'key'; + combo.displayKey = 'value'; + combo.data = [ + { key: 1, value: null }, + { key: 2, value: 'val2' }, + { key: 3, value: '' }, + { key: 4, value: undefined }, + ]; + + combo.open(); + fixture.detectChanges(); + const item1 = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`)); + expect(item1).toBeDefined(); + + item1.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + expect(combo.displayValue).toEqual(''); + + combo.open(); + fixture.detectChanges(); + const item2 = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`))[1]; + expect(item2).toBeDefined(); + + item2.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + expect(combo.displayValue).toEqual('val2'); + + combo.open(); + fixture.detectChanges(); + const item3 = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`))[2]; + expect(item3).toBeDefined(); + + item3.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + expect(combo.displayValue).toEqual(''); + + combo.open(); + fixture.detectChanges(); + const item5 = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`))[3]; + expect(item5).toBeDefined(); + + item5.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + expect(combo.displayValue).toEqual(''); + }); + + it('should select falsy values except "undefined"', () => { + combo.valueKey = 'value'; + combo.displayKey = 'field'; + combo.data = [ + { field: '0', value: 0 }, + { field: 'false', value: false }, + { field: '', value: '' }, + { field: 'null', value: null }, + { field: 'NaN', value: NaN }, + { field: 'undefined', value: undefined }, + ]; + + combo.open(); + fixture.detectChanges(); + const item1 = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`))[0]; + expect(item1).toBeDefined(); + + item1.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + expect(combo.displayValue).toEqual('0'); + expect(combo.value).toEqual(0); + expect(combo.selection).toEqual({ field: '0', value: 0 }); + + combo.open(); + fixture.detectChanges(); + const item2 = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`))[1]; + expect(item2).toBeDefined(); + + item2.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + expect(combo.displayValue).toEqual('false'); + expect(combo.value).toEqual(false); + expect(combo.selection).toEqual({ field: 'false', value: false }); + + combo.open(); + fixture.detectChanges(); + const item3 = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`))[2]; + expect(item3).toBeDefined(); + + item3.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + expect(combo.displayValue).toEqual(''); + expect(combo.value).toEqual(''); + expect(combo.selection).toEqual({ field: '', value: '' }); + + combo.open(); + fixture.detectChanges(); + const item4 = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`))[3]; + expect(item4).toBeDefined(); + + item4.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + expect(combo.displayValue).toEqual('null'); + expect(combo.value).toEqual(null); + expect(combo.selection).toEqual({ field: 'null', value: null }); + + combo.open(); + fixture.detectChanges(); + const item5 = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`))[4]; + expect(item5).toBeDefined(); + + item5.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + expect(combo.displayValue).toEqual('NaN'); + expect(combo.value).toEqual(NaN); + expect(combo.selection).toEqual({ field: 'NaN', value: NaN }); + + // should not select "undefined" + // combo.displayValue & combo.selection equal the values from the previous selection + combo.open(); + fixture.detectChanges(); + const item6 = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`))[5]; + expect(item6).toBeDefined(); + + item6.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + expect(combo.displayValue).toEqual('NaN'); + expect(combo.value).toEqual(NaN); + expect(combo.selection).toEqual({ field: 'NaN', value: NaN }); + }); + + it('should select falsy values except "undefined" with "writeValue" method', () => { + combo.valueKey = 'value'; + combo.displayKey = 'field'; + combo.data = [ + { field: '0', value: 0 }, + { field: 'false', value: false }, + { field: 'empty', value: '' }, + { field: 'null', value: null }, + { field: 'NaN', value: NaN }, + { field: 'undefined', value: undefined }, + ]; + + combo.writeValue(0); + expect(combo.value).toEqual(0); + expect(combo.selection).toEqual({ field: '0', value: 0 }); + expect(combo.displayValue).toEqual('0'); + + combo.writeValue(false); + expect(combo.value).toEqual(false); + expect(combo.selection).toEqual({ field: 'false', value: false }); + expect(combo.displayValue).toEqual('false'); + + combo.writeValue(''); + expect(combo.value).toEqual(''); + expect(combo.selection).toEqual({ field: 'empty', value: '' }); + expect(combo.displayValue).toEqual('empty'); + + combo.writeValue(null); + expect(combo.value).toEqual(null); + expect(combo.selection).toEqual({ field: 'null', value: null }); + expect(combo.displayValue).toEqual('null'); + + combo.writeValue(NaN); + expect(combo.value).toEqual(NaN); + expect(combo.selection).toEqual({ field: 'NaN', value: NaN }); + expect(combo.displayValue).toEqual('NaN'); + + // should not select undefined + combo.writeValue(undefined); + expect(combo.value).toEqual(undefined); + expect(combo.selection).toEqual(undefined); + expect(combo.displayValue).toEqual(''); + }); + + it('should toggle dropdown list on clicking a templated toggle icon', fakeAsync(() => { + fixture = TestBed.createComponent(IgxSimpleComboIconTemplatesComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.combo; + + const toggleIcon = fixture.debugElement.query(By.css(`.${CSS_CLASS_TOGGLEBUTTON}`)); + expect(toggleIcon).toBeDefined(); + + expect(toggleIcon.nativeElement.textContent).toBe('search'); + expect(combo.collapsed).toBeTruthy(); + + toggleIcon.nativeElement.click(); + tick(); + fixture.detectChanges(); + + expect(combo.collapsed).toBeFalsy(); + + toggleIcon.nativeElement.click(); + tick(); + fixture.detectChanges(); + + expect(combo.collapsed).toBeTruthy(); + })); + + it('should clear the selection when typing in the input', () => { + combo.select('Wisconsin'); + fixture.detectChanges(); + + expect(combo.selection).toBeDefined() + + let clearButton = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + expect(clearButton).not.toBeNull(); + + input.triggerEventHandler('focus', {}); + fixture.detectChanges(); + + UIInteractions.simulateTyping('L', input, 9, 10); + fixture.detectChanges(); + expect(combo.selection).not.toBeDefined() + + //should hide the clear button immediately when clearing the selection by typing + clearButton = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + expect(clearButton).toBeNull(); + }); + + it('should open the combo to the top when there is no space to open to the bottom', fakeAsync(() => { + fixture = TestBed.createComponent(IgxBottomPositionSimpleComboComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.combo; + + const newSettings = { + positionStrategy: new AutoPositionStrategy(), + scrollStrategy: new AbsoluteScrollStrategy() + }; + combo.overlaySettings = newSettings; + fixture.detectChanges(); + + combo.open(); + fixture.detectChanges(); + expect(combo.collapsed).toEqual(false); + expect(combo.overlaySettings.positionStrategy.settings.verticalDirection).toBe(-1); + + combo.select('Connecticut'); + fixture.detectChanges(); + + expect(combo.selection).toEqual({ field: 'Connecticut', region: 'New England' }); + fixture.detectChanges(); + + combo.dropdown.close(); + tick(); + fixture.detectChanges(); + expect(combo.collapsed).toEqual(true); + + combo.open(); + fixture.detectChanges(); + expect(combo.collapsed).toEqual(false); + expect(combo.overlaySettings.positionStrategy.settings.verticalDirection).toBe(-1); + + combo.dropdown.close(); + tick(); + fixture.detectChanges(); + expect(combo.collapsed).toEqual(true); + })); + + it('should not open when clearing the selection from the clear icon when the combo is collapsed', fakeAsync(() => { + fixture = TestBed.createComponent(IgxSimpleComboSampleComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.combo; + + combo.select('Connecticut'); + fixture.detectChanges(); + + combo.handleClear(new MouseEvent('click')); + fixture.detectChanges(); + expect(combo.collapsed).toEqual(true); + })); + + it('should select values that have spaces as prefixes/suffixes', fakeAsync(() => { + fixture.detectChanges(); + + dropdown.toggle(); + fixture.detectChanges(); + + UIInteractions.simulateTyping('Ohio ', input); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('Enter', input.nativeElement); + fixture.detectChanges(); + + combo.toggle(); + tick(); + fixture.detectChanges(); + + combo.onBlur(); + tick(); + fixture.detectChanges(); + expect(combo.displayValue).toEqual('Ohio '); + })); + + it('should properly filter dropdown when pasting from clipboard in input', () => { + spyOn(combo, 'handleInputChange').and.callThrough(); + combo.open(); + input.triggerEventHandler('focus', {}); + fixture.detectChanges(); + + const target = { + value: combo.data[1].field + } + combo.comboInput.value = target.value + const pasteData = new DataTransfer(); + const pasteEvent = new ClipboardEvent('paste', { clipboardData: pasteData }); + Object.defineProperty(pasteEvent, 'target', { + writable: false, + value: target + }) + input.triggerEventHandler('paste', pasteEvent); + fixture.detectChanges(); + + expect(combo.handleInputChange).toHaveBeenCalledTimes(1); + expect(combo.handleInputChange).toHaveBeenCalledWith(jasmine.objectContaining({ + target: jasmine.objectContaining({ value: target.value }) + })); + expect(combo.filteredData.length).toBeLessThan(combo.data.length) + expect(combo.filteredData[0].field).toBe(target.value) + }); + + it('should prevent Enter key default behavior when filtering data', fakeAsync(() => { + const keyEvent = new KeyboardEvent('keydown', { key: 'Enter' }); + const spy = spyOn(keyEvent, 'preventDefault'); + + expect(combo.collapsed).toBe(true); + expect(combo.selection).toBeUndefined(); + + input.triggerEventHandler('focus', {}); + UIInteractions.simulateTyping('c', input); + fixture.detectChanges(); + + expect(combo.collapsed).toBe(false); + + combo.handleKeyDown(keyEvent); + tick(); + fixture.detectChanges(); + + expect(combo.selection).toBeDefined(); + expect(combo.collapsed).toBe(true); + expect(spy).toHaveBeenCalled(); + + combo.handleKeyDown(keyEvent); + tick(); + fixture.detectChanges(); + + expect(spy).toHaveBeenCalledTimes(1); + })); + + it('should emit selectionChanging event when input value changes', () => { + spyOn(combo.selectionChanging, 'emit').and.callThrough(); + fixture.detectChanges(); + + combo.select('Connecticut'); + fixture.detectChanges(); + + expect(combo.selectionChanging.emit).toHaveBeenCalledTimes(1); + expect(combo.selectionChanging.emit).toHaveBeenCalledWith({ + newValue: "Connecticut", + oldValue: undefined, + newSelection: { + field: "Connecticut", + region: "New England" + }, + oldSelection: undefined, + displayText: 'Connecticut', + owner: combo, + cancel: false + }); + + combo.handleInputChange('z'); + fixture.detectChanges(); + + expect(combo.selectionChanging.emit).toHaveBeenCalledTimes(2); + expect(combo.selectionChanging.emit).toHaveBeenCalledWith({ + oldValue: "Connecticut", + newValue: undefined, + oldSelection: { + field: "Connecticut", + region: "New England" + }, + newSelection: undefined, + owner: combo, + displayText: "z", + cancel: false + }); + }); + + it('should not change selection when selectionChanging event is canceled', () => { + fixture.detectChanges(); + + combo.select('Connecticut'); + fixture.detectChanges(); + + expect(combo.selection).toEqual({ + field: 'Connecticut', + region: 'New England' + }); + + const cancelEventSpy = spyOn(combo.selectionChanging, 'emit').and.callFake((args: ISimpleComboSelectionChangingEventArgs) => { + args.cancel = true; + }); + + combo.handleInputChange('z'); + fixture.detectChanges(); + + expect(cancelEventSpy).toHaveBeenCalled(); + expect(combo.selection).toEqual({ + field: 'Connecticut', + region: 'New England' + }); + + combo.handleInputChange(' '); + fixture.detectChanges(); + + expect(cancelEventSpy).toHaveBeenCalled(); + expect(combo.selection).toEqual({ + field: 'Connecticut', + region: 'New England' + }); + }); + + + it('should preserved the input value of the combo when selectionChanging event is canceled', () => { + fixture.detectChanges(); + + const comboInput = fixture.debugElement.query(By.css(`.igx-input-group__input`)); + fixture.detectChanges(); + + combo.select('Connecticut'); + fixture.detectChanges(); + + expect(combo.selection).toEqual({ + field: 'Connecticut', + region: 'New England' + }); + + const cancelEventSpy = spyOn(combo.selectionChanging, 'emit').and.callFake((args: ISimpleComboSelectionChangingEventArgs) => { + args.cancel = true; + }); + + const clearButton = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + clearButton.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + + expect(cancelEventSpy).toHaveBeenCalled(); + expect(combo.selection).toEqual({ + field: 'Connecticut', + region: 'New England' + }); + + expect(comboInput.nativeElement.value).toEqual('Connecticut'); + + combo.handleInputChange('z'); + fixture.detectChanges(); + + expect(cancelEventSpy).toHaveBeenCalled(); + expect(combo.selection).toEqual({ + field: 'Connecticut', + region: 'New England' + }); + + expect(comboInput.nativeElement.value).toEqual('Connecticut'); + + combo.handleInputChange(' '); + fixture.detectChanges(); + + expect(cancelEventSpy).toHaveBeenCalled(); + expect(combo.selection).toEqual({ + field: 'Connecticut', + region: 'New England' + }); + + expect(comboInput.nativeElement.value).toEqual('Connecticut'); + }); + + it('should properly filter dropdown when a key is pressed while the combo is focused but not opened, with no selected item', fakeAsync(() => { + expect(combo.collapsed).toEqual(true); + + // filter with a specific character when there is no selected item + combo.handleInputChange('z'); + tick(); + fixture.detectChanges(); + + expect(combo.filteredData.length).toEqual(1); + expect(combo.filteredData[0].field).toEqual('Arizona'); + + combo.close(); + tick(); + fixture.detectChanges(); + + expect(combo.collapsed).toEqual(true); + + // filter with ' ' when there is no selected item + combo.handleInputChange(' '); + tick(); + fixture.detectChanges(); + + expect(combo.filteredData.length).toEqual(12); + })); + + it('should properly filter dropdown when a key is pressed while the combo is focused but not opened, with a selected item', fakeAsync(() => { + // select an item + combo.select('Connecticut'); + tick(); + fixture.detectChanges(); + + expect(combo.selection.field).toEqual('Connecticut'); + + combo.close(); + tick(); + fixture.detectChanges(); + + // filter with a specific character when there is a selected item + combo.handleInputChange('z'); + tick(); + fixture.detectChanges(); + + expect(combo.filteredData.length).toEqual(1); + expect(combo.filteredData[0].field).toEqual('Arizona'); + })); + + it('should not select the first item when combo is focused there is no focus item and Enter is pressed', fakeAsync(() => { + combo.open(); + tick(); + fixture.detectChanges(); + + UIInteractions.simulateTyping('ariz', input); + tick(); + fixture.detectChanges(); + + expect(combo.dropdown.collapsed).toBe(false); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', input.nativeElement); + tick(); + fixture.detectChanges(); + + input.nativeElement.focus(); + tick(); + fixture.detectChanges(); + + combo.dropdown.focusedItem = undefined; + tick(); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('Enter', input.nativeElement); + tick(); + fixture.detectChanges(); + + expect(combo.comboInput.value).toEqual('ariz'); + })); + + it('should not mark form as dirty when tabbing through an empty combo', fakeAsync(() => { + fixture = TestBed.createComponent(IgxSimpleComboDirtyCheckTestComponent); + fixture.detectChanges(); + + combo = fixture.componentInstance.combo; + input = fixture.debugElement.query(By.css('.igx-input-group__input')); + reactiveForm = fixture.componentInstance.form; + fixture.detectChanges(); + + expect(reactiveForm.dirty).toBe(false); + + input.nativeElement.focus(); + tick(); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', input.nativeElement); + tick(); + fixture.detectChanges(); + + input.nativeElement.focus(); + tick(); + fixture.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('Tab', input); + tick(); + fixture.detectChanges(); + + expect(reactiveForm.dirty).toBe(false); + })); + + it('should focus on the next combo when Tab is pressed', fakeAsync(() => { + fixture = TestBed.createComponent(IgxSimpleComboTabBehaviorTestComponent); + fixture.detectChanges(); + + const combos = fixture.debugElement.queryAll(By.directive(IgxSimpleComboComponent)); + expect(combos.length).toBe(3); + + const firstComboInput = combos[0].query(By.css(`.${CSS_CLASS_COMBO_INPUTGROUP}`)); + const secondComboInput = combos[1].query(By.css(`.${CSS_CLASS_COMBO_INPUTGROUP}`)); + const thirdComboInput = combos[2].query(By.css(`.${CSS_CLASS_COMBO_INPUTGROUP}`)); + + firstComboInput.nativeElement.focus(); + tick(); + fixture.detectChanges(); + expect(document.activeElement).toEqual(firstComboInput.nativeElement); + + UIInteractions.triggerEventHandlerKeyDown('Tab', firstComboInput); + secondComboInput.nativeElement.focus(); + tick(); + fixture.detectChanges(); + expect(document.activeElement).toEqual(secondComboInput.nativeElement); + + UIInteractions.triggerEventHandlerKeyDown('Tab', secondComboInput); + thirdComboInput.nativeElement.focus(); + tick(); + fixture.detectChanges(); + expect(document.activeElement).toEqual(thirdComboInput.nativeElement); + })); + }); + + describe('Form control tests: ', () => { + describe('Template form tests: ', () => { + let inputGroupRequired: DebugElement; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + ReactiveFormsModule, + FormsModule, + IgxSimpleComboInTemplatedFormComponent + ] + }).compileComponents(); + })); + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(IgxSimpleComboInTemplatedFormComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.testCombo; + input = fixture.debugElement.query(By.css(`${CSS_CLASS_INPUTGROUP} input`)); + inputGroupRequired = fixture.debugElement.query(By.css(`.${CSS_CLASS_INPUTGROUP_REQUIRED}`)); + })); + it('should properly initialize when used in a template form control', () => { + expect(combo.valid).toEqual(IgxInputState.INITIAL); + expect(combo.comboInput.valid).toEqual(IgxInputState.INITIAL); + expect(inputGroupRequired).toBeDefined(); + combo.onBlur(); + fixture.detectChanges(); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + + input.triggerEventHandler('focus', {}); + combo.select('Wisconsin'); + fixture.detectChanges(); + expect(combo.valid).toEqual(IgxInputState.VALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.VALID); + + const clearButton = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); + clearButton.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + }); + it('should set validity to initial when the form is reset', fakeAsync(() => { + combo.onBlur(); + fixture.detectChanges(); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + + fixture.componentInstance.form.resetForm(); + tick(); + expect(combo.valid).toEqual(IgxInputState.INITIAL); + expect(combo.comboInput.valid).toEqual(IgxInputState.INITIAL); + })); + it('should not select null, undefined and empty string in a template form with required', () => { + // array of objects + combo.valueKey = 'value'; + combo.displayKey = 'field'; + combo.groupKey = undefined; + combo.data = [ + { field: '0', value: 0 }, + { field: 'false', value: false }, + { field: '', value: '' }, + { field: 'null', value: null }, + { field: 'NaN', value: NaN }, + { field: 'undefined', value: undefined }, + ]; + + expect(combo.valid).toEqual(IgxInputState.INITIAL); + expect(combo.comboInput.valid).toEqual(IgxInputState.INITIAL); + + // empty string + combo.open(); + fixture.detectChanges(); + const item1 = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`))[2]; + item1.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + + expect(combo.displayValue).toEqual(''); + expect(combo.selection).toEqual(undefined); + expect(combo.value).toEqual(undefined); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + + // null + combo.open(); + fixture.detectChanges(); + const item2 = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`))[3]; + item2.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + + expect(combo.displayValue).toEqual(''); + expect(combo.selection).toEqual(undefined); + expect(combo.value).toEqual(undefined); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + + // undefined + combo.open(); + fixture.detectChanges(); + const item3 = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`))[5]; + item3.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + + expect(combo.displayValue).toEqual(''); + expect(combo.selection).toEqual(undefined); + expect(combo.value).toEqual(undefined); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + + // primitive data - undefined is not displayed in the dropdown + combo.valueKey = undefined; + combo.displayKey = undefined; + combo.groupKey = undefined; + combo.data = [0, false, '', null, NaN, undefined]; + + fixture.componentInstance.form.resetForm(); + fixture.detectChanges(); + expect(combo.valid).toEqual(IgxInputState.INITIAL); + expect(combo.comboInput.valid).toEqual(IgxInputState.INITIAL); + + // empty string + combo.open(); + fixture.detectChanges(); + const item4 = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`))[2]; + item4.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + + expect(combo.displayValue).toEqual(''); + expect(combo.selection).toEqual(undefined); + expect(combo.value).toEqual(undefined); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + + // null + combo.open(); + fixture.detectChanges(); + const item5 = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`))[3]; + item5.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + + expect(combo.displayValue).toEqual(''); + expect(combo.selection).toEqual(undefined); + expect(combo.value).toEqual(undefined); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + }); + it('should not select null, undefined and empty string with "writeValue" method in a template form with required', () => { + // array of objects + combo.valueKey = 'value'; + combo.displayKey = 'field'; + combo.groupKey = undefined; + combo.data = [ + { field: '0', value: 0 }, + { field: 'false', value: false }, + { field: '', value: '' }, + { field: 'null', value: null }, + { field: 'NaN', value: NaN }, + { field: 'undefined', value: undefined }, + ]; + + expect(combo.valid).toEqual(IgxInputState.INITIAL); + expect(combo.comboInput.valid).toEqual(IgxInputState.INITIAL); + + combo.onBlur(); + fixture.detectChanges(); + + combo.writeValue(null); + expect(combo.displayValue).toEqual(''); + expect(combo.selection).toEqual(undefined); + expect(combo.value).toEqual(undefined); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + + combo.writeValue(''); + expect(combo.displayValue).toEqual(''); + expect(combo.selection).toEqual(undefined); + expect(combo.value).toEqual(undefined); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + + combo.writeValue(undefined); + expect(combo.displayValue).toEqual(''); + expect(combo.selection).toEqual(undefined); + expect(combo.value).toEqual(undefined); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + + // primitive data - undefined is not displayed in the dropdown + combo.valueKey = undefined; + combo.displayKey = undefined; + combo.groupKey = undefined; + combo.data = [0, false, '', null, NaN, undefined]; + + fixture.componentInstance.form.resetForm(); + fixture.detectChanges(); + expect(combo.valid).toEqual(IgxInputState.INITIAL); + expect(combo.comboInput.valid).toEqual(IgxInputState.INITIAL); + + combo.onBlur(); + fixture.detectChanges(); + + combo.writeValue(null); + expect(combo.displayValue).toEqual(''); + expect(combo.selection).toEqual(undefined); + expect(combo.value).toEqual(undefined); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + + combo.writeValue(''); + expect(combo.displayValue).toEqual(''); + expect(combo.selection).toEqual(undefined); + expect(combo.value).toEqual(undefined); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + + combo.writeValue(undefined); + expect(combo.displayValue).toEqual(''); + expect(combo.selection).toEqual(undefined); + expect(combo.value).toEqual(undefined); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + }); + + it('Should update the model only if a selection is changing otherwise it shoudl be undefiend when the user is filtering in templeted form', fakeAsync(() => { + input = fixture.debugElement.query(By.css(`.${CSS_CLASS_COMBO_INPUTGROUP}`)); + let model; + + combo.open(); + fixture.detectChanges(); + const item2 = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`))[3]; + item2.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + model = fixture.componentInstance.values; + + expect(combo.displayValue).toEqual('Illinois'); + expect(combo.value).toEqual('Illinois'); + expect(model).toEqual('Illinois'); + + combo.deselect(); + fixture.detectChanges(); + model = fixture.componentInstance.values; + + expect(combo.selection).not.toBeDefined(); + expect(model).toEqual(undefined); + expect(combo.displayValue).toEqual(''); + + combo.focusSearchInput(); + UIInteractions.simulateTyping('con', input); + fixture.detectChanges(); + model = fixture.componentInstance.values; + expect(combo.comboInput.value).toEqual('con'); + expect(model).toEqual(undefined); + + UIInteractions.triggerKeyDownEvtUponElem('Enter', input.nativeElement); + fixture.detectChanges(); + model = fixture.componentInstance.values; + expect(combo.selection).toBeDefined() + expect(combo.displayValue).toEqual('Wisconsin'); + expect(combo.value).toEqual('Wisconsin'); + expect(model).toEqual('Wisconsin'); + })); + }); + describe('Reactive form tests: ', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + ReactiveFormsModule, + FormsModule, + IgxSimpleComboInReactiveFormComponent + ] + }).compileComponents(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(IgxSimpleComboInReactiveFormComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.reactiveCombo; + reactiveForm = fixture.componentInstance.reactiveForm; + reactiveControl = reactiveForm.form.controls['comboValue']; + }); + it('should not select null, undefined and empty string in a reactive form with required', fakeAsync(() => { + // array of objects + combo.data = [ + { field: '0', value: 0 }, + { field: 'false', value: false }, + { field: 'empty string', value: '' }, + { field: 'null', value: null }, + { field: 'NaN', value: NaN }, + { field: 'undefined', value: undefined }, + ]; + + reactiveForm.resetForm(); + fixture.detectChanges(); + + expect(combo.displayValue).toEqual(''); + expect(combo.selection).toEqual(undefined); + expect(combo.value).toEqual(undefined); + expect(combo.valid).toEqual(IgxInputState.INITIAL); + expect(combo.comboInput.valid).toEqual(IgxInputState.INITIAL); + expect(reactiveForm.status).toEqual('INVALID'); + expect(reactiveControl.status).toEqual('INVALID'); + + // empty string + combo.open(); + fixture.detectChanges(); + const item1 = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`))[2]; + item1.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + + expect(combo.displayValue).toEqual(''); + expect(combo.selection).toEqual(undefined); + expect(combo.value).toEqual(undefined); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + expect(reactiveForm.status).toEqual('INVALID'); + expect(reactiveControl.status).toEqual('INVALID'); + + // null + combo.open(); + fixture.detectChanges(); + const item2 = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`))[3]; + item2.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + + expect(combo.displayValue).toEqual(''); + expect(combo.selection).toEqual(undefined); + expect(combo.value).toEqual(undefined); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + expect(reactiveForm.status).toEqual('INVALID'); + expect(reactiveControl.status).toEqual('INVALID'); + + // undefined + combo.open(); + fixture.detectChanges(); + const item3 = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`))[5]; + item3.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + + expect(combo.displayValue).toEqual(''); + expect(combo.selection).toEqual(undefined); + expect(combo.value).toEqual(undefined); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + expect(reactiveForm.status).toEqual('INVALID'); + expect(reactiveControl.status).toEqual('INVALID'); + + // primitive data - undefined is not displayed in the dropdown + combo.valueKey = undefined; + combo.displayKey = undefined; + combo.data = [0, false, '', null, NaN, undefined]; + + reactiveForm.resetForm(); + fixture.detectChanges(); + expect(combo.displayValue).toEqual(''); + expect(combo.selection).toEqual(undefined); + expect(combo.value).toEqual(undefined); + expect(combo.valid).toEqual(IgxInputState.INITIAL); + expect(combo.comboInput.valid).toEqual(IgxInputState.INITIAL); + expect(reactiveForm.status).toEqual('INVALID'); + expect(reactiveControl.status).toEqual('INVALID'); + + // empty string + combo.open(); + fixture.detectChanges(); + const item4 = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`))[2]; + item4.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + + expect(combo.displayValue).toEqual(''); + expect(combo.selection).toEqual(undefined); + expect(combo.value).toEqual(undefined); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + expect(reactiveForm.status).toEqual('INVALID'); + expect(reactiveControl.status).toEqual('INVALID'); + + // null + combo.open(); + fixture.detectChanges(); + const item5 = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`))[3]; + item5.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + + expect(combo.displayValue).toEqual(''); + expect(combo.selection).toEqual(undefined); + expect(combo.value).toEqual(undefined); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + expect(reactiveForm.status).toEqual('INVALID'); + expect(reactiveControl.status).toEqual('INVALID'); + })); + it('should not select null, undefined and empty string with "writeValue" method in a reactive form with required', () => { + // array of objects + combo.data = [ + { field: '0', value: 0 }, + { field: 'false', value: false }, + { field: 'empty string', value: '' }, + { field: 'null', value: null }, + { field: 'NaN', value: NaN }, + { field: 'undefined', value: undefined }, + ]; + + reactiveForm.resetForm(); + fixture.detectChanges(); + + expect(combo.displayValue).toEqual(''); + expect(combo.selection).toEqual(undefined); + expect(combo.value).toEqual(undefined); + expect(combo.valid).toEqual(IgxInputState.INITIAL); + expect(combo.comboInput.valid).toEqual(IgxInputState.INITIAL); + expect(reactiveForm.status).toEqual('INVALID'); + expect(reactiveControl.status).toEqual('INVALID'); + + combo.onBlur(); + fixture.detectChanges(); + + combo.writeValue(null); + expect(combo.displayValue).toEqual(''); + expect(combo.selection).toEqual(undefined); + expect(combo.value).toEqual(undefined); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + expect(reactiveForm.status).toEqual('INVALID'); + expect(reactiveControl.status).toEqual('INVALID'); + + combo.writeValue(''); + expect(combo.displayValue).toEqual(''); + expect(combo.selection).toEqual(undefined); + expect(combo.value).toEqual(undefined); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + expect(reactiveForm.status).toEqual('INVALID'); + expect(reactiveControl.status).toEqual('INVALID'); + + combo.writeValue(undefined); + expect(combo.displayValue).toEqual(''); + expect(combo.selection).toEqual(undefined); + expect(combo.value).toEqual(undefined); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + expect(reactiveForm.status).toEqual('INVALID'); + expect(reactiveControl.status).toEqual('INVALID'); + + // primitive data - undefined is not displayed in the dropdown + combo.valueKey = undefined; + combo.displayKey = undefined; + combo.data = [0, false, '', null, NaN, undefined]; + + reactiveForm.resetForm(); + fixture.detectChanges(); + + expect(combo.displayValue).toEqual(''); + expect(combo.selection).toEqual(undefined); + expect(combo.value).toEqual(undefined); + expect(combo.valid).toEqual(IgxInputState.INITIAL); + expect(combo.comboInput.valid).toEqual(IgxInputState.INITIAL); + expect(reactiveForm.status).toEqual('INVALID'); + expect(reactiveControl.status).toEqual('INVALID'); + + combo.onBlur(); + fixture.detectChanges(); + + combo.writeValue(null); + expect(combo.displayValue).toEqual(''); + expect(combo.selection).toEqual(undefined); + expect(combo.value).toEqual(undefined); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + expect(reactiveForm.status).toEqual('INVALID'); + expect(reactiveControl.status).toEqual('INVALID'); + + combo.writeValue(''); + expect(combo.displayValue).toEqual(''); + expect(combo.selection).toEqual(undefined); + expect(combo.value).toEqual(undefined); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + expect(reactiveForm.status).toEqual('INVALID'); + expect(reactiveControl.status).toEqual('INVALID'); + + combo.writeValue(undefined); + expect(combo.displayValue).toEqual(''); + expect(combo.selection).toEqual(undefined); + expect(combo.value).toEqual(undefined); + expect(combo.valid).toEqual(IgxInputState.INVALID); + expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); + expect(reactiveForm.status).toEqual('INVALID'); + expect(reactiveControl.status).toEqual('INVALID'); + }); + + it('Should update validity state when programmatically setting errors on reactive form controls', fakeAsync(() => { + const form = (fixture.componentInstance as IgxSimpleComboInReactiveFormComponent).comboForm; + + // the form control has validators + form.markAllAsTouched(); + form.get('comboValue').setErrors({ error: true }); + fixture.detectChanges(); + + expect((combo as any).comboInput.valid).toBe(IgxInputState.INVALID); + expect((combo as any).inputGroup.element.nativeElement.classList.contains(CSS_CLASS_INPUT_GROUP_INVALID)).toBe(true); + expect((combo as any).inputGroup.element.nativeElement.classList.contains(CSS_CLASS_INPUT_GROUP_REQUIRED)).toBe(true); + + // remove the validators and set errors + form.get('comboValue').clearValidators(); + form.markAsUntouched(); + fixture.detectChanges(); + + form.markAllAsTouched(); + form.get('comboValue').setErrors({ error: true }); + fixture.detectChanges(); + + // no validator, but there is a set error + expect((combo as any).comboInput.valid).toBe(IgxInputState.INVALID); + expect((combo as any).inputGroup.element.nativeElement.classList.contains(CSS_CLASS_INPUT_GROUP_INVALID)).toBe(true); + expect((combo as any).inputGroup.element.nativeElement.classList.contains(CSS_CLASS_INPUT_GROUP_REQUIRED)).toBe(false); + })); + + it('Should update the model only if a selection is changing otherwise it shoudl be undefiend when the user is filtering in reactive form', fakeAsync(() => { + const form = (fixture.componentInstance as IgxSimpleComboInReactiveFormComponent).comboForm; + input = fixture.debugElement.query(By.css(`.${CSS_CLASS_COMBO_INPUTGROUP}`)); + + combo.open(); + fixture.detectChanges(); + const item2 = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`))[3]; + item2.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + + expect(combo.displayValue).toEqual('Four'); + expect(combo.value).toEqual(4); + expect(form.controls['comboValue'].value).toEqual(4); + + combo.deselect(); + fixture.detectChanges(); + + expect(combo.selection).not.toBeDefined() + expect(form.controls['comboValue'].value).toEqual(undefined); + expect(combo.displayValue).toEqual(''); + + combo.focusSearchInput(); + UIInteractions.simulateTyping('on', input); + fixture.detectChanges(); + expect(combo.comboInput.value).toEqual('on'); + expect(form.controls['comboValue'].value).toEqual(undefined); + + UIInteractions.triggerKeyDownEvtUponElem('Enter', input.nativeElement); + expect(combo.selection).toBeDefined() + expect(combo.displayValue).toEqual('One'); + expect(combo.value).toEqual(1); + expect(form.controls['comboValue'].value).toEqual(1); + })); + }); + }); + + describe('Selection tests: ', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + ReactiveFormsModule, + FormsModule, + IgxComboRemoteDataComponent, + IgxSimpleComboBindingDataAfterInitComponent, + IgxComboRemoteDataInReactiveFormComponent + ] + }).compileComponents(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(IgxComboRemoteDataComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.instance; + input = fixture.debugElement.query(By.css(`.${CSS_CLASS_COMBO_INPUTGROUP}`)); + }); + it('should prevent registration of remote entries when selectionChanging is cancelled', () => { + spyOn(combo.selectionChanging, 'emit').and.callFake((event: IComboSelectionChangingEventArgs) => event.cancel = true); + combo.open(); + fixture.detectChanges(); + const item1 = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`)); + expect(item1).toBeDefined(); + + item1.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + expect(combo.selection).not.toBeDefined() + expect((combo as any)._remoteSelection[0]).toBeUndefined(); + }); + it('should add predefined selection to the input when data is bound after initialization', fakeAsync(() => { + fixture = TestBed.createComponent(IgxSimpleComboBindingDataAfterInitComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.instance; + input = fixture.debugElement.query(By.css(`.${CSS_CLASS_COMBO_INPUTGROUP}`)); + tick(16); + fixture.detectChanges(); + + const expectedOutput = 'One'; + expect(input.nativeElement.value).toEqual(expectedOutput); + })); + it('should clear selection and not clear value when bound to remote data and item is out of view', (async () => { + expect(combo.valueKey).toBeDefined(); + expect(combo.selection).not.toBeDefined() + + const selectedItem = combo.data[1]; + combo.toggle(); + combo.select(combo.data[1][combo.valueKey]); + + // Scroll selected item out of view + combo.virtualScrollContainer.scrollTo(40); + await wait(300); + fixture.detectChanges(); + + input.nativeElement.focus(); + fixture.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('Tab', input); + fixture.detectChanges(); + + expect(combo.selection).toBeDefined() + expect(combo.displayValue).toEqual(`${selectedItem[combo.displayKey]}`); + expect(combo.value).toEqual(selectedItem[combo.valueKey]); + })); + it('should set combo.displayValue to empty string when bound to remote data and selected item\'s data is not present', (async () => { + expect(combo.valueKey).toBeDefined(); + expect(combo.valueKey).toEqual('id'); + expect(combo.selection).not.toBeDefined() + + // current combo data - id: 0 - 9 + // select item that is not present in the data source yet + combo.select(15); + + expect(combo.selection).toBeDefined() + expect(combo.displayValue).toEqual(''); + + combo.toggle(); + + // scroll to selected item + combo.virtualScrollContainer.scrollTo(15); + await wait(30); + fixture.detectChanges(); + + const selectedItem = combo.data[combo.data.length - 1]; + expect(combo.displayValue).toEqual(`${selectedItem[combo.displayKey]}`); + })); + it('should not clear input on blur when bound to remote data and item is selected', () => { + input.triggerEventHandler('focus', {}); + fixture.detectChanges(); + + UIInteractions.simulateTyping('pro', input); + fixture.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('Tab', input); + fixture.detectChanges(); + + expect(combo.comboInput.value).toEqual('Product 0'); + }); + + it('should display correct value after the value has been changed from the form and then by the user', fakeAsync(() => { + fixture = TestBed.createComponent(IgxComboRemoteDataInReactiveFormComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.reactiveCombo; + reactiveForm = fixture.componentInstance.reactiveForm; + reactiveControl = reactiveForm.form.controls['comboValue']; + input = fixture.debugElement.query(By.css(`.${CSS_CLASS_COMBO_INPUTGROUP}`)); + tick() + fixture.detectChanges(); + expect(combo).toBeTruthy(); + + combo.select(0); + fixture.detectChanges(); + expect(combo.value).toEqual(0); + expect(input.nativeElement.value).toEqual('Product 0'); + + reactiveControl.setValue(3); + fixture.detectChanges(); + expect(combo.value).toEqual(3); + expect(input.nativeElement.value).toEqual('Product 3'); + + combo.open(); + fixture.detectChanges(); + const item1 = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`))[5]; + item1.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + + expect(combo.value).toEqual(5); + expect(input.nativeElement.value).toEqual('Product 5'); + })); + }); + + describe('Integration', () => { + let grid: IgxGridComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxSimpleComboInGridComponent + ] + }).compileComponents(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(IgxSimpleComboInGridComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + }); + it('Combo in IgxGrid cell display template correctly handles selection - issue #14305', async () => { + const firstRecRegionCell = grid.gridAPI.get_cell_by_index(0, 'Region') as any; + let comboNativeEl = firstRecRegionCell.nativeElement.querySelector(SIMPLE_COMBO_ELEMENT); + const comboToggleButton = comboNativeEl.querySelector(`.${CSS_CLASS_TOGGLEBUTTON}`); + + UIInteractions.simulateClickEvent(comboToggleButton); + fixture.detectChanges(); + + const comboDropDownList = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROPDOWNLIST}`)); + const firstItem = comboDropDownList.nativeElement.querySelector(`.${CSS_CLASS_DROPDOWNLISTITEM}`); + + UIInteractions.simulateClickEvent(firstItem); + fixture.detectChanges(); + + const firstRegionCellObject = grid.getCellByColumn(0, 'Region'); + expect(firstRegionCellObject.value).toEqual(fixture.componentInstance.regions[0]); + + try { + // combo should not throw from the selection getter at this point + grid.navigateTo(fixture.componentInstance.data.length - 1, 0); + await wait(30); + fixture.detectChanges(); + } catch (error) { + fail(`Test failed with error: ${error}`) + } + + const virtState = grid.verticalScrollContainer.state; + expect(virtState.startIndex).toBe(grid.dataView.length - virtState.chunkSize); + + // These will fail in case the editor (combo) in the cell display template is not bound to the cell value + // as the first record's selected value will be applied on the reused combos bc of the virtualization + for (let i = virtState.startIndex; i < virtState.startIndex + virtState.chunkSize && i < grid.dataView.length; i++) { + const targetCell = grid.gridAPI.get_cell_by_index(i, 'Region') as any; + comboNativeEl = targetCell.nativeElement.querySelector(SIMPLE_COMBO_ELEMENT); + const comboInput = comboNativeEl.querySelector('input'); + expect(comboInput.value).toBe('', `Failed on index: ${i.toString()}`); + } + + for (let i = virtState.startIndex; i < virtState.startIndex + virtState.chunkSize && i < grid.dataView.length; i++) { + const cell = grid.getCellByColumn(i, 'Region'); + expect(cell.value).toBe(undefined); + } + }); + }); +}); + +@Component({ + template: ` + + + + + +
    + + +
    +
    +
    +
    + `, + imports: [IgxSimpleComboComponent, IGX_GRID_DIRECTIVES, FormsModule] +}) +class IgxSimpleComboInGridComponent { + @ViewChild('grid', { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + + public data = []; + public regions = []; + constructor() { + for (let i = 1; i <= 15; i++) { + this.data.push({ + ID: i, + region: undefined + }); + } + for (let i = 1; i <= 5; i++) { + this.regions.push(`Region ${i}`); + } + } +} + +@Component({ + template: ` + + +
    + +
    State: {{display[key]}}
    +
    Region: {{display.region}}
    +
    +
    + +
    This is a header
    +
    + + + +
    + `, + imports: [IgxSimpleComboComponent, IgxComboItemDirective, IgxComboHeaderDirective, IgxComboFooterDirective] +}) +class IgxSimpleComboSampleComponent { + @ViewChild('combo', { read: IgxSimpleComboComponent, static: true }) + public combo: IgxSimpleComboComponent; + public allowCustomValues = false; + public size = 'medium'; + + public items = []; + public initData = []; + + constructor() { + + const division = { + 'New England 01': ['Connecticut', 'Maine', 'Massachusetts'], + 'New England 02': ['New Hampshire', 'Rhode Island', 'Vermont'], + 'Mid-Atlantic': ['New Jersey', 'New York', 'Pennsylvania'], + 'East North Central 02': ['Michigan', 'Ohio ', 'Wisconsin'], + 'East North Central 01': ['Illinois', 'Indiana'], + 'West North Central 01': ['Missouri', 'Nebraska', 'North Dakota', 'South Dakota'], + 'West North Central 02': ['Iowa', 'Kansas', 'Minnesota'], + 'South Atlantic 01': ['Delaware', 'Florida', 'Georgia', 'Maryland'], + 'South Atlantic 02': ['North Carolina', 'South Carolina', 'Virginia'], + 'South Atlantic 03': ['District of Columbia', 'West Virginia'], + 'East South Central 01': ['Alabama', 'Kentucky'], + 'East South Central 02': ['Mississippi', 'Tennessee'], + 'West South Central': ['Arkansas', 'Louisiana', 'Oklahome', 'Texas'], + Mountain: ['Arizona', 'Colorado', 'Idaho', 'Montana', 'Nevada', 'New Mexico', 'Utah', 'Wyoming'], + 'Pacific 01': ['Alaska', 'California'], + 'Pacific 02': ['Hawaii', 'Oregon', 'Washington'] + }; + const keys = Object.keys(division); + for (const key of keys) { + division[key].map((e) => { + this.items.push({ + field: e, + region: key.substring(0, key.length - 3) + }); + }); + } + + this.initData = this.items; + } + + public selectionChanging() { + } +} + +@Component({ + template: ``, + imports: [IgxSimpleComboComponent, FormsModule] +}) +export class IgxSimpleComboEmptyComponent { + @ViewChild('combo', { read: IgxSimpleComboComponent, static: true }) + public combo: IgxSimpleComboComponent; + + public data: any[] = []; + public name!: string; +} + +@Component({ + template: ` + search + `, + imports: [IgxSimpleComboComponent, IgxIconComponent, IgxComboToggleIconDirective, FormsModule] +}) +export class IgxSimpleComboIconTemplatesComponent { + @ViewChild('combo', { read: IgxSimpleComboComponent, static: true }) + public combo: IgxSimpleComboComponent; + + public data: any[] = [ + { name: 'Sofia', id: '1' }, + { name: 'London', id: '2' }, + ]; + public name!: string; +} + +@Component({ + template: ``, + imports: [IgxSimpleComboComponent, FormsModule] +}) +export class ComboModelBindingComponent implements OnInit { + @ViewChild(IgxSimpleComboComponent, { read: IgxSimpleComboComponent, static: true }) + public combo: IgxSimpleComboComponent; + public items: any[]; + public selectedItem: any; + + public ngOnInit() { + this.items = [{ text: 'One', id: 0 }, { text: 'Two', id: 1 }, { text: 'Three', id: 2 }, + { text: 'Four', id: 3 }, { text: 'Five', id: 4 }]; + } +} + +@Component({ + template: ` +
    + +> + +
    +`, + imports: [IgxSimpleComboComponent] +}) +class IgxComboInContainerTestComponent { + @ViewChild('combo', { read: IgxSimpleComboComponent, static: true }) + public combo: IgxSimpleComboComponent; + + public citiesData: string[] = [ + 'New York', + 'Sofia', + 'Istanbul', + 'Paris', + 'Hamburg', + 'Berlin', + 'London', + 'Oslo', + 'Los Angeles', + 'Rome', + 'Madrid', + 'Ottawa', + 'Prague', + 'Padua', + 'Palermo', + 'Palma de Mallorca']; + +} + +@Component({ + providers: [RemoteDataService], + template: ` + + + + `, + imports: [IgxSimpleComboComponent, AsyncPipe] +}) +export class IgxComboRemoteDataComponent implements OnInit, AfterViewInit, OnDestroy { + private remoteDataService = inject(RemoteDataService); + public cdr = inject(ChangeDetectorRef); + + @ViewChild('combo', { read: IgxSimpleComboComponent, static: true }) + public instance: IgxSimpleComboComponent; + public data; + public ngOnInit(): void { + this.data = this.remoteDataService.records; + } + + public ngAfterViewInit() { + this.remoteDataService.getData(this.instance.virtualizationState, (count) => { + this.instance.totalItemCount = count; + this.cdr.detectChanges(); + }); + } + + public dataLoading(evt) { + this.remoteDataService.getData(evt, () => { + this.cdr.detectChanges(); + }); + } + + public ngOnDestroy() { + this.cdr.detach(); + } +} + +@Component({ + template: ` +
    + + + + + `, + imports: [IgxSimpleComboComponent, IgxLabelDirective, FormsModule] +}) +class IgxSimpleComboInTemplatedFormComponent { + @ViewChild('testCombo', { read: IgxSimpleComboComponent, static: true }) + public testCombo: IgxSimpleComboComponent; + @ViewChild('form') + public form: NgForm; + public items: any[] = []; + public values: Array; + + constructor() { + const division = { + 'New England 01': ['Connecticut', 'Maine', 'Massachusetts'], + 'New England 02': ['New Hampshire', 'Rhode Island', 'Vermont'], + 'Mid-Atlantic': ['New Jersey', 'New York', 'Pennsylvania'], + 'East North Central 02': ['Michigan', 'Ohio', 'Wisconsin'], + 'East North Central 01': ['Illinois', 'Indiana'], + 'West North Central 01': ['Missouri', 'Nebraska', 'North Dakota', 'South Dakota'], + 'West North Central 02': ['Iowa', 'Kansas', 'Minnesota'], + 'South Atlantic 01': ['Delaware', 'Florida', 'Georgia', 'Maryland'], + 'South Atlantic 02': ['North Carolina', 'South Carolina', 'Virginia'], + 'South Atlantic 03': ['District of Columbia', 'West Virginia'], + 'East South Central 01': ['Alabama', 'Kentucky'], + 'East South Central 02': ['Mississippi', 'Tennessee'], + 'West South Central': ['Arkansas', 'Louisiana', 'Oklahome', 'Texas'], + Mountain: ['Arizona', 'Colorado', 'Idaho', 'Montana', 'Nevada', 'New Mexico', 'Utah', 'Wyoming'], + 'Pacific 01': ['Alaska', 'California'], + 'Pacific 02': ['Hawaii', 'Oregon', 'Washington'] + }; + const keys = Object.keys(division); + for (const key of keys) { + division[key].map((e) => { + this.items.push({ + field: e, + region: key.substring(0, key.length - 3) + }); + }); + } + } +} + +@Component({ + providers: [RemoteDataService], + template: ` +
    + + + + + `, + imports: [IgxSimpleComboComponent, AsyncPipe, ReactiveFormsModule] +}) +export class IgxComboRemoteDataInReactiveFormComponent implements OnInit, AfterViewInit, OnDestroy { + private remoteDataService = inject(RemoteDataService); + public cdr = inject(ChangeDetectorRef); + + @ViewChild('reactiveCombo', { read: IgxSimpleComboComponent, static: true }) + public reactiveCombo: IgxSimpleComboComponent; + @ViewChild('button', { read: HTMLButtonElement, static: true }) + public button: HTMLButtonElement; + @ViewChild('reactiveForm') + public reactiveForm: NgForm; + public comboForm: UntypedFormGroup; + public data; + constructor() { + const fb = inject(UntypedFormBuilder); + + this.comboForm = fb.group({ + comboValue: new UntypedFormControl('', Validators.required), + }); + } + public ngOnInit(): void { + this.data = this.remoteDataService.records; + } + + public ngAfterViewInit() { + this.remoteDataService.getData(this.reactiveCombo.virtualizationState, (count) => { + this.reactiveCombo.totalItemCount = count; + this.cdr.detectChanges(); + }); + } + + public dataLoading(evt) { + this.remoteDataService.getData(evt, () => { + this.cdr.detectChanges(); + }); + } + + public ngOnDestroy() { + this.cdr.detach(); + } + + public changeValue() { + this.comboForm.get('comboValue').setValue(14); + } +} + +@Component({ + template: ` +
    + + + + `, + imports: [IgxSimpleComboComponent, ReactiveFormsModule] +}) +export class IgxSimpleComboInReactiveFormComponent { + @ViewChild('reactiveCombo', { read: IgxSimpleComboComponent, static: true }) + public reactiveCombo: IgxSimpleComboComponent; + @ViewChild('reactiveForm') + public reactiveForm: NgForm; + public comboForm: UntypedFormGroup; + public comboData: any; + + constructor() { + const fb = inject(UntypedFormBuilder); + + this.comboForm = fb.group({ + comboValue: new UntypedFormControl('', Validators.required), + }); + + this.comboData = [ + { field: 'One', value: 1 }, + { field: 'Two', value: 2 }, + { field: 'Three', value: 3 }, + { field: 'Four', value: 4 }, + ]; + } +} + +@Component({ + template: ` + + `, + imports: [IgxSimpleComboComponent, FormsModule] +}) +export class IgxSimpleComboBindingDataAfterInitComponent implements AfterViewInit { + private cdr = inject(ChangeDetectorRef); + + public items: any[]; + public selectedItem = 1; + + public ngAfterViewInit() { + requestAnimationFrame(() => { + this.items = [{ text: 'One', id: 1 }, { text: 'Two', id: 2 }, { text: 'Three', id: 3 }, + { text: 'Four', id: 4 }, { text: 'Five', id: 5 }]; + this.cdr.detectChanges(); + }); + } +} + +@Component({ + template: ` +
    + + +
    + `, + imports: [IgxSimpleComboComponent] +}) +export class IgxBottomPositionSimpleComboComponent { + @ViewChild('combo', { read: IgxSimpleComboComponent, static: true }) + public combo: IgxSimpleComboComponent; + public items: any[] = []; + + constructor() { + const division = { + 'New England 01': ['Connecticut', 'Maine', 'Massachusetts'], + 'New England 02': ['New Hampshire', 'Rhode Island', 'Vermont'], + 'Mid-Atlantic': ['New Jersey', 'New York', 'Pennsylvania'], + 'East North Central 02': ['Michigan', 'Ohio', 'Wisconsin'], + 'East North Central 01': ['Illinois', 'Indiana'], + 'West North Central 01': ['Missouri', 'Nebraska', 'North Dakota', 'South Dakota'], + 'West North Central 02': ['Iowa', 'Kansas', 'Minnesota'], + 'South Atlantic 01': ['Delaware', 'Florida', 'Georgia', 'Maryland'], + 'South Atlantic 02': ['North Carolina', 'South Carolina', 'Virginia'], + 'South Atlantic 03': ['District of Columbia', 'West Virginia'], + 'East South Central 01': ['Alabama', 'Kentucky'], + 'East South Central 02': ['Mississippi', 'Tennessee'], + 'West South Central': ['Arkansas', 'Louisiana', 'Oklahome', 'Texas'], + Mountain: ['Arizona', 'Colorado', 'Idaho', 'Montana', 'Nevada', 'New Mexico', 'Utah', 'Wyoming'], + 'Pacific 01': ['Alaska', 'California'], + 'Pacific 02': ['Hawaii', 'Oregon', 'Washington'] + }; + const keys = Object.keys(division); + for (const key of keys) { + division[key].map((e) => { + this.items.push({ + field: e, + region: key.substring(0, key.length - 3) + }); + }); + } + } +} + +@Component({ + template: ` + + `, + imports: [IgxSimpleComboComponent, FormsModule, ReactiveFormsModule] +}) +export class IgxSimpleComboFormControlRequiredComponent implements OnInit { + public items: any[]; + + public formControl: FormControl = new FormControl(); + + constructor() { } + + public ngOnInit() { + this.items = [ + { id: 1, text: 'Option 1' }, + { id: 2, text: 'Option 2' }, + { id: 3, text: 'Option 3' }, + { id: 4, text: 'Option 4' }, + { id: 5, text: 'Option 5' } + ]; + } +} + +@Component({ + template: ` +
    + + + + `, + imports: [IgxSimpleComboComponent, FormsModule, ReactiveFormsModule] +}) +export class IgxSimpleComboFormWithFormControlComponent implements OnInit { + public items: any[]; + + public formGroup = new FormGroup({ + simpleCombo: new FormControl() + }); + + public formControl: FormControl = new FormControl(); + + constructor() { } + + public ngOnInit() { + this.items = [ + { id: 1, text: 'Option 1' }, + { id: 2, text: 'Option 2' }, + { id: 3, text: 'Option 3' }, + { id: 4, text: 'Option 4' }, + { id: 5, text: 'Option 5' } + ]; + } +} + +@Component({ + template: ` + + `, + imports: [IgxSimpleComboComponent, FormsModule, ReactiveFormsModule] +}) +export class IgxSimpleComboNgModelComponent implements OnInit { + public items: any[]; + public selectedItem: any; + + constructor() { } + + public ngOnInit() { + this.items = [ + { id: 1, text: 'Option 1' }, + { id: 2, text: 'Option 2' }, + { id: 3, text: 'Option 3' }, + { id: 4, text: 'Option 4' }, + { id: 5, text: 'Option 5' } + ]; + } +} + +@Component({ + template: ` +
    +
    + + +
    + + `, + imports: [IgxSimpleComboComponent, ReactiveFormsModule] +}) +export class IgxSimpleComboDirtyCheckTestComponent implements OnInit { + @ViewChild('combo', { read: IgxSimpleComboComponent, static: true }) + public combo: IgxSimpleComboComponent; + + public cities: any = []; + + public form = new FormGroup({ + city: new FormControl({ value: undefined, disabled: false }), + }); + + public ngOnInit(): void { + this.cities = [ + { id: 1, name: 'New York' }, + { id: 2, name: 'Los Angeles' }, + { id: 3, name: 'Chicago' }, + { id: 4, name: 'Houston' }, + { id: 5, name: 'Phoenix' } + ]; + } +} + +@Component({ + template: ` +
    +
    + + + + +
    + + `, + imports: [IgxSimpleComboComponent, ReactiveFormsModule] +}) +export class IgxSimpleComboTabBehaviorTestComponent implements OnInit { + @ViewChild('combo', { read: IgxSimpleComboComponent, static: true }) + public combo: IgxSimpleComboComponent; + @ViewChild('combo2', { read: IgxSimpleComboComponent, static: true }) + public combo2: IgxSimpleComboComponent; + @ViewChild('combo3', { read: IgxSimpleComboComponent, static: true }) + public combo3: IgxSimpleComboComponent; + + public cities = []; + + public form = new FormGroup({ + city: new FormControl({ value: undefined, disabled: false }), + city2: new FormControl({ value: undefined, disabled: false }), + city3: new FormControl({ value: undefined, disabled: false }), + }); + + public ngOnInit(): void { + this.cities = [ + { id: 1, name: 'New York' }, + { id: 2, name: 'Los Angeles' }, + { id: 3, name: 'Chicago' }, + { id: 4, name: 'Houston' }, + { id: 5, name: 'Phoenix' } + ]; + } +} diff --git a/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.ts b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.ts new file mode 100644 index 00000000000..f1aa318af7d --- /dev/null +++ b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.ts @@ -0,0 +1,618 @@ +import { NgTemplateOutlet } from '@angular/common'; +import { AfterViewInit, Component, DoCheck, EventEmitter, HostListener, Output, ViewChild, inject } from '@angular/core'; +import { ControlValueAccessor, FormGroupDirective, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { takeUntil } from 'rxjs/operators'; + +import { CancelableEventArgs, IBaseCancelableBrowserEventArgs, IBaseEventArgs, PlatformUtil } from 'igniteui-angular/core'; +import { IgxButtonDirective } from 'igniteui-angular/directives'; +import { IgxForOfDirective } from 'igniteui-angular/directives'; +import { IgxRippleDirective } from 'igniteui-angular/directives'; +import { IgxTextSelectionDirective } from 'igniteui-angular/directives'; +import { IgxInputGroupComponent, IgxInputDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IGX_COMBO_COMPONENT, IgxComboAddItemComponent, IgxComboAPIService, IgxComboBaseDirective, IgxComboDropDownComponent, IgxComboFilteringPipe, IgxComboGroupingPipe, IgxComboItemComponent } from 'igniteui-angular/combo'; +import { IgxDropDownItemNavigationDirective } from 'igniteui-angular/drop-down'; + +/** Emitted when an igx-simple-combo's selection is changing. */ +export interface ISimpleComboSelectionChangingEventArgs extends CancelableEventArgs, IBaseEventArgs { + /** An object which represents the value that is currently selected */ + oldValue: any; + /** An object which represents the value that will be selected after this event */ + newValue: any; + /** An object which represents the item that is currently selected */ + oldSelection: any; + /** An object which represents the item that will be selected after this event */ + newSelection: any; + /** The text that will be displayed in the combo text box */ + displayText: string; +} + +/** + * Represents a drop-down list that provides filtering functionality, allowing users to choose a single option from a predefined list. + * + * @igxModule IgxSimpleComboModule + * @igxTheme igx-combo-theme + * @igxKeywords combobox, single combo selection + * @igxGroup Grids & Lists + * + * @remarks + * It provides the ability to filter items as well as perform single selection on the provided data. + * Additionally, it exposes keyboard navigation and custom styling capabilities. + * @example + * ```html + * + * + * ``` + */ +@Component({ + selector: 'igx-simple-combo', + templateUrl: 'simple-combo.component.html', + providers: [ + IgxComboAPIService, + { provide: IGX_COMBO_COMPONENT, useExisting: IgxSimpleComboComponent }, + { provide: NG_VALUE_ACCESSOR, useExisting: IgxSimpleComboComponent, multi: true } + ], + imports: [IgxInputGroupComponent, IgxInputDirective, IgxTextSelectionDirective, IgxSuffixDirective, NgTemplateOutlet, IgxIconComponent, IgxComboDropDownComponent, IgxDropDownItemNavigationDirective, IgxForOfDirective, IgxComboItemComponent, IgxComboAddItemComponent, IgxButtonDirective, IgxRippleDirective, IgxComboFilteringPipe, IgxComboGroupingPipe] +}) +export class IgxSimpleComboComponent extends IgxComboBaseDirective implements ControlValueAccessor, AfterViewInit, DoCheck { + private platformUtil = inject(PlatformUtil); + private formGroupDirective = inject(FormGroupDirective, { optional: true }); + + /** @hidden @internal */ + @ViewChild(IgxComboDropDownComponent, { static: true }) + public dropdown: IgxComboDropDownComponent; + + /** @hidden @internal */ + @ViewChild(IgxComboAddItemComponent) + public addItem: IgxComboAddItemComponent; + + /** + * Emitted when item selection is changing, before the selection completes + * + * ```html + * + * ``` + */ + @Output() + public selectionChanging = new EventEmitter(); + + @ViewChild(IgxTextSelectionDirective, { static: true }) + private textSelection: IgxTextSelectionDirective; + + public override get value(): any { + return this._value[0]; + } + + /** + * Get current selection state + * + * @returns The selected item, if any + * ```typescript + * let mySelection = this.combo.selection; + * ``` + */ + public override get selection(): any { + return super.selection[0]; + } + + /** @hidden @internal */ + public composing = false; + + private _updateInput = true; + + private _collapsing = false; + + /** @hidden @internal */ + public get filteredData(): any[] | null { + return this._filteredData; + } + /** @hidden @internal */ + public set filteredData(val: any[] | null) { + this._filteredData = this.groupKey ? (val || []).filter((e) => e.isHeader !== true) : val; + this.checkMatch(); + } + + /** @hidden @internal */ + public override get searchValue(): string { + return this._searchValue; + } + public override set searchValue(val: string) { + this._searchValue = val; + } + + private get selectedItem(): any { + return this.selectionService.get(this.id).values().next().value; + } + + protected get hasSelectedItem(): boolean { + return !!this.selectionService.get(this.id).size; + } + + constructor() { + super(); + this.comboAPI.register(this); + } + + /** @hidden @internal */ + @HostListener('keydown.ArrowDown', ['$event']) + @HostListener('keydown.Alt.ArrowDown', ['$event']) + public onArrowDown(event: Event): void { + if (this.collapsed) { + event.preventDefault(); + event.stopPropagation(); + this.open(); + } else { + if (this.virtDir.igxForOf.length > 0 && !this.hasSelectedItem) { + this.dropdown.navigateNext(); + this.dropdownContainer.nativeElement.focus(); + } else if (this.allowCustomValues) { + this.addItem?.element.nativeElement.focus(); + } + } + } + + /** + * Select a defined item + * + * @param item the item to be selected + * ```typescript + * this.combo.select("New York"); + * ``` + */ + public select(item: any): void { + if (item !== undefined) { + const newSelection = this.selectionService.add_items(this.id, item instanceof Array ? item : [item], true); + this.setSelection(newSelection); + } + } + + /** + * Deselect the currently selected item + * + * @param item the items to be deselected + * ```typescript + * this.combo.deselect("New York"); + * ``` + */ + public deselect(): void { + this.clearSelection(); + } + + /** @hidden @internal */ + public writeValue(value: any): void { + const oldSelection = super.selection; + this.selectionService.select_items(this.id, this.isValid(value) ? [value] : [], true); + this.cdr.markForCheck(); + this._displayValue = this.createDisplayText(super.selection, oldSelection); + this._value = this.valueKey ? super.selection.map(item => item[this.valueKey]) : super.selection; + this.filterValue = this._displayValue?.toString() || ''; + } + + /** @hidden @internal */ + public override ngAfterViewInit(): void { + this.virtDir.contentSizeChange.pipe(takeUntil(this.destroy$)).subscribe(() => { + if (super.selection.length > 0) { + const index = this.virtDir.igxForOf.findIndex(e => { + let current = e ? e[this.valueKey] : undefined; + if (this.valueKey === null || this.valueKey === undefined) { + current = e; + } + return current === super.selection[0]; + }); + if (!this.isRemote) { + // navigate to item only if we have local data + // as with remote data this will fiddle with igxFor's scroll handler + // and will trigger another chunk load which will break the visualization + this.dropdown.navigateItem(index); + } + } + }); + this.dropdown.opening.pipe(takeUntil(this.destroy$)).subscribe((args) => { + if (args.cancel) { + return; + } + this._collapsing = false; + const filtered = this.filteredData.find(this.findAllMatches); + if (filtered === undefined || filtered === null) { + this.filterValue = this.searchValue = this.comboInput.value; + return; + } + }); + this.dropdown.opened.pipe(takeUntil(this.destroy$)).subscribe(() => { + if (this.composing) { + this.comboInput.focus(); + } + }); + this.dropdown.closing.pipe(takeUntil(this.destroy$)).subscribe((args) => { + if (args.cancel) { + return; + } + if (this.getEditElement() && !args.event) { + this._collapsing = true; + } else { + this.clearOnBlur(); + this._onTouchedCallback(); + } + this.comboInput.focus(); + }); + + // in reactive form the control is not present initially + // and sets the selection to an invalid value in writeValue method + if (!this.isValid(this.selectedItem)) { + this.selectionService.clear(this.id); + this._displayValue = ''; + } + + super.ngAfterViewInit(); + } + + /** @hidden @internal */ + public ngDoCheck(): void { + if (this.data?.length && super.selection.length && !this._displayValue) { + this._displayValue = this.createDisplayText(super.selection, []); + this._value = this.valueKey ? super.selection.map(item => item[this.valueKey]) : super.selection; + } + } + + /** @hidden @internal */ + public override handleInputChange(event?: any): void { + if (this.collapsed && this.comboInput.focused) { + this.open(); + } + if (event !== undefined) { + this.filterValue = this.searchValue = typeof event === 'string' ? event : event.target.value; + } + if (!this.comboInput.value.trim() && super.selection.length) { + // handle clearing of input by space + this.clearSelection(); + this._onChangeCallback(null); + this.filterValue = ''; + } + if (super.selection.length) { + const args: ISimpleComboSelectionChangingEventArgs = { + newValue: undefined, + oldValue: this.selectedItem, + newSelection: undefined, + oldSelection: this.selection, + displayText: typeof event === 'string' ? event : event?.target?.value, + owner: this, + cancel: false + }; + this.selectionChanging.emit(args); + if (!args.cancel) { + this.selectionService.select_items(this.id, [], true); + } + } + // when filtering the focused item should be the first item or the currently selected item + if (!this.dropdown.focusedItem || this.dropdown.focusedItem.id !== this.dropdown.items[0].id) { + this.dropdown.navigateFirst(); + } + super.handleInputChange(event); + this.composing = true; + } + + /** @hidden @internal */ + public handleInputClick(): void { + if (this.collapsed) { + this.open(); + this.comboInput.focus(); + } + } + + /** @hidden @internal */ + public override handleKeyDown(event: KeyboardEvent): void { + if (event.key === this.platformUtil.KEYMAP.ENTER) { + const filtered = this.filteredData.find(this.findAllMatches); + if (filtered === null || filtered === undefined) { + return; + } + if (!this.dropdown.collapsed) { + const focusedItem = this.dropdown.focusedItem; + if (focusedItem && !focusedItem.isHeader) { + this.select(focusedItem.itemID); + event.preventDefault(); + event.stopPropagation(); + this.close(); + } else { + event.preventDefault(); + event.stopPropagation(); + this.comboInput.focus(); + } + } + // manually trigger text selection as it will not be triggered during editing + this.textSelection.trigger(); + return; + } + if (event.key === this.platformUtil.KEYMAP.BACKSPACE + || event.key === this.platformUtil.KEYMAP.DELETE) { + this._updateInput = false; + this.clearSelection(true); + } + if (!this.collapsed && event.key === this.platformUtil.KEYMAP.TAB) { + const filtered = this.filteredData.find(this.findAllMatches); + if (filtered === null || filtered === undefined) { + this.clearOnBlur(); + this.close(); + return; + } + const focusedItem = this.dropdown.focusedItem; + if (focusedItem && !focusedItem.isHeader) { + this.select(focusedItem.itemID); + this.close(); + this.textSelection.trigger(); + } else { + this.clearOnBlur(); + this.close(); + } + } + this.composing = false; + super.handleKeyDown(event); + } + + /** @hidden @internal */ + public handleKeyUp(event: KeyboardEvent): void { + if (event.key === this.platformUtil.KEYMAP.ARROW_DOWN) { + this.dropdown.focusedItem = this.hasSelectedItem && this.filteredData.length > 0 + ? this.dropdown.items.find(i => i.itemID === this.selectedItem) + : this.dropdown.items[0]; + this.dropdownContainer.nativeElement.focus(); + } + } + + /** @hidden @internal */ + public handleItemKeyDown(event: KeyboardEvent): void { + if (event.key === this.platformUtil.KEYMAP.ARROW_UP && event.altKey) { + this.close(); + this.comboInput.focus(); + return; + } + if (event.key === this.platformUtil.KEYMAP.ENTER) { + this.comboInput.focus(); + } + } + + /** @hidden @internal */ + public handleItemClick(): void { + this.close(); + this.comboInput.focus(); + } + + /** @hidden @internal */ + public override onBlur(): void { + // when clicking the toggle button to close the combo and immediately clicking outside of it + // the collapsed state is not modified as the dropdown is still not closed + if (this.collapsed || this._collapsing) { + this.clearOnBlur(); + } + super.onBlur(); + } + + /** @hidden @internal */ + public getEditElement(): HTMLElement { + return this.comboInput.nativeElement; + } + + /** @hidden @internal */ + public clearInput(event: Event): void { + const oldSelection = this.selection; + this.clearSelection(true); + + if (!this.collapsed) { + this.focusSearchInput(true); + } + event.stopPropagation(); + + if (this.selection !== oldSelection) { + this.comboInput.value = this.filterValue = this.searchValue = ''; + } + + this.dropdown.focusedItem = null; + this.composing = false; + this.comboInput.focus(); + } + + /** @hidden @internal */ + public handleClear(event: Event): void { + if (this.disabled) { + return; + } + + this.clearInput(event); + } + + /** @hidden @internal */ + public handleClearKeyDown(event: KeyboardEvent): void { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.clearInput(event); + } + } + + /** @hidden @internal */ + public handleOpened(): void { + this.triggerCheck(); + if (!this.comboInput.focused) { + this.dropdownContainer.nativeElement.focus(); + } + this.opened.emit({ owner: this }); + } + + /** @hidden @internal */ + public override handleClosing(e: IBaseCancelableBrowserEventArgs): void { + const args: IBaseCancelableBrowserEventArgs = { owner: this, event: e.event, cancel: e.cancel }; + this.closing.emit(args); + e.cancel = args.cancel; + if (e.cancel) { + return; + } + + this.composing = false; + // explicitly update selection so that we don't have to force CD + this.textSelection.selected = true; + } + + /** @hidden @internal */ + public focusSearchInput(opening?: boolean): void { + if (opening) { + this.dropdownContainer.nativeElement.focus(); + } else { + this.comboInput.nativeElement.focus(); + } + } + + /** @hidden @internal */ + public override onClick(event: Event): void { + super.onClick(event); + if (this.comboInput.value.length === 0) { + this.virtDir.scrollTo(0); + } + } + + protected findAllMatches = (element: any): boolean => { + const value = this.displayKey ? element[this.displayKey] : element; + if (value === null || value === undefined || value === '') { + // we can accept null, undefined and empty strings as empty display values + return true; + } + const searchValue = this.searchValue || this.comboInput.value; + return !!searchValue && value.toString().toLowerCase().includes(searchValue.toLowerCase()); + }; + + protected setSelection(newSelection: any): void { + const newValueAsArray = newSelection ? Array.from(newSelection) as IgxComboItemComponent[] : []; + const oldValueAsArray = Array.from(this.selectionService.get(this.id) || []); + const newItems = this.convertKeysToItems(newValueAsArray); + const oldItems = this.convertKeysToItems(oldValueAsArray); + const displayText = this.createDisplayText(this.convertKeysToItems(newValueAsArray), oldValueAsArray); + const args: ISimpleComboSelectionChangingEventArgs = { + newValue: newValueAsArray[0], + oldValue: oldValueAsArray[0], + newSelection: newItems[0], + oldSelection: oldItems[0], + displayText, + owner: this, + cancel: false + }; + if (args.newSelection !== args.oldSelection) { + this.selectionChanging.emit(args); + } + // TODO: refactor below code as it sets the selection and the display text + if (!args.cancel) { + let argsSelection = this.isValid(args.newValue) + ? args.newValue + : []; + argsSelection = Array.isArray(argsSelection) ? argsSelection : [argsSelection]; + this.selectionService.select_items(this.id, argsSelection, true); + this._value = argsSelection; + if (this._updateInput) { + this.comboInput.value = this._displayValue = this.searchValue = displayText !== args.displayText + ? args.displayText + : this.createDisplayText(super.selection, [args.oldValue]); + } + this._onChangeCallback(args.newValue); + this._updateInput = true; + } else if (this.isRemote) { + this.registerRemoteEntries(newValueAsArray, false); + } else { + args.displayText = this.createDisplayText(oldItems, []); + + const oldSelectionArray = args.oldSelection ? [args.oldSelection] : []; + this.comboInput.value = this._displayValue = this.searchValue = this.createDisplayText(oldSelectionArray, []); + + if (this.isRemote) { + this.registerRemoteEntries(newValueAsArray, false); + } + } + } + + protected createDisplayText(newSelection: any[], oldSelection: any[]): string { + if (this.isRemote) { + const selection = this.valueKey ? newSelection.map(item => item[this.valueKey]) : newSelection; + return this.getRemoteSelection(selection, oldSelection); + } + + if (this.displayKey !== null + && this.displayKey !== undefined + && newSelection.length > 0) { + return newSelection.filter(e => e).map(e => e[this.displayKey])[0]?.toString() || ''; + } + + return newSelection[0]?.toString() || ''; + } + + protected override getRemoteSelection(newSelection: any[], oldSelection: any[]): string { + if (!newSelection.length) { + this.registerRemoteEntries(oldSelection, false); + return ''; + } + + this.registerRemoteEntries(oldSelection, false); + this.registerRemoteEntries(newSelection); + return Object.keys(this._remoteSelection).map(e => this._remoteSelection[e])[0] || ''; + } + + /** Contains key-value pairs of the selected valueKeys and their resp. displayKeys */ + protected override registerRemoteEntries(ids: any[], add = true) { + const selection = this.getValueDisplayPairs(ids)[0]; + + if (add && selection) { + this._remoteSelection[selection[this.valueKey]] = selection[this.displayKey].toString(); + } else { + this._remoteSelection = {}; + } + } + + private clearSelection(ignoreFilter?: boolean): void { + let newSelection = this.selectionService.get_empty(); + if (this.filteredData.length !== this.data.length && !ignoreFilter) { + newSelection = this.selectionService.delete_items(this.id, this.selectionService.get_all_ids(this.filteredData, this.valueKey)); + } + if (this.selectionService.get(this.id).size > 0 || this.comboInput.value.trim()) { + this.setSelection(newSelection); + } + } + + private clearOnBlur(): void { + if (this.isRemote) { + const searchValue = this.searchValue || this.comboInput.value; + const remoteValue = Object.keys(this._remoteSelection).map(e => this._remoteSelection[e])[0] || ''; + if (searchValue !== remoteValue) { + this.clear(); + } + return; + } + + const filtered = this.filteredData.find(this.findMatch); + // selecting null in primitive data returns undefined as the search text is '', but the item is null + if (filtered === undefined && this.selectedItem !== null || !super.selection.length) { + this.clear(); + } + } + + private getElementVal(element: any): string { + const elementVal = this.displayKey ? element[this.displayKey] : element; + return String(elementVal); + } + + private clear(): void { + this.clearSelection(true); + const oldSelection = this.selection; + if (this.selection !== oldSelection) { + this.comboInput.value = this._displayValue = this.searchValue = ''; + } + } + + private isValid(value: any): boolean { + if (this.formGroupDirective && value === null) { + return false; + } + + if (this.required) { + return value !== null && value !== '' && value !== undefined + } + + return value !== undefined; + } +} diff --git a/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.module.ts b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.module.ts new file mode 100644 index 00000000000..50e1b8b5a6d --- /dev/null +++ b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { IGX_SIMPLE_COMBO_DIRECTIVES } from './public_api'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + ...IGX_SIMPLE_COMBO_DIRECTIVES + ], + exports: [ + ...IGX_SIMPLE_COMBO_DIRECTIVES + ] +}) +export class IgxSimpleComboModule { } diff --git a/projects/igniteui-angular/slider/README.md b/projects/igniteui-angular/slider/README.md new file mode 100644 index 00000000000..aa6af195a57 --- /dev/null +++ b/projects/igniteui-angular/slider/README.md @@ -0,0 +1,85 @@ +# igx-slider + +### The latest version of the SPEC could be found in the [Wiki](https://github.com/IgniteUI/igniteui-angular/wiki/igxSlider-Specification). + +IgxSliderComponent is a much more powerful alternative to ``. +The slider component allows users to select a single value from a range or select upper and lower values from range of values. +The slider is a form component and can be used in both template-driven and reactive forms. When using the slider with `[(ngModel)]` consider the fact that the `IgxSliderType.SLIDER` (single-value) slider supports it fully, but the `IgxSliderType.RANGE` (upper and lower value) slider supports it only to write to the `ngModel`. If you want to take advantage of two-way databinding with the `IgxSliderType.RANGE` slider, then use `[(upperValue)]` and `[(lowerValue)]` bindings. +Based on its configuration it's a slider (single value) or range (upper and lower value) slider. +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/slider) + +## Usage + +### Slider + +```html + + +``` + +---- + +### Range slider + +```html + + +``` + +## Getting Started + +### Dependencies + +To use the IgxSlider import the IgxSliderComponent: + +```typescript +import { IgxSliderComponent } from "../../../src/main"; +``` + +## API + +##### Enums + +###### IgxSliderType + +| Name | Description | +| :--- | :---------- | +| SLIDER | Slider with single thumb. | +| RANGE | Range slider with multiple thumbs, that can mark the range. | + +##### Interfaces + +###### IRangeSliderValue + +| Name | Type | Description | +| :--- | :--- | :---------- | +| lower | number | The lower value of the RANGE slider | +| upper | number | The upper value of the RANGE slider | + + +##### Inputs + +| Name | Type | Description | +| :--- | :--- | :--- | +| id | string | Unique identifier of the component. If not provided it will be automatically generated.| +| disabled | boolean | Disables or enables UI interaction. | +| continuous | boolean | Marks slider as continuous. By default is considered that the slider is discrete. Discrete slider does not have ticks and does not shows bubble labels for values. | +| lowerBound | number | The lower boundary of the slider value. If not set is the same as min value. | +| upperBound | number | The lower boundary of the slider value. If not set is the same as max value. | +| lowerValue | number | The lower value of a RANGE slider. | +| upperValue | number | The upper value of a RANGE slider. | +| maxValue | number | The maximal value for the slider. | +| minValue | number | The minimal value for the slider. | +| step | number | The incremental/decremental step of the value when dragging the thumb. The default step is 1, and step should be greater than 0. | +| thumbLabelVisibilityDuration | number | The duration visibility of thumbs labels. The default value is 750 milliseconds. | +| type | [IgxSliderType](#slidertype) | Sets the IgxSliderType, which is SLIDER or RANGE. | +| value | number | [IRangeSliderValue](#irangeslidervalue) | The slider value. If the slider is of type SLIDER the argument is number. By default if no value is set the default value is same as lower upper bound. If the slider type is RANGE then the argument is object containing lower and upper properties for the values. By default if no value is set the default value is for lower value it is the same as lower bound and if no value is set for the upper value it is the same as the upper bound. + +##### Outputs + +| Name | Description | +| :--- | :--- | +| valueChange | This event is emitted when user has stopped interacting the thumb and value is changed. | +| upperValueChange | This event is emitted when `upperValue` changes in a RANGE slider. | +| lowerValueChange | This event is emitted when `lowerValue` changes in a RANGE slider. | diff --git a/projects/igniteui-angular/slider/index.ts b/projects/igniteui-angular/slider/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/slider/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/slider/ng-package.json b/projects/igniteui-angular/slider/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/slider/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/slider/src/public_api.ts b/projects/igniteui-angular/slider/src/public_api.ts new file mode 100644 index 00000000000..a49d2772886 --- /dev/null +++ b/projects/igniteui-angular/slider/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './slider/public_api'; +export * from './slider/slider.module'; diff --git a/projects/igniteui-angular/slider/src/slider/label/thumb-label.component.html b/projects/igniteui-angular/slider/src/slider/label/thumb-label.component.html new file mode 100644 index 00000000000..83978a85c84 --- /dev/null +++ b/projects/igniteui-angular/slider/src/slider/label/thumb-label.component.html @@ -0,0 +1,7 @@ +
    + +
    + + + {{ value }} + diff --git a/projects/igniteui-angular/slider/src/slider/label/thumb-label.component.ts b/projects/igniteui-angular/slider/src/slider/label/thumb-label.component.ts new file mode 100644 index 00000000000..10ac41aebfc --- /dev/null +++ b/projects/igniteui-angular/slider/src/slider/label/thumb-label.component.ts @@ -0,0 +1,93 @@ +import { Component, Input, TemplateRef, HostBinding, ElementRef, booleanAttribute, inject } from '@angular/core'; +import { SliderHandle } from '../slider.common'; +import { IgxSliderThumbComponent } from '../thumb/thumb-slider.component'; +import { NgClass, NgTemplateOutlet } from '@angular/common'; + +/** + * @hidden + */ +@Component({ + selector: 'igx-thumb-label', + templateUrl: 'thumb-label.component.html', + imports: [NgClass, NgTemplateOutlet] +}) +export class IgxThumbLabelComponent { + private _elementRef = inject(ElementRef); + + @Input() + public value: any; + + @Input() + public templateRef: TemplateRef; + + @Input() + public context: any; + + @Input() + public type: SliderHandle; + + @Input({ transform: booleanAttribute }) + public continuous: boolean; + + @Input({ transform: booleanAttribute }) + public deactiveState: boolean; + + @Input() + public thumb: IgxSliderThumbComponent; + + + @HostBinding('class.igx-slider-thumb-label-from') + public get thumbFromClass() { + return this.type === SliderHandle.FROM; + } + + @HostBinding('class.igx-slider-thumb-label-to') + public get thumbToClass() { + return this.type === SliderHandle.TO; + } + + @HostBinding('class.igx-slider-thumb-label-from--active') + public get thumbFromActiveClass() { + return this.type === SliderHandle.FROM && this.active; + } + + @HostBinding('class.igx-slider-thumb-label-to--active') + public get thumbToActiveClass() { + return this.type === SliderHandle.TO && this.active; + } + + @HostBinding('class.igx-slider-thumb-label-from--pressed') + public get labelFromPressedClass() { + return this.thumb?.thumbFromPressedClass; + } + + @HostBinding('class.igx-slider-thumb-label-to--pressed') + public get labelToPressedClass() { + return this.thumb?.thumbToPressedClass; + } + + public get getLabelClass() { + return { + 'igx-slider-thumb-label-from__container': this.type === SliderHandle.FROM, + 'igx-slider-thumb-label-to__container': this.type === SliderHandle.TO + }; + } + + private _active: boolean; + + public get nativeElement() { + return this._elementRef.nativeElement; + } + + public get active() { + return this._active; + } + + public set active(val: boolean) { + if (this.continuous || this.deactiveState) { + this._active = false; + } else { + this._active = val; + } + } +} diff --git a/projects/igniteui-angular/slider/src/slider/public_api.ts b/projects/igniteui-angular/slider/src/slider/public_api.ts new file mode 100644 index 00000000000..634b5483c8f --- /dev/null +++ b/projects/igniteui-angular/slider/src/slider/public_api.ts @@ -0,0 +1,13 @@ +import { IgxThumbFromTemplateDirective, IgxThumbToTemplateDirective, IgxTickLabelTemplateDirective } from './slider.common'; +import { IgxSliderComponent } from './slider.component'; + +export * from './slider.component'; +export * from './slider.common'; + +/* NOTE: Slider directives collection for ease-of-use import in standalone components scenario */ +export const IGX_SLIDER_DIRECTIVES = [ + IgxSliderComponent, + IgxThumbFromTemplateDirective, + IgxThumbToTemplateDirective, + IgxTickLabelTemplateDirective +] as const; diff --git a/projects/igniteui-angular/slider/src/slider/slider.common.ts b/projects/igniteui-angular/slider/src/slider/slider.common.ts new file mode 100644 index 00000000000..49fd25f3d81 --- /dev/null +++ b/projects/igniteui-angular/slider/src/slider/slider.common.ts @@ -0,0 +1,94 @@ +import { Directive } from '@angular/core'; + +/** + * Template directive that allows you to set a custom template representing the lower label value of the {@link IgxSliderComponent} + * + * ```html + * + * {{value}} + * + * ``` + * + * @context {@link IgxSliderComponent.context} + */ +@Directive({ + selector: '[igxSliderThumbFrom]', + standalone: true +}) +export class IgxThumbFromTemplateDirective {} + +/** + * Template directive that allows you to set a custom template representing the upper label value of the {@link IgxSliderComponent} + * + * ```html + * + * {{value}} + * + * ``` + * + * @context {@link IgxSliderComponent.context} + */ +@Directive({ + selector: '[igxSliderThumbTo]', + standalone: true +}) +export class IgxThumbToTemplateDirective {} + +/** + * Template directive that allows you to set a custom template, represeting primary/secondary tick labels of the {@link IgxSliderComponent} + * + * @context {@link IgxTicksComponent.context} + */ +@Directive({ + selector: '[igxSliderTickLabel]', + standalone: true +}) +export class IgxTickLabelTemplateDirective {} + +export interface IRangeSliderValue { + lower: number; + upper: number; +} + +export interface ISliderValueChangeEventArgs { + oldValue: number | IRangeSliderValue; + value: number | IRangeSliderValue; +} + +export const IgxSliderType = { + /** + * Slider with single thumb. + */ + SLIDER: 'slider', + /** + * Range slider with multiple thumbs, that can mark the range. + */ + RANGE: 'range' +} as const; +export type IgxSliderType = (typeof IgxSliderType)[keyof typeof IgxSliderType]; + +export const SliderHandle = { + FROM: 'from', + TO: 'to' +} as const; +export type SliderHandle = (typeof SliderHandle)[keyof typeof SliderHandle]; + +/** + * Slider Tick labels Orientation + */ +export const TickLabelsOrientation = { + Horizontal: 'horizontal', + TopToBottom: 'toptobottom', + BottomToTop: 'bottomtotop' +} as const; +export type TickLabelsOrientation = (typeof TickLabelsOrientation)[keyof typeof TickLabelsOrientation]; + +/** + * Slider Ticks orientation + */ +export const TicksOrientation = { + Top: 'top', + Bottom: 'bottom', + Mirror: 'mirror' +} as const; +export type TicksOrientation = (typeof TicksOrientation)[keyof typeof TicksOrientation]; diff --git a/projects/igniteui-angular/slider/src/slider/slider.component.html b/projects/igniteui-angular/slider/src/slider/slider.component.html new file mode 100644 index 00000000000..953f2b88ade --- /dev/null +++ b/projects/igniteui-angular/slider/src/slider/slider.component.html @@ -0,0 +1,100 @@ +@if (showTicks && showTopTicks) { + + +} +
    +
    +
    +
    + + + +
    +
    +
    + @if (isRange) { + + } + + @if (isRange) { + + } + + + + +
    +@if (showTicks && showBottomTicks) { + + +} diff --git a/projects/igniteui-angular/slider/src/slider/slider.component.spec.ts b/projects/igniteui-angular/slider/src/slider/slider.component.spec.ts new file mode 100644 index 00000000000..4838a1e45ba --- /dev/null +++ b/projects/igniteui-angular/slider/src/slider/slider.component.spec.ts @@ -0,0 +1,2255 @@ +import { Component, Input, ViewChild, inject } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { FormsModule, ReactiveFormsModule, UntypedFormControl } from '@angular/forms'; +import { By, HammerModule } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { ɵDIR_DOCUMENT, ɵIgxDirectionality } from 'igniteui-angular/core'; +import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; +import { IgxSliderType, IgxThumbFromTemplateDirective, IgxThumbToTemplateDirective, IRangeSliderValue, TickLabelsOrientation, TicksOrientation } from './slider.common'; +import { IgxSliderComponent } from './slider.component'; + +const SLIDER_CLASS = '.igx-slider'; +const THUMB_TAG = 'igx-thumb'; +const THUMB_TO_CLASS = '.igx-slider-thumb-to'; +const THUMB_TO_PRESSED_CLASS = '.igx-slider-thumb-to--pressed'; +const THUMB_FROM_CLASS = '.igx-slider-thumb-from'; +const THUMB_LABEL = 'igx-thumb-label'; +const SLIDER_TICKS_ELEMENT = '.igx-slider__ticks'; +const SLIDER_TICKS_TOP_ELEMENT = '.igx-slider__ticks--top'; +const SLIDER_PRIMARY_GROUP_TICKS_CLASS_NAME = 'igx-slider__ticks-group--tall'; +const SLIDER_PRIMARY_GROUP_TICKS_CLASS = `.${SLIDER_PRIMARY_GROUP_TICKS_CLASS_NAME}`; +const SLIDER_GROUP_TICKS_CLASS = '.igx-slider__ticks-group'; +const SLIDER_TICK_LABELS_CLASS = '.igx-slider__ticks-label'; +const SLIDER_TICK_LABELS_HIDDEN_CLASS = 'igx-slider__tick-label--hidden'; +const TOP_TO_BOTTOM_TICK_LABLES = '.igx-slider__tick-labels--top-bottom'; +const BOTTOM_TO_TOP_TICK_LABLES = '.igx-slider__tick-labels--bottom-top'; + +interface FakeDoc { + body: { dir?: string }; + documentElement: { dir?: string }; +} + +describe('IgxSlider', () => { + let fakeDoc: FakeDoc; + beforeEach(waitForAsync(() => { + fakeDoc = { body: {}, documentElement: {} }; + + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, FormsModule, ReactiveFormsModule, HammerModule, + SliderInitializeTestComponent, + SliderMinMaxComponent, + SliderTestComponent, + SliderWithLabelsComponent, + RangeSliderTestComponent, + RangeSliderWithLabelsComponent, + RangeSliderWithCustomTemplateComponent, + SliderTicksComponent, + SliderRtlComponent, + SliderTemplateFormComponent, + SliderReactiveFormComponent, + SliderWithValueAdjustmentComponent + ], + providers: [ + { provide: ɵDIR_DOCUMENT, useFactory: () => fakeDoc } + ] + }).compileComponents(); + })); + + describe('Base tests', () => { + let fixture: ComponentFixture; + let slider: IgxSliderComponent; + + beforeEach(() => { + fixture = TestBed.createComponent(SliderInitializeTestComponent); + slider = fixture.componentInstance.slider; + fixture.detectChanges(); + }); + + it('should have lower bound equal to min value when lower bound is not set', () => { + const domSlider = fixture.debugElement.query(By.css(SLIDER_CLASS)).nativeElement; + + expect(slider.id).toContain('igx-slider-'); + expect(domSlider.id).toContain('igx-slider-'); + expect(slider.lowerBound).toBe(slider.minValue); + }); + + it('should have upper bound equal to max value when upper bound is not set', () => { + expect(slider.upperBound).toBe(slider.maxValue); + }); + + it('should have upper value equal to lower bound when lower value is not set and slider type is SLIDER', () => { + slider.type = IgxSliderType.SLIDER; + fixture.detectChanges(); + + expect(slider.value).toBe(slider.lowerBound); + }); + + it('should change minValue', () => { + const expectedMinValue = 3; + slider.minValue = expectedMinValue; + + fixture.detectChanges(); + expect(slider.minValue).toBe(expectedMinValue); + }); + + it('should change maxValue', () => { + const expectedMaxValue = 15; + slider.maxValue = expectedMaxValue; + + fixture.detectChanges(); + expect(slider.maxValue).toBe(expectedMaxValue); + }); + + it('should prevent setting minValue when greater than maxValue', () => { + slider.maxValue = 6; + slider.minValue = 10; + + const expectedMinValue = 0; + fixture.detectChanges(); + + expect(slider.minValue).toBe(expectedMinValue); + expect(slider.minValue).toBeLessThan(slider.maxValue); + }); + + it('should prevent setting maxValue when lower than minValue', () => { + slider.minValue = 3; + slider.maxValue = -5; + + const expectedMaxValue = 100; + fixture.detectChanges(); + + expect(slider.maxValue).toBe(expectedMaxValue); + expect(slider.maxValue).toBeGreaterThan(slider.minValue); + }); + + it('should change lowerBound', () => { + const expectedLowerBound = 3; + slider.lowerBound = expectedLowerBound; + slider.upperBound = 20; + + fixture.detectChanges(); + + expect(slider.lowerBound).toBe(expectedLowerBound); + }); + + it('should change upperBound', () => { + const expectedUpperBound = 40; + slider.upperBound = expectedUpperBound; + slider.lowerBound = 2; + + fixture.detectChanges(); + + expect(slider.upperBound).toBe(expectedUpperBound); + }); + + it('should set lowerBound to be same as minValue if exceeds upperBound', () => { + slider.upperBound = 20; + slider.lowerBound = 40; + + fixture.detectChanges(); + + expect(slider.lowerBound).toBe(slider.minValue); + expect(slider.lowerBound).toBeLessThan(slider.upperBound); + }); + + it('should set upperBound to be same as maxValue if exceeds lowerBound', () => { + slider.lowerBound = 40; + slider.upperBound = 20; + + fixture.detectChanges(); + + expect(slider.upperBound).toBe(slider.maxValue); + expect(slider.upperBound).toBeGreaterThan(slider.lowerBound); + }); + + it('should set upperBound to be same as maxValue if exceeds lowerBound', () => { + slider.lowerBound = 40; + slider.upperBound = 20; + fixture.detectChanges(); + expect(slider.upperBound).toBe(slider.maxValue); + expect(slider.upperBound).toBeGreaterThan(slider.lowerBound); + }); + + + it('should not set upper value outside bounds slider when slider is SLIDER', () => { + slider.lowerBound = 10; + slider.upperBound = 40; + fixture.detectChanges(); + + slider.value = 20; + fixture.detectChanges(); + expect(fixture.componentInstance.slider.value).toBe(20); + + slider.value = 45; + fixture.detectChanges(); + expect(fixture.componentInstance.slider.value).toBe(40); + }); + + it('should not set upper value to outside bounds slider when slider is RANGE', () => { + slider.lowerBound = 10; + slider.upperBound = 40; + slider.type = IgxSliderType.RANGE; + + fixture.detectChanges(); + + slider.value = { + lower: 20, + upper: 30 + }; + + fixture.detectChanges(); + expect(slider.value.lower).toBe(20); + expect(slider.value.upper).toBe(30); + + slider.upperValue = 50; + + fixture.detectChanges(); + expect(slider.value.lower).toBe(20); + expect(slider.value.upper).toBe(40); + }); + + it('should not set value upper when is less than lower value when slider is RANGE', () => { + slider.lowerBound = 10; + slider.upperBound = 40; + slider.type = IgxSliderType.RANGE; + + fixture.detectChanges(); + + slider.value = { + lower: 20, + upper: 30 + }; + + fixture.detectChanges(); + expect(slider.value.lower).toBe(20); + expect(slider.value.upper).toBe(30); + + slider.value = { + lower: 20, + upper: 15 + }; + + fixture.detectChanges(); + expect(slider.value.lower).toBe(15); + expect(slider.value.upper).toBe(20); + }); + + it('should position correctly lower and upper value based on the step', () => { + slider.step = 10; + slider.type = IgxSliderType.RANGE; + slider.value = { + lower: 23, + upper: 56 + }; + + fixture.detectChanges(); + + expect((slider.value as IRangeSliderValue).lower).toBe(20); + expect((slider.value as IRangeSliderValue).upper).toBe(50); + }); + + it('should not set lower value outside bounds slider when slider is RANGE', () => { + slider.lowerBound = 10; + slider.upperBound = 40; + slider.type = IgxSliderType.RANGE; + + fixture.detectChanges(); + + slider.value = { + lower: 20, + upper: 30 + }; + + fixture.detectChanges(); + expect(slider.value.lower).toBe(20); + expect(slider.value.upper).toBe(30); + + slider.value = { + lower: 5, + upper: 30 + }; + + fixture.detectChanges(); + expect(slider.value.lower).toBe(10); + expect(slider.value.upper).toBe(30); + }); + + it('should not set value lower when is more than upper value when slider is RANGE', () => { + slider.lowerBound = 10; + slider.upperBound = 40; + slider.type = IgxSliderType.RANGE; + + fixture.detectChanges(); + + slider.value = { + lower: 20, + upper: 30 + }; + + fixture.detectChanges(); + expect(slider.value.lower).toBe(20); + expect(slider.value.upper).toBe(30); + + slider.value = { + lower: 35, + upper: 30 + }; + + fixture.detectChanges(); + expect(slider.value.lower).toBe(30); + expect(slider.value.upper).toBe(35); + }); + + it('should set upperBound to be same as maxValue if exceeds lowerBound', () => { + slider.lowerBound = 40; + slider.upperBound = 20; + + fixture.detectChanges(); + expect(slider.upperBound).toBe(slider.maxValue); + expect(slider.upperBound).toBeGreaterThan(slider.lowerBound); + }); + + it('should set slider width', () => { + expect(slider.upperBound).toBe(slider.maxValue); + expect(slider.upperBound).toBeGreaterThan(slider.lowerBound); + }); + + + it('should change value when arrows are pressed and slider is SLIDER', () => { + slider.value = 60; + fixture.detectChanges(); + + const thumbTo = fixture.nativeElement.querySelector(THUMB_TO_CLASS); + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', thumbTo, true); + + fixture.detectChanges(); + expect(Math.round(slider.value as number)).toBe(61); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', thumbTo, true); + + fixture.detectChanges(); + expect(Math.round(slider.value as number)).toBe(60); + }); + + it('should not set value if value is nullish but not zero', () => { + spyOn(slider as any, 'isNullishButNotZero').and.returnValue(true); + const setValueSpy = spyOn(slider, 'setValue'); + const positionHandlersAndUpdateTrackSpy = spyOn(slider as any, 'positionHandlersAndUpdateTrack'); + + slider.writeValue(null); + fixture.detectChanges(); + + expect(setValueSpy).not.toHaveBeenCalled(); + expect(positionHandlersAndUpdateTrackSpy).not.toHaveBeenCalled(); + + slider.writeValue(undefined); + fixture.detectChanges(); + + expect(setValueSpy).not.toHaveBeenCalled(); + expect(positionHandlersAndUpdateTrackSpy).not.toHaveBeenCalled(); + }); + + it('should set value and update track when value is not nullish and not zero', () => { + spyOn(slider as any, 'isNullishButNotZero').and.returnValue(false); + const setValueSpy = spyOn(slider, 'setValue'); + const positionHandlersAndUpdateTrackSpy = spyOn(slider as any, 'positionHandlersAndUpdateTrack'); + + const value = 10; + slider.writeValue(value); + fixture.detectChanges(); + + expect(setValueSpy).toHaveBeenCalledWith(value, false); + expect(positionHandlersAndUpdateTrackSpy).toHaveBeenCalled(); + }); + + it('should normalize value by step', () => { + spyOn(slider as any, 'isNullishButNotZero').and.returnValue(false); + const normalizeByStepSpy = spyOn(slider as any, 'normalizeByStep'); + + const value = 10; + slider.writeValue(value); + fixture.detectChanges(); + + expect(normalizeByStepSpy).toHaveBeenCalledWith(value); + }); + + it('should return true if value is null or undefined', () => { + expect((slider as any).isNullishButNotZero(null)).toBe(true); + expect((slider as any).isNullishButNotZero(undefined)).toBe(true); + }); + + it('should return false if value is zero', () => { + expect((slider as any).isNullishButNotZero(0)).toBe(false); + }); + + it('should return false if value is not nullish and not zero', () => { + expect((slider as any).isNullishButNotZero(10)).toBe(false); + }); + }); + + describe('Slider: with set min and max value', () => { + let fixture: ComponentFixture; + let sliderInstance: IgxSliderComponent; + + beforeEach(() => { + fixture = TestBed.createComponent(SliderMinMaxComponent); + sliderInstance = fixture.componentInstance.slider; + fixture.detectChanges(); + }); + + it('Value should remain to the max one if it exceeds.', () => { + let expectedVal = 150; + let expectedMax = 300; + + expect(sliderInstance.value).toEqual(expectedVal); + expect(sliderInstance.maxValue).toEqual(expectedMax); + + expectedVal = 250; + expectedMax = 200; + sliderInstance.maxValue = expectedMax; + sliderInstance.value = expectedVal; + fixture.detectChanges(); + + expect(sliderInstance.value).not.toEqual(expectedVal); + expect(sliderInstance.value).toEqual(expectedMax); + expect(sliderInstance.maxValue).toEqual(expectedMax); + }); + + it('continuous(smooth) sliding should be allowed', async() => { + sliderInstance.continuous = true; + sliderInstance.thumbLabelVisibilityDuration = 10; + fixture.detectChanges(); + + expect(sliderInstance.continuous).toBe(true); + expect(sliderInstance.value).toBe(150); + const thumbEl = fixture.debugElement.query(By.css(THUMB_TAG)).nativeElement; + const { x: sliderX, width: sliderWidth } = thumbEl.getBoundingClientRect(); + const startX = sliderX + sliderWidth / 2; + + thumbEl.dispatchEvent(new Event('focus')); + fixture.detectChanges(); + + (sliderInstance as any).onPointerDown(new PointerEvent('pointerdown', { pointerId: 1, clientX: startX })); + fixture.detectChanges(); + + (sliderInstance as any).onPointerMove(new PointerEvent('pointermove', { pointerId: 1, clientX: startX + 150 })); + fixture.detectChanges(); + await wait(); + + const activeThumb = fixture.debugElement.query(By.css(THUMB_TO_PRESSED_CLASS)); + expect(activeThumb).not.toBeNull(); + expect(sliderInstance.value).toBeGreaterThan(sliderInstance.minValue); + + (sliderInstance as any).onPointerMove(new PointerEvent('pointermove', { pointerId: 1, clientX: startX })); + fixture.detectChanges(); + await wait(); + + expect(sliderInstance.value).toEqual(sliderInstance.minValue); + }); + + it('should not move thumb slider and value should remain the same when slider is disabled', async() => { + sliderInstance.disabled = true; + fixture.detectChanges(); + + const thumbEl = fixture.debugElement.query(By.css(THUMB_TAG)).nativeElement; + const { x: sliderX, width: sliderWidth } = thumbEl.getBoundingClientRect(); + const startX = sliderX + sliderWidth / 2; + + thumbEl.dispatchEvent(new Event('focus')); + fixture.detectChanges(); + + (sliderInstance as any).onPointerDown(new PointerEvent('pointerdown', { pointerId: 1, clientX: startX })); + fixture.detectChanges(); + + (sliderInstance as any).onPointerMove(new PointerEvent('pointermove', { pointerId: 1, clientX: startX + 150 })); + fixture.detectChanges(); + await wait(); + + expect(sliderInstance.value).toBe(sliderInstance.minValue); + }); + }); + + describe('RANGE slider Base tests', () => { + let fixture: ComponentFixture; + let slider: IgxSliderComponent; + + beforeEach(() => { + fixture = TestBed.createComponent(SliderInitializeTestComponent); + slider = fixture.componentInstance.slider; + slider.type = IgxSliderType.RANGE; + fixture.detectChanges(); + }); + + it(`should have lower and upper value equal to lower and upper bound when lower and upper values are not set`, () => { + expect((slider.value as IRangeSliderValue).lower).toBe(slider.lowerBound); + expect((slider.value as IRangeSliderValue).upper).toBe(slider.upperBound); + }); + + it('continuous(smooth) sliding should be allowed', async() => { + slider.continuous = true; + fixture.detectChanges(); + + const fromThumb = fixture.debugElement.query(By.css(THUMB_FROM_CLASS)).nativeElement; + const { x: sliderX, width: sliderWidth } = fromThumb.getBoundingClientRect(); + const startX = sliderX + sliderWidth / 2; + + fromThumb.dispatchEvent(new Event('focus')); + fixture.detectChanges(); + + (slider as any).onPointerDown(new PointerEvent('pointerdown', { pointerId: 1, clientX: startX })); + fixture.detectChanges(); + await wait(); + + (slider as any).onPointerMove(new PointerEvent('pointermove', { pointerId: 1, clientX: startX + 150 })); + fixture.detectChanges(); + await wait(); + + expect((slider.value as any).lower).toBeGreaterThan(slider.minValue); + expect((slider.value as any).upper).toEqual(slider.maxValue); + }); + + // K.D. Removing this functionality because of 0 benefit and lots of issues. + xit('should switch from lower to upper thumb and vice versa when the lower value is equal to the upper one', () => { + slider.value = { + lower: 60, + upper: 60 + }; + + fixture.detectChanges(); + + const fromThumb = fixture.nativeElement.querySelector(THUMB_FROM_CLASS); + fromThumb.focus(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', fromThumb, true); + fixture.detectChanges(); + + expect(slider.value.lower).toBe(60); + expect(slider.value.upper).toBe(60); + expect(document.activeElement).toBe(fixture.nativeElement.querySelector(THUMB_TO_CLASS)); + + const thumbTo = fixture.nativeElement.querySelector(THUMB_TO_CLASS); + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', thumbTo, true); + fixture.detectChanges(); + + expect(slider.value.lower).toBe(60); + expect(slider.value.upper).toBe(61); + expect(document.activeElement).toBe(fixture.nativeElement.querySelector(THUMB_TO_CLASS)); + + fromThumb.focus(); + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', fromThumb, true); + fixture.detectChanges(); + + expect(slider.value.lower).toBe(61); + expect(slider.value.upper).toBe(61); + expect(document.activeElement).toBe(fixture.nativeElement.querySelector(THUMB_TO_CLASS)); + + thumbTo.focus(); + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', thumbTo, true); + fixture.detectChanges(); + + expect(slider.value.lower).toBe(61); + expect(slider.value.upper).toBe(61); + expect(document.activeElement).toBe(fixture.nativeElement.querySelector(THUMB_FROM_CLASS)); + }); + + it('should not change value if different key from arrows is pressed and slider is RANGE', () => { + slider.value = { + lower: 50, + upper: 60 + }; + fixture.detectChanges(); + + const toThumb = fixture.nativeElement.querySelector(THUMB_TO_CLASS); + toThumb.focus(); + UIInteractions.triggerKeyDownEvtUponElem('A', toThumb, true); + fixture.detectChanges(); + + expect(slider.value.lower).toBe(50); + expect(slider.value.upper).toBe(60); + expect(document.activeElement).toBe(fixture.nativeElement.querySelector(THUMB_TO_CLASS)); + }); + + it('should increment lower value when lower thumb is focused if right arrow is pressed and slider is RANGE', () => { + slider.value = { + lower: 50, + upper: 60 + }; + fixture.detectChanges(); + + const fromThumb = fixture.nativeElement.querySelector(THUMB_FROM_CLASS); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', fromThumb, true); + fixture.detectChanges(); + + expect(slider.value.lower).toBe(51); + expect(slider.value.upper).toBe(60); + }); + + it('should increment upper value when upper thumb is focused if right arrow is pressed and slider is RANGE', () => { + slider.value = { + lower: 50, + upper: 60 + }; + fixture.detectChanges(); + + const toThumb = fixture.nativeElement.querySelector(THUMB_TO_CLASS); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', toThumb, true); + fixture.detectChanges(); + expect(slider.value.lower).toBe(50); + expect(slider.value.upper).toBe(61); + }); + + it('should not increment upper value when slider is disabled', () => { + slider.disabled = true; + slider.value = { + lower: 50, + upper: 60 + }; + fixture.detectChanges(); + + const toThumb = fixture.nativeElement.querySelector(THUMB_TO_CLASS); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', toThumb, true); + fixture.detectChanges(); + expect(slider.value.lower).toBe(50); + expect(slider.value.upper).toBe(60); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', toThumb, true); + fixture.detectChanges(); + expect(slider.value.lower).toBe(50); + expect(slider.value.upper).toBe(60); + + const fromThumb = fixture.nativeElement.querySelector(THUMB_FROM_CLASS); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', fromThumb, true); + fixture.detectChanges(); + expect(slider.value.lower).toBe(50); + expect(slider.value.upper).toBe(60); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', fromThumb, true); + fixture.detectChanges(); + expect(slider.value.lower).toBe(50); + expect(slider.value.upper).toBe(60); + }); + + it('should reach max value with upper thumb in RANGE mode with decimal steps', () => { + slider.minValue = 0; + slider.maxValue = 10; + slider.step = 0.1; + slider.type = IgxSliderType.RANGE; + slider.value = { lower: 0, upper: 10 }; + fixture.detectChanges(); + + const toThumb = fixture.nativeElement.querySelector(THUMB_TO_CLASS); + toThumb.focus(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', toThumb, true); + fixture.detectChanges(); + + expect((slider.value as IRangeSliderValue).upper).toBe(10); + }); + }); + + describe('Slider - List View', () => { + let fixture: ComponentFixture; + let slider: IgxSliderComponent; + + beforeEach(() => { + fixture = TestBed.createComponent(SliderWithLabelsComponent); + slider = fixture.componentInstance.slider; + fixture.detectChanges(); + }); + + it('labels should show/hide on pointer up/down', async () => { + const sliderEl = fixture.debugElement.query(By.css(SLIDER_CLASS)); + sliderEl.triggerEventHandler('pointerdown', { pointerId: 1, preventDefault: () => {}}); + await wait(50); + fixture.detectChanges(); + + expect(sliderEl).toBeDefined(); + let activeLabel = fixture.debugElement.query(By.css('.igx-slider-thumb-label-to--active')); + expect(activeLabel).not.toBeNull(); + + sliderEl.triggerEventHandler('pointerup', {pointerId: 1, preventDefault: () => {}}); + await wait(slider.thumbLabelVisibilityDuration + 10); + fixture.detectChanges(); + + activeLabel = fixture.debugElement.query(By.css('.igx-slider-thumb-label-to--active')); + expect(activeLabel).toBeNull(); + }); + + it('should be able to change thumbLabelVisibilityDuration', async () => { + const sliderEl = fixture.debugElement.query(By.css(SLIDER_CLASS)); + slider.thumbLabelVisibilityDuration = 1000; + sliderEl.triggerEventHandler('pointerdown', {pointerId: 1, preventDefault: () => {}}); + await wait(50); + fixture.detectChanges(); + + expect(sliderEl).toBeDefined(); + let activeLabel = fixture.debugElement.query(By.css('.igx-slider-thumb-label-to--active')); + expect(activeLabel).not.toBeNull(); + + sliderEl.triggerEventHandler('pointerup', {pointerId: 1, preventDefault: () => {}}); + await wait(750); + fixture.detectChanges(); + + activeLabel = fixture.debugElement.query(By.css('.igx-slider-thumb-label-to--active')); + expect(activeLabel).not.toBeNull(); + + await wait(300); + fixture.detectChanges(); + activeLabel = fixture.debugElement.query(By.css('.igx-slider-thumb-label-to--active')); + expect(activeLabel).toBeNull(); + }); + + it('rendering of the slider should corresponds to the set labels', () => { + const thumb = fixture.debugElement.query(By.css(THUMB_TO_CLASS)); + + expect(slider).toBeDefined(); + expect(thumb).toBeDefined(); + expect(slider.upperLabel).toEqual('Winter'); + expect(slider.lowerLabel).toEqual('Winter'); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', thumb.nativeElement, true); + fixture.detectChanges(); + + expect(slider.upperLabel).toEqual('Spring'); + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', thumb.nativeElement, true); + fixture.detectChanges(); + + expect(slider.upperLabel).toEqual('Summer'); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', thumb.nativeElement, true); + fixture.detectChanges(); + + expect(slider.upperLabel).toEqual('Autumn'); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', thumb.nativeElement, true); + fixture.detectChanges(); + + expect(slider.upperLabel).toEqual('Autumn'); + }); + + it('when labels are enabled should not be able to set min/max and step', () => { + slider.step = 5; + fixture.detectChanges(); + + expect(slider.step).toBe(1); + + slider.minValue = 3; + fixture.detectChanges(); + + expect(slider.minValue).toBe(0); + + slider.maxValue = 90; + expect(slider.maxValue).toBe(3); + }); + + it('tick marks(steps) should be shown equally spread based on labels length', () => { + const ticks = fixture.nativeElement.querySelector('.igx-slider__track-steps'); + const sliderWidth = parseInt(fixture.nativeElement.querySelector('igx-slider').clientWidth, 10); + fixture.detectChanges(); + + expect(slider.type).toBe(IgxSliderType.SLIDER); + expect(ticks).toBeDefined(); + expect(slider.stepDistance).toEqual(sliderWidth / 3); + }); + + it('Upper bounds should be applied correctly', () => { + const thumb = fixture.debugElement.query(By.css(THUMB_TO_CLASS)); + expect(slider.value).toBe(0); + + slider.lowerBound = 1; + slider.upperBound = 2; + fixture.detectChanges(); + + expect(slider.upperBound).toBe(2); + expect(slider.lowerBound).toBe(1); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', thumb.nativeElement, true); + fixture.detectChanges(); + + expect(slider.upperLabel).toEqual('Summer'); + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', thumb.nativeElement, true); + fixture.detectChanges(); + + expect(slider.upperLabel).toEqual('Summer'); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', thumb.nativeElement, true); + fixture.detectChanges(); + + expect(slider.upperLabel).toEqual('Spring'); + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', thumb.nativeElement, true); + fixture.detectChanges(); + + expect(slider.upperLabel).toEqual('Spring'); + }); + + it('when you try to set invalid value for lower/upper bound should not reset', () => { + slider.lowerBound = 1; + slider.upperBound = 2; + fixture.detectChanges(); + + slider.upperBound = 4; + fixture.detectChanges(); + expect(slider.upperBound).toBe(2); + slider.lowerBound = -1; + fixture.detectChanges(); + expect(slider.lowerBound).toBe(1); + + slider.lowerBound = 3; + fixture.detectChanges(); + expect(slider.upperBound).toBe(2); + expect(slider.lowerBound).toBe(1); + + slider.upperBound = 0; + fixture.detectChanges(); + expect(slider.upperBound).toBe(2); + expect(slider.lowerBound).toBe(1); + }); + + it('Label view should not be enabled if labels array is set uncorrectly', () => { + expect(slider.labelsViewEnabled).toBe(true); + + slider.labels = ['Winter']; + fixture.detectChanges(); + + expect(slider.labelsViewEnabled).toBe(false); + + slider.labels = []; + fixture.detectChanges(); + + expect(slider.labelsViewEnabled).toBe(false); + + slider.labels = ['Winter', 'Summer']; + fixture.detectChanges(); + + expect(slider.labelsViewEnabled).toBe(true); + + slider.labels = undefined; + fixture.detectChanges(); + + expect(slider.labelsViewEnabled).toBe(false); + + slider.labels = null; + fixture.detectChanges(); + + expect(slider.labelsViewEnabled).toBe(false); + }); + + it('should be able to track the value changes per every slide action through an event emitter', () => { + const thumb = fixture.debugElement.query(By.css(THUMB_TO_CLASS)); + + expect(slider).toBeDefined(); + expect(slider.upperLabel).toEqual('Winter'); + const valueChangeSpy = spyOn(slider.valueChange, 'emit').and.callThrough(); + const upperValueChangeSpy = spyOn(slider.upperValueChange, 'emit').and.callThrough(); + const lowerValueChangeSpy = spyOn(slider.lowerValueChange, 'emit').and.callThrough(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', thumb.nativeElement, true); + fixture.detectChanges(); + expect(valueChangeSpy).not.toHaveBeenCalled(); + expect(upperValueChangeSpy).not.toHaveBeenCalled(); + expect(lowerValueChangeSpy).not.toHaveBeenCalled(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', thumb.nativeElement, true); + fixture.detectChanges(); + expect(valueChangeSpy).toHaveBeenCalledTimes(1); + expect(valueChangeSpy).toHaveBeenCalledWith({oldValue: 0, value: 1}); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', thumb.nativeElement, true); + fixture.detectChanges(); + expect(valueChangeSpy).toHaveBeenCalledTimes(2); + expect(valueChangeSpy).toHaveBeenCalledWith({oldValue: 1, value: 2}); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', thumb.nativeElement, true); + fixture.detectChanges(); + expect(valueChangeSpy).toHaveBeenCalledTimes(3); + expect(valueChangeSpy).toHaveBeenCalledWith({oldValue: 2, value: 3}); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', thumb.nativeElement, true); + fixture.detectChanges(); + expect(valueChangeSpy).toHaveBeenCalledTimes(3); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', thumb.nativeElement, true); + fixture.detectChanges(); + expect(valueChangeSpy).toHaveBeenCalledTimes(4); + expect(valueChangeSpy).toHaveBeenCalledWith({oldValue: 3, value: 2}); + expect(upperValueChangeSpy).not.toHaveBeenCalled(); + expect(lowerValueChangeSpy).not.toHaveBeenCalled(); + }); + + it('Dynamically change the type of the slider SLIDER, RANGE, LABEL', () => { + expect(slider.type).toBe(IgxSliderType.SLIDER); + expect(slider.labelsViewEnabled).toBe(true); + + slider.labels = []; + fixture.detectChanges(); + + expect(slider.type).toBe(IgxSliderType.SLIDER); + expect(slider.labelsViewEnabled).toBe(false); + + let fromThumb = fixture.nativeElement.querySelector(THUMB_FROM_CLASS); + let toThumb = fixture.nativeElement.querySelector(THUMB_TO_CLASS); + + expect(slider.type).toBe(IgxSliderType.SLIDER); + expect(toThumb).toBeDefined(); + expect(fromThumb).toBeFalsy(); + expect(slider.upperBound).toBe(slider.maxValue); + expect(slider.lowerBound).toBe(slider.minValue); + + slider.type = IgxSliderType.RANGE; + fixture.detectChanges(); + + fromThumb = fixture.nativeElement.querySelector(THUMB_FROM_CLASS); + toThumb = fixture.nativeElement.querySelector(THUMB_TO_CLASS); + + expect(toThumb).toBeDefined(); + expect(fromThumb).toBeDefined(); + expect(slider.upperBound).toBe(100); + expect(slider.lowerBound).toBe(0); + }); + }); + + describe('Slider type: Range - List View', () => { + let fixture: ComponentFixture; + let slider: IgxSliderComponent; + + beforeEach(() => { + fixture = TestBed.createComponent(RangeSliderWithLabelsComponent); + slider = fixture.componentInstance.slider; + fixture.detectChanges(); + }); + + it('labels should show/hide on pointer up/down', async () => { + const sliderEl = fixture.debugElement.query(By.css(SLIDER_CLASS)); + fixture.detectChanges(); + + sliderEl.triggerEventHandler('pointerdown', { pointerId: 1, preventDefault: () => {}}); + await wait(100); + fixture.detectChanges(); + + expect(sliderEl).toBeDefined(); + let activeLabel = fixture.debugElement.query(By.css('.igx-slider-thumb-label-from--active')); + expect(activeLabel).not.toBeNull(); + + sliderEl.triggerEventHandler('pointerup', { pointerId: 1, preventDefault: () => {}}); + await wait(slider.thumbLabelVisibilityDuration + 10); + fixture.detectChanges(); + + activeLabel = fixture.debugElement.query(By.css('.igx-slider-thumb-label-from--active')); + expect(activeLabel).toBeNull(); + }); + + it('should be able to change thumbLabelVisibilityDuration', async () => { + const sliderEl = fixture.debugElement.query(By.css(SLIDER_CLASS)).nativeElement; + slider.thumbLabelVisibilityDuration = 1000; + sliderEl.dispatchEvent( new PointerEvent('pointerdown', { pointerId: 1 })); + await wait(50); + fixture.detectChanges(); + + expect(sliderEl).toBeDefined(); + let activeLabel = fixture.debugElement.query(By.css('.igx-slider-thumb-label-from--active')); + expect(activeLabel).not.toBeNull(); + + sliderEl.dispatchEvent( new PointerEvent('pointerup', { pointerId: 1})); + await wait(750); + fixture.detectChanges(); + + activeLabel = fixture.debugElement.query(By.css('.igx-slider-thumb-label-from--active')); + expect(activeLabel).not.toBeNull(); + + await wait(300); + fixture.detectChanges(); + activeLabel = fixture.debugElement.query(By.css('.igx-slider-thumb-label-from--active')); + expect(activeLabel).toBeNull(); + }); + + it('rendering of the slider should corresponds to the set labels', () => { + const fromThumb = fixture.debugElement.query(By.css(THUMB_FROM_CLASS)).nativeElement; + const toThumb = fixture.debugElement.query(By.css(THUMB_TO_CLASS)).nativeElement; + + expect(fromThumb).toBeDefined(); + expect(toThumb).toBeDefined(); + + expect(slider.lowerLabel).toEqual('Monday'); + expect(slider.upperLabel).toEqual('Sunday'); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', fromThumb, true); + fixture.detectChanges(); + expect(slider.value).toEqual({lower: 1, upper: 6}); + expect(slider.lowerLabel).toEqual('Tuesday'); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', fromThumb, true); + fixture.detectChanges(); + expect(slider.value).toEqual({lower: 2, upper: 6}); + expect(slider.lowerLabel).toEqual('Wednesday'); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', fromThumb, true); + fixture.detectChanges(); + expect(slider.value).toEqual({lower: 3, upper: 6}); + expect(slider.lowerLabel).toEqual('Thursday'); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', fromThumb, true); + fixture.detectChanges(); + expect(slider.value).toEqual({lower: 4, upper: 6}); + expect(slider.lowerLabel).toEqual('Friday'); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', toThumb, true); + fixture.detectChanges(); + expect(slider.value).toEqual({lower: 4, upper: 5}); + expect(slider.upperLabel).toEqual('Saturday'); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', toThumb, true); + fixture.detectChanges(); + expect(slider.value).toEqual({lower: 4, upper: 4}); + expect(slider.upperLabel).toEqual('Friday'); + }); + + it('when labels are enabled should not be able to set min/max and step', () => { + expect(slider.type).toBe(IgxSliderType.RANGE); + expect(slider.labelsViewEnabled).toBe(true); + slider.step = 5; + fixture.detectChanges(); + + expect(slider.step).toBe(1); + + slider.minValue = 3; + fixture.detectChanges(); + + expect(slider.minValue).toBe(0); + + slider.maxValue = 90; + expect(slider.maxValue).toBe(6); + }); + + it('tick marks(steps) should be shown equally spread based on labels length', () => { + const ticks = fixture.nativeElement.querySelector('.igx-slider__track-steps'); + const sliderWidth = parseInt(fixture.nativeElement.querySelector('igx-slider').clientWidth, 10); + fixture.detectChanges(); + + expect(slider.type).toBe(IgxSliderType.RANGE); + expect(ticks).not.toBeNull(); + expect(slider.stepDistance).toEqual(sliderWidth / 6); + }); + + it('upper bounds should be applied correctly', () => { + const toThumb = fixture.debugElement.query(By.css(THUMB_TO_CLASS)); + const fromThumb = fixture.debugElement.query(By.css(THUMB_FROM_CLASS)); + expect(slider.value).toEqual({lower: 0, upper: 6}); + + slider.lowerBound = 1; + slider.upperBound = 4; + fixture.detectChanges(); + + expect(slider.upperBound).toBe(4); + expect(slider.lowerBound).toBe(1); + + slider.value = {lower: -1, upper: 3}; + fixture.detectChanges(); + + expect(slider.value).toEqual({lower: 1, upper: 3}); + + slider.value = {lower: 1, upper: 10}; + fixture.detectChanges(); + + expect(slider.value).toEqual({lower: 1, upper: 4}); + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', toThumb.nativeElement, true); + fixture.detectChanges(); + + expect(slider.upperLabel).toEqual('Friday'); + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', fromThumb.nativeElement, true); + fixture.detectChanges(); + + expect(slider.lowerLabel).toEqual('Wednesday'); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', fromThumb.nativeElement, true); + fixture.detectChanges(); + + expect(slider.lowerLabel).toEqual('Tuesday'); + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', fromThumb.nativeElement, true); + fixture.detectChanges(); + + expect(slider.lowerLabel).toEqual('Tuesday'); + }); + + it ('when you try to set invalid value for upper/lower value they should not reset', () => { + slider.lowerBound = 1; + slider.upperBound = 4; + fixture.detectChanges(); + + slider.upperBound = 7; + fixture.detectChanges(); + expect(slider.upperBound).toBe(4); + slider.lowerBound = -1; + fixture.detectChanges(); + expect(slider.lowerBound).toBe(1); + + slider.lowerBound = 9; + fixture.detectChanges(); + expect(slider.upperBound).toBe(4); + expect(slider.lowerBound).toBe(1); + + slider.upperBound = 0; + fixture.detectChanges(); + expect(slider.upperBound).toBe(4); + expect(slider.lowerBound).toBe(1); + }); + + it('label view should not be enabled if labels array is set uncorrectly', async () => { + expect(slider.labelsViewEnabled).toBe(true); + + slider.labels = ['Winter']; + fixture.detectChanges(); + + expect(slider.labelsViewEnabled).toBe(false); + + slider.labels = []; + fixture.detectChanges(); + + expect(slider.labelsViewEnabled).toBe(false); + + slider.labels = ['Winter', 'Summer']; + fixture.detectChanges(); + + expect(slider.labelsViewEnabled).toBe(true); + + slider.labels = undefined; + fixture.detectChanges(); + + expect(slider.labelsViewEnabled).toBe(false); + + slider.labels = null; + fixture.detectChanges(); + + expect(slider.labelsViewEnabled).toBe(false); + }); + + it('should be able to track the value changes per every slide action through an event emitter', () => { + const fromThumb = fixture.debugElement.query(By.css(THUMB_FROM_CLASS)); + const toThumb = fixture.debugElement.query(By.css(THUMB_TO_CLASS)); + + expect(slider).toBeDefined(); + expect(fromThumb).toBeDefined(); + expect(slider.upperLabel).toEqual('Sunday'); + expect(slider.lowerLabel).toEqual('Monday'); + const valueChangeSpy = spyOn(slider.valueChange, 'emit').and.callThrough(); + const lowerValueChangeSpy = spyOn(slider.lowerValueChange, 'emit').and.callThrough(); + const upperValueChangeSpy = spyOn(slider.upperValueChange, 'emit').and.callThrough(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', fromThumb.nativeElement, true); + fixture.detectChanges(); + expect(valueChangeSpy).toHaveBeenCalledTimes(1); + expect(lowerValueChangeSpy).toHaveBeenCalledTimes(1); + expect(valueChangeSpy).toHaveBeenCalledWith({oldValue: {lower: 0, upper: 6}, value: {lower: 1, upper: 6}}); + expect(lowerValueChangeSpy).toHaveBeenCalledWith(1); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', fromThumb.nativeElement, true); + fixture.detectChanges(); + expect(valueChangeSpy).toHaveBeenCalledTimes(2); + expect(lowerValueChangeSpy).toHaveBeenCalledTimes(2); + expect(valueChangeSpy).toHaveBeenCalledWith({oldValue: {lower: 1, upper: 6}, value: {lower: 2, upper: 6}}); + expect(lowerValueChangeSpy).toHaveBeenCalledWith(2); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', toThumb.nativeElement, true); + fixture.detectChanges(); + expect(valueChangeSpy).toHaveBeenCalledTimes(2); + expect(upperValueChangeSpy).not.toHaveBeenCalled(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', toThumb.nativeElement, true); + fixture.detectChanges(); + expect(valueChangeSpy).toHaveBeenCalledTimes(3); + expect(upperValueChangeSpy).toHaveBeenCalledOnceWith(5); + expect(valueChangeSpy).toHaveBeenCalledWith({oldValue: {lower: 2, upper: 6}, value: {lower: 2, upper: 5}}); + }); + + it('dynamically change the type of the slider SLIDER, RANGE, LABEL', () => { + expect(slider.type).toBe(IgxSliderType.RANGE); + expect(slider.labelsViewEnabled).toBe(true); + + slider.labels = []; + fixture.detectChanges(); + + expect(slider.type).toBe(IgxSliderType.RANGE); + expect(slider.labelsViewEnabled).toBe(false); + + let fromThumb = fixture.nativeElement.querySelector(THUMB_FROM_CLASS); + let toThumb = fixture.nativeElement.querySelector(THUMB_TO_CLASS); + + expect(toThumb).toBeDefined(); + expect(fromThumb).toBeDefined(); + expect(slider.upperBound).toBe(slider.maxValue); + expect(slider.lowerBound).toBe(slider.minValue); + + slider.type = IgxSliderType.SLIDER; + fixture.detectChanges(); + + fromThumb = fixture.nativeElement.querySelector(THUMB_FROM_CLASS); + toThumb = fixture.nativeElement.querySelector(THUMB_TO_CLASS); + expect(slider.type).toBe(IgxSliderType.SLIDER); + expect(toThumb).toBeDefined(); + expect(fromThumb).toBeFalsy(); + expect(slider.upperBound).toBe(slider.maxValue); + expect(slider.lowerBound).toBe(slider.minValue); + }); + }); + + describe('General Tests', () => { + it('custom templates for the lower/upper thumb labels should be allowed', () => { + const fixture = TestBed.createComponent(RangeSliderWithCustomTemplateComponent); + const slider = fixture.componentInstance.slider; + fixture.detectChanges(); + + const fromThumb = fixture.debugElement.query(By.css(THUMB_FROM_CLASS)); + const toThumb = fixture.debugElement.query(By.css(THUMB_TO_CLASS)); + + expect(toThumb).toBeDefined(); + expect(fromThumb).toBeDefined(); + + let customTemplates = fixture.nativeElement.querySelectorAll('span.custom'); + + expect(customTemplates).toBeDefined(); + expect(customTemplates.length).toBe(2); + + slider.type = IgxSliderType.SLIDER; + fixture.detectChanges(); + customTemplates = fixture.nativeElement.querySelectorAll('span.custom'); + + expect(customTemplates.length).toBe(1); + + }); + + it('should draw tick marks', () => { + const fixture = TestBed.createComponent(SliderInitializeTestComponent); + const ticks = fixture.nativeElement.querySelector('.igx-slider__track-steps > svg > line'); + fixture.detectChanges(); + + // Slider steps <= 1. No marks should be drawn; + expect(ticks.style.visibility).toEqual('hidden'); + + // Slider steps > 1. Should draw tick marks; + fixture.componentInstance.slider.step = 10; + fixture.detectChanges(); + + expect(ticks.style.visibility).toEqual('visible'); + }); + + it('should hide tick marks', () => { + const fixture = TestBed.createComponent(SliderInitializeTestComponent); + fixture.detectChanges(); + + const ticks = fixture.nativeElement.querySelector('.igx-slider__track-steps > svg > line'); + const slider = fixture.componentInstance.slider; + + expect(ticks.style.visibility).toEqual('hidden'); + + slider.step = 10; + fixture.detectChanges(); + + expect(ticks.style.visibility).toEqual('visible'); + + slider.continuous = true; + fixture.detectChanges(); + + expect(ticks.style.visibility).toEqual('hidden'); + }); + + it(`When setting min and max value for range slider, + max value should be applied firstly, due correct appliement of the min value.`, () => { + const fix = TestBed.createComponent(SliderMinMaxComponent); + fix.detectChanges(); + + const slider = fix.componentInstance.slider; + slider.type = IgxSliderType.RANGE; + + fix.detectChanges(); + + expect(slider.minValue).toEqual(fix.componentInstance.minValue); + expect(slider.maxValue).toEqual(fix.componentInstance.maxValue); + }); + + it('should track min/maxValue if lower/upperBound are undefined (issue #920)', () => { + const fixture = TestBed.createComponent(SliderTestComponent); + fixture.detectChanges(); + + const slider = fixture.componentInstance.slider; + + expect(slider.minValue).toBe(0); + expect(slider.maxValue).toBe(10); + expect(slider.lowerBound).toBe(0); + expect(slider.upperBound).toBe(10); + + fixture.componentInstance.changeMinValue(5); + fixture.detectChanges(); + fixture.componentInstance.changeMaxValue(8); + fixture.detectChanges(); + + expect(slider.minValue).toBe(5); + expect(slider.maxValue).toBe(8); + expect(slider.lowerBound).toBe(5); + expect(slider.upperBound).toBe(8); + }); + + it('should track min/maxValue and invalidate the value if lower/upperBound are undefined (issue #920)', () => { + const fixture = TestBed.createComponent(SliderTestComponent); + fixture.detectChanges(); + + const slider = fixture.componentInstance.slider; + + fixture.componentInstance.slider.value = 5; + fixture.detectChanges(); + + fixture.componentInstance.changeMinValue(6); + fixture.detectChanges(); + + expect(slider.value).toBe(6); + expect(slider.minValue).toBe(6); + expect(slider.maxValue).toBe(10); + expect(slider.lowerBound).toBe(6); + expect(slider.upperBound).toBe(10); + + fixture.componentInstance.slider.value = 9; + fixture.detectChanges(); + expect(slider.value).toBe(9); + + fixture.componentInstance.changeMaxValue(8); + fixture.detectChanges(); + + expect(slider.value).toBe(8); + expect(slider.minValue).toBe(6); + expect(slider.maxValue).toBe(8); + expect(slider.lowerBound).toBe(6); + expect(slider.upperBound).toBe(8); + }); + + it('should stop tracking min/maxValue if lower/upperBound is set from outside (issue #920)', () => { + const fixture = TestBed.createComponent(SliderTestComponent); + fixture.detectChanges(); + + const slider = fixture.componentInstance.slider; + + slider.lowerBound = 3; + slider.upperBound = 8; + fixture.detectChanges(); + + fixture.componentInstance.changeMinValue(2); + fixture.detectChanges(); + fixture.componentInstance.changeMaxValue(9); + fixture.detectChanges(); + + expect(slider.minValue).toBe(2); + expect(slider.maxValue).toBe(9); + expect(slider.lowerBound).toBe(3); + expect(slider.upperBound).toBe(8); + }); + + it('should track min/maxValue if lower/upperBound are undefined - range slider (issue #920)', () => { + const fixture = TestBed.createComponent(SliderTestComponent); + fixture.detectChanges(); + + const slider = fixture.componentInstance.slider; + fixture.componentInstance.type = IgxSliderType.RANGE; + fixture.detectChanges(); + + expect(slider.minValue).toBe(0); + expect(slider.maxValue).toBe(10); + expect(slider.lowerBound).toBe(0); + expect(slider.upperBound).toBe(10); + + fixture.componentInstance.changeMinValue(5); + fixture.detectChanges(); + fixture.componentInstance.changeMaxValue(8); + fixture.detectChanges(); + + expect(slider.minValue).toBe(5); + expect(slider.maxValue).toBe(8); + expect(slider.lowerBound).toBe(5); + expect(slider.upperBound).toBe(8); + }); + + it('should track min/maxValue and invalidate the value if lower/upperBound are undefined - range slider (issue #920)', () => { + const fixture = TestBed.createComponent(SliderTestComponent); + fixture.detectChanges(); + + const slider = fixture.componentInstance.slider; + fixture.componentInstance.type = IgxSliderType.RANGE; + fixture.detectChanges(); + + slider.value = { + lower: 2, + upper: 9 + }; + + fixture.componentInstance.changeMinValue(5); + fixture.componentInstance.changeMaxValue(7); + fixture.detectChanges(); + + expect(slider.minValue).toBe(5); + expect(slider.maxValue).toBe(7); + expect(slider.lowerBound).toBe(5); + expect(slider.upperBound).toBe(7); + expect(slider.value.lower).toBe(5); + expect(slider.value.upper).toBe(7); + }); + + it('Lower and upper bounds should not exceed min and max values', () => { + const fix = TestBed.createComponent(SliderTestComponent); + fix.detectChanges(); + + const componentInst = fix.componentInstance; + const slider = componentInst.slider; + const expectedMinVal = 0; + const expectedMaxVal = 10; + + expect(slider.minValue).toEqual(expectedMinVal); + expect(slider.maxValue).toEqual(expectedMaxVal); + + let expectedLowerBound = -1; + let expectedUpperBound = 11; + slider.lowerBound = expectedLowerBound; + slider.upperBound = expectedUpperBound; + + expect(slider.lowerBound).toEqual(expectedMinVal); + expect(slider.upperBound).toEqual(expectedMaxVal); + + // Setting minValue > upperBound should result in boundry updates + slider.lowerBound = 2; + slider.upperBound = 5; + slider.minValue = 6; + expectedLowerBound = 6; + expectedUpperBound = 10; + + expect(slider.lowerBound).toEqual(expectedLowerBound); + expect(slider.upperBound).toEqual(expectedUpperBound); + + // Setting maxValue < lowerBound should result in boundry updates + slider.minValue = 0; + slider.lowerBound = 5; + slider.upperBound = 9; + slider.maxValue = 4; + expectedLowerBound = 0; + expectedUpperBound = 4; + + expect(slider.lowerBound).toEqual(expectedLowerBound); + expect(slider.upperBound).toEqual(expectedUpperBound); + }); + + it('Should swap value upper and lower and should adjust according to bounds', () => { + const fix = TestBed.createComponent(SliderWithValueAdjustmentComponent); + fix.detectChanges(); + + const slider = fix.componentInstance.slider; + const value = slider.value as IRangeSliderValue; + + // Value is initially { lower: 50, upper: 20 } with slider bounds 30 to 40. Value should be swaped and adjusted accordingly + expect(value.lower).toEqual(30); + expect(value.upper).toEqual(40); + expect(slider.lowerValue).toEqual(30); + expect(slider.upperValue).toEqual(40); + }); + + it('Should emit dragFinished only when stop interacting with the slider', () => { + const fix = TestBed.createComponent(SliderTestComponent); + fix.detectChanges(); + + const instance = fix.componentInstance; + const spyOnDragFinished = spyOn(instance.slider.dragFinished, 'emit').and.callThrough(); + const sliderEl = fix.debugElement.query(By.css(SLIDER_CLASS)); + const thumbTo = fix.debugElement.query(By.css(THUMB_TO_CLASS)); + thumbTo.triggerEventHandler('focus', null); + fix.detectChanges(); + + sliderEl.triggerEventHandler('pointerdown', {pointerId: 1, clientX: 150, preventDefault: () => { }}); + fix.detectChanges(); + let currentValue = instance.slider.value; + expect(spyOnDragFinished).toHaveBeenCalledTimes(0); + expect(currentValue).toBeGreaterThan(0); + + sliderEl.triggerEventHandler('pointerdown', {pointerId: 1, clientX: 350, preventDefault: () => { }}); + fix.detectChanges(); + expect(spyOnDragFinished).toHaveBeenCalledTimes(0); + expect(instance.slider.value).toBeGreaterThan(currentValue as number); + + currentValue = instance.slider.value; + sliderEl.triggerEventHandler('pointerup', {pointerId: 1, preventDefault: () => { }}); + fix.detectChanges(); + expect(spyOnDragFinished).toHaveBeenCalledTimes(1); + expect(instance.slider.value).toEqual(currentValue); + }); + }); + + describe('igxSlider ticks', () => { + let fixture: ComponentFixture; + let slider: IgxSliderComponent; + + beforeEach(() => { + fixture = TestBed.createComponent(SliderTicksComponent); + slider = fixture.componentInstance.slider; + fixture.detectChanges(); + }); + + it('should render a specific amount of primary ticks', () => { + const ticks = fixture.debugElement.query(By.css(SLIDER_TICKS_ELEMENT)); + expect(ticks).not.toBeNull(); + + const expectedPrimary = 5; + fixture.componentInstance.primaryTicks = expectedPrimary; + fixture.detectChanges(); + + const primaryTicks = ticks.nativeElement + .querySelectorAll(SLIDER_PRIMARY_GROUP_TICKS_CLASS); + expect(primaryTicks.length).toEqual(expectedPrimary); + }); + + it('should render a specific amount of secondary ticks', () => { + const ticks = fixture.debugElement.query(By.css(SLIDER_TICKS_ELEMENT)); + expect(ticks).not.toBeNull(); + + const expectedSecondary = 5; + fixture.componentInstance.secondaryTicks = expectedSecondary; + fixture.detectChanges(); + + const secondaryTicks = ticks.nativeElement + .querySelectorAll(`${SLIDER_GROUP_TICKS_CLASS}:not(${SLIDER_PRIMARY_GROUP_TICKS_CLASS})`); + expect(secondaryTicks.length).toEqual(expectedSecondary); + }); + + it('should render secondary and primary ticks', () => { + const ticks = fixture.debugElement.query(By.css(SLIDER_TICKS_ELEMENT)); + expect(ticks).not.toBeNull(); + + const expectedPrimary = 5; + const expectedSecondary = 3; + fixture.componentInstance.primaryTicks = expectedPrimary; + fixture.componentInstance.secondaryTicks = expectedSecondary; + fixture.detectChanges(); + + const primaryTicks = ticks.nativeElement + .querySelectorAll(SLIDER_PRIMARY_GROUP_TICKS_CLASS); + expect(primaryTicks.length).toEqual(expectedPrimary); + + const secondaryTicks = ticks.nativeElement + .querySelectorAll(`${SLIDER_GROUP_TICKS_CLASS}:not(${SLIDER_PRIMARY_GROUP_TICKS_CLASS})`); + expect(secondaryTicks.length).toEqual((expectedPrimary - 1) * expectedSecondary); + }); + + it('check primary ticks length (16px)', () => { + const ticks = fixture.debugElement.query(By.css(SLIDER_TICKS_ELEMENT)); + fixture.componentInstance.primaryTicks = 5; + fixture.detectChanges(); + + const primaryTick = ticks.nativeElement.querySelectorAll(SLIDER_PRIMARY_GROUP_TICKS_CLASS)[0]; + expect(primaryTick.firstElementChild.getBoundingClientRect().height).toEqual(16); + }); + + it('check secondary ticks length (8px)', () => { + const ticks = fixture.debugElement.query(By.css(SLIDER_TICKS_ELEMENT)); + fixture.componentInstance.secondaryTicks = 5; + fixture.detectChanges(); + + const primaryTick = ticks.nativeElement.querySelectorAll(SLIDER_GROUP_TICKS_CLASS)[0]; + expect(primaryTick.firstElementChild.getBoundingClientRect().height).toEqual(8); + }); + + it('hide/show top and bottom ticks', () => { + let ticks = fixture.debugElement.nativeElement.querySelector(SLIDER_TICKS_ELEMENT); + let ticksTop = fixture.debugElement.nativeElement.querySelector(SLIDER_TICKS_TOP_ELEMENT); + + expect(ticks).not.toBeNull(); + expect(ticksTop).toBeNull(); + + fixture.componentInstance.showTicks = false; + fixture.detectChanges(); + + ticks = fixture.debugElement.nativeElement.querySelector(SLIDER_TICKS_ELEMENT); + ticksTop = fixture.debugElement.nativeElement.querySelector(SLIDER_TICKS_TOP_ELEMENT); + + expect(ticks).toBeNull(); + expect(ticksTop).toBeNull(); + + fixture.componentInstance.showTicks = true; + fixture.componentInstance.ticksOrientation = TicksOrientation.Mirror; + fixture.detectChanges(); + + ticks = fixture.debugElement.nativeElement.querySelector(SLIDER_TICKS_ELEMENT); + ticksTop = fixture.debugElement.nativeElement.querySelector(SLIDER_TICKS_TOP_ELEMENT); + expect(ticks).not.toBeNull(); + expect(ticksTop).not.toBeNull(); + + fixture.componentInstance.showTicks = false; + fixture.detectChanges(); + + ticks = fixture.debugElement.nativeElement.querySelector(SLIDER_TICKS_ELEMENT); + ticksTop = fixture.debugElement.nativeElement.querySelector(SLIDER_TICKS_TOP_ELEMENT); + expect(ticks).toBeNull(); + expect(ticksTop).toBeNull(); + }); + + it('show/hide primary tick labels', () => { + const ticks = fixture.debugElement.query(By.css(SLIDER_TICKS_ELEMENT)); + const primaryTicks = 5; + const secondaryTicks = 3; + fixture.componentInstance.primaryTicks = primaryTicks; + fixture.componentInstance.secondaryTicks = secondaryTicks; + fixture.detectChanges(); + + verifyPrimaryTicsLabelsAreHidden(ticks, false); + verifySecondaryTicsLabelsAreHidden(ticks, false); + + fixture.componentInstance.primaryTickLabels = false; + fixture.detectChanges(); + + verifyPrimaryTicsLabelsAreHidden(ticks, true); + verifySecondaryTicsLabelsAreHidden(ticks, false); + + fixture.componentInstance.primaryTickLabels = true; + fixture.detectChanges(); + + verifyPrimaryTicsLabelsAreHidden(ticks, false); + verifySecondaryTicsLabelsAreHidden(ticks, false); + }); + + + it('show/hide secondary tick labels', () => { + const ticks = fixture.debugElement.query(By.css(SLIDER_TICKS_ELEMENT)); + const primaryTicks = 5; + const secondaryTicks = 3; + fixture.componentInstance.primaryTicks = primaryTicks; + fixture.componentInstance.secondaryTicks = secondaryTicks; + fixture.detectChanges(); + + verifyPrimaryTicsLabelsAreHidden(ticks, false); + verifySecondaryTicsLabelsAreHidden(ticks, false); + + fixture.componentInstance.secondaryTickLabels = false; + fixture.detectChanges(); + + verifyPrimaryTicsLabelsAreHidden(ticks, false); + verifySecondaryTicsLabelsAreHidden(ticks, true); + + fixture.componentInstance.secondaryTickLabels = true; + fixture.detectChanges(); + verifyPrimaryTicsLabelsAreHidden(ticks, false); + verifySecondaryTicsLabelsAreHidden(ticks, false); + }); + + it('change ticks orientation (top, bottom, mirror)', () => { + let bottomTicks = fixture.debugElement.nativeElement + .querySelector(`${SLIDER_TICKS_ELEMENT}:not(${SLIDER_TICKS_TOP_ELEMENT})`); + + expect(bottomTicks).not.toBeNull(); + + fixture.componentInstance.ticksOrientation = TicksOrientation.Top; + fixture.detectChanges(); + + let topTIcks = fixture.debugElement.nativeElement.querySelector(SLIDER_TICKS_TOP_ELEMENT); + bottomTicks = fixture.debugElement.nativeElement + .querySelector(`${SLIDER_TICKS_ELEMENT}:not(${SLIDER_TICKS_TOP_ELEMENT})`); + expect(topTIcks).not.toBeNull(); + expect(bottomTicks).toBeNull(); + + fixture.componentInstance.ticksOrientation = TicksOrientation.Mirror; + fixture.detectChanges(); + + topTIcks = fixture.debugElement.nativeElement.querySelector(SLIDER_TICKS_TOP_ELEMENT); + bottomTicks = fixture.debugElement.nativeElement + .querySelector(`${SLIDER_TICKS_ELEMENT}:not(${SLIDER_TICKS_TOP_ELEMENT})`); + expect(topTIcks).not.toBeNull(); + expect(bottomTicks).not.toBeNull(); + }); + + it('change ticks label orientation (horizontal, toptobottom, bottomtotop)', () => { + fixture.componentInstance.primaryTicks = 5; + const nativeElem = fixture.debugElement.nativeElement; + fixture.detectChanges(); + + let labelsTopBottom = nativeElem.querySelector(TOP_TO_BOTTOM_TICK_LABLES); + let labelsBottomTop = nativeElem.querySelector(BOTTOM_TO_TOP_TICK_LABLES); + expect(labelsBottomTop).toBeNull(); + expect(labelsTopBottom).toBeNull(); + + fixture.componentInstance.tickLabelsOrientation = TickLabelsOrientation.BottomToTop; + fixture.detectChanges(); + + labelsBottomTop = nativeElem.querySelector(BOTTOM_TO_TOP_TICK_LABLES); + labelsTopBottom = nativeElem.querySelector(TOP_TO_BOTTOM_TICK_LABLES); + expect(labelsBottomTop).not.toBeNull(); + expect(labelsTopBottom).toBeNull(); + + fixture.componentInstance.tickLabelsOrientation = TickLabelsOrientation.TopToBottom; + fixture.detectChanges(); + + labelsBottomTop = nativeElem.querySelector(BOTTOM_TO_TOP_TICK_LABLES); + labelsTopBottom = nativeElem.querySelector(TOP_TO_BOTTOM_TICK_LABLES); + expect(labelsTopBottom).not.toBeNull(); + expect(labelsBottomTop).toBeNull(); + }); + + it('ticks change should reflect dynamically when slider labels are changed', () => { + const ticks = fixture.debugElement.query(By.css(SLIDER_TICKS_ELEMENT)); + let labels = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; + slider.labels = labels; + fixture.detectChanges(); + + let primaryTicks = ticks.nativeElement.querySelectorAll(SLIDER_PRIMARY_GROUP_TICKS_CLASS); + expect(primaryTicks.length).toEqual(labels.length); + + labels = labels.splice(0, labels.length - 2); + slider.labels = labels; + fixture.detectChanges(); + + primaryTicks = ticks.nativeElement.querySelectorAll(SLIDER_PRIMARY_GROUP_TICKS_CLASS); + + expect(primaryTicks.length).toEqual(labels.length); + + }); + }); + + describe('EditorProvider', () => { + it('Should return correct edit element (single)', () => { + const fixture = TestBed.createComponent(SliderInitializeTestComponent); + fixture.detectChanges(); + + const instance = fixture.componentInstance.slider; + const editElement = fixture.debugElement.query(By.css(THUMB_TO_CLASS)).nativeElement; + + expect(instance.getEditElement()).toBe(editElement); + }); + + it('Should return correct edit element (range)', () => { + const fixture = TestBed.createComponent(SliderInitializeTestComponent); + const instance = fixture.componentInstance.slider; + instance.type = IgxSliderType.RANGE; + fixture.detectChanges(); + + const editElement = fixture.debugElement.query(By.css(THUMB_FROM_CLASS)).nativeElement; + + expect(instance.getEditElement()).toBe(editElement); + }); + }); + + describe('Slider RTL', () => { + beforeEach(() => { + fakeDoc.documentElement.dir = 'rtl'; + }); + + afterEach(() => { + fakeDoc.documentElement.dir = 'ltr'; + }); + + it('should reflect on the right instead of the left css property of the slider handlers', () => { + const fix = TestBed.createComponent(SliderRtlComponent); + fix.detectChanges(); + + const inst = fix.componentInstance; + const thumbs = fix.debugElement.queryAll(By.css(THUMB_TAG)); + const labels = fix.debugElement.queryAll(By.css(THUMB_LABEL)); + + expect(inst.dir.rtl).toEqual(true); + + expect(thumbs[0].nativeElement.style['right']).toEqual(`${fix.componentInstance.value.lower}%`); + expect(thumbs[1].nativeElement.style['right']).toEqual(`${fix.componentInstance.value.upper}%`); + + expect(labels[0].nativeElement.style['right']).toEqual(`${fix.componentInstance.value.lower}%`); + expect(labels[1].nativeElement.style['right']).toEqual(`${fix.componentInstance.value.upper}%`); + }); + }); + + describe('Form Component', () => { + it('Should correctly bind, update and get updated by ngModel', fakeAsync(() => { + const fixture = TestBed.createComponent(SliderTemplateFormComponent); + fixture.detectChanges(); + tick(); + + const slider = fixture.componentInstance.slider; + + expect(slider.value).toBe(fixture.componentInstance.value); + + fixture.componentInstance.value = 20; + fixture.detectChanges(); + tick(); + expect(slider.value).toBe(fixture.componentInstance.value); + + slider.value = 30; + fixture.detectChanges(); + tick(); + expect(slider.value).toBe(fixture.componentInstance.value); + })); + + it('Should correctly bind, update and get updated by ngModel', () => { + const fixture = TestBed.createComponent(SliderReactiveFormComponent); + fixture.detectChanges(); + + const slider = fixture.componentInstance.slider; + const formControl = fixture.componentInstance.formControl; + + expect(slider.value).toBe(formControl.value); + + formControl.setValue(20); + fixture.detectChanges(); + expect(slider.value).toBe(formControl.value); + + slider.value = 30; + fixture.detectChanges(); + expect(slider.value).toBe(formControl.value); + }); + + it('Should respect the ngModelOptions updateOn: blur', fakeAsync(() => { + const fixture = TestBed.createComponent(SliderTemplateFormComponent); + fixture.componentInstance.updateOn = 'blur'; + fixture.componentInstance.value = 0; + fixture.detectChanges(); + + const slider = fixture.componentInstance.slider; + + const thumbEl = fixture.debugElement.query(By.css(THUMB_TAG)).nativeElement; + const { x: sliderX, width: sliderWidth } = thumbEl.getBoundingClientRect(); + const startX = sliderX + sliderWidth / 2; + + thumbEl.dispatchEvent(new Event('focus')); + fixture.detectChanges(); + + (slider as any).onPointerDown(new PointerEvent('pointerdown', { pointerId: 1, clientX: startX })); + fixture.detectChanges(); + tick(); + + (slider as any).onPointerMove(new PointerEvent('pointermove', { pointerId: 1, clientX: startX + 150 })); + fixture.detectChanges(); + tick(); + + const activeThumb = fixture.debugElement.query(By.css(THUMB_TO_PRESSED_CLASS)); + expect(activeThumb).not.toBeNull(); + expect(fixture.componentInstance.value).not.toBeGreaterThan(0); + + thumbEl.dispatchEvent(new Event('blur')); + fixture.detectChanges(); + tick(); + + expect(fixture.componentInstance.value).toBeGreaterThan(0); + })); + }); + + describe('Accessibility: ARIA Attributes', () => { + let fixture: ComponentFixture; + let slider: IgxSliderComponent; + + beforeEach(() => { + fixture = TestBed.createComponent(RangeSliderTestComponent); + slider = fixture.componentInstance.slider; + fixture.detectChanges(); + }); + + it('should apply all ARIA properties correctly to both thumbs', fakeAsync(() => { + fixture = TestBed.createComponent(RangeSliderTestComponent); + slider = fixture.componentInstance.slider; + fixture.detectChanges(); + tick(); + + const thumbFrom = fixture.debugElement.query(By.css(THUMB_FROM_CLASS)).nativeElement; + const thumbTo = fixture.debugElement.query(By.css(THUMB_TO_CLASS)).nativeElement; + + expect(thumbFrom.getAttribute('role')).toBe('slider'); + expect(thumbFrom.getAttribute('tabindex')).toBe('0'); + expect(parseInt(thumbFrom.getAttribute('aria-valuenow'), 10)).toBe(slider.lowerValue); + expect(parseInt(thumbFrom.getAttribute('aria-valuemin'), 10)).toBe(slider.minValue); + expect(parseInt(thumbFrom.getAttribute('aria-valuemax'), 10)).toBe(slider.maxValue); + expect(thumbFrom.getAttribute('aria-label')).toBe('Slider thumb from'); + expect(thumbFrom.getAttribute('aria-orientation')).toBe('horizontal'); + expect(thumbFrom.getAttribute('aria-disabled')).toBe('false'); + + expect(thumbTo.getAttribute('role')).toBe('slider'); + expect(thumbTo.getAttribute('tabindex')).toBe('0'); + expect(parseInt(thumbTo.getAttribute('aria-valuenow'), 10)).toBe(slider.upperValue); + expect(parseInt(thumbTo.getAttribute('aria-valuemin'), 10)).toBe(slider.minValue); + expect(parseInt(thumbTo.getAttribute('aria-valuemax'), 10)).toBe(slider.maxValue); + expect(thumbTo.getAttribute('aria-label')).toBe('Slider thumb to'); + expect(thumbTo.getAttribute('aria-orientation')).toBe('horizontal'); + expect(thumbTo.getAttribute('aria-disabled')).toBe('false'); + + slider.labels = ['Low', 'Medium', 'High']; + fixture.detectChanges(); + tick(); + + expect(thumbFrom.getAttribute('aria-valuetext')).toBe('Low'); + expect(thumbTo.getAttribute('aria-valuetext')).toBe('High'); + + slider.disabled = true; + fixture.detectChanges(); + tick(); + + expect(thumbFrom.getAttribute('aria-disabled')).toBe('true'); + expect(thumbTo.getAttribute('aria-disabled')).toBe('true'); + })); + + it('should apply correct tabindex to thumbs', fakeAsync(() => { + fixture = TestBed.createComponent(RangeSliderTestComponent); + slider = fixture.componentInstance.slider; + fixture.detectChanges(); + tick(); + + const thumbFrom = fixture.debugElement.query(By.css(THUMB_FROM_CLASS)).nativeElement; + const thumbTo = fixture.debugElement.query(By.css(THUMB_TO_CLASS)).nativeElement; + + expect(thumbFrom.getAttribute('tabindex')).toBe('0'); + expect(thumbTo.getAttribute('tabindex')).toBe('0'); + })); + + it('should apply correct role to thumbs', fakeAsync(() => { + fixture = TestBed.createComponent(RangeSliderTestComponent); + slider = fixture.componentInstance.slider; + fixture.detectChanges(); + tick(); + + const thumbFrom = fixture.debugElement.query(By.css(THUMB_FROM_CLASS)).nativeElement; + const thumbTo = fixture.debugElement.query(By.css(THUMB_TO_CLASS)).nativeElement; + + expect(thumbFrom.getAttribute('role')).toBe('slider'); + expect(thumbTo.getAttribute('role')).toBe('slider'); + })); + + it('should apply aria-valuenow, aria-valuemin, and aria-valuemax to thumbs', fakeAsync(() => { + fixture = TestBed.createComponent(RangeSliderTestComponent); + slider = fixture.componentInstance.slider; + fixture.detectChanges(); + tick(); + + const thumbFrom = fixture.debugElement.query(By.css(THUMB_FROM_CLASS)).nativeElement; + const thumbTo = fixture.debugElement.query(By.css(THUMB_TO_CLASS)).nativeElement; + + expect(thumbFrom.getAttribute('aria-valuenow')).toBe(String(slider.lowerValue)); + expect(thumbFrom.getAttribute('aria-valuemin')).toBe(String(slider.minValue)); + expect(thumbFrom.getAttribute('aria-valuemax')).toBe(String(slider.maxValue)); + + expect(thumbTo.getAttribute('aria-valuenow')).toBe(String(slider.upperValue)); + expect(thumbTo.getAttribute('aria-valuemin')).toBe(String(slider.minValue)); + expect(thumbTo.getAttribute('aria-valuemax')).toBe(String(slider.maxValue)); + })); + + it('should apply aria-valuenow to the thumbs', fakeAsync(() => { + fixture = TestBed.createComponent(RangeSliderTestComponent); + slider = fixture.componentInstance.slider; + fixture.detectChanges(); + tick(); + + const thumbFrom = fixture.debugElement.query(By.css(THUMB_FROM_CLASS)).nativeElement; + const thumbTo = fixture.debugElement.query(By.css(THUMB_TO_CLASS)).nativeElement; + + expect(thumbFrom.getAttribute('aria-valuenow')).toBe(String(slider.lowerLabel)); + expect(thumbTo.getAttribute('aria-valuenow')).toBe(String(slider.upperLabel)); + })); + + it('should update aria-valuenow when the slider value changes', fakeAsync(() => { + fixture = TestBed.createComponent(RangeSliderTestComponent); + slider = fixture.componentInstance.slider; + fixture.detectChanges(); + tick(); + + const thumbFrom = fixture.debugElement.query(By.css(THUMB_FROM_CLASS)).nativeElement; + const thumbTo = fixture.debugElement.query(By.css(THUMB_TO_CLASS)).nativeElement; + + expect(thumbFrom.getAttribute('aria-valuenow')).toBe(String(slider.lowerLabel)); + expect(thumbTo.getAttribute('aria-valuenow')).toBe(String(slider.upperLabel)); + + slider.value = { + lower: 30, + upper: 70 + }; + fixture.detectChanges(); + tick(); + + expect(thumbFrom.getAttribute('aria-valuenow')).toBe('30'); + expect(thumbTo.getAttribute('aria-valuenow')).toBe('70'); + })); + + it('should apply aria-valuetext when labels are provided', fakeAsync(() => { + fixture = TestBed.createComponent(RangeSliderTestComponent); + slider = fixture.componentInstance.slider; + fixture.detectChanges(); + tick(); + + slider.labels = ['Low', 'Medium', 'High']; + tick(); + fixture.detectChanges(); + + const thumbFrom = fixture.debugElement.query(By.css(THUMB_FROM_CLASS)).nativeElement; + const thumbTo = fixture.debugElement.query(By.css(THUMB_TO_CLASS)).nativeElement; + + expect(thumbFrom.getAttribute('aria-valuetext')).toBe('Low'); + expect(thumbTo.getAttribute('aria-valuetext')).toBe('High'); + + slider.value = { + lower: 1, + upper: 1 + }; + fixture.detectChanges(); + tick(); + + expect(thumbFrom.getAttribute('aria-valuetext')).toBe('Medium'); + expect(thumbTo.getAttribute('aria-valuetext')).toBe('Medium'); + })); + + it('should apply correct aria-label to thumbs', fakeAsync(() => { + fixture = TestBed.createComponent(RangeSliderTestComponent); + slider = fixture.componentInstance.slider; + fixture.detectChanges(); + tick(); + + const thumbFrom = fixture.debugElement.query(By.css(THUMB_FROM_CLASS)).nativeElement; + const thumbTo = fixture.debugElement.query(By.css(THUMB_TO_CLASS)).nativeElement; + + expect(thumbFrom.getAttribute('aria-label')).toBe('Slider thumb from'); + expect(thumbTo.getAttribute('aria-label')).toBe('Slider thumb to'); + })); + + it('should apply correct aria-orientation to thumbs', fakeAsync(() => { + fixture = TestBed.createComponent(RangeSliderTestComponent); + slider = fixture.componentInstance.slider; + fixture.detectChanges(); + tick(); + + const thumbFrom = fixture.debugElement.query(By.css(THUMB_FROM_CLASS)).nativeElement; + const thumbTo = fixture.debugElement.query(By.css(THUMB_TO_CLASS)).nativeElement; + + expect(thumbFrom.getAttribute('aria-orientation')).toBe('horizontal'); + expect(thumbTo.getAttribute('aria-orientation')).toBe('horizontal'); + })); + + it('should update aria-disabled when the slider is disabled', fakeAsync(() => { + fixture = TestBed.createComponent(RangeSliderTestComponent); + slider = fixture.componentInstance.slider; + fixture.detectChanges(); + tick(); + + const thumbFrom = fixture.debugElement.query(By.css(THUMB_FROM_CLASS)).nativeElement; + const thumbTo = fixture.debugElement.query(By.css(THUMB_TO_CLASS)).nativeElement; + + expect(thumbFrom.getAttribute('aria-disabled')).toBe('false'); + expect(thumbTo.getAttribute('aria-disabled')).toBe('false'); + + slider.disabled = true; + fixture.detectChanges(); + tick(); + + expect(thumbFrom.getAttribute('aria-disabled')).toBe('true'); + expect(thumbTo.getAttribute('aria-disabled')).toBe('true'); + })); + }); + + const verifySecondaryTicsLabelsAreHidden = (ticks, hidden) => { + const allTicks = Array.from(ticks.nativeElement.querySelectorAll(`${SLIDER_GROUP_TICKS_CLASS}`)); + const secondaryTicks = allTicks.filter((ticker: any) => + !ticker.classList.contains(SLIDER_PRIMARY_GROUP_TICKS_CLASS_NAME) + ); + secondaryTicks.forEach(ticker => { + const label = (ticker as HTMLElement).querySelector(SLIDER_TICK_LABELS_CLASS); + expect(label.classList.contains(SLIDER_TICK_LABELS_HIDDEN_CLASS)).toEqual(hidden); + }); + }; + + const verifyPrimaryTicsLabelsAreHidden = (ticks, hidden) => { + const primaryTicks = ticks.nativeElement.querySelectorAll(`${SLIDER_PRIMARY_GROUP_TICKS_CLASS}`); + primaryTicks.forEach(ticker => { + const label = (ticker as HTMLElement).querySelector(SLIDER_TICK_LABELS_CLASS); + expect(label.classList.contains(SLIDER_TICK_LABELS_HIDDEN_CLASS)).toEqual(hidden); + }); + }; +}); + +@Component({ + selector: 'igx-slider-rtl', + template: ` + + `, + imports: [IgxSliderComponent] +}) +export class SliderRtlComponent { + public dir = inject(ɵIgxDirectionality); + + @ViewChild(IgxSliderComponent) + public slider: IgxSliderComponent; + + public value = { + lower: 20, + upper: 80 + }; + + public type: IgxSliderType = IgxSliderType.RANGE; +} + +@Component({ + selector: 'igx-slider-ticks', + template: ` + + `, + imports: [IgxSliderComponent] +}) +export class SliderTicksComponent { + + @ViewChild(IgxSliderComponent, {static: true}) + public slider: IgxSliderComponent; + + public primaryTicks = 0; + public secondaryTicks = 0; + public showTicks = true; + public ticksOrientation: TicksOrientation = TicksOrientation.Bottom; + public primaryTickLabels = true; + public secondaryTickLabels = true; + public tickLabelsOrientation: TickLabelsOrientation = TickLabelsOrientation.Horizontal; +} +@Component({ + selector: 'igx-slider-test-component', + template: ` + `, + imports: [IgxSliderComponent] +}) +class SliderInitializeTestComponent { + @ViewChild(IgxSliderComponent, { read: IgxSliderComponent, static: true }) public slider: IgxSliderComponent; +} + +@Component({ + template: ` + + `, + imports: [IgxSliderComponent] +}) +export class SliderMinMaxComponent { + @ViewChild(IgxSliderComponent, { read: IgxSliderComponent, static: true }) public slider: IgxSliderComponent; + + public minValue = 150; + public maxValue = 300; +} + +@Component({ + selector: 'igx-slider-test-component', + template: `
    + + +
    `, + imports: [IgxSliderComponent] +}) +class SliderTestComponent { + @ViewChild(IgxSliderComponent, { read: IgxSliderComponent, static: true }) public slider: IgxSliderComponent; + + public minValue = 0; + public maxValue = 10; + public type: IgxSliderType = IgxSliderType.SLIDER; + + public changeMinValue(val: number) { + this.minValue = val; + } + + public changeMaxValue(val: number) { + this.maxValue = val; + } +} + +@Component({ + template: ` + + `, + imports: [IgxSliderComponent] +}) +class SliderWithLabelsComponent { + @ViewChild(IgxSliderComponent, { read: IgxSliderComponent, static: true }) public slider: IgxSliderComponent; +} + +@Component({ + template: ` + `, + imports: [IgxSliderComponent] +}) +class RangeSliderTestComponent { + @ViewChild(IgxSliderComponent, { static: true }) public slider: IgxSliderComponent; + public type = IgxSliderType.RANGE; +} + +@Component({ + template: ` + + `, + imports: [IgxSliderComponent] +}) +class RangeSliderWithLabelsComponent { + @ViewChild(IgxSliderComponent, { read: IgxSliderComponent, static: true }) + public slider: IgxSliderComponent; + + public type = IgxSliderType.RANGE; +} + +@Component({ + template: ` + + + Lower {{ labels[value.lower] }} + + + Upper {{ labels[value.upper] }} + + + `, + imports: [IgxSliderComponent, IgxThumbFromTemplateDirective, IgxThumbToTemplateDirective] +}) +class RangeSliderWithCustomTemplateComponent { + @ViewChild(IgxSliderComponent, { read: IgxSliderComponent, static: true }) public slider: IgxSliderComponent; + public type = IgxSliderType.RANGE; +} + +@Component({ + template: ` +
    + + + `, + imports: [IgxSliderComponent, FormsModule] +}) +export class SliderTemplateFormComponent { + @ViewChild(IgxSliderComponent, { read: IgxSliderComponent, static: true }) public slider: IgxSliderComponent; + + @Input() public updateOn: 'change' | 'blur' | 'submit' = 'change'; + + public value = 10; +} + +@Component({ + template: ` +
    + + + `, + imports: [IgxSliderComponent, FormsModule, ReactiveFormsModule] +}) +export class SliderReactiveFormComponent { + @ViewChild(IgxSliderComponent, { read: IgxSliderComponent, static: true }) public slider: IgxSliderComponent; + + public formControl = new UntypedFormControl(10); +} + +@Component({ + template: ` + + `, + imports: [IgxSliderComponent] +}) +export class SliderWithValueAdjustmentComponent { + @ViewChild(IgxSliderComponent, { read: IgxSliderComponent, static: true }) public slider: IgxSliderComponent; + + public value = { + lower: 50, + upper: 20 + }; +} diff --git a/projects/igniteui-angular/slider/src/slider/slider.component.ts b/projects/igniteui-angular/slider/src/slider/slider.component.ts new file mode 100644 index 00000000000..9c742100f4a --- /dev/null +++ b/projects/igniteui-angular/slider/src/slider/slider.component.ts @@ -0,0 +1,1413 @@ +import { AfterContentInit, AfterViewInit, ChangeDetectorRef, Component, ContentChild, ElementRef, EventEmitter, HostBinding, HostListener, Input, NgZone, OnChanges, OnDestroy, OnInit, Output, QueryList, Renderer2, SimpleChanges, TemplateRef, ViewChild, ViewChildren, booleanAttribute, inject } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { animationFrameScheduler, fromEvent, interval, merge, noop, Observable, Subject, timer } from 'rxjs'; +import { takeUntil, throttle, throttleTime } from 'rxjs/operators'; +import { EditorProvider, ɵIgxDirectionality, resizeObservable } from 'igniteui-angular/core'; +import { IgxThumbLabelComponent } from './label/thumb-label.component'; +import { + IgxSliderType, IgxThumbFromTemplateDirective, + IgxThumbToTemplateDirective, IgxTickLabelTemplateDirective, IRangeSliderValue, ISliderValueChangeEventArgs, SliderHandle, TickLabelsOrientation, TicksOrientation +} from './slider.common'; +import { IgxSliderThumbComponent } from './thumb/thumb-slider.component'; +import { IgxTickLabelsPipe } from './ticks/tick.pipe'; +import { IgxTicksComponent } from './ticks/ticks.component'; + +let NEXT_ID = 0; + +/** + * **Ignite UI for Angular Slider** - + * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/slider/slider) + * + * The Ignite UI Slider allows selection in a given range by moving the thumb along the track. The track + * can be defined as continuous or stepped, and you can choose between single and range slider types. + * + * Example: + * ```html + * + * + * ``` + */ +@Component({ + providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: IgxSliderComponent, multi: true }], + selector: 'igx-slider', + templateUrl: 'slider.component.html', + imports: [IgxTicksComponent, IgxThumbLabelComponent, IgxSliderThumbComponent, IgxTickLabelsPipe] +}) +export class IgxSliderComponent implements + ControlValueAccessor, + EditorProvider, + OnInit, + AfterViewInit, + AfterContentInit, + OnChanges, + OnDestroy { + private renderer = inject(Renderer2); + private _el = inject(ElementRef); + private _cdr = inject(ChangeDetectorRef); + private _ngZone = inject(NgZone); + private _dir = inject(ɵIgxDirectionality); + + /** + * @hidden + */ + public get thumbFrom(): IgxSliderThumbComponent { + return this.thumbs.find(thumb => thumb.type === SliderHandle.FROM); + } + + /** + * @hidden + */ + public get thumbTo(): IgxSliderThumbComponent { + return this.thumbs.find(thumb => thumb.type === SliderHandle.TO); + } + + private get labelFrom(): IgxThumbLabelComponent { + return this.labelRefs.find(label => label.type === SliderHandle.FROM); + } + + private get labelTo(): IgxThumbLabelComponent { + return this.labelRefs.find(label => label.type === SliderHandle.TO); + } + + /** + * @hidden + */ + @ViewChild('track', { static: true }) + public trackRef: ElementRef; + + /** + * @hidden + */ + @ContentChild(IgxThumbFromTemplateDirective, { read: TemplateRef }) + public thumbFromTemplateRef: TemplateRef; + + /** + * @hidden + */ + @ContentChild(IgxThumbToTemplateDirective, { read: TemplateRef }) + public thumbToTemplateRef: TemplateRef; + + /** + * @hidden + */ + @ContentChild(IgxTickLabelTemplateDirective, { read: TemplateRef, static: false }) + public tickLabelTemplateRef: TemplateRef; + + /** + * @hidden + */ + @HostBinding('class.igx-slider') + public slierClass = true; + + /** + * Sets the value of the `id` attribute. + * If not provided it will be automatically generated. + * ```html + * + * ``` + */ + @HostBinding('attr.id') + @Input() + public id = `igx-slider-${NEXT_ID++}`; + + /** + * Sets the duration visibility of thumbs labels. The default value is 750 milliseconds. + * ```html + * + * ``` + */ + @Input() + public thumbLabelVisibilityDuration = 750; + + /** + * @hidden + */ + @HostBinding('class.igx-slider--disabled') + public get disabledClass() { + return this.disabled; + } + + /** + * Gets the type of the `IgxSliderComponent`. + * The slider can be IgxSliderType.SLIDER(default) or IgxSliderType.RANGE. + * ```typescript + * @ViewChild("slider2") + * public slider: IgxSliderComponent; + * ngAfterViewInit(){ + * let type = this.slider.type; + * } + */ + @Input() + public get type() { + return this._type as IgxSliderType; + } + + /** + * Sets the type of the `IgxSliderComponent`. + * The slider can be IgxSliderType.SLIDER(default) or IgxSliderType.RANGE. + * ```typescript + * sliderType: IgxSliderType = IgxSliderType.RANGE; + * ``` + * ```html + * + * ``` + */ + public set type(type: IgxSliderType) { + this._type = type; + + if (type === IgxSliderType.SLIDER) { + this.lowerValue = 0; + } + + if (this._hasViewInit) { + this.updateTrack(); + } + } + + + /** + * Enables `labelView`, by accepting a collection of primitive values with more than one element. + * Each element will be equally spread over the slider and it will serve as a thumb label. + * Once the property is set, it will precendence over {@link maxValue}, {@link minValue}, {@link step}. + * This means that the manipulation for those properties won't be allowed. + */ + @Input() + public get labels() { + return this._labels; + } + + public set labels(labels: Array) { + this._labels = labels; + + this._pMax = this.valueToFraction(this.upperBound, 0, 1); + this._pMin = this.valueToFraction(this.lowerBound, 0, 1); + + this.positionHandlersAndUpdateTrack(); + + if (this._hasViewInit) { + this.stepDistance = this.calculateStepDistance(); + this.setTickInterval(); + } + } + + /** + * Returns the template context corresponding + * to {@link IgxThumbFromTemplateDirective} and {@link IgxThumbToTemplateDirective} templates. + * + * ```typescript + * return { + * $implicit // returns the value of the label, + * labels // returns the labels collection the user has passed. + * } + * ``` + */ + public get context(): any { + return { + $implicit: this.value, + labels: this.labels + }; + } + + /** + * Sets the incremental/decremental step of the value when dragging the thumb. + * The default step is 1, and step should not be less or equal than 0. + * ```html + * + * ``` + */ + @Input() + public set step(step: number) { + this._step = step; + + if (this._hasViewInit) { + this.stepDistance = this.calculateStepDistance(); + this.normalizeByStep(this._value); + this.setValue(this._value, true); + this.positionHandlersAndUpdateTrack(); + this.setTickInterval(); + } + } + + /** + * Returns the incremental/decremental dragging step of the {@link IgxSliderComponent}. + * ```typescript + * @ViewChild("slider2") + * public slider: IgxSliderComponent; + * ngAfterViewInit(){ + * let step = this.slider.step; + * } + * ``` + */ + public get step() { + return this.labelsViewEnabled ? 1 : this._step; + } + + /** + * Returns if the {@link IgxSliderComponent} is disabled. + * ```typescript + * @ViewChild("slider2") + * public slider: IgxSliderComponent; + * ngAfterViewInit(){ + * let isDisabled = this.slider.disabled; + * } + * ``` + */ + @Input({ transform: booleanAttribute }) + public get disabled(): boolean { + return this._disabled; + } + + /** + * Disables the component. + * ```html + * + * ``` + */ + public set disabled(disable: boolean) { + this._disabled = disable; + + if (this._hasViewInit) { + this.changeThumbFocusableState(disable); + } + } + + /** + * Returns if the {@link IgxSliderComponent} is set as continuous. + * ```typescript + * @ViewChild("slider2") + * public slider: IgxSliderComponent; + * ngAfterViewInit(){ + * let continuous = this.slider.continuous; + * } + * ``` + */ + @Input({ transform: booleanAttribute }) + public get continuous(): boolean { + return this._continuous; + } + + /** + * Sets the {@link IgxSliderComponent} as continuous. + * By default is considered that the {@link IgxSliderComponent} is discrete. + * Discrete {@link IgxSliderComponent} slider has step indicators over the track and visible thumb labels during interaction. + * Continuous {@link IgxSliderComponent} does not have ticks and does not show bubble labels for values. + * ```html + * + * ``` + */ + public set continuous(continuous: boolean) { + this._continuous = continuous; + if (this._hasViewInit) { + this.setTickInterval(); + } + } + + /** + * Returns the minimal displayed track value of the `IgxSliderComponent`. + * ```typescript + * @ViewChild("slider2") + * public slider: IgxSliderComponent; + * ngAfterViewInit(){ + * let sliderMin = this.slider.minValue; + * } + * ``` + */ + public get minValue(): number { + if (this.labelsViewEnabled) { + return 0; + } + + return this._minValue; + } + + /** + * Sets the minimal displayed track value for the `IgxSliderComponent`. + * The default minimal value is 0. + * ```html + * + * ``` + */ + @Input() + public set minValue(value: number) { + if (value >= this.maxValue) { + return; + } else { + this._minValue = value; + } + + if (value > this._upperBound) { + this.updateUpperBoundAndMaxTravelZone(); + this.lowerBound = value; + } + + // Refresh min travel zone limit. + this._pMin = 0; + // Recalculate step distance. + this.positionHandlersAndUpdateTrack(); + if (this._hasViewInit) { + this.stepDistance = this.calculateStepDistance(); + this.setTickInterval(); + } + } + + /** + * Returns the maximum displayed track value for the {@link IgxSliderComponent}. + * ```typescript + * @ViewChild("slider") + * public slider: IgxSliderComponent; + * ngAfterViewInit(){ + * let sliderMax = this.slider.maxValue; + * } + * ``` + */ + public get maxValue(): number { + return this.labelsViewEnabled ? + this.labels.length - 1 : + this._maxValue; + } + + /** + * Sets the maximal displayed track value for the `IgxSliderComponent`. + * The default maximum value is 100. + * ```html + * + * ``` + */ + @Input() + public set maxValue(value: number) { + if (value <= this._minValue) { + return; + } else { + this._maxValue = value; + } + + if (value < this._lowerBound) { + this.updateLowerBoundAndMinTravelZone(); + this.upperBound = value; + } + + // refresh max travel zone limits. + this._pMax = 1; + // recalculate step distance. + this.positionHandlersAndUpdateTrack(); + if (this._hasViewInit) { + this.stepDistance = this.calculateStepDistance(); + this.setTickInterval(); + } + } + + /** + * Returns the lower boundary of settable values of the `IgxSliderComponent`. + * If not set, will return `minValue`. + * ```typescript + * @ViewChild("slider") + * public slider: IgxSliderComponent; + * ngAfterViewInit(){ + * let sliderLowBound = this.slider.lowerBound; + * } + * ``` + */ + public get lowerBound(): number { + if (!Number.isNaN(this._lowerBound) && this._lowerBound !== undefined) { + return this.valueInRange(this._lowerBound, this.minValue, this.maxValue); + } + + return this.minValue; + } + + /** + * Sets the lower boundary of settable values of the `IgxSliderComponent`. + * If not set is the same as min value. + * ```html + * + * ``` + */ + @Input() + public set lowerBound(value: number) { + if (value >= this.upperBound || (this.labelsViewEnabled && value < 0)) { + return; + } + + this._lowerBound = this.valueInRange(value, this.minValue, this.maxValue); + + // Refresh min travel zone. + this._pMin = this.valueToFraction(this._lowerBound, 0, 1); + this.positionHandlersAndUpdateTrack(); + } + + /** + * Returns the upper boundary of settable values of the `IgxSliderComponent`. + * If not set, will return `maxValue` + * ```typescript + * @ViewChild("slider") + * public slider: IgxSliderComponent; + * ngAfterViewInit(){ + * let sliderUpBound = this.slider.upperBound; + * } + * ``` + */ + public get upperBound(): number { + if (!Number.isNaN(this._upperBound) && this._upperBound !== undefined) { + return this.valueInRange(this._upperBound, this.minValue, this.maxValue); + } + + return this.maxValue; + } + + /** + * Sets the upper boundary of the `IgxSliderComponent`. + * If not set is the same as max value. + * ```html + * + * ``` + */ + @Input() + public set upperBound(value: number) { + if (value <= this.lowerBound || (this.labelsViewEnabled && value > this.labels.length - 1)) { + return; + } + + this._upperBound = this.valueInRange(value, this.minValue, this.maxValue); + // Refresh time travel zone. + this._pMax = this.valueToFraction(this._upperBound, 0, 1); + this.positionHandlersAndUpdateTrack(); + } + + /** + * Returns the slider value. If the slider is of type {@link IgxSliderType.SLIDER} the returned value is number. + * If the slider type is {@link IgxSliderType.RANGE}. + * The returned value represents an object of {@link lowerValue} and {@link upperValue}. + * ```typescript + * @ViewChild("slider2") + * public slider: IgxSliderComponent; + * public sliderValue(event){ + * let sliderVal = this.slider.value; + * } + * ``` + */ + public get value(): number | IRangeSliderValue { + if (this.isRange) { + return { + lower: this.valueInRange(this.lowerValue, this.lowerBound, this.upperBound), + upper: this.valueInRange(this.upperValue, this.lowerBound, this.upperBound) + }; + } else { + return this.valueInRange(this.upperValue, this.lowerBound, this.upperBound); + } + } + + /** + * Sets the slider value. + * If the slider is of type {@link IgxSliderType.SLIDER}. + * The argument is number. By default the {@link value} gets the {@link lowerBound}. + * If the slider type is {@link IgxSliderType.RANGE} the argument + * represents an object of {@link lowerValue} and {@link upperValue} properties. + * By default the object is associated with the {@link lowerBound} and {@link upperBound} property values. + * ```typescript + * rangeValue = { + * lower: 30, + * upper: 60 + * }; + * ``` + * ```html + * + * ``` + */ + @Input() + public set value(value: number | IRangeSliderValue) { + this.normalizeByStep(value); + + if (this._hasViewInit) { + this.setValue(this._value, true); + this.positionHandlersAndUpdateTrack(); + } + } + + /** + * Returns the number of the presented primary ticks. + * ```typescript + * const primaryTicks = this.slider.primaryTicks; + * ``` + */ + @Input() + public get primaryTicks() { + if (this.labelsViewEnabled) { + return this._primaryTicks = this.labels.length; + } + return this._primaryTicks; + } + + /** + * Sets the number of primary ticks. If {@link @labels} is enabled, this property won't function. + * Insted enable ticks by {@link showTicks} property. + * ```typescript + * this.slider.primaryTicks = 5; + * ``` + */ + public set primaryTicks(val: number) { + if (val <= 1) { + return; + } + + this._primaryTicks = val; + } + + /** + * Returns the number of the presented secondary ticks. + * ```typescript + * const secondaryTicks = this.slider.secondaryTicks; + * ``` + */ + @Input() + public get secondaryTicks() { + return this._secondaryTicks; + } + + /** + * Sets the number of secondary ticks. The property functions even when {@link labels} is enabled, + * but all secondary ticks won't present any tick labels. + * ```typescript + * this.slider.secondaryTicks = 5; + * ``` + */ + public set secondaryTicks(val: number) { + if (val < 1) { + return; + } + + this._secondaryTicks = val; + } + + /** + * Show/hide slider ticks + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public showTicks = false; + + /** + * show/hide primary tick labels + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public primaryTickLabels = true; + + /** + * show/hide secondary tick labels + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public secondaryTickLabels = true; + + /** + * Changes ticks orientation: + * bottom - The default orienation, below the slider track. + * top - Above the slider track + * mirror - combines top and bottom orientation. + * ```html + * + * ``` + */ + @Input() + public ticksOrientation: TicksOrientation = TicksOrientation.Bottom; + + /** + * Changes tick labels rotation: + * horizontal - The default rotation + * toptobottom - Rotates tick labels vertically to 90deg + * bottomtotop - Rotate tick labels vertically to -90deg + * ```html + * + * ``` + */ + @Input() + public tickLabelsOrientation: TickLabelsOrientation = TickLabelsOrientation.Horizontal; + + /** + * @hidden + */ + public get deactivateThumbLabel() { + return ((this.primaryTicks && this.primaryTickLabels) || (this.secondaryTicks && this.secondaryTickLabels)) && + (this.ticksOrientation === TicksOrientation.Top || this.ticksOrientation === TicksOrientation.Mirror); + } + + /** + * This event is emitted every time the value is changed. + * ```typescript + * public change(event){ + * alert("The value has been changed!"); + * } + * ``` + * ```html + * + * ``` + */ + @Output() + public valueChange = new EventEmitter(); + + /** + * This event is emitted every time the lower value of a range slider is changed. + * ```typescript + * public change(value){ + * alert(`The lower value has been changed to ${value}`); + * } + * ``` + * ```html + * + * ``` + */ + @Output() + public lowerValueChange = new EventEmitter(); + + /** + * This event is emitted every time the upper value of a range slider is changed. + * ```typescript + * public change(value){ + * alert(`The upper value has been changed to ${value}`); + * } + * ``` + * ```html + * + * ``` + */ + @Output() + public upperValueChange = new EventEmitter(); + + /** + * This event is emitted at the end of every slide interaction. + * ```typescript + * public change(event){ + * alert("The value has been changed!"); + * } + * ``` + * ```html + * + * ``` + */ + @Output() + public dragFinished = new EventEmitter(); + + /** + * @hidden + */ + @ViewChild('ticks', { static: true }) + private ticks: ElementRef; + + /** + * @hidden + */ + @ViewChildren(IgxSliderThumbComponent) + private thumbs: QueryList = new QueryList(); + + /** + * @hidden + */ + @ViewChildren(IgxThumbLabelComponent) + private labelRefs: QueryList = new QueryList(); + + /** + * @hidden + */ + public onPan: Subject = new Subject(); + + /** + * @hidden + */ + public stepDistance: number; + + // Limit handle travel zone + private _pMin = 0; + private _pMax = 1; + + // From/upperValue in percent values + private _hasViewInit = false; + private _minValue = 0; + private _maxValue = 100; + private _lowerBound: number; + private _upperBound: number; + private _lowerValue: number; + private _upperValue: number; + private _continuous = false; + private _disabled = false; + private _step = 1; + private _value: number | IRangeSliderValue = 0; + + // ticks + private _primaryTicks = 0; + private _secondaryTicks = 0; + private _sliding = false; + + private _labels = new Array(); + private _type: IgxSliderType = IgxSliderType.SLIDER; + + private _destroyer$ = new Subject(); + private _indicatorsDestroyer$ = new Subject(); + private _indicatorsTimer: Observable; + + private _onChangeCallback: (_: any) => void = noop; + private _onTouchedCallback: () => void = noop; + + constructor() { + this.stepDistance = this._step; + } + + /** + * @hidden + */ + @HostListener('focus') + public onFocus() { + this.toggleSliderIndicators(); + } + + /** + * Returns whether the `IgxSliderComponent` type is RANGE. + * ```typescript + * @ViewChild("slider") + * public slider: IgxSliderComponent; + * ngAfterViewInit(){ + * let sliderRange = this.slider.isRange; + * } + * ``` + */ + public get isRange(): boolean { + return this.type === IgxSliderType.RANGE; + } + + /** + * Returns the lower value of a RANGE `IgxSliderComponent`. + * ```typescript + * @ViewChild("slider") + * public slider: IgxSliderComponent; + * public lowValue(event){ + * let sliderLowValue = this.slider.lowerValue; + * } + * ``` + */ + public get lowerValue(): number { + if (!Number.isNaN(this._lowerValue) && this._lowerValue !== undefined && this._lowerValue >= this.lowerBound) { + return this._lowerValue; + } + + return this.lowerBound; + } + + /** + * Sets the lower value of a RANGE `IgxSliderComponent`. + * ```typescript + * @ViewChild("slider") + * public slider: IgxSliderComponent; + * public lowValue(event){ + * this.slider.lowerValue = value; + * } + * ``` + */ + @Input() + public set lowerValue(value: number) { + const adjustedValue = this.valueInRange(value, this.lowerBound, this.upperBound); + if (this._lowerValue !== adjustedValue) { + this._lowerValue = adjustedValue; + this.lowerValueChange.emit(this._lowerValue); + this.value = { lower: this._lowerValue, upper: this._upperValue }; + } + } + + /** + * Returns the upper value of a RANGE `IgxSliderComponent`. + * Returns `value` of a SLIDER `IgxSliderComponent` + * ```typescript + * @ViewChild("slider2") + * public slider: IgxSliderComponent; + * public upperValue(event){ + * let upperValue = this.slider.upperValue; + * } + * ``` + */ + public get upperValue() { + if (!Number.isNaN(this._upperValue) && this._upperValue !== undefined && this._upperValue <= this.upperBound) { + return this._upperValue; + } + + return this.upperBound; + } + + /** + * Sets the upper value of a RANGE `IgxSliderComponent`. + * ```typescript + * @ViewChild("slider2") + * public slider: IgxSliderComponent; + * public upperValue(event){ + * this.slider.upperValue = value; + * } + * ``` + */ + @Input() + public set upperValue(value: number) { + const adjustedValue = this.valueInRange(value, this.lowerBound, this.upperBound); + if (this._upperValue !== adjustedValue) { + this._upperValue = adjustedValue; + this.upperValueChange.emit(this._upperValue); + this.value = { lower: this._lowerValue, upper: this._upperValue }; + } + } + + /** + * Returns the value corresponding the lower label. + * ```typescript + * @ViewChild("slider") + * public slider: IgxSliderComponent; + * let label = this.slider.lowerLabel; + * ``` + */ + public get lowerLabel() { + return this.labelsViewEnabled ? this.labels[this.lowerValue] : this.lowerValue; + } + + /** + * Returns the value corresponding the upper label. + * ```typescript + * @ViewChild("slider") + * public slider: IgxSliderComponent; + * let label = this.slider.upperLabel; + * ``` + */ + public get upperLabel() { + return this.labelsViewEnabled ? this.labels[this.upperValue] : this.upperValue; + } + + /** + * Returns if label view is enabled. + * If the {@link labels} is set, the view is automatically activated. + * ```typescript + * @ViewChild("slider") + * public slider: IgxSliderComponent; + * let labelView = this.slider.labelsViewEnabled; + * ``` + */ + public get labelsViewEnabled(): boolean { + return !!(this.labels && this.labels.length > 1); + } + + /** + * @hidden + */ + public get showTopTicks() { + return this.ticksOrientation === TicksOrientation.Top || + this.ticksOrientation === TicksOrientation.Mirror; + } + + /** + * @hidden + */ + public get showBottomTicks() { + return this.ticksOrientation === TicksOrientation.Bottom || + this.ticksOrientation === TicksOrientation.Mirror; + } + + /** + * @hidden + */ + public ngOnChanges(changes: SimpleChanges) { + if (changes.minValue && changes.maxValue && + changes.minValue.currentValue < changes.maxValue.currentValue) { + this._maxValue = changes.maxValue.currentValue; + this._minValue = changes.minValue.currentValue; + } + + if (changes.step && changes.step.isFirstChange()) { + this.normalizeByStep(this._value); + } + } + + /** + * @hidden + */ + public ngOnInit() { + /** + * if {@link SliderType.SLIDER} than the initial value shold be the lowest one. + */ + if (!this.isRange) { + this._upperValue = this.lowerBound; + } + + // Set track travel zone + this._pMin = this.valueToFraction(this.lowerBound) || 0; + this._pMax = this.valueToFraction(this.upperBound) || 1; + } + + public ngAfterContentInit() { + this.setValue(this._value, false); + } + + /** + * @hidden + */ + public ngAfterViewInit() { + this._hasViewInit = true; + this.stepDistance = this.calculateStepDistance(); + this.positionHandlersAndUpdateTrack(); + this.setTickInterval(); + this.changeThumbFocusableState(this.disabled); + + this.subscribeToEvents(this.thumbFrom); + this.subscribeToEvents(this.thumbTo); + + this.thumbs.changes.pipe(takeUntil(this._destroyer$)).subscribe(change => { + const thumbFrom = change.find((thumb: IgxSliderThumbComponent) => thumb.type === SliderHandle.FROM); + this.positionHandler(thumbFrom, null, this.lowerValue); + this.subscribeToEvents(thumbFrom); + this.changeThumbFocusableState(this.disabled); + }); + + this.labelRefs.changes.pipe(takeUntil(this._destroyer$)).subscribe(() => { + const labelFrom = this.labelRefs.find((label: IgxThumbLabelComponent) => label.type === SliderHandle.FROM); + this.positionHandler(null, labelFrom, this.lowerValue); + }); + + this._ngZone.runOutsideAngular(() => { + resizeObservable(this._el.nativeElement).pipe( + throttleTime(40), + takeUntil(this._destroyer$)).subscribe(() => this._ngZone.run(() => { + this.stepDistance = this.calculateStepDistance(); + })); + fromEvent(this._el.nativeElement, 'pointermove').pipe( + throttle(() => interval(0, animationFrameScheduler)), + takeUntil(this._destroyer$)).subscribe(($event: PointerEvent) => this._ngZone.run(() => { + this.onPointerMove($event); + })); + }); + } + + /** + * @hidden + */ + public ngOnDestroy() { + this._destroyer$.next(true); + this._destroyer$.complete(); + + this._indicatorsDestroyer$.next(true); + this._indicatorsDestroyer$.complete(); + } + + /** + * @hidden + */ + public writeValue(value: IRangeSliderValue | number): void { + if (this.isNullishButNotZero(value)) { + return; + } + + this.normalizeByStep(value); + this.setValue(this._value, false); + this.positionHandlersAndUpdateTrack(); + } + + /** + * @hidden + */ + public registerOnChange(fn: any): void { + this._onChangeCallback = fn; + } + + /** + * @hidden + */ + public registerOnTouched(fn: any): void { + this._onTouchedCallback = fn; + } + + /** @hidden */ + public getEditElement() { + return this.isRange ? this.thumbFrom.nativeElement : this.thumbTo.nativeElement; + } + + /** + * + * @hidden + */ + public update(mouseX) { + if (this.disabled) { + return; + } + + // Update To/From Values + this.onPan.next(mouseX); + + // Finally do positionHandlersAndUpdateTrack the DOM + // based on data values + this.positionHandlersAndUpdateTrack(); + } + + /** + * @hidden + */ + public thumbChanged(value: number, thumbType: string) { + const oldValue = this.value; + + if (this.isRange) { + if (thumbType === SliderHandle.FROM) { + if (this.lowerValue + value > this.upperValue) { + this.upperValue = this.lowerValue + value; + } + this.lowerValue += value; + } else { + if (this.upperValue + value < this.lowerValue) { + this.lowerValue = this.upperValue + value; + } + this.upperValue += value; + } + + const newVal: IRangeSliderValue = { + lower: this.lowerValue, + upper: this.upperValue + } + + // Swap the thumbs if a collision appears. + // if (newVal.lower == newVal.upper) { + // this.toggleThumb(); + // } + + this.value = newVal; + + } else { + const newVal = (this.value as number) + value; + if (newVal >= this.lowerBound && newVal <= this.upperBound) { + this.value = newVal; + } + } + + if (this.hasValueChanged(oldValue)) { + this.emitValueChange(oldValue); + } + } + + /** + * @hidden + */ + public onThumbChange() { + this.toggleSliderIndicators(); + } + + /** + * @hidden + */ + public onHoverChange(state: boolean) { + return state ? this.showSliderIndicators() : this.hideSliderIndicators(); + } + + public setValue(value: number | IRangeSliderValue, triggerChange: boolean) { + let res; + if (!this.isRange) { + value = value as number; + if (!isNaN(value)) { + this._upperValue = value - value % this.step; + res = this.upperValue; + } + } else { + value = this.validateInitialValue(value as IRangeSliderValue); + this._upperValue = value.upper; + this._lowerValue = value.lower; + res = { lower: this.lowerValue, upper: this.upperValue }; + } + + if (triggerChange) { + this._onChangeCallback(res); + } + } + + @HostListener('pointerdown', ['$event']) + protected onPointerDown($event: PointerEvent) { + this.findClosestThumb($event); + + if (!this.thumbTo.isActive && this.thumbFrom === undefined) { + return; + } + + this._sliding = true; + const activeThumb = this.thumbTo.isActive ? this.thumbTo : this.thumbFrom; + activeThumb.nativeElement.setPointerCapture($event.pointerId); + this.showSliderIndicators(); + + $event.preventDefault(); + } + + private onPointerMove($event: PointerEvent) { + if (this._sliding) { + this.update($event.clientX); + } + } + + @HostListener('pointerup', ['$event']) + protected onPointerUp($event: PointerEvent) { + if (!this.thumbTo.isActive && this.thumbFrom === undefined) { + return; + } + + const activeThumb = this.thumbTo.isActive ? this.thumbTo : this.thumbFrom; + activeThumb.nativeElement.releasePointerCapture($event.pointerId); + + this._sliding = false; + this.hideSliderIndicators(); + this.dragFinished.emit(this.value); + } + + private validateInitialValue(value: IRangeSliderValue) { + if (value.upper < value.lower) { + const temp = value.upper; + value.upper = value.lower; + value.lower = temp; + } + + if (value.lower < this.lowerBound) { + value.lower = this.lowerBound; + } + + if (value.upper > this.upperBound) { + value.upper = this.upperBound; + } + + return value; + } + + private findClosestThumb(event: PointerEvent) { + if (this.isRange) { + this.closestHandle(event); + } else { + this.thumbTo.nativeElement.focus(); + } + + this.update(event.clientX); + } + + private updateLowerBoundAndMinTravelZone() { + this.lowerBound = this.minValue; + this._pMin = 0; + } + + private updateUpperBoundAndMaxTravelZone() { + this.upperBound = this.maxValue; + this._pMax = 1; + } + + private calculateStepDistance() { + return this._el.nativeElement.getBoundingClientRect().width / (this.maxValue - this.minValue) * this.step; + } + + // private toggleThumb() { + // return this.thumbFrom.isActive ? + // this.thumbTo.nativeElement.focus() : + // this.thumbFrom.nativeElement.focus(); + // } + + private valueInRange(value, min = 0, max = 100) { + return Math.max(Math.min(value, max), min); + } + + private positionHandler(thumbHandle: ElementRef, labelHandle: ElementRef, position: number) { + const percent = `${this.valueToFraction(position) * 100}%`; + const dir = this._dir.rtl ? 'right' : 'left'; + + if (thumbHandle) { + thumbHandle.nativeElement.style[dir] = percent; + } + + if (labelHandle) { + labelHandle.nativeElement.style[dir] = percent; + } + } + + private positionHandlersAndUpdateTrack() { + if (!this.isRange) { + this.positionHandler(this.thumbTo, this.labelTo, this.value as number); + } else { + this.positionHandler(this.thumbTo, this.labelTo, (this.value as IRangeSliderValue).upper); + this.positionHandler(this.thumbFrom, this.labelFrom, (this.value as IRangeSliderValue).lower); + } + + if (this._hasViewInit) { + this.updateTrack(); + } + } + + private closestHandle(event: PointerEvent) { + const fromOffset = this.thumbFrom.nativeElement.offsetLeft + this.thumbFrom.nativeElement.offsetWidth / 2; + const toOffset = this.thumbTo.nativeElement.offsetLeft + this.thumbTo.nativeElement.offsetWidth / 2; + const xPointer = event.clientX - this._el.nativeElement.getBoundingClientRect().left; + const match = this.closestTo(xPointer, [fromOffset, toOffset]); + + if (fromOffset === toOffset && toOffset < xPointer) { + this.thumbTo.nativeElement.focus(); + } else if (fromOffset === toOffset && toOffset > xPointer) { + this.thumbFrom.nativeElement.focus(); + } else if (match === fromOffset) { + this.thumbFrom.nativeElement.focus(); + } else { + this.thumbTo.nativeElement.focus(); + } + } + + private setTickInterval() { + let tickInterval; + const trackProgress = 100; + + if (this.labelsViewEnabled) { + // Calc ticks depending on the labels length; + tickInterval = ((trackProgress / (this.labels.length - 1) * 10)) / 10; + } else { + const trackRange = this.maxValue - this.minValue; + tickInterval = this.step > 1 ? + (trackProgress / ((trackRange / this.step)) * 10) / 10 + : null; + } + + this.renderer.setStyle(this.ticks.nativeElement, 'stroke-dasharray', `0, ${tickInterval * Math.sqrt(2)}%`); + this.renderer.setStyle(this.ticks.nativeElement, 'visibility', this.continuous || tickInterval === null ? 'hidden' : 'visible'); + } + + private showSliderIndicators() { + if (this.disabled) { + return; + } + + if (this._indicatorsTimer) { + this._indicatorsDestroyer$.next(true); + this._indicatorsTimer = null; + } + + this.thumbTo.showThumbIndicators(); + this.labelTo.active = true; + if (this.thumbFrom) { + this.thumbFrom.showThumbIndicators(); + } + + if (this.labelFrom) { + this.labelFrom.active = true; + } + + } + + private hideSliderIndicators() { + if (this.disabled) { + return; + } + + this._indicatorsTimer = timer(this.thumbLabelVisibilityDuration); + this._indicatorsTimer.pipe(takeUntil(this._indicatorsDestroyer$)).subscribe(() => { + this.thumbTo.hideThumbIndicators(); + this.labelTo.active = false; + if (this.thumbFrom) { + this.thumbFrom.hideThumbIndicators(); + } + + if (this.labelFrom) { + this.labelFrom.active = false; + } + }); + } + + private toggleSliderIndicators() { + this.showSliderIndicators(); + this.hideSliderIndicators(); + } + + private changeThumbFocusableState(state: boolean) { + const value = state ? -1 : 0; + + if (this.isRange) { + this.thumbFrom.tabindex = value; + } + + this.thumbTo.tabindex = value; + + this._cdr.detectChanges(); + } + + private closestTo(goal: number, positions: number[]): number { + return positions.reduce((previous, current) => (Math.abs(goal - current) < Math.abs(goal - previous) ? current : previous)); + } + + private valueToFraction(value: number, pMin = this._pMin, pMax = this._pMax) { + return this.valueInRange((value - this.minValue) / (this.maxValue - this.minValue), pMin, pMax); + } + + private isNullishButNotZero(value: any): boolean { + return !value && value !== 0; + } + + /** + * @hidden + * Normalizе the value when two-way data bind is used and {@link this.step} is set. + * @param value + */ + private normalizeByStep(value: IRangeSliderValue | number) { + if (this.isRange) { + this._value = { + lower: Math.floor((value as IRangeSliderValue).lower / this.step) * this.step, + upper: Math.floor((value as IRangeSliderValue).upper / this.step) * this.step + }; + } else { + this._value = Math.floor((value as number) / this.step) * this.step; + } + } + + private updateTrack() { + const fromPosition = this.valueToFraction(this.lowerValue); + const toPosition = this.valueToFraction(this.upperValue); + const positionGap = toPosition - fromPosition; + + let trackLeftIndention = fromPosition; + if (this.isRange) { + if (positionGap) { + trackLeftIndention = Math.round((1 / positionGap * fromPosition) * 100); + } + + trackLeftIndention = this._dir.rtl ? -trackLeftIndention : trackLeftIndention; + this.renderer.setStyle(this.trackRef.nativeElement, 'transform', `scaleX(${positionGap}) translateX(${trackLeftIndention}%)`); + } else { + this.renderer.setStyle(this.trackRef.nativeElement, 'transform', `scaleX(${toPosition})`); + } + } + + private subscribeToEvents(thumb: IgxSliderThumbComponent) { + if (!thumb) { + return; + } + + thumb.thumbValueChange + .pipe(takeUntil(this.unsubscriber(thumb))) + .subscribe(value => this.thumbChanged(value, thumb.type)); + + thumb.thumbBlur + .pipe(takeUntil(this.unsubscriber(thumb))) + .subscribe(() => this._onTouchedCallback()); + } + + private unsubscriber(thumb: IgxSliderThumbComponent) { + return merge(this._destroyer$, thumb.destroy); + } + + private hasValueChanged(oldValue) { + const isSliderWithDifferentValue: boolean = !this.isRange && oldValue !== this.value; + const isRangeWithOneDifferentValue: boolean = this.isRange && + ((oldValue as IRangeSliderValue).lower !== (this.value as IRangeSliderValue).lower || + (oldValue as IRangeSliderValue).upper !== (this.value as IRangeSliderValue).upper); + + return isSliderWithDifferentValue || isRangeWithOneDifferentValue; + } + + private emitValueChange(oldValue: number | IRangeSliderValue) { + this.valueChange.emit({ oldValue, value: this.value }); + } +} + +/** + * @hidden + */ + diff --git a/projects/igniteui-angular/slider/src/slider/slider.module.ts b/projects/igniteui-angular/slider/src/slider/slider.module.ts new file mode 100644 index 00000000000..755d691578b --- /dev/null +++ b/projects/igniteui-angular/slider/src/slider/slider.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { IGX_SLIDER_DIRECTIVES } from './public_api'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + ...IGX_SLIDER_DIRECTIVES + ], + exports: [ + ...IGX_SLIDER_DIRECTIVES + ] +}) +export class IgxSliderModule { } diff --git a/projects/igniteui-angular/slider/src/slider/thumb/thumb-slider.component.html b/projects/igniteui-angular/slider/src/slider/thumb/thumb-slider.component.html new file mode 100644 index 00000000000..ae88b00ecde --- /dev/null +++ b/projects/igniteui-angular/slider/src/slider/thumb/thumb-slider.component.html @@ -0,0 +1 @@ +
    diff --git a/projects/igniteui-angular/slider/src/slider/thumb/thumb-slider.component.ts b/projects/igniteui-angular/slider/src/slider/thumb/thumb-slider.component.ts new file mode 100644 index 00000000000..b744b5eb050 --- /dev/null +++ b/projects/igniteui-angular/slider/src/slider/thumb/thumb-slider.component.ts @@ -0,0 +1,308 @@ +import { Component, Input, HostListener, ElementRef, HostBinding, Output, EventEmitter, OnInit, OnDestroy, TemplateRef, booleanAttribute, inject } from '@angular/core'; +import { takeUntil } from 'rxjs/operators'; +import { SliderHandle } from '../slider.common'; +import { Subject } from 'rxjs'; +import { NgClass } from '@angular/common'; +import { ɵIgxDirectionality } from 'igniteui-angular/core'; + +/** + * @hidden + */ +@Component({ + selector: 'igx-thumb', + templateUrl: 'thumb-slider.component.html', + imports: [NgClass] +}) +export class IgxSliderThumbComponent implements OnInit, OnDestroy { + private _elementRef = inject(ElementRef); + private _dir = inject(ɵIgxDirectionality); + + @Input() + public value: any; + + @Input({ transform: booleanAttribute }) + public continuous: boolean; + + @Input() + public thumbLabelVisibilityDuration: number; + + @Input({ transform: booleanAttribute }) + public disabled: boolean; + + @Input() + public onPan: Subject; + + @Input() + public stepDistance: number; + + @Input() + public step: number; + + @Input() + public templateRef: TemplateRef; + + @Input() + public context: any; + + @Input() + public type: SliderHandle; + + @Input({ transform: booleanAttribute }) + public deactiveState: boolean; + + @Input() + public min: number; + + @Input() + public max: number; + + @Input() + public labels: any[]; + + @Output() + public thumbValueChange = new EventEmitter(); + + @Output() + public thumbChange = new EventEmitter(); + + @Output() + public thumbBlur = new EventEmitter(); + + @Output() + public hoverChange = new EventEmitter(); + + @HostBinding('attr.tabindex') + public tabindex = 0; + + @HostBinding('attr.role') + public role = 'slider'; + + @HostBinding('attr.aria-valuenow') + public get ariaValueNow() { + return this.value; + } + + @HostBinding('attr.aria-valuemin') + public get ariaValueMin() { + return this.min; + } + + @HostBinding('attr.aria-valuemax') + public get ariaValueMax() { + return this.max; + } + + @HostBinding('attr.aria-valuetext') + public get ariaValueText() { + if (this.labels && this.labels[this.value] !== undefined) { + return this.labels[this.value]; + } + return this.value; + } + + @HostBinding('attr.aria-label') + public get ariaLabelAttr() { + return `Slider thumb ${this.type}`; + } + + @HostBinding('attr.aria-orientation') + public ariaOrientation = 'horizontal'; + + @HostBinding(`attr.aria-disabled`) + public get ariaDisabled() { + return this.disabled; + } + + @HostBinding('attr.z-index') + public zIndex = 0; + + @HostBinding('class.igx-slider-thumb-to--focused') + public focused = false; + + @HostBinding('class.igx-slider-thumb-from') + public get thumbFromClass() { + return this.type === SliderHandle.FROM; + } + + @HostBinding('class.igx-slider-thumb-to') + public get thumbToClass() { + return this.type === SliderHandle.TO; + } + + @HostBinding('class.igx-slider-thumb-from--active') + public get thumbFromActiveClass() { + return this.type === SliderHandle.FROM && this._isActive; + } + + @HostBinding('class.igx-slider-thumb-to--active') + public get thumbToActiveClass() { + return this.type === SliderHandle.TO && this._isActive; + } + + @HostBinding('class.igx-slider-thumb-from--disabled') + public get thumbFromDisabledClass() { + return this.type === SliderHandle.FROM && this.disabled; + } + + @HostBinding('class.igx-slider-thumb-to--disabled') + public get thumbToDisabledClass() { + return this.type === SliderHandle.TO && this.disabled; + } + + @HostBinding('class.igx-slider-thumb-from--pressed') + public get thumbFromPressedClass() { + return this.type === SliderHandle.FROM && this.isActive && this._isPressed; + } + + @HostBinding('class.igx-slider-thumb-to--pressed') + public get thumbToPressedClass() { + return this.type === SliderHandle.TO && this.isActive && this._isPressed; + } + + public get getDotClass() { + return { + 'igx-slider-thumb-from__dot': this.type === SliderHandle.FROM, + 'igx-slider-thumb-to__dot': this.type === SliderHandle.TO + }; + } + + public isActive = false; + + public get nativeElement() { + return this._elementRef.nativeElement; + } + + public get destroy(): Subject { + return this._destroy$; + } + + private _isActive = false; + private _isPressed = false; + private _destroy$ = new Subject(); + + private get thumbPositionX() { + const thumbBounderies = this.nativeElement.getBoundingClientRect(); + const thumbCenter = (thumbBounderies.right - thumbBounderies.left) / 2; + return thumbBounderies.left + thumbCenter; + } + + @HostListener('pointerenter') + public onPointerEnter() { + this.focused = false; + this.hoverChange.emit(true); + } + + @HostListener('pointerleave') + public onPointerLeave() { + this.hoverChange.emit(false); + } + + @HostListener('keyup', ['$event']) + public onKeyUp(event: KeyboardEvent) { + event.stopPropagation(); + this.focused = true; + } + + @HostListener('keydown', ['$event']) + public onKeyDown(event: KeyboardEvent) { + if (this.disabled) { + return; + } + + let increment = 0; + const stepWithDir = (rtl: boolean) => rtl ? this.step * -1 : this.step; + if (event.key.endsWith('Left')) { + increment = stepWithDir(!this._dir.rtl); + } else if (event.key.endsWith('Right')) { + increment = stepWithDir(this._dir.rtl); + } else { + return; + } + + this.thumbChange.emit(); + this.thumbValueChange.emit(increment); + } + + @HostListener('blur') + public onBlur() { + this.isActive = false; + this.zIndex = 0; + this.focused = false; + this.thumbBlur.emit(); + } + + @HostListener('focus') + public onFocusListener() { + this.isActive = true; + this.zIndex = 1; + } + + /** + * @hidden + */ + public ngOnInit() { + this.onPan + .pipe(takeUntil(this._destroy$)) + .subscribe(mouseX => + this.updateThumbValue(mouseX) + ); + } + + /** + * @hidden + */ + public ngOnDestroy() { + this._destroy$.next(true); + this._destroy$.complete(); + } + + /** + * Show thumb label and ripple. + */ + public showThumbIndicators() { + this.toggleThumbIndicators(true); + } + + /** + * Hide thumb label and ripple. + */ + public hideThumbIndicators() { + this.toggleThumbIndicators(false); + } + + private updateThumbValue(mouseX: number) { + const updateValue = this.calculateTrackUpdate(mouseX); + if (this.isActive && updateValue !== 0) { + this.thumbValueChange.emit(updateValue); + } + } + + private calculateTrackUpdate(mouseX: number): number { + const scaleX = this._dir.rtl ? this.thumbPositionX - mouseX : mouseX - this.thumbPositionX; + const stepDistanceCenter = this.stepDistance / 2; + + // If the thumb scale range (slider update) is less thàn a half step, + // the position stays the same. + const scaleXPositive = Math.abs(scaleX); + if (scaleXPositive < stepDistanceCenter) { + return 0; + } + + return this.stepToProceed(scaleX, this.stepDistance); + } + + private stepToProceed(scaleX, stepDist) { + return Math.round(scaleX / stepDist) * this.step; + } + + private toggleThumbIndicators(visible: boolean) { + this._isPressed = visible; + + if (this.continuous || this.deactiveState) { + this._isActive = false; + } else { + this._isActive = visible; + } + + } +} diff --git a/projects/igniteui-angular/slider/src/slider/ticks/tick.pipe.ts b/projects/igniteui-angular/slider/src/slider/ticks/tick.pipe.ts new file mode 100644 index 00000000000..ac9979dea12 --- /dev/null +++ b/projects/igniteui-angular/slider/src/slider/ticks/tick.pipe.ts @@ -0,0 +1,28 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +/** + * @hidden + */ +@Pipe({ + name: 'spreadTickLabels', + standalone: true +}) +export class IgxTickLabelsPipe implements PipeTransform { + + + public transform(labels: Array, secondaryTicks: number) { + if (!labels) { + return; + } + + const result = []; + labels.forEach(item => { + result.push(item); + for (let i = 0; i < secondaryTicks; i++) { + result.push(''); + } + }); + + return result; + } +} diff --git a/projects/igniteui-angular/slider/src/slider/ticks/ticks.component.html b/projects/igniteui-angular/slider/src/slider/ticks/ticks.component.html new file mode 100644 index 00000000000..538b8a38a96 --- /dev/null +++ b/projects/igniteui-angular/slider/src/slider/ticks/ticks.component.html @@ -0,0 +1,13 @@ +@for (n of [].constructor(ticksLength); track $index; let idx = $index) { +
    +
    + + + +
    +
    +} + + + {{ value }} + diff --git a/projects/igniteui-angular/slider/src/slider/ticks/ticks.component.ts b/projects/igniteui-angular/slider/src/slider/ticks/ticks.component.ts new file mode 100644 index 00000000000..f95a509a261 --- /dev/null +++ b/projects/igniteui-angular/slider/src/slider/ticks/ticks.component.ts @@ -0,0 +1,143 @@ +import { Component, Input, TemplateRef, HostBinding, booleanAttribute } from '@angular/core'; +import { TicksOrientation, TickLabelsOrientation } from '../slider.common'; +import { NgClass, NgTemplateOutlet } from '@angular/common'; + +/** + * @hidden + */ +@Component({ + selector: 'igx-ticks', + templateUrl: 'ticks.component.html', + imports: [NgClass, NgTemplateOutlet] +}) +export class IgxTicksComponent { + @Input() + public primaryTicks: number; + + @Input() + public secondaryTicks: number; + + @Input({ transform: booleanAttribute }) + public primaryTickLabels: boolean; + + @Input({ transform: booleanAttribute }) + public secondaryTickLabels: boolean; + + @Input() + public ticksOrientation: TicksOrientation; + + @Input() + public tickLabelsOrientation: TickLabelsOrientation; + + @Input() + public maxValue: number; + + @Input() + public minValue: number; + + @Input({ transform: booleanAttribute }) + public labelsViewEnabled: boolean; + + @Input() + public labels: Array; + + @Input() + public tickLabelTemplateRef: TemplateRef; + + /** + * @hidden + */ + @HostBinding('class.igx-slider__ticks') + public ticksClass = true; + + /** + * @hidden + */ + @HostBinding('class.igx-slider__ticks--top') + public get ticksTopClass() { + return this.ticksOrientation === TicksOrientation.Top; + } + + /** + * @hidden + */ + @HostBinding('class.igx-slider__ticks--tall') + public get hasPrimaryClass() { + return this.primaryTicks > 0; + } + + /** + * @hidden + */ + @HostBinding('class.igx-slider__tick-labels--top-bottom') + public get labelsTopToBottomClass() { + return this.tickLabelsOrientation === TickLabelsOrientation.TopToBottom; + } + + /** + * @hidden + */ + @HostBinding('class.igx-slider__tick-labels--bottom-top') + public get labelsBottomToTopClass() { + return this.tickLabelsOrientation === TickLabelsOrientation.BottomToTop; + } + + /** + * Returns the template context corresponding to + * {@link IgxTickLabelTemplateDirective} + * + * ```typescript + * return { + * $implicit //returns the value per each tick label. + * isPrimery //returns if the tick is primary. + * labels // returns the {@link labels} collection. + * index // returns the index per each tick of the whole sequence. + * } + * ``` + * + * @param idx the index per each tick label. + */ + public context(idx: number): any { + return { + $implicit: this.tickLabel(idx), + isPrimary: this.isPrimary(idx), + labels: this.labels, + index: idx + }; + } + + /** + * @hidden + */ + public get ticksLength() { + return this.primaryTicks > 0 ? + ((this.primaryTicks - 1) * this.secondaryTicks) + this.primaryTicks : + this.secondaryTicks > 0 ? this.secondaryTicks : 0; + } + + public hiddenTickLabels(idx: number) { + return this.isPrimary(idx) ? this.primaryTickLabels : this.secondaryTickLabels; + } + + /** + * @hidden + */ + public isPrimary(idx: number) { + return this.primaryTicks <= 0 ? false : + idx % (this.secondaryTicks + 1) === 0; + } + + /** + * @hidden + */ + public tickLabel(idx: number) { + if (this.labelsViewEnabled) { + return this.labels[idx]; + } + + const labelStep = (Math.max(this.minValue, this.maxValue) - Math.min(this.minValue, this.maxValue)) / (this.ticksLength - 1); + const labelVal = labelStep * idx; + + return (this.minValue + labelVal).toFixed(2); + } +} diff --git a/projects/igniteui-angular/snackbar/README.md b/projects/igniteui-angular/snackbar/README.md new file mode 100644 index 00000000000..e475d13e18d --- /dev/null +++ b/projects/igniteui-angular/snackbar/README.md @@ -0,0 +1,56 @@ +# igx-snackbar + +**igx-snackbar** provides feedback about an operation by showing a brief message at the bottom of the screen on mobile and lower left on larger devices. IgxSnackbar will appear above all other elements on screen and only one can be displayed at a time. +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/snackbar) + +# Usage + +## Simple Snackbar + +```html + + + + +``` + +You can be more descriptive and set a message `message="This is a simple snackbar!"`. + +You can show the snackbar by using `snackbar.open()` method. + + +## Snackbar with button and action + +```html + + + + +``` +You can set the id of the component by `id="Snackbar"`, otherwise it will be automatically generated. + +You can set the title of the button by setting `actionName="Dismiss"`. + +You can hide the Snackbar by using `snackbar.close()` method. + +By default, the IgxSnackbar will be automatically hidden after 4000 milliseconds. The automatic hiding behavior can be controlled via the following attributes: + - `autoHide` - whether the snackbar should be hidden after a certain time interval. + - `displayTime` - the time interval in which the snackbar would hide. + + +## Snackbar with custom content + +```html + + + + Custom content + +``` +You can display custom content by adding elements inside the snackbar. diff --git a/projects/igniteui-angular/snackbar/index.ts b/projects/igniteui-angular/snackbar/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/snackbar/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/snackbar/ng-package.json b/projects/igniteui-angular/snackbar/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/snackbar/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/snackbar/src/public_api.ts b/projects/igniteui-angular/snackbar/src/public_api.ts new file mode 100644 index 00000000000..b584d6623f9 --- /dev/null +++ b/projects/igniteui-angular/snackbar/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './snackbar/public_api'; +export * from './snackbar/snackbar.module'; diff --git a/projects/igniteui-angular/snackbar/src/snackbar/public_api.ts b/projects/igniteui-angular/snackbar/src/snackbar/public_api.ts new file mode 100644 index 00000000000..ce032878293 --- /dev/null +++ b/projects/igniteui-angular/snackbar/src/snackbar/public_api.ts @@ -0,0 +1,2 @@ +export * from './snackbar.component'; +export * from './snackbar.module'; diff --git a/projects/igniteui-angular/snackbar/src/snackbar/snackbar.component.html b/projects/igniteui-angular/snackbar/src/snackbar/snackbar.component.html new file mode 100644 index 00000000000..e712098f9fc --- /dev/null +++ b/projects/igniteui-angular/snackbar/src/snackbar/snackbar.component.html @@ -0,0 +1,12 @@ +
    + {{ textMessage }} + +
    +
    + +
    +@if (!customButton.children.length && actionText) { + +} diff --git a/projects/igniteui-angular/snackbar/src/snackbar/snackbar.component.spec.ts b/projects/igniteui-angular/snackbar/src/snackbar/snackbar.component.spec.ts new file mode 100644 index 00000000000..d8cf22d0adb --- /dev/null +++ b/projects/igniteui-angular/snackbar/src/snackbar/snackbar.component.spec.ts @@ -0,0 +1,275 @@ +import { Component, ViewChild } from '@angular/core'; +import { TestBed, fakeAsync, tick, waitForAsync, ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxSnackbarComponent } from './snackbar.component'; +import { useAnimation } from '@angular/animations'; +import { HorizontalAlignment, PositionSettings, VerticalAlignment } from 'igniteui-angular/core'; +import { slideInLeft, slideInRight } from 'igniteui-angular/animations'; +import { IgxButtonDirective } from '../../../directives/src/directives/button/button.directive'; + +describe('IgxSnackbar', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + SnackbarInitializeTestComponent, + SnackbarCustomContentComponent + ] + }).compileComponents(); + })); + + let fixture: ComponentFixture; + let snackbar: IgxSnackbarComponent; + let domSnackbar; + beforeEach(() => { + fixture = TestBed.createComponent(SnackbarInitializeTestComponent); + fixture.detectChanges(); + snackbar = fixture.componentInstance.snackbar; + domSnackbar = fixture.debugElement.query(By.css('igx-snackbar')).nativeElement; + }); + + it('should properly initialize properties', () => { + expect(snackbar.id).toContain('igx-snackbar-'); + expect(snackbar.actionText).toBeUndefined(); + expect(snackbar.displayTime).toBe(4000); + expect(snackbar.autoHide).toBeTruthy(); + expect(snackbar.isVisible).toBeFalsy(); + + expect(domSnackbar.id).toContain('igx-snackbar-'); + snackbar.id = 'customId'; + fixture.detectChanges(); + + expect(snackbar.id).toBe('customId'); + expect(domSnackbar.id).toBe('customId'); + }); + + it('should auto hide 1 second after is open', fakeAsync(() => { + spyOn(snackbar.closing, 'emit'); + const displayTime = 1000; + snackbar.displayTime = displayTime; + fixture.detectChanges(); + snackbar.open(); + + fixture.detectChanges(); + expect(snackbar.isVisible).toBeTruthy(); + expect(snackbar.autoHide).toBeTruthy(); + + tick(1000); + fixture.detectChanges(); + expect(snackbar.isVisible).toBeFalsy(); + expect(snackbar.closing.emit).toHaveBeenCalled(); + })); + + it('should not auto hide 1 second after is open', fakeAsync(() => { + spyOn(snackbar.closing, 'emit'); + const displayTime = 1000; + snackbar.displayTime = displayTime; + snackbar.autoHide = false; + snackbar.open(); + + fixture.detectChanges(); + expect(snackbar.isVisible).toBeTruthy(); + expect(snackbar.autoHide).toBeFalsy(); + + tick(1000); + fixture.detectChanges(); + expect(snackbar.isVisible).toBeTruthy(); + expect(snackbar.closing.emit).not.toHaveBeenCalled(); + snackbar.close(); + })); + + it('should trigger on action', fakeAsync(() => { + snackbar.actionText = 'undo'; + snackbar.displayTime = 100; + spyOn(snackbar.clicked, 'emit'); + + snackbar.open(); + tick(100); + fixture.detectChanges(); + + fixture.debugElement.query(By.css('button')).nativeElement.click(); + fixture.detectChanges(); + + expect(snackbar.clicked.emit).toHaveBeenCalledWith(snackbar); + })); + + it('should emit opening when snackbar is shown', fakeAsync(() => { + spyOn(snackbar.opening, 'emit'); + snackbar.open(); + tick(100); + expect(snackbar.opening.emit).toHaveBeenCalled(); + snackbar.close(); + })); + + it('should emit onOpened when snackbar is opened', fakeAsync(() => { + snackbar.displayTime = 100; + snackbar.autoHide = false; + spyOn(snackbar.opened, 'emit'); + snackbar.open(); + tick(100); + fixture.detectChanges(); + expect(snackbar.opened.emit).toHaveBeenCalled(); + snackbar.close(); + })); + + it('should emit closing when snackbar is hidden', () => { + spyOn(snackbar.closing, 'emit'); + snackbar.open(); + snackbar.close(); + expect(snackbar.closing.emit).toHaveBeenCalled(); + }); + + it('should emit onClosed when snackbar is closed', fakeAsync(() => { + snackbar.displayTime = 100; + snackbar.autoHide = false; + spyOn(snackbar.closed, 'emit'); + snackbar.open(); + snackbar.close(); + tick(100); + fixture.detectChanges(); + expect(snackbar.closed.emit).toHaveBeenCalled(); + })); + + it('should be opened and closed by the toggle method', fakeAsync(() => { + snackbar.displayTime = 100; + snackbar.autoHide = false; + + snackbar.toggle(); + tick(100); + expect(snackbar.isVisible).toBeTrue(); + expect(snackbar.collapsed).toBeFalse(); + + snackbar.toggle(); + tick(100); + expect(snackbar.isVisible).toBeFalse(); + expect(snackbar.collapsed).toBeTrue(); + })); + + it('can set snackbar message through open method', fakeAsync(() => { + snackbar.displayTime = 100; + snackbar.autoHide = false; + + snackbar.open('Custom Message'); + tick(100); + fixture.detectChanges(); + expect(snackbar.isVisible).toBeTruthy(); + + expect(snackbar.autoHide).toBeFalsy(); + expect(snackbar.textMessage).toBe('Custom Message'); + snackbar.close(); + })); + it('should be able to set custom positionSettings', () => { + const defaultPositionSettings = snackbar.positionSettings; + const defaulOpenAnimationParams = {duration: '.35s', easing: 'cubic-bezier(0.0, 0.0, 0.2, 1)', + fromPosition: 'translateY(100%)', toPosition: 'translateY(0)'}; + expect(defaultPositionSettings.horizontalDirection).toBe(-0.5); + expect(defaultPositionSettings.verticalDirection).toBe(0); + expect(defaultPositionSettings.openAnimation.options.params).toEqual(defaulOpenAnimationParams); + const newPositionSettings: PositionSettings = { + openAnimation: useAnimation(slideInLeft, { params: { duration: '1000ms' } }), + closeAnimation: useAnimation(slideInRight, { params: { duration: '1000ms' } }), + horizontalDirection: HorizontalAlignment.Center, + verticalDirection: VerticalAlignment.Middle, + horizontalStartPoint: HorizontalAlignment.Center, + verticalStartPoint: VerticalAlignment.Middle, + minSize: { height: 100, width: 100 } + }; + snackbar.positionSettings = newPositionSettings; + fixture.detectChanges(); + const customPositionSettings = snackbar.positionSettings; + expect(customPositionSettings.horizontalDirection).toBe(-0.5); + expect(customPositionSettings.verticalDirection).toBe(-0.5); + expect(customPositionSettings.openAnimation.options.params).toEqual({duration: '1000ms'}); + expect(customPositionSettings.minSize).toEqual({height: 100, width: 100}); + }); +}); + +describe('IgxSnackbar with custom content', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + SnackbarCustomContentComponent + ] + }).compileComponents(); + })); + + let fixture: ComponentFixture; + let snackbar: IgxSnackbarComponent; + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(SnackbarCustomContentComponent); + fixture.detectChanges(); + snackbar = fixture.componentInstance.snackbar; + })); + + it('should display a message, a custom content element and a button', () => { + fixture.componentInstance.text = 'Undo'; + snackbar.open('Item shown'); + fixture.detectChanges(); + + const messageEl = fixture.debugElement.query(By.css('.igx-snackbar__message')); + expect(messageEl.nativeElement.innerText).toContain('Item shown'); + + const customContent = fixture.debugElement.query(By.css('.igx-snackbar__content')); + expect(customContent).toBeTruthy('Custom content is not found'); + + // Verify the custom button is displayed instead of the snackbar actionText + const button = fixture.debugElement.query(By.css('.igx-button')); + expect(button.nativeElement.innerText).toEqual('Read More'); + expect(button.nativeElement.innerText).not.toContain(snackbar.actionText); + + // Verify the message is displayed on the left side of the custom content + const messageElRect = messageEl.nativeElement.getBoundingClientRect(); + const customContentRect = customContent.nativeElement.getBoundingClientRect(); + expect(messageElRect.left <= customContentRect.left).toBe(true, 'The message is not on the left of the custom content'); + + // Verify the custom content element is on the left side of the button + const buttonRect = button.nativeElement.getBoundingClientRect(); + expect(customContentRect.right <= buttonRect.left).toBe(true, 'The custom element is not on the left of the button'); + expect(messageElRect.right <= buttonRect.left).toBe(true, 'The button is not on the right side of the snackbar content'); + snackbar.close(); + }); + + it('should be able to set a message though open method', () => { + snackbar.autoHide = false; + fixture.componentInstance.text = 'Retry'; + fixture.detectChanges(); + + snackbar.open('The message was not send. Would you like to retry?'); + fixture.detectChanges(); + + const messageEl = fixture.debugElement.query(By.css('.igx-snackbar__message')); + expect(messageEl.nativeElement.innerText).toBe('The message was not send. Would you like to retry? Custom content'); + snackbar.close(); + + snackbar.open('Another Message?!'); + fixture.detectChanges(); + + expect(snackbar.isVisible).toBe(true); + expect(messageEl.nativeElement.innerText).toBe('Another Message?! Custom content'); + snackbar.close(); + }); +}); + +@Component({ + template: ` + `, + imports: [IgxSnackbarComponent] +}) +class SnackbarInitializeTestComponent { + @ViewChild(IgxSnackbarComponent, { static: true }) public snackbar: IgxSnackbarComponent; + public text: string; +} + +@Component({ + template: ` + Custom content + + `, + imports: [IgxSnackbarComponent, IgxButtonDirective] +}) +class SnackbarCustomContentComponent { + @ViewChild(IgxSnackbarComponent, { static: true }) public snackbar: IgxSnackbarComponent; + public text: string; +} diff --git a/projects/igniteui-angular/snackbar/src/snackbar/snackbar.component.ts b/projects/igniteui-angular/snackbar/src/snackbar/snackbar.component.ts new file mode 100644 index 00000000000..cd7b50468fe --- /dev/null +++ b/projects/igniteui-angular/snackbar/src/snackbar/snackbar.component.ts @@ -0,0 +1,200 @@ +import { useAnimation } from '@angular/animations'; +import { + Component, + EventEmitter, + HostBinding, + Input, + OnInit, + Output +} from '@angular/core'; +import { takeUntil } from 'rxjs/operators'; +import { ContainerPositionStrategy, GlobalPositionStrategy, HorizontalAlignment, + PositionSettings, VerticalAlignment } from 'igniteui-angular/core'; +import { ToggleViewEventArgs, IgxButtonDirective, IgxNotificationsDirective } from 'igniteui-angular/directives'; +import { fadeIn, fadeOut } from 'igniteui-angular/animations'; + +let NEXT_ID = 0; +/** + * **Ignite UI for Angular Snackbar** - + * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/snackbar.html) + * + * The Ignite UI Snack Bar provides feedback about an operation with a single-line message, which can + * include a link to an action such as Undo. + * + * Example: + * ```html + * + *
    + * + * Message sent + * + *
    + * ``` + */ +@Component({ + selector: 'igx-snackbar', + templateUrl: 'snackbar.component.html', + imports: [IgxButtonDirective] +}) +export class IgxSnackbarComponent extends IgxNotificationsDirective + implements OnInit { + /** + * Sets/gets the `id` of the snackbar. + * If not set, the `id` of the first snackbar component will be `"igx-snackbar-0"`; + * ```html + * + * ``` + * ```typescript + * let snackbarId = this.snackbar.id; + * ``` + * + * @memberof IgxSnackbarComponent + */ + @HostBinding('attr.id') + @Input() + public override id = `igx-snackbar-${NEXT_ID++}`; + + /** + * The default css class applied to the component. + * + * @hidden + * @internal + */ + @HostBinding('class.igx-snackbar') + public cssClass = 'igx-snackbar'; + + /** + * Sets/gets the `actionText` attribute. + * ```html + * + * ``` + */ + @Input() public actionText?: string; + + /** + * An event that will be emitted when the action button is clicked. + * Provides reference to the `IgxSnackbarComponent` as an argument. + * ```html + * + * ``` + */ + @Output() + public clicked = new EventEmitter(); + + /** + * An event that will be emitted when the snackbar animation starts. + * Provides reference to the `ToggleViewEventArgs` interface as an argument. + * ```html + * + * ``` + */ + @Output() public animationStarted = new EventEmitter(); + + /** + * An event that will be emitted when the snackbar animation ends. + * Provides reference to the `ToggleViewEventArgs` interface as an argument. + * ```html + * + * ``` + */ + @Output() public animationDone = new EventEmitter(); + + /** + * Get the position and animation settings used by the snackbar. + * ```typescript + * @ViewChild('snackbar', { static: true }) public snackbar: IgxSnackbarComponent; + * let currentPosition: PositionSettings = this.snackbar.positionSettings + * ``` + */ + @Input() + public get positionSettings(): PositionSettings { + return this._positionSettings; + } + + /** + * Set the position and animation settings used by the snackbar. + * ```html + * + * ``` + * ```typescript + * import { slideInTop, slideOutBottom } from 'igniteui-angular'; + * ... + * @ViewChild('snackbar', { static: true }) public snackbar: IgxSnackbarComponent; + * public newPositionSettings: PositionSettings = { + * openAnimation: useAnimation(slideInTop, { params: { duration: '1000ms', fromPosition: 'translateY(100%)'}}), + * closeAnimation: useAnimation(slideOutBottom, { params: { duration: '1000ms', fromPosition: 'translateY(0)'}}), + * horizontalDirection: HorizontalAlignment.Left, + * verticalDirection: VerticalAlignment.Middle, + * horizontalStartPoint: HorizontalAlignment.Left, + * verticalStartPoint: VerticalAlignment.Middle, + * minSize: { height: 100, width: 100 } + * }; + * this.snackbar.positionSettings = this.newPositionSettings; + * ``` + */ + public set positionSettings(settings: PositionSettings) { + this._positionSettings = settings; + } + + private _positionSettings: PositionSettings = { + horizontalDirection: HorizontalAlignment.Center, + verticalDirection: VerticalAlignment.Bottom, + openAnimation: useAnimation(fadeIn, { params: { duration: '.35s', easing: 'cubic-bezier(0.0, 0.0, 0.2, 1)', + fromPosition: 'translateY(100%)', toPosition: 'translateY(0)'} }), + closeAnimation: useAnimation(fadeOut, { params: { duration: '.2s', easing: 'cubic-bezier(0.4, 0.0, 1, 1)', + fromPosition: 'translateY(0)', toPosition: 'translateY(100%)'} }), + }; + + /** + * Shows the snackbar and hides it after the `displayTime` is over if `autoHide` is set to `true`. + * ```typescript + * this.snackbar.open(); + * ``` + */ + public override open(message?: string) { + if (message !== undefined) { + this.textMessage = message; + } + + this.strategy = this.outlet ? new ContainerPositionStrategy(this.positionSettings) + : new GlobalPositionStrategy(this.positionSettings); + super.open(); + } + + /** + * Opens or closes the snackbar, depending on its current state. + * + * ```typescript + * this.snackbar.toggle(); + * ``` + */ + public override toggle() { + if (this.collapsed || this.isClosing) { + this.open(); + } else { + this.close(); + } + } + + /** + * @hidden + */ + public triggerAction(): void { + this.clicked.emit(this); + } + + /** + * @hidden + */ + public override ngOnInit() { + this.opened.pipe(takeUntil(this.destroy$)).subscribe(() => { + const openedEventArgs: ToggleViewEventArgs = { owner: this, id: this._overlayId }; + this.animationStarted.emit(openedEventArgs); + }); + + this.closed.pipe(takeUntil(this.destroy$)).subscribe(() => { + const closedEventArgs: ToggleViewEventArgs = { owner: this, id: this._overlayId }; + this.animationDone.emit(closedEventArgs); + }); + } +} diff --git a/projects/igniteui-angular/snackbar/src/snackbar/snackbar.module.ts b/projects/igniteui-angular/snackbar/src/snackbar/snackbar.module.ts new file mode 100644 index 00000000000..0ba2bea7e20 --- /dev/null +++ b/projects/igniteui-angular/snackbar/src/snackbar/snackbar.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { IgxSnackbarComponent } from './snackbar.component'; + +/** + * @hidden + * @deprecated + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + IgxSnackbarComponent + ], + exports: [ + IgxSnackbarComponent + ] +}) +export class IgxSnackbarModule { } diff --git a/projects/igniteui-angular/splitter/README.md b/projects/igniteui-angular/splitter/README.md new file mode 100644 index 00000000000..f55d76356c9 --- /dev/null +++ b/projects/igniteui-angular/splitter/README.md @@ -0,0 +1,110 @@ +# igx-splitter + +Responsive layout component for Ignite UI for Angular. + +This entry point exposes the splitter component and supporting panes used to divide content horizontally or vertically with live resizing and optional collapse behavior. + +## Getting Started + +```ts +import { Component } from '@angular/core'; +import { IGX_SPLITTER_DIRECTIVES, SplitterType } from 'igniteui-angular/splitter'; + +@Component({ + selector: 'app-split-layout', + standalone: true, + imports: [IGX_SPLITTER_DIRECTIVES], + template: ` + + Navigation + Content + + ` +}) +export class SplitLayoutComponent { + public orientation = SplitterType.Horizontal; +} +``` + +> Prefer `IGX_SPLITTER_DIRECTIVES` for standalone components. For NgModule-based apps import `IgxSplitterModule` from the same package. + +## Basic Configuration + +```html + + + + Filters + + + + Details + + + +``` + +1. Bind `type` to `SplitterType.Horizontal` or `SplitterType.Vertical` to control orientation. +2. Provide optional `minSize`, `maxSize`, or `size` values (px or %), giving the layout deterministic behavior. +3. Use the resize events to update persisted layout settings or trigger data refreshes. +4. Toggle panes by binding to `collapsed` or calling the `toggle()` helper on the pane instance. + +## Customization + +- **Non-collapsible bars** – set `nonCollapsible` on the splitter or bar to hide expander affordances when panes must stay visible. +- **Keyboard support** – users can resize with arrow keys; combine with `ctrl` to collapse panes for accessibility. +- **Drag constraints** – `minSize` and `maxSize` enforce boundaries while resizing, ensuring important content stays visible. +- **Custom order** – bind `order` on panes or bars to change layout stacking in complex UIs. + +## API Reference + +### IgxSplitterComponent inputs + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `type` | `SplitterType` | `SplitterType.Horizontal` | Orientation of the splitter (`Horizontal` renders a row layout, `Vertical` renders a column layout). | +| `nonCollapsible` | `boolean` | `false` | Hides collapse/expand affordances on splitter bars. | + +### IgxSplitterComponent outputs + +| Event | Payload | Description | +| --- | --- | --- | +| `resizeStart` | `ISplitterBarResizeEventArgs` | Fires when a drag gesture begins; exposes the active pane and its sibling. | +| `resizing` | `ISplitterBarResizeEventArgs` | Emits while dragging to allow live layout updates. | +| `resizeEnd` | `ISplitterBarResizeEventArgs` | Emits after the drag completes with the final pane references. | + +### IgxSplitterComponent properties + +- `panes: QueryList` – runtime access to the pane collection for advanced scenarios (saving layout, programmatic collapse). + +### IgxSplitterPaneComponent inputs + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `size` | `string` | `'auto'` | Desired pane size (`px` or `%`). Automatically recalculated during drag. | +| `minSize` | `string` | `undefined` | Minimum allowed size for the pane. | +| `maxSize` | `string` | `undefined` | Maximum allowed size for the pane. | +| `resizable` | `boolean` | `true` | Prevents drag interactions when set to `false`. | +| `collapsed` | `boolean` | `false` | Controls pane visibility. Collapsed panes free space for siblings. | + +### IgxSplitterPaneComponent outputs + +| Event | Payload | Description | +| --- | --- | --- | +| `collapsedChange` | `boolean` | Fires whenever the pane collapses or expands. | + +### IgxSplitterPaneComponent methods + +- `toggle()` – switches between collapsed and expanded states programmatically. + +## Related Packages + +- [Directives](../directives/README.md) – the splitter relies on the drag-and-drop directives documented here. +- [Core](../core/README.md) – shared utilities and overlay services used across layout components. + +See the [Splitter documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/splitter) for comprehensive guides and live examples. diff --git a/projects/igniteui-angular/splitter/index.ts b/projects/igniteui-angular/splitter/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/splitter/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/splitter/ng-package.json b/projects/igniteui-angular/splitter/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/splitter/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/splitter/src/public_api.ts b/projects/igniteui-angular/splitter/src/public_api.ts new file mode 100644 index 00000000000..5adc679714c --- /dev/null +++ b/projects/igniteui-angular/splitter/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './splitter/public_api'; +export * from './splitter/splitter.module'; diff --git a/projects/igniteui-angular/splitter/src/splitter/public_api.ts b/projects/igniteui-angular/splitter/src/splitter/public_api.ts new file mode 100644 index 00000000000..f9564e5a11a --- /dev/null +++ b/projects/igniteui-angular/splitter/src/splitter/public_api.ts @@ -0,0 +1,12 @@ +import { IgxSplitterPaneComponent } from './splitter-pane/splitter-pane.component'; +import { IgxSplitBarComponent, IgxSplitterComponent } from './splitter.component'; + +export * from './splitter.component'; +export * from './splitter-pane/splitter-pane.component'; + +/* NOTE: Splitter directives collection for ease-of-use import in standalone components scenario */ +export const IGX_SPLITTER_DIRECTIVES = [ + IgxSplitterComponent, + IgxSplitterPaneComponent, + IgxSplitBarComponent +] as const; diff --git a/projects/igniteui-angular/splitter/src/splitter/splitter-bar.component.html b/projects/igniteui-angular/splitter/src/splitter/splitter-bar.component.html new file mode 100644 index 00000000000..e9acd565283 --- /dev/null +++ b/projects/igniteui-angular/splitter/src/splitter/splitter-bar.component.html @@ -0,0 +1,15 @@ +
    +
    +
    +
    +
    diff --git a/projects/igniteui-angular/splitter/src/splitter/splitter-pane/splitter-pane.component.html b/projects/igniteui-angular/splitter/src/splitter/splitter-pane/splitter-pane.component.html new file mode 100644 index 00000000000..95a0b70bdc7 --- /dev/null +++ b/projects/igniteui-angular/splitter/src/splitter/splitter-pane/splitter-pane.component.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/projects/igniteui-angular/splitter/src/splitter/splitter-pane/splitter-pane.component.ts b/projects/igniteui-angular/splitter/src/splitter/splitter-pane/splitter-pane.component.ts new file mode 100644 index 00000000000..380065de59e --- /dev/null +++ b/projects/igniteui-angular/splitter/src/splitter/splitter-pane/splitter-pane.component.ts @@ -0,0 +1,261 @@ +import { Component, HostBinding, Input, ElementRef, Output, EventEmitter, booleanAttribute, signal, inject } from '@angular/core'; + +/** + * Represents individual resizable/collapsible panes. + * + * @igxModule IgxSplitterModule + * + * @igxParent IgxSplitterComponent + * + * @igxKeywords pane + * + * @igxGroup presentation + * + * @remarks + * Users can control the resize behavior via the min and max size properties. + */ +@Component({ + selector: 'igx-splitter-pane', + templateUrl: './splitter-pane.component.html', + standalone: true +}) +export class IgxSplitterPaneComponent { + private el = inject(ElementRef); + + private _minSize: string; + private _maxSize: string; + private _order = signal(null); + + /** + * @hidden @internal + * Gets/Sets the 'display' property of the current pane. + */ + @HostBinding('style.display') + public display = 'flex'; + + /** + * Gets/Sets the minimum allowed size of the current pane. + * + * @example + * ```html + * + * ... + * + * ``` + */ + @Input() + public get minSize(): string { + return this._minSize; + } + public set minSize(value: string) { + this._minSize = value; + if (this.owner) { + this.owner.panes.notifyOnChanges(); + } + } + + /** + * Gets/Set the maximum allowed size of the current pane. + * + * @example + * ```html + * + * ... + * + * ``` + */ + @Input() + public get maxSize(): string { + return this._maxSize; + } + public set maxSize(value: string) { + this._maxSize = value; + if (this.owner) { + this.owner.panes.notifyOnChanges(); + } + } + + /** + * Gets/Sets whether pane is resizable. + * + * @example + * ```html + * + * ... + * + * ``` + * @remarks + * If pane is not resizable its related splitter bar cannot be dragged. + */ + @Input({ transform: booleanAttribute }) + public resizable = true; + + /** + * Event fired when collapsed state of pane is changed. + * + * @example + * ```html + * + * ... + * + * ``` + */ + @Output() + public collapsedChange = new EventEmitter(); + + /** @hidden @internal */ + @HostBinding('style.order') + public get order() { + return this._order(); + } + public set order(val) { + this._order.set(val) + } + + /** + * @hidden @internal + * Gets/Sets the `overflow`. + */ + @HostBinding('style.overflow') + public overflow = 'auto'; + + /** + * @hidden @internal + * Get/Sets the `minWidth` properties of the current pane. + */ + @HostBinding('style.min-width') + public minWidth = '0'; + + /** + * @hidden @internal + * Get/Sets the `maxWidth` properties of the current pane. + */ + @HostBinding('style.max-width') + public maxWidth = '100%'; + + /** + * @hidden @internal + * Gets/Sets the `minHeight` properties of the current pane. + */ + @HostBinding('style.min-height') + public minHeight = '0'; + + /** + * @hidden @internal + * Gets/Sets the `maxHeight` properties of the current `IgxSplitterPaneComponent`. + */ + @HostBinding('style.max-height') + public maxHeight = '100%'; + + /** @hidden @internal */ + public owner; + + /** + * Gets/Sets the size of the current pane. + * * @example + * ```html + * + * ... + * + * ``` + */ + @Input() + public get size() { + return this._size; + } + + public set size(value) { + this._size = value; + this.el.nativeElement.style.flex = this.flex; + } + + /** @hidden @internal */ + public get isPercentageSize() { + return this.size === 'auto' || this.size.indexOf('%') !== -1; + } + + /** @hidden @internal */ + public get dragSize() { + return this._dragSize; + } + public set dragSize(val) { + this._dragSize = val; + this.el.nativeElement.style.flex = this.flex; + } + + /** + * + * @hidden @internal + * Gets the host native element. + */ + public get element(): any { + return this.el.nativeElement; + } + + /** + * @hidden @internal + * Gets the `flex` property of the current `IgxSplitterPaneComponent`. + */ + @HostBinding('style.flex') + public get flex() { + const size = this.dragSize || this.size; + const grow = this.isPercentageSize && !this.dragSize ? 1 : 0; + return `${grow} ${grow} ${size}`; + } + + /** + * Gets/Sets whether current pane is collapsed. + * + * @example + * ```typescript + * const isCollapsed = pane.collapsed; + * ``` + */ + @Input({ transform: booleanAttribute }) + public set collapsed(value) { + if (this.owner) { + // reset sibling sizes when pane collapse state changes. + this._getSiblings().forEach(sibling => { + sibling.size = 'auto' + sibling.dragSize = null; + }); + } + this._collapsed = value; + this.display = this._collapsed ? 'none' : 'flex'; + this.collapsedChange.emit(this._collapsed); + } + + public get collapsed() { + return this._collapsed; + } + + private _size = 'auto'; + private _dragSize; + private _collapsed = false; + + /** + * Toggles the collapsed state of the pane. + * + * @example + * ```typescript + * pane.toggle(); + * ``` + */ + public toggle() { + this.collapsed = !this.collapsed; + } + + /** @hidden @internal */ + private _getSiblings() { + const panes = this.owner.panes.toArray(); + const index = panes.indexOf(this); + const siblings = []; + if (index !== 0) { + siblings.push(panes[index - 1]); + } + if (index !== panes.length - 1) { + siblings.push(panes[index + 1]); + } + return siblings; + } +} diff --git a/projects/igniteui-angular/splitter/src/splitter/splitter.component.html b/projects/igniteui-angular/splitter/src/splitter/splitter.component.html new file mode 100644 index 00000000000..0bb944fbbd1 --- /dev/null +++ b/projects/igniteui-angular/splitter/src/splitter/splitter.component.html @@ -0,0 +1,14 @@ + +@for (pane of panes; track pane; let last = $last; let index = $index) { + @if (!last) { + + + } +} diff --git a/projects/igniteui-angular/splitter/src/splitter/splitter.component.spec.ts b/projects/igniteui-angular/splitter/src/splitter/splitter.component.spec.ts new file mode 100644 index 00000000000..6933bfa774b --- /dev/null +++ b/projects/igniteui-angular/splitter/src/splitter/splitter.component.spec.ts @@ -0,0 +1,622 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { Component, ViewChild, DebugElement } from '@angular/core'; +import { SplitterType, IgxSplitterComponent, ISplitterBarResizeEventArgs } from './splitter.component'; +import { By } from '@angular/platform-browser'; +import { UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { IgxSplitterPaneComponent } from './splitter-pane/splitter-pane.component'; + +const SPLITTERBAR_CLASS = 'igx-splitter-bar'; +const SPLITTERBAR_DIV_CLASS = '.igx-splitter-bar'; +const SPLITTER_BAR_VERTICAL_CLASS = 'igx-splitter-bar--vertical'; +const COLLAPSIBLE_CLASS = 'igx-splitter-bar--collapsible'; + +describe('IgxSplitter', () => { + beforeEach(waitForAsync(() => + TestBed.configureTestingModule({ + imports: [ + SplitterTestComponent + ] + }).compileComponents() + )); + let fixture: ComponentFixture; + let splitter: IgxSplitterComponent; + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(SplitterTestComponent); + fixture.detectChanges(); + splitter = fixture.componentInstance.splitter; + })); + + it('should render pane content correctly in splitter.', () => { + expect(splitter.panes.length).toBe(2); + const firstPane = splitter.panes.toArray()[0].element; + const secondPane = splitter.panes.toArray()[1].element; + expect(firstPane.textContent.trim()).toBe('Pane 1'); + expect(secondPane.textContent.trim()).toBe('Pane 2'); + fixture.detectChanges(); + const splitterBar = fixture.debugElement.query(By.css(SPLITTERBAR_CLASS)).nativeElement; + expect(firstPane.style.order).toBe('0'); + expect(splitterBar.style.order).toBe('1'); + expect(secondPane.style.order).toBe('2'); + }); + + it('should correctly add the collapsible class.', () => { + const splitterBarDIV = fixture.debugElement.query(By.css(SPLITTERBAR_DIV_CLASS)).nativeElement; + const collapsibleClass = splitterBarDIV.classList.contains(COLLAPSIBLE_CLASS); + expect(collapsibleClass).toBeTruthy(); + + splitter.nonCollapsible = true; + fixture.detectChanges(); + + const noCollapsibleClass = splitterBarDIV.classList.contains(COLLAPSIBLE_CLASS); + expect(noCollapsibleClass).toBeFalsy(); + }); + + it('should render vertical splitter.', () => { + fixture.componentInstance.type = SplitterType.Vertical; + fixture.detectChanges(); + + const splitterBarDIV = fixture.debugElement.query(By.css(SPLITTERBAR_DIV_CLASS)); + const hasVerticalClass = splitterBarDIV.nativeElement.classList.contains(SPLITTER_BAR_VERTICAL_CLASS); + expect(hasVerticalClass).toBeFalsy(); + }); + it('should render horizontal splitter.', () => { + const splitterBarDIV = fixture.debugElement.query(By.css(SPLITTERBAR_DIV_CLASS)); + const hasVerticalClass = splitterBarDIV.nativeElement.classList.contains(SPLITTER_BAR_VERTICAL_CLASS); + expect(hasVerticalClass).toBeTruthy(); + }); + it('should allow resizing vertical splitter', () => { + fixture.componentInstance.type = SplitterType.Vertical; + fixture.detectChanges(); + const pane1 = splitter.panes.toArray()[0]; + const pane2 = splitter.panes.toArray()[1]; + expect(pane1.size).toBe('auto'); + expect(pane2.size).toBe('auto'); + const pane1_originalSize = pane1.element.offsetHeight; + const pane2_originalSize = pane2.element.offsetHeight; + const splitterBarComponent = fixture.debugElement.query(By.css(SPLITTERBAR_CLASS)).context; + splitterBarComponent.moveStart.emit(pane1); + splitterBarComponent.moving.emit(-100); + fixture.detectChanges(); + expect(pane1.dragSize).toBe(pane1_originalSize + 100 + 'px'); + expect(pane2.dragSize).toBe(pane2_originalSize - 100 + 'px'); + + splitterBarComponent.moving.emit(100); + fixture.detectChanges(); + expect(pane1.dragSize).toBe(pane1_originalSize - 100 + 'px'); + expect(pane2.dragSize).toBe(pane2_originalSize + 100 + 'px'); + }); + it('should allow resizing horizontal splitter', () => { + const pane1 = splitter.panes.toArray()[0]; + const pane2 = splitter.panes.toArray()[1]; + expect(pane1.size).toBe('auto'); + expect(pane2.size).toBe('auto'); + const pane1_originalSize = pane1.element.offsetWidth; + const pane2_originalSize = pane2.element.offsetWidth; + const splitterBarComponent = fixture.debugElement.query(By.css(SPLITTERBAR_CLASS)).context; + splitterBarComponent.moveStart.emit(pane1); + splitterBarComponent.moving.emit(-100); + fixture.detectChanges(); + + expect(parseFloat(pane1.dragSize)).toBeCloseTo(pane1_originalSize + 100, 0); + expect(parseFloat(pane2.dragSize)).toBeCloseTo(pane2_originalSize - 100, 0); + + splitterBarComponent.moving.emit(100); + fixture.detectChanges(); + expect(parseFloat(pane1.dragSize)).toBeCloseTo(pane1_originalSize - 100, 0); + expect(parseFloat(pane2.dragSize)).toBeCloseTo(pane2_originalSize + 100, 0); + }); + it('should honor minSize/maxSize when resizing.', () => { + fixture.componentInstance.type = SplitterType.Vertical; + fixture.detectChanges(); + const pane1 = splitter.panes.toArray()[0]; + const pane2 = splitter.panes.toArray()[1]; + pane1.minSize = '100px'; + pane1.maxSize = '300px'; + fixture.detectChanges(); + + const splitterBarComponent = fixture.debugElement.query(By.css(SPLITTERBAR_CLASS)).context; + splitterBarComponent.moveStart.emit(pane1); + splitterBarComponent.moving.emit(100); + splitterBarComponent.moveStart.emit(pane1); + splitterBarComponent.moving.emit(100); + fixture.detectChanges(); + expect(pane1.dragSize).toBe('100px'); + expect(pane2.dragSize).toBe('300px'); + + splitterBarComponent.moveStart.emit(pane1); + splitterBarComponent.moving.emit(-200); + splitterBarComponent.moveStart.emit(pane1); + splitterBarComponent.moving.emit(-50); + fixture.detectChanges(); + expect(pane1.dragSize).toBe('300px'); + expect(pane2.dragSize).toBe('100px'); + }); + + it('should not allow drag resize if resizable is set to false.', () => { + const splitterBarComponent = fixture.debugElement.query(By.css(SPLITTERBAR_CLASS)).context; + expect(splitterBarComponent.cursor).toBe('col-resize'); + const pane1 = splitter.panes.toArray()[0]; + pane1.resizable = false; + fixture.detectChanges(); + const args = {cancel: false}; + splitterBarComponent.onDragStart(args); + expect(args.cancel).toBeTruthy(); + expect(splitterBarComponent.cursor).toBe(''); + }); + + it('should allow resizing with up/down arrow keys', () => { + fixture.componentInstance.type = SplitterType.Vertical; + fixture.detectChanges(); + const pane1 = splitter.panes.toArray()[0]; + const pane2 = splitter.panes.toArray()[1]; + expect(pane1.size).toBe('auto'); + expect(pane2.size).toBe('auto'); + const pane1_originalSize = pane1.element.offsetHeight; + const pane2_originalSize = pane2.element.offsetHeight; + const splitterBarComponent: DebugElement = fixture.debugElement.query(By.css(SPLITTERBAR_CLASS)); + splitterBarComponent.nativeElement.focus(); + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', splitterBarComponent); + fixture.detectChanges(); + expect(pane1.dragSize).toBe(pane1_originalSize - 10 + 'px'); + expect(pane2.dragSize).toBe(pane2_originalSize + 10 + 'px'); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', splitterBarComponent); + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', splitterBarComponent); + fixture.detectChanges(); + expect(pane1.dragSize).toBe(pane1_originalSize + 10 + 'px'); + expect(pane2.dragSize).toBe(pane2_originalSize - 10 + 'px'); + + pane2.resizable = false; + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', splitterBarComponent); + fixture.detectChanges(); + expect(pane1.dragSize).toBe(pane1_originalSize + 10 + 'px'); + expect(pane2.dragSize).toBe(pane2_originalSize - 10 + 'px'); + }); + + it('should allow resizing with left/right arrow keys', () => { + fixture.componentInstance.type = SplitterType.Horizontal; + fixture.detectChanges(); + const pane1 = splitter.panes.toArray()[0]; + const pane2 = splitter.panes.toArray()[1]; + expect(pane1.size).toBe('auto'); + expect(pane2.size).toBe('auto'); + const pane1_originalSize = pane1.element.offsetWidth; + const pane2_originalSize = pane2.element.offsetWidth; + const splitterBarComponent: DebugElement = fixture.debugElement.query(By.css(SPLITTERBAR_CLASS)); + splitterBarComponent.nativeElement.focus(); + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', splitterBarComponent); + fixture.detectChanges(); + expect(parseFloat(pane1.dragSize)).toBeCloseTo(pane1_originalSize - 10, 0); + expect(parseFloat(pane2.dragSize)).toBeCloseTo(pane2_originalSize + 10, 0); + + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', splitterBarComponent); + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', splitterBarComponent); + fixture.detectChanges(); + expect(parseFloat(pane1.dragSize)).toBeCloseTo(pane1_originalSize + 10, 0); + expect(parseFloat(pane2.dragSize)).toBeCloseTo(pane2_originalSize - 10, 0); + + pane1.resizable = false; + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', splitterBarComponent); + fixture.detectChanges(); + expect(parseFloat(pane1.dragSize)).toBeCloseTo(pane1_originalSize + 10, 0); + expect(parseFloat(pane2.dragSize)).toBeCloseTo(pane2_originalSize - 10, 0); + }); + + it('should allow expand/collapse with Ctrl + up/down arrow keys', () => { + fixture.componentInstance.type = SplitterType.Vertical; + fixture.detectChanges(); + const pane1 = splitter.panes.toArray()[0]; + const pane2 = splitter.panes.toArray()[1]; + expect(pane1.size).toBe('auto'); + expect(pane2.size).toBe('auto'); + const splitterBarComponent: DebugElement = fixture.debugElement.query(By.css(SPLITTERBAR_CLASS)); + splitterBarComponent.nativeElement.focus(); + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', splitterBarComponent, false, false, true); + fixture.detectChanges(); + expect(pane1.collapsed).toBeTruthy(); + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', splitterBarComponent, false, false, true); + fixture.detectChanges(); + expect(pane1.collapsed).toBeFalsy(); + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', splitterBarComponent, false, false, true); + fixture.detectChanges(); + expect(pane2.collapsed).toBeTruthy(); + UIInteractions.triggerEventHandlerKeyDown('ArrowUp', splitterBarComponent, false, false, true); + fixture.detectChanges(); + expect(pane2.collapsed).toBeFalsy(); + }); + + it('should allow expand/collapse with Ctrl + left/right arrow keys', () => { + fixture.componentInstance.type = SplitterType.Horizontal; + fixture.detectChanges(); + const pane1 = splitter.panes.toArray()[0]; + const pane2 = splitter.panes.toArray()[1]; + expect(pane1.size).toBe('auto'); + expect(pane2.size).toBe('auto'); + const splitterBarComponent: DebugElement = fixture.debugElement.query(By.css(SPLITTERBAR_CLASS)); + splitterBarComponent.nativeElement.focus(); + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', splitterBarComponent, false, false, true); + fixture.detectChanges(); + expect(pane1.collapsed).toBeTruthy(); + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', splitterBarComponent, false, false, true); + fixture.detectChanges(); + expect(pane1.collapsed).toBeFalsy(); + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', splitterBarComponent, false, false, true); + fixture.detectChanges(); + expect(pane2.collapsed).toBeTruthy(); + UIInteractions.triggerEventHandlerKeyDown('ArrowLeft', splitterBarComponent, false, false, true); + fixture.detectChanges(); + expect(pane2.collapsed).toBeFalsy(); + }); + + it('should allow resize in % when pane size is auto.', () => { + const pane1 = splitter.panes.toArray()[0]; + const pane2 = splitter.panes.toArray()[1]; + expect(pane1.size).toBe('auto'); + expect(pane2.size).toBe('auto'); + const pane1_originalSize = pane1.element.offsetWidth; + const pane2_originalSize = pane2.element.offsetWidth; + const splitterBarComponent = fixture.debugElement.query(By.css(SPLITTERBAR_CLASS)).context; + splitterBarComponent.moveStart.emit(pane1); + splitterBarComponent.moving.emit(-100); + fixture.detectChanges(); + + expect(parseFloat(pane1.dragSize)).toBeCloseTo(pane1_originalSize + 100, 0); + expect(parseFloat(pane2.dragSize)).toBeCloseTo(pane2_originalSize - 100, 0); + + // on move end convert to % value and apply to size. + splitterBarComponent.movingEnd.emit(-100); + fixture.detectChanges(); + + expect(pane1.size.indexOf('%') !== -1).toBeTrue(); + expect(pane2.size.indexOf('%') !== -1).toBeTrue(); + + expect(pane1.element.offsetWidth).toBeCloseTo(pane1_originalSize + 100); + expect(pane2.element.offsetWidth).toBeCloseTo(pane2_originalSize - 100); + }); + + it('should allow mixing % and px sizes.', () => { + const pane1 = splitter.panes.toArray()[0]; + const pane2 = splitter.panes.toArray()[1]; + pane1.size = '200px'; + fixture.detectChanges(); + + const pane1_originalSize = pane1.element.offsetWidth; + const pane2_originalSize = pane2.element.offsetWidth; + const splitterBarComponent = fixture.debugElement.query(By.css(SPLITTERBAR_CLASS)).context; + splitterBarComponent.moveStart.emit(pane1); + splitterBarComponent.moving.emit(-100); + fixture.detectChanges(); + + expect(parseFloat(pane1.dragSize)).toBeCloseTo(pane1_originalSize + 100, 0); + expect(parseFloat(pane2.dragSize)).toBeCloseTo(pane2_originalSize - 100, 0); + + // on move end convert to % value and apply to size. + splitterBarComponent.movingEnd.emit(-100); + fixture.detectChanges(); + + // fist pane should remain in px + expect(pane1.size).toBe('300px'); + expect(pane2.size.indexOf('%') !== -1).toBeTrue(); + + expect(pane1.element.offsetWidth).toBeCloseTo(pane1_originalSize + 100); + expect(pane2.element.offsetWidth).toBeCloseTo(pane2_originalSize - 100); + }); + + it('should reset transform style of vertical splitter bar after dragging', async () => { + const pane1 = splitter.panes.toArray()[0]; + pane1.size = '200px'; + fixture.detectChanges(); + + fixture.componentInstance.type = SplitterType.Vertical; + fixture.detectChanges(); + const splitterBarComponent = fixture.debugElement.query(By.css(SPLITTERBAR_CLASS)).nativeElement; + + const splitterBar = fixture.debugElement.query(By.css(SPLITTERBAR_CLASS)).context; + splitterBar.moveStart.emit(pane1); + splitterBar.moving.emit(-150); + fixture.detectChanges(); + + splitterBar.movingEnd.emit(50); + fixture.detectChanges(); + + expect(splitterBarComponent.style.transform).not.toBe('translate3d(0px, 0px, 0px)'); + }); + + it('should render correctly panes created dynamically using @for', () => { + fixture = TestBed.createComponent(SplitterForOfPanesComponent); + fixture.detectChanges(); + splitter = fixture.componentInstance.splitter; + expect(splitter.panes.length).toBe(3); + }); +}); + +describe('IgxSplitter pane toggle', () => { + beforeEach(waitForAsync(() => TestBed.configureTestingModule({ + imports: [ + SplitterTogglePaneComponent + ] + }).compileComponents())); + + let fixture; let splitter; + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(SplitterTogglePaneComponent); + fixture.detectChanges(); + splitter = fixture.componentInstance.splitter; + fixture.detectChanges(); + })); + + it('should collapse/expand panes', () => { + const pane1 = splitter.panes.toArray()[0]; + const splitterBarComponent = fixture.debugElement.query(By.css(SPLITTERBAR_CLASS)).context; + + // collapse left sibling pane + splitterBarComponent.onCollapsing(0); + fixture.detectChanges(); + expect(pane1.collapsed).toBeTruthy(); + + // expand left sibling pane + splitterBarComponent.onCollapsing(1); + fixture.detectChanges(); + expect(pane1.collapsed).toBeFalsy(); + }); + + it('should be able to expand both siblings when they are collapsed', () => { + const panes = splitter.panes.toArray(); + const pane1 = panes[0]; + const pane2 = panes[1]; + const splitterBarComponents = fixture.debugElement.queryAll(By.css(SPLITTERBAR_CLASS)); + const splitterBar1 = splitterBarComponents[0].context; + const splitterBar2 = splitterBarComponents[1].context; + + splitterBar1.onCollapsing(0); + splitterBar2.onCollapsing(0); + fixture.detectChanges(); + + expect(pane1.collapsed).toBeTruthy(); + expect(pane2.collapsed).toBeTruthy(); + + splitterBar1.onCollapsing(1); + fixture.detectChanges(); + expect(pane1.collapsed).toBeFalsy(); + }); + + it('should not be able to resize a pane when it is collapsed', () => { + const pane1 = splitter.panes.toArray()[0]; + const splitterBarComponent = fixture.debugElement.query(By.css(SPLITTERBAR_CLASS)).context; + + pane1.size = '340'; + const pane1_originalSize = pane1.size; + const splitterBarComponentDebug: DebugElement = fixture.debugElement.query(By.css(SPLITTERBAR_CLASS)); + + // collapse left sibling pane + splitterBarComponent.onCollapsing(0); + fixture.detectChanges(); + expect(pane1.collapsed).toBeTruthy(); + expect(pane1.resizable).toBeTruthy(); + splitterBarComponentDebug.nativeElement.focus(); + UIInteractions.triggerEventHandlerKeyDown('ArrowRight', splitterBarComponentDebug); + fixture.detectChanges(); + expect(pane1.size).toEqual(pane1_originalSize); + + splitterBarComponent.onCollapsing(1); + fixture.detectChanges(); + expect(pane1.collapsed).toBeFalsy(); + expect(pane1.resizable).toBeTruthy(); + }); + + it('should emit resizing events on splitter bar move: resizeStart, resizing, resizeEnd.', () => { + fixture.componentInstance.type = SplitterType.Vertical; + fixture.detectChanges(); + spyOn(splitter.resizeStart, 'emit').and.callThrough(); + spyOn(splitter.resizing, 'emit').and.callThrough(); + spyOn(splitter.resizeEnd, 'emit').and.callThrough(); + + const pane1 = splitter.panes.toArray()[0]; + const pane2 = splitter.panes.toArray()[1]; + const splitterBarComponent = fixture.debugElement.query(By.css(SPLITTERBAR_CLASS)).context; + splitterBarComponent.moveStart.emit(pane1); + fixture.detectChanges(); + splitterBarComponent.moving.emit(-100); + fixture.detectChanges(); + splitterBarComponent.movingEnd.emit(-100); + fixture.detectChanges(); + + const args: ISplitterBarResizeEventArgs = { + pane: pane1, + sibling: pane2 + }; + expect(splitter.resizeStart.emit).toHaveBeenCalledTimes(1); + expect(splitter.resizeStart.emit).toHaveBeenCalledWith(args); + expect(splitter.resizing.emit).toHaveBeenCalledTimes(1); + expect(splitter.resizing.emit).toHaveBeenCalledWith(args); + expect(splitter.resizeEnd.emit).toHaveBeenCalledTimes(1); + expect(splitter.resizeEnd.emit).toHaveBeenCalledWith(args); + }); +}); + +describe('IgxSplitter pane collapse', () => { + beforeEach(waitForAsync(() => TestBed.configureTestingModule({ + imports: [ + SplitterCollapsedPaneComponent + ] + }).compileComponents())); + + let fixture; let splitter; + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(SplitterCollapsedPaneComponent); + fixture.detectChanges(); + splitter = fixture.componentInstance.splitter; + })); + + it('should reset sizes when pane is initially collapsed.', () => { + const panes = splitter.panes.toArray(); + panes.forEach(pane => { + expect(pane.size).toBe('auto'); + }); + }); + it('should reset sizes when pane is runtime collapsed.', () => { + const panes = splitter.panes.toArray(); + panes[0].size = '70%'; + fixture.detectChanges(); + panes[1].collapsed = true; + fixture.detectChanges(); + panes.forEach(pane => { + expect(pane.size).toBe('auto'); + }); + }); +}); + +describe('IgxSplitter resizing with minSize and browser window is shrinked', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + SplitterMinSiezComponent + ] + }).compileComponents(); + })); + + let fixture; let splitter; + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(SplitterMinSiezComponent); + fixture.detectChanges(); + splitter = fixture.componentInstance.splitter; + })); + + it('should set the correct sizes when the user drags one pane to the end of another', () => { + const pane1 = splitter.panes.toArray()[0]; + const pane2 = splitter.panes.toArray()[1]; + const splitterBarComponent = fixture.debugElement.query(By.css(SPLITTERBAR_CLASS)).context; + const minSize = parseInt(pane1.minSize); + spyOn(splitter, 'onMoveEnd').and.callThrough(); + + splitterBarComponent.moveStart.emit(pane1); + fixture.detectChanges(); + splitterBarComponent.movingEnd.emit(splitter.getTotalSize() -minSize); + fixture.detectChanges(); + + splitter.elementRef.nativeElement.style.width = '500px'; + fixture.detectChanges(); + + splitterBarComponent.moveStart.emit(pane1); + fixture.detectChanges(); + splitterBarComponent.movingEnd.emit(-200); + fixture.detectChanges(); + + expect(splitter.onMoveEnd).toHaveBeenCalled(); + expect(pane1.size).toEqual('80%'); + expect(pane2.size).toEqual('100px'); + }); +}); + +@Component({ + template: ` + + +
    + Pane 1 +
    +
    + +
    + Pane 2 +
    +
    +
    + `, + imports: [IgxSplitterComponent, IgxSplitterPaneComponent] +}) +export class SplitterMinSiezComponent { + @ViewChild(IgxSplitterComponent, { static: true }) + public splitter: IgxSplitterComponent; +} + +@Component({ + template: ` + + +
    + Pane 1 +
    +
    + +
    + Pane 2 +
    +
    +
    + `, + imports: [IgxSplitterComponent, IgxSplitterPaneComponent] +}) +export class SplitterTestComponent { + @ViewChild(IgxSplitterComponent, { static: true }) + public splitter: IgxSplitterComponent; + public type = SplitterType.Horizontal; +} + +@Component({ + template: ` + + +
    + Pane 1 +
    +
    + +
    + Pane 2 +
    +
    + +
    + Pane 3 +
    +
    +
    + `, + imports: [IgxSplitterComponent, IgxSplitterPaneComponent] +}) + +export class SplitterTogglePaneComponent extends SplitterTestComponent { +} + +@Component({ + template: ` + + +
    + Pane 1 +
    +
    + +
    + Pane 2 +
    +
    + +
    + Pane 3 +
    +
    +
    + `, + imports: [IgxSplitterComponent, IgxSplitterPaneComponent] +}) +export class SplitterCollapsedPaneComponent extends SplitterTestComponent { +} + +@Component({ + template: ` + + @for (number of numbers; track number) { + +

    {{ number }}

    +
    + } +
    + `, + imports: [IgxSplitterComponent, IgxSplitterPaneComponent] +}) +export class SplitterForOfPanesComponent extends SplitterTestComponent { + public numbers = [1, 2, 3]; +} diff --git a/projects/igniteui-angular/splitter/src/splitter/splitter.component.ts b/projects/igniteui-angular/splitter/src/splitter/splitter.component.ts new file mode 100644 index 00000000000..374ec9cfeeb --- /dev/null +++ b/projects/igniteui-angular/splitter/src/splitter/splitter.component.ts @@ -0,0 +1,624 @@ +import { AfterContentInit, Component, ContentChildren, ElementRef, EventEmitter, HostBinding, HostListener, Input, NgZone, Output, QueryList, booleanAttribute, forwardRef, DOCUMENT, inject } from '@angular/core'; +import { DragDirection, IDragMoveEventArgs, IDragStartEventArgs, IgxDragDirective, IgxDragIgnoreDirective } from 'igniteui-angular/directives'; +import { IgxSplitterPaneComponent } from './splitter-pane/splitter-pane.component'; +import { take } from 'rxjs'; + +/** + * An enumeration that defines the `SplitterComponent` panes orientation. + */ +export enum SplitterType { + Horizontal, + Vertical +} + +export declare interface ISplitterBarResizeEventArgs { + pane: IgxSplitterPaneComponent; + sibling: IgxSplitterPaneComponent; +} + +/** + * Provides a framework for a simple layout, splitting the view horizontally or vertically + * into multiple smaller resizable and collapsible areas. + * + * @igxModule IgxSplitterModule + * + * @igxParent Layouts + * + * @igxTheme igx-splitter-theme + * + * @igxKeywords splitter panes layout + * + * @igxGroup presentation + * + * @example + * ```html + * + * + * ... + * + * + * ... + * + * + * ``` + */ +@Component({ + selector: 'igx-splitter', + templateUrl: './splitter.component.html', + imports: [forwardRef(() => IgxSplitBarComponent)] +}) +export class IgxSplitterComponent implements AfterContentInit { + public document = inject(DOCUMENT); + private elementRef = inject(ElementRef); + private zone = inject(NgZone); + + /** + * Gets the list of splitter panes. + * + * @example + * ```typescript + * const panes = this.splitter.panes; + * ``` + */ + @ContentChildren(IgxSplitterPaneComponent, { read: IgxSplitterPaneComponent }) + public panes!: QueryList; + + /** + * @hidden + * @internal + */ + @HostBinding('class.igx-splitter') + public cssClass = 'igx-splitter'; + + /** + * @hidden @internal + * Gets/Sets the `overflow` property of the current splitter. + */ + @HostBinding('style.overflow') + public overflow = 'hidden'; + + /** + * @hidden @internal + * Sets/Gets the `display` property of the current splitter. + */ + @HostBinding('style.display') + public display = 'flex'; + + /** + * @hidden + * @internal + */ + @HostBinding('attr.aria-orientation') + public get orientation() { + return this.type === SplitterType.Horizontal ? 'horizontal' : 'vertical'; + } + + /** + * Event fired when resizing of panes starts. + * + * @example + * ```html + * + * ... + * + * ``` + */ + @Output() + public resizeStart = new EventEmitter(); + + /** + * Event fired when resizing of panes is in progress. + * + * @example + * ```html + * + * ... + * + * ``` + */ + @Output() + public resizing = new EventEmitter(); + + + /** + * Event fired when resizing of panes ends. + * + * @example + * ```html + * + * ... + * + * ``` + */ + @Output() + public resizeEnd = new EventEmitter(); + + private _type: SplitterType = SplitterType.Horizontal; + + /** + * @hidden @internal + * A field that holds the initial size of the main `IgxSplitterPaneComponent` in each pair of panes divided by a splitter bar. + */ + private initialPaneSize!: number; + + /** + * @hidden @internal + * A field that holds the initial size of the sibling pane in each pair of panes divided by a gripper. + * @memberof SplitterComponent + */ + private initialSiblingSize!: number; + + /** + * @hidden @internal + * The main pane in each pair of panes divided by a gripper. + */ + private pane!: IgxSplitterPaneComponent; + + /** + * The sibling pane in each pair of panes divided by a splitter bar. + */ + private sibling!: IgxSplitterPaneComponent; + /** + * Gets/Sets the splitter orientation. + * + * @example + * ```html + * ... + * ``` + */ + @Input() + public get type() { + return this._type; + } + public set type(value) { + this._type = value; + this.resetPaneSizes(); + this.panes?.notifyOnChanges(); + } + + /** + * Sets the visibility of the handle and expanders in the splitter bar. + * False by default + * + * @example + * ```html + * + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public nonCollapsible = false; // Input to toggle showing/hiding expanders + + /** + * @hidden @internal + * Gets the `flex-direction` property of the current `SplitterComponent`. + */ + @HostBinding('style.flex-direction') + public get direction(): string { + return this.type === SplitterType.Horizontal ? 'row' : 'column'; + } + + /** @hidden @internal */ + public ngAfterContentInit(): void { + this.zone.onStable.pipe(take(1)).subscribe(() => { + this.initPanes(); + }); + this.panes.changes.subscribe(() => { + this.initPanes(); + }); + } + + /** + * @hidden @internal + * This method performs initialization logic when the user starts dragging the splitter bar between each pair of panes. + * @param pane - the main pane associated with the currently dragged bar. + */ + public onMoveStart(pane: IgxSplitterPaneComponent) { + const panes = this.panes.toArray(); + this.pane = pane; + this.sibling = panes[panes.indexOf(this.pane) + 1]; + + const paneRect = this.pane.element.getBoundingClientRect(); + this.initialPaneSize = this.type === SplitterType.Horizontal ? paneRect.width : paneRect.height; + + const siblingRect = this.sibling.element.getBoundingClientRect(); + this.initialSiblingSize = this.type === SplitterType.Horizontal ? siblingRect.width : siblingRect.height; + const args: ISplitterBarResizeEventArgs = { pane: this.pane, sibling: this.sibling }; + this.resizeStart.emit(args); + } + + /** + * @hidden @internal + * This method performs calculations concerning the sizes of each pair of panes when the bar between them is dragged. + * @param delta - The difference along the X (or Y) axis between the initial and the current point when dragging the bar. + */ + public onMoving(delta: number) { + const [ paneSize, siblingSize ] = this.calcNewSizes(delta); + + this.pane.dragSize = paneSize + 'px'; + this.sibling.dragSize = siblingSize + 'px'; + + const args: ISplitterBarResizeEventArgs = { pane: this.pane, sibling: this.sibling }; + this.resizing.emit(args); + } + + public onMoveEnd(delta: number) { + let [ paneSize, siblingSize ] = this.calcNewSizes(delta); + + if (paneSize + siblingSize > this.getTotalSize() && delta < 0) { + siblingSize = this.getTotalSize() - paneSize; + } else if (paneSize + siblingSize > this.getTotalSize() && delta > 0) { + paneSize = this.getTotalSize() - siblingSize; + } + + if (this.pane.isPercentageSize) { + // handle % resizes + const totalSize = this.getTotalSize(); + const percentPaneSize = (paneSize / totalSize) * 100; + this.pane.size = percentPaneSize + '%'; + } else { + // px resize + this.pane.size = paneSize + 'px'; + } + + if (this.sibling.isPercentageSize) { + // handle % resizes + const totalSize = this.getTotalSize(); + const percentSiblingPaneSize = (siblingSize / totalSize) * 100; + this.sibling.size = percentSiblingPaneSize + '%'; + } else { + // px resize + this.sibling.size = siblingSize + 'px'; + } + this.pane.dragSize = null; + this.sibling.dragSize = null; + + const args: ISplitterBarResizeEventArgs = { pane: this.pane, sibling: this.sibling }; + this.resizeEnd.emit(args); + } + + /** @hidden @internal */ + public getPaneSiblingsByOrder(order: number, barIndex: number): Array { + const panes = this.panes.toArray(); + const prevPane = panes[order - barIndex - 1]; + const nextPane = panes[order - barIndex]; + const siblings = [prevPane, nextPane]; + return siblings; + } + + private getTotalSize() { + const computed = this.document.defaultView.getComputedStyle(this.elementRef.nativeElement); + const totalSize = this.type === SplitterType.Horizontal ? computed.getPropertyValue('width') : computed.getPropertyValue('height'); + return parseFloat(totalSize); + } + + + /** + * @hidden @internal + * This method inits panes with properties. + */ + private initPanes() { + this.panes.forEach(pane => { + pane.owner = this; + if (this.type === SplitterType.Horizontal) { + pane.minWidth = pane.minSize ?? '0'; + pane.maxWidth = pane.maxSize ?? '100%'; + } else { + pane.minHeight = pane.minSize ?? '0'; + pane.maxHeight = pane.maxSize ?? '100%'; + } + }); + this.assignFlexOrder(); + if (this.panes.filter(x => x.collapsed).length > 0) { + // if any panes are collapsed, reset sizes. + this.resetPaneSizes(); + } + } + + /** + * @hidden @internal + * This method reset pane sizes. + */ + private resetPaneSizes() { + if (this.panes) { + // if type is changed runtime, should reset sizes. + this.panes.forEach(x => { + x.size = 'auto' + x.minWidth = '0'; + x.maxWidth = '100%'; + x.minHeight = '0'; + x.maxHeight = '100%'; + }); + } + } + + /** + * @hidden @internal + * This method assigns the order of each pane. + */ + private assignFlexOrder() { + let k = 0; + this.panes.forEach((pane: IgxSplitterPaneComponent) => { + pane.order = k; + k += 2; + }); + } + + /** + * @hidden @internal + * Calculates new sizes for the panes based on move delta and initial sizes + */ + private calcNewSizes(delta: number): [number, number] { + const min = parseInt(this.pane.minSize, 10) || 0; + const minSibling = parseInt(this.sibling.minSize, 10) || 0; + const max = parseInt(this.pane.maxSize, 10) || this.initialPaneSize + this.initialSiblingSize - minSibling; + const maxSibling = parseInt(this.sibling.maxSize, 10) || this.initialPaneSize + this.initialSiblingSize - min; + + if (delta < 0) { + const maxPossibleDelta = Math.min( + max - this.initialPaneSize, + this.initialSiblingSize - minSibling, + ) + delta = Math.min(maxPossibleDelta, Math.abs(delta)) * -1; + } else { + const maxPossibleDelta = Math.min( + this.initialPaneSize - min, + maxSibling - this.initialSiblingSize + ) + delta = Math.min(maxPossibleDelta, Math.abs(delta)); + } + return [this.initialPaneSize - delta, this.initialSiblingSize + delta]; + } +} + +/** + * @hidden @internal + * Represents the draggable bar that visually separates panes and allows for changing their sizes. + */ +@Component({ + selector: 'igx-splitter-bar', + templateUrl: './splitter-bar.component.html', + imports: [IgxDragDirective, IgxDragIgnoreDirective] +}) +export class IgxSplitBarComponent { + /** + * Set css class to the host element. + */ + @HostBinding('class.igx-splitter-bar-host') + public cssClass = 'igx-splitter-bar-host'; + + /** + * Sets the visibility of the handle and expanders in the splitter bar. + */ + @Input({ transform: booleanAttribute }) + public nonCollapsible; + + /** + * Gets/Sets the orientation. + */ + @Input() + public type: SplitterType = SplitterType.Horizontal; + + /** + * Sets/gets the element order. + */ + @HostBinding('style.order') + @Input() + public order!: number; + + /** + * @hidden + * @internal + */ + @HostBinding('attr.tabindex') + public get tabindex() { + return this.resizeDisallowed ? null : 0; + } + + /** + * @hidden + * @internal + */ + @HostBinding('attr.aria-orientation') + public get orientation() { + return this.type === SplitterType.Horizontal ? 'horizontal' : 'vertical'; + } + + /** + * @hidden + * @internal + */ + public get cursor() { + if (this.resizeDisallowed) { + return ''; + } + return this.type === SplitterType.Horizontal ? 'col-resize' : 'row-resize'; + } + + /** + * Sets/gets the `SplitPaneComponent` associated with the current `SplitBarComponent`. + * + * @memberof SplitBarComponent + */ + @Input() + public pane!: IgxSplitterPaneComponent; + + /** + * Sets/Gets the `SplitPaneComponent` sibling components associated with the current `SplitBarComponent`. + */ + @Input() + public siblings!: Array; + + /** + * An event that is emitted whenever we start dragging the current `SplitBarComponent`. + */ + @Output() + public moveStart = new EventEmitter(); + + /** + * An event that is emitted while we are dragging the current `SplitBarComponent`. + */ + @Output() + public moving = new EventEmitter(); + + @Output() + public movingEnd = new EventEmitter(); + + /** + * A temporary holder for the pointer coordinates. + */ + private startPoint!: number; + + private interactionKeys = new Set('right down left up arrowright arrowdown arrowleft arrowup'.split(' ')); + + /** + * @hidden @internal + */ + public get prevButtonHidden() { + return this.siblings[0]?.collapsed && !this.siblings[1]?.collapsed; + } + + /** + * @hidden @internal + */ + @HostListener('keydown', ['$event']) + public keyEvent(event: KeyboardEvent) { + const key = event.key.toLowerCase(); + const ctrl = event.ctrlKey; + event.stopPropagation(); + if (this.interactionKeys.has(key)) { + event.preventDefault(); + } + switch (key) { + case 'arrowup': + case 'up': + if (this.type === SplitterType.Vertical) { + if (ctrl) { + this.onCollapsing(false); + break; + } + if (!this.resizeDisallowed) { + event.preventDefault(); + this.moveStart.emit(this.pane); + this.moving.emit(10); + } + } + break; + case 'arrowdown': + case 'down': + if (this.type === SplitterType.Vertical) { + if (ctrl) { + this.onCollapsing(true); + break; + } + if (!this.resizeDisallowed) { + event.preventDefault(); + this.moveStart.emit(this.pane); + this.moving.emit(-10); + } + } + break; + case 'arrowleft': + case 'left': + if (this.type === SplitterType.Horizontal) { + if (ctrl) { + this.onCollapsing(false); + break; + } + if (!this.resizeDisallowed) { + event.preventDefault(); + this.moveStart.emit(this.pane); + this.moving.emit(10); + } + } + break; + case 'arrowright': + case 'right': + if (this.type === SplitterType.Horizontal) { + if (ctrl) { + this.onCollapsing(true); + break; + } + if (!this.resizeDisallowed) { + event.preventDefault(); + this.moveStart.emit(this.pane); + this.moving.emit(-10); + } + } + break; + default: + break; + } + } + + /** + * @hidden @internal + */ + public get dragDir() { + return this.type === SplitterType.Horizontal ? DragDirection.VERTICAL : DragDirection.HORIZONTAL; + } + + /** + * @hidden @internal + */ + public get nextButtonHidden() { + return this.siblings[1]?.collapsed && !this.siblings[0]?.collapsed; + } + + /** + * @hidden @internal + */ + public onDragStart(event: IDragStartEventArgs) { + if (this.resizeDisallowed) { + event.cancel = true; + return; + } + this.startPoint = this.type === SplitterType.Horizontal ? event.startX : event.startY; + this.moveStart.emit(this.pane); + } + + /** + * @hidden @internal + */ + public onDragMove(event: IDragMoveEventArgs) { + const isHorizontal = this.type === SplitterType.Horizontal; + const curr = isHorizontal ? event.pageX : event.pageY; + const delta = this.startPoint - curr; + if (delta !== 0) { + this.moving.emit(delta); + event.cancel = true; + event.owner.element.nativeElement.style.transform = ''; + } + } + + public onDragEnd(event: any) { + const isHorizontal = this.type === SplitterType.Horizontal; + const curr = isHorizontal ? event.pageX : event.pageY; + const delta = this.startPoint - curr; + if (delta !== 0) { + this.movingEnd.emit(delta); + } + } + + protected get resizeDisallowed() { + const relatedTabs = this.siblings; + return !!relatedTabs.find(x => x?.resizable === false || x?.collapsed === true); + } + + /** + * @hidden @internal + */ + public onCollapsing(next: boolean) { + const prevSibling = this.siblings[0]; + const nextSibling = this.siblings[1]; + let target; + if (next) { + // if next is clicked when prev pane is hidden, show prev pane, else hide next pane. + target = prevSibling.collapsed ? prevSibling : nextSibling; + } else { + // if prev is clicked when next pane is hidden, show next pane, else hide prev pane. + target = nextSibling.collapsed ? nextSibling : prevSibling; + } + target.toggle(); + } +} diff --git a/projects/igniteui-angular/splitter/src/splitter/splitter.module.ts b/projects/igniteui-angular/splitter/src/splitter/splitter.module.ts new file mode 100644 index 00000000000..c1f37cd2fc4 --- /dev/null +++ b/projects/igniteui-angular/splitter/src/splitter/splitter.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { IGX_SPLITTER_DIRECTIVES } from './public_api'; + +/** + * @hidden + * @deprecated + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + ...IGX_SPLITTER_DIRECTIVES + ], + exports: [ + ...IGX_SPLITTER_DIRECTIVES + ] +}) +export class IgxSplitterModule { } diff --git a/projects/igniteui-angular/src/lib/action-strip/grid-actions/grid-editing-actions.component.spec.ts b/projects/igniteui-angular/src/lib/action-strip/grid-actions/grid-editing-actions.component.spec.ts index 4a86a58fdf4..34750256cd2 100644 --- a/projects/igniteui-angular/src/lib/action-strip/grid-actions/grid-editing-actions.component.spec.ts +++ b/projects/igniteui-angular/src/lib/action-strip/grid-actions/grid-editing-actions.component.spec.ts @@ -15,6 +15,7 @@ import { IgxGridPinningActionsComponent } from './grid-pinning-actions.component import { IgxActionStripComponent } from '../action-strip.component'; import { IRowDataCancelableEventArgs, IgxColumnComponent } from '../../grids/public_api'; import { SampleTestData } from '../../test-utils/sample-test-data.spec'; +import { SortingDirection } from '../../data-operations/sorting-strategy'; describe('igxGridEditingActions #grid ', () => { let fixture; @@ -274,6 +275,59 @@ describe('igxGridEditingActions #grid ', () => { expect(actionStrip.hidden).toBeTrue(); }); + + it('should auto-hide on delete action click.', () => { + const row = grid.rowList.toArray()[0]; + actionStrip.show(row); + fixture.detectChanges(); + + expect(actionStrip.hidden).toBeFalse(); + + const deleteIcon = fixture.debugElement.queryAll(By.css(`igx-grid-editing-actions igx-icon`))[1]; + expect(deleteIcon.nativeElement.innerText).toBe('delete'); + deleteIcon.parent.triggerEventHandler('click', new Event('click')); + fixture.detectChanges(); + + expect(actionStrip.hidden).toBeTrue(); + + }); + + it('should auto-hide if context row is destroyed.', () => { + const row = grid.rowList.toArray()[0]; + actionStrip.show(row); + fixture.detectChanges(); + + expect(actionStrip.hidden).toBeFalse(); + + // bind to no data, which removes all rows. + grid.data = []; + grid.cdr.detectChanges(); + + expect((row.cdr as any).destroyed).toBeTrue(); + expect(actionStrip.hidden).toBeTrue(); + }); + + it('should auto-hide if context row is cached.', () => { + // create group rows + grid.groupBy({ fieldName: 'ContactTitle', dir: SortingDirection.Desc, ignoreCase: false }); + fixture.detectChanges(); + + // show for first data row + const row = grid.dataRowList.toArray()[0]; + actionStrip.show(row); + fixture.detectChanges(); + + // collapse all groups to cache data rows + grid.toggleAllGroupRows(); + fixture.detectChanges(); + + // not destroyed, but not in DOM anymore + expect((row.cdr as any).destroyed).toBeFalse(); + expect(row.element.nativeElement.isConnected).toBe(false); + + // action strip should be hidden + expect(actionStrip.hidden).toBeTrue(); + }); }); describe('auto show/hide in HierarchicalGrid', () => { diff --git a/projects/igniteui-angular/src/lib/badge/README.md b/projects/igniteui-angular/src/lib/badge/README.md index cd6e11958ac..aefde3b2065 100644 --- a/projects/igniteui-angular/src/lib/badge/README.md +++ b/projects/igniteui-angular/src/lib/badge/README.md @@ -1,7 +1,7 @@ # igx-badge The **igx-badge** component is an absolutely positioned element that can be used in tandem with other components such as avatars, navigation menus, or anywhere else in an app where some active indication is required. -With the igx-badge you can display active count or an icon in several different predefined styles. +With the igx-badge you can display active count or an icon in several different predefined styles and sizes. A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/badge.html) # Usage @@ -14,9 +14,12 @@ A walkthrough of how to get started can be found [here](https://www.infragistics |:----------|:-------------:|:------| | `id` | string | Unique identifier of the component. If not provided it will be automatically generated.| | `type` | string | Set the type of the badge to either `primary`, `info`, `success`, `warning`, or `error`. This will change the background color of the badge according to the values set in the default theme. | +| `dot` | boolean | Set whether the badge is displayed as a minimal dot indicator without any content. Default is `false`. | | `position` | string | Set the position of the badge relative to its parent container to either `top-right`, `top-left`, `bottom-right`, or `bottom-left`. | | `value` | string | Set the value to be displayed inside the badge. | | `icon` | string | Set an icon for the badge from the material icons set. Will not be displayed if `value` for the badge is already set. | +| `outlined` | boolean | Set whether the badge should have an outline. Default is `false`. | +| `shape` | string | Set the shape of the badge to either `rounded` or `square`. Default is `rounded`. | # Examples @@ -26,3 +29,18 @@ Using `igx-badge` with the `igx-avatar` component to show active status. ``` + +Using `igx-badge` as a dot indicator for notifications. +```html + + +``` + +Using different badge types. +```html + + + + + +``` \ No newline at end of file diff --git a/projects/igniteui-angular/src/lib/badge/badge.component.spec.ts b/projects/igniteui-angular/src/lib/badge/badge.component.spec.ts index d6654f0974e..541c38a0cbd 100644 --- a/projects/igniteui-angular/src/lib/badge/badge.component.spec.ts +++ b/projects/igniteui-angular/src/lib/badge/badge.component.spec.ts @@ -11,7 +11,8 @@ describe('Badge', () => { InitBadgeWithDefaultsComponent, InitBadgeWithIconComponent, IgxBadgeComponent, - InitBadgeWithIconARIAComponent + InitBadgeWithIconARIAComponent, + InitBadgeWithDotComponent ] }).compileComponents(); })); @@ -87,6 +88,26 @@ describe('Badge', () => { const container = fixture.nativeElement.querySelectorAll('.igx-badge')[0]; expect(container.getAttribute('aria-roledescription')).toMatch(expectedDescription); }); + + it('Initializes badge with dot property', () => { + const fixture = TestBed.createComponent(InitBadgeWithDotComponent); + fixture.detectChanges(); + const badge = fixture.componentInstance.badge; + + expect(badge.dot).toBeTruthy(); + expect(fixture.debugElement.query(By.css('.igx-badge--dot'))).toBeTruthy(); + }); + + it('Initializes success badge as dot', () => { + const fixture = TestBed.createComponent(InitBadgeWithDotComponent); + fixture.detectChanges(); + const badge = fixture.componentInstance.badge; + + expect(badge.type).toBe(IgxBadgeType.SUCCESS); + expect(badge.dot).toBeTruthy(); + expect(fixture.debugElement.query(By.css('.igx-badge--dot'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('.igx-badge--success'))).toBeTruthy(); + }); }); @Component({ @@ -120,3 +141,11 @@ class InitBadgeWithIconComponent { class InitBadgeWithIconARIAComponent { @ViewChild(IgxBadgeComponent, { static: true }) public badge: IgxBadgeComponent; } + +@Component({ + template: ``, + imports: [IgxBadgeComponent] +}) +class InitBadgeWithDotComponent { + @ViewChild(IgxBadgeComponent, { static: true }) public badge: IgxBadgeComponent; +} \ No newline at end of file diff --git a/projects/igniteui-angular/src/lib/badge/badge.component.ts b/projects/igniteui-angular/src/lib/badge/badge.component.ts index fe3990d8b12..c0cfc3a43ad 100644 --- a/projects/igniteui-angular/src/lib/badge/badge.component.ts +++ b/projects/igniteui-angular/src/lib/badge/badge.component.ts @@ -153,7 +153,9 @@ export class IgxBadgeComponent { /** @hidden @internal */ @HostBinding('class.igx-badge--square') public get _squareShape(): boolean { - return this.shape === 'square'; + if (!this.dot) { + return this.shape === 'square'; + } } /** @@ -183,6 +185,20 @@ export class IgxBadgeComponent { @HostBinding('class.igx-badge--outlined') public outlined = false; + /** + * Sets/gets whether the badge is displayed as a dot. + * When true, the badge will be rendered as a minimal 8px indicator without any content. + * Default value is `false`. + * + * @example + * ```html + * + * ``` + */ + @Input({transform: booleanAttribute}) + @HostBinding('class.igx-badge--dot') + public dot = false; + /** * Defines a human-readable, accessor, author-localized description for * the `type` and the `icon` or `value` of the element. diff --git a/projects/igniteui-angular/src/lib/calendar/calendar.component.html b/projects/igniteui-angular/src/lib/calendar/calendar.component.html index e7ec1e520d4..bcca8927465 100644 --- a/projects/igniteui-angular/src/lib/calendar/calendar.component.html +++ b/projects/igniteui-angular/src/lib/calendar/calendar.component.html @@ -192,23 +192,25 @@

    (focus)="this.onWrapperFocus($event)" (blur)="this.onWrapperBlur($event)" > -

    +
    path.includes(year.nativeElement)); @@ -997,7 +1005,9 @@ export class IgxCalendarComponent extends IgxCalendarBaseDirective implements Af this.activeViewIdx = activeViewIdx; this.viewDate = date; - this.wrapper.nativeElement.focus(); + if (this.platform.isBrowser && this.wrapper?.nativeElement) { + this.wrapper.nativeElement.focus(); + } } } diff --git a/projects/igniteui-angular/src/lib/calendar/calendar.services.ts b/projects/igniteui-angular/src/lib/calendar/calendar.services.ts index 450f310d1ba..8eb0e638f5e 100644 --- a/projects/igniteui-angular/src/lib/calendar/calendar.services.ts +++ b/projects/igniteui-angular/src/lib/calendar/calendar.services.ts @@ -1,10 +1,12 @@ -import { Injectable, ElementRef, NgZone } from "@angular/core"; +import { Injectable, ElementRef, NgZone, inject } from "@angular/core"; import { EventManager } from "@angular/platform-browser"; +import { PlatformUtil } from "../core/utils"; @Injectable() export class KeyboardNavigationService { private keyHandlers = new Map void>(); private eventUnsubscribeFn: Function | null = null; + private platform = inject(PlatformUtil); constructor( private eventManager: EventManager, @@ -12,6 +14,10 @@ export class KeyboardNavigationService { ) {} public attachKeyboardHandlers(elementRef: ElementRef, context: any) { + if (!this.platform.isBrowser) { + return this; + } + this.detachKeyboardHandlers(); // Clean up any existing listeners this.ngZone.runOutsideAngular(() => { diff --git a/projects/igniteui-angular/src/lib/calendar/days-view/days-view.component.ts b/projects/igniteui-angular/src/lib/calendar/days-view/days-view.component.ts index 173dbbce11d..413f19b1ac4 100644 --- a/projects/igniteui-angular/src/lib/calendar/days-view/days-view.component.ts +++ b/projects/igniteui-angular/src/lib/calendar/days-view/days-view.component.ts @@ -13,6 +13,9 @@ import { ElementRef, ChangeDetectorRef, ChangeDetectionStrategy, + inject, + DestroyRef, + AfterContentChecked } from '@angular/core'; import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { TitleCasePipe } from '@angular/common'; @@ -20,7 +23,7 @@ import { CalendarSelection, ScrollDirection } from '../../calendar/calendar'; import { IgxDayItemComponent } from './day-item.component'; import { DateRangeType } from '../../core/dates'; import { IgxCalendarBaseDirective } from '../calendar-base'; -import { PlatformUtil, intoChunks } from '../../core/utils'; +import {PlatformUtil, intoChunks, getComponentTheme} from '../../core/utils'; import { IViewChangingEventArgs } from './days-view.interface'; import { areSameMonth, @@ -31,6 +34,7 @@ import { isDateInRanges, } from "../common/helpers"; import { CalendarDay } from '../common/model'; +import {IgxTheme, THEME_TOKEN, ThemeToken} from "../../services/theme/theme.token"; let NEXT_ID = 0; @@ -47,7 +51,7 @@ let NEXT_ID = 0; changeDetection: ChangeDetectionStrategy.OnPush, imports: [IgxDayItemComponent, TitleCasePipe] }) -export class IgxDaysViewComponent extends IgxCalendarBaseDirective { +export class IgxDaysViewComponent extends IgxCalendarBaseDirective implements AfterContentChecked { #standalone = true; /** @@ -197,6 +201,33 @@ export class IgxDaysViewComponent extends IgxCalendarBaseDirective { private _hideTrailingDays: boolean; private _showActiveDay: boolean; + private _destroyRef = inject(DestroyRef); + private _theme: IgxTheme; + + @HostBinding('class.igx-days-view') + public defaultClass = true; + + // Theme-specific classes + @HostBinding('class.igx-days-view--material') + protected get isMaterial(): boolean { + return this._theme === 'material'; + } + + @HostBinding('class.igx-days-view--fluent') + protected get isFluent(): boolean { + return this._theme === 'fluent'; + } + + @HostBinding('class.igx-days-view--bootstrap') + protected get isBootstrap(): boolean { + return this._theme === 'bootstrap'; + } + + @HostBinding('class.igx-days-view--indigo') + protected get isIndigo(): boolean { + return this._theme === 'indigo'; + } + /** * @hidden */ @@ -205,8 +236,37 @@ export class IgxDaysViewComponent extends IgxCalendarBaseDirective { @Inject(LOCALE_ID) _localeId: string, protected el: ElementRef, public override cdr: ChangeDetectorRef, + @Inject(THEME_TOKEN) private themeToken: ThemeToken + ) { super(platform, _localeId, null, cdr); + + this._theme = this.themeToken.theme; + + const themeChange = this.themeToken.onChange((theme) => { + if (this._theme !== theme) { + this._theme = theme; + this.cdr.detectChanges(); + } + }); + + this._destroyRef.onDestroy(() => themeChange.unsubscribe()); + } + + private setComponentTheme() { + // allow DOM theme override (same pattern as input-group) + if (!this.themeToken.preferToken) { + const theme = getComponentTheme(this.el.nativeElement); + + if (theme && theme !== this._theme) { + this._theme = theme; + this.cdr.markForCheck(); + } + } + } + + public ngAfterContentChecked() { + this.setComponentTheme(); } /** @@ -349,7 +409,7 @@ export class IgxDaysViewComponent extends IgxCalendarBaseDirective { }); } - if (this.tabIndex !== -1) { + if (this.tabIndex !== -1 && this.platform.isBrowser && this.el?.nativeElement) { this.el.nativeElement.focus(); } diff --git a/projects/igniteui-angular/src/lib/calendar/month-picker/month-picker.component.ts b/projects/igniteui-angular/src/lib/calendar/month-picker/month-picker.component.ts index 1929f51da7c..964f8ef0aa5 100644 --- a/projects/igniteui-angular/src/lib/calendar/month-picker/month-picker.component.ts +++ b/projects/igniteui-angular/src/lib/calendar/month-picker/month-picker.component.ts @@ -152,7 +152,9 @@ export class IgxMonthPickerComponent extends IgxCalendarBaseDirective implements if (this.platform.isActivationKey(event)) { this.viewDate = date; - this.wrapper.nativeElement.focus(); + if (this.platform.isBrowser && this.wrapper?.nativeElement) { + this.wrapper.nativeElement.focus(); + } } } @@ -188,9 +190,13 @@ export class IgxMonthPickerComponent extends IgxCalendarBaseDirective implements public override activeViewDecade() { super.activeViewDecade(); - requestAnimationFrame(() => { - this.dacadeView.el.nativeElement.focus(); - }); + if (this.platform.isBrowser) { + requestAnimationFrame(() => { + if (this.dacadeView?.el?.nativeElement) { + this.dacadeView.el.nativeElement.focus(); + } + }); + } } /** @@ -221,7 +227,9 @@ export class IgxMonthPickerComponent extends IgxCalendarBaseDirective implements ); this.activeView = IgxCalendarView.Year; - this.wrapper.nativeElement.focus(); + if (this.platform.isBrowser && this.wrapper?.nativeElement) { + this.wrapper.nativeElement.focus(); + } } /** @@ -279,7 +287,9 @@ export class IgxMonthPickerComponent extends IgxCalendarBaseDirective implements @HostListener('mousedown', ['$event']) protected onMouseDown(event: MouseEvent) { event.stopPropagation(); - this.wrapper.nativeElement.focus(); + if (this.platform.isBrowser && this.wrapper?.nativeElement) { + this.wrapper.nativeElement.focus(); + } } private _showActiveDay: boolean; diff --git a/projects/igniteui-angular/src/lib/calendar/months-view/months-view.component.ts b/projects/igniteui-angular/src/lib/calendar/months-view/months-view.component.ts index 956e7acc000..3db6ccb0a93 100644 --- a/projects/igniteui-angular/src/lib/calendar/months-view/months-view.component.ts +++ b/projects/igniteui-angular/src/lib/calendar/months-view/months-view.component.ts @@ -5,6 +5,7 @@ import { ElementRef, booleanAttribute, Inject, + inject, } from "@angular/core"; import { IgxCalendarMonthDirective } from "../calendar.directives"; import { TitleCasePipe } from "@angular/common"; @@ -16,6 +17,7 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; import { CalendarDay } from "../common/model"; import type { DayInterval } from "../common/model"; import { calendarRange } from "../common/helpers"; +import { PlatformUtil } from "../../core/utils"; let NEXT_ID = 0; @@ -37,6 +39,7 @@ let NEXT_ID = 0; }) export class IgxMonthsViewComponent extends IgxCalendarViewDirective implements ControlValueAccessor { #standalone = true; + private platform = inject(PlatformUtil); /** * Sets/gets the `id` of the months view. @@ -139,7 +142,7 @@ export class IgxMonthsViewComponent extends IgxCalendarViewDirective implements * @hidden */ protected onMouseDown() { - if (this.tabIndex !== -1) { + if (this.tabIndex !== -1 && this.platform.isBrowser && this.el?.nativeElement) { this.el.nativeElement.focus(); } } diff --git a/projects/igniteui-angular/src/lib/calendar/years-view/years-view.component.ts b/projects/igniteui-angular/src/lib/calendar/years-view/years-view.component.ts index 07fd46a8de5..8e145269357 100644 --- a/projects/igniteui-angular/src/lib/calendar/years-view/years-view.component.ts +++ b/projects/igniteui-angular/src/lib/calendar/years-view/years-view.component.ts @@ -4,6 +4,7 @@ import { HostBinding, ElementRef, Inject, + inject, } from "@angular/core"; import { IgxCalendarYearDirective } from "../calendar.directives"; import { @@ -14,6 +15,7 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; import { CalendarDay } from "../common/model"; import type { DayInterval } from "../common/model"; import { calendarRange } from "../common/helpers"; +import { PlatformUtil } from "../../core/utils"; @Component({ providers: [ @@ -33,6 +35,7 @@ import { calendarRange } from "../common/helpers"; }) export class IgxYearsViewComponent extends IgxCalendarViewDirective implements ControlValueAccessor { #standalone = true; + private platform = inject(PlatformUtil); /** * The default css class applied to the component. @@ -158,7 +161,7 @@ export class IgxYearsViewComponent extends IgxCalendarViewDirective implements C * @hidden */ protected onMouseDown() { - if (this.tabIndex !== -1) { + if (this.tabIndex !== -1 && this.platform.isBrowser && this.el?.nativeElement) { this.el.nativeElement.focus(); } } diff --git a/projects/igniteui-angular/src/lib/core/styles/components/action-strip/_action-strip-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/action-strip/_action-strip-theme.scss index 78d97b80462..26c2c7dd010 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/action-strip/_action-strip-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/action-strip/_action-strip-theme.scss @@ -2,64 +2,6 @@ @use '../../base' as *; @use '../../themes/schemas' as *; -//// -/// @group themes -/// @access public -/// @author Simeon Simeonoff -/// @author Marin Popov -//// - -/// If only background color is specified, text/icon color will be assigned automatically to a contrasting color. -/// @param {Map} $schema [$light-material-schema] - The schema used as basis for styling the component. -/// -/// @param {Color} $icon-color [null] - The color used for the actions icons. -/// @param {Color} $background [null] - The color used for the action strip component content background. -/// @param {Color} $actions-background [null] - The color used for the actions background. -/// @param {Color} $delete-action [null] - The color used for the delete icon in action strip component. -/// @param {List} $actions-border-radius [null] - The border radius used for actions container inside action strip component. -/// -/// @example scss Change the background and icon colors in action strip -/// $my-action-strip-theme: action-strip-theme($background: black); -/// // Pass the theme to the css-vars() mixin -/// @include css-vars($my-action-strip-theme); -@function action-strip-theme( - $schema: $light-material-schema, - - $background: null, - $actions-background: null, - $icon-color: null, - $delete-action: null, - $actions-border-radius: null, -) { - $name: 'igx-action-strip'; - $action-strip-schema: (); - - @if map.has-key($schema, 'action-strip') { - $action-strip-schema: map.get($schema, 'action-strip'); - } @else { - $action-strip-schema: $schema; - } - - $theme: digest-schema($action-strip-schema); - - @if not($icon-color) and $actions-background { - $icon-color: adaptive-contrast(var(--actions-background)); - } - - @if not($actions-border-radius) { - $actions-border-radius: map.get($theme, 'actions-border-radius'); - } - - @return extend($theme, ( - name: $name, - background: $background, - actions-background: $actions-background, - icon-color: $icon-color, - delete-action: $delete-action, - actions-border-radius: $actions-border-radius, - )); -} - /// @deprecated Use the `css-vars` mixin instead. /// @see {mixin} css-vars /// @param {Map} $theme - The theme used to style the component. diff --git a/projects/igniteui-angular/src/lib/core/styles/components/badge/_badge-component.scss b/projects/igniteui-angular/src/lib/core/styles/components/badge/_badge-component.scss index 909374bd288..a630908f0f7 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/badge/_badge-component.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/badge/_badge-component.scss @@ -35,6 +35,10 @@ @extend %igx-badge--error !optional; } + @include m(dot) { + @extend %igx-badge--dot !optional; + } + @include m(outlined) { @extend %igx-badge--outlined !optional; } diff --git a/projects/igniteui-angular/src/lib/core/styles/components/badge/_badge-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/badge/_badge-theme.scss index 2e82deb4545..47555fe0140 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/badge/_badge-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/badge/_badge-theme.scss @@ -6,12 +6,15 @@ /// @param {Map} $theme - The theme used to style the component. @mixin badge($theme) { @include css-vars($theme); - + $theme-variant: map.get($theme, '_meta', 'variant'); $variant: map.get($theme, '_meta', 'theme'); %igx-badge-display { - --size: #{rem(22px)}; - --_badge-size: var(--size); + @include sizable(); + + --component-size: var(--ig-size, #{var-get($theme, 'default-size')}); + --badge-size: var(--component-size); + --_badge-size: #{var-get($theme, 'size')}; display: inline-flex; justify-content: center; @@ -21,22 +24,22 @@ color: var-get($theme, 'text-color'); background: var-get($theme, 'background-color'); border-radius: calc(var(--size) / 2); - box-shadow: var-get($theme, 'shadow'); + box-shadow: var-get($theme, 'elevation'); overflow: hidden; igx-icon { - --size: var(--igx-icon-size, calc(var(--_badge-size) / 2)); + --size: var(--igx-icon-size, #{sizable(rem(12px), rem(14px), rem(16px))}); + --component-size: var(--badge-size); display: inline-flex; justify-content: center; align-items: center; - font-weight: 400; color: var-get($theme, 'icon-color'); } @if $variant == 'indigo' { igx-icon { - $icon-size: rem(12px); + $icon-size: sizable(rem(8px), rem(10px), rem(12px)); --ig-icon-size: #{$icon-size}; --igx-icon-size: #{$icon-size}; @@ -45,7 +48,7 @@ } %igx-badge--outlined { - box-shadow: inset 0 0 0 rem(if($variant != 'bootstrap', 2px, 1px)) var-get($theme, 'border-color'); + box-shadow: 0 0 0 rem(2px) var-get($theme, 'border-color'); } %igx-badge--square { @@ -54,23 +57,57 @@ %igx-badge-value { white-space: nowrap; - padding-inline: pad-inline(rem(4px)); + padding-inline: pad-inline(rem(4px), rem(6px), if($variant == 'indigo', rem(6px), rem(8px))); } %igx-badge--success { - background: color($color: 'success'); + background: color($color: 'success', $variant: if($variant != 'material', if($variant == 'indigo', 700, 500), 900)); } %igx-badge--info { - background: color($color: 'info'); + background: color($color: 'info', $variant: if($variant != 'material', if($variant == 'fluent', 700, 500), 800)); } %igx-badge--warn { background: color($color: 'warn'); + + @if $variant == 'indigo' and $theme-variant == 'light' { + color: color($color: 'gray', $variant: 900); + + igx-icon { + color: color($color: 'gray', $variant: 900); + } + } @else if $variant == 'indigo' and $theme-variant == 'dark' { + color: color($color: 'gray', $variant: 50); + + igx-icon { + color: color($color: 'gray', $variant: 50); + } + } @else { + color: contrast-color($color: 'warn', $variant: 500); + + igx-icon { + color: contrast-color($color: 'warn', $variant: 500); + } + } } %igx-badge--error { - background: color($color: 'error'); + background: color($color: 'error', $variant: if($variant == 'material', 700, 500)); + color: contrast-color($color: 'error', $variant: if($variant == 'bootstrap', 100, 900)); + } + + %igx-badge--dot { + --_dot-size: #{var-get($theme, 'dot-size')}; + + min-width: var(--_dot-size); + min-height: var(--_dot-size); + padding: 0; + + igx-icon, + > * { + display: none; + } } %igx-badge--hidden { @@ -79,15 +116,28 @@ } /// Adds typography styles for the igx-badge component. -/// Uses the 'caption' category from the typographic scale. +/// Uses 'caption' and 'body-2' categories from the typographic scale. /// @group typography /// @param {Map} $categories [(text: 'caption')] - The categories from the typographic scale used for type styles. -@mixin badge-typography($categories: (text: 'caption')) { +@mixin badge-typography($categories: (text: null), $theme: null) { $text: map.get($categories, 'text'); %igx-badge-display { - @include type-style($text) { - margin: 0; + @if $text { + @include type-style($text); + } @else { + @if $theme == 'indigo' { + @include type-style('button', false) { + font-size: sizable(rem(9px), rem(10px), var(--ig-button-font-size)); + line-height: sizable(rem(12px), rem(14px), var(--ig-button-line-height)); + } + } @else { + font-size: sizable(var(--ig-caption-font-size), var(--ig-body-2-font-size), var(--ig-body-2-font-size)); + font-weight: sizable(var(--ig-caption-font-weight), var(--ig-body-2-font-weight), var(--ig-body-2-font-weight)); + line-height: sizable(var(--ig-caption-line-height), var(--ig-body-2-line-height), var(--ig-body-2-line-height)); + letter-spacing: sizable(var(--ig-caption-letter-spacing), var(--ig-body-2-letter-spacing), var(--ig-body-2-letter-spacing)); + text-transform: sizable(var(--ig-caption-text-transform), var(--ig-body-2-text-transform), var(--ig-body-2-text-transform)); + } } } } diff --git a/projects/igniteui-angular/src/lib/core/styles/components/bottom-nav/_bottom-nav-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/bottom-nav/_bottom-nav-theme.scss index 6f21583c88e..549b33c75ed 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/bottom-nav/_bottom-nav-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/bottom-nav/_bottom-nav-theme.scss @@ -43,13 +43,13 @@ %igx-bottom-nav-menu--top { inset-block-start: 0; inset-block-end: inherit; - box-shadow: var-get($theme, 'shadow'); + box-shadow: var-get($theme, 'elevation'); } %igx-bottom-nav-menu--bottom { inset-block-start: inherit; inset-block-end: 0; - box-shadow: var-get($theme, 'shadow'); + box-shadow: var-get($theme, 'elevation'); } %igx-bottom-nav-menu-item { diff --git a/projects/igniteui-angular/src/lib/core/styles/components/button-group/_button-group-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/button-group/_button-group-theme.scss index b942c0e20a8..8757e17a0c0 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/button-group/_button-group-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/button-group/_button-group-theme.scss @@ -36,7 +36,7 @@ %igx-group-display { display: flex; - box-shadow: var-get($theme, 'shadow'); + box-shadow: var-get($theme, 'elevation'); border-radius: var-get($theme, 'border-radius'); button { @@ -269,6 +269,7 @@ color: var-get($theme, 'item-focused-text-color'); background: var-get($theme, 'item-focused-background'); border-color: var-get($theme, 'item-focused-border-color'); + box-shadow: 0 0 0 rem(3px) var-get($theme, 'idle-shadow-color'); z-index: 2; igx-icon { @@ -352,12 +353,12 @@ &:active { @extend %item-overlay; - color: var-get($theme, 'item-selected-text-color'); + color: var-get($theme, 'item-selected-hover-text-color'); background: var-get($theme, 'item-selected-background'); border-color: var-get($theme, 'item-selected-border-color'); igx-icon { - color: var-get($theme, 'item-selected-icon-color'); + color: var-get($theme, 'item-selected-hover-icon-color'); } &::before { diff --git a/projects/igniteui-angular/src/lib/core/styles/components/button/_button-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/button/_button-theme.scss index 2b978400de7..ee01588453e 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/button/_button-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/button/_button-theme.scss @@ -193,7 +193,7 @@ $contained-shadow: map.get( ( - 'material': var-get($contained-theme, 'resting-shadow'), + 'material': var-get($contained-theme, 'resting-elevation'), 'fluent': none, 'bootstrap': none, 'indigo': none, @@ -203,7 +203,7 @@ $contained-shadow--hover: map.get( ( - 'material': var-get($contained-theme, 'hover-shadow'), + 'material': var-get($contained-theme, 'hover-elevation'), 'fluent': none, 'bootstrap': none, 'indigo': none, @@ -213,8 +213,8 @@ $contained-shadow--focus: map.get( ( - 'material': var-get($contained-theme, 'focus-shadow'), - 'fluent': 0 0 0 rem(3px) var-get($contained-theme, 'shadow-color'), + 'material': var-get($contained-theme, 'focus-elevation'), + 'fluent': none, 'bootstrap': 0 0 0 rem(4px) var-get($contained-theme, 'shadow-color'), 'indigo': 0 0 0 rem(3px) var-get($contained-theme, 'shadow-color'), @@ -224,8 +224,8 @@ $contained-shadow--active: map.get( ( - 'material': var-get($contained-theme, 'active-shadow'), - 'fluent': 0 0 0 rem(3px) var-get($contained-theme, 'shadow-color'), + 'material': var-get($contained-theme, 'active-elevation'), + 'fluent': none, 'bootstrap': 0 0 0 rem(4px) var-get($contained-theme, 'shadow-color'), 'indigo': 0 0 0 rem(3px) var-get($contained-theme, 'shadow-color'), @@ -235,7 +235,7 @@ $fab-shadow: map.get( ( - 'material': var-get($fab-theme, 'resting-shadow'), + 'material': var-get($fab-theme, 'resting-elevation'), 'fluent': none, 'bootstrap': none, 'indigo': none, @@ -245,7 +245,7 @@ $fab-shadow--hover: map.get( ( - 'material': var-get($fab-theme, 'hover-shadow'), + 'material': var-get($fab-theme, 'hover-elevation'), 'fluent': none, 'bootstrap': none, 'indigo': none, @@ -255,8 +255,8 @@ $fab-shadow--focus: map.get( ( - 'material': var-get($fab-theme, 'focus-shadow'), - 'fluent': 0 0 0 rem(3px) var-get($fab-theme, 'shadow-color'), + 'material': var-get($fab-theme, 'focus-elevation'), + 'fluent': none, 'bootstrap': 0 0 0 rem(4px) var-get($fab-theme, 'shadow-color'), 'indigo': 0 0 0 rem(3px) var-get($fab-theme, 'shadow-color'), ), @@ -265,8 +265,8 @@ $fab-shadow--active: map.get( ( - 'material': var-get($fab-theme, 'active-shadow'), - 'fluent': 0 0 0 rem(3px) var-get($fab-theme, 'shadow-color'), + 'material': var-get($fab-theme, 'active-elevation'), + 'fluent': none, 'bootstrap': 0 0 0 rem(4px) var-get($fab-theme, 'shadow-color'), 'indigo': 0 0 0 rem(3px) var-get($fab-theme, 'shadow-color'), ), @@ -407,7 +407,11 @@ border-color: var-get($flat-theme, 'focus-visible-border-color'); igx-icon { - color: var-get($flat-theme, 'icon-color'); + @if $variant == 'material' { + color: var-get($flat-theme, 'icon-color-hover'); + } @else { + color: var-get($flat-theme, 'icon-color'); + } } &:hover { @@ -422,6 +426,7 @@ &:active { background: var-get($flat-theme, 'focus-background'); color: var-get($flat-theme, 'focus-foreground'); + border-color: var-get($flat-theme, 'focus-border-color'); igx-icon { color: var-get($flat-theme, 'focus-foreground'); @@ -520,18 +525,20 @@ color: var-get($outlined-theme, 'focus-visible-foreground'); border-color: var-get($outlined-theme, 'focus-visible-border-color'); - igx-icon { - color: var-get($outlined-theme, 'focus-visible-foreground'); + @if $variant == 'material' or $variant == 'bootstrap' { + igx-icon { + color: var-get($outlined-theme, 'icon-color-hover'); + } + } @else { + igx-icon { + color: var-get($outlined-theme, 'icon-color'); + } } @if $variant == 'bootstrap' { box-shadow: 0 0 0 rem(4px) var-get($outlined-theme, 'shadow-color'); } @else if $variant == 'indigo' { box-shadow: 0 0 0 rem(3px) var-get($outlined-theme, 'shadow-color'); - - igx-icon { - color: var-get($outlined-theme, 'icon-color'); - } } &:hover { @@ -547,7 +554,11 @@ &:active { background: var-get($outlined-theme, 'focus-background'); color: var-get($outlined-theme, 'focus-foreground'); - border-color: var-get($outlined-theme, 'active-border-color'); + border-color: var-get($outlined-theme, 'focus-border-color'); + + igx-icon { + color: var-get($outlined-theme, 'focus-foreground'); + } @if $variant == 'indigo' { igx-icon { @@ -573,7 +584,10 @@ background: var-get($contained-theme, 'background'); border-color: var-get($contained-theme, 'border-color'); border-radius: var-get($contained-theme, 'border-radius'); - box-shadow: var-get($contained-theme, 'resting-shadow'); + + @if $variant == 'material' { + box-shadow: var-get($contained-theme, 'resting-elevation'); + } igx-icon { color: var-get($contained-theme, 'icon-color'); @@ -583,7 +597,10 @@ color: var-get($contained-theme, 'hover-foreground'); background: var-get($contained-theme, 'hover-background'); border-color: var-get($contained-theme, 'hover-border-color'); - box-shadow: var-get($contained-theme, 'hover-shadow'); + + @if $variant == 'material' { + box-shadow: var-get($contained-theme, 'hover-elevation'); + } igx-icon { color: var-get($contained-theme, 'icon-color-hover'); @@ -594,7 +611,10 @@ color: var-get($contained-theme, 'active-foreground'); background: var-get($contained-theme, 'active-background'); border-color: var-get($contained-theme, 'active-border-color'); - box-shadow: var-get($contained-theme, 'active-shadow'); + + @if $variant == 'material' { + box-shadow: var-get($contained-theme, 'active-elevation'); + } igx-icon { color: var-get($contained-theme, 'active-foreground'); @@ -627,7 +647,7 @@ } @if $variant == 'material' { - box-shadow: var-get($contained-theme, 'focus-shadow'); + box-shadow: var-get($contained-theme, 'focus-elevation'); } @else { box-shadow: $contained-shadow--active; } @@ -652,13 +672,18 @@ } @if $variant == 'material' { - box-shadow: var-get($contained-theme, 'focus-shadow'); + box-shadow: var-get($contained-theme, 'focus-elevation'); } } &:active { color: var-get($contained-theme, 'focus-foreground'); background: var-get($contained-theme, 'focus-background'); + border-color: var-get($contained-theme, 'focus-border-color'); + + igx-icon { + color: var-get($contained-theme, 'focus-foreground'); + } @if $variant == 'indigo' { igx-icon { @@ -713,7 +738,10 @@ background: var-get($fab-theme, 'background'); border-color: var-get($fab-theme, 'border-color'); border-radius: var-get($fab-theme, 'border-radius'); - box-shadow: var-get($fab-theme, 'resting-shadow'); + + @if $variant == 'material' { + box-shadow: var-get($fab-theme, 'resting-elevation'); + } igx-icon { color: var-get($fab-theme, 'icon-color'); @@ -723,7 +751,10 @@ color: var-get($fab-theme, 'hover-foreground'); background: var-get($fab-theme, 'hover-background'); border-color: var-get($fab-theme, 'hover-border-color'); - box-shadow: var-get($fab-theme, 'hover-shadow'); + + @if $variant == 'material' { + box-shadow: var-get($fab-theme, 'hover-elevation'); + } igx-icon { color: var-get($fab-theme, 'icon-color-hover'); @@ -734,7 +765,10 @@ color: var-get($fab-theme, 'active-foreground'); background: var-get($fab-theme, 'active-background'); border-color: var-get($fab-theme, 'active-border-color'); - box-shadow: var-get($fab-theme, 'active-shadow'); + + @if $variant == 'material' { + box-shadow: var-get($fab-theme, 'active-elevation'); + } igx-icon { color: var-get($fab-theme, 'active-foreground'); @@ -758,7 +792,7 @@ } @if $variant == 'material' { - box-shadow: var-get($fab-theme, 'focus-shadow'); + box-shadow: var-get($fab-theme, 'focus-elevation'); } @else { box-shadow: $contained-shadow--focus; } @@ -787,6 +821,11 @@ &:active { background: var-get($fab-theme, 'focus-background'); color: var-get($fab-theme, 'focus-foreground'); + border-color: var-get($fab-theme, 'focus-border-color'); + + igx-icon { + color: var-get($contained-theme, 'focus-foreground'); + } @if $variant == 'indigo' { igx-icon { diff --git a/projects/igniteui-angular/src/lib/core/styles/components/calendar/_calendar-component.scss b/projects/igniteui-angular/src/lib/core/styles/components/calendar/_calendar-component.scss index ac78526f137..1efc74d8642 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/calendar/_calendar-component.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/calendar/_calendar-component.scss @@ -110,6 +110,21 @@ @extend %days-view-row !optional; } + // LABEL + @include e(label) { + @extend %date !optional; + @extend %weekday-label !optional; + } + + @include e(label, 'week-number') { + @extend %label-week-number !optional; + } + + @include e(label-inner) { + @extend %weekday-label-inner !optional; + } + + // DATE @include e(date) { @extend %date !optional; } @@ -118,396 +133,1171 @@ @extend %date-inner !optional; } + @include e(date-inner, 'week-number') { + @extend %date-inner-week-number !optional; + } + @include e(date, 'week-number') { @extend %date-week-number !optional; } + @include e(date, 'weekend') { + %date-inner { + @extend %date-weekend !optional; + } + } + @include e(date, 'inactive') { - @extend %date-inactive !optional; + %date-inner { + @extend %date-inactive !optional; + } } - @include e(date, $mods: ('inactive', 'special')) { - @extend %date-inactive-special !optional; + // HIDDEN + @include e(date, 'hidden') { + @extend %date-hidden !optional; } + + + + + + + + + + // STATE STYLES + // ----------------------------------------------------------------------------------- + + // ACTIVE PLAYS ROLE FOR FOCUS @include e(date, 'active') { - @extend %date-active !optional; + %date-inner { + @extend %date-focus !optional; + } } + // SELECTED @include e(date, 'selected') { - @extend %date-selected !optional; + %date-inner { + @extend %date-selected !optional; + } } @include e(date, $mods: ('selected', 'active')) { - @extend %date-selected-active !optional; + %date-inner { + @extend %date-selected--focus !optional; + } } - @include e(date, $mods: ('selected', 'special')) { - @extend %date-selected-special !optional; + // CURRENT + @include e(date, 'current') { + %date-inner { + @extend %date-current !optional; + @extend %date-current-border-radius !optional; + } } - @include e(date, $mods: ('selected', 'special', 'active')) { - @extend %date-selected-special-active !optional; + @include e(date, $mods: ('current', 'active')) { + %date-inner { + @extend %date-current--focus !optional; + } } - @include e(date, $mods: ('selected', 'range')) { - @extend %date-selected-range !optional; + @include e(date, $mods: ('current', 'first', 'last')) { + @extend %date-current-border-radius !optional; } - @include e(date, $mods: ('selected', 'special','range')) { - @extend %date-selected-special-range !optional; + @include e(date, $mods: ('current', 'selected'), $not: ('range')) { + %date-inner { + @extend %date-current--selected !optional; + } } - @include e(date, $mods: ('selected', 'special', 'active', 'range')) { - @extend %date-selected-special-range !optional; - @extend %date-selected-special-range-active !optional; + @include e(date, $mods: ('current', 'selected', 'active'), $not: ( 'range')) { + %date-inner { + @extend %date-current--selected-focus !optional; + } } - @include e(date, 'hidden') { - @extend %date-hidden !optional; + @include e(date, $mods: ('current', 'selected', 'first')) { + %date-inner { + @extend %date-current--selected !optional; + @extend %date-current--selected-first !optional; + } } - @include e(date, 'range') { - @extend %date-range !optional; - - &:hover { - @extend %date-range-hover !optional; + @include e(date, $mods: ('current', 'selected', 'active', 'first')) { + %date-inner { + @extend %date-current--selected-focus !optional; + @extend %date-current--selected-first-focus !optional; } } - @include e(date, 'range-preview') { - @extend %date-range-preview !optional; + @include e(date, $mods: ('current', 'selected', 'last')) { + %date-inner { + @extend %date-current--selected !optional; + @extend %date-current--selected-last !optional; + } } - @include e(date, $mods: ('range-preview', 'inactive')) { - @extend %date-range-preview-inactive !optional; + @include e(date, $mods: ('current', 'selected', 'active', 'last')) { + %date-inner { + @extend %date-current--selected-focus !optional; + @extend %date-current--selected-last-focus !optional; + } } - @include e(date, $mods: ('range-preview', 'current')) { - @extend %date-range-preview-current !optional; + @include e(date, $mods: ('current', 'selected', 'range', 'first')) { + &::before { + @extend %date-current-border-radius !optional; + } } - @include e(date, $mods: ('range-preview', 'special')) { - @extend %date-range-preview-special !optional; + @include e(date, $mods: ('current', 'selected', 'range', 'last')) { + &::before { + @extend %date-current-border-radius !optional; + } } - @include e(date, $mods: ('range-preview', 'special', 'current')) { - @extend %date-range-preview-special-current !optional; + @include e(date, $mods: ('current', 'range'), $not: ('first', 'last')) { + %date-inner { + @extend %date-selected-current-range !optional; + } } - @include e(date, $mods: ('range', 'selected')) { - @extend %date-range-selected !optional; + @include e(date, $mods: ('current', 'range', 'active'), $not: ('first', 'last')) { + %date-inner { + @extend %date-selected-current-focus !optional; + } } - @include e(date, $mods: ('range', 'selected', 'first')) { - @extend %date-range-selected-first !optional; + // SPECIAL + @include e(date, 'special') { + %date-inner { + @extend %date-special !optional; + @extend %date-special-border-radius !optional; + } } - @include e(date, $mods: ('first', 'last')) { - @extend %date-first-last !optional; + @include e(date, $mods: ('special', 'first', 'last')) { + @extend %date-special-border-radius !optional; } - @include e(date, $mods: ('range', 'selected', 'current', 'first')) { - @extend %date-range-selected-current-first !optional; + @include e(date, $mods: ('special', 'active')) { + %date-inner { + @extend %date-special--focus !optional; + } } - @include e(date, $mods: ('range', 'selected', 'special', 'first')) { - @extend %date-range-selected-special-first !optional; + @include e(date, $mods: ('special', 'selected'), $not: ('range')) { + %date-inner { + @extend %date-special--selected !optional; + } } - @include e(date, $mods: ('range', 'selected', 'current', 'last')) { - @extend %date-range-selected-current-last !optional; + @include e(date, $mods: ('special', 'selected', 'first')) { + %date-inner { + @extend %date-special--selected !optional; + @extend %date-special--selected-first !optional; + } } - @include e(date, $mods: ('range', 'selected', 'last')) { - @extend %date-range-selected-last !optional; + @include e(date, $mods: ('special', 'selected', 'last')) { + %date-inner { + @extend %date-special--selected !optional; + @extend %date-special--selected-last !optional; + } } - @include e(date, $mods: ('range', 'selected', 'special', 'last')) { - @extend %date-range-selected-special-last !optional; + @include e(date, $mods: ('special', 'selected', 'active'), $not: ('range')) { + %date-inner { + @extend %date-special--selected-focus !optional; + } } - @include e(date, $mods: ('range', 'selected', 'special', 'first', 'last')) { - @extend %date-selected-special-first-last !optional; + @include e(date, $mods: ('special', 'selected', 'active', 'first')) { + %date-inner { + @extend %date-special--selected-focus !optional; + @extend %date-special--selected-first-focus !optional; + } } - @include e(date, $mods: ('selected', 'special', 'current', 'first', 'last')) { - @extend %date-selected-special-current-first-last !optional; + @include e(date, $mods: ('special', 'selected', 'active', 'last')) { + %date-inner { + @extend %date-special--selected-focus !optional; + @extend %date-special--selected-last-focus !optional; + } } - @include e(date, $mods: ('selected', 'special', 'current', 'preview', 'first')) { - @extend %date-selected-special-current-last !optional; + @include e(date, $mods: ('special', 'selected', 'range', 'first')) { + &::before { + @extend %date-special-border-radius !optional; + } } - @include e(date, $mods: ('selected', 'special', 'current', 'preview', 'last')) { - @extend %date-selected-special-current-first !optional; + @include e(date, $mods: ('special', 'selected', 'range', 'last')) { + &::before { + @extend %date-special-border-radius !optional; + } } - @include e(date, $mods: ('range', 'selected', 'active')) { - @extend %date-range-selected-active !optional; + @include e(date, $mods: ('special', 'range'), $not: ('range-preview', 'first', 'last')) { + %date-inner { + @extend %date-special-range-not-preview !optional; + } } - @include e(date, $mods: ('range', 'selected', 'active', 'first')) { - @extend %date-range-selected-active-first !optional; + @include e(date, $mods: ('special', 'range', 'active'), $not: ('range-preview', 'first', 'last')) { + %date-inner { + @extend %date-special-range-not-preview-focus !optional; + } } - @include e(date, $mods: ('range', 'selected', 'active', 'last')) { - @extend %date-range-selected-active-last !optional; + @include e(date, $mods: ('special', 'range'), $not: ('first', 'last')) { + %date-inner { + @extend %date-special-range !optional; + } } - @include e(date, $mods: ('range', 'selected', 'current')) { - @extend %date-selected-current-range !optional; + @include e(date, $mods: ('special', 'range', 'active'), $not: ('first', 'last')) { + %date-inner { + @extend %date-special-range-focus !optional; + } } - @include e(date, $mods: ('range', 'selected', 'current', 'active')) { - @extend %date-selected-current-range-active !optional; + // SPECIAL + CURRENT + @include m(material) { + @include e(date, $mods: ('special', 'current')) { + %date-inner { + @extend %material-date-special-current !optional; + } + } } - @include e(date, $mods: ('range', 'selected', 'current', 'special')) { - @extend %date-selected-current-range-special !optional; + // RANGE STYLES + @include e(date, 'range') { + @extend %date-range !optional; } - @include e(date, $mods: ('range', 'selected', 'current', 'special', 'active')) { - @extend %date-selected-current-range-special-active !optional; + @include e(date, 'first') { + @extend %date-first !optional; } - @include e(date, $mods: ('range', 'selected', 'current', 'first')) { - @extend %date-selected-current-range-first !optional; + @include e(date, 'last') { + @extend %date-last !optional; } - @include e(date, $mods: ('range', 'selected', 'current', 'last')) { - @extend %date-selected-current-range-last !optional; + @include e(date, 'first', $not: ( 'current', 'special')) { + %date-inner { + @extend %date-range-border !optional; + } } - @include e(date, $mods: ('range', 'selected', 'current', 'first', 'special')) { - @extend %date-selected-current-range-special-first !optional; + @include e(date, 'last', $not: ( 'current', 'special')) { + %date-inner { + @extend %date-range-border !optional; + } } - @include e(date, $mods: ('range', 'selected', 'current', 'first', 'special', 'active')) { - @extend %date-selected-current-range-special-active-first !optional; + @include e(date, $mods: ('range', 'first')) { + @extend %date-range--first !optional; } - @include e(date, $mods: ('range', 'selected', 'current', 'last', 'special')) { - @extend %date-selected-current-range-special-last !optional; + @include e(date, $mods: ('range', 'last')) { + @extend %date-range--last !optional; } - @include e(date, $mods: ('range', 'selected', 'current', 'last', 'special', 'active')) { - @extend %date-selected-current-range-special-active-last !optional; + @include e(date, $mods: ('range', 'first'), $not: ( 'current', 'special')) { + @extend %date-wrapper-range-border !optional; } - @include e(date, 'weekend') { - @extend %date-weekend !optional; + @include e(date, $mods: ('range', 'last'), $not: ( 'current', 'special')) { + @extend %date-wrapper-range-border !optional; } - @include e(date, 'current') { - @extend %date-current !optional; + @include e(date, 'range', $not: ('first', 'last', 'current', 'special')) { + %date-inner { + @extend %date-range-middle !optional; + } } - @include e(date, $mods: ('current', 'active')) { - @extend %date-current-active !optional; + @include e(date, $mods: ('range', 'active'), $not: ('first', 'last', 'current', 'special', 'range-preview')) { + %date-inner { + @extend %date-range-middle--focus !optional; + } } - @include e(date, $mods: ('current', 'range')) { - @extend %date-current-range !optional; + // PREVIEW STYLES + @include e(date, 'range-preview') { + @extend %date-range-preview !optional; } - @include e(date, $mods: ('current', 'selected')) { - @extend %date-selected-current !optional; + @include e(date, $mods:('range-preview', 'first')) { + @extend %date-preview--first !optional; } - @include e(date, $mods: ('current', 'selected', 'active')) { - @extend %date-selected-current-active !optional; + @include e(date, $mods:('range-preview', 'last')) { + @extend %date-preview--last !optional; } - @include e(date, $mods: ('current', 'selected', 'special')) { - @extend %date-selected-current-special !optional; + @include e(date, 'disabled') { + pointer-events: none; + cursor: not-allowed; } - @include e(date, $mods: ('current', 'selected', 'special', 'active')) { - @extend %date-selected-current-special-active !optional; + // DISABLED + @include e(date, $mods: ('disabled', 'special')) { + %date-inner { + opacity: .38; + } } - @include e(date, 'special') { - @extend %date-special !optional; + @include e(date, $mods: ('disabled', 'current')) { + %date-inner { + opacity: .38; + } } - @include e(date, $mods: ('special', 'current')) { - @extend %date-special-current !optional; + @include e(date, 'disabled', $not: ('special', 'current', 'range', 'first', 'last')) { + %date-inner { + @extend %date-disabled !optional; + } } - @include e(date, $mods: ('special', 'current', 'selected')) { - @extend %date-special-current-selected !optional; + @include e(date, $mods: ('disabled', 'range'), $not: ('selected', 'special', 'current', 'range-preview', 'first', 'last')) { + %date-inner { + @extend %date-disabled-range !optional; + } } - @include e(date, $mods: ('special', 'current', 'selected', 'active')) { - @extend %date-special-current-selected-active !optional; + @include e(date, 'range-preview', $not: ('special', 'current', 'first', 'last')) { + %date-inner { + @extend %date-disabled-range-preview !optional; + } } - @include e(date, $mods: ('special' 'active')) { - @extend %date-special-active !optional; - } + // FLUENT THEME + @include m(fluent) { + // CURRENT + @include e(date, 'current') { + %date-inner { + @extend %fluent-date-current !optional; + } + } - @include e(date, $mods: ('special', 'current', 'active')) { - @extend %date-special-current !optional; - @extend %date-special-current-active !optional; - } + @include e(date, $mods: ('current', 'active')) { + %date-inner { + @extend %fluent-date-current-focus !optional; + } + } - @include e(date, $mods: ('special', 'current', 'active', 'range')) { - @extend %date-special-current !optional; - @extend %date-special-current-active !optional; - } + // CURRENT + SELECTED + @include e(date, $mods: ('current', 'selected')) { + %date-inner { + @extend %fluent-date-current-selected !optional; + } + } - @include e(date, 'first') { - @extend %date-first !optional; - } + @include e(date, $mods: ('current', 'selected', 'active')) { + %date-inner { + @extend %fluent-date-current-selected-focus !optional; + } + } - @include e(date, $mods: ('first', 'range-preview')) { - @extend %date-first-preview !optional; - } + // SPECIAL + @include e(date, 'special') { + %date-inner { + @extend %fluent-date-special !optional; + } + } - @include e(date, $mods: ('last', 'range-preview')) { - @extend %date-last-preview !optional; - } + @include e(date, $mods: ('special', 'active')) { + %date-inner { + @extend %fluent-date-special-focus !optional; + } + } - @include e(date, $mods: ('first', 'range-preview', 'selected')) { - @extend %date-first-preview-selected !optional; - } + // SPECIAL + SELECTED + @include e(date, $mods: ('special', 'selected')) { + %date-inner { + @extend %fluent-date-special-selected !optional; + } + } - @include e(date, $mods: ('first', 'range-preview', 'current', 'selected')) { - @extend %date-first-preview-current-selected !optional; - } + @include e(date, $mods: ('special', 'selected', 'active')) { + %date-inner { + @extend %fluent-date-special-selected-focus !optional; + } + } - @include e(date, $mods: ('last', 'range-preview', 'selected')) { - @extend %date-last-preview-selected !optional; - } - @include e(date, $mods: ('last', 'range-preview', 'current', 'selected')) { - @extend %date-last-preview-current-selected !optional; - } + // CURRENT + SPECIAL + @include e(date, $mods: ('current', 'special')) { + %date-inner { + @extend %fluent-date-current-special !optional; + } + } + @include e(date, $mods: ('current', 'special', 'active')) { + %date-inner { + @extend %fluent-date-current-special-focus !optional; + } + } - @include e(date, $mods: ('first', 'range-preview', 'special')) { - @extend %date-first-preview-special !optional; - } + // CURRENT + SPECIAL + SELECTED + @include e(date, $mods: ('current', 'special', 'selected')) { + %date-inner { + @extend %fluent-date-current-special-selected !optional; + } + } + @include e(date, $mods: ('current', 'special', 'selected', 'active')) { + %date-inner { + @extend %fluent-date-current-special-selected-focus !optional; + } + } - @include e(date, $mods: ('last', 'range-preview', 'special')) { - @extend %date-last-preview-special !optional; - } + @include e(date, $mods: ('current', 'special', 'selected', 'range-preview', 'first')) { + %date-inner { + @extend %fluent-date-current-special-selected-range-preview-first-last !optional; + } + } - @include e(date, $mods: ('first', 'range-preview', 'active')) { - @extend %date-first-preview-active !optional; - } + @include e(date, $mods: ('current', 'special', 'selected', 'range-preview', 'last')) { + %date-inner { + @extend %fluent-date-current-special-selected-range-preview-first-last !optional; + } + } - @include e(date, $mods: ('last', 'range-preview', 'active')) { - @extend %date-last-preview-active !optional; - } + // FIRST + LAST + @include e(date, $mods: ('first', 'last'), $not: ('current', 'special')) { + %date-inner { + @extend %fluent-date-first-last !optional; + } + } - @include e(date, $mods: ('first', 'last', 'selected')) { - @extend %date-first-last-selected !optional; - } + @include e(date, $mods: ('first', 'last', 'active'), $not: ('current', 'special')) { + %date-inner { + @extend %fluent-date-first-last-focus !optional; + } + } - @include e(date, $mods: ('first', 'range-preview', 'special', 'current')) { - @extend %date-first-preview-special-current !optional; - } + // RANGE + @include e(date, range) { + @extend %fluent-date-range !optional; + @extend %fluent-date-range-plus !optional; - @include e(date, $mods: ('last', 'range-preview', 'special', 'current')) { - @extend %date-last-preview-special-current !optional; - } + } - @include e(date, $mods: ('first', 'range-preview', 'special', 'active')) { - @extend %date-first-preview-special-active !optional; - } + @include e(date, $mods: ('range', 'first')) { + @extend %fluent-date-range-first !optional; + } - @include e(date, $mods: ('last', 'range-preview', 'special', 'active')) { - @extend %date-last-preview-special-active !optional; - } + @include e(date, $mods: ('range', 'last')) { + @extend %fluent-date-range-last !optional; + } - @include e(date, $mods: ('first', 'range-preview', 'special', 'active', 'selected')) { - @extend %date-first-preview-special-active-selected !optional; - } + // PREVIEW + @include e(date, 'range-preview') { + @extend %fluent-date-range-preview-last-after !optional; - @include e(date, $mods: ('last', 'range-preview', 'special', 'active', 'selected')) { - @extend %date-last-preview-special-active-selected !optional; - } + %date-inner { + @extend %fluent-date-range-preview !optional; + } + } - @include e(date, $mods: ('first', 'range-preview', 'special', 'active', 'current')) { - @extend %date-first-preview-special-active-current !optional; - } + @include e(date, 'range-preview', $not: ('disabled', 'inactive', 'weekend')) { + %date-inner { + @extend %fluent-date-range-preview-not-disabled !optional; + } + } - @include e(date, $mods: ('last', 'range-preview', 'special', 'active', 'current')) { - @extend %date-last-preview-special-active-current !optional; - } + @include e(date, $mods: ('range-preview', 'inactive'), $not: ('disabled')) { + %date-inner { + @extend %fluent-date-range-preview-inactive !optional; + } + } - @include e(date, 'last') { - @extend %date-last !optional; - } + @include e(date, $mods: ('range-preview', 'weekend'), $not: ('current', 'special', 'inactive', 'disabled')) { + %date-inner { + @extend %fluent-date-range-preview-weekend !optional; + } + } - @include e(label) { - @extend %date !optional; - @extend %weekday-label !optional; - } + @include e(date, 'range-preview', $not: ('first', 'last', 'current', 'special')) { + %date-inner { + @extend %fluent-date-range-preview-middle !optional; + } + } - @include e(label-inner) { - @extend %weekday-label-inner !optional; - } + @include e(date, $mods: ('range-preview', 'first')) { + @extend %fluent-date-range-preview-first !optional; + } - @include e(label, 'week-number') { - @extend %label-week-number !optional; - } + @include e(date, $mods: ('range-preview', 'last')) { + @extend %fluent-date-range-preview-last !optional; + } - @include e(date-inner, 'week-number') { - @extend %date-inner-week-number !optional; - } + @include e(date, $mods: ('range-preview', 'first', 'selected')) { + %date-inner { + @extend %fluent-date-range-preview-first-last-selected !optional; + } + } - @include e(date, 'disabled') { - @extend %date-disabled !optional; - } + @include e(date, $mods: ('range-preview', 'last', 'selected')) { + %date-inner { + @extend %fluent-date-range-preview-first-last-selected !optional; + } + } - @include e(date, $mods: ('disabled', 'range')) { - @extend %date-disabled-range !optional; - } + // RESET HOVER/FOCUS STYLES IN PREVIEW + @include e(date, $mods: ('range-preview', 'special')) { + %date-inner { + @extend %fluent-date-range-preview-special !optional; + } + } - @include e(date, $mods: ('disabled', 'range-preview')) { - @extend %date-disabled-range-preview !optional; - } + @include e(date, $mods: ('range-preview', 'special', 'active')) { + %date-inner { + @extend %fluent-date-range-preview-special-focus !optional; + } + } - @include e(date, $mods: ('disabled', 'special')) { - @extend %date-disabled-special !optional; - } + @include e(date, $mods: ('range-preview', 'current')) { + %date-inner { + @extend %fluent-date-range-preview-current !optional; + } + } - @include e(date, $mods: ('disabled', 'special', 'selected')) { - @extend %date-disabled-special-selected !optional; - } + @include e(date, $mods: ('range-preview', 'current', 'active')) { + %date-inner { + @extend %fluent-date-range-preview-current-focus !optional; + } + } - @include e(date, $mods: ('disabled', 'selected')) { - @extend %date-disabled-selected !optional; - } + @include e(date, $mods: ('range-preview', 'current', 'special')) { + %date-inner { + @extend %fluent-date-range-preview-current-special !optional; + } + } - @include e(date, $mods: ('disabled', 'current')) { - @extend %date-disabled-current !optional; - } + @include e(date, $mods: ('range-preview', 'current', 'special', 'active')) { + %date-inner { + @extend %fluent-date-range-preview-current-special-focus !optional; + } + } - @include e(date, $mods: ('disabled', 'current', 'special')) { - @extend %date-disabled-current-special !optional; - } + @include e(date, $mods: ('selected', 'first', 'last')) { + %date-inner { + @extend %fluent-selected-first-last !optional; + } + } - @include e(date, $mods: ('disabled', 'single', 'current', 'special', 'selected')) { - @extend %date-disabled-current-special-selected !optional; - } + @include e(date, $mods: ('selected', 'first', 'last', 'special')) { + %date-inner { + @extend %fluent-selected-first-last-special !optional; + } + } - @include e(date, $mods: ('disabled', 'current', 'selected')) { - @extend %date-disabled-current-selected !optional; - } + @include e(date, $mods: ('selected', 'first', 'last', 'current')) { + %date-inner { + @extend %fluent-selected-first-last-current !optional; + } + } - @include e(date, $mods: ('disabled', 'inactive')) { - @extend %date-disabled-inactive !optional; + @include e(date, $mods: ('selected', 'first', 'last', 'current', 'special')) { + %date-inner { + @extend %fluent-selected-first-last-current-special !optional; + } + } + + @include e(date, $mods: ('selected', 'first', 'last', 'current', 'special', 'active')) { + %date-inner { + @extend %fluent-selected-first-last-current-special-focus !optional; + } + } + + @include e(date, $mods: ('range-preview', 'selected', 'special', 'first')) { + %date-inner { + @extend %fluent-date-range-preview-selected-special-first !optional; + } + } + + @include e(date, $mods: ('range-preview', 'selected', 'special', 'last')) { + %date-inner { + @extend %fluent-date-range-preview-selected-special-last !optional; + } + } + + @include e(date, $mods: ('range-preview', 'selected', 'current', 'first')) { + %date-inner { + @extend %fluent-date-range-preview-current-first-last !optional; + } + } + + @include e(date, $mods: ('range-preview', 'selected', 'current', 'last')) { + %date-inner { + @extend %fluent-date-range-preview-current-first-last !optional; + } + } + + // RANGE + SELECTED + @include e(date, $mods: ('range', 'selected')) { + @extend %fluent-date-range-selected !optional; + } + + @include e(date, $mods: ('range', 'selected', 'first')) { + @extend %fluent-date-range-selected-first-last !optional; + @extend %fluent-date-range-selected-first !optional; + } + + @include e(date, $mods: ('range', 'selected', 'last')) { + @extend %fluent-date-range-selected-first-last !optional; + @extend %fluent-date-range-selected-last !optional; + } + + @include e(date, $mods: ('range', 'selected'), $not: ('range-preview', 'special', 'current', 'disabled')) { + %date-inner { + @extend %fluent-date-range-selected-not-preview-disabled !optional; + } + } + + @include e(date, $mods: ('range', 'selected', 'active'), $not: ('range-preview', 'special', 'current', 'disabled')) { + %date-inner { + @extend %fluent-date-range-selected-not-preview-disabled-focus !optional; + } + } + + @include e(date, $mods: ('range', 'selected', 'inactive'), $not: ('range-preview')) { + %date-inner { + @extend %fluent-date-range-selected-not-preview !optional; + } + } + + @include e(date, $mods: ('range', 'selected', 'special'), $not: ('range-preview')) { + %date-inner { + @extend %fluent-date-range-selected-special-not-preview !optional; + } + } + + @include e(date, $mods: ('range', 'selected', 'current'), $not: ('range-preview')) { + %date-inner { + @extend %fluent-date-range-selected-current-not-preview !optional; + } + } + + @include e(date, $mods: ('range', 'special'), $not: ('range-preview')) { + %date-inner { + @extend %fluent-date-range-special-not-preview !optional; + } + } + + @include e(date, $mods: ('range', 'special', 'active'), $not: ('range-preview')) { + %date-inner { + @extend %fluent-date-range-special-not-preview-focus !optional; + } + } + + @include e(date, $mods: ('range', 'current'), $not: ('range-preview')) { + %date-inner { + @extend %fluent-date-range-current-not-preview !optional; + } + } + + @include e(date, $mods: ('range', 'current', 'active'), $not: ('range-preview')) { + %date-inner { + @extend %fluent-date-range-current-not-preview-focus !optional; + } + } + + @include e(date, $mods: ('range', 'current', 'special'), $not: ('range-preview')) { + %date-inner { + @extend %fluent-date-range-current-special-not-preview !optional; + } + } + + @include e(date, $mods: ('range', 'current', 'special', 'active'), $not: ('range-preview')) { + %date-inner { + @extend %fluent-date-range-current-special-not-preview-focus !optional; + } + } + + // DISABLED + @include e(date, $mods: ('range', 'special', 'disabled'), $not: ('range-preview')) { + %date-inner { + @extend %fluent-date-range-special-disabled !optional; + } + } + + @include e(date, $mods: ('range', 'current', 'disabled'), $not: ('range-preview')) { + %date-inner { + @extend %fluent-date-range-special-disabled !optional; + } + } + + @include e(date, $mods: ('range', 'special', 'current', 'disabled'), $not: ('range-preview')) { + %date-inner { + @extend %fluent-date-range-special-current-disabled !optional; + } + } } - @include e(date, $mods: ('range', 'selected', 'first', 'disabled')) { - @extend %date-range-selected-first-disabled !optional; + // BOOTSTRAP THEME + @include m(bootstrap) { + // SPECIAL + @include e(date, 'special') { + %date-inner { + @extend %bootstrap-date-special !optional; + } + } + + @include e(date, $mods: ('special', 'first')) { + %date-inner { + @extend %bootstrap-date-special-first-last !optional; + } + } + + @include e(date, $mods: ('special', 'last')) { + %date-inner { + @extend %bootstrap-date-special-first-last !optional; + } + } + + @include e(date, $mods: ('special', 'range'), $not: ('range-preview', 'current', 'first', 'last')) { + %date-inner { + @extend %bootstrap-date-special-range !optional; + } + } + + @include e(date, $mods: ('special', 'range', 'active'), $not: ('range-preview', 'current', 'first', 'last')) { + %date-inner { + @extend %bootstrap-date-special-range-focus !optional; + } + } + + // CURRENT + @include e(date, $mods: ('current', 'first', 'last')) { + %date-inner { + @extend %bootstrap-date-current-first-last !optional; + } + } + + // CURRENT + SPECIAL + @include e(date, $mods: ('current', 'special'), $not: ('first', 'last')) { + %date-inner { + @extend %bootstrap-date-current-special !optional; + } + } + + @include e(date, $mods: ('current', 'special', 'active'), $not: ('first', 'last')) { + %date-inner { + @extend %bootstrap-date-current-special-focus !optional; + } + } + + + // CURRENT + SPECIAL + RANGE + @include e(date, $mods: ('current', 'special', 'range'), $not: ('first', 'last')) { + %date-inner { + @extend %bootstrap-date-current-special-range !optional; + } + } + + @include e(date, $mods: ('current', 'special', 'range', 'active'), $not: ('first', 'last')) { + %date-inner { + @extend %bootstrap-date-current-special-range-focus !optional; + } + } + + @include e(date, $mods: ('current', 'special', 'selected'), $not: ('range', 'first', 'last')) { + %date-inner { + @extend %bootstrap-date-current-special-selected-not-range !optional; + } + } + + @include e(date, $mods: ('current', 'special', 'selected', 'active'), $not: ('range', 'first', 'last')) { + %date-inner { + @extend %bootstrap-date-current-special-selected-not-range-focus !optional; + } + } + + // PREVIEW + @include e(date, 'range-preview') { + @extend %bootstrap-date-range-preview !optional; + } + + @include e(date, $mods:('range-preview', 'first')) { + @extend %bootstrap-date-preview--first-last !optional; + } + + @include e(date, $mods:('range-preview', 'last')) { + @extend %bootstrap-date-preview--first-last !optional; + } + + @include e(date, $mods:('range-preview', 'first', 'last')) { + %date-inner { + @extend %bootstrap-date-preview--first-and-last !optional; + } + } + + @include e(date, $mods:('range-preview', 'first', 'active')) { + @extend %bootstrap-date-preview--first-last-focus !optional; + } + + @include e(date, $mods:('range-preview', 'last', 'active')) { + @extend %bootstrap-date-preview--first-last-focus !optional; + } + + @include e(date, $mods: ('range-preview', 'current')) { + %date-inner { + @extend %bootstrap-date-range-preview-current !optional; + } + } + + @include e(date, $mods: ('range-preview', 'current', 'active')) { + %date-inner { + @extend %bootstrap-date-range-preview-current-focus !optional; + } + } + + @include e(date, $mods: ('range-preview', 'current', 'first')) { + %date-inner { + @extend %bootstrap-date-range-preview-current-first-last !optional; + } + } + + @include e(date, $mods: ('range-preview', 'current', 'last')) { + %date-inner { + @extend %bootstrap-date-range-preview-current-first-last !optional; + } + } + + @include e(date, $mods: ('range-preview', 'current', 'active', 'first')) { + %date-inner { + @extend %bootstrap-date-range-preview-current-focus-first-last !optional; + } + } + + @include e(date, $mods: ('range-preview', 'current', 'active', 'last')) { + %date-inner { + @extend %bootstrap-date-range-preview-current-focus-first-last !optional; + } + } + + @include e(date, $mods: ('range-preview', 'current', 'first')) { + %date-inner { + @extend %bootstrap-date-preview-special--first-last !optional; + } + } + + @include e(date, $mods: ('range-preview', 'current', 'last')) { + %date-inner { + @extend %bootstrap-date-preview-special--first-last !optional; + } + } + + @include e(date, $mods: ('range-preview', 'current', 'active', 'first')) { + %date-inner { + @extend %bootstrap-date-preview-special-focus--first-last !optional; + } + } + + @include e(date, $mods: ('range-preview', 'current', 'active', 'last')) { + %date-inner { + @extend %bootstrap-date-preview-special-focus--first-last !optional; + } + } + + @include e(date, $mods: ('range-preview', 'special', 'first')) { + %date-inner { + @extend %bootstrap-date-preview-special--first-last !optional; + } + } + + @include e(date, $mods: ('range-preview', 'special', 'last')) { + %date-inner { + @extend %bootstrap-date-preview-special--first-last !optional; + } + } + + @include e(date, $mods: ('range-preview', 'special', 'active', 'first')) { + %date-inner { + @extend %bootstrap-date-preview-special-focus--first-last !optional; + } + } + + @include e(date, $mods: ('range-preview', 'special', 'active', 'last')) { + %date-inner { + @extend %bootstrap-date-preview-special-focus--first-last !optional; + } + } + + @include e(date, $mods: ('range-preview', 'weekend'), $not: ('special', 'current', 'first', 'last')) { + %date-inner { + @extend %bootstrap-date-preview-weekend-inactive !optional; + } + } + + @include e(date, $mods: ('range-preview', 'weekend', 'active'), $not: ('special', 'current', 'first', 'last')) { + %date-inner { + @extend %bootstrap-date-preview-weekend-inactive-focus !optional; + } + } + + @include e(date, $mods: ('range-preview', 'inactive'), $not: ('special', 'current', 'first', 'last')) { + %date-inner { + @extend %bootstrap-date-preview-weekend-inactive !optional; + } + } + + @include e(date, $mods: ('range-preview', 'inactive', 'active'), $not: ('special', 'current', 'first', 'last')) { + %date-inner { + @extend %bootstrap-date-preview-weekend-inactive-focus !optional; + } + } + + + // RANGE + @include e(date, $mods: ('range', 'current', 'first')) { + %date-inner { + @extend %bootstrap-date-range-radius !optional; + } + } + + @include e(date, $mods: ('range', 'current', 'last')) { + %date-inner { + @extend %bootstrap-date-range-radius !optional; + } + } + + @include e(date, $mods: ('range', 'special', 'first')) { + %date-inner { + @extend %bootstrap-date-range-radius !optional; + } + } + + @include e(date, $mods: ('range', 'special', 'last')) { + %date-inner { + @extend %bootstrap-date-range-radius !optional; + } + } + + + // DISABLED PREVIEW + @include e(date, $mods: ('disabled', 'range-preview'), $not: ('special', 'current', 'first', 'last')) { + %date-inner { + @extend %bootstrap-date-disabled-range-preview !optional; + } + } } - @include e(date, $mods: ('range', 'selected', 'last', 'disabled')) { - @extend %date-range-selected-first-disabled !optional; + // INDIGO THEME + @include m(indigo) { + @include e(label, 'week-number') { + @extend %indigo-label-week-number !optional; + } + + @include e(date-inner, 'week-number') { + @extend %indigo-date-inner-week-number !optional; + } + + @include e(date, 'special') { + %date-inner { + font-weight: 700; + } + } + + // SELECTED + CURRENT + FIRST/ LAST + @include e(date, $mods: ('current', 'selected', 'first')) { + %date-inner { + @extend %indigo-current-selected-first-last !optional; + } + } + + @include e(date, $mods: ('current', 'selected', 'last')) { + %date-inner { + @extend %indigo-current-selected-first-last !optional; + } + } + + @include e(date, $mods: ('current', 'selected', 'first', 'active')) { + %date-inner { + @extend %indigo-current-selected-first-last-focus !optional; + } + } + + @include e(date, $mods: ('current', 'selected', 'last', 'active')) { + %date-inner { + @extend %indigo-current-selected-first-last-focus !optional; + } + } + + // CURRENT + SELECTED RANGE + FIRST/ LAST + @include e(date, $mods: ('current', 'selected', 'range', 'first')) { + %date-inner { + @extend %indigo-current-selected-range-first-last !optional; + } + } + + @include e(date, $mods: ('current', 'selected', 'range', 'last')) { + %date-inner { + @extend %indigo-current-selected-range-first-last !optional; + } + } + + @include e(date, $mods: ('current', 'selected', 'range', 'first', 'active')) { + %date-inner { + @extend %indigo-current-selected-range-first-last-focus !optional; + } + } + + @include e(date, $mods: ('current', 'selected', 'range', 'last', 'active')) { + %date-inner { + @extend %indigo-current-selected-range-first-last-focus !optional; + } + } + + // SPECIAL + SELECTED FIRST/ LAST + @include e(date, $mods: ('special', 'selected', 'first')) { + %date-inner { + @extend %indigo-special-selected-first-last !optional; + + // stylelint-disable-next-line max-nesting-depth + &::after { + @extend %indigo-date-special-selected-inner-radius !optional; + } + } + } + + @include e(date, $mods: ('special', 'selected', 'last')) { + %date-inner { + @extend %indigo-special-selected-first-last !optional; + + // stylelint-disable-next-line max-nesting-depth + &::after { + @extend %indigo-date-special-selected-inner-radius !optional; + } + } + } + + @include e(date, $mods: ('special', 'selected', 'first', 'active')) { + %date-inner { + @extend %indigo-special-selected-first-last-focus !optional; + } + } + + @include e(date, $mods: ('special', 'selected', 'last', 'active')) { + %date-inner { + @extend %indigo-special-selected-first-last-focus !optional; + } + } + + // SPECIAL + SELECTED + @include e(date, $mods: ('special', 'selected'), $not: ( 'range', 'range-preview')) { + %date-inner { + // stylelint-disable-next-line max-nesting-depth + &::after { + @extend %indigo-date-special-selected-inner-radius !optional; + } + } + } + + // SPECIAL + CURRENT + RANGE + @include e(date, $mods: ('special', 'current')) { + %date-inner { + @extend %indigo-special-current !optional; + } + } + + @include e(date, $mods: ('special', 'current', 'range')) { + %date-inner { + @extend %indigo-special-current-range !optional; + } + } + + @include e(date, $mods: ('special', 'current', 'range', 'active')) { + %date-inner { + @extend %indigo-special-current-range-focus !optional; + } + } + + @include e(date, $mods: ('special', 'current', 'range'), $not: ('first', 'last')) { + %date-inner { + @extend %indigo-special-current-range-not-first-last !optional; + } + } + + @include e(date, $mods: ('special', 'current', 'range', 'active'), $not: ('first', 'last')) { + %date-inner { + @extend %indigo-special-current-range-not-first-last-focus !optional; + } + } + + + @include e(date, $mods: ('special', 'current', 'range', 'first')) { + %date-inner { + @extend %indigo-special-current-range-first-last !optional; + } + } + + @include e(date, $mods: ('special', 'current', 'range', 'last')) { + %date-inner { + @extend %indigo-special-current-range-first-last !optional; + } + } + + @include e(date, $mods: ('special', 'current', 'range', 'active', 'first')) { + %date-inner { + @extend %indigo-special-current-range-first-last-focus !optional; + } + } + + @include e(date, $mods: ('special', 'current', 'range', 'active', 'last')) { + %date-inner { + @extend %indigo-special-current-range-first-last-focus !optional; + } + } + + // SPECIAL + CURRENT + @include e(date, $mods: ('special', 'current'), $not: ('range')) { + %date-inner { + @extend %indigo-date-special-current-indented !optional; + + // stylelint-disable-next-line max-nesting-depth + &::after { + @extend %indigo-date-special-selected-inner-radius !optional; + } + } + } + + @include e(date, $mods: ('special', 'current', 'active'), $not: ('range')) { + %date-inner { + @extend %indigo-date-special-current-focus !optional; + } + } + + @include e(date, $mods: ('special', 'current'), $not: ('selected', 'range')) { + %date-inner { + @extend %indigo-date-special-current-not-selected !optional; + } + } + + @include e(date, $mods: ('special', 'current', 'active'), $not: ('selected', 'range')) { + %date-inner { + @extend %indigo-date-special-current-focus-not-selected !optional; + } + } + + @include e(date, $mods: ('special', 'current', 'selected'), $not: ( 'range')) { + %date-inner { + @extend %indigo-date-special-current-selected !optional; + } + } + + @include e(date, $mods: ('special', 'current', 'selected', active), $not: ( 'range')) { + %date-inner { + @extend %indigo-date-special-current-selected-focus !optional; + } + } } } 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 14004190d7e..cf67ef36662 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 @@ -9,7 +9,6 @@ @include css-vars($theme); $variant: map.get($theme, '_meta', 'theme'); - $bootstrap-theme: $variant == 'bootstrap'; $cal-picker-padding: map.get(( 'material': rem(16px), @@ -60,6 +59,9 @@ $cal-row-padding: pad(rem(8px)); $cal-row-margin: pad-block(rem(2px)) 0; + $fake-bg-size: calc(50% + (var-get($theme, 'size') / 2)); + $fake-bg-position: calc(50% - (var-get($theme, 'size') / 2)); + %parent-container { color: var-get($theme, 'content-foreground'); background: var-get($theme, 'content-background'); @@ -132,7 +134,7 @@ align-items: center; } - @if $bootstrap-theme { + @if $variant == 'bootstrap' { %days-view { padding-block-end: pad-block(rem(16px)); padding-block-start: 0; @@ -171,9 +173,7 @@ top: $cal-picker-padding; } } - } - - @if not $bootstrap-theme { + } @else { %body-display:not(%body-display--vertical) { %days-view + %days-view { padding-inline-start: 0; @@ -202,9 +202,10 @@ border-radius: 0; } - %views-navigation { - @if $bootstrap-theme { + @if $variant == 'bootstrap' { + %views-navigation { border-block-end: rem(1px) solid var-get($theme, 'border-color'); + } } } @@ -241,13 +242,8 @@ } %header-display-vertical { - @if $variant == 'indigo' { - min-width: rem(136px); - width: rem(136px); - } @else { - min-width: rem(168px); - width: rem(168px); - } + min-width: if($variant == 'indigo', rem(136px), rem(168px)); + width: if($variant == 'indigo', rem(136px), rem(168px)); @if $variant == 'indigo' { &::after { @@ -261,18 +257,17 @@ %header-year { margin: 0; + color: currentColor; - @if $bootstrap-theme { + @if $variant == 'bootstrap' { min-height: rem(24px); } - - color: currentColor; } %header-date { display: flex; - @if $bootstrap-theme { + @if $variant == 'bootstrap' { padding-block-end: pad-block(rem(8px)); } @else { margin: 0; @@ -302,7 +297,7 @@ %calendar-wrapper--vertical { display: grid; - grid-template-rows: repeat(calc(var(--calendar-months) * 2), auto); + grid-template-rows: repeat(#{calc(var(--calendar-months) * 2), auto}); %days-view { grid-row: var(--calendar-row-start); @@ -324,20 +319,18 @@ } } - %body-display { + %body-display, + %pickers-display--days { display: grid; grid-template-columns: repeat(var(--calendar-months), 1fr); + } - @if not $bootstrap-theme { + @if $variant != 'bootstrap' { + %body-display { column-gap: rem(44px); } - } - - %pickers-display--days { - display: grid; - grid-template-columns: repeat(var(--calendar-months), 1fr); - @if not $bootstrap-theme { + %pickers-display--days { gap: rem(40px); } } @@ -382,12 +375,6 @@ } %days-view { - @if not $bootstrap-theme { - padding-inline: pad-inline(rem(12px)); - } - - gap: $date-view-row-gap; - @if $variant == 'bootstrap' { %days-view-row { // This is the weekday labels row @@ -396,7 +383,11 @@ border-block-end: rem(1px) solid var-get($theme, 'border-color'); } } + } @else { + padding-inline: pad-inline(rem(12px)); } + + gap: $date-view-row-gap; } %picker__nav { @@ -415,11 +406,15 @@ outline: none; cursor: pointer; + [dir='rtl'] & { + transform: scaleX(-1); + } + @if $variant == 'indigo' { padding: pad(rem(5px)); } - @if $bootstrap-theme { + @if $variant == 'bootstrap' { top: math.div($cal-picker-padding, 2); } @@ -430,10 +425,6 @@ &:focus { color: var-get($theme, 'navigation-focus-color'); } - - [dir='rtl'] & { - transform: scaleX(-1); - } } %picker-date { @@ -467,6 +458,11 @@ border-start-end-radius: var-get($theme, 'week-number-border-radius'); } } + padding-inline: pad-inline(rem(12px)); + + &:last-of-type { + margin-block-end: 0; + } } &:last-of-type { @@ -478,26 +474,9 @@ display: none; } } - - @if $bootstrap-theme { - margin-block-end: 0; - } - } - - // TODO Hide the 7th row only if all its children have the hidden class. - // To implement this, we need to know if the calendar is in multi-view state. - //&:nth-child(7) { - // > %date:has(:not(%date-hidden)) { - // display: none; - // } - //} - - @if $bootstrap-theme { - padding-inline: pad-inline(rem(12px)); } } - %label-week-number, %date-inner-week-number { position: relative; @@ -505,7 +484,7 @@ pointer-events: none; z-index: 1; - @if $bootstrap-theme { + @if $variant == 'bootstrap' { font-style: italic !important; } } @@ -534,28 +513,24 @@ width: $date-size; height: $date-height; position: relative; - - @if $bootstrap-theme { - color: var-get($theme, 'weekday-color'); - } @else { - color: var-get($theme, 'week-number-foreground'); - background: var-get($theme, 'week-number-background'); - } - border-top-left-radius: var-get($theme, 'week-number-border-radius'); border-top-right-radius: var-get($theme, 'week-number-border-radius'); border: rem(1px) solid transparent; - @if not $bootstrap-theme { + @if $variant == 'bootstrap' { + color: var-get($theme, 'weekday-color'); + } @else { &::before { content: ''; position: absolute; background: var-get($theme, 'week-number-background'); - border-inline: rem(1px) solid var-get($theme, 'week-number-background'); - inset-block-start: 100%; - height: calc(#{$date-view-row-gap} + #{rem(if($variant == 'indigo', 0px, 2px))}); + inset-block-start: calc(100% + #{$border-size}); + height: $date-view-row-gap; width: $date-size; } + + color: var-get($theme, 'week-number-foreground'); + background: var-get($theme, 'week-number-background'); } @if $variant == 'indigo' { @@ -627,6 +602,28 @@ border-color: var-get($theme, 'ym-selected-current-outline-focus-color'); } } + + @if $variant == 'fluent' { + box-shadow: 0 0 0 $border-size var-get($theme, 'ym-selected-current-outline-focus-color'); + } + + &:hover { + color: var-get($theme, 'ym-selected-current-hover-foreground'); + background: var-get($theme, 'ym-selected-current-hover-background'); + + // stylelint-disable-next-line + @if $variant != 'indigo' { + box-shadow: inset 0 0 0 $border-size var-get($theme, 'ym-selected-current-outline-focus-color'); + } @else { + &::after { + border-color: var-get($theme, 'ym-selected-current-outline-focus-color'); + } + } + + @if $variant == 'fluent' { + box-shadow: 0 0 0 $border-size var-get($theme, 'ym-selected-current-outline-focus-color'); + } + } } } @@ -664,10 +661,7 @@ %calendar-view__item-inner { color: var-get($theme, 'ym-selected-foreground'); background: var-get($theme, 'ym-selected-background'); - - @if not $bootstrap-theme and $variant != 'fluent' { - box-shadow: inset 0 0 0 $border-size var-get($theme, 'ym-selected-outline-color'); - } + box-shadow: inset 0 0 0 $border-size var-get($theme, 'ym-selected-outline-color'); &:hover { color: var-get($theme, 'ym-selected-hover-foreground'); @@ -698,6 +692,10 @@ } } + @if $variant == 'fluent' { + box-shadow: 0 0 0 $border-size var-get($theme, 'ym-selected-current-outline-color'); + } + &:hover { color: var-get($theme, 'ym-selected-current-hover-foreground'); background: var-get($theme, 'ym-selected-current-hover-background'); @@ -711,10 +709,16 @@ border-color: var-get($theme, 'ym-selected-current-outline-hover-color'); } } + + + @if $variant == 'fluent' { + box-shadow: 0 0 0 $border-size var-get($theme, 'ym-selected-current-outline-hover-color'); + } } } } + // DATE, LABEL and WEEK NUMBERS %date { position: relative; display: flex; @@ -726,28 +730,13 @@ width: 100%; border-block-start: rem(1px) solid transparent; border-block-end: rem(1px) solid transparent; + } - &%label-week-number, - &%date-week-number { - margin-inline-end: rem(4px); - justify-content: flex-start; - width: var-get($theme, 'size'); - } - - %date-inner-week-number { - border-radius: 0; - - @if $variant == 'indigo' { - border: 0; - - &::before { - height: $date-view-row-gap; - inset-block-start: 100%; - inset-inline-start: 0; - border: 0; - } - } - } + %label-week-number, + %date-week-number { + margin-inline-end: rem(4px); + justify-content: flex-start; + width: var-get($theme, 'size'); } %date-inner { @@ -761,68 +750,35 @@ border-radius: var-get($theme, 'date-border-radius'); border: $border-size solid var-get($theme, 'date-border-color'); z-index: 2; + } - &:hover { - color: var-get($theme, 'date-hover-foreground'); - background: var-get($theme, 'date-hover-background'); - border-color: var-get($theme, 'date-hover-border-color'); - cursor: pointer; - } + %date-inner-week-number { + min-width: auto; + width: $date-size; + color: var-get($theme, 'week-number-foreground'); + background: var-get($theme, 'week-number-background'); + border-color: transparent; + border-radius: 0; - &::after { + &::before { content: ''; position: absolute; - z-index: 0; - border: $border-size solid transparent; - border-radius: calc(var-get($theme, 'date-special-border-radius') - $border-size); - - @if $variant == 'fluent' { - width: $date-inner-size; - height: $date-inner-size; - } @else { - // By default initial size of the inner element is the same as the date size - width: $date-size; - height: $date-size; - } - } - - &%date-inner-week-number { - min-width: auto; - width: $date-size; - color: var-get($theme, 'week-number-foreground'); background: var-get($theme, 'week-number-background'); - // This is not an actual date and should not change it's border when changing the date border - border-color: var-get($theme, 'week-number-background'); - - &::after { - display: none !important; - } - - &::before { - content: ''; - position: absolute; - background: var-get($theme, 'week-number-background'); + @if $variant != 'indigo' { + inset-block-start: calc(100% + #{$border-size}); + } @else { inset-block-start: 100%; - height: calc(#{$date-view-row-gap} + #{rem(if($variant == 'indigo', 0, 2px))}); - width: $date-size; } - } - } - - %date-weekend { - %date-inner { - color: var-get($theme, 'weekend-color'); - &:hover { - color: var-get($theme, 'date-hover-foreground'); - } + height: $date-view-row-gap; + width: $date-size; } } // Has to be after the %date placeholder do to specificity %weekday-label { - @if not $bootstrap-theme { + @if $variant != 'bootstrap' { height: $date-height; } @@ -837,7 +793,7 @@ border-radius: 0; - @if $bootstrap-theme { + @if $variant == 'bootstrap' { cursor: default; // Important is needed in order to override the typography styles font-style: italic !important; @@ -848,1266 +804,1664 @@ @include ellipsis(); } - %date-current, - %date-selected { - %date-inner { - &::after { - @if $variant != 'fluent' { - width: $date-inner-size; - height: $date-inner-size; - } - } - } + + // DATE AND DATE STATES STYLES + // ---------------------------------------------------------------------------------------------- + %date-weekend { + color: var-get($theme, 'weekend-color'); } - %date-selected-special { - %date-inner { - &::after { - border-color: var-get($theme, 'date-selected-special-border-color'); - } + %date-inactive { + color: var-get($theme, 'inactive-color'); + } - &:hover { - &::after { - border-color: var-get($theme, 'date-selected-special-hover-border-color'); - } - } + %date-inner { + &:hover { + color: var-get($theme, 'date-hover-foreground'); + background: var-get($theme, 'date-hover-background'); + border-color: var-get($theme, 'date-hover-border-color'); + cursor: pointer; } } - %date-selected-special-active { - %date-inner { - &::after { - @if $variant != 'fluent' { - border-color: var-get($theme, 'date-selected-special-focus-border-color'); - } - } - } + // ACTIVE + %date-focus { + color: var-get($theme, 'date-focus-foreground'); + background: var-get($theme, 'date-focus-background'); + border-color: var-get($theme, 'date-focus-border-color'); } - %date-selected-range { - %date-inner { - &::after { - @if $variant != 'fluent' { - width: $date-size; - height: $date-size; - } @else { - width: $date-inner-size; - height: $date-inner-size; - } + // SELECTED + %date-current, + %date-selected { + &::after { + @if $variant != 'fluent' { + width: $date-inner-size; + height: $date-inner-size; } } } - %date-selected-special-range { - %date-inner { - color: var-get($theme, 'date-special-range-foreground'); - background: var-get($theme, 'date-special-range-background'); + %date-selected { + color: var-get($theme, 'date-selected-foreground'); + background: var-get($theme, 'date-selected-background'); + border-color: var-get($theme, 'date-selected-border-color'); - &:hover { - color: var-get($theme, 'date-special-hover-foreground'); - background: var-get($theme, 'date-special-range-hover-background'); + &:hover { + color: var-get($theme, 'date-selected-hover-foreground'); + background: var-get($theme, 'date-selected-hover-background'); + border-color: var-get($theme, 'date-selected-hover-border-color'); + } + } - @if $variant == 'indigo' { - // stylelint-disable-next-line - &::after { - border-color: var-get($theme, 'date-special-hover-border-color'); - } - } - } + %date-selected--focus { + color: var-get($theme, 'date-selected-focus-foreground'); + background: var-get($theme, 'date-selected-focus-background'); + border-color: var-get($theme, 'date-selected-focus-border-color'); + } - @if $variant == 'material' { - &::after { - border-color: var-get($theme, 'date-special-range-border-color'); - } + // CURRENT + %date-current { + color: var-get($theme, 'date-current-foreground'); + background: var-get($theme, 'date-current-background'); + border-color: var-get($theme, 'date-current-border-color'); + border-radius: inherit; - &:hover { - //color: var-get($theme, 'date-special-hover-foreground'); + &:hover { + color: var-get($theme, 'date-current-hover-foreground'); + background: var-get($theme, 'date-current-hover-background'); + border-color: var-get($theme, 'date-current-hover-border-color'); + } + } - // stylelint-disable-next-line - &::after { - border-color: var-get($theme, 'date-special-hover-border-color'); - } - } - } + %date-current-border-radius { + border-radius: var-get($theme, 'date-current-border-radius'); + } - @if $variant == 'bootstrap' or $variant == 'indigo' { - &::after { - border-color: var-get($theme, 'date-special-border-color'); - } - } - } + %date-current--focus { + color: var-get($theme, 'date-current-focus-foreground'); + background: var-get($theme, 'date-current-focus-background'); + border-color: var-get($theme, 'date-current-focus-border-color'); } - %date-selected-special-current-first, - %date-selected-special-current-last, - %date-selected-special-current-first-last { - %date-inner { - &::after { - @if $variant != 'fluent' { - border-color: var-get($theme, 'date-selected-special-border-color'); - } - } + %date-current--selected { + color: var-get($theme, 'date-selected-current-foreground'); + background: var-get($theme, 'date-selected-current-background'); + border-color: var-get($theme, 'date-selected-current-border-color'); + + &:hover { + color: var-get($theme, 'date-selected-current-hover-foreground'); + background: var-get($theme, 'date-selected-current-hover-background'); + border-color: var-get($theme, 'date-selected-current-hover-border-color'); } } - %date-selected-special-range-active { - %date-inner { - color: var-get($theme, 'date-special-focus-foreground'); - background: var-get($theme, 'date-special-range-focus-background'); + %date-current--selected-first, + %date-current--selected-last { + color: var-get($theme, 'date-selected-foreground'); + background: var-get($theme, 'date-selected-background'); - &::after { - border-color: var-get($theme, 'date-special-hover-border-color'); - } + &:hover { + color: var-get($theme, 'date-selected-hover-foreground'); + background: var-get($theme, 'date-selected-hover-background'); } } - %date-current { - %date-inner { - color: var-get($theme, 'date-current-foreground'); - border-color: var-get($theme, 'date-current-border-color'); + %date-current--selected-focus { + color: var-get($theme, 'date-selected-current-focus-foreground'); + background: var-get($theme, 'date-selected-current-focus-background'); + border-color: var-get($theme, 'date-selected-current-focus-border-color'); + } - @if $variant != 'fluent' { - background: var-get($theme, 'date-current-background'); - } + %date-current--selected-first-focus, + %date-current--selected-last-focus { + color: var-get($theme, 'date-selected-focus-foreground'); + background: var-get($theme, 'date-selected-focus-background'); + } - &:hover { - color: var-get($theme, 'date-current-hover-foreground'); - border-color: var-get($theme, 'date-current-hover-border-color'); + %date-selected-current-range { + color: var-get($theme, 'date-selected-current-range-foreground'); + background: var-get($theme, 'date-selected-current-range-background'); + border-color: var-get($theme, 'date-selected-current-border-color'); - @if $variant != 'fluent' { - background: var-get($theme, 'date-current-hover-background'); - } - } + &:hover { + color: var-get($theme, 'date-selected-current-range-hover-foreground'); + background: var-get($theme, 'date-selected-current-range-hover-background'); + border-color: var-get($theme, 'date-selected-current-hover-border-color'); + } + } - @if $variant == 'fluent' { - &::before { - content: ''; - position: absolute; - z-index: -1; - width: $date-inner-size; - height: $date-inner-size; - border-radius: calc(var-get($theme, 'date-current-border-radius') - ($border-size * 2)); - background: var-get($theme, 'date-current-background'); - } + %date-selected-current-focus { + color: var-get($theme, 'date-selected-current-range-focus-foreground'); + background: var-get($theme, 'date-selected-current-range-focus-background'); + border-color: var-get($theme, 'date-selected-current-focus-border-color'); + } - &::after { - border-radius: calc(var-get($theme, 'date-current-border-radius') - $border-size); - } + %wrapper-date-current--selected-range { + &::before { + border-radius: var-get($theme, 'date-current-border-radius'); + } + } + + // SPECIAL + %date-special { + color: var-get($theme, 'date-special-foreground'); + background: var-get($theme, 'date-special-background'); + + &::after { + content: ''; + position: absolute; + z-index: 0; + border: $border-size solid var-get($theme, 'date-special-border-color'); + border-radius: inherit; + width: var-get($theme, 'size'); + height: var-get($theme, 'size'); + box-sizing: border-box; + } + + &:hover { + color: var-get($theme, 'date-special-hover-foreground'); + background: var-get($theme, 'date-special-hover-background'); + + &::after { + border-color: var-get($theme, 'date-special-hover-border-color') } + } + } - @if $variant == 'indigo' { - &::after { - width: $date-size; - height: $date-size; - } + %date-special-border-radius { + border-radius: var-get($theme, 'date-special-border-radius'); + } + + %date-special--focus { + color: var-get($theme, 'date-special-focus-foreground'); + background: var-get($theme, 'date-special-focus-background'); + + &::after { + border-color: var-get($theme, 'date-special-focus-border-color') + } + } + + %date-special--selected { + color: var-get($theme, 'date-selected-special-foreground'); + background: var-get($theme, 'date-selected-special-background'); + + &::after { + width: var-get($theme, 'inner-size'); + height: var-get($theme, 'inner-size'); + border-color: var-get($theme, 'date-selected-special-border-color'); + } + + &:hover { + color: var-get($theme, 'date-selected-special-hover-foreground'); + background: var-get($theme, 'date-selected-special-hover-background'); + + &::after { + border-color: var-get($theme, 'date-selected-special-hover-border-color'); } } } - %date-current-range { - %date-inner { - color: var-get($theme, 'date-current-foreground'); + %date-special--selected-first, + %date-special--selected-last { + color: var-get($theme, 'date-selected-foreground'); + background: var-get($theme, 'date-selected-background'); + + &:hover { + color: var-get($theme, 'date-selected-hover-foreground'); + background: var-get($theme, 'date-selected-hover-background'); } } - %date-current-active { - %date-inner { - color: var-get($theme, 'date-current-focus-foreground'); - background: var-get($theme, 'date-current-focus-background'); - border-color: var-get($theme, 'date-current-focus-border-color'); + %date-special--selected-focus { + color: var-get($theme, 'date-selected-special-focus-foreground'); + background: var-get($theme, 'date-selected-special-focus-background'); + + &::after { + border-color: var-get($theme, 'date-selected-special-focus-border-color'); } } - %date-active { - %date-inner { - color: var-get($theme, 'date-focus-foreground'); - background: var-get($theme, 'date-focus-background'); - border-color: var-get($theme, 'date-focus-border-color'); + %date-special--selected-first-focus, + %date-special--selected-last-focus { + color: var-get($theme, 'date-selected-focus-foreground'); + background: var-get($theme, 'date-selected-focus-background'); + + &::after { + border-color: var-get($theme, 'date-selected-special-focus-border-color'); } } - %date-special { + %date-special-range { + color: var-get($theme, 'date-special-range-foreground'); + background: var-get($theme, 'date-special-range-background'); + + &::after { + border-color: var-get($theme, 'date-special-range-border-color'); + } + + &:hover { + color: var-get($theme, 'date-special-range-hover-foreground'); + background: var-get($theme, 'date-special-range-hover-background'); + + // stylelint-disable-next-line + &::after { + border-color: var-get($theme, 'date-special-range-hover-border-color'); + } + } + } + + %date-special-range-focus { + color: var-get($theme, 'date-special-range-focus-foreground'); + background: var-get($theme, 'date-special-range-focus-background'); + + // stylelint-disable-next-line + &::after { + border-color: var-get($theme, 'date-special-range-focus-border-color'); + } + } + + %date-special-range-not-preview { + color: var-get($theme, 'date-special-range-foreground'); + background: var-get($theme, 'date-special-range-background'); + + &:hover { + color: var-get($theme, 'date-special-range-hover-foreground'); + background: var-get($theme, 'date-special-range-hover-background'); + } + } + + %date-special-range-not-preview-focus { + color: var-get($theme, 'date-special-range-focus-foreground'); + background: var-get($theme, 'date-special-range-focus-background'); + } + + %wrapper-date-special--selected-range { + &::before { + border-radius: var-get($theme, 'date-special-border-radius'); + } + } + + %date-special--selected-range-all { + border-radius: var-get($theme, 'date-special-border-radius'); + } + + // SPECIAL + CURRENT + %material-date-special-current { + border-radius: var-get($theme, 'date-current-border-radius'); + + &::after { + width: var-get($theme, 'inner-size'); + height: var-get($theme, 'inner-size'); + border-radius: var-get($theme, 'date-special-border-radius'); + } + } + + // RANGE + %date-range { + border-block-color: var-get($theme, 'date-range-border-color'); + background: var-get($theme, 'date-selected-range-background'); + %date-inner { - @if $variant == 'indigo' { - font-weight: 700; + @if $variant == 'fluent' { + height: 100%; + } @else { + height: $date-height; } + } + } - color: var-get($theme, 'date-special-foreground'); - background: var-get($theme, 'date-special-background'); + %date-first { + &::after { + inset-inline-start: 50%; + } + } - &::after { - border-color: var-get($theme, 'date-special-border-color'); + %date-last { + &::after { + inset-inline-end: 50%; + } + } + + %date-first, + %date-last { + &::after { + width: 50%; + height: var-get($theme, 'size'); + } + } - @if $variant == 'bootstrap' or $variant == 'fluent' { - border-radius: calc(var-get($theme, 'date-special-border-radius') - ($border-size * 2)); + %date-range-border { + border-radius: var-get($theme, 'date-range-border-radius'); + } + + // You can have first and last without range that's why we need this selector + %date-range--first, + %date-range--last { + background: transparent; + border-block-color: transparent; + + @if $variant == 'fluent' { + %date-inner { + background: transparent; + border-color: transparent; + + &:hover { + border-color: transparent; } } + } - &:hover { - color: var-get($theme, 'date-special-hover-foreground'); - background: var-get($theme, 'date-special-hover-background'); + z-index: 0; - &::after { - border-color: var-get($theme, 'date-special-hover-border-color'); + &::after { + position: absolute; + content: ''; + z-index: -1; + background: var-get($theme, 'date-selected-range-background'); + border-block: rem(1px) solid var-get($theme, 'date-range-border-color'); + } + + &::before { + content: ''; + position: absolute; + height: $date-size; + width: $date-size; + background: var-get($theme, 'content-background'); + } + } + + %date-wrapper-range-border { + &::before { + border-radius: var-get($theme, 'date-range-border-radius'); + } + } + + %date-range-middle { + color: var-get($theme, 'date-selected-range-foreground'); + background: transparent; + border-color: transparent; + + &:hover { + color: var-get($theme, 'date-selected-range-hover-foreground'); + background: var-get($theme, 'date-selected-range-hover-background'); + } + } + + %date-range-middle--focus { + color: var-get($theme, 'date-selected-range-focus-foreground'); + background: var-get($theme, 'date-selected-range-focus-background'); + } + + // PREVIEW + %date-range-preview { + position: relative; + border-block-color: var-get($theme, 'date-range-preview-border-color'); + border-block-style: dashed; + + @if $variant == 'fluent' { + border-block-style: solid; + + %date-inner { + border-color: transparent; + + &:hover { + color: var-get($theme, 'content-foreground'); + background: transparent; + border-color: transparent; } } } } - %date-special-active { - %date-inner { - background: var-get($theme, 'date-special-focus-background'); - color: var-get($theme, 'date-special-focus-foreground'); + %date-preview--first, + %date-preview--last { + border-block-color: transparent; + + &::after { + content: ''; + position: absolute; + height: $date-size; + border-block-color: var-get($theme, 'date-range-preview-border-color'); + width: calc(50% + #{rem(1px)}); + border-width: rem(1px); + border-style: dashed; + border-inline-color: transparent; + + @if $variant == 'fluent' { + width: calc(50% + #{rem(2px)}); + border-style: solid; + border-inline-color: transparent; + } + } + } + + // DISABLED + %date-disabled { + color: var-get($theme, 'date-disabled-foreground'); + } + + %date-disabled-range { + color: var-get($theme, 'date-disabled-range-foreground'); + } + + %date-disabled-range-preview { + border-color: transparent; + } + + // OTHER + %date-hidden { + cursor: default; + visibility: hidden; + } + + %calendar__aria-off-screen { + position: absolute !important; + border: none !important; + height: 1px !important; + width: 1px !important; + inset-inline-start: 0 !important; + top: 0 !important; + overflow: hidden !important; + padding: 0 !important; + margin: 0 !important; + user-select: none; + pointer-events: none; + + &:focus { + outline: none; + } + } + + + /////////////////////////// + ////// FLUENT THEME ////// + ////////////////////////// + + // CURRENT + %fluent-date-current { + &::before { + content: ''; + position: absolute; + border: 1px solid var-get($theme, 'date-current-border-color'); + box-sizing: border-box; + width: var-get($theme, 'inner-size'); + height: var-get($theme, 'inner-size'); + background: var-get($theme, 'date-current-background'); + border-radius: var-get($theme, 'date-current-border-radius'); + z-index: -1; + } + + background: transparent; + border-color: var-get($theme, 'date-border-color'); + border-radius: var-get($theme, 'date-border-radius'); + + &:hover { + background: var-get($theme, 'date-hover-background'); + border-color: var-get($theme, 'date-hover-border-color'); + + &::before { + background: var-get($theme, 'date-current-hover-background'); + border-color: var-get($theme, 'date-current-hover-border-color'); + } + } + } + + %fluent-date-current-focus { + background: var-get($theme, 'date-focus-background'); + border-color: var-get($theme, 'date-focus-border-color'); + + &::before { + background: var-get($theme, 'date-current-focus-background'); + border-color: var-get($theme, 'date-current-focus-border-color'); + } + } + + // CURRENT + SELECTED + %fluent-date-current-selected { + color: var-get($theme, 'date-selected-current-foreground'); + background: var-get($theme, 'date-selected-background'); + border-color: var-get($theme, 'date-selected-border-color'); + + &::before { + border-color: var-get($theme, 'date-selected-current-border-color'); + background: var-get($theme, 'date-selected-current-background'); + } + + &:hover { + color: var-get($theme, 'date-selected-current-hover-foreground'); + background: var-get($theme, 'date-selected-hover-background'); + border-color: var-get($theme, 'date-selected-hover-border-color'); + + &::before { + border-color: var-get($theme, 'date-selected-current-hover-border-color'); + background: var-get($theme, 'date-selected-current-hover-background'); + } + } + } + + %fluent-date-current-selected-focus { + color: var-get($theme, 'date-selected-current-focus-foreground'); + background: var-get($theme, 'date-selected-focus-background'); + border-color: var-get($theme, 'date-selected-focus-border-color'); + + &::before { + border-color: var-get($theme, 'date-selected-current-focus-border-color'); + background: var-get($theme, 'date-selected-current-focus-background'); + } + } + + // SPECIAL + %fluent-date-special { + background: transparent; + border-color: var-get($theme, 'date-border-color'); + border-radius: var-get($theme, 'date-border-radius'); + + &::after { + width: var-get($theme, 'inner-size'); + height: var-get($theme, 'inner-size'); + background: var-get($theme, 'date-special-background'); + border-color: var-get($theme, 'date-special-border-color'); + border-radius: var-get($theme, 'date-special-border-radius'); + z-index: -1; + } + + &:hover { + background: var-get($theme, 'date-hover-background'); + border-color: var-get($theme, 'date-hover-border-color'); &::after { + background: var-get($theme, 'date-special-hover-background'); border-color: var-get($theme, 'date-special-hover-border-color'); } } } - %date-special-current { - %date-inner { - @if $variant == 'material' or $variant == 'indigo' { - &:hover { - color: var-get($theme, 'date-special-hover-foreground'); - } - } + %fluent-date-special-focus { + background: var-get($theme, 'date-focus-background'); + border-color: var-get($theme, 'date-focus-border-color'); - @if $variant == 'indigo' { - background: var-get($theme, 'date-current-background'); - border-color: var-get($theme, 'date-current-border-color'); + &::after { + background: var-get($theme, 'date-special-focus-background'); + border-color: var-get($theme, 'date-special-focus-border-color'); + } + } - &:hover { - background: var-get($theme, 'date-current-hover-background'); - border-color: var-get($theme, 'date-current-hover-border-color'); - } + // SPECIAL + SELECTED + %fluent-date-special-selected { + color: var-get($theme, 'date-selected-special-foreground'); + background: var-get($theme, 'date-selected-background'); + border-color: var-get($theme, 'date-selected-border-color'); - &::after { - width: calc($date-inner-size - #{rem(4px)}); - height: calc($date-inner-size - #{rem(4px)}); - } - } + &::after { + background: var-get($theme, 'date-selected-special-background'); + border-color: var-get($theme, 'date-selected-special-border-color'); + border-radius: var-get($theme, 'date-special-border-radius'); + } - @if $variant == 'fluent' { - color: var-get($theme, 'date-current-foreground'); + &:hover { + color: var-get($theme, 'date-selected-special-hover-foreground'); + background: var-get($theme, 'date-selected-hover-background'); + border-color: var-get($theme, 'date-selected-hover-border-color'); - &::after { - border-color: var-get($theme, 'date-current-foreground'); - width: sizable(rem(22px), rem(24px), rem(28px)); - height: sizable(rem(22px), rem(24px), rem(28px)); - } + &::after { + background: var-get($theme, 'date-selected-special-hover-background'); + border-color: var-get($theme, 'date-selected-special-hover-border-color'); } } } - @if $variant == 'indigo' { - %date-special-current-active { - %date-inner { - color: var-get($theme, 'date-special-focus-foreground'); - background: var-get($theme, 'date-current-focus-background'); - border-color: var-get($theme, 'date-current-focus-border-color'); - } - } + %fluent-date-special-selected-focus { + color: var-get($theme, 'date-selected-special-focus-foreground'); + background: var-get($theme, 'date-selected-focus-background'); + border-color: var-get($theme, 'date-selected-focus-border-color'); - %date-special-current-selected { - %date-inner { - &::after { - width: calc($date-inner-size - #{rem(4px)}); - height: calc($date-inner-size - #{rem(4px)}); - } - } + &::after { + background: var-get($theme, 'date-selected-special-focus-background'); + border-color: var-get($theme, 'date-selected-special-focus-border-color'); } } - %date-special-current-selected-active { - %date-inner { + // CURRENT + SPECIAL + %fluent-date-current-special { + color: var-get($theme, 'date-current-foreground'); + + &::after { + width: calc(var-get($theme, 'inner-size') - #{rem(4px)}); + height: calc(var-get($theme, 'inner-size') - #{rem(4px)}); + background: var-get($theme, 'date-current-background'); + border-color: var-get($theme, 'date-current-foreground'); + } + + &:hover { + color: var-get($theme, 'date-current-hover-foreground'); + &::after { - @if $variant != 'fluent' { - border-color: var-get($theme, 'date-selected-special-border-color'); - } + background: var-get($theme, 'date-current-hover-background'); + border-color: var-get($theme, 'date-current-hover-foreground'); } } } - %date-selected { - %date-inner { - color: var-get($theme, 'date-selected-foreground'); - background: var-get($theme, 'date-selected-background'); - border-color: var-get($theme, 'date-selected-border-color'); + %fluent-date-current-special-focus { + color: var-get($theme, 'date-current-focus-foreground'); - &:hover { - color: var-get($theme, 'date-selected-hover-foreground'); - background: var-get($theme, 'date-selected-hover-background'); - border-color: var-get($theme, 'date-selected-hover-border-color'); - } + &::after { + background: var-get($theme, 'date-current-focus-background'); + border-color: var-get($theme, 'date-current-focus-foreground'); } } - %date-selected-active { - %date-inner { - color: var-get($theme, 'date-selected-focus-foreground'); - background: var-get($theme, 'date-selected-focus-background'); - border-color: var-get($theme, 'date-selected-focus-border-color'); + // CURRENT + SPECIAL + SELECTED + + %fluent-date-current-special-selected { + color: var-get($theme, 'date-selected-current-foreground'); + + &::after { + background: var-get($theme, 'date-selected-current-background'); + border-color: var-get($theme, 'date-selected-current-foreground'); } - } - %date-selected-current { - %date-inner { - color: var-get($theme, 'date-selected-current-foreground'); - background: var-get($theme, 'date-selected-current-background') ; - border-color: var-get($theme, 'date-selected-current-border-color'); + &:hover { + color: var-get($theme, 'date-selected-current-hover-foreground'); - &:hover { - color: var-get($theme, 'date-selected-current-hover-foreground'); + &::after { background: var-get($theme, 'date-selected-current-hover-background'); - border-color: var-get($theme, 'date-selected-current-hover-border-color'); + border-color: var-get($theme, 'date-selected-current-hover-foreground'); } } } - %date-selected-current-active { - %date-inner { - color: var-get($theme, 'date-selected-current-focus-foreground'); + %fluent-date-current-special-selected-focus { + color: var-get($theme, 'date-selected-current-focus-foreground'); + + &::after { background: var-get($theme, 'date-selected-current-focus-background'); - border-color: var-get($theme, 'date-selected-current-focus-border-color'); + border-color: var-get($theme, 'date-selected-current-focus-foreground'); } } - @if $variant == 'indigo' { - %date-selected-current-special { - %date-inner { - &::after { - border-color: var-get($theme, 'date-selected-special-border-color'); - } + // CURRENT + SPECIAL + SELECTED + PREVIEW + FIRST/LAST + %fluent-date-current-special-selected-range-preview-first-last { + &:hover { + color: var-get($theme, 'date-current-foreground'); - &:hover { - // stylelint-disable-next-line - &::after { - border-color: var-get($theme, 'date-selected-special-hover-border-color'); - } - } + &::after { + border-color: var-get($theme, 'date-current-foreground'); } } + } - %date-selected-current-special-active { - %date-inner { - &::after { - border-color: var-get($theme, 'date-selected-special-focus-border-color'); - } - } + // FIRST + LAST + %fluent-date-first-last { + color: inherit; + background: transparent; + border-color: var-get($theme, 'date-range-preview-border-color'); + border-radius: var-get($theme, 'date-range-border-radius'); + + &:hover { + border-color: var-get($theme, 'date-range-preview-border-color'); } } - @if $variant == 'fluent' { - %date-selected-current-special { - %date-inner { - &::after { - border-color: var-get($theme, 'date-selected-current-background'); - } - } + %fluent-date-first-last-focus { + border-color: var-get($theme, 'date-range-preview-border-color'); + } + + // PREVIEW + %fluent-date-range-preview { + background: transparent; + border-color: transparent; + + &:hover { + background: transparent; + border-color: transparent; } } - %date-inactive { - cursor: default; + %fluent-date-range-preview-not-disabled { + color: inherit; + } - %date-inner { + %fluent-date-range-preview-inactive { + color: var-get($theme, 'inactive-color'); + + &:hover { color: var-get($theme, 'inactive-color'); + } + } - &:hover { - color: var-get($theme, 'inactive-color'); - } + %fluent-date-range-preview-weekend { + color: var-get($theme, 'weekend-color'); + + &:hover { + color: var-get($theme, 'weekend-color'); } } - %date-inactive-special { + %fluent-date-range-preview-middle { + border-color: transparent; + } + + %fluent-date-range-preview-first, + %fluent-date-range-preview-last { + color: inherit; + border-block-color: transparent; + + &::after { + background: transparent !important; + border-block-color: var-get($theme, 'date-range-preview-border-color'); + } + %date-inner { - &::after { - border-color: transparent; - } + border-color: transparent; + border-radius: var-get($theme, 'date-range-border-radius'); } } - %date-selected-current-range { - %date-inner { - color: var-get($theme, 'date-selected-current-range-foreground'); - background: var-get($theme, 'date-selected-current-range-background'); + %fluent-date-range-preview-last-after + %fluent-date-range-preview-last { + &::after { + inset-inline-end: $fake-bg-position; + } + } - @if $variant == 'material' or $variant == 'bootstrap' { - border-color: var-get($theme, 'date-selected-current-border-color'); - } + %fluent-date-range-preview-selected-special-first { + border-color: transparent; + } - @if $variant == 'indigo' { - border-color: transparent; + %fluent-date-range-preview-selected-special-last { + border-color: transparent; + } - &::after { - border-color: var-get($theme, 'date-current-border-color'); - } - } + %fluent-date-range-preview-current-first-last { + &::before { + background: var-get($theme, 'date-current-background'); + border-color: var-get($theme, 'date-current-border-color'); + } + } - &:hover { - color: var-get($theme, 'date-selected-current-range-hover-foreground'); - background: var-get($theme, 'date-selected-current-range-hover-background'); + // PREVIEW + SELECTED + %fluent-date-range-preview-first-last-selected { + &::after { + border-block-color: var-get($theme, 'date-range-preview-border-color'); + } + } - @if $variant == 'material' or $variant == 'bootstrap' { - border-color: var-get($theme, 'date-selected-current-hover-border-color'); - } + // PREVIEW + SPECIAL, CURRENT - (START) + // This part revert the hover styles for special and current dates in preview mode + %fluent-date-range-preview-current-focus, + %fluent-date-range-preview-current-special-focus { + &::before { + background: var-get($theme, 'date-current-background'); + border-color: var-get($theme, 'date-current-border-color'); + } + } - @if $variant == 'indigo' { - border-color: transparent; + %fluent-date-range-preview-special, + %fluent-date-range-preview-special-focus { + color: var-get($theme, 'date-special-foreground'); + background: transparent; - // stylelint-disable-next-line - &::after { - border-color: var-get($theme, 'date-current-hover-border-color'); - } - } + &::after { + background: var-get($theme, 'date-special-background'); + border-color: var-get($theme, 'date-special-border-color'); + } + + &:hover { + background: transparent; + color: var-get($theme, 'date-special-foreground'); + + &::after { + background: var-get($theme, 'date-special-background'); + border-color: var-get($theme, 'date-special-border-color'); } } } - %date-selected-current-range-active { - %date-inner { - color: var-get($theme, 'date-selected-current-range-focus-foreground'); - background: var-get($theme, 'date-selected-current-range-focus-background'); + %fluent-date-range-preview-current, + %fluent-date-range-preview-current-focus, + %fluent-date-range-preview-current-special, + %fluent-date-range-preview-current-special-focus { + color: var-get($theme, 'date-current-foreground'); + background: transparent; + border-color: transparent; - @if $variant == 'material' or $variant == 'bootstrap' { - border-color: var-get($theme, 'date-selected-current-focus-border-color'); - } + &::after { + border-color: var-get($theme, 'date-current-foreground'); + } - @if $variant == 'indigo' { - border-color: transparent; + &:hover { + color: var-get($theme, 'date-current-foreground'); + background: transparent; + border-color: transparent; - &::after { - border-color: var-get($theme, 'date-current-focus-border-color'); - } + &::before { + background: var-get($theme, 'date-current-background'); + border-color: var-get($theme, 'date-current-border-color'); + } + + &::after { + border-color: var-get($theme, 'date-current-foreground'); } } } - %date-selected-current-range-first, - %date-selected-current-range-last { - %date-inner { - @if $variant == 'indigo' { - &::after { - border-color: var-get($theme, 'date-current-border-color'); - } + %fluent-date-range-preview-current-special { + &::after { + background: var-get($theme, 'date-current-background'); + } - &:hover { - // stylelint-disable-next-line - &::after { - border-color: var-get($theme, 'date-current-hover-border-color'); - } - } + &:hover { + &::after { + background: var-get($theme, 'date-current-background'); } } } - %date-selected-current-range-special { - %date-inner { - @if $variant != 'fluent' { - color: var-get($theme, 'date-special-range-foreground'); - } @else { - color: var-get($theme, 'date-selected-current-range-foreground'); - } + %fluent-date-range-preview-current-special-focus { + &::after { + background: var-get($theme, 'date-current-background'); + } + } - @if $variant == 'indigo' { - border-color: var-get($theme, 'date-current-border-color'); - } + %fluent-selected-first-last { + background: transparent; + border-color: var-get($theme, 'date-range-preview-border-color'); + border-radius: var-get($theme, 'date-range-border-radius'); + } - &:hover { - @if $variant == 'fluent' { - color: var-get($theme, 'date-selected-current-range-foreground'); - } @else { - color: var-get($theme, 'date-special-hover-foreground'); + %fluent-selected-first-last-current, + %fluent-selected-first-last-current-special { + color: var-get($theme, 'date-current-foreground'); - // stylelint-disable-next-line - &::after { - border-color: var-get($theme, 'date-special-hover-border-color'); - } - } + &::before { + background: var-get($theme, 'date-current-background'); + border-color: var-get($theme, 'date-current-border-color'); + } - @if $variant == 'indigo' { - background: var-get($theme, 'date-selected-current-range-hover-background'); - border-color: var-get($theme, 'date-current-hover-border-color'); - } + &::after { + border-color: var-get($theme, 'date-current-foreground'); + } + + &:hover { + &::before { + background: var-get($theme, 'date-current-background'); + border-color: var-get($theme, 'date-current-border-color'); } &::after { - @if $variant != 'fluent' { - width: $date-inner-size; - height: $date-inner-size; - } @else { - width: sizable(rem(22px), rem(24px), rem(28px)); - height: sizable(rem(22px), rem(24px), rem(28px)); - } - - @if $variant == 'indigo' { - width: calc($date-inner-size - #{rem(4px)}); - height: calc($date-inner-size - #{rem(4px)}); - } + border-color: var-get($theme, 'date-current-foreground'); + } + } + } - @if $variant == 'fluent' { - border-color: var-get($theme, 'date-current-foreground'); - } - @if $variant == 'material' or $variant == 'bootstrap' { - border-color: var-get($theme, 'date-special-range-border-color'); - } + %fluent-selected-first-last-current-special { + &::after { + background: var-get($theme, 'date-current-background'); + border-color: var-get($theme, 'date-current-foreground'); + } - @if $variant == 'indigo' { - border-color: var-get($theme, 'date-special-range-border-color'); - } + &:hover { + &::after { + background: var-get($theme, 'date-current-background'); + border-color: var-get($theme, 'date-current-foreground'); } } } - %date-selected-current-range-special-active:not(%date-selected-current-range-special-first), - %date-selected-current-range-special-active:not(%date-selected-current-range-special-last) { - %date-inner { - @if not $bootstrap-theme and not $variant == 'fluent' { - color: var-get($theme, 'date-special-focus-foreground'); - } - - @if $variant == 'indigo' { - background: var-get($theme, 'date-selected-current-range-focus-background'); - border-color: var-get($theme, 'date-current-focus-border-color'); - - &::after { - border-color: var-get($theme, 'date-special-hover-border-color'); - } - } + %fluent-selected-first-last-current-special-focus { + &::after { + background: var-get($theme, 'date-current-background'); + border-color: var-get($theme, 'date-current-foreground'); } } - %date-selected-current-range-special-first, - %date-selected-current-range-special-last { - @if $variant != 'fluent' { - %date-inner { - color: var-get($theme, 'date-selected-foreground'); + %fluent-selected-first-last-special { + color: var-get($theme, 'date-special-foreground'); - &::after { - border-color: var-get($theme, 'date-selected-foreground'); - } - } - } @else { - %date-inner { - color: var-get($theme, 'date-selected-current-range-foreground'); + &::before { + border-color: transparent; + } - &::after { - width: sizable(rem(22px), rem(24px), rem(28px)); - height: sizable(rem(22px), rem(24px), rem(28px)); - } + &::after { + background: var-get($theme, 'date-special-background'); + border-color: var-get($theme, 'date-special-border-color'); + } + + &:hover { + &::after { + background: var-get($theme, 'date-special-background'); + border-color: var-get($theme, 'date-special-border-color'); } } + } + // PREVIEW + SPECIAL, CURRENT - (END) - @if $variant == 'indigo' { - %date-inner { - color: var-get($theme, 'date-selected-foreground'); - background: var-get($theme, 'date-selected-background'); + // RANGE + %fluent-date-range { + background: var-get($theme, 'date-selected-range-background'); + border-block-color: var-get($theme, 'date-range-border-color'); + } - &::after { - width: calc($date-inner-size - #{rem(4px)}); - height: calc($date-inner-size - #{rem(4px)}); - border-color: var-get($theme, 'date-selected-special-border-color'); - } + %fluent-date-range-first, + %fluent-date-range-last, + %fluent-date-range-preview-first, + %fluent-date-range-preview-last { + &::before, + &::after { + height: var-get($theme, 'size'); + } - &:hover { - color: var-get($theme, 'date-selected-hover-foreground'); - background: var-get($theme, 'date-selected-hover-background'); + &::before { + content: ''; + position: absolute; + width: var-get($theme, 'size'); + z-index: 3; + pointer-events: none; + } - // stylelint-disable-next-line - &::after { - border-color: var-get($theme, 'date-selected-special-hover-border-color'); - } - } - } + &::after { + width: $fake-bg-size; } } - %date-selected-current-range-special-active-first, - %date-selected-current-range-special-active-last { - @if $variant == 'indigo' { - %date-inner { - color: var-get($theme, 'date-selected-focus-foreground') !important; - background: var-get($theme, 'date-selected-focus-background') !important; - - &::after { - width: calc($date-inner-size - #{rem(4px)}); - height: calc($date-inner-size - #{rem(4px)}); - } - } + %fluent-date-range-first, + %fluent-date-range-last { + &::after { + background: var-get($theme, 'date-selected-range-background'); + border-block: $border-size solid var-get($theme, 'date-range-border-color'); } } - %date-range-selected-active { - %date-inner { - color: var-get($theme, 'date-selected-range-focus-foreground'); - background: var-get($theme, 'date-selected-range-focus-background'); + %fluent-date-range-preview-first, + %fluent-date-range-first { + &::after { + inset-inline-start: $fake-bg-position; + border-start-start-radius: var-get($theme, 'date-range-border-radius'); + border-end-start-radius: var-get($theme, 'date-range-border-radius'); + border-inline-end: 0; + } + + &::before { + inset-inline-end: initial; + border-start-start-radius: var-get($theme, 'date-range-border-radius'); + border-end-start-radius: var-get($theme, 'date-range-border-radius'); + border-start-end-radius: 0; + border-end-end-radius: 0; } } - %date-range-selected-active-first { - %date-inner { - color: var-get($theme, 'date-selected-focus-foreground'); - background: var-get($theme, 'date-selected-focus-background'); + %fluent-date-range-preview-first { + &::after { + border-color: var-get($theme, 'date-range-preview-border-color'); } } - %date-range-selected-active-last { - %date-inner { - color: var-get($theme, 'date-selected-focus-foreground'); - background: var-get($theme, 'date-selected-focus-background'); + %fluent-date-range-first { + &::after { + border-inline-start: $border-size solid transparent; + border-color: var-get($theme, 'date-range-border-color'); } } - %date-range { - border-top-color: var-get($theme, 'date-range-border-color'); - border-bottom-color: var-get($theme, 'date-range-border-color'); - background: var-get($theme, 'date-selected-range-background'); + %fluent-date-range-preview-last, + %fluent-date-range-last { + &::after { + inset-inline-end: $fake-bg-position; + border-start-end-radius: var-get($theme, 'date-range-border-radius'); + border-end-end-radius: var-get($theme, 'date-range-border-radius'); + border-inline-start: 0; + } - %date-inner { - background: transparent; - color: var-get($theme, 'date-selected-range-foreground'); + &::before { + inset-inline-start: initial; + border-start-end-radius: var-get($theme, 'date-range-border-radius'); + border-end-end-radius: var-get($theme, 'date-range-border-radius'); + border-start-start-radius: 0; + border-end-start-radius: 0; + } + } - @if $variant == 'fluent' { - height: 100%; - } @else { - height: $date-height; - } + %fluent-date-range-preview-last { + &::after { + border-color: var-get($theme, 'date-range-preview-border-color'); + } + } + + %fluent-date-range-last { + &::after { + border-inline-end: $border-size solid transparent; + border-color: var-get($theme, 'date-range-border-color'); } } - %date-range-hover { - border-top-color: var-get($theme, 'date-range-border-color'); - border-bottom-color: var-get($theme, 'date-range-border-color'); + %fluent-date-range-selected-special-not-preview, + %fluent-date-range-selected-current-not-preview{ + border-color: transparent; } - %date-range-selected-first, - %date-range-selected-last { + %fluent-date-range-special-not-preview { + color: var-get($theme, 'date-special-range-foreground'); background: transparent; - border-block-color: transparent; + border-radius: var-get($theme, 'date-range-border-radius'); - @if $variant == 'fluent' { - %date-inner { - background: transparent; - border-color: transparent; + &::after { + background: var-get($theme, 'date-special-range-background'); + border-color: var-get($theme, 'date-special-range-border-color'); + } - &:hover { - border-color: transparent; - } + &:hover { + color: var-get($theme, 'date-special-range-hover-foreground'); + background: var-get($theme, 'date-selected-range-hover-background'); + + &::after { + background: var-get($theme, 'date-special-range-hover-background'); + border-color: var-get($theme, 'date-special-range-hover-border-color'); } } + } - z-index: 0; + %fluent-date-range-special-not-preview-focus { + color: var-get($theme, 'date-special-range-focus-foreground'); + background: var-get($theme, 'date-selected-range-focus-background'); &::after { - position: absolute; - content: ''; - z-index: -1; - color: var-get($theme, 'date-selected-foreground'); - background: var-get($theme, 'date-selected-range-background'); - border-block: $border-size solid transparent; + background: var-get($theme, 'date-special-range-focus-background'); + border-color: var-get($theme, 'date-special-range-focus-border-color'); } + } + + %fluent-date-range-current-not-preview { + color: var-get($theme, 'date-selected-current-range-foreground'); + background: transparent; + border-radius: var-get($theme, 'date-range-border-radius'); &::before { - content: ''; - position: absolute; - height: $date-size; - width: $date-size; + background: var-get($theme, 'date-selected-current-range-background'); + border-color: var-get($theme, 'date-selected-current-border-color'); } - } - %date-range-selected-first-disabled, - %date-range-selected-last-disabled { - %date-inner { - opacity: .38; - } + &:hover { + color: var-get($theme, 'date-selected-current-range-hover-foreground'); + background: var-get($theme, 'date-selected-range-hover-background'); - &::before { - @if $variant == 'fluent' { - background: var-get($theme, 'date-selected-range-background'); - z-index: -1; - } @else { - background: var-get($theme, 'content-background'); + &::before { + background: var-get($theme, 'date-selected-current-range-hover-background'); + border-color: var-get($theme, 'date-selected-current-hover-border-color'); } } } - %date-range-selected-special-first, - %date-range-selected-special-last { - %date-inner { - &::after { - width: $date-inner-size; - height: $date-inner-size; + %fluent-date-range-current-not-preview-focus { + color: var-get($theme, 'date-selected-current-range-focus-foreground'); + background: var-get($theme, 'date-selected-range-focus-background'); - @if $variant != 'fluent' { - border-color: var-get($theme, 'date-selected-special-border-color'); - } - } + &::before { + background: var-get($theme, 'date-selected-current-range-focus-background'); + border-color: var-get($theme, 'date-selected-current-focus-border-color'); } } - @if $variant == 'fluent' { - %date-first-preview-active { - %date-inner { - background: transparent; - border-inline-end-color: transparent; - } - } + // RANGE + SELECTED + %fluent-date-range-selected { + background: var-get($theme, 'date-selected-range-background'); + } - %date-last-preview-active { - %date-inner { - background: transparent; - border-inline-start-color: transparent; - } + %fluent-date-range-selected-first-last { + background: transparent; + border-color: transparent; + + &::before { + background: transparent; } - %date-first-last-selected { - %date-inner { - background: transparent; - } + &::after { + background: var-get($theme, 'date-selected-range-background'); } } - %date-selected-special-first-last { - %date-inner { - &::after { - @if $variant == 'material' or $variant == 'bootstrap' { - border-color: var-get($theme, 'date-selected-foreground'); - } + %fluent-date-range-selected-first { + &::before { + border-inline-start: $border-size solid var-get($theme, 'date-range-border-color'); + border-inline-end: 0; + border-start-start-radius: var-get($theme, 'date-range-border-radius'); + border-end-start-radius: var-get($theme, 'date-range-border-radius'); + border-start-end-radius: 0; + border-end-end-radius: 0; + } + } - @if $variant == 'indigo' { - border-color: var-get($theme, 'date-current-border-color'); - } - } + %fluent-date-range-selected-last { + &::before { + border-inline-end: $border-size solid var-get($theme, 'date-range-border-color'); + border-inline-start: 0; + border-start-end-radius: var-get($theme, 'date-range-border-radius'); + border-end-end-radius: var-get($theme, 'date-range-border-radius'); + border-start-start-radius: 0; + border-end-start-radius: 0; } } - %date-range-selected-first { - %date-inner { - color: var-get($theme, 'date-selected-foreground'); - background: var-get($theme, 'date-selected-background'); - border-radius: var-get($theme, 'date-range-border-radius'); + %fluent-date-range-selected-not-preview { + color: var-get($theme, 'date-selected-range-foreground'); - &:hover { - color: var-get($theme, 'date-selected-hover-foreground'); - background: var-get($theme, 'date-selected-hover-background'); - } + &:hover { + color: var-get($theme, 'date-selected-range-hover-foreground'); + background: var-get($theme, 'date-selected-range-hover-background'); } - } - %date-range-selected-last { - %date-inner { - color: var-get($theme, 'date-selected-foreground'); - background: var-get($theme, 'date-selected-background'); - border-radius: var-get($theme, 'date-range-border-radius'); + &:focus { + color: var-get($theme, 'date-selected-range-focus-foreground'); + background: var-get($theme, 'date-selected-range-focus-background'); + } - &:hover { - color: var-get($theme, 'date-selected-hover-foreground'); - background: var-get($theme, 'date-selected-hover-background'); - } + &::before { + background: var-get($theme, 'date-selected-range-background'); } } - @if $variant == 'fluent' { - %date-range-selected-current-first, - %date-range-selected-current-last { - %date-inner { - color: var-get($theme, 'date-selected-current-range-foreground'); + %fluent-date-range-selected-not-preview-disabled { + color: var-get($theme, 'date-selected-range-foreground'); - &:hover { - color: var-get($theme, 'date-selected-current-range-foreground'); - } - } + &:hover { + color: var-get($theme, 'date-selected-range-hover-foreground'); + background: var-get($theme, 'date-selected-range-hover-background'); } } - %date-first { - &::after { - inset-inline-start: 50%; - } + %fluent-date-range-selected-not-preview-disabled-focus { + color: var-get($theme, 'date-selected-range-focus-foreground'); + background: var-get($theme, 'date-selected-range-focus-background'); } - %date-last { - &::after { - inset-inline-end: 50%; + %fluent-date-range-current-special-not-preview { + color: var-get($theme, 'date-selected-current-range-foreground'); + + &::before { + background: var-get($theme, 'date-selected-current-range-background'); + border-color: var-get($theme, 'date-selected-current-border-color'); } - } - %date-range-selected { &::after { - border-block: $border-size solid var-get($theme, 'date-range-border-color'); + background: var-get($theme, 'date-selected-current-range-background'); + border-color: var-get($theme, 'date-selected-current-foreground'); } - %date-inner { - @if $variant == 'fluent' { - border-color: transparent !important; - } + &:hover { + color: var-get($theme, 'date-selected-current-range-hover-foreground'); - @if $variant != 'fluent' and $variant != 'indigo' { - border-inline-color: transparent; + &::before { + background: var-get($theme, 'date-selected-current-range-hover-background'); + border-color: var-get($theme, 'date-selected-current-hover-border-color'); } - &:hover { - background: var-get($theme, 'date-selected-range-hover-background'); - color: var-get($theme, 'date-selected-range-hover-foreground'); + &::after { + background: var-get($theme, 'date-selected-current-range-hover-background'); + border-color: var-get($theme, 'date-selected-current-hover-foreground'); } } } - %date-range-selected-first, - %date-range-selected-last, - %date-first-preview, - %date-last-preview { + %fluent-date-range-current-special-not-preview-focus { + color: var-get($theme, 'date-selected-current-range-focus-foreground'); + + &::before { + background: var-get($theme, 'date-selected-current-range-focus-background'); + border-color: var-get($theme, 'date-selected-current-focus-border-color'); + } + &::after { - content: ''; - position: absolute; - height: $date-size; - width: 50%; + background: var-get($theme, 'date-selected-current-range-focus-background'); + border-color: var-get($theme, 'date-selected-current-focus-foreground'); + } + } - @if $variant == 'indigo' { - border-width: calc(#{$border-size} / 2); - } @else { - border-width: $border-size; - } + // DISABLED + %fluent-date-range-special-disabled, + %fluent-date-range-current-disabled { + border-color: transparent; + } + + %fluent-date-range-special-current-disabled { + &::after { + border-color: var-get($theme, 'date-current-foreground'); } } - @if $variant == 'fluent' { - %date-range-selected-first, - %date-range-selected-last { - &::after { - border-block-color: var-get($theme, 'date-range-border-color'); - } + /////////////////////////////////// + ////////// BOOTSTRAP THEME /////// + ///////////////////////////////// - &::before { - content: ''; - position: absolute; - height: $date-size; - width: $date-size; - border: rem(1px) solid var-get($theme, 'date-range-border-color'); - z-index: 3; - pointer-events: none; - } + // SPECIAL + %bootstrap-date-special { + border-radius: var-get($theme, 'date-border-radius'); + + &::after { + border-radius: var-get($theme, 'date-special-border-radius'); } + } - %date-range-selected-first { - %date-inner { - border-start-end-radius: var-get($theme, 'date-border-radius'); - border-end-end-radius: var-get($theme, 'date-border-radius'); - } + %bootstrap-date-special-first-last { + border-radius: var-get($theme, 'date-range-border-radius'); + } + + %bootstrap-date-special-range { + border-color: transparent; - &::before { - border-inline-end-color: transparent; - border-start-start-radius: var-get($theme, 'date-range-border-radius'); - border-end-start-radius: var-get($theme, 'date-range-border-radius'); - } + &:hover { + border-color: var-get($theme, 'date-hover-border-color'); } + } - %date-range-selected-last { - %date-inner { - border-start-start-radius: var-get($theme, 'date-border-radius'); - border-end-start-radius: var-get($theme, 'date-border-radius'); - } + %bootstrap-date-special-range-focus { + border-color: var-get($theme, 'date-focus-border-color'); + } - &::before { - border-inline-start-color: transparent; - border-start-end-radius: var-get($theme, 'date-range-border-radius'); - border-end-end-radius: var-get($theme, 'date-range-border-radius'); - } - } + // CURRENT + %bootstrap-date-current-first-last { + border-radius: var-get($theme, 'date-range-border-radius'); + } + // CURRENT + SPECIAL + %bootstrap-date-current-special { + color: var-get($theme, 'date-current-foreground'); + background: var-get($theme, 'date-current-background'); + border-color: var-get($theme, 'date-current-border-color'); + border-radius: var-get($theme, 'date-current-border-radius'); - %date-first-preview { - %date-inner { - border-start-start-radius: var-get($theme, 'date-range-border-radius'); - border-end-start-radius: var-get($theme, 'date-range-border-radius'); - border-start-end-radius: 0; - border-end-end-radius: 0; - } + &:hover { + color: var-get($theme, 'date-current-hover-foreground'); + background: var-get($theme, 'date-current-hover-background'); + border-color: var-get($theme, 'date-current-hover-border-color'); } - %date-last-preview { - %date-inner { - border-start-end-radius: var-get($theme, 'date-range-border-radius'); - border-end-end-radius: var-get($theme, 'date-range-border-radius'); - border-start-start-radius: 0; - border-end-start-radius: 0; - } + &::after { + width: var-get($theme, 'inner-size'); + height: var-get($theme, 'inner-size'); } + } - %date-first-last { - %date-inner { - border-radius: var-get($theme, 'date-range-border-radius'); - } + %bootstrap-date-current-special-focus { + color: var-get($theme, 'date-current-focus-foreground'); + background: var-get($theme, 'date-current-focus-background'); + border-color: var-get($theme, 'date-current-focus-border-color'); + } + + %bootstrap-date-current-special-range { + color: var-get($theme, 'date-selected-current-range-foreground'); + background: var-get($theme, 'date-selected-current-range-background'); + border-color: var-get($theme, 'date-current-border-color'); + + &:hover { + color: var-get($theme, 'date-selected-current-range-hover-foreground'); + background: var-get($theme, 'date-selected-current-range-hover-background'); + border-color: var-get($theme, 'date-current-hover-border-color'); } } - %date-range-preview { - position: relative; - border-block-color: var-get($theme, 'date-range-preview-border-color'); + %bootstrap-date-current-special-range-focus { + color: var-get($theme, 'date-selected-current-range-focus-foreground'); + background: var-get($theme, 'date-selected-current-range-focus-background'); + border-color: var-get($theme, 'date-current-focus-border-color'); + } + + %bootstrap-date-current-special-selected-not-range { + color: var-get($theme, 'date-selected-current-foreground'); + background: var-get($theme, 'date-selected-current-background'); - @if $variant == 'material' or $variant == 'indigo' { - border-block-style: dashed; + &:hover { + color: var-get($theme, 'date-selected-current-hover-foreground'); + background: var-get($theme, 'date-selected-current-hover-background'); } + } - @if $variant == 'bootstrap' { - background: var-get($theme, 'date-selected-range-background'); + %bootstrap-date-current-special-selected-not-range-focus { + color: var-get($theme, 'date-selected-current-focus-foreground'); + background: var-get($theme, 'date-selected-current-focus-background'); + } - &::after { - background: var-get($theme, 'date-selected-range-background'); - } + // PREVIEW + %bootstrap-date-range-preview { + color: var-get($theme, 'date-selected-range-foreground'); + background: var-get($theme, 'date-selected-range-background'); + border-block-style: solid; + + &::after { + background: var-get($theme, 'date-selected-range-background'); } %date-inner { - color: var-get($theme, 'date-selected-range-foreground'); + border-color: transparent; &:hover { - @if not $bootstrap-theme { - color: var-get($theme, 'date-selected-range-hover-foreground'); - } @else { - color: var-get($theme, 'date-selected-foreground'); - } + color: var-get($theme, 'date-selected-range-foreground'); + border-color: transparent; } } } - %date-range-preview-inactive { - %date-inner { - color: var-get($theme, 'inactive-color'); + %bootstrap-date-preview--first-last { + background: transparent; + + &::after { + width: 50%; + border-style: solid; + border-inline: 0; } - } - %date-range-preview-current { %date-inner { - color: var-get($theme, 'date-selected-current-range-foreground'); + color: var-get($theme, 'date-selected-foreground'); + background: var-get($theme, 'date-selected-background'); + border-color: var-get($theme, 'date-selected-border-color'); + border-radius: var-get($theme, 'date-range-border-radius'); - @if $variant == 'bootstrap' { - color: var-get($theme, 'date-selected-current-range-foreground'); - background: var-get($theme, 'date-selected-current-range-background'); + &:hover { + color: var-get($theme, 'date-selected-hover-foreground'); + background: var-get($theme, 'date-selected-hover-background'); + border-color: var-get($theme, 'date-selected-hover-border-color'); + } + + &::after { + width: var-get($theme, 'inner-size'); + height: var-get($theme, 'inner-size'); } } } - %date-range-preview-special { + %bootstrap-date-preview--first-last-focus { %date-inner { - color: var-get($theme, 'date-special-foreground'); + color: var-get($theme, 'date-selected-focus-foreground'); + background: var-get($theme, 'date-selected-focus-background'); + border-color: var-get($theme, 'date-selected-focus-border-color'); + border-radius: var-get($theme, 'date-range-border-radius'); } } - @if $variant == 'fluent' { - %date-range-preview-special-current { - %date-inner { - color: var-get($theme, 'date-selected-current-range-foreground'); - } + // range preview current + %bootstrap-date-range-preview-current { + color: var-get($theme, 'date-current-foreground'); + background: var-get($theme, 'date-current-background'); + border-color: var-get($theme, 'date-current-border-color'); + + &:hover { + color: var-get($theme, 'date-current-hover-foreground'); + background: var-get($theme, 'date-current-hover-background'); } } - %date-first-preview, - %date-last-preview { - &::after { - content: ''; - position: absolute; - - @if $variant == 'fluent' { - width: calc(50% + #{rem(2px)}); - } - - @if $variant == 'material' or $variant == 'indigo' { - width: calc(50% + #{rem(1px)}); - } + %bootstrap-date-range-preview-current-focus { + color: var-get($theme, 'date-current-focus-foreground'); + background: var-get($theme, 'date-current-focus-background'); + } - @if $variant == 'bootstrap' { - width: 50%; - } + // range preview current + firs/last + %bootstrap-date-range-preview-current-first-last { + color: var-get($theme, 'date-selected-foreground'); + background: var-get($theme, 'date-selected-background'); + border-color: var-get($theme, 'date-selected-current-border-color'); - @if not $bootstrap-theme { - border-inline-color: transparent; - } @else { - border-inline: 0; - } + &:hover { + color: var-get($theme, 'date-selected-hover-foreground'); + background: var-get($theme, 'date-selected-hover-background'); + border-color: var-get($theme, 'date-selected-current-hover-border-color'); + } + } - @if $variant == 'material' or $variant == 'indigo' { - border-style: dashed; - } @else { - border-style: solid; - } + %bootstrap-date-range-preview-current-focus-first-last { + color: var-get($theme, 'date-selected-focus-foreground'); + background: var-get($theme, 'date-selected-focus-background'); + border-color: var-get($theme, 'date-selected-current-focus-border-color'); + } - border-block-color: var-get($theme, 'date-range-preview-border-color'); + // range preview special + firs/last + %bootstrap-date-preview-special--first-last { + &::after { + width: var-get($theme, 'inner-size'); + height: var-get($theme, 'inner-size'); + border-color: var-get($theme, 'date-selected-special-border-color'); } - %date-inner { - @if $variant == 'bootstrap' { - color: var-get($theme, 'date-selected-foreground'); - background: var-get($theme, 'date-selected-background'); + &:hover { + &::after { + border-color: var-get($theme, 'date-selected-special-hover-border-color'); } } } - %date-first-preview-selected, - %date-last-preview-selected { - %date-inner { - color: var-get($theme, 'date-selected-foreground'); + %bootstrap-date-preview-special-focus--first-last { + &::after { + border-color: var-get($theme, 'date-selected-special-focus-border-color'); } } - @if $variant == 'fluent' { - %date-first-preview-current-selected, - %date-last-preview-current-selected { - %date-inner { - color: var-get($theme, 'date-selected-current-range-foreground'); - } + %bootstrap-date-preview-weekend-inactive { + color: var-get($theme, 'date-selected-range-foreground'); + + &:hover { + color: var-get($theme, 'date-selected-range-hover-foreground'); } } - %date-first-preview { - background: transparent; - border-block-color: transparent ; + %bootstrap-date-preview-weekend-inactive-focus { + color: var-get($theme, 'date-selected-range-focus-foreground'); + } - %date-inner { - @if $variant == 'fluent' { - background: transparent; - border-color: var-get($theme, 'date-range-preview-border-color'); - border-inline-end-color: transparent; - } - } + // RANGE + %bootstrap-date-range-radius { + border-radius: var-get($theme, 'date-range-border-radius'); + } - &::after { - inset-inline-start: 50%; - } + // DISABLED + %bootstrap-date-disabled-range-preview { + color: var-get($theme, 'date-disabled-range-foreground'); } - %date-first-preview-special { - %date-inner { - &::after { - @if $variant == 'material' or $variant == 'bootstrap' { - width: var-get($theme, 'inner-size'); - height: var-get($theme, 'inner-size'); - border-color: var-get($theme, 'date-selected-foreground'); - } - } - &:hover { - &::after { - /* stylelint-disable-next-line */ - @if $variant == 'material' { - width: $date-size; - height: $date-size; - border-color: var-get($theme, 'date-special-border-color'); - } - } + ////////////////////////////////////// + //////////// INDIGO THEME /////////// + //////////////////////////////////// + %indigo-label-week-number { + span { + border: 0; + + &::before { + height: $date-view-row-gap; + inset-block-start: 100%; + inset-inline-start: 0; + border: 0; } } } - %date-last-preview { - background: transparent; - border-block-color: transparent ; + %indigo-date-inner-week-number { + border: 0; - %date-inner { - @if $variant == 'fluent' { - background: transparent ; - border-color: var-get($theme, 'date-range-preview-border-color'); - border-inline-start-color: transparent; - } + &::before { + height: $date-view-row-gap; + inset-block-start: 100%; + inset-inline-start: 0; + border: 0; } + } - &::after { - inset-inline-end: 50%; - } + %indigo-date-special-selected-inner-radius { + border-radius: calc(var-get($theme, 'date-special-border-radius') - $border-size); } - %date-first-last { - %date-inner { - @if $variant == 'fluent' { - background: transparent; - } + %indigo-date-special-current-indented { + &::after { + width: calc(var-get($theme, 'inner-size') - #{rem(4px)}); + height: calc(var-get($theme, 'inner-size') - #{rem(4px)}); } } - %date-last-preview-special { - %date-inner { - &::after { - @if $variant == 'material' or $variant == 'bootstrap' { - width: var-get($theme, 'inner-size'); - height: var-get($theme, 'inner-size'); - border-color: var-get($theme, 'date-selected-foreground'); - } - } + // CURRENT + SELECTED + FIRST/LAST + %indigo-current-selected-first-last, + %indigo-current-selected-range-first-last { + color:var-get($theme, 'date-selected-current-foreground'); + background: var-get($theme, 'date-selected-current-background'); + border-color: var-get($theme, 'date-selected-current-border-color'); - &:hover { - &::after { - /* stylelint-disable-next-line */ - @if $variant == 'material' { - width: $date-size; - height: $date-size; - border-color: var-get($theme, 'date-special-border-color'); - } - } - } + &:hover { + color:var-get($theme, 'date-selected-current-hover-foreground'); + background: var-get($theme, 'date-selected-current-hover-background'); + border-color: var-get($theme, 'date-selected-current-hover-border-color'); } } - %date-first-preview-special-current, - %date-last-preview-special-current { - %date-inner { - &:hover { - &::after { - /* stylelint-disable-next-line */ - @if $variant == 'material' { - width: $date-inner-size; - height: $date-inner-size; - } - } - } - } + %indigo-current-selected-first-last-focus, + %indigo-current-selected-range-first-last-focus { + color:var-get($theme, 'date-selected-current-focus-foreground'); + background: var-get($theme, 'date-selected-current-focus-background'); + border-color: var-get($theme, 'date-selected-current-focus-border-color'); } - %date-first-preview-special-active, - %date-last-preview-special-active { - %date-inner { - &::after { - @if $variant == 'material' { - width: $date-size; - height: $date-size; - border-color: var-get($theme, 'date-special-border-color'); - } - } + // SPECIAL + SELECTED + FIRST/LAST + %indigo-special-selected-first-last { + color:var-get($theme, 'date-selected-special-foreground'); + background: var-get($theme, 'date-selected-special-background'); + + &:hover { + color:var-get($theme, 'date-selected-special-hover-foreground'); + background: var-get($theme, 'date-selected-special-hover-background'); } } + %indigo-special-selected-first-last-focus { + color:var-get($theme, 'date-selected-special-focus-foreground'); + background: var-get($theme, 'date-selected-special-focus-background'); + } - %date-first-preview-special-active-selected, - %date-last-preview-special-active-selected { - %date-inner { - &::after { - @if $variant == 'material' { - width: $date-inner-size; - height: $date-inner-size; - border-color: var-get($theme, 'date-selected-foreground'); - } - } + // SPECIAL + CURRENT + %indigo-special-current { + border-radius: var-get($theme, 'date-current-border-radius'); + + &::after { + width: calc(var-get($theme, 'inner-size') - #{rem(4px)}); + height: calc(var-get($theme, 'inner-size') - #{rem(4px)}); + border-radius: calc(var-get($theme, 'date-special-border-radius') - $border-size); } } - %date-first-preview-special-active-current, - %date-last-preview-special-active-current { - %date-inner { - &::after { - @if $variant == 'material' { - width: $date-inner-size; - height: $date-inner-size; - } - } - } + %indigo-date-special-current-focus { + color:var-get($theme, 'date-selected-special-focus-foreground'); + background: var-get($theme, 'date-selected-current-focus-background'); + border-color: var-get($theme, 'date-selected-special-focus-border-color'); } - %date-disabled { - pointer-events: none; - cursor: not-allowed; + %indigo-special-current-range { + color:var-get($theme, 'date-special-foreground'); + background: var-get($theme, 'date-selected-current-background'); + border-color: var-get($theme, 'date-selected-current-border-color'); - %date-inner { - color: var-get($theme, 'date-disabled-foreground'); + &:hover { + color:var-get($theme, 'date-special-hover-foreground'); + background: var-get($theme, 'date-selected-current-hover-background'); + border-color: var-get($theme, 'date-selected-current-hover-border-color'); } } - %date-disabled-range { - %date-inner { - color: var-get($theme, 'date-disabled-range-foreground'); + %indigo-special-current-range-focus { + color:var-get($theme, 'date-special-focus-foreground'); + background: var-get($theme, 'date-selected-current-focus-background'); + border-color: var-get($theme, 'date-selected-current-focus-border-color'); + } - @if $variant == 'fluent' { - opacity: .38; - } + %indigo-special-current-range-not-first-last { + color:var-get($theme, 'date-special-range-foreground'); + background: var-get($theme, 'date-selected-current-range-background'); + border-color: var-get($theme, 'date-selected-current-border-color'); + + &:hover { + color:var-get($theme, 'date-special-range-hover-foreground'); + background: var-get($theme, 'date-selected-current-range-hover-background'); + border-color: var-get($theme, 'date-selected-current-hover-border-color'); } } - %date-disabled-range-preview { - %date-inner { - @if $bootstrap-theme { - color: var-get($theme, 'date-disabled-range-foreground'); - } - } + %indigo-special-current-range-not-first-last-focus { + color:var-get($theme, 'date-special-range-focus-foreground'); + background: var-get($theme, 'date-selected-current-range-focus-background'); + border-color: var-get($theme, 'date-selected-current-focus-border-color'); } - %date-disabled-selected:not(%date-range) { - %date-inner { - color: var-get($theme, 'date-selected-foreground'); - opacity: .38; + %indigo-special-current-range-first-last, + %indigo-special-current-range-first-last { + color:var-get($theme, 'date-selected-special-foreground'); + background: var-get($theme, 'date-selected-current-background'); + border-color: var-get($theme, 'date-selected-current-border-color'); + + &:hover { + color:var-get($theme, 'date-selected-special-hover-foreground'); + background: var-get($theme, 'date-selected-current-hover-background'); + border-color: var-get($theme, 'date-selected-current-hover-border-color'); } } - %date-disabled-inactive { - %date-inner { - color: var-get($theme, 'inactive-color'); - opacity: 1; - } + %indigo-special-current-range-first-last-focus, + %indigo-special-current-range-first-last-focus { + color:var-get($theme, 'date-selected-special-focus-foreground'); + background: var-get($theme, 'date-selected-current-focus-background'); + border-color: var-get($theme, 'date-selected-current-focus-border-color'); } - %date-disabled-special { - %date-inner { - color: var-get($theme, 'date-special-foreground'); - opacity: .38; + %indigo-date-special-current-selected { + color: var-get($theme, 'date-selected-special-foreground'); + background: var-get($theme, 'date-selected-current-background'); + border-color: var-get($theme, 'date-selected-current-border-color'); + + &::after { + border-color: var-get($theme, 'date-selected-special-border-color'); } - } - %date-disabled-special-selected { - %date-inner { - @if $variant == 'fluent' { - color: var-get($theme, 'date-selected-foreground'); + &:hover { + color: var-get($theme, 'date-selected-special-hover-foreground'); + background: var-get($theme, 'date-selected-current-hover-background'); + border-color: var-get($theme, 'date-selected-current-hover-border-color'); + + &::after { + border-color: var-get($theme, 'date-selected-special-hover-border-color'); } } } - %date-disabled-current { - %date-inner { - color: var-get($theme, 'date-current-foreground'); - opacity: .38; - } - } + %indigo-date-special-current-selected-focus { + color: var-get($theme, 'date-selected-special-focus-foreground'); + background: var-get($theme, 'date-selected-current-focus-background'); + border-color: var-get($theme, 'date-selected-current-focus-border-color'); - %date-disabled-current-special { - @if $variant == 'indigo' { - %date-inner { - color: var-get($theme, 'date-special-foreground'); - } + &::after { + border-color: var-get($theme, 'date-selected-special-focus-border-color'); } } - %date-disabled-current-special-selected { - @if $variant == 'indigo' { - %date-inner { - color: var-get($theme, 'date-selected-foreground'); - } + %indigo-date-special-current-not-selected { + color: var-get($theme, 'date-special-foreground'); + background: var-get($theme, 'date-current-background'); + border-color: var-get($theme, 'date-current-border-color'); + + &::after { + border-color: var-get($theme, 'date-special-border-color'); } - } - %date-disabled-current-selected { - %date-inner { - @if $variant == 'fluent' { - color: var-get($theme, 'date-current-foreground'); + &:hover { + color: var-get($theme, 'date-special-hover-foreground'); + background: var-get($theme, 'date-current-hover-background'); + border-color: var-get($theme, 'date-current-hover-border-color'); + + &::after { + border-color: var-get($theme, 'date-special-hover-border-color'); } } } - %date-hidden { - cursor: default; - visibility: hidden; - } - - %calendar__aria-off-screen { - position: absolute !important; - border: none !important; - height: 1px !important; - width: 1px !important; - inset-inline-start: 0 !important; - top: 0 !important; - overflow: hidden !important; - padding: 0 !important; - margin: 0 !important; - user-select: none; - pointer-events: none; + %indigo-date-special-current-focus-not-selected { + color: var-get($theme, 'date-special-focus-foreground'); + background: var-get($theme, 'date-current-focus-background'); + border-color: var-get($theme, 'date-current-focus-border-color'); - &:focus { - outline: none; + &::after { + border-color: var-get($theme, 'date-special-focus-border-color'); } } } @@ -2141,7 +2495,8 @@ %header-date { @include type-style($header-date) { margin: 0; - }; + } + ; } %views-navigation, diff --git a/projects/igniteui-angular/src/lib/core/styles/components/card/_card-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/card/_card-theme.scss index bcaa61c6e4c..8c05c45c68f 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/card/_card-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/card/_card-theme.scss @@ -31,10 +31,10 @@ } %igx-card--elevated { - box-shadow: var-get($theme, 'resting-shadow'); + box-shadow: var-get($theme, 'resting-elevation'); &:hover { - box-shadow: var-get($theme, 'hover-shadow'); + box-shadow: var-get($theme, 'hover-elevation'); } @if $not-material-theme { diff --git a/projects/igniteui-angular/src/lib/core/styles/components/carousel/_carousel-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/carousel/_carousel-theme.scss index e3b4e109b69..d91da52b494 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/carousel/_carousel-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/carousel/_carousel-theme.scss @@ -46,7 +46,7 @@ outline-style: none; transition: all .15s ease-in-out; background: var-get($theme, 'button-background'); - box-shadow: var-get($theme, 'button-shadow'); + box-shadow: var-get($theme, 'button-elevation'); border: rem(1px) solid var-get($theme, 'button-border-color'); border-radius: var(--nav-btn-border-radius); @@ -167,7 +167,7 @@ inset-inline-start: 50%; transform: translateX(-50%); background: var-get($theme, 'indicator-background'); - box-shadow: var-get($theme, 'button-shadow'); + box-shadow: var-get($theme, 'button-elevation'); border-radius: var-get($theme, 'border-radius'); [dir='rtl'] & { @@ -247,7 +247,6 @@ background: var-get($theme, 'indicator-dot-color'); @if $variant != 'indigo' { - @include animation('scale-out-center' .15s $ease-out-quad forwards); inset: rem(1px); } @else { width: rem(8px); @@ -261,11 +260,11 @@ &:hover { border-color: var-get($theme, 'indicator-active-border-color'); - @if $variant == 'indigo' { - &::after { - background: var-get($theme, 'indicator-hover-dot-color'); - } + &::after { + background: var-get($theme, 'indicator-hover-dot-color'); + } + @if $variant == 'indigo' { &::before { position: absolute; content: ''; @@ -290,7 +289,7 @@ content: ''; width: inherit; height: inherit; - border: rem(2px) solid var-get($theme, 'indicator-active-dot-color'); + border: rem(2px) solid var-get($theme, 'indicator-active-border-color'); inset-inline-start: 0; top: 0; border-radius: border-radius(50%); @@ -304,12 +303,14 @@ } } - @if $variant == 'indigo' { - &:hover { - &::after { - background: var-get($theme, 'indicator-active-hover-dot-color'); - } + &:hover { + border-color: var-get($theme, 'indicator-active-hover-dot-color'); + &::after { + background: var-get($theme, 'indicator-active-hover-dot-color'); + } + + @if $variant == 'indigo' { &::before { border-color: var-get($theme, 'indicator-active-hover-dot-color'); } diff --git a/projects/igniteui-angular/src/lib/core/styles/components/checkbox/_checkbox-component.scss b/projects/igniteui-angular/src/lib/core/styles/components/checkbox/_checkbox-component.scss index 5c0def5856a..512d2335b64 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/checkbox/_checkbox-component.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/checkbox/_checkbox-component.scss @@ -14,10 +14,22 @@ @extend %cbx-display !optional; &:hover { + @include e(label) { + @extend %cbx-label--hover !optional; + } + + @include e(label, $m: before) { + @extend %cbx-label--hover !optional; + } + @include e(ripple) { @extend %cbx-ripple--hover !optional; } + @include e(composite) { + @extend %cbx-composite--hover !optional; + } + @include e(composite-mark) { @extend %cbx-composite-mark--fluent !optional; } @@ -60,30 +72,10 @@ @extend %cbx-ripple !optional; } - @include m(bootstrap) { - @include e(composite) { - &:hover { - @extend %cbx-composite--hover !optional; - } - } - } - @include m(indigo) { - @include e(composite) { - &:hover { - @extend %cbx-composite--hover !optional; - } - } - @include e(composite-mark) { @extend %cbx-composite-mark-indigo !optional; } - - @include e(label) { - &:hover { - @extend %cbx-label--hover !optional; - } - } } @include m(invalid) { @@ -99,6 +91,10 @@ @extend %cbx-label--invalid !optional; } + @include e(label, $m: before) { + @extend %cbx-label--invalid !optional; + } + &:hover { @include e(ripple) { @extend %cbx-ripple--hover !optional; @@ -112,6 +108,14 @@ @include e(composite-mark) { @extend %cbx-composite-mark--invalid--fluent !optional; } + + @include e(label) { + @extend %cbx-label--invalid !optional; + } + + @include e(label, $m: before) { + @extend %cbx-label--invalid !optional; + } } &:active { @@ -183,6 +187,12 @@ @include e(ripple) { @extend %cbx-ripple--focused-invalid !optional; } + + &:hover { + @include e(ripple) { + @extend %cbx-ripple--hover-invalid !optional; + } + } } @include mx(indigo, focused, invalid) { @@ -239,11 +249,19 @@ @include e(composite) { @extend %cbx-composite--x--hover !optional; } + + @include e(composite-mark) { + @extend %cbx-composite-mark--in--fluent !optional; + } } } @include mx(material, disabled, indeterminate) { - @extend %igx-checkbox--disabled-indeterminate-material !optional; + @extend %igx-checkbox--disabled-indeterminate !optional; + } + + @include mx(bootstrap, disabled, indeterminate) { + @extend %igx-checkbox--disabled-indeterminate !optional; } @include mx(fluent, disabled, indeterminate) { @@ -251,7 +269,9 @@ } @include mx(indigo, disabled, indeterminate) { - @extend %igx-checkbox--disabled-indeterminate-indigo !optional; + @include e(composite) { + @extend %igx-checkbox--disabled-indeterminate-indigo !optional; + } } @include mx(indigo, focused, indeterminate) { @@ -324,6 +344,21 @@ @extend %cbx-ripple--focused !optional; @extend %cbx-ripple--focused-checked !optional; } + + &:hover { + @include e(ripple) { + @extend %cbx-ripple--focused !optional; + @extend %cbx-ripple--focused--hover-checked !optional; + } + } + } + + @include mx(focused, invalid, checked) { + &:hover { + @include e(ripple) { + @extend %cbx-ripple--hover-invalid !optional; + } + } } @include mx(focused, indeterminate) { diff --git a/projects/igniteui-angular/src/lib/core/styles/components/checkbox/_checkbox-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/checkbox/_checkbox-theme.scss index 7b4cdd24724..3bcf97b1d4d 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/checkbox/_checkbox-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/checkbox/_checkbox-theme.scss @@ -1,3 +1,4 @@ +/* stylelint-disable max-nesting-depth */ @use 'sass:map'; @use 'sass:math'; @use '../../base' as *; @@ -49,13 +50,6 @@ 'indigo': rem(8px), ), $variant); - $mark-stroke: map.get(( - 'material': 3, - 'fluent': 1, - 'bootstrap': 3, - 'indigo': 3, - ), $variant); - $mark-offset: map.get(( 'material': 0, 'fluent': -1px, @@ -64,7 +58,7 @@ ), $variant); $mark-length: 24; - $mark-x-factor: math.div($mark-stroke, $mark-length); + $mark-x-factor: calc(#{var-get($theme, 'tick-width')} / $mark-length); $ripple-size: rem(40px); $ripple-radius: math.div($ripple-size, 2); @@ -128,7 +122,9 @@ } %cbx-composite--hover { - border-color: var-get($theme, 'empty-color-hover'); + @if $variant == 'bootstrap' or $variant == 'indigo' { + border-color: var-get($theme, 'empty-color-hover'); + } } %cbx-composite--x { @@ -181,6 +177,7 @@ %cbx-composite--x--disabled { @if $variant == 'material' or $variant == 'fluent' { background: var-get($theme, 'disabled-color'); + border-color: var-get($theme, 'disabled-color'); } @if $variant == 'indigo' or $variant == 'bootstrap' { @@ -205,7 +202,7 @@ inset: 0; stroke: var-get($theme, 'tick-color'); stroke-linecap: square; - stroke-width: $mark-stroke; + stroke-width: var-get($theme, 'tick-width'); stroke-dasharray: $mark-length; stroke-dashoffset: $mark-length; fill: none; @@ -268,7 +265,7 @@ } %igx-checkbox--disabled-indeterminate-indigo { - @extend %igx-checkbox--indeterminate-indigo; + @extend %cbx-composite--x--disabled; %cbx-composite-mark { rect { @@ -297,6 +294,14 @@ z-index: 1; } } + + &:hover { + %cbx-composite { + &::before { + background: var-get($theme, 'fill-color-hover'); + } + } + } } %igx-checkbox--disabled-indeterminate-fluent { @@ -305,7 +310,7 @@ } %cbx-composite--x--disabled { - background: transparent; + border-color: var-get($theme, 'disabled-color'); &::before { background: var-get($theme, 'disabled-color'); @@ -313,7 +318,7 @@ } } - %igx-checkbox--disabled-indeterminate-material { + %igx-checkbox--disabled-indeterminate { %cbx-composite--x--disabled { border-color: var-get($theme, 'disabled-indeterminate-color'); background: var-get($theme, 'disabled-indeterminate-color'); @@ -366,7 +371,7 @@ %cbx-composite-mark--in { stroke-dashoffset: 41; /* length of path - adjacent line length */ opacity: 1; - transform: rotate(45deg) translateX(-#{$mark-x-factor}em); + transform: rotate(45deg) translateX(calc(#{$mark-x-factor} * -1em)); } %cbx-composite-mark--fluent { @@ -464,11 +469,11 @@ } %cbx-ripple--hover-checked { - background: var-get($theme, 'fill-color'); + background: var-get($theme, 'fill-color-hover'); } %cbx-ripple--hover-invalid { - background: var-get($theme, 'error-color'); + background: var-get($theme, 'error-color-hover'); } %igx-checkbox--focused-indigo { @@ -550,6 +555,10 @@ background: var-get($theme, 'fill-color'); } + %cbx-ripple--focused--hover-checked { + background: var-get($theme, 'fill-color-hover'); + } + %cbx-ripple--focused-invalid { background: var-get($theme, 'error-color'); } diff --git a/projects/igniteui-angular/src/lib/core/styles/components/chip/_chip-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/chip/_chip-theme.scss index e17b34ca090..003a8ad71e2 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/chip/_chip-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/chip/_chip-theme.scss @@ -540,7 +540,7 @@ @extend %igx-chip; position: absolute; - box-shadow: var-get($theme, 'ghost-shadow'); + box-shadow: var-get($theme, 'ghost-elevation'); overflow: hidden; color: var-get($theme, 'focus-text-color'); background: var-get($theme, 'ghost-background'); 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 1eb5c643103..7cab448d4b7 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 @@ -13,7 +13,7 @@ 'material': pad-inline(rem(4px), rem(8px), rem(16px)), 'fluent': pad-inline(rem(2px), rem(4px), rem(8px)), 'bootstrap': pad-inline(rem(4px), rem(8px), rem(16px)), - 'indigo': pad-inline(rem(12px)) + 'indigo': pad-inline(rem(8px), rem(12px), rem(12px)) ), $variant); $search-input-block-padding: map.get(( @@ -73,6 +73,16 @@ igx-input-group { --theme: #{if($variant == 'indigo', 'indigo', 'material')}; --ig-size: #{if($variant == 'indigo', 2, 1)}; + + @if $variant == 'bootstrap' or $variant == 'indigo' { + input { + height: rem(28px); + } + } @else if $variant == 'fluent' { + input { + height: rem(32px); + } + } } } @@ -151,6 +161,18 @@ %igx-combo__search { --igx-input-group-input-suffix-background: transparent; --igx-input-group-input-suffix-background--focused: transparent; + + .igx-input-group__bundle::after { + border-block-end-color: var(--border-color); + } + + .igx-input-group__bundle:hover::after { + border-block-end-color: #{if($variant == 'fluent', var(--hover-border-color), var(--border-color))};; + } + + .igx-input-group--focused .igx-input-group__bundle::after { + border-block-end-color: var(--focused-bottom-line-color); + } } } @@ -188,12 +210,24 @@ color: var-get($theme, 'clear-button-foreground-focus'); background: var-get($theme, 'clear-button-background-focus'); } + + &%form-group-bundle--border { + %igx-combo__toggle-button { + background: var-get($theme, 'toggle-button-background-focus--border'); + } + } } @if $variant == 'indigo' { %form-group-bundle:not(%form-group-bundle--disabled):hover { %igx-combo__toggle-button { color: var-get($theme, 'toggle-button-foreground-focus'); + background: var-get($theme, 'toggle-button-background-focus'); + } + + %igx-combo__clear-button { + color: var-get($theme, 'clear-button-foreground-focus'); + background: var-get($theme, 'clear-button-background-focus'); } } } @@ -202,6 +236,14 @@ %igx-combo__toggle-button { color: var-get($theme, 'toggle-button-foreground-filled'); } + + @if $variant == 'material' { + &.igx-input-group--focused { + %igx-combo__toggle-button { + color: var-get($theme, 'toggle-button-foreground-filled'); + } + } + } } .igx-input-group--focused { @@ -232,18 +274,10 @@ } } - //.igx-input-group:not(.igx-input-group--box) { - // %igx-combo__toggle-button:focus { - // @if $variant == 'material' { - // background: var-get($theme, 'toggle-button-background-focus--border'); - // } - // } - //} - .igx-input-group--disabled { %igx-combo__toggle-button { - background: var-get($theme, 'toggle-button-background-disabled') !important; - color: var-get($theme, 'toggle-button-foreground-disabled') !important; + background: var-get($theme, 'toggle-button-background-disabled'); + color: var-get($theme, 'toggle-button-foreground-disabled'); } %igx-combo__clear-button { @@ -252,12 +286,4 @@ } } } - - %form-group-bundle { - &:hover { - %igx-combo__clear-button { - color: inherit; - } - } - } } diff --git a/projects/igniteui-angular/src/lib/core/styles/components/date-range-picker/_date-range-picker-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/date-range-picker/_date-range-picker-theme.scss index 5cd16765d30..8ca25e08c0e 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/date-range-picker/_date-range-picker-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/date-range-picker/_date-range-picker-theme.scss @@ -6,8 +6,12 @@ /// @param {Map} $theme - The theme used to style the component. @mixin date-range-picker($theme) { @include css-vars($theme); + $variant: map.get($theme, '_meta', 'theme'); %igx-date-range-picker { + @include sizable(); + --input-group-size: #{map.get($theme, 'size')}; + --component-size: var(--ig-size, #{var-get($theme, 'default-size')}); display: flex; > igx-icon { @@ -23,10 +27,17 @@ } } + igx-date-range-start, + igx-date-range-end { + min-width: 0; + } + igx-date-range-start, igx-date-range-end, %igx-date-range-picker__start, %igx-date-range-picker__end { + --size: var(--input-group-size) !important; + flex: 1 0 0%; } @@ -35,6 +46,21 @@ align-items: center; color: var-get($theme, 'label-color'); margin: 0 rem(8px); + height: var(--input-group-size); + + @if $variant != 'material' { + align-self: flex-end; + + &.input-has-hint { + align-self: center; + + &:not(.input-has-label) { + align-self: flex-start; + } + } + } @else { + align-self: flex-start; + } } %igx-date-range-picker-buttons { @@ -57,18 +83,18 @@ } /// Adds typography styles for the igx-date-range-picker component. -/// Uses the 'subtitle-1' +/// Uses the 'caption' /// categories from the typographic scale. /// @group typography -/// @param {Map} $categories [(label: 'subtitle-1')] - The categories from the typographic scale used for type styles. +/// @param {Map} $categories [(label: 'caption')] - The categories from the typographic scale used for type styles. @mixin date-range-typography( $categories: ( - label: 'subtitle-1', + label: 'caption', ) ) { $label: map.get($categories, 'label'); - %igx-date-range__label { + %igx-date-range-picker__label { @include type-style($label); } } diff --git a/projects/igniteui-angular/src/lib/core/styles/components/dialog/_dialog-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/dialog/_dialog-theme.scss index c39996752e8..b87b260406e 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/dialog/_dialog-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/dialog/_dialog-theme.scss @@ -51,7 +51,7 @@ border: rem(1px) solid var-get($theme, 'border-color'); border-radius: var-get($theme, 'border-radius'); background: var-get($theme, 'background'); - box-shadow: var-get($theme, 'shadow'); + box-shadow: var-get($theme, 'elevation'); overflow: hidden; .igx-calendar { diff --git a/projects/igniteui-angular/src/lib/core/styles/components/drop-down/_drop-down-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/drop-down/_drop-down-theme.scss index 1a261ddabcc..abbe8002bb1 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/drop-down/_drop-down-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/drop-down/_drop-down-theme.scss @@ -20,7 +20,7 @@ overflow: hidden; border-radius: var-get($theme, 'border-radius'); background: var-get($theme, 'background-color'); - box-shadow: var-get($theme, 'shadow'); + box-shadow: var-get($theme, 'elevation'); min-width: rem(128px); border: var-get($theme, 'border-width') solid var-get($theme, 'border-color'); 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 d1f9b070cb3..83809224003 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 @@ -34,7 +34,7 @@ ), $variant); $cbx-bs-size: rem(14px); - $grid-shadow: var-get($theme, 'grid-shadow'); + $grid-shadow: var-get($theme, 'grid-elevation'); $grid-caption-fs: rem(20px); $grid-caption-lh: rem(32px); @@ -177,7 +177,7 @@ igx-date-picker, igx-time-picker { position: relative; - height: calc(100% - #{$editing-outline-width * 2}); + height: auto; width: 100% !important; overflow: hidden; } @@ -211,6 +211,10 @@ min-height: 100% !important; border: none !important; + .igx-input-group__filler { + border: none !important; + } + &::before { content: none !important; } @@ -292,6 +296,12 @@ box-shadow: none !important; border: none !important; } + + .igx-input-group--disabled, + .igx-input-group--disabled igx-prefix, + .igx-input-group--disabled igx-suffix { + color: var-get($theme, 'cell-disabled-color'); + } } @if $variant != 'indigo' { @@ -888,7 +898,7 @@ @include css-vars(( name: 'igx-grid-row', row-ghost-background: map.get($theme, 'row-ghost-background'), - row-drag-color: map.get($theme, 'row-drago-color') + row-drag-color: map.get($theme, 'row-drag-color') )); } } @@ -1000,7 +1010,11 @@ } %igx-icon--error { - color: color($color: 'gray', $variant: 500); + @if $variant == 'indigo' or $theme-variant == 'dark' { + color: color($color: 'gray', $variant: 500); + } @else { + color: color($color: 'gray', $variant: 600); + } } } @@ -1192,6 +1206,12 @@ align-items: center; outline-style: none; + @extend %cell-input-overrides; + + igx-input-group { + background: transparent; + } + @if $variant != 'indigo' { padding-inline: pad-inline( map.get($grid-cell-padding-inline, 'compact'), @@ -1407,8 +1427,6 @@ &%grid-cell-number { justify-content: flex-start !important; } - - @extend %cell-input-overrides; } %grid-cell--pinned { @@ -1908,7 +1926,7 @@ top: rem(-99999px); inset-inline-start: rem(-99999px); border: none; - box-shadow: var-get($theme, 'drag-shadow'); + box-shadow: var-get($theme, 'drag-elevation'); overflow: hidden; z-index: 20; diff --git a/projects/igniteui-angular/src/lib/core/styles/components/grid/_pivot-data-selector-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/grid/_pivot-data-selector-theme.scss index 25e17d6286b..52d94dffac7 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/grid/_pivot-data-selector-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/grid/_pivot-data-selector-theme.scss @@ -3,31 +3,6 @@ @use '../../base' as *; @use '../../themes/schemas' as *; -/// @deprecated Use the `css-vars` mixin instead. -/// @see {mixin} css-vars -/// @param {Map} $schema [$light-material-schema] - The schema used as basis for styling the component. -@function pivot-data-selector-theme( - $schema: $light-material-schema, - $background: null -) { - $name: 'igx-pivot-data-selector'; - $selector: '.igx-pivot-data-selector'; - $pivot-data-selector-schema: (); - - @if map.has-key($schema, 'pivot-data-selector') { - $pivot-data-selector-schema: map.get($schema, 'pivot-data-selector'); - } @else { - $pivot-data-selector-schema: $schema; - } - - $theme: digest-schema($pivot-data-selector-schema); - - @return extend($theme, ( - name: $name, - selector: $selector, - )); -} - /// @deprecated Use the `css-vars` mixin instead. /// @see {mixin} css-vars /// @param {Map} $theme - The theme used to style the component. diff --git a/projects/igniteui-angular/src/lib/core/styles/components/input/_file-input-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/input/_file-input-theme.scss index c722e7a819c..6c692aaf588 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/input/_file-input-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/input/_file-input-theme.scss @@ -4,88 +4,6 @@ @use '../../themes/schemas' as *; @use 'igniteui-theming/sass/animations/easings' as *; -//// -/// @group themes -/// @access public -//// - -/// @param {Map} $schema [$light-material-schema] - The schema used as basis for styling the component. -/// @param {Color} $file-names-background [null] - The file names container background color. -/// @param {Color} $file-names-background--focused [null] - The file names container background color when the file input is focused. -/// @param {Color} $file-names-background--filled [null] - The file names container background color when the file input is filled. -/// @param {Color} $file-names-background--disabled [null] - The file names container background color when the file input is disabled. -/// @param {Color} $file-names-foreground [null] - The file names color. -/// @param {Color} $file-names-foreground--focused [null] - The file names color when the file input is focused. -/// @param {Color} $file-names-foreground--filled [null] - The file names color when the file input is filled. -/// @param {Color} $file-names-foreground--disabled [null] - The file names color when the file input is disabled. -/// @param {Color} $file-selector-button-background [null] - The file input selector button background color. -/// @param {Color} $file-selector-button-background--focused [null] - The selector button background color when the file input is focused. -/// @param {Color} $file-selector-button-background--filled [null] - The selector button background color when the file input is filled. -/// @param {Color} $file-selector-button-background--disabled [null] - The selector button background color when the file input is disabled. -/// @param {Color} $file-selector-button-foreground [null] - The file input selector button foreground color. -/// @param {Color} $file-selector-button-foreground--focused [null] - The selector button foreground color when the file input is focused. -/// @param {Color} $file-selector-button-foreground--filled [null] - The selector button foreground color when the file input is filled. -/// @param {Color} $file-selector-button-foreground--disabled [null] - The selector button foreground color when the file input is disabled. -/// @example scss Change the focused border and label colors -/// $my-file-input-theme: file-input-theme($file-names-foreground: #09f); -/// // Pass the theme to the css-vars() mixin -/// @include css-vars($my-file-input-theme); -@function file-input-theme( - $schema: $light-material-schema, - $file-names-background: null, - $file-names-background--focused: null, - $file-names-background--filled: null, - $file-names-background--disabled: null, - $file-names-foreground: null, - $file-names-foreground--focused: null, - $file-names-foreground--filled: null, - $file-names-foreground--disabled: null, - - $file-selector-button-background: null, - $file-selector-button-background--focused: null, - $file-selector-button-background--filled: null, - $file-selector-button-background--disabled: null, - $file-selector-button-foreground: null, - $file-selector-button-foreground--focused: null, - $file-selector-button-foreground--filled: null, - $file-selector-button-foreground--disabled: null, -) { - $name: 'igx-file-input'; - $file-input-schema: (); - - @if map.has-key($schema, 'file-input') { - $file-input-schema: map.get($schema, 'file-input'); - } @else { - $file-input-schema: $schema; - } - - $theme: digest-schema($file-input-schema); - - @return extend( - $theme, - ( - name: $name, - file-names-background: $file-names-background, - file-names-background--focused: $file-names-background--focused, - file-names-background--filled: $file-names-background--filled, - file-names-background--disabled: $file-names-background--disabled, - file-names-foreground: $file-names-foreground, - file-names-foreground--focused: $file-names-foreground--focused, - file-names-foreground--filled: $file-names-foreground--filled, - file-names-foreground--disabled: $file-names-foreground--disabled, - - file-selector-button-background: $file-selector-button-background, - file-selector-button-background--focused: $file-selector-button-background--focused, - file-selector-button-background--filled: $file-selector-button-background--filled, - file-selector-button-background--disabled: $file-selector-button-background--disabled, - file-selector-button-foreground: $file-selector-button-foreground, - file-selector-button-foreground--focused: $file-selector-button-foreground--focused, - file-selector-button-foreground--filled: $file-selector-button-foreground--filled, - file-selector-button-foreground--disabled: $file-selector-button-foreground--disabled, - ), - ); -} - /// @deprecated Use the `css-vars` mixin instead. /// @see {mixin} css-vars /// @param {Map} $theme - The theme used to style the component. diff --git a/projects/igniteui-angular/src/lib/core/styles/components/input/_input-group-component.scss b/projects/igniteui-angular/src/lib/core/styles/components/input/_input-group-component.scss index bf077148fc2..31d41920020 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/input/_input-group-component.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/input/_input-group-component.scss @@ -754,12 +754,6 @@ @include mx(fluent, search, focused) { @extend %igx-input-group-fluent-search--focused !optional; } - - @include mx(fluent, search, disabled) { - @include e(bundle) { - @extend %form-group-bundle-search--disabled !optional; - } - } // FLUENT END // ============================== // 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 2f84ee71e3d..531dafb9b20 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 @@ -16,28 +16,13 @@ $indigo-theme: $variant == 'indigo'; $fluent-theme: $variant == 'fluent'; $bootstrap-theme: $variant == 'bootstrap'; - $NOT-material-theme: $variant != 'material'; - $NOT-indigo-theme: $variant != 'indigo'; - $NOT-fluent-theme: $variant != 'fluent'; - $NOT-bootstrap-theme: $variant != 'bootstrap'; $required-symbol: '*'; $required-symbol-margin: rem(2px); - // This creates an inverse relationship - // between the value of the base scale size and - // the sizing of all inner elements. - // i.e. the bigger the scale size, - // the smaller the padding of the inner items. - $base-scale-size: ( - 'comfortable': 16px, - 'cosy': 19px, - 'compact': 22px - ); - $bootstrap-inline-padding: ( - 'comfortable': rem(16px), - 'cosy': rem(12px), + 'comfortable': rem(14px), + 'cosy': rem(10px), 'compact': rem(8px) ); @@ -50,12 +35,6 @@ $input-top-padding: rem(20px); $input-bottom-padding: rem(6px); - $bundle-padding-top: ( - 'comfortable': rem(18px, map.get($base-scale-size, 'comfortable')), - 'cosy': rem(18px, map.get($base-scale-size, 'cosy')), - 'compact': rem(18px, map.get($base-scale-size, 'compact')), - ); - $hint-spacing-block: map.get(( 'material': rem(4px), 'fluent': rem(5px), @@ -64,7 +43,7 @@ ), $variant); $hint-spacing-inline: map.get(( - 'material': sizable(rem(14px), rem(16px), rem(18px)), + 'material': sizable(rem(12px), rem(14px), rem(16px)), 'fluent': 0, 'bootstrap': 0, 'indigo': 0, @@ -77,6 +56,23 @@ 'indigo': rem(15px), ), $variant); + $material-box-top-padding: sizable(rem(16px), rem(20px), rem(24px)); + $material-border-top-padding: sizable(rem(8px), rem(12px), rem(16px)); + + $textarea-top-padding: map.get(( + 'material': rem(0px), + 'fluent': sizable(rem(6px), rem(10px), rem(14px)), + 'bootstrap': sizable(rem(4px), rem(8px), rem(12px)), + 'indigo': sizable(rem(4px), rem(6px), rem(8px)), + ), $variant); + + $textarea-font: map.get(( + 'material': 'var(--ig-subtitle-1-line-height)', + 'fluent': 'var(--ig-body-2-line-height)', + 'bootstrap': 'var(--ig-body-1-line-height)', + 'indigo': 'var(--ig-body-2-line-height)', + ), $variant); + // Base Start %form-group-prefix--upload { padding: 0; @@ -100,13 +96,7 @@ display: inline-flex; width: max-content; align-items: center; - - @if $indigo-theme { - min-height: calc(100% - #{rem(1px)}) !important; - } @else { - min-height: 100% !important; - } - + min-height: 100% !important; transition: color $transition-timing, background $transition-timing; &:not(:empty) { @@ -115,7 +105,7 @@ } @else if $indigo-theme { padding-inline: pad-inline(rem(2px), rem(4px), rem(6px)); } @else { - padding-inline: pad-inline(rem(8px), rem(12px), rem(16px)); + padding-inline: pad-inline(rem(8px), rem(10px), rem(14px)); } } } @@ -149,7 +139,7 @@ @include sizable(); --component-size: var(--ig-size, #{var-get($theme, 'default-size')}); --input-size: var(--component-size); - --input-icon: #{sizable(rem(14px), rem(16px), rem(18px))}; + --input-icon: #{sizable(rem(14px), rem(16px), rem(16px))}; position: relative; display: block; @@ -159,12 +149,30 @@ [igxPrefix] { @extend %form-group-prefix; outline-style: none; + + &:first-child { + @if $variant == 'fluent' { + border-start-start-radius: calc(var-get($theme, 'border-border-radius') - var(--_fluent-input-border-size)); + border-end-start-radius: calc(var-get($theme, 'border-border-radius') - var(--_fluent-input-border-size)); + } @else if $variant == "indigo" { + border-start-start-radius: var-get($theme, 'box-border-radius'); + } + } } igx-suffix, [igxSuffix] { @extend %form-group-suffix; outline-style: none; + + &:last-child { + @if $variant == 'fluent' { + border-start-end-radius: calc(var-get($theme, 'border-border-radius') - var(--_fluent-input-border-size)); + border-end-end-radius: calc(var-get($theme, 'border-border-radius') - var(--_fluent-input-border-size)); + } @else if $variant == "indigo" { + border-start-end-radius: var-get($theme, 'box-border-radius'); + } + } } input, @@ -347,7 +355,7 @@ &%form-group-display--search { %form-group-bundle-search--hover:not(:focus-within) { - box-shadow: var-get($theme, 'search-resting-shadow'); + box-shadow: var-get($theme, 'search-resting-elevation'); } } @@ -368,7 +376,7 @@ %form-group-display--disabled { pointer-events: none; user-select: none; - color: var-get($theme, 'disabled-text-color') !important; + color: var-get($theme, 'disabled-text-color'); igx-prefix, [igxPrefix] { @@ -684,7 +692,7 @@ %bootstrap-file-warning, %bootstrap-file-invalid { %form-group-bundle { - border-radius: var-get($theme, 'box-border-radius'); + border-radius: var-get($theme, 'border-border-radius'); transition: box-shadow .15s ease-out, border .15s ease-out; &:hover { @@ -797,7 +805,7 @@ .igx-input-group--bootstrap:not(.igx-input-group--prefixed) { .igx-input-group__upload-button { - border-radius: var-get($theme, 'box-border-radius') 0 0 var-get($theme, 'box-border-radius'); + border-radius: var-get($theme, 'border-border-radius') 0 0 var-get($theme, 'border-border-radius'); } .igx-input-group__file-input { @@ -869,7 +877,7 @@ @if $variant == 'material' { overflow: hidden; - min-width: pad(rem(10px), rem(12px), rem(14px)); + min-width: pad(rem(8px), rem(10px), rem(12px)); } } @@ -1020,7 +1028,7 @@ %form-group-display--search { %igx-input-group__notch--search, %form-group-bundle-main--search { - @if $variant != 'indigo' { + @if $variant == 'material' { padding-inline: rem(4px); } } @@ -1028,10 +1036,10 @@ %form-group-bundle--search { background: var-get($theme, 'search-background'); - box-shadow: var-get($theme, 'search-resting-shadow'); + box-shadow: var-get($theme, 'search-resting-elevation'); + border-radius: var-get($theme, 'search-border-radius'); @if $variant != 'bootstrap' { - border-radius: var-get($theme, 'search-border-radius'); overflow: hidden; } @@ -1043,28 +1051,28 @@ } %form-group-bundle-search--hover { - box-shadow: var-get($theme, 'search-hover-shadow'); + box-shadow: var-get($theme, 'search-hover-elevation'); border-color: var-get($theme, 'hover-border-color'); } %form-group-bundle-search--focus { - box-shadow: var-get($theme, 'search-hover-shadow'); + box-shadow: var-get($theme, 'search-hover-elevation'); border-color: var-get($theme, 'hover-border-color'); } %form-group-bundle-search--error { - box-shadow: var-get($theme, 'search-hover-shadow'); - border-color: var-get($theme, 'search-hover-shadow'); + box-shadow: var-get($theme, 'search-hover-elevation'); + border-color: var-get($theme, 'search-hover-elevation'); } %form-group-bundle-search--success { - box-shadow: var-get($theme, 'search-hover-shadow'); - border-color: var-get($theme, 'search-hover-shadow'); + box-shadow: var-get($theme, 'search-hover-elevation'); + border-color: var-get($theme, 'search-hover-elevation'); } %form-group-bundle-search--disabled { background: var-get($theme, 'search-disabled-background'); - box-shadow: var-get($theme, 'search-disabled-shadow'); + box-shadow: var-get($theme, 'search-disabled-elevation'); border-color: var-get($theme, 'disabled-border-color'); igx-prefix, @@ -1083,11 +1091,9 @@ } %form-group-label { - padding-inline-end: rem(4px); backface-visibility: hidden; will-change: transform; transform-origin: top left; - margin-inline-start: pad-inline(0, rem(-2px), rem(-4px)); } %form-group-label--border { @@ -1204,6 +1210,18 @@ } @if $variant == 'material' { + %textarea-group:not(%textarea-group--outlined) { + --textarea-box-padding: #{pad-block(rem(8px), rem(12px), rem(16px))}; + + &:has(%igx-input-group__notch:not(:empty)) { + --textarea-box-padding: #{pad-block(rem(16px), rem(20px), rem(24px))}; + } + + %form-group-textarea { + margin-block-end: rem(2px); + } + } + %textarea-group:not(%suffixed) { %form-group-bundle-main { grid-area: 1 / 2 / span 1 / span 3; @@ -1211,7 +1229,7 @@ } textarea { - padding-inline-end: rem(4px); + padding-inline-end: #{pad-inline(rem(12px), rem(14px), rem(16px))}; width: calc(100% - #{rem(1px)}); } } @@ -1227,7 +1245,7 @@ %form-group-textarea-label:not(%textarea-group-label--focused) { @include type-style('subtitle-1'); - top: calc($input-top-padding - #{rem(3px)}); + top: calc(#{$material-box-top-padding} - #{rem(3px)}); transform: translateY(0); margin-bottom: auto; } @@ -1244,13 +1262,6 @@ } %form-group-textarea-group-bundle { - // 3 lines * 22px + 8px bottom padding + 8px top padding - --textarea-size: #{sizable( - rem(82px, map.get($base-scale-size, 'compact')), - rem(82px, map.get($base-scale-size, 'cosy')), - rem(82px, map.get($base-scale-size, 'comfortable')) - )}; - min-height: var(--textarea-size) !important; height: auto !important; %form-group-label { @@ -1260,25 +1271,25 @@ @if $material-theme { %form-group-textarea-label { - top: calc($input-top-padding - #{rem(1px)}); + top: calc(#{$material-box-top-padding} - #{rem(1px)}); margin-block-end: auto; } %textarea-group--outlined { %form-group-textarea-label { - top: calc($input-top-padding - #{rem(3px)}); + top: calc(#{$material-border-top-padding} - #{rem(3px)}); } } %textarea-group--box { %form-group-textarea-label { - top: calc($input-top-padding - #{rem(2px)}); + top: calc(#{$material-box-top-padding} - #{rem(2px)}); } } %textarea-group-label--focused { transform: translateY(0); - top: calc(#{$input-top-padding} / 4); + top: calc(#{$material-box-top-padding} / 4); } %textarea-group-label--filled--border, @@ -1336,7 +1347,7 @@ overflow: hidden; text-overflow: ellipsis; - &:not([type='date']) { + &:not(%form-group-textarea, [type='date']) { line-height: 0 !important; /* Reset typography */ } @@ -1434,7 +1445,7 @@ %form-group-input--disabled { cursor: default; - color: var-get($theme, 'disabled-text-color') !important; + color: var-get($theme, 'disabled-text-color'); &::placeholder { color: var-get($theme, 'disabled-placeholder-color'); @@ -1450,29 +1461,13 @@ } %form-group-textarea { - --textarea-size: #{sizable( - rem(82px, map.get($base-scale-size, 'compact')), - rem(82px, map.get($base-scale-size, 'cosy')), - rem(82px, map.get($base-scale-size, 'comfortable')) - )}; - - min-height: var(--textarea-size); height: auto; resize: vertical; overflow: hidden; + z-index: 1; @if $material-theme { padding: 0; - inset-block-start: rem(-3px); - } - - // resets typography styles - line-height: normal !important; - - z-index: 1; - - &:not([type='*']) { - line-height: normal !important; /* resets typography styles */ } } @@ -1485,12 +1480,22 @@ } @if $material-theme { - padding-block-start: $input-top-padding; + padding-block-start: var(--textarea-box-padding); + } + } + + %textarea-group--outlined { + %form-group-textarea-group-bundle-main { + padding-block-start: #{$material-border-top-padding}; + } + + %form-group-textarea { + inset-block-start: rem(-2px); } } %form-group-textarea--disabled { - color: var-get($theme, 'disabled-text-color') !important; + color: var-get($theme, 'disabled-text-color'); cursor: default; &::placeholder { @@ -1507,7 +1512,7 @@ align-self: end; transform: scaleX(0); transform-origin: center; - background: var-get($theme, 'focused-secondary-color'); + background: var-get($theme, 'focused-bottom-line-color'); z-index: 1; } } @@ -1637,13 +1642,13 @@ %form-group-helper { --ig-caption-margin-top: #{$hint-spacing-block}; --ig-caption-margin-bottom: 0; + --ig-body-2-margin-top: #{$hint-spacing-block}; color: var-get($theme, 'helper-text-color'); position: relative; display: grid; grid-auto-rows: minmax($hint-min-size, auto); padding-inline: pad-inline($hint-spacing-inline); - justify-content: space-between; > * { @@ -1670,7 +1675,9 @@ } %form-group-helper-item { - display: flex; + @include line-clamp(2, true, true); + + overflow-wrap: anywhere; align-items: center; position: relative; } @@ -1710,7 +1717,9 @@ [igxPrefix], igx-suffix, [igxSuffix] { - padding-inline: pad-inline(rem(6px), rem(8px), rem(10px)); + &:not(:empty) { + padding-inline: pad-inline(rem(6px), rem(8px), rem(10px)); + } } } @@ -2014,13 +2023,14 @@ } %indigo-textarea { - padding-block: rem(6px); + padding-block: $textarea-top-padding 0; padding-inline: pad-inline(rem(2px), rem(4px), rem(6px)); inset-block-end: rem(2px); } %fluent-textarea { - padding: rem(8px); + padding-inline: pad-inline(rem(8px)); + padding-block: $textarea-top-padding 0; } %fluent-input-disabled { @@ -2038,8 +2048,6 @@ color: var-get($theme, 'idle-secondary-color'); @if $variant == 'fluent' { - --ig-subtitle-2-line-height: rem(16px); - margin-block-end: rem(5px); } @else { margin-block-end: rem(4px); @@ -2057,15 +2065,15 @@ } %fluent-label-success { - color: var-get($theme, 'idle-text-color'); + color: var-get($theme, 'idle-secondary-color'); } %fluent-label-error { - color: var-get($theme, 'idle-text-color'); + color: var-get($theme, 'idle-secondary-color'); } %fluent-label-disabled { - color: var-get($theme, 'disabled-text-color') !important; + color: var-get($theme, 'disabled-text-color'); } %fluent-label-filled { @@ -2091,29 +2099,12 @@ } } - .igx-input-group--fluent [igxPrefix], - .igx-input-group--fluent igx-prefix { - &:first-child { - [igxButton]::after, - button::after { - border: { - start: { - start-radius: var-get($theme, 'border-border-radius'); - }; - end: { - start-radius: var-get($theme, 'border-border-radius'); - }; - } - } - } - } - %form-group-prefix-fluent, %form-group-suffix-fluent, %form-group-prefix-fluent-search, %form-group-suffix-fluent-search { &:not(:empty) { - padding-inline: pad-inline(rem(8px), rem(12px), rem(16px)); + padding-inline: pad-inline(rem(8px), rem(10px), rem(14px)); } } @@ -2237,10 +2228,10 @@ end-width: rem(1px); }; start: { - start-radius: var-get($theme, 'box-border-radius'); + start-radius: var-get($theme, 'border-border-radius'); }; end: { - start-radius: var-get($theme, 'box-border-radius'); + start-radius: var-get($theme, 'border-border-radius'); }; } } @@ -2257,10 +2248,10 @@ end-width: rem(1px); }; start: { - end-radius: var-get($theme, 'box-border-radius'); + end-radius: var-get($theme, 'border-border-radius'); }; end: { - end-radius: var-get($theme, 'box-border-radius'); + end-radius: var-get($theme, 'border-border-radius'); }; } } @@ -2299,6 +2290,10 @@ map.get($bootstrap-inline-padding, 'cosy'), map.get($bootstrap-inline-padding, 'comfortable') ); + + &:is(textarea) { + padding-block: $textarea-top-padding 0; + } } // The :not selector is needed otherwise bootstrap will override the %autofill-background-fix @@ -2323,7 +2318,7 @@ map.get($bootstrap-inline-padding, 'cosy'), map.get($bootstrap-inline-padding, 'comfortable') ); - border-radius: var-get($theme, 'box-border-radius'); + border-radius: var-get($theme, 'border-border-radius'); } diff --git a/projects/igniteui-angular/src/lib/core/styles/components/list/_list-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/list/_list-theme.scss index c439845c70d..d5ba5820585 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/list/_list-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/list/_list-theme.scss @@ -69,7 +69,7 @@ border-radius: var-get($theme, 'border-radius'); @if $variant == 'bootstrap' { - border: rem(1px) solid var-get($theme, 'border-color'); + border: var-get($theme, 'border-width') solid var-get($theme, 'border-color'); } &:focus-visible { @@ -137,43 +137,48 @@ justify-content: center; border-radius: var-get($theme, 'item-border-radius'); color: var-get($theme, 'item-text-color'); + border-bottom: var-get($theme, 'border-width') solid var-get($theme, 'border-color'); - @if $bootstrap-theme or $variant == 'fluent' { - border-bottom: var-get($theme, 'border-width') solid var-get($theme, 'border-color'); - - &:last-of-type { - border-bottom: none; - } + &:last-of-type { + border-bottom: none; } + } - &:hover { + %igx-list-item-base:not(%igx-list-item-base--selected) { + &:hover, + &:focus-within { %igx-list__item-lines { color: currentColor; } - %igx-list__item-line-title { - color: var-get($theme, 'item-title-color-hover'); - } + %igx-list-item-content:not(%igx-list-item-content--active) { + color: var-get($theme, 'item-text-color-hover'); + background: var-get($theme, 'item-background-hover'); - %igx-list__item-line-subtitle { - color: var-get($theme, 'item-subtitle-color-hover'); - } + %igx-list__item-line-title { + color: var-get($theme, 'item-title-color-hover'); + } - %igx-list__item-actions { - color: var-get($theme, 'item-action-color-hover'); + %igx-list__item-line-subtitle { + color: var-get($theme, 'item-subtitle-color-hover'); + } - igx-icon, - igc-icon { - color: var-get($theme, 'item-action-color-hover') + %igx-list__item-actions { + color: var-get($theme, 'item-action-color-hover'); + + igx-icon, + igc-icon { + color: var-get($theme, 'item-action-color-hover') + } } - } - %igx-list__item-thumbnail { - color: var-get($theme, 'item-thumbnail-color-hover'); + %igx-list__item-thumbnail { + color: var-get($theme, 'item-thumbnail-color-hover'); - igx-icon, - igc-icon { - color: var-get($theme, 'item-thumbnail-color-hover') + igx-icon, + igc-icon { + color: var-get($theme, 'item-thumbnail-color-hover') + } } } } @@ -229,7 +234,8 @@ display: none; } - > * { + > *, + [class^='igx'] { --component-size: #{if($variant == 'indigo', 2, var(--list-size))}; } @@ -255,12 +261,6 @@ background: var-get($theme, 'item-background'); z-index: 2; gap: if($variant == 'indigo', rem(8px), rem(16px)); - - &:hover, - &:focus-within { - color: var-get($theme, 'item-text-color-hover'); - background: var-get($theme, 'item-background-hover'); - } } %igx-list-header, @@ -284,6 +284,11 @@ --component-size: #{if($variant == 'indigo', 2, var(--list-size))}; } + igx-icon, + igc-icon { + color: var-get($theme, 'item-thumbnail-color'); + } + &:empty { display: none; } diff --git a/projects/igniteui-angular/src/lib/core/styles/components/navbar/_navbar-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/navbar/_navbar-theme.scss index 90bb814dd6d..83ae92dc981 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/navbar/_navbar-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/navbar/_navbar-theme.scss @@ -25,7 +25,7 @@ padding-inline: pad-inline($navbar-padding); background: var-get($theme, 'background'); color: var-get($theme, 'text-color'); - box-shadow: var-get($theme, 'shadow'); + box-shadow: var-get($theme, 'elevation'); z-index: 4; overflow: hidden; border-bottom: rem(1px) solid var-get($theme, 'border-color'); @@ -38,6 +38,11 @@ igx-input-group { --ig-size: 1; } + + .igx-icon-button, + igc-icon-button { + --ig-size: 2; + } } @if $variant == 'bootstrap' { @@ -114,30 +119,27 @@ igx-icon, igc-icon { - --component-size: 3; + --component-size: #{if($variant == 'indigo', 2, 3)}; cursor: pointer; user-select: none; transition: color .15s $out-quad; + + @if $variant == 'indigo' { + width: auto; + height: auto; + padding: rem(6px); + } } - >igx-icon, - >igc-icon { + > igx-icon, + > igc-icon { color: var-get($theme, 'idle-icon-color'); &:hover { color: var-get($theme, 'hover-icon-color'); } } - - @if $variant == 'indigo' { - igx-icon, - igc-icon { - --component-size: 2; - - margin-inline: rem(6px); - } - } } igx-navbar-action, diff --git a/projects/igniteui-angular/src/lib/core/styles/components/navdrawer/_navdrawer-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/navdrawer/_navdrawer-theme.scss index b3f91966295..fa032dc01b5 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/navdrawer/_navdrawer-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/navdrawer/_navdrawer-theme.scss @@ -78,7 +78,7 @@ z-index: 999; transition: width, padding, transform; transition-timing-function: $in-out-quad; - box-shadow: var-get($theme, 'shadow'); + box-shadow: var-get($theme, 'elevation'); padding: $aside-padding; @if $variant != 'fluent' { diff --git a/projects/igniteui-angular/src/lib/core/styles/components/radio/_radio-component.scss b/projects/igniteui-angular/src/lib/core/styles/components/radio/_radio-component.scss index a7bb5e6e2be..0f3f7aa848d 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/radio/_radio-component.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/radio/_radio-component.scss @@ -133,6 +133,10 @@ } &:hover { + @include e(label) { + @extend %radio-label--invalid--hover !optional; + } + @include e(ripple) { @extend %radio-ripple--hover !optional; @extend %radio-ripple--hover-invalid !optional; diff --git a/projects/igniteui-angular/src/lib/core/styles/components/radio/_radio-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/radio/_radio-theme.scss index 0edfd40c513..87dc986ffd8 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/radio/_radio-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/radio/_radio-theme.scss @@ -98,22 +98,6 @@ user-select: none; } - %radio-label--invalid { - color: var-get($theme, 'error-color'); - - @if $variant == 'indigo' { - color: var-get($theme, 'label-color'); - - &:hover { - color: var-get($theme, 'label-color-hover'); - } - } - } - - %radio-label--disabled { - color: var-get($theme, 'disabled-label-color'); - } - %radio-composite { position: relative; display: inline-block; @@ -247,11 +231,6 @@ &::after { border: $border-width $border-style var-get($theme, 'error-color'); - - @if $bootstrap-theme { - background: transparent; - border: $border-width $border-style var-get($theme, 'error-color'); - } } } @@ -268,11 +247,10 @@ @if $bootstrap-theme { &::after { background: var-get($theme, 'error-color'); - border-color: var-get($theme, 'error-color'); } &::before { - background: white; + background: var-get($theme, 'fill-hover-border-color'); } } } @@ -317,11 +295,29 @@ } %radio-label--hover { + color: var-get($theme, 'label-color-hover'); + } + + %radio-label--invalid { + color: var-get($theme, 'error-color'); + + @if $variant == 'indigo' { + color: var-get($theme, 'label-color'); + } + } + + %radio-label--invalid--hover { + color: var-get($theme, 'error-color'); + @if $variant == 'indigo' { color: var-get($theme, 'label-color-hover'); } } + %radio-label--disabled { + color: var-get($theme, 'disabled-label-color'); + } + %radio-label--after { margin-inline-start: $label-margin; } 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 e6c2fa6b836..55092b0b220 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 @@ -55,7 +55,7 @@ .igx-input-group.igx-input-group--disabled.igx-input-group--filled, .igx-input-group.igx-input-group--disabled { - %igx-select__toggle-button { + %form-group-bundle %igx-select__toggle-button { background: var-get($theme, 'toggle-button-background-disabled'); color: var-get($theme, 'toggle-button-foreground-disabled'); } diff --git a/projects/igniteui-angular/src/lib/core/styles/components/snackbar/_snackbar-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/snackbar/_snackbar-theme.scss index 5a27bbedd05..8e6234d7330 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/snackbar/_snackbar-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/snackbar/_snackbar-theme.scss @@ -27,7 +27,7 @@ color: var-get($theme, 'text-color'); background: var-get($theme, 'background'); backface-visibility: hidden; - box-shadow: var-get($theme, 'shadow'); + box-shadow: var-get($theme, 'elevation'); border-radius: var-get($theme, 'border-radius'); backdrop-filter: blur(8px); diff --git a/projects/igniteui-angular/src/lib/core/styles/components/stepper/_stepper-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/stepper/_stepper-theme.scss index 329de693b52..558489d3864 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/stepper/_stepper-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/stepper/_stepper-theme.scss @@ -35,7 +35,7 @@ 'material': clamp(1px, rem(1px), rem(1px)), 'fluent': clamp(1px, rem(1px), rem(1px)), 'bootstrap': clamp(1px, rem(1px), rem(1px)), - 'indigo': clamp(2px, rem(2px), rem(2px)) + 'indigo': clamp(1px, rem(1px), rem(1px)) ), $variant); $separator-size: map.get(( @@ -99,18 +99,18 @@ &:focus { outline: none; - %igx-stepper__step-title { - color: var-get($theme, 'title-focus-color'); - } - - %igx-stepper__step-subtitle { - color: var-get($theme, 'subtitle-focus-color'); - } - %igx-stepper__step-header { background: var-get($theme, 'step-focus-background'); color: var-get($theme, 'title-focus-color'); + %igx-stepper__step-title { + color: var-get($theme, 'title-focus-color'); + } + + %igx-stepper__step-subtitle { + color: var-get($theme, 'subtitle-focus-color'); + } + @if $variant == 'bootstrap' { box-shadow: inset 0 0 0 $outline-width var-get($theme, 'indicator-outline'); } @@ -239,13 +239,13 @@ } %igx-stepper__step-header--current { - background: var-get($theme, 'current-step-background') !important; + background: var-get($theme, 'current-step-background'); color: var-get($theme, 'current-title-color'); %igx-stepper__step-indicator { - color: var-get($theme, 'current-indicator-color') !important; - background: var-get($theme, 'current-indicator-background') !important; - box-shadow: 0 0 0 $outline-width var-get($theme, 'current-indicator-outline') !important; + color: var-get($theme, 'current-indicator-color'); + background: var-get($theme, 'current-indicator-background'); + box-shadow: 0 0 0 $outline-width var-get($theme, 'current-indicator-outline'); } %igx-stepper__step-title { @@ -263,7 +263,7 @@ } &:hover { - background: var-get($theme, 'current-step-hover-background') !important; + background: var-get($theme, 'current-step-hover-background'); %igx-stepper__step-title { color: var-get($theme, 'current-title-hover-color'); @@ -418,12 +418,26 @@ } %igx-stepper__step--completed { - - %igx-stepper__step-header { + %igx-stepper__step-header:not(%igx-stepper__step-header--current) { background: var-get($theme, 'complete-step-background'); + %igx-stepper__step-indicator { + color: var-get($theme, 'complete-indicator-color'); + background: var-get($theme, 'complete-indicator-background'); + box-shadow: 0 0 0 $outline-width var-get($theme, 'complete-indicator-outline'); + } + + %igx-stepper__step-title { + color: var-get($theme, 'complete-title-color'); + } + + %igx-stepper__step-subtitle { + color: var-get($theme, 'complete-subtitle-color'); + } + &:hover { background: var-get($theme, 'complete-step-hover-background'); + %igx-stepper__step-title { color: var-get($theme, 'complete-title-hover-color'); } @@ -432,29 +446,15 @@ color: var-get($theme, 'complete-subtitle-hover-color'); } } - - &::after { - border-top-color: var-get($theme, 'complete-step-separator-color') !important; - border-top-style: var-get($theme, 'complete-step-separator-style') !important; - } } - %igx-stepper__step-indicator { - color: var-get($theme, 'complete-indicator-color'); - background: var-get($theme, 'complete-indicator-background'); - box-shadow: 0 0 0 $outline-width var-get($theme, 'complete-indicator-outline'); - } - - %igx-stepper__step-title { - color: var-get($theme, 'complete-title-color'); - } - - %igx-stepper__step-subtitle { - color: var-get($theme, 'complete-subtitle-color'); + %igx-stepper__step-header::after { + border-top-color: var-get($theme, 'complete-step-separator-color') !important; + border-top-style: var-get($theme, 'complete-step-separator-style') !important; } &:focus { - %igx-stepper__step-header { + %igx-stepper__step-header:not(%igx-stepper__step-header--current) { background: var-get($theme, 'complete-step-focus-background'); %igx-stepper__step-title { diff --git a/projects/igniteui-angular/src/lib/core/styles/components/switch/_switch-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/switch/_switch-theme.scss index 5f2285a090c..1fc56ec75e7 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/switch/_switch-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/switch/_switch-theme.scss @@ -56,21 +56,21 @@ ), $variant); $thumb-resting-shadow: map.get(( - 'material': var-get($theme, 'resting-shadow'), + 'material': var-get($theme, 'resting-elevation'), 'fluent': none, 'bootstrap': none, 'indigo': none ), $variant); $thumb-hover-shadow: map.get(( - 'material': var-get($theme, 'hover-shadow'), + 'material': var-get($theme, 'hover-elevation'), 'fluent': none, 'bootstrap': none, 'indigo': none ), $variant); $thumb-disabled-shadow: map.get(( - 'material': var-get($theme, 'disabled-shadow'), + 'material': var-get($theme, 'disabled-elevation'), 'fluent': none, 'bootstrap': none, 'indigo': none @@ -130,14 +130,12 @@ %switch-composite--hover { border-color: var-get($theme, 'border-hover-color'); - @if $variant != 'material' { - %switch-thumb { - background: var-get($theme, 'thumb-off-hover-color'); - } + %switch-thumb { + background: var-get($theme, 'thumb-off-hover-color'); + } - %switch-thumb--x { - background: var-get($theme, 'thumb-on-color'); - } + %switch-thumb--x { + background: var-get($theme, 'thumb-on-color'); } } @@ -153,10 +151,8 @@ background: var-get($theme, 'track-on-hover-color'); border-color: var-get($theme, 'border-on-hover-color'); - @if $variant != 'material' { - %switch-thumb { - background: var-get($theme, 'thumb-on-color'); - } + %switch-thumb { + background: var-get($theme, 'thumb-on-color'); } } @@ -259,7 +255,6 @@ @if $variant == 'indigo' { %switch-composite { - border-radius: var-get($theme, 'border-radius-thumb'); box-shadow: 0 0 0 rem(3px) var-get($theme, 'focus-outline-color'); } } diff --git a/projects/igniteui-angular/src/lib/core/styles/components/tabs/_tabs-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/tabs/_tabs-theme.scss index cf817cbca0c..2ecc36cf428 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/tabs/_tabs-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/tabs/_tabs-theme.scss @@ -61,23 +61,26 @@ %tabs-header { overflow: hidden; flex: 0 0 auto; - background: var-get($theme, 'item-background'); z-index: 1; - @if $bootstrap-theme { - position: relative; - - &::after { - content: ''; - position: absolute; - bottom: 0; - inset-inline-start: 0; - width: 100%; - height: rem(1px); - background: var-get($theme, 'border-color'); - z-index: 0; - } - } + @if $variant == 'material' or $variant == 'bootstrap' { + background: var-get($theme, 'item-background'); + } + + @if $bootstrap-theme { + position: relative; + + &::after { + content: ''; + position: absolute; + bottom: 0; + inset-inline-start: 0; + width: 100%; + height: rem(1px); + background: var-get($theme, 'border-color'); + z-index: 0; + } + } } %tabs-header-content { @@ -204,6 +207,7 @@ @if $not-bootstrap-theme { transition: all .3s $tabs-animation-function; border: rem(1px) solid var-get($theme, 'border-color'); + border-radius: var-get($theme, 'border-radius'); &:hover, &:focus { @@ -240,7 +244,7 @@ } &:focus { - background: var-get($theme, 'item-active-background'); + background: var-get($theme, 'item-hover-background'); color: var-get($theme, 'item-hover-color'); border-bottom-color: transparent; } @@ -268,14 +272,15 @@ %tabs-header-item--selected { outline: 0; color: var-get($theme, 'item-active-color'); + background: var-get($theme, 'item-active-background'); &:hover, &:focus { - background: var-get($theme, 'item-active-background'); - color: if($variant == 'fluent', var-get($theme, 'item-hover-color'), var-get($theme, 'item-active-color')); + background: var-get($theme, 'item-active-hover-background'); + color: var-get($theme, 'item-active-hover-color'); igx-icon { - color: var-get($theme, 'item-active-icon-color'); + color: var-get($theme, 'item-active-hover-icon-color'); } } @@ -284,7 +289,6 @@ } @if $bootstrap-theme { - background: var-get($theme, 'item-active-background'); position: relative; box-shadow: inset 0 0 0 rem(1px) var-get($theme, 'border-color'); z-index: 1; @@ -310,6 +314,16 @@ &:hover { box-shadow: inset 0 0 0 rem(1px) var-get($theme, 'border-color'); + + &::before { + background: linear-gradient( + to right, + var-get($theme, 'border-color') 1px, + var-get($theme, 'item-active-hover-background') 1px, + var-get($theme, 'item-active-hover-background') calc(100% - 1px), + var-get($theme, 'border-color') calc(100% - 1px) + ); + } } } @@ -318,13 +332,6 @@ font-weight: 600; } } - - @if $indigo-theme { - &:hover, - &:focus-within { - background: var-get($theme, 'item-hover-background'); - } - } } %tabs-header-item:focus, @@ -332,7 +339,8 @@ @if $bootstrap-theme { border: none; box-shadow: inset 0 0 0 rem(2px) var-get($theme, 'item-hover-color'); - border-radius: rem(4px); + border-bottom-left-radius: rem(4px); + border-bottom-right-radius: rem(4px); z-index: 1; &::after { diff --git a/projects/igniteui-angular/src/lib/core/styles/components/time-picker/_time-picker-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/time-picker/_time-picker-theme.scss index 91ae0f34ed2..42e22233e56 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/time-picker/_time-picker-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/time-picker/_time-picker-theme.scss @@ -8,7 +8,6 @@ @include css-vars($theme, '.igx-time-picker'); $variant: map.get($theme, '_meta', 'theme'); - $not-bootstrap-theme: $variant != 'bootstrap'; $picker-buttons-padding: map.get(( 'material': rem(8px), @@ -33,7 +32,7 @@ flex-flow: column nowrap; border-radius: var-get($theme, 'border-radius'); box-shadow: 0 0 0 rem(1px) var-get($theme, 'border-color'), - var-get($theme, 'modal-shadow'); + var-get($theme, 'modal-elevation'); background: var-get($theme, 'background-color'); overflow: hidden; min-width: fit-content; @@ -59,7 +58,7 @@ %time-picker--dropdown { min-width: sizable(rem(290px), rem(314px), rem(360px)); box-shadow: inset 0 0 0 rem(1px) var-get($theme, 'border-color'), - var-get($theme, 'dropdown-shadow'); + var-get($theme, 'dropdown-elevation'); %time-picker__body { min-width: auto; diff --git a/projects/igniteui-angular/src/lib/core/styles/components/toast/_toast-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/toast/_toast-theme.scss index 355afdff3fc..411989a9bba 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/toast/_toast-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/toast/_toast-theme.scss @@ -29,7 +29,7 @@ color: var-get($theme, 'text-color'); background: var-get($theme, 'background'); border-radius: var-get($theme, 'border-radius'); - box-shadow: map.get($theme, 'shadow'); + box-shadow: var-get($theme, 'elevation'); backdrop-filter: blur(10px); &::after { 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 7d8d0705dad..2e1f4a21a61 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 @@ -18,12 +18,12 @@ background: var-get($theme, 'background'); color: var-get($theme, 'text-color'); border-radius: var-get($theme, 'border-radius'); - box-shadow: map.get($theme, 'shadow'); + box-shadow: var-get($theme, 'elevation'); padding: pad-block(rem(4px)) pad-inline(rem(8px)); gap: rem(8px); min-height: rem(24px); min-width: rem(24px); - max-width: 200px; + max-width: rem(200px); width: fit-content; igx-icon { @@ -34,6 +34,10 @@ display: flex; cursor: default; } + + &:not([data-default]) { + max-width: initial; + } } %arrow--top { diff --git a/projects/igniteui-angular/src/lib/core/styles/typography/_bootstrap.scss b/projects/igniteui-angular/src/lib/core/styles/typography/_bootstrap.scss index 2bb1b767151..fe8225fdea5 100644 --- a/projects/igniteui-angular/src/lib/core/styles/typography/_bootstrap.scss +++ b/projects/igniteui-angular/src/lib/core/styles/typography/_bootstrap.scss @@ -36,7 +36,7 @@ @use '../components/input/file-input-theme' as *; @mixin typography($type-scale) { - @include badge-typography(); + @include badge-typography($theme: 'bootstrap'); @include banner-typography(); @include bottom-nav-typography(); @include button-typography(); @@ -59,7 +59,9 @@ )); @include chip-typography(); @include column-actions-typography(); - @include date-range-typography(); + @include date-range-typography($categories: ( + label: 'body-1', + )); @include dialog-typography($categories: ( title: 'h5', content: 'body-1' diff --git a/projects/igniteui-angular/src/lib/core/styles/typography/_fluent.scss b/projects/igniteui-angular/src/lib/core/styles/typography/_fluent.scss index 2b8a6323070..3f9ab803925 100644 --- a/projects/igniteui-angular/src/lib/core/styles/typography/_fluent.scss +++ b/projects/igniteui-angular/src/lib/core/styles/typography/_fluent.scss @@ -35,7 +35,7 @@ @use '../components/input/file-input-theme' as *; @mixin typography() { - @include badge-typography(); + @include badge-typography($theme: 'fluent'); @include banner-typography($categories: ( message: 'caption' )); diff --git a/projects/igniteui-angular/src/lib/core/styles/typography/_indigo.scss b/projects/igniteui-angular/src/lib/core/styles/typography/_indigo.scss index 737346995c4..5f1228dfb6e 100644 --- a/projects/igniteui-angular/src/lib/core/styles/typography/_indigo.scss +++ b/projects/igniteui-angular/src/lib/core/styles/typography/_indigo.scss @@ -35,9 +35,7 @@ @use '../components/input/file-input-theme' as *; @mixin typography($type-scale) { - @include badge-typography($categories: ( - text: 'button', - )); + @include badge-typography($theme: 'indigo'); @include banner-typography(); @include bottom-nav-typography(); @include button-typography(); diff --git a/projects/igniteui-angular/src/lib/core/styles/typography/_material.scss b/projects/igniteui-angular/src/lib/core/styles/typography/_material.scss index bc15191255c..8a6dd101db6 100644 --- a/projects/igniteui-angular/src/lib/core/styles/typography/_material.scss +++ b/projects/igniteui-angular/src/lib/core/styles/typography/_material.scss @@ -35,7 +35,7 @@ @use '../components/input/file-input-theme' as *; @mixin typography() { - @include badge-typography(); + @include badge-typography($theme: 'material'); @include banner-typography(); @include bottom-nav-typography(); @include button-typography(); diff --git a/projects/igniteui-angular/src/lib/data-operations/sorting-strategy.spec.ts b/projects/igniteui-angular/src/lib/data-operations/sorting-strategy.spec.ts index 152ec5abef3..20428ffa6b1 100644 --- a/projects/igniteui-angular/src/lib/data-operations/sorting-strategy.spec.ts +++ b/projects/igniteui-angular/src/lib/data-operations/sorting-strategy.spec.ts @@ -24,34 +24,46 @@ describe('Unit testing SortingStrategy', () => { strategy: DefaultSortingStrategy.instance() }]); expect(dataGenerator.getValuesForColumn(res, 'number')) - .toEqual([4, 2, 0, 3, 1]); + .toEqual([4, 2, 0, 3, 1]); }); it('tests `compareObjects`', () => { const strategy = DefaultSortingStrategy.instance(); expect(strategy.compareValues(1, 0) === 1 && - strategy.compareValues(true, false) === 1 && - strategy.compareValues('bc', 'adfc') === 1) + strategy.compareValues(true, false) === 1 && + strategy.compareValues('bc', 'adfc') === 1) .toBeTruthy('compare first argument greater than second'); expect(strategy.compareValues(1, 2) === -1 && - strategy.compareValues('a', 'b') === -1 && - strategy.compareValues(false, true) === -1) + strategy.compareValues('a', 'b') === -1 && + strategy.compareValues(false, true) === -1) .toBeTruthy('compare 0, 1'); expect(strategy.compareValues(0, 0) === 0 && - strategy.compareValues(true, true) === 0 && - strategy.compareValues('test', 'test') === 0 - ) + strategy.compareValues(true, true) === 0 && + strategy.compareValues('test', 'test') === 0 + ) .toBeTruthy('Comare equal variables'); }); it('tests default settings', () => { (data[4] as { string: string }).string = 'ROW'; const res = sorting.sort(data, [{ - dir: SortingDirection.Asc, - fieldName: 'string', - ignoreCase: true, - strategy: DefaultSortingStrategy.instance() - }]); + dir: SortingDirection.Asc, + fieldName: 'string', + ignoreCase: true, + strategy: DefaultSortingStrategy.instance() + }]); expect(dataGenerator.getValuesForColumn(res, 'number')) - .toEqual([4, 0, 1, 2, 3]); + .toEqual([4, 0, 1, 2, 3]); + }); + + it('should not sort when sorting direction is None', () => { + const unsortedData = [{ number: 3 }, { number: 1 }, { number: 4 }, { number: 0 }, { number: 2 }]; + const res = sorting.sort(unsortedData, [{ + dir: SortingDirection.None, + fieldName: 'number', + ignoreCase: false, + strategy: DefaultSortingStrategy.instance() + }]); + expect(res.map(d => d.number)) + .toEqual([3, 1, 4, 0, 2]); }); }); 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 46347ae67c4..8ff75e92d91 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 @@ -24,7 +24,7 @@ import { DateTimeUtil } from '../date-common/util/date-time.util'; import { IgxOverlayOutletDirective } from '../directives/toggle/toggle.directive'; import { IgxInputDirective, IgxInputGroupComponent, IgxInputGroupType, IgxInputState, - IgxLabelDirective, IGX_INPUT_GROUP_TYPE, IgxSuffixDirective + IgxLabelDirective, IgxHintDirective, IGX_INPUT_GROUP_TYPE, IgxSuffixDirective } from '../input-group/public_api'; import { AutoPositionStrategy, IgxOverlayService, OverlayCancelableEventArgs, OverlayEventArgs, @@ -407,6 +407,9 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective @ContentChild(IgxLabelDirective) public label: IgxLabelDirective; + @ContentChild(IgxHintDirective) + public hint: IgxHintDirective; + @ContentChild(IgxPickerActionsDirective) public pickerActions: IgxPickerActionsDirective; @@ -537,7 +540,10 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective /** @hidden @internal */ public get separatorClass(): string { - return 'igx-date-range-picker__label'; + const classes = ['igx-date-range-picker__label']; + if (this.label) classes.push('input-has-label'); + if (this.hint) classes.push('input-has-hint'); + return classes.join(' '); } protected override get toggleContainer(): HTMLElement | undefined { @@ -871,8 +877,8 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective } /** @hidden @internal */ - public getEditElement(): HTMLInputElement | undefined { - return this.inputDirective?.nativeElement; + public getEditElement(): HTMLInputElement { + return this.inputDirective!.nativeElement; } protected onStatusChanged = () => { diff --git a/projects/igniteui-angular/src/lib/directives/button/button-base.ts b/projects/igniteui-angular/src/lib/directives/button/button-base.ts index baeef654d91..e18d7a99a94 100644 --- a/projects/igniteui-angular/src/lib/directives/button/button-base.ts +++ b/projects/igniteui-angular/src/lib/directives/button/button-base.ts @@ -8,7 +8,7 @@ import { Output, booleanAttribute, inject, - afterRenderEffect, + AfterViewInit, } from '@angular/core'; import { PlatformUtil } from '../../core/utils'; @@ -20,8 +20,9 @@ export const IgxBaseButtonType = { @Directive() -export abstract class IgxButtonBaseDirective { +export abstract class IgxButtonBaseDirective implements AfterViewInit{ private _platformUtil = inject(PlatformUtil); + private _viewInit = false; /** * Emitted when the button is clicked. @@ -101,15 +102,16 @@ export abstract class IgxButtonBaseDirective { // In SSR there is no paint, so there’s no visual rendering or transitions to suppress. // Fix style flickering https://github.com/IgniteUI/igniteui-angular/issues/14759 if (this._platformUtil.isBrowser) { - afterRenderEffect({ - write: () => { - this.element.nativeElement.style.setProperty('--_init-transition', '0s'); - }, - read: () => { - requestAnimationFrame(() => { - this.element.nativeElement.style.removeProperty('--_init-transition'); - }); - } + this.element.nativeElement.style.setProperty('--_init-transition', '0s'); + } + } + + public ngAfterViewInit(): void { + if (this._platformUtil.isBrowser && !this._viewInit) { + this._viewInit = true; + + requestAnimationFrame(() => { + this.element.nativeElement.style.removeProperty('--_init-transition'); }); } } diff --git a/projects/igniteui-angular/src/lib/directives/input/input.directive.ts b/projects/igniteui-angular/src/lib/directives/input/input.directive.ts index 84fc82c9547..63d0bcce68b 100644 --- a/projects/igniteui-angular/src/lib/directives/input/input.directive.ts +++ b/projects/igniteui-angular/src/lib/directives/input/input.directive.ts @@ -303,6 +303,10 @@ export class IgxInputDirective implements AfterViewInit, OnDestroy { const elTag = this.nativeElement.tagName.toLowerCase(); if (elTag === 'textarea') { this.isTextArea = true; + + if (this.nativeElement.getAttribute('rows') === null) { + this.renderer.setAttribute(this.nativeElement, 'rows', '3'); + } } else { this.isInput = true; } diff --git a/projects/igniteui-angular/src/lib/directives/toggle/toggle.directive.ts b/projects/igniteui-angular/src/lib/directives/toggle/toggle.directive.ts index 72b44260461..2e329eb71f7 100644 --- a/projects/igniteui-angular/src/lib/directives/toggle/toggle.directive.ts +++ b/projects/igniteui-angular/src/lib/directives/toggle/toggle.directive.ts @@ -3,14 +3,13 @@ import { Directive, ElementRef, EventEmitter, - HostBinding, HostListener, Inject, Input, OnDestroy, OnInit, Optional, - Output + Output, } from '@angular/core'; import { AbsoluteScrollStrategy } from '../../services/overlay/scroll/absolute-scroll-strategy'; import { CancelableBrowserEventArgs, IBaseEventArgs, PlatformUtil } from '../../core/utils'; @@ -34,7 +33,13 @@ export interface ToggleViewCancelableEventArgs extends ToggleViewEventArgs, Canc @Directive({ exportAs: 'toggle', selector: '[igxToggle]', - standalone: true + standalone: true, + host: { + '[class.igx-toggle--hidden]': 'hiddenClass', + '[attr.aria-hidden]': 'hiddenClass', + '[class.igx-toggle--hidden-webkit]': 'hiddenWebkitClass', + '[class.igx-toggle]': 'defaultClass' + } }) export class IgxToggleDirective implements IToggleView, OnInit, OnDestroy { /** @@ -159,13 +164,13 @@ export class IgxToggleDirective implements IToggleView, OnInit, OnDestroy { /** * @hidden */ - @HostBinding('class.igx-toggle--hidden') - @HostBinding('attr.aria-hidden') public get hiddenClass() { return this.collapsed; } - @HostBinding('class.igx-toggle--hidden-webkit') + /** + * @hidden + */ public get hiddenWebkitClass() { const isSafari = this.platform?.isSafari; const browserVersion = this.platform?.browserVersion; @@ -176,7 +181,6 @@ export class IgxToggleDirective implements IToggleView, OnInit, OnDestroy { /** * @hidden */ - @HostBinding('class.igx-toggle') public get defaultClass() { return !this.collapsed; } @@ -224,6 +228,16 @@ export class IgxToggleDirective implements IToggleView, OnInit, OnDestroy { } this._collapsed = false; + + // TODO: this is a workaround for the issue introduced by Angular's with Ivy renderer. + // When calling detectChanges(), Angular marks the element for check, but does not update the classes + // immediately, which causes the overlay to calculate incorrect dimensions of target element. + // Overlay show should be called in the next tick to ensure the classes are updated and target element is measured correctly. + // Note: across the codebase, each host binding should be checked and similar fix applied if needed!!! + this.elementRef.nativeElement.className = this.elementRef.nativeElement.className.replace('igx-toggle--hidden', 'igx-toggle'); + this.elementRef.nativeElement.className = this.elementRef.nativeElement.className.replace('igx-toggle--hidden-webkit', 'igx-toggle'); + this.elementRef.nativeElement.removeAttribute('aria-hidden'); + this.cdr.detectChanges(); if (!info) { 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 93faf786554..33a59cbf661 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,8 +1,9 @@ import { Directive, OnInit, OnDestroy, Output, ElementRef, Optional, ViewContainerRef, HostListener, - Input, EventEmitter, booleanAttribute, TemplateRef, ComponentRef, Renderer2, OnChanges, SimpleChanges, + Input, EventEmitter, booleanAttribute, TemplateRef, ComponentRef, Renderer2, EnvironmentInjector, createComponent, + AfterViewInit, } from '@angular/core'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; @@ -45,7 +46,7 @@ export interface ITooltipHideEventArgs extends IBaseEventArgs { selector: '[igxTooltipTarget]', standalone: true }) -export class IgxTooltipTargetDirective extends IgxToggleActionDirective implements OnChanges, OnInit, OnDestroy { +export class IgxTooltipTargetDirective extends IgxToggleActionDirective implements OnInit, AfterViewInit, OnDestroy { /** * Gets/sets the amount of milliseconds that should pass before showing the tooltip. * @@ -101,7 +102,7 @@ export class IgxTooltipTargetDirective extends IgxToggleActionDirective implemen */ @Input() public set hasArrow(value: boolean) { - if (this.target) { + if (this.target && this.target.arrow) { this.target.arrow.style.display = value ? '' : 'none'; } this._hasArrow = value; @@ -397,16 +398,6 @@ export class IgxTooltipTargetDirective extends IgxToggleActionDirective implemen } } - - /** - * @hidden - */ - public ngOnChanges(changes: SimpleChanges): void { - if (changes['hasArrow']) { - this.target.arrow.style.display = changes['hasArrow'].currentValue ? '' : 'none'; - } - } - /** * @hidden */ @@ -433,6 +424,15 @@ export class IgxTooltipTargetDirective extends IgxToggleActionDirective implemen this.nativeElement.addEventListener('touchstart', this.onTouchStart = this.onTouchStart.bind(this), { passive: true }); } + /** + * @hidden + */ + public ngAfterViewInit(): void { + if (this.target && this.target.arrow) { + this.target.arrow.style.display = this.hasArrow ? '' : 'none'; + } + } + /** * @hidden */ @@ -559,8 +559,6 @@ export class IgxTooltipTargetDirective extends IgxToggleActionDirective implemen /** * 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) { @@ -573,8 +571,10 @@ export class IgxTooltipTargetDirective extends IgxToggleActionDirective implemen this.target.tooltipTarget._removeCloseButtonFromTooltip(); } + // If the tooltip is shown for one target and the user interacts with another target, + // the tooltip is instantly hidden for the first target. clearTimeout(this.target.timeoutId); - this.target.stopAnimations(true); + this.target.forceClose(this._mergedOverlaySettings); this.target.tooltipTarget = this; this._isForceClosed = true; diff --git a/projects/igniteui-angular/src/lib/directives/tooltip/tooltip.component.html b/projects/igniteui-angular/src/lib/directives/tooltip/tooltip.component.html index 4dd74656536..d1e984f1251 100644 --- a/projects/igniteui-angular/src/lib/directives/tooltip/tooltip.component.html +++ b/projects/igniteui-angular/src/lib/directives/tooltip/tooltip.component.html @@ -1 +1 @@ -{{content}} \ No newline at end of file +{{content}} 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 0f78506dfbd..1cd84369e48 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,7 +2,7 @@ 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, IgxTooltipWithCloseButtonComponent } from '../../test-utils/tooltip-components.spec'; +import { IgxTooltipSingleTargetComponent, IgxTooltipMultipleTargetsComponent, IgxTooltipPlainStringComponent, IgxTooltipWithToggleActionComponent, IgxTooltipMultipleTooltipsComponent, IgxTooltipWithCloseButtonComponent, IgxTooltipWithNestedContentComponent } 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'; @@ -28,7 +28,8 @@ describe('IgxTooltip', () => { IgxTooltipMultipleTargetsComponent, IgxTooltipPlainStringComponent, IgxTooltipWithToggleActionComponent, - IgxTooltipWithCloseButtonComponent + IgxTooltipWithCloseButtonComponent, + IgxTooltipWithNestedContentComponent ] }).compileComponents(); UIInteractions.clearOverlay(); @@ -500,6 +501,36 @@ describe('IgxTooltip', () => { verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); })); + + it('Should respect default max-width constraint for plain string tooltip', fakeAsync(() => { + hoverElement(button); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + + const maxWidth = getComputedStyle(tooltipNativeElement).maxWidth; + expect(maxWidth).toBe('200px'); + })); + }); + + describe('Custom content tooltip', () => { + beforeEach(waitForAsync(() => { + fix = TestBed.createComponent(IgxTooltipWithNestedContentComponent); + fix.detectChanges(); + button = fix.debugElement.query(By.directive(IgxTooltipTargetDirective)); + tooltipTarget = fix.componentInstance.tooltipTarget; + tooltipNativeElement = fix.debugElement.query(By.directive(IgxTooltipDirective)).nativeElement; + })); + + it('Should not have max-width constraint for custom content tooltip', fakeAsync(() => { + hoverElement(button); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + + const maxWidth = getComputedStyle(tooltipNativeElement).maxWidth; + expect(maxWidth).toBe('none'); + })); }); describe('Multiple targets with single tooltip', () => { @@ -558,6 +589,31 @@ describe('IgxTooltip', () => { flush(); })); + it('Should position relative to its target when having no close animation - #16288', fakeAsync(() => { + targetOne.positionSettings = targetTwo.positionSettings = { + openAnimation: undefined, + closeAnimation: undefined + }; + fix.detectChanges(); + + hoverElement(buttonOne); + tick(targetOne.showDelay); + + verifyTooltipVisibility(tooltipNativeElement, targetOne, true); + verifyTooltipPosition(tooltipNativeElement, buttonOne, true); + + unhoverElement(buttonOne); + + hoverElement(buttonTwo); + tick(targetTwo.showDelay); + + // Tooltip is visible and positioned relative to buttonTwo + verifyTooltipVisibility(tooltipNativeElement, targetTwo, true); + verifyTooltipPosition(tooltipNativeElement, buttonTwo); + // Tooltip is NOT visible and positioned relative to buttonOne + verifyTooltipPosition(tooltipNativeElement, buttonOne, false); + })); + it('Hovering first target briefly and then hovering second target leads to tooltip showing for second target', fakeAsync(() => { targetOne.showDelay = 600; fix.detectChanges(); 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 92bef088822..e04f71ba426 100644 --- a/projects/igniteui-angular/src/lib/directives/tooltip/tooltip.directive.ts +++ b/projects/igniteui-angular/src/lib/directives/tooltip/tooltip.directive.ts @@ -1,12 +1,16 @@ import { Directive, ElementRef, Input, ChangeDetectorRef, Optional, HostBinding, Inject, OnDestroy, inject, DOCUMENT, HostListener, + Renderer2, + AfterViewInit, } from '@angular/core'; import { IgxOverlayService } from '../../services/overlay/overlay'; +import { OverlaySettings } from '../../services/overlay/utilities'; import { IgxNavigationService } from '../../core/navigation'; import { IgxToggleDirective } from '../toggle/toggle.directive'; import { IgxTooltipTargetDirective } from './tooltip-target.directive'; import { Subject, takeUntil } from 'rxjs'; +import { PlatformUtil } from '../../core/utils'; let NEXT_ID = 0; /** @@ -28,7 +32,7 @@ let NEXT_ID = 0; selector: '[igxTooltip]', standalone: true }) -export class IgxTooltipDirective extends IgxToggleDirective implements OnDestroy { +export class IgxTooltipDirective extends IgxToggleDirective implements AfterViewInit, OnDestroy { /** * @hidden */ @@ -116,6 +120,8 @@ export class IgxTooltipDirective extends IgxToggleDirective implements OnDestroy private _role: 'tooltip' | 'status' = 'tooltip'; private _destroy$ = new Subject(); private _document = inject(DOCUMENT); + private _renderer = inject(Renderer2); + private _platformUtil = inject(PlatformUtil); /** @hidden */ constructor( @@ -133,8 +139,13 @@ export class IgxTooltipDirective extends IgxToggleDirective implements OnDestroy this.closed.pipe(takeUntil(this._destroy$)).subscribe(() => { this._document.removeEventListener('touchstart', this.onDocumentTouchStart); }); + } - this._createArrow(); + /** @hidden */ + public ngAfterViewInit(): void { + if (this._platformUtil.isBrowser) { + this._createArrow(); + } } /** @hidden */ @@ -144,7 +155,10 @@ export class IgxTooltipDirective extends IgxToggleDirective implements OnDestroy this._document.removeEventListener('touchstart', this.onDocumentTouchStart); this._destroy$.next(true); this._destroy$.complete(); - this._removeArrow(); + + if (this.arrow) { + this._removeArrow(); + } } /** @@ -165,37 +179,51 @@ export class IgxTooltipDirective extends IgxToggleDirective implements OnDestroy /** * If there is an animation in progress, this method will reset it to its initial state. - * Optional `force` parameter that ends the animation. + * Allows hovering over the tooltip while an open/close animation is running. + * Stops the animation and immediately shows the tooltip. * * @hidden - * @param force if set to `true`, the animation will be ended. */ - public stopAnimations(force: boolean = false): void { + public stopAnimations(): void { const info = this.overlayService.getOverlayById(this._overlayId); if (!info) return; if (info.openAnimationPlayer) { info.openAnimationPlayer.reset(); - if (force) { - info.openAnimationPlayer.finish(); - info.openAnimationPlayer = null; - } } if (info.closeAnimationPlayer) { info.closeAnimationPlayer.reset(); - if (force) { - info.closeAnimationPlayer.finish(); - info.closeAnimationPlayer = null; - } + } + } + + /** + * If there is a close animation in progress, this method will end it. + * If there is no close animation in progress, this method will close the tooltip with no animation. + * + * @param overlaySettings settings to use for closing the tooltip + * @hidden + */ + public forceClose(overlaySettings: OverlaySettings) { + const info = this.overlayService.getOverlayById(this._overlayId); + + if (info && info.closeAnimationPlayer) { + info.closeAnimationPlayer.finish(); + 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; } } private _createArrow(): void { - this._arrowEl = document.createElement('span'); - this._arrowEl.style.position = 'absolute'; - this._arrowEl.setAttribute('data-arrow', 'true'); - this.element.appendChild(this._arrowEl); + this._arrowEl = this._renderer.createElement('span'); + this._renderer.setStyle(this._arrowEl, 'position', 'absolute'); + this._renderer.setAttribute(this._arrowEl, 'data-arrow', 'true'); + this._renderer.appendChild(this.element, this._arrowEl); } private _removeArrow(): void { 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 de299087ef5..619b0756d89 100644 --- a/projects/igniteui-angular/src/lib/grids/columns/column.component.ts +++ b/projects/igniteui-angular/src/lib/grids/columns/column.component.ts @@ -1066,6 +1066,7 @@ export class IgxColumnComponent implements AfterContentInit, OnDestroy, ColumnTy return (this.grid as any)._columns.indexOf(this); } + /* mustCoerceToInt */ /** * Gets the pinning position of the column. * ```typescript diff --git a/projects/igniteui-angular/src/lib/grids/common/crud.service.ts b/projects/igniteui-angular/src/lib/grids/common/crud.service.ts index b8beb886740..03ac534540e 100644 --- a/projects/igniteui-angular/src/lib/grids/common/crud.service.ts +++ b/projects/igniteui-angular/src/lib/grids/common/crud.service.ts @@ -332,8 +332,11 @@ export class IgxCellCrudState { /** Clears cell editing state */ - public endCellEdit() { + public endCellEdit(restoreFocus: boolean = false) { this.cell = null; + if (restoreFocus) { + this.grid.tbody.nativeElement.focus(); + } } /** Returns whether the targeted cell is in edit mode */ diff --git a/projects/igniteui-angular/src/lib/grids/common/strategy.ts b/projects/igniteui-angular/src/lib/grids/common/strategy.ts index d951a4d38bc..3da2f12876e 100644 --- a/projects/igniteui-angular/src/lib/grids/common/strategy.ts +++ b/projects/igniteui-angular/src/lib/grids/common/strategy.ts @@ -5,7 +5,7 @@ import { IGroupingState } from '../../data-operations/groupby-state.interface'; import { IGroupingExpression } from '../../data-operations/grouping-expression.interface'; import { IGroupByResult } from '../../data-operations/grouping-result.interface'; import { getHierarchy, isHierarchyMatch } from '../../data-operations/operations'; -import { DefaultSortingStrategy, ISortingExpression } from '../../data-operations/sorting-strategy'; +import { DefaultSortingStrategy, ISortingExpression, SortingDirection } from '../../data-operations/sorting-strategy'; import { GridType } from './grid.interface'; const DATE_TYPE = 'date'; @@ -141,6 +141,9 @@ export class IgxSorting implements IGridSortingStrategy { private prepareExpressions(expressions: ISortingExpression[], grid: GridType): IGridInternalSortingExpression[] { const multipleSortingExpressions: IGridInternalSortingExpression[] = []; for (const expr of expressions) { + if (expr.dir === SortingDirection.None) { + continue; + } if (!expr.strategy) { expr.strategy = DefaultSortingStrategy.instance(); } 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 fdd88ba2794..092dbc8dffe 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -4106,7 +4106,10 @@ export abstract class IgxGridBaseDirective implements GridType, return this._activeRowIndexes; } else { const activeRow = this.navigation.activeNode?.row; - const selectedCellIndexes = (this.selectionService.selection?.keys() as any)?.toArray(); + + const selectedCellIndexes = this.selectionService.selection + ? Array.from(this.selectionService.selection.keys()) + : []; this._activeRowIndexes = [activeRow, ...selectedCellIndexes]; return this._activeRowIndexes; } @@ -6438,6 +6441,12 @@ export abstract class IgxGridBaseDirective implements GridType, } } + protected viewDetachHandler(args) { + if (this.actionStrip && args.view.rootNodes.find(x => x === this.actionStrip.context?.element.nativeElement)) { + this.actionStrip.hide(); + } + } + /** * @hidden @internal */ diff --git a/projects/igniteui-angular/src/lib/grids/grid-public-cell.ts b/projects/igniteui-angular/src/lib/grids/grid-public-cell.ts index e327858e99a..5c878475c4c 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-public-cell.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-public-cell.ts @@ -188,7 +188,7 @@ export class IgxGridCell implements CellType { // TODO possibly define similar method in gridAPI, which does not emit event this.grid.crudService.enterEditMode(this); } else { - this.grid.crudService.endCellEdit(); + this.grid.crudService.endCellEdit(true); } this.grid.notifyChanges(); } diff --git a/projects/igniteui-angular/src/lib/grids/grid/column-group.spec.ts b/projects/igniteui-angular/src/lib/grids/grid/column-group.spec.ts index 01f954a99e0..4cd61fbe4af 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/column-group.spec.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/column-group.spec.ts @@ -204,6 +204,27 @@ describe('IgxGrid - multi-column headers #grid', () => { } })); + it('The ariaHidden getter should not throw when the grid has no active node (#16517)', fakeAsync(() => { + fixture = TestBed.createComponent(BlueWhaleGridComponent) as ComponentFixture; + tick(); + fixture.detectChanges(); + + // The grid active node will be null if there is no data and the body is focused + grid = fixture.componentInstance.grid; + grid.data = []; + + tick(); + fixture.detectChanges(); + + const gridContent = GridFunctions.getGridContent(fixture); + + expect(() => { + gridContent.triggerEventHandler('focus', null); + tick(400); + fixture.detectChanges(); + }).not.toThrow(); + })); + it('Should render dynamic column group header correctly (#12165).', () => { fixture = TestBed.createComponent(BlueWhaleGridComponent) as ComponentFixture; (fixture as ComponentFixture).componentInstance.firstGroupRepeats = 1; diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid-cell-editing.spec.ts b/projects/igniteui-angular/src/lib/grids/grid/grid-cell-editing.spec.ts index 5ce3b82948b..9a7e01897e2 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid-cell-editing.spec.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/grid-cell-editing.spec.ts @@ -361,6 +361,48 @@ describe('IgxGrid - Cell Editing #grid', () => { expect(cell.editMode).toBe(false); expect(cell.value).toBe(newValue); })); + + it('should preserve the navigation when cancel cellEdit and async set cell.editMode=false', fakeAsync(() => { + grid.cellEdit.subscribe((evt: IGridEditEventArgs) => { + evt.cancel = true; + const rowIndex = evt.cellID.rowIndex; + const field = evt.column.field; + const target = grid.getCellByColumn(rowIndex, field); + setTimeout(() => { + target.editMode = false; + }, 100); + }); + + const cell = grid.gridAPI.get_cell_by_index(0, 'fullName'); + + UIInteractions.simulateDoubleClickAndSelectEvent(cell); + fixture.detectChanges(); + tick(16); + expect(cell.editMode).toBeTrue(); + + const editInput = fixture.debugElement.query(By.css('igx-grid-cell input')); + if (editInput) { + UIInteractions.clickAndSendInputElementValue(editInput, 'Edited'); + } + fixture.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('enter', gridContent); + fixture.detectChanges(); + + tick(100); + fixture.detectChanges(); + expect(cell.editMode).toBeFalse(); + + expect(document.activeElement).toBe(grid.tbody.nativeElement); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', document.activeElement as HTMLElement, true); + fixture.detectChanges(); + + const nextCell = grid.getCellByColumn(0, 'age'); + const active = (grid as any).navigation.activeNode; + expect(active.row).toBe(0); + expect(active.column).toBe(nextCell.column.visibleIndex); + })); }); describe('Scroll, pin and blur', () => { diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid-filtering-ui.spec.ts b/projects/igniteui-angular/src/lib/grids/grid/grid-filtering-ui.spec.ts index 265eb1786ca..1841cfe1279 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid-filtering-ui.spec.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/grid-filtering-ui.spec.ts @@ -6580,9 +6580,15 @@ describe('IgxGrid - Filtering actions - Excel style filtering #grid', () => { // Scroll the search list to the bottom. let scrollbar = GridFunctions.getExcelStyleSearchComponentScrollbar(fix); + expect(scrollbar.scrollTop).toBe(0); + let listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix); + expect(listItems[0].innerText).toBe('Select All'); + scrollbar.scrollTop = 3000; await wait(); fix.detectChanges(); + expect(listItems[0].innerText).not.toBe('Select All'); + expect(scrollbar.scrollTop).toBeGreaterThan(300); // Select another column GridFunctions.clickExcelFilterIcon(fix, 'Downloads'); @@ -6590,17 +6596,8 @@ describe('IgxGrid - Filtering actions - Excel style filtering #grid', () => { fix.detectChanges(); // Update scrollbar - const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix); scrollbar = GridFunctions.getExcelStyleSearchComponentScrollbar(fix); - await wait(); - fix.detectChanges(); - - // Get the display container and its parent and verify that the display container is at start - const displayContainer = searchComponent.querySelector('igx-display-container'); - const displayContainerRect = displayContainer.getBoundingClientRect(); - const parentContainerRect = displayContainer.parentElement.getBoundingClientRect(); - - expect(displayContainerRect.top - parentContainerRect.top <= 1).toBe(true, 'search scrollbar did not reset'); + expect(scrollbar.scrollTop).toBe(0, 'search scrollbar did not reset'); }); }); diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid-validation.spec.ts b/projects/igniteui-angular/src/lib/grids/grid/grid-validation.spec.ts index cb4f5b0b3b2..9b660e56674 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid-validation.spec.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/grid-validation.spec.ts @@ -167,8 +167,8 @@ describe('IgxGrid - Validation #grid', () => { cell = grid.gridAPI.get_cell_by_visible_index(1, 1); //min length should be 4 GridFunctions.verifyCellValid(cell, false); - const erorrMessage = cell.errorTooltip.first.elementRef.nativeElement.children[1].textContent; - expect(erorrMessage).toEqual(' Entry should be at least 4 character(s) long '); + const errorMessage = cell.errorTooltip.first.elementRef.nativeElement.children[0].textContent; + expect(errorMessage).toEqual(' Entry should be at least 4 character(s) long '); }); it('should mark invalid cell with igx-grid__td--invalid class and show the related error cell template when the field contains "."', () => { @@ -186,8 +186,8 @@ describe('IgxGrid - Validation #grid', () => { cell = grid.gridAPI.get_cell_by_visible_index(1, 4); //min length should be 4 GridFunctions.verifyCellValid(cell, false); - const erorrMessage = cell.errorTooltip.first.elementRef.nativeElement.children[1].textContent; - expect(erorrMessage).toEqual(' Entry should be at least 4 character(s) long '); + const errorMessage = cell.errorTooltip.first.elementRef.nativeElement.children[0].textContent; + expect(errorMessage).toEqual(' Entry should be at least 4 character(s) long '); }); it('should show the error message on error icon hover and when the invalid cell becomes active.', fakeAsync(() => { @@ -204,8 +204,8 @@ describe('IgxGrid - Validation #grid', () => { //min length should be 4 GridFunctions.verifyCellValid(cell, false); GridSelectionFunctions.verifyCellActive(cell, true); - const erorrMessage = cell.errorTooltip.first.elementRef.nativeElement.children[1].textContent; - expect(erorrMessage).toEqual(' Entry should be at least 4 character(s) long '); + const errorMessage = cell.errorTooltip.first.elementRef.nativeElement.children[0].textContent; + expect(errorMessage).toEqual(' Entry should be at least 4 character(s) long '); const overlayService = TestBed.inject(IgxOverlayService); const info = overlayService.getOverlayById(cell.errorTooltip.first.overlayId); @@ -390,8 +390,8 @@ describe('IgxGrid - Validation #grid', () => { cell = grid.gridAPI.get_cell_by_visible_index(1, 1); //bob cannot be the name GridFunctions.verifyCellValid(cell, false); - const erorrMessage = cell.errorTooltip.first.elementRef.nativeElement.children[1].textContent; - expect(erorrMessage).toEqual(' This name is forbidden. '); + const errorMessage = cell.errorTooltip.first.elementRef.nativeElement.children[0].textContent; + expect(errorMessage).toEqual(' This name is forbidden. '); cell.editMode = true; cell.update('test'); @@ -425,8 +425,8 @@ describe('IgxGrid - Validation #grid', () => { fixture.detectChanges(); GridFunctions.verifyCellValid(cell, false); - const erorrMessage = cell.errorTooltip.first.elementRef.nativeElement.children[1].textContent; - expect(erorrMessage).toEqual(' Entry should be at least 4 character(s) long '); + const errorMessage = cell.errorTooltip.first.elementRef.nativeElement.children[0].textContent; + expect(errorMessage).toEqual(' Entry should be at least 4 character(s) long '); }); it('should trigger validation on change when using custom editor bound via editValue.', () => { @@ -444,8 +444,8 @@ describe('IgxGrid - Validation #grid', () => { fixture.detectChanges(); GridFunctions.verifyCellValid(cell, false); - const erorrMessage = cell.errorTooltip.first.elementRef.nativeElement.children[1].textContent; - expect(erorrMessage).toEqual(' Entry should be at least 4 character(s) long '); + const errorMessage = cell.errorTooltip.first.elementRef.nativeElement.children[0].textContent; + expect(errorMessage).toEqual(' Entry should be at least 4 character(s) long '); }); it('should trigger validation on blur when using custom editor bound via editValue.', () => { @@ -472,8 +472,8 @@ describe('IgxGrid - Validation #grid', () => { grid.crudService.endEdit(true); fixture.detectChanges(); GridFunctions.verifyCellValid(cell, false); - const erorrMessage = cell.errorTooltip.first.elementRef.nativeElement.children[1].textContent; - expect(erorrMessage).toEqual(' Entry should be at least 4 character(s) long '); + const errorMessage = cell.errorTooltip.first.elementRef.nativeElement.children[0].textContent; + expect(errorMessage).toEqual(' Entry should be at least 4 character(s) long '); }); }); 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 f7b47ea5aec..2c588d04f72 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid.component.html +++ b/projects/igniteui-angular/src/lib/grids/grid/grid.component.html @@ -109,6 +109,7 @@ [igxTemplateOutletContext]="getContext(rowData, rowIndex)" (cachedViewLoaded)="cachedViewLoaded($event)" (viewCreated)="viewCreatedHandler($event)" + (beforeViewDetach)="viewDetachHandler($event)" (viewMoved)="viewMovedHandler($event)"> 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 307827b1922..f062bf0d0f6 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid.component.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/grid.component.ts @@ -415,7 +415,7 @@ export class IgxGridComponent extends IgxGridBaseDirective implements GridType, this.validation.updateAll(this._data); } - if (this.autoGenerate && this._data.length > 0 && this.shouldRecreateColumns(oldData, this._data)) { + if (this.autoGenerate && this._data.length > 0 && this.shouldRecreateColumns(oldData, this._data) && this.gridAPI.grid) { this.setupColumns(); } @@ -1304,6 +1304,17 @@ export class IgxGridComponent extends IgxGridBaseDirective implements GridType, super.onColumnsAddedOrRemoved(); } + /** + * @hidden + */ + protected override onColumnsChanged(change: QueryList) { + super.onColumnsChanged(change); + + if (this.hasColumnLayouts && !(this.navigation instanceof IgxGridMRLNavigationService)) { + this._setupNavigationService(); + } + } + /** * @hidden @internal */ 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 b6c2091ff0b..3cd45f5ca41 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts @@ -114,7 +114,7 @@ export class IgxGridUnmergeActivePipe implements PipeTransform { const rootsToUpdate = []; activeRowIndexes.forEach(index => { const target = collection[index]; - if (target) { + if (target && target.cellMergeMeta) { colsToMerge.forEach(col => { const colMeta = target.cellMergeMeta.get(col.field); const root = colMeta.root || (colMeta.rowSpan > 1 ? target : null); diff --git a/projects/igniteui-angular/src/lib/grids/headers/grid-header-group.component.ts b/projects/igniteui-angular/src/lib/grids/headers/grid-header-group.component.ts index 27d28045182..63128024e2e 100644 --- a/projects/igniteui-angular/src/lib/grids/headers/grid-header-group.component.ts +++ b/projects/igniteui-angular/src/lib/grids/headers/grid-header-group.component.ts @@ -151,7 +151,7 @@ export class IgxGridHeaderGroupComponent implements DoCheck { * @hidden */ public get ariaHidden(): boolean { - return this.grid.hasColumnGroups && (this.column.hidden || this.grid.navigation.activeNode.row !== -1); + return this.grid.hasColumnGroups && (this.column.hidden || this.grid.navigation.activeNode?.row !== -1); } /** diff --git a/projects/igniteui-angular/src/lib/grids/headers/grid-header-row.component.ts b/projects/igniteui-angular/src/lib/grids/headers/grid-header-row.component.ts index 6bc670cc319..48a891e6e19 100644 --- a/projects/igniteui-angular/src/lib/grids/headers/grid-header-row.component.ts +++ b/projects/igniteui-angular/src/lib/grids/headers/grid-header-row.component.ts @@ -123,7 +123,7 @@ export class IgxGridHeaderRowComponent implements DoCheck { * @internal */ public get isLeafHeaderAriaHidden(): boolean { - return this.grid.navigation.activeNode.row === -1; + return this.grid.navigation.activeNode?.row === -1; } /** The virtualized part of the header row containing the unpinned header groups. */ 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 2b640e97ddd..dd46674993c 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 @@ -85,7 +85,7 @@ + (viewMoved)="viewMovedHandler($event)" (cachedViewLoaded)="cachedViewLoaded($event)" (beforeViewDetach)="viewDetachHandler($event)"> 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 789b46b4453..5b89ccafa6e 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 @@ -868,7 +868,7 @@ export class IgxHierarchicalGridComponent extends IgxHierarchicalGridBaseDirecti if (!this._init) { this.validation.updateAll(this._data); } - if (this.autoGenerate && this._data.length > 0 && this.shouldRecreateColumns(oldData, this._data)) { + if (this.autoGenerate && this._data.length > 0 && this.shouldRecreateColumns(oldData, this._data) && this.gridAPI.grid) { this.setupColumns(); this.reflow(); } diff --git a/projects/igniteui-angular/src/lib/grids/hierarchical-grid/row-island.component.ts b/projects/igniteui-angular/src/lib/grids/hierarchical-grid/row-island.component.ts index e0ec1491026..a0761125179 100644 --- a/projects/igniteui-angular/src/lib/grids/hierarchical-grid/row-island.component.ts +++ b/projects/igniteui-angular/src/lib/grids/hierarchical-grid/row-island.component.ts @@ -137,10 +137,10 @@ export class IgxRowIslandComponent extends IgxHierarchicalGridBaseDirective @ContentChildren(IgxColumnComponent, { read: IgxColumnComponent, descendants: false }) public childColumns = new QueryList(); - @ContentChild(IgxGridToolbarDirective, { read: TemplateRef }) + @ContentChild(IgxGridToolbarDirective, { read: TemplateRef, descendants: false }) protected toolbarDirectiveTemplate: TemplateRef; - @ContentChild(IgxPaginatorDirective, { read: TemplateRef }) + @ContentChild(IgxPaginatorDirective, { read: TemplateRef, descendants: false }) protected paginatorDirectiveTemplate: TemplateRef; /* csSuppress */ diff --git a/projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-grid.component.ts b/projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-grid.component.ts index 57890443e81..3d1010770ce 100644 --- a/projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-grid.component.ts +++ b/projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-grid.component.ts @@ -2021,7 +2021,7 @@ export class IgxPivotGridComponent extends IgxGridBaseDirective implements OnIni } protected getPivotRowHeaderContentWidth(headerGroup: IgxPivotRowHeaderGroupComponent) { - const headerSizes = this.getHeaderCellWidth(headerGroup.header.refInstance.nativeElement); + const headerSizes = this.getHeaderCellWidth(headerGroup.nativeElement); return headerSizes.width + headerSizes.padding; } diff --git a/projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-grid.spec.ts b/projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-grid.spec.ts index 481c29cfec1..8b7307764a3 100644 --- a/projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-grid.spec.ts +++ b/projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-grid.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { CellType, FilteringExpressionsTree, FilteringLogic, GridColumnDataType, IGridCellEventArgs, IgxIconComponent, IgxPivotGridComponent, IgxStringFilteringOperand } from 'igniteui-angular'; +import { FilteringExpressionsTree, FilteringLogic, GridColumnDataType, IGridCellEventArgs, IgxGridCell, IgxIconComponent, IgxPivotGridComponent, IgxStringFilteringOperand } from 'igniteui-angular'; import { IgxChipComponent } from '../../chips/chip.component'; import { IgxChipsAreaComponent } from '../../chips/chips-area.component'; import { DefaultPivotSortingStrategy } from '../../data-operations/pivot-sort-strategy'; @@ -22,7 +22,6 @@ import { Size } from '../common/enums'; import { setElementSize } from '../../test-utils/helper-utils.spec'; import { IgxPivotRowDimensionMrlRowComponent } from './pivot-row-dimension-mrl-row.component'; import { IgxPivotRowDimensionContentComponent } from './pivot-row-dimension-content.component'; -import { IgxGridCellComponent } from '../cell.component'; const CSS_CLASS_LIST = 'igx-drop-down__list'; const CSS_CLASS_ITEM = 'igx-drop-down__item'; @@ -760,7 +759,7 @@ describe('IgxPivotGrid #pivotGrid', () => { expect(pivotGrid.columns.length).toBe(3); }); - it('should calculate row headers according to grid size', async() => { + it('should calculate row headers according to grid size', async () => { const pivotGrid = fixture.componentInstance.pivotGrid; const rowHeightSmall = 32; const rowHeightMedium = 40; @@ -797,7 +796,7 @@ describe('IgxPivotGrid #pivotGrid', () => { expect(rowHeader[0].nativeElement.offsetHeight).toBe(rowHeightMedium); }); - it('should render with correct width when set to 100% inside of flex container', async() => { + it('should render with correct width when set to 100% inside of flex container', async () => { fixture = TestBed.createComponent(IgxPivotGridFlexContainerComponent); fixture.detectChanges(); await wait(100); @@ -808,6 +807,22 @@ describe('IgxPivotGrid #pivotGrid', () => { expect(pivotGrid.nativeElement.clientWidth - expectedSize).toBeLessThan(50, "should take sum of columns as width."); }); + it('should render cell values for dimension columns containing dots - issue #16445', () => { + let data = fixture.componentInstance.data; + data = data.map(item => { + return { + ...item, + Country: `${item['Country']}.Test` + }; + }); + + fixture.componentInstance.data = [...data]; + fixture.detectChanges(); + + const cell = fixture.componentInstance.pivotGrid.gridAPI.get_cell_by_index(0, 'Bulgaria.Test-UnitsSold'); + expect(cell.value).not.toBeUndefined(); + }); + describe('IgxPivotGrid Features #pivotGrid', () => { it('should show excel style filtering via dimension chip.', async () => { const pivotGrid = fixture.componentInstance.pivotGrid; @@ -1415,7 +1430,7 @@ describe('IgxPivotGrid #pivotGrid', () => { GridFunctions.clickHeaderSortIcon(headerCell); fixture.detectChanges(); expect(pivotGrid.sortingExpressions.length).toBe(0); - expectedOrder = [829, 293, undefined, 296, 240]; + expectedOrder = [829, 296, undefined, 293, 240]; columnValues = pivotGrid.dataView.map(x => (x as IPivotGridRecord).aggregationValues.get('USA-UnitsSold')); expect(columnValues).toEqual(expectedOrder); }); @@ -2113,15 +2128,16 @@ describe('IgxPivotGrid #pivotGrid', () => { spyOn(pivotGrid.cellClick, 'emit').and.callThrough(); fixture.detectChanges(); - const cell = pivotGrid.gridAPI.get_cell_by_index(0, 'Bulgaria-UnitsSold') as CellType; + const cell = pivotGrid.gridAPI.get_cell_by_index(0, 'Bulgaria-UnitsSold'); - pivotGrid.cellClick.emit({ cell, event: null }); - cell.nativeElement.click(); - const cellClickargs: IGridCellEventArgs = { cell, event: new MouseEvent('click') }; + const event = new Event('click'); + cell.nativeElement.dispatchEvent(event); + fixture.detectChanges(); + + const expectedCell = new IgxGridCell(pivotGrid, 0, cell.column); - const gridCell = cellClickargs.cell as IgxGridCellComponent; - const firstEntry = gridCell.rowData.aggregationValues.entries().next().value; - expect(firstEntry).toEqual(['USA-UnitsSold', 829]); + const cellClickArgs: IGridCellEventArgs = { cell: expectedCell, event }; + expect(pivotGrid.cellClick.emit).toHaveBeenCalledOnceWith(cellClickArgs); }); }); }); @@ -2139,7 +2155,7 @@ describe('IgxPivotGrid #pivotGrid', () => { const pivotGrid = fixture.componentInstance.pivotGrid; expect(pivotGrid.selectedRows).toEqual([]); const pivotRows = GridFunctions.getPivotRows(fixture); - const row = pivotRows[2].componentInstance; + const row = pivotRows[1].componentInstance; const rowHeaders = fixture.debugElement.queryAll( By.directive(IgxPivotRowDimensionHeaderComponent)); const secondDimCell = rowHeaders.find(x => x.componentInstance.column.header === 'Clothing'); @@ -2177,13 +2193,17 @@ describe('IgxPivotGrid #pivotGrid', () => { [ { AllProducts: 'AllProducts', 'All cities': 'All Cities' - }, { - ProductCategory: 'Bikes', 'All cities': 'All Cities' - }, { + }, + { ProductCategory: 'Clothing', 'All cities': 'All Cities' - }, { + }, + { + ProductCategory: 'Bikes', 'All cities': 'All Cities' + }, + { ProductCategory: 'Accessories', 'All cities': 'All Cities' - }, { + }, + { ProductCategory: 'Components', 'All cities': 'All Cities' } ]; @@ -2233,38 +2253,38 @@ describe('IgxPivotGrid #pivotGrid', () => { columns: fixture.componentInstance.pivotConfigHierarchy.columns, rows: fixture.componentInstance.pivotConfigHierarchy.rows, values: [ - { - member: 'UnitsSold', - aggregate: { - aggregator: IgxPivotNumericAggregate.sum, - key: 'SUM', - label: 'Sum' - }, - enabled: true, - formatter: (value, row, column) => { - if (!column || !column.value || column.value.member !== 'UnitsSold') { - correctFirstColumnData = false; + { + member: 'UnitsSold', + aggregate: { + aggregator: IgxPivotNumericAggregate.sum, + key: 'SUM', + label: 'Sum' + }, + enabled: true, + formatter: (value, row, column) => { + if (!column || !column.value || column.value.member !== 'UnitsSold') { + correctFirstColumnData = false; + } + return value; } - return value; - } - }, - { - member: 'AmountOfSale', - displayName: 'Amount of Sale', - aggregate: { - aggregator: IgxTotalSaleAggregate.totalSale, - key: 'TOTAL', - label: 'Total' }, - enabled: true, - formatter: (value, row, column) => { - if (!column || !column.value || column.value.member !== 'AmountOfSale') { - correctSecondColumnData = false; + { + member: 'AmountOfSale', + displayName: 'Amount of Sale', + aggregate: { + aggregator: IgxTotalSaleAggregate.totalSale, + key: 'TOTAL', + label: 'Total' + }, + enabled: true, + formatter: (value, row, column) => { + if (!column || !column.value || column.value.member !== 'AmountOfSale') { + correctSecondColumnData = false; + } + return value; } - return value; } - } - ] + ] }; pivotGrid.width = '1500px'; @@ -2528,7 +2548,7 @@ describe('IgxPivotGrid #pivotGrid', () => { const dimensionContents = fixture.debugElement.queryAll(By.css('.igx-grid__tbody-pivot-dimension')); const rowHeaders = dimensionContents[1].queryAll(By.directive(IgxPivotRowDimensionHeaderGroupComponent)); const first = rowHeaders.map(x => x.componentInstance.column.header)[0]; - expect(first).toBe('Larry Lieb'); + expect(first).toBe('Stanley Brooker'); // insert in columns pivotGrid.insertDimensionAt({ memberName: 'SellerNameColumn', memberFunction: (rec) => rec.SellerName, enabled: true }, PivotDimensionType.Column, 0); @@ -2822,7 +2842,7 @@ describe('IgxPivotGrid #pivotGrid', () => { //check rows const rows = pivotGrid.rowList.toArray(); expect(rows.length).toBe(4); - const expectedHeaders = ['Accessories', 'Bikes', 'Clothing', 'Components']; + const expectedHeaders = ['Clothing', 'Bikes', 'Accessories', 'Components']; const rowHeaders = fixture.debugElement.queryAll( By.directive(IgxPivotRowDimensionHeaderComponent)); const rowDimensionHeaders = rowHeaders.map(x => x.componentInstance.column.header); @@ -2835,7 +2855,7 @@ describe('IgxPivotGrid #pivotGrid', () => { // check data const pivotRecord = (pivotGrid.rowList.first as IgxPivotRowComponent).data; - expect(pivotRecord.aggregationValues.get('London')).toBe(293); + expect(pivotRecord.aggregationValues.get('New York')).toBe(296); }); @@ -2868,8 +2888,8 @@ describe('IgxPivotGrid #pivotGrid', () => { ] }; fixture.detectChanges(); - expect(pivotGrid.gridAPI.get_cell_by_index(0, 0).nativeElement.innerText).toBe('Accessories/Plovdiv:undefined'); - expect(pivotGrid.gridAPI.get_cell_by_index(0, 3).nativeElement.innerText).toBe('Accessories/London:293'); + expect(pivotGrid.gridAPI.get_cell_by_index(0, 0).nativeElement.innerText).toBe('Clothing/Plovdiv:282'); + expect(pivotGrid.gridAPI.get_cell_by_index(0, 3).nativeElement.innerText).toBe('Clothing/London:undefined'); }); it('should allow filtering a dimension runtime.', () => { @@ -3029,7 +3049,7 @@ describe('IgxPivotGrid #pivotGrid', () => { const rowDimensionHeadersCol2 = rowDimensionCol2.map(x => x.componentInstance.rowDimensionColumn.header); dimensions = rowDimensionCol2.map(x => x.componentInstance.dimension); expect(dimensions.every(x => x.memberName === "City")).toBeTruthy(); - expect(rowDimensionHeadersCol2).toEqual(["Ciudad de la Costa", "London", "New York", "Plovdiv", "Sofia", "Yokohama"]); + expect(rowDimensionHeadersCol2).toEqual(['Plovdiv', 'New York', 'Ciudad de la Costa', 'London', 'Yokohama', 'Sofia']); const rowDimensionCol3 = contentRowHeaders.filter(y => y.componentInstance.layout.colStart === 3); const rowDimensionHeadersCol3 = rowDimensionCol3.map(x => x.componentInstance.rowDimensionColumn.header); @@ -3042,8 +3062,8 @@ describe('IgxPivotGrid #pivotGrid', () => { .map(x => x.componentInstance.rowDimensionColumn.header); dimensions = rowDimensionCol4.map(x => x.componentInstance.dimension); expect(dimensions.every(x => x.memberName === "ProductCategory")).toBeTruthy(); - expect(rowDimensionHeadersCol4).toEqual(["Bikes", "Clothing", "Accessories", - "Clothing", "Clothing", "Components", "Components"]); + expect(rowDimensionHeadersCol4).toEqual(["Clothing", "Clothing", "Bikes", + "Clothing", "Accessories", "Components", "Components"]); }); it("should horizontally expand/collapse on a single dimension hierarchy.", () => { @@ -3262,11 +3282,14 @@ describe('IgxPivotGrid #pivotGrid', () => { const allGroups = layoutContainer.queryAll( By.directive(IgxPivotRowDimensionHeaderComponent)); const row0Col0 = allGroups[0]; - const row0Col1 = allGroups.filter(x => x.componentInstance.column.header === "Ciudad de la Costa")[0]; + const row0Col1 = allGroups.filter(x => x.componentInstance.column.header === "Plovdiv")[0]; + const row1Col1 = allGroups.filter(x => x.componentInstance.column.header === "New York")[0]; + const row0Col2 = allGroups.filter(x => x.componentInstance.column.header === "AllProducts")[0]; - const row0Col3 = allGroups.filter(x => x.componentInstance.column.header === "Bikes")[0]; - const row1Col3 = allGroups.filter(x => x.componentInstance.column.header === "Clothing")[0]; - const row2Col3 = allGroups.filter(x => x.componentInstance.column.header === "Accessories")[0]; + const row1Col2 = allGroups.filter(x => x.componentInstance.column.header === "AllProducts")[1]; + const row0Col3 = allGroups.filter(x => x.componentInstance.column.header === "Clothing")[0]; + const row1Col3 = allGroups.filter(x => x.componentInstance.column.header === "Clothing")[1]; + const row2Col3 = allGroups.filter(x => x.componentInstance.column.header === "Bikes")[0]; UIInteractions.simulateClickAndSelectEvent(row0Col0); fixture.detectChanges(); @@ -3320,18 +3343,18 @@ describe('IgxPivotGrid #pivotGrid', () => { UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', row1Col3.nativeElement); tick(); fixture.detectChanges(); - GridFunctions.verifyHeaderIsFocused(row0Col2.parent); + GridFunctions.verifyHeaderIsFocused(row1Col2.parent); activeCells = fixture.debugElement.queryAll(By.css(`${ACTIVE_CELL_CSS_CLASS}`)); expect(activeCells.length).toBe(1); - UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', row0Col2.nativeElement); + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', row1Col2.nativeElement); tick(); fixture.detectChanges(); - GridFunctions.verifyHeaderIsFocused(row0Col1.parent); + GridFunctions.verifyHeaderIsFocused(row1Col1.parent); activeCells = fixture.debugElement.queryAll(By.css(`${ACTIVE_CELL_CSS_CLASS}`)); expect(activeCells.length).toBe(1); - UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', row0Col1.nativeElement); + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', row1Col1.nativeElement); tick(); fixture.detectChanges(); GridFunctions.verifyHeaderIsFocused(row0Col0.parent); @@ -3385,7 +3408,7 @@ describe('IgxPivotGrid #pivotGrid', () => { const productRowContents = rowHeaders.filter(x => x.componentInstance.column.field === "ProductCategory"); const productRowContentsHeaders = productRowContents.map(x => x.componentInstance.column.header); - expect(productRowContentsHeaders).toEqual(['ProductCategory', 'Accessories', 'Bikes', 'Clothing', 'Components']); + expect(productRowContentsHeaders).toEqual(['ProductCategory', 'Clothing', 'Bikes', 'Accessories', 'Components']); const sortIcon = productsHeaderColumn.querySelectorAll('igx-icon')[0]; sortIcon.click(); @@ -3408,7 +3431,7 @@ describe('IgxPivotGrid #pivotGrid', () => { By.directive(IgxPivotRowDimensionHeaderComponent)); expect(pivotGrid.selectedRows).toEqual([]); const pivotRows = GridFunctions.getPivotRows(fixture); - const row = pivotRows[2].componentInstance; + const row = pivotRows[4].componentInstance; const secondDimCell = rowHeaders.find(x => x.componentInstance.column.header === 'Accessories'); secondDimCell.nativeElement.click(); fixture.detectChanges(); diff --git a/projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-header-row.component.html b/projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-header-row.component.html index 246d852ae53..3772f3958c4 100644 --- a/projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-header-row.component.html +++ b/projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-header-row.component.html @@ -254,8 +254,8 @@ @if (!column.columnGroup && column.resizable) { diff --git a/projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-row-header-group.component.ts b/projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-row-header-group.component.ts index f3baf7b4a9f..963facbcaa3 100644 --- a/projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-row-header-group.component.ts +++ b/projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-row-header-group.component.ts @@ -159,9 +159,6 @@ export class IgxPivotRowHeaderGroupComponent extends IgxGridHeaderGroupComponent } protected getHeaderWidthFromDimension() { - if (this.grid.hasHorizontalLayout) { - return this.dimWidth === -1 ? 'fit-content' : this.width; - } - return this.grid.rowDimensionWidth(this.parent.rootDimension); + return this.grid.hasHorizontalLayout && this.dimWidth === -1 ? 'fit-content' : null; } } 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 a6a93da2e92..118c665e2d5 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 @@ -14,7 +14,7 @@ [rowData]="data" [columnData]='getColumnData(col)' [style.min-width]="col.resolvedWidth" [style.max-width]="col.resolvedWidth" [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" + [value]="pivotAggregationData[col.field]" [cellTemplate]="col.bodyTemplate" [lastSearchInfo]="grid.lastSearchInfo" [cellSelectionMode]="grid.cellSelection" [displayPinnedChip]="shouldDisplayPinnedChip(col)" (pointerdown)="grid.navigation.focusOutRowHeader($event)"> diff --git a/projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-row.component.ts b/projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-row.component.ts index ed542116c43..72cd2088c95 100644 --- a/projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-row.component.ts +++ b/projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-row.component.ts @@ -13,7 +13,7 @@ import { IgxGridSelectionService } from '../selection/selection.service'; import { IPivotGridColumn, IPivotGridRecord } from './pivot-grid.interface'; import { PivotUtil } from './pivot-util'; import { IgxPivotGridCellStyleClassesPipe } from './pivot-grid.pipes'; -import { IgxGridNotGroupedPipe, IgxGridCellStylesPipe, IgxGridDataMapperPipe, IgxGridTransactionStatePipe } from '../common/pipes'; +import { IgxGridNotGroupedPipe, IgxGridCellStylesPipe, IgxGridTransactionStatePipe } from '../common/pipes'; import { IgxCheckboxComponent } from '../../checkbox/checkbox.component'; import { NgClass, NgStyle } from '@angular/common'; import { IgxGridCellComponent } from '../cell.component'; @@ -24,7 +24,7 @@ import { IgxGridForOfDirective } from '../../directives/for-of/for_of.directive' selector: 'igx-pivot-row', templateUrl: './pivot-row.component.html', providers: [{ provide: IgxRowDirective, useExisting: forwardRef(() => IgxPivotRowComponent) }], - imports: [IgxGridForOfDirective, IgxGridCellComponent, NgClass, NgStyle, IgxCheckboxComponent, IgxGridNotGroupedPipe, IgxGridCellStylesPipe, IgxGridDataMapperPipe, IgxGridTransactionStatePipe, IgxPivotGridCellStyleClassesPipe] + imports: [IgxGridForOfDirective, IgxGridCellComponent, NgClass, NgStyle, IgxCheckboxComponent, IgxGridNotGroupedPipe, IgxGridCellStylesPipe, IgxGridTransactionStatePipe, IgxPivotGridCellStyleClassesPipe] }) export class IgxPivotRowComponent extends IgxRowDirective { /** diff --git a/projects/igniteui-angular/src/lib/grids/row.directive.ts b/projects/igniteui-angular/src/lib/grids/row.directive.ts index 81a0bced344..4752e1ce936 100644 --- a/projects/igniteui-angular/src/lib/grids/row.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/row.directive.ts @@ -492,6 +492,10 @@ export class IgxRowDirective implements DoCheck, AfterViewInit, OnDestroy { * @internal */ public ngOnDestroy() { + // if action strip is shown here but row is about to be destroyed, hide it. + if (this.grid.actionStrip && this.grid.actionStrip.context === this) { + this.grid.actionStrip.hide(); + } this.destroy$.next(true); this.destroy$.complete(); } diff --git a/projects/igniteui-angular/src/lib/grids/state.pivotgrid.spec.ts b/projects/igniteui-angular/src/lib/grids/state.pivotgrid.spec.ts index 7ef2e6779cc..bbc6bf04b4e 100644 --- a/projects/igniteui-angular/src/lib/grids/state.pivotgrid.spec.ts +++ b/projects/igniteui-angular/src/lib/grids/state.pivotgrid.spec.ts @@ -228,7 +228,7 @@ describe('IgxPivotGridState #pivotGrid :', () => { pivotGrid.rowSelection = 'single'; const state = fixture.componentInstance.state; expect(state).toBeDefined('IgxGridState directive is initialized'); - const headerRow = fixture.nativeElement.querySelector('igx-pivot-row-dimension-content'); + const headerRow = fixture.nativeElement.querySelectorAll('igx-pivot-row-dimension-content')[2]; const header = headerRow.querySelector('igx-pivot-row-dimension-header'); header.click(); fixture.detectChanges(); diff --git a/projects/igniteui-angular/src/lib/grids/toolbar/grid-toolbar.base.ts b/projects/igniteui-angular/src/lib/grids/toolbar/grid-toolbar.base.ts index cb3a3c69c9a..e386ac9fad6 100644 --- a/projects/igniteui-angular/src/lib/grids/toolbar/grid-toolbar.base.ts +++ b/projects/igniteui-angular/src/lib/grids/toolbar/grid-toolbar.base.ts @@ -9,7 +9,7 @@ import { IgxColumnActionsComponent } from '../column-actions/column-actions.comp import { IgxToggleDirective, ToggleViewCancelableEventArgs, ToggleViewEventArgs } from '../../directives/toggle/toggle.directive'; import { HorizontalAlignment, OverlaySettings, VerticalAlignment } from '../../services/overlay/utilities'; import { IgxToolbarToken } from './token'; -import { ConnectedPositioningStrategy } from '../../services/overlay/position/connected-positioning-strategy'; +import { AutoPositionStrategy } from '../../services/overlay/position/auto-position-strategy'; /* blazorInclude */ /* blazorElement */ @@ -88,7 +88,7 @@ export abstract class BaseToolbarDirective implements OnDestroy { private $sub: Subscription; private _overlaySettings: OverlaySettings = { - positionStrategy: new ConnectedPositioningStrategy({ + positionStrategy: new AutoPositionStrategy({ horizontalDirection: HorizontalAlignment.Left, horizontalStartPoint: HorizontalAlignment.Right, verticalDirection: VerticalAlignment.Bottom, 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 7195fcbf71b..3f435bdb30e 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 @@ -90,6 +90,7 @@ (dataChanging)="dataRebinding($event)" (dataChanged)="dataRebound($event)"> diff --git a/projects/igniteui-angular/src/lib/services/csv/csv-verification-wrapper.spec.ts b/projects/igniteui-angular/src/lib/services/csv/csv-verification-wrapper.spec.ts index 105cd01b132..65639433acc 100644 --- a/projects/igniteui-angular/src/lib/services/csv/csv-verification-wrapper.spec.ts +++ b/projects/igniteui-angular/src/lib/services/csv/csv-verification-wrapper.spec.ts @@ -291,9 +291,9 @@ export class CSVWrapper { public get pivotGridData() { return `ProductCategory${this._delimiter}Bulgaria${this._delimiter}USA${this._delimiter}Uruguay${this._eor}` + - `Accessories${this._delimiter}${this._delimiter}293${this._delimiter}${this._eor}` + - `Bikes${this._delimiter}${this._delimiter}${this._delimiter}68${this._eor}` + `Clothing${this._delimiter}774${this._delimiter}296${this._delimiter}456${this._eor}` + + `Bikes${this._delimiter}${this._delimiter}${this._delimiter}68${this._eor}` + + `Accessories${this._delimiter}${this._delimiter}293${this._delimiter}${this._eor}` + `Components${this._delimiter}${this._delimiter}240${this._delimiter}${this._eor}`; } } diff --git a/projects/igniteui-angular/src/lib/services/excel/test-data.service.spec.ts b/projects/igniteui-angular/src/lib/services/excel/test-data.service.spec.ts index 07322d9553e..97ed95aeb96 100644 --- a/projects/igniteui-angular/src/lib/services/excel/test-data.service.spec.ts +++ b/projects/igniteui-angular/src/lib/services/excel/test-data.service.spec.ts @@ -1292,7 +1292,6 @@ export class FileContentData {
    012345657891011121311141524253637 `; - return this.createData(); } @@ -1658,7 +1657,7 @@ export class FileContentData { public get exportPivotGridData() { this._sharedStringsData = - `count="38" uniqueCount="23">AccessoriesBikesClothingComponentsUSAUruguayBulgaria04/07/202101/06/202001/01/202102/19/202001/05/201905/12/202012/08/2021StanleyElisaLydiaDavidJohnLarryWalterUnitsSoldUnitPrice`; + `count="38" uniqueCount="23">ClothingBikesAccessoriesComponentsBulgariaUSAUruguay01/01/202102/19/202001/05/201905/12/202001/06/202004/07/202112/08/2021StanleyElisaLydiaDavidJohnLarryWalterUnitsSoldUnitPrice`; this._worksheetData = ` @@ -1666,38 +1665,44 @@ export class FileContentData { topLeftCell="D1" activePane="topRight" state="frozen"/> - 14151617181920212221222122212221222122212204729385.58158683.5626928212.811049216.0541129649.5751245668.33341324018.13 `; + 14151617181920212221222122212221222122212204728212.81849216.055929649.5761045668.331611683.56251229385.58351324018.13 `; return this.createData(); } public get exportPivotGridDataWithHeaders() { this._sharedStringsData = - `count="41" uniqueCount="26">ProductCategoryAccessoriesBikesClothingComponentsCountryUSAUruguayBulgariaDate04/07/202101/06/202001/01/202102/19/202001/05/201905/12/202012/08/2021StanleyElisaLydiaDavidJohnLarryWalterUnitsSoldUnitPrice`; + `count="41" uniqueCount="26">ProductCategoryClothingBikesAccessoriesComponentsCountryBulgariaUSAUruguayDate01/01/202102/19/202001/05/201905/12/202001/06/202004/07/202112/08/2021StanleyElisaLydiaDavidJohnLarryWalterUnitsSoldUnitPrice`; this._worksheetData = ` + topLeftCell="D1" activePane="topRight" state="frozen"/> - 059171819202122232425242524252425242524252425161029385.582711683.56381228212.811349216.0561429649.5771545668.33461624018.13 `; + 059171819202122232425242524252425242524252425161028212.811149216.0571229649.5781345668.332814683.56371529385.58471624018.13 `; return this.createData(); } public get exportPivotGridDataHorizontal() { this._sharedStringsData = - `count="41" uniqueCount="26">ProductCategoryAccessoriesBikesClothingComponentsCountryUSAUruguayBulgariaDate04/07/202101/06/202001/01/202102/19/202001/05/201905/12/202012/08/2021StanleyElisaLydiaDavidJohnLarryWalterUnitsSoldUnitPrice`; + `count="41" uniqueCount="26">ProductCategoryClothingBikesAccessoriesComponentsCountryBulgariaUSAUruguayDate01/01/202102/19/202001/05/201905/12/202001/06/202004/07/202112/08/2021StanleyElisaLydiaDavidJohnLarryWalterUnitsSoldUnitPrice`; this._worksheetData = - `059171819202122232425242524252425242524252425161029385.582711683.56381228212.811349216.0561429649.5771545668.33461624018.13 `; + ` + + + + + 059171819202122232425242524252425242524252425161028212.811149216.0571229649.5781345668.332814683.56371529385.58471624018.13 `; return this.createData(); } public get exportPivotGridHierarchicalData() { this._sharedStringsData = - `count="40" uniqueCount="19">All CitiesCiudad de la CostaLondonNew YorkPlovdivSofiaYokohamaAllProductsBikesClothingAccessoriesComponentsBulgariaUSUruguayUKJapanUnitsSoldAmount of Sale`; + `count="40" uniqueCount="19">All CitiesPlovdivNew YorkCiudad de la CostaLondonYokohamaSofiaAllProductsClothingBikesAccessoriesComponentsBulgariaUSUruguayUKJapanUnitsSoldAmount of Sale`; this._worksheetData = ` @@ -1706,7 +1711,7 @@ export class FileContentData { topLeftCell="C1" activePane="topRight" state="frozen"/> - 1213141516171817181718171817180777411509.0229614672.7252431400.5629325074.942404351.2868242.0892823612.4229614672.7245631158.481029325074.94114927896.62404351.21752431400.56868242.08945631158.482729325074.941029325074.943729614672.72929614672.72472823612.4292823612.42574927896.6114927896.6672404351.2112404351.2 `; + 1213141516171817181718171817180777411509.0229614672.7252431400.5629325074.942404351.282823612.4229614672.7245631158.48968242.081029325074.94114927896.62404351.2172823612.4282823612.422729614672.72829614672.723752431400.56968242.08845631158.484729325074.941029325074.94572404351.2112404351.2674927896.6114927896.6 `; return this.createData(); } diff --git a/projects/igniteui-angular/src/lib/services/overlay/overlay.ts b/projects/igniteui-angular/src/lib/services/overlay/overlay.ts index 5cfccdf58e7..e8e511fb15c 100644 --- a/projects/igniteui-angular/src/lib/services/overlay/overlay.ts +++ b/projects/igniteui-angular/src/lib/services/overlay/overlay.ts @@ -431,12 +431,18 @@ export class IgxOverlayService implements OnDestroy { } } this.updateSize(info); + const openAnimation = info.settings.positionStrategy.settings.openAnimation; + const closeAnimation = info.settings.positionStrategy.settings.closeAnimation; info.settings.positionStrategy.position( info.elementRef.nativeElement.parentElement, { width: info.initialSize.width, height: info.initialSize.height }, this._document, true, info.settings.target); + if (openAnimation !== info.settings.positionStrategy.settings.openAnimation || + closeAnimation !== info.settings.positionStrategy.settings.closeAnimation){ + this.buildAnimationPlayers(info); + } this.addModalClasses(info); if (info.settings.positionStrategy.settings.openAnimation) { // TODO: should we build players again. This was already done in attach!!! diff --git a/projects/igniteui-angular/src/lib/splitter/splitter.component.spec.ts b/projects/igniteui-angular/src/lib/splitter/splitter.component.spec.ts index 927e7bd8ae4..64132d138d8 100644 --- a/projects/igniteui-angular/src/lib/splitter/splitter.component.spec.ts +++ b/projects/igniteui-angular/src/lib/splitter/splitter.component.spec.ts @@ -490,27 +490,22 @@ describe('IgxSplitter resizing with minSize and browser window is shrinked', () const minSize = parseInt(pane1.minSize); spyOn(splitter, 'onMoveEnd').and.callThrough(); - pane1.size = (splitter.getTotalSize() - parseInt(pane2.size)) + 'px'; - fixture.detectChanges(); - splitterBarComponent.moveStart.emit(pane1); fixture.detectChanges(); splitterBarComponent.movingEnd.emit(splitter.getTotalSize() -minSize); fixture.detectChanges(); splitter.elementRef.nativeElement.style.width = '500px'; - pane2.size = (splitter.getTotalSize() - minSize) + 'px'; fixture.detectChanges(); splitterBarComponent.moveStart.emit(pane1); fixture.detectChanges(); - splitterBarComponent.movingEnd.emit(-400); + splitterBarComponent.movingEnd.emit(-200); fixture.detectChanges(); - const isFullSize = pane1.size === '100%' || pane1.size === (splitter.getTotalSize() + 'px'); - expect(splitter.onMoveEnd).toHaveBeenCalled(); - expect(isFullSize).toBeTruthy(); + expect(pane1.size).toEqual('80%'); + expect(pane2.size).toEqual('100px'); }); }); diff --git a/projects/igniteui-angular/src/lib/splitter/splitter.component.ts b/projects/igniteui-angular/src/lib/splitter/splitter.component.ts index 6ca08559410..499ed735409 100644 --- a/projects/igniteui-angular/src/lib/splitter/splitter.component.ts +++ b/projects/igniteui-angular/src/lib/splitter/splitter.component.ts @@ -244,11 +244,9 @@ export class IgxSplitterComponent implements AfterContentInit { let [ paneSize, siblingSize ] = this.calcNewSizes(delta); if (paneSize + siblingSize > this.getTotalSize() && delta < 0) { - paneSize = this.getTotalSize(); - siblingSize = 0; - } else if(paneSize + siblingSize > this.getTotalSize() && delta > 0) { - paneSize = 0; - siblingSize = this.getTotalSize(); + siblingSize = this.getTotalSize() - paneSize; + } else if (paneSize + siblingSize > this.getTotalSize() && delta > 0) { + paneSize = this.getTotalSize() - siblingSize; } if (this.pane.isPercentageSize) { diff --git a/projects/igniteui-angular/src/lib/test-utils/tooltip-components.spec.ts b/projects/igniteui-angular/src/lib/test-utils/tooltip-components.spec.ts index 89254e52c70..0f8e8cb4265 100644 --- a/projects/igniteui-angular/src/lib/test-utils/tooltip-components.spec.ts +++ b/projects/igniteui-angular/src/lib/test-utils/tooltip-components.spec.ts @@ -149,3 +149,23 @@ export class IgxTooltipWithCloseButtonComponent { @ViewChild(IgxTooltipDirective, { static: true }) public tooltip: IgxTooltipDirective; @ViewChild(IgxTooltipTargetDirective, { static: true }) public tooltipTarget: IgxTooltipTargetDirective; } + +@Component({ + template: ` + + +
    +
    + Nested content +
    +
    + `, + imports: [IgxTooltipDirective, IgxTooltipTargetDirective], + standalone: true +}) +export class IgxTooltipWithNestedContentComponent { + @ViewChild(IgxTooltipDirective, { static: true }) public tooltip!: IgxTooltipDirective; + @ViewChild(IgxTooltipTargetDirective, { static: true }) public tooltipTarget!: IgxTooltipTargetDirective; +} diff --git a/projects/igniteui-angular/stepper/README.md b/projects/igniteui-angular/stepper/README.md new file mode 100644 index 00000000000..725712c6178 --- /dev/null +++ b/projects/igniteui-angular/stepper/README.md @@ -0,0 +1,123 @@ +# IgxStepperComponent + +## Description +_**IgxStepperComponent** is a collection of **IgxStepComponent**s that delivers a wizard-like workflow:_ + +A complete walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/stepper). +The specification for the stepper can be found [here](https://github.com/IgniteUI/igniteui-angular/wiki/Stepper-Specification) + +---------- + +## Usage +```html + + + + {{step.indicator}} + + +

    + {{step.title}} +

    + +
    + ... +
    +
    +
    +``` + +---------- + +## Keyboard Navigation + +The keyboard can be used to navigate through all steps in the stpper. + +_Disabled steps are not counted as visible steps for the purpose of keyboard navigation._ + +|Keys |Description| +|---------------|-----------| +| ARROW DOWN | Focuses the next step header in a vertical stepper. | +| ARROW UP | Focuses the previous step header in a vertical stepper. | +| TAB | Moves the focus to the next tabbable element. | +| SHIFT + TAB | Moves the focus to the previous tabbable element. | +| HOME | Moves the focus to the header of the FIRST enabled step in the _igx-stepper_ | +| END | Moves the focus to the header of the LAST enabled step in the _igx-stepper_ | +| ARROW RIGHT | Moves the focus to the header of the next accessible step in both orientations. | +| ARROW LEFT | Moves the focus to the header of the previous accessible step in both orientations. | +| ENTER / SPACE | Activates the currently focused step. | +| CLICK | Activates the currently focused step. | + +_By design when the user presses the **Tab** key over the step header the focus will move to the step content container. In case the container should be skipped the developer should set the content container [tabIndex]="-1"_ + +---------- + +## API Summary + +### IgxStepperComponent + +#### Accessors + +**Get** + + | Name | Description | Type | + |----------------|------------------------------------------------------------------------------|---------------------| + | steps | Gets the steps that are rendered in the stepper. | `IgxStepComponent[]` | + + +#### Properties + + | Name | Description | Type | + |----------------|------------------------------------------------------------------------------|----------------------------------------| + | id | The id of the stepper. Bound to attr.id | `string` | + | orientation | Gets/sets the orientation of the stepper. Default is `horizontal`. | `IgxStepperOrientation` | + | stepType| Gets/sets the type of the steps in the stepper. Default value is `full` | `IgxStepType` | + | titlePosition | Gets/sets the position of the titles in the stepper. Default value is `bottom` when the stepper is horizontally orientated and `end` when the layout is set to vertical. | `IgxStepperTitlePosition` | + | linear | Whether the validity of previous steps should be checked and only in case, it's valid to be able to move forward or not. Default value is `false`. | `boolean` | + | contentTop| Whether the steps content should be displayed above the steps header when the stepper orientation is Horizontal. Default value is `false`. | `boolean` | + | verticalAnimationType | Gets/sets the animation type of the stepper when the orientation direction is vertical. Default value is `grow`. | `VerticalAnimationType` | + | horizontalAnimationType | Gets/sets the animation type of the stepper when the orientation direction is horizontal. Default value is `slide`. |`HorizontalAnimationType` | + | animationDuration | 320 | `number` | + +#### Methods + | Name | Description | Parameters | Returns | + |-----------------|----------------------------|-------------------------|--------| + | navigateTo | Activates the step given by index. | `index: number` | `void` | + | next | Activates the next enabled step. | | `void` | + | prev | Activates the previous enabled step. | | `void` | + | reset | Resets the stepper to its initial state. | | `void` | + +#### Events + + | Name | Description | Cancelable | Arguments | + |----------------|-------------------------------------------------------------------------|------------|------------| + | activeStepChanging | Emitted when the active step is about to change. | true | `{ oldIndex: number, newIndex: number, owner: IgxStepperComponent, cancel: boolean }` | + | activeStepChanged | Emitted when the active step is changed. | false | `{ index: number, owner: IgxStepperComponent }` | +### IgxStepComponent + +#### Accessors + +**Get** + + | Name | Description | Type | + |-----------------|-------------------------------------------------------------------------------|---------------------| + | index | Gets the step index inside of the stepper. | `number` | + +#### Properties + + | Name | Description | Type | + |-----------------|-------------------------------------------------------------------------------|---------------------| + | id | The id of the step. Bound to attr.id | `string` | + | disabled | Gets/sets whether the step is interactable. | `boolean` | + | active | Gets/sets whether the step is activе. Two-way data binding. | `boolean` | + | optional | Gets/sets whether the step is optional. | `boolean` | + | complete | Gets/sets whether the step is completed. | `boolean` | + | isValid | Gets/sets whether the step is valid. Default value is `true`. | `boolean` | + +#### Events + + | Name | Description | Cancelable | Parameters | + |-----------------|-------------------------------------------------------------------------------|------------|---------| + | activeChange | Emitted when the step's active property changes | false | `boolean` | + + diff --git a/projects/igniteui-angular/stepper/index.ts b/projects/igniteui-angular/stepper/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/stepper/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/stepper/ng-package.json b/projects/igniteui-angular/stepper/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/stepper/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/stepper/src/public_api.ts b/projects/igniteui-angular/stepper/src/public_api.ts new file mode 100644 index 00000000000..704384a4fa0 --- /dev/null +++ b/projects/igniteui-angular/stepper/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './stepper/public_api'; +export * from './stepper/stepper.module'; diff --git a/projects/igniteui-angular/stepper/src/stepper/public_api.ts b/projects/igniteui-angular/stepper/src/stepper/public_api.ts new file mode 100644 index 00000000000..c5a11a9034e --- /dev/null +++ b/projects/igniteui-angular/stepper/src/stepper/public_api.ts @@ -0,0 +1,29 @@ +import { IgxStepComponent } from './step/step.component'; +import { IgxStepperComponent } from './stepper.component'; +import { IgxStepActiveIndicatorDirective, IgxStepCompletedIndicatorDirective, IgxStepContentDirective, IgxStepIndicatorDirective, IgxStepInvalidIndicatorDirective, IgxStepSubtitleDirective, IgxStepTitleDirective } from './stepper.directive'; + +export * from './stepper.component'; +export * from './step/step.component'; +export { + HorizontalAnimationType, + IStepChangingEventArgs, + IStepChangedEventArgs, + IgxStepperOrientation, + IgxStepType, + IgxStepperTitlePosition, + VerticalAnimationType +} from './stepper.common'; +export * from './stepper.directive'; + +/* NOTE: Stepper directives collection for ease-of-use import in standalone components scenario */ +export const IGX_STEPPER_DIRECTIVES = [ + IgxStepComponent, + IgxStepperComponent, + IgxStepTitleDirective, + IgxStepSubtitleDirective, + IgxStepIndicatorDirective, + IgxStepContentDirective, + IgxStepActiveIndicatorDirective, + IgxStepCompletedIndicatorDirective, + IgxStepInvalidIndicatorDirective +] as const; diff --git a/projects/igniteui-angular/stepper/src/stepper/step/step.component.html b/projects/igniteui-angular/stepper/src/stepper/step/step.component.html new file mode 100644 index 00000000000..48dd55aed14 --- /dev/null +++ b/projects/igniteui-angular/stepper/src/stepper/step/step.component.html @@ -0,0 +1,44 @@ + + @if (isTitleVisible) { + + } + @if (isTitleVisible) { + + } + + + +
    + @if (active || collapsing) { + + } +
    +
    + + + {{ index + 1 }} + + + + + + +
    + + @if (isIndicatorVisible) { +
    + +
    + } + +
    + +
    +
    + +@if (!isHorizontal) { +
    + +
    +} diff --git a/projects/igniteui-angular/stepper/src/stepper/step/step.component.ts b/projects/igniteui-angular/stepper/src/stepper/step/step.component.ts new file mode 100644 index 00000000000..a2c09f9f43d --- /dev/null +++ b/projects/igniteui-angular/stepper/src/stepper/step/step.component.ts @@ -0,0 +1,544 @@ +import { AfterViewInit, booleanAttribute, ChangeDetectorRef, Component, ContentChild, ElementRef, EventEmitter, forwardRef, HostBinding, HostListener, Input, OnDestroy, Output, Renderer2, TemplateRef, ViewChild, inject } from '@angular/core'; +import { takeUntil } from 'rxjs/operators'; +import { IgxStep, IgxStepper, IgxStepperOrientation, IgxStepType, IGX_STEPPER_COMPONENT, IGX_STEP_COMPONENT, HorizontalAnimationType } from '../stepper.common'; +import { IgxStepContentDirective, IgxStepIndicatorDirective } from '../stepper.directive'; +import { IgxStepperService } from '../stepper.service'; +import { NgClass, NgTemplateOutlet } from '@angular/common'; +import { IgxRippleDirective } from 'igniteui-angular/directives'; +import { ToggleAnimationPlayer, ToggleAnimationSettings } from 'igniteui-angular/expansion-panel'; +import { CarouselAnimationDirection, IgxSlideComponentBase } from 'igniteui-angular/carousel'; +import { ɵIgxDirectionality, PlatformUtil } from 'igniteui-angular/core'; + +let NEXT_ID = 0; + +/** + * The IgxStepComponent is used within the `igx-stepper` element and it holds the content of each step. + * It also supports custom indicators, title and subtitle. + * + * @igxModule IgxStepperModule + * + * @igxKeywords step + * + * @example + * ```html + * + * ... + * + * ... + * + * ... + * + * ``` + */ +@Component({ + selector: 'igx-step', + templateUrl: 'step.component.html', + providers: [ + { provide: IGX_STEP_COMPONENT, useExisting: IgxStepComponent } + ], + imports: [NgClass, IgxRippleDirective, NgTemplateOutlet] +}) +export class IgxStepComponent extends ToggleAnimationPlayer implements IgxStep, AfterViewInit, OnDestroy, IgxSlideComponentBase { + public stepper = inject(IGX_STEPPER_COMPONENT); + public cdr = inject(ChangeDetectorRef); + public renderer = inject(Renderer2); + protected platform = inject(PlatformUtil); + protected stepperService = inject(IgxStepperService); + private element = inject>(ElementRef); + private dir = inject(ɵIgxDirectionality); + + + /** + * Get/Set the `id` of the step component. + * Default value is `"igx-step-0"`; + * ```html + * + * ``` + * ```typescript + * const stepId = this.step.id; + * ``` + */ + @HostBinding('attr.id') + @Input() + public id = `igx-step-${NEXT_ID++}`; + + /** + * Get/Set whether the step is interactable. + * + * ```html + * + * ... + * + * ... + * + * ``` + * + * ```typescript + * this.stepper.steps[1].disabled = true; + * ``` + */ + @Input({ transform: booleanAttribute }) + public set disabled(value: boolean) { + this._disabled = value; + if (this.stepper.linear) { + this.stepperService.calculateLinearDisabledSteps(); + } + } + + public get disabled(): boolean { + return this._disabled; + } + + /** + * Get/Set whether the step is completed. + * + * @remarks + * When set to `true` the following separator is styled `solid`. + * + * ```html + * + * ... + * + * ... + * + * ``` + * + * ```typescript + * this.stepper.steps[1].completed = true; + * ``` + */ + @Input({ transform: booleanAttribute }) + @HostBinding('class.igx-stepper__step--completed') + public completed = false; + + /** + * Get/Set whether the step is valid. + *```html + * + * ... + *
    + *
    + * ... + * + *
    + *
    + * ``` + */ + @Input({ transform: booleanAttribute }) + public get isValid(): boolean { + return this._valid; + } + + public set isValid(value: boolean) { + this._valid = value; + if (this.stepper.linear && this.index !== undefined) { + this.stepperService.calculateLinearDisabledSteps(); + } + } + + /** + * Get/Set whether the step is optional. + * + * @remarks + * Optional steps validity does not affect the default behavior when the stepper is in linear mode i.e. + * if optional step is invalid the user could still move to the next step. + * + * ```html + * + * ``` + * ```typescript + * this.stepper.steps[1].optional = true; + * ``` + */ + @Input({ transform: booleanAttribute }) + public optional = false; + + /** + * Get/Set the active state of the step + * + * ```html + * + * ``` + * + * ```typescript + * this.stepper.steps[1].active = true; + * ``` + * + * @param value: boolean + */ + @HostBinding('attr.aria-selected') + @Input({ transform: booleanAttribute }) + public set active(value: boolean) { + if (value) { + this.stepperService.expandThroughApi(this); + } else { + this.stepperService.collapse(this); + } + } + + public get active(): boolean { + return this.stepperService.activeStep === this; + } + + /** @hidden @internal */ + @HostBinding('attr.tabindex') + @Input() + public set tabIndex(value: number) { + this._tabIndex = value; + } + + public get tabIndex(): number { + return this._tabIndex; + } + + /** @hidden @internal **/ + @HostBinding('attr.role') + public role = 'tab'; + + /** @hidden @internal */ + @HostBinding('attr.aria-controls') + public get contentId(): string { + return this.content?.id; + } + + /** @hidden @internal */ + @HostBinding('class.igx-stepper__step') + public cssClass = true; + + /** @hidden @internal */ + @HostBinding('class.igx-stepper__step--disabled') + public get generalDisabled(): boolean { + return this.disabled || this.linearDisabled; + } + + /** @hidden @internal */ + @HostBinding('class') + public get titlePositionTop(): string { + if (this.stepper.stepType !== IgxStepType.Full) { + return 'igx-stepper__step--simple'; + } + + return `igx-stepper__step--${this.titlePosition}`; + } + + /** + * Emitted when the step's `active` property changes. Can be used for two-way binding. + * + * ```html + * + * + * ``` + * + * ```typescript + * const step: IgxStepComponent = this.stepper.step[0]; + * step.activeChange.subscribe((e: boolean) => console.log("Step active state change to ", e)) + * ``` + */ + @Output() + public activeChange = new EventEmitter(); + + /** @hidden @internal */ + @ViewChild('contentTemplate', { static: true }) + public contentTemplate: TemplateRef; + + /** @hidden @internal */ + @ViewChild('customIndicator', { static: true }) + public customIndicatorTemplate: TemplateRef; + + /** @hidden @internal */ + @ViewChild('contentContainer') + public contentContainer: ElementRef; + + /** @hidden @internal */ + @ContentChild(forwardRef(() => IgxStepIndicatorDirective)) + public indicator: IgxStepIndicatorDirective; + + /** @hidden @internal */ + @ContentChild(forwardRef(() => IgxStepContentDirective)) + public content: IgxStepContentDirective; + + /** + * Get the step index inside of the stepper. + * + * ```typescript + * const step = this.stepper.steps[1]; + * const stepIndex: number = step.index; + * ``` + */ + public get index(): number { + return this._index; + } + + /** @hidden @internal */ + public get indicatorTemplate(): TemplateRef { + if (this.active && this.stepper.activeIndicatorTemplate) { + return this.stepper.activeIndicatorTemplate; + } + + if (!this.isValid && this.stepper.invalidIndicatorTemplate) { + return this.stepper.invalidIndicatorTemplate; + } + + if (this.completed && this.stepper.completedIndicatorTemplate) { + return this.stepper.completedIndicatorTemplate; + } + + if (this.indicator) { + return this.customIndicatorTemplate; + } + + return null; + } + + /** @hidden @internal */ + public get direction(): CarouselAnimationDirection { + return this.stepperService.previousActiveStep + && this.stepperService.previousActiveStep.index > this.index + ? CarouselAnimationDirection.PREV + : CarouselAnimationDirection.NEXT; + } + + /** @hidden @internal */ + public get isAccessible(): boolean { + return !this.disabled && !this.linearDisabled; + } + + /** @hidden @internal */ + public get isHorizontal(): boolean { + return this.stepper.orientation === IgxStepperOrientation.Horizontal; + } + + /** @hidden @internal */ + public get isTitleVisible(): boolean { + return this.stepper.stepType !== IgxStepType.Indicator; + } + + /** @hidden @internal */ + public get isIndicatorVisible(): boolean { + return this.stepper.stepType !== IgxStepType.Title; + } + + /** @hidden @internal */ + public get titlePosition(): string { + return this.stepper.titlePosition ? this.stepper.titlePosition : this.stepper._defaultTitlePosition; + } + + /** @hidden @internal */ + public get linearDisabled(): boolean { + return this.stepperService.linearDisabledSteps.has(this); + } + + /** @hidden @internal */ + public get collapsing(): boolean { + return this.stepperService.collapsingSteps.has(this); + } + + /** @hidden @internal */ + public override get animationSettings(): ToggleAnimationSettings { + return this.stepper.verticalAnimationSettings; + } + + /** @hidden @internal */ + public get contentClasses(): any { + if (this.isHorizontal) { + return { 'igx-stepper__body-content': true, 'igx-stepper__body-content--active': this.active }; + } else { + return 'igx-stepper__step-content'; + } + } + + /** @hidden @internal */ + public get stepHeaderClasses(): any { + return { + 'igx-stepper__step--optional': this.optional, + 'igx-stepper__step-header--current': this.active, + 'igx-stepper__step-header--invalid': !this.isValid + && this.stepperService.visitedSteps.has(this) && !this.active && this.isAccessible + }; + } + + /** @hidden @internal */ + public get nativeElement(): HTMLElement { + return this.element.nativeElement; + } + /** @hidden @internal */ + public previous: boolean; + /** @hidden @internal */ + public _index: number; + private _tabIndex = -1; + private _valid = true; + private _focused = false; + private _disabled = false; + + /** @hidden @internal */ + @HostListener('focus') + public onFocus(): void { + this._focused = true; + this.stepperService.focusedStep = this; + if (this.stepperService.focusedStep !== this.stepperService.activeStep) { + this.stepperService.activeStep.tabIndex = -1; + } + } + + /** @hidden @internal */ + @HostListener('blur') + public onBlur(): void { + this._focused = false; + this.stepperService.activeStep.tabIndex = 0; + } + + /** @hidden @internal */ + @HostListener('keydown', ['$event']) + public handleKeydown(event: KeyboardEvent): void { + if (!this._focused) { + return; + } + const key = event.key; + if (this.stepper.orientation === IgxStepperOrientation.Horizontal) { + if (key === this.platform.KEYMAP.ARROW_UP || key === this.platform.KEYMAP.ARROW_DOWN) { + return; + } + } + if (!(this.platform.isNavigationKey(key) || this.platform.isActivationKey(event))) { + return; + } + event.preventDefault(); + this.handleNavigation(key); + } + + /** @hidden @internal */ + public ngAfterViewInit(): void { + this.openAnimationDone.pipe(takeUntil(this.destroy$)).subscribe( + () => { + if (this.stepperService.activeStep === this) { + this.stepper.activeStepChanged.emit({ owner: this.stepper, index: this.index }); + } + } + ); + this.closeAnimationDone.pipe(takeUntil(this.destroy$)).subscribe(() => { + this.stepperService.collapse(this); + this.cdr.markForCheck(); + }); + } + + /** @hidden @internal */ + public onPointerDown(event: MouseEvent): void { + event.stopPropagation(); + if (this.isHorizontal) { + this.changeHorizontalActiveStep(); + } else { + this.changeVerticalActiveStep(); + } + } + + /** @hidden @internal */ + public handleNavigation(key: string): void { + switch (key) { + case this.platform.KEYMAP.HOME: + this.stepper.steps.filter(s => s.isAccessible)[0]?.nativeElement.focus(); + break; + case this.platform.KEYMAP.END: + this.stepper.steps.filter(s => s.isAccessible).pop()?.nativeElement.focus(); + break; + case this.platform.KEYMAP.ARROW_UP: + this.previousStep?.nativeElement.focus(); + break; + case this.platform.KEYMAP.ARROW_LEFT: + if (this.dir.rtl && this.stepper.orientation === IgxStepperOrientation.Horizontal) { + this.nextStep?.nativeElement.focus(); + } else { + this.previousStep?.nativeElement.focus(); + } + break; + case this.platform.KEYMAP.ARROW_DOWN: + this.nextStep?.nativeElement.focus(); + break; + case this.platform.KEYMAP.ARROW_RIGHT: + if (this.dir.rtl && this.stepper.orientation === IgxStepperOrientation.Horizontal) { + this.previousStep?.nativeElement.focus(); + } else { + this.nextStep?.nativeElement.focus(); + } + break; + case this.platform.KEYMAP.SPACE: + case this.platform.KEYMAP.ENTER: + if (this.isHorizontal) { + this.changeHorizontalActiveStep(); + } else { + this.changeVerticalActiveStep(); + } + break; + default: + return; + } + } + + /** @hidden @internal */ + public changeHorizontalActiveStep(): void { + if (this.stepper.animationType === HorizontalAnimationType.none && this.stepperService.activeStep !== this) { + const argsCanceled = this.stepperService.emitActivatingEvent(this); + if (argsCanceled) { + return; + } + + this.active = true; + this.stepper.activeStepChanged.emit({ owner: this.stepper, index: this.index }); + return; + } + this.stepperService.expand(this); + if (this.stepper.animationType === HorizontalAnimationType.fade) { + if (this.stepperService.collapsingSteps.has(this.stepperService.previousActiveStep)) { + this.stepperService.previousActiveStep.active = false; + } + } + } + + private get nextStep(): IgxStepComponent | null { + const focusedStep = this.stepperService.focusedStep; + if (focusedStep) { + if (focusedStep.index === this.stepper.steps.length - 1) { + return this.stepper.steps.find(s => s.isAccessible); + } + + const nextAccessible = this.stepper.steps.find((s, i) => i > focusedStep.index && s.isAccessible); + return nextAccessible ? nextAccessible : this.stepper.steps.find(s => s.isAccessible); + } + + return null; + } + + private get previousStep(): IgxStepComponent | null { + const focusedStep = this.stepperService.focusedStep; + if (focusedStep) { + if (focusedStep.index === 0) { + return this.stepper.steps.filter(s => s.isAccessible).pop(); + } + + let prevStep; + for (let i = focusedStep.index - 1; i >= 0; i--) { + const step = this.stepper.steps[i]; + if (step.isAccessible) { + prevStep = step; + break; + } + } + + return prevStep ? prevStep : this.stepper.steps.filter(s => s.isAccessible).pop(); + + } + + return null; + } + + private changeVerticalActiveStep(): void { + this.stepperService.expand(this); + + if (!this.animationSettings.closeAnimation) { + this.stepperService.previousActiveStep?.openAnimationPlayer?.finish(); + } + + if (!this.animationSettings.openAnimation) { + this.stepperService.activeStep.closeAnimationPlayer?.finish(); + } + } +} diff --git a/projects/igniteui-angular/stepper/src/stepper/stepper.common.ts b/projects/igniteui-angular/stepper/src/stepper/stepper.common.ts new file mode 100644 index 00000000000..0dd312e3255 --- /dev/null +++ b/projects/igniteui-angular/stepper/src/stepper/stepper.common.ts @@ -0,0 +1,152 @@ +import { ChangeDetectorRef, ElementRef, EventEmitter, InjectionToken, TemplateRef } from '@angular/core'; +import { IBaseCancelableBrowserEventArgs, IBaseEventArgs } from 'igniteui-angular/core'; +import { IgxStepperComponent } from './stepper.component'; +import { IgxStepComponent } from './step/step.component'; +import { + IgxStepActiveIndicatorDirective, IgxStepCompletedIndicatorDirective, IgxStepContentDirective, + IgxStepIndicatorDirective, IgxStepInvalidIndicatorDirective +} from './stepper.directive'; +import { ToggleAnimationPlayer, ToggleAnimationSettings } from 'igniteui-angular/expansion-panel'; +import { CarouselAnimationType, IgxCarouselComponentBase, CarouselAnimationDirection } from 'igniteui-angular/carousel'; + +// Component interfaces +export interface IgxStepper extends IgxCarouselComponentBase { + steps: IgxStepComponent[]; + /** @hidden @internal */ + nativeElement: HTMLElement; + /** @hidden @internal */ + invalidIndicatorTemplate: TemplateRef; + /** @hidden @internal */ + completedIndicatorTemplate: TemplateRef; + /** @hidden @internal */ + activeIndicatorTemplate: TemplateRef; + verticalAnimationType: VerticalAnimationType; + horizontalAnimationType: HorizontalAnimationType; + animationDuration: number; + linear: boolean; + orientation: IgxStepperOrientation; + stepType: IgxStepType; + contentTop: boolean; + titlePosition: IgxStepperTitlePosition; + /** @hidden @internal */ + verticalAnimationSettings: ToggleAnimationSettings; + /** @hidden @internal */ + _defaultTitlePosition: IgxStepperTitlePosition; + activeStepChanging: EventEmitter; + activeStepChanged: EventEmitter; + navigateTo(index: number): void; + next(): void; + prev(): void; + reset(): void; + /** @hidden @internal */ + playHorizontalAnimations(): void; +} + +// Item interfaces +export interface IgxStep extends ToggleAnimationPlayer { + id: string; + /** @hidden @internal */ + contentTemplate: TemplateRef; + /** @hidden @internal */ + customIndicatorTemplate: TemplateRef; + /** @hidden @internal */ + contentContainer: ElementRef; + /** @hidden @internal */ + indicator: IgxStepIndicatorDirective; + /** @hidden @internal */ + content: IgxStepContentDirective; + /** @hidden @internal */ + indicatorTemplate: TemplateRef; + index: number; + disabled: boolean; + completed: boolean; + isValid: boolean; + optional: boolean; + active: boolean; + tabIndex: number; + /** @hidden @internal */ + contentId: string; + /** @hidden @internal */ + generalDisabled: boolean; + /** @hidden @internal */ + titlePositionTop: string; + /** @hidden @internal */ + direction: CarouselAnimationDirection; + /** @hidden @internal */ + isAccessible: boolean; + /** @hidden @internal */ + isHorizontal: boolean; + /** @hidden @internal */ + isTitleVisible: boolean; + /** @hidden @internal */ + isIndicatorVisible: boolean; + /** @hidden @internal */ + titlePosition: string; + /** @hidden @internal */ + linearDisabled: boolean; + /** @hidden @internal */ + collapsing: boolean; + /** @hidden @internal */ + animationSettings: ToggleAnimationSettings; + /** @hidden @internal */ + contentClasses: any; + /** @hidden @internal */ + stepHeaderClasses: any; + /** @hidden @internal */ + nativeElement: HTMLElement; + /** @hidden @internal */ + previous: boolean; + cdr: ChangeDetectorRef; + activeChange: EventEmitter; +} + +// Events +export interface IStepChangingEventArgs extends IBaseEventArgs, IBaseCancelableBrowserEventArgs { + newIndex: number; + oldIndex: number; + owner: IgxStepper; +} + +export interface IStepChangedEventArgs extends IBaseEventArgs { + // Provides the index of the current active step within the stepper steps + index: number; + owner: IgxStepper; +} + +// Enums +export const IgxStepperOrientation = { + Horizontal: 'horizontal', + Vertical: 'vertical' +} as const; +export type IgxStepperOrientation = (typeof IgxStepperOrientation)[keyof typeof IgxStepperOrientation]; + +export const IgxStepType = { + Indicator: 'indicator', + Title: 'title', + Full: 'full' +} as const; +export type IgxStepType = (typeof IgxStepType)[keyof typeof IgxStepType]; + +export const IgxStepperTitlePosition = { + Bottom: 'bottom', + Top: 'top', + End: 'end', + Start: 'start' +} as const; +export type IgxStepperTitlePosition = (typeof IgxStepperTitlePosition)[keyof typeof IgxStepperTitlePosition]; + +export const VerticalAnimationType = { + Grow: 'grow', + Fade: 'fade', + None: 'none' +} as const; +export type VerticalAnimationType = (typeof VerticalAnimationType)[keyof typeof VerticalAnimationType]; + +export const HorizontalAnimationType = { + ...CarouselAnimationType +} as const; +export type HorizontalAnimationType = (typeof HorizontalAnimationType)[keyof typeof HorizontalAnimationType]; + +// Token +export const IGX_STEPPER_COMPONENT = /*@__PURE__*/new InjectionToken('IgxStepperToken'); +export const IGX_STEP_COMPONENT = /*@__PURE__*/new InjectionToken('IgxStepToken'); diff --git a/projects/igniteui-angular/stepper/src/stepper/stepper.component.html b/projects/igniteui-angular/stepper/src/stepper/stepper.component.html new file mode 100644 index 00000000000..f6315d58910 --- /dev/null +++ b/projects/igniteui-angular/stepper/src/stepper/stepper.component.html @@ -0,0 +1,23 @@ +@if (!contentTop || orientation !== 'horizontal') { +
    + +
    +} + +@if (orientation === 'horizontal') { +
    + @for (step of steps; track step) { + + } +
    +} + +@if (contentTop && orientation === 'horizontal') { +
    + +
    +} + + + + diff --git a/projects/igniteui-angular/stepper/src/stepper/stepper.component.spec.ts b/projects/igniteui-angular/stepper/src/stepper/stepper.component.spec.ts new file mode 100644 index 00000000000..5dfafc2feed --- /dev/null +++ b/projects/igniteui-angular/stepper/src/stepper/stepper.component.spec.ts @@ -0,0 +1,1380 @@ +import { AnimationBuilder } from '@angular/animations'; +import { ChangeDetectorRef, Component, ElementRef, Renderer2, ViewChild } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { take } from 'rxjs/operators'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxInputDirective, IgxInputGroupComponent } from '../../../input-group/src/public_api'; +import { IgxAngularAnimationService, PlatformUtil, ɵDirection } from 'igniteui-angular/core'; +import { UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { IgxStepComponent } from './step/step.component'; +import { + HorizontalAnimationType, + IGX_STEPPER_COMPONENT, + IgxStepperOrientation, + IgxStepperTitlePosition, + IgxStepType, + IStepChangedEventArgs, + IStepChangingEventArgs, + VerticalAnimationType +} from './stepper.common'; +import { IgxStepperComponent } from './stepper.component'; +import { IgxStepActiveIndicatorDirective, IgxStepCompletedIndicatorDirective, IgxStepContentDirective, IgxStepIndicatorDirective, IgxStepInvalidIndicatorDirective, IgxStepSubtitleDirective, IgxStepTitleDirective } from './stepper.directive'; +import { IgxStepperService } from './stepper.service'; +import { IgxDirectionality } from 'igniteui-angular/core/src/services/direction/directionality'; + +const STEPPER_CLASS = 'igx-stepper'; +const STEPPER_HEADER = 'igx-stepper__header'; +const STEPPER_BODY = 'igx-stepper__body'; +const STEP_TAG = 'IGX-STEP'; +const STEP_HEADER = 'igx-stepper__step-header'; +const STEP_INDICATOR_CLASS = 'igx-stepper__step-indicator'; +const STEP_TITLE_CLASS = 'igx-stepper__step-title'; +const STEP_SUBTITLE_CLASS = 'igx-stepper__step-subtitle'; +const INVALID_CLASS = 'igx-stepper__step-header--invalid'; +const DISABLED_CLASS = 'igx-stepper__step--disabled'; +const COMPLETED_CLASS = 'igx-stepper__step--completed'; +const CURRENT_CLASS = 'igx-stepper__step-header--current'; + +const getHeaderElements = (stepper: IgxStepperComponent, stepIndex: number): Map => { + const elementsMap = new Map(); + elementsMap.set('indicator', stepper.steps[stepIndex].nativeElement.querySelector(`div.${STEP_INDICATOR_CLASS}`)); + elementsMap.set('title', stepper.steps[stepIndex].nativeElement.querySelector(`.${STEP_TITLE_CLASS}`)); + elementsMap.set('subtitle', stepper.steps[stepIndex].nativeElement.querySelector(`.${STEP_SUBTITLE_CLASS}`)); + return elementsMap; +}; + +const getStepperPositions = (): string[] => { + const positions = []; + Object.values(IgxStepperTitlePosition).forEach((position: IgxStepperTitlePosition) => { + positions.push(position); + }); + return positions; +}; + +const testAnimationBehavior = ( + val: any, + fix: ComponentFixture, + isHorAnimTypeInvalidTest: boolean +): void => { + const stepper = fix.componentInstance.stepper; + stepper.steps[0].active = true; + fix.detectChanges(); + const previousActiveStep = stepper.steps[0]; + const activeChangeSpy = spyOn(previousActiveStep.activeChange, 'emit'); + activeChangeSpy.calls.reset(); + stepper.next(); + fix.detectChanges(); + tick(1000); + if (!isHorAnimTypeInvalidTest) { + expect(previousActiveStep.activeChange.emit).withContext(val).toHaveBeenCalledOnceWith(false); + } else { + expect(previousActiveStep.activeChange.emit).withContext(val).not.toHaveBeenCalled(); + } + activeChangeSpy.calls.reset(); +}; + +describe('Rendering Tests', () => { + let fix: ComponentFixture; + let stepper: IgxStepperComponent; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxStepperSampleTestComponent, + IgxStepperLinearComponent + ] + }).compileComponents(); + }) + ); + beforeEach(() => { + fix = TestBed.createComponent(IgxStepperSampleTestComponent); + fix.detectChanges(); + stepper = fix.componentInstance.stepper; + }); + + describe('General', () => { + it('should render a stepper containing a sequence of steps', () => { + const stepperElement: HTMLElement = fix.debugElement.queryAll(By.css(`${STEPPER_CLASS}`))[0].nativeElement; + const stepperHeader = stepperElement.querySelector(`.${STEPPER_HEADER}`); + const steps = Array.from(stepperHeader.children); + expect(steps.length).toBe(5); + for (const step of steps) { + expect(step.tagName === STEP_TAG).toBeTruthy(); + } + }); + + it('should not allow activating a step with next/prev methods when disabled is set to true', fakeAsync(() => { + const serviceExpandSpy = spyOn((stepper as any).stepperService, 'expand').and.callThrough(); + const serviceCollapseSpy = spyOn((stepper as any).stepperService, 'collapse').and.callThrough(); + stepper.orientation = IgxStepperOrientation.Horizontal; + stepper.steps[0].active = true; + stepper.steps[1].disabled = true; + fix.detectChanges(); + tick(); + + expect(stepper.steps[1].nativeElement).toHaveClass('igx-stepper__step--disabled'); + + stepper.next(); + fix.detectChanges(); + tick(350); + + expect(stepper.steps[1].active).toBeFalsy(); + expect(stepper.steps[2].isAccessible).toBeTruthy(); + expect(stepper.steps[2].active).toBeTruthy(); + expect(serviceExpandSpy).toHaveBeenCalledOnceWith(stepper.steps[2]); + expect(serviceCollapseSpy).toHaveBeenCalledOnceWith(stepper.steps[0]); + + serviceExpandSpy.calls.reset(); + serviceCollapseSpy.calls.reset(); + + stepper.orientation = IgxStepperOrientation.Vertical; + stepper.steps[0].active = true; + stepper.steps[1].disabled = true; + fix.detectChanges(); + tick(); + + stepper.next(); + fix.detectChanges(); + tick(350); + + expect(stepper.steps[1].active).toBeFalsy(); + expect(stepper.steps[2].isAccessible).toBeTruthy(); + expect(stepper.steps[2].active).toBeTruthy(); + expect(serviceExpandSpy).toHaveBeenCalledOnceWith(stepper.steps[2]); + expect(serviceCollapseSpy).toHaveBeenCalledOnceWith(stepper.steps[0]); + })); + + it('should calculate disabled steps properly when the stepper is initially in linear mode', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxStepperLinearComponent); + fixture.detectChanges(); + const linearStepper = fixture.componentInstance.stepper; + + const serviceExpandSpy = spyOn((linearStepper as any).stepperService, 'expand').and.callThrough(); + linearStepper.next(); + fixture.detectChanges(); + tick(); + + expect(linearStepper.steps[1].active).toBeFalsy(); + expect(linearStepper.steps[0].active).toBeTruthy(); + expect(linearStepper.steps[1].linearDisabled).toBeTruthy(); + expect(serviceExpandSpy).not.toHaveBeenCalled(); + })); + + it('should not allow moving forward to next step in linear mode if the previous step is invalid', fakeAsync(() => { + const serviceExpandSpy = spyOn((stepper as any).stepperService, 'expand').and.callThrough(); + stepper.orientation = IgxStepperOrientation.Horizontal; + stepper.linear = true; + stepper.steps[0].isValid = false; + fix.detectChanges(); + + stepper.next(); + fix.detectChanges(); + tick(); + + expect(stepper.steps[1].active).toBeFalsy(); + expect(stepper.steps[0].active).toBeTruthy(); + expect(stepper.steps[1].linearDisabled).toBeTruthy(); + expect(stepper.steps[2].linearDisabled).toBeTruthy(); + expect(serviceExpandSpy).not.toHaveBeenCalled(); + + stepper.orientation = IgxStepperOrientation.Vertical; + fix.detectChanges(); + + stepper.next(); + fix.detectChanges(); + tick(); + + expect(stepper.steps[1].active).toBeFalsy(); + expect(stepper.steps[0].active).toBeTruthy(); + expect(stepper.steps[1].linearDisabled).toBeTruthy(); + expect(serviceExpandSpy).not.toHaveBeenCalled(); + + // if the step after the active and valid step is disabled, + // the following accessible one should not be linear disabled + stepper.steps[0].isValid = true; + fix.detectChanges(); + expect(stepper.steps[1].linearDisabled).toBeFalsy(); + + stepper.steps[1].disabled = true; + stepper.steps[1].isValid = false; + fix.detectChanges(); + + expect(stepper.steps[1].linearDisabled).toBeFalsy(); + expect(stepper.steps[2].isAccessible).toBeTruthy(); + expect(stepper.steps[2].linearDisabled).toBeFalsy(); + expect(stepper.steps[2].isValid).toBeTruthy(); + + // in case the disabled step ([1]) becomes enabled and invalid, + // the following step becomes linear disabled + stepper.steps[1].disabled = false; + fix.detectChanges(); + + expect(stepper.steps[2].linearDisabled).toBeTruthy(); + + stepper.steps[1].isValid = true; + fix.detectChanges(); + + expect(stepper.steps[2].linearDisabled).toBeFalsy(); + })); + + it('should emit ing and ed events when a step is activated', fakeAsync(() => { + const changingSpy = spyOn(stepper.activeStepChanging, 'emit').and.callThrough(); + const changedSpy = spyOn(stepper.activeStepChanged, 'emit').and.callThrough(); + const serviceExpandSpy = spyOn((stepper as any).stepperService, 'expand').and.callThrough(); + const serviceCollapseSpy = spyOn((stepper as any).stepperService, 'collapse').and.callThrough(); + + expect(changingSpy).not.toHaveBeenCalled(); + expect(changedSpy).not.toHaveBeenCalled(); + + const argsIng: IStepChangingEventArgs = { + newIndex: stepper.steps[1].index, + oldIndex: stepper.steps[0].index, + owner: stepper, + cancel: false + }; + const argsEd: IStepChangedEventArgs = { + index: stepper.steps[1].index, + owner: stepper, + }; + + const testValues = [null, undefined, [], {}, 'sampleString']; + + for (const val of testValues) { + stepper.navigateTo(val as any); + fix.detectChanges(); + expect(changingSpy).not.toHaveBeenCalled(); + expect(changedSpy).not.toHaveBeenCalled(); + expect(serviceExpandSpy).not.toHaveBeenCalled(); + expect(serviceCollapseSpy).not.toHaveBeenCalled(); + } + + stepper.navigateTo(1); + fix.detectChanges(); + tick(); + + expect(stepper.steps[1].active).toBeTruthy(); + expect(changingSpy).toHaveBeenCalledOnceWith(argsIng); + expect(changedSpy).toHaveBeenCalledOnceWith(argsEd); + expect(serviceExpandSpy).toHaveBeenCalledOnceWith(stepper.steps[1]); + expect(serviceCollapseSpy).toHaveBeenCalledOnceWith(stepper.steps[0]); + })); + + it('should be able to cancel the activeStepChanging event', fakeAsync(() => { + const changingSpy = spyOn(stepper.activeStepChanging, 'emit').and.callThrough(); + const serviceExpandSpy = spyOn((stepper as any).stepperService, 'expand').and.callThrough(); + const serviceCollapseSpy = spyOn((stepper as any).stepperService, 'collapse').and.callThrough(); + + expect(changingSpy).not.toHaveBeenCalled(); + + const argsIng: IStepChangingEventArgs = { + newIndex: stepper.steps[1].index, + oldIndex: stepper.steps[0].index, + owner: stepper, + cancel: true + }; + + stepper.activeStepChanging.pipe(take(1)).subscribe(e => { + e.cancel = true; + }); + + stepper.navigateTo(1); + fix.detectChanges(); + tick(); + + expect(stepper.steps[1].active).toBeFalsy(); + expect(stepper.steps[0].active).toBeTruthy(); + expect(changingSpy).toHaveBeenCalledOnceWith(argsIng); + expect(serviceExpandSpy).toHaveBeenCalledOnceWith(stepper.steps[1]); + expect(serviceCollapseSpy).not.toHaveBeenCalled(); + })); + + it('a step should emit activeChange event when its active property changes', fakeAsync(() => { + const fourthActiveChangeSpy = spyOn(stepper.steps[3].activeChange, 'emit').and.callThrough(); + const fifthActiveChangeSpy = spyOn(stepper.steps[4].activeChange, 'emit').and.callThrough(); + const serviceExpandAPISpy = spyOn((stepper as any).stepperService, 'expandThroughApi').and.callThrough(); + + expect(fourthActiveChangeSpy).not.toHaveBeenCalled(); + expect(fifthActiveChangeSpy).not.toHaveBeenCalled(); + + stepper.steps[0].active = true; + fix.detectChanges(); + expect(serviceExpandAPISpy).toHaveBeenCalledOnceWith(stepper.steps[0]); + + stepper.steps[3].active = true; + fix.detectChanges(); + tick(); + + expect(stepper.steps[3].active).toBeTruthy(); + expect(stepper.steps[3].activeChange.emit).toHaveBeenCalledOnceWith(true); + expect(fifthActiveChangeSpy).not.toHaveBeenCalled(); + expect(serviceExpandAPISpy.calls.mostRecent().args[0]).toBe(stepper.steps[3]); + + fourthActiveChangeSpy.calls.reset(); + serviceExpandAPISpy.calls.reset(); + + stepper.steps[4].active = true; + fix.detectChanges(); + tick(); + + expect(stepper.steps[4].active).toBeTruthy(); + expect(stepper.steps[3].active).toBeFalsy(); + expect(fifthActiveChangeSpy).toHaveBeenCalledOnceWith(true); + expect(fourthActiveChangeSpy).toHaveBeenCalledOnceWith(false); + expect(serviceExpandAPISpy).toHaveBeenCalledOnceWith(stepper.steps[4]); + })); + }); + + describe('Appearance', () => { + beforeAll(() => { + jasmine.getEnv().allowRespy(true); + }); + + afterAll(() => { + jasmine.getEnv().allowRespy(false); + }); + + it('should apply the appropriate class to a stepper in horizontal mode', () => { + stepper.orientation = IgxStepperOrientation.Horizontal; + fix.detectChanges(); + + expect(stepper.nativeElement).toHaveClass('igx-stepper--horizontal'); + // no css class is applied when the stepper is in vertical mode + }); + + it('should indicate the currently active step', () => { + const step0Header = stepper.steps[0].nativeElement.querySelector(`.${STEP_HEADER}`); + const step1Header = stepper.steps[1].nativeElement.querySelector(`.${STEP_HEADER}`); + const serviceExpandSpy = spyOn((stepper as any).stepperService, 'expand').and.callThrough(); + + stepper.steps[0].active = true; + fix.detectChanges(); + + expect(step0Header).toHaveClass(CURRENT_CLASS); + + stepper.steps[1].active = true; + stepper.steps[1].nativeElement.focus(); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem(' ', stepper.steps[1].nativeElement); + fix.detectChanges(); + + expect(step0Header).not.toHaveClass(CURRENT_CLASS); + expect(step1Header).toHaveClass(CURRENT_CLASS); + expect(serviceExpandSpy).toHaveBeenCalledOnceWith(stepper.steps[1]); + }); + + it('should indicate that a step is completed', () => { + stepper.steps[0].active = true; + fix.detectChanges(); + + expect(stepper.steps[0].completed).toBeFalsy(); + expect(stepper.steps[0].nativeElement).not.toHaveClass(COMPLETED_CLASS); + + stepper.steps[0].completed = true; + fix.detectChanges(); + + expect(stepper.steps[0].nativeElement).toHaveClass(COMPLETED_CLASS); + + stepper.steps[1].completed = true; + fix.detectChanges(); + + expect(stepper.steps[1].nativeElement).toHaveClass(COMPLETED_CLASS); + }); + + it('should indicate that a step is invalid', () => { + const step0Header = stepper.steps[0].nativeElement.querySelector(`.${STEP_HEADER}`); + stepper.steps[0].isValid = true; + fix.detectChanges(); + + expect(step0Header).not.toHaveClass(INVALID_CLASS); + + stepper.steps[0].isValid = false; + fix.detectChanges(); + + expect(step0Header).not.toHaveClass(INVALID_CLASS); + + stepper.steps[1].active = true; + fix.detectChanges(); + + expect(step0Header).toHaveClass(INVALID_CLASS); + + //indicate that a step is disabled without indicating that it is also invalid + stepper.steps[0].disabled = true; + fix.detectChanges(); + + expect(step0Header).not.toHaveClass(INVALID_CLASS); + expect(stepper.steps[0].nativeElement).toHaveClass(DISABLED_CLASS); + }); + + it('should render the visual step element according to the specified stepType', () => { + stepper.stepType = IgxStepType.Full; + fix.detectChanges(); + + for (let i = 0; i < stepper.steps.length; i++) { + const elementsMap = getHeaderElements(stepper, i); + + expect(elementsMap.get('indicator')).not.toBeNull(); + expect(stepper.steps[i].isIndicatorVisible).toBeTruthy(); + if (i === 3) { + expect(elementsMap.get('title')).toBeNull(); + expect(elementsMap.get('subtitle')).toBeNull(); + continue; + } + expect(elementsMap.get('title')).not.toBeNull(); + expect(elementsMap.get('subtitle')).not.toBeNull(); + expect(stepper.steps[i].isTitleVisible).toBeTruthy(); + } + + stepper.stepType = IgxStepType.Indicator; + fix.detectChanges(); + + for (let i = 0; i < stepper.steps.length; i++) { + const elementsMap = getHeaderElements(stepper, i); + + expect(elementsMap.get('indicator')).not.toBeNull(); + expect(stepper.steps[i].isIndicatorVisible).toBeTruthy(); + expect(elementsMap.get('title')).toBeNull(); + expect(elementsMap.get('subtitle')).toBeNull(); + expect(stepper.steps[i].isTitleVisible).toBeFalsy(); + } + + stepper.stepType = IgxStepType.Title; + fix.detectChanges(); + + for (let i = 0; i < stepper.steps.length; i++) { + const elementsMap = getHeaderElements(stepper, i); + + expect(elementsMap.get('indicator')).toBeNull(); + expect(stepper.steps[i].isIndicatorVisible).toBeFalsy(); + if (i === 3) { + expect(elementsMap.get('title')).toBeNull(); + expect(elementsMap.get('subtitle')).toBeNull(); + continue; + } + expect(elementsMap.get('title')).not.toBeNull(); + expect(elementsMap.get('subtitle')).not.toBeNull(); + expect(stepper.steps[i].isTitleVisible).toBeTruthy(); + } + }); + + it('should place the title in the step element according to the specified titlePosition when stepType is set to "full"', () => { + stepper.orientation = IgxStepperOrientation.Horizontal; + stepper.stepType = IgxStepType.Full; + stepper.titlePosition = null; + fix.detectChanges(); + + //test default title positions + for (const step of stepper.steps) { + expect(step.titlePosition).toBe(stepper._defaultTitlePosition); + expect(step.titlePosition).toBe(IgxStepperTitlePosition.Bottom); + expect(step.nativeElement).toHaveClass(`igx-stepper__step--${stepper._defaultTitlePosition}`); + } + + const positions = getStepperPositions(); + positions.forEach((pos: IgxStepperTitlePosition) => { + stepper.titlePosition = pos; + fix.detectChanges(); + + for (const step of stepper.steps) { + expect(step.nativeElement).toHaveClass(`igx-stepper__step--${pos}`); + } + }); + + stepper.orientation = IgxStepperOrientation.Vertical; + stepper.titlePosition = null; + fix.detectChanges(); + + //test default title positions + for (const step of stepper.steps) { + expect(step.titlePosition).toBe(stepper._defaultTitlePosition); + expect(step.titlePosition).toBe(IgxStepperTitlePosition.End); + expect(step.nativeElement).toHaveClass(`igx-stepper__step--${stepper._defaultTitlePosition}`); + } + + positions.forEach((pos: IgxStepperTitlePosition) => { + stepper.titlePosition = pos; + fix.detectChanges(); + + for (const step of stepper.steps) { + expect(step.nativeElement).toHaveClass(`igx-stepper__step--${pos}`); + } + }); + }); + + it('should indicate steps with a number when igxStepIndicator is not set and stepType is "indicator" or "full"', () => { + stepper.stepType = IgxStepType.Full; + fix.detectChanges(); + + let indicatorElement5 = stepper.steps[4].nativeElement.querySelector(`div.${STEP_INDICATOR_CLASS}`); + + expect(stepper.steps[4].isIndicatorVisible).toBeTruthy(); + expect(indicatorElement5).not.toBeNull(); + expect(indicatorElement5.textContent).toBe((stepper.steps[4].index + 1).toString()); + + stepper.stepType = IgxStepType.Indicator; + fix.detectChanges(); + + indicatorElement5 = stepper.steps[4].nativeElement.querySelector(`div.${STEP_INDICATOR_CLASS}`); + + expect(indicatorElement5).not.toBeNull(); + expect(indicatorElement5.textContent).toBe((stepper.steps[4].index + 1).toString()); + }); + + it('should allow overriding the default invalid, completed and active indicators', () => { + const step0Header = stepper.steps[0].nativeElement.querySelector(`.${STEP_HEADER}`); + let indicatorElement = step0Header.querySelector(`.${STEP_INDICATOR_CLASS}`).children[0]; + + expect(step0Header).not.toHaveClass(INVALID_CLASS); + expect(step0Header).toHaveClass(CURRENT_CLASS); + expect(stepper.steps[0].nativeElement).not.toHaveClass(COMPLETED_CLASS); + expect(indicatorElement.tagName).toBe('IGX-ICON'); + expect(indicatorElement.textContent).toBe('edit'); + + stepper.steps[0].isValid = false; + fix.detectChanges(); + stepper.steps[1].active = true; + fix.detectChanges(); + + indicatorElement = step0Header.querySelector(`.${STEP_INDICATOR_CLASS}`).children[0]; + + expect(step0Header).toHaveClass(INVALID_CLASS); + expect(step0Header).not.toHaveClass(CURRENT_CLASS); + expect(stepper.steps[0].nativeElement).not.toHaveClass(COMPLETED_CLASS); + expect(indicatorElement.tagName).toBe('IGX-ICON'); + expect(indicatorElement.textContent).toBe('error'); + + stepper.steps[0].isValid = true; + stepper.steps[0].completed = true; + fix.detectChanges(); + + indicatorElement = step0Header.querySelector(`.${STEP_INDICATOR_CLASS}`).children[0]; + + expect(step0Header).not.toHaveClass(INVALID_CLASS); + expect(step0Header).not.toHaveClass(CURRENT_CLASS); + expect(stepper.steps[0].nativeElement).toHaveClass(COMPLETED_CLASS); + expect(indicatorElement.tagName).toBe('IGX-ICON'); + expect(indicatorElement.textContent).toBe('check'); + }); + + it('should be able to display the steps\' content above the steps headers when the stepper is horizontally orientated', () => { + stepper.orientation = IgxStepperOrientation.Horizontal; + fix.detectChanges(); + expect(stepper.contentTop).toBeFalsy(); + + expect(stepper.nativeElement.children[0]).toHaveClass(STEPPER_HEADER); + expect(stepper.nativeElement.children[1]).toHaveClass(STEPPER_BODY); + + stepper.contentTop = true; + fix.detectChanges(); + + expect(stepper.nativeElement.children[0]).toHaveClass(STEPPER_BODY); + expect(stepper.nativeElement.children[1]).toHaveClass(STEPPER_HEADER); + }); + + it('should allow modifying animationSettings that are used for transitioning between steps ', fakeAsync(() => { + const numericTestValues = [100, 1000]; + + for (const val of numericTestValues) { + fix.componentInstance.animationDuration = val as any; + testAnimationBehavior(val, fix, false); + } + + const fallbackToDefaultValues = [-1, 0, null, undefined, 'sampleString', [], {}]; + for (const val of fallbackToDefaultValues) { + fix.componentInstance.animationDuration = val as any; + fix.detectChanges(); + expect(stepper.animationDuration) + .toBe((stepper as any)._defaultAnimationDuration); + testAnimationBehavior(val, fix, false); + } + + fix.componentInstance.animationDuration = 300; + stepper.orientation = IgxStepperOrientation.Horizontal; + fix.detectChanges(); + + const horAnimTypeValidValues = ['slide', 'fade', 'none']; + for (const val of horAnimTypeValidValues) { + fix.componentInstance.horizontalAnimationType = val as any; + testAnimationBehavior(val, fix, false); + } + + const horAnimTypeTestValues = ['sampleString', null, undefined, 0, [], {}]; + for (const val of horAnimTypeTestValues) { + fix.componentInstance.horizontalAnimationType = val as any; + testAnimationBehavior(val, fix, true); + } + + stepper.orientation = IgxStepperOrientation.Vertical; + fix.detectChanges(); + + const vertAnimTypeTestValues = ['fade', 'grow', 'none', 'sampleString', null, undefined, 0, [], {}]; + for (const val of vertAnimTypeTestValues) { + fix.componentInstance.verticalAnimationType = val as any; + testAnimationBehavior(val, fix, false); + } + })); + + it('should render dynamically added step and properly set the linear disabled steps with its addition', fakeAsync(() => { + const stepsLength = stepper.steps.length; + expect(stepsLength).toBe(5); + + fix.componentInstance.displayHiddenStep = true; + fix.detectChanges(); + + expect(stepper.steps.length).toBe(stepsLength + 1); + + const titleElement = stepper.steps[2].nativeElement.querySelector(`.${STEP_TITLE_CLASS}`); + expect(titleElement.textContent).toBe('Hidden step'); + + // should set the first accessible step as active when the active step is dynamically removed + stepper.steps[2].active = true; + fix.detectChanges(); + tick(300); + fix.componentInstance.displayHiddenStep = false; + fix.detectChanges(); + tick(300); + + let firstAccessibleStepIdx = stepper.steps.findIndex(step => step.isAccessible); + expect(stepper.steps[firstAccessibleStepIdx].active).toBeTruthy(); + + fix.componentInstance.displayHiddenStep = true; + fix.detectChanges(); + tick(300); + stepper.steps[2].active = true; + stepper.steps[0].disabled = true; + fix.detectChanges(); + tick(300); + expect(stepper.steps[0].isAccessible).toBeFalsy(); + + fix.componentInstance.displayHiddenStep = false; + fix.detectChanges(); + tick(300); + + firstAccessibleStepIdx = stepper.steps.findIndex(step => step.isAccessible); + expect(firstAccessibleStepIdx).toBe(1); + expect(stepper.steps[firstAccessibleStepIdx].active).toBeTruthy(); + + // if the dynamically added step's position is before the active step in linear mode, + // it should not be linear disabled + stepper.linear = true; + stepper.steps[4].active = true; + for (let index = 0; index <= 4; index++) { + const step = stepper.steps[index]; + step.isValid = true; + } + fix.detectChanges(); + fix.componentInstance.displayHiddenStep = true; + fix.detectChanges(); + + for (let index = 0; index <= 5; index++) { + const step = stepper.steps[index]; + expect(step.linearDisabled).toBeFalsy(); + } + + fix.componentInstance.displayHiddenStep = false; + fix.detectChanges(); + + // if the dynamically added step's position is after the active step in linear mode, + // and the latter is not valid, the added step should be linear disabled + stepper.steps[0].isValid = true; + stepper.steps[1].isValid = false; + stepper.steps[1].active = true; + fix.detectChanges(); + fix.componentInstance.displayHiddenStep = true; + fix.detectChanges(); + tick(300); + + expect(stepper.steps[2].linearDisabled).toBeTruthy(); + + for (let index = 3; index <= 5; index++) { + const step = stepper.steps[index]; + expect(step.linearDisabled).toBeTruthy(); + } + })); + + it('should activate the first accessible step and clear the visited steps collection when the stepper is reset', fakeAsync(() => { + // "visit" some steps + stepper.steps[0].active = true; + fix.detectChanges(); + stepper.steps[1].active = true; + fix.detectChanges(); + stepper.steps[2].active = true; + fix.detectChanges(); + + expect((stepper as any).stepperService.visitedSteps.size).toBe(3); + + stepper.reset(); + fix.detectChanges(); + + const firstAccessibleStepIdx = stepper.steps.findIndex(step => step.isAccessible); + expect(stepper.steps[firstAccessibleStepIdx].active).toBeTruthy(); + + expect((stepper as any).stepperService.visitedSteps.size).toBe(1); + expect((stepper as any).stepperService.visitedSteps).toContain(stepper.steps[firstAccessibleStepIdx]); + })); + + it('should properly collapse the previously active step in horizontal orientation and animation type \'fade\'', fakeAsync(() => { + stepper.orientation = IgxStepperOrientation.Horizontal; + stepper.horizontalAnimationType = 'fade'; + testAnimationBehavior('fade', fix, false); + })); + }); + + describe('Keyboard navigation', () => { + it('should navigate to first/last step on Home/End key press', fakeAsync(() => { + const serviceExpandSpy = spyOn((stepper as any).stepperService, 'expand').and.callThrough(); + const serviceCollapseSpy = spyOn((stepper as any).stepperService, 'collapse').and.callThrough(); + + stepper.steps[3].active = true; + stepper.steps[3].nativeElement.focus(); + fix.detectChanges(); + + expect(document.activeElement).toBe(stepper.steps[3].nativeElement as Element); + + UIInteractions.triggerKeyDownEvtUponElem('Home', stepper.steps[3].nativeElement); + fix.detectChanges(); + + expect(stepper.steps[0].nativeElement as Element).toBe(document.activeElement); + expect(serviceExpandSpy).not.toHaveBeenCalled(); + expect(serviceCollapseSpy).not.toHaveBeenCalled(); + + UIInteractions.triggerKeyDownEvtUponElem('End', stepper.steps[0].nativeElement); + fix.detectChanges(); + + expect(stepper.steps[4].nativeElement as Element).toBe(document.activeElement); + expect(serviceExpandSpy).not.toHaveBeenCalled(); + expect(serviceCollapseSpy).not.toHaveBeenCalled(); + })); + + it('should activate the currently focused step on Enter/Space key press', fakeAsync(() => { + const serviceExpandSpy = spyOn((stepper as any).stepperService, 'expand').and.callThrough(); + stepper.steps[0].active = true; + fix.detectChanges(); + + expect(stepper.steps[3].active).toBeFalsy(); + + stepper.steps[3].nativeElement.focus(); + fix.detectChanges(); + + expect(document.activeElement).toBe(stepper.steps[3].nativeElement as Element); + + UIInteractions.triggerKeyDownEvtUponElem('Enter', stepper.steps[3].nativeElement); + fix.detectChanges(); + + expect(stepper.steps[3].nativeElement as Element).toBe(document.activeElement); + expect(stepper.steps[3].active).toBeTruthy(); + expect(serviceExpandSpy).toHaveBeenCalledOnceWith(stepper.steps[3]); + + stepper.steps[4].nativeElement.focus(); + fix.detectChanges(); + + expect(document.activeElement).toBe(stepper.steps[4].nativeElement as Element); + expect(stepper.steps[4].active).toBeFalsy(); + + UIInteractions.triggerKeyDownEvtUponElem(' ', stepper.steps[4].nativeElement); + fix.detectChanges(); + + expect(stepper.steps[4].active).toBeTruthy(); + expect(serviceExpandSpy.calls.mostRecent().args[0]).toBe(stepper.steps[4]); + })); + + it('should navigate to the next/previous step in horizontal orientation on Arrow Right/Left key press', fakeAsync(() => { + stepper.orientation = IgxStepperOrientation.Horizontal; + stepper.steps[0].active = true; + fix.detectChanges(); + + expect(stepper.steps[1].active).toBeFalsy(); + + stepper.steps[0].nativeElement.focus(); + fix.detectChanges(); + + expect(document.activeElement).toBe(stepper.steps[0].nativeElement as Element); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', stepper.steps[0].nativeElement); + fix.detectChanges(); + + expect(stepper.steps[1].nativeElement as Element).toBe(document.activeElement); + expect(stepper.steps[1].active).toBeFalsy(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', stepper.steps[1].nativeElement); + fix.detectChanges(); + + expect(stepper.steps[0].nativeElement as Element).toBe(document.activeElement); + })); + + it('should navigate to the next/previous step in vertical orientation on Arrow Down/Up key press', fakeAsync(() => { + stepper.orientation = IgxStepperOrientation.Vertical; + stepper.steps[0].active = true; + fix.detectChanges(); + + expect(stepper.steps[1].active).toBeFalsy(); + + stepper.steps[0].nativeElement.focus(); + fix.detectChanges(); + + expect(document.activeElement).toBe(stepper.steps[0].nativeElement as Element); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', stepper.steps[0].nativeElement); + fix.detectChanges(); + + expect(stepper.steps[1].nativeElement as Element).toBe(document.activeElement); + expect(stepper.steps[1].active).toBeFalsy(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', stepper.steps[1].nativeElement); + fix.detectChanges(); + + expect(stepper.steps[0].nativeElement as Element).toBe(document.activeElement); + })); + + it('should specify tabIndex="0" for the active step and tabIndex="-1" for the other steps', fakeAsync(() => { + stepper.orientation = IgxStepperOrientation.Horizontal; + stepper.steps[0].active = true; + fix.detectChanges(); + + stepper.steps[0].nativeElement.focus(); + let stepContent = stepper.nativeElement.querySelector(`#${stepper.steps[0].id.replace('step', 'content')}`); + + expect(stepper.steps[0].tabIndex).toBe(0); + expect(stepContent.getAttribute('tabIndex')).toBe('0'); + + for (let i = 1; i < stepper.steps.length; i++) { + expect(stepper.steps[i].tabIndex).toBe(-1); + } + + stepper.steps[1].active = true; + fix.detectChanges(); + + expect(stepContent.getAttribute('tabIndex')).toBe('-1'); + expect(stepper.steps[1].tabIndex).toBe(0); + + stepper.steps[1].nativeElement.focus(); + UIInteractions.triggerKeyDownEvtUponElem('Enter', stepper.steps[1].nativeElement); + fix.detectChanges(); + + stepContent = stepper.nativeElement.querySelector(`#${stepper.steps[1].id.replace('step', 'content')}`); + expect(stepContent).not.toBeNull(); + expect(stepContent.getAttribute('tabIndex')).toBe('0'); + + for (let i = 0; i < stepper.steps.length; i++) { + if (i === 1) { + continue; + } + expect(stepper.steps[i].tabIndex).toBe(-1); + } + + stepper.orientation = IgxStepperOrientation.Vertical; + stepper.steps[0].active = true; + fix.detectChanges(); + + stepContent = stepper.nativeElement.querySelector(`#${stepper.steps[0].id.replace('step', 'content')}`); + stepper.steps[0].nativeElement.focus(); + + expect(stepper.steps[0].tabIndex).toBe(0); + expect(stepContent).not.toBeNull(); + expect(stepContent.getAttribute('tabIndex')).toBe('0'); + + for (let i = 1; i < stepper.steps.length; i++) { + stepContent = stepper.nativeElement.querySelector(`#${stepper.steps[i].id.replace('step', 'content')}`); + expect(stepper.steps[i].tabIndex).toBe(-1); + expect(stepContent).toBeNull(); + } + })); + }); + + describe('ARIA', () => { + it('should render proper role and orientation attributes for the stepper', () => { + expect(stepper.nativeElement.attributes['role'].value).toEqual('tablist'); + + stepper.orientation = IgxStepperOrientation.Horizontal; + fix.detectChanges(); + + expect(stepper.nativeElement.attributes['aria-orientation'].value).toEqual('horizontal'); + + stepper.orientation = IgxStepperOrientation.Vertical; + fix.detectChanges(); + + expect(stepper.nativeElement.attributes['aria-orientation'].value).toEqual('vertical'); + }); + + it('should render proper aria attributes for each step', () => { + for (let i = 0; i < stepper.steps.length; i++) { + expect(stepper.steps[i].nativeElement.attributes['role'].value) + .toEqual('tab'); + expect(stepper.steps[i].nativeElement.attributes['aria-posinset'].value) + .toEqual((i + 1).toString()); + expect(stepper.steps[i].nativeElement.attributes['aria-setsize'].value) + .toEqual(stepper.steps.length.toString()); + expect(stepper.steps[i].nativeElement.attributes['aria-controls'].value) + .toEqual(`${stepper.steps[i].id.replace('step', 'content')}`); + + if (i !== 0) { + expect(stepper.steps[i].nativeElement.attributes['aria-selected'].value).toEqual('false'); + } + + stepper.steps[i].active = true; + fix.detectChanges(); + + expect(stepper.steps[i].nativeElement.attributes['aria-selected'].value).toEqual('true'); + } + }); + }); +}); + +describe('Stepper service unit tests', () => { + + let stepperService: IgxStepperService; + let mockElement: any; + let mockElementRef: any; + let mockCdr: any; + let mockAnimationService: any; + let mockPlatform: any; + let mockDocument: any; + let mockDir: any; + + let steps: IgxStepComponent[] = []; + let stepper: IgxStepperComponent; + + beforeAll(() => { + jasmine.getEnv().allowRespy(true); + }); + + afterAll(() => { + jasmine.getEnv().allowRespy(false); + }); + + beforeEach(() => { + mockElement = { + style: { visibility: '', cursor: '', transitionDuration: '' }, + classList: { add: () => { }, remove: () => { } }, + appendChild: () => { }, + removeChild: () => { }, + addEventListener: (_type: string, _listener: (this: HTMLElement, ev: MouseEvent) => any) => { }, + removeEventListener: (_type: string, _listener: (this: HTMLElement, ev: MouseEvent) => any) => { }, + insertBefore: (_newChild: HTMLDivElement, _refChild: Node) => { }, + contains: () => { } + }; + mockElement.parent = mockElement; + mockElement.parentElement = mockElement; + mockElement.parentNode = mockElement; + mockElementRef = { nativeElement: mockElement }; + + mockAnimationService = { + buildAnimation: (_builder: AnimationBuilder) => ({ + animationEnd: { + pipe: () => ({ + subscribe: () => { } + }), + subscribe: () => { } + }, + animationStart: { + pipe: () => ({ + subscribe: () => { } + }), + subscribe: () => { } + }, + position: 0, + init: () => { }, + hasStarted: () => true, + play: () => { }, + finish: () => { }, + reset: () => { }, + destroy: () => { } + }) + }; + + mockPlatform = { isIOS: false }; + + mockDocument = { + body: mockElement, + defaultView: mockElement, + createElement: () => mockElement, + appendChild: () => { }, + addEventListener: (_type: string, _listener: (this: HTMLElement, ev: MouseEvent) => any) => { }, + removeEventListener: (_type: string, _listener: (this: HTMLElement, ev: MouseEvent) => any) => { } + }; + + mockDir = { + value: (): ɵDirection => 'rtl', + document: () => mockDocument, + rtl: () => true + }; + + mockCdr = { + markForCheck: (): void => { }, + detach: (): void => { }, + detectChanges: (): void => { }, + checkNoChanges: (): void => { }, + reattach: (): void => { }, + }; + + stepperService = new IgxStepperService(); + + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, IgxStepComponent], + providers: [ + { provide: ChangeDetectorRef, useValue: mockCdr }, + { provide: IgxAngularAnimationService, useValue: mockAnimationService }, + { provide: ElementRef, useValue: mockElementRef }, + { provide: IgxStepperService, useValue: stepperService }, + { provide: IgxDirectionality, useValue: mockDir }, + { provide: PlatformUtil, useValue: mockPlatform }, + IgxStepperComponent, + { provide: IGX_STEPPER_COMPONENT, useExisting: IgxStepperComponent }, + IgxStepComponent, + Renderer2 + ] + }); + + stepper = TestBed.inject(IgxStepperComponent); + steps = []; + for (let index = 0; index < 4; index++) { + const fixture = TestBed.createComponent(IgxStepComponent); + fixture.detectChanges(); + const newStep = fixture.componentInstance; + newStep._index = index; + steps.push(newStep); + } + }); + + it('should expand a step by activating it and firing the step\'s activeChange event', () => { + spyOnProperty(stepper, 'orientation', 'get').and.returnValue(IgxStepperOrientation.Horizontal); + spyOnProperty(stepper, 'steps', 'get').and.returnValue(steps); + + stepperService.activeStep = steps[0]; + + steps[0].contentContainer = mockElementRef; + steps[1].contentContainer = mockElementRef; + + spyOn(steps[0].activeChange, 'emit').and.callThrough(); + spyOn(steps[1].activeChange, 'emit').and.callThrough(); + + stepperService.expand(steps[1]); + expect(stepperService.activeStep).toBe(steps[1]); + expect(steps[1].activeChange.emit).toHaveBeenCalledTimes(1); + expect(steps[1].activeChange.emit).toHaveBeenCalledWith(true); + + spyOnProperty(stepper, 'orientation', 'get').and.returnValue(IgxStepperOrientation.Vertical); + stepperService.expand(steps[0]); + + expect(stepperService.activeStep).toBe(steps[0]); + expect(steps[0].activeChange.emit).toHaveBeenCalledOnceWith(true); + + const testValues = [null, undefined, [], {}, 'sampleString']; + + for (const val of testValues) { + expect(() => { + stepperService.expand(val as any); + }).toThrow(); + } + }); + + it('should expand a step through API by activating it and firing the step\'s activeChange event', () => { + spyOnProperty(stepper, 'orientation', 'get').and.returnValue(IgxStepperOrientation.Horizontal); + spyOnProperty(stepper, 'steps', 'get').and.returnValue(steps); + + stepperService.activeStep = steps[0]; + + spyOn(steps[0].activeChange, 'emit'); + spyOn(steps[1].activeChange, 'emit'); + + stepperService.expandThroughApi(steps[1]); + + expect(stepperService.activeStep).toBe(steps[1]); + expect(steps[0].activeChange.emit).toHaveBeenCalledOnceWith(false); + expect(steps[1].activeChange.emit).toHaveBeenCalledOnceWith(true); + + spyOnProperty(stepper, 'orientation', 'get').and.returnValue(IgxStepperOrientation.Vertical); + stepperService.expandThroughApi(steps[0]); + + expect(stepperService.activeStep).toBe(steps[0]); + expect(steps[1].activeChange.emit).toHaveBeenCalledTimes(2); + expect(steps[1].activeChange.emit).toHaveBeenCalledWith(false); + expect(steps[0].activeChange.emit).toHaveBeenCalledTimes(2); + expect(steps[0].activeChange.emit).toHaveBeenCalledWith(true); + + const testValues = [null, undefined, [], {}, 'sampleString']; + + for (const val of testValues) { + expect(() => { + stepperService.expandThroughApi(val as any); + }).toThrow(); + } + }); + + it('should collapse the currently active step and fire the change event', () => { + spyOnProperty(stepper, 'orientation', 'get').and.returnValue(IgxStepperOrientation.Horizontal); + spyOnProperty(stepper, 'steps', 'get').and.returnValue(steps); + + stepperService.previousActiveStep = steps[0]; + stepperService.activeStep = steps[1]; + stepperService.collapsingSteps.add(stepperService.previousActiveStep); + + expect(stepperService.collapsingSteps).toContain(steps[0]); + expect(stepperService.collapsingSteps).not.toContain(steps[1]); + + spyOn(steps[0].activeChange, 'emit'); + spyOn(steps[1].activeChange, 'emit'); + + stepperService.collapse(steps[0]); + + expect(stepperService.collapsingSteps).not.toContain(steps[0]); + expect(stepperService.activeStep).not.toBe(steps[0]); + expect(stepperService.activeStep).toBe(steps[1]); + expect(steps[0].activeChange.emit).toHaveBeenCalledOnceWith(false); + expect(steps[1].activeChange.emit).not.toHaveBeenCalled(); + + spyOnProperty(stepper, 'orientation', 'get').and.returnValue(IgxStepperOrientation.Vertical); + + stepperService.previousActiveStep = steps[1]; + stepperService.activeStep = steps[0]; + + stepperService.collapsingSteps.add(stepperService.previousActiveStep); + expect(stepperService.collapsingSteps).toContain(steps[1]); + expect(stepperService.collapsingSteps).not.toContain(steps[0]); + + stepperService.collapse(steps[1]); + + expect(stepperService.collapsingSteps).not.toContain(steps[1]); + expect(stepperService.activeStep).not.toBe(steps[1]); + expect(stepperService.activeStep).toBe(steps[0]); + + expect(steps[1].activeChange.emit).toHaveBeenCalledOnceWith(false); + expect(steps[0].activeChange.emit).not.toHaveBeenCalledTimes(2); + + const testValues = [null, undefined, [], {}, 'sampleString']; + + for (const val of testValues) { + expect(() => { + stepperService.collapse(val as any); + }).toThrow(); + } + }); + + it('should determine the steps that are marked as visited based on the active step', () => { + spyOnProperty(stepper, 'steps', 'get').and.returnValue(steps); + let sampleSet: Set; + + stepperService.activeStep = steps[0]; + stepperService.calculateVisitedSteps(); + expect(stepperService.visitedSteps.size).toEqual(1); + sampleSet = new Set([steps[0]]); + expect(stepperService.visitedSteps).toEqual(sampleSet); + + stepperService.activeStep = steps[1]; + stepperService.calculateVisitedSteps(); + expect(stepperService.visitedSteps.size).toEqual(2); + sampleSet = new Set([steps[0], steps[1]]); + expect(stepperService.visitedSteps).toEqual(sampleSet); + + stepperService.activeStep = steps[2]; + stepperService.calculateVisitedSteps(); + expect(stepperService.visitedSteps.size).toEqual(3); + sampleSet = new Set([steps[0], steps[1], steps[2]]); + expect(stepperService.visitedSteps).toEqual(sampleSet); + }); + + it('should determine the steps that should be disabled in linear mode based on the validity of the active step', () => { + spyOnProperty(stepper, 'orientation', 'get').and.returnValue(IgxStepperOrientation.Horizontal); + spyOnProperty(stepper, 'steps').and.returnValue(steps); + + for (const step of steps) { + spyOnProperty(step, 'isValid').and.returnValue(false); + } + spyOnProperty(stepper, 'linear').and.returnValue(true); + stepperService.activeStep = steps[0]; + spyOnProperty(steps[0], 'active').and.returnValue(true); + + expect(stepperService.linearDisabledSteps.size).toBe(0); + stepperService.calculateLinearDisabledSteps(); + expect(stepperService.linearDisabledSteps.size).toBe(3); + let sampleSet = new Set([steps[1], steps[2], steps[3]]); + expect(stepperService.linearDisabledSteps).toEqual(sampleSet); + + spyOnProperty(steps[0], 'isValid').and.returnValue(true); + stepperService.calculateLinearDisabledSteps(); + sampleSet = new Set([steps[2], steps[3]]); + expect(stepperService.linearDisabledSteps.size).toBe(2); + expect(stepperService.linearDisabledSteps).toEqual(sampleSet); + + spyOnProperty(steps[1], 'active').and.returnValue(true); + spyOnProperty(steps[1], 'isValid').and.returnValue(false); + stepperService.calculateLinearDisabledSteps(); + expect(stepperService.linearDisabledSteps.size).toBe(2); + expect(stepperService.linearDisabledSteps).toEqual(sampleSet); + + spyOnProperty(steps[1], 'isValid').and.returnValue(true); + stepperService.activeStep = steps[1]; + sampleSet = new Set([steps[3]]); + stepperService.calculateLinearDisabledSteps(); + expect(stepperService.linearDisabledSteps.size).toBe(1); + expect(stepperService.linearDisabledSteps).toEqual(sampleSet); + expect(stepperService.linearDisabledSteps).toContain(steps[3]); + + spyOnProperty(steps[2], 'isValid').and.returnValue(true); + spyOnProperty(steps[3], 'isValid').and.returnValue(true); + stepperService.activeStep = steps[3]; + stepperService.calculateLinearDisabledSteps(); + expect(stepperService.linearDisabledSteps.size).toBe(0); + expect(stepperService.linearDisabledSteps).not.toContain(steps[3]); + }); + + it('should emit activating event', () => { + spyOnProperty(stepper, 'orientation', 'get').and.returnValue(IgxStepperOrientation.Horizontal); + spyOnProperty(stepper, 'steps').and.returnValue(steps); + const activeChangingSpy = spyOn(stepper.activeStepChanging, 'emit'); + stepperService.activeStep = steps[0]; + + let activeChangingEventArgs: any = { + owner: stepper, + newIndex: steps[1].index, + oldIndex: steps[0].index, + cancel: false + }; + + let result: boolean = stepperService.emitActivatingEvent(steps[1]); + expect(result).toEqual(false); + expect(activeChangingSpy).toHaveBeenCalledOnceWith(activeChangingEventArgs); + + activeChangingSpy.calls.reset(); + + stepperService.activeStep = steps[1]; + stepperService.previousActiveStep = steps[0]; + + result = stepperService.emitActivatingEvent(steps[0]); + expect(result).toEqual(false); + expect(activeChangingSpy).toHaveBeenCalledTimes(1); + expect(activeChangingSpy).not.toHaveBeenCalledWith(activeChangingEventArgs); + + activeChangingEventArgs = { + owner: stepper, + newIndex: steps[0].index, + oldIndex: steps[1].index, + cancel: false + }; + + expect(activeChangingSpy).toHaveBeenCalledWith(activeChangingEventArgs); + }); +}); + + +@Component({ + template: ` + + + + error + + + + check + + + + edit + + + + 1 + Step No 1 + Step SubTitle +
    + + + +
    +
    + + + 2 + Step No 2 + Step SubTitle +
    +

    Test step 2

    +
    +
    + + @if (displayHiddenStep) { + + * + Hidden step + Step SubTitle +
    +

    Test hidden step

    +
    +
    + } + + + 3 + Step No 3 + Step SubTitle +
    +

    Test step 3

    +
    +
    + + + 4 +
    +

    Test step 4

    +
    +
    + + + Step No 5 + Step SubTitle +
    +

    Test step 5

    +
    +
    +
    +
    + `, + imports: [ + IgxStepperComponent, + IgxStepComponent, + IgxStepTitleDirective, + IgxStepIndicatorDirective, + IgxStepSubtitleDirective, + IgxStepContentDirective, + IgxStepInvalidIndicatorDirective, + IgxStepCompletedIndicatorDirective, + IgxStepActiveIndicatorDirective, + IgxIconComponent, + IgxInputDirective, + IgxInputGroupComponent, + ] +}) +export class IgxStepperSampleTestComponent { + @ViewChild(IgxStepperComponent) public stepper: IgxStepperComponent; + + public horizontalAnimationType: HorizontalAnimationType = 'slide'; + public verticalAnimationType: VerticalAnimationType = 'grow'; + public animationDuration = 300; + public displayHiddenStep = false; + +} + +@Component({ + template: ` + + + + + + + + + + + `, + imports: [IgxStepperComponent, IgxStepComponent] +}) +export class IgxStepperLinearComponent { + @ViewChild(IgxStepperComponent) public stepper: IgxStepperComponent; +} diff --git a/projects/igniteui-angular/stepper/src/stepper/stepper.component.ts b/projects/igniteui-angular/stepper/src/stepper/stepper.component.ts new file mode 100644 index 00000000000..3763ed54010 --- /dev/null +++ b/projects/igniteui-angular/stepper/src/stepper/stepper.component.ts @@ -0,0 +1,534 @@ +import { AnimationReferenceMetadata, useAnimation } from '@angular/animations'; +import { NgTemplateOutlet } from '@angular/common'; +import { AfterContentInit, Component, ContentChild, ContentChildren, ElementRef, EventEmitter, HostBinding, Input, OnChanges, OnDestroy, OnInit, Output, QueryList, SimpleChanges, TemplateRef, booleanAttribute, inject } from '@angular/core'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +import { IgxCarouselComponentBase } from 'igniteui-angular/carousel'; +import { IgxStepComponent } from './step/step.component'; +import { + IgxStepper, IgxStepperOrientation, IgxStepperTitlePosition, IgxStepType, + IGX_STEPPER_COMPONENT, IStepChangedEventArgs, IStepChangingEventArgs, VerticalAnimationType, + HorizontalAnimationType +} from './stepper.common'; +import { + IgxStepActiveIndicatorDirective, + IgxStepCompletedIndicatorDirective, + IgxStepInvalidIndicatorDirective +} from './stepper.directive'; +import { IgxStepperService } from './stepper.service'; +import { fadeIn, growVerIn, growVerOut } from 'igniteui-angular/animations'; +import { ToggleAnimationSettings } from 'igniteui-angular/expansion-panel'; + + +// TODO: common interface between IgxCarouselComponentBase and ToggleAnimationPlayer? + +/** + * IgxStepper provides a wizard-like workflow by dividing content into logical steps. + * + * @igxModule IgxStepperModule + * + * @igxKeywords stepper + * + * @igxGroup Layouts + * + * @remarks + * The Ignite UI for Angular Stepper component allows the user to navigate between multiple steps. + * It supports horizontal and vertical orientation as well as keyboard navigation and provides API methods to control the active step. + * The component offers keyboard navigation and API to control the active step. + * + * @example + * ```html + * + * + * home + *

    Home

    + *
    + * ... + *
    + *
    + * + *
    + * ... + *
    + *
    + * + *
    + * ... + *
    + *
    + *
    + * ``` + */ +@Component({ + selector: 'igx-stepper', + templateUrl: 'stepper.component.html', + providers: [ + IgxStepperService, + { provide: IGX_STEPPER_COMPONENT, useExisting: IgxStepperComponent }, + ], + imports: [NgTemplateOutlet] +}) +export class IgxStepperComponent extends IgxCarouselComponentBase implements IgxStepper, OnChanges, OnInit, AfterContentInit, OnDestroy { + private stepperService = inject(IgxStepperService); + private element = inject>(ElementRef); + + + /** + * Get/Set the animation type of the stepper when the orientation direction is vertical. + * + * @remarks + * Default value is `grow`. Other possible values are `fade` and `none`. + * + * ```html + * + * + * ``` + */ + @Input() + public get verticalAnimationType(): VerticalAnimationType { + return this._verticalAnimationType; + } + + public set verticalAnimationType(value: VerticalAnimationType) { + // TODO: activeChange event is not emitted for the collapsing steps (loop through collapsing steps and emit) + this.stepperService.collapsingSteps.clear(); + this._verticalAnimationType = value; + + switch (value) { + case 'grow': + this.verticalAnimationSettings = this.updateVerticalAnimationSettings(growVerIn, growVerOut); + break; + case 'fade': + this.verticalAnimationSettings = this.updateVerticalAnimationSettings(fadeIn, null); + break; + case 'none': + this.verticalAnimationSettings = this.updateVerticalAnimationSettings(null, null); + break; + } + } + + /** + * Get/Set the animation type of the stepper when the orientation direction is horizontal. + * + * @remarks + * Default value is `grow`. Other possible values are `fade` and `none`. + * + * ```html + * + * + * ``` + */ + @Input() + public get horizontalAnimationType(): HorizontalAnimationType { + return this.animationType; + } + + public set horizontalAnimationType(value: HorizontalAnimationType) { + // TODO: activeChange event is not emitted for the collapsing steps (loop through collapsing steps and emit) + this.stepperService.collapsingSteps.clear(); + this.animationType = value; + } + + /** + * Get/Set the animation duration. + * ```html + * + * + * ``` + */ + @Input() + public get animationDuration(): number { + return this.defaultAnimationDuration; + } + + public set animationDuration(value: number) { + if (value && value > 0) { + this.defaultAnimationDuration = value; + return; + } + this.defaultAnimationDuration = this._defaultAnimationDuration; + } + + /** + * Get/Set whether the stepper is linear. + * + * @remarks + * If the stepper is in linear mode and if the active step is valid only then the user is able to move forward. + * + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public get linear(): boolean { + return this._linear; + } + + public set linear(value: boolean) { + this._linear = value; + if (this._linear && this.steps.length > 0) { + // when the stepper is in linear mode we should calculate which steps should be disabled + // and which are visited i.e. their validity should be correctly displayed. + this.stepperService.calculateVisitedSteps(); + this.stepperService.calculateLinearDisabledSteps(); + } else { + this.stepperService.linearDisabledSteps.clear(); + } + } + + /** + * Get/Set the stepper orientation. + * + * ```typescript + * this.stepper.orientation = IgxStepperOrientation.Vertical; + * ``` + */ + @HostBinding('attr.aria-orientation') + @Input() + public get orientation(): IgxStepperOrientation { + return this._orientation; + } + + public set orientation(value: IgxStepperOrientation) { + if (this._orientation === value) { + return; + } + + // TODO: activeChange event is not emitted for the collapsing steps + this.stepperService.collapsingSteps.clear(); + this._orientation = value; + this._defaultTitlePosition = this._orientation === IgxStepperOrientation.Horizontal ? + IgxStepperTitlePosition.Bottom : IgxStepperTitlePosition.End; + } + + /** + * Get/Set the type of the steps. + * + * ```typescript + * this.stepper.stepType = IgxStepType.Indicator; + * ``` + */ + @Input() + public stepType: IgxStepType = IgxStepType.Full; + + /** + * Get/Set whether the content is displayed above the steps. + * + * @remarks + * Default value is `false` and the content is below the steps. + * + * ```typescript + * this.stepper.contentTop = true; + * ``` + */ + @Input({ transform: booleanAttribute }) + public contentTop = false; + + /** + * Get/Set the position of the steps title. + * + * @remarks + * The default value when the stepper is horizontally orientated is `bottom`. + * In vertical layout the default title position is `end`. + * + * ```typescript + * this.stepper.titlePosition = IgxStepperTitlePosition.Top; + * ``` + */ + @Input() + public titlePosition: IgxStepperTitlePosition = null; + + /** @hidden @internal **/ + @HostBinding('class.igx-stepper') + public cssClass = 'igx-stepper'; + + /** @hidden @internal **/ + @HostBinding('attr.role') + public role = 'tablist'; + + /** @hidden @internal **/ + @HostBinding('class.igx-stepper--horizontal') + public get directionClass() { + return this.orientation === IgxStepperOrientation.Horizontal; + } + + /** + * Emitted when the stepper's active step is changing. + * + *```html + * + * + * ``` + * + *```typescript + * public handleActiveStepChanging(event: IStepChangingEventArgs) { + * if (event.newIndex < event.oldIndex) { + * event.cancel = true; + * } + * } + *``` + */ + @Output() + public activeStepChanging = new EventEmitter(); + + /** + * Emitted when the active step is changed. + * + * @example + * ``` + * + * ``` + */ + @Output() + public activeStepChanged = new EventEmitter(); + + /** @hidden @internal */ + @ContentChild(IgxStepInvalidIndicatorDirective, { read: TemplateRef }) + public invalidIndicatorTemplate: TemplateRef; + + /** @hidden @internal */ + @ContentChild(IgxStepCompletedIndicatorDirective, { read: TemplateRef }) + public completedIndicatorTemplate: TemplateRef; + + /** @hidden @internal */ + @ContentChild(IgxStepActiveIndicatorDirective, { read: TemplateRef }) + public activeIndicatorTemplate: TemplateRef; + + /** @hidden @internal */ + @ContentChildren(IgxStepComponent, { descendants: false }) + private _steps: QueryList; + + /** + * Get all steps. + * + * ```typescript + * const steps: IgxStepComponent[] = this.stepper.steps; + * ``` + */ + public get steps(): IgxStepComponent[] { + return this._steps?.toArray() || []; + } + + /** @hidden @internal */ + public get nativeElement(): HTMLElement { + return this.element.nativeElement; + } + + /** @hidden @internal */ + public verticalAnimationSettings: ToggleAnimationSettings = { + openAnimation: growVerIn, + closeAnimation: growVerOut, + }; + /** @hidden @internal */ + public _defaultTitlePosition: IgxStepperTitlePosition = IgxStepperTitlePosition.Bottom; + private destroy$ = new Subject(); + private _orientation: IgxStepperOrientation = IgxStepperOrientation.Horizontal; + private _verticalAnimationType: VerticalAnimationType = VerticalAnimationType.Grow; + private _linear = false; + private readonly _defaultAnimationDuration = 350; + + constructor() { + super(); + this.stepperService.stepper = this; + } + + /** @hidden @internal */ + public ngOnChanges(changes: SimpleChanges): void { + if (changes['animationDuration']) { + this.verticalAnimationType = this._verticalAnimationType; + } + } + + /** @hidden @internal */ + public ngOnInit(): void { + this.enterAnimationDone.pipe(takeUntil(this.destroy$)).subscribe(() => { + this.activeStepChanged.emit({ owner: this, index: this.stepperService.activeStep.index }); + }); + this.leaveAnimationDone.pipe(takeUntil(this.destroy$)).subscribe(() => { + if (this.stepperService.collapsingSteps.size === 1) { + this.stepperService.collapse(this.stepperService.previousActiveStep); + } else { + Array.from(this.stepperService.collapsingSteps).slice(0, this.stepperService.collapsingSteps.size - 1) + .forEach(step => this.stepperService.collapse(step)); + } + }); + + + } + + /** @hidden @internal */ + public ngAfterContentInit(): void { + let activeStep; + this.steps.forEach((step, index) => { + this.updateStepAria(step, index); + if (!activeStep && step.active) { + activeStep = step; + } + }); + if (!activeStep) { + this.activateFirstStep(true); + } + + if (this.linear) { + this.stepperService.calculateLinearDisabledSteps(); + } + + this.handleStepChanges(); + } + + /** @hidden @internal */ + public override ngOnDestroy(): void { + super.ngOnDestroy(); + this.destroy$.next(); + this.destroy$.complete(); + } + + /** + * Activates the step at a given index. + * + *```typescript + * this.stepper.navigateTo(1); + *``` + */ + public navigateTo(index: number): void { + const step = this.steps[index]; + if (!step || this.stepperService.activeStep === step) { + return; + } + this.activateStep(step); + } + + /** + * Activates the next enabled step. + * + *```typescript + * this.stepper.next(); + *``` + */ + public next(): void { + this.moveToNextStep(); + } + + /** + * Activates the previous enabled step. + * + *```typescript + * this.stepper.prev(); + *``` + */ + public prev(): void { + this.moveToNextStep(false); + } + + /** + * Resets the stepper to its initial state i.e. activates the first step. + * + * @remarks + * The steps' content will not be automatically reset. + *```typescript + * this.stepper.reset(); + *``` + */ + public reset(): void { + this.stepperService.visitedSteps.clear(); + const activeStep = this.steps.find(s => !s.disabled); + if (activeStep) { + this.activateStep(activeStep); + } + } + + /** @hidden @internal */ + public playHorizontalAnimations(): void { + this.previousItem = this.stepperService.previousActiveStep; + this.currentItem = this.stepperService.activeStep; + this.triggerAnimations(); + } + + protected getPreviousElement(): HTMLElement { + return this.stepperService.previousActiveStep?.contentContainer.nativeElement; + } + + protected getCurrentElement(): HTMLElement { + return this.stepperService.activeStep.contentContainer.nativeElement; + } + + private updateVerticalAnimationSettings( + openAnimation: AnimationReferenceMetadata, + closeAnimation: AnimationReferenceMetadata): ToggleAnimationSettings { + const customCloseAnimation = useAnimation(closeAnimation, { + params: { + duration: this.animationDuration + 'ms' + } + }); + const customOpenAnimation = useAnimation(openAnimation, { + params: { + duration: this.animationDuration + 'ms' + } + }); + + return { + openAnimation: openAnimation ? customOpenAnimation : null, + closeAnimation: closeAnimation ? customCloseAnimation : null + }; + } + + private updateStepAria(step: IgxStepComponent, index: number): void { + step._index = index; + step.renderer.setAttribute(step.nativeElement, 'aria-setsize', (this.steps.length).toString()); + step.renderer.setAttribute(step.nativeElement, 'aria-posinset', (index + 1).toString()); + } + + private handleStepChanges(): void { + this._steps.changes.pipe(takeUntil(this.destroy$)).subscribe(steps => { + Promise.resolve().then(() => { + steps.forEach((step, index) => { + this.updateStepAria(step, index); + }); + + // when the active step is removed + const hasActiveStep = this.steps.find(s => s === this.stepperService.activeStep); + if (!hasActiveStep) { + this.activateFirstStep(); + } + // TO DO: mark step added before the active as visited? + if (this.linear) { + this.stepperService.calculateLinearDisabledSteps(); + } + }); + }); + } + + private activateFirstStep(activateInitially = false) { + const firstEnabledStep = this.steps.find(s => !s.disabled); + if (firstEnabledStep) { + firstEnabledStep.active = true; + if (activateInitially) { + firstEnabledStep.activeChange.emit(true); + this.activeStepChanged.emit({ owner: this, index: firstEnabledStep.index }); + } + } + } + + private activateStep(step: IgxStepComponent) { + if (this.orientation === IgxStepperOrientation.Horizontal) { + step.changeHorizontalActiveStep(); + } else { + this.stepperService.expand(step); + } + } + + private moveToNextStep(next = true) { + let steps: IgxStepComponent[] = this.steps; + let activeStepIndex = this.stepperService.activeStep.index; + if (!next) { + steps = this.steps.reverse(); + activeStepIndex = steps.findIndex(s => s === this.stepperService.activeStep); + } + + const nextStep = steps.find((s, i) => i > activeStepIndex && s.isAccessible); + if (nextStep) { + this.activateStep(nextStep); + } + } +} diff --git a/projects/igniteui-angular/stepper/src/stepper/stepper.directive.ts b/projects/igniteui-angular/stepper/src/stepper/stepper.directive.ts new file mode 100644 index 00000000000..9d198e4b34c --- /dev/null +++ b/projects/igniteui-angular/stepper/src/stepper/stepper.directive.ts @@ -0,0 +1,195 @@ +import { Directive, ElementRef, HostBinding, Input, inject } from '@angular/core'; +import { IgxStep, IGX_STEP_COMPONENT } from './stepper.common'; +import { IgxStepperService } from './stepper.service'; + +/** + * Allows a custom element to be added as an active step indicator. + * + * @igxModule IgxStepperModule + * @igxTheme igx-stepper-theme + * @igxKeywords stepper + * @igxGroup Layouts + * + * @example + * + * + * edit + * + * + */ +@Directive({ + selector: '[igxStepActiveIndicator]', + standalone: true +}) +export class IgxStepActiveIndicatorDirective { } + +/** + * Allows a custom element to be added as a complete step indicator. + * + * @igxModule IgxStepperModule + * @igxTheme igx-stepper-theme + * @igxKeywords stepper + * @igxGroup Layouts + * + * @example + * + * + * check + * + * + */ +@Directive({ + selector: '[igxStepCompletedIndicator]', + standalone: true +}) +export class IgxStepCompletedIndicatorDirective { } + +/** + * Allows a custom element to be added as an invalid step indicator. + * + * @igxModule IgxStepperModule + * @igxTheme igx-stepper-theme + * @igxKeywords stepper + * @igxGroup Layouts + * + * @example + * + * + * error + * + * + */ +@Directive({ + selector: '[igxStepInvalidIndicator]', + standalone: true +}) +export class IgxStepInvalidIndicatorDirective { } + +/** + * Allows a custom element to be added as a step indicator. + * + * @igxModule IgxStepperModule + * @igxTheme igx-stepper-theme + * @igxKeywords stepper + * @igxGroup Layouts + * + * @example + * + * + * home + * + * + */ +@Directive({ + selector: '[igxStepIndicator]', + standalone: true +}) +export class IgxStepIndicatorDirective { } + +/** + * Allows a custom element to be added as a step title. + * + * @igxModule IgxStepperModule + * @igxTheme igx-stepper-theme + * @igxKeywords stepper + * @igxGroup Layouts + * + * @example + * + * + *

    Home

    + *
    + *
    + */ +@Directive({ + selector: '[igxStepTitle]', + standalone: true +}) +export class IgxStepTitleDirective { + @HostBinding('class.igx-stepper__step-title') + public defaultClass = true; +} + +/** + * Allows a custom element to be added as a step subtitle. + * + * @igxModule IgxStepperModule + * @igxTheme igx-stepper-theme + * @igxKeywords stepper + * @igxGroup Layouts + * + * @example + * + * + *

    Home Subtitle

    + *
    + *
    + */ +@Directive({ + selector: '[igxStepSubtitle]', + standalone: true +}) +export class IgxStepSubtitleDirective { + @HostBinding('class.igx-stepper__step-subtitle') + public defaultClass = true; +} + +/** + * Allows a custom element to be added as a step content. + * + * @igxModule IgxStepperModule + * @igxTheme igx-stepper-theme + * @igxKeywords stepper + * @igxGroup Layouts + * + * @example + * + * + *
    ...
    + *
    + *
    + */ +@Directive({ + selector: '[igxStepContent]', + standalone: true +}) +export class IgxStepContentDirective { + private step = inject(IGX_STEP_COMPONENT); + private stepperService = inject(IgxStepperService); + public elementRef = inject>(ElementRef); + + private get target(): IgxStep { + return this.step; + } + + @HostBinding('class.igx-stepper__step-content') + public defaultClass = true; + + @HostBinding('attr.role') + public role = 'tabpanel'; + + @HostBinding('attr.aria-labelledby') + public get stepId(): string { + return this.target.id; + } + + @HostBinding('attr.id') + @Input() + public id = this.target.id.replace('step', 'content'); + + @HostBinding('attr.tabindex') + @Input() + public get tabIndex(): number { + if (this._tabIndex !== null) { + return this._tabIndex; + } + + return this.stepperService.activeStep === this.target ? 0 : -1; + } + + public set tabIndex(val: number) { + this._tabIndex = val; + } + + private _tabIndex = null; +} diff --git a/projects/igniteui-angular/stepper/src/stepper/stepper.module.ts b/projects/igniteui-angular/stepper/src/stepper/stepper.module.ts new file mode 100644 index 00000000000..45fedb83aa6 --- /dev/null +++ b/projects/igniteui-angular/stepper/src/stepper/stepper.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { IGX_STEPPER_DIRECTIVES } from './public_api'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + ...IGX_STEPPER_DIRECTIVES + ], + exports: [ + ...IGX_STEPPER_DIRECTIVES + ] +}) +export class IgxStepperModule { } diff --git a/projects/igniteui-angular/stepper/src/stepper/stepper.service.ts b/projects/igniteui-angular/stepper/src/stepper/stepper.service.ts new file mode 100644 index 00000000000..af1bd30d227 --- /dev/null +++ b/projects/igniteui-angular/stepper/src/stepper/stepper.service.ts @@ -0,0 +1,163 @@ +import { Injectable } from '@angular/core'; +import { IgxStepper, IgxStepperOrientation, IStepChangingEventArgs } from './stepper.common'; +import { IgxStepComponent } from './step/step.component'; + +/** @hidden @internal */ +@Injectable() +export class IgxStepperService { + public activeStep: IgxStepComponent; + public previousActiveStep: IgxStepComponent; + public focusedStep: IgxStepComponent; + + public collapsingSteps: Set = new Set(); + public linearDisabledSteps: Set = new Set(); + public visitedSteps: Set = new Set(); + public stepper: IgxStepper; + + /** + * Activates the step, fires the steps change event and plays animations. + */ + public expand(step: IgxStepComponent): void { + if (this.activeStep === step) { + return; + } + + const cancel = this.emitActivatingEvent(step); + if (cancel) { + return; + } + + this.collapsingSteps.delete(step); + + this.previousActiveStep = this.activeStep; + this.activeStep = step; + this.activeStep.activeChange.emit(true); + + this.collapsingSteps.add(this.previousActiveStep); + this.visitedSteps.add(this.activeStep); + + if (this.stepper.orientation === IgxStepperOrientation.Vertical) { + this.previousActiveStep.playCloseAnimation( + this.previousActiveStep.contentContainer + ); + this.activeStep.cdr.detectChanges(); + + this.activeStep.playOpenAnimation( + this.activeStep.contentContainer + ); + } else { + this.activeStep.cdr.detectChanges(); + this.stepper.playHorizontalAnimations(); + } + } + + /** + * Activates the step and fires the steps change event without playing animations. + */ + public expandThroughApi(step: IgxStepComponent): void { + if (this.activeStep === step) { + return; + } + + this.collapsingSteps.clear(); + + this.previousActiveStep = this.activeStep; + this.activeStep = step; + + if (this.previousActiveStep) { + this.previousActiveStep.tabIndex = -1; + } + this.activeStep.tabIndex = 0; + this.visitedSteps.add(this.activeStep); + + this.activeStep.cdr.markForCheck(); + this.previousActiveStep?.cdr.markForCheck(); + + this.activeStep.activeChange.emit(true); + this.previousActiveStep?.activeChange.emit(false); + } + + /** + * Collapses the currently active step and fires the change event. + */ + public collapse(step: IgxStepComponent): void { + if (this.activeStep === step) { + return; + } + step.activeChange.emit(false); + this.collapsingSteps.delete(step); + } + + /** + * Determines the steps that should be marked as visited based on the active step. + */ + public calculateVisitedSteps(): void { + this.stepper.steps.forEach(step => { + if (step.index <= this.activeStep.index) { + this.visitedSteps.add(step); + } else { + this.visitedSteps.delete(step); + } + }); + } + + /** + * Determines the steps that should be disabled in linear mode based on the validity of the active step. + */ + public calculateLinearDisabledSteps(): void { + if (!this.activeStep) { + return; + } + + if (this.activeStep.isValid) { + const firstRequiredIndex = this.getNextRequiredStep(); + if (firstRequiredIndex !== -1) { + this.updateLinearDisabledSteps(firstRequiredIndex); + } else { + this.linearDisabledSteps.clear(); + } + } else { + this.stepper.steps.forEach(s => { + if (s.index > this.activeStep.index) { + this.linearDisabledSteps.add(s); + } + }); + } + } + + public emitActivatingEvent(step: IgxStepComponent): boolean { + const args: IStepChangingEventArgs = { + owner: this.stepper, + newIndex: step.index, + oldIndex: this.activeStep.index, + cancel: false + }; + + this.stepper.activeStepChanging.emit(args); + return args.cancel; + } + + /** + * Updates the linearDisabled steps from the current active step to the next required invalid step. + * + * @param toIndex the index of the last step that should be enabled. + */ + private updateLinearDisabledSteps(toIndex: number): void { + this.stepper.steps.forEach(s => { + if (s.index > this.activeStep.index) { + if (s.index <= toIndex) { + this.linearDisabledSteps.delete(s); + } else { + this.linearDisabledSteps.add(s); + } + } + }); + } + + private getNextRequiredStep(): number { + if (!this.activeStep) { + return; + } + return this.stepper.steps.findIndex(s => s.index > this.activeStep.index && !s.optional && !s.disabled && !s.isValid); + } +} diff --git a/projects/igniteui-angular/switch/README.md b/projects/igniteui-angular/switch/README.md new file mode 100644 index 00000000000..7ac218251d9 --- /dev/null +++ b/projects/igniteui-angular/switch/README.md @@ -0,0 +1,73 @@ +# igx-switch + +`igx-switch` is a selection component that allows users to make a binary choice for a certain condition. It behaves similar to the switch component sans the indeterminate state. +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/switch) + +Basic usage of `igx-switch` + +```html +
      +
    • + + {{ task.description }} + +
    • +
    +``` + +You can easily use it within forms with `[(ngModel)]` + +```html +
    +
    +
    + + {{ item.description }} + +
    +
    + +``` + +### Switch Label + +The switch label is set to anything passed between the opening and closing tags of the `` component. + +The position of the label can be set to either `before` or `after`(default) the switch using the `labelPosition` input property. For instance, to set the label position ___before___ the switch: + +```html +Label +``` + +### Ripple Touch Feedback + +The `igx-switch` is styled according to the Google's Material spec, and provides a ripple effect around the switch's thumb when the switch is clicked/tapped. +To disable the ripple effect, do: + +```html + +``` + +# API Summary +| Name | Type | Description | +|:----------|:-------------:|:------| +| `@Input()` id | string | The unique `id` attribute to be used for the switch. If you do not provide a value, it will be auto-generated. | +| `@Input()` labelId | string | The unique `id` attribute to be used for the switch label. If you do not provide a value, it will be auto-generated. | +| `@Input()` name | string | The `name` attribute to be used for the switch. | +| `@Input()` value | any | The value to be set for the switch. | +| `@Input()` tabindex | number | Specifies the tabbing order of the switch. | +| `@Input()` checked | boolean | Specifies the checked state of the switch. | +| `@Input()` required | boolean | Specifies the required state of the switch. | +| `@Input()` disabled | boolean | Specifies the disabled state of the switch. | +| `@Input()` disableRipple | boolean | Specifies the whether the ripple effect should be disabled for the switch. | +| `@Input()` labelPosition | string `|` enum LabelPosition | Specifies the position of the text label relative to the switch element. | +| `@Input("aria-labelledby")` ariaLabelledBy | string | Specify an external element by id to be used as label for the switch. | +| `@Output()` change | EventEmitter | Emitted when the switch checked value changes. | + +### Methods + +| toggle | +|:----------| +| Toggles the checked state of the switch. | diff --git a/projects/igniteui-angular/switch/index.ts b/projects/igniteui-angular/switch/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/switch/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/switch/ng-package.json b/projects/igniteui-angular/switch/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/switch/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/switch/src/public_api.ts b/projects/igniteui-angular/switch/src/public_api.ts new file mode 100644 index 00000000000..e7c487770a5 --- /dev/null +++ b/projects/igniteui-angular/switch/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './switch/public_api'; +export * from './switch/switch.module'; diff --git a/projects/igniteui-angular/switch/src/switch/public_api.ts b/projects/igniteui-angular/switch/src/switch/public_api.ts new file mode 100644 index 00000000000..9770834d6e5 --- /dev/null +++ b/projects/igniteui-angular/switch/src/switch/public_api.ts @@ -0,0 +1,2 @@ +export * from './switch.component'; +export * from './switch.module'; diff --git a/projects/igniteui-angular/switch/src/switch/switch.component.html b/projects/igniteui-angular/switch/src/switch/switch.component.html new file mode 100644 index 00000000000..e525680a699 --- /dev/null +++ b/projects/igniteui-angular/switch/src/switch/switch.component.html @@ -0,0 +1,33 @@ + + + +
    +
    +
    +
    +
    + + + + diff --git a/projects/igniteui-angular/switch/src/switch/switch.component.spec.ts b/projects/igniteui-angular/switch/src/switch/switch.component.spec.ts new file mode 100644 index 00000000000..f7776837d07 --- /dev/null +++ b/projects/igniteui-angular/switch/src/switch/switch.component.spec.ts @@ -0,0 +1,390 @@ +import { Component, ViewChild, inject } from '@angular/core'; +import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { UntypedFormBuilder, FormsModule, ReactiveFormsModule, Validators, NgForm } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { IgxSwitchComponent } from './switch.component'; + +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +describe('IgxSwitch', () => { + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + InitSwitchComponent, + SwitchSimpleComponent, + SwitchRequiredComponent, + SwitchExternalLabelComponent, + SwitchInvisibleLabelComponent, + SwitchFormComponent, + SwitchFormGroupComponent, + IgxSwitchComponent + ] + }).compileComponents(); + })); + + it('Initializes', () => { + const fixture = TestBed.createComponent(InitSwitchComponent); + fixture.detectChanges(); + + const switchComp = fixture.componentInstance.switch; + const domSwitch = fixture.debugElement.query(By.css('igx-switch')).nativeElement; + const nativeCheckbox = switchComp.nativeInput.nativeElement; + const nativeLabel = switchComp.nativeLabel.nativeElement; + const placeholderLabel = fixture.debugElement.query(By.css('.igx-switch__label')).nativeElement; + + expect(domSwitch.id).toContain('igx-checkbox-'); + expect(nativeCheckbox).toBeTruthy(); + expect(nativeCheckbox.id).toContain('igx-checkbox-'); + expect(nativeCheckbox.getAttribute('aria-label')).toEqual(null); + expect(nativeCheckbox.getAttribute('aria-labelledby')).toContain('igx-checkbox-'); + + expect(nativeLabel).toBeTruthy(); + // No longer have a for attribute to not propagate clicks to the native checkbox + // expect(nativeLabel.getAttribute('for')).toEqual('igx-switch-0-input'); + + expect(placeholderLabel.textContent.trim()).toEqual('Init'); + expect(placeholderLabel.classList).toContain('igx-switch__label'); + expect(placeholderLabel.getAttribute('id')).toContain('igx-checkbox-'); + + switchComp.id = 'customSwitch'; + fixture.detectChanges(); + expect(switchComp.id).toBe('customSwitch'); + expect(domSwitch.id).toBe('customSwitch'); + + // When aria-label is present, aria-labeledby shouldn't be + switchComp.ariaLabel = 'New Label'; + fixture.detectChanges(); + expect(nativeCheckbox.getAttribute('aria-labelledby')).toEqual(null); + expect(nativeCheckbox.getAttribute('aria-label')).toMatch('New Label'); + }); + + it('Initializes with ngModel', () => { + const fixture = TestBed.createComponent(SwitchSimpleComponent); + fixture.detectChanges(); + + const testInstance = fixture.componentInstance; + const switchInstance = testInstance.switch; + const nativeCheckbox = switchInstance.nativeInput.nativeElement; + + fixture.detectChanges(); + + expect(nativeCheckbox.checked).toBe(false); + expect(switchInstance.checked).toBe(null); + + testInstance.subscribed = true; + switchInstance.name = 'my-switch'; + fixture.detectChanges(); + + expect(nativeCheckbox.checked).toBe(true); + expect(switchInstance.checked).toBe(true); + expect(switchInstance.name).toEqual('my-switch'); + }); + + it('Initializes with form group', () => { + const fixture = TestBed.createComponent(SwitchFormGroupComponent); + fixture.detectChanges(); + + const testInstance = fixture.componentInstance; + const switchInstance = testInstance.switch; + const form = testInstance.myForm; + + form.setValue({ switch: true }); + expect(switchInstance.checked).toBe(true); + + form.reset(); + + expect(switchInstance.checked).toBe(null); + }); + + it('Initializes with external label', () => { + const fixture = TestBed.createComponent(SwitchExternalLabelComponent); + const switchInstance = fixture.componentInstance.switch; + const nativeCheckbox = switchInstance.nativeInput.nativeElement; + const externalLabel = fixture.debugElement.query(By.css('#my-label')).nativeElement; + fixture.detectChanges(); + + expect(nativeCheckbox.getAttribute('aria-labelledby')).toMatch(externalLabel.getAttribute('id')); + expect(externalLabel.textContent).toMatch(fixture.componentInstance.label); + }); + + it('Initializes with invisible label', () => { + const fixture = TestBed.createComponent(SwitchInvisibleLabelComponent); + const switchInstance = fixture.componentInstance.switch; + const nativeCheckbox = switchInstance.nativeInput.nativeElement; + fixture.detectChanges(); + + expect(nativeCheckbox.getAttribute('aria-label')).toMatch(fixture.componentInstance.label); + }); + + it('Positions label before and after switch', () => { + const fixture = TestBed.createComponent(SwitchSimpleComponent); + const switchInstance = fixture.componentInstance.switch; + const placeholderLabel = switchInstance.placeholderLabel.nativeElement; + const labelStyles = window.getComputedStyle(placeholderLabel); + fixture.detectChanges(); + + expect(labelStyles.order).toEqual('0'); + + switchInstance.labelPosition = 'before'; + fixture.detectChanges(); + + expect(labelStyles.order).toEqual('-1'); + }); + + it('Required state', () => { + const fixture = TestBed.createComponent(SwitchRequiredComponent); + const testInstance = fixture.componentInstance; + const switchInstance = testInstance.switch; + const nativeCheckbox = switchInstance.nativeInput.nativeElement; + fixture.detectChanges(); + + expect(switchInstance.required).toBe(true); + expect(nativeCheckbox.required).toBeTruthy(); + + switchInstance.required = false; + nativeCheckbox.required = false; + fixture.detectChanges(); + + expect(switchInstance.required).toBe(false); + expect(nativeCheckbox.required).toBe(false); + }); + + it('Disabled state', () => { + const fixture = TestBed.createComponent(IgxSwitchComponent); + const switchInstance = fixture.componentInstance; + switchInstance.id = "root1"; + switchInstance.disabled = true; + const nativeCheckbox = switchInstance.nativeInput.nativeElement as HTMLInputElement; + const nativeLabel = switchInstance.nativeLabel.nativeElement; + const placeholderLabel = switchInstance.placeholderLabel.nativeElement; + fixture.detectChanges(); + + expect(switchInstance.disabled).toBe(true); + expect(nativeCheckbox.disabled).toBe(true); + + nativeCheckbox.click(); + nativeLabel.click(); + placeholderLabel.click(); + fixture.detectChanges(); + + // Should not update + expect(switchInstance.checked).toBe(false); + }); + + it('Event handling', () => { + const fixture = TestBed.createComponent(SwitchSimpleComponent); + const testInstance = fixture.componentInstance; + const switchInstance = testInstance.switch; + const nativeCheckbox = switchInstance.nativeInput.nativeElement; + const nativeLabel = switchInstance.nativeLabel.nativeElement; + const placeholderLabel = switchInstance.placeholderLabel.nativeElement; + const switchEl = fixture.debugElement.query(By.directive(IgxSwitchComponent)).nativeElement; + + fixture.detectChanges(); + expect(switchInstance.focused).toBe(false); + + switchEl.dispatchEvent(new KeyboardEvent('keyup')); + fixture.detectChanges(); + expect(switchInstance.focused).toBe(true); + + nativeCheckbox.dispatchEvent(new Event('blur')); + fixture.detectChanges(); + expect(switchInstance.focused).toBe(false); + + nativeLabel.click(); + fixture.detectChanges(); + + expect(testInstance.changeEventCalled).toBe(true); + expect(testInstance.subscribed).toBe(true); + expect(testInstance.clickCounter).toEqual(1); + + placeholderLabel.click(); + fixture.detectChanges(); + + expect(testInstance.changeEventCalled).toBe(true); + expect(testInstance.subscribed).toBe(false); + expect(testInstance.clickCounter).toEqual(2); + }); + + it('Should update style when required switch\'s value is set.', () => { + const fixture = TestBed.createComponent(SwitchRequiredComponent); + fixture.detectChanges(); + + const switchInstance = fixture.componentInstance.switch; + const domSwitch = fixture.debugElement.query(By.css('igx-switch')).nativeElement; + + expect(domSwitch.classList.contains('igx-switch--invalid')).toBe(false); + expect(switchInstance.invalid).toBe(false); + expect(switchInstance.checked).toBe(false); + expect(switchInstance.required).toBe(true); + + dispatchCbEvent('keyup', domSwitch, fixture); + expect(domSwitch.classList.contains('igx-switch--focused')).toBe(true); + dispatchCbEvent('blur', domSwitch, fixture); + + expect(switchInstance.invalid).toBe(true); + expect(domSwitch.classList.contains('igx-switch--invalid')).toBe(true); + + dispatchCbEvent('keyup', domSwitch, fixture); + expect(domSwitch.classList.contains('igx-switch--focused')).toBe(true); + dispatchCbEvent('click', domSwitch, fixture); + + expect(domSwitch.classList.contains('igx-switch--checked')).toBe(true); + expect(switchInstance.checked).toBe(true); + expect(switchInstance.invalid).toBe(false); + expect(domSwitch.classList.contains('igx-switch--invalid')).toBe(false); + + dispatchCbEvent('click', domSwitch, fixture); + dispatchCbEvent('keyup', domSwitch, fixture); + expect(domSwitch.classList.contains('igx-switch--focused')).toBe(true); + dispatchCbEvent('blur', domSwitch, fixture); + + expect(switchInstance.checked).toBe(false); + expect(switchInstance.invalid).toBe(true); + expect(domSwitch.classList.contains('igx-switch--invalid')).toBe(true); + }); + + it('Should work properly with ngModel', fakeAsync(() => { + const fixture = TestBed.createComponent(SwitchFormComponent); + fixture.detectChanges(); + tick(); + + const switchEl = fixture.componentInstance.switch; + expect(switchEl.invalid).toEqual(false); + + switchEl.onBlur(); + expect(switchEl.invalid).toEqual(true); + + fixture.componentInstance.ngForm.resetForm(); + tick(); + expect(switchEl.invalid).toEqual(false); + })); + + it('Should work properly with reactive forms validation.', () => { + const fixture = TestBed.createComponent(SwitchFormGroupComponent); + fixture.detectChanges(); + + const switchEl = fixture.componentInstance.switch; + const switchNative = fixture.debugElement.query(By.directive(IgxSwitchComponent)).nativeElement; + expect(switchEl.required).toBe(true); + expect(switchEl.invalid).toBe(false); + expect(switchNative.classList.contains('igx-switch--invalid')).toBe(false); + expect(switchEl.nativeElement.getAttribute('aria-required')).toEqual('true'); + expect(switchEl.nativeElement.getAttribute('aria-invalid')).toEqual('false'); + + dispatchCbEvent('keyup', switchNative, fixture); + expect(switchEl.focused).toBe(true); + dispatchCbEvent('blur', switchNative, fixture); + + expect(switchNative.classList.contains('igx-switch--invalid')).toBe(true); + expect(switchEl.invalid).toBe(true); + expect(switchEl.nativeElement.getAttribute('aria-invalid')).toEqual('true'); + + switchEl.checked = true; + fixture.detectChanges(); + + expect(switchNative.classList.contains('igx-switch--invalid')).toBe(false); + expect(switchEl.invalid).toBe(false); + expect(switchEl.nativeElement.getAttribute('aria-invalid')).toEqual('false'); + }); + + describe('EditorProvider', () => { + it('Should return correct edit element', () => { + const fixture = TestBed.createComponent(SwitchSimpleComponent); + fixture.detectChanges(); + + const instance = fixture.componentInstance.switch; + const editElement = fixture.debugElement.query(By.css('.igx-switch__input')).nativeElement; + + expect(instance.getEditElement()).toBe(editElement); + }); + }); +}); + +@Component({ + template: `Init`, + imports: [IgxSwitchComponent] +}) +class InitSwitchComponent { + @ViewChild('switch', { static: true }) public switch: IgxSwitchComponent; +} + +@Component({ + template: `Simple`, + imports: [FormsModule, IgxSwitchComponent] +}) +class SwitchSimpleComponent { + @ViewChild('switch', { static: true }) public switch: IgxSwitchComponent; + public changeEventCalled = false; + public subscribed = false; + public clickCounter = 0; + public onChange() { + this.changeEventCalled = true; + } + public onClick() { + this.clickCounter++; + } +} + +@Component({ + template: `Required`, + imports: [IgxSwitchComponent] +}) +class SwitchRequiredComponent { + @ViewChild('switch', { static: true }) public switch: IgxSwitchComponent; +} + +@Component({ + template: `

    {{label}}

    + `, + imports: [IgxSwitchComponent] +}) +class SwitchExternalLabelComponent { + @ViewChild('switch', { static: true }) public switch: IgxSwitchComponent; + public label = 'My Label'; +} + +@Component({ + template: ``, + imports: [IgxSwitchComponent] +}) +class SwitchInvisibleLabelComponent { + @ViewChild('switch', { static: true }) public switch: IgxSwitchComponent; + public label = 'Invisible Label'; +} + +@Component({ + template: `
    Form Group`, + imports: [ReactiveFormsModule, IgxSwitchComponent] +}) +class SwitchFormGroupComponent { + private fb = inject(UntypedFormBuilder); + + @ViewChild('switch', { static: true }) public switch: IgxSwitchComponent; + + public myForm = this.fb.group({ switch: ['', Validators.required] }); +} + +@Component({ + template: ` +
    + Switch + + `, + imports: [FormsModule, IgxSwitchComponent] +}) +class SwitchFormComponent { + @ViewChild('switch', { read: IgxSwitchComponent, static: true }) + public switch: IgxSwitchComponent; + @ViewChild(NgForm, { static: true }) + public ngForm: NgForm; + public subscribed: string; +} + +const dispatchCbEvent = (eventName, switchNativeElement, fixture) => { + switchNativeElement.dispatchEvent(new Event(eventName)); + fixture.detectChanges(); +}; diff --git a/projects/igniteui-angular/switch/src/switch/switch.component.ts b/projects/igniteui-angular/switch/src/switch/switch.component.ts new file mode 100644 index 00000000000..c60dcbf39f4 --- /dev/null +++ b/projects/igniteui-angular/switch/src/switch/switch.component.ts @@ -0,0 +1,114 @@ +import { + Component, + HostBinding, + Input, + AfterViewInit, + booleanAttribute +} from '@angular/core'; +import { ControlValueAccessor } from '@angular/forms'; +import { CheckboxBaseDirective, IgxRippleDirective } from 'igniteui-angular/directives'; +import { EditorProvider, EDITOR_PROVIDER } from 'igniteui-angular/core'; + +/** + * + * The Switch component is a binary choice selection component. + * + * @igxModule IgxSwitchModule + * + * @igxTheme igx-switch-theme, igx-tooltip-theme + * + * @igxKeywords switch, states, tooltip + * + * @igxGroup Data Entry & Display + * @remarks + * + * The Ignite UI Switch lets the user toggle between on/off or true/false states. + * + * @example + * ```html + * + * Simple switch + * + * ``` + */ +@Component({ + providers: [{ + provide: EDITOR_PROVIDER, + useExisting: IgxSwitchComponent, + multi: true + }], + selector: 'igx-switch', + templateUrl: 'switch.component.html', + imports: [IgxRippleDirective] +}) +export class IgxSwitchComponent + extends CheckboxBaseDirective + implements ControlValueAccessor, EditorProvider, AfterViewInit { + /** + * Returns the class of the switch component. + * + * @example + * ```typescript + * let switchClass = this.switch.cssClass; + * ``` + */ + @HostBinding('class.igx-switch') + public override cssClass = 'igx-switch'; + /** + * Sets/gets whether the switch is on or off. + * Default value is 'false'. + * + * @example + * ```html + * + * ``` + */ + @HostBinding('class.igx-switch--checked') + @Input() + public override set checked(value: boolean) { + super.checked = value; + } + public override get checked() { + return super.checked; + } + /** + * Sets/gets the `disabled` attribute. + * Default value is `false`. + * + * @example + * ```html + * + * ``` + */ + @HostBinding('class.igx-switch--disabled') + @Input({ transform: booleanAttribute }) + public override disabled = false; + + /** + * Sets/gets whether the switch component is invalid. + * Default value is `false`. + * + * @example + * ```html + * + * ``` + * ```typescript + * let isInvalid = this.switch.invalid; + * ``` + */ + @HostBinding('class.igx-switch--invalid') + @Input({ transform: booleanAttribute }) + public override invalid = false; + + /** + * Sets/gets whether the switch component is on focus. + * Default value is `false`. + * + * @example + * ```typescript + * this.switch.focused = true; + * ``` + */ + @HostBinding('class.igx-switch--focused') + public override focused = false; +} diff --git a/projects/igniteui-angular/switch/src/switch/switch.module.ts b/projects/igniteui-angular/switch/src/switch/switch.module.ts new file mode 100644 index 00000000000..ffd6b1a6db7 --- /dev/null +++ b/projects/igniteui-angular/switch/src/switch/switch.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { IgxSwitchComponent } from './switch.component'; + +/** + * @hidden + * @deprecated + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + IgxSwitchComponent + ], + exports: [ + IgxSwitchComponent + ] +}) +export class IgxSwitchModule { } diff --git a/projects/igniteui-angular/tabs/README.md b/projects/igniteui-angular/tabs/README.md new file mode 100644 index 00000000000..42bd2c0cee4 --- /dev/null +++ b/projects/igniteui-angular/tabs/README.md @@ -0,0 +1,93 @@ +# igx-tabs + +## Description +_igx-tabs component allows you to add a tabs component with tab items, positioned at the top, and item content in your application. The tabs in Ignite UI for Angular can be composed with the following components and directives:_ + +- *igx-tab-item* - single content area that holds header and content components +- *igx-tab-header* - holds the title and/or icon of the item and you can add them with `igxTabHeaderIcon` and `igxTabHeaderLabel` +- *igx-tab-content* - represents the wrapper of the content that needs to be displayed + +Each item (`igx-tab-item`) contains header (`igx-tab-header`) and content (`igx-tab-content`). When a tab is clicked, the associated content is selected and visualized into a single container. There should always be a selected tab. Only one tab can be selected at a time. +A walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/tabs). + +---------- +## Usage + + + + + folder + Tab 1 + + + Content 1 + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius sapien ligula. + + + + + + folder + Tab 2 + + + Content 2 + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius sapien ligula. + + + + + + folder + Tab 3 + + + Content 3 + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius sapien ligula. + + + + + + folder + Tab 4 + + + Content 4 + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius sapien ligula. + + + + + +# API Summary + +## igx-tabs + +### Properties + +| Name | Type | Description | +|:----------|:-------------:|:------| +| `items` | QueryList | Observable collection of `IgxTabItemDirective` content children. | +| `tabAlignment` | string | Property which determines the tab alignment. Defaults to `start`. | +| `selectedIndex` | number | Gets/Sets the index of selected tab in the respective collection. Default value is 0 if content is defined, otherwise defaults to -1. | +| `disableAnimation` | boolean | Enables/disables the transition animation of the content. | +| `selectedItem` | IgxTabItemDirective | Gets the selected `IgxTabItemDirective` in the igx-tabs based on selectedIndex. | + + +### Events + +| Name | Description | +|:---------- |:-----------------------------------------| +| `selectedIndexChange` | Emitted when the new tab item is selected. | +| `selectedIndexChanging` | Emitted when the selected index is about to change. This event is cancelable. | +| `selectedItemChange` | Emitted when the new tab is selected. | + +## igx-tab-item + +### Properties + +| Name | Type | Description | +|:----------|:-------------:|:------| +| `selected` | boolean | Determines whether the item is selected. | +| `disabled` | boolean | Determines whether the item is disabled. | diff --git a/projects/igniteui-angular/tabs/index.ts b/projects/igniteui-angular/tabs/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/tabs/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/tabs/ng-package.json b/projects/igniteui-angular/tabs/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/tabs/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/tabs/src/public_api.ts b/projects/igniteui-angular/tabs/src/public_api.ts new file mode 100644 index 00000000000..b3fe78ede65 --- /dev/null +++ b/projects/igniteui-angular/tabs/src/public_api.ts @@ -0,0 +1 @@ +export * from './tabs/public_api'; diff --git a/projects/igniteui-angular/tabs/src/tabs/public_api.ts b/projects/igniteui-angular/tabs/src/tabs/public_api.ts new file mode 100644 index 00000000000..d52db648ae9 --- /dev/null +++ b/projects/igniteui-angular/tabs/src/tabs/public_api.ts @@ -0,0 +1,7 @@ +export * from './tabs/public_api'; +export * from './tabs/tabs.module'; +export * from './tabs.base'; +export * from './tabs.directive'; +export * from './tab-item.directive'; +export * from './tab-header.directive'; +export * from './tab-content.directive'; diff --git a/projects/igniteui-angular/tabs/src/tabs/tab-content.directive.ts b/projects/igniteui-angular/tabs/src/tabs/tab-content.directive.ts new file mode 100644 index 00000000000..f322fb1f8dc --- /dev/null +++ b/projects/igniteui-angular/tabs/src/tabs/tab-content.directive.ts @@ -0,0 +1,32 @@ +import { Directive, ElementRef, HostBinding, inject } from '@angular/core'; +import { IgxTabItemDirective } from './tab-item.directive'; +import { IgxTabContentBase } from './tabs.base'; + +@Directive() +export abstract class IgxTabContentDirective implements IgxTabContentBase { + /** @hidden */ + public tab = inject(IgxTabItemDirective); + /** @hidden */ + private elementRef = inject(ElementRef); + + /** @hidden */ + @HostBinding('attr.role') + public role = 'tabpanel'; + + /** @hidden */ + @HostBinding('attr.tabindex') + public get tabIndex() { + return this.tab.selected ? 0 : -1; + } + + /** @hidden */ + @HostBinding('style.z-index') + public get zIndex() { + return this.tab.selected ? 'auto' : -1; + } + + /** @hidden */ + public get nativeElement() { + return this.elementRef.nativeElement; + } +} diff --git a/projects/igniteui-angular/tabs/src/tabs/tab-header.directive.ts b/projects/igniteui-angular/tabs/src/tabs/tab-header.directive.ts new file mode 100644 index 00000000000..4108e2530aa --- /dev/null +++ b/projects/igniteui-angular/tabs/src/tabs/tab-header.directive.ts @@ -0,0 +1,52 @@ + +import { Directive, ElementRef, HostBinding, HostListener, inject } from '@angular/core'; +import { PlatformUtil } from 'igniteui-angular/core'; +import { IgxTabItemDirective } from './tab-item.directive'; +import { IgxTabHeaderBase, IgxTabsBase } from './tabs.base'; + +@Directive() +export abstract class IgxTabHeaderDirective implements IgxTabHeaderBase { + /** @hidden */ + protected tabs = inject(IgxTabsBase); + /** @hidden */ + public tab = inject(IgxTabItemDirective); + /** @hidden */ + private elementRef = inject(ElementRef); + /** @hidden */ + protected platform = inject(PlatformUtil); + + /** @hidden */ + @HostBinding('attr.role') + public role = 'tab'; + + /** @hidden */ + @HostBinding('attr.tabindex') + public get tabIndex() { + return this.tab.selected ? 0 : -1; + } + + /** @hidden */ + @HostBinding('attr.aria-selected') + public get ariaSelected() { + return this.tab.selected; + } + + /** @hidden */ + @HostBinding('attr.aria-disabled') + public get ariaDisabled() { + return this.tab.disabled; + } + + /** @hidden */ + @HostListener('click') + public onClick() { + if (this.tab.panelComponent) { + this.tabs.selectTab(this.tab, true); + } + } + + /** @hidden */ + public get nativeElement() { + return this.elementRef.nativeElement; + } +} diff --git a/projects/igniteui-angular/tabs/src/tabs/tab-item.directive.ts b/projects/igniteui-angular/tabs/src/tabs/tab-item.directive.ts new file mode 100644 index 00000000000..9cb8ed85313 --- /dev/null +++ b/projects/igniteui-angular/tabs/src/tabs/tab-item.directive.ts @@ -0,0 +1,60 @@ +import { ContentChild, Directive, EventEmitter, Input, Output, TemplateRef, ViewChild, booleanAttribute, inject } from '@angular/core'; +import { IgxTabHeaderBase, IgxTabItemBase, IgxTabContentBase, IgxTabsBase } from './tabs.base'; +import { CarouselAnimationDirection, IgxSlideComponentBase } from 'igniteui-angular/carousel'; + +@Directive() +export abstract class IgxTabItemDirective implements IgxTabItemBase, IgxSlideComponentBase { + /** @hidden */ + private tabs = inject(IgxTabsBase); + + /** @hidden */ + @ContentChild(IgxTabHeaderBase) + public headerComponent: IgxTabHeaderBase; + + /** @hidden */ + @ContentChild(IgxTabContentBase) + public panelComponent: IgxTabContentBase; + + /** @hidden */ + @ViewChild('headerTemplate', { static: true }) + public headerTemplate: TemplateRef; + + /** @hidden */ + @ViewChild('panelTemplate', { static: true }) + public panelTemplate: TemplateRef; + + /** + * Output to enable support for two-way binding on [(selected)] + */ + @Output() + public selectedChange = new EventEmitter(); + + /** + * Disables the item. + */ + @Input({ transform: booleanAttribute }) + public disabled = false; + + /** @hidden */ + public direction = CarouselAnimationDirection.NONE; + /** @hidden */ + public previous: boolean; + + private _selected = false; + + /** + * Gets/Sets whether an item is selected. + */ + @Input({ transform: booleanAttribute }) + public get selected(): boolean { + return this._selected; + } + + public set selected(value: boolean) { + if (this._selected !== value) { + this._selected = value; + this.tabs.selectTab(this, this._selected); + this.selectedChange.emit(this._selected); + } + } +} diff --git a/projects/igniteui-angular/tabs/src/tabs/tabs.base.ts b/projects/igniteui-angular/tabs/src/tabs/tabs.base.ts new file mode 100644 index 00000000000..87b3775869e --- /dev/null +++ b/projects/igniteui-angular/tabs/src/tabs/tabs.base.ts @@ -0,0 +1,29 @@ +import { QueryList, TemplateRef } from '@angular/core'; + +/** @hidden */ +export abstract class IgxTabsBase { + public items: QueryList; + public selectedIndex: number; + public abstract selectTab(tab: IgxTabItemBase, selected: boolean); +} + +/** @hidden */ +export abstract class IgxTabItemBase { + public disabled: boolean; + public selected: boolean; + public headerTemplate: TemplateRef; + public panelTemplate: TemplateRef; + public headerComponent: IgxTabHeaderBase; + public panelComponent: IgxTabContentBase; +} + +/** @hidden */ +export abstract class IgxTabHeaderBase { + public nativeElement: HTMLElement; +} + +/** @hidden */ +export abstract class IgxTabContentBase { + public nativeElement: HTMLElement; +} + diff --git a/projects/igniteui-angular/tabs/src/tabs/tabs.directive.ts b/projects/igniteui-angular/tabs/src/tabs/tabs.directive.ts new file mode 100644 index 00000000000..3d6e6e322dc --- /dev/null +++ b/projects/igniteui-angular/tabs/src/tabs/tabs.directive.ts @@ -0,0 +1,305 @@ +import { + AfterViewInit, ContentChildren, Directive, EventEmitter, + Input, OnDestroy, Output, QueryList, booleanAttribute, + inject +} from '@angular/core'; +import { Subscription } from 'rxjs'; +import { IBaseEventArgs, ɵIgxDirectionality } from 'igniteui-angular/core'; +import { IgxTabItemDirective } from './tab-item.directive'; +import { IgxTabContentBase, IgxTabsBase } from './tabs.base'; +import { IgxCarouselComponentBase, CarouselAnimationDirection } from 'igniteui-angular/carousel'; + +export interface ITabsBaseEventArgs extends IBaseEventArgs { + readonly owner: IgxTabsDirective; +} + +export interface ITabsSelectedIndexChangingEventArgs extends ITabsBaseEventArgs { + cancel: boolean; + readonly oldIndex: number; + newIndex: number; +} + +export interface ITabsSelectedItemChangeEventArgs extends ITabsBaseEventArgs { + readonly oldItem: IgxTabItemDirective; + readonly newItem: IgxTabItemDirective; +} + +@Directive() +export abstract class IgxTabsDirective extends IgxCarouselComponentBase implements IgxTabsBase, AfterViewInit, OnDestroy { + /** @hidden */ + public dir = inject(ɵIgxDirectionality); + + /** + * Gets/Sets the index of the selected item. + * Default value is 0 if contents are defined otherwise defaults to -1. + */ + @Input() + public get selectedIndex(): number { + return this._selectedIndex; + } + + public set selectedIndex(value: number) { + if (this._selectedIndex !== value) { + let newIndex = value; + const oldIndex = this._selectedIndex; + const args: ITabsSelectedIndexChangingEventArgs = { + owner: this, + cancel: false, + oldIndex, + newIndex + }; + this.selectedIndexChanging.emit(args); + + if (!args.cancel) { + newIndex = args.newIndex; + this._selectedIndex = newIndex; + this.selectedIndexChange.emit(this._selectedIndex); + } + + this.updateSelectedTabs(oldIndex); + } + } + + /** + * Enables/disables the transition animation of the contents. + */ + @Input({ transform: booleanAttribute }) + public disableAnimation = false; + + /** + * Output to enable support for two-way binding on [(selectedIndex)] + */ + @Output() + public selectedIndexChange = new EventEmitter(); + + /** + * Emitted when the selected index is about to change. + */ + @Output() + public selectedIndexChanging = new EventEmitter(); + + /** + * Emitted when the selected item is changed. + */ + @Output() + public selectedItemChange = new EventEmitter(); + + /** + * Returns the items. + */ + @ContentChildren(IgxTabItemDirective) + public items: QueryList; + + /** + * Gets the selected item. + */ + public get selectedItem(): IgxTabItemDirective { + return this.items && this.selectedIndex >= 0 && this.selectedIndex < this.items.length ? + this.items.get(this.selectedIndex) : null; + } + + /** @hidden */ + @ContentChildren(IgxTabContentBase, { descendants: true }) + public panels: QueryList; + + /** @hidden */ + protected override currentItem: IgxTabItemDirective; + /** @hidden */ + protected override previousItem: IgxTabItemDirective; + /** @hidden */ + protected componentName: string; + + private _selectedIndex = -1; + private _itemChanges$: Subscription; + + /** @hidden */ + public ngAfterViewInit(): void { + if (this._selectedIndex === -1) { + const hasSelectedTab = this.items.some((tab, i) => { + if (tab.selected) { + this._selectedIndex = i; + } + return tab.selected; + }); + + if (!hasSelectedTab && this.hasPanels) { + this._selectedIndex = 0; + } + } + + // Use promise to avoid expression changed after check error + Promise.resolve().then(() => { + this.updateSelectedTabs(null, false); + }); + + this._itemChanges$ = this.items.changes.subscribe(() => { + this.onItemChanges(); + }); + + this.setAttributes(); + } + + /** @hidden */ + public override ngOnDestroy(): void { + super.ngOnDestroy(); + if (this._itemChanges$) { + this._itemChanges$.unsubscribe(); + } + } + + /** @hidden */ + public selectTab(tab: IgxTabItemDirective, selected: boolean): void { + if (!this.items) { + return; + } + + const tabs = this.items.toArray(); + + if (selected) { + const index = tabs.indexOf(tab); + if (index > -1) { + this.selectedIndex = index; + } + } else { + if (tabs.every(t => !t.selected)) { + this.selectedIndex = -1; + } + } + } + + /** @hidden */ + protected getPreviousElement(): HTMLElement { + return this.previousItem.panelComponent.nativeElement; + } + + /** @hidden */ + protected getCurrentElement(): HTMLElement { + return this.currentItem.panelComponent.nativeElement; + } + + /** @hidden */ + protected scrollTabHeaderIntoView() { + } + + /** @hidden */ + protected onItemChanges() { + this.setAttributes(); + + // Check if there is selected tab + let selectedIndex = -1; + this.items.some((tab, i) => { + if (tab.selected) { + selectedIndex = i; + } + return tab.selected; + }); + + if (selectedIndex >= 0) { + // Set the selected index to the tab that has selected=true + Promise.resolve().then(() => { + this.selectedIndex = selectedIndex; + }); + } else { + if (this.selectedIndex >= 0 && this.selectedIndex < this.items.length) { + // Select the tab on the same index the previous selected tab was + Promise.resolve().then(() => { + this.updateSelectedTabs(null); + }); + } else if (this.selectedIndex >= this.items.length) { + // Select the last tab + Promise.resolve().then(() => { + this.selectedIndex = this.items.length - 1; + }); + } + } + } + + private setAttributes() { + this.items.forEach(item => { + if (item.panelComponent && !item.headerComponent.nativeElement.getAttribute('id')) { + const id = this.getNextTabId(); + const tabHeaderId = `${this.componentName}-header-${id}`; + const tabPanelId = `${this.componentName}-content-${id}`; + + this.setHeaderAttribute(item, 'id', tabHeaderId); + this.setHeaderAttribute(item, 'aria-controls', tabPanelId); + this.setPanelAttribute(item, 'id', tabPanelId); + this.setPanelAttribute(item, 'aria-labelledby', tabHeaderId); + } + }); + } + + private setHeaderAttribute(item: IgxTabItemDirective, attrName: string, value: string) { + item.headerComponent.nativeElement.setAttribute(attrName, value); + } + + private setPanelAttribute(item: IgxTabItemDirective, attrName: string, value: string) { + item.panelComponent.nativeElement.setAttribute(attrName, value); + } + + private get hasPanels() { + return this.panels && this.panels.length; + } + + private updateSelectedTabs(oldSelectedIndex: number, raiseEvent = true) { + if (!this.items) { + return; + } + + let newTab: IgxTabItemDirective; + const oldTab = this.currentItem; + + // First select the new tab + if (this._selectedIndex >= 0 && this._selectedIndex < this.items.length) { + newTab = this.items.get(this._selectedIndex); + newTab.selected = true; + } + // Then unselect the other tabs + this.items.forEach((tab, i) => { + if (i !== this._selectedIndex) { + tab.selected = false; + } + }); + + if (this._selectedIndex !== oldSelectedIndex) { + this.scrollTabHeaderIntoView(); + this.triggerPanelAnimations(oldSelectedIndex); + + if (raiseEvent && newTab !== oldTab) { + this.selectedItemChange.emit({ + owner: this, + newItem: newTab, + oldItem: oldTab + }); + } + } + } + + private triggerPanelAnimations(oldSelectedIndex: number) { + const item = this.items.get(this._selectedIndex); + + if (item && + !this.disableAnimation && + this.hasPanels && + this.currentItem && + !this.currentItem.selected) { + item.direction = (!this.dir.rtl && this._selectedIndex > oldSelectedIndex) || + (this.dir.rtl && this._selectedIndex < oldSelectedIndex) + ? CarouselAnimationDirection.NEXT : CarouselAnimationDirection.PREV; + + if (this.previousItem && this.previousItem.previous) { + this.previousItem.previous = false; + } + this.currentItem.direction = item.direction; + + this.previousItem = this.currentItem; + this.currentItem = item; + this.triggerAnimations(); + } else { + this.currentItem = item; + } + } + + /** @hidden */ + protected abstract getNextTabId(); +} diff --git a/projects/igniteui-angular/tabs/src/tabs/tabs/public_api.ts b/projects/igniteui-angular/tabs/src/tabs/tabs/public_api.ts new file mode 100644 index 00000000000..0c75ed75537 --- /dev/null +++ b/projects/igniteui-angular/tabs/src/tabs/tabs/public_api.ts @@ -0,0 +1,28 @@ +import { IgxPrefixDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; +import { IgxTabContentComponent } from './tab-content.component'; +import { IgxTabHeaderComponent } from './tab-header.component'; +import { IgxTabItemComponent } from './tab-item.component'; +import { IgxTabsComponent } from './tabs.component'; +import { IgxTabHeaderIconDirective, IgxTabHeaderLabelDirective } from './tabs.directives'; + +export * from './tabs.component'; +export * from './tab-item.component'; +export * from './tab-header.component'; +export * from './tabs.directives'; +export * from './tab-content.component'; +export { + ITabsSelectedIndexChangingEventArgs, + ITabsSelectedItemChangeEventArgs +} from '../tabs.directive' + +/* NOTE: Tabs directives collection for ease-of-use import in standalone components scenario */ +export const IGX_TABS_DIRECTIVES = [ + IgxTabsComponent, + IgxTabItemComponent, + IgxTabHeaderComponent, + IgxTabContentComponent, + IgxTabHeaderLabelDirective, + IgxTabHeaderIconDirective, + IgxPrefixDirective, + IgxSuffixDirective +] as const; diff --git a/projects/igniteui-angular/tabs/src/tabs/tabs/tab-content.component.html b/projects/igniteui-angular/tabs/src/tabs/tabs/tab-content.component.html new file mode 100644 index 00000000000..8500c10e34a --- /dev/null +++ b/projects/igniteui-angular/tabs/src/tabs/tabs/tab-content.component.html @@ -0,0 +1,3 @@ +@if (tab.selected || tab.previous) { + +} diff --git a/projects/igniteui-angular/tabs/src/tabs/tabs/tab-content.component.ts b/projects/igniteui-angular/tabs/src/tabs/tabs/tab-content.component.ts new file mode 100644 index 00000000000..4b131585242 --- /dev/null +++ b/projects/igniteui-angular/tabs/src/tabs/tabs/tab-content.component.ts @@ -0,0 +1,15 @@ +import { Component, HostBinding } from '@angular/core'; +import { IgxTabContentDirective } from '../tab-content.directive'; +import { IgxTabContentBase } from '../tabs.base'; + +@Component({ + selector: 'igx-tab-content', + templateUrl: 'tab-content.component.html', + providers: [{ provide: IgxTabContentBase, useExisting: IgxTabContentComponent }], + imports: [] +}) +export class IgxTabContentComponent extends IgxTabContentDirective { + /** @hidden */ + @HostBinding('class.igx-tabs__panel') + public cssClass = true; +} diff --git a/projects/igniteui-angular/tabs/src/tabs/tabs/tab-header.component.html b/projects/igniteui-angular/tabs/src/tabs/tabs/tab-header.component.html new file mode 100644 index 00000000000..832c9fd7d13 --- /dev/null +++ b/projects/igniteui-angular/tabs/src/tabs/tabs/tab-header.component.html @@ -0,0 +1,7 @@ + + +
    + +
    + + diff --git a/projects/igniteui-angular/tabs/src/tabs/tabs/tab-header.component.ts b/projects/igniteui-angular/tabs/src/tabs/tabs/tab-header.component.ts new file mode 100644 index 00000000000..ec8944d7233 --- /dev/null +++ b/projects/igniteui-angular/tabs/src/tabs/tabs/tab-header.component.ts @@ -0,0 +1,120 @@ +import { AfterViewInit, Component, HostBinding, HostListener, NgZone, OnDestroy, inject } from '@angular/core'; +import { IgxTabHeaderDirective } from '../tab-header.directive'; +import { IgxTabHeaderBase } from '../tabs.base'; +import { IgxTabsComponent } from './tabs.component'; +import { getResizeObserver, ɵIgxDirectionality } from 'igniteui-angular/core'; + +@Component({ + selector: 'igx-tab-header', + templateUrl: 'tab-header.component.html', + providers: [{ provide: IgxTabHeaderBase, useExisting: IgxTabHeaderComponent }], + standalone: true +}) +export class IgxTabHeaderComponent extends IgxTabHeaderDirective implements AfterViewInit, OnDestroy { + protected override tabs = inject(IgxTabsComponent); + private ngZone = inject(NgZone); + private dir = inject(ɵIgxDirectionality); + + /** @hidden @internal */ + @HostBinding('class.igx-tabs__header-item--selected') + public get provideCssClassSelected(): boolean { + return this.tab.selected; + } + + /** @hidden @internal */ + @HostBinding('class.igx-tabs__header-item--disabled') + public get provideCssClassDisabled(): boolean { + return this.tab.disabled; + } + + /** @hidden @internal */ + @HostBinding('class.igx-tabs__header-item') + public cssClass = true; + + private _resizeObserver: ResizeObserver; + + /** @hidden @internal */ + @HostListener('keydown', ['$event']) + public keyDown(event: KeyboardEvent) { + let unsupportedKey = false; + const itemsArray = this.tabs.items.toArray(); + const previousIndex = itemsArray.indexOf(this.tab); + let newIndex = previousIndex; + const hasDisabledItems = itemsArray.some((item) => item.disabled); + + switch (event.key) { + case this.platform.KEYMAP.ARROW_RIGHT: + newIndex = this.getNewSelectionIndex(newIndex, itemsArray, event.key, hasDisabledItems); + break; + case this.platform.KEYMAP.ARROW_LEFT: + newIndex = this.getNewSelectionIndex(newIndex, itemsArray, event.key, hasDisabledItems); + break; + case this.platform.KEYMAP.HOME: + event.preventDefault(); + newIndex = 0; + while (itemsArray[newIndex].disabled && newIndex < itemsArray.length) { + newIndex = newIndex === itemsArray.length - 1 ? 0 : newIndex + 1; + } + break; + case this.platform.KEYMAP.END: + event.preventDefault(); + newIndex = itemsArray.length - 1; + while (hasDisabledItems && itemsArray[newIndex].disabled && newIndex > 0) { + newIndex = newIndex === 0 ? itemsArray.length - 1 : newIndex - 1; + } + break; + case this.platform.KEYMAP.ENTER: + case this.platform.KEYMAP.SPACE: + event.preventDefault(); + if (this.tabs.activation === 'manual') { + this.nativeElement.click(); + } + unsupportedKey = true; + break; + default: + unsupportedKey = true; + break; + } + + if (!unsupportedKey) { + itemsArray[newIndex].headerComponent.nativeElement.focus({ preventScroll: true }); + if (this.tabs.activation === 'auto') { + this.tabs.selectedIndex = newIndex; + } + } + } + + /** @hidden @internal */ + public ngAfterViewInit(): void { + this.ngZone.runOutsideAngular(() => { + if (this.platform.isBrowser) { + this._resizeObserver = new (getResizeObserver())(() => { + this.tabs.realignSelectedIndicator(); + }); + this._resizeObserver.observe(this.nativeElement); + } + }); + } + + /** @hidden @internal */ + public ngOnDestroy(): void { + this.ngZone.runOutsideAngular(() => { + this._resizeObserver?.disconnect(); + }); + } + + private getNewSelectionIndex(newIndex: number, itemsArray: any[], key: string, hasDisabledItems: boolean): number { + if ((key === this.platform.KEYMAP.ARROW_RIGHT && !this.dir.rtl) || (key === this.platform.KEYMAP.ARROW_LEFT && this.dir.rtl)) { + newIndex = newIndex === itemsArray.length - 1 ? 0 : newIndex + 1; + while (hasDisabledItems && itemsArray[newIndex].disabled && newIndex < itemsArray.length) { + newIndex = newIndex === itemsArray.length - 1 ? 0 : newIndex + 1; + } + } else { + newIndex = newIndex === 0 ? itemsArray.length - 1 : newIndex - 1; + while (hasDisabledItems && itemsArray[newIndex].disabled && newIndex >= 0) { + newIndex = newIndex === 0 ? itemsArray.length - 1 : newIndex - 1; + } + } + return newIndex; + } +} diff --git a/projects/igniteui-angular/tabs/src/tabs/tabs/tab-item.component.html b/projects/igniteui-angular/tabs/src/tabs/tabs/tab-item.component.html new file mode 100644 index 00000000000..f49b848d342 --- /dev/null +++ b/projects/igniteui-angular/tabs/src/tabs/tabs/tab-item.component.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/projects/igniteui-angular/tabs/src/tabs/tabs/tab-item.component.ts b/projects/igniteui-angular/tabs/src/tabs/tabs/tab-item.component.ts new file mode 100644 index 00000000000..ce23b6cba4d --- /dev/null +++ b/projects/igniteui-angular/tabs/src/tabs/tabs/tab-item.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; +import { IgxTabItemDirective } from '../tab-item.directive'; + +@Component({ + selector: 'igx-tab-item', + templateUrl: 'tab-item.component.html', + providers: [{ provide: IgxTabItemDirective, useExisting: IgxTabItemComponent }], + standalone: true +}) +export class IgxTabItemComponent extends IgxTabItemDirective { + +} diff --git a/projects/igniteui-angular/tabs/src/tabs/tabs/tabs.component.html b/projects/igniteui-angular/tabs/src/tabs/tabs/tabs.component.html new file mode 100644 index 00000000000..94df1e7c990 --- /dev/null +++ b/projects/igniteui-angular/tabs/src/tabs/tabs/tabs.component.html @@ -0,0 +1,26 @@ +
    + +
    +
    +
    + @for (tab of items; track tab; let i = $index) { + + } +
    + @if (items.length > 0) { +
    +
    + } +
    +
    + +
    +
    + @for (tab of items; track tab; let i = $index) { + + } +
    diff --git a/projects/igniteui-angular/tabs/src/tabs/tabs/tabs.component.spec.ts b/projects/igniteui-angular/tabs/src/tabs/tabs/tabs.component.spec.ts new file mode 100644 index 00000000000..fd25641aa9a --- /dev/null +++ b/projects/igniteui-angular/tabs/src/tabs/tabs/tabs.component.spec.ts @@ -0,0 +1,1473 @@ +import { QueryList } from '@angular/core'; +import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { IgxTabItemComponent } from './tab-item.component'; +import { IgxTabsAlignment, IgxTabsComponent } from './tabs.component'; + +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { By } from '@angular/platform-browser'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Router } from '@angular/router'; +import { Location } from '@angular/common'; +import { + AddingSelectedTabComponent, TabsContactsComponent, TabsDisabledTestComponent, TabsRoutingDisabledTestComponent, + TabsRoutingGuardTestComponent, TabsRoutingTestComponent, TabsRtlComponent, TabsTabsOnlyModeTest1Component, + TabsTest2Component, TabsTestBug4420Component, TabsTestComponent, TabsTestCustomStylesComponent, + TabsTestHtmlAttributesComponent, TabsTestSelectedTabComponent, TabsWithPrefixSuffixTestComponent, + TemplatedTabsTestComponent +} from '../../../../test-utils/tabs-components.spec'; +import { UIInteractions, wait } from '../../../../test-utils/ui-interactions.spec'; +import { IgxTabContentComponent } from './tab-content.component'; +import { RoutingTestGuard } from '../../../../test-utils/routing-test-guard.spec'; +import { RoutingView1Component, RoutingView2Component, RoutingView3Component, RoutingView4Component, RoutingView5Component } from '../../../../test-utils/routing-view-components.spec'; + +const KEY_RIGHT_EVENT = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }); +const KEY_LEFT_EVENT = new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }); +const KEY_HOME_EVENT = new KeyboardEvent('keydown', { key: 'Home', bubbles: true }); +const KEY_END_EVENT = new KeyboardEvent('keydown', { key: 'End', bubbles: true }); +const KEY_ENTER_EVENT = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }); +const KEY_SPACE_EVENT = new KeyboardEvent('keydown', { key: ' ', bubbles: true }); + +describe('IgxTabs', () => { + + const tabItemNormalCssClass = 'igx-tabs__header-item'; + const tabItemSelectedCssClass = 'igx-tabs__header-item--selected'; + const headerScrollCssClass = 'igx-tabs__header-scroll'; + const testRoutes = [ + { path: 'view1', component: RoutingView1Component, canActivate: [RoutingTestGuard] }, + { path: 'view2', component: RoutingView2Component, canActivate: [RoutingTestGuard] }, + { path: 'view3', component: RoutingView3Component, canActivate: [RoutingTestGuard] }, + { path: 'view4', component: RoutingView4Component, canActivate: [RoutingTestGuard] }, + { path: 'view5', component: RoutingView5Component, canActivate: [RoutingTestGuard] } + ]; + + beforeEach(waitForAsync(() => { + + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + RouterTestingModule.withRoutes(testRoutes), + TabsTestHtmlAttributesComponent, + TabsTestComponent, + TabsTest2Component, + TemplatedTabsTestComponent, + TabsRoutingDisabledTestComponent, + TabsTestSelectedTabComponent, + TabsTestCustomStylesComponent, + TabsTestBug4420Component, + TabsRoutingTestComponent, + TabsTabsOnlyModeTest1Component, + TabsDisabledTestComponent, + TabsRoutingGuardTestComponent, + TabsWithPrefixSuffixTestComponent, + TabsContactsComponent, + AddingSelectedTabComponent, + TabsRtlComponent + ], + providers: [RoutingTestGuard] + }).compileComponents(); + })); + + describe('IgxTabs Html Attributes', () => { + let fixture; + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(TabsTestHtmlAttributesComponent); + fixture.detectChanges(); + })); + + it('should set the correct attributes on the html elements', fakeAsync(() => { + const igxTabs = document.querySelectorAll('igx-tabs'); + expect(igxTabs.length).toBe(2); + const initialIndex = parseInt(document.querySelector('igx-tab-header').id.replace('igx-tabs-header-', ''), 10); + + igxTabs.forEach((tab, i) => { + const tabHeaders = tab.querySelectorAll('igx-tab-header'); + const tabPanels = tab.querySelectorAll('igx-tab-content'); + expect(tabHeaders.length).toBe(3); + expect(tabPanels.length).toBe(3); + + for (let itemIndex = 0; itemIndex < 3; itemIndex++) { + const headerId = `igx-tabs-header-${initialIndex + itemIndex + 3 * i}`; + const panelId = `igx-tabs-content-${initialIndex + itemIndex + 3 * i}`; + + expect(tabHeaders[itemIndex].id).toEqual(headerId); + expect(tabPanels[itemIndex].id).toEqual(panelId); + + expect(tabHeaders[itemIndex].getAttribute('aria-controls')).toEqual(panelId); + expect(tabPanels[itemIndex].getAttribute('aria-labelledby')).toEqual(headerId); + } + }); + })); + }); + + describe('IgxTabs Component with static Panels Definitions', () => { + let fixture; + let tabs; + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(TabsTestComponent); + fixture.detectChanges(); + tabs = fixture.componentInstance.tabs; + })); + + it('should initialize igx-tabs, igx-tab-content and igx-tab-item', fakeAsync(() => { + tick(100); + fixture.detectChanges(); + + const panels: IgxTabContentComponent[] = tabs.panels.toArray(); + const tabsItems: IgxTabItemComponent[] = tabs.items.toArray(); + + expect(tabs).toBeDefined(); + expect(tabs instanceof IgxTabsComponent).toBeTruthy(); + expect(tabs.panels instanceof QueryList).toBeTruthy(); + expect(tabs.panels.length).toBe(3); + + for (let i = 0; i < tabs.panels.length; i++) { + expect(panels[i] instanceof IgxTabContentComponent).toBeTruthy(); + expect(panels[i].tab).toBe(tabsItems[i]); + } + + expect(tabs.items.length).toBe(3); + + for (let i = 0; i < tabs.items.length; i++) { + expect(tabsItems[i] instanceof IgxTabItemComponent).toBeTruthy(); + expect(tabsItems[i].panelComponent).toBe(panels[i]); + } + tick(); + })); + + it('should initialize default values of properties', fakeAsync(() => { + tick(100); + fixture.detectChanges(); + + expect(tabs.selectedIndex).toBe(0); + + tick(100); + fixture.detectChanges(); + + const tabItems = tabs.items.toArray(); + expect(tabItems[0].disabled).toBe(false); + expect(tabItems[1].disabled).toBe(false); + })); + + it('should initialize set/get properties', fakeAsync(() => { + const icons = ['library_music', 'video_library', 'library_books']; + + tick(100); + fixture.detectChanges(); + + const tabItems = tabs.items.toArray(); + const tabHeaders = tabItems.map(item => item.headerComponent); + const tabHeaderElements = tabHeaders.map(item => item.nativeElement); + + for (let i = 0; i < tabHeaderElements.length; i++) { + const headerDiv = tabHeaderElements[i].firstChild; + expect(headerDiv.firstChild.localName).toBe('igx-icon'); + expect(headerDiv.firstChild.innerText).toBe(icons[i]); + expect(headerDiv.lastChild.localName).toBe('span'); + expect(headerDiv.lastChild.innerText).toBe('Tab ' + (i + 1)); + } + tick(); + })); + + it('should select/deselect tabs', fakeAsync(() => { + fixture.detectChanges(); + + expect(tabs.selectedIndex).toBe(0); + + tick(100); + fixture.detectChanges(); + const tabItems = tabs.items.toArray(); + const tab1: IgxTabItemComponent = tabItems[0]; + const tab2: IgxTabItemComponent = tabItems[1]; + + tab2.selected = true; + + tick(100); + fixture.detectChanges(); + + expect(tabs.selectedIndex).toBe(1); + expect(tabs.selectedItem).toBe(tab2); + expect(tab2.selected).toBeTruthy(); + expect(tab1.selected).toBeFalsy(); + + tab1.selected = true; + + tick(100); + fixture.detectChanges(); + + expect(tabs.selectedIndex).toBe(0); + expect(tabs.selectedItem).toBe(tab1); + expect(tab1.selected).toBeTruthy(); + expect(tab2.selected).toBeFalsy(); + + // Disabled tab is selectable programmatically + tab2.disabled = true; + tick(100); + fixture.detectChanges(); + + tab2.selected = true; + + tick(100); + fixture.detectChanges(); + + expect(tabs.selectedIndex).toBe(1); + expect(tabs.selectedItem).toBe(tab2); + expect(tab2.selected).toBeTruthy(); + expect(tab1.selected).toBeFalsy(); + })); + + it('should select next/previous tab when pressing right/left arrow', fakeAsync(() => { + tick(100); + fixture.detectChanges(); + const headers = tabs.items.map(item => item.headerComponent.nativeElement); + + headers[0].focus(); + headers[0].dispatchEvent(KEY_RIGHT_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabs.selectedIndex).toBe(1); + + headers[1].dispatchEvent(KEY_RIGHT_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabs.selectedIndex).toBe(2); + + headers[2].dispatchEvent(KEY_RIGHT_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabs.selectedIndex).toBe(0); + + headers[0].dispatchEvent(KEY_LEFT_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabs.selectedIndex).toBe(2); + + headers[2].dispatchEvent(KEY_LEFT_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabs.selectedIndex).toBe(1); + })); + + it('should select first/last tab when pressing home/end button', fakeAsync(() => { + tick(100); + fixture.detectChanges(); + const headers = tabs.items.map(item => item.headerComponent.nativeElement); + + headers[0].focus(); + headers[0].dispatchEvent(KEY_END_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabs.selectedIndex).toBe(2); + + headers[2].dispatchEvent(KEY_HOME_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabs.selectedIndex).toBe(0); + })); + + it('should scroll tab area when clicking left/right scroll buttons', fakeAsync(() => { + tick(100); + fixture.detectChanges(); + + fixture.componentInstance.wrapperDiv.nativeElement.style.width = '200px'; + tick(100); + fixture.detectChanges(); + + const rightScrollButton = tabs.headerContainer.nativeElement.children[2]; + window.dispatchEvent(new Event('resize')); + rightScrollButton.dispatchEvent(new Event('click', { bubbles: true })); + + tick(100); + fixture.detectChanges(); + expect(tabs.offset).toBeGreaterThan(0); + + tabs.scrollPrev(null); + + tick(100); + fixture.detectChanges(); + expect(tabs.offset).toBe(0); + })); + + it('should select tab on click', fakeAsync(() => { + tick(100); + fixture.detectChanges(); + const headers = tabs.items.map(item => item.headerComponent.nativeElement); + + fixture.componentInstance.wrapperDiv.nativeElement.style.width = '400px'; + tick(100); + fixture.detectChanges(); + + headers[2].dispatchEvent(new Event('click', { bubbles: true })); + tick(200); + fixture.detectChanges(); + expect(tabs.selectedIndex).toBe(2); + + headers[0].dispatchEvent(new Event('click', { bubbles: true })); + tick(200); + fixture.detectChanges(); + expect(tabs.selectedIndex).toBe(0); + })); + + it('should not select disabled tabs when navigating with left/right/home/end', fakeAsync(() => { + fixture = TestBed.createComponent(TabsDisabledTestComponent); + tabs = fixture.componentInstance.tabs; + tick(100); + fixture.detectChanges(); + const headerElements = tabs.items.map(item => item.headerComponent.nativeElement); + + headerElements[1].click(); + headerElements[1].dispatchEvent(KEY_RIGHT_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabs.selectedIndex).toBe(3); + + headerElements[3].dispatchEvent(KEY_RIGHT_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabs.selectedIndex).toBe(1); + + headerElements[1].dispatchEvent(KEY_LEFT_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabs.selectedIndex).toBe(3); + + headerElements[3].dispatchEvent(KEY_LEFT_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabs.selectedIndex).toBe(1); + + headerElements[1].dispatchEvent(KEY_END_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabs.selectedIndex).toBe(3); + + headerElements[3].dispatchEvent(KEY_HOME_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabs.selectedIndex).toBe(1); + })); + }); + + describe('IgxTabs Component with custom content in headers', () => { + let fixture; + let tabs; + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(TemplatedTabsTestComponent); + tabs = fixture.componentInstance.tabs; + })); + + it('should initialize igx-tab-header with custom content', fakeAsync(() => { + tick(100); + fixture.detectChanges(); + expect(tabs.items.length).toBe(3); + const headerDivs = tabs.items.map(item => item.headerComponent.nativeElement.firstChild); //Get header's div containers + + headerDivs.forEach((header, i) => { + expect(header.firstChild.innerText).toBe(`T${i + 1}`); + expect(header.lastChild.innerText).toBe(`Tab ${i + 1}`); + }); + tick(); + })); + + it('should change selection in runtime using selectedIndex', fakeAsync(() => { + tick(100); + fixture.detectChanges(); + + const tabsItems = tabs.items.toArray(); + expect(tabs.selectedIndex).toBe(0); + expect(tabs.selectedItem).toBe(tabsItems[0]); + + tabs.selectedIndex = 2; + tick(100); + fixture.detectChanges(); + + expect(tabs.selectedItem).toBe(tabsItems[2]); + expect(tabs.selectedItem.headerComponent.nativeElement.firstChild.lastChild.innerText).toBe('Tab 3'); + })); + + }); + + describe('IgxTabs Miscellaneous Tests', () => { + + it('check selection when tabs collection is modified', fakeAsync(() => { + const fixture = TestBed.createComponent(TabsTest2Component); + const tabs = fixture.componentInstance.tabs; + fixture.detectChanges(); + + tick(100); + fixture.detectChanges(); + + const tabItems = tabs.items.toArray(); + const tab3: IgxTabItemComponent = tabItems[2]; + + tick(100); + fixture.detectChanges(); + expect(tabs.selectedIndex).toBe(0); + + tab3.selected = true; + tick(200); + fixture.detectChanges(); + + expect(tabs.selectedIndex).toBe(2); + + fixture.componentInstance.resetCollectionFourTabs(); + fixture.detectChanges(); + tick(200); + expect(tabs.selectedIndex).toBe(2); + + fixture.componentInstance.resetCollectionOneTab(); + tick(100); + fixture.detectChanges(); + + tick(100); + fixture.detectChanges(); + expect(tabs.selectedIndex).toBe(0); + + fixture.componentInstance.resetCollectionTwoTabs(); + tick(100); + fixture.detectChanges(); + + tick(100); + fixture.detectChanges(); + expect(tabs.selectedIndex).toBe(0); + + fixture.componentInstance.resetToEmptyCollection(); + tick(100); + fixture.detectChanges(); + + tick(100); + fixture.detectChanges(); + expect(tabs.panels.length).toBe(0); + expect(tabs.selectedItem).toBe(null); + })); + + it('should select third tab by default', fakeAsync(() => { + const fixture = TestBed.createComponent(TabsTestSelectedTabComponent); + const tabs = fixture.componentInstance.tabs; + + tick(100); + fixture.detectChanges(); + expect(tabs.selectedIndex).toBe(2); + + tick(100); + fixture.detectChanges(); + expect(tabs.items.toArray()[2].selected).toBeTruthy(); + + tick(100); + fixture.detectChanges(); + expect(tabs.selectedIndicator.nativeElement.style.transform).toBe('translate(180px)'); + })); + + it('tabs in drop down, bug #4420 - check selection indicator width', fakeAsync(() => { + const fixture = TestBed.createComponent(TabsTestBug4420Component); + const dom = fixture.debugElement; + const tabs = fixture.componentInstance.tabs; + tick(50); + fixture.detectChanges(); + + const button = dom.query(By.css('.igx-button--flat')); + UIInteractions.simulateClickAndSelectEvent(button); + tick(50); + fixture.detectChanges(); + + expect(tabs.selectedIndex).toBe(1); + const selectedPanel = document.getElementsByTagName('igx-tab-content')[1] as HTMLElement; + expect(selectedPanel.innerText.trim()).toEqual('Tab content 2'); + const indicator = dom.query(By.css('.igx-tabs__header-active-indicator')); + expect(indicator.nativeElement.style.width).toBe('90px'); + })); + + it('add a tab with selected set to true', fakeAsync(() => { + const fixture = TestBed.createComponent(AddingSelectedTabComponent); + const tabs = fixture.componentInstance.tabs; + fixture.detectChanges(); + + tick(100); + fixture.detectChanges(); + + expect(tabs.items.length).toBe(2); + expect(tabs.selectedIndex).toBe(0); + + fixture.componentInstance.addTab(); + fixture.detectChanges(); + tick(100); + + expect(tabs.items.length).toBe(3); + expect(tabs.selectedIndex).toBe(2); + })); + }); + + describe('Routing Navigation Tests', () => { + let router; + let location; + let fixture; + let tabsComp; + let headerElements; + let tabItems; + + beforeEach(waitForAsync(() => { + router = TestBed.inject(Router); + location = TestBed.inject(Location); + fixture = TestBed.createComponent(TabsRoutingTestComponent); + tabsComp = fixture.componentInstance.tabs; + fixture.detectChanges(); + tabItems = tabsComp.items.toArray(); + headerElements = tabItems.map(item => item.headerComponent.nativeElement); + })); + + it('should navigate to the correct URL when clicking on tab buttons', fakeAsync(() => { + fixture.ngZone.run(() => router.initialNavigation()); + tick(); + expect(location.path()).toBe('/'); + + fixture.ngZone.run(() => { + UIInteractions.simulateClickAndSelectEvent(headerElements[2]); + }); + tick(); + expect(location.path()).toBe('/view3'); + + fixture.ngZone.run(() => { + UIInteractions.simulateClickAndSelectEvent(headerElements[1]); + }); + tick(); + expect(location.path()).toBe('/view2'); + + fixture.ngZone.run(() => { + UIInteractions.simulateClickAndSelectEvent(headerElements[0]); + }); + tick(); + expect(location.path()).toBe('/view1'); + })); + + it('should select the correct tab button/panel when navigating an URL', fakeAsync(() => { + fixture.ngZone.run(() => router.initialNavigation()); + tick(); + expect(location.path()).toBe('/'); + + fixture.ngZone.run(() => { + router.navigate(['/view3']); + }); + tick(); + expect(location.path()).toBe('/view3'); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(2); + expect(tabItems[2].selected).toBe(true); + expect(tabItems[0].selected).toBe(false); + expect(tabItems[1].selected).toBe(false); + + fixture.ngZone.run(() => { + router.navigate(['/view2']); + }); + tick(); + expect(location.path()).toBe('/view2'); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(1); + expect(tabItems[1].selected).toBe(true); + expect(tabItems[0].selected).toBe(false); + expect(tabItems[2].selected).toBe(false); + + fixture.ngZone.run(() => { + router.navigate(['/view1']); + }); + tick(); + expect(location.path()).toBe('/view1'); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(0); + expect(tabItems[0].selected).toBe(true); + expect(tabItems[1].selected).toBe(false); + expect(tabItems[2].selected).toBe(false); + })); + + it('should focus next/previous tab when pressing right/left arrow', fakeAsync(() => { + tabsComp.activation = 'manual'; + tick(); + fixture.detectChanges(); + + headerElements[0].click(); + tick(200); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(0); + + headerElements[0].dispatchEvent(KEY_RIGHT_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(0); + expect(document.activeElement).toBe(headerElements[1]); + + headerElements[1].dispatchEvent(KEY_RIGHT_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(0); + expect(document.activeElement).toBe(headerElements[2]); + + headerElements[2].dispatchEvent(KEY_RIGHT_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(0); + expect(document.activeElement).toBe(headerElements[0]); + + headerElements[0].dispatchEvent(KEY_LEFT_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(0); + expect(document.activeElement).toBe(headerElements[2]); + + headerElements[2].dispatchEvent(KEY_LEFT_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(0); + expect(document.activeElement).toBe(headerElements[1]); + })); + + it('should focus first/last tab when pressing home/end button', fakeAsync(() => { + tabsComp.activation = 'manual'; + tick(); + fixture.detectChanges(); + + headerElements[0].click(); + tick(200); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(0); + + headerElements[0].dispatchEvent(KEY_END_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(0); + expect(document.activeElement).toBe(headerElements[2]); + + headerElements[2].dispatchEvent(KEY_HOME_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(0); + expect(document.activeElement).toBe(headerElements[0]); + })); + + it('should select focused tabs on enter/space', fakeAsync(() => { + tabsComp.activation = 'manual'; + tick(); + fixture.detectChanges(); + + headerElements[0].click(); + tick(200); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(0); + + headerElements[0].dispatchEvent(KEY_LEFT_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(0); + expect(document.activeElement).toBe(headerElements[2]); + + headerElements[2].dispatchEvent(KEY_ENTER_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(2); + expect(document.activeElement).toBe(headerElements[2]); + + headerElements[2].dispatchEvent(KEY_HOME_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(2); + expect(document.activeElement).toBe(headerElements[0]); + + headerElements[0].dispatchEvent(KEY_SPACE_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(0); + expect(document.activeElement).toBe(headerElements[0]); + })); + + it('should not focus disabled tabs when navigating with keyboard', fakeAsync(() => { + fixture = TestBed.createComponent(TabsRoutingDisabledTestComponent); + tabsComp = fixture.componentInstance.tabs; + fixture.detectChanges(); + tabItems = tabsComp.items.toArray(); + headerElements = tabItems.map(item => item.headerComponent.nativeElement); + + headerElements[1].click(); + tick(200); + fixture.detectChanges(); + + headerElements[1].dispatchEvent(KEY_RIGHT_EVENT); + tick(200); + fixture.detectChanges(); + expect(document.activeElement).toBe(headerElements[3]); + + headerElements[3].dispatchEvent(KEY_HOME_EVENT); + tick(200); + fixture.detectChanges(); + expect(document.activeElement).toBe(headerElements[1]); + + headerElements[1].dispatchEvent(KEY_LEFT_EVENT); + tick(200); + fixture.detectChanges(); + expect(document.activeElement).toBe(headerElements[3]); + })); + + it('should not navigate to an URL blocked by activate guard', fakeAsync(() => { + fixture = TestBed.createComponent(TabsRoutingGuardTestComponent); + tabsComp = fixture.componentInstance.tabs; + fixture.detectChanges(); + tabItems = tabsComp.items.toArray(); + headerElements = tabItems.map(item => item.headerComponent.nativeElement); + + fixture.ngZone.run(() => { + router.initialNavigation(); + }); + tick(); + expect(location.path()).toBe('/'); + + fixture.ngZone.run(() => { + UIInteractions.simulateClickAndSelectEvent(headerElements[0]); + }); + tick(); + expect(location.path()).toBe('/view1'); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(0); + expect(tabItems[0].selected).toBe(true); + expect(tabItems[1].selected).toBe(false); + + fixture.ngZone.run(() => { + UIInteractions.simulateClickAndSelectEvent(headerElements[1]); + }); + tick(); + expect(location.path()).toBe('/view1'); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(0); + expect(tabItems[0].selected).toBe(true); + expect(tabItems[1].selected).toBe(false); + })); + + it('should set auto activation mode by default and change selectedIndex on arrow keys', fakeAsync(() => { + expect(tabsComp.activation).toBe('auto'); + + headerElements[0].dispatchEvent(KEY_RIGHT_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(1); + + headerElements[1].dispatchEvent(KEY_RIGHT_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(2); + })); + + it('should update focus and selectedIndex correctly in auto mode when navigating with arrow keys', fakeAsync(() => { + expect(tabsComp.selectedIndex).toBe(-1); + + headerElements[0].dispatchEvent(KEY_RIGHT_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(1); + expect(document.activeElement).toBe(headerElements[1]); + + headerElements[1].dispatchEvent(KEY_RIGHT_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(2); + expect(document.activeElement).toBe(headerElements[2]); + + headerElements[2].dispatchEvent(KEY_LEFT_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(1); + expect(document.activeElement).toBe(headerElements[1]); + + headerElements[1].dispatchEvent(KEY_LEFT_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(0); + expect(document.activeElement).toBe(headerElements[0]); + })); + + it('should not change selectedIndex when using arrow keys in manual mode', fakeAsync(() => { + tabsComp.activation = 'manual'; + fixture.detectChanges(); + + headerElements[0].click(); + tick(200); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(0); + + headerElements[0].dispatchEvent(KEY_RIGHT_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(0); + expect(document.activeElement).toBe(headerElements[1]); + + headerElements[1].dispatchEvent(KEY_RIGHT_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(0); + expect(document.activeElement).toBe(headerElements[2]); + })); + + it('should select focused tab on Enter or Space in manual mode', fakeAsync(() => { + tabsComp.activation = 'manual'; + fixture.detectChanges(); + + headerElements[0].click(); + tick(200); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(0); + + headerElements[0].dispatchEvent(KEY_RIGHT_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(0); + expect(document.activeElement).toBe(headerElements[1]); + + headerElements[1].dispatchEvent(KEY_ENTER_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(1); + + headerElements[1].dispatchEvent(KEY_RIGHT_EVENT); + tick(200); + fixture.detectChanges(); + headerElements[2].dispatchEvent(KEY_SPACE_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabsComp.selectedIndex).toBe(2); + })); + }); + + describe('Tabs-only Mode With Initial Selection Set on TabItems Tests', () => { + let fixture; + let tabsComp; + let tabItems; + let headerElements; + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(TabsTabsOnlyModeTest1Component); + tabsComp = fixture.componentInstance.tabs; + fixture.detectChanges(); + tabItems = tabsComp.items.toArray(); + headerElements = tabItems.map(item => item.headerComponent.nativeElement); + })); + + it('should retain the correct initial selection set by the isSelected property', () => { + expect(tabItems[0].selected).toBe(false); + expect(headerElements[0].classList.contains(tabItemNormalCssClass)).toBe(true); + + expect(tabItems[1].selected).toBe(true); + expect(headerElements[1].classList.contains(tabItemSelectedCssClass)).toBe(true); + + expect(tabItems[2].selected).toBe(false); + expect(headerElements[2].classList.contains(tabItemNormalCssClass)).toBe(true); + }); + + it('should hide the selection indicator when no tab item is selected', () => { + expect(tabsComp.selectedIndicator.nativeElement.style.visibility).toBe('visible'); + tabItems[1].selected = false; + fixture.detectChanges(); + expect(tabsComp.selectedIndicator.nativeElement.style.visibility).toBe('hidden'); + }); + }); + + describe('Tabs-only Mode With Initial Selection Set on Tabs Component Tests', () => { + let fixture; + let tabsComp; + let tabItems; + let headerElements; + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(TabsTabsOnlyModeTest1Component); + tabsComp = fixture.componentInstance.tabs; + tabsComp.selectedIndex = 2; + fixture.detectChanges(); + tabItems = tabsComp.items.toArray(); + headerElements = tabItems.map(item => item.headerComponent.nativeElement); + })); + + it('should retain the correct initial selection set by the selectedIndex property', () => { + fixture.detectChanges(); + + expect(tabItems[0].selected).toBe(false); + expect(headerElements[0].classList.contains(tabItemNormalCssClass)).toBe(true); + + expect(tabItems[1].selected).toBe(false); + expect(headerElements[1].classList.contains(tabItemNormalCssClass)).toBe(true); + + expect(tabItems[2].selected).toBe(true); + expect(headerElements[2].classList.contains(tabItemSelectedCssClass)).toBe(true); + }); + + }); + + describe('Events', () => { + let fixture; + let tabs; + let tabItems; + let headers; + let itemChangeSpy; + let indexChangeSpy; + let indexChangingSpy; + + describe('', () => { + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(TabsTestComponent); + fixture.detectChanges(); + tabs = fixture.componentInstance.tabs; + tabItems = tabs.items.toArray(); + headers = tabItems.map(item => item.headerComponent.nativeElement); + itemChangeSpy = spyOn(tabs.selectedItemChange, 'emit').and.callThrough(); + indexChangeSpy = spyOn(tabs.selectedIndexChange, 'emit').and.callThrough(); + indexChangingSpy = spyOn(tabs.selectedIndexChanging, 'emit').and.callThrough(); + })); + + it('Validate the fired events on clicking tab headers.', fakeAsync(() => { + tick(100); + + headers[1].dispatchEvent(new Event('click', { bubbles: true })); + tick(200); + fixture.detectChanges(); + expect(tabs.selectedIndex).toBe(1); + + expect(indexChangingSpy).toHaveBeenCalledWith({ + owner: tabs, + cancel: false, + oldIndex: 0, + newIndex: 1 + }); + expect(indexChangeSpy).toHaveBeenCalledWith(1); + expect(itemChangeSpy).toHaveBeenCalledWith({ + owner: tabs, + oldItem: tabItems[0], + newItem: tabItems[1] + }); + })); + + it('Cancel selectedIndexChanging event.', fakeAsync(() => { + tick(100); + tabs.selectedIndexChanging.pipe().subscribe((e) => e.cancel = true); + fixture.detectChanges(); + + headers[1].dispatchEvent(new Event('click', { bubbles: true })); + tick(200); + fixture.detectChanges(); + expect(tabs.selectedIndex).toBe(0); + + expect(indexChangingSpy).toHaveBeenCalledWith({ + owner: tabs, + cancel: true, + oldIndex: 0, + newIndex: 1 + }); + expect(indexChangeSpy).not.toHaveBeenCalled(); + expect(itemChangeSpy).not.toHaveBeenCalled(); + })); + + it('Validate the fired events when navigating between tabs with left and right arrows.', fakeAsync(() => { + tick(100); + + headers[0].focus(); + headers[0].dispatchEvent(KEY_RIGHT_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabs.selectedIndex).toBe(1); + + expect(indexChangingSpy).toHaveBeenCalledWith({ + owner: tabs, + cancel: false, + oldIndex: 0, + newIndex: 1 + }); + expect(indexChangeSpy).toHaveBeenCalledWith(1); + expect(itemChangeSpy).toHaveBeenCalledWith({ + owner: tabs, + oldItem: tabItems[0], + newItem: tabItems[1] + }); + + headers[1].dispatchEvent(KEY_LEFT_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabs.selectedIndex).toBe(0); + + expect(indexChangingSpy).toHaveBeenCalledWith({ + owner: tabs, + cancel: false, + oldIndex: 1, + newIndex: 0 + }); + expect(indexChangeSpy).toHaveBeenCalledWith(0); + expect(itemChangeSpy).toHaveBeenCalledWith({ + owner: tabs, + oldItem: tabItems[1], + newItem: tabItems[0] + }); + })); + + it('Validate the fired events when navigating between tabs with home and end keys.', fakeAsync(() => { + tick(100); + + headers[0].focus(); + headers[0].dispatchEvent(KEY_END_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabs.selectedIndex).toBe(2); + + expect(itemChangeSpy).toHaveBeenCalledWith({ + owner: tabs, + oldItem: tabItems[0], + newItem: tabItems[2] + }); + expect(indexChangingSpy).toHaveBeenCalledWith({ + owner: tabs, + cancel: false, + oldIndex: 0, + newIndex: 2 + }); + expect(indexChangeSpy).toHaveBeenCalledWith(2); + + headers[2].dispatchEvent(KEY_HOME_EVENT); + tick(200); + fixture.detectChanges(); + expect(tabs.selectedIndex).toBe(0); + + expect(itemChangeSpy).toHaveBeenCalledWith({ + owner: tabs, + oldItem: tabItems[2], + newItem: tabItems[0] + }); + expect(indexChangingSpy).toHaveBeenCalledWith({ + owner: tabs, + cancel: false, + oldIndex: 2, + newIndex: 0 + }); + expect(indexChangeSpy).toHaveBeenCalledWith(0); + })); + }); + + describe('& Routing', () => { + let router; + let location; + beforeEach(waitForAsync(() => { + router = TestBed.inject(Router); + location = TestBed.inject(Location); + fixture = TestBed.createComponent(TabsRoutingTestComponent); + tabs = fixture.componentInstance.tabs; + fixture.detectChanges(); + tabItems = tabs.items.toArray(); + headers = tabItems.map(item => item.headerComponent.nativeElement); + itemChangeSpy = spyOn(tabs.selectedItemChange, 'emit'); + indexChangeSpy = spyOn(tabs.selectedIndexChange, 'emit'); + indexChangingSpy = spyOn(tabs.selectedIndexChanging, 'emit'); + })); + + it('Validate the events are not fired on clicking tab headers before pressing enter/space key.', fakeAsync(() => { + fixture.ngZone.run(() => router.initialNavigation()); + tick(); + expect(location.path()).toBe('/'); + + fixture.ngZone.run(() => { + UIInteractions.simulateClickAndSelectEvent(headers[1]); + }); + tick(); + expect(location.path()).toBe('/view2'); + expect(tabs.selectedIndex).toBe(-1); + + expect(indexChangingSpy).not.toHaveBeenCalled(); + expect(indexChangeSpy).not.toHaveBeenCalled(); + expect(itemChangeSpy).not.toHaveBeenCalled(); + + headers[1].dispatchEvent(KEY_ENTER_EVENT); + tick(200); + fixture.detectChanges(); + + expect(indexChangingSpy).toHaveBeenCalledWith({ + owner: tabs, + cancel: false, + oldIndex: -1, + newIndex: 1 + }); + expect(indexChangeSpy).toHaveBeenCalledWith(1); + expect(itemChangeSpy).toHaveBeenCalledWith({ + owner: tabs, + oldItem: undefined, + newItem: tabItems[1] + }); + })); + + it('Validate the events are not fired when navigating between tabs with arrow keys before pressing enter/space key.', + fakeAsync(() => { + tabs.activation = 'manual'; + tick(); + fixture.detectChanges(); + + tick(100); + headers[0].focus(); + + headers[0].dispatchEvent(KEY_LEFT_EVENT); + tick(200); + fixture.detectChanges(); + + expect(indexChangingSpy).not.toHaveBeenCalled(); + expect(indexChangeSpy).not.toHaveBeenCalled(); + expect(itemChangeSpy).not.toHaveBeenCalled(); + + headers[2].dispatchEvent(KEY_ENTER_EVENT); + tick(200); + fixture.detectChanges(); + + expect(indexChangingSpy).toHaveBeenCalledWith({ + owner: tabs, + cancel: false, + oldIndex: -1, + newIndex: 2 + }); + expect(indexChangeSpy).toHaveBeenCalledWith(2); + expect(itemChangeSpy).toHaveBeenCalledWith({ + owner: tabs, + oldItem: undefined, + newItem: tabItems[2] + }); + + expect(indexChangingSpy).toHaveBeenCalledTimes(1); + expect(indexChangeSpy).toHaveBeenCalledTimes(1); + expect(itemChangeSpy).toHaveBeenCalledTimes(1); + + headers[2].dispatchEvent(KEY_RIGHT_EVENT); + tick(200); + fixture.detectChanges(); + + expect(indexChangingSpy).toHaveBeenCalledTimes(1); + expect(indexChangeSpy).toHaveBeenCalledTimes(1); + expect(itemChangeSpy).toHaveBeenCalledTimes(1); + + headers[0].dispatchEvent(KEY_SPACE_EVENT); + tick(200); + fixture.detectChanges(); + + expect(indexChangingSpy).toHaveBeenCalledWith({ + owner: tabs, + cancel: false, + oldIndex: 2, + newIndex: 0 + }); + expect(indexChangeSpy).toHaveBeenCalledWith(0); + expect(itemChangeSpy).toHaveBeenCalledWith({ + owner: tabs, + oldItem: tabItems[2], + newItem: tabItems[0] + }); + + expect(indexChangingSpy).toHaveBeenCalledTimes(2); + expect(indexChangeSpy).toHaveBeenCalledTimes(2); + expect(itemChangeSpy).toHaveBeenCalledTimes(2); + })); + + it('Validate the events are not fired when navigating between tabs with home/end before pressing enter/space key.', + fakeAsync(() => { + tabs.activation = 'manual'; + tick(); + fixture.detectChanges(); + + tick(100); + headers[0].focus(); + + headers[0].dispatchEvent(KEY_END_EVENT); + tick(200); + fixture.detectChanges(); + + expect(indexChangingSpy).not.toHaveBeenCalled(); + expect(indexChangeSpy).not.toHaveBeenCalled(); + expect(itemChangeSpy).not.toHaveBeenCalled(); + + headers[2].dispatchEvent(KEY_ENTER_EVENT); + tick(200); + fixture.detectChanges(); + + expect(indexChangingSpy).toHaveBeenCalledWith({ + owner: tabs, + cancel: false, + oldIndex: -1, + newIndex: 2 + }); + expect(indexChangeSpy).toHaveBeenCalledWith(2); + expect(itemChangeSpy).toHaveBeenCalledWith({ + owner: tabs, + oldItem: undefined, + newItem: tabItems[2] + }); + + expect(indexChangingSpy).toHaveBeenCalledTimes(1); + expect(indexChangeSpy).toHaveBeenCalledTimes(1); + expect(itemChangeSpy).toHaveBeenCalledTimes(1); + + headers[2].dispatchEvent(KEY_HOME_EVENT); + tick(200); + fixture.detectChanges(); + + expect(indexChangingSpy).toHaveBeenCalledTimes(1); + expect(indexChangeSpy).toHaveBeenCalledTimes(1); + expect(itemChangeSpy).toHaveBeenCalledTimes(1); + + headers[0].dispatchEvent(KEY_SPACE_EVENT); + tick(200); + fixture.detectChanges(); + + expect(indexChangingSpy).toHaveBeenCalledWith({ + owner: tabs, + cancel: false, + oldIndex: 2, + newIndex: 0 + }); + expect(indexChangeSpy).toHaveBeenCalledWith(0); + expect(itemChangeSpy).toHaveBeenCalledWith({ + owner: tabs, + oldItem: tabItems[2], + newItem: tabItems[0] + }); + + expect(indexChangingSpy).toHaveBeenCalledTimes(2); + expect(indexChangeSpy).toHaveBeenCalledTimes(2); + expect(itemChangeSpy).toHaveBeenCalledTimes(2); + })); + + }); + }); + describe('', () => { + let fixture; + let tabs; + let tabItems; + let headers; + let actualHeadersContainer; + + beforeEach(waitForAsync(() => { + fixture = TestBed.createComponent(TabsWithPrefixSuffixTestComponent); + fixture.detectChanges(); + tabs = fixture.componentInstance.tabs; + tabItems = tabs.items.toArray(); + headers = tabItems.map(item => item.headerComponent.nativeElement); + actualHeadersContainer = fixture.debugElement.query(By.css('.' + headerScrollCssClass)).nativeNode; + })); + + it('show tabs prefix and suffix properly.', () => { + const header0Elements = headers[0].children; + expect(header0Elements[0].localName).toBe('span'); + expect(header0Elements[0].innerText).toBe('Test:'); + expect(header0Elements[1].children[0].localName).toBe('igx-icon'); + expect(header0Elements[1].children[0].innerText).toBe('library_music'); + expect(header0Elements[1].children[1].localName).toBe('span'); + expect(header0Elements[1].children[1].innerText).toBe('Tab 1'); + expect(header0Elements[2].localName).toBe('igx-icon'); + expect(header0Elements[2].innerText).toBe('close'); + + const header1Elements = headers[1].children; + expect(header1Elements[0].localName).toBe('span'); + expect(header1Elements[0].innerText).toBe('Test:'); + expect(header1Elements[1].children[0].localName).toBe('igx-icon'); + expect(header1Elements[1].children[0].innerText).toBe('video_library'); + expect(header1Elements[1].children[1].localName).toBe('span'); + expect(header1Elements[1].children[1].innerText).toBe('Tab 2'); + + const header2Elements = headers[2].children; + expect(header2Elements[0].children[0].localName).toBe('igx-icon'); + expect(header2Elements[0].children[0].innerText).toBe('library_books'); + expect(header2Elements[0].children[1].localName).toBe('span'); + expect(header2Elements[0].children[1].innerText).toBe('Tab 3'); + expect(header2Elements[1].localName).toBe('igx-icon'); + expect(header2Elements[1].innerText).toBe('close'); + }); + + it('tabAlignment is set to "start" by default.', () => { + expect(tabs.tabAlignment).toBe(IgxTabsAlignment.start); + expect(actualHeadersContainer).toBeTruthy(); + expect(actualHeadersContainer.classList.contains(headerScrollCssClass + '--start')).toBeTruthy(); + }); + + it('tabAlignment changes in runtime are properly applied.', () => { + tabs.tabAlignment = IgxTabsAlignment.justify; + fixture.detectChanges(); + + expect(tabs.tabAlignment).toBe(IgxTabsAlignment.justify); + expect(actualHeadersContainer.classList.contains(headerScrollCssClass + '--justify')).toBeTruthy(); + + tabs.tabAlignment = IgxTabsAlignment.end; + fixture.detectChanges(); + + expect(tabs.tabAlignment).toBe(IgxTabsAlignment.end); + expect(actualHeadersContainer.classList.contains(headerScrollCssClass + '--end')).toBeTruthy(); + }); + + it('aligns tab headers properly when tabAlignment="justify".', async () => { + tabs.tabAlignment = IgxTabsAlignment.justify; + fixture.detectChanges(); + await wait(200); + + const diffs: number[] = []; + const expectedWidth = Math.round(actualHeadersContainer.offsetWidth / tabItems.length); + headers.map((elem) => diffs.push(elem.offsetWidth - expectedWidth)); + const result = diffs.reduce((a, b) => a - b); + expect(result).toBeLessThan(3); + }); + + it('aligns tab headers properly when tabAlignment="center".', async () => { + tabs.tabAlignment = IgxTabsAlignment.center; + fixture.detectChanges(); + await wait(200); + expect(actualHeadersContainer.classList.contains(headerScrollCssClass + '--center')).toBeTruthy(); + + const widths = []; + headers.map((elem) => { + widths.push(elem.offsetWidth); + }); + + const result = widths.reduce((a, b) => a + b); + const noTabsAreaWidth = actualHeadersContainer.offsetWidth - result; + const offsetRight = actualHeadersContainer.offsetWidth - headers[2].offsetLeft - headers[2].offsetWidth; + + expect(Math.round(noTabsAreaWidth / 2) - headers[0].offsetLeft).toBeLessThan(3); + expect(offsetRight - headers[0].offsetLeft).toBeGreaterThanOrEqual(0); + expect(offsetRight - headers[0].offsetLeft).toBeLessThan(3); + expect(Math.abs(150 - widths[0])).toBeLessThan(3); + expect(Math.abs(113 - widths[1])).toBeLessThan(3); + expect(Math.abs(104 - widths[2])).toBeLessThan(3); + }); + + it('aligns tab headers properly when tabAlignment="start".', async () => { + tabs.tabAlignment = IgxTabsAlignment.start; + fixture.detectChanges(); + await wait(200); + + const widths = []; + headers.map((elem) => { + widths.push(elem.offsetWidth); + }); + + const result = widths.reduce((a, b) => a + b); + const noTabsAreaWidth = actualHeadersContainer.offsetWidth - result; + const offsetRight = actualHeadersContainer.offsetWidth - headers[2].offsetLeft - headers[2].offsetWidth; + + expect(headers[0].offsetLeft).toBe(0); + expect(offsetRight - noTabsAreaWidth).toBeGreaterThanOrEqual(0); + expect(offsetRight - noTabsAreaWidth).toBeLessThan(3); + expect(Math.abs(150 - widths[0])).toBeLessThan(3); + expect(Math.abs(113 - widths[1])).toBeLessThan(3); + expect(Math.abs(104 - widths[2])).toBeLessThan(3); + }); + + it('aligns tab headers properly when tabAlignment="end".', async () => { + tabs.tabAlignment = IgxTabsAlignment.end; + fixture.detectChanges(); + await wait(200); + + const widths = []; + headers.map((elem) => { + widths.push(elem.offsetWidth); + }); + + const result = widths.reduce((a, b) => a + b); + const noTabsAreaWidth = actualHeadersContainer.offsetWidth - result; + const offsetRight = actualHeadersContainer.offsetWidth - headers[2].offsetLeft - headers[2].offsetWidth; + + expect(offsetRight).toBe(0); + expect(headers[0].offsetLeft - noTabsAreaWidth).toBeGreaterThanOrEqual(0); + expect(headers[0].offsetLeft - noTabsAreaWidth).toBeLessThan(3); + expect(Math.abs(150 - widths[0])).toBeLessThan(3); + expect(Math.abs(113 - widths[1])).toBeLessThan(3); + expect(Math.abs(104 - widths[2])).toBeLessThan(3); + }); + + it('should hide scroll buttons if visible when alignment is set to "justify".', async () => { + fixture.componentInstance.wrapperDiv.nativeElement.style.width = '360px'; + fixture.detectChanges(); + await wait(200); + + const leftScrollButton = tabs.headerContainer.nativeElement.children[0]; + const rightScrollButton = tabs.headerContainer.nativeElement.children[2]; + expect(leftScrollButton.clientWidth).toBeTruthy(); + expect(rightScrollButton.clientWidth).toBeTruthy(); + + tabs.tabAlignment = IgxTabsAlignment.justify; + fixture.detectChanges(); + await wait(500); + + expect(leftScrollButton.clientWidth).toBeFalsy(); + expect(rightScrollButton.clientWidth).toBeFalsy(); + }); + }); + + it('should hide scroll buttons when no longer needed after deleting tabs.', async () => { + const fixture = TestBed.createComponent(TabsContactsComponent); + const tabs = fixture.componentInstance.tabs; + fixture.componentInstance.wrapperDiv.nativeElement.style.width = '260px'; + fixture.detectChanges(); + + const rightScrollButton = tabs.headerContainer.nativeElement.children[2]; + const leftScrollButton = tabs.headerContainer.nativeElement.children[0]; + expect(leftScrollButton.clientWidth).toBeTruthy(); + expect(rightScrollButton.clientWidth).toBeTruthy(); + + fixture.componentInstance.contacts.splice(0, 1); + fixture.detectChanges(); + await wait(); + + expect(leftScrollButton.clientWidth).toBeFalsy(); + expect(rightScrollButton.clientWidth).toBeFalsy(); + }); + + describe('IgxTabs RTL', () => { + let fix; + let tabs; + let tabItems; + let headers; + + beforeEach(() => { + document.body.dir = 'rtl'; + fix = TestBed.createComponent(TabsRtlComponent); + tabs = fix.componentInstance.tabs; + fix.detectChanges(); + tabItems = tabs.items.toArray(); + headers = tabItems.map(item => item.headerComponent.nativeElement); + }); + + it('should position scroll buttons properly', () => { + fix.componentInstance.wrapperDiv.nativeElement.style.width = '300px'; + fix.detectChanges(); + + const scrollNextButton = fix.componentInstance.tabs.scrollNextButton; + const scrollPrevButton = fix.componentInstance.tabs.scrollPrevButton; + expect(scrollNextButton.nativeElement.offsetLeft).toBeLessThan(scrollPrevButton.nativeElement.offsetLeft); + }); + + it('should select next tab when left arrow is pressed and previous tab when right arrow is pressed', fakeAsync(() => { + tick(100); + fix.detectChanges(); + headers = tabs.items.map(item => item.headerComponent.nativeElement); + + headers[0].focus(); + headers[0].dispatchEvent(KEY_LEFT_EVENT); + tick(200); + fix.detectChanges(); + expect(tabs.selectedIndex).toBe(1); + + headers[1].dispatchEvent(KEY_LEFT_EVENT); + tick(200); + fix.detectChanges(); + expect(tabs.selectedIndex).toBe(2); + + headers[2].dispatchEvent(KEY_LEFT_EVENT); + tick(200); + fix.detectChanges(); + expect(tabs.selectedIndex).toBe(3); + + headers[0].dispatchEvent(KEY_RIGHT_EVENT); + tick(200); + fix.detectChanges(); + expect(tabs.selectedIndex).toBe(8); + + headers[8].dispatchEvent(KEY_RIGHT_EVENT); + tick(200); + fix.detectChanges(); + expect(tabs.selectedIndex).toBe(7); + })); + }); +}); + + diff --git a/projects/igniteui-angular/tabs/src/tabs/tabs/tabs.component.ts b/projects/igniteui-angular/tabs/src/tabs/tabs/tabs.component.ts new file mode 100644 index 00000000000..edcf617b6e9 --- /dev/null +++ b/projects/igniteui-angular/tabs/src/tabs/tabs/tabs.component.ts @@ -0,0 +1,347 @@ +import { AfterViewInit, Component, ElementRef, HostBinding, inject, Input, NgZone, OnDestroy, ViewChild } from '@angular/core'; +import { IgxTabsBase } from '../tabs.base'; +import { IgxTabsDirective } from '../tabs.directive'; +import { NgClass, NgTemplateOutlet } from '@angular/common'; +import { IgxIconButtonDirective, IgxRippleDirective } from 'igniteui-angular/directives'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { getResizeObserver, PlatformUtil } from 'igniteui-angular/core'; + +export const IgxTabsAlignment = { + start: 'start', + end: 'end', + center: 'center', + justify: 'justify' +} as const; + +/** @hidden */ +const enum TabScrollButtonStyle { + Enabled = 'enabled', + Disabled = 'disabled', + NotDisplayed = 'not_displayed' +} + +export type IgxTabsAlignment = (typeof IgxTabsAlignment)[keyof typeof IgxTabsAlignment]; + +/** @hidden */ +let NEXT_TAB_ID = 0; + +/** + * Tabs component is used to organize or switch between similar data sets. + * + * @igxModule IgxTabsModule + * + * @igxTheme igx-tabs-theme + * + * @igxKeywords tabs + * + * @igxGroup Layouts + * + * @remarks + * The Ignite UI for Angular Tabs component places tabs at the top and allows for scrolling when there are multiple tab items on the screen. + * + * @example + * ```html + * + * + * + * folder + * Tab 1 + * + * + * Content 1 + * + * + * ... + * + * ``` + */ +@Component({ + selector: 'igx-tabs', + templateUrl: 'tabs.component.html', + providers: [{ provide: IgxTabsBase, useExisting: IgxTabsComponent }], + imports: [IgxRippleDirective, IgxIconComponent, NgClass, NgTemplateOutlet, IgxIconButtonDirective] +}) + +export class IgxTabsComponent extends IgxTabsDirective implements AfterViewInit, OnDestroy { + private ngZone = inject(NgZone); + private platform = inject(PlatformUtil); + + + /** + * Gets/Sets the tab alignment. Defaults to `start`. + */ + @Input() + public get tabAlignment(): string | IgxTabsAlignment { + return this._tabAlignment; + } + + public set tabAlignment(value: string | IgxTabsAlignment) { + this._tabAlignment = value; + requestAnimationFrame(() => { + this.updateScrollButtons(); + this.realignSelectedIndicator(); + }); + } + + /** + * Determines the tab activation. + * When set to auto, the tab is instantly selected while navigating with the Left/Right Arrows, Home or End keys and the corresponding panel is displayed. + * When set to manual, the tab is only focused. The selection happens after pressing Space or Enter. + * Defaults is auto. + */ + @Input() + public activation: 'auto' | 'manual' = 'auto'; + + /** @hidden */ + @ViewChild('headerContainer', { static: true }) + public headerContainer: ElementRef; + + /** @hidden */ + @ViewChild('viewPort', { static: true }) + public viewPort: ElementRef; + + /** @hidden */ + @ViewChild('itemsWrapper', { static: true }) + public itemsWrapper: ElementRef; + + /** @hidden */ + @ViewChild('itemsContainer', { static: true }) + public itemsContainer: ElementRef; + + /** @hidden */ + @ViewChild('selectedIndicator') + public selectedIndicator: ElementRef; + + /** @hidden */ + @ViewChild('scrollPrevButton') + public scrollPrevButton: ElementRef; + + /** @hidden */ + @ViewChild('scrollNextButton') + public scrollNextButton: ElementRef; + + /** @hidden */ + @HostBinding('class.igx-tabs') + public defaultClass = true; + + /** @hidden */ + public offset = 0; + + /** @hidden */ + protected override componentName = 'igx-tabs'; + + private _tabAlignment: string | IgxTabsAlignment = 'start'; + private _resizeObserver: ResizeObserver; + + /** @hidden @internal */ + public override ngAfterViewInit(): void { + super.ngAfterViewInit(); + + this.ngZone.runOutsideAngular(() => { + if (this.platform.isBrowser) { + this._resizeObserver = new (getResizeObserver())(() => { + this.updateScrollButtons(); + this.realignSelectedIndicator(); + }); + this._resizeObserver.observe(this.headerContainer.nativeElement); + this._resizeObserver.observe(this.viewPort.nativeElement); + } + }); + } + + /** @hidden @internal */ + public override ngOnDestroy(): void { + super.ngOnDestroy(); + + this.ngZone.runOutsideAngular(() => { + this._resizeObserver?.disconnect(); + }); + } + + /** @hidden */ + public scrollPrev() { + this.scroll(false); + } + + /** @hidden */ + public scrollNext() { + this.scroll(true); + } + + /** @hidden */ + public realignSelectedIndicator() { + if (this.selectedIndex >= 0 && this.selectedIndex < this.items.length) { + const header = this.items.get(this.selectedIndex).headerComponent.nativeElement; + this.alignSelectedIndicator(header, 0); + } + } + + /** @hidden */ + public resolveHeaderScrollClasses() { + return { + 'igx-tabs__header-scroll--start': this.tabAlignment === 'start', + 'igx-tabs__header-scroll--end': this.tabAlignment === 'end', + 'igx-tabs__header-scroll--center': this.tabAlignment === 'center', + 'igx-tabs__header-scroll--justify': this.tabAlignment === 'justify', + }; + } + + /** @hidden */ + protected override scrollTabHeaderIntoView() { + if (this.selectedIndex >= 0) { + const tabItems = this.items.toArray(); + const tabHeaderNativeElement = tabItems[this.selectedIndex].headerComponent.nativeElement; + + // Scroll left if there is need + if (this.getElementOffset(tabHeaderNativeElement) < this.offset) { + this.scrollElement(tabHeaderNativeElement, false); + } + + // Scroll right if there is need + const viewPortOffsetWidth = this.viewPort.nativeElement.offsetWidth; + const delta = (this.getElementOffset(tabHeaderNativeElement) + tabHeaderNativeElement.offsetWidth) - (viewPortOffsetWidth + this.offset); + + // Fix for IE 11, a difference is accumulated from the widths calculations + if (delta > 1) { + this.scrollElement(tabHeaderNativeElement, true); + } + + this.alignSelectedIndicator(tabHeaderNativeElement); + } else { + this.hideSelectedIndicator(); + } + } + + /** @hidden */ + protected getNextTabId() { + return NEXT_TAB_ID++; + } + + /** @hidden */ + protected override onItemChanges() { + super.onItemChanges(); + + Promise.resolve().then(() => { + this.updateScrollButtons(); + }); + } + + private alignSelectedIndicator(element: HTMLElement, duration = 0.3): void { + if (this.selectedIndicator) { + this.selectedIndicator.nativeElement.style.visibility = 'visible'; + this.selectedIndicator.nativeElement.style.transitionDuration = duration > 0 ? `${duration}s` : 'initial'; + this.selectedIndicator.nativeElement.style.width = `${element.offsetWidth}px`; + this.selectedIndicator.nativeElement.style.transform = `translate(${element.offsetLeft}px)`; + } + } + + private hideSelectedIndicator(): void { + if (this.selectedIndicator) { + this.selectedIndicator.nativeElement.style.visibility = 'hidden'; + } + } + + private scroll(scrollNext: boolean): void { + const tabsArray = this.items.toArray(); + + for (let index = 0; index < tabsArray.length; index++) { + const tab = tabsArray[index]; + const element = tab.headerComponent.nativeElement; + if (scrollNext) { + if (element.offsetWidth + this.getElementOffset(element) > this.viewPort.nativeElement.offsetWidth + this.offset) { + this.scrollElement(element, scrollNext); + break; + } + } else { + if (this.getElementOffset(element) >= this.offset) { + this.scrollElement(tabsArray[index - 1].headerComponent.nativeElement, scrollNext); + break; + } + } + } + } + + private scrollElement(element: any, scrollNext: boolean): void { + const viewPortWidth = this.viewPort.nativeElement.offsetWidth; + + this.offset = (scrollNext) ? element.offsetWidth + this.getElementOffset(element) - viewPortWidth : this.getElementOffset(element); + this.viewPort.nativeElement.scrollLeft = this.getOffset(this.offset); + this.updateScrollButtons(); + } + + private updateScrollButtons() { + const itemsContainerWidth = this.getTabItemsContainerWidth(); + + const scrollPrevButtonStyle = this.resolveLeftScrollButtonStyle(itemsContainerWidth); + this.setScrollButtonStyle(this.scrollPrevButton.nativeElement, scrollPrevButtonStyle); + + const scrollNextButtonStyle = this.resolveRightScrollButtonStyle(itemsContainerWidth); + this.setScrollButtonStyle(this.scrollNextButton.nativeElement, scrollNextButtonStyle); + } + + private setScrollButtonStyle(button: HTMLButtonElement, buttonStyle: TabScrollButtonStyle) { + if (buttonStyle === TabScrollButtonStyle.Enabled) { + button.disabled = false; + button.style.display = ''; + } else if (buttonStyle === TabScrollButtonStyle.Disabled) { + button.disabled = true; + button.style.display = ''; + } else if (buttonStyle === TabScrollButtonStyle.NotDisplayed) { + button.style.display = 'none'; + } + } + private resolveLeftScrollButtonStyle(itemsContainerWidth: number): TabScrollButtonStyle { + const headerContainerWidth = this.headerContainer.nativeElement.offsetWidth; + const offset = this.offset; + + if (offset === 0) { + // Fix for IE 11, a difference is accumulated from the widths calculations. + if (itemsContainerWidth - headerContainerWidth <= 1) { + return TabScrollButtonStyle.NotDisplayed; + } + return TabScrollButtonStyle.Disabled; + } else { + return TabScrollButtonStyle.Enabled; + } + } + + private resolveRightScrollButtonStyle(itemsContainerWidth: number): TabScrollButtonStyle { + const viewPortWidth = this.viewPort.nativeElement.offsetWidth; + const headerContainerWidth = this.headerContainer.nativeElement.offsetWidth; + const offset = this.offset; + const total = offset + viewPortWidth; + + // Fix for IE 11, a difference is accumulated from the widths calculations. + if (itemsContainerWidth - headerContainerWidth <= 1 && offset === 0) { + return TabScrollButtonStyle.NotDisplayed; + } + + if (itemsContainerWidth > total) { + return TabScrollButtonStyle.Enabled; + } else { + return TabScrollButtonStyle.Disabled; + } + } + + private getTabItemsContainerWidth() { + // We use this hacky way to get the width of the itemsContainer, + // because there is inconsistency in IE we cannot use offsetWidth or scrollOffset. + const itemsContainerChildrenCount = this.itemsContainer.nativeElement.children.length; + let itemsContainerWidth = 0; + + if (itemsContainerChildrenCount > 1) { + const lastTab = this.itemsContainer.nativeElement.children[itemsContainerChildrenCount - 1] as HTMLElement; + itemsContainerWidth = this.getElementOffset(lastTab) + lastTab.offsetWidth; + } + + return itemsContainerWidth; + } + + private getOffset(offset: number): number { + return this.dir.rtl ? -offset : offset; + } + + private getElementOffset(element: HTMLElement): number { + return this.dir.rtl ? this.itemsWrapper.nativeElement.offsetWidth - element.offsetLeft - element.offsetWidth : element.offsetLeft; + } +} diff --git a/projects/igniteui-angular/tabs/src/tabs/tabs/tabs.directives.ts b/projects/igniteui-angular/tabs/src/tabs/tabs/tabs.directives.ts new file mode 100644 index 00000000000..3f387f56b4f --- /dev/null +++ b/projects/igniteui-angular/tabs/src/tabs/tabs/tabs.directives.ts @@ -0,0 +1,13 @@ +import { Directive } from '@angular/core'; + +@Directive({ + selector: 'igx-tab-header-label,[igxTabHeaderLabel]', + standalone: true +}) +export class IgxTabHeaderLabelDirective { } + +@Directive({ + selector: 'igx-tab-header-icon,[igxTabHeaderIcon]', + standalone: true +}) +export class IgxTabHeaderIconDirective { } diff --git a/projects/igniteui-angular/tabs/src/tabs/tabs/tabs.module.ts b/projects/igniteui-angular/tabs/src/tabs/tabs/tabs.module.ts new file mode 100644 index 00000000000..78250567079 --- /dev/null +++ b/projects/igniteui-angular/tabs/src/tabs/tabs/tabs.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { IGX_TABS_DIRECTIVES } from './public_api'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + ...IGX_TABS_DIRECTIVES + ], + exports: [ + ...IGX_TABS_DIRECTIVES + ] +}) +export class IgxTabsModule { } diff --git a/projects/igniteui-angular/test-utils/assets/images/avatar/1.jpg b/projects/igniteui-angular/test-utils/assets/images/avatar/1.jpg new file mode 100644 index 00000000000..28d3021c939 Binary files /dev/null and b/projects/igniteui-angular/test-utils/assets/images/avatar/1.jpg differ diff --git a/projects/igniteui-angular/test-utils/assets/images/avatar/10.jpg b/projects/igniteui-angular/test-utils/assets/images/avatar/10.jpg new file mode 100644 index 00000000000..99ca93d34a6 Binary files /dev/null and b/projects/igniteui-angular/test-utils/assets/images/avatar/10.jpg differ diff --git a/projects/igniteui-angular/test-utils/assets/images/avatar/11.jpg b/projects/igniteui-angular/test-utils/assets/images/avatar/11.jpg new file mode 100644 index 00000000000..a93271e1469 Binary files /dev/null and b/projects/igniteui-angular/test-utils/assets/images/avatar/11.jpg differ diff --git a/projects/igniteui-angular/test-utils/assets/images/avatar/12.jpg b/projects/igniteui-angular/test-utils/assets/images/avatar/12.jpg new file mode 100644 index 00000000000..1c89b4e9b48 Binary files /dev/null and b/projects/igniteui-angular/test-utils/assets/images/avatar/12.jpg differ diff --git a/projects/igniteui-angular/test-utils/assets/images/avatar/13.jpg b/projects/igniteui-angular/test-utils/assets/images/avatar/13.jpg new file mode 100644 index 00000000000..5ec2f4fbdb7 Binary files /dev/null and b/projects/igniteui-angular/test-utils/assets/images/avatar/13.jpg differ diff --git a/projects/igniteui-angular/test-utils/assets/images/avatar/14.jpg b/projects/igniteui-angular/test-utils/assets/images/avatar/14.jpg new file mode 100644 index 00000000000..773eade87c4 Binary files /dev/null and b/projects/igniteui-angular/test-utils/assets/images/avatar/14.jpg differ diff --git a/projects/igniteui-angular/test-utils/assets/images/avatar/15.jpg b/projects/igniteui-angular/test-utils/assets/images/avatar/15.jpg new file mode 100644 index 00000000000..cacf3848060 Binary files /dev/null and b/projects/igniteui-angular/test-utils/assets/images/avatar/15.jpg differ diff --git a/projects/igniteui-angular/test-utils/assets/images/avatar/16.jpg b/projects/igniteui-angular/test-utils/assets/images/avatar/16.jpg new file mode 100644 index 00000000000..75f77fc1c2f Binary files /dev/null and b/projects/igniteui-angular/test-utils/assets/images/avatar/16.jpg differ diff --git a/projects/igniteui-angular/test-utils/assets/images/avatar/17.jpg b/projects/igniteui-angular/test-utils/assets/images/avatar/17.jpg new file mode 100644 index 00000000000..4fda8c3964d Binary files /dev/null and b/projects/igniteui-angular/test-utils/assets/images/avatar/17.jpg differ diff --git a/projects/igniteui-angular/test-utils/assets/images/avatar/18.jpg b/projects/igniteui-angular/test-utils/assets/images/avatar/18.jpg new file mode 100644 index 00000000000..a3e3e193322 Binary files /dev/null and b/projects/igniteui-angular/test-utils/assets/images/avatar/18.jpg differ diff --git a/projects/igniteui-angular/test-utils/assets/images/avatar/19.jpg b/projects/igniteui-angular/test-utils/assets/images/avatar/19.jpg new file mode 100644 index 00000000000..d73abae2c20 Binary files /dev/null and b/projects/igniteui-angular/test-utils/assets/images/avatar/19.jpg differ diff --git a/projects/igniteui-angular/test-utils/assets/images/avatar/2.jpg b/projects/igniteui-angular/test-utils/assets/images/avatar/2.jpg new file mode 100644 index 00000000000..dd992e87625 Binary files /dev/null and b/projects/igniteui-angular/test-utils/assets/images/avatar/2.jpg differ diff --git a/projects/igniteui-angular/test-utils/assets/images/avatar/20.jpg b/projects/igniteui-angular/test-utils/assets/images/avatar/20.jpg new file mode 100644 index 00000000000..b6d8717e3f9 Binary files /dev/null and b/projects/igniteui-angular/test-utils/assets/images/avatar/20.jpg differ diff --git a/projects/igniteui-angular/test-utils/assets/images/avatar/21.jpg b/projects/igniteui-angular/test-utils/assets/images/avatar/21.jpg new file mode 100644 index 00000000000..a9138245dd6 Binary files /dev/null and b/projects/igniteui-angular/test-utils/assets/images/avatar/21.jpg differ diff --git a/projects/igniteui-angular/test-utils/assets/images/avatar/22.jpg b/projects/igniteui-angular/test-utils/assets/images/avatar/22.jpg new file mode 100644 index 00000000000..34a1392aca1 Binary files /dev/null and b/projects/igniteui-angular/test-utils/assets/images/avatar/22.jpg differ diff --git a/projects/igniteui-angular/test-utils/assets/images/avatar/23.jpg b/projects/igniteui-angular/test-utils/assets/images/avatar/23.jpg new file mode 100644 index 00000000000..2ac66665e8c Binary files /dev/null and b/projects/igniteui-angular/test-utils/assets/images/avatar/23.jpg differ diff --git a/projects/igniteui-angular/test-utils/assets/images/avatar/24.jpg b/projects/igniteui-angular/test-utils/assets/images/avatar/24.jpg new file mode 100644 index 00000000000..e9cf1e56006 Binary files /dev/null and b/projects/igniteui-angular/test-utils/assets/images/avatar/24.jpg differ diff --git a/projects/igniteui-angular/test-utils/assets/images/avatar/3.jpg b/projects/igniteui-angular/test-utils/assets/images/avatar/3.jpg new file mode 100644 index 00000000000..46fba71dd18 Binary files /dev/null and b/projects/igniteui-angular/test-utils/assets/images/avatar/3.jpg differ diff --git a/projects/igniteui-angular/test-utils/assets/images/avatar/4.jpg b/projects/igniteui-angular/test-utils/assets/images/avatar/4.jpg new file mode 100644 index 00000000000..096ca437e74 Binary files /dev/null and b/projects/igniteui-angular/test-utils/assets/images/avatar/4.jpg differ diff --git a/projects/igniteui-angular/test-utils/assets/images/avatar/5.jpg b/projects/igniteui-angular/test-utils/assets/images/avatar/5.jpg new file mode 100644 index 00000000000..27087306321 Binary files /dev/null and b/projects/igniteui-angular/test-utils/assets/images/avatar/5.jpg differ diff --git a/projects/igniteui-angular/test-utils/assets/images/avatar/6.jpg b/projects/igniteui-angular/test-utils/assets/images/avatar/6.jpg new file mode 100644 index 00000000000..8fbf99325ea Binary files /dev/null and b/projects/igniteui-angular/test-utils/assets/images/avatar/6.jpg differ diff --git a/projects/igniteui-angular/test-utils/assets/images/avatar/7.jpg b/projects/igniteui-angular/test-utils/assets/images/avatar/7.jpg new file mode 100644 index 00000000000..87c0818dc03 Binary files /dev/null and b/projects/igniteui-angular/test-utils/assets/images/avatar/7.jpg differ diff --git a/projects/igniteui-angular/test-utils/assets/images/avatar/8.jpg b/projects/igniteui-angular/test-utils/assets/images/avatar/8.jpg new file mode 100644 index 00000000000..52f96f184b6 Binary files /dev/null and b/projects/igniteui-angular/test-utils/assets/images/avatar/8.jpg differ diff --git a/projects/igniteui-angular/test-utils/assets/images/avatar/9.jpg b/projects/igniteui-angular/test-utils/assets/images/avatar/9.jpg new file mode 100644 index 00000000000..7175dc3c5d8 Binary files /dev/null and b/projects/igniteui-angular/test-utils/assets/images/avatar/9.jpg differ diff --git a/projects/igniteui-angular/test-utils/bottom-nav-components.spec.ts b/projects/igniteui-angular/test-utils/bottom-nav-components.spec.ts new file mode 100644 index 00000000000..6cd132ce3cf --- /dev/null +++ b/projects/igniteui-angular/test-utils/bottom-nav-components.spec.ts @@ -0,0 +1,291 @@ +import { Component, ViewChild } from '@angular/core'; +import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; +import { IgxBottomNavComponent, IgxBottomNavContentComponent, IgxBottomNavHeaderComponent, IgxBottomNavItemComponent } from 'igniteui-angular/bottom-nav'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxTabHeaderIconDirective, IgxTabHeaderLabelDirective } from 'igniteui-angular/tabs'; + +@Component({ + template: ` +
    + + + + library_music + Tab 1 + + +

    Tab 1 Content

    +

    Lorem ipsum dolor sit amet, consectetur adipiscing elit.

    +
    +
    + + + video_library + Tab 2 + + +

    Tab 2 Content

    +

    Lorem ipsum dolor sit amet, consectetur adipiscing elit.

    +
    +
    + + + library_books + Tab 3 + + +

    Tab 3 Content

    +

    Lorem ipsum dolor sit amet, consectetur adipiscing elit. + Vivamus vitae malesuada odio. Praesent ante lectus, porta a eleifend vel, sodales eu nisl. + Vivamus sit amet purus eu lectus cursus rhoncus quis non ex. + Cras ac nulla sed arcu finibus volutpat. + Vivamus risus ipsum, pharetra a augue nec, euismod fringilla odio. + Integer id velit rutrum, accumsan ante a, semper nunc. + Phasellus ultrices tincidunt imperdiet. Nullam vulputate mauris diam. + Nullam elementum, libero vel varius fermentum, lorem ex bibendum nulla, + pretium lacinia erat nibh vel massa. + In hendrerit, sapien ac mollis iaculis, dolor tellus malesuada sem, + a accumsan lectus nisl facilisis leo. + Curabitur consequat sit amet nulla at consequat. Duis volutpat tristique luctus. +

    +
    +
    +
    +
    `, + imports: [IgxBottomNavComponent, IgxBottomNavItemComponent, IgxBottomNavHeaderComponent, IgxTabHeaderIconDirective, IgxTabHeaderLabelDirective, IgxBottomNavContentComponent] +}) +export class TabBarTestComponent { + @ViewChild(IgxBottomNavComponent, { static: true }) public bottomNav: IgxBottomNavComponent; + @ViewChild('wrapperDiv', { static: true }) public wrapperDiv: any; +} + +@Component({ + template: ` +
    + + + + library_music + Tab 1 + + +

    Tab 1 Content

    +

    Lorem ipsum dolor sit amet, consectetur adipiscing elit.

    +
    +
    + + + video_library + Tab 2 + + +

    Tab 2 Content

    +

    Lorem ipsum dolor sit amet, consectetur adipiscing elit.

    +
    +
    + + + library_books + Tab 3 + + +

    Tab 3 Content

    +

    Lorem ipsum dolor sit amet, consectetur adipiscing elit. + Vivamus vitae malesuada odio. Praesent ante lectus, porta a eleifend vel, sodales eu nisl. + Vivamus sit amet purus eu lectus cursus rhoncus quis non ex. + Cras ac nulla sed arcu finibus volutpat. + Vivamus risus ipsum, pharetra a augue nec, euismod fringilla odio. + Integer id velit rutrum, accumsan ante a, semper nunc. + Phasellus ultrices tincidunt imperdiet. Nullam vulputate mauris diam. + Nullam elementum, libero vel varius fermentum, lorem ex bibendum nulla, + pretium lacinia erat nibh vel massa. + In hendrerit, sapien ac mollis iaculis, dolor tellus malesuada sem, + a accumsan lectus nisl facilisis leo. + Curabitur consequat sit amet nulla at consequat. Duis volutpat tristique luctus. +

    +
    +
    +
    +
    `, + imports: [IgxBottomNavComponent, IgxBottomNavItemComponent, IgxBottomNavHeaderComponent, IgxTabHeaderIconDirective, IgxTabHeaderLabelDirective, IgxBottomNavContentComponent] +}) +export class BottomTabBarTestComponent { + @ViewChild(IgxBottomNavComponent, { static: true }) public bottomNav: IgxBottomNavComponent; + @ViewChild('wrapperDiv', { static: true }) public wrapperDiv: any; +} + +@Component({ + template: ` +
    +
    + +
    + + + + library_music + Tab 1 + + + + + video_library + Tab 2 + + + + + library_books + Tab 3 + + + +
    + `, + imports: [ + IgxBottomNavComponent, + IgxBottomNavItemComponent, + IgxBottomNavHeaderComponent, + IgxTabHeaderIconDirective, + IgxTabHeaderLabelDirective, + IgxIconComponent, + RouterLinkActive, + RouterLink, + RouterOutlet + ] +}) +export class TabBarRoutingTestComponent { + @ViewChild(IgxBottomNavComponent, { static: true }) + public bottomNav: IgxBottomNavComponent; +} + +@Component({ + template: ` +
    + + + + library_music + Tab 1 + + + + + + video_library + Tab 2 + + + + + + library_books + Tab 3 + + + + +
    + `, + imports: [IgxBottomNavComponent, IgxBottomNavItemComponent, IgxBottomNavHeaderComponent, IgxTabHeaderIconDirective, IgxTabHeaderLabelDirective, IgxBottomNavContentComponent] +}) +export class TabBarTabsOnlyModeTestComponent { + @ViewChild(IgxBottomNavComponent, { static: true }) + public bottomNav: IgxBottomNavComponent; +} + +@Component({ + template: ` +
    +
    + +
    + + + + library_music + Tab 1 + + + + + library_books + Tab 5 + + + +
    + `, + imports: [IgxBottomNavComponent, IgxBottomNavItemComponent, IgxBottomNavHeaderComponent, IgxTabHeaderIconDirective, IgxTabHeaderLabelDirective, RouterLink, RouterLinkActive, RouterOutlet] +}) +export class BottomNavRoutingGuardTestComponent { + @ViewChild(IgxBottomNavComponent, { static: true }) + public bottomNav: IgxBottomNavComponent; +} + +@Component({ + template: ` +
    +
    + + + + Tab 1 + + +
    Content 1
    +
    +
    + + + Tab 2 + + +
    Content 2
    +
    +
    + + + Tab 3 + + +
    Content 3
    +
    +
    +
    +
    +
    + + + + Tab 4 + + +
    Content 4
    +
    +
    + + + Tab 5 + + +
    Content 5
    +
    +
    + + + Tab 6 + + +
    Content 6
    +
    +
    +
    +
    +
    + `, + imports: [IgxBottomNavComponent, IgxBottomNavItemComponent, IgxBottomNavHeaderComponent, IgxTabHeaderLabelDirective, IgxBottomNavContentComponent] +}) +export class BottomNavTestHtmlAttributesComponent { + @ViewChild(IgxBottomNavComponent, { static: true }) public bottomNav: IgxBottomNavComponent; +} diff --git a/projects/igniteui-angular/test-utils/calendar-helper-utils.ts b/projects/igniteui-angular/test-utils/calendar-helper-utils.ts new file mode 100644 index 00000000000..324a4baef13 --- /dev/null +++ b/projects/igniteui-angular/test-utils/calendar-helper-utils.ts @@ -0,0 +1,173 @@ +import { ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +export class HelperTestFunctions { + public static DAYS_VIEW = 'igx-days-view'; + public static CALENDAR = 'igx-calendar'; + public static SELECTED_DATE = 'igx-days-view__date--selected'; + public static ICON_CSSCLASS = '.igx-icon'; + public static OVERLAY_CSSCLASS = '.igx-overlay'; + public static MODAL_OVERLAY_CSSCLASS = 'igx-overlay__wrapper--modal'; + + public static CALENDAR_CSSCLASS = '.igx-calendar'; + public static CALENDAR_WRAPPER_CLASS = '.igx-calendar__wrapper'; + public static CALENDAR_WEEK_NUMBER_CLASS = '.igx-days-view__date--week-number'; + public static CALENDAR_WEEK_NUMBER_ITEM_CLASS = '.igx-days-view__date-inner--week-number'; + public static CALENDAR_WEEK_NUMBER_LABEL_CLASS = '.igx-days-view__label--week-number'; + public static CALENDAR_HEADER_CSSCLASS = '.igx-calendar__header'; + public static CALENDAR_HEADER_YEAR_CSSCLASS = '.igx-calendar__header-year'; + public static CALENDAR_HEADER_DATE_CSSCLASS = '.igx-calendar__header-date'; + public static WEEKSTART_LABEL_CSSCLASS = '.igx-days-view__label'; + public static VERTICAL_CALENDAR_CSSCLASS = '.igx-calendar__wrapper--vertical'; + public static DAY_CSSCLASS = '.igx-days-view__date'; + public static CURRENT_MONTH_DATES = '.igx-days-view__date:not(.igx-days-view__date--inactive)'; + public static CURRENT_DATE_CSSCLASS = '.igx-days-view__date--current'; + public static INACTIVE_DAYS_CSSCLASS = '.igx-days-view__date--inactive'; + public static HIDDEN_DAYS_CSSCLASS = '.igx-days-view__date--hidden'; + public static SELECTED_DATE_CSSCLASS = '.igx-days-view__date--selected'; + public static RANGE_CSSCLASS = 'igx-days-view__date--range'; + public static CALENDAR_ROW_CSSCLASS = '.igx-days-view__row'; + public static CALENDAR_ROW_WRAP_CSSCLASS = '.igx-days-view__row--wrap'; + public static MONTH_CSSCLASS = '.igx-calendar-view__item'; + public static CURRENT_MONTH_CSSCLASS = '.igx-calendar-view__item--selected'; + public static YEAR_CSSCLASS = '.igx-calendar-view__item'; + public static CURRENT_YEAR_CSSCLASS = '.igx-calendar-view__item--selected'; + + public static CALENDAR_PREV_BUTTON_CSSCLASS = '.igx-calendar-picker__prev'; + public static CALENDAR_NEXT_BUTTON_CSSCLASS = '.igx-calendar-picker__next'; + public static CALENDAR_DATE_CSSCLASS = '.igx-calendar-picker__date'; + + public static CALENDAR_SUBHEADERS_SELECTOR = + 'div:not(' + HelperTestFunctions.CALENDAR_PREV_BUTTON_CSSCLASS + '):not(' + HelperTestFunctions.CALENDAR_NEXT_BUTTON_CSSCLASS + ')'; + + public static verifyMonthsViewNumber(fixture, monthsView: number, checkCurrentDate = false) { + const el = fixture.nativeElement ? fixture.nativeElement : fixture; + const daysView = el.querySelectorAll(HelperTestFunctions.DAYS_VIEW); + expect(daysView).toBeDefined(); + expect(daysView.length).toBe(monthsView); + const monthPickers = HelperTestFunctions.getCalendarSubHeader(el).querySelectorAll('.igx-calendar-picker__dates'); + expect(monthPickers.length).toBe(monthsView); + if (checkCurrentDate) { + const currentDate = el.querySelector(HelperTestFunctions.CURRENT_DATE_CSSCLASS); + expect(currentDate).not.toBeNull(); + } + } + + public static verifyCalendarHeader(fixture: ComponentFixture, selectedDate: Date) { + const daysView = fixture.nativeElement.querySelector(HelperTestFunctions.CALENDAR_HEADER_CSSCLASS); + expect(daysView).not.toBeNull(); + + const year = fixture.nativeElement.querySelector(HelperTestFunctions.CALENDAR_HEADER_YEAR_CSSCLASS); + expect(year).not.toBeNull(); + expect(year.innerText).toEqual("Select Date"); + + const date = fixture.nativeElement.querySelector(HelperTestFunctions.CALENDAR_HEADER_DATE_CSSCLASS); + expect(date).not.toBeNull(); + + const [weekday, month, day] = selectedDate.toLocaleString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }).split(' '); // (weekday, month day) + expect(date.children[0].innerText.trim()).toEqual(weekday); + expect(date.children[1].innerText.trim()).toEqual(month + ' ' + day); + } + + public static verifyNoRangeSelectionCreated(fixture, monthNumber: number) { + expect(HelperTestFunctions.getMonthView(fixture, monthNumber).querySelector('.igx-days-view__date--range')).toBeNull(); + expect(HelperTestFunctions.getMonthView(fixture, monthNumber).querySelector('.igx-days-view__date--first')).toBeNull(); + expect(HelperTestFunctions.getMonthView(fixture, monthNumber).querySelector('.igx-days-view__date--last')).toBeNull(); + } + + public static verifyCalendarSubHeader(fixture, monthNumber: number, viewDate: Date) { + const monthPickers = HelperTestFunctions.getCalendarSubHeader(fixture).querySelectorAll('div'); + const dateParts = viewDate.toString().split(' '); // weekday month day year + expect(monthPickers[monthNumber].children[0].innerHTML.trim()).toEqual(dateParts[1]); + expect(monthPickers[monthNumber].children[1].innerHTML.trim()).toEqual(dateParts[3]); + } + + public static verifyCalendarSubHeaders(fixture, viewDates: Date[]) { + const dom = fixture.nativeElement ? fixture.nativeElement : fixture; + const monthPickers = HelperTestFunctions.getCalendarSubHeader(dom).querySelectorAll('.igx-calendar-picker__dates'); + + expect(monthPickers.length).toEqual(viewDates.length); + + for (let index = 0; index < viewDates.length; index++) { + const dateParts = viewDates[index].toString().split(' '); // weekday month day year + const monthPickerDates = monthPickers[index].querySelectorAll('.igx-calendar-picker__date'); + expect(monthPickerDates[0].innerHTML.trim()).toContain(dateParts[1]); + expect(monthPickerDates[1].innerHTML.trim()).toContain(dateParts[3]); + } + } + + public static getHiddenDays(fixture, monthNumber: number) { + const monthView = HelperTestFunctions.getMonthView(fixture, monthNumber); + return monthView.querySelectorAll(HelperTestFunctions.HIDDEN_DAYS_CSSCLASS); + } + + public static getInactiveDays(fixture, monthNumber: number) { + const monthView = HelperTestFunctions.getMonthView(fixture, monthNumber); + return monthView.querySelectorAll(HelperTestFunctions.INACTIVE_DAYS_CSSCLASS); + } + + public static getCalendarSubHeader(fixture): HTMLElement { + const element = fixture.nativeElement ? fixture.nativeElement : fixture; + return element.querySelector('.igx-calendar__pickers'); + } + + public static getMonthView(fixture, monthsViewNumber: number) { + const domEL = fixture.nativeElement ? fixture.nativeElement : fixture; + return domEL.querySelectorAll('igx-days-view')[monthsViewNumber]; + } + + public static getMonthViewDates(fixture, monthsViewNumber: number) { + const month = HelperTestFunctions.getMonthView(fixture, monthsViewNumber); + return month.querySelectorAll(HelperTestFunctions.CURRENT_MONTH_DATES); + } + + public static getMonthViewInactiveDates(fixture, monthsViewNumber: number) { + const month = HelperTestFunctions.getMonthView(fixture, monthsViewNumber); + return month.querySelectorAll(HelperTestFunctions.INACTIVE_DAYS_CSSCLASS); + } + + public static getMonthViewSelectedDates(fixture, monthsViewNumber: number) { + const month = HelperTestFunctions.getMonthView(fixture, monthsViewNumber); + return month.querySelectorAll(HelperTestFunctions.SELECTED_DATE_CSSCLASS + + `:not(${HelperTestFunctions.HIDDEN_DAYS_CSSCLASS})`); + } + + public static getMonthsFromMonthView(fixture) { + return fixture.nativeElement.querySelector('igx-months-view') + .querySelectorAll('.igx-calendar-view__item, .igx-calendar-view__item--current'); + } + + public static getYearsFromYearView(fixture) { + return fixture.nativeElement.querySelector('igx-years-view') + .querySelectorAll('.igx-calendar-view__item, .igx-calendar-view__item--selected'); + } + + public static getCurrentYearsFromYearView(fixture) { + return fixture.nativeElement.querySelector('igx-years-view') + .querySelector('.igx-calendar-view__item--current'); + } + + public static getNexArrowElement(fixture) { + return fixture.debugElement.query(By.css(HelperTestFunctions.CALENDAR_NEXT_BUTTON_CSSCLASS)).nativeElement; + } + + public static getPreviousArrowElement(fixture) { + return fixture.debugElement.query(By.css(HelperTestFunctions.CALENDAR_PREV_BUTTON_CSSCLASS)).nativeElement; + } + + public static verifyDateSelected(el) { + expect( + el.nativeElement.classList.contains( + HelperTestFunctions.SELECTED_DATE + ) + ).toBe(true); + } + + public static verifyDateNotSelected(el) { + expect( + el.nativeElement.classList.contains( + HelperTestFunctions.SELECTED_DATE + ) + ).toBe(false); + } +} diff --git a/projects/igniteui-angular/test-utils/configure-suite.ts b/projects/igniteui-angular/test-utils/configure-suite.ts new file mode 100644 index 00000000000..8643c2c0286 --- /dev/null +++ b/projects/igniteui-angular/test-utils/configure-suite.ts @@ -0,0 +1,172 @@ +import { NgModuleRef } from '@angular/core'; +import { TestBed, getTestBed, ComponentFixture, waitForAsync } from '@angular/core/testing'; + +const checkLeaksAvailable = typeof window.gc === 'function'; + +const debug = false; +function debugLog(...args) { + if (debug) { + // eslint-disable-next-line no-console + console.log(...args); + } +} + +let _skipLeakCheck = false; +const throwOnLeak = true; + +interface ConfigureOptions { + /** + * Check for memory leaks when the tests finishes. + * Note, this only works in Chrome configurations with expose the gc. + * Caveats: + * * if there are pending (non-cancelled) timers or animation frames it may report false positives. + * * if there's a beforeEach create it must be cleaned up in an afterEach to avoid being detected as a leak + */ + checkLeaks?: boolean; +} + +/** + * Per https://github.com/angular/angular/issues/12409#issuecomment-391087831 + * Destroy fixtures after each, reset testing module after all + * + * @hidden + */ + +export const configureTestSuite = (configureActionOrOptions?: (() => TestBed) | ConfigureOptions, options: ConfigureOptions = {}) => { + setupJasmineCurrentTest(); + + const configureAction = typeof configureActionOrOptions === 'function' ? configureActionOrOptions : undefined; + options = (configureActionOrOptions && typeof configureActionOrOptions === 'object') ? configureActionOrOptions : options; + options.checkLeaks = options.checkLeaks && checkLeaksAvailable; + + let componentRefs: { test: string, ref: WeakRef<{}> }[]; + const moduleRefs = new Set>(); + + const testBed = getTestBed(); + const originReset = testBed.resetTestingModule; + const originCreateComponent = testBed.createComponent; + + const clearStyles = () => { + document.querySelectorAll('style').forEach(tag => tag.remove()); + }; + + const clearSVGContainer = () => { + document.querySelectorAll('svg').forEach(tag => tag.remove()); + }; + + beforeAll(() => { + testBed.resetTestingModule(); + testBed.resetTestingModule = () => { + softResetTestingModule(); + return testBed; + }; + + if (options.checkLeaks) { + componentRefs = []; + testBed.createComponent = function () { + const fixture = originCreateComponent.apply(testBed, arguments); + if (!_skipLeakCheck) { + componentRefs.push({ test: jasmine['currentTest'].fullName, ref: new WeakRef(fixture.componentInstance) }); + } + return fixture; + }; + } + + jasmine.getEnv().allowRespy(true); + }); + + if (configureAction) { + beforeAll(waitForAsync(() => { + configureAction().compileComponents(); + })); + } + + function reportLeaks() { + gc(); + const leaks = componentRefs.map(({test, ref}) => ({ test, instance: ref.deref()})).filter(item => !!item.instance); + if (leaks.length > 0) { + console.warn(`Detected ${leaks.length} leaks:`); + for (const test of new Set(leaks.map(l => l.test))) { + const testLeaks = leaks.filter(l => l.test === test).map(l => l.instance); + const classNames = new Set(testLeaks.map(i => i.constructor.name)); + for (const name of classNames) { + const count = testLeaks.filter(i => i.constructor.name === name).length; + console.warn(` · ${name}: ${count} - ${test}`); + } + } + if (throwOnLeak) { + throw new Error(`Detected ${leaks.length} leaks`); + } + } else { + debugLog('No leaks detected'); + } + } + + function softResetTestingModule() { + debugLog("Soft-reset testing module"); + clearStyles(); + clearSVGContainer(); + (testBed as any)._activeFixtures.forEach((fixture: ComponentFixture) => { + const element = fixture.debugElement.nativeElement as HTMLElement; + fixture.destroy(); + debugLog("Destroying fixture for component:", fixture.componentInstance.constructor.name); + // If the fixture element ID changes, then it's not properly disposed + element?.remove(); + }); + (testBed as any)._activeFixtures = []; + + // reset ViewEngine TestBed + (testBed as any)._instantiated = false; + + // reset Ivy TestBed + const moduleRef = testBed['_testModuleRef']; + moduleRefs.add(moduleRef); + testBed['_testModuleRef'] = null; + } + + afterAll(() => { + testBed.resetTestingModule = originReset; + debugLog(`Destroying ${moduleRefs.size} module refs`); + for (const moduleRef of moduleRefs) { + testBed['_testModuleRef'] = moduleRef; + testBed.resetTestingModule(); + } + moduleRefs.clear(); + + testBed.createComponent = originCreateComponent; + if (options.checkLeaks) { + reportLeaks(); + } + }); +}; + +/** Calls to Testbed.createComponent() inside this wrapper wont be tracked for leaks */ +export function skipLeakCheck(fn: () => void | Promise) { + return function() { + _skipLeakCheck = true; + const res = fn(); + if (res instanceof Promise) { + return res.finally(() => { + _skipLeakCheck = false; + }); + } else { + _skipLeakCheck = false; + return res; + } + } +} + +let setupJasmineCurrentTestDone = false; +function setupJasmineCurrentTest() { + if (!setupJasmineCurrentTestDone) { + jasmine.getEnv().addReporter({ + specStarted(result) { + jasmine['currentTest'] = result; + }, + specDone(result) { + jasmine['currentTest'] = result; + }, + }); + setupJasmineCurrentTestDone = true; + } +} diff --git a/projects/igniteui-angular/test-utils/controls-functions.spec.ts b/projects/igniteui-angular/test-utils/controls-functions.spec.ts new file mode 100644 index 00000000000..b2d5595140f --- /dev/null +++ b/projects/igniteui-angular/test-utils/controls-functions.spec.ts @@ -0,0 +1,95 @@ +import { By } from '@angular/platform-browser'; +import { DebugElement } from '@angular/core'; +import { ComponentFixture } from '@angular/core/testing'; +import { UIInteractions } from './ui-interactions.spec'; +import { IgxCheckboxComponent } from 'igniteui-angular/checkbox'; + +const CHIP_REMOVE_BUTTON = '.igx-chip__remove'; +const DROP_DOWN_SELECTED_ITEM_CLASS = '.igx-drop-down__item--selected'; +const DROP_DOWN_SCROLL_CLASS = '.igx-drop-down__list-scroll'; +const DROP_DOWN__ITEM_CLASS = '.igx-drop-down__item'; +const BUTTON_SELECTED_CLASS = 'igx-button-group__item--selected'; +const CHECKBOX_CHECKED_CLASS = 'igx-checkbox--checked'; +const CHECKBOX_IND_CLASS = 'igx-checkbox--indeterminate'; + +export const BUTTON_DISABLED_CLASS = 'igx-button--disabled'; +export class ControlsFunction { + + public static getChipRemoveButton(chip: HTMLElement): HTMLElement { + return chip.querySelector(CHIP_REMOVE_BUTTON); + } + + public static clickChipRemoveButton(chip: HTMLElement) { + const removeButton = ControlsFunction.getChipRemoveButton(chip); + removeButton.click(); + } + + public static getDropDownSelectedItem(element: DebugElement): DebugElement { + return element.query(By.css(DROP_DOWN_SELECTED_ITEM_CLASS)); + } + + public static clickDropDownItem(fix: ComponentFixture, index: number) { + const list: HTMLElement = fix.nativeElement.querySelector(DROP_DOWN_SCROLL_CLASS); + const items = list.querySelectorAll(DROP_DOWN__ITEM_CLASS); + const item = items.item(index); + UIInteractions.simulateClickEvent(item); + fix.detectChanges(); + } + + public static verifyButtonIsSelected(element: HTMLElement, selected = true) { + expect(element.classList.contains(BUTTON_SELECTED_CLASS)).toEqual(selected); + } + + public static verifyButtonIsDisabled(element: HTMLElement, disabled = true) { + expect(element.classList.contains(BUTTON_DISABLED_CLASS)).toEqual(disabled); + } + + public static verifyCheckboxState(element: HTMLElement, checked = true, indeterminate = false) { + expect(element.classList.contains(CHECKBOX_CHECKED_CLASS)).toEqual(checked); + expect(element.classList.contains(CHECKBOX_IND_CLASS)).toEqual(indeterminate); + } + + public static getCheckboxElement(name: string, element: DebugElement) { + const checkboxElements = element.queryAll(By.css('igx-checkbox')); + const chkElement = checkboxElements.find((el) => + (el.context as IgxCheckboxComponent).placeholderLabel.nativeElement.innerText === name); + + return chkElement; + } + + public static getCheckboxInput(name: string, element: DebugElement) { + const checkboxEl = ControlsFunction.getCheckboxElement(name, element); + const chkInput = checkboxEl.query(By.css('input')).nativeElement as HTMLInputElement; + + return chkInput; + } + + public static getCheckboxInputs(element: DebugElement): HTMLInputElement[] { + const checkboxElements = element.queryAll(By.css('igx-checkbox')); + const inputs = []; + checkboxElements.forEach((el) => { + inputs.push(el.query(By.css('input')).nativeElement as HTMLInputElement); + }); + + return inputs; + } + + public static verifyCheckbox(name: string, isChecked: boolean, isDisabled: boolean, element: DebugElement) { + const chkInput = ControlsFunction.getCheckboxInput(name, element); + expect(chkInput.type).toBe('checkbox'); + expect(chkInput.disabled).toBe(isDisabled); + expect(chkInput.checked).toBe(isChecked); + } + + /** + * Formats a date according to the provided formatting options + * + * @param date Date to be formatted + * @param formatOptions DateTime formatting options + * @param locale Date language + */ + public static formatDate(date: Date, formatOptions, locale = 'en-US'): string { + const dateFormatter = new Intl.DateTimeFormat(locale, formatOptions); + return `${dateFormatter.format(date)}`; + } +} diff --git a/projects/igniteui-angular/test-utils/grid-base-components.spec.ts b/projects/igniteui-angular/test-utils/grid-base-components.spec.ts new file mode 100644 index 00000000000..0e718e53aae --- /dev/null +++ b/projects/igniteui-angular/test-utils/grid-base-components.spec.ts @@ -0,0 +1,288 @@ +import { Component, OnInit, ViewChild, AfterViewInit, ChangeDetectorRef, inject } from '@angular/core'; +import { SampleTestData } from './sample-test-data.spec'; +import { ColumnDefinitions, GridTemplateStrings } from './template-strings.spec'; +import { IgxPaginatorComponent } from 'igniteui-angular/paginator'; +import { IgxGridComponent } from 'igniteui-angular/grids/grid'; +import { IgxCellTemplateDirective, IgxColumnActionsComponent, IgxColumnComponent, IgxColumnGroupComponent, IgxColumnHidingDirective, IgxColumnPinningDirective, IgxGridToolbarActionsComponent, IgxGridToolbarComponent, IgxGridToolbarHidingComponent, IgxGridToolbarPinningComponent } from 'igniteui-angular/grids/core'; + +@Component({ + template: ` + + + `, + selector: 'igx-basic-grid', + imports: [IgxGridComponent] +}) +export class BasicGridComponent { + @ViewChild(IgxGridComponent, { static: true }) + public grid: IgxGridComponent; + + public data = []; +} + +@Component({ + template: ` + + + `, + selector: 'igx-auto-generate-grid', + imports: [IgxGridComponent] +}) +export class GridAutoGenerateComponent extends BasicGridComponent { + public autoGenerate = true; +} + +@Component({ + template: ` + + + `, + imports: [IgxGridComponent] +}) +export class GridWithSizeComponent extends GridAutoGenerateComponent { + public width = '100%'; + public height = '100%'; + + public scrollTop(newTop: number) { + this.grid.verticalScrollContainer.getScroll().scrollTop = newTop; + } +} + +@Component({ + template: GridTemplateStrings.declareBasicGridWithColumns(ColumnDefinitions.generatedEditable), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class GridNxMComponent extends GridWithSizeComponent implements OnInit { + public colsCount: number; + public rowsCount: number; + public columnsType = 'string'; + public hasEditableColumns = true; + public startFromOne = false; + public columnNamePrefix = 'col'; + public columns = []; + public override autoGenerate = false; + + public ngOnInit() { + this.columns = (this.hasEditableColumns) ? + SampleTestData.generateEditableColumns(this.colsCount, this.columnsType, this.columnNamePrefix) + : SampleTestData.generateColumnsByType(this.colsCount, this.columnsType, this.columnNamePrefix); + this.data = SampleTestData.generateDataForColumns(this.columns, this.rowsCount, this.startFromOne); + } + + public isHorizonatScrollbarVisible() { + const scrollbar = this.grid.headerContainer.getScroll(); + return scrollbar.offsetWidth < (scrollbar.children[0] as HTMLElement).offsetWidth; + } +} + +@Component({ + template: GridTemplateStrings.declareGrid('', '', ColumnDefinitions.idNameJobHireDate, '', + `@if (paging) { + + }`), + imports: [IgxGridComponent, IgxColumnComponent, IgxPaginatorComponent] +}) +export class BasicGridSearchComponent extends GridWithSizeComponent { + public highlightClass = 'igx-highlight'; + public activeClass = 'igx-highlight__active'; + public paging = false; +} + +@Component({ + template: GridTemplateStrings.declareGrid(``, + '', ColumnDefinitions.idNameJobTitle, '', `@if (paging) { + + }`), + imports: [IgxGridComponent, IgxColumnComponent, IgxPaginatorComponent] +}) +export class PagingComponent extends GridWithSizeComponent { + public paging = true; + public perPage = 3; + public override data = SampleTestData.personJobDataFull(); +} + +@Component({ + template: GridTemplateStrings.declareGrid('[pagingMode]="pagingMode"', '', ColumnDefinitions.idNameJobTitle, '', ''), + imports: [IgxGridComponent, IgxColumnComponent, IgxPaginatorComponent] +}) +export class RemotePagingComponent extends GridWithSizeComponent { + public pagingMode = 'remote'; + public perPage = 3; + public totalRecords = 10; + public override data = SampleTestData.personJobDataFull(); +} + +@Component({ + template: GridTemplateStrings.declareGrid(` rowSelection = "multiple"`, '', ColumnDefinitions.productBasicNumberID), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class SelectionComponent extends BasicGridComponent { + public override data = SampleTestData.generateBigValuesData(100); +} + +@Component({ + template: GridTemplateStrings.declareGrid(` [autoGenerate]="true" [exportExcel]="exportExcel" [exportCsv]="exportCsv"`, '', ''), + imports: [IgxGridComponent] +}) +export class GridWithToolbarComponent extends GridWithSizeComponent { + public showToolbar = true; + public columnHiding = true; + public columnPinning = true; + public exportExcel = true; + public exportCsv = true; + + public override data = SampleTestData.contactInfoData(); +} + +@Component({ + template: GridTemplateStrings.declareGrid(` [autoGenerate]="true" [rowClasses]="rowClasses"`, '', ''), + imports: [IgxGridComponent] +}) +export class GridRowConditionalStylingComponent extends GridWithSizeComponent { + + public override data = SampleTestData.contactInfoData(); + public evenRowCondition = (row) => row.index % 2 === 0; + public oddRowCondition = (row) => row.index % 2 !== 0; + + public rowClasses = { + eventRow: this.evenRowCondition, + oddRow: this.oddRowCondition + }; +} +@Component({ + template: `
    + @if (showInline) { + + } + ${GridTemplateStrings.declareGrid('#grid [height]="height" [width]="width"', '', ColumnDefinitions.productHidable, + '' + '' + + '', '@if (paging) { }')} +
    `, + imports: [ + IgxGridComponent, + IgxColumnComponent, + IgxColumnActionsComponent, + IgxGridToolbarComponent, + IgxGridToolbarHidingComponent, + IgxGridToolbarActionsComponent, + IgxPaginatorComponent, + IgxColumnHidingDirective + ] +}) +export class ColumnHidingTestComponent extends GridWithSizeComponent implements OnInit, AfterViewInit { + private cdr = inject(ChangeDetectorRef); + + @ViewChild(IgxColumnActionsComponent) + public chooser: IgxColumnActionsComponent; + public override width = '500px'; + public override height = '500px'; + public showInline = true; + public hideFilter = false; + public paging = false; + + public get hiddenColumnsCount(): number { + return this.chooser.columnItems.filter(c => c.checked).length; + } + + public ngOnInit() { + this.data = SampleTestData.productInfoData(); + } + + public ngAfterViewInit() { + this.cdr.detectChanges(); + } +} + +@Component({ + template: `
    + @if (showInline) { + + } + ${GridTemplateStrings.declareGrid(' #grid [height]="height" [width]="width" [moving]="true"', '', ColumnDefinitions.contactInfoGroupableColumns)} +
    `, + imports: [IgxGridComponent, IgxColumnComponent, IgxColumnActionsComponent, IgxColumnGroupComponent, IgxColumnHidingDirective] +}) +export class ColumnGroupsHidingTestComponent extends ColumnHidingTestComponent { + @ViewChild(IgxGridComponent, { static: true }) public override grid: IgxGridComponent; + + public hasGroupColumns = false; + public override data = SampleTestData.contactInfoDataFull(); +} + +@Component({ + template: `
    + @if (showInline) { + + } + ${GridTemplateStrings.declareGrid('#grid [height]="height" [width]="width"', '', ColumnDefinitions.productFilterable, + '' + + '' + + '')} +
    `, + imports: [IgxGridComponent, IgxColumnComponent, IgxColumnActionsComponent, IgxColumnPinningDirective, IgxGridToolbarComponent, IgxGridToolbarPinningComponent, IgxGridToolbarActionsComponent] +}) +export class ColumnPinningTestComponent extends GridWithSizeComponent implements AfterViewInit, OnInit { + private cdr = inject(ChangeDetectorRef); + + @ViewChild(IgxColumnActionsComponent) public chooser: IgxColumnActionsComponent; + + public override height = '500px'; + public override width = '500px'; + public showInline = true; + public hideFilter = false; + + public ngOnInit() { + this.data = SampleTestData.productInfoData(); + } + + public ngAfterViewInit() { + this.cdr.detectChanges(); + } +} +@Component({ + template: ` + + + + + + + + + +
    + {{val}} +
    +
    +
    +
    + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxCellTemplateDirective] +}) +export class ColumnPinningWithTemplateTestComponent extends ColumnPinningTestComponent { +} + +@Component({ + template: `
    + @if (showInline) { + + } + ${ GridTemplateStrings.declareGrid(' #grid [height]="height" [moving]="true"', '', ColumnDefinitions.contactInfoGroupableColumns, + '')} +
    `, + imports: [IgxGridComponent, IgxColumnComponent, IgxColumnGroupComponent, IgxGridToolbarComponent, IgxColumnActionsComponent, IgxColumnPinningDirective] +}) +export class ColumnGroupsPinningTestComponent extends ColumnPinningTestComponent { + public override data = SampleTestData.contactInfoDataFull(); +} diff --git a/projects/igniteui-angular/test-utils/grid-cell-style-testing.scss b/projects/igniteui-angular/test-utils/grid-cell-style-testing.scss new file mode 100644 index 00000000000..de5fd429f2c --- /dev/null +++ b/projects/igniteui-angular/test-utils/grid-cell-style-testing.scss @@ -0,0 +1,17 @@ +:host::ng-deep { + .test2 { + color: black !important; + font-weight: bold; + background-color: greenyellow !important; + } + .test { + color: red; + font-weight: bold; + background-color: yellow; + } + .test1 { + color: blue ; + font-weight: bold; + background-color: salmon; + } +} diff --git a/projects/igniteui-angular/test-utils/grid-functions.spec.ts b/projects/igniteui-angular/test-utils/grid-functions.spec.ts new file mode 100644 index 00000000000..a46159be685 --- /dev/null +++ b/projects/igniteui-angular/test-utils/grid-functions.spec.ts @@ -0,0 +1,2109 @@ +import { DebugElement, QueryList } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { ComponentFixture, tick } from '@angular/core/testing'; +import { IgxInputDirective } from '../input-group/src/public_api'; +import { IgxChipComponent } from 'igniteui-angular/chips'; +import { UIInteractions, wait } from './ui-interactions.spec'; +import { ControlsFunction } from './controls-functions.spec'; +import { SortingDirection } from '../core/src/data-operations/sorting-strategy'; +import { IgxTreeNodeComponent } from 'igniteui-angular/tree'; +import { IgxIconComponent } from '../icon/src/icon/icon.component'; +import { ColumnType, parseDate } from 'igniteui-angular/core'; +import { CellType, GridType, IgxColumnComponent, IgxColumnGroupComponent, IgxColumnHidingDirective, IgxColumnPinningDirective, IgxGridCellComponent, IgxGridHeaderComponent, IgxGridHeaderGroupComponent, IgxGridHeaderRowComponent, IgxRowDirective, RowType } from 'igniteui-angular/grids/core'; +import { IgxPivotGridComponent } from 'igniteui-angular/grids/pivot-grid'; +import { IgxGridComponent, IgxGridExpandableCellComponent, IgxGridRowComponent } from 'igniteui-angular/grids/grid'; +import { IgxPivotRowComponent } from 'igniteui-angular/grids/pivot-grid/src/pivot-row.component'; + +const SUMMARY_LABEL_CLASS = '.igx-grid-summary__label'; +const SUMMARY_ROW = 'igx-grid-summary-row'; +const SUMMARY_CELL_ACTIVE_CSS_CLASS = 'igx-grid-summary--active'; +const FILTER_UI_CELL = 'igx-grid-filtering-cell'; +const FILTER_UI_ROW = 'igx-grid-filtering-row'; +const FILTER_UI_CONNECTOR = 'igx-filtering-chips__connector'; +const FILTER_ROW_BUTTONS_CLASS = '.igx-grid__filtering-row-editing-buttons'; +const FILTER_UI_INDICATOR = 'igx-grid__filtering-cell-indicator'; +const FILTER_CHIP_CLASS = '.igx-filtering-chips'; +const ESF_MENU_CLASS = '.igx-excel-filter__menu'; +const ESF_SORT_CLASS = '.igx-excel-filter__sort'; +const ESF_MOVE_CLASS = '.igx-excel-filter__move'; +const ESF_CUSTOM_FILTER_DIALOG_CLASS = '.igx-excel-filter__secondary'; +const ESF_FILTER_ICON = '.igx-excel-filter__icon'; +const ESF_FILTER_ICON_FILTERED = '.igx-excel-filter__icon--filtered'; +const ESF_ADD_FILTER_CLASS = '.igx-excel-filter__add-filter'; +const ESF_DEFAULT_EXPR = 'igx-excel-style-default-expression'; +const ESF_DATE_EXPR = 'igx-excel-style-date-expression'; +const BANNER_CLASS = '.igx-banner'; +const BANNER_TEXT_CLASS = '.igx-banner__text'; +const BANNER_ROW_CLASS = '.igx-banner__row'; +const EDIT_OVERLAY_CONTENT = '.igx-overlay__content'; +const PAGER_BUTTONS = 'igx-page-nav > button'; +const ACTIVE_GROUP_ROW_CLASS = 'igx-grid__group-row--active'; +const ACTIVE_HEADER_CLASS = 'igx-grid-th--active'; +const GROUP_ROW_CLASS = 'igx-grid-groupby-row'; +const CELL_SELECTED_CSS_CLASS = 'igx-grid__td--selected'; +const CELL_INVALID_CSS_CLASS = 'igx-grid__td--invalid'; +const CELL_ACTIVE_CSS_CLASS = 'igx-grid__td--active'; +const ROW_DIV_SELECTION_CHECKBOX_CSS_CLASS = 'igx-grid__cbx-selection'; +const ROW_SELECTION_CSS_CLASS = 'igx-grid__tr--selected'; +const HEADER_ROW_CSS_CLASS = '.igx-grid-thead'; +const CHECKBOX_INPUT_CSS_CLASS = '.igx-checkbox__input'; +const CHECKBOX_ELEMENT = 'igx-checkbox'; +const ICON_CSS_CLASS = 'material-icons igx-icon'; +const CHECKBOX_LBL_CSS_CLASS = '.igx-checkbox__composite'; +const GROUP_EXPANDER_CLASS = '.igx-grid-th__expander'; +const GROUP_HEADER_CLASS = '.igx-grid-th__group-title'; +const CELL_CSS_CLASS = '.igx-grid__td'; +const ROW_CSS_CLASS = '.igx-grid__tr'; +const FOCUSED_CHECKBOX_CLASS = 'igx-checkbox--focused'; +const GRID_BODY_CLASS = '.igx-grid__tbody'; +const GRID_FOOTER_CLASS = '.igx-grid__tfoot'; +const GRID_CONTENT_CLASS = '.igx-grid__tbody-content'; +const DISPLAY_CONTAINER = 'igx-display-container'; +const SORT_ICON_CLASS = '.sort-icon'; +const FILTER_ICON_CLASS = '.igx-excel-filter__icon'; +const SELECTED_COLUMN_CLASS = 'igx-grid-th--selected'; +const HOVERED_COLUMN_CLASS = 'igx-grid-th--selectable'; +const SELECTED_COLUMN_CELL_CLASS = 'igx-grid__td--column-selected'; +const FOCUSED_DETAILS_ROW_CLASS = 'igx-grid__tr-container--active'; +const DRAG_INDICATOR_CLASS = '.igx-grid__drag-indicator'; +const SORTED_COLUMN_CLASS = 'igx-grid-th--sorted'; +const SORTING_ICON_ASC_CONTENT = 'arrow_upward'; +const SORTING_ICON_DESC_CONTENT = 'arrow_downward'; +const SUMMARY_CELL = 'igx-grid-summary-cell'; +const COLUMN_ACTIONS_INPUT_CLASS = '.igx-column-actions__header-input'; +const COLUMN_ACTIONS_COLUMNS_CLASS = '.igx-column-actions__columns'; +const COLUMN_ACTIONS_COLUMNS_LABEL_CLASS = 'igx-checkbox__label'; +const GRID_TOOLBAR_TAG = 'igx-grid-toolbar'; +const GRID_TOOLBAR_EXPORT_BUTTON_CLASS = '.igx-grid-toolbar__dropdown#btnExport'; +const GRID_OUTLET_CLASS = 'div.igx-grid__outlet'; +const SORT_INDEX_ATTRIBUTE = 'data-sortIndex'; +const RESIZE_LINE_CLASS = '.igx-grid-th__resize-line'; +const RESIZE_AREA_CLASS = '.igx-grid-th__resize-handle'; +const GRID_COL_THEAD_CLASS = '.igx-grid-th'; +const TREE_NODE_TOGGLE = '.igx-tree-node__toggle-button'; + +export const GRID_SCROLL_CLASS = '.igx-grid__scroll'; +export const GRID_MRL_BLOCK = 'igx-grid__mrl-block'; +export const CELL_PINNED_CLASS = 'igx-grid__td--pinned'; +export const HEADER_PINNED_CLASS = 'igx-grid-th--pinned'; +export const GRID_HEADER_CLASS = '.igx-grid-thead__wrapper'; +export const PINNED_SUMMARY = 'igx-grid-summary--pinned'; +export const PAGER_CLASS = '.igx-page-nav'; +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).toEqual(currState.value); + expect(rowSpan).toEqual(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(); + return rows; + } + + public static getRowCells(fix, rowIndex: number, row: DebugElement = null): DebugElement[] { + const rowElement = row ? row : GridFunctions.getRows(fix)[rowIndex]; + return rowElement.queryAll(By.css(CELL_CSS_CLASS)); + } + + public static getGridBody(fix): DebugElement { + return fix.debugElement.query(By.css(GRID_BODY_CLASS)); + } + + public static getGridContent(fix): DebugElement { + return fix.debugElement.query(By.css(GRID_CONTENT_CLASS)); + } + + public static getGridHeader(grid: GridType): IgxGridHeaderRowComponent { + return grid.theadRow; + } + + public static getGridDisplayContainer(fix): DebugElement { + const gridBody = this.getGridBody(fix); + return gridBody.query(By.css(DISPLAY_CONTAINER)); + } + + public static getGridFooterWrapper(fix): DebugElement { + return fix.debugElement.query(By.css(GRID_FOOTER_CLASS)); + } + + public static getGridFooter(fix): DebugElement { + return fix.debugElement.query(By.css(GRID_FOOTER_CLASS)).children[0]; + } + + public static getGridScroll(fix): DebugElement { + return fix.debugElement.query(By.css(GRID_SCROLL_CLASS)); + } + + public static getRowDisplayContainer(fix, index: number): DebugElement { + const row = GridFunctions.getRows(fix)[index]; + return row.query(By.css(DISPLAY_CONTAINER)); + } + + public static getColGroup(grid: IgxGridComponent | IgxPivotGridComponent, headerName: string): IgxColumnGroupComponent { + const colGroups = grid.columns.filter(c => c.columnGroup && c.header === headerName); + if (colGroups.length === 0) { + return null; + } else if (colGroups.length === 1) { + return colGroups[0]; + } else { + throw new Error('More than one column group found.'); + } + } + + public static getPivotRows(fix): DebugElement[] { + const rows: DebugElement[] = fix.debugElement.queryAll(By.directive(IgxPivotRowComponent)); + return rows; + } + + /** + * Focus the grid header + */ + public static focusHeader(fix: ComponentFixture, grid: GridType) { + this.getGridHeader(grid).nativeElement.focus(); + fix.detectChanges(); + } + + /** + * Focus the first cell in the grid + */ + public static focusFirstCell(fix: ComponentFixture, grid: GridType) { + this.getGridHeader(grid).nativeElement.focus(); + fix.detectChanges(); + this.getGridContent(fix).triggerEventHandler('focus', null); + fix.detectChanges(); + } + + /** + * Focus the cell in the grid + */ + public static focusCell(fix: ComponentFixture, cell: IgxGridCellComponent | CellType) { + this.getGridContent(fix).triggerEventHandler('focus', null); + fix.detectChanges(); + cell.activate(null); + fix.detectChanges(); + } + + public static scrollLeft(grid: IgxGridComponent, newLeft: number) { + const hScrollbar = grid.headerContainer.getScroll(); + hScrollbar.scrollLeft = newLeft; + } + + public static scrollTop(grid: IgxGridComponent, newTop: number) { + const vScrollbar = grid.verticalScrollContainer.getScroll(); + vScrollbar.scrollTop = newTop; + } + + public static getMasterRowDetail(row) { + const nextSibling = row.element.nativeElement.nextElementSibling; + if (nextSibling && + nextSibling.tagName.toLowerCase() === 'div' && + nextSibling.getAttribute('detail') === 'true') { + return nextSibling; + } + return null; + } + + public static verifyMasterDetailRowFocused(row: HTMLElement, focused = true) { + expect(row.classList.contains(FOCUSED_DETAILS_ROW_CLASS)).toEqual(focused); + } + + public static setAllExpanded(grid: IgxGridComponent, data: Array) { + const allExpanded = new Map(); + data.forEach(item => { + allExpanded.set(item['ID'], true); + }); + grid.expansionStates = allExpanded; + } + + public static elementInGridView(grid: IgxGridComponent, element: HTMLElement): boolean { + const gridBottom = grid.tbody.nativeElement.getBoundingClientRect().bottom; + const gridTop = grid.tbody.nativeElement.getBoundingClientRect().top; + return element.getBoundingClientRect().top >= gridTop && element.getBoundingClientRect().bottom <= gridBottom; + } + + public static toggleMasterRowByClick = + async (fix, row: IgxGridRowComponent, debounceTime) => { + const icon = row.element.nativeElement.querySelector('igx-icon'); + UIInteractions.simulateClickAndSelectEvent(icon.parentElement); + await wait(debounceTime); + fix.detectChanges(); + }; + + public static toggleMasterRow(fix: ComponentFixture, row: IgxRowDirective) { + const rowDE = fix.debugElement.queryAll(By.directive(IgxRowDirective)).find(el => el.componentInstance === row); + const expandCellDE = rowDE.query(By.directive(IgxGridExpandableCellComponent)); + expandCellDE.componentInstance.toggle(new MouseEvent('click')); + fix.detectChanges(); + } + + public static getMasterRowDetailDebug(fix: ComponentFixture, row: IgxRowDirective) { + const rowDE = fix.debugElement.queryAll(By.directive(IgxRowDirective)).find(el => el.componentInstance === row); + const detailDE = rowDE.parent.children + .find(el => el.attributes['detail'] === 'true' && el.attributes['data-rowindex'] === row.index + 1 + ''); + return detailDE; + } + + public static getAllMasterRowDetailDebug(fix: ComponentFixture) { + return fix.debugElement.queryAll(By.css('div[detail="true"]')).sort((a, b) => a.context.index - b.context.index); + } + + public static getRowExpandIconName(row: IgxRowDirective) { + return row.element.nativeElement.querySelector('igx-icon').textContent; + } + + public static getGroupedRows(fix): DebugElement[] { + return fix.debugElement.queryAll(By.css(GROUP_ROW_CLASS)); + } + + public static verifyGroupRowIsFocused(groupRow, focused = true) { + expect(groupRow.nativeElement.classList.contains(ACTIVE_GROUP_ROW_CLASS)).toBe(focused); + } + + public static verifyHeaderIsFocused(header, focused = true) { + expect(header.nativeElement.classList.contains(ACTIVE_HEADER_CLASS)).toBe(focused); + } + + public static verifyPivotElementActiveDescendant(elem: DebugElement, id: string): void { + const activeDescendant = elem.nativeElement.getAttribute('aria-activedescendant'); + expect(activeDescendant).toBe(id); + } + + public static verifyHeaderActiveDescendant(headerRow: IgxGridHeaderRowComponent, id: string): void { + const headerRowElem = headerRow.nativeElement; + expect(headerRow.activeDescendant).toBe(id); + const activeDescendant = headerRowElem.getAttribute('aria-activedescendant'); + expect(activeDescendant).toBe(id); + } + + public static verifyGridContentActiveDescendant(gridContent: DebugElement, id: string): void { + const gridContentElem = gridContent.nativeElement; + expect(gridContent.componentInstance.activeDescendant).toBe(id); + const activeDescendant = gridContentElem.getAttribute('aria-activedescendant'); + expect(activeDescendant).toBe(id); + } + + public static getCurrentCellFromGrid(grid, row, cell) { + const gridRow = grid.rowList.toArray()[row]; + const gridCell = gridRow.cells.toArray()[cell]; + return gridCell; + } + + public static getValueFromCellElement(cell) { + return cell.nativeElement.textContent.trim(); + } + + public static verifyColumnIsHidden(column, isHidden: boolean, visibleColumnsCount: number) { + expect(column.hidden).toBe(isHidden, 'Hidden is not ' + isHidden); + + const visibleColumns = column.grid.visibleColumns; + expect(visibleColumns.length).toBe(visibleColumnsCount, 'Unexpected visible columns count!'); + expect(visibleColumns.findIndex((col) => col === column) > -1).toBe(!isHidden, 'Unexpected result for visibleColumns collection!'); + } + + public static verifyColumnsAreHidden(columns, isHidden: boolean, visibleColumnsCount: number) { + const visibleColumns = columns[0].grid.visibleColumns; + columns.forEach(column => { + expect(column.hidden).toBe(isHidden, 'Hidden is not ' + isHidden); + expect(visibleColumns.findIndex((col) => col === column) > -1) + .toBe(!isHidden, 'Unexpected result for visibleColumns collection!'); + }); + expect(visibleColumns.length).toBe(visibleColumnsCount, 'Unexpected visible columns count!'); + } + + public static verifyColumnIsPinned(column, isPinned: boolean, pinnedColumnsCount: number) { + expect(column.pinned).toBe(isPinned, 'Pinned is not ' + isPinned); + + const pinnedColumns = column.grid.pinnedColumns; + expect(pinnedColumns.length).toBe(pinnedColumnsCount, 'Unexpected pinned columns count!'); + expect(pinnedColumns.findIndex((col) => col === column) > -1).toBe(isPinned, 'Unexpected result for pinnedColumns collection!'); + } + + public static verifyUnpinnedAreaWidth(grid: GridType, expectedWidth: number, includeScrollWidth = true) { + const tolerance = includeScrollWidth ? Math.abs(expectedWidth - (grid.unpinnedWidth + grid.scrollSize)) : + Math.abs(expectedWidth - grid.unpinnedWidth); + expect(tolerance).toBeLessThanOrEqual(1); + } + + public static verifyPinnedStartAreaWidth(grid: GridType, expectedWidth: number) { + const tolerance = Math.abs(expectedWidth - grid.pinnedStartWidth); + expect(tolerance).toBeLessThanOrEqual(1); + } + + public static verifyPinnedEndAreaWidth(grid: GridType, expectedWidth: number) { + const tolerance = Math.abs(expectedWidth - grid.pinnedEndWidth); + expect(tolerance).toBeLessThanOrEqual(1); + } + + /* Filtering-related methods */ + public static verifyFilterUIPosition(filterUIContainer, grid) { + const filterUiRightBorder = filterUIContainer.nativeElement.offsetParent.offsetLeft + + filterUIContainer.nativeElement.offsetLeft + filterUIContainer.nativeElement.offsetWidth; + expect(filterUiRightBorder).toBeLessThanOrEqual(grid.nativeElement.offsetWidth); + } + + // Generate expected results for 'date' filtering conditions based on the current date + public static createDateFilterConditions(grid: IgxGridComponent, today) { + const expectedResults = []; + // day + 15 + const dateItem0 = GridFunctions.generateICalendarDate(grid.data[0].ReleaseDate, + today.getFullYear(), today.getMonth()); + // month - 1 + const dateItem1 = GridFunctions.generateICalendarDate(grid.data[1].ReleaseDate, + today.getFullYear(), today.getMonth()); + // day - 1 + const dateItem3 = GridFunctions.generateICalendarDate(grid.data[3].ReleaseDate, + today.getFullYear(), today.getMonth()); + // day + 1 + const dateItem5 = GridFunctions.generateICalendarDate(grid.data[5].ReleaseDate, + today.getFullYear(), today.getMonth()); + // month + 1 + const dateItem6 = GridFunctions.generateICalendarDate(grid.data[6].ReleaseDate, + today.getFullYear(), today.getMonth()); + + let thisMonthCountItems = 1; + let nextMonthCountItems = 1; + let lastMonthCountItems = 1; + let thisYearCountItems = 6; + let nextYearCountItems = 0; + let lastYearCountItems = 0; + + // LastMonth filter + if (dateItem3.isPrevMonth) { + lastMonthCountItems++; + } + expectedResults[0] = lastMonthCountItems; + + // thisMonth filter + if (dateItem0.isCurrentMonth) { + thisMonthCountItems++; + } + + if (dateItem3.isCurrentMonth) { + thisMonthCountItems++; + } + + if (dateItem5.isCurrentMonth) { + thisMonthCountItems++; + } + + // NextMonth filter + if (dateItem0.isNextMonth) { + nextMonthCountItems++; + } + + if (dateItem5.isNextMonth) { + nextMonthCountItems++; + } + expectedResults[1] = nextMonthCountItems; + + // ThisYear, NextYear, PreviousYear filter + + // day + 15 + if (!dateItem0.isThisYear) { + thisYearCountItems--; + } + + if (dateItem0.isNextYear) { + nextYearCountItems++; + } + + // month - 1 + if (!dateItem1.isThisYear) { + thisYearCountItems--; + } + + if (dateItem1.isLastYear) { + lastYearCountItems++; + } + + // day - 1 + if (!dateItem3.isThisYear) { + thisYearCountItems--; + } + + if (dateItem3.isLastYear) { + lastYearCountItems++; + } + + // day + 1 + if (!dateItem5.isThisYear) { + thisYearCountItems--; + } + + if (dateItem5.isNextYear) { + nextYearCountItems++; + } + + // month + 1 + if (!dateItem6.isThisYear) { + thisYearCountItems--; + } + + if (dateItem6.isNextYear) { + nextYearCountItems++; + } + + // ThisYear filter result + expectedResults[2] = thisYearCountItems; + + // NextYear filter result + expectedResults[3] = nextYearCountItems; + + // PreviousYear filter result + expectedResults[4] = lastYearCountItems; + + // ThisMonth filter result + expectedResults[5] = thisMonthCountItems; + + return expectedResults; + } + + public static generateICalendarDate(date: Date, year: number, month: number) { + date = parseDate(date); + return { + date, + isCurrentMonth: date.getFullYear() === year && date.getMonth() === month, + isLastYear: GridFunctions.isLastYear(date, year), + isNextMonth: GridFunctions.isNextMonth(date, year, month), + isNextYear: GridFunctions.isNextYear(date, year), + isPrevMonth: GridFunctions.isPreviousMonth(date, year, month), + isThisYear: GridFunctions.isThisYear(date, year) + }; + } + + public static isPreviousMonth(date: Date, year: number, month: number): boolean { + if (date.getFullYear() === year) { + return date.getMonth() < month; + } + return date.getFullYear() < year; + } + + public static isNextMonth(date: Date, year: number, month: number): boolean { + if (date.getFullYear() === year) { + return date.getMonth() > month; + } + return date.getFullYear() > year; + } + + public static isThisYear(date: Date, year: number): boolean { + return date.getFullYear() === year; + } + + public static isLastYear(date: Date, year: number): boolean { + return date.getFullYear() < year; + } + + public static isNextYear(date: Date, year: number): boolean { + return date.getFullYear() > year; + } + + /* Grouping-related members */ + public static checkGroups(groupRows, expectedGroupOrder, grExpr?) { + // verify group rows are sorted correctly, their indexes in the grid are correct and their group records match the group value. + let count = 0; + const maxLevel = grExpr ? grExpr.length - 1 : 0; + for (const groupRow of groupRows) { + const recs = groupRow.groupRow.records; + const val = groupRow.groupRow.value; + const index = groupRow.index; + const field = groupRow.groupRow.expression.fieldName; + const level = groupRow.groupRow.level; + expect(level).toEqual(grExpr ? grExpr.indexOf(groupRow.groupRow.expression) : 0); + expect(index).toEqual(count); + count++; + expect(val).toEqual(expectedGroupOrder[groupRows.indexOf(groupRow)]); + for (const rec of recs) { + if (level === maxLevel) { + count++; + } + expect(rec[field]).toEqual(val); + } + } + } + + public static checkChips(chips, grExpr, sortExpr) { + for (let i = 0; i < chips.length; i++) { + const chip = chips[i].querySelector('span.igx-chip__label>span').innerText; + const chipDirection = chips[i].querySelector('span.igx-chip__label>igx-icon').innerText; + const grp = grExpr[i]; + const s = sortExpr[i]; + expect(chip).toBe(grp.fieldName); + expect(chip).toBe(s.fieldName); + if (chipDirection === SORTING_ICON_ASC_CONTENT) { + expect(grp.dir).toBe(SortingDirection.Asc); + expect(s.dir).toBe(SortingDirection.Asc); + } else { + expect(grp.dir).toBe(SortingDirection.Desc); + expect(s.dir).toBe(SortingDirection.Desc); + } + } + } + + public static getChipText(chipElem) { + return chipElem.nativeElement.querySelector('div.igx-chip__content').innerText.trim(); + } + + public static clickChip(debugElement) { + UIInteractions.simulateClickAndSelectEvent(debugElement.componentInstance.nativeElement); + } + + public static isInView(index, state): boolean { + return index > state.startIndex && index <= state.startIndex + state.chunkSize; + } + + /* Toolbar-related members */ + public static getToolbar(fixture: ComponentFixture) { + return fixture.debugElement.query(By.css(GRID_TOOLBAR_TAG)); + } + + public static getOverlay(fixture) { + const div = fixture.debugElement.query(By.css(GRID_OUTLET_CLASS)); + return div.nativeElement; + } + + public static getAdvancedFilteringButton(fix: ComponentFixture) { + const button = GridFunctions.getToolbar(fix).query(By.css('igx-grid-toolbar-advanced-filtering > button')); + return button ? button.nativeElement : undefined; + } + + public static getColumnHidingButton(fixture: ComponentFixture) { + const button = GridFunctions.getToolbar(fixture).query(By.css('igx-grid-toolbar-hiding > button')); + return button ? button.nativeElement : undefined; + } + + public static getColumnPinningButton(fixture: ComponentFixture) { + const button = GridFunctions.getToolbar(fixture).query(By.css('igx-grid-toolbar-pinning > button')); + return button ? button.nativeElement : undefined; + } + + public static getExportButton(fixture: ComponentFixture) { + const div = GridFunctions.getToolbar(fixture).query(By.css(GRID_TOOLBAR_EXPORT_BUTTON_CLASS)); + return (div) ? div.query(By.css('button')) : null; + } + + public static getExportOptions(fixture: ComponentFixture) { + const div = GridFunctions.getOverlay(fixture); + return (div) ? div.querySelectorAll('li') : null; + } + + // Filtering + public static getFilteringCells(fix) { + return fix.debugElement.queryAll(By.css(FILTER_UI_CELL)); + } + + public static getFilteringChips(fix) { + return fix.debugElement.queryAll(By.css(FILTER_CHIP_CLASS)); + } + + public static getFilteringChipPerIndex(fix, index) { + return this.getFilteringCells(fix)[index].queryAll(By.css(FILTER_CHIP_CLASS)); + } + + public static getFilterRowCloseButton(fix): DebugElement { + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const buttonsContainer = filterUIRow.query(By.css(FILTER_ROW_BUTTONS_CLASS)); + return buttonsContainer.queryAll(By.css('button'))[1]; + } + + public static removeFilterChipByIndex(index: number, filterUIRow) { + const filterChip = filterUIRow.queryAll(By.css('igx-chip'))[index]; + ControlsFunction.clickChipRemoveButton(filterChip.nativeElement); + } + + public static verifyFilteringDropDownIsOpened(fix, opened = true) { + const dropdownList = fix.debugElement.query(By.css('div.igx-drop-down__list.igx-toggle')); + expect(dropdownList !== null).toEqual(opened); + } + + public static selectFilteringCondition(cond: string, ddList) { + const ddItems = ddList.nativeElement.children; + let i; + for (i = 0; i < ddItems.length; i++) { + const ddItem = ddItems[i].querySelector('.igx-grid__filtering-dropdown-items span'); + if (ddItem.textContent === cond) { + ddItem.click(); + tick(100); + return; + } + } + } + + public static openFilterDDAndSelectCondition(fix: ComponentFixture, index: number) { + GridFunctions.openFilterDD(fix.debugElement); + tick(); + fix.detectChanges(); + + const ddList = fix.debugElement.query(By.css('div.igx-drop-down__list-scroll')); + const ddItems = ddList.nativeElement.children; + ddItems[index].click(); + tick(100); + fix.detectChanges(); + } + + public static applyFilter(value: string, fix: ComponentFixture) { + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const input = filterUIRow.query(By.directive(IgxInputDirective)); + UIInteractions.clickAndSendInputElementValue(input.nativeElement, value, fix); + tick(); // Needed because of the debounce time in filtering row input + fix.detectChanges(); + + // Enter key to submit + UIInteractions.triggerEventHandlerKeyDown('Enter', input); + fix.detectChanges(); + } + + public static filterBy(condition: string, value: string, fix: ComponentFixture) { + // open dropdown + this.openFilterDD(fix.debugElement); + fix.detectChanges(); + + const ddList = fix.debugElement.query(By.css('div.igx-drop-down__list-scroll')); + this.selectFilteringCondition(condition, ddList); + // fix.detectChanges(); + tick(100); + this.applyFilter(value, fix); + tick(100); + } + + public static typeValueInFilterRowInput(value, fix, input = null) { + if (!input) { + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + input = filterUIRow.query(By.directive(IgxInputDirective)); + } + UIInteractions.clickAndSendInputElementValue(input.nativeElement, value, fix); + } + + public static submitFilterRowInput(fix) { + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const input = filterUIRow.query(By.directive(IgxInputDirective)); + + UIInteractions.triggerEventHandlerKeyDown('Enter', input); + fix.detectChanges(); + } + + public static resetFilterRow(fix: ComponentFixture) { + fix.componentInstance.grid.filteringRow.onClearClick(); + tick(100); + fix.detectChanges(); + } + + public static closeFilterRow(fix: ComponentFixture) { + fix.componentInstance.grid.filteringRow.close(); + fix.detectChanges(); + } + + public static openFilterDD(elem: DebugElement) { + const filterUIRow = elem.query(By.css(FILTER_UI_ROW)); + const filterIcon = filterUIRow.query(By.css('igx-icon')); + filterIcon.nativeElement.click(); + } + + /** + * Gets the ESF icon when no filter is applied + */ + public static getExcelFilterIcon(fix: ComponentFixture, columnField: string) { + const columnHeader = GridFunctions.getColumnHeader(columnField, fix).nativeElement; + return columnHeader.querySelector(ESF_FILTER_ICON); + } + + /** + * Gets the ESF icon when filter is applied + */ + public static getExcelFilterIconFiltered(fix: ComponentFixture, columnField: string) { + const columnHeader = GridFunctions.getColumnHeader(columnField, fix).nativeElement; + return columnHeader.querySelector(ESF_FILTER_ICON_FILTERED); + } + + /** + * Gets the ESF tree node icon + */ + public static getExcelFilterTreeNodeIcon(fix: ComponentFixture, index: number) { + const treeNodeEl = fix.debugElement.queryAll(By.directive(IgxTreeNodeComponent))[index]?.nativeElement; + const expandIcon = treeNodeEl.querySelector(TREE_NODE_TOGGLE); + return expandIcon; + } + + public static clickExcelFilterIcon(fix: ComponentFixture, columnField: string) { + const filterIcon = GridFunctions.getExcelFilterIcon(fix, columnField); + const filterIconFiltered = GridFunctions.getExcelFilterIconFiltered(fix, columnField); + const icon = (filterIcon) ? filterIcon : filterIconFiltered; + UIInteractions.simulateClickAndSelectEvent(icon); + } + + public static clickExcelTreeNodeExpandIcon(fix: ComponentFixture, index: number) { + const expandIcon = GridFunctions.getExcelFilterTreeNodeIcon(fix, index); + UIInteractions.simulateClickAndSelectEvent(expandIcon); + } + + public static clickExcelFilterIconFromCode(fix: ComponentFixture, grid: GridType, columnField: string) { + const event = { stopPropagation: () => { }, preventDefault: () => { } }; + const header = grid.getColumnByName(columnField).headerCell; + header.onFilteringIconClick(event); + tick(50); + fix.detectChanges(); + } + + public static clickExcelFilterIconFromCodeAsync(fix: ComponentFixture, grid: GridType, columnField: string) { + const event = { stopPropagation: () => { }, preventDefault: () => { } }; + const header = grid.getColumnByName(columnField).headerCell; + header.onFilteringIconClick(event); + fix.detectChanges(); + } + + public static getApplyButtonExcelStyleFiltering(fix: ComponentFixture, menu = null, grid = 'igx-grid') { + const excelMenu = menu ? menu : GridFunctions.getExcelStyleFilteringComponent(fix, grid); + const containedButtons = Array.from(excelMenu.querySelectorAll('.igx-button--contained')); + const applyButton: any = containedButtons.find((rb: any) => rb.innerText.toLowerCase() === 'apply'); + return applyButton; + } + + public static clickApplyExcelStyleFiltering(fix: ComponentFixture, menu = null, grid = 'igx-grid') { + const applyButton = GridFunctions.getApplyButtonExcelStyleFiltering(fix, menu, grid); + applyButton.click(); + } + + public static clickCancelExcelStyleFiltering(fix: ComponentFixture, menu = null) { + const excelMenu = menu ? menu : GridFunctions.getExcelStyleFilteringComponent(fix); + const flatButtons = Array.from(excelMenu.querySelectorAll('.igx-button--flat')); + const cancelButton: any = flatButtons.find((rb: any) => rb.innerText.toLowerCase() === 'cancel'); + cancelButton.click(); + } + + public static getExcelFilterCascadeButton(fix: ComponentFixture, menu = null): HTMLElement { + const excelMenu = menu ? menu : GridFunctions.getExcelStyleFilteringComponent(fix); + return excelMenu.querySelector('.igx-excel-filter__actions-filter'); + } + + public static clickExcelFilterCascadeButton(fix: ComponentFixture, menu = null) { + const cascadeButton = GridFunctions.getExcelFilterCascadeButton(fix, menu); + cascadeButton.click(); + } + + public static clickOperatorFromCascadeMenu(fix: ComponentFixture, operatorIndex: number) { + ControlsFunction.clickDropDownItem(fix, operatorIndex); + } + + public static getApplyExcelStyleCustomFiltering(fix: ComponentFixture): HTMLElement { + const customFilterMenu = GridFunctions.getExcelStyleCustomFilteringDialog(fix); + const containedButtons = Array.from(customFilterMenu.querySelectorAll('.igx-button--contained')); + const applyButton = containedButtons.find((rb: any) => rb.innerText.toLowerCase() === 'apply'); + return applyButton as HTMLElement; + } + + public static clickApplyExcelStyleCustomFiltering(fix: ComponentFixture) { + const applyButton = GridFunctions.getApplyExcelStyleCustomFiltering(fix); + applyButton.click(); + fix.detectChanges(); + } + + public static clickClearFilterExcelStyleCustomFiltering(fix: ComponentFixture) { + const customFilterMenu = GridFunctions.getExcelStyleCustomFilteringDialog(fix); + const containedButtons = Array.from(customFilterMenu.querySelectorAll('.igx-button--flat')); + const button: any = containedButtons.find((rb: any) => rb.innerText === 'Clear filter'); + button.click(); + fix.detectChanges(); + } + + public static getExcelCustomFilteringExpressionAndButton(fix: ComponentFixture, expressionIndex = 0): HTMLElement { + const expr = GridFunctions.getExcelCustomFilteringDefaultExpressions(fix)[expressionIndex]; + const andButton = GridFunctions.sortNativeElementsHorizontally(Array.from(expr.querySelectorAll('.igx-button-group__item')))[0]; + return andButton; + } + + public static getExcelCustomFilteringExpressionOrButton(fix: ComponentFixture, expressionIndex = 0): HTMLElement { + const expr = GridFunctions.getExcelCustomFilteringDefaultExpressions(fix)[expressionIndex]; + const orButton = GridFunctions.sortNativeElementsHorizontally(Array.from(expr.querySelectorAll('.igx-button-group__item')))[1]; + return orButton; + } + + public static clickCancelExcelStyleCustomFiltering(fix: ComponentFixture) { + const customFilterMenu = GridFunctions.getExcelStyleCustomFilteringDialog(fix); + const flatButtons = Array.from(customFilterMenu.querySelectorAll('.igx-button--flat')); + const cancelButton: any = flatButtons.find((rb: any) => rb.innerText.toLowerCase() === 'cancel'); + cancelButton.click(); + } + + public static getAddFilterExcelStyleCustomFiltering(fix: ComponentFixture): HTMLElement { + const customFilterMenu = GridFunctions.getExcelStyleCustomFilteringDialog(fix); + const addFilterButton: HTMLElement = customFilterMenu.querySelector(ESF_ADD_FILTER_CLASS); + return addFilterButton; + } + + public static clickAddFilterExcelStyleCustomFiltering(fix: ComponentFixture) { + const addFilterButton = GridFunctions.getAddFilterExcelStyleCustomFiltering(fix); + addFilterButton.click(); + } + + /** + * Click the pin/unpin icon in the ESF by specifying whether the icon is in the header + * or at its default position (depending on the display density). + */ + public static clickPinIconInExcelStyleFiltering(fix: ComponentFixture, isIconInHeader = true) { + let pinUnpinIcon: any; + if (isIconInHeader) { + const headerIcons: DebugElement[] = GridFunctions.getExcelFilteringHeaderIconsDebugElements(fix); + const headerAreaPinIcon = headerIcons.find((buttonIcon: DebugElement) => buttonIcon.query(By.directive(IgxIconComponent)).componentInstance.name === "pin"); + const headerAreaUnpinIcon = headerIcons.find((buttonIcon: DebugElement) => buttonIcon.query(By.directive(IgxIconComponent)).componentInstance.name === "unpin"); + pinUnpinIcon = headerAreaPinIcon ? headerAreaPinIcon.nativeElement : headerAreaUnpinIcon.nativeElement; + } else { + const pinContainer = GridFunctions.getExcelFilteringPinContainer(fix); + const unpinContainer = GridFunctions.getExcelFilteringUnpinContainer(fix); + pinUnpinIcon = pinContainer ? pinContainer : unpinContainer; + } + pinUnpinIcon.click(); + } + + /** + * Click the hide icon in the ESF by specifying whether the icon is in the header + * or at its default position (depending on the display density). + */ + public static clickHideIconInExcelStyleFiltering(fix: ComponentFixture, isIconInHeader = true) { + let hideIcon: any; + if (isIconInHeader) { + const headerIcons = GridFunctions.getExcelFilteringHeaderIcons(fix); + hideIcon = headerIcons.find((buttonIcon: any) => buttonIcon.innerText === 'visibility_off'); + } else { + hideIcon = GridFunctions.getExcelFilteringHideContainer(fix); + } + hideIcon.click(); + } + + public static getIconFromButton(iconName: string, fixture: ComponentFixture) { + const icons = fixture.debugElement.queryAll(By.directive(IgxIconComponent)); + return icons.find((de: DebugElement) => { + return de.componentInstance.name === iconName; + }); + } + + /** + * Click the sort ascending button in the ESF. + */ + public static clickSortAscInExcelStyleFiltering(fix: ComponentFixture) { + const sortAscIcon: DebugElement = this.getIconFromButton('sort_asc', fix); + sortAscIcon?.nativeElement.click(); + } + + /** + * Click the column selection button in the ESF. + */ + public static clickColumnSelectionInExcelStyleFiltering(fix: ComponentFixture) { + const columnSelectIcon: any = this.getIconFromButton('selected', fix); + columnSelectIcon?.nativeElement.click(); + } + + /** + * Click the sort descending button in the ESF. + */ + public static clickSortDescInExcelStyleFiltering(fix: ComponentFixture) { + const sortDescIcon: any = this.getIconFromButton('sort_desc', fix); + sortDescIcon?.nativeElement.click(); + } + + /** + * Click the move left button in the ESF. + */ + public static clickMoveLeftInExcelStyleFiltering(fix: ComponentFixture) { + const moveLeftIcon: any = this.getIconFromButton('arrow_back', fix); + moveLeftIcon?.nativeElement.click(); + } + + /** + * Click the move right button in the ESF. + */ + public static clickMoveRightInExcelStyleFiltering(fix: ComponentFixture) { + const moveRightIcon: any = this.getIconFromButton('arrow_forward', fix); + moveRightIcon?.nativeElement.click(); + } + + public static getExcelFilteringInput(fix: ComponentFixture, expressionIndex = 0): HTMLInputElement { + const expr = GridFunctions.getExcelCustomFilteringDefaultExpressions(fix)[expressionIndex]; + return expr.querySelectorAll('.igx-input-group__input').item(1) as HTMLInputElement; + } + + public static getExcelFilteringDDInput(fix: ComponentFixture, + expressionIndex = 0, isDate = false): HTMLInputElement { + const allExpressions = isDate ? GridFunctions.getExcelCustomFilteringDateExpressions(fix) : + GridFunctions.getExcelCustomFilteringDefaultExpressions(fix); + return allExpressions[expressionIndex].querySelectorAll('.igx-input-group__input').item(0) as HTMLInputElement; + } + + public static setInputValueESF(fix: ComponentFixture, expressionIndex: number, value: any) { + const input = GridFunctions.getExcelFilteringInput(fix, expressionIndex); + UIInteractions.clickAndSendInputElementValue(input, value, fix); + } + + /** + * Gets the clear filter button in the ESF. + */ + public static getClearFilterInExcelStyleFiltering(fix: ComponentFixture, menu = null): HTMLElement { + const excelMenu = menu ? menu : GridFunctions.getExcelStyleFilteringComponent(fix); + const clearFilterContainer = excelMenu.querySelector('.igx-excel-filter__actions-clear'); + const clearFilterDisabledContainer = excelMenu.querySelector('.igx-excel-filter__actions-clear--disabled'); + const clearIcon = clearFilterContainer ? clearFilterContainer : clearFilterDisabledContainer; + return clearIcon; + } + + /** + * Click the clear filter button in the ESF. + */ + public static clickClearFilterInExcelStyleFiltering(fix: ComponentFixture, menu = null) { + const clearIcon = GridFunctions.getClearFilterInExcelStyleFiltering(fix, menu); + clearIcon.click(); + } + + /** + * returns the filter row debug element. + */ + public static getFilterRow(fix: ComponentFixture): DebugElement { + return fix.debugElement.query(By.css(FILTER_UI_ROW)); + } + + /** + * Open filtering row for a column. + */ + public static clickFilterCellChip(fix, columnField: string) { + const grid = fix.componentInstance.grid; + grid.getColumnByName(columnField).filterCell.onChipClicked(); + fix.detectChanges(); + } + + /** + * Click the filter chip for the provided column in order to open the filter row for it. + */ + public static clickFilterCellChipUI(fix, columnField: string, forGrid?: GridType) { + const headerGroups = fix.debugElement.queryAll(By.directive(IgxGridHeaderGroupComponent)); + const headerGroup = headerGroups.find((hg) => { + const col: IgxColumnComponent = hg.componentInstance.column; + return col.field === columnField && (forGrid ? forGrid.gridAPI.grid === col.grid : true); + }); + const filterCell = headerGroup.query(By.css(FILTER_UI_CELL)); + const chip = filterCell.query(By.css('igx-chip')); + + chip.nativeElement.click(); + fix.detectChanges(); + } + /** + * Presuming filter row is opened, click the filter condition chip based on ascending index (left to right). + */ + public static clickFilterConditionChip(fix: ComponentFixture, index: number) { + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const conditionChips = GridFunctions.sortNativeElementsHorizontally( + filterUIRow.queryAll(By.directive(IgxChipComponent)).map((ch) => ch.nativeElement)); + conditionChips[index].click(); + } + + /** + * Presuming filter row is opened, click the inter-chip operator based on its index. + */ + public static clickChipOperator(fix: ComponentFixture, index: number) { + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const allIcons = filterUIRow.queryAll(By.css('igx-icon')).map((icon) => icon.nativeElement); + const operatorIcons = GridFunctions.sortNativeElementsHorizontally( + allIcons.filter((icon) => icon.innerText === 'expand_more')); + const operatorIcon = operatorIcons[index]; + operatorIcon.click(); + } + + /** + * Presuming chip operator dropdown is opened, set the inter-chip operator value. (should be 'And' or 'Or') + */ + public static clickChipOperatorValue(fix: ComponentFixture, operatorValue: string) { + const gridNativeElement = fix.debugElement.query(By.css('igx-grid')).nativeElement; + const operators = GridFunctions.sortNativeElementsVertically( + Array.from(gridNativeElement.querySelectorAll('.igx-drop-down__item'))); + const operator = operators.find((op) => op.innerText.toLowerCase() === operatorValue.toLowerCase()); + operator.click(); + fix.detectChanges(); + } + + public static getExcelStyleFilteringComponent(fix, grid = 'igx-grid') { + const gridNativeElement = fix.debugElement.query(By.css(grid)).nativeElement; + let excelMenu = gridNativeElement.querySelector(ESF_MENU_CLASS); + if (!excelMenu) { + excelMenu = fix.nativeElement.querySelector(ESF_MENU_CLASS); + } + return excelMenu; + } + public static getExcelStyleFilteringComponents(fix, grid = 'igx-pivot-grid') { + const gridNativeElement = fix.debugElement.query(By.css(grid)).nativeElement; + let excelMenus = gridNativeElement.querySelectorAll(ESF_MENU_CLASS); + if (!excelMenus) { + excelMenus = fix.nativeElement.querySelector(ESF_MENU_CLASS); + } + return excelMenus; + } + public static getExcelStyleFilteringCheckboxes(fix, menu = null, grid = 'igx-grid'): HTMLElement[] { + const searchComp = GridFunctions.getExcelStyleSearchComponent(fix, menu, grid); + return GridFunctions.sortNativeElementsVertically(Array.from(searchComp.querySelectorAll(CHECKBOX_INPUT_CSS_CLASS))); + } + + public static getExcelStyleFilteringSortContainer(fix, menu = null) { + const excelMenu = menu ? menu : GridFunctions.getExcelStyleFilteringComponent(fix); + return excelMenu.querySelector(ESF_SORT_CLASS); + } + + public static getExcelStyleFilteringSortButtons(fix, menu = null): HTMLElement[] { + const sortContainer = GridFunctions.getExcelStyleFilteringSortContainer(fix, menu); + return sortContainer.querySelectorAll('.igx-button--flat'); + } + + public static getExcelStyleFilteringMoveContainer(fix, menu = null) { + const excelMenu = menu ? menu : GridFunctions.getExcelStyleFilteringComponent(fix); + return excelMenu.querySelector(ESF_MOVE_CLASS); + } + + public static getExcelStyleFilteringMoveButtons(fix, menu = null): HTMLElement[] { + const moveContainer = GridFunctions.getExcelStyleFilteringMoveContainer(fix, menu); + return moveContainer.querySelectorAll('.igx-button--flat'); + } + + public static getExcelStyleSearchComponent(fix, menu = null, grid = 'igx-grid') { + const excelMenu = menu ? menu : GridFunctions.getExcelStyleFilteringComponent(fix, grid); + const searchComponent = excelMenu.querySelector('.igx-excel-filter__menu-main'); + return searchComponent; + } + + public static getExcelStyleSearchComponentScrollbar(fix, menu = null) { + const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix, menu); + const scrollbar = searchComponent.querySelector('igx-virtual-helper'); + return scrollbar; + } + + public static getExcelStyleSearchComponentInput(fix, comp = null, grid = 'igx-grid'): HTMLInputElement { + const searchComponent = comp ? comp : GridFunctions.getExcelStyleSearchComponent(fix, null, grid); + return searchComponent.querySelector('.igx-input-group__input'); + } + + public static getExcelStyleSearchComponentListItems(fix, comp = null, grid = 'igx-grid'): HTMLElement[] { + const searchComponent = comp ? comp : GridFunctions.getExcelStyleSearchComponent(fix, null, grid); + return GridFunctions.sortNativeElementsVertically(Array.from(searchComponent.querySelectorAll('igx-list-item'))); + } + + public static getExcelStyleSearchComponentTreeNodes(fix, comp = null, grid = 'igx-tree-grid'): HTMLElement[] { + const searchComponent = comp ? comp : GridFunctions.getExcelStyleSearchComponent(fix, null, grid); + return GridFunctions.sortNativeElementsVertically(Array.from(searchComponent.querySelectorAll('igx-tree-node'))); + } + + public static getColumnHeaders(fix: ComponentFixture): DebugElement[] { + return fix.debugElement.queryAll(By.directive(IgxGridHeaderComponent)); + } + + public static getColumnHeader(columnField: string, fix: ComponentFixture, forGrid?: GridType): DebugElement { + return this.getColumnHeaders(fix).find((header) => { + const col = header.componentInstance.column; + return col.field === columnField && (forGrid ? forGrid === col.grid : true); + }); + } + + public static getColumnGroupHeaders(fix: ComponentFixture): DebugElement[] { + const allHeaders = fix.debugElement.queryAll(By.directive(IgxGridHeaderGroupComponent)); + const groupHeaders = allHeaders.filter(h => h.componentInstance.column.columnGroup); + return groupHeaders; + } + + public static getColumnGroupHeader(header: string, fix: ComponentFixture, forGrid?: GridType): DebugElement { + const headers = this.getColumnGroupHeaders(fix); + const head = headers.find((gr) => { + const col = gr.componentInstance.column; + return col.header === header && (forGrid ? forGrid === col.grid : true); + }); + return head; + } + + public static clickColumnHeaderUI(columnField: string, fix: ComponentFixture, ctrlKey = false, shiftKey = false) { + const header = this.getColumnHeader(columnField, fix); + header.triggerEventHandler('click', new MouseEvent('click', { shiftKey, ctrlKey })); + fix.detectChanges(); + } + + public static clickColumnGroupHeaderUI(columnField: string, fix: ComponentFixture, ctrlKey = false, shiftKey = false) { + const header = this.getColumnGroupHeaderCell(columnField, fix); + header.triggerEventHandler('click', new MouseEvent('click', { shiftKey, ctrlKey })); + fix.detectChanges(); + } + + public static getColumnHeaderByIndex(fix: ComponentFixture, index: number) { + return fix.debugElement.queryAll(By.css(GRID_COL_THEAD_CLASS))[index]; + } + + + public static getColumnHeaderTitleByIndex(fix: ComponentFixture, index: number) { + const nativeHeaders = fix.debugElement.queryAll(By.directive(IgxGridHeaderComponent)) + .map((header) => header.nativeElement); + const sortedNativeHeaders = GridFunctions.sortNativeElementsHorizontally(nativeHeaders); + return sortedNativeHeaders[index].querySelector('.igx-grid-th__title'); + } + + public static getFilterChipsForColumn(columnField: string, fix: ComponentFixture) { + const columnHeader = this.getColumnHeader(columnField, fix); + return columnHeader.parent.queryAll(By.directive(IgxChipComponent)); + } + + public static getFilterOperandsForColumn(columnField: string, fix: ComponentFixture) { + const columnHeader = this.getColumnHeader(columnField, fix); + return columnHeader.parent.queryAll(By.css('.' + FILTER_UI_CONNECTOR)); + } + + public static getFilterIndicatorForColumn(columnField: string, fix: ComponentFixture): DebugElement[] { + const columnHeader = this.getColumnHeader(columnField, fix); + return columnHeader.parent.queryAll(By.css('.' + FILTER_UI_INDICATOR)); + } + + public static getFilterCellMoreIcon(columnField: string, fix: ComponentFixture) { + const filterCell = GridFunctions.getFilterCell(fix, columnField); + const moreIcon = Array.from(filterCell.queryAll(By.css('igx-icon'))) + .find((ic: any) => ic.nativeElement.innerText === 'filter_list'); + return moreIcon; + } + + public static getExcelFilteringHeaderIcons(fix: ComponentFixture, menu = null) { + const excelMenu = menu ? menu : GridFunctions.getExcelStyleFilteringComponent(fix); + const headerArea = excelMenu.querySelector('.igx-excel-filter__menu-header'); + return Array.from(headerArea.querySelectorAll('.igx-icon-button')); + } + + public static getExcelFilteringHeaderIconsDebugElements(fix: ComponentFixture, menu = null) { + const headerArea = fix.debugElement.query(By.css('.igx-excel-filter__menu-header')); + return Array.from(headerArea.queryAll(By.css('.igx-icon-button'))); + } + + public static getExcelFilteringPinContainer(fix: ComponentFixture, menu = null): HTMLElement { + const excelMenu = menu ? menu : GridFunctions.getExcelStyleFilteringComponent(fix); + const pinContainer = excelMenu.querySelector('.igx-excel-filter__actions-pin'); + const pinContainerDisabled = excelMenu.querySelector('.igx-excel-filter__actions-pin--disabled'); + return pinContainer ? pinContainer : pinContainerDisabled; + } + + public static getExcelFilteringUnpinContainer(fix: ComponentFixture, menu = null): HTMLElement { + const excelMenu = menu ? menu : GridFunctions.getExcelStyleFilteringComponent(fix); + return excelMenu.querySelector('.igx-excel-filter__actions-unpin'); + } + + public static getExcelFilteringPinComponent(fix: ComponentFixture, menu = null): HTMLElement { + const excelMenu = menu ? menu : GridFunctions.getExcelStyleFilteringComponent(fix); + return excelMenu.querySelector('igx-excel-style-pinning'); + } + + public static getExcelFilteringHideContainer(fix: ComponentFixture, menu = null): HTMLElement { + const excelMenu = menu ? menu : GridFunctions.getExcelStyleFilteringComponent(fix); + return excelMenu.querySelector('.igx-excel-filter__actions-hide'); + } + + public static getExcelFilteringHideComponent(fix: ComponentFixture, menu = null): HTMLElement { + const excelMenu = menu ? menu : GridFunctions.getExcelStyleFilteringComponent(fix); + return excelMenu.querySelector('igx-excel-style-hiding'); + } + + public static getExcelFilteringHeaderComponent(fix: ComponentFixture, menu = null): HTMLElement { + const excelMenu = menu ? menu : GridFunctions.getExcelStyleFilteringComponent(fix); + return excelMenu.querySelector('igx-excel-style-header'); + } + + public static getExcelFilteringSortComponent(fix: ComponentFixture, menu = null): HTMLElement { + const excelMenu = menu ? menu : GridFunctions.getExcelStyleFilteringComponent(fix); + return excelMenu.querySelector('igx-excel-style-sorting'); + } + + public static getExcelFilteringMoveComponent(fix: ComponentFixture, menu = null): HTMLElement { + const excelMenu = menu ? menu : GridFunctions.getExcelStyleFilteringComponent(fix); + return excelMenu.querySelector('igx-excel-style-moving'); + } + + public static getExcelFilteringColumnSelectionContainer(fix: ComponentFixture, menu = null): HTMLElement { + const excelMenu = menu ? menu : GridFunctions.getExcelStyleFilteringComponent(fix); + return excelMenu.querySelector('.igx-excel-filter__actions-select') || + excelMenu.querySelector('.igx-excel-filter__actions-selected'); + } + + public static getExcelFilteringColumnSelectionComponent(fix: ComponentFixture, menu = null): HTMLElement { + const excelMenu = menu ? menu : GridFunctions.getExcelStyleFilteringComponent(fix); + return excelMenu.querySelector('igx-excel-style-column-selecting'); + } + + public static getExcelFilteringClearFiltersComponent(fix: ComponentFixture, menu = null): HTMLElement { + const excelMenu = menu ? menu : GridFunctions.getExcelStyleFilteringComponent(fix); + return excelMenu.querySelector('igx-excel-style-clear-filters'); + } + + public static getExcelFilteringConditionalFilterComponent(fix: ComponentFixture, menu = null): HTMLElement { + const excelMenu = menu ? menu : GridFunctions.getExcelStyleFilteringComponent(fix); + return excelMenu.querySelector('igx-excel-style-conditional-filter'); + } + + public static getExcelFilteringSearchComponent(fix: ComponentFixture, menu = null, grid = 'igx-grid'): HTMLElement { + const excelMenu = menu ? menu : GridFunctions.getExcelStyleFilteringComponent(fix, grid); + return excelMenu.querySelector('igx-excel-style-search'); + } + + public static getExcelFilteringLoadingIndicator(fix: ComponentFixture) { + const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix); + const loadingIndicator = searchComponent.querySelector('.igx-excel-filter__loading'); + return loadingIndicator; + } + + public static getColumnCells(fix, columnKey, gridCell = 'igx-grid-cell') { + const allCells = fix.debugElement.queryAll(By.css(gridCell)); + return allCells.filter((cell) => cell.componentInstance.column.field === columnKey); + } + + public static getFilterRowLeftArrowButton(fix) { + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + return filterUIRow.query(By.css('.igx-grid__filtering-row-scroll-start')); + } + + public static getFilterRowRightArrowButton(fix) { + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + return filterUIRow.query(By.css('.igx-grid__filtering-row-scroll-end')); + } + + public static getFilterCell(fix, columnKey) { + const headerGroups = fix.debugElement.queryAll(By.directive(IgxGridHeaderGroupComponent)); + const headerGroup = headerGroups.find((hg) => hg.componentInstance.column.field === columnKey); + return headerGroup.query(By.css(FILTER_UI_CELL)); + } + + public static getFilterConditionChip(fix, index) { + const conditionChips = this.getAllFilterConditionChips(fix); + + return conditionChips[index]; + } + + public static getAllFilterConditionChips(fix) { + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const conditionChips = GridFunctions.sortNativeElementsHorizontally( + filterUIRow.queryAll(By.directive(IgxChipComponent)).map((ch) => ch.nativeElement)); + return conditionChips; + } + + public static getFilterRowPrefix(fix): DebugElement { + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const inputgroup = filterUIRow.query(By.css('igx-input-group')); + return inputgroup.query(By.css('igx-prefix')); + } + + public static getFilterRowSuffix(fix): DebugElement { + const filterUIRow = fix.debugElement.query(By.css(FILTER_UI_ROW)); + const inputGroup = filterUIRow.query(By.css('igx-input-group')); + return inputGroup.query(By.css('igx-suffix')); + } + + public static getFilterRowInputCommitIcon(fix) { + const suffix = GridFunctions.getFilterRowSuffix(fix); + const commitIcon: any = Array.from(suffix.queryAll(By.css('igx-icon'))) + .find((icon: any) => icon.nativeElement.innerText === 'check'); + return commitIcon; + } + + public static getFilterRowInputClearIcon(fix) { + const suffix = GridFunctions.getFilterRowSuffix(fix); + const clearIcon: any = Array.from(suffix.queryAll(By.css('igx-icon'))) + .find((icon: any) => icon.nativeElement.innerText === 'clear'); + return clearIcon; + } + + public static getExcelStyleCustomFilteringDialog(fix: ComponentFixture): HTMLElement { + return fix.nativeElement.querySelector(ESF_CUSTOM_FILTER_DIALOG_CLASS); + } + + public static getExcelCustomFilteringDefaultExpressions(fix: ComponentFixture): HTMLElement[] { + const customFilterMenu = GridFunctions.getExcelStyleCustomFilteringDialog(fix); + const expressions = customFilterMenu.querySelectorAll(ESF_DEFAULT_EXPR); + return GridFunctions.sortNativeElementsVertically(Array.from(expressions)); + } + + public static getExcelCustomFilteringDateExpressions(fix: ComponentFixture) { + const customFilterMenu = GridFunctions.getExcelStyleCustomFilteringDialog(fix); + return GridFunctions.sortNativeElementsVertically( + Array.from(customFilterMenu.querySelectorAll(ESF_DATE_EXPR))); + } + + public static clickAdvancedFilteringButton(fix: ComponentFixture) { + const advFilterButton = GridFunctions.getAdvancedFilteringButton(fix); + advFilterButton.click(); + } + + public static clickAdvancedFilteringClearFilterButton(fix: ComponentFixture) { + const clearFilterButton = GridFunctions.getAdvancedFilteringClearFilterButton(fix); + clearFilterButton.click(); + } + + public static clickAdvancedFilteringCancelButton(fix: ComponentFixture) { + const cancelButton = GridFunctions.getAdvancedFilteringCancelButton(fix); + cancelButton.click(); + } + + public static clickAdvancedFilteringApplyButton(fix: ComponentFixture) { + const applyButton = GridFunctions.getAdvancedFilteringApplyButton(fix); + applyButton.click(); + } + + public static getAdvancedFilteringComponent(fix: ComponentFixture) { + const gridNativeElement = fix.debugElement.query(By.css('igx-grid')).nativeElement; + let advFilterDialog = gridNativeElement.querySelector('.igx-advanced-filter'); + + if (!advFilterDialog) { + advFilterDialog = fix.nativeElement.querySelector('.igx-advanced-filter'); + } + return advFilterDialog; + } + + public static getAdvancedFilteringFooter(fix: ComponentFixture) { + const advFilterDialog = GridFunctions.getAdvancedFilteringComponent(fix); + const footer = advFilterDialog.querySelector('.igx-excel-filter__secondary-footer'); + return footer; + } + + public static getAdvancedFilteringClearFilterButton(fix: ComponentFixture) { + const footer = GridFunctions.getAdvancedFilteringFooter(fix); + const clearFilterButton: any = Array.from(footer.querySelectorAll('button')) + .find((b: any) => b.innerText.toLowerCase() === 'clear filter'); + return clearFilterButton; + } + + public static getAdvancedFilteringCancelButton(fix: ComponentFixture) { + const footer = GridFunctions.getAdvancedFilteringFooter(fix); + const cancelFilterButton: any = Array.from(footer.querySelectorAll('button')) + .find((b: any) => b.innerText.toLowerCase() === 'cancel'); + return cancelFilterButton; + } + + public static getAdvancedFilteringApplyButton(fix: ComponentFixture) { + const footer = GridFunctions.getAdvancedFilteringFooter(fix); + const applyFilterButton: any = Array.from(footer.querySelectorAll('button')) + .find((b: any) => b.innerText.toLowerCase() === 'apply'); + return applyFilterButton; + } + + public static getAdvancedFilteringOutlet(fix: ComponentFixture) { + const gridNativeElement = fix.debugElement.query(By.css('igx-grid')).nativeElement; + let advFilteringDialog = gridNativeElement.querySelector('igx-advanced-filtering-dialog'); + + if (!advFilteringDialog) { + advFilteringDialog = fix.nativeElement.querySelector('igx-advanced-filtering-dialog'); + } + const outlet = advFilteringDialog.querySelector('.igx-query-builder__outlet'); + return outlet; + } + + public static setOperatorESF(fix: ComponentFixture, expressionIndex: number, itemIndex: number) { + const input: HTMLInputElement = GridFunctions.getExcelFilteringDDInput(fix, expressionIndex); + input.click(); + fix.detectChanges(); + + const operators = fix.nativeElement.querySelectorAll('.igx-drop-down__list-scroll')[expressionIndex + 1]; + const operator = operators.children[itemIndex].children[0]; + operator.click(); + tick(); + fix.detectChanges(); + } + + public static sortNativeElementsVertically(arr) { + return arr.sort((a: HTMLElement, b: HTMLElement) => a.getBoundingClientRect().top - b.getBoundingClientRect().top); + } + + public static sortNativeElementsHorizontally(arr) { + return arr.sort((a: HTMLElement, b: HTMLElement) => a.getBoundingClientRect().left - b.getBoundingClientRect().left); + } + + public static sortDebugElementsVertically(arr) { + return arr.sort((a, b) => a.nativeElement.getBoundingClientRect().top - b.nativeElement.getBoundingClientRect().top); + } + + public static sortDebugElementsHorizontally(arr) { + return arr.sort((a, b) => a.nativeElement.getBoundingClientRect().left - b.nativeElement.getBoundingClientRect().left); + } + + public static getRowEditingBannerRow(fix): HTMLElement { + return fix.nativeElement.querySelector(BANNER_ROW_CLASS); + } + + public static getRowEditingDebugElement(fix): DebugElement { + return fix.debugElement.query(By.css(BANNER_ROW_CLASS)); + } + + public static getRowEditingBanner(fix): HTMLElement { + return fix.nativeElement.querySelector(BANNER_CLASS); + } + + public static getRowEditingOverlay(fix): HTMLElement { + return fix.nativeElement.querySelector(EDIT_OVERLAY_CONTENT); + } + + public static getRowEditingBannerText(fix) { + return fix.nativeElement.querySelector(BANNER_TEXT_CLASS).textContent.trim(); + } + + public static getRowEditingDoneButton(fix): HTMLElement { + return GridFunctions.getRowEditingBannerRow(fix).lastElementChild as HTMLElement; + } + + public static getRowEditingCancelButton(fix): HTMLElement { + return GridFunctions.getRowEditingBannerRow(fix).firstElementChild as HTMLElement; + } + + public static getRowEditingCancelDebugElement(fix): DebugElement { + return GridFunctions.getRowEditingDebugElement(fix).queryAll(By.css('.igx-button--flat'))[0]; + } + + public static getRowEditingDoneDebugElement(fix): DebugElement { + return GridFunctions.getRowEditingDebugElement(fix).queryAll(By.css('.igx-button--flat'))[1]; + } + + public static getPagingButtons(parent) { + return parent.querySelectorAll(PAGER_BUTTONS); + } + + public static clickPagingButton(parent, buttonIndex: number) { + const pagingButtons = GridFunctions.getPagingButtons(parent); + pagingButtons[buttonIndex].dispatchEvent(new Event('click')); + } + + public static navigateToFirstPage(parent) { + GridFunctions.clickPagingButton(parent, 0); + } + + public static navigateToPrevPage(parent) { + GridFunctions.clickPagingButton(parent, 1); + } + + public static navigateToNextPage(parent) { + GridFunctions.clickPagingButton(parent, 2); + } + + public static navigateToLastPage(parent) { + GridFunctions.clickPagingButton(parent, 3); + } + + public static getColGroupExpandIndicator(group): HTMLElement { + return group.nativeElement.querySelector(GROUP_EXPANDER_CLASS); + } + + public static getColumnGroupHeaderCell(columnField: string, fix: ComponentFixture) { + const headerTitle = fix.debugElement.queryAll(By.css(GROUP_HEADER_CLASS)) + .find(header => header.nativeElement.title === columnField); + return headerTitle.parent; + } + + public static getGridPaginator(by: IgxGridComponent | ComponentFixture) { + return by.nativeElement.querySelector(PAGER_CLASS); + } + + public static getGridPageSelectElement(fix) { + return fix.debugElement.query(By.css('igx-select')).nativeElement; + } + + public static clickOnPaginatorButton(btn: DebugElement) { + btn.triggerEventHandler('click', new Event('click')); + } + + public static clickOnPageSelectElement(fix) { + const select = GridFunctions.getGridPageSelectElement(fix); + UIInteractions.simulateClickEvent(select); + } + + public static verifyGroupIsExpanded(fixture, group, collapsible = true, isExpanded = true, indicatorText = ['expand_more', 'chevron_right']) { + const groupHeader = GridFunctions.getColumnGroupHeaderCell(group.header, fixture); + expect(group.collapsible).toEqual(collapsible); + + if (collapsible === false) { + expect(GridFunctions.getColGroupExpandIndicator(groupHeader)).toBeNull(); + } else { + expect(group.expanded).toEqual(isExpanded); + const text = isExpanded ? indicatorText[0] : indicatorText[1]; + expect(GridFunctions.getColGroupExpandIndicator(groupHeader)).toBeDefined(); + expect(GridFunctions.getColGroupExpandIndicator(groupHeader).innerText.trim()).toEqual(text); + } + } + + public static clickGroupExpandIndicator(fixture, group) { + const groupHeader = GridFunctions.getColumnGroupHeaderCell(group.header, fixture); + const expandInd = GridFunctions.getColGroupExpandIndicator(groupHeader); + expandInd.dispatchEvent(new Event('click', {})); + } + + public static simulateGridContentKeydown(fix: ComponentFixture, keyName: string, + altKey = false, shiftKey = false, ctrlKey = false) { + const gridContent = GridFunctions.getGridContent(fix); + UIInteractions.triggerEventHandlerKeyDown(keyName, gridContent, altKey, shiftKey, ctrlKey); + } + + public static getHeaderSortIcon(header: DebugElement): DebugElement { + return header.query(By.css(SORT_ICON_CLASS))?.query(By.css('igx-icon')); + } + + public static getHeaderFilterIcon(header: DebugElement): DebugElement { + return header.query(By.css(FILTER_ICON_CLASS)); + } + + public static clickHeaderSortIcon(header: DebugElement) { + const sortIcon = header.query(By.css(SORT_ICON_CLASS)); + sortIcon.triggerEventHandler('click', new Event('click')); + } + + public static verifyHeaderSortIndicator(header: DebugElement, sortedAsc = true, sortedDesc = false, sortable = true) { + const sortIcon = header.query(By.css(SORT_ICON_CLASS)); + if (sortable) { + const sortIconText = sortedDesc ? SORTING_ICON_DESC_CONTENT : SORTING_ICON_ASC_CONTENT; + expect(sortIcon.nativeElement.textContent.trim()).toEqual(sortIconText); + expect(header.nativeElement.classList.contains(SORTED_COLUMN_CLASS)).toEqual(sortedAsc || sortedDesc); + } else { + expect(sortIcon).toBeNull(); + } + } + + + public static getDragIndicators(fix: ComponentFixture): HTMLElement[] { + return fix.nativeElement.querySelectorAll(DRAG_INDICATOR_CLASS); + } + + public static isCellPinned(cell: CellType): boolean { + return cell.nativeElement.classList.contains(CELL_PINNED_CLASS); + } + + public static isHeaderPinned(header: DebugElement): boolean { + return header.nativeElement.classList.contains(HEADER_PINNED_CLASS); + } + + public static getColumnHidingElement(fix: ComponentFixture): DebugElement { + return fix.debugElement.query(By.directive(IgxColumnHidingDirective)); + } + + public static getColumnPinningElement(fix: ComponentFixture): DebugElement { + return fix.debugElement.query(By.directive(IgxColumnPinningDirective)); + } + + public static getColumnActionsColumnList(element: DebugElement): string[] { + const labels = element.queryAll(By.css(`.${COLUMN_ACTIONS_COLUMNS_LABEL_CLASS}`)); + return labels.map(label => label.nativeElement.textContent.trim()); + } + + public static getColumnChooserTitle(columnChooserElement: DebugElement): DebugElement { + return columnChooserElement.query(By.css('h4')); + } + + public static getColumnHidingHeaderInput(columnChooserElement: DebugElement): DebugElement { + return columnChooserElement.query(By.css(COLUMN_ACTIONS_INPUT_CLASS)); + } + + public static getColumnChooserFilterInput(columnChooserElement: DebugElement): DebugElement { + return this.getColumnHidingHeaderInput(columnChooserElement).query(By.directive(IgxInputDirective)); + } + + public static getColumnChooserItems(columnChooserElement: DebugElement): DebugElement[] { + return columnChooserElement.queryAll(By.css('igx-checkbox')); + } + + public static getColumnChooserItemElement(columnChooserElement: DebugElement, name: string): DebugElement { + const item = this.getColumnChooserItems(columnChooserElement).find((el) => el.nativeElement.outerText.includes(name)); + return item; + } + + public static clickColumnChooserItem(columnChooserElement: DebugElement, name: string) { + const item = this.getColumnChooserItemElement(columnChooserElement, name); + item.triggerEventHandler('click', new Event('click')); + } + + public static getColumnChooserItemInput(item: DebugElement): HTMLInputElement { + return item.query(By.css('input')).nativeElement as HTMLInputElement; + } + + public static getColumnChooserButton(columnChooserElement: DebugElement, name: string): DebugElement { + const buttonElements = columnChooserElement.queryAll(By.css('button')); + return buttonElements.find((el) => (el.nativeElement as HTMLButtonElement).textContent === name); + } + + public static getColumnHidingColumnsContainer(columnChooserElement: DebugElement): DebugElement { + return columnChooserElement.query(By.css(COLUMN_ACTIONS_COLUMNS_CLASS)); + } + + public static getColumnSortingIndex(columnHeader: DebugElement): number { + let sortIndex = columnHeader.query(By.css(SORT_ICON_CLASS)).nativeElement.getAttribute(SORT_INDEX_ATTRIBUTE); + sortIndex = parseInt(sortIndex?.trim(), 10); + if (!isNaN(sortIndex)) { + return sortIndex; + } + return null; + } + + public static verifyLayoutHeadersAreAligned(grid: GridType, row: RowType, verifyPinnedCells?: boolean) { + let firstRowCells = (row.cells as QueryList).toArray(); + const headerCells = grid.headerCellList; + + if (verifyPinnedCells) { + firstRowCells = firstRowCells + .filter(c => c.nativeElement.className.indexOf('igx-grid__td--pinned') !== -1); + } + + for (let i = 0; i < firstRowCells.length; i++) { + const dataCell = firstRowCells[i]; + const headerCell = headerCells.find(h => h.column.index === dataCell.column.index); + const widthDiff = headerCell?.nativeElement.clientWidth - dataCell?.nativeElement.clientWidth; + const heightDiff = headerCell?.nativeElement.clientHeight - dataCell?.nativeElement.clientHeight; + expect(widthDiff).toBeLessThanOrEqual(1); + expect(heightDiff).toBeLessThanOrEqual(3); + } + } + + public static verifyDOMMatchesLayoutSettings(grid: GridType, row: RowType, colSettings) { + const firstRowCells = (row.cells as QueryList).toArray(); + const rowElem = row.nativeElement; + const mrlBlocks = rowElem.querySelectorAll(`.${GRID_MRL_BLOCK}`); + + colSettings.forEach((groupSetting, index) => { + // check group has rendered block + const groupBlock = mrlBlocks[index] as any; + const cellsFromBlock = firstRowCells.filter((cell) => cell.nativeElement.parentNode === groupBlock); + expect(groupBlock).not.toBeNull(); + groupSetting.columns.forEach((col) => { + const cell = cellsFromBlock.find(x => x.column.field == col.field) as any; + const cellElem = cell.nativeElement; + // check correct attributes are applied + expect(parseInt(cellElem.style['gridRowStart'], 10)).toBe(parseInt(col.rowStart, 10)); + expect(parseInt(cellElem.style['gridColumnStart'], 10)).toBe(parseInt(col.colStart, 10)); + expect(cellElem.style['gridColumnEnd']).toBe(col.colEnd ? col.colEnd.toString() : ''); + expect(cellElem.style['gridRowEnd']).toBe(col.rowEnd ? col.rowEnd.toString() : ''); + + // check width + let sum = 0; + if (cell.gridColumnSpan > 1) { + for (let i = col.colStart; i < col.colStart + cell.column.gridColumnSpan; i++) { + const colData = groupSetting.columns.find((currCol) => currCol.colStart === i && currCol.field !== col.field); + const col2 = row.grid.getColumnByName(colData ? colData.field : ''); + sum += col2 ? parseFloat(col2.calcWidth) : 0; + } + } + const expectedWidth = Math.max(parseFloat(cell.column.calcWidth) * cell.column.gridColumnSpan, sum); + expect(cellElem.getBoundingClientRect().width - expectedWidth).toBeLessThan(1); + // check height + const expectedHeight = cell.grid.rowHeight * cell.gridRowSpan; + expect(cellElem.offsetHeight).toBe(expectedHeight); + + // check offset left + const acc = (accum, c) => { + if (c.column.colStart < col.colStart && c.column.rowStart === col.rowStart) { + return accum += parseFloat(c.column.calcWidth) * c.column.gridColumnSpan; + } else { + return accum; + } + }; + const expectedLeft = cellsFromBlock.reduce(acc, 0); + expect(cellElem.offsetLeft - groupBlock.offsetLeft - expectedLeft).toBeLessThan(1); + // check offsetTop + const expectedTop = (col.rowStart - 1) * cell.grid.rowHeight; + expect(cellElem.offsetTop).toBe(expectedTop); + }); + }); + } + + public static getHeaderResizeArea(header: DebugElement): DebugElement { + return header.parent.query(By.css(RESIZE_AREA_CLASS)); + } + + public static getResizer(fix): DebugElement { + return fix.debugElement.query(By.css(RESIZE_LINE_CLASS)); + } + + public static verifyCellValid(cell: IgxGridCellComponent, valid = true) { + expect(cell.isInvalid).toEqual(!valid); + expect(cell.nativeElement.classList.contains(CELL_INVALID_CSS_CLASS)).not.toEqual(valid); + } +} +export class GridSummaryFunctions { + public static getRootSummaryRow(fix): DebugElement { + const footer = GridFunctions.getGridFooter(fix); + return footer.query(By.css(SUMMARY_ROW)); + } + + public static calcMaxSummaryHeight(columnList, summaries: DebugElement[], defaultRowHeight) { + let maxSummaryLength = 0; + let index = 0; + columnList.filter((col) => col.hasSummary).forEach(() => { + const currentLength = summaries[index].queryAll(By.css(SUMMARY_LABEL_CLASS)).length; + if (maxSummaryLength < currentLength) { + maxSummaryLength = currentLength; + } + index++; + }); + const expectedLength = maxSummaryLength * defaultRowHeight; + return expectedLength; + } + + public static getRootPinnedSummaryCells(fix): DebugElement[] { + const rootSummaryRow = GridSummaryFunctions.getRootSummaryRow(fix); + return rootSummaryRow.queryAll(By.css(`${SUMMARY_CELL}.${PINNED_SUMMARY}`)); + } + + public static verifyColumnSummariesBySummaryRowIndex(fix, rowIndex: number, summaryIndex: number, summaryLabels, summaryResults) { + const summaryRow = rowIndex ? GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, rowIndex) + : GridSummaryFunctions.getRootSummaryRow(fix); + GridSummaryFunctions.verifyColumnSummaries(summaryRow, summaryIndex, summaryLabels, summaryResults); + } + + public static verifyRowWithIndexIsOfType(grid, index: number, type: any) { + expect(grid.getRowByIndex(index) instanceof type).toBe(true); + } + + public static verifyColumnSummaries(summaryRow: DebugElement, summaryIndex: number, summaryLabels, summaryResults) { + // const summary = summaryRow.query(By.css('igx-grid-summary-cell[data-visibleindex="' + summaryIndex + '"]')); + const summary = GridSummaryFunctions.getSummaryCellByVisibleIndex(summaryRow, summaryIndex); + expect(summary).toBeDefined(); + const summaryItems = summary.queryAll(By.css('.igx-grid-summary__item')); + if (summaryLabels.length === 0) { + expect(summary.nativeElement.classList.contains('igx-grid-summary--empty')).toBeTruthy(); + expect(summaryItems.length).toBe(0); + } else { + expect(summary.nativeElement.classList.contains('igx-grid-summary--empty')).toBeFalsy(); + expect(summaryItems.length).toEqual(summaryLabels.length); + if (summaryItems.length === summaryLabels.length) { + for (let i = 0; i < summaryLabels.length; i++) { + const summaryItem = summaryItems[i]; + const summaryLabel = summaryItem.query(By.css('.igx-grid-summary__label')); + expect(summaryLabels[i]).toEqual(summaryLabel.nativeElement.textContent.trim()); + if (summaryResults.length > 0) { + const summaryResult = summaryItem.query(By.css('.igx-grid-summary__result')); + expect(summaryResults[i].normalize("NFKD")) + .toEqual(summaryResult.nativeElement.textContent.trim().normalize("NFKD")); + } + } + } + } + } + + public static getSummaryRowByDataRowIndex(fix, rowIndex: number) { + return fix.debugElement.query(By.css('igx-grid-summary-row[data-rowindex="' + rowIndex + '"]')); + } + + public static getSummaryCellByVisibleIndex(summaryRow: DebugElement, summaryIndex: number) { + return summaryRow.query(By.css('igx-grid-summary-cell[data-visibleindex="' + summaryIndex + '"]')); + } + + public static getAllVisibleSummariesLength(fix) { + return GridSummaryFunctions.getAllVisibleSummaries(fix).length; + } + + public static getAllVisibleSummariesRowIndexes(fix) { + const summaries = GridSummaryFunctions.getAllVisibleSummaries(fix); + const rowIndexes = []; + summaries.forEach(summary => { + rowIndexes.push(Number(summary.nativeElement.attributes['data-rowindex'].value)); + }); + return rowIndexes.sort((a: number, b: number) => a - b); + } + + public static getAllVisibleSummaries(fix) { + return fix.debugElement.queryAll(By.css(SUMMARY_ROW)); + } + + public static getAllVisibleSummariesSorted(fix: ComponentFixture) { + const summaries = GridSummaryFunctions.getAllVisibleSummaries(fix); + return summaries.sort((a, b) => a.nativeElement.getBoundingClientRect().top - b.nativeElement.getBoundingClientRect().top); + } + + public static verifyVisibleSummariesHeight(fix, summariesRows, rowHeight = 36) { + const visibleSummaries = GridSummaryFunctions.getAllVisibleSummaries(fix); + visibleSummaries.forEach(summary => { + expect(summary.nativeElement.getBoundingClientRect().height).toBeGreaterThanOrEqual(summariesRows * rowHeight - 1); + expect(summary.nativeElement.getBoundingClientRect().height).toBeLessThanOrEqual(summariesRows * rowHeight + 1); + }); + } + + public static verifySummaryCellActive(fix, row, cellIndex, active = true) { + const summaryRow = typeof row === 'number' ? + GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, row) : row; + const summ = GridSummaryFunctions.getSummaryCellByVisibleIndex(summaryRow, cellIndex); + const hasClass = summ.nativeElement.classList.contains(SUMMARY_CELL_ACTIVE_CSS_CLASS); + expect(hasClass === active).toBeTruthy(); + } + + public static focusSummaryCell(fix, row, cellIndex) { + const summaryRow = typeof row === 'number' ? + GridSummaryFunctions.getSummaryRowByDataRowIndex(fix, row) : row; + const summaryCell = GridSummaryFunctions.getSummaryCellByVisibleIndex(summaryRow, cellIndex); + UIInteractions.simulateClickAndSelectEvent(summaryCell); + fix.detectChanges(); + } +} +export class GridSelectionFunctions { + public static selectCellsRange = + async (fix, startCell, endCell, ctrl = false, shift = false) => { + UIInteractions.simulatePointerOverElementEvent('pointerdown', startCell.nativeElement, shift, ctrl); + fix.detectChanges(); + await wait(); + fix.detectChanges(); + + UIInteractions.simulatePointerOverElementEvent('pointerenter', endCell.nativeElement, shift, ctrl); + UIInteractions.simulatePointerOverElementEvent('pointerup', endCell.nativeElement, shift, ctrl); + await wait(); + fix.detectChanges(); + }; + + public static selectCellsRangeNoWait(fix, startCell, endCell, ctrl = false, shift = false) { + UIInteractions.simulatePointerOverElementEvent('pointerdown', startCell.nativeElement, shift, ctrl); + fix.detectChanges(); + + UIInteractions.simulatePointerOverElementEvent('pointerenter', endCell.nativeElement, shift, ctrl); + UIInteractions.simulatePointerOverElementEvent('pointerup', endCell.nativeElement, shift, ctrl); + fix.detectChanges(); + } + + public static selectCellsRangeWithShiftKey = + async (fix, startCell, endCell) => { + UIInteractions.simulateClickAndSelectEvent(startCell); + await wait(); + fix.detectChanges(); + + UIInteractions.simulateClickAndSelectEvent(endCell, true); + await wait(); + fix.detectChanges(); + }; + + public static selectCellsRangeWithShiftKeyNoWait(fix, startCell, endCell) { + UIInteractions.simulateClickAndSelectEvent(startCell); + fix.detectChanges(); + + UIInteractions.simulateClickAndSelectEvent(endCell, true); + fix.detectChanges(); + } + + public static verifyCellsRegionSelected(grid, startRowIndex, endRowIndex, startColumnIndex, endColumnIndex, selected = true) { + const startRow = startRowIndex < endRowIndex ? startRowIndex : endRowIndex; + const endRow = startRowIndex < endRowIndex ? endRowIndex : startRowIndex; + const startCol = startColumnIndex < endColumnIndex ? startColumnIndex : endColumnIndex; + const endCol = startColumnIndex < endColumnIndex ? endColumnIndex : startColumnIndex; + for (let i = startCol; i <= endCol; i++) { + for (let j = startRow; j <= endRow; j++) { + const cell = grid.gridAPI.get_cell_by_index(j, grid.columns.find(col => col.visibleIndex === i).field); + if (cell) { + GridSelectionFunctions.verifyCellSelected(cell, selected); + } + } + } + } + + public static verifySelectedRange(grid, rowStart, rowEnd, columnStart, columnEnd, rangeIndex = 0, selectedRanges = 1) { + const range = grid.getSelectedRanges(); + expect(range).toBeDefined(); + expect(range.length).toBe(selectedRanges); + expect(range[rangeIndex].columnStart).toBe(columnStart); + expect(range[rangeIndex].columnEnd).toBe(columnEnd); + expect(range[rangeIndex].rowStart).toBe(rowStart); + expect(range[rangeIndex].rowEnd).toBe(rowEnd); + } + + public static verifyCellSelected(cell: IgxGridCellComponent | CellType, selected = true) { + expect(cell.selected).toBe(selected); + expect(cell.nativeElement.classList.contains(CELL_SELECTED_CSS_CLASS)).toBe(selected); + } + + public static verifyCellActive(cell, active = true) { + expect(cell.active).toBe(active); + expect(cell.nativeElement.classList.contains(CELL_ACTIVE_CSS_CLASS)).toBe(active); + } + + // Check the grid selected cell and cell in in the selected function + public static verifyGridCellSelected(fix, cell: CellType) { + const selectedCellFromGrid = cell.grid.selectedCells[0]; + const selectedCell = fix.componentInstance.selectedCell; + expect(cell.selected).toBe(true); + expect(selectedCell.value).toEqual(cell.value); + expect(selectedCell.column.field).toMatch(cell.column.field); + expect(selectedCell.row.index).toEqual(cell.row.index); + expect(selectedCellFromGrid.value).toEqual(cell.value); + expect(selectedCellFromGrid.column.field).toMatch(cell.column.field); + expect(selectedCellFromGrid.row.index).toEqual(cell.row.index); + // Check if the cell id is assigned as the active descendant of the grid content + const cellElement = cell.grid.gridAPI.get_cell_by_index(cell.row.index, cell.column.field).nativeElement; + const gridContent = GridFunctions.getGridContent(fix); + GridFunctions.verifyGridContentActiveDescendant(gridContent, cellElement.id); + } + + public static verifyRowSelected(row, selected = true, hasCheckbox = true) { + expect(row.selected).toBe(selected); + expect(row.nativeElement.classList.contains(ROW_SELECTION_CSS_CLASS)).toBe(selected); + if (hasCheckbox) { + GridSelectionFunctions.verifyRowHasCheckbox(row.nativeElement); + expect(GridSelectionFunctions.getRowCheckboxInput(row.nativeElement).checked).toBe(selected); + } + } + + public static verifyRowsArraySelected(rows, selected = true, hasCheckbox = true) { + rows.forEach(row => { + GridSelectionFunctions.verifyRowSelected(row, selected, hasCheckbox); + }); + } + + public static getHeaderRow(fix): HTMLElement { + return fix.nativeElement.querySelector(HEADER_ROW_CSS_CLASS); + } + + public static getHeaderRows(fix): HTMLElement[] { + return fix.nativeElement.querySelectorAll(HEADER_ROW_CSS_CLASS); + } + + public static verifyGroupByRowCheckboxState(groupByRow, checked = false, indeterminate = false, disabled = false) { + const groupByRowCheckboxElement = GridSelectionFunctions.getRowCheckboxInput(groupByRow.element.nativeElement); + expect(groupByRowCheckboxElement.checked).toBe(checked); + expect(groupByRowCheckboxElement.indeterminate).toBe(indeterminate); + expect(groupByRowCheckboxElement.disabled).toBe(disabled); + } + + public static verifyHeaderRowCheckboxState(parent, checked = false, indeterminate = false) { + const header = GridSelectionFunctions.getHeaderRow(parent); + const headerCheckboxElement = GridSelectionFunctions.getRowCheckboxInput(header); + expect(headerCheckboxElement.checked).toBe(checked); + expect(headerCheckboxElement.indeterminate).toBe(indeterminate); + } + + public static verifySelectionCheckBoxesAlignment(grid) { + const headerDiv = GridSelectionFunctions.getRowCheckboxDiv(GridSelectionFunctions.getHeaderRow(grid)); + const firstRowDiv = GridSelectionFunctions.getRowCheckboxDiv(grid.dataRowList.first.nativeElement); + if (grid.groupingExpressions && grid.groupingExpressions.length > 0) { + const groupByRowDiv = GridSelectionFunctions.getRowCheckboxDiv(grid.groupsRowList.first.nativeElement); + expect(groupByRowDiv.offsetWidth).toEqual(firstRowDiv.offsetWidth); + expect(groupByRowDiv.offsetLeft).toEqual(firstRowDiv.offsetLeft); + } + const hScrollbar = grid.headerContainer.getScroll(); + + expect(headerDiv.clientWidth).toEqual(firstRowDiv.clientWidth); + expect(headerDiv.clientWidth).toEqual(firstRowDiv.clientWidth); + if (hScrollbar.scrollWidth) { + expect(hScrollbar.offsetLeft).toEqual(firstRowDiv.offsetWidth + firstRowDiv.offsetLeft); + } + } + + public static verifyRowHasCheckbox(rowDOM, hasCheckbox = true, hasCheckboxDiv = true, verifyHeader = false) { + const checkboxDiv = GridSelectionFunctions.getRowCheckboxDiv(rowDOM); + if (!hasCheckbox && !hasCheckboxDiv) { + expect(GridSelectionFunctions.getRowCheckboxDiv(rowDOM)).toBeNull(); + } else { + expect(checkboxDiv).toBeDefined(); + const rowCheckbox = GridSelectionFunctions.getRowCheckbox(rowDOM); + expect(rowCheckbox).toBeDefined(); + if (!hasCheckbox) { + expect(rowCheckbox.style.visibility).toEqual('hidden'); + } else if (verifyHeader) { + expect(rowCheckbox.style.visibility).toEqual('visible'); + } else { + expect(rowCheckbox.style.visibility).toEqual(''); + } + } + } + + public static verifyRowCheckboxIsNotFocused(rowDOM: HTMLElement, focused = false) { + const rowCheckbox: HTMLElement = GridSelectionFunctions.getRowCheckbox(rowDOM); + expect(rowCheckbox.classList.contains(FOCUSED_CHECKBOX_CLASS)).toEqual(focused); + } + + public static verifyHeaderRowHasCheckbox(parent, hasCheckbox = true, hasCheckboxDiv = true) { + GridSelectionFunctions.verifyRowHasCheckbox(GridSelectionFunctions.getHeaderRow(parent), hasCheckbox, hasCheckboxDiv, true); + } + + public static getRowCheckboxDiv(rowDOM): HTMLElement { + return rowDOM.querySelector(`.${ROW_DIV_SELECTION_CHECKBOX_CSS_CLASS}`); + } + + /** + * Returns if the specified element looks like a selection checkbox based on specific class affix + * + * @param element The element to check + * @param modifier The modifier to the base class + */ + public static isCheckbox(element: HTMLElement, modifier?: string): boolean { + return element.classList.contains(`${ROW_DIV_SELECTION_CHECKBOX_CSS_CLASS}${modifier || ''}`); + } + + public static getRowCheckboxInput(rowDOM): HTMLInputElement { + return GridSelectionFunctions.getRowCheckboxDiv(rowDOM).querySelector(CHECKBOX_INPUT_CSS_CLASS); + } + + public static getRowCheckbox(rowDOM): HTMLElement { + return GridSelectionFunctions.getRowCheckboxDiv(rowDOM).querySelector(CHECKBOX_ELEMENT); + } + + public static getCheckboxes(fix: ComponentFixture): HTMLElement[] { + return fix.nativeElement.querySelectorAll(CHECKBOX_ELEMENT); + } + + public static clickRowCheckbox(row) { + const checkboxElement = GridSelectionFunctions.getRowCheckboxDiv(row.nativeElement); + checkboxElement.dispatchEvent(new Event('click', {})); + } + + public static clickHeaderRowCheckbox(parent) { + const checkboxElement = GridSelectionFunctions.getRowCheckboxDiv(GridSelectionFunctions.getHeaderRow(parent)); + checkboxElement.dispatchEvent(new Event('click', {})); + } + + // select - deselect a checkbox without a handler + public static rowCheckboxClick(row) { + const checkboxElement = row.nativeElement ? + row.nativeElement.querySelector(CHECKBOX_LBL_CSS_CLASS) : + row.querySelector(CHECKBOX_LBL_CSS_CLASS); + checkboxElement.click(); + } + + public static headerCheckboxClick(parent) { + GridSelectionFunctions.rowCheckboxClick(GridSelectionFunctions.getHeaderRow(parent)); + } + // + + public static expandRowIsland(rowNumber = 1) { + (document.getElementsByClassName(ICON_CSS_CLASS)[rowNumber] as any).click(); + } + + public static verifyColumnSelected(column: IgxColumnComponent, selected = true) { + expect(column.selected).toEqual(selected); + if (!column.hidden) { + expect(column.headerCell.nativeElement.classList.contains(SELECTED_COLUMN_CLASS)).toEqual(selected); + } + } + + public static verifyColumnsSelected(columns: IgxColumnComponent[], selected = true) { + columns.forEach(c => this.verifyColumnSelected(c, selected)); + } + + public static verifyColumnGroupSelected(fixture: ComponentFixture, column: IgxColumnGroupComponent, selected = true) { + expect(column.selected).toEqual(selected); + const header = GridFunctions.getColumnGroupHeaderCell(column.header, fixture); + expect(header.nativeElement.classList.contains(SELECTED_COLUMN_CLASS)).toEqual(selected); + } + + public static verifyColumnHeaderHasSelectableClass(header: DebugElement, hovered = true) { + expect(header.nativeElement.classList.contains(HOVERED_COLUMN_CLASS)).toEqual(hovered); + } + + public static verifyColumnsHeadersHasSelectableClass(headers: DebugElement[], hovered = true) { + headers.forEach(header => this.verifyColumnHeaderHasSelectableClass(header, hovered)); + } + + public static verifyColumnAndCellsSelected(column: IgxColumnComponent, selected = true) { + this.verifyColumnSelected(column, selected); + column._cells.forEach(cell => { + expect(cell.nativeElement.classList.contains(SELECTED_COLUMN_CELL_CLASS)).toEqual(selected); + }); + } + + public static clickOnColumnToSelect(column: IgxColumnComponent, ctrlKey = false, shiftKey = false) { + const event = { + shiftKey, + ctrlKey, + stopPropagation: () => { } + }; + + column.headerCell.onClick(event as any); + } +} diff --git a/projects/igniteui-angular/test-utils/grid-interfaces.spec.ts b/projects/igniteui-angular/test-utils/grid-interfaces.spec.ts new file mode 100644 index 00000000000..8043d5266f5 --- /dev/null +++ b/projects/igniteui-angular/test-utils/grid-interfaces.spec.ts @@ -0,0 +1,52 @@ + +/* Add to template: (selected)="cellSelected($event)" */ +export interface IGridSelection { + cellSelected(event); +} + +/* Add to template: (onCellClick)="cellClick($event)" */ +export interface IGridCellClick { + cellClick(evt): void; +} + +/* Add to template: (doubleClick)="doubleClick($event)" */ +export interface IGridCellDoubleClick { + doubleClick(evt): void; +} + +/* Add to template: (contextMenu)="cellRightClick($event)" */ +export interface IGridContextMenu { + cellRightClick(evt): void; +} + +/* Add to template: ` (columnInit)="columnInit($event)"` */ +export interface IGridColumnInit { + columnInit(column): void; +} + +/* Add to template: `(rowAdded)="rowAdded($event)" + (rowDeleted)="rowDeleted($event)"` */ +export interface IGridRowEvents { + rowAdded(event): void; + rowDeleted(event): void; +} + +/* Add to template: `(columnPin)="columnPinning($event)"` */ +export interface IGridColumnPinning { + columnPinning(event): void; +} + +/* Add to template: ` (onEditDone)="editDone($event)"` */ +export interface IEditDone { + editDone(event): void; +} + +/* Add to template: ` (rowSelectionChanging)="rowSelectionChange($event)"` */ +export interface IGridRowSelectionChange { + rowSelectionChanging(event): void; +} + +/* Add to template: ` (columnResized)="columnResized($event)"` */ +export interface IColumnResized { + columnResized(event): void; +} diff --git a/projects/igniteui-angular/test-utils/grid-mch-sample.spec.ts b/projects/igniteui-angular/test-utils/grid-mch-sample.spec.ts new file mode 100644 index 00000000000..b133118e0b7 --- /dev/null +++ b/projects/igniteui-angular/test-utils/grid-mch-sample.spec.ts @@ -0,0 +1,692 @@ +import { Component, TemplateRef, ViewChild, OnInit } from '@angular/core'; +import { SampleTestData } from './sample-test-data.spec'; +import { IgxGridComponent } from 'igniteui-angular/grids/grid'; +import { IgxCellHeaderTemplateDirective, IgxColumnComponent, IgxColumnGroupComponent } from 'igniteui-angular/grids/core'; + +@Component({ + template: ` +
    + + + + + +
    + `, + imports: [IgxGridComponent, IgxColumnGroupComponent, IgxColumnComponent] +}) +export class OneGroupOneColGridComponent { + @ViewChild('grid', { static: true }) + public grid: IgxGridComponent; + public gridWrapperWidthPx = '1000'; + public gridHeight = '500px'; + public columnWidth: string; + public data = SampleTestData.contactInfoDataFull(); +} + +@Component({ + template: ` +
    + + + + + + + +
    + `, + imports: [IgxGridComponent, IgxColumnGroupComponent, IgxColumnComponent] +}) +export class OneGroupThreeColsGridComponent { + @ViewChild('grid', { static: true }) public grid: IgxGridComponent; + public gridWrapperWidthPx = '900'; + public gridHeight = '500px'; + public columnWidth: string; + public data = SampleTestData.contactInfoDataFull(); +} + +@Component({ + template: ` + + + + + + + + + + + + + + + + + + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnGroupComponent, IgxColumnComponent] +}) +export class ColumnGroupTestComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + + @ViewChild('emptyColGroup', { read: IgxColumnGroupComponent, static: true }) + public emptyColGroup: IgxColumnGroupComponent; + + public data = SampleTestData.contactInfoDataFull(); + + public hideGroupedColumns = false; +} + +@Component({ + template: ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnGroupComponent, IgxColumnComponent] +}) +export class ColumnGroupFourLevelTestComponent implements OnInit { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + @ViewChild('idCol', { read: IgxColumnComponent, static: true }) + public idCol: IgxColumnComponent; + @ViewChild('genInfoColGroup', { read: IgxColumnGroupComponent, static: true }) + public genInfoColGroup: IgxColumnGroupComponent; + @ViewChild('companyNameCol', { read: IgxColumnComponent, static: true }) + public companyNameCol: IgxColumnComponent; + @ViewChild('pDetailsColGroup', { read: IgxColumnGroupComponent, static: true }) + public pDetailsColGroup: IgxColumnGroupComponent; + @ViewChild('contactNameCol', { read: IgxColumnComponent, static: true }) + public contactNameCol: IgxColumnComponent; + @ViewChild('contactTitleCol', { read: IgxColumnComponent, static: true }) + public contactTitleCol: IgxColumnComponent; + @ViewChild('addrInfoColGroup', { read: IgxColumnGroupComponent, static: true }) + public addrInfoColGroup: IgxColumnGroupComponent; + @ViewChild('locationColGroup', { read: IgxColumnGroupComponent, static: true }) + public locationColGroup: IgxColumnGroupComponent; + @ViewChild('countryCol', { read: IgxColumnComponent, static: true }) + public countryCol: IgxColumnComponent; + @ViewChild('regionCol', { read: IgxColumnComponent, static: true }) + public regionCol: IgxColumnComponent; + @ViewChild('locCityColGroup', { read: IgxColumnGroupComponent, static: true }) + public locCityColGroup: IgxColumnGroupComponent; + @ViewChild('cityCol', { read: IgxColumnComponent, static: true }) + public cityCol: IgxColumnComponent; + @ViewChild('addressCol', { read: IgxColumnComponent, static: true }) + public addressCol: IgxColumnComponent; + @ViewChild('contactInfoColGroup', { read: IgxColumnGroupComponent, static: true }) + public contactInfoColGroup: IgxColumnGroupComponent; + @ViewChild('phoneCol', { read: IgxColumnComponent, static: true }) + public phoneCol: IgxColumnComponent; + @ViewChild('faxCol', { read: IgxColumnComponent, static: true }) + public faxCol: IgxColumnComponent; + @ViewChild('postalCodeColGroup', { read: IgxColumnGroupComponent, static: true }) + public postalCodeColGroup: IgxColumnGroupComponent; + @ViewChild('postalCodeCol', { read: IgxColumnComponent, static: true }) + public postalCodeCol: IgxColumnComponent; + + public genInfoColsAndGroups = []; + public addressColsAndGroups = []; + public colsAndGroupsNaturalOrder = []; + + public data = SampleTestData.contactInfoDataFull(); + + public ngOnInit() { + this.genInfoColsAndGroups = [this.genInfoColGroup, this.companyNameCol, this.pDetailsColGroup, + this.contactNameCol, this.contactTitleCol]; + + this.addressColsAndGroups = [this.addrInfoColGroup, this.locationColGroup, this.countryCol, + this.regionCol, this.locCityColGroup, this.cityCol, this.addressCol, this.contactInfoColGroup, + this.phoneCol, this.faxCol, this.postalCodeColGroup, this.postalCodeCol]; + + this.colsAndGroupsNaturalOrder = [this.idCol].concat(this.genInfoColsAndGroups) + .concat(this.addressColsAndGroups); + } +} + +@Component({ + template: ` + + + + + + + + + + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnGroupComponent, IgxColumnComponent] +}) +export class ThreeGroupsThreeColumnsGridComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + + @ViewChild('genInfoColGroup', { read: IgxColumnGroupComponent, static: true }) + public genInfoColGroup: IgxColumnGroupComponent; + @ViewChild('companyNameCol', { read: IgxColumnComponent, static: true }) + public companyNameCol: IgxColumnComponent; + @ViewChild('contactNameCol', { read: IgxColumnComponent, static: true }) + public contactNameCol: IgxColumnComponent; + @ViewChild('contactTitleCol', { read: IgxColumnComponent, static: true }) + public contactTitleCol: IgxColumnComponent; + + @ViewChild('locationColGroup', { read: IgxColumnGroupComponent, static: true }) + public locationColGroup: IgxColumnGroupComponent; + @ViewChild('countryCol', { read: IgxColumnComponent, static: true }) + public countryCol: IgxColumnComponent; + @ViewChild('regionCol', { read: IgxColumnComponent, static: true }) + public regionCol: IgxColumnComponent; + @ViewChild('cityCol', { read: IgxColumnComponent, static: true }) + public cityCol: IgxColumnComponent; + + @ViewChild('contactInfoColGroup', { read: IgxColumnGroupComponent, static: true }) + public contactInfoColGroup: IgxColumnGroupComponent; + @ViewChild('phoneCol', { read: IgxColumnComponent, static: true }) + public phoneCol: IgxColumnComponent; + @ViewChild('faxCol', { read: IgxColumnComponent, static: true }) + public faxCol: IgxColumnComponent; + @ViewChild('postalCodeCol', { read: IgxColumnComponent, static: true }) + public postalCodeCol: IgxColumnComponent; + + public data = SampleTestData.contactInfoDataFull(); + public cnPinned = false; +} + +@Component({ + template: ` + + + + + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnGroupComponent, IgxColumnComponent] +}) +export class NestedColGroupsGridComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + + @ViewChild('contactInfoColGroup', { read: IgxColumnGroupComponent, static: true }) + public contactInfoColGroup: IgxColumnGroupComponent; + @ViewChild('locationColGroup', { read: IgxColumnGroupComponent, static: true }) + public locationColGroup: IgxColumnGroupComponent; + @ViewChild('countryCol', { read: IgxColumnComponent, static: true }) + public countryCol: IgxColumnComponent; + @ViewChild('phoneCol', { read: IgxColumnComponent, static: true }) + public phoneCol: IgxColumnComponent; + + @ViewChild('genInfoColGroup', { read: IgxColumnGroupComponent, static: true }) + public genInfoColGroup: IgxColumnGroupComponent; + @ViewChild('companyNameCol', { read: IgxColumnComponent, static: true }) + public companyNameCol: IgxColumnComponent; + + @ViewChild('cityCol', { read: IgxColumnComponent, static: true }) + public cityCol: IgxColumnComponent; + + public data = SampleTestData.contactInfoDataFull(); +} + +@Component({ + template: ` + + @for (colGroup of columnGroups; track colGroup.columnHeader) { + + @for (column of colGroup.columns; track column.field) { + + } + + } + + `, + imports: [IgxGridComponent, IgxColumnGroupComponent, IgxColumnComponent] +}) +export class DynamicColGroupsGridComponent { + + @ViewChild(IgxGridComponent, { static: true }) + public grid: IgxGridComponent; + + public columnGroups: Array; + public data = SampleTestData.contactInfoDataFull(); + + constructor() { + this.columnGroups = [ + { + columnHeader: 'First', columns: [ + { field: 'ID', type: 'string' }, + { field: 'CompanyName', type: 'string' }, + { field: 'ContactName', type: 'string' }, + ] + }, + { + columnHeader: 'Second', columns: [ + { field: 'ContactTitle', type: 'string' }, + { field: 'Address', type: 'string' }, + ] + }, + { + columnHeader: 'Third', columns: [ + { field: 'PostalCode', type: 'string' }, + { field: 'Country', type: 'string' }, + ] + }, + ]; + } +} + +@Component({ + template: ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnGroupComponent, IgxColumnComponent] +}) +export class StegosaurusGridComponent implements OnInit { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + + @ViewChild('idCol', { read: IgxColumnComponent, static: true }) + public idCol: IgxColumnComponent; + + @ViewChild('genInfoColGroup', { read: IgxColumnGroupComponent, static: true }) + public genInfoColGroup: IgxColumnGroupComponent; + @ViewChild('companyNameCol', { read: IgxColumnComponent, static: true }) + public companyNameCol: IgxColumnComponent; + @ViewChild('pDetailsColGroup', { read: IgxColumnGroupComponent, static: true }) + public pDetailsColGroup: IgxColumnGroupComponent; + @ViewChild('contactNameCol', { read: IgxColumnComponent, static: true }) + public contactNameCol: IgxColumnComponent; + @ViewChild('contactTitleCol', { read: IgxColumnComponent, static: true }) + public contactTitleCol: IgxColumnComponent; + + @ViewChild('postalCodeColGroup', { read: IgxColumnGroupComponent, static: true }) + public postalCodeColGroup: IgxColumnGroupComponent; + @ViewChild('postalCodeCol', { read: IgxColumnComponent, static: true }) + public postalCodeCol: IgxColumnComponent; + + @ViewChild('cityColGroup', { read: IgxColumnGroupComponent, static: true }) + public cityColGroup: IgxColumnGroupComponent; + @ViewChild('cityCol', { read: IgxColumnComponent, static: true }) + public cityCol: IgxColumnComponent; + + @ViewChild('countryColGroup', { read: IgxColumnGroupComponent, static: true }) + public countryColGroup: IgxColumnGroupComponent; + @ViewChild('countryCol', { read: IgxColumnComponent, static: true }) + public countryCol: IgxColumnComponent; + + @ViewChild('regionColGroup', { read: IgxColumnGroupComponent, static: true }) + public regionColGroup: IgxColumnGroupComponent; + @ViewChild('regionCol', { read: IgxColumnComponent, static: true }) + public regionCol: IgxColumnComponent; + + @ViewChild('addressColGroup', { read: IgxColumnGroupComponent, static: true }) + public addressColGroup: IgxColumnGroupComponent; + @ViewChild('addressCol', { read: IgxColumnComponent, static: true }) + public addressCol: IgxColumnComponent; + + @ViewChild('phoneColGroup', { read: IgxColumnGroupComponent, static: true }) + public phoneColGroup: IgxColumnGroupComponent; + @ViewChild('phoneCol', { read: IgxColumnComponent, static: true }) + public phoneCol: IgxColumnComponent; + + @ViewChild('faxColGroup', { read: IgxColumnGroupComponent, static: true }) + public faxColGroup: IgxColumnGroupComponent; + @ViewChild('faxCol', { read: IgxColumnComponent, static: true }) + public faxCol: IgxColumnComponent; + + public genInfoColList; + public postalCodeColList; + public cityColList; + public countryColList; + public regionColList; + public addressColList; + public phoneColList; + public faxColList; + + public data = SampleTestData.contactInfoDataFull(); + + public ngOnInit() { + this.genInfoColList = [this.genInfoColGroup, this.companyNameCol, this.pDetailsColGroup, + this.contactNameCol, this.contactTitleCol]; + this.postalCodeColList = [this.postalCodeColGroup, this.postalCodeCol]; + this.cityColList = [this.cityColGroup, this.cityCol]; + this.countryColList = [this.countryColGroup, this.countryCol]; + this.regionColList = [this.regionColGroup, this.regionCol]; + this.addressColList = [this.addressColGroup, this.addressCol]; + this.phoneColList = [this.phoneColGroup, this.phoneCol]; + this.faxColList = [this.faxColGroup, this.faxCol]; + } +} + +@Component({ + template: ` + + + @for (item of hundredItems; track $index) { + + } + @if (extraMissingColumn) { + + } + + + + @for (item of fiftyItems; track $index) { + + } + + + @for (item of fiftyItems; track $index) { + + } + + + + + + + + + + + + + + + + + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnGroupComponent, IgxColumnComponent] +}) +export class BlueWhaleGridComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + + public gridHeight = '500px'; + public columnWidth = '100px'; + public data = SampleTestData.contactInfoDataFull(); + public extraMissingColumn = false; + public firstGroupRepeats = 100; + public secondGroupRepeats = 50; + + public get hundredItems() { + return new Array(this.firstGroupRepeats); + } + public get fiftyItems() { + return new Array(this.secondGroupRepeats); + } + + public firstGroupTitle = '100 IDs'; + public secondGroupTitle = '2 col groups with 50 IDs each'; + public secondSubGroupTitle = '50 IDs'; + public idHeaderTitle = 'ID'; + public companyNameTitle = 'Company Name'; + public personDetailsTitle = 'Person Details'; +} + +@Component({ + template: ` + + + + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnGroupComponent, IgxColumnComponent] +}) +export class OneColPerGroupGridComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + + public columnWidth = '100px'; + public phoneColWidth = '200px'; + public faxColWidth = '300px'; + + public addressColGroupTitle = 'Address Group'; + public addressColTitle = 'Address'; + + public phoneColGroupTitle = 'Phone Group'; + public phoneColTitle = 'Phone'; + + public faxColGroupTitle = 'Fax Group'; + public faxColTitle = 'Fax'; + + public data = SampleTestData.contactInfoDataFull(); +} + +@Component({ + template: ` + + + + + + + + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnGroupComponent, IgxColumnComponent] +}) +export class NestedColumnGroupsGridComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + + public columnWidth = '100px'; + public phoneColWidth = '200px'; + public faxColWidth = '300px'; + public cityColWidth = '400px'; + + public masterColGroupTitle = 'Master'; + public firstSlaveColGroupTitle = 'Slave 1'; + public secondSlaveColGroupTitle = 'Slave 2'; + + public addressColTitle = 'Address'; + public phoneColTitle = 'Phone'; + public faxColTitle = 'Fax'; + public cityColTitle = 'City'; + + public data = SampleTestData.contactInfoDataFull(); +} + +@Component({ + template: ` + + @for (item of mchCount; track item) { + + + + } + + `, + imports: [IgxGridComponent, IgxColumnGroupComponent, IgxColumnComponent] +}) +export class DynamicGridComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + public mchCount = new Array(1); + + public data = SampleTestData.contactInfoDataFull(); +} + +@Component({ + template: ` + + Dynamic column group template + + + + + + Column group template + + + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnGroupComponent, IgxColumnComponent, IgxCellHeaderTemplateDirective] +}) +export class NestedColGroupsWithTemplatesGridComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + + @ViewChild('contactInfoColGroup', { read: IgxColumnGroupComponent, static: true }) + public contactInfoColGroup: IgxColumnGroupComponent; + @ViewChild('locationColGroup', { read: IgxColumnGroupComponent, static: true }) + public locationColGroup: IgxColumnGroupComponent; + @ViewChild('countryCol', { read: IgxColumnComponent, static: true }) + public countryCol: IgxColumnComponent; + @ViewChild('phoneCol', { read: IgxColumnComponent, static: true }) + public phoneCol: IgxColumnComponent; + + @ViewChild('genInfoColGroup', { read: IgxColumnGroupComponent, static: true }) + public genInfoColGroup: IgxColumnGroupComponent; + @ViewChild('companyNameCol', { read: IgxColumnComponent, static: true }) + public companyNameCol: IgxColumnComponent; + + @ViewChild('cityCol', { read: IgxColumnComponent, static: true }) + public cityCol: IgxColumnComponent; + + @ViewChild('dynamicColGroupTemplate', { read: TemplateRef, static: true }) + public dynamicColGroupTemplate: TemplateRef; + + public data = SampleTestData.contactInfoDataFull(); +} + +@Component({ + template: ` + + + + + + + + + + + + + + + + + + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnGroupComponent, IgxColumnComponent] +}) +export class ColumnGroupHiddenInTemplateComponent extends ColumnGroupTestComponent { } diff --git a/projects/igniteui-angular/test-utils/grid-samples.spec.ts b/projects/igniteui-angular/test-utils/grid-samples.spec.ts new file mode 100644 index 00000000000..05685a5cd69 --- /dev/null +++ b/projects/igniteui-angular/test-utils/grid-samples.spec.ts @@ -0,0 +1,2796 @@ +import { Component, TemplateRef, ViewChild, Input, AfterViewInit, QueryList, ViewChildren, OnInit } from '@angular/core'; + +import { + BasicGridComponent, BasicGridSearchComponent, GridAutoGenerateComponent, + GridWithSizeComponent, PagingComponent +} from './grid-base-components.spec'; +import { IGridSelection } from './grid-interfaces.spec'; +import { SampleTestData, DataParent } from './sample-test-data.spec'; +import { ColumnDefinitions, GridTemplateStrings, EventSubscriptions, TemplateDefinitions, ExternalTemplateDefinitions } from './template-strings.spec'; + +import { ColumnPinningPosition, ColumnType, FilteringExpressionsTree, FilteringLogic, FilteringStrategy, FormattedValuesSortingStrategy, IDataCloneStrategy, IFilteringExpressionsTree, IgxFilteringOperand, IgxFilterItem, IgxNumberFilteringOperand, IgxSummaryResult, ISortingOptions, ISortingStrategy, OverlaySettings, SortingDirection } from 'igniteui-angular/core'; +import { IgxActionStripComponent } from 'igniteui-angular/action-strip'; +import { IgxPaginatorComponent } from 'igniteui-angular/paginator'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxInputDirective, IgxInputGroupComponent, IgxPrefixDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; +import { IgxCheckboxComponent } from 'igniteui-angular/checkbox'; +import { IgxButtonDirective, IgxFocusDirective } from 'igniteui-angular/directives'; +import { IgxGridComponent } from 'igniteui-angular/grids/grid'; +import { CellType, IGridCellEventArgs, IgxAdvancedFilteringDialogComponent, IgxCellEditorTemplateDirective, IgxCellHeaderTemplateDirective, IgxCellTemplateDirective, IgxCollapsibleIndicatorTemplateDirective, IgxColumnComponent, IgxColumnGroupComponent, IgxColumnLayoutComponent, IgxDateSummaryOperand, IgxExcelStyleColumnOperationsTemplateDirective, IgxExcelStyleConditionalFilterComponent, IgxExcelStyleFilterOperationsTemplateDirective, IgxExcelStyleHeaderIconDirective, IgxExcelStyleMovingComponent, IgxExcelStylePinningComponent, IgxExcelStyleSearchComponent, IgxExcelStyleSelectingComponent, IgxFilterCellTemplateDirective, IgxGridEditingActionsComponent, IgxGridExcelStyleFilteringComponent, IgxGridToolbarActionsComponent, IgxGridToolbarAdvancedFilteringComponent, IgxGridToolbarComponent, IgxGridToolbarHidingComponent, IgxGroupByRowSelectorDirective, IgxHeadSelectorDirective, IgxNumberSummaryOperand, IgxRowAddTextDirective, IgxRowEditActionsDirective, IgxRowEditTabStopDirective, IgxRowEditTemplateDirective, IgxRowEditTextDirective, IgxRowSelectorDirective, IgxSortAscendingHeaderIconDirective, IgxSortDescendingHeaderIconDirective, IgxSortHeaderIconDirective } from 'igniteui-angular/grids/core'; + +@Component({ + template: GridTemplateStrings.declareGrid('', '', ``), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class ColumnHiddenFromMarkupComponent extends BasicGridComponent { + public override data = SampleTestData.personIDNameData(); +} + +@Component({ + template: ` + + @for (c of columns; track c.field) { + + + } + + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class GridAddColumnComponent extends BasicGridComponent implements OnInit { + public columns: Array; + public override data = SampleTestData.contactInfoDataFull(); + public ngOnInit(): void { + this.columns = [ + { field: 'ID', width: 150, type: 'string', pinned: true }, + { field: 'CompanyName', width: 150, type: 'string' }, + { field: 'ContactName', width: 150, type: 'string' }, + { field: 'ContactTitle', width: 150, type: 'string' }, + { field: 'Address', width: 150, type: 'string' }]; + } +} + +@Component({ + template: GridTemplateStrings.declareGrid('', '', ColumnDefinitions.idNameFormatter), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class ColumnCellFormatterComponent extends BasicGridComponent { + public override data = SampleTestData.personIDNameData(); + + public multiplier(value: number): string { + return `${value * value}`; + } + + public containsY(_: number, data: { ID: number; Name: string }) { + return data.Name.includes('y') ? 'true' : 'false'; + } + + public boolFormatter(value: boolean): string { + return value ? 'check' : 'close'; + } +} + +@Component({ + template: GridTemplateStrings.declareGrid(` height="500px"`, '', ColumnDefinitions.productFilterable), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class FilteringComponent extends BasicGridComponent { + public override data = SampleTestData.productInfoData(); +} + +@Component({ + template: GridTemplateStrings.declareGrid( + ` [width]="width" [height]="height" [rowSelection]="'multiple'" [primaryKey]="'ProductID'" [selectedRows]="selectedRows"`, + '', ColumnDefinitions.productBasicNumberID, '', '@if (paging) { }'), + imports: [IgxGridComponent, IgxColumnComponent, IgxPaginatorComponent] +}) +export class RowSelectionComponent extends BasicGridComponent { + public override data = SampleTestData.foodProductDataExtended(); + public width = '800px'; + public height = '600px'; + public selectedRows = []; + public paging = false; +} + +@Component({ + template: GridTemplateStrings.declareGrid(` [width]="width" [height]="height" [rowSelection]="'single'" [primaryKey]="'ProductID'"`, '', ColumnDefinitions.productBasicNumberID), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class SingleRowSelectionComponent extends BasicGridComponent { + public override data = SampleTestData.foodProductDataExtended(); + public width = '800px'; + public height = '600px'; +} + +@Component({ + template: GridTemplateStrings.declareGrid(` [width]="width" [height]="height" [rowSelection]="'multiple'"`, '', ColumnDefinitions.idFirstLastNameSortable), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class RowSelectionWithoutPrimaryKeyComponent extends BasicGridComponent { + public override data = SampleTestData.personIDNameRegionData(); + public width = '800px'; + public height = '600px'; +} + +@Component({ + template: GridTemplateStrings.declareGrid(` [primaryKey]="'ID'" + [width]="'900px'" + [height]="'900px'" + [columnWidth]="'200px'"`, '', ColumnDefinitions.idNameJobTitleCompany), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class NoScrollsComponent extends GridWithSizeComponent { + public override data = SampleTestData.personIDNameJobCompany(); +} + +class DealsSummaryMinMax extends IgxNumberSummaryOperand { + constructor() { + super(); + } + + public override operate(summaries?: any[]): IgxSummaryResult[] { + const result = super.operate(summaries).filter((obj) => { + if (obj.key === 'min' || obj.key === 'max') { + return obj; + } + }); + return result; + } +} +@Component({ + template: GridTemplateStrings.declareGrid(` [primaryKey]="'ProductID'" [height]="null" [allowFiltering]="true"`, '', ColumnDefinitions.productDefaultSummaries), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class SummaryColumnComponent extends BasicGridComponent { + public override data = SampleTestData.foodProductData(); + public hasSummary = true; + + public numberSummary = new IgxNumberSummaryOperand(); + public dateSummary = new IgxDateSummaryOperand(); + public dealsSummaryMinMax = DealsSummaryMinMax; +} + +@Component({ + template: GridTemplateStrings.declareGrid('', '', ColumnDefinitions.productBasic, '', '@if (paging) {}'), + imports: [IgxGridComponent, IgxColumnComponent, IgxPaginatorComponent] +}) +export class ProductsComponent extends BasicGridComponent { + public override data = SampleTestData.foodProductData(); + + public paging = false; + + @ViewChild(IgxPaginatorComponent) + public paginator: IgxPaginatorComponent; +} + +@Component({ + template: GridTemplateStrings.declareBasicGridWithColumns(ColumnDefinitions.idFirstLastNameSortable), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class GridDeclaredColumnsComponent extends BasicGridComponent { + @ViewChild('nameColumn', { static: true }) public nameColumn; + + public override data = SampleTestData.personIDNameRegionData(); +} + +@Component({ + template: GridTemplateStrings.declareGrid(` [autoGenerate]="autoGenerate" [height]="height" [width]="width"`, `${EventSubscriptions.columnInit}${EventSubscriptions.selected}`, ''), + imports: [IgxGridComponent] +}) +export class PinOnInitAndSelectionComponent extends GridWithSizeComponent { + public override data = SampleTestData.contactInfoDataFull(); + public override width = '800px'; + public override height = '300px'; + + public selectedCell; + public columnInit(column) { + if (column.field === 'CompanyName' || column.field === 'ContactName') { + column.pinned = true; + } + column.width = '200px'; + } + + public cellSelected(event) { + this.selectedCell = event.cell; + } +} + +@Component({ + template: GridTemplateStrings.declareGrid(` [autoGenerate]="autoGenerate" [height]="height" [width]="width"`, `${EventSubscriptions.columnInit}`, ''), + imports: [IgxGridComponent] +}) +export class PinOnBothSidesInitComponent extends GridWithSizeComponent { + public override data = SampleTestData.contactInfoDataFull(); + public override width = '800px'; + public override height = '300px'; + + public selectedCell; + public columnInit(column) { + if (column.field === 'CompanyName' || column.field === 'ContactName') { + column.pinned = true; + } + if (column.field === 'CompanyName') { + column.pinningPosition = ColumnPinningPosition.End; + } + column.width = '200px'; + } +} + +@Component({ + template: ` + + @for (group of colGroups; track group.field) { + + @for (col of group.columns; track col.field) { + + } + + } + + `, + imports: [IgxGridComponent, IgxColumnLayoutComponent, IgxColumnComponent] +}) +export class GridPinningMRLComponent extends PinOnInitAndSelectionComponent { + public colGroups = [ + { + field: 'group1', + group: 'group1', + pinned: true, + columns: [ + { field: 'ID', rowStart: 1, colStart: 1 }, + { field: 'CompanyName', rowStart: 1, colStart: 2 }, + { field: 'ContactName', rowStart: 1, colStart: 3 }, + { field: 'ContactTitle', rowStart: 2, colStart: 1, rowEnd: 4, colEnd: 4 }, + ] + }, + { + field: 'group2', + group: 'group2', + columns: [ + { field: 'Country', rowStart: 1, colStart: 1, colEnd: 4, rowEnd: 3 }, + { field: 'Region', rowStart: 3, colStart: 1 }, + { field: 'PostalCode', rowStart: 3, colStart: 2 }, + { field: 'Fax', rowStart: 3, colStart: 3 } + ] + } + ]; +} + +@Component({ + template: GridTemplateStrings.declareGrid(` [height]="height" [width]="width"`, + `${EventSubscriptions.selected}${EventSubscriptions.columnPin}`, + ColumnDefinitions.generatedWithWidth), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class PinningComponent extends GridWithSizeComponent + implements IGridSelection { + + public column: IgxColumnComponent; + public columns = [ + { field: 'ID', width: 100 }, + { field: 'CompanyName', width: 300 }, + { field: 'ContactName', width: 200 }, + { field: 'ContactTitle', width: 200 }, + { field: 'Address', width: 300 }, + { field: 'City', width: 100 }, + { field: 'Region', width: 100 }, + { field: 'PostalCode', width: 100 }, + { field: 'Phone', width: 150 }, + { field: 'Fax', width: 150 } + ]; + + public override data = SampleTestData.contactMariaAndersData(); + public override width = '800px'; + public override height = '300px'; + + public selectedCell: CellType; + public cellSelected(event: IGridCellEventArgs) { + this.selectedCell = event.cell; + } + + public columnPinning($event) { + $event.insertAtIndex = 0; + } +} + +@Component({ + template: GridTemplateStrings.declareBasicGridWithColumns(ColumnDefinitions.productFilterSortPin), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class GridFeaturesComponent extends BasicGridComponent { + public override data = SampleTestData.productInfoData(); + +} + +@Component({ + template: GridTemplateStrings.declareGrid( + ` columnWidth="200" `, + '', ColumnDefinitions.idNameJobHireDate, '', + '@if (paging) { }'), + imports: [IgxGridComponent, IgxColumnComponent, IgxPaginatorComponent] +}) +export class ScrollableGridSearchComponent extends BasicGridSearchComponent { + public override data = SampleTestData.generateFromData(SampleTestData.personJobDataFull(), 30); + public override paging = false; +} + +@Component({ + template: GridTemplateStrings.declareGrid( + ` columnWidth="200" [height]="null" `, + '', ColumnDefinitions.idNameJobTitleCompany, '', + '@if (paging) { }'), + imports: [IgxGridComponent, IgxColumnComponent, IgxPaginatorComponent] +}) +export class GroupableGridSearchComponent extends ScrollableGridSearchComponent { + public override data = SampleTestData.personIDNameJobCompany(); + public override paging = false; +} + +@Component({ + template: GridTemplateStrings.declareGrid(` [height]="height" [width]="width" [columnWidth]="columnWidth" `, '', ColumnDefinitions.productAllColumnFeatures), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class GridAllFeaturesComponent extends GridWithSizeComponent { + @Input() + public columnWidth = 200; + +} + +@Component({ + template: GridTemplateStrings.declareBasicGridWithColumns(ColumnDefinitions.nameJobTitleId), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class ReorderedColumnsComponent extends BasicGridComponent { + public override data = SampleTestData.personJobData(); +} + +@Component({ + template: GridTemplateStrings.declareBasicGridWithColumns(ColumnDefinitions.simpleDatePercentColumns), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class GridUserMeetingDataComponent extends BasicGridComponent { + public override data = SampleTestData.personMeetingData(); +} + +@Component({ + template: GridTemplateStrings.declareBasicGridWithColumns(ColumnDefinitions.idNameJobTitleEditable), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class GridIDNameJobTitleComponent extends PagingComponent { + public override data = SampleTestData.personJobDataFull(); + public override width = '100%'; + public override height = '100%'; + public formatter = (value: any, rowData: any) => { + return `${value} - ${rowData.JobTitle}`; + }; +} + +@Component({ + template: GridTemplateStrings.declareBasicGridWithColumns(ColumnDefinitions.idNameJobHoursHireDatePerformance), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class GridIDNameJobTitleHireDataPerformanceComponent extends BasicGridComponent { + public override data = SampleTestData.personJobHoursDataPerformance(); +} + +@Component({ + template: GridTemplateStrings.declareBasicGridWithColumns(ColumnDefinitions.hireDate), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class GridHireDateComponent extends BasicGridComponent { + public override data = SampleTestData.hireDate(); +} + +@Component({ + template: `
    + ${GridTemplateStrings.declareGrid('[height]="height" [moving]="true" [width]="width" [rowSelection]="rowSelection" [autoGenerate]="autoGenerate"', EventSubscriptions.columnMovingStart + EventSubscriptions.columnMoving + EventSubscriptions.columnMovingEnd, ColumnDefinitions.movableColumns)}
    `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class MovableColumnsComponent extends BasicGridComponent { + public override data = SampleTestData.personIDNameRegionData(); + public autoGenerate = false; + public rowSelection = 'none'; + public isFilterable = false; + public isSortable = false; + public isResizable = false; + public isEditable = false; + public isHidden = false; + public isGroupable = false; + public width = '500px'; + public height = '300px'; + public count = 0; + public countStart = 0; + public countEnd = 0; + public cancel = false; + public source: IgxColumnComponent; + public target: IgxColumnComponent; + + public columnMovingStarted(event) { + this.countStart++; + this.source = event.source; + } + + public columnMoving(event) { + this.count++; + this.source = event.source; + + if (this.cancel) { + event.cancel = true; + } + } + + public columnMovingEnded(event) { + this.countEnd++; + this.source = event.source; + this.target = event.target; + + if (event.target.field === 'Region') { + event.cancel = true; + } + } +} + +@Component({ + template: GridTemplateStrings.declareGrid(`[moving]="true" height="300px" width="500px"`, '', ColumnDefinitions.movableColumns), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class MovableTemplatedColumnsComponent extends BasicGridComponent { + public override data = SampleTestData.personIDNameRegionData(); + public isFilterable = false; + public isSortable = false; + public isResizable = false; +} + +@Component({ + template: GridTemplateStrings.declareGrid(`height="300px" width="500px" [moving]="true" [autoGenerate]="autoGenerate"`, + EventSubscriptions.columnInit, '', '', + '@if (paging) { }'), + imports: [IgxGridComponent, IgxPaginatorComponent] +}) +export class MovableColumnsLargeComponent extends GridAutoGenerateComponent { + + public override data = SampleTestData.contactInfoDataFull(); + + public width = '500px'; + public height = '400px'; + + public columnInit(column: IgxColumnComponent) { + column.sortable = true; + column.width = '100px'; + } + + public pinColumn(name: string) { + const col = this.grid.getColumnByName(name); + col.pinned = !col.pinned; + } +} + +@Component({ + template: GridTemplateStrings.declareGrid(`height="800px"`, '', ColumnDefinitions.multiColHeadersColumns), + imports: [IgxGridComponent, IgxColumnComponent, IgxColumnGroupComponent, IgxCellTemplateDirective, IgxCellHeaderTemplateDirective] +}) +export class MultiColumnHeadersComponent extends BasicGridComponent { + public override data = SampleTestData.contactInfoDataFull(); + public isPinned = false; +} + +@Component({ + template: GridTemplateStrings.declareGrid(`[moving]="true" height="800px" width="500px"`, '', ColumnDefinitions.multiColHeadersWithGroupingColumns), + imports: [IgxGridComponent, IgxColumnComponent, IgxColumnGroupComponent] +}) +export class MultiColumnHeadersWithGroupingComponent extends BasicGridComponent { + public override data = SampleTestData.contactInfoDataFull(); + public isPinned = false; +} + + +@Component({ + template: GridTemplateStrings.declareBasicGridWithColumns(ColumnDefinitions.nameAvatar), + imports: [IgxGridComponent, IgxColumnComponent, IgxCellTemplateDirective] +}) +export class GridWithAvatarComponent extends GridWithSizeComponent { + public override data = SampleTestData.personAvatarData(); + public override height = '500px'; +} + + +@Component({ + template: GridTemplateStrings.declareGrid(`height="1000px" width="900px" primaryKey="ID"`, '', + ColumnDefinitions.summariesGroupByColumns, '', + '@if (paging) { }'), + imports: [IgxGridComponent, IgxColumnComponent, IgxPaginatorComponent] +}) +export class SummariesGroupByComponent extends BasicGridComponent { + public override data = SampleTestData.employeeGroupByData(); + public calculationMode = 'rootAndChildLevels'; + public ageSummary = AgeSummary; + public ageSummaryTest = AgeSummaryTest; + public paging = false; +} + +@Component({ + template: GridTemplateStrings.declareGrid(`height="600px" width="900px" [batchEditing]="true" primaryKey="ID"`, '', ColumnDefinitions.summariesGroupByTansColumns), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class SummariesGroupByTransactionsComponent extends BasicGridComponent { + public override data = SampleTestData.employeeGroupByData(); + public calculationMode = 'rootAndChildLevels'; + public ageSummary = AgeSummary; + public ageSummaryMinMax = DealsSummaryMinMax; +} + +class AgeSummary extends IgxNumberSummaryOperand { + constructor() { + super(); + } + + public override operate(summaries?: any[]): IgxSummaryResult[] { + const result = super.operate(summaries).filter((obj) => { + if (obj.key === 'average' || obj.key === 'sum' || obj.key === 'count') { + const summaryResult = obj.summaryResult; + // apply formatting to float numbers + if (Number(summaryResult) === summaryResult) { + obj.summaryResult = summaryResult.toLocaleString('en-us', { maximumFractionDigits: 2 }); + } + return obj; + } + }); + return result; + } +} + +class AgeSummaryTest extends IgxNumberSummaryOperand { + constructor() { + super(); + } + + public override operate(summaries?: any[]): IgxSummaryResult[] { + const result = super.operate(summaries); + result.push({ + key: 'test', + label: 'Test', + summaryResult: summaries.filter(rec => rec > 10 && rec < 40).length + }); + + return result; + } +} + +@Component({ + template: GridTemplateStrings.declareGrid(`[height]="gridHeight" [columnWidth]="defaultWidth" [width]="gridWidth"`, + `${EventSubscriptions.selected}`, ColumnDefinitions.generatedWithWidth), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class VirtualGridComponent extends BasicGridComponent { + public gridWidth = '800px'; + public gridHeight = '300px'; + public defaultWidth = '200px'; + public columns = [ + { field: 'index' }, + { field: 'value' }, + { field: 'other' }, + { field: 'another' } + ]; + public selectedCell: CellType; + constructor() { + super(); + this.data = this.generateData(1000); + } + public generateCols(numCols: number, defaultColWidth = null) { + const cols = []; + for (let j = 0; j < numCols; j++) { + cols.push({ + field: j.toString(), + width: defaultColWidth || (j % 8 < 2 ? 100 : (j % 6) * 125) + }); + } + return cols; + } + public generateData(numRows: number) { + const data = []; + for (let i = 0; i < numRows; i++) { + const obj = {}; + for (let j = 0; j < this.columns.length; j++) { + const col = this.columns[j].field; + obj[col] = 10 * i * j; + } + data.push(obj); + } + return data; + } + public cellSelected(event: IGridCellEventArgs) { + this.selectedCell = event.cell; + } + public scrollTop(newTop: number) { + this.grid.verticalScrollContainer.getScroll().scrollTop = newTop; + } + public scrollLeft(newLeft: number) { + this.grid.headerContainer.getScroll().scrollLeft = newLeft; + } +} + +@Component({ + template: GridTemplateStrings.declareGrid(` [primaryKey]="'ID'"`, '', ColumnDefinitions.idNameJobHireWithTypes), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class GridWithPrimaryKeyComponent extends BasicGridSearchComponent { + public override data = SampleTestData.personJobDataFull(); +} + +@Component({ + template: GridTemplateStrings.declareGrid(`height="300px" width="600px" primaryKey="ID"`, '', + ColumnDefinitions.selectionWithScrollsColumns, '', + '@if (paging) { }'), + imports: [IgxGridComponent, IgxColumnComponent, IgxPaginatorComponent] +}) +export class SelectionWithScrollsComponent extends BasicGridComponent { + public override data = SampleTestData.employeeGroupByData(); + public paging = false; +} + +@Component({ + template: GridTemplateStrings.declareGrid(`height="300px" width="600px" primaryKey="ID" cellSelection="none"`, '', ColumnDefinitions.selectionWithScrollsColumns), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class CellSelectionNoneComponent extends BasicGridComponent { + public override data = SampleTestData.employeeGroupByData(); +} + +@Component({ + template: GridTemplateStrings.declareGrid(`height="300px" width="600px" primaryKey="ID" cellSelection="single"`, '', ColumnDefinitions.selectionWithScrollsColumns), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class CellSelectionSingleComponent extends BasicGridComponent { + public override data = SampleTestData.employeeGroupByData(); +} +@Component({ + template: GridTemplateStrings.declareGrid(`height="300px" width="600px" [batchEditing]="true" primaryKey="ID"`, '', ColumnDefinitions.selectionWithScrollsColumns), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class SelectionWithTransactionsComponent extends BasicGridComponent { + public override data = SampleTestData.employeeGroupByData(); +} + +export class CustomFilter extends IgxFilteringOperand { + private constructor() { + super(); + this.operations = [{ + name: 'custom', + isUnary: false, + logic: (target: string): boolean => target === 'custom', + iconName: 'custom' + }]; + } +} +@Component({ + template: ` + + + + + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class IgxGridFilteringComponent extends BasicGridComponent { + public customFilter = CustomFilter.instance(); + public resizable = false; + public filterable = true; + + public override data = SampleTestData.excelFilteringData(); + public activateFiltering(activate: boolean) { + this.grid.allowFiltering = activate; + this.grid.cdr.markForCheck(); + } +} + +@Component({ + template: ` + + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class IgxGridFilteringNumericComponent extends BasicGridComponent { + public override data = SampleTestData.numericData(); +} + +@Component({ + template: ` + + + + + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class IgxGridDatesFilteringComponent extends BasicGridComponent { + public customFilter = CustomFilter.instance(); + public resizable = false; + public filterable = true; + + public override data = SampleTestData.excelFilteringData().map(rec => { + const newRec = Object.assign({}, rec) as any; + newRec.ReleaseDate = rec.ReleaseDate ? rec.ReleaseDate.toISOString() : null; + return newRec; + }); + public activateFiltering(activate: boolean) { + this.grid.allowFiltering = activate; + this.grid.cdr.markForCheck(); + } +} + +@Component({ + template: ` + + + + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxGridExcelStyleFilteringComponent] +}) +export class IgxGridExternalESFComponent extends BasicGridComponent implements AfterViewInit { + @ViewChild('esf', { read: IgxGridExcelStyleFilteringComponent, static: true }) + public esf: IgxGridExcelStyleFilteringComponent; + + public customFilter = CustomFilter.instance(); + public resizable = false; + public filterable = true; + + public override data = SampleTestData.excelFilteringData(); + + constructor() { + super(); + } + + public ngAfterViewInit(): void { + this.esf.column = this.grid.getColumnByName('ProductName'); + } +} + +export class CustomFilterStrategy extends FilteringStrategy { + constructor() { + super(); + } + + public override findMatchByExpression(rec, expr): boolean { + const cond = expr.condition; + const val = this.getFieldValue(rec, expr.fieldName); + const ignoreCase = expr.fieldName === 'JobTitle' ? false : true; + return cond.logic(val, expr.searchVal, ignoreCase); + } + + public override filter(data: T[], expressionsTree: IFilteringExpressionsTree): T[] { + return super.filter(data, expressionsTree, null, null); + } + + public override getFieldValue(rec, fieldName: string): any { + return fieldName === 'Name' ? rec[fieldName]['FirstName'] : rec[fieldName]; + } +} + +@Component({ + template: ` + + + + {{val.FirstName}} + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class CustomFilteringStrategyComponent extends BasicGridComponent { + public strategy = new CustomFilterStrategy(); + public filterable = true; + + public override data = SampleTestData.personNameObjectJobCompany(); +} + + +export class LoadOnDemandFilterStrategy extends FilteringStrategy { + public override getFilterItems(column: ColumnType, tree: IFilteringExpressionsTree): Promise { + return new Promise(resolve => setTimeout(() => + resolve(super.getFilterItems(column, tree)), 1000)); + } +} + +@Component({ + template: ` + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class IgxGridFilteringESFLoadOnDemandComponent extends BasicGridComponent { + public override data = SampleTestData.excelFilteringData(); + public doneCallbackCounter = 0; + + private _filteringStrategy = new FilteringStrategy(); + + public columnValuesStrategy = (column: IgxColumnComponent, + columnExprTree: IFilteringExpressionsTree, + done: (uniqueValues: any[]) => void) => { + setTimeout(() => { + const filteredData = this._filteringStrategy.filter(this.data, columnExprTree, null, null); + const columnValues = filteredData.map(record => record[column.field]); + done(columnValues); + this.doneCallbackCounter++; + }, 1000); + }; +} + +@Component({ + template: ` + + + + + + + + + + + Column Operations Template + Filter Operations Template + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxGridExcelStyleFilteringComponent, IgxExcelStyleColumnOperationsTemplateDirective, IgxExcelStyleFilterOperationsTemplateDirective] +}) +export class IgxGridFilteringESFEmptyTemplatesComponent extends BasicGridComponent { + public customFilter = CustomFilter.instance(); + public resizable = false; + public filterable = true; + public override data = SampleTestData.excelFilteringData(); +} + +@Component({ + template: ` + + + + + + + + + + + filter_alt + + + + + + + + + + + + + + search + + `, + imports: [ + IgxGridComponent, + IgxColumnComponent, + IgxGridExcelStyleFilteringComponent, + IgxExcelStyleColumnOperationsTemplateDirective, + IgxExcelStyleFilterOperationsTemplateDirective, + IgxIconComponent, + IgxExcelStyleMovingComponent, + IgxExcelStylePinningComponent, + IgxExcelStyleSearchComponent, + IgxExcelStyleHeaderIconDirective + ] +}) +export class IgxGridFilteringESFTemplatesComponent extends BasicGridComponent { + public customFilter = CustomFilter.instance(); + public resizable = false; + public filterable = true; + public override data = SampleTestData.excelFilteringData(); + @ViewChild('template', {read: TemplateRef }) + public customExcelHeaderIcon: TemplateRef; +} + +@Component({ + template: ` + + + + + Filter Operations Template + + + + + + + + + + + `, + imports: [ + IgxGridComponent, + IgxColumnComponent, + IgxGridExcelStyleFilteringComponent, + IgxExcelStyleColumnOperationsTemplateDirective, + IgxExcelStyleFilterOperationsTemplateDirective, + IgxExcelStyleSelectingComponent + ] +}) +export class IgxGridExternalESFTemplateComponent extends BasicGridComponent implements OnInit { + @ViewChild('esf', { read: IgxGridExcelStyleFilteringComponent, static: true }) + public esf: IgxGridExcelStyleFilteringComponent; + + public customFilter = CustomFilter.instance(); + public resizable = false; + public filterable = true; + + public override data = SampleTestData.excelFilteringData(); + + constructor() { + super(); + } + + public ngOnInit(): void { + this.esf.column = this.grid.getColumnByName('Downloads'); + } +} + +@Component({ + template: ` + + + + + + + + + + +
    + + + search + + + + clear + + +
    +
    + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxInputGroupComponent, IgxPrefixDirective, IgxSuffixDirective, IgxInputDirective, IgxFilterCellTemplateDirective, IgxIconComponent] +}) +export class IgxGridFilteringTemplateComponent extends BasicGridComponent { + public resizable = false; + public filterable = true; + + public override data = SampleTestData.excelFilteringData(); +} + +@Component({ + template: ` + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class IgxGridFilteringScrollComponent extends IgxGridFilteringComponent { } + +@Component({ + template: ` + + + + + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxColumnGroupComponent] +}) +export class IgxGridFilteringMCHComponent extends IgxGridFilteringComponent { } + +@Component({ + template: ` + + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxGridToolbarComponent] +}) +export class IgxGridAdvancedFilteringComponent extends BasicGridComponent { + public customFilter = CustomFilter.instance(); + public resizable = false; + public filterable = true; + + public override data = SampleTestData.excelFilteringData(); + public activateFiltering(activate: boolean) { + this.grid.allowFiltering = activate; + this.grid.cdr.markForCheck(); + } +} + +@Component({ + template: ` + + + Really advanced filtering + + + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxGridToolbarComponent, IgxGridToolbarActionsComponent, IgxGridToolbarAdvancedFilteringComponent] +}) +export class IgxGridAdvancedFilteringWithToolbarComponent extends BasicGridComponent { + public customFilter = CustomFilter.instance(); + public override data = SampleTestData.excelFilteringData(); +} + +@Component({ + template: ` + + @for (c of columns; track c.field) { + + + } + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxGridToolbarComponent] +}) +export class IgxGridAdvancedFilteringDynamicColumnsComponent extends BasicGridComponent implements OnInit { + public override data = []; + public columns = []; + + public ngOnInit(): void { + this.columns = [ + { field: 'ID', header: 'HeaderID', width: '100px', type: 'number' }, + { field: 'ProductName', header: 'Product Name', width: '200px', type: 'string'}, + { field: 'Downloads', header: 'Downloads', width: '100px', type: 'number' }, + { field: 'Released', header: 'Released', width: '100px', type: 'boolean' }, + { field: 'ReleaseDate', header: 'Release Date', width: '200px', type: 'date' }, + { field: 'AnotherField', header: 'Another Field', width: '200px', type: 'string' }, + ]; + this.data = SampleTestData.excelFilteringData(); + } +} + +@Component({ + template: ` + + + + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxGridToolbarComponent, IgxGridToolbarAdvancedFilteringComponent] +}) +export class IgxGridAdvancedFilteringOverlaySettingsComponent extends BasicGridComponent { + public customFilter = CustomFilter.instance(); + public hidingOverlaySettings: OverlaySettings = {}; + public override data = SampleTestData.excelFilteringData(); + + public filteringOverlaySettings: OverlaySettings = { + closeOnEscape: false + }; +} + +@Component({ + template: ` + + + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxAdvancedFilteringDialogComponent] +}) +export class IgxGridExternalAdvancedFilteringComponent extends BasicGridComponent { + public customFilter = CustomFilter.instance(); + public resizable = false; + public filterable = true; + + public override data = SampleTestData.excelFilteringData(); +} + +@Component({ + template: ` + + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxColumnGroupComponent] +}) +export class IgxGridAdvancedFilteringColumnGroupComponent extends BasicGridComponent { + public customFilter = CustomFilter.instance(); + public resizable = false; + public filterable = true; + + public override data = SampleTestData.excelFilteringData(); + public activateFiltering(activate: boolean) { + this.grid.allowFiltering = activate; + this.grid.cdr.markForCheck(); + } +} + +@Component({ + template: ` + + + + + + + @if (paging) { + + } + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxPaginatorComponent] +}) +export class IgxGridClipboardComponent extends BasicGridComponent { + public override data = SampleTestData.excelFilteringData(); + public formatter = (value: any) => `** ${value} **`; + public allowFiltering = false; + public paging = false; +} + +@Component({ + template: GridTemplateStrings.declareGrid(`id="testGridSum" [height]="height" [width]="width"`, ``, + ColumnDefinitions.generatedWithDataType), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class DynamicColumnsComponent extends GridWithSizeComponent { + public columns = [ + { field: 'ID', width: 100, dataType: 'string' }, + { field: 'CompanyName', width: 300, dataType: 'string' }, + { field: 'ContactName', width: 200, dataType: 'string' }, + { field: 'ContactTitle', width: 200, dataType: 'string' }, + { field: 'Address', width: 300, dataType: 'string' }, + { field: 'City', width: 100, dataType: 'string' }, + { field: 'Region', width: 100, dataType: 'string' } + ]; + + public override data = SampleTestData.contactInfoDataFull(); + public override width = '800px'; + public override height = '800px'; +} + +@Component({ + template: ` + + + + + + + + + {{ rowContext.index }} + + + + + + + + CUSTOM SELECTOR: {{ rowContext.index }} + + + CUSTOM HEADER SELECTOR + + + CUSTOM GROUP SELECTOR + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxCheckboxComponent, IgxPaginatorComponent, IgxRowSelectorDirective, IgxHeadSelectorDirective, IgxGroupByRowSelectorDirective] +}) +export class GridCustomSelectorsComponent extends BasicGridComponent implements OnInit { + @ViewChild('gridCustomSelectors', { static: true }) + public override grid: IgxGridComponent; + public rowCheckboxClick: any; + public headerCheckboxClick: any; + @ViewChild('customRow', {read: TemplateRef, static: true }) + public customRowTemplate: TemplateRef; + + @ViewChild('customHeader', {read: TemplateRef, static: true }) + public customHeaderTemplate: TemplateRef; + + @ViewChild('customGroupRow', {read: TemplateRef, static: true }) + public customGroupRowTemplate: TemplateRef; + + public ngOnInit(): void { + this.data = SampleTestData.contactInfoDataFull(); + } + + public onRowCheckboxClick(event, rowContext) { + this.rowCheckboxClick = event; + event.stopPropagation(); + event.preventDefault(); + if (rowContext.selected) { + this.grid.deselectRows([rowContext.rowID]); + } else { + this.grid.selectRows([rowContext.rowID]); + } + } + + public onHeaderCheckboxClick(event, headContext) { + this.headerCheckboxClick = event; + event.stopPropagation(); + event.preventDefault(); + if (headContext.selected) { + this.grid.deselectAllRows(); + } else { + this.grid.selectAllRows(); + } + } +} + +@Component({ + template: ` + + @if (showToolbar) { + + + + + + } + + + + + + + @if (paging) { + + } + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxGridToolbarComponent, IgxGridToolbarActionsComponent, IgxGridToolbarHidingComponent, IgxPaginatorComponent] +}) +export class IgxGridRowEditingComponent extends BasicGridComponent { + public showToolbar = false; + public paging = false; + public override data = SampleTestData.foodProductData(); +} + +@Component({ + template: ` + + + + + + + + + + + + val +
    + {{val}} +
    +
    `, + imports: [IgxGridComponent, IgxColumnComponent, IgxCellTemplateDirective] +}) +export class IgxGridRowEditingWithoutEditableColumnsComponent extends BasicGridComponent { + public override data = SampleTestData.foodProductData(); + @ViewChild('customCell', { static: true }) + public customCell!: TemplateRef; +} + +@Component({ + template: ` + + + + + + + + + + @if (columnGroupingFlag) { + + + + + + + } + @else { + + + + + } + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxColumnGroupComponent] +}) +export class IgxGridWithEditingAndFeaturesComponent extends BasicGridComponent { + /* Data fields: Downloads:number, ID: number, ProductName: string, ReleaseDate: Date, + Released: boolean, Category: string, Items: string, Test: string. */ + public pinnedFlag = false; + public hiddenFlag = false; + public columnGroupingFlag = false; + public override data = SampleTestData.generateProductData(11); +} + +@Component({ + template: ` + + + + + + +
    + {{ rowChangesCount }} +
    +
    +
    + + +
    +
    +
    +
    + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxRowEditTabStopDirective, IgxRowEditTemplateDirective, IgxButtonDirective] +}) +export class IgxGridCustomOverlayComponent extends BasicGridComponent { + @ViewChildren(IgxRowEditTabStopDirective) public buttons: QueryList; + public override data = SampleTestData.foodProductData(); + + public get gridAPI() { + return this.grid.gridAPI; + } + + public get cellInEditMode() { + return this.grid.gridAPI.crudService.cell; + } + + public getCurrentEditCell(): CellType { + const grid = this.grid as any; + const currentCell = grid.gridAPI.crudService.cell; + return this.grid.getCellByColumn(currentCell.id.rowIndex, currentCell.column.field); + } + + public moveNext(shiftKey: boolean): void { + this.grid.navigation.dispatchEvent(new KeyboardEvent('keydown', { + key: 'tab', + code: 'tab', + shiftKey + })); + } +} + +@Component({ + template: ` + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxRowEditTemplateDirective] +}) +export class IgxGridEmptyRowEditTemplateComponent extends BasicGridComponent { + public override data = SampleTestData.foodProductData(); +} + + +@Component({ + template: ` + + + + + + + + CUSTOM EDIT ACTIONS + + + CUSTOM ADD TEXT + + + CUSTOM EDIT TEXT + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxRowEditActionsDirective, IgxRowAddTextDirective, IgxRowEditTextDirective] +}) +export class IgxGridCustomRowEditTemplateComponent extends BasicGridComponent { + public override data = SampleTestData.foodProductData(); + @ViewChild('editActions', {read: TemplateRef }) + public editActions: TemplateRef; + + @ViewChild('addText', {read: TemplateRef }) + public addText: TemplateRef; + + @ViewChild('editText', {read: TemplateRef }) + public editText: TemplateRef; +} + +@Component({ + template: ` + + + + + + + @if (paging) { + + } + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxPaginatorComponent] +}) +export class IgxGridRowEditingTransactionComponent extends BasicGridComponent { + public override data = SampleTestData.foodProductData(); + public paging = false; +} + + +@Component({ + template: ` + + + + + + + @if (paging) { + + } + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxPaginatorComponent] +}) +export class IgxGridCurrencyColumnComponent extends BasicGridComponent { + public override data = SampleTestData.foodProductData(); + public paging = false; +} + +@Component({ + template: ` + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class IgxGridPercentColumnComponent extends BasicGridComponent { + public override data = SampleTestData.foodPercentProductData(); +} + +@Component({ + template: ` + + + + + + + + + @if (paging) { + + } + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxPaginatorComponent] +}) +export class IgxGridDateTimeColumnComponent extends BasicGridComponent { + public override data = SampleTestData.foodProductDateTimeData(); + public paging = false; + + public testFormatter = (val: string) => 'test' + val; +} + +@Component({ + template: ` + + + + Custom template + + `, + imports: [IgxGridComponent] +}) +export class IgxGridRowEditingWithFeaturesComponent extends DataParent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + + @ViewChild('dropArea', { read: TemplateRef, static: true }) + public dropAreaTemplate: TemplateRef; + + public width = '800px'; + public height = null; + + public enableSorting = false; + public enableFiltering = false; + public enableResizing = false; + public enableEditing = true; + public enableGrouping = true; + public enableRowEditing = true; + public currentSortExpressions; + + public columnsCreated(column: IgxColumnComponent) { + column.sortable = this.enableSorting; + column.filterable = this.enableFiltering; + column.resizable = this.enableResizing; + column.editable = this.enableEditing; + column.groupable = this.enableGrouping; + } + public groupingDoneHandler(sortExpr) { + this.currentSortExpressions = sortExpr; + } +} + +@Component({ + template: ` + + + + Custom template + + `, + imports: [IgxGridComponent] +}) +export class IgxGridGroupByComponent extends DataParent implements OnInit { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + + @ViewChild('dropArea', { read: TemplateRef, static: true }) + public dropAreaTemplate: TemplateRef; + + public width = '600px'; + public height = '600px'; + + public enableSorting = false; + public enableFiltering = false; + public enableResizing = false; + public enableEditing = true; + public enableGrouping = true; + public enableRowEditing = false; + public currentSortExpressions; + + public columnsCreated(column: IgxColumnComponent) { + column.sortable = this.enableSorting; + column.filterable = this.enableFiltering; + column.resizable = this.enableResizing; + column.editable = this.enableEditing; + column.groupable = this.enableGrouping; + } + public groupingDoneHandler(sortExpr) { + this.currentSortExpressions = sortExpr; + } + + public ngOnInit() { + this.grid.groupingExpressions = [ + { fieldName: 'ProductName', dir: SortingDirection.Desc } + ]; + } +} + +@Component({ + template: ` + + + + + + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxCellEditorTemplateDirective, IgxFocusDirective] +}) +export class CellEditingCustomEditorTestComponent extends BasicGridComponent { + @ViewChild('cellEdit', { read: TemplateRef }) public templateCell; + public override data = [ + { personNumber: 0, fullName: 'John Brown', age: 20, isActive: true, birthday: new Date('08/08/2001') }, + { personNumber: 1, fullName: 'Ben Affleck', age: 30, isActive: false, birthday: new Date('08/08/1991') }, + { personNumber: 2, fullName: 'Tom Riddle', age: 50, isActive: true, birthday: new Date('08/08/1961') } + ]; + + public onChange(event: any, cell: CellType) { + cell.editValue = event.target.value; + } +} + +@Component({ + template: ` + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class CellEditingTestComponent extends BasicGridComponent { + public override data = [ + { personNumber: 0, fullName: 'John Brown', age: 20, isActive: true, birthday: new Date('08/08/2001') }, + { personNumber: 1, fullName: 'Ben Affleck', age: 30, isActive: false, birthday: new Date('08/08/1991') }, + { personNumber: 2, fullName: 'Tom Riddle', age: 50, isActive: true, birthday: new Date('08/08/1961') } + ]; +} + +@Component({ + template: ` + + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class CellEditingScrollTestComponent extends BasicGridComponent { + public override data = [ + { firstName: 'John', lastName: 'Brown', age: 20, isActive: true, birthday: new Date('08/08/2001'), fullName: 'John Brown' }, + { firstName: 'Ben', lastName: 'Hudson', age: 30, isActive: false, birthday: new Date('08/08/1991'), fullName: 'Ben Hudson' }, + { firstName: 'Tom', lastName: 'Riddle', age: 50, isActive: true, birthday: new Date('08/08/1967'), fullName: 'Tom Riddle' }, + { firstName: 'John', lastName: 'David', age: 27, isActive: true, birthday: new Date('08/08/1990'), fullName: 'John David' }, + { firstName: 'David', lastName: 'Affleck', age: 36, isActive: false, birthday: new Date('08/08/1982'), fullName: 'David Affleck' }, + { firstName: 'Jimmy', lastName: 'Johnson', age: 57, isActive: true, birthday: new Date('08/08/1961'), fullName: 'Jimmy Johnson' }, + { firstName: 'Martin', lastName: 'Brown', age: 31, isActive: true, birthday: new Date('08/08/1987'), fullName: 'Martin Brown' }, + { firstName: 'Tomas', lastName: 'Smith', age: 81, isActive: false, birthday: new Date('08/08/1931'), fullName: 'Tomas Smith' }, + { firstName: 'Michael', lastName: 'Parker', age: 48, isActive: true, birthday: new Date('08/08/1970'), fullName: 'Michael Parker' } + ]; +} + +@Component({ + template: GridTemplateStrings.declareGrid(` [width]="width" [height]="height" [primaryKey]="'ProductID'"`, '', ColumnDefinitions.productBasic, '', ''), + imports: [IgxGridComponent, IgxColumnComponent, IgxPaginatorComponent] +}) +export class GridWithUndefinedDataComponent implements OnInit { + @ViewChild(IgxGridComponent, { static: true }) + public grid: IgxGridComponent; + public data; + public perPage = 5; + public width = '800px'; + public height = '600px'; + + public ngOnInit(): void { + requestAnimationFrame(() => { + this.data = SampleTestData.foodProductDataExtended(); + }); + } +} + +@Component({ + template: ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxColumnGroupComponent] +}) +export class CollapsibleColumnGroupTestComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + public generalInfCollapsible; + public generalInfExpanded; + public personDetailsCollapsible; + public personDetailsExpanded; + public personDetailsVisibleWhenCollapse; + public companyNameVisibleWhenCollapse; + public hideContactInformation = true; + public data = SampleTestData.contactInfoDataFull(); +} + + +@Component({ + template: ` + + + + + + + + + + + + + + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxColumnGroupComponent] +}) +export class ColumnSelectionGroupTestComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + public data = SampleTestData.contactInfoDataFull(); +} + +@Component({ + template: ` + + {{column.expanded ? 'lock' : 'lock_open'}} + + + + + + + + + + + + {{column.expanded ? 'remove' : 'add'}} + + + + + + + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxColumnGroupComponent, IgxIconComponent, IgxCollapsibleIndicatorTemplateDirective] +}) +export class CollapsibleGroupsTemplatesTestComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + + @ViewChild('indicatorTemplate', { read: TemplateRef, static: false }) + public indicatorTemplate; + + public data = SampleTestData.contactInfoDataFull(); +} + +@Component({ + template: ` + + @for (colGroup of columnGroups; track colGroup.columnHeader) { + + @for (column of colGroup.columns; track column.field) { + + } + + } + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxColumnGroupComponent] +}) +export class CollapsibleGroupsDynamicColComponent { + @ViewChild(IgxGridComponent, { static: true }) + public grid: IgxGridComponent; + + public columnGroups: Array; + public data = SampleTestData.contactInfoDataFull(); + + constructor() { + this.columnGroups = [ + { + columnHeader: 'First', collapsible: true, columns: [ + { field: 'ID', type: 'string', visibleWhenCollapsed: true }, + { field: 'CompanyName', type: 'string', visibleWhenCollapsed: true }, + { field: 'ContactName', type: 'string', visibleWhenCollapsed: true }, + ] + }, + { + columnHeader: 'Second', collapsible: true, columns: [ + { field: 'ContactTitle', type: 'string', visibleWhenCollapsed: true }, + { field: 'Address', type: 'string', visibleWhenCollapsed: true }, + { field: 'PostalCode', type: 'string', visibleWhenCollapsed: false }, + { field: 'Country', type: 'string', visibleWhenCollapsed: false } + ] + } + ]; + } + + public removeColumnFromGroup(groupIndex = 0) { + this.columnGroups[groupIndex].columns.pop(); + } + + public addColumnToGroup(groupIndex = 0, visibleWhenCollapsed = false) { + this.columnGroups[groupIndex].columns.push({ field: 'Missing', type: 'string', visibleWhenCollapsed }); + } +} + +@Component({ + template: ` + + + + + + + + + + + + + + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxColumnGroupComponent] +}) +export class ColumnGroupsNavigationTestComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + public data = SampleTestData.contactInfoDataFull(); +} + +@Component({ + template: ` + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class IgxGridFilteringBindingComponent extends BasicGridComponent implements OnInit { + public resizable = false; + public filterable = true; + public filterTree: IFilteringExpressionsTree; + + public override data = SampleTestData.excelFilteringData(); + + public ngOnInit(): void { + this.filterTree = new FilteringExpressionsTree(FilteringLogic.And); + this.filterTree.filteringOperands = [ + { + condition: IgxNumberFilteringOperand.instance().condition('greaterThan'), + conditionName: 'greaterThan', + fieldName: 'Downloads', + searchVal: 200 + } + ]; + } +} + +@Component({ + selector: 'test-grid-advanced-filtering-binding', + template: ` + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class IgxGridAdvancedFilteringBindingComponent extends BasicGridComponent implements OnInit { + public resizable = false; + public filterable = true; + public filterTree: IFilteringExpressionsTree; + + public override data = SampleTestData.excelFilteringData(); + + public ngOnInit(): void { + this.filterTree = new FilteringExpressionsTree(FilteringLogic.And); + this.filterTree.filteringOperands = [ + { + condition: IgxNumberFilteringOperand.instance().condition('greaterThan'), + conditionName: 'greaterThan', + fieldName: 'Downloads', + searchVal: 200 + } + ]; + } +} + +@Component({ + selector: 'test-grid-advanced-filtering-serialized-tree', + template: ` + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class IgxGridAdvancedFilteringSerializedTreeComponent extends BasicGridComponent implements OnInit { + public resizable = false; + public filterable = true; + public filterTree: IFilteringExpressionsTree; + public filterTreeObject: IFilteringExpressionsTree; + + public override data = SampleTestData.excelFilteringData(); + + public ngOnInit(): void { + this.filterTree = JSON.parse(`{ + "filteringOperands": [ + { + "conditionName": "greaterThan", + "fieldName": "Downloads", + "searchVal": 200 + } + ], + "operator": 0 + }`); + + this.filterTreeObject = { + "filteringOperands": [ + { + "fieldName": "ProductName", + "condition": { + "name": "contains", + "isUnary": false, + "iconName": "filter_contains" + }, + "conditionName": "contains", + "ignoreCase": true, + "searchVal": "Ig", + "searchTree": null + } + ], + "operator": 1, + "returnFields": [ + "ID", + "ProductName" + ] + }; + } +} + +@Component({ + template: ` + + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class ColumnEditablePropertyTestComponent extends BasicGridComponent { + public override data = [ + { personNumber: 0, fullName: 'John Brown', age: 20, isActive: true, birthday: new Date('08/08/2001') }, + { personNumber: 1, fullName: 'Ben Affleck', age: 30, isActive: false, birthday: new Date('08/08/1991') }, + { personNumber: 2, fullName: 'Tom Riddle', age: 50, isActive: true, birthday: new Date('08/08/1961') } + ]; +} + +@Component({ + template: ` + + `, + imports: [IgxGridComponent] +}) +export class NoColumnWidthGridComponent extends BasicGridComponent { + public override data = SampleTestData.generateNumberData(1000); +} + +@Component({ + template: GridTemplateStrings.declareGrid('', '', ColumnDefinitions.idFirstLastNameSortable, '', '', TemplateDefinitions.sortIconTemplates) + + ExternalTemplateDefinitions.sortIconTemplates, + imports: [IgxGridComponent, IgxColumnComponent, IgxIconComponent, IgxSortHeaderIconDirective, IgxSortAscendingHeaderIconDirective, IgxSortDescendingHeaderIconDirective] +}) +export class SortByParityComponent extends GridDeclaredColumnsComponent implements ISortingStrategy { + @ViewChild('sortIcon', {read: TemplateRef }) + public sortIconTemplate: TemplateRef; + + @ViewChild('sortAscIcon', {read: TemplateRef }) + public sortAscIconTemplate: TemplateRef; + + @ViewChild('sortDescIcon', {read: TemplateRef }) + public sortDescIconTemplate: TemplateRef; + + public sort(data: any[], fieldName: string, dir: SortingDirection) { + const key = fieldName; + const reverse = (dir === SortingDirection.Desc ? -1 : 1); + const cmpFunc = (obj1, obj2) => this.compareObjects(obj1, obj2, key, reverse); + return data.sort(cmpFunc); + } + protected sortByParity(a: any, b: any) { + return a % 2 === 0 ? -1 : b % 2 === 0 ? 1 : 0; + } + protected compareObjects(obj1, obj2, key: string, reverse: number) { + const a = obj1[key]; + const b = obj2[key]; + return reverse * this.sortByParity(a, b); + } +} + +@Component({ + template: GridTemplateStrings.declareGrid('', '', ColumnDefinitions.idFirstLastNameSortable, '', '', ''), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class SortByAnotherColumnComponent extends GridDeclaredColumnsComponent implements ISortingStrategy { + + public sort(data: any[]) { + const key = 'Name'; + const cmpFunc = (obj1, obj2) => this.compareObjects(obj1, obj2, key); + return data.sort(cmpFunc); + } + + protected compareObjects(obj1, obj2, key: string) { + const a = obj1[key].toLowerCase(); + const b = obj2[key].toLowerCase(); + return a > b ? 1 : a < b ? -1 : 0; + } +} + +@Component({ + template: GridTemplateStrings.declareGrid('[sortingOptions]="sortingOptions"', '', ColumnDefinitions.idFirstLastNameSortable, '', '', ''), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class SortOnInitComponent extends GridDeclaredColumnsComponent implements OnInit { + public sortingOptions: ISortingOptions = { mode: 'single' }; + public ngOnInit(): void { + this.grid.sortingExpressions = [{ fieldName: 'Name', dir: SortingDirection.Asc }]; + } +} + +@Component({ + template: ` + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class IgxGridFormattedValuesSortingComponent extends BasicGridComponent { + public override data = SampleTestData.gridProductData(); + public sortStrategy = new FormattedValuesSortingStrategy(); + + public formatProductName = (value: string) => { + if (!value) { + return 'a'; + } + const prefix = value.length > 10 ? 'a' : 'b'; + return `${prefix}-${value}`; + } + + public formatQuantity = (value: string) => { + if (!value) { + return 'c'; + } + const prefix = value.length > 10 ? 'c' : 'd'; + return `${prefix}-${value}`; + } +} + +@Component({ + template: ` + + @for (group of colGroups; track group.group) { + + @for (col of group.columns; track col.field) { + + } + + } + + `, + imports: [IgxGridComponent, IgxColumnLayoutComponent, IgxColumnComponent] +}) +export class MRLTestComponent { + @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + public colGroups: Array = [ + { + group: 'group1', + columns: [ + { field: 'CompanyName', rowStart: 1, colStart: 1, colEnd: 3, editable: true }, + { field: 'ContactName', rowStart: 2, colStart: 1, editable: false, width: '100px' }, + { field: 'ContactTitle', rowStart: 2, colStart: 2, editable: true, width: '100px' }, + { field: 'Address', rowStart: 3, colStart: 1, colEnd: 3, editable: true, width: '100px' } + ] + }, + { + group: 'group2', + columns: [ + { field: 'City', rowStart: 1, colStart: 1, colEnd: 3, rowEnd: 3, width: '400px', editable: true }, + { field: 'Region', rowStart: 3, colStart: 1, editable: true }, + { field: 'PostalCode', rowStart: 3, colStart: 2, editable: true } + ] + }, + { + group: 'group3', + columns: [ + { field: 'Country', rowStart: 1, colStart: 1 }, + { field: 'Phone', rowStart: 1, colStart: 2 }, + { field: 'Fax', rowStart: 2, colStart: 1, colEnd: 3, rowEnd: 4 } + ] + } + ]; + public data = SampleTestData.contactInfoDataFull(); +} + +@Component({ + template: ` + + @for (c of columns; track c.field) { + + + } + @if (paging) { + + } + + + + + + Adding Row + + +`, + imports: [ + IgxGridComponent, + IgxColumnComponent, + IgxActionStripComponent, + IgxGridEditingActionsComponent, + IgxPaginatorComponent, + IgxRowAddTextDirective + ] +}) +export class IgxAddRowComponent implements OnInit { + @ViewChild('actionStrip', { read: IgxActionStripComponent, static: true }) + public actionStrip: IgxActionStripComponent; + + @ViewChild('grid', { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + + public data: any[]; + public columns: any[]; + public paging = false; + + public ngOnInit() { + + this.columns = [ + { field: 'ID', width: '200px', hidden: false }, + { field: 'CompanyName', width: '200px' }, + { field: 'ContactName', width: '200px', pinned: false }, + { field: 'ContactTitle', width: '300px', pinned: false }, + ]; + + this.data = [ + { ID: 'ALFKI', CompanyName: 'Alfreds Futterkiste', ContactName: 'Maria Anders', ContactTitle: 'Sales Representative' }, + { ID: 'ANATR', CompanyName: 'Ana Trujillo Emparedados y helados', ContactName: 'Ana Trujillo', ContactTitle: 'Owner' }, + { ID: 'ANTON', CompanyName: 'Antonio Moreno Taquería', ContactName: 'Antonio Moreno', ContactTitle: 'Owner' }, + { ID: 'AROUT', CompanyName: 'Around the Horn', ContactName: 'Thomas Hardy', ContactTitle: 'Sales Representative' }, + { ID: 'BERGS', CompanyName: 'Berglunds snabbköp', ContactName: 'Christina Berglund', ContactTitle: 'Order Administrator' }, + { ID: 'BLAUS', CompanyName: 'Blauer See Delikatessen', ContactName: 'Hanna Moos', ContactTitle: 'Sales Representative' }, + { ID: 'BLONP', CompanyName: 'Blondesddsl père et fils', ContactName: 'Frédérique Citeaux', ContactTitle: 'Marketing Manager' }, + { ID: 'BOLID', CompanyName: 'Bólido Comidas preparadas', ContactName: 'Martín Sommer', ContactTitle: 'Owner' }, + { ID: 'BONAP', CompanyName: 'Bon app\'', ContactName: 'Laurence Lebihan', ContactTitle: 'Owner' }, + { ID: 'BOTTM', CompanyName: 'Bottom-Dollar Markets', ContactName: 'Elizabeth Lincoln', ContactTitle: 'Accounting Manager' }, + { ID: 'BSBEV', CompanyName: 'B\'s Beverages', ContactName: 'Victoria Ashworth', ContactTitle: 'Sales Representative', Address: 'Fauntleroy Circus', City: 'London', Region: null, PostalCode: 'EC2 5NT', Country: 'UK', Phone: '(171) 555-1212', Fax: null }, + { ID: 'CACTU', CompanyName: 'Cactus Comidas para llevar', ContactName: 'Patricio Simpson', ContactTitle: 'Sales Agent', Address: 'Cerrito 333', City: 'Buenos Aires', Region: null, PostalCode: '1010', Country: 'Argentina', Phone: '(1) 135-5555', Fax: '(1) 135-4892' }, + { ID: 'CENTC', CompanyName: 'Centro comercial Moctezuma', ContactName: 'Francisco Chang', ContactTitle: 'Marketing Manager', Address: 'Sierras de Granada 9993', City: 'México D.F.', Region: null, PostalCode: '05022', Country: 'Mexico', Phone: '(5) 555-3392', Fax: '(5) 555-7293' }, + { ID: 'CHOPS', CompanyName: 'Chop-suey Chinese', ContactName: 'Yang Wang', ContactTitle: 'Owner', Address: 'Hauptstr. 29', City: 'Bern', Region: null, PostalCode: '3012', Country: 'Switzerland', Phone: '0452-076545', Fax: null }, + { ID: 'COMMI', CompanyName: 'Comércio Mineiro', ContactName: 'Pedro Afonso', ContactTitle: 'Sales Associate', Address: 'Av. dos Lusíadas, 23', City: 'Sao Paulo', Region: 'SP', PostalCode: '05432-043', Country: 'Brazil', Phone: '(11) 555-7647', Fax: null }, + { ID: 'CONSH', CompanyName: 'Consolidated Holdings', ContactName: 'Elizabeth Brown', ContactTitle: 'Sales Representative', Address: 'Berkeley Gardens 12 Brewery', City: 'London', Region: null, PostalCode: 'WX1 6LT', Country: 'UK', Phone: '(171) 555-2282', Fax: '(171) 555-9199' }, + { ID: 'DRACD', CompanyName: 'Drachenblut Delikatessen', ContactName: 'Sven Ottlieb', ContactTitle: 'Order Administrator', Address: 'Walserweg 21', City: 'Aachen', Region: null, PostalCode: '52066', Country: 'Germany', Phone: '0241-039123', Fax: '0241-059428' }, + { ID: 'DUMON', CompanyName: 'Du monde entier', ContactName: 'Janine Labrune', ContactTitle: 'Owner', Address: '67, rue des Cinquante Otages', City: 'Nantes', Region: null, PostalCode: '44000', Country: 'France', Phone: '40.67.88.88', Fax: '40.67.89.89' }, + { ID: 'EASTC', CompanyName: 'Eastern Connection', ContactName: 'Ann Devon', ContactTitle: 'Sales Agent', Address: '35 King George', City: 'London', Region: null, PostalCode: 'WX3 6FW', Country: 'UK', Phone: '(171) 555-0297', Fax: '(171) 555-3373' }, + { ID: 'ERNSH', CompanyName: 'Ernst Handel', ContactName: 'Roland Mendel', ContactTitle: 'Sales Manager', Address: 'Kirchgasse 6', City: 'Graz', Region: null, PostalCode: '8010', Country: 'Austria', Phone: '7675-3425', Fax: '7675-3426' }, + { ID: 'FAMIA', CompanyName: 'Familia Arquibaldo', ContactName: 'Aria Cruz', ContactTitle: 'Marketing Assistant', Address: 'Rua Orós, 92', City: 'Sao Paulo', Region: 'SP', PostalCode: '05442-030', Country: 'Brazil', Phone: '(11) 555-9857', Fax: null }, + { ID: 'FISSA', CompanyName: 'FISSA Fabrica Inter. Salchichas S.A.', ContactName: 'Diego Roel', ContactTitle: 'Accounting Manager', Address: 'C/ Moralzarzal, 86', City: 'Madrid', Region: null, PostalCode: '28034', Country: 'Spain', Phone: '(91) 555 94 44', Fax: '(91) 555 55 93' }, + { ID: 'FOLIG', CompanyName: 'Folies gourmandes', ContactName: 'Martine Rancé', ContactTitle: 'Assistant Sales Agent', Address: '184, chaussée de Tournai', City: 'Lille', Region: null, PostalCode: '59000', Country: 'France', Phone: '20.16.10.16', Fax: '20.16.10.17' }, + { ID: 'FOLKO', CompanyName: 'Folk och fä HB', ContactName: 'Maria Larsson', ContactTitle: 'Owner', Address: 'Åkergatan 24', City: 'Bräcke', Region: null, PostalCode: 'S-844 67', Country: 'Sweden', Phone: '0695-34 67 21', Fax: null }, + { ID: 'FRANK', CompanyName: 'Frankenversand', ContactName: 'Peter Franken', ContactTitle: 'Marketing Manager', Address: 'Berliner Platz 43', City: 'München', Region: null, PostalCode: '80805', Country: 'Germany', Phone: '089-0877310', Fax: '089-0877451' }, + { ID: 'FRANR', CompanyName: 'France restauration', ContactName: 'Carine Schmitt', ContactTitle: 'Marketing Manager', Address: '54, rue Royale', City: 'Nantes', Region: null, PostalCode: '44000', Country: 'France', Phone: '40.32.21.21', Fax: '40.32.21.20' }, + { ID: 'FRANS', CompanyName: 'Franchi S.p.A.', ContactName: 'Paolo Accorti', ContactTitle: 'Sales Representative', Address: 'Via Monte Bianco 34', City: 'Torino', Region: null, PostalCode: '10100', Country: 'Italy', Phone: '011-4988260', Fax: '011-4988261' } + ]; + } +} + +@Component({ + template: GridTemplateStrings.declareGrid(` [hideGroupedColumns]="true"`, '', ColumnDefinitions.exportGroupedDataColumns), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class GridExportGroupedDataComponent extends BasicGridComponent { + public override data = SampleTestData.exportGroupedDataColumns(); +} + +@Component({ + template: GridTemplateStrings.declareGrid(`[moving]="true" height="1000px"`, '', ColumnDefinitions.multiColHeadersExportColumns), + imports: [IgxGridComponent, IgxColumnComponent, IgxColumnGroupComponent] +}) +export class MultiColumnHeadersExportComponent extends BasicGridComponent { + public override data = SampleTestData.contactInfoDataFull(); +} + +@Component({ + template: GridTemplateStrings.declareGrid(`height="1000px"`, '', ColumnDefinitions.multiColHeadersExportColumns), + imports: [IgxGridComponent, IgxColumnComponent, IgxColumnGroupComponent] +}) +export class GridWithThreeLevelsOfMultiColumnHeadersAndTwoRowsExportComponent extends BasicGridComponent { + public override data = SampleTestData.contactInfoDataTwoRecords(); +} + +@Component({ + template: ` + + + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class GridWithEmptyColumnsComponent { + @ViewChild('grid1', { static: true }) public grid: IgxGridComponent; + + public data = SampleTestData.personJobDataFull(); +} + +@Component({ + template: ` + + `, + imports: [IgxGridComponent] +}) +export class EmptyGridComponent { + @ViewChild('grid1', { static: true }) public grid: IgxGridComponent; +} + +/** Issue 9872 */ +@Component({ + template: GridTemplateStrings.declareGrid('', '', ColumnDefinitions.generatedWithDataType), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class ColumnsAddedOnInitComponent extends BasicGridComponent implements OnInit { + public columns = []; + public override data = []; + public ngOnInit(): void { + this.columns = [ + { field: 'CompanyName' }, + { field: 'ContactName' }, + { field: 'Address' }]; + this.data = SampleTestData.contactInfoData(); + + for (let i = 0; i < 3; i++) { + this.columns.push({ field: i.toString() }); //add columns for the horizon + this.data.forEach( + c => (c[i] = i * 2500) + ); //add random quantity to each customer for each period in the horizon + } + } +} + +@Component({ + template: GridTemplateStrings.declareGrid(' [hideGroupedColumns]="true"', '', ColumnDefinitions.generatedGroupableWithEnabledSummariesAndDataType), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class GroupedGridWithSummariesComponent extends BasicGridComponent implements OnInit { + public columns = []; + public override data = []; + + public ngOnInit(): void { + this.columns = [ + { dataType: 'string', field: 'City', groupable: true }, + { dataType: 'boolean', field: 'Shipped', groupable: true }, + { dataType: 'string', field: 'ContactTitle', groupable: true }, + { dataType: 'number', field: 'PTODays', groupable: false }, + ]; + + this.data = SampleTestData.contactInfoWithPTODaysData(); + } +} + +@Component({ + template: GridTemplateStrings.declareGrid('', '', ColumnDefinitions.generatedWithColumnBasedSummariesAndDataType), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class GridCurrencySummariesComponent extends BasicGridComponent implements OnInit { + public columns = []; + public override data = []; + + public ngOnInit(): void { + this.columns = [ + { dataType: 'string', field: 'ProductID', header: "Product ID", hasSummary: false }, + { dataType: 'string', field: 'ProductName', header: "Product Name", hasSummary: true }, + { dataType: 'currency', field: 'UnitPrice', header: "Price", hasSummary: true }, + { dataType: 'number', field: 'UnitsInStock', header: "Units In Stock", hasSummary: false }, + { dataType: 'boolean', field: 'Discontinued', hasSummary: true }, + { dataType: 'date', field: 'OrderDate', hasSummary: true }, + ]; + + this.data = SampleTestData.gridProductData(); + } +} + +class CustomSummaryWithNullAndZero { + public operate(): IgxSummaryResult[] { + const result = []; + + result.push({ + key: 'total', + label: null, + summaryResult: 0, + }); + + result.push({ + key: 'totalDiscontinued', + label: 0, + summaryResult: null, + }); + return result; + } +} + +class CustomSummaryWithUndefinedZeroAndValidNumber { + public operate(): IgxSummaryResult[] { + const result = []; + + result.push({ + key: 'total', + label: undefined, + summaryResult: 0, + }); + + result.push({ + key: 'totalDiscontinued', + label: 23, + summaryResult: undefined, + }); + return result; + } +} + +class CustomSummaryWithUndefinedAndNull { + public operate(): IgxSummaryResult[] { + const result = []; + + result.push({ + key: 'total', + label: undefined, + summaryResult: null, + }); + + result.push({ + key: 'totalDiscontinued', + label: null, + summaryResult: undefined, + }); + return result; + } +} + +class DiscontinuedSummary { + public operate(data?: any[], allData = [], fieldName = ''): IgxSummaryResult[] { + const result = []; + + result.push({ + key: 'total', + label: IgxNumberSummaryOperand.sum(data).toString(), + summaryResult: '', + }); + + result.push({ + key: 'totalDiscontinued', + label: IgxNumberSummaryOperand.sum( + allData.filter((rec) => rec['Discontinued']).map((r) => r[fieldName]) + ).toString(), + summaryResult: '', + }); + return result; + } +} + +class CustomSummaryWithDate { + public operate(): IgxSummaryResult[] { + const result = []; + + result.push({ + key: 'total', + label: new Date(2015, 11, 8), + summaryResult: null, + }); + + result.push({ + key: 'totalDiscontinued', + label: null, + summaryResult: new Date(2020, 4, 12), + }); + return result; + } +} + +@Component({ + selector: 'test-grid-custom-summary', + template: ` + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class GridCustomSummaryComponent extends BasicGridComponent implements OnInit { + public override data = []; + public customSummary = DiscontinuedSummary; + + public ngOnInit(): void { + this.data = SampleTestData.gridCustomSummaryData(); + } +} + +@Component({ + selector: 'test-grid-custom-summary-with-null-and-zero', + template: ` + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class GridCustomSummaryWithNullAndZeroComponent extends BasicGridComponent implements OnInit { + public override data = []; + public customSummary = CustomSummaryWithNullAndZero; + + public ngOnInit(): void { + this.data = SampleTestData.gridCustomSummaryData(); + } +} + +@Component({ + selector: 'test-grid-custom-summary-with-undefined-zero-and-valid-number', + template: ` + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class GridCustomSummaryWithUndefinedZeroAndValidNumberComponent extends BasicGridComponent implements OnInit { + public override data = []; + public customSummary = CustomSummaryWithUndefinedZeroAndValidNumber; + + public ngOnInit(): void { + this.data = SampleTestData.gridCustomSummaryData(); + } +} + +@Component({ + selector: 'test-grid-custom-summary-with-undefined-and-null', + template: ` + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class GridCustomSummaryWithUndefinedAndNullComponent extends BasicGridComponent implements OnInit { + public override data = []; + public customSummary = CustomSummaryWithUndefinedAndNull; + + public ngOnInit(): void { + this.data = SampleTestData.gridCustomSummaryData(); + } +} + +@Component({ + selector: 'test-grid-custom-summary-with-date', + template: ` + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class GridCustomSummaryWithDateComponent extends BasicGridComponent implements OnInit { + public override data = []; + public customSummary = CustomSummaryWithDate; + + public ngOnInit(): void { + this.data = SampleTestData.gridCustomSummaryData(); + } +} +export class ObjectCloneStrategy implements IDataCloneStrategy { + public clone(data: any): any { + const clonedData = {}; + if (data) { + const clone = Object.defineProperties({}, Object.getOwnPropertyDescriptors(data)); + for (const key in clone) { + clonedData[key] = clone[key] + } + + clonedData['cloned'] = true; + } + + return clonedData; + } +} + +@Component({ + template: ` + + + + + `, + imports: [IgxColumnComponent, IgxGridComponent] +}) +export class IgxGridRowEditingDefinedColumnsComponent extends BasicGridComponent { + public override data = SampleTestData.foodProductData(); +} + +@Component({ + template: ` + + + + + + + + + + + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxExcelStyleConditionalFilterComponent, IgxGridExcelStyleFilteringComponent, IgxExcelStyleFilterOperationsTemplateDirective] +}) +export class IgxGridConditionalFilteringComponent extends IgxGridFilteringComponent { +} diff --git a/projects/igniteui-angular/test-utils/grid-validation-samples.spec.ts b/projects/igniteui-angular/test-utils/grid-validation-samples.spec.ts new file mode 100644 index 00000000000..34e76960d3c --- /dev/null +++ b/projects/igniteui-angular/test-utils/grid-validation-samples.spec.ts @@ -0,0 +1,150 @@ +import { NgTemplateOutlet } from '@angular/common'; +import { Component, Input, ViewChild, Directive, TemplateRef } from '@angular/core'; +import { AbstractControl, FormsModule, NG_VALIDATORS, ReactiveFormsModule, ValidationErrors, ValidatorFn, Validators } from '@angular/forms'; +import { data } from '../../../src/app/shared/data'; +import { SampleTestData } from './sample-test-data.spec'; +import { GridColumnDataType } from 'igniteui-angular/core'; +import { IgxGridComponent } from 'igniteui-angular/grids/grid'; +import { IGX_GRID_VALIDATION_DIRECTIVES, IgxCellEditorTemplateDirective, IgxCellValidationErrorDirective, IgxColumnComponent } from 'igniteui-angular/grids/core'; +import { IgxTreeGridComponent } from 'igniteui-angular/grids/tree-grid'; + +@Directive({ + selector: '[igxAppForbiddenName]', + providers: [{ provide: NG_VALIDATORS, useExisting: ForbiddenValidatorDirective, multi: true }], + standalone: true +}) +export class ForbiddenValidatorDirective extends Validators { + @Input('igxAppForbiddenName') + public forbiddenName = ''; + + public validate(control: AbstractControl): ValidationErrors | null { + return this.forbiddenName ? this.forbiddenNameValidator(new RegExp(this.forbiddenName, 'i'))(control) + : null; + } + + public forbiddenNameValidator(nameRe: RegExp): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const forbidden = nameRe.test(control.value); + return forbidden ? { forbiddenName: { value: control.value } } : null; + }; + } +} + +@Component({ + template: ` + + @for (c of columns; track c.field) { + + + } + + `, + imports: [IgxGridComponent, IgxColumnComponent, ForbiddenValidatorDirective, IGX_GRID_VALIDATION_DIRECTIVES] +}) +export class IgxGridValidationTestBaseComponent { + public batchEditing = false; + public rowEditable = true; + public columns = [ + { field: 'ProductID', dataType: GridColumnDataType.String }, + { field: 'ProductName', dataType: GridColumnDataType.String }, + { field: 'UnitPrice', dataType: GridColumnDataType.String }, + { field: 'UnitsInStock', dataType: GridColumnDataType.Number } + ]; + public data = [...data]; + + @ViewChild('grid', { read: IgxGridComponent, static: true }) public grid: IgxGridComponent; +} + +@Component({ + template: ` + + @for (c of columns; track c.field) { + + + @if (cell.validation.errors?.['forbiddenName']) { +
    + This name is forbidden. +
    + } @else { + + } +
    +
    + } +
    + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxCellValidationErrorDirective, ForbiddenValidatorDirective, IGX_GRID_VALIDATION_DIRECTIVES] +}) +export class IgxGridValidationTestCustomErrorComponent extends IgxGridValidationTestBaseComponent { +} + +@Component({ + template: ` + + @for (c of columns; track c.field) { + + + } + + + + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxCellEditorTemplateDirective, ForbiddenValidatorDirective, IGX_GRID_VALIDATION_DIRECTIVES, ReactiveFormsModule, FormsModule] +}) +export class IgxGridCustomEditorsComponent extends IgxGridValidationTestCustomErrorComponent { + @ViewChild('modelTemplate', {read: TemplateRef }) + public modelTemplate: TemplateRef; + + @ViewChild('formControlTemplate', {read: TemplateRef }) + public formControlTemplate: TemplateRef; +} + +@Component({ + template: ` + + @for (c of columns; track c.field) { + + + @if (cell.validation.errors?.['forbiddenName']) { +
    + This name is forbidden. +
    + } @else { + + } +
    +
    + } +
    + `, + imports: [IgxTreeGridComponent, IgxColumnComponent, IgxCellValidationErrorDirective, ForbiddenValidatorDirective, IGX_GRID_VALIDATION_DIRECTIVES, NgTemplateOutlet] +}) +export class IgxTreeGridValidationTestComponent { + public batchEditing = false; + public rowEditable = true; + public columns = [ + { field: 'ID', dataType: GridColumnDataType.String }, + { field: 'Name', dataType: GridColumnDataType.String }, + { field: 'HireDate', dataType: GridColumnDataType.Date }, + { field: 'Age', dataType: GridColumnDataType.Number } + ]; + public data = [...SampleTestData.employeeSmallTreeData()]; + + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; +} diff --git a/projects/igniteui-angular/test-utils/helper-utils.spec.ts b/projects/igniteui-angular/test-utils/helper-utils.spec.ts new file mode 100644 index 00000000000..d2edea81111 --- /dev/null +++ b/projects/igniteui-angular/test-utils/helper-utils.spec.ts @@ -0,0 +1,121 @@ +import { EventEmitter, NgZone, Injectable } from '@angular/core'; +import { ComponentFixture } from '@angular/core/testing'; +import { GridType } from 'igniteui-angular/grids/core'; +import { IgxHierarchicalGridComponent } from 'igniteui-angular/grids/hierarchical-grid'; +import { Subscription } from 'rxjs'; + +/** + * Global beforeEach and afterEach checks to ensure test fails on specific warnings + * Use direct env because karma-parallel's wrap ignores these in secondary shards + * https://github.com/joeljeske/karma-parallel/issues/64 + */ +(jasmine.getEnv() as any).beforeEach(() => { + spyOn(console, 'warn').and.callThrough(); +}); + +(jasmine.getEnv() as any).afterEach(() => { + expect(console.warn) + .withContext('Components & tests should be free of @for track duplicated keys warnings') + .not.toHaveBeenCalledWith(jasmine.stringContaining('NG0955')); + expect(console.warn) + .withContext('Components & tests should be free of @for track DOM re-creation warnings') + .not.toHaveBeenCalledWith(jasmine.stringContaining('NG0956')); +}); + + +export let gridsubscriptions: Subscription [] = []; + +export const setupGridScrollDetection = (fixture: ComponentFixture, grid: GridType) => { + gridsubscriptions.push(grid.verticalScrollContainer.chunkLoad.subscribe(() => fixture.detectChanges())); + gridsubscriptions.push(grid.parentVirtDir.chunkLoad.subscribe(() => fixture.detectChanges())); + gridsubscriptions.push(grid.activeNodeChange.subscribe(() => grid.cdr.detectChanges())); + gridsubscriptions.push(grid.selected.subscribe(() => grid.cdr.detectChanges())); +}; + +export const setupHierarchicalGridScrollDetection = (fixture: ComponentFixture, hierarchicalGrid: IgxHierarchicalGridComponent) => { + setupGridScrollDetection(fixture, hierarchicalGrid); + + const existingChildren = hierarchicalGrid.gridAPI.getChildGrids(true); + existingChildren.forEach(child => setupGridScrollDetection(fixture, child)); + + const layouts = hierarchicalGrid.allLayoutList.toArray(); + layouts.forEach((layout) => { + gridsubscriptions.push(layout.gridCreated.subscribe(evt => { + setupGridScrollDetection(fixture, evt.grid); + })); + }); +}; + +export const clearGridSubs = () => { + gridsubscriptions.forEach(sub => sub.unsubscribe()); + gridsubscriptions = []; +} + +/** + * Sets element size as a inline style + */ +export function setElementSize(element: HTMLElement, size: string) { + element.style.setProperty('--ig-size', size); +} + +/** + * Checks if an element contains a given class and compares it with the expected result. + */ +export function hasClass(element: HTMLElement, className: string, expected: boolean) { + expect(element.classList.contains(className)).toBe(expected); +} + +type YMD = `${string}-${string}-${string}`; + +/** Convert a YMD string to local timezone date */ +export function ymd(str: YMD): Date { + return new Date(str + 'T00:00'); +} + + +@Injectable() +export class TestNgZone extends NgZone { + public override onStable: EventEmitter = new EventEmitter(false); + + constructor() { + super({enableLongStackTrace: false, shouldCoalesceEventChangeDetection: false}); + } + + public override run(fn: () => void): any { + return fn(); + } + + public override runOutsideAngular(fn: () => void): any { + return fn(); + } + + public simulateOnStable(): void { + this.onStable.emit(null); + } +} + +/* eslint-disable no-console */ +// TODO: enable on re-run by selecting enable debug logging +// https://docs.github.com/en/actions/monitoring-and-troubleshooting-workflows/troubleshooting-workflows/enabling-debug-logging +const shardLogging = false; +if (shardLogging) { + const myReporter = { + suiteStarted: function(result) { + const id = new URLSearchParams(window.parent.location.search).get('id'); + console.log(`[${id}] Suite started: ${result.fullName}`); + }, + suiteDone: function(result) { + const id = new URLSearchParams(window.parent.location.search).get('id'); + console.log(`[${id}] Suite: ${result.fullName} has ${result.status}`); + for (const expectation of result.failedExpectations) { + console.log('Suite ' + expectation.message); + console.log(expectation.stack); + } + var memory = (performance as any).memory; + console.log(`[${id}] totalJSHeapSize: ${memory['totalJSHeapSize']} usedJSHeapSize: ${memory['usedJSHeapSize']} jsHeapSizeLimit: ${memory['jsHeapSizeLimit']}`); + if (memory['totalJSHeapSize'] >= memory['jsHeapSizeLimit'] ) + console.log( '--------------------Heap Size limit reached!!!-------------------'); + }, + }; + jasmine.getEnv().addReporter(myReporter); +} diff --git a/projects/igniteui-angular/test-utils/hierarchical-grid-components.spec.ts b/projects/igniteui-angular/test-utils/hierarchical-grid-components.spec.ts new file mode 100644 index 00000000000..081cd33f2a8 --- /dev/null +++ b/projects/igniteui-angular/test-utils/hierarchical-grid-components.spec.ts @@ -0,0 +1,750 @@ +import { Component, ViewChild, OnInit, TemplateRef } from '@angular/core'; +import { SampleTestData } from './sample-test-data.spec'; +import { HIERARCHICAL_SAMPLE_DATA, HIERARCHICAL_SAMPLE_DATA_SHORT } from 'src/app/shared/sample-data'; +import { IgxButtonDirective } from '../directives/src/directives/button/button.directive'; +import { IgxCheckboxComponent } from '../checkbox/src/checkbox/checkbox.component'; +import { IgxPaginatorComponent, IgxPaginatorContentDirective } from '../paginator/src/paginator/paginator.component'; +import { IgxIconComponent } from '../icon/src/icon/icon.component'; +import { IgxPaginatorDirective } from '../paginator/src/paginator/paginator-interfaces'; +import { ColumnPinningPosition, ColumnType, IgxSummaryResult } from 'igniteui-angular/core'; +import { IgxActionStripComponent } from 'igniteui-angular/action-strip'; +import { IgxHierarchicalGridComponent, IgxRowIslandComponent } from 'igniteui-angular/grids/hierarchical-grid'; +import { IgxAdvancedFilteringDialogComponent, IgxCellHeaderTemplateDirective, IgxColumnComponent, IgxColumnGroupComponent, IgxGridEditingActionsComponent, IgxGridPinningActionsComponent, IgxGridToolbarComponent, IgxGridToolbarDirective, IgxHeadSelectorDirective, IgxNumberSummaryOperand, IgxRowSelectorDirective, IPinningConfig, RowPinningPosition } from 'igniteui-angular/grids/core'; +import { IgxHierarchicalTransactionServiceFactory } from 'igniteui-angular/grids/hierarchical-grid/src/hierarchical-grid-base.directive'; + +@Component({ + selector: 'igx-hierarchical-grid-test-base', + template: ` + + + + + + + @if (paging) { + + } + + + + +
    + ID + lock +
    +
    +
    + + + + + + + + + + + +
    +
    `, + imports: [ + IgxHierarchicalGridComponent, + IgxColumnComponent, + IgxColumnGroupComponent, + IgxRowIslandComponent, + IgxPaginatorComponent, + IgxGridToolbarComponent, + IgxIconComponent, + IgxCellHeaderTemplateDirective, + ] +}) +export class IgxHierarchicalGridTestBaseComponent { + @ViewChild('hierarchicalGrid', { read: IgxHierarchicalGridComponent, static: true }) + public hgrid: IgxHierarchicalGridComponent; + + @ViewChild('rowIsland', { read: IgxRowIslandComponent, static: true }) + public rowIsland: IgxRowIslandComponent; + + @ViewChild('rowIsland2', { read: IgxRowIslandComponent, static: true }) + public rowIsland2: IgxRowIslandComponent; + + public data; + public pinningConfig: IPinningConfig = { columns: ColumnPinningPosition.Start, rows: RowPinningPosition.Top }; + public paging = false; + + constructor() { + // 3 level hierarchy + this.data = SampleTestData.generateHGridData(40, 3); + } + + public pinColumn(column: ColumnType) { + if (column.pinned) { + column.unpin(); + } else { + column.pin(); + } + } +} + +@Component({ + selector: 'igx-hierarchical-grid-with-transaction-provider', + template: ` + + + + + + + @if (paging) { + + } + + + + +
    + ID + lock +
    +
    +
    + + + + + + + + + + + +
    +
    `, + providers: [IgxHierarchicalTransactionServiceFactory], + imports: [ + IgxHierarchicalGridComponent, + IgxColumnComponent, + IgxColumnGroupComponent, + IgxRowIslandComponent, + IgxPaginatorComponent, + IgxGridToolbarComponent, + IgxIconComponent, + ] +}) +export class IgxHierarchicalGridWithTransactionProviderComponent { + @ViewChild('hierarchicalGrid', { read: IgxHierarchicalGridComponent, static: true }) + public hgrid: IgxHierarchicalGridComponent; + + @ViewChild('rowIsland', { read: IgxRowIslandComponent, static: true }) + public rowIsland: IgxRowIslandComponent; + + @ViewChild('rowIsland2', { read: IgxRowIslandComponent, static: true }) + public rowIsland2: IgxRowIslandComponent; + + public data; + public pinningConfig: IPinningConfig = { columns: ColumnPinningPosition.Start, rows: RowPinningPosition.Top }; + public paging = false; + + constructor() { + // 3 level hierarchy + this.data = SampleTestData.generateHGridData(40, 3); + } + + public pinColumn(column: IgxColumnComponent) { + if (column.pinned) { + column.unpin(); + } else { + column.pin(); + } + } +} + +@Component({ + template: ` + + + + + + + + + + + + + + + `, + imports: [IgxHierarchicalGridComponent, IgxColumnComponent, IgxRowIslandComponent] +}) +export class IgxHierarchicalGridRowSelectionComponent { + @ViewChild('hierarchicalGrid', { read: IgxHierarchicalGridComponent, static: true }) public hgrid: IgxHierarchicalGridComponent; + @ViewChild('rowIsland', { read: IgxRowIslandComponent, static: true }) public rowIsland: IgxRowIslandComponent; + @ViewChild('rowIsland2', { read: IgxRowIslandComponent, static: true }) public rowIsland2: IgxRowIslandComponent; + public data; + public selectedRows = []; + + constructor() { + // 3 level hierarchy + this.data = SampleTestData.generateHGridData(5, 3); + } +} +@Component({ + template: ` + + + + + + + + + + + + + + + `, + imports: [IgxHierarchicalGridComponent, IgxColumnComponent, IgxRowIslandComponent] +}) +export class IgxHierarchicalGridRowSelectionTestSelectRowOnClickComponent { + @ViewChild('hierarchicalGrid', { read: IgxHierarchicalGridComponent, static: true }) public hgrid: IgxHierarchicalGridComponent; + @ViewChild('rowIsland', { read: IgxRowIslandComponent, static: true }) public rowIsland: IgxRowIslandComponent; + @ViewChild('rowIsland2', { read: IgxRowIslandComponent, static: true }) public rowIsland2: IgxRowIslandComponent; + public data; + public selectedRows = []; + + constructor() { + // 3 level hierarchy + this.data = SampleTestData.generateHGridData(5, 3); + } +} + +@Component({ + template: ` + + + + + + + + + + + + + + + `, + imports: [IgxHierarchicalGridComponent, IgxColumnComponent, IgxRowIslandComponent] +}) +export class IgxHierarchicalGridRowSelectionNoTransactionsComponent { + @ViewChild('hierarchicalGrid', { read: IgxHierarchicalGridComponent, static: true }) public hgrid: IgxHierarchicalGridComponent; + @ViewChild('rowIsland', { read: IgxRowIslandComponent, static: true }) public rowIsland: IgxRowIslandComponent; + @ViewChild('rowIsland2', { read: IgxRowIslandComponent, static: true }) public rowIsland2: IgxRowIslandComponent; + public data; + + constructor() { + // 3 level hierarchy + this.data = SampleTestData.generateHGridData(5, 3); + } +} + +@Component({ + template: ` + + + + + + + + + + + + + + {{ rowContext.index }} + + + + + + + + + + {{ rowContext.index }} + + + + `, + imports: [ + IgxHierarchicalGridComponent, + IgxColumnComponent, + IgxRowIslandComponent, + IgxCheckboxComponent, + IgxPaginatorComponent, + IgxPaginatorDirective, + IgxRowSelectorDirective, + IgxHeadSelectorDirective + ] +}) +export class IgxHierarchicalGridCustomSelectorsComponent implements OnInit { + @ViewChild('hGridCustomSelectors', { read: IgxHierarchicalGridComponent, static: true }) + public hGrid: IgxHierarchicalGridComponent; + + @ViewChild('rowIsland1', { read: IgxRowIslandComponent, static: true }) + public firstLevelChild: IgxRowIslandComponent; + + public data = []; + + public ngOnInit(): void { + // 2 level hierarchy + this.data = SampleTestData.generateHGridData(40, 2); + } + + public handleHeadSelectorClick(headContext) { + if (headContext.totalCount !== headContext.selectedCount) { + headContext.selectAll(); + } else { + headContext.deselectAll(); + } + } + + public handleRowSelectorClick(rowContext) { + if (rowContext.selected) { + rowContext.deselect(); + } else { + rowContext.select(); + } + } +} + +@Component({ + template: ` + + + + + + + + + + + + + + + `, + imports: [IgxHierarchicalGridComponent, IgxGridToolbarComponent, IgxGridToolbarDirective, IgxRowIslandComponent, IgxButtonDirective] +}) +export class IgxHierarchicalGridTestCustomToolbarComponent extends IgxHierarchicalGridTestBaseComponent { } + +@Component({ + template: ` + + + + + + + + + + + + + + + `, + imports: [IgxHierarchicalGridComponent, IgxGridToolbarComponent, IgxRowIslandComponent, IgxButtonDirective] +}) +export class IgxHierarchicalGridTestInputToolbarComponent extends IgxHierarchicalGridTestBaseComponent { } + +@Component({ + template: ` + + + + + + + + + + + + + + + + `, + imports: [IgxHierarchicalGridComponent, IgxGridToolbarComponent, IgxPaginatorComponent, IgxPaginatorContentDirective, IgxRowIslandComponent, IgxButtonDirective] +}) +export class IgxHierarchicalGridTestInputPaginatorComponent extends IgxHierarchicalGridTestBaseComponent { } + +@Component({ + template: ` + + + + + + + + + + + + + + `, + imports: [ + IgxHierarchicalGridComponent, + IgxActionStripComponent, + IgxGridPinningActionsComponent, + IgxGridEditingActionsComponent, + IgxRowIslandComponent + ] +}) +export class IgxHierarchicalGridActionStripComponent extends IgxHierarchicalGridTestBaseComponent { + @ViewChild('actionStrip1', { read: IgxActionStripComponent, static: true }) + public actionStripRoot: IgxActionStripComponent; + + @ViewChild('actionStrip2', { read: IgxActionStripComponent, static: true }) + public actionStripChild: IgxActionStripComponent; +} + +@Component({ + template: ` + + + + + + + + + + + + + + + + val +
    + {{val}} +
    +
    + + `, + imports: [IgxHierarchicalGridComponent, IgxColumnComponent, IgxRowIslandComponent, IgxAdvancedFilteringDialogComponent] +}) +export class IgxHierGridExternalAdvancedFilteringComponent extends IgxHierarchicalGridTestBaseComponent { + @ViewChild('hierarchicalGrid', { read: IgxHierarchicalGridComponent, static: true }) + public override hgrid: IgxHierarchicalGridComponent; + @ViewChild('customCell', { static: true }) + public customCell!: TemplateRef; + public override data = SampleTestData.generateHGridData(5, 3); +} + +@Component({ + template: ` + + + + + + + + + + + + @if(shouldDisplayArtist) { + + } + + + + + + + + + + + + + + + + + + + + + `, + imports: [IgxHierarchicalGridComponent, IgxColumnComponent, IgxRowIslandComponent] +}) +export class IgxHierarchicalGridExportComponent { + @ViewChild('hierarchicalGrid', { read: IgxHierarchicalGridComponent, static: true }) public hGrid: IgxHierarchicalGridComponent; + public data = SampleTestData.hierarchicalGridExportData(); + public shouldDisplayArtist = false; +} + + +@Component({ + template: ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `, + imports: [IgxHierarchicalGridComponent, IgxColumnComponent, IgxColumnGroupComponent, IgxRowIslandComponent] +}) +export class IgxHierarchicalGridMultiColumnHeadersExportComponent { + @ViewChild('hierarchicalGrid', { read: IgxHierarchicalGridComponent, static: true }) public hGrid: IgxHierarchicalGridComponent; + public data = HIERARCHICAL_SAMPLE_DATA; +} + +@Component({ + template: ` + + + + + + + + + + + + + + + + + `, + imports: [IgxHierarchicalGridComponent, IgxColumnComponent, IgxColumnGroupComponent, IgxRowIslandComponent] +}) +export class IgxHierarchicalGridMCHCollapsibleComponent { + @ViewChild('hierarchicalGrid', { read: IgxHierarchicalGridComponent, static: true }) public hGrid: IgxHierarchicalGridComponent; + public data = HIERARCHICAL_SAMPLE_DATA; +} + +@Component({ + template: ` + + + + + + + + + + + + + + + + + + + + + + + + + + `, + imports: [IgxHierarchicalGridComponent, IgxColumnComponent, IgxColumnGroupComponent, IgxRowIslandComponent] +}) +export class IgxHierarchicalGridMultiColumnHeaderIslandsExportComponent { + @ViewChild('hierarchicalGrid', { read: IgxHierarchicalGridComponent, static: true }) public hGrid: IgxHierarchicalGridComponent; + public data = HIERARCHICAL_SAMPLE_DATA_SHORT; +} + +@Component({ + template: ` + + + + + + + + + + + + + + + + + + + + + `, + imports: [IgxHierarchicalGridComponent, IgxColumnComponent, IgxRowIslandComponent] +}) +export class IgxHierarchicalGridSummariesExportComponent { + @ViewChild('hierarchicalGrid', { read: IgxHierarchicalGridComponent, static: true }) public hGrid: IgxHierarchicalGridComponent; + public data = SampleTestData.hierarchicalGridExportData(); + + public mySummary = MySummary; + public myChildSummary = MyChildSummary; +} + + +class MySummary { + + public operate(data?: any[]): IgxSummaryResult[] { + const result = []; + result.push( + { + key: 'min', + label: 'Min', + summaryResult: IgxNumberSummaryOperand.min(data) + }, + { + key: 'max', + label: 'Max', + summaryResult: IgxNumberSummaryOperand.max(data) + }, + { + key: 'avg', + label: 'Avg', + summaryResult: IgxNumberSummaryOperand.average(data) + }); + return result; + } +} +class MyChildSummary { + + public operate(data?: any[]): IgxSummaryResult[] { + const result = []; + result.push( + { + key: 'count', + label: 'Count', + summaryResult: IgxNumberSummaryOperand.count(data) + }); + return result; + } +} + +@Component({ + template: ` + + + + + + + + + + + + + + + + + + + + + + + + + + + `, + imports: [IgxHierarchicalGridComponent, IgxColumnComponent, IgxRowIslandComponent] +}) +export class IgxHierarchicalGridDefaultComponent { + @ViewChild('hierarchicalGrid', { read: IgxHierarchicalGridComponent, static: true }) + public hierarchicalGrid: IgxHierarchicalGridComponent; + + public data; + + constructor() { + this.data = SampleTestData.hierarchicalGridSingersFullData(); + } +} diff --git a/projects/igniteui-angular/test-utils/hierarchical-grid-functions.spec.ts b/projects/igniteui-angular/test-utils/hierarchical-grid-functions.spec.ts new file mode 100644 index 00000000000..98f7138dcea --- /dev/null +++ b/projects/igniteui-angular/test-utils/hierarchical-grid-functions.spec.ts @@ -0,0 +1,71 @@ +import { DebugElement } from '@angular/core'; +import { ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { IgxRowDirective } from 'igniteui-angular/grids/core'; +import { IgxHierarchicalRowComponent } from 'igniteui-angular/grids/hierarchical-grid/src/hierarchical-row.component'; + +const HIERARCHICAL_GRID_TAG = 'igx-hierarchical-grid'; +const EXPANDER_CLASS = 'igx-grid__hierarchical-expander'; +const SCROLL_TBODY_CLASS = 'igx-grid__tbody-scrollbar'; + +export class HierarchicalGridFunctions { + + /** + * Gets all hierarchical grid row components as an array of DebugElement + * + * @param fix the ComponentFixture to search + */ + public static getHierarchicalRows(fix: ComponentFixture): DebugElement[] { + return fix.debugElement.queryAll(By.directive(IgxHierarchicalRowComponent)); + } + + /** + * Gets all hierarchical grid expanders as an array of DebugElement + * + * @param fix the ComponentFixture to search + */ + public static getExpanders(fix: ComponentFixture): DebugElement[] { + return fix.debugElement.queryAll(By.css('.' + EXPANDER_CLASS)); + } + + /** + * Gets the first hierarchical grid expander as an HTMLElement + * + * @param fix the ComponentFixture to search + * @param modifier css search modifier + */ + public static getExpander(fix: ComponentFixture, modifier?: string): HTMLElement { + return fix.nativeElement.querySelector(`.${EXPANDER_CLASS}${modifier || ''}`); + } + + /** + * Returns if there is an expander element in the specified row + * + * @param row the row instance to check for expander + */ + public static hasExpander(row: IgxRowDirective): boolean { + return row.nativeElement.children[0].classList.contains(EXPANDER_CLASS); + } + + /** + * Returns if the specified element looks like an expander based on specific class affix + * + * @param element The element to check + * @param modifier The modifier to the base class + */ + public static isExpander(element: HTMLElement, modifier?: string): boolean { + return element.classList.contains(`${EXPANDER_CLASS}${modifier || ''}`); + } + + /** + * Gets the main wrapper element of the vertical scrollbar. + * + * @param fix the ComponentFixture to search + */ + public static getVerticalScrollWrapper(fix: ComponentFixture, gridID): HTMLElement { + const gridDebugEl = fix.debugElement.query(By.css(HIERARCHICAL_GRID_TAG + `[id='${gridID}'`)); + const scrollWrappers = gridDebugEl.queryAll(By.css('.' + SCROLL_TBODY_CLASS)); + // Return the last element since the scrollbar for the targeted grid is after all children that also have scrollbars + return scrollWrappers[scrollWrappers.length - 1].nativeElement; + } +} diff --git a/projects/igniteui-angular/test-utils/list-components.spec.ts b/projects/igniteui-angular/test-utils/list-components.spec.ts new file mode 100644 index 00000000000..05c7b2095b3 --- /dev/null +++ b/projects/igniteui-angular/test-utils/list-components.spec.ts @@ -0,0 +1,244 @@ +import { Component, ViewChild } from '@angular/core'; +import { IgxForOfDirective } from '../directives/src/directives/for-of/for_of.directive'; +import { IgxIconComponent } from '../icon/src/icon/icon.component'; +import { IgxDataLoadingTemplateDirective, IgxEmptyListTemplateDirective, IgxListActionDirective, IgxListComponent, IgxListItemComponent, IgxListItemLeftPanningTemplateDirective, IgxListItemRightPanningTemplateDirective, IgxListLineDirective, IgxListLineSubTitleDirective, IgxListLineTitleDirective, IgxListThumbnailDirective } from 'igniteui-angular/list'; + +@Component({ + template: ` +
    + + + face + Text-Line +

    Title

    +

    Subtitle

    + share +
    +
    +
    + `, + imports: [ + IgxListComponent, + IgxListItemComponent, + IgxIconComponent, + IgxListThumbnailDirective, + IgxListLineDirective, + IgxListLineTitleDirective, + IgxListLineSubTitleDirective, + IgxListActionDirective + ] +}) + +export class ListDirectivesComponent { +} + +@Component({ + template: ` +
    + + Item 1 + Item 2 + Item 3 + +
    `, + imports: [IgxListComponent, IgxListItemComponent] +}) +export class BasicListComponent { + @ViewChild(IgxListComponent, { static: true }) public list: IgxListComponent; + @ViewChild('wrapper', { static: true }) public wrapper; +} + +@Component({ + template: ` +
    + + Header + Item 1 + Item 2 + Item 3 + +
    `, + imports: [IgxListComponent, IgxListItemComponent] +}) +export class ListWithHeaderComponent extends BasicListComponent { +} + +@Component({ + template: ` +
    + + Header + Item 1 + Item 2 + Item 3 + +
    `, + imports: [IgxListComponent, IgxListItemComponent] +}) +export class ListWithSelectedItemComponent extends BasicListComponent { +} + +@Component({ + template: ` +
    + + Item 1 + Item 2 + Item 3 + +
    `, + imports: [IgxListComponent, IgxListItemComponent] +}) +export class ListWithPanningComponent extends BasicListComponent { + public allowRightPanning = true; + public allowLeftPanning = true; +} + +@Component({ + template: ` +
    + + +
    `, + imports: [IgxListComponent] +}) +export class EmptyListComponent extends BasicListComponent { +} + +@Component({ + template: ` +
    + + +

    Custom no items message.

    +
    +
    +
    `, + imports: [IgxListComponent, IgxEmptyListTemplateDirective] +}) +export class CustomEmptyListComponent extends BasicListComponent { +} + +@Component({ + template: ` +
    + + +
    `, + imports: [IgxListComponent] +}) +export class ListLoadingComponent extends BasicListComponent { + public isLoading = true; +} + +@Component({ + template: ` +
    + + +

    Loading data...

    +
    +
    +
    `, + imports: [IgxListComponent, IgxDataLoadingTemplateDirective] +}) +export class ListCustomLoadingComponent extends ListLoadingComponent { +} + +@Component({ + template: ` +
    + + Header 1 + Item 1 + Header 2 + Item 2 + Item 3 + +
    `, + selector: 'igx-list-with-headers', + imports: [IgxListComponent, IgxListItemComponent] +}) +export class TwoHeadersListComponent extends ListWithPanningComponent { +} + +@Component({ + template: ` +
    + + Header 1 + Item 1 + Header 2 + Item 2 + Item 3 + +
    `, + selector: 'igx-list-with-headers-no-panning', + imports: [IgxListComponent, IgxListItemComponent] +}) +export class TwoHeadersListNoPanningComponent extends ListWithHeaderComponent { +} + +@Component({ + template: ` +
    + + +
    Left
    +
    + +
    Right
    +
    + Header + Item 1 + Item 2 + Item 3 +
    +
    `, + imports: [IgxListComponent, IgxListItemComponent, IgxListItemLeftPanningTemplateDirective, IgxListItemRightPanningTemplateDirective] +}) +export class ListWithPanningTemplatesComponent extends ListWithPanningComponent { +} + +@Component({ + template: ` + +
    + +
    + {{ item.key }}  + {{ item.name }} +
    +
    +
    +
    `, + styles: [`.item-container { + display: flex; + }`], + imports: [IgxListComponent, IgxListItemComponent, IgxForOfDirective] +}) +export class ListWithIgxForAndScrollingComponent { + @ViewChild('forOfList', {read: IgxListComponent, static: true }) + public forOfList: IgxListComponent; + + @ViewChild(IgxForOfDirective) + public igxFor: IgxForOfDirective; + + public data = [ + {key: 1, name: 'John'}, + {key: 2, name: 'Brian'}, + {key: 3, name: 'Christian'}, + {key: 4, name: 'Mark'}, + {key: 5, name: 'William'}, + {key: 6, name: 'Dave'}, + {key: 7, name: 'Riley'}, + {key: 8, name: 'Terrance'}, + {key: 9, name: 'Erick'}, + {key: 10, name: 'Victor'}, + {key: 11, name: 'Rick'}, + {key: 12, name: 'Stefan'} + ]; +} diff --git a/projects/igniteui-angular/test-utils/pivot-grid-functions.spec.ts b/projects/igniteui-angular/test-utils/pivot-grid-functions.spec.ts new file mode 100644 index 00000000000..3f9f455d4c1 --- /dev/null +++ b/projects/igniteui-angular/test-utils/pivot-grid-functions.spec.ts @@ -0,0 +1,23 @@ +import { IPivotGridRecord } from 'igniteui-angular/grids/core'; + +export class PivotGridFunctions { + + public static checkUniqueValuesCount(data: any[], value: string, count:number) { + expect(data.filter(x => x === data).length).toBe(count); + } + + public static getDimensionValues(records: IPivotGridRecord[]) { + return records.map(x => this.transformMapToObject(x.dimensionValues)); + } + + public static getAggregationValues(records: IPivotGridRecord[]) { + return records.map(x => this.transformMapToObject(x.aggregationValues)); + } + public static transformMapToObject(map: Map) { + const obj = {}; + map.forEach((value, key) => { + obj[key] = value; + }); + return obj; + } +} diff --git a/projects/igniteui-angular/test-utils/pivot-grid-samples.spec.ts b/projects/igniteui-angular/test-utils/pivot-grid-samples.spec.ts new file mode 100644 index 00000000000..c553226cc3c --- /dev/null +++ b/projects/igniteui-angular/test-utils/pivot-grid-samples.spec.ts @@ -0,0 +1,545 @@ +import { Component, TemplateRef, ViewChild } from '@angular/core'; +import { IgxGridStateDirective, IgxPivotNumericAggregate, IPivotConfiguration, IPivotGridColumn, IPivotGridRecord, PivotAggregation } from 'igniteui-angular/grids/core'; +import { IgxPivotDataSelectorComponent, IgxPivotGridComponent } from 'igniteui-angular/grids/pivot-grid'; + +@Component({ + template: ` +
    + + + + +
    + + Custom empty template. + + + {{value.member}} + + `, + imports: [IgxPivotGridComponent, IgxPivotDataSelectorComponent] +}) +export class IgxPivotGridTestBaseComponent { + public defaultExpand = true; + @ViewChild('emptyTemplate', { read: TemplateRef, static: true }) public emptyTemplate: TemplateRef; + @ViewChild('chipValue', { read: TemplateRef, static: true }) public chipValueTemplate: TemplateRef; + @ViewChild('grid', { read: IgxPivotGridComponent, static: true }) public pivotGrid: IgxPivotGridComponent; + @ViewChild('selector', { read: IgxPivotDataSelectorComponent, static: true }) public dataSelector: IgxPivotDataSelectorComponent; + public data; + + public cellClasses; + + public pivotConfigHierarchy: IPivotConfiguration; + public filterExpandState = true; + public columnExpandState = true; + public rowExpandState = true; + public valueExpandState = true; + + constructor() { + this.data = [ + { + ProductCategory: 'Clothing', UnitPrice: 12.81, SellerName: 'Stanley', + Country: 'Bulgaria', Date: '01/01/2021', UnitsSold: 282 + }, + { + ProductCategory: 'Clothing', UnitPrice: 49.57, SellerName: 'Elisa', + Country: 'USA', Date: '01/05/2019', UnitsSold: 296 + }, + { + ProductCategory: 'Bikes', UnitPrice: 3.56, SellerName: 'Lydia', + Country: 'Uruguay', Date: '01/06/2020', UnitsSold: 68 + }, + { + ProductCategory: 'Accessories', UnitPrice: 85.58, SellerName: 'David', + Country: 'USA', Date: '04/07/2021', UnitsSold: 293 + }, + { + ProductCategory: 'Components', UnitPrice: 18.13, SellerName: 'John', + Country: 'USA', Date: '12/08/2021', UnitsSold: 240 + }, + { + ProductCategory: 'Clothing', UnitPrice: 68.33, SellerName: 'Larry', + Country: 'Uruguay', Date: '05/12/2020', UnitsSold: 456 + }, + { + ProductCategory: 'Clothing', UnitPrice: 16.05, SellerName: 'Walter', + Country: 'Bulgaria', Date: '02/19/2020', UnitsSold: 492 + }]; + + this.cellClasses = { + test: this.callback, + test2: this.callback1 + }; + + this.pivotConfigHierarchy = { + columns: [{ + memberName: 'Country', + enabled: true + }, + ], + rows: [{ + memberName: 'All', + memberFunction: () => 'All', + enabled: true, + childLevel: { + memberName: 'ProductCategory', + memberFunction: (data) => data.ProductCategory, + enabled: true + } + }], + values: [ + { + member: 'UnitsSold', + aggregate: { + aggregator: IgxPivotNumericAggregate.sum, + key: 'SUM', + label: 'Sum', + }, + enabled: true, + // dataType: 'currency', + formatter: (value) => value ? value + '$' : undefined, + styles: this.cellClasses + }, + { + member: 'UnitPrice', + aggregate: { + aggregator: IgxPivotNumericAggregate.sum, + key: 'SUM', + label: 'Sum', + }, + enabled: true, + dataType: 'currency' + } + ], + filters: [] + }; + } + public callback = (rowData: IPivotGridRecord, columnData: IPivotGridColumn) => rowData.aggregationValues.get(columnData.field) >= 5; + public callback1 = (rowData: IPivotGridRecord, columnData: IPivotGridColumn) => rowData.aggregationValues.get(columnData.field) < 5; +} + +@Component({ + template: ` + + `, + imports: [IgxPivotGridComponent] +}) +export class IgxPivotGridTestComplexHierarchyComponent extends IgxPivotGridTestBaseComponent { + @ViewChild('grid', { read: IgxPivotGridComponent, static: true }) public override pivotGrid: IgxPivotGridComponent; + + public override defaultExpand = true; + constructor() { + super(); + this.data = [ + { + ProductCategory: 'Clothing', UnitPrice: 12.81, SellerName: 'Stanley Brooker', + Country: 'Bulgaria', City: 'Plovdiv', Date: '01/01/2012', UnitsSold: 282 + }, + { + ProductCategory: 'Clothing', UnitPrice: 49.57, SellerName: 'Elisa Longbottom', + Country: 'US', City: 'New York', Date: '01/05/2013', UnitsSold: 296 + }, + { + ProductCategory: 'Bikes', UnitPrice: 3.56, SellerName: 'Lydia Burson', + Country: 'Uruguay', City: 'Ciudad de la Costa', Date: '01/06/2011', UnitsSold: 68 + }, + { + ProductCategory: 'Accessories', UnitPrice: 85.58, SellerName: 'David Haley', + Country: 'UK', City: 'London', Date: '04/07/2012', UnitsSold: 293 + }, + { + ProductCategory: 'Components', UnitPrice: 18.13, SellerName: 'John Smith', + Country: 'Japan', City: 'Yokohama', Date: '12/08/2012', UnitsSold: 240 + }, + { + ProductCategory: 'Clothing', UnitPrice: 68.33, SellerName: 'Larry Lieb', + Country: 'Uruguay', City: 'Ciudad de la Costa', Date: '05/12/2011', UnitsSold: 456 + }, + { + ProductCategory: 'Components', UnitPrice: 16.05, SellerName: 'Walter Pang', + Country: 'Bulgaria', City: 'Sofia', Date: '02/19/2013', UnitsSold: 492 + }]; + this.pivotConfigHierarchy = { + columns: [ + + { + memberName: 'Country', + enabled: true + } + ] + , + rows: [{ + memberName: 'All cities', + memberFunction: () => 'All Cities', + enabled: true, + childLevel: { + memberName: 'City', + enabled: true + } + }, { + memberFunction: () => 'AllProducts', + memberName: 'AllProducts', + enabled: true, + childLevel: + { + memberFunction: (data) => data.ProductCategory, + memberName: 'ProductCategory', + enabled: true + } + }], + values: [ + { + member: 'UnitsSold', + aggregate: { + aggregator: IgxPivotNumericAggregate.sum, + key: 'SUM', + label: 'Sum' + }, + enabled: true + }, + { + member: 'AmountOfSale', + displayName: 'Amount of Sale', + aggregate: { + aggregator: IgxTotalSaleAggregate.totalSale, + key: 'TOTAL', + label: 'Total' + }, + enabled: true + } + ] + }; + } +} + +@Component({ + template: ` + + + `, + imports: [IgxPivotGridComponent, IgxGridStateDirective] +}) +export class IgxPivotGridPersistanceComponent { + @ViewChild(IgxGridStateDirective, { static: true }) public state: IgxGridStateDirective; + @ViewChild('grid', { read: IgxPivotGridComponent, static: true }) public pivotGrid: IgxPivotGridComponent; + public data = [ + { + ProductCategory: 'Clothing', UnitPrice: 12.81, SellerName: 'Stanley Brooker', + Country: 'Bulgaria', City: 'Plovdiv', Date: '01/01/2012', UnitsSold: 282 + }, + { + ProductCategory: 'Clothing', UnitPrice: 49.57, SellerName: 'Elisa Longbottom', + Country: 'US', City: 'New York', Date: '01/05/2013', UnitsSold: 296 + }, + { + ProductCategory: 'Bikes', UnitPrice: 3.56, SellerName: 'Lydia Burson', + Country: 'Uruguay', City: 'Ciudad de la Costa', Date: '01/06/2011', UnitsSold: 68 + }, + { + ProductCategory: 'Accessories', UnitPrice: 85.58, SellerName: 'David Haley', + Country: 'UK', City: 'London', Date: '04/07/2012', UnitsSold: 293 + }, + { + ProductCategory: 'Components', UnitPrice: 18.13, SellerName: 'John Smith', + Country: 'Japan', City: 'Yokohama', Date: '12/08/2012', UnitsSold: 240 + }, + { + ProductCategory: 'Clothing', UnitPrice: 68.33, SellerName: 'Larry Lieb', + Country: 'Uruguay', City: 'Ciudad de la Costa', Date: '05/12/2011', UnitsSold: 456 + }, + { + ProductCategory: 'Components', UnitPrice: 16.05, SellerName: 'Walter Pang', + Country: 'Bulgaria', City: 'Sofia', Date: '02/19/2013', UnitsSold: 492 + }]; + public pivotConfigHierarchy = { + columns: [ + { + memberName: 'Country', + enabled: true + } + ] + , + rows: [ + { + memberName: 'City', + enabled: true + }, + { + memberName: 'ProductCategory', + enabled: true + }], + values: [ + { + member: 'UnitsSold', + aggregate: { + aggregator: IgxPivotNumericAggregate.sum, + key: 'SUM', + label: 'Sum' + }, + enabled: true + } + ] + }; +} + +@Component({ + template: ` + + `, + imports: [IgxPivotGridComponent] +}) +export class IgxPivotGridMultipleRowComponent extends IgxPivotGridTestBaseComponent { + @ViewChild('grid', { read: IgxPivotGridComponent, static: true }) public override pivotGrid: IgxPivotGridComponent; + + constructor() { + super(); + this.pivotConfigHierarchy = { + columns: [{ + memberName: 'SellerName', + enabled: true + }, + ], + rows: [{ + memberName: 'ProductCategory', + memberFunction: (data) => data.ProductCategory, + enabled: true + }, { + memberName: 'Country', + enabled: true + }, { + memberName: 'Date', + enabled: true + }], + values: [ + { + member: 'UnitsSold', + aggregate: { + aggregator: IgxPivotNumericAggregate.sum, + key: 'UnitsSoldSUM', + label: 'Sum of Units Sold' + }, + enabled: true, + // dataType: 'currency', + formatter: (value) => value ? value + '$' : undefined + }, + { + member: 'UnitPrice', + aggregate: { + aggregator: IgxPivotNumericAggregate.sum, + key: 'UnitPriceSUM', + label: 'Sum of Unit Price' + }, + enabled: true, + dataType: 'currency' + } + ], + filters: null + }; + } +} + + +@Component({ + styles: ` + .pivot-container { + display: flex; + align-items: flex-start; + flex: 1 1 auto; + order: 0; + align-items: stretch; + } + `, + template: ` +
    +
    + + +
    +
    + `, + standalone: true, + imports: [IgxPivotGridComponent] +}) +export class IgxPivotGridFlexContainerComponent extends IgxPivotGridTestBaseComponent{ +} + +export class IgxTotalSaleAggregate { + public static totalSale: PivotAggregation = (members, data: any) => + data.reduce((accumulator, value) => accumulator + value.UnitPrice * value.UnitsSold, 0); + + public static totalMin: PivotAggregation = (members, data: any) => { + let min = 0; + if (data.length === 1) { + min = data[0].UnitPrice * data[0].UnitsSold || 0; + } else if (data.length > 1) { + min = data.reduce((a, b) => Math.min(a.UnitPrice * a.UnitsSold || 0, b.UnitPrice * b.UnitsSold || 0)); + } + return min; + }; + + public static totalMax: PivotAggregation = (members, data: any) => { + let max = 0; + if (data.length === 1) { + max = data[0].UnitPrice * data[0].UnitsSold; + } else if (data.length > 1) { + max = data.reduce((a, b) => Math.max(a.UnitPrice * a.UnitsSold, b.UnitPrice * b.UnitsSold)); + } + return max; + }; +} + +export const SALES_DATA =[ + { + "JOBS": 35, + "INV_SALES": 2497.11, + "CUST_CODE": "1057802", + "SREP_CODE": "231", + "SREP_CODE_ALT": "060" + }, + { + "JOBS": 1241, + "INV_SALES": 98731.63, + "CUST_CODE": "CW9211", + "SREP_CODE": "162", + "SREP_CODE_ALT": "024" + }, + { + "JOBS": 619, + "INV_SALES": 58532.89, + "CUST_CODE": "VS02524", + "SREP_CODE": "238", + "SREP_CODE_ALT": "060" + }, + { + "JOBS": 534, + "INV_SALES": 37109.5, + "CUST_CODE": "80033239", + "SREP_CODE": "604", + "SREP_CODE_ALT": "041" + }, + { + "JOBS": 262, + "INV_SALES": 16352.73, + "CUST_CODE": "8049699", + "SREP_CODE": "103", + "SREP_CODE_ALT": "029" + }, + { + "JOBS": 1621, + "INV_SALES": 176455.66, + "CUST_CODE": "5062088", + "SREP_CODE": "421", + "SREP_CODE_ALT": "047" + }, + { + "JOBS": 150, + "INV_SALES": 13113.94, + "CUST_CODE": "1060983", + "SREP_CODE": "220", + "SREP_CODE_ALT": "060" + }, + { + "JOBS": 400, + "INV_SALES": 24663.14, + "CUST_CODE": "2038891", + "SREP_CODE": "111", + "SREP_CODE_ALT": "056" + }, + { + "JOBS": 62, + "INV_SALES": 4418.15, + "CUST_CODE": "VS0568", + "SREP_CODE": "263", + "SREP_CODE_ALT": "053" + }, + { + "JOBS": 128, + "INV_SALES": 10792.43, + "CUST_CODE": "1486", + "SREP_CODE": "410", + "SREP_CODE_ALT": "047" + }, + { + "JOBS": 393, + "INV_SALES": 30797.93, + "CUST_CODE": "5049856", + "SREP_CODE": "603", + "SREP_CODE_ALT": "041" + }, + { + "JOBS": 458, + "INV_SALES": 24370.78, + "CUST_CODE": "5044928", + "SREP_CODE": "622", + "SREP_CODE_ALT": "041" + }, + { + "JOBS": 289, + "INV_SALES": 30413.87, + "CUST_CODE": "5053596", + "SREP_CODE": "159", + "SREP_CODE_ALT": "037" + }, + { + "JOBS": 372, + "INV_SALES": 27565.64, + "CUST_CODE": "VS00862", + "SREP_CODE": "256", + "SREP_CODE_ALT": "020" + }, + { + "JOBS": 354, + "INV_SALES": 40424.75, + "CUST_CODE": "80018567", + "SREP_CODE": "183", + "SREP_CODE_ALT": "024" + }, + { + "JOBS": 356, + "INV_SALES": 31745.11, + "CUST_CODE": "80033864", + "SREP_CODE": "256", + "SREP_CODE_ALT": "020" + }, + { + "JOBS": 910, + "INV_SALES": 58081.95, + "CUST_CODE": "80015557", + "SREP_CODE": "254", + "SREP_CODE_ALT": "020" + }, + { + "JOBS": 166, + "INV_SALES": 7229.73, + "CUST_CODE": "5059647", + "SREP_CODE": "419", + "SREP_CODE_ALT": "047" + }, + { + "JOBS": 304, + "INV_SALES": 18192.16, + "CUST_CODE": "2053968", + "SREP_CODE": "110", + "SREP_CODE_ALT": "056" + }, + { + "JOBS": 40, + "INV_SALES": 3253.31, + "CUST_CODE": "4523057", + "SREP_CODE": "538", + "SREP_CODE_ALT": "006" + }, + { + "JOBS": 332, + "INV_SALES": 18538.62, + "CUST_CODE": "8046621", + "SREP_CODE": "103", + "SREP_CODE_ALT": "029" + } +]; diff --git a/projects/igniteui-angular/test-utils/routing-test-guard.spec.ts b/projects/igniteui-angular/test-utils/routing-test-guard.spec.ts new file mode 100644 index 00000000000..f960b3a2d4e --- /dev/null +++ b/projects/igniteui-angular/test-utils/routing-test-guard.spec.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; + +@Injectable() +export class RoutingTestGuard { + public canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean { + if (state.url === '/view5') { + return false; + } else { + return true; + } + } +} diff --git a/projects/igniteui-angular/test-utils/routing-view-components.spec.ts b/projects/igniteui-angular/test-utils/routing-view-components.spec.ts new file mode 100644 index 00000000000..1fb8f96622e --- /dev/null +++ b/projects/igniteui-angular/test-utils/routing-view-components.spec.ts @@ -0,0 +1,41 @@ +import { Component } from '@angular/core'; + +@Component({ + template: `This is a content from view component # 1`, + standalone: true +}) +export class RoutingView1Component { +} + +@Component({ + selector: 'igx-routing-view-2', + template: `This is a content from view component # 2`, + standalone: true +}) +export class RoutingView2Component { +} + +@Component({ + selector: 'igx-routing-view-3', + template: `This is a content from view component # 3`, + standalone: true +}) +export class RoutingView3Component { +} + +@Component({ + selector: 'igx-routing-view-4', + template: `This is a content from view component # 4`, + standalone: true +}) +export class RoutingView4Component { +} + +@Component({ + selector: 'igx-routing-view-5', + template: `This is a content from view component # 5`, + standalone: true +}) +export class RoutingView5Component { +} + diff --git a/projects/igniteui-angular/test-utils/sample-test-data.spec.ts b/projects/igniteui-angular/test-utils/sample-test-data.spec.ts new file mode 100644 index 00000000000..d94b76511b6 --- /dev/null +++ b/projects/igniteui-angular/test-utils/sample-test-data.spec.ts @@ -0,0 +1,4820 @@ +import { Calendar } from 'igniteui-angular/calendar'; +import { ValueData } from '../grids/core/src/services/excel/test-data.service.spec'; +import { ymd } from './helper-utils.spec'; +import { cloneValue } from 'igniteui-angular/core'; + +export class SampleTestData { + + public static timeGenerator: Calendar = new Calendar(); + public static today: Date = new Date(new Date().getFullYear(), new Date().getMonth(), new Date().getDate(), 0, 0, 0); + public static todayFullDate: Date = new Date(new Date().getFullYear(), new Date().getMonth(), new Date().getDate(), 10, 15, 35); + + public static stringArray = () => ([ + 'Terrance Orta', + 'Richard Mahoney LongerName', + 'Donna Price', + 'Lisa Landers', + 'Dorothy H. Spencer' + ]); + + public static numbersArray = () => ([ + 10, + 20, + 30 + ]); + + public static dateArray = () => ([ + new Date('2018'), + new Date(2018, 3, 23), + new Date(30), + new Date('2018/03/23') + ]); + + public static excelDateArray = () => ([ + new Date(2018, 3, 23), + new Date('2018/03/23') + ]); + + public static emptyObjectData = () => ([ + {}, + {}, + {} + ]); + + public static noHeadersObjectArray = () => ([ + new ValueData('1'), + new ValueData('2'), + new ValueData('3') + ]); + + public static oneItemNumberData = () => ([{ index: 1, value: 1 }]); + + /* Fields: index: number, value: number; 2 items. */ + public static numberDataTwoFields = () => ([ + { index: 1, value: 1 }, + { index: 2, value: 2 } + ]); + + /* Fields: index: number, value: number, other: number, another: number; 2 items. */ + public static numberDataFourFields = () => ([ + { index: 1, value: 1, other: 1, another: 1 }, + { index: 2, value: 2, other: 2, another: 2 } + ]); + + /* Fields: Number: number, String: string, Boolean: boolean; Date: date; 3 items. */ + public static differentTypesData = () => ([ + { Number: 1, String: '1', Boolean: true, Date: new Date(2018, 3, 3) }, + { Number: 2, String: '2', Boolean: false, Date: new Date(2018, 5, 6) }, + { Number: 3, String: '3', Boolean: true, Date: new Date(2018, 9, 22) } + ]); + + /* Fields: Number: number; 3 items. */ + public static numericData = () => ([ + { Number: -1 }, + { Number: 2.5 }, + { Number: -0.5 } + ]); + + /* Fields: Name: string, Avatar: string; 3 items. */ + public static personAvatarData = () => ([ + { + Name: 'Person 1', + Avatar: 'https://randomuser.me/api/portraits/men/43.jpg' + }, + { + Name: 'Person 2', + Avatar: 'https://randomuser.me/api/portraits/women/66.jpg' + }, + { + Name: 'Person 3', + Avatar: 'https://randomuser.me/api/portraits/men/92.jpg' + } + ]); + + /* Fields: name: string, phone: string; 5 items. */ + public static contactsData = () => ([ + { + name: 'Terrance Orta', + phone: '770-504-2217' + }, { + name: 'Richard Mahoney LongerName', + phone: '' + }, { + name: 'Donna Price', + phone: '859-496-2817' + }, { + name: '', + phone: '901-747-3428' + }, { + name: 'Dorothy H. Spencer', + phone: '573-394-9254' + } + ]); + + /* Fields: name: string, phone: string; 6 items. Remarks: Contains special and cyrilic characters. */ + public static contactsFunkyData = () => ([ + { + name: 'Terrance Mc\'Orta', + phone: '(+359)770-504-2217 | 2218' + }, { + name: 'Richard Mahoney /LongerName/', + phone: '' + }, { + name: 'Donna, \/; Price', + phone: '859 496 28**' + }, { + name: '\r\n', + phone: '901-747-3428' + }, { + name: 'Dorothy "H." Spencer', + phone: '573-394-9254[fax]' + }, { + name: 'Иван Иванов (1,2)', + phone: '№ 573-394-9254' + } + ]); + + /* Fields: name: string, phone: string; 3 items. Remarks: Contains records without values for one of the fields. */ + public static contactsDataPartial = () => ([ + { + name: 'Terrance Orta', + phone: '770-504-2217' + }, { + name: 'Richard Mahoney LongerName' + }, { + phone: '780-555-1331' + } + ]); + + /* Data fields: ID: number, Name: string; 3 items. */ + public static personIDNameData = () => ([ + { ID: 1, IsEmployed: true, Name: 'Johny' }, + { ID: 2, IsEmployed: true, Name: 'Sally' }, + { ID: 3, IsEmployed: false, Name: 'Tim' }, + ]); + + /* Data fields: FirstName: string, LastName: string, age:number; 3 items. */ + public static personNameAgeData = () => ([ + { FirstName: 'John', LastName: 'Brown', age: 20 }, + { FirstName: 'Ben', LastName: 'Affleck', age: 30 }, + { FirstName: 'Tom', LastName: 'Riddle', age: 50 } + ]); + + /* Data fields: ID: number, Name: string, LastName: string, Region: string; 7 items. */ + public static personIDNameRegionData = () => ([ + { ID: 2, Name: 'Jane', LastName: 'Brown', Region: 'AD' }, + { ID: 1, Name: 'Brad', LastName: 'Williams', Region: 'BD' }, + { ID: 6, Name: 'Rick', LastName: 'Jones', Region: 'ACD' }, + { ID: 7, Name: 'Rick', LastName: 'BRown', Region: 'DD' }, + { ID: 5, Name: 'ALex', LastName: 'Smith', Region: 'MlDs' }, + { ID: 4, Name: 'Alex', LastName: 'Wilson', Region: 'DC' }, + { ID: 3, Name: 'Connor', LastName: 'Walker', Region: 'OC' } + ]); + + /* Data fields: ID: number, Name: string, JobTitle: string, HireDate: string; 10 items, sorted by ID. */ + public static personJobDataFull = () => ([ + { ID: 1, Name: 'Casey Houston', JobTitle: 'Vice President', HireDate: '2017-06-19T11:43:07.714Z' }, + { ID: 2, Name: 'Gilberto Todd', JobTitle: 'Director', HireDate: '2015-12-18T11:23:17.714Z' }, + { ID: 3, Name: 'Tanya Bennett', JobTitle: 'Director', HireDate: '2005-11-18T11:23:17.714Z' }, + { ID: 4, Name: 'Jack Simon', JobTitle: 'Software Developer', HireDate: '2008-12-18T11:23:17.714Z' }, + { ID: 5, Name: 'Celia Martinez', JobTitle: 'Senior Software Developer', HireDate: '2007-12-19T11:23:17.714Z' }, + { ID: 6, Name: 'Erma Walsh', JobTitle: 'CEO', HireDate: '2016-12-18T11:23:17.714Z' }, + { ID: 7, Name: 'Debra Morton', JobTitle: 'Associate Software Developer', HireDate: '2005-11-19T11:23:17.714Z' }, + { ID: 8, Name: 'Erika Wells', JobTitle: 'Software Development Team Lead', HireDate: '2005-10-14T11:23:17.714Z' }, + { ID: 9, Name: 'Leslie Hansen', JobTitle: 'Associate Software Developer', HireDate: '2013-10-10T11:23:17.714Z' }, + { ID: 10, Name: 'Eduardo Ramirez', JobTitle: 'Manager', HireDate: '2011-11-28T11:23:17.714Z' } + ]); + + /* Data fields: ID: number, Name: string, JobTitle: string, WokingHours: number, HireDate: string, Performance: array; + 3 items, sorted by ID. */ + public static personJobHoursDataPerformance = () => ([ + { ID: 1, Name: 'Casey Houston', JobTitle: 'Vice President', WorkingHours: 4, HireDate: '2017-06-19T11:43:07.714Z', Performance: + [ + {Points: 3, Week: 1}, + {Points: 6, Week: 2}, + {Points: 1, Week: 3}, + {Points: 12, Week: 4} + ] + }, + { ID: 2, Name: 'Gilberto Todd', JobTitle: 'Director', WorkingHours: 6, HireDate: '2015-12-18T11:23:17.714Z', Performance: + [ + {Points: 8, Week: 1}, + {Points: 7, Week: 2}, + {Points: 4, Week: 3}, + {Points: 9, Week: 4} + ] + }, + { ID: 3, Name: 'Tanya Bennett', JobTitle: 'Director', WorkingHours: 8, HireDate: '2005-11-18T11:23:17.714Z', Performance: + [ + {Points: 1, Week: 1}, + {Points: 3, Week: 2}, + {Points: 14, Week: 3}, + {Points: 29, Week: 4} + ] + } + ]); + + public static hireDate = () => ([ + { ID: 1, HireDate: new Date(2008, 3, 20).toISOString() }, + { ID: 2, HireDate: new Date(2015, 11, 8) }, + { ID: 3, HireDate: new Date(2012, 6, 30).toISOString() }, + { ID: 4, HireDate: new Date(2010, 1, 5).toISOString() }, + { ID: 5, HireDate: new Date(2020, 4, 17).toISOString() }, + ]); + + /* Data fields: Name: string, BirthDate: date, LastLogin: dateTime, MeetingTime: time, AttendanceRate: percent; 5 items. */ + public static personMeetingData = () => ([ + { Name: 'Casey Houston', BirthDate: new Date(1990, 2, 14), LastLogin: new Date(2023, 3, 28).setHours(13, 12, 36), MeetingTime: new Date(2023, 6, 7).setHours(10, 30, 1), AttendanceRate: 0.78 }, + { Name: 'Gilberto Todd', BirthDate: new Date(1985, 4, 17), LastLogin: new Date(2023, 3, 14).setHours(14, 25, 23), MeetingTime: new Date(2023, 6, 7).setHours(9, 35, 31), AttendanceRate: 0.46 }, + { Name: 'Tanya Bennett', BirthDate: new Date(1987, 6, 19), LastLogin: new Date(2023, 2, 23).setHours(19, 7, 13), MeetingTime: new Date(2023, 6, 7).setHours(13, 10, 36), AttendanceRate: 0.289 }, + { Name: 'Jack Simon', BirthDate: new Date(1995, 8, 23), LastLogin: new Date(2023, 1, 27).setHours(17, 17, 41), MeetingTime: new Date(2023, 6, 7).setHours(14, 50, 47), AttendanceRate: 1 }, + { Name: 'Celia Martinez', BirthDate: new Date(1994, 10, 27), LastLogin: new Date(2023, 2, 14).setHours(1, 31, 49), MeetingTime: new Date(2023, 6, 7).setHours(7, 0, 17), AttendanceRate: 0.384} + ]); + + /* Data fields: ID: number, Name: string, JobTitle: string; 10 items, sorted by ID. */ + public static personJobData = () => ([ + { ID: 1, Name: 'Casey Houston', JobTitle: 'Vice President' }, + { ID: 2, Name: 'Gilberto Todd', JobTitle: 'Director' }, + { ID: 3, Name: 'Tanya Bennett', JobTitle: 'Director' }, + { ID: 4, Name: 'Jack Simon', JobTitle: 'Software Developer' }, + { ID: 5, Name: 'Celia Martinez', JobTitle: 'Senior Software Developer' }, + { ID: 6, Name: 'Erma Walsh', JobTitle: 'CEO' }, + { ID: 7, Name: 'Debra Morton', JobTitle: 'Associate Software Developer' }, + { ID: 8, Name: 'Erika Wells', JobTitle: 'Software Development Team Lead' }, + { ID: 9, Name: 'Leslie Hansen', JobTitle: 'Associate Software Developer' }, + { ID: 10, Name: 'Eduardo Ramirez', JobTitle: 'Manager' } + ]); + + /* Data fields: ID: number, Name: string, JobTitle: string, Company: string; 10 items, sorted by ID. */ + public static personIDNameJobCompany = () => ([ + { ID: 1, Name: 'Casey Houston', JobTitle: 'Vice President', Company: 'Company A' }, + { ID: 2, Name: 'Gilberto Todd', JobTitle: 'Director', Company: 'Company C' }, + { ID: 3, Name: 'Tanya Bennett', JobTitle: 'Director', Company: 'Company A' }, + { ID: 4, Name: 'Jack Simon', JobTitle: 'Software Developer', Company: 'Company D' }, + { ID: 5, Name: 'Celia Martinez', JobTitle: 'Senior Software DEVELOPER', Company: 'Company B' }, + { ID: 6, Name: 'Erma Walsh', JobTitle: 'CEO', Company: 'Company C' }, + { ID: 7, Name: 'Debra Morton', JobTitle: 'Associate Software Developer', Company: 'Company B' }, + { ID: 8, Name: 'Erika Wells', JobTitle: 'Software Development Team Lead', Company: 'Company A' }, + { ID: 9, Name: 'Leslie Hansen', JobTitle: 'Associate Software Developer', Company: 'Company D' }, + { ID: 10, Name: 'Eduardo Ramirez', JobTitle: 'Manager', Company: 'Company E' } + ]); + + /* Data fields: ID: number, Name: Object{FirstName: string, LastName: string }, + JobTitle: string, Company: string; 10 items, sorted by ID. */ + public static personNameObjectJobCompany = () => ([ + { ID: 1, Name: { FirstName: 'Casey', LastName: 'Houston' }, JobTitle: 'Vice President', Company: 'Company A' }, + { ID: 2, Name: { FirstName: 'Gilberto', LastName: 'Todd' } , JobTitle: 'Director', Company: 'Company C' }, + { ID: 3, Name: { FirstName: 'Tanya', LastName: 'Bennett' } , JobTitle: 'Director', Company: 'Company A' }, + { ID: 4, Name: { FirstName: 'Jack', LastName: 'Simon' }, JobTitle: 'Software Developer', Company: 'Company D' }, + { ID: 5, Name: { FirstName: 'Celia', LastName: 'Martinez' }, JobTitle: 'Senior Software DEVELOPER', Company: 'Company B' }, + { ID: 6, Name: { FirstName: 'Erma', LastName: 'Walsh' }, JobTitle: 'CEO', Company: 'Company C' }, + { ID: 7, Name: { FirstName: 'Debra', LastName: 'Morton' } , JobTitle: 'Associate Software Developer', Company: 'Company B' }, + { ID: 8, Name: { FirstName: 'Erika', LastName: 'Wells' } , JobTitle: 'Software Development Team Lead', Company: 'Company A' }, + { ID: 9, Name: { FirstName: 'Leslie', LastName: 'Hansen' } , JobTitle: 'Associate Software Developer', Company: 'Company D' }, + { ID: 10, Name: { FirstName: 'Eduardo', LastName: 'Ramirez' }, JobTitle: 'Manager', Company: 'Company E' } + ]); + /* Data fields: ID: number, CompanyName: string, ContactName: string, ContactTitle: string, Address: string, + City: string, Region: string, PostalCode: string, Country: string, Phone: string, Fax: string; + 11 items, sorted by ID. */ + public static contactInfoData = () => ([ + { + ID: 'ALFKI', + CompanyName: 'Alfreds Futterkiste', + ContactName: 'Maria Anders', + ContactTitle: 'Sales Representative', + Address: 'Obere Str. 57', + City: 'Berlin', + Region: null, + PostalCode: '12209', + Country: 'Germany', + Phone: '030-0074321', + Fax: '030-0076545' + }, + { + ID: 'ANATR', + CompanyName: 'Ana Trujillo Emparedados y helados', + ContactName: 'Ana Trujillo', + ContactTitle: 'Owner', + Address: 'Avda. de la Constitución 2222', + City: 'México D.F.', + Region: null, + PostalCode: '05021', + Country: 'Mexico', + Phone: '(5) 555-4729', + Fax: '(5) 555-3745' + }, + { + ID: 'ANTON', + CompanyName: 'Antonio Moreno Taquería', + ContactName: 'Antonio Moreno', + ContactTitle: 'Owner', + Address: 'Mataderos 2312', + City: 'México D.F.', + Region: null, + PostalCode: '05023', + Country: 'Mexico', + Phone: '(5) 555-3932', + Fax: null + }, + { + ID: 'AROUT', + CompanyName: 'Around the Horn', + ContactName: 'Thomas Hardy', + ContactTitle: 'Sales Representative', + Address: '120 Hanover Sq.', + City: 'London', + Region: null, + PostalCode: 'WA1 1DP', + Country: 'UK', + Phone: '(171) 555-7788', + Fax: '(171) 555-6750' + }, + { + ID: 'BERGS', + CompanyName: 'Berglunds snabbköp', + ContactName: 'Christina Berglund', + ContactTitle: 'Order Administrator', + Address: 'Berguvsvägen 8', + City: 'Luleå', + Region: null, + PostalCode: 'S-958 22', + Country: 'Sweden', + Phone: '0921-12 34 65', + Fax: '0921-12 34 67' + }, + { + ID: 'BLAUS', + CompanyName: 'Blauer See Delikatessen', + ContactName: 'Hanna Moos', + ContactTitle: 'Sales Representative', + Address: 'Forsterstr. 57', + City: 'Mannheim', + Region: null, + PostalCode: '68306', + Country: 'Germany', + Phone: '0621-08460', + Fax: '0621-08924' + }, + { + ID: 'BLONP', + CompanyName: 'Blondesddsl père et fils', + ContactName: 'Frédérique Citeaux', + ContactTitle: 'Marketing Manager', + Address: '24, place Kléber', + City: 'Strasbourg', + Region: null, + PostalCode: '67000', + Country: 'France', + Phone: '88.60.15.31', + Fax: '88.60.15.32' + }, + { + ID: 'BOLID', + CompanyName: 'Bólido Comidas preparadas', + ContactName: 'Martín Sommer', + ContactTitle: 'Owner', + Address: 'C/ Araquil, 67', + City: 'Madrid', + Region: null, + PostalCode: '28023', + Country: 'Spain', + Phone: '(91) 555 22 82', + Fax: '(91) 555 91 99' + }, + { + ID: 'BONAP', + CompanyName: 'Bon app\'', + ContactName: 'Laurence Lebihan', + ContactTitle: 'Owner', + Address: '12, rue des Bouchers', + City: 'Marseille', + Region: null, + PostalCode: '13008', + Country: 'France', + Phone: '91.24.45.40', + Fax: '91.24.45.41' + }, + { + ID: 'BOTTM', + CompanyName: 'Bottom-Dollar Markets', + ContactName: 'Elizabeth Lincoln', + ContactTitle: 'Accounting Manager', + Address: '23 Tsawassen Blvd.', + City: 'Tsawassen', + Region: 'BC', + PostalCode: 'T2F 8M4', + Country: 'Canada', + Phone: '(604) 555-4729', + Fax: '(604) 555-3745' + }, + { + ID: 'BSBEV', + CompanyName: 'B\'s Beverages', + ContactName: 'Victoria Ashworth', + ContactTitle: 'Sales Representative', + Address: 'Fauntleroy Circus', City: 'London', + Region: null, PostalCode: 'EC2 5NT', + Country: 'UK', + Phone: '(171) 555-1212', + Fax: null + } + ]); + + /* Data fields: ID: number, CompanyName: string, ContactName: string, ContactTitle: string, Address: string, + City: string, Region: string, PostalCode: string, Country: string, Phone: string, Fax: string; + 27 items, sorted by ID. */ + + public static contactInfoDataFull = () => ([ + { ID: 'ALFKI', CompanyName: 'Alfreds Futterkiste', ContactName: 'Maria Anders', ContactTitle: 'Sales Representative', Address: 'Obere Str. 57', City: 'Berlin', Region: null, PostalCode: '12209', Country: 'Germany', Phone: '030-0074321', Fax: '030-0076545' }, + { ID: 'ANATR', CompanyName: 'Ana Trujillo Emparedados y helados', ContactName: 'Ana Trujillo', ContactTitle: 'Owner', Address: 'Avda. de la Constitución 2222', City: 'México D.F.', Region: null, PostalCode: '05021', Country: 'Mexico', Phone: '(5) 555-4729', Fax: '(5) 555-3745' }, + { ID: 'ANTON', CompanyName: 'Antonio Moreno Taquería', ContactName: 'Antonio Moreno', ContactTitle: 'Owner', Address: 'Mataderos 2312', City: 'México D.F.', Region: null, PostalCode: '05023', Country: 'Mexico', Phone: '(5) 555-3932', Fax: null }, + { ID: 'AROUT', CompanyName: 'Around the Horn', ContactName: 'Thomas Hardy', ContactTitle: 'Sales Representative', Address: '120 Hanover Sq.', City: 'London', Region: null, PostalCode: 'WA1 1DP', Country: 'UK', Phone: '(171) 555-7788', Fax: '(171) 555-6750' }, + { ID: 'BERGS', CompanyName: 'Berglunds snabbköp', ContactName: 'Christina Berglund', ContactTitle: 'Order Administrator', Address: 'Berguvsvägen 8', City: 'Luleå', Region: null, PostalCode: 'S-958 22', Country: 'Sweden', Phone: '0921-12 34 65', Fax: '0921-12 34 67' }, + { ID: 'BLAUS', CompanyName: 'Blauer See Delikatessen', ContactName: 'Hanna Moos', ContactTitle: 'Sales Representative', Address: 'Forsterstr. 57', City: 'Mannheim', Region: null, PostalCode: '68306', Country: 'Germany', Phone: '0621-08460', Fax: '0621-08924' }, + { ID: 'BLONP', CompanyName: 'Blondesddsl père et fils', ContactName: 'Frédérique Citeaux', ContactTitle: 'Marketing Manager', Address: '24, place Kléber', City: 'Strasbourg', Region: null, PostalCode: '67000', Country: 'France', Phone: '88.60.15.31', Fax: '88.60.15.32' }, + { ID: 'BOLID', CompanyName: 'Bólido Comidas preparadas', ContactName: 'Martín Sommer', ContactTitle: 'Owner', Address: 'C/ Araquil, 67', City: 'Madrid', Region: null, PostalCode: '28023', Country: 'Spain', Phone: '(91) 555 22 82', Fax: '(91) 555 91 99' }, + { ID: 'BONAP', CompanyName: 'Bon app\'', ContactName: 'Laurence Lebihan', ContactTitle: 'Owner', Address: '12, rue des Bouchers', City: 'Marseille', Region: null, PostalCode: '13008', Country: 'France', Phone: '91.24.45.40', Fax: '91.24.45.41' }, + { ID: 'BOTTM', CompanyName: 'Bottom-Dollar Markets', ContactName: 'Elizabeth Lincoln', ContactTitle: 'Accounting Manager', Address: '23 Tsawassen Blvd.', City: 'Tsawassen', Region: 'BC', PostalCode: 'T2F 8M4', Country: 'Canada', Phone: '(604) 555-4729', Fax: '(604) 555-3745' }, + { ID: 'BSBEV', CompanyName: 'B\'s Beverages', ContactName: 'Victoria Ashworth', ContactTitle: 'Sales Representative', Address: 'Fauntleroy Circus', City: 'London', Region: null, PostalCode: 'EC2 5NT', Country: 'UK', Phone: '(171) 555-1212', Fax: null }, + { ID: 'CACTU', CompanyName: 'Cactus Comidas para llevar', ContactName: 'Patricio Simpson', ContactTitle: 'Sales Agent', Address: 'Cerrito 333', City: 'Buenos Aires', Region: null, PostalCode: '1010', Country: 'Argentina', Phone: '(1) 135-5555', Fax: '(1) 135-4892' }, + { ID: 'CENTC', CompanyName: 'Centro comercial Moctezuma', ContactName: 'Francisco Chang', ContactTitle: 'Marketing Manager', Address: 'Sierras de Granada 9993', City: 'México D.F.', Region: null, PostalCode: '05022', Country: 'Mexico', Phone: '(5) 555-3392', Fax: '(5) 555-7293' }, + { ID: 'CHOPS', CompanyName: 'Chop-suey Chinese', ContactName: 'Yang Wang', ContactTitle: 'Owner', Address: 'Hauptstr. 29', City: 'Bern', Region: null, PostalCode: '3012', Country: 'Switzerland', Phone: '0452-076545', Fax: null }, + { ID: 'COMMI', CompanyName: 'Comércio Mineiro', ContactName: 'Pedro Afonso', ContactTitle: 'Sales Associate', Address: 'Av. dos Lusíadas, 23', City: 'Sao Paulo', Region: 'SP', PostalCode: '05432-043', Country: 'Brazil', Phone: '(11) 555-7647', Fax: null }, + { ID: 'CONSH', CompanyName: 'Consolidated Holdings', ContactName: 'Elizabeth Brown', ContactTitle: 'Sales Representative', Address: 'Berkeley Gardens 12 Brewery', City: 'London', Region: null, PostalCode: 'WX1 6LT', Country: 'UK', Phone: '(171) 555-2282', Fax: '(171) 555-9199' }, + { ID: 'DRACD', CompanyName: 'Drachenblut Delikatessen', ContactName: 'Sven Ottlieb', ContactTitle: 'Order Administrator', Address: 'Walserweg 21', City: 'Aachen', Region: null, PostalCode: '52066', Country: 'Germany', Phone: '0241-039123', Fax: '0241-059428' }, + { ID: 'DUMON', CompanyName: 'Du monde entier', ContactName: 'Janine Labrune', ContactTitle: 'Owner', Address: '67, rue des Cinquante Otages', City: 'Nantes', Region: null, PostalCode: '44000', Country: 'France', Phone: '40.67.88.88', Fax: '40.67.89.89' }, + { ID: 'EASTC', CompanyName: 'Eastern Connection', ContactName: 'Ann Devon', ContactTitle: 'Sales Agent', Address: '35 King George', City: 'London', Region: null, PostalCode: 'WX3 6FW', Country: 'UK', Phone: '(171) 555-0297', Fax: '(171) 555-3373' }, + { ID: 'ERNSH', CompanyName: 'Ernst Handel', ContactName: 'Roland Mendel', ContactTitle: 'Sales Manager', Address: 'Kirchgasse 6', City: 'Graz', Region: null, PostalCode: '8010', Country: 'Austria', Phone: '7675-3425', Fax: '7675-3426' }, + { ID: 'FAMIA', CompanyName: 'Familia Arquibaldo', ContactName: 'Aria Cruz', ContactTitle: 'Marketing Assistant', Address: 'Rua Orós, 92', City: 'Sao Paulo', Region: 'SP', PostalCode: '05442-030', Country: 'Brazil', Phone: '(11) 555-9857', Fax: null }, + { ID: 'FISSA', CompanyName: 'FISSA Fabrica Inter. Salchichas S.A.', ContactName: 'Diego Roel', ContactTitle: 'Accounting Manager', Address: 'C/ Moralzarzal, 86', City: 'Madrid', Region: null, PostalCode: '28034', Country: 'Spain', Phone: '(91) 555 94 44', Fax: '(91) 555 55 93' }, + { ID: 'FOLIG', CompanyName: 'Folies gourmandes', ContactName: 'Martine Rancé', ContactTitle: 'Assistant Sales Agent', Address: '184, chaussée de Tournai', City: 'Lille', Region: null, PostalCode: '59000', Country: 'France', Phone: '20.16.10.16', Fax: '20.16.10.17' }, + { ID: 'FOLKO', CompanyName: 'Folk och fä HB', ContactName: 'Maria Larsson', ContactTitle: 'Owner', Address: 'Åkergatan 24', City: 'Bräcke', Region: null, PostalCode: 'S-844 67', Country: 'Sweden', Phone: '0695-34 67 21', Fax: null }, + { ID: 'FRANK', CompanyName: 'Frankenversand', ContactName: 'Peter Franken', ContactTitle: 'Marketing Manager', Address: 'Berliner Platz 43', City: 'München', Region: null, PostalCode: '80805', Country: 'Germany', Phone: '089-0877310', Fax: '089-0877451' }, + { ID: 'FRANR', CompanyName: 'France restauration', ContactName: 'Carine Schmitt', ContactTitle: 'Marketing Manager', Address: '54, rue Royale', City: 'Nantes', Region: null, PostalCode: '44000', Country: 'France', Phone: '40.32.21.21', Fax: '40.32.21.20' }, + { ID: 'FRANS', CompanyName: 'Franchi S.p.A.', ContactName: 'Paolo Accorti', ContactTitle: 'Sales Representative', Address: 'Via Monte Bianco 34', City: 'Torino', Region: null, PostalCode: '10100', Country: 'Italy', Phone: '011-4988260', Fax: '011-4988261' } + ]); + + + /* Data fields: ID: number, PTODays: number, CompanyName: string, ContactName: string, ContactTitle: string, Address: string, + City: string, Region: string, PostalCode: string, Country: string, Phone: string, Fax: string; + 27 items, sorted by ID. */ + + public static contactInfoWithPTODaysData = () => ([ + { ID: 'ALFKI', PTODays: 20, CompanyName: 'Alfreds Futterkiste', ContactName: 'Maria Anders', ContactTitle: 'Sales Representative', Address: 'Obere Str. 57', City: 'Berlin', Region: null, PostalCode: '12209', Country: 'Germany', Phone: '030-0074321', Fax: '030-0076545' }, + { ID: 'ANATR', PTODays: 12, CompanyName: 'Ana Trujillo Emparedados y helados', ContactName: 'Ana Trujillo', ContactTitle: 'Owner', Address: 'Avda. de la Constitución 2222', City: 'México D.F.', Region: null, PostalCode: '05021', Country: 'Mexico', Phone: '(5) 555-4729', Fax: '(5) 555-3745' }, + { ID: 'ANTON', PTODays: 32, CompanyName: 'Antonio Moreno Taquería', ContactName: 'Antonio Moreno', ContactTitle: 'Owner', Address: 'Mataderos 2312', City: 'México D.F.', Region: null, PostalCode: '05023', Country: 'Mexico', Phone: '(5) 555-3932', Fax: null }, + { ID: 'AROUT', PTODays: 23, CompanyName: 'Around the Horn', ContactName: 'Thomas Hardy', ContactTitle: 'Sales Representative', Address: '120 Hanover Sq.', City: 'London', Region: null, PostalCode: 'WA1 1DP', Country: 'UK', Phone: '(171) 555-7788', Fax: '(171) 555-6750' }, + { ID: 'BERGS', PTODays: 15, CompanyName: 'Berglunds snabbköp', ContactName: 'Christina Berglund', ContactTitle: 'Order Administrator', Address: 'Berguvsvägen 8', City: 'Luleå', Region: null, PostalCode: 'S-958 22', Country: 'Sweden', Phone: '0921-12 34 65', Fax: '0921-12 34 67' }, + { ID: 'BLAUS', PTODays: 17, CompanyName: 'Blauer See Delikatessen', ContactName: 'Hanna Moos', ContactTitle: 'Sales Representative', Address: 'Forsterstr. 57', City: 'Mannheim', Region: null, PostalCode: '68306', Country: 'Germany', Phone: '0621-08460', Fax: '0621-08924' }, + { ID: 'BLONP', PTODays: 33, CompanyName: 'Blondesddsl père et fils', ContactName: 'Frédérique Citeaux', ContactTitle: 'Marketing Manager', Address: '24, place Kléber', City: 'Strasbourg', Region: null, PostalCode: '67000', Country: 'France', Phone: '88.60.15.31', Fax: '88.60.15.32' }, + { ID: 'BOLID', PTODays: 27, CompanyName: 'Bólido Comidas preparadas', ContactName: 'Martín Sommer', ContactTitle: 'Owner', Address: 'C/ Araquil, 67', City: 'Madrid', Region: null, PostalCode: '28023', Country: 'Spain', Phone: '(91) 555 22 82', Fax: '(91) 555 91 99' }, + { ID: 'BONAP', PTODays: 11, CompanyName: 'Bon app\'', ContactName: 'Laurence Lebihan', ContactTitle: 'Owner', Address: '12, rue des Bouchers', City: 'Marseille', Region: null, PostalCode: '13008', Country: 'France', Phone: '91.24.45.40', Fax: '91.24.45.41' }, + { ID: 'BOTTM', PTODays: 6, CompanyName: 'Bottom-Dollar Markets', ContactName: 'Elizabeth Lincoln', ContactTitle: 'Accounting Manager', Address: '23 Tsawassen Blvd.', City: 'Tsawassen', Region: 'BC', PostalCode: 'T2F 8M4', Country: 'Canada', Phone: '(604) 555-4729', Fax: '(604) 555-3745' }, + { ID: 'BSBEV', PTODays: 0, CompanyName: 'B\'s Beverages', ContactName: 'Victoria Ashworth', ContactTitle: 'Sales Representative', Address: 'Fauntleroy Circus', City: 'London', Region: null, PostalCode: 'EC2 5NT', Country: 'UK', Phone: '(171) 555-1212', Fax: null }, + { ID: 'CACTU', PTODays: 0, CompanyName: 'Cactus Comidas para llevar', ContactName: 'Patricio Simpson', ContactTitle: 'Sales Agent', Address: 'Cerrito 333', City: 'Buenos Aires', Region: null, PostalCode: '1010', Country: 'Argentina', Phone: '(1) 135-5555', Fax: '(1) 135-4892' }, + { ID: 'CENTC', PTODays: 25, CompanyName: 'Centro comercial Moctezuma', ContactName: 'Francisco Chang', ContactTitle: 'Marketing Manager', Address: 'Sierras de Granada 9993', City: 'México D.F.', Region: null, PostalCode: '05022', Country: 'Mexico', Phone: '(5) 555-3392', Fax: '(5) 555-7293' }, + { ID: 'CHOPS', PTODays: 27, CompanyName: 'Chop-suey Chinese', ContactName: 'Yang Wang', ContactTitle: 'Owner', Address: 'Hauptstr. 29', City: 'Bern', Region: null, PostalCode: '3012', Country: 'Switzerland', Phone: '0452-076545', Fax: null }, + { ID: 'COMMI', PTODays: 17, CompanyName: 'Comércio Mineiro', ContactName: 'Pedro Afonso', ContactTitle: 'Sales Associate', Address: 'Av. dos Lusíadas, 23', City: 'Sao Paulo', Region: 'SP', PostalCode: '05432-043', Country: 'Brazil', Phone: '(11) 555-7647', Fax: null }, + { ID: 'CONSH', PTODays: 2, CompanyName: 'Consolidated Holdings', ContactName: 'Elizabeth Brown', ContactTitle: 'Sales Representative', Address: 'Berkeley Gardens 12 Brewery', City: 'London', Region: null, PostalCode: 'WX1 6LT', Country: 'UK', Phone: '(171) 555-2282', Fax: '(171) 555-9199' }, + { ID: 'DRACD', PTODays: 6, CompanyName: 'Drachenblut Delikatessen', ContactName: 'Sven Ottlieb', ContactTitle: 'Order Administrator', Address: 'Walserweg 21', City: 'Aachen', Region: null, PostalCode: '52066', Country: 'Germany', Phone: '0241-039123', Fax: '0241-059428' }, + { ID: 'DUMON', PTODays: 16, CompanyName: 'Du monde entier', ContactName: 'Janine Labrune', ContactTitle: 'Owner', Address: '67, rue des Cinquante Otages', City: 'Nantes', Region: null, PostalCode: '44000', Country: 'France', Phone: '40.67.88.88', Fax: '40.67.89.89' }, + { ID: 'EASTC', PTODays: 9, CompanyName: 'Eastern Connection', ContactName: 'Ann Devon', ContactTitle: 'Sales Agent', Address: '35 King George', City: 'London', Region: null, PostalCode: 'WX3 6FW', Country: 'UK', Phone: '(171) 555-0297', Fax: '(171) 555-3373' }, + { ID: 'ERNSH', PTODays: 29, CompanyName: 'Ernst Handel', ContactName: 'Roland Mendel', ContactTitle: 'Sales Manager', Address: 'Kirchgasse 6', City: 'Graz', Region: null, PostalCode: '8010', Country: 'Austria', Phone: '7675-3425', Fax: '7675-3426' }, + { ID: 'FAMIA', PTODays: 0, CompanyName: 'Familia Arquibaldo', ContactName: 'Aria Cruz', ContactTitle: 'Marketing Assistant', Address: 'Rua Orós, 92', City: 'Sao Paulo', Region: 'SP', PostalCode: '05442-030', Country: 'Brazil', Phone: '(11) 555-9857', Fax: null }, + { ID: 'FISSA', PTODays: 2, CompanyName: 'FISSA Fabrica Inter. Salchichas S.A.', ContactName: 'Diego Roel', ContactTitle: 'Accounting Manager', Address: 'C/ Moralzarzal, 86', City: 'Madrid', Region: null, PostalCode: '28034', Country: 'Spain', Phone: '(91) 555 94 44', Fax: '(91) 555 55 93' }, + { ID: 'FOLIG', PTODays: 1, CompanyName: 'Folies gourmandes', ContactName: 'Martine Rancé', ContactTitle: 'Assistant Sales Agent', Address: '184, chaussée de Tournai', City: 'Lille', Region: null, PostalCode: '59000', Country: 'France', Phone: '20.16.10.16', Fax: '20.16.10.17', Shipped: true }, + { ID: 'FOLKO', PTODays: 12, CompanyName: 'Folk och fä HB', ContactName: 'Maria Larsson', ContactTitle: 'Owner', Address: 'Åkergatan 24', City: 'Bräcke', Region: null, PostalCode: 'S-844 67', Country: 'Sweden', Phone: '0695-34 67 21', Fax: null, Shipped: true }, + { ID: 'FRANK', PTODays: 24, CompanyName: 'Frankenversand', ContactName: 'Peter Franken', ContactTitle: 'Marketing Manager', Address: 'Berliner Platz 43', City: 'München', Region: null, PostalCode: '80805', Country: 'Germany', Phone: '089-0877310', Fax: '089-0877451', Shipped: true }, + { ID: 'FRANR', PTODays: 26, CompanyName: 'France restauration', ContactName: 'Carine Schmitt', ContactTitle: 'Marketing Manager', Address: '54, rue Royale', City: 'Nantes', Region: null, PostalCode: '44000', Country: 'France', Phone: '40.32.21.21', Fax: '40.32.21.20', Shipped: true }, + { ID: 'FRANS', PTODays: 18, CompanyName: 'Franchi S.p.A.', ContactName: 'Paolo Accorti', ContactTitle: 'Sales Representative', Address: 'Via Monte Bianco 34', City: 'Torino', Region: null, PostalCode: '10100', Country: 'Italy', Phone: '011-4988260', Fax: '011-4988261', Shipped: true } + ]); + + public static contactInfoDataTwoRecords = () => ([ + { ID: 'ALFKI', CompanyName: 'Alfreds Futterkiste', ContactName: 'Maria Anders', ContactTitle: 'Sales Representative', Address: 'Obere Str. 57', City: 'Berlin', Region: null, PostalCode: '12209', Country: 'Germany', Phone: '030-0074321', Fax: '030-0076545' }, + { ID: 'ANATR', CompanyName: 'Ana Trujillo Emparedados y helados', ContactName: 'Ana Trujillo', ContactTitle: 'Owner', Address: 'Avda. de la Constitución 2222', City: 'México D.F.', Region: null, PostalCode: '05021', Country: 'Mexico', Phone: '(5) 555-4729', Fax: '(5) 555-3745' }, + ]); + + /* Data fields: ID: number, CompanyName: string, ContactName: string, ContactTitle: string, Address: string, + City: string, Region: string, PostalCode: string, Country: string, Phone: string, Fax: string; 1 item. */ + public static contactMariaAndersData = () => ([{ + ID: 'ALFKI', + CompanyName: 'Alfreds Futterkiste', + ContactName: 'Maria Anders', + ContactTitle: 'Sales Representative', + Address: 'Obere Str. 57', + City: 'Berlin', + Region: null, + PostalCode: '12209', + Country: 'Germany', + Phone: '030-0074321', + Fax: '030-0076545' + }]); + + /* Data fields: Downloads: number, ID: number, ProductName: string, ReleaseDate: Date, Released: boolean; + 8 items, sorted by ID. */ + public static productInfoData = () => ([ + { + Downloads: 254, + ID: 1, + ProductName: 'Ignite UI for JavaScript', + ReleaseDate: SampleTestData.timeGenerator.timedelta(SampleTestData.today, 'day', 15), + Released: false + }, + { + Downloads: 127, + ID: 2, + ProductName: 'NetAdvantage', + ReleaseDate: SampleTestData.timeGenerator.timedelta(SampleTestData.today, 'month', -1), + Released: true + }, + { + Downloads: 20, + ID: 3, + ProductName: 'Ignite UI for Angular', + ReleaseDate: null, + Released: null + }, + { + Downloads: null, + ID: 4, + ProductName: null, + ReleaseDate: SampleTestData.timeGenerator.timedelta(SampleTestData.today, 'day', -1), + Released: true + }, + { + Downloads: 100, + ID: 5, + ProductName: '', + ReleaseDate: undefined, + Released: '' + }, + { + Downloads: 702, + ID: 6, + ProductName: 'Some other item with Script', + ReleaseDate: SampleTestData.timeGenerator.timedelta(SampleTestData.today, 'day', 1), + Released: null + }, + { + Downloads: 1, + ID: 7, + ProductName: null, + ReleaseDate: SampleTestData.timeGenerator.timedelta(SampleTestData.today, 'month', 1), + Released: true + }, + { + Downloads: 1000, + ID: 8, + ProductName: null, + ReleaseDate: SampleTestData.today, + Released: false + } + ]); + + /* Data fields: Downloads: number, ID: number, ProductName: string, ReleaseDate: Date, Released: boolean, + Category: string, Items: string, Test: string; + 8 items, sorted by ID. */ + public static productInfoDataFull = () => ([ + { + Downloads: 254, + ID: 1, + ProductName: 'Ignite UI for JavaScript', + ReleaseDate: SampleTestData.timeGenerator.timedelta(SampleTestData.today, 'day', 15), + Released: false, + Category: 'Category 1', + Items: 'Item 1', + Test: 'Test 1' + }, + { + Downloads: 127, + ID: 2, + ProductName: 'NetAdvantage', + ReleaseDate: SampleTestData.timeGenerator.timedelta(SampleTestData.today, 'month', -1), + Released: true, + Category: 'Category 2', + Items: 'Item 2', + Test: 'Test 2' + }, + { + Downloads: 20, + ID: 3, + ProductName: 'Ignite UI for Angular', + ReleaseDate: null, + Released: null, + Category: 'Category 3', + Items: 'Item 3', + Test: 'Test 3' + }, + { + Downloads: null, + ID: 4, + ProductName: null, + ReleaseDate: SampleTestData.timeGenerator.timedelta(SampleTestData.today, 'day', -1), + Released: true, + Category: 'Category 4', + Items: 'Item 4', + Test: 'Test 4' + }, + { + Downloads: 100, + ID: 5, + ProductName: '', + ReleaseDate: undefined, + Released: '', + Category: 'Category 5', + Items: 'Item 5', + Test: 'Test 5' + }, + { + Downloads: 702, + ID: 6, + ProductName: 'Some other item with Script', + ReleaseDate: SampleTestData.timeGenerator.timedelta(SampleTestData.today, 'day', 1), + Released: null, + Category: 'Category 6', + Items: 'Item 6', + Test: 'Test 6' + }, + { + Downloads: 0, + ID: 7, + ProductName: null, + ReleaseDate: SampleTestData.timeGenerator.timedelta(SampleTestData.today, 'month', 1), + Released: true, + Category: 'Category 7', + Items: 'Item 7', + Test: 'Test 7' + }, + { + Downloads: 1000, + ID: 8, + ProductName: null, + ReleaseDate: SampleTestData.today, + Released: false, + Category: 'Category 8', + Items: 'Item 8', + Test: 'Test 8' + } + ]); + + /* Data fields: ProductID: number, ProductName: string, InStock: boolean, UnitsInStock: number, OrderDate: Date; + 10 items, sorted by ID. */ + public static foodProductData = () => ([ + { ProductID: 1, ProductName: 'Chai', InStock: true, UnitsInStock: 2760, OrderDate: ymd('2005-03-21') }, + { ProductID: 2, ProductName: 'Aniseed Syrup', InStock: false, UnitsInStock: 198, OrderDate: ymd('2008-01-15') }, + { ProductID: 3, ProductName: 'Chef Antons Cajun Seasoning', InStock: true, UnitsInStock: 52, OrderDate: ymd('2010-11-20') }, + { ProductID: 4, ProductName: 'Grandmas Boysenberry Spread', InStock: false, UnitsInStock: 0, OrderDate: ymd('2007-10-11') }, + { ProductID: 5, ProductName: 'Uncle Bobs Dried Pears', InStock: false, UnitsInStock: 0, OrderDate: ymd('2001-07-27') }, + { ProductID: 6, ProductName: 'Northwoods Cranberry Sauce', InStock: true, UnitsInStock: 1098, OrderDate: ymd('1990-05-17') }, + { ProductID: 7, ProductName: 'Queso Cabrales', InStock: false, UnitsInStock: 0, OrderDate: ymd('2005-03-03') }, + { ProductID: 8, ProductName: 'Tofu', InStock: true, UnitsInStock: 7898, OrderDate: ymd('2017-09-09') }, + { ProductID: 9, ProductName: 'Teatime Chocolate Biscuits', InStock: true, UnitsInStock: 6998, OrderDate: ymd('2025-12-25') }, + { ProductID: 10, ProductName: 'Chocolate', InStock: true, UnitsInStock: 20000, OrderDate: ymd('2018-03-01') } + ]); + + public static foodProductDateTimeData = () => ([ + { ProductID: 1, ProductName: 'Chai', InStock: true, UnitsInStock: 2760, OrderDate: new Date(2015, 9, 1, 11, 37, 22), + ReceiveTime: new Date(2015, 10, 1, 8, 37, 11), ProducedDate: new Date(2014, 9, 1, 11, 37, 22) }, + { ProductID: 2, ProductName: 'Aniseed Syrup', InStock: false, UnitsInStock: 198, OrderDate: new Date(2016, 7, 18, 11, 17, 22), + ReceiveTime: new Date(2016, 10, 8, 12, 12, 2), ProducedDate: new Date(2015, 7, 18, 11, 17, 22) }, + { ProductID: 3, ProductName: 'Antons Cajun Seasoning', InStock: true, UnitsInStock: 52, OrderDate: new Date(2021, 4, 11, 7, 47, 1), + ReceiveTime: new Date(2021, 4, 29, 14, 7, 12), ProducedDate: new Date(2020, 4, 11, 7, 47, 1) }, + { ProductID: 4, ProductName: 'Boysenberry Spread', InStock: false, UnitsInStock: 0, OrderDate: new Date(2021, 4, 11, 18, 37, 2), + ReceiveTime: new Date(2021, 4, 27, 6, 40, 18), ProducedDate: new Date(2020, 4, 11, 18, 37, 2) }, + { ProductID: 5, ProductName: 'Uncle Bobs Dried Pears', InStock: false, UnitsInStock: 0, OrderDate: new Date(2019, 3, 17, 5, 5, 15), + ReceiveTime: new Date(2019, 3, 31, 12, 47, 42), ProducedDate: new Date(2018, 3, 17, 5, 5, 15) }, + { ProductID: 6, ProductName: 'Cranberry Sauce', InStock: true, UnitsInStock: 1098, OrderDate: new Date(2019, 9, 30, 16, 17, 27), + ReceiveTime: new Date(2019, 10, 11, 12, 47, 42), ProducedDate: new Date(2018, 9, 30, 16, 17, 27) }, + { ProductID: 7, ProductName: 'Queso Cabrales', InStock: false, UnitsInStock: 0, OrderDate: new Date(2015, 2, 12, 21, 31, 22), + ReceiveTime: new Date(2015, 3, 3, 20, 20, 24), ProducedDate: new Date(2014, 2, 12, 21, 31, 22) }, + { ProductID: 8, ProductName: 'Tofu', InStock: true, UnitsInStock: 7898, OrderDate: new Date(2018, 6, 14, 17, 27, 23), + ReceiveTime: new Date(2018, 6, 18, 15, 30, 30), ProducedDate: new Date(2017, 6, 14, 17, 27, 23) }, + { ProductID: 9, ProductName: 'Chocolate Biscuits', InStock: true, UnitsInStock: 6998, OrderDate: new Date(2021, 7, 3, 15, 15, 0), + ReceiveTime: new Date(2021, 7, 7, 15, 30, 22), ProducedDate: new Date(2020, 7, 3, 15, 15, 0) }, + { ProductID: 10, ProductName: 'Chocolate', InStock: true, UnitsInStock: 20000, OrderDate: new Date(2021, 7, 3, 15, 15, 0), + ReceiveTime: new Date(2021, 7, 11, 14, 30, 0), ProducedDate: new Date(2020, 7, 3, 15, 15, 0) } + ]); + + public static foodPercentProductData = () => ([ + { ProductID: 1, ProductName: 'Chai', InStock: true, UnitsInStock: 2760, OrderDate: ymd('2005-03-21'), Discount: 0.27 }, + { ProductID: 2, ProductName: 'Syrup', InStock: false, UnitsInStock: 198, OrderDate: ymd('2008-01-15'), Discount: 0.83 }, + { ProductID: 3, ProductName: 'Seasoning', InStock: true, UnitsInStock: 5, OrderDate: ymd('2010-11-20'), Discount: -0.7 }, + { ProductID: 4, ProductName: 'Spread', InStock: false, UnitsInStock: 0, OrderDate: ymd('2007-10-11'), Discount: 11 }, + { ProductID: 5, ProductName: 'Bobs Pears', InStock: false, UnitsInStock: 0, OrderDate: ymd('2001-07-27'), Discount: -0.5}, + { ProductID: 6, ProductName: 'Sauce', InStock: true, UnitsInStock: 1098, OrderDate: ymd('1990-05-17'), Discount: 0.027 }, + { ProductID: 7, ProductName: 'Queso Cabrale', InStock: false, UnitsInStock: 0, OrderDate: ymd('2005-03-03'), Discount: 0.099 }, + { ProductID: 8, ProductName: 'Tofu', InStock: true, UnitsInStock: 7898, OrderDate: ymd('2017-09-09'), Discount: 10 }, + { ProductID: 9, ProductName: 'Chocolate', InStock: true, UnitsInStock: 698, OrderDate: ymd('2025-12-25'), Discount: .123}, + { ProductID: 10, ProductName: 'Biscuits', InStock: true, UnitsInStock: 20000, OrderDate: ymd('2018-03-01'), Discount: 0.39 } + ]); + + + /* Data fields: ProductID: number, ProductName: string, InStock: boolean, UnitsInStock: number, OrderDate: Date; + 19 items, sorted by ID. */ + public static foodProductDataExtended = () => ([ + { ProductID: 1, ProductName: 'Chai', InStock: true, UnitsInStock: 2760, OrderDate: ymd('2005-03-21') }, + { ProductID: 2, ProductName: 'Aniseed Syrup', InStock: false, UnitsInStock: 198, OrderDate: ymd('2008-01-15') }, + { ProductID: 3, ProductName: 'Chef Antons Cajun Seasoning', InStock: true, UnitsInStock: 52, OrderDate: ymd('2010-11-20') }, + { ProductID: 4, ProductName: 'Grandmas Boysenberry Spread', InStock: false, UnitsInStock: 0, OrderDate: ymd('2007-10-11') }, + { ProductID: 5, ProductName: 'Uncle Bobs Dried Pears', InStock: false, UnitsInStock: 0, OrderDate: ymd('2001-07-27') }, + { ProductID: 6, ProductName: 'Northwoods Cranberry Sauce', InStock: true, UnitsInStock: 1098, OrderDate: ymd('1990-05-17') }, + { ProductID: 7, ProductName: 'Queso Cabrales', InStock: false, UnitsInStock: 0, OrderDate: ymd('2005-03-03') }, + { ProductID: 8, ProductName: 'Tofu', InStock: true, UnitsInStock: 7898, OrderDate: ymd('2017-09-09') }, + { ProductID: 9, ProductName: 'Teatime Chocolate Biscuits', InStock: true, UnitsInStock: 6998, OrderDate: ymd('2025-12-25') }, + { ProductID: 10, ProductName: 'Pie', InStock: true, UnitsInStock: 1000, OrderDate: ymd('2017-05-07') }, + { ProductID: 11, ProductName: 'Pasta', InStock: false, UnitsInStock: 198, OrderDate: ymd('2001-02-15') }, + { ProductID: 12, ProductName: 'Krusty krab\'s burger', InStock: true, UnitsInStock: 52, OrderDate: ymd('2012-09-25') }, + { ProductID: 13, ProductName: 'Lasagna', InStock: false, UnitsInStock: 0, OrderDate: ymd('2015-02-09') }, + { ProductID: 14, ProductName: 'Uncle Bobs Dried Pears', InStock: false, UnitsInStock: 0, OrderDate: ymd('2008-03-17') }, + { ProductID: 15, ProductName: 'Cheese', InStock: true, UnitsInStock: 1098, OrderDate: ymd('1990-11-27') }, + { ProductID: 16, ProductName: 'Devil\'s Hot Chilli Sauce', InStock: false, UnitsInStock: 0, OrderDate: ymd('2012-08-14') }, + { ProductID: 17, ProductName: 'Parmesan', InStock: true, UnitsInStock: 4898, OrderDate: ymd('2017-09-09') }, + { ProductID: 18, ProductName: 'Steaks', InStock: true, UnitsInStock: 3098, OrderDate: ymd('2025-12-25') }, + { ProductID: 19, ProductName: 'Biscuits', InStock: true, UnitsInStock: 10570, OrderDate: ymd('2018-03-01') } + ]); + + /* Generates data with the following data fields: index: number, value: number, other: number, another: number. */ + public static generateNumberData(rowsCount: number) { + const data = []; + for (let i = 0; i < rowsCount; i++) { + data.push({ index: i, value: i, other: i, another: i }); + } + return data; + } + + /* Generates columns with 'field' and 'width' fields. */ + public static generateNumberDataSpecial(rowsCount, colsCount, defaultColWidth = null) { + const cols = []; + for (let j = 0; j < colsCount; j++) { + cols.push({ + field: j.toString(), + width: defaultColWidth !== null ? defaultColWidth : j % 8 < 2 ? 100 : (j % 6) * 125 + }); + } + + const data = []; + for (let i = 0; i < rowsCount; i++) { + const obj = {}; + for (let j = 0; j < cols.length; j++) { + const col = cols[j].field; + obj[col] = 10 * i * j; + } + data.push(obj); + } + return data; + } + + /* Data fields: Downloads:number, ID: number, ProductName: string, ReleaseDate: Date, + Released: boolean, Category: string, Items: string, Test: string. */ + public static generateProductData(itemsCount: number) { + const data = []; + for (let i = 0; i < itemsCount; i++) { + const item = { + Downloads: 100 + i, + ID: i, + ProductName: 'ProductName' + i, + ReleaseDate: SampleTestData.timeGenerator.timedelta(SampleTestData.today, 'month', -1), + Released: true, + Category: 'Category' + i, + Items: 'Items' + i, + Test: 'test' + i + }; + data.push(item); + } + + return data; + } + + /* Data fields: ID: string, Column1: string, Column2: string, Column3: string. */ + public static generateBigValuesData(rowsCount: number) { + const bigData = []; + for (let i = 0; i < rowsCount; i++) { + for (let j = 0; j < 5; j++) { + bigData.push({ + ID: i.toString() + '_' + j.toString(), + Column1: i * j, + Column2: i * j * Math.pow(10, i), + Column3: i * j * Math.pow(100, i) + }); + } + } + return bigData; + } + + /* Data fields: ID: string, Column 1..N: number. */ + public static generateBigDataRowsAndCols(rowsCount: number, colsCount: number) { + const bigData = []; + for (let i = 0; i < rowsCount; i++) { + const row = {}; + row['ID'] = i.toString(); + for (let j = 1; j < colsCount; j++) { + row['Column ' + j] = i * j; + } + + bigData.push(row); + } + return bigData; + } + + /* Generates columns with the following fields: key, field and header. */ + public static generateColumns(count, namePrefix = 'col') { + const cols = []; + for (let i = 0; i < count; i++) { + cols.push({ + key: namePrefix + i, + field: namePrefix + i, + header: namePrefix + i + }); + } + return cols; + } + + /* Generates columns with the following fields: key, field, header and dataType. */ + public static generateColumnsByType(count, type: string, namePrefix = 'col') { + const cols = []; + for (let i = 0; i < count; i++) { + cols.push({ + key: namePrefix + i, + field: namePrefix + i, + header: namePrefix + i, + dataType: type + }); + } + return cols; + } + + /* Generates columns with the following fields: key, dataType and editable. */ + public static generateEditableColumns(count, columnsType = 'string', namePrefix = 'col') { + const cols = []; + for (let i = 0; i < count; i++) { + if (i % 2 === 0) { + cols.push({ + key: namePrefix + i, + dataType: columnsType, + editable: true + }); + } else { + cols.push({ + key: namePrefix + i, + dataType: columnsType, + editable: false + }); + } + } + return cols; + } + + /* Generates numeric data for the specified columns collection. */ + public static generateDataForColumns(columns: any[], rowsCount: number, startFromOne = false) { + const data = []; + + for (let r = 0; r < rowsCount; r++) { + const record = {}; + for (let c = 0; c < columns.length; c++) { + record[columns[c].key] = startFromOne && c === 0 ? 1 : c * r; + } + data.push(record); + } + + return data; + } + + /* Generates data with headers in the format "colNamePrefix1..N" and + number values calculated by "colIndex * rowIndex" formula. */ + public static generateData(rowsCount, colsCount, colNamePrefix = 'col') { + const cols = SampleTestData.generateColumns(colsCount, colNamePrefix); + const data = []; + for (let r = 0; r < rowsCount; r++) { + const record = {}; + for (let c = 0; c < cols.length; c++) { + record[cols[c].field] = c * r; + } + data.push(record); + } + return data; + } + + /* Generate a different set of data using the specified baseData. + Note: If a numeric ID field is available, it will be incremented accordingly. */ + public static generateFromData(baseData: any[], rowsCount: number) { + const data = []; + const iterations = Math.floor(rowsCount / baseData.length); + const remainder = rowsCount % baseData.length; + + for (let i = 0; i < iterations; i++) { + baseData.forEach((item) => { + const currentItem = cloneValue(item); + const id = SampleTestData.getIDColumnName(currentItem); + if (id) { + currentItem[id] = item[id] + i * baseData.length; + } + data.push(currentItem); + }); + } + const currentLength = data.length; + for (let i = 0; i < remainder; i++) { + const currentItem = cloneValue(baseData[i]); + const id = SampleTestData.getIDColumnName(currentItem); + if (id) { + currentItem[id] = currentLength + baseData[i][id]; + } + data.push(currentItem); + } + + return data; + } + + /* Fields: name: string, phone: string; 6 items. Remarks: Contains special and cyrilic characters. + Certain characters serving as delimiters can be changed. Mostly used in CSV exporters tests. */ + public static getContactsFunkyData(delimiter) { + return [{ + name: 'Terrance Mc\'Orta', + phone: '(+359)770-504-2217 | 2218' + }, { + name: 'Richard Mahoney /LongerName/', + phone: '' + }, { + name: 'Donna' + delimiter + ' \/; Price', + phone: '859 496 28**' + }, { + name: '\r\n', + phone: '901-747-3428' + }, { + name: 'Dorothy "H." Spencer', + phone: '573-394-9254[fax]' + }, { + name: 'Иван Иванов (1' + delimiter + '2)', + phone: '№ 573-394-9254' + }]; + } + + /* Tree data: Every employee node has ID, Name, HireDate, Age and Employees */ + public static employeeTreeData = () => ([ + { + ID: 147, + Name: 'John Winchester', + HireDate: new Date(2008, 3, 20), + Age: 55, + OnPTO: false, + Employees: [ + { + ID: 475, + Name: 'Michael Langdon', + HireDate: new Date(2011, 6, 3), + Age: 43, + OnPTO: false, + Employees: null + }, + { + ID: 957, + Name: 'Thomas Hardy', + HireDate: new Date(2009, 6, 19), + Age: 29, + OnPTO: true, + Employees: undefined + }, + { + ID: 317, + Name: 'Monica Reyes', + HireDate: new Date(2014, 8, 18), + Age: 31, + OnPTO: false, + Employees: [ + { + ID: 711, + Name: 'Roland Mendel', + HireDate: new Date(2015, 9, 17), + Age: 35, + OnPTO: true, + }, + { + ID: 998, + Name: 'Sven Ottlieb', + HireDate: new Date(2009, 10, 11), + Age: 44, + OnPTO: false, + } + ] + }] + }, + { + ID: 847, + Name: 'Ana Sanders', + HireDate: new Date(2014, 1, 22), + Age: 42, + OnPTO: false, + Employees: [ + { + ID: 225, + Name: 'Laurence Johnson', + HireDate: new Date(2014, 4, 4), + OnPTO: true, + Age: 44, + }, + { + ID: 663, + Name: 'Elizabeth Richards', + HireDate: new Date(2017, 11, 9), + Age: 25, + OnPTO: false, + Employees: [ + { + ID: 141, + Name: 'Trevor Ashworth', + HireDate: new Date(2010, 3, 22), + OnPTO: false, + Age: 39 + } + ] + }] + }, + { + ID: 19, + Name: 'Victoria Lincoln', + HireDate: new Date(2014, 1, 22), + Age: 49, + OnPTO: false, + Employees: [ + { + ID: 15, + Name: 'Antonio Moreno', + HireDate: new Date(2014, 4, 4), + Age: 44, + OnPTO: true, + Employees: [] + }] + }, + { + ID: 17, + Name: 'Yang Wang', + HireDate: new Date(2010, 1, 1), + Age: 61, + OnPTO: false, + Employees: [ + { + ID: 12, + Name: 'Pedro Afonso', + HireDate: new Date(2007, 11, 18), + Age: 50, + OnPTO: false, + Employees: [ + { + ID: 109, + Name: 'Patricio Simpson', + HireDate: new Date(2017, 11, 9), + Age: 25, + OnPTO: false, + Employees: [] + }, + { + ID: 99, + Name: 'Francisco Chang', + HireDate: new Date(2010, 3, 22), + OnPTO: true, + Age: 39 + }, + { + ID: 299, + Name: 'Peter Lewis', + HireDate: new Date(2018, 3, 18), + OnPTO: false, + Age: 25 + } + ] + }, + { + ID: 101, + Name: 'Casey Harper', + HireDate: new Date(2016, 2, 19), + OnPTO: false, + Age: 27 + }] + } + ]); + + public static employeeScrollingData = () => ([ + { Salary: 2500, employeeID: 0, PID: -1, firstName: 'Andrew', lastName: 'Fuller', Title: 'Vice President, Sales' }, + { Salary: 3500, employeeID: 1, PID: -1, firstName: 'Jonathan', lastName: 'Smith', Title: 'Human resources' }, + { Salary: 1500, employeeID: 2, PID: -1, firstName: 'Nancy', lastName: 'Davolio', Title: 'CFO' }, + { Salary: 2500, employeeID: 3, PID: -1, firstName: 'Steven', lastName: 'Buchanan', Title: 'CTO' }, + // sub of ID 0 + { Salary: 2500, employeeID: 4, PID: 0, firstName: 'Janet', lastName: 'Leverling', Title: 'Sales Manager' }, + { Salary: 3500, employeeID: 5, PID: 0, firstName: 'Laura', lastName: 'Callahan', Title: 'Inside Sales Coordinator' }, + { Salary: 1500, employeeID: 6, PID: 0, firstName: 'Margaret', lastName: 'Peacock', Title: 'Sales Representative' }, + { Salary: 2500, employeeID: 7, PID: 0, firstName: 'Michael', lastName: 'Suyama', Title: 'Sales Representative' }, + // sub of ID 4 + { Salary: 2500, employeeID: 8, PID: 4, firstName: 'Anne', lastName: 'Dodsworth', Title: 'Sales Representative' }, + { Salary: 3500, employeeID: 9, PID: 4, firstName: 'Danielle', lastName: 'Davis', Title: 'Sales Representative' }, + { Salary: 1500, employeeID: 10, PID: 4, firstName: 'Robert', lastName: 'King', Title: 'Sales Representative' }, + // sub of ID 2 + { Salary: 2500, employeeID: 11, PID: 2, firstName: 'Peter', lastName: 'Lewis', Title: 'Chief Accountant' }, + { Salary: 3500, employeeID: 12, PID: 2, firstName: 'Ryder', lastName: 'Zenaida', Title: 'Accountant' }, + { Salary: 1500, employeeID: 13, PID: 2, firstName: 'Wang', lastName: 'Mercedes', Title: 'Accountant' }, + // sub of ID 3 + { Salary: 1500, employeeID: 14, PID: 3, firstName: 'Theodore', lastName: 'Zia', Title: 'Software Architect' }, + { Salary: 4500, employeeID: 15, PID: 3, firstName: 'Lacota', lastName: 'Mufutau', Title: 'Product Manager' }, + // sub of ID 16 + { Salary: 2500, employeeID: 16, PID: 15, firstName: 'Jin', lastName: 'Elliott', Title: 'Product Owner' }, + { Salary: 3500, employeeID: 17, PID: 15, firstName: 'Armand', lastName: 'Ross', Title: 'Product Owner' }, + { Salary: 1500, employeeID: 18, PID: 15, firstName: 'Dane', lastName: 'Rodriquez', Title: 'Team Leader' }, + // sub of ID 19 + { Salary: 2500, employeeID: 19, PID: 18, firstName: 'Declan', lastName: 'Lester', Title: 'Senior Software Developer' }, + { Salary: 3500, employeeID: 20, PID: 18, firstName: 'Bernard', lastName: 'Jarvis', Title: 'Senior Software Developer' }, + { Salary: 1500, employeeID: 21, PID: 18, firstName: 'Jason', lastName: 'Clark', Title: 'QA' }, + { Salary: 1500, employeeID: 22, PID: 18, firstName: 'Mark', lastName: 'Young', Title: 'QA' }, + // sub of ID 20 + { Salary: 1500, employeeID: 23, PID: 20, firstName: 'Jeremy', lastName: 'Donaldson', Title: 'Software Developer' } + ]); + + /* Small tree data: Every employee node has ID, Name, HireDate, Age and Employees */ + public static employeeSmallTreeData = () => ([ + { + ID: 147, + Name: 'John Winchester', + HireDate: new Date(2008, 3, 20), + Age: 55, + Employees: [ + { + ID: 475, + Name: 'Michael Langdon', + HireDate: new Date(2011, 6, 3), + Age: 30, + Employees: null + }, + { + ID: 957, + Name: 'Thomas Hardy', + HireDate: new Date(2009, 6, 19), + Age: 29, + Employees: undefined + }, + { + ID: 317, + Name: 'Monica Reyes', + HireDate: new Date(2014, 8, 18), + Age: 31, + Employees: [ + { + ID: 711, + Name: 'Roland Mendel', + HireDate: new Date(2015, 9, 17), + Age: 35 + }, + { + ID: 998, + Name: 'Sven Ottlieb', + HireDate: new Date(2009, 10, 11), + Age: 44 + }, + { + ID: 299, + Name: 'Peter Lewis', + HireDate: new Date(2018, 3, 18), + Age: 25 + } + ] + }] + }, + { + ID: 19, + Name: 'Yang Wang', + HireDate: new Date(2010, 1, 1), + Age: 61 + }, + { + ID: 847, + Name: 'Ana Sanders', + HireDate: new Date(2014, 1, 22), + Age: 42, + Employees: [ + { + ID: 663, + Name: 'Elizabeth Richards', + HireDate: new Date(2017, 11, 9), + Age: 25 + }] + } + ]); + + public static employeeSmallPrimaryForeignKeyTreeData = () => ([ + { + ID: 147, + ParentID: -1, + Name: 'John Winchester', + HireDate: new Date(2008, 3, 20), + Age: 55, + }, + { + ID: 475, + ParentID: 147, + Name: 'Michael Langdon', + HireDate: new Date(2011, 6, 3), + Age: 30, + }, + { + ID: 957, + ParentID: 147, + Name: 'Thomas Hardy', + HireDate: new Date(2009, 6, 19), + Age: 29, + }, + { + ID: 317, + ParentID: 147, + Name: 'Monica Reyes', + HireDate: new Date(2014, 8, 18), + Age: 31, + }, + { + ID: 711, + ParentID: 317, + Name: 'Roland Mendel', + HireDate: new Date(2015, 9, 17), + Age: 35 + }, + { + ID: 998, + ParentID: 317, + Name: 'Sven Ottlieb', + HireDate: new Date(2009, 10, 11), + Age: 44 + }, + { + ID: 299, + ParentID: 317, + Name: 'Peter Lewis', + HireDate: new Date(2018, 3, 18), + Age: 25 + }, + { + ID: 19, + ParentID: -1, + Name: 'Yang Wang', + HireDate: new Date(2010, 1, 1), + Age: 61, + }, + { + ID: 847, + ParentID: -1, + Name: 'Ana Sanders', + HireDate: new Date(2014, 1, 22), + Age: 42, + }, + { + ID: 663, + ParentID: 847, + Name: 'Elizabeth Richards', + HireDate: new Date(2017, 11, 9), + Age: 25 + } + ]); + + /* Search tree data: Every employee node has ID, Name, HireDate, Age, JobTitle and Employees */ + public static employeeSearchTreeData = () => ([ + { + ID: 147, + Name: 'John Winchester', + HireDate: new Date(2008, 3, 20), + Age: 55, + JobTitle: 'Director', + Employees: [ + { + ID: 475, + Name: 'Michael Langdon', + HireDate: new Date(2011, 6, 3), + Age: 30, + JobTitle: 'Software Developer Evangelist', + Employees: null + }, + { + ID: 957, + Name: 'Thomas Hardy', + HireDate: new Date(2009, 6, 19), + Age: 29, + JobTitle: 'Junior Software Developer', + Employees: undefined + }, + { + ID: 317, + Name: 'Monica Reyes', + HireDate: new Date(2014, 8, 18), + Age: 31, + JobTitle: 'Manager', + Employees: [ + { + ID: 711, + Name: 'Eva Mendel', + HireDate: new Date(2015, 9, 17), + JobTitle: 'Senior Software Developer', + Age: 35 + }, + { + ID: 998, + Name: 'Sven Ottlieb', + HireDate: new Date(2009, 10, 11), + JobTitle: 'Senior Software Developer', + Age: 44 + }, + { + ID: 299, + Name: 'Peter Lewis', + HireDate: new Date(2018, 3, 18), + JobTitle: 'QA Developer', + Age: 25 + } + ] + }] + }, + { + ID: 19, + Name: 'Yang Wang', + HireDate: new Date(2010, 1, 1), + JobTitle: 'Software Developer', + Age: 61 + }, + { + ID: 847, + Name: 'Ana Sanders', + HireDate: new Date(2014, 1, 22), + Age: 42, + JobTitle: 'QA Developer', + Employees: [ + { + ID: 663, + Name: 'Elizabeth Richards', + HireDate: new Date(2017, 11, 9), + JobTitle: 'Software Developer', + Age: 25 + }] + } + ]); + + /* All types tree data: Every employee node has ID, Name, HireDate, Age, OnPTO and Employees */ + public static employeeAllTypesTreeData = () => ([ + { + ID: 147, + Name: 'John Winchester', + HireDate: new Date(2008, 3, 20), + Age: 55, + OnPTO: false, + Employees: [ + { + ID: 475, + Name: 'Michael Langdon', + HireDate: new Date(2011, 6, 3), + Age: 30, + OnPTO: true, + Employees: null + }, + { + ID: 957, + Name: 'Thomas Hardy', + HireDate: new Date(2009, 6, 19), + Age: 29, + OnPTO: false, + Employees: undefined + }, + { + ID: 317, + Name: 'Monica Reyes', + HireDate: new Date(2014, 8, 18), + Age: 31, + OnPTO: true, + Employees: [ + { + ID: 711, + Name: 'Roland Mendel', + HireDate: new Date(2015, 9, 17), + Age: 35, + OnPTO: false, + }, + { + ID: 998, + Name: 'Sven Ottlieb', + HireDate: new Date(2009, 10, 11), + Age: 44, + OnPTO: false, + }, + { + ID: 299, + Name: 'Peter Lewis', + HireDate: new Date(2018, 3, 18), + Age: 25, + OnPTO: false, + } + ] + }] + }, + { + ID: 19, + Name: 'Yang Wang', + HireDate: new Date(2010, 1, 1), + Age: 61, + OnPTO: true + }, + { + ID: 847, + Name: 'Ana Sanders', + HireDate: new Date(2014, 1, 22), + Age: 42, + OnPTO: false, + Employees: [ + { + ID: 663, + Name: 'Elizabeth Richards', + HireDate: new Date(2017, 11, 9), + Age: 25, + OnPTO: true + }] + } + ]); + + public static employeeTreeDataDisplayOrder = () => ([ + { ID: 1, ParentID: -1, Name: 'Casey Houston', JobTitle: 'Vice President', Age: 32 }, + { ID: 2, ParentID: 1, Name: 'Gilberto Todd', JobTitle: 'Director', Age: 41 }, + { ID: 3, ParentID: 2, Name: 'Tanya Bennett', JobTitle: 'Director', Age: 29 }, + { ID: 7, ParentID: 2, Name: 'Debra Morton', JobTitle: 'Associate Software Developer', Age: 35 }, + { ID: 4, ParentID: 1, Name: 'Jack Simon', JobTitle: 'Software Developer', Age: 33 }, + { ID: 6, ParentID: -1, Name: 'Erma Walsh', JobTitle: 'CEO', Age: 52 }, + { ID: 10, ParentID: -1, Name: 'Eduardo Ramirez', JobTitle: 'Manager', Age: 53 }, + { ID: 9, ParentID: 10, Name: 'Leslie Hansen', JobTitle: 'Associate Software Developer', Age: 44 } + ]); + + public static employeePrimaryForeignKeyTreeData = () => ([ + { ID: 1, ParentID: -1, Name: 'Casey Houston', JobTitle: 'Vice President', Age: 32 }, + { ID: 2, ParentID: 1, Name: 'Gilberto Todd', JobTitle: 'Director', Age: 41 }, + { ID: 3, ParentID: 2, Name: 'Tanya Bennett', JobTitle: 'Director', Age: 29 }, + { ID: 4, ParentID: 1, Name: 'Jack Simon', JobTitle: 'Software Developer', Age: 33 }, + { ID: 6, ParentID: -1, Name: 'Erma Walsh', JobTitle: 'CEO', Age: 52 }, + { ID: 7, ParentID: 2, Name: 'Debra Morton', JobTitle: 'Associate Software Developer', Age: 35 }, + { ID: 9, ParentID: 10, Name: 'Leslie Hansen', JobTitle: 'Associate Software Developer', Age: 44 }, + { ID: 10, ParentID: -1, Name: 'Eduardo Ramirez', JobTitle: 'Manager', Age: 53 } + ]); + + public static employeeTreeDataPrimaryForeignKey = () => ([ + { + ID: 147, + ParentID: -1, + Name: 'John Winchester', + HireDate: new Date(2008, 3, 20), + Age: 55, + OnPTO: false + }, + { + ID: 475, + ParentID: 147, + Name: 'Michael Langdon', + HireDate: new Date(2011, 6, 3), + Age: 43, + OnPTO: false, + Employees: null + }, + { + ID: 957, + ParentID: 147, + Name: 'Thomas Hardy', + HireDate: new Date(2009, 6, 19), + Age: 29, + OnPTO: true, + Employees: undefined + }, + { + ID: 317, + ParentID: 147, + Name: 'Monica Reyes', + HireDate: new Date(2014, 8, 18), + Age: 31, + OnPTO: false + }, + { + ID: 711, + ParentID: 317, + Name: 'Roland Mendel', + HireDate: new Date(2015, 9, 17), + Age: 35, + OnPTO: true, + }, + { + ID: 998, + ParentID: 317, + Name: 'Sven Ottlieb', + HireDate: new Date(2009, 10, 11), + Age: 44, + OnPTO: false, + }, + { + ID: 847, + ParentID: -1, + Name: 'Ana Sanders', + HireDate: new Date(2014, 1, 22), + Age: 42, + OnPTO: false + }, + { + ID: 225, + ParentID: 847, + Name: 'Laurence Johnson', + HireDate: new Date(2014, 4, 4), + OnPTO: true, + Age: 44, + }, + { + ID: 663, + ParentID: 847, + Name: 'Elizabeth Richards', + HireDate: new Date(2017, 11, 9), + Age: 25, + OnPTO: false + }, + { + ID: 141, + ParentID: 663, + Name: 'Trevor Ashworth', + HireDate: new Date(2010, 3, 22), + OnPTO: false, + Age: 39 + }, + { + ID: 19, + ParentID: -1, + Name: 'Victoria Lincoln', + HireDate: new Date(2014, 1, 22), + Age: 49, + OnPTO: false + }, + { + ID: 15, + ParentID: 19, + Name: 'Antonio Moreno', + HireDate: new Date(2014, 4, 4), + Age: 44, + OnPTO: true, + Employees: [] + }, + { + ID: 17, + ParentID: -1, + Name: 'Yang Wang', + HireDate: new Date(2010, 1, 1), + Age: 61, + OnPTO: false + }, + { + ID: 12, + ParentID: 17, + Name: 'Pedro Afonso', + HireDate: new Date(2007, 11, 18), + Age: 50, + OnPTO: false + }, + { + ID: 109, + ParentID: 12, + Name: 'Patricio Simpson', + HireDate: new Date(2017, 11, 9), + Age: 25, + OnPTO: false, + Employees: [] + }, + { + ID: 99, + ParentID: 12, + Name: 'Francisco Chang', + HireDate: new Date(2010, 3, 22), + OnPTO: true, + Age: 39 + }, + { + ID: 299, + ParentID: 12, + Name: 'Peter Lewis', + HireDate: new Date(2018, 3, 18), + OnPTO: false, + Age: 25 + }, + { + ID: 101, + ParentID: 17, + Name: 'Casey Harper', + HireDate: new Date(2016, 2, 19), + OnPTO: false, + Age: 27 + } + ]); + + public static employeeTreeDataPrimaryForeignKeyExt = () => ([ + { + ID: 147, + ParentID: -1, + Name: 'John Winchester', + HireDate: new Date(2008, 3, 20), + Age: 55, + OnPTO: false, + JobTitle: 'Director' + }, + { + ID: 475, + ParentID: 147, + Name: 'Michael Langdon', + HireDate: new Date(2011, 6, 3), + Age: 43, + OnPTO: false, + Employees: null, + JobTitle: 'Software Developer' + }, + { + ID: 957, + ParentID: 147, + Name: 'Thomas Hardy', + HireDate: new Date(2009, 6, 19), + Age: 29, + OnPTO: true, + Employees: undefined, + JobTitle: 'Associate Software Developer' + }, + { + ID: 317, + ParentID: 147, + Name: 'Monica Reyes', + HireDate: new Date(2014, 8, 18), + Age: 31, + OnPTO: false, + JobTitle: 'Software Developer' + }, + { + ID: 711, + ParentID: 317, + Name: 'Roland Mendel', + HireDate: new Date(2015, 9, 17), + Age: 35, + OnPTO: true, + JobTitle: 'Software Developer' + }, + { + ID: 998, + ParentID: 317, + Name: 'Sven Ottlieb', + HireDate: new Date(2009, 10, 11), + Age: 44, + OnPTO: false, + JobTitle: 'Senior Software Developer' + }, + { + ID: 847, + ParentID: -1, + Name: 'Ana Sanders', + HireDate: new Date(2014, 1, 22), + Age: 42, + OnPTO: false, + JobTitle: 'Vice President' + }, + { + ID: 225, + ParentID: 847, + Name: 'Laurence Johnson', + HireDate: new Date(2014, 4, 4), + OnPTO: true, + Age: 44, + JobTitle: 'Senior Software Developer' + }, + { + ID: 663, + ParentID: 847, + Name: 'Elizabeth Richards', + HireDate: new Date(2017, 11, 9), + Age: 25, + OnPTO: false, + JobTitle: 'Associate Software Developer' + }, + + { + ID: 141, + ParentID: 663, + Name: 'Trevor Ashworth', + HireDate: new Date(2010, 3, 22), + OnPTO: false, + Age: 39, + JobTitle: 'Software Developer' + }, + { + ID: 19, + ParentID: -1, + Name: 'Victoria Lincoln', + HireDate: new Date(2014, 1, 22), + Age: 49, + OnPTO: false, + JobTitle: 'Director' + }, + { + ID: 15, + ParentID: 19, + Name: 'Antonio Moreno', + HireDate: new Date(2014, 4, 4), + Age: 44, + OnPTO: true, + Employees: [], + JobTitle: 'Senior Software Developer, TL' + }, + { + ID: 17, + ParentID: -1, + Name: 'Yang Wang', + HireDate: new Date(2010, 1, 1), + Age: 61, + OnPTO: false, + JobTitle: 'Director' + }, + { + ID: 12, + ParentID: 17, + Name: 'Pedro Afonso', + HireDate: new Date(2007, 11, 18), + Age: 50, + OnPTO: false, + JobTitle: 'Director' + }, + { + ID: 109, + ParentID: 12, + Name: 'Patricio Simpson', + HireDate: new Date(2017, 11, 9), + Age: 25, + OnPTO: false, + Employees: [], + JobTitle: 'Associate Software Developer' + }, + { + ID: 99, + ParentID: 12, + Name: 'Francisco Chang', + HireDate: new Date(2010, 3, 22), + OnPTO: true, + Age: 39, + JobTitle: 'Senior Software Developer' + }, + { + ID: 299, + ParentID: 12, + Name: 'Peter Lewis', + HireDate: new Date(2018, 3, 18), + OnPTO: false, + Age: 25, + JobTitle: 'Associate Software Developer' + }, + { + ID: 101, + ParentID: 17, + Name: 'Casey Harper', + HireDate: new Date(2010, 3, 22), + OnPTO: false, + Age: 27, + JobTitle: 'Software Developer' + } + ]); + + public static employeeTreeDataCaseSensitive = () => ([ + { + ID: 147, + ParentID: -1, + Name: 'John Winchester', + HireDate: new Date(2008, 3, 20), + Age: 55, + OnPTO: false, + JobTitle: 'Director' + }, + { + ID: 475, + ParentID: 147, + Name: 'Michael Langdon', + HireDate: new Date(2011, 6, 3), + Age: 43, + OnPTO: false, + Employees: null, + JobTitle: 'Software Developer' + }, + { + ID: 957, + ParentID: 147, + Name: 'Thomas Hardy', + HireDate: new Date(2009, 6, 19), + Age: 29, + OnPTO: true, + Employees: undefined, + JobTitle: 'Software developer' + }, + { + ID: 317, + ParentID: 147, + Name: 'Monica Reyes', + HireDate: new Date(2014, 8, 18), + Age: 31, + OnPTO: false, + JobTitle: 'Software Developer' + }, + { + ID: 19, + ParentID: -1, + Name: 'Victoria Lincoln', + HireDate: new Date(2014, 1, 22), + Age: 49, + OnPTO: false, + JobTitle: 'Director' + } + ]); + + public static employeeGroupByData = () => ([ + { + ID: 475, + ParentID: 147, + Name: 'Michael Langdon', + HireDate: new Date(2011, 6, 3), + Age: 43, + OnPTO: false, + Employees: null + }, + { + ID: 957, + ParentID: 147, + Name: 'Thomas Hardy', + HireDate: new Date(2009, 6, 19), + Age: 29, + OnPTO: true, + Employees: undefined + }, + { + ID: 317, + ParentID: 147, + Name: 'Monica Reyes', + HireDate: new Date(2014, 8, 18), + Age: 31, + OnPTO: false + }, + { + ID: 225, + ParentID: 847, + Name: 'Laurence Johnson', + HireDate: new Date(2014, 4, 4), + OnPTO: true, + Age: 44, + }, + { + ID: 663, + ParentID: 847, + Name: 'Elizabeth Richards', + HireDate: new Date(2017, 11, 9), + Age: 25, + OnPTO: false + }, + + { + ID: 15, + ParentID: 19, + Name: 'Antonio Moreno', + HireDate: new Date(2014, 4, 4), + Age: 44, + OnPTO: true, + Employees: [] + }, + { + ID: 12, + ParentID: 17, + Name: 'Pedro Afonso', + HireDate: new Date(2007, 11, 18), + Age: 50, + OnPTO: false + }, + { + ID: 101, + ParentID: 17, + Name: 'Casey Harper', + HireDate: new Date(2016, 2, 19), + OnPTO: false, + Age: 27 + } + ]); + + public static excelFilteringData = () => ([ + { + Downloads: 254, + ID: 1, + ProductName: 'Ignite UI for JavaScript', + ReleaseDate: SampleTestData.timeGenerator.timedelta(SampleTestData.today, 'day', 1), + ReleaseDateTime: SampleTestData.timeGenerator.timedelta(SampleTestData.todayFullDate, 'hour', 1), + ReleaseTime: SampleTestData.timeGenerator.timedelta(SampleTestData.todayFullDate, 'hour', 1), + Released: false, + AnotherField: 'a', + Revenue: 100000 + }, + { + Downloads: 127, + ID: 2, + ProductName: 'NetAdvantage', + ReleaseDate: SampleTestData.timeGenerator.timedelta(SampleTestData.today, 'month', -1), + ReleaseDateTime: SampleTestData.timeGenerator.timedelta(SampleTestData.todayFullDate, 'hour', -1), + ReleaseTime: SampleTestData.timeGenerator.timedelta(SampleTestData.todayFullDate, 'hour', -1), + Released: true, + AnotherField: 'a', + Revenue: 40000 + }, + { + Downloads: 20, + ID: 3, + ProductName: 'Ignite UI for Angular', + ReleaseDate: null, + ReleaseDateTime: null, + ReleaseTime: null, + Released: null, + AnotherField: 'a', + Revenue: 9000 + }, + { + Downloads: null, + ID: 4, + ProductName: null, + ReleaseDate: SampleTestData.timeGenerator.timedelta(SampleTestData.today, 'day', -1), + ReleaseDateTime: SampleTestData.timeGenerator.timedelta(SampleTestData.todayFullDate, 'minute', -10), + ReleaseTime: SampleTestData.timeGenerator.timedelta(SampleTestData.todayFullDate, 'minute', -10), + Released: true, + AnotherField: 'a', + Revenue: 10000 + }, + { + Downloads: 100, + ID: 5, + ProductName: '', + ReleaseDate: undefined, + ReleaseDateTime: undefined, + ReleaseTime: undefined, + Released: false, + AnotherField: 'a', + Revenue: 30000 + }, + { + Downloads: 702, + ID: 6, + ProductName: 'Some other item with Script', + ReleaseDate: SampleTestData.timeGenerator.timedelta(SampleTestData.today, 'day', 1), + ReleaseDateTime: SampleTestData.timeGenerator.timedelta(SampleTestData.todayFullDate, 'second', 20), + ReleaseTime: SampleTestData.timeGenerator.timedelta(SampleTestData.todayFullDate, 'second', 20), + Released: null, + AnotherField: 'Custom', + Revenue: 60000 + }, + { + Downloads: 0, + ID: 7, + ProductName: null, + ReleaseDate: SampleTestData.timeGenerator.timedelta(SampleTestData.today, 'month', 1), + ReleaseDateTime: SampleTestData.timeGenerator.timedelta(SampleTestData.todayFullDate, 'minute', +10), + ReleaseTime: SampleTestData.timeGenerator.timedelta(SampleTestData.todayFullDate, 'minute', +10), + Released: true, + AnotherField: 'custoM', + Revenue: 10000 + }, + { + Downloads: 1000, + ID: 8, + ProductName: null, + ReleaseDate: SampleTestData.today, + ReleaseDateTime: SampleTestData.todayFullDate, + ReleaseTime: SampleTestData.todayFullDate, + Released: undefined, + AnotherField: 'custom', + Revenue: 50000 + } + ]); + + public static excelFilteringDataDuplicateValues = () => ([ + { + Downloads: 254, + ID: 1, + ProductName: 'Ignite UI for JavaScript', + ReleaseDate: SampleTestData.timeGenerator.timedelta(SampleTestData.today, 'day', 1), + ReleaseDateTime: SampleTestData.timeGenerator.timedelta(SampleTestData.todayFullDate, 'hour', 1), + ReleaseTime: SampleTestData.timeGenerator.timedelta(SampleTestData.todayFullDate, 'hour', 1), + Released: false, + AnotherField: 'a', + Revenue: 100000 + }, + { + Downloads: 702, + ID: 2, + ProductName: 'Some other item with Script', + ReleaseDate: SampleTestData.timeGenerator.timedelta(SampleTestData.today, 'day', 1), + ReleaseDateTime: SampleTestData.timeGenerator.timedelta(SampleTestData.todayFullDate, 'second', 20), + ReleaseTime: SampleTestData.timeGenerator.timedelta(SampleTestData.todayFullDate, 'second', 20), + Released: null, + AnotherField: 'Custom', + Revenue: 60000 + }, + { + Downloads: 0, + ID: 3, + ProductName: null, + ReleaseDate: SampleTestData.timeGenerator.timedelta(SampleTestData.today, 'month', 1), + ReleaseDateTime: SampleTestData.timeGenerator.timedelta(SampleTestData.todayFullDate, 'minute', +10), + ReleaseTime: SampleTestData.timeGenerator.timedelta(SampleTestData.todayFullDate, 'minute', +10), + Released: true, + AnotherField: 'custoM', + Revenue: 10000 + }, + { + Downloads: 1000, + ID: 4, + ProductName: null, + ReleaseDate: SampleTestData.today, + ReleaseDateTime: SampleTestData.todayFullDate, + ReleaseTime: SampleTestData.todayFullDate, + Released: undefined, + AnotherField: 'custom', + Revenue: 50000 + }, + { + Downloads: 1000, + ID: 5, + ProductName: null, + ReleaseDate: SampleTestData.today, + ReleaseDateTime: SampleTestData.todayFullDate, + ReleaseTime: SampleTestData.todayFullDate, + Released: undefined, + AnotherField: 'custom_1', + Revenue: 50000 + }, + { + Downloads: 1000, + ID: 6, + ProductName: null, + ReleaseDate: SampleTestData.today, + ReleaseDateTime: SampleTestData.todayFullDate, + ReleaseTime: SampleTestData.todayFullDate, + Released: undefined, + AnotherField: 'custom_A', + Revenue: 50000 + } + ]); + + /* Data fields: Price: number, Brand: string, Model: string, Edition: string */ + public static exportGroupedDataColumns = () => ([ + { Price: 75000, Brand: 'Tesla', Model: 'Model S', Edition: 'Sport' }, + { Price: 100000, Brand: 'Tesla', Model: 'Roadster', Edition: 'Performance' }, + { Price: 65000, Brand: 'Tesla', Model: 'Model S', Edition: 'Base' }, + { Price: 150000, Brand: 'BMW', Model: 'M5', Edition: 'Competition' }, + { Price: 100000, Brand: 'BMW', Model: 'M5', Edition: 'Performance' }, + { Price: 75000, Brand: 'VW', Model: 'Arteon', Edition: 'Business' }, + { Price: 65000, Brand: 'VW', Model: 'Passat', Edition: 'Business' }, + { Price: 100000, Brand: 'VW', Model: 'Arteon', Edition: 'R Line' }, + ]); + + /* Data fields: Artist, Debut, GrammyNominations, GrammyAwards, Tours, Albums, Songs */ + public static hierarchicalGridExportData = () => ([ + { + Artist: 'Naomí Yepes', + Debut: 2011, + GrammyNominations: 6, + GrammyAwards: 0, + HasGrammyAward: false, + Tours: [ + { + Tour: 'Faithful Tour', + StartedOn: 'Sep 12', + Location: 'Worldwide', + Headliner: 'NO', + TouredBy: 'Naomí Yepes', + TourData: [ + { + Country: 'Belgium', + TicketsSold: 10000, + Attendants: 10000, + }, + { + Country: 'USA', + TicketsSold: 192300, + Attendants: 186523, + } + ] + }, + { + Tour: 'Faithful Tour', + StartedOn: 'Sep 12', + Location: 'Worldwide', + Headliner: 'NO', + TouredBy: 'Naomí Yepes' + }, + { + Tour: 'Faithful Tour', + StartedOn: 'Sep 12', + Location: 'Worldwide', + Headliner: 'NO', + TouredBy: 'Naomí Yepes' + }, + { + Tour: 'Faithful Tour', + StartedOn: 'Sep 12', + Location: 'Worldwide', + Headliner: 'NO', + TouredBy: 'Naomí Yepes' + } + ], + Albums: [ + { + Album: 'Pushing up daisies', + LaunchDate: new Date('May 31, 2000'), + BillboardReview: 86, + USBillboard200: 42, + Artist: 'Naomí Yepes', + Songs: [ + { + Number: 1, + Title: 'Wood Shavifdsafdsafsangs Forever', + Released: new Date('9 Jun 2019'), + Genre: '*fdasfsa', + Album: 'Pushing up daisies' + }, + { + Number: 2, + Title: 'Wood Shavifdsafdsafsavngs Forever', + Released: new Date('9 Jun 2019'), + Genre: '*vxzvczx', + Album: 'Pushing up daisies' + }, + { + Number: 3, + Title: 'Wfdsafsaings Forever', + Released: new Date('9 Jun 2019'), + Genre: '*fdsacewwwqwq', + Album: 'Pushing up daisies' + }, + { + Number: 4, + Title: 'Wood Shavings Forever', + Released: new Date('9 Jun 2019'), + Genre: '*rewqrqcxz', + Album: 'Pushing up daisies' + }, + ] + }, + { + Album: 'Pushing up daisies - Deluxe', + LaunchDate: new Date('May 31, 2001'), + BillboardReview: 12, + USBillboard200: 2, + Artist: 'Naomí Yepes', + Songs: [ + { + Number: 1, + Title: 'Wood Shavings Forever - Remix', + Released: new Date('9 Jun 2020'), + Genre: 'Punk', + Album: 'Pushing up daisies' + }, + ] + }, + { + Album: 'Utopia', + LaunchDate: new Date('Dec 19, 2021'), + BillboardReview: 1, + USBillboard200: 1, + Artist: 'Naomí Yepes', + Songs: [ + { + Number: 1, + Title: 'SANTORINI', + Released: new Date('19 Dec 2021'), + Genre: 'Hip-Hop', + Album: 'Utopia' + }, + { + Number: 2, + Title: 'HEARTBEAT', + Released: new Date('19 Dec 2021'), + Genre: 'Hip-Hop', + Album: 'Utopia' + }, + { + Number: 3, + Title: 'OVERSEAS', + Released: new Date('19 Dec 2021'), + Genre: 'Hip-Hop', + Album: 'Utopia' + }, + ] + }, + { + Album: 'Wish You Were Here', + LaunchDate: new Date('Jul 17, 2020'), + BillboardReview: 5, + USBillboard200: 3, + Artist: 'Naomí Yepes', + Songs: [ + { + Number: 1, + Title: 'Zoom', + Released: new Date('17 Jul 2020'), + Genre: 'Hip-Hop', + Album: 'Wish You Were Here' + }, + { + Number: 2, + Title: 'Do You?', + Released: new Date('17 Jul 2020'), + Genre: 'Hip-Hop', + Album: 'Wish You Were Here' + }, + { + Number: 3, + Title: 'No Photos', + Released: new Date('17 Jul 2020'), + Genre: 'Hip-Hop', + Album: 'Wish You Were Here' + }, + ] + } + ] + }, + { + Artist: 'Babila Ebwélé', + Debut: 2009, + GrammyNominations: 0, + GrammyAwards: 11, + HasGrammyAward: true, + Albums: [ + { + Album: 'Fahrenheit', + LaunchDate: new Date('May 31, 2000'), + BillboardReview: 86, + USBillboard200: 42, + Artist: 'Babila Ebwélé', + Songs: [ + { + Number: 1, + Title: 'Show Out', + Released: new Date('17 Jul 2020'), + Genre: 'Hip-Hop', + Album: 'Fahrenheit' + }, + { + Number: 2, + Title: 'Mood Swings', + Released: new Date('17 Jul 2020'), + Genre: 'Hip-Hop', + Album: 'Fahrenheit' + }, + { + Number: 3, + Title: 'Scenario', + Released: new Date('17 Jul 2020'), + Genre: 'Hip-Hop', + Album: 'Fahrenheit' + }, + ] + } + ], + Tours: [ + { + Tour: 'Astroworld', + StartedOn: 'Jul 21', + Location: 'Worldwide', + Headliner: 'NO', + TouredBy: 'Babila Ebwélé', + TourData: [ + { + Country: 'Bulgaria', + TicketsSold: 25000, + Attendants: 19822, + }, + { + Country: 'Romania', + TicketsSold: 65021, + Attendants: 63320, + } + ] + }, + ] + }, + { + Artist: 'Chloe', + Debut: 2015, + GrammyNominations: 3, + GrammyAwards: 1, + HasGrammyAward: true, + } + ]); + + /* Data fields: Artist, Debut, GrammyNominations, GrammyAwards, Tours, Albums, Songs */ + public static hierarchicalGridSingersFullData = () => ([ + { + ID: 0, + Artist: 'Naomí Yepes', + Debut: 2011, + GrammyNominations: 6, + GrammyAwards: 0, + HasGrammyAward: false, + Tours: [ + { + Tour: 'Faithful Tour', + StartedOn: 'Sep 12', + Location: 'Worldwide', + Headliner: 'NO', + TouredBy: 'Naomí Yepes' + }, + { + Tour: 'City Jam Sessions', + StartedOn: 'Aug 13', + Location: 'North America', + Headliner: 'YES', + TouredBy: 'Naomí Yepes' + }, + { + Tour: 'Christmas NYC 2013', + StartedOn: 'Dec 13', + Location: 'United States', + Headliner: 'NO', + TouredBy: 'Naomí Yepes' + }, + { + Tour: 'Christmas NYC 2014', + StartedOn: 'Dec 14', + Location: 'North America', + Headliner: 'NO', + TouredBy: 'Naomí Yepes' + }, + { + Tour: 'Watermelon Tour', + StartedOn: 'Feb 15', + Location: 'Worldwide', + Headliner: 'YES', + TouredBy: 'Naomí Yepes' + }, + { + Tour: 'Christmas NYC 2016', + StartedOn: 'Dec 16', + Location: 'United States', + Headliner: 'NO', + TouredBy: 'Naomí Yepes' + }, + { + Tour: 'The Dragon Tour', + StartedOn: 'Feb 17', + Location: 'Worldwide', + Headliner: 'NO', + TouredBy: 'Naomí Yepes' + }, + { + Tour: 'Organic Sessions', + StartedOn: 'Aug 18', + Location: 'United States, England', + Headliner: 'YES', + TouredBy: 'Naomí Yepes' + }, + { + Tour: 'Hope World Tour', + StartedOn: 'Mar 19', + Location: 'Worldwide', + Headliner: 'NO', + TouredBy: 'Naomí Yepes' + } + ], + Albums: [ + { + Album: 'Initiation', + LaunchDate: new Date('September 3, 2013'), + BillboardReview: 86, + USBillboard200: 1, + Artist: 'Naomí Yepes', + Songs: [{ + Number: 1, + Title: 'Ambitious', + Released: new Date('28 Apr 2015'), + Genre: 'Dance-pop R&B', + Album: 'Initiation' + }, + { + Number: 2, + Title: 'My heart will go on', + Released: new Date('24 May 2015'), + Genre: 'Dance-pop R&B', + Album: 'Initiation' + }, + { + Number: 3, + Title: 'Sing to me', + Released: new Date('28 May 2015'), + Genre: 'Dance-pop R&B', + Album: 'Initiation' + }, + { + Number: 4, + Title: 'Want to dance with somebody', + Released: new Date('03 Jun 2015'), + Genre: 'Dance-pop R&B', + Album: 'Initiation' + }] + }, + { + Album: 'Dream Driven', + LaunchDate: new Date('August 25, 2014'), + BillboardReview: 81, + USBillboard200: 1, + Artist: 'Naomí Yepes', + Songs: [{ + Number: 1, + Title: 'Intro', + Released: null, + Genre: '*', + Album: 'Dream Driven' + }, + { + Number: 2, + Title: 'Ferocious', + Released: new Date('28 Apr 2014'), + Genre: 'Dance-pop R&B', + Album: 'Dream Driven' + }, + { + Number: 3, + Title: 'Going crazy', + Released: new Date('10 Feb 2015'), + Genre: 'Dance-pop EDM', + Album: 'Dream Driven' + }, + { + Number: 4, + Title: 'Future past', + Released: null, + Genre: '*', + Album: 'Dream Driven' + }, + { + Number: 5, + Title: 'Roaming like them', + Released: new Date('2 Jul 2014'), + Genre: 'Electro house Electropop', + Album: 'Dream Driven' + }, + { + Number: 6, + Title: 'Last Wishes', + Released: new Date('12 Aug 2014'), + Genre: 'R&B', + Album: 'Dream Driven' + }, + { + Number: 7, + Title: 'Stay where you are', + Released: null, + Genre: '*', + Album: 'Dream Driven' + }, + { + Number: 8, + Title: 'Imaginarium', + Released: null, + Genre: '*', + Album: 'Dream Driven' + }, + { + Number: 9, + Title: 'Tell me', + Released: new Date('30 Sep 2014'), + Genre: 'Synth-pop R&B', + Album: 'Dream Driven' + }, + { + Number: 10, + Title: 'Shredded into pieces', + Released: null, + Genre: '*', + Album: 'Dream Driven' + }, + { + Number: 11, + Title: 'Capture this moment', + Released: null, + Genre: '*', + Album: 'Dream Driven' + }, + { + Number: 12, + Title: 'Dream Driven', + Released: null, + Genre: '*', + Album: 'Dream Driven' + }] + }, + { + Album: 'The dragon journey', + LaunchDate: new Date('May 20, 2016'), + BillboardReview: 60, + USBillboard200: 2, + Artist: 'Naomí Yepes', + Songs: [{ + Number: 1, + Title: 'My dream', + Released: new Date('13 Jan 2017'), + Genre: 'Dance-pop EDM', + Album: 'The dragon journey' + }, + { + Number: 2, + Title: 'My passion', + Released: new Date('23 Sep 2017'), + Genre: 'Crunk reggaeton', + Album: 'The dragon journey' + }, + { + Number: 3, + Title: 'What is love', + Released: new Date('28 Nov 2018'), + Genre: 'Dance-pop R&B', + Album: 'The dragon journey' + }, + { + Number: 4, + Title: 'Negative', + Released: new Date('01 Dec 2018'), + Genre: 'Dance-pop EDM', + Album: 'The dragon journey' + }] + }, + { + Album: 'Organic me', + LaunchDate: new Date('August 17, 2018'), + BillboardReview: 82, + USBillboard200: 1, + Artist: 'Naomí Yepes', + Songs: [{ + Number: 1, + Title: 'I Love', + Released: new Date('11 May 2019'), + Genre: 'Crunk reggaeton', + Album: 'Organic me' + }, + { + Number: 2, + Title: 'Early Morning Compass', + Released: new Date('15 Jan 2020'), + Genre: 'mystical parody-bap ', + Album: 'Organic me' + }, + { + Number: 3, + Title: 'Key Fields Forever', + Released: new Date('2 Jan 2020'), + Genre: 'Dance-pop EDM', + Album: 'Organic me' + }, + { + Number: 4, + Title: 'Stand by Your Goblins', + Released: new Date('20 Nov 2019'), + Genre: '*', + Album: 'Organic me' + }, + { + Number: 5, + Title: 'Mad to Walk', + Released: new Date('12 May 2019'), + Genre: 'Electro house Electropop', + Album: 'Organic me' + }, + { + Number: 6, + Title: 'Alice\'s Waiting', + Released: new Date('28 Jan 2020'), + Genre: 'R&B', + Album: 'Organic me' + }, + { + Number: 7, + Title: 'We Shall Kiss', + Released: new Date('30 Oct 2019'), + Genre: '*', + Album: 'Organic me' + }, + { + Number: 8, + Title: 'Behind Single Ants', + Released: new Date('2 Oct 2019'), + Genre: '*', + Album: 'Organic me' + }, + { + Number: 9, + Title: 'Soap Autopsy', + Released: new Date('8 Aug 2019'), + Genre: 'Synth-pop R&B', + Album: 'Organic me' + }, + { + Number: 10, + Title: 'Have You Met Rich?', + Released: new Date('1 Jul 2019'), + Genre: 'ethno-tunes', + Album: 'Organic me' + }, + { + Number: 11, + Title: 'Livin\' on a Banana', + Released: new Date('22 Nov 2019'), + Genre: 'Crunk reggaeton', + Album: 'Organic me' + }] + }, + { + Album: 'Curiosity', + LaunchDate: new Date('December 7, 2019'), + BillboardReview: 75, + USBillboard200: 12, + Artist: 'Naomí Yepes', + Songs: [{ + Number: 1, + Title: 'Goals', + Released: new Date('07 Dec 2019'), + Genre: '*', + Album: 'Curiosity' + }, + { + Number: 2, + Title: 'Explorer', + Released: new Date('08 Dec 2019'), + Genre: 'Crunk reggaeton', + Album: 'Curiosity' + }, + { + Number: 3, + Title: 'I need to know', + Released: new Date('09 Dec 2019'), + Genre: 'Dance-pop R&B', + Album: 'Curiosity' + }, + { + Number: 4, + Title: 'Finding my purpose', + Released: new Date('10 Dec 2019'), + Genre: 'Heavy metal', + Album: 'Curiosity' + }, + { + Number: 5, + Title: 'Faster than the speed of love', + Released: new Date('21 Dec 2019'), + Genre: 'Dance-pop EDM', + Album: 'Curiosity' + }, + { + Number: 6, + Title: 'I like it', + Released: new Date('01 Jan 2020'), + Genre: 'Dance-pop EDM', + Album: 'Curiosity' + }] + } + ] + }, + { + ID: 1, + Artist: 'Babila Ebwélé', + Debut: 2009, + GrammyNominations: 0, + GrammyAwards: 11, + HasGrammyAward: true, + Tours: [ + { + Tour: 'The last straw', + StartedOn: 'May 09', + Location: 'Europe, Asia', + Headliner: 'NO', + TouredBy: 'Babila Ebwélé' + }, + { + Tour: 'No foundations', + StartedOn: 'Jun 04', + Location: 'United States, Europe', + Headliner: 'YES', + TouredBy: 'Babila Ebwélé' + }, + { + Tour: 'Crazy eyes', + StartedOn: 'Jun 08', + Location: 'North America', + Headliner: 'NO', + TouredBy: 'Babila Ebwélé' + }, + { + Tour: 'Zero gravity', + StartedOn: 'Apr 19', + Location: 'United States', + Headliner: 'NO', + TouredBy: 'Babila Ebwélé' + }, + { + Tour: 'Battle with myself', + StartedOn: 'Mar 08', + Location: 'North America', + Headliner: 'YES', + TouredBy: 'Babila Ebwélé' + }], + Albums: [ + { + Album: 'Pushing up daisies', + LaunchDate: new Date('May 31, 2000'), + BillboardReview: 86, + USBillboard200: 42, + Artist: 'Babila Ebwélé', + Songs: [{ + Number: 1, + Title: 'Wood Shavings Forever', + Released: new Date('9 Jun 2019'), + Genre: '*', + Album: 'Pushing up daisies' + }, + { + Number: 2, + Title: 'Early Morning Drive', + Released: new Date('20 May 2019'), + Genre: '*', + Album: 'Pushing up daisies' + }, + { + Number: 3, + Title: 'Don\'t Natter', + Released: new Date('10 Jun 2019'), + Genre: 'adult calypso-industrial', + Album: 'Pushing up daisies' + }, + { + Number: 4, + Title: 'Stairway to Balloons', + Released: new Date('18 Jun 2019'), + Genre: 'calypso and mariachi', + Album: 'Pushing up daisies' + }, + { + Number: 5, + Title: 'The Number of your Apple', + Released: new Date('29 Oct 2019'), + Genre: '*', + Album: 'Pushing up daisies' + }, + { + Number: 6, + Title: 'Your Delightful Heart', + Released: new Date('24 Feb 2019'), + Genre: '*', + Album: 'Pushing up daisies' + }, + { + Number: 7, + Title: 'Nice Weather For Balloons', + Released: new Date('1 Aug 2019'), + Genre: 'rap-hop', + Album: 'Pushing up daisies' + }, + { + Number: 8, + Title: 'The Girl From Cornwall', + Released: new Date('4 May 2019'), + Genre: 'enigmatic rock-and-roll', + Album: 'Pushing up daisies' + }, + { + Number: 9, + Title: 'Here Without Jack', + Released: new Date('24 Oct 2019'), + Genre: '*', + Album: 'Pushing up daisies' + }, + { + Number: 10, + Title: 'Born Rancid', + Released: new Date('19 Mar 2019'), + Genre: '*', + Album: 'Pushing up daisies' + }] + }, + { + Album: 'Death\'s dead', + LaunchDate: new Date('June 8, 2016'), + BillboardReview: 85, + USBillboard200: 95, + Artist: 'Babila Ebwélé', + Songs: [{ + Number: 1, + Title: 'Men Sound Better With You', + Released: new Date('20 Oct 2016'), + Genre: 'rap-hop', + Album: 'Death\'s dead' + }, + { + Number: 2, + Title: 'Ghost in My Rod', + Released: new Date('5 Oct 2016'), + Genre: 'enigmatic rock-and-roll', + Album: 'Death\'s dead' + }, + { + Number: 3, + Title: 'Bed of Men', + Released: new Date('14 Nov 2016'), + Genre: 'whimsical comedy-grass ', + Album: 'Death\'s dead' + }, + { + Number: 4, + Title: 'Don\'t Push', + Released: new Date('2 Jan 2017'), + Genre: 'unblack electronic-trip-hop', + Album: 'Death\'s dead' + }, + { + Number: 5, + Title: 'Nice Weather For Men', + Released: new Date('18 Dec 2017'), + Genre: '*', + Album: 'Death\'s dead' + }, + { + Number: 6, + Title: 'Rancid Rhapsody', + Released: new Date('10 Mar 2017'), + Genre: '*', + Album: 'Death\'s dead' + }, + { + Number: 7, + Title: 'Push, Push, Push!', + Released: new Date('21 Feb 2017'), + Genre: '*', + Album: 'Death\'s dead' + }, + { + Number: 8, + Title: 'My Name is Sarah', + Released: new Date('15 Nov 2017'), + Genre: '*', + Album: 'Death\'s dead' + }, + { + Number: 9, + Title: 'The Girl From My Hotel', + Released: new Date('6 Nov 2017'), + Genre: '*', + Album: 'Death\'s dead' + }, + { + Number: 10, + Title: 'Free Box', + Released: new Date('18 Apr 2017'), + Genre: 'splitter-funk', + Album: 'Death\'s dead' + }, + { + Number: 11, + Title: 'Hotel Cardiff', + Released: new Date('30 Dec 2017'), + Genre: 'guilty pleasure ebm', + Album: 'Death\'s dead' + }] + }] + }, + { + ID: 2, + Artist: 'Ahmad Nazeri', + Debut: 2004, + GrammyNominations: 3, + GrammyAwards: 1, + HasGrammyAward: true, + Tours: [], + Albums: [ + { + Album: 'Emergency', + LaunchDate: new Date('March 6, 2004'), + BillboardReview: 98, + USBillboard200: 69, + Artist: 'Ahmad Nazeri', + Songs: [{ + Number: 1, + Title: 'I am machine', + Released: new Date('20 Oct 2004'), + Genre: 'Heavy metal', + Album: 'Emergency' + }, + { + Number: 2, + Title: 'I wish I knew', + Released: new Date('21 Oct 2004'), + Genre: 'rap-hop', + Album: 'Emergency' + }, + { + Number: 3, + Title: 'How I feel', + Released: new Date('22 Oct 2004'), + Genre: 'Heavy metal', + Album: 'Emergency' + }, + { + Number: 4, + Title: 'I am machine', + Released: new Date('30 Oct 2004'), + Genre: 'Heavy metal', + Album: 'Emergency' + }, + { + Number: 5, + Title: 'Monsters under my bed', + Released: new Date('01 Nov 2004'), + Genre: 'rap-hop', + Album: 'Emergency' + }, + { + Number: 6, + Title: 'I know what you want', + Released: new Date('20 Nov 2004'), + Genre: 'rap-hop', + Album: 'Emergency' + }, + { + Number: 7, + Title: 'Lies', + Released: new Date('21 Nov 2004'), + Genre: 'Heavy metal', + Album: 'Emergency' + }, + { + Number: 8, + Title: 'I did it for you', + Released: new Date('22 Nov 2004'), + Genre: 'rap-hop', + Album: 'Emergency' + }] + }, + { + Album: 'Bursting bubbles', + LaunchDate: new Date('April 17, 2006'), + BillboardReview: 69, + USBillboard200: 39, + Artist: 'Ahmad Nazeri', + Songs: [{ + Number: 1, + Title: 'Ghosts', + Released: new Date('20 Apr 2006'), + Genre: 'Hip-hop', + Album: 'Bursting bubbles' + }, + { + Number: 2, + Title: 'What goes around comes around', + Released: new Date('20 Apr 2006'), + Genre: 'Heavy metal', + Album: 'Bursting bubbles' + }, + { + Number: 3, + Title: 'I want nothing', + Released: new Date('21 Apr 2006'), + Genre: 'Heavy metal', + Album: 'Bursting bubbles' + }, + { + Number: 4, + Title: 'Me and you', + Released: new Date('22 Apr 2006'), + Genre: 'Rock', + Album: 'Bursting bubbles' + }] + } + ] + }, + { + ID: 3, + Artist: 'Kimmy McIlmorie', + Debut: 2007, + GrammyNominations: 21, + GrammyAwards: 3, + HasGrammyAward: true, + Albums: [ + { + Album: 'Here we go again', + LaunchDate: new Date('November 18, 2017'), + BillboardReview: 68, + USBillboard200: 1, + Artist: 'Kimmy McIlmorie', + Songs: [{ + Number: 1, + Title: 'Same old love', + Released: new Date('20 Nov 2017'), + Genre: 'Hip-hop', + Album: 'Here we go again' + }, + { + Number: 2, + Title: 'Sick of it', + Released: new Date('20 Nov 2017'), + Genre: 'Hip-hop', + Album: 'Here we go again' + }, + { + Number: 3, + Title: 'No one', + Released: new Date('21 Nov 2017'), + Genre: 'Metal', + Album: 'Here we go again' + }, + { + Number: 4, + Title: 'Circles', + Released: new Date('22 Nov 2017'), + Genre: 'Heavy metal', + Album: 'Here we go again' + }, + { + Number: 5, + Title: 'Coming for you', + Released: new Date('30 Nov 2017'), + Genre: 'Hip-hop', + Album: 'Here we go again' + }] + } + ] + }, + { + ID: 4, + Artist: 'Mar Rueda', + Debut: 1996, + GrammyNominations: 14, + GrammyAwards: 2, + HasGrammyAward: true, + Albums: [ + { + Album: 'Trouble', + LaunchDate: new Date('November 18, 2017'), + BillboardReview: 65, + USBillboard200: 2, + Artist: 'Mar Rueda', + Songs: [{ + Number: 1, + Title: 'You knew I was trouble', + Released: new Date('20 Nov 2017'), + Genre: 'Pop', + Album: 'Trouble' + }, + { + Number: 2, + Title: 'Cannot live without you', + Released: new Date('20 Nov 2017'), + Genre: 'Pop', + Album: 'Trouble' + }, + { + Number: 3, + Title: 'Lost you', + Released: new Date('21 Nov 2017'), + Genre: 'Metal', + Album: 'Trouble' + }, + { + Number: 4, + Title: 'Happiness starts with you', + Released: new Date('22 Nov 2017'), + Genre: '*', + Album: 'Trouble' + }, + { + Number: 5, + Title: 'I saw it coming', + Released: new Date('30 Dec 2017'), + Genre: 'Hip-hop', + Album: 'Trouble' + }] + } + ] + }, + { + ID: 5, + Artist: 'Izabella Tabakova', + Debut: 2017, + GrammyNominations: 7, + GrammyAwards: 11, + HasGrammyAward: true, + Tours: [ + { + Tour: 'Final breath', + StartedOn: 'Jun 13', + Location: 'Europe', + Headliner: 'YES', + TouredBy: 'Izabella Tabakova' + }, + { + Tour: 'Once bitten', + StartedOn: 'Dec 18', + Location: 'Australia, United States', + Headliner: 'NO', + TouredBy: 'Izabella Tabakova' + }, + { + Tour: 'Code word', + StartedOn: 'Sep 19', + Location: 'United States, Europe', + Headliner: 'NO', + TouredBy: 'Izabella Tabakova' + }, + { + Tour: 'Final draft', + StartedOn: 'Sep 17', + Location: 'United States, Europe', + Headliner: 'YES', + TouredBy: 'Izabella Tabakova' + } + ], + Albums: [ + { + Album: 'Once bitten', + LaunchDate: new Date('July 16, 2007'), + BillboardReview: 79, + USBillboard200: 53, + Artist: 'Izabella Tabakova', + Songs: [{ + Number: 1, + Title: 'Whole Lotta Super Cats', + Released: new Date('21 May 2019'), + Genre: '*', + Album: 'Once bitten' + }, + { + Number: 2, + Title: 'Enter Becky', + Released: new Date('16 Jan 2020'), + Genre: '*', + Album: 'Once bitten' + }, + { + Number: 3, + Title: 'Your Cheatin\' Flamingo', + Released: new Date('14 Jan 2020'), + Genre: '*', + Album: 'Once bitten' + }, + { + Number: 4, + Title: 'Mad to Kiss', + Released: new Date('6 Nov 2019'), + Genre: 'Synth-pop R&B', + Album: 'Once bitten' + }, + { + Number: 5, + Title: 'Hotel Prague', + Released: new Date('20 Oct 2019'), + Genre: 'ethno-tunes', + Album: 'Once bitten' + }, + { + Number: 6, + Title: 'Jail on My Mind', + Released: new Date('31 May 2019'), + Genre: 'Crunk reggaeton', + Album: 'Once bitten' + }, + { + Number: 7, + Title: 'Amazing Blues', + Released: new Date('29 May 2019'), + Genre: 'mystical parody-bap ', + Album: 'Once bitten' + }, + { + Number: 8, + Title: 'Goody Two Iron Filings', + Released: new Date('4 Jul 2019'), + Genre: 'Electro house Electropop', + Album: 'Once bitten' + }, + { + Number: 9, + Title: 'I Love in Your Arms', + Released: new Date('7 Jun 2019'), + Genre: 'R&B', + Album: 'Once bitten' + }, + { + Number: 10, + Title: 'Truly Madly Amazing', + Released: new Date('12 Sep 2019'), + Genre: 'ethno-tunes', + Album: 'Once bitten' + } + ] + }, + { + Album: 'Your graciousness', + LaunchDate: new Date('November 17, 2004'), + BillboardReview: 69, + USBillboard200: 30, + Artist: 'Izabella Tabakova', + Songs: [ + { + Number: 1, + Title: 'We Shall Tickle', + Released: new Date('31 Aug 2019'), + Genre: 'old emo-garage ', + Album: 'Your graciousness' + }, + { + Number: 2, + Title: 'Snail Boogie', + Released: new Date('14 Jun 2019'), + Genre: '*', + Album: 'Your graciousness' + }, + { + Number: 3, + Title: 'Amazing Liz', + Released: new Date('15 Oct 2019'), + Genre: '*', + Album: 'Your graciousness' + }, + { + Number: 4, + Title: 'When Sexy Aardvarks Cry', + Released: new Date('1 Oct 2019'), + Genre: 'whimsical comedy-grass ', + Album: 'Your graciousness' + }, + { + Number: 5, + Title: 'Stand By Dave', + Released: new Date('18 Aug 2019'), + Genre: 'unblack electronic-trip-hop', + Album: 'Your graciousness' + }, + { + Number: 6, + Title: 'The Golf Course is Your Land', + Released: new Date('2 Apr 2019'), + Genre: '*', + Album: 'Your graciousness' + }, + { + Number: 7, + Title: 'Where Have All the Men Gone?', + Released: new Date('29 Apr 2019'), + Genre: '*', + Album: 'Your graciousness' + }, + { + Number: 8, + Title: 'Rhythm of the Leg', + Released: new Date('5 Aug 2019'), + Genre: 'ethno-tunes', + Album: 'Your graciousness' + }, + { + Number: 9, + Title: 'Baby, I Need Your Hats', + Released: new Date('5 Dec 2019'), + Genre: 'neuro-tunes', + Album: 'Your graciousness' + }, + { + Number: 10, + Title: 'Stand by Your Cat', + Released: new Date('25 Jul 2019'), + Genre: '*', + Album: 'Your graciousness' + }] + }, + { + Album: 'Dark matters', + LaunchDate: new Date('November 3, 2002'), + BillboardReview: 79, + USBillboard200: 85, + Artist: 'Izabella Tabakova', + Songs: [{ + Number: 1, + Title: 'The Sun', + Released: new Date('31 Oct 2002'), + Genre: 'old emo-garage ', + Album: 'Dark matters' + }, + { + Number: 2, + Title: 'I will survive', + Released: new Date('03 Nov 2002'), + Genre: 'old emo-garage ', + Album: 'Dark matters' + }, + { + Number: 3, + Title: 'Try', + Released: new Date('04 Nov 2002'), + Genre: 'old emo-garage ', + Album: 'Dark matters' + }, + { + Number: 4, + Title: 'Miracle', + Released: new Date('05 Nov 2002'), + Genre: 'old emo-garage ', + Album: 'Dark matters' + }] + } + ] + }, + { + ID: 6, + Artist: 'Nguyễn Diệp Chi', + Debut: 1992, + GrammyNominations: 4, + GrammyAwards: 2, + HasGrammyAward: true, + Albums: [ + { + Album: 'Library of liberty', + LaunchDate: new Date('December 22, 2003'), + BillboardReview: 93, + USBillboard200: 5, + Artist: 'Nguyễn Diệp Chi', + Songs: [{ + Number: 1, + Title: 'Book of love', + Released: new Date('31 Dec 2003'), + Genre: 'Hip-hop', + Album: 'Library of liberty' + }, + { + Number: 2, + Title: 'Commitment', + Released: new Date('01 Jan 2004'), + Genre: 'Hip-hop', + Album: 'Library of liberty' + }, + { + Number: 3, + Title: 'Satisfaction', + Released: new Date('01 Jan 2004'), + Genre: 'Hip-hop', + Album: 'Library of liberty' + }, + { + Number: 4, + Title: 'Obsession', + Released: new Date('01 Jan 2004'), + Genre: 'Hip-hop', + Album: 'Library of liberty' + }, + { + Number: 5, + Title: 'Oblivion', + Released: new Date('02 Jan 2004'), + Genre: 'Hip-hop', + Album: 'Library of liberty' + }, + { + Number: 6, + Title: 'Energy', + Released: new Date('03 Jan 2004'), + Genre: 'Hip-hop', + Album: 'Library of liberty' + }] + } + ] + }, + { + ID: 7, + Artist: 'Eva Lee', + Debut: 2008, + GrammyNominations: 2, + GrammyAwards: 0, + HasGrammyAward: false, + Albums: [ + { + Album: 'Just a tease', + LaunchDate: new Date('May 3, 2001'), + BillboardReview: 91, + USBillboard200: 29, + Artist: 'Eva Lee', + Songs: [{ + Number: 1, + Title: 'We shall see', + Released: new Date('03 May 2001'), + Genre: 'rap-hop', + Album: 'Just a tease' + }, + { + Number: 2, + Title: 'Hopeless', + Released: new Date('04 May 2001'), + Genre: 'rap-hop', + Album: 'Just a tease' + }, + { + Number: 3, + Title: 'Ignorant', + Released: new Date('04 May 2001'), + Genre: 'rap-hop', + Album: 'Just a tease' + }, + { + Number: 4, + Title: 'Dance', + Released: new Date('05 May 2019'), + Genre: 'Metal', + Album: 'Just a tease' + }, + { + Number: 5, + Title: 'Fire', + Released: new Date('06 May 2019'), + Genre: 'Metal', + Album: 'Just a tease' + }] + } + ] + }, + { + ID: 8, + Artist: 'Siri Jakobsson', + Debut: 1990, + GrammyNominations: 2, + GrammyAwards: 8, + HasGrammyAward: true, + Tours: [ + { + Tour: 'Basket case', + StartedOn: 'Jan 07', + Location: 'Europe, Asia', + Headliner: 'NO', + TouredBy: 'Siri Jakobsson' + }, + { + Tour: 'The bigger fish', + StartedOn: 'Dec 07', + Location: 'United States, Europe', + Headliner: 'YES', + TouredBy: 'Siri Jakobsson' + }, + { + Tour: 'Missed the boat', + StartedOn: 'Jun 09', + Location: 'Europe, Asia', + Headliner: 'NO', + TouredBy: 'Siri Jakobsson' + }, + { + Tour: 'Equivalent exchange', + StartedOn: 'Feb 06', + Location: 'United States, Europe', + Headliner: 'YES', + TouredBy: 'Siri Jakobsson' + }, + { + Tour: 'Damage control', + StartedOn: 'Oct 11', + Location: 'Australia, United States', + Headliner: 'NO', + TouredBy: 'Siri Jakobsson' + } + ], + Albums: [ + { + Album: 'Under the bus', + LaunchDate: new Date('May 14, 2000'), + BillboardReview: 67, + USBillboard200: 67, + Artist: 'Siri Jakobsson', + Songs: [ + { + Number: 1, + Title: 'Jack Broke My Heart At Tesco\'s', + Released: new Date('19 Jan 2020'), + Genre: '*', + Album: 'Under the bus' + }, + { + Number: 2, + Title: 'Cat Deep, Hats High', + Released: new Date('5 Dec 2019'), + Genre: '*', + Album: 'Under the bus' + }, + { + Number: 3, + Title: 'In Snail We Trust', + Released: new Date('31 May 2019'), + Genre: 'hardcore opera', + Album: 'Under the bus' + }, + { + Number: 4, + Title: 'Liz\'s Waiting', + Released: new Date('22 Jul 2019'), + Genre: 'emotional C-jam ', + Album: 'Under the bus' + }, + { + Number: 5, + Title: 'Lifeless Blues', + Released: new Date('14 Jun 2019'), + Genre: '*', + Album: 'Under the bus' + }, + { + Number: 6, + Title: 'I Spin', + Released: new Date('26 Mar 2019'), + Genre: '*', + Album: 'Under the bus' + }, + { + Number: 7, + Title: 'Ring of Rock', + Released: new Date('12 Dec 2019'), + Genre: '*', + Album: 'Under the bus' + }, + { + Number: 8, + Title: 'Livin\' on a Rock', + Released: new Date('17 Apr 2019'), + Genre: '*', + Album: 'Under the bus' + }, + { + Number: 9, + Title: 'Your Lifeless Heart', + Released: new Date('15 Sep 2019'), + Genre: 'adult calypso-industrial', + Album: 'Under the bus' + }, + { + Number: 10, + Title: 'The High Street on My Mind', + Released: new Date('11 Nov 2019'), + Genre: 'calypso and mariachi', + Album: 'Under the bus' + }, + { + Number: 11, + Title: 'Behind Ugly Curtains', + Released: new Date('8 May 2019'), + Genre: '*', + Album: 'Under the bus' + }, + { + Number: 12, + Title: 'Where Have All the Curtains Gone?', + Released: new Date('28 Jun 2019'), + Genre: '*', + Album: 'Under the bus' + }, + { + Number: 13, + Title: 'Ghost in My Apple', + Released: new Date('14 Dec 2019'), + Genre: '*', + Album: 'Under the bus' + }, + { + Number: 14, + Title: 'I Chatter', + Released: new Date('30 Nov 2019'), + Genre: '*', + Album: 'Under the bus' + } + ] + } + ] + }, + { + ID: 9, + Artist: 'Pablo Cambeiro', + Debut: 2011, + GrammyNominations: 5, + GrammyAwards: 0, + HasGrammyAward: false, + Tours: [ + { + Tour: 'Beads', + StartedOn: 'May 11', + Location: 'Worldwide', + Headliner: 'NO', + TouredBy: 'Pablo Cambeiro' + }, + { + Tour: 'Concept art', + StartedOn: 'Dec 18', + Location: 'United States', + Headliner: 'YES', + TouredBy: 'Pablo Cambeiro' + }, + { + Tour: 'Glass shoe', + StartedOn: 'Jan 20', + Location: 'Worldwide', + Headliner: 'YES', + TouredBy: 'Pablo Cambeiro' + }, + { + Tour: 'Pushing buttons', + StartedOn: 'Feb 15', + Location: 'Europe, Asia', + Headliner: 'NO', + TouredBy: 'Pablo Cambeiro' + }, + { + Tour: 'Dark matters', + StartedOn: 'Jan 04', + Location: 'Australia, United States', + Headliner: 'YES', + TouredBy: 'Pablo Cambeiro' + }, + { + Tour: 'Greener grass', + StartedOn: 'Sep 09', + Location: 'United States, Europe', + Headliner: 'NO', + TouredBy: 'Pablo Cambeiro' + }, + { + Tour: 'Apparatus', + StartedOn: 'Nov 16', + Location: 'Europe', + Headliner: 'NO', + TouredBy: 'Pablo Cambeiro' + } + ], + Albums: [ + { + Album: 'Fluke', + LaunchDate: new Date('August 4, 2017'), + BillboardReview: 93, + USBillboard200: 98, + Artist: 'Pablo Cambeiro', + Songs: [{ + Number: 1, + Title: 'Silence', + Released: new Date('25 Aug 2017'), + Genre: 'rap-hop', + Album: 'Fluke' + }, + { + Number: 2, + Title: 'Nothing matters anymore', + Released: new Date('25 Aug 2017'), + Genre: '*', + Album: 'Fluke' + }, + { + Number: 3, + Title: 'Everything wrong with me', + Released: new Date('25 Aug 2017'), + Genre: '*', + Album: 'Fluke' + }] + }, + { + Album: 'Crowd control', + LaunchDate: new Date('August 26, 2003'), + BillboardReview: 68, + USBillboard200: 84, + Artist: 'Pablo Cambeiro', + Songs: [{ + Number: 1, + Title: 'My Bed on My Mind', + Released: new Date('25 Mar 2019'), + Genre: 'ethno-tunes', + Album: 'Crowd control' + }, + { + Number: 2, + Title: 'Bright Blues', + Released: new Date('28 Sep 2019'), + Genre: 'neuro-tunes', + Album: 'Crowd control' + }, + { + Number: 3, + Title: 'Sail, Sail, Sail!', + Released: new Date('5 Mar 2019'), + Genre: '*', + Album: 'Crowd control' + }, + { + Number: 4, + Title: 'Hotel My Bed', + Released: new Date('22 Mar 2019'), + Genre: '*', + Album: 'Crowd control' + }, + { + Number: 5, + Title: 'Gonna Make You Mash', + Released: new Date('18 May 2019'), + Genre: '*', + Album: 'Crowd control' + }, + { + Number: 6, + Title: 'Straight Outta America', + Released: new Date('16 Jan 2020'), + Genre: 'hardcore opera', + Album: 'Crowd control' + }, + { + Number: 7, + Title: 'I Drive', + Released: new Date('23 Feb 2019'), + Genre: 'emotional C-jam ', + Album: 'Crowd control' + }, + { + Number: 8, + Title: 'Like a Teddy', + Released: new Date('31 Aug 2019'), + Genre: '*', + Album: 'Crowd control' + }, + { + Number: 9, + Title: 'Teddy Boogie', + Released: new Date('30 Nov 2019'), + Genre: '*', + Album: 'Crowd control' + }] + }] + }, + { + ID: 10, + Artist: 'Athar Malakooti', + Debut: 2017, + GrammyNominations: 0, + GrammyAwards: 0, + HasGrammyAward: false, + Albums: [ + { + Album: 'Pushing up daisies', + LaunchDate: new Date('February 24, 2016'), + BillboardReview: 74, + USBillboard200: 77, + Artist: 'Athar Malakooti', + Songs: [{ + Number: 1, + Title: 'Actions', + Released: new Date('25 Feb 2016'), + Genre: 'ethno-tunes', + Album: 'Pushing up daisies' + }, + { + Number: 2, + Title: 'Blinding lights', + Released: new Date('28 Feb 2016'), + Genre: 'neuro-tunes', + Album: 'Pushing up daisies' + }, + { + Number: 3, + Title: 'I want more', + Released: new Date('5 Mar 2016'), + Genre: '*', + Album: 'Pushing up daisies' + }, + { + Number: 4, + Title: 'House by the lake', + Released: new Date('22 Mar 2016'), + Genre: '*', + Album: 'Pushing up daisies' + }] + } + ] + }, + { + ID: 11, + Artist: 'Marti Valencia', + Debut: 2004, + GrammyNominations: 1, + GrammyAwards: 1, + HasGrammyAward: true, + Tours: [ + { + Tour: 'Cat eat cat world', + StartedOn: 'Sep 00', + Location: 'Worldwide', + Headliner: 'YES', + TouredBy: 'Marti Valencia' + }, + { + Tour: 'Final straw', + StartedOn: 'Sep 06', + Location: 'United States, Europe', + Headliner: 'NO', + TouredBy: 'Marti Valencia' + }], + Albums: [ + { + Album: 'Nemesis', + LaunchDate: new Date('June 30, 2004'), + BillboardReview: 94, + USBillboard200: 9, + Artist: 'Marti Valencia', + Songs: [{ + Number: 1, + Title: 'Love in motion', + Released: new Date('25 Jun 2004'), + Genre: 'ethno-tunes', + Album: 'Nemesis' + }, + { + Number: 2, + Title: 'The picture', + Released: new Date('28 Jun 2004'), + Genre: 'neuro-tunes', + Album: 'Nemesis' + }, + { + Number: 3, + Title: 'Flowers', + Released: new Date('5 Jul 2004'), + Genre: '*', + Album: 'Nemesis' + }, + { + Number: 4, + Title: 'Regret', + Released: new Date('22 Avg 2004'), + Genre: 'Heavy metal', + Album: 'Nemesis' + }] + }, + { + Album: 'First chance', + LaunchDate: new Date('January 7, 2019'), + BillboardReview: 96, + USBillboard200: 19, + Artist: 'Marti Valencia', + Songs: [{ + Number: 1, + Title: 'My Name is Jason', + Released: new Date('12 Jul 2019'), + Genre: '*', + Album: 'First chance' + }, + { + Number: 2, + Title: 'Amazing Andy', + Released: new Date('5 Mar 2019'), + Genre: '*', + Album: 'First chance' + }, + { + Number: 3, + Title: 'The Number of your Knight', + Released: new Date('4 Dec 2019'), + Genre: '*', + Album: 'First chance' + }, + { + Number: 4, + Title: 'I Sail', + Released: new Date('3 Mar 2019'), + Genre: '*', + Album: 'First chance' + }, + { + Number: 5, + Title: 'Goody Two Hands', + Released: new Date('11 Oct 2019'), + Genre: 'Electro house Electropop', + Album: 'First chance' + }, + { + Number: 6, + Title: 'Careful With That Knife', + Released: new Date('18 Dec 2019'), + Genre: 'R&B', + Album: 'First chance' + }, + { + Number: 7, + Title: 'Four Single Ants', + Released: new Date('18 Jan 2020'), + Genre: '*', + Album: 'First chance' + }, + { + Number: 8, + Title: 'Kiss Forever', + Released: new Date('10 Aug 2019'), + Genre: '*', + Album: 'First chance' + }, + { + Number: 9, + Title: 'Rich\'s Waiting', + Released: new Date('15 Mar 2019'), + Genre: 'Synth-pop R&B', + Album: 'First chance' + }, + { + Number: 10, + Title: 'Japan is Your Land', + Released: new Date('7 Mar 2019'), + Genre: 'ethno-tunes', + Album: 'First chance' + }, + { + Number: 11, + Title: 'Pencils in My Banana', + Released: new Date('21 Jun 2019'), + Genre: 'Crunk reggaeton', + Album: 'First chance' + }, + { + Number: 12, + Title: 'I Sail in Your Arms', + Released: new Date('30 Apr 2019'), + Genre: 'Synth-pop R&B', + Album: 'First chance' + }] + }, + { + Album: 'God\'s advocate', + LaunchDate: new Date('April 29, 2007'), + BillboardReview: 66, + USBillboard200: 37, + Artist: 'Marti Valencia', + Songs: [{ + Number: 1, + Title: 'Destiny', + Released: new Date('07 May 2007'), + Genre: '*', + Album: 'God\'s advocate' + }, + { + Number: 2, + Title: 'I am the chosen one', + Released: new Date('08 May 2007'), + Genre: 'Heavy metal', + Album: 'God\'s advocate' + }, + { + Number: 3, + Title: 'New me', + Released: new Date('09 May 2007'), + Genre: 'Dance-pop R&B', + Album: 'God\'s advocate' + }, + { + Number: 4, + Title: 'Miss you', + Released: new Date('10 May 2007'), + Genre: 'Heavy metal', + Album: 'God\'s advocate' + }, + { + Number: 5, + Title: 'Turn back the time', + Released: new Date('21 May 2007'), + Genre: 'Dance-pop EDM', + Album: 'God\'s advocate' + }, + { + Number: 6, + Title: 'Let us have fun', + Released: new Date('01 Jun 2007'), + Genre: 'Dance-pop EDM', + Album: 'God\'s advocate' + }] + } + ] + }, + { + ID: 12, + Artist: 'Alicia Stanger', + Debut: 2010, + GrammyNominations: 1, + GrammyAwards: 0, + HasGrammyAward: false, + Albums: [ + { + Album: 'Forever alone', + LaunchDate: new Date('November 3, 2005'), + BillboardReview: 82, + USBillboard200: 7, + Artist: 'Alicia Stanger', + Songs: [{ + Number: 1, + Title: 'Brothers', + Released: new Date('25 Oct 2005'), + Genre: 'Hip-hop', + Album: 'Forever alone' + }, + { + Number: 2, + Title: 'Alone', + Released: new Date('28 Oct 2005'), + Genre: 'Heavy metal', + Album: 'Forever alone' + }, + { + Number: 3, + Title: 'I will go on', + Released: new Date('5 Nov 2005'), + Genre: 'Heavy metal', + Album: 'Forever alone' + }, + { + Number: 4, + Title: 'Horses', + Released: new Date('22 Dec 2005'), + Genre: '*', + Album: 'Forever alone' + }] + } + ] + }, + { + ID: 13, + Artist: 'Peter Taylor', + Debut: 2005, + GrammyNominations: 0, + GrammyAwards: 2, + HasGrammyAward: true, + Tours: [ + { + Tour: 'Love', + StartedOn: 'Jun 04', + Location: 'Europe, Asia', + Headliner: 'YES', + TouredBy: 'Peter Taylor' + }, + { + Tour: 'Fault of treasures', + StartedOn: 'Oct 13', + Location: 'North America', + Headliner: 'NO', + TouredBy: 'Peter Taylor' + }, + { + Tour: 'For eternity', + StartedOn: 'Mar 05', + Location: 'United States', + Headliner: 'YES', + TouredBy: 'Peter Taylor' + }, + { + Tour: 'Time flies', + StartedOn: 'Jun 03', + Location: 'North America', + Headliner: 'NO', + TouredBy: 'Peter Taylor' + }, + { + Tour: 'Highest difficulty', + StartedOn: 'Nov 01', + Location: 'Worldwide', + Headliner: 'YES', + TouredBy: 'Peter Taylor' + }, + { + Tour: 'Sleeping dogs', + StartedOn: 'May 04', + Location: 'United States, Europe', + Headliner: 'NO', + TouredBy: 'Peter Taylor' + } + ], + Albums: [ + { + Album: 'Decisions decisions', + LaunchDate: new Date('April 10, 2008'), + BillboardReview: 85, + USBillboard200: 35, + Artist: 'Peter Taylor', + Songs: [{ + Number: 1, + Title: 'Now that I am alone', + Released: new Date('25 Apr 2008'), + Genre: '*', + Album: 'Decisions decisions' + }, + { + Number: 2, + Title: 'Hopefully', + Released: new Date('26 Apr 2008'), + Genre: '*', + Album: 'Decisions decisions' + }, + { + Number: 3, + Title: 'Wonderful life', + Released: new Date('5 May 2008'), + Genre: '*', + Album: 'Decisions decisions' + }, + { + Number: 4, + Title: 'Amazing world', + Released: new Date('22 Dec 2008'), + Genre: '*', + Album: 'Decisions decisions' + }] + }, + { + Album: 'Climate changed', + LaunchDate: new Date('June 20, 2015'), + BillboardReview: 66, + USBillboard200: 89, + Artist: 'Peter Taylor', + Songs: [{ + Number: 1, + Title: 'This is how I am now', + Released: new Date('22 Jun 2015'), + Genre: 'Hip-hop', + Album: 'Climate changed' + }, + { + Number: 2, + Title: 'I feel', + Released: new Date('26 Jun 2015'), + Genre: 'rap-hop', + Album: 'Climate changed' + }, + { + Number: 3, + Title: 'Do I want to know', + Released: new Date('5 Jul 2015'), + Genre: 'rap-hop', + Album: 'Climate changed' + }, + { + Number: 4, + Title: 'Natural love', + Released: new Date('22 Jul 2015'), + Genre: '*', + Album: 'Climate changed' + }, + { + Number: 5, + Title: 'I will help', + Released: new Date('22 Jul 2015'), + Genre: '*', + Album: 'Climate changed' + }, + { + Number: 6, + Title: 'No matter what', + Released: new Date('22 Jul 2015'), + Genre: 'hip-hop', + Album: 'Climate changed' + }] + } + ] + } + ]); + + public static gridProductData = () => [{ + ProductID: 1, + ProductName: "Chai", + SupplierID: 1, + CategoryID: 1, + QuantityPerUnit: "10 boxes x 20 bags", + UnitPrice: 18.0000, + UnitsInStock: 39, + UnitsOnOrder: 30, + ReorderLevel: 10, + Discontinued: false, + OrderDate: new Date("2012-02-12") + }, { + ProductID: 2, + ProductName: "Chang", + SupplierID: 1, + CategoryID: 1, + QuantityPerUnit: "24 - 12 oz bottles", + UnitPrice: 19.0000, + UnitsInStock: 17, + UnitsOnOrder: 40, + ReorderLevel: 25, + Discontinued: true, + OrderDate: new Date("2003-03-17") + }, { + ProductID: 3, + 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") + }, { + ProductID: 4, + ProductName: "Chef Antons Cajun Seasoning", + SupplierID: 2, + CategoryID: 2, + QuantityPerUnit: "48 - 6 oz jars", + UnitPrice: 22.0000, + UnitsInStock: 53, + UnitsOnOrder: 30, + ReorderLevel: 0, + Discontinued: false, + OrderDate: new Date("2016-03-17") + }, { + ProductID: 5, + ProductName: "Chef Antons Gumbo Mix", + SupplierID: 2, + CategoryID: 2, + QuantityPerUnit: "36 boxes", + UnitPrice: 21.3500, + UnitsInStock: 0, + UnitsOnOrder: 30, + ReorderLevel: 0, + Discontinued: true, + OrderDate: new Date("2011-11-11") + }, { + ProductID: 6, + ProductName: "Grandmas Boysenberry Spread", + SupplierID: 3, + CategoryID: 2, + QuantityPerUnit: "12 - 8 oz jars", + UnitPrice: 25.0000, + UnitsInStock: 0, + UnitsOnOrder: 30, + ReorderLevel: 25, + Discontinued: false, + OrderDate: new Date("2017-12-17") + }, { + ProductID: 7, + ProductName: "Uncle Bobs Organic Dried Pears", + SupplierID: 3, + CategoryID: 7, + QuantityPerUnit: "12 - 1 lb pkgs.", + UnitPrice: 30.0000, + UnitsInStock: 150, + UnitsOnOrder: 30, + ReorderLevel: 10, + Discontinued: false, + OrderDate: new Date("2016-07-17") + }, { + ProductID: 8, + ProductName: "Northwoods Cranberry Sauce", + SupplierID: 3, + CategoryID: 2, + QuantityPerUnit: "12 - 12 oz jars", + UnitPrice: 40.0000, + UnitsInStock: 6, + UnitsOnOrder: 30, + ReorderLevel: 0, + Discontinued: false, + OrderDate: new Date("2018-01-17") + }, { + ProductID: 9, + ProductName: "Mishi Kobe Niku", + SupplierID: 4, + CategoryID: 6, + QuantityPerUnit: "18 - 500 g pkgs.", + UnitPrice: 97.0000, + UnitsInStock: 29, + UnitsOnOrder: 30, + ReorderLevel: 0, + Discontinued: true, + OrderDate: new Date("2010-02-17") + }, { + ProductID: 10, + ProductName: "Ikura", + SupplierID: 4, + CategoryID: 8, + QuantityPerUnit: "12 - 200 ml jars", + UnitPrice: 31.0000, + UnitsInStock: 31, + UnitsOnOrder: 30, + ReorderLevel: 0, + Discontinued: false, + OrderDate: new Date("2008-05-17") + }, { + ProductID: 11, + ProductName: "Queso Cabrales", + SupplierID: 5, + CategoryID: 4, + QuantityPerUnit: "1 kg pkg.", + UnitPrice: 21.0000, + UnitsInStock: 22, + UnitsOnOrder: 30, + ReorderLevel: 30, + Discontinued: false, + OrderDate: new Date("2009-01-17") + }, { + ProductID: 12, + ProductName: "Queso Manchego La Pastora", + SupplierID: 5, + CategoryID: 4, + QuantityPerUnit: "10 - 500 g pkgs.", + UnitPrice: 38.0000, + UnitsInStock: 86, + UnitsOnOrder: 30, + ReorderLevel: 0, + Discontinued: false, + OrderDate: new Date("2015-11-17") + }, { + ProductID: 13, + ProductName: "Konbu", + SupplierID: 6, + CategoryID: 8, + QuantityPerUnit: "2 kg box", + UnitPrice: 6.0000, + UnitsInStock: 24, + UnitsOnOrder: 30, + ReorderLevel: 5, + Discontinued: false, + OrderDate: new Date("2015-03-17") + }, { + ProductID: 14, + ProductName: "Tofu", + SupplierID: 6, + CategoryID: 7, + QuantityPerUnit: "40 - 100 g pkgs.", + UnitPrice: 23.2500, + UnitsInStock: 35, + UnitsOnOrder: 30, + ReorderLevel: 0, + Discontinued: false, + OrderDate: new Date("2017-06-17") + }, { + ProductID: 15, + ProductName: "Genen Shouyu", + SupplierID: 6, + CategoryID: 2, + QuantityPerUnit: "24 - 250 ml bottles", + UnitPrice: 15.5000, + UnitsInStock: 39, + UnitsOnOrder: 30, + ReorderLevel: 5, + Discontinued: false, + OrderDate: new Date("2014-03-17") + }, { + ProductID: 16, + ProductName: "Pavlova", + SupplierID: 7, + CategoryID: 3, + QuantityPerUnit: "32 - 500 g boxes", + UnitPrice: 17.4500, + UnitsInStock: 29, + UnitsOnOrder: 30, + ReorderLevel: 10, + Discontinued: false, + OrderDate: new Date("2018-03-28") + }, { + ProductID: 17, + ProductName: "Alice Mutton", + SupplierID: 7, + CategoryID: 6, + QuantityPerUnit: "20 - 1 kg tins", + UnitPrice: 39.0000, + UnitsInStock: 0, + UnitsOnOrder: 30, + ReorderLevel: 0, + Discontinued: true, + OrderDate: new Date("2015-08-17") + }, { + ProductID: 18, + ProductName: "Carnarvon Tigers", + SupplierID: 7, + CategoryID: 8, + QuantityPerUnit: "16 kg pkg.", + UnitPrice: 62.5000, + UnitsInStock: 42, + UnitsOnOrder: 30, + ReorderLevel: 0, + Discontinued: false, + OrderDate: new Date("2005-09-27") + }, { + ProductID: 19, + ProductName: "Teatime Chocolate Biscuits", + SupplierID: 8, + CategoryID: 3, + QuantityPerUnit: "", + UnitPrice: 9.2000, + UnitsInStock: 25, + UnitsOnOrder: 30, + ReorderLevel: 5, + Discontinued: false, + OrderDate: new Date("2001-03-17") + }, { + ProductID: 20, + ProductName: "Sir Rodneys Marmalade", + SupplierID: 8, + CategoryID: 3, + QuantityPerUnit: undefined, + UnitPrice: 4.5, + UnitsInStock: 40, + UnitsOnOrder: 30, + ReorderLevel: 0, + Discontinued: false, + OrderDate: new Date("2005-03-17") + } + ]; + + public static gridCustomSummaryData = () => [{ + ProductID: 1, + ProductName: "Chai", + SupplierID: 1, + CategoryID: 1, + QuantityPerUnit: "10 boxes x 20 bags", + UnitPrice: 18.0000, + UnitsInStock: 39, + UnitsOnOrder: 30, + ReorderLevel: 10, + Discontinued: false, + OrderDate: new Date("2012-02-12") + }, { + ProductID: 2, + ProductName: "Chang", + SupplierID: 1, + CategoryID: 1, + QuantityPerUnit: "24 - 12 oz bottles", + UnitPrice: 19.0000, + UnitsInStock: 17, + UnitsOnOrder: 40, + ReorderLevel: 25, + Discontinued: true, + OrderDate: new Date("2003-03-17") + }, { + ProductID: 3, + 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") + }, { + ProductID: 4, + ProductName: "Chef Antons Cajun Seasoning", + SupplierID: 2, + CategoryID: 2, + QuantityPerUnit: "48 - 6 oz jars", + UnitPrice: 22.0000, + UnitsInStock: 53, + UnitsOnOrder: 30, + ReorderLevel: 0, + Discontinued: false, + OrderDate: new Date("2016-03-17") + }, { + ProductID: 5, + ProductName: "Chef Antons Gumbo Mix", + SupplierID: 2, + CategoryID: 2, + QuantityPerUnit: "36 boxes", + UnitPrice: 21.3500, + UnitsInStock: 0, + UnitsOnOrder: 30, + ReorderLevel: 0, + Discontinued: true, + OrderDate: new Date("2011-11-11") + }, { + ProductID: 6, + ProductName: "Grandmas Boysenberry Spread", + SupplierID: 3, + CategoryID: 2, + QuantityPerUnit: "12 - 8 oz jars", + UnitPrice: 25.0000, + UnitsInStock: 0, + UnitsOnOrder: 30, + ReorderLevel: 25, + Discontinued: false, + OrderDate: new Date("2017-12-17") + }, { + ProductID: 7, + ProductName: "Uncle Bobs Organic Dried Pears", + SupplierID: 3, + CategoryID: 7, + QuantityPerUnit: "12 - 1 lb pkgs.", + UnitPrice: 30.0000, + UnitsInStock: 150, + UnitsOnOrder: 30, + ReorderLevel: 10, + Discontinued: false, + OrderDate: new Date("2016-07-17") + }, { + ProductID: 8, + ProductName: "Northwoods Cranberry Sauce", + SupplierID: 3, + CategoryID: 2, + QuantityPerUnit: "12 - 12 oz jars", + UnitPrice: 40.0000, + UnitsInStock: 6, + UnitsOnOrder: 30, + ReorderLevel: 0, + Discontinued: false, + OrderDate: new Date("2018-01-17") + }, { + ProductID: 9, + ProductName: "Mishi Kobe Niku", + SupplierID: 4, + CategoryID: 6, + QuantityPerUnit: "18 - 500 g pkgs.", + UnitPrice: 97.0000, + UnitsInStock: 29, + UnitsOnOrder: 30, + ReorderLevel: 0, + Discontinued: true, + OrderDate: new Date("2010-02-17") + }, { + ProductID: 10, + ProductName: "Ikura", + SupplierID: 4, + CategoryID: 8, + QuantityPerUnit: "12 - 200 ml jars", + UnitPrice: 31.0000, + UnitsInStock: 31, + UnitsOnOrder: 30, + ReorderLevel: 0, + Discontinued: false, + OrderDate: new Date("2008-05-17") + }, { + ProductID: 11, + ProductName: "Queso Cabrales", + SupplierID: 5, + CategoryID: 4, + QuantityPerUnit: "1 kg pkg.", + UnitPrice: 21.0000, + UnitsInStock: 22, + UnitsOnOrder: 30, + ReorderLevel: 30, + Discontinued: false, + OrderDate: new Date("2009-01-17") + }, { + ProductID: 12, + ProductName: "Queso Manchego La Pastora", + SupplierID: 5, + CategoryID: 4, + QuantityPerUnit: "10 - 500 g pkgs.", + UnitPrice: 38.0000, + UnitsInStock: 86, + UnitsOnOrder: 30, + ReorderLevel: 0, + Discontinued: false, + OrderDate: new Date("2015-11-17") + }, { + ProductID: 13, + ProductName: "Konbu", + SupplierID: 6, + CategoryID: 8, + QuantityPerUnit: "2 kg box", + UnitPrice: 6.0000, + UnitsInStock: 24, + UnitsOnOrder: 30, + ReorderLevel: 5, + Discontinued: false, + OrderDate: new Date("2015-03-17") + }, { + ProductID: 14, + ProductName: "Tofu", + SupplierID: 6, + CategoryID: 7, + QuantityPerUnit: "40 - 100 g pkgs.", + UnitPrice: 23.2500, + UnitsInStock: 35, + UnitsOnOrder: 30, + ReorderLevel: 0, + Discontinued: false, + OrderDate: new Date("2017-06-17") + }, { + ProductID: 15, + ProductName: "Genen Shouyu", + SupplierID: 6, + CategoryID: 2, + QuantityPerUnit: "24 - 250 ml bottles", + UnitPrice: 15.5000, + UnitsInStock: 39, + UnitsOnOrder: 30, + ReorderLevel: 5, + Discontinued: false, + OrderDate: new Date("2014-03-17") + }, { + ProductID: 16, + ProductName: "Pavlova", + SupplierID: 7, + CategoryID: 3, + QuantityPerUnit: "32 - 500 g boxes", + UnitPrice: 17.4500, + UnitsInStock: 29, + UnitsOnOrder: 30, + ReorderLevel: 10, + Discontinued: false, + OrderDate: new Date("2018-03-28") + }, { + ProductID: 17, + ProductName: "Alice Mutton", + SupplierID: 7, + CategoryID: 6, + QuantityPerUnit: "20 - 1 kg tins", + UnitPrice: 39.0000, + UnitsInStock: 0, + UnitsOnOrder: 30, + ReorderLevel: 0, + Discontinued: true, + OrderDate: new Date("2015-08-17") + }, { + ProductID: 18, + ProductName: "Carnarvon Tigers", + SupplierID: 7, + CategoryID: 8, + QuantityPerUnit: "16 kg pkg.", + UnitPrice: 62.5000, + UnitsInStock: 42, + UnitsOnOrder: 30, + ReorderLevel: 0, + Discontinued: false, + OrderDate: new Date("2005-09-27") + }, { + ProductID: 19, + ProductName: "Teatime Chocolate Biscuits", + SupplierID: 8, + CategoryID: 3, + QuantityPerUnit: "", + UnitPrice: 9.2000, + UnitsInStock: 25, + UnitsOnOrder: 30, + ReorderLevel: 5, + Discontinued: false, + OrderDate: new Date("2001-03-17") + }, { + ProductID: 20, + ProductName: "Sir Rodneys Marmalade", + SupplierID: 8, + CategoryID: 3, + QuantityPerUnit: undefined, + UnitPrice: 4.5, + UnitsInStock: 40, + UnitsOnOrder: 30, + ReorderLevel: 0, + Discontinued: false, + OrderDate: new Date("2005-03-17") + } + ]; + + /** + * Generates simple array of primitve values + * + * @param rows Number of items to add to the array + * @param type The type of the items + */ + public static generateListOfPrimitiveValues(rows: number, type: string): any[] { + const data: any[] = []; + for (let row = 0; row < rows; row++) { + if (type === 'Number') { + data.push(row); + } else if (type === 'String') { + data.push(`Row ${row}`); + } else if (type === 'Boolean') { + data.push(row % 7 === 0); + } + } + return data; + } + + /* Generates hierahical data */ + public static generateHGridData(count: number, level: number, parendID?) { + const prods = []; + const currLevel = level; + let children; + for (let i = 0; i < count; i++) { + const rowID = parendID ? parendID + i : i.toString(); + if (level > 0 ) { + children = this.generateHGridData(count / 2 , currLevel - 1, rowID); + } + prods.push({ + ID: rowID, ChildLevels: currLevel, ProductName: 'Product: A' + i, Col1: i, + Col2: i, Col3: i, childData: children, childData2: children }); + } + return prods; + } + + public static generateTestDateTimeData = () => { + return [ + { + ProductID: 1, + ProductName: 'Product1', + DateField: ymd('2012-02-12'), + TimeField: new Date(ymd('2012-02-12').setHours(3, 20, 0, 1)), + DateTimeField: new Date(ymd('2003-03-17').setHours(3, 20, 5)), + }, + { + ProductID: 2, + ProductName: 'Product2', + DateField: ymd('2012-02-13'), + TimeField: new Date(ymd('2003-03-17').setHours(3, 20, 0, 1)), + DateTimeField: new Date(ymd('2003-03-17').setHours(3, 20)), + }, + { + ProductID: 3, + ProductName: 'Product3', + DateField: ymd('2012-02-12').setHours(1, 55), + TimeField: new Date(ymd('2012-02-12').setHours(4, 4)), + DateTimeField: new Date(ymd('2006-03-17').setHours(1, 55)), + }, + { + ProductID: 4, + ProductName: 'Product3', + DateField: new Date(ymd('2006-03-17').setHours(11, 11)), + TimeField: new Date(ymd('2006-03-17').setHours(11, 11)), + DateTimeField: new Date(ymd('2003-03-17').setHours(3, 20, 0, 1)), + }, + { + ProductID: 5, + ProductName: 'Product5', + DateField: new Date(ymd('2006-03-17').setHours(11, 11)), + TimeField: new Date(ymd('2003-03-17').setHours(3, 20, 0, 1)), + DateTimeField: new Date(ymd('2003-03-17').setHours(3, 20, 0, 1)), + } + ]; + }; + + /* Gets the name of the identifier column if exists. */ + private static getIDColumnName(dataItem: any) { + if (!dataItem) { + return undefined; + } + + if (dataItem['ID']) { + return 'ID'; + } else if (dataItem['Id']) { + return 'Id'; + } else if (dataItem['id']) { + return 'id'; + } else { + return undefined; + } + } +} + +export class DataParent { + public today: Date = new Date(new Date().getFullYear(), new Date().getMonth(), new Date().getDate(), 0, 0, 0); + public nextDay = new Date(new Date().getFullYear(), new Date().getMonth(), new Date().getDate() + 1, 0, 0, 0); + public prevDay = new Date(new Date().getFullYear(), new Date().getMonth(), new Date().getDate() - 1, 0, 0, 0); + public data = [ + { + Downloads: 254, + ID: 1, + ProductName: 'Ignite UI for JavaScript', + ReleaseDate: this.today, + Released: false + }, + { + Downloads: 1000, + ID: 2, + ProductName: 'NetAdvantage', + ReleaseDate: this.nextDay, + Released: true + }, + { + Downloads: 20, + ID: 3, + ProductName: 'Ignite UI for Angular', + ReleaseDate: null, + Released: false + }, + { + Downloads: null, + ID: 4, + ProductName: 'Ignite UI for JavaScript', + ReleaseDate: this.prevDay, + Released: true + }, + { + Downloads: 100, + ID: 5, + ProductName: '', + ReleaseDate: null, + Released: true + }, + { + Downloads: 1000, + ID: 6, + ProductName: 'Ignite UI for Angular', + ReleaseDate: this.nextDay, + Released: null + }, + { + Downloads: 0, + ID: 7, + ProductName: null, + ReleaseDate: this.prevDay, + Released: true + }, + { + Downloads: 1000, + ID: 8, + ProductName: 'NetAdvantage', + ReleaseDate: this.today, + Released: false + } + ]; +} diff --git a/projects/igniteui-angular/test-utils/tabs-components.spec.ts b/projects/igniteui-angular/test-utils/tabs-components.spec.ts new file mode 100644 index 00000000000..376b65c2313 --- /dev/null +++ b/projects/igniteui-angular/test-utils/tabs-components.spec.ts @@ -0,0 +1,585 @@ +import { Component, QueryList, ViewChild, ViewChildren } from '@angular/core'; +import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; +import { SampleTestData } from './sample-test-data.spec'; +import { IgxTabContentComponent, IgxTabHeaderComponent, IgxTabHeaderIconDirective, IgxTabHeaderLabelDirective, IgxTabItemComponent, IgxTabsComponent } from 'igniteui-angular/tabs'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxDropDownComponent } from 'igniteui-angular/drop-down'; +import { IgxButtonDirective, IgxToggleActionDirective } from 'igniteui-angular/directives'; +import { IgxPrefixDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; + +@Component({ + template: ` + + + + Tab 1 + + + Content 1 + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius sapien ligula. + + + + + Tab 2 + + + Content 2 + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius sapien ligula. + + + + + Tab 3 + + + Content 3 + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius sapien ligula. + + + + `, + imports: [IgxTabsComponent, IgxTabItemComponent, IgxTabHeaderComponent, IgxTabContentComponent] +}) +export class BasicTabsComponent { + @ViewChild(IgxTabsComponent, { static: true }) public tabs: IgxTabsComponent; + @ViewChildren(IgxTabItemComponent) public tabItems: QueryList; +} + +@Component({ + template: ` +
    + + + + library_music + Tab 1 + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + + + + video_library + Tab 2 + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + + + + library_books + Tab 3 + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + Vivamus vitae malesuada odio. Praesent ante lectus, porta a eleifend vel, sodales eu nisl. + Vivamus sit amet purus eu lectus cursus rhoncus quis non ex. + Cras ac nulla sed arcu finibus volutpat. + Vivamus risus ipsum, pharetra a augue nec, euismod fringilla odio. + Integer id velit rutrum, accumsan ante a, semper nunc. + Phasellus ultrices tincidunt imperdiet. Nullam vulputate mauris diam. + Nullam elementum, libero vel varius fermentum, lorem ex bibendum nulla, + pretium lacinia erat nibh vel massa. + In hendrerit, sapien ac mollis iaculis, dolor tellus malesuada sem, + a accumsan lectus nisl facilisis leo. + Curabitur consequat sit amet nulla at consequat. Duis volutpat tristique luctus. + + + +
    + `, + imports: [IgxTabsComponent, IgxTabItemComponent, IgxTabHeaderComponent, IgxTabContentComponent, IgxIconComponent, IgxTabHeaderIconDirective, IgxTabHeaderLabelDirective] +}) +export class TabsTestComponent { + @ViewChild(IgxTabsComponent, { static: true }) public tabs: IgxTabsComponent; + @ViewChild('wrapperDiv', { static: true }) public wrapperDiv: any; +} + +@Component({ + template: ` +
    + + @for (tab of collection; track trackByItemRef(tab)) { + + {{ tab.name }} + + + } + +
    + `, + imports: [IgxTabsComponent, IgxTabItemComponent, IgxTabHeaderComponent, IgxTabContentComponent, IgxTabHeaderLabelDirective] +}) +export class TabsTest2Component { + @ViewChild(IgxTabsComponent, { static: true }) public tabs: IgxTabsComponent; + @ViewChild('wrapperDiv', { static: true }) public wrapperDiv: any; + protected collection: any[]; + + constructor() { + this.resetCollectionThreeTabs(); + } + + public resetCollectionOneTab() { + this.collection = + [ + { name: 'Tab 3' } + ]; + } + public resetCollectionTwoTabs() { + this.collection = + [ + { name: 'Tab 1' }, + { name: 'Tab 3' } + ]; + } + public resetCollectionThreeTabs() { + this.collection = + [ + { name: 'Tab 1' }, + { name: 'Tab 2' }, + { name: 'Tab 3' } + ]; + } + public resetCollectionFourTabs() { + this.collection = + [ + { name: 'Tab 1' }, + { name: 'Tab 2' }, + { name: 'Tab 3' }, + { name: 'Tab 4' } + ]; + } + public resetToEmptyCollection() { + this.collection = []; + } + + /** Explicitly track object so collection changes entirely for index logic test */ + protected trackByItemRef = (x: any) => x; +} + +@Component({ + template: ` +
    + + + +
    T1
    + Tab 1 +
    + +

    Tab 1 Content

    +
    +
    + + +
    T2
    + Tab 2 +
    + +

    Tab 2 Content

    +
    +
    + + +
    T3
    + Tab 3 +
    + +

    Tab 3 Content

    +
    +
    +
    +
    + `, + imports: [IgxTabsComponent, IgxTabItemComponent, IgxTabHeaderComponent, IgxTabContentComponent, IgxTabHeaderLabelDirective] +}) +export class TemplatedTabsTestComponent { + @ViewChild(IgxTabsComponent, { static: true }) public tabs: IgxTabsComponent; + @ViewChild('wrapperDiv', { static: true }) public wrapperDiv: any; +} + +@Component({ + template: ` +
    + + @for (tab of collection; track tab.name) { + + {{ tab.name }} + + } + +
    + `, + imports: [IgxTabsComponent, IgxTabItemComponent, IgxTabHeaderComponent, IgxTabHeaderLabelDirective] +}) +export class TabsTestSelectedTabComponent { + @ViewChild(IgxTabsComponent, { static: true }) public tabs: IgxTabsComponent; + protected collection: any[]; + + constructor() { + this.collection = + [ + { name: 'Tab 1' }, + { name: 'Tab 2' }, + { name: 'Tab 3' }, + { name: 'Tab 4' } + ]; + } +} + +@Component({ + template: ` + + + Tab1 + Content 1 + + + Tab2 + Content 2 + + + `, + imports: [IgxTabsComponent, IgxTabItemComponent, IgxTabHeaderComponent, IgxTabContentComponent, IgxTabHeaderLabelDirective] +}) +export class TabsTestCustomStylesComponent { +} + +@Component({ + template: ` + + +
    + + + tab2 + Tab content 1 + + + tab2 + Tab content 2 + + +
    +
    + `, + imports: [IgxTabsComponent, IgxTabItemComponent, IgxTabHeaderComponent, IgxTabContentComponent, IgxTabHeaderLabelDirective, IgxDropDownComponent, IgxToggleActionDirective, IgxButtonDirective] +}) +export class TabsTestBug4420Component { + @ViewChild(IgxTabsComponent, { static: true }) public tabs: IgxTabsComponent; +} + +@Component({ + template: ` +
    + + + Tab 1 + + + Tab 2 + + + Tab 3 + + +
    + +
    +
    + `, + imports: [IgxTabsComponent, IgxTabItemComponent, IgxTabHeaderComponent, IgxTabHeaderLabelDirective, RouterLinkActive, RouterLink, RouterOutlet] +}) +export class TabsRoutingTestComponent { + @ViewChild(IgxTabsComponent, { static: true }) + public tabs: IgxTabsComponent; +} + +@Component({ + template: ` +
    + + + Tab 1 + + + Tab 2 + + + Tab 3 + + + Tab 4 + + + Tab 5 + + +
    + +
    +
    + `, + imports: [IgxTabsComponent, IgxTabItemComponent, IgxTabHeaderComponent, IgxTabHeaderLabelDirective, RouterLink, RouterLinkActive, RouterOutlet] +}) +export class TabsRoutingDisabledTestComponent { + @ViewChild(IgxTabsComponent, { static: true }) + public tabs: IgxTabsComponent; +} + +@Component({ + template: ` +
    + + + Tab 1 + + + Tab X + + +
    + +
    +
    + `, + imports: [IgxTabsComponent, IgxTabItemComponent, IgxTabHeaderComponent, IgxTabHeaderLabelDirective, RouterLinkActive, RouterLink, RouterOutlet] +}) +export class TabsRoutingGuardTestComponent { + @ViewChild(IgxTabsComponent, { static: true }) + public tabs: IgxTabsComponent; +} + +@Component({ + template: ` +
    + + + Tab 1 + + + Tab 2 + + + Tab 3 + + +
    + `, + imports: [IgxTabsComponent, IgxTabItemComponent, IgxTabHeaderComponent, IgxTabHeaderLabelDirective] +}) +export class TabsTabsOnlyModeTest1Component { + @ViewChild(IgxTabsComponent, { static: true }) + public tabs: IgxTabsComponent; +} + +@Component({ + template: ` +
    + + + Tab 1 + + + + Tab 2 + + + + Tab 3 + + + + Tab 4 + + + + Tab 5 + + + +
    + `, + imports: [IgxTabsComponent, IgxTabItemComponent, IgxTabHeaderComponent, IgxTabContentComponent, IgxTabHeaderLabelDirective] +}) +export class TabsDisabledTestComponent { + @ViewChild(IgxTabsComponent, { static: true }) public tabs: IgxTabsComponent; +} + +@Component({ + template: ` +
    + + + Tab 1 +
    Content 1
    +
    + + Tab 2 +
    Content 2
    +
    + + Tab 3 +
    Content 3
    +
    +
    +
    +
    + + + Tab 4 +
    Content 4
    +
    + + Tab 5 +
    Content 5
    +
    + + Tab 6 +
    Content 6
    +
    +
    +
    + `, + imports: [IgxTabsComponent, IgxTabItemComponent, IgxTabHeaderComponent, IgxTabContentComponent, IgxTabHeaderLabelDirective] +}) +export class TabsTestHtmlAttributesComponent { + @ViewChild(IgxTabsComponent, { static: true }) public tabs: IgxTabsComponent; +} + +@Component({ + template: ` +
    + + + + Test: + library_music + Tab 1 + close + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + + + + Test: + video_library + Tab 2 + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + + + + library_books + Tab 3 + close + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + Vivamus vitae malesuada odio. Praesent ante lectus, porta a eleifend vel, sodales eu nisl. + Vivamus sit amet purus eu lectus cursus rhoncus quis non ex. + Cras ac nulla sed arcu finibus volutpat. + Vivamus risus ipsum, pharetra a augue nec, euismod fringilla odio. + Integer id velit rutrum, accumsan ante a, semper nunc. + Phasellus ultrices tincidunt imperdiet. Nullam vulputate mauris diam. + Nullam elementum, libero vel varius fermentum, lorem ex bibendum nulla, + pretium lacinia erat nibh vel massa. + In hendrerit, sapien ac mollis iaculis, dolor tellus malesuada sem, + a accumsan lectus nisl facilisis leo. + Curabitur consequat sit amet nulla at consequat. Duis volutpat tristique luctus. + + + +
    + `, + imports: [IgxTabsComponent, IgxTabItemComponent, IgxTabHeaderComponent, IgxTabContentComponent, IgxTabHeaderLabelDirective, IgxIconComponent, IgxTabHeaderIconDirective, IgxPrefixDirective, IgxSuffixDirective] +}) +export class TabsWithPrefixSuffixTestComponent extends TabsTestComponent { +} + +@Component({ + selector: 'igx-tabs-contacts', + template: ` +
    + + @for (contact of contacts; track contact.Name) { + + + {{contact.Name}} + + + + + } + +
    + `, + imports: [IgxTabsComponent, IgxTabItemComponent, IgxTabHeaderComponent, IgxTabContentComponent, IgxTabHeaderLabelDirective] +}) +export class TabsContactsComponent extends TabsTestComponent { + public contacts = SampleTestData.personAvatarData(); +} + +@Component({ + template: ` +
    + + @for (tab of collection; track tab.name) { + + {{ tab.name }} + + + } + +
    + `, + imports: [IgxTabsComponent, IgxTabItemComponent, IgxTabHeaderComponent, IgxTabContentComponent, IgxTabHeaderLabelDirective] +}) +export class AddingSelectedTabComponent { + @ViewChild(IgxTabsComponent, { static: true }) public tabs: IgxTabsComponent; + protected collection: any[]; + constructor() { + this.collection = [ + { name: 'tab1', selected: true }, + { name: 'tab2', selected: false } + ]; + } + + public addTab() { + this.collection.forEach(t => t.selected = false); + this.collection.push({ name: 'tab' + (this.collection.length + 1), selected: true }); + } +} + +@Component({ + template: ` +
    + + @for (tab of collection; track tab.name) { + + {{ tab.name }} + + + } + +
    + `, + imports: [IgxTabsComponent, IgxTabItemComponent, IgxTabHeaderComponent, IgxTabContentComponent, IgxTabHeaderLabelDirective] +}) +export class TabsRtlComponent { + @ViewChild(IgxTabsComponent, { static: true }) public tabs: IgxTabsComponent; + @ViewChild('wrapperDiv', { static: true }) public wrapperDiv: any; + protected collection = [ + { name: 'tab1', selected: true }, + { name: 'tab2', selected: false }, + { name: 'tab3', selected: false }, + { name: 'tab4', selected: false }, + { name: 'tab5', selected: false }, + { name: 'tab6', selected: false }, + { name: 'tab7', selected: false }, + { name: 'tab8', selected: false }, + { name: 'tab9', selected: false }, + ]; +} diff --git a/projects/igniteui-angular/test-utils/template-strings.spec.ts b/projects/igniteui-angular/test-utils/template-strings.spec.ts new file mode 100644 index 00000000000..587f1f2b9bd --- /dev/null +++ b/projects/igniteui-angular/test-utils/template-strings.spec.ts @@ -0,0 +1,584 @@ +export class GridTemplateStrings { + + public static basicGrid = ` + + `; + + public static gridAutoGenerate = ` + + `; + + public static gridWithSize = ` + + `; + + public static declareGrid(attributes = ``, events = ``, columnDefinitions: ColumnDefinitions = ``, + toolbarDefinition = '', paginatorDefinition = '', templateDefinitions: TemplateDefinitions = '') { + return ` + ${toolbarDefinition} + ${columnDefinitions} + ${paginatorDefinition} + ${templateDefinitions} + `; + } + + public static declareBasicGridWithColumns(columnDefinitions: ColumnDefinitions) { + return GridTemplateStrings.declareGrid(``, ``, columnDefinitions); + } + +} + +export class ColumnDefinitions { + + public static idNameJobTitle = ` + + + + `; + + public static idNameJobTitleEditable = ` + + + + `; + + public static idNameJobTitleCompany = ` + + + + + `; + + public static idNameJobHireDate = ` + + + + + `; + + public static idNameJobHireWithTypes = ` + + + + + `; + + public static idNameJobHireSortable = ` + + + + + `; + + public static idNameHiddenJobHirePinned = ` + + + + + `; + + public static idNameJobHoursHireDatePerformance = ` + + + + + + + `; + + public static hireDate = ` + + `; + + public static nameJobTitleId = ` + + + + `; + + public static simpleDatePercentColumns = ` + + + + + + `; + + public static nameAgeEditable = ` + + + + `; + + public static nameAvatar = ` + + + +
    + +
    +
    +
    + `; + + public static idFirstLastNameSortable = ` + + + + `; + + public static resizableThreeOfFour = ` + + + + + `; + + public static pinnedTwoOfFour = ` + + + + + `; + + public static pinnedThreeOfEight = ` + + + + + + + +
    +
    +
    + + + `; + + public static gridFeatures = ` + + + + + + + + + + + + + + `; + + public static resizableColsComponent = ` + @for (c of columns; track c.field) { + + + }`; + + public static iterableComponent = ` + @for (each of columns; track each) { + + }`; + + public static columnTemplates = ` + + + Header text + + + + Cell text + + + + Summary text + + + + Footer text + + + + + Header text + + + + Cell text + + + + Summary text + + + + Footer text + + + `; + + public static idNameFormatter = ` + + + + `; + + public static productHidable = ` + + + + + + `; + + public static productFilterable = ` + + + + + + + `; + + public static productDefaultSummaries = ` + + + + + + + + + + + `; + + public static productBasic = ` + + + + + + + + + + + `; + + public static productBasicNumberID = ` + + + + + + + + + + + `; + + public static productSummariesAndFilter = ` + + + + + + + + + + + `; + + public static indexAndValue = ` + + + `; + + public static generatedWithSummaries = ` + @for (c of columns; track c.field) { + + + }`; + + public static generatedWithDataType = ` + @for (c of columns; track c.field) { + + + }`; + + public static generatedGroupableWithEnabledSummariesAndDataType = ` + @for (c of columns; track c.field) { + + + }`; + + public static generatedWithColumnBasedSummariesAndDataType = ` + @for (c of columns; track c.field) { + + }`; + + public static generatedEditable = ` + @for (col of columns; track col.key) { + + }`; + + public static generatedWithWidth = ` + @for (c of columns; track c.field) { + + + }`; + + public static productFilterSortPin = ` + + + + + + + `; + + public static productAllColumnFeatures = ` + + + + + + + `; + + public static movableColumns = ` + + + + + + + + + `; + + public static multiColHeadersColumns = ` + + + + + + + + + + + + + {{ column.field }} + + + {{val}} + + + + + + + `; + + public static multiColHeadersWithGroupingColumns = ` + + + + + + + + + +`; + + public static contactInfoGroupableColumns = ` + + + + + + + + + + `; + + public static summariesGroupByColumns = ` + + + + + + + `; + + public static summariesGroupByTansColumns = ` + + + + + + + `; + + public static selectionWithScrollsColumns = ` + + + + + + + `; + + public static exportGroupedDataColumns = ` + + + + + `; + + public static multiColHeadersExportColumns = ` + + + + + + + + + + + + + + + + + + + + + + `; +} + +export class TemplateDefinitions { + public static sortIconTemplates = ` + + unfold_more + + + expand_less + + + expand_more + + `; +} + +export class ExternalTemplateDefinitions { + public static sortIconTemplates = ` + + arrow_right + + + arrow_drop_up + + + arrow_drop_down + + `; +} + +export class EventSubscriptions { + + public static columnInit = ` (columnInit)="columnInit($event)"`; + + public static selected = ` (selected)="cellSelected($event)"`; + + public static onCellClick = ` (onCellClick)="cellClick($event)"`; + + public static doubleClick = ` (doubleClick)="doubleClick($event)"`; + + public static contextMenu = ` (contextMenu)="cellRightClick($event)"`; + + public static columnPin = ` (columnPin)="columnPinning($event)"`; + + public static rowAdded = ` (rowAdded)="rowAdded($event)"`; + + public static rowDeleted = ` (rowDeleted)="rowDeleted($event)"`; + + public static onEditDone = ` (cellEdit)="editDone($event)"`; + + public static rowSelectionChanging = ` (rowSelectionChanging)="rowSelectionChanging($event)"`; + + public static columnResized = ` (columnResized)="columnResized($event)"`; + + public static columnMovingStart = ` (columnMovingStart)="columnMovingStarted($event)"`; + + public static columnMoving = ` (columnMoving)="columnMoving($event)"`; + + public static columnMovingEnd = ` (columnMovingEnd)="columnMovingEnded($event)"`; +} diff --git a/projects/igniteui-angular/test-utils/tooltip-components.spec.ts b/projects/igniteui-angular/test-utils/tooltip-components.spec.ts new file mode 100644 index 00000000000..c5cc8b9a719 --- /dev/null +++ b/projects/igniteui-angular/test-utils/tooltip-components.spec.ts @@ -0,0 +1,170 @@ +import { Component, TemplateRef, ViewChild } from '@angular/core'; +import { IgxToggleActionDirective, IgxToggleDirective, IgxTooltipDirective, IgxTooltipTargetDirective, ITooltipHideEventArgs, ITooltipShowEventArgs } from 'igniteui-angular/directives'; + + +@Component({ + template: ` +
    dummy div for touch tests
    + + @if (showButton) { + + } +
    + Hello, I am a tooltip! +
    + `, + imports: [IgxTooltipDirective, IgxTooltipTargetDirective] +}) +export class IgxTooltipSingleTargetComponent { + @ViewChild(IgxTooltipDirective, { static: true }) public tooltip: IgxTooltipDirective; + @ViewChild(IgxTooltipTargetDirective, { static: false }) public tooltipTarget: IgxTooltipTargetDirective; + public cancelShowing = false; + public cancelHiding = false; + public showButton = true; + + public showing(args: ITooltipShowEventArgs) { + if (this.cancelShowing) { + args.cancel = true; + } + } + + public hiding(args: ITooltipHideEventArgs) { + if (this.cancelHiding) { + args.cancel = true; + } + } +} + +@Component({ + template: ` +
    dummy div for touch tests
    + + + + + +
    + Hello, I am a tooltip! +
    + + +
    Custom Close Button
    +
    + + +
    Second Custom Close Button
    +
    + `, + imports: [IgxTooltipDirective, IgxTooltipTargetDirective] +}) +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({ + template: ` +
    dummy div for touch tests
    + + + + + +
    + Hello, I am tooltip 1! +
    +
    + Hello, I am tooltip 2! +
    + `, + imports: [IgxTooltipDirective, IgxTooltipTargetDirective] +}) +export class IgxTooltipMultipleTooltipsComponent { + @ViewChild('targetOne', { read: IgxTooltipTargetDirective, static: true }) public targetOne: IgxTooltipTargetDirective; + @ViewChild('targetTwo', { read: IgxTooltipTargetDirective, static: true }) public targetTwo: IgxTooltipTargetDirective; + @ViewChild('tooltipRef1', { read: IgxTooltipDirective, static: true }) public tooltipOne: IgxTooltipDirective; + @ViewChild('tooltipRef2', { read: IgxTooltipDirective, static: true }) public tooltipTwo: IgxTooltipDirective; +} + + +@Component({ + template: ` + + `, + imports: [IgxTooltipTargetDirective] +}) +export class IgxTooltipPlainStringComponent { + @ViewChild(IgxTooltipTargetDirective, { static: true }) public tooltipTarget: IgxTooltipTargetDirective; +} + +@Component({ + template: ` + +
    Toggle content
    +
    Test
    + `, + imports: [IgxTooltipDirective, IgxTooltipTargetDirective, IgxToggleActionDirective, IgxToggleDirective] +}) +export class IgxTooltipWithToggleActionComponent { + @ViewChild(IgxTooltipDirective, { static: true }) public tooltip: IgxTooltipDirective; + @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; +} + +@Component({ + template: ` + + +
    +
    + Nested content +
    +
    + `, + imports: [IgxTooltipDirective, IgxTooltipTargetDirective], + standalone: true +}) +export class IgxTooltipWithNestedContentComponent { + @ViewChild(IgxTooltipDirective, { static: true }) public tooltip!: IgxTooltipDirective; + @ViewChild(IgxTooltipTargetDirective, { static: true }) public tooltipTarget!: IgxTooltipTargetDirective; +} diff --git a/projects/igniteui-angular/test-utils/tree-grid-components.spec.ts b/projects/igniteui-angular/test-utils/tree-grid-components.spec.ts new file mode 100644 index 00000000000..704f9ef36c8 --- /dev/null +++ b/projects/igniteui-angular/test-utils/tree-grid-components.spec.ts @@ -0,0 +1,1127 @@ +import { Component, ViewChild, OnInit, TemplateRef } from '@angular/core'; +import { SampleTestData } from './sample-test-data.spec'; +import { DefaultSortingStrategy, GridSummaryCalculationMode, IGroupingExpression, IgxSummaryResult } from 'igniteui-angular/core'; +import { IgxActionStripComponent } from 'igniteui-angular/action-strip'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxPaginatorComponent } from 'igniteui-angular/paginator'; +import { IgxCheckboxComponent } from 'igniteui-angular/checkbox'; +import { IgxTreeGridComponent, IgxTreeGridGroupByAreaComponent, IgxTreeGridGroupingPipe } from 'igniteui-angular/grids/tree-grid'; +import { IgxColumnComponent, IgxColumnGroupComponent, IgxExcelStyleColumnOperationsTemplateDirective, IgxExcelStyleFilterOperationsTemplateDirective, IgxExcelStyleHeaderIconDirective, IgxExcelStyleSearchComponent, IgxExcelStyleSortingComponent, IgxGridEditingActionsComponent, IgxGridExcelStyleFilteringComponent, IgxGridPinningActionsComponent, IgxHeadSelectorDirective, IgxNumberSummaryOperand, IgxRowCollapsedIndicatorDirective, IgxRowExpandedIndicatorDirective, IgxRowSelectorDirective, IgxSummaryOperand, IPinningConfig, RowPinningPosition } from 'igniteui-angular/grids/core'; + +@Component({ + template: ` + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent] +}) +export class IgxTreeGridSortingComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeSmallTreeData(); +} + +@Component({ + template: ` + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent] +}) +export class IgxTreeGridFilteringComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeTreeData(); +} + +@Component({ + template: ` + + + + + + + + filter_alt + + + + + + + + + + + + `, + imports: [ + IgxTreeGridComponent, + IgxColumnComponent, + IgxIconComponent, + IgxGridExcelStyleFilteringComponent, + IgxExcelStyleColumnOperationsTemplateDirective, + IgxExcelStyleSortingComponent, + IgxExcelStyleFilterOperationsTemplateDirective, + IgxExcelStyleSearchComponent, + IgxExcelStyleHeaderIconDirective + ] +}) +export class IgxTreeGridFilteringESFTemplatesComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeTreeData(); +} + +@Component({ + template: ` + + + + + + @if (paging) { + + } + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent, IgxPaginatorComponent] +}) +export class IgxTreeGridSimpleComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeSmallTreeData(); + public selectedRows = []; + public paging = false; +} + +@Component({ + template: ` + + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent] +}) +export class IgxTreeGridWithScrollsComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeAllTypesTreeData(); +} + +@Component({ + template: ` + + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent] +}) +export class IgxTreeGridWithNoScrollsComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeAllTypesTreeData(); +} + +@Component({ + template: ` + + + + + + + @if (paging) { + + } + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent, IgxPaginatorComponent] +}) +export class IgxTreeGridPrimaryForeignKeyComponent implements OnInit { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = []; + public paging = false; + public sortByName = false; + + public ngOnInit(): void { + this.data = !this.sortByName + ? SampleTestData.employeePrimaryForeignKeyTreeData() + : SampleTestData.employeePrimaryForeignKeyTreeData().sort((a, b) => a.Name.localeCompare(b.Name)); + } +} + +@Component({ + template: ` + + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent, IgxPaginatorComponent] +}) +export class IgxTreeGridExpandingComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeTreeData(); +} + +@Component({ + template: ` + + + + + + + + val +
    + {{val}} +
    +
    + `, + imports: [IgxTreeGridComponent, IgxColumnComponent, IgxPaginatorComponent] +}) +export class IgxTreeGridCellSelectionComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeTreeData(); + @ViewChild('customCell', { static: true }) + public customCell!: TemplateRef; +} + +@Component({ + template: ` + + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent, IgxPaginatorComponent] +}) +export class IgxTreeGridNoDataComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; +} + +// Test Component with 'string' dataType tree-column +@Component({ + template: ` + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent] +}) +export class IgxTreeGridStringTreeColumnComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeSmallTreeData(); +} + +// Test Component with 'date' dataType tree-column +@Component({ + template: ` + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent] +}) +export class IgxTreeGridDateTreeColumnComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeSmallTreeData(); +} + +// Test Component with 'boolean' dataType tree-column +@Component({ + template: ` + + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent] +}) +export class IgxTreeGridBooleanTreeColumnComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeAllTypesTreeData(); +} + +// Test Component for tree-grid row editing +@Component({ + template: ` + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent] +}) +export class IgxTreeGridRowEditingComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeSmallTreeData(); +} + +// Test Component for tree-grid filtering and row editing +@Component({ + template: ` + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent] +}) +export class IgxTreeGridFilteringRowEditingComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeTreeData(); +} + +@Component({ + template: ` + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent] +}) +export class IgxTreeGridSelectionRowEditingComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeTreeData(); +} + +@Component({ + template: ` + + + + + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent, IgxColumnGroupComponent] +}) +export class IgxTreeGridMultiColHeadersComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeSmallTreeData(); +} + + +@Component({ + template: ` + + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent] +}) +export class IgxTreeGridSummariesComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeTreeData(); + public ageSummary = AgeSummary; + public ageSummaryTest = AgeSummaryTest; +} + +@Component({ + template: ` + + + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent] +}) +export class IgxTreeGridSummariesKeyScroliingComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeTreeDataPrimaryForeignKey(); +} + +@Component({ + template: ` + + + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent] +}) +export class IgxTreeGridWithNoForeignKeyComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeTreeDataPrimaryForeignKey(); +} + +@Component({ + template: ` + + + + + + + @if (paging) { + + } + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent, IgxPaginatorComponent] +}) +export class IgxTreeGridSummariesKeyComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeTreeDataPrimaryForeignKey(); + public calculationMode: GridSummaryCalculationMode = 'rootAndChildLevels'; + public ageSummary = AgeSummary; + public ageSummaryTest = AgeSummaryTest; + public paging = false; +} + +@Component({ + template: ` + + + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent] +}) +export class IgxTreeGridSummariesTransactionsComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeTreeDataPrimaryForeignKey(); + public calculationMode: GridSummaryCalculationMode = 'rootAndChildLevels'; + public ageSummary = AgeSummaryMinMax; + public ageSummaryTest = AgeSummaryTest; +} + +class AgeSummary extends IgxNumberSummaryOperand { + constructor() { + super(); + } + + public override operate(summaries?: any[]): IgxSummaryResult [] { + const result = super.operate(summaries).filter((obj) => { + if (obj.key === 'average' || obj.key === 'sum' || obj.key === 'count') { + const summaryResult = obj.summaryResult; + // apply formatting to float numbers + if (!Number.isInteger(parseFloat(summaryResult))) { + obj.summaryResult = parseFloat(summaryResult).toLocaleString('en-us', { maximumFractionDigits: 2 }); + } + return obj; + } + }); + return result; + } +} + +class AgeSummaryMinMax extends IgxNumberSummaryOperand { + constructor() { + super(); + } + + public override operate(summaries?: any[]): IgxSummaryResult[] { + const result = super.operate(summaries).filter((obj) => { + if (obj.key === 'min' || obj.key === 'max') { + const summaryResult = obj.summaryResult; + // apply formatting to float numbers + if (Number(summaryResult) === summaryResult) { + obj.summaryResult = summaryResult.toLocaleString('en-us', { maximumFractionDigits: 2 }); + } + return obj; + } + }); + return result; + } +} + +class AgeSummaryTest extends IgxNumberSummaryOperand { + constructor() { + super(); + } + + public override operate(summaries?: any[]): IgxSummaryResult[] { + const result = super.operate(summaries); + result.push({ + key: 'test', + label: 'Test', + summaryResult: summaries.filter(rec => rec > 10 && rec < 40).length + }); + + return result; + } +} + +class PTOSummary extends IgxSummaryOperand { + constructor() { + super(); + } + + public override operate(summaries?: any[], allData = [], field?): IgxSummaryResult[] { + const result = super.operate(summaries); + if (field && field === 'Name') { + result.push({ + key: 'test', + label: 'People on PTO', + summaryResult: allData.filter((rec) => rec.OnPTO).length + }); + } + return result; + } +} + +@Component({ + template: ` + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent] +}) +export class IgxTreeGridRowEditingTransactionComponent { + @ViewChild('treeGrid', { read: IgxTreeGridComponent, static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeePrimaryForeignKeyTreeData(); + public paging = false; +} + +@Component({ + template: ` + + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent] +}) +export class IgxTreeGridCustomSummariesComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeTreeData(); + public ageSummary = AgeSummary; + public ageSummaryTest = AgeSummaryTest; + public ptoSummary = PTOSummary; +} + +@Component({ + template: ` + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent] +}) +export class IgxTreeGridRowEditingHierarchicalDSTransactionComponent { + @ViewChild('treeGrid', { read: IgxTreeGridComponent, static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeAllTypesTreeData(); + public paging = false; +} + +@Component({ + template: ` + + + + + + + @if (paging) { + + } + `, + imports: [IgxTreeGridComponent, IgxColumnComponent, IgxPaginatorComponent] +}) +export class IgxTreeGridRowPinningComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeAllTypesTreeData(); + public paging = false; +} + +@Component({ + template: ` +
    + + + + + + +
    `, + imports: [IgxTreeGridComponent, IgxColumnComponent] +}) + +export class IgxTreeGridWrappedInContComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeTreeData(); + + public height = null; + public paging = false; + public pageSize = 5; + public outerWidth = 800; + public outerHeight: number; + + public isHorizontalScrollbarVisible() { + const scrollbar = this.treeGrid.headerContainer.getScroll(); + if (scrollbar) { + return scrollbar.offsetWidth < (scrollbar.children[0] as HTMLElement).offsetWidth; + } + + return false; + } + + public getVerticalScrollHeight() { + const scrollbar = this.treeGrid.verticalScrollContainer.getScroll(); + if (scrollbar) { + return parseInt(scrollbar.style.height, 10); + } + + return 0; + } + + public isVerticalScrollbarVisible() { + const scrollbar = this.treeGrid.verticalScrollContainer.getScroll(); + if (scrollbar && scrollbar.offsetHeight > 0) { + return scrollbar.offsetHeight < (scrollbar.children[0] as HTMLElement).offsetHeight; + } + return false; + } + + public clearData(){ + this.data = []; + } +} + +@Component({ + template: ` + + + + + + @if (paging) { + + } + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent, IgxPaginatorComponent] +}) +export class IgxTreeGridSummariesScrollingComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeScrollingData(); + public paging = false; +} + +@Component({ + template: ` + + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent] +}) +export class IgxTreeGridSearchComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeSearchTreeData(); +} + +@Component({ + template: ` + + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent] +}) +export class IgxTreeGridLoadOnDemandComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public allData = SampleTestData.employeePrimaryForeignKeyTreeData(); + public data = []; + + constructor() { + this.data = this.allData.filter(r => r.ParentID === -1); + } + + public loadChildren = (parentID: any, done: (children: any[]) => void) => { + requestAnimationFrame(() => done(this.allData.filter(r => r.ParentID === parentID))); + }; +} +@Component({ + template: ` + + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent] +}) +export class IgxTreeGridSelectionKeyComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeTreeDataPrimaryForeignKey(); +} + +@Component({ + template: ` + + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent] +}) +export class IgxTreeGridLoadOnDemandChildDataComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public allData = SampleTestData.employeePrimaryForeignKeyTreeData(); + public data = []; + + constructor() { + this.data = this.allData.filter(r => r.ParentID === -1); + } + + public loadChildren = (parentID: any, done: (children: any[]) => void) => { + requestAnimationFrame(() => done(this.allData.filter(r => r.ParentID === parentID))); + }; +} + +@Component({ + template: ` + + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent] +}) +export class IgxTreeGridSelectionComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeTreeData(); +} + +@Component({ + template: ` + + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent] +}) +export class IgxTreeGridLoadOnDemandHasChildrenComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public allData = SampleTestData.employeePrimaryForeignKeyTreeData(); + public data = []; + + constructor() { + this.data = this.getChildren(-1); + } + + public loadChildren = (parentID: any, done: (children: any[]) => void) => { + requestAnimationFrame(() => { + const children = this.getChildren(parentID); + done(children); + }); + }; + + private getChildren(parentID) { + const children = this.allData.filter(r => r.ParentID === parentID); + + for (const child of children) { + child['hasEmployees'] = this.allData.some(r => r.ParentID === child.ID); + } + return children; + } +} + +@Component({ + template: ` + + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent] +}) +export class IgxTreeGridSelectionWithTransactionComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeTreeData(); +} + +@Component({ + template: ` + + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent] +}) +export class IgxTreeGridFKeySelectionWithTransactionComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeTreeDataPrimaryForeignKey(); +} + +@Component({ + template: ` + + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent] +}) +export class IgxTreeGridDefaultLoadingComponent implements OnInit { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = []; + + public ngOnInit(): void { + this.treeGrid.isLoading = true; + setTimeout(() => { + this.data = SampleTestData.employeePrimaryForeignKeyTreeData(); + this.treeGrid.isLoading = false; + }, 1000); + } +} + +@Component({ + template: ` + + + + + + + + + {{ rowContext.index }} + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent, IgxCheckboxComponent, IgxPaginatorComponent, IgxHeadSelectorDirective, IgxRowSelectorDirective] +}) +export class IgxTreeGridCustomRowSelectorsComponent implements OnInit { + @ViewChild(IgxTreeGridComponent, { static: true }) + public treeGrid: IgxTreeGridComponent; + public rowCheckboxClick: any; + public headerCheckboxClick: any; + public data = []; + + public ngOnInit(): void { + this.data = SampleTestData.employeePrimaryForeignKeyTreeData(); + } + + public onRowCheckboxClick(event, rowContext) { + this.rowCheckboxClick = event; + event.stopPropagation(); + event.preventDefault(); + if (rowContext.selected) { + this.treeGrid.deselectRows([rowContext.rowID]); + } else { + this.treeGrid.selectRows([rowContext.rowID]); + } + } + + public onHeaderCheckboxClick(event, headContext) { + this.headerCheckboxClick = event; + event.stopPropagation(); + event.preventDefault(); + if (headContext.selected) { + this.treeGrid.deselectAllRows(); + } else { + this.treeGrid.selectAllRows(); + } + } +} + +@Component({ + template: ` + + + + + + + EXPANDED + + + COLLAPSED + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent, IgxRowExpandedIndicatorDirective, IgxRowCollapsedIndicatorDirective] +}) +export class IgxTreeGridCustomExpandersTemplateComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeTreeData(); +} + +@Component({ + template: ` + + + + + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent, IgxActionStripComponent, IgxGridEditingActionsComponent] +}) +export class IgxTreeGridEditActionsComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + @ViewChild('actionStrip', { read: IgxActionStripComponent, static: true }) + public actionStrip: IgxActionStripComponent; + public data = SampleTestData.employeePrimaryForeignKeyTreeData(); +} + +@Component({ + template: ` + + + + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent, IgxActionStripComponent, IgxGridEditingActionsComponent] +}) +export class IgxTreeGridCascadingSelectionComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + @ViewChild('actionStrip', { read: IgxActionStripComponent, static: true }) + public actionStrip: IgxActionStripComponent; + public data = SampleTestData.employeeSmallTreeData(); +} +@Component({ + template: ` + + + + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent, IgxActionStripComponent, IgxGridEditingActionsComponent] +}) +export class IgxTreeGridCascadingSelectionTransactionComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + @ViewChild('actionStrip', { read: IgxActionStripComponent, static: true }) + public actionStrip: IgxActionStripComponent; + public data = SampleTestData.employeeSmallTreeData(); +} + +@Component({ + template: ` + + + + + + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent, IgxTreeGridGroupByAreaComponent, IgxTreeGridGroupingPipe] +}) +export class IgxTreeGridGroupingComponent { + @ViewChild(IgxTreeGridGroupByAreaComponent, { static: true }) public groupByArea: IgxTreeGridGroupByAreaComponent; + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + public data = SampleTestData.employeeTreeDataPrimaryForeignKeyExt(); + public groupKey = 'GK_Employees'; + public groupedInitially = true; + public childDataKey='Employees'; + public groupingExpressions: IGroupingExpression[] = + [ + { fieldName: 'OnPTO', dir: 1, ignoreCase: true, strategy: DefaultSortingStrategy.instance() }, + { fieldName: 'HireDate', dir: 2, ignoreCase: true, strategy: DefaultSortingStrategy.instance() } + ]; + public aggregations = []; +} + +@Component({ + template: ` +
    + + + + +
    + `, + imports: [IgxTreeGridComponent, IgxTreeGridGroupByAreaComponent] +}) +export class IgxTreeGridGroupByAreaTestComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + @ViewChild(IgxTreeGridGroupByAreaComponent, { static: true }) public groupByArea: IgxTreeGridGroupByAreaComponent; +} + +@Component({ + template: ` + + + + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent, IgxActionStripComponent, IgxGridEditingActionsComponent] +}) +export class IgxTreeGridPrimaryForeignKeyCascadeSelectionComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + @ViewChild('actionStrip', { read: IgxActionStripComponent, static: true }) + public actionStrip: IgxActionStripComponent; + public data = SampleTestData.employeeSmallPrimaryForeignKeyTreeData(); +} + +@Component({ + template: ` + + + + + + + + + + + + `, + imports: [IgxTreeGridComponent, IgxColumnComponent, IgxActionStripComponent, IgxGridPinningActionsComponent, IgxGridEditingActionsComponent] +}) +export class IgxTreeGridEditActionsPinningComponent { + @ViewChild(IgxTreeGridComponent, { static: true }) public treeGrid: IgxTreeGridComponent; + @ViewChild('actionStrip', { read: IgxActionStripComponent, static: true }) + public actionStrip: IgxActionStripComponent; + public data = SampleTestData.employeePrimaryForeignKeyTreeData(); + public pinningConfig: IPinningConfig = { rows: RowPinningPosition.Bottom }; +} diff --git a/projects/igniteui-angular/test-utils/tree-grid-functions.spec.ts b/projects/igniteui-angular/test-utils/tree-grid-functions.spec.ts new file mode 100644 index 00000000000..2705c21c41d --- /dev/null +++ b/projects/igniteui-angular/test-utils/tree-grid-functions.spec.ts @@ -0,0 +1,482 @@ +import { By } from '@angular/platform-browser'; +import { UIInteractions, wait } from './ui-interactions.spec'; +import { GridFunctions } from './grid-functions.spec'; +import { DebugElement } from '@angular/core'; +import { IgxCheckboxComponent } from 'igniteui-angular/checkbox'; +import { IgxTreeGridComponent } from 'igniteui-angular/grids/tree-grid'; +import { CellType, IgxGridCellComponent, IgxRowDirective } from 'igniteui-angular/grids/core'; + +// CSS class should end with a number that specified the row's level +const TREE_CELL_DIV_INDENTATION_CSS_CLASS = '.igx-grid__tree-cell--padding-level-'; +const DEBOUNCETIME = 30; + +export const TREE_ROW_DIV_SELECTION_CHECKBOX_CSS_CLASS = '.igx-grid__cbx-selection'; +export const TREE_ROW_SELECTION_CSS_CLASS = 'igx-grid__tr--selected'; +export const TREE_CELL_SELECTION_CSS_CLASS = 'igx-grid__td--selected'; +export const TREE_HEADER_ROW_CSS_CLASS = '.igx-grid-thead'; +export const CHECKBOX_INPUT_CSS_CLASS = '.igx-checkbox__input'; +export const TREE_CELL_INDICATOR_CSS_CLASS = '.igx-grid__tree-grouping-indicator'; +export const TREE_CELL_LOADING_CSS_CLASS = '.igx-grid__tree-loading-indicator'; +export const NUMBER_CELL_CSS_CLASS = 'igx-grid__td--number'; +export const CELL_VALUE_DIV_CSS_CLASS = '.igx-grid__td-text'; +export const ROW_EDITING_BANNER_OVERLAY_CLASS = 'igx-overlay__content'; +export const TREE_GRID_CONTENT_CLASS = '.igx-grid__tbody-content'; + +export class TreeGridFunctions { + public static getHeaderRow(fix) { + return fix.debugElement.query(By.css(TREE_HEADER_ROW_CSS_CLASS)); + } + + public static getAllRows(fix): DebugElement [] { + return fix.debugElement.queryAll(By.css('igx-tree-grid-row')); + } + + public static getTreeCell(rowDOM) { + return rowDOM.query(By.css('igx-tree-grid-cell')); + } + + public static getCell(fix, rowIndex, columnKey) { + const rowDOM = TreeGridFunctions.sortElementsVertically(TreeGridFunctions.getAllRows(fix))[rowIndex]; + const rowCells = [TreeGridFunctions.getTreeCell(rowDOM)].concat(TreeGridFunctions.getNormalCells(rowDOM)); + return rowCells.filter(domCell => domCell.componentInstance.column.field === columnKey)[0]; + } + + public static getTreeCells(fix) { + return fix.debugElement.queryAll(By.css('igx-tree-grid-cell')); + } + + public static getNormalCells(rowDOM) { + return rowDOM.queryAll(By.css('igx-grid-cell')); + } + + public static getColumnCells(fix, columnKey) { + const allTreeCells = fix.debugElement.queryAll(By.css('igx-tree-grid-cell')); + const allNormalCells = fix.debugElement.queryAll(By.css('igx-grid-cell')); + const allDOMCells = allTreeCells.concat(allNormalCells); + return allDOMCells.filter(domCell => domCell.componentInstance.column.field === columnKey); + } + + public static getAllCells(fix) { + const allTreeCells = fix.debugElement.queryAll(By.css('igx-tree-grid-cell')); + const allNormalCells = fix.debugElement.queryAll(By.css('igx-grid-cell')); + return allTreeCells.concat(allNormalCells); + } + + public static getCellValue(fix, rowIndex, columnKey) { + const rowDOM = TreeGridFunctions.sortElementsVertically(TreeGridFunctions.getAllRows(fix))[rowIndex]; + const rowCells = [TreeGridFunctions.getTreeCell(rowDOM)].concat(TreeGridFunctions.getNormalCells(rowDOM)); + const cell = rowCells.filter(domCell => domCell.componentInstance.column.field === columnKey)[0]; + const valueDiv = cell.query(By.css(CELL_VALUE_DIV_CSS_CLASS)); + return valueDiv.nativeElement.textContent; + } + + public static getExpansionIndicatorDiv(rowDOM) { + const treeGridCell = TreeGridFunctions.getTreeCell(rowDOM); + return treeGridCell.query(By.css(TREE_CELL_INDICATOR_CSS_CLASS)); + } + + public static getLoadingIndicatorDiv(rowDOM) { + const treeGridCell = TreeGridFunctions.getTreeCell(rowDOM); + return treeGridCell.query(By.css(TREE_CELL_LOADING_CSS_CLASS)); + } + + public static getHeaderCell(fix, columnKey) { + const headerCells = fix.debugElement.queryAll(By.css('igx-grid-header')); + const headerCell = headerCells.filter((cell) => cell.nativeElement.textContent.indexOf(columnKey) !== -1)[0]; + return headerCell; + } + + public static getHeaderCellMultiColHeaders(fix, columnKey) { + const headerCells = fix.debugElement.queryAll(By.css('igx-grid-header')); + const headerCell = headerCells.filter((cell) => cell.nativeElement.textContent.indexOf(columnKey) !== -1).pop(); + return headerCell; + } + + public static getRowCheckbox(rowDOM) { + const checkboxDiv = TreeGridFunctions.getRowCheckboxDiv(rowDOM); + return checkboxDiv.query(By.css(CHECKBOX_INPUT_CSS_CLASS)); + } + + public static getRowCheckboxDiv(rowDOM) { + return rowDOM.query(By.css(TREE_ROW_DIV_SELECTION_CHECKBOX_CSS_CLASS)); + } + + public static clickHeaderCell(fix, columnKey) { + const cell = TreeGridFunctions.getHeaderCell(fix, columnKey); + cell.nativeElement.dispatchEvent(new Event('click')); + } + + public static clickRowSelectionCheckbox(fix, rowIndex) { + const rowDOM = TreeGridFunctions.sortElementsVertically(TreeGridFunctions.getAllRows(fix))[rowIndex]; + const checkbox = TreeGridFunctions.getRowCheckboxDiv(rowDOM); + checkbox.nativeElement.dispatchEvent(new Event('click')); + } + + public static clickHeaderRowSelectionCheckbox(fix) { + const headerRow = TreeGridFunctions.getHeaderRow(fix); + const checkbox = TreeGridFunctions.getRowCheckboxDiv(headerRow); + checkbox.nativeElement.dispatchEvent(new Event('click')); + } + + public static clickRowIndicator(fix, rowIndex) { + const rowDOM = TreeGridFunctions.sortElementsVertically(TreeGridFunctions.getAllRows(fix))[rowIndex]; + const indicatorDiv = TreeGridFunctions.getExpansionIndicatorDiv(rowDOM); + indicatorDiv.triggerEventHandler('click', new Event('click')); + } + + /** + * Verifies that the first cell of every row is its tree cell. + */ + public static verifyCellsPosition(rowsDOM, expectedColumnsCount) { + rowsDOM.forEach((row) => { + // Verify each row's cell count + const treeCell = TreeGridFunctions.getTreeCell(row); + const normalCells = TreeGridFunctions.getNormalCells(row); + expect(1 + normalCells.length).toBe(expectedColumnsCount, 'incorrect cell count for a row'); + + const treeCellRectRight = treeCell.nativeElement.getBoundingClientRect().right; + normalCells.forEach((normalCell) => { + // Verify that the treeCell is the first cell (on the left of all the other cells) + const normalCellRectLeft = normalCell.nativeElement.getBoundingClientRect().left; + expect(treeCellRectRight <= normalCellRectLeft).toBe(true, 'TreeCell is not on the left of a normal cell.'); + }); + }); + } + + /** + * Verifies both the RowComponent and the respective DOM Row are with the expected indentation level. + */ + public static verifyRowIndentationLevel(rowComponent, rowDOM, expectedIndentationLevel) { + const treeCell = TreeGridFunctions.getTreeCell(rowDOM); + const divChildren = treeCell.queryAll(By.css('div')); + + // If 'expectedIndentationLevel' is 0, we expect the row to be a root level row + // and thus it has no indentation div. + const indentationDiv = treeCell.query(By.css(TREE_CELL_DIV_INDENTATION_CSS_CLASS + expectedIndentationLevel)); + if (expectedIndentationLevel === 0) { + expect(divChildren.length).toBe(2, 'root treeCell has incorrect divs count'); + expect(indentationDiv).toBeNull(); + } else { + expect(divChildren.length).toBe(3, 'child treeCell has incorrect divs count'); + expect(indentationDiv).toBeDefined(); + expect(indentationDiv).not.toBeNull(); + } + + // Verify rowComponent's indentation API. + expect(rowComponent.treeRow.level).toBe(expectedIndentationLevel); + + // Verify expand/collapse icon's position. + TreeGridFunctions.verifyTreeRowIconPosition(rowDOM, expectedIndentationLevel); + } + + /** + * Verifies both the RowComponent and the respective DOM Row are with the expected indentation level. + * The rowIndex is the index of the row in ascending order (if rowIndex is 0, then the top-most row in view will be verified). + */ + public static verifyRowIndentationLevelByIndex(fix, rowIndex, expectedIndentationLevel) { + const treeGrid = fix.debugElement.query(By.css('igx-tree-grid')).componentInstance as IgxTreeGridComponent; + const rowComponent = treeGrid.getRowByIndex(rowIndex); + const rowDOM = TreeGridFunctions.sortElementsVertically(TreeGridFunctions.getAllRows(fix))[rowIndex]; + TreeGridFunctions.verifyRowIndentationLevel(rowComponent, rowDOM, expectedIndentationLevel); + } + + /** + * Verifies that the specified column is the tree column, that contains the tree cells. + */ + public static verifyTreeColumn(fix, expectedTreeColumnKey, expectedColumnsCount) { + const headerCell = TreeGridFunctions.getHeaderCell(fix, expectedTreeColumnKey).parent; + + const treeCells = TreeGridFunctions.getTreeCells(fix); + const rows = TreeGridFunctions.getAllRows(fix); + + // Verify the tree cells are first (on the left) in comparison to the rest of the cells. + TreeGridFunctions.verifyCellsPosition(rows, expectedColumnsCount); + + // Verify the tree cells are exactly under the respective header cell. + const headerCellRect = headerCell.nativeElement.getBoundingClientRect(); + treeCells.forEach(treeCell => { + const treeCellRect = treeCell.nativeElement.getBoundingClientRect(); + expect(headerCellRect.bottom <= treeCellRect.top).toBe(true, 'headerCell is not on top of a treeCell'); + expect(headerCellRect.left).toBe(treeCellRect.left, 'headerCell and treeCell are not left-aligned'); + expect(headerCellRect.right).toBe(treeCellRect.right, 'headerCell and treeCell are not right-aligned'); + }); + } + + /** + * Verifies that the specified column is the tree column, that contains the tree cells, when there are multi column headers. + */ + public static verifyTreeColumnInMultiColHeaders(fix, _expectedTreeColumnKey, expectedColumnsCount) { + const headersDOM = TreeGridFunctions.sortElementsHorizontally(fix.debugElement.queryAll(By.css('igx-grid-header'))); + const leftMostHeaders = headersDOM.filter(x => + x.nativeElement.getBoundingClientRect().left === headersDOM[0].nativeElement.getBoundingClientRect().left); + const headerCell = TreeGridFunctions.getElementWithMinHeight(leftMostHeaders); + + const treeCells = TreeGridFunctions.getTreeCells(fix); + const rows = TreeGridFunctions.getAllRows(fix); + + // Verify the tree cells are first (on the left) in comparison to the rest of the cells. + TreeGridFunctions.verifyCellsPosition(rows, expectedColumnsCount); + // Verify the tree cells are exactly under the respective header cell. + const headerCellRect = headerCell.nativeElement.getBoundingClientRect(); + treeCells.forEach(treeCell => { + const treeCellRect = treeCell.nativeElement.getBoundingClientRect(); + expect(headerCellRect.bottom <= treeCellRect.top).toBe(true, 'headerCell is not above a treeCell'); + expect(headerCellRect.left).toBe(treeCellRect.left, 'headerCell and treeCell are not left-aligned'); + expect(headerCellRect.right).toBe(treeCellRect.right, 'headerCell and treeCell are not right-aligned'); + }); + } + + public static getElementWithMinHeight(arr) { + return arr.reduce((a, b) => + (a.nativeElement.getBoundingClientRect().height < b.nativeElement.getBoundingClientRect().height) ? a : b); + } + + public static sortElementsVertically(arr) { + return arr.sort((a, b) => + a.nativeElement.getBoundingClientRect().top - b.nativeElement.getBoundingClientRect().top); + } + + public static sortElementsHorizontally(arr) { + return arr.sort((a, b) => + a.nativeElement.getBoundingClientRect().left - b.nativeElement.getBoundingClientRect().left); + } + + public static verifyTreeRowHasCollapsedIcon(treeRowDOM) { + const indicatorDiv = TreeGridFunctions.getExpansionIndicatorDiv(treeRowDOM); + const igxIcon = indicatorDiv.query(By.css('igx-icon')); + expect(igxIcon.nativeElement.textContent).toEqual('chevron_right'); + } + + public static verifyTreeRowHasExpandedIcon(treeRowDOM) { + const indicatorDiv = TreeGridFunctions.getExpansionIndicatorDiv(treeRowDOM); + const igxIcon = indicatorDiv.query(By.css('igx-icon')); + expect(igxIcon.nativeElement.textContent).toEqual('expand_more'); + } + + public static verifyTreeRowExpandIndicatorVisibility(treeRowDOM, visibility = 'visible') { + const indicatorDiv = TreeGridFunctions.getExpansionIndicatorDiv(treeRowDOM); + expect(indicatorDiv.nativeElement.style.visibility).toBe(visibility); + } + + public static verifyTreeRowIconPosition(treeRowDOM, indentationLevel) { + const treeCell = TreeGridFunctions.getTreeCell(treeRowDOM); + const treeCellPaddingLeft = parseInt(window.getComputedStyle(treeCell.nativeElement).paddingLeft, 10); + const treeCellRect = treeCell.nativeElement.getBoundingClientRect(); + + let indentation = 0; + if (indentationLevel !== 0) { + const indentationDiv = treeCell.query(By.css(TREE_CELL_DIV_INDENTATION_CSS_CLASS + indentationLevel)); + const indentationDivRect = indentationDiv.nativeElement.getBoundingClientRect(); + indentation = indentationDivRect.width; + } + + const iconDiv = TreeGridFunctions.getExpansionIndicatorDiv(treeRowDOM); + const iconDivRect = iconDiv.nativeElement.getBoundingClientRect(); + expect((iconDivRect.left - (treeCellRect.left + treeCellPaddingLeft + indentation)) < 2) + .toBe(true, 'TreeRow icon has incorrect position'); + } + + /** + * Returns true if a tree-grid row is 'grayed out' because of filtering + */ + public static checkRowIsGrayedOut(row: IgxRowDirective): boolean { + return row.nativeElement.classList.contains('igx-grid__tr--filtered'); + } + + /** + * Returns true if a tree-grid row is NOT 'grayed out' because of filtering + */ + public static checkRowIsNotGrayedOut(row: IgxRowDirective): boolean { + return !row.nativeElement.classList.contains('igx-grid__tr--filtered'); + } + + /** + * Verifies the selection of both the RowComponent and the respective DOM Row. + */ + public static verifyTreeRowSelection(treeGridComponent, rowComponent, rowDOM, expectedSelection: boolean) { + // Verfiy selection of checkbox + const checkboxDiv = rowDOM.query(By.css(TREE_ROW_DIV_SELECTION_CHECKBOX_CSS_CLASS)); + const checkboxComponent = checkboxDiv.query(By.css('igx-checkbox')).componentInstance as IgxCheckboxComponent; + expect(checkboxComponent.checked).toBe(expectedSelection, 'Incorrect checkbox selection state'); + expect(checkboxComponent.nativeInput.nativeElement.checked).toBe(expectedSelection, 'Incorrect native checkbox selection state'); + + // Verify selection of row + expect(rowComponent.selected).toBe(expectedSelection, 'Incorrect row selection state'); + expect(rowDOM.nativeElement.classList.contains(TREE_ROW_SELECTION_CSS_CLASS)).toBe(expectedSelection); + + // Verify selection of row through treeGrid + const selectedRows = (treeGridComponent as IgxTreeGridComponent).selectedRows; + expect(selectedRows.includes(rowComponent.key)).toBe(expectedSelection); + } + + /** + * Verifies the selection of both the RowComponent and the respective DOM Row. + * The rowIndex is the index of the row in ascending order (if rowIndex is 0, then the top-most row in view will be verified). + */ + public static verifyTreeRowSelectionByIndex(fix, rowIndex, expectedSelection: boolean) { + const treeGrid = fix.debugElement.query(By.css('igx-tree-grid')).componentInstance as IgxTreeGridComponent; + const rowComponent = treeGrid.getRowByIndex(rowIndex); + const rowDOM = TreeGridFunctions.sortElementsVertically(TreeGridFunctions.getAllRows(fix))[rowIndex]; + TreeGridFunctions.verifyTreeRowSelection(treeGrid, rowComponent, rowDOM, expectedSelection); + } + + /** + * Verifies the selection of the treeGrid rows. + * Every index of the provided array is the index of the respective row in ascending order + * (if rowIndex is 0, then the top-most row in view will be verified). + */ + public static verifyDataRowsSelection(fix, expectedSelectedRowIndices: any[], expectedSelection: boolean) { + if (expectedSelection) { + const treeGrid = fix.debugElement.query(By.css('igx-tree-grid')).componentInstance as IgxTreeGridComponent; + expect(treeGrid.selectedRows.length).toBe(expectedSelectedRowIndices.length, 'Incorrect number of rows that are selected.'); + } + + expectedSelectedRowIndices.forEach(rowIndex => { + TreeGridFunctions.verifyTreeRowSelectionByIndex(fix, rowIndex, expectedSelection); + }); + } + + /** + * Verifies the selection and checkbox state of the treeGrid row. + */ + public static verifyRowByIndexSelectionAndCheckboxState(fix, rowIndex: any, expectedSelection: boolean, + expectedCheckboxState: boolean | null) { + const treeGrid = fix.debugElement.query(By.css('igx-tree-grid')).componentInstance as IgxTreeGridComponent; + const rowComponent = treeGrid.getRowByIndex(rowIndex); + const rowDOM = TreeGridFunctions.sortElementsVertically(TreeGridFunctions.getAllRows(fix))[rowIndex]; + // Verfiy selection of checkbox + const checkboxDiv = rowDOM.query(By.css(TREE_ROW_DIV_SELECTION_CHECKBOX_CSS_CLASS)); + const checkboxComponent = checkboxDiv.query(By.css('igx-checkbox')).componentInstance as IgxCheckboxComponent; + + if (expectedCheckboxState === null) { + expect(checkboxComponent.indeterminate).toBe(true); + expect(checkboxComponent.checked).toBe(false, 'Incorrect checkbox selection state'); + expect(checkboxComponent.nativeInput.nativeElement.checked).toBe(false, 'Incorrect native checkbox selection state'); + + // Verify selection of row + expect(rowComponent.selected).toBe(false, 'Incorrect row selection state'); + expect((rowDOM.nativeElement as HTMLElement).classList.contains(TREE_ROW_SELECTION_CSS_CLASS)).toBe(false); + + // Verify selection of row through treeGrid + const selectedRows = (treeGrid as IgxTreeGridComponent).selectedRows; + expect(selectedRows.includes(rowComponent.key)).toBe(false); + } else { + expect(checkboxComponent.checked).toBe(expectedCheckboxState, 'Incorrect checkbox selection state'); + expect(checkboxComponent.nativeInput.nativeElement.checked).toBe( + expectedCheckboxState, 'Incorrect native checkbox selection state'); + + // Verify selection of row + expect(rowComponent.selected).toBe(expectedSelection, 'Incorrect row selection state'); + expect((rowDOM.nativeElement as HTMLElement).classList.contains(TREE_ROW_SELECTION_CSS_CLASS)).toBe(expectedSelection); + + // Verify selection of row through treeGrid + const selectedRows = (treeGrid as IgxTreeGridComponent).selectedRows; + expect(selectedRows.includes(rowComponent.key)).toBe(expectedSelection); + } + } + + /** + * Verifies the selection of the header checkbox. + * The expected value can be true, false or null (indeterminate). + */ + public static verifyHeaderCheckboxSelection(fix, expectedSelection: boolean | null) { + const headerRow = TreeGridFunctions.getHeaderRow(fix); + const checkboxDiv = headerRow.query(By.css(TREE_ROW_DIV_SELECTION_CHECKBOX_CSS_CLASS)); + const checkboxComponent = checkboxDiv.query(By.css('igx-checkbox')).componentInstance as IgxCheckboxComponent; + + if (expectedSelection === null) { + expect(checkboxComponent.indeterminate).toBe(true); + expect(checkboxComponent.checked).toBe(false, 'Incorrect checkbox selection state'); + expect(checkboxComponent.nativeInput.nativeElement.checked).toBe(false, 'Incorrect native checkbox selection state'); + } else { + expect(checkboxComponent.indeterminate).toBe(false); + expect(checkboxComponent.checked).toBe(expectedSelection, 'Incorrect checkbox selection state'); + expect(checkboxComponent.nativeInput.nativeElement.checked).toBe(expectedSelection, + 'Incorrect native checkbox selection state'); + } + } + + public static verifyGridCellHasSelectedClass(cellDOM) { + return cellDOM.nativeElement.classList.contains(TREE_CELL_SELECTION_CSS_CLASS); + } + + public static verifyTreeGridCellSelected(treeGrid: IgxTreeGridComponent, + cell: IgxGridCellComponent | CellType, selected = true) { + expect(cell).toBeDefined(); + if (cell) { + expect(TreeGridFunctions.verifyGridCellHasSelectedClass(cell)).toBe(selected); + + if (selected) { + const selectedCell = treeGrid.selectedCells[0]; + expect(selectedCell).toBeDefined(); + if (selectedCell) { + expect(selectedCell.value).toEqual(cell.value); + expect(selectedCell.column.field).toEqual(cell.column.field); + expect(selectedCell.row.index).toEqual(cell.row.index); + expect(selectedCell.value).toEqual(cell.value); + + // Verify the selected cell is the active descendant of the content element. + let cellElement = cell.nativeElement; + if (cell instanceof IgxGridCellComponent) { + cellElement = treeGrid.gridAPI.get_cell_by_index(cell.row.index, cell.column.field).nativeElement; + } + expect(treeGrid.activeDescendant).toEqual(cellElement.id); + const gridContentEl = treeGrid.nativeElement.querySelector(TREE_GRID_CONTENT_CLASS); + const activeDescendant = gridContentEl.getAttribute('aria-activedescendant'); + expect(activeDescendant).toBe(cellElement.id); + } + } + } + } + + public static verifyTreeRowIndicator(row, isLoading: boolean, isExpandVisible = true) { + const indicatorDiv = TreeGridFunctions.getExpansionIndicatorDiv(row); + const loadingDiv = TreeGridFunctions.getLoadingIndicatorDiv(row); + + if (isLoading) { + expect(loadingDiv).toBeDefined(); + expect(indicatorDiv).toBeNull(); + } else { + expect(loadingDiv).toBeNull(); + expect(indicatorDiv).toBeDefined(); + expect(indicatorDiv.nativeElement.style.visibility).toBe(isExpandVisible ? 'visible' : 'hidden'); + } + } + + public static moveCellUpDown(fix, treeGrid: IgxTreeGridComponent, rowIndex: number, columnName: string, moveDown = true) { + const cell = treeGrid.gridAPI.get_cell_by_index(rowIndex, columnName); + const newRowIndex = moveDown ? rowIndex + 1 : rowIndex - 1; + const keyboardEventKey = moveDown ? 'ArrowDown' : 'ArrowUp'; + const gridContent = GridFunctions.getGridContent(fix); + + UIInteractions.triggerEventHandlerKeyDown(keyboardEventKey, gridContent); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell, false); + + const newCell = treeGrid.gridAPI.get_cell_by_index(newRowIndex, columnName); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, newCell); + } + + public static moveCellLeftRight(fix, treeGrid: IgxTreeGridComponent, rowIndex: number, + firstColumnName: string, nextColumnName: string, moveRight = true) { + const cell = treeGrid.gridAPI.get_cell_by_index(rowIndex, firstColumnName); + const keyboardEventKey = moveRight ? 'ArrowRight' : 'ArrowLeft'; + const gridContent = GridFunctions.getGridContent(fix); + + UIInteractions.triggerEventHandlerKeyDown(keyboardEventKey, gridContent); + fix.detectChanges(); + + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, cell, false); + const newCell = treeGrid.gridAPI.get_cell_by_index(rowIndex, nextColumnName); + TreeGridFunctions.verifyTreeGridCellSelected(treeGrid, newCell); + } + + + public static moveGridCellWithTab = + async (fix, cell: IgxGridCellComponent | CellType) => { + UIInteractions.triggerKeyDownEvtUponElem('Tab', cell.nativeElement, true); + await wait(DEBOUNCETIME); + fix.detectChanges(); + }; +} diff --git a/projects/igniteui-angular/test-utils/ui-interactions.spec.ts b/projects/igniteui-angular/test-utils/ui-interactions.spec.ts new file mode 100644 index 00000000000..3bd6c261053 --- /dev/null +++ b/projects/igniteui-angular/test-utils/ui-interactions.spec.ts @@ -0,0 +1,483 @@ +import { first } from 'rxjs/operators'; +import { DebugElement } from '@angular/core'; +import { HorizontalAlignment, Point, VerticalAlignment } from 'igniteui-angular/core'; + +export const wait = (ms = 0) => new Promise(resolve => setTimeout(resolve, ms)); + +// export const waitForGridScroll = grid => new Promise(resolve => grid.gridScroll.pipe(first()).subscribe(() => { +// grid.cdr.detectChanges(); +// resolve(); +// })); + +export const waitForActiveNodeChange = grid => new Promise(resolve => grid.activeNodeChange.pipe(first()).subscribe(() => { + grid.cdr.detectChanges(); + resolve(); +})); + +export const waitForSelectionChange = grid => new Promise(resolve => grid.selected.pipe(first()).subscribe(() => { + grid.cdr.detectChanges(); + resolve(); +})); + +declare let Touch: { + prototype: Touch; + new(prop): Touch; +}; +export class UIInteractions { + /** + * Clears all opened overlays and resets document scrollTop and scrollLeft + */ + public static clearOverlay() { + const overlays = document.getElementsByClassName('igx-overlay') as HTMLCollectionOf; + Array.from(overlays).forEach(element => { + element.remove(); + }); + document.documentElement.scrollTop = 0; + document.documentElement.scrollLeft = 0; + document.body.style.overflow = 'hidden'; + } + + /** + * Clicks an element - native or debug, by dispatching pointerdown, pointerup and click events. + * + * @param element - Native or debug element. + * @param shift - if the shift key is pressed. + * @param ctrl - if the ctrl key is pressed. + */ + public static simulateClickAndSelectEvent(element, shift = false, ctrl = false) { + const nativeElement = element.nativeElement ?? element; + UIInteractions.simulatePointerOverElementEvent('pointerdown', nativeElement, shift, ctrl); + UIInteractions.simulatePointerOverElementEvent('pointerup', nativeElement); + nativeElement.dispatchEvent(new MouseEvent('click', { bubbles: true })); + } + + /** + * Double click an element - native or debug, by dispatching pointerdown, pointerup and dblclick events. + * // TODO - typing of element - whe npassing cell/row, should be CellType/RowTpe + * + * @param element - Native or debug element. + */ + public static simulateDoubleClickAndSelectEvent(element: any) { + const nativeElement = element.nativeElement ?? element; + UIInteractions.simulatePointerOverElementEvent('pointerdown', nativeElement); + UIInteractions.simulatePointerOverElementEvent('pointerup', nativeElement); + nativeElement.dispatchEvent(new MouseEvent('dblclick')); + } + + /** + * click with non primary button on an element - native or debug, by dispatching pointerdown, pointerup and click events. + * + * @param element - Native or debug element. + */ + public static simulateNonPrimaryClick(element) { + const nativeElement = element.nativeElement ?? element; + nativeElement.dispatchEvent(new PointerEvent('pointerdown', { button: 2 })); + nativeElement.dispatchEvent(new PointerEvent('pointerup', { button: 2 })); + nativeElement.dispatchEvent(new Event('click')); + } + + /** + * gets a keyboard event + * + * @param eventType - name of the event + * @param keyPressed- pressed key + */ + public static getKeyboardEvent(eventType: string, keyPressed: string, altKey = false, shiftKey = false, ctrlKey = false) { + const keyboardEvent = { + key: keyPressed, + altKey, + shiftKey, + ctrlKey, + stopPropagation: () => { }, + stopImmediatePropagation: () => { }, + preventDefault: () => { } + }; + return new KeyboardEvent(eventType, keyboardEvent); + } + + /** + * gets a mouse event + * + * @param eventType - name of the event + */ + public static getMouseEvent(eventType, altKey = false, shiftKey = false, ctrlKey = false) { + const clickEvent = { + altKey, + shiftKey, + ctrlKey, + stopPropagation: () => { }, + stopImmediatePropagation: () => { }, + preventDefault: () => { } + }; + return new MouseEvent(eventType, clickEvent); + } + + /** + * Press a key on an element - debug, by triggerEventHandler. + * + * @param keyPressed - pressed key + * @param elem - debug element + */ + public static triggerEventHandlerKeyDown(key: string, elem: any, altKey = false, shiftKey = false, ctrlKey = false) { + const event = { + target: elem.nativeElement, + key, + altKey, + shiftKey, + ctrlKey, + stopPropagation: () => { }, + stopImmediatePropagation: () => { }, + preventDefault: () => { } + }; + if (elem.hasOwnProperty('triggerEventHandler')) { + elem.triggerEventHandler('keydown', event); + } else { + (elem.nativeElement as HTMLElement).dispatchEvent(new KeyboardEvent('keydown', { ...event })); + } + } + + /** + * Trigger key up on an element - debug, by triggerEventHandler. + * + * @param keyPressed - pressed key + * @param elem - debug element + */ + public static triggerEventHandlerKeyUp(key: string, elem: DebugElement, altKey = false, shiftKey = false, ctrlKey = false) { + const event = { + key, + altKey, + shiftKey, + ctrlKey, + stopPropagation: () => { }, + stopImmediatePropagation: () => { }, + preventDefault: () => { } + }; + elem.triggerEventHandler('keyup', event); + } + + /** + * Sets an input value- native or debug, by dispatching keydown, input and keyup events. + * + * @param element - Native or debug element. + * @param text - text to be set. + * @param fix - if fixture is set it will detect changes on it. + */ + public static clickAndSendInputElementValue(element, text, fix = null) { + const nativeElement = element.nativeElement ?? element; + nativeElement.value = text; + nativeElement.dispatchEvent(new Event('keydown')); + nativeElement.dispatchEvent(new Event('input')); + nativeElement.dispatchEvent(new Event('keyup')); + if (fix) { + fix.detectChanges(); + } + } + + /** + * Sets an input value- native or debug, by dispatching only input events. + * + * @param element - Native or debug element. + * @param text - text to be set. + * @param fix - if fixture is set it will detect changes on it. + */ + public static setInputElementValue(element, text, fix = null) { + const nativeElement = element.nativeElement ?? element; + nativeElement.value = text; + nativeElement.dispatchEvent(new Event('input')); + if (fix) { + fix.detectChanges(); + } + } + + /** + * Sets an input value- debug element. + * + * @param inputElement - debug element. + * @param inputValue - text to be set. + */ + public static triggerInputEvent(inputElement: DebugElement, inputValue: string) { + inputElement.nativeElement.value = inputValue; + inputElement.triggerEventHandler('input', { target: inputElement.nativeElement }); + } + + public static triggerInputKeyInteraction(inputValue: string, target: DebugElement) { + const startPos = target.nativeElement.selectionStart; + const endPos = target.nativeElement.selectionEnd; + target.nativeElement.value = + target.nativeElement.value.substring(0, startPos) + + inputValue + + target.nativeElement.value.substring(endPos); + // move the caret + if (startPos !== endPos) { + // replaced selection, cursor goes to end + target.nativeElement.selectionStart = target.nativeElement.selectionEnd = startPos + inputValue.length; + } else { + // typing move the cursor after the typed value + target.nativeElement.selectionStart = target.nativeElement.selectionEnd = endPos + inputValue.length; + } + target.triggerEventHandler('input', { target: target.nativeElement }); + } + + public static simulateTyping(characters: string, target: DebugElement, selectionStart = 0, selectionEnd = 0) { + if (characters) { + if (selectionStart > selectionEnd) { + return Error('Selection start should be less than selection end position'); + } + + const inputEl = target.nativeElement as HTMLInputElement; + inputEl.setSelectionRange(selectionStart, selectionEnd); + for (const char of characters) { + this.triggerEventHandlerKeyDown(char, target); + this.triggerInputKeyInteraction(char, target); + this.triggerEventHandlerKeyUp(char, target); + } + } + } + + public static simulatePaste(pasteText: string, target: DebugElement, selectionStart = 0, selectionEnd = 0) { + if (selectionStart > selectionEnd) { + return Error('Selection start should be less than selection end position'); + } + const inputEl = target.nativeElement as HTMLInputElement; + inputEl.setSelectionRange(selectionStart, selectionEnd); + UIInteractions.triggerPasteEvent(target, pasteText); + UIInteractions.triggerInputKeyInteraction(pasteText, target); + } + + public static triggerPasteEvent(inputElement: DebugElement, inputValue: string) { + const pasteData = new DataTransfer(); + pasteData.setData('text/plain', inputValue); + const event = new ClipboardEvent('paste', { clipboardData: pasteData }); + inputElement.triggerEventHandler('paste', event); + } + + public static simulateCompositionEvent(characters: string, target: DebugElement, selectionStart = 0, selectionEnd = 0, isBlur = true) { + if (characters) { + if (selectionStart > selectionEnd) { + return Error('Selection start should be less than selection end position'); + } + + const inputEl = target.nativeElement as HTMLInputElement; + inputEl.setSelectionRange(selectionStart, selectionEnd); + target.triggerEventHandler('compositionstart', { target: target.nativeElement }); + for (const char of characters) { + this.triggerEventHandlerKeyDown(char, target); + this.triggerInputKeyInteraction(char, target); + this.triggerEventHandlerKeyUp(char, target); + } + + target.triggerEventHandler('compositionend', { target: target.nativeElement }); + if (isBlur) { + this.triggerInputKeyInteraction(characters, target); + } + } + } + + public static triggerKeyDownEvtUponElem(key, elem, bubbles = true, altKey = false, shiftKey = false, ctrlKey = false) { + const keyboardEvent = new KeyboardEvent('keydown', { + key, + bubbles, + shiftKey, + ctrlKey, + altKey + }); + elem.dispatchEvent(keyboardEvent); + } + + public static simulateClickEvent(element, shift = false, ctrl = false) { + const event = new MouseEvent('click', { + bubbles: true, + shiftKey: shift, + ctrlKey: ctrl + }); + element.dispatchEvent(event); + } + + public static simulateMouseEvent(eventName: string, element, clientX?, clientY?) { + const options: MouseEventInit = { + view: window, + bubbles: true, + cancelable: true, + clientX, + clientY + }; + element.dispatchEvent(new MouseEvent(eventName, options)); + } + + public static simulateMouseDownEvent(element: HTMLElement, shift = false, ctrl = false) { + const event = new MouseEvent('mousedown', { bubbles: true }); + UIInteractions.simulatePointerOverElementEvent('pointerdown', element, shift, ctrl); + UIInteractions.simulatePointerOverElementEvent('pointerup', element); + element.dispatchEvent(event); + } + + public static createPointerEvent(eventName: string, point: Point) { + const options: PointerEventInit = { + view: window, + bubbles: true, + cancelable: true, + pointerId: 1 + }; + const pointerEvent = new PointerEvent(eventName, options); + Object.defineProperty(pointerEvent, 'pageX', { value: point.x, enumerable: true }); + Object.defineProperty(pointerEvent, 'pageY', { value: point.y, enumerable: true }); + return pointerEvent; + } + + public static simulatePointerEvent(eventName: string, element, x, y) { + const options: PointerEventInit = { + view: window, + bubbles: true, + cancelable: true, + pointerId: 1 + }; + const pointerEvent = new PointerEvent(eventName, options); + Object.defineProperty(pointerEvent, 'pageX', { value: x, enumerable: true }); + Object.defineProperty(pointerEvent, 'pageY', { value: y, enumerable: true }); + element.dispatchEvent(pointerEvent); + return pointerEvent; + } + + public static simulatePointerOverElementEvent(eventName: string, element, shiftKey = false, ctrlKey = false) { + const options: PointerEventInit = { + view: window, + bubbles: true, + cancelable: true, + pointerId: 1, + buttons: 1, + clientY: element.getBoundingClientRect().y, + button: eventName === 'pointerenter' ? -1 : 0, + shiftKey, + ctrlKey + }; + element.dispatchEvent(new PointerEvent(eventName, options)); + } + + public static simulateDropEvent(nativeElement: HTMLElement, data: any, format: string) { + const dataTransfer = new DataTransfer(); + dataTransfer.setData(format, data); + + nativeElement.dispatchEvent(new DragEvent('drop', { dataTransfer })); + } + + public static simulateWheelEvent(element, deltaX, deltaY, shiftKey = false) { + const event = new WheelEvent('wheel', { deltaX, deltaY, shiftKey }); + Object.defineProperty(event, 'wheelDeltaX', { value: deltaX }); + Object.defineProperty(event, 'wheelDeltaY', { value: deltaY }); + + return new Promise(resolve => { + element.dispatchEvent(event); + resolve(); + }); + } + + public static simulateTouchStartEvent(target, pageX, pageY) { + const touchInit = { + identifier: 0, + target, + pageX, + pageY + }; + const t = new Touch(touchInit); + const touchEventObject = new TouchEvent('touchstart', { touches: [t] }); + return new Promise(resolve => { + target.dispatchEvent(touchEventObject); + resolve(); + }); + } + + public static simulateTouchMoveEvent(element, movedX, movedY) { + const touchInit = { + identifier: 0, + target: element, + pageX: movedX, + pageY: movedY + }; + const t = new Touch(touchInit); + const touchEventObject = new TouchEvent('touchmove', { touches: [t] }); + return new Promise(resolve => { + element.dispatchEvent(touchEventObject); + resolve(); + }); + } + + public static simulateTouchEndEvent(element, movedX, movedY) { + const touchInit = { + identifier: 0, + target: element, + pageX: movedX, + pageY: movedY + }; + const t = new Touch(touchInit); + const touchEventObject = new TouchEvent('touchend', { touches: [t] }); + return new Promise(resolve => { + element.dispatchEvent(touchEventObject); + resolve(); + }); + } + + /** + * Calculate point within element + * + * @param element Element to calculate point for + * @param hAlign The horizontal position of the point within the element (defaults to center) + * @param vAlign The vertical position of the point within the element (defaults to middle) + */ + public static getPointFromElement( + element: Element, + hAlign: HorizontalAlignment = HorizontalAlignment.Center, + vAlign: VerticalAlignment = VerticalAlignment.Middle): Point { + const elementRect = element.getBoundingClientRect(); + return { + x: elementRect.right + hAlign * elementRect.width, + y: elementRect.bottom + vAlign * elementRect.height + }; + } + + public static hoverElement(element: HTMLElement, bubbles = false) { + element.dispatchEvent(new MouseEvent('mouseenter', { bubbles })); + } + + public static unhoverElement(element: HTMLElement, bubbles = false) { + element.dispatchEvent(new MouseEvent('mouseleave', { bubbles })); + } + + public static clickDragDirective(fix, dragDir) { + dragDir.onPointerDown(new PointerEvent('pointerdown', { pointerId: 1 })); + dragDir.onPointerUp(new PointerEvent('pointerup')); + fix.detectChanges(); + } + + public static moveDragDirective(fix, dragDir, moveX, moveY, triggerPointerUp = false) { + const dragElem = dragDir.element.nativeElement; + const startingTop = dragElem.getBoundingClientRect().top; + const startingLeft = dragElem.getBoundingClientRect().left; + const startingBottom = dragElem.getBoundingClientRect().bottom; + const startingRight = dragElem.getBoundingClientRect().right; + + const startingX = (startingLeft + startingRight) / 2; + const startingY = (startingTop + startingBottom) / 2; + + dragDir.onPointerDown({ pointerId: 1, pageX: startingX, pageY: startingY }); + fix.detectChanges(); + + dragDir.onPointerMove({ pointerId: 1, pageX: startingX + 10, pageY: startingY + 10 }); + fix.detectChanges(); + + dragDir.onPointerMove({ + pointerId: 1, + pageX: startingX + moveX, + pageY: startingY + moveY + }); + fix.detectChanges(); + + if (triggerPointerUp) { + dragDir.onPointerUp({ + pointerId: 1, + pageX: startingX + moveX, + pageY: startingY + moveY + }); + fix.detectChanges(); + } + } +} diff --git a/projects/igniteui-angular/time-picker/README.md b/projects/igniteui-angular/time-picker/README.md new file mode 100644 index 00000000000..3babdbe9cdd --- /dev/null +++ b/projects/igniteui-angular/time-picker/README.md @@ -0,0 +1,116 @@ +# igx-time-picker Component + +The **igx-time-picker** component allows you to select time from dropdown/dialog which is presented into input field. +A walk through of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/time_picker.html) + +# Usage +```typescript +import { IgxTimePickerComponent } from "igniteui-angular"; +``` + +Basic initialization +```html + +``` + +Custom formats for the input field. +```html + + +``` +If the `inputFormat` is not set, it will default to `hh:mm a`. The `displayFormat` accepts all supported formats by Angular's `DatePipe`. + +The time picker also supports binding through `ngModel` in case two-way date-binding is needed. +```html + + + Time is not in range. + + +``` + +Additionally the time picker spin options can be set by the `spinLoop` property. Its default value is `true`, in which case the spinning is wrapped around the min and max values. +```html + + +``` + +In dialog mode the dialog's header orientation can be set to `vertical` or `horizontal` +```html + + +``` + +A label can be added to the time picker in the following way: +````html + + + +```` + +The component's action buttons can be templated using the `igxPickerActions` directive: +```html + + +
    + +
    +
    +
    +``` +```typescript + public selectToday(picker: IgxTimePickerComponent) { + picker.value = new Date(Date.now()); + picker.close(); + } +``` + +# API + +###### Inputs +| Name | Type | Description | +|:----------|:-------------:|:------| +| `id` | string | Unique identifier of the component. If not provided it will be automatically generated.| +| `okButtonLabel` | string | Renders OK button with custom content, which closes the dropdown/dialog. By default `okButtonLabel` is set to 'OK'. | +| `cancelButtonLabel` | string | Renders cancel button with custom content, which closes the dropdown/dialog and reverts picker's value to the value at the moment of opening. By default `cancelButtonLabel` is set to 'Cancel'. | +| `value` | `Date | string` | Value of the time picker. | +|`resourceStrings`| ITimePickerResourceStrings | Resource strings of the time-picker. | +| `disabled` | boolean | Disable the time picker. | +| `itemsDelta`| object | Sets the delta for hour, minute and second items. By default `itemsDelta` is set to {hour:1, minute:1, second:1} | +| `minValue` | `Date | string` | The minimum value required for the picker to remain valid. | +| `maxValue` | `Date | string` | The maximum value required for the editor to remain valid. | +| `headerOrientation` | `'horizontal' | 'vertical'` | Determines whether the dialog's header renders in vertical or horizontal state. Applies only in dialog mode. | +| `locale` | `string` | Sets the locale used for formatting and displaying time in the dropdown/dialog. For more information check out [this](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl) page for valid formats. | +| `displayFormat` | `string` | The format used to display the picker's value when it's not being edited. | +| `inputFormat` | `string` | The editor's input mask. | +| `formatter` | `function` | Applied custom formatter on the selected or passed in date. | +| `spinLoop` | boolean | Determines the spin behavior. By default `spinLoop` is set to true. | +| `mode` | PickerInteractionMode | Determines the interaction mode - a dialog picker or a dropdown with editable masked input. Default is dropdown picker.| +| `overlaySettings` | `OverlaySettings` | Changes the default overlay settings used by the `IgxTimePickerComponent`. +| `placeholder` | `string` | Sets the placeholder text for empty input. +| `type` | `IgxInputGroupType` | Determines how the picker will be styled. + +### Outputs +| Name | Description | Cancelable | Emitted with | +|:----:|:------------|:----------:|--------------| +| `opening` | Fired when the dropdown/dialog has started opening | true | `IBaseCancelableBrowserEventArgs` | +| `opened` | Fired after the dropdown/dialog has opened. | false | `IBaseEventArgs` | +| `closing` | Fired when the dropdown/dialog has started closing, cancelable. | true | `IBaseCancelableBrowserEventArgs` | +| `closed` | Fired after the dropdown/dialog has closed. | false | `IBaseEventArgs` | +| `validationFailed` | Emitted when an invalid time string is entered or when the value is outside the min/max range. | false | `ITimePickerValidationFailedEventArgs` | +| `valueChange` | Emitted when the picker's value changes. Allows two-way binding of `value`. | false | `Date | string` | + +### Methods +| Name | Arguments | Return Type | Description | +|:----------:|:------|:------|:------| +| `select` | `Date | string` | `void` | Accepts a Date object or string and selects the corresponding time from the dropdown/dialog. | +| `clear` | n/a | `void` | Clears the picker's value in case it is a string and resets it to `00:00:00` when it is a Date object | +| `open` | `OverlaySettings` | `void` | Opens the dropdown/dialog. | +| `close` | n/a | `void` | Closes the dropdown/dialog. | +| `toggle` | `OverlaySettings` | `void` | Toggles the dropdown/dialog between opened and closed states. | +| `increment` | `DatePart?, number?` | | `void` | Accepts a `DatePart` and increments it by one. If no value is provided, it defaults to the part at the position of the cursor. +| `decrement` | `DatePart?, number?` | `void` | Accepts a `DatePart` and decrements it by one. If no value is provided, it defaults to the part at the position of the cursor. diff --git a/projects/igniteui-angular/time-picker/index.ts b/projects/igniteui-angular/time-picker/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/time-picker/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/time-picker/ng-package.json b/projects/igniteui-angular/time-picker/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/time-picker/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/time-picker/src/public_api.ts b/projects/igniteui-angular/time-picker/src/public_api.ts new file mode 100644 index 00000000000..57fb9c155ea --- /dev/null +++ b/projects/igniteui-angular/time-picker/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './time-picker/public_api'; +export * from './time-picker/time-picker.module'; diff --git a/projects/igniteui-angular/time-picker/src/time-picker/public_api.ts b/projects/igniteui-angular/time-picker/src/time-picker/public_api.ts new file mode 100644 index 00000000000..bb027f9d462 --- /dev/null +++ b/projects/igniteui-angular/time-picker/src/time-picker/public_api.ts @@ -0,0 +1,18 @@ +import { IgxPickerActionsDirective, IgxPickerClearComponent, IgxPickerToggleComponent } from 'igniteui-angular/core'; +import { IgxTimePickerComponent } from './time-picker.component'; +import { IgxHintDirective, IgxLabelDirective, IgxPrefixDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; + +export * from './time-picker.component'; +export * from './time-picker.directives'; + +/* NOTE: Time picker directives collection for ease-of-use import in standalone components scenario */ +export const IGX_TIME_PICKER_DIRECTIVES = [ + IgxTimePickerComponent, + IgxPickerActionsDirective, + IgxPickerToggleComponent, + IgxPickerClearComponent, + IgxLabelDirective, + IgxPrefixDirective, + IgxSuffixDirective, + IgxHintDirective +] as const; diff --git a/projects/igniteui-angular/time-picker/src/time-picker/time-picker.common.ts b/projects/igniteui-angular/time-picker/src/time-picker/time-picker.common.ts new file mode 100644 index 00000000000..d724606ddc7 --- /dev/null +++ b/projects/igniteui-angular/time-picker/src/time-picker/time-picker.common.ts @@ -0,0 +1,39 @@ +import { ElementRef, InjectionToken } from '@angular/core'; +import { DatePartDeltas } from 'igniteui-angular/core'; + +/** @hidden */ +export const IGX_TIME_PICKER_COMPONENT = new InjectionToken('IgxTimePickerComponentToken'); + +/** @hidden */ +export interface IgxTimePickerBase { + hourList: ElementRef; + locale: string; + minuteList: ElementRef; + secondsList: ElementRef; + ampmList: ElementRef; + inputFormat: string; + itemsDelta: Pick; + spinLoop: boolean; + selectedDate: Date; + maxDropdownValue: Date; + minDropdownValue: Date; + isTwelveHourFormat: boolean; + showHoursList: boolean; + showMinutesList: boolean; + showSecondsList: boolean; + showAmPmList: boolean; + minDateValue: Date; + maxDateValue: Date; + /** @hidden @internal */ + appliedFormat: string; + nextHour(delta: number); + nextMinute(delta: number); + nextSeconds(delta: number); + nextAmPm(delta: number); + close(): void; + cancelButtonClick(): void; + okButtonClick(): void; + onItemClick(item: string, dateType: string): void; + getPartValue(value: Date, type: string): string; + toISOString(value: Date): string; +} diff --git a/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.html b/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.html new file mode 100644 index 00000000000..1c1cc42d568 --- /dev/null +++ b/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.html @@ -0,0 +1,127 @@ + + + + @if (!toggleComponents.length) { + + + + } + + + + + + + + + @if (showClearButton) { + + + + } + + + + + + + + + + + @if (cancelButtonLabel || okButtonLabel) { +
    + @if (cancelButtonLabel) { + + } + @if (okButtonLabel) { + + } +
    + } +
    + + diff --git a/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.spec.ts b/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.spec.ts new file mode 100644 index 00000000000..40fc1dee34c --- /dev/null +++ b/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.spec.ts @@ -0,0 +1,2037 @@ +import { Component, ViewChild, DebugElement, EventEmitter, QueryList, ElementRef, Injector, ChangeDetectorRef } from '@angular/core'; +import { TestBed, fakeAsync, tick, ComponentFixture, waitForAsync } from '@angular/core/testing'; +import { UntypedFormControl, UntypedFormGroup, FormsModule, NgForm, ReactiveFormsModule, Validators } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxTimePickerComponent, IgxTimePickerValidationFailedEventArgs } from './time-picker.component'; +import { UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { + IgxHintDirective, IgxInputGroupComponent, IgxInputState, IgxLabelDirective, IgxPrefixDirective, IgxSuffixDirective +} from '../../../input-group/src/public_api'; +import { PickerInteractionMode } from '../../../core/src/date-common/types'; +import { PlatformUtil } from 'igniteui-angular/core'; +import { DatePart } from '../../../core/src/date-common/public_api'; +import { IgxDateTimeEditorDirective } from '../../../directives/src/directives/date-time-editor/date-time-editor.directive'; +import { IgxItemListDirective, IgxTimeItemDirective } from './time-picker.directives'; +import { IgxPickerClearComponent, IgxPickerToggleComponent } from '../../../core/src/date-common/public_api'; +import { Subscription } from 'rxjs'; +import { HammerGesturesManager } from 'igniteui-angular/core'; +import { HammerOptions } from 'igniteui-angular/core'; +import { registerLocaleData } from "@angular/common"; +import localeJa from "@angular/common/locales/ja"; +import localeBg from "@angular/common/locales/bg"; +import { IGX_TIME_PICKER_COMPONENT } from './time-picker.common'; + +const CSS_CLASS_TIMEPICKER = 'igx-time-picker'; +const CSS_CLASS_INPUTGROUP = 'igx-input-group'; +const CSS_CLASS_INPUTGROUP_DISABLED = 'igx-input-group--disabled'; +const CSS_CLASS_INPUT_GROUP_REQUIRED = 'igx-input-group--required'; +const CSS_CLASS_INPUT_GROUP_INVALID = 'igx-input-group--invalid'; +const CSS_CLASS_INPUT_GROUP_LABEL = 'igx-input-group__label'; +const CSS_CLASS_INPUT = '.igx-input-group__input'; +const CSS_CLASS_DROPDOWN = '.igx-time-picker--dropdown'; +const CSS_CLASS_HOURLIST = 'igx-time-picker__hourList'; +const CSS_CLASS_MINUTELIST = 'igx-time-picker__minuteList'; +const CSS_CLASS_SECONDSLIST = '.igx-time-picker__secondsList'; +const CSS_CLASS_AMPMLIST = 'igx-time-picker__ampmList'; +const CSS_CLASS_SELECTED_ITEM = '.igx-time-picker__item--selected'; +const CSS_CLASS_HEADER_HOUR = '.igx-time-picker__header-hour'; +const CSS_CLASS_HEADER = '.igx-time-picker__header'; +const CSS_CLASS_OVERLAY_WRAPPER = 'igx-overlay__wrapper'; +const TIME_PICKER_TOGGLE_ICON = 'access_time'; +const TIME_PICKER_CLEAR_ICON = 'clear'; +const CSS_CLASS_TIME_PICKER_VERTICAL = '.igx-time-picker--vertical'; + +describe('IgxTimePicker', () => { + let timePicker: IgxTimePickerComponent; + + describe('Unit tests', () => { + let mockControlInstance: any; + let elementRef; + let mockNgControl; + let mockInjector; + let mockCdr; + let mockDateTimeEditorDirective; + let mockInputGroup: Partial; + let mockInputDirective; + + beforeEach(() => { + mockDateTimeEditorDirective = { + _value: null, + get value() { + return this._value; + }, + clear() { + this.valueChange.emit(null); + }, + increment: () => { }, + decrement: () => { }, + set value(val: any) { + this._value = val; + }, + valueChange: new EventEmitter(), + validationFailed: new EventEmitter() + }; + spyOn(mockDateTimeEditorDirective, 'increment'); + spyOn(mockDateTimeEditorDirective, 'decrement'); + + mockInputGroup = { + _isFocused: false, + get isFocused() { + return this._isFocused; + }, + set isFocused(val: boolean) { + this._isFocused = val; + }, + _isRequired: false, + get isRequired() { + return this._isRequired; + }, + set isRequired(val: boolean) { + this._isRequired = val; + }, + element: { + nativeElement: jasmine.createSpyObj('mockElement', + ['focus', 'blur', 'click', 'addEventListener', 'removeEventListener']) + } + } as any; + + elementRef = { + nativeElement: jasmine.createSpyObj('mockElement', ['blur', 'click', 'focus']) + }; + mockControlInstance = { + _touched: false, + get touched() { + return this._touched; + }, + set touched(val: boolean) { + this._touched = val; + }, + _dirty: false, + get dirty() { + return this._dirty; + }, + set dirty(val: boolean) { + this._dirty = val; + }, + _asyncValidator: () => { }, + get asyncValidator() { + return this._asyncValidator; + }, + set asyncValidator(val: () => boolean) { + this._asyncValidator = val; + }, + _validator: () => { }, + get validator() { + return this._validator; + }, + set validator(val: () => boolean) { + this._validator = val; + } + }; + mockNgControl = { + registerOnChangeCb: () => { }, + registerOnTouchedCb: () => { }, + registerOnValidatorChangeCb: () => { }, + statusChanges: new EventEmitter(), + _control: mockControlInstance, + get control() { + return this._control; + }, + set control(val: any) { + this._control = val; + }, + valid: true + }; + mockInputDirective = { + valid: 'mock', + nativeElement: { + _listeners: { + none: [] + }, + addEventListener(event: string, cb: () => void) { + let target = this._listeners[event]; + if (!target) { + this._listeners[event] = []; + target = this._listeners[event]; + } + target.push(cb); + }, + removeEventListener(event: string, cb: () => void) { + const target = this._listeners[event]; + if (!target) { + return; + } + const index = target.indexOf(cb); + if (index !== -1) { + target.splice(index, 1); + } + }, + dispatchEvent(event: string) { + const target = this._listeners[event]; + if (!target) { + return; + } + target.forEach(e => { + e(); + }); + }, + focus() { + this.dispatchEvent('focus'); + }, + click() { + this.dispatchEvent('click'); + }, + blur() { + this.dispatchEvent('blur'); + } + }, + focus: () => { } + }; + mockInjector = jasmine.createSpyObj('Injector', { + get: mockNgControl + }); + + mockCdr = jasmine.createSpyObj('ChangeDetectorRef', ['detectChanges']); + //const platformUtil = TestBed.inject(PlatformUtil); + + TestBed.configureTestingModule({ + providers: [ + {provide: ElementRef, useValue: elementRef}, + {provide: Injector, useValue: mockInjector}, + {provide: ChangeDetectorRef, useValue: mockCdr}, + { provide: IGX_TIME_PICKER_COMPONENT, useExisting: IgxTimePickerComponent }, + IgxTimePickerComponent, + PlatformUtil, + HammerGesturesManager, + IgxItemListDirective + ] + }); + timePicker = TestBed.inject(IgxTimePickerComponent); + (timePicker as any).dateTimeEditor = mockDateTimeEditorDirective; + (timePicker as any)._inputGroup = mockInputGroup; + (timePicker as any).inputDirective = mockInputDirective; + timePicker.toggleComponents = new QueryList(); + timePicker.clearComponents = new QueryList(); + }); + + it('should properly initialize w/ ngControl', () => { + const mockSub = jasmine.createSpyObj('mockSub', ['unsubscribe']); + spyOn(mockNgControl.statusChanges, 'subscribe').and.returnValue(mockSub); + timePicker.ngOnInit(); + timePicker.ngAfterViewInit(); + expect(mockNgControl.statusChanges.subscribe).toHaveBeenCalledTimes(1); + timePicker.ngOnDestroy(); + expect(mockSub.unsubscribe).toHaveBeenCalledTimes(1); + }); + + it('should properly subscribe to ngControl status changes', () => { + timePicker.ngOnInit(); + timePicker.ngAfterViewInit(); + const touchedSpy = spyOnProperty(mockControlInstance, 'touched', 'get'); + const dirtySpy = spyOnProperty(mockControlInstance, 'dirty', 'get'); + const validatorSpy = spyOnProperty(mockControlInstance, 'validator'); + const asyncValidatorSpy = spyOnProperty(mockControlInstance, 'asyncValidator'); + const inputGroupFocusedSpy = spyOnProperty(mockInputGroup, 'isFocused', 'get'); + const inputGroupRequiredGet = spyOnProperty(mockInputGroup, 'isRequired', 'get'); + const inputGroupRequiredSet = spyOnProperty(mockInputGroup, 'isRequired', 'set'); + inputGroupRequiredGet.and.returnValue(false); + inputGroupFocusedSpy.and.returnValue(false); + expect(touchedSpy).not.toHaveBeenCalled(); + expect(dirtySpy).not.toHaveBeenCalled(); + expect(validatorSpy).not.toHaveBeenCalled(); + expect(asyncValidatorSpy).not.toHaveBeenCalled(); + + touchedSpy.and.returnValue(false); + dirtySpy.and.returnValue(false); + mockNgControl.statusChanges.emit(); + expect(touchedSpy).toHaveBeenCalledTimes(1); + expect(dirtySpy).toHaveBeenCalledTimes(1); + // required getter + expect(validatorSpy).toHaveBeenCalledTimes(1); + + touchedSpy.and.returnValue(true); + dirtySpy.and.returnValue(true); + validatorSpy.and.returnValue(false); + asyncValidatorSpy.and.returnValue(false); + mockNgControl.statusChanges.emit(); + expect(validatorSpy).toHaveBeenCalledTimes(3); + expect(asyncValidatorSpy).toHaveBeenCalledTimes(1); + expect(inputGroupFocusedSpy).not.toHaveBeenCalled(); + + validatorSpy.and.returnValue(() => { }); + asyncValidatorSpy.and.returnValue(() => { }); + + mockNgControl.statusChanges.emit(); + expect(inputGroupFocusedSpy).toHaveBeenCalledTimes(1); + expect(inputGroupRequiredSet).not.toHaveBeenCalled(); + + inputGroupRequiredGet.and.returnValue(false); + validatorSpy.and.returnValue(() => ({ required: true })); + mockNgControl.statusChanges.emit(); + expect(inputGroupFocusedSpy).toHaveBeenCalledTimes(2); + expect(inputGroupRequiredSet).toHaveBeenCalledTimes(1); + expect(inputGroupRequiredSet).toHaveBeenCalledWith(true); + + inputGroupRequiredGet.and.returnValue(true); + + mockNgControl.statusChanges.emit(); + expect(inputGroupFocusedSpy).toHaveBeenCalledTimes(3); + + expect(mockInputDirective.valid).toBe(IgxInputState.INITIAL); + mockNgControl.valid = false; + + mockNgControl.statusChanges.emit(); + expect(mockInputDirective.valid).toBe(IgxInputState.INVALID); + + inputGroupFocusedSpy.and.returnValue(true); + mockNgControl.statusChanges.emit(); + expect(mockInputDirective.valid).toBe(IgxInputState.INVALID); + + mockNgControl.valid = true; + mockNgControl.statusChanges.emit(); + expect(mockInputDirective.valid).toBe(IgxInputState.VALID); + timePicker.ngOnDestroy(); + }); + + it('should open/close the dropdown with open()/close() method', () => { + const mockToggleDirective = jasmine.createSpyObj('IgxToggleDirective', ['open', 'close'], { collapsed: true }); + (timePicker as any).toggleRef = mockToggleDirective; + timePicker.ngOnInit(); + + timePicker.open(); + expect(mockToggleDirective.open).toHaveBeenCalledTimes(1); + + (Object.getOwnPropertyDescriptor(mockToggleDirective, 'collapsed')?.get as jasmine.Spy<() => boolean>).and.returnValue(false); + timePicker.close(); + expect(mockToggleDirective.close).toHaveBeenCalledTimes(1); + }); + + it('should open/close the dropdown with toggle() method', () => { + (timePicker as any).dateTimeEditor = mockDateTimeEditorDirective; + const mockToggleDirective = jasmine.createSpyObj('IgxToggleDirective', ['open', 'close'], { collapsed: true }); + (timePicker as any).toggleRef = mockToggleDirective; + timePicker.ngOnInit(); + + timePicker.toggle(); + expect(mockToggleDirective.open).toHaveBeenCalledTimes(1); + + (Object.getOwnPropertyDescriptor(mockToggleDirective, 'collapsed')?.get as jasmine.Spy<() => boolean>).and.returnValue(false); + timePicker.toggle(); + expect(mockToggleDirective.close).toHaveBeenCalledTimes(1); + }); + + it('should reset value and emit valueChange with clear() method', () => { + (timePicker as any).dateTimeEditor = mockDateTimeEditorDirective; + const mockToggleDirective = jasmine.createSpyObj('IgxToggleDirective', { collapsed: true }); + (timePicker as any).toggleRef = mockToggleDirective; + timePicker.minDropdownValue = timePicker.minDateValue; + timePicker.maxDropdownValue = timePicker.maxDateValue; + + const date = new Date(2020, 12, 12, 10, 30, 30); + timePicker.value = new Date(date); + date.setHours(0, 0, 0); + spyOn(timePicker.valueChange, 'emit').and.callThrough(); + + timePicker.clear(); + expect(timePicker.value).toEqual(date); + expect(timePicker.valueChange.emit).toHaveBeenCalled(); + expect(timePicker.valueChange.emit).toHaveBeenCalledWith(date); + + const stringDate = '09:20:00'; + timePicker.value = stringDate; + + timePicker.clear(); + expect(timePicker.value).toBeNull(); + expect(timePicker.valueChange.emit).toHaveBeenCalled(); + expect(timePicker.valueChange.emit).toHaveBeenCalledWith(null); + }); + + it('should not emit valueChange when value is \'00:00:00\' and is cleared', () => { + (timePicker as any).dateTimeEditor = mockDateTimeEditorDirective; + const mockToggleDirective = jasmine.createSpyObj('IgxToggleDirective', { collapsed: true }); + (timePicker as any).toggleRef = mockToggleDirective; + + const date = new Date(2020, 12, 12, 0, 0, 0); + timePicker.value = date; + spyOn(timePicker.valueChange, 'emit').and.callThrough(); + + timePicker.ngOnInit(); + + timePicker.clear(); + expect(timePicker.valueChange.emit).not.toHaveBeenCalled(); + }); + + it('should not emit valueChange when value is null and is cleared', () => { + (timePicker as any).dateTimeEditor = mockDateTimeEditorDirective; + const mockToggleDirective = jasmine.createSpyObj('IgxToggleDirective', { collapsed: true }); + (timePicker as any).toggleRef = mockToggleDirective; + timePicker.value = null; + timePicker.ngOnInit(); + spyOn(timePicker.valueChange, 'emit').and.callThrough(); + + timePicker.clear(); + expect(timePicker.valueChange.emit).not.toHaveBeenCalled(); + }); + + it('should select time and trigger valueChange event with select() method', () => { + (timePicker as any).dateTimeEditor = mockDateTimeEditorDirective; + + const date = new Date(2020, 12, 12, 10, 30, 30); + timePicker.value = new Date(date); + timePicker.minDropdownValue = timePicker.minDateValue; + timePicker.maxDropdownValue = timePicker.maxDateValue; + + const selectedDate = new Date(2020, 12, 12, 6, 45, 0); + spyOn(timePicker.valueChange, 'emit').and.callThrough(); + + timePicker.select(selectedDate); + expect(timePicker.value).toEqual(selectedDate); + expect(timePicker.valueChange.emit).toHaveBeenCalled(); + expect(timePicker.valueChange.emit).toHaveBeenCalledWith(selectedDate); + }); + + it('should correctly implement ControlValueAccessor methods', () => { + const date = new Date(2020, 12, 12, 10, 30, 30); + const updatedDate = new Date(2020, 12, 12, 11, 30, 30); + + const mockToggleDirective = jasmine.createSpyObj('IgxToggleDirective', ['close'], { collapsed: true }); + timePicker['dateTimeEditor'] = mockDateTimeEditorDirective; + timePicker['inputDirective'] = mockInputDirective; + timePicker['toggleRef'] = mockToggleDirective; + timePicker.minDropdownValue = timePicker.minDateValue; + timePicker.maxDropdownValue = timePicker.maxDateValue; + timePicker.ngOnInit(); + spyOn(mockNgControl, 'registerOnChangeCb'); + spyOn(mockNgControl, 'registerOnTouchedCb'); + timePicker.registerOnChange(mockNgControl.registerOnChangeCb); + timePicker.registerOnTouched(mockNgControl.registerOnTouchedCb); + + expect(timePicker.value).toBeUndefined(); + expect(mockNgControl.registerOnChangeCb).not.toHaveBeenCalled(); + timePicker.writeValue(date); + expect(timePicker.value).toBe(date); + + timePicker.nextHour(100); + timePicker.okButtonClick(); + expect(mockNgControl.registerOnChangeCb).toHaveBeenCalledTimes(1); + expect(mockNgControl.registerOnChangeCb).toHaveBeenCalledWith(updatedDate); + (timePicker as any).updateValidityOnBlur(); + expect(mockNgControl.registerOnTouchedCb).toHaveBeenCalledTimes(1); + + timePicker.setDisabledState(true); + expect(timePicker.disabled).toBe(true); + timePicker.setDisabledState(false); + expect(timePicker.disabled).toBe(false); + }); + + it('should validate correctly minValue and maxValue', () => { + timePicker['dateTimeEditor'] = mockDateTimeEditorDirective; + timePicker['inputDirective'] = mockInputDirective; + timePicker.ngOnInit(); + + spyOn(mockNgControl, 'registerOnChangeCb'); + spyOn(mockNgControl, 'registerOnValidatorChangeCb'); + + timePicker.registerOnChange(mockNgControl.registerOnChangeCb); + timePicker.registerOnValidatorChange(mockNgControl.registerOnValidatorChangeCb); + + timePicker.minValue = new Date(2020, 4, 7, 6, 0, 0); + expect(mockNgControl.registerOnValidatorChangeCb).toHaveBeenCalledTimes(1); + timePicker.maxValue = new Date(2020, 4, 7, 16, 0, 0); + expect(mockNgControl.registerOnValidatorChangeCb).toHaveBeenCalledTimes(2); + + const date = new Date(2020, 4, 7, 8, 50, 0); + timePicker.writeValue(date); + const mockFormControl = new UntypedFormControl(timePicker.value); + expect(timePicker.validate(mockFormControl)).toBeNull(); + + date.setHours(3); + expect(timePicker.validate(mockFormControl)).toEqual({ minValue: true }); + + date.setHours(20); + expect(timePicker.validate(mockFormControl)).toEqual({ maxValue: true }); + }); + + it('should handle panmove event correctly', () => { + const touchManager = TestBed.inject(HammerGesturesManager); + const itemListDirective = TestBed.inject(IgxItemListDirective); + spyOn(touchManager, 'addEventListener'); + + itemListDirective.ngOnInit(); + expect(touchManager.addEventListener).toHaveBeenCalledTimes(1); + const hammerOptions: HammerOptions = { recognizers: [[HammerGesturesManager.Hammer.Pan, { direction: HammerGesturesManager.Hammer.DIRECTION_VERTICAL, threshold: 10 }]] }; + expect(touchManager.addEventListener).toHaveBeenCalledWith( + elementRef.nativeElement, + 'pan', + (itemListDirective as any).onPanMove, + hammerOptions); + + spyOn(itemListDirective, 'onPanMove').and.callThrough(); + const event = { type: 'pan' }; + (itemListDirective as any).onPanMove(event); + expect(itemListDirective['onPanMove']).toHaveBeenCalled(); + }); + }); + + describe('Interaction tests', () => { + let timePickerElement: HTMLElement; + let timePickerDebElement: DebugElement; + let inputGroup: DebugElement; + let hourColumn: DebugElement; + let minutesColumn: DebugElement; + let secondsColumn: DebugElement; + let ampmColumn: DebugElement; + let dateTimeEditor: IgxDateTimeEditorDirective; + + describe('Dropdown/dialog mode', () => { + let fixture: ComponentFixture; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + FormsModule, + NoopAnimationsModule, + IgxTimePickerTestComponent + ], + providers: [PlatformUtil] + }).compileComponents(); + })); + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(IgxTimePickerTestComponent); + fixture.detectChanges(); + timePicker = fixture.componentInstance.timePicker; + timePickerDebElement = fixture.debugElement.query(By.css(CSS_CLASS_TIMEPICKER)); + timePickerElement = fixture.debugElement.query(By.css(CSS_CLASS_TIMEPICKER)).nativeElement; + inputGroup = fixture.debugElement.query(By.css(`.${CSS_CLASS_INPUTGROUP}`)); + hourColumn = fixture.debugElement.query(By.css(`.${CSS_CLASS_HOURLIST}`)); + minutesColumn = fixture.debugElement.query(By.css(`.${CSS_CLASS_MINUTELIST}`)); + secondsColumn = fixture.debugElement.query(By.css(CSS_CLASS_SECONDSLIST)); + ampmColumn = fixture.debugElement.query(By.css(`.${CSS_CLASS_AMPMLIST}`)); + dateTimeEditor = fixture.debugElement.query(By.directive(IgxDateTimeEditorDirective)). + injector.get(IgxDateTimeEditorDirective); + })); + it('should open/close the dropdown and keep the current selection on toggle icon click', fakeAsync(() => { + const toggleIcon = fixture.debugElement.query(By.css('igx-prefix')); + toggleIcon.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + expect(timePicker.collapsed).toBeFalsy(); + + const event = new WheelEvent('wheel', { deltaX: 0, deltaY: -100 }); + hourColumn.triggerEventHandler('wheel', event); + fixture.detectChanges(); + + toggleIcon.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + tick(); + fixture.detectChanges(); + expect(timePicker.collapsed).toBeTruthy(); + const pickerValue = new Date(fixture.componentInstance.date); + pickerValue.setHours(pickerValue.getHours() - 1); + expect(timePicker.value).toEqual(pickerValue); + })); + + it('should open the dropdown with `ArrowDown` + `Alt` key press and close it on outside click', fakeAsync(() => { + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', timePickerDebElement, true); + tick(); + fixture.detectChanges(); + expect(timePicker.collapsed).toBeFalsy(); + + const event = new WheelEvent('wheel', { deltaX: 0, deltaY: -100 }); + hourColumn.triggerEventHandler('wheel', event); + fixture.detectChanges(); + + const overlay = document.getElementsByClassName(CSS_CLASS_OVERLAY_WRAPPER)[0]; + UIInteractions.simulateClickEvent(overlay); + tick(); + fixture.detectChanges(); + expect(timePicker.collapsed).toBeTruthy(); + const pickerValue = new Date(fixture.componentInstance.date); + pickerValue.setHours(pickerValue.getHours() - 1); + expect(timePicker.value).toEqual(pickerValue); + })); + + it('should close the dropdown and keep the current selection on outside click in dialog mode', fakeAsync(() => { + fixture.componentInstance.mode = PickerInteractionMode.Dialog; + fixture.detectChanges(); + + const input = fixture.debugElement.query(By.css(CSS_CLASS_INPUT)); + input.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + tick(); + fixture.detectChanges(); + expect(timePicker.collapsed).toBeFalsy(); + + const event = new WheelEvent('wheel', { deltaX: 0, deltaY: -100 }); + hourColumn.triggerEventHandler('wheel', event); + fixture.detectChanges(); + + const overlayWrapper = document.getElementsByClassName(CSS_CLASS_TIMEPICKER)[0].parentNode.parentNode; + UIInteractions.simulateClickEvent(overlayWrapper); + tick(); + fixture.detectChanges(); + expect(timePicker.collapsed).toBeTruthy(); + const pickerValue = new Date(fixture.componentInstance.date); + pickerValue.setHours(pickerValue.getHours() - 1); + expect(timePicker.value).toEqual(pickerValue); + })); + + it('should not assign value on dropdown open and outside click without interaction', fakeAsync(() => { + timePicker.value = null; + fixture.detectChanges(); + + timePicker.open(); + fixture.detectChanges(); + expect(timePicker.collapsed).toBeFalsy(); + + const overlay = document.getElementsByClassName(CSS_CLASS_OVERLAY_WRAPPER)[0]; + UIInteractions.simulateClickEvent(overlay); + tick(); + fixture.detectChanges(); + expect(timePicker.collapsed).toBeTruthy(); + expect(timePicker.value).toEqual(null); + })); + + it('should assign Date value after interaction when initial value is null', fakeAsync(() => { + timePicker.value = null; + fixture.detectChanges(); + + timePicker.open(); + fixture.detectChanges(); + expect(timePicker.collapsed).toBeFalsy(); + + const event = new WheelEvent('wheel', { deltaX: 0, deltaY: 100 }); + hourColumn.triggerEventHandler('wheel', event); + fixture.detectChanges(); + + const overlay = document.getElementsByClassName(CSS_CLASS_OVERLAY_WRAPPER)[0]; + UIInteractions.simulateClickEvent(overlay); + tick(); + fixture.detectChanges(); + const expectedDate = new Date(); + expectedDate.setHours(1, 0, 0, 0); + expect(timePicker.collapsed).toBeTruthy(); + expect((timePicker.value as Date).getTime()).toEqual(expectedDate.getTime()); + })); + + it('should open/close the dropdown and keep the current selection on Space/Enter key press', fakeAsync(() => { + UIInteractions.triggerEventHandlerKeyDown(' ', timePickerDebElement); + tick(); + fixture.detectChanges(); + expect(timePicker.collapsed).toBeFalsy(); + + const event = new WheelEvent('wheel', { deltaX: 0, deltaY: -100 }); + hourColumn.triggerEventHandler('wheel', event); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('Enter', hourColumn.nativeElement); + tick(); + fixture.detectChanges(); + expect(timePicker.collapsed).toBeTruthy(); + const pickerValue = new Date(fixture.componentInstance.date); + pickerValue.setHours(pickerValue.getHours() - 1); + expect(timePicker.value).toEqual(pickerValue); + })); + + it('should reset selection to the value when dropdown was opened on Escape key press', fakeAsync(() => { + fixture.componentInstance.minValue = new Date(2021, 24, 2, 9, 45, 0); + fixture.componentInstance.maxValue = new Date(2021, 24, 2, 13, 45, 0); + fixture.detectChanges(); + + timePicker.open(); + fixture.detectChanges(); + expect(timePicker.collapsed).toBeFalsy(); + + const event = new WheelEvent('wheel', { deltaX: 0, deltaY: -100 }); + hourColumn.triggerEventHandler('wheel', event); + fixture.detectChanges(); + hourColumn.triggerEventHandler('wheel', event); + fixture.detectChanges(); hourColumn.triggerEventHandler('wheel', event); + fixture.detectChanges(); + let selectedHour = fixture.componentInstance.date.getHours() + 2; + const selectedAmpm = selectedHour < 12 ? 'AM' : 'PM'; + selectedHour = selectedHour > 12 ? selectedHour - 12 : selectedHour; + const selectedMinutes = fixture.componentInstance.date.getMinutes(); + expect((dateTimeEditor.nativeElement.value).normalize('NFKC')).toEqual(`0${selectedHour}:${selectedMinutes} ${selectedAmpm}`); + + UIInteractions.triggerEventHandlerKeyDown('Escape', timePickerDebElement); + tick(); + fixture.detectChanges(); + expect(timePicker.collapsed).toBeTruthy(); + expect(timePicker.value).toEqual(fixture.componentInstance.date); + })); + + it('should not change the current selection and close the dropdown on OK button click', fakeAsync(() => { + timePicker.open(); + tick(); + fixture.detectChanges(); + + const event = new WheelEvent('wheel', { deltaX: 0, deltaY: -100 }); + hourColumn.triggerEventHandler('wheel', event); + fixture.detectChanges(); + + const okButton = fixture.debugElement.queryAll(By.css('button'))[1]; + okButton.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + tick(); + fixture.detectChanges(); + + expect(timePicker.collapsed).toBeTruthy(); + const pickerValue = new Date(fixture.componentInstance.date); + pickerValue.setHours(pickerValue.getHours() - 1); + expect(timePicker.value).toEqual(pickerValue); + })); + + it('should close the dropdown and discard the current selection on Cancel button click', fakeAsync(() => { + timePicker.open(); + tick(); + fixture.detectChanges(); + + const event = new WheelEvent('wheel', { deltaX: 0, deltaY: -100 }); + hourColumn.triggerEventHandler('wheel', event); + fixture.detectChanges(); + + const cancelButton = fixture.debugElement.queryAll(By.css('button'))[0]; + cancelButton.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + tick(); + fixture.detectChanges(); + + expect(timePicker.collapsed).toBeTruthy(); + expect(timePicker.value).toEqual(fixture.componentInstance.date); + })); + + it('should fire opening/closing event on open/close', fakeAsync(() => { + spyOn(timePicker.opening, 'emit').and.callThrough(); + spyOn(timePicker.opened, 'emit').and.callThrough(); + spyOn(timePicker.closing, 'emit').and.callThrough(); + spyOn(timePicker.closed, 'emit').and.callThrough(); + + timePicker.open(); + tick(); + fixture.detectChanges(); + expect(timePicker.collapsed).toBeFalsy(); + expect(timePicker.opening.emit).toHaveBeenCalled(); + expect(timePicker.opened.emit).toHaveBeenCalled(); + + timePicker.close(); + tick(); + fixture.detectChanges(); + expect(timePicker.collapsed).toBeTruthy(); + expect(timePicker.closing.emit).toHaveBeenCalled(); + expect(timePicker.closed.emit).toHaveBeenCalled(); + })); + + it('should be able to cancel opening/closing events', fakeAsync(() => { + spyOn(timePicker.opening, 'emit').and.callThrough(); + spyOn(timePicker.opened, 'emit').and.callThrough(); + spyOn(timePicker.closing, 'emit').and.callThrough(); + spyOn(timePicker.closed, 'emit').and.callThrough(); + + const openingSub = timePicker.opening.subscribe((event) => event.cancel = true); + + timePicker.open(); + tick(); + fixture.detectChanges(); + expect(timePicker.collapsed).toBeTruthy(); + expect(timePicker.opening.emit).toHaveBeenCalled(); + expect(timePicker.opened.emit).not.toHaveBeenCalled(); + + openingSub.unsubscribe(); + + const closingSub = timePicker.closing.subscribe((event) => event.cancel = true); + + timePicker.open(); + tick(); + fixture.detectChanges(); + + timePicker.close(); + tick(); + fixture.detectChanges(); + expect(timePicker.collapsed).toBeFalsy(); + expect(timePicker.closing.emit).toHaveBeenCalled(); + expect(timePicker.closed.emit).not.toHaveBeenCalled(); + + closingSub.unsubscribe(); + })); + + it('should change date parts correctly and emit valueChange with increment() and decrement() methods', () => { + const date = new Date(2020, 12, 12, 10, 30, 30, 999); + timePicker.inputFormat = 'hh:mm:ss:SS a'; + timePicker.value = new Date(date); + timePicker.minValue = new Date(2020, 12, 12, 6, 0, 0, 0); + timePicker.maxValue = new Date(2020, 12, 12, 16, 0, 0, 0); + timePicker.itemsDelta = { hours: 2, minutes: 20, seconds: 15 }; + fixture.detectChanges(); + spyOn(timePicker.valueChange, 'emit').and.callThrough(); + + timePicker.increment(DatePart.Hours); + date.setHours(date.getHours() + timePicker.itemsDelta.hours); + expect(timePicker.value).toEqual(date); + expect(timePicker.valueChange.emit).toHaveBeenCalledTimes(1); + expect(timePicker.valueChange.emit).toHaveBeenCalledWith(date); + + timePicker.increment(DatePart.Minutes); + date.setMinutes(date.getMinutes() + timePicker.itemsDelta.minutes); + expect(timePicker.value).toEqual(date); + expect(timePicker.valueChange.emit).toHaveBeenCalledTimes(2); + expect(timePicker.valueChange.emit).toHaveBeenCalledWith(date); + + timePicker.decrement(DatePart.Seconds); + date.setSeconds(date.getSeconds() - timePicker.itemsDelta.seconds); + expect(timePicker.value).toEqual(date); + expect(timePicker.valueChange.emit).toHaveBeenCalledTimes(3); + expect(timePicker.valueChange.emit).toHaveBeenCalledWith(date); + + timePicker.decrement(DatePart.FractionalSeconds); + date.setMilliseconds(date.getMilliseconds() - timePicker.itemsDelta.fractionalSeconds); + expect(timePicker.value).toEqual(date); + expect(timePicker.valueChange.emit).toHaveBeenCalledTimes(4); + expect(timePicker.valueChange.emit).toHaveBeenCalledWith(date); + }); + + it('should fire vallidationFailed on incrementing time outside min/max range', () => { + const date = new Date(2020, 12, 12, 15, 30, 30); + const selectedDate = new Date(date); + selectedDate.setHours(date.getHours() + 2); + timePicker.value = new Date(date); + timePicker.minValue = new Date(2020, 12, 12, 6, 0, 0); + timePicker.maxValue = new Date(2020, 12, 12, 16, 0, 0); + timePicker.itemsDelta = { hours: 2, minutes: 20, seconds: 15 }; + fixture.detectChanges(); + spyOn(timePicker.validationFailed, 'emit').and.callThrough(); + + timePicker.increment(DatePart.Hours); + fixture.detectChanges(); + + const args: IgxTimePickerValidationFailedEventArgs = { + owner: timePicker, + previousValue: date, + currentValue: selectedDate + }; + expect(timePicker.value).toEqual(selectedDate); + expect(timePicker.validationFailed.emit).toHaveBeenCalled(); + expect(timePicker.validationFailed.emit).toHaveBeenCalledWith(args); + }); + + it('should scroll trough hours/minutes/seconds/AM PM based on default or set itemsDelta', fakeAsync(() => { + timePicker.inputFormat = 'hh:mm:ss a'; + fixture.detectChanges(); + + secondsColumn = fixture.debugElement.query(By.css(CSS_CLASS_SECONDSLIST)); + timePicker.open(); + fixture.detectChanges(); + expect(timePicker.collapsed).toBeFalsy(); + + // spin all columns with default delta + const eventScrollDown = new WheelEvent('wheel', { deltaX: 0, deltaY: 100 }); + const eventScrollUp = new WheelEvent('wheel', { deltaX: 0, deltaY: -100 }); + hourColumn.triggerEventHandler('wheel', eventScrollDown); + minutesColumn.triggerEventHandler('wheel', eventScrollDown); + secondsColumn.triggerEventHandler('wheel', eventScrollDown); + ampmColumn.triggerEventHandler('wheel', eventScrollUp); + fixture.detectChanges(); + + const expectedValuedHour = 0; + const expectedDisplayHour = 12; + const expectedMinute = 46; + const expectedSecond = 1; + const expectedAmPm = 'AM'; + const expectedPrependZero = '0'; + + // test rendered display value + const selectedItems = fixture.debugElement.queryAll(By.css(CSS_CLASS_SELECTED_ITEM)); + const selectedHour = selectedItems[0].nativeElement.innerText; + const selectedMinute = selectedItems[1].nativeElement.innerText; + const selectedSecond = selectedItems[2].nativeElement.innerText; + const selectedAMPM = selectedItems[3].nativeElement.innerText; + + expect(selectedHour).toEqual(expectedDisplayHour.toString()); + expect(selectedMinute).toEqual(expectedMinute.toString()); + expect(selectedSecond).toEqual(expectedPrependZero + expectedSecond.toString()); + expect(selectedAMPM).toEqual(expectedAmPm); + + // apply selected value on toggle btn click + const toggleIcon = fixture.debugElement.query(By.css('igx-prefix')); + toggleIcon.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + tick(); + fixture.detectChanges(); + expect(timePicker.collapsed).toBeTrue(); + + expect((timePicker.value as Date).getHours()).toEqual(expectedValuedHour); + expect((timePicker.value as Date).getMinutes()).toEqual(expectedMinute); + expect((timePicker.value as Date).getSeconds()).toEqual(expectedSecond); + })); + + it('should scroll trough hours/minutes/seconds/AM PM based on custom itemsDelta', fakeAsync(() => { + const newDate = new Date(2021, 24, 2, 10, 20, 0); + fixture.componentInstance.date = newDate; + timePicker.inputFormat = 'hh:mm:ss tt'; + timePicker.itemsDelta = { hours: 2, minutes: 20, seconds: 20 }; + fixture.detectChanges(); + + timePicker.open(); + fixture.detectChanges(); + secondsColumn = fixture.debugElement.query(By.css(CSS_CLASS_SECONDSLIST)); + expect(timePicker.collapsed).toBeFalsy(); + + // spin all columns with the custom itemsDelta + const eventScrollDown = new WheelEvent('wheel', { deltaX: 0, deltaY: 100 }); + const eventScrollUp = new WheelEvent('wheel', { deltaX: 0, deltaY: -100 }); + hourColumn.triggerEventHandler('wheel', eventScrollDown); + minutesColumn.triggerEventHandler('wheel', eventScrollDown); + secondsColumn.triggerEventHandler('wheel', eventScrollDown); + ampmColumn.triggerEventHandler('wheel', eventScrollUp); + fixture.detectChanges(); + + const expectedValuedHour = 0; + const expectedDisplayHour = 12; + const expectedMinute = 40; + const expectedSecond = 20; + const expectedAmPm = 'AM'; + + // test rendered display value + const selectedItems = fixture.debugElement.queryAll(By.css(CSS_CLASS_SELECTED_ITEM)); + const selectedHour = selectedItems[0].nativeElement.innerText; + const selectedMinute = selectedItems[1].nativeElement.innerText; + const selectedSecond = selectedItems[2].nativeElement.innerText; + const selectedAMPM = selectedItems[3].nativeElement.innerText; + + expect(selectedHour).toEqual(expectedDisplayHour.toString()); + expect(selectedMinute).toEqual(expectedMinute.toString()); + expect(selectedSecond).toEqual(expectedSecond.toString()); + expect(selectedAMPM).toEqual(expectedAmPm); + + // apply selected value on toggle btn click + const toggleIcon = fixture.debugElement.query(By.css('igx-prefix')); + toggleIcon.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + tick(); + fixture.detectChanges(); + expect(timePicker.collapsed).toBeTrue(); + + expect((timePicker.value as Date).getHours()).toEqual(expectedValuedHour); + expect((timePicker.value as Date).getMinutes()).toEqual(expectedMinute); + expect((timePicker.value as Date).getSeconds()).toEqual(expectedSecond); + })); + + it('should navigate through columns with arrow keys', () => { + timePicker.open(); + fixture.detectChanges(); + expect(timePicker.collapsed).toBeFalsy(); + + hourColumn.nativeElement.focus(); + fixture.detectChanges(); + expect(document.activeElement.classList).toContain(CSS_CLASS_HOURLIST); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', hourColumn.nativeElement, true); + fixture.detectChanges(); + expect(document.activeElement.classList).toContain(CSS_CLASS_MINUTELIST); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', minutesColumn.nativeElement, true); + fixture.detectChanges(); + expect(document.activeElement.classList).toContain(CSS_CLASS_AMPMLIST); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', ampmColumn.nativeElement, true); + fixture.detectChanges(); + expect(document.activeElement.classList).toContain(CSS_CLASS_MINUTELIST); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', minutesColumn.nativeElement, true); + fixture.detectChanges(); + expect(document.activeElement.children[3].innerHTML.trim()).toBe('46'); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', minutesColumn.nativeElement, true); + fixture.detectChanges(); + expect(document.activeElement.classList).toContain(CSS_CLASS_HOURLIST); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', hourColumn.nativeElement, true); + fixture.detectChanges(); + expect(document.activeElement.children[3].innerHTML.trim()).toBe('10'); + }); + + it('should navigate through items with arrow keys', () => { + timePicker.itemsDelta = { hours: 4, minutes: 7, seconds: 1 }; + fixture.detectChanges(); + + timePicker.open(); + fixture.detectChanges(); + + let selectedItems = fixture.debugElement.queryAll(By.css(CSS_CLASS_SELECTED_ITEM)); + let selectedHour = selectedItems[0].nativeElement.innerText; + let selectedMinutes = selectedItems[1].nativeElement.innerText; + let selectedAMPM = selectedItems[2].nativeElement.innerText; + expect(selectedHour).toEqual('12'); + expect(selectedMinutes).toEqual('00'); + expect(selectedAMPM).toEqual('PM'); + + hourColumn.nativeElement.focus(); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', hourColumn.nativeElement, true); + fixture.detectChanges(); + + selectedItems = fixture.debugElement.queryAll(By.css(CSS_CLASS_SELECTED_ITEM)); + selectedHour = selectedItems[0].nativeElement.innerText; + selectedMinutes = selectedItems[1].nativeElement.innerText; + selectedAMPM = selectedItems[2].nativeElement.innerText; + expect(selectedHour).toEqual('08'); + expect(selectedMinutes).toEqual('00'); + expect(selectedAMPM).toEqual('AM'); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', hourColumn.nativeElement, true); + fixture.detectChanges(); + + selectedItems = fixture.debugElement.queryAll(By.css(CSS_CLASS_SELECTED_ITEM)); + selectedHour = selectedItems[0].nativeElement.innerText; + selectedMinutes = selectedItems[1].nativeElement.innerText; + selectedAMPM = selectedItems[2].nativeElement.innerText; + expect(selectedHour).toEqual('12'); + expect(selectedMinutes).toEqual('00'); + expect(selectedAMPM).toEqual('PM'); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', hourColumn.nativeElement, true); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', minutesColumn.nativeElement, true); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', ampmColumn.nativeElement, true); + fixture.detectChanges(); + + selectedItems = fixture.debugElement.queryAll(By.css(CSS_CLASS_SELECTED_ITEM)); + selectedHour = selectedItems[0].nativeElement.innerText; + selectedMinutes = selectedItems[1].nativeElement.innerText; + selectedAMPM = selectedItems[2].nativeElement.innerText; + expect(selectedHour).toEqual('12'); + expect(selectedMinutes).toEqual('00'); + expect(selectedAMPM).toEqual('AM'); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', ampmColumn.nativeElement, true); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', minutesColumn.nativeElement, true); + fixture.detectChanges(); + + selectedItems = fixture.debugElement.queryAll(By.css(CSS_CLASS_SELECTED_ITEM)); + selectedHour = selectedItems[0].nativeElement.innerText; + selectedMinutes = selectedItems[1].nativeElement.innerText; + selectedAMPM = selectedItems[2].nativeElement.innerText; + expect(selectedHour).toEqual('12'); + expect(selectedMinutes).toEqual('56'); + expect(selectedAMPM).toEqual('AM'); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', minutesColumn.nativeElement, true); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', minutesColumn.nativeElement, true); + fixture.detectChanges(); + + selectedItems = fixture.debugElement.queryAll(By.css(CSS_CLASS_SELECTED_ITEM)); + selectedHour = selectedItems[0].nativeElement.innerText; + selectedMinutes = selectedItems[1].nativeElement.innerText; + selectedAMPM = selectedItems[2].nativeElement.innerText; + expect(selectedHour).toEqual('12'); + expect(selectedMinutes).toEqual('07'); + expect(selectedAMPM).toEqual('AM'); + }); + + it('should navigate to min/max items with arrow keys when selected value is outside the min/max range', () => { + timePicker.itemsDelta = { hours: 2, minutes: 20, seconds: 15 }; + timePicker.minValue = new Date(2020, 12, 12, 9, 30, 0); + timePicker.maxValue = new Date(2020, 12, 12, 14, 35, 0); + fixture.detectChanges(); + + timePicker.open(); + fixture.detectChanges(); + + let selectedItems = fixture.debugElement.queryAll(By.css(CSS_CLASS_SELECTED_ITEM)); + let selectedHour = selectedItems[0].nativeElement.innerText; + let selectedMinutes = selectedItems[1].nativeElement.innerText; + let selectedAMPM = selectedItems[2].nativeElement.innerText; + expect(selectedHour).toEqual('12'); + expect(selectedMinutes).toEqual('00'); + expect(selectedAMPM).toEqual('PM'); + + hourColumn.nativeElement.focus(); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', hourColumn.nativeElement, true); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', minutesColumn.nativeElement, true); + fixture.detectChanges(); + + selectedItems = fixture.debugElement.queryAll(By.css(CSS_CLASS_SELECTED_ITEM)); + selectedHour = selectedItems[0].nativeElement.innerText; + selectedMinutes = selectedItems[1].nativeElement.innerText; + selectedAMPM = selectedItems[2].nativeElement.innerText; + expect(selectedHour).toEqual('12'); + expect(selectedMinutes).toEqual('40'); + expect(selectedAMPM).toEqual('PM'); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowLeft', minutesColumn.nativeElement, true); + fixture.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowDown', hourColumn.nativeElement, true); + fixture.detectChanges(); + + selectedItems = fixture.debugElement.queryAll(By.css(CSS_CLASS_SELECTED_ITEM)); + selectedHour = selectedItems[0].nativeElement.innerText; + selectedMinutes = selectedItems[1].nativeElement.innerText; + selectedAMPM = selectedItems[2].nativeElement.innerText; + expect(selectedHour).toEqual('02'); + expect(selectedMinutes).toEqual('20'); + expect(selectedAMPM).toEqual('PM'); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', hourColumn.nativeElement, true); + fixture.detectChanges(); + UIInteractions.triggerKeyDownEvtUponElem('ArrowRight', minutesColumn.nativeElement, true); + fixture.detectChanges(); + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', ampmColumn.nativeElement, true); + fixture.detectChanges(); + + selectedItems = fixture.debugElement.queryAll(By.css(CSS_CLASS_SELECTED_ITEM)); + selectedHour = selectedItems[0].nativeElement.innerText; + selectedMinutes = selectedItems[1].nativeElement.innerText; + selectedAMPM = selectedItems[2].nativeElement.innerText; + expect(selectedHour).toEqual('10'); + expect(selectedMinutes).toEqual('00'); + expect(selectedAMPM).toEqual('AM'); + }); + + it('should not reset the time when clicking on AM/PM - GH#15158', fakeAsync(() => { + timePicker.itemsDelta = { hours: 1, minutes: 15, seconds: 1 }; + timePicker.mode = "dialog"; + + timePicker.open(); + fixture.detectChanges(); + + const amElement = ampmColumn.query(e => e.nativeElement.textContent === 'AM'); + const pmElement = ampmColumn.query(e => e.nativeElement.textContent === 'PM'); + + UIInteractions.simulateClickEvent(amElement.nativeElement); + fixture.detectChanges(); + + let selectedItems = fixture.debugElement.queryAll(By.css(CSS_CLASS_SELECTED_ITEM)); + let selectedHour = selectedItems[0].nativeElement.innerText; + let selectedMinutes = selectedItems[1].nativeElement.innerText; + let selectedAMPM = selectedItems[2].nativeElement.innerText; + + expect(selectedHour).toBe('11'); + expect(selectedMinutes).toEqual('45'); + expect(selectedAMPM).toEqual('AM'); + + UIInteractions.simulateClickEvent(pmElement.nativeElement); // move to the PM element + fixture.detectChanges(); + UIInteractions.simulateClickEvent(pmElement.nativeElement); // click again to reproduce the issue + fixture.detectChanges(); + + selectedItems = fixture.debugElement.queryAll(By.css(CSS_CLASS_SELECTED_ITEM)); + selectedHour = selectedItems[0].nativeElement.innerText; + selectedMinutes = selectedItems[1].nativeElement.innerText; + + expect(selectedItems[2]).toBeDefined(); // if the minutes column has no elements, this will be undefined + selectedAMPM = selectedItems[2].nativeElement.innerText; + expect(selectedHour).toBe('11'); + expect(selectedMinutes).toEqual('45'); + expect(selectedAMPM).toEqual('PM'); + + // ensure there is content in each element of the spinners + // '08', '09', '10', '11', '12', '01', '02' + expect(hourColumn.queryAll(By.css('span')).every(e => !!e.nativeElement.innerText)).toBeTrue(); + + // '00', '15', '30', '45', '', '', '' - three empty elements to align the minutes spinner length with the hours spinner length + expect(minutesColumn.queryAll(By.css('span')).filter(e => !!e.nativeElement.innerText).length).toEqual(4); + })); + }); + + describe('Rendering tests', () => { + let fixture: ComponentFixture; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, IgxTimePickerTestComponent] + }).compileComponents(); + })); + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(IgxTimePickerTestComponent); + fixture.detectChanges(); + timePicker = fixture.componentInstance.timePicker; + timePickerDebElement = fixture.debugElement.query(By.css(CSS_CLASS_TIMEPICKER)); + timePickerElement = fixture.debugElement.query(By.css(CSS_CLASS_TIMEPICKER)).nativeElement; + inputGroup = fixture.debugElement.query(By.css(`.${CSS_CLASS_INPUTGROUP}`)); + hourColumn = fixture.debugElement.query(By.css(`.${CSS_CLASS_HOURLIST}`)); + minutesColumn = fixture.debugElement.query(By.css(`.${CSS_CLASS_MINUTELIST}`)); + ampmColumn = fixture.debugElement.query(By.css(`.${CSS_CLASS_AMPMLIST}`)); + dateTimeEditor = fixture.debugElement.query(By.directive(IgxDateTimeEditorDirective)). + injector.get(IgxDateTimeEditorDirective); + })); + + it('Should render default toggle and clear icons', () => { + fixture = TestBed.createComponent(IgxTimePickerTestComponent); + fixture.detectChanges(); + inputGroup = fixture.debugElement.query(By.directive(IgxInputGroupComponent)); + const prefix = inputGroup.queryAll(By.directive(IgxPrefixDirective)); + expect(prefix).toHaveSize(1); + expect(prefix[0].nativeElement.innerText).toEqual(TIME_PICKER_TOGGLE_ICON); + const suffix = inputGroup.queryAll(By.directive(IgxSuffixDirective)); + expect(suffix).toHaveSize(1); + expect(suffix[0].nativeElement.innerText).toEqual(TIME_PICKER_CLEAR_ICON); + }); + + it('should initialize all input properties with their default values', () => { + expect(timePicker.mode).toEqual(PickerInteractionMode.DropDown); + expect(timePicker.inputFormat).toEqual(undefined); + expect(timePicker.itemsDelta.hours).toEqual(1); + expect(timePicker.itemsDelta.minutes).toEqual(1); + expect(timePicker.itemsDelta.seconds).toEqual(1); + expect(timePicker.itemsDelta.fractionalSeconds).toEqual(1); + expect(timePicker.disabled).toEqual(false); + }); + + it('should initialize all IgxDateTimeEditorDirective input properties correctly', () => { + timePicker.itemsDelta = { hours: 2, minutes: 20, seconds: 15 }; + timePicker.displayFormat = 'hh:mm'; + fixture.componentInstance.minValue = new Date(2020, 12, 12, 9, 30, 0); + fixture.componentInstance.maxValue = new Date(2020, 12, 12, 14, 35, 0); + fixture.detectChanges(); + + expect(dateTimeEditor.value).toEqual(fixture.componentInstance.date); + expect(dateTimeEditor.minValue).toEqual(fixture.componentInstance.minValue); + expect(dateTimeEditor.maxValue).toEqual(fixture.componentInstance.maxValue); + expect(dateTimeEditor.spinDelta).toEqual(timePicker.itemsDelta); + expect(dateTimeEditor.spinLoop).toEqual(true); + + expect(dateTimeEditor.inputFormat.normalize('NFKC')).toEqual('hh:mm'); + expect(dateTimeEditor.displayFormat).toEqual('hh:mm'); + expect(dateTimeEditor.mask.normalize('NFKC')).toEqual('00:00'); + }); + + it('should be able to change the mode at runtime', fakeAsync(() => { + fixture.componentInstance.timePicker.mode = PickerInteractionMode.DropDown; + fixture.detectChanges(); + + timePicker.open(); + tick(); + fixture.detectChanges(); + + let dropdown = fixture.debugElement.query(By.css(CSS_CLASS_DROPDOWN)); + expect(dropdown).toBeDefined(); + + timePicker.close(); + tick(); + fixture.detectChanges(); + + fixture.componentInstance.timePicker.mode = PickerInteractionMode.Dialog; + fixture.detectChanges(); + + timePicker.open(); + tick(); + fixture.detectChanges(); + + dropdown = fixture.debugElement.query(By.css(CSS_CLASS_DROPDOWN)); + expect(dropdown).toBeNull(); + })); + + it('should apply disabled class when component is disabled', () => { + fixture.componentInstance.timePicker.disabled = true; + fixture.detectChanges(); + + expect(inputGroup.classes[CSS_CLASS_INPUTGROUP_DISABLED]).toEqual(true); + + fixture.componentInstance.timePicker.disabled = false; + fixture.detectChanges(); + + expect(inputGroup.classes[CSS_CLASS_INPUTGROUP_DISABLED]).toBeFalsy(); + }); + + it('should highlight selected time', fakeAsync(() => { + fixture.componentInstance.timePicker.mode = PickerInteractionMode.DropDown; + fixture.detectChanges(); + + timePicker.open(); + tick(); + fixture.detectChanges(); + + const selectedItems = fixture.debugElement.queryAll(By.css(CSS_CLASS_SELECTED_ITEM)); + const selectedHour = selectedItems[0].nativeElement.innerText; + const selectedMinutes = selectedItems[1].nativeElement.innerText; + const selectedAMPM = selectedItems[2].nativeElement.innerText; + + const hours = fixture.componentInstance.date.getHours(); + const minutes = fixture.componentInstance.date.getMinutes(); + const ampm = hours >= 12 ? 'PM' : 'AM'; + + expect(selectedHour).toEqual(hours.toString()); + expect(selectedMinutes).toEqual(minutes.toString()); + expect(selectedAMPM).toEqual(ampm); + })); + + it('should display correctly non-zero padded time format', fakeAsync(() => { + fixture.componentInstance.date = new Date(2021, 24, 2, 8, 5, 5); + fixture.componentInstance.timePicker.mode = PickerInteractionMode.DropDown; + timePicker.inputFormat = 'h:m:s'; + fixture.detectChanges(); + + timePicker.open(); + tick(); + fixture.detectChanges(); + + const selectedItems = fixture.debugElement.queryAll(By.css(CSS_CLASS_SELECTED_ITEM)); + const selectedHour = selectedItems[0].nativeElement.innerText; + const selectedMinutes = selectedItems[1].nativeElement.innerText; + const selectedAMPM = selectedItems[2].nativeElement.innerText; + + const hours = fixture.componentInstance.date.getHours(); + const minutes = fixture.componentInstance.date.getMinutes(); + const seconds = fixture.componentInstance.date.getSeconds(); + + expect(selectedHour).toEqual(hours.toString()); + expect(selectedMinutes).toEqual(minutes.toString()); + expect(selectedAMPM).toEqual(seconds.toString()); + })); + + it('should set default inputFormat, if none, for the editor with parts for hour, minutes and day period based on locale', fakeAsync(() => { + registerLocaleData(localeBg); + registerLocaleData(localeJa); + timePicker.locale = 'en-US'; + fixture.detectChanges(); + + expect(dateTimeEditor.inputFormat.normalize('NFKC')).toEqual('hh:mm tt'); + + timePicker.locale = 'bg-BG'; + fixture.detectChanges(); + tick(); + + expect(dateTimeEditor.inputFormat).toEqual('HH:mm'); + + timePicker.locale = 'ja-JP'; + fixture.detectChanges(); + tick(); + + expect(dateTimeEditor.inputFormat).toEqual('HH:mm'); + })); + + it('should resolve inputFormat, if not set, for the editor to the value of displayFormat if it contains only numeric date/time parts', fakeAsync(() => { + timePicker.displayFormat = 'h:mm:ss aa'; + fixture.detectChanges(); + + expect(dateTimeEditor.displayFormat.normalize('NFKC')).toEqual('h:mm:ss aa'); + expect(dateTimeEditor.inputFormat.normalize('NFKC')).toEqual('h:mm:ss aa'); + + timePicker.displayFormat = 'shortTime'; + fixture.detectChanges(); + + expect(dateTimeEditor.displayFormat.normalize('NFKC')).toEqual('shortTime'); + expect(dateTimeEditor.inputFormat.normalize('NFKC')).toEqual('hh:mm tt'); + })); + + it('should resolve to the default locale-based input format for the editor in case inputFormat is not set and displayFormat contains non-numeric date/time parts', fakeAsync(() => { + registerLocaleData(localeBg); + timePicker.locale = 'en-US'; + timePicker.displayFormat = 'longTime'; + fixture.detectChanges(); + + expect(dateTimeEditor.inputFormat.normalize('NFKC')).toEqual('hh:mm tt'); + + timePicker.locale = 'bg-BG'; + timePicker.displayFormat = 'fullTime'; + fixture.detectChanges(); + + expect(dateTimeEditor.inputFormat).toEqual('HH:mm'); + })); + + it('should display selected time in dialog header', fakeAsync(() => { + fixture.componentInstance.timePicker.mode = PickerInteractionMode.Dialog; + fixture.detectChanges(); + + timePicker.open(); + tick(); + fixture.detectChanges(); + + const hourHeader = fixture.debugElement.query(By.css(CSS_CLASS_HEADER_HOUR)); + const selectedTime = hourHeader.children[0].nativeElement.innerText; + + + const hours = fixture.componentInstance.date.getHours(); + const minutes = fixture.componentInstance.date.getMinutes().toString(); + const ampm = hours >= 12 ? 'PM' : 'AM'; + + expect(selectedTime.normalize('NFKC')).toEqual(`${hours}:${minutes} ${ampm}`); + })); + + it('should apply all aria attributes correctly', fakeAsync(() => { + const inputEl = fixture.nativeElement.querySelector(CSS_CLASS_INPUT); + expect(inputEl.getAttribute('role')).toEqual('combobox'); + expect(inputEl.getAttribute('aria-haspopup')).toEqual('dialog'); + expect(inputEl.getAttribute('aria-labelledby')).toEqual(timePicker.label.id); + expect(inputEl.getAttribute('aria-expanded')).toEqual('false'); + + timePicker.open(); + tick(); + fixture.detectChanges(); + expect(inputEl.getAttribute('aria-expanded')).toEqual('true'); + + let selectedItems = fixture.debugElement.queryAll(By.css(CSS_CLASS_SELECTED_ITEM)); + let selectedHour = selectedItems[0]; + let selectedMinute = selectedItems[1]; + let selectedAMPM = selectedItems[2]; + + expect(selectedHour.attributes['role']).toEqual('spinbutton'); + expect(selectedHour.attributes['aria-label']).toEqual('hour'); + expect(selectedHour.attributes['aria-valuenow']).toEqual('11 AM'); + expect(selectedHour.attributes['aria-valuemin']).toEqual('12 AM'); + expect(selectedHour.attributes['aria-valuemax']).toEqual('11 PM'); + + expect(selectedMinute.attributes['role']).toEqual('spinbutton'); + expect(selectedMinute.attributes['aria-label']).toEqual('minutes'); + expect(selectedMinute.attributes['aria-valuenow']).toEqual('45'); + expect(selectedMinute.attributes['aria-valuemin']).toEqual('00'); + expect(selectedMinute.attributes['aria-valuemax']).toEqual('59'); + + expect(selectedAMPM.attributes['role']).toEqual('spinbutton'); + expect(selectedAMPM.attributes['aria-label']).toEqual('ampm'); + expect(selectedAMPM.attributes['aria-valuenow']).toEqual('AM'); + expect(selectedAMPM.attributes['aria-valuemin']).toEqual('AM'); + expect(selectedAMPM.attributes['aria-valuemax']).toEqual('PM'); + + timePicker.close(); + tick(); + fixture.detectChanges(); + expect(inputEl.getAttribute('aria-expanded')).toEqual('false'); + + timePicker.value = new Date(2021, 24, 2, 6, 42, 0); + fixture.componentInstance.minValue = '06:30:00'; + fixture.componentInstance.maxValue = '18:30:00'; + timePicker.itemsDelta = { hours: 3, minutes: 7, seconds: 1 }; + fixture.detectChanges(); + + timePicker.open(); + tick(); + fixture.detectChanges(); + + selectedItems = fixture.debugElement.queryAll(By.css(CSS_CLASS_SELECTED_ITEM)); + selectedHour = selectedItems[0]; + selectedMinute = selectedItems[1]; + selectedAMPM = selectedItems[2]; + + expect(selectedHour.attributes['aria-valuenow']).toEqual('06 AM'); + expect(selectedHour.attributes['aria-valuemin']).toEqual('06 AM'); + expect(selectedHour.attributes['aria-valuemax']).toEqual('06 PM'); + + expect(selectedMinute.attributes['aria-valuenow']).toEqual('42'); + expect(selectedMinute.attributes['aria-valuemin']).toEqual('35'); + expect(selectedMinute.attributes['aria-valuemax']).toEqual('56'); + + const item = ampmColumn.queryAll(By.directive(IgxTimeItemDirective))[4]; + item.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + tick(); + fixture.detectChanges(); + + selectedItems = fixture.debugElement.queryAll(By.css(CSS_CLASS_SELECTED_ITEM)); + selectedHour = selectedItems[0]; + selectedMinute = selectedItems[1]; + selectedAMPM = selectedItems[2]; + + expect(selectedHour.attributes['aria-valuenow']).toEqual('06 PM'); + expect(selectedHour.attributes['aria-valuemin']).toEqual('06 AM'); + expect(selectedHour.attributes['aria-valuemax']).toEqual('06 PM'); + + expect(selectedMinute.attributes['aria-valuenow']).toEqual('28'); + expect(selectedMinute.attributes['aria-valuemin']).toEqual('00'); + expect(selectedMinute.attributes['aria-valuemax']).toEqual('28'); + + timePicker.close(); + tick(); + fixture.detectChanges(); + })); + + it('should select closest value when value does not match dropdown values', fakeAsync(() => { + fixture.componentInstance.minValue = new Date(2021, 24, 2, 9, 0, 0); + fixture.componentInstance.maxValue = new Date(2021, 24, 2, 16, 0, 0); + timePicker.itemsDelta = { hours: 2, minutes: 15, seconds: 30 }; + fixture.detectChanges(); + + timePicker.open(); + tick(); + fixture.detectChanges(); + + const selectedItems = fixture.debugElement.queryAll(By.css(CSS_CLASS_SELECTED_ITEM)); + const selectedHour = selectedItems[0].nativeElement.innerText; + const selectedMinutes = selectedItems[1].nativeElement.innerText; + const selectedAMPM = selectedItems[2].nativeElement.innerText; + + expect(selectedHour).toEqual('12'); + expect(selectedMinutes).toEqual('00'); + expect(selectedAMPM).toEqual('PM'); + })); + + it('should select minValue when value is outside the min/max range', fakeAsync(() => { + fixture.componentInstance.minValue = new Date(2021, 24, 2, 13, 0, 0); + fixture.componentInstance.maxValue = new Date(2021, 24, 2, 19, 0, 0); + timePicker.itemsDelta = { hours: 2, minutes: 15, seconds: 30 }; + fixture.detectChanges(); + + timePicker.open(); + tick(); + fixture.detectChanges(); + + const selectedItems = fixture.debugElement.queryAll(By.css(CSS_CLASS_SELECTED_ITEM)); + const selectedHour = selectedItems[0].nativeElement.innerText; + const selectedMinutes = selectedItems[1].nativeElement.innerText; + const selectedAMPM = selectedItems[2].nativeElement.innerText; + + expect(selectedHour).toEqual('02'); + expect(selectedMinutes).toEqual('00'); + expect(selectedAMPM).toEqual('PM'); + })); + + it('should select minValue when value is null', fakeAsync(() => { + fixture.componentInstance.date = null; + fixture.componentInstance.minValue = new Date(2021, 24, 2, 13, 0, 0); + fixture.componentInstance.maxValue = new Date(2021, 24, 2, 19, 0, 0); + timePicker.itemsDelta = { hours: 2, minutes: 15, seconds: 30 }; + fixture.detectChanges(); + + timePicker.open(); + tick(); + fixture.detectChanges(); + + const selectedItems = fixture.debugElement.queryAll(By.css(CSS_CLASS_SELECTED_ITEM)); + const selectedHour = selectedItems[0].nativeElement.innerText; + const selectedMinutes = selectedItems[1].nativeElement.innerText; + const selectedAMPM = selectedItems[2].nativeElement.innerText; + + expect(selectedHour).toEqual('02'); + expect(selectedMinutes).toEqual('00'); + expect(selectedAMPM).toEqual('PM'); + })); + it('should select hour/minute/second/AMPM via the drop down list (throw onItemClick event)', fakeAsync(() => { + timePicker.inputFormat = 'hh:mm:ss tt'; + fixture.detectChanges(); + + secondsColumn = fixture.debugElement.query(By.css(CSS_CLASS_SECONDSLIST)); + timePicker.open(); + tick(); + fixture.detectChanges(); + expect(timePicker.collapsed).toBeFalsy(); + + const expectedHour = '12'; + const expectedMinute = '46'; + const expectedSecond = '01'; + const expectedAmPm = 'PM'; + + let item; let selectedItems; + item = ampmColumn.queryAll(By.directive(IgxTimeItemDirective))[4]; + item.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + tick(); + fixture.detectChanges(); + selectedItems = fixture.debugElement.queryAll(By.css(CSS_CLASS_SELECTED_ITEM)); + let selectedAMPM = selectedItems[3].nativeElement.innerText; + expect(selectedAMPM).toEqual(expectedAmPm); + + item = hourColumn.queryAll(By.directive(IgxTimeItemDirective))[4]; + item.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + tick(); + fixture.detectChanges(); + selectedItems = fixture.debugElement.queryAll(By.css(CSS_CLASS_SELECTED_ITEM)); + const selectedHour = selectedItems[0].nativeElement.innerText; + expect(selectedHour).toEqual(expectedHour); + + item = minutesColumn.queryAll(By.directive(IgxTimeItemDirective))[4]; + item.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + tick(); + fixture.detectChanges(); + selectedItems = fixture.debugElement.queryAll(By.css(CSS_CLASS_SELECTED_ITEM)); + const selectedMinute = selectedItems[1].nativeElement.innerText; + expect(selectedMinute).toEqual(expectedMinute); + + item = secondsColumn.queryAll(By.directive(IgxTimeItemDirective))[4]; + item.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + tick(); + fixture.detectChanges(); + selectedItems = fixture.debugElement.queryAll(By.css(CSS_CLASS_SELECTED_ITEM)); + const selectedSecond = selectedItems[2].nativeElement.innerText; + expect(selectedSecond).toEqual(expectedSecond); + + timePicker.inputFormat = 'hh:mm:ss a'; + fixture.detectChanges(); + + item = ampmColumn.queryAll(By.directive(IgxTimeItemDirective))[4]; + item.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); + tick(); + fixture.detectChanges(); + selectedItems = fixture.debugElement.queryAll(By.css(CSS_CLASS_SELECTED_ITEM)); + selectedAMPM = selectedItems[3].nativeElement.innerText; + expect(selectedAMPM).toEqual(expectedAmPm); + })); + + it('should set placeholder correctly', fakeAsync(() => { + // no inputFormat set - placeholder equals the default date time input format + let inputEl = fixture.nativeElement.querySelector(CSS_CLASS_INPUT); + expect(inputEl.placeholder.normalize('NFKC')).toEqual('hh:mm tt'); + + // no placeholder - set to inputFormat, if it is set + // test with the different a,aa,.. ampm formats + for(let i = 1; i <= 5; i++) { + const format = `hh:mm ${'a'.repeat(i)}`; + timePicker.inputFormat = format; + fixture.detectChanges(); + + inputEl = fixture.nativeElement.querySelector(CSS_CLASS_INPUT); + expect(inputEl.placeholder).toEqual(i === 5 ? 'hh:mm a' : 'hh:mm aa'); + } + + timePicker.placeholder = 'sample placeholder'; + fixture.detectChanges(); + + inputEl = fixture.nativeElement.querySelector(CSS_CLASS_INPUT); + expect(inputEl.placeholder).toEqual('sample placeholder'); + })); + + it('should set headerOrientation prop in dialog mode', fakeAsync(() => { + timePicker.mode = PickerInteractionMode.Dialog; + timePicker.open(); + tick(); + fixture.detectChanges(); + expect(timePicker.headerOrientation).toEqual('horizontal'); + let dialogDivVertical = timePickerDebElement.query(By.css(CSS_CLASS_TIME_PICKER_VERTICAL)); + expect(dialogDivVertical).toBeNull(); + + timePicker.close(); + tick(); + fixture.detectChanges(); + + timePicker.mode = PickerInteractionMode.Dialog; + timePicker.headerOrientation = 'vertical'; + fixture.detectChanges(); + + timePicker.open(); + tick(); + fixture.detectChanges(); + + dialogDivVertical = timePickerDebElement.query(By.css(CSS_CLASS_TIME_PICKER_VERTICAL)); + expect(dialogDivVertical).not.toBeNull(); + })); + + it('should hide the calendar header if hideHeader is true in dialog mode', fakeAsync(() => { + timePicker.mode = PickerInteractionMode.Dialog; + timePicker.hideHeader = true; + fixture.detectChanges(); + + timePicker.open(); + tick(); + fixture.detectChanges(); + + const header = fixture.debugElement.query(By.css(CSS_CLASS_HEADER)); + expect(header).toBeNull(); + })); + }); + + describe('Keyboard navigation', () => { + let fixture: ComponentFixture; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, IgxTimePickerTestComponent] + }).compileComponents(); + })); + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(IgxTimePickerTestComponent); + fixture.detectChanges(); + timePicker = fixture.componentInstance.timePicker; + timePickerDebElement = fixture.debugElement.query(By.css(CSS_CLASS_TIMEPICKER)); + timePickerElement = fixture.debugElement.query(By.css(CSS_CLASS_TIMEPICKER)).nativeElement; + inputGroup = fixture.debugElement.query(By.css(`.${CSS_CLASS_INPUTGROUP}`)); + hourColumn = fixture.debugElement.query(By.css(`.${CSS_CLASS_HOURLIST}`)); + minutesColumn = fixture.debugElement.query(By.css(`.${CSS_CLASS_MINUTELIST}`)); + ampmColumn = fixture.debugElement.query(By.css(`.${CSS_CLASS_AMPMLIST}`)); + })); + + it('should toggle the dropdown with ALT + DOWN/UP ARROW key', fakeAsync(() => { + spyOn(timePicker.opening, 'emit').and.callThrough(); + spyOn(timePicker.opened, 'emit').and.callThrough(); + spyOn(timePicker.closing, 'emit').and.callThrough(); + spyOn(timePicker.closed, 'emit').and.callThrough(); + expect(timePicker.collapsed).toBeTruthy(); + expect(timePicker.isFocused).toBeFalse(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', timePickerDebElement, true); + + tick(16); + fixture.detectChanges(); + + expect(timePicker.collapsed).toBeFalsy(); + expect(timePicker.opening.emit).toHaveBeenCalledTimes(1); + expect(timePicker.opened.emit).toHaveBeenCalledTimes(1); + expect(hourColumn.nativeElement.contains(document.activeElement)) + .withContext('focus should move to hour column for KB nav') + .toBeTrue(); + expect(timePicker.isFocused).toBeTrue(); + + UIInteractions.triggerKeyDownEvtUponElem('ArrowUp', timePickerElement, true, true); + tick(); + fixture.detectChanges(); + expect(timePicker.collapsed).toBeTruthy(); + expect(timePicker.closing.emit).toHaveBeenCalledTimes(1); + expect(timePicker.closed.emit).toHaveBeenCalledTimes(1); + expect(inputGroup.nativeElement.contains(document.activeElement)) + .withContext('focus should return to the picker input') + .toBeTrue(); + expect(timePicker.isFocused).toBeTrue(); + })); + + it('should open the dropdown with SPACE key', fakeAsync(() => { + spyOn(timePicker.opening, 'emit').and.callThrough(); + spyOn(timePicker.opened, 'emit').and.callThrough(); + expect(timePicker.collapsed).toBeTruthy(); + + UIInteractions.triggerEventHandlerKeyDown(' ', timePickerDebElement); + tick(); + fixture.detectChanges(); + + expect(timePicker.collapsed).toBeFalsy(); + expect(timePicker.opening.emit).toHaveBeenCalledTimes(1); + expect(timePicker.opened.emit).toHaveBeenCalledTimes(1); + })); + + it('should close the dropdown with ESC', fakeAsync(() => { + spyOn(timePicker.closing, 'emit').and.callThrough(); + spyOn(timePicker.closed, 'emit').and.callThrough(); + + expect(timePicker.collapsed).toBeTruthy(); + timePicker.open(); + tick(); + fixture.detectChanges(); + expect(timePicker.collapsed).toBeFalsy(); + + UIInteractions.triggerKeyDownEvtUponElem('Escape', timePickerElement, true); + tick(); + fixture.detectChanges(); + expect(timePicker.collapsed).toBeTruthy(); + expect(timePicker.closing.emit).toHaveBeenCalledTimes(1); + expect(timePicker.closed.emit).toHaveBeenCalledTimes(1); + })); + }); + + describe('Projected elements', () => { + let fixture: ComponentFixture; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, IgxTimePickerWithProjectionsComponent] + }).compileComponents(); + })); + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(IgxTimePickerWithProjectionsComponent); + fixture.detectChanges(); + })); + + it('Should project label/hint and additional prefix/suffix in the correct location', () => { + fixture.componentInstance.timePicker.value = new Date(); + fixture.detectChanges(); + inputGroup = fixture.debugElement.query(By.directive(IgxInputGroupComponent)); + + const label = inputGroup.queryAll(By.directive(IgxLabelDirective)); + expect(label).toHaveSize(1); + expect(label[0].nativeElement.innerText).toEqual('Label'); + const hint = inputGroup.queryAll(By.directive(IgxHintDirective)); + expect(hint).toHaveSize(1); + expect(hint[0].nativeElement.innerText).toEqual('Hint'); + + const prefix = inputGroup.queryAll(By.directive(IgxPrefixDirective)); + expect(prefix).toHaveSize(2); + expect(prefix[0].nativeElement.innerText).toEqual(TIME_PICKER_TOGGLE_ICON); + expect(prefix[1].nativeElement.innerText).toEqual('Prefix'); + const suffix = inputGroup.queryAll(By.directive(IgxSuffixDirective)); + expect(suffix).toHaveSize(2); + expect(suffix[0].nativeElement.innerText).toEqual(TIME_PICKER_CLEAR_ICON); + expect(suffix[1].nativeElement.innerText).toEqual('Suffix'); + }); + + it('Should project custom toggle/clear and hide defaults', () => { + fixture.componentInstance.timePicker.value = new Date(); + fixture.componentInstance.showCustomClear = true; + fixture.componentInstance.showCustomToggle = true; + fixture.detectChanges(); + inputGroup = fixture.debugElement.query(By.directive(IgxInputGroupComponent)); + + const prefix = inputGroup.queryAll(By.directive(IgxPrefixDirective)); + expect(prefix).toHaveSize(2); + expect(prefix[0].nativeElement.innerText).toEqual('CustomToggle'); + expect(prefix[1].nativeElement.innerText).toEqual('Prefix'); + const suffix = inputGroup.queryAll(By.directive(IgxSuffixDirective)); + expect(suffix).toHaveSize(2); + expect(suffix[0].nativeElement.innerText).toEqual('CustomClear'); + expect(suffix[1].nativeElement.innerText).toEqual('Suffix'); + }); + + it('Should correctly sub/unsub to custom toggle and clear', () => { + timePicker = fixture.componentInstance.timePicker; + timePicker.value = new Date(); + fixture.componentInstance.showCustomClear = true; + fixture.componentInstance.showCustomToggle = true; + fixture.detectChanges(); + spyOn(timePicker, 'open'); + spyOn(timePicker, 'clear'); + + inputGroup = fixture.debugElement.query(By.directive(IgxInputGroupComponent)); + const toggleElem = inputGroup.query(By.directive(IgxPickerToggleComponent)); + const clearElem = inputGroup.query(By.directive(IgxPickerClearComponent)); + let toggle = fixture.componentInstance.customToggle; + let clear = fixture.componentInstance.customClear; + + expect(toggle.clicked.observers).toHaveSize(1); + expect(clear.clicked.observers).toHaveSize(1); + const event = jasmine.createSpyObj('event', ['stopPropagation']); + toggleElem.triggerEventHandler('click', event); + expect(timePicker.open).toHaveBeenCalledTimes(1); + clearElem.triggerEventHandler('click', event); + expect(timePicker.clear).toHaveBeenCalledTimes(1); + + // hide + fixture.componentInstance.showCustomToggle = false; + fixture.detectChanges(); + expect(toggle.clicked.observers).toHaveSize(0); + expect(clear.clicked.observers).toHaveSize(1); + fixture.componentInstance.showCustomClear = false; + fixture.detectChanges(); + expect(toggle.clicked.observers).toHaveSize(0); + expect(clear.clicked.observers).toHaveSize(0); + + // show again + fixture.componentInstance.showCustomClear = true; + fixture.componentInstance.showCustomToggle = true; + fixture.detectChanges(); + toggle = fixture.componentInstance.customToggle; + clear = fixture.componentInstance.customClear; + expect(toggle.clicked.observers).toHaveSize(1); + expect(clear.clicked.observers).toHaveSize(1); + + timePicker.ngOnDestroy(); + expect(toggle.clicked.observers).toHaveSize(0); + expect(clear.clicked.observers).toHaveSize(0); + }); + }); + + describe('FormControl integration', () => { + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxTimePickerInFormComponent, + IgxTimePickerReactiveFormComponent + ] + }).compileComponents(); + })); + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(IgxTimePickerInFormComponent); + fixture.detectChanges(); + timePicker = fixture.componentInstance.timePicker; + })); + + it('should set validity to initial when the form is reset', fakeAsync(() => { + const tpInput = document.getElementsByClassName('igx-input-group__input')[0] as HTMLInputElement; + tpInput.focus(); + tick(); + fixture.detectChanges(); + + tpInput.blur(); + tick(50); + fixture.detectChanges(); + expect((timePicker as any).inputDirective.valid).toEqual(IgxInputState.INVALID); + + (fixture.componentInstance as IgxTimePickerInFormComponent).form.resetForm(); + tick(); + expect((timePicker as any).inputDirective.valid).toEqual(IgxInputState.INITIAL); + })); + + it('should apply asterisk properly when required validator is set dynamically', () => { + fixture = TestBed.createComponent(IgxTimePickerReactiveFormComponent); + fixture.detectChanges(); + timePicker = fixture.componentInstance.timePicker; + + let inputGroupRequiredClass = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT_GROUP_REQUIRED)); + let inputGroupInvalidClass = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT_GROUP_INVALID)); + let asterisk = window. + getComputedStyle(fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT_GROUP_LABEL)).nativeElement, ':after'). + content; + expect(asterisk).toBe('"*"'); + expect(inputGroupRequiredClass).toBeDefined(); + expect(inputGroupRequiredClass).not.toBeNull(); + + timePicker.clear(); + fixture.detectChanges(); + + inputGroupInvalidClass = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT_GROUP_INVALID)); + expect(inputGroupInvalidClass).not.toBeNull(); + expect(inputGroupInvalidClass).not.toBeUndefined(); + + inputGroupRequiredClass = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT_GROUP_REQUIRED)); + expect(inputGroupRequiredClass).not.toBeNull(); + expect(inputGroupRequiredClass).not.toBeUndefined(); + + (fixture.componentInstance as IgxTimePickerReactiveFormComponent).removeValidators(); + fixture.detectChanges(); + + inputGroupRequiredClass = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT_GROUP_REQUIRED)); + asterisk = window. + getComputedStyle(fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT_GROUP_LABEL)).nativeElement, ':after'). + content; + expect(inputGroupRequiredClass).toBeNull(); + expect(asterisk).toBe('none'); + + (fixture.componentInstance as IgxTimePickerReactiveFormComponent).addValidators(); + fixture.detectChanges(); + + inputGroupRequiredClass = fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT_GROUP_REQUIRED)); + asterisk = window. + getComputedStyle(fixture.debugElement.query(By.css('.' + CSS_CLASS_INPUT_GROUP_LABEL)).nativeElement, ':after'). + content; + expect(inputGroupRequiredClass).toBeDefined(); + expect(inputGroupRequiredClass).not.toBeNull(); + expect(asterisk).toBe('"*"'); + }); + + it('should set initial validity state when the form group is disabled', () => { + fixture = TestBed.createComponent(IgxTimePickerReactiveFormComponent); + fixture.detectChanges(); + timePicker = fixture.componentInstance.timePicker; + + (fixture.componentInstance as IgxTimePickerReactiveFormComponent).markAsTouched(); + fixture.detectChanges(); + expect((timePicker as any).inputDirective.valid).toBe(IgxInputState.INVALID); + + (fixture.componentInstance as IgxTimePickerReactiveFormComponent).disableForm(); + fixture.detectChanges(); + expect((timePicker as any).inputDirective.valid).toBe(IgxInputState.INITIAL); + }); + + it('should update validity state when programmatically setting errors on reactive form controls', () => { + fixture = TestBed.createComponent(IgxTimePickerReactiveFormComponent); + fixture.detectChanges(); + timePicker = fixture.componentInstance.timePicker; + const form = fixture.componentInstance.form as UntypedFormGroup; + + // the form control has validators + form.markAllAsTouched(); + form.get('time').setErrors({ error: true }); + fixture.detectChanges(); + + expect((timePicker as any).inputDirective.valid).toBe(IgxInputState.INVALID); + expect((timePicker as any).inputGroup.element.nativeElement.classList.contains(CSS_CLASS_INPUT_GROUP_INVALID)).toBe(true); + expect((timePicker as any).inputGroup.element.nativeElement.classList.contains(CSS_CLASS_INPUT_GROUP_REQUIRED)).toBe(true); + + // remove the validators and set errors + (fixture.componentInstance as IgxTimePickerReactiveFormComponent).removeValidators(); + form.markAsUntouched(); + fixture.detectChanges(); + + form.markAllAsTouched(); + form.get('time').setErrors({ error: true }); + fixture.detectChanges(); + + // no validator, but there is a set error + expect((timePicker as any).inputDirective.valid).toBe(IgxInputState.INVALID); + expect((timePicker as any).inputGroup.element.nativeElement).toHaveClass(CSS_CLASS_INPUT_GROUP_INVALID); + expect((timePicker as any).inputGroup.element.nativeElement.classList.contains(CSS_CLASS_INPUT_GROUP_REQUIRED)).toBe(false); + }); + }); + }); +}); + +@Component({ + template: ` + + + `, + imports: [IgxTimePickerComponent, IgxLabelDirective] +}) +export class IgxTimePickerTestComponent { + @ViewChild('picker', { read: IgxTimePickerComponent, static: true }) + public timePicker: IgxTimePickerComponent; + public mode: PickerInteractionMode = PickerInteractionMode.DropDown; + public date = new Date(2021, 24, 2, 11, 45, 0, 0); + public minValue; + public maxValue; +} + +@Component({ + template: ` + + + @if (showCustomToggle) { + CustomToggle + } + Prefix + @if (showCustomClear) { + CustomClear + } + Suffix + Hint + +`, + imports: [IgxTimePickerComponent, IgxPickerToggleComponent, IgxPickerClearComponent, IgxLabelDirective, IgxPrefixDirective, IgxSuffixDirective, IgxHintDirective] +}) +export class IgxTimePickerWithProjectionsComponent { + @ViewChild(IgxTimePickerComponent) public timePicker: IgxTimePickerComponent; + @ViewChild(IgxPickerToggleComponent) public customToggle: IgxPickerToggleComponent; + @ViewChild(IgxPickerClearComponent) public customClear: IgxPickerClearComponent; + public mode: PickerInteractionMode = PickerInteractionMode.DropDown; + public showCustomToggle = false; + public showCustomClear = false; +} + +@Component({ + template: ` +
    + + + `, + imports: [IgxTimePickerComponent, FormsModule] +}) +export class IgxTimePickerInFormComponent { + @ViewChild('form') + public form: NgForm; + + @ViewChild(IgxTimePickerComponent) + public timePicker: IgxTimePickerComponent; + + public minValue = new Date(2010, 3, 3, 13, 0, 0); + public date: Date = new Date(2010, 3, 3, 12, 0, 0); +} + +@Component({ + template: ` +
    +
    + + + +
    + + `, + imports: [IgxTimePickerComponent, IgxLabelDirective, ReactiveFormsModule] +}) +export class IgxTimePickerReactiveFormComponent { + @ViewChild(IgxTimePickerComponent) + public timePicker: IgxTimePickerComponent; + + public time: Date = new Date(2012, 5, 3); + + public form: UntypedFormGroup = new UntypedFormGroup({ + time: new UntypedFormControl(null, Validators.required) + }); + + public removeValidators() { + this.form.get('time').clearValidators(); + this.form.get('time').updateValueAndValidity(); + } + + public addValidators() { + this.form.get('time').setValidators(Validators.required); + this.form.get('time').updateValueAndValidity(); + } + + public markAsTouched() { + this.form.get('time').markAsTouched(); + this.form.get('time').updateValueAndValidity(); + } + + public disableForm() { + this.form.disable(); + } +} diff --git a/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.ts b/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.ts new file mode 100644 index 00000000000..e46c5538d7e --- /dev/null +++ b/projects/igniteui-angular/time-picker/src/time-picker/time-picker.component.ts @@ -0,0 +1,1266 @@ +import { NgClass, NgTemplateOutlet } from '@angular/common'; +import { + Component, + ElementRef, + EventEmitter, + HostBinding, + Input, + OnDestroy, + OnInit, + Output, + ViewChild, + ContentChild, + AfterViewInit, + Injector, + PipeTransform, + ChangeDetectorRef, + HostListener, booleanAttribute, + inject +} from '@angular/core'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + NgControl, + AbstractControl, + ValidationErrors, + Validator, + NG_VALIDATORS +} from '@angular/forms'; + +import { IgxInputDirective, IgxInputGroupComponent, IgxInputState, IgxLabelDirective, IgxPrefixDirective, IgxReadOnlyInputDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; +import { + IgxItemListDirective, + IgxTimeItemDirective +} from './time-picker.directives'; +import { Subscription, noop, fromEvent } from 'rxjs'; +import { IgxTimePickerBase, IGX_TIME_PICKER_COMPONENT } from './time-picker.common'; +import { AbsoluteScrollStrategy, DatePart, DatePartDeltas, DateTimeUtil, IgxPickerActionsDirective, PickerHeaderOrientation, PickerInteractionMode } from 'igniteui-angular/core'; +import { AutoPositionStrategy } from 'igniteui-angular/core'; +import { OverlaySettings } from 'igniteui-angular/core'; +import { takeUntil } from 'rxjs/operators'; +import { IgxButtonDirective } from 'igniteui-angular/directives'; + +import { IgxDateTimeEditorDirective } from 'igniteui-angular/directives'; +import { IgxToggleDirective } from 'igniteui-angular/directives'; +import { ITimePickerResourceStrings, TimePickerResourceStringsEN } from 'igniteui-angular/core'; +import { IBaseEventArgs, isEqual, isDate, PlatformUtil, IBaseCancelableBrowserEventArgs } from 'igniteui-angular/core'; + +import { IgxTextSelectionDirective } from 'igniteui-angular/directives'; +import { TimeFormatPipe, TimeItemPipe } from './time-picker.pipes'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { getCurrentResourceStrings } from 'igniteui-angular/core'; +import { IgxDividerDirective } from 'igniteui-angular/directives'; +import { PickerBaseDirective } from 'igniteui-angular/date-picker'; + +let NEXT_ID = 0; +export interface IgxTimePickerValidationFailedEventArgs extends IBaseEventArgs { + previousValue: Date | string; + currentValue: Date | string; +} + +@Component({ + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: IgxTimePickerComponent, + multi: true + }, + { + provide: IGX_TIME_PICKER_COMPONENT, + useExisting: IgxTimePickerComponent + }, + { + provide: NG_VALIDATORS, + useExisting: IgxTimePickerComponent, + multi: true + } + ], + selector: 'igx-time-picker', + templateUrl: 'time-picker.component.html', + styles: [ + `:host { + display: block; + }` + ], + imports: [IgxInputGroupComponent, IgxInputDirective, IgxDateTimeEditorDirective, IgxTextSelectionDirective, IgxPrefixDirective, IgxIconComponent, IgxSuffixDirective, IgxButtonDirective, IgxToggleDirective, NgClass, IgxItemListDirective, IgxTimeItemDirective, NgTemplateOutlet, TimeFormatPipe, TimeItemPipe, IgxDividerDirective, IgxReadOnlyInputDirective] +}) +export class IgxTimePickerComponent extends PickerBaseDirective + implements + IgxTimePickerBase, + ControlValueAccessor, + OnInit, + OnDestroy, + AfterViewInit, + Validator { + private _injector = inject(Injector); + private platform = inject(PlatformUtil); + private cdr = inject(ChangeDetectorRef); + + /** + * Sets the value of the `id` attribute. + * ```html + * + * ``` + */ + @HostBinding('attr.id') + @Input() + public id = `igx-time-picker-${NEXT_ID++}`; + + /** + * The format used when editable input is not focused. Defaults to the `inputFormat` if not set. + * + * @remarks + * Uses Angular's `DatePipe`. + * + * @example + * ```html + * + * ``` + * + */ + @Input() + public override displayFormat: string; + + /** + * The expected user input format and placeholder. + * + * @remarks + * Default is `hh:mm tt` + * + * @example + * ```html + * + * ``` + */ + @Input() + public override inputFormat: string; + + /** + * Gets/Sets the interaction mode - dialog or drop down. + * + * @example + * ```html + * + * ``` + */ + @Input() + public override mode: PickerInteractionMode = PickerInteractionMode.DropDown; + + /** + * The minimum value the picker will accept. + * + * @remarks + * If a `string` value is passed in, it must be in ISO format. + * + * @example + * ```html + * + * ``` + */ + @Input() + public set minValue(value: Date | string) { + this._minValue = value; + const date = this.parseToDate(value); + if (date) { + this._dateMinValue = new Date(); + this._dateMinValue.setHours(date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds()); + this.minDropdownValue = this.setMinMaxDropdownValue('min', this._dateMinValue); + } + this.setSelectedValue(this._selectedDate); + this._onValidatorChange(); + } + + public get minValue(): Date | string { + return this._minValue; + } + + /** + * Gets if the dropdown/dialog is collapsed + * + * ```typescript + * let isCollapsed = this.timePicker.collapsed; + * ``` + */ + public override get collapsed(): boolean { + return this.toggleRef?.collapsed; + } + + /** + * The maximum value the picker will accept. + * + * @remarks + * If a `string` value is passed in, it must be in ISO format. + * + * @example + * ```html + * + * ``` + */ + @Input() + public set maxValue(value: Date | string) { + this._maxValue = value; + const date = this.parseToDate(value); + if (date) { + this._dateMaxValue = new Date(); + this._dateMaxValue.setHours(date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds()); + this.maxDropdownValue = this.setMinMaxDropdownValue('max', this._dateMaxValue); + } + this.setSelectedValue(this._selectedDate); + this._onValidatorChange(); + } + + public get maxValue(): Date | string { + return this._maxValue; + } + + /** + * Sets whether the seconds, minutes and hour spinning will loop back around when end value is reached. + * By default it's set to true. + * ```html + * + * ``` + */ + @Input({ transform: booleanAttribute }) + public spinLoop = true; + + /** + * Gets/Sets a custom formatter function on the selected or passed date. + * + * @example + * ```html + * + * ``` + */ + @Input() + public formatter: (val: Date) => string; + + /** @hidden @internal */ + @Input({ transform: booleanAttribute }) + public readOnly = false; + + /** + * Emitted after a selection has been done. + * + * @example + * ```html + * + * ``` + */ + @Output() + public selected = new EventEmitter(); + + /** + * Emitted when the picker's value changes. + * + * @remarks + * Used for `two-way` bindings. + * + * @example + * ```html + * + * ``` + */ + @Output() + public valueChange = new EventEmitter(); + + /** + * Emitted when the user types/spins invalid time in the time-picker editor. + * + * @example + * ```html + * + * ``` + */ + @Output() + public validationFailed = new EventEmitter(); + + /** @hidden */ + @ViewChild('hourList') + public hourList: ElementRef; + + /** @hidden */ + @ViewChild('minuteList') + public minuteList: ElementRef; + + /** @hidden */ + @ViewChild('secondsList') + public secondsList: ElementRef; + + /** @hidden */ + @ViewChild('ampmList') + public ampmList: ElementRef; + + + /** @hidden @internal */ + @ContentChild(IgxLabelDirective) + public label: IgxLabelDirective; + + /** @hidden @internal */ + @ContentChild(IgxPickerActionsDirective) + public timePickerActionsDirective: IgxPickerActionsDirective; + + @ViewChild(IgxInputDirective, { read: IgxInputDirective }) + private inputDirective: IgxInputDirective; + + @ViewChild('inputGroup', { read: IgxInputGroupComponent, static: true }) + private _inputGroup: IgxInputGroupComponent; + + @ViewChild(IgxDateTimeEditorDirective, { static: true }) + private dateTimeEditor: IgxDateTimeEditorDirective; + + @ViewChild(IgxToggleDirective) + private toggleRef: IgxToggleDirective; + + /** @hidden */ + public cleared = false; + + /** @hidden */ + public isNotEmpty = false; + + /** @hidden */ + public currentHour: number; + + /** @hidden */ + public currentMinutes: number; + + /** @hidden */ + public get showClearButton(): boolean { + if (this.clearComponents.length) { + return false; + } + if (DateTimeUtil.isValidDate(this.value)) { + // TODO: Update w/ clear behavior + return this.value.getHours() !== 0 || this.value.getMinutes() !== 0 || + this.value.getSeconds() !== 0 || this.value.getMilliseconds() !== 0; + } + return !!this.dateTimeEditor.value; + } + + /** @hidden */ + public get showHoursList(): boolean { + return this.appliedFormat?.indexOf('h') !== - 1 || this.appliedFormat?.indexOf('H') !== - 1; + } + + /** @hidden */ + public get showMinutesList(): boolean { + return this.appliedFormat?.indexOf('m') !== - 1; + } + + /** @hidden */ + public get showSecondsList(): boolean { + return this.appliedFormat?.indexOf('s') !== - 1; + } + + /** @hidden */ + public get showAmPmList(): boolean { + return this.appliedFormat?.indexOf('t') !== - 1 || this.appliedFormat?.indexOf('a') !== - 1; + } + + /** @hidden */ + public get isTwelveHourFormat(): boolean { + return this.appliedFormat?.indexOf('h') !== - 1; + } + + /** @hidden @internal */ + public get isVertical(): boolean { + return this.headerOrientation === PickerHeaderOrientation.Vertical; + } + + /** @hidden @internal */ + public get selectedDate(): Date { + return this._selectedDate; + } + + /** @hidden @internal */ + public get minDateValue(): Date { + if (!this._dateMinValue) { + const minDate = new Date(); + minDate.setHours(0, 0, 0, 0); + return minDate; + } + + return this._dateMinValue; + } + + /** @hidden @internal */ + public get maxDateValue(): Date { + if (!this._dateMaxValue) { + const maxDate = new Date(); + maxDate.setHours(23, 59, 59, 999); + return maxDate; + } + + return this._dateMaxValue; + } + + /** @hidden @internal */ + public get appliedFormat(): string { + return this.inputFormat || this.dateTimeEditor?.inputFormat; + } + + protected override get toggleContainer(): HTMLElement | undefined { + return this.toggleRef?.element; + } + + private get required(): boolean { + if (this._ngControl && this._ngControl.control && this._ngControl.control.validator) { + // Run the validation with empty object to check if required is enabled. + const error = this._ngControl.control.validator({} as AbstractControl); + return !!(error && error.required); + } + + return false; + } + + private get dialogOverlaySettings(): OverlaySettings { + return Object.assign({}, this._defaultDialogOverlaySettings, this.overlaySettings); + } + + private get dropDownOverlaySettings(): OverlaySettings { + return Object.assign({}, this._defaultDropDownOverlaySettings, this.overlaySettings); + } + + /** @hidden @internal */ + public displayValue: PipeTransform = { transform: (date: Date) => this.formatter(date) }; + /** @hidden @internal */ + public minDropdownValue: Date; + /** @hidden @internal */ + public maxDropdownValue: Date; + /** @hidden @internal */ + public hourItems = []; + /** @hidden @internal */ + public minuteItems = []; + /** @hidden @internal */ + public secondsItems = []; + /** @hidden @internal */ + public ampmItems = []; + + private _value: Date | string; + private _dateValue: Date; + private _dateMinValue: Date; + private _dateMaxValue: Date; + private _selectedDate: Date; + private _resourceStrings = getCurrentResourceStrings(TimePickerResourceStringsEN); + private _okButtonLabel = null; + private _cancelButtonLabel = null; + private _itemsDelta: Pick = + { hours: 1, minutes: 1, seconds: 1, fractionalSeconds: 1 }; + + private _statusChanges$: Subscription; + private _ngControl: NgControl = null; + private _onChangeCallback: (_: Date | string) => void = noop; + private _onTouchedCallback: () => void = noop; + private _onValidatorChange: () => void = noop; + + private _defaultDialogOverlaySettings: OverlaySettings = { + closeOnOutsideClick: true, + modal: true, + closeOnEscape: true, + outlet: this.outlet + }; + private _defaultDropDownOverlaySettings: OverlaySettings = { + target: this.element.nativeElement, + modal: false, + closeOnOutsideClick: true, + scrollStrategy: new AbsoluteScrollStrategy(), + positionStrategy: new AutoPositionStrategy(), + outlet: this.outlet + }; + + + /** + * The currently selected value / time from the drop-down/dialog + * + * @remarks + * The current value is of type `Date` + * + * @example + * ```typescript + * const newValue: Date = new Date(2000, 2, 2, 10, 15, 15); + * this.timePicker.value = newValue; + * ``` + */ + public get value(): Date | string { + return this._value; + } + + /** + * An accessor that allows you to set a time using the `value` input. + * ```html + * public date: Date = new Date(Date.now()); + * //... + * + * ``` + */ + @Input() + public set value(value: Date | string) { + const oldValue = this._value; + this._value = value; + const date = this.parseToDate(value); + if (date) { + this._dateValue = new Date(); + this._dateValue.setHours(date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds()); + this.setSelectedValue(this._dateValue); + } else { + this._dateValue = null; + this.setSelectedValue(null); + } + if (this.dateTimeEditor) { + this.dateTimeEditor.value = date; + } + this.emitValueChange(oldValue, this._value); + this._onChangeCallback(this._value); + } + + /** + * An accessor that sets the resource strings. + * By default it uses EN resources. + */ + @Input() + public set resourceStrings(value: ITimePickerResourceStrings) { + this._resourceStrings = Object.assign({}, this._resourceStrings, value); + } + + /** + * An accessor that returns the resource strings. + */ + public get resourceStrings(): ITimePickerResourceStrings { + return this._resourceStrings; + } + + /** + * Overrides the default text of the **OK** button. + * + * @remarks + * Defaults to the value from resource strings, `"OK"` for the built-in EN. + * + * ```html + * + * ``` + */ + @Input() + public set okButtonLabel(value: string) { + this._okButtonLabel = value; + } + + /** + * An accessor that returns the label of ok button. + */ + public get okButtonLabel(): string { + if (this._okButtonLabel === null) { + return this.resourceStrings.igx_time_picker_ok; + } + return this._okButtonLabel; + } + + /** + * Overrides the default text of the **Cancel** button. + * @remarks + * Defaults to the value from resource strings, `"Cancel"` for the built-in EN. + * ```html + * + * ``` + */ + @Input() + public set cancelButtonLabel(value: string) { + this._cancelButtonLabel = value; + } + + /** + * An accessor that returns the label of cancel button. + */ + public get cancelButtonLabel(): string { + if (this._cancelButtonLabel === null) { + return this.resourceStrings.igx_time_picker_cancel; + } + return this._cancelButtonLabel; + } + + /** + * Delta values used to increment or decrement each editor date part on spin actions and + * to display time portions in the dropdown/dialog. + * By default `itemsDelta` is set to `{hour: 1, minute: 1, second: 1}` + * ```html + * + * ``` + */ + @Input() + public set itemsDelta(value: Pick) { + Object.assign(this._itemsDelta, value); + } + + public get itemsDelta(): Pick { + return this._itemsDelta; + } + + constructor() { + super(); + this.locale = this.locale || this._localeId; + } + + /** @hidden @internal */ + @HostListener('keydown', ['$event']) + public onKeyDown(event: KeyboardEvent): void { + switch (event.key) { + case this.platform.KEYMAP.ARROW_UP: + if (event.altKey && this.isDropdown) { + this.close(); + } + break; + case this.platform.KEYMAP.ARROW_DOWN: + if (event.altKey && this.isDropdown) { + this.open(); + } + break; + case this.platform.KEYMAP.ESCAPE: + this.cancelButtonClick(); + break; + case this.platform.KEYMAP.SPACE: + this.open(); + event.preventDefault(); + break; + } + } + + /** @hidden @internal */ + public getPartValue(value: Date, type: string): string { + const inputDateParts = DateTimeUtil.parseDateTimeFormat(this.appliedFormat); + const part = inputDateParts.find(element => element.type === type); + return DateTimeUtil.getPartValue(value, part, part.format?.length); + } + + /** @hidden @internal */ + public toISOString(value: Date): string { + return value.toLocaleTimeString('en-GB', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + fractionalSecondDigits: 3 + }); + } + + // #region ControlValueAccessor + + /** @hidden @internal */ + public writeValue(value: Date | string) { + this._value = value; + const date = this.parseToDate(value); + if (date) { + this._dateValue = new Date(); + this._dateValue.setHours(date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds()); + this.setSelectedValue(this._dateValue); + } else { + this.setSelectedValue(null); + } + if (this.dateTimeEditor) { + this.dateTimeEditor.value = date; + } + } + + /** @hidden @internal */ + public registerOnChange(fn: (_: Date | string) => void) { + this._onChangeCallback = fn; + } + + /** @hidden @internal */ + public registerOnTouched(fn: () => void) { + this._onTouchedCallback = fn; + } + + /** @hidden @internal */ + public registerOnValidatorChange(fn: any) { + this._onValidatorChange = fn; + } + + /** @hidden @internal */ + public validate(control: AbstractControl): ValidationErrors | null { + if (!control.value) { + return null; + } + // InvalidDate handling + if (isDate(control.value) && !DateTimeUtil.isValidDate(control.value)) { + return { value: true }; + } + + const errors = {}; + const value = DateTimeUtil.isValidDate(control.value) ? control.value : DateTimeUtil.parseIsoDate(control.value); + Object.assign(errors, DateTimeUtil.validateMinMax(value, this.minValue, this.maxValue, true, false)); + return Object.keys(errors).length > 0 ? errors : null; + } + + /** @hidden @internal */ + public setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + //#endregion + + /** @hidden */ + public ngOnInit(): void { + this._ngControl = this._injector.get(NgControl, null); + this.minDropdownValue = this.setMinMaxDropdownValue('min', this.minDateValue); + this.maxDropdownValue = this.setMinMaxDropdownValue('max', this.maxDateValue); + this.setSelectedValue(this._dateValue); + } + + /** @hidden */ + public override ngAfterViewInit(): void { + super.ngAfterViewInit(); + this.subscribeToDateEditorEvents(); + this.subscribeToToggleDirectiveEvents(); + + this._defaultDropDownOverlaySettings.excludeFromOutsideClick = [this._inputGroup.element.nativeElement]; + + fromEvent(this.inputDirective.nativeElement, 'blur') + .pipe(takeUntil(this._destroy$)) + .subscribe(() => { + if (this.collapsed) { + this.updateValidityOnBlur(); + } + }); + + if (this._ngControl) { + this._statusChanges$ = this._ngControl.statusChanges.subscribe(this.onStatusChanged.bind(this)); + this._inputGroup.isRequired = this.required; + this.cdr.detectChanges(); + } + } + + /** @hidden */ + public override ngOnDestroy(): void { + super.ngOnDestroy(); + if (this._statusChanges$) { + this._statusChanges$.unsubscribe(); + } + } + + /** @hidden */ + public getEditElement(): HTMLInputElement { + return this.dateTimeEditor.nativeElement; + } + + /** + * Opens the picker's dialog UI. + * + * @param settings OverlaySettings - the overlay settings to use for positioning the drop down or dialog container according to + * ```html + * + * + * ``` + */ + public open(settings?: OverlaySettings): void { + if (this.disabled || !this.toggleRef.collapsed || this.readOnly) { + return; + } + + this.setSelectedValue(this._dateValue); + const overlaySettings = Object.assign({}, this.isDropdown + ? this.dropDownOverlaySettings + : this.dialogOverlaySettings + , settings); + + this.toggleRef.open(overlaySettings); + } + + /** + * Closes the dropdown/dialog. + * ```html + * + * ``` + * ```typescript + * @ViewChild('timePicker', { read: IgxTimePickerComponent }) picker: IgxTimePickerComponent; + * picker.close(); + * ``` + */ + public close(): void { + this.toggleRef.close(); + } + + public toggle(settings?: OverlaySettings): void { + if (this.toggleRef.collapsed) { + this.open(settings); + } else { + this.close(); + } + } + + /** + * Clears the time picker value if it is a `string` or resets the time to `00:00:00` if the value is a Date object. + * + * @example + * ```typescript + * this.timePicker.clear(); + * ``` + */ + public clear(): void { + if (this.disabled || this.readOnly) { + return; + } + + if (!this.toggleRef.collapsed) { + this.close(); + } + + if (DateTimeUtil.isValidDate(this.value)) { + const oldValue = new Date(this.value); + this.value.setHours(0, 0, 0, 0); + if (this.value.getTime() !== oldValue.getTime()) { + this.emitValueChange(oldValue, this.value); + this._dateValue.setHours(0, 0, 0, 0); + this.dateTimeEditor.value = new Date(this.value); + this.setSelectedValue(this._dateValue); + } + } else { + this.value = null; + } + } + + /** + * Selects time from the igxTimePicker. + * + * @example + * ```typescript + * this.timePicker.select(date); + * + * @param date Date object containing the time to be selected. + */ + public select(date: Date | string): void { + this.value = date; + } + + /** + * Increment a specified `DatePart`. + * + * @param datePart The optional DatePart to increment. Defaults to Hour. + * @param delta The optional delta to increment by. Overrides `itemsDelta`. + * @example + * ```typescript + * this.timePicker.increment(DatePart.Hours); + * ``` + */ + public increment(datePart?: DatePart, delta?: number): void { + this.dateTimeEditor.increment(datePart, delta); + } + + /** + * Decrement a specified `DatePart` + * + * @param datePart The optional DatePart to decrement. Defaults to Hour. + * @param delta The optional delta to decrement by. Overrides `itemsDelta`. + * @example + * ```typescript + * this.timePicker.decrement(DatePart.Seconds); + * ``` + */ + public decrement(datePart?: DatePart, delta?: number): void { + this.dateTimeEditor.decrement(datePart, delta); + } + + /** @hidden @internal */ + public cancelButtonClick(): void { + this.setSelectedValue(this._dateValue); + this.dateTimeEditor.value = this.parseToDate(this.value); + this.close(); + } + + /** @hidden @internal */ + public okButtonClick(): void { + this.updateValue(this._selectedDate); + this.close(); + } + + /** @hidden @internal */ + public onItemClick(item: string, dateType: string): void { + let date = new Date(this._selectedDate); + switch (dateType) { + case 'hourList': { + let ampm: string; + const selectedHour = parseInt(item, 10); + let hours = selectedHour; + + if (this.showAmPmList) { + ampm = this.getPartValue(date, 'ampm'); + hours = this.toTwentyFourHourFormat(hours, ampm); + const minHours = this.minDropdownValue?.getHours() || 0; + const maxHours = this.maxDropdownValue?.getHours() || 24; + if (hours < minHours || hours > maxHours) { + hours = hours < 12 ? hours + 12 : hours - 12; + } + } + + date.setHours(hours); + date = this.validateDropdownValue(date); + + if (this.valueInRange(date, this.minDropdownValue, this.maxDropdownValue)) { + this.setSelectedValue(date); + } + break; + } + case 'minuteList': { + const minutes = parseInt(item, 10); + date.setMinutes(minutes); + date = this.validateDropdownValue(date); + this.setSelectedValue(date); + break; + } + case 'secondsList': { + const seconds = parseInt(item, 10); + date.setSeconds(seconds); + if (this.valueInRange(date, this.minDropdownValue, this.maxDropdownValue)) { + this.setSelectedValue(date); + } + break; + } + case 'ampmList': { + let hour = this._selectedDate.getHours(); + hour = DateTimeUtil.isAm(item) + ? hour % 12 + : (hour % 12) + 12; + + date.setHours(hour); + date = this.validateDropdownValue(date, true); + this.setSelectedValue(date); + break; + } + } + this.updateEditorValue(); + } + + /** @hidden @internal */ + public nextHour(delta: number) { + delta = delta > 0 ? 1 : -1; + const previousDate = new Date(this._selectedDate); + const minHours = this.minDropdownValue?.getHours(); + const maxHours = this.maxDropdownValue?.getHours(); + const previousHours = previousDate.getHours(); + let hours = previousHours + delta * this.itemsDelta.hours; + if ((previousHours === maxHours && delta > 0) || (previousHours === minHours && delta < 0)) { + hours = !this.spinLoop ? previousHours : delta > 0 ? minHours : maxHours; + } + + this._selectedDate.setHours(hours); + this._selectedDate = this.validateDropdownValue(this._selectedDate); + this._selectedDate = new Date(this._selectedDate); + this.updateEditorValue(); + } + + /** @hidden @internal */ + public nextMinute(delta: number) { + delta = delta > 0 ? 1 : -1; + const minHours = this.minDropdownValue.getHours(); + const maxHours = this.maxDropdownValue.getHours(); + const hours = this._selectedDate.getHours(); + let minutes = this._selectedDate.getMinutes(); + const minMinutes = hours === minHours ? this.minDropdownValue.getMinutes() : 0; + const maxMinutes = hours === maxHours ? this.maxDropdownValue.getMinutes() : + 60 % this.itemsDelta.minutes > 0 ? 60 - (60 % this.itemsDelta.minutes) : + 60 - this.itemsDelta.minutes; + + if ((delta < 0 && minutes === minMinutes) || (delta > 0 && minutes === maxMinutes)) { + minutes = this.spinLoop && minutes === minMinutes ? maxMinutes : this.spinLoop && minutes === maxMinutes ? minMinutes : minutes; + } else { + minutes = minutes + delta * this.itemsDelta.minutes; + } + + this._selectedDate.setMinutes(minutes); + this._selectedDate = this.validateDropdownValue(this._selectedDate); + this._selectedDate = new Date(this._selectedDate); + this.updateEditorValue(); + } + + /** @hidden @internal */ + public nextSeconds(delta: number) { + delta = delta > 0 ? 1 : -1; + const minHours = this.minDropdownValue.getHours(); + const maxHours = this.maxDropdownValue.getHours(); + const hours = this._selectedDate.getHours(); + const minutes = this._selectedDate.getMinutes(); + const minMinutes = this.minDropdownValue.getMinutes(); + const maxMinutes = this.maxDropdownValue.getMinutes(); + let seconds = this._selectedDate.getSeconds(); + const minSeconds = (hours === minHours && minutes === minMinutes) ? this.minDropdownValue.getSeconds() : 0; + const maxSeconds = (hours === maxHours && minutes === maxMinutes) ? this.maxDropdownValue.getSeconds() : + 60 % this.itemsDelta.seconds > 0 ? 60 - (60 % this.itemsDelta.seconds) : + 60 - this.itemsDelta.seconds; + + if ((delta < 0 && seconds === minSeconds) || (delta > 0 && seconds === maxSeconds)) { + seconds = this.spinLoop && seconds === minSeconds ? maxSeconds : this.spinLoop && seconds === maxSeconds ? minSeconds : seconds; + } else { + seconds = seconds + delta * this.itemsDelta.seconds; + } + + this._selectedDate.setSeconds(seconds); + this._selectedDate = this.validateDropdownValue(this._selectedDate); + this._selectedDate = new Date(this._selectedDate); + this.updateEditorValue(); + } + + /** @hidden @internal */ + public nextAmPm(delta?: number) { + const ampm = this.getPartValue(this._selectedDate, 'ampm'); + if (!delta || (DateTimeUtil.isAm(ampm) && delta > 0) + || (DateTimeUtil.isPm(ampm) && delta < 0)) { + let hours = this._selectedDate.getHours(); + const sign = hours < 12 ? 1 : -1; + hours = hours + sign * 12; + this._selectedDate.setHours(hours); + this._selectedDate = this.validateDropdownValue(this._selectedDate, true); + this._selectedDate = new Date(this._selectedDate); + this.updateEditorValue(); + } + } + + /** @hidden @internal */ + public setSelectedValue(value: Date) { + this._selectedDate = value ? new Date(value) : null; + if (!DateTimeUtil.isValidDate(this._selectedDate)) { + this._selectedDate = new Date(this.minDropdownValue); + return; + } + if (this.minValue && DateTimeUtil.lessThanMinValue(this._selectedDate, this.minDropdownValue, true, false)) { + this._selectedDate = new Date(this.minDropdownValue); + return; + } + if (this.maxValue && DateTimeUtil.greaterThanMaxValue(this._selectedDate, this.maxDropdownValue, true, false)) { + this._selectedDate = new Date(this.maxDropdownValue); + return; + } + + if (this._selectedDate.getHours() % this.itemsDelta.hours > 0) { + this._selectedDate.setHours( + this._selectedDate.getHours() + this.itemsDelta.hours - this._selectedDate.getHours() % this.itemsDelta.hours, + 0, + 0 + ); + } + + if (this._selectedDate.getMinutes() % this.itemsDelta.minutes > 0) { + this._selectedDate.setHours( + this._selectedDate.getHours(), + this._selectedDate.getMinutes() + this.itemsDelta.minutes - this._selectedDate.getMinutes() % this.itemsDelta.minutes, + 0 + ); + } + + if (this._selectedDate.getSeconds() % this.itemsDelta.seconds > 0) { + this._selectedDate.setSeconds( + this._selectedDate.getSeconds() + this.itemsDelta.seconds - this._selectedDate.getSeconds() % this.itemsDelta.seconds + ); + } + } + + protected onStatusChanged() { + if (this._ngControl && !this._ngControl.disabled && this.isTouchedOrDirty) { + if (this.hasValidators && this._inputGroup.isFocused) { + this.inputDirective.valid = this._ngControl.valid ? IgxInputState.VALID : IgxInputState.INVALID; + } else { + this.inputDirective.valid = this._ngControl.valid ? IgxInputState.INITIAL : IgxInputState.INVALID; + } + } else { + // B.P. 18 May 2021: IgxDatePicker does not reset its state upon resetForm #9526 + this.inputDirective.valid = IgxInputState.INITIAL; + } + + if (this._inputGroup && this._inputGroup.isRequired !== this.required) { + this._inputGroup.isRequired = this.required; + } + } + + private get isTouchedOrDirty(): boolean { + return (this._ngControl.control.touched || this._ngControl.control.dirty); + } + + private get hasValidators(): boolean { + return (!!this._ngControl.control.validator || !!this._ngControl.control.asyncValidator); + } + + private setMinMaxDropdownValue(type: string, time: Date): Date { + let delta: number; + + const sign = type === 'min' ? 1 : -1; + + const hours = time.getHours(); + let minutes = time.getMinutes(); + let seconds = time.getSeconds(); + + if (this.showHoursList && hours % this.itemsDelta.hours > 0) { + delta = type === 'min' ? this.itemsDelta.hours - hours % this.itemsDelta.hours + : hours % this.itemsDelta.hours; + minutes = type === 'min' ? 0 + : 60 % this.itemsDelta.minutes > 0 ? 60 - 60 % this.itemsDelta.minutes + : 60 - this.itemsDelta.minutes; + seconds = type === 'min' ? 0 + : 60 % this.itemsDelta.seconds > 0 ? 60 - 60 % this.itemsDelta.seconds + : 60 - this.itemsDelta.seconds; + time.setHours(hours + sign * delta, minutes, seconds); + } else if (this.showMinutesList && minutes % this.itemsDelta.minutes > 0) { + delta = type === 'min' ? this.itemsDelta.minutes - minutes % this.itemsDelta.minutes + : minutes % this.itemsDelta.minutes; + seconds = type === 'min' ? 0 + : 60 % this.itemsDelta.seconds > 0 ? 60 - 60 % this.itemsDelta.seconds + : 60 - this.itemsDelta.seconds; + time.setHours(hours, minutes + sign * delta, seconds); + } else if (this.showSecondsList && seconds % this.itemsDelta.seconds > 0) { + delta = type === 'min' ? this.itemsDelta.seconds - seconds % this.itemsDelta.seconds + : seconds % this.itemsDelta.seconds; + time.setHours(hours, minutes, seconds + sign * delta); + } + + return time; + } + + private initializeContainer() { + requestAnimationFrame(() => { + if (this.hourList) { + this.hourList.nativeElement.focus(); + } else if (this.minuteList) { + this.minuteList.nativeElement.focus(); + } else if (this.secondsList) { + this.secondsList.nativeElement.focus(); + } + }); + } + + private validateDropdownValue(date: Date, isAmPm = false): Date { + if (date > this.maxDropdownValue) { + if (isAmPm && date.getHours() !== this.maxDropdownValue.getHours()) { + date.setHours(12); + } else { + date = new Date(this.maxDropdownValue); + } + } + + if (date < this.minDropdownValue) { + date = new Date(this.minDropdownValue); + } + + return date; + } + + private emitValueChange(oldValue: Date | string, newValue: Date | string) { + if (!isEqual(oldValue, newValue)) { + this.valueChange.emit(newValue); + } + } + + private emitValidationFailedEvent(previousValue: Date | string) { + const args: IgxTimePickerValidationFailedEventArgs = { + owner: this, + previousValue, + currentValue: this.value + }; + this.validationFailed.emit(args); + } + + private updateValidityOnBlur() { + this._onTouchedCallback(); + if (this._ngControl) { + if (!this._ngControl.valid) { + this.inputDirective.valid = IgxInputState.INVALID; + } else { + this.inputDirective.valid = IgxInputState.INITIAL; + } + } + } + + private valueInRange(value: Date, minValue: Date, maxValue: Date): boolean { + if (minValue && DateTimeUtil.lessThanMinValue(value, minValue, true, false)) { + return false; + } + if (maxValue && DateTimeUtil.greaterThanMaxValue(value, maxValue, true, false)) { + return false; + } + + return true; + } + + private parseToDate(value: Date | string): Date | null { + return DateTimeUtil.isValidDate(value) ? value : DateTimeUtil.parseIsoDate(value); + } + + private toTwentyFourHourFormat(hour: number, ampm: string): number { + if (DateTimeUtil.isPm(ampm) && hour < 12) { + hour += 12; + } else if (DateTimeUtil.isAm(ampm) && hour === 12) { + hour = 0; + } + + return hour; + } + + private updateValue(newValue: Date | null): void { + if (!this.value) { + this.value = newValue ? new Date(newValue) : newValue; + } else if (isDate(this.value)) { + const date = new Date(this.value); + date.setHours(newValue?.getHours() || 0, newValue?.getMinutes() || 0, newValue?.getSeconds() || 0, newValue?.getMilliseconds() || 0); + this.value = date; + } else { + this.value = newValue ? this.toISOString(newValue) : newValue; + } + } + + private updateEditorValue(): void { + const date = this.dateTimeEditor.value ? new Date(this.dateTimeEditor.value) : new Date(); + date.setHours(this._selectedDate.getHours(), this._selectedDate.getMinutes(), this._selectedDate.getSeconds(), this._selectedDate.getMilliseconds()); + this.dateTimeEditor.value = date; + } + + private subscribeToDateEditorEvents(): void { + this.dateTimeEditor.valueChange.pipe( + // internal date editor directive is only used w/ Date object values: + takeUntil(this._destroy$)).subscribe((date: Date | null) => { + this.updateValue(date); + }); + + this.dateTimeEditor.validationFailed.pipe( + takeUntil(this._destroy$)).subscribe((event) => { + this.emitValidationFailedEvent(event.oldValue); + }); + } + + private subscribeToToggleDirectiveEvents(): void { + if (this.toggleRef) { + if (this._inputGroup && this.platform.isBrowser) { + this.toggleRef.element.style.width = this._inputGroup.element.nativeElement.getBoundingClientRect().width + 'px'; + } + + this.toggleRef.opening.pipe(takeUntil(this._destroy$)).subscribe((e) => { + const args: IBaseCancelableBrowserEventArgs = { owner: this, event: e.event, cancel: false }; + this.opening.emit(args); + e.cancel = args.cancel; + if (args.cancel) { + return; + } + this.initializeContainer(); + }); + + this.toggleRef.opened.pipe(takeUntil(this._destroy$)).subscribe(() => { + this.opened.emit({ owner: this }); + }); + + this.toggleRef.closed.pipe(takeUntil(this._destroy$)).subscribe(() => { + this.closed.emit({ owner: this }); + }); + + this.toggleRef.closing.pipe(takeUntil(this._destroy$)).subscribe((e) => { + const args: IBaseCancelableBrowserEventArgs = { owner: this, event: e.event, cancel: false }; + this.closing.emit(args); + e.cancel = args.cancel; + if (args.cancel) { + return; + } + const value = this.parseToDate(this.value); + if ((this.dateTimeEditor.value as Date)?.getTime() !== value?.getTime()) { + this.updateValue(this._selectedDate); + } + // Do not focus the input if clicking outside in dropdown mode + const input = this.getEditElement(); + if (input && !(e.event && this.isDropdown)) { + input.focus(); + } else { + this.updateValidityOnBlur(); + } + }); + } + } +} diff --git a/projects/igniteui-angular/time-picker/src/time-picker/time-picker.directives.ts b/projects/igniteui-angular/time-picker/src/time-picker/time-picker.directives.ts new file mode 100644 index 00000000000..73261ad080d --- /dev/null +++ b/projects/igniteui-angular/time-picker/src/time-picker/time-picker.directives.ts @@ -0,0 +1,380 @@ +/** + * This file contains all the directives used by the @link IgxTimePickerComponent. + * You should generally not use them directly. + * + * @preferred + */ +import { + Directive, + ElementRef, + HostBinding, + HostListener, + inject, + Input, + OnDestroy, + OnInit +} from '@angular/core'; +import { DateTimeUtil, HammerGesturesManager, HammerInput, HammerOptions } from 'igniteui-angular/core'; +import { IgxTimePickerBase, IGX_TIME_PICKER_COMPONENT } from './time-picker.common'; + +/** @hidden */ +@Directive({ + selector: '[igxItemList]', + providers: [HammerGesturesManager], + standalone: true +}) +export class IgxItemListDirective implements OnInit, OnDestroy { + public timePicker = inject(IGX_TIME_PICKER_COMPONENT); + private elementRef = inject(ElementRef); + private touchManager = inject(HammerGesturesManager); + + @HostBinding('attr.tabindex') + public tabindex = 0; + + @Input('igxItemList') + public type: string; + + public isActive: boolean; + + private readonly SCROLL_THRESHOLD = 50; + private readonly PAN_THRESHOLD = 10; + + /** + * accumulates wheel scrolls and triggers a change action above SCROLL_THRESHOLD + */ + private scrollAccumulator = 0; + + @HostBinding('class.igx-time-picker__column') + public get defaultCSS(): boolean { + return true; + } + + @HostBinding('class.igx-time-picker__hourList') + public get hourCSS(): boolean { + return this.type === 'hourList'; + } + + @HostBinding('class.igx-time-picker__minuteList') + public get minuteCSS(): boolean { + return this.type === 'minuteList'; + } + + @HostBinding('class.igx-time-picker__secondsList') + public get secondsCSS(): boolean { + return this.type === 'secondsList'; + } + + @HostBinding('class.igx-time-picker__ampmList') + public get ampmCSS(): boolean { + return this.type === 'ampmList'; + } + + @HostListener('focus') + public onFocus() { + this.isActive = true; + } + + @HostListener('blur') + public onBlur() { + this.isActive = false; + } + + /** + * @hidden + */ + @HostListener('keydown.arrowdown', ['$event']) + public onKeydownArrowDown(event: KeyboardEvent) { + event.preventDefault(); + + this.nextItem(1); + } + + /** + * @hidden + */ + @HostListener('keydown.arrowup', ['$event']) + public onKeydownArrowUp(event: KeyboardEvent) { + event.preventDefault(); + + this.nextItem(-1); + } + + /** + * @hidden + */ + @HostListener('keydown.arrowright', ['$event']) + public onKeydownArrowRight(event: KeyboardEvent) { + event.preventDefault(); + + const listName = (event.target as HTMLElement).className; + + if (listName.indexOf('hourList') !== -1 && this.timePicker.minuteList) { + this.timePicker.minuteList.nativeElement.focus(); + } else if ((listName.indexOf('hourList') !== -1 || listName.indexOf('minuteList') !== -1) && this.timePicker.secondsList) { + this.timePicker.secondsList.nativeElement.focus(); + } else if ((listName.indexOf('hourList') !== -1 || listName.indexOf('minuteList') !== -1 || + listName.indexOf('secondsList') !== -1) && this.timePicker.ampmList) { + this.timePicker.ampmList.nativeElement.focus(); + } + } + + /** + * @hidden + */ + @HostListener('keydown.arrowleft', ['$event']) + public onKeydownArrowLeft(event: KeyboardEvent) { + event.preventDefault(); + const listName = (event.target as HTMLElement).className; + + if (listName.indexOf('ampmList') !== -1 && this.timePicker.secondsList) { + this.timePicker.secondsList.nativeElement.focus(); + } else if (listName.indexOf('secondsList') !== -1 && this.timePicker.secondsList + && listName.indexOf('minutesList') && this.timePicker.minuteList) { + this.timePicker.minuteList.nativeElement.focus(); + } else if (listName.indexOf('ampmList') !== -1 && this.timePicker.minuteList) { + this.timePicker.minuteList.nativeElement.focus(); + } else if ((listName.indexOf('ampmList') !== -1 || listName.indexOf('secondsList') !== -1 || + listName.indexOf('minuteList') !== -1) && this.timePicker.hourList) { + this.timePicker.hourList.nativeElement.focus(); + } + } + + /** + * @hidden + */ + @HostListener('keydown.enter', ['$event']) + public onKeydownEnter(event: KeyboardEvent) { + event.preventDefault(); + this.timePicker.okButtonClick(); + } + + /** + * @hidden + */ + @HostListener('keydown.escape', ['$event']) + public onKeydownEscape(event: KeyboardEvent) { + event.preventDefault(); + + this.timePicker.cancelButtonClick(); + } + + /** + * @hidden + */ + @HostListener('mouseover') + public onHover() { + this.elementRef.nativeElement.focus(); + } + + /** + * @hidden + */ + @HostListener('wheel', ['$event']) + public onScroll(event) { + event.preventDefault(); + event.stopPropagation(); + + this.scrollAccumulator += event.deltaY; + if (Math.abs(this.scrollAccumulator) > this.SCROLL_THRESHOLD) { + this.nextItem(this.scrollAccumulator); + this.scrollAccumulator = 0; + } + } + + /** + * @hidden @internal + */ + public ngOnInit() { + const hammerOptions: HammerOptions = { + recognizers: [ + [ + HammerGesturesManager.Hammer?.Pan, + { + direction: HammerGesturesManager.Hammer?.DIRECTION_VERTICAL, + threshold: this.PAN_THRESHOLD + } + ] + ] + }; + this.touchManager.addEventListener(this.elementRef.nativeElement, 'pan', this.onPanMove, hammerOptions); + } + + /** + * @hidden @internal + */ + public ngOnDestroy() { + this.touchManager.destroy(); + } + + private onPanMove = (event: HammerInput) => { + const delta = event.deltaY < 0 ? -1 : event.deltaY > 0 ? 1 : 0; + if (delta !== 0) { + this.nextItem(delta); + } + }; + + private nextItem(delta: number): void { + switch (this.type) { + case 'hourList': { + this.timePicker.nextHour(delta); + break; + } + case 'minuteList': { + this.timePicker.nextMinute(delta); + break; + } + case 'secondsList': { + this.timePicker.nextSeconds(delta); + break; + } + case 'ampmList': { + this.timePicker.nextAmPm(delta); + break; + } + } + } +} + +/** + * @hidden + */ +@Directive({ + selector: '[igxTimeItem]', + exportAs: 'timeItem', + standalone: true +}) +export class IgxTimeItemDirective { + public timePicker = inject(IGX_TIME_PICKER_COMPONENT); + private itemList = inject(IgxItemListDirective); + + @Input('igxTimeItem') + public value: string; + + @HostBinding('class.igx-time-picker__item') + public get defaultCSS(): boolean { + return true; + } + + @HostBinding('class.igx-time-picker__item--selected') + public get selectedCSS(): boolean { + return this.isSelectedTime; + } + + @HostBinding('class.igx-time-picker__item--active') + public get activeCSS(): boolean { + return this.isSelectedTime && this.itemList.isActive; + } + + public get isSelectedTime(): boolean { + const currentValue = this.value.length < 2 ? `0${this.value}` : this.value; + const dateType = this.itemList.type; + const inputDateParts = DateTimeUtil.parseDateTimeFormat(this.timePicker.appliedFormat); + switch (dateType) { + case 'hourList': + const hourPart = inputDateParts.find(element => element.type === 'hours'); + return DateTimeUtil.getPartValue(this.timePicker.selectedDate, hourPart, hourPart.format.length) === currentValue; + case 'minuteList': + const minutePart = inputDateParts.find(element => element.type === 'minutes'); + return DateTimeUtil.getPartValue(this.timePicker.selectedDate, minutePart, minutePart.format.length) === currentValue; + case 'secondsList': + const secondsPart = inputDateParts.find(element => element.type === 'seconds'); + return DateTimeUtil.getPartValue(this.timePicker.selectedDate, secondsPart, secondsPart.format.length) === currentValue; + case 'ampmList': + const ampmPart = inputDateParts.find(element => element.format.indexOf('a') !== -1 || element.format === 'tt'); + return DateTimeUtil.getPartValue(this.timePicker.selectedDate, ampmPart, ampmPart.format.length) === this.value; + } + } + + public get minValue(): string { + const dateType = this.itemList.type; + const inputDateParts = DateTimeUtil.parseDateTimeFormat(this.timePicker.appliedFormat); + switch (dateType) { + case 'hourList': + return this.getHourPart(this.timePicker.minDropdownValue); + case 'minuteList': + if (this.timePicker.selectedDate.getHours() === this.timePicker.minDropdownValue.getHours()) { + const minutePart = inputDateParts.find(element => element.type === 'minutes'); + return DateTimeUtil.getPartValue(this.timePicker.minDropdownValue, minutePart, minutePart.format.length); + } + return '00'; + case 'secondsList': + const date = new Date(this.timePicker.selectedDate); + const min = new Date(this.timePicker.minDropdownValue); + date.setSeconds(0); + min.setSeconds(0); + if (date.getTime() === min.getTime()) { + const secondsPart = inputDateParts.find(element => element.type === 'seconds'); + return DateTimeUtil.getPartValue(this.timePicker.minDropdownValue, secondsPart, secondsPart.format.length); + } + return '00'; + case 'ampmList': + const ampmPart = inputDateParts.find(element => element.format.indexOf('a') !== -1 || element.format === 'tt'); + return DateTimeUtil.getPartValue(this.timePicker.minDropdownValue, ampmPart, ampmPart.format.length); + } + } + + public get maxValue(): string { + const dateType = this.itemList.type; + const inputDateParts = DateTimeUtil.parseDateTimeFormat(this.timePicker.appliedFormat); + switch (dateType) { + case 'hourList': + return this.getHourPart(this.timePicker.maxDropdownValue); + case 'minuteList': + if (this.timePicker.selectedDate.getHours() === this.timePicker.maxDropdownValue.getHours()) { + const minutePart = inputDateParts.find(element => element.type === 'minutes'); + return DateTimeUtil.getPartValue(this.timePicker.maxDropdownValue, minutePart, minutePart.format.length); + } else { + const currentTime = new Date(this.timePicker.selectedDate); + const minDelta = this.timePicker.itemsDelta.minutes; + const remainder = 60 % minDelta; + const delta = remainder === 0 ? 60 - minDelta : 60 - remainder; + currentTime.setMinutes(delta); + const minutePart = inputDateParts.find(element => element.type === 'minutes'); + return DateTimeUtil.getPartValue(currentTime, minutePart, minutePart.format.length); + } + case 'secondsList': + const date = new Date(this.timePicker.selectedDate); + const max = new Date(this.timePicker.maxDropdownValue); + date.setSeconds(0); + max.setSeconds(0); + if (date.getTime() === max.getTime()) { + const secondsPart = inputDateParts.find(element => element.type === 'seconds'); + return DateTimeUtil.getPartValue(this.timePicker.maxDropdownValue, secondsPart, secondsPart.format.length); + } else { + const secDelta = this.timePicker.itemsDelta.seconds; + const remainder = 60 % secDelta; + const delta = remainder === 0 ? 60 - secDelta : 60 - remainder; + date.setSeconds(delta); + const secondsPart = inputDateParts.find(element => element.type === 'seconds'); + return DateTimeUtil.getPartValue(date, secondsPart, secondsPart.format.length); + } + case 'ampmList': + const ampmPart = inputDateParts.find(element => element.format.indexOf('a') !== -1 || element.format === 'tt'); + return DateTimeUtil.getPartValue(this.timePicker.maxDropdownValue, ampmPart, ampmPart.format.length); + } + } + + public get hourValue(): string { + return this.getHourPart(this.timePicker.selectedDate); + } + + @HostListener('click', ['value']) + public onClick(item) { + if (item !== '') { + const dateType = this.itemList.type; + this.timePicker.onItemClick(item, dateType); + } + } + + private getHourPart(date: Date): string { + const inputDateParts = DateTimeUtil.parseDateTimeFormat(this.timePicker.appliedFormat); + const hourPart = inputDateParts.find(element => element.type === 'hours'); + const ampmPart = inputDateParts.find(element => element.format.indexOf('a') !== -1 || element.format === 'tt'); + const hour = DateTimeUtil.getPartValue(date, hourPart, hourPart.format.length); + if (ampmPart) { + const ampm = DateTimeUtil.getPartValue(date, ampmPart, ampmPart.format.length); + return `${hour} ${ampm}`; + } + return hour; + } +} diff --git a/projects/igniteui-angular/time-picker/src/time-picker/time-picker.module.ts b/projects/igniteui-angular/time-picker/src/time-picker/time-picker.module.ts new file mode 100644 index 00000000000..2a3b62eaf35 --- /dev/null +++ b/projects/igniteui-angular/time-picker/src/time-picker/time-picker.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { IGX_TIME_PICKER_DIRECTIVES } from './public_api'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + ...IGX_TIME_PICKER_DIRECTIVES + ], + exports: [ + ...IGX_TIME_PICKER_DIRECTIVES + ] +}) +export class IgxTimePickerModule { } diff --git a/projects/igniteui-angular/time-picker/src/time-picker/time-picker.pipes.ts b/projects/igniteui-angular/time-picker/src/time-picker/time-picker.pipes.ts new file mode 100644 index 00000000000..61a44fb3c33 --- /dev/null +++ b/projects/igniteui-angular/time-picker/src/time-picker/time-picker.pipes.ts @@ -0,0 +1,214 @@ +import { Pipe, PipeTransform, inject } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { IGX_TIME_PICKER_COMPONENT, IgxTimePickerBase } from './time-picker.common'; +import { DatePart, DateTimeUtil } from 'igniteui-angular/core'; + +const ITEMS_COUNT = 7; + +@Pipe({ + name: 'timeFormatPipe', + standalone: true +}) +export class TimeFormatPipe implements PipeTransform { + private timePicker = inject(IGX_TIME_PICKER_COMPONENT); + + + public transform(value: Date): string { + const format = this.timePicker.appliedFormat.replace('tt', 'aa'); + const datePipe = new DatePipe(this.timePicker.locale); + return datePipe.transform(value, format); + } +} + +@Pipe({ + name: 'timeItemPipe', + standalone: true +}) +export class TimeItemPipe implements PipeTransform { + private timePicker = inject(IGX_TIME_PICKER_COMPONENT); + + + public transform(_collection: any[], timePart: string, selectedDate: Date, min: Date, max: Date) { + let list; + let part; + switch (timePart) { + case 'hour': + list = this.generateHours(min, max); + const hours = this.timePicker.isTwelveHourFormat ? this.toTwelveHourFormat(selectedDate.getHours()) + : selectedDate.getHours(); + list = this.scrollListItem(hours, list); + part = DatePart.Hours; + break; + case 'minutes': + list = this.generateMinutes(selectedDate, min, max); + list = this.scrollListItem(selectedDate.getMinutes(), list); + part = DatePart.Minutes; + break; + case 'seconds': + list = this.generateSeconds(selectedDate, min, max); + list = this.scrollListItem(selectedDate.getSeconds(), list); + part = DatePart.Seconds; + break; + case 'ampm': + const selectedAmPm = this.timePicker.getPartValue(selectedDate, 'ampm'); + list = this.generateAmPm(min, max, selectedAmPm); + list = this.scrollListItem(selectedAmPm, list); + part = DatePart.AmPm; + break; + } + return this.getListView(list, part); + } + + private getListView(view: any, dateType: DatePart): any { + for (let i = 0; i < view.length; i++) { + view[i] = this.getItemView(view[i], dateType); + } + return view; + } + + private getItemView(item: any, dateType: DatePart): string { + if (item === null) { + item = ''; + } else if (dateType && typeof (item) !== 'string') { + const leadZeroHour = (item < 10 && (this.timePicker.appliedFormat?.indexOf('hh') !== -1 + || this.timePicker.appliedFormat?.indexOf('HH') !== -1)); + const leadZeroMinute = (item < 10 && this.timePicker.appliedFormat?.indexOf('mm') !== -1); + const leadZeroSeconds = (item < 10 && this.timePicker.appliedFormat?.indexOf('ss') !== -1); + + const leadZero = { + hours: leadZeroHour, + minutes: leadZeroMinute, + seconds: leadZeroSeconds + }[dateType]; + + item = (leadZero) ? '0' + item : `${item}`; + } + return item; + } + + private scrollListItem(item: number | string, items: any[]): any[] { + const itemsCount = items.length; + let view; + if (items) { + const index = items.indexOf(item); + if (index < 3) { + view = items.slice(itemsCount - (3 - index), itemsCount); + view = view.concat(items.slice(0, index + 4)); + } else if (index + 4 > itemsCount) { + view = items.slice(index - 3, itemsCount); + view = view.concat(items.slice(0, index + 4 - itemsCount)); + } else { + view = items.slice(index - 3, index + 4); + } + } + return view; + } + + private generateHours(min: Date, max: Date): any[] { + const hourItems = []; + let hoursCount = this.timePicker.isTwelveHourFormat ? 13 : 24; + hoursCount /= this.timePicker.itemsDelta.hours; + const minHours = min.getHours(); + const maxHours = max.getHours(); + + if (hoursCount > 1) { + for (let hourIndex = 0; hourIndex < 24; hourIndex++) { + let hours = hourIndex * this.timePicker.itemsDelta.hours; + if (hours >= minHours && hours <= maxHours) { + hours = this.timePicker.isTwelveHourFormat ? this.toTwelveHourFormat(hours) : hours; + if (!hourItems.find((element => element === hours))) { + hourItems.push(hours); + } + } + } + } else { + hourItems.push(0); + } + + if (hourItems.length < ITEMS_COUNT || hoursCount < ITEMS_COUNT || !this.timePicker.spinLoop) { + const index = !this.timePicker.spinLoop || (hourItems.length < ITEMS_COUNT && hoursCount < ITEMS_COUNT) ? 6 : 3; + for (let i = 0; i < index; i++) { + hourItems.push(null); + } + } + + return hourItems; + } + + private generateMinutes(time: Date, min: Date, max: Date): any[] { + const minuteItems = []; + const minuteItemsCount = 60 / this.timePicker.itemsDelta.minutes; + time = new Date(time); + + for (let i = 0; i < minuteItemsCount; i++) { + const minutes = i * this.timePicker.itemsDelta.minutes; + time.setMinutes(minutes); + if (time >= min && time <= max) { + minuteItems.push(i * this.timePicker.itemsDelta.minutes); + } + } + + if (minuteItems.length < ITEMS_COUNT || minuteItemsCount < ITEMS_COUNT || !this.timePicker.spinLoop) { + const index = !this.timePicker.spinLoop || (minuteItems.length < ITEMS_COUNT && minuteItemsCount < ITEMS_COUNT) ? 6 : 3; + for (let i = 0; i < index; i++) { + minuteItems.push(null); + } + } + + return minuteItems; + } + + private generateSeconds(time: Date, min: Date, max: Date): any[] { + const secondsItems = []; + const secondsItemsCount = 60 / this.timePicker.itemsDelta.seconds; + time = new Date(time); + + for (let i = 0; i < secondsItemsCount; i++) { + const seconds = i * this.timePicker.itemsDelta.seconds; + time.setSeconds(seconds); + if (time.getTime() >= min.getTime() + && time.getTime() <= max.getTime()) { + secondsItems.push(i * this.timePicker.itemsDelta.seconds); + } + } + + if (secondsItems.length < ITEMS_COUNT || secondsItemsCount < ITEMS_COUNT || !this.timePicker.spinLoop) { + const index = !this.timePicker.spinLoop || (secondsItems.length < ITEMS_COUNT && secondsItemsCount < ITEMS_COUNT) ? 6 : 3; + for (let i = 0; i < index; i++) { + secondsItems.push(null); + } + } + + return secondsItems; + } + + private generateAmPm(min: Date, max: Date, selectedAmPm: string): any[] { + const ampmItems = []; + const minHour = min.getHours(); + const maxHour = max.getHours(); + + if (minHour < 12) { + ampmItems.push(DateTimeUtil.getAmPmValue(selectedAmPm.length, true)); + } + + if (minHour >= 12 || maxHour >= 12) { + ampmItems.push(DateTimeUtil.getAmPmValue(selectedAmPm.length, false)); + } + + for (let i = 0; i < 5; i++) { + ampmItems.push(null); + } + + return ampmItems; + } + + private toTwelveHourFormat(hour: number): number { + if (hour > 12) { + hour -= 12; + } else if (hour === 0) { + hour = 12; + } + + return hour; + } +} diff --git a/projects/igniteui-angular/toast/README.md b/projects/igniteui-angular/toast/README.md new file mode 100644 index 00000000000..dae0735b6af --- /dev/null +++ b/projects/igniteui-angular/toast/README.md @@ -0,0 +1,62 @@ +# igx-toast + +The Toast component shows application messages in a stylized pop-up box positioned inside the global overlay outlet(default). Toasts can't be dismissed, they are non-interactive and can appear on top, middle, and the bottom of the screen. A walkthrough on how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/toast) + +# Usage + +## Simple Toast + +```html + + + +Well, hi there! +``` + +You can set the id of the component by setting the attribute `id` on the component (e.g. `id="myToast"`), or it will be automatically generated for you if you don't provide anything; + +The toast can be shown by using the `open()` method. + +You can hide the toast by using the `close()` method. + +## Toast Position +You can set the `positon` property to `top`, `middle`, or `bottom`, which will position the toast near the top, middle, or bottom of the document*. + +*By default the toast renders inside a global overlay outlet. You can specify a different overlay outlet by setting the `outlet` property on the toast; + +```html + +Top Positioned Toast +``` + +## Toast with different content + +```html + + notifications + This message will self-destruct in 4 seconds. + +``` + +You can display various content by placing it between the `igx-toast` tags. + +## Toast Events + +```html + + + + +``` + +You can handle the onShowing event by using `(onShowing)="someFunc($event)"`. +You can handle the onShown event by using `(onShowing)="someFunc($event)"`. +You can handle the onHiding event by using `(onHiding)="someFunc($event)"`. +You can handle the onHidden event by using `(onHidden)="someFunc($event)"`. diff --git a/projects/igniteui-angular/toast/index.ts b/projects/igniteui-angular/toast/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/toast/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/toast/ng-package.json b/projects/igniteui-angular/toast/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/toast/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/toast/src/public_api.ts b/projects/igniteui-angular/toast/src/public_api.ts new file mode 100644 index 00000000000..993721a984b --- /dev/null +++ b/projects/igniteui-angular/toast/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './toast/public_api'; +export * from './toast/toast.module'; diff --git a/projects/igniteui-angular/toast/src/toast/public_api.ts b/projects/igniteui-angular/toast/src/toast/public_api.ts new file mode 100644 index 00000000000..6502de796e0 --- /dev/null +++ b/projects/igniteui-angular/toast/src/toast/public_api.ts @@ -0,0 +1 @@ +export * from './toast.component'; diff --git a/projects/igniteui-angular/toast/src/toast/toast.component.html b/projects/igniteui-angular/toast/src/toast/toast.component.html new file mode 100644 index 00000000000..41f91dbada0 --- /dev/null +++ b/projects/igniteui-angular/toast/src/toast/toast.component.html @@ -0,0 +1,3 @@ + +{{ textMessage }} + diff --git a/projects/igniteui-angular/toast/src/toast/toast.component.spec.ts b/projects/igniteui-angular/toast/src/toast/toast.component.spec.ts new file mode 100644 index 00000000000..72640693dcc --- /dev/null +++ b/projects/igniteui-angular/toast/src/toast/toast.component.spec.ts @@ -0,0 +1,89 @@ +import { + waitForAsync, + TestBed, + ComponentFixture, + flushMicrotasks, + fakeAsync, +} from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { + IgxToastComponent +} from './toast.component'; +import { HorizontalAlignment, PositionSettings, VerticalAlignment } from 'igniteui-angular/core';; + +describe('IgxToast', () => { + let fixture: ComponentFixture; + let toast: IgxToastComponent; + const firstPositionSettings: PositionSettings = { + horizontalDirection: HorizontalAlignment.Left, + verticalDirection: VerticalAlignment.Middle, + horizontalStartPoint: HorizontalAlignment.Left, + verticalStartPoint: VerticalAlignment.Middle + }; + const secondPositionSettings: PositionSettings = { + horizontalDirection: HorizontalAlignment.Center, + verticalDirection: VerticalAlignment.Middle, + horizontalStartPoint: HorizontalAlignment.Center, + verticalStartPoint: VerticalAlignment.Middle + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, IgxToastComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(IgxToastComponent); + toast = fixture.componentInstance; + // For test fixture destroy + toast.id = "root1"; + fixture.detectChanges(); + }); + + it('should properly initialize', () => { + toast.id = 'customToast'; + fixture.detectChanges(); + + expect(toast.id).toBe('customToast'); + expect(toast.element.id).toContain('customToast'); + // For test fixture destroy + toast.id = "root1"; + fixture.detectChanges(); + }); + + it('should properly toggle and emit isVisibleChange', fakeAsync(() => { + spyOn(toast.isVisibleChange, 'emit').and.callThrough(); + expect(toast.isVisible).toBe(false); + expect(toast.isVisibleChange.emit).toHaveBeenCalledTimes(0); + + toast.toggle(); + expect(toast.isVisible).toBe(true); + flushMicrotasks(); + expect(toast.isVisibleChange.emit).toHaveBeenCalledOnceWith({ owner: toast, id: '0' }); + + toast.toggle(); + flushMicrotasks(); + expect(toast.isVisible).toBe(false); + expect(toast.isVisibleChange.emit).toHaveBeenCalledTimes(2); + })); + + it('should be able to change positionSettings', () => { + toast.positionSettings = firstPositionSettings; + expect(toast.positionSettings.horizontalDirection).toBe(-1); + expect(toast.positionSettings.verticalDirection).toBe(-0.5); + toast.positionSettings = secondPositionSettings; + fixture.detectChanges(); + expect(toast.positionSettings.horizontalDirection).toBe(-0.5); + expect(toast.positionSettings.verticalDirection).toBe(-0.5); + }); + + it('positionSettings passed in the open method should be applied', () => { + const positions = secondPositionSettings; + toast.open("New Message", positions); + fixture.detectChanges(); + expect(toast.positionSettings.horizontalDirection).toBe(-0.5); + expect(toast.positionSettings.verticalDirection).toBe(-0.5); + expect(toast.textMessage).toBe("New Message"); + }); +}); diff --git a/projects/igniteui-angular/toast/src/toast/toast.component.ts b/projects/igniteui-angular/toast/src/toast/toast.component.ts new file mode 100644 index 00000000000..6f0ba09f023 --- /dev/null +++ b/projects/igniteui-angular/toast/src/toast/toast.component.ts @@ -0,0 +1,184 @@ +import { Component, ElementRef, EventEmitter, HostBinding, Input, OnInit, Output, inject } from '@angular/core'; +import { takeUntil } from 'rxjs/operators'; +import { + HorizontalAlignment, + VerticalAlignment, + GlobalPositionStrategy, + PositionSettings +} from 'igniteui-angular/core'; +import { IgxNotificationsDirective } from 'igniteui-angular/directives'; +import { ToggleViewEventArgs } from 'igniteui-angular/directives'; +import { useAnimation } from '@angular/animations'; +import { fadeIn, fadeOut } from 'igniteui-angular/animations'; + +let NEXT_ID = 0; + +/** + * **Ignite UI for Angular Toast** - + * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/toast) + * + * The Ignite UI Toast provides information and warning messages that are non-interactive and cannot + * be dismissed by the user. Toasts can be displayed at the bottom, middle, or top of the page. + * + * Example: + * ```html + * + * + * Notification displayed + * + * ``` + */ +@Component({ + selector: 'igx-toast', + templateUrl: 'toast.component.html', + standalone: true +}) +export class IgxToastComponent extends IgxNotificationsDirective implements OnInit { + private _element = inject(ElementRef); + + /** + * @hidden + */ + @HostBinding('class.igx-toast') + public cssClass = 'igx-toast'; + + /** + * Sets/gets the `id` of the toast. + * If not set, the `id` will have value `"igx-toast-0"`. + * ```html + * + * ``` + * ```typescript + * let toastId = this.toast.id; + * ``` + */ + @HostBinding('attr.id') + @Input() + public override id = `igx-toast-${NEXT_ID++}`; + + /** + * Sets/gets the `role` attribute. + * If not set, `role` will have value `"alert"`. + * ```html + * + * ``` + * ```typescript + * let toastRole = this.toast.role; + * ``` + * + * @memberof IgxToastComponent + */ + @HostBinding('attr.role') + @Input() + public role = 'alert'; + + /** + * @hidden + */ + @Output() + public isVisibleChange = new EventEmitter(); + + /** + * Get the position and animation settings used by the toast. + * ```typescript + * @ViewChild('toast', { static: true }) public toast: IgxToastComponent; + * let currentPosition: PositionSettings = this.toast.positionSettings + * ``` + */ + @Input() + public get positionSettings(): PositionSettings { + return this._positionSettings; + } + + /** + * Set the position and animation settings used by the toast. + * ```html + * + * ``` + * ```typescript + * import { slideInTop, slideOutBottom } from 'igniteui-angular'; + * ... + * @ViewChild('toast', { static: true }) public toast: IgxToastComponent; + * public newPositionSettings: PositionSettings = { + * openAnimation: useAnimation(slideInTop, { params: { duration: '1000ms', fromPosition: 'translateY(100%)'}}), + * closeAnimation: useAnimation(slideOutBottom, { params: { duration: '1000ms', fromPosition: 'translateY(0)'}}), + * horizontalDirection: HorizontalAlignment.Left, + * verticalDirection: VerticalAlignment.Middle, + * horizontalStartPoint: HorizontalAlignment.Left, + * verticalStartPoint: VerticalAlignment.Middle + * }; + * this.toast.positionSettings = this.newPositionSettings; + * ``` + */ + public set positionSettings(settings: PositionSettings) { + this._positionSettings = settings; + } + + private _positionSettings: PositionSettings = { + horizontalDirection: HorizontalAlignment.Center, + verticalDirection: VerticalAlignment.Bottom, + openAnimation: useAnimation(fadeIn), + closeAnimation: useAnimation(fadeOut), + }; + + /** + * Gets the nativeElement of the toast. + * ```typescript + * let nativeElement = this.toast.element; + * ``` + * + * @memberof IgxToastComponent + */ + public override get element() { + return this._element.nativeElement; + } + + /** + * Shows the toast. + * If `autoHide` is enabled, the toast will hide after `displayTime` is over. + * + * ```typescript + * this.toast.open(); + * ``` + */ + public override open(message?: string, settings?: PositionSettings) { + if (message !== undefined) { + this.textMessage = message; + } + if (settings !== undefined) { + this.positionSettings = settings; + } + this.strategy = new GlobalPositionStrategy(this.positionSettings); + super.open(); + } + + /** + * Opens or closes the toast, depending on its current state. + * + * ```typescript + * this.toast.toggle(); + * ``` + */ + public override toggle() { + if (this.collapsed || this.isClosing) { + this.open(); + } else { + this.close(); + } + } + + /** + * @hidden + */ + public override ngOnInit() { + this.opened.pipe(takeUntil(this.destroy$)).subscribe(() => { + const openedEventArgs: ToggleViewEventArgs = { owner: this, id: this._overlayId }; + this.isVisibleChange.emit(openedEventArgs); + }); + + this.closed.pipe(takeUntil(this.destroy$)).subscribe(() => { + const closedEventArgs: ToggleViewEventArgs = { owner: this, id: this._overlayId }; + this.isVisibleChange.emit(closedEventArgs); + }); + } +} diff --git a/projects/igniteui-angular/toast/src/toast/toast.module.ts b/projects/igniteui-angular/toast/src/toast/toast.module.ts new file mode 100644 index 00000000000..bb8401b2dcd --- /dev/null +++ b/projects/igniteui-angular/toast/src/toast/toast.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { IgxToastComponent } from './toast.component'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + IgxToastComponent + ], + exports: [ + IgxToastComponent + ] +}) +export class IgxToastModule { } diff --git a/projects/igniteui-angular/tree/README.md b/projects/igniteui-angular/tree/README.md new file mode 100644 index 00000000000..8f10ca1b8f5 --- /dev/null +++ b/projects/igniteui-angular/tree/README.md @@ -0,0 +1,158 @@ +# IgxTreeComponent + +## Description +_igx-tree component allows you to render hierarchical data in an easy-to-navigate view. Declaring a tree is done by using `igx-tree` and specifying its `igx-tree-nodes`:_ + +- *`igx-tree`* - The tree container. Consists of a tree root that renders all passed `igx-tree-node`s +- *`igx-tree-node`* - A single node for the tree. Renders its content as-is. Houses other `igx-tree-node`s. +- *`[igxTreeNodeLink]`* - A directive that should be put on **any** link child of an `igx-tree-node`, to ensure proper ARIA attributes and navigation +- *`[igxTreeNodeExpandIndicator]`* - A directive that can be passed to an `ng-template` within the `igx-tree`. The template will be used to render parent nodes' `expandIndicator` + +A complete walkthrough of how to get started can be found [here](https://www.infragistics.com/products/ignite-ui-angular/angular/components/tree). +The specification for the tree can be found [here](https://github.com/IgniteUI/igniteui-angular/wiki/Tree-Specification) + +---------- + +## Usage +```html + + + {{ node.text }} + node.imageAlt + + {{ child.text }} + + {{ leafChild.text }} + + + + +``` + +---------- + +## Keyboard Navigation + +The keyboard can be used to navigate through all nodes in the tree. +The control distinguishes two states - `focused` and `active`. +The focused node is where all events are fired and from where navigation will begin/continue. Focused nodes are marked with a distinct style. +The active node, in most cases, is the last node on which user interaction took place. Active nodes also have a distinct style. Active nodes can be used to better accent a node in that tree that indicates the app's current state (e.g. a current route in the app when using a tree as a navigation component). + +In most cases, moving the focused node also moves the active node. + +When navigating to nodes that are outside of view, if the tree (`igx-tree` tag) has a scrollbar, scrolls the focused node into view. +When finishing state transition animations (expand/collapse), if the target node is outside of view AND if the tree (`igx-tree` tag) has a scrollbar, scrolls the focused node into view. +When initializing the tree and a node is marked as active, if that node is outside of view AND if the tree (`igx-tree` tag) has a scrollbar, scrolls the activated node into view. + +_FIRST and LAST node refers to the respective visible node WITHOUT expanding/collapsing any existing node._ + +_Disabled nodes are not counted as visible nodes for the purpose of keyboard navigation._ + +|Keys |Description| Activates Node | +|---------------|-----------|-----------| +| ARROW DOWN | Moves to the next visible node. Does nothing if on the LAST node. | true | +| CTRL + ARROW DOWN | Performs the same as ARROW DOWN. | false | +| ARROW UP | Moves to the previous visible node. Does nothing if on the FIRST node. | true | +| CTRL + ARROW UP | Performs the same as ARROW UP. | false | +| TAB | Navigate to the next focusable element on the page, outside of the tree.* | false | +| SHIFT + TAB | Navigate to the previous focusable element on the page, outside of the tree.* | false | +| HOME | Navigates to the FIRST node. | true | +| END | Navigates to the LAST node. | true | +| ARROW RIGHT | On an **expanded** parent node, navigates to the first child of the node. If on a **collapsed** parent node, expands it. | true | +| ARROW LEFT | On an **expanded** parent node, collapses it. If on a child node, moves to its parent node. | true | +| SPACE | Toggles selection of the current node. Marks the node as active. | true | +| * | Expand the node and all sibling nodes on the same level w/ children | true | +| CLICK | Focuses the node | true | + +When selection is enabled, end-user selection of nodes is **only allowed through the displayed checkbox**. Since both selection types allow multiple selection, the following mouse + keyboard interaction is available: + +| Combination |Description| Activates Node | +|---------------|-----------|-----------| +| SHIFT + CLICK / SPACE | when multiple selection is enabled, toggles selection of all nodes between the active one and the one clicked while holding SHIFT. | true | + +---------- + +## API Summary + +### IgxTreeComponent + +#### Accessors + +**Get** + + | Name | Description | Type | + |----------------|------------------------------------------------------------------------------|---------------------| + | rootNodes | Returns all of the tree's nodes that are on root level | `IgxTreeNodeComponent[]` | + + +#### Properties + + | Name | Description | Type | + |----------------|------------------------------------------------------------------------------|----------------------------------------| + | selection | The selection state of the tree | `"None"` \| `"BiState"` \| `"Cascading"` | + | animationSettings | The setting for the animation when opening / closing a node | `{ openAnimation: AnimationMetadata, closeAnimation: AnimationMetadata }` | + | singleBranchExpand | Whether a single or multiple of a parent's child nodes can be expanded. Default is `false` | `boolean` | + | expandIndicator | Get\Set a reference to a custom template that should be used for rendering the expand/collapse indicators of nodes. | `TemplateRef` | + +#### Methods + | Name | Description | Parameters | Returns | + |-----------------|----------------------------|-------------------------|--------| + | findNodes | Returns an array of nodes which match the specified data. `[data]` input should be specified in order to find nodes. A custom comparer function can be specified for custom search (e.g. by a specific value key). Returns `null` if **no** nodes match | `data: T\|, comparer?: (data: T, node: IgxTreeNodeComponent) => boolean` | `IgxTreeNodeComponent[]` \| `null` | + | deselectAll | Deselects all nodes. If a nodes array is passed, deselects only the specified nodes. **Does not** emit `nodeSelection` event. | `nodes?: IgxTreeNodeComponent[]` | `void` | + | collapseAll | Collapses the specified nodes. If no nodes passed, collapses **all parent nodes**. | `nodes?: IgxTreeNodeComponent[]` | `void` | + | expandAll | Sets the specified nodes as expanded. If no nodes passed, expands **all parent nodes**. | `nodes?: IgxTreeNodeComponent[]` | `void` | + +#### Events + + | Name | Description | Cancelable | Arguments | + |----------------|-------------------------------------------------------------------------|------------|------------| + | nodeSelection | Emitted when item selection is changing, before the selection completes | true | `{ owner: IgxTreeComponent, newSelection: IgxTreeNodeComponent[], oldSelection: IgxTreeNodeComponent[], added: IgxTreeNodeComponent[], removed: IgxTreeNodeComponent[], cancel: true }` | + | nodeCollapsed | Emitted when node collapsing animation finishes and node is collapsed. | false | `{ node: IgxTreeNodeComponent, owner: IgxTreeComponent }` | + | nodeCollapsing | Emitted when node collapsing animation starts, when `node.expanded` is set to transition from `true` to `false`. | true | `{ node: IgxTreeNodeComponent, owner: IgxTreeComponent, cancel: boolean }` | + | nodeExpanded | Emitted when node expanding animation finishes and node is expanded. | false | `{ node: IgxTreeNodeComponent, owner: IgxTreeComponent }` | + | nodeExpanding | Emitted when node expanding animation starts, when `node.expanded` is set to transition from `false` to `true`. | true | `node: IgxTreeNodeComponent, owner: IgxTreeComponent, cancel: boolean }` | + | activeNodeChanged | Emitted when the tree's `active` node changes | false | `IgxTreeNodeComponent` | + +### IgxTreeNodeComponent + +#### Accessors + +**Get** + + | Name | Description | Type | + |-----------------|-------------------------------------------------------------------------------|---------------------| + | parentNode | The parent node of the current node (if any) | `IgxTreeNodeComponent` | + | path | The full path to the node, starting from the top-most ancestor | `IgxTreeNodeComponent[]` | + | level | The "depth" of the node. If root node - 0, if a child of parent - `parent.level` + 1 | `number` | + | tree | A reference to the tree the node is a part of | `IgxTreecomponent` | + | children | A collection of child nodes. `null` if node does not have children | `IgxTreeNodeComponent[]` \| `null` | + +#### Properties + + | Name | Description | Type | + |-----------------|-------------------------------------------------------------------------------|---------------------| + | disabled | Get/Set whether the node is disabled. Disabled nodes are ignore for user interactions. | `boolean` | + | expanded | The node expansion state. Does not trigger animation. | `boolean` \| `null` | + | selected | The node selection state. | `boolean` | + | data | The data entry that the node is visualizing. Required for searching through nodes. | `T` | + | active | Marks the node as the tree's active node | `boolean` | + | resourceStrings | An accessor for the current resource strings used for the node | `ITreeResourceStrings` | + | loading | Specifies whether the node is loading data. Loading nodes do not render children. To be used for load-on-demand scenarios | `boolean` | + + +#### Methods + + | Name | Description | Parameters | Returns | + |-----------------|-------------------------------------------------------------------------------|------------|---------| + | expand | Expands the node, triggering animations | None | `void` | + | collapse | Collapses the node, triggering animations | None | `void` | + | toggle| Toggles node expansion state, triggering animations | None | `void` |\ + +#### Events + + | Name | Description | Cancelable | Parameters | + |-----------------|-------------------------------------------------------------------------------|------------|---------| + | expandedChange | Emitted when the node's `expanded` property changes | false | `boolean` | + | selectedChange | Emitted when the node's `selected` property changes | false | `boolean` | + + diff --git a/projects/igniteui-angular/tree/index.ts b/projects/igniteui-angular/tree/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/tree/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/tree/ng-package.json b/projects/igniteui-angular/tree/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/tree/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/tree/src/public_api.ts b/projects/igniteui-angular/tree/src/public_api.ts new file mode 100644 index 00000000000..0660c71f1d7 --- /dev/null +++ b/projects/igniteui-angular/tree/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './tree/public_api'; +export * from './tree/tree.module'; diff --git a/projects/igniteui-angular/tree/src/tree/common.ts b/projects/igniteui-angular/tree/src/tree/common.ts new file mode 100644 index 00000000000..d30fed3c1cf --- /dev/null +++ b/projects/igniteui-angular/tree/src/tree/common.ts @@ -0,0 +1,111 @@ +import { ElementRef, EventEmitter, InjectionToken, QueryList, TemplateRef } from '@angular/core'; +import { IBaseCancelableBrowserEventArgs, IBaseEventArgs } from 'igniteui-angular/core'; +import { ToggleAnimationSettings } from 'igniteui-angular/expansion-panel'; + +// Component interfaces + +/** Comparer function that can be used when searching through IgxTreeNode[] */ +export type IgxTreeSearchResolver = (data: any, node: IgxTreeNode) => boolean; + +export interface IgxTree { + /** @hidden @internal */ + nodes: QueryList>; + /** @hidden @internal */ + rootNodes: IgxTreeNode[]; + singleBranchExpand: boolean; + toggleNodeOnClick: boolean; + selection: IgxTreeSelectionType; + expandIndicator: TemplateRef; + animationSettings: ToggleAnimationSettings; + /** @hidden @internal */ + forceSelect: IgxTreeNode[]; + /** @hidden @internal */ + disabledChange: EventEmitter>; + /** @hidden @internal */ + activeNodeBindingChange: EventEmitter>; + nodeSelection: EventEmitter; + nodeExpanding: EventEmitter; + nodeExpanded: EventEmitter; + nodeCollapsing: EventEmitter; + nodeCollapsed: EventEmitter; + activeNodeChanged: EventEmitter>; + expandAll(nodes: IgxTreeNode[]): void; + collapseAll(nodes: IgxTreeNode[]): void; + deselectAll(node?: IgxTreeNode[]): void; + findNodes(searchTerm: any, comparer?: IgxTreeSearchResolver): IgxTreeNode[] | null; +} + +// Item interfaces +export interface IgxTreeNode { + parentNode?: IgxTreeNode | null; + loading: boolean; + path: IgxTreeNode[]; + expanded: boolean | null; + /** @hidden @internal */ + indeterminate: boolean; + selected: boolean | null; + disabled: boolean; + /** @hidden @internal */ + isFocused: boolean; + active: boolean; + level: number; + data: T; + /** @hidden @internal */ + nativeElement: HTMLElement; + /** @hidden @internal */ + header: ElementRef; + /** @hidden @internal */ + tabIndex: number; + /** @hidden @internal */ + allChildren: QueryList>; + /** @hidden @internal */ + _children: QueryList> | null; + selectedChange: EventEmitter; + expandedChange: EventEmitter; + expand(): void; + collapse(): void; + toggle(): void; + /** @hidden @internal */ + addLinkChild(node: any): void; + /** @hidden @internal */ + removeLinkChild(node: any): void; +} + +// Events +export interface ITreeNodeSelectionEvent extends IBaseCancelableBrowserEventArgs { + oldSelection: IgxTreeNode[]; + newSelection: IgxTreeNode[]; + added: IgxTreeNode[]; + removed: IgxTreeNode[]; + event?: Event; +} + +export interface ITreeNodeEditingEvent extends IBaseCancelableBrowserEventArgs { + node: IgxTreeNode; + value: string; +} + +export interface ITreeNodeEditedEvent extends IBaseEventArgs { + node: IgxTreeNode; + value: any; +} + +export interface ITreeNodeTogglingEventArgs extends IBaseEventArgs, IBaseCancelableBrowserEventArgs { + node: IgxTreeNode; +} + +export interface ITreeNodeToggledEventArgs extends IBaseEventArgs { + node: IgxTreeNode; +} + +// Enums +export const IgxTreeSelectionType = { + None: 'None', + BiState: 'BiState', + Cascading: 'Cascading' +} as const; +export type IgxTreeSelectionType = (typeof IgxTreeSelectionType)[keyof typeof IgxTreeSelectionType]; + +// Token +export const IGX_TREE_COMPONENT = /*@__PURE__*/new InjectionToken('IgxTreeToken'); +export const IGX_TREE_NODE_COMPONENT = /*@__PURE__*/new InjectionToken>('IgxTreeNodeToken'); diff --git a/projects/igniteui-angular/tree/src/tree/public_api.ts b/projects/igniteui-angular/tree/src/tree/public_api.ts new file mode 100644 index 00000000000..10f198e3a53 --- /dev/null +++ b/projects/igniteui-angular/tree/src/tree/public_api.ts @@ -0,0 +1,17 @@ +import { IgxTreeNodeComponent, IgxTreeNodeLinkDirective } from './tree-node/tree-node.component'; +import { IgxTreeComponent, IgxTreeExpandIndicatorDirective } from './tree.component'; + +export { IgxTreeComponent, IgxTreeExpandIndicatorDirective } from './tree.component'; +export * from './tree-node/tree-node.component'; +export { IgxTreeSearchResolver, ITreeNodeSelectionEvent, ITreeNodeEditingEvent, + ITreeNodeEditedEvent, ITreeNodeTogglingEventArgs, ITreeNodeToggledEventArgs, + IgxTreeSelectionType, IgxTree, IgxTreeNode +} from './common'; + +/* NOTE: Tree directives collection for ease-of-use import in standalone components scenario */ +export const IGX_TREE_DIRECTIVES = [ + IgxTreeComponent, + IgxTreeNodeComponent, + IgxTreeNodeLinkDirective, + IgxTreeExpandIndicatorDirective +] as const; diff --git a/projects/igniteui-angular/tree/src/tree/tree-functions.spec.ts b/projects/igniteui-angular/tree/src/tree/tree-functions.spec.ts new file mode 100644 index 00000000000..51341ef59cb --- /dev/null +++ b/projects/igniteui-angular/tree/src/tree/tree-functions.spec.ts @@ -0,0 +1,112 @@ +import { EventEmitter, QueryList } from '@angular/core'; +import { ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { IgxTreeNode } from './common'; +import { IgxTreeNodeComponent } from './tree-node/tree-node.component'; + +export const TREE_NODE_DIV_SELECTION_CHECKBOX_CSS_CLASS = 'igx-tree-node__select'; +const CHECKBOX_INPUT_CSS_CLASS = '.igx-checkbox__input'; +const TREE_NODE_CSS_CLASS = 'igx-tree-node'; +const TREE_NODE_WRAPPER_CSS_CLASS = 'igx-tree-node__wrapper'; +const TREE_NODE_EXPAND_INDICATOR_CSS_CLASS = 'igx-tree-node__toggle-button'; + +export class TreeTestFunctions { + + public static getAllNodes(fix: ComponentFixture) { + return fix.debugElement.queryAll(By.css(`.${TREE_NODE_CSS_CLASS}`)); + } + + public static getNodeCheckboxDiv(nodeDOM: HTMLElement): HTMLElement { + return nodeDOM.querySelector(`.${TREE_NODE_DIV_SELECTION_CHECKBOX_CSS_CLASS}`); + } + + public static getNodeExpandIndicatorDiv(nodeDOM: HTMLElement): HTMLElement { + return nodeDOM.querySelector(`.${TREE_NODE_EXPAND_INDICATOR_CSS_CLASS}`); + } + + public static getNodeCheckboxInput(nodeDOM: HTMLElement): HTMLInputElement { + return TreeTestFunctions.getNodeCheckboxDiv(nodeDOM).querySelector(CHECKBOX_INPUT_CSS_CLASS); + } + + public static getNodeWrapperDiv(nodeDOM: HTMLElement): HTMLInputElement { + return nodeDOM.querySelector(`.${TREE_NODE_WRAPPER_CSS_CLASS}`); + } + + public static clickNodeCheckbox(node: IgxTreeNodeComponent): Event { + const checkboxElement = TreeTestFunctions.getNodeCheckboxDiv(node.nativeElement); + const event = new Event('click', {}); + checkboxElement.dispatchEvent(event); + return event; + } + + public static clickNodeExpandIndicator(node: IgxTreeNodeComponent): Event { + const indicatorElement = TreeTestFunctions.getNodeExpandIndicatorDiv(node.nativeElement); + const event = new Event('click', {}); + indicatorElement.dispatchEvent(event); + return event; + } + + public static clickOnTreeNode(nodeDOM: HTMLElement): Event { + const nodeWrapperElement = this.getNodeWrapperDiv(nodeDOM); + const event = new MouseEvent('pointerdown', { button: 0 }); + nodeWrapperElement.dispatchEvent(event); + return event; + } + + public static verifyNodeSelected(node: IgxTreeNodeComponent, selected = true, hasCheckbox = true, indeterminate = false) { + expect(node.selected).toBe(selected); + expect(node.indeterminate).toBe(indeterminate); + if (hasCheckbox) { + expect(this.getNodeCheckboxDiv(node.nativeElement)).not.toBeNull(); + expect(TreeTestFunctions.getNodeCheckboxInput(node.nativeElement).checked).toBe(selected); + expect(TreeTestFunctions.getNodeCheckboxInput(node.nativeElement).indeterminate).toBe(indeterminate); + } else { + expect(this.getNodeCheckboxDiv(node.nativeElement)).toBeNull(); + } + } + + public static createNodeSpy( + properties: { [key: string]: any } = null, + methodNames: (keyof IgxTreeNode)[] = ['selected']): jasmine.SpyObj> { + if (!properties) { + return jasmine.createSpyObj>(methodNames); + } + return jasmine.createSpyObj>(methodNames, properties); + } + + public static createNodeSpies( + level: number, + count: number, + parentNode?: IgxTreeNodeComponent, + children?: any[], + allChildren?: any[] + ): IgxTreeNodeComponent[] { + const nodesArr = []; + const mockEmitter: EventEmitter = jasmine.createSpyObj('emitter', ['emit']); + for (let i = 0; i < count; i++) { + nodesArr.push(this.createNodeSpy({ + level, + expanded: false, + disabled: false, + tabIndex: null, + header: { nativeElement: { focus: () => undefined } }, + parentNode: parentNode ? parentNode : null, + _children: children ? children[i] : null, + allChildren: allChildren ? allChildren[i] : null, + selectedChange: mockEmitter + })); + } + return nodesArr; + } + + public static createQueryListSpy(nodes: IgxTreeNodeComponent[]): jasmine.SpyObj>> { + const mockQuery = jasmine.createSpyObj(['toArray', 'filter', 'forEach']); + Object.defineProperty(mockQuery, 'first', { value: nodes[0], enumerable: true }); + Object.defineProperty(mockQuery, 'last', { value: nodes[nodes.length - 1], enumerable: true }); + Object.defineProperty(mockQuery, 'length', { value: nodes.length, enumerable: true }); + mockQuery.toArray.and.returnValue(nodes); + mockQuery.filter.and.callFake((cb) => nodes.filter(cb)); + mockQuery.forEach.and.callFake((cb) => nodes.forEach(cb)); + return mockQuery; + } +} diff --git a/projects/igniteui-angular/tree/src/tree/tree-navigation.service.ts b/projects/igniteui-angular/tree/src/tree/tree-navigation.service.ts new file mode 100644 index 00000000000..8efc4e63cb6 --- /dev/null +++ b/projects/igniteui-angular/tree/src/tree/tree-navigation.service.ts @@ -0,0 +1,255 @@ +import { Injectable, OnDestroy, inject } from '@angular/core'; +import { IgxTree, IgxTreeNode, IgxTreeSelectionType } from './common'; +import { NAVIGATION_KEYS } from 'igniteui-angular/core'; +import { IgxTreeService } from './tree.service'; +import { IgxTreeSelectionService } from './tree-selection.service'; +import { Subject } from 'rxjs'; + +/** @hidden @internal */ +@Injectable() +export class IgxTreeNavigationService implements OnDestroy { + private treeService = inject(IgxTreeService); + private selectionService = inject(IgxTreeSelectionService); + + private tree: IgxTree; + + private _focusedNode: IgxTreeNode = null; + private _lastFocusedNode: IgxTreeNode = null; + private _activeNode: IgxTreeNode = null; + + private _visibleChildren: IgxTreeNode[] = []; + private _invisibleChildren: Set> = new Set(); + private _disabledChildren: Set> = new Set(); + + private _cacheChange = new Subject(); + + constructor() { + this._cacheChange.subscribe(() => { + this._visibleChildren = + this.tree?.nodes ? + this.tree.nodes.filter(e => !(this._invisibleChildren.has(e) || this._disabledChildren.has(e))) : + []; + }); + } + + public register(tree: IgxTree) { + this.tree = tree; + } + + public get focusedNode() { + return this._focusedNode; + } + + public set focusedNode(value: IgxTreeNode) { + if (this._focusedNode === value) { + return; + } + this._lastFocusedNode = this._focusedNode; + if (this._lastFocusedNode) { + this._lastFocusedNode.tabIndex = -1; + } + this._focusedNode = value; + if (this._focusedNode !== null) { + this._focusedNode.tabIndex = 0; + this._focusedNode.header.nativeElement.focus(); + } + } + + public get activeNode() { + return this._activeNode; + } + + public set activeNode(value: IgxTreeNode) { + if (this._activeNode === value) { + return; + } + this._activeNode = value; + this.tree.activeNodeChanged.emit(this._activeNode); + } + + public get visibleChildren(): IgxTreeNode[] { + return this._visibleChildren; + } + + public update_disabled_cache(node: IgxTreeNode): void { + if (node.disabled) { + this._disabledChildren.add(node); + } else { + this._disabledChildren.delete(node); + } + this._cacheChange.next(); + } + + public init_invisible_cache() { + this.tree.nodes.filter(e => e.level === 0).forEach(node => { + this.update_visible_cache(node, node.expanded, false); + }); + this._cacheChange.next(); + } + + public update_visible_cache(node: IgxTreeNode, expanded: boolean, shouldEmit = true): void { + if (expanded) { + node._children.forEach(child => { + this._invisibleChildren.delete(child); + this.update_visible_cache(child, child.expanded, false); + }); + } else { + node.allChildren.forEach(c => this._invisibleChildren.add(c)); + } + + if (shouldEmit) { + this._cacheChange.next(); + } + } + + /** + * Sets the node as focused (and active) + * + * @param node target node + * @param isActive if true, sets the node as active + */ + public setFocusedAndActiveNode(node: IgxTreeNode, isActive = true): void { + if (isActive) { + this.activeNode = node; + } + this.focusedNode = node; + } + + /** Handler for keydown events. Used in tree.component.ts */ + public handleKeydown(event: KeyboardEvent) { + const key = event.key.toLowerCase(); + if (!this.focusedNode) { + return; + } + if (!(NAVIGATION_KEYS.has(key) || key === '*')) { + if (key === 'enter') { + this.activeNode = this.focusedNode; + } + return; + } + event.preventDefault(); + if (event.repeat) { + setTimeout(() => this.handleNavigation(event), 1); + } else { + this.handleNavigation(event); + } + } + + public ngOnDestroy() { + this._cacheChange.next(); + this._cacheChange.complete(); + } + + private handleNavigation(event: KeyboardEvent) { + switch (event.key.toLowerCase()) { + case 'home': + this.setFocusedAndActiveNode(this.visibleChildren[0]); + break; + case 'end': + this.setFocusedAndActiveNode(this.visibleChildren[this.visibleChildren.length - 1]); + break; + case 'arrowleft': + case 'left': + this.handleArrowLeft(); + break; + case 'arrowright': + case 'right': + this.handleArrowRight(); + break; + case 'arrowup': + case 'up': + this.handleUpDownArrow(true, event); + break; + case 'arrowdown': + case 'down': + this.handleUpDownArrow(false, event); + break; + case '*': + this.handleAsterisk(); + break; + case ' ': + case 'spacebar': + case 'space': + this.handleSpace(event.shiftKey); + break; + default: + return; + } + } + + private handleArrowLeft(): void { + if (this.focusedNode.expanded && !this.treeService.collapsingNodes.has(this.focusedNode) && this.focusedNode._children?.length) { + this.activeNode = this.focusedNode; + this.focusedNode.collapse(); + } else { + const parentNode = this.focusedNode.parentNode; + if (parentNode && !parentNode.disabled) { + this.setFocusedAndActiveNode(parentNode); + } + } + } + + private handleArrowRight(): void { + if (this.focusedNode._children.length > 0) { + if (!this.focusedNode.expanded) { + this.activeNode = this.focusedNode; + this.focusedNode.expand(); + } else { + if (this.treeService.collapsingNodes.has(this.focusedNode)) { + this.focusedNode.expand(); + return; + } + const firstChild = this.focusedNode._children.find(node => !node.disabled); + if (firstChild) { + this.setFocusedAndActiveNode(firstChild); + } + } + } + } + + private handleUpDownArrow(isUp: boolean, event: KeyboardEvent): void { + const next = this.getVisibleNode(this.focusedNode, isUp ? -1 : 1); + if (next === this.focusedNode) { + return; + } + + if (event.ctrlKey) { + this.setFocusedAndActiveNode(next, false); + } else { + this.setFocusedAndActiveNode(next); + } + } + + private handleAsterisk(): void { + const nodes = this.focusedNode.parentNode ? this.focusedNode.parentNode._children : this.tree.rootNodes; + nodes?.forEach(node => { + if (!node.disabled && (!node.expanded || this.treeService.collapsingNodes.has(node))) { + node.expand(); + } + }); + } + + private handleSpace(shiftKey = false): void { + if (this.tree.selection === IgxTreeSelectionType.None) { + return; + } + + this.activeNode = this.focusedNode; + if (shiftKey) { + this.selectionService.selectMultipleNodes(this.focusedNode); + return; + } + + if (this.focusedNode.selected) { + this.selectionService.deselectNode(this.focusedNode); + } else { + this.selectionService.selectNode(this.focusedNode); + } + } + + /** Gets the next visible node in the given direction - 1 -> next, -1 -> previous */ + private getVisibleNode(node: IgxTreeNode, dir: 1 | -1 = 1): IgxTreeNode { + const nodeIndex = this.visibleChildren.indexOf(node); + return this.visibleChildren[nodeIndex + dir] || node; + } +} diff --git a/projects/igniteui-angular/tree/src/tree/tree-navigation.spec.ts b/projects/igniteui-angular/tree/src/tree/tree-navigation.spec.ts new file mode 100644 index 00000000000..88edbd5b540 --- /dev/null +++ b/projects/igniteui-angular/tree/src/tree/tree-navigation.spec.ts @@ -0,0 +1,866 @@ +import { waitForAsync, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { IgxTreeNavigationComponent, IgxTreeScrollComponent, IgxTreeSimpleComponent } from './tree-samples.spec'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; +import { IgxTreeNavigationService } from './tree-navigation.service'; +import { ElementRef, EventEmitter } from '@angular/core'; +import { IgxTreeSelectionService } from './tree-selection.service'; +import { TreeTestFunctions } from './tree-functions.spec'; +import { IgxTreeService } from './tree.service'; +import { IgxTreeComponent } from './tree.component'; +import { IgxTree, IgxTreeNode, IgxTreeSelectionType } from './common'; +import { IgxTreeNodeComponent } from './tree-node/tree-node.component'; + +describe('IgxTree - Navigation #treeView', () => { + + describe('Navigation - UI Tests', () => { + let fix; + let tree: IgxTreeComponent; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxTreeNavigationComponent, + IgxTreeScrollComponent, + IgxTreeSimpleComponent + ] + }).compileComponents(); + })); + + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(IgxTreeNavigationComponent); + fix.detectChanges(); + tree = fix.componentInstance.tree; + })); + + describe('UI Interaction tests - None', () => { + beforeEach(fakeAsync(() => { + tree.selection = IgxTreeSelectionType.None; + fix.detectChanges(); + })); + + it('Initial tab index without focus SHOULD be 0 for all nodes and active input should be set correctly', () => { + const visibleNodes = (tree as any).navService.visibleChildren; + visibleNodes.forEach(node => { + expect(node.header.nativeElement.tabIndex).toEqual(0); + }); + + // Should render node with `node.active === true`, set through input, as active in the tree + expect((tree as any).navService.activeNode).toEqual(tree.nodes.toArray()[17]); + expect(tree.nodes.toArray()[17].active).toBeTruthy(); + + tree.nodes.first.header.nativeElement.dispatchEvent(new Event('pointerdown')); + fix.detectChanges(); + visibleNodes.forEach(node => { + if (node !== tree.nodes.first) { + expect(node.header.nativeElement.tabIndex).toEqual(-1); + } else { + expect(node.header.nativeElement.tabIndex).toEqual(0); + } + }); + }); + + it('Should focus/activate correct node on ArrowDown/ArrowUp (+ Ctrl) key pressed', () => { + spyOn(tree.activeNodeChanged, 'emit').and.callThrough(); + tree.nodes.first.header.nativeElement.dispatchEvent(new Event('pointerdown')); + fix.detectChanges(); + + expect((tree as any).navService.focusedNode).toEqual(tree.nodes.first); + expect((tree as any).navService.activeNode).toEqual(tree.nodes.first); + expect(tree.activeNodeChanged.emit).toHaveBeenCalledWith(tree.nodes.first); + + // ArrowDown + Ctrl should only focus the next visible node + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', tree.nodes.first.nativeElement, true, false, false, true); + fix.detectChanges(); + + expect((tree as any).navService.focusedNode).toEqual(tree.nodes.toArray()[17]); + expect((tree as any).navService.activeNode).toEqual(tree.nodes.first); + + // ArrowDown should focus and activate the next visible node + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', tree.nodes.first.nativeElement); + fix.detectChanges(); + + expect((tree as any).navService.focusedNode).toEqual(tree.nodes.toArray()[28]); + expect((tree as any).navService.activeNode).toEqual(tree.nodes.toArray()[28]); + expect(tree.activeNodeChanged.emit).toHaveBeenCalledWith(tree.nodes.toArray()[28]); + + // ArrowUp + Ctrl should only focus the previous visible node + UIInteractions.triggerKeyDownEvtUponElem('arrowup', tree.nodes.toArray()[28].nativeElement, true, false, false, true); + fix.detectChanges(); + + expect((tree as any).navService.focusedNode).toEqual(tree.nodes.toArray()[17]); + expect((tree as any).navService.activeNode).toEqual(tree.nodes.toArray()[28]); + + // ArrowUp should focus and activate the previous visible node + UIInteractions.triggerKeyDownEvtUponElem('arrowup', tree.nodes.toArray()[17].nativeElement); + fix.detectChanges(); + + expect((tree as any).navService.focusedNode).toEqual(tree.nodes.first); + expect((tree as any).navService.activeNode).toEqual(tree.nodes.first); + expect(tree.activeNodeChanged.emit).toHaveBeenCalledWith(tree.nodes.first); + }); + + it('Should focus and activate the first/last visible node on Home/End key press', () => { + spyOn(tree.activeNodeChanged, 'emit').and.callThrough(); + tree.nodes.first.expand(); + fix.detectChanges(); + tree.nodes.toArray()[2].header.nativeElement.dispatchEvent(new Event('pointerdown')); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('home', tree.nodes.toArray()[2].nativeElement); + fix.detectChanges(); + + expect((tree as any).navService.focusedNode).toEqual(tree.nodes.first); + expect((tree as any).navService.activeNode).toEqual(tree.nodes.first); + expect(tree.activeNodeChanged.emit).toHaveBeenCalledWith(tree.nodes.first); + + UIInteractions.triggerKeyDownEvtUponElem('end', tree.nodes.first.nativeElement); + fix.detectChanges(); + + expect((tree as any).navService.focusedNode).toEqual(tree.nodes.last); + expect((tree as any).navService.activeNode).toEqual(tree.nodes.last); + expect(tree.activeNodeChanged.emit).toHaveBeenCalledWith(tree.nodes.last); + }); + + it('Should collapse/navigate to correct node on Arrow left key press', fakeAsync(() => { + spyOn(tree.activeNodeChanged, 'emit').and.callThrough(); + // If node is collapsed and has no parents the focus and activation should not be moved on Arrow left key press + tree.nodes.first.header.nativeElement.dispatchEvent(new Event('pointerdown')); + tick(); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('arrowleft', tree.nodes.first.nativeElement); + tick(); + fix.detectChanges(); + + expect((tree as any).navService.focusedNode).toEqual(tree.nodes.first); + expect((tree as any).navService.activeNode).toEqual(tree.nodes.first); + expect(tree.activeNodeChanged.emit).toHaveBeenCalledWith(tree.nodes.first); + + // If node is collapsed and has parent the focus and activation should be moved to the parent node on Arrow left key press + tree.nodes.first.expand(); + tick(); + fix.detectChanges(); + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', tree.nodes.first.nativeElement); + tick(); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('arrowleft', tree.nodes.first.nativeElement); + tick(); + fix.detectChanges(); + + expect((tree as any).navService.focusedNode).toEqual(tree.nodes.first); + expect((tree as any).navService.activeNode).toEqual(tree.nodes.first); + expect(tree.activeNodeChanged.emit).toHaveBeenCalledWith(tree.nodes.first); + + // If node is expanded the node should collapse on Arrow left key press + UIInteractions.triggerKeyDownEvtUponElem('arrowleft', tree.nodes.first.nativeElement); + tick(); + fix.detectChanges(); + + expect((tree as any).navService.focusedNode).toEqual(tree.nodes.first); + expect((tree as any).navService.activeNode).toEqual(tree.nodes.first); + expect(tree.nodes.first.expanded).toBeFalsy(); + })); + + it('Should expand/navigate to correct node on Arrow right key press', () => { + spyOn(tree.activeNodeChanged, 'emit').and.callThrough(); + // If node has no children the focus and activation should not be moved on Arrow right key press + tree.nodes.last.header.nativeElement.dispatchEvent(new Event('pointerdown')); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('arrowright', tree.nodes.last.nativeElement); + fix.detectChanges(); + + expect((tree as any).navService.focusedNode).toEqual(tree.nodes.last); + expect((tree as any).navService.activeNode).toEqual(tree.nodes.last); + expect(tree.activeNodeChanged.emit).toHaveBeenCalledWith(tree.nodes.last); + + // If node is collapsed and has children the node should be expanded on Arrow right key press + UIInteractions.triggerKeyDownEvtUponElem('home', tree.nodes.last.nativeElement); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('arrowright', tree.nodes.first.nativeElement); + fix.detectChanges(); + + expect((tree as any).navService.focusedNode).toEqual(tree.nodes.first); + expect((tree as any).navService.activeNode).toEqual(tree.nodes.first); + expect(tree.activeNodeChanged.emit).toHaveBeenCalledWith(tree.nodes.first); + expect(tree.nodes.first.expanded).toBeTruthy(); + + // If node is expanded and has children the focus and activation should be moved to the first child on Arrow right key press + UIInteractions.triggerKeyDownEvtUponElem('arrowright', tree.nodes.first.nativeElement); + fix.detectChanges(); + + expect((tree as any).navService.focusedNode).toEqual(tree.nodes.toArray()[1]); + expect((tree as any).navService.activeNode).toEqual(tree.nodes.toArray()[1]); + expect(tree.activeNodeChanged.emit).toHaveBeenCalledWith(tree.nodes.toArray()[1]); + }); + + it('Pressing Asterisk on focused node should expand all expandable nodes in the same group', () => { + tree.nodes.first.header.nativeElement.dispatchEvent(new Event('pointerdown')); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('arrowright', tree.nodes.first.nativeElement); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('arrowright', tree.nodes.first.nativeElement); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('*', tree.nodes.first.nativeElement); + fix.detectChanges(); + + expect(tree.nodes.toArray()[2].expanded).toBeTruthy(); + expect(tree.nodes.toArray()[12].expanded).toBeTruthy(); + }); + + it('Pressing Enter should activate the focused node and not prevent the keydown event`s deafault behavior', () => { + spyOn(tree.activeNodeChanged, 'emit').and.callThrough(); + tree.nodes.first.header.nativeElement.dispatchEvent(new Event('pointerdown')); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', tree.nodes.first.nativeElement, true, false, false, true); + fix.detectChanges(); + + const mockEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }); + spyOn(mockEvent, 'preventDefault'); + tree.nodes.toArray()[17].nativeElement.dispatchEvent(mockEvent); + expect(mockEvent.preventDefault).not.toHaveBeenCalled(); + + expect((tree as any).navService.focusedNode).toEqual(tree.nodes.toArray()[17]); + expect((tree as any).navService.activeNode).toEqual(tree.nodes.toArray()[17]); + expect(tree.activeNodeChanged.emit).toHaveBeenCalledWith(tree.nodes.toArray()[17]); + }); + + it('Should correctly set node`s selection state on Space key press', () => { + spyOn(tree.activeNodeChanged, 'emit').and.callThrough(); + // Space on None Selection Mode + tree.selection = 'None'; + tree.nodes.first.header.nativeElement.dispatchEvent(new Event('pointerdown')); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', tree.nodes.first.nativeElement, true, false, false, true); + fix.detectChanges(); + + spyOn((tree as any).selectionService, 'selectNode').and.callThrough(); + spyOn((tree as any).selectionService, 'deselectNode').and.callThrough(); + spyOn((tree as any).selectionService, 'selectMultipleNodes').and.callThrough(); + + UIInteractions.triggerKeyDownEvtUponElem('space', tree.nodes.toArray()[17].nativeElement); + fix.detectChanges(); + + expect(tree.nodes.toArray()[17].selected).toBeFalsy(); + expect((tree as any).selectionService.selectNode).toHaveBeenCalledTimes(0); + expect((tree as any).selectionService.deselectNode).toHaveBeenCalledTimes(0); + expect((tree as any).selectionService.selectMultipleNodes).toHaveBeenCalledTimes(0); + expect((tree as any).navService.activeNode).not.toEqual(tree.nodes.toArray()[17]); + + // Space for select + tree.selection = 'BiState'; + UIInteractions.triggerKeyDownEvtUponElem('space', tree.nodes.toArray()[17].nativeElement); + fix.detectChanges(); + + expect(tree.nodes.toArray()[17].selected).toBeTruthy(); + expect((tree as any).selectionService.selectNode).toHaveBeenCalledTimes(1); + expect((tree as any).selectionService.deselectNode).toHaveBeenCalledTimes(0); + expect((tree as any).selectionService.selectMultipleNodes).toHaveBeenCalledTimes(0); + expect((tree as any).selectionService.selectNode).toHaveBeenCalledWith(tree.nodes.toArray()[17]); + expect((tree as any).navService.activeNode).toEqual(tree.nodes.toArray()[17]); + expect(tree.activeNodeChanged.emit).toHaveBeenCalledWith(tree.nodes.toArray()[17]); + + // Space with Shift key + UIInteractions.triggerKeyDownEvtUponElem('arrowup', tree.nodes.toArray()[17].nativeElement, true, false, false, true); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('space', tree.nodes.first.nativeElement, true, false, true, false); + fix.detectChanges(); + + expect(tree.nodes.first.selected).toBeTruthy(); + expect(tree.nodes.toArray()[17].selected).toBeTruthy(); + expect((tree as any).selectionService.selectNode).toHaveBeenCalledTimes(1); + expect((tree as any).selectionService.deselectNode).toHaveBeenCalledTimes(0); + expect((tree as any).selectionService.selectMultipleNodes).toHaveBeenCalledTimes(1); + expect((tree as any).selectionService.selectMultipleNodes).toHaveBeenCalledWith(tree.nodes.first); + expect((tree as any).navService.activeNode).toEqual(tree.nodes.first); + + // Space for deselect + + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', tree.nodes.first.nativeElement, true, false, false, true); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('space', tree.nodes.toArray()[17].nativeElement); + fix.detectChanges(); + + expect(tree.nodes.toArray()[17].selected).toBeFalsy(); + expect((tree as any).selectionService.selectNode).toHaveBeenCalledTimes(1); + expect((tree as any).selectionService.deselectNode).toHaveBeenCalledTimes(1); + expect((tree as any).selectionService.selectMultipleNodes).toHaveBeenCalledTimes(1); + expect((tree as any).selectionService.deselectNode).toHaveBeenCalledWith(tree.nodes.toArray()[17]); + expect((tree as any).navService.activeNode).toEqual(tree.nodes.toArray()[17]); + }); + }); + + describe('UI Interaction tests - Expand/Collapse nodes', () => { + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(IgxTreeSimpleComponent); + fix.detectChanges(); + tree = fix.componentInstance.tree; + tree.selection = IgxTreeSelectionType.BiState; + fix.detectChanges(); + })); + + it('Should be able to expand/collapse nodes only through clicking on the expand indicator if `toggleNodeOnClick` is set to `false`', () => { + const firstNode = tree.nodes.toArray()[0]; + + UIInteractions.simulateClickEvent(firstNode.nativeElement); + fix.detectChanges(); + + expect(firstNode.expanded).toBeFalsy(); + + TreeTestFunctions.clickNodeExpandIndicator(firstNode); + fix.detectChanges(); + + expect(firstNode.expanded).toBeTruthy(); + }); + + it('Should be able to expand/collapse nodes when clicking over them if `toggleNodeOnClick` is set to `true`', () => { + tree.toggleNodeOnClick = true; + fix.detectChanges(); + + const firstNode = tree.nodes.first; + expect(firstNode.expanded).toBeFalsy(); + + TreeTestFunctions.clickOnTreeNode(firstNode.nativeElement); + + fix.detectChanges(); + + expect(firstNode.expanded).toBeTruthy(); + }); + + it('Should not be able to expand/collapse nodes on right click', () => { + tree.toggleNodeOnClick = true; + fix.detectChanges(); + + const firstNode = tree.nodes.first; + expect(firstNode.expanded).toBeFalsy(); + + const nodeWrapperElement = TreeTestFunctions.getNodeWrapperDiv(firstNode.nativeElement); + UIInteractions.simulateNonPrimaryClick(nodeWrapperElement); + fix.detectChanges(); + + expect(firstNode.expanded).toBeFalsy(); + }); + + it('Should not be able to expand/collapse nodes when clicking over nodes` checkbox if `toggleNodeOnClick` is set to `true`', () => { + tree.toggleNodeOnClick = true; + fix.detectChanges(); + + const firstNode = tree.nodes.toArray()[0]; + TreeTestFunctions.clickNodeCheckbox(firstNode); + fix.detectChanges(); + + TreeTestFunctions.verifyNodeSelected(firstNode); + expect(firstNode.expanded).toBeFalsy(); + }); + }); + + describe('UI Interaction tests - Scroll to focused node', () => { + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(IgxTreeScrollComponent); + fix.detectChanges(); + tree = fix.componentInstance.tree; + tree.selection = IgxTreeSelectionType.None; + fix.detectChanges(); + })); + + it('The tree should have custome expand indicator templates', () => { + expect(tree.nodes.first.nativeElement.querySelector('.igx-icon').textContent).toBe('close_fullscreen'); + }); + + it('The tree container should be scrolled so that the focused node is in view', fakeAsync(() => { + // set another node as active element, expect node to be in view + tick(); + const treeElement = tree.nativeElement; + let targetNode = tree.nodes.last; + let nodeElement = targetNode.nativeElement; + let nodeRect = nodeElement.getBoundingClientRect(); + let treeRect = treeElement.getBoundingClientRect(); + // expect node is in view + expect((treeRect.top > nodeRect.top) || (treeRect.bottom < nodeRect.bottom)).toBeFalsy(); + targetNode = tree.nodes.first; + nodeElement = targetNode?.header.nativeElement; + targetNode.active = true; + tick(); + fix.detectChanges(); + nodeRect = nodeElement.getBoundingClientRect(); + treeRect = treeElement.getBoundingClientRect(); + expect(treeElement.scrollTop).toBe(0); + expect((treeRect.top > nodeRect.top) || (treeRect.bottom < nodeRect.bottom)).toBeFalsy(); + let lastNodeIndex = 0; + let nodeIndex = 0; + for (let i = 0; i < 150; i++) { + while (nodeIndex === lastNodeIndex) { + nodeIndex = Math.floor(Math.random() * tree.nodes.length); + } + lastNodeIndex = nodeIndex; + targetNode = tree.nodes.toArray()[nodeIndex]; + nodeElement = targetNode.header.nativeElement; + targetNode.active = true; + tick(); + fix.detectChanges(); + tick(); + fix.detectChanges(); + // recalculate rectangles + treeRect = treeElement.getBoundingClientRect(); + nodeRect = targetNode.header.nativeElement.getBoundingClientRect(); + expect((treeRect.top <= nodeRect.top) && (treeRect.bottom >= nodeRect.bottom)).toBeTruthy(); + } + })); + }); + + describe('UI Interaction tests - Disabled Nodes', () => { + beforeEach(fakeAsync(() => { + tree.selection = IgxTreeSelectionType.None; + fix.detectChanges(); + fix.componentInstance.isDisabled = true; + fix.detectChanges(); + })); + + it('TabIndex on disabled node should be -1', () => { + expect(tree.nodes.last.header.nativeElement.tabIndex).toEqual(-1); + }); + + it('Should focus and activate the first/last enabled and visible node on Home/End key press', () => { + tree.nodes.first.disabled = true; + fix.detectChanges(); + + tree.nodes.toArray()[38].header.nativeElement.dispatchEvent(new Event('pointerdown')); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('home', tree.nodes.first.nativeElement); + fix.detectChanges(); + + expect((tree as any).navService.focusedNode).toEqual(tree.nodes.toArray()[17]); + expect((tree as any).navService.activeNode).toEqual(tree.nodes.toArray()[17]); + + UIInteractions.triggerKeyDownEvtUponElem('end', tree.nodes.toArray()[17].nativeElement); + fix.detectChanges(); + + expect((tree as any).navService.focusedNode).toEqual(tree.nodes.toArray()[38]); + expect((tree as any).navService.activeNode).toEqual(tree.nodes.toArray()[38]); + }); + + it('Should navigate to correct node on Arrow left/right key press', () => { + // If a node is collapsed and has a disabled parent the focus and activation + // should not be moved from the node on Arrow left key press + tree.nodes.first.expanded = true; + fix.detectChanges(); + tree.nodes.toArray()[2].header.nativeElement.dispatchEvent(new Event('pointerdown')); + fix.detectChanges(); + + tree.nodes.first.disabled = true; + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('arrowleft', tree.nodes.toArray()[2].nativeElement); + fix.detectChanges(); + + expect((tree as any).navService.focusedNode).toEqual(tree.nodes.toArray()[2]); + expect((tree as any).navService.activeNode).toEqual(tree.nodes.toArray()[2]); + + // If a node is expanded and all its children are disabled the focus and activation + // should not be moved from the node on Arrow right key press + + UIInteractions.triggerKeyDownEvtUponElem('arrowright', tree.nodes.toArray()[2].nativeElement); + fix.detectChanges(); + + tree.nodes.toArray()[2]._children.forEach(child => { + child.disabled = true; + }); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('arrowright', tree.nodes.toArray()[2].nativeElement); + fix.detectChanges(); + + expect((tree as any).navService.focusedNode).toEqual(tree.nodes.toArray()[2]); + expect((tree as any).navService.activeNode).toEqual(tree.nodes.toArray()[2]); + + // If a node is expanded and has enabled children the focus and activation + // should be moved to the first enabled child on Arrow right key press + + tree.nodes.toArray()[4].disabled = false; + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('arrowright', tree.nodes.toArray()[2].nativeElement); + fix.detectChanges(); + + expect((tree as any).navService.focusedNode).toEqual(tree.nodes.toArray()[4]); + expect((tree as any).navService.activeNode).toEqual(tree.nodes.toArray()[4]); + }); + + it('Should navigate to the right node on Arrow up/down key press', () => { + tree.nodes.toArray()[28].disabled = true; + tree.nodes.toArray()[38].header.nativeElement.dispatchEvent(new Event('pointerdown')); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('arrowup', tree.nodes.toArray()[38].nativeElement); + fix.detectChanges(); + + expect((tree as any).navService.focusedNode).toEqual(tree.nodes.toArray()[17]); + expect((tree as any).navService.activeNode).toEqual(tree.nodes.toArray()[17]); + + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', tree.nodes.toArray()[17].nativeElement); + fix.detectChanges(); + + expect((tree as any).navService.focusedNode).toEqual(tree.nodes.toArray()[38]); + expect((tree as any).navService.activeNode).toEqual(tree.nodes.toArray()[38]); + }); + + it('Pressing Asterisk on focused node should expand only the enabled and expandable nodes in the same group', () => { + tree.nodes.toArray()[17].disabled = true; + tree.nodes.first.header.nativeElement.dispatchEvent(new Event('pointerdown')); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('*', tree.nodes.first.nativeElement); + fix.detectChanges(); + + expect(tree.nodes.toArray()[17].expanded).toBeFalsy(); + expect(tree.nodes.first.expanded).toBeTruthy(); + expect(tree.nodes.toArray()[28].expanded).toBeTruthy(); + expect(tree.nodes.toArray()[38].expanded).toBeTruthy(); + expect(tree.nodes.last.expanded).toBeFalsy(); + }); + }); + + describe('UI Interaction tests - igxTreeNodeLink', () => { + beforeEach(fakeAsync(() => { + tree.selection = IgxTreeSelectionType.None; + fix.detectChanges(); + fix.componentInstance.showNodesWithDirective = true; + fix.detectChanges(); + })); + + it('Nodes with igxTreeNodeLink should have tabIndex -1', () => { + expect(tree.nodes.toArray()[41].header.nativeElement.tabIndex).toEqual(-1); + expect(tree.nodes.last.header.nativeElement.tabIndex).toEqual(-1); + }); + + it('When focus falls on link with directive, document.activeElement should be link with directive', fakeAsync(() => { + tree.nodes.toArray()[40].header.nativeElement.dispatchEvent(new Event('pointerdown')); + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('arrowdown', tree.nodes.toArray()[40].nativeElement); + fix.detectChanges(); + tick(); + fix.detectChanges(); + + const linkNode = tree.nodes.toArray()[41].linkChildren.first.nativeElement; + expect(linkNode.tabIndex).toEqual(0); + + // When focus falls on link with directive, parent has focused class (nav.service.focusedNode === link.parent) + expect((tree as any).navService.focusedNode).toEqual(tree.nodes.toArray()[41]); + expect(document.activeElement).toEqual(linkNode); + })); + + it('Link with passed parent in ng-template outisde of node parent has proper ref to parent', () => { + tree.nodes.toArray()[40].header.nativeElement.dispatchEvent(new Event('pointerdown')); + fix.detectChanges(); + + tree.nodes.toArray()[46].expanded = true; + fix.detectChanges(); + + UIInteractions.triggerKeyDownEvtUponElem('end', tree.nodes.toArray()[46].nativeElement); + fix.detectChanges(); + + expect(tree.nodes.last.registeredChildren[0].tabIndex).toEqual(0); + }); + + }); + }); + + describe('IgxTreeNavigationSerivce - Unit Tests', () => { + let selectionService: IgxTreeSelectionService; + let treeService: IgxTreeService; + let navService: IgxTreeNavigationService; + let mockTree: IgxTree; + let mockEmitter: EventEmitter>; + let mockNodesLevel1: IgxTreeNodeComponent[]; + let mockNodesLevel2_1: IgxTreeNodeComponent[]; + let mockNodesLevel2_2: IgxTreeNodeComponent[]; + let mockNodesLevel3_1: IgxTreeNodeComponent[]; + let mockNodesLevel3_2: IgxTreeNodeComponent[]; + let allNodes: IgxTreeNodeComponent[]; + const mockQuery1: any = {}; + const mockQuery2: any = {}; + const mockQuery3: any = {}; + const mockQuery4: any = {}; + const mockQuery5: any = {}; + const mockQuery6: any = {}; + + beforeEach(() => { + selectionService = new IgxTreeSelectionService(); + treeService = new IgxTreeService(); + navService?.ngOnDestroy(); + //navService = new IgxTreeNavigationService(); + mockNodesLevel1 = TreeTestFunctions.createNodeSpies(0, 3, null, [mockQuery2, mockQuery3, []], [mockQuery6, mockQuery3, []]); + mockNodesLevel2_1 = TreeTestFunctions.createNodeSpies(1, 2, + mockNodesLevel1[0], [mockQuery4, mockQuery5], [mockQuery4, mockQuery5]); + mockNodesLevel2_2 = TreeTestFunctions.createNodeSpies(1, 1, mockNodesLevel1[1], [[]]); + mockNodesLevel3_1 = TreeTestFunctions.createNodeSpies(2, 2, mockNodesLevel2_1[0], [[], []]); + mockNodesLevel3_2 = TreeTestFunctions.createNodeSpies(2, 2, mockNodesLevel2_1[1], [[], []]); + allNodes = [ + mockNodesLevel1[0], + mockNodesLevel2_1[0], + ...mockNodesLevel3_1, + mockNodesLevel2_1[1], + ...mockNodesLevel3_2, + mockNodesLevel1[1], + ...mockNodesLevel2_2, + mockNodesLevel1[2] + ]; + + + + Object.assign(mockQuery1, TreeTestFunctions.createQueryListSpy(allNodes)); + Object.assign(mockQuery2, TreeTestFunctions.createQueryListSpy(mockNodesLevel2_1)); + Object.assign(mockQuery3, TreeTestFunctions.createQueryListSpy(mockNodesLevel2_2)); + Object.assign(mockQuery4, TreeTestFunctions.createQueryListSpy(mockNodesLevel3_1)); + Object.assign(mockQuery5, TreeTestFunctions.createQueryListSpy(mockNodesLevel3_2)); + Object.assign(mockQuery6, TreeTestFunctions.createQueryListSpy([ + mockNodesLevel2_1[0], + ...mockNodesLevel3_1, + mockNodesLevel2_1[1], + ...mockNodesLevel3_2 + ])); + }); + + describe('IgxNavigationService', () => { + beforeEach(() => { + mockEmitter = jasmine.createSpyObj('emitter', ['emit']); + mockTree = jasmine.createSpyObj('tree', [''], + { selection: IgxTreeSelectionType.BiState, activeNodeChanged: mockEmitter, nodes: mockQuery1 }); + + TestBed.configureTestingModule({ + providers: [ + { provide: IgxTreeComponent, useValue: mockTree }, + { provide: IgxTreeService, useValue: treeService }, + { provide: IgxTreeSelectionService, useValue: selectionService }, + IgxTreeNavigationService + ] + }); + + navService = TestBed.inject(IgxTreeNavigationService); + }); + + it('Should properly register the specified tree', () => { + expect((navService as any).tree).toBeFalsy(); + + navService.register(mockTree); + expect((navService as any).tree).toEqual(mockTree); + }); + + it('Should properly calculate VisibleChildren collection', () => { + navService.register(mockTree); + navService.init_invisible_cache(); + expect(navService.visibleChildren.length).toEqual(3); + + (Object.getOwnPropertyDescriptor(allNodes[0], 'expanded').get as jasmine.Spy) + .and.returnValue(true); + navService.init_invisible_cache(); + expect(navService.visibleChildren.length).toEqual(5); + + (Object.getOwnPropertyDescriptor(allNodes[0], 'disabled').get as jasmine.Spy) + .and.returnValue(true); + navService.update_disabled_cache(allNodes[0]); + expect(navService.visibleChildren.length).toEqual(4); + allNodes.forEach(e => { + (Object.getOwnPropertyDescriptor(e, 'disabled').get as jasmine.Spy) + .and.returnValue(true); + navService.update_disabled_cache(e); + }); + expect(navService.visibleChildren.length).toEqual(0); + mockTree.nodes = null; + expect(navService.visibleChildren.length).toEqual(0); + }); + + it('Should set activeNode and focusedNode correctly', () => { + navService.register(mockTree); + const someNode = { + tabIndex: null, + header: { + nativeElement: jasmine.createSpyObj('nativeElement', ['focus']) + } + } as any; + + const someNode2 = { + tabIndex: null, + header: { + nativeElement: jasmine.createSpyObj('nativeElement', ['focus']) + } + } as any; + + navService.focusedNode = someNode; + expect(someNode.header.nativeElement.focus).toHaveBeenCalled(); + expect(someNode.tabIndex).toBe(0); + + navService.setFocusedAndActiveNode(someNode2); + + expect(navService.activeNode).toEqual(someNode2); + expect(someNode2.header.nativeElement.focus).toHaveBeenCalled(); + expect(someNode2.tabIndex).toBe(0); + expect(someNode.tabIndex).toBe(-1); + expect(mockTree.activeNodeChanged.emit).toHaveBeenCalledTimes(1); + expect(mockTree.activeNodeChanged.emit).toHaveBeenCalledWith(someNode2); + + // do not change active node when call w/ same node + // navService.focusedNode = navService.focusedNode; + expect(mockTree.activeNodeChanged.emit).toHaveBeenCalledTimes(1); + + // handle call w/ null + navService.focusedNode = null; + expect(someNode2.tabIndex).toBe(-1); + expect(mockTree.activeNodeChanged.emit).toHaveBeenCalledTimes(1); + + }); + + it('Should traverse visibleChildren on handleKeyDown', async () => { + navService.register(mockTree); + navService.init_invisible_cache(); + const mockEvent1 = new KeyboardEvent('keydown', { key: 'arrowdown', bubbles: true }); + spyOn(mockEvent1, 'preventDefault'); + spyOn(navService, 'handleKeydown').and.callThrough(); + navService.focusedNode = mockNodesLevel1[0]; + + navService.handleKeydown(mockEvent1); + + expect(mockEvent1.preventDefault).toHaveBeenCalled(); + expect(navService.handleKeydown).toHaveBeenCalledTimes(1); + expect(navService.focusedNode).toEqual(mockNodesLevel1[1]); + + const mockEvent2 = new KeyboardEvent('keydown', { key: 'arrowup', bubbles: true }); + spyOn(mockEvent2, 'preventDefault'); + navService.handleKeydown(mockEvent2); + + expect(mockEvent2.preventDefault).toHaveBeenCalled(); + expect(navService.handleKeydown).toHaveBeenCalledTimes(2); + expect(navService.focusedNode).toEqual(mockNodesLevel1[0]); + + const mockEvent3 = new KeyboardEvent('keydown', { key: 'arrowdown', bubbles: true, repeat: true }); + spyOn(mockEvent3, 'preventDefault'); + // when event is repeated, prevent default and wait + navService.handleKeydown(mockEvent3); + expect(navService.handleKeydown).toHaveBeenCalledTimes(3); + expect(mockEvent3.preventDefault).toHaveBeenCalled(); + // when event is repeating, node does not change immediately + expect(navService.focusedNode).toEqual(mockNodesLevel1[0]); + await wait(1); + expect(navService.focusedNode).toEqual(mockNodesLevel1[1]); + + // does nothing if there is no focused node + navService.focusedNode = null; + const mockEvent4 = new KeyboardEvent('keydown', { key: 'arrowdown', bubbles: true, repeat: false }); + spyOn(mockEvent4, 'preventDefault'); + navService.handleKeydown(mockEvent4); + expect(mockEvent4.preventDefault).not.toHaveBeenCalled(); + + // do not move focused node if on last node + navService.focusedNode = allNodes[allNodes.length - 1]; + navService.handleKeydown(mockEvent4); + expect(navService.focusedNode).toEqual(allNodes[allNodes.length - 1]); + }); + + it('Should update visible children on all relevant tree events', () => { + const mockTreeService = jasmine.createSpyObj('mockSelection', + ['register', 'collapse', 'expand', 'collapsing'], { + collapsingNodes: jasmine.createSpyObj>>('mockCollpasingSet', + ['add', 'delete', 'has'], { + size: 0 + }), + expandedNodes: jasmine.createSpyObj>>('mockExpandedSet', + ['add', 'delete', 'has'], { + size: 0 + }), + }); + const mockElementRef = jasmine.createSpyObj('mockElement', ['nativeElement'], { + nativeElement: document.createElement('div') + }); + const mockSelectionService = jasmine.createSpyObj('mockSelection', + ['selectNodesWithNoEvent', 'selectMultipleNodes', 'deselectNode', 'selectNode', 'register']); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + { provide: IgxTreeService, useValue: mockTreeService }, + { provide: ElementRef, useValue: mockElementRef }, + { provide: IgxTreeSelectionService, useValue: mockSelectionService }, + IgxTreeNavigationService, + IgxTreeComponent + ] + }); + + const nav = TestBed.inject(IgxTreeNavigationService); + + const lvl1Nodes = TreeTestFunctions.createNodeSpies(0, 5); + const mockQuery = TreeTestFunctions.createQueryListSpy(lvl1Nodes); + Object.assign(mockQuery, { changes: new EventEmitter() }); + spyOn(nav, 'init_invisible_cache'); + spyOn(nav, 'update_disabled_cache'); + spyOn(nav, 'update_visible_cache'); + spyOn(nav, 'register'); + const mockPlatform = jasmine.createSpyObj('platform', ['isBrowser', 'isServer']); + const tree = TestBed.inject(IgxTreeComponent, mockPlatform); + tree.nodes = mockQuery; + expect(nav.register).toHaveBeenCalledWith(tree); + expect(nav.init_invisible_cache).not.toHaveBeenCalled(); + expect(nav.update_disabled_cache).not.toHaveBeenCalled(); + expect(nav.update_visible_cache).not.toHaveBeenCalled(); + // not initialized + tree.ngOnInit(); + // manual call + expect(nav.init_invisible_cache).not.toHaveBeenCalled(); + expect(nav.update_disabled_cache).not.toHaveBeenCalled(); + expect(nav.update_visible_cache).not.toHaveBeenCalled(); + // nav service will now be updated after any of the following are emitted + const emitNode = tree.nodes.first; + tree.disabledChange.emit(emitNode); + expect(nav.init_invisible_cache).not.toHaveBeenCalled(); + expect(nav.update_disabled_cache).toHaveBeenCalledTimes(1); + expect(nav.update_visible_cache).toHaveBeenCalledTimes(0); + tree.disabledChange.emit(emitNode); + expect(nav.update_disabled_cache).toHaveBeenCalledTimes(2); + tree.nodeCollapsing.emit({ + node: emitNode, + owner: tree, + cancel: false + }); + expect(nav.update_visible_cache).toHaveBeenCalledTimes(1); + tree.nodeExpanding.emit({ + node: emitNode, + owner: tree, + cancel: false + }); + expect(nav.update_visible_cache).toHaveBeenCalledTimes(2); + // attach emitters to mock children + lvl1Nodes.forEach(e => { + e.expandedChange = new EventEmitter(); + e.openAnimationDone = new EventEmitter(); + e.closeAnimationDone = new EventEmitter(); + }); + tree.ngAfterViewInit(); + // inits cache on tree.ngAfterViewInit(); + expect(nav.init_invisible_cache).toHaveBeenCalledTimes(1); + // init cache when tree nodes collection changes; + (tree.nodes as any).changes.emit(); + expect(nav.init_invisible_cache).toHaveBeenCalledTimes(2); + emitNode.expandedChange.emit(true); + expect(nav.update_visible_cache).toHaveBeenCalledTimes(3); + emitNode.expandedChange.emit(false); + expect(nav.update_visible_cache).toHaveBeenCalledTimes(4); + nav.ngOnDestroy(); + tree.ngOnDestroy(); + }); + }); + }); +}); + + + diff --git a/projects/igniteui-angular/tree/src/tree/tree-node/tree-node.component.html b/projects/igniteui-angular/tree/src/tree/tree-node/tree-node.component.html new file mode 100644 index 00000000000..0e03ff507cf --- /dev/null +++ b/projects/igniteui-angular/tree/src/tree/tree-node/tree-node.component.html @@ -0,0 +1,117 @@ + + + + + + + + +@if (expanded && !loading) { +
    + +
    +} + + + + + + + + + + + + + + +
    + + + + @if (!loading) { + + + + + } + @if (loading) { + + + + + } + + + @if (showSelectors) { +
    + + +
    + } + +
    + + +
    +
    + + +
    + @for (item of [].constructor(level); track $index) { + + } + +
    +
    +
    + + + +
    + +
    +
    diff --git a/projects/igniteui-angular/tree/src/tree/tree-node/tree-node.component.ts b/projects/igniteui-angular/tree/src/tree/tree-node/tree-node.component.ts new file mode 100644 index 00000000000..7e932997b65 --- /dev/null +++ b/projects/igniteui-angular/tree/src/tree/tree-node/tree-node.component.ts @@ -0,0 +1,691 @@ +import { ChangeDetectorRef, Component, ContentChildren, Directive, ElementRef, EventEmitter, HostBinding, HostListener, Input, OnDestroy, OnInit, Output, QueryList, TemplateRef, ViewChild, booleanAttribute, inject } from '@angular/core'; +import { takeUntil } from 'rxjs/operators'; +import { + IgxTree, + IgxTreeNode, + IgxTreeSelectionType, + IGX_TREE_COMPONENT, + IGX_TREE_NODE_COMPONENT, + ITreeNodeTogglingEventArgs +} from '../common'; +import { IgxTreeNavigationService } from '../tree-navigation.service'; +import { IgxTreeSelectionService } from '../tree-selection.service'; +import { IgxTreeService } from '../tree.service'; +import { NgTemplateOutlet, NgClass } from '@angular/common'; +import { IgxIconComponent } from 'igniteui-angular/icon'; +import { IgxCheckboxComponent } from 'igniteui-angular/checkbox'; +import { IgxCircularProgressBarComponent } from 'igniteui-angular/progressbar'; +import { ToggleAnimationPlayer, ToggleAnimationSettings } from 'igniteui-angular/expansion-panel'; +import { getCurrentResourceStrings, ITreeResourceStrings, TreeResourceStringsEN } from 'igniteui-angular/core'; + +// TODO: Implement aria functionality +/** + * @hidden @internal + * Used for links (`a` tags) in the body of an `igx-tree-node`. Handles aria and event dispatch. + */ +@Directive({ + selector: `[igxTreeNodeLink]`, + standalone: true +}) +export class IgxTreeNodeLinkDirective implements OnDestroy { + private node = inject>(IGX_TREE_NODE_COMPONENT, { optional: true }); + private navService = inject(IgxTreeNavigationService); + public elementRef = inject(ElementRef); + + + @HostBinding('attr.role') + public role = 'treeitem'; + + /** + * The node's parent. Should be used only when the link is defined + * in `` tag outside of its parent, as Angular DI will not properly provide a reference + * + * ```html + * + * + * + * + * + * ... + * + * + * {{ data.label }} + * + * + * ``` + */ + @Input('igxTreeNodeLink') + public set parentNode(val: any) { + if (val) { + this._parentNode = val; + (this._parentNode as any).addLinkChild(this); + } + } + + public get parentNode(): any { + return this._parentNode; + } + + /** A pointer to the parent node */ + private get target(): IgxTreeNode { + return this.node || this.parentNode; + } + + private _parentNode: IgxTreeNode = null; + + /** @hidden @internal */ + @HostBinding('attr.tabindex') + public get tabIndex(): number { + return this.navService.focusedNode === this.target ? (this.target?.disabled ? -1 : 0) : -1; + } + + /** + * @hidden @internal + * Clear the node's focused state + */ + @HostListener('blur') + public handleBlur() { + this.target.isFocused = false; + } + + /** + * @hidden @internal + * Set the node as focused + */ + @HostListener('focus') + public handleFocus() { + if (this.target && !this.target.disabled) { + if (this.navService.focusedNode !== this.target) { + this.navService.focusedNode = this.target; + } + this.target.isFocused = true; + } + } + + public ngOnDestroy() { + this.target.removeLinkChild(this); + } +} + +/** + * + * The tree node component represents a child node of the tree component or another tree node. + * Usage: + * + * ```html + * + * ... + * + * {{ data.FirstName }} {{ data.LastName }} + * + * ... + * + * ``` + */ +@Component({ + selector: 'igx-tree-node', + templateUrl: 'tree-node.component.html', + providers: [ + { provide: IGX_TREE_NODE_COMPONENT, useExisting: IgxTreeNodeComponent } + ], + imports: [NgTemplateOutlet, IgxIconComponent, IgxCheckboxComponent, NgClass, IgxCircularProgressBarComponent] +}) +export class IgxTreeNodeComponent extends ToggleAnimationPlayer implements IgxTreeNode, OnInit, OnDestroy { + public tree = inject(IGX_TREE_COMPONENT); + protected selectionService = inject(IgxTreeSelectionService); + protected treeService = inject(IgxTreeService); + protected navService = inject(IgxTreeNavigationService); + protected cdr = inject(ChangeDetectorRef); + private element = inject>(ElementRef); + public parentNode = inject>(IGX_TREE_NODE_COMPONENT, { optional: true, skipSelf: true }); + + /** + * The data entry that the node is visualizing. + * + * @remarks + * Required for searching through nodes. + * + * @example + * ```html + * + * ... + * + * {{ data.FirstName }} {{ data.LastName }} + * + * ... + * + * ``` + */ + @Input() + public data: T; + + /** + * To be used for load-on-demand scenarios in order to specify whether the node is loading data. + * + * @remarks + * Loading nodes do not render children. + */ + @Input({ transform: booleanAttribute }) + public loading = false; + + // TO DO: return different tab index depending on anchor child + /** @hidden @internal */ + public set tabIndex(val: number) { + this._tabIndex = val; + } + + /** @hidden @internal */ + public get tabIndex(): number { + if (this.disabled) { + return -1; + } + if (this._tabIndex === null) { + if (this.navService.focusedNode === null) { + return this.hasLinkChildren ? -1 : 0; + } + return -1; + } + return this.hasLinkChildren ? -1 : this._tabIndex; + } + + /** @hidden @internal */ + public override get animationSettings(): ToggleAnimationSettings { + return this.tree.animationSettings; + } + + /** + * Gets/Sets the resource strings. + * + * @remarks + * Uses EN resources by default. + */ + @Input() + public set resourceStrings(value: ITreeResourceStrings) { + this._resourceStrings = Object.assign({}, this._resourceStrings, value); + } + + /** + * An accessor that returns the resource strings. + */ + public get resourceStrings(): ITreeResourceStrings { + return this._resourceStrings; + } + + /** + * Gets/Sets the active state of the node + * + * @param value: boolean + */ + @Input({ transform: booleanAttribute }) + public set active(value: boolean) { + if (value) { + this.navService.activeNode = this; + this.tree.activeNodeBindingChange.emit(this); + } + } + + public get active(): boolean { + return this.navService.activeNode === this; + } + + /** + * Emitted when the node's `selected` property changes. + * + * ```html + * + * + * + * + * ``` + * + * ```typescript + * const node: IgxTreeNode = this.tree.findNodes(data[0])[0]; + * node.selectedChange.pipe(takeUntil(this.destroy$)).subscribe((e: boolean) => console.log("Node selection changed to ", e)) + * ``` + */ + @Output() + public selectedChange = new EventEmitter(); + + /** + * Emitted when the node's `expanded` property changes. + * + * ```html + * + * + * + * + * ``` + * + * ```typescript + * const node: IgxTreeNode = this.tree.findNodes(data[0])[0]; + * node.expandedChange.pipe(takeUntil(this.destroy$)).subscribe((e: boolean) => console.log("Node expansion state changed to ", e)) + * ``` + */ + @Output() + public expandedChange = new EventEmitter(); + + /** @hidden @internal */ + public get focused() { + return this.isFocused && + this.navService.focusedNode === this; + } + + /** + * Retrieves the full path to the node incuding itself + * + * ```typescript + * const node: IgxTreeNode = this.tree.findNodes(data[0])[0]; + * const path: IgxTreeNode[] = node.path; + * ``` + */ + public get path(): IgxTreeNode[] { + return this.parentNode?.path ? [...this.parentNode.path, this] : [this]; + } + + // TODO: bind to disabled state when node is dragged + /** + * Gets/Sets the disabled state of the node + * + * @param value: boolean + */ + @Input({ transform: booleanAttribute }) + @HostBinding('class.igx-tree-node--disabled') + public get disabled(): boolean { + return this._disabled; + } + + public set disabled(value: boolean) { + if (value !== this._disabled) { + this._disabled = value; + this.tree.disabledChange.emit(this); + } + } + + /** @hidden @internal */ + @HostBinding('class.igx-tree-node') + public cssClass = 'igx-tree-node'; + + /** @hidden @internal */ + @HostBinding('attr.role') + public get role() { + return this.hasLinkChildren ? 'none' : 'treeitem'; + } + + /** @hidden @internal */ + @ContentChildren(IgxTreeNodeLinkDirective, { read: ElementRef }) + public linkChildren: QueryList; + + /** @hidden @internal */ + @ContentChildren(IGX_TREE_NODE_COMPONENT, { read: IGX_TREE_NODE_COMPONENT }) + public _children: QueryList>; + + /** @hidden @internal */ + @ContentChildren(IGX_TREE_NODE_COMPONENT, { read: IGX_TREE_NODE_COMPONENT, descendants: true }) + public allChildren: QueryList>; + + /** + * Return the child nodes of the node (if any) + * + * @remarks + * Returns `null` if node does not have children + * + * @example + * ```typescript + * const node: IgxTreeNode = this.tree.findNodes(data[0])[0]; + * const children: IgxTreeNode[] = node.children; + * ``` + */ + public get children(): IgxTreeNode[] { + return this._children?.length ? this._children.toArray() : null; + } + + // TODO: will be used in Drag and Drop implementation + /** @hidden @internal */ + @ViewChild('ghostTemplate', { read: ElementRef }) + public header: ElementRef; + + @ViewChild('defaultIndicator', { read: TemplateRef, static: true }) + private _defaultExpandIndicatorTemplate: TemplateRef; + + @ViewChild('childrenContainer', { read: ElementRef }) + private childrenContainer: ElementRef; + + private get hasLinkChildren(): boolean { + return this.linkChildren?.length > 0 || this.registeredChildren?.length > 0; + } + + /** @hidden @internal */ + public isFocused: boolean; + + /** @hidden @internal */ + public registeredChildren: IgxTreeNodeLinkDirective[] = []; + + /** @hidden @internal */ + private _resourceStrings = getCurrentResourceStrings(TreeResourceStringsEN); + + private _tabIndex = null; + private _disabled = false; + + /** + * @hidden @internal + */ + public get showSelectors() { + return this.tree.selection !== IgxTreeSelectionType.None; + } + + /** + * @hidden @internal + */ + public get indeterminate(): boolean { + return this.selectionService.isNodeIndeterminate(this); + } + + /** The depth of the node, relative to the root + * + * ```html + * + * ... + * + * My level is {{ node.level }} + * + * + * ``` + * + * ```typescript + * const node: IgxTreeNode = this.tree.findNodes(data[12])[0]; + * const level: number = node.level; + * ``` + */ + public get level(): number { + return this.parentNode ? this.parentNode.level + 1 : 0; + } + + /** Get/set whether the node is selected. Supporst two-way binding. + * + * ```html + * + * ... + * + * {{ node.label }} + * + * + * ``` + * + * ```typescript + * const node: IgxTreeNode = this.tree.findNodes(data[0])[0]; + * const selected = node.selected; + * node.selected = true; + * ``` + */ + @Input({ transform: booleanAttribute }) + public get selected(): boolean { + return this.selectionService.isNodeSelected(this); + } + + public set selected(val: boolean) { + if (!(this.tree?.nodes && this.tree.nodes.find((e) => e === this)) && val) { + this.tree.forceSelect.push(this); + return; + } + if (val && !this.selectionService.isNodeSelected(this)) { + this.selectionService.selectNodesWithNoEvent([this]); + } + if (!val && this.selectionService.isNodeSelected(this)) { + this.selectionService.deselectNodesWithNoEvent([this]); + } + } + + /** Get/set whether the node is expanded + * + * ```html + * + * ... + * + * {{ node.label }} + * + * + * ``` + * + * ```typescript + * const node: IgxTreeNode = this.tree.findNodes(data[0])[0]; + * const expanded = node.expanded; + * node.expanded = true; + * ``` + */ + @Input({ transform: booleanAttribute }) + public get expanded() { + return this.treeService.isExpanded(this); + } + + public set expanded(val: boolean) { + if (val) { + this.treeService.expand(this, false); + } else { + this.treeService.collapse(this); + } + } + + /** @hidden @internal */ + public get expandIndicatorTemplate(): TemplateRef { + return this.tree?.expandIndicator || this._defaultExpandIndicatorTemplate; + } + + /** + * The native DOM element representing the node. Could be null in certain environments. + * + * ```typescript + * // get the nativeElement of the second node + * const node: IgxTreeNode = this.tree.nodes.first(); + * const nodeElement: HTMLElement = node.nativeElement; + * ``` + */ + /** @hidden @internal */ + public get nativeElement() { + return this.element.nativeElement; + } + + /** @hidden @internal */ + public ngOnInit() { + this.openAnimationDone.pipe(takeUntil(this.destroy$)).subscribe( + () => { + this.tree.nodeExpanded.emit({ owner: this.tree, node: this }); + } + ); + this.closeAnimationDone.pipe(takeUntil(this.destroy$)).subscribe(() => { + this.tree.nodeCollapsed.emit({ owner: this.tree, node: this }); + this.treeService.collapse(this); + this.cdr.markForCheck(); + }); + } + + /** + * @hidden @internal + * Sets the focus to the node's child, if present + * Sets the node as the tree service's focusedNode + * Marks the node as the current active element + */ + public handleFocus(): void { + if (this.disabled) { + return; + } + if (this.navService.focusedNode !== this) { + this.navService.focusedNode = this; + } + this.isFocused = true; + if (this.linkChildren?.length) { + this.linkChildren.first.nativeElement.focus(); + return; + } + if (this.registeredChildren.length) { + this.registeredChildren[0].elementRef.nativeElement.focus(); + return; + } + } + + /** + * @hidden @internal + * Clear the node's focused status + */ + public clearFocus(): void { + this.isFocused = false; + } + + /** + * @hidden @internal + */ + public onSelectorPointerDown(event) { + event.preventDefault(); + event.stopPropagation() + } + + /** + * @hidden @internal + */ + public onSelectorClick(event) { + // event.stopPropagation(); + event.preventDefault(); + // this.navService.handleFocusedAndActiveNode(this); + if (event.shiftKey) { + this.selectionService.selectMultipleNodes(this, event); + return; + } + if (this.selected) { + this.selectionService.deselectNode(this, event); + } else { + this.selectionService.selectNode(this, event); + } + } + + /** + * Toggles the node expansion state, triggering animation + * + * ```html + * + * My Node + * + * + * ``` + * + * ```typescript + * const myNode: IgxTreeNode = this.tree.findNodes(data[0])[0]; + * myNode.toggle(); + * ``` + */ + public toggle() { + if (this.expanded) { + this.collapse(); + } else { + this.expand(); + } + } + + /** @hidden @internal */ + public indicatorClick() { + if(!this.tree.toggleNodeOnClick) { + this.toggle(); + this.navService.setFocusedAndActiveNode(this); + } + } + + /** + * @hidden @internal + */ + public onPointerDown(event) { + event.stopPropagation(); + + //Toggle the node only on left mouse click - https://w3c.github.io/pointerevents/#button-states + if(this.tree.toggleNodeOnClick && event.button === 0) { + this.toggle(); + } + + this.navService.setFocusedAndActiveNode(this); + } + + public override ngOnDestroy() { + super.ngOnDestroy(); + this.selectionService.ensureStateOnNodeDelete(this); + } + + /** + * Expands the node, triggering animation + * + * ```html + * + * My Node + * + * + * ``` + * + * ```typescript + * const myNode: IgxTreeNode = this.tree.findNodes(data[0])[0]; + * myNode.expand(); + * ``` + */ + public expand() { + if (this.expanded && !this.treeService.collapsingNodes.has(this)) { + return; + } + const args: ITreeNodeTogglingEventArgs = { + owner: this.tree, + node: this, + cancel: false + + }; + this.tree.nodeExpanding.emit(args); + if (!args.cancel) { + this.treeService.expand(this, true); + this.cdr.detectChanges(); + this.playOpenAnimation( + this.childrenContainer + ); + } + } + + /** + * Collapses the node, triggering animation + * + * ```html + * + * My Node + * + * + * ``` + * + * ```typescript + * const myNode: IgxTreeNode = this.tree.findNodes(data[0])[0]; + * myNode.collapse(); + * ``` + */ + public collapse() { + if (!this.expanded || this.treeService.collapsingNodes.has(this)) { + return; + } + const args: ITreeNodeTogglingEventArgs = { + owner: this.tree, + node: this, + cancel: false + + }; + this.tree.nodeCollapsing.emit(args); + if (!args.cancel) { + this.treeService.collapsing(this); + this.playCloseAnimation( + this.childrenContainer + ); + } + } + + /** @hidden @internal */ + public addLinkChild(link: IgxTreeNodeLinkDirective) { + this._tabIndex = -1; + this.registeredChildren.push(link); + } + + /** @hidden @internal */ + public removeLinkChild(link: IgxTreeNodeLinkDirective) { + const index = this.registeredChildren.indexOf(link); + if (index !== -1) { + this.registeredChildren.splice(index, 1); + } + } +} diff --git a/projects/igniteui-angular/tree/src/tree/tree-samples.spec.ts b/projects/igniteui-angular/tree/src/tree/tree-samples.spec.ts new file mode 100644 index 00000000000..6d98bc70ed2 --- /dev/null +++ b/projects/igniteui-angular/tree/src/tree/tree-samples.spec.ts @@ -0,0 +1,163 @@ +import { Component, ViewChild, ChangeDetectorRef, inject } from '@angular/core'; +import { IgxTreeComponent, IgxTreeExpandIndicatorDirective, IgxTreeNodeComponent, IgxTreeNodeLinkDirective } from './public_api'; +import { HIERARCHICAL_SAMPLE_DATA } from 'src/app/shared/sample-data'; +import { NgTemplateOutlet } from '@angular/common'; +import { IgxIconComponent } from 'igniteui-angular/icon'; + +@Component({ + template: ` + + @for (node of data; track node.ID) { + + {{ node.CompanyName }} + @for (child of node.ChildCompanies; track child.ID) { + + {{ child.CompanyName }} + @for (leafChild of child.ChildCompanies; track leafChild.ID) { + + {{ leafChild.CompanyName }} + + } + + } + + } + + `, + imports: [IgxTreeComponent, IgxTreeNodeComponent] +}) +export class IgxTreeSimpleComponent { + @ViewChild(IgxTreeComponent, { static: true }) public tree: IgxTreeComponent; + public data = HIERARCHICAL_SAMPLE_DATA; +} + +@Component({ + template: ` + + @for (node of data; track node.ID) { + + {{ node.CompanyName }} + @for (child of node.ChildCompanies; track child.ID) { + + {{ child.CompanyName }} + @for (leafChild of child.ChildCompanies; track leafChild.ID) { + + {{ leafChild.CompanyName }} + + } + + } + + } + + `, + imports: [IgxTreeComponent, IgxTreeNodeComponent] +}) +export class IgxTreeSelectionSampleComponent { + public cdr = inject(ChangeDetectorRef); + + @ViewChild(IgxTreeComponent, { static: true }) public tree: IgxTreeComponent; + public data; + constructor() { + this.data = HIERARCHICAL_SAMPLE_DATA; + this.mapData(this.data); + } + private mapData(data: any[]) { + data.forEach(x => { + x.selected = false; + if (x.hasOwnProperty('ChildCompanies') && x.ChildCompanies.length) { + this.mapData(x.ChildCompanies); + } + }); + } +} + +@Component({ + template: ` + + @for (node of data; track node.ID) { + + {{ node.CompanyName }} + Disable Node Level 1 + @for (child of node.ChildCompanies; track child.ID) { + + {{ child.CompanyName }} + @for (leafChild of child.ChildCompanies; track leafChild.ID) { + + {{ leafChild.CompanyName }} + + } + Disable Node Level 2 + + } + + } + Disable Node Level 0 + @if (showNodesWithDirective) { + + Link to Infragistics + @for (node of [].constructor(3); track $index) { + + Link to Infragistics + + } + + Link to Infragistics + + + } + @if (showNodesWithDirective) { + + + + + + @for (node of [].constructor(3); track $index) { + + + + } + + } + + Link to Infragistics + + + `, + imports: [IgxTreeComponent, IgxTreeNodeComponent, IgxTreeNodeLinkDirective, NgTemplateOutlet] +}) +export class IgxTreeNavigationComponent { + @ViewChild(IgxTreeComponent, { static: true }) public tree: IgxTreeComponent; + public data = HIERARCHICAL_SAMPLE_DATA; + public showNodesWithDirective = false; + public isDisabled = false; +} +@Component({ + template: ` + + @for (node of data; track node.ID) { + + {{ node.CompanyName }} + @for (child of node.ChildCompanies; track child.ID) { + + {{ child.CompanyName }} + @for (leafChild of child.ChildCompanies; track leafChild.ID) { + + {{ leafChild.CompanyName }} + + } + + } + + } + + {{ expanded ? "close_fullscreen": "open_in_full"}} + + + `, + imports: [IgxTreeComponent, IgxTreeNodeComponent, IgxTreeExpandIndicatorDirective, IgxIconComponent] +}) +export class IgxTreeScrollComponent { + @ViewChild(IgxTreeComponent, { static: true }) public tree: IgxTreeComponent; + public data = HIERARCHICAL_SAMPLE_DATA; +} diff --git a/projects/igniteui-angular/tree/src/tree/tree-selection.service.spec.ts b/projects/igniteui-angular/tree/src/tree/tree-selection.service.spec.ts new file mode 100644 index 00000000000..82d8162c187 --- /dev/null +++ b/projects/igniteui-angular/tree/src/tree/tree-selection.service.spec.ts @@ -0,0 +1,653 @@ +import { EventEmitter } from '@angular/core'; +import { IgxTree, IgxTreeNode, IgxTreeSelectionType, ITreeNodeSelectionEvent } from './common'; +import { TreeTestFunctions } from './tree-functions.spec'; +import { IgxTreeNodeComponent } from './tree-node/tree-node.component'; +import { IgxTreeSelectionService } from './tree-selection.service'; + +describe('IgxTreeSelectionService - Unit Tests #treeView', () => { + let selectionService: IgxTreeSelectionService; + let mockEmitter: EventEmitter; + let mockTree: IgxTree; + let mockNodesLevel1: IgxTreeNodeComponent[]; + let mockNodesLevel2_1: IgxTreeNodeComponent[]; + let mockNodesLevel2_2: IgxTreeNodeComponent[]; + let mockNodesLevel3_1: IgxTreeNodeComponent[]; + let mockNodesLevel3_2: IgxTreeNodeComponent[]; + let allNodes: IgxTreeNodeComponent[]; + const mockQuery1: any = {}; + const mockQuery2: any = {}; + const mockQuery3: any = {}; + const mockQuery4: any = {}; + const mockQuery5: any = {}; + const mockQuery6: any = {}; + + beforeEach(() => { + selectionService = new IgxTreeSelectionService(); + mockNodesLevel1 = TreeTestFunctions.createNodeSpies(0, 3, null, [mockQuery2, mockQuery3], [mockQuery6, mockQuery3]); + mockNodesLevel2_1 = TreeTestFunctions.createNodeSpies(1, 2, mockNodesLevel1[0], [mockQuery4, mockQuery5], [mockQuery4, mockQuery5]); + mockNodesLevel2_2 = TreeTestFunctions.createNodeSpies(1, 1, mockNodesLevel1[1], null); + mockNodesLevel3_1 = TreeTestFunctions.createNodeSpies(2, 2, mockNodesLevel2_1[0], null); + mockNodesLevel3_2 = TreeTestFunctions.createNodeSpies(2, 2, mockNodesLevel2_1[1], null); + allNodes = [ + mockNodesLevel1[0], + mockNodesLevel2_1[0], + ...mockNodesLevel3_1, + mockNodesLevel2_1[1], + ...mockNodesLevel3_2, + mockNodesLevel1[1], + ...mockNodesLevel2_2, + mockNodesLevel1[2] + ]; + + Object.assign(mockQuery1, TreeTestFunctions.createQueryListSpy(allNodes)); + Object.assign(mockQuery2, TreeTestFunctions.createQueryListSpy(mockNodesLevel2_1)); + Object.assign(mockQuery3, TreeTestFunctions.createQueryListSpy(mockNodesLevel2_2)); + Object.assign(mockQuery4, TreeTestFunctions.createQueryListSpy(mockNodesLevel3_1)); + Object.assign(mockQuery5, TreeTestFunctions.createQueryListSpy(mockNodesLevel3_2)); + Object.assign(mockQuery6, TreeTestFunctions.createQueryListSpy([ + mockNodesLevel2_1[0], + ...mockNodesLevel3_1, + mockNodesLevel2_1[1], + ...mockNodesLevel3_2 + ])); + }); + + describe('IgxTreeSelectionService - BiState & None', () => { + beforeEach(() => { + mockEmitter = jasmine.createSpyObj('emitter', ['emit']); + mockTree = jasmine.createSpyObj('tree', [''], + { selection: IgxTreeSelectionType.BiState, nodeSelection: mockEmitter, nodes: mockQuery1 }); + selectionService.register(mockTree); + }); + + it('Should properly register the specified tree', () => { + selectionService = new IgxTreeSelectionService(); + + expect((selectionService as any).tree).toBeFalsy(); + + selectionService.register(mockTree); + expect((selectionService as any).tree).toEqual(mockTree); + }); + + it('Should return proper value when isNodeSelected is called', () => { + const selectionSet: Set> = (selectionService as any).nodeSelection; + + expect(selectionSet.size).toBe(0); + + spyOn(selectionSet, 'clear').and.callThrough(); + + const mockNode1 = TreeTestFunctions.createNodeSpy(); + const mockNode2 = TreeTestFunctions.createNodeSpy(); + expect(selectionService.isNodeSelected(mockNode1)).toBeFalsy(); + expect(selectionService.isNodeSelected(mockNode2)).toBeFalsy(); + + selectionSet.add(mockNode1); + + expect(selectionService.isNodeSelected(mockNode1)).toBeTruthy(); + expect(selectionService.isNodeSelected(mockNode2)).toBeFalsy(); + expect(selectionSet.size).toBe(1); + + selectionService.clearNodesSelection(); + + expect(selectionService.isNodeSelected(mockNode1)).toBeFalsy(); + expect(selectionService.isNodeSelected(mockNode2)).toBeFalsy(); + expect(selectionSet.clear).toHaveBeenCalled(); + expect(selectionSet.size).toBe(0); + }); + + it('Should handle selection based on tree.selection', () => { + const mockSelectedChangeEmitter: EventEmitter = jasmine.createSpyObj('emitter', ['emit']); + const mockNode = TreeTestFunctions.createNodeSpy({ selectedChange: mockSelectedChangeEmitter }); + + // None + (Object.getOwnPropertyDescriptor(mockTree, 'selection').get as jasmine.Spy).and.returnValue(IgxTreeSelectionType.None); + selectionService.selectNode(mockNode); + expect(selectionService.isNodeSelected(mockNode)).toBeFalsy(); + expect(mockTree.nodeSelection.emit).not.toHaveBeenCalled(); + expect(mockNode.selectedChange.emit).not.toHaveBeenCalled(); + + // BiState + (Object.getOwnPropertyDescriptor(mockTree, 'selection').get as jasmine.Spy) + .and.returnValue(IgxTreeSelectionType.BiState); + let expected: ITreeNodeSelectionEvent = { + oldSelection: [], newSelection: [mockNode], + added: [mockNode], removed: [], event: undefined, cancel: false, owner: mockTree + }; + + selectionService.selectNode(mockNode); + + expect(selectionService.isNodeSelected(mockNode)).toBeTruthy(); + expect(mockTree.nodeSelection.emit).toHaveBeenCalledTimes(1); + expect(mockTree.nodeSelection.emit).toHaveBeenCalledWith(expected); + expect(mockNode.selectedChange.emit).toHaveBeenCalledTimes(1); + expect(mockNode.selectedChange.emit).toHaveBeenCalledWith(true); + + // Cascading + selectionService.deselectNode(mockNode); + + (Object.getOwnPropertyDescriptor(mockTree, 'selection').get as jasmine.Spy) + .and.returnValue(IgxTreeSelectionType.Cascading); + selectionService.selectNode(allNodes[1]); + + expected = { + oldSelection: [], newSelection: [allNodes[1], allNodes[2], allNodes[3]], + added: [allNodes[1], allNodes[2], allNodes[3]], removed: [], event: undefined, cancel: false, owner: mockTree + }; + + expect(selectionService.isNodeSelected(allNodes[1])).toBeTruthy(); + expect(selectionService.isNodeSelected(allNodes[2])).toBeTruthy(); + expect(selectionService.isNodeSelected(allNodes[3])).toBeTruthy(); + expect(selectionService.isNodeIndeterminate(allNodes[0])).toBeTruthy(); + expect(mockTree.nodeSelection.emit).toHaveBeenCalledTimes(3); + expect(mockTree.nodeSelection.emit).toHaveBeenCalledWith(expected); + for (let i = 1; i < 4; i++) { + expect(allNodes[i].selectedChange.emit).toHaveBeenCalled(); + expect(allNodes[i].selectedChange.emit).toHaveBeenCalledWith(true); + } + }); + + it('Should deselect nodes', () => { + const mockSelectedChangeEmitter: EventEmitter = jasmine.createSpyObj('emitter', ['emit']); + const mockNode1 = TreeTestFunctions.createNodeSpy({ selectedChange: mockSelectedChangeEmitter }); + const mockNode2 = TreeTestFunctions.createNodeSpy({ selectedChange: mockSelectedChangeEmitter }); + + selectionService.deselectNode(mockNode1); + + expect(selectionService.isNodeSelected(mockNode1)).toBeFalsy(); + expect(selectionService.isNodeSelected(mockNode2)).toBeFalsy(); + expect(mockTree.nodeSelection.emit).not.toHaveBeenCalled(); + expect(mockNode1.selectedChange.emit).not.toHaveBeenCalled(); + + // mark a node as selected + selectionService.selectNode(mockNode1); + + expect(selectionService.isNodeSelected(mockNode1)).toBeTruthy(); + expect(selectionService.isNodeSelected(mockNode2)).toBeFalsy(); + expect(mockTree.nodeSelection.emit).toHaveBeenCalledTimes(1); + expect(mockNode1.selectedChange.emit).toHaveBeenCalledTimes(1); + expect(mockNode1.selectedChange.emit).toHaveBeenCalledWith(true); + + // deselect node + const expected: ITreeNodeSelectionEvent = { + newSelection: [], oldSelection: [mockNode1], + removed: [mockNode1], added: [], event: undefined, cancel: false, owner: mockTree + }; + selectionService.deselectNode(mockNode1); + + expect(selectionService.isNodeSelected(mockNode1)).toBeFalsy(); + expect(selectionService.isNodeSelected(mockNode2)).toBeFalsy(); + expect(mockTree.nodeSelection.emit).toHaveBeenCalledTimes(2); + expect(mockTree.nodeSelection.emit).toHaveBeenCalledWith(expected); + expect(mockNode1.selectedChange.emit).toHaveBeenCalledTimes(2); + expect(mockNode1.selectedChange.emit).toHaveBeenCalledWith(false); + }); + + it('Should be able to deselect all nodes', () => { + selectionService.selectNodesWithNoEvent(allNodes.slice(0, 3)); + for (const node of allNodes.slice(0, 3)) { + expect(selectionService.isNodeSelected(node)).toBeTruthy(); + expect(node.selectedChange.emit).toHaveBeenCalled(); + expect(node.selectedChange.emit).toHaveBeenCalledWith(true); + } + expect(mockTree.nodeSelection.emit).not.toHaveBeenCalled(); + + selectionService.deselectNodesWithNoEvent(); + + for (const node of allNodes.slice(0, 3)) { + expect(selectionService.isNodeSelected(node)).toBeFalsy(); + expect(node.selectedChange.emit).toHaveBeenCalled(); + expect(node.selectedChange.emit).toHaveBeenCalledWith(false); + } + expect(mockTree.nodeSelection.emit).not.toHaveBeenCalled(); + }); + + it('Should be able to deselect range of nodes', () => { + selectionService.selectNodesWithNoEvent(allNodes.slice(0, 3)); + + for (const node of allNodes.slice(0, 3)) { + expect(selectionService.isNodeSelected(node)).toBeTruthy(); + expect(node.selectedChange.emit).toHaveBeenCalled(); + expect(node.selectedChange.emit).toHaveBeenCalledWith(true); + } + expect(mockTree.nodeSelection.emit).not.toHaveBeenCalled(); + + selectionService.deselectNodesWithNoEvent([allNodes[0], allNodes[2]]); + + expect(selectionService.isNodeSelected(allNodes[0])).toBeFalsy(); + expect(selectionService.isNodeSelected(allNodes[2])).toBeFalsy(); + expect(allNodes[0].selectedChange.emit).toHaveBeenCalled(); + expect(allNodes[0].selectedChange.emit).toHaveBeenCalledWith(false); + expect(allNodes[2].selectedChange.emit).toHaveBeenCalled(); + expect(allNodes[2].selectedChange.emit).toHaveBeenCalledWith(false); + expect(mockTree.nodeSelection.emit).not.toHaveBeenCalled(); + }); + + it('Should be able to select multiple nodes', () => { + selectionService.selectNodesWithNoEvent(allNodes.slice(0, 3)); + + for (const node of allNodes.slice(0, 3)) { + expect(selectionService.isNodeSelected(node)).toBeTruthy(); + expect(node.selectedChange.emit).toHaveBeenCalled(); + expect(node.selectedChange.emit).toHaveBeenCalledWith(true); + } + expect(selectionService.isNodeSelected(allNodes[3])).toBeFalsy(); + + selectionService.selectNodesWithNoEvent([allNodes[3]]); + for (const node of allNodes.slice(0, 4)) { + expect(selectionService.isNodeSelected(node)).toBeTruthy(); + } + expect(allNodes[3].selectedChange.emit).toHaveBeenCalled(); + expect(allNodes[3].selectedChange.emit).toHaveBeenCalledWith(true); + + expect(mockTree.nodeSelection.emit).not.toHaveBeenCalled(); + }); + + it('Should be able to clear selection when adding multiple nodes', () => { + selectionService.selectNodesWithNoEvent(allNodes.slice(0, 3)); + + for (const node of allNodes.slice(0, 3)) { + expect(selectionService.isNodeSelected(node)).toBeTruthy(); + expect(node.selectedChange.emit).toHaveBeenCalled(); + expect(node.selectedChange.emit).toHaveBeenCalledWith(true); + } + expect(selectionService.isNodeSelected(allNodes[3])).toBeFalsy(); + + selectionService.selectNodesWithNoEvent(allNodes.slice(1, 4), true); + + for (const node of allNodes.slice(1, 4)) { + expect(selectionService.isNodeSelected(node)).toBeTruthy(); + expect(node.selectedChange.emit).toHaveBeenCalled(); + expect(node.selectedChange.emit).toHaveBeenCalledWith(true); + } + expect(selectionService.isNodeSelected(allNodes[0])).toBeFalsy(); + expect(mockTree.nodeSelection.emit).not.toHaveBeenCalled(); + }); + + it('Should add newly selected nodes to the existing selection', () => { + selectionService.selectNode(mockTree.nodes.first); + + let expected: ITreeNodeSelectionEvent = { + oldSelection: [], newSelection: [mockQuery1.first], + added: [mockQuery1.first], removed: [], event: undefined, cancel: false, owner: mockTree + }; + + expect(selectionService.isNodeSelected(allNodes[0])).toBeTruthy(); + expect(mockTree.nodes.first.selectedChange.emit).toHaveBeenCalled(); + expect(mockTree.nodes.first.selectedChange.emit).toHaveBeenCalledWith(true); + + for (let i = 1; i < allNodes.length; i++) { + expect(selectionService.isNodeSelected(allNodes[i])).toBeFalsy(); + } + + expect(mockTree.nodeSelection.emit).toHaveBeenCalledWith(expected); + expect(mockTree.nodeSelection.emit).toHaveBeenCalledTimes(1); + + expected = { + oldSelection: [allNodes[0]], newSelection: [allNodes[0], allNodes[1]], + added: [allNodes[1]], removed: [], event: undefined, cancel: false, owner: mockTree + }; + + selectionService.selectNode(mockTree.nodes.toArray()[1]); + + expect(mockTree.nodes.toArray()[1].selectedChange.emit).toHaveBeenCalled(); + expect(mockTree.nodes.toArray()[1].selectedChange.emit).toHaveBeenCalledWith(true); + expect(mockTree.nodeSelection.emit).toHaveBeenCalledWith(expected); + expect(mockTree.nodeSelection.emit).toHaveBeenCalledTimes(2); + expect(selectionService.isNodeSelected(allNodes[0])).toBeTruthy(); + expect(selectionService.isNodeSelected(allNodes[1])).toBeTruthy(); + }); + + it('Should be able to select a range of nodes', () => { + selectionService.selectNode(allNodes[3]); + + // only third node is selected + expect(selectionService.isNodeSelected(allNodes[3])).toBeTruthy(); + expect(allNodes[3].selectedChange.emit).toHaveBeenCalled(); + expect(allNodes[3].selectedChange.emit).toHaveBeenCalledWith(true); + for (let i = 0; i < allNodes.length; i++) { + if (i !== 3) { + expect(selectionService.isNodeSelected(allNodes[i])).toBeFalsy(); + } + } + + // select all nodes from third to eighth + selectionService.selectMultipleNodes(allNodes[8]); + + allNodes.forEach((node, index) => { + if (index >= 3 && index <= 8) { + expect(selectionService.isNodeSelected(node)).toBeTruthy(); + expect(node.selectedChange.emit).toHaveBeenCalled(); + expect(node.selectedChange.emit).toHaveBeenCalledWith(true); + } else { + expect(selectionService.isNodeSelected(node)).toBeFalsy(); + } + }); + + const expected: ITreeNodeSelectionEvent = { + oldSelection: [allNodes[3]], newSelection: allNodes.slice(3, 9), + added: allNodes.slice(4, 9), removed: [], event: undefined, cancel: false, owner: mockTree + }; + expect(mockTree.nodeSelection.emit).toHaveBeenCalledWith(expected); + }); + + it('Should be able to select a range of nodes in reverse order', () => { + selectionService.selectNode(allNodes[8]); + + // only eighth node is selected + expect(selectionService.isNodeSelected(allNodes[8])).toBeTruthy(); + expect(allNodes[8].selectedChange.emit).toHaveBeenCalled(); + expect(allNodes[8].selectedChange.emit).toHaveBeenCalledWith(true); + for (let i = 0; i < allNodes.length; i++) { + if (i !== 8) { + expect(selectionService.isNodeSelected(allNodes[i])).toBeFalsy(); + } + } + + // select all nodes from eighth to second + selectionService.selectMultipleNodes(allNodes[2]); + + allNodes.forEach((node, index) => { + if (index >= 2 && index <= 8) { + expect(selectionService.isNodeSelected(node)).toBeTruthy(); + expect(node.selectedChange.emit).toHaveBeenCalled(); + expect(node.selectedChange.emit).toHaveBeenCalledWith(true); + } else { + expect(selectionService.isNodeSelected(node)).toBeFalsy(); + } + }); + + const expected: ITreeNodeSelectionEvent = { + oldSelection: [allNodes[8]], newSelection: [allNodes[8], ...allNodes.slice(2, 8)], + added: allNodes.slice(2, 8), removed: [], event: undefined, cancel: false, owner: mockTree + }; + expect(mockTree.nodeSelection.emit).toHaveBeenCalledWith(expected); + }); + }); + + describe('IgxTreeSelectionService - Cascading', () => { + beforeEach(() => { + mockEmitter = jasmine.createSpyObj('emitter', ['emit']); + mockTree = jasmine.createSpyObj('tree', [''], + { selection: IgxTreeSelectionType.Cascading, nodeSelection: mockEmitter, nodes: mockQuery1 }); + selectionService.register(mockTree); + }); + + it('Should deselect nodes', () => { + selectionService.deselectNode(allNodes[1]); + + for (const node of allNodes.slice(1, 4)) { + expect(selectionService.isNodeSelected(node)).toBeFalsy(); + expect(node.selectedChange.emit).not.toHaveBeenCalled(); + } + expect(mockTree.nodeSelection.emit).not.toHaveBeenCalled(); + + // mark a node as selected + selectionService.selectNode(allNodes[1]); + + for (const node of allNodes.slice(1, 4)) { + expect(selectionService.isNodeSelected(node)).toBeTruthy(); + expect(node.selectedChange.emit).toHaveBeenCalled(); + expect(node.selectedChange.emit).toHaveBeenCalledWith(true); + } + expect(selectionService.isNodeIndeterminate(allNodes[0])).toBeTruthy(); + expect(allNodes[0].selectedChange.emit).not.toHaveBeenCalled(); + expect(mockTree.nodeSelection.emit).toHaveBeenCalledTimes(1); + + const expected: ITreeNodeSelectionEvent = { + newSelection: [], oldSelection: [allNodes[1], allNodes[2], allNodes[3]], + removed: [allNodes[1], allNodes[2], allNodes[3]], added: [], event: undefined, cancel: false, owner: mockTree + }; + // deselect node + selectionService.deselectNode(allNodes[1]); + + for (const node of allNodes.slice(1, 4)) { + expect(selectionService.isNodeSelected(node)).toBeFalsy(); + expect(node.selectedChange.emit).toHaveBeenCalled(); + expect(node.selectedChange.emit).toHaveBeenCalledWith(false); + } + expect(selectionService.isNodeIndeterminate(allNodes[0])).toBeFalse(); + expect(mockTree.nodeSelection.emit).toHaveBeenCalledTimes(2); + expect(mockTree.nodeSelection.emit).toHaveBeenCalledWith(expected); + }); + + it('Should be able to deselect range of nodes', () => { + selectionService.selectNodesWithNoEvent([allNodes[1], allNodes[4]]); + + for (const node of allNodes.slice(0, 7)) { + expect(selectionService.isNodeSelected(node)).toBeTruthy(); + expect(node.selectedChange.emit).toHaveBeenCalled(); + expect(node.selectedChange.emit).toHaveBeenCalledWith(true); + } + expect(mockTree.nodeSelection.emit).not.toHaveBeenCalled(); + + selectionService.deselectNodesWithNoEvent([allNodes[1], allNodes[5]]); + + for (const node of allNodes.slice(1, 4)) { + expect(selectionService.isNodeSelected(node)).toBeFalsy(); + expect(node.selectedChange.emit).toHaveBeenCalled(); + expect(node.selectedChange.emit).toHaveBeenCalledWith(false); + } + expect(selectionService.isNodeSelected(allNodes[5])).toBeFalsy(); + expect(selectionService.isNodeSelected(allNodes[6])).toBeTruthy(); + expect(selectionService.isNodeIndeterminate(allNodes[0])).toBeTruthy(); + expect(selectionService.isNodeIndeterminate(allNodes[4])).toBeTruthy(); + expect(mockTree.nodeSelection.emit).not.toHaveBeenCalled(); + }); + + it('Should be able to select multiple nodes', () => { + selectionService.selectNodesWithNoEvent([allNodes[1], allNodes[8]]); + + for (const node of allNodes.slice(1, 4)) { + expect(selectionService.isNodeSelected(node)).toBeTruthy(); + expect(node.selectedChange.emit).toHaveBeenCalled(); + expect(node.selectedChange.emit).toHaveBeenCalledWith(true); + } + expect(selectionService.isNodeSelected(allNodes[7])).toBeTruthy(); + expect(selectionService.isNodeSelected(allNodes[8])).toBeTruthy(); + expect(selectionService.isNodeIndeterminate(allNodes[0])).toBeTruthy(); + + expect(mockTree.nodeSelection.emit).not.toHaveBeenCalled(); + }); + + it('Should be able to clear selection when adding multiple nodes', () => { + selectionService.selectNodesWithNoEvent([allNodes[1]], true); + + for (const node of allNodes.slice(1, 4)) { + expect(selectionService.isNodeSelected(node)).toBeTruthy(); + expect(node.selectedChange.emit).toHaveBeenCalled(); + expect(node.selectedChange.emit).toHaveBeenCalledWith(true); + } + expect(selectionService.isNodeIndeterminate(allNodes[0])).toBeTruthy(); + expect(mockTree.nodeSelection.emit).not.toHaveBeenCalled(); + + selectionService.selectNodesWithNoEvent([allNodes[3], allNodes[4]], true); + + for (const node of allNodes.slice(3, 7)) { + expect(selectionService.isNodeSelected(node)).toBeTruthy(); + expect(node.selectedChange.emit).toHaveBeenCalled(); + expect(node.selectedChange.emit).toHaveBeenCalledWith(true); + } + expect(selectionService.isNodeIndeterminate(allNodes[0])).toBeTruthy(); + expect(selectionService.isNodeIndeterminate(allNodes[1])).toBeTruthy(); + expect(selectionService.isNodeSelected(allNodes[2])).toBeFalsy(); + expect(mockTree.nodeSelection.emit).not.toHaveBeenCalled(); + }); + + it('Should add newly selected nodes to the existing selection', () => { + selectionService.selectNode(allNodes[1]); + + let expected: ITreeNodeSelectionEvent = { + oldSelection: [], newSelection: allNodes.slice(1, 4), + added: allNodes.slice(1, 4), removed: [], event: undefined, cancel: false, owner: mockTree + }; + + for (const node of allNodes.slice(1, 4)) { + expect(selectionService.isNodeSelected(node)).toBeTruthy(); + expect(node.selectedChange.emit).toHaveBeenCalled(); + expect(node.selectedChange.emit).toHaveBeenCalledWith(true); + } + for (let i = 4; i < allNodes.length; i++) { + expect(selectionService.isNodeSelected(allNodes[i])).toBeFalsy(); + } + expect(selectionService.isNodeIndeterminate(allNodes[0])).toBeTruthy(); + expect(mockTree.nodeSelection.emit).toHaveBeenCalledWith(expected); + expect(mockTree.nodeSelection.emit).toHaveBeenCalledTimes(1); + + expected = { + oldSelection: allNodes.slice(1, 4), newSelection: [allNodes[1], allNodes[2], allNodes[3], allNodes[5]], + added: [allNodes[5]], removed: [], event: undefined, cancel: false, owner: mockTree + }; + + selectionService.selectNode(allNodes[5]); + + for (const node of allNodes.slice(1, 4)) { + expect(selectionService.isNodeSelected(node)).toBeTruthy(); + expect(node.selectedChange.emit).toHaveBeenCalled(); + expect(node.selectedChange.emit).toHaveBeenCalledWith(true); + } + expect(selectionService.isNodeSelected(allNodes[5])).toBeTruthy(); + expect(selectionService.isNodeIndeterminate(allNodes[0])).toBeTruthy(); + expect(mockTree.nodeSelection.emit).toHaveBeenCalledWith(expected); + expect(mockTree.nodeSelection.emit).toHaveBeenCalledTimes(2); + }); + + it('Should be able to select a range of nodes', () => { + selectionService.selectNode(allNodes[3]); + expect(selectionService.isNodeSelected(allNodes[3])).toBeTruthy(); + + // select all nodes from first to eighth + selectionService.selectMultipleNodes(allNodes[8]); + + allNodes.forEach((node, index) => { + if (index >= 4 && index <= 8) { + expect(selectionService.isNodeSelected(node)).toBeTruthy(); + expect(node.selectedChange.emit).toHaveBeenCalled(); + expect(node.selectedChange.emit).toHaveBeenCalledWith(true); + } else if (index === 3) { + expect(selectionService.isNodeSelected(node)).toBeTruthy(); + } else { + expect(selectionService.isNodeSelected(node)).toBeFalsy(); + } + }); + + const expected: ITreeNodeSelectionEvent = { + oldSelection: [allNodes[3]], newSelection: allNodes.slice(3, 9), + added: allNodes.slice(4, 9), + removed: [], event: undefined, cancel: false, owner: mockTree + }; + expect(mockTree.nodeSelection.emit).toHaveBeenCalledWith(expected); + expect(selectionService.isNodeIndeterminate(allNodes[0])).toBeTruthy(); + expect(selectionService.isNodeIndeterminate(allNodes[1])).toBeTruthy(); + }); + + it('Should be able to select a range of nodes in reverse order', () => { + selectionService.selectNode(allNodes[8]); + + // only seventh and eighth node are selected + expect(selectionService.isNodeSelected(allNodes[7])).toBeTruthy(); + expect(selectionService.isNodeSelected(allNodes[8])).toBeTruthy(); + expect(allNodes[7].selectedChange.emit).toHaveBeenCalled(); + expect(allNodes[7].selectedChange.emit).toHaveBeenCalledWith(true); + expect(allNodes[8].selectedChange.emit).toHaveBeenCalled(); + expect(allNodes[8].selectedChange.emit).toHaveBeenCalledWith(true); + expect(mockTree.nodeSelection.emit).toHaveBeenCalledTimes(1); + + for (let i = 0; i < allNodes.length; i++) { + if (i !== 7 && i !== 8) { + expect(selectionService.isNodeSelected(allNodes[i])).toBeFalsy(); + } + } + + // select all nodes from eight to second + selectionService.selectMultipleNodes(allNodes[2]); + + allNodes.forEach((node, index) => { + if (index <= 8) { + expect(selectionService.isNodeSelected(node)).toBeTruthy(); + if (index < 7) { + expect(node.selectedChange.emit).toHaveBeenCalled(); + expect(node.selectedChange.emit).toHaveBeenCalledWith(true); + } + } else { + expect(selectionService.isNodeSelected(node)).toBeFalsy(); + } + }); + + const expected: ITreeNodeSelectionEvent = { + oldSelection: [allNodes[8], allNodes[7]], + newSelection: [allNodes[8], allNodes[7], ...allNodes.slice(2, 7), allNodes[1], allNodes[0]], + added: [...allNodes.slice(2, 7), allNodes[1], allNodes[0]], removed: [], event: undefined, cancel: false, owner: mockTree + }; + expect(mockTree.nodeSelection.emit).toHaveBeenCalledWith(expected); + }); + + it('Should ensure correct state after a node entry is destroyed', () => { + // instant frames go 'BRRRR' + spyOn(window, 'requestAnimationFrame').and.callFake((callback: any) => callback()); + const deselectSpy = spyOn(selectionService, 'deselectNodesWithNoEvent'); + const selectSpy = spyOn(selectionService, 'selectNodesWithNoEvent'); + const tree = { + selection: IgxTreeSelectionType.None + } as any; + const selectedNodeSpy = spyOn(selectionService, 'isNodeSelected').and.returnValue(false); + const mockNode = { + selected: false + } as any; + selectionService.register(tree); + + selectionService.ensureStateOnNodeDelete(mockNode); + expect(deselectSpy).not.toHaveBeenCalled(); + expect(selectSpy).not.toHaveBeenCalled(); + expect(selectedNodeSpy).not.toHaveBeenCalled(); + tree.selection = IgxTreeSelectionType.BiState; + + selectionService.ensureStateOnNodeDelete(mockNode); + expect(deselectSpy).not.toHaveBeenCalled(); + expect(selectSpy).not.toHaveBeenCalled(); + expect(selectedNodeSpy).not.toHaveBeenCalled(); + tree.selection = IgxTreeSelectionType.Cascading; + selectedNodeSpy.and.returnValue(true); + + selectionService.ensureStateOnNodeDelete(mockNode); + expect(selectedNodeSpy).toHaveBeenCalledTimes(1); + expect(selectedNodeSpy).toHaveBeenCalledWith(mockNode); + expect(deselectSpy).toHaveBeenCalledTimes(1); + expect(deselectSpy).toHaveBeenCalledWith([mockNode], false); + expect(selectSpy).not.toHaveBeenCalled(); + mockNode.parentNode = false; + selectedNodeSpy.and.returnValue(false); + + selectionService.ensureStateOnNodeDelete(mockNode); + expect(selectedNodeSpy).toHaveBeenCalledTimes(2); + expect(deselectSpy).toHaveBeenCalledTimes(1); + expect(selectSpy).not.toHaveBeenCalled(); + const childrenSpy = jasmine.createSpyObj('creep', ['find']); + childrenSpy.find.and.returnValue(null); + mockNode.parentNode = { + allChildren: childrenSpy + }; + + selectionService.ensureStateOnNodeDelete(mockNode); + expect(selectedNodeSpy).toHaveBeenCalledTimes(3); + expect(deselectSpy).toHaveBeenCalledTimes(1); + expect(selectSpy).not.toHaveBeenCalled(); + const mockChild = { + selected: true + } as any; + childrenSpy.find.and.returnValue(mockChild); + + selectionService.ensureStateOnNodeDelete(mockNode); + expect(selectedNodeSpy).toHaveBeenCalledTimes(4); + expect(deselectSpy).toHaveBeenCalledTimes(1); + expect(selectSpy).toHaveBeenCalledTimes(1); + expect(selectSpy).toHaveBeenCalledWith([mockChild], false, false); + + mockChild.selected = false; + selectionService.ensureStateOnNodeDelete(mockNode); + expect(selectedNodeSpy).toHaveBeenCalledTimes(5); + expect(deselectSpy).toHaveBeenCalledTimes(2); + expect(deselectSpy).toHaveBeenCalledWith([mockChild], false); + expect(selectSpy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/projects/igniteui-angular/tree/src/tree/tree-selection.service.ts b/projects/igniteui-angular/tree/src/tree/tree-selection.service.ts new file mode 100644 index 00000000000..88b3a4aaf52 --- /dev/null +++ b/projects/igniteui-angular/tree/src/tree/tree-selection.service.ts @@ -0,0 +1,362 @@ +import { Injectable } from '@angular/core'; +import { IgxTree, IgxTreeNode, IgxTreeSelectionType, ITreeNodeSelectionEvent } from './common'; + +/** A collection containing the nodes affected in the selection as well as their direct parents */ +interface CascadeSelectionNodeCollection { + nodes: Set>; + parents: Set>; +} + +/** @hidden @internal */ +@Injectable() +export class IgxTreeSelectionService { + private tree: IgxTree; + private nodeSelection: Set> = new Set>(); + private indeterminateNodes: Set> = new Set>(); + + private nodesToBeSelected: Set>; + private nodesToBeIndeterminate: Set>; + + public register(tree: IgxTree) { + this.tree = tree; + } + + /** Select range from last selected node to the current specified node. */ + public selectMultipleNodes(node: IgxTreeNode, event?: Event): void { + if (!this.nodeSelection.size) { + this.selectNode(node); + return; + } + const lastSelectedNodeIndex = this.tree.nodes.toArray().indexOf(this.getSelectedNodes()[this.nodeSelection.size - 1]); + const currentNodeIndex = this.tree.nodes.toArray().indexOf(node); + const nodes = this.tree.nodes.toArray().slice(Math.min(currentNodeIndex, lastSelectedNodeIndex), + Math.max(currentNodeIndex, lastSelectedNodeIndex) + 1); + + const added = nodes.filter(_node => !this.isNodeSelected(_node)); + const newSelection = this.getSelectedNodes().concat(added); + this.emitNodeSelectionEvent(newSelection, added, [], event); + } + + /** Select the specified node and emit event. */ + public selectNode(node: IgxTreeNode, event?: Event): void { + if (this.tree.selection === IgxTreeSelectionType.None) { + return; + } + this.emitNodeSelectionEvent([...this.getSelectedNodes(), node], [node], [], event); + } + + /** Deselect the specified node and emit event. */ + public deselectNode(node: IgxTreeNode, event?: Event): void { + const newSelection = this.getSelectedNodes().filter(r => r !== node); + this.emitNodeSelectionEvent(newSelection, [], [node], event); + } + + /** Clears node selection */ + public clearNodesSelection(): void { + this.nodeSelection.clear(); + this.indeterminateNodes.clear(); + } + + public isNodeSelected(node: IgxTreeNode): boolean { + return this.nodeSelection.has(node); + } + + public isNodeIndeterminate(node: IgxTreeNode): boolean { + return this.indeterminateNodes.has(node); + } + + /** Select specified nodes. No event is emitted. */ + public selectNodesWithNoEvent(nodes: IgxTreeNode[], clearPrevSelection = false, shouldEmit = true): void { + if (this.tree && this.tree.selection === IgxTreeSelectionType.Cascading) { + this.cascadeSelectNodesWithNoEvent(nodes, clearPrevSelection); + return; + } + + const oldSelection = this.getSelectedNodes(); + + if (clearPrevSelection) { + this.nodeSelection.clear(); + } + nodes.forEach(node => this.nodeSelection.add(node)); + + if (shouldEmit) { + this.emitSelectedChangeEvent(oldSelection); + } + } + + /** Deselect specified nodes. No event is emitted. */ + public deselectNodesWithNoEvent(nodes?: IgxTreeNode[], shouldEmit = true): void { + const oldSelection = this.getSelectedNodes(); + + if (!nodes) { + this.nodeSelection.clear(); + } else if (this.tree && this.tree.selection === IgxTreeSelectionType.Cascading) { + this.cascadeDeselectNodesWithNoEvent(nodes); + } else { + nodes.forEach(node => this.nodeSelection.delete(node)); + } + + if (shouldEmit) { + this.emitSelectedChangeEvent(oldSelection); + } + } + + /** Called on `node.ngOnDestroy` to ensure state is correct after node is removed */ + public ensureStateOnNodeDelete(node: IgxTreeNode): void { + if (this.tree?.selection !== IgxTreeSelectionType.Cascading) { + return; + } + requestAnimationFrame(() => { + if (this.isNodeSelected(node)) { + // node is destroyed, do not emit event + this.deselectNodesWithNoEvent([node], false); + } else { + if (!node.parentNode) { + return; + } + const assitantLeafNode = node.parentNode?.allChildren.find(e => !e._children?.length); + if (!assitantLeafNode) { + return; + } + this.retriggerNodeState(assitantLeafNode); + } + }); + } + + /** Retriggers a node's selection state */ + private retriggerNodeState(node: IgxTreeNode): void { + if (node.selected) { + this.nodeSelection.delete(node); + this.selectNodesWithNoEvent([node], false, false); + } else { + this.nodeSelection.add(node); + this.deselectNodesWithNoEvent([node], false); + } + } + + /** Returns array of the selected nodes. */ + private getSelectedNodes(): IgxTreeNode[] { + return this.nodeSelection.size ? Array.from(this.nodeSelection) : []; + } + + /** Returns array of the nodes in indeterminate state. */ + private getIndeterminateNodes(): IgxTreeNode[] { + return this.indeterminateNodes.size ? Array.from(this.indeterminateNodes) : []; + } + + private emitNodeSelectionEvent( + newSelection: IgxTreeNode[], added: IgxTreeNode[], removed: IgxTreeNode[], event: Event + ): boolean { + if (this.tree.selection === IgxTreeSelectionType.Cascading) { + this.emitCascadeNodeSelectionEvent(newSelection, added, removed, event); + return; + } + const currSelection = this.getSelectedNodes(); + if (this.areEqualCollections(currSelection, newSelection)) { + return; + } + + const args: ITreeNodeSelectionEvent = { + oldSelection: currSelection, newSelection, + added, removed, event, cancel: false, owner: this.tree + }; + this.tree.nodeSelection.emit(args); + if (args.cancel) { + return; + } + this.selectNodesWithNoEvent(args.newSelection, true); + } + + private areEqualCollections(first: IgxTreeNode[], second: IgxTreeNode[]): boolean { + return first.length === second.length && new Set(first.concat(second)).size === first.length; + } + + private cascadeSelectNodesWithNoEvent(nodes?: IgxTreeNode[], clearPrevSelection = false): void { + const oldSelection = this.getSelectedNodes(); + + if (clearPrevSelection) { + this.indeterminateNodes.clear(); + this.nodeSelection.clear(); + this.calculateNodesNewSelectionState({ added: nodes, removed: [] }); + } else { + const newSelection = [...oldSelection, ...nodes]; + const args: Partial = { oldSelection, newSelection }; + + // retrieve only the rows without their parents/children which has to be added to the selection + this.populateAddRemoveArgs(args); + + this.calculateNodesNewSelectionState(args); + } + this.nodeSelection = new Set(this.nodesToBeSelected); + this.indeterminateNodes = new Set(this.nodesToBeIndeterminate); + + this.emitSelectedChangeEvent(oldSelection); + } + + private cascadeDeselectNodesWithNoEvent(nodes: IgxTreeNode[]): void { + const args = { added: [], removed: nodes }; + this.calculateNodesNewSelectionState(args); + + this.nodeSelection = new Set>(this.nodesToBeSelected); + this.indeterminateNodes = new Set>(this.nodesToBeIndeterminate); + } + + /** + * populates the nodesToBeSelected and nodesToBeIndeterminate sets + * with the nodes which will be eventually in selected/indeterminate state + */ + private calculateNodesNewSelectionState(args: Partial): void { + this.nodesToBeSelected = new Set>(args.oldSelection ? args.oldSelection : this.getSelectedNodes()); + this.nodesToBeIndeterminate = new Set>(this.getIndeterminateNodes()); + + this.cascadeSelectionState(args.removed, false); + this.cascadeSelectionState(args.added, true); + } + + /** Ensures proper selection state for all predescessors and descendants during a selection event */ + private cascadeSelectionState(nodes: IgxTreeNode[], selected: boolean): void { + if (!nodes || nodes.length === 0) { + return; + } + + if (nodes && nodes.length > 0) { + const nodeCollection: CascadeSelectionNodeCollection = this.getCascadingNodeCollection(nodes); + + nodeCollection.nodes.forEach(node => { + if (selected) { + this.nodesToBeSelected.add(node); + } else { + this.nodesToBeSelected.delete(node); + } + this.nodesToBeIndeterminate.delete(node); + }); + + Array.from(nodeCollection.parents).forEach((parent) => { + this.handleParentSelectionState(parent); + }); + } + } + + private emitCascadeNodeSelectionEvent(newSelection, added, removed, event?): boolean { + const currSelection = this.getSelectedNodes(); + if (this.areEqualCollections(currSelection, newSelection)) { + return; + } + + const args: ITreeNodeSelectionEvent = { + oldSelection: currSelection, newSelection, + added, removed, event, cancel: false, owner: this.tree + }; + + this.calculateNodesNewSelectionState(args); + + args.newSelection = Array.from(this.nodesToBeSelected); + + // retrieve nodes/parents/children which has been added/removed from the selection + this.populateAddRemoveArgs(args); + + this.tree.nodeSelection.emit(args); + + if (args.cancel) { + return; + } + + // if args.newSelection hasn't been modified + if (this.areEqualCollections(Array.from(this.nodesToBeSelected), args.newSelection)) { + this.nodeSelection = new Set>(this.nodesToBeSelected); + this.indeterminateNodes = new Set(this.nodesToBeIndeterminate); + this.emitSelectedChangeEvent(currSelection); + } else { + // select the nodes within the modified args.newSelection with no event + this.cascadeSelectNodesWithNoEvent(args.newSelection, true); + } + } + + /** + * recursively handle the selection state of the direct and indirect parents + */ + private handleParentSelectionState(node: IgxTreeNode) { + if (!node) { + return; + } + this.handleNodeSelectionState(node); + if (node.parentNode) { + this.handleParentSelectionState(node.parentNode); + } + } + + /** + * Handle the selection state of a given node based the selection states of its direct children + */ + private handleNodeSelectionState(node: IgxTreeNode) { + const nodesArray = (node && node._children) ? node._children.toArray() : []; + if (nodesArray.length) { + if (nodesArray.every(n => this.nodesToBeSelected.has(n))) { + this.nodesToBeSelected.add(node); + this.nodesToBeIndeterminate.delete(node); + } else if (nodesArray.some(n => this.nodesToBeSelected.has(n) || this.nodesToBeIndeterminate.has(n))) { + this.nodesToBeIndeterminate.add(node); + this.nodesToBeSelected.delete(node); + } else { + this.nodesToBeIndeterminate.delete(node); + this.nodesToBeSelected.delete(node); + } + } else { + // if the children of the node has been deleted and the node was selected do not change its state + if (this.isNodeSelected(node)) { + this.nodesToBeSelected.add(node); + } else { + this.nodesToBeSelected.delete(node); + } + this.nodesToBeIndeterminate.delete(node); + } + } + + /** + * Get a collection of all nodes affected by the change event + * + * @param nodesToBeProcessed set of the nodes to be selected/deselected + * @returns a collection of all affected nodes and all their parents + */ + private getCascadingNodeCollection(nodes: IgxTreeNode[]): CascadeSelectionNodeCollection { + const collection: CascadeSelectionNodeCollection = { + parents: new Set>(), + nodes: new Set>(nodes) + }; + + Array.from(collection.nodes).forEach((node) => { + const nodeAndAllChildren = node.allChildren?.toArray() || []; + nodeAndAllChildren.forEach(n => { + collection.nodes.add(n); + }); + + if (node && node.parentNode) { + collection.parents.add(node.parentNode); + } + }); + return collection; + } + + /** + * retrieve the nodes which should be added/removed to/from the old selection + */ + private populateAddRemoveArgs(args: Partial): void { + args.removed = args.oldSelection.filter(x => args.newSelection.indexOf(x) < 0); + args.added = args.newSelection.filter(x => args.oldSelection.indexOf(x) < 0); + } + + /** Emits the `selectedChange` event for each node affected by the selection */ + private emitSelectedChangeEvent(oldSelection: IgxTreeNode[]): void { + this.getSelectedNodes().forEach(n => { + if (oldSelection.indexOf(n) < 0) { + n.selectedChange.emit(true); + } + }); + + oldSelection.forEach(n => { + if (!this.nodeSelection.has(n)) { + n.selectedChange.emit(false); + } + }); + } +} diff --git a/projects/igniteui-angular/tree/src/tree/tree-selection.spec.ts b/projects/igniteui-angular/tree/src/tree/tree-selection.spec.ts new file mode 100644 index 00000000000..a7438f0c994 --- /dev/null +++ b/projects/igniteui-angular/tree/src/tree/tree-selection.spec.ts @@ -0,0 +1,690 @@ +import { TestBed, fakeAsync, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { ChangeDetectorRef, ElementRef, EventEmitter, QueryList } from '@angular/core'; +import { IgxTreeComponent } from './tree.component'; +import { UIInteractions } from '../../../test-utils/ui-interactions.spec'; +import { TreeTestFunctions, TREE_NODE_DIV_SELECTION_CHECKBOX_CSS_CLASS } from './tree-functions.spec'; +import { IGX_TREE_COMPONENT, IgxTree, IgxTreeSelectionType, ITreeNodeSelectionEvent } from './common'; +import { IgxTreeSelectionService } from './tree-selection.service'; +import { IgxTreeService } from './tree.service'; +import { IgxTreeNodeComponent } from './tree-node/tree-node.component'; +import { IgxTreeNavigationService } from './tree-navigation.service'; +import { IgxTreeSelectionSampleComponent, IgxTreeSimpleComponent } from './tree-samples.spec'; + +describe('IgxTree - Selection #treeView', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxTreeSimpleComponent, + IgxTreeSelectionSampleComponent + ] + }).compileComponents(); + })); + + describe('UI Interaction tests - None & BiState', () => { + let fix; + let tree: IgxTreeComponent; + + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(IgxTreeSimpleComponent); + fix.detectChanges(); + tree = fix.componentInstance.tree; + tree.selection = IgxTreeSelectionType.BiState; + fix.detectChanges(); + })); + + it('Should have checkbox on each node if selection mode is BiState', () => { + const nodes = TreeTestFunctions.getAllNodes(fix); + expect(nodes.length).toBe(4); + nodes.forEach((node) => { + const checkBoxElement = node.nativeElement.querySelector(`.${TREE_NODE_DIV_SELECTION_CHECKBOX_CSS_CLASS}`); + expect(checkBoxElement).not.toBeNull(); + }); + + tree.selection = IgxTreeSelectionType.None; + fix.detectChanges(); + + expect(nodes.length).toBe(4); + nodes.forEach((node) => { + const checkBoxElement = node.nativeElement.querySelector(`.${TREE_NODE_DIV_SELECTION_CHECKBOX_CSS_CLASS}`); + expect(checkBoxElement).toBeNull(); + }); + }); + + it('Should be able to change node selection to None', () => { + expect(tree.selection).toEqual(IgxTreeSelectionType.BiState); + const firstNode = tree.nodes.toArray()[0]; + TreeTestFunctions.clickNodeCheckbox(firstNode); + fix.detectChanges(); + TreeTestFunctions.verifyNodeSelected(firstNode); + + tree.selection = IgxTreeSelectionType.None; + fix.detectChanges(); + expect(tree.selection).toEqual(IgxTreeSelectionType.None); + TreeTestFunctions.verifyNodeSelected(firstNode, false, false); + }); + + it('Should be able to change node selection to Cascading', () => { + expect(tree.selection).toEqual(IgxTreeSelectionType.BiState); + const firstNode = tree.nodes.toArray()[0]; + TreeTestFunctions.clickNodeCheckbox(firstNode); + fix.detectChanges(); + TreeTestFunctions.verifyNodeSelected(firstNode); + + tree.selection = IgxTreeSelectionType.Cascading; + fix.detectChanges(); + expect(tree.selection).toEqual(IgxTreeSelectionType.Cascading); + TreeTestFunctions.verifyNodeSelected(firstNode, false); + }); + + it('Click on checkbox should call node`s onSelectorClick method', () => { + const firstNode = tree.nodes.toArray()[0]; + spyOn(firstNode, 'onSelectorClick').and.callThrough(); + + const ev = TreeTestFunctions.clickNodeCheckbox(firstNode); + fix.detectChanges(); + + expect(firstNode.onSelectorClick).toHaveBeenCalledTimes(1); + expect(firstNode.onSelectorClick).toHaveBeenCalledWith(ev); + }); + + it('Checkbox should correctly represent the node`s selection state', () => { + const firstNode = tree.nodes.toArray()[0]; + firstNode.selected = true; + fix.detectChanges(); + + const secondNode = tree.nodes.toArray()[1]; + + TreeTestFunctions.verifyNodeSelected(firstNode, true); + TreeTestFunctions.verifyNodeSelected(secondNode, false); + }); + + it('Nodes should be selected only from checkboxes', () => { + const firstNode = tree.nodes.toArray()[0]; + firstNode.expanded = true; + fix.detectChanges(); + const secondNode = tree.nodes.toArray()[1]; + + UIInteractions.simulateClickEvent(firstNode.nativeElement); + fix.detectChanges(); + UIInteractions.simulateClickEvent(secondNode.nativeElement); + fix.detectChanges(); + + TreeTestFunctions.verifyNodeSelected(firstNode, false); + TreeTestFunctions.verifyNodeSelected(secondNode, false); + }); + + it('Should select multiple nodes with Shift + Click', () => { + tree.nodes.toArray()[0].expanded = true; + fix.detectChanges(); + const firstNode = tree.nodes.toArray()[10]; + + tree.nodes.toArray()[14].expanded = true; + fix.detectChanges(); + const secondNode = tree.nodes.toArray()[15]; + + const mockEvent = new MouseEvent('click', { shiftKey: true }); + + TreeTestFunctions.clickNodeCheckbox(firstNode); + fix.detectChanges(); + + TreeTestFunctions.verifyNodeSelected(firstNode); + + // Click on other node holding Shift key + secondNode.nativeElement.querySelector(`.${TREE_NODE_DIV_SELECTION_CHECKBOX_CSS_CLASS}`).dispatchEvent(mockEvent); + fix.detectChanges(); + + for (let index = 10; index < 16; index++) { + const node = tree.nodes.toArray()[index]; + TreeTestFunctions.verifyNodeSelected(node); + } + }); + + it('Should be able to cancel nodeSelection event', () => { + const firstNode = tree.nodes.toArray()[0]; + + tree.nodeSelection.subscribe((e: any) => { + e.cancel = true; + }); + + // Click on a node checkbox + TreeTestFunctions.clickNodeCheckbox(firstNode); + fix.detectChanges(); + TreeTestFunctions.verifyNodeSelected(firstNode, false); + }); + + it('Should be able to programmatically overwrite the selection using nodeSelection event', () => { + const firstNode = tree.nodes.toArray()[0]; + + tree.nodeSelection.subscribe((e: any) => { + e.newSelection = [tree.nodes.toArray()[1], tree.nodes.toArray()[14]]; + }); + + TreeTestFunctions.clickNodeCheckbox(firstNode); + fix.detectChanges(); + + TreeTestFunctions.verifyNodeSelected(firstNode, false); + TreeTestFunctions.verifyNodeSelected(tree.nodes.toArray()[1]); + TreeTestFunctions.verifyNodeSelected(tree.nodes.toArray()[14]); + }); + }); + + describe('UI Interaction tests - Cascading', () => { + let fix; + let tree: IgxTreeComponent; + + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(IgxTreeSimpleComponent); + fix.detectChanges(); + tree = fix.componentInstance.tree; + tree.selection = IgxTreeSelectionType.Cascading; + fix.detectChanges(); + })); + + it('Should have checkbox on each node if selection mode is Cascading', () => { + const nodes = TreeTestFunctions.getAllNodes(fix); + expect(nodes.length).toBe(4); + nodes.forEach((node) => { + const checkBoxElement = node.nativeElement.querySelector(`.${TREE_NODE_DIV_SELECTION_CHECKBOX_CSS_CLASS}`); + expect(checkBoxElement).not.toBeNull(); + }); + }); + + it('Should be able to change node selection to None', () => { + expect(tree.selection).toEqual(IgxTreeSelectionType.Cascading); + TreeTestFunctions.clickNodeCheckbox(tree.nodes.toArray()[10]); + fix.detectChanges(); + + for (let i = 10; i < 14; i++) { + TreeTestFunctions.verifyNodeSelected(tree.nodes.toArray()[i]); + } + TreeTestFunctions.verifyNodeSelected(tree.nodes.toArray()[0], false, true, true); + + tree.selection = IgxTreeSelectionType.None; + fix.detectChanges(); + + expect(tree.selection).toEqual(IgxTreeSelectionType.None); + for (let i = 10; i < 14; i++) { + TreeTestFunctions.verifyNodeSelected(tree.nodes.toArray()[i], false, false); + } + TreeTestFunctions.verifyNodeSelected(tree.nodes.toArray()[0], false, false); + }); + + it('Should be able to change node selection to BiState', () => { + expect(tree.selection).toEqual(IgxTreeSelectionType.Cascading); + TreeTestFunctions.clickNodeCheckbox(tree.nodes.toArray()[10]); + fix.detectChanges(); + + for (let i = 10; i < 14; i++) { + TreeTestFunctions.verifyNodeSelected(tree.nodes.toArray()[i]); + } + TreeTestFunctions.verifyNodeSelected(tree.nodes.toArray()[0], false, true, true); + + tree.selection = IgxTreeSelectionType.BiState; + fix.detectChanges(); + + expect(tree.selection).toEqual(IgxTreeSelectionType.BiState); + for (let i = 10; i < 14; i++) { + TreeTestFunctions.verifyNodeSelected(tree.nodes.toArray()[i], false); + } + TreeTestFunctions.verifyNodeSelected(tree.nodes.toArray()[0], false); + }); + + it('Checkbox should correctly represent the node`s selection state', () => { + const firstNode = tree.nodes.toArray()[0]; + const secondNode = tree.nodes.toArray()[10]; + secondNode.selected = true; + fix.detectChanges(); + + TreeTestFunctions.verifyNodeSelected(firstNode, false, true, true); + for (let i = 1; i < 14; i++) { + if (i < 10) { + TreeTestFunctions.verifyNodeSelected(tree.nodes.toArray()[i], false); + } else { + TreeTestFunctions.verifyNodeSelected(tree.nodes.toArray()[i]); + } + } + }); + + it('Should select multiple nodes with Shift + Click', () => { + const firstNode = tree.nodes.toArray()[10]; + const secondNode = tree.nodes.toArray()[15]; + + const mockEvent = new MouseEvent('click', { shiftKey: true }); + + TreeTestFunctions.clickNodeCheckbox(firstNode); + fix.detectChanges(); + + TreeTestFunctions.verifyNodeSelected(firstNode); + + // Click on other node holding Shift key + secondNode.nativeElement.querySelector(`.${TREE_NODE_DIV_SELECTION_CHECKBOX_CSS_CLASS}`).dispatchEvent(mockEvent); + fix.detectChanges(); + + for (let index = 10; index < 21; index++) { + const node = tree.nodes.toArray()[index]; + TreeTestFunctions.verifyNodeSelected(node); + } + TreeTestFunctions.verifyNodeSelected(tree.nodes.toArray()[0], false, true, true); + }); + + it('Should be able to cancel nodeSelection event', () => { + const firstNode = tree.nodes.toArray()[0]; + + tree.nodeSelection.subscribe((e: any) => { + e.cancel = true; + }); + + // Click on a node checkbox + TreeTestFunctions.clickNodeCheckbox(firstNode); + fix.detectChanges(); + TreeTestFunctions.verifyNodeSelected(firstNode, false); + }); + + it('Should be able to programmatically overwrite the selection using nodeSelection event', () => { + const firstNode = tree.nodes.toArray()[0]; + + tree.nodeSelection.subscribe((e: any) => { + e.newSelection = [tree.nodes.toArray()[10], tree.nodes.toArray()[15]]; + }); + + TreeTestFunctions.clickNodeCheckbox(firstNode); + fix.detectChanges(); + + TreeTestFunctions.verifyNodeSelected(firstNode, false, true, true); + for (let i = 10; i < 18; i++) { + if (i !== 14) { + TreeTestFunctions.verifyNodeSelected(tree.nodes.toArray()[i]); + } else { + TreeTestFunctions.verifyNodeSelected(tree.nodes.toArray()[i], false, true, true); + } + } + }); + }); + + describe('UI Interaction - Two-Way Binding', () => { + let fix; + let tree: IgxTreeComponent; + + beforeEach(fakeAsync(() => { + fix = TestBed.createComponent(IgxTreeSelectionSampleComponent); + fix.detectChanges(); + tree = fix.componentInstance.tree; + tree.selection = IgxTreeSelectionType.BiState; + fix.detectChanges(); + })); + + it('Should correctly represent the node`s selection state on click', () => { + const firstNode = tree.nodes.toArray()[0]; + firstNode.expanded = true; + fix.detectChanges(); + const secondNode = tree.nodes.toArray()[1]; + secondNode.expanded = true; + fix.detectChanges(); + const thirdNode = tree.nodes.toArray()[2]; + + TreeTestFunctions.clickNodeCheckbox(thirdNode); + fix.detectChanges(); + + expect(firstNode.data.selected).toBeFalsy(); + expect(secondNode.data.selected).toBeFalsy(); + expect(thirdNode.data.selected).toBeTruthy(); + TreeTestFunctions.verifyNodeSelected(firstNode, false); + TreeTestFunctions.verifyNodeSelected(secondNode, false); + TreeTestFunctions.verifyNodeSelected(thirdNode, true); + + TreeTestFunctions.clickNodeCheckbox(firstNode); + fix.detectChanges(); + + expect(firstNode.data.selected).toBeTruthy(); + expect(secondNode.data.selected).toBeFalsy(); + expect(thirdNode.data.selected).toBeTruthy(); + TreeTestFunctions.verifyNodeSelected(firstNode, true); + TreeTestFunctions.verifyNodeSelected(secondNode, false); + TreeTestFunctions.verifyNodeSelected(thirdNode, true); + + TreeTestFunctions.clickNodeCheckbox(thirdNode); + fix.detectChanges(); + + expect(firstNode.data.selected).toBeTruthy(); + expect(secondNode.data.selected).toBeFalsy(); + expect(thirdNode.data.selected).toBeFalsy(); + TreeTestFunctions.verifyNodeSelected(firstNode, true); + TreeTestFunctions.verifyNodeSelected(secondNode, false); + TreeTestFunctions.verifyNodeSelected(thirdNode, false); + }); + + it('Should correctly represent the node`s selection state when changing node`s selected property', () => { + const firstNode = tree.nodes.toArray()[0]; + const secondNode = tree.nodes.toArray()[1]; + const thirdNode = tree.nodes.toArray()[2]; + thirdNode.selected = true; + fix.detectChanges(); + + expect(firstNode.data.selected).toBeFalsy(); + expect(secondNode.data.selected).toBeFalsy(); + expect(thirdNode.data.selected).toBeTruthy(); + TreeTestFunctions.verifyNodeSelected(firstNode, false); + TreeTestFunctions.verifyNodeSelected(secondNode, false); + TreeTestFunctions.verifyNodeSelected(thirdNode, true); + + firstNode.selected = true; + fix.detectChanges(); + + expect(firstNode.data.selected).toBeTruthy(); + expect(secondNode.data.selected).toBeFalsy(); + expect(thirdNode.data.selected).toBeTruthy(); + TreeTestFunctions.verifyNodeSelected(firstNode, true); + TreeTestFunctions.verifyNodeSelected(secondNode, false); + TreeTestFunctions.verifyNodeSelected(thirdNode, true); + + thirdNode.selected = false; + fix.detectChanges(); + + expect(firstNode.data.selected).toBeTruthy(); + expect(secondNode.data.selected).toBeFalsy(); + expect(thirdNode.data.selected).toBeFalsy(); + TreeTestFunctions.verifyNodeSelected(firstNode, true); + TreeTestFunctions.verifyNodeSelected(secondNode, false); + TreeTestFunctions.verifyNodeSelected(thirdNode, false); + }); + + it('Should correctly represent the node`s selection state when changing data selected property', () => { + const firstNode = tree.nodes.toArray()[0]; + const secondNode = tree.nodes.toArray()[1]; + const thirdNode = tree.nodes.toArray()[2]; + thirdNode.data.selected = true; + fix.detectChanges(); + + expect(firstNode.data.selected).toBeFalsy(); + expect(secondNode.data.selected).toBeFalsy(); + expect(thirdNode.data.selected).toBeTruthy(); + TreeTestFunctions.verifyNodeSelected(firstNode, false); + TreeTestFunctions.verifyNodeSelected(secondNode, false); + TreeTestFunctions.verifyNodeSelected(thirdNode, true); + + firstNode.data.selected = true; + fix.detectChanges(); + + expect(firstNode.data.selected).toBeTruthy(); + expect(secondNode.data.selected).toBeFalsy(); + expect(thirdNode.data.selected).toBeTruthy(); + TreeTestFunctions.verifyNodeSelected(firstNode, true); + TreeTestFunctions.verifyNodeSelected(secondNode, false); + TreeTestFunctions.verifyNodeSelected(thirdNode, true); + + thirdNode.data.selected = false; + fix.detectChanges(); + + expect(firstNode.data.selected).toBeTruthy(); + expect(secondNode.data.selected).toBeFalsy(); + expect(thirdNode.data.selected).toBeFalsy(); + TreeTestFunctions.verifyNodeSelected(firstNode, true); + TreeTestFunctions.verifyNodeSelected(secondNode, false); + TreeTestFunctions.verifyNodeSelected(thirdNode, false); + }); + + it('Should correctly represent the node`s selection state on click in Cascading mode', () => { + tree.selection = IgxTreeSelectionType.Cascading; + fix.detectChanges(); + + const firstNode = tree.nodes.toArray()[0]; + firstNode.expanded = true; + fix.detectChanges(); + const secondNode = tree.nodes.toArray()[1]; + secondNode.expanded = true; + fix.detectChanges(); + const thirdNode = tree.nodes.toArray()[2]; + + TreeTestFunctions.clickNodeCheckbox(thirdNode); + fix.detectChanges(); + + expect(firstNode.data.selected).toBeFalsy(); + expect(secondNode.data.selected).toBeFalsy(); + expect(thirdNode.data.selected).toBeTruthy(); + TreeTestFunctions.verifyNodeSelected(firstNode, false, true, true); + TreeTestFunctions.verifyNodeSelected(secondNode, false, true, true); + TreeTestFunctions.verifyNodeSelected(thirdNode, true, true, false); + + TreeTestFunctions.clickNodeCheckbox(firstNode); + fix.detectChanges(); + + expect(firstNode.data.selected).toBeTruthy(); + expect(secondNode.data.selected).toBeTruthy(); + expect(thirdNode.data.selected).toBeTruthy(); + TreeTestFunctions.verifyNodeSelected(firstNode, true); + TreeTestFunctions.verifyNodeSelected(secondNode, true); + TreeTestFunctions.verifyNodeSelected(thirdNode, true); + + TreeTestFunctions.clickNodeCheckbox(thirdNode); + fix.detectChanges(); + + expect(firstNode.data.selected).toBeFalsy(); + expect(secondNode.data.selected).toBeFalsy(); + expect(thirdNode.data.selected).toBeFalsy(); + TreeTestFunctions.verifyNodeSelected(firstNode, false, true, true); + TreeTestFunctions.verifyNodeSelected(secondNode, false, true, true); + TreeTestFunctions.verifyNodeSelected(thirdNode, false, true, false); + }); + + it('Should correctly represent the node`s selection state when changing node`s selected property in Cascading mode', () => { + tree.selection = IgxTreeSelectionType.Cascading; + fix.detectChanges(); + + const firstNode = tree.nodes.toArray()[0]; + const secondNode = tree.nodes.toArray()[1]; + const thirdNode = tree.nodes.toArray()[2]; + thirdNode.selected = true; + fix.detectChanges(); + + expect(firstNode.data.selected).toBeFalsy(); + expect(secondNode.data.selected).toBeFalsy(); + expect(thirdNode.data.selected).toBeTruthy(); + TreeTestFunctions.verifyNodeSelected(firstNode, false, true, true); + TreeTestFunctions.verifyNodeSelected(secondNode, false, true, true); + TreeTestFunctions.verifyNodeSelected(thirdNode, true, true, false); + + firstNode.selected = true; + fix.detectChanges(); + + expect(firstNode.data.selected).toBeTruthy(); + expect(secondNode.data.selected).toBeTruthy(); + expect(thirdNode.data.selected).toBeTruthy(); + TreeTestFunctions.verifyNodeSelected(firstNode, true); + TreeTestFunctions.verifyNodeSelected(secondNode, true); + TreeTestFunctions.verifyNodeSelected(thirdNode, true); + + thirdNode.selected = false; + fix.detectChanges(); + + expect(firstNode.data.selected).toBeFalsy(); + expect(secondNode.data.selected).toBeFalsy(); + expect(thirdNode.data.selected).toBeFalsy(); + TreeTestFunctions.verifyNodeSelected(firstNode, false, true, true); + TreeTestFunctions.verifyNodeSelected(secondNode, false, true, true); + TreeTestFunctions.verifyNodeSelected(thirdNode, false, true, false); + }); + + it('Should correctly represent the node`s selection state when changing data selected property in Cascading mode', () => { + tree.selection = IgxTreeSelectionType.Cascading; + fix.detectChanges(); + + const firstNode = tree.nodes.toArray()[0]; + const secondNode = tree.nodes.toArray()[1]; + const thirdNode = tree.nodes.toArray()[2]; + + thirdNode.data.selected = true; + fix.componentInstance.cdr.detectChanges(); + + expect(firstNode.data.selected).toBeFalsy(); + expect(secondNode.data.selected).toBeFalsy(); + expect(thirdNode.data.selected).toBeTruthy(); + TreeTestFunctions.verifyNodeSelected(firstNode, false, true, true); + TreeTestFunctions.verifyNodeSelected(secondNode, false, true, true); + TreeTestFunctions.verifyNodeSelected(thirdNode, true, true, false); + + firstNode.data.selected = true; + fix.componentInstance.cdr.detectChanges(); + + expect(firstNode.data.selected).toBeTruthy(); + expect(secondNode.data.selected).toBeTruthy(); + expect(thirdNode.data.selected).toBeTruthy(); + TreeTestFunctions.verifyNodeSelected(firstNode, true); + TreeTestFunctions.verifyNodeSelected(secondNode, true); + TreeTestFunctions.verifyNodeSelected(thirdNode, true); + + thirdNode.data.selected = false; + fix.componentInstance.cdr.detectChanges(); + + expect(firstNode.data.selected).toBeFalsy(); + expect(secondNode.data.selected).toBeFalsy(); + expect(thirdNode.data.selected).toBeFalsy(); + TreeTestFunctions.verifyNodeSelected(firstNode, false, true, true); + TreeTestFunctions.verifyNodeSelected(secondNode, false, true, true); + TreeTestFunctions.verifyNodeSelected(thirdNode, false, true, false); + }); + }); + + describe('IgxTree - API Tests', () => { + let mockNodes: IgxTreeNodeComponent[]; + let mockQuery: jasmine.SpyObj>; + const selectionService = new IgxTreeSelectionService(); + const treeService = new IgxTreeService(); + const elementRef = { nativeElement: null }; + const mockPlatform = jasmine.createSpyObj('platform', ['isBrowser', 'isServer']); + mockPlatform.isBrowser = true; + let navService: IgxTreeNavigationService; + let tree: IgxTreeComponent; + + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { provide: IgxTreeSelectionService, useValue: selectionService }, + { provide: IgxTreeService, useValue: treeService }, + { provide: ElementRef, useValue: elementRef }, + IgxTreeNavigationService, + IgxTreeComponent + ] + }); + + navService = TestBed.inject(IgxTreeNavigationService); + tree = TestBed.inject(IgxTreeComponent); + + mockNodes = TreeTestFunctions.createNodeSpies(0, 5); + mockQuery = TreeTestFunctions.createQueryListSpy(mockNodes); + mockQuery.toArray.and.returnValue(mockNodes); + mockQuery.forEach.and.callFake((cb) => mockNodes.forEach(cb)); + + tree.selection = IgxTreeSelectionType.BiState; + (tree.nodes as any) = mockQuery; + }); + + afterAll(() => { + navService.ngOnDestroy(); + tree.ngOnDestroy(); + }); + + it('Should be able to deselect all nodes', () => { + spyOn(selectionService, 'deselectNodesWithNoEvent').and.callThrough(); + + tree.nodes.forEach(node => node.selected = true); + + tree.deselectAll(); + expect((tree as any).selectionService.deselectNodesWithNoEvent).toHaveBeenCalled(); + expect((tree as any).selectionService.deselectNodesWithNoEvent).toHaveBeenCalledWith(undefined); + }); + + it('Should be able to deselect multiple nodes', () => { + spyOn(selectionService, 'deselectNodesWithNoEvent').and.callThrough(); + + tree.nodes.toArray()[0].selected = true; + tree.nodes.toArray()[1].selected = true; + + tree.deselectAll([tree.nodes.toArray()[0], tree.nodes.toArray()[1]]); + expect((tree as any).selectionService.deselectNodesWithNoEvent).toHaveBeenCalled(); + expect((tree as any).selectionService.deselectNodesWithNoEvent) + .toHaveBeenCalledWith([tree.nodes.toArray()[0], tree.nodes.toArray()[1]]); + }); + }); + + describe('IgxTreeNode - API Tests', () => { + let node: IgxTreeNodeComponent; + let navService: IgxTreeNavigationService; + const elementRef = { nativeElement: null }; + const selectionService = new IgxTreeSelectionService(); + const treeService = new IgxTreeService(); + const mockEmitter: EventEmitter = jasmine.createSpyObj('emitter', ['emit']); + const mockTree: IgxTree = jasmine.createSpyObj('tree', [''], + { + selection: IgxTreeSelectionType.BiState, nodeSelection: mockEmitter, nodes: { + find: () => true + } + }); + const mockCdr = jasmine.createSpyObj('ChangeDetectorRef', ['markForCheck', 'detectChanges']); + + selectionService.register(mockTree); + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { provide: IgxTreeSelectionService, useValue: selectionService }, + { provide: IgxTreeService, useValue: treeService }, + { provide: IgxTreeNavigationService, useClass: IgxTreeNavigationService }, + { provide: IGX_TREE_COMPONENT, useValue: mockTree }, + { provide: ChangeDetectorRef, useValue: mockCdr }, + { provide: ElementRef, useValue: elementRef }, + IgxTreeNodeComponent + ] + }); + + navService = TestBed.inject(IgxTreeNavigationService); + node = TestBed.inject(IgxTreeNodeComponent); + }); + + afterAll(() => { + navService.ngOnDestroy(); + }); + + it('Should call selectNodesWithNoEvent when setting node`s selected property to true', () => { + spyOn(selectionService, 'selectNodesWithNoEvent').and.callThrough(); + node.selected = true; + + expect((node as any).selectionService.selectNodesWithNoEvent).toHaveBeenCalled(); + expect((node as any).selectionService.selectNodesWithNoEvent).toHaveBeenCalledWith([node]); + }); + + it('Should call deselectNodesWithNoEvent when seting node`s selected property to false', () => { + spyOn(selectionService, 'deselectNodesWithNoEvent').and.callThrough(); + + if (!node.selected) { + node.selected = true; + } + + node.selected = false; + + expect((node as any).selectionService.deselectNodesWithNoEvent).toHaveBeenCalled(); + expect((node as any).selectionService.deselectNodesWithNoEvent).toHaveBeenCalledWith([node]); + }); + + it('Should call isNodeSelected when node`s selected getter is invoked', () => { + spyOn(selectionService, 'isNodeSelected').and.callThrough(); + const isSelected = node.selected; + + expect(isSelected).toBeFalse(); + expect((node as any).selectionService.isNodeSelected).toHaveBeenCalled(); + expect((node as any).selectionService.isNodeSelected).toHaveBeenCalledWith(node); + }); + + it('Should call isNodeIndeterminate when node`s indeterminate getter is invoked', () => { + spyOn(selectionService, 'isNodeIndeterminate').and.callThrough(); + const isIndeterminate = node.indeterminate; + + expect(isIndeterminate).toBeFalse(); + expect((node as any).selectionService.isNodeIndeterminate).toHaveBeenCalled(); + expect((node as any).selectionService.isNodeIndeterminate).toHaveBeenCalledWith(node); + }); + }); +}); + diff --git a/projects/igniteui-angular/tree/src/tree/tree.component.html b/projects/igniteui-angular/tree/src/tree/tree.component.html new file mode 100644 index 00000000000..98128089e56 --- /dev/null +++ b/projects/igniteui-angular/tree/src/tree/tree.component.html @@ -0,0 +1,3 @@ +
    + +
    diff --git a/projects/igniteui-angular/tree/src/tree/tree.component.ts b/projects/igniteui-angular/tree/src/tree/tree.component.ts new file mode 100644 index 00000000000..d3f13fd9cdc --- /dev/null +++ b/projects/igniteui-angular/tree/src/tree/tree.component.ts @@ -0,0 +1,529 @@ +import { Component, QueryList, Input, Output, EventEmitter, ContentChild, Directive, TemplateRef, OnInit, AfterViewInit, ContentChildren, OnDestroy, HostBinding, ElementRef, booleanAttribute, inject } from '@angular/core'; + +import { Subject } from 'rxjs'; +import { takeUntil, throttleTime } from 'rxjs/operators'; + +import { + IGX_TREE_COMPONENT, IgxTreeSelectionType, IgxTree, ITreeNodeToggledEventArgs, + ITreeNodeTogglingEventArgs, ITreeNodeSelectionEvent, IgxTreeNode, IgxTreeSearchResolver +} from './common'; +import { IgxTreeNavigationService } from './tree-navigation.service'; +import { IgxTreeNodeComponent } from './tree-node/tree-node.component'; +import { IgxTreeSelectionService } from './tree-selection.service'; +import { IgxTreeService } from './tree.service'; +import { growVerIn, growVerOut } from 'igniteui-angular/animations'; +import { PlatformUtil, resizeObservable } from 'igniteui-angular/core'; +import { ToggleAnimationSettings } from 'igniteui-angular/expansion-panel'; + +/** + * @hidden @internal + * Used for templating the select marker of the tree + */ +@Directive({ + selector: '[igxTreeSelectMarker]', + standalone: true +}) +export class IgxTreeSelectMarkerDirective { +} + +/** + * @hidden @internal + * Used for templating the expand indicator of the tree + */ +@Directive({ + selector: '[igxTreeExpandIndicator]', + standalone: true +}) +export class IgxTreeExpandIndicatorDirective { +} + +/** + * IgxTreeComponent allows a developer to show a set of nodes in a hierarchical fashion. + * + * @igxModule IgxTreeModule + * @igxKeywords tree + * @igxTheme igx-tree-theme + * @igxGroup Grids & Lists + * + * @remark + * The Angular Tree Component allows users to represent hierarchical data in a tree-view structure, + * maintaining parent-child relationships, as well as to define static tree-view structure without a corresponding data model. + * Its primary purpose is to allow end-users to visualize and navigate within hierarchical data structures. + * The Ignite UI for Angular Tree Component also provides load on demand capabilities, item activation, + * bi-state and cascading selection of items through built-in checkboxes, built-in keyboard navigation and more. + * + * @example + * ```html + * + * + * I am a parent node 1 + * + * I am a child node 1 + * + * ... + * + * ... + * + * ``` + */ +@Component({ + selector: 'igx-tree', + templateUrl: 'tree.component.html', + providers: [ + IgxTreeService, + IgxTreeSelectionService, + IgxTreeNavigationService, + { provide: IGX_TREE_COMPONENT, useExisting: IgxTreeComponent }, + ], + standalone: true +}) +export class IgxTreeComponent implements IgxTree, OnInit, AfterViewInit, OnDestroy { + private navService = inject(IgxTreeNavigationService); + private selectionService = inject(IgxTreeSelectionService); + private treeService = inject(IgxTreeService); + private element = inject>(ElementRef); + private platform = inject(PlatformUtil); + + + @HostBinding('class.igx-tree') + public cssClass = 'igx-tree'; + + /** + * Gets/Sets tree selection mode + * + * @remarks + * By default the tree selection mode is 'None' + * @param selectionMode: IgxTreeSelectionType + */ + @Input() + public get selection() { + return this._selection; + } + + public set selection(selectionMode: IgxTreeSelectionType) { + this._selection = selectionMode; + this.selectionService.clearNodesSelection(); + } + + /** Get/Set how the tree should handle branch expansion. + * If set to `true`, only a single branch can be expanded at a time, collapsing all others + * + * ```html + * + * ... + * + * ``` + * + * ```typescript + * const tree: IgxTree = this.tree; + * this.tree.singleBranchExpand = false; + * ``` + */ + @Input({ transform: booleanAttribute }) + public singleBranchExpand = false; + + /** Get/Set if nodes should be expanded/collapsed when clicking over them. + * + * ```html + * + * ... + * + * ``` + * + * ```typescript + * const tree: IgxTree = this.tree; + * this.tree.toggleNodeOnClick = false; + * ``` + */ + @Input({ transform: booleanAttribute }) + public toggleNodeOnClick = false; + + + /** Get/Set the animation settings that branches should use when expanding/collpasing. + * + * ```html + * + * + * ``` + * + * ```typescript + * const animationSettings: ToggleAnimationSettings = { + * openAnimation: growVerIn, + * closeAnimation: growVerOut + * }; + * + * this.tree.animationSettings = animationSettings; + * ``` + */ + @Input() + public animationSettings: ToggleAnimationSettings = { + openAnimation: growVerIn, + closeAnimation: growVerOut + }; + + /** Emitted when the node selection is changed through interaction + * + * ```html + * + * + * ``` + * + *```typescript + * public handleNodeSelection(event: ITreeNodeSelectionEvent) { + * const newSelection: IgxTreeNode[] = event.newSelection; + * const added: IgxTreeNode[] = event.added; + * console.log("New selection will be: ", newSelection); + * console.log("Added nodes: ", event.added); + * } + *``` + */ + @Output() + public nodeSelection = new EventEmitter(); + + /** Emitted when a node is expanding, before it finishes + * + * ```html + * + * + * ``` + * + *```typescript + * public handleNodeExpanding(event: ITreeNodeTogglingEventArgs) { + * const expandedNode: IgxTreeNode = event.node; + * if (expandedNode.disabled) { + * event.cancel = true; + * } + * } + *``` + */ + @Output() + public nodeExpanding = new EventEmitter(); + + /** Emitted when a node is expanded, after it finishes + * + * ```html + * + * + * ``` + * + *```typescript + * public handleNodeExpanded(event: ITreeNodeToggledEventArgs) { + * const expandedNode: IgxTreeNode = event.node; + * console.log("Node is expanded: ", expandedNode.data); + * } + *``` + */ + @Output() + public nodeExpanded = new EventEmitter(); + + /** Emitted when a node is collapsing, before it finishes + * + * ```html + * + * + * ``` + * + *```typescript + * public handleNodeCollapsing(event: ITreeNodeTogglingEventArgs) { + * const collapsedNode: IgxTreeNode = event.node; + * if (collapsedNode.alwaysOpen) { + * event.cancel = true; + * } + * } + *``` + */ + @Output() + public nodeCollapsing = new EventEmitter(); + + /** Emitted when a node is collapsed, after it finishes + * + * @example + * ```html + * + * + * ``` + * ```typescript + * public handleNodeCollapsed(event: ITreeNodeToggledEventArgs) { + * const collapsedNode: IgxTreeNode = event.node; + * console.log("Node is collapsed: ", collapsedNode.data); + * } + * ``` + */ + @Output() + public nodeCollapsed = new EventEmitter(); + + /** + * Emitted when the active node is changed. + * + * @example + * ``` + * + * ``` + */ + @Output() + public activeNodeChanged = new EventEmitter>(); + + /** + * A custom template to be used for the expand indicator of nodes + * ```html + * + * + * {{ expanded ? "close_fullscreen": "open_in_full"}} + * + * + * ``` + */ + @ContentChild(IgxTreeExpandIndicatorDirective, { read: TemplateRef }) + public expandIndicator: TemplateRef; + + /** @hidden @internal */ + @ContentChildren(IgxTreeNodeComponent, { descendants: true }) + public nodes: QueryList>; + + /** @hidden @internal */ + public disabledChange = new EventEmitter>(); + + /** + * Returns all **root level** nodes + * + * ```typescript + * const tree: IgxTree = this.tree; + * const rootNodes: IgxTreeNodeComponent[] = tree.rootNodes; + * ``` + */ + public get rootNodes(): IgxTreeNodeComponent[] { + return this.nodes?.filter(node => node.level === 0); + } + + /** + * Emitted when the active node is set through API + * + * @hidden @internal + */ + public activeNodeBindingChange = new EventEmitter>(); + + /** @hidden @internal */ + public forceSelect = []; + + /** @hidden @internal */ + public resizeNotify = new Subject(); + + private _selection: IgxTreeSelectionType = IgxTreeSelectionType.None; + private destroy$ = new Subject(); + private unsubChildren$ = new Subject(); + + constructor() { + this.selectionService.register(this); + this.treeService.register(this); + this.navService.register(this); + } + + /** @hidden @internal */ + public get nativeElement() { + return this.element.nativeElement; + } + + /** + * Expands all of the passed nodes. + * If no nodes are passed, expands ALL nodes + * + * @param nodes nodes to be expanded + * + * ```typescript + * const targetNodes: IgxTreeNode = this.tree.findNodes(true, (_data: any, node: IgxTreeNode) => node.data.expandable); + * tree.expandAll(nodes); + * ``` + */ + public expandAll(nodes?: IgxTreeNode[]) { + nodes = nodes || this.nodes.toArray(); + nodes.forEach(e => e.expanded = true); + } + + /** + * Collapses all of the passed nodes. + * If no nodes are passed, collapses ALL nodes + * + * @param nodes nodes to be collapsed + * + * ```typescript + * const targetNodes: IgxTreeNode = this.tree.findNodes(true, (_data: any, node: IgxTreeNode) => node.data.collapsible); + * tree.collapseAll(nodes); + * ``` + */ + public collapseAll(nodes?: IgxTreeNode[]) { + nodes = nodes || this.nodes.toArray(); + nodes.forEach(e => e.expanded = false); + } + + /** + * Deselect all nodes if the nodes collection is empty. Otherwise, deselect the nodes in the nodes collection. + * + * @example + * ```typescript + * const arr = [ + * this.tree.nodes.toArray()[0], + * this.tree.nodes.toArray()[1] + * ]; + * this.tree.deselectAll(arr); + * ``` + * @param nodes: IgxTreeNodeComponent[] + */ + public deselectAll(nodes?: IgxTreeNodeComponent[]) { + this.selectionService.deselectNodesWithNoEvent(nodes); + } + + /** + * Returns all of the nodes that match the passed searchTerm. + * Accepts a custom comparer function for evaluating the search term against the nodes. + * + * @remarks + * Default search compares the passed `searchTerm` against the node's `data` Input. + * When using `findNodes` w/o a `comparer`, make sure all nodes have `data` passed. + * + * @param searchTerm The data of the searched node + * @param comparer A custom comparer function that evaluates the passed `searchTerm` against all nodes. + * @returns Array of nodes that match the search. `null` if no nodes are found. + * + * ```html + * + * + * {{ node.label }} + * + * + * ``` + * + * ```typescript + * public data: DataEntry[] = FETCHED_DATA; + * ... + * const matchedNodes: IgxTreeNode[] = this.tree.findNodes(searchTerm: data[5]); + * ``` + * + * Using a custom comparer + * ```typescript + * public data: DataEntry[] = FETCHED_DATA; + * ... + * const comparer: IgxTreeSearchResolver = (data: any, node: IgxTreeNode) { + * return node.data.index % 2 === 0; + * } + * const evenIndexNodes: IgxTreeNode[] = this.tree.findNodes(null, comparer); + * ``` + */ + public findNodes(searchTerm: any, comparer?: IgxTreeSearchResolver): IgxTreeNodeComponent[] | null { + const compareFunc = comparer || this._comparer; + const results = this.nodes.filter(node => compareFunc(searchTerm, node)); + return results?.length === 0 ? null : results; + } + + /** @hidden @internal */ + public handleKeydown(event: KeyboardEvent) { + this.navService.handleKeydown(event); + } + + /** @hidden @internal */ + public ngOnInit() { + this.disabledChange.pipe(takeUntil(this.destroy$)).subscribe((e) => { + this.navService.update_disabled_cache(e); + }); + this.activeNodeBindingChange.pipe(takeUntil(this.destroy$)).subscribe((node) => { + this.expandToNode(this.navService.activeNode); + this.scrollNodeIntoView(node?.header?.nativeElement); + }); + this.subToCollapsing(); + this.resizeNotify.pipe( + throttleTime(40, null, { trailing: true }), + takeUntil(this.destroy$) + ) + .subscribe(() => { + requestAnimationFrame(() => { + this.scrollNodeIntoView(this.navService.activeNode?.header.nativeElement); + }); + }); + } + + /** @hidden @internal */ + public ngAfterViewInit() { + this.nodes.changes.pipe(takeUntil(this.destroy$)).subscribe(() => { + this.subToChanges(); + }); + this.scrollNodeIntoView(this.navService.activeNode?.header?.nativeElement); + this.subToChanges(); + resizeObservable(this.nativeElement).pipe(takeUntil(this.destroy$)).subscribe(() => this.resizeNotify.next()); + } + + /** @hidden @internal */ + public ngOnDestroy() { + this.unsubChildren$.next(); + this.unsubChildren$.complete(); + this.destroy$.next(); + this.destroy$.complete(); + } + + private expandToNode(node: IgxTreeNode) { + if (node && node.parentNode) { + node.path.forEach(n => { + if (n !== node && !n.expanded) { + n.expanded = true; + } + }); + } + } + + private subToCollapsing() { + this.nodeCollapsing.pipe(takeUntil(this.destroy$)).subscribe(event => { + if (event.cancel) { + return; + } + this.navService.update_visible_cache(event.node, false); + }); + this.nodeExpanding.pipe(takeUntil(this.destroy$)).subscribe(event => { + if (event.cancel) { + return; + } + this.navService.update_visible_cache(event.node, true); + }); + } + + private subToChanges() { + this.unsubChildren$.next(); + const toBeSelected = [...this.forceSelect]; + if(this.platform.isBrowser) { + requestAnimationFrame(() => { + this.selectionService.selectNodesWithNoEvent(toBeSelected); + }); + } + this.forceSelect = []; + this.nodes.forEach(node => { + node.expandedChange.pipe(takeUntil(this.unsubChildren$)).subscribe(nodeState => { + this.navService.update_visible_cache(node, nodeState); + }); + node.closeAnimationDone.pipe(takeUntil(this.unsubChildren$)).subscribe(() => { + const targetElement = this.navService.focusedNode?.header.nativeElement; + this.scrollNodeIntoView(targetElement); + }); + node.openAnimationDone.pipe(takeUntil(this.unsubChildren$)).subscribe(() => { + const targetElement = this.navService.focusedNode?.header.nativeElement; + this.scrollNodeIntoView(targetElement); + }); + }); + this.navService.init_invisible_cache(); + } + + private scrollNodeIntoView(el: HTMLElement) { + if (!el) { + return; + } + const nodeRect = el.getBoundingClientRect(); + const treeRect = this.nativeElement.getBoundingClientRect(); + const topOffset = treeRect.top > nodeRect.top ? nodeRect.top - treeRect.top : 0; + const bottomOffset = treeRect.bottom < nodeRect.bottom ? nodeRect.bottom - treeRect.bottom : 0; + const shouldScroll = !!topOffset || !!bottomOffset; + if (shouldScroll && this.nativeElement.scrollHeight > this.nativeElement.clientHeight) { + // this.nativeElement.scrollTop = nodeRect.y - treeRect.y - nodeRect.height; + this.nativeElement.scrollTop = + this.nativeElement.scrollTop + bottomOffset + topOffset + (topOffset ? -1 : +1) * nodeRect.height; + } + } + + private _comparer = (data: T, node: IgxTreeNodeComponent) => node.data === data; + +} diff --git a/projects/igniteui-angular/tree/src/tree/tree.module.ts b/projects/igniteui-angular/tree/src/tree/tree.module.ts new file mode 100644 index 00000000000..b9aca17f843 --- /dev/null +++ b/projects/igniteui-angular/tree/src/tree/tree.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { IGX_TREE_DIRECTIVES } from './public_api'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + ...IGX_TREE_DIRECTIVES + ], + exports: [ + ...IGX_TREE_DIRECTIVES + ] +}) +export class IgxTreeModule { } diff --git a/projects/igniteui-angular/tree/src/tree/tree.service.ts b/projects/igniteui-angular/tree/src/tree/tree.service.ts new file mode 100644 index 00000000000..b2aea95cad4 --- /dev/null +++ b/projects/igniteui-angular/tree/src/tree/tree.service.ts @@ -0,0 +1,71 @@ +import { Injectable } from '@angular/core'; +import { IgxTree, IgxTreeNode } from './common'; + +/** @hidden @internal */ +@Injectable() +export class IgxTreeService { + public expandedNodes: Set> = new Set>(); + public collapsingNodes: Set> = new Set>(); + private tree: IgxTree; + + /** + * Adds the node to the `expandedNodes` set and fires the nodes change event + * + * @param node target node + * @param uiTrigger is the event triggered by a ui interraction (so we know if we should animate) + * @returns void + */ + public expand(node: IgxTreeNode, uiTrigger?: boolean): void { + this.collapsingNodes.delete(node); + if (!this.expandedNodes.has(node)) { + node.expandedChange.emit(true); + } else { + return; + } + this.expandedNodes.add(node); + if (this.tree.singleBranchExpand) { + this.tree.findNodes(node, this.siblingComparer)?.forEach(e => { + if (uiTrigger) { + e.collapse(); + } else { + e.expanded = false; + } + }); + } + } + + /** + * Adds a node to the `collapsing` collection + * + * @param node target node + */ + public collapsing(node: IgxTreeNode): void { + this.collapsingNodes.add(node); + } + + /** + * Removes the node from the 'expandedNodes' set and emits the node's change event + * + * @param node target node + * @returns void + */ + public collapse(node: IgxTreeNode): void { + if (this.expandedNodes.has(node)) { + node.expandedChange.emit(false); + } + this.collapsingNodes.delete(node); + this.expandedNodes.delete(node); + } + + public isExpanded(node: IgxTreeNode): boolean { + return this.expandedNodes.has(node); + } + + public register(tree: IgxTree) { + this.tree = tree; + } + + private siblingComparer: + (data: IgxTreeNode, node: IgxTreeNode) => boolean = + (data: IgxTreeNode, node: IgxTreeNode) => node !== data && node.level === data.level; +} diff --git a/projects/igniteui-angular/tree/src/tree/tree.spec.ts b/projects/igniteui-angular/tree/src/tree/tree.spec.ts new file mode 100644 index 00000000000..290c2e85944 --- /dev/null +++ b/projects/igniteui-angular/tree/src/tree/tree.spec.ts @@ -0,0 +1,780 @@ +import { ChangeDetectorRef, Component, DebugElement, ElementRef, EventEmitter, QueryList, ViewChild } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { AnimationService, IgxAngularAnimationService } from 'igniteui-angular/core'; +import { TreeTestFunctions } from './tree-functions.spec'; +import { IgxTreeNavigationService } from './tree-navigation.service'; +import { IgxTreeNodeComponent } from './tree-node/tree-node.component'; +import { IgxTreeSelectionService } from './tree-selection.service'; +import { IgxTreeComponent } from './tree.component'; +import { IgxTreeService } from './tree.service'; +import { IGX_TREE_COMPONENT } from './common'; + +const TREE_ROOT_CLASS = 'igx-tree__root'; +const NODE_TAG = 'igx-tree-node'; + +describe('IgxTree #treeView', () => { + describe('Unit Tests', () => { + let mockNavService: IgxTreeNavigationService; + let mockTreeService: IgxTreeService; + let mockSelectionService: IgxTreeSelectionService; + let mockElementRef: ElementRef; + let mockNodes: QueryList>; + let mockNodesArray: IgxTreeNodeComponent[] = []; + let tree: IgxTreeComponent = null; + + beforeAll(() => { + jasmine.getEnv().allowRespy(true); + }); + + afterAll(() => { + jasmine.getEnv().allowRespy(false); + }); + + beforeEach(() => { + mockNodesArray = []; + mockNavService = jasmine.createSpyObj('navService', + ['register', 'update_disabled_cache', 'update_visible_cache', + 'init_invisible_cache', 'setFocusedAndActiveNode', 'handleKeydown']); + mockTreeService = jasmine.createSpyObj('treeService', + ['register', 'collapse', 'expand', 'collapsing', 'isExpanded']); + mockSelectionService = jasmine.createSpyObj('selectionService', + ['register', 'deselectNodesWithNoEvent', 'ensureStateOnNodeDelete', 'selectNodesWithNoEvent']); + mockElementRef = jasmine.createSpyObj('elementRef', [], { + nativeElement: document.createElement('div') + }); + + TestBed.configureTestingModule({ + providers: [ + { provide: IgxTreeNavigationService, useValue: mockNavService }, + { provide: IgxTreeService, useValue: mockTreeService }, + { provide: IgxTreeSelectionService, useValue: mockSelectionService }, + { provide: ElementRef, useValue: mockElementRef }, + IgxTreeComponent + ] + }); + + const mockPlatform = jasmine.createSpyObj('platform', ['isBrowser', 'isServer']); + mockPlatform.isBrowser = true; + + tree = TestBed.inject(IgxTreeComponent); + + mockNodes = jasmine.createSpyObj('mockList', ['toArray'], { + changes: new Subject(), + get first() { + return mockNodesArray[0]; + }, + get last() { + return mockNodesArray[mockNodesArray.length - 1]; + }, + get length() { + return mockNodesArray.length; + }, + forEach: (cb: (n: IgxTreeNodeComponent) => void): void => { + mockNodesArray.forEach(cb); + }, + find: (cb: (n: IgxTreeNodeComponent) => boolean): IgxTreeNodeComponent => mockNodesArray.find(cb), + filter: jasmine.createSpy('filter'). + and.callFake((cb: (n: IgxTreeNodeComponent) => boolean): IgxTreeNodeComponent[] => mockNodesArray.filter(cb)), + }); + spyOn(mockNodes, 'toArray').and.returnValue(mockNodesArray); + }); + afterEach(() => { + tree?.ngOnDestroy(); + }); + describe('IgxTreeComponent', () => { + it('Should update nav children cache when events are fired', fakeAsync(() => { + expect(mockNavService.init_invisible_cache).toHaveBeenCalledTimes(0); + expect(mockNavService.update_visible_cache).toHaveBeenCalledTimes(0); + expect(mockNavService.update_disabled_cache).toHaveBeenCalledTimes(0); + tree.ngOnInit(); + tick(); + expect(mockNavService.init_invisible_cache).toHaveBeenCalledTimes(0); + expect(mockNavService.update_visible_cache).toHaveBeenCalledTimes(0); + expect(mockNavService.update_disabled_cache).toHaveBeenCalledTimes(0); + tree.disabledChange.emit('mockNode' as any); + tick(); + expect(mockNavService.update_disabled_cache).toHaveBeenCalledTimes(1); + expect(mockNavService.update_disabled_cache).toHaveBeenCalledWith('mockNode' as any); + tree.nodeCollapsing.emit({ node: 'mockNode' as any } as any); + tick(); + expect(mockNavService.update_visible_cache).toHaveBeenCalledTimes(1); + expect(mockNavService.update_visible_cache).toHaveBeenCalledWith('mockNode' as any, false); + tree.nodeExpanding.emit({ node: 'mockNode' as any } as any); + tick(); + expect(mockNavService.update_visible_cache).toHaveBeenCalledTimes(2); + expect(mockNavService.update_visible_cache).toHaveBeenCalledWith('mockNode' as any, true); + tree.nodes = mockNodes; + const mockNode = TreeTestFunctions.createNodeSpy({ + expandedChange: new EventEmitter(), + closeAnimationDone: new EventEmitter(), + openAnimationDone: new EventEmitter() + }) as any; + mockNodesArray.push( + mockNode + ); + + spyOnProperty(mockNodes, 'first', 'get').and.returnValue(mockNode); + tree.ngAfterViewInit(); + tick(); + expect(mockNavService.init_invisible_cache).toHaveBeenCalledTimes(1); + tree.nodes.first.expandedChange.emit(true); + expect(mockNavService.update_visible_cache).toHaveBeenCalledTimes(3); + expect(mockNavService.update_visible_cache).toHaveBeenCalledWith(tree.nodes.first, true); + tree.nodes.first.expandedChange.emit(false); + expect(mockNavService.update_visible_cache).toHaveBeenCalledTimes(4); + expect(mockNavService.update_visible_cache).toHaveBeenCalledWith(tree.nodes.first, false); + (tree.nodes.changes as any).next(); + tick(); + expect(mockNavService.init_invisible_cache).toHaveBeenCalledTimes(2); + tree.ngOnDestroy(); + })); + it('Should update delegate keyboard events to nav service', () => { + const mockEvent: any = {}; + tree.handleKeydown(mockEvent as any); + expect(mockNavService.handleKeydown).toHaveBeenCalledWith(mockEvent as any); + }); + it('Should search through nodes and return expected value w/ `findNodes`', () => { + tree.nodes = mockNodes; + let id = 0; + let itemRef = {} as any; + mockNodesArray = TreeTestFunctions.createNodeSpies(0, 5); + mockNodesArray.forEach(n => { + itemRef = { id: id++ }; + n.data = itemRef; + }); + expect(tree.findNodes(itemRef)).toEqual([mockNodesArray[mockNodesArray.length - 1]]); + expect(tree.nodes.filter).toHaveBeenCalledTimes(1); + expect(tree.findNodes(1, (p, n) => n.data.id === p)).toEqual([mockNodes.find(n => n.data.id === 1)]); + expect(tree.nodes.filter).toHaveBeenCalledTimes(2); + expect(tree.findNodes('Not found', (p, n) => n.data.id === p)).toEqual(null); + expect(tree.nodes.filter).toHaveBeenCalledTimes(3); + + }); + it('Should return only root level nodes w/ `rootNodes` accessor', () => { + tree.nodes = mockNodes; + const arr = []; + for (let i = 0; i < 7; i++) { + const level = i > 4 ? 1 : 0; + arr.push({ + level + }); + } + mockNodesArray = [...arr]; + expect(tree.rootNodes.length).toBe(5); + mockNodesArray.forEach(n => { + (n as any).level = 1; + }); + expect(tree.rootNodes.length).toBe(0); + mockNodesArray.forEach(n => { + (n as any).level = 0; + }); + expect(tree.rootNodes.length).toBe(7); + tree.nodes = null; + expect(tree.rootNodes).toBe(undefined); + }); + it('Should expandAll nodes nodes w/ proper methods', () => { + tree.nodes = mockNodes; + const customArrayParam = []; + for (let i = 0; i < 5; i++) { + const node = jasmine.createSpyObj('node', ['expand', 'collapse'], { + _expanded: false, + get expanded() { + return this._expanded; + }, + set expanded(val: boolean) { + this._expanded = val; + } + }); + node.spyProp = spyOnProperty(node, 'expanded', 'set').and.callThrough(); + mockNodesArray.push(node); + if (i > 3) { + customArrayParam.push(node); + } + } + spyOn(mockNodesArray, 'forEach').and.callThrough(); + tree.expandAll(); + expect(mockNodesArray.forEach).toHaveBeenCalledTimes(1); + mockNodesArray.forEach(n => { + expect((n as any).spyProp).toHaveBeenCalledWith(true); + expect((n as any).spyProp).toHaveBeenCalledTimes(1); + }); + tree.expandAll(customArrayParam); + customArrayParam.forEach(n => { + expect((n as any).spyProp).toHaveBeenCalledWith(true); + expect((n as any).spyProp).toHaveBeenCalledTimes(2); + }); + }); + it('Should collapseAll nodes nodes w/ proper methods', () => { + tree.nodes = mockNodes; + const customArrayParam = []; + for (let i = 0; i < 5; i++) { + const node = jasmine.createSpyObj('node', ['expand', 'collapse'], { + _expanded: false, + get expanded() { + return this._expanded; + }, + set expanded(val: boolean) { + this._expanded = val; + } + }); + node.spyProp = spyOnProperty(node, 'expanded', 'set').and.callThrough(); + mockNodesArray.push(node); + if (i > 3) { + customArrayParam.push(node); + } + } + spyOn(mockNodesArray, 'forEach').and.callThrough(); + tree.collapseAll(); + expect(mockNodesArray.forEach).toHaveBeenCalledTimes(1); + mockNodesArray.forEach(n => { + expect((n as any).spyProp).toHaveBeenCalledWith(false); + expect((n as any).spyProp).toHaveBeenCalledTimes(1); + }); + tree.collapseAll(customArrayParam); + customArrayParam.forEach(n => { + expect((n as any).spyProp).toHaveBeenCalledWith(false); + expect((n as any).spyProp).toHaveBeenCalledTimes(2); + }); + }); + it('Should deselectAll nodes w/ proper method', () => { + tree.nodes = mockNodes; + tree.deselectAll(); + expect(mockSelectionService.deselectNodesWithNoEvent).toHaveBeenCalledWith(undefined); + const customParam = jasmine.createSpyObj('nodes', ['toArray']); + tree.deselectAll(customParam); + expect(mockSelectionService.deselectNodesWithNoEvent).toHaveBeenCalledWith(customParam); + }); + }); + describe('IgxTreeNodeComponent', () => { + let node: IgxTreeNodeComponent; + let mockTree: IgxTreeComponent; + let mockCdr: ChangeDetectorRef; + let mockAnimationService: AnimationService; + let treeService: IgxTreeService; + + beforeEach(() => { + mockTree = jasmine.createSpyObj('mockTree', ['findNodes'], + { + nodeCollapsing: jasmine.createSpyObj('spy', ['emit']), + nodeExpanding: jasmine.createSpyObj('spy', ['emit']), + nodeCollapsed: jasmine.createSpyObj('spy', ['emit']), + nodeExpanded: jasmine.createSpyObj('spy', ['emit']) + }); + mockCdr = jasmine.createSpyObj('mockCdr', ['detectChanges', 'markForCheck'], {}); + mockAnimationService = jasmine.createSpyObj('mockAB', ['buildAnimation'], {}); + treeService = new IgxTreeService(); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + { provide: IgxTreeSelectionService, useValue: mockSelectionService }, + { provide: IgxTreeService, useValue: treeService }, + { provide: IgxTreeNavigationService, useValue: mockNavService }, + { provide: ElementRef, useValue: mockElementRef }, + { provide: ChangeDetectorRef, useValue: mockCdr }, + { provide: IgxAngularAnimationService, useValue: mockAnimationService }, + { provide: IgxTreeComponent, useValue: mockTree }, + { provide: IGX_TREE_COMPONENT, useValue: mockTree }, + IgxTreeNodeComponent + ] + }); + + node = TestBed.inject(IgxTreeNodeComponent); + }); + it('Should call service expand/collapse methods when toggling state through `[expanded]` input', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + { provide: IgxTreeSelectionService, useValue: mockSelectionService }, + { provide: IgxTreeService, useValue: mockTreeService }, + { provide: IgxTreeNavigationService, useValue: mockNavService }, + { provide: ElementRef, useValue: mockElementRef }, + { provide: ChangeDetectorRef, useValue: mockCdr }, + { provide: IgxAngularAnimationService, useValue: mockAnimationService }, + { provide: IgxTreeComponent, useValue: mockTree }, + { provide: IGX_TREE_COMPONENT, useValue: mockTree }, + IgxTreeNodeComponent + ] + }); + + node = TestBed.inject(IgxTreeNodeComponent); + + mockTreeService.register(mockTree); + expect(mockTreeService.collapse).not.toHaveBeenCalled(); + expect(mockTreeService.expand).not.toHaveBeenCalled(); + expect(mockTree.nodeExpanded.emit).not.toHaveBeenCalled(); + expect(mockTree.nodeCollapsed.emit).not.toHaveBeenCalled(); + expect(mockTree.nodeExpanding.emit).not.toHaveBeenCalled(); + expect(mockTree.nodeExpanded.emit).not.toHaveBeenCalled(); + node.expanded = true; + expect(mockTreeService.expand).toHaveBeenCalledTimes(1); + expect(mockTreeService.expand).toHaveBeenCalledWith(node, false); + node.expanded = false; + expect(mockTreeService.collapse).toHaveBeenCalledTimes(1); + expect(mockTreeService.collapse).toHaveBeenCalledWith(node); + // events are not emitted when chaining state through input + expect(mockTree.nodeExpanded.emit).not.toHaveBeenCalled(); + expect(mockTree.nodeCollapsed.emit).not.toHaveBeenCalled(); + expect(mockTree.nodeExpanding.emit).not.toHaveBeenCalled(); + expect(mockTree.nodeExpanded.emit).not.toHaveBeenCalled(); + }); + it('Expand() should expand currently collapsing node', () => { + treeService.register(mockTree); + treeService.expandedNodes.add(node); + treeService.collapsingNodes.add(node); + node.expand(); + expect(mockTree.nodeExpanding.emit).toHaveBeenCalledTimes(1); + + }); + it('Collapse() shouldn`t affect a currently collapsing node', () => { + treeService.register(mockTree); + treeService.expandedNodes.add(node); + treeService.collapsingNodes.add(node); + node.collapse(); + expect(mockTree.nodeCollapsing.emit).toHaveBeenCalledTimes(0); + }); + it('Should call service expand/collapse methods when calling API state methods', () => { + treeService.register(mockTree); + node.expandedChange = jasmine.createSpyObj('emitter', ['emit']) + const openAnimationSpy = spyOn(node, 'playOpenAnimation'); + const closeAnimationSpy = spyOn(node, 'playCloseAnimation'); + const mockObj = jasmine.createSpyObj('mockElement', ['focus']); + spyOn(treeService, 'collapse').and.callThrough(); + spyOn(treeService, 'collapsing').and.callThrough(); + spyOn(treeService, 'expand').and.callThrough(); + spyOn(node, 'expandedChange').and.callThrough(); + const ingArgs = { + owner: mockTree, + cancel: false, + node + }; + const edArgs = { + owner: mockTree, + node + }; + (node as any).childrenContainer = mockObj; + expect(treeService.collapse).not.toHaveBeenCalled(); + expect(treeService.expand).not.toHaveBeenCalled(); + expect(treeService.collapsing).not.toHaveBeenCalled(); + expect(openAnimationSpy).not.toHaveBeenCalled(); + expect(closeAnimationSpy).not.toHaveBeenCalled(); + expect(mockCdr.markForCheck).not.toHaveBeenCalled(); + expect(treeService.collapsing).not.toHaveBeenCalled(); + expect(mockTree.nodeExpanding.emit).not.toHaveBeenCalledWith(); + expect(mockTree.nodeCollapsing.emit).not.toHaveBeenCalledWith(); + expect(mockTree.nodeExpanded.emit).not.toHaveBeenCalledWith(); + expect(mockTree.nodeCollapsed.emit).not.toHaveBeenCalledWith(); + expect(node.expandedChange).not.toHaveBeenCalled(); + node.ngOnInit(); + node.expand(); + expect(openAnimationSpy).toHaveBeenCalledWith(mockObj); + expect(openAnimationSpy).toHaveBeenCalledTimes(1); + expect(mockTree.nodeExpanded.emit).toHaveBeenCalledTimes(0); + expect(mockTree.nodeExpanding.emit).toHaveBeenCalledWith(ingArgs); + expect(treeService.expand).toHaveBeenCalledWith(node, true); + expect(treeService.expand).toHaveBeenCalledTimes(1); + node.openAnimationDone.emit(); + expect(node.expandedChange.emit).toHaveBeenCalledTimes(1); + expect(node.expandedChange.emit).toHaveBeenCalledWith(true); + expect(mockTree.nodeExpanded.emit).toHaveBeenCalledTimes(1); + expect(mockTree.nodeExpanded.emit).toHaveBeenCalledWith(edArgs); + node.collapse(); + expect(closeAnimationSpy).toHaveBeenCalledWith(mockObj); + expect(closeAnimationSpy).toHaveBeenCalledTimes(1); + expect(mockTree.nodeCollapsed.emit).toHaveBeenCalledTimes(0); + expect(mockTree.nodeCollapsing.emit).toHaveBeenCalledWith(ingArgs); + // collapse happens after animation finishes + expect(treeService.collapse).toHaveBeenCalledTimes(0); + node.closeAnimationDone.emit(); + expect(treeService.collapse).toHaveBeenCalledTimes(1); + expect(treeService.collapse).toHaveBeenCalledWith(node); + expect(node.expandedChange.emit).toHaveBeenCalledTimes(2); + expect(node.expandedChange.emit).toHaveBeenCalledWith(false); + expect(mockTree.nodeCollapsed.emit).toHaveBeenCalledTimes(1); + expect(mockTree.nodeCollapsed.emit).toHaveBeenCalledWith(edArgs); + spyOn(node, 'expand'); + spyOn(node, 'collapse'); + node.toggle(); + expect(node.expand).toHaveBeenCalledTimes(1); + expect(node.collapse).toHaveBeenCalledTimes(0); + spyOn(treeService, 'isExpanded').and.returnValue(true); + node.toggle(); + expect(node.expand).toHaveBeenCalledTimes(1); + expect(node.collapse).toHaveBeenCalledTimes(1); + }); + + it('Should have correct path to node, regardless if node has parent or not', () => { + expect(node.path).toEqual([node]); + const childNode = TestBed.createComponent(IgxTreeNodeComponent).componentInstance; + (childNode as any).parentNode = node; + expect(childNode.path).toEqual([node, childNode]); + }); + + it('Should clear itself from selection service on destroy', () => { + node.ngOnDestroy(); + expect(mockSelectionService.ensureStateOnNodeDelete).toHaveBeenCalledWith(node); + }); + }); + describe('IgxTreeService', () => { + it('Should properly register tree', () => { + const service = new IgxTreeService(); + expect((service as any).tree).toBe(undefined); + const mockTree = jasmine.createSpyObj('tree', ['findNodes']); + service.register(mockTree); + expect((service as any).tree).toBe(mockTree); + }); + it('Should keep a proper collection of expanded and collapsing nodes at all time, firing `expandedChange` when needed', () => { + const service = new IgxTreeService(); + const mockTree = jasmine.createSpyObj('tree', ['findNodes'], { + _singleBranchExpand: false, + get singleBranchExpand(): boolean { + return this._singleBranchExpand; + }, + set singleBranchExpand(val: boolean) { + this._singleBranchExpand = val; + } + }); + service.register(mockTree); + spyOn(service.expandedNodes, 'add').and.callThrough(); + spyOn(service.expandedNodes, 'delete').and.callThrough(); + spyOn(service.collapsingNodes, 'add').and.callThrough(); + spyOn(service.collapsingNodes, 'delete').and.callThrough(); + expect(service.expandedNodes.size).toBe(0); + expect(service.collapsingNodes.size).toBe(0); + const mockNode = jasmine.createSpyObj('node', ['collapse'], { + expandedChange: jasmine.createSpyObj('emitter', ['emit']) + }); + service.expand(mockNode); + expect(service.collapsingNodes.delete).toHaveBeenCalledWith(mockNode); + expect(service.collapsingNodes.delete).toHaveBeenCalledTimes(1); + expect(service.expandedNodes.add).toHaveBeenCalledWith(mockNode); + expect(mockNode.expandedChange.emit).toHaveBeenCalledTimes(1); + expect(mockNode.expandedChange.emit).toHaveBeenCalledWith(true); + expect(service.expandedNodes.size).toBe(1); + expect(mockNode.collapse).not.toHaveBeenCalled(); + service.expand(mockNode); + expect(service.collapsingNodes.delete).toHaveBeenCalledTimes(2); + expect(mockNode.expandedChange.emit).toHaveBeenCalledTimes(1); + expect(service.expandedNodes.size).toBe(1); + service.collapse(mockNode); + expect(mockNode.expandedChange.emit).toHaveBeenCalledTimes(2); + expect(mockNode.expandedChange.emit).toHaveBeenCalledWith(false); + expect(service.collapsingNodes.delete).toHaveBeenCalledWith(mockNode); + expect(service.collapsingNodes.delete).toHaveBeenCalledTimes(3); + expect(service.expandedNodes.delete).toHaveBeenCalledTimes(1); + expect(service.expandedNodes.delete).toHaveBeenCalledWith(mockNode); + expect(service.expandedNodes.size).toBe(0); + service.collapse(mockNode); + expect(mockNode.expandedChange.emit).toHaveBeenCalledTimes(2); + expect(service.collapsingNodes.delete).toHaveBeenCalledTimes(4); + expect(service.expandedNodes.delete).toHaveBeenCalledTimes(2); + const mockArray = []; + for (let i = 0; i < 5; i++) { + const node = jasmine.createSpyObj('node', ['collapse'], { + _expanded: false, + get expanded() { + return this._expanded; + }, + set expanded(val: boolean) { + this._expanded = val; + } + }); + node.spyProp = spyOnProperty(node, 'expanded', 'set').and.callThrough(); + mockArray.push(node); + } + spyOn(mockTree, 'findNodes').and.returnValue(mockArray); + spyOnProperty(mockTree, 'singleBranchExpand', 'get').and.returnValue(true); + service.expand(mockNode); + mockArray.forEach(n => { + expect((n as any).spyProp).toHaveBeenCalledWith(false); + expect(n.collapse).not.toHaveBeenCalled(); + }); + service.collapse(mockNode); + service.expand(mockNode, true); + mockArray.forEach(n => { + expect(n.collapse).toHaveBeenCalled(); + expect(n.collapse).toHaveBeenCalledTimes(1); + }); + expect(service.collapsingNodes.size).toBe(0); + service.collapsing(mockNode); + expect(service.collapsingNodes.size).toBe(1); + service.collapse(mockNode); + spyOnProperty(mockTree, 'singleBranchExpand', 'get').and.returnValue(true); + spyOn(mockTree, 'findNodes').and.returnValue(null); + service.expand(mockNode, true); + expect(mockTree.findNodes).toHaveBeenCalledWith(mockNode, (service as any).siblingComparer); + mockArray.forEach(n => { + expect(n.collapse).toHaveBeenCalledTimes(1); + }); + }); + }); + }); + describe('Rendering Tests', () => { + let fix: ComponentFixture; + let tree: IgxTreeComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxTreeSampleComponent + ] + }).compileComponents(); + })); + + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeSampleComponent); + fix.detectChanges(); + tree = fix.componentInstance.tree; + }); + + describe('General', () => { + it('Should only render node children', () => { + const treeEl: HTMLElement = fix.debugElement.queryAll(By.css(`.${TREE_ROOT_CLASS}`))[0].nativeElement; + let childNodes = treeEl.children; + expect(childNodes.length).toBe(5); + for (let i = 0; i < childNodes.length; i++) { + expect(childNodes.item(i).tagName === NODE_TAG); + } + fix.componentInstance.divChild = true; + childNodes = treeEl.children; + expect(childNodes.length).toBe(5); + for (let i = 0; i < childNodes.length; i++) { + expect(childNodes.item(i).tagName === NODE_TAG); + } + }); + it('Should not render collapsed nodes', () => { + let allNodes: DebugElement[] = fix.debugElement.queryAll(By.css(NODE_TAG)); + expect(allNodes.length).toBe(5); + tree.nodes.first.expanded = true; + fix.detectChanges(); + allNodes = fix.debugElement.queryAll(By.css(NODE_TAG)); + expect(allNodes.length).toBe(10); + const visibleNodes = tree.nodes.filter(n => allNodes.findIndex(e => e.nativeElement === n.nativeElement) > -1); + visibleNodes.forEach(n => { + expect(n.level === 0 || n.parentNode.expanded === true).toBeTruthy(); + }); + }); + + it('Should apply proper node classes depending on tree displayDensity', () => { + pending('Test not implemented'); + }); + + it('Should do nothing when calling expand()/collapse() on expanded/collapsed node', fakeAsync(() => { + const expandingSpy = spyOn(tree.nodeExpanding, 'emit').and.callThrough(); + const collapsingSpy = spyOn(tree.nodeCollapsing, 'emit').and.callThrough(); + tree.nodes.first.collapse(); + expect(expandingSpy).not.toHaveBeenCalled(); + + tree.nodes.first.expanded = true; + + tree.nodes.first.expand(); + expect(collapsingSpy).not.toHaveBeenCalled(); + })); + + it('Should properly emit state toggle events', fakeAsync(() => { + // node event spies + const collapsingSpy = spyOn(tree.nodeCollapsing, 'emit').and.callThrough(); + const expandingSpy = spyOn(tree.nodeExpanding, 'emit').and.callThrough(); + const expandedSpy = spyOn(tree.nodeExpanded, 'emit').and.callThrough(); + const collapsedSpy = spyOn(tree.nodeCollapsed, 'emit').and.callThrough(); + expect(collapsingSpy).not.toHaveBeenCalled(); + expect(expandingSpy).not.toHaveBeenCalled(); + expect(expandedSpy).not.toHaveBeenCalled(); + expect(collapsedSpy).not.toHaveBeenCalled(); + tree.nodes.first.expand(); + expect(expandingSpy).toHaveBeenCalledTimes(1); + expect(collapsingSpy).not.toHaveBeenCalled(); + expect(expandedSpy).not.toHaveBeenCalled(); + expect(collapsedSpy).not.toHaveBeenCalled(); + tick(); + fix.detectChanges(); + tick(); + expect(expandingSpy).toHaveBeenCalledTimes(1); + expect(expandedSpy).toHaveBeenCalledTimes(1); + expect(collapsingSpy).not.toHaveBeenCalled(); + expect(collapsedSpy).not.toHaveBeenCalled(); + tree.nodes.first.collapse(); + expect(expandingSpy).toHaveBeenCalledTimes(1); + expect(expandedSpy).toHaveBeenCalledTimes(1); + expect(collapsingSpy).toHaveBeenCalledTimes(1); + expect(collapsedSpy).not.toHaveBeenCalled(); + tick(); + fix.detectChanges(); + tick(); + expect(expandingSpy).toHaveBeenCalledTimes(1); + expect(expandedSpy).toHaveBeenCalledTimes(1); + expect(collapsingSpy).toHaveBeenCalledTimes(1); + expect(collapsedSpy).toHaveBeenCalledTimes(1); + // cancel ingEvents + const unsub$ = new Subject(); + tree.nodeExpanding.pipe(takeUntil(unsub$)).subscribe(e => { + e.cancel = true; + }); + tree.nodes.first.expand(); + expect(expandingSpy).toHaveBeenCalledTimes(2); + expect(expandedSpy).toHaveBeenCalledTimes(1); + tick(); + fix.detectChanges(); + tick(); + expect(expandingSpy).toHaveBeenCalledTimes(2); + expect(expandedSpy).toHaveBeenCalledTimes(1); + tree.nodes.first.expanded = true; + unsub$.next(); + tree.nodeCollapsing.pipe(takeUntil(unsub$)).subscribe(e => { + e.cancel = true; + }); + tree.nodes.first.collapse(); + expect(collapsingSpy).toHaveBeenCalledTimes(2); + expect(collapsedSpy).toHaveBeenCalledTimes(1); + tick(); + fix.detectChanges(); + tick(); + expect(collapsingSpy).toHaveBeenCalledTimes(2); + expect(collapsedSpy).toHaveBeenCalledTimes(1); + unsub$.next(); + unsub$.complete(); + })); + + it('Should collapse all sibling nodes when `singleBranchExpand` is set and node is toggled', fakeAsync(() => { + pending('Causes jasmine to hang'); + tree.rootNodes.forEach((n, index) => index > 0 ? n.expanded = true : n.expanded = false); + fix.detectChanges(); + expect(tree.nodes.filter(n => n.expanded).length).toBe(4); + tree.singleBranchExpand = true; + tree.rootNodes.forEach(n => { + spyOn(n.expandedChange, 'emit').and.callThrough(); + }); + const collapsingSpy = spyOn(tree.nodeCollapsing, 'emit').and.callThrough(); + const expandingSpy = spyOn(tree.nodeExpanding, 'emit').and.callThrough(); + const expandedSpy = spyOn(tree.nodeCollapsed, 'emit').and.callThrough(); + const collapsedSpy = spyOn(tree.nodeExpanded, 'emit').and.callThrough(); + // should not emit event when nodes are toggled through input + tree.rootNodes[0].expanded = true; + fix.detectChanges(); + tree.rootNodes.forEach(n => { + expect(n.expandedChange.emit).toHaveBeenCalled(); + }); + expect(expandingSpy).not.toHaveBeenCalled(); + expect(collapsingSpy).not.toHaveBeenCalled(); + expect(expandedSpy).not.toHaveBeenCalled(); + expect(collapsedSpy).not.toHaveBeenCalled(); + expect(tree.nodes.filter(n => n.expanded).length).toBe(1); + const expandedArgs = { + node: tree.rootNodes[1], + owner: tree + }; + const collapsedArgs = { + node: tree.rootNodes[0], + owner: tree + }; + tree.rootNodes[1].expand(); + tick(); + fix.detectChanges(); + expect(expandingSpy).toHaveBeenCalledTimes(1); + expect(expandingSpy).toHaveBeenCalledWith(Object.assign({}, expandedArgs, { cancel: false })); + expect(collapsingSpy).toHaveBeenCalledTimes(1); + expect(expandingSpy).toHaveBeenCalledWith(Object.assign({}, collapsedArgs, { cancel: false })); + expect(expandedSpy).toHaveBeenCalledTimes(1); + expect(expandedSpy).toHaveBeenCalledWith(expandedArgs); + expect(collapsedSpy).toHaveBeenCalledTimes(1); + expect(collapsedSpy).toHaveBeenCalledWith(collapsedArgs); + tree.singleBranchExpand = false; + fix.detectChanges(); + const deepNode = tree.findNodes('2-1', (_id: '2-1', n: IgxTreeNodeComponent) => n.data.id === '2-1')[0]; + expect(deepNode).not.toBeNull(); + fix.componentInstance.expandToNode(deepNode); + const siblingNodes = tree.findNodes(deepNode, + (tn: IgxTreeNodeComponent, n: IgxTreeNodeComponent) => n.level === tn.level && n.parentNode === tn.parentNode + ); + expect(siblingNodes.length).toBe(5); + siblingNodes.forEach(n => n.expanded = true); + fix.detectChanges(); + expect(tree.nodes.filter(e => e.expanded).length).toBe(7); + siblingNodes[0].expanded = false; + fix.detectChanges(); + expect(tree.nodes.filter(e => e.expanded).length).toBe(6); + tree.singleBranchExpand = true; + siblingNodes[0].expanded = true; + fix.detectChanges(); + expect(tree.nodes.filter(e => e.expanded).length).toBe(3); + const nodeLevels = tree.nodes.filter(n => n.expanded).map(n => n.level); + expect(nodeLevels).toEqual([0, 1, 2]); + })); + }); + describe('ARIA', () => { + it('Should render proper roles for tree and nodes', () => { + pending('Test not implemented'); + }); + it('Should render proper label for expand/collapse indicator, depending on node state', () => { + pending('Test not implemented'); + + }); + it('Should render proper roles for nodes containing link children', () => { + pending('Test not implemented'); + }); + }); + }); +}); +@Component({ + template: ` + + @for (node of data; track node.id) { + + {{ node.label }} + @for (child of node.children; track child.id) { + + {{ child.label }} + @for (leafChild of child.children; track leafChild.id) { + + {{ leafChild.label }} + + } + + } + + } + @if (divChild) { +
    + } +
    + `, + imports: [IgxTreeComponent, IgxTreeNodeComponent] +}) +class IgxTreeSampleComponent { + @ViewChild(IgxTreeComponent) + public tree: IgxTreeComponent; + + public divChild = true; + public data = createHierarchicalData(5, 3); + + public expandToNode(node: IgxTreeNodeComponent): void { + node.path.forEach(n => n.expanded = true); + } +} + +class MockDataItem { + public selected = false; + public expanded = false; + public children: MockDataItem[] = []; + constructor(public id: string, public label: string) { + } +} + +const createHierarchicalData = (siblings: number, depth: number): MockDataItem[] => { + let id = 0; + const returnArr = []; + for (let i = 0; i < siblings; i++) { + const item = new MockDataItem(`${depth}-${id}`, `Label ${depth}-${id}`); + id++; + returnArr.push(item); + if (depth > 0) { + item.children = createHierarchicalData(siblings, depth - 1); + } + } + return returnArr; +}; diff --git a/src/app/badge/badge.sample.html b/src/app/badge/badge.sample.html index 47e674f1fb3..5240855c490 100644 --- a/src/app/badge/badge.sample.html +++ b/src/app/badge/badge.sample.html @@ -1,21 +1,90 @@

    Angular Badge

    - -
    - - - bluetooth - +
    +
    + + With Value +
    +
    + + bluetooth + + With Icon +
    +
    + + + bluetooth + + On Avatar +
    +
    + +

    Dot Type Badges

    +
    +
    + + Primary Dot +
    +
    + + Info Dot +
    +
    + + Success Dot +
    +
    + + Warning Dot +
    +
    + + Error Dot +
    +
    + + + Dot on Avatar +
    +
    + +

    Type Variants

    +
    +
    + + Primary +
    +
    + + Info +
    +
    + + Success +
    +
    + + Warning +
    +
    + + Error +
    diff --git a/src/app/badge/badge.sample.scss b/src/app/badge/badge.sample.scss index 85f3261b048..126f27254d0 100644 --- a/src/app/badge/badge.sample.scss +++ b/src/app/badge/badge.sample.scss @@ -1,16 +1,54 @@ +@use '../../../projects/igniteui-angular/src/lib/core/styles/themes/utilities' as *; + .wrapper { display: grid; - grid-template-columns: repeat(2, 1fr); + gap: rem(48px); + padding: rem(24px); } .badges { - place-items: center; - display: grid; - grid-template-columns: subgrid; + place-items: start; + display: flex; + flex-direction: column; gap: 24px; } +.badge-examples { + display: flex; + flex-wrap: wrap; + gap: rem(32px); + align-items: center; + padding: rem(16px); + background: color($color: 'gray', $variant: 100); + border-radius: rem(8px); +} + +.badge-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + position: relative; + + span { + font-size: 14px; + } +} + .avatar-sample { display: flex; align-items: center; + position: relative; + + igx-avatar { + anchor-name: --avatar; + } + + igx-badge { + position: absolute; + position-anchor: --avatar; + bottom: anchor(--avatar top); + left: anchor(right); + transform: translate(-75%, 75%); + } } diff --git a/src/app/badge/badge.sample.ts b/src/app/badge/badge.sample.ts index f8bcb45c90a..0e784d41fea 100644 --- a/src/app/badge/badge.sample.ts +++ b/src/app/badge/badge.sample.ts @@ -3,6 +3,7 @@ import { IgxBadgeComponent, IgxAvatarComponent, IgxIconComponent, + IgSizeDirective } from 'igniteui-angular'; import { defineComponents, @@ -32,11 +33,18 @@ registerIconFromText('bluetooth', bluetooth); styleUrls: ['badge.sample.scss'], templateUrl: 'badge.sample.html', schemas: [CUSTOM_ELEMENTS_SCHEMA], - imports: [IgxBadgeComponent, IgxAvatarComponent, IgxIconComponent] + imports: [IgxBadgeComponent, IgxAvatarComponent, IgxIconComponent, IgSizeDirective] }) export class BadgeSampleComponent { public panelConfig: PropertyPanelConfig = { + size: { + control: { + type: 'button-group', + options: ['small', 'medium', 'large'], + defaultValue: 'medium' + } + }, shape: { control: { type: 'button-group', diff --git a/src/app/calendar/calendar.sample.html b/src/app/calendar/calendar.sample.html index eb41b90a0d9..b381413e398 100644 --- a/src/app/calendar/calendar.sample.html +++ b/src/app/calendar/calendar.sample.html @@ -47,10 +47,22 @@
    + + + + Currently disabled + selected is not possible combination! + + + @for (option of ['narrow', 'short', 'long']; track option) { diff --git a/src/app/calendar/calendar.sample.ts b/src/app/calendar/calendar.sample.ts index 6d4827504a6..b24ae47380c 100644 --- a/src/app/calendar/calendar.sample.ts +++ b/src/app/calendar/calendar.sample.ts @@ -141,7 +141,7 @@ export class CalendarSampleComponent implements OnInit { label: 'Show Week Numbers', control: { type: 'boolean', - defaultValue: false + defaultValue: true } }, monthsViewNumber: { @@ -235,28 +235,45 @@ export class CalendarSampleComponent implements OnInit { }; } - protected disabledDates = [ - { - type: DateRangeType.Specific, - dateRange: [ - new Date(this._today.getFullYear(), this._today.getMonth(), 0), - new Date(this._today.getFullYear(), this._today.getMonth(), 20), - new Date(this._today.getFullYear(), this._today.getMonth(), 21), - ], - }, - ]; + // DISABLED DATES + private _disabledRange: DateRange = { + start: new Date(this._today.getFullYear(), this._today.getMonth(), 15), + end: new Date(this._today.getFullYear(), this._today.getMonth(), 17), + }; + + protected set disabledRange(value: DateRange) { + this.disabledDates = value; + this._disabledRange = value; + } + + protected get disabledRange(): DateRange { + return this._disabledRange; + } - protected mySpecialDates = [ + private _disabledDates: DateRangeDescriptor[] = [ { - type: DateRangeType.Specific, + type: DateRangeType.Between, dateRange: [ - new Date(this._today.getFullYear(), this._today.getMonth(), 1), - new Date(this._today.getFullYear(), this._today.getMonth(), 3), - new Date(this._today.getFullYear(), this._today.getMonth(), 7), + this.disabledRange.start as Date, + this.disabledRange.end as Date, ], }, ]; + protected get disabledDates(): DateRangeDescriptor[] { + return this._disabledDates; + } + + protected set disabledDates(dates: DateRange) { + this._disabledDates = [ + { + type: DateRangeType.Between, + dateRange: [dates.start as Date, dates.end as Date] + } + ]; + } + + // SPECIAL DATES private _specialRange: DateRange = { start: new Date(this._today.getFullYear(), this._today.getMonth(), 8), end: new Date(this._today.getFullYear(), this._today.getMonth(), 10), diff --git a/src/app/chat/chat.sample.html b/src/app/chat/chat.sample.html new file mode 100644 index 00000000000..2e278ad1b91 --- /dev/null +++ b/src/app/chat/chat.sample.html @@ -0,0 +1,8 @@ + + Prefix + Actions + + + +
    +
    diff --git a/src/app/chat/chat.sample.scss b/src/app/chat/chat.sample.scss new file mode 100644 index 00000000000..87a84a0fd2f --- /dev/null +++ b/src/app/chat/chat.sample.scss @@ -0,0 +1,4 @@ +#igniteui-demo-app .sample-wrapper { + padding: 0; +} + diff --git a/src/app/chat/chat.sample.ts b/src/app/chat/chat.sample.ts new file mode 100644 index 00000000000..d4d9058fa34 --- /dev/null +++ b/src/app/chat/chat.sample.ts @@ -0,0 +1,115 @@ + +import { AsyncPipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + CUSTOM_ELEMENTS_SCHEMA, + effect, + signal, + viewChild, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { + IgxChatComponent, + IgxChatMessageContextDirective, + type IgxChatOptions, +} from 'igniteui-angular/chat'; +import { MarkdownPipe } from 'igniteui-angular/chat-extras'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'app-chat-sample', + styleUrls: ['chat.sample.scss'], + templateUrl: 'chat.sample.html', + schemas: [CUSTOM_ELEMENTS_SCHEMA], + imports: [ + FormsModule, + AsyncPipe, + IgxChatComponent, + MarkdownPipe, + IgxChatMessageContextDirective, + ] +}) +export class ChatSampleComponent { + protected _template = viewChild.required('renderer'); + + public messages = signal([ + { + id: '1', + text: `Hello. How can we assist you today?`, + sender: 'support', + }, + { + id: '2', + text: `Hello. I have problem with styling IgcAvatarComponent. Can you take a look at the attached file and help me?`, + sender: 'user', + attachments: [ + { + id: 'AvatarStyles.css', + name: 'AvatarStyles.css', + url: './styles/AvatarStyles.css', + type: 'text/css' + }, + ], + }, + { + id: '3', + text: `Sure, give me a moment to check the file.`, + sender: 'support', + }, + { + id: '4', + text: ` +Thank you for your patience. It seems that the issue is the name of the **CSS part**. Here is the fixed code: + + +\`\`\`css +igc-avatar::part(base) { + --size: 60px; + color: var(--ig-success-500-contrast); + background: var(--ig-success-500); + border-radius: 20px; +} +\`\`\``, + sender: 'support', + }, + { + id: '123213123', + sender: 'support', + text: ` +Here is some typescript: + + +\`\`\`ts + +class User { + constructor(public name: string, public age: number) {} +} +\`\`\`` + } + ]); + + public options = signal({ + disableAutoScroll: false, + disableInputAttachments: false, + suggestions: [`It works. Thanks.`, `It doesn't work.`], + inputPlaceholder: 'Type your message here...', + headerText: 'Customer Support', + }); + + + public templates = signal({}); + + constructor() { + effect(() => { + const template = this._template(); + if (template) { + this.templates.set({ messageContent: template }); + } + }); + } + + public onMessageReact(event: any) { + console.log(event); + } +} diff --git a/src/app/date-picker/date-picker.sample.html b/src/app/date-picker/date-picker.sample.html index ded04c6a749..4d58e0c8498 100644 --- a/src/app/date-picker/date-picker.sample.html +++ b/src/app/date-picker/date-picker.sample.html @@ -1,35 +1,37 @@
    Angular Date Picker
    - - - - alarm - - - - - - - +
    + + + + alarm + + Helper text + + + + + + +
    WC Date Picker
    WC Date Picker [displayFormat]="properties.displayFormat" [inputFormat]="properties.inputFormat" [required]="properties.required" + [readOnly]="properties.readonly" [disabled]="properties.disabled" [placeholder]="properties.placeholder" show-week-numbers="true" diff --git a/src/app/date-picker/date-picker.sample.ts b/src/app/date-picker/date-picker.sample.ts index fcc1d9042ed..e7b5413497c 100644 --- a/src/app/date-picker/date-picker.sample.ts +++ b/src/app/date-picker/date-picker.sample.ts @@ -1,4 +1,5 @@ -import { Component, CUSTOM_ELEMENTS_SCHEMA, DestroyRef } from '@angular/core'; +import { Component, CUSTOM_ELEMENTS_SCHEMA, DestroyRef, inject } from '@angular/core'; +import { ReactiveFormsModule, UntypedFormBuilder, Validators } from '@angular/forms'; import { IGX_DATE_PICKER_DIRECTIVES, IgxButtonDirective, @@ -39,9 +40,14 @@ registerIconFromText('alarm', alarm); IgxSuffixDirective, IgxIconComponent, IgSizeDirective, + ReactiveFormsModule, ], }) export class DatePickerSampleComponent { + private fb = inject(UntypedFormBuilder); + private propertyChangeService = inject(PropertyChangeService); + private destroyRef = inject(DestroyRef); + public date1 = new Date(); public date2 = new Date( new Date( @@ -155,26 +161,80 @@ export class DatePickerSampleComponent { type: 'text' } } - } + }; - public properties: Properties; + public properties: Properties = Object.fromEntries( + Object.keys(this.panelConfig).map((key) => { + const control = this.panelConfig[key]?.control; + return [key, control?.defaultValue]; + }) + ) as Properties; - constructor( - private propertyChangeService: PropertyChangeService, - private destroyRef: DestroyRef - ) { + // FormControl owns the date picker value + public reactiveForm = this.fb.group({ + datePicker: [null], + }); + + constructor() { this.propertyChangeService.setPanelConfig(this.panelConfig); const propertyChange = this.propertyChangeService.propertyChanges.subscribe( (properties) => { this.properties = properties; + this.syncFormControlFromProperties(); } ); this.destroyRef.onDestroy(() => propertyChange.unsubscribe()); } + /** + * Syncs the reactive form control with the properties panel: + * - programmatic value updates + * - required validator + * - disabled state + * + * All done in a way that does NOT mark the control dirty/touched. + */ + private syncFormControlFromProperties(): void { + const control = this.reactiveForm.get('datePicker'); + if (!control) { + return; + } + + // 1) Programmatic value update (from properties.value) + // This does NOT mark the control dirty/touched. + if ('value' in this.properties) { + const newValue = this.properties.value ?? null; + const currentValue = control.value; + + // Shallow equality check to avoid unnecessary writes + const sameValue = + (newValue === currentValue) || + (newValue instanceof Date && + currentValue instanceof Date && + newValue.getTime() === currentValue.getTime()); + + if (!sameValue) { + control.setValue(newValue, { emitEvent: false }); + } + } + + // 2) Required validator + control.setValidators(this.properties?.required ? Validators.required : null); + // This will trigger statusChanges, but control is still pristine/untouched, + // so IgxDatePicker will keep the visual state INITIAL until user interaction. + control.updateValueAndValidity(); + + // 3) Disabled state + if (this.properties?.disabled) { + control.disable({ emitEvent: false }); + } else { + control.enable({ emitEvent: false }); + } + } + protected get modeAngular() { const modeValue = this.propertyChangeService.getProperty('mode'); return modeValue === 'dropdown' @@ -182,7 +242,7 @@ export class DatePickerSampleComponent { : PickerInteractionMode.Dialog; } - protected selectToday(picker) { + protected selectToday(picker: { value: Date; hide: () => void }) { picker.value = new Date(); picker.hide(); } diff --git a/src/app/date-range/date-range.sample.html b/src/app/date-range/date-range.sample.html index 550e770ec48..123c51f4357 100644 --- a/src/app/date-range/date-range.sample.html +++ b/src/app/date-range/date-range.sample.html @@ -46,6 +46,9 @@
    Angular Date Range Picker, two inputs, template-driven form
    type="text" [placeholder]="properties.placeholder" /> + @if (properties.hint) { + {{ properties.hint }} + } @@ -61,6 +64,7 @@
    Angular Date Range Picker, two inputs, template-driven form
    type="text" [placeholder]="properties.placeholder" /> + Helper text
    diff --git a/src/app/date-range/date-range.sample.ts b/src/app/date-range/date-range.sample.ts index 17a6c357b24..d9dc33bd735 100644 --- a/src/app/date-range/date-range.sample.ts +++ b/src/app/date-range/date-range.sample.ts @@ -150,6 +150,12 @@ export class DateRangeSampleComponent { defaultValue: 'MM/dd/yyyy' } }, + hint: { + label: 'Hint Text', + control: { + type: 'text' + } + }, displayFormat: { label: 'Display Format', control: { diff --git a/src/app/grid-pdf-export/grid-pdf-export.sample.html b/src/app/grid-pdf-export/grid-pdf-export.sample.html new file mode 100644 index 00000000000..4726a8516b4 --- /dev/null +++ b/src/app/grid-pdf-export/grid-pdf-export.sample.html @@ -0,0 +1,160 @@ +
    +

    PDF Export Service Demo

    +

    + This demo shows how to use the IgxPdfExporterService API directly to export grids to PDF format. + Configure the export options using the controls below and click the export buttons. +

    + + +
    +

    Export Options Configuration

    + +
    + + + + +
    + +
    + + + Portrait + Landscape + +
    + +
    + + + @for (size of pageSizes; track size) { + {{ size.toUpperCase() }} + } + +
    + +
    + + + + +
    + +
    + Show Table Borders +
    +
    + + +
    +
    +

    Regular Grid (with Multi-Column Headers and Summaries)

    + +
    + + + + + + + + + + + + + + + + +
    + + +
    +
    +

    Tree Grid (with Hierarchy and Summaries)

    + +
    + + + + + + +
    + + +
    +
    +

    Hierarchical Grid (with Multi-Column Headers and Summaries)

    + +
    + + + + + + + + + + + + + + + + + + + + +
    + + +
    +
    +

    Pivot Grid (with Aggregated Data)

    + +
    + +
    + + + +
    +
    + +
    +

    How to Use:

    +
      +
    1. Configure the export options using the controls at the top
    2. +
    3. Click any of the "Export to PDF" buttons to export the corresponding grid
    4. +
    5. The PDF will be downloaded with your configured settings
    6. +
    7. Tree and Hierarchical grids will show indentation in the exported PDF
    8. +
    9. Multi-column headers and summaries are automatically included in the export
    10. +
    11. Pivot grids export with aggregated data values
    12. +
    + +

    Key Features:

    +
      +
    • Direct API Usage: Uses IgxPdfExporterService.export() method directly
    • +
    • Configurable Options: Adjust orientation, page size, font size, and borders
    • +
    • Hierarchy Support: Tree and Hierarchical grids export with proper indentation
    • +
    • Multi-Column Headers: Column groups are preserved in the exported PDF
    • +
    • Summaries Support: Grid summaries are automatically exported to PDF
    • +
    • Pivot Grid Support: Pivot grids export with aggregated data and pivot structure
    • +
    • Multiple Grids: Export different grid types with the same configuration
    • +
    +
    +
    diff --git a/src/app/grid-pdf-export/grid-pdf-export.sample.scss b/src/app/grid-pdf-export/grid-pdf-export.sample.scss new file mode 100644 index 00000000000..b7b84f0356e --- /dev/null +++ b/src/app/grid-pdf-export/grid-pdf-export.sample.scss @@ -0,0 +1,120 @@ +.sample-wrapper { + padding: 20px; + max-width: 1400px; + margin: 0 auto; +} + +.sample-title { + color: #333; + margin-bottom: 10px; +} + +.sample-description { + color: #666; + margin-bottom: 30px; + line-height: 1.6; +} + +.config-panel { + background: #f5f5f5; + border-radius: 8px; + padding: 20px; + margin-bottom: 30px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + + h3 { + margin-top: 0; + margin-bottom: 20px; + color: #333; + } +} + +.config-row { + margin-bottom: 15px; + display: inline-block; + margin-right: 20px; + min-width: 200px; + + igx-input-group { + width: 100%; + } +} + +.grid-container { + margin-bottom: 40px; + border: 1px solid #e0e0e0; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); +} + +.grid-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px 20px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + + h3 { + margin: 0; + font-size: 18px; + } + + button { + background: white; + color: #667eea; + font-weight: 500; + + &:hover { + background: #f0f0f0; + } + } +} + +.info-section { + background: #e8f4f8; + border-left: 4px solid #2196F3; + padding: 20px; + border-radius: 4px; + margin-top: 30px; + + h4 { + color: #1976D2; + margin-top: 0; + margin-bottom: 15px; + } + + ol, ul { + line-height: 1.8; + color: #555; + + li { + margin-bottom: 8px; + } + } + + strong { + color: #1976D2; + } +} + +// Responsive design +@media (max-width: 768px) { + .config-row { + display: block; + width: 100%; + margin-right: 0; + min-width: unset; + } + + .grid-header { + flex-direction: column; + gap: 10px; + align-items: flex-start; + + button { + width: 100%; + } + } +} diff --git a/src/app/grid-pdf-export/grid-pdf-export.sample.ts b/src/app/grid-pdf-export/grid-pdf-export.sample.ts new file mode 100644 index 00000000000..410ca32f6e6 --- /dev/null +++ b/src/app/grid-pdf-export/grid-pdf-export.sample.ts @@ -0,0 +1,195 @@ +import { Component, ViewChild, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { + IgxGridComponent, + IgxColumnComponent, + IgxPdfExporterService, + IgxPdfExporterOptions, + IgxTreeGridComponent, + IgxHierarchicalGridComponent, + IgxRowIslandComponent, + IgxButtonDirective, + IgxSwitchComponent, + IgxSelectComponent, + IgxSelectItemComponent, + IgxInputGroupComponent, + IgxLabelDirective, + IgxInputDirective, + IgxColumnGroupComponent, + IgxPivotGridComponent, + IgxPivotDataSelectorComponent +} from 'igniteui-angular'; + +@Component({ + selector: 'app-grid-pdf-export-sample', + templateUrl: 'grid-pdf-export.sample.html', + styleUrls: ['grid-pdf-export.sample.scss'], + imports: [ + IgxGridComponent, + IgxColumnComponent, + IgxColumnGroupComponent, + IgxTreeGridComponent, + IgxHierarchicalGridComponent, + IgxPivotGridComponent, + IgxPivotDataSelectorComponent, + IgxRowIslandComponent, + IgxButtonDirective, + IgxSwitchComponent, + IgxSelectComponent, + IgxSelectItemComponent, + IgxInputGroupComponent, + IgxLabelDirective, + IgxInputDirective, + FormsModule + ], + providers: [IgxPdfExporterService] +}) +export class GridPdfExportSampleComponent { + private pdfExporter = inject(IgxPdfExporterService); + + @ViewChild('grid1', { static: true }) + public grid1: IgxGridComponent; + + @ViewChild('treeGrid', { static: true }) + public treeGrid: IgxTreeGridComponent; + + @ViewChild('hierarchicalGrid', { static: true }) + public hierarchicalGrid: IgxHierarchicalGridComponent; + + @ViewChild('pivotGrid', { static: true }) + public pivotGrid: IgxPivotGridComponent; + + // Grid data + public gridData = [ + { ID: 1, Name: 'Product A', Category: 'Electronics', Price: 299.99, InStock: true, LaunchDate: new Date(2023, 0, 15) }, + { ID: 2, Name: 'Product B', Category: 'Clothing', Price: 49.99, InStock: true, LaunchDate: new Date(2023, 1, 20) }, + { ID: 3, Name: 'Product C', Category: 'Electronics', Price: 599.99, InStock: false, LaunchDate: new Date(2023, 2, 10) }, + { ID: 4, Name: 'Product D', Category: 'Books', Price: 19.99, InStock: true, LaunchDate: new Date(2023, 3, 5) }, + { ID: 5, Name: 'Product E', Category: 'Clothing', Price: 79.99, InStock: true, LaunchDate: new Date(2023, 4, 12) }, + { ID: 6, Name: 'Product F', Category: 'Electronics', Price: 899.99, InStock: false, LaunchDate: new Date(2023, 5, 8) }, + { ID: 7, Name: 'Product G', Category: 'Books', Price: 24.99, InStock: true, LaunchDate: new Date(2023, 6, 22) }, + { ID: 8, Name: 'Product H', Category: 'Clothing', Price: 39.99, InStock: true, LaunchDate: new Date(2023, 7, 18) }, + { ID: 9, Name: 'Product I', Category: 'Electronics', Price: 1299.99, InStock: true, LaunchDate: new Date(2023, 8, 5) }, + { ID: 10, Name: 'Product J', Category: 'Books', Price: 34.99, InStock: true, LaunchDate: new Date(2023, 9, 14) }, + { ID: 11, Name: 'Product K', Category: 'Clothing', Price: 89.99, InStock: false, LaunchDate: new Date(2023, 10, 3) }, + { ID: 12, Name: 'Product L', Category: 'Electronics', Price: 449.99, InStock: true, LaunchDate: new Date(2023, 11, 1) } + ]; + + // Tree Grid data + public treeGridData = [ + { ID: 1, ParentID: -1, Name: 'Electronics', Budget: 5000 }, + { ID: 2, ParentID: 1, Name: 'Laptops', Budget: 2000 }, + { ID: 3, ParentID: 1, Name: 'Phones', Budget: 1500 }, + { ID: 4, ParentID: 1, Name: 'Tablets', Budget: 1500 }, + { ID: 5, ParentID: -1, Name: 'Furniture', Budget: 3000 }, + { ID: 6, ParentID: 5, Name: 'Chairs', Budget: 800 }, + { ID: 7, ParentID: 5, Name: 'Desks', Budget: 1200 }, + { ID: 8, ParentID: 5, Name: 'Cabinets', Budget: 1000 }, + { ID: 9, ParentID: -1, Name: 'Office Supplies', Budget: 2500 }, + { ID: 10, ParentID: 9, Name: 'Paper Products', Budget: 600 }, + { ID: 11, ParentID: 9, Name: 'Writing Instruments', Budget: 400 }, + { ID: 12, ParentID: 9, Name: 'Storage Solutions', Budget: 1500 } + ]; + + // Hierarchical Grid data + public hierarchicalGridData = [ + { + ID: 1, + CompanyName: 'Company A', + Revenue: 1000000, + Employees: [ + { ID: 1, Name: 'John Doe', Position: 'Manager', Salary: 80000 }, + { ID: 2, Name: 'Jane Smith', Position: 'Developer', Salary: 70000 }, + { ID: 3, Name: 'Mike Wilson', Position: 'Developer', Salary: 72000 } + ] + }, + { + ID: 2, + CompanyName: 'Company B', + Revenue: 2000000, + Employees: [ + { ID: 4, Name: 'Bob Johnson', Position: 'CEO', Salary: 150000 }, + { ID: 5, Name: 'Alice Brown', Position: 'Designer', Salary: 65000 }, + { ID: 6, Name: 'Carol Davis', Position: 'Developer', Salary: 75000 } + ] + }, + { + ID: 3, + CompanyName: 'Company C', + Revenue: 1500000, + Employees: [ + { ID: 7, Name: 'David Lee', Position: 'Manager', Salary: 85000 }, + { ID: 8, Name: 'Emma Taylor', Position: 'Analyst', Salary: 68000 }, + { ID: 9, Name: 'Frank Martinez', Position: 'Developer', Salary: 73000 }, + { ID: 10, Name: 'Grace Anderson', Position: 'Designer', Salary: 67000 } + ] + } + ]; + + public pivotGridData = [ + { + ProductCategory: 'Clothing', UnitPrice: 12.81, SellerName: 'Stanley', + Country: 'Bulgaria', City: 'Sofia', Date: '01/01/2021', UnitsSold: 282 + }, + { + ProductCategory: 'Clothing', UnitPrice: 49.57, SellerName: 'Elisa', + Country: 'USA', City: 'New York', Date: '01/05/2019', UnitsSold: 296 + }, + { + ProductCategory: 'Bikes', UnitPrice: 3.56, SellerName: 'Lydia', + Country: 'Uruguay', City: 'Ciudad de la Costa', Date: '01/06/2020', UnitsSold: 68 + }, + { + ProductCategory: 'Accessories', UnitPrice: 85.58, SellerName: 'David', + Country: 'USA', City: 'New York', Date: '04/07/2021', UnitsSold: 293 + }, + { + ProductCategory: 'Components', UnitPrice: 18.13, SellerName: 'John', + Country: 'USA', City: 'New York', Date: '12/08/2021', UnitsSold: 240 + }, + { + ProductCategory: 'Clothing', UnitPrice: 68.33, SellerName: 'Larry', + Country: 'Uruguay', City: 'Ciudad de la Costa', Date: '05/12/2020', UnitsSold: 456 + } + ]; + + // Export options + public fileName = 'GridExport'; + public pageOrientation: 'portrait' | 'landscape' = 'landscape'; + public pageSize = 'a4'; + public showTableBorders = true; + public fontSize = 10; + public pageSizes = ['a3', 'a4', 'a5', 'letter', 'legal']; + + public exportGrid() { + const options = this.createExportOptions(); + this.pdfExporter.export(this.grid1, options); + } + + public exportTreeGrid() { + const options = this.createExportOptions(); + options.fileName = `TreeGrid_${this.fileName}`; + this.pdfExporter.export(this.treeGrid, options); + } + + public exportHierarchicalGrid() { + const options = this.createExportOptions(); + options.fileName = `HierarchicalGrid_${this.fileName}`; + this.pdfExporter.export(this.hierarchicalGrid, options); + } + + public exportPivotGrid() { + const options = this.createExportOptions(); + options.fileName = `PivotGrid_${this.fileName}`; + this.pdfExporter.export(this.pivotGrid, options); + } + + private createExportOptions(): IgxPdfExporterOptions { + const options = new IgxPdfExporterOptions(this.fileName); + options.pageOrientation = this.pageOrientation; + options.pageSize = this.pageSize; + options.showTableBorders = this.showTableBorders; + options.fontSize = this.fontSize; + return options; + } +} diff --git a/src/app/input-group-showcase/input-group-showcase.sample.scss b/src/app/input-group-showcase/input-group-showcase.sample.scss index e8a7ddbeccb..c201af3218d 100644 --- a/src/app/input-group-showcase/input-group-showcase.sample.scss +++ b/src/app/input-group-showcase/input-group-showcase.sample.scss @@ -53,6 +53,7 @@ display: flex; flex-direction: column; gap: 2rem; + min-width: 0; } .showcase__placeholder { diff --git a/src/app/list/list.sample.html b/src/app/list/list.sample.html index a1e71892699..962167f9908 100644 --- a/src/app/list/list.sample.html +++ b/src/app/list/list.sample.html @@ -55,7 +55,7 @@
    WC List
    Employees List @for(employee of employeeItems; track employee){ - + @if(properties.addAvatarThumbnail) { } @@ -69,7 +69,7 @@
    WC List
    {{employee.position}} } @if(properties.addCheckboxAction) { - + } @if(properties.addIconAction) { diff --git a/src/app/tabs-showcase/tabs-showcase.sample.html b/src/app/tabs-showcase/tabs-showcase.sample.html index f90f445539c..bd9df358a8a 100644 --- a/src/app/tabs-showcase/tabs-showcase.sample.html +++ b/src/app/tabs-showcase/tabs-showcase.sample.html @@ -32,7 +32,7 @@ @for (contact of contacts; track contact.id) { @if(!properties.hideIcon) { - folder + } @if(!properties.hideText) { {{ contact.text }} diff --git a/src/app/tabs-showcase/tabs-showcase.sample.ts b/src/app/tabs-showcase/tabs-showcase.sample.ts index b271789cfb2..fcf308b106f 100644 --- a/src/app/tabs-showcase/tabs-showcase.sample.ts +++ b/src/app/tabs-showcase/tabs-showcase.sample.ts @@ -20,13 +20,20 @@ import { defineComponents, IgcTabsComponent, IgcTabComponent, + IgcIconComponent, + registerIconFromText } from 'igniteui-webcomponents'; import { PropertyChangeService, Properties, } from '../properties-panel/property-change.service'; -defineComponents(IgcTabsComponent, IgcTabComponent); +defineComponents(IgcTabsComponent, IgcTabComponent, IgcIconComponent); + +registerIconFromText( + 'folder', + '' +); @Component({ selector: 'app-tabs-showcase-sample', diff --git a/src/app/time-picker/time-picker.sample.html b/src/app/time-picker/time-picker.sample.html index 7da22ba5546..73994abe019 100644 --- a/src/app/time-picker/time-picker.sample.html +++ b/src/app/time-picker/time-picker.sample.html @@ -1,11 +1,57 @@
    +
    +

    Angular Time Picker with Reactive Form

    +
    +
    + + + Helper text + + +
    +

    Time Picker with Date value binding

    {{showDate(date)}}

    -
    - +
    + + @if (hasLabel) { + + } @if (hasPrefix) { + face + } @if (hasSuffix) { + face + } @if (hasHint) { + It's a hint + }
    @@ -77,4 +123,24 @@

    Time Picker with custom spin buttons

    + + +
    + + + + + + + + + + + + + + + +
    +
    diff --git a/src/app/time-picker/time-picker.sample.scss b/src/app/time-picker/time-picker.sample.scss index 7d984d7219f..a3231aaec7b 100644 --- a/src/app/time-picker/time-picker.sample.scss +++ b/src/app/time-picker/time-picker.sample.scss @@ -14,3 +14,15 @@ justify-content: center; padding: 8px; } + +.custom-controls { + igx-switch { + display: flex; + align-items: center; + margin-bottom: 16px; + + label { + margin: 0; + } + } +} diff --git a/src/app/time-picker/time-picker.sample.ts b/src/app/time-picker/time-picker.sample.ts index 8f94c56d4f8..6023e5906f0 100644 --- a/src/app/time-picker/time-picker.sample.ts +++ b/src/app/time-picker/time-picker.sample.ts @@ -1,10 +1,7 @@ -import { Component, ViewChild } from '@angular/core'; -import { FormsModule } from '@angular/forms'; +import { Component, DestroyRef, inject, TemplateRef, ViewChild, OnInit } from '@angular/core'; +import { FormsModule, ReactiveFormsModule, UntypedFormBuilder, Validators } from '@angular/forms'; import { IgxTimePickerComponent, - IgxInputDirective, - AutoPositionStrategy, - OverlaySettings, DatePart, IgxHintDirective, IgxButtonDirective, @@ -13,24 +10,52 @@ import { IgxPrefixDirective, IgxIconComponent, IgxPickerClearComponent, - IgxSuffixDirective + IgxSuffixDirective, + IgxLabelDirective, + IgSizeDirective, + PickerInteractionMode, + IgxSwitchComponent } from 'igniteui-angular'; +import { + PropertyPanelConfig, + PropertyChangeService, + Properties, +} from '../properties-panel/property-change.service'; @Component({ selector: 'app-time-picker-sample', styleUrls: ['time-picker.sample.scss'], templateUrl: 'time-picker.sample.html', - imports: [IgxTimePickerComponent, FormsModule, IgxHintDirective, IgxButtonDirective, IgxPickerActionsDirective, IgxPickerToggleComponent, IgxPrefixDirective, IgxIconComponent, IgxPickerClearComponent, IgxSuffixDirective] + imports: [ + IgxTimePickerComponent, + FormsModule, + ReactiveFormsModule, + IgxHintDirective, + IgxButtonDirective, + IgxPickerActionsDirective, + IgxPickerToggleComponent, + IgxPrefixDirective, + IgxIconComponent, + IgxPickerClearComponent, + IgxSuffixDirective, + IgxLabelDirective, + IgSizeDirective, + IgxSwitchComponent + ] }) -export class TimePickerSampleComponent { +export class TimePickerSampleComponent implements OnInit { @ViewChild('tp', { read: IgxTimePickerComponent, static: true }) public tp: IgxTimePickerComponent; - @ViewChild('target') - public target: IgxInputDirective; + @ViewChild('customControls', { static: true }) + public customControlsTemplate!: TemplateRef; + + public hasSuffix = false; + public hasPrefix = false; + public hasLabel = false; + public hasHint = false; public itemsDelta = { hours: 1, minutes: 15, seconds: 20 }; - public format = 'hh:mm:ss:SS a'; public spinLoop = true; public datePart = DatePart.Hours; @@ -41,19 +66,159 @@ export class TimePickerSampleComponent { public val = '08:30:00'; public today = new Date(Date.now()); - public isRequired = true; + public panelConfig: PropertyPanelConfig = { + size: { + control: { + type: 'button-group', + options: ['small', 'medium', 'large'], + defaultValue: 'medium', + } + }, + mode: { + control: { + type: 'button-group', + options: [ + { label: 'dialog', value: PickerInteractionMode.Dialog}, + { label: 'dropdown', value: PickerInteractionMode.DropDown} + ], + defaultValue: 'dropdown' + } + }, + type: { + control: { + type: 'button-group', + options: ['box', 'border', 'line'], + defaultValue: 'box' + } + }, + required: { + control: { + type: 'boolean', + defaultValue: false + } + }, + readonly: { + control: { + type: 'boolean', + defaultValue: false + } + }, + disabled: { + control: { + type: 'boolean', + defaultValue: false + } + }, + value: { + control: { + type: 'time' + } + }, + placeholder: { + control: { + type: 'text', + defaultValue: 'hh:mm' + } + }, + displayFormat: { + label: 'Display Format', + control: { + type: 'text' + } + }, + inputFormat: { + label: 'Input Format', + control: { + type: 'text' + } + } + } + + private fb = inject(UntypedFormBuilder); + private propertyChangeService = inject(PropertyChangeService); + private destroyRef = inject(DestroyRef); - public myOverlaySettings: OverlaySettings = { - modal: false, - closeOnOutsideClick: true, - positionStrategy: new AutoPositionStrategy() - }; + public properties: Properties = Object.fromEntries( + Object.keys(this.panelConfig).map((key) => { + const control = this.panelConfig[key]?.control; + return [key, control?.defaultValue]; + }) + ) as Properties; - public change() { - this.isRequired = !this.isRequired; + // FormControl owns the time picker value + public reactiveForm = this.fb.group({ + timePicker: [null], + }); + + constructor() { + this.propertyChangeService.setPanelConfig(this.panelConfig); + + const propertyChange = + this.propertyChangeService.propertyChanges.subscribe( + (properties) => { + this.properties = properties; + this.syncFormControlFromProperties(); + } + ); + + this.destroyRef.onDestroy(() => propertyChange.unsubscribe()); } - public valueChanged(event) { + public ngOnInit() { + this.propertyChangeService.setCustomControls( + this.customControlsTemplate + ); + } + + /** + * Syncs the reactive form control with the properties panel: + * - programmatic value updates + * - required validator + * - disabled state + * + * All done in a way that does NOT mark the control dirty/touched. + */ + private syncFormControlFromProperties(): void { + const control = this.reactiveForm.get('timePicker'); + if (!control) { + return; + } + + // 1) Programmatic value update (from properties.value) + // This does NOT mark the control dirty/touched. + if ('value' in this.properties) { + const newValue = this.properties.value ?? null; + const currentValue = control.value; + + // Shallow equality check to avoid unnecessary writes + const sameValue = + (newValue === currentValue) || + (newValue instanceof Date && + currentValue instanceof Date && + newValue.getTime() === currentValue.getTime()); + + if (!sameValue) { + control.setValue(newValue, { emitEvent: false }); + } + } + + // 2) Required validator - set without triggering validation + const currentValidators = control.validator; + const newValidators = this.properties?.required ? Validators.required : null; + + // Only update validators if they actually changed + if (currentValidators !== newValidators) { + control.setValidators(newValidators); + // Don't call updateValueAndValidity - let natural form lifecycle handle validation + } + + // 3) Disabled state + if (this.properties?.disabled) { + control.disable({ emitEvent: false }); + } else { + control.enable({ emitEvent: false }); + } + } public valueChanged(event) { console.log(event); } diff --git a/src/styles/_app-layout.scss b/src/styles/_app-layout.scss index b8f1cb11ada..4626008c618 100644 --- a/src/styles/_app-layout.scss +++ b/src/styles/_app-layout.scss @@ -81,7 +81,6 @@ &-title { @include type-style('h6'); - margin-bottom: 24px; .light { font-weight: 400;
    - @if (selection === 'multi') { - {{ monthsViewNumber && monthsViewNumber > 1 ? - resourceStrings.igx_calendar_multi_selection.replace('{0}', monthsViewNumber.toString()) : - resourceStrings.igx_calendar_singular_multi_selection}} - } - @if (selection === 'range') { - {{ monthsViewNumber && monthsViewNumber > 1 ? - resourceStrings.igx_calendar_range_selection.replace('{0}', monthsViewNumber.toString()) : - resourceStrings.igx_calendar_singular_range_selection}} - } - @if (selection === 'single') { - {{ monthsViewNumber && monthsViewNumber > 1 ? - resourceStrings.igx_calendar_single_selection.replace('{0}', monthsViewNumber.toString()) : - resourceStrings.igx_calendar_singular_single_selection}} +
    + @switch (selection) { + @case ('multi') { + {{ monthsViewNumber && monthsViewNumber > 1 ? + resourceStrings.igx_calendar_multi_selection.replace('{0}', monthsViewNumber.toString()) : + resourceStrings.igx_calendar_singular_multi_selection}} + } + @case ('range') { + {{ monthsViewNumber && monthsViewNumber > 1 ? + resourceStrings.igx_calendar_range_selection.replace('{0}', monthsViewNumber.toString()) : + resourceStrings.igx_calendar_singular_range_selection}} + } + @default { + {{ monthsViewNumber && monthsViewNumber > 1 ? + resourceStrings.igx_calendar_single_selection.replace('{0}', monthsViewNumber.toString()) : + resourceStrings.igx_calendar_singular_single_selection}} + } } -